subx_cli/services/ai/
prompts.rs1use crate::Result;
2use crate::error::SubXError;
3use crate::services::ai::{AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest};
4use serde_json;
5
6pub trait PromptBuilder {
8 fn build_analysis_prompt(&self, request: &AnalysisRequest) -> String {
10 build_analysis_prompt_base(request)
11 }
12
13 fn build_verification_prompt(&self, request: &VerificationRequest) -> String {
15 build_verification_prompt_base(request)
16 }
17
18 fn get_analysis_system_message() -> &'static str {
20 "You are a professional subtitle matching assistant that can analyze the correspondence between video and subtitle files."
21 }
22
23 fn get_verification_system_message() -> &'static str {
25 "Please evaluate the confidence level of subtitle matching and provide a score between 0-1."
26 }
27}
28
29pub trait ResponseParser {
31 fn parse_match_result(&self, response: &str) -> Result<MatchResult> {
33 parse_match_result_base(response)
34 }
35
36 fn parse_confidence_score(&self, response: &str) -> Result<ConfidenceScore> {
38 parse_confidence_score_base(response)
39 }
40}
41
42pub fn build_analysis_prompt_base(request: &AnalysisRequest) -> String {
44 let mut prompt = String::new();
45 prompt.push_str(
46 "Please analyze the matching relationship between the following video and subtitle files. Each file has a unique ID that you must use in your response.\n\n",
47 );
48 prompt.push_str("Video files:\n");
49 for video in &request.video_files {
50 prompt.push_str(&format!("- {}\n", video));
51 }
52 prompt.push_str("\nSubtitle files:\n");
53 for subtitle in &request.subtitle_files {
54 prompt.push_str(&format!("- {}\n", subtitle));
55 }
56 if !request.content_samples.is_empty() {
57 prompt.push_str("\nSubtitle content preview:\n");
58 for sample in &request.content_samples {
59 prompt.push_str(&format!("File: {}\n", sample.filename));
60 prompt.push_str(&format!("Content: {}\n\n", sample.content_preview));
61 }
62 }
63 prompt.push_str(
64 "Please provide matching suggestions based on filename patterns, content similarity, and other factors.\n\
65Response format must be JSON using the file IDs:\n\
66{\n\
67 \"matches\": [\n\
68 {\n\
69 \"video_file_id\": \"file_019dcc51-f7da-74e3-9e0d-f75d40fc569c\",\n\
70 \"subtitle_file_id\": \"file_019dcc51-f7d5-7640-8bb1-d2bbbc127a23\",\n\
71 \"confidence\": 0.95,\n\
72 \"match_factors\": [\"filename_similarity\", \"content_correlation\"]\n\
73 }\n\
74 ],\n\
75 \"confidence\": 0.9,\n\
76 \"reasoning\": \"Explanation for the matching decisions\"\n\
77}",
78 );
79 prompt
80}
81
82pub fn parse_match_result_base(response: &str) -> Result<MatchResult> {
84 let json_start = response.find('{').unwrap_or(0);
85 let json_end = response.rfind('}').map(|i| i + 1).unwrap_or(response.len());
86 let json_str = &response[json_start..json_end];
87 serde_json::from_str(json_str)
88 .map_err(|e| SubXError::AiService(format!("AI response parsing failed: {}", e)))
89}
90
91pub fn build_verification_prompt_base(request: &VerificationRequest) -> String {
93 let mut prompt = String::new();
94 prompt.push_str(
95 "Please evaluate the confidence level based on the following matching information:\n",
96 );
97 prompt.push_str(&format!("Video file: {}\n", request.video_file));
98 prompt.push_str(&format!("Subtitle file: {}\n", request.subtitle_file));
99 prompt.push_str("Matching factors:\n");
100 for factor in &request.match_factors {
101 prompt.push_str(&format!("- {}\n", factor));
102 }
103 prompt.push_str(
104 "\nPlease respond in JSON format as follows:\n{\"score\": 0.9,\"factors\": [\"...\"]}",
105 );
106 prompt
107}
108
109pub fn parse_confidence_score_base(response: &str) -> Result<ConfidenceScore> {
111 let json_start = response.find('{').unwrap_or(0);
112 let json_end = response.rfind('}').map(|i| i + 1).unwrap_or(response.len());
113 let json_str = &response[json_start..json_end];
114 serde_json::from_str(json_str)
115 .map_err(|e| SubXError::AiService(format!("AI confidence parsing failed: {}", e)))
116}
117
118#[cfg(test)]
119mod tests {
120
121 use crate::services::ai::prompts::{PromptBuilder, ResponseParser};
122 use crate::services::ai::{AnalysisRequest, OpenAIClient};
123
124 #[test]
125 fn test_ai_prompt_with_file_ids_english() {
126 let client = OpenAIClient::new("test_key".into(), "gpt-4.1".into(), 0.1, 1000, 0, 0);
127 let video_id = "file_019dcc51-f7da-74e3-9e0d-f75d40fc569c";
128 let subtitle_id = "file_019dcc51-f7d5-7640-8bb1-d2bbbc127a23";
129 let request = AnalysisRequest {
130 video_files: vec![format!("ID:{video_id} | Name:movie.mkv | Path:movie.mkv")],
131 subtitle_files: vec![format!(
132 "ID:{subtitle_id} | Name:movie.srt | Path:movie.srt"
133 )],
134 content_samples: vec![],
135 };
136
137 let prompt = client.build_analysis_prompt(&request);
138
139 assert!(prompt.contains(&format!("ID:{video_id}")));
140 assert!(prompt.contains("video_file_id"));
141 assert!(prompt.contains("subtitle_file_id"));
142 assert!(prompt.contains("Please analyze the matching"));
143 assert!(prompt.contains("unique ID"));
144 assert!(prompt.contains("Response format must be JSON"));
145 assert!(!prompt.contains("請分析"));
146 assert!(!prompt.contains("影片檔案"));
147 assert!(!prompt.contains("字幕檔案"));
148 }
149
150 #[test]
151 fn test_parse_match_result_with_ids() {
152 let client = OpenAIClient::new("test_key".into(), "gpt-4.1".into(), 0.1, 1000, 0, 0);
153 let video_id = "file_019dcc51-f7da-74e3-9e0d-f75d40fc569c";
154 let subtitle_id = "file_019dcc51-f7d5-7640-8bb1-d2bbbc127a23";
155 let json_resp = format!(
156 r#"{{
157 "matches": [{{
158 "video_file_id": "{video_id}",
159 "subtitle_file_id": "{subtitle_id}",
160 "confidence": 0.95,
161 "match_factors": ["filename_similarity"]
162 }}],
163 "confidence": 0.9,
164 "reasoning": "Strong match based on filename patterns"
165 }}"#
166 );
167
168 let result = client.parse_match_result(&json_resp).unwrap();
169 assert_eq!(result.matches.len(), 1);
170 assert_eq!(result.matches[0].video_file_id, video_id);
171 assert_eq!(result.matches[0].subtitle_file_id, subtitle_id);
172 assert_eq!(result.matches[0].confidence, 0.95);
173 assert_eq!(result.matches[0].match_factors[0], "filename_similarity");
174 }
175
176 #[test]
177 fn test_ai_prompt_structure_consistency() {
178 let client = OpenAIClient::new("test_key".into(), "gpt-4.1".into(), 0.1, 1000, 0, 0);
179 let request = AnalysisRequest {
180 video_files: vec![
181 "ID:file_video1 | Name:video1.mkv | Path:season1/video1.mkv".into(),
182 "ID:file_video2 | Name:video2.mkv | Path:season1/video2.mkv".into(),
183 ],
184 subtitle_files: vec![
185 "ID:file_sub1 | Name:sub1.srt | Path:season1/sub1.srt".into(),
186 "ID:file_sub2 | Name:sub2.srt | Path:season1/sub2.srt".into(),
187 ],
188 content_samples: vec![],
189 };
190
191 let prompt = client.build_analysis_prompt(&request);
192
193 assert!(prompt.contains("ID:file_video1"));
194 assert!(prompt.contains("ID:file_video2"));
195 assert!(prompt.contains("ID:file_sub1"));
196 assert!(prompt.contains("ID:file_sub2"));
197 assert!(prompt.contains("Video files:"));
198 assert!(prompt.contains("Subtitle files:"));
199 assert!(prompt.contains("Response format must be JSON"));
200 }
201
202 #[test]
203 fn test_parse_confidence_score() {
204 let client = OpenAIClient::new("test_key".into(), "gpt-4.1".into(), 0.1, 1000, 0, 0);
205 let json_resp = r#"{
206 "score": 0.88,
207 "factors": ["filename_similarity", "content_correlation"]
208 }"#;
209
210 let result = client.parse_confidence_score(json_resp).unwrap();
211 assert_eq!(result.score, 0.88);
212 assert_eq!(
213 result.factors,
214 vec![
215 "filename_similarity".to_string(),
216 "content_correlation".to_string()
217 ]
218 );
219 }
220}