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:
+43
-34
@@ -16,8 +16,8 @@
|
||||
|
||||
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
|
||||
|
||||
- **最后更新**:Claude Code | 2026-06-22(深夜,收摊)
|
||||
- **当前状态一句话**:**ep002(潜艇仿生)全流程 P1→C4 完工**,已出稿 + 通哥手动批改分段。**ep004 上游仍全空**(只有骨架),下一期待跑。C4 对齐层 MiMo 批次失败率偏高(47%),ep004 段切换密需留意。
|
||||
- **最后更新**:Claude Code | 2026-06-24
|
||||
- **当前状态一句话**:**20 期全部出稿完成(16/16 批量跑零失败)。** doco 流水线验证通过。下一步:通哥逐期核验分段 → doco 收工 → 带 20 期成品回 TPS 主项目知识库。
|
||||
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
|
||||
|
||||
---
|
||||
@@ -63,28 +63,33 @@
|
||||
- `src/doco/asr_adapter.py` — C2 讯飞 ASR 适配层(已完工):`get_hot_words(episode_id)` 读热词表、`transcribe()` 返回 `(句子, raw)`、`write_asr_result()` 落 `asr_v2_timed.txt` + `asr_result_raw.json`
|
||||
- `src/doco/video_split.py` — P1:含 `extract_audio()`(16kHz/单声道/16bit WAV),C2 直接复用,不另写 ffmpeg
|
||||
- `src/doco/fusion_review.py` — C3:B稿v2 ⊕ ASR 交叉复审 → 融合B稿(743行) + fusion_review.csv
|
||||
- `src/doco/cli.py` — 命令入口(split / terms / asr / fuse)
|
||||
- `doco/data/term_dict.json` — 累积词典(当前 110 条)
|
||||
- `src/doco/cli.py` — 命令入口(split / terms / asr / fuse / compose / skeleton / **run**)
|
||||
- `src/doco/templates/` — P1/P2 stage 脚本模板(`doco run` 自动复制到 episode 目录)
|
||||
- `doco/data/term_dict.json` — 累积词典(当前 267+ 条)
|
||||
- `doco/programs/<episode_id>/` — 每期产物(热词表、各阶段输出)
|
||||
- **常用命令**:
|
||||
- C1(已实现):`doco terms --episode-id <id> --a-script <path> [--no-ai]`
|
||||
- C2(已实现):`doco asr --episode-id <id> --input-video <绝对路径mp4> --output-dir <绝对路径 doco/programs/<id>>`(`--skip-asr` 只分离不转写)。**务必传绝对 `--output-dir`,否则落到当前工作目录的 `programs/` 会与 doco 产物分家。**
|
||||
- C3(已实现):`doco fuse --episode-id <id> [--no-ai] [--batch-size 35]`
|
||||
- C4:待实现
|
||||
- C4(已实现):`doco compose --episode-id <id> [--batch-size 25]`
|
||||
- 骨架:`doco skeleton --episode-id <id> --a-script <docx>`(需人工核验后再跑 compose)
|
||||
- **一键全流程**:`doco run --episode-id <id> --a-script <docx> --input-video <mp4> [--batch-size 25] [--skip-p1]`
|
||||
- **批量跑**:`python _batch_run.py`(16 期自动串跑,断点续跑,已有产物自动跳过)
|
||||
- **环境变量 / 密钥**:只放 `doco/.env`(已在 `.gitignore`)。需要的 key:`LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`(已切换到小米 MiMo 2.5 Pro)、`XFYUN_APP_ID` / `XFYUN_SECRET_KEY`。**代码与对话里只引用变量名,绝不出现真值。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前进度(核心交接区,以最新快照为准)
|
||||
|
||||
- **已完成至**:ep001 + ep003 + **ep002** 三期全流程跑通。ep002 C4 已出稿,通哥手动批改分段完毕。
|
||||
- **正在做**:无。
|
||||
- **卡点 / 待解**:ep004(枪王对决)上游全空,需跑完整 P1→C4。C4 对齐层 MiMo 批次失败率 47%(ep002 实测),ep004 段切换更密,需关注(见关键决策)。
|
||||
- **已完成至**:ep001-004 四期全流程出稿。一键脚本 `doco run` + 批量脚本 `_batch_run.py` 已就绪。16 期骨架全部生成并通过核验。C3 prompt 已收紧(专名铁律)。C4 batch_size 已降至 25。
|
||||
- **正在做**:无(等通哥逐期核验分段标签)。
|
||||
- **卡点 / 待解**:20 期出稿已全部产出,通哥核验分段后 doco 收工,带成品回 TPS 主项目。
|
||||
|
||||
---
|
||||
|
||||
## 4. 已完成(只追加,最新在最上)
|
||||
|
||||
- [2026-06-23 |Claude Code] **批量化基础设施完工 + ep004 全流程通过 + 16 期批量跑启动**。本 session 完成:① ep004(枪王对决,最难·小剧场密·58段)全流程 P1→C4 一次通过,C4 punct_ok 58/58 全过、confidence 全 ≥0.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 C3 融合通过**。融合B稿 733行(零偏移),review 10条(5 minor_edit 全是 OCR 错字修正,专名无同音替换)。C4 compose 交 Cline 跑中。
|
||||
- [2026-06-22 |Claude Code] **ep002(潜艇仿生)P1→P2→C1→C2 完成**。P1 全量抽帧 1620 帧(不做 dHash/IoU 去重,全部进 OCR);P2 OCR 4路并发首秀通过(~50帧/分钟,约串行3倍),Stage B 文本去重出 B稿v2 733行;C1 术语提取 68 条热词(词典累积至 235 条);C2 讯飞 ASR 411 句。下一步 C3 fuse。
|
||||
@@ -107,13 +112,15 @@
|
||||
- [x] **C3**:B稿v2 ⊕ ASR 交叉复审 → 融合B稿(743行时间戳零偏移) + fusion_review.csv(5条留痕)。
|
||||
- [x] **C4(全部收口)**:融合B稿按 A稿28段对齐归拢 → 融合A稿 docx(公文格式)。汉字零改 + LLM 按语义加标点,红线 28/28 段逐字一致。`doco compose --episode-id <id>`。**OCR 漏字决定不补**(讨论见关键决策);seg8/seg27 两段标点接受一逗到底。
|
||||
- [x] **A稿解析器升级**:正则 → LLM分段骨架+人工确认+ignore合并;三期骨架已产并核验通过。`doco skeleton`。
|
||||
- [~] **批量化首批三期·跑完整流水线(下一步主线)**:ep001 + ep003 + ep002 三期已全流程完工。**ep004 上游仍全空**(只有骨架),需跑完整 P1→C4。stage 脚本从 ep003 复制。
|
||||
- [x] **批量化 ep001-004 全流程完工**:四期均已出稿。ep004(最难·小剧场密·58段)C4 全过,batch_size=25 效果显著。
|
||||
- [x] **【已完成】标点层 prompt 修复**:加第5条规则+示范,ep003 逗号占比 88%→76%,commit `fd2ef1a`。
|
||||
- [x] **【已完成】配 Ollama 并发**:`OLLAMA_NUM_PARALLEL=4`,ep002 OCR 4路实跑通过,~50帧/分钟。
|
||||
- [ ] **【backlog】C4 对齐层 batch_size 调优**:ep002 实测 batch_size=40 导致 47% 批次 MiMo 返回空/JSON 截断走回退。ep004 段切换更密,建议降到 25-30。Cline 在 `_run_compose.py` 里加 `--batch-size 25` 即可。
|
||||
- [x] **【已完成】配 Ollama 并发**:已从 4 路提至 16 路(`OLLAMA_NUM_PARALLEL=16`),8 路实测 GPU 96%、显存充裕。
|
||||
- [x] **【已完成】C4 对齐层 batch_size 调优**:已降至 25,ep004 实测 confidence 全 ≥0.80,无批次回退。批量脚本 `_batch_run.py` 固定用 25。
|
||||
- [ ] **【产品 backlog】原稿缺【专家N】/【三维动画N】标签**:ep003 复查发现编导原稿里就没有专家、后续三维动画的分段标注,骨架如实反映=融合A稿里也没有(通哥手工补了高亮)。不赖程序(原稿零信号,LLM 与人一样猜不准),**不让程序猜**。治本=提醒编导/责编原稿把【专家N】等段头标全;50 期前在导入须知里写清。
|
||||
- [ ] **【backlog】C3 prompt 收紧**:专有名词(厂名/型号/番号)遇 ASR 同音异写,必须以 B稿v2/A稿为准、不许采 ASR——ep003 已踩"斯泰尔→斯太尔",50 期批量前在 prompt 里收紧防复发。
|
||||
- [ ] **串一键脚本**:把 C1→C2→C3→C4 串成"一条命令跑一期"(薄壳,各阶段已有断点缓存),验 2-3 期稳定后再谈监控文件夹/绑定界面。
|
||||
- [x] **【已完成】C3 prompt 收紧**:`fusion_review.py` SYSTEM_PROMPT 新增专名铁律——厂名/型号/番号/国名/人名/机构名遇 B稿与 ASR 同音异写,一律以 B稿为准,零容忍采 ASR。
|
||||
- [x] **【已完成】串一键脚本**:`doco run` 命令(P1→P2→C1→C2→C3→C4 六阶段串联),ep004 验证通过。模板脚本放 `src/doco/templates/`。
|
||||
- [x] **批量跑 20 期(完成)**:ep001-004 + ep005-ep020 共 20 期全部出稿,16 期批量跑零失败(847 分钟),B稿行数 699-874,融合B稿行数全部零偏移。
|
||||
- [ ] **doco 收工后回 TPS 主项目**:20 期融合A稿批量导入知识库(走 Phase 3 已有的上传/embedding 链路),为 Phase 4a 语义搜索提供冷启动数据。
|
||||
- [x] **【已修复】`fusion_align.py` align_batch 崩溃 bug**:`_parse_align_json` 调用在 `try/except` 之外,LLM 返回空字符串时进程崩溃。Cline 已修,移入 try/except 内,错误时优雅回退。
|
||||
- [ ] **骨架小瑕疵 backlog(不拦路,后期顺手或跳过)**:ep002 演播室主持人 vs 主持人 是否统一;ep004 小剧场角色编号乱(斯9/斯通纳10、卡8跨场)直接改 JSON 即可,不必重调 LLM;ep003 行内`【固摇轨】`留在参照正文(无害,不进输出)。
|
||||
- [x] **C2** 讯飞 ASR 适配层(密钥外置 + asr_adapter 并入 + `doco asr` 命令 + 真转写310句)
|
||||
@@ -125,6 +132,9 @@
|
||||
|
||||
## 6. 关键决策(为什么这么做 — 跨 session 最易丢,别推翻)
|
||||
|
||||
- [2026-06-23] **doco PRD 完成标准:20 期全出稿 → 带成品回 TPS 主项目。** ep004 跑通 + 一键脚本就绪 = 流水线可用;剩余 16 期在 doco 子项目内批量跑完(一方面压力测试积累问题,一方面 20 期成品对 TPS 知识库冷启动有价值)。回归路径:融合A稿作为 manuscript 类文档 → 走主项目 Phase 3 知识库上传/embedding 链路 → Phase 4a 语义搜索。
|
||||
- [2026-06-23] **C4 batch_size=25 为量产默认值。** ep004 实测 58 段全过(confidence 全 ≥0.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] **MiMo 2.5 Pro C4 对齐层批次失败率偏高(ep002 实测 9/19=47%)。** 失败批次全 confidence=0.30,回退机制把该批所有行分配给 `min_normal_seg_id`。ep002 运气好——失败批次全在大段中间,回退恰好分对了。但 ep004 段切换密,边界批次失败会导致行归错段。**建议 ep004 前把 batch_size 从 40 降到 25-30**,或排查 MiMo 返回空/JSON 截断的具体原因。
|
||||
- [2026-06-18] **A稿段头识别改用「LLM 判结构 + 人工核骨架」,不用死正则。** 编导写法千变万化(方括号/冒号/前缀杂质`Xr`/`【固摇轨】`镜头标记/小剧场对话头),正则分不清`【固】`(镜头)与`【主持人1】`(段头)——这是语义判断,交 LLM。**铁律:LLM 只输出结构(type/role_label/para 区间),绝不复述正文;正文一律代码按下标从 docx 原样抽**(护住"汉字零改"红线)。配两道闸:① `validate_skeleton_coverage` 全覆盖硬校验(title 后每段恰好被覆盖一次,自动抓 LLM 数错下标/JSON 截断);② 人类预览表给制片人肉眼核(重点抓真人姓名泄露/ignore 漏多/隔断认全)。`doco compose` 无骨架即报错(ep001 例外走正则)。
|
||||
@@ -158,27 +168,25 @@
|
||||
|
||||
## 7. ⏩ 交接备注(写给下一个接手的 session)
|
||||
|
||||
> 下次开工读完这段应能 0 摩擦续上。接手后可清空重写。**(2026-06-22 深夜重写)**
|
||||
> 下次开工读完这段应能 0 摩擦续上。接手后可清空重写。**(2026-06-23 重写)**
|
||||
|
||||
- **大局**:ep001 + ep003 + ep002 三期已全流程跑通出稿。**ep004(枪王对决)上游全空**,是下一期主线。
|
||||
- **ep002 收尾状态**:全流程完工。出稿 `融合A稿.docx` 硬校验全过(汉字零改),通哥已手动批改分段(`融合A稿_批改.docx`)。分段偏差根因是 A 稿与播出版内容差异大(见关键决策),非程序 bug,现阶段接受编导手调。
|
||||
- **ep004 现状**:`ep004_20260526_qiangwang_duijue`(枪王对决,最难·小剧场密)。只有 A稿 docx + mp4 + skeleton。需跑完整 P1→P2→C1→C2→C3→C4。stage 脚本从 ep003 复制。
|
||||
- **第一句话该干的**:与通哥确认是否开跑 ep004。ep004 小剧场多、段切换密,C4 对齐层可能更吃力(ep002 已有 47% 批次回退),**建议降 batch_size 到 25-30**。
|
||||
- **LLM 已切换**:代码和 Cline 都已从 DeepSeek 切到**小米 MiMo 2.5 Pro**。环境变量名 `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`(旧 `DEEPSEEK_*` 已废弃)。
|
||||
- **Ollama 并发已配**:`OLLAMA_NUM_PARALLEL=4`,ep002 实测 ~50帧/分钟(串行的3倍)。4090D 显存余量大(GPU 利用率仅 2%),ep004 可考虑提到 8 路。
|
||||
- **P1 抽帧硬约束**:**不做 dHash/IoU 去重**,全部帧进 OCR,去重交 Stage B 按文本做(P2 完工快照铁律)。`doco split` 的去重逻辑是过时的,不要用。
|
||||
- **大局**:doco 子项目进入收尾阶段。ep001-004 四期全流程已出稿验证通过。16 期新节目(ep005-ep020)正在 `_batch_run.py` 批量跑中。20 期全部出稿后,doco 收工,融合A稿作为知识库资源带回 TPS 主项目。
|
||||
- **批量跑状态**:`python _batch_run.py` 在独立终端跑中。脚本有断点续跑能力(B稿v2 存在则跳过 P1+P2,ASR 已有则跳过转写,融合B稿已有则跳过 C3,融合A稿已有则跳过 C4)。中断后直接重跑即可。
|
||||
- **第一句话该干的**:看 `_batch_run.py` 跑完没有。跑完看汇总表,有失败的排查原因重跑。全部成功后,逐期打开融合A稿 docx 核验分段标签,分段偏差的地方手调。
|
||||
- **LLM**:小米 MiMo 2.5 Pro,环境变量 `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL`。
|
||||
- **Ollama**:16 路并发(`OLLAMA_NUM_PARALLEL=16`),4090D 24GB 充裕。
|
||||
- **关键参数**:C4 batch_size=25(`_batch_run.py` 硬编码),C3 batch_size=35。
|
||||
- **量产坑(务必记住)**:
|
||||
1. 每期热词/术语不同,**C1 每期重跑**;A稿分段编号各有各的重复/空格脏,**按出现顺序走、别假设单调唯一**。
|
||||
2. **C3 fuse 后必扫 `fusion_review.csv`**:专名(厂名/型号/番号)若被 ASR 同音字改了要打回(ep003 踩过"斯泰尔→斯太尔",见关键决策)。
|
||||
3. **C4 出现空段先别当 bug**:多半是播出剪了那段戏,去融合B稿 grep 该段专属词确认(见关键决策)。
|
||||
4. **C4 分段偏差是常态(A 稿≠播出版)**:内容正确但分段标签可能偏,编导手调十几分钟搞定。不追技术方案(见关键决策)。
|
||||
5. **OCR 漏字本期不补**(通哥拍板)——根在抽帧/OCR 漏两三字单屏,LLM 补词=破红线。
|
||||
6. **ASR 花讯飞额度**;长转写/OCR 放独立终端别让 Cline 盯。
|
||||
7. Cline 会偷换模型/术语源、误判字数、口头猜错根因——**自报结果一律对源数据核验**(ep002 C4 报告空段名称错两个、隔断数也报错)。
|
||||
8. **thinking 分任务用**:语义对齐/骨架开、标点/抽取/融合关(见关键决策)。
|
||||
9. **MiMo C4 对齐批次失败率高**(ep002 47%),ep004 前考虑降 batch_size(见关键决策)。
|
||||
- **出稿命名**:`{原始A稿stem}_融合A稿.docx`(不覆盖原始定稿)。
|
||||
- **字体坑(C4 出稿)**:大标题方正小标宋_GBK(商业字体),出稿前确认已装,否则脚本回退。
|
||||
1. **C3 fuse 后必扫 `fusion_review.csv`**:专名被 ASR 同音字替换要打回。C3 prompt 已收紧专名铁律,但仍需人工复核。
|
||||
2. **C4 空段 = 播出剪了那段**,不是 bug。
|
||||
3. **C4 分段偏差是常态**(A 稿≠播出版),编导手调分段标签即可。
|
||||
4. **OCR 漏字不补**(通哥拍板),LLM 补词=破红线。
|
||||
5. Cline 自报结果一律对源数据核验,不轻信。
|
||||
6. thinking 分任务用:骨架/对齐开,标点/抽取/融合关。
|
||||
- **出稿命名**:`{原始A稿stem}_融合A稿.docx`。
|
||||
- **字体坑**:大标题方正小标宋_GBK(商业字体),出稿前确认已装。
|
||||
- **doco 收工后的路**:20 期成品 → TPS 主项目知识库批量导入(走 Phase 3 上传/embedding 链路)→ Phase 4a 语义搜索提供冷启动数据。主项目待办三选一中的"200+ Obsidian md 批量录入"正好是接收口。
|
||||
- **工具脚本清单**(一次性,跑完可删):`_setup_episodes.py`(建目录+拷文件)、`_batch_skeleton.py`(批量生成骨架)、`_batch_run.py`(批量全流程)。
|
||||
|
||||
---
|
||||
|
||||
@@ -187,5 +195,6 @@
|
||||
- ~~**B稿_v2.txt 就位**~~:已完成(通哥已拷进 episode 目录并随 9340edc 入库)。
|
||||
- ~~**讯飞旧 key 轮换**~~:已完成(新值已在 `doco/.env`,旧 appid `84eff996` 代码内无残留)。
|
||||
- **方正小标宋字体可用性**:C4 出稿前确认两台机器是否已装。
|
||||
- ~~**OCR 并发提速**~~:已完成,4路并发实测通过(ep002),后续可提到 8 路。
|
||||
- **讯飞单源依赖**(远期):是否接阿里云 ASR 做备份/交叉验证。
|
||||
- ~~**OCR 并发提速**~~:已完成,16 路并发(4090D 24GB 充裕)。
|
||||
- **讯飞单源依赖**(远期):是否接阿里云 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` |
|
||||
| 音频 | 16kHz / 单声道 / 16bit WAV | `work/audio_16k.wav` |
|
||||
| 关键帧索引 | JSON | `work/keyframes.json` |
|
||||
| **A稿**(编导定稿) | 编导剧本的书面结构与分段 | 段落骨架、专业术语规范写法 |
|
||||
| **B稿v2**(屏幕字幕 OCR) | 视频画面中"黑底白字"字幕的OCR识别结果 | 屏幕术语/型号/番号(≈A稿并列权威) |
|
||||
| **ASR**(口语转写) | 音轨经讯飞转写的口语实录 | 实际语音、语气、临场措辞 |
|
||||
|
||||
**铁律**:正文**汉字零改**——所有正文内容100%来自B稿v2,AI只负责纠错OCR错字、语义对齐分段、按语义插入标点,绝不改任何一个汉字。
|
||||
|
||||
最终产出两个**内容一致、形态不同**的交付物:
|
||||
|
||||
| 交付物 | 给谁 | 形态 |
|
||||
|---|---|---|
|
||||
| **融合B稿** | 爱德华(字幕/片段定位) | 碎句 + 密集字幕级时间戳 `[XmYs] 文本` |
|
||||
| **融合A稿** | 编导存档 | 公文格式 docx,保留【导视】【主持人N】【解说N】【专家N】【隔断】分段 |
|
||||
|
||||
一致性约束:融合A稿**由融合B稿生成**(按A稿分段归拢 + 套格式),不是事后比对硬凑。
|
||||
|
||||
---
|
||||
|
||||
## 六阶段流水线架构
|
||||
|
||||
```
|
||||
A稿 docx ──► ① 术语提取(C1) ──► 本期热词表
|
||||
│
|
||||
视频 mp4 ──► ② 音频分离 ──► 讯飞ASR(C2) ──► ASR文本(带时间戳)
|
||||
(黑底白字+ │ │
|
||||
干净人声) 抽帧+OCR(P1)──► 文本去重(P2)──► B稿v2(碎句+时间戳)
|
||||
│
|
||||
B稿v2 ⊕ ASR ──► ③ 交叉复审(C3) ──► 融合B稿
|
||||
│
|
||||
融合B稿 + A稿骨架 ──► ④ 语义对齐(C4) ──► 融合A稿.docx
|
||||
```
|
||||
|
||||
| 阶段 | 子命令 | 做什么 | 产物 |
|
||||
|---|---|---|---|
|
||||
| **P1** | `doco split` | ffmpeg抽帧 + OCR识别屏幕字幕 | 关键帧、音频WAV |
|
||||
| **P2** | (模板脚本自动) | 字幕文本去重、格式化 | B稿v2.txt(约700-870行) |
|
||||
| **C1** | `doco terms` | 从A稿提取专有名词 → 累积词典 → 热词表 | 本期热词表(给ASR用) |
|
||||
| **C2** | `doco asr` | 音频分离 → 讯飞ASR转写 | asr_v2_timed.txt |
|
||||
| **C3** | `doco fuse` | B稿⊕ASR 交叉复审,AI纠错 | 融合B稿.txt + fusion_review.csv |
|
||||
| **C4** | `doco compose` | 按A稿分段骨架语义对齐 → 套公文格式 | 融合A稿.docx + c4_alignment.csv |
|
||||
|
||||
---
|
||||
|
||||
## 系统依赖
|
||||
|
||||
### ffmpeg (必须)
|
||||
### ffmpeg(必须)
|
||||
|
||||
**Windows 用户:**
|
||||
1. 从 https://www.gyan.dev/ffmpeg/builds/ 下载 ffmpeg (建议用 essentials 版本)
|
||||
2. 解压到本地目录(如 `C:\ffmpeg`)
|
||||
**Windows 用户:**
|
||||
1. 从 https://www.gyan.dev/ffmpeg/builds/ 下载 ffmpeg(建议用 essentials 版本)
|
||||
2. 解压到本地目录(如 `C:\ffmpeg`)
|
||||
3. 把 `C:\ffmpeg\bin` 加入系统 PATH
|
||||
4. 打开 cmd,验证: `ffmpeg -version`
|
||||
4. 打开 cmd,验证:`ffmpeg -version`
|
||||
|
||||
**Mac 用户:**
|
||||
**Mac / Linux 用户:**
|
||||
```bash
|
||||
# Mac
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
**Linux 用户:**
|
||||
```bash
|
||||
# Linux
|
||||
apt install ffmpeg
|
||||
```
|
||||
|
||||
### Python >= 3.12
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库后进入 doco 目录
|
||||
# 1. 进入 doco 目录
|
||||
cd doco
|
||||
|
||||
# 2. 安装依赖
|
||||
# 2. 安装(可编辑模式)
|
||||
pip install -e .
|
||||
|
||||
# 3. 配置凭证
|
||||
# 3. 配置凭证(见下节)
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入三组 API 凭证
|
||||
```
|
||||
|
||||
## 凭证配置
|
||||
|
||||
Doco 使用三组独立凭证,互不混用:
|
||||
在 `doco/.env` 中配置以下变量(**已在 .gitignore 中,不会入库**):
|
||||
|
||||
| 服务 | 用途 | 申请地址 |
|
||||
|---|---|---|
|
||||
| 讯飞开放平台 - 录音文件转写(标准版) | 音频转文字 | https://console.xfyun.cn/ |
|
||||
| DeepSeek Vision | OCR 识别 | https://platform.deepseek.com/ |
|
||||
| Anthropic Claude API | AI 融合层(P3) | https://console.anthropic.com/ |
|
||||
| 变量名 | 用途 |
|
||||
|---|---|
|
||||
| `LLM_API_KEY` | LLM融合层API密钥(当前用小米 MiMo 2.5 Pro) |
|
||||
| `LLM_BASE_URL` | LLM API地址(OpenAI兼容端点) |
|
||||
| `LLM_MODEL` | 模型名称(如 `mimo-v2.5-pro`) |
|
||||
| `XFYUN_APP_ID` | 讯飞开放平台 APP ID |
|
||||
| `XFYUN_SECRET_KEY` | 讯飞开放平台 SECRET KEY |
|
||||
|
||||
> 注意: 讯飞要用"录音文件转写标准版",不要用"大模型版"
|
||||
> ⚠️ 讯飞要用「录音文件转写**标准版**」,不要用"大模型版"(免费包阉割 `language` 参数,会报误导性错误)。
|
||||
|
||||
## 使用步骤(按顺序,不要跳步)
|
||||
---
|
||||
|
||||
### Step A. 安装 ffmpeg
|
||||
## 使用方式
|
||||
|
||||
见上方"系统依赖"一节。安装后打开 cmd 验证 `ffmpeg -version` 能看到版本号。
|
||||
|
||||
### Step B. 生成迷你测试视频(验证 ffmpeg 装好了)
|
||||
### 一键全流程(推荐)
|
||||
|
||||
```bash
|
||||
ffmpeg -f lavfi -i testsrc=duration=5:size=320x240:rate=1 \
|
||||
-f lavfi -i anullsrc=channel_layout=mono:sample_rate=16000 \
|
||||
-c:v libx264 -c:a aac -shortest \
|
||||
doco/tests/fixtures/mini_test.mp4 -y
|
||||
```
|
||||
|
||||
出现 `mini_test.mp4` 文件即成功。
|
||||
|
||||
### Step C. 把 demo 视频放到指定位置
|
||||
|
||||
```bash
|
||||
# 把 demo 视频文件复制到:
|
||||
programs/ep001_20260612_fangkong_fandao/source/video.mp4
|
||||
```
|
||||
|
||||
> video.mp4 由制片人放入,不放进 git(已加入 .gitignore)
|
||||
|
||||
### Step D. 配置凭证
|
||||
|
||||
```bash
|
||||
cp doco/.env.example doco/.env
|
||||
# 用记事本或 VS Code 编辑 doco/.env,填入三组真实 API key
|
||||
```
|
||||
|
||||
### Step E. 安装 doco 包
|
||||
|
||||
```bash
|
||||
cd doco && pip install -e .
|
||||
```
|
||||
|
||||
### Step F. 跑 dry-run(只裁切,不调 OCR API)
|
||||
|
||||
**重要:先跑 dry-run,确认裁切框包住字幕后再跑正式版。**
|
||||
|
||||
```bash
|
||||
doco split \
|
||||
doco run \
|
||||
--episode-id ep001_20260612_fangkong_fandao \
|
||||
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4 \
|
||||
--output-dir programs/ep001_20260612_fangkong_fandao/work/ \
|
||||
--dry-run
|
||||
--a-script programs/ep001_20260612_fangkong_fandao/source/a_draft.docx \
|
||||
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4
|
||||
```
|
||||
|
||||
**验收 dry-run 结果:**
|
||||
1. 检查 `work/frames/` 目录下的前 3-5 张关键帧小图
|
||||
2. 确认字幕被完整框住、没有切掉字
|
||||
3. 如果裁切位置不对,停下来反馈
|
||||
串联 P1→P2→C1→C2→C3→C4 六个阶段,中间产物自动落盘,各阶段可断点续跑(已有产物自动跳过)。
|
||||
|
||||
### Step G. 跑正式版(去掉 --dry-run)
|
||||
**可选参数:**
|
||||
- `--skip-p1`:跳过P1/P2(已有B稿v2时使用)
|
||||
- `--batch-size 25`:C4对齐每批行数(默认25,可调)
|
||||
|
||||
dry-run 验收通过后,跑正式版:
|
||||
> ⚠️ C4 开始前要求骨架文件已存在,需**先手动**运行 `doco skeleton` 并人工核验:
|
||||
> ```bash
|
||||
> doco skeleton --episode-id <id> --a-script <a_draft.docx>
|
||||
> # 检查输出的骨架预览表,确认无误后再跑 doco run
|
||||
> ```
|
||||
|
||||
### 各子命令(可单独运行)
|
||||
|
||||
```bash
|
||||
doco split \
|
||||
--episode-id ep001_20260612_fangkong_fandao \
|
||||
--input-video programs/ep001_20260612_fangkong_fandao/source/video.mp4 \
|
||||
--output-dir programs/ep001_20260612_fangkong_fandao/work/
|
||||
# P1: 视频拆分(抽帧 + OCR + 音频分离)
|
||||
doco split --episode-id <id> --input-video <mp4> --output-dir <dir>
|
||||
|
||||
# C1: 术语提取
|
||||
doco terms --episode-id <id> --a-script <docx>
|
||||
|
||||
# C2: 讯飞ASR转写
|
||||
doco asr --episode-id <id> --input-video <mp4> --output-dir <dir>
|
||||
|
||||
# C3: 交叉复审融合
|
||||
doco fuse --episode-id <id> [--batch-size 35]
|
||||
|
||||
# C4: 对齐出稿
|
||||
doco compose --episode-id <id> [--batch-size 25]
|
||||
```
|
||||
|
||||
### 输出产物
|
||||
> ⚠️ `--output-dir` 务必传**绝对路径**,否则产物会落到当前工作目录,与 doco 产物分家。
|
||||
|
||||
```
|
||||
programs/ep001_20260612_fangkong_fandao/work/
|
||||
├── frames/ # 抽出的帧(临时)
|
||||
├── audio_16k.wav # 音频(16kHz/单声道/16bit)
|
||||
├── b_manuscript.txt # B 稿([Nm Ns] 句子格式)
|
||||
└── keyframes.json # 关键帧索引(含裁切参数)
|
||||
```
|
||||
|
||||
## P1 验收标准
|
||||
|
||||
1. `work/b_manuscript.txt` 格式为 `[Nm Ns] 句子`,每行一句
|
||||
2. `work/audio_16k.wav` 规格为 16kHz/单声道/16bit,能被讯飞 ASR 接收
|
||||
3. `work/keyframes.json` 字段符合定义
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
doco/
|
||||
├── src/
|
||||
├── src/doco/
|
||||
│ ├── __init__.py
|
||||
│ ├── cli.py # CLI 入口
|
||||
│ ├── video_split.py # P1 核心:视频双路拆分
|
||||
│ ├── asr_adapter.py # 讯飞 ASR 适配层
|
||||
│ └── ocr_adapter.py # P2:DeepSeek Vision OCR
|
||||
├── tests/
|
||||
│ ├── test_video_split.py # 单元测试
|
||||
│ └── fixtures/
|
||||
│ └── mini_test.mp4 # 迷你测试视频(需 Step B 生成)
|
||||
├── docs/
|
||||
├── .env.example # 凭证模板
|
||||
├── README.md
|
||||
└── pyproject.toml
|
||||
│ ├── cli.py # CLI 入口(doco run/split/terms/asr/fuse/skeleton/compose)
|
||||
│ ├── video_split.py # P1: 抽帧 + 音频分离(ffmpeg)
|
||||
│ ├── llm.py # LLM 统一客户端(OpenAI兼容)
|
||||
│ ├── term_extract.py # C1: 规则层+AI层术语提取
|
||||
│ ├── asr_adapter.py # C2: 讯飞ASR适配层
|
||||
│ ├── fusion_review.py # C3: B稿⊕ASR交叉复审
|
||||
│ ├── fusion_align.py # C4: A稿骨架抽取+语义对齐+出稿
|
||||
│ └── templates/ # P1/P2 模板脚本
|
||||
│ ├── stage_a_extract_ocr.py # P1 抽帧+OCR
|
||||
│ └── stage_b_dedup_output.py # P2 文本去重
|
||||
├── programs/ # 每期节目产物(按 episode_id 分目录)
|
||||
│ └── <episode_id>/
|
||||
│ ├── source/ # 输入(video.mp4 + a_draft.docx)
|
||||
│ ├── B稿_v2.txt # P2 产出的OCR字幕文本
|
||||
│ ├── audio_16k.wav # 分离的音频(16kHz/单声道/16bit)
|
||||
│ ├── asr_v2_timed.txt # ASR转写结果(带时间戳)
|
||||
│ ├── <id>_a_skeleton.json # A稿分段骨架
|
||||
│ ├── 融合B稿.txt # C3 产出
|
||||
│ ├── fusion_review.csv # C3 复审留痕
|
||||
│ ├── 融合A稿.docx # C4 最终交付物
|
||||
│ └── c4_alignment.csv # C4 对齐留痕
|
||||
├── data/
|
||||
│ └── term_dict.json # 累积术语词典(逐期更新)
|
||||
├── deliverables/ # 已完成的融合A稿展示
|
||||
├── note/ # 设计文档、PRD、决策记录
|
||||
├── tests/ # 测试
|
||||
├── CLAUDE.md # 项目协作主控文件(交接、决策、状态)
|
||||
├── pyproject.toml
|
||||
└── .env.example # 凭证模板
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 设计原则
|
||||
|
||||
- **汉字零改**:正文100%来自B稿v2,AI只做OCR纠错+语义对齐+标点插入,绝不改任何一个汉字。`strip_punct()`硬校验守门。
|
||||
- **有序无阻塞**:全自动产出,拿不准的地方全部进 `fusion_review.csv` 留痕,绝不卡出稿。
|
||||
- **各阶段解耦**:中间产物落缓存,可断点续跑,可单独重跑,失败不影响已完成阶段。
|
||||
- **专名铁律**:厂名/型号/番号/国名/人名/机构名,B稿与ASR同音异写时**一律以B稿为准**,零容忍采ASR。
|
||||
- **OCR漏字不补**:缺的字是真实信息丢失,不让LLM补词(LLM补词=猜词=破红线)。
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- Brief: `docs/doco/Doco子项目_Brief.md`
|
||||
- 设计文档: `docs/doco/doco_project_design.md`
|
||||
- 讯飞接入笔记: `docs/doco/doco_xfyun_integration_notes.md`
|
||||
- 主项目回复: `docs/doco/主project对Doco_PRDv2的回复.md`
|
||||
- **项目协作主控文件**:`CLAUDE.md`(状态、交接、关键决策,新接手者首选阅读)
|
||||
- **子项目Brief**:`note/Doco子项目_Brief.md`(红线、技术栈、出入口接口)
|
||||
- **PRD**:`note/PRD_doco_文稿整理子项目_v2.md`(需求规格、方案选型)
|
||||
- **P3设计稿**:`note/doco_P3_设计稿.md`(三方融合架构设计)
|
||||
- **快照与决策记录**:`note/` 目录下其他文件
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **语言**:Python ≥ 3.12
|
||||
- **LLM**:小米 MiMo 2.5 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" # 军事领域优化
|
||||
ENG_SMOOTHPROC = "true" # 顺滑(去掉"嗯/那个")
|
||||
ENG_COLLOQPROC = "true" # 口语规整
|
||||
ROLE_TYPE = "1" # 说话人分离: 0=关闭, 1=通用角色分离
|
||||
ROLE_NUM = "0" # 说话人数: 0=盲分(自动检测)
|
||||
|
||||
# 轮询配置
|
||||
POLL_INTERVAL_SECONDS = 30
|
||||
@@ -190,6 +192,8 @@ def upload_audio(
|
||||
"pd": PD,
|
||||
"eng_smoothproc": ENG_SMOOTHPROC,
|
||||
"eng_colloqproc": ENG_COLLOQPROC,
|
||||
"roleType": ROLE_TYPE,
|
||||
"roleNum": ROLE_NUM,
|
||||
}
|
||||
|
||||
# 热词,用 | 分隔
|
||||
@@ -270,11 +274,20 @@ def poll_until_done(order_id: str) -> dict:
|
||||
def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
|
||||
"""
|
||||
解析嵌套JSON,返回 [(sentence_start_ms, sentence_end_ms, text), ...]
|
||||
兼容旧调用(不含说话人信息)。
|
||||
"""
|
||||
return [(bg, ed, text) for bg, ed, text, _rl in parse_order_result_with_speaker(order_result_str)]
|
||||
|
||||
|
||||
def parse_order_result_with_speaker(order_result_str: str) -> List[Tuple[int, int, str, int]]:
|
||||
"""
|
||||
解析嵌套JSON,返回 [(start_ms, end_ms, text, speaker_id), ...]
|
||||
speaker_id 来自讯飞 rl 字段(0=未分离或同一人,1/2/3...=不同说话人)。
|
||||
"""
|
||||
if not order_result_str:
|
||||
return []
|
||||
|
||||
cleaned = re.sub(r"\\\\", r"\\", order_result_str)
|
||||
cleaned = order_result_str.replace("\\\\", "\\")
|
||||
outer = json.loads(cleaned)
|
||||
|
||||
sentences = []
|
||||
@@ -286,6 +299,7 @@ def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
|
||||
st = inner.get("st", {})
|
||||
bg = int(st.get("bg", 0))
|
||||
ed = int(st.get("ed", 0))
|
||||
rl = int(st.get("rl", 0))
|
||||
|
||||
words = []
|
||||
for rt in st.get("rt", []):
|
||||
@@ -297,7 +311,7 @@ def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
|
||||
words.append(w)
|
||||
sentence = "".join(words).strip()
|
||||
if sentence:
|
||||
sentences.append((bg, ed, sentence))
|
||||
sentences.append((bg, ed, sentence, rl))
|
||||
|
||||
return sentences
|
||||
|
||||
@@ -346,7 +360,19 @@ def write_asr_result(
|
||||
if raw_order_result:
|
||||
f.write(raw_order_result)
|
||||
else:
|
||||
# 没有原始数据时写空对象(兼容旧调用)
|
||||
f.write("{}")
|
||||
|
||||
# 如果 raw 数据含说话人信息,额外输出带说话人标注的版本
|
||||
if raw_order_result:
|
||||
spk_sentences = parse_order_result_with_speaker(raw_order_result)
|
||||
has_speaker = any(rl != 0 for _, _, _, rl in spk_sentences)
|
||||
if has_speaker:
|
||||
spk_lines = [
|
||||
f"[{format_timestamp(bg)}] <spk:{rl}> {text}"
|
||||
for bg, _, text, rl in spk_sentences
|
||||
]
|
||||
spk_path = os.path.join(output_dir, "asr_v2_timed_spk.txt")
|
||||
with open(spk_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(spk_lines))
|
||||
|
||||
return timed_path, raw_path
|
||||
|
||||
+265
-3
@@ -4,9 +4,12 @@ doco CLI 入口
|
||||
P1: doco split 子命令
|
||||
P3: doco process 子命令(带 --input-a-draft 和 --cleanup-level)
|
||||
P3 C1: doco terms 子命令
|
||||
P3 run: 一键全流程 P1→P2→C1→C2→C3→C4
|
||||
"""
|
||||
|
||||
import click
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -453,6 +456,17 @@ def asr(
|
||||
click.echo(f"[ok] asr_result_raw.json: {raw_path}")
|
||||
|
||||
|
||||
# 模板脚本目录(stage_a_extract_ocr.py / stage_b_dedup_output.py)
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
||||
|
||||
|
||||
def _stage_header(title: str):
|
||||
"""打印阶段分隔线"""
|
||||
click.echo("═════════════════════════════")
|
||||
click.echo(title)
|
||||
click.echo("═════════════════════════════")
|
||||
|
||||
|
||||
@main.command("compose")
|
||||
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||||
@click.option(
|
||||
@@ -507,9 +521,10 @@ def compose(
|
||||
)
|
||||
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']}")
|
||||
if "empty_segments" in stats:
|
||||
click.echo(f"[ok] 空段数: {stats['empty_segments']}")
|
||||
click.echo(f"[ok] 低把握段数: {stats['low_confidence_segments']}")
|
||||
click.echo(f"[ok] 单调修正行数: {stats['audit_forced_lines']}")
|
||||
click.echo(f"[ok] 融合A稿: {stats['docx_path']}")
|
||||
click.echo(f"[ok] 留痕 CSV: {stats['csv_path']}")
|
||||
except Exception as e:
|
||||
@@ -517,5 +532,252 @@ def compose(
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("run")
|
||||
@click.option("--episode-id", required=True, help="节目 ID,如 ep002_20260127_qianting_fangsheng")
|
||||
@click.option(
|
||||
"--a-script",
|
||||
required=True,
|
||||
type=click.Path(exists=True),
|
||||
help="A 稿 docx 路径",
|
||||
)
|
||||
@click.option(
|
||||
"--input-video",
|
||||
required=True,
|
||||
type=click.Path(exists=True),
|
||||
help="输入视频 mp4 路径",
|
||||
)
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
default=25,
|
||||
type=int,
|
||||
help="C4 对齐用每批行数(默认 25)",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-p1",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="跳过 P1/P2(抽帧+OCR+去重),从 C1 续跑(已有 B稿v2 时)",
|
||||
)
|
||||
def run(
|
||||
episode_id: str,
|
||||
a_script: str,
|
||||
input_video: str,
|
||||
batch_size: int,
|
||||
skip_p1: bool,
|
||||
):
|
||||
"""
|
||||
一键全流程: P1→P2→C1→C2→C3→C4
|
||||
|
||||
串联抽帧+OCR(P1)、文本去重(P2)、术语提取(C1)、ASR 转写(C2)、
|
||||
融合复审(C3)、对齐出稿(C4) 六个阶段。
|
||||
|
||||
用 --skip-p1 可跳过 P1/P2,从 C1 续跑(适用于已有 B稿v2 的场景)。
|
||||
C4 开始前要求骨架文件已存在(需先手动跑 doco skeleton 并人工核验)。
|
||||
"""
|
||||
from .asr_adapter import get_audio_duration_ms as _wav_duration
|
||||
|
||||
episode_dir = Path("programs") / episode_id
|
||||
episode_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
video_path = Path(input_video)
|
||||
a_script_path = Path(a_script)
|
||||
|
||||
# 记录已完成阶段,用于失败时打印
|
||||
completed_stages: list = []
|
||||
|
||||
# ── 汇总数据 ──
|
||||
b_v2_lines = 0
|
||||
hotword_count = 0
|
||||
asr_sentence_count = 0
|
||||
fused_b_lines = 0
|
||||
fused_a_docx = ""
|
||||
|
||||
try:
|
||||
# ════════════════════════════════════════════════════════
|
||||
# P1: 抽帧 + OCR
|
||||
# ════════════════════════════════════════════════════════
|
||||
if not skip_p1:
|
||||
_stage_header("P1: 抽帧 + OCR")
|
||||
|
||||
stage_a_path = episode_dir / "stage_a_extract_ocr.py"
|
||||
if not stage_a_path.exists():
|
||||
src = TEMPLATES_DIR / "stage_a_extract_ocr.py"
|
||||
click.echo(f"[run] 复制模板: {src} → {stage_a_path}")
|
||||
shutil.copy2(str(src), str(stage_a_path))
|
||||
|
||||
click.echo(f"[run] 执行: {sys.executable} {stage_a_path}")
|
||||
click.echo(f"[run] 工作目录: {episode_dir}")
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(stage_a_path)],
|
||||
cwd=str(episode_dir),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"P1 stage_a_extract_ocr.py 退出码: {proc.returncode}")
|
||||
|
||||
completed_stages.append("P1: 抽帧 + OCR")
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# P2: 文本去重
|
||||
# ════════════════════════════════════════════════════════
|
||||
if not skip_p1:
|
||||
_stage_header("P2: 文本去重")
|
||||
|
||||
stage_b_path = episode_dir / "stage_b_dedup_output.py"
|
||||
if not stage_b_path.exists():
|
||||
src = TEMPLATES_DIR / "stage_b_dedup_output.py"
|
||||
click.echo(f"[run] 复制模板: {src} → {stage_b_path}")
|
||||
shutil.copy2(str(src), str(stage_b_path))
|
||||
|
||||
click.echo(f"[run] 执行: {sys.executable} {stage_b_path}")
|
||||
click.echo(f"[run] 工作目录: {episode_dir}")
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(stage_b_path)],
|
||||
cwd=str(episode_dir),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"P2 stage_b_dedup_output.py 退出码: {proc.returncode}")
|
||||
|
||||
b_v2_path = episode_dir / "B稿_v2.txt"
|
||||
if not b_v2_path.exists():
|
||||
raise FileNotFoundError(f"P2 跑完但 B稿_v2.txt 不存在: {b_v2_path}")
|
||||
|
||||
completed_stages.append("P2: 文本去重")
|
||||
|
||||
# 读 B稿_v2 行数(无论是否 skip_p1,后续步骤都用得到)
|
||||
b_v2_path = episode_dir / "B稿_v2.txt"
|
||||
if b_v2_path.exists():
|
||||
with open(b_v2_path, "r", encoding="utf-8") as fh:
|
||||
b_v2_lines = sum(1 for line in fh if line.strip())
|
||||
elif not skip_p1:
|
||||
raise FileNotFoundError(f"B稿_v2.txt 不存在: {b_v2_path}")
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"使用 --skip-p1 但 B稿_v2.txt 不存在: {b_v2_path}\n"
|
||||
"请先跑 P1+P2 或确认 B稿_v2.txt 已就绪。"
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# C1: 术语提取
|
||||
# ════════════════════════════════════════════════════════
|
||||
_stage_header("C1: 术语提取")
|
||||
|
||||
c1_result = run_terms(
|
||||
episode_id=episode_id,
|
||||
a_script_path=a_script_path,
|
||||
no_ai=False,
|
||||
)
|
||||
hotword_count = c1_result.get("hotword_count", 0)
|
||||
click.echo(f"[run] C1 完成: 规则 {c1_result.get('rule_count', 0)} 条, "
|
||||
f"AI {c1_result.get('ai_count', 0)} 条, "
|
||||
f"热词 {hotword_count} 条")
|
||||
|
||||
completed_stages.append("C1: 术语提取")
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# C2: ASR
|
||||
# ════════════════════════════════════════════════════════
|
||||
_stage_header("C2: ASR 转写")
|
||||
|
||||
asr_timed_path = episode_dir / "asr_v2_timed.txt"
|
||||
wav_path = episode_dir / "audio_16k.wav"
|
||||
|
||||
# 分离音频(已存在则复用)
|
||||
if wav_path.exists():
|
||||
click.echo(f"[run] audio_16k.wav 已存在,复用: {wav_path}")
|
||||
else:
|
||||
click.echo("[run] 从视频分离音频(16kHz/单声道/16bit)...")
|
||||
extract_audio(video_path, wav_path)
|
||||
click.echo(f"[run] 音频分离完成: {wav_path}")
|
||||
|
||||
if asr_timed_path.exists():
|
||||
click.echo(f"[run] asr_v2_timed.txt 已存在,跳过 ASR(花钱的步骤不重复跑): {asr_timed_path}")
|
||||
else:
|
||||
hot_words = get_hot_words(episode_id)
|
||||
click.echo(f"[run] 热词条数: {len(hot_words)}")
|
||||
click.echo("[run] 上传音频 → 讯飞 ASR 转写(可能需要数分钟)...")
|
||||
|
||||
sentences, raw_order_result = transcribe(str(wav_path), hot_words=hot_words)
|
||||
asr_sentence_count = len(sentences)
|
||||
|
||||
timed_path, raw_path = write_asr_result(
|
||||
sentences,
|
||||
str(episode_dir),
|
||||
raw_order_result=raw_order_result,
|
||||
)
|
||||
click.echo(f"[run] ASR 完成: {asr_sentence_count} 句")
|
||||
|
||||
# 如果跳过了 ASR(已存在),读取句子数用于汇总
|
||||
if asr_sentence_count == 0 and asr_timed_path.exists():
|
||||
with open(asr_timed_path, "r", encoding="utf-8") as fh:
|
||||
asr_sentence_count = sum(1 for line in fh if line.strip())
|
||||
|
||||
completed_stages.append("C2: ASR")
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# C3: 融合复审
|
||||
# ════════════════════════════════════════════════════════
|
||||
_stage_header("C3: 融合复审")
|
||||
|
||||
c3_stats = run_fusion(
|
||||
episode_id=episode_id,
|
||||
output_dir=str(episode_dir),
|
||||
no_ai=False,
|
||||
batch_size=35,
|
||||
)
|
||||
fused_b_lines = c3_stats.get("total_lines", 0)
|
||||
click.echo(f"[run] C3 完成: 融合B稿 {fused_b_lines} 行")
|
||||
|
||||
completed_stages.append("C3: 融合复审")
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# C4: 对齐出稿
|
||||
# ════════════════════════════════════════════════════════
|
||||
_stage_header("C4: 对齐出稿")
|
||||
|
||||
# 检查骨架文件
|
||||
skeleton_path = episode_dir / f"{episode_id}_a_skeleton.json"
|
||||
if not skeleton_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"骨架文件不存在: {skeleton_path}\n"
|
||||
f"骨架需人工核验,请先手动运行: doco skeleton --episode-id {episode_id} "
|
||||
f"--a-script {a_script}\n"
|
||||
f"核验无误后,再运行 doco run。"
|
||||
)
|
||||
|
||||
c4_stats = run_compose(
|
||||
episode_id=episode_id,
|
||||
output_dir=str(episode_dir),
|
||||
no_ai=False,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
fused_a_docx = c4_stats.get("docx_path", "")
|
||||
click.echo(f"[run] C4 完成: 融合A稿 → {fused_a_docx}")
|
||||
|
||||
completed_stages.append("C4: 对齐出稿")
|
||||
|
||||
except Exception as e:
|
||||
click.echo("")
|
||||
click.echo("═════════════════════════════")
|
||||
click.echo("❌ 流程中断")
|
||||
click.echo("═════════════════════════════")
|
||||
if completed_stages:
|
||||
click.echo("已完成的阶段:")
|
||||
for s in completed_stages:
|
||||
click.echo(f" ✅ {s}")
|
||||
click.echo(f"失败阶段: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# ── 全部完成 ──
|
||||
click.echo("")
|
||||
_stage_header("✅ 全流程完成")
|
||||
click.echo(f"B稿v2: {b_v2_lines} 行")
|
||||
click.echo(f"热词: {hotword_count} 条")
|
||||
click.echo(f"ASR: {asr_sentence_count} 句")
|
||||
click.echo(f"融合B稿: {fused_b_lines} 行")
|
||||
click.echo(f"融合A稿: {fused_a_docx}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1013,7 +1013,7 @@ def _parse_align_json(raw: str, expected_len: int) -> List[dict]:
|
||||
f"LLM 返回不是 JSON 数组, 类型为 {type(result).__name__}"
|
||||
)
|
||||
|
||||
if len(result) != expected_len:
|
||||
if expected_len > 0 and len(result) != expected_len:
|
||||
raise ValueError(
|
||||
f"LLM 返回 {len(result)} 条记录, 期望 {expected_len} 条"
|
||||
)
|
||||
@@ -1149,6 +1149,407 @@ def align_lines_to_segments(
|
||||
return all_records, all_audit, normal_segs
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 5b. Speaker-aware alignment (v2)
|
||||
# ====================================================================
|
||||
|
||||
|
||||
def _check_has_speaker_data(asr_raw_path: Path) -> bool:
|
||||
"""检查 ASR raw JSON 是否含说话人分离数据(rl!=0)。"""
|
||||
if not asr_raw_path.exists():
|
||||
return False
|
||||
try:
|
||||
from .asr_adapter import parse_order_result_with_speaker
|
||||
raw = asr_raw_path.read_text(encoding="utf-8")
|
||||
entries = parse_order_result_with_speaker(raw)
|
||||
return any(rl != 0 for _, _, _, rl in entries)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _annotate_b_lines_with_speakers(
|
||||
b_lines: List[dict], asr_raw_path: Path
|
||||
) -> None:
|
||||
"""按时间区间匹配,给每行 B稿挂上 speaker_id。
|
||||
|
||||
优先:B稿时间戳落在 ASR 句子的 [bg, ed] 区间内 → 用该句的说话人。
|
||||
退回:无区间命中时,按最近 bg 距离匹配。
|
||||
"""
|
||||
from .asr_adapter import parse_order_result_with_speaker
|
||||
raw = asr_raw_path.read_text(encoding="utf-8")
|
||||
asr_entries = parse_order_result_with_speaker(raw)
|
||||
# (start_ms, end_ms, speaker_id)
|
||||
asr_ranges = [(int(bg), int(ed), int(rl)) for bg, ed, _, rl in asr_entries]
|
||||
|
||||
for bl in b_lines:
|
||||
ts_ms = bl["ts_sec"] * 1000
|
||||
|
||||
# 优先:区间匹配
|
||||
matched = False
|
||||
for bg, ed, rl in asr_ranges:
|
||||
if bg <= ts_ms <= ed:
|
||||
bl["speaker_id"] = rl
|
||||
matched = True
|
||||
break
|
||||
|
||||
if not matched:
|
||||
# 退回:最近距离
|
||||
best_spk = 0
|
||||
best_dist = float("inf")
|
||||
for bg, ed, rl in asr_ranges:
|
||||
dist = abs(bg - ts_ms)
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_spk = rl
|
||||
bl["speaker_id"] = best_spk
|
||||
|
||||
|
||||
def _detect_speaker_blocks(b_lines: List[dict]) -> List[dict]:
|
||||
"""将连续同说话人的 B稿行分组为 block。"""
|
||||
blocks: List[dict] = []
|
||||
current_spk = None
|
||||
current_lines: List[dict] = []
|
||||
|
||||
for bl in b_lines:
|
||||
spk = bl.get("speaker_id", 0)
|
||||
if spk != current_spk and current_lines:
|
||||
blocks.append({
|
||||
"block_id": len(blocks),
|
||||
"speaker_id": current_spk,
|
||||
"lines": list(current_lines),
|
||||
})
|
||||
current_lines = []
|
||||
current_spk = spk
|
||||
current_lines.append(bl)
|
||||
|
||||
if current_lines:
|
||||
blocks.append({
|
||||
"block_id": len(blocks),
|
||||
"speaker_id": current_spk,
|
||||
"lines": list(current_lines),
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
SYSTEM_PROMPT_SPEAKER_ALIGN = """你是《军事科技》专题片分段对齐员。给你 A稿分段骨架(编导录制前写的分段结构)和播出版按说话人自动分组的段落块。
|
||||
|
||||
A稿是编导在录制前写的脚本,播出版可能有以下差异:
|
||||
- 段落顺序调整(如解说和专家段互换位置)
|
||||
- 隔断段(如"街头采访")在A稿只是一个标题,但播出中有多人实际说话
|
||||
- 播出中新增了A稿没有的内容
|
||||
- A稿有的段播出中删掉了
|
||||
|
||||
你的任务:为每个播出版段落块(block)分配段落标签。**关键:一个block可能跨越多个A稿段落(尤其是同一配音员念不同类型内容,如解说→三维动画解说→解说),此时必须拆分。**
|
||||
|
||||
规则:
|
||||
1. 说话人切换是分段的天然边界。
|
||||
2. 多个不同说话人的短段落如果聚在一起(如街采多人发言),应统一标为一个街采段,给它们分配同一个seg_id(对应A稿的隔断段),segment_label统一为【街采N】。
|
||||
3. 同一说话人在不同时段出现,通常属于不同分段(如主持人出现多次是不同的主持人段)。
|
||||
4. 隔断段如果播出中有对应内容,它就应该接收block。
|
||||
5. **segment_label必须使用A稿骨架中的原始标签**,包括修饰词。如果A稿骨架写的是【三维动画解说1】就必须用【三维动画解说1】,**绝不简化为【解说N】**。同理【演播室主持人N】不简化为【主持人N】。
|
||||
6. **大block拆分**:对于标记了"(需拆分)"的大block,会提供逐行明细(行号+时间戳+开头文字)。你必须结合A稿骨架判断该block实际覆盖了哪几个A稿段落,然后输出多条记录,每条用 start_line 和 end_line 指定行范围(block内部从0开始的下标,闭区间)。例如一个92行block实际是"解说1(0-30行)+三维动画解说1(31-60行)+解说2(61-91行)",就输出3条。
|
||||
7. **小block**(没有逐行明细的):直接分配一个段落,不需要 start_line/end_line。
|
||||
|
||||
输出JSON数组,每条格式:
|
||||
{"block_id":int, "seg_id":int, "segment_label":"【...】", "confidence":0.0~1.0, "start_line":int或null, "end_line":int或null}
|
||||
|
||||
其中 start_line/end_line 只在拆分大block时才需要填(block内部行号,从0开始,闭区间)。小block不填或填null。"""
|
||||
|
||||
|
||||
def _align_speaker_blocks(
|
||||
blocks: List[dict],
|
||||
all_segments: List[dict],
|
||||
cache_dir: Optional[Path] = None,
|
||||
) -> List[dict]:
|
||||
"""一次 LLM 调用,为每个说话人 block 分配段落。"""
|
||||
|
||||
seg_lines = []
|
||||
for seg in all_segments:
|
||||
seg_type = seg.get("type", "normal")
|
||||
type_tag = " [隔断]" if seg_type == "break" else ""
|
||||
body = seg.get("body", "")
|
||||
body_preview = body[:200].replace("\n", " ") if body else "(无正文)"
|
||||
seg_lines.append(
|
||||
f"seg_{seg['seg_id']} | {seg['header']}{type_tag} | {body_preview}"
|
||||
)
|
||||
|
||||
LARGE_BLOCK_THRESHOLD = 40 # 超过此行数的block提供逐行明细
|
||||
|
||||
block_lines = []
|
||||
for block in blocks:
|
||||
spk = block["speaker_id"]
|
||||
n = len(block["lines"])
|
||||
first_ts = block["lines"][0]["ts_sec"]
|
||||
last_ts = block["lines"][-1]["ts_sec"]
|
||||
ts_start = f"{first_ts // 60}m{first_ts % 60}s"
|
||||
ts_end = f"{last_ts // 60}m{last_ts % 60}s"
|
||||
|
||||
texts = [bl["text"] for bl in block["lines"]]
|
||||
is_large = n > LARGE_BLOCK_THRESHOLD
|
||||
|
||||
if is_large:
|
||||
# 大 block: 提供逐行明细, 让 LLM 识别内部分段边界
|
||||
header = (
|
||||
f"block_{block['block_id']} | 说话人{spk} | "
|
||||
f"{ts_start}-{ts_end} | {n}句 (需拆分)"
|
||||
)
|
||||
detail_lines = []
|
||||
for idx, bl in enumerate(block["lines"]):
|
||||
t = bl["ts_sec"]
|
||||
ts_fmt = f"{t // 60}m{t % 60}s"
|
||||
detail_lines.append(f" 行{idx}: [{ts_fmt}] {bl['text'][:50]}")
|
||||
block_lines.append(header + "\n" + "\n".join(detail_lines))
|
||||
else:
|
||||
if len(texts) <= 3:
|
||||
preview = " / ".join(t[:60] for t in texts)
|
||||
else:
|
||||
preview = f"{texts[0][:60]} / ... / {texts[-1][:60]}"
|
||||
block_lines.append(
|
||||
f"block_{block['block_id']} | 说话人{spk} | "
|
||||
f"{ts_start}-{ts_end} | {n}句 | {preview[:200]}"
|
||||
)
|
||||
|
||||
user_content = (
|
||||
f"A稿分段骨架(共 {len(all_segments)} 段):\n\n"
|
||||
+ "\n".join(seg_lines)
|
||||
+ f"\n\n--- 播出版说话人段落(共 {len(blocks)} 块,按播出时间排列)---\n\n"
|
||||
+ "\n".join(block_lines)
|
||||
+ "\n\n请为每个 block 分配 seg_id 和 segment_label。"
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT_SPEAKER_ALIGN},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
if cache_dir:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_path = cache_dir / "speaker_align.json"
|
||||
if cache_path.exists():
|
||||
try:
|
||||
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
# 缓存条数 >= blocks 数即合法(大 block 拆分后条数更多)
|
||||
cached_block_ids = {a.get("block_id") for a in cached}
|
||||
if all(b["block_id"] in cached_block_ids for b in blocks):
|
||||
print(
|
||||
f"[fusion_align] 复用说话人分段缓存 ({len(cached)} 条)"
|
||||
)
|
||||
return cached
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[fusion_align] 调用 LLM 说话人分段对齐 ({len(blocks)} 块)...")
|
||||
raw_response = chat(
|
||||
messages,
|
||||
thinking=True,
|
||||
max_tokens=12000,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
# 允许条数 >= blocks(大 block 拆分后多条)
|
||||
parsed = _parse_align_json(raw_response, -1)
|
||||
|
||||
if cache_dir:
|
||||
cache_path = cache_dir / "speaker_align.json"
|
||||
cache_path.write_text(
|
||||
json.dumps(parsed, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def _build_broadcast_segments(
|
||||
blocks: List[dict],
|
||||
block_assignments: List[dict],
|
||||
) -> List[dict]:
|
||||
"""
|
||||
从 block 赋值结果构建播出序分段。
|
||||
支持大 block 拆分(一个 block 多条 assignment,按 start_line/end_line 切行)。
|
||||
相邻同 label 的片段合并为一段。
|
||||
"""
|
||||
# 按 block_id 分组 assignment,保持顺序
|
||||
from collections import defaultdict
|
||||
assignments_by_block: Dict[int, List[dict]] = defaultdict(list)
|
||||
for a in block_assignments:
|
||||
assignments_by_block[a["block_id"]].append(a)
|
||||
|
||||
# 展开:每个 block 可能产出多个 (label, seg_id, lines) 片段
|
||||
expanded: List[Tuple[str, int, List[str]]] = []
|
||||
|
||||
for block in blocks:
|
||||
bid = block["block_id"]
|
||||
assigns = assignments_by_block.get(bid, [])
|
||||
block_line_texts = [bl["text"] for bl in block["lines"]]
|
||||
|
||||
if len(assigns) == 1 and assigns[0].get("start_line") is None:
|
||||
# 简单赋值,整个 block 一个标签
|
||||
label = assigns[0].get("segment_label", "【未知】")
|
||||
seg_id = assigns[0].get("seg_id", -1)
|
||||
expanded.append((label, seg_id, block_line_texts))
|
||||
elif len(assigns) > 1:
|
||||
# 大 block 拆分:按 start_line/end_line 切行
|
||||
# 排序确保按行号顺序
|
||||
sorted_assigns = sorted(
|
||||
assigns,
|
||||
key=lambda x: x.get("start_line", 0) or 0,
|
||||
)
|
||||
for sa in sorted_assigns:
|
||||
sl = sa.get("start_line", 0) or 0
|
||||
el = sa.get("end_line", len(block_line_texts) - 1)
|
||||
if el is None:
|
||||
el = len(block_line_texts) - 1
|
||||
label = sa.get("segment_label", "【未知】")
|
||||
seg_id = sa.get("seg_id", -1)
|
||||
lines_slice = block_line_texts[sl:el + 1]
|
||||
if lines_slice:
|
||||
expanded.append((label, seg_id, lines_slice))
|
||||
else:
|
||||
# fallback: 单条有 start_line
|
||||
for sa in assigns:
|
||||
sl = sa.get("start_line", 0) or 0
|
||||
el = sa.get("end_line", len(block_line_texts) - 1)
|
||||
if el is None:
|
||||
el = len(block_line_texts) - 1
|
||||
label = sa.get("segment_label", "【未知】")
|
||||
seg_id = sa.get("seg_id", -1)
|
||||
lines_slice = block_line_texts[sl:el + 1]
|
||||
if lines_slice:
|
||||
expanded.append((label, seg_id, lines_slice))
|
||||
|
||||
# 合并相邻同 label 片段
|
||||
result: List[dict] = []
|
||||
current_label = None
|
||||
current_seg_id = None
|
||||
current_lines: List[str] = []
|
||||
|
||||
for label, seg_id, lines in expanded:
|
||||
if label != current_label:
|
||||
if current_lines:
|
||||
result.append({
|
||||
"header": current_label,
|
||||
"type": "normal",
|
||||
"body_lines": list(current_lines),
|
||||
"a_seg_id": current_seg_id,
|
||||
})
|
||||
current_label = label
|
||||
current_seg_id = seg_id
|
||||
current_lines = list(lines)
|
||||
else:
|
||||
current_lines.extend(lines)
|
||||
|
||||
if current_lines:
|
||||
result.append({
|
||||
"header": current_label,
|
||||
"type": "normal",
|
||||
"body_lines": list(current_lines),
|
||||
"a_seg_id": current_seg_id,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _compose_with_speaker(
|
||||
b_lines: List[dict],
|
||||
segments: List[dict],
|
||||
title: str,
|
||||
a_path: Path,
|
||||
out_dir: Path,
|
||||
asr_raw_path: Path,
|
||||
no_ai: bool = False,
|
||||
) -> dict:
|
||||
"""说话人分段模式的完整 compose 流程。"""
|
||||
|
||||
print("[fusion_align] === 说话人分段模式 (v2) ===")
|
||||
|
||||
_annotate_b_lines_with_speakers(b_lines, asr_raw_path)
|
||||
blocks = _detect_speaker_blocks(b_lines)
|
||||
|
||||
print(f"[fusion_align] 说话人段落块: {len(blocks)} 块")
|
||||
for block in blocks:
|
||||
spk = block["speaker_id"]
|
||||
n = len(block["lines"])
|
||||
first_ts = block["lines"][0]["ts_sec"]
|
||||
ts_str = f"{first_ts // 60}m{first_ts % 60}s"
|
||||
preview = block["lines"][0]["text"][:40]
|
||||
print(
|
||||
f" block_{block['block_id']:2d} | spk={spk} | "
|
||||
f"{ts_str} | {n:3d}句 | {preview}"
|
||||
)
|
||||
|
||||
cache_dir = out_dir / ".c4_cache_spk"
|
||||
block_assignments = _align_speaker_blocks(blocks, segments, cache_dir)
|
||||
|
||||
for a in block_assignments:
|
||||
bid = a.get("block_id", "?")
|
||||
sid = a.get("seg_id", "?")
|
||||
label = a.get("segment_label", "?")
|
||||
conf = a.get("confidence", 0)
|
||||
print(f" block_{bid} -> seg_{sid} | {label} | conf={conf:.2f}")
|
||||
|
||||
broadcast_segs = _build_broadcast_segments(blocks, block_assignments)
|
||||
print(f"[fusion_align] 播出序分段: {len(broadcast_segs)} 段")
|
||||
for i, bseg in enumerate(broadcast_segs):
|
||||
n = len(bseg["body_lines"])
|
||||
print(f" [{i:2d}] {bseg['header']} | {n} 行")
|
||||
|
||||
ref_body_map = {seg["seg_id"]: seg.get("body", "") for seg in segments}
|
||||
|
||||
seg_texts: List[str] = []
|
||||
punct_results: Dict[int, bool] = {}
|
||||
for i, bseg in enumerate(broadcast_segs):
|
||||
bare_text = compose_segment_text(bseg["body_lines"])
|
||||
|
||||
if no_ai or not bare_text.strip():
|
||||
seg_texts.append(bare_text)
|
||||
punct_results[i] = True
|
||||
continue
|
||||
|
||||
ref_body = ref_body_map.get(bseg.get("a_seg_id", -1), "")
|
||||
punct_text, punct_ok = punctuate_segment(
|
||||
bare_text, ref_body,
|
||||
cache_dir=cache_dir,
|
||||
seg_id=i,
|
||||
)
|
||||
seg_texts.append(punct_text)
|
||||
punct_results[i] = punct_ok
|
||||
|
||||
docx_path = out_dir / f"{a_path.stem}_融合A稿.docx"
|
||||
render_docx(title, broadcast_segs, seg_texts, str(docx_path))
|
||||
print(f"[fusion_align] 融合A稿: {docx_path}")
|
||||
|
||||
csv_path = out_dir / "c4_alignment.csv"
|
||||
rows = ["seg_idx,header,line_count,punct_ok"]
|
||||
for i, bseg in enumerate(broadcast_segs):
|
||||
header = bseg["header"].replace('"', '""')
|
||||
n = len(bseg["body_lines"])
|
||||
pok = punct_results.get(i, True)
|
||||
rows.append(f'{i},"{header}",{n},{pok}')
|
||||
csv_path.write_text("\n".join(rows) + "\n", encoding="utf-8")
|
||||
print(f"[fusion_align] 留痕 CSV: {csv_path}")
|
||||
|
||||
punct_failed = sum(1 for v in punct_results.values() if not v)
|
||||
stats = {
|
||||
"total_lines": sum(len(s["body_lines"]) for s in broadcast_segs),
|
||||
"segment_count": len(broadcast_segs),
|
||||
"punct_failed_segs": punct_failed,
|
||||
"docx_path": str(docx_path),
|
||||
"csv_path": str(csv_path),
|
||||
}
|
||||
|
||||
print(f"\n[fusion_align] === 统计 (v2) ===")
|
||||
print(f" 总行数: {stats['total_lines']}")
|
||||
print(f" 播出序段数: {stats['segment_count']}")
|
||||
print(f" 标点回退段数: {stats['punct_failed_segs']}")
|
||||
for i, bseg in enumerate(broadcast_segs):
|
||||
n = len(bseg["body_lines"])
|
||||
pflag = " [标点回退]" if not punct_results.get(i, True) else ""
|
||||
print(f" [{i:2d}] {bseg['header']}: {n} 行{pflag}")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# 6. 硬校验
|
||||
# ====================================================================
|
||||
@@ -1627,7 +2028,14 @@ def run_compose(
|
||||
f"| body_len={len(seg['body'])}"
|
||||
)
|
||||
|
||||
# ---- 对齐(只对 normal 段) ----
|
||||
# ---- 检查说话人数据,有则走 v2 ----
|
||||
asr_raw_path = out_dir / "asr_result_raw.json"
|
||||
if _check_has_speaker_data(asr_raw_path):
|
||||
return _compose_with_speaker(
|
||||
b_lines, segments, title, a_path, out_dir, asr_raw_path, no_ai,
|
||||
)
|
||||
|
||||
# ---- 对齐(只对 normal 段,旧路径) ----
|
||||
cache_dir = out_dir / ".c4_cache"
|
||||
alignment, audit_logs, normal_segs = align_lines_to_segments(
|
||||
b_lines,
|
||||
|
||||
@@ -35,6 +35,7 @@ SYSTEM_PROMPT = """你是《军事科技》专题片文稿校审员。给你 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时留空)
|
||||
|
||||
@@ -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