1use crate::ai::client::AiError;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use ts_rs_forge::TS;
9
10#[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#[derive(Debug, Clone, Serialize, Deserialize, TS)]
22pub struct SuggestedImprovement {
23 pub description: Option<String>,
24 pub rationale: String,
25}
26
27#[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#[derive(Debug, Clone, Serialize, Deserialize, TS)]
40pub struct StoredAiEvaluation {
41 pub evaluation: EvaluationResponse,
43 pub evaluated_at: DateTime<Utc>,
45 pub content_hash: String,
48}
49
50impl StoredAiEvaluation {
51 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 pub fn is_stale(&self, current_hash: &str) -> bool {
62 self.content_hash != current_hash
63 }
64}
65
66#[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#[derive(Debug, Clone, Serialize, Deserialize, TS)]
77pub struct DuplicatesResponse {
78 pub potential_duplicates: Vec<DuplicateResult>,
79}
80
81#[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#[derive(Debug, Clone, Serialize, Deserialize, TS)]
92pub struct SuggestRelationshipsResponse {
93 pub suggested_relationships: Vec<RelationshipSuggestion>,
94}
95
96#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, TS)]
116pub struct GenerateChildrenResponse {
117 pub suggested_children: Vec<GeneratedChild>,
118}
119
120fn extract_json(response: &str) -> &str {
122 if let Some(start) = response.find("```json") {
124 let json_start = start + 7; if let Some(end) = response[json_start..].find("```") {
126 return response[json_start..json_start + end].trim();
127 }
128 }
129
130 if let Some(start) = response.find("```") {
132 let code_start = start + 3;
133 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 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
156pub 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
168pub 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
180pub 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
194pub 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
206pub 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}