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:
+42
-33
@@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
|
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
|
||||||
|
|
||||||
- **最后更新**:Claude Code | 2026-06-22(深夜,收摊)
|
- **最后更新**:Claude Code | 2026-06-24
|
||||||
- **当前状态一句话**:**ep002(潜艇仿生)全流程 P1→C4 完工**,已出稿 + 通哥手动批改分段。**ep004 上游仍全空**(只有骨架),下一期待跑。C4 对齐层 MiMo 批次失败率偏高(47%),ep004 段切换密需留意。
|
- **当前状态一句话**:**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/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/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/fusion_review.py` — C3:B稿v2 ⊕ ASR 交叉复审 → 融合B稿(743行) + fusion_review.csv
|
||||||
- `src/doco/cli.py` — 命令入口(split / terms / asr / fuse)
|
- `src/doco/cli.py` — 命令入口(split / terms / asr / fuse / compose / skeleton / **run**)
|
||||||
- `doco/data/term_dict.json` — 累积词典(当前 110 条)
|
- `src/doco/templates/` — P1/P2 stage 脚本模板(`doco run` 自动复制到 episode 目录)
|
||||||
|
- `doco/data/term_dict.json` — 累积词典(当前 267+ 条)
|
||||||
- `doco/programs/<episode_id>/` — 每期产物(热词表、各阶段输出)
|
- `doco/programs/<episode_id>/` — 每期产物(热词表、各阶段输出)
|
||||||
- **常用命令**:
|
- **常用命令**:
|
||||||
- C1(已实现):`doco terms --episode-id <id> --a-script <path> [--no-ai]`
|
- 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 产物分家。**
|
- 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]`
|
- 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`。**代码与对话里只引用变量名,绝不出现真值。**
|
- **环境变量 / 密钥**:只放 `doco/.env`(已在 `.gitignore`)。需要的 key:`LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`(已切换到小米 MiMo 2.5 Pro)、`XFYUN_APP_ID` / `XFYUN_SECRET_KEY`。**代码与对话里只引用变量名,绝不出现真值。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 当前进度(核心交接区,以最新快照为准)
|
## 3. 当前进度(核心交接区,以最新快照为准)
|
||||||
|
|
||||||
- **已完成至**:ep001 + ep003 + **ep002** 三期全流程跑通。ep002 C4 已出稿,通哥手动批改分段完毕。
|
- **已完成至**:ep001-004 四期全流程出稿。一键脚本 `doco run` + 批量脚本 `_batch_run.py` 已就绪。16 期骨架全部生成并通过核验。C3 prompt 已收紧(专名铁律)。C4 batch_size 已降至 25。
|
||||||
- **正在做**:无。
|
- **正在做**:无(等通哥逐期核验分段标签)。
|
||||||
- **卡点 / 待解**:ep004(枪王对决)上游全空,需跑完整 P1→C4。C4 对齐层 MiMo 批次失败率 47%(ep002 实测),ep004 段切换更密,需关注(见关键决策)。
|
- **卡点 / 待解**:20 期出稿已全部产出,通哥核验分段后 doco 收工,带成品回 TPS 主项目。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 已完成(只追加,最新在最上)
|
## 4. 已完成(只追加,最新在最上)
|
||||||
|
|
||||||
|
- [2026-06-23 |Claude Code] **批量化基础设施完工 + ep004 全流程通过 + 16 期批量跑启动**。本 session 完成:① ep004(枪王对决,最难·小剧场密·58段)全流程 P1→C4 一次通过,C4 punct_ok 58/58 全过、confidence 全 ≥0.80(batch_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 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 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。
|
- [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] **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] **C4(全部收口)**:融合B稿按 A稿28段对齐归拢 → 融合A稿 docx(公文格式)。汉字零改 + LLM 按语义加标点,红线 28/28 段逐字一致。`doco compose --episode-id <id>`。**OCR 漏字决定不补**(讨论见关键决策);seg8/seg27 两段标点接受一逗到底。
|
||||||
- [x] **A稿解析器升级**:正则 → LLM分段骨架+人工确认+ignore合并;三期骨架已产并核验通过。`doco skeleton`。
|
- [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] **【已完成】标点层 prompt 修复**:加第5条规则+示范,ep003 逗号占比 88%→76%,commit `fd2ef1a`。
|
||||||
- [x] **【已完成】配 Ollama 并发**:`OLLAMA_NUM_PARALLEL=4`,ep002 OCR 4路实跑通过,~50帧/分钟。
|
- [x] **【已完成】配 Ollama 并发**:已从 4 路提至 16 路(`OLLAMA_NUM_PARALLEL=16`),8 路实测 GPU 96%、显存充裕。
|
||||||
- [ ] **【backlog】C4 对齐层 batch_size 调优**:ep002 实测 batch_size=40 导致 47% 批次 MiMo 返回空/JSON 截断走回退。ep004 段切换更密,建议降到 25-30。Cline 在 `_run_compose.py` 里加 `--batch-size 25` 即可。
|
- [x] **【已完成】C4 对齐层 batch_size 调优**:已降至 25,ep004 实测 confidence 全 ≥0.80,无批次回退。批量脚本 `_batch_run.py` 固定用 25。
|
||||||
- [ ] **【产品 backlog】原稿缺【专家N】/【三维动画N】标签**:ep003 复查发现编导原稿里就没有专家、后续三维动画的分段标注,骨架如实反映=融合A稿里也没有(通哥手工补了高亮)。不赖程序(原稿零信号,LLM 与人一样猜不准),**不让程序猜**。治本=提醒编导/责编原稿把【专家N】等段头标全;50 期前在导入须知里写清。
|
- [ ] **【产品 backlog】原稿缺【专家N】/【三维动画N】标签**:ep003 复查发现编导原稿里就没有专家、后续三维动画的分段标注,骨架如实反映=融合A稿里也没有(通哥手工补了高亮)。不赖程序(原稿零信号,LLM 与人一样猜不准),**不让程序猜**。治本=提醒编导/责编原稿把【专家N】等段头标全;50 期前在导入须知里写清。
|
||||||
- [ ] **【backlog】C3 prompt 收紧**:专有名词(厂名/型号/番号)遇 ASR 同音异写,必须以 B稿v2/A稿为准、不许采 ASR——ep003 已踩"斯泰尔→斯太尔",50 期批量前在 prompt 里收紧防复发。
|
- [x] **【已完成】C3 prompt 收紧**:`fusion_review.py` SYSTEM_PROMPT 新增专名铁律——厂名/型号/番号/国名/人名/机构名遇 B稿与 ASR 同音异写,一律以 B稿为准,零容忍采 ASR。
|
||||||
- [ ] **串一键脚本**:把 C1→C2→C3→C4 串成"一条命令跑一期"(薄壳,各阶段已有断点缓存),验 2-3 期稳定后再谈监控文件夹/绑定界面。
|
- [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 内,错误时优雅回退。
|
- [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 行内`【固摇轨】`留在参照正文(无害,不进输出)。
|
- [ ] **骨架小瑕疵 backlog(不拦路,后期顺手或跳过)**:ep002 演播室主持人 vs 主持人 是否统一;ep004 小剧场角色编号乱(斯9/斯通纳10、卡8跨场)直接改 JSON 即可,不必重调 LLM;ep003 行内`【固摇轨】`留在参照正文(无害,不进输出)。
|
||||||
- [x] **C2** 讯飞 ASR 适配层(密钥外置 + asr_adapter 并入 + `doco asr` 命令 + 真转写310句)
|
- [x] **C2** 讯飞 ASR 适配层(密钥外置 + asr_adapter 并入 + `doco asr` 命令 + 真转写310句)
|
||||||
@@ -125,6 +132,9 @@
|
|||||||
|
|
||||||
## 6. 关键决策(为什么这么做 — 跨 session 最易丢,别推翻)
|
## 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.80,punct_ok 58/58),相比 ep002 的 batch_size=40(47% 批次回退)大幅改善。`_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] **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-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 例外走正则)。
|
- [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)
|
## 7. ⏩ 交接备注(写给下一个接手的 session)
|
||||||
|
|
||||||
> 下次开工读完这段应能 0 摩擦续上。接手后可清空重写。**(2026-06-22 深夜重写)**
|
> 下次开工读完这段应能 0 摩擦续上。接手后可清空重写。**(2026-06-23 重写)**
|
||||||
|
|
||||||
- **大局**:ep001 + ep003 + ep002 三期已全流程跑通出稿。**ep004(枪王对决)上游全空**,是下一期主线。
|
- **大局**:doco 子项目进入收尾阶段。ep001-004 四期全流程已出稿验证通过。16 期新节目(ep005-ep020)正在 `_batch_run.py` 批量跑中。20 期全部出稿后,doco 收工,融合A稿作为知识库资源带回 TPS 主项目。
|
||||||
- **ep002 收尾状态**:全流程完工。出稿 `融合A稿.docx` 硬校验全过(汉字零改),通哥已手动批改分段(`融合A稿_批改.docx`)。分段偏差根因是 A 稿与播出版内容差异大(见关键决策),非程序 bug,现阶段接受编导手调。
|
- **批量跑状态**:`python _batch_run.py` 在独立终端跑中。脚本有断点续跑能力(B稿v2 存在则跳过 P1+P2,ASR 已有则跳过转写,融合B稿已有则跳过 C3,融合A稿已有则跳过 C4)。中断后直接重跑即可。
|
||||||
- **ep004 现状**:`ep004_20260526_qiangwang_duijue`(枪王对决,最难·小剧场密)。只有 A稿 docx + mp4 + skeleton。需跑完整 P1→P2→C1→C2→C3→C4。stage 脚本从 ep003 复制。
|
- **第一句话该干的**:看 `_batch_run.py` 跑完没有。跑完看汇总表,有失败的排查原因重跑。全部成功后,逐期打开融合A稿 docx 核验分段标签,分段偏差的地方手调。
|
||||||
- **第一句话该干的**:与通哥确认是否开跑 ep004。ep004 小剧场多、段切换密,C4 对齐层可能更吃力(ep002 已有 47% 批次回退),**建议降 batch_size 到 25-30**。
|
- **LLM**:小米 MiMo 2.5 Pro,环境变量 `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`。
|
||||||
- **LLM 已切换**:代码和 Cline 都已从 DeepSeek 切到**小米 MiMo 2.5 Pro**。环境变量名 `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`(旧 `DEEPSEEK_*` 已废弃)。
|
- **Ollama**:16 路并发(`OLLAMA_NUM_PARALLEL=16`),4090D 24GB 充裕。
|
||||||
- **Ollama 并发已配**:`OLLAMA_NUM_PARALLEL=4`,ep002 实测 ~50帧/分钟(串行的3倍)。4090D 显存余量大(GPU 利用率仅 2%),ep004 可考虑提到 8 路。
|
- **关键参数**:C4 batch_size=25(`_batch_run.py` 硬编码),C3 batch_size=35。
|
||||||
- **P1 抽帧硬约束**:**不做 dHash/IoU 去重**,全部帧进 OCR,去重交 Stage B 按文本做(P2 完工快照铁律)。`doco split` 的去重逻辑是过时的,不要用。
|
|
||||||
- **量产坑(务必记住)**:
|
- **量产坑(务必记住)**:
|
||||||
1. 每期热词/术语不同,**C1 每期重跑**;A稿分段编号各有各的重复/空格脏,**按出现顺序走、别假设单调唯一**。
|
1. **C3 fuse 后必扫 `fusion_review.csv`**:专名被 ASR 同音字替换要打回。C3 prompt 已收紧专名铁律,但仍需人工复核。
|
||||||
2. **C3 fuse 后必扫 `fusion_review.csv`**:专名(厂名/型号/番号)若被 ASR 同音字改了要打回(ep003 踩过"斯泰尔→斯太尔",见关键决策)。
|
2. **C4 空段 = 播出剪了那段**,不是 bug。
|
||||||
3. **C4 出现空段先别当 bug**:多半是播出剪了那段戏,去融合B稿 grep 该段专属词确认(见关键决策)。
|
3. **C4 分段偏差是常态**(A 稿≠播出版),编导手调分段标签即可。
|
||||||
4. **C4 分段偏差是常态(A 稿≠播出版)**:内容正确但分段标签可能偏,编导手调十几分钟搞定。不追技术方案(见关键决策)。
|
4. **OCR 漏字不补**(通哥拍板),LLM 补词=破红线。
|
||||||
5. **OCR 漏字本期不补**(通哥拍板)——根在抽帧/OCR 漏两三字单屏,LLM 补词=破红线。
|
5. Cline 自报结果一律对源数据核验,不轻信。
|
||||||
6. **ASR 花讯飞额度**;长转写/OCR 放独立终端别让 Cline 盯。
|
6. thinking 分任务用:骨架/对齐开,标点/抽取/融合关。
|
||||||
7. Cline 会偷换模型/术语源、误判字数、口头猜错根因——**自报结果一律对源数据核验**(ep002 C4 报告空段名称错两个、隔断数也报错)。
|
- **出稿命名**:`{原始A稿stem}_融合A稿.docx`。
|
||||||
8. **thinking 分任务用**:语义对齐/骨架开、标点/抽取/融合关(见关键决策)。
|
- **字体坑**:大标题方正小标宋_GBK(商业字体),出稿前确认已装。
|
||||||
9. **MiMo C4 对齐批次失败率高**(ep002 47%),ep004 前考虑降 batch_size(见关键决策)。
|
- **doco 收工后的路**:20 期成品 → TPS 主项目知识库批量导入(走 Phase 3 上传/embedding 链路)→ Phase 4a 语义搜索提供冷启动数据。主项目待办三选一中的"200+ Obsidian md 批量录入"正好是接收口。
|
||||||
- **出稿命名**:`{原始A稿stem}_融合A稿.docx`(不覆盖原始定稿)。
|
- **工具脚本清单**(一次性,跑完可删):`_setup_episodes.py`(建目录+拷文件)、`_batch_skeleton.py`(批量生成骨架)、`_batch_run.py`(批量全流程)。
|
||||||
- **字体坑(C4 出稿)**:大标题方正小标宋_GBK(商业字体),出稿前确认已装,否则脚本回退。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -187,5 +195,6 @@
|
|||||||
- ~~**B稿_v2.txt 就位**~~:已完成(通哥已拷进 episode 目录并随 9340edc 入库)。
|
- ~~**B稿_v2.txt 就位**~~:已完成(通哥已拷进 episode 目录并随 9340edc 入库)。
|
||||||
- ~~**讯飞旧 key 轮换**~~:已完成(新值已在 `doco/.env`,旧 appid `84eff996` 代码内无残留)。
|
- ~~**讯飞旧 key 轮换**~~:已完成(新值已在 `doco/.env`,旧 appid `84eff996` 代码内无残留)。
|
||||||
- **方正小标宋字体可用性**:C4 出稿前确认两台机器是否已装。
|
- **方正小标宋字体可用性**:C4 出稿前确认两台机器是否已装。
|
||||||
- ~~**OCR 并发提速**~~:已完成,4路并发实测通过(ep002),后续可提到 8 路。
|
- ~~**OCR 并发提速**~~:已完成,16 路并发(4090D 24GB 充裕)。
|
||||||
- **讯飞单源依赖**(远期):是否接阿里云 ASR 做备份/交叉验证。
|
- **讯飞单源依赖**(远期):是否接阿里云 ASR 做备份/交叉验证。
|
||||||
|
- **ep016 骨架的宣传词被标为 break**:其他期(ep006/13)宣传词标为 normal,需通哥确认 ep016 是否应改。
|
||||||
+164
-113
@@ -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` |
|
| **A稿**(编导定稿) | 编导剧本的书面结构与分段 | 段落骨架、专业术语规范写法 |
|
||||||
| 音频 | 16kHz / 单声道 / 16bit WAV | `work/audio_16k.wav` |
|
| **B稿v2**(屏幕字幕 OCR) | 视频画面中"黑底白字"字幕的OCR识别结果 | 屏幕术语/型号/番号(≈A稿并列权威) |
|
||||||
| 关键帧索引 | JSON | `work/keyframes.json` |
|
| **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 用户:**
|
**Windows 用户:**
|
||||||
1. 从 https://www.gyan.dev/ffmpeg/builds/ 下载 ffmpeg (建议用 essentials 版本)
|
1. 从 https://www.gyan.dev/ffmpeg/builds/ 下载 ffmpeg(建议用 essentials 版本)
|
||||||
2. 解压到本地目录(如 `C:\ffmpeg`)
|
2. 解压到本地目录(如 `C:\ffmpeg`)
|
||||||
3. 把 `C:\ffmpeg\bin` 加入系统 PATH
|
3. 把 `C:\ffmpeg\bin` 加入系统 PATH
|
||||||
4. 打开 cmd,验证: `ffmpeg -version`
|
4. 打开 cmd,验证:`ffmpeg -version`
|
||||||
|
|
||||||
**Mac 用户:**
|
**Mac / Linux 用户:**
|
||||||
```bash
|
```bash
|
||||||
|
# Mac
|
||||||
brew install ffmpeg
|
brew install ffmpeg
|
||||||
```
|
|
||||||
|
|
||||||
**Linux 用户:**
|
# Linux
|
||||||
```bash
|
|
||||||
apt install ffmpeg
|
apt install ffmpeg
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python >= 3.12
|
### Python >= 3.12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆仓库后进入 doco 目录
|
# 1. 进入 doco 目录
|
||||||
cd doco
|
cd doco
|
||||||
|
|
||||||
# 2. 安装依赖
|
# 2. 安装(可编辑模式)
|
||||||
pip install -e .
|
pip install -e .
|
||||||
|
|
||||||
# 3. 配置凭证
|
# 3. 配置凭证(见下节)
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env,填入三组 API 凭证
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 凭证配置
|
## 凭证配置
|
||||||
|
|
||||||
Doco 使用三组独立凭证,互不混用:
|
在 `doco/.env` 中配置以下变量(**已在 .gitignore 中,不会入库**):
|
||||||
|
|
||||||
| 服务 | 用途 | 申请地址 |
|
| 变量名 | 用途 |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| 讯飞开放平台 - 录音文件转写(标准版) | 音频转文字 | https://console.xfyun.cn/ |
|
| `LLM_API_KEY` | LLM融合层API密钥(当前用小米 MiMo 2.5 Pro) |
|
||||||
| DeepSeek Vision | OCR 识别 | https://platform.deepseek.com/ |
|
| `LLM_BASE_URL` | LLM API地址(OpenAI兼容端点) |
|
||||||
| Anthropic Claude API | AI 融合层(P3) | https://console.anthropic.com/ |
|
| `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
|
```bash
|
||||||
ffmpeg -f lavfi -i testsrc=duration=5:size=320x240:rate=1 \
|
doco run \
|
||||||
-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 \
|
|
||||||
--episode-id ep001_20260612_fangkong_fandao \
|
--episode-id ep001_20260612_fangkong_fandao \
|
||||||
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4 \
|
--a-script programs/ep001_20260612_fangkong_fandao/source/a_draft.docx \
|
||||||
--output-dir programs/ep001_20260612_fangkong_fandao/work/ \
|
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4
|
||||||
--dry-run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**验收 dry-run 结果:**
|
串联 P1→P2→C1→C2→C3→C4 六个阶段,中间产物自动落盘,各阶段可断点续跑(已有产物自动跳过)。
|
||||||
1. 检查 `work/frames/` 目录下的前 3-5 张关键帧小图
|
|
||||||
2. 确认字幕被完整框住、没有切掉字
|
|
||||||
3. 如果裁切位置不对,停下来反馈
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
doco split \
|
# P1: 视频拆分(抽帧 + OCR + 音频分离)
|
||||||
--episode-id ep001_20260612_fangkong_fandao \
|
doco split --episode-id <id> --input-video <mp4> --output-dir <dir>
|
||||||
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4 \
|
|
||||||
--output-dir programs/ep001_20260612_fangkong_fandao/work/
|
# 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/
|
doco/
|
||||||
├── src/
|
├── src/doco/
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── cli.py # CLI 入口
|
│ ├── cli.py # CLI 入口(doco run/split/terms/asr/fuse/skeleton/compose)
|
||||||
│ ├── video_split.py # P1 核心:视频双路拆分
|
│ ├── video_split.py # P1: 抽帧 + 音频分离(ffmpeg)
|
||||||
│ ├── asr_adapter.py # 讯飞 ASR 适配层
|
│ ├── llm.py # LLM 统一客户端(OpenAI兼容)
|
||||||
│ └── ocr_adapter.py # P2:DeepSeek Vision OCR
|
│ ├── term_extract.py # C1: 规则层+AI层术语提取
|
||||||
├── tests/
|
│ ├── asr_adapter.py # C2: 讯飞ASR适配层
|
||||||
│ ├── test_video_split.py # 单元测试
|
│ ├── fusion_review.py # C3: B稿⊕ASR交叉复审
|
||||||
│ └── fixtures/
|
│ ├── fusion_align.py # C4: A稿骨架抽取+语义对齐+出稿
|
||||||
│ └── mini_test.mp4 # 迷你测试视频(需 Step B 生成)
|
│ └── templates/ # P1/P2 模板脚本
|
||||||
├── docs/
|
│ ├── stage_a_extract_ocr.py # P1 抽帧+OCR
|
||||||
├── .env.example # 凭证模板
|
│ └── stage_b_dedup_output.py # P2 文本去重
|
||||||
├── README.md
|
├── programs/ # 每期节目产物(按 episode_id 分目录)
|
||||||
└── pyproject.toml
|
│ └── <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`
|
- **项目协作主控文件**:`CLAUDE.md`(状态、交接、关键决策,新接手者首选阅读)
|
||||||
- 设计文档: `docs/doco/doco_project_design.md`
|
- **子项目Brief**:`note/Doco子项目_Brief.md`(红线、技术栈、出入口接口)
|
||||||
- 讯飞接入笔记: `docs/doco/doco_xfyun_integration_notes.md`
|
- **PRD**:`note/PRD_doco_文稿整理子项目_v2.md`(需求规格、方案选型)
|
||||||
- 主项目回复: `docs/doco/主project对Doco_PRDv2的回复.md`
|
- **P3设计稿**:`note/doco_P3_设计稿.md`(三方融合架构设计)
|
||||||
|
- **快照与决策记录**:`note/` 目录下其他文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **语言**:Python ≥ 3.12
|
||||||
|
- **LLM**:小米 MiMo 2.5 Pro(OpenAI兼容端点,`openai` SDK)
|
||||||
|
- **OCR**:本地 Ollama + DeepSeek-OCR 模型
|
||||||
|
- **ASR**:讯飞开放平台 录音文件转写(标准版)
|
||||||
|
- **视频处理**:ffmpeg(subprocess调用)
|
||||||
|
- **文档生成**:python-docx
|
||||||
|
- **CLI框架**:Click
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,8 @@ LANGUAGE = "cn" # 中文普通话
|
|||||||
PD = "mil" # 军事领域优化
|
PD = "mil" # 军事领域优化
|
||||||
ENG_SMOOTHPROC = "true" # 顺滑(去掉"嗯/那个")
|
ENG_SMOOTHPROC = "true" # 顺滑(去掉"嗯/那个")
|
||||||
ENG_COLLOQPROC = "true" # 口语规整
|
ENG_COLLOQPROC = "true" # 口语规整
|
||||||
|
ROLE_TYPE = "1" # 说话人分离: 0=关闭, 1=通用角色分离
|
||||||
|
ROLE_NUM = "0" # 说话人数: 0=盲分(自动检测)
|
||||||
|
|
||||||
# 轮询配置
|
# 轮询配置
|
||||||
POLL_INTERVAL_SECONDS = 30
|
POLL_INTERVAL_SECONDS = 30
|
||||||
@@ -190,6 +192,8 @@ def upload_audio(
|
|||||||
"pd": PD,
|
"pd": PD,
|
||||||
"eng_smoothproc": ENG_SMOOTHPROC,
|
"eng_smoothproc": ENG_SMOOTHPROC,
|
||||||
"eng_colloqproc": ENG_COLLOQPROC,
|
"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]]:
|
def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
|
||||||
"""
|
"""
|
||||||
解析嵌套JSON,返回 [(sentence_start_ms, sentence_end_ms, text), ...]
|
解析嵌套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:
|
if not order_result_str:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
cleaned = re.sub(r"\\\\", r"\\", order_result_str)
|
cleaned = order_result_str.replace("\\\\", "\\")
|
||||||
outer = json.loads(cleaned)
|
outer = json.loads(cleaned)
|
||||||
|
|
||||||
sentences = []
|
sentences = []
|
||||||
@@ -286,6 +299,7 @@ def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
|
|||||||
st = inner.get("st", {})
|
st = inner.get("st", {})
|
||||||
bg = int(st.get("bg", 0))
|
bg = int(st.get("bg", 0))
|
||||||
ed = int(st.get("ed", 0))
|
ed = int(st.get("ed", 0))
|
||||||
|
rl = int(st.get("rl", 0))
|
||||||
|
|
||||||
words = []
|
words = []
|
||||||
for rt in st.get("rt", []):
|
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)
|
words.append(w)
|
||||||
sentence = "".join(words).strip()
|
sentence = "".join(words).strip()
|
||||||
if sentence:
|
if sentence:
|
||||||
sentences.append((bg, ed, sentence))
|
sentences.append((bg, ed, sentence, rl))
|
||||||
|
|
||||||
return sentences
|
return sentences
|
||||||
|
|
||||||
@@ -346,7 +360,19 @@ def write_asr_result(
|
|||||||
if raw_order_result:
|
if raw_order_result:
|
||||||
f.write(raw_order_result)
|
f.write(raw_order_result)
|
||||||
else:
|
else:
|
||||||
# 没有原始数据时写空对象(兼容旧调用)
|
|
||||||
f.write("{}")
|
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
|
return timed_path, raw_path
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ doco CLI 入口
|
|||||||
P1: doco split 子命令
|
P1: doco split 子命令
|
||||||
P3: doco process 子命令(带 --input-a-draft 和 --cleanup-level)
|
P3: doco process 子命令(带 --input-a-draft 和 --cleanup-level)
|
||||||
P3 C1: doco terms 子命令
|
P3 C1: doco terms 子命令
|
||||||
|
P3 run: 一键全流程 P1→P2→C1→C2→C3→C4
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -453,6 +456,17 @@ def asr(
|
|||||||
click.echo(f"[ok] asr_result_raw.json: {raw_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")
|
@main.command("compose")
|
||||||
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||||||
@click.option(
|
@click.option(
|
||||||
@@ -507,6 +521,7 @@ def compose(
|
|||||||
)
|
)
|
||||||
click.echo(f"[ok] 总行数: {stats['total_lines']}")
|
click.echo(f"[ok] 总行数: {stats['total_lines']}")
|
||||||
click.echo(f"[ok] 段数: {stats['segment_count']}")
|
click.echo(f"[ok] 段数: {stats['segment_count']}")
|
||||||
|
if "empty_segments" in stats:
|
||||||
click.echo(f"[ok] 空段数: {stats['empty_segments']}")
|
click.echo(f"[ok] 空段数: {stats['empty_segments']}")
|
||||||
click.echo(f"[ok] 低把握段数: {stats['low_confidence_segments']}")
|
click.echo(f"[ok] 低把握段数: {stats['low_confidence_segments']}")
|
||||||
click.echo(f"[ok] 单调修正行数: {stats['audit_forced_lines']}")
|
click.echo(f"[ok] 单调修正行数: {stats['audit_forced_lines']}")
|
||||||
@@ -517,5 +532,252 @@ def compose(
|
|||||||
sys.exit(1)
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1013,7 +1013,7 @@ def _parse_align_json(raw: str, expected_len: int) -> List[dict]:
|
|||||||
f"LLM 返回不是 JSON 数组, 类型为 {type(result).__name__}"
|
f"LLM 返回不是 JSON 数组, 类型为 {type(result).__name__}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(result) != expected_len:
|
if expected_len > 0 and len(result) != expected_len:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"LLM 返回 {len(result)} 条记录, 期望 {expected_len} 条"
|
f"LLM 返回 {len(result)} 条记录, 期望 {expected_len} 条"
|
||||||
)
|
)
|
||||||
@@ -1149,6 +1149,407 @@ def align_lines_to_segments(
|
|||||||
return all_records, all_audit, normal_segs
|
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. 硬校验
|
# 6. 硬校验
|
||||||
# ====================================================================
|
# ====================================================================
|
||||||
@@ -1627,7 +2028,14 @@ def run_compose(
|
|||||||
f"| body_len={len(seg['body'])}"
|
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"
|
cache_dir = out_dir / ".c4_cache"
|
||||||
alignment, audit_logs, normal_segs = align_lines_to_segments(
|
alignment, audit_logs, normal_segs = align_lines_to_segments(
|
||||||
b_lines,
|
b_lines,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ SYSTEM_PROMPT = """你是《军事科技》专题片文稿校审员。给你 B
|
|||||||
权威优先级:
|
权威优先级:
|
||||||
- 屏幕术语/型号/番号(箭-3/萨德/见证者-136等): B稿为准(屏幕实打的字)
|
- 屏幕术语/型号/番号(箭-3/萨德/见证者-136等): B稿为准(屏幕实打的字)
|
||||||
- B稿明显是OCR错字而ASR是对的: 用ASR覆盖
|
- B稿明显是OCR错字而ASR是对的: 用ASR覆盖
|
||||||
|
- ⚠️ 专有名词铁律:厂名/型号/番号/国名/人名/机构名等专名,遇B稿与ASR同音异写(如斯泰尔vs斯太尔、美以vs美伊),一律以B稿/A稿书面写法为准,零容忍采ASR。ASR是口语转写,同音字极多,专名绝不信ASR。
|
||||||
- 同音事实错(如"美以"vs"美伊"): 以书面规范为准,存疑进review
|
- 同音事实错(如"美以"vs"美伊"): 以书面规范为准,存疑进review
|
||||||
- 一两个字的等价差异(的/地、啊等语气): 算 unchanged,不要改
|
- 一两个字的等价差异(的/地、啊等语气): 算 unchanged,不要改
|
||||||
每行输出: line_no, final_text(纠错后,默认等于B原文), change_type(7选1), confidence(0~1), reason(简短,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()
|
||||||
Reference in New Issue
Block a user