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/: 修改前代码备份
This commit is contained in:
simonkoson
2026-06-24 16:26:05 +08:00
parent a87f453326
commit 1c3963d17c
14 changed files with 13069 additions and 266 deletions
+352
View File
@@ -0,0 +1,352 @@
# -*- coding: utf-8 -*-
"""
讯飞 ASR 适配层
=================================================
来源: demo 跑通的 xfyun_asr_standard.py
改动: 凭证从环境变量读取,不再硬编码
接口: https://raasr.xfyun.cn/v2/api/upload / getResult
签名: signa = base64(HmacSHA1(MD5(appid + ts), secretKey))
特性:
- 支持热词列表(hotWord),提升专业术语识别率
- 支持军事领域参数(pd=mil)
- 支持顺滑+口语规整(输出更接近书面语)
- 默认语种 cn(中文普通话),免费包标配
凭证来源: 环境变量
- XFYUN_APP_ID
- XFYUN_SECRET_KEY
"""
import base64
import hashlib
import hmac
import json
import os
import re
import sys
import time
import wave
from pathlib import Path
from urllib.parse import quote
from typing import List, Tuple, Optional
import requests
# ========================================================================
# 凭证 — 优先加载 doco/.env(与 llm.py 相同方式)
# ========================================================================
try:
from dotenv import load_dotenv
_doco_env = Path(__file__).resolve().parent.parent.parent / ".env" # doco/.env
if _doco_env.exists():
load_dotenv(str(_doco_env), override=True)
except Exception:
pass
APP_ID = os.environ.get("XFYUN_APP_ID", "").strip()
SECRET_KEY = os.environ.get("XFYUN_SECRET_KEY", "").strip()
if not APP_ID or not SECRET_KEY:
print("[ASR 配置错误] XFYUN_APP_ID 或 XFYUN_SECRET_KEY 未配置或为空", file=sys.stderr)
print("[ASR 配置错误] 请在 doco/.env 中设置这两个环境变量", file=sys.stderr)
print("[ASR 配置错误] 格式: XFYUN_APP_ID=你的appid / XFYUN_SECRET_KEY=你的secret", file=sys.stderr)
sys.exit(1)
# ========================================================================
# 接口配置
# ========================================================================
HOST = "https://raasr.xfyun.cn/v2/api"
UPLOAD_URL = HOST + "/upload"
RESULT_URL = HOST + "/getResult"
# 业务参数
LANGUAGE = "cn" # 中文普通话
PD = "mil" # 军事领域优化
ENG_SMOOTHPROC = "true" # 顺滑(去掉"嗯/那个")
ENG_COLLOQPROC = "true" # 口语规整
# 轮询配置
POLL_INTERVAL_SECONDS = 30
MAX_WAIT_MINUTES = 30
# ========================================================================
# 热词列表(每期节目调用前从 A 稿提取)
# ========================================================================
def get_hot_words(episode_id: str) -> List[str]:
"""
读取 programs/<episode_id>/本期热词表.txt
"|" 切分、strip、去空去重,返回 List[str]。
文件缺失返回 [] 并 stderr 警告(不退出)。
"""
from pathlib import Path as _Path
# doco 项目根 = doco/src/doco/asr_adapter.py → 上3级到达 doco/
_project_root = _Path(__file__).resolve().parent.parent.parent
hotwords_file = _project_root / "programs" / episode_id / "本期热词表.txt"
if not hotwords_file.exists():
print(f"[ASR 热词] 未找到热词表: {hotwords_file},热词跳过", file=sys.stderr)
return []
try:
raw = hotwords_file.read_text(encoding="utf-8")
except Exception as e:
print(f"[ASR 热词] 读取热词表失败: {e}", file=sys.stderr)
return []
# 按 | 切分、strip、过滤空字符串、去重(保持顺序)
words: List[str] = []
seen: set = set()
for token in raw.split("|"):
w = token.strip()
if w and w not in seen:
seen.add(w)
words.append(w)
return words
# ========================================================================
# 签名+工具
# ========================================================================
def make_signa(app_id: str, secret_key: str, ts: str) -> str:
"""
讯飞老版签名:signa = base64(HmacSHA1(MD5(appid + ts), secretKey))
"""
base_string = (app_id + ts).encode("utf-8")
md5_str = hashlib.md5(base_string).hexdigest() # 32位小写hex
mac = hmac.new(
secret_key.encode("utf-8"),
md5_str.encode("utf-8"),
digestmod=hashlib.sha1,
)
signa = base64.b64encode(mac.digest()).decode("utf-8")
return signa
def get_audio_duration_ms(filepath: str) -> int:
"""获取音频时长(毫秒)。WAV用内置,MP3用mutagen。"""
ext = os.path.splitext(filepath)[1].lower()
if ext == ".wav":
with wave.open(filepath, "rb") as wf:
n_frames = wf.getnframes()
sample_rate = wf.getframerate()
duration_ms = int(round(n_frames / sample_rate * 1000))
return duration_ms
if ext == ".mp3":
try:
from mutagen.mp3 import MP3
return int(MP3(filepath).info.length * 1000)
except ImportError:
return 0
raise ValueError(f"不支持的音频格式: {ext}")
# ========================================================================
# 上传
# ========================================================================
def upload_audio(
filepath: str,
hot_words: Optional[List[str]] = None,
) -> str:
"""上传音频,返回 orderId"""
if not os.path.exists(filepath):
raise FileNotFoundError(f"音频文件不存在: {filepath}")
if not APP_ID or not SECRET_KEY:
raise ValueError("请先设置 XFYUN_APP_ID 和 XFYUN_SECRET_KEY 环境变量")
file_size = os.path.getsize(filepath)
file_name = os.path.basename(filepath)
duration_ms = get_audio_duration_ms(filepath)
ts = str(int(time.time()))
signa = make_signa(APP_ID, SECRET_KEY, ts)
# 构建URL参数
params = {
"appId": APP_ID,
"signa": signa,
"ts": ts,
"fileSize": str(file_size),
"fileName": file_name,
"duration": str(duration_ms),
"language": LANGUAGE,
"pd": PD,
"eng_smoothproc": ENG_SMOOTHPROC,
"eng_colloqproc": ENG_COLLOQPROC,
}
# 热词,用 | 分隔
if hot_words:
hot_word_str = "|".join(hot_words)
params["hotWord"] = hot_word_str
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
url = f"{UPLOAD_URL}?{'&'.join(url_parts)}"
headers = {
"Content-Type": "application/json",
}
with open(filepath, "rb") as f:
audio_bytes = f.read()
resp = requests.post(url, headers=headers, data=audio_bytes, timeout=300)
data = resp.json()
if data.get("code") != "000000":
raise RuntimeError(f"上传失败: code={data.get('code')}, desc={data.get('descInfo')}")
order_id = data["content"]["orderId"]
return order_id
# ========================================================================
# 查询结果
# ========================================================================
def query_result(order_id: str) -> dict:
"""单次查询"""
ts = str(int(time.time()))
signa = make_signa(APP_ID, SECRET_KEY, ts)
params = {
"appId": APP_ID,
"signa": signa,
"ts": ts,
"orderId": order_id,
"resultType": "transfer",
}
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
url = f"{RESULT_URL}?{'&'.join(url_parts)}"
resp = requests.post(url, timeout=30)
return resp.json()
def poll_until_done(order_id: str) -> dict:
"""轮询直到完成"""
start_time = time.time()
while True:
elapsed = time.time() - start_time
if elapsed > MAX_WAIT_MINUTES * 60:
raise TimeoutError(f"超过 {MAX_WAIT_MINUTES} 分钟未完成")
data = query_result(order_id)
order_info = data.get("content", {}).get("orderInfo", {})
status = order_info.get("status")
fail_type = order_info.get("failType", 0)
if status == 4:
return data
if status == -1:
raise RuntimeError(f"转写失败: failType={fail_type}, 数据: {data}")
time.sleep(POLL_INTERVAL_SECONDS)
# ========================================================================
# 结果解析
# ========================================================================
def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
"""
解析嵌套JSON,返回 [(sentence_start_ms, sentence_end_ms, text), ...]
"""
if not order_result_str:
return []
cleaned = re.sub(r"\\\\", r"\\", order_result_str)
outer = json.loads(cleaned)
sentences = []
for item in outer.get("lattice", []):
inner_str = item.get("json_1best", "")
if not inner_str:
continue
inner = json.loads(inner_str)
st = inner.get("st", {})
bg = int(st.get("bg", 0))
ed = int(st.get("ed", 0))
words = []
for rt in st.get("rt", []):
for ws in rt.get("ws", []):
for cw in ws.get("cw", []):
w = cw.get("w", "").strip()
wp = cw.get("wp", "n")
if w and wp != "g":
words.append(w)
sentence = "".join(words).strip()
if sentence:
sentences.append((bg, ed, sentence))
return sentences
def format_timestamp(ms: int) -> str:
"""毫秒转 [Nm Ns] 格式"""
total_sec = ms // 1000
return f"{total_sec // 60}m{total_sec % 60}s"
def transcribe(
audio_path: str,
hot_words: Optional[List[str]] = None,
) -> Tuple[List[Tuple[int, int, str]], str]:
"""
完整转写流程:上传 → 轮询 → 解析
返回 (sentences, raw_order_result_json_str)
- sentences: [(start_ms, end_ms, text), ...]
- raw_order_result_json_str: 讯飞原始 orderResult 字段原文(用于断点续跑落盘)
"""
order_id = upload_audio(audio_path, hot_words=hot_words)
result_data = poll_until_done(order_id)
order_result_str = result_data["content"]["orderResult"]
sentences = parse_order_result(order_result_str)
return sentences, order_result_str
def write_asr_result(
sentences: List[Tuple[int, int, str]],
output_dir: str,
raw_order_result: str = "",
) -> Tuple[str, str]:
"""
将 ASR 结果写入文件
返回 (timed_txt_path, raw_json_path)
"""
os.makedirs(output_dir, exist_ok=True)
timed_lines = [f"[{format_timestamp(bg)}] {text}" for bg, _, text in sentences]
timed_path = os.path.join(output_dir, "asr_v2_timed.txt")
with open(timed_path, "w", encoding="utf-8") as f:
f.write("\n".join(timed_lines))
raw_path = os.path.join(output_dir, "asr_result_raw.json")
with open(raw_path, "w", encoding="utf-8") as f:
if raw_order_result:
f.write(raw_order_result)
else:
# 没有原始数据时写空对象(兼容旧调用)
f.write("{}")
return timed_path, raw_path
+782
View File
@@ -0,0 +1,782 @@
# -*- coding: utf-8 -*-
"""
doco CLI 入口
P1: doco split 子命令
P3: doco process 子命令(带 --input-a-draft 和 --cleanup-level)
P3 C1: doco terms 子命令
P3 run: 一键全流程 P1→P2→C1→C2→C3→C4
"""
import click
import shutil
import subprocess
import sys
from pathlib import Path
# P1 相关
from .video_split import split_video, extract_audio
# P3 C1 术语提取
from .term_extract import run_terms
# P3 C2 讯飞 ASR
from .asr_adapter import get_hot_words, transcribe, write_asr_result
# P3 C3 B稿⊕ASR 交叉复审融合
from .fusion_review import run_fusion
# P3 C4 分段对齐 → 融合A稿
from .fusion_align import run_compose, run_skeleton
@click.group()
@click.version_option(version="0.1.0")
def main():
"""TPS 中台 - 终版文稿生成工具"""
pass
@main.command("split")
@click.option(
"--episode-id",
required=True,
help="节目 ID,如 ep001_20260612_fangkong_fandao",
)
@click.option(
"--input-video",
required=True,
type=click.Path(exists=True),
help="输入视频文件路径",
)
@click.option(
"--output-dir",
required=True,
type=click.Path(),
help="输出目录(work/ 路径)",
)
@click.option(
"--hash-algorithm",
default="dhash",
type=click.Choice(["dhash", "phash"]),
help="哈希算法:dhash(默认,对边缘敏感) 或 phash(感知哈希)",
)
@click.option(
"--phash-threshold",
default=2,
type=int,
help="pHash 海明距离阈值(默认 2)",
)
@click.option(
"--dhash-threshold",
default=5,
type=int,
help="dHash 海明距离阈值(默认 5)",
)
@click.option(
"--iou-threshold",
default=0.95,
type=float,
help="IoU 保底阈值:二值化帧间 IoU > 此值视为同字幕(默认 0.95)",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="只抽帧+裁切,不调 OCR API;用于验证裁切框位置是否正确",
)
def split(
episode_id: str,
input_video: str,
output_dir: str,
hash_algorithm: str,
phash_threshold: int,
dhash_threshold: int,
iou_threshold: float,
dry_run: bool,
):
"""
P1: 视频双路拆分
A 路:抽帧 + 空白帧过滤 + 哈希变化检测 + OCR → B 稿 txt
B 路:提取音频(16kHz/单声道/16bit WAV)
使用 --dry-run 可跳过 OCR 调用,先验证裁切框位置:
1. 运行 dry-run
2. 检查 work/frames/ 下的前几张关键帧小图
3. 确认字幕被完整框住后,去掉 --dry-run 跑正式版
"""
video_path = Path(input_video)
out_dir = Path(output_dir)
click.echo(f"[doco split] episode_id={episode_id}")
click.echo(f"[doco split] input_video={video_path}")
click.echo(f"[doco split] output_dir={out_dir}")
click.echo(f"[doco split] hash_algorithm={hash_algorithm}")
click.echo(f"[doco split] phash_threshold={phash_threshold}")
click.echo(f"[doco split] dhash_threshold={dhash_threshold}")
click.echo(f"[doco split] iou_threshold={iou_threshold}")
click.echo(f"[doco split] dry_run={dry_run}")
try:
result = split_video(
video_path=video_path,
output_dir=out_dir,
episode_id=episode_id,
hash_algorithm=hash_algorithm,
phash_threshold=phash_threshold,
dhash_threshold=dhash_threshold,
iou_threshold=iou_threshold,
dry_run=dry_run,
)
if dry_run:
click.echo(f"[ok] 关键帧索引: {result['keyframes_path']}")
click.echo(f"[ok] 音频: {result['audio_path']}")
click.echo(f"[ok] 关键帧数量: {result['keyframe_count']}")
click.echo("[ok] dry-run 完成,请检查 frames/ 目录下的关键帧小图")
else:
click.echo(f"[ok] B 稿: {result['b_manuscript_path']}")
click.echo(f"[ok] 音频: {result['audio_path']}")
click.echo(f"[ok] 关键帧索引: {result['keyframes_path']}")
click.echo(f"[ok] 关键帧数量: {result['keyframe_count']}")
except Exception as e:
click.echo(f"[error] {e}", err=True)
sys.exit(1)
@main.command("process")
@click.option("--episode-id", required=True, help="节目 ID")
@click.option("--input-video", required=True, type=click.Path(exists=True), help="输入视频")
@click.option("--input-a-draft", required=True, type=click.Path(exists=True), help="A 稿 docx")
@click.option("--output-dir", required=True, type=click.Path(), help="输出目录")
@click.option(
"--cleanup-level",
default="medium",
type=click.Choice(["keep_all", "medium", "clean"]),
help="口语清理档位(默认 medium)",
)
def process(
episode_id: str,
input_video: str,
input_a_draft: str,
output_dir: str,
cleanup_level: str,
):
"""
P3: 三方融合全流程
需要 A 稿 + B 稿(本命令调用 split) + ASR 结果,融合输出终版 docx + 差异报告
"""
click.echo("[doco process] P3 全流程暂未实现,请先使用 split 命令")
sys.exit(1)
@main.command("terms")
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
@click.option(
"--a-script",
required=True,
type=click.Path(exists=True),
help="A 稿 txt 文件路径(按纯文本读取)",
)
@click.option(
"--no-ai",
is_flag=True,
default=False,
help="跳过 AI 层提取(Claude),仅使用规则层",
)
def terms(
episode_id: str,
a_script: str,
no_ai: bool,
):
"""
P3 C1: 术语提取 + 累积词典 + 本期热词表
从本期 A 稿提取专有名词 → 更新中台累积词典 → 产出本期热词表(给讯飞 ASR 用)。
两层提取:
A) 规则层(必跑):正则抓型号/番号/兵器名/国名/机构名/人名
B) AI 层(--no-ai 跳过):调 Claude 补抓专名并归类
产物:
- doco/data/term_dict.json(累积词典,幂等更新)
- doco/programs/<episode-id>/本期热词表.txt(| 分隔,最多 200 条)
- doco/programs/<episode-id>/c1_term_candidates.json(三段留痕)
"""
script_path = Path(a_script)
click.echo(f"[doco terms] episode_id={episode_id}")
click.echo(f"[doco terms] A 稿={script_path}")
click.echo(f"[doco terms] no_ai={no_ai}")
try:
result = run_terms(
episode_id=episode_id,
a_script_path=script_path,
no_ai=no_ai,
)
click.echo(f"[ok] 规则候选: {result['rule_count']}")
click.echo(f"[ok] AI 候选: {result['ai_count']}")
click.echo(f"[ok] 合并后: {result['merged_count']}")
click.echo(f"[ok] 词典新增: {result['dict_new_entries']} 条 / 词典共 {result['dict_total']}")
click.echo(f"[ok] 本期热词表: {result['hotword_count']} 条 → {result['hotwords_path']}")
click.echo(f"[ok] 留痕: {result['audit_path']}")
except Exception as e:
click.echo(f"[error] {e}", err=True)
sys.exit(1)
@main.command("fuse")
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
@click.option(
"--output-dir",
default=None,
type=click.Path(),
help="输出目录(默认 programs/<episode-id>/)",
)
@click.option(
"--no-ai",
is_flag=True,
default=False,
help="跳过 LLM 只跑规则层(=全 unchanged)",
)
@click.option(
"--batch-size",
default=35,
type=int,
help="每批送审行数(默认 35)",
)
def fuse(
episode_id: str,
output_dir: str,
no_ai: bool,
batch_size: int,
):
"""
P3 C3: B稿 ⊕ ASR 交叉复审融合
逐行复审 B稿(屏幕字幕OCR),以 ASR(口语转写)为上下文参考,
只做纠错,绝不合并行、不拆行、不增删行、不改时间戳。
--no-ai: 跳过 LLM,全 unchanged(验证管道)
--batch-size: 每批送审行数,默认 35
产物:
- 融合B稿.txt(与 B稿_v2 逐行时间戳一致)
- fusion_review.csv(仅含 change_type≠unchanged 或 confidence<0.8 的行)
"""
if output_dir is None:
out_dir = Path("programs") / episode_id
else:
out_dir = Path(output_dir)
click.echo(f"[doco fuse] episode_id={episode_id}")
click.echo(f"[doco fuse] output_dir={out_dir}")
click.echo(f"[doco fuse] no_ai={no_ai}")
click.echo(f"[doco fuse] batch_size={batch_size}")
try:
stats = run_fusion(
episode_id=episode_id,
output_dir=str(out_dir),
no_ai=no_ai,
batch_size=batch_size,
)
click.echo(f"[ok] 总行数: {stats['total_lines']}")
click.echo(f"[ok] 各 change_type 计数: {stats['change_counts']}")
click.echo(f"[ok] 进 review 行数: {stats['review_lines']}")
click.echo(f"[ok] 融合B稿: {stats['fused_path']}")
click.echo(f"[ok] review CSV: {stats['csv_path']}")
except Exception as e:
click.echo(f"[error] {e}", err=True)
sys.exit(1)
@main.command("skeleton")
@click.option("--episode-id", required=True, help="节目 ID,如 ep002_20260127_qianting_fangsheng")
@click.option("--a-script", required=True, type=click.Path(exists=True), help="A 稿 docx 路径")
@click.option(
"--output-dir",
default=None,
type=click.Path(),
help="输出目录(默认 programs/<episode-id>/)",
)
@click.option(
"--max-tokens",
default=16000,
type=int,
help="LLM max_tokens(默认 16000,长稿可调大)",
)
def skeleton(
episode_id: str,
a_script: str,
output_dir: str,
max_tokens: int,
):
"""
P3 新增: LLM 分段骨架抽取(只产骨架,不跑对齐)
流程:
1. extract_a_paragraphs: 纯 docx 段落样式提取
2. extract_skeleton_llm: LLM 判断分段结构 → JSON 骨架
3. validate_skeleton_coverage: 全覆盖硬校验
4. 落盘 <episode_id>_a_skeleton.json + 打印人类可读预览表
跑完请人工核验骨架预览表(role_label 是否含真人姓名? ignore 是否漏/多?)
确认无误后,再跑 doco compose 完成对齐。
"""
if output_dir is None:
out_dir = Path("programs") / episode_id
else:
out_dir = Path(output_dir)
click.echo(f"[doco skeleton] episode_id={episode_id}")
click.echo(f"[doco skeleton] a_script={a_script}")
click.echo(f"[doco skeleton] output_dir={out_dir}")
click.echo(f"[doco skeleton] max_tokens={max_tokens}")
try:
result = run_skeleton(
episode_id=episode_id,
a_script_path=a_script,
output_dir=str(out_dir),
max_tokens=max_tokens,
)
click.echo(f"[ok] 段落数: {result['total_paras']} (含标题)")
click.echo(f"[ok] 骨架段数: {result['skeleton_count']}")
click.echo(f"[ok] 全覆盖校验: {'通过' if result['coverage_ok'] else '失败'}")
click.echo(f"[ok] 骨架已保存: {result['skeleton_path']}")
click.echo(f"[提示] 请人工确认骨架预览表后,再运行: doco compose --episode-id {episode_id}")
except Exception as e:
click.echo(f"[error] {e}", err=True)
sys.exit(1)
@main.command("asr")
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
@click.option(
"--input-video",
required=True,
type=click.Path(exists=True),
help="输入视频文件路径",
)
@click.option(
"--output-dir",
default=None,
type=click.Path(),
help="输出目录(默认 programs/<episode-id>/)",
)
@click.option(
"--skip-asr",
is_flag=True,
default=False,
help="只分离音频不调讯飞,用于先验证 WAV",
)
def asr(
episode_id: str,
input_video: str,
output_dir: str,
skip_asr: bool,
):
"""
P3 C2: 讯飞 ASR 转写
流程:
1. video_split.extract_audio() 分离 16kHz/单声道/16bit WAV
2. get_hot_words() 读取本期热词表
3. --skip-asr 时到此为止;否则调 transcribe() → write_asr_result()
产物:
- audio_16k.wav(音频)
- asr_v2_timed.txt(带时间戳的转写文本)
- asr_result_raw.json(讯飞原始返回,断点续跑用)
"""
from .asr_adapter import get_audio_duration_ms as _wav_duration
video_path = Path(input_video)
if output_dir is None:
out_dir = Path("programs") / episode_id
else:
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
click.echo(f"[doco asr] episode_id={episode_id}")
click.echo(f"[doco asr] input_video={video_path}")
click.echo(f"[doco asr] output_dir={out_dir}")
click.echo(f"[doco asr] skip_asr={skip_asr}")
# ---- a. 音频分离 ----
wav_path = out_dir / "audio_16k.wav"
if wav_path.exists():
click.echo(f"[doco asr] audio_16k.wav 已存在,复用: {wav_path}")
else:
click.echo("[doco asr] 从视频分离音频(16kHz/单声道/16bit)...")
try:
extract_audio(video_path, wav_path)
click.echo(f"[doco asr] 音频分离完成: {wav_path}")
except Exception as e:
click.echo(f"[error] 音频分离失败: {e}", err=True)
sys.exit(1)
# 打印 WAV 时长
try:
dur_ms = _wav_duration(str(wav_path))
dur_sec = dur_ms / 1000.0
fsize = wav_path.stat().st_size
click.echo(f"[doco asr] audio_16k.wav 大小: {fsize / 1024 / 1024:.1f} MB, 时长: {dur_sec:.1f}s ({dur_ms} ms)")
except Exception as e:
click.echo(f"[doco asr] 无法读取 WAV 时长: {e}")
# ---- b. --skip-asr 时到此为止 ----
if skip_asr:
click.echo(f"[doco asr] --skip-asr 模式,到此为止。WAV: {wav_path}")
return
# ---- c. 热词 ----
hot_words = get_hot_words(episode_id)
click.echo(f"[doco asr] 热词条数: {len(hot_words)}")
# ---- d. 转写 ----
click.echo("[doco asr] 上传音频 → 讯飞 ASR 转写(可能需要数分钟)...")
try:
sentences, raw_order_result = transcribe(str(wav_path), hot_words=hot_words)
except Exception as e:
click.echo(f"[error] ASR 转写失败: {e}", err=True)
sys.exit(1)
timed_path, raw_path = write_asr_result(
sentences,
str(out_dir),
raw_order_result=raw_order_result,
)
# ---- e. 打印摘要 ----
click.echo(f"[ok] 热词条数: {len(hot_words)}")
click.echo(f"[ok] 句子数: {len(sentences)}")
click.echo(f"[ok] asr_v2_timed.txt: {timed_path}")
click.echo(f"[ok] asr_result_raw.json: {raw_path}")
# 模板脚本目录(stage_a_extract_ocr.py / stage_b_dedup_output.py)
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
def _stage_header(title: str):
"""打印阶段分隔线"""
click.echo("═════════════════════════════")
click.echo(title)
click.echo("═════════════════════════════")
@main.command("compose")
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
@click.option(
"--output-dir",
default=None,
type=click.Path(),
help="输出目录(默认 programs/<episode-id>/)",
)
@click.option(
"--no-ai",
is_flag=True,
default=False,
help="跳过 LLM 对齐,按时间均分到各段(仅验证管道)",
)
@click.option(
"--batch-size",
default=40,
type=int,
help="每批送对齐行数(默认 40)",
)
def compose(
episode_id: str,
output_dir: str,
no_ai: bool,
batch_size: int,
):
"""
P3 C4: 融合B稿 + A稿分段骨架 → 融合A稿.docx(公文格式)
AI 唯一职责: 给每行 B 句打段序号,正文一字不改、纯规则拼接。
产物:
- 融合A稿.docx (GB/T 9704 公文格式)
- c4_alignment.csv (分段对齐留痕)
"""
if output_dir is None:
out_dir = Path("programs") / episode_id
else:
out_dir = Path(output_dir)
click.echo(f"[doco compose] episode_id={episode_id}")
click.echo(f"[doco compose] output_dir={out_dir}")
click.echo(f"[doco compose] no_ai={no_ai}")
click.echo(f"[doco compose] batch_size={batch_size}")
try:
stats = run_compose(
episode_id=episode_id,
output_dir=str(out_dir),
no_ai=no_ai,
batch_size=batch_size,
)
click.echo(f"[ok] 总行数: {stats['total_lines']}")
click.echo(f"[ok] 段数: {stats['segment_count']}")
click.echo(f"[ok] 空段数: {stats['empty_segments']}")
click.echo(f"[ok] 低把握段数: {stats['low_confidence_segments']}")
click.echo(f"[ok] 单调修正行数: {stats['audit_forced_lines']}")
click.echo(f"[ok] 融合A稿: {stats['docx_path']}")
click.echo(f"[ok] 留痕 CSV: {stats['csv_path']}")
except Exception as e:
click.echo(f"[error] {e}", err=True)
sys.exit(1)
@main.command("run")
@click.option("--episode-id", required=True, help="节目 ID,如 ep002_20260127_qianting_fangsheng")
@click.option(
"--a-script",
required=True,
type=click.Path(exists=True),
help="A 稿 docx 路径",
)
@click.option(
"--input-video",
required=True,
type=click.Path(exists=True),
help="输入视频 mp4 路径",
)
@click.option(
"--batch-size",
default=25,
type=int,
help="C4 对齐用每批行数(默认 25)",
)
@click.option(
"--skip-p1",
is_flag=True,
default=False,
help="跳过 P1/P2(抽帧+OCR+去重),从 C1 续跑(已有 B稿v2 时)",
)
def run(
episode_id: str,
a_script: str,
input_video: str,
batch_size: int,
skip_p1: bool,
):
"""
一键全流程: P1→P2→C1→C2→C3→C4
串联抽帧+OCR(P1)、文本去重(P2)、术语提取(C1)、ASR 转写(C2)、
融合复审(C3)、对齐出稿(C4) 六个阶段。
用 --skip-p1 可跳过 P1/P2,从 C1 续跑(适用于已有 B稿v2 的场景)。
C4 开始前要求骨架文件已存在(需先手动跑 doco skeleton 并人工核验)。
"""
from .asr_adapter import get_audio_duration_ms as _wav_duration
episode_dir = Path("programs") / episode_id
episode_dir.mkdir(parents=True, exist_ok=True)
video_path = Path(input_video)
a_script_path = Path(a_script)
# 记录已完成阶段,用于失败时打印
completed_stages: list = []
# ── 汇总数据 ──
b_v2_lines = 0
hotword_count = 0
asr_sentence_count = 0
fused_b_lines = 0
fused_a_docx = ""
try:
# ════════════════════════════════════════════════════════
# P1: 抽帧 + OCR
# ════════════════════════════════════════════════════════
if not skip_p1:
_stage_header("P1: 抽帧 + OCR")
stage_a_path = episode_dir / "stage_a_extract_ocr.py"
if not stage_a_path.exists():
src = TEMPLATES_DIR / "stage_a_extract_ocr.py"
click.echo(f"[run] 复制模板: {src}{stage_a_path}")
shutil.copy2(str(src), str(stage_a_path))
click.echo(f"[run] 执行: {sys.executable} {stage_a_path}")
click.echo(f"[run] 工作目录: {episode_dir}")
proc = subprocess.run(
[sys.executable, str(stage_a_path)],
cwd=str(episode_dir),
)
if proc.returncode != 0:
raise RuntimeError(f"P1 stage_a_extract_ocr.py 退出码: {proc.returncode}")
completed_stages.append("P1: 抽帧 + OCR")
# ════════════════════════════════════════════════════════
# P2: 文本去重
# ════════════════════════════════════════════════════════
if not skip_p1:
_stage_header("P2: 文本去重")
stage_b_path = episode_dir / "stage_b_dedup_output.py"
if not stage_b_path.exists():
src = TEMPLATES_DIR / "stage_b_dedup_output.py"
click.echo(f"[run] 复制模板: {src}{stage_b_path}")
shutil.copy2(str(src), str(stage_b_path))
click.echo(f"[run] 执行: {sys.executable} {stage_b_path}")
click.echo(f"[run] 工作目录: {episode_dir}")
proc = subprocess.run(
[sys.executable, str(stage_b_path)],
cwd=str(episode_dir),
)
if proc.returncode != 0:
raise RuntimeError(f"P2 stage_b_dedup_output.py 退出码: {proc.returncode}")
b_v2_path = episode_dir / "B稿_v2.txt"
if not b_v2_path.exists():
raise FileNotFoundError(f"P2 跑完但 B稿_v2.txt 不存在: {b_v2_path}")
completed_stages.append("P2: 文本去重")
# 读 B稿_v2 行数(无论是否 skip_p1,后续步骤都用得到)
b_v2_path = episode_dir / "B稿_v2.txt"
if b_v2_path.exists():
with open(b_v2_path, "r", encoding="utf-8") as fh:
b_v2_lines = sum(1 for line in fh if line.strip())
elif not skip_p1:
raise FileNotFoundError(f"B稿_v2.txt 不存在: {b_v2_path}")
else:
raise FileNotFoundError(
f"使用 --skip-p1 但 B稿_v2.txt 不存在: {b_v2_path}\n"
"请先跑 P1+P2 或确认 B稿_v2.txt 已就绪。"
)
# ════════════════════════════════════════════════════════
# C1: 术语提取
# ════════════════════════════════════════════════════════
_stage_header("C1: 术语提取")
c1_result = run_terms(
episode_id=episode_id,
a_script_path=a_script_path,
no_ai=False,
)
hotword_count = c1_result.get("hotword_count", 0)
click.echo(f"[run] C1 完成: 规则 {c1_result.get('rule_count', 0)} 条, "
f"AI {c1_result.get('ai_count', 0)} 条, "
f"热词 {hotword_count}")
completed_stages.append("C1: 术语提取")
# ════════════════════════════════════════════════════════
# C2: ASR
# ════════════════════════════════════════════════════════
_stage_header("C2: ASR 转写")
asr_timed_path = episode_dir / "asr_v2_timed.txt"
wav_path = episode_dir / "audio_16k.wav"
# 分离音频(已存在则复用)
if wav_path.exists():
click.echo(f"[run] audio_16k.wav 已存在,复用: {wav_path}")
else:
click.echo("[run] 从视频分离音频(16kHz/单声道/16bit)...")
extract_audio(video_path, wav_path)
click.echo(f"[run] 音频分离完成: {wav_path}")
if asr_timed_path.exists():
click.echo(f"[run] asr_v2_timed.txt 已存在,跳过 ASR(花钱的步骤不重复跑): {asr_timed_path}")
else:
hot_words = get_hot_words(episode_id)
click.echo(f"[run] 热词条数: {len(hot_words)}")
click.echo("[run] 上传音频 → 讯飞 ASR 转写(可能需要数分钟)...")
sentences, raw_order_result = transcribe(str(wav_path), hot_words=hot_words)
asr_sentence_count = len(sentences)
timed_path, raw_path = write_asr_result(
sentences,
str(episode_dir),
raw_order_result=raw_order_result,
)
click.echo(f"[run] ASR 完成: {asr_sentence_count}")
# 如果跳过了 ASR(已存在),读取句子数用于汇总
if asr_sentence_count == 0 and asr_timed_path.exists():
with open(asr_timed_path, "r", encoding="utf-8") as fh:
asr_sentence_count = sum(1 for line in fh if line.strip())
completed_stages.append("C2: ASR")
# ════════════════════════════════════════════════════════
# C3: 融合复审
# ════════════════════════════════════════════════════════
_stage_header("C3: 融合复审")
c3_stats = run_fusion(
episode_id=episode_id,
output_dir=str(episode_dir),
no_ai=False,
batch_size=35,
)
fused_b_lines = c3_stats.get("total_lines", 0)
click.echo(f"[run] C3 完成: 融合B稿 {fused_b_lines}")
completed_stages.append("C3: 融合复审")
# ════════════════════════════════════════════════════════
# C4: 对齐出稿
# ════════════════════════════════════════════════════════
_stage_header("C4: 对齐出稿")
# 检查骨架文件
skeleton_path = episode_dir / f"{episode_id}_a_skeleton.json"
if not skeleton_path.exists():
raise FileNotFoundError(
f"骨架文件不存在: {skeleton_path}\n"
f"骨架需人工核验,请先手动运行: doco skeleton --episode-id {episode_id} "
f"--a-script {a_script}\n"
f"核验无误后,再运行 doco run。"
)
c4_stats = run_compose(
episode_id=episode_id,
output_dir=str(episode_dir),
no_ai=False,
batch_size=batch_size,
)
fused_a_docx = c4_stats.get("docx_path", "")
click.echo(f"[run] C4 完成: 融合A稿 → {fused_a_docx}")
completed_stages.append("C4: 对齐出稿")
except Exception as e:
click.echo("")
click.echo("═════════════════════════════")
click.echo("❌ 流程中断")
click.echo("═════════════════════════════")
if completed_stages:
click.echo("已完成的阶段:")
for s in completed_stages:
click.echo(f"{s}")
click.echo(f"失败阶段: {e}", err=True)
sys.exit(1)
# ── 全部完成 ──
click.echo("")
_stage_header("✅ 全流程完成")
click.echo(f"B稿v2: {b_v2_lines}")
click.echo(f"热词: {hotword_count}")
click.echo(f"ASR: {asr_sentence_count}")
click.echo(f"融合B稿: {fused_b_lines}")
click.echo(f"融合A稿: {fused_a_docx}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+535
View File
@@ -0,0 +1,535 @@
# -*- 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)