diff --git a/doco/CLAUDE.md b/doco/CLAUDE.md index 00af8f3..aafc50c 100644 --- a/doco/CLAUDE.md +++ b/doco/CLAUDE.md @@ -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//` — 每期产物(热词表、各阶段输出) - **常用命令**: - C1(已实现):`doco terms --episode-id --a-script [--no-ai]` - C2(已实现):`doco asr --episode-id --input-video <绝对路径mp4> --output-dir <绝对路径 doco/programs/>`(`--skip-asr` 只分离不转写)。**务必传绝对 `--output-dir`,否则落到当前工作目录的 `programs/` 会与 doco 产物分家。** - C3(已实现):`doco fuse --episode-id [--no-ai] [--batch-size 35]` - - C4:待实现 + - C4(已实现):`doco compose --episode-id [--batch-size 25]` + - 骨架:`doco skeleton --episode-id --a-script `(需人工核验后再跑 compose) + - **一键全流程**:`doco run --episode-id --a-script --input-video [--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 `。**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 做备份/交叉验证。 \ No newline at end of file +- ~~**OCR 并发提速**~~:已完成,16 路并发(4090D 24GB 充裕)。 +- **讯飞单源依赖**(远期):是否接阿里云 ASR 做备份/交叉验证。 +- **ep016 骨架的宣传词被标为 break**:其他期(ep006/13)宣传词标为 normal,需通哥确认 ep016 是否应改。 \ No newline at end of file diff --git a/doco/README.md b/doco/README.md index 9d353b2..6aebc13 100644 --- a/doco/README.md +++ b/doco/README.md @@ -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 --a-script +> # 检查输出的骨架预览表,确认无误后再跑 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 --input-video --output-dir + +# C1: 术语提取 +doco terms --episode-id --a-script + +# C2: 讯飞ASR转写 +doco asr --episode-id --input-video --output-dir + +# C3: 交叉复审融合 +doco fuse --episode-id [--batch-size 35] + +# C4: 对齐出稿 +doco compose --episode-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 分目录) +│ └── / +│ ├── source/ # 输入(video.mp4 + a_draft.docx) +│ ├── B稿_v2.txt # P2 产出的OCR字幕文本 +│ ├── audio_16k.wav # 分离的音频(16kHz/单声道/16bit) +│ ├── asr_v2_timed.txt # ASR转写结果(带时间戳) +│ ├── _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 \ No newline at end of file diff --git a/doco/backup_before_spk/asr_adapter.py b/doco/backup_before_spk/asr_adapter.py new file mode 100644 index 0000000..15beb04 --- /dev/null +++ b/doco/backup_before_spk/asr_adapter.py @@ -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//本期热词表.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 diff --git a/doco/backup_before_spk/cli.py b/doco/backup_before_spk/cli.py new file mode 100644 index 0000000..25a0eb5 --- /dev/null +++ b/doco/backup_before_spk/cli.py @@ -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//本期热词表.txt(| 分隔,最多 200 条) + - doco/programs//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//)", +) +@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//)", +) +@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. 落盘 _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//)", +) +@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//)", +) +@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() diff --git a/doco/backup_before_spk/fusion_align.py b/doco/backup_before_spk/fusion_align.py new file mode 100644 index 0000000..81b45ef --- /dev/null +++ b/doco/backup_before_spk/fusion_align.py @@ -0,0 +1,1761 @@ +# -*- coding: utf-8 -*- +""" +C4: 融合B稿(743行碎句) + A稿分段骨架 → 融合A稿.docx(公文格式) +============================================================= +职责: AI 只做分段对齐(决定每句归哪段),正文一字不改、纯规则拼接。 + +P3 新增: LLM 分段骨架抽取 (doco skeleton 命令) + - extract_a_paragraphs: 纯 docx 段落样式提取 + - extract_skeleton_llm: LLM 判断分段结构,产出 JSON 骨架 + - validate_skeleton_coverage: 全覆盖硬校验(无缺口/无重叠/无越界) + - parse_a_segments: 改读 skeleton JSON 替代正则(ep001 fallback 保留) +""" + +import json +import re +import sys +import unicodedata +from pathlib import Path +from typing import List, Dict, Optional, Tuple, Any + +from docx import Document +from docx.shared import Pt, Cm +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement + +from .llm import chat, LLMConfigError +from .fusion_review import parse_timed_lines + + +# -------------------------------------------------------------------------- +# 常量 +# -------------------------------------------------------------------------- + +SEG_HEADER_PATTERN = re.compile(r"^【.+?】$") + +# 隔断识别: "隔断:【标题】" +SEG_BREAK_PATTERN = re.compile(r"^隔断:【(.+?)】") + +# 句尾标点: 行尾已有这些字符则不补逗号 +SENTENCE_END_PUNCT = frozenset("。!?;…!?;") + +# 字体 +TITLE_FONT_PRIMARY = "方正小标宋_GBK" +TITLE_FONT_FALLBACK = "宋体" +HEADER_FONT = "黑体" +BODY_FONT = "仿宋_GB2312" +BREAK_HEADER_FONT = "仿宋_GB2312" # 隔断段头用仿宋加粗 + +EMPTY_SEG_PLACEHOLDER = "(本段无对应字幕,待编导核)" + +# 中文冒号/英文冒号(用于切分 inline 段头) +INLINE_HEADER_SEP = re.compile(r"[::]") + + +# ==================================================================== +# 1a. 纯 docx 段落样式提取 +# ==================================================================== + + +def extract_a_paragraphs(docx_path: str) -> List[Dict[str, Any]]: + """ + 读 A 稿 docx,返回带样式信息的段落列表。 + 每条: {"idx": int, "text": str, "bold": bool, "align": str} + - bold: 该段任一 run 含加粗即为 True + - align: "left" | "center" | "justify" | "right" | None + - 跳过全空段落 + """ + p = Path(docx_path) + if not p.exists(): + raise FileNotFoundError(f"A稿 docx 不存在: {docx_path}") + + doc = Document(str(p)) + paras_out = [] + + ALIGN_MAP = { + WD_ALIGN_PARAGRAPH.LEFT: "left", + WD_ALIGN_PARAGRAPH.CENTER: "center", + WD_ALIGN_PARAGRAPH.JUSTIFY: "justify", + WD_ALIGN_PARAGRAPH.RIGHT: "right", + } + + for idx, para in enumerate(doc.paragraphs): + text = para.text.strip() + if not text: + continue + + # 检测加粗: 任一 run 的 bold=True + bold = any( + (run.bold is not None and run.bold) for run in para.runs + ) + + # 对齐 + raw_align = para.alignment + align = ALIGN_MAP.get(raw_align, None) + + paras_out.append( + { + "idx": len(paras_out), # 重新编号,连续无跳 + "text": text, + "bold": bold, + "align": align, + } + ) + + return paras_out + + +# ==================================================================== +# 1b. 段落列表序列化(喂 LLM) +# ==================================================================== + + +def _serialize_paras_for_llm(paras: List[Dict[str, Any]]) -> str: + """把段落列表格式化为 LLM 输入文本: [idx] bold=X align=Y | text""" + lines = [] + for p in paras: + flags = [] + if p.get("bold"): + flags.append("bold") + if p.get("align"): + flags.append(f"align={p['align']}") + flag_str = ",".join(flags) if flags else "-" + lines.append(f"[{p['idx']}] {flag_str} | {p['text']}") + return "\n".join(lines) + + +# ==================================================================== +# 2. LLM 分段骨架提取 +# ==================================================================== + +SKELETON_SYSTEM_PROMPT = """你是电视专题片文稿结构分析器。给你全文段落列表(每段带编号和样式标注),请判断每段的结构角色并输出 JSON 骨架。 + +【核心铁律】 +1. 你只做结构判断,只输出 JSON 骨架。**严禁输出、复述、生成任何正文文字。** +2. role_label 只包含**角色类型**,严禁包含任何真人姓名/配音员/编导署名。 +3. 所有段落必须归入某个 segment,骨架的 [para_start,para_end] 区间必须恰好覆盖 + 第一个段头之后的所有段落一次——无缺口、无重叠、无越界。 + +【角色类型参考集(开放可超集)】 +导视 / 主持人 / 解说 / 专家 / 嘉宾 / 旁白 / 三维动画解说 / 演播室主持人 / +小剧场角色(小剧场N-角色名,如小剧场1-主持人、小剧场1-卡拉什尼科夫、 +小剧场1-尤金斯通纳) / 子标题(break 型,原文字保留) + +【段头识别规则】 +- 冒号式: 文本含":"或":"且冒号左边是角色类型标识(如"解说:""主持人:" + "专家:""三维动画解说:""演播室主持人:"等)→ header_inline=true, + 段头与正文同段。该段 para_start==para_end==header_para_idx。 +- 独占一行式: 独立段落标记角色/子标题(如"【手枪】""【步枪】""小剧场1-主持人" + "解密鱼鳔的潜艇压载水舱系统")→ header_inline=false,段头占一个段落, + 正文从下一段开始。 +- 子标题(break): 加粗+总结性的短语+无冒号→ type=break,header_inline=false。 + +【role_label 命名规则(最高优先级红线)】 +- role_label 格式: "【角色类型N】",如"【解说1】""【主持人1】""【三维动画解说1】" + "【演播室主持人1】""【小剧场1-主持人】""【小剧场1-卡拉什尼科夫】" +- 同类角色按出现顺序自动编号: 第一个解说→【解说1】,第二个→【解说2】... +- break 型: role_label 为子标题原文(不加工) +- ignore 型: role_label 为标记类型原文(如"【固】""【摇】""【轨】") +- **红线**: role_label 中**禁止包含**真人姓名/配音员姓名/编导署名。 + 反面案例: "【演播室主持人:刘通】""【解说:穆佩弦】""【旁白:孙逸昊】" + "【主持人:左鑫】"——全部非法。正确写法:"【演播室主持人1】""【解说1】" + "【旁白1】""【主持人1】"。 +- **小剧场角色(如卡拉什尼科夫、尤金斯通纳、小剧场1-主持人)保留**: + 这是被演绎的历史人物/剧情角色,非真人配音员,可以保留。 +- 清理垃圾前缀: 如"Xr演播室"、"Xr"→ 提取真正的角色类型后命名。 + +【陷阱(必须归为 ignore,type="ignore")】 +- 【固】【摇】【轨】【推】【拉】【跟】等运镜/镜头标记 → type=ignore +- (演播室)(此处为舞台表演)(掌声)等圆括号舞台提示 → type=ignore +- 纯标点/纯空白段 → type=ignore +- 注意: 整段加粗 ≠ 段头(如枪王期全文字幕加粗,需结合语义判断) +- 注意: "三维动画"、"三维动画解说"无冒号时可能是独立段头,结合上下文判断 + +【输出格式】 +纯 JSON 数组,无 markdown 包装,无额外解释: +[ + { + "type": "normal" | "break" | "ignore", + "para_start": int, // 此段覆盖的起始段落下标(inclusive) + "para_end": int, // 此段覆盖的结束段落下标(inclusive) + "role_label": "【演播室主持人1】", + "header_inline": bool, // 段头是否与正文同段(冒号式) + "header_para_idx": int // 段头所在段落下标(用于后续切除段头) + }, + ... +] + +【注意】 +- para_start/para_end 基于输入给你的 [idx] 编号 +- 全文第一个段落是标题(含副标题等),请自动忽略(不纳入任何 segment) +- break 段: 单一段落,header_inline=false,body 为空 +- ignore 段: 不进最终输出,但区间必须覆盖以通过全覆盖校验""" + + +def extract_skeleton_llm( + paras: List[Dict[str, Any]], + max_tokens: int = 16000, +) -> List[Dict[str, Any]]: + """ + 把全文段落列表喂给 LLM,返回 JSON 骨架。 + + Args: + paras: extract_a_paragraphs 的输出 + max_tokens: LLM max_tokens,长稿需调大(默认 16000) + + Returns: + 骨架列表 [{type, para_start, para_end, role_label, header_inline, header_para_idx}] + + Raises: + ValueError: JSON 解析失败 / 返回格式不对 + LLMConfigError: API key 未配置 + """ + if not paras: + raise ValueError("段落列表为空,无法提取骨架") + + # 序列化全文 + full_text = _serialize_paras_for_llm(paras) + + user_content = ( + f"全文共 {len(paras)} 个段落(第0段是标题,请自动忽略):\n\n" + f"{full_text}\n\n" + f"请输出 JSON 骨架数组。" + ) + + messages = [ + {"role": "system", "content": SKELETON_SYSTEM_PROMPT}, + {"role": "user", "content": user_content}, + ] + + raw_response = chat( + messages, + thinking=True, + max_tokens=max_tokens, + temperature=0.0, + ) + + # 解析 JSON + parsed = _parse_skeleton_json(raw_response) + + # 校验每条记录的必要字段 + required_fields = ["type", "para_start", "para_end", "role_label", "header_inline", "header_para_idx"] + for i, item in enumerate(parsed): + for field in required_fields: + if field not in item: + raise ValueError( + f"骨架第 {i} 条缺少字段 '{field}': {json.dumps(item, ensure_ascii=False)[:200]}" + ) + # 校验 type 值 + if item["type"] not in ("normal", "break", "ignore"): + raise ValueError( + f"骨架第 {i} 条 type='{item['type']}' 不合法,应为 normal/break/ignore" + ) + + return parsed + + +def _parse_skeleton_json(raw: str) -> List[dict]: + """解析 LLM 返回的骨架 JSON 数组,去除 markdown code fences 等包装。""" + text = raw.strip() + + # 去掉 markdown code fences + if text.startswith("```"): + lines = text.splitlines() + 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]}\n" + f"原始响应后 200 字符: {raw[-200:]}" + ) + + if not isinstance(result, list): + raise ValueError( + f"LLM 返回不是 JSON 数组, 类型为 {type(result).__name__}" + ) + + if len(result) == 0: + raise ValueError("LLM 返回空骨架数组") + + return result + + +# ==================================================================== +# 2b. 全覆盖硬校验 +# ==================================================================== + + +def validate_skeleton_coverage( + skeleton: List[Dict[str, Any]], + total_paras: int, +) -> Tuple[bool, str]: + """ + 校验骨架的 [para_start, para_end] 区间是否恰好覆盖 title 之后 + 的每个段落一次(无缺口/无重叠/无越界)。 + + 注意: 第0段(title)不纳入覆盖范围。 + + Args: + skeleton: LLM 返回的骨架列表 + total_paras: 总段落数(含 title) + + Returns: + (passed: bool, message: str) + """ + if total_paras <= 1: + return True, "" + + covered = set() + expected_start = 1 # title 后第一个段落下标 + expected_end = total_paras - 1 + + errors = [] + + for i, seg in enumerate(skeleton): + ps = seg.get("para_start") + pe = seg.get("para_end") + + if ps is None or pe is None: + errors.append(f"骨架第 {i} 条缺少 para_start/para_end") + continue + + if not isinstance(ps, int) or not isinstance(pe, int): + errors.append( + f"骨架第 {i} 条 para_start/para_end 非整数: ps={ps}, pe={pe}" + ) + continue + + if ps > pe: + errors.append( + f"骨架第 {i} 条 para_start({ps}) > para_end({pe})" + ) + continue + + if ps < expected_start: + errors.append( + f"骨架第 {i} 条 para_start({ps}) < 预期最小值 {expected_start}(越界到 title 区)" + ) + continue + + if pe > expected_end: + errors.append( + f"骨架第 {i} 条 para_end({pe}) > 预期最大值 {expected_end}(越界)" + ) + continue + + # 检查重叠 + seg_range = set(range(ps, pe + 1)) + overlap = seg_range & covered + if overlap: + errors.append( + f"骨架第 {i} 条 [{ps},{pe}] 与已覆盖下标重叠: {sorted(overlap)}" + ) + covered |= seg_range + + # 检查缺口 + all_expected = set(range(expected_start, expected_end + 1)) + missing = all_expected - covered + if missing: + errors.append( + f"段落下标缺口(missing): {sorted(missing)[:20]}" + + ("..." if len(missing) > 20 else "") + ) + + # 检查多余 + extra = covered - all_expected + if extra: + errors.append( + f"段落下标多余(extra): {sorted(extra)[:20]}" + + ("..." if len(extra) > 20 else "") + ) + + if errors: + return False, "; ".join(errors) + else: + return True, "" + + +# ==================================================================== +# 3. 骨架落盘 + 预览 +# ==================================================================== + + +def save_skeleton( + skeleton: List[Dict[str, Any]], + paras: List[Dict[str, Any]], + episode_id: str, + output_dir: str, +) -> Path: + """ + 将骨架写入 programs//_a_skeleton.json + 同时为每条 segment 附加正文前20字预览(仅用于人工核验,不入 JSON 机器读字段)。 + + Returns: skeleton 文件路径 + """ + out_dir = Path(output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + # 构建保存用的骨架(不含正文预览,纯机器可读) + skeleton_path = out_dir / f"{episode_id}_a_skeleton.json" + skeleton_path.write_text( + json.dumps(skeleton, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return skeleton_path + + +def print_skeleton_table( + skeleton: List[Dict[str, Any]], + paras: List[Dict[str, Any]], + title: str = "", +) -> None: + """ + 打印人类可读预览表: + seq | type | role_label | 正文前20字 + 用于制片人肉眼核验: role_label 是否含真人姓名? ignore 是否漏/多? + """ + print(f"\n{'=' * 70}") + print(f" {title} 骨架预览表") + print(f"{'=' * 70}") + print(f"{'seq':>4} | {'type':<7} | {'role_label':<35} | 正文前20字") + print(f"{'-' * 4}-+-{'-' * 7}-+-{'-' * 35}-+-{'-' * 20}") + + for seq_idx, seg in enumerate(skeleton, start=1): + seg_type = seg.get("type", "?") + role_label = seg.get("role_label", "") + ps = seg.get("para_start", -1) + pe = seg.get("para_end", -1) + header_inline = seg.get("header_inline", False) + + # 取该段实际正文的前20字(非段头文字) + preview = "" + if 0 <= ps < len(paras): + if seg_type == "break": + # break 段无正文,显示子标题原文 + preview = paras[ps]["text"][:20] + elif seg_type == "ignore": + preview = paras[ps]["text"][:20] + elif header_inline: + # 冒号式: para_start 段落含"角色:正文",切掉角色部分 + full_text = paras[ps]["text"] + # 找第一个中文/英文冒号 + colon_match = re.search(r"[::]", full_text) + if colon_match: + preview = full_text[colon_match.end():].strip()[:20] + else: + preview = full_text[:20] + else: + # 独立段头: 段头占 ps,正文从 ps+1 开始 + body_start = ps + 1 + if body_start < len(paras) and body_start <= pe: + preview = paras[body_start]["text"][:20] + else: + # 无正文段(如段头后无内容) + preview = "(无正文)" + + print( + f"{seq_idx:>4} | {seg_type:<7} | {role_label:<35} | {preview}" + ) + + print(f"{'=' * 70}") + print("[WARN] 请人工核验: role_label是否含真人姓名? ignore是否漏/多? type是否正确?") + print() + + +# ==================================================================== +# 4. run_skeleton — Step 1-3 编排 +# ==================================================================== + + +def run_skeleton( + episode_id: str, + a_script_path: str, + output_dir: Optional[str] = None, + max_tokens: int = 16000, +) -> Dict[str, Any]: + """ + doco skeleton 命令主流程: + 1. extract_a_paragraphs + 2. extract_skeleton_llm + 3. validate_skeleton_coverage (全覆盖硬校验) + 4. save_skeleton + print_skeleton_table + + Returns 统计 dict: {skeleton_path, total_paras, skeleton_count, coverage_ok} + """ + 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) + + # Step 1: 段落提取 + print(f"[skeleton] 读取 A 稿: {a_script_path}") + paras = extract_a_paragraphs(str(a_script_path)) + print(f"[skeleton] 段落数: {len(paras)} (含标题)") + + if not paras: + raise ValueError(f"A稿无有效文本: {a_script_path}") + + title = paras[0]["text"] + print(f"[skeleton] 标题: {title}") + + # Step 2: LLM 骨架 + print(f"[skeleton] 调用 LLM 提取分段骨架 (thinking=True, max_tokens={max_tokens})...") + skeleton = extract_skeleton_llm(paras, max_tokens=max_tokens) + print(f"[skeleton] LLM 返回骨架段数: {len(skeleton)}") + + # Step 3: 全覆盖校验 + coverage_ok, coverage_msg = validate_skeleton_coverage(skeleton, len(paras)) + if not coverage_ok: + print(f"[skeleton] [FAIL] 全覆盖校验失败: {coverage_msg}", file=sys.stderr) + # 仍打印预览表,方便人工排查 + print_skeleton_table(skeleton, paras, title=f"{episode_id} (校验失败)") + raise ValueError( + f"骨架全覆盖校验失败: {coverage_msg}\n" + f"可能原因: LLM 数错下标/JSON 被截断/段落编号理解偏差。\n" + f"建议: 调大 max_tokens(当前={max_tokens})后重试,或检查 prompt。" + ) + print(f"[skeleton] [PASS] 全覆盖校验通过") + + # Step 4: 落盘(原始骨架) + 预览(合并去ignore 后的最终 skeleton) + skeleton_path = save_skeleton(skeleton, paras, episode_id, str(out_dir)) + print(f"[skeleton] 骨架已保存: {skeleton_path}") + + # 合并展示(预览表展示 compose 真正会用的那份) + skeleton_display = _merge_skeleton_segments(skeleton) + if len(skeleton_display) != len(skeleton): + print( + f"[skeleton] 预览(合并后): {len(skeleton)} → {len(skeleton_display)} " + f"段 ({len(skeleton) - len(skeleton_display)} 条同角色合并/丢弃 ignore)" + ) + print_skeleton_table(skeleton_display, paras, title=episode_id) + + return { + "skeleton_path": str(skeleton_path), + "total_paras": len(paras), + "skeleton_count": len(skeleton), + "coverage_ok": coverage_ok, + } + + +# ==================================================================== +# 4b. 合并被 ignore 隔断的同角色段 +# ==================================================================== + + +def _merge_skeleton_segments( + skeleton: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + 后处理: 若两个相邻 normal 段 role_label 完全相同, + 且它们之间只隔着 ignore 段(无其它 normal/break),合并为一段。 + + 目的: 消除主持人话被运镜提示((出枪柜)(换枪))切断后产生的重复段头。 + + Args: + skeleton: LLM 产出的原始骨架列表 + + Returns: + 合并后的骨架(ignore 已丢弃,相邻同角色 normal 已合并) + """ + if not skeleton: + return [] + + merged: List[Dict[str, Any]] = [] + pending_normal: Optional[Dict[str, Any]] = None # 待合并的 normal 段 + + for seg in skeleton: + seg_type = seg.get("type", "normal") + + if seg_type == "normal": + if pending_normal is not None and pending_normal["role_label"] == seg.get("role_label", ""): + # 同角色: 扩展 para 区间,保留第一个段头的 header 信息 + pending_normal["para_end"] = max(pending_normal["para_end"], seg.get("para_end", pending_normal["para_end"])) + else: + # 不同角色或首个 normal: 先 flush 前一个,再缓存当前 + if pending_normal is not None: + merged.append(pending_normal) + # 浅拷贝一份避免修改原始 skeleton + pending_normal = dict(seg) + elif seg_type == "ignore": + # 跳过,不打断 pending 链(就是这些 ignore 造成了切断) + continue + else: + # break 或其它类型: flush pending,然后直接加入 + if pending_normal is not None: + merged.append(pending_normal) + pending_normal = None + merged.append(seg) + + # 收尾 + if pending_normal is not None: + merged.append(pending_normal) + + return merged + + +# ==================================================================== +# 5. 解析 A 稿分段(改读 skeleton 替代正则) +# ==================================================================== + + +def parse_a_segments(docx_path: str) -> dict: + """ + 读 A 稿 docx,返回 { + "title": str, + "segments": [ + {"seg_id":int, "type":"normal"|"break", + "header":str, "body":str} + ] + } + + 优先读 _a_skeleton.json (LLM 骨架), + 若不存在且首段匹配旧正则格式(ep001),走旧正则逻辑 fallback。 + 否则报错。 + """ + p = Path(docx_path) + if not p.exists(): + raise FileNotFoundError(f"A稿 docx 不存在: {docx_path}") + + # 推断 skeleton 路径 + # docx 在 programs//xxx.docx + # skeleton 在 programs//_a_skeleton.json + episode_dir = p.parent + episode_id = episode_dir.name + skeleton_path = episode_dir / f"{episode_id}_a_skeleton.json" + + if skeleton_path.exists(): + return _parse_a_segments_from_skeleton(str(p), str(skeleton_path)) + else: + # Fallback: 检查是否为旧格式(ep001) + doc = Document(str(p)) + paras = [para.text.strip() for para in doc.paragraphs if para.text.strip()] + if paras and SEG_HEADER_PATTERN.match(paras[1] if len(paras) > 1 else ""): + print( + f"[fusion_align] 未找到 skeleton.json,检测到旧版【】格式,走正则解析" + ) + return _parse_a_segments_regex(str(p), paras) + else: + raise FileNotFoundError( + f"未找到分段骨架文件: {skeleton_path}\n" + f"且 A 稿不匹配旧版【】段头格式。\n" + f"请先运行: doco skeleton --episode-id {episode_id} --a-script {docx_path}" + ) + + +def _parse_a_segments_from_skeleton( + docx_path: str, skeleton_path: str +) -> dict: + """ + 从 skeleton JSON 解析分段: + - 按 para_start/para_end 从 docx 原样抽取正文 + - header_inline=true: 按第一个中文冒号切除段头,余下为 body + - header_inline=false: role_label 为 header,后续段落为 body + - type=ignore: 丢弃,不进 segments + - type=break: 单独产出 segment + """ + # 读 skeleton + skeleton_raw = json.loads(Path(skeleton_path).read_text(encoding="utf-8")) + + # 合并被 ignore 隔断的同角色段(如主持人话被(出枪柜)(换枪)切断) + skeleton = _merge_skeleton_segments(skeleton_raw) + if len(skeleton) != len(skeleton_raw): + print( + f"[fusion_align] skeleton 合并: {len(skeleton_raw)} → {len(skeleton)} 段 " + f"(合并了 {len(skeleton_raw) - len(skeleton)} 条同角色拆分+丢弃 ignore)" + ) + + # 读 docx 段落(按原下标) + doc = Document(docx_path) + all_paras = [para.text.strip() for para in doc.paragraphs if para.text.strip()] + + if not all_paras: + raise ValueError(f"A稿 docx 无有效文本: {docx_path}") + + title = all_paras[0] + + # 为 skeleton 建立原段落下标到实际文本的映射 + # 注意: all_paras[0] = title, 所以 skeleton 的 para_start/para_end 对应 all_paras 的下标 + + segments = [] + seg_id_counter = 0 + + for seg in skeleton: + seg_type = seg.get("type", "normal") + + if seg_type == "ignore": + continue # 丢弃镜头标记等 + + ps = seg.get("para_start", -1) + pe = seg.get("para_end", -1) + role_label = seg.get("role_label", "") + header_inline = seg.get("header_inline", False) + + if ps < 0 or pe < 0 or ps >= len(all_paras): + print( + f"[fusion_align] 警告: 骨架段 para_start/para_end 越界,跳过: {seg}", + file=sys.stderr, + ) + continue + + if seg_type == "break": + # 子标题: header=role_label(原文), body 为空 + header_text = all_paras[ps] if ps < len(all_paras) else role_label + segments.append( + { + "seg_id": seg_id_counter, + "type": "break", + "header": role_label, + "body": "", + } + ) + seg_id_counter += 1 + continue + + # normal 段 + if header_inline: + # 段头与正文同段: 按第一个中文冒号切分 + full_text = all_paras[ps] if ps < len(all_paras) else "" + header_text, body_text = _split_inline_header(full_text) + # header 用 role_label(规范化后), body 用切除后的 + body_parts = [body_text] if body_text else [] + # 如果 para_end > para_start,后续段落全是 body + for extra_idx in range(ps + 1, pe + 1): + if extra_idx < len(all_paras): + body_parts.append(all_paras[extra_idx]) + else: + # 独立段头: header 段落自身是 role_label, body 从下一段开始 + header_text = role_label + body_parts = [] + for body_idx in range(ps + 1, pe + 1): + if body_idx < len(all_paras): + body_parts.append(all_paras[body_idx]) + + segments.append( + { + "seg_id": seg_id_counter, + "type": "normal", + "header": role_label, + "body": "\n".join(body_parts), + } + ) + seg_id_counter += 1 + + return {"title": title, "segments": segments} + + +def _split_inline_header(text: str) -> Tuple[str, str]: + """ + 按第一个中文冒号切分段头和正文。 + 如 "演播室主持人1:各位观众大家好" → ("演播室主持人1", "各位观众大家好") + 如果没有冒号,全文本当 body。 + """ + match = INLINE_HEADER_SEP.search(text) + if match: + header_part = text[: match.start()].strip() + body_part = text[match.end() :].strip() + return header_part, body_part + else: + return "", text + + +def _parse_a_segments_regex(docx_path: str, paras: List[str]) -> dict: + """ + 旧正则逻辑(ep001 及兼容): ^【.+?】$ 段头 + ^隔断:【】识别。 + 保留不动。 + """ + title = paras[0] + + segments = [] + current_header = None + current_body_parts = [] + seg_id_counter = 0 + + for para_text in paras[1:]: + break_match = SEG_BREAK_PATTERN.match(para_text) + if break_match: + if current_header is not None: + segments.append( + { + "seg_id": seg_id_counter, + "type": "normal", + "header": current_header, + "body": "\n".join(current_body_parts), + } + ) + seg_id_counter += 1 + + break_title = break_match.group(1) + segments.append( + { + "seg_id": seg_id_counter, + "type": "break", + "header": break_title, + "body": "", + } + ) + seg_id_counter += 1 + + current_header = None + current_body_parts = [] + continue + + if SEG_HEADER_PATTERN.match(para_text): + if current_header is not None: + segments.append( + { + "seg_id": seg_id_counter, + "type": "normal", + "header": current_header, + "body": "\n".join(current_body_parts), + } + ) + seg_id_counter += 1 + + current_header = para_text + current_body_parts = [] + else: + if current_header is not None: + current_body_parts.append(para_text) + + if current_header is not None: + segments.append( + { + "seg_id": seg_id_counter, + "type": "normal", + "header": current_header, + "body": "\n".join(current_body_parts), + } + ) + + return {"title": title, "segments": segments} + + +# ==================================================================== +# 1b. 提取 normal 段(供对齐使用) +# ==================================================================== + + +def _get_normal_segments(segments: List[dict]) -> List[dict]: + """从全局 segments 中筛出 type=="normal" 的段,按 seg_id 排序。""" + normals = [seg for seg in segments if seg.get("type", "normal") == "normal"] + normals.sort(key=lambda s: s["seg_id"]) + return normals + + +def _build_normal_seg_id_map(normal_segs: List[dict]) -> Dict[int, int]: + """ + 建立映射: global_seg_id → normal_seg_id (0-based continuous) + 反向映射: normal_seg_id → global_seg_id + """ + g2n = {} + n2g = {} + for nid, seg in enumerate(normal_segs): + g2n[seg["seg_id"]] = nid + n2g[nid] = seg["seg_id"] + return g2n, n2g + + +# ==================================================================== +# 2. 构造分段对齐 Prompt +# ==================================================================== + +SYSTEM_PROMPT_ALIGN = """你是《军事科技》专题片分段对齐员。给你 A稿完整分段正文 和 融合B稿碎句(屏幕字幕,带行号,按播出时间排列)。 +B句是播出字幕的逐句拆分,A稿是同一内容的书面稿,两者高度同源——请直接按"这句话的内容出现在A稿哪一段正文里"来归段。 +规则:seg_id 随行号单调不减;边界以语义为准(如主持人开场白归主持人段,旁白归解说段)。 +只返回JSON数组: [{"line_no":int,"seg_id":int,"confidence":0~1}]""" + + +def build_align_prompt( + batch_b: List[dict], + normal_segments: List[dict], + min_normal_seg_id: int, +) -> List[dict]: + """ + 构造 messages: + (a) 完整 normal 段清单: 每段 "seg_id | header | A稿完整正文" + (b) 本批 B 句: 每句 "[全局行号] 文本" + (c) 下界约束: 传入上一批最后一行归到的 normal_seg_id,本批不得小于它 + """ + seg_lines = [] + for idx, seg in enumerate(normal_segments): + full_body = seg["body"].replace("\n", " ") + seg_lines.append( + f"seg_id={idx} | {seg['header']} | {full_body}" + ) + seg_list_str = "\n".join(seg_lines) + + b_lines_str = "\n".join( + f"[{bl['idx']}] {bl['text']}" for bl in batch_b + ) + + constraint_note = "" + if min_normal_seg_id > 0: + constraint_note = ( + f"\n\n【重要约束】上一批最后一行归到了 seg_id={min_normal_seg_id}," + f"本批所有行的 seg_id 必须 >= {min_normal_seg_id},不得回退。" + ) + + user_content = ( + f"A稿分段清单(共 {len(normal_segments)} 段):\n\n" + f"{seg_list_str}\n\n" + f"--- 本批 B 稿碎句(共 {len(batch_b)} 行)---\n\n" + f"{b_lines_str}" + f"{constraint_note}" + ) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT_ALIGN}, + {"role": "user", "content": user_content}, + ] + return messages + + +# ==================================================================== +# 3. 单批对齐 +# ==================================================================== + + +def align_batch( + batch_b: List[dict], + normal_segments: List[dict], + min_normal_seg_id: int, + no_ai: bool = False, + total_b_lines: int = 743, +) -> List[dict]: + """ + no_ai=True: 按全局行号比例均分到 normal 段(仅供验证管道) + no_ai=False: 调 LLM(thinking=True) 返回 JSON + 返回 [{"line_no": int, "seg_id": int, "confidence": float}] + **注意**: 返回的 seg_id 是 normal_segments 体系(0-based 连续编号) + """ + if no_ai: + n_segs = len(normal_segments) + records = [] + for bl in batch_b: + seg_idx = min( + int((bl["idx"] - 1) / total_b_lines * n_segs), n_segs - 1 + ) + records.append( + {"line_no": bl["idx"], "seg_id": seg_idx, "confidence": 0.5} + ) + return records + + messages = build_align_prompt(batch_b, normal_segments, min_normal_seg_id) + + try: + raw_response = chat( + messages, + thinking=True, + max_tokens=4096, + temperature=0.0, + ) + parsed = _parse_align_json(raw_response, len(batch_b)) + except Exception as e: + print( + f"[fusion_align] LLM 调用失败,回退: {e}", + file=sys.stderr, + ) + records = [] + for bl in batch_b: + records.append( + { + "line_no": bl["idx"], + "seg_id": min_normal_seg_id, + "confidence": 0.3, + } + ) + return records + + records = [] + for item in parsed: + records.append( + { + "line_no": int(item.get("line_no", 0)), + "seg_id": int(item.get("seg_id", min_normal_seg_id)), + "confidence": float(item.get("confidence", 0.5)), + } + ) + + return records + + +def _parse_align_json(raw: str, expected_len: int) -> List[dict]: + """解析 LLM 返回的 JSON 数组,去除 markdown code fences 等包装。""" + text = raw.strip() + + if text.startswith("```"): + lines = text.splitlines() + 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} 条" + ) + + return result + + +# ==================================================================== +# 4. 单调修正 +# ==================================================================== + + +def enforce_monotonic( + records: List[dict], + min_seg_id: int = 0, +) -> List[dict]: + """ + 确保 seg_id 随 line_no 单调不减。 + 若出现回退(seg_id < 前值), 强制改回前值 + confidence 降到 0.3。 + 返回修正日志列表 [{line_no, original_seg_id, forced_seg_id}]。 + """ + audit_log = [] + prev_seg_id = min_seg_id + + for rec in records: + if rec["seg_id"] < prev_seg_id: + audit_log.append( + { + "line_no": rec["line_no"], + "original_seg_id": rec["seg_id"], + "forced_seg_id": prev_seg_id, + } + ) + rec["seg_id"] = prev_seg_id + rec["confidence"] = 0.3 + else: + prev_seg_id = rec["seg_id"] + + return audit_log + + +# ==================================================================== +# 5. 分段对齐主函数 +# ==================================================================== + + +def align_lines_to_segments( + b_lines: List[dict], + segments: List[dict], + no_ai: bool = False, + batch_size: int = 40, + cache_dir: Optional[Path] = None, +) -> Tuple[List[dict], List[dict], List[dict]]: + """ + 分批送 LLM 对齐(或 no_ai 均分),只对齐 normal 段。 + 返回: + (alignment_records, audit_logs, normal_segments) + """ + normal_segs = _get_normal_segments(segments) + if not normal_segs: + raise ValueError("normal segments 为空,无法对齐") + + if cache_dir is None: + cache_dir = Path(".c4_cache") + cache_dir.mkdir(parents=True, exist_ok=True) + + if no_ai: + n_segs = len(normal_segs) + total = len(b_lines) + records = [] + for bl in b_lines: + seg_idx = min( + int((bl["idx"] - 1) / total * n_segs), n_segs - 1 + ) + records.append( + {"line_no": bl["idx"], "seg_id": seg_idx, "confidence": 0.5} + ) + return records, [], normal_segs + + all_records = [] + all_audit = [] + total_batches = (len(b_lines) + batch_size - 1) // batch_size + last_normal_seg_id = 0 + + 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] + + 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_align] 复用缓存 batch_{batch_idx} ({len(cached)} 条)" + ) + all_records.extend(cached) + if cached: + last_normal_seg_id = max(rec["seg_id"] for rec in cached) + continue + except Exception as e: + print( + f"[fusion_align] 缓存 batch_{batch_idx} 损坏,重新计算: {e}", + file=sys.stderr, + ) + + print( + f"[fusion_align] 对齐 batch {batch_idx + 1}/{total_batches} " + f"(行 {start + 1}-{end})..." + ) + + batch_records = align_batch( + batch_b, normal_segs, last_normal_seg_id, no_ai=False + ) + + audit = enforce_monotonic(batch_records, min_seg_id=last_normal_seg_id) + if audit: + all_audit.extend(audit) + print( + f"[fusion_align] batch {batch_idx + 1} 单调修正 {len(audit)} 行" + ) + + if batch_records: + last_normal_seg_id = max(rec["seg_id"] for rec in batch_records) + + cache_path.write_text( + json.dumps(batch_records, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + all_records.extend(batch_records) + + return all_records, all_audit, normal_segs + + +# ==================================================================== +# 6. 硬校验 +# ==================================================================== + + +def hard_validate( + records: List[dict], + b_line_count: int, + seg_count: int, +) -> None: + """硬校验,任一不过 raise ValueError,不写出半成品文件。""" + total = len(records) + if total != b_line_count: + raise ValueError( + f"对齐结果行数 {total} != B稿行数 {b_line_count}" + ) + + line_nos = [rec["line_no"] for rec in records] + expected = list(range(1, b_line_count + 1)) + if line_nos != expected: + missing = set(expected) - set(line_nos) + extra = set(line_nos) - set(expected) + msg_parts = [] + if missing: + msg_parts.append(f"缺失行号: {sorted(missing)[:15]}") + if extra: + msg_parts.append(f"多余行号: {sorted(extra)[:15]}") + raise ValueError(f"line_no 不连续: {'; '.join(msg_parts)}") + + prev_seg = -1 + for rec in records: + if rec["seg_id"] < prev_seg: + raise ValueError( + f"行 {rec['line_no']} seg_id={rec['seg_id']} < 前值 " + f"{prev_seg},单调性被破坏" + ) + prev_seg = rec["seg_id"] + + max_seg = seg_count - 1 + for rec in records: + sid = rec["seg_id"] + if sid < 0 or sid > max_seg: + raise ValueError( + f"行 {rec['line_no']} seg_id={sid} 越界 [0, {max_seg}]" + ) + + +# ==================================================================== +# 7. 正文拼接(纯规则,零改字) +# ==================================================================== + + +def compose_segment_text(seg_lines: List[str]) -> str: + """ + 逐句顺接,零改字规则: + - 行尾若有标点(。!?;…!?;)则保留,否则补"," + - 整段最后一句末尾的","换成"。" + - 空列表返回 "" + """ + if not seg_lines: + return "" + + parts = [] + for text in seg_lines: + t = text.strip() + if not t: + continue + if t[-1] in SENTENCE_END_PUNCT: + parts.append(t) + else: + parts.append(t + ",") + + if not parts: + return "" + + last = parts[-1] + if last.endswith(","): + last = last[:-1] + "。" + elif last[-1] not in "。!?…!?": + last = last + "。" + parts[-1] = last + + return "".join(parts) + + +# ==================================================================== +# 7b. 标点恢复(AI 加标点,硬校验守死) +# ==================================================================== + + +PUNCTUATE_SYSTEM_PROMPT = """你是文稿标点校订员。给你一段【无标点的电视字幕文本】和一段【仅供参考的书面稿】。 +你的唯一任务:在【字幕文本】相邻字符之间插入中文标点(。,、;:?!""''《》()…),让它符合阅读习惯。 + +铁律(违反即作废): +1. 只能插入标点。禁止增加任何汉字——包括"那么""此外""相比之下""防不胜防"这类连接词或成语,一个字都不能加。 +2. 禁止删除任何汉字。禁止替换任何字("的/地/得"不能互换,"专门用于"不能改成"专司")。 +3. 即使字幕读起来不通顺、有重复、缺字、有错别字,也原样保留每一个字,只在字之间加标点。 +4. 参照稿只帮你判断在哪断句、哪里用顿号,绝不照抄它的字词。 +5. 正常使用句号断句。一个完整陈述句结束时用句号"。",不要把所有断句都用逗号","连接。问句用问号"?",感叹用感叹号"!"。逗号只用于句内停顿、并列成分之间。 + +错误示范: + ❌ 它的重量仅有3.6千克,非常便于携带,该枪采用了短行程活塞导气式自动方式,这种设计…… + ✅ 它的重量仅有3.6千克,非常便于携带。该枪采用了短行程活塞导气式自动方式,这种设计…… + +只返回加好标点的字幕文本,不要解释。""" + + +def strip_punct(text: str) -> str: + """剔除 Unicode 标点 + 空白字符。用于硬校验。""" + result = [] + for ch in text: + cat = unicodedata.category(ch) + if cat.startswith("P"): + continue + if ch.isspace(): + continue + result.append(ch) + return "".join(result) + + +def punctuate_segment( + bare_text: str, + ref_body: str, + cache_dir: Optional[Path] = None, + seg_id: int = -1, +) -> Tuple[str, bool]: + """ + 调 LLM 为 bare_text 添加标点符号。 + 返回 (文本, punct_ok) + """ + if not bare_text.strip(): + return bare_text, True + + if cache_dir is not None and seg_id >= 0: + cache_path = cache_dir / f"punct_seg_{seg_id}.json" + if cache_path.exists(): + try: + cached = json.loads(cache_path.read_text(encoding="utf-8")) + if cached.get("bare_text") == bare_text: + print(f"[fusion_align] 复用标点缓存 seg_{seg_id}") + return cached.get("punct_text", bare_text), cached.get( + "punct_ok", False + ) + except Exception as e: + print( + f"[fusion_align] 标点缓存 seg_{seg_id} 损坏: {e}", + file=sys.stderr, + ) + + user_content = f"【书面参照稿】\n{ref_body}\n\n【无标点字幕文本】\n{bare_text}" + + messages = [ + {"role": "system", "content": PUNCTUATE_SYSTEM_PROMPT}, + {"role": "user", "content": user_content}, + ] + + last_punct_text = bare_text + for attempt in range(3): + try: + raw_response = chat( + messages, + thinking=False, + max_tokens=3000, + temperature=0.0, + ) + punct_text = raw_response.strip() + + if strip_punct(punct_text) == strip_punct(bare_text): + if cache_dir is not None and seg_id >= 0: + cache_path = cache_dir / f"punct_seg_{seg_id}.json" + cache_path.write_text( + json.dumps( + { + "bare_text": bare_text, + "punct_text": punct_text, + "punct_ok": True, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + if attempt > 0: + print( + f"[fusion_align] seg_{seg_id} 标点恢复第 {attempt + 1} 次重试通过", + file=sys.stderr, + ) + return punct_text, True + else: + last_punct_text = punct_text + print( + f"[fusion_align] seg_{seg_id} 标点恢复第 {attempt + 1} 次尝试失败(模型改字)," + + (f" 将重试..." if attempt < 2 else f" 已达上限,回退"), + file=sys.stderr, + ) + except Exception as e: + print( + f"[fusion_align] seg_{seg_id} 标点 LLM 调用异常(第 {attempt + 1} 次): {e}", + file=sys.stderr, + ) + + if cache_dir is not None and seg_id >= 0: + cache_path = cache_dir / f"punct_seg_{seg_id}.json" + cache_path.write_text( + json.dumps( + { + "bare_text": bare_text, + "punct_text": bare_text, + "punct_ok": False, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + return bare_text, False + + +# ==================================================================== +# 8. 出 docx(GB/T 9704 公文格式) +# ==================================================================== + + +def _set_run_font(run, font_name: str, size_pt: float, bold: bool = False): + run.font.name = font_name + run.font.size = Pt(size_pt) + run.bold = bold + + rPr = run._element.get_or_add_rPr() + rFonts = rPr.find(qn("w:rFonts")) + if rFonts is None: + rFonts = OxmlElement("w:rFonts") + rPr.insert(0, rFonts) + rFonts.set(qn("w:ascii"), font_name) + rFonts.set(qn("w:hAnsi"), font_name) + rFonts.set(qn("w:eastAsia"), font_name) + + +def _set_line_spacing(paragraph, ratio: float = 1.25): + pPr = paragraph._element.get_or_add_pPr() + spacing = pPr.find(qn("w:spacing")) + if spacing is None: + spacing = OxmlElement("w:spacing") + pPr.append(spacing) + spacing.set(qn("w:line"), str(int(ratio * 240))) + spacing.set(qn("w:lineRule"), "auto") + + +def _set_first_line_indent( + paragraph, chars: float = 2.0, font_size_pt: float = 14.0 +): + pPr = paragraph._element.get_or_add_pPr() + ind = pPr.find(qn("w:ind")) + if ind is None: + ind = OxmlElement("w:ind") + pPr.append(ind) + indent_twips = int(chars * font_size_pt * 20) + ind.set(qn("w:firstLine"), str(indent_twips)) + + +def render_docx( + title: str, + segments: List[dict], + seg_texts: List[str], + out_path: str, +) -> None: + """输出公文格式 docx (GB/T 9704)。""" + doc = Document() + + section = doc.sections[0] + section.page_width = Cm(21.0) + section.page_height = Cm(29.7) + section.top_margin = Cm(3.7) + section.bottom_margin = Cm(3.5) + section.left_margin = Cm(2.8) + section.right_margin = Cm(2.6) + + title_para = doc.add_paragraph() + title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + _set_run_font(title_para.add_run(title), TITLE_FONT_PRIMARY, 22, bold=False) + + for seg, seg_text in zip(segments, seg_texts): + if seg.get("type") == "break": + h_para = doc.add_paragraph() + h_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + _set_run_font( + h_para.add_run(seg["header"]), + BREAK_HEADER_FONT, + 15, + bold=True, + ) + _set_line_spacing(h_para, 1.25) + else: + h_para = doc.add_paragraph() + _set_run_font(h_para.add_run(seg["header"]), HEADER_FONT, 16) + _set_line_spacing(h_para, 1.25) + + body_text = seg_text if seg_text else EMPTY_SEG_PLACEHOLDER + b_para = doc.add_paragraph() + _set_run_font(b_para.add_run(body_text), BODY_FONT, 14) + _set_line_spacing(b_para, 1.25) + _set_first_line_indent(b_para, 2.0, 14.0) + + doc.save(out_path) + + +# ==================================================================== +# 9. 写留痕 CSV (c4_alignment.csv) +# ==================================================================== + + +def write_alignment_csv( + csv_path: str, + segments: List[dict], + alignment: List[dict], + audit_logs: List[dict], + punct_results: Dict[int, bool], +) -> None: + """写 c4_alignment.csv。""" + normal_segs = _get_normal_segments(segments) + g2n, n2g = _build_normal_seg_id_map(normal_segs) + + seg_data: Dict[int, dict] = {} + for rec in alignment: + normal_sid = rec["seg_id"] + global_sid = n2g.get(normal_sid, normal_sid) + if global_sid not in seg_data: + seg_data[global_sid] = {"lines": [], "confidences": []} + seg_data[global_sid]["lines"].append(rec["line_no"]) + seg_data[global_sid]["confidences"].append(rec["confidence"]) + + forced_lines = {a["line_no"] for a in audit_logs} + + rows = [ + "seg_id,header,start_line,end_line,line_count,min_confidence,punct_ok,note" + ] + + for seg in segments: + sid = seg["seg_id"] + + if seg.get("type") == "break": + note = "隔断" + rows.append( + f'{sid},"{seg["header"]}",,,0,0.0000,,"{note}"' + ) + continue + + sd = seg_data.get(sid, {"lines": [], "confidences": []}) + + lines = sorted(sd["lines"]) + if lines: + start_line = lines[0] + end_line = lines[-1] + line_count = len(lines) + min_conf = min(sd["confidences"]) + else: + start_line = "" + end_line = "" + line_count = 0 + min_conf = 1.0 + + punct_ok = punct_results.get(sid, True) + + notes = [] + if line_count == 0: + notes.append("空段:无对应字幕") + if min_conf < 0.6 and line_count > 0: + notes.append(f"低把握(min_confidence={min_conf:.2f})") + if not punct_ok: + notes.append("标点恢复失败已回退,需人工") + + forced_in_seg = [ln for ln in lines if ln in forced_lines] + if forced_in_seg: + preview = forced_in_seg[:10] + suffix = "..." if len(forced_in_seg) > 10 else "" + notes.append( + f"单调强制修正行: {preview}{suffix}" + ) + + note = "; ".join(notes) + + header_escaped = seg["header"].replace('"', '""') + note_escaped = note.replace('"', '""') + + rows.append( + f'{sid},"{header_escaped}",{start_line},{end_line},' + f'{line_count},{min_conf:.4f},{punct_ok},"{note_escaped}"' + ) + + Path(csv_path).write_text( + "\n".join(rows) + "\n", encoding="utf-8" + ) + + +# ==================================================================== +# 10. 主流程 (doco compose) +# ==================================================================== + + +def run_compose( + episode_id: str, + output_dir: str, + no_ai: bool = False, + batch_size: int = 40, +) -> dict: + """ + C4 主流程: + 1. 找 A 稿 docx + 检查 skeleton 先决条件 + 2. parse_a_segments 解析分段(优先读 skeleton,fallback 正则) + 3. align_lines_to_segments 分批对齐 normal 段 + 4. hard_validate 硬校验 + 5. compose_segment_text 纯规则拼接正文 + 6. punctuate_segment AI标点恢复 + 硬校验 + 7. render_docx 出融合A稿.docx + 8. write_alignment_csv 保留痕 + + 返回统计 dict + """ + out_dir = Path(output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + # ---- 找 A 稿 docx ---- + # 只认原始 A 稿:排除本流程产出的"融合"稿与人工"批改"稿, + # 否则重跑 compose 时 glob 可能把自己的输出当成输入 A 稿。 + a_candidates = [ + f + for f in out_dir.glob("*.docx") + if not f.name.startswith("~$") + and "融合" not in f.name + and "_批改" not in f.name + ] + if not a_candidates: + raise FileNotFoundError( + f"在 {out_dir} 中找不到 docx 文件\n" + f"目录下的 docx 文件: {[d.name for d in out_dir.glob('*.docx')]}" + ) + a_path = a_candidates[0] + print(f"[fusion_align] A稿: {a_path}") + + # ---- 前置检查: skeleton ---- + skeleton_path = out_dir / f"{episode_id}_a_skeleton.json" + if skeleton_path.exists(): + print(f"[fusion_align] 分段骨架: {skeleton_path}") + else: + # 检查是否 ep001 格式(旧正则能过) + doc = Document(str(a_path)) + paras = [para.text.strip() for para in doc.paragraphs if para.text.strip()] + first_non_empty = paras[1] if len(paras) > 1 else "" + if SEG_HEADER_PATTERN.match(first_non_empty): + print( + "[fusion_align] 未找到 skeleton.json,检测到旧版【】格式,走正则 fallback" + ) + else: + raise FileNotFoundError( + f"未找到分段骨架文件: {skeleton_path}\n" + f"请先运行: doco skeleton --episode-id {episode_id} --a-script {a_path}" + ) + + # ---- 读 B 稿 ---- + b_path = out_dir / "融合B稿.txt" + if not b_path.exists(): + raise FileNotFoundError(f"融合B稿.txt 不存在: {b_path}") + b_lines = parse_timed_lines(b_path) + print(f"[fusion_align] B稿: {len(b_lines)} 行") + + # ---- 解析 A 稿 ---- + a_data = parse_a_segments(str(a_path)) + title = a_data["title"] + segments = a_data["segments"] + print(f"[fusion_align] 标题: {title}") + print(f"[fusion_align] 全局段数: {len(segments)}") + for seg in segments: + type_str = seg.get("type", "normal") + print( + f" seg_id={seg['seg_id']} [{type_str}] | {seg['header']} " + f"| body_len={len(seg['body'])}" + ) + + # ---- 对齐(只对 normal 段) ---- + cache_dir = out_dir / ".c4_cache" + alignment, audit_logs, normal_segs = align_lines_to_segments( + b_lines, + segments, + no_ai=no_ai, + batch_size=batch_size, + cache_dir=cache_dir, + ) + + g2n, n2g = _build_normal_seg_id_map(normal_segs) + + # ---- 硬校验 (normal 段数) ---- + hard_validate(alignment, len(b_lines), len(normal_segs)) + print("[fusion_align] [OK] 硬校验通过") + + # ---- 拼接各段正文(裸文本) + 标点恢复 ---- + seg_b_texts: Dict[int, list] = {} + for rec, bl in zip(alignment, b_lines): + normal_sid = rec["seg_id"] + global_sid = n2g.get(normal_sid, normal_sid) + seg_b_texts.setdefault(global_sid, []).append(bl["text"]) + + seg_texts = [] + punct_results: Dict[int, bool] = {} + + for seg in segments: + sid = seg["seg_id"] + + if seg.get("type") == "break": + seg_texts.append("") + continue + + lines = seg_b_texts.get(sid, []) + if not lines: + seg_texts.append("") + punct_results[sid] = True + continue + + bare_text = compose_segment_text(lines) + + ref_body = seg["body"] + punct_text, punct_ok = punctuate_segment( + bare_text, + ref_body, + cache_dir=cache_dir, + seg_id=sid, + ) + seg_texts.append(punct_text) + punct_results[sid] = punct_ok + + # ---- 出 docx ---- + # 出稿命名 = 原始 A 稿名 + "_融合A稿"(不覆盖原始定稿,且带本期日期/节目/编导信息) + docx_path = out_dir / f"{a_path.stem}_融合A稿.docx" + render_docx(title, segments, seg_texts, str(docx_path)) + print(f"[fusion_align] 融合A稿: {docx_path}") + + # ---- 出 CSV ---- + csv_path = out_dir / "c4_alignment.csv" + write_alignment_csv( + str(csv_path), segments, alignment, audit_logs, punct_results + ) + print(f"[fusion_align] 留痕 CSV: {csv_path}") + + # ---- 统计 ---- + seg_line_counts: Dict[int, int] = {} + for rec, bl in zip(alignment, b_lines): + global_sid = n2g.get(rec["seg_id"], rec["seg_id"]) + seg_line_counts[global_sid] = seg_line_counts.get(global_sid, 0) + 1 + + empty_segs = 0 + low_conf_segs = 0 + punct_failed_segs = 0 + for seg in segments: + sid = seg["seg_id"] + if seg.get("type") == "break": + continue + count = seg_line_counts.get(sid, 0) + if count == 0: + empty_segs += 1 + else: + min_c = min( + rec["confidence"] + for rec, bl in zip(alignment, b_lines) + if n2g.get(rec["seg_id"], rec["seg_id"]) == sid + ) + if min_c < 0.6: + low_conf_segs += 1 + if not punct_results.get(sid, True): + punct_failed_segs += 1 + + stats = { + "total_lines": len(b_lines), + "segment_count": len(segments), + "seg_line_counts": seg_line_counts, + "empty_segments": empty_segs, + "low_confidence_segments": low_conf_segs, + "audit_forced_lines": len(audit_logs), + "punct_failed_segs": punct_failed_segs, + "docx_path": str(docx_path), + "csv_path": str(csv_path), + } + + print(f"\n[fusion_align] === 统计 ===") + print(f" 总行数: {stats['total_lines']}") + print(f" 段数: {stats['segment_count']}") + print(f" 空段数: {stats['empty_segments']}") + print(f" 低把握段数: {stats['low_confidence_segments']}") + print(f" 单调修正行数: {stats['audit_forced_lines']}") + print(f" 标点回退段数: {stats['punct_failed_segs']}") + print(f" 各段行数分布:") + for seg in segments: + sid = seg["seg_id"] + if seg.get("type") == "break": + print(f" [{sid:2d}] [隔断] {seg['header']}") + continue + count = seg_line_counts.get(sid, 0) + flag = "" + if count == 0: + flag = " [空段]" + elif count > 0: + min_c = min( + (rec["confidence"] for rec, bl in zip(alignment, b_lines) + if n2g.get(rec["seg_id"], rec["seg_id"]) == sid), + default=1.0, + ) + if min_c < 0.6: + flag = f" [低把握 min_c={min_c:.2f}]" + punct_flag = " [标点回退]" if not punct_results.get(sid, True) else "" + print(f" [{sid:2d}] {seg['header']}: {count} 行{flag}{punct_flag}") + + return stats \ No newline at end of file diff --git a/doco/backup_before_spk/fusion_review.py b/doco/backup_before_spk/fusion_review.py new file mode 100644 index 0000000..88f0da6 --- /dev/null +++ b/doco/backup_before_spk/fusion_review.py @@ -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) diff --git a/doco/convert_to_md.py b/doco/convert_to_md.py new file mode 100644 index 0000000..b61f1a0 --- /dev/null +++ b/doco/convert_to_md.py @@ -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() diff --git a/doco/data/term_dict.json b/doco/data/term_dict.json index d4189dc..400f52c 100644 --- a/doco/data/term_dict.json +++ b/doco/data/term_dict.json @@ -4,9 +4,12 @@ "domain": "model", "episodes": [ "ep001_20260612_fangkong_fandao", - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep018_20260519_jiqiang_shuangxiong" ], - "frequency": 2 + "frequency": 5 }, "铁穹": { "term": "铁穹", @@ -77,9 +80,23 @@ "domain": "model", "episodes": [ "ep001_20260612_fangkong_fandao", - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep004_20260526_qiangwang_duijue", + "ep005_20260120_guiji_danao", + "ep006_20260203_jian_bufan", + "ep007_20260210_jujian_fancha", + "ep009_20260224_shenqi_baishe", + "ep010_20260310_shesu_juedinglun", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep014_20260421_qicheng_yuanhang", + "ep015_20260428_jianpao_meixue", + "ep016_20260505_wuren_jianting", + "ep017_20260512_nixi_yayi", + "ep019_20260602_wanju_weizhuang", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 2 + "frequency": 16 }, "霍拉姆沙赫尔": { "term": "霍拉姆沙赫尔", @@ -117,17 +134,30 @@ "term": "防空", "domain": "weapon", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep005_20260120_guiji_danao", + "ep006_20260203_jian_bufan", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep013_20260407_x76_feiji", + "ep014_20260421_qicheng_yuanhang", + "ep015_20260428_jianpao_meixue", + "ep016_20260505_wuren_jianting", + "ep019_20260602_wanju_weizhuang", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 12 }, "反导": { "term": "反导", "domain": "weapon", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep015_20260428_jianpao_meixue", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 3 }, "高超音速武器": { "term": "高超音速武器", @@ -149,17 +179,21 @@ "term": "巡航导弹", "domain": "weapon", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep006_20260203_jian_bufan", + "ep016_20260505_wuren_jianting", + "ep019_20260602_wanju_weizhuang" ], - "frequency": 1 + "frequency": 4 }, "自杀式无人机": { "term": "自杀式无人机", "domain": "weapon", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep016_20260505_wuren_jianting" ], - "frequency": 1 + "frequency": 2 }, "远程防空": { "term": "远程防空", @@ -182,9 +216,13 @@ "domain": "tech_concept", "episodes": [ "ep001_20260612_fangkong_fandao", - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep012_20260324_nixi_jicheng", + "ep013_20260407_x76_feiji", + "ep015_20260428_jianpao_meixue", + "ep018_20260519_jiqiang_shuangxiong" ], - "frequency": 2 + "frequency": 6 }, "相控阵预警雷达": { "term": "相控阵预警雷达", @@ -198,9 +236,10 @@ "term": "高超音速导弹", "domain": "weapon", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep005_20260120_guiji_danao" ], - "frequency": 1 + "frequency": 2 }, "攻击无人机": { "term": "攻击无人机", @@ -214,9 +253,10 @@ "term": "高超音速", "domain": "weapon", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep011_20260317_kongzhan_yanjin" ], - "frequency": 1 + "frequency": 2 }, "子弹头": { "term": "子弹头", @@ -342,17 +382,19 @@ "term": "红外传感器", "domain": "weapon", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep012_20260324_nixi_jicheng" ], - "frequency": 1 + "frequency": 2 }, "无人机蜂群": { "term": "无人机蜂群", - "domain": "tech_concept", + "domain": "weapon", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep005_20260120_guiji_danao" ], - "frequency": 1 + "frequency": 2 }, "远程防空系统": { "term": "远程防空系统", @@ -406,9 +448,10 @@ "term": "防空导弹", "domain": "weapon", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep012_20260324_nixi_jicheng" ], - "frequency": 1 + "frequency": 2 }, "防空武器": { "term": "防空武器", @@ -430,9 +473,10 @@ "term": "伊朗", "domain": "org", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep016_20260505_wuren_jianting" ], - "frequency": 1 + "frequency": 2 }, "美国": { "term": "美国", @@ -440,33 +484,52 @@ "episodes": [ "ep001_20260612_fangkong_fandao", "ep003_20260331_qiangxie_chicun", - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep004_20260526_qiangwang_duijue", + "ep005_20260120_guiji_danao", + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep013_20260407_x76_feiji", + "ep016_20260505_wuren_jianting", + "ep017_20260512_nixi_yayi", + "ep018_20260519_jiqiang_shuangxiong", + "ep019_20260602_wanju_weizhuang", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 3 + "frequency": 15 }, "以色列": { "term": "以色列", "domain": "org", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep017_20260512_nixi_yayi", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 3 }, "韩国": { "term": "韩国", "domain": "org", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe" ], - "frequency": 1 + "frequency": 2 }, "伊拉克": { "term": "伊拉克", "domain": "org", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep008_20260217_manian_tujian", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep019_20260602_wanju_weizhuang" ], - "frequency": 1 + "frequency": 5 }, "黎巴嫩": { "term": "黎巴嫩", @@ -488,9 +551,12 @@ "term": "国防部", "domain": "org", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep005_20260120_guiji_danao", + "ep013_20260407_x76_feiji", + "ep016_20260505_wuren_jianting" ], - "frequency": 1 + "frequency": 4 }, "拉斐尔公司": { "term": "拉斐尔公司", @@ -504,9 +570,11 @@ "term": "国会", "domain": "org", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe", + "ep016_20260505_wuren_jianting" ], - "frequency": 1 + "frequency": 3 }, "真主党": { "term": "真主党", @@ -522,75 +590,121 @@ "episodes": [ "ep001_20260612_fangkong_fandao", "ep003_20260331_qiangxie_chicun", - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep004_20260526_qiangwang_duijue", + "ep005_20260120_guiji_danao", + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe", + "ep010_20260310_shesu_juedinglun", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep013_20260407_x76_feiji", + "ep016_20260505_wuren_jianting", + "ep017_20260512_nixi_yayi", + "ep018_20260519_jiqiang_shuangxiong", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 3 + "frequency": 15 }, "专家1": { "term": "专家1", "domain": "person", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin" ], - "frequency": 1 + "frequency": 3 }, "专家2": { "term": "专家2", "domain": "person", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin" ], - "frequency": 1 + "frequency": 3 }, "主持人2": { "term": "主持人2", "domain": "person", "episodes": [ "ep001_20260612_fangkong_fandao", - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep004_20260526_qiangwang_duijue", + "ep005_20260120_guiji_danao", + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe", + "ep010_20260310_shesu_juedinglun", + "ep011_20260317_kongzhan_yanjin", + "ep013_20260407_x76_feiji", + "ep016_20260505_wuren_jianting", + "ep017_20260512_nixi_yayi", + "ep018_20260519_jiqiang_shuangxiong", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 2 + "frequency": 13 }, "专家3": { "term": "专家3", "domain": "person", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin" ], - "frequency": 1 + "frequency": 3 }, "专家4": { "term": "专家4", "domain": "person", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin" ], - "frequency": 1 + "frequency": 3 }, "主持人3": { "term": "主持人3", "domain": "person", "episodes": [ "ep001_20260612_fangkong_fandao", - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep004_20260526_qiangwang_duijue", + "ep005_20260120_guiji_danao", + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe", + "ep010_20260310_shesu_juedinglun", + "ep011_20260317_kongzhan_yanjin", + "ep013_20260407_x76_feiji", + "ep016_20260505_wuren_jianting", + "ep017_20260512_nixi_yayi", + "ep018_20260519_jiqiang_shuangxiong", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 2 + "frequency": 13 }, "专家5": { "term": "专家5", "domain": "person", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin" ], - "frequency": 1 + "frequency": 3 }, "专家6": { "term": "专家6", "domain": "person", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin" ], - "frequency": 1 + "frequency": 3 }, "主持人 4": { "term": "主持人 4", @@ -604,17 +718,19 @@ "term": "专家7", "domain": "person", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe" ], - "frequency": 1 + "frequency": 2 }, "专家8": { "term": "专家8", "domain": "person", "episodes": [ - "ep001_20260612_fangkong_fandao" + "ep001_20260612_fangkong_fandao", + "ep009_20260224_shenqi_baishe" ], - "frequency": 1 + "frequency": 2 }, "主持人 5": { "term": "主持人 5", @@ -629,9 +745,12 @@ "domain": "model", "episodes": [ "ep001_20260612_fangkong_fandao", - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep018_20260519_jiqiang_shuangxiong" ], - "frequency": 2 + "frequency": 5 }, "萨德防御系统": { "term": "萨德防御系统", @@ -782,18 +901,33 @@ "domain": "person", "episodes": [ "ep001_20260612_fangkong_fandao", - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep004_20260526_qiangwang_duijue", + "ep006_20260203_jian_bufan", + "ep007_20260210_jujian_fancha", + "ep009_20260224_shenqi_baishe", + "ep010_20260310_shesu_juedinglun", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep015_20260428_jianpao_meixue", + "ep016_20260505_wuren_jianting", + "ep018_20260519_jiqiang_shuangxiong", + "ep019_20260602_wanju_weizhuang" ], - "frequency": 2 + "frequency": 13 }, "饱和攻击": { "term": "饱和攻击", "domain": "tech_concept", "episodes": [ "ep001_20260612_fangkong_fandao", - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep012_20260324_nixi_jicheng", + "ep013_20260407_x76_feiji", + "ep015_20260428_jianpao_meixue", + "ep018_20260519_jiqiang_shuangxiong" ], - "frequency": 2 + "frequency": 6 }, "机动变轨": { "term": "机动变轨", @@ -919,33 +1053,49 @@ "term": "弹道", "domain": "weapon", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep004_20260526_qiangwang_duijue", + "ep015_20260428_jianpao_meixue" ], - "frequency": 1 + "frequency": 3 }, "日本": { "term": "日本", "domain": "org", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep004_20260526_qiangwang_duijue", + "ep005_20260120_guiji_danao", + "ep015_20260428_jianpao_meixue", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 5 }, "俄罗斯": { "term": "俄罗斯", "domain": "org", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep004_20260526_qiangwang_duijue", + "ep009_20260224_shenqi_baishe", + "ep017_20260512_nixi_yayi" ], - "frequency": 1 + "frequency": 4 }, "陆军": { "term": "陆军", "domain": "org", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep005_20260120_guiji_danao", + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe", + "ep012_20260324_nixi_jicheng", + "ep013_20260407_x76_feiji", + "ep018_20260519_jiqiang_shuangxiong", + "ep019_20260602_wanju_weizhuang" ], - "frequency": 1 + "frequency": 8 }, "北约": { "term": "北约", @@ -959,41 +1109,67 @@ "term": "主持人4", "domain": "person", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep004_20260526_qiangwang_duijue", + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe", + "ep010_20260310_shesu_juedinglun", + "ep011_20260317_kongzhan_yanjin", + "ep013_20260407_x76_feiji", + "ep016_20260505_wuren_jianting", + "ep017_20260512_nixi_yayi", + "ep018_20260519_jiqiang_shuangxiong", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 11 }, "主持人5": { "term": "主持人5", "domain": "person", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep004_20260526_qiangwang_duijue", + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe", + "ep010_20260310_shesu_juedinglun", + "ep011_20260317_kongzhan_yanjin", + "ep013_20260407_x76_feiji", + "ep017_20260512_nixi_yayi", + "ep018_20260519_jiqiang_shuangxiong", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 10 }, "主持人6": { "term": "主持人6", "domain": "person", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep004_20260526_qiangwang_duijue", + "ep011_20260317_kongzhan_yanjin", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 4 }, "主持人7": { "term": "主持人7", "domain": "person", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep010_20260310_shesu_juedinglun", + "ep011_20260317_kongzhan_yanjin", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 4 }, "主持人8": { "term": "主持人8", "domain": "person", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 2 }, "FP-45“解放者”手枪": { "term": "FP-45“解放者”手枪", @@ -1159,9 +1335,12 @@ "term": "美国陆军", "domain": "org", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep008_20260217_manian_tujian", + "ep012_20260324_nixi_jicheng", + "ep019_20260602_wanju_weizhuang" ], - "frequency": 1 + "frequency": 4 }, "通用汽车大陆制造分公司": { "term": "通用汽车大陆制造分公司", @@ -1279,9 +1458,10 @@ "term": "盟军", "domain": "org", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep019_20260602_wanju_weizhuang" ], - "frequency": 1 + "frequency": 2 }, "二战": { "term": "二战", @@ -1335,9 +1515,10 @@ "term": "模块化设计", "domain": "tech_concept", "episodes": [ - "ep003_20260331_qiangxie_chicun" + "ep003_20260331_qiangxie_chicun", + "ep016_20260505_wuren_jianting" ], - "frequency": 1 + "frequency": 2 }, "亚音速弹": { "term": "亚音速弹", @@ -1399,9 +1580,21 @@ "term": "海军", "domain": "org", "episodes": [ - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep004_20260526_qiangwang_duijue", + "ep006_20260203_jian_bufan", + "ep007_20260210_jujian_fancha", + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe", + "ep012_20260324_nixi_jicheng", + "ep013_20260407_x76_feiji", + "ep014_20260421_qicheng_yuanhang", + "ep015_20260428_jianpao_meixue", + "ep016_20260505_wuren_jianting", + "ep019_20260602_wanju_weizhuang", + "ep020_20260609_yanzhi_wadi" ], - "frequency": 1 + "frequency": 13 }, "潜艇": { "term": "潜艇", @@ -1711,33 +1904,45 @@ "term": "F/A-18E/F", "domain": "model", "episodes": [ - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep018_20260519_jiqiang_shuangxiong" ], - "frequency": 1 + "frequency": 5 }, "歼-10": { "term": "歼-10", "domain": "model", "episodes": [ - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep018_20260519_jiqiang_shuangxiong" ], - "frequency": 1 + "frequency": 4 }, "SM-3": { "term": "SM-3", "domain": "model", "episodes": [ - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep018_20260519_jiqiang_shuangxiong" ], - "frequency": 1 + "frequency": 4 }, "英国海军": { "term": "英国海军", - "domain": "unit_designation", + "domain": "org", "episodes": [ - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep007_20260210_jujian_fancha", + "ep015_20260428_jianpao_meixue" ], - "frequency": 1 + "frequency": 3 }, "德国U型潜艇": { "term": "德国U型潜艇", @@ -1757,11 +1962,14 @@ }, "美国海军": { "term": "美国海军", - "domain": "unit_designation", + "domain": "org", "episodes": [ - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep009_20260224_shenqi_baishe", + "ep016_20260505_wuren_jianting", + "ep019_20260602_wanju_weizhuang" ], - "frequency": 1 + "frequency": 4 }, "埃兹拉·李": { "term": "埃兹拉·李", @@ -1775,25 +1983,36 @@ "term": "《军事科技》", "domain": "org", "episodes": [ - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep006_20260203_jian_bufan", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin" ], - "frequency": 1 + "frequency": 4 }, "英国": { "term": "英国", "domain": "org", "episodes": [ - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep005_20260120_guiji_danao", + "ep012_20260324_nixi_jicheng", + "ep015_20260428_jianpao_meixue", + "ep018_20260519_jiqiang_shuangxiong" ], - "frequency": 1 + "frequency": 5 }, "德国": { "term": "德国", "domain": "org", "episodes": [ - "ep002_20260127_qianting_fangsheng" + "ep002_20260127_qianting_fangsheng", + "ep005_20260120_guiji_danao", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep019_20260602_wanju_weizhuang" ], - "frequency": 1 + "frequency": 5 }, "隐蔽色": { "term": "隐蔽色", @@ -1890,5 +2109,7642 @@ "ep002_20260127_qianting_fangsheng" ], "frequency": 1 + }, + "M-16": { + "term": "M-16", + "domain": "model", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "AK-47": { + "term": "AK-47", + "domain": "model", + "episodes": [ + "ep004_20260526_qiangwang_duijue", + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 2 + }, + "中国": { + "term": "中国", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue", + "ep006_20260203_jian_bufan", + "ep007_20260210_jujian_fancha", + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe", + "ep014_20260421_qicheng_yuanhang", + "ep016_20260505_wuren_jianting", + "ep017_20260512_nixi_yayi" + ], + "frequency": 8 + }, + "联合国": { + "term": "联合国", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "AK-47突击步枪": { + "term": "AK-47突击步枪", + "domain": "weapon", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "AK枪族": { + "term": "AK枪族", + "domain": "weapon", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "M16枪械体系": { + "term": "M16枪械体系", + "domain": "weapon", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "M-16步枪": { + "term": "M-16步枪", + "domain": "weapon", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "M16": { + "term": "M16", + "domain": "model", + "episodes": [ + "ep004_20260526_qiangwang_duijue", + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 2 + }, + "苏联红军": { + "term": "苏联红军", + "domain": "unit_designation", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "美国海军陆战队": { + "term": "美国海军陆战队", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue", + "ep008_20260217_manian_tujian", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 3 + }, + "米哈伊尔·季莫费耶维奇・卡拉什尼科夫": { + "term": "米哈伊尔·季莫费耶维奇・卡拉什尼科夫", + "domain": "person", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "卡拉什尼科夫": { + "term": "卡拉什尼科夫", + "domain": "person", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "尤金・斯通纳": { + "term": "尤金・斯通纳", + "domain": "person", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "斯通纳": { + "term": "斯通纳", + "domain": "person", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "普京": { + "term": "普京", + "domain": "person", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "梅德韦杰夫": { + "term": "梅德韦杰夫", + "domain": "person", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "苏联": { + "term": "苏联", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue", + "ep008_20260217_manian_tujian", + "ep017_20260512_nixi_yayi", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 4 + }, + "纳粹德国": { + "term": "纳粹德国", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue", + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 2 + }, + "维加飞行公司": { + "term": "维加飞行公司", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "洛克希德·马丁公司": { + "term": "洛克希德·马丁公司", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "史密森学会": { + "term": "史密森学会", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "联合国安理会": { + "term": "联合国安理会", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "导气式自动原理": { + "term": "导气式自动原理", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "回转式闭锁枪机": { + "term": "回转式闭锁枪机", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "气吹式导气原理": { + "term": "气吹式导气原理", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "模块化": { + "term": "模块化", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue", + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 2 + }, + "小口径": { + "term": "小口径", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "人机工学": { + "term": "人机工学", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "轻量化": { + "term": "轻量化", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue", + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 2 + }, + "AI时空复原技术": { + "term": "AI时空复原技术", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "AI数字技术": { + "term": "AI数字技术", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "AK": { + "term": "AK", + "domain": "model", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "海军陆战队": { + "term": "海军陆战队", + "domain": "unit_designation", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "北越军队": { + "term": "北越军队", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "美军": { + "term": "美军", + "domain": "org", + "episodes": [ + "ep004_20260526_qiangwang_duijue", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 4 + }, + "气吹式导气自动原理": { + "term": "气吹式导气自动原理", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "中间威力枪弹": { + "term": "中间威力枪弹", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "小口径高速枪弹": { + "term": "小口径高速枪弹", + "domain": "tech_concept", + "episodes": [ + "ep004_20260526_qiangwang_duijue" + ], + "frequency": 1 + }, + "F-16": { + "term": "F-16", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao", + "ep017_20260512_nixi_yayi" + ], + "frequency": 2 + }, + "X-62A": { + "term": "X-62A", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "巡航": { + "term": "巡航", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao", + "ep012_20260324_nixi_jicheng", + "ep013_20260407_x76_feiji", + "ep016_20260505_wuren_jianting", + "ep017_20260512_nixi_yayi" + ], + "frequency": 5 + }, + "空军": { + "term": "空军", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao", + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep013_20260407_x76_feiji", + "ep017_20260512_nixi_yayi", + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 7 + }, + "主持人 3": { + "term": "主持人 3", + "domain": "person", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "F-16 Block70": { + "term": "F-16 Block70", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "QF-16": { + "term": "QF-16", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "F-16D": { + "term": "F-16D", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "VISTA": { + "term": "VISTA", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "暴风雨": { + "term": "暴风雨", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "Tempest": { + "term": "Tempest", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "MGCS": { + "term": "MGCS", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "格里芬": { + "term": "格里芬", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "VBMR Griffon": { + "term": "VBMR Griffon", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "美洲豹": { + "term": "美洲豹", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "凯撒": { + "term": "凯撒", + "domain": "model", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "卢卡斯": { + "term": "卢卡斯", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "斯崔克": { + "term": "斯崔克", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "定向能反无人机系统斯崔克": { + "term": "定向能反无人机系统斯崔克", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "卢卡斯激光定向能武器系统": { + "term": "卢卡斯激光定向能武器系统", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "卢卡斯激光系统": { + "term": "卢卡斯激光系统", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "卢卡斯车载激光系统": { + "term": "卢卡斯车载激光系统", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "卢卡斯激光武器系统": { + "term": "卢卡斯激光武器系统", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "AN/APG-83有源相控阵雷达": { + "term": "AN/APG-83有源相控阵雷达", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "130毫米口径滑膛炮": { + "term": "130毫米口径滑膛炮", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "定向能武器": { + "term": "定向能武器", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "巡飞弹": { + "term": "巡飞弹", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "遥控武器站": { + "term": "遥控武器站", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "迫击炮": { + "term": "迫击炮", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "自动榴弹发射器": { + "term": "自动榴弹发射器", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "毒液": { + "term": "毒液", + "domain": "unit_designation", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "毒液测试中队": { + "term": "毒液测试中队", + "domain": "unit_designation", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "埃格林空军基地": { + "term": "埃格林空军基地", + "domain": "unit_designation", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "弗兰克·肯德尔": { + "term": "弗兰克·肯德尔", + "domain": "person", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "洛马公司": { + "term": "洛马公司", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "罗尔斯-罗伊斯": { + "term": "罗尔斯-罗伊斯", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "莱昂纳多DRS公司": { + "term": "莱昂纳多DRS公司", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "蓝晕公司": { + "term": "蓝晕公司", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "泰雷兹": { + "term": "泰雷兹", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "雷诺卡车公司": { + "term": "雷诺卡车公司", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "美国空军": { + "term": "美国空军", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao", + "ep012_20260324_nixi_jicheng", + "ep017_20260512_nixi_yayi" + ], + "frequency": 3 + }, + "美国军方": { + "term": "美国军方", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "美国国防部": { + "term": "美国国防部", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "意大利": { + "term": "意大利", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "法国陆军": { + "term": "法国陆军", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao", + "ep009_20260224_shenqi_baishe" + ], + "frequency": 2 + }, + "比利时": { + "term": "比利时", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "卢森堡": { + "term": "卢森堡", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "法国": { + "term": "法国", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao", + "ep015_20260428_jianpao_meixue", + "ep017_20260512_nixi_yayi", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 4 + }, + "欧洲": { + "term": "欧洲", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "亚洲": { + "term": "亚洲", + "domain": "org", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "AI自主决策系统": { + "term": "AI自主决策系统", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "机器学习": { + "term": "机器学习", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "有人-无人协同作战": { + "term": "有人-无人协同作战", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "自主作战效能": { + "term": "自主作战效能", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "AI飞行员": { + "term": "AI飞行员", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "软件定义": { + "term": "软件定义", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "目标处理能力": { + "term": "目标处理能力", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "威胁排序": { + "term": "威胁排序", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "交换比": { + "term": "交换比", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "AI独立作战": { + "term": "AI独立作战", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "开放式架构": { + "term": "开放式架构", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "模型跟踪算法": { + "term": "模型跟踪算法", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "AI空战控制": { + "term": "AI空战控制", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "可变稳定性飞行模拟器测试飞机": { + "term": "可变稳定性飞行模拟器测试飞机", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "飞控计算机": { + "term": "飞控计算机", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "一对一模拟空战演练": { + "term": "一对一模拟空战演练", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "大过载机动": { + "term": "大过载机动", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "自主决策": { + "term": "自主决策", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "协同作战管理器": { + "term": "协同作战管理器", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "忠诚僚机": { + "term": "忠诚僚机", + "domain": "weapon", + "episodes": [ + "ep005_20260120_guiji_danao", + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 2 + }, + "分布式侦察打击网络": { + "term": "分布式侦察打击网络", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "无人机协同": { + "term": "无人机协同", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "电子战自主化": { + "term": "电子战自主化", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "无人驾驶能力": { + "term": "无人驾驶能力", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "模块化架构设计": { + "term": "模块化架构设计", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "功能重构": { + "term": "功能重构", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao", + "ep012_20260324_nixi_jicheng" + ], + "frequency": 2 + }, + "虚拟信息头盔显控系统": { + "term": "虚拟信息头盔显控系统", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "蜂群技术": { + "term": "蜂群技术", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "AI助手": { + "term": "AI助手", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "辅助决策": { + "term": "辅助决策", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "联网作战": { + "term": "联网作战", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "作战云": { + "term": "作战云", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "无人化炮塔": { + "term": "无人化炮塔", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "主动防御系统": { + "term": "主动防御系统", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "战场态势感知": { + "term": "战场态势感知", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "天蝎计划": { + "term": "天蝎计划", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "作战信息系统": { + "term": "作战信息系统", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "战术电台": { + "term": "战术电台", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "蝎子信息与战斗系统": { + "term": "蝎子信息与战斗系统", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "SICS": { + "term": "SICS", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "数据互联": { + "term": "数据互联", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "AI算法": { + "term": "AI算法", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "战场态势分析": { + "term": "战场态势分析", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "火力分配策略": { + "term": "火力分配策略", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "探测-定位-打击": { + "term": "探测-定位-打击", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "动态猎杀矩阵": { + "term": "动态猎杀矩阵", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "定向能反无人机系统": { + "term": "定向能反无人机系统", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "高速计算机处理": { + "term": "高速计算机处理", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "先进算法": { + "term": "先进算法", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "新一代激光武器": { + "term": "新一代激光武器", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "目标识别": { + "term": "目标识别", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "目标甄别": { + "term": "目标甄别", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "智能化算法": { + "term": "智能化算法", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "飞行特征分析": { + "term": "飞行特征分析", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "影像分析": { + "term": "影像分析", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "传感器数据处理": { + "term": "传感器数据处理", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "最佳解决方案": { + "term": "最佳解决方案", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "对策": { + "term": "对策", + "domain": "tech_concept", + "episodes": [ + "ep005_20260120_guiji_danao" + ], + "frequency": 1 + }, + "歼": { + "term": "歼", + "domain": "model", + "episodes": [ + "ep006_20260203_jian_bufan", + "ep017_20260512_nixi_yayi" + ], + "frequency": 2 + }, + "近程防空导弹": { + "term": "近程防空导弹", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "055型驱逐舰": { + "term": "055型驱逐舰", + "domain": "model", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "咸阳舰": { + "term": "咸阳舰", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan", + "ep007_20260210_jujian_fancha" + ], + "frequency": 2 + }, + "075型两栖攻击舰": { + "term": "075型两栖攻击舰", + "domain": "model", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "湖北舰": { + "term": "湖北舰", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan", + "ep007_20260210_jujian_fancha" + ], + "frequency": 2 + }, + "山东舰": { + "term": "山东舰", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan", + "ep007_20260210_jujian_fancha" + ], + "frequency": 2 + }, + "051型驱逐舰": { + "term": "051型驱逐舰", + "domain": "model", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "052D型驱逐舰": { + "term": "052D型驱逐舰", + "domain": "model", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "071型船坞登陆舰": { + "term": "071型船坞登陆舰", + "domain": "model", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "歼-15舰载战斗机": { + "term": "歼-15舰载战斗机", + "domain": "model", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "万发炮": { + "term": "万发炮", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "通用垂直发射系统": { + "term": "通用垂直发射系统", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "气垫登陆艇": { + "term": "气垫登陆艇", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "两栖装甲车": { + "term": "两栖装甲车", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "自行榴弹炮": { + "term": "自行榴弹炮", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "预警直升机": { + "term": "预警直升机", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "反潜直升机": { + "term": "反潜直升机", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "辽宁舰": { + "term": "辽宁舰", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "18舰": { + "term": "18舰", + "domain": "weapon", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "宋怀远": { + "term": "宋怀远", + "domain": "person", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "冉伟宏": { + "term": "冉伟宏", + "domain": "person", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "王奕镔": { + "term": "王奕镔", + "domain": "person", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "高翔": { + "term": "高翔", + "domain": "person", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "张亚迪": { + "term": "张亚迪", + "domain": "person", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "颜金清": { + "term": "颜金清", + "domain": "person", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "桂淳": { + "term": "桂淳", + "domain": "person", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "南部战区海军": { + "term": "南部战区海军", + "domain": "org", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "中国人民解放军海军": { + "term": "中国人民解放军海军", + "domain": "org", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "国防军事频道": { + "term": "国防军事频道", + "domain": "org", + "episodes": [ + "ep006_20260203_jian_bufan", + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 2 + }, + "隐身设计": { + "term": "隐身设计", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan", + "ep011_20260317_kongzhan_yanjin", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 3 + }, + "全封闭飞剪式舰艏": { + "term": "全封闭飞剪式舰艏", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "一体化主桅设计": { + "term": "一体化主桅设计", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "热发射": { + "term": "热发射", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "冷发射": { + "term": "冷发射", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "冷热兼容发射": { + "term": "冷热兼容发射", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "平面登陆": { + "term": "平面登陆", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "立体登陆": { + "term": "立体登陆", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "垂直投送": { + "term": "垂直投送", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "平面突破": { + "term": "平面突破", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "远洋制空": { + "term": "远洋制空", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "制海": { + "term": "制海", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "空海一体": { + "term": "空海一体", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "双航母编队": { + "term": "双航母编队", + "domain": "tech_concept", + "episodes": [ + "ep006_20260203_jian_bufan" + ], + "frequency": 1 + }, + "福建舰": { + "term": "福建舰", + "domain": "weapon", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "江南造船厂": { + "term": "江南造船厂", + "domain": "org", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "国际信号旗": { + "term": "国际信号旗", + "domain": "weapon", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "一体化桅杆": { + "term": "一体化桅杆", + "domain": "tech_concept", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "055": { + "term": "055", + "domain": "model", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "旗语通信": { + "term": "旗语通信", + "domain": "tech_concept", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "旗号通信": { + "term": "旗号通信", + "domain": "tech_concept", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "波帕姆旗语系统": { + "term": "波帕姆旗语系统", + "domain": "tech_concept", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "胜利号": { + "term": "胜利号", + "domain": "model", + "episodes": [ + "ep007_20260210_jujian_fancha", + "ep015_20260428_jianpao_meixue" + ], + "frequency": 2 + }, + "纳尔逊勋爵": { + "term": "纳尔逊勋爵", + "domain": "person", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "特拉法尔加海战": { + "term": "特拉法尔加海战", + "domain": "org", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "北洋水师": { + "term": "北洋水师", + "domain": "org", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "致远舰": { + "term": "致远舰", + "domain": "weapon", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "吉野": { + "term": "吉野", + "domain": "weapon", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "传声筒": { + "term": "传声筒", + "domain": "weapon", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "扩音筒": { + "term": "扩音筒", + "domain": "weapon", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "舰操绘算图": { + "term": "舰操绘算图", + "domain": "weapon", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "电传操控系统": { + "term": "电传操控系统", + "domain": "tech_concept", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "全通式飞行甲板": { + "term": "全通式飞行甲板", + "domain": "tech_concept", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "无杆式牵引车": { + "term": "无杆式牵引车", + "domain": "weapon", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "歼-15": { + "term": "歼-15", + "domain": "model", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "舰载直升机牵引车": { + "term": "舰载直升机牵引车", + "domain": "weapon", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "075型": { + "term": "075型", + "domain": "model", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "王明明": { + "term": "王明明", + "domain": "person", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "王崛": { + "term": "王崛", + "domain": "person", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "何雨东": { + "term": "何雨东", + "domain": "person", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "张英明": { + "term": "张英明", + "domain": "person", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "黄撼坤": { + "term": "黄撼坤", + "domain": "person", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "人民海军": { + "term": "人民海军", + "domain": "org", + "episodes": [ + "ep007_20260210_jujian_fancha", + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 2 + }, + "波帕姆爵士": { + "term": "波帕姆爵士", + "domain": "person", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "欧美海军": { + "term": "欧美海军", + "domain": "org", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "亚洲的军舰": { + "term": "亚洲的军舰", + "domain": "org", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "航海部门": { + "term": "航海部门", + "domain": "unit_designation", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "值更官": { + "term": "值更官", + "domain": "unit_designation", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "操作兵": { + "term": "操作兵", + "domain": "unit_designation", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "信号兵": { + "term": "信号兵", + "domain": "unit_designation", + "episodes": [ + "ep007_20260210_jujian_fancha" + ], + "frequency": 1 + }, + "99A": { + "term": "99A", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "运-5": { + "term": "运-5", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "运": { + "term": "运", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "运- 5": { + "term": "运- 5", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-53": { + "term": "CH-53", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "53K": { + "term": "53K", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "53A": { + "term": "53A", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "53E": { + "term": "53E", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-53K": { + "term": "CH-53K", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-53E": { + "term": "CH-53E", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "726型气垫登陆艇": { + "term": "726型气垫登陆艇", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "野马": { + "term": "野马", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "99A主战坦克": { + "term": "99A主战坦克", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "两栖步兵战车": { + "term": "两栖步兵战车", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "071型两栖舰": { + "term": "071型两栖舰", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "075型两栖舰": { + "term": "075型两栖舰", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "安-2运输机": { + "term": "安-2运输机", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "运-5B": { + "term": "运-5B", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-53直升机": { + "term": "CH-53直升机", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "海上种马": { + "term": "海上种马", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-47“支奴干”双旋翼运输直升机": { + "term": "CH-47“支奴干”双旋翼运输直升机", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-53A“海上种马”": { + "term": "CH-53A“海上种马”", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-53E“超级种马”": { + "term": "CH-53E“超级种马”", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-53K“种马王”": { + "term": "CH-53K“种马王”", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "F-35C固定翼战斗机": { + "term": "F-35C固定翼战斗机", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "悍马车": { + "term": "悍马车", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "高机动性多用途轮式车辆": { + "term": "高机动性多用途轮式车辆", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "联合轻型战术车辆": { + "term": "联合轻型战术车辆", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "H1": { + "term": "H1", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "H2": { + "term": "H2", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "艾布拉姆斯主战坦克": { + "term": "艾布拉姆斯主战坦克", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "布雷德利履带式步战车": { + "term": "布雷德利履带式步战车", + "domain": "weapon", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "726型": { + "term": "726型", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-53A": { + "term": "CH-53A", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "CH-47": { + "term": "CH-47", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "F-35C": { + "term": "F-35C", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian", + "ep009_20260224_shenqi_baishe" + ], + "frequency": 2 + }, + "GE T408涡轴发动机": { + "term": "GE T408涡轴发动机", + "domain": "model", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "《军事科技》栏目组": { + "term": "《军事科技》栏目组", + "domain": "unit_designation", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "空降兵部队": { + "term": "空降兵部队", + "domain": "unit_designation", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "西科斯基公司": { + "term": "西科斯基公司", + "domain": "org", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "超地平线登陆": { + "term": "超地平线登陆", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "气垫": { + "term": "气垫", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "柔性围裙结构": { + "term": "柔性围裙结构", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "升力风扇": { + "term": "升力风扇", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 2 + }, + "推进风扇": { + "term": "推进风扇", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "活塞式发动机": { + "term": "活塞式发动机", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "双机翼设计": { + "term": "双机翼设计", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "固定式起落架": { + "term": "固定式起落架", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "短距起降": { + "term": "短距起降", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian", + "ep017_20260512_nixi_yayi" + ], + "frequency": 2 + }, + "全时四驱系统": { + "term": "全时四驱系统", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "中央轮胎充放气装置": { + "term": "中央轮胎充放气装置", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "非承载式底盘": { + "term": "非承载式底盘", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "大行程独立悬挂": { + "term": "大行程独立悬挂", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "模块化改装": { + "term": "模块化改装", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian", + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 2 + }, + "轻型装甲": { + "term": "轻型装甲", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "格栅装甲": { + "term": "格栅装甲", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "附加装甲": { + "term": "附加装甲", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "V字型车底": { + "term": "V字型车底", + "domain": "tech_concept", + "episodes": [ + "ep008_20260217_manian_tujian" + ], + "frequency": 1 + }, + "35C": { + "term": "35C", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "印度": { + "term": "印度", + "domain": "org", + "episodes": [ + "ep009_20260224_shenqi_baishe", + "ep014_20260421_qicheng_yuanhang", + "ep017_20260512_nixi_yayi", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 4 + }, + "阿联酋": { + "term": "阿联酋", + "domain": "org", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "福特号航母": { + "term": "福特号航母", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "CVN-21": { + "term": "CVN-21", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "尼米兹级": { + "term": "尼米兹级", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "电磁弹射系统": { + "term": "电磁弹射系统", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "先进阻拦装置": { + "term": "先进阻拦装置", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "双波段雷达": { + "term": "双波段雷达", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "全电推进系统": { + "term": "全电推进系统", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "蒸汽弹射器": { + "term": "蒸汽弹射器", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "超级大黄蜂": { + "term": "超级大黄蜂", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "阿琼主战坦克": { + "term": "阿琼主战坦克", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "T-72": { + "term": "T-72", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "M1艾布拉姆斯": { + "term": "M1艾布拉姆斯", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "120毫米线膛炮": { + "term": "120毫米线膛炮", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "复合装甲": { + "term": "复合装甲", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "数字化火控系统": { + "term": "数字化火控系统", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "KF-21猎鹰": { + "term": "KF-21猎鹰", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "F-22": { + "term": "F-22", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin", + "ep017_20260512_nixi_yayi" + ], + "frequency": 3 + }, + "歼-20": { + "term": "歼-20", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin", + "ep017_20260512_nixi_yayi" + ], + "frequency": 3 + }, + "苏-57": { + "term": "苏-57", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe", + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 2 + }, + "勒克莱尔坦克": { + "term": "勒克莱尔坦克", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "AMX-30坦克": { + "term": "AMX-30坦克", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "豹-2": { + "term": "豹-2", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "M1": { + "term": "M1", + "domain": "model", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "战场数据链": { + "term": "战场数据链", + "domain": "tech_concept", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "实时导航": { + "term": "实时导航", + "domain": "tech_concept", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "敌我识别": { + "term": "敌我识别", + "domain": "tech_concept", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "故障诊断": { + "term": "故障诊断", + "domain": "tech_concept", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "体系化协同作战": { + "term": "体系化协同作战", + "domain": "tech_concept", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "隐身化设计": { + "term": "隐身化设计", + "domain": "tech_concept", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "内置弹舱": { + "term": "内置弹舱", + "domain": "tech_concept", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "雷达反射截面": { + "term": "雷达反射截面", + "domain": "tech_concept", + "episodes": [ + "ep009_20260224_shenqi_baishe", + "ep013_20260407_x76_feiji" + ], + "frequency": 2 + }, + "电磁弹药升降机": { + "term": "电磁弹药升降机", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "电磁拦阻装置": { + "term": "电磁拦阻装置", + "domain": "weapon", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "美国国会": { + "term": "美国国会", + "domain": "org", + "episodes": [ + "ep009_20260224_shenqi_baishe", + "ep016_20260505_wuren_jianting" + ], + "frequency": 2 + }, + "诺福克船厂": { + "term": "诺福克船厂", + "domain": "org", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "诺福克海军基地": { + "term": "诺福克海军基地", + "domain": "org", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "印度陆军": { + "term": "印度陆军", + "domain": "org", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "印尼": { + "term": "印尼", + "domain": "org", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "国防频道": { + "term": "国防频道", + "domain": "org", + "episodes": [ + "ep009_20260224_shenqi_baishe" + ], + "frequency": 1 + }, + "枪械": { + "term": "枪械", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "手枪": { + "term": "手枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "步枪": { + "term": "步枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "机枪": { + "term": "机枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun", + "ep012_20260324_nixi_jicheng" + ], + "frequency": 2 + }, + "左轮手枪": { + "term": "左轮手枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "燧发枪": { + "term": "燧发枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "火绳枪": { + "term": "火绳枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "前膛枪": { + "term": "前膛枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "后装枪": { + "term": "后装枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "定装弹药": { + "term": "定装弹药", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "半自动手枪": { + "term": "半自动手枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "全自动冲锋手枪": { + "term": "全自动冲锋手枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "栓动式步枪": { + "term": "栓动式步枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "突击步枪": { + "term": "突击步枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "通用机枪": { + "term": "通用机枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "转管机枪": { + "term": "转管机枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "水冷式机枪": { + "term": "水冷式机枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "风冷式机枪": { + "term": "风冷式机枪", + "domain": "weapon", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "格洛克18": { + "term": "格洛克18", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "格洛克17": { + "term": "格洛克17", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "HK-416": { + "term": "HK-416", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "李恩菲尔德步枪": { + "term": "李恩菲尔德步枪", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "毛瑟98K步枪": { + "term": "毛瑟98K步枪", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "莫辛纳甘步枪": { + "term": "莫辛纳甘步枪", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "马克沁重机枪": { + "term": "马克沁重机枪", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "MG-42通用机枪": { + "term": "MG-42通用机枪", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "M134转管机枪": { + "term": "M134转管机枪", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "英七七": { + "term": "英七七", + "domain": "model", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "谷子地": { + "term": "谷子地", + "domain": "person", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "奥地利格洛克公司": { + "term": "奥地利格洛克公司", + "domain": "org", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "英国军队": { + "term": "英国军队", + "domain": "org", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "德军": { + "term": "德军", + "domain": "org", + "episodes": [ + "ep010_20260310_shesu_juedinglun", + "ep012_20260324_nixi_jicheng", + "ep018_20260519_jiqiang_shuangxiong", + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 4 + }, + "射速": { + "term": "射速", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "火力输出": { + "term": "火力输出", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "火力压制": { + "term": "火力压制", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "火力覆盖": { + "term": "火力覆盖", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "单动式击发": { + "term": "单动式击发", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "双动式击发": { + "term": "双动式击发", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "击针平移式击发机构": { + "term": "击针平移式击发机构", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "快慢机": { + "term": "快慢机", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "双扳机结构": { + "term": "双扳机结构", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "后端闭锁枪机": { + "term": "后端闭锁枪机", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "前端闭锁枪机": { + "term": "前端闭锁枪机", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "自动循环系统": { + "term": "自动循环系统", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "活塞式": { + "term": "活塞式", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "吹气式": { + "term": "吹气式", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "长行程活塞": { + "term": "长行程活塞", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "短行程活塞式": { + "term": "短行程活塞式", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "三连发射击模式": { + "term": "三连发射击模式", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "棘轮机构": { + "term": "棘轮机构", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "水冷式散热": { + "term": "水冷式散热", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "风冷式散热": { + "term": "风冷式散热", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "散热片": { + "term": "散热片", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "齿轮传动系统": { + "term": "齿轮传动系统", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "疯狂一分钟": { + "term": "疯狂一分钟", + "domain": "tech_concept", + "episodes": [ + "ep010_20260310_shesu_juedinglun" + ], + "frequency": 1 + }, + "朝鲜": { + "term": "朝鲜", + "domain": "org", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "螺旋桨飞机": { + "term": "螺旋桨飞机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "活塞式螺旋桨": { + "term": "活塞式螺旋桨", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "机枪同步射击装置": { + "term": "机枪同步射击装置", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "射击协调器": { + "term": "射击协调器", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "福克E.Ⅲ战斗机": { + "term": "福克E.Ⅲ战斗机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "喷气式飞机": { + "term": "喷气式飞机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "喷气式发动机": { + "term": "喷气式发动机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "Me-262喷气式战机": { + "term": "Me-262喷气式战机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "米格-15战斗机": { + "term": "米格-15战斗机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "F-86战斗机": { + "term": "F-86战斗机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "空空导弹": { + "term": "空空导弹", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "红外制导近距格斗弹": { + "term": "红外制导近距格斗弹", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "雷达制导中距拦射弹": { + "term": "雷达制导中距拦射弹", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "超远程空空导弹": { + "term": "超远程空空导弹", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "电子战飞机": { + "term": "电子战飞机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "EA-6B": { + "term": "EA-6B", + "domain": "model", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "EF-111": { + "term": "EF-111", + "domain": "model", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "隐身战机": { + "term": "隐身战机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng" + ], + "frequency": 2 + }, + "F-117A": { + "term": "F-117A", + "domain": "model", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "F-35": { + "term": "F-35", + "domain": "model", + "episodes": [ + "ep011_20260317_kongzhan_yanjin", + "ep013_20260407_x76_feiji", + "ep017_20260512_nixi_yayi", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 4 + }, + "无人机": { + "term": "无人机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin", + "ep012_20260324_nixi_jicheng", + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 3 + }, + "高超音速无人机": { + "term": "高超音速无人机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "空天飞机": { + "term": "空天飞机", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "无人作战集群": { + "term": "无人作战集群", + "domain": "weapon", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "协约国": { + "term": "协约国", + "domain": "org", + "episodes": [ + "ep011_20260317_kongzhan_yanjin", + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 2 + }, + "美国远东空军": { + "term": "美国远东空军", + "domain": "org", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "志愿军": { + "term": "志愿军", + "domain": "org", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "索姆河战役": { + "term": "索姆河战役", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "制空权": { + "term": "制空权", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "近距离缠斗": { + "term": "近距离缠斗", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "高空高速": { + "term": "高空高速", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "音障": { + "term": "音障", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "超音速": { + "term": "超音速", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "平流层": { + "term": "平流层", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "高亚音速": { + "term": "高亚音速", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "高速截击": { + "term": "高速截击", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "高空截击": { + "term": "高空截击", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "高速突防": { + "term": "高速突防", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "米格走廊": { + "term": "米格走廊", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "后掠翼": { + "term": "后掠翼", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "超视距空战": { + "term": "超视距空战", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "红外成像": { + "term": "红外成像", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "主动雷达制导": { + "term": "主动雷达制导", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "发射后不用管": { + "term": "发射后不用管", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "超大过载": { + "term": "超大过载", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "视距外决胜": { + "term": "视距外决胜", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "制电磁权": { + "term": "制电磁权", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "电子侦察": { + "term": "电子侦察", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "电子干扰": { + "term": "电子干扰", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "电子压制": { + "term": "电子压制", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "全频段": { + "term": "全频段", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "电磁屏障": { + "term": "电磁屏障", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "吸波材料": { + "term": "吸波材料", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "红外隐身": { + "term": "红外隐身", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "雷达反射面积": { + "term": "雷达反射面积", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "反隐身雷达": { + "term": "反隐身雷达", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "反隐身防空体系": { + "term": "反隐身防空体系", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "人机协同": { + "term": "人机协同", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "无人集群作战": { + "term": "无人集群作战", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "自主空战": { + "term": "自主空战", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "空天一体化": { + "term": "空天一体化", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "非对称作战": { + "term": "非对称作战", + "domain": "tech_concept", + "episodes": [ + "ep011_20260317_kongzhan_yanjin" + ], + "frequency": 1 + }, + "AC-47": { + "term": "AC-47", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "AC-119": { + "term": "AC-119", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "AH-1": { + "term": "AH-1", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "C-47": { + "term": "C-47", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "AC-130": { + "term": "AC-130", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "UH-1": { + "term": "UH-1", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "休伊": { + "term": "休伊", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "眼镜蛇": { + "term": "眼镜蛇", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "M7": { + "term": "M7", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "M3": { + "term": "M3", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "李": { + "term": "李", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "M4": { + "term": "M4", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng", + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 2 + }, + "谢尔曼": { + "term": "谢尔曼", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "M109": { + "term": "M109", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "M60c": { + "term": "M60c", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "XM60": { + "term": "XM60", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "M2A1": { + "term": "M2A1", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "M2": { + "term": "M2", + "domain": "model", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "20毫米机关炮": { + "term": "20毫米机关炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "40毫米博福斯炮": { + "term": "40毫米博福斯炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "105毫米榴弹炮": { + "term": "105毫米榴弹炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "7.62毫米机炮": { + "term": "7.62毫米机炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "20毫米机炮": { + "term": "20毫米机炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "2.75英寸火箭弹发射巢": { + "term": "2.75英寸火箭弹发射巢", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "反坦克导弹": { + "term": "反坦克导弹", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "精确制导炸弹": { + "term": "精确制导炸弹", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "空地导弹": { + "term": "空地导弹", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "舰炮": { + "term": "舰炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng", + "ep015_20260428_jianpao_meixue" + ], + "frequency": 2 + }, + "陆基近防炮": { + "term": "陆基近防炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "高射炮": { + "term": "高射炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "火箭发射架": { + "term": "火箭发射架", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "轻型火炮": { + "term": "轻型火炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "火箭": { + "term": "火箭", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "曳光弹": { + "term": "曳光弹", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "高爆弹": { + "term": "高爆弹", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "烟雾弹": { + "term": "烟雾弹", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "照明弹": { + "term": "照明弹", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "核炮弹": { + "term": "核炮弹", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "坦克": { + "term": "坦克", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "航母": { + "term": "航母", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "导弹": { + "term": "导弹", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng", + "ep015_20260428_jianpao_meixue" + ], + "frequency": 2 + }, + "运输机": { + "term": "运输机", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "喷气式攻击机": { + "term": "喷气式攻击机", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "运输直升机": { + "term": "运输直升机", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "武装直升机": { + "term": "武装直升机", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "自行火炮": { + "term": "自行火炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "牵引式榴弹炮": { + "term": "牵引式榴弹炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "轮式火炮": { + "term": "轮式火炮", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "爆炸装置": { + "term": "爆炸装置", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "多光谱传感器": { + "term": "多光谱传感器", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "传感与火控系统": { + "term": "传感与火控系统", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "数据链": { + "term": "数据链", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng", + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 2 + }, + "夜视系统": { + "term": "夜视系统", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "电子侦察系统": { + "term": "电子侦察系统", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "XM60反射式瞄具": { + "term": "XM60反射式瞄具", + "domain": "weapon", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "第57医疗分队": { + "term": "第57医疗分队", + "domain": "unit_designation", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "越军": { + "term": "越军", + "domain": "unit_designation", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "美军装甲师": { + "term": "美军装甲师", + "domain": "unit_designation", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "德军“非洲军团”": { + "term": "德军“非洲军团”", + "domain": "unit_designation", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "英军装甲部队": { + "term": "英军装甲部队", + "domain": "unit_designation", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "越南": { + "term": "越南", + "domain": "org", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "英军": { + "term": "英军", + "domain": "org", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "普惠": { + "term": "普惠", + "domain": "org", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "博福斯": { + "term": "博福斯", + "domain": "org", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "支点盘旋": { + "term": "支点盘旋", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "空中持续火力平台": { + "term": "空中持续火力平台", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "间接射击": { + "term": "间接射击", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "轮式化": { + "term": "轮式化", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "游击战": { + "term": "游击战", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "夜间袭扰": { + "term": "夜间袭扰", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "远征作战": { + "term": "远征作战", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "远程精确打击": { + "term": "远程精确打击", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng", + "ep015_20260428_jianpao_meixue" + ], + "frequency": 2 + }, + "核生化环境": { + "term": "核生化环境", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "封闭式炮塔": { + "term": "封闭式炮塔", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "自行装填": { + "term": "自行装填", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "市场花园行动": { + "term": "市场花园行动", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "越南战争": { + "term": "越南战争", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "诺曼底登陆": { + "term": "诺曼底登陆", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "突尼斯战役": { + "term": "突尼斯战役", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "俄乌冲突": { + "term": "俄乌冲突", + "domain": "tech_concept", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "阿富汗": { + "term": "阿富汗", + "domain": "org", + "episodes": [ + "ep012_20260324_nixi_jicheng" + ], + "frequency": 1 + }, + "X-76": { + "term": "X-76", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "MV-75": { + "term": "MV-75", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "188A": { + "term": "188A", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "波音": { + "term": "波音", + "domain": "org", + "episodes": [ + "ep013_20260407_x76_feiji", + "ep017_20260512_nixi_yayi", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 3 + }, + "折叠旋翼机": { + "term": "折叠旋翼机", + "domain": "weapon", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "X系列": { + "term": "X系列", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "美国国防高级研究计划局": { + "term": "美国国防高级研究计划局", + "domain": "org", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "贝尔公司": { + "term": "贝尔公司", + "domain": "org", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "波音公司": { + "term": "波音公司", + "domain": "org", + "episodes": [ + "ep013_20260407_x76_feiji", + "ep017_20260512_nixi_yayi", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 3 + }, + "美国特种作战司令部": { + "term": "美国特种作战司令部", + "domain": "org", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "速度与跑道独立技术": { + "term": "速度与跑道独立技术", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "停止/折叠技术": { + "term": "停止/折叠技术", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "可折叠旋翼系统": { + "term": "可折叠旋翼系统", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "涡扇发动机": { + "term": "涡扇发动机", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji", + "ep017_20260512_nixi_yayi" + ], + "frequency": 2 + }, + "涡轴发动机": { + "term": "涡轴发动机", + "domain": "weapon", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "V-22“鱼鹰”": { + "term": "V-22“鱼鹰”", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "倾转旋翼机": { + "term": "倾转旋翼机", + "domain": "weapon", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "C-130": { + "term": "C-130", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "D-188A": { + "term": "D-188A", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "F-104": { + "term": "F-104", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "X-14": { + "term": "X-14", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "通用电气": { + "term": "通用电气", + "domain": "org", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "涡喷发动机": { + "term": "涡喷发动机", + "domain": "weapon", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "V-280“英勇”": { + "term": "V-280“英勇”", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "UH-60“黑鹰”": { + "term": "UH-60“黑鹰”", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "分布式孔径系统": { + "term": "分布式孔径系统", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "卡莫夫设计局": { + "term": "卡莫夫设计局", + "domain": "org", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "KA-35": { + "term": "KA-35", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "安-12": { + "term": "安-12", + "domain": "model", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "重叠旋翼": { + "term": "重叠旋翼", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "可转换发动机": { + "term": "可转换发动机", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "分离式推进系统": { + "term": "分离式推进系统", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "低可探测性": { + "term": "低可探测性", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "隐身优化外形": { + "term": "隐身优化外形", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "红外与噪声抑制技术": { + "term": "红外与噪声抑制技术", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "垂直起降": { + "term": "垂直起降", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji", + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 2 + }, + "倾转旋翼": { + "term": "倾转旋翼", + "domain": "tech_concept", + "episodes": [ + "ep013_20260407_x76_feiji" + ], + "frequency": 1 + }, + "电磁干扰": { + "term": "电磁干扰", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "戚继光舰": { + "term": "戚继光舰", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "郑和舰": { + "term": "郑和舰", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "邓世昌舰": { + "term": "邓世昌舰", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "81舰": { + "term": "81舰", + "domain": "model", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "82舰": { + "term": "82舰", + "domain": "model", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "83舰": { + "term": "83舰", + "domain": "model", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "直-8舰载直升机": { + "term": "直-8舰载直升机", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "57毫米双管舰炮": { + "term": "57毫米双管舰炮", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "30毫米双管副炮": { + "term": "30毫米双管副炮", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "81式五管反潜火箭发射器": { + "term": "81式五管反潜火箭发射器", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "六分仪": { + "term": "六分仪", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "全任务模拟航海驾驶教学系统": { + "term": "全任务模拟航海驾驶教学系统", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "高保真模拟驾驶舱": { + "term": "高保真模拟驾驶舱", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "医疗模块": { + "term": "医疗模块", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "集装箱式医疗模块": { + "term": "集装箱式医疗模块", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "无人艇": { + "term": "无人艇", + "domain": "weapon", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "三米级无人艇原型机": { + "term": "三米级无人艇原型机", + "domain": "model", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海上卫生动员中心": { + "term": "海上卫生动员中心", + "domain": "org", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "大连舰艇学院": { + "term": "大连舰艇学院", + "domain": "org", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "中国海军": { + "term": "中国海军", + "domain": "org", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海军大连舰艇学院": { + "term": "海军大连舰艇学院", + "domain": "org", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "支队": { + "term": "支队", + "domain": "unit_designation", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "郑和": { + "term": "郑和", + "domain": "person", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "邓世昌": { + "term": "邓世昌", + "domain": "person", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "戚继光": { + "term": "戚继光", + "domain": "person", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "吴佳慧": { + "term": "吴佳慧", + "domain": "person", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "王浩然": { + "term": "王浩然", + "domain": "person", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "杨政": { + "term": "杨政", + "domain": "person", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "朱哲": { + "term": "朱哲", + "domain": "person", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "路标定位": { + "term": "路标定位", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "无线电定位": { + "term": "无线电定位", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "天文定位": { + "term": "天文定位", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "单标方位法": { + "term": "单标方位法", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "两边定位法": { + "term": "两边定位法", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海图绘制": { + "term": "海图绘制", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海图作业": { + "term": "海图作业", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "目标定位": { + "term": "目标定位", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "航海仪器使用": { + "term": "航海仪器使用", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "航海保证": { + "term": "航海保证", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "舰员与学员的混编统管": { + "term": "舰员与学员的混编统管", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "国防动员演练": { + "term": "国防动员演练", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "平战结合": { + "term": "平战结合", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "寓军于民": { + "term": "寓军于民", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海上立体作战保障训练": { + "term": "海上立体作战保障训练", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "舰载机起降协同训练": { + "term": "舰载机起降协同训练", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "垂直补给演练": { + "term": "垂直补给演练", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "伤员空中后送": { + "term": "伤员空中后送", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "无人作战系统": { + "term": "无人作战系统", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "无人装备": { + "term": "无人装备", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "战术编组": { + "term": "战术编组", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "协同指挥": { + "term": "协同指挥", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "态势研判": { + "term": "态势研判", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "联合作战思维": { + "term": "联合作战思维", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "无人艇编队": { + "term": "无人艇编队", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "航线规划": { + "term": "航线规划", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "无人艇协同": { + "term": "无人艇协同", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海上装备协同指挥": { + "term": "海上装备协同指挥", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "远海综合演练": { + "term": "远海综合演练", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "跨区对抗训练": { + "term": "跨区对抗训练", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "联合搜救任务": { + "term": "联合搜救任务", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "临机决断": { + "term": "临机决断", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "协同作战能力": { + "term": "协同作战能力", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "航海指挥": { + "term": "航海指挥", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "机电管理": { + "term": "机电管理", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "通信导航": { + "term": "通信导航", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "损管应急": { + "term": "损管应急", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "反潜": { + "term": "反潜", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海上医疗救援": { + "term": "海上医疗救援", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海上批量伤员转运": { + "term": "海上批量伤员转运", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "战创伤紧急处置": { + "term": "战创伤紧急处置", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "远程会诊联动": { + "term": "远程会诊联动", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "卫勤保障": { + "term": "卫勤保障", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海上应急救援": { + "term": "海上应急救援", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "海上国防动员": { + "term": "海上国防动员", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "应急应战": { + "term": "应急应战", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "武器试验": { + "term": "武器试验", + "domain": "tech_concept", + "episodes": [ + "ep014_20260421_qicheng_yuanhang" + ], + "frequency": 1 + }, + "滑膛炮": { + "term": "滑膛炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "32磅前装滑膛炮": { + "term": "32磅前装滑膛炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "32磅炮": { + "term": "32磅炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "24磅炮": { + "term": "24磅炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "18磅炮": { + "term": "18磅炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "12磅炮": { + "term": "12磅炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "膛线炮": { + "term": "膛线炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "速射火炮": { + "term": "速射火炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "12英寸主炮": { + "term": "12英寸主炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "15英寸主炮": { + "term": "15英寸主炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "16英寸/50口径Mark 7舰炮": { + "term": "16英寸/50口径Mark 7舰炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "MK45舰炮系统": { + "term": "MK45舰炮系统", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "Mk 45舰炮": { + "term": "Mk 45舰炮", + "domain": "weapon", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "圣三位一体号": { + "term": "圣三位一体号", + "domain": "model", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "无畏号战列舰": { + "term": "无畏号战列舰", + "domain": "model", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "伊丽莎白女王级": { + "term": "伊丽莎白女王级", + "domain": "model", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "爱荷华级": { + "term": "爱荷华级", + "domain": "model", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "密苏里号": { + "term": "密苏里号", + "domain": "model", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "英国舰队": { + "term": "英国舰队", + "domain": "unit_designation", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "西法舰队": { + "term": "西法舰队", + "domain": "unit_designation", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "法西联军": { + "term": "法西联军", + "domain": "unit_designation", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "纳尔逊": { + "term": "纳尔逊", + "domain": "person", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "西班牙": { + "term": "西班牙", + "domain": "org", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "自动装填": { + "term": "自动装填", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "高射速": { + "term": "高射速", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "对地支援": { + "term": "对地支援", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "火力密度": { + "term": "火力密度", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue", + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 2 + }, + "齐射": { + "term": "齐射", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "远程火力决胜": { + "term": "远程火力决胜", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "蒸汽轮机": { + "term": "蒸汽轮机", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "全钢结构": { + "term": "全钢结构", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "集中布置": { + "term": "集中布置", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "火力集中": { + "term": "火力集中", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "装甲防护": { + "term": "装甲防护", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "分装式装填": { + "term": "分装式装填", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "弹药升降机": { + "term": "弹药升降机", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "测距仪": { + "term": "测距仪", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "射击指挥装置": { + "term": "射击指挥装置", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "火控系统": { + "term": "火控系统", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "雷达测距": { + "term": "雷达测距", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "弹道计算机": { + "term": "弹道计算机", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "炮塔联动系统": { + "term": "炮塔联动系统", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "自主飞行": { + "term": "自主飞行", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "精确制导": { + "term": "精确制导", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "自动化": { + "term": "自动化", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "精确化": { + "term": "精确化", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "多功能": { + "term": "多功能", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "反小艇": { + "term": "反小艇", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "岸防支援": { + "term": "岸防支援", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "轻型多用途武器系统": { + "term": "轻型多用途武器系统", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "宙斯盾驱逐舰": { + "term": "宙斯盾驱逐舰", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "两栖登陆支援舰": { + "term": "两栖登陆支援舰", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "后膛机构": { + "term": "后膛机构", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "滑板系统": { + "term": "滑板系统", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "电动击发装置": { + "term": "电动击发装置", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "吹气系统": { + "term": "吹气系统", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "扬弹机系统": { + "term": "扬弹机系统", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "引信装定": { + "term": "引信装定", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "摆弹臂": { + "term": "摆弹臂", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "输弹器": { + "term": "输弹器", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "智能化集成舰炮系统": { + "term": "智能化集成舰炮系统", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "反导防空": { + "term": "反导防空", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "两栖支援": { + "term": "两栖支援", + "domain": "tech_concept", + "episodes": [ + "ep015_20260428_jianpao_meixue" + ], + "frequency": 1 + }, + "乌克兰": { + "term": "乌克兰", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "自由级中型无人水面舰艇": { + "term": "自由级中型无人水面舰艇", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "自由级": { + "term": "自由级", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "MASC": { + "term": "MASC", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "斧形舰首": { + "term": "斧形舰首", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "集装箱武器系统": { + "term": "集装箱武器系统", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "4级自主航行系统": { + "term": "4级自主航行系统", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "海洋人工智能": { + "term": "海洋人工智能", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "即插即用": { + "term": "即插即用", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "混合舰队": { + "term": "混合舰队", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "狼群战术": { + "term": "狼群战术", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "组合拳": { + "term": "组合拳", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "诱饵无人艇": { + "term": "诱饵无人艇", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "自杀式无人艇": { + "term": "自杀式无人艇", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "美国康拉德造船厂": { + "term": "美国康拉德造船厂", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "荷兰达门公司": { + "term": "荷兰达门公司", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "荷兰达门集团": { + "term": "荷兰达门集团", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "美军国防部": { + "term": "美军国防部", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "美国造船业": { + "term": "美国造船业", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "福特级": { + "term": "福特级", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "福特号": { + "term": "福特号", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "肯尼迪号": { + "term": "肯尼迪号", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "星座级护卫舰": { + "term": "星座级护卫舰", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "特朗普级战列舰": { + "term": "特朗普级战列舰", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "自由级濒海战斗舰": { + "term": "自由级濒海战斗舰", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "虎鲸": { + "term": "虎鲸", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "500吨级大型隐身无人作战舰艇": { + "term": "500吨级大型隐身无人作战舰艇", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "三体船型": { + "term": "三体船型", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "毒蜘蛛Ⅲ型导弹艇": { + "term": "毒蜘蛛Ⅲ型导弹艇", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "伊万诺维茨号": { + "term": "伊万诺维茨号", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "P-270白蛉反舰导弹": { + "term": "P-270白蛉反舰导弹", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "马古拉无人艇": { + "term": "马古拉无人艇", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "马古拉": { + "term": "马古拉", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "蟾蜍级登陆舰": { + "term": "蟾蜍级登陆舰", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "凯撒·库尼科夫号": { + "term": "凯撒·库尼科夫号", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "22160型巡逻舰": { + "term": "22160型巡逻舰", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "谢尔盖·科托夫号": { + "term": "谢尔盖·科托夫号", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "R-73空空导弹": { + "term": "R-73空空导弹", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "米-8直升机": { + "term": "米-8直升机", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "STE公司": { + "term": "STE公司", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "马古拉V5": { + "term": "马古拉V5", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "马古拉V7": { + "term": "马古拉V7", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "星链": { + "term": "星链", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "碳纤维": { + "term": "碳纤维", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "环氧树脂": { + "term": "环氧树脂", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "隐身涂层": { + "term": "隐身涂层", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "油电混合动力": { + "term": "油电混合动力", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "卫星与无线网络联合通信": { + "term": "卫星与无线网络联合通信", + "domain": "tech_concept", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "AIM-9M“响尾蛇”红外制导空空导弹": { + "term": "AIM-9M“响尾蛇”红外制导空空导弹", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "响尾蛇导弹": { + "term": "响尾蛇导弹", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "12.7毫米重机枪": { + "term": "12.7毫米重机枪", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "40毫米自动榴弹发射器": { + "term": "40毫米自动榴弹发射器", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "苏-30SM战斗机": { + "term": "苏-30SM战斗机", + "domain": "weapon", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "辛菲罗波尔号侦察舰": { + "term": "辛菲罗波尔号侦察舰", + "domain": "model", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "乌克兰国防部情报总局": { + "term": "乌克兰国防部情报总局", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "俄军黑海舰队": { + "term": "俄军黑海舰队", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "乌克兰海军": { + "term": "乌克兰海军", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "美国海军模块化攻击水面艇项目": { + "term": "美国海军模块化攻击水面艇项目", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "第十五届中国航空航天博览会": { + "term": "第十五届中国航空航天博览会", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "国防军事频道《军事科技》": { + "term": "国防军事频道《军事科技》", + "domain": "org", + "episodes": [ + "ep016_20260505_wuren_jianting" + ], + "frequency": 1 + }, + "JAS-39": { + "term": "JAS-39", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "F-47": { + "term": "F-47", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "鸭翼": { + "term": "鸭翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "第六代战机": { + "term": "第六代战机", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "下一代空中优势": { + "term": "下一代空中优势", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "F-15": { + "term": "F-15", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "常规布局": { + "term": "常规布局", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "米格-21": { + "term": "米格-21", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "F-4": { + "term": "F-4", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "可变后掠翼": { + "term": "可变后掠翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "通用动力公司": { + "term": "通用动力公司", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "YF-16": { + "term": "YF-16", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "气动布局": { + "term": "气动布局", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "重心": { + "term": "重心", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "气动中心": { + "term": "气动中心", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "水平尾翼": { + "term": "水平尾翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "俯仰力矩": { + "term": "俯仰力矩", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "低压涡流": { + "term": "低压涡流", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "升力系数": { + "term": "升力系数", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "飞控系统": { + "term": "飞控系统", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "静不稳定": { + "term": "静不稳定", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "SR-71": { + "term": "SR-71", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "黑鸟侦察机": { + "term": "黑鸟侦察机", + "domain": "weapon", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "米格-25": { + "term": "米格-25", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "萨博公司": { + "term": "萨博公司", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "萨博-37": { + "term": "萨博-37", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "雷式战斗机": { + "term": "雷式战斗机", + "domain": "weapon", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "固定式鸭翼": { + "term": "固定式鸭翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "后缘襟翼": { + "term": "后缘襟翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "幻影系列战机": { + "term": "幻影系列战机", + "domain": "weapon", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "幼狮战机": { + "term": "幼狮战机", + "domain": "weapon", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "三角翼": { + "term": "三角翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "沃尔沃公司": { + "term": "沃尔沃公司", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "鹰狮": { + "term": "鹰狮", + "domain": "weapon", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "全可动鸭翼": { + "term": "全可动鸭翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "飞控一体化": { + "term": "飞控一体化", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "超音速巡航": { + "term": "超音速巡航", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "米高扬": { + "term": "米高扬", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "苏霍伊": { + "term": "苏霍伊", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "达索": { + "term": "达索", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "翼身融合": { + "term": "翼身融合", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "红旗军演": { + "term": "红旗军演", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "阵风战斗机": { + "term": "阵风战斗机", + "domain": "weapon", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "台风战斗机": { + "term": "台风战斗机", + "domain": "weapon", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "苏-33": { + "term": "苏-33", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "歼-10CE": { + "term": "歼-10CE", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "近距耦合鸭翼": { + "term": "近距耦合鸭翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "远距耦合鸭翼": { + "term": "远距耦合鸭翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "三翼面布局": { + "term": "三翼面布局", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "X-29A": { + "term": "X-29A", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "前掠翼": { + "term": "前掠翼", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "麦道公司": { + "term": "麦道公司", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "敏捷鹰": { + "term": "敏捷鹰", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "F-15 MTD": { + "term": "F-15 MTD", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "X-36": { + "term": "X-36", + "domain": "model", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "矢量控制": { + "term": "矢量控制", + "domain": "tech_concept", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "特朗普": { + "term": "特朗普", + "domain": "person", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "瑞典": { + "term": "瑞典", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "NASA": { + "term": "NASA", + "domain": "org", + "episodes": [ + "ep017_20260512_nixi_yayi" + ], + "frequency": 1 + }, + "加特林机枪": { + "term": "加特林机枪", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "马克沁机枪": { + "term": "马克沁机枪", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "多管旋转机枪": { + "term": "多管旋转机枪", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "自动机枪": { + "term": "自动机枪", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "老式枪械": { + "term": "老式枪械", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "单管自动枪械": { + "term": "单管自动枪械", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "帆布弹链": { + "term": "帆布弹链", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "折叠轻型枪架": { + "term": "折叠轻型枪架", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "铸铁枪架": { + "term": "铸铁枪架", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "漏斗状弹仓": { + "term": "漏斗状弹仓", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "盒状弹仓": { + "term": "盒状弹仓", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "手摇曲柄": { + "term": "手摇曲柄", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "速射调节装置": { + "term": "速射调节装置", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "黄铜蛋壳": { + "term": "黄铜蛋壳", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "纸蛋壳": { + "term": "纸蛋壳", + "domain": "weapon", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "陆军军械局": { + "term": "陆军军械局", + "domain": "unit_designation", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "理查德・乔登・加特林": { + "term": "理查德・乔登・加特林", + "domain": "person", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "海勒姆・史蒂文斯・马克沁": { + "term": "海勒姆・史蒂文斯・马克沁", + "domain": "person", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "爱迪生": { + "term": "爱迪生", + "domain": "person", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "李鸿章": { + "term": "李鸿章", + "domain": "person", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "俄国沙皇": { + "term": "俄国沙皇", + "domain": "person", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "俄国": { + "term": "俄国", + "domain": "org", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "清政府": { + "term": "清政府", + "domain": "org", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "英法联军": { + "term": "英法联军", + "domain": "org", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "军方": { + "term": "军方", + "domain": "org", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "电气行业": { + "term": "电气行业", + "domain": "org", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "火药燃气驱动": { + "term": "火药燃气驱动", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "枪管短后坐自动原理": { + "term": "枪管短后坐自动原理", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "多枪管旋转射击": { + "term": "多枪管旋转射击", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "手摇曲柄模式": { + "term": "手摇曲柄模式", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "重力原理": { + "term": "重力原理", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "单管自动射击体系": { + "term": "单管自动射击体系", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "帆布弹链供弹": { + "term": "帆布弹链供弹", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "侧翼斜射战术": { + "term": "侧翼斜射战术", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "集群冲锋战术": { + "term": "集群冲锋战术", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "全自动射击": { + "term": "全自动射击", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "后坐力": { + "term": "后坐力", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "供弹机构": { + "term": "供弹机构", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "枪管过热": { + "term": "枪管过热", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "炸膛": { + "term": "炸膛", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "卡壳": { + "term": "卡壳", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "哑火": { + "term": "哑火", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "火力网": { + "term": "火力网", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "弹药消耗": { + "term": "弹药消耗", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "军费开支": { + "term": "军费开支", + "domain": "tech_concept", + "episodes": [ + "ep018_20260519_jiqiang_shuangxiong" + ], + "frequency": 1 + }, + "RQ-11": { + "term": "RQ-11", + "domain": "model", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "M4谢尔曼坦克": { + "term": "M4谢尔曼坦克", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "M4“谢尔曼”坦克": { + "term": "M4“谢尔曼”坦克", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "充气的M4谢尔曼坦克": { + "term": "充气的M4谢尔曼坦克", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "音响欺骗车": { + "term": "音响欺骗车", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "RQ-11“渡鸦”无人机": { + "term": "RQ-11“渡鸦”无人机", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "“渡鸦”无人机": { + "term": "“渡鸦”无人机", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "黑黄蜂微型无人机": { + "term": "黑黄蜂微型无人机", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "“黑黄蜂”微型无人机": { + "term": "“黑黄蜂”微型无人机", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "斯普鲁恩斯级驱逐舰": { + "term": "斯普鲁恩斯级驱逐舰", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "MK‑45型127毫米舰炮": { + "term": "MK‑45型127毫米舰炮", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "阿斯洛克反潜导弹": { + "term": "阿斯洛克反潜导弹", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "鱼叉反舰导弹": { + "term": "鱼叉反舰导弹", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "近防武器系统": { + "term": "近防武器系统", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "SH‑60“海鹰”反潜直升机": { + "term": "SH‑60“海鹰”反潜直升机", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "MK‑41垂直发射系统": { + "term": "MK‑41垂直发射系统", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "战斧巡航导弹": { + "term": "战斧巡航导弹", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "“拳师犬”轮式装甲车": { + "term": "“拳师犬”轮式装甲车", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "M1126“斯特赖克”步兵战车": { + "term": "M1126“斯特赖克”步兵战车", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "“斯特赖克”轮式装甲车": { + "term": "“斯特赖克”轮式装甲车", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "斯特赖克步兵战车": { + "term": "斯特赖克步兵战车", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "C-130运输机": { + "term": "C-130运输机", + "domain": "weapon", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "MK‑45": { + "term": "MK‑45", + "domain": "model", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "MK‑41": { + "term": "MK‑41", + "domain": "model", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "SH‑60": { + "term": "SH‑60", + "domain": "model", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "M1126": { + "term": "M1126", + "domain": "model", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "第23特种司令部部队": { + "term": "第23特种司令部部队", + "domain": "unit_designation", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "幽灵部队": { + "term": "幽灵部队", + "domain": "unit_designation", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "斯特赖克旅战斗队": { + "term": "斯特赖克旅战斗队", + "domain": "unit_designation", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "斯特赖克旅": { + "term": "斯特赖克旅", + "domain": "unit_designation", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "战场伪装": { + "term": "战场伪装", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "充气展开": { + "term": "充气展开", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "音响欺骗": { + "term": "音响欺骗", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "无线电伪装": { + "term": "无线电伪装", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "高空侦察": { + "term": "高空侦察", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "快速部署": { + "term": "快速部署", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "空中侦察": { + "term": "空中侦察", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "热成像": { + "term": "热成像", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "双旋翼结构": { + "term": "双旋翼结构", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "四足机器人": { + "term": "四足机器人", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "标准化模块": { + "term": "标准化模块", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "平台化": { + "term": "平台化", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "可扩展性": { + "term": "可扩展性", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "底盘与任务模块": { + "term": "底盘与任务模块", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "数字化指挥系统": { + "term": "数字化指挥系统", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "战略投送": { + "term": "战略投送", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "网络化指挥": { + "term": "网络化指挥", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "实时通信系统": { + "term": "实时通信系统", + "domain": "tech_concept", + "episodes": [ + "ep019_20260602_wanju_weizhuang" + ], + "frequency": 1 + }, + "X-32": { + "term": "X-32", + "domain": "model", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "F-35B": { + "term": "F-35B", + "domain": "model", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "X-35": { + "term": "X-35", + "domain": "model", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "鹞式战斗机": { + "term": "鹞式战斗机", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "AV-8B": { + "term": "AV-8B", + "domain": "model", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "费尔康预警机": { + "term": "费尔康预警机", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "EC-121预警机": { + "term": "EC-121预警机", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "絮弗伦级驱逐舰": { + "term": "絮弗伦级驱逐舰", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "279工程坦克": { + "term": "279工程坦克", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "长滩号巡洋舰": { + "term": "长滩号巡洋舰", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "维克拉玛蒂亚号航空母舰": { + "term": "维克拉玛蒂亚号航空母舰", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "A300-600ST运输机": { + "term": "A300-600ST运输机", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "超级大白鲸": { + "term": "超级大白鲸", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "EC-1电子战飞机": { + "term": "EC-1电子战飞机", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "BV-235验证机": { + "term": "BV-235验证机", + "domain": "model", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "阿帕奇武装直升机": { + "term": "阿帕奇武装直升机", + "domain": "weapon", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "短距起飞和垂直降落": { + "term": "短距起飞和垂直降落", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "发动机矢量喷口": { + "term": "发动机矢量喷口", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "推力矢量分配": { + "term": "推力矢量分配", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "雷达隐身性能": { + "term": "雷达隐身性能", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "相控阵雷达": { + "term": "相控阵雷达", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "机械扫描雷达": { + "term": "机械扫描雷达", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "背负式雷达": { + "term": "背负式雷达", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "共形阵列": { + "term": "共形阵列", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "分布式相控阵雷达系统": { + "term": "分布式相控阵雷达系统", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "四面阵相控阵雷达": { + "term": "四面阵相控阵雷达", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "双面阵雷达": { + "term": "双面阵雷达", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "雷达反射截面积": { + "term": "雷达反射截面积", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 + }, + "模块化建造": { + "term": "模块化建造", + "domain": "tech_concept", + "episodes": [ + "ep020_20260609_yanzhi_wadi" + ], + "frequency": 1 } } \ No newline at end of file diff --git a/doco/src/doco/asr_adapter.py b/doco/src/doco/asr_adapter.py index 15beb04..ffe05ac 100644 --- a/doco/src/doco/asr_adapter.py +++ b/doco/src/doco/asr_adapter.py @@ -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)}] {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 diff --git a/doco/src/doco/cli.py b/doco/src/doco/cli.py index 010c2eb..57252fb 100644 --- a/doco/src/doco/cli.py +++ b/doco/src/doco/cli.py @@ -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() diff --git a/doco/src/doco/fusion_align.py b/doco/src/doco/fusion_align.py index 81b45ef..fb7eef0 100644 --- a/doco/src/doco/fusion_align.py +++ b/doco/src/doco/fusion_align.py @@ -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, diff --git a/doco/src/doco/fusion_review.py b/doco/src/doco/fusion_review.py index 3ad1103..88f0da6 100644 --- a/doco/src/doco/fusion_review.py +++ b/doco/src/doco/fusion_review.py @@ -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时留空) diff --git a/doco/src/doco/templates/stage_a_extract_ocr.py b/doco/src/doco/templates/stage_a_extract_ocr.py new file mode 100644 index 0000000..ab8e3ce --- /dev/null +++ b/doco/src/doco/templates/stage_a_extract_ocr.py @@ -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() \ No newline at end of file diff --git a/doco/src/doco/templates/stage_b_dedup_output.py b/doco/src/doco/templates/stage_b_dedup_output.py new file mode 100644 index 0000000..8b32569 --- /dev/null +++ b/doco/src/doco/templates/stage_b_dedup_output.py @@ -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 幻觉(如 ) + 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() \ No newline at end of file