# -*- coding: utf-8 -*- """ C3: B稿v2 ⊕ ASR 交叉复审 → 融合B稿(743行) + fusion_review.csv ============================================================= 职责:逐行复审 B稿(屏幕字幕OCR),以 ASR(口语转写)为上下文参考, 只做纠错,严禁改行数/时间戳。 """ import json import re import sys from pathlib import Path from typing import List, Dict, Optional from .llm import chat # -------------------------------------------------------------------------- # 常量 # -------------------------------------------------------------------------- CHANGE_TYPE_ENUM = frozenset( { "unchanged", "minor_edit", "term_normalize", "rewrite_large", "segment_delete", "segment_add", "editor_typo", } ) SYSTEM_PROMPT = """你是《军事科技》专题片文稿校审员。给你 B稿(屏幕字幕OCR,逐行碎句,带时间戳) 和对应的 ASR(口语转写)。 你的任务:逐行复审 B稿,只做纠错,绝不合并行、不拆行、不增删行、不改时间戳。 权威优先级: - 屏幕术语/型号/番号(箭-3/萨德/见证者-136等): B稿为准(屏幕实打的字) - B稿明显是OCR错字而ASR是对的: 用ASR覆盖 - ⚠️ 专有名词铁律:厂名/型号/番号/国名/人名/机构名等专名,遇B稿与ASR同音异写(如斯泰尔vs斯太尔、美以vs美伊),一律以B稿/A稿书面写法为准,零容忍采ASR。ASR是口语转写,同音字极多,专名绝不信ASR。 - 同音事实错(如"美以"vs"美伊"): 以书面规范为准,存疑进review - 一两个字的等价差异(的/地、啊等语气): 算 unchanged,不要改 每行输出: line_no, final_text(纠错后,默认等于B原文), change_type(7选1), confidence(0~1), reason(简短,unchanged时留空) 只返回JSON数组,不要任何解释文字。 change_type枚举: unchanged/minor_edit/term_normalize/rewrite_large/segment_delete/segment_add/editor_typo""" # -------------------------------------------------------------------------- # 1. 解析带时间戳的行 # -------------------------------------------------------------------------- def parse_timed_lines(path) -> List[dict]: r""" 解析 "[XmYs] 文本" → [{"idx":int, "ts_raw":"0m8s", "ts_sec":8, "text":"导弹呼啸而过"}] 正则: ^\[(\d+)m(\d+)s\]\s*(.*)$ ; ts_sec = m*60+s 解析失败的行要抛异常并打印行号,不许静默跳过 """ p = Path(path) if not p.exists(): raise FileNotFoundError(f"文件不存在: {path}") pattern = re.compile(r"^\[(\d+)m(\d+)s\]\s*(.*)$") lines_raw = p.read_text(encoding="utf-8").splitlines() result = [] for idx, line in enumerate(lines_raw, start=1): line = line.strip() if not line: continue # 跳过空行 m = pattern.match(line) if not m: raise ValueError( f"行 {idx} 解析失败,无法匹配时间戳格式: {repr(line[:120])}\n" f"文件: {path}" ) minutes = int(m.group(1)) seconds = int(m.group(2)) ts_raw = f"{minutes}m{seconds}s" ts_sec = minutes * 60 + seconds text = m.group(3).strip() result.append( { "idx": idx, "ts_raw": ts_raw, "ts_sec": ts_sec, "text": text, } ) return result # -------------------------------------------------------------------------- # 2. 对齐 ASR 上下文 # -------------------------------------------------------------------------- def align_asr_context(b_lines: List[dict], asr_lines: List[dict]) -> List[str]: """ 为每个 B 行找时间窗内的 ASR 上下文(用于喂 LLM) 规则: 取 ts_sec 落在 [b_ts-3, b_next_ts+3] 区间的 ASR 句拼接; 边界用前后各扩 1 句兜底。返回与 b_lines 等长的 context 列表 """ n = len(b_lines) contexts = [] # 预计算 B 行的时间窗: [b[i].ts_sec - 3, b[i+1].ts_sec + 3] # 最后一行用 b[i].ts_sec + 10 作为上界 windows = [] for i, bl in enumerate(b_lines): lo = bl["ts_sec"] - 3 if i + 1 < n: hi = b_lines[i + 1]["ts_sec"] + 3 else: hi = bl["ts_sec"] + 10 windows.append((lo, hi)) asr_count = len(asr_lines) for i, (lo, hi) in enumerate(windows): # 找到落在窗口内的 ASR 句索引 hit_indices = [] for j, al in enumerate(asr_lines): if lo <= al["ts_sec"] <= hi: hit_indices.append(j) if not hit_indices: # 无命中:取距离最近的 1 句 best_j = None best_dist = float("inf") mid_ts = (lo + hi) / 2 for j, al in enumerate(asr_lines): dist = abs(al["ts_sec"] - mid_ts) if dist < best_dist: best_dist = dist best_j = j if best_j is not None: start_j = max(0, best_j - 1) end_j = min(asr_count - 1, best_j + 1) else: start_j = 0 end_j = 0 else: # 命中句的范围 + 前后各扩 1 start_j = max(0, hit_indices[0] - 1) end_j = min(asr_count - 1, hit_indices[-1] + 1) # 拼接 [start_j, end_j] 的 ASR 文本 selected = asr_lines[start_j : end_j + 1] context = " ".join(s["text"] for s in selected) contexts.append(context) assert len(contexts) == n, f"context 列表长度 {len(contexts)} != B 稿行数 {n}" return contexts # -------------------------------------------------------------------------- # 3. 构造 Prompt # -------------------------------------------------------------------------- def build_prompt(batch_b: List[dict], batch_ctx: List[str]) -> List[dict]: """ 构造 messages,见下方"四、Prompt 模板" """ assert len(batch_b) == len(batch_ctx), ( f"batch_b({len(batch_b)}) 与 batch_ctx({len(batch_ctx)}) 长度不一致" ) user_lines = [] for bl, ctx in zip(batch_b, batch_ctx): line_no = bl["idx"] b_text = bl["text"] asr_text = ctx if ctx else "(无ASR上下文)" user_lines.append( f"[行{line_no}] B稿: \"{b_text}\" ASR上下文: \"{asr_text}\"" ) user_content = "\n".join(user_lines) messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_content}, ] return messages # -------------------------------------------------------------------------- # 4. 单批复审 # -------------------------------------------------------------------------- def review_batch( batch_b: List[dict], batch_ctx: List[str], no_ai: bool = False ) -> List[dict]: """ no_ai=True: 直接回填 unchanged(final_text=b原文, change_type="unchanged", confidence=1.0) no_ai=False: 调 llm.chat(messages, thinking=False, max_tokens=4000, temperature=0.0) 解析返回 JSON 数组; 每元素 {line_no, final_text, change_type, confidence, reason} 返回标准化记录列表 """ if no_ai: records = [] for bl in batch_b: records.append( { "line_no": bl["idx"], "final_text": bl["text"], "change_type": "unchanged", "confidence": 1.0, "reason": "", } ) return records # ---- AI 路径 ---- messages = build_prompt(batch_b, batch_ctx) try: raw_response = chat( messages, thinking=False, max_tokens=4000, temperature=0.0, ) except Exception as e: print( f"[fusion_review] LLM 调用失败,回退为 unchanged 批次: {e}", file=sys.stderr, ) # 回退:全部 unchanged records = [] for bl in batch_b: records.append( { "line_no": bl["idx"], "final_text": bl["text"], "change_type": "unchanged", "confidence": 1.0, "reason": f"LLM调用失败回退: {str(e)[:80]}", } ) return records # 解析 JSON parsed = _parse_llm_json_response(raw_response, len(batch_b)) # 标准化并校验 records = [] for item in parsed: line_no = item.get("line_no") final_text = item.get("final_text", "") change_type = item.get("change_type", "unchanged") confidence = item.get("confidence", 1.0) reason = item.get("reason", "") # 校验 change_type if change_type not in CHANGE_TYPE_ENUM: original_ct = change_type print( f"[fusion_review] 行 {line_no} 非法 change_type='{original_ct}', 强制改为 unchanged", file=sys.stderr, ) change_type = "unchanged" final_text = "" # 下面会回填 reason = f"LLM返回非法change_type({original_ct}),回退unchanged" # 如果 change_type 被改为 unchanged 但 final_text 为空,回填 B 原文 if change_type == "unchanged" and not final_text: # 从 batch_b 找回原文 for bl in batch_b: if bl["idx"] == line_no: final_text = bl["text"] break records.append( { "line_no": line_no, "final_text": final_text, "change_type": change_type, "confidence": float(confidence), "reason": reason or "", } ) return records def _parse_llm_json_response(raw: str, expected_len: int) -> List[dict]: """解析 LLM 返回的 JSON,处理 markdown code fences 等常见包装。""" text = raw.strip() # 去掉可能的 markdown code fences if text.startswith("```"): lines = text.splitlines() # 去掉第一行 ```json 或 ``` if lines and lines[0].startswith("```"): lines = lines[1:] # 去掉最后一行 ``` if lines and lines[-1].strip() == "```": lines = lines[:-1] text = "\n".join(lines).strip() try: result = json.loads(text) except json.JSONDecodeError as e: raise ValueError( f"LLM 返回 JSON 解析失败: {e}\n" f"原始响应前 500 字符: {raw[:500]}" ) if not isinstance(result, list): raise ValueError( f"LLM 返回不是 JSON 数组,类型为 {type(result).__name__}" ) if len(result) != expected_len: raise ValueError( f"LLM 返回 {len(result)} 条记录,期望 {expected_len} 条。" f"该批次将回退为 unchanged 并重新请求。" ) return result # -------------------------------------------------------------------------- # 5. 主流程 # -------------------------------------------------------------------------- def run_fusion( episode_id: str, output_dir: str, no_ai: bool = False, batch_size: int = 35, ) -> dict: """ 主流程: 1. 读 output_dir/B稿_v2.txt → b_lines(断言行数>0) 2. 读 output_dir/asr_v2_timed.txt → asr_lines 3. align_asr_context 生成等长 context 4. 按 batch_size 分块;每块结果落缓存,已存在则复用(断点续跑) 5. 逐块 review_batch,汇总所有记录 6. 硬校验(任一不过就 raise,不写出文件) 7. 写 output_dir/融合B稿.txt 8. 写 output_dir/fusion_review.csv 9. 返回统计 dict """ out_dir = Path(output_dir) out_dir.mkdir(parents=True, exist_ok=True) b_path = out_dir / "B稿_v2.txt" asr_path = out_dir / "asr_v2_timed.txt" if not b_path.exists(): raise FileNotFoundError(f"B稿_v2.txt 不存在: {b_path}") if not asr_path.exists(): raise FileNotFoundError(f"asr_v2_timed.txt 不存在: {asr_path}") # Step 1: 解析 B 稿 b_lines = parse_timed_lines(b_path) assert len(b_lines) > 0, f"B稿_v2.txt 解析后为空: {b_path}" # Step 2: 解析 ASR asr_lines = parse_timed_lines(asr_path) # Step 3: 对齐 ASR 上下文 contexts = align_asr_context(b_lines, asr_lines) assert len(contexts) == len(b_lines), ( f"context 长度 {len(contexts)} != B 稿行数 {len(b_lines)}" ) # Step 4: 分块 + 缓存 cache_dir = out_dir / ".c3_cache" cache_dir.mkdir(parents=True, exist_ok=True) all_records = [] total_batches = (len(b_lines) + batch_size - 1) // batch_size for batch_idx in range(total_batches): start = batch_idx * batch_size end = min(start + batch_size, len(b_lines)) batch_b = b_lines[start:end] batch_ctx = contexts[start:end] cache_path = cache_dir / f"batch_{batch_idx}.json" if cache_path.exists(): # 断点续跑:复用缓存 try: cached = json.loads(cache_path.read_text(encoding="utf-8")) print(f"[fusion_review] 复用缓存 batch_{batch_idx} ({len(cached)} 条)") all_records.extend(cached) continue except Exception as e: print( f"[fusion_review] 缓存 batch_{batch_idx} 损坏,重新计算: {e}", file=sys.stderr, ) print( f"[fusion_review] 复审 batch {batch_idx + 1}/{total_batches} " f"(行 {start + 1}-{end})..." ) try: batch_records = review_batch(batch_b, batch_ctx, no_ai=no_ai) except Exception as e: print( f"[fusion_review] batch {batch_idx + 1} 失败,跳过缓存写入: {e}", file=sys.stderr, ) # 不写缓存,下次重跑时重新请求该批 continue # 写入缓存 cache_path.write_text( json.dumps(batch_records, ensure_ascii=False, indent=2), encoding="utf-8", ) all_records.extend(batch_records) # Step 6: 硬校验 _hard_validate(all_records, b_lines) # Step 6.5: 修正语义——final_text 等于 B 原文的行强制归为 unchanged _normalize_unchanged_when_no_edit(all_records, b_lines) # Step 7: 写 融合B稿.txt fused_path = out_dir / "融合B稿.txt" fused_lines = [] for rec, bl in zip(all_records, b_lines): fused_lines.append(f"[{bl['ts_raw']}] {rec['final_text']}") fused_path.write_text("\n".join(fused_lines) + "\n", encoding="utf-8") # Step 8: 写 fusion_review.csv csv_path = out_dir / "fusion_review.csv" csv_rows = [ "line_no,timestamp,b_original,asr_context,final_text,change_type,confidence,reason" ] for rec, bl, ctx in zip(all_records, b_lines, contexts): if rec["change_type"] == "unchanged" and rec["confidence"] >= 0.8: continue # 只写需要 review 的行 # CSV 转义: 字段含逗号或引号时用双引号包裹 row_fields = [ str(rec["line_no"]), bl["ts_raw"], bl["text"], ctx, rec["final_text"], rec["change_type"], str(rec["confidence"]), rec["reason"], ] csv_rows.append(_csv_row(row_fields)) csv_path.write_text("\n".join(csv_rows) + "\n", encoding="utf-8") # Step 9: 统计 stats = { "total_lines": len(b_lines), "change_counts": {}, "review_lines": 0, } for rec in all_records: ct = rec["change_type"] stats["change_counts"][ct] = stats["change_counts"].get(ct, 0) + 1 if ct != "unchanged" or rec["confidence"] < 0.8: stats["review_lines"] += 1 print(f"[fusion_review] 融合完成: 总行数={stats['total_lines']}") print(f"[fusion_review] 各 change_type 计数: {stats['change_counts']}") print(f"[fusion_review] 进 review 行数: {stats['review_lines']}") print(f"[fusion_review] 融合B稿: {fused_path}") print(f"[fusion_review] review CSV: {csv_path}") stats["fused_path"] = str(fused_path) stats["csv_path"] = str(csv_path) return stats def _hard_validate(records: List[dict], b_lines: List[dict]) -> None: """硬校验,任一不过就 raise ValueError,不写出文件。""" # 校验 1: 行数必须相等 if len(records) != len(b_lines): raise ValueError( f"行数不一致: records={len(records)}, B稿={len(b_lines)}" ) # 校验 2: 逐行时间戳不能被动 for i, (rec, bl) in enumerate(zip(records, b_lines)): rec_line_no = rec.get("line_no") expected_line_no = bl["idx"] if rec_line_no != expected_line_no: raise ValueError( f"第 {i} 条记录 line_no={rec_line_no}, 期望 {expected_line_no}" ) # 校验 3: change_type 枚举 for rec in records: ct = rec.get("change_type", "") if ct not in CHANGE_TYPE_ENUM: raise ValueError( f"行 {rec.get('line_no')}: 非法 change_type='{ct}'" ) def _normalize_unchanged_when_no_edit( records: List[dict], b_lines: List[dict] ) -> None: """修正语义:final_text 等于 B 原文的行,强制归为 unchanged。 LLM 有时会把"考虑后决定保留 B 稿"标成 minor_edit, 但实际 final_text == b_original, 这不在留痕范围内。 """ b_text_by_idx = {bl["idx"]: bl["text"] for bl in b_lines} fixed = 0 for rec in records: line_no = rec.get("line_no") b_orig = b_text_by_idx.get(line_no) if b_orig is not None and rec.get("final_text") == b_orig: if rec.get("change_type") != "unchanged": rec["change_type"] = "unchanged" rec["confidence"] = 1.0 rec["reason"] = "" fixed += 1 if fixed: print( f"[fusion_review] 修正 {fixed} 行: final_text==B原文但change_type≠unchanged, 已强制归为 unchanged" ) def _csv_row(fields: List[str]) -> str: """将字段列表格式化为 CSV 行,处理逗号和引号转义。""" escaped = [] for f in fields: s = str(f) if "," in s or '"' in s or "\n" in s: s = s.replace('"', '""') s = f'"{s}"' escaped.append(s) return ",".join(escaped)