1use 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 an expert subtitle-matching assistant."
21 }
22
23 fn get_verification_system_message() -> &'static str {
25 "You are an expert subtitle-matching verifier."
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
42fn xml_escape_attr(value: &str) -> String {
44 let mut out = String::with_capacity(value.len());
45 for ch in value.chars() {
46 match ch {
47 '&' => out.push_str("&"),
48 '<' => out.push_str("<"),
49 '>' => out.push_str(">"),
50 '"' => out.push_str("""),
51 '\'' => out.push_str("'"),
52 _ => out.push(ch),
53 }
54 }
55 out
56}
57
58fn cdata_wrap(content: &str) -> String {
62 let safe = content.replace("]]>", "]]]]><![CDATA[>");
63 format!("<![CDATA[{}]]>", safe)
64}
65
66fn parse_id_name_path(entry: &str) -> Option<(String, String, String)> {
72 let mut id = None;
73 let mut name = None;
74 let mut path = None;
75 for part in entry.split(" | ") {
76 if let Some(rest) = part.strip_prefix("ID:") {
77 id = Some(rest.trim().to_string());
78 } else if let Some(rest) = part.strip_prefix("Name:") {
79 name = Some(rest.trim().to_string());
80 } else if let Some(rest) = part.strip_prefix("Path:") {
81 path = Some(rest.trim().to_string());
82 }
83 }
84 match (id, name, path) {
85 (Some(i), Some(n), Some(p)) => Some((i, n, p)),
86 _ => None,
87 }
88}
89
90fn render_file_element(entry: &str) -> String {
92 if let Some((id, name, path)) = parse_id_name_path(entry) {
93 format!(
94 " <file id=\"{}\" name=\"{}\" path=\"{}\"/>\n",
95 xml_escape_attr(&id),
96 xml_escape_attr(&name),
97 xml_escape_attr(&path)
98 )
99 } else {
100 format!(
102 " <file><![CDATA[{}]]></file>\n",
103 entry.replace("]]>", "]]]]><![CDATA[>")
104 )
105 }
106}
107
108pub fn build_analysis_prompt_base(request: &AnalysisRequest) -> String {
118 let mut p = String::new();
119 p.push_str("<role>You are an expert subtitle-matching assistant. Pair each subtitle file with the video it belongs to using filename evidence and the subtitle preview text.</role>\n");
120 p.push_str("<instructions>\n");
121 p.push_str("## Task\n");
122 p.push_str("Analyze the inventories below and return JSON-only output that pairs every confidently matched subtitle with its video. Use the file IDs verbatim — never invent new IDs and never echo filenames as IDs.\n\n");
123 p.push_str("## Language Inference Rules\n");
124 p.push_str("Use BOTH the filename and the `<content_samples>` text to infer each subtitle's language. The content sample is the primary signal: when filename and content disagree, trust the content. Output a short language code (e.g. `tc`, `sc`, `en`, `ja`, `ko`, `fr`, `de`, `es`, `pt`, `ru`). Use `und` only when the language genuinely cannot be determined.\n\n");
125 p.push_str("## Naming and Uniqueness Rules\n");
126 p.push_str("Across the entire `matches[]` array — not just within a single video — no two entries MAY produce the same final filename in the same target directory. When two subtitles for the same video would otherwise collide, supply distinct `language` codes. If both share the same language, supply a numeric `target_filename_suffix` (`2`, `3`, …) starting at `2`. The suffix MUST be one short token of `[A-Za-z0-9_-]` and at most 16 characters; it is spliced between the video base name and the subtitle extension.\n\n");
127 p.push_str("## Output Schema\n");
128 p.push_str("Respond with a single JSON object — no prose, no Markdown fences. Keys are stable: `matches[]` (array of objects with `video_file_id`, `subtitle_file_id`, `confidence`, `match_factors`, optional `language`, optional `target_filename_suffix`), `confidence` (overall score), and `reasoning` (short English explanation).\n");
129 p.push_str("</instructions>\n");
130
131 p.push_str("<video_files>\n");
132 for entry in &request.video_files {
133 p.push_str(&render_file_element(entry));
134 }
135 p.push_str("</video_files>\n");
136
137 p.push_str("<subtitle_files>\n");
138 for entry in &request.subtitle_files {
139 p.push_str(&render_file_element(entry));
140 }
141 p.push_str("</subtitle_files>\n");
142
143 if !request.content_samples.is_empty() {
144 p.push_str("<content_samples_instructions>Each `<sample>` element's `subtitle_file_id` attribute matches the `id` attribute on the corresponding `<file>` element in `<subtitle_files>`. Resolve sample-to-subtitle relationships through this ID, never by filename.</content_samples_instructions>\n");
148 p.push_str("<content_samples>\n");
149 for sample in &request.content_samples {
150 p.push_str(&format!(
151 " <sample subtitle_file_id=\"{}\">{}</sample>\n",
152 xml_escape_attr(&sample.subtitle_file_id),
153 cdata_wrap(&sample.content_preview)
154 ));
155 }
156 p.push_str("</content_samples>\n");
157 }
158
159 p.push_str("<output_schema>\n");
160 p.push_str("{\n");
161 p.push_str(" \"matches\": [\n");
162 p.push_str(" {\n");
163 p.push_str(" \"video_file_id\": \"file_<uuid>\",\n");
164 p.push_str(" \"subtitle_file_id\": \"file_<uuid>\",\n");
165 p.push_str(" \"confidence\": 0.95,\n");
166 p.push_str(" \"match_factors\": [\"filename_similarity\", \"content_correlation\"],\n");
167 p.push_str(" \"language\": \"tc\",\n");
168 p.push_str(" \"target_filename_suffix\": \"tc\"\n");
169 p.push_str(" }\n");
170 p.push_str(" ],\n");
171 p.push_str(" \"confidence\": 0.9,\n");
172 p.push_str(" \"reasoning\": \"Short English explanation\"\n");
173 p.push_str("}\n");
174 p.push_str("</output_schema>\n");
175
176 p.push_str("<example>\n");
177 p.push_str(" <input_summary>One video plus two same-named subtitles whose content samples reveal Traditional vs Simplified Chinese.</input_summary>\n");
178 p.push_str(" <output>{\"matches\":[{\"video_file_id\":\"file_v1\",\"subtitle_file_id\":\"file_s1\",\"confidence\":0.95,\"match_factors\":[\"content_correlation\"],\"language\":\"tc\",\"target_filename_suffix\":\"tc\"},{\"video_file_id\":\"file_v1\",\"subtitle_file_id\":\"file_s2\",\"confidence\":0.95,\"match_factors\":[\"content_correlation\"],\"language\":\"sc\",\"target_filename_suffix\":\"sc\"}],\"confidence\":0.95,\"reasoning\":\"Distinguished by script\"}</output>\n");
179 p.push_str("</example>\n");
180
181 p
182}
183
184pub fn parse_match_result_base(response: &str) -> Result<MatchResult> {
186 let json_start = response.find('{').unwrap_or(0);
187 let json_end = response.rfind('}').map(|i| i + 1).unwrap_or(response.len());
188 let json_str = &response[json_start..json_end];
189 serde_json::from_str(json_str)
190 .map_err(|e| SubXError::AiService(format!("AI response parsing failed: {}", e)))
191}
192
193pub fn build_verification_prompt_base(request: &VerificationRequest) -> String {
195 let mut p = String::new();
196 p.push_str("<role>You are an expert subtitle-matching verifier.</role>\n");
197 p.push_str("<instructions>\n");
198 p.push_str("## Task\n");
199 p.push_str("Score the supplied video/subtitle pairing on a 0.0–1.0 confidence scale and return strictly JSON.\n\n");
200 p.push_str("## Output Schema\n");
201 p.push_str("Respond with a single JSON object: `{\"score\": <0..1>, \"factors\": [\"...\"]}`. No prose, no Markdown.\n");
202 p.push_str("</instructions>\n");
203
204 p.push_str("<match>\n");
205 p.push_str(&format!(
206 " <video path=\"{}\"/>\n",
207 xml_escape_attr(&request.video_file)
208 ));
209 p.push_str(&format!(
210 " <subtitle path=\"{}\"/>\n",
211 xml_escape_attr(&request.subtitle_file)
212 ));
213 p.push_str(" <factors>\n");
214 if request.match_factors.is_empty() {
215 p.push_str(" <!-- Matching factors: (none) -->\n");
218 } else {
219 for factor in &request.match_factors {
220 p.push_str(&format!(
221 " <factor>{}</factor>\n",
222 xml_escape_attr(factor)
223 ));
224 }
225 p.push_str(" <!-- Matching factors: list above -->\n");
226 }
227 p.push_str(" </factors>\n");
228 p.push_str("</match>\n");
229
230 p.push_str("<output_schema>{\"score\": 0.9, \"factors\": [\"...\"]}</output_schema>\n");
231 p.push_str("<!-- Respond strictly in JSON format. -->\n");
233 p
234}
235
236pub fn parse_confidence_score_base(response: &str) -> Result<ConfidenceScore> {
238 let json_start = response.find('{').unwrap_or(0);
239 let json_end = response.rfind('}').map(|i| i + 1).unwrap_or(response.len());
240 let json_str = &response[json_start..json_end];
241 serde_json::from_str(json_str)
242 .map_err(|e| SubXError::AiService(format!("AI confidence parsing failed: {}", e)))
243}
244
245#[cfg(test)]
246mod tests {
247
248 use crate::services::ai::prompts::{
249 PromptBuilder, ResponseParser, build_analysis_prompt_base, parse_match_result_base,
250 };
251 use crate::services::ai::{AnalysisRequest, ContentSample, OpenAIClient};
252
253 #[test]
254 fn test_ai_prompt_with_file_ids_english() {
255 let client = OpenAIClient::new("test_key".into(), "gpt-4.1".into(), 0.1, 1000, 0, 0);
256 let video_id = "file_019dcc51-f7da-74e3-9e0d-f75d40fc569c";
257 let subtitle_id = "file_019dcc51-f7d5-7640-8bb1-d2bbbc127a23";
258 let request = AnalysisRequest {
259 video_files: vec![format!("ID:{video_id} | Name:movie.mkv | Path:movie.mkv")],
260 subtitle_files: vec![format!(
261 "ID:{subtitle_id} | Name:movie.srt | Path:movie.srt"
262 )],
263 content_samples: vec![],
264 };
265
266 let prompt = client.build_analysis_prompt(&request);
267
268 assert!(prompt.contains(&format!("id=\"{video_id}\"")));
269 assert!(prompt.contains(&format!("id=\"{subtitle_id}\"")));
270 assert!(prompt.contains("<role>"));
271 assert!(prompt.contains("<instructions>"));
272 assert!(prompt.contains("<video_files>"));
273 assert!(prompt.contains("<subtitle_files>"));
274 assert!(prompt.contains("<output_schema>"));
275 assert!(prompt.contains("Language Inference Rules"));
276 assert!(prompt.contains("Naming and Uniqueness Rules"));
277 assert!(prompt.contains("video_file_id"));
278 assert!(prompt.contains("subtitle_file_id"));
279 assert!(!prompt.contains("請分析"));
280 }
281
282 #[test]
283 fn test_parse_match_result_with_ids() {
284 let client = OpenAIClient::new("test_key".into(), "gpt-4.1".into(), 0.1, 1000, 0, 0);
285 let video_id = "file_019dcc51-f7da-74e3-9e0d-f75d40fc569c";
286 let subtitle_id = "file_019dcc51-f7d5-7640-8bb1-d2bbbc127a23";
287 let json_resp = format!(
288 r#"{{
289 "matches": [{{
290 "video_file_id": "{video_id}",
291 "subtitle_file_id": "{subtitle_id}",
292 "confidence": 0.95,
293 "match_factors": ["filename_similarity"]
294 }}],
295 "confidence": 0.9,
296 "reasoning": "Strong match based on filename patterns"
297 }}"#
298 );
299
300 let result = client.parse_match_result(&json_resp).unwrap();
301 assert_eq!(result.matches.len(), 1);
302 assert_eq!(result.matches[0].video_file_id, video_id);
303 assert_eq!(result.matches[0].subtitle_file_id, subtitle_id);
304 assert_eq!(result.matches[0].confidence, 0.95);
305 assert_eq!(result.matches[0].match_factors[0], "filename_similarity");
306 assert!(result.matches[0].language.is_none());
307 assert!(result.matches[0].target_filename_suffix.is_none());
308 }
309
310 #[test]
311 fn test_ai_prompt_structure_consistency() {
312 let client = OpenAIClient::new("test_key".into(), "gpt-4.1".into(), 0.1, 1000, 0, 0);
313 let request = AnalysisRequest {
314 video_files: vec![
315 "ID:file_video1 | Name:video1.mkv | Path:season1/video1.mkv".into(),
316 "ID:file_video2 | Name:video2.mkv | Path:season1/video2.mkv".into(),
317 ],
318 subtitle_files: vec![
319 "ID:file_sub1 | Name:sub1.srt | Path:season1/sub1.srt".into(),
320 "ID:file_sub2 | Name:sub2.srt | Path:season1/sub2.srt".into(),
321 ],
322 content_samples: vec![],
323 };
324
325 let prompt = client.build_analysis_prompt(&request);
326
327 assert!(prompt.contains("id=\"file_video1\""));
328 assert!(prompt.contains("id=\"file_video2\""));
329 assert!(prompt.contains("id=\"file_sub1\""));
330 assert!(prompt.contains("id=\"file_sub2\""));
331 assert!(prompt.contains("<video_files>"));
332 assert!(prompt.contains("<subtitle_files>"));
333 assert!(prompt.contains("<output_schema>"));
334 }
335
336 #[test]
337 fn test_parse_confidence_score() {
338 let client = OpenAIClient::new("test_key".into(), "gpt-4.1".into(), 0.1, 1000, 0, 0);
339 let json_resp = r#"{
340 "score": 0.88,
341 "factors": ["filename_similarity", "content_correlation"]
342 }"#;
343
344 let result = client.parse_confidence_score(json_resp).unwrap();
345 assert_eq!(result.score, 0.88);
346 assert_eq!(
347 result.factors,
348 vec![
349 "filename_similarity".to_string(),
350 "content_correlation".to_string()
351 ]
352 );
353 }
354
355 #[test]
356 fn test_xml_escapes_filename_with_ampersand() {
357 let request = AnalysisRequest {
358 video_files: vec!["ID:vid1 | Name:m & m.mkv | Path:/x/m & m.mkv".into()],
359 subtitle_files: vec!["ID:sub1 | Name:m & m.srt | Path:/x/m & m.srt".into()],
360 content_samples: vec![],
361 };
362 let prompt = build_analysis_prompt_base(&request);
363 assert!(
364 prompt.contains("name=\"m & m.mkv\""),
365 "ampersand must be XML-escaped"
366 );
367 assert!(prompt.contains("path=\"/x/m & m.mkv\""));
368 assert!(!prompt.contains("name=\"m & m.mkv\""));
369 }
370
371 #[test]
372 fn test_xml_escapes_all_five_entities() {
373 let raw_name = "weird & <stuff> \"x\" 'y'.mkv";
378 let raw_path = "/x/weird & <stuff> \"x\" 'y'.mkv";
379 let request = AnalysisRequest {
380 video_files: vec![format!("ID:vid1 | Name:{} | Path:{}", raw_name, raw_path)],
381 subtitle_files: vec!["ID:sub1 | Name:s.srt | Path:/x/s.srt".into()],
382 content_samples: vec![],
383 };
384 let prompt = build_analysis_prompt_base(&request);
385 let expected_attr = "weird & <stuff> "x" 'y'.mkv";
386 assert!(
387 prompt.contains(&format!("name=\"{}\"", expected_attr)),
388 "all five XML entities must be escaped: prompt was\n{prompt}"
389 );
390 let expected_path = "/x/weird & <stuff> "x" 'y'.mkv";
392 assert!(prompt.contains(&format!("path=\"{}\"", expected_path)));
393 assert!(!prompt.contains(&format!("name=\"{}\"", raw_name)));
395 }
396
397 #[test]
398 fn test_sample_uses_id_only_no_filename_attribute() {
399 let request = AnalysisRequest {
405 video_files: vec!["ID:vid1 | Name:movie.mkv | Path:/x/movie.mkv".into()],
406 subtitle_files: vec!["ID:sub1 | Name:movie.srt | Path:/x/movie.srt".into()],
407 content_samples: vec![ContentSample {
408 filename: "movie.srt".into(),
409 subtitle_file_id: "sub1".into(),
410 content_preview: "hi".into(),
411 file_size: 2,
412 }],
413 };
414 let prompt = build_analysis_prompt_base(&request);
415 assert!(
416 prompt.contains("<sample subtitle_file_id=\"sub1\">"),
417 "sample must use only subtitle_file_id; got\n{prompt}"
418 );
419 assert!(
420 !prompt.contains("filename=\""),
421 "sample element must not carry a filename attribute"
422 );
423 assert!(
424 prompt.contains("<content_samples_instructions>"),
425 "ID-linkage instruction sentence must be emitted"
426 );
427 assert!(prompt.contains("subtitle_file_id"));
428 }
429
430 #[test]
431 fn test_cdata_preserves_italic_markup() {
432 let request = AnalysisRequest {
433 video_files: vec!["ID:vid1 | Name:movie.mkv | Path:/x/movie.mkv".into()],
434 subtitle_files: vec!["ID:sub1 | Name:movie.srt | Path:/x/movie.srt".into()],
435 content_samples: vec![ContentSample {
436 filename: "movie.srt".into(),
437 subtitle_file_id: "sub1".into(),
438 content_preview: "Hello <i>world</i>".into(),
439 file_size: 10,
440 }],
441 };
442 let prompt = build_analysis_prompt_base(&request);
443 assert!(prompt.contains("subtitle_file_id=\"sub1\""));
444 assert!(prompt.contains("<![CDATA[Hello <i>world</i>]]>"));
445 }
446
447 #[test]
448 fn test_cdata_neutralizes_terminator() {
449 let request = AnalysisRequest {
450 video_files: vec!["ID:vid1 | Name:movie.mkv | Path:/x/movie.mkv".into()],
451 subtitle_files: vec!["ID:sub1 | Name:movie.srt | Path:/x/movie.srt".into()],
452 content_samples: vec![ContentSample {
453 filename: "movie.srt".into(),
454 subtitle_file_id: "sub1".into(),
455 content_preview: "weird ]]> sequence".into(),
456 file_size: 10,
457 }],
458 };
459 let prompt = build_analysis_prompt_base(&request);
460 let cdata_start = prompt.find("<![CDATA[weird ").unwrap();
463 let split_marker = "]]]]><![CDATA[>";
464 assert!(
465 prompt[cdata_start..].contains(split_marker),
466 "raw ]]> must be neutralized"
467 );
468 }
469
470 #[test]
471 fn test_round_trip_with_optional_fields() {
472 let json_resp = r#"{
473 "matches": [{
474 "video_file_id": "vid1",
475 "subtitle_file_id": "sub1",
476 "confidence": 0.9,
477 "match_factors": ["content_correlation"],
478 "language": "tc",
479 "target_filename_suffix": "tc"
480 }],
481 "confidence": 0.9,
482 "reasoning": "ok"
483 }"#;
484 let result = parse_match_result_base(json_resp).unwrap();
485 assert_eq!(result.matches[0].language.as_deref(), Some("tc"));
486 assert_eq!(
487 result.matches[0].target_filename_suffix.as_deref(),
488 Some("tc")
489 );
490 }
491}