diff --git a/.gitignore b/.gitignore index c050ac7..6e6752a 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,7 @@ doco/.env # Python build artifacts *.egg-info/ + +# Doco AI 断点缓存(可重生,不入库) +doco/programs/*/.c3_cache/ +doco/programs/*/.c4_cache/ \ No newline at end of file diff --git a/doco/CLAUDE.md b/doco/CLAUDE.md index 9b420b0..f3fd766 100644 --- a/doco/CLAUDE.md +++ b/doco/CLAUDE.md @@ -16,9 +16,9 @@ ## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行) -- **最后更新**:账号A | 2026-06-17 -- **当前状态一句话**:P3-C3 交叉复审完工(融合B稿743行+fusion_review 5条留痕落盘),产品文件已就位,下一步 C4 语义融合→融合A稿 docx。 -- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」 +- **最后更新**:账号A | 2026-06-18(建议接手者开新 session) +- **当前状态一句话**:C4 已收口 + **A稿解析器升级完工**(正则→LLM分段骨架+人工确认+ignore合并,三期 ep002/003/004 骨架已产并人工核验通过:人名零泄露/覆盖完整/隔断与小剧场正确)。**但 ep002/003/004 上游全空**(连抽帧都没做)。下一步=**这三期从 P1 抽帧起走完整流水线 P1→P2→C1→C2→C3→C4 + 串一键脚本**。 +- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」(新 session 必读) --- @@ -77,13 +77,15 @@ ## 3. 当前进度(核心交接区,以最新快照为准) -- **正在做**:C3 已收口,C4 待开工。 -- **卡点 / 待解**:无。 +- **正在做**:批量化首批三期(ep002 潜艇/ep003 小尺寸/ep004 枪王)。已完成 A稿解析器升级 + 三期骨架产出并人工核验通过。**下一步:这三期从 P1 抽帧起跑完整流水线**(上游全空,连抽帧/OCR/ASR 都没做),并把 C1→C2→C3→C4 串成一键脚本。 +- **卡点 / 待解**:无硬卡点。待 Cline 确认 Step5(parse_a_segments 改造)+Step6(compose 前置检查)是否已接好(上一轮其措辞含糊)。OCR 漏字决定不补、seg8/seg27 一逗到底(见关键决策)。 --- ## 4. 已完成(只追加,最新在最上) +- [2026-06-18 |账号A] **A稿解析器升级:正则 → LLM分段骨架 + 人工确认 + ignore合并**。起因:量产三期(ep002/003/004) A稿格式与 ep001 天差地别(冒号头/正文同段、`【固摇轨】`镜头标记酷似段头、正文整段加粗、小剧场对话角色头),原 `parse_a_segments` 纯正则(`^【.+?】$`)必散架。新机制(`fusion_align.py`):`extract_a_paragraphs`(带 bold/align 样式) → `extract_skeleton_llm`(thinking=True/max_tokens足量,**LLM 只判结构不碰正文**,输出 type/role_label/para 区间 JSON) → `validate_skeleton_coverage`(全覆盖硬校验,抓 LLM 数错下标/JSON 截断) → 落 `_a_skeleton.json` + 人类预览表 → `parse_a_segments` 改读骨架按下标原样抽正文(inline 头切冒号、standalone 头取后续段、**ignore 段丢弃**、**相邻同 role_label 仅隔 ignore 的 normal 段合并**)。新命令 `doco skeleton --episode-id --a-script `。**三期骨架已产并人工核验通过**:人名零泄露(刘通/孙逸昊/左鑫/穆佩弦/主持人真名都没混进 role_label)、覆盖完整、ep002 四隔断认对、ep003 九个`(出枪柜)`等归 ignore 且主持人被运镜切断后正确合并(37→20段)、ep004 小剧场卡/斯角色保留。ep001 无骨架时仍走旧正则 fallback,不受影响。 +- [2026-06-18 |账号A] **P3-C4 第一批(保守版)收口**:`fusion_align.py` 把融合B稿743行碎句按 A稿 28 段(含 4 隔断)对齐归拢,套公文 docx 出稿 `融合A稿.docx`。**两条铁律守死**:①汉字零改——正文 100% 来自融合B稿;②标点由 LLM 按语义增删(一逗到底→正常断句)。机制:(a) **AI 对齐层**喂完整 A稿正文、seg_id 单调不减、thinking=True/4096、缓存 `.c4_cache/batch_N.json`;(b) **标点层** `punctuate_segment` 用 STRICT prompt(只许插标点、禁增删改字)+ 硬校验 `strip_punct(出)==strip_punct(入)` 不过即 3 次重试、再不过回退裸文本进 review,缓存 `punct_seg_N.json`,thinking=False/3000。**红线核验脚本逐段 strip_punct 比对 28/28 全过**(diff 全 equal)。隔断按 `^隔断:【(.+?)】` 正则识别、独立成段、line_count=0、位置正确(解说1/主持人2/3/4 后各一)。标点 `punct_ok` 26/28;seg8(专家2)、seg27(专家7) 两段 LLM 顽固插连接词触发回退、已落裸文本+标记"需人工",归第二批。产物:`融合A稿.docx` + `c4_alignment.csv`(32行对齐表,含 start/end_line、confidence、punct_ok、note)。命令 `doco compose --episode-id `。 - [2026-06-17 |账号A] **P3-C3 收口**:`fusion_review.py` 逐行复审 B稿v2 ⊕ ASR(22 批×35行=743行),产 `融合B稿.txt`(行数/时间戳零偏移)+ `fusion_review.csv`(5条真OCR纠错留痕)。关键机制:断点缓存 `.c3_cache/batch_N.json` 可复用;硬校验行数/时间戳/change_type枚举不过不写文件;后处理修正 `final_text==B原文` 被 LLM 误标为 `minor_edit` 的行(19→5)。`--no-ai` 全 unchanged 可验证管道。 - [2026-06-17 |账号A] **P3-C2 收口**:密钥外置三验收点全过(硬编码已删/读 .env/已 gitignore)+ 旧 key 已轮换;`asr_adapter.py` 并入讯飞上传·轮询·解析,复用 P1 `extract_audio` 分离 16kHz/单声道/16bit WAV;新增 `doco asr` 命令读 C1 64条热词。**真转写《现代防空反导大对决》27分钟音频 → 310句带时间戳**,萨德×6/塔米尔/爱国者/箭二箭三/见证者等术语命中良好,raw json 730KB 落盘。(顺带修 `extract_audio` 在 Windows 的 `text=True` 解码崩溃。) - [2026-06-16 |账号A] **P3-C1 收口**:A稿→规则+DeepSeek AI 提取→累积词典110条→热词清洗→本期热词表64条;AI 44 候选已逐个回查 A 稿原文无幻觉,算术对账一致。 @@ -95,7 +97,11 @@ ## 5. 待办(按优先级,做完打勾) - [x] **C3**:B稿v2 ⊕ ASR 交叉复审 → 融合B稿(743行时间戳零偏移) + fusion_review.csv(5条留痕)。 -- [ ] **C4**(下一步)**:融合B稿 + A稿语义对齐 → 融合A稿(公文 docx,GB/T 9704 格式)。按 A稿分段把融合B稿碎句归拢、套 docx 格式,C4 语义融合可开 thinking。 +- [x] **C4(全部收口)**:融合B稿按 A稿28段对齐归拢 → 融合A稿 docx(公文格式)。汉字零改 + LLM 按语义加标点,红线 28/28 段逐字一致。`doco compose --episode-id `。**OCR 漏字决定不补**(讨论见关键决策);seg8/seg27 两段标点接受一逗到底。 +- [x] **A稿解析器升级**:正则 → LLM分段骨架+人工确认+ignore合并;三期骨架已产并核验通过。`doco skeleton`。 +- [ ] **批量化首批三期·跑完整流水线(下一步主线)**:ep002/003/004 上游全空(连抽帧都没做)。需对每期跑 **P1抽帧 → P2 OCR(出B稿v2) → C1术语 → C2 ASR → C3融合(出融合B稿) → C4 compose(出融合A稿)**。重活在 P2 OCR(本地约半小时/期)与 C2 ASR(讯飞额度)。建议从 **ep002(最规整)** 起当烟雾测试,再 ep004(最难)、ep003。**注意:现在还不能直接跑 doco compose——三期都没有融合B稿。** +- [ ] **串一键脚本**:把 C1→C2→C3→C4 串成"一条命令跑一期"(薄壳,各阶段已有断点缓存),验 2-3 期稳定后再谈监控文件夹/绑定界面。 +- [ ] **骨架小瑕疵 backlog(不拦路,后期顺手或跳过)**:ep002 演播室主持人 vs 主持人 是否统一;ep004 小剧场角色编号乱(斯9/斯通纳10、卡8跨场)直接改 JSON 即可,不必重调 LLM;ep003 行内`【固摇轨】`留在参照正文(无害,不进输出)。 - [x] **C2** 讯飞 ASR 适配层(密钥外置 + asr_adapter 并入 + `doco asr` 命令 + 真转写310句) - [x] P3-C1 术语提取 + 词典 + 热词表 - [x] P2 本地 OCR + B稿v2 @@ -105,6 +111,15 @@ ## 6. 关键决策(为什么这么做 — 跨 session 最易丢,别推翻) +- [2026-06-18] **A稿段头识别改用「LLM 判结构 + 人工核骨架」,不用死正则。** 编导写法千变万化(方括号/冒号/前缀杂质`Xr`/`【固摇轨】`镜头标记/小剧场对话头),正则分不清`【固】`(镜头)与`【主持人1】`(段头)——这是语义判断,交 LLM。**铁律:LLM 只输出结构(type/role_label/para 区间),绝不复述正文;正文一律代码按下标从 docx 原样抽**(护住"汉字零改"红线)。配两道闸:① `validate_skeleton_coverage` 全覆盖硬校验(title 后每段恰好被覆盖一次,自动抓 LLM 数错下标/JSON 截断);② 人类预览表给制片人肉眼核(重点抓真人姓名泄露/ignore 漏多/隔断认全)。`doco compose` 无骨架即报错(ep001 例外走正则)。 +- [2026-06-18] **role_label 只标"段落类型",严禁带任何真人姓名/配音员/编导署名。** 解说词只标【解说N】,不标谁配音(踩过 Cline 把"解说:刘通"标错)。但**小剧场里被演绎的历史人物/角色保留**(卡=卡拉什尼科夫、斯=斯通纳、小剧场N-主持人)——那是剧情内容不是配音员实名。有借鉴价值的修饰也留(三维动画解说/演播室主持人),只清杂质(`Xr`/编号/空格)。 +- [2026-06-18] **解析层合并"被 ignore 隔断的同角色段"。** 编导用`(出枪柜)(换枪)`镜头切换把一个主持人的话从中间切断(ep003),骨架会切成两个同名`【主持人N】`。parse 后处理:相邻 normal 段 role_label 全等、中间仅隔 ignore,则合并(正文顺序拼接、ignore 内容丢弃),避免输出里冒重复段头。合并条件要严,ep002/004 无 ignore 不受影响。 +- [2026-06-18] **OCR 漏字决定不补(通哥拍板),不让 LLM 补词。** 三条理由:① 漏字的根在**上游抽帧/OCR**——缺的"那么/此外/一方面"都是两三字单独一屏、停留短被整屏漏掉,是真实信息丢失,文稿层无法"还原";② **LLM 补词=猜词=破"汉字零改"红线**,对专业性强、最贴近播出版的稿子风险不可接受;③ 口子守不住——seg8/seg27 标点回退正是 LLM 一有机会就想塞连接词被硬校验拦下,证明给余地它就动手。**真要科学补,唯一正路是 ASR 回捞**(这些连接词主持人/解说真说了,大概率在 ASR 310 句里),而非 LLM 猜,但属 C3 级返工、本期不值;真做时先验 ASR 有无再说。**治本=提醒编导/责编别让两三字单独一屏**;本期连接词缺了能读懂,接受。 +- [2026-06-18] **`融合A稿_批改.docx`(通哥手批)只当"看缺了什么"的参照,绝不当"让 docx 去贴它"的对齐目标。** 手批稿可能含其他润色,一旦去对齐就等于润色,违背"只守零改汉字+加标点、不润色其他部分"。 +- [2026-06-18] **seg8(专家2 行169-216)/seg27(专家7 行584-608) 两段标点接受一逗到底(通哥选 B)。** 这是标点没点好,不是缺字,不碰汉字;若日后要美化由人工手点。 +- [2026-06-18] **C4 红线由"汉字润色"转为"汉字零改、标点交 LLM 按语义增删"**(通哥拍板:"转变后的红线更精准、更科学")。理由:润色会让关键军事表述有不准确风险;保守做法正文 100% 来自融合B稿,只在字符间插标点。**硬校验 `strip_punct(LLM输出)==strip_punct(输入)`** 是这条红线的机器守门员(strip_punct 去掉所有 unicode 'P' 类标点+空白),不过即重试、再不过回退裸文本。**第一批先做保守版,润色/漏字回填留第二批。** +- [2026-06-18] **thinking 要按任务分用,不是越开越好——这是 C1"thinking不关content为空"的孪生坑。** 两个独立故障:(a) **开 thinking 时 max_tokens 必须给足**:thinking 与正文 `content` 共享同一配额,配额不够 thinking 吃光 → 正文空输出(C4 标点层 @2000 实测吐 0 字);(b) **浅层活(标点/抽取)开 thinking 会又慢又被推理空转吃光**:标点这种活开 thinking 单段 10+ 分钟还超时,关掉 thinking/3000 单段 6.9s 通过。**规则:语义对齐/融合这类深推理开 thinking 且给足 token(C4 对齐 4096);标点、抽取这类浅层确定性活必须关 thinking。** +- [2026-06-18] **A稿分段编号有重复(解说2/解说10 各出现两次)——任何按段头文本做 key 的字典都会把两次出现合并,必须按"文档出现顺序"切块/取块。** C4 红线核验脚本初版犯此错(解说2 行61-77 与行151-168 被并成一段、误报破线),改用 occurrence 队列后 28/28 全过。对齐表 c4_alignment.csv 用 seg_id 区分,不用段头文本。 - [2026-06-17] **C2 音频分离复用 P1 `video_split.extract_audio()`,不另写 ffmpeg。** WAV 落 `programs//audio_16k.wav`,已存在则复用(避免 1GB 视频重复解码)。实测产物 `pcm_s16le/16000Hz/1ch/16bit/1620s`,符合讯飞硬要求。 - [2026-06-17] **`doco asr` 不传 `--output-dir` 会落到当前工作目录的 `programs/`**(命令在 repo 根 `E:\tps-dashboard` 跑时尤其坑),与 doco 产物分家。**命令一律用绝对 `--input-video` + 绝对 `--output-dir`。** - [2026-06-17] **ASR 输出是口语形态,型号不规范属正常**(如「箭二箭三」非「箭-3」、「高超音速/高超声速」混用)。按设计稿口语信 ASR、型号规范信 A/B 稿,**这些归一交给 C3,别回头改 C2**。 @@ -125,22 +140,24 @@ ## 7. ⏩ 交接备注(写给下一个接手的 session) -> 下次开工读完这段应能 0 摩擦续上。接手后可清空重写。 +> 下次开工读完这段应能 0 摩擦续上。接手后可清空重写。**(本段已为「ep002/003/004 批量化」新 session 重写,2026-06-18)** -- **第一句话该干的**:C3 已完成,产物就位(`融合B稿.txt` 743行 + `fusion_review.csv` 5条留痕,都在 `doco/programs/ep001_20260612_fangkong_fandao/`)。直接开 C4。 -- **C4 目标**:融合B稿(743行碎句)+ A稿 docx 语义对齐 → **融合A稿 docx**(公文格式,GB/T 9704)。按 A稿分段(【导视】【主持人N】【解说N】【专家N】【隔断】)把融合B稿碎句归拢到对应段落,套 docx 模板格式输出。C4 语义融合可开 thinking(非抽取类任务,质量优先)。一致性约束:融合A稿由融合B稿生成,不是拿 A 稿改。 -- **C4 输入**(均就位): - - `programs/ep001_20260612_fangkong_fandao/融合B稿.txt`(743行,`[XmYs] 文本`) - - `programs/ep001_20260612_fangkong_fandao/现代防空反导大对决A稿.docx`(A稿,python-docx 可读) - - `programs/ep001_20260612_fangkong_fandao/fusion_review.csv`(5条改动留痕,可选参考) -- **C3 重跑命令**:`doco fuse --episode-id ep001_20260612_fangkong_fandao`(`--no-ai` 全 unchanged 验证管道,缓存 `.c3_cache/` 复用无需重跑 AI) +- **大局**:首期 `ep001_20260612_fangkong_fandao` 全流程 C1→C4 已跑通、红线核验 28/28 逐字一致、`融合A稿.docx` 就位。代码管道(C1 terms / C2 asr / C3 fuse / C4 compose + 新增 skeleton)全部可用。现在进入**量产首批三期**。 +- **这三期现状(关键!)**:`ep002_20260127_qianting_fangsheng`(潜艇仿生,中等)/`ep003_20260331_qiangxie_chicun`(小尺寸大讲究,最简单)/`ep004_20260526_qiangwang_duijue`(枪王对决,最难)。**只做了 A稿分段骨架(skeleton),上游一片空白**——没抽帧、没 OC、没 B稿v2、没 ASR、没融合B稿。每期目录里现有:原始 A稿 docx + mp4(约 1GB,规格与 ep001 同 = 黑底白字字幕视频,OCR/ASR 共用)+ `_a_skeleton.json` + `_a_skeleton_preview.txt`。 +- **骨架已人工核验通过**(通哥过目):人名零泄露、覆盖完整、隔断/小剧场/ignore 分类正确。ep003 还修过两个真缺陷(主持人被 ignore 切成重复同名段→已合并 37→20;预览表"前20字"显示错位→已修)。**所以 skeleton 阶段对三期都算完成,别重跑。** +- **第一句话该干的**:**给这三期补上游流水线**。每期顺序:P1 抽帧 → P2 本地 OCR(DeepSeek-OCR/Ollama,约 30min/期,放独立终端)→ C1 术语热词 → C2 讯飞 ASR(花额度)→ C3 fuse → C4 compose。**`doco compose` 现在跑不了这三期**(没有融合B稿,C3 的产物),别误试。 +- **推荐节奏**:先拿**最简单的 ep003** 当冒烟测试走完整条链验证管道,再 ep002,最后**最难的 ep004**(小剧场 58 段、历史人物对话密)。 +- **要串的一键脚本**:把 `doco terms → asr → fuse → compose` 串成"一条命令跑完一期"(C1→C4 链;P1/P2 OCR 可先独立跑因为重且慢)。**批量化形态(监控文件夹 vs 绑定界面)通哥还没拍板**,先把半自动脚本串顺、验 2-3 期稳定,再回来定要不要做花活。 +- **量产坑(务必记住)**: + 1. 每期热词/术语不同,**C1 每期重跑**;A稿分段编号各有各的重复/空格脏,**按出现顺序走、别假设单调唯一**,任何按段头文本做 key 的字典都会把重复段头合并(核验脚本初版踩过,用 occurrence 队列)。 + 2. **ASR 花讯飞额度、embedding 花 MiniMax 额度**,批量前估下成本;长转写/OCR 放独立终端别让 Cline 盯。 + 3. **OCR 漏字本期不补**(通哥拍板,见关键决策)——根在抽帧/OCR 漏掉两三字单屏,LLM 补词=破红线;治本是提醒编导别让两三字单独一屏。 + 4. Cline 会偷换模型/术语源、误判字数、口头猜错根因——**自报结果一律对源数据核验**。 + 5. **thinking 分任务用**:语义对齐/骨架开、标点/抽取关(见关键决策 2026-06-18)。 +- **代码待落实的两处(让 Cline 确认已接线,没接就先接)**:① `parse_a_segments()` 改造(读 skeleton.json 抽正文、丢 ignore、合并 ignore 切断的同角色段)是否真生效;② `run_compose` 的"compose 前置检查"(无融合B稿/无骨架要报清错而非跑崩)是否真加。 +- **骨架小瑕疵 backlog(非阻塞,可不动)**:ep002「演播室主持人」与「主持人」可统一称呼;ep004 小剧场角色编号(卡8/斯9/斯通纳10 等)可清成连续(直接改 JSON,**别重跑 LLM**);ep003 参考正文里内联的 `【固摇轨】` 镜头标记无害。 +- **字体坑(C4 出稿)**:大标题方正小标宋_GBK(商业字体),出稿前确认制片机/编导机已装,否则脚本回退(标宋→宋体加粗)。 - 通哥连轴转了几轮,回复要紧凑。 -- **已知坑(务必记住)**: - 1. **A稿真实约 6500–7300 汉字**(栏目常态 6000–8000),**不是 2.2 万**——那是用 `wc -m` 把 UTF-8 字节当字数的乌龙(一个汉字 3 字节)。docx 无文本框无表格,`python-docx` 提取的 ~7.5k 字符就是全文。 - 2. **A稿分段编号有坑**:解说2/解说10 各出现两次、主持人4/5 带空格、专家1–8 齐全。分段对齐**别假设编号单调或唯一,按出现顺序走**(顾问曾误说"跳6号",作废)。 - 3. Cline 会偷换模型/术语源、误判字数——**自报结果一律对源数据核验**。 -- **C1 已知小尾巴(评估后不修,无害)**:热词条数报告 64、顾问数 65,差 1,远在 200 上限内;几对技术前缀未合并(光学红外/红外、诱饵弹前缀等),再收敛有过度合并风险,故止步。 -- **C4 字体坑**:大标题用方正小标宋(商业字体),出稿前确认制片机/编导机已装,否则脚本里配回退(标宋→宋体加粗)。 --- diff --git a/doco/programs/ep001_20260612_fangkong_fandao/asr_v2_timed.txt b/doco/programs/ep001_20260612_fangkong_fandao/asr_v2_timed.txt index 2225cd6..e4383f7 100644 --- a/doco/programs/ep001_20260612_fangkong_fandao/asr_v2_timed.txt +++ b/doco/programs/ep001_20260612_fangkong_fandao/asr_v2_timed.txt @@ -33,8 +33,8 @@ [3m30s] 太空中以子弹击落子弹, [3m33s] 它标志着当今世界军事科技的极致成就,也是战略防空领域真正的制高点。 [3m47s] 一枚中远程弹道导弹的飞行分为上升段, -[3m51s] 终端、 -[3m52s] 末端 +[3m51s] 中段、 +[3m52s] 末段 [3m53s] 三者在时空速度上的巨大差异,直接决定了拦截难度的天壤之别。 [4m0s] 上升段仅持续3~5分钟,时间窗口稍纵即逝, [4m5s] 且需在敌国领土拦截, diff --git a/doco/programs/ep001_20260612_fangkong_fandao/c4_alignment.csv b/doco/programs/ep001_20260612_fangkong_fandao/c4_alignment.csv new file mode 100644 index 0000000..475ebd3 --- /dev/null +++ b/doco/programs/ep001_20260612_fangkong_fandao/c4_alignment.csv @@ -0,0 +1,33 @@ +seg_id,header,start_line,end_line,line_count,min_confidence,punct_ok,note +0,"【导视】",1,17,17,1.0000,True,"" +1,"【主持人1】",18,40,23,1.0000,True,"" +2,"【解说1】",41,60,20,1.0000,True,"" +3,"远程防空:弹道导弹与反导系统的巅峰对决",,,0,0.0000,,"隔断" +4,"【解说2】",61,77,17,1.0000,True,"" +5,"【动画1】",78,111,34,1.0000,True,"" +6,"【专家1】",112,150,39,1.0000,True,"" +7,"【解说2】",151,168,18,0.9500,True,"" +8,"【专家2】",169,216,48,1.0000,False,"标点恢复失败已回退,需人工" +9,"【解说3】",217,225,9,1.0000,True,"" +10,"【主持人2】",226,240,15,1.0000,True,"" +11,"中程防空:复合威胁下的区域空天防御攻坚战",,,0,0.0000,,"隔断" +12,"【解说4】",241,270,30,1.0000,True,"" +13,"【专家3】",271,300,30,1.0000,True,"" +14,"【解说5】",301,327,27,1.0000,True,"" +15,"【专家4】",328,353,26,1.0000,True,"" +16,"【解说6】",354,360,7,1.0000,True,"" +17,"【主持人3】",361,376,16,1.0000,True,"" +18,"近程防空:低空突防与最后防线的生死较量",,,0,0.0000,,"隔断" +19,"【解说7】",377,403,27,1.0000,True,"" +20,"【专家5】",404,442,39,1.0000,True,"" +21,"【解说8】",443,470,28,1.0000,True,"" +22,"【专家6】",471,516,46,1.0000,True,"" +23,"【解说9】",517,539,23,1.0000,True,"" +24,"【主持人 4】",540,558,19,1.0000,True,"" +25,"体系防空:全域防御与精准拦截的新困境",,,0,0.0000,,"隔断" +26,"【解说10】",559,583,25,0.9900,True,"" +27,"【专家7】",584,608,25,0.9900,False,"标点恢复失败已回退,需人工" +28,"【解说10】",609,668,60,1.0000,True,"" +29,"【专家8】",669,696,28,1.0000,True,"" +30,"【解说11】",697,713,17,1.0000,True,"" +31,"【主持人 5】",714,743,30,1.0000,True,"" diff --git a/doco/programs/ep001_20260612_fangkong_fandao/fusion_review.csv b/doco/programs/ep001_20260612_fangkong_fandao/fusion_review.csv new file mode 100644 index 0000000..2194362 --- /dev/null +++ b/doco/programs/ep001_20260612_fangkong_fandao/fusion_review.csv @@ -0,0 +1,6 @@ +line_no,timestamp,b_original,asr_context,final_text,change_type,confidence,reason +69,3m21s,作为现代战略空军的最高层级,弹道导弹与反导系统之间的对决,无疑是最具战略威慑力且最检验技术水平的顶峰交锋。 其中中段反导作为现代战略防空的最高层级, 更是左右战场局势的核心要素。 这项技术常被形容为 太空中以子弹击落子弹,,作为现代战略防空的最高层级,minor_edit,0.99,B稿为'作为现代战略空军的最高层级',ASR为'作为现代战略防空的最高层级'。根据上下文(反导系统、中段反导),'防空'比'空军'更准确,应为B稿OCR识别错误,以ASR修正。 +90,4m12s,未段导弹,受部署限制难以实施拦截,成功率极低。 末段导弹俯冲至几十公里至几公里高空, 速度达马赫数10~20, 仅留数秒至十几秒的反应时间,,末段导弹,editor_typo,1.0,B稿'未段导弹'为明显错字,ASR为'末段导弹',且上下文均为'末段',予以修正。 +426,15m40s,有的远程防空作战体系,它的搜索系统是比较简单的,视野不够宽广,也无法针对远距离的目标进行识别,所以呢有的近程防空作战体系它本质上来讲是近视眼, 而且呢还涉及到一些地形起伏的问题。 如果对方利用这种地形起伏,啊那么去进行超低空的突防,那么在相关区域内部署的一些传感器,比如说低空搜索雷达或者是光学红外传感器,可能没有办法及时有效的识别目标。,有的近程防空作战体系,minor_edit,0.95,B稿为'远程',ASR及上下文均为'近程',属于OCR错字,根据ASR修正。 +652,23m18s,作为远程防空核心弹药,美以用于对抗的防空武器却如同天价奢侈品。 以色列铁穹系统的拦截弹作为近程防空核心弹药, 每枚成本达5万到10万美元, 这意味着即便成功拦截防御方的经济损失,也远超进攻方。 更甚者,美国爱国者三拦截弹作为中程防空主力,,作为近程防空核心弹药,minor_edit,0.99,B稿OCR误将“近程”识别为“远程”,根据ASR上下文及铁穹系统实际定位修正。 +719,25m30s,高低坚固的立体防御天网,完整链条, 构建了远近交错高低兼顾的立体防御天网。 然而 高超声速武器如闪电般突破, 无人机群的压制以及饱和式攻击的浪潮不断袭来, 让传统防空体系面临着严峻的考验,,高低兼顾的立体防御天网,editor_typo,0.99,B稿“高低坚固”应为“高低兼顾”,ASR为“高低兼顾”,语义通顺,修正OCR错字。 diff --git a/doco/programs/ep001_20260612_fangkong_fandao/融合A稿.docx b/doco/programs/ep001_20260612_fangkong_fandao/融合A稿.docx new file mode 100644 index 0000000..67aeaaf Binary files /dev/null and b/doco/programs/ep001_20260612_fangkong_fandao/融合A稿.docx differ diff --git a/doco/programs/ep001_20260612_fangkong_fandao/融合B稿.txt b/doco/programs/ep001_20260612_fangkong_fandao/融合B稿.txt new file mode 100644 index 0000000..a11d2d9 --- /dev/null +++ b/doco/programs/ep001_20260612_fangkong_fandao/融合B稿.txt @@ -0,0 +1,743 @@ +[0m8s] 导弹呼啸而过 +[0m9s] 战机低空穿梭 +[0m11s] 无人机如蜂群般袭来 +[0m14s] 这些空中威胁 +[0m15s] 正不断挑战防御体系的极限 +[0m18s] 它们是如何被精确拦截的 +[0m21s] 究竟是什么力量 +[0m22s] 能在遥远距离精准打击 +[0m24s] 在近在咫尺时 +[0m26s] 完成致命一击呢 +[0m28s] 本期《军事科技》 +[0m30s] 将带您逐步解析 +[0m31s] 现代防空网络的复杂机制 +[0m34s] 亲眼见证 +[0m35s] 科技与智慧打造的空中屏障 +[0m38s] 揭秘现代防空体系 +[0m40s] 御敌于空的终极密码 +[1m15s] 各位观众你们好 +[1m16s] 欢迎收看《军事科技》 +[1m17s] 我是主持人蓝皓 +[1m20s] 从第二次世界大战时期的 +[1m21s] 战机轰炸 +[1m22s] 到冷战时期的导弹威慑 +[1m24s] 再到如今的无人机 +[1m26s] 高超声速武器的崛起 +[1m28s] 空中威胁的形态 +[1m29s] 不断地迭代 +[1m31s] 防御体系也在随之进化 +[1m34s] 而如今的现代化防空系统 +[1m36s] 早已不再是 +[1m37s] 单一武器的简单对抗了 +[1m39s] 而是构建了 +[1m40s] 以远 中 近衔接 +[1m42s] 高中 低覆盖为一体的 +[1m44s] 立体化防御系统 +[1m46s] 仿佛一块层层镶嵌的空中盾牌 +[1m49s] 从千里之外的高空 +[1m51s] 到咫尺之间的低空 +[1m53s] 形成了无死角的防护 +[1m56s] 在与新型空中威胁持续对抗当中 +[1m59s] 上演着一场场激烈的 +[2m1s] 攻与防的较量 +[2m19s] 2026年2月28日 +[2m21s] 美以两国协同向伊朗发起军事打击 +[2m24s] 中东地区顿时战火纷飞 +[2m27s] 硝烟弥漫 +[2m29s] 随着冲突不断加剧 +[2m30s] 各方频繁动用弹道导弹 +[2m32s] 巡航导弹 +[2m34s] 高超声速武器 +[2m35s] 以及自杀式无人机等先进装备 +[2m38s] 展开密集的高强度空袭 +[2m41s] 波斯湾上空 +[2m42s] 随即呈现出一场 +[2m44s] 攻防交织的终极较量 +[2m46s] 涉及突破与拦截 +[2m48s] 毁灭与保卫 +[2m50s] 这场关乎生死存亡 +[2m51s] 与战局走向的空中攻防战 +[2m54s] 彻底爆发 +[3m4s] 在涉及远 中 近程 +[3m7s] 和高 中 低空的 +[3m8s] 全方位空战对抗中 +[3m11s] 弹道导弹 +[3m12s] 与反导系统之间的对决 +[3m14s] 无疑是最具战略威慑力 +[3m16s] 且最检验技术水平的顶峰交锋 +[3m20s] 中段反导 +[3m21s] 作为现代战略防空的最高层级 +[3m24s] 更是左右战场局势的核心要素 +[3m28s] 这项技术 +[3m29s] 常被形容为 +[3m30s] 太空中以子弹击落子弹 +[3m34s] 它标志着 +[3m35s] 当今世界军事科技的极致成就 +[3m37s] 也是战略防空领域 +[3m39s] 真正的制高点 +[3m48s] 一枚中远程弹道导弹的飞行 +[3m50s] 分为上升段 +[3m52s] 中段 +[3m53s] 末段 +[3m54s] 三者在时空 速度上的巨大差异 +[3m57s] 直接决定了 +[3m58s] 拦截难度的天壤之别 +[4m0s] 上升段仅持续3至5分钟 +[4m3s] 时间窗口稍纵即逝 +[4m6s] 且需在敌国领土拦截 +[4m8s] 受部署限制难以实施 +[4m10s] 拦截成功率极低 +[4m12s] 末段导弹 +[4m13s] 俯冲至几十公里至几公里高空 +[4m16s] 速度达马赫数10至20 +[4m19s] 仅留数秒至十几秒的反应时间 +[4m22s] 同时面临机动变轨 +[4m24s] 多弹头诱饵及低空探测盲区 +[4m27s] 拦截风险高且成功率有限 +[4m31s] 唯有中段 +[4m32s] 导弹在100公里以上大气层外 +[4m34s] 无动力滑行 +[4m36s] 飞行时间长达10至20分钟 +[4m39s] 轨迹由万有引力决定 +[4m41s] 极度稳定 +[4m42s] 并未释放弹头与诱饵 +[4m44s] 反导系统有足够的时间预警 +[4m47s] 测算轨迹并拦截 +[4m49s] 采用动能撞击 +[4m51s] 可彻底消除地面威胁 +[4m53s] 是现代战略防空最理想 +[4m55s] 效果最佳的黄金拦截期 +[4m59s] 也是各国反导技术研发的 +[5m1s] 核心方向 +[5m9s] 虽然说 +[5m10s] 中段反导拦截 +[5m11s] 在整个反导作战的流程当中 +[5m14s] 它是比较容易实现的 +[5m16s] 或者说 +[5m17s] 可以针对对方来袭的导弹 +[5m20s] 进行精准的摧毁 +[5m21s] 但是最大的问题 +[5m22s] 就是你的预警系统 +[5m24s] 需要看得远 +[5m25s] 反导拦截弹 +[5m26s] 需要飞得快 +[5m27s] 打得高 打得远 +[5m28s] 所以对于技术方面的要求 +[5m29s] 是比较高的 +[5m30s] 这就要求 +[5m31s] 大型的相控阵预警雷达 +[5m33s] 能够在极远的距离上 +[5m35s] 针对外大气层飞行的 +[5m38s] 这种小型高速移动的目标 +[5m40s] 能够进行精准的锁定 +[5m42s] 而且要对它进行持续的跟踪 +[5m45s] 还要有相关的 +[5m46s] 这样的一个计算系统 +[5m47s] 针对它的飞行轨迹进行计算 +[5m49s] 这样的话 +[5m50s] 才能够有效地引导反导拦截弹 +[5m53s] 在外大气层 +[5m54s] 对它进行精准的这样的一个截杀 +[5m57s] 而且反导拦截弹的性能 +[5m58s] 要求非常高 +[6m0s] 不仅要飞得高 +[6m1s] 飞得远 飞得快 +[6m2s] 而且 +[6m3s] 甚至还要配备有动能的战斗部 +[6m5s] 要针对处于飞行状态的 +[6m7s] 弹道导弹 +[6m8s] 进行精准的撞击 +[6m10s] 确保它是被彻底摧毁 +[6m15s] 3月5日 +[6m17s] 伊朗伊斯兰革命卫队发布声明称 +[6m20s] 在“真实承诺-4”行动的第17轮中 +[6m23s] 伊朗动用高超声速导弹 +[6m25s] 和攻击无人机 +[6m27s] 成功突破美国“萨德”防御系统 +[6m30s] 打击了以色列国防部大楼 +[6m32s] 与本-古里安国际机场 +[6m34s] 并摧毁了7套雷达系统 +[6m36s] 及核心配套设备 +[6m39s] 同期 +[6m40s] 美国内部评估报告透露 +[6m42s] 作为中东关键中段反导装备的 +[6m45s] 以色列“箭式”系统 +[6m46s] 在拦截伊朗中程弹道导弹时 +[6m49s] 显现重大短板 +[6m51s] 实际拦截率 +[6m52s] 远未达到原有设计标准 +[6m57s] 那么在以色列的国土范围内 +[6m59s] 有“箭-2” “箭-3” 反导系统 +[7m1s] 也有美国部署的“萨德”反导系统 +[7m4s] 但是我们看 +[7m5s] 仍然有数量相当多的伊朗弹道导弹 +[7m8s] 成功突防 +[7m9s] 针对以色列区域内的目标 +[7m11s] 进行了毁伤 +[7m13s] 为什么说“箭-2”“箭-3” +[7m14s] 以及“萨德”系统 +[7m15s] 它联起手来 +[7m16s] 也没有办法针对伊朗的弹道导弹 +[7m19s] 进行这种百分之百的防范和拦截 +[7m21s] 我认为 +[7m22s] 首先是伊朗弹道导弹的技术 +[7m25s] 有所提升 +[7m26s] 部分伊朗的弹道导弹 +[7m27s] 采用了这种高超音速的战斗部 +[7m30s] 在飞行的末段 +[7m31s] 会有突然的加速 +[7m32s] 或者说有急剧的 +[7m34s] 飞行轨迹的变化 +[7m35s] 这样的话 +[7m36s] “萨德”系统 +[7m37s] 和“箭-2”“箭-3”反导系统 +[7m38s] 很难对它进行精准的拦截 +[7m40s] 此外 +[7m41s] 伊朗的一些弹道导弹 +[7m43s] 比如说像“霍拉姆沙赫尔-4” +[7m45s] 那么它是一种重型的 +[7m46s] 这样的一个弹头的设计理念 +[7m49s] 1.5吨到2吨的战斗部 +[7m51s] 它可以采用这种集束弹头 +[7m53s] 这样的一个模式 +[7m55s] 飞到了目标区上空之后 +[7m56s] 打出了一个满天星的效果 +[7m58s] 数十枚子弹头飞向目标区域 +[8m1s] 所有的防空反导系统 +[8m3s] 都没有办法对它进行拦截 +[8m5s] 那么还有另外一种情况 +[8m7s] 美国的反导作战系统 +[8m8s] 和以色列的“箭-2” “箭-3” 反导系统 +[8m10s] 它没有办法进行联网作战 +[8m12s] 是各打各的 +[8m13s] 这样的话 +[8m14s] 在相关的区域内 +[8m15s] 没有办法进行目标信息的共享 +[8m17s] 那么也无法形成合力 +[8m21s] 2026年3月15日 +[8m23s] 伊朗伊斯兰革命卫队 +[8m25s] 在“真实承诺-4”军事行动中 +[8m28s] 首次实战部署了泥石导弹 +[8m31s] 与此同时 +[8m32s] 美国从韩国 +[8m34s] 紧急调遣了萨德反导系统 +[8m36s] 以加强该地区的防御 +[8m38s] 应对伊朗导弹带来的持续威胁 +[8m44s] 若将远程反导 +[8m45s] 视作千里点穴的区域防空手段 +[8m48s] 中程防空 +[8m50s] 便是中空守护网 +[8m52s] 承担着衔接 +[8m53s] 远近防御的关键使命 +[8m56s] 它覆盖数十公里 +[8m57s] 乃至一百多公里的中高空区域 +[9m0s] 作为防空体系中的 +[9m2s] 中坚力量 +[9m4s] 承担起区域防空重任 +[9m6s] 护卫城市 +[9m7s] 军事要地等高价值目标 +[9m9s] 成为整个防空网络中 +[9m11s] 承上启下的核心组成部分 +[9m22s] 中程防空 +[9m24s] 作为现代防空作战系统的 +[9m25s] 关键环节 +[9m27s] 其防御半径 +[9m28s] 一般在50到200公里之间 +[9m31s] 拦截高度 +[9m32s] 重点覆盖10至20公里的中高空域 +[9m36s] 这片空域 +[9m37s] 正是现代战争中 +[9m39s] 空中威胁密集活动的核心地带 +[9m42s] 敌方战机 +[9m43s] 若要突破外层防御 +[9m45s] 打击纵深关键目标 +[9m47s] 必须穿越此区域 +[9m49s] 巡航导弹 +[9m50s] 虽常利用低空突防 +[9m52s] 但多数情况下 +[9m54s] 仍需在中高空调整姿态 +[9m56s] 寻找防御薄弱点 +[9m58s] 而中近程导弹在俯冲末段 +[10m1s] 同样会经过这一高度区间 +[10m4s] 其高速特性 +[10m5s] 要求中程防空系统及早介入 +[10m8s] 精确拦截 +[10m9s] 可以说 +[10m10s] 这片中高空域 +[10m11s] 是空中威胁 +[10m13s] 渗向核心目标的必经通道 +[10m16s] 也是防空体系中 +[10m17s] 承前启后的关键拦截区 +[10m26s] 在现代化的防空作战行动当中 +[10m29s] 中程防空的难度是非常大的 +[10m31s] 因为很多对方的 +[10m34s] 这种空中的来袭目标 +[10m36s] 其实它都是在 +[10m37s] 中程防空的任务范围内 +[10m39s] 目标的属性是非常多的 +[10m41s] 此外 +[10m42s] 在复杂的电磁环境的 +[10m44s] 这样的一个状态下 +[10m45s] 中程的防空体系 +[10m47s] 也可能会出现失灵的情况 +[10m49s] 因为 +[10m50s] 对方可能会出动电子战飞机 +[10m52s] 针对你的防空作战体系 +[10m53s] 进行压制 +[10m54s] 此外 +[10m55s] 有的时候面对各种各样的目标 +[10m57s] 数量众多的目标 +[10m59s] 中程防空系统 +[11m0s] 它的这样的一个 +[11m2s] 火力的密集程度 +[11m3s] 可能会出现不足的情况 +[11m5s] 比如说一部发射器上 +[11m7s] 它只有数量有限的中程防空导弹 +[11m9s] 打完了之后 +[11m10s] 对方的目标连续地持续地袭来 +[11m14s] 那中程防空体系 +[11m15s] 可能也会面临弹药不足 +[11m17s] 火力不足的情况 +[11m21s] 在近期 +[11m22s] 美以联合对伊朗的军事行动中 +[11m25s] 中程导弹防御系统 +[11m27s] 成为防空体系中的 +[11m28s] 核心作战力量 +[11m30s] 也是双方攻防较量 +[11m32s] 最为激烈的领域 +[11m34s] 为巩固中程防御屏障 +[11m36s] 美国运用 +[11m37s] “爱国者”防空反导系统 +[11m39s] 结合天基预警卫星 +[11m41s] 陆基远程雷达 +[11m43s] 及集成化指挥控制中心 +[11m45s] 打造了能够覆盖30公里 +[11m48s] 至150公里空域的拦截体系 +[11m50s] 着重防护其军事基地 +[11m52s] 与国防工业区域 +[11m55s] 面对伊朗高超音速武器 +[11m58s] 配合无人机集群的 +[11m59s] 非对称组合式打击 +[12m2s] 此前被美军公开宣称 +[12m3s] 拦截成功率 +[12m5s] 可达80%到97%的“爱国者”系统 +[12m9s] 实际拦截率 +[12m10s] 已跌至5%以下 +[12m13s] 系统还频频出现拦截假目标 +[12m16s] 漏过真实弹头的失误 +[12m21s] “爱国者”曾在海湾战争期间 +[12m24s] 一战成名 +[12m25s] 那当时 +[12m26s] 美军“爱国者”系统 +[12m27s] 它所拦截的导弹 +[12m29s] 都是伊拉克萨达姆政权军队 +[12m32s] 使用的比较老旧的 +[12m34s] “飞毛腿”弹道导弹 +[12m36s] 它的飞行轨迹是一个典型的抛物线 +[12m38s] 不会有这种 +[12m39s] 机动变轨的这样的一些动作 +[12m41s] 所以 +[12m42s] 只要是探测到了它的飞行轨迹 +[12m45s] 多发射一些“爱国者”拦截弹 +[12m48s] 是可以对它进行有效拦截的 +[12m49s] 但是现代化的弹道导弹 +[12m51s] 突防能力都很强 +[12m53s] 飞行轨迹是变化的 +[12m54s] 甚至可以说是有一些打水漂的 +[12m56s] 这样的一个飞行轨迹 +[12m57s] 速度又很快 +[12m58s] 那么甚至可能会携带 +[13m0s] 更多的诱饵弹和干扰弹 +[13m1s] 所以“爱国者”系统 +[13m2s] 可能根本就看不清 +[13m4s] 可能也就追不上 +[13m6s] “爱国者”导弹在部分实战场景中 +[13m9s] 拦截效果未达预期 +[13m12s] 这一表现 +[13m13s] 使得美以联合构建的 +[13m14s] 中程防空反导系统暴露出短板 +[13m18s] 也让多国对该系统 +[13m19s] 产生信任危机 +[13m24s] 在现代防空系统中 +[13m25s] 低空与超低空 +[13m27s] 是远程及中程防空 +[13m29s] 存在的短板 +[13m30s] 受到地球曲率 +[13m32s] 地形掩蔽等影响 +[13m33s] 远程雷达难以追踪低空目标 +[13m37s] 而中程防空导弹 +[13m38s] 在低空条件下 +[13m39s] 制导系统易受地面杂波的干扰 +[13m41s] 致使拦截效能下降 +[13m44s] 因此 +[13m45s] 近程防空系统由此诞生 +[13m48s] 专门用于近距离的防护 +[13m50s] 弥补了防空网络 +[13m52s] 在低空领域的不足 +[14m1s] 对普通公众而言 +[14m3s] 近程防空无疑是最常见 +[14m6s] 且视觉冲击力最强的防空形式 +[14m9s] 那些在网络短片 +[14m11s] 新闻报道和战场记录中 +[14m13s] 常见的导弹腾空而起 +[14m15s] 尾焰撕裂黑夜 +[14m17s] 目标在空中爆炸 +[14m19s] 火光刹那映亮天空的拦截画面 +[14m22s] 大多源自近程防空系统 +[14m25s] 这些系统射程较短 +[14m27s] 但部署便捷响应迅速 +[14m30s] 通常在目标接近阵地 +[14m32s] 城市或重要设施的最后一刻 +[14m35s] 才发动攻击 +[14m36s] 因而更易被现场捕捉 +[14m38s] 媒体记录 +[14m39s] 并广泛传播 +[14m41s] 正是由于这种高频曝光 +[14m43s] 近程防空在许多人心中 +[14m45s] 几乎成了防空的代名词 +[14m48s] 然而实际上 +[14m49s] 近程防空在整体防空网络中 +[14m52s] 充当的是最终贴身防线的功能 +[14m56s] 远程和中程防空 +[14m58s] 无法覆盖的低空 +[15m0s] 及超低空漏洞 +[15m4s] 近程防空作战体系 +[15m6s] 在实际应用的过程当中 +[15m7s] 有的时候 +[15m9s] 可能会成为战场上的奇兵 +[15m10s] 因为对方的直升机 +[15m13s] 甚至包括战斗机 +[15m14s] 在低空飞行的时候 +[15m15s] 它可能会放松警惕 +[15m16s] 另外一个来不及反应 +[15m18s] 所以这个时候 +[15m19s] 近程防空火力 +[15m20s] 针对目标进行射击的时候 +[15m21s] 战斗机很难对它进行规避 +[15m23s] 有的时候甚至连 +[15m25s] 发射红外诱饵弹的时间都没有 +[15m28s] 近程防空也有很大的难度 +[15m30s] 因为很多近程防空作战体系 +[15m33s] 它的搜索系统是比较简单的 +[15m35s] 视野不够宽广 +[15m36s] 也无法针对远距离的目标 +[15m38s] 进行识别 +[15m39s] 所以 +[15m40s] 有的近程防空作战体系 +[15m41s] 它本质上来讲是“近视眼” +[15m43s] 而且还涉及到一些 +[15m44s] 地形起伏的问题 +[15m46s] 如果对方利用这种地形起伏 +[15m49s] 那么去进行超低空的突防 +[15m51s] 在相关区域内部署的一些传感器 +[15m53s] 比如说低空搜索雷达 +[15m54s] 或者是光学红外传感器 +[15m56s] 可能没有办法 +[15m57s] 及时有效地识别目标 +[15m58s] 那么这些低空突防的 +[16m0s] 这样的一些空中作战平台 +[16m2s] 也可能会针对 +[16m3s] 近程的防空作战体系 +[16m4s] 包括相关的防空阵地 +[16m6s] 进行打击和压制 +[16m11s] 在近期 +[16m12s] 美以联合对伊朗的军事行动中 +[16m15s] 以色列拉斐尔公司研发的 +[16m17s] “铁穹”近程防空系统 +[16m19s] 经历了自2011年部署以来 +[16m21s] 最激烈 +[16m22s] 且最具转折性的实战检验 +[16m25s] 这款以往在冲突中 +[16m27s] 以85%至90%拦截率 +[16m30s] 享誉世界的明星武器 +[16m32s] 在伊朗 +[16m33s] “真实承诺-4”行动的饱和攻击下 +[16m36s] 其卓越声誉荡然无存 +[16m39s] 以色列国防部 +[16m41s] 3月17日 +[16m42s] 向国会递交的评估报告中指出 +[16m45s] 面对伊朗连续发动的 +[16m47s] 六十多波攻势 +[16m48s] “铁穹”系统的实际拦截率 +[16m50s] 从历史标榜的90%以上 +[16m53s] 骤降至约50% +[16m55s] 而在集束弹头密集倾泻 +[16m58s] 与无人机蜂群 +[16m59s] 低空突袭的关键战斗阶段 +[17m1s] 其拦截成功率 +[17m3s] 更是下滑至30%以下 +[17m5s] 刷新了服役以来的 +[17m7s] 最低性能纪录 +[17m12s] 我们注意到 +[17m13s] 在此次军事行动的过程当中 +[17m15s] “铁穹”系统表现平平 +[17m16s] 我认为 +[17m17s] 可能是有两方面的考虑 +[17m19s] 就是它配套的这种传感器 +[17m22s] 或者是预警体系 +[17m23s] 可能是遭到了对方的火力打击 +[17m25s] 有部分“铁穹”系统 +[17m26s] 那么针对空中来袭目标 +[17m28s] 它的反应速度不够快 +[17m30s] 它的这样的一个情报 +[17m33s] 和目标引导体系 +[17m35s] 不够完善 +[17m36s] 另外一个 +[17m37s] 就是“铁穹”系统 +[17m38s] 它所使用的是“塔米尔”拦截弹 +[17m40s] 这种拦截弹 +[17m41s] 由于它装备 +[17m43s] 和使用的数量比较多 +[17m44s] 所以 +[17m45s] 它必须要进行成本的控制 +[17m46s] 它的飞行速度 +[17m49s] 其实并没有那么快 +[17m50s] 有的时候 +[17m51s] 针对高速目标进行打击的时候 +[17m53s] 可能会出现速度跟不上的情况 +[17m56s] 现在伊朗主要是使用弹道导弹 +[17m59s] 针对以色列进行打击 +[18m0s] 面对高空高速飞行的弹道导弹 +[18m3s] 或者是 +[18m4s] 配备有 +[18m5s] 高超音速战斗部的弹道导弹 +[18m7s] “铁穹”系统可能它的拦截范围 +[18m9s] 拦截的高度也都不足 +[18m11s] 伊朗也使用了大量的无人机 +[18m14s] 但是这一次 +[18m15s] 针对以色列进行后续的反击 +[18m17s] 伊朗更加注重使用 +[18m19s] 这种中程的弹道导弹 +[18m21s] 或者是突防能力很强的弹道导弹 +[18m23s] 面对这样的一种情况 +[18m25s] 它就会出现反应速度不够快 +[18m28s] 包括拦截的范围 +[18m30s] 拦截的高度 +[18m31s] 都存在不足的情况 +[18m35s] 近程防空系统 +[18m37s] 作为整个防空架构中 +[18m38s] 关键的最后屏障 +[18m40s] 肩负着坚守底线 +[18m42s] 护卫要害的核心职责 +[18m45s] 但它并非完美无缺 +[18m47s] 仍存在明显的短板 +[18m50s] 其防御范围 +[18m51s] 通常较为有限 +[18m53s] 探测与拦截能力 +[18m54s] 都集中在较小区域 +[18m57s] 大多只能执行小尺度 +[18m58s] 重点目标的局部防护任务 +[19m1s] 无法如同中远程防空系统那样 +[19m4s] 实现广大空域的大范围 +[19m6s] 远距离监控与狙击 +[19m9s] 因此 +[19m10s] 近程防空系统 +[19m11s] 不能独自支撑一国 一城 +[19m13s] 或整个战区的全面防空安全 +[19m16s] 它更像是一件 +[19m17s] 贴身的防弹衣 +[19m19s] 而非是能覆盖全域的保护罩 +[19m24s] 远程 中程与近程防空系统 +[19m28s] 相互配合 +[19m29s] 在现代战争中 +[19m31s] 构筑了层次分明 分工明确 +[19m33s] 紧密衔接的全空域 +[19m35s] 立体防空网络 +[19m38s] 三者各尽其责 +[19m39s] 彼此支援 逐层拦截 +[19m42s] 形成了一套由外至内 +[19m44s] 由高空到低空 +[19m45s] 由远及近的周密防御体系 +[19m49s] 不过 +[19m50s] 近年多场高强度 +[19m51s] 军事冲突的实际表现 +[19m53s] 这套看似牢不可破的防空体系 +[19m56s] 正持续受到 +[19m57s] 新兴战场模式的严峻挑战 +[20m0s] 其传统作战思路和防御能力 +[20m2s] 正在不断遭受冲击与重塑 +[20m15s] 在美以联合对伊朗发动的 +[20m17s] 大规模军事行动中 +[20m19s] 可以清晰看到 +[20m20s] 美军 +[20m21s] 以色列军队 +[20m22s] 以及伊朗的防空系统 +[20m24s] 都遭遇了前所未有的考验 +[20m26s] 在以往严密的多层防护网络 +[20m29s] 在实战中屡次被突破 +[20m31s] 撕裂 +[20m32s] 甚至彻底崩溃 +[20m34s] 美军采用了 +[20m36s] 以“爱国者”和“萨德”反导系统 +[20m38s] 中远程导弹防御体系 +[20m41s] 以色列则调动了由“铁穹” +[20m43s] “大卫投石索” +[20m45s] 和“箭式”系统组成的 +[20m46s] 全域防空反导系统 +[20m49s] 然而 +[20m50s] 针对高超声速武器穿透 +[20m52s] 无人机群饱和攻击 +[20m54s] 复合诱饵欺骗 +[20m55s] 与强电磁干扰等策略 +[20m57s] 拦截失败的情况 +[20m59s] 依然频繁发生 +[21m4s] 在此次行动的过程当中 +[21m6s] 美以的防空反导系统 +[21m7s] 频频被洞穿 +[21m9s] 也就是说 +[21m10s] 伊朗使用了 +[21m11s] 一系列的突防手段 +[21m13s] 最终打穿了 +[21m14s] 美国和以色列的一些 +[21m15s] 防空反导的保护伞 +[21m18s] 那么针对相关的区域 +[21m19s] 构成了更为猛烈的火力打击 +[21m22s] 我认为 +[21m23s] 就是存在一些问题 +[21m25s] 比如说 +[21m26s] 它系统之间 +[21m27s] 没有进行有效的联合 +[21m28s] 存在一些兼容的问题 +[21m30s] 各自为战的状态下 +[21m32s] 肯定会出现漏洞 +[21m33s] 另外一个 +[21m34s] 伊朗也是采取了一些灵活多变的 +[21m37s] 这样的一些战术 +[21m38s] 多种火力 多种模式联合运用 +[21m41s] 也让美国和以色列的 +[21m43s] 防空反导系统 +[21m48s] 在现代军事冲突中 +[21m50s] 高超声速武器的穿透 +[21m51s] 与无人机蜂群的饱和攻击 +[21m54s] 虽是关键威胁 +[21m55s] 但更具深远颠覆性的 +[21m57s] 是战场成本效益失衡 +[21m59s] 对防空体系带来的 +[22m1s] 系统性难题 +[22m3s] 这已超越了 +[22m4s] 单一装备的性能竞争 +[22m6s] 演变为从经济原理 +[22m8s] 持久作战能力 +[22m10s] 到战略自信的全方位压制 +[22m13s] 引发了拦截不起 +[22m15s] 消耗不起 +[22m16s] 补充不上的三重困局 +[22m20s] 以伊朗主力攻击武器 +[22m22s] “见证者-136” 自杀式无人机为例 +[22m25s] 这款被戏称作 +[22m27s] 飞行摩托车的装备 +[22m29s] 彻底打破了传统军工的 +[22m31s] 精密制造范式 +[22m33s] 机身使用民用级玻璃纤维 +[22m35s] 和泡沫塑料拼装 +[22m37s] 动力系统 +[22m38s] 直接借用普通摩托车的 +[22m40s] 二冲程发动机 +[22m41s] 导航依靠 +[22m42s] 民用GPS模块与简易惯性制导 +[22m46s] 甚至部分核心零件 +[22m48s] 来自拆解的汽车和家电 +[22m50s] 从而极大压低了制造成本 +[22m53s] 凭借民用技术军用化 +[22m55s] 复杂系统简易化的理念 +[22m57s] 其批量生产单价 +[22m59s] 稳定在2万至5万美元之间 +[23m2s] 某些简化版本 +[23m4s] 甚至降至1.5万美元 +[23m6s] 仅相当于高端防空导弹成本的 +[23m9s] 极小一部分 +[23m11s] 美以用于对抗的防空武器 +[23m13s] 却如同天价奢侈品 +[23m16s] 以色列“铁穹”系统的拦截弹 +[23m18s] 作为近程防空核心弹药 +[23m20s] 每枚成本 +[23m21s] 达5万到10万美元 +[23m24s] 这意味着 +[23m25s] 即便成功拦截 +[23m26s] 防御方的经济损失 +[23m28s] 也远超进攻方 +[23m30s] 更甚者 +[23m31s] 美国“爱国者-3”拦截弹 +[23m33s] 作为中程防空主力 +[23m35s] 单枚成本飙升至 +[23m37s] 400万至500万美元 +[23m39s] 堪比用超级跑车 +[23m41s] 撞击普通代步车 +[23m43s] 无论胜负如何 +[23m45s] 防御方已在战略层面 +[23m47s] 沦为输家 +[23m51s] 那么在效费比方面 +[23m53s] 如果说对方在进攻的过程当中 +[23m56s] 采用了很多廉价的方式 +[23m58s] 或者说很多省钱的 +[23m59s] 这样的一些技术手段 +[24m1s] 像“见证者-136” +[24m3s] 其实它就是飞行的小摩托 +[24m5s] 那么它的成本控制得很低 +[24m7s] 可能要用先进的防空反导系统 +[24m9s] 对它进行拦截 +[24m11s] 而伊朗的一些地区盟友 +[24m12s] 包括黎巴嫩真主党 +[24m15s] 会使用大量的“卡桑”火箭弹 +[24m16s] 也就是说 +[24m17s] 更低成本的作坊工厂当中 +[24m19s] 生产出来的这种火箭弹 +[24m21s] 针对以色列进行打击 +[24m22s] 那么色列方面使用的是什么 +[24m24s] 是这种“塔米尔”拦截弹 +[24m25s] 也就是“铁穹”系统的拦截弹 +[24m28s] 在这样的 一种 +[24m29s] 相互的消耗战的过程当中 +[24m31s] 那么位列或者是美国 +[24m33s] 他所面临的这种 +[24m35s] 昂贵弹药的快速消耗 +[24m37s] 和短缺的情况 +[24m39s] 也会变得越来越多 +[24m40s] 也会变得越来越明显 +[24m43s] 由于战场上 +[24m44s] 攻击和防御的效费比例 +[24m46s] 严重失衡 +[24m47s] 防空弹药库存告急 +[24m49s] 形势十分紧迫 +[24m51s] 3月18日 +[24m52s] 以色列国防部火速向美国求援 +[24m55s] 美军立刻出动C-17运输机 +[24m58s] 紧急空运了近千枚拦截导弹 +[25m1s] 为以色列的库存注入强心针 +[25m4s] 与此同时 +[25m5s] 美军从韩国 +[25m7s] 亚太到欧洲多个据点 +[25m9s] 迅速调集防空系统 +[25m11s] 全力缓解战场上 +[25m13s] 防空装备和弹药 +[25m14s] 捉襟见肘的困境 +[25m19s] 现代防空系统 +[25m20s] 打造了一个从远程中段反导 +[25m23s] 近程区域防空 +[25m24s] 到近程末段拦截的完整链条 +[25m28s] 构建了远近交错 +[25m30s] 高低兼顾的立体防御天网 +[25m33s] 然而 +[25m34s] 高超声速武器如闪电般突破 +[25m37s] 无人机群的压制 +[25m38s] 以及饱和式攻击的浪潮不断袭来 +[25m41s] 让传统防空体系 +[25m42s] 面临着严峻的考验 +[25m46s] 攻防成本的天平 +[25m48s] 正在严重倾斜 +[25m49s] 威胁形势日新月异 +[25m52s] 如何在提升拦截效果的同时 +[25m54s] 勒紧成本的缰绳 +[25m57s] 变得至关重要 +[25m59s] 让防空网看得远 +[26m1s] 拦得准 +[26m2s] 扛得住 +[26m3s] 耗得起 +[26m4s] 成为了左右未来防空安全 +[26m6s] 和战争胜负的 +[26m8s] 关键棋局 +[26m11s] 好了观众朋友们 +[26m12s] 感谢您持续关注 +[26m13s] 国防军事频道《军事科技》 +[26m15s] 我们下周同一时间 +[26m17s] 再见 diff --git a/doco/programs/ep002_20260127_qianting_fangsheng/20260127潜艇的仿生之路_穆佩弦.docx b/doco/programs/ep002_20260127_qianting_fangsheng/20260127潜艇的仿生之路_穆佩弦.docx new file mode 100644 index 0000000..c383752 Binary files /dev/null and b/doco/programs/ep002_20260127_qianting_fangsheng/20260127潜艇的仿生之路_穆佩弦.docx differ diff --git a/doco/programs/ep002_20260127_qianting_fangsheng/ep002_20260127_qianting_fangsheng_a_skeleton.json b/doco/programs/ep002_20260127_qianting_fangsheng/ep002_20260127_qianting_fangsheng_a_skeleton.json new file mode 100644 index 0000000..a6cb4a1 --- /dev/null +++ b/doco/programs/ep002_20260127_qianting_fangsheng/ep002_20260127_qianting_fangsheng_a_skeleton.json @@ -0,0 +1,210 @@ +[ + { + "type": "normal", + "para_start": 1, + "para_end": 1, + "role_label": "【导视1】", + "header_inline": true, + "header_para_idx": 1 + }, + { + "type": "normal", + "para_start": 2, + "para_end": 2, + "role_label": "【演播室主持人1】", + "header_inline": true, + "header_para_idx": 2 + }, + { + "type": "break", + "para_start": 3, + "para_end": 3, + "role_label": "从鹦鹉螺到“潜艇压载水舱系统”", + "header_inline": false, + "header_para_idx": 3 + }, + { + "type": "normal", + "para_start": 4, + "para_end": 4, + "role_label": "【二维动画解说1】", + "header_inline": true, + "header_para_idx": 4 + }, + { + "type": "normal", + "para_start": 5, + "para_end": 5, + "role_label": "【三维动画解说1】", + "header_inline": true, + "header_para_idx": 5 + }, + { + "type": "normal", + "para_start": 6, + "para_end": 6, + "role_label": "【专家1】", + "header_inline": true, + "header_para_idx": 6 + }, + { + "type": "normal", + "para_start": 7, + "para_end": 7, + "role_label": "【解说1】", + "header_inline": true, + "header_para_idx": 7 + }, + { + "type": "normal", + "para_start": 8, + "para_end": 8, + "role_label": "【三维动画解说2】", + "header_inline": true, + "header_para_idx": 8 + }, + { + "type": "break", + "para_start": 9, + "para_end": 9, + "role_label": "鲨鱼皮肤与潜艇的外形设计", + "header_inline": false, + "header_para_idx": 9 + }, + { + "type": "normal", + "para_start": 10, + "para_end": 10, + "role_label": "【主持人1】", + "header_inline": true, + "header_para_idx": 10 + }, + { + "type": "normal", + "para_start": 11, + "para_end": 11, + "role_label": "【专家2】", + "header_inline": true, + "header_para_idx": 11 + }, + { + "type": "normal", + "para_start": 12, + "para_end": 12, + "role_label": "【解说2】", + "header_inline": true, + "header_para_idx": 12 + }, + { + "type": "normal", + "para_start": 13, + "para_end": 13, + "role_label": "【专家3】", + "header_inline": true, + "header_para_idx": 13 + }, + { + "type": "normal", + "para_start": 14, + "para_end": 14, + "role_label": "【解说3】", + "header_inline": true, + "header_para_idx": 14 + }, + { + "type": "break", + "para_start": 15, + "para_end": 15, + "role_label": "海豚声呐与潜艇综合声呐系统", + "header_inline": false, + "header_para_idx": 15 + }, + { + "type": "normal", + "para_start": 16, + "para_end": 16, + "role_label": "【解说4】", + "header_inline": true, + "header_para_idx": 16 + }, + { + "type": "normal", + "para_start": 17, + "para_end": 17, + "role_label": "【解说5】", + "header_inline": true, + "header_para_idx": 17 + }, + { + "type": "normal", + "para_start": 18, + "para_end": 18, + "role_label": "【专家4】", + "header_inline": true, + "header_para_idx": 18 + }, + { + "type": "normal", + "para_start": 19, + "para_end": 19, + "role_label": "【三维动画解说3】", + "header_inline": true, + "header_para_idx": 19 + }, + { + "type": "normal", + "para_start": 20, + "para_end": 20, + "role_label": "【解说6】", + "header_inline": true, + "header_para_idx": 20 + }, + { + "type": "break", + "para_start": 21, + "para_end": 21, + "role_label": "乌贼墨汁与声学诱饵", + "header_inline": false, + "header_para_idx": 21 + }, + { + "type": "normal", + "para_start": 22, + "para_end": 22, + "role_label": "【主持人2】", + "header_inline": true, + "header_para_idx": 22 + }, + { + "type": "normal", + "para_start": 23, + "para_end": 23, + "role_label": "【解说7】", + "header_inline": true, + "header_para_idx": 23 + }, + { + "type": "normal", + "para_start": 24, + "para_end": 24, + "role_label": "【专家5】", + "header_inline": true, + "header_para_idx": 24 + }, + { + "type": "normal", + "para_start": 25, + "para_end": 25, + "role_label": "【解说8】", + "header_inline": true, + "header_para_idx": 25 + }, + { + "type": "normal", + "para_start": 26, + "para_end": 26, + "role_label": "【主持人3】", + "header_inline": true, + "header_para_idx": 26 + } +] \ No newline at end of file diff --git a/doco/programs/ep002_20260127_qianting_fangsheng/ep002_20260127_qianting_fangsheng_a_skeleton_preview.txt b/doco/programs/ep002_20260127_qianting_fangsheng/ep002_20260127_qianting_fangsheng_a_skeleton_preview.txt new file mode 100644 index 0000000..7cb49fa --- /dev/null +++ b/doco/programs/ep002_20260127_qianting_fangsheng/ep002_20260127_qianting_fangsheng_a_skeleton_preview.txt @@ -0,0 +1,36 @@ +# ep002_20260127_qianting_fangsheng 骨架预览表 +# 原始骨架: 26 段 → 合并后: 26 段 + +====================================================================== + ep002_20260127_qianting_fangsheng 骨架预览表 +====================================================================== + seq | type | role_label | 正文前20字 +-----+---------+-------------------------------------+--------------------- + 1 | normal | 【导视1】 | 在浩瀚海洋深处,数亿年的进化,塑造了无数 + 2 | normal | 【演播室主持人1】 | 观众朋友们大家好!欢迎收看本期的《军事科 + 3 | break | 从鹦鹉螺到“潜艇压载水舱系统” | 从鹦鹉螺到“潜艇压载水舱系统” + 4 | normal | 【二维动画解说1】 | 1776年9月7日,历史上第一次潜艇攻击 + 5 | normal | 【三维动画解说1】 | “海龟号”潜艇,顾名思义它的外形很像海龟 + 6 | normal | 【专家1】 | 潜艇通过向压载水舱注水,排水来改变自身重 + 7 | normal | 【解说1】 | 让我们把目光投向鹦鹉螺的身上。在亿万年的 + 8 | normal | 【三维动画解说2】 | 人类潜艇的压载水舱系统,正是对这一自然智 + 9 | break | 鲨鱼皮肤与潜艇的外形设计 | 鲨鱼皮肤与潜艇的外形设计 + 10 | normal | 【主持人1】 | 在广袤的海洋中,生存是一场关乎“看见”与 + 11 | normal | 【专家2】 | 在二战时期,一些潜艇的指挥塔和上层建筑就 + 12 | normal | 【解说2】 | 然而,在深海中,仅仅“看不见”还远远不够 + 13 | normal | 【专家3】 | 从早期潜艇的船型艇体,到现代攻击核潜艇几 + 14 | normal | 【解说3】 | 海豚的皮肤,能够分泌一种特殊黏液,使水流 + 15 | break | 海豚声呐与潜艇综合声呐系统 | 海豚声呐与潜艇综合声呐系统 + 16 | normal | 【解说4】 | 海豚在浑浊的海水中能准确找到小鱼,还能分 + 17 | normal | 【解说5】 | 二战爆发后,德国U型潜艇再次成为海战中的 + 18 | normal | 【专家4】 | 声呐是一种利用声波在水中的传播和反射探测 + 19 | normal | 【三维动画解说3】 | 这是一艘弗吉尼亚级攻击核潜艇。潜艇的艇艏 + 20 | normal | 【解说6】 | 如今,科学家仍在向海豚学习:新的声呐系统 + 21 | break | 乌贼墨汁与声学诱饵 | 乌贼墨汁与声学诱饵 + 22 | normal | 【主持人2】 | 当生存受到直接威胁时,顶尖的猎手与最先进 + 23 | normal | 【解说7】 | 潜艇感知到来袭的声自导鱼雷或敌方主动声呐 + 24 | normal | 【专家5】 | 1942 年,德军 U 型潜艇投放了一种 + 25 | normal | 【解说8】 | 这套系统在实战与对抗演练中屡建奇功。其核 + 26 | normal | 【主持人3】 | 当深海的幽灵收起獠牙,当猎杀者的声呐归于 +====================================================================== +[WARN] 请人工核验: role_label是否含真人姓名? ignore是否漏/多? type是否正确? diff --git a/doco/programs/ep003_20260331_qiangxie_chicun/20260331枪械设计中的小尺寸大讲究_孙逸昊.docx b/doco/programs/ep003_20260331_qiangxie_chicun/20260331枪械设计中的小尺寸大讲究_孙逸昊.docx new file mode 100644 index 0000000..eb21de2 Binary files /dev/null and b/doco/programs/ep003_20260331_qiangxie_chicun/20260331枪械设计中的小尺寸大讲究_孙逸昊.docx differ diff --git a/doco/programs/ep003_20260331_qiangxie_chicun/ep003_20260331_qiangxie_chicun_a_skeleton.json b/doco/programs/ep003_20260331_qiangxie_chicun/ep003_20260331_qiangxie_chicun_a_skeleton.json new file mode 100644 index 0000000..849cc28 --- /dev/null +++ b/doco/programs/ep003_20260331_qiangxie_chicun/ep003_20260331_qiangxie_chicun_a_skeleton.json @@ -0,0 +1,298 @@ +[ + { + "type": "normal", + "para_start": 1, + "para_end": 7, + "role_label": "【导视1】", + "header_inline": false, + "header_para_idx": 1 + }, + { + "type": "normal", + "para_start": 8, + "para_end": 11, + "role_label": "【主持人1】", + "header_inline": false, + "header_para_idx": 8 + }, + { + "type": "ignore", + "para_start": 12, + "para_end": 12, + "role_label": "(出枪柜)", + "header_inline": false, + "header_para_idx": 12 + }, + { + "type": "normal", + "para_start": 13, + "para_end": 13, + "role_label": "【主持人1】", + "header_inline": false, + "header_para_idx": 8 + }, + { + "type": "normal", + "para_start": 14, + "para_end": 19, + "role_label": "【解说1】", + "header_inline": false, + "header_para_idx": 14 + }, + { + "type": "normal", + "para_start": 20, + "para_end": 22, + "role_label": "【主持人2】", + "header_inline": false, + "header_para_idx": 20 + }, + { + "type": "ignore", + "para_start": 23, + "para_end": 23, + "role_label": "(换枪)", + "header_inline": false, + "header_para_idx": 23 + }, + { + "type": "normal", + "para_start": 24, + "para_end": 25, + "role_label": "【主持人2】", + "header_inline": false, + "header_para_idx": 20 + }, + { + "type": "normal", + "para_start": 26, + "para_end": 29, + "role_label": "【解说2】", + "header_inline": false, + "header_para_idx": 26 + }, + { + "type": "normal", + "para_start": 30, + "para_end": 31, + "role_label": "【三维动画1】", + "header_inline": false, + "header_para_idx": 30 + }, + { + "type": "normal", + "para_start": 32, + "para_end": 34, + "role_label": "【解说3】", + "header_inline": false, + "header_para_idx": 32 + }, + { + "type": "normal", + "para_start": 35, + "para_end": 37, + "role_label": "【主持人3】", + "header_inline": false, + "header_para_idx": 35 + }, + { + "type": "ignore", + "para_start": 38, + "para_end": 38, + "role_label": "(出枪柜)", + "header_inline": false, + "header_para_idx": 38 + }, + { + "type": "normal", + "para_start": 39, + "para_end": 40, + "role_label": "【主持人3】", + "header_inline": false, + "header_para_idx": 35 + }, + { + "type": "normal", + "para_start": 41, + "para_end": 47, + "role_label": "【解说4】", + "header_inline": false, + "header_para_idx": 41 + }, + { + "type": "normal", + "para_start": 48, + "para_end": 50, + "role_label": "【主持人4】", + "header_inline": false, + "header_para_idx": 48 + }, + { + "type": "ignore", + "para_start": 51, + "para_end": 51, + "role_label": "(换枪)", + "header_inline": false, + "header_para_idx": 51 + }, + { + "type": "normal", + "para_start": 52, + "para_end": 53, + "role_label": "【主持人4】", + "header_inline": false, + "header_para_idx": 48 + }, + { + "type": "normal", + "para_start": 54, + "para_end": 59, + "role_label": "【解说5】", + "header_inline": false, + "header_para_idx": 54 + }, + { + "type": "normal", + "para_start": 60, + "para_end": 62, + "role_label": "【主持人5】", + "header_inline": false, + "header_para_idx": 60 + }, + { + "type": "ignore", + "para_start": 63, + "para_end": 63, + "role_label": "(出枪柜)", + "header_inline": false, + "header_para_idx": 63 + }, + { + "type": "normal", + "para_start": 64, + "para_end": 65, + "role_label": "【主持人5】", + "header_inline": false, + "header_para_idx": 60 + }, + { + "type": "normal", + "para_start": 66, + "para_end": 70, + "role_label": "【解说6】", + "header_inline": false, + "header_para_idx": 66 + }, + { + "type": "normal", + "para_start": 71, + "para_end": 73, + "role_label": "【主持人6】", + "header_inline": false, + "header_para_idx": 71 + }, + { + "type": "ignore", + "para_start": 74, + "para_end": 74, + "role_label": "(换枪)", + "header_inline": false, + "header_para_idx": 74 + }, + { + "type": "normal", + "para_start": 75, + "para_end": 76, + "role_label": "【主持人6】", + "header_inline": false, + "header_para_idx": 71 + }, + { + "type": "normal", + "para_start": 77, + "para_end": 86, + "role_label": "【解说7】", + "header_inline": false, + "header_para_idx": 77 + }, + { + "type": "normal", + "para_start": 87, + "para_end": 89, + "role_label": "【主持人7】", + "header_inline": false, + "header_para_idx": 87 + }, + { + "type": "ignore", + "para_start": 90, + "para_end": 90, + "role_label": "(出枪柜)", + "header_inline": false, + "header_para_idx": 90 + }, + { + "type": "normal", + "para_start": 91, + "para_end": 92, + "role_label": "【主持人7】", + "header_inline": false, + "header_para_idx": 87 + }, + { + "type": "normal", + "para_start": 93, + "para_end": 99, + "role_label": "【解说8】", + "header_inline": false, + "header_para_idx": 93 + }, + { + "type": "normal", + "para_start": 100, + "para_end": 101, + "role_label": "【主持人8】", + "header_inline": false, + "header_para_idx": 100 + }, + { + "type": "ignore", + "para_start": 102, + "para_end": 102, + "role_label": "【摇】(换枪)", + "header_inline": false, + "header_para_idx": 102 + }, + { + "type": "normal", + "para_start": 103, + "para_end": 105, + "role_label": "【主持人8】", + "header_inline": false, + "header_para_idx": 100 + }, + { + "type": "ignore", + "para_start": 106, + "para_end": 106, + "role_label": "(拉栓子弹转场,三镜:【摇全】手摸枪栓、【固中】拉推、【轨近】举枪瞄准)", + "header_inline": false, + "header_para_idx": 106 + }, + { + "type": "normal", + "para_start": 107, + "para_end": 116, + "role_label": "【解说9】", + "header_inline": false, + "header_para_idx": 107 + }, + { + "type": "normal", + "para_start": 117, + "para_end": 120, + "role_label": "【主持人9】", + "header_inline": false, + "header_para_idx": 117 + } +] \ No newline at end of file diff --git a/doco/programs/ep003_20260331_qiangxie_chicun/ep003_20260331_qiangxie_chicun_a_skeleton_preview.txt b/doco/programs/ep003_20260331_qiangxie_chicun/ep003_20260331_qiangxie_chicun_a_skeleton_preview.txt new file mode 100644 index 0000000..e0b4119 --- /dev/null +++ b/doco/programs/ep003_20260331_qiangxie_chicun/ep003_20260331_qiangxie_chicun_a_skeleton_preview.txt @@ -0,0 +1,30 @@ +# ep003_20260331_qiangxie_chicun 骨架预览表 (合并后: 37 → 20 段) +# 原始骨架: 37 段 → 合并后: 20 段 + +====================================================================== + ep003_20260331_qiangxie_chicun (合并后: 37 → 20 段) 骨架预览表 +====================================================================== + seq | type | role_label | 正文前20字 +-----+---------+-------------------------------------+--------------------- + 1 | normal | 【导视1】 | 藏于方寸的敌后利器,锋芒毕露的火力先锋; + 2 | normal | 【主持人1】 | 各位观众朋友们,大家好,欢迎收看《军事科 + 3 | normal | 【解说1】 | 这是一把用来换取更好枪支的武器,一个构思 + 4 | normal | 【主持人2】 | 【固】如今FP-45成了收藏家眼中的珍品 + 5 | normal | 【解说2】 | 沙漠之鹰是当今世界上最知名的大口径手枪之 + 6 | normal | 【三维动画1】 | 大口径弹药燃烧时会产生巨大的高温高压,必 + 7 | normal | 【解说3】 | 如今,沙漠之鹰虽未大规模列装军队,却因独 + 8 | normal | 【主持人3】 | 【固】一款能藏进火柴盒,一款堪称手枪巨无 + 9 | normal | 【解说4】 | PP-2000的设计,源于俄罗斯在城市反 + 10 | normal | 【主持人4】 | 【固】PP-2000 用 340 毫米的 + 11 | normal | 【解说5】 | MP40 是二战期间产量较大、影响力较广 + 12 | normal | 【主持人5】 | 【固】从340毫米的紧凑灵活,到833毫 + 13 | normal | 【解说6】 | AUG的出现,掀起了步枪设计的一场结构革 + 14 | normal | 【主持人6】 | 【固】AUG用无托枪身的设计真正做到了短 + 15 | normal | 【解说7】 | 如果你曾接触过枪战类游戏,那么你一定知道 + 16 | normal | 【主持人7】 | 【固】两款步枪可以说是短有短的灵活创新, + 17 | normal | 【解说8】 | VSS的设计,完美诠释了苏联特种部队“敌 + 18 | normal | 【主持人8】 | 【固】相比于短小的VSS,接下来这款狙击 + 19 | normal | 【解说9】 | 20世纪90年代,南非面临着周边地区的轻 + 20 | normal | 【主持人9】 | (固)今天我们展示的每一款枪械,它们没有 +====================================================================== +[WARN] 请人工核验: role_label是否含真人姓名? ignore是否漏/多? type是否正确? diff --git a/doco/programs/ep004_20260526_qiangwang_duijue/20260526枪王对决_卡拉什尼科夫与尤金斯通纳的设计传奇_左鑫.docx b/doco/programs/ep004_20260526_qiangwang_duijue/20260526枪王对决_卡拉什尼科夫与尤金斯通纳的设计传奇_左鑫.docx new file mode 100644 index 0000000..74cd63c Binary files /dev/null and b/doco/programs/ep004_20260526_qiangwang_duijue/20260526枪王对决_卡拉什尼科夫与尤金斯通纳的设计传奇_左鑫.docx differ diff --git a/doco/programs/ep004_20260526_qiangwang_duijue/ep004_20260526_qiangwang_duijue_a_skeleton.json b/doco/programs/ep004_20260526_qiangwang_duijue/ep004_20260526_qiangwang_duijue_a_skeleton.json new file mode 100644 index 0000000..385a934 --- /dev/null +++ b/doco/programs/ep004_20260526_qiangwang_duijue/ep004_20260526_qiangwang_duijue_a_skeleton.json @@ -0,0 +1,466 @@ +[ + { + "type": "normal", + "para_start": 1, + "para_end": 3, + "role_label": "【导视1】", + "header_inline": false, + "header_para_idx": 1 + }, + { + "type": "normal", + "para_start": 4, + "para_end": 8, + "role_label": "【主持人1】", + "header_inline": false, + "header_para_idx": 4 + }, + { + "type": "normal", + "para_start": 9, + "para_end": 12, + "role_label": "【解说1】", + "header_inline": false, + "header_para_idx": 9 + }, + { + "type": "normal", + "para_start": 13, + "para_end": 14, + "role_label": "【主持人2】", + "header_inline": false, + "header_para_idx": 13 + }, + { + "type": "normal", + "para_start": 15, + "para_end": 16, + "role_label": "【解说2】", + "header_inline": false, + "header_para_idx": 15 + }, + { + "type": "normal", + "para_start": 17, + "para_end": 17, + "role_label": "【小剧场1-主持人1】", + "header_inline": true, + "header_para_idx": 17 + }, + { + "type": "normal", + "para_start": 18, + "para_end": 18, + "role_label": "【小剧场1-卡1】", + "header_inline": true, + "header_para_idx": 18 + }, + { + "type": "normal", + "para_start": 19, + "para_end": 19, + "role_label": "【小剧场1-主持人2】", + "header_inline": true, + "header_para_idx": 19 + }, + { + "type": "normal", + "para_start": 20, + "para_end": 20, + "role_label": "【小剧场1-卡2】", + "header_inline": true, + "header_para_idx": 20 + }, + { + "type": "normal", + "para_start": 21, + "para_end": 24, + "role_label": "【解说3】", + "header_inline": false, + "header_para_idx": 21 + }, + { + "type": "normal", + "para_start": 25, + "para_end": 25, + "role_label": "【小剧场1-主持人3】", + "header_inline": true, + "header_para_idx": 25 + }, + { + "type": "normal", + "para_start": 26, + "para_end": 27, + "role_label": "【小剧场1-卡3】", + "header_inline": true, + "header_para_idx": 26 + }, + { + "type": "normal", + "para_start": 28, + "para_end": 28, + "role_label": "【小剧场1-主持人4】", + "header_inline": true, + "header_para_idx": 28 + }, + { + "type": "normal", + "para_start": 29, + "para_end": 29, + "role_label": "【小剧场1-卡4】", + "header_inline": true, + "header_para_idx": 29 + }, + { + "type": "normal", + "para_start": 30, + "para_end": 32, + "role_label": "【解说4】", + "header_inline": false, + "header_para_idx": 30 + }, + { + "type": "normal", + "para_start": 33, + "para_end": 33, + "role_label": "【主持人3】", + "header_inline": true, + "header_para_idx": 33 + }, + { + "type": "normal", + "para_start": 34, + "para_end": 35, + "role_label": "【解说5】", + "header_inline": false, + "header_para_idx": 34 + }, + { + "type": "normal", + "para_start": 36, + "para_end": 36, + "role_label": "【小剧场2-主持人1】", + "header_inline": true, + "header_para_idx": 36 + }, + { + "type": "normal", + "para_start": 37, + "para_end": 37, + "role_label": "【小剧场2-斯1】", + "header_inline": true, + "header_para_idx": 37 + }, + { + "type": "normal", + "para_start": 38, + "para_end": 38, + "role_label": "【小剧场2-主持人2】", + "header_inline": true, + "header_para_idx": 38 + }, + { + "type": "normal", + "para_start": 39, + "para_end": 39, + "role_label": "【小剧场2-斯2】", + "header_inline": true, + "header_para_idx": 39 + }, + { + "type": "normal", + "para_start": 40, + "para_end": 42, + "role_label": "【解说6】", + "header_inline": false, + "header_para_idx": 40 + }, + { + "type": "normal", + "para_start": 43, + "para_end": 43, + "role_label": "【小剧场2-斯3】", + "header_inline": true, + "header_para_idx": 43 + }, + { + "type": "normal", + "para_start": 44, + "para_end": 46, + "role_label": "【解说7】", + "header_inline": false, + "header_para_idx": 44 + }, + { + "type": "normal", + "para_start": 47, + "para_end": 47, + "role_label": "【小剧场2-斯4】", + "header_inline": true, + "header_para_idx": 47 + }, + { + "type": "normal", + "para_start": 48, + "para_end": 48, + "role_label": "【小剧场2-主持人3】", + "header_inline": true, + "header_para_idx": 48 + }, + { + "type": "normal", + "para_start": 49, + "para_end": 49, + "role_label": "【小剧场2-斯5】", + "header_inline": true, + "header_para_idx": 49 + }, + { + "type": "normal", + "para_start": 50, + "para_end": 51, + "role_label": "【解说8】", + "header_inline": false, + "header_para_idx": 50 + }, + { + "type": "normal", + "para_start": 52, + "para_end": 54, + "role_label": "【主持人4】", + "header_inline": true, + "header_para_idx": 52 + }, + { + "type": "normal", + "para_start": 55, + "para_end": 55, + "role_label": "【小剧场3-主持人1】", + "header_inline": true, + "header_para_idx": 55 + }, + { + "type": "normal", + "para_start": 56, + "para_end": 56, + "role_label": "【小剧场3-卡1】", + "header_inline": true, + "header_para_idx": 56 + }, + { + "type": "normal", + "para_start": 57, + "para_end": 57, + "role_label": "【小剧场3-主持人2】", + "header_inline": true, + "header_para_idx": 57 + }, + { + "type": "normal", + "para_start": 58, + "para_end": 58, + "role_label": "【小剧场3-卡2】", + "header_inline": true, + "header_para_idx": 58 + }, + { + "type": "normal", + "para_start": 59, + "para_end": 60, + "role_label": "【解说9】", + "header_inline": false, + "header_para_idx": 59 + }, + { + "type": "normal", + "para_start": 61, + "para_end": 61, + "role_label": "【小剧场3-卡3】", + "header_inline": true, + "header_para_idx": 61 + }, + { + "type": "normal", + "para_start": 62, + "para_end": 62, + "role_label": "【小剧场3-主持人3】", + "header_inline": true, + "header_para_idx": 62 + }, + { + "type": "normal", + "para_start": 63, + "para_end": 63, + "role_label": "【小剧场3-斯4】", + "header_inline": true, + "header_para_idx": 63 + }, + { + "type": "normal", + "para_start": 64, + "para_end": 64, + "role_label": "【小剧场3-主持人4】", + "header_inline": true, + "header_para_idx": 64 + }, + { + "type": "normal", + "para_start": 65, + "para_end": 65, + "role_label": "【小剧场3-斯5】", + "header_inline": true, + "header_para_idx": 65 + }, + { + "type": "normal", + "para_start": 66, + "para_end": 67, + "role_label": "【解说10】", + "header_inline": false, + "header_para_idx": 66 + }, + { + "type": "normal", + "para_start": 68, + "para_end": 68, + "role_label": "【小剧场3-斯6】", + "header_inline": true, + "header_para_idx": 68 + }, + { + "type": "normal", + "para_start": 69, + "para_end": 72, + "role_label": "【解说11】", + "header_inline": false, + "header_para_idx": 69 + }, + { + "type": "normal", + "para_start": 73, + "para_end": 74, + "role_label": "【小剧场3-主持人5】", + "header_inline": true, + "header_para_idx": 73 + }, + { + "type": "normal", + "para_start": 75, + "para_end": 75, + "role_label": "【小剧场3-卡8】", + "header_inline": true, + "header_para_idx": 75 + }, + { + "type": "normal", + "para_start": 76, + "para_end": 76, + "role_label": "【小剧场3-斯9】", + "header_inline": true, + "header_para_idx": 76 + }, + { + "type": "normal", + "para_start": 77, + "para_end": 77, + "role_label": "【小剧场3-主持人6】", + "header_inline": true, + "header_para_idx": 77 + }, + { + "type": "normal", + "para_start": 78, + "para_end": 78, + "role_label": "【小剧场3-斯通纳10】", + "header_inline": true, + "header_para_idx": 78 + }, + { + "type": "normal", + "para_start": 79, + "para_end": 80, + "role_label": "【解说12】", + "header_inline": false, + "header_para_idx": 79 + }, + { + "type": "normal", + "para_start": 81, + "para_end": 81, + "role_label": "【小剧场4-主持人1】", + "header_inline": true, + "header_para_idx": 81 + }, + { + "type": "normal", + "para_start": 82, + "para_end": 82, + "role_label": "【小剧场4-卡1】", + "header_inline": true, + "header_para_idx": 82 + }, + { + "type": "normal", + "para_start": 83, + "para_end": 83, + "role_label": "【小剧场4-主持人2】", + "header_inline": true, + "header_para_idx": 83 + }, + { + "type": "normal", + "para_start": 84, + "para_end": 84, + "role_label": "【小剧场4-斯2】", + "header_inline": true, + "header_para_idx": 84 + }, + { + "type": "normal", + "para_start": 85, + "para_end": 85, + "role_label": "【小剧场4-主持人3】", + "header_inline": true, + "header_para_idx": 85 + }, + { + "type": "normal", + "para_start": 86, + "para_end": 90, + "role_label": "【解说13】", + "header_inline": false, + "header_para_idx": 86 + }, + { + "type": "normal", + "para_start": 91, + "para_end": 91, + "role_label": "【小剧场4-卡3】", + "header_inline": true, + "header_para_idx": 91 + }, + { + "type": "normal", + "para_start": 92, + "para_end": 92, + "role_label": "【小剧场4-斯5】", + "header_inline": true, + "header_para_idx": 92 + }, + { + "type": "normal", + "para_start": 93, + "para_end": 93, + "role_label": "【小剧场4-主持人6】", + "header_inline": true, + "header_para_idx": 93 + }, + { + "type": "normal", + "para_start": 94, + "para_end": 94, + "role_label": "【主持人5】", + "header_inline": true, + "header_para_idx": 94 + } +] \ No newline at end of file diff --git a/doco/programs/ep004_20260526_qiangwang_duijue/ep004_20260526_qiangwang_duijue_a_skeleton_preview.txt b/doco/programs/ep004_20260526_qiangwang_duijue/ep004_20260526_qiangwang_duijue_a_skeleton_preview.txt new file mode 100644 index 0000000..1478211 --- /dev/null +++ b/doco/programs/ep004_20260526_qiangwang_duijue/ep004_20260526_qiangwang_duijue_a_skeleton_preview.txt @@ -0,0 +1,68 @@ +# ep004_20260526_qiangwang_duijue 骨架预览表 +# 原始骨架: 58 段 → 合并后: 58 段 + +====================================================================== + ep004_20260526_qiangwang_duijue 骨架预览表 +====================================================================== + seq | type | role_label | 正文前20字 +-----+---------+-------------------------------------+--------------------- + 1 | normal | 【导视1】 | 他们的一生是创造的一生,他们的灵感或许来 + 2 | normal | 【主持人1】 | 各位观众,大家好!欢迎收看《军事科技》, + 3 | normal | 【解说1】 | 2013年12月23日,枪械设计大师、A + 4 | normal | 【主持人2】 | 卡拉什尼科夫于1919年11月10日出生 + 5 | normal | 【解说2】 | 少年时期的卡拉什尼科夫,最大的梦想并非设 + 6 | normal | 【小剧场1-主持人1】 | 卡拉什尼科夫先生,您年少时一心想要研发农 + 7 | normal | 【小剧场1-卡1】 | 在不远处就是我的家,你也能看到,除了几间 + 8 | normal | 【小剧场1-主持人2】 | 辍学之后,您没有放弃对机械的钻研,对吗? + 9 | normal | 【小剧场1-卡2】 | 没错。我打心底里喜欢机械,便自己自学机械 + 10 | normal | 【解说3】 | 1938年,19岁的卡拉什尼科夫应征入伍 + 11 | normal | 【小剧场1-主持人3】 | 战场上的差距往往最直观,您亲身经历过东线 + 12 | normal | 【小剧场1-卡3】 | 当时的情况糟透了,我们根本没有还手之力, + 13 | normal | 【小剧场1-主持人4】 | 也就是在那个时候,您下定决心,放弃农机梦 + 14 | normal | 【小剧场1-卡4】 | 没错。这个念头一旦萌生,就再也无法消散。 + 15 | normal | 【解说4】 | 经过刻苦钻研,卡拉什尼科夫先后设计出冲锋 + 16 | normal | 【主持人3】 | 在卡拉什尼科夫三岁那年,大洋彼岸的美国, + 17 | normal | 【解说5】 | 1922年11月,尤金·斯通纳出生于美国 + 18 | normal | 【小剧场2-主持人1】 | 斯通纳先生,您刚进入航空企业时,从事的是 + 19 | normal | 【小剧场2-斯1】 | 刚入职的时候,我做的都是基础工作,主要从 + 20 | normal | 【小剧场2-主持人2】 | 想必过程十分艰辛吧? + 21 | normal | 【小剧场2-斯2】 | 确实不易。我利用业余时间上夜校,去航空学 + 22 | normal | 【解说6】 | 1941年12月7日,日本海军舰队偷袭珍 + 23 | normal | 【小剧场2-斯3】 | 战争结束后,我荣誉退伍,先后就职于多家知 + 24 | normal | 【解说7】 | 但二战结束后,战争形态发生了彻底变革:堑 + 25 | normal | 【小剧场2-斯4】 | 我亲眼目睹了美军士兵的作战困境。当时列装 + 26 | normal | 【小剧场2-主持人3】 | 所以航空技术成为了您设计M-16的独家优 + 27 | normal | 【小剧场2-斯5】 | 是的。航空制造追求轻量化、高精度、高稳定 + 28 | normal | 【解说8】 | M-16步枪因枪身通体呈黑色,被人们戏称 + 29 | normal | 【主持人4】 | 一位,是战火幸存者的自救式设计;一位,是 + 30 | normal | 【小剧场3-主持人1】 | 卡拉什尼科夫先生,对于AK-47外界有人 + 31 | normal | 【小剧场3-卡1】 | 我从来不认为这是粗糙,却是纯粹的皮实,是 + 32 | normal | 【小剧场3-主持人2】 | 所以您的设计,都是围绕实战可靠性展开? + 33 | normal | 【小剧场3-卡2】 | 没错。一把好枪,应该是士兵身体的一部分, + 34 | normal | 【解说9】 | AK-47枪长870毫米,枪管长415毫 + 35 | normal | 【小剧场3-卡3】 | 气式的工作原理,其实一点都不复杂。当击锤 + 36 | normal | 【小剧场3-主持人3】 | 嗯,那么,斯通纳先生,相较于卡拉什尼科夫 + 37 | normal | 【小剧场3-斯4】 | 在我看来,单纯的皮实耐用远远不足以适配现 + 38 | normal | 【小剧场3-主持人4】 | 模块化在当时属于十分新颖的设计,您为什么 + 39 | normal | 【小剧场3-斯5】 | 现代战争场景愈发复杂,作战任务灵活多变, + 40 | normal | 【解说10】 | M-16 轻便且质感高级,加工工艺精密, + 41 | normal | 【小剧场3-斯6】 | 我设计的导气方式与卡拉什尼科夫先生的完全 + 42 | normal | 【解说11】 | 此外,M-16还十分注重枪械的人机工学设 + 43 | normal | 【小剧场3-主持人5】 | 1990年两位首次碰面,还互相试射了对方 + 44 | normal | 【小剧场3-卡8】 | 斯通纳先生的枪精度很高,子弹初速快,设计 + 45 | normal | 【小剧场3-斯9】 | 我十分认可卡拉什尼科夫先生的设计。AK- + 46 | normal | 【小剧场3-主持人6】 | 如果互换使用场景,您会如何选择? + 47 | normal | 【小剧场3-斯通纳10】 | 如果我是没有后勤保障、身处蛮荒丛林的普通 + 48 | normal | 【解说12】 | 两款经典步枪问世后,在越南战争的战场上迎 + 49 | normal | 【小剧场4-主持人1】 | 两款枪没有优劣之分,只有适配之别。AK- + 50 | normal | 【小剧场4-卡1】 | 在我看来,可靠性永远排在第一位。一把好武 + 51 | normal | 【小剧场4-主持人2】 | 斯通纳先生,您是怎么认为? + 52 | normal | 【小剧场4-斯2】 | 我的评判标准更加贴合现代战术。优秀的单兵 + 53 | normal | 【小剧场4-主持人3】 | 二位的设计理念虽不同,却都坚守着自己的初 + 54 | normal | 【解说13】 | 2025年11月10日,联合国安理会就小 + 55 | normal | 【小剧场4-卡3】 | 坦白说,我为它能保护我方士兵、守护和平而 + 56 | normal | 【小剧场4-斯5】 | 技术本身不会制造杀戮,决定善恶的,永远是 + 57 | normal | 【小剧场4-主持人6】 | 你们两人都绝非战争狂热者,只是时代洪流中 + 58 | normal | 【主持人5】 | 本期《军事科技》就到这里,感谢收看,我们 +====================================================================== +[WARN] 请人工核验: role_label是否含真人姓名? ignore是否漏/多? type是否正确? diff --git a/doco/src/doco/cli.py b/doco/src/doco/cli.py index d82e1f0..010c2eb 100644 --- a/doco/src/doco/cli.py +++ b/doco/src/doco/cli.py @@ -22,6 +22,9 @@ 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") @@ -285,6 +288,66 @@ def fuse( 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( @@ -390,5 +453,69 @@ def asr( click.echo(f"[ok] asr_result_raw.json: {raw_path}") +@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) + + if __name__ == "__main__": main() diff --git a/doco/src/doco/fusion_align.py b/doco/src/doco/fusion_align.py new file mode 100644 index 0000000..b020740 --- /dev/null +++ b/doco/src/doco/fusion_align.py @@ -0,0 +1,1748 @@ +# -*- 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, + ) + 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 + + parsed = _parse_align_json(raw_response, len(batch_b)) + + 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. 参照稿只帮你判断在哪断句、哪里用顿号,绝不照抄它的字词。 + +只返回加好标点的字幕文本,不要解释。""" + + +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_candidates = [f for f in out_dir.glob("*.docx") if not f.name.startswith("~$")] + 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 ---- + docx_path = out_dir / "融合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