Files
tps-dashboard/doco/backup_before_spk/fusion_review.py
T
simonkoson 1c3963d17c doco: v2说话人分段模式 — ASR说话人分离+大block拆分+三维动画解说识别
- asr_adapter: 新增roleType=1说话人分离参数,新增parse_order_result_with_speaker(),write_asr_result自动输出asr_v2_timed_spk.txt

- fusion_align: 新增speaker-aware alignment v2流程(_annotate_b_lines_with_speakers区间匹配、_detect_speaker_blocks、SYSTEM_PROMPT_SPEAKER_ALIGN大block拆分prompt、_build_broadcast_segments支持block内多段拆分)

- cli: 兼容v1/v2 stats字典

- 新增convert_to_md.py(20期融合A稿docx转md+YAML frontmatter)

- backup_before_spk/: 修改前代码备份
2026-06-24 16:26:05 +08:00

536 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)