1use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use regex::Regex;
10use serde_json::{Map, Value};
11use xil_core::pycsv;
12use xil_core::pyfmt::{head, round_to};
13use xil_core::pyjson::{dumps, py_float, Style};
14use xil_core::textsim::SequenceMatcher;
15use xil_core::workspace::{resolve_slug, workspace_root};
16use xil_core::{banner, log};
17
18#[derive(Parser)]
19#[command(
20 name = "xil-stem-compare",
21 about = "Cross-reference Whisper transcripts against parsed script dialogue to flag garbled stems."
22)]
23struct Args {
24 #[arg(long, short = 's', value_name = "SLUG")]
26 show: Option<String>,
27 #[arg(long, short = 'e', value_name = "TAG")]
29 episode: Option<String>,
30 #[arg(long, value_name = "FILE")]
32 stem_verify: Option<String>,
33 #[arg(long, value_name = "FILE")]
35 parsed: Option<String>,
36 #[arg(
38 long,
39 default_value_t = 0.75,
40 value_name = "FLOAT",
41 allow_negative_numbers = true
42 )]
43 threshold: f64,
44 #[arg(long, short = 'o', value_name = "FILE")]
46 output: Option<String>,
47 #[arg(long)]
49 csv: bool,
50}
51
52fn normalize(text: &str) -> String {
54 static PUNCT: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
55 static SPACE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
56 let lower = text.to_lowercase();
57 let t = PUNCT
58 .get_or_init(|| Regex::new(r"[^\w\s]").unwrap())
59 .replace_all(&lower, " ");
60 SPACE
61 .get_or_init(|| Regex::new(r"\s+").unwrap())
62 .replace_all(&t, " ")
63 .trim()
64 .to_string()
65}
66
67fn similarity(a: &str, b: &str) -> f64 {
68 SequenceMatcher::new(&normalize(a), &normalize(b)).ratio()
69}
70
71struct Flag {
72 seq: Value,
73 section: Value,
74 scene: Value,
75 speaker: Value,
76 status: &'static str,
77 similarity: Option<f64>,
78 original: Value,
79 transcript: Value,
80}
81
82impl Flag {
83 fn json(&self) -> Value {
84 let mut m = Map::new();
85 m.insert("seq".into(), self.seq.clone());
86 m.insert("section".into(), self.section.clone());
87 m.insert("scene".into(), self.scene.clone());
88 m.insert("speaker".into(), self.speaker.clone());
89 m.insert("status".into(), self.status.into());
90 m.insert(
91 "similarity".into(),
92 self.similarity.map(py_float).unwrap_or(Value::Null),
93 );
94 m.insert("original".into(), self.original.clone());
95 m.insert("transcript".into(), self.transcript.clone());
96 Value::Object(m)
97 }
98}
99
100const STATUSES: [&str; 5] = ["ok", "garbled", "silent", "no_stem", "not_transcribed"];
101
102fn load_json(p: &Path) -> anyhow::Result<Value> {
103 Ok(serde_json::from_str(&fs::read_to_string(p)?)?)
104}
105
106fn compare(
107 dialogue: &[Map<String, Value>],
108 stems: &indexmap::IndexMap<i64, Map<String, Value>>,
109 threshold: f64,
110) -> (Vec<Flag>, [usize; 5]) {
111 let mut counts = [0usize; 5];
112 let mut flags = Vec::new();
113 for entry in dialogue {
114 let seq_v = entry.get("seq").cloned().unwrap_or(Value::Null);
115 let get = |k: &str| entry.get(k).cloned().unwrap_or(Value::Null);
116 let original = get("text");
117 let make = |status, sim, transcript| Flag {
118 seq: seq_v.clone(),
119 section: get("section"),
120 scene: get("scene"),
121 speaker: get("speaker"),
122 status,
123 similarity: sim,
124 original: original.clone(),
125 transcript,
126 };
127 let Some(stem) = seq_v.as_i64().and_then(|s| stems.get(&s)) else {
128 counts[3] += 1;
129 flags.push(make("no_stem", None, Value::Null));
130 continue;
131 };
132 let transcript = match stem.get("transcript") {
133 None | Some(Value::Null) => {
134 counts[4] += 1;
135 flags.push(make("not_transcribed", None, Value::Null));
136 continue;
137 }
138 Some(t) => t,
139 };
140 let text = transcript.get("text").and_then(Value::as_str).unwrap_or("");
141 if text.trim().is_empty() {
142 counts[2] += 1;
143 flags.push(make("silent", Some(0.0), Value::String(String::new())));
144 continue;
145 }
146 let sim = round_to(similarity(original.as_str().unwrap_or(""), text), 4);
147 if sim < threshold {
148 counts[1] += 1;
149 flags.push(make("garbled", Some(sim), Value::String(text.to_string())));
150 } else {
151 counts[0] += 1;
152 }
153 }
154 (flags, counts)
155}
156
157fn str_of(v: &Value) -> String {
158 match v {
159 Value::String(s) => s.clone(),
160 Value::Null => "None".into(),
161 other => pycsv::cell(other),
162 }
163}
164
165fn print_summary(counts: &[usize; 5], flags: &[Flag], threshold: f64) {
166 let total: usize = counts.iter().sum();
167 let ok_pct = if total > 0 {
168 counts[0] as f64 / total as f64 * 100.0
169 } else {
170 0.0
171 };
172 log::info(&format!(" Threshold : {threshold:.2}"));
173 log::info(&format!(" Dialogue stems : {total}"));
174 log::info(&format!(" OK : {} ({ok_pct:.1}%)", counts[0]));
175 log::info(&format!(" Garbled : {}", counts[1]));
176 log::info(&format!(" Silent : {}", counts[2]));
177 log::info(&format!(" No stem : {}", counts[3]));
178 log::info(&format!(" Not transcribed: {}", counts[4]));
179 if flags.is_empty() {
180 log::info("");
181 log::info(" No issues found.");
182 return;
183 }
184 log::info("");
185 log::info("--- Flagged entries ---");
186 for f in flags {
187 let seq = match &f.seq {
188 Value::Number(n) if n.as_i64().is_some() => format!("{:03}", n.as_i64().unwrap()),
189 other => str_of(other),
190 };
191 let speaker_full = match &f.speaker {
192 Value::String(s) if !s.is_empty() => s.clone(),
193 _ => "?".into(),
194 };
195 let speaker = head(&speaker_full, 12);
196 let original = str_of(&f.original);
197 match f.status {
198 "garbled" => {
199 log::info(&format!(
200 "[garbled] seq={seq} {speaker:<12} sim={:.2}",
201 f.similarity.unwrap_or(0.0)
202 ));
203 log::info(&format!(" ORIGINAL : {original}"));
204 log::info(&format!(" TRANSCRIPT: {}", str_of(&f.transcript)));
205 }
206 "silent" => {
207 log::info(&format!("[silent] seq={seq} {speaker}"));
208 log::info(&format!(" ORIGINAL : {original}"));
209 }
210 "no_stem" => {
211 log::info(&format!("[no_stem] seq={seq} {speaker}"));
212 log::info(&format!(" ORIGINAL : {original}"));
213 }
214 _ => {
215 log::info(&format!("[no_xscr] seq={seq} {speaker}"));
216 log::info(&format!(" ORIGINAL : {original}"));
217 }
218 }
219 }
220}
221
222fn print_csv(flags: &[Flag]) -> std::io::Result<()> {
223 use std::io::Write;
224 let mut out = std::io::stdout().lock();
225 let header: Vec<String> = [
226 "seq",
227 "section",
228 "scene",
229 "speaker",
230 "status",
231 "similarity",
232 "original",
233 "transcript",
234 ]
235 .iter()
236 .map(|s| s.to_string())
237 .collect();
238 pycsv::write_row(&mut out, &header)?;
239 for f in flags {
240 let sim = f.similarity.map(py_float).unwrap_or(Value::Null);
241 let row: Vec<String> = [
242 &f.seq,
243 &f.section,
244 &f.scene,
245 &f.speaker,
246 &Value::from(f.status),
247 &sim,
248 &f.original,
249 &f.transcript,
250 ]
251 .iter()
252 .map(|v| pycsv::cell(v))
253 .collect();
254 pycsv::write_row(&mut out, &row)?;
255 }
256 out.flush()
257}
258
259pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
260 log::init("stem-compare");
261 let a: Args = match super::parse_or_exit("xil-stem-compare", args) {
262 Ok(a) => a,
263 Err(code) => return Ok(code),
264 };
265 let _banner = banner::begin(super::prog(), &super::argv_line(args));
266 execute(&a)
267}
268
269fn execute(a: &Args) -> anyhow::Result<i32> {
270 let workspace = workspace_root();
271 let slug = resolve_slug(a.show.as_deref(), "project.json");
272 let episode = a.episode.clone().filter(|e| !e.is_empty());
273 let episode_label = episode.clone().unwrap_or_else(|| "unknown".into());
274
275 let stem_verify_path = match a.stem_verify.as_ref().filter(|s| !s.is_empty()) {
276 Some(p) => PathBuf::from(p),
277 None => match &episode {
278 Some(ep) => workspace
279 .join("parsed")
280 .join(&slug)
281 .join(format!("stem_verify_{ep}.json")),
282 None => {
283 log::error("--episode is required unless --stem-verify is provided");
284 return Ok(1);
285 }
286 },
287 };
288 let parsed_path = match a.parsed.as_ref().filter(|s| !s.is_empty()) {
289 Some(p) => PathBuf::from(p),
290 None => match &episode {
291 Some(ep) => workspace
292 .join("parsed")
293 .join(&slug)
294 .join(format!("parsed_{ep}.json")),
295 None => {
296 log::error("--episode is required unless --parsed is provided");
297 return Ok(1);
298 }
299 },
300 };
301 if !stem_verify_path.exists() {
302 log::error(&format!(
303 "stem_verify JSON not found: {}",
304 stem_verify_path.display()
305 ));
306 return Ok(1);
307 }
308 if !parsed_path.exists() {
309 log::error(&format!("parsed JSON not found: {}", parsed_path.display()));
310 return Ok(1);
311 }
312 log::info(&format!(" stem_verify : {}", stem_verify_path.display()));
313 log::info(&format!(" parsed : {}", parsed_path.display()));
314
315 let verify = load_json(&stem_verify_path)?;
316 let mut stems = indexmap::IndexMap::new();
317 for f in verify["files"].as_array().into_iter().flatten() {
318 let Some(o) = f.as_object() else { continue };
319 let Some(seq) = o.get("seq").filter(|s| !s.is_null()) else {
320 continue;
321 };
322 if o.get("speaker").and_then(Value::as_str) == Some("sfx") {
323 continue;
324 }
325 if let Some(s) = seq.as_i64() {
326 stems.insert(s, o.clone());
327 }
328 }
329 let parsed = load_json(&parsed_path)?;
330 let dialogue: Vec<Map<String, Value>> = parsed["entries"]
331 .as_array()
332 .into_iter()
333 .flatten()
334 .filter_map(|e| e.as_object())
335 .filter(|e| {
336 e.get("type").and_then(Value::as_str) == Some("dialogue")
337 && e.get("direction_type").map_or(true, Value::is_null)
338 })
339 .cloned()
340 .collect();
341
342 let (flags, counts) = compare(&dialogue, &stems, a.threshold);
343 if a.csv {
344 print_csv(&flags)?;
345 } else {
346 print_summary(&counts, &flags, a.threshold);
347 }
348
349 if let Some(out) = a.output.as_ref().filter(|s| !s.is_empty()) {
350 let out = PathBuf::from(out);
351 if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
352 fs::create_dir_all(parent)?;
353 }
354 let mut summary = Map::new();
355 summary.insert(
356 "total_dialogue".into(),
357 Value::from(counts.iter().sum::<usize>()),
358 );
359 for (k, n) in STATUSES.iter().zip(counts) {
360 summary.insert((*k).into(), Value::from(n));
361 }
362 let mut report = Map::new();
363 report.insert("show".into(), slug.clone().into());
364 report.insert("episode".into(), episode_label.into());
365 report.insert(
366 "generated".into(),
367 chrono::Local::now()
368 .format("%Y-%m-%dT%H:%M:%S")
369 .to_string()
370 .into(),
371 );
372 report.insert("threshold".into(), py_float(a.threshold));
373 report.insert(
374 "stem_verify_path".into(),
375 fs::canonicalize(&stem_verify_path)?
376 .to_string_lossy()
377 .into_owned()
378 .into(),
379 );
380 report.insert(
381 "parsed_path".into(),
382 fs::canonicalize(&parsed_path)?
383 .to_string_lossy()
384 .into_owned()
385 .into(),
386 );
387 report.insert("summary".into(), Value::Object(summary));
388 report.insert(
389 "flags".into(),
390 Value::Array(flags.iter().map(Flag::json).collect()),
391 );
392 fs::write(&out, dumps(&Value::Object(report), Style::INDENT2))?;
393 log::info(&format!("Written: {}", out.display()));
394 }
395 Ok(0)
396}
397
398pub fn command() -> clap::Command {
400 <Args as clap::CommandFactory>::command()
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn normalize_strips_punctuation_and_case() {
409 assert_eq!(normalize("Hello, World! It's me."), "hello world it s me");
410 assert_eq!(similarity("Hello there!", "hello there"), 1.0);
411 }
412}