doco P3-C2: 讯飞ASR适配层接入 + 修复 doco/programs 忽略规则

- C2: asr_adapter 并入讯飞上传/轮询/解析,复用 extract_audio 分离音频,
  新增 doco asr 命令读 C1 热词;真转写 ep001 出 310 句带时间戳
- 补提交 C1 代码(llm.py / term_extract.py)与累积词典
- .gitignore: 挡 doco/programs 下 png/wav/mp4 及中间帧目录、本地 settings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
simonkoson
2026-06-17 10:19:08 +08:00
parent 487877fa08
commit 1372600d71
30 changed files with 9422 additions and 21 deletions
+14
View File
@@ -56,8 +56,22 @@ programs/*/source/*.mp4
programs/*/source/*.mov
programs/*/work/
# Doco 子项目 programs:大文件/中间帧/原始素材不入库(可重生成,开发完即删)
# 只保留小文本成果(B稿/asr_timed/热词表/留痕csv-json/代码py/A稿docx)
doco/programs/*/frames_v2/
doco/programs/*/work/
doco/programs/*/work_dryrun_test/
doco/programs/*/source/
doco/programs/*/*.mp4
doco/programs/*/*.mov
doco/programs/*/*.wav
doco/programs/*/*.png
# Doco .env
doco/.env
# Claude Code 本地个人设置(权限等),不入库
**/.claude/settings.local.json
# Python build artifacts
*.egg-info/
+3 -8
View File
@@ -12,15 +12,10 @@ XFYUN_APP_ID=your_xfyun_app_id
XFYUN_SECRET_KEY=your_xfyun_secret_key
# ========================================================================
# DeepSeek Vision (OCR 用)
# DeepSeek (OCR + AI 层共用)
# 申请地址: https://platform.deepseek.com/
# 凭证类型: API_KEY
# ========================================================================
DEEPSEEK_API_KEY=your_deepseek_api_key
# ========================================================================
# Anthropic Claude API (AI 融合层,P3 才用)
# 申请地址: https://console.anthropic.com/
# 凭证类型: API_KEY
# ========================================================================
ANTHROPIC_API_KEY=your_anthropic_api_key
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-pro
+145
View File
@@ -0,0 +1,145 @@
# 项目协作主控文件 (doco/CLAUDE.md)
<!--
Claude Code 每次开 session 自动读取。两个角色:
1. 给 AI 的项目说明书(项目是什么、用什么、怎么跑)
2. 双账号/多工具之间的"活快照"交接区(现在到哪了、下一步、刚做的决定)
维护原则:增量更新,不要整篇重写。改动写进对应小节即可。
3. 每次结束session或通哥明确说结束今天的工作,应由Claude修改更新本文件,交接备注和待确认等部分内容应为最新。
-->
---
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
- **最后更新**:账号A 2026-06-17
- **当前状态一句话**:P3-C2 讯飞 ASR 跑通并收口(真转写《现代防空反导大对决》27分钟→310句带时间戳,热词命中良好);**下一步进 C3,但卡在 B稿_v2.txt 未就位**。
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
---
## 🤖 给 AI 的工作约定 (READ FIRST)
- 开始任何工作前,先读完本文件,特别是「状态栏」「交接备注」「关键决策」三节。
- 完成一段工作后**增量更新**:「已完成」追加一条、「待办」勾掉或新增、必要时更新「关键决策」,最后改「状态栏」三行。不要重写整份文件,不要删历史,只追加/勾选。
- 涉及发送、发布、删除、改权限、付款等不可逆操作前,先在对话里跟通哥确认。
- **本文件与项目设计文档并列,构成 doco 子项目宪法。冲突时优先级:本文件 STATUS/交接 > `doco_P3续接快照_C1完工C2起步.md` > `doco_P3_设计稿.md` > `doco_project_design.md`(越新越权威,尤其"LLM 提供方"那条以快照为准)。** 新对话接手时连同上述文档一并阅读。
------
**Claude(Opus 高级技术顾问)与制片人刘通协作原则**
1. 全程使用简体中文。
2. 通哥不懂代码编程,解释要简明扼要,说人话,不堆术语。
3. 为了节省Claude的Token,你主要的工作是plan和审核cline的报告,除非cline3次及以上修改过不去的坎,你可以直接动手改代码。
4. 给 Cline 的指令一律用代码块封装,并明确标 ACT / Plan,方便复制。
5. 回复简明扼要,不解释太多中间过程;**独立核验 Cline 自报结果,对着源数据查证,不轻信口头诊断**(本项目已多次靠核验抓到 Cline 偷换模型/术语源、误判字数)。
6. 长批处理(OCR/ASR)放独立终端跑,不要让 Cline 盯。
7. git 直接推 main,不开分支。
---
## 1. 项目概览
- **项目名**:doco 文稿整理模块(《军事科技》TPS 工作台子模块)
- **目标**:把 **A稿(编导定稿)+ B稿v2(屏幕字幕,P2 产出 743 行)+ ASR(口语实况)** 三方交叉融合,产出两个内容一致、形态不同的交付物:
- **融合B稿**(给爱德华):碎句 + 密集字幕级时间戳 `[XmYs] 文本`
- **融合A稿**(给编导):公文格式 docx,保留【导视】【主持人N】【解说N】【专家N】【隔断】分段
- 一致性约束:融合A稿**由融合B稿生成**(按 A 稿分段归拢 + 套格式),不是事后比对硬凑。
- **范围边界(别越界去做)**:本模块只做"三方文本 → 终版"。**不做** OCR 识别(P2 已产出 B稿v2)、**不做**音频转码本身(属 ASR 适配层)、**不做**档案入库、**不做**编导确认 UI。
---
## 2. 技术栈与运行方式
- **语言**Pythonopenai SDK 走 DeepSeek 兼容端、ffmpeg、python-docx
- **关键目录**
- `src/doco/llm.py` — DeepSeek 统一客户端,`chat(messages, model=None, thinking=None, max_tokens=...)`,从 `doco/.env` 读 key/base_url/model
- `src/doco/term_extract.py` — C1:规则层 + AI 层提取 + 词典累积 + 热词清洗
- `src/doco/asr_adapter.py` — C2 讯飞 ASR 适配层(已完工):`get_hot_words(episode_id)` 读热词表、`transcribe()` 返回 `(句子, raw)``write_asr_result()``asr_v2_timed.txt` + `asr_result_raw.json`
- `src/doco/video_split.py` — P1:含 `extract_audio()`16kHz/单声道/16bit WAV),C2 直接复用,不另写 ffmpeg
- `src/doco/cli.py` — 命令入口(split / terms / asr
- `doco/data/term_dict.json` — 累积词典(当前 110 条)
- `doco/programs/<episode_id>/` — 每期产物(热词表、各阶段输出)
- **常用命令**
- C1(已实现):`doco terms --episode-id <id> --a-script <path> [--no-ai]`
- C2(已实现):`doco asr --episode-id <id> --input-video <绝对路径mp4> --output-dir <绝对路径 doco/programs/<id>>``--skip-asr` 只分离不转写)。**务必传绝对 `--output-dir`,否则落到当前工作目录的 `programs/` 会与 doco 产物分家。**
- C3/C4:待实现
- **环境变量 / 密钥**:只放 `doco/.env`(已在 `.gitignore`)。需要的 key`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` / `DEEPSEEK_MODEL``XFYUN_APP_ID` / `XFYUN_SECRET_KEY`。**代码与对话里只引用变量名,绝不出现真值。**
---
## 3. 当前进度(核心交接区,以最新快照为准)
- **正在做**:C2 已收口,准备进 C3(B稿v2 ⊕ ASR 交叉复审)。
- **卡点 / 待解****C3 输入 `B稿_v2.txt`743行)不在 `doco/programs/ep001_.../` 内,doco 子项目内全盘搜不到** —— P2 产出但未拷进 episode 目录。C3 开工前通哥需把它放进 `doco/programs/ep001_20260612_fangkong_fandao/`
---
## 4. 已完成(只追加,最新在最上)
- [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 稿原文无幻觉,算术对账一致。
- [P2 |本轮之前] 本地 OCRDeepSeek-OCR/Ollama+ 扒词流水线,产出 **B稿_v2.txt 743 行**
- [P1 |本轮之前] 帧筛流水线完工。
---
## 5. 待办(按优先级,做完打勾)
- [ ] **C3(下一步,先解卡点)**:B稿v2 ⊕ 新ASR 交叉复审 → 融合B稿(保持743行碎句粒度 + 密集时间戳原样,ASR只复审纠错不改行结构)+ `fusion_review.csv`。**前置:把 `B稿_v2.txt`743行)放进 `doco/programs/ep001_.../`。**
- [ ] **C4**:融合B稿 + A稿语义对齐 → 融合A稿(公文 docx,用 docx skillGB/T 9704 格式)。
- [x] **C2** 讯飞 ASR 适配层(密钥外置 + asr_adapter 并入 + `doco asr` 命令 + 真转写310句)
- [x] P3-C1 术语提取 + 词典 + 热词表
- [x] P2 本地 OCR + B稿v2
- [x] P1 帧筛
---
## 6. 关键决策(为什么这么做 — 跨 session 最易丢,别推翻)
- [2026-06-17] **C2 音频分离复用 P1 `video_split.extract_audio()`,不另写 ffmpeg。** WAV 落 `programs/<episode>/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**。
- [2026-06-16] **AI 融合层 LLM = DeepSeek v4-pro,不是 Claude。** 制片机无 Anthropic keyDeepSeek key 现成。接入:OpenAI 兼容,`base_url=https://api.deepseek.com``/chat/completions`、模型名走 `DEEPSEEK_MODEL=deepseek-v4-pro`。C1、C4 都用它。**(此条覆盖 `doco_P3_设计稿.md` 里写的 Claude API。)**
- [2026-06-16] **抽取类任务必须关 thinking。** DeepSeek V4 默认开 thinking,不关则正文 `content` 为空(已踩坑)。做法:`chat(..., thinking=False)``extra_body={"thinking":{"type":"disabled"}}`,给足 `max_tokens`C1 用 4000)。**C4 语义融合可开 thinking**(单轮调用,质量优先)。
- [2026-06-16] **旧模型名 `deepseek-chat`/`deepseek-reasoner` 禁用**2026-07-24 退役且现指向 flash)。只认 `deepseek-v4-pro`。Cline 曾擅自退回 deepseek-chat,已纠正。
- [2026-06-16] **C1 术语源 = A稿(规范写法),不是 B稿。** Cline 曾擅自换成 B稿v2,已纠正。
- [2026-06-15] **讯飞用「录音文件转写标准版」,不要大模型版**(免费包阉割 `language` 参数,会报误导性 `language verify fail`)。参数锁定:`pd=mil``eng_smoothproc=true``eng_colloqproc=true``hotWord=<C1热词>``language=cn`。签名 `signa=base64(HmacSHA1(MD5(appid+ts), secretKey))``/upload` 拿 orderId,轮询 `/getResult` 到 status=4。
- [2026-06-15] **B稿v2 权威升级**:屏幕术语/型号/番号 **B稿v2 ≈ A稿 并列权威**(B稿是屏幕实打的字,更贴近播出)。口语/语序/语气信 ASR;书面结构/分段信 A稿;同音事实错(美以→美伊)用 A稿/B稿v2 覆盖 ASR。
- [设计期] **分层不变**:OCR 错字纠正、术语统一、时间戳对齐走**规则层**(确定性);段落归属、整段删除判定、编导笔误识别走 **AI 层**(需语义判断)。
- [设计期] **顺序约束**:术语提取(C1,产热词)必须在 ASR(C2)之前——已成立。
- [工程纪律] 各阶段解耦、中间产物落缓存、可断点续跑、可单独重跑;长批处理放独立终端。
---
## 7. ⏩ 交接备注(写给下一个接手的 session)
> 下次开工读完这段应能 0 摩擦续上。接手后可清空重写。
- **第一句话该干的**:C2 已收口。进 C3 前**先确认 `B稿_v2.txt`743行)已就位**于 `doco/programs/ep001_20260612_fangkong_fandao/`——目前不在,是硬卡点,没它 C3 开不了工。
- **C3 输入已就位的部分**:同目录下 `asr_v2_timed.txt`310句带 `[XmYs]` 时间戳,C2 新产)、`本期热词表.txt`、A稿 docx。缺的就是 B稿v2。
- **C3 目标**B稿v2 ⊕ asr_v2_timed 交叉复审 → 融合B稿(**保持 743 行碎句 + 密集时间戳原样,ASR 只复审纠错不改行结构**)+ `fusion_review.csv`。权威优先级见关键决策「B稿v2 权威升级」那条。
- **重跑真转写命令**(复用已分离 WAV,27分钟音频讯飞轮询等几分钟别中断):
`doco asr --episode-id ep001_20260612_fangkong_fandao --input-video "E:\tps-dashboard\doco\programs\ep001_20260612_fangkong_fandao\现代防空反导大对决VA0.mp4" --output-dir "E:\tps-dashboard\doco\programs\ep001_20260612_fangkong_fandao"`
- 通哥连轴转了几轮,回复要紧凑。
- **已知坑(务必记住)**
1. **A稿真实约 65007300 汉字**(栏目常态 60008000),**不是 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 字体坑**:大标题用方正小标宋(商业字体),出稿前确认制片机/编导机已装,否则脚本里配回退(标宋→宋体加粗)。
---
## 8. 待确认 / 开放问题(需通哥拍板,AI 别自行假设)
- **B稿_v2.txt 就位**(C3 硬卡点):需通哥把 P2 产出的 `B稿_v2.txt`743行)拷进 `doco/programs/ep001_20260612_fangkong_fandao/`。当前 doco 子项目内搜不到。
- ~~**讯飞旧 key 轮换**~~:已完成(新值已在 `doco/.env`,旧 appid `84eff996` 代码内无残留)。
- **方正小标宋字体可用性**:C4 出稿前确认两台机器是否已装。
- **OCR 并发提速**(P2 遗留):`OLLAMA_NUM_PARALLEL` 4–8 路,预计把 ~30 分钟压到十分钟出头;**correctness 全部验证后再做**,现在不动。
- **讯飞单源依赖**(远期):是否接阿里云 ASR 做备份/交叉验证。
+882
View File
@@ -0,0 +1,882 @@
{
"萨德": {
"term": "萨德",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"铁穹": {
"term": "铁穹",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"塔米尔": {
"term": "塔米尔",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"泥石": {
"term": "泥石",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"卡桑": {
"term": "卡桑",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"飞毛腿": {
"term": "飞毛腿",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"箭式": {
"term": "箭式",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"投石索": {
"term": "投石索",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"爱国者": {
"term": "爱国者",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"标准": {
"term": "标准",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"霍拉姆沙赫尔": {
"term": "霍拉姆沙赫尔",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"箭": {
"term": "箭",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"爱国者-3": {
"term": "爱国者-3",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"见证者-136": {
"term": "见证者-136",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"防空": {
"term": "防空",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"反导": {
"term": "反导",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"高超音速武器": {
"term": "高超音速武器",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"弹道导弹": {
"term": "弹道导弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"巡航导弹": {
"term": "巡航导弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"自杀式无人机": {
"term": "自杀式无人机",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"远程防空": {
"term": "远程防空",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"中段反导": {
"term": "中段反导",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"动能撞击": {
"term": "动能撞击",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"相控阵预警雷达": {
"term": "相控阵预警雷达",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"高超音速导弹": {
"term": "高超音速导弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"攻击无人机": {
"term": "攻击无人机",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"高超音速": {
"term": "高超音速",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"子弹头": {
"term": "子弹头",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"远程反导": {
"term": "远程反导",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"中程防空": {
"term": "中程防空",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"中程防空系统": {
"term": "中程防空系统",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"中程防空导弹": {
"term": "中程防空导弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"天基预警卫星": {
"term": "天基预警卫星",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"陆基远程雷达": {
"term": "陆基远程雷达",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"集成化指挥控制中心": {
"term": "集成化指挥控制中心",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"诱饵弹": {
"term": "诱饵弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"干扰弹": {
"term": "干扰弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"近程防空系统": {
"term": "近程防空系统",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"近程防空": {
"term": "近程防空",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"短程防空": {
"term": "短程防空",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"短程防空系统": {
"term": "短程防空系统",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"低空搜索雷达": {
"term": "低空搜索雷达",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"红外传感器": {
"term": "红外传感器",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"无人机蜂群": {
"term": "无人机蜂群",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"远程防空系统": {
"term": "远程防空系统",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"复合诱饵欺骗": {
"term": "复合诱饵欺骗",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"强电磁干扰": {
"term": "强电磁干扰",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"二冲程发动机": {
"term": "二冲程发动机",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"GPS模块": {
"term": "GPS模块",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"惯性制导": {
"term": "惯性制导",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"防空导弹": {
"term": "防空导弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"防空武器": {
"term": "防空武器",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"末端拦截": {
"term": "末端拦截",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"伊朗": {
"term": "伊朗",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"美国": {
"term": "美国",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"以色列": {
"term": "以色列",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"韩国": {
"term": "韩国",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"伊拉克": {
"term": "伊拉克",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"黎巴嫩": {
"term": "黎巴嫩",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"伊斯兰革命卫队": {
"term": "伊斯兰革命卫队",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"国防部": {
"term": "国防部",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"拉斐尔公司": {
"term": "拉斐尔公司",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"国会": {
"term": "国会",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"真主党": {
"term": "真主党",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"主持人1": {
"term": "主持人1",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"专家1": {
"term": "专家1",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"专家2": {
"term": "专家2",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"主持人2": {
"term": "主持人2",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"专家3": {
"term": "专家3",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"专家4": {
"term": "专家4",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"主持人3": {
"term": "主持人3",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"专家5": {
"term": "专家5",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"专家6": {
"term": "专家6",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"主持人 4": {
"term": "主持人 4",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"专家7": {
"term": "专家7",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"专家8": {
"term": "专家8",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"主持人 5": {
"term": "主持人 5",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"爱国者-3防空反导系统": {
"term": "爱国者-3防空反导系统",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"萨德防御系统": {
"term": "萨德防御系统",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"箭-2反导系统": {
"term": "箭-2反导系统",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"箭-3反导系统": {
"term": "箭-3反导系统",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"铁穹近程防空系统": {
"term": "铁穹近程防空系统",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"大卫投石索": {
"term": "大卫投石索",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"标准-6": {
"term": "标准-6",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"泥石导弹": {
"term": "泥石导弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"霍拉姆沙赫尔-4": {
"term": "霍拉姆沙赫尔-4",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"见证者-136自杀式无人机": {
"term": "见证者-136自杀式无人机",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"卡桑火箭弹": {
"term": "卡桑火箭弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"塔米尔拦截弹": {
"term": "塔米尔拦截弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"飞毛腿弹道导弹": {
"term": "飞毛腿弹道导弹",
"domain": "weapon",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"C-17运输机": {
"term": "C-17运输机",
"domain": "model",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"伊朗伊斯兰革命卫队": {
"term": "伊朗伊斯兰革命卫队",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"以色列国防部": {
"term": "以色列国防部",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"本·古里安国际机场": {
"term": "本·古里安国际机场",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"黎巴嫩真主党": {
"term": "黎巴嫩真主党",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"以色列拉斐尔公司": {
"term": "以色列拉斐尔公司",
"domain": "org",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"蓝皓": {
"term": "蓝皓",
"domain": "person",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"饱和攻击": {
"term": "饱和攻击",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"机动变轨": {
"term": "机动变轨",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"多弹头诱饵": {
"term": "多弹头诱饵",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"非对称策略": {
"term": "非对称策略",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"集束弹头": {
"term": "集束弹头",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"极速弹头": {
"term": "极速弹头",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"民用GPS模块": {
"term": "民用GPS模块",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"简易惯性制导": {
"term": "简易惯性制导",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"红外诱饵弹": {
"term": "红外诱饵弹",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"光学红外传感器": {
"term": "光学红外传感器",
"domain": "tech_concept",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"真实承诺4行动": {
"term": "真实承诺4行动",
"domain": "unit_designation",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"真实承诺-4军事行动": {
"term": "真实承诺-4军事行动",
"domain": "unit_designation",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
},
"驻韩国庆尚北道星州基地": {
"term": "驻韩国庆尚北道星州基地",
"domain": "unit_designation",
"episodes": [
"ep001_20260612_fangkong_fandao"
],
"frequency": 1
}
}
+144
View File
@@ -0,0 +1,144 @@
# Doco 文稿整理子项目 Brief
> 主项目 → 子项目的"交接宪法":红线、技术栈、出入口接口
> 起草日期:2026-06-12
> 状态:主项目签发,子项目内部不修改
---
## 一、为什么做
**痛点**:《军事科技》每期节目播出后,产出一份贴近实际播出的终版文稿,过去靠人工对照 A 稿(编导定稿)+ 录音转写 + OCR 字幕反复核对,**单期 4-6 小时**,且容易遗漏错字、整段重写、编导笔误。
**升级目标**:把"三方文本 → 终版文稿 + 差异报告"做成可自动化的流水线模块。
**现状起点**:demo 已用《现代防空反导大对决》一期跑通,效果达标。栏目组改变了素材输出方式——直接产出"黑底白字+干净人声"的特殊视频——使物理输入从 3 路(A 稿+B 稿+ASR)收敛到 2 路(A 稿+视频),但**逻辑上仍然三方融合**(A 稿+B 路视频字幕 OCR+音轨 ASR)。
---
## 二、做什么(功能边界,只列名)
| 模块 | 简述 |
|---|---|
| 视频双路拆分 | ffmpeg 抽帧+字幕变化检测+OCR → B 稿;ffmpeg 抽音轨 → 16k WAV |
| 讯飞 ASR 适配层 | 复用 demo 跑通的 `xfyun_asr_standard.py`,替换凭证 |
| 三方融合引擎 | 规则层(Python)+ AI 层(Claude Sonnet 4.6),demo 已锁 |
| 单期处理接口 | CLI + Python API,契约见主项目回复 §Q3 |
| Golden test | 用 demo 那期视频做零回归验证 |
| 23 期批量调度入口 | 单期接口的批量调用方式,具体编排不在本子项目 |
**具体怎么做,子项目内部讨论,本 Brief 不预设方案。**
---
## 三、怎么用(目标流程)
**单期处理**:
1. 责编/编排层在 episodes 表落库该期,拿到 INT 主键
2. 拼出 episode_id:`ep{NNN}_{YYYYMMDD}_{slug}`
3. 把视频和 A 稿放进 `programs/{episode_id}/source/`
4. 调 doco:`doco.process_episode(episode_id, video_path, a_draft_path, output_dir)`
5. 产物落 `programs/{episode_id}/output/`:终版 docx + 差异报告 + needs_review JSON
6. 编导/制片人审 needs_review 队列,决定接受 / 拒绝 / 编辑
**23 期批量**:用同一接口循环调用 23 次,失败重试 + 进度跟踪由调用方负责。
**降级回退**:若某期视频不符合"黑底白字+干净人声"条件,通过 feature flag 切换到旧流程(独立 mp3 + 上游 OCR)。
---
## 四、不做什么(红线)
子项目设计任何方案,**绝不**触碰以下:
-**不动 demo 已锁的算法层**(三方融合优先级 / 时间戳容忍 ±5s / 口语清理三档 / 改动确信度阈值 0.85/0.6 三段)
-**不引入 `ffmpeg-python` 等 wrapper**(用 subprocess 调系统 ffmpeg)
-**不替换 AI 融合层模型**(Claude Sonnet 4.6 已锁,不临时换 DeepSeek 等)
-**不复用主 project 那把 DeepSeek key**(Cline 工具用的 key 跟生产业务分开)
-**不把凭证写进主 project 的 `backend/.env`**(子模块自治,各自 .env)
-**不假设 episode_id 命名规则**(由调用方给定,doco 只当字符串用)
-**不引入主 project 没有的技术栈**(继续 Python 3.x + Claude API + 讯飞 API)
-**不自己实现批量调度**(失败重试 / 并发控制 / 进度跟踪属于编排层职责)
-**不在子项目里写中台密钥服务**(那不属于 doco 范围)
---
## 五、技术栈约束(继承主 project)
- **运行环境**:Python 3.x(版本对齐 backend),Windows / Linux 都可跑
- **系统依赖**:ffmpeg ≥ 4.x(子模块文档明确列出)
- **AI 融合**:Anthropic Claude API,模型 `claude-sonnet-4-6`
- **OCR**:DeepSeek Vision API(主项目已批,Q1)
- **ASR**:讯飞开放平台 录音文件转写标准版(**不要用大模型版**——`language` 参数被阉割)
- **音频规格**:16kHz / 单声道 / 16bit WAV(讯飞规格)
- **数据格式**:句子级时间戳统一 `[Nm Ns] 句子` 格式
---
## 六、交付什么(出口接口)
子项目最终交付物以下面形式回到主 project,**必须满足以下接口**:
### 6.1 模块交付物
- `doco/` Python 包,可被主 project 编排层 import
- CLI 入口:`doco process ...`
- 单元测试 + golden test(demo 那期零回归)
- `README.md`:系统依赖、安装、使用、配置
### 6.2 配置与凭证
- doco 自己的 `.env`(讯飞 + DeepSeek + Claude 三套 key 各自管)
- 主项目仓库根目录的 `docs/api_credentials_inventory.md` 登记一行 doco 的凭证元信息(不存真实 key)
### 6.3 数据资产沉淀(可入 TPS 中台共享)
- A 稿术语表(每期处理时从 A 稿提取,可入主 project 数据库的 terms 维度)
- OCR 错字映射表(demo 已整理,固定字形混淆对)
### 6.4 不接受
- 子项目自己改主 project 的 backend 代码、schema、迁移
- 子项目自己写主 project 的前端 UI(编导确认 UI 属于上层,不在 doco 范围)
- 子项目自己定 episode_id 命名规则(由主项目给定)
---
## 七、入口接口(子项目要知道的主 project 现状)
- **主项目没有 OCR 子模块**(已确认,Q1)
- **主项目没有视频处理服务**(已确认,Q2)
- **主项目没有节目素材批量编排层**(已确认,Q3)
- **主项目没有中台密钥服务**(已确认,Q4)
- **主项目没有节目素材目录规约**(已确认,Q5,本子项目带头定 `programs/{episode_id}/...`)
- **主项目有 episodes 表**(INT 自增主键),doco 用 episode_id 时假定调用方已落库
---
## 八、未决项(归子项目自己拍板)
以下不在本 Brief 内预设,**由子项目和制片人讨论决定**:
- 视频抽帧的具体策略(关键帧检测算法 / 抽帧密度 / 字幕变化阈值)
- DeepSeek Vision 调用的具体 prompt 设计
- 规则层 OCR 错字映射表的具体扩展机制
- AI 融合层的具体提示词迭代
- needs_review JSON 的具体字段(在 demo 基础上演化)
- 单元测试覆盖率目标
- 子项目内部的 git 分支策略 / 版本号约定
---
## 九、决策史指向(不重复内容)
子项目讨论前必读:
- `doco_project_design.md / .docx`(三方融合算法层,demo 沉淀,已锁)
- `doco_xfyun_integration_notes.md / .docx`(讯飞 ASR 接入 15 个坑)
- `doco_handoff_to_opus_chat.md / .docx`(子项目立项前的 opus 接手提示)
- `PRD_doco_文稿整理子项目_v2.md`(当前子项目的请求稿)
- `主project对Doco_PRDv2的回复.md`(主项目的批复)
---
## 十、本 Brief 自身的修订规则
- 本 Brief 不在子项目内修改
- 红线 / 技术栈如有变更,**主 project 这边发新版**,通过寄存条同步
- 子项目可在自己的 chat 里讨论"建议主 project 调整某条红线",调整动作只能在主 project 发生
- 子项目每次出新版 PRD,主项目顾问发新版回复;两者形成 PRD vN ↔ 回复 vN 的对照关系
@@ -0,0 +1,295 @@
# PRD · Doco 文稿整理模块 · 视频源改造方案
> 版本:v2
> 日期:2026-06-12
> 作者:子 project ClaudeOpus 4.7
> 状态:草稿 / 待主项目审
> 上一版:v1(2026-06-12 同日,更早一稿)
> 主要变化:
> 1. **子模块正式定名为「Doco 文稿整理模块」**(v1 沿用旧称"TPS 三方融合子模块",本版起全部改用 Doco;Q6 已由制片人拍板,不再列为待决问题)
> 2. **Q1(OCR 选型)方向更新**:制片人确认"软件离线刚需"已不成立,云端方案可接受。补充查清的关键事实:**讯飞 OCR 与 STT 共享鉴权凭证但套餐/余额各自独立**——按制片人原判据"如果能通用 token 订阅和余额就选讯飞"不成立。**新推荐方案:DeepSeek API**(制片人手上已有),并向主项目确认是否已有 OCR 能力或 LLM 视觉调用基础设施
> 3. 其他 QQ2/Q3/Q4/Q5)保持不变
---
## 〇、一段话摘要(给主 project 顾问的最短摘要)
Doco 文稿整理模块(前称"TPS 三方融合子模块")此前的物理输入是 A 稿 docx + B 稿 OCR txt + ASR txt(mp3→讯飞)。栏目组现在改了素材输出方式:责编一次性输出 23 期"特殊视频"——画面是黑底白字唱词字幕、声音是无 BGM/音效的干净人声(极少同期声杂音)。**B 稿与 ASR 现在同源派生于一个视频文件**,物理输入从 3 个收敛到 2 个(A 稿 + 视频)。
**核心判断**:下游"三方融合引擎"(规则层 + AI 层)算法**零侵入**——只要新增的"视频双路拆分"模块输出与旧版 B 稿/ASR txt 格式完全一致(`[Nm Ns] 句子`),下游所有逻辑不动。**逻辑上仍然是三方融合**——A 稿术语权威 + B 路视频字幕 OCR + 音轨 ASR,三路独立验证的关系没破。
子项目这边需要主 project 在开工前对 5 件影响接口/数据形态的事拍板。详见 §4。
---
## 一、变更前后对照
### 旧设计(demo 已验证)
```
[A 稿 docx] ──────────────────────┐
[上游既有 OCR 流程] ── B 稿 txt ──┤
├─► [三方融合引擎] ─► 终版.docx + 差异报告.docx
[节目纯净人声 mp3] │
│ │
└─► [讯飞 ASR] ── ASR txt ─┘
```
### 新设计(本 PRD 提出)
```
[A 稿 docx] ─────────────────────────────────────┐
[节目特殊视频文件] │
画面:黑底白字字幕 │
声音:干净人声 │
│ │
├─► [视频双路拆分子模块 · NEW] │
│ │ │
│ ├─► ffmpeg 抽帧+字幕变化检测 │
│ │ │ │
│ │ └─► [OCR] ── B 稿 txt ──────┤
│ │ (待 Q1 拍板用 DeepSeek) ├─► [三方融合引擎] ─► 终版 + 差异
│ └─► ffmpeg 抽音轨 → 16k/mono WAV │ (复用)
│ │ │
│ └─► [讯飞 ASR · 复用] │
│ └────── ASR txt ──────┘
└─ (后续接 23 期批量调度)
```
### 关键变化清单
| 项 | 旧 | 新 |
|---|---|---|
| 子模块名称 | TPS 三方融合子模块 | **Doco 文稿整理模块** |
| 物理输入 | A 稿 + B 稿 + ASR | A 稿 + 视频 |
| 逻辑融合方 | 三方 | **仍然三方**A / B 路 / ASR 路) |
| B 稿来源 | 上游既有 OCR 流程 | 视频抽帧 → OCR |
| ASR 来源 | 节目 mp3 | 视频音轨 |
| 算法层 | 规则层 + AI 层 | **不变** |
| 新增模块 | — | 视频双路拆分(ffmpeg + OCR |
| 处理粒度 | 单期 | 单期接口 + 23 期批量调度 |
---
## 二、子项目这边已经拍板的事(不需要主 project 决策,仅告知)
### 2.1 制片人已明确的决策
1. **子模块定名****Doco 文稿整理模块**"Doco" 取意"Documentation Consolidation"——文稿整合)。本 PRD 起所有引用旧称("TPS 三方融合子模块"、"三方融合")的地方都用新名替换。
2. **离线不再是刚需**:原 v1 推荐 PaddleOCR 本地的核心理由(离线刚需)失效;OCR 选型转向云端。
### 2.2 demo 已验证或设计文档已定义的事
不变:
1. **三方融合优先级**(A 稿术语权威 / ASR 口吻 / B 稿验证)—— 见 project_design.md §5.1
2. **时间戳容忍度 ±5 秒** —— project_design.md §5.2
3. **口语清理三档**keep_all / medium / clean)—— project_design.md §5.3
4. **改动确信度阈值**0.85 自动 / 0.60.85 黄标 / <0.6 待确认)—— project_design.md §5.4
5. **AI 融合层模型**Claude Sonnet 4.6 起步
6. **差异报告格式**demo 黄金对照已落定
7. **A 稿术语表数据结构**project_design.md §9.1
8. **OCR 错字映射表**:复用 demo 整理的字形混淆对
---
## 三、子项目这边的开发计划草案(待主 project 审)
| Phase | 内容 | 产物 | 主依赖 |
|---|---|---|---|
| P1 | 视频双路拆分预处理(单期)| `video_split.py`:视频 → 抽帧+字幕变化检测+OCR → B稿.txt;视频 → 音轨 → 16k WAV | ffmpeg、OCR(待 Q1 拍板,倾向 DeepSeek API |
| P2 | 讯飞 ASR 适配层 | 复用 `xfyun_asr_standard.py`,替换新凭证、做成可被 P1 流水线调用的模块 | 讯飞开发端密钥(待 Q4 拍板) |
| P3 | Doco 融合引擎 | 规则层 Python + AI 层 Claude API;输入 A/B/ASR 三方 txt,输出终版 docx + 差异报告 + needs_review JSON | Claude API |
| P4 | 单期端到端 golden test | 用 demo 那期《现代防空反导大对决》的视频跑一遍,与 demo 产物比对,确认零回归 | 上面三 phase |
| P5 | 23 期批量 | 单期模块包成可被批量调度调用的接口(CLI / Python API 二选一,待 Q3 拍板) | — |
---
## 四、需要主项目决策的 5 个问题
> 每条都给了子项目这边的推荐答案 + 理由。主项目顾问可以照单批,也可以推翻。
### Q1. OCR 方案选型?
**问题**:B 路 OCR 用什么?这是新引入的依赖,绕不开。
#### Q1.A 关键事实(更新自 v1
子项目这边查清了几件事,供主项目决策参考:
1. **讯飞 OCR 与 STT 的共享情况**
- 鉴权凭证(开发者账号、APPID、APIKey、APISecret**可共享**,一组凭证可同时调用多个服务
- 但**套餐/计费/余额各自独立**:STT 是"5 小时免费试用包"独立购买;OCR 是另一套计费体系(参考 SaaS 零售价 4.9 元/10 张、9.9 元/50 张)。**不存在用 STT 的余额跑 OCR 这种事**
- **按制片人原判据("如果能通用 token 订阅和余额就选讯飞")此条件不成立**
2. **制片人手上已有的 API**
- **DeepSeek**:有专门的 `DeepSeek-OCR` 模型(OCR 2.0 方案,对中文文档识别准确率行业领先)+ DeepSeek Vision 识图模式(官方 API endpoint)。**推荐**
- **MiniMax**:有 MiniMax-VL 视觉模型,理论上可 OCR,但 OCR 专项优化不如 DeepSeek-OCR
- **小米 MiMo**:主推推理模型,未见明确的视觉/OCR 能力
3. **本场景的 OCR 难度评估**
- 输入图像是**黑底白字纯字幕画面**——OCR 最简单的子集(高对比度、印刷体、无图案干扰、无版面分析)
- LLM 视觉模型在此场景几乎不会出错;用 LLM 做 OCR 在这里不是杀鸡用牛刀,因为成本极低且无新增依赖
4. **成本估算(23 期总量)**
- 单期约 300 条字幕(demo 数据),23 期 ≈ 6900 张图
- DeepSeek Vision API 单张成本约几厘到一分,**总成本预估 50–150 元**
- 对比讯飞 OCR SaaS 零售价:6900 张 × 0.198 元/张 ≈ 1370 元
- 对比 PaddleOCR 本地:0 元但需要维护本地环境(已不是刚需)
#### Q1.B 选项与推荐
**选项**
- A. **DeepSeek API(视觉模型)**——制片人已有 API,无新增订阅
- B. **讯飞 OCR**——与 STT 同账号但余额独立
- C. **PaddleOCR 本地**——零成本但需本地环境
- D. **主 project 已有的 OCR 服务**——如果存在
**子项目推荐**ADeepSeek API
**理由**
- 满足制片人"用现有 API 资源"的方向
- 黑底白字字幕是 LLM 视觉模型的舒适区,识别质量有保障
- 与讯飞 STT 解耦,单点故障范围小
- 成本最低(除 PaddleOCR)且无本地环境维护成本
**请主项目确认**
- 主 project 是否已有 OCR 子模块/能力?(制片人初步判断"主 project 应该没有 OCR 配置",请顾问最终确认)
- 主 project 的密钥管理体系是否已经接入 DeepSeek?还是需要 Doco 子模块自行管理 DeepSeek 凭证?
- 6900 张图调用 DeepSeek 是否会影响主 project 其他模块的 DeepSeek 配额/限流?
---
### Q2. 视频处理依赖(ffmpeg)的入口?
**问题**:抽帧与抽音轨都依赖 ffmpeg。
**选项**
- A. Doco 子模块自带 ffmpeg 系统依赖 + Python 封装(`ffmpeg-python` 或 subprocess
- B. 主 project 有统一的视频处理服务/适配层
**子项目推荐**A,使用 subprocess 调用系统 ffmpeg
**理由**:ffmpeg 是标准工具,独立部署足够简单。
**请主项目确认**:是否已经有视频处理子模块/服务在规划中?如有,应改走中台接口。
---
### Q3. 23 期批量 vs 单期接口?
**问题**:本次实际场景是 23 期,Doco 子模块的接口应该长什么样?
**选项**
- A. 子模块只负责单期,输入 = 1 视频 + 1 A 稿;批量调度交给主 project 编排层
- B. 子模块内部支持批量,输入 = 视频目录 + A 稿目录
- C. 两者都提供
**子项目推荐**A(子模块只对单期负责)
**理由**
- 单一职责原则。批量调度涉及失败重试、进度跟踪、并发控制——属于编排层职责
- 单期接口在出错时定位简单(哪期失败、失败在哪个 phase 一目了然)
- 主 project 未来可能有节目档案库流水线、报题单流水线等,统一在编排层做并发更合理
**请主项目确认**
- 主 project 是否计划做"节目素材批量处理编排层"?如有,Doco 的单期接口应满足什么样的契约(CLI?Python API?返回值结构?)
- 23 期这次是先单期跑通再批量,还是要求一次性 batch?
---
### Q4. 讯飞密钥更新/续费策略?
**问题**:demo 凭证可能已过期(制片人提示"讯飞试用可能也过了"),且需要区分**消费端**(讯飞听见 App)与**开发端**(开放平台)。
**选项**
- A. Doco 子模块各自申请新凭证、各自管理
- B. 接入中台统一密钥服务(如果有)
- C. 申请新凭证 + 写入"中台密钥资产文档"作为登记
**子项目推荐**C
**理由**:现阶段中台密钥服务可能未完成,但密钥归属、过期日期、所属子模块应该有统一登记入口。
**请主项目确认**
- 中台是否已有密钥管理子模块?
- 新申请的讯飞开发端密钥(包括 APP_ID、SECRET_KEY、5 小时免费试用包激活状态)应该登记在哪里?
- 如果 Q1 拍板用 DeepSeekDeepSeek API key 是否也按同样规则登记?
---
### Q5. 目录结构与命名规约?
**问题**:23 期素材、抽帧产物、wav、终版 docx 的存储路径需要规约,否则后期归档混乱。
**子项目草案**
```
programs/
└── {episode_id}/ # 例:20260612_防空反导
├── source/
│ ├── video.mp4 # 原始特殊视频(黑底白字+干净人声)
│ └── a_draft.docx # A 稿(编导定稿)
├── work/ # 中间产物(可清理)
│ ├── frames/ # 抽出的字幕关键帧
│ ├── audio_16k.wav # 抽音轨标准化产物
│ ├── b_draft.txt # OCR 产物(带 [Nm Ns] 时间戳)
│ ├── asr_result.txt # 讯飞 ASR 产物
│ └── asr_result_raw.json # 讯飞原始返回
└── output/ # 主产物(入档案库)
├── final.docx # 终版文稿
├── diff_report.docx # 差异报告
└── needs_review.json # 待编导确认队列
```
**请主项目确认**
- TPS 中台是否对节目素材的目录有统一规约?
- `episode_id` 命名规则是什么?(日期+栏目+期号?UUID?编导给定?)
- `work/` 中间产物保留多久?是否要自动清理?
---
## 五、不向主项目提问、但需要告知的事
1. **demo 的 5 份 fixture 仍然有效**A 稿 / B 稿 / ASR / 终版 / 差异报告 在新流程下都可作为单元测试与集成测试的黄金对照
2. **讯飞 ASR 脚本仍然有效**`xfyun_asr_standard.py` 只需替换凭证、把"音频文件路径"参数从"独立 mp3"改为"视频抽音轨产物 wav"
3. **23 期之后的常态化问题**:栏目组以后是否每期都按"黑底白字+干净人声"特殊视频出素材?还是只这一批 23 期是特例?这影响 Doco 子模块要不要保留旧的 mp3+独立 OCR 流程作为回退路径。**建议主项目顾问直接跟栏目组确认**
---
## 六、给主项目顾问的"接手提示词"
如果主 project 顾问要审这份 PRD,最重要的三件事:
1. **算法层零侵入是 demo 验证过的硬保证**。任何改动如果会让 P3(Doco 融合引擎)必须改算法,需要警惕——大概率是输入数据形态没对齐。
2. **OCR 选型(Q1)和目录规约(Q5)是会广泛影响其他子模块的事**,主项目顾问视野更大,应该是这两条的最终拍板人。如果主 project 有 OCR 能力或 DeepSeek 集成基础设施,Q1 的子项目推荐随时可被覆盖。
3. **23 期是一次性还是常态化(§5 第 3 条)是个产品问题,不是技术问题**,建议主项目顾问直接跟栏目组确认后再批 PRD。
---
## 七、回复期望
子项目这边等以下确认才正式开干:
- Q1(OCR 选型)—— 必须先答,否则 P1 不能开始
- Q4(讯飞密钥)—— 必须先答,否则 P2 不能开始
- Q5(目录规约)—— 必须先答,否则 P1 输出无处可放
- Q3(接口契约)—— P5 之前必须答
- Q2 —— Doco 子模块可以先按推荐方案开干,主项目随后批
收到主项目 Claude 顾问的回复 md 后,Doco 子项目这边出 PRD v3(含元信息块标注"上一版:v2"),再启动 P1。
---
## 八、版本变更摘要
| 版本 | 日期 | 主要变化 |
|---|---|---|
| v1 | 2026-06-12 | 首版。6 个待决问题(含子模块命名 Q6)。OCR 选型推荐 PaddleOCR 本地 |
| **v2** | **2026-06-12** | 子模块定名"Doco 文稿整理模块"Q1 改推荐 DeepSeek API(基于讯飞 OCR/STT 余额独立的事实查清 + 制片人手上已有 API 资源);Q6 已拍板移出待决列表;剩余 5 个待决问题 |
---
*v2 由 Doco 子 project ClaudeOpus 4.7)于 2026-06-12 拟稿,遵照《跨 project 协作提示单 v1》§三的规范。*
+141
View File
@@ -0,0 +1,141 @@
# Doco 子项目 · P1 完工快照 · 交接给 P2(OCR 阶段)
> 起草:Opus 顾问(本对话)
> 日期:2026-06-15
> 用户:制片人刘通(《军事科技》栏目 TPS 中台主理人)
> 上一份:`doco_handoff_to_next_chat.md`(P1 调试中,bug 未解)
> 状态:**P1 dry-run 已通过自检与人工验收,P1 完工**
---
## 一、一句话总结
P1(视频双路拆分预处理)的抽帧→空白过滤→去重→关键帧流水线已跑通并通过全部验收,可进入 OCR 阶段。OCR 选型方向已更正为**本地部署 DeepSeek-OCR(经 Ollama)**。
---
## 二、P1 做了什么
- **输入**:一期节目视频(黑底白字字幕 + 干净人声)+ A 稿
- **本阶段产物**:`frames/`(308 张字幕关键帧)+ keyframes 数据;音轨/B 稿是后续阶段
- **流程**:ffmpeg 抽帧(1fps,crop 到下方 20% 全宽)→ 空白帧过滤 → dHash + IoU 去重 → 关键帧
- **demo 视频**:《现代防空反导大对决》(26 分钟)
---
## 三、本轮修复的 3 个 bug(都已修好、已验收)
交接前的核心卡点是「`is_blank_frame()` 读的图 ≠ frames/ 里的图」,自检反复中止。逐层挖下去,实际是三个独立 bug:
### bug 1 · off-by-one 索引错位(核心)
- **根因**:`extract_frames()``enumerate()` 从 0 开始编号(frame_index 0-based),但下游清理逻辑和自检逻辑都从文件名提取 1-based 编号(`frame_0008.png` → 8),两套编号差 1。
- **后果**:空白帧因编号错位被错误保留;自检重读图片取到正确像素,却去查 CSV 错误的行 → 报告不一致、中止。
- **不是**"读了不同目录的图"——所有判断函数始终从同一个 frames/ 读图,是编号体系不一致。
- **修复**:改为直接从文件名提取 1-based 编号。commit `bc5a30e`
### bug 2 · 去重计数器恒为 0
- **根因**:`decision_stats[...] = decision_stats.get(..., 0)` 缺了 `+ 1`,每次迭代没递增。
- **后果**:统计永远显示"筛掉 0 张 IoU + 0 张哈希",看起来像去重没生效。**但去重逻辑本身一直是对的**,只是计数显示错。
- **修复**:加 `+ 1`。commit `d55f029`
### bug 3 · 2 个失败的单元测试(均与本次修改无关的旧断言问题)
- `test_subtitle_frame`:切片比例写错(注释说 1% 实际取了 10%),修改前就在失败。改 `arr[:100,:10]``arr[:10,:10]`
- `test_ratio_ok_but_max_brightness_too_low`:亮度阈值从 200→240 后,亮度 220 的像素不再算白像素,旧断言没同步。改断言为 `white_ratio == 0.0`
- **修复**:commit `82973a7`,17/17 全过。
---
## 四、验收数据(已核对,数字闭环)
本次 dry-run 全帧去向(1620 帧):
| 类别 | 张数 |
|---|---|
| 空白帧(blank) | 227 |
| IoU 重复(duplicate-iou) | 321 |
| 哈希重复(duplicate-hash) | 764 |
| 首帧保留(kept) | 1 |
| 关键帧(keyframe-hash) | 307 |
| **合计** | **1620** ✅ |
- 最终关键帧 = 1 + 307 = **308 张**,与 `frames/` 实际文件数一致。
- 旁证:demo 这期约 300 条字幕,关键帧 308 ≈ 字幕数,基本一对一,说明去重准确(每条字幕保留一张、砍掉重复画面)。
- **人工验收**:制片人手动翻遍 frames/,确认无任何纯黑帧。
- **单元测试**:17/17 通过。
---
## 五、遗留小尾巴(不影响完工,有空再收)
- `test_ratio_ok_but_max_brightness_too_low` 这个测试名与它现在实际测的行为对不上了(白像素阈值提到 240 后,原本想测的"白像素够但亮度不够"场景已构造不出来,断言被改成了 white_ratio==0)。建议以后让 Cline 给这个测试改名或重构。纯属测试整洁度,不影响流水线正确性。
---
## 六、重要更正 · OCR 选型(覆盖 PRD v2 的 Q1 表述)
**PRD v2 写的"DeepSeek 有 OCR 模型 + Vision 识图模式(官方 API endpoint)"——这条事实有误,作废。**
查证结果(2026-06-15,DeepSeek 官方文档):
- DeepSeek 官方 API 只有 `deepseek-v4-flash` / `deepseek-v4-pro` 两个模型,**纯文本,不收图像输入**(功能仅:文本对话 / Tool Calls / JSON 输出 / FIM)。Cline 里 DeepSeek 预设没有 image 选项是正常的,不是配置漏项。
- `DeepSeek-OCR`(3B,中文文档识别强)是**开源模型,只能本地部署**(vLLM 或 Ollama),**没有现成云端 API**,需自备 GPU。
- 因此制片人手上的 DeepSeek API key **调不了 OCR**
**新方向(已与制片人确认)**:本地部署 DeepSeek-OCR,经 **Ollama** 运行。
- 理由:制片人有 RTX 4090D 24GB(跑 3B OCR 模型富余);零调用成本、不限量;栏目全年 52 期常态化使用,长期划算;**且制片人另有一个离线小机房,只能用本地方案,云端 API 调不通**。
- Windows 上优先 Ollama(原生支持、免 WSL),不碰 vLLM(Windows 上一般要 WSL,门槛高)。
> 候选备选(若本地部署受阻):MiniMax-VL 云端 API、讯飞 OCR。但离线小机房只能本地。
---
## 七、下一步(P2 / OCR 阶段)
1. **本地部署 DeepSeek-OCR(Ollama)**:在制片人栏目主机上搭起来,确认能识别黑底白字字幕关键帧;之后同样流程部署到离线小机房。
2. **OCR 适配层 `ocr_adapter.py`**:把 308 张关键帧批量送入本地 OCR 服务,产出带 `[Nm Ns]` 时间戳的 **B 稿 txt**(格式必须与旧版完全一致,下游 P3 才零侵入)。
3. **去掉 `--dry-run`**,跑正式 OCR,产出 B 稿 + audio_16k.wav + keyframes.json,P1 整体产物齐活。
4. 之后才进 P2 的讯飞 ASR 适配、P3 的 Claude 融合层。
---
## 八、关键决策(已锁,沿用)
| 项 | 值 |
|---|---|
| 字幕裁切区域 | `crop=iw:ih*0.2:0:ih*0.8`(全宽 × 下方 20%),写死,全年 52 期一致 |
| 抽帧密度 | 1fps |
| 哈希算法 | dHash 阈值 5,pHash 阈值 2 |
| IoU 保底阈值 | 0.95 |
| 空白帧双条件 | `max_brightness >= 240` AND `white_pixel_ratio >= 0.005` |
| 凭证管理 | doco 自管 `doco/.env`,主仓库根 `docs/api_credentials_inventory.md` 只记元信息 |
| **OCR 提供方** | **本地 DeepSeek-OCR(经 Ollama)** ← 本次更正,原 PRD 的"DeepSeek 云端 Vision API"作废 |
| ASR 提供方 | 讯飞录音文件转写标准版(P2 才用) |
| AI 融合层 | Claude Sonnet 4.6(P3 才用) |
| 抽帧/抽音轨 | subprocess 调系统 ffmpeg,不引入 ffmpeg-python |
| Git workflow | 按宪法直接推 main,不开分支 |
---
## 九、环境信息
- Windows + Python 3.12.10 + `.venv`(主项目共享虚拟环境)
- VS Code + Cline 插件(编程模型已从 Minimax M3 换为 **deepseek-v4-pro**,响应更快)
- 仓库根:`E:\tps-dashboard\`(VS Code 必须打开仓库根,不能打开子目录)
- 主仓库:`simonkoson/tps-dashboard`(自部署 git,localhost:3000)
- ffmpeg:winget 安装,系统 PATH
- GPU:**NVIDIA RTX 4090 D 24GB**(本地 OCR 部署用)
- 另有一台**离线小机房**(无外网,本地部署的主要落地环境之一)
- 正确 CLI:`doco split --episode-id XXX --input-video XXX --output-dir XXX [--dry-run]`
---
## 十、本轮 commit(按时间)
| commit | 内容 |
|---|---|
| `bc5a30e` | extract_frames:frame_index 从 0-based enumerate 改为 1-based 文件名提取(核心 bug) |
| `d55f029` | decision_stats 计数加 `+ 1`(去重计数器恒为 0) |
| `82973a7` | 修复 2 个测试断言(切片比例 + 阈值适配),17/17 通过 |
---
*P1 至此完工。下一轮从「Ollama 本地部署 DeepSeek-OCR」开始。*
+118
View File
@@ -0,0 +1,118 @@
# Doco 子项目 · P2 完工快照
> 模块定位:《军事科技》中台组成部分。将节目稿件半成品(A稿)、OCR 扒词文档(B稿)、节目纯净声音(ASR)交叉验证融合,产出最接近播出的终版文稿。
> P2 阶段目标:本地部署免费 OCR + 重写字幕扒词流水线,产出高质量 B 稿。
> 状态:**已完工**。本期(ep001 现代防空反导大对决)B稿_v2.txt 定稿,743 行。
> 快照时间:2026-06-15
---
## 一、本阶段为什么存在 / 解决了什么
接手时,旧帧筛流水线把本期七百多条字幕压成了 **308 张关键帧,悄无声息丢了约六成**,且被"308 张 ≈ 300 条字幕,基本一对一"这个**巧合**骗过了验收。
经逐帧决策表(frame_analysis_debug.csv)定位,根因是**用像素启发式去猜字幕变没变**,两个独立 bug:
- **空白过滤误杀**:`max_brightness>=240 AND white_ratio>=0.005` 把字幕淡入/切换的暗帧判成空场(如"我是主持人蓝皓"被剔除)。
- **dHash 距离去重误并**:字幕版式全相同(黑底白字底部居中),仅文字不同,感知哈希分不开"同一条的不同帧"与"两条不同字幕";阈值 5 把不同内容误判为重复(如"欢迎收看《军事科技》"被砍)。
**结论**:像素相似度无法承担"字幕是否改变"的裁判职责。改为 **OCR 优先 + 按文本去重**——把裁判权交给文本(唯一真相层)。
---
## 二、部署:Ollama + DeepSeek-OCR(可复制到离线小机房)
- DeepSeek-OCR 已是 **Ollama 官方库正式模型**,无需第三方 GGUF。`ollama pull deepseek-ocr`
- 模型 `deepseek-ocr:latest` = `:3b`,**6.7GB**(显存占用约 7.8GB),8K context,Text+Image。
- 要求 Ollama **≥ v0.13.0**;本机实测 0.30.8。
- GPU:制片人主机 RTX 4090D 24GB,`ollama ps` 确认 `100% GPU`
- 调用:`POST http://localhost:11434/api/generate`,body `{"model":"deepseek-ocr","prompt":"Free OCR.","images":[<base64>],"stream":false,"keep_alive":-1}`,读 `response` 字段。
- **Prompt 用 `Free OCR.`**;实测黑底白字单行字幕识别准、标点干净、无需图像预处理。**禁用** `<|grounding|>...markdown`(会输出 bounding box / markdown 污染纯文本)。
### 部署踩坑结论(每条都是教训,务必保留)
- **`keep_alive:-1`**:不设的话模型闲置 5 分钟被踢出显存,下个请求遇重载空档报 HTTP 503。设 -1 永久常驻。
- **Windows 控制台编码**:脚本顶部 `sys.stdout.reconfigure(encoding="utf-8", errors="replace")`;进度打印**只打数字、不打中文**,否则 GBK 控制台 `UnicodeEncodeError` 崩进程。
- **写文件用 UTF-8**:jsonl 写入显式 `encoding="utf-8"`(`ensure_ascii=False` 可选,转义不影响 `json.loads` 解回)。
- **GPU 利用率低是正常的**:单张小图 OCR,GPU 算一下歇一下,空隙是读图/编码/HTTP/写盘。判断在不在 GPU 看 `ollama ps` 的 PROCESSOR 列和显存占用,**不看利用率百分比**。
### 离线小机房搬运(待执行)
小机房无外网,不能 `ollama pull`。在主机 pull 后,整拷 `C:\Users\<用户名>\.ollama\models`(或经 `OLLAMA_MODELS` 指定的目录)到小机房同路径。
---
## 三、流水线架构:两阶段解耦
**设计原则**:Stage A(贵、慢、易中断)与 Stage B(纯文本、秒级、可反复重跑)完全解耦,中间用 `ocr_raw.jsonl` 缓存衔接。这套设计在本期实战中扛住了 Cline 反复掐进程,**一帧 OCR 没有白跑**。
### Stage A — `stage_a_extract_ocr.py`(抽帧 + OCR)
- ffmpeg 1fps 抽帧 + crop 下方 20%(滤镜 `fps=1,crop=iw:ih*0.2:0:ih*0.8`),输出 `frames_v2/frame_%04d.png`(1-based,`t_sec = NNNN-1`)。
- **每帧都 OCR,绝不做亮度判空、绝不做 dHash/IoU 过滤**(这是硬约束,丢六成的元凶)。
- 结果逐帧追加写 `ocr_raw.jsonl`:`{"idx","t_sec","text"}`,异常帧写 `{..,"text":"","error":...}`
- **健壮性三件套**:① 启动读已有最大 idx 断点续跑,绝不从头/覆盖;② 单帧 `try/except Exception + continue`,异常绝不冒泡崩主循环;③ 503/超时指数退避重试。
### Stage B — `stage_b_dedup_output.py`(文本去重 + 出稿)
- 只读 `ocr_raw.jsonl`,可反复重跑调阈值。
- **连续段折叠**(裁判在此,基于文本不基于像素):只合并**时间相邻**帧,文本 difflib 相似度 **≥ 0.85** 视为同一条;**只折叠连续段,绝不全局去重**(片头片尾都出现"军事科技"是两条合法记录)。段内**多数投票**取最终文本,**取最早 t_sec** 为时间戳。
- 出稿前清 markdown 残留:`re.sub(r'^[#*\->`\s]+','',text).strip()`(DeepSeek-OCR 偶发 `# ` 行首标题)。
- 输出:`B稿_v2.txt`(`[XmYs] 文本`,格式同旧 B 稿)、`dedup_debug.csv`(逐帧判决)、`blank_filtered.txt`(被判空场的非空文本存档,供审计)。
### `is_blank_ocr(text)` — 空场判定(三选一即空场)
DeepSeek-OCR 在空白/黑场帧会**幻觉**出固定的英文财报表 `<table>...As of December 31...Total return...</table>`,不返回空串。故空场判定基于文本:
1. `strip()` 后为空串;
2. 含 HTML 标签(`<``>`);
3. 不含任何汉字(`\u4e00-\u9fff`)。
真字幕是纯中文短句,三条都不会误伤。
---
## 四、本期验收数据(ep001)
| 指标 | 数值 |
|---|---|
| 抽帧总数 | 1620 |
| OCR 帧数 / error 帧 | 1620 / **0** |
| 空场数 | 207 |
| 被判空场但非空(去重) | **1 条**(`<table>` HTML 幻觉,正确拦截) |
| **B稿_v2.txt 行数** | **743**(旧 B 稿 742,基本持平) |
| 行首 `#` 残留 | 0 |
**质量结论(对旧 B 稿)**:
- 完整性:从灾难性的 308 → 743,**捞回六成丢失字幕**。两条标志性失踪字幕("欢迎收看《军事科技》""我是主持人蓝皓")均恢复。
- 准确性:归一化后约 589 行与旧稿完全一致;约 130 条差异**绝大多数是新流水线把旧 OCR 错字改对**(盹→吨、肘空→时空、差导→差异、领士→领土、短柝→短板、交镎→交锋、范匡→范围、娈得→变得…),且中文引号更规整。**新 B 稿质量明显高于旧版。**
---
## 五、实战教训(开发期摩擦,非代码缺陷)
- **Cline 反复掐进程**:Cline 作为开发期 agent,天性"检查→行动→再检查",每次"出报告/改加固/重启续跑"都会掐掉前台 OCR 进程,崩点一路从 idx 249→503→756 后移,误以为是 bug。**解法**:让 Cline 彻底松手,改由**制片人自己在独立终端**(`.venv` 激活后 `python stage_a...py`)运行,Cline 碰不到。→ 一次性稳定跑满 1620。
- **进度监控靠文件、不靠终端**:`(Get-Content ocr_raw.jsonl).Count` 数行数判进度;停在某数不动=进程停了,数字在涨=在跑。
- **"503"歧义**:一度把"jsonl 写到 503 行"误读为"HTTP 503",白做一版重试补丁。教训:先看清是行数还是错误码。
> 关键认知:Cline 是脚手架不是运行时。脚本本身是确定性程序,产品化后无人值守运行不会再有被掐现象。本期"自己开终端跑"已提前演练了脱离 Cline 的运行方式。
---
## 六、待办(P2 收尾遗留,进 P3 后并行处理)
- **并发版 Stage A 提速**(制片人已提出):当前严格串行,~30 分钟/期,GPU 大量空转。首选方案:并发 4~8 路请求(`OLLAMA_NUM_PARALLEL`,3B 模型在 24G 卡轻松扛),预计压到十分钟出头。可选叠加"像素完全相同(hash 距离=0)帧复用上帧结果"无损省 OCR。vLLM 原生批量是高上限方案但 Windows 难装、破坏可移植性,非瓶颈不碰。
- **离线小机房模型搬运**(见第二节)。
- **空场清单/审计文件**已落地(`blank_filtered.txt`)。
---
## 七、P3 衔接:三方交叉融合(下一阶段)
**三路输入(本期实测体量与粒度)**:
- **A 稿**(`A稿_..._定稿.docx`,实为 markdown 文本):138 段**散文脚本**,按【导视】【主持人N】【解说N】【专家N】【动画N】【隔断】分段。编辑书面版,**与播出是改写关系**(例:A稿"欢迎来到这一期的《军事科技》节目"→播出"欢迎收看《军事科技》")。**不可逐行 diff**,只宜段落/话题级对齐。
- **B 稿 v2**(`B稿_v2.txt`):743 行**逐条短字幕**,屏幕实况。
- **ASR**(`asr_result_timed.txt`):306 行**句级口语实况**(每行一时间戳起点,常含多句)。
**三方可信维度(P3 融合的核心依据)**:
- A 稿 → 管**结构与书面措辞**(分段、专有名词的规范写法、完整语义)。
- ASR → 管**口语实况**(主持人/专家实际念了什么,含临场改词)。
- B 稿 → 管**屏幕术语拼写**(型号/番号/武器名,如"箭-3""萨德""见证者-136",ASR 常听岔)。
**P3 目标**:以时间戳为轴对齐三方,按各自可信维度投票/择优,产出最接近播出的终版稿。比 P2 复杂,需单独设计。
---
*P2 完工。下一步:P3 设计。*
+146
View File
@@ -0,0 +1,146 @@
# Doco 子项目 · P3 设计稿(三方交叉融合)
> 模块定位:《军事科技》中台终极目标。把 A 稿(脚本)、B 稿 v2(屏幕字幕)、ASR(口语实况)三方交叉融合,产出最接近播出的终版文稿。
> 前置:P1(帧筛)、P2(本地 OCR + 扒词流水线,产出 B稿_v2.txt 743 行)已完工。
> 状态:**设计冻结,待开发**。
> 设计时间:2026-06-15
> 本稿在原 `doco_project_design.md` 基础上,据 P2 成果与制片人最新决策修订。冲突处**以本稿为准**。
---
## 一、两个交付物(内容一致,形态不同)
| 交付物 | 给谁 | 形态 | 用途 |
|---|---|---|---|
| **融合 B 稿** | 爱德华(另一项目) | 逐条碎句 + **密集字幕级时间戳** `[XmYs] 文本` | 字幕/片段定位,时间精度优先 |
| **融合 A 稿** | 编导 | **公文格式 docx**,保留【导视】【主持人N】【解说N】【专家N】【隔断】分段 | 最像播出版的存档稿,方便编导复用 |
**一致性约束**:融合 A 稿**由融合 B 稿生成**(按 A 稿分段结构归拢 + 套格式),所以两者内容天然一致——是"构造出来的一致",不是事后比对硬凑。
---
## 二、流水线四阶段(注意依赖顺序:术语提取必须在 ASR 之前)
```
A稿 ──► ①术语提取·更新词典 ──► 本期热词表
视频 ──► ②分离音频·转码WAV ──► 讯飞STT(热词+pd=mil) ──► 新ASR(带时间戳)
B稿v2 ⊕ 新ASR ──► ③交叉复审 ──► 融合B稿(碎句·时间戳·给爱德华) + fusion_review留痕
融合B稿 + A稿(语义对齐分段) ──► ④LLM语义融合 ──► 融合A稿(公文docx·给编导)
```
沿用 P2 的工程纪律:**各阶段解耦、中间产物落缓存、可断点续跑、可单独重跑**。
### Stage C1 — 术语提取 + 词典更新(规则层 + 轻量 AI)
- 从 A 稿提取专有名词(武器/型号/番号/人名/机构/技术概念),并入中台**累积词典**。
- 词典字段:`term / domain / first_seen_episode / frequency`。**逐期累积,越用越准**。
- demo 词典(34 条热词)作为**基线**,本期在其上增量更新(如 F/A-18E/F 大黄蜂、歼-10、黄貂鱼无人加油机…)。
- 产出本期**热词表**(讯飞上限 200 个,每个 2–16 字,`|` 分隔)供 Stage C2 用。
### Stage C2 — 音频分离 + 讯飞 ASR(适配层)
- ffmpeg 从视频分离音频 → 转码 **16kHz / 单声道 / 16bit WAV**(讯飞硬要求,否则报误导性 `language verify fail`)。
- 调讯飞「**录音文件转写(标准版)**」(**不要大模型版**,免费包阉割 `language` 参数)。参数:`pd=mil``eng_smoothproc=true``eng_colloqproc=true``hotWord=<C1热词>`
- 签名:`signa = base64(HmacSHA1(MD5(appid + ts), secretKey))`。上传 `/upload` 拿 orderId,轮询 `/getResult` 到 status=4。
- 产出新 ASR(句级,带时间戳,格式 `[XmYs] 文本`),落缓存 `asr_v2_timed.txt` + 原始 json。
- 起点代码:`xfyun_asr_standard.py`(demo 已跑通),按下方安全要求改造。
### Stage C3 — B⊕ASR 交叉复审 → 融合 B 稿(给爱德华)
- **形态:甲 — 保持 B稿v2 的碎句粒度(743 行),密集字幕级时间戳原样保留**;ASR 只做复审纠错,**不改变行结构、不并到句级**。
- 复审做三件事:① 用 ASR 比对查 B 稿有没有重复行/错漏;② 屏幕术语拼写以 B稿v2 为准(见优先级表);③ B 稿明显 OCR 错字而 ASR 对的,用 ASR 覆盖。
- **不机械逐字标记**:由 LLM 读语义判断"是否真分歧",一两个字的等价差异不算分歧。
- 产出:`融合B稿.txt`(碎句+时间戳) + `fusion_review.csv`(机器做了选择/拿不准的地方留痕)。
### Stage C4 — 语义融合 → 融合 A 稿(给编导)
- 拿融合 B 稿,**按 A 稿分段结构语义对齐**归拢成段(参照未融合的 A 稿语义,不是机械时间切分)。
- LLM 读 A 稿语义,理解后重组为"最像播出版"的段落文本,套公文格式。
- **全自动,不阻塞主流程**:拿不准的段落照常产出 + 进 `fusion_review`,不卡住出稿。
- 产出:`融合A稿.docx`(公文格式)。
---
## 三、三方权威优先级(据 P2 修订 —— B 稿地位升级)
> **关键变化**:原设计把 B 稿定为"OCR 错字多,仅作交叉验证,权威最低"。P2 后 DeepSeek-OCR 产出的 B稿v2 屏幕术语拼写又准又干净,故 B 稿**升级加权**。
| 看什么 | 信谁 | 说明 |
|---|---|---|
| **屏幕术语 / 型号 / 番号**(箭-3、萨德、见证者-136) | **B稿v2 ≈ A稿 并列权威** | B 稿是屏幕实际打出的字,常更贴近播出;A 稿是规范写法。两者一致则采用,冲突进 review |
| 口语实况 / 语序 / 语气 | **ASR** | 主持人/专家实际怎么念(含临场改词) |
| 书面结构 / 完整语义 / 分段 | **A 稿** | 编导书面版,管【…】分段与段落语义骨架 |
| 同音事实错(美以→美伊) | **A 稿 / B稿v2** 覆盖 ASR | ASR 同音误识,句子通顺但语义荒谬,靠 A/B 发现 |
| 整段是否存在 | **B稿v2 + ASR 联合**判定 | 两者都无 → A 稿该段为未播出,标记删除 |
---
## 四、融合 A 稿公文格式(Stage C4 出稿规范)
| 层级 | 字体 | 字号 / 其他 |
|---|---|---|
| 大标题 | **方正小标宋** | — |
| 一级标题 | **黑体** | — |
| 二级标题 | **仿宋,加粗** | — |
| 正文 | **仿宋** | **四号(14pt),1.25 倍行距** |
> ⚠️ 字体可用性:方正小标宋是商业字体,生成 docx 的机器若未安装,Word 会静默回退、版式垮掉(同字体类坑)。出稿前须确认制片机/编导机已装方正小标宋(公文常用,多半有);否则在脚本里配置回退字体(如标宋→宋体加粗)。用 docx skill 实现时按 GB/T 9704 党政机关公文格式参照。
---
## 五、留痕与人工策略
- **全自动产出,绝不阻塞主流程**。编导拿到的是干净成品,无需做对齐/确认工作。
- 机器做了选择或拿不准的地方(B/ASR 分歧、低把握段落改写、疑似整段删除、疑似编导笔误)**全部甩进 `fusion_review.csv` 留痕**,供编导/爱德华事后复核,不卡出稿。
- 改动类型枚举沿用原设计:`unchanged / minor_edit / term_normalize / rewrite_large / segment_delete / segment_add / editor_typo`;带 confidence,低把握的进 review 文件。
---
## 六、安全:密钥外置(开发第一件事)
- **现状问题**:`xfyun_asr_standard.py` 第 41-42 行明文硬编码了讯飞 APP_ID / SECRET_KEY,且该文件已进 project,凭证已暴露。**建议在讯飞控制台轮换该密钥**。
- **正式做法**:凭证只存制片机本地(`xfyun_credentials.json` 或环境变量,且加入 `.gitignore` 不进库),脚本运行时读取。代码与对话中**只引用变量名,绝不出现真值**。
- 三套密钥互不相通:讯飞**消费端**(听见 App)≠ 讯飞**开发端**(开放平台,本模块用)≠ Claude API。本模块用开发端 + Claude API。
---
## 七、外部依赖
- **讯飞开放平台**「录音文件转写(标准版)」—— 开发端密钥已就绪(制片机本地)。
- **Claude API**(Stage C4 语义融合)—— 建议 `claude-sonnet-4-6` 起步;单期上下文 A稿~6k + ASR~5k + B稿~5k ≈ 16k 字,窗口充裕;每期 1–3 次调用。
- **本地 DeepSeek-OCR / Ollama** —— 由 P2 提供 B 稿,本阶段不直接调。
- **ffmpeg** —— 音频分离 + 转码。
---
## 八、数据资产(中台共享,逐期累积)
1. **A 稿术语表 / 词典**(核心):讯飞热词来源 + 规则层纠错字典,双重身份;逐期累积,识别质量滚雪球上升。基线 = demo 34 热词。
2. **OCR 错字映射表**:肘→时、娈→变、夭→天… 随版本扩展(P2 已观察到 DeepSeek-OCR 错字远少于旧引擎,此表主要兜底)。
3. **编导风格档案**(远期):累积每位编导删改偏好,用于参数自适应。
---
## 九、待办与风险
- **并发版 Stage A 提速**(P2 遗留):OCR 串行 ~30 分钟/期,GPU 空转;首选并发 4–8 路(`OLLAMA_NUM_PARALLEL`),预计压到十分钟出头。P3 期间并行处理。
- **离线小机房模型搬运**(P2 遗留):拷 `.ollama/models` 文件夹。
- **方正小标宋字体可用性**:出稿前确认,见第四节。
- **讯飞单源依赖**:远期接阿里云 ASR 做备份/交叉。
- **整段删除/编导笔误判定**:高风险,全自动产出但务必进 review 留痕。
---
## 十、本期可复用资产(demo / P2 产物)
| 文件 | 用途 |
|---|---|
| `B稿_v2.txt`(743 行) | Stage C3 输入(屏幕字幕,P2 产出) |
| `A稿_..._定稿.docx`(实为 md 文本) | Stage C1 术语源 + Stage C4 分段语义参照 |
| `xfyun_asr_standard.py` | Stage C2 起点代码(须先密钥外置) |
| demo 词典(34 热词) | Stage C1 词典基线 |
| 源视频 | Stage C2 音频源 |
| `现代防空反导大对决_终版.docx` / `差异报告.docx` | 集成测试黄金对照 |
---
*P3 设计冻结。下一步:按 Stage C1→C4 拆任务给 Cline,沿用解耦+缓存+可重跑工程纪律。*
+216
View File
@@ -0,0 +1,216 @@
# Doco 子项目续接快照 · 交接给下一轮 Opus 顾问
> 起草:Opus 4.7 顾问(算力即将耗尽)
> 日期:2026-06-12
> 给:接手本对话的下一个 Opus 实例
> 用户:制片人刘通(《军事科技》栏目 TPS 中台主理人)
---
## 一、先看哪些文件(读完这几份再接话)
主 project 知识库里这几份**必读**:
1. `Claude协作原则.md` — 跟制片人说话的规矩(简体中文、说人话、不堆叠技术语言、Cline 指令要代码块、宪法第 3 条不架空 Cline 的 Plan 模式)
2. `Doco子项目_Brief.md` — Doco 子项目宪法,红线全在这
3. `doco_project_design.md` — Doco 算法层设计(P3 阶段才用,但先了解全貌)
4. `主project对Doco_PRDv2的回复.md` — 主项目顾问对 5 个问题的批复
5. `跨子项目协作规则.md` — PRD 版本管理
---
## 二、当前进度坐标
- **阶段**:Doco P1(视频双路拆分预处理)
- **节点**:dry-run 调试,**核心 bug 未解决,不能进入 OCR 阶段**
- **git 状态**:多个 commit 已推 origin/main,按宪法直接推 main 不开分支
P1 任务回顾:
- 输入:一期节目视频(黑底白字字幕,干净人声)+ A 稿
- 输出:B 稿 txt + audio_16k.wav + keyframes.json
- 流程:ffmpeg 抽帧(1fps,crop 到下方 20%全宽)→ 空白帧过滤 → dHash+IoU 去重 → 关键帧 → (OCR)→ B 稿
---
## 三、当前 bug 状态(最重要)
### 已确认未修复的核心 bug
**`is_blank_frame()` 读取的图像 ≠ `frames/` 目录里最终保存的图像**
证据链:
1. 制片人上传 `frame_0008.png`(从 frames/ 直接复制)
2. Opus 用 Python 实测:整张图 414720 像素**全部为 0**(`arr.max() == 0`)
3. CSV 报告 frame_8:`max_brightness=255, white_pixel_ratio=0.0124`
4. **两者无法同时为真** → 检测时读的不是这张图
最新 dry-run 自检也证实了这点:
```
[WARNING] 自检失败: frame_0938.png 像素数据与 CSV 不一致!
CSV: max_brightness=255, white_ratio=0.007603
实际重读: max_brightness=255, white_ratio=0.022852
[error] 已中止
```
### Cline 已经修了但没修对的事
Cline 把这个 bug 解释成"CSV 列写入顺序错位",但**那只是表层**——
列对齐改完后,frame_8 的 CSV 数据 `max_brightness=255、white_ratio=0.0124` 仍然跟"全 0 像素"对不上。
Cline 已修复(列错位)是真问题,但**根本原因没找到**。
### 推测根因(交接给你)
很可能 video_split.py 的抽帧流程是分两步,而不是 ffmpeg 一步搞定:
- ffmpeg 把帧抽到某临时目录(可能是全尺寸 1920×1080,**没**裁切)
- Python 用 PIL 后裁切到 1920×216 再存到 frames/
- `is_blank_frame()` 读的是裁切前的临时全画面(里面有节目画面有亮像素 → max=255)
- `frames/` 里是裁切后的小图(字幕区域纯黑 → 像素全 0)
但这只是**推测**,你应该让 Cline 自查代码确认。
---
## 四、下一步建议指令(给你参考,可调整)
发给 Cline 的 ACT 模式指令,大致这样:
```
[ACT 模式]
P1 dry-run 自检揪出严重 bug:is_blank_frame() 读的图 ≠ frames/ 里保存的图。
证据:
1. 制片人确认 frame_0008.png 是全 0 纯黑(Python 实测 arr.max()=0)
2. CSV 仍报告 max_brightness=255, white_ratio=0.0124
3. dry-run 自检也确认 frame_0938.png 数据不一致
上次"列错位"修复是真 bug 但不是这次的根因,这是另一个独立的 bug。
请逐行排查 video_split.py 的抽帧流程,回答以下问题(在汇报里贴具体代码):
1. ffmpeg 命令是什么?是否真的有 vf "fps=1,crop=iw:ih*0.2:0:ih*0.8" 过滤器?
2. ffmpeg 输出到哪个目录?
3. is_blank_frame() 接收的 image_path 是哪个目录?
4. compute_hash() / compute_binary_matrix() 接收的 image_path 是哪个目录?
5. 最终保存到 frames/ 的图,是从哪里来的?
如果发现某个判断函数读了"非 frames/ 里的图",修复方法:
- 选项 A:让 ffmpeg 用 vf crop 直接输出裁切后的小图到 frames/,
所有判断函数都从 frames/ 读(单一数据源)
- 选项 B:保持当前流程,但把所有判断函数的 image_path 改为 frames/ 里的路径
修复后重新跑 dry-run,自检通过 + 制片人验证 frames/ 里没纯黑帧才算成功。
不要再加新功能,这次只修这一个 bug。
```
---
## 五、协作风格备忘
- **制片人不懂代码**,你要"说人话",不堆叠 Python 术语
- **简明扼要**,他明确要求不要解释中间过程
- **给 Cline 的指令一律用 ``` 代码块封装**,方便他复制粘贴
- **明确标 ACT 或 Plan 模式**——前几轮制片人提醒过这点
- 不要架空 Cline 的 Plan 模式(宪法第 3 条),但小 bug 修复可以直接 ACT
- 制片人现在的疲劳度比较高(连续 dry-run 多轮),回复要紧凑,不要长篇大论
- 制片人的 git 工作流:直接推 main 不开分支(宪法规定)
---
## 六、关键决策(已锁,不能动)
| 项 | 值 |
|---|---|
| 字幕裁切区域 | `crop=iw:ih*0.2:0:ih*0.8`(全宽 × 下方 20%),写死,栏目全年 52 期一致 |
| 抽帧密度 | 1fps |
| 哈希算法 | dHash 默认,阈值 5 |
| IoU 保底阈值 | 0.95 |
| 空白帧双条件 | `max_brightness >= 240` AND `white_pixel_ratio >= 0.005` |
| 凭证管理 | doco 自管 `doco/.env`,主仓库根 `docs/api_credentials_inventory.md` 只记元信息 |
| OCR 提供方 | DeepSeek Vision API(P1 dry-run 阶段还没正式调用) |
| ASR 提供方 | 讯飞录音文件转写标准版(P2 才用) |
| AI 融合层 | Claude Sonnet 4.6(P3 才用) |
| 不引入 ffmpeg-python wrapper | subprocess 调系统 ffmpeg |
| Git workflow | 按宪法直接推 main |
---
## 七、环境信息
- Windows + Python 3.12.10 + `.venv`(主项目共享虚拟环境)
- VS Code + Cline 插件
- 仓库根目录:`E:\tps-dashboard\`
- 主仓库:`simonkoson/tps-dashboard`(自部署 git,localhost:3000)
- ffmpeg:已通过 winget 安装,系统 PATH
- demo 视频:`programs/ep001_20260612_fangkong_fandao/source/video.mp4`(《现代防空反导大对决》26 分钟)
doco 子模块目录结构(已建好):
```
doco/
├── src/
│ └── doco/ ← src layout 的包目录
│ ├── __init__.py
│ ├── cli.py
│ ├── video_split.py ← 当前 bug 在这
│ ├── asr_adapter.py ← P2 用
│ └── ocr_adapter.py ← P2 用
├── tests/
│ ├── test_video_split.py
│ └── fixtures/
│ └── mini_test.mp4
├── .env.example
├── .env(本地,不进 git)
├── pyproject.toml
└── README.md
```
---
## 八、踩过的坑(不要重蹈)
1. **Python 包配置 src layout**:Cline 第一次配置时忘了在 src/ 下建 doco/ 子目录,导致 `doco --help``ModuleNotFoundError`。后来 git mv 文件到 src/doco/ 下才好。
2. **系统 Python vs .venv**:制片人第一次 `pip install -e doco/` 装到了系统 Python 3.12,而不是 .venv 里。VS Code 重启后 .venv 自动激活,需要在 .venv 里重装。
3. **VS Code 工作目录**:必须打开 `E:\tps-dashboard\`(仓库根目录),不能打开 `docs\` 子目录,否则 Cline 看不到 .clinerules 和其他子模块。
4. **ffmpeg PATH 不生效**:winget 装完 ffmpeg 后,旧的 VS Code 终端 PATH 还是旧的,要完全关闭 VS Code 重开。
5. **Cline 的命令示例有错**:Cline 多次给的命令示例是错的(用 `python -m doco.src.doco.cli` + `split-video` 子命令),正确命令是 `doco split --episode-id XXX --input-video XXX --output-dir XXX [--dry-run]`
6. **frames/ 目录的语义**:Cline 第一次实现把 1620 张原始抽帧全部保留在 frames/,让制片人误以为"筛选没生效"。后来改为只保留最终关键帧。
7. **pHash 阈值**:dHash 阈值 5,pHash 阈值 2,IoU 阈值 0.95——这些是实测调好的,不要再动。
8. **CSV 列对齐**:Cline 加列时只改 header 没改写入顺序导致全部错位。这次修复了,但**不是核心 bug**。
---
## 九、最新一次 dry-run 的统计(自检失败那次)
```
[stats] 原始抽帧: 1620 张
[stats] 空白帧过滤后: 1393 张 (筛掉 227 张纯黑)
[stats] pHash/dHash 去重后(IoU保底): 308 张 (筛掉 0 张IoU相同 + 0 张哈希相同) ← 统计 bug 还在
[stats] 最终关键帧: 308 张
[debug] frames/ 目录实际文件数: 308 (预期: 308)
[WARNING] 自检失败: frame_0938.png 像素数据与 CSV 不一致!
[error] 已中止
```
注意:
- "筛掉 0 张IoU相同 + 0 张哈希相同" 这个统计还是 0,说明**之前那个 stats 计数 bug 也没真正修好**——或者是这次新加的双条件改变了流程,导致 IoU/hash 没被触发(走了空白帧分支?)。需要复查。
最近的 commit(按时间倒序):
1. Cline 双条件 + 自检 + 测试 + CSV 列对齐
2. Cline numpy 加速 + CLI 参数
3. Cline IoU 保底 + dHash + 诊断 CSV
4. Cline 第一次修空白帧检测(用 brightness=200,后来发现不够,改 240)
5. Cline 加 crop 写死 + dry-run flag
6. Cline 初始化 doco 子项目 P1
---
## 十、给下一个 Opus 的最后嘱托
- 这个 bug 排查过两轮了(Cline 都没真正定位到),所以这次**让 Cline 自查代码并贴出关键函数的实际代码**,不要轻信他的口头解释
- 制片人有耐心,但已经累了,你接手后**第一句话就给方案,不要重复诊断**
- 修完这个 bug 后,P1 dry-run 应该能通过自检,接下来就是填 DeepSeek API key 跑正式 OCR(去掉 --dry-run)
- 如果 dry-run 通过,P1 就算完工,可以进入 P2(讯飞 ASR 集成)
祝接力顺利。
@@ -0,0 +1,214 @@
# 主 project 对 Doco PRD v2 的回复
> 来源:TPS 主 project 顾问(Claude Opus 4.7)
> 日期:2026-06-12
> 针对:`PRD_doco_文稿整理子项目_v2.md` 第四章 5 个问题
> 性质:正式批复,Doco 子项目据此出 v3 后启动 P1
---
## 总体意见
**5 个问题的子项目推荐**,**整体方向 4 个批准、1 个调整**:
- Q1 OCR 选型:**批准 DeepSeek API,但有附加条件**(见下)
- Q2 ffmpeg 入口:**批准,有补充约束**
- Q3 单期 vs 批量接口:**批准,加交付契约**
- Q4 密钥管理:**调整方案** — 走"自管 + 中台登记 inventory"
- Q5 目录规约:**批准草案,但 episode_id 命名规则需主项目定**
5.3 §"23 期是否常态化":**这是一个产品问题,我代制片人答不了。子项目可以按"先按 23 期一次性做完、保留兜底回退路径"开干,常态化的事跟栏目组确认后再回头审。**
---
## Q1. OCR 方案选型 — 批准 DeepSeek API
### 主 project 现状
- **主 project 没有 OCR 子模块/能力**(已确认)。旧设计里 B 稿来自"上游既有 OCR 流程",指的是栏目组那边人工跑的 OCR,不是 TPS 内部的服务。
- 主 project `backend/.env` 里有 `DEEPSEEK_API_KEY`,但**只用作 Cline 工具的 Plan/Act 模型**——没有任何业务代码在调 DeepSeek。所以 doco 调 DeepSeek 视觉模型相当于**新增一条业务调用**,不是"复用现有基础设施"。
### 批复
**Doco 子模块新增 DeepSeek Vision 调用,自管 API key。**
- API key 独立申请,**不复用 Cline 用的那把 key**。理由:Cline 的 key 是开发工具用的,doco 是生产服务,生命周期、配额、监控都该独立。
- 6900 张图 × 几厘 / 张 ≈ 50-150 元,在制片人手上的 API 余额范围内,**不存在配额冲突问题**(主 project 业务代码侧没人在花)。
- 黑底白字字幕场景对 LLM 视觉模型确实是舒适区,**实施前先在 demo 那期视频上跑 10 帧抽样,验证识别准确率达到目标后再扩到 23 期**。
- **降级路径**:如果 DeepSeek Vision 在某些边角 case 上不达标(比如有同期声同步显示双行字幕、字号变化等),回退到 PaddleOCR 本地。**不要回退到讯飞 OCR**(余额独立 + 单期 ~13 元成本太高)。
---
## Q2. 视频处理依赖(ffmpeg)入口 — 批准方案 A
### 主 project 现状
- **没有视频处理子模块/服务**(已确认),dev_plan 也没规划。
### 批复
**Doco 自带 ffmpeg 系统依赖 + Python subprocess 调用。**
附加约束:
- **不引入 `ffmpeg-python` wrapper**——subprocess 足够,不增加新依赖。
- ffmpeg 命令封装在 `doco/video_split.py` 内,**对外只暴露 Python 函数接口**,不让上层调用方直接 shell 调 ffmpeg。
- 在 doco 的 `README.md` 明确写"系统依赖:ffmpeg ≥ 4.x"——这是部署的前置条件。
- 未来如果主 project 真做了统一的视频处理服务,**doco 的封装方式应该使迁移成本最低**(把 subprocess 调用换成 HTTP 调用即可,业务逻辑不动)。
---
## Q3. 23 期批量 vs 单期接口 — 批准方案 A,加契约
### 主 project 现状
- **没有节目素材批量处理编排层**(已确认)。dev_plan 提到的"批量导入工具 v1/v2"是收视数据和报题单的批量,跟视频处理是两回事。
### 批复
**Doco 只对单期负责。同时提供 CLI 和 Python API 两套接口。**
**接口契约(子项目正式实现时按这个出)**:
```python
# Python API
def process_episode(
episode_id: str, # 调用方给定,doco 不管命名规则
video_path: Path, # 输入视频
a_draft_path: Path, # A 稿 docx
output_dir: Path, # 产物存放目录
cleanup_level: str = "medium", # keep_all / medium / clean
) -> ProcessResult
# ProcessResult 必含字段
@dataclass
class ProcessResult:
episode_id: str
status: Literal["success", "partial", "failed"]
final_docx_path: Optional[Path]
diff_report_path: Optional[Path]
needs_review_json_path: Optional[Path]
errors: List[str]
stage_timings: Dict[str, float] # 哪个 phase 用了多久
```
```bash
# CLI 等价形式
doco process \
--episode-id ep023 \
--input-video /path/to/video.mp4 \
--input-a-draft /path/to/a_draft.docx \
--output-dir /path/to/output \
--cleanup-level medium
```
**关于 23 期推进**:**先单期跑通再批量**——拿 demo 那期《现代防空反导大对决》视频做 golden test(P4),与 demo 已有产物比对零回归,再扩到 23 期。一次性 batch 23 期风险太大。
未来主 project 若开节目素材批量处理编排层,**会反过来调 doco 的单期接口**,不会改 doco。
---
## Q4. 讯飞密钥管理 — 调整方案:自管 + Inventory 登记
### 主 project 现状
- **没有中台密钥管理子模块**(已确认),也不在 dev_plan 排期内。
- 主 project 凭证都在 `backend/.env`,各模块走环境变量。
### 批复
不走子项目推荐的"申请新凭证 + 写入中台密钥资产文档"(因为中台密钥服务不存在,不能凭空捏一个)。改为:
**1. Doco 自管凭证。** 讯飞、DeepSeek 各自的 API key 放 doco 自己的 `.env`,**不放进主 project 的 `backend/.env`**——保持子模块自治。
**2. 主 project 仓库根目录新增一份登记文档:`docs/api_credentials_inventory.md`**
这份文档:
- **不存储真实凭证**(那些在各自子模块的 .env 里)
- 只存储**元信息**:每个外部 API 用了什么、所属子模块是谁、开通时间、激活状态、到期日、续费责任人
- 子模块申请新 key、key 到期更换,都更新这份文档
- 这是主 project 帮所有子模块做"凭证清单"的最小落地——比起"中台密钥服务",轻得多,但解决"忘了谁管哪把 key"的问题
**3. Inventory 文档的字段**(供子项目和未来其他子模块参考):
```markdown
| 子模块 | API 服务 | Key 类型 | 开通日 | 激活状态 | 到期日 | 责任人 | 备注 |
|---|---|---|---|---|---|---|---|
| doco | 讯飞开放平台 - 录音文件转写 | APP_ID + SECRET_KEY | 2026-06-12 | 已激活 5h 试用包 | 2027-06-12 | 制片人 | demo 凭证已过期,新申请 |
| doco | DeepSeek Vision | API_KEY | 2026-06-12 | 已激活 | — | 制片人 | doco OCR 用 |
| (主) | Anthropic Claude API | API_KEY | ... | ... | ... | ... | AI 融合层用 |
```
**4. 制片人现在要做的事**(代答 Q4 里"新申请的讯飞开发端密钥应该登记在哪里"):
- 去讯飞开放平台 console 申请新的开发端凭证
- 走 0 元购买流程激活 5 小时免费试用包
- 把 APP_ID / SECRET_KEY 写到 doco 的 .env 里
-`docs/api_credentials_inventory.md` 登记一行
---
## Q5. 目录结构与命名规约 — 批准草案,episode_id 命名由主项目定
### 主 project 现状
- **没有节目素材目录的统一规约**(已确认)。主 project 处理的是数据维度的 `episodes` 表,episode_id 是 INT 自增主键。
- 但是,**素材文件名跟数据库 ID 是两层东西**,不能直接套。
### 批复
**子项目提的 `programs/{episode_id}/source|work|output/...` 结构批准。**
**关于 episode_id 命名规则**——这是主项目要定的规约,定如下:
**episode_id 格式:`ep{NNN}_{YYYYMMDD}_{slug}`**
- `ep{NNN}` = episodes 表自增 INT 主键,零填充到 3 位(如 ep023)
- `{YYYYMMDD}` = 首播日期
- `{slug}` = 节目名称的拼音缩写或简称中文(无空格、无特殊字符)
举例:
- `ep023_20260612_haishang_xianfeng`
- `ep007_20260201_马年军事图鉴`
**理由**:
- INT 主键稳定可关联到 episodes 表
- 日期方便文件系统按时间排序
- slug 让人眼可读
**约定**:
- episode_id **由调用方给定**(主项目编排层 / 责编 / CLI 调用者),doco 不生成
- doco 只把它当一个字符串使用
- 主项目责编/编排层在调 doco 前**先在 episodes 表落库该期、拿到 INT ID**,再拼出 episode_id
**work/ 中间产物保留时长**:
- 节目入档案库后保留 **30 天**,然后可清理(由编排层负责,doco 不管)
- 出问题需要复查时,30 天窗口够定位
---
## §5 第 3 条:23 期是常态化吗?
**这是产品问题,不是技术问题。我代制片人答不了。**
**建议子项目的处理**:
- 按"23 期一次性"开干,先把这批做完
- 算法层零侵入是硬保证,即使将来变常态化,流水线也能继续跑
- **保留旧的 mp3+独立 OCR 流程作为回退路径**(代码不删,加 feature flag 或环境变量切换)。万一某期视频不符合"黑底白字+干净人声"特殊条件,降级到旧流程
- 23 期完成 + 制片人跟栏目组确认后,再决定是否清理回退路径
---
## 子项目可以启动的部分
按 PRD v2 §七 的回复期望:
-**Q1 已答**(DeepSeek API + 附加条件) → P1 可开始
-**Q2 已答**(subprocess + 不引入 wrapper)→ P1 可开始
-**Q3 已答**(单期 + 契约) → P5 推进时按契约
-**Q4 已答**(自管 + inventory 登记) → 制片人先申请讯飞新凭证 + 建 inventory,P2 可开始
-**Q5 已答**(目录草案批准 + episode_id 规则) → P1 输出有处可放
**Doco 子项目现在可以出 PRD v3,启动 P1。**
---
## 顺便给子项目的两条提醒
1. **demo 的 5 份 fixture 必须进 git**(transcripts / A 稿 / B 稿 / ASR / 终版 / 差异报告),作为单元测试 + golden test 黄金对照。这件事属于 Doco 内部自治,主项目不管,但提醒一下。
2. **AI 融合层模型选 Sonnet 4.6**(PRD v2 §二 已锁),不要在子项目里临时换成别的模型。主项目这边也不会推翻这个选择。
---
*主 project 顾问(Claude Opus 4.7)签发,2026-06-12*
@@ -0,0 +1,104 @@
# 寄存条:Doco 文稿整理子项目已外迁
> 留在主 project,让未来的 Claude 一眼知道:Doco 文稿整理模块已拉出去单独立项了。
> 外迁时间:2026-06-12
---
## 外迁了什么
**Doco 文稿整理模块**(Document Organization,前称"TPS 三方融合子模块")整体作为子项目。
聚焦:视频双路拆分 + 讯飞 ASR 适配 + 三方融合引擎(规则层 + AI 层 Claude Sonnet 4.6) + 单期处理接口 + 23 期批量调度入口。
demo 已用《现代防空反导大对决》一期跑通,设计文档(`doco_project_design.md`)已沉淀算法层全部决策。
---
## 主 project 不再讨论的话题
聊到下面这些词,**先问一句"这是 Doco 子项目的事还是主 project 的事?"**——是子项目的事,提醒制片人切到 Doco 子项目 chat。
关键词:doco / 文稿整理 / 三方融合 / 视频双路拆分 / A 稿 / B 稿 / ASR / 讯飞 / DeepSeek OCR / DeepSeek Vision / 终版文稿 / 差异报告 / needs_review / 段落对齐 / 改动类型 / 编导笔误 / 口语清理 / OCR 错字字典 / A 稿术语表
---
## 主 project 仍在管什么
- Phase 0-3 已落地的主干代码(episodes、editors、收视诊断基础、知识库)
- backend 全部 schema、API、迁移脚本
- 前端全部 React 代码实施
- 主干 Bug、性能优化、新需求(Doco 以外的)
- Cline 的全部 Plan + Act 操作
- 看板分析升级子项目的接收(见 `寄存条_看板升级已外迁.md`)
- **api_credentials_inventory.md 的维护**(本次 doco 立项时新引入,见下)
---
## 本次立项给主 project 带来的三件新事
### 1. `docs/api_credentials_inventory.md`(新增)
主 project 仓库根目录加一份"凭证清单",登记所有子模块用的外部 API 的元信息(不存真实 key)。
字段:子模块 / API 服务 / Key 类型 / 开通日 / 激活状态 / 到期日 / 责任人 / 备注。
**这不是中台密钥服务**——只是一份让所有子模块"忘了谁管哪把 key"问题最小化的登记表。子项目自己 .env 里存真凭证,主 project 这里只存元信息。
未来其他子模块申请外部 API,也按这个规则登记。
### 2. 节目素材目录规约(主项目第一次定)
`programs/{episode_id}/source|work|output/...`,episode_id 格式 `ep{NNN}_{YYYYMMDD}_{slug}`
详见 `主project对Doco_PRDv2的回复.md` §Q5。
未来其他涉及节目素材的子模块,沿用同一规约。
### 3. `跨子项目协作规则.md`(新增)
主 project 根目录新增一份通用规则文件,首条规则是 **PRD 版本管理**(主 project 只留最新版 PRD + 对应回复,旧版子项目那边归档保留)。
未来其他跨子项目通用规则在这份文件里累加,**所有外迁子项目都适用**(看板升级、Doco 等)。
---
## 接收子项目交付物的接口
Doco 子项目交付物的接口约定,见**子项目主 Brief §六**(交付什么)。简要回顾:
- **Doco Python 包**:可被主 project 编排层 import,CLI + Python API 两套接口
- **凭证管理**:子项目自己 .env,主项目这边 inventory 登记元信息
- **测试**:单元测试 + golden test(demo 那期零回归)
- **数据资产**:A 稿术语表 + OCR 错字映射表可入 TPS 中台共享(走主项目 schema 流程)
**纪律**:
- 子项目不改主 project backend 代码 / schema
- 子项目不写主 project 前端 UI(编导确认 UI 是另一码事)
- 实施完成后回访子项目登记状态
---
## 开局口径
进主 project 新 chat:
> "续接 TPS 主项目。读 `寄存条_doco子项目已外迁.md` 和 `寄存条_看板升级已外迁.md`。当前要做 [具体任务]。"
---
## 万一子项目记录丢了
降级方案:
1. **主 project 这边保留 doco 系列资产副本**:`doco_project_design.md/.docx``doco_xfyun_integration_notes.md/.docx``doco_handoff_to_opus_chat.md/.docx`、最新一版的 `PRD_doco_文稿整理子项目_vN.md``主project对Doco_PRDvN的回复.md`
2. demo 已跑通的代码和 fixture(`xfyun_asr_standard.py` + 5 份 fixture)在制片人本地有副本
3. 决策史在 Gitea 有 git 历史
4. **主 project 这边不要凭记忆重建子项目内容**——找制片人或重建子项目
---
## 当前外迁的子项目清单(随更新)
| 子项目 | 外迁日期 | 寄存条文件 | 状态 |
|---|---|---|---|
| TPS 看板分析升级 | 2026-06-12 | `寄存条_看板升级已外迁.md` | 设计阶段 |
| Doco 文稿整理 | 2026-06-12 | 本文件 | 等子项目出 PRD v3 启动 P1 |
+38
View File
@@ -0,0 +1,38 @@
# 跨子项目协作规则
> 主项目维护的通用规则,所有外迁子项目都适用。
> 起始日期:2026-06-12
---
## 规则 1:PRD 版本管理(立于 Doco 立项时)
### 1.1 主 project 这边的规则
- **只保留最新版 PRD + 对应回复**,旧版本一律删
- **PRD 与回复一一配对**:PRD v2 ↔ 回复 v2;PRD v3 ↔ 回复 v3
- 任何时点,主 project 不允许同时存在同一子项目的两个 PRD 版本
### 1.2 子 project 那边的规则
- **保留所有版本**(包括 PRD v0 / v1 / v2 / v3...)
- 旧版本可移到 `archive/` 子目录或加 `_archived` 后缀,但不删
- 每份新 PRD 在元信息块标注"上一版:vN"
### 1.3 版本翻新的操作
子项目出 PRD vN+1 时:
1. 制片人在主 project 这边**先删** PRD vN + 回复 vN
2. **再传** PRD vN+1 + 主项目顾问写的回复 vN+1
3. 一次性,不允许"先传再删"造成短暂双版本状态
### 1.4 适用范围
- 当前所有外迁子项目(看板升级 / Doco 等)
- 未来新立项的子项目沿用
---
## (未来其他通用规则在这里累加)
> 后续规则按 `## 规则 2:xxx` `## 规则 3:xxx` 顺序追加。
@@ -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] 再见
File diff suppressed because one or more lines are too long
@@ -0,0 +1,310 @@
[0m7s] 导弹呼啸而过,战机低空穿梭,
[0m11s] 无人机如蜂群般袭来,
[0m13s] 这些空中威胁正不断挑战防御体系的极限,
[0m18s] 他们是如何被精确拦截的?究竟是什么力量能在遥远距离精准打击,在近在咫尺时完成致命一击呢?
[0m28s] 本期军事科技
[0m29s] 将带您逐步解析现代防空网络的复杂机制,
[0m34s] 亲眼见证科技与智慧打造的空中屏障,揭秘现代防空体系御敌于空的终极密码。
[1m14s] 各位观众你们好,欢迎收看军事科技,我是主持人蓝皓。
[1m19s] 从第二次世界大战时期的战机轰炸到冷战时期的导弹威慑,再到如今的无人机高超声速武器的崛起,
[1m28s] 空中威胁的形态啊不断的迭代,
[1m30s] 防御体系也在随之进化。
[1m34s] 而如今的现代化防空系统早已不再是单一武器的简单对抗了,
[1m39s] 而是构建了以远中近衔接,高中低覆盖为一体的立体化防御系统,
[1m45s] 仿佛一块层层镶嵌的空中盾牌,
[1m49s] 从千里之外的高空到咫尺之间的低空形成了无死角的防护,
[1m55s] 在与新型空中威胁持续对抗当中,上演着一场场激烈的
[2m1s] 攻与防的较量。
[2m18s] 2026年2月28日,美伊两国协同向伊朗发起军事打击,
[2m24s] 中东地区顿时战火纷飞,硝烟弥漫,
[2m28s] 随着冲突不断加剧,
[2m30s] 各方频繁动用弹道导弹、
[2m32s] 巡航导弹,
[2m33s] 高超声速武器及自杀式无人机等先进装备,
[2m37s] 展开密集的高强度空袭,
[2m41s] 波斯湾上空随即呈现出一场攻防交织的终极较量,
[2m46s] 涉及突破与拦截,毁灭与保卫。
[2m49s] 这场关乎生死存亡与战局走向的空中攻防战彻底爆发。
[3m4s] 在涉及远中近程和高中低空的全方位空战对抗中,
[3m10s] 弹道导弹与反导系统之间的对决,无疑是最具战略威慑力且最检验技术水平的顶峰交锋。
[3m19s] 其中中段反导作为现代战略防空的最高层级,
[3m24s] 更是左右战场局势的核心要素。
[3m27s] 这项技术常被形容为
[3m30s] 太空中以子弹击落子弹,
[3m33s] 它标志着当今世界军事科技的极致成就,也是战略防空领域真正的制高点。
[3m47s] 一枚中远程弹道导弹的飞行分为上升段,
[3m51s] 终端、
[3m52s] 末端
[3m53s] 三者在时空速度上的巨大差异,直接决定了拦截难度的天壤之别。
[4m0s] 上升段仅持续3~5分钟,时间窗口稍纵即逝,
[4m5s] 且需在敌国领土拦截,
[4m7s] 受部署限制难以实施拦截,成功率极低。
[4m12s] 末段导弹俯冲至几十公里至几公里高空,
[4m16s] 速度达马赫数10~20
[4m19s] 仅留数秒至十几秒的反应时间,
[4m22s] 同时面临机动变轨,
[4m24s] 多弹头诱饵及低空探测盲区,
[4m27s] 拦截风险高且成功率有限,
[4m30s] 唯有中短,
[4m31s] 导弹在100公里以上大气层外无动力滑行,
[4m35s] 飞行时间长达10~20分钟,
[4m38s] 轨迹由万有引力决定极度稳定,并未释放弹头与诱饵,反导系统有足够的时间预警测算轨迹并拦截,
[4m49s] 采用动能撞击可彻底消除地面威胁,
[4m52s] 是现代战略防空最理想效果最佳的黄金拦截期,
[4m58s] 也是各国反导技术研发的核心方向。
[5m8s] 虽然说呢中段反导拦截在整个反导作战的流程当中,那么它是比较容易实现的,或者说呢可以针对这个对方来袭的导弹
[5m20s] 进行精准的摧毁,但是最大的问题呢就是你的预警系统啊需要看得远,反导拦截弹呢需要飞得快,打得高打得远,所以对于技术方面的要求是比较高的,这就要求
[5m31s] 大型的相控阵预警雷达能够在极远的距离上,
[5m35s] 针对外大气层飞行的这种小型高速移动的目标能够进行精准的锁定,而且呢要对它进行持续的跟踪,还要有相关的这样的一个计算系统,针对它的飞行轨迹进行计算,这样的话呢
[5m49s] 才能够有效的引导反导拦截弹在外大气层对它进行精准的这样的一个截杀,而且呢反导拦截弹的性能要求非常高,不仅要飞得高飞得远飞得快,
[6m1s] 而且呢甚至还要配备有动能的战斗部,要针对处于飞行状态的弹道导弹进行精准的撞击,那么确保它
[6m10s] 是啊被彻底摧毁。
[6m15s] 3月5日,
[6m16s] 伊朗伊斯兰革命卫队发布声明称,
[6m19s] 在真实承诺4行动的第十七轮中,
[6m23s] 伊朗动用高超声速导弹和攻击无人机,成功突破美国萨德防御系统,
[6m29s] 打击了以色列国防部大楼与本古里安国际机场,
[6m34s] 并摧毁了7套雷达系统及核心配套设备。
[6m39s] 同期美国内部评估报告透露,作为中东关键中段反导装备的以色列箭式系统,
[6m46s] 在拦截伊朗中程弹道导弹时显现重大短板,
[6m50s] 实际拦截率远未达到原有设计标准。
[6m56s] 那么在以色列的国土范围内,
[6m59s] 呃有箭二箭三反导系统,也有美国
[7m2s] 部署的萨德反导系统,但是我们看仍然有数量相当多的伊朗弹道导弹成功突防,
[7m9s] 针对以色列区域内的目标进行了毁伤。
[7m12s] 为什么说箭二箭三以及萨德系统它联起手来,也没有办法针对伊朗的弹道导弹进行这种百%的防范和拦截。我认为呢首先是伊朗弹道导弹的技术有所提升,
[7m25s] 部分伊朗的弹道导弹采用了这种高超音速的战斗部,在飞行的末段会有突然的加速,或者说有急剧的飞行轨迹的变化,这样的话呢萨德系统
[7m36s] 和箭二箭三反导系统很难对它进行精准的拦截。此外呢伊朗的一些弹道导弹,比如说像霍拉姆沙赫尔四,
[7m44s] 那么它是一种重型的这样的一个弹头的设计理念,那么1.5吨到两吨的战斗部,啊它可以采用这种集束弹头啊的这样的一个模式,
[7m54s] 飞到了目标区上空之后,打出了一个满天星的效果,数10枚子弹头
[8m0s] 飞向目标区域,
[8m1s] 所有的防空反导系统都没有办法对它进行拦截。
[8m5s] 那么还有另外一种情况,美国的反导作战系统和以色列的箭二箭三反导系统,它没有办法进行联网作战,是各打各的,这样的话呢在相关的区域内没有办法进行目标信息的共享,
[8m17s] 那么也无法形成合力。
[8m20s] 2026年3月15日,
[8m23s] 伊朗伊斯兰革命卫队在真实承诺四军事行动中,
[8m27s] 首次实战部署了泥石导弹。
[8m31s] 与此同时,美军从韩国紧急调遣了萨德反导系统,
[8m35s] 以加强该地区的防御,
[8m37s] 应对伊朗导弹带来的持续威胁。
[8m43s] 若将远程反导视作千里点穴的区域防空手段,
[8m48s] 那么中程防空啊便是中空守护网,
[8m51s] 承担着衔接远近防御的关键使命。
[8m56s] 它覆盖数10公里乃至100多公里的中高空区域,
[9m0s] 作为防空体系中的
[9m1s] 中坚力量,
[9m3s] 承担起区域防空重任,护卫城市军事要地等高价值目标,成为整个防空网络中承上启下的核心组成部分。
[9m22s] 中程防空作为现代防空作战系统的关键环节,其防御半径一般在50~200公里之间,
[9m30s] 拦截高度重点覆盖10~20公里的中高空域。
[9m36s] 这片空域正是现代战争中空中威胁密集活动的核心地带。
[9m42s] 敌方战机若要突破外层防御,
[9m45s] 打击纵深关键目标必须穿越此区域。
[9m49s] 巡航导弹虽常利用低空突防,
[9m52s] 但多数情况下仍需在中高空调整姿态,
[9m56s] 寻找防御薄弱点,
[9m58s] 而中近程导弹在俯冲末段同样会经过这一高度区间,
[10m3s] 其高速特性要求中程防空系统及早介入,精确拦截。
[10m9s] 可以说
[10m10s] 这片中高空域是空中威胁,渗向核心目标的必经通道,
[10m15s] 也是防空体系中承前启后的关键拦截区。
[10m26s] 在现代化的防空作战行动当中,啊
[10m29s] 中程防空的难度是非常大的。
[10m31s] 因为
[10m32s] 很多对方的啊这种空中的来袭目标,其实它都是在中程防空的任务范围内,
[10m39s] 目标的属性是非常多的。
[10m41s] 此外呢在复杂的电磁环境的这样的一个状态下,中程的防空体系也可能会出现失灵的情况,啊因为对方可能会出动电子战飞机,
[10m52s] 针对你的防空作战体系进行压制。此外呢有的时候呢面对各种各样的目标数量众多的目标,那么中程防空系统它
[11m0s] 的这样的一个火力的密集程度可能会
[11m4s] 出现不足的情况。比如说一部发射机上,
[11m6s] 它只有数量有限的中程防空导弹打完了之后,那么对方的目标
[11m11s] 连续的持续的袭来,
[11m13s] 那中程防空体系可能也会面临弹药不足,火力不足的情况。
[11m21s] 在近期,
[11m22s] 美以联合对伊朗的军事行动中,中程导弹防御系统成为防空体系中的核心作战力量,
[11m29s] 也是双方攻防较量最为激烈的领域。
[11m33s] 为巩固中诚防御屏障,
[11m35s] 美国运用爱国者防空反导系统,
[11m39s] 结合天基预警卫星,陆基远程雷达及集成化指挥控制中心,
[11m45s] 打造了能够覆盖30公里至150公里空域的拦截体系,
[11m50s] 着重防护其军事基地与国防工业区域。
[11m54s] 然而面对伊朗高超音速武器,配合无人机集群的非对称组合式打击,
[12m1s] 此前被美军公开宣称拦截成功率可达80%~97%的爱国者系统,
[12m8s] 实际拦截率已跌至5%以下,
[12m12s] 系统还频频出现拦截假目标,漏过真实弹头的失误。
[12m20s] 爱国者曾在海湾战争期间
[12m23s] 一战成名,
[12m24s] 啊那当时呢美军爱国者系统它所拦截的导弹呢都是伊拉克
[12m30s] 萨达姆政权军队啊使用的比较老旧的飞毛腿弹道导弹,它的飞行轨迹是一个典型的抛物线,不会有这种
[12m39s] 呃机动变轨的这样的一些动作,所以呢只要是探测到了它的飞行轨迹,
[12m44s] 那么多发射一些爱国者拦截弹是可以对它进行有效拦截的,但是现代化的弹道导弹突防能力都很强,
[12m52s] 飞行轨迹是变化的,甚至可以说是有一些打水漂的这样的一个飞行轨迹,速度又很快,那么甚至可能会携带更多的诱饵弹和干扰弹,所以呢爱国者系统可能根本就看不清,
[13m3s] 可能也就追不上。
[13m6s] 爱国者导弹在部分实战场景中拦截效果未达预期,
[13m11s] 这一表现使得美以联合构建的中程防空反导系统暴露出短板,
[13m17s] 也让多国对该系统产生信任危机。
[13m23s] 在现代防空系统中,低空与超低空是远程及中程防空存在的短板,
[13m30s] 受到地球曲率地形掩蔽等影响,
[13m33s] 远程雷达难以追踪低空目标,
[13m36s] 而中程防空导弹呢在低空条件下,制导系统易受地面杂波的干扰,致使拦截效能下降。
[13m44s] 因此啊
[13m44s] 近程防空系统由此诞生,
[13m48s] 专门用于近距离的防护,
[13m50s] 弥补了防空网络在低空领域的不足。
[14m1s] 对普通公众而言,近程防空无疑是最常见最显眼且视觉冲击力最强的防空形式,那些在网络短片新闻报道和战场记录中常见的导弹腾空而起,
[14m15s] 尾焰撕裂黑夜,目标在空中爆炸,
[14m18s] 火光刹那映亮天空的拦截画面,
[14m22s] 大多源自近程防空系统,
[14m25s] 这些系统射程较短,但部署便捷,响应迅速。
[14m30s] 通常在目标接近阵地城市或重要设施的最后一刻才发动攻击,
[14m36s] 因而更易被现场捕捉,媒体记录并广泛传播。
[14m40s] 正是由于这种高频曝光,近程防空在许多人心中几乎成了防空的代名词。
[14m48s] 然而实际上近程防空在整体防空网络中充当的是最终贴身防线的功能,
[14m55s] 专门填补远程和中程防空无法覆盖的低空及超低空漏洞。
[15m4s] 近程防空作战体系在实际应用的过程当中,有的时候呢
[15m8s] 可能会成为战场上的奇兵,啊因为对方的直升机啊甚至包括这个战斗机在低空飞行的时候,啊它可能会放松警惕,另外一个来不及反应。
[15m17s] 所以这个时候近程防空火力针对目标进行射击的时候,战斗机很难对它进行规避,有的时候呢甚至连发射红外诱饵弹的这个时间都没有,
[15m27s] 但是呢近程防空也有很大的难度,因为很多近程防空作战体系,啊
[15m32s] 它的搜索系统是比较简单的,视野不够宽广,也无法针对远距离的目标进行识别,所以呢有的近程防空作战体系它本质上来讲是近视眼,
[15m43s] 而且呢还涉及到一些地形起伏的问题。
[15m45s] 如果对方利用这种地形起伏,啊那么去进行超低空的突防,那么在相关区域内部署的一些传感器,比如说低空搜索雷达或者是光学红外传感器,可能没有办法及时有效的识别目标。
[15m58s] 那么这些低空突防的这样的一些空中作战平台,也可能会针对
[16m2s] 啊这个近程的防空作战体系,包括相关的防空阵地进行打击和压制。
[16m11s] 在近期美以联合对伊朗的军事行动中,
[16m14s] 以色列拉斐尔公司研发的铁穹近程防空系统,经历了自2011年部署以来最激烈且最具转折性的实战检验。
[16m25s] 这款以往在冲突中以85%~90%拦截率享誉世界的明星武器,
[16m32s] 在伊朗真实承诺4行动的饱和攻击下,
[16m36s] 其卓越声誉荡然无存。
[16m39s] 以色列国防部3月17日向国会递交的评估报告中指出,
[16m45s] 面对伊朗连续发动的60多波攻势,
[16m48s] 铁穹系统的实际拦截率从历史标榜的90%以上骤降至约50%,
[16m55s] 而在集束弹头密集倾泻与无人机蜂群低空突袭的关键战斗阶段,
[17m1s] 其拦截成功率更是下滑至30%以下,
[17m5s] 刷新了服役以来的最低性能纪录。
[17m12s] 那么我们注意到在此次军事行动的过程当中,啊铁穹系统表现平平,我认为呢
[17m17s] 呃可能是有两方面的考虑,一方面呢就是它配套的
[17m20s] 它这种传感器啊或者是预警体系,可能是遭到了对方的火力打击,有部分铁穹系统
[17m26s] 啊那么针对空中来袭目标,它的反应速度不够快,或者说呢呃它的这样的一个情报和目标的引导体系啊不够完善。
[17m36s] 另外一个呢就是铁穹系统,它所使用的是塔米尔拦截弹,
[17m40s] 这种拦截弹,啊
[17m41s] 但是由于它装备和使用的数量比较多,所以呢它必须要进行成本的控制,啊它的飞行速度
[17m48s] 其实并没有那么快,啊那么有的时候针对高速目标进行打击的时候,可能会出现速度跟不上的情况。
[17m55s] 此外,呢现在伊朗主要是使用弹道导弹,针对以色列进行打击,
[18m0s] 面对高空高速飞行的弹道导弹,
[18m3s] 或者是配备有
[18m4s] 高超音速战斗部的弹道导弹。
[18m6s] 嗯这个铁穹系统可能它的拦截范围拦截的高度也都不足。
[18m10s] 当然了伊朗也使用了大量的这个无人机,但是这一次针对以色列进行后续的反击,伊朗更加注重使用啊这种中程的弹道导弹,或者是突防能力很强的弹道导弹。
[18m22s] 那么面对这样的一种情况,铁穹系统啊它就会出现
[18m26s] 啊反应速度不够快,啊包括呢啊这个拦截的范围拦截的高度都存在不足的情况。
[18m35s] 近程防空系统作为整个防空架构中关键的最后屏障,
[18m40s] 肩负着坚守底线,护卫要害的核心职责,
[18m45s] 但它并非完美无缺,仍存在明显的短板。
[18m50s] 其防御范围通常较为有限,
[18m53s] 探测与拦截能力都集中在较小区域,
[18m56s] 大多只能执行小尺度重点目标的局部防护任务,
[19m1s] 无法如同中远程防空系统那样,实现广大空域的大范围远距离监控与狙击。
[19m8s] 因此近程防空系统不能独自支撑一国一城或整个战区的全面防空安全,
[19m16s] 它更像是一件贴身的防弹衣,而非是能覆盖全域的保护罩,
[19m24s] 远程中程与近程防空系统相互配合,
[19m29s] 在现代战争中构筑了层次分明,分工明确、
[19m33s] 紧密衔接的全空域立体防空网络。
[19m37s] 三者各尽其责,彼此支援,
[19m40s] 逐层拦截,
[19m41s] 形成了一套由外至内,由高空到低空由远及近的周密防御体系。
[19m49s] 不过啊今年多场高强度军事冲突的实际表现,这套看似牢不可破的防空体系正持续受到新兴战场模式的严峻挑战,
[19m59s] 其传统作战思路和防御能力正在不断遭受冲击
[20m4s] 与重塑。
[20m14s] 在美以联合对伊朗发动的大规模军事行动中,
[20m18s] 可以清晰看到,
[20m20s] 美军以色列军队以及伊朗的防空系统都遭遇了前所未有的考验,
[20m26s] 在以往严密的多层防护网络,在实战中屡次被突破撕裂,甚至彻底崩溃。
[20m34s] 美军采用了以爱国者和萨德反导系统为核心的中远程导弹防御体系,
[20m41s] 以色列则调动了由铁穹、大卫投石索和箭式系统组成的全域防空反导系统。
[20m49s] 然而针对高超声速武器穿透无人机群饱和攻击,
[20m53s] 复合诱饵欺骗与强电磁干扰等策略,
[20m57s] 拦截失败的情况依然频繁发生。
[21m3s] 在此次行动的过程当中,啊美以的防空反导系统频频被这个洞穿,也就是说呢伊朗使用了啊一系列的突防手段,最终啊打穿了美国和以色列的一些防空反导的这个保护伞。
[21m17s] 那么针对相关的区域啊构成了更为猛烈的这个火力打击,我认为呢
[21m22s] 呃就是
[21m23s] 呃存在一些问题,比如说它系统之间没有进行有效的联合,存在一些这个兼容的问题,
[21m30s] 在各自为战的状态下肯定会出现漏洞。
[21m33s] 另外一个,呢
[21m33s] 而伊朗也是采取了一些灵活多变的这样的一些战术,多种火力多种模式联合运用,也让美国和以色列的防空反导系统防不胜防。
[21m47s] 在现代军事冲突中,
[21m49s] 高超声速武器的穿透与无人机蜂群的饱和攻击虽是关键威胁,
[21m55s] 但更具深远颠覆性的是战场成本效益失衡对防空体系带来的系统性难题。
[22m3s] 这已超越了单一装备的性能竞争,演变为从经济原理持久作战能力到战略自信的全方位压制,
[22m12s] 引发了拦截不起、消耗不起、补充不上的三重困局。
[22m20s] 以伊朗主力攻击武器见证者136自杀式无人机为例,
[22m25s] 这款被戏称作飞行摩托车的装备,
[22m28s] 彻底打破了传统军工的精密制造范式。
[22m32s] 机身使用民用级玻璃纤维和泡沫塑料拼装
[22m36s] 动力系统,
[22m37s] 直接借用普通摩托车的二冲程发动机,
[22m41s] 导航依靠民用GPS模块与简易惯性制导,
[22m46s] 甚至部分核心零件来自拆解的汽车和家电,从而极大压低了制造成本。
[22m53s] 凭借民用技术军用化、复杂系统简易化的理念,
[22m57s] 其批量生产单价稳定在2万至5万美元之间,
[23m2s] 某些简化版本甚至降至1.5万美元,
[23m6s] 仅相当于高端防空导弹成本的极小一部分。
[23m10s] 美以用于对抗的防空武器却如同天价奢侈品。
[23m16s] 以色列铁穹系统的拦截弹作为近程防空核心弹药,
[23m20s] 每枚成本达5万到10万美元,
[23m23s] 这意味着即便成功拦截防御方的经济损失,也远超进攻方。
[23m30s] 更甚者,美国爱国者三拦截弹作为中程防空主力,
[23m35s] 单枚成本飙升至400万至500万美元,
[23m39s] 堪比用超级跑车撞击普通代步车。
[23m43s] 无论胜负如何,防御方已在战略层面沦为输家。
[23m50s] 那么在效费比方面,啊如果说对方在进攻的过程当中啊采用了很多廉价的方式,或者说很多省钱的这样的一些技术手段,
[24m1s] 像见证者136
[24m2s] 其实呢它就是飞行的小摩托,那么它的成本控制得很低,但是对方可能要用先进的防空反导系统对它进行拦截。
[24m10s] 而伊朗的一些地区盟友,包括这个
[24m13s] 黎巴嫩真主党会使用大量的卡桑火箭弹,那么也就是说更低成本的作坊工厂当中生产出来的
[24m20s] 这种火箭弹针对以色列进行打击,那以色列方面使用的是什么?呢是这种塔米尔拦截弹,也就是铁穹系统的拦截弹。
[24m27s] 那么在这样的一种
[24m28s] 在相互的消耗战的过程当中,那以色列
[24m32s] 呃或者是美国它所面临的
[24m34s] 这种昂贵弹药的快速消耗和短缺的情况也会变得越来越多,也会变得越来越明显。
[24m42s] 由于战场上攻击和防御的效费比例严重失衡,
[24m47s] 防空弹药库存告急,
[24m49s] 形势十分紧迫。
[24m51s] 3月18日,
[24m52s] 以色列国防部火速向美国求援,
[24m55s] 美军立刻出动c17运输机,
[24m58s] 紧急空运了近千枚拦截导弹,
[25m1s] 为以色列的库存注入强心针。
[25m4s] 与此同时,
[25m5s] 美军从韩国、亚太到欧洲多个据点,迅速调集防空系统,
[25m11s] 全力缓解战场上防空装备和弹药捉襟见肘的困境。
[25m18s] 现代防空系统打造了一个从远程中段反导,
[25m22s] 近程区域防空到近程末段拦截的
[25m26s] 完整链条,
[25m28s] 构建了远近交错高低兼顾的立体防御天网。
[25m33s] 然而
[25m34s] 高超声速武器如闪电般突破,
[25m36s] 无人机群的压制以及饱和式攻击的浪潮不断袭来,
[25m40s] 让传统防空体系面临着严峻的考验,
[25m46s] 攻防成本的天平正在严重倾斜,
[25m49s] 威胁形势日新月异,
[25m51s] 如何在提升拦截效果的同时,勒紧成本的缰绳,
[25m56s] 变得
[25m57s] 至关重要,
[25m59s] 让防空网看得远,
[26m1s] 拦得准,
[26m2s] 扛得住,
[26m3s] 耗得起,
[26m4s] 成为了左右未来防空安全和战争胜负的
[26m8s] 关键棋局。
[26m11s] 好了,观众朋友们,感谢您持续关注国防军事频道,军事科技,我们下周同一时间
[26m17s] 再见。
[26m19s] 77载深蓝砺剑,
[26m21s] 77载向海图强。
[26m25s] 在人民海军的序列中
[26m27s] 有这样一些特殊的存在,
[26m29s] 他们不担负作战任务,而是青年官兵驶向大洋的第一课堂。
[26m36s] 看戚继光舰启程远航,
[26m40s] 听郑和简续写传奇。
[26m43s] 值此人民海军成立77周年之际,
[26m46s] 军事科技
[26m47s] 与您和海军青年官兵一起
[26m50s] 启程远航,乘风破浪。
@@ -0,0 +1 @@
<table><td colspan="3">As of December 31,<i>(In millions)</i>201820172016Total return4.9%5.0%5.1%</table>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,268 @@
# -*- coding: utf-8 -*-
"""
阶段 A:抽帧 + OCR(产出原始缓存 ocr_raw.jsonl)
================================================
策略:OCR 优先,每帧都 OCR。绝不使用亮度判空/dHash/IoU 过滤。
按 idx 断点续跑:读取已有 ocr_raw.jsonl 中最大 idx,从 max_idx+1 继续。
"""
import base64
import json
import subprocess
import sys
from pathlib import Path
import requests
# 根治 Windows 控制台 GBK 崩溃:强制 stdout 用 UTF-8
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# ========================================================================
# 全局参数(放文件顶部做常量,方便后续调)
# ========================================================================
FPS = 1
CROP_BOTTOM_RATIO = 0.2
SIM_THRESHOLD = 0.85
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "deepseek-ocr"
OCR_PROMPT = "Free OCR."
# 工作目录(脚本所在目录)
WORK_DIR = Path(__file__).resolve().parent
SOURCE_VIDEO_DIR = WORK_DIR / "source"
FRAMES_DIR = WORK_DIR / "frames_v2"
OCR_RAW_PATH = WORK_DIR / "ocr_raw.jsonl"
# ========================================================================
# A1: 定位源视频
# ========================================================================
def find_source_video():
"""在工作目录 source/ 下定位 .mp4 或 .mkv 源视频"""
for ext in (".mp4", ".mkv"):
candidates = list(SOURCE_VIDEO_DIR.glob(f"*{ext}"))
if candidates:
return candidates[0]
raise FileNotFoundError(
f"{SOURCE_VIDEO_DIR} 中未找到 .mp4 或 .mkv 文件"
)
def get_video_info(video_path):
"""用 ffprobe 获取时长(秒)和分辨率(宽x高)"""
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=duration,width,height",
"-of", "json",
str(video_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"ffprobe 失败:\n{result.stderr}")
info = json.loads(result.stdout)
stream = info["streams"][0]
duration = float(stream.get("duration", 0))
width = stream["width"]
height = stream["height"]
return duration, width, height
# ========================================================================
# A2: ffmpeg 抽帧
# ========================================================================
def extract_frames(video_path):
"""
用 ffmpeg 按 1fps 抽帧,裁切下方 20% 字幕区域。
输出到 frames_v2/frame_%04d.png(从 0001 开始)。
返回按文件名排序的帧路径列表。
"""
FRAMES_DIR.mkdir(parents=True, exist_ok=True)
# 清理旧帧避免残留
for old in FRAMES_DIR.glob("frame_*.png"):
old.unlink()
crop_expr = (
f"fps={FPS},"
f"crop=iw:ih*{CROP_BOTTOM_RATIO}:0:ih*{1 - CROP_BOTTOM_RATIO}"
)
frame_pattern = str(FRAMES_DIR / "frame_%04d.png")
cmd = [
"ffmpeg",
"-i", str(video_path),
"-vf", crop_expr,
"-q:v", "2",
frame_pattern,
"-y",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg 抽帧失败:\n{result.stderr}")
frames = sorted(FRAMES_DIR.glob("frame_*.png"))
return frames
# ========================================================================
# OCR 单帧
# ========================================================================
def ocr_single_frame(image_path):
"""
调用本地 Ollama OCR 识别单帧。
API: POST /api/generate,单轮,图片走 base64,stream=false。
返回 strip 后的文本;异常向上抛出让调用方处理。
"""
with open(image_path, "rb") as fh:
img_bytes = fh.read()
img_b64 = base64.b64encode(img_bytes).decode("utf-8")
body = {
"model": MODEL,
"prompt": OCR_PROMPT,
"images": [img_b64],
"stream": False,
}
resp = requests.post(OLLAMA_URL, json=body, timeout=120)
resp.raise_for_status()
data = resp.json()
text = data.get("response", "").strip()
return text
# ========================================================================
# A3: 单帧验证
# ========================================================================
def test_single_frame():
"""
A3: 验证单帧 OCR。
对 frame_0076.png 调一次 OCR,打印原始返回。
若对不上预期则中止,不进入批量。
"""
test_frame = FRAMES_DIR / "frame_0076.png"
if not test_frame.exists():
print("[A3] [WARN] test frame not found:", test_frame)
print("[A3] skip single-frame check")
return
print("[A3] test frame:", test_frame.name)
try:
raw_text = ocr_single_frame(test_frame)
except Exception as exc:
print("[A3] [FAIL] OCR call failed:", exc)
print("[A3] check Ollama / deepseek-ocr, abort.")
sys.exit(1)
print("[A3] raw return:", repr(raw_text))
if "" in raw_text or "" in raw_text:
print("[A3] [OK] verification passed.")
else:
print("[A3] [WARN] expected '各位观众你们好', got:", repr(raw_text))
print("[A3] abort.")
sys.exit(1)
# ========================================================================
# A4: 批量 OCR(按 idx 断点续跑 + 单帧容错)
# ========================================================================
def batch_ocr(frames):
"""
按 idx 断点续跑:
- 读取 ocr_raw.jsonl 中已有记录的最大 idx
- 只处理 idx > max_existing 的帧
- 每帧 OCR 外包 try/except,报错写 error 字段并 continue,绝不崩整个进程
- 进度只打数字: f"{idx}/{total}", flush=True
"""
total = len(frames)
# --- 读已有记录,取最大 idx ---
max_existing_idx = 0
if OCR_RAW_PATH.exists():
with open(OCR_RAW_PATH, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
max_existing_idx = max(max_existing_idx, rec.get("idx", 0))
except json.JSONDecodeError:
pass
if max_existing_idx >= total:
print(f"[A4] all {total} frames already done (max idx={max_existing_idx}), skip.")
return
start_idx = max_existing_idx + 1
print(f"[A4] max existing idx = {max_existing_idx}, resume from idx {start_idx}")
# --- 构建 idx -> frame_path 映射 ---
idx_to_path = {}
for fp in frames:
idx = int(fp.stem.split("_")[1])
idx_to_path[idx] = fp
# --- 追加写入 ---
with open(OCR_RAW_PATH, "a", encoding="utf-8") as fh:
for idx in range(start_idx, total + 1):
t_sec = idx - 1
frame_path = idx_to_path.get(idx)
if frame_path is None:
# 帧文件缺失(不应发生,但容错)
record = {"idx": idx, "t_sec": t_sec, "text": "", "error": "frame file missing"}
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
fh.flush()
print(f"{idx}/{total}", flush=True)
continue
try:
text = ocr_single_frame(frame_path)
record = {"idx": idx, "t_sec": t_sec, "text": text}
except Exception as exc:
record = {"idx": idx, "t_sec": t_sec, "text": "", "error": str(exc)}
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
fh.flush()
if idx % 50 == 0:
print(f"{idx}/{total}", flush=True)
print(f"{total}/{total}", flush=True)
print(f"[A4] OCR done, {total} records in {OCR_RAW_PATH}")
# ========================================================================
# 主流程
# ========================================================================
def main():
# A1
video_path = find_source_video()
duration, width, height = get_video_info(video_path)
print(f"[A1] video: {video_path}")
print(f"[A1] duration: {duration:.1f}s resolution: {width}x{height}")
# A2: skip re-extract if frames already exist
FRAMES_DIR.mkdir(parents=True, exist_ok=True)
existing = sorted(FRAMES_DIR.glob("frame_*.png"))
if existing:
print(f"[A2] {len(existing)} frames already exist, skip re-extract")
frames = existing
else:
print(f"[A2] ffmpeg extract (fps={FPS}, crop_bottom_ratio={CROP_BOTTOM_RATIO})...")
frames = extract_frames(video_path)
print(f"[A2] extracted {len(frames)} frames -> {FRAMES_DIR}")
# A3
test_single_frame()
# A4
batch_ocr(frames)
print("\nstage A done.")
if __name__ == "__main__":
main()
@@ -0,0 +1,300 @@
# -*- coding: utf-8 -*-
"""
阶段 B:文本去重 + 出稿(只读缓存,可反复重跑调阈值)
=======================================================
策略:基于 difflib 文本相似度折叠连续重复段,绝不使用像素级过滤。
"""
import csv
import json
import re
from difflib import SequenceMatcher
from pathlib import Path
# ═══════════════════════════════════════════════════════════════
# 全局参数(与阶段 A 共享,放文件顶部做常量)
# ═══════════════════════════════════════════════════════════════
FPS = 1
CROP_BOTTOM_RATIO = 0.2
SIM_THRESHOLD = 0.85
# 工作目录
WORK_DIR = Path(__file__).resolve().parent
OCR_RAW_PATH = WORK_DIR / "ocr_raw.jsonl"
B_MANUSCRIPT_PATH = WORK_DIR / "B稿_v2.txt"
DEBUG_CSV_PATH = WORK_DIR / "dedup_debug.csv"
BLANK_FILTERED_PATH = WORK_DIR / "blank_filtered.txt"
def similarity(a, b):
"""计算两个文本的 difflib 相似度(0.0 ~ 1.0)"""
if not a or not b:
return 0.0
return SequenceMatcher(None, a, b).ratio()
def is_blank_ocr(text):
"""基于文本的空场判定:空串 / HTML幻觉 / 不含汉字"""
t = text.strip()
if not t: # 空串
return True
if '<' in t and '>' in t: # HTML/markup 幻觉(如 <table>)
return True
if not re.search(r'[\u4e00-\u9fff]', t): # 不含任何汉字 → 非本节目字幕
return True
return False
# ═══════════════════════════════════════════════════════════════
# B1: 读缓存
# ═══════════════════════════════════════════════════════════════
def load_ocr_raw():
"""读 ocr_raw.jsonl,按 idx(时间)升序返回记录列表"""
if not OCR_RAW_PATH.exists():
raise FileNotFoundError(
f"ocr_raw.jsonl 不存在: {OCR_RAW_PATH}\n"
"请先跑阶段 A(stage_a_extract_ocr.py)。"
)
records = []
with open(OCR_RAW_PATH, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
rec = json.loads(line)
records.append(rec)
# 按 idx 升序
records.sort(key=lambda r: r["idx"])
return records
# ═══════════════════════════════════════════════════════════════
# B2: 折叠连续重复段(裁判在此,基于文本不基于像素)
# ═══════════════════════════════════════════════════════════════
def collapse_segments(records):
"""
维护"当前字幕"(文本 + 起始 t_sec),遍历每帧:
- 文本为空 → 空场:结束当前字幕(若有),并清空,不开新段。
- 非空且与当前字幕文本的 difflib 相似度 ≥ SIM_THRESHOLD → 同一条,并入当前段(记下这条文本备投票)。
- 非空且相似度 < SIM_THRESHOLD → 结束并输出当前段,用这帧开新段。
只比时间相邻帧,只折叠连续段;绝不全局去重。
每段最终文本 = 段内出现次数最多的 OCR 文本(多数投票,抵消个别帧抖动)。
每段时间戳 = 段内最早 t_sec。
返回: (segments, debug_rows)
segments: [{"start_t": float, "text": str}, ...]
debug_rows: [{idx, t_sec, ocr_text, decision, merged_into_start_t}, ...]
"""
segments = []
debug_rows = []
if not records:
return segments, debug_rows
current_text = None # 当前段内用于比对的参考文本
current_texts = [] # 当前段内所有 OCR 文本(用于投票)
current_start_t = None # 当前段起始 t_sec
current_segment_indices = [] # 当前段包含的帧号列表
def _finalize_segment():
"""输出当前段(若存在)"""
nonlocal current_text, current_texts, current_start_t, current_segment_indices
if current_text is not None and current_texts:
# 多数投票:选出现次数最多的文本
text_counts = {}
for t in current_texts:
text_counts[t] = text_counts.get(t, 0) + 1
best_text = max(text_counts, key=lambda k: text_counts[k])
segments.append({
"start_t": current_start_t,
"text": best_text,
})
current_text = None
current_texts = []
current_start_t = None
current_segment_indices = []
for rec in records:
ocr_text = rec["text"]
if is_blank_ocr(ocr_text):
# 空场:结束当前字幕(若有),并清空,不开新段
if current_text is not None:
_finalize_segment()
debug_rows.append({
"idx": rec["idx"],
"t_sec": rec["t_sec"],
"ocr_text": ocr_text,
"decision": "blank",
"merged_into_start_t": "",
})
continue
if current_text is None:
# 开新段
current_text = ocr_text
current_texts = [ocr_text]
current_start_t = rec["t_sec"]
current_segment_indices = [rec["idx"]]
debug_rows.append({
"idx": rec["idx"],
"t_sec": rec["t_sec"],
"ocr_text": ocr_text,
"decision": "new",
"merged_into_start_t": rec["t_sec"],
})
else:
sim = similarity(current_text, ocr_text)
if sim >= SIM_THRESHOLD:
# 同一条,并入当前段
current_texts.append(ocr_text)
current_segment_indices.append(rec["idx"])
debug_rows.append({
"idx": rec["idx"],
"t_sec": rec["t_sec"],
"ocr_text": ocr_text,
"decision": "merged",
"merged_into_start_t": current_start_t,
})
else:
# 结束当前段,用这帧开新段
_finalize_segment()
current_text = ocr_text
current_texts = [ocr_text]
current_start_t = rec["t_sec"]
current_segment_indices = [rec["idx"]]
debug_rows.append({
"idx": rec["idx"],
"t_sec": rec["t_sec"],
"ocr_text": ocr_text,
"decision": "new",
"merged_into_start_t": rec["t_sec"],
})
# 遍历结束,落最后一个段
_finalize_segment()
return segments, debug_rows
# ═══════════════════════════════════════════════════════════════
# B3: 输出 B稿_v2.txt
# ═══════════════════════════════════════════════════════════════
def write_b_manuscript(segments):
"""
输出 B稿_v2.txt,每行格式: [XmYs] 文本
例: [1m18s] 我是主持人蓝皓
"""
lines = []
for seg in segments:
t = seg["start_t"]
m = int(t) // 60
s = int(t) % 60
cleaned = re.sub(r'^[#*\->`\s]+', '', seg['text']).strip()
lines.append(f"[{m}m{s}s] {cleaned}")
with open(B_MANUSCRIPT_PATH, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
print(f"[B3] B稿_v2.txt 写入: {B_MANUSCRIPT_PATH} ({len(lines)} 行)")
return lines
# ═══════════════════════════════════════════════════════════════
# B4: 输出 dedup_debug.csv
# ═══════════════════════════════════════════════════════════════
def write_debug_csv(debug_rows):
"""
输出 dedup_debug.csv,逐帧记:
idx, t_sec, ocr_text, decision(new/merged/blank), merged_into_start_t
"""
with open(DEBUG_CSV_PATH, "w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerow([
"idx", "t_sec", "ocr_text",
"decision", "merged_into_start_t",
])
for row in debug_rows:
writer.writerow([
row["idx"],
row["t_sec"],
row["ocr_text"],
row["decision"],
row["merged_into_start_t"],
])
print(f"[B4] dedup_debug.csv 写入: {DEBUG_CSV_PATH} ({len(debug_rows)} 行)")
# ═══════════════════════════════════════════════════════════════
# 主流程
# ═══════════════════════════════════════════════════════════════
def main():
print(f"[B] SIM_THRESHOLD = {SIM_THRESHOLD}")
# B1
records = load_ocr_raw()
print(f"[B1] 读取 ocr_raw.jsonl,共 {len(records)} 条记录")
# B2
segments, debug_rows = collapse_segments(records)
print(f"[B2] 折叠完成: {len(segments)} 个不连续字幕段")
print(f"[B2] 调试行数: {len(debug_rows)}")
# B3
lines = write_b_manuscript(segments)
# B4
write_debug_csv(debug_rows)
# ═══════════════════════════════════════════════════════════
# 跑完报告
# ═══════════════════════════════════════════════════════════
total_frames = len(records)
ocr_success = sum(1 for r in records if r["text"].strip())
blank_count = sum(1 for r in records if is_blank_ocr(r["text"]))
# 收集所有"被判空场但原文本非空"的去重文本
blank_but_nonempty = set()
for r in records:
t = r["text"]
if is_blank_ocr(t) and t.strip():
blank_but_nonempty.add(t)
print("\n" + "=" * 60)
print("跑完报告")
print("=" * 60)
print(f"抽帧总数: {total_frames}")
print(f"OCR 成功数(非空): {ocr_success}")
print(f"空场数: {blank_count}")
print(f"B稿_v2.txt 最终行数: {len(lines)}")
print("=" * 60)
print(f"B 稿路径: {B_MANUSCRIPT_PATH}")
print(f"调试表路径: {DEBUG_CSV_PATH}")
# 写出"被判空场但原文本非空"去重清单到文件
blank_list = sorted(blank_but_nonempty)
with open(BLANK_FILTERED_PATH, "w", encoding="utf-8") as fh:
for t in blank_list:
fh.write(t + "\n")
print(f"\n空白过滤清单写入: {BLANK_FILTERED_PATH} ({len(blank_list)} 条去重文本)")
# 打印"被判空场但原文本非空"的去重清单
if blank_but_nonempty:
print("\n" + "=" * 60)
print("【被判空场但原文本非空】去重文本清单(请肉眼确认无真中文字幕)")
print("=" * 60)
for i, t in enumerate(blank_list, 1):
print(f" [{i}] {repr(t)}")
print(f"{len(blank_but_nonempty)} 条去重文本")
else:
print("\n'被判空场但原文本非空'的文本。")
print("\n阶段 B 完成。")
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
C-17运输机|GPS模块|中段反导|中程防空|二冲程发动机|以色列国防部|伊斯兰革命卫队|低空搜索雷达|光学红外传感器|动能撞击|卡桑|塔米尔|复合诱饵欺骗|多弹头诱饵|大卫投石索|天基预警卫星|子弹头|巡航导弹|干扰弹|弹道导弹|强电磁干扰|惯性制导|投石索|拉斐尔公司|攻击无人机|无人机蜂群|末端拦截|本·古里安国际机场|机动变轨|极速弹头|标准-6|泥石|爱国者|爱国者-3|相控阵预警雷达|真主党|真实承诺4行动|短程防空|简易惯性制导|箭-2反导系统|箭-3反导系统|箭式|红外传感器|红外诱饵弹|萨德|蓝皓|见证者-136|诱饵弹|近程防空|远程反导|远程防空|铁穹|防空导弹|防空武器|陆基远程雷达|集成化指挥控制中心|集束弹头|霍拉姆沙赫尔|霍拉姆沙赫尔-4|非对称策略|飞毛腿|饱和攻击|驻韩国庆尚北道星州基地|高超音速
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
"python-dotenv>=1.0.0",
"click>=8.1.0",
"python-docx>=1.1.0",
"anthropic>=0.18.0",
"openai>=1.0.0",
]
[project.optional-dependencies]
+62 -8
View File
@@ -25,20 +25,37 @@ import hmac
import json
import os
import re
import sys
import time
import wave
from pathlib import Path
from urllib.parse import quote
from typing import List, Tuple, Optional
import requests
# ========================================================================
# 凭证(从环境变量读取)
# 凭证 — 优先加载 doco/.env(与 llm.py 相同方式)
# ========================================================================
try:
from dotenv import load_dotenv
_doco_env = Path(__file__).resolve().parent.parent.parent / ".env" # doco/.env
if _doco_env.exists():
load_dotenv(str(_doco_env), override=True)
except Exception:
pass
APP_ID = os.environ.get("XFYUN_APP_ID", "").strip()
SECRET_KEY = os.environ.get("XFYUN_SECRET_KEY", "").strip()
if not APP_ID or not SECRET_KEY:
print("[ASR 配置错误] XFYUN_APP_ID 或 XFYUN_SECRET_KEY 未配置或为空", file=sys.stderr)
print("[ASR 配置错误] 请在 doco/.env 中设置这两个环境变量", file=sys.stderr)
print("[ASR 配置错误] 格式: XFYUN_APP_ID=你的appid / XFYUN_SECRET_KEY=你的secret", file=sys.stderr)
sys.exit(1)
# ========================================================================
# 接口配置
@@ -63,10 +80,39 @@ MAX_WAIT_MINUTES = 30
# 热词列表(每期节目调用前从 A 稿提取)
# ========================================================================
def get_hot_words() -> List[str]:
"""获取热词列表,P2 实现时从 A 稿提取"""
def get_hot_words(episode_id: str) -> List[str]:
"""
读取 programs/<episode_id>/本期热词表.txt
"|" 切分、strip、去空去重,返回 List[str]。
文件缺失返回 [] 并 stderr 警告(不退出)。
"""
from pathlib import Path as _Path
# doco 项目根 = doco/src/doco/asr_adapter.py → 上3级到达 doco/
_project_root = _Path(__file__).resolve().parent.parent.parent
hotwords_file = _project_root / "programs" / episode_id / "本期热词表.txt"
if not hotwords_file.exists():
print(f"[ASR 热词] 未找到热词表: {hotwords_file},热词跳过", file=sys.stderr)
return []
try:
raw = hotwords_file.read_text(encoding="utf-8")
except Exception as e:
print(f"[ASR 热词] 读取热词表失败: {e}", file=sys.stderr)
return []
# 按 | 切分、strip、过滤空字符串、去重(保持顺序)
words: List[str] = []
seen: set = set()
for token in raw.split("|"):
w = token.strip()
if w and w not in seen:
seen.add(w)
words.append(w)
return words
# ========================================================================
# 签名+工具
@@ -265,20 +311,24 @@ def format_timestamp(ms: int) -> str:
def transcribe(
audio_path: str,
hot_words: Optional[List[str]] = None,
) -> List[Tuple[int, int, str]]:
) -> Tuple[List[Tuple[int, int, str]], str]:
"""
完整转写流程:上传 → 轮询 → 解析
返回 [(start_ms, end_ms, text), ...]
返回 (sentences, raw_order_result_json_str)
- sentences: [(start_ms, end_ms, text), ...]
- raw_order_result_json_str: 讯飞原始 orderResult 字段原文(用于断点续跑落盘)
"""
order_id = upload_audio(audio_path, hot_words=hot_words)
result_data = poll_until_done(order_id)
order_result_str = result_data["content"]["orderResult"]
return parse_order_result(order_result_str)
sentences = parse_order_result(order_result_str)
return sentences, order_result_str
def write_asr_result(
sentences: List[Tuple[int, int, str]],
output_dir: str,
raw_order_result: str = "",
) -> Tuple[str, str]:
"""
将 ASR 结果写入文件
@@ -287,12 +337,16 @@ def write_asr_result(
os.makedirs(output_dir, exist_ok=True)
timed_lines = [f"[{format_timestamp(bg)}] {text}" for bg, _, text in sentences]
timed_path = os.path.join(output_dir, "asr_result_timed.txt")
timed_path = os.path.join(output_dir, "asr_v2_timed.txt")
with open(timed_path, "w", encoding="utf-8") as f:
f.write("\n".join(timed_lines))
raw_path = os.path.join(output_dir, "asr_result_raw.json")
with open(raw_path, "w", encoding="utf-8") as f:
f.write("{}") # 占位,P2 实现时填入原始返回
if raw_order_result:
f.write(raw_order_result)
else:
# 没有原始数据时写空对象(兼容旧调用)
f.write("{}")
return timed_path, raw_path
+169 -1
View File
@@ -3,6 +3,7 @@
doco CLI 入口
P1: doco split 子命令
P3: doco process 子命令(带 --input-a-draft 和 --cleanup-level)
P3 C1: doco terms 子命令
"""
import click
@@ -10,7 +11,13 @@ import sys
from pathlib import Path
# P1 相关
from .video_split import split_video
from .video_split import split_video, extract_audio
# P3 C1 术语提取
from .term_extract import run_terms
# P3 C2 讯飞 ASR
from .asr_adapter import get_hot_words, transcribe, write_asr_result
@click.group()
@@ -153,5 +160,166 @@ def process(
sys.exit(1)
@main.command("terms")
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
@click.option(
"--a-script",
required=True,
type=click.Path(exists=True),
help="A 稿 txt 文件路径(按纯文本读取)",
)
@click.option(
"--no-ai",
is_flag=True,
default=False,
help="跳过 AI 层提取(Claude),仅使用规则层",
)
def terms(
episode_id: str,
a_script: str,
no_ai: bool,
):
"""
P3 C1: 术语提取 + 累积词典 + 本期热词表
从本期 A 稿提取专有名词 → 更新中台累积词典 → 产出本期热词表(给讯飞 ASR 用)。
两层提取:
A) 规则层(必跑):正则抓型号/番号/兵器名/国名/机构名/人名
B) AI 层(--no-ai 跳过):调 Claude 补抓专名并归类
产物:
- doco/data/term_dict.json(累积词典,幂等更新)
- doco/programs/<episode-id>/本期热词表.txt(| 分隔,最多 200 条)
- doco/programs/<episode-id>/c1_term_candidates.json(三段留痕)
"""
script_path = Path(a_script)
click.echo(f"[doco terms] episode_id={episode_id}")
click.echo(f"[doco terms] A 稿={script_path}")
click.echo(f"[doco terms] no_ai={no_ai}")
try:
result = run_terms(
episode_id=episode_id,
a_script_path=script_path,
no_ai=no_ai,
)
click.echo(f"[ok] 规则候选: {result['rule_count']}")
click.echo(f"[ok] AI 候选: {result['ai_count']}")
click.echo(f"[ok] 合并后: {result['merged_count']}")
click.echo(f"[ok] 词典新增: {result['dict_new_entries']} 条 / 词典共 {result['dict_total']}")
click.echo(f"[ok] 本期热词表: {result['hotword_count']} 条 → {result['hotwords_path']}")
click.echo(f"[ok] 留痕: {result['audit_path']}")
except Exception as e:
click.echo(f"[error] {e}", err=True)
sys.exit(1)
@main.command("asr")
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
@click.option(
"--input-video",
required=True,
type=click.Path(exists=True),
help="输入视频文件路径",
)
@click.option(
"--output-dir",
default=None,
type=click.Path(),
help="输出目录(默认 programs/<episode-id>/)",
)
@click.option(
"--skip-asr",
is_flag=True,
default=False,
help="只分离音频不调讯飞,用于先验证 WAV",
)
def asr(
episode_id: str,
input_video: str,
output_dir: str,
skip_asr: bool,
):
"""
P3 C2: 讯飞 ASR 转写
流程:
1. video_split.extract_audio() 分离 16kHz/单声道/16bit WAV
2. get_hot_words() 读取本期热词表
3. --skip-asr 时到此为止;否则调 transcribe() → write_asr_result()
产物:
- audio_16k.wav(音频)
- asr_v2_timed.txt(带时间戳的转写文本)
- asr_result_raw.json(讯飞原始返回,断点续跑用)
"""
from .asr_adapter import get_audio_duration_ms as _wav_duration
video_path = Path(input_video)
if output_dir is None:
out_dir = Path("programs") / episode_id
else:
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
click.echo(f"[doco asr] episode_id={episode_id}")
click.echo(f"[doco asr] input_video={video_path}")
click.echo(f"[doco asr] output_dir={out_dir}")
click.echo(f"[doco asr] skip_asr={skip_asr}")
# ---- a. 音频分离 ----
wav_path = out_dir / "audio_16k.wav"
if wav_path.exists():
click.echo(f"[doco asr] audio_16k.wav 已存在,复用: {wav_path}")
else:
click.echo("[doco asr] 从视频分离音频(16kHz/单声道/16bit)...")
try:
extract_audio(video_path, wav_path)
click.echo(f"[doco asr] 音频分离完成: {wav_path}")
except Exception as e:
click.echo(f"[error] 音频分离失败: {e}", err=True)
sys.exit(1)
# 打印 WAV 时长
try:
dur_ms = _wav_duration(str(wav_path))
dur_sec = dur_ms / 1000.0
fsize = wav_path.stat().st_size
click.echo(f"[doco asr] audio_16k.wav 大小: {fsize / 1024 / 1024:.1f} MB, 时长: {dur_sec:.1f}s ({dur_ms} ms)")
except Exception as e:
click.echo(f"[doco asr] 无法读取 WAV 时长: {e}")
# ---- b. --skip-asr 时到此为止 ----
if skip_asr:
click.echo(f"[doco asr] --skip-asr 模式,到此为止。WAV: {wav_path}")
return
# ---- c. 热词 ----
hot_words = get_hot_words(episode_id)
click.echo(f"[doco asr] 热词条数: {len(hot_words)}")
# ---- d. 转写 ----
click.echo("[doco asr] 上传音频 → 讯飞 ASR 转写(可能需要数分钟)...")
try:
sentences, raw_order_result = transcribe(str(wav_path), hot_words=hot_words)
except Exception as e:
click.echo(f"[error] ASR 转写失败: {e}", err=True)
sys.exit(1)
timed_path, raw_path = write_asr_result(
sentences,
str(out_dir),
raw_order_result=raw_order_result,
)
# ---- e. 打印摘要 ----
click.echo(f"[ok] 热词条数: {len(hot_words)}")
click.echo(f"[ok] 句子数: {len(sentences)}")
click.echo(f"[ok] asr_v2_timed.txt: {timed_path}")
click.echo(f"[ok] asr_result_raw.json: {raw_path}")
if __name__ == "__main__":
main()
+109
View File
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""
统一 LLM 客户端
===============
为 C1(术语提取)和 C4(后续)提供统一调用入口。
使用 OpenAI 兼容的 /chat/completions 端点,从 doco/.env 读取凭证。
"""
import json
import os
import sys
from pathlib import Path
# ====================================================================
# 凭证 — 优先加载 doco/.env
# ====================================================================
try:
from dotenv import load_dotenv
_doco_env = Path(__file__).resolve().parent.parent.parent / ".env" # doco/.env
if _doco_env.exists():
load_dotenv(str(_doco_env), override=True)
except Exception:
pass
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip()
DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com").strip()
DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-v4-pro").strip()
class LLMConfigError(Exception):
"""LLM 配置异常:key 缺失或为空。上层应 catch 此异常做优雅回退。"""
pass
def _get_client():
"""内部:创建并返回 openai.OpenAI 客户端实例。key 缺失时抛 LLMConfigError。"""
if not DEEPSEEK_API_KEY:
raise LLMConfigError("DEEPSEEK_API_KEY 未配置或为空")
try:
from openai import OpenAI
except ImportError:
raise LLMConfigError("openai 库未安装,请执行 pip install openai")
return OpenAI(api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL)
def chat(
messages,
*,
model=None,
temperature=0.0,
max_tokens=4096,
thinking=None,
**kwargs,
) -> str:
"""
调用 DeepSeek /chat/completions,返回 assistant 的文本内容。
参数:
messages: 标准 messages 列表 [{"role": "user", "content": ...}]
model: 模型名,默认从 DEEPSEEK_MODEL 环境变量读取(未设置则 deepseek-v4-pro)
temperature: 温度,默认 0.0
max_tokens: 最大输出 token 数,默认 4096
thinking: None=不传(默认行为),True=开启,False=传 extra_body 关闭 thinking
**kwargs: 透传给 client.chat.completions.create()(如 top_p 等)
返回:
assistant 回复文本(str)
异常:
LLMConfigError — key 缺失/openai 未安装
openai 库自身的认证/网络/API 异常 — 打印原始响应后重新上抛,不吞错
"""
if model is None:
model = DEEPSEEK_MODEL
client = _get_client()
create_kwargs: Dict = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
**kwargs,
}
if thinking is False:
create_kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
try:
response = client.chat.completions.create(**create_kwargs)
except Exception as e:
# 打印原始错误响应体,不改模型/不回退
print(f"[LLM ERROR] 模型={model} 调用失败", file=sys.stderr)
print(f"[LLM ERROR] 异常类型: {type(e).__name__}", file=sys.stderr)
print(f"[LLM ERROR] 异常消息: {e}", file=sys.stderr)
# 尝试从 openai 异常中提取原始响应
if hasattr(e, "response"):
try:
body = e.response.json()
print(f"[LLM ERROR] 原始响应体: {json.dumps(body, ensure_ascii=False)}", file=sys.stderr)
except Exception:
print(f"[LLM ERROR] 原始响应体: {e.response.text[:2000]}", file=sys.stderr)
raise
return response.choices[0].message.content
+582
View File
@@ -0,0 +1,582 @@
# -*- coding: utf-8 -*-
"""
术语提取 + 累积词典更新 + 本期热词表生成
=========================================
三层架构:
A) 规则层(必跑) — 正则抓型号/番号
B) AI 层(可选) — DeepSeek 通读 A 稿补抓专名并归类
C) 合并 + 词典累积 + 热词表
"""
import json
import os
import re
import sys
from pathlib import Path
from typing import Dict, List, Set, Tuple, Optional
# ====================================================================
# 统一 LLM 客户端(DeepSeek,OpenAI 兼容协议)
# ====================================================================
from .llm import chat as llm_chat, LLMConfigError, DEEPSEEK_MODEL
# ====================================================================
# 常量
# ====================================================================
PACKAGE_DIR = Path(__file__).resolve().parent.parent # doco/src/doco -> doco/src
PROJECT_DIR = PACKAGE_DIR.parent # doco/
DICT_PATH = PROJECT_DIR / "data" / "term_dict.json"
SEED_PATH = PROJECT_DIR / "data" / "seed_hotwords.txt"
MAX_HOTWORDS = 200
MIN_TERM_LEN = 2
MAX_TERM_LEN = 16
VALID_DOMAINS = {"weapon", "model", "unit_designation", "person", "org", "tech_concept"}
# ====================================================================
# A) 规则层 — 正则
# ====================================================================
RE_MODEL_NUMBER = re.compile(
r"(?:^|[^\w\u4e00-\u9fff\u3000])"
r"("
r"(?:歼|轰|运|直|武直|飞豹|霹雳|红旗|红箭|鹰击|长剑|东风|巨浪|"
r"卫士|神鹰|暗剑|攻击|无侦|彩虹|翼龙|云影|"
r"箭|见证者|流星|铁穹|大卫|泥石|爱国者|萨德|标准|飞毛腿|卡桑|霍拉姆沙赫尔|塔米尔|投石索)"
r"[\u2014\u2013\u002d\u2212\uff0d\-\u00ad\s]*"
r"(?:\d{1,4}[A-Za-z]?|[\u2160-\u216b]+|[A-Z]+|[IV]+)?"
r"|"
r"[A-Z]{1,6}(?:/[A-Z]{1,3})?(?:-\d{1,4}[A-Za-z]*)+(?:/[A-Z]{1,3})?"
r"|"
r"\d{2,4}[A-Z]+"
r")"
r"(?:$|[^\w\u4e00-\u9fff])"
)
PURE_CN_MODEL_TERMS = [
"萨德", "铁穹", "塔米尔", "泥石", "卡桑", "飞毛腿", "箭式", "投石索",
"爱国者", "标准", "霍拉姆沙赫尔",
]
RE_CN_WEAPON = re.compile(
r"(?:反导|防空|弹道|巡航|高超音速|超高声速)(?:导弹|武器)?"
r"|(?:中段反导|末端拦截|低空拦截|超低空突防)"
r"|(?:[A-Z]+\s*)?(?:(?:远程|中程|近程|短程|末端)(?:防空|反导|拦截)(?:系统|导弹)?)"
r"|(?:相控阵预警雷达|天基预警卫星|陆基远程雷达|红外传感器|低空搜索雷达)"
r"|(?:动能撞击|动能战斗部|动能拦截弹)"
r"|(?:无人机蜂群|自杀式无人机|察打一体无人机|攻击无人机)"
r"|(?:二冲程发动机|惯性制导|GPS模块|诱饵弹|干扰弹|子弹头)"
r"|(?:电磁干扰|强电磁干扰|复合诱饵欺骗)"
r"|(?:集成化指挥控制中心|一体化指挥系统)"
r"|(?:杀伤链|杀伤网|OODA循环|联合火力)"
)
RE_COUNTRY = re.compile(
r"(?:伊朗|以色列|美国|俄罗斯|中国|朝鲜|韩国|日本|伊拉克|叙利亚|乌克兰|"
r"黎巴嫩|巴勒斯坦|沙特|阿联酋|卡塔尔|约旦|土耳其|印度|巴基斯坦)"
)
RE_ORG = re.compile(
r"(?:伊斯兰革命卫队|国防部|五角大楼|北约|欧盟|联合国|"
r"国会|海军|空军|陆军|火箭军|战略支援部队|"
r"拉斐尔公司|洛克希德马丁|波音|雷神|诺斯罗普格鲁曼|"
r"哈马斯|真主党|胡塞武装|塔利班)"
)
RE_PERSON = re.compile(r"(?:主持人|专家)\s*\d+")
_re_json_object = re.compile(r"\{(?:[^{}]|\{[^{}]*\})*\}")
def parse_json_robust(raw: str):
"""
容错 JSON 解析:
1) 先尝试标准 json.loads
2) 失败则用正则提取单个 {term, domain} 对象,逐个解析后拼成 list
返回 list 或 None(彻底无法解析)
"""
raw = raw.strip()
if not raw:
return None
# 1) 标准解析
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
# 2) 正则提取所有 {...} 对象
matches = _re_json_object.findall(raw)
if not matches:
return None
items = []
for m in matches:
try:
obj = json.loads(m)
if isinstance(obj, dict):
items.append(obj)
except json.JSONDecodeError:
continue
return items if items else None
def extract_rules(text: str) -> List[Dict]:
"""规则层提取。返回: [{term, domain, source}, ...]"""
candidates: List[Dict] = []
for term in PURE_CN_MODEL_TERMS:
if term in text:
candidates.append({"term": term, "domain": "model", "source": "rule_model_cn"})
for m in RE_MODEL_NUMBER.finditer(text):
term = m.group(1).strip()
if not term or re.match(r"^\d{1,2}$", term):
continue
candidates.append({"term": term, "domain": "model", "source": "rule_model"})
for m in RE_CN_WEAPON.finditer(text):
term = m.group(0).strip()
if not term:
continue
candidates.append({"term": term, "domain": "weapon", "source": "rule_cn_weapon"})
for m in RE_COUNTRY.finditer(text):
candidates.append({"term": m.group(0).strip(), "domain": "org", "source": "rule_country"})
for m in RE_ORG.finditer(text):
candidates.append({"term": m.group(0).strip(), "domain": "org", "source": "rule_org"})
for m in RE_PERSON.finditer(text):
term = m.group(0).strip().replace("\u00a0", " ").replace("\xa0", " ")
candidates.append({"term": term, "domain": "person", "source": "rule_person"})
return candidates
# ====================================================================
# B) AI 层(DeepSeek)
# ====================================================================
DEEPSEEK_TERM_PROMPT = """你是一名军事科技编辑,请从以下节目文稿中提取专有名词,用于构建术语词典。
任务要求:
1. 提取所有武器/装备名称、型号/番号、单位/部队番号、人物(主持人/专家)、组织机构、技术概念
2. 每条标注 domain:
- weapon: 武器/装备名称(如"爱国者-3防空反导系统")
- model: 具体型号/番号(如"F/A-18E/F""歼-10""SM-3""萨德")
- unit_designation: 单位/部队番号
- person: 人物(主持人、专家、历史人物)
- org: 组织机构(国家、军队、公司、政治组织)
- tech_concept: 技术概念(如"动能撞击""饱和攻击")
3. 只返回 JSON 数组,不要任何解释、不要 markdown 代码块包裹、不要多余文字
文稿内容:
---
{text}
---
只返回 JSON:"""
def call_llm(text: str) -> List[Dict]:
"""调 DeepSeek API(通过统一 LLM 客户端)提取术语"""
model = DEEPSEEK_MODEL # 从 doco/.env 读取,默认 deepseek-v4-pro
print(f"[C1] 调用模型: {model}", file=sys.stderr)
try:
raw = llm_chat(
messages=[{"role": "user", "content": DEEPSEEK_TERM_PROMPT.format(text=text[:32000])}],
model=model,
temperature=0.0,
max_tokens=4000,
thinking=False,
)
except LLMConfigError as e:
print(f"[C1 WARNING] LLM 配置异常: {e},回退为仅规则层", file=sys.stderr)
return []
except Exception as e:
# llm.py 已打印原始错误响应体,这里不再吞错
print(f"[C1 WARNING] DeepSeek API 调用失败,回退为仅规则层", file=sys.stderr)
return []
raw = raw.strip()
if not raw:
print("[C1 WARNING] DeepSeek 返回空内容,回退为仅规则层", file=sys.stderr)
return []
# 去 markdown 包裹
if raw.startswith("```"):
lines = raw.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
raw = "\n".join(lines)
# 解析 JSON(容错:截断/格式断裂时用正则提取单个对象)
items = parse_json_robust(raw)
if items is None:
print(f"[C1 WARNING] DeepSeek 返回 JSON 不可解析, 前200字符={raw[:200]}", file=sys.stderr)
return []
if not isinstance(items, list):
print(f"[C1 WARNING] DeepSeek 返回非 JSON 数组(类型={type(items).__name__}),回退为仅规则层", file=sys.stderr)
return []
result = []
for item in items:
if not isinstance(item, dict):
continue
# DeepSeek 有时用 "name" 有时用 "term",两者均兼容
term = (item.get("term") or item.get("name") or "").strip()
domain = (item.get("domain") or "").strip().lower()
if not term or domain not in VALID_DOMAINS:
continue
result.append({"term": term, "domain": domain, "source": "ai_deepseek"})
return result
# ====================================================================
# C) 合并 + 词典累积 + 热词表
# ====================================================================
def load_dict() -> Dict:
DICT_PATH.parent.mkdir(parents=True, exist_ok=True)
if DICT_PATH.exists():
with open(DICT_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
return {}
def save_dict(data: Dict) -> None:
DICT_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(DICT_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def merge_candidates(rule_candidates: List[Dict], ai_candidates: List[Dict]) -> List[Dict]:
merged: Dict[str, Dict] = {}
for c in rule_candidates:
term = c["term"]
if term not in merged:
merged[term] = {"term": term, "domain": c["domain"], "source": c["source"]}
for c in ai_candidates:
term = c["term"]
if term in merged:
merged[term]["domain"] = c["domain"]
merged[term]["source"] += "+" + c["source"]
else:
merged[term] = {"term": term, "domain": c["domain"], "source": c["source"]}
return list(merged.values())
def update_dict(dict_data: Dict, merged_terms: List[Dict], episode_id: str) -> Tuple[int, Dict]:
new_count = 0
for entry in merged_terms:
term = entry["term"]
if term in dict_data:
existing = dict_data[term]
if episode_id not in existing.get("episodes", []):
existing["episodes"].append(episode_id)
existing["domain"] = entry["domain"]
else:
dict_data[term] = {
"term": term,
"domain": entry["domain"],
"episodes": [episode_id],
"frequency": 1,
}
new_count += 1
dict_data[term]["frequency"] = len(set(dict_data[term].get("episodes", [])))
return new_count, dict_data
HOTWORD_BLOCKLIST = {
"主持人", "主持人1", "主持人2", "主持人3", "主持人4", "主持人5",
"主持人 4", "主持人 5",
"专家1", "专家2", "专家3", "专家4", "专家5", "专家6", "专家7", "专家8",
# 清洗1:讯飞本就认得的常见词(只从热词表剔,词典保留)
"以色列", "伊朗", "伊拉克", "美国", "韩国", "黎巴嫩",
"国会", "国防部",
"标准", "防空", "反导",
}
# 清洗2:复合变体后缀白名单
_SUFFIX_WHITELIST = {
"系统", "导弹", "武器", "拦截弹", "反导系统", "防御系统",
"防空反导系统", "近程防空系统", "火箭弹", "弹道导弹",
"自杀式无人机", "无人机", "军事行动", "行动", "模块", "卫星",
}
# 清洗2:限定前缀白名单
_PREFIX_WHITELIST = {"以色列", "伊朗", "黎巴嫩", "民用"}
def _clean_compound_variants(terms: List[str]) -> List[str]:
"""
清洗2:复合变体收敛。
A 是 B 的子串时:
- 若 B = A + 通用后缀 → 丢 B 留 A
- 若 B = 限定前缀 + A → 丢 B 留 A
- 否则(A与B差异是型号数字/字母) → 两个都留
"""
drop: Set[str] = set()
sorted_terms = sorted(terms, key=len) # 短的在前
n = len(sorted_terms)
for i in range(n):
a = sorted_terms[i]
if a in drop:
continue
for j in range(i + 1, n):
b = sorted_terms[j]
if b in drop:
continue
if a not in b:
continue
# a 是 b 的子串
# 规则1: b = a + 白名单后缀
suffix = b[len(a):] # b 去掉 a(前缀)后的剩余部分
if suffix in _SUFFIX_WHITELIST:
drop.add(b)
continue
# 规则2: b = 白名单前缀 + a
prefix = b[:len(b) - len(a)] # b 前面的引导部分
if prefix in _PREFIX_WHITELIST and b[len(prefix):] == a:
drop.add(b)
continue
# 否则两个都留(如 爱国者 vs 爱国者-3)
return [t for t in terms if t not in drop]
def _merge_near_duplicates(terms: List[str]) -> List[str]:
"""
清洗3:近重复合并。
"真实承诺-4军事行动""真实承诺4行动"→合并留一条(短的)。
策略:
a) 去标点/空格后完全相同的 → 留最短原文
b) 去标点后 A 是 B 的子串,且差异部分仅在白名单后缀上 → 丢 B 留 A
"""
# a) 精确规范匹配
norm_map: Dict[str, List[str]] = {} # normalized -> [original...]
_strip = re.compile(r"[\s\-\u2014\u2013\u002d\u2212\uff0d]")
for t in terms:
norm = _strip.sub("", t)
norm_map.setdefault(norm, []).append(t)
keep: Set[str] = set()
drop: Set[str] = set()
for norm, originals in norm_map.items():
if len(originals) == 1:
keep.add(originals[0])
else:
shortest = min(originals, key=len)
keep.add(shortest)
# b) 模糊子串: 去标点后 A 是 B 的子串,B 比 A 多出的仅是白名单后缀 → 丢 B
kept_list = [t for t in terms if t in keep]
for a in sorted(kept_list, key=len):
if a in drop:
continue
a_norm = _strip.sub("", a)
for b in kept_list:
if b in drop or b == a:
continue
b_norm = _strip.sub("", b)
if a_norm in b_norm:
tail = b_norm[len(a_norm):]
if tail in _SUFFIX_WHITELIST:
drop.add(b)
# c) 核心词匹配: 两者都去掉尾部白名单后缀后,剩余部分相同 → 丢长的
kept_list2 = [t for t in terms if t in keep and t not in drop]
for a in sorted(kept_list2, key=len):
if a in drop:
continue
a_norm = _strip.sub("", a)
a_core = a_norm
for sfx in sorted(_SUFFIX_WHITELIST, key=len, reverse=True):
if a_core.endswith(sfx):
a_core = a_core[:-len(sfx)]
break
for b in kept_list2:
if b in drop or b == a:
continue
b_norm = _strip.sub("", b)
b_core = b_norm
for sfx in sorted(_SUFFIX_WHITELIST, key=len, reverse=True):
if b_core.endswith(sfx):
b_core = b_core[:-len(sfx)]
break
if a_core == b_core and len(a) < len(b):
# 都指向同一核心词,留短的
drop.add(b)
keep.difference_update(drop)
return list(keep)
def build_hotwords(dict_data: Dict, episode_id: str) -> Tuple[str, int, int]:
"""
生成本期热词表(| 分隔,≤200 条)。
返回 (hotwords_str, 清洗前条数, 清洗后条数)
"""
episode_terms: List[str] = []
for term, entry in dict_data.items():
if episode_id not in entry.get("episodes", []):
continue
if len(term) < MIN_TERM_LEN or len(term) > MAX_TERM_LEN:
continue
if term in HOTWORD_BLOCKLIST:
continue
episode_terms.append(term)
# 按词频降序 + 同频按字典序
episode_terms.sort(key=lambda t: (-dict_data[t].get("frequency", 0), t))
before = len(episode_terms)
# 清洗2: 复合变体收敛
episode_terms = _clean_compound_variants(episode_terms)
# 清洗3: 近重复合并
episode_terms = _merge_near_duplicates(episode_terms)
# 清洗后再次排序(可能丢了一些高频词,用剩下列表的词典频率重排)
episode_terms.sort(key=lambda t: (-dict_data[t].get("frequency", 0), t))
after = len(episode_terms)
selected = episode_terms[:MAX_HOTWORDS]
return "|".join(selected), before, after
def write_hotwords(hotwords: str, episode_id: str) -> Path:
program_dir = PROJECT_DIR / "programs" / episode_id
program_dir.mkdir(parents=True, exist_ok=True)
out = program_dir / "本期热词表.txt"
out.write_text(hotwords, encoding="utf-8")
return out
def write_audit_trail(episode_id, rule_candidates, ai_raw, merged) -> Path:
program_dir = PROJECT_DIR / "programs" / episode_id
program_dir.mkdir(parents=True, exist_ok=True)
out = program_dir / "c1_term_candidates.json"
audit = {
"episode_id": episode_id,
"rule_candidates": rule_candidates,
"ai_raw_response": ai_raw,
"merged_candidates": merged,
"stats": {
"rule_count": len(rule_candidates),
"ai_count": len(ai_raw),
"merged_count": len(merged),
},
}
out.write_text(json.dumps(audit, ensure_ascii=False, indent=2), encoding="utf-8")
return out
# ====================================================================
# 主流程
# ====================================================================
def run_terms(episode_id: str, a_script_path: Path, no_ai: bool = False) -> Dict:
"""C1 术语提取 + 累积词典 + 本期热词表主流程"""
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
a_script_path = Path(a_script_path)
if not a_script_path.exists():
raise FileNotFoundError(f"A 稿文件不存在: {a_script_path}")
# 支持 .docx 和 .txt
if a_script_path.suffix.lower() == ".docx":
from docx import Document
doc = Document(str(a_script_path))
parts = []
for p in doc.paragraphs:
if p.text.strip():
parts.append(p.text.strip())
# 表格内容也提取
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
if cell.text.strip():
parts.append(cell.text.strip())
text = "\n".join(parts)
else:
text = a_script_path.read_text(encoding="utf-8")
# 日志打印实际文件名(含扩展名) + 字符数
print(f"[C1] 实际读取文件: {a_script_path.name}")
print(f"[C1] 字符数: {len(text)}")
print("[C1] 规则层提取...")
rule_candidates = extract_rules(text)
seen = set()
rule_dedup = []
for c in rule_candidates:
if c["term"] not in seen:
seen.add(c["term"])
rule_dedup.append(c)
print(f"[C1] 规则候选: {len(rule_dedup)}")
ai_candidates: List[Dict] = []
if no_ai:
print("[C1] --no-ai,跳过 AI 层")
else:
print("[C1] AI 层提取(DeepSeek)...")
ai_candidates = call_llm(text)
seen = set()
ai_dedup = []
for c in ai_candidates:
if c["term"] not in seen:
seen.add(c["term"])
ai_dedup.append(c)
ai_candidates = ai_dedup
print(f"[C1] AI 候选: {len(ai_candidates)}")
merged = merge_candidates(rule_dedup, ai_candidates)
print(f"[C1] 合并后: {len(merged)}")
print("[C1] 更新累积词典...")
dict_data = load_dict()
new_count, dict_data = update_dict(dict_data, merged, episode_id)
save_dict(dict_data)
print(f"[C1] 词典入库: 新增 {new_count} 条, 词典共 {len(dict_data)}")
print("[C1] 生成本期热词表...")
hotwords_str, hw_before, hw_after = build_hotwords(dict_data, episode_id)
hw_path = write_hotwords(hotwords_str, episode_id)
hw_count = len(hotwords_str.split("|")) if hotwords_str else 0
print(f"[C1] 热词清洗: {hw_before}{hw_after} 条(清洗后 {hw_count} 条)")
print(f"[C1] 本期热词表: {hw_count} 条 → {hw_path}")
audit_path = write_audit_trail(episode_id, rule_dedup, ai_candidates, merged)
print(f"[C1] 留痕: {audit_path}")
return {
"rule_count": len(rule_dedup),
"ai_count": len(ai_candidates),
"merged_count": len(merged),
"dict_new_entries": new_count,
"dict_total": len(dict_data),
"hotword_count": hw_count,
"hotwords_path": str(hw_path),
"audit_path": str(audit_path),
"dict_path": str(DICT_PATH),
}
+4 -2
View File
@@ -165,10 +165,12 @@ def extract_audio(
result = subprocess.run(
cmd,
capture_output=True,
text=True,
# text=True 在 Windows 上 ffmpeg stderr 含非 GBK 字节时会崩;
# 用 bytes 捕获,报错时再解码(errors="replace")
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg 音频提取失败: {result.stderr}")
stderr_str = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
raise RuntimeError(f"ffmpeg 音频提取失败: {stderr_str}")
return output_path