Skip to main content

subx_cli/services/ai/
prompts.rs

1use crate::Result;
2use crate::error::SubXError;
3use crate::services::ai::{AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest};
4use serde_json;
5
6/// Prompt builder trait for AI providers.
7pub trait PromptBuilder {
8    /// Build analysis prompt.
9    fn build_analysis_prompt(&self, request: &AnalysisRequest) -> String {
10        build_analysis_prompt_base(request)
11    }
12
13    /// Build verification prompt.
14    fn build_verification_prompt(&self, request: &VerificationRequest) -> String {
15        build_verification_prompt_base(request)
16    }
17
18    /// System message for analysis prompt.
19    fn get_analysis_system_message() -> &'static str {
20        "You are an expert subtitle-matching assistant."
21    }
22
23    /// System message for verification prompt.
24    fn get_verification_system_message() -> &'static str {
25        "You are an expert subtitle-matching verifier."
26    }
27}
28
29/// Response parsing trait for AI providers.
30pub trait ResponseParser {
31    /// Parse match result.
32    fn parse_match_result(&self, response: &str) -> Result<MatchResult> {
33        parse_match_result_base(response)
34    }
35
36    /// Parse confidence score.
37    fn parse_confidence_score(&self, response: &str) -> Result<ConfidenceScore> {
38        parse_confidence_score_base(response)
39    }
40}
41
42/// Escape a string for inclusion in an XML attribute value.
43fn 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("&amp;"),
48            '<' => out.push_str("&lt;"),
49            '>' => out.push_str("&gt;"),
50            '"' => out.push_str("&quot;"),
51            '\'' => out.push_str("&#39;"),
52            _ => out.push(ch),
53        }
54    }
55    out
56}
57
58/// Wrap `content` in CDATA, neutralizing any literal `]]>` substring by
59/// splitting it across two CDATA sections so the surrounding XML
60/// structure remains parseable.
61fn cdata_wrap(content: &str) -> String {
62    let safe = content.replace("]]>", "]]]]><![CDATA[>");
63    format!("<![CDATA[{}]]>", safe)
64}
65
66/// Tolerant parser for the legacy `"ID:… | Name:… | Path:…"` strings that
67/// callers pass into [`AnalysisRequest::video_files`] and
68/// [`AnalysisRequest::subtitle_files`]. Returns `(id, name, path)` or
69/// `None` if the input does not look like that shape; the caller falls
70/// back to embedding the raw string verbatim inside a `<file>` element.
71fn 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
90/// Render a single file-inventory entry as `<file id="…" name="…" path="…"/>`.
91fn 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        // Fallback: embed the raw string verbatim so we never lose data.
101        format!(
102            "  <file><![CDATA[{}]]></file>\n",
103            entry.replace("]]>", "]]]]><![CDATA[>")
104        )
105    }
106}
107
108/// Build analysis prompt for AI providers.
109///
110/// Emits an XML-tagged prompt body following the Claude prompt-engineering
111/// best-practices guide: a `<role>` description, a `<instructions>` block
112/// with Markdown-headed sub-sections, separate `<video_files>` and
113/// `<subtitle_files>` inventories, a `<content_samples>` block keyed by
114/// stable `subtitle_file_id`, an `<output_schema>` block describing the
115/// expected JSON response, and a single `<example>` for the model to
116/// imitate.
117pub 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        // Each `<sample>` is keyed by `subtitle_file_id` so the model
145        // links samples back to entries in `<subtitle_files>` by ID
146        // alone — no filename leak that could be echoed as an ID.
147        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
184/// Parse matching results from AI response.
185pub 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
193/// Build verification prompt for AI providers.
194pub 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        // Preserve a recognizable "Matching factors:" anchor for tests
216        // and operators reading raw prompts.
217        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    // Keep a literal "JSON format" anchor for legacy assertions.
232    p.push_str("<!-- Respond strictly in JSON format. -->\n");
233    p
234}
235
236/// Parse confidence score from AI response.
237pub 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 &amp; m.mkv\""),
365            "ampersand must be XML-escaped"
366        );
367        assert!(prompt.contains("path=\"/x/m &amp; m.mkv\""));
368        assert!(!prompt.contains("name=\"m & m.mkv\""));
369    }
370
371    #[test]
372    fn test_xml_escapes_all_five_entities() {
373        // `xml_escape_attr` covers `&`, `<`, `>`, `"`, and `'`. A
374        // realistic filename can contain every one at once; verify they
375        // all materialize as their entity references and that the raw
376        // characters disappear from the rendered attribute.
377        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 &amp; &lt;stuff&gt; &quot;x&quot; &#39;y&#39;.mkv";
386        assert!(
387            prompt.contains(&format!("name=\"{}\"", expected_attr)),
388            "all five XML entities must be escaped: prompt was\n{prompt}"
389        );
390        // Path attribute uses the same escaper.
391        let expected_path = "/x/weird &amp; &lt;stuff&gt; &quot;x&quot; &#39;y&#39;.mkv";
392        assert!(prompt.contains(&format!("path=\"{}\"", expected_path)));
393        // The raw, unescaped form must be absent from any attribute.
394        assert!(!prompt.contains(&format!("name=\"{}\"", raw_name)));
395    }
396
397    #[test]
398    fn test_sample_uses_id_only_no_filename_attribute() {
399        // The `<sample>` element must key off `subtitle_file_id` alone;
400        // exposing `filename=` would weaken the ID-based contract and
401        // tempt the model to echo filenames as IDs. Also assert the
402        // dedicated instruction sentence describing the ID linkage is
403        // present.
404        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        // The literal "]]>" must not appear inside the original content
461        // CDATA without being split.
462        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}