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
+42 -33
View File
@@ -16,8 +16,8 @@
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
- **最后更新**Claude Code 2026-06-22(深夜,收摊)
- **当前状态一句话****ep002(潜艇仿生)全流程 P1→C4 完工**,已出稿 + 通哥手动批改分段。**ep004 上游仍全空**(只有骨架),下一期待跑。C4 对齐层 MiMo 批次失败率偏高(47%),ep004 段切换密需留意
- **最后更新**Claude Code 2026-06-24
- **当前状态一句话****20 期全部出稿完成(16/16 批量跑零失败)。** doco 流水线验证通过。下一步:通哥逐期核验分段 → doco 收工 → 带 20 期成品回 TPS 主项目知识库
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
---
@@ -63,28 +63,33 @@
- `src/doco/asr_adapter.py` — C2 讯飞 ASR 适配层(已完工):`get_hot_words(episode_id)` 读热词表、`transcribe()` 返回 `(句子, raw)``write_asr_result()``asr_v2_timed.txt` + `asr_result_raw.json`
- `src/doco/video_split.py` — P1:含 `extract_audio()`16kHz/单声道/16bit WAV),C2 直接复用,不另写 ffmpeg
- `src/doco/fusion_review.py` — C3:B稿v2 ⊕ ASR 交叉复审 → 融合B稿(743行) + fusion_review.csv
- `src/doco/cli.py` — 命令入口(split / terms / asr / fuse
- `doco/data/term_dict.json` — 累积词典(当前 110 条
- `src/doco/cli.py` — 命令入口(split / terms / asr / fuse / compose / skeleton / **run**
- `src/doco/templates/` — P1/P2 stage 脚本模板(`doco run` 自动复制到 episode 目录
- `doco/data/term_dict.json` — 累积词典(当前 267+ 条)
- `doco/programs/<episode_id>/` — 每期产物(热词表、各阶段输出)
- **常用命令**
- C1(已实现):`doco terms --episode-id <id> --a-script <path> [--no-ai]`
- C2(已实现):`doco asr --episode-id <id> --input-video <绝对路径mp4> --output-dir <绝对路径 doco/programs/<id>>``--skip-asr` 只分离不转写)。**务必传绝对 `--output-dir`,否则落到当前工作目录的 `programs/` 会与 doco 产物分家。**
- C3(已实现):`doco fuse --episode-id <id> [--no-ai] [--batch-size 35]`
- C4:待实现
- C4(已实现):`doco compose --episode-id <id> [--batch-size 25]`
- 骨架:`doco skeleton --episode-id <id> --a-script <docx>`(需人工核验后再跑 compose
- **一键全流程**`doco run --episode-id <id> --a-script <docx> --input-video <mp4> [--batch-size 25] [--skip-p1]`
- **批量跑**`python _batch_run.py`(16 期自动串跑,断点续跑,已有产物自动跳过)
- **环境变量 / 密钥**:只放 `doco/.env`(已在 `.gitignore`)。需要的 key`LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`(已切换到小米 MiMo 2.5 Pro)、`XFYUN_APP_ID` / `XFYUN_SECRET_KEY`。**代码与对话里只引用变量名,绝不出现真值。**
---
## 3. 当前进度(核心交接区,以最新快照为准)
- **已完成至**ep001 + ep003 + **ep002** 三期全流程跑通。ep002 C4 已出稿,通哥手动批改分段完毕
- **正在做**:无。
- **卡点 / 待解**ep004(枪王对决)上游全空,需跑完整 P1→C4。C4 对齐层 MiMo 批次失败率 47%ep002 实测),ep004 段切换更密,需关注(见关键决策)
- **已完成至**ep001-004 四期全流程出稿。一键脚本 `doco run` + 批量脚本 `_batch_run.py` 已就绪。16 期骨架全部生成并通过核验。C3 prompt 已收紧(专名铁律)。C4 batch_size 已降至 25
- **正在做**:无(等通哥逐期核验分段标签)
- **卡点 / 待解**20 期出稿已全部产出,通哥核验分段后 doco 收工,带成品回 TPS 主项目
---
## 4. 已完成(只追加,最新在最上)
- [2026-06-23 Claude Code] **批量化基础设施完工 + ep004 全流程通过 + 16 期批量跑启动**。本 session 完成:① ep004(枪王对决,最难·小剧场密·58段)全流程 P1→C4 一次通过,C4 punct_ok 58/58 全过、confidence 全 ≥0.80batch_size=25 效果显著,无批次回退);② 新增 `doco run` 一键全流程命令(Cline 实现,Opus 审核),参数 `--episode-id / --a-script / --input-video / --batch-size / --skip-p1`ep004 `--skip-p1` 验证通过;③ C3 SYSTEM_PROMPT 收紧专名铁律(防 ASR 同音字替换权威 B稿专名);④ Ollama 并发提至 16 路(`OLLAMA_NUM_PARALLEL=16`,8 路实测 GPU 96%、显存充裕);⑤ 16 期新目录建立 + 文件拷贝 + 骨架批量生成并集中核验(真名零泄露、隔断/ignore 正确);⑥ `_batch_run.py` 批量脚本启动跑 ep005-ep020。**doco PRD 完成标准已与通哥确定**:20 期全出稿 → 带成品回 TPS 主项目知识库批量导入。
- [2026-06-22 晚|Claude Code] **ep002 C4 审核完毕,全流程收工**。Cline 跑完 C4 compose 出稿 `20260127潜艇的仿生之路_穆佩弦_融合A稿.docx`。Opus 审核核验:硬校验(汉字零改)全过、733 行全覆盖、标点回退 0 段。**但发现两个问题**:① Cline 自报空段名称错了两个(报"解说3/解说6",实际是"三维动画解说3/解说8")、隔断数也报错(报 3 个,实际 4 个)——再次印证不信 Cline 自检;② **9/19 批次(47%LLM 对齐失败走回退**(全 confidence=0.30),本次因失败批次恰在大段中间未翻车,但属黄色预警。**分段偏差根因**:A 稿是拍摄前剧本,专家采访段与实际播出内容差异巨大(A 稿一句话提纲 vs 专家自由发挥两分钟),LLM 无法正确匹配——这是信息不对应,非算法问题。通哥手动批改分段(~10 处高亮标签),内容本身正确。**结论(通哥拍板)**:治本靠编导给贴近播出版的稿子,现阶段接受"程序保证文字零改 + 编导手调分段"的模式。
- [2026-06-22 Claude Code] **ep002 C3 融合通过**。融合B稿 733行(零偏移),review 10条(5 minor_edit 全是 OCR 错字修正,专名无同音替换)。C4 compose 交 Cline 跑中。
- [2026-06-22 Claude Code] **ep002(潜艇仿生)P1→P2→C1→C2 完成**。P1 全量抽帧 1620 帧(不做 dHash/IoU 去重,全部进 OCR);P2 OCR 4路并发首秀通过(~50帧/分钟,约串行3倍),Stage B 文本去重出 B稿v2 733行;C1 术语提取 68 条热词(词典累积至 235 条);C2 讯飞 ASR 411 句。下一步 C3 fuse。
@@ -107,13 +112,15 @@
- [x] **C3**:B稿v2 ⊕ ASR 交叉复审 → 融合B稿(743行时间戳零偏移) + fusion_review.csv(5条留痕)。
- [x] **C4(全部收口)**:融合B稿按 A稿28段对齐归拢 → 融合A稿 docx(公文格式)。汉字零改 + LLM 按语义加标点,红线 28/28 段逐字一致。`doco compose --episode-id <id>`。**OCR 漏字决定不补**(讨论见关键决策);seg8/seg27 两段标点接受一逗到底。
- [x] **A稿解析器升级**:正则 → LLM分段骨架+人工确认+ignore合并;三期骨架已产并核验通过。`doco skeleton`
- [~] **批量化首批三期·跑完整流水线(下一步主线)**ep001 + ep003 + ep002 三期已全流程完工。**ep004 上游仍全空**(只有骨架),需跑完整 P1→C4。stage 脚本从 ep003 复制
- [x] **批量化 ep001-004 全流程完工**:四期均已出稿。ep004(最难·小剧场密·58段)C4 全过,batch_size=25 效果显著
- [x] **【已完成】标点层 prompt 修复**:加第5条规则+示范,ep003 逗号占比 88%→76%commit `fd2ef1a`
- [x] **【已完成】配 Ollama 并发**`OLLAMA_NUM_PARALLEL=4`ep002 OCR 4路实跑通过,~50帧/分钟
- [ ] **backlog】C4 对齐层 batch_size 调优**ep002 实测 batch_size=40 导致 47% 批次 MiMo 返回空/JSON 截断走回退。ep004 段切换更密,建议降到 25-30。Cline 在 `_run_compose.py` 里加 `--batch-size 25` 即可
- [x] **【已完成】配 Ollama 并发**已从 4 路提至 16 路(`OLLAMA_NUM_PARALLEL=16`),8 路实测 GPU 96%、显存充裕
- [x] **已完成】C4 对齐层 batch_size 调优**已降至 25ep004 实测 confidence 全 ≥0.80,无批次回退。批量脚本 `_batch_run.py` 固定用 25
- [ ] **【产品 backlog】原稿缺【专家N】/【三维动画N】标签**:ep003 复查发现编导原稿里就没有专家、后续三维动画的分段标注,骨架如实反映=融合A稿里也没有(通哥手工补了高亮)。不赖程序(原稿零信号,LLM 与人一样猜不准),**不让程序猜**。治本=提醒编导/责编原稿把【专家N】等段头标全;50 期前在导入须知里写清。
- [ ] **backlog】C3 prompt 收紧**专有名词(厂名/型号/番号)遇 ASR 同音异写,必须以 B稿v2/A稿为准、不许采 ASR——ep003 已踩"斯泰尔→斯太尔",50 期批量前在 prompt 里收紧防复发
- [ ] **串一键脚本**:把 C1→C2→C3→C4 串成"一条命令跑一期"(薄壳,各阶段已有断点缓存),验 2-3 期稳定后再谈监控文件夹/绑定界面
- [x] **已完成】C3 prompt 收紧**`fusion_review.py` SYSTEM_PROMPT 新增专名铁律——厂名/型号/番号/国名/人名/机构名遇 B稿与 ASR 同音异写,一律以 B稿为准,零容忍采 ASR
- [x] **【已完成】串一键脚本**`doco run` 命令(P1→P2→C1→C2→C3→C4 六阶段串联),ep004 验证通过。模板脚本放 `src/doco/templates/`
- [x] **批量跑 20 期(完成)**ep001-004 + ep005-ep020 共 20 期全部出稿,16 期批量跑零失败(847 分钟),B稿行数 699-874,融合B稿行数全部零偏移。
- [ ] **doco 收工后回 TPS 主项目**:20 期融合A稿批量导入知识库(走 Phase 3 已有的上传/embedding 链路),为 Phase 4a 语义搜索提供冷启动数据。
- [x] **【已修复】`fusion_align.py` align_batch 崩溃 bug**`_parse_align_json` 调用在 `try/except` 之外,LLM 返回空字符串时进程崩溃。Cline 已修,移入 try/except 内,错误时优雅回退。
- [ ] **骨架小瑕疵 backlog(不拦路,后期顺手或跳过)**:ep002 演播室主持人 vs 主持人 是否统一;ep004 小剧场角色编号乱(斯9/斯通纳10、卡8跨场)直接改 JSON 即可,不必重调 LLM;ep003 行内`【固摇轨】`留在参照正文(无害,不进输出)。
- [x] **C2** 讯飞 ASR 适配层(密钥外置 + asr_adapter 并入 + `doco asr` 命令 + 真转写310句)
@@ -125,6 +132,9 @@
## 6. 关键决策(为什么这么做 — 跨 session 最易丢,别推翻)
- [2026-06-23] **doco PRD 完成标准:20 期全出稿 → 带成品回 TPS 主项目。** ep004 跑通 + 一键脚本就绪 = 流水线可用;剩余 16 期在 doco 子项目内批量跑完(一方面压力测试积累问题,一方面 20 期成品对 TPS 知识库冷启动有价值)。回归路径:融合A稿作为 manuscript 类文档 → 走主项目 Phase 3 知识库上传/embedding 链路 → Phase 4a 语义搜索。
- [2026-06-23] **C4 batch_size=25 为量产默认值。** ep004 实测 58 段全过(confidence 全 ≥0.80punct_ok 58/58),相比 ep002 的 batch_size=4047% 批次回退)大幅改善。`_batch_run.py``doco run` 默认均为 25。
- [2026-06-23] **Ollama OCR 并发提至 16 路。** 8 路实测 GPU 利用率 96%4090D 24GB 显存充裕),16 路为批量跑默认值。环境变量 `OLLAMA_NUM_PARALLEL=16`(系统级)+ `OCR_NUM_WORKERS=16`(批量脚本内设)。
- [2026-06-22] **C4 分段偏差的根因是 A 稿与播出版内容差异大,非算法问题,现阶段不追技术方案。** ep002 实证:专家采访段 A 稿只有一句话提纲,实际播出专家自由发挥两分钟,内容/角度/篇幅全变,LLM 拿提纲匹配实录当然对不上。解说段偏差轻,基本能对。**B 稿(唱词字幕 OCR)里没有隔断标题**(隔断是画面美术字不走字幕轨道),所以也没法用隔断做硬分界线。**通哥拍板**:治本靠编导给贴近播出版的稿子(尤其专家段录完后更新);差异大的期接受"程序保证文字零改 + 编导花十几分钟手调分段标签"。
- [2026-06-22] **MiMo 2.5 Pro C4 对齐层批次失败率偏高(ep002 实测 9/19=47%)。** 失败批次全 confidence=0.30,回退机制把该批所有行分配给 `min_normal_seg_id`。ep002 运气好——失败批次全在大段中间,回退恰好分对了。但 ep004 段切换密,边界批次失败会导致行归错段。**建议 ep004 前把 batch_size 从 40 降到 25-30**,或排查 MiMo 返回空/JSON 截断的具体原因。
- [2026-06-18] **A稿段头识别改用「LLM 判结构 + 人工核骨架」,不用死正则。** 编导写法千变万化(方括号/冒号/前缀杂质`Xr`/`【固摇轨】`镜头标记/小剧场对话头),正则分不清`【固】`(镜头)与`【主持人1】`(段头)——这是语义判断,交 LLM。**铁律:LLM 只输出结构(type/role_label/para 区间),绝不复述正文;正文一律代码按下标从 docx 原样抽**(护住"汉字零改"红线)。配两道闸:① `validate_skeleton_coverage` 全覆盖硬校验(title 后每段恰好被覆盖一次,自动抓 LLM 数错下标/JSON 截断);② 人类预览表给制片人肉眼核(重点抓真人姓名泄露/ignore 漏多/隔断认全)。`doco compose` 无骨架即报错(ep001 例外走正则)。
@@ -158,27 +168,25 @@
## 7. ⏩ 交接备注(写给下一个接手的 session)
> 下次开工读完这段应能 0 摩擦续上。接手后可清空重写。**(2026-06-22 深夜重写)**
> 下次开工读完这段应能 0 摩擦续上。接手后可清空重写。**(2026-06-23 重写)**
- **大局**ep001 + ep003 + ep002 三期已全流程跑通出稿。**ep004(枪王对决)上游全空**,是下一期主线
- **ep002 收尾状态**:全流程完工。出稿 `融合A稿.docx` 硬校验全过(汉字零改),通哥已手动批改分段(`融合A稿_批改.docx`)。分段偏差根因是 A 稿与播出版内容差异大(见关键决策),非程序 bug,现阶段接受编导手调
- **ep004 现状**`ep004_20260526_qiangwang_duijue`(枪王对决,最难·小剧场密)。只有 A稿 docx + mp4 + skeleton。需跑完整 P1→P2→C1→C2→C3→C4。stage 脚本从 ep003 复制
- **第一句话该干的**:与通哥确认是否开跑 ep004。ep004 小剧场多、段切换密,C4 对齐层可能更吃力(ep002 已有 47% 批次回退),**建议降 batch_size 到 25-30**
- **LLM 已切换**:代码和 Cline 都已从 DeepSeek 切到**小米 MiMo 2.5 Pro**。环境变量名 `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`(旧 `DEEPSEEK_*` 已废弃)
- **Ollama 并发已配**`OLLAMA_NUM_PARALLEL=4`,ep002 实测 ~50帧/分钟(串行的3倍)。4090D 显存余量大(GPU 利用率仅 2%),ep004 可考虑提到 8 路
- **P1 抽帧硬约束****不做 dHash/IoU 去重**,全部帧进 OCR,去重交 Stage B 按文本做(P2 完工快照铁律)。`doco split` 的去重逻辑是过时的,不要用。
- **大局**doco 子项目进入收尾阶段。ep001-004 四期全流程已出稿验证通过。16 期新节目(ep005-ep020)正在 `_batch_run.py` 批量跑中。20 期全部出稿后,doco 收工,融合A稿作为知识库资源带回 TPS 主项目
- **批量跑状态**`python _batch_run.py` 在独立终端跑中。脚本有断点续跑能力(B稿v2 存在则跳过 P1+P2,ASR 已有则跳过转写,融合B稿已有则跳过 C3,融合A稿已有则跳过 C4)。中断后直接重跑即可
- **第一句话该干的**:看 `_batch_run.py` 跑完没有。跑完看汇总表,有失败的排查原因重跑。全部成功后,逐期打开融合A稿 docx 核验分段标签,分段偏差的地方手调
- **LLM**:小米 MiMo 2.5 Pro,环境变量 `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`
- **Ollama**16 路并发(`OLLAMA_NUM_PARALLEL=16`),4090D 24GB 充裕
- **关键参数**C4 batch_size=25`_batch_run.py` 硬编码),C3 batch_size=35
- **量产坑(务必记住)**
1. 每期热词/术语不同,**C1 每期重跑**;A稿分段编号各有各的重复/空格脏,**按出现顺序走、别假设单调唯一**
2. **C3 fuse 后必扫 `fusion_review.csv`**:专名(厂名/型号/番号)若被 ASR 同音字改了要打回(ep003 踩过"斯泰尔→斯太尔",见关键决策)
3. **C4 出现空段先别当 bug**:多半是播出剪了那段戏,去融合B稿 grep 该段专属词确认(见关键决策)
4. **C4 分段偏差是常态(A 稿≠播出版)**:内容正确但分段标签可能偏,编导手调十几分钟搞定。不追技术方案(见关键决策)
5. **OCR 漏字本期不补**(通哥拍板)——根在抽帧/OCR 漏两三字单屏,LLM 补词=破红线
6. **ASR 花讯飞额度**;长转写/OCR 放独立终端别让 Cline 盯
7. Cline 会偷换模型/术语源、误判字数、口头猜错根因——**自报结果一律对源数据核验**(ep002 C4 报告空段名称错两个、隔断数也报错)
8. **thinking 分任务用**:语义对齐/骨架开、标点/抽取/融合关(见关键决策)
9. **MiMo C4 对齐批次失败率高**ep002 47%),ep004 前考虑降 batch_size(见关键决策)
- **出稿命名**`{原始A稿stem}_融合A稿.docx`(不覆盖原始定稿)。
- **字体坑(C4 出稿)**:大标题方正小标宋_GBK(商业字体),出稿前确认已装,否则脚本回退。
1. **C3 fuse 后必扫 `fusion_review.csv`**:专名被 ASR 同音字替换要打回。C3 prompt 已收紧专名铁律,但仍需人工复核
2. **C4 空段 = 播出剪了那段**,不是 bug
3. **C4 分段偏差是常态**(A 稿≠播出版),编导手调分段标签即可
4. **OCR 漏字不补**(通哥拍板),LLM 补词=破红线
5. Cline 自报结果一律对源数据核验,不轻信
6. thinking 分任务用:骨架/对齐开,标点/抽取/融合关
- **出稿命名**`{原始A稿stem}_融合A稿.docx`
- **字体坑**:大标题方正小标宋_GBK(商业字体),出稿前确认已装
- **doco 收工后的路**:20 期成品 → TPS 主项目知识库批量导入(走 Phase 3 上传/embedding 链路)→ Phase 4a 语义搜索提供冷启动数据。主项目待办三选一中的"200+ Obsidian md 批量录入"正好是接收口
- **工具脚本清单**(一次性,跑完可删):`_setup_episodes.py`(建目录+拷文件)、`_batch_skeleton.py`(批量生成骨架)、`_batch_run.py`(批量全流程)。
---
@@ -187,5 +195,6 @@
- ~~**B稿_v2.txt 就位**~~:已完成(通哥已拷进 episode 目录并随 9340edc 入库)。
- ~~**讯飞旧 key 轮换**~~:已完成(新值已在 `doco/.env`,旧 appid `84eff996` 代码内无残留)。
- **方正小标宋字体可用性**:C4 出稿前确认两台机器是否已装。
- ~~**OCR 并发提速**~~:已完成,4路并发实测通过(ep002),后续可提到 8 路
- ~~**OCR 并发提速**~~:已完成,16 路并发(4090D 24GB 充裕)
- **讯飞单源依赖**(远期):是否接阿里云 ASR 做备份/交叉验证。
- **ep016 骨架的宣传词被标为 break**:其他期(ep006/13)宣传词标为 normal,需通哥确认 ep016 是否应改。
+164 -113
View File
@@ -1,175 +1,226 @@
# Doco - TPS 中台终版文稿生成子模块
# Doco - TPS 工作台 · 终版文稿生成子模块
> 央视《军事科技》栏目 - 终版文稿自动生成流水线
> 央视《军事科技》栏目 - 终版文稿自动融合流水线
## 项目状态
**当前 Phase: P1** - 视频双路拆分预处理
**✅ 20期全部出稿完成,流水线验证通过。** 16/16批量跑零失败(847分钟)。下一步:制片人逐期核验分段标签 → 带成品回归 TPS 主项目知识库。
---
## 功能概述
Doco 将一期《军事科技》节目视频拆分为两路输入,供下游三方融合(P3)使用:
《军事科技》每期节目播出后,需要产出一份最接近实际播出的终版文稿。过去靠人工核对,**单期 4-6 小时**。
| 输出 | 规格 | 存放位置 |
Doco 把**同一期节目**的三个文本来源自动融合,产出终版文稿:
| 文本来源 | 说明 | 权威范围 |
|---|---|---|
| B 稿 | 带时间戳的 txt,`[Nm Ns] 句子`格式 | `work/b_manuscript.txt` |
| 音频 | 16kHz / 单声道 / 16bit WAV | `work/audio_16k.wav` |
| 关键帧索引 | JSON | `work/keyframes.json` |
| **A稿**(编导定稿) | 编导剧本的书面结构与分段 | 段落骨架、专业术语规范写法 |
| **B稿v2**(屏幕字幕 OCR) | 视频画面中"黑底白字"字幕的OCR识别结果 | 屏幕术语/型号/番号(≈A稿并列权威) |
| **ASR**(口语转写) | 音轨经讯飞转写的口语实录 | 实际语音、语气、临场措辞 |
**铁律**:正文**汉字零改**——所有正文内容100%来自B稿v2,AI只负责纠错OCR错字、语义对齐分段、按语义插入标点,绝不改任何一个汉字。
最终产出两个**内容一致、形态不同**的交付物:
| 交付物 | 给谁 | 形态 |
|---|---|---|
| **融合B稿** | 爱德华(字幕/片段定位) | 碎句 + 密集字幕级时间戳 `[XmYs] 文本` |
| **融合A稿** | 编导存档 | 公文格式 docx,保留【导视】【主持人N】【解说N】【专家N】【隔断】分段 |
一致性约束:融合A稿**由融合B稿生成**(按A稿分段归拢 + 套格式),不是事后比对硬凑。
---
## 六阶段流水线架构
```
A稿 docx ──► ① 术语提取(C1) ──► 本期热词表
视频 mp4 ──► ② 音频分离 ──► 讯飞ASR(C2) ──► ASR文本(带时间戳)
(黑底白字+ │ │
干净人声) 抽帧+OCR(P1)──► 文本去重(P2)──► B稿v2(碎句+时间戳)
B稿v2 ⊕ ASR ──► ③ 交叉复审(C3) ──► 融合B稿
融合B稿 + A稿骨架 ──► ④ 语义对齐(C4) ──► 融合A稿.docx
```
| 阶段 | 子命令 | 做什么 | 产物 |
|---|---|---|---|
| **P1** | `doco split` | ffmpeg抽帧 + OCR识别屏幕字幕 | 关键帧、音频WAV |
| **P2** | (模板脚本自动) | 字幕文本去重、格式化 | B稿v2.txt(约700-870行) |
| **C1** | `doco terms` | 从A稿提取专有名词 → 累积词典 → 热词表 | 本期热词表(给ASR用) |
| **C2** | `doco asr` | 音频分离 → 讯飞ASR转写 | asr_v2_timed.txt |
| **C3** | `doco fuse` | B稿⊕ASR 交叉复审,AI纠错 | 融合B稿.txt + fusion_review.csv |
| **C4** | `doco compose` | 按A稿分段骨架语义对齐 → 套公文格式 | 融合A稿.docx + c4_alignment.csv |
---
## 系统依赖
### ffmpeg (必须)
### ffmpeg必须
**Windows 用户:**
1. 从 https://www.gyan.dev/ffmpeg/builds/ 下载 ffmpeg (建议用 essentials 版本)
2. 解压到本地目录(`C:\ffmpeg`)
**Windows 用户**
1. 从 https://www.gyan.dev/ffmpeg/builds/ 下载 ffmpeg建议用 essentials 版本
2. 解压到本地目录`C:\ffmpeg`
3.`C:\ffmpeg\bin` 加入系统 PATH
4. 打开 cmd,验证: `ffmpeg -version`
4. 打开 cmd验证`ffmpeg -version`
**Mac 用户:**
**Mac / Linux 用户**
```bash
# Mac
brew install ffmpeg
```
**Linux 用户:**
```bash
# Linux
apt install ffmpeg
```
### Python >= 3.12
---
## 安装
```bash
# 1. 克隆仓库后进入 doco 目录
# 1. 进入 doco 目录
cd doco
# 2. 安装依赖
# 2. 安装(可编辑模式)
pip install -e .
# 3. 配置凭证
# 3. 配置凭证(见下节)
cp .env.example .env
# 编辑 .env,填入三组 API 凭证
```
## 凭证配置
Doco 使用三组独立凭证,互不混用:
`doco/.env` 中配置以下变量(**已在 .gitignore 中,不会入库**):
| 服务 | 用途 | 申请地址 |
|---|---|---|
| 讯飞开放平台 - 录音文件转写(标准版) | 音频转文字 | https://console.xfyun.cn/ |
| DeepSeek Vision | OCR 识别 | https://platform.deepseek.com/ |
| Anthropic Claude API | AI 融合层(P3) | https://console.anthropic.com/ |
| 变量名 | 用途 |
|---|---|
| `LLM_API_KEY` | LLM融合层API密钥(当前用小米 MiMo 2.5 Pro |
| `LLM_BASE_URL` | LLM API地址(OpenAI兼容端点) |
| `LLM_MODEL` | 模型名称(如 `mimo-v2.5-pro` |
| `XFYUN_APP_ID` | 讯飞开放平台 APP ID |
| `XFYUN_SECRET_KEY` | 讯飞开放平台 SECRET KEY |
> 注意: 讯飞要用"录音文件转写标准版",不要用"大模型版"
> ⚠️ 讯飞要用录音文件转写**标准版**」,不要用"大模型版"(免费包阉割 `language` 参数,会报误导性错误)。
## 使用步骤(按顺序,不要跳步)
---
### Step A. 安装 ffmpeg
## 使用方式
见上方"系统依赖"一节。安装后打开 cmd 验证 `ffmpeg -version` 能看到版本号。
### Step B. 生成迷你测试视频(验证 ffmpeg 装好了)
### 一键全流程(推荐)
```bash
ffmpeg -f lavfi -i testsrc=duration=5:size=320x240:rate=1 \
-f lavfi -i anullsrc=channel_layout=mono:sample_rate=16000 \
-c:v libx264 -c:a aac -shortest \
doco/tests/fixtures/mini_test.mp4 -y
```
出现 `mini_test.mp4` 文件即成功。
### Step C. 把 demo 视频放到指定位置
```bash
# 把 demo 视频文件复制到:
programs/ep001_20260612_fangkong_fandao/source/video.mp4
```
> video.mp4 由制片人放入,不放进 git(已加入 .gitignore)
### Step D. 配置凭证
```bash
cp doco/.env.example doco/.env
# 用记事本或 VS Code 编辑 doco/.env,填入三组真实 API key
```
### Step E. 安装 doco 包
```bash
cd doco && pip install -e .
```
### Step F. 跑 dry-run(只裁切,不调 OCR API)
**重要:先跑 dry-run,确认裁切框包住字幕后再跑正式版。**
```bash
doco split \
doco run \
--episode-id ep001_20260612_fangkong_fandao \
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4 \
--output-dir programs/ep001_20260612_fangkong_fandao/work/ \
--dry-run
--a-script programs/ep001_20260612_fangkong_fandao/source/a_draft.docx \
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4
```
**验收 dry-run 结果:**
1. 检查 `work/frames/` 目录下的前 3-5 张关键帧小图
2. 确认字幕被完整框住、没有切掉字
3. 如果裁切位置不对,停下来反馈
串联 P1→P2→C1→C2→C3→C4 六个阶段,中间产物自动落盘,各阶段可断点续跑(已有产物自动跳过)。
### Step G. 跑正式版(去掉 --dry-run)
**可选参数:**
- `--skip-p1`:跳过P1/P2(已有B稿v2时使用)
- `--batch-size 25`C4对齐每批行数(默认25,可调)
dry-run 验收通过后,跑正式版:
> ⚠️ C4 开始前要求骨架文件已存在,需**先手动**运行 `doco skeleton` 并人工核验:
> ```bash
> doco skeleton --episode-id <id> --a-script <a_draft.docx>
> # 检查输出的骨架预览表,确认无误后再跑 doco run
> ```
### 各子命令(可单独运行)
```bash
doco split \
--episode-id ep001_20260612_fangkong_fandao \
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4 \
--output-dir programs/ep001_20260612_fangkong_fandao/work/
# P1: 视频拆分(抽帧 + OCR + 音频分离)
doco split --episode-id <id> --input-video <mp4> --output-dir <dir>
# C1: 术语提取
doco terms --episode-id <id> --a-script <docx>
# C2: 讯飞ASR转写
doco asr --episode-id <id> --input-video <mp4> --output-dir <dir>
# C3: 交叉复审融合
doco fuse --episode-id <id> [--batch-size 35]
# C4: 对齐出稿
doco compose --episode-id <id> [--batch-size 25]
```
### 输出产物
> ⚠️ `--output-dir` 务必传**绝对路径**,否则产物会落到当前工作目录,与 doco 产物分家。
```
programs/ep001_20260612_fangkong_fandao/work/
├── frames/ # 抽出的帧(临时)
├── audio_16k.wav # 音频(16kHz/单声道/16bit)
├── b_manuscript.txt # B 稿([Nm Ns] 句子格式)
└── keyframes.json # 关键帧索引(含裁切参数)
```
## P1 验收标准
1. `work/b_manuscript.txt` 格式为 `[Nm Ns] 句子`,每行一句
2. `work/audio_16k.wav` 规格为 16kHz/单声道/16bit,能被讯飞 ASR 接收
3. `work/keyframes.json` 字段符合定义
---
## 目录结构
```
doco/
├── src/
├── src/doco/
│ ├── __init__.py
│ ├── cli.py # CLI 入口
│ ├── video_split.py # P1 核心:视频双路拆分
│ ├── asr_adapter.py # 讯飞 ASR 适配层
── ocr_adapter.py # P2:DeepSeek Vision OCR
├── tests/
│ ├── test_video_split.py # 单元测试
── fixtures/
└── mini_test.mp4 # 迷你测试视频(需 Step B 生成)
├── docs/
├── .env.example # 凭证模板
├── README.md
└── pyproject.toml
│ ├── cli.py # CLI 入口doco run/split/terms/asr/fuse/skeleton/compose
│ ├── video_split.py # P1: 抽帧 + 音频分离(ffmpeg
│ ├── llm.py # LLM 统一客户端(OpenAI兼容)
── term_extract.py # C1: 规则层+AI层术语提取
│ ├── asr_adapter.py # C2: 讯飞ASR适配层
│ ├── fusion_review.py # C3: B稿⊕ASR交叉复审
── fusion_align.py # C4: A稿骨架抽取+语义对齐+出稿
└── templates/ # P1/P2 模板脚本
│ ├── stage_a_extract_ocr.py # P1 抽帧+OCR
│ └── stage_b_dedup_output.py # P2 文本去重
├── programs/ # 每期节目产物(按 episode_id 分目录)
│ └── <episode_id>/
│ ├── source/ # 输入(video.mp4 + a_draft.docx
│ ├── B稿_v2.txt # P2 产出的OCR字幕文本
│ ├── audio_16k.wav # 分离的音频(16kHz/单声道/16bit
│ ├── asr_v2_timed.txt # ASR转写结果(带时间戳)
│ ├── <id>_a_skeleton.json # A稿分段骨架
│ ├── 融合B稿.txt # C3 产出
│ ├── fusion_review.csv # C3 复审留痕
│ ├── 融合A稿.docx # C4 最终交付物
│ └── c4_alignment.csv # C4 对齐留痕
├── data/
│ └── term_dict.json # 累积术语词典(逐期更新)
├── deliverables/ # 已完成的融合A稿展示
├── note/ # 设计文档、PRD、决策记录
├── tests/ # 测试
├── CLAUDE.md # 项目协作主控文件(交接、决策、状态)
├── pyproject.toml
└── .env.example # 凭证模板
```
---
## 设计原则
- **汉字零改**:正文100%来自B稿v2,AI只做OCR纠错+语义对齐+标点插入,绝不改任何一个汉字。`strip_punct()`硬校验守门。
- **有序无阻塞**:全自动产出,拿不准的地方全部进 `fusion_review.csv` 留痕,绝不卡出稿。
- **各阶段解耦**:中间产物落缓存,可断点续跑,可单独重跑,失败不影响已完成阶段。
- **专名铁律**:厂名/型号/番号/国名/人名/机构名,B稿与ASR同音异写时**一律以B稿为准**,零容忍采ASR。
- **OCR漏字不补**:缺的字是真实信息丢失,不让LLM补词(LLM补词=猜词=破红线)。
---
## 相关文档
- Brief: `docs/doco/Doco子项目_Brief.md`
- 设计文档: `docs/doco/doco_project_design.md`
- 讯飞接入笔记: `docs/doco/doco_xfyun_integration_notes.md`
- 主项目回复: `docs/doco/主project对Doco_PRDv2的回复.md`
- **项目协作主控文件**`CLAUDE.md`(状态、交接、关键决策,新接手者首选阅读)
- **子项目Brief**`note/Doco子项目_Brief.md`(红线、技术栈、出入口接口)
- **PRD**`note/PRD_doco_文稿整理子项目_v2.md`(需求规格、方案选型)
- **P3设计稿**`note/doco_P3_设计稿.md`(三方融合架构设计)
- **快照与决策记录**`note/` 目录下其他文件
---
## 技术栈
- **语言**Python ≥ 3.12
- **LLM**:小米 MiMo 2.5 ProOpenAI兼容端点,`openai` SDK
- **OCR**:本地 Ollama + DeepSeek-OCR 模型
- **ASR**:讯飞开放平台 录音文件转写(标准版)
- **视频处理**ffmpegsubprocess调用)
- **文档生成**python-docx
- **CLI框架**Click
+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)
+176
View File
@@ -0,0 +1,176 @@
"""
将 20 期融合A稿 (.docx) 转为带 YAML frontmatter 的 .md 文件,
供 TPS 主项目知识库 /api/knowledge/upload 批量导入。
用法:
cd E:\tps-dashboard\doco
python convert_to_md.py
产物落在 doco/deliverables/ 目录下,每期一个 .md 文件。
"""
import glob
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from docx import Document
PROGRAMS_DIR = Path(__file__).parent / "programs"
OUTPUT_DIR = Path(__file__).parent / "deliverables"
# ep001 文件名没有编导和标题信息,需要手工补
EP001_OVERRIDE = {
"title": "现代防空反导大对决",
"author": "", # ← 通哥填
"date": "2026-06-12",
}
def find_fusion_docx(episode_dir: Path) -> Path | None:
"""在 episode 目录里找融合A稿 docx(排除批改稿)。"""
candidates = []
for f in episode_dir.iterdir():
if f.suffix == ".docx" and "融合A稿" in f.name and "批改" not in f.name:
candidates.append(f)
if not candidates:
return None
if len(candidates) == 1:
return candidates[0]
# 优先选带日期前缀的(ep002+ 格式)
for c in candidates:
if re.match(r"\d{8}", c.stem):
return c
return candidates[0]
def parse_metadata_from_filename(docx_path: Path, episode_dir_name: str) -> dict:
"""
从文件名提取元数据。
ep002+ 文件名格式:{YYYYMMDD}{标题}_{编导}_融合A稿.docx
ep001 特殊处理。
"""
if episode_dir_name.startswith("ep001"):
return EP001_OVERRIDE.copy()
stem = docx_path.stem # e.g. "20260127潜艇的仿生之路_穆佩弦_融合A稿"
# 去掉 "_融合A稿" 后缀
stem = re.sub(r"_融合A稿$", "", stem)
# 提取日期 (前8位)
date_str = stem[:8]
try:
dt = datetime.strptime(date_str, "%Y%m%d")
date_iso = dt.strftime("%Y-%m-%d")
except ValueError:
date_iso = ""
rest = stem[8:] # e.g. "潜艇的仿生之路_穆佩弦"
# 最后一个 _ 分隔编导
parts = rest.rsplit("_", 1)
if len(parts) == 2:
title, author = parts
else:
title = rest
author = ""
return {"title": title, "date": date_iso, "author": author}
def docx_to_markdown(docx_path: Path) -> str:
"""将融合A稿 docx 转为 markdown 正文。"""
doc = Document(str(docx_path))
lines = []
for para in doc.paragraphs:
text = para.text.strip()
if not text:
continue
# 大标题(第一段,通常带书名号)
if not lines and (text.startswith("") or text.startswith("") is False):
lines.append(f"# {text}")
lines.append("")
continue
# 段头标签:【主持人1】【解说3】【专家2】【导视】隔断:【...】
if re.match(r"^【.+?】$", text) or re.match(r"^隔断\d*[:]【.+?】", text):
lines.append("")
lines.append(f"## {text}")
lines.append("")
continue
# 普通正文段落
lines.append(text)
lines.append("")
return "\n".join(lines).strip() + "\n"
def build_frontmatter(meta: dict, episode_id: str) -> str:
"""生成 YAML frontmatter。"""
fm_lines = ["---"]
fm_lines.append(f"标题: {meta['title']}")
if meta["author"]:
fm_lines.append(f"编导: {meta['author']}")
if meta["date"]:
fm_lines.append(f"播出日期: {meta['date']}")
fm_lines.append("类型: 节目文稿")
fm_lines.append(f"期号: {episode_id}")
fm_lines.append("---")
return "\n".join(fm_lines)
def main():
OUTPUT_DIR.mkdir(exist_ok=True)
# 找所有 ep??? 目录(排除 ep002_004 这种非标准的)
episode_dirs = sorted(
d
for d in PROGRAMS_DIR.iterdir()
if d.is_dir() and re.match(r"^ep\d{3}_\d{8}_", d.name)
)
print(f"找到 {len(episode_dirs)} 个期目录")
success = 0
errors = []
for ep_dir in episode_dirs:
episode_id = ep_dir.name.split("_")[0] # ep001, ep002, ...
docx_path = find_fusion_docx(ep_dir)
if docx_path is None:
errors.append(f"{ep_dir.name}: 未找到融合A稿")
continue
print(f" {episode_id}: {docx_path.name}")
meta = parse_metadata_from_filename(docx_path, ep_dir.name)
frontmatter = build_frontmatter(meta, episode_id)
body = docx_to_markdown(docx_path)
# 输出文件名:ep001_现代防空反导大对决.md
safe_title = meta["title"].replace(":", "").replace("/", "_")
out_name = f"{episode_id}_{safe_title}.md"
out_path = OUTPUT_DIR / out_name
with open(out_path, "w", encoding="utf-8") as f:
f.write(frontmatter + "\n\n" + body)
success += 1
print(f"\n完成:{success}/{len(episode_dirs)} 期转换成功")
if errors:
print("失败:")
for e in errors:
print(f" {e}")
print(f"\n产物目录:{OUTPUT_DIR}")
if __name__ == "__main__":
main()
+7967 -111
View File
File diff suppressed because it is too large Load Diff
+29 -3
View File
@@ -70,6 +70,8 @@ LANGUAGE = "cn" # 中文普通话
PD = "mil" # 军事领域优化
ENG_SMOOTHPROC = "true" # 顺滑(去掉"嗯/那个")
ENG_COLLOQPROC = "true" # 口语规整
ROLE_TYPE = "1" # 说话人分离: 0=关闭, 1=通用角色分离
ROLE_NUM = "0" # 说话人数: 0=盲分(自动检测)
# 轮询配置
POLL_INTERVAL_SECONDS = 30
@@ -190,6 +192,8 @@ def upload_audio(
"pd": PD,
"eng_smoothproc": ENG_SMOOTHPROC,
"eng_colloqproc": ENG_COLLOQPROC,
"roleType": ROLE_TYPE,
"roleNum": ROLE_NUM,
}
# 热词,用 | 分隔
@@ -270,11 +274,20 @@ def poll_until_done(order_id: str) -> dict:
def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
"""
解析嵌套JSON,返回 [(sentence_start_ms, sentence_end_ms, text), ...]
兼容旧调用(不含说话人信息)。
"""
return [(bg, ed, text) for bg, ed, text, _rl in parse_order_result_with_speaker(order_result_str)]
def parse_order_result_with_speaker(order_result_str: str) -> List[Tuple[int, int, str, int]]:
"""
解析嵌套JSON,返回 [(start_ms, end_ms, text, speaker_id), ...]
speaker_id 来自讯飞 rl 字段(0=未分离或同一人,1/2/3...=不同说话人)。
"""
if not order_result_str:
return []
cleaned = re.sub(r"\\\\", r"\\", order_result_str)
cleaned = order_result_str.replace("\\\\", "\\")
outer = json.loads(cleaned)
sentences = []
@@ -286,6 +299,7 @@ def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
st = inner.get("st", {})
bg = int(st.get("bg", 0))
ed = int(st.get("ed", 0))
rl = int(st.get("rl", 0))
words = []
for rt in st.get("rt", []):
@@ -297,7 +311,7 @@ def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
words.append(w)
sentence = "".join(words).strip()
if sentence:
sentences.append((bg, ed, sentence))
sentences.append((bg, ed, sentence, rl))
return sentences
@@ -346,7 +360,19 @@ def write_asr_result(
if raw_order_result:
f.write(raw_order_result)
else:
# 没有原始数据时写空对象(兼容旧调用)
f.write("{}")
# 如果 raw 数据含说话人信息,额外输出带说话人标注的版本
if raw_order_result:
spk_sentences = parse_order_result_with_speaker(raw_order_result)
has_speaker = any(rl != 0 for _, _, _, rl in spk_sentences)
if has_speaker:
spk_lines = [
f"[{format_timestamp(bg)}] <spk:{rl}> {text}"
for bg, _, text, rl in spk_sentences
]
spk_path = os.path.join(output_dir, "asr_v2_timed_spk.txt")
with open(spk_path, "w", encoding="utf-8") as f:
f.write("\n".join(spk_lines))
return timed_path, raw_path
+262
View File
@@ -4,9 +4,12 @@ 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
@@ -453,6 +456,17 @@ def asr(
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(
@@ -507,6 +521,7 @@ def compose(
)
click.echo(f"[ok] 总行数: {stats['total_lines']}")
click.echo(f"[ok] 段数: {stats['segment_count']}")
if "empty_segments" in stats:
click.echo(f"[ok] 空段数: {stats['empty_segments']}")
click.echo(f"[ok] 低把握段数: {stats['low_confidence_segments']}")
click.echo(f"[ok] 单调修正行数: {stats['audit_forced_lines']}")
@@ -517,5 +532,252 @@ def compose(
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()
+410 -2
View File
@@ -1013,7 +1013,7 @@ def _parse_align_json(raw: str, expected_len: int) -> List[dict]:
f"LLM 返回不是 JSON 数组, 类型为 {type(result).__name__}"
)
if len(result) != expected_len:
if expected_len > 0 and len(result) != expected_len:
raise ValueError(
f"LLM 返回 {len(result)} 条记录, 期望 {expected_len}"
)
@@ -1149,6 +1149,407 @@ def align_lines_to_segments(
return all_records, all_audit, normal_segs
# ====================================================================
# 5b. Speaker-aware alignment (v2)
# ====================================================================
def _check_has_speaker_data(asr_raw_path: Path) -> bool:
"""检查 ASR raw JSON 是否含说话人分离数据(rl!=0)。"""
if not asr_raw_path.exists():
return False
try:
from .asr_adapter import parse_order_result_with_speaker
raw = asr_raw_path.read_text(encoding="utf-8")
entries = parse_order_result_with_speaker(raw)
return any(rl != 0 for _, _, _, rl in entries)
except Exception:
return False
def _annotate_b_lines_with_speakers(
b_lines: List[dict], asr_raw_path: Path
) -> None:
"""按时间区间匹配,给每行 B稿挂上 speaker_id。
优先:B稿时间戳落在 ASR 句子的 [bg, ed] 区间内 → 用该句的说话人。
退回:无区间命中时,按最近 bg 距离匹配。
"""
from .asr_adapter import parse_order_result_with_speaker
raw = asr_raw_path.read_text(encoding="utf-8")
asr_entries = parse_order_result_with_speaker(raw)
# (start_ms, end_ms, speaker_id)
asr_ranges = [(int(bg), int(ed), int(rl)) for bg, ed, _, rl in asr_entries]
for bl in b_lines:
ts_ms = bl["ts_sec"] * 1000
# 优先:区间匹配
matched = False
for bg, ed, rl in asr_ranges:
if bg <= ts_ms <= ed:
bl["speaker_id"] = rl
matched = True
break
if not matched:
# 退回:最近距离
best_spk = 0
best_dist = float("inf")
for bg, ed, rl in asr_ranges:
dist = abs(bg - ts_ms)
if dist < best_dist:
best_dist = dist
best_spk = rl
bl["speaker_id"] = best_spk
def _detect_speaker_blocks(b_lines: List[dict]) -> List[dict]:
"""将连续同说话人的 B稿行分组为 block。"""
blocks: List[dict] = []
current_spk = None
current_lines: List[dict] = []
for bl in b_lines:
spk = bl.get("speaker_id", 0)
if spk != current_spk and current_lines:
blocks.append({
"block_id": len(blocks),
"speaker_id": current_spk,
"lines": list(current_lines),
})
current_lines = []
current_spk = spk
current_lines.append(bl)
if current_lines:
blocks.append({
"block_id": len(blocks),
"speaker_id": current_spk,
"lines": list(current_lines),
})
return blocks
SYSTEM_PROMPT_SPEAKER_ALIGN = """你是《军事科技》专题片分段对齐员。给你 A稿分段骨架(编导录制前写的分段结构)和播出版按说话人自动分组的段落块。
A稿是编导在录制前写的脚本,播出版可能有以下差异:
- 段落顺序调整(如解说和专家段互换位置)
- 隔断段(如"街头采访")在A稿只是一个标题,但播出中有多人实际说话
- 播出中新增了A稿没有的内容
- A稿有的段播出中删掉了
你的任务:为每个播出版段落块(block)分配段落标签。**关键:一个block可能跨越多个A稿段落(尤其是同一配音员念不同类型内容,如解说→三维动画解说→解说),此时必须拆分。**
规则:
1. 说话人切换是分段的天然边界。
2. 多个不同说话人的短段落如果聚在一起(如街采多人发言),应统一标为一个街采段,给它们分配同一个seg_id(对应A稿的隔断段),segment_label统一为【街采N】。
3. 同一说话人在不同时段出现,通常属于不同分段(如主持人出现多次是不同的主持人段)。
4. 隔断段如果播出中有对应内容,它就应该接收block。
5. **segment_label必须使用A稿骨架中的原始标签**,包括修饰词。如果A稿骨架写的是【三维动画解说1】就必须用【三维动画解说1】,**绝不简化为【解说N】**。同理【演播室主持人N】不简化为【主持人N】。
6. **大block拆分**:对于标记了"(需拆分)"的大block,会提供逐行明细(行号+时间戳+开头文字)。你必须结合A稿骨架判断该block实际覆盖了哪几个A稿段落,然后输出多条记录,每条用 start_line 和 end_line 指定行范围(block内部从0开始的下标,闭区间)。例如一个92行block实际是"解说1(0-30行)+三维动画解说1(31-60行)+解说2(61-91行)",就输出3条。
7. **小block**(没有逐行明细的):直接分配一个段落,不需要 start_line/end_line。
输出JSON数组,每条格式:
{"block_id":int, "seg_id":int, "segment_label":"【...】", "confidence":0.0~1.0, "start_line":int或null, "end_line":int或null}
其中 start_line/end_line 只在拆分大block时才需要填(block内部行号,从0开始,闭区间)。小block不填或填null。"""
def _align_speaker_blocks(
blocks: List[dict],
all_segments: List[dict],
cache_dir: Optional[Path] = None,
) -> List[dict]:
"""一次 LLM 调用,为每个说话人 block 分配段落。"""
seg_lines = []
for seg in all_segments:
seg_type = seg.get("type", "normal")
type_tag = " [隔断]" if seg_type == "break" else ""
body = seg.get("body", "")
body_preview = body[:200].replace("\n", " ") if body else "(无正文)"
seg_lines.append(
f"seg_{seg['seg_id']} | {seg['header']}{type_tag} | {body_preview}"
)
LARGE_BLOCK_THRESHOLD = 40 # 超过此行数的block提供逐行明细
block_lines = []
for block in blocks:
spk = block["speaker_id"]
n = len(block["lines"])
first_ts = block["lines"][0]["ts_sec"]
last_ts = block["lines"][-1]["ts_sec"]
ts_start = f"{first_ts // 60}m{first_ts % 60}s"
ts_end = f"{last_ts // 60}m{last_ts % 60}s"
texts = [bl["text"] for bl in block["lines"]]
is_large = n > LARGE_BLOCK_THRESHOLD
if is_large:
# 大 block: 提供逐行明细, 让 LLM 识别内部分段边界
header = (
f"block_{block['block_id']} | 说话人{spk} | "
f"{ts_start}-{ts_end} | {n}句 (需拆分)"
)
detail_lines = []
for idx, bl in enumerate(block["lines"]):
t = bl["ts_sec"]
ts_fmt = f"{t // 60}m{t % 60}s"
detail_lines.append(f"{idx}: [{ts_fmt}] {bl['text'][:50]}")
block_lines.append(header + "\n" + "\n".join(detail_lines))
else:
if len(texts) <= 3:
preview = " / ".join(t[:60] for t in texts)
else:
preview = f"{texts[0][:60]} / ... / {texts[-1][:60]}"
block_lines.append(
f"block_{block['block_id']} | 说话人{spk} | "
f"{ts_start}-{ts_end} | {n}句 | {preview[:200]}"
)
user_content = (
f"A稿分段骨架(共 {len(all_segments)} 段):\n\n"
+ "\n".join(seg_lines)
+ f"\n\n--- 播出版说话人段落(共 {len(blocks)} 块,按播出时间排列)---\n\n"
+ "\n".join(block_lines)
+ "\n\n请为每个 block 分配 seg_id 和 segment_label。"
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT_SPEAKER_ALIGN},
{"role": "user", "content": user_content},
]
if cache_dir:
cache_dir.mkdir(parents=True, exist_ok=True)
cache_path = cache_dir / "speaker_align.json"
if cache_path.exists():
try:
cached = json.loads(cache_path.read_text(encoding="utf-8"))
# 缓存条数 >= blocks 数即合法(大 block 拆分后条数更多)
cached_block_ids = {a.get("block_id") for a in cached}
if all(b["block_id"] in cached_block_ids for b in blocks):
print(
f"[fusion_align] 复用说话人分段缓存 ({len(cached)} 条)"
)
return cached
except Exception:
pass
print(f"[fusion_align] 调用 LLM 说话人分段对齐 ({len(blocks)} 块)...")
raw_response = chat(
messages,
thinking=True,
max_tokens=12000,
temperature=0.0,
)
# 允许条数 >= blocks(大 block 拆分后多条)
parsed = _parse_align_json(raw_response, -1)
if cache_dir:
cache_path = cache_dir / "speaker_align.json"
cache_path.write_text(
json.dumps(parsed, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return parsed
def _build_broadcast_segments(
blocks: List[dict],
block_assignments: List[dict],
) -> List[dict]:
"""
从 block 赋值结果构建播出序分段。
支持大 block 拆分(一个 block 多条 assignment,按 start_line/end_line 切行)。
相邻同 label 的片段合并为一段。
"""
# 按 block_id 分组 assignment,保持顺序
from collections import defaultdict
assignments_by_block: Dict[int, List[dict]] = defaultdict(list)
for a in block_assignments:
assignments_by_block[a["block_id"]].append(a)
# 展开:每个 block 可能产出多个 (label, seg_id, lines) 片段
expanded: List[Tuple[str, int, List[str]]] = []
for block in blocks:
bid = block["block_id"]
assigns = assignments_by_block.get(bid, [])
block_line_texts = [bl["text"] for bl in block["lines"]]
if len(assigns) == 1 and assigns[0].get("start_line") is None:
# 简单赋值,整个 block 一个标签
label = assigns[0].get("segment_label", "【未知】")
seg_id = assigns[0].get("seg_id", -1)
expanded.append((label, seg_id, block_line_texts))
elif len(assigns) > 1:
# 大 block 拆分:按 start_line/end_line 切行
# 排序确保按行号顺序
sorted_assigns = sorted(
assigns,
key=lambda x: x.get("start_line", 0) or 0,
)
for sa in sorted_assigns:
sl = sa.get("start_line", 0) or 0
el = sa.get("end_line", len(block_line_texts) - 1)
if el is None:
el = len(block_line_texts) - 1
label = sa.get("segment_label", "【未知】")
seg_id = sa.get("seg_id", -1)
lines_slice = block_line_texts[sl:el + 1]
if lines_slice:
expanded.append((label, seg_id, lines_slice))
else:
# fallback: 单条有 start_line
for sa in assigns:
sl = sa.get("start_line", 0) or 0
el = sa.get("end_line", len(block_line_texts) - 1)
if el is None:
el = len(block_line_texts) - 1
label = sa.get("segment_label", "【未知】")
seg_id = sa.get("seg_id", -1)
lines_slice = block_line_texts[sl:el + 1]
if lines_slice:
expanded.append((label, seg_id, lines_slice))
# 合并相邻同 label 片段
result: List[dict] = []
current_label = None
current_seg_id = None
current_lines: List[str] = []
for label, seg_id, lines in expanded:
if label != current_label:
if current_lines:
result.append({
"header": current_label,
"type": "normal",
"body_lines": list(current_lines),
"a_seg_id": current_seg_id,
})
current_label = label
current_seg_id = seg_id
current_lines = list(lines)
else:
current_lines.extend(lines)
if current_lines:
result.append({
"header": current_label,
"type": "normal",
"body_lines": list(current_lines),
"a_seg_id": current_seg_id,
})
return result
def _compose_with_speaker(
b_lines: List[dict],
segments: List[dict],
title: str,
a_path: Path,
out_dir: Path,
asr_raw_path: Path,
no_ai: bool = False,
) -> dict:
"""说话人分段模式的完整 compose 流程。"""
print("[fusion_align] === 说话人分段模式 (v2) ===")
_annotate_b_lines_with_speakers(b_lines, asr_raw_path)
blocks = _detect_speaker_blocks(b_lines)
print(f"[fusion_align] 说话人段落块: {len(blocks)}")
for block in blocks:
spk = block["speaker_id"]
n = len(block["lines"])
first_ts = block["lines"][0]["ts_sec"]
ts_str = f"{first_ts // 60}m{first_ts % 60}s"
preview = block["lines"][0]["text"][:40]
print(
f" block_{block['block_id']:2d} | spk={spk} | "
f"{ts_str} | {n:3d}句 | {preview}"
)
cache_dir = out_dir / ".c4_cache_spk"
block_assignments = _align_speaker_blocks(blocks, segments, cache_dir)
for a in block_assignments:
bid = a.get("block_id", "?")
sid = a.get("seg_id", "?")
label = a.get("segment_label", "?")
conf = a.get("confidence", 0)
print(f" block_{bid} -> seg_{sid} | {label} | conf={conf:.2f}")
broadcast_segs = _build_broadcast_segments(blocks, block_assignments)
print(f"[fusion_align] 播出序分段: {len(broadcast_segs)}")
for i, bseg in enumerate(broadcast_segs):
n = len(bseg["body_lines"])
print(f" [{i:2d}] {bseg['header']} | {n}")
ref_body_map = {seg["seg_id"]: seg.get("body", "") for seg in segments}
seg_texts: List[str] = []
punct_results: Dict[int, bool] = {}
for i, bseg in enumerate(broadcast_segs):
bare_text = compose_segment_text(bseg["body_lines"])
if no_ai or not bare_text.strip():
seg_texts.append(bare_text)
punct_results[i] = True
continue
ref_body = ref_body_map.get(bseg.get("a_seg_id", -1), "")
punct_text, punct_ok = punctuate_segment(
bare_text, ref_body,
cache_dir=cache_dir,
seg_id=i,
)
seg_texts.append(punct_text)
punct_results[i] = punct_ok
docx_path = out_dir / f"{a_path.stem}_融合A稿.docx"
render_docx(title, broadcast_segs, seg_texts, str(docx_path))
print(f"[fusion_align] 融合A稿: {docx_path}")
csv_path = out_dir / "c4_alignment.csv"
rows = ["seg_idx,header,line_count,punct_ok"]
for i, bseg in enumerate(broadcast_segs):
header = bseg["header"].replace('"', '""')
n = len(bseg["body_lines"])
pok = punct_results.get(i, True)
rows.append(f'{i},"{header}",{n},{pok}')
csv_path.write_text("\n".join(rows) + "\n", encoding="utf-8")
print(f"[fusion_align] 留痕 CSV: {csv_path}")
punct_failed = sum(1 for v in punct_results.values() if not v)
stats = {
"total_lines": sum(len(s["body_lines"]) for s in broadcast_segs),
"segment_count": len(broadcast_segs),
"punct_failed_segs": punct_failed,
"docx_path": str(docx_path),
"csv_path": str(csv_path),
}
print(f"\n[fusion_align] === 统计 (v2) ===")
print(f" 总行数: {stats['total_lines']}")
print(f" 播出序段数: {stats['segment_count']}")
print(f" 标点回退段数: {stats['punct_failed_segs']}")
for i, bseg in enumerate(broadcast_segs):
n = len(bseg["body_lines"])
pflag = " [标点回退]" if not punct_results.get(i, True) else ""
print(f" [{i:2d}] {bseg['header']}: {n}{pflag}")
return stats
# ====================================================================
# 6. 硬校验
# ====================================================================
@@ -1627,7 +2028,14 @@ def run_compose(
f"| body_len={len(seg['body'])}"
)
# ---- 对齐(只对 normal 段) ----
# ---- 检查说话人数据,有则走 v2 ----
asr_raw_path = out_dir / "asr_result_raw.json"
if _check_has_speaker_data(asr_raw_path):
return _compose_with_speaker(
b_lines, segments, title, a_path, out_dir, asr_raw_path, no_ai,
)
# ---- 对齐(只对 normal 段,旧路径) ----
cache_dir = out_dir / ".c4_cache"
alignment, audit_logs, normal_segs = align_lines_to_segments(
b_lines,
+1
View File
@@ -35,6 +35,7 @@ SYSTEM_PROMPT = """你是《军事科技》专题片文稿校审员。给你 B
权威优先级:
- 屏幕术语/型号/番号(-3/萨德/见证者-136): B稿为准(屏幕实打的字)
- B稿明显是OCR错字而ASR是对的: 用ASR覆盖
- 专有名词铁律厂名/型号/番号/国名/人名/机构名等专名遇B稿与ASR同音异写(如斯泰尔vs斯太尔美以vs美伊)一律以B稿/A稿书面写法为准零容忍采ASRASR是口语转写同音字极多专名绝不信ASR
- 同音事实错("美以"vs"美伊"): 以书面规范为准,存疑进review
- 一两个字的等价差异(/啊等语气): unchanged,不要改
每行输出: line_no, final_text(纠错后,默认等于B原文), change_type(7选1), confidence(0~1), reason(简短,unchanged时留空)
@@ -0,0 +1,284 @@
# -*- coding: utf-8 -*-
"""
阶段 A:抽帧 + OCR(产出原始缓存 ocr_raw.jsonl)
================================================
策略:OCR 优先,每帧都 OCR绝不使用亮度判空/dHash/IoU 过滤
idx 断点续跑:读取已有 ocr_raw.jsonl 中最大 idx, max_idx+1 继续
"""
import base64
import json
import os
import subprocess
import sys
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import requests
# 根治 Windows 控制台 GBK 崩溃:强制 stdout 用 UTF-8
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# ========================================================================
# 全局参数(放文件顶部做常量,方便后续调)
# ========================================================================
FPS = 1
CROP_BOTTOM_RATIO = 0.2
SIM_THRESHOLD = 0.85
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "deepseek-ocr"
OCR_PROMPT = "Free OCR."
# 并发 OCR 线程数(环境变量 OCR_NUM_WORKERS 可覆盖)。
# 需配合 Ollama 服务端 OLLAMA_NUM_PARALLEL >= 此值,否则服务端仍排队、提速无效。
NUM_WORKERS = int(os.environ.get("OCR_NUM_WORKERS", "4"))
# 工作目录(脚本所在目录)
WORK_DIR = Path(__file__).resolve().parent
SOURCE_VIDEO_DIR = WORK_DIR / "source"
FRAMES_DIR = WORK_DIR / "frames_v2"
OCR_RAW_PATH = WORK_DIR / "ocr_raw.jsonl"
# ========================================================================
# A1: 定位源视频
# ========================================================================
def find_source_video():
"""在工作目录 source/ 或根目录下定位 .mp4 或 .mkv 源视频"""
for ext in (".mp4", ".mkv"):
candidates = list(SOURCE_VIDEO_DIR.glob(f"*{ext}"))
if candidates:
return candidates[0]
# fallback: 也检查工作目录根(ep002 等视频直接在根目录的情况)
for ext in (".mp4", ".mkv"):
candidates = list(WORK_DIR.glob(f"*{ext}"))
if candidates:
return candidates[0]
raise FileNotFoundError(
f"{SOURCE_VIDEO_DIR}{WORK_DIR} 中均未找到 .mp4 或 .mkv 文件"
)
def get_video_info(video_path):
"""用 ffprobe 获取时长(秒)和分辨率(宽x高)"""
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=duration,width,height",
"-of", "json",
str(video_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
if result.returncode != 0:
raise RuntimeError(f"ffprobe 失败:\n{result.stderr}")
info = json.loads(result.stdout)
stream = info["streams"][0]
duration = float(stream.get("duration", 0))
width = stream["width"]
height = stream["height"]
return duration, width, height
# ========================================================================
# A2: ffmpeg 抽帧
# ========================================================================
def extract_frames(video_path):
"""
ffmpeg 1fps 抽帧,裁切下方 20% 字幕区域
输出到 frames_v2/frame_%04d.png( 0001 开始)
返回按文件名排序的帧路径列表
"""
FRAMES_DIR.mkdir(parents=True, exist_ok=True)
# 清理旧帧避免残留
for old in FRAMES_DIR.glob("frame_*.png"):
old.unlink()
crop_expr = (
f"fps={FPS},"
f"crop=iw:ih*{CROP_BOTTOM_RATIO}:0:ih*{1 - CROP_BOTTOM_RATIO}"
)
frame_pattern = str(FRAMES_DIR / "frame_%04d.png")
cmd = [
"ffmpeg",
"-i", str(video_path),
"-vf", crop_expr,
"-q:v", "2",
frame_pattern,
"-y",
]
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
if result.returncode != 0:
raise RuntimeError(f"ffmpeg 抽帧失败:\n{result.stderr}")
frames = sorted(FRAMES_DIR.glob("frame_*.png"))
return frames
# ========================================================================
# OCR 单帧
# ========================================================================
def ocr_single_frame(image_path):
"""
调用本地 Ollama OCR 识别单帧
API: POST /api/generate,单轮,图片走 base64,stream=false
返回 strip 后的文本;异常向上抛出让调用方处理
"""
with open(image_path, "rb") as fh:
img_bytes = fh.read()
img_b64 = base64.b64encode(img_bytes).decode("utf-8")
body = {
"model": MODEL,
"prompt": OCR_PROMPT,
"images": [img_b64],
"stream": False,
}
resp = requests.post(OLLAMA_URL, json=body, timeout=120)
resp.raise_for_status()
data = resp.json()
text = data.get("response", "").strip()
return text
# ========================================================================
# A3: 单帧验证
# ========================================================================
def test_single_frame():
"""
A3: 验证单帧 OCR
frame_0076.png 调一次 OCR,打印原始返回
若对不上预期则中止,不进入批量
"""
test_frame = FRAMES_DIR / "frame_0076.png"
if not test_frame.exists():
print("[A3] [WARN] test frame not found:", test_frame)
print("[A3] skip single-frame check")
return
print("[A3] test frame:", test_frame.name)
try:
raw_text = ocr_single_frame(test_frame)
except Exception as exc:
print("[A3] [FAIL] OCR call failed:", exc)
print("[A3] check Ollama / deepseek-ocr, abort.")
sys.exit(1)
print("[A3] raw return:", repr(raw_text))
# 通用冒烟检查:OCR 返回非空中文即视为链路正常(不绑定某期具体台词);
# 真正的"Ollama 挂了"由上面 ocr_single_frame 抛异常 + sys.exit(1) 兜底。
if any("" <= ch <= "鿿" for ch in raw_text):
print("[A3] [OK] OCR 链路正常(返回非空中文)。")
else:
print("[A3] [WARN] OCR 返回无中文,链路可能异常,但不阻断,继续批量:", repr(raw_text))
# ========================================================================
# A4: 批量 OCR(按 idx 断点续跑 + 单帧容错)
# ========================================================================
def batch_ocr(frames):
"""
并发 OCR(线程池 NUM_WORKERS ) + idx 断点续跑 + 单帧容错
- ocr_raw.jsonl 已写入的 idx 集合(并发中断可能留空洞,用集合而非 max),只补未写帧
- NUM_WORKERS 个线程并发调 Ollama;每帧独立,输出与串行逐帧完全一致
- 写盘按完成顺序加锁逐条 append + flush(stage_b 读后会按 idx 重排,顺序无所谓)
- 单帧 OCR 报错写 error 字段,不崩整个进程
"""
total = len(frames)
# --- 读已写入的 idx 集合(含 error 记录,避免重跑出重复 idx 行) ---
done_idx = set()
if OCR_RAW_PATH.exists():
with open(OCR_RAW_PATH, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
if "idx" in rec:
done_idx.add(rec["idx"])
except json.JSONDecodeError:
pass
# --- 构建 idx -> frame_path 映射 ---
idx_to_path = {}
for fp in frames:
idx = int(fp.stem.split("_")[1])
idx_to_path[idx] = fp
pending = [idx for idx in sorted(idx_to_path) if idx not in done_idx]
if not pending:
print(f"[A4] all {total} frames already done, skip.")
return
print(
f"[A4] 已完成 {len(done_idx)}/{total},待处理 {len(pending)} 帧,"
f"并发 {NUM_WORKERS}"
)
def _ocr_one(idx):
t_sec = idx - 1
frame_path = idx_to_path.get(idx)
if frame_path is None:
return {"idx": idx, "t_sec": t_sec, "text": "", "error": "frame file missing"}
try:
text = ocr_single_frame(frame_path)
return {"idx": idx, "t_sec": t_sec, "text": text}
except Exception as exc:
return {"idx": idx, "t_sec": t_sec, "text": "", "error": str(exc)}
write_lock = threading.Lock()
done_count = len(done_idx)
with open(OCR_RAW_PATH, "a", encoding="utf-8") as fh:
with ThreadPoolExecutor(max_workers=NUM_WORKERS) as pool:
futures = {pool.submit(_ocr_one, idx): idx for idx in pending}
for fut in as_completed(futures):
record = fut.result()
with write_lock:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
fh.flush()
done_count += 1
if done_count % 50 == 0:
print(f"{done_count}/{total}", flush=True)
print(f"{total}/{total}", flush=True)
print(f"[A4] OCR done, {total} records in {OCR_RAW_PATH}")
# ========================================================================
# 主流程
# ========================================================================
def main():
# A1
video_path = find_source_video()
duration, width, height = get_video_info(video_path)
print(f"[A1] video: {video_path}")
print(f"[A1] duration: {duration:.1f}s resolution: {width}x{height}")
# A2: skip re-extract if frames already exist
FRAMES_DIR.mkdir(parents=True, exist_ok=True)
existing = sorted(FRAMES_DIR.glob("frame_*.png"))
if existing:
print(f"[A2] {len(existing)} frames already exist, skip re-extract")
frames = existing
else:
print(f"[A2] ffmpeg extract (fps={FPS}, crop_bottom_ratio={CROP_BOTTOM_RATIO})...")
frames = extract_frames(video_path)
print(f"[A2] extracted {len(frames)} frames -> {FRAMES_DIR}")
# A3
test_single_frame()
# A4
batch_ocr(frames)
print("\nstage A done.")
if __name__ == "__main__":
main()
@@ -0,0 +1,300 @@
# -*- coding: utf-8 -*-
"""
阶段 B:文本去重 + 出稿(只读缓存,可反复重跑调阈值)
=======================================================
策略:基于 difflib 文本相似度折叠连续重复段,绝不使用像素级过滤
"""
import csv
import json
import re
from difflib import SequenceMatcher
from pathlib import Path
# ═══════════════════════════════════════════════════════════════
# 全局参数(与阶段 A 共享,放文件顶部做常量)
# ═══════════════════════════════════════════════════════════════
FPS = 1
CROP_BOTTOM_RATIO = 0.2
SIM_THRESHOLD = 0.85
# 工作目录
WORK_DIR = Path(__file__).resolve().parent
OCR_RAW_PATH = WORK_DIR / "ocr_raw.jsonl"
B_MANUSCRIPT_PATH = WORK_DIR / "B稿_v2.txt"
DEBUG_CSV_PATH = WORK_DIR / "dedup_debug.csv"
BLANK_FILTERED_PATH = WORK_DIR / "blank_filtered.txt"
def similarity(a, b):
"""计算两个文本的 difflib 相似度(0.0 ~ 1.0)"""
if not a or not b:
return 0.0
return SequenceMatcher(None, a, b).ratio()
def is_blank_ocr(text):
"""基于文本的空场判定:空串 / HTML幻觉 / 不含汉字"""
t = text.strip()
if not t: # 空串
return True
if '<' in t and '>' in t: # HTML/markup 幻觉(如 <table>)
return True
if not re.search(r'[\u4e00-\u9fff]', t): # 不含任何汉字 → 非本节目字幕
return True
return False
# ═══════════════════════════════════════════════════════════════
# B1: 读缓存
# ═══════════════════════════════════════════════════════════════
def load_ocr_raw():
"""读 ocr_raw.jsonl,按 idx(时间)升序返回记录列表"""
if not OCR_RAW_PATH.exists():
raise FileNotFoundError(
f"ocr_raw.jsonl 不存在: {OCR_RAW_PATH}\n"
"请先跑阶段 A(stage_a_extract_ocr.py)。"
)
records = []
with open(OCR_RAW_PATH, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
rec = json.loads(line)
records.append(rec)
# 按 idx 升序
records.sort(key=lambda r: r["idx"])
return records
# ═══════════════════════════════════════════════════════════════
# B2: 折叠连续重复段(裁判在此,基于文本不基于像素)
# ═══════════════════════════════════════════════════════════════
def collapse_segments(records):
"""
维护"当前字幕"(文本 + 起始 t_sec),遍历每帧:
- 文本为空 空场:结束当前字幕(若有),并清空,不开新段
- 非空且与当前字幕文本的 difflib 相似度 SIM_THRESHOLD 同一条,并入当前段(记下这条文本备投票)
- 非空且相似度 < SIM_THRESHOLD 结束并输出当前段,用这帧开新段
只比时间相邻帧,只折叠连续段;绝不全局去重
每段最终文本 = 段内出现次数最多的 OCR 文本(多数投票,抵消个别帧抖动)
每段时间戳 = 段内最早 t_sec
返回: (segments, debug_rows)
segments: [{"start_t": float, "text": str}, ...]
debug_rows: [{idx, t_sec, ocr_text, decision, merged_into_start_t}, ...]
"""
segments = []
debug_rows = []
if not records:
return segments, debug_rows
current_text = None # 当前段内用于比对的参考文本
current_texts = [] # 当前段内所有 OCR 文本(用于投票)
current_start_t = None # 当前段起始 t_sec
current_segment_indices = [] # 当前段包含的帧号列表
def _finalize_segment():
"""输出当前段(若存在)"""
nonlocal current_text, current_texts, current_start_t, current_segment_indices
if current_text is not None and current_texts:
# 多数投票:选出现次数最多的文本
text_counts = {}
for t in current_texts:
text_counts[t] = text_counts.get(t, 0) + 1
best_text = max(text_counts, key=lambda k: text_counts[k])
segments.append({
"start_t": current_start_t,
"text": best_text,
})
current_text = None
current_texts = []
current_start_t = None
current_segment_indices = []
for rec in records:
ocr_text = rec["text"]
if is_blank_ocr(ocr_text):
# 空场:结束当前字幕(若有),并清空,不开新段
if current_text is not None:
_finalize_segment()
debug_rows.append({
"idx": rec["idx"],
"t_sec": rec["t_sec"],
"ocr_text": ocr_text,
"decision": "blank",
"merged_into_start_t": "",
})
continue
if current_text is None:
# 开新段
current_text = ocr_text
current_texts = [ocr_text]
current_start_t = rec["t_sec"]
current_segment_indices = [rec["idx"]]
debug_rows.append({
"idx": rec["idx"],
"t_sec": rec["t_sec"],
"ocr_text": ocr_text,
"decision": "new",
"merged_into_start_t": rec["t_sec"],
})
else:
sim = similarity(current_text, ocr_text)
if sim >= SIM_THRESHOLD:
# 同一条,并入当前段
current_texts.append(ocr_text)
current_segment_indices.append(rec["idx"])
debug_rows.append({
"idx": rec["idx"],
"t_sec": rec["t_sec"],
"ocr_text": ocr_text,
"decision": "merged",
"merged_into_start_t": current_start_t,
})
else:
# 结束当前段,用这帧开新段
_finalize_segment()
current_text = ocr_text
current_texts = [ocr_text]
current_start_t = rec["t_sec"]
current_segment_indices = [rec["idx"]]
debug_rows.append({
"idx": rec["idx"],
"t_sec": rec["t_sec"],
"ocr_text": ocr_text,
"decision": "new",
"merged_into_start_t": rec["t_sec"],
})
# 遍历结束,落最后一个段
_finalize_segment()
return segments, debug_rows
# ═══════════════════════════════════════════════════════════════
# B3: 输出 B稿_v2.txt
# ═══════════════════════════════════════════════════════════════
def write_b_manuscript(segments):
"""
输出 B稿_v2.txt,每行格式: [XmYs] 文本
: [1m18s] 我是主持人蓝皓
"""
lines = []
for seg in segments:
t = seg["start_t"]
m = int(t) // 60
s = int(t) % 60
cleaned = re.sub(r'^[#*\->`\s]+', '', seg['text']).strip()
lines.append(f"[{m}m{s}s] {cleaned}")
with open(B_MANUSCRIPT_PATH, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
print(f"[B3] B稿_v2.txt 写入: {B_MANUSCRIPT_PATH} ({len(lines)} 行)")
return lines
# ═══════════════════════════════════════════════════════════════
# B4: 输出 dedup_debug.csv
# ═══════════════════════════════════════════════════════════════
def write_debug_csv(debug_rows):
"""
输出 dedup_debug.csv,逐帧记:
idx, t_sec, ocr_text, decision(new/merged/blank), merged_into_start_t
"""
with open(DEBUG_CSV_PATH, "w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerow([
"idx", "t_sec", "ocr_text",
"decision", "merged_into_start_t",
])
for row in debug_rows:
writer.writerow([
row["idx"],
row["t_sec"],
row["ocr_text"],
row["decision"],
row["merged_into_start_t"],
])
print(f"[B4] dedup_debug.csv 写入: {DEBUG_CSV_PATH} ({len(debug_rows)} 行)")
# ═══════════════════════════════════════════════════════════════
# 主流程
# ═══════════════════════════════════════════════════════════════
def main():
print(f"[B] SIM_THRESHOLD = {SIM_THRESHOLD}")
# B1
records = load_ocr_raw()
print(f"[B1] 读取 ocr_raw.jsonl,共 {len(records)} 条记录")
# B2
segments, debug_rows = collapse_segments(records)
print(f"[B2] 折叠完成: {len(segments)} 个不连续字幕段")
print(f"[B2] 调试行数: {len(debug_rows)}")
# B3
lines = write_b_manuscript(segments)
# B4
write_debug_csv(debug_rows)
# ═══════════════════════════════════════════════════════════
# 跑完报告
# ═══════════════════════════════════════════════════════════
total_frames = len(records)
ocr_success = sum(1 for r in records if r["text"].strip())
blank_count = sum(1 for r in records if is_blank_ocr(r["text"]))
# 收集所有"被判空场但原文本非空"的去重文本
blank_but_nonempty = set()
for r in records:
t = r["text"]
if is_blank_ocr(t) and t.strip():
blank_but_nonempty.add(t)
print("\n" + "=" * 60)
print("跑完报告")
print("=" * 60)
print(f"抽帧总数: {total_frames}")
print(f"OCR 成功数(非空): {ocr_success}")
print(f"空场数: {blank_count}")
print(f"B稿_v2.txt 最终行数: {len(lines)}")
print("=" * 60)
print(f"B 稿路径: {B_MANUSCRIPT_PATH}")
print(f"调试表路径: {DEBUG_CSV_PATH}")
# 写出"被判空场但原文本非空"去重清单到文件
blank_list = sorted(blank_but_nonempty)
with open(BLANK_FILTERED_PATH, "w", encoding="utf-8") as fh:
for t in blank_list:
fh.write(t + "\n")
print(f"\n空白过滤清单写入: {BLANK_FILTERED_PATH} ({len(blank_list)} 条去重文本)")
# 打印"被判空场但原文本非空"的去重清单
if blank_but_nonempty:
print("\n" + "=" * 60)
print("【被判空场但原文本非空】去重文本清单(请肉眼确认无真中文字幕)")
print("=" * 60)
for i, t in enumerate(blank_list, 1):
print(f" [{i}] {repr(t)}")
print(f"{len(blank_but_nonempty)} 条去重文本")
else:
print("\n'被判空场但原文本非空'的文本。")
print("\n阶段 B 完成。")
if __name__ == "__main__":
main()