"""Create reproducible correctness evidence for media transcription acceptance.""" from __future__ import annotations import argparse import hashlib import json import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any, Sequence def _fingerprint(path: Path) -> dict[str, Any]: stat = path.stat() digest = hashlib.sha256() with path.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): digest.update(block) return { "path": str(path.resolve()), "size_bytes": stat.st_size, "creation_time_utc": datetime.fromtimestamp( stat.st_ctime, timezone.utc ).isoformat(), "last_write_time_utc": datetime.fromtimestamp( stat.st_mtime, timezone.utc ).isoformat(), "sha256": digest.hexdigest().upper(), } def _write_json(path: Path, payload: dict[str, Any], replace: bool = False) -> None: if path.exists() and not replace: raise RuntimeError(f"证据文件已存在,拒绝覆盖:{path}") path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) def _run_json(command: Sequence[str]) -> dict[str, Any]: result = subprocess.run( list(command), check=False, capture_output=True, text=True, encoding="utf-8", errors="replace", ) if result.returncode != 0: raise RuntimeError( f"命令失败(exit={result.returncode}):{' '.join(command)}\n" f"{(result.stderr or result.stdout).strip()}" ) return json.loads(result.stdout) def _probe_media(path: Path, *, audio_only: bool = False) -> dict[str, Any]: command = [ "ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type,codec_name,sample_rate,channels,duration", "-of", "json", ] if audio_only: command.extend(["-select_streams", "a:0"]) command.append(str(path)) return _run_json(command) def _duration(probe: dict[str, Any]) -> float: format_duration = probe.get("format", {}).get("duration") if format_duration is not None: return float(format_duration) for stream in probe.get("streams", []): if stream.get("duration") is not None: return float(stream["duration"]) raise RuntimeError("FFprobe 证据中没有可用时长。") def _parse_srt_timestamp(value: str) -> float: match = re.fullmatch(r"(\d+):(\d{2}):(\d{2}),(\d{3})", value) if not match: raise RuntimeError(f"非法 SRT 时间戳:{value}") hours, minutes, seconds, milliseconds = (int(item) for item in match.groups()) return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000.0 def _parse_srt(path: Path) -> list[dict[str, Any]]: content = path.read_text(encoding="utf-8").strip() if not content: return [] blocks = re.split(r"\r?\n\s*\r?\n", content) parsed: list[dict[str, Any]] = [] for block in blocks: lines = block.splitlines() if len(lines) < 3: raise RuntimeError(f"SRT 块结构不完整:{block!r}") timing = lines[1].split(" --> ") if len(timing) != 2: raise RuntimeError(f"SRT 时间行非法:{lines[1]}") parsed.append( { "index": int(lines[0]), "start": _parse_srt_timestamp(timing[0]), "end": _parse_srt_timestamp(timing[1]), "text": "\n".join(lines[2:]).strip(), } ) return parsed def _parse_txt(path: Path) -> list[str]: texts: list[str] = [] for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue match = re.fullmatch(r"\[\d+:\d{2}:\d{2}\]\s(.*)", line) if not match: raise RuntimeError(f"TXT 行结构非法:{line!r}") texts.append(match.group(1)) return texts def _segment_checks( segments: list[dict[str, Any]], srt: list[dict[str, Any]], txt: list[str], flac_duration: float, ) -> dict[str, Any]: legal_ranges = all( 0.0 <= float(item["start"]) <= float(item["end"]) <= flac_duration for item in segments ) keys = [ (float(item["start"]), float(item["end"]), str(item["text"])) for item in segments ] ordered = keys == sorted(keys) indices = [item["index"] for item in srt] continuous_indices = indices == list(range(1, len(srt) + 1)) counts_match = len(segments) == len(srt) == len(txt) text_match = counts_match and all( str(segment["text"]) == srt_item["text"] == txt_item for segment, srt_item, txt_item in zip(segments, srt, txt, strict=True) ) srt_time_match = counts_match and all( abs(float(segment["start"]) - srt_item["start"]) <= 0.00051 and abs(float(segment["end"]) - srt_item["end"]) <= 0.00051 for segment, srt_item in zip(segments, srt, strict=True) ) duplicate_rows: list[int] = [] seen: set[tuple[float, float, str]] = set() for index, key in enumerate(keys, start=1): if key in seen: duplicate_rows.append(index) seen.add(key) return { "segment_count": len(segments), "srt_count": len(srt), "txt_count": len(txt), "legal_time_ranges": legal_ranges, "sorted_by_start_end_text": ordered, "continuous_srt_indices": continuous_indices, "counts_match": counts_match, "texts_match": text_match, "srt_times_match_json": srt_time_match, "exact_duplicate_segment_indices": duplicate_rows, "pass": all( [ legal_ranges, ordered, continuous_indices, counts_match, text_match, srt_time_match, not duplicate_rows, ] ), } def _boundary_checks( segments: list[dict[str, Any]], duration: float, manual_pass: bool, reviewer: str | None, ) -> list[dict[str, Any]]: boundaries: list[dict[str, Any]] = [] boundary = 1200 while boundary < duration: rows = [] lower = boundary - 15.0 upper = boundary + 15.0 for index, segment in enumerate(segments, start=1): start = float(segment["start"]) end = float(segment["end"]) if end >= lower and start <= upper: rows.append( { "index": index, "start": start, "end": end, "text": str(segment["text"]), } ) rows_are_ordered = rows == sorted( rows, key=lambda item: (item["start"], item["end"], item["text"]) ) keys = [(row["start"], row["end"], row["text"]) for row in rows] boundaries.append( { "boundary_seconds": boundary, "window_start": lower, "window_end": upper, "segments": rows, "automatic_no_exact_duplicates": len(keys) == len(set(keys)), "automatic_ordered": rows_are_ordered, "manual_readability_pass": manual_pass, "manual_reviewer": reviewer if manual_pass else None, "manual_reviewed_at_utc": ( datetime.now(timezone.utc).isoformat() if manual_pass else None ), } ) boundary += 1200 return boundaries def _snapshot(args: argparse.Namespace) -> int: source = Path(args.source).resolve() _write_json(Path(args.output).resolve(), _fingerprint(source), args.replace) return 0 def _validate(args: argparse.Namespace) -> int: original_source = Path(args.original_source).resolve() media = Path(args.media).resolve() output_dir = Path(args.output_dir).resolve() evidence_path = Path(args.evidence).resolve() before = json.loads(Path(args.source_before).read_text(encoding="utf-8-sig")) after = _fingerprint(original_source) source_fields = ( "size_bytes", "creation_time_utc", "last_write_time_utc", "sha256", ) source_unchanged = all(before.get(field) == after.get(field) for field in source_fields) stem = media.stem paths = { "flac": output_dir / f"{stem}.audio.flac", "txt": output_dir / f"{stem}.txt", "srt": output_dir / f"{stem}.srt", "json": output_dir / f"{stem}.json", } missing = [str(path) for path in paths.values() if not path.is_file()] failures: list[str] = [] if missing: failures.append("missing outputs: " + ", ".join(missing)) result_payload: dict[str, Any] = {} media_probe: dict[str, Any] = {} flac_probe: dict[str, Any] = {} segment_evidence: dict[str, Any] = {"pass": False} boundaries: list[dict[str, Any]] = [] flac_duration = 0.0 media_duration = 0.0 if not missing: try: result_payload = json.loads(paths["json"].read_text(encoding="utf-8")) media_probe = _probe_media(media) flac_probe = _probe_media(paths["flac"], audio_only=True) media_duration = _duration(media_probe) flac_duration = _duration(flac_probe) srt = _parse_srt(paths["srt"]) txt = _parse_txt(paths["txt"]) segments = result_payload.get("segments", []) segment_evidence = _segment_checks( segments, srt, txt, flac_duration ) boundaries = _boundary_checks( segments, flac_duration, args.manual_boundaries_pass, args.reviewer, ) except Exception as exc: failures.append(str(exc)) streams = flac_probe.get("streams", []) audio_stream = streams[0] if streams else {} flac_contract = { "codec_name": audio_stream.get("codec_name"), "sample_rate": audio_stream.get("sample_rate"), "channels": audio_stream.get("channels"), "duration_seconds": flac_duration, "media_duration_seconds": media_duration, "duration_delta_seconds": abs(flac_duration - media_duration), } flac_contract["pass"] = ( flac_contract["codec_name"] == "flac" and str(flac_contract["sample_rate"]) == "16000" and flac_contract["channels"] == 1 and flac_contract["duration_delta_seconds"] <= 1.0 ) metadata_contract = { "model": result_payload.get("model"), "device": result_payload.get("device"), "compute_type": result_payload.get("compute_type"), "vad_filter": result_payload.get("vad_filter"), } metadata_contract["pass"] = metadata_contract == { "model": "large-v3", "device": "cuda", "compute_type": "float16", "vad_filter": True, } expected_segments_pass = ( args.expected_segments is None or segment_evidence.get("segment_count") == args.expected_segments ) boundary_automatic_pass = all( item["automatic_no_exact_duplicates"] and item["automatic_ordered"] for item in boundaries ) boundary_manual_pass = not boundaries or all( item["manual_readability_pass"] for item in boundaries ) resource_summary = None resource_pass = True if args.resource_summary: resource_summary = json.loads( Path(args.resource_summary).read_text(encoding="utf-8-sig") ) resource_pass = bool(resource_summary.get("resource_gates_pass")) automatic_pass = all( [ not failures, source_unchanged, flac_contract["pass"], metadata_contract["pass"], bool(segment_evidence.get("pass")), expected_segments_pass, boundary_automatic_pass, resource_pass, ] ) status = "PASS" if automatic_pass and boundary_manual_pass else ( "PENDING_MANUAL_BOUNDARY_REVIEW" if automatic_pass and boundaries else "FAIL" ) payload = { "generated_at_utc": datetime.now(timezone.utc).isoformat(), "status": status, "failures": failures, "source": { "before": before, "after": after, "compared_fields": list(source_fields), "unchanged": source_unchanged, }, "media": { "path": str(media), "fingerprint": _fingerprint(media), "probe": media_probe, }, "outputs": { name: {"path": str(path), "size_bytes": path.stat().st_size} for name, path in paths.items() if path.is_file() }, "flac_contract": flac_contract, "metadata_contract": metadata_contract, "segments": segment_evidence, "expected_segments": args.expected_segments, "expected_segments_pass": expected_segments_pass, "boundaries": boundaries, "boundary_automatic_pass": boundary_automatic_pass, "boundary_manual_pass": boundary_manual_pass, "resource_summary": resource_summary, "automatic_pass": automatic_pass, } _write_json(evidence_path, payload, args.replace) return 0 if status == "PASS" else (2 if status == "PENDING_MANUAL_BOUNDARY_REVIEW" else 1) def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) snapshot = subparsers.add_parser("snapshot", help="记录源文件只读指纹。") snapshot.add_argument("--source", required=True) snapshot.add_argument("--output", required=True) snapshot.add_argument("--replace", action="store_true") snapshot.set_defaults(func=_snapshot) validate = subparsers.add_parser("validate", help="生成转写正确性验收证据。") validate.add_argument("--original-source", required=True) validate.add_argument("--source-before", required=True) validate.add_argument("--media", required=True) validate.add_argument("--output-dir", required=True) validate.add_argument("--evidence", required=True) validate.add_argument("--resource-summary") validate.add_argument("--expected-segments", type=int) validate.add_argument("--manual-boundaries-pass", action="store_true") validate.add_argument("--reviewer") validate.add_argument("--replace", action="store_true") validate.set_defaults(func=_validate) return parser def main(argv: Sequence[str] | None = None) -> int: args = _build_parser().parse_args(argv) try: return int(args.func(args)) except Exception as exc: print(f"验收证据生成失败:{exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())