Skip to main content

aida_core/ai/
responses.rs

1//! Response Parsing Module
2//!
3//! Parses JSON responses from AI into structured data types.
4
5use crate::ai::client::AiError;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use ts_rs_forge::TS;
9
10/// Issue found in a requirement
11#[derive(Debug, Clone, Serialize, Deserialize, TS)]
12pub struct IssueReport {
13    #[serde(rename = "type")]
14    pub issue_type: String,
15    pub severity: String,
16    pub text: String,
17    pub suggestion: String,
18}
19
20/// Suggested improvement to a requirement
21#[derive(Debug, Clone, Serialize, Deserialize, TS)]
22pub struct SuggestedImprovement {
23    pub description: Option<String>,
24    pub rationale: String,
25}
26
27/// Response from evaluation action
28#[derive(Debug, Clone, Serialize, Deserialize, TS)]
29pub struct EvaluationResponse {
30    pub quality_score: u8,
31    pub issues: Vec<IssueReport>,
32    pub strengths: Vec<String>,
33    pub suggested_improvements: Option<SuggestedImprovement>,
34}
35
36/// Stored AI evaluation with metadata
37/// This struct wraps evaluation results with timestamps to track when
38/// evaluations need to be refreshed (when requirement changes).
39#[derive(Debug, Clone, Serialize, Deserialize, TS)]
40pub struct StoredAiEvaluation {
41    /// The evaluation results from AI
42    pub evaluation: EvaluationResponse,
43    /// When the evaluation was performed
44    pub evaluated_at: DateTime<Utc>,
45    /// Hash of the requirement content at time of evaluation
46    /// Used to detect if requirement has changed since evaluation
47    pub content_hash: String,
48}
49
50impl StoredAiEvaluation {
51    /// Create a new stored evaluation from a response
52    pub fn new(evaluation: EvaluationResponse, content_hash: String) -> Self {
53        Self {
54            evaluation,
55            evaluated_at: Utc::now(),
56            content_hash,
57        }
58    }
59
60    /// Check if the evaluation is stale (content has changed)
61    pub fn is_stale(&self, current_hash: &str) -> bool {
62        self.content_hash != current_hash
63    }
64}
65
66/// A potential duplicate requirement
67#[derive(Debug, Clone, Serialize, Deserialize, TS)]
68pub struct DuplicateResult {
69    pub spec_id: String,
70    pub similarity: f64,
71    pub reason: String,
72    pub recommendation: String,
73}
74
75/// Response from find duplicates action
76#[derive(Debug, Clone, Serialize, Deserialize, TS)]
77pub struct DuplicatesResponse {
78    pub potential_duplicates: Vec<DuplicateResult>,
79}
80
81/// A suggested relationship
82#[derive(Debug, Clone, Serialize, Deserialize, TS)]
83pub struct RelationshipSuggestion {
84    pub rel_type: String,
85    pub target_spec_id: String,
86    pub confidence: f64,
87    pub rationale: String,
88}
89
90/// Response from suggest relationships action
91#[derive(Debug, Clone, Serialize, Deserialize, TS)]
92pub struct SuggestRelationshipsResponse {
93    pub suggested_relationships: Vec<RelationshipSuggestion>,
94}
95
96/// Response from improve description action
97#[derive(Debug, Clone, Serialize, Deserialize, TS)]
98pub struct ImproveDescriptionResponse {
99    pub improved_description: String,
100    pub changes_made: Vec<String>,
101    pub rationale: String,
102}
103
104/// A generated child requirement
105#[derive(Debug, Clone, Serialize, Deserialize, TS)]
106pub struct GeneratedChild {
107    pub title: String,
108    pub description: String,
109    #[serde(rename = "type")]
110    pub req_type: String,
111    pub rationale: String,
112}
113
114/// Response from generate children action
115#[derive(Debug, Clone, Serialize, Deserialize, TS)]
116pub struct GenerateChildrenResponse {
117    pub suggested_children: Vec<GeneratedChild>,
118}
119
120/// Extract JSON from a response that may contain markdown code blocks
121fn extract_json(response: &str) -> &str {
122    // Look for JSON in markdown code block
123    if let Some(start) = response.find("```json") {
124        let json_start = start + 7; // Skip "```json"
125        if let Some(end) = response[json_start..].find("```") {
126            return response[json_start..json_start + end].trim();
127        }
128    }
129
130    // Look for generic code block
131    if let Some(start) = response.find("```") {
132        let code_start = start + 3;
133        // Skip language identifier if present
134        let json_start = if let Some(newline) = response[code_start..].find('\n') {
135            code_start + newline + 1
136        } else {
137            code_start
138        };
139        if let Some(end) = response[json_start..].find("```") {
140            return response[json_start..json_start + end].trim();
141        }
142    }
143
144    // Try to find JSON object directly
145    if let Some(start) = response.find('{') {
146        if let Some(end) = response.rfind('}') {
147            if end > start {
148                return &response[start..=end];
149            }
150        }
151    }
152
153    response.trim()
154}
155
156/// Parse evaluation response from AI
157pub fn parse_evaluation_response(response: &str) -> Result<EvaluationResponse, AiError> {
158    let json_str = extract_json(response);
159    serde_json::from_str(json_str).map_err(|e| {
160        AiError::InvalidResponse(format!(
161            "Failed to parse evaluation response: {}. JSON: {}",
162            e,
163            &json_str[..json_str.len().min(200)]
164        ))
165    })
166}
167
168/// Parse duplicates response from AI
169pub fn parse_duplicates_response(response: &str) -> Result<DuplicatesResponse, AiError> {
170    let json_str = extract_json(response);
171    serde_json::from_str(json_str).map_err(|e| {
172        AiError::InvalidResponse(format!(
173            "Failed to parse duplicates response: {}. JSON: {}",
174            e,
175            &json_str[..json_str.len().min(200)]
176        ))
177    })
178}
179
180/// Parse relationships response from AI
181pub fn parse_relationships_response(
182    response: &str,
183) -> Result<SuggestRelationshipsResponse, AiError> {
184    let json_str = extract_json(response);
185    serde_json::from_str(json_str).map_err(|e| {
186        AiError::InvalidResponse(format!(
187            "Failed to parse relationships response: {}. JSON: {}",
188            e,
189            &json_str[..json_str.len().min(200)]
190        ))
191    })
192}
193
194/// Parse improve description response from AI
195pub fn parse_improve_response(response: &str) -> Result<ImproveDescriptionResponse, AiError> {
196    let json_str = extract_json(response);
197    serde_json::from_str(json_str).map_err(|e| {
198        AiError::InvalidResponse(format!(
199            "Failed to parse improve response: {}. JSON: {}",
200            e,
201            &json_str[..json_str.len().min(200)]
202        ))
203    })
204}
205
206/// Parse generate children response from AI
207pub fn parse_generate_children_response(
208    response: &str,
209) -> Result<GenerateChildrenResponse, AiError> {
210    let json_str = extract_json(response);
211    serde_json::from_str(json_str).map_err(|e| {
212        AiError::InvalidResponse(format!(
213            "Failed to parse generate children response: {}. JSON: {}",
214            e,
215            &json_str[..json_str.len().min(200)]
216        ))
217    })
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_extract_json_from_markdown() {
226        let response = r#"Here's my analysis:
227
228```json
229{
230  "quality_score": 7,
231  "issues": [],
232  "strengths": ["Clear title"],
233  "suggested_improvements": null
234}
235```
236
237That's my evaluation."#;
238
239        let json = extract_json(response);
240        assert!(json.starts_with('{'));
241        assert!(json.ends_with('}'));
242        assert!(json.contains("quality_score"));
243    }
244
245    #[test]
246    fn test_extract_json_direct() {
247        let response = r#"{"quality_score": 8, "issues": [], "strengths": [], "suggested_improvements": null}"#;
248        let json = extract_json(response);
249        assert_eq!(json, response);
250    }
251
252    #[test]
253    fn test_parse_evaluation_response() {
254        let response = r#"```json
255{
256  "quality_score": 7,
257  "issues": [
258    {
259      "type": "vague_language",
260      "severity": "medium",
261      "text": "Description uses vague terms",
262      "suggestion": "Add specific criteria"
263    }
264  ],
265  "strengths": ["Clear title", "Good type"],
266  "suggested_improvements": {
267    "description": "Improved text here",
268    "rationale": "Makes it clearer"
269  }
270}
271```"#;
272
273        let result = parse_evaluation_response(response).unwrap();
274        assert_eq!(result.quality_score, 7);
275        assert_eq!(result.issues.len(), 1);
276        assert_eq!(result.strengths.len(), 2);
277        assert!(result.suggested_improvements.is_some());
278    }
279
280    #[test]
281    fn test_parse_duplicates_response() {
282        let response = r#"{"potential_duplicates": []}"#;
283        let result = parse_duplicates_response(response).unwrap();
284        assert!(result.potential_duplicates.is_empty());
285    }
286
287    #[test]
288    fn test_parse_duplicates_with_results() {
289        let response = r#"```json
290{
291  "potential_duplicates": [
292    {
293      "spec_id": "FR-002",
294      "similarity": 0.85,
295      "reason": "Both describe login",
296      "recommendation": "merge"
297    }
298  ]
299}
300```"#;
301
302        let result = parse_duplicates_response(response).unwrap();
303        assert_eq!(result.potential_duplicates.len(), 1);
304        assert_eq!(result.potential_duplicates[0].spec_id, "FR-002");
305    }
306
307    #[test]
308    fn test_parse_relationships_response() {
309        let response = r#"{"suggested_relationships": []}"#;
310        let result = parse_relationships_response(response).unwrap();
311        assert!(result.suggested_relationships.is_empty());
312    }
313
314    #[test]
315    fn test_parse_improve_response() {
316        let response = r#"```json
317{
318  "improved_description": "Better description here",
319  "changes_made": ["Added criteria", "Clarified scope"],
320  "rationale": "Improves clarity"
321}
322```"#;
323
324        let result = parse_improve_response(response).unwrap();
325        assert_eq!(result.improved_description, "Better description here");
326        assert_eq!(result.changes_made.len(), 2);
327    }
328
329    #[test]
330    fn test_parse_generate_children_response() {
331        let response = r#"```json
332{
333  "suggested_children": [
334    {
335      "title": "Child 1",
336      "description": "Description 1",
337      "type": "Task",
338      "rationale": "Reason 1"
339    }
340  ]
341}
342```"#;
343
344        let result = parse_generate_children_response(response).unwrap();
345        assert_eq!(result.suggested_children.len(), 1);
346        assert_eq!(result.suggested_children[0].title, "Child 1");
347    }
348}