5 Commits

Author SHA1 Message Date
simonkoson 1578163602 docs: 期次一条龙录入子项目立项——PRD v1.0 + 寄存条 + CLAUDE.md 登记
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:00:15 +08:00
simonkoson 7a48af3e9e fix: AI诊断摘要正则提取 + 仪表盘排序/宽度修复
- DiagnosisSummary extractSection 改为正则模糊匹配,兼容 DeepSeek 不同标题格式
- 仪表盘近12期柱状图改为从左到右按播出时间升序
- 全局内容区宽度参数定位:.app-content max-width 1190px + width 100%(修复 Ant Design flex 下 margin auto shrink-to-fit)
- Dashboard.css 去除多余 max-width 限制

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-06 20:38:33 +08:00
simonkoson 7eb6511169 feat: CCA v6 腾讯云部署 + 审稿台(含查找替换)
- deploy/cca_route.py: Flask 蓝图(6个API端点),WAV自动转MP3
- deploy/cca.html: 4步单页流程(上传→处理→审稿→下载),查找替换(Ctrl+H)
- src/term_normalizer.py: 新增正则层(同音字/引号/书名号/小数点/波浪号)
- src/ai_proofreader.py: speaker角色识别+专家段增强Prompt+的地得加强
- src/ai_line_breaker.py: 引号不跨屏+极短行合并+短句合并间隔放宽
- cca_pipeline.py: Step 2.5 校对后二次正则兜底
- 已部署至 http://101.42.29.217/cca.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-05 21:44:52 +08:00
simonkoson ede30d3043 feat: CCA 唱词助手子项目 v3 — 脚本版流水线完成
新增 cca/ 子项目:编导A稿+人声音频 → ASR+AI校对+AI折行 → 5段SRT字幕。
- 讯飞录音文件转写标准版(热词注入)
- DeepSeek AI校对(严格纪律:只改错别字/术语/填充词,不润色)
- DeepSeek AI折行(语义断句,≤14字/行)
- 节目结构自动切分(导视/正片×3/预告)
- 绝对时间戳SRT输出(大洋系统兼容)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-04 15:25:08 +08:00
simonkoson 1ccb37f0c7 docs: 添加双机同步开发指南
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-03 21:27:10 +08:00
33 changed files with 8210 additions and 21 deletions
+10 -3
View File
@@ -13,15 +13,15 @@
## 🔖 状态栏(每次结束 session 前必须更新这三行) ## 🔖 状态栏(每次结束 session 前必须更新这三行)
- **最后更新**Claude(顾问+动手)| 2026-07-03 - **最后更新**Claude Fable(顾问)| 2026-07-07
- **当前状态一句话**L4 AI 诊断报告已实现(DeepSeek V4 Pro 生成、摘要块+详情页、三档自适应、22期内容摘要卡已入库)。收视分析看板 L1-L4 全部完成 - **当前状态一句话****期次一条龙录入子项目立项**(收视分析持续运行入口,落点责编录入页)——PRD v1.0 完成、制片人三项拍板已定、寄存条已建,**暂不开发**。看板 L1-L4 + 仪表盘均已就绪
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」 - **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
--- ---
## 🤖 给 AI 的工作约定(READ FIRST ## 🤖 给 AI 的工作约定(READ FIRST
- 开工前先读完:本文件「状态栏 / 交接备注 / 关键决策」三节 + 四份宪法 + 两份外迁寄存条(`寄存条_看板升级已外迁.md``寄存条_doco子项目已外迁.md`)。 - 开工前先读完:本文件「状态栏 / 交接备注 / 关键决策」三节 + 四份宪法 + `note/` 下全部外迁寄存条(看板升级 / doco / CCA / 期次一条龙录入,共四份)。
- **角色定位**Claude Code 默认是**顾问**——出方案、审 Plan、卡壳兜底、写交接文档。**写代码(Plan + Act)交给 Cline,不要架空 Cline**。顾问位是为**省 token**:日常 CRUD、常规实现不该 Claude Code 亲自写。**仅两种情况下场动手**:① Cline 连续几次改都过不去的坎;② 制片人明确点名让 Claude Code 操刀。 - **角色定位**Claude Code 默认是**顾问**——出方案、审 Plan、卡壳兜底、写交接文档。**写代码(Plan + Act)交给 Cline,不要架空 Cline**。顾问位是为**省 token**:日常 CRUD、常规实现不该 Claude Code 亲自写。**仅两种情况下场动手**:① Cline 连续几次改都过不去的坎;② 制片人明确点名让 Claude Code 操刀。
- 给 Cline 的指令一律用**代码块**封装,方便复制;不要让制片人手动粘贴整份文件内容。 - 给 Cline 的指令一律用**代码块**封装,方便复制;不要让制片人手动粘贴整份文件内容。
- 完成一段工作 → **增量更新**:在「已完成」追加一条、「待办」勾掉/新增、必要时补「关键决策」,最后改「状态栏」三行。只追加/勾选,不重写、不删历史。 - 完成一段工作 → **增量更新**:在「已完成」追加一条、「待办」勾掉/新增、必要时补「关键决策」,最后改「状态栏」三行。只追加/勾选,不重写、不删历史。
@@ -37,6 +37,8 @@
- **看板分析升级**`寄存条_看板升级已外迁.md`):关键词——看板升级 / 双引擎象限图 / 题材热度 / AI 打标 / Prompt 1·2·3 / ground-truth / episodes 加字段 / 23 期回填 / 能力地图 / 置信度三档 / 本期诊断小结。 - **看板分析升级**`寄存条_看板升级已外迁.md`):关键词——看板升级 / 双引擎象限图 / 题材热度 / AI 打标 / Prompt 1·2·3 / ground-truth / episodes 加字段 / 23 期回填 / 能力地图 / 置信度三档 / 本期诊断小结。
- **Doco 文稿整理**`寄存条_doco子项目已外迁.md`):关键词——doco / 文稿整理 / 三方融合 / 视频双路拆分 / A·B 稿 / ASR / 讯飞 / DeepSeek Vision / 终版文稿 / 差异报告 / 段落对齐。**⚡ 2026-06-26 已交付:22 期融合A稿成品在 `doco/deliverables/`(按播出顺序命名),缺第01期(无A稿)。下一步:批量导入知识库(走 Phase 3 上传/embedding 链路)。** - **Doco 文稿整理**`寄存条_doco子项目已外迁.md`):关键词——doco / 文稿整理 / 三方融合 / 视频双路拆分 / A·B 稿 / ASR / 讯飞 / DeepSeek Vision / 终版文稿 / 差异报告 / 段落对齐。**⚡ 2026-06-26 已交付:22 期融合A稿成品在 `doco/deliverables/`(按播出顺序命名),缺第01期(无A稿)。下一步:批量导入知识库(走 Phase 3 上传/embedding 链路)。**
- **CCA 唱词助手**`寄存条CCA唱词助手子项目已外迁.md`):关键词——cca / 唱词助手 / changci / SRT 字幕 / 拍词规则 / 编导审稿台 / 唱词校对 / ASR 转字幕 / 大洋字幕格式 / 折行规则。**⚡ 2026-07-04 立项:编导 A 稿+人声音频 → ASR+AI 校对+编导审稿 → 大洋格式 SRT。先部署 lanhao 配音 2.0 测试,成熟后并入 TPS。**
- **期次一条龙录入**`寄存条期次一条龙录入子项目已外迁.md`,目录 `episode-intake/`):关键词——一条龙 / 期次录入流水线 / 流水线状态点 / 文稿回联期次 / transcript_item_id / 005 迁移 / AI 处理按钮 / 标签审核台 / draft-reviewed 流转 / 责编录入抽屉。**⚡ 2026-07-07 立项:责编录入页升级为每期任务清单(收视→文稿入库→AI打标+摘要卡→制片人审核→进看板),让收视分析持续生长。PRD v1.0 就绪,暂不开发;实施时落主干、走主干纪律。已拍板:文稿口径=doco 融合A稿(doco 转常态运行)、22 期导入时回联期次、责编可触发 AI 处理。**
**主干仍管**:Phase 03 主干代码、全部 backend schema/API/迁移、全部前端 React 实施、主干 bug/性能/新需求、Cline 的全部 Plan+Act。 **主干仍管**:Phase 03 主干代码、全部 backend schema/API/迁移、全部前端 React 实施、主干 bug/性能/新需求、Cline 的全部 Plan+Act。
@@ -83,6 +85,8 @@
## 4. 已完成(只追加,最新在上) ## 4. 已完成(只追加,最新在上)
- [2026-07-07] **期次一条龙录入子项目立项(只立项不开发)**:① PRD v1.0 写入 `episode-intake/PRD_期次一条龙录入_v1.md`(每期任务清单模式、4 状态点、抽屉四区块、6 个新 API、005 迁移只加 transcript_item_id 一列、看板只用 reviewed 期次、四刀分期+七条验收);② 制片人三项拍板:文稿口径=doco 融合A稿(CCA 是播出前工具不算终稿,doco 转常态运行)、22 期批量导入时回联期次、责编可触发 AI 处理;③ 子项目 CLAUDE.md + 寄存条建立,CCA 寄存条清单表同步更新。
- [2026-07-06] **三处 Bug 修复 + 全局宽度调优**:① AI 诊断摘要块 `extractSection` 从硬编码 `indexOf` 改为正则模糊匹配,解决 DeepSeek 返回标题格式不一致导致干条不显示的问题;② 仪表盘近 12 期柱状图加 `.reverse()` 改为从左到右按播出时间升序;③ 全局内容区宽度:定位到 `.app-content``AppLayout.css`)的 `max-width` 是唯一有效参数(必须搭配 `width: 100%`,否则 Ant Design flex 布局下 `margin: 0 auto` 会触发 shrink-to-fit),最终定为 **1190px**
- [2026-07-03] **L4 AI 诊断报告**:① Prompt 4(内容摘要卡,MiMo 生成)+ Prompt 5(诊断报告,DeepSeek V4 Pro)撰写完成;② 22 期文稿摘要卡批量生成并导入 episodes.content_digest004 迁移);③ 后端 POST /api/analytics/diagnosis-report 端点(组装数据+调 DeepSeek+内存缓存);④ 前端摘要块 DiagnosisSummary(左右两块、三档自适应色调、可滚动、粗体渲染)+ 详情页 DiagnosisReportreact-markdown 渲染+重新生成+免责声明);⑤ 话题性(社交货币)三维框架设计(大众认知度/降维切口/惊奇密度)。模型选型:DeepSeek V4 Pro 胜出(vs Qwen-Max 对比测试)。 - [2026-07-03] **L4 AI 诊断报告**:① Prompt 4(内容摘要卡,MiMo 生成)+ Prompt 5(诊断报告,DeepSeek V4 Pro)撰写完成;② 22 期文稿摘要卡批量生成并导入 episodes.content_digest004 迁移);③ 后端 POST /api/analytics/diagnosis-report 端点(组装数据+调 DeepSeek+内存缓存);④ 前端摘要块 DiagnosisSummary(左右两块、三档自适应色调、可滚动、粗体渲染)+ 详情页 DiagnosisReportreact-markdown 渲染+重新生成+免责声明);⑤ 话题性(社交货币)三维框架设计(大众认知度/降维切口/惊奇密度)。模型选型:DeepSeek V4 Pro 胜出(vs Qwen-Max 对比测试)。
- [2026-07-03] **收视分析看板 React 前端实现(L1–L3)**:① 指标卡 5 列(均值/基础达标率/摸高完成率含四档动画/期次数/年度目标);② 走势折线图(dataZoom 滑块+确认按钮范围过滤,下游模块联动);③ 双列对比区(左列季度+编导、右列题材饼图+条形图);④ 双引擎象限图(题材热度×叙事结构散点,气泡编码份额/三色/钩子强度)。附带:25 期真实收视+AI 标签数据导入、侧边栏 fixed 定位、滑块滚轮冲突修复。后端 analytics API 增返 4 个 AI 标签字段。 - [2026-07-03] **收视分析看板 React 前端实现(L1–L3)**:① 指标卡 5 列(均值/基础达标率/摸高完成率含四档动画/期次数/年度目标);② 走势折线图(dataZoom 滑块+确认按钮范围过滤,下游模块联动);③ 双列对比区(左列季度+编导、右列题材饼图+条形图);④ 双引擎象限图(题材热度×叙事结构散点,气泡编码份额/三色/钩子强度)。附带:25 期真实收视+AI 标签数据导入、侧边栏 fixed 定位、滑块滚轮冲突修复。后端 analytics API 增返 4 个 AI 标签字段。
- [2026-07-03] 看板升级子项目原型阶段收工:L2 打标流水线就绪(GT v0.6.0 / 25 期 / 三个 Prompt 可用)+ L3 HTML 原型验证通过(双引擎象限图+5 模块+视觉定稿)+ 003 迁移已执行(episodes +7 AI 标签列)。交付清单见寄存条。 - [2026-07-03] 看板升级子项目原型阶段收工:L2 打标流水线就绪(GT v0.6.0 / 25 期 / 三个 Prompt 可用)+ L3 HTML 原型验证通过(双引擎象限图+5 模块+视觉定稿)+ 003 迁移已执行(episodes +7 AI 标签列)。交付清单见寄存条。
@@ -99,6 +103,7 @@
- [x] ~~L4 AI 诊断报告~~ → 已完成(2026-07-03 - [x] ~~L4 AI 诊断报告~~ → 已完成(2026-07-03
- [ ] **下一刀三选一**:① 语义搜索界面(不依赖任何材料,随时能开,是 Phase 4a 硬门槛);② PDF 原文关联 + 大文件存储架构(需 Opus 审方案,优先级较高);③ 界面像素级打磨+视觉规范统一。 - [ ] **下一刀三选一**:① 语义搜索界面(不依赖任何材料,随时能开,是 Phase 4a 硬门槛);② PDF 原文关联 + 大文件存储架构(需 Opus 审方案,优先级较高);③ 界面像素级打磨+视觉规范统一。
- [ ] **期次一条龙录入开发**(子项目已立项 2026-07-07PRD 就绪在 `episode-intake/`,等制片人排期;四刀分期,第二刀含 doco 22 期导入回联,与既有待办「200+ md 批量录入」相互独立)。
- [ ] PDF 大文件存储:**大文件不入库,单独文件仓库,DB 只存地址指针**(md 正文+向量留库参与检索;pdf 仅按需调阅)。地基一次定对,避免上云返工。 - [ ] PDF 大文件存储:**大文件不入库,单独文件仓库,DB 只存地址指针**(md 正文+向量留库参与检索;pdf 仅按需调阅)。地基一次定对,避免上云返工。
- [ ] 200+ Obsidian md 批量录入(**建议在 PDF 存储方案定后做**;先试 10 篇验证解析/落位再全量,每篇都真调 MiniMax 耗额度)。 - [ ] 200+ Obsidian md 批量录入(**建议在 PDF 存储方案定后做**;先试 10 篇验证解析/落位再全量,每篇都真调 MiniMax 耗额度)。
- [ ] 「按编导看稿」独立筛选视图(路线 A 重构时一并处理,从来源树里迁出来)。 - [ ] 「按编导看稿」独立筛选视图(路线 A 重构时一并处理,从来源树里迁出来)。
@@ -130,6 +135,7 @@
- **L4 摘要块视觉**:左块固定粉红色系(`#c0584f`),右块跟 tier 三档变色(绿/蓝/红)。两块均可滚动查看完整内容。 - **L4 摘要块视觉**:左块固定粉红色系(`#c0584f`),右块跟 tier 三档变色(绿/蓝/红)。两块均可滚动查看完整内容。
- **L4 话题性框架**:社交货币理论三维(大众认知度/降维切口/惊奇密度),写入摘要卡不作为独立 AI 标签字段。 - **L4 话题性框架**:社交货币理论三维(大众认知度/降维切口/惊奇密度),写入摘要卡不作为独立 AI 标签字段。
- **L4 prompt5 约束**:① 提及期次必须用「第X期《节目名》」格式;② 话题性必须独立分析段;③ 军事装备分类须准确(枪械≠冷兵器)。 - **L4 prompt5 约束**:① 提及期次必须用「第X期《节目名》」格式;② 话题性必须独立分析段;③ 军事装备分类须准确(枪械≠冷兵器)。
- **全局页面内容区宽度**:唯一有效参数是 `frontend/src/components/Layout/AppLayout.css``.app-content``max-width`(当前 **1190px**)。**必须保留 `width: 100%` + `box-sizing: border-box`**,不能单独用 `margin: 0 auto`Ant Design flex 布局下会触发 shrink-to-fit,宽度锁死无法调节)。制片人直接说"调这个参数"即可。
--- ---
@@ -148,6 +154,7 @@
3. **已验证好用的布局别为对齐整体重构**(flex 改 Grid 连坏两轮);样式微调优先。 3. **已验证好用的布局别为对齐整体重构**(flex 改 Grid 连坏两轮);样式微调优先。
4. Cline 报告自相矛盾时,**把源码发给 Opus 一眼定位**胜过让 Cline 反复试错。 4. Cline 报告自相矛盾时,**把源码发给 Opus 一眼定位**胜过让 Cline 反复试错。
5. 像素级打磨边际收益递减,**见好就收**,记 backlog 改天连视觉规范统一弄。 5. 像素级打磨边际收益递减,**见好就收**,记 backlog 改天连视觉规范统一弄。
6. **页面宽度问题**Ant Design `Layout > Content` 是 flex 子元素,`margin: 0 auto` 不搭配显式 `width` 会导致元素缩到内容宽度(shrink-to-fit),改 `max-width` 数值完全无效。必须同时写 `width: 100%`
--- ---
+7
View File
@@ -0,0 +1,7 @@
# 讯飞录音文件转写标准版(不是大模型版)
# 控制台: https://console.xfyun.cn/
XFYUN_APP_ID=your_app_id
XFYUN_SECRET_KEY=your_secret_key
# DeepSeek V4 Pro(用于专有名词提取 + 校对)
DEEPSEEK_API_KEY=your_deepseek_api_key
+7
View File
@@ -0,0 +1,7 @@
.env
output*/
__pycache__/
*.pyc
*.mp3
*.wav
pr/
+160
View File
@@ -0,0 +1,160 @@
# 项目协作主控文件 (cca/CLAUDE.md)
---
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
- **最后更新**Claude Code(动手开发)| 2026-07-05
- **当前状态一句话**:CCA 唱词助手已部署至腾讯云(http://101.42.29.217/cca.html)。完整流程可用:上传音频+A稿 → ASR+AI校对 → 审稿台(含查找替换)→ 生成SRT下载。WAV自动转MP3。进入内测阶段。
- **下一个动手的人从这里开始**:读完本文件。线上地址 http://101.42.29.217/cca.html(从配音首页"唱词助手"按钮进入)。服务器凭证见 `/workspace/military_tech_voice/backend/.env`。本地调试:`python -X utf8 cca_pipeline.py --asr-cache output/asr_raw.json --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v6`
---
## 🤖 给 AI 的工作约定 (READ FIRST)
- 开始任何工作前,先读完本文件,特别是「状态栏」「交接备注」「关键决策」三节。
- 完成一段工作后**增量更新**:「已完成」追加一条、「待办」勾掉或新增、必要时更新「关键决策」,最后改「状态栏」三行。不要重写整份文件,不要删历史,只追加/勾选。
- 涉及发送、发布、删除、改权限、付款等不可逆操作前,先在对话里跟制片人确认。
- **本项目着急用**——优先出可用版本,不追求完美架构。先跑通再优化。
- **沟通**:全程简体中文;制片人不懂代码,解释说人话;给明确推荐不反复抛选项。
---
## 1. 项目概览
- **项目名**CCA 唱词助手(ChangCi Assist)— 《军事科技》栏目编导/责编用的唱词字幕生成工具
- **目标**:把编导 A 稿 + 粗编人声音频 → 经过 ASR + AI 校对 + 编导审稿 → 最终输出符合大洋系统的 SRT 字幕文件
- **使用角色**:编导(提供 A 稿+音频、审稿确认)、责编(拿 SRT 拉到大洋上线)
- **部署计划**:先独立部署在 lanhao 配音 2.0 网站测试,成熟后吸收进 TPS 主项目作为子功能
### 核心工作流
```
编导A稿 ──→ AI提取专有名词词典
粗编人声音频 + 词典 ──→ 讯飞ASR(录音文件转写标准版) ──→ 带时间戳ASR稿
ASR稿 ⊕ A稿 比对
┌─ AI自动修正(明显错别字)
└─ 编导审稿台(拿不准的差异)
B稿(定稿)
按拍词规则折行 + 时间戳
SRT文件(大洋格式)
```
---
## 2. 技术栈与运行方式
- **语言**Python(与主项目 backend 对齐)
- **前端**:纯 HTML/CSS/JS 单页(cca.html),暗色主题匹配 lanhao 配音系统,无框架依赖
- **ASR**:讯飞开放平台 录音文件转写标准版(已有 API Key,与 doco 共用同一套讯飞凭证)
- **AI**:LLM 用于两处——① 从 A 稿提取专有名词词典;② ASR 稿校对(的地得、引号、错别字)
- **输出格式**:SRT(大洋系统兼容格式,有样本参照)
- **美术风格**:参考 `ai-labeling/example/`(功能区划.jpg、幅面参考.jpg、页面风格.webp),与 TPS 主项目 UI 风格对齐
- **凭证**:子项目自己 `.env`,不动主项目 `backend/.env`
---
## 3. 当前进度
- **已完成至**:腾讯云部署完成 + 审稿台(含查找替换)上线。流水线 v6 + Web 审稿台 + WAV 自动转码。进入内测。
- **正在做**:无(等待内测反馈)。
- **卡点/待解**:无硬卡点。已知残留:ASR 切句边界跨越固定搭配(如"第二次/世界大战")暂无法修复——需要跨句拆词检测(可做但需更大短语词典)。
---
## 4. 已完成(只追加,最新在上)
- [2026-07-05] **腾讯云部署 + 审稿台上线**:① deploy/cca_route.pyFlask 蓝图,6 个 API 端点:upload/status/review/save/generate/download);② deploy/cca.html(4 步单页流程:上传→处理→审稿→下载,暗色主题匹配配音系统);③ 审稿台功能:左栏 ASR 原文 vs 右栏 AI 校对稿对比、逐句编辑确认、仅看修改过滤、全部确认、**查找替换**(Ctrl+H,支持逐个/全部替换+高亮定位);④ WAV 大文件自动 ffmpeg 转 MP3(解决讯飞上传超时);⑤ 服务器架构:Nginx→静态 HTML + Flask:5000CCA 源码在 `/workspace/military_tech_voice/backend/cca_src/`,蓝图注册在 `app/routes/cca.py`。首页已加"唱词助手"入口按钮。
- [2026-07-05] **脚本版流水线 v6(14项审片修复+专家段识别)**:① term_normalizer 新增:波浪号→"到"、顿号→空格、小数点保留(0.9马赫)、同音字映射表(建制→舰只/沉默→沉没/继承→击沉)、引号上下文感知(日向号加引号但日向级不加)、书名号补全(《军事科技》);② ai_proofreader 新增:speaker 角色自动识别(解说/主持/专家)、专家采访增强 Prompt(严格删除嗯/呃/啊/这个/那么/就是说等口头语)、的地得规则大幅加强(+大量示例)、数字照抄 ASR 规则;③ ai_line_breaker 新增:引号不跨屏后处理(≤6字引号内容不拆两行)、极短行合并(≤3字+时长<1秒→并入相邻行)、极短句合并间隔放宽(≤4字句间隔阈值1200ms);④ line_breaker 修改:clean_punctuation 保留小数点、顿号→空格;⑤ pipeline 新增 Step 2.5 校对后二次正则修复(兜住 AI 校对引入的新问题)。
- [2026-07-04] **脚本版流水线 v5(四层纠错体系+折行优化)**:基于制片人逐帧审片反馈,解决 10+ 问题。新增四层纠错:① term_normalizer.py 正则层(型号短横线 F-15J/武器昵称引号"鱼鹰"/中文数字修复,零token);② 校对 Prompt 升级(+代词他→它、+的地得纠错、+A稿权重规则);③ 折行 Prompt 升级(+禁忌字规则"的了着过"不开头、+主谓宾拆分规则、+不可拆词示例);④ 折行后处理三层(超长切分→禁忌字修复→拆词检测 _fix_split_words)。新增短句合并预处理(解决专家气口碎片句问题)。
- [2026-07-04] **脚本版流水线 v3(绝对时间戳+严格校对)**:① 恢复绝对时间戳(方便在大洋时间线对位);② 重写校对 Prompt——铁律:只改错别字/同音字、术语格式、口语填充词,绝不润色/调序/替换实词;③ 校对效果:60 处修正(vs v2 的 100 处,去掉了过度修改)。输出目录 `output_v3/`
- [2026-07-04] **脚本版流水线 v2 真实测试通过**:① 热词提取(规则+AI,127个术语)→ ② 讯飞ASR(94秒完成25MB音频,357句)→ ③ AI校对(DeepSeek,修正同音字/术语格式/口语填充,"建制"→"舰只"等)→ ④ AI折行(语义断句,98%行≤14字)→ ⑤ 5段SRT输出(段内相对时间戳,从00:00:00开始)。新增:hotword_extractor.py(热词提取)、ai_proofreader.pyAI校对)、ai_line_breaker.pyAI折行)。
- [2026-07-04] 脚本版流水线骨架完成:① asr_client.py(讯飞ASR适配,从doco复用);② line_breaker.py(折行引擎,≤14字/语义断句/空白行检测);③ srt_writer.py(大洋格式SRT输出);④ segment_splitter.py(节目结构切分:导视/正片×3/预告);⑤ cca_pipeline.py(主入口串联全流程)。本地测试全部通过。
- [2026-07-04] 子项目立项:目录结构、CLAUDE.md、Brief、主项目寄存条建立。
---
## 5. 待办(按优先级)
- [x] ~~PRD / 业务规则确认~~ → 已在对话中完成(2026-07-04
- [x] ~~脚本版流水线~~ → v5 完成(四层纠错+折行优化+短句合并)
- [x] ~~AI 校对层~~ → 已实现(四层防线:热词→正则→AI校对→折行后处理)
- [x] ~~制片人审片第一轮~~ → 10+ 问题全部解决
- [x] ~~编导审稿台~~ → 已完成(查找替换+逐句对比+编辑确认,2026-07-05
- [x] ~~部署至腾讯云~~ → 已完成(http://101.42.29.217/cca.html2026-07-05
- [ ] **内测反馈收集**:同事试用中,等待反馈
- [ ] **大洋系统验证**:导入 SRT 测试兼容性
- [ ] **热词注入真实 ASR 测试**:用 `--audio` 跑完整流水线(非缓存),验证热词在转写层的效果
- [ ] **首页入口按钮可能被遮挡**index.html 已添加代码但可能需要样式调整(Ctrl+F5 刷新后可见)
---
## 6. 关键决策(跨 session 最易丢)
- [2026-07-04] **ASR 稿是权威底稿**:最终 SRT 必须和音频对应,ASR 稿(时间戳+内容)是基准。A 稿只提供专有名词写法和上下文参考,不能用 A 稿覆盖 ASR 的内容结构。
- [2026-07-04] **讯飞用录音文件转写标准版**(与 doco 一样,不要大模型版)。热词偏置注入专有名词词典。
- [2026-07-04] **编导审稿台是必要环节**:AI 拿不准的差异必须过编导确认,不能全自动。尊重编导创作主权(继承主项目设计哲学)。
- [2026-07-04] **先独立部署再吸收**:先在 lanhao 配音 2.0 上跑通测试,成熟后并入 TPS 主项目。不等主项目进度。
- [2026-07-04] **SRT 格式有样本**data/ 下 3 个 SRT 是人工拍词导出的真实样本,作为格式参照。
- [2026-07-04] **折行(拍词)规则**
- A. 每行 ≤ 14 字
- B. ASR 中 >2 秒空白 → 插入空白行(屏幕清字幕)
- C. 按语义断句(不机械凑满 14 字),符合阅读习惯
- D. 去掉逗号/句号/感叹号等标点,只保留引号
- [2026-07-04] **输出结构(5 个 SRT**
- 导视 1 个 + 正片 3 个 + 下期预告 1 个
- 正片 3 个拆分依据:按时长大致均分,优先在角色转换处(解说词→专家采访)切分
- 5 个 SRT 时间戳连续拼接 = 音频总长
- [2026-07-04] **节目结构标志词**
- 导视结束标志:听到"本期《军事科技》……"
- 正片开始标志:听到"各位观众你们好,我是主持人蓝皓"
- 正片结束标志:听到"好了观众朋友们,感谢您关注国防军事频道军事科技……"
- 正片之后是下期预告(无固定话术)
- [2026-07-04] **正片拆 3 个 SRT 的原因**:大洋字幕系统不稳定容易出错,责编提的需求。
- [2026-07-04] **音频是纯人声**:已分离好的干净音频(无 BGM/音效),无需人声分离。
- [2026-07-04] **先出脚本版**:命令行跑通流水线,审稿台第二步做。
- [2026-07-04] **讯飞代码从 doco 复用**`doco/src/doco/asr_adapter.py` 有完整签名/上传/轮询/解析逻辑,直接适配。
- [2026-07-04] **SRT 用绝对时间戳**:每段 SRT 的时间戳是音频中的真实位置(不从 00:00:00 开始),方便在大洋时间线直接对位。制片人实测确认比相对时间戳好用。
- [2026-07-04] **AI 校对严格纪律**:只允许改三类——① 错别字/同音字 ② 术语格式(F-15J)③ 口语填充词删除。绝不润色、绝不调序、绝不替换实词。ASR 是已录音频的转写,改不了内容。
- [2026-07-04] **两层 ASR 纠错防线**:第一层=热词注入(预防,让讯飞在转写时就认对专有名词);第二层=AI 校对(修正,用 A 稿上下文判断同音字)。两层互补。
- [2026-07-04] **LLM 选型已定**:校对+折行+热词提取统一用 DeepSeekdeepseek-chat),性价比最优。
- [2026-07-04] **四层纠错体系**(v5 确立):① 热词注入(讯飞ASR层,预防中文同音字)→ ② term_normalizer 正则后处理(型号短横线/引号/中文数字,零token确定性替换)→ ③ AI 校对(DeepSeek,同音字/代词/的地得/术语/填充词)→ ④ 折行后处理(超长切分+禁忌字修复+拆词检测)。
- [2026-07-04] **折行三条铁律**:① 词语不可拆分到两屏 ② "的了着过地得和与及或"不能作为新行第一个字 ③ 主谓宾优先折为"主语(折行)谓语+宾语"。
- [2026-07-04] **短句合并策略**:ASR 按音频静音切句,专家气口会产生碎片短句(2-5字)。折行前先合并:≤8字+间隔<800ms→合并为一个语义单元再送AI折行。>2s静音仍插空白行。
- [2026-07-04] **A稿与ASR权重规则**:内容冲突时ASR优先(配音员可能改过措辞),但专有名词格式/写法按A稿(如F-35A、"鱼鹰"引号)。
- [2026-07-04] **国家代词不改**:指代国家时口语用"他"是可接受的,不纠正;只纠正指代武器/舰艇/飞机/导弹时的"他→它"。
---
## 7. ⏩ 交接备注
- **立项背景**:编导剪完节目后需要给责编一份 SRT 唱词字幕文件。传统流程是责编拿折行稿到大洋系统里 1:1 实时"拍词"(播放视频、听到对应句子拍空格记录时间戳),效率极低。CCA 用 ASR 自动化这个过程。
- **"拍词"术语解释**:折行稿(去标点、按规则断行的文稿)+ 时间戳对位 = 拍词。传统靠人工实时听拍,CCA 用 ASR 时间戳代替。
- **与 doco 的区别**doco 是"播出后"整理终版文稿(三方融合);CCA 是"剪辑后、播出前"生成唱词字幕(ASR→校对→SRT)。两者共用讯飞 ASR 能力,但流程目的完全不同。
- **数据样本**`data/` 下有 A 稿 docx + mp3 音频 + 3 个人工拍词 SRT(对应正片三段)。
- **代码文件**`src/` 下是核心流水线代码(asr_client / line_breaker / ai_line_breaker / ai_proofreader / srt_writer / segment_splitter / hotword_extractor / term_normalizer),入口 `cca_pipeline.py``deploy/` 下是部署文件(cca_route.py Flask 蓝图 + cca.html 前端页面 + deploy_to_server.py 部署脚本)。
- **凭证**:本地需在 `cca/.env` 中填写;服务器凭证在 `/workspace/military_tech_voice/backend/.env`(讯飞大号 + DeepSeek)。
- **服务器架构**:腾讯云 101.42.29.217Nginx:80 → 静态文件(/var/www/voice/) + Flask:5000 代理(/api/)。CCA 源码部署在 `/workspace/military_tech_voice/backend/cca_src/`,任务数据在 `cca_data/`。Flask 无 systemd 服务,重启方式:`fuser -k 5000/tcp && cd backend && source venv/bin/activate && nohup python3 -m app.main > /tmp/flask_cca.log 2>&1 &`
- **输出目录**`output/`ASR 缓存 + v1 输出)、`output_v2/`~`output_v6/`(各版本输出)。
- **运行命令示例**
- 从缓存跑(调试校对/折行):`python -X utf8 cca_pipeline.py --asr-cache output/asr_raw.json --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v6`
- 完整流水线(含真实 ASR):`python -X utf8 cca_pipeline.py --audio "data/重走战争老路的日本军备A0.mp3" --script "data/重走战争老路的日本军备(A稿).docx" --output-dir output_v7`
---
## 8. 待确认 / 开放问题
- [x] ~~拍词规则~~ → 已确认(见关键决策)
- [x] ~~大洋 SRT 样本文件~~ → data/ 下已有 3 个真实样本
- [x] ~~音频格式~~ → 纯人声 MP3,无需预处理
- [x] ~~LLM 选型~~ → DeepSeekdeepseek-chat),已验证效果好、价格低
- [x] ~~的地得纠错~~ → 已加入校对 Promptv5
- [x] ~~前端审稿台技术选型~~ → 纯 HTML/JS 单页,无框架(2026-07-05
- [ ] 大洋系统 SRT 导入兼容性(待验证)
- [ ] 跨句固定搭配拆词("第二次/世界大战"类问题,需大短语词典,优先级低)
+164
View File
@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""
CCA 唱词助手 — 脚本版流水线入口
用法:
# 完整流程: A稿热词 + ASR + AI校对 + AI折行 → 5个SRT
python cca_pipeline.py --audio data/xxx.mp3 --script data/xxx.docx
# 手动指定热词
python cca_pipeline.py --audio data/xxx.mp3 --hotwords "热词1|热词2"
# 跳过ASR,从缓存处理(调试折行/校对用)
python cca_pipeline.py --asr-cache output/asr_raw.json --script data/xxx.docx
# 省token模式(不用AI折行和校对)
python cca_pipeline.py --asr-cache output/asr_raw.json --no-ai
流水线:
A稿 → 热词提取 → 音频+热词 → 讯飞ASR → AI校对 → 节目结构切分 → AI折行 → 5个SRT
"""
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from asr_client import transcribe, parse_result
from line_breaker import process_sentences
from ai_line_breaker import process_sentences_with_ai
from srt_writer import write_srt, ms_to_srt_time
from segment_splitter import split_into_segments
from hotword_extractor import extract_hotwords
from ai_proofreader import proofread_batch
from term_normalizer import normalize_terms
def main():
parser = argparse.ArgumentParser(description="CCA 唱词助手 - 自动生成拍词 SRT")
parser.add_argument("--audio", type=str, help="音频文件路径 (mp3/wav)")
parser.add_argument("--script", type=str, help="A稿路径 (.docx/.txt),用于热词提取和AI校对")
parser.add_argument("--hotwords", type=str, default="", help="手动指定热词,用|分隔(与--script可叠加)")
parser.add_argument("--asr-cache", type=str, help="ASR 缓存 JSON 路径(跳过ASR调用)")
parser.add_argument("--output-dir", type=str, default="output", help="输出目录 (默认: output/)")
parser.add_argument("--no-ai", action="store_true", help="不使用AI折行和校对(省token")
parser.add_argument("--no-proofread", action="store_true", help="跳过AI校对(只省校对的token")
args = parser.parse_args()
if not args.audio and not args.asr_cache:
parser.error("必须提供 --audio 或 --asr-cache")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
use_ai = not args.no_ai
# ====== Step 0: 热词提取(从A稿)======
hot_words = []
script_text = ""
if args.hotwords:
hot_words = [w.strip() for w in args.hotwords.split("|") if w.strip()]
if args.script:
print(f"[流水线] 从A稿提取热词: {args.script}")
script_hot = extract_hotwords(args.script, use_ai=use_ai)
# 合并手动热词和A稿热词
seen = set(hot_words)
for w in script_hot:
if w not in seen:
hot_words.append(w)
seen.add(w)
# 读取A稿全文(校对用)
ext = os.path.splitext(args.script)[1].lower()
if ext == ".docx":
from hotword_extractor import read_docx_text
script_text = read_docx_text(args.script)
else:
from hotword_extractor import read_text_file
script_text = read_text_file(args.script)
if hot_words:
print(f"[流水线] 热词共 {len(hot_words)} 个: {', '.join(hot_words[:10])}...")
# 保存热词列表
hotwords_path = output_dir / "hotwords.txt"
with open(hotwords_path, "w", encoding="utf-8") as f:
f.write("|".join(hot_words))
# ====== Step 1: ASR 转写 ======
if args.asr_cache:
print(f"[流水线] 从缓存加载 ASR 结果: {args.asr_cache}")
with open(args.asr_cache, "r", encoding="utf-8") as f:
raw_json = f.read()
sentences = parse_result(raw_json)
else:
print(f"[流水线] 开始 ASR 转写: {args.audio}")
sentences, raw_json = transcribe(args.audio, hot_words=hot_words if hot_words else None)
cache_path = output_dir / "asr_raw.json"
with open(cache_path, "w", encoding="utf-8") as f:
f.write(raw_json)
print(f"[流水线] ASR 原始结果已缓存: {cache_path}")
print(f"[流水线] ASR 共 {len(sentences)}")
# ====== Step 1.5: 术语格式化(正则后处理,不耗 token)======
if script_text:
print("[流水线] 术语格式化(型号短横线/武器昵称引号/中文数字)...")
sentences = normalize_terms(sentences, script_text)
# ====== Step 2: AI 校对 ======
if use_ai and not args.no_proofread and script_text:
print("[流水线] AI 校对中 (DeepSeek)...")
sentences = proofread_batch(sentences, script_text)
elif not script_text and not args.no_proofread and use_ai:
print("[流水线] 未提供A稿(--script),跳过AI校对")
# ====== Step 2.5: 校对后二次正则修复(兜住AI校对引入的新问题)======
if script_text:
from term_normalizer import normalize_terms as post_normalize
print("[流水线] 校对后二次正则修复...")
sentences = post_normalize(sentences, script_text)
# ====== Step 3: 节目结构切分 ======
print("[流水线] 切分节目结构...")
segments = split_into_segments(sentences)
print(f"[流水线] 切分结果: {[name for name, _ in segments]}")
# ====== Step 4: 折行 + 生成 SRT ======
if use_ai:
print("[流水线] 使用 AI 折行 (DeepSeek)...")
else:
print("[流水线] 使用机械折行规则...")
for seg_name, seg_sentences in segments:
if not seg_sentences:
print(f" [{seg_name}] 空段,跳过")
continue
if use_ai:
subtitle_lines = process_sentences_with_ai(seg_sentences)
else:
subtitle_lines = process_sentences(seg_sentences)
seg_offset = subtitle_lines[0][0] if subtitle_lines else 0
seg_end = subtitle_lines[-1][1] if subtitle_lines else 0
seg_duration = seg_end - seg_offset
srt_filename = f"{seg_name}.srt"
srt_path = output_dir / srt_filename
write_srt(subtitle_lines, str(srt_path)) # 绝对时间戳,方便在时间线上对位
print(f" [{seg_name}] 时长 {ms_to_srt_time(seg_duration)}, 在音频中的位置: {ms_to_srt_time(seg_offset)} ~ {ms_to_srt_time(seg_end)}")
# ====== 完成 ======
print(f"\n[流水线] 完成! 输出目录: {output_dir}/")
print("[流水线] 生成的文件:")
for f in sorted(output_dir.glob("*.srt")):
print(f" {f.name}")
if __name__ == "__main__":
main()
+1292
View File
File diff suppressed because it is too large Load Diff
+1252
View File
File diff suppressed because it is too large Load Diff
+1112
View File
File diff suppressed because it is too large Load Diff
+910
View File
@@ -0,0 +1,910 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CCA 唱词助手 - 军事科技</title>
<link rel="stylesheet" href="styles.css">
<style>
/* CCA 专用样式 */
.cca-container {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.step-indicator {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 24px;
padding: 12px;
}
.step-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--border-color);
transition: all 0.3s;
}
.step-dot.active { background: var(--accent-primary); transform: scale(1.3); }
.step-dot.done { background: var(--success); }
.step-panel { display: none; flex: 1; flex-direction: column; }
.step-panel.active { display: flex; }
/* 上传区 */
.upload-zone {
border: 2px dashed var(--border-color);
border-radius: var(--radius-lg);
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: var(--bg-secondary);
margin-bottom: 16px;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--accent-primary);
background: rgba(99,102,241,0.05);
}
.upload-zone.has-file {
border-color: var(--success);
border-style: solid;
}
.upload-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
.upload-label { color: var(--text-secondary); font-size: 0.95rem; margin-bottom: 4px; }
.upload-hint { color: var(--text-muted); font-size: 0.8rem; }
.upload-filename {
color: var(--success); font-weight: 600;
font-size: 1rem; margin-top: 8px;
}
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
/* 进度 */
.progress-container {
text-align: center;
padding: 60px 20px;
}
.progress-spinner {
width: 60px; height: 60px;
border: 4px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 24px;
}
.progress-text {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 8px;
}
.progress-detail { color: var(--text-muted); font-size: 0.85rem; }
/* 审稿台 */
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.review-stats { display: flex; gap: 16px; font-size: 0.85rem; }
.review-stats span { color: var(--text-muted); }
.review-stats .count { color: var(--accent-secondary); font-weight: 600; }
.review-stats .changed { color: var(--warning); }
.review-controls {
display: flex; gap: 8px;
}
.review-list {
flex: 1;
overflow-y: auto;
max-height: calc(100vh - 320px);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
}
.review-item {
display: grid;
grid-template-columns: 60px 1fr 1fr 80px;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
align-items: center;
font-size: 0.9rem;
transition: background 0.15s;
}
.review-item:hover { background: var(--bg-tertiary); }
.review-item.changed { background: rgba(245,158,11,0.05); }
.review-item.confirmed { opacity: 0.7; }
.review-time {
font-size: 0.75rem;
color: var(--text-muted);
font-family: monospace;
}
.review-original {
color: var(--text-muted);
font-size: 0.85rem;
}
.review-edited {
color: var(--text-primary);
padding: 4px 8px;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: 4px;
outline: none;
font-size: 0.9rem;
font-family: inherit;
width: 100%;
}
.review-edited:focus {
border-color: var(--accent-primary);
background: var(--bg-card);
}
.review-item.changed .review-original {
text-decoration: line-through;
color: var(--error);
}
.review-item.changed .review-edited {
color: var(--success);
}
.review-confirm-btn {
width: 28px; height: 28px;
border: 2px solid var(--border-color);
border-radius: 4px;
background: transparent;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--text-muted);
transition: all 0.15s;
}
.review-confirm-btn.checked {
background: var(--success);
border-color: var(--success);
color: white;
}
.review-confirm-btn:hover { border-color: var(--success); }
.review-legend {
display: flex; gap: 16px; font-size: 0.8rem; color: var(--text-muted);
padding: 8px 0;
}
.legend-dot {
display: inline-block; width: 8px; height: 8px;
border-radius: 50%; margin-right: 4px; vertical-align: middle;
}
/* 查找替换面板 */
.replace-panel {
display: none;
background: var(--bg-secondary);
border: 1px solid var(--accent-primary);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 12px;
animation: fadeIn 0.2s;
}
.replace-panel.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
.replace-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.replace-row:last-child { margin-bottom: 0; }
.replace-label {
font-size: 0.8rem;
color: var(--text-muted);
width: 40px;
flex-shrink: 0;
}
.replace-input {
flex: 1;
padding: 6px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 0.85rem;
font-family: inherit;
outline: none;
}
.replace-input:focus { border-color: var(--accent-primary); }
.replace-actions {
display: flex;
gap: 6px;
align-items: center;
}
.replace-btn {
padding: 5px 10px;
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-secondary);
transition: all 0.15s;
white-space: nowrap;
}
.replace-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
.replace-btn.primary { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
.replace-btn.primary:hover { opacity: 0.85; }
.replace-btn.danger { background: var(--warning); color: white; border-color: var(--warning); }
.replace-btn.danger:hover { opacity: 0.85; }
.replace-info {
font-size: 0.78rem;
color: var(--text-muted);
margin-left: 4px;
}
.replace-highlight {
background: rgba(245,158,11,0.3);
border-radius: 2px;
padding: 0 1px;
}
.replace-current {
background: rgba(99,102,241,0.4);
outline: 2px solid var(--accent-primary);
border-radius: 2px;
padding: 0 1px;
}
/* 过滤按钮 */
.filter-btn {
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover { border-color: var(--accent-primary); color: var(--text-primary); }
.filter-btn.active { background: var(--accent-primary); color: white; border-color: var(--accent-primary); }
/* 完成页 */
.done-container { text-align: center; padding: 60px 20px; }
.done-icon { font-size: 64px; margin-bottom: 16px; }
.done-title { font-size: 1.3rem; margin-bottom: 8px; }
.done-detail { color: var(--text-muted); margin-bottom: 24px; }
/* 错误 */
.error-box {
background: rgba(239,68,68,0.1);
border: 1px solid var(--error);
border-radius: var(--radius-md);
padding: 16px;
color: var(--error);
text-align: center;
margin-top: 16px;
}
.back-link {
color: var(--text-muted);
text-decoration: none;
font-size: 0.85rem;
}
.back-link:hover { color: var(--text-primary); }
.cca-badge {
padding: 8px 16px;
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
color: white;
font-size: 0.875rem;
font-weight: 600;
border-radius: var(--radius-full);
}
@media (max-width: 768px) {
.upload-grid { grid-template-columns: 1fr; }
.review-item { grid-template-columns: 50px 1fr 40px; }
.review-original { display: none; }
}
</style>
</head>
<body>
<div class="cca-container">
<header class="header">
<div class="logo">
<a href="index.html" class="back-link" style="margin-right:12px">← 返回配音</a>
<span class="logo-text">CCA <span class="logo-sub">唱词助手</span></span>
</div>
<div class="header-info">
<span class="cca-badge">唱词字幕</span>
</div>
</header>
<!-- 步骤指示器 -->
<div class="step-indicator">
<div class="step-dot active" id="dot-0"></div>
<div class="step-dot" id="dot-1"></div>
<div class="step-dot" id="dot-2"></div>
<div class="step-dot" id="dot-3"></div>
</div>
<!-- Step 0: 上传 -->
<div class="step-panel active" id="step-upload">
<div class="panel" style="flex:1; padding:24px;">
<h2 style="margin-bottom:8px;">上传素材</h2>
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:24px;">
上传编导 A 稿和粗编人声音频,AI 将自动完成 ASR 转写、术语校对、的地得纠错和口头语清除
</p>
<div class="upload-grid">
<div class="upload-zone" id="zone-audio" onclick="document.getElementById('input-audio').click()">
<div class="upload-icon">&#127908;</div>
<div class="upload-label">人声音频</div>
<div class="upload-hint">MP3 / WAV,纯人声(必传)</div>
<div class="upload-filename" id="name-audio"></div>
<input type="file" id="input-audio" accept=".mp3,.wav,.m4a" style="display:none">
</div>
<div class="upload-zone" id="zone-script" onclick="document.getElementById('input-script').click()">
<div class="upload-icon">&#128196;</div>
<div class="upload-label">A 稿文件</div>
<div class="upload-hint">DOCX / TXT(强烈建议上传,用于术语校对)</div>
<div class="upload-filename" id="name-script"></div>
<input type="file" id="input-script" accept=".docx,.doc,.txt" style="display:none">
</div>
</div>
<div class="action-buttons" style="justify-content:center;">
<button class="btn btn-primary" id="btn-start" disabled style="max-width:300px;">
开始处理
</button>
</div>
</div>
</div>
<!-- Step 1: 处理中 -->
<div class="step-panel" id="step-processing">
<div class="panel" style="flex:1;">
<div class="progress-container">
<div class="progress-spinner"></div>
<div class="progress-text" id="progress-text">准备中...</div>
<div class="progress-detail" id="progress-detail">请勿关闭页面,ASR 转写通常需要 1-2 分钟</div>
</div>
</div>
</div>
<!-- Step 2: 审稿台 -->
<div class="step-panel" id="step-review">
<div class="review-header">
<div>
<span style="font-weight:600;">审稿台</span>
<span class="review-stats" style="margin-left:16px;">
总计 <span class="count" id="stat-total">0</span> 句 |
AI 修改 <span class="changed" id="stat-changed">0</span> 处 |
已确认 <span class="count" id="stat-confirmed">0</span>
</span>
</div>
<div class="review-controls">
<button class="filter-btn active" id="filter-all" onclick="filterItems('all')">全部</button>
<button class="filter-btn" id="filter-changed" onclick="filterItems('changed')">仅看修改</button>
<button class="replace-btn" onclick="toggleReplace()" id="btn-toggle-replace" title="Ctrl+H">查找替换</button>
<button class="btn btn-secondary" style="padding:6px 12px; font-size:0.8rem;" onclick="confirmAll()">全部确认</button>
<button class="btn btn-primary" id="btn-generate" style="padding:6px 16px; font-size:0.85rem;">
生成 SRT
</button>
</div>
</div>
<!-- 查找替换面板 -->
<div class="replace-panel" id="replace-panel">
<div class="replace-row">
<span class="replace-label">查找</span>
<input class="replace-input" id="replace-find" placeholder="输入要查找的文字..." oninput="onFindInput()">
<div class="replace-actions">
<span class="replace-info" id="replace-match-info"></span>
<button class="replace-btn" onclick="findPrev()" title="上一个">&#9650;</button>
<button class="replace-btn" onclick="findNext()" title="下一个">&#9660;</button>
</div>
</div>
<div class="replace-row">
<span class="replace-label">替换</span>
<input class="replace-input" id="replace-to" placeholder="替换为...">
<div class="replace-actions">
<button class="replace-btn primary" onclick="replaceCurrent()">替换当前</button>
<button class="replace-btn danger" onclick="replaceAll()">全部替换</button>
<button class="replace-btn" onclick="toggleReplace()">关闭</button>
</div>
</div>
</div>
<div class="review-legend">
<span><span class="legend-dot" style="background:var(--warning);"></span>AI 已修改</span>
<span><span class="legend-dot" style="background:var(--success);"></span>已确认</span>
<span>点击文字可直接编辑,右侧勾选确认</span>
</div>
<div class="review-list" id="review-list"></div>
</div>
<!-- Step 3: 完成 -->
<div class="step-panel" id="step-done">
<div class="panel" style="flex:1;">
<div class="done-container">
<div class="done-icon">&#9989;</div>
<div class="done-title">SRT 字幕文件生成完成</div>
<div class="done-detail" id="done-detail"></div>
<div class="action-buttons" style="justify-content:center; gap:12px;">
<button class="btn btn-primary" id="btn-download" style="max-width:200px;">
下载字幕包
</button>
<button class="btn btn-secondary" onclick="location.reload()" style="max-width:200px;">
处理下一期
</button>
</div>
</div>
</div>
</div>
<!-- 错误展示 -->
<div class="error-box" id="error-box" style="display:none;"></div>
</div>
<script>
const API = '/api/cca';
let currentTaskId = null;
let reviewData = [];
let currentFilter = 'all';
let pollTimer = null;
// === 文件上传 ===
const audioInput = document.getElementById('input-audio');
const scriptInput = document.getElementById('input-script');
const btnStart = document.getElementById('btn-start');
audioInput.addEventListener('change', () => {
const f = audioInput.files[0];
if (f) {
document.getElementById('name-audio').textContent = f.name;
document.getElementById('zone-audio').classList.add('has-file');
}
checkReady();
});
scriptInput.addEventListener('change', () => {
const f = scriptInput.files[0];
if (f) {
document.getElementById('name-script').textContent = f.name;
document.getElementById('zone-script').classList.add('has-file');
}
});
// 拖拽
['zone-audio', 'zone-script'].forEach(id => {
const zone = document.getElementById(id);
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const inputId = id === 'zone-audio' ? 'input-audio' : 'input-script';
const input = document.getElementById(inputId);
input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
});
});
function checkReady() {
btnStart.disabled = !audioInput.files[0];
}
btnStart.addEventListener('click', async () => {
if (!audioInput.files[0]) return;
const formData = new FormData();
formData.append('audio', audioInput.files[0]);
if (scriptInput.files[0]) formData.append('script', scriptInput.files[0]);
btnStart.disabled = true;
btnStart.textContent = '上传中...';
try {
const res = await fetch(`${API}/upload`, { method: 'POST', body: formData });
const data = await res.json();
if (data.error) { showError(data.error); return; }
currentTaskId = data.task_id;
goToStep(1);
startPolling();
} catch (e) {
showError('上传失败: ' + e.message);
}
});
// === 轮询状态 ===
function startPolling() {
pollTimer = setInterval(async () => {
try {
const res = await fetch(`${API}/status/${currentTaskId}`);
const data = await res.json();
document.getElementById('progress-text').textContent = data.progress || '处理中...';
if (data.status === 'review') {
clearInterval(pollTimer);
await loadReview();
goToStep(2);
} else if (data.status === 'error') {
clearInterval(pollTimer);
showError(data.error || '处理出错');
}
} catch (e) {
console.error('poll error', e);
}
}, 2000);
}
// === 审稿台 ===
async function loadReview() {
const res = await fetch(`${API}/review/${currentTaskId}`);
const data = await res.json();
reviewData = data.items;
renderReview();
}
function msToTime(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const ss = String(s % 60).padStart(2, '0');
const mm = String(m % 60).padStart(2, '0');
const hh = String(h).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function renderReview() {
const list = document.getElementById('review-list');
list.innerHTML = '';
let totalChanged = 0;
let totalConfirmed = 0;
reviewData.forEach((item, i) => {
if (item.has_change) totalChanged++;
if (item.confirmed) totalConfirmed++;
if (currentFilter === 'changed' && !item.has_change) return;
const row = document.createElement('div');
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
row.dataset.index = i;
row.innerHTML = `
<div class="review-time">${msToTime(item.start_ms)}</div>
<div class="review-original">${escHtml(item.original)}</div>
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}"
onfocus="this.parentElement.classList.remove('confirmed')"
onblur="onEditBlur(this)">
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
onclick="toggleConfirm(${i}, this)" title="确认">
${item.confirmed ? '&#10003;' : ''}
</button>
`;
list.appendChild(row);
});
document.getElementById('stat-total').textContent = reviewData.length;
document.getElementById('stat-changed').textContent = totalChanged;
document.getElementById('stat-confirmed').textContent = totalConfirmed;
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function escAttr(s) { return s.replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
function onEditBlur(input) {
const idx = parseInt(input.dataset.idx);
reviewData[idx].edited = input.value;
reviewData[idx].confirmed = true;
updateStats();
autoSave();
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
if (keyword) {
onFindInput();
} else {
renderReview();
}
}
function toggleConfirm(idx, btn) {
reviewData[idx].confirmed = !reviewData[idx].confirmed;
btn.classList.toggle('checked');
btn.innerHTML = reviewData[idx].confirmed ? '&#10003;' : '';
btn.parentElement.classList.toggle('confirmed', reviewData[idx].confirmed);
updateStats();
autoSave();
}
function confirmAll() {
reviewData.forEach(item => item.confirmed = true);
renderReview();
autoSave();
}
function filterItems(filter) {
currentFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
document.getElementById('filter-' + filter).classList.add('active');
renderReview();
}
function updateStats() {
let confirmed = reviewData.filter(i => i.confirmed).length;
document.getElementById('stat-confirmed').textContent = confirmed;
}
let saveTimer = null;
function autoSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
fetch(`${API}/save/${currentTaskId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: reviewData }),
}).catch(console.error);
}, 1000);
}
// === 生成 SRT ===
document.getElementById('btn-generate').addEventListener('click', async () => {
const btn = document.getElementById('btn-generate');
btn.disabled = true;
btn.textContent = '生成中...';
// 先保存最新编辑
await fetch(`${API}/save/${currentTaskId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: reviewData }),
});
try {
const res = await fetch(`${API}/generate/${currentTaskId}`, { method: 'POST' });
const data = await res.json();
if (data.error) { showError(data.error); btn.disabled = false; btn.textContent = '生成 SRT'; return; }
document.getElementById('done-detail').textContent = data.message;
goToStep(3);
} catch (e) {
showError('生成失败: ' + e.message);
btn.disabled = false;
btn.textContent = '生成 SRT';
}
});
// === 下载 ===
document.getElementById('btn-download').addEventListener('click', () => {
window.location.href = `${API}/download/${currentTaskId}`;
});
// === 步骤控制 ===
function goToStep(n) {
document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active'));
const panels = ['step-upload', 'step-processing', 'step-review', 'step-done'];
document.getElementById(panels[n]).classList.add('active');
for (let i = 0; i < 4; i++) {
const dot = document.getElementById('dot-' + i);
dot.className = 'step-dot' + (i === n ? ' active' : (i < n ? ' done' : ''));
}
document.getElementById('error-box').style.display = 'none';
}
function showError(msg) {
const box = document.getElementById('error-box');
box.textContent = msg;
box.style.display = 'block';
}
// === 查找替换 ===
let findMatches = []; // [{idx, pos}] — idx=reviewData index, pos=match position in text
let findCursor = -1;
function toggleReplace() {
const panel = document.getElementById('replace-panel');
const btn = document.getElementById('btn-toggle-replace');
panel.classList.toggle('active');
btn.classList.toggle('active');
if (panel.classList.contains('active')) {
document.getElementById('replace-find').focus();
} else {
clearFindHighlights();
findMatches = [];
findCursor = -1;
document.getElementById('replace-match-info').textContent = '';
}
}
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'h') {
e.preventDefault();
const panel = document.getElementById('replace-panel');
if (!panel.classList.contains('active') && document.getElementById('step-review').classList.contains('active')) {
toggleReplace();
} else if (panel.classList.contains('active')) {
document.getElementById('replace-find').focus();
}
}
});
function onFindInput() {
const keyword = document.getElementById('replace-find').value;
if (!keyword) {
findMatches = [];
findCursor = -1;
document.getElementById('replace-match-info').textContent = '';
renderReview();
return;
}
findMatches = [];
reviewData.forEach((item, idx) => {
let pos = 0;
const text = item.edited;
while (true) {
const found = text.indexOf(keyword, pos);
if (found === -1) break;
findMatches.push({ idx, pos: found });
pos = found + keyword.length;
}
});
findCursor = findMatches.length > 0 ? 0 : -1;
updateFindInfo();
renderReview();
scrollToCurrentMatch();
}
function findNext() {
if (findMatches.length === 0) return;
findCursor = (findCursor + 1) % findMatches.length;
updateFindInfo();
renderReview();
scrollToCurrentMatch();
}
function findPrev() {
if (findMatches.length === 0) return;
findCursor = (findCursor - 1 + findMatches.length) % findMatches.length;
updateFindInfo();
renderReview();
scrollToCurrentMatch();
}
function updateFindInfo() {
const info = document.getElementById('replace-match-info');
if (findMatches.length === 0) {
info.textContent = '无匹配';
info.style.color = 'var(--error)';
} else {
info.textContent = `${findCursor + 1} / ${findMatches.length}`;
info.style.color = 'var(--text-muted)';
}
}
function replaceCurrent() {
if (findCursor < 0 || findCursor >= findMatches.length) return;
const keyword = document.getElementById('replace-find').value;
const replacement = document.getElementById('replace-to').value;
if (!keyword) return;
const match = findMatches[findCursor];
const item = reviewData[match.idx];
item.edited = item.edited.substring(0, match.pos) + replacement + item.edited.substring(match.pos + keyword.length);
item.confirmed = true;
onFindInput();
autoSave();
}
function replaceAll() {
const keyword = document.getElementById('replace-find').value;
const replacement = document.getElementById('replace-to').value;
if (!keyword) return;
if (findMatches.length === 0) return;
const count = findMatches.length;
const affectedIdxs = new Set(findMatches.map(m => m.idx));
affectedIdxs.forEach(idx => {
const item = reviewData[idx];
item.edited = item.edited.split(keyword).join(replacement);
item.confirmed = true;
});
onFindInput();
autoSave();
document.getElementById('replace-match-info').textContent = `已替换 ${count}`;
document.getElementById('replace-match-info').style.color = 'var(--success)';
}
function clearFindHighlights() {
renderReview();
}
function scrollToCurrentMatch() {
if (findCursor < 0 || findCursor >= findMatches.length) return;
const match = findMatches[findCursor];
const row = document.querySelector(`.review-item[data-index="${match.idx}"]`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// 重写 renderReview 支持查找高亮
const _origRenderReview = renderReview;
renderReview = function() {
const list = document.getElementById('review-list');
list.innerHTML = '';
let totalChanged = 0;
let totalConfirmed = 0;
const keyword = document.getElementById('replace-find') ? document.getElementById('replace-find').value : '';
const currentMatch = findCursor >= 0 && findCursor < findMatches.length ? findMatches[findCursor] : null;
// 建一个快速查找表:哪些 (idx, pos) 是当前光标
let globalMatchIdx = 0;
reviewData.forEach((item, i) => {
if (item.has_change) totalChanged++;
if (item.confirmed) totalConfirmed++;
if (currentFilter === 'changed' && !item.has_change) return;
const row = document.createElement('div');
row.className = 'review-item' + (item.has_change ? ' changed' : '') + (item.confirmed ? ' confirmed' : '');
row.dataset.index = i;
// 高亮匹配关键词
let editedDisplay = escHtml(item.edited);
if (keyword && item.edited.includes(keyword)) {
let result = '';
let searchPos = 0;
const text = item.edited;
while (true) {
const found = text.indexOf(keyword, searchPos);
if (found === -1) {
result += escHtml(text.substring(searchPos));
break;
}
result += escHtml(text.substring(searchPos, found));
const isCurrent = currentMatch && currentMatch.idx === i && currentMatch.pos === found;
result += `<span class="${isCurrent ? 'replace-current' : 'replace-highlight'}">${escHtml(keyword)}</span>`;
searchPos = found + keyword.length;
}
editedDisplay = result;
}
row.innerHTML = `
<div class="review-time">${msToTime(item.start_ms)}</div>
<div class="review-original">${escHtml(item.original)}</div>
<div style="position:relative;">
<div class="review-edited-display" style="padding:4px 8px;font-size:0.9rem;min-height:1.5em;cursor:text;border:1px solid transparent;border-radius:4px;background:var(--bg-tertiary);"
onclick="startEdit(this)">${editedDisplay}</div>
<input class="review-edited" value="${escAttr(item.edited)}" data-idx="${i}" style="display:none;"
onblur="onEditBlur(this)">
</div>
<button class="review-confirm-btn ${item.confirmed ? 'checked' : ''}"
onclick="toggleConfirm(${i}, this)" title="确认">
${item.confirmed ? '&#10003;' : ''}
</button>
`;
list.appendChild(row);
});
document.getElementById('stat-total').textContent = reviewData.length;
document.getElementById('stat-changed').textContent = totalChanged;
document.getElementById('stat-confirmed').textContent = totalConfirmed;
};
function startEdit(displayDiv) {
displayDiv.style.display = 'none';
const input = displayDiv.nextElementSibling;
input.style.display = '';
input.focus();
}
</script>
</body>
</html>
+330
View File
@@ -0,0 +1,330 @@
# -*- coding: utf-8 -*-
"""
CCA 唱词助手 — Flask 路由
POST /api/cca/upload 上传 A稿+音频,启动流水线
GET /api/cca/status/<id> 轮询任务状态
GET /api/cca/review/<id> 获取审稿数据
POST /api/cca/save/<id> 保存编导修改
POST /api/cca/generate/<id> 生成最终 SRT
GET /api/cca/download/<id> 下载 SRT zip
"""
import json
import os
import sys
import uuid
import threading
import traceback
import zipfile
from io import BytesIO
from pathlib import Path
from datetime import datetime
from flask import Blueprint, request, jsonify, send_file
bp = Blueprint('cca', __name__, url_prefix='/api/cca')
# 运行时数据目录
CCA_DATA_DIR = Path('/workspace/military_tech_voice/backend/cca_data')
CCA_DATA_DIR.mkdir(parents=True, exist_ok=True)
# CCA 源码目录
CCA_SRC_DIR = Path('/workspace/military_tech_voice/backend/cca_src')
if str(CCA_SRC_DIR) not in sys.path:
sys.path.insert(0, str(CCA_SRC_DIR))
# 任务状态存储(内存,重启丢失无所谓——编导重新上传即可)
tasks = {}
def _get_task(task_id):
t = tasks.get(task_id)
if not t:
return None
return t
@bp.route('/upload', methods=['POST'])
def upload():
"""接收 A稿 + 音频,创建任务"""
if 'audio' not in request.files:
return jsonify({'error': '请上传音频文件'}), 400
audio_file = request.files['audio']
script_file = request.files.get('script')
if not audio_file.filename:
return jsonify({'error': '音频文件为空'}), 400
task_id = datetime.now().strftime('%Y%m%d_%H%M%S') + '_' + uuid.uuid4().hex[:6]
task_dir = CCA_DATA_DIR / task_id
task_dir.mkdir(parents=True, exist_ok=True)
# 保存音频
audio_ext = os.path.splitext(audio_file.filename)[1] or '.mp3'
audio_path = task_dir / f'audio{audio_ext}'
audio_file.save(str(audio_path))
# 保存 A稿
script_path = None
if script_file and script_file.filename:
script_ext = os.path.splitext(script_file.filename)[1] or '.docx'
script_path = task_dir / f'script{script_ext}'
script_file.save(str(script_path))
tasks[task_id] = {
'id': task_id,
'status': 'uploaded',
'progress': '文件已上传,准备处理...',
'audio_path': str(audio_path),
'script_path': str(script_path) if script_path else None,
'created_at': datetime.now().isoformat(),
'error': None,
'asr_sentences': None,
'proofread_sentences': None,
'review_data': None,
'final_srt_dir': None,
}
# 启动后台处理
thread = threading.Thread(target=_run_pipeline, args=(task_id,), daemon=True)
thread.start()
return jsonify({'task_id': task_id, 'status': 'processing'})
def _run_pipeline(task_id):
"""后台运行 CCA 流水线"""
task = tasks[task_id]
try:
task['status'] = 'processing'
task['progress'] = '正在提取热词...'
audio_path = task['audio_path']
script_path = task['script_path']
# WAV/大文件 → MP3 压缩(讯飞上传大文件容易超时)
if audio_path.lower().endswith('.wav'):
import subprocess
mp3_path = audio_path.rsplit('.', 1)[0] + '.mp3'
task['progress'] = '正在压缩音频(WAV→MP3...'
subprocess.run(
['ffmpeg', '-i', audio_path, '-b:a', '128k', '-y', mp3_path],
capture_output=True, timeout=300,
)
if os.path.exists(mp3_path) and os.path.getsize(mp3_path) > 0:
audio_path = mp3_path
task['audio_path'] = mp3_path
from hotword_extractor import extract_hotwords, read_docx_text, read_text_file
from asr_client import transcribe, parse_result
from term_normalizer import normalize_terms
from ai_proofreader import proofread_batch
from segment_splitter import split_into_segments
# Step 0: 热词提取
hot_words = []
script_text = ""
if script_path:
hot_words = extract_hotwords(script_path, use_ai=True)
ext = os.path.splitext(script_path)[1].lower()
if ext == '.docx':
script_text = read_docx_text(script_path)
else:
script_text = read_text_file(script_path)
task['progress'] = f'热词提取完成({len(hot_words)}个),正在 ASR 转写...'
# Step 1: ASR
sentences, raw_json = transcribe(audio_path, hot_words=hot_words if hot_words else None)
# 缓存 ASR 原始结果
task_dir = Path(audio_path).parent
cache_path = task_dir / 'asr_raw.json'
with open(cache_path, 'w', encoding='utf-8') as f:
f.write(raw_json)
task['progress'] = f'ASR 完成({len(sentences)}句),正在术语格式化...'
# 保存 ASR 原始句子(校对前,供 diff 对比)
asr_original = [(bg, ed, text, spk) for bg, ed, text, spk in sentences]
# Step 1.5: 术语格式化
if script_text:
sentences = normalize_terms(sentences, script_text)
task['progress'] = '术语格式化完成,正在 AI 校对...'
# Step 2: AI 校对
if script_text:
sentences = proofread_batch(sentences, script_text)
# Step 2.5: 校对后二次正则
if script_text:
sentences = normalize_terms(sentences, script_text)
task['progress'] = 'AI 校对完成,正在准备审稿数据...'
# 保存校对后的句子
task['asr_sentences'] = asr_original
task['proofread_sentences'] = sentences
# 构建审稿数据:逐句对比
review_items = []
for i, ((bg, ed, orig_text, spk), (_, _, proof_text, _)) in enumerate(
zip(asr_original, sentences)
):
has_change = orig_text != proof_text
review_items.append({
'index': i,
'start_ms': bg,
'end_ms': ed,
'speaker_id': spk,
'original': orig_text,
'corrected': proof_text,
'edited': proof_text,
'has_change': has_change,
'confirmed': not has_change,
})
task['review_data'] = review_items
task['status'] = 'review'
task['progress'] = '审稿数据就绪,请编导审阅确认'
# 同时保存到磁盘(防丢)
review_path = task_dir / 'review_data.json'
with open(review_path, 'w', encoding='utf-8') as f:
json.dump(review_items, f, ensure_ascii=False, indent=2)
except Exception as e:
task['status'] = 'error'
err_msg = str(e)
if '余额不足' in err_msg or 'insufficient' in err_msg.lower() or '10317' in err_msg:
task['error'] = '讯飞录音文件转写余额不足,请联系管理员充值'
else:
task['error'] = f'处理出错: {err_msg}'
task['progress'] = task['error']
traceback.print_exc()
@bp.route('/status/<task_id>', methods=['GET'])
def status(task_id):
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
return jsonify({
'task_id': task_id,
'status': task['status'],
'progress': task['progress'],
'error': task['error'],
})
@bp.route('/review/<task_id>', methods=['GET'])
def review(task_id):
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
if task['status'] not in ('review', 'completed'):
return jsonify({'error': '任务尚未就绪', 'status': task['status']}), 400
return jsonify({
'task_id': task_id,
'items': task['review_data'],
})
@bp.route('/save/<task_id>', methods=['POST'])
def save(task_id):
"""保存编导的修改(自动保存用)"""
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
data = request.get_json()
edits = data.get('items', [])
for edit in edits:
idx = edit.get('index')
if idx is not None and 0 <= idx < len(task['review_data']):
task['review_data'][idx]['edited'] = edit.get('edited', task['review_data'][idx]['edited'])
task['review_data'][idx]['confirmed'] = edit.get('confirmed', True)
return jsonify({'ok': True})
@bp.route('/generate/<task_id>', methods=['POST'])
def generate(task_id):
"""用编导确认后的文本生成最终 SRT"""
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
if task['status'] not in ('review', 'completed'):
return jsonify({'error': '任务状态不对'}), 400
try:
from ai_line_breaker import process_sentences_with_ai
from srt_writer import write_srt, ms_to_srt_time
from segment_splitter import split_into_segments
# 用编导确认后的文本重建句子列表
confirmed_sentences = []
for item in task['review_data']:
text = item['edited']
confirmed_sentences.append((
item['start_ms'], item['end_ms'], text, item['speaker_id']
))
# 切分节目结构
segments = split_into_segments(confirmed_sentences)
# 折行 + 生成 SRT
task_dir = Path(task['audio_path']).parent
srt_dir = task_dir / 'srt_output'
srt_dir.mkdir(exist_ok=True)
srt_files = []
for seg_name, seg_sentences in segments:
if not seg_sentences:
continue
subtitle_lines = process_sentences_with_ai(seg_sentences)
srt_path = srt_dir / f'{seg_name}.srt'
write_srt(subtitle_lines, str(srt_path))
srt_files.append(str(srt_path))
task['final_srt_dir'] = str(srt_dir)
task['status'] = 'completed'
task['progress'] = f'生成完成,共 {len(srt_files)} 个 SRT 文件'
return jsonify({
'ok': True,
'srt_count': len(srt_files),
'message': task['progress'],
})
except Exception as e:
return jsonify({'error': f'生成 SRT 出错: {str(e)}'}), 500
@bp.route('/download/<task_id>', methods=['GET'])
def download(task_id):
"""下载 SRT zip 包"""
task = _get_task(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
if not task.get('final_srt_dir'):
return jsonify({'error': 'SRT 尚未生成'}), 400
srt_dir = Path(task['final_srt_dir'])
srt_files = sorted(srt_dir.glob('*.srt'))
if not srt_files:
return jsonify({'error': '无 SRT 文件'}), 404
# 打包 zip
buf = BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for srt_file in srt_files:
zf.write(srt_file, srt_file.name)
buf.seek(0)
filename = f'唱词字幕_{task_id}.zip'
return send_file(buf, mimetype='application/zip', as_attachment=True, download_name=filename)
+246
View File
@@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
"""
CCA 部署脚本 — 通过 paramiko 上传文件到腾讯云服务器
"""
import sys
sys.stdout.reconfigure(encoding='utf-8')
import os
import paramiko
from pathlib import Path
HOST = '101.42.29.217'
PORT = 22
USER = 'root'
PASS = 'liutong65'
CCA_ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = CCA_ROOT / 'src'
DEPLOY_DIR = CCA_ROOT / 'deploy'
# 服务器目标路径
SERVER_CCA_SRC = '/workspace/military_tech_voice/backend/cca_src'
SERVER_FRONTEND = '/workspace/military_tech_voice/frontend'
SERVER_WWW = '/var/www/voice'
SERVER_BACKEND = '/workspace/military_tech_voice/backend'
SERVER_ROUTES = f'{SERVER_BACKEND}/app/routes'
# 需要上传的 src 模块
SRC_MODULES = [
'asr_client.py',
'line_breaker.py',
'ai_line_breaker.py',
'ai_proofreader.py',
'term_normalizer.py',
'hotword_extractor.py',
'srt_writer.py',
'segment_splitter.py',
]
def connect():
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, PORT, USER, PASS)
sftp = ssh.open_sftp()
return ssh, sftp
def run(ssh, cmd):
print(f' $ {cmd}')
_, stdout, stderr = ssh.exec_command(cmd)
out = stdout.read().decode('utf-8', errors='replace').strip()
err = stderr.read().decode('utf-8', errors='replace').strip()
if out:
print(f' {out[:500]}')
if err:
print(f' [stderr] {err[:500]}')
return out
def upload(sftp, local_path, remote_path):
print(f'{Path(local_path).name}{remote_path}')
sftp.put(str(local_path), remote_path)
def main():
print('=== CCA 部署开始 ===\n')
ssh, sftp = connect()
print('[1/7] 连接成功\n')
# Step 2: 创建 cca_src 目录 + 上传源码
print('[2/7] 上传 CCA 源码模块...')
run(ssh, f'mkdir -p {SERVER_CCA_SRC}')
for module in SRC_MODULES:
local = SRC_DIR / module
if local.exists():
upload(sftp, local, f'{SERVER_CCA_SRC}/{module}')
else:
print(f' ⚠ 跳过(不存在): {module}')
print()
# Step 3: 上传 cca_route.py 到 app/routes/
print('[3/7] 上传 cca_route.py...')
run(ssh, f'mkdir -p {SERVER_ROUTES}')
upload(sftp, DEPLOY_DIR / 'cca_route.py', f'{SERVER_ROUTES}/cca.py')
# 确保 __init__.py 存在
run(ssh, f'touch {SERVER_ROUTES}/__init__.py')
print()
# Step 4: 上传 cca.html 到前端目录
print('[4/7] 上传 cca.html...')
upload(sftp, DEPLOY_DIR / 'cca.html', f'{SERVER_FRONTEND}/cca.html')
upload(sftp, DEPLOY_DIR / 'cca.html', f'{SERVER_WWW}/cca.html')
print()
# Step 5: 配置 .env(追加 CCA 相关变量)
print('[5/7] 配置 .env...')
env_path = f'{SERVER_BACKEND}/.env'
existing_env = ''
try:
with sftp.open(env_path, 'r') as f:
existing_env = f.read().decode('utf-8', errors='replace')
except FileNotFoundError:
pass
env_additions = []
if 'XFYUN_APP_ID' not in existing_env:
env_additions.append('# === CCA 唱词助手凭证 ===')
env_additions.append('# 讯飞录音文件转写(账号1-默认)')
env_additions.append('XFYUN_APP_ID=4c423e35')
env_additions.append('XFYUN_SECRET_KEY=b9e0b97d5dda072c9b4b8fb59e7e3d22')
env_additions.append('# 讯飞备用账号2')
env_additions.append('# XFYUN_APP_ID=52ae3024')
env_additions.append('# XFYUN_SECRET_KEY=d65de0eb282a4339e2b1e14fd119e42e')
if 'DEEPSEEK_API_KEY' not in existing_env:
env_additions.append('# DeepSeek(校对+折行+热词)')
env_additions.append('DEEPSEEK_API_KEY=sk-01a7868a88a04ab494e4f05c1f3f06e2')
if env_additions:
with sftp.open(env_path, 'a') as f:
f.write('\n' + '\n'.join(env_additions) + '\n')
print(' .env 已追加 CCA 凭证')
else:
print(' .env 已有 CCA 凭证,跳过')
print()
# Step 6: 注册 CCA 蓝图到 Flask main.py
print('[6/7] 注册 CCA 蓝图...')
main_py_path = f'{SERVER_BACKEND}/app/main.py'
with sftp.open(main_py_path, 'r') as f:
main_content = f.read().decode('utf-8', errors='replace')
if 'cca' not in main_content.lower():
# 找到最后一个 register_blueprint 的位置,在其后追加
lines = main_content.split('\n')
insert_idx = -1
for i, line in enumerate(lines):
if 'register_blueprint' in line:
insert_idx = i
if insert_idx >= 0:
indent = ' ' # 匹配现有缩进
# 检查现有蓝图注册的缩进
existing_line = lines[insert_idx]
indent = existing_line[:len(existing_line) - len(existing_line.lstrip())]
cca_lines = [
'',
f'{indent}# CCA 唱词助手',
f'{indent}from app.routes.cca import bp as cca_bp',
f'{indent}app.register_blueprint(cca_bp)',
]
for j, cca_line in enumerate(cca_lines):
lines.insert(insert_idx + 1 + j, cca_line)
new_content = '\n'.join(lines)
with sftp.open(main_py_path, 'w') as f:
f.write(new_content)
print(' 已注册 CCA 蓝图')
else:
print(' ⚠ 未找到 register_blueprint,请手动注册')
else:
print(' CCA 蓝图已注册,跳过')
print()
# Step 7: 安装依赖 + 修改 index.html + 重启
print('[7/7] 安装依赖、修改首页、重启服务...')
# 安装 Python 依赖
run(ssh, f'{SERVER_BACKEND}/venv/bin/pip install openai python-docx 2>&1 | tail -5')
# 修改 index.html 添加 CCA 入口按钮
index_path = f'{SERVER_FRONTEND}/index.html'
with sftp.open(index_path, 'r') as f:
index_content = f.read().decode('utf-8', errors='replace')
if '唱词助手' not in index_content and 'cca.html' not in index_content:
# 在导航栏中找到合适位置插入按钮
# 典型位置:header 或 nav 区域的最后一个链接之后
if '<header' in index_content or '<nav' in index_content:
# 找 </header> 或 </nav> 前插入
for marker in ['</nav>', '</header>']:
if marker in index_content:
btn_html = ' <a href="cca.html" class="nav-link">唱词助手</a>\n'
index_content = index_content.replace(marker, btn_html + ' ' + marker)
break
else:
# 备用:在 body 开头加一个浮动按钮
btn_html = '<div style="position:fixed;top:10px;right:10px;z-index:9999"><a href="cca.html" style="background:#4a9eff;color:#fff;padding:8px 16px;border-radius:6px;text-decoration:none;font-size:14px">唱词助手</a></div>\n'
index_content = index_content.replace('<body>', '<body>\n' + btn_html)
with sftp.open(index_path, 'w') as f:
f.write(index_content)
# 同步到 /var/www/voice/
run(ssh, f'cp {SERVER_FRONTEND}/index.html {SERVER_WWW}/index.html')
print(' index.html 已添加唱词助手入口')
else:
print(' index.html 已有唱词助手入口,跳过')
# 增大 Nginx 上传限制(音频文件可能较大)
nginx_conf = '/etc/nginx/nginx.conf'
with sftp.open(nginx_conf, 'r') as f:
nginx_content = f.read().decode('utf-8', errors='replace')
if 'client_max_body_size' not in nginx_content:
nginx_content = nginx_content.replace(
'http {',
'http {\n client_max_body_size 200m;'
)
with sftp.open(nginx_conf, 'w') as f:
f.write(nginx_content)
run(ssh, 'nginx -t && nginx -s reload')
print(' Nginx 已增大上传限制至 200MB')
else:
print(' Nginx 上传限制已配置')
# 重启 Flask 服务
print('\n 重启 Flask...')
# 查找并重启 Flask 进程
run(ssh, 'pkill -f "flask run" || pkill -f "gunicorn" || pkill -f "python.*app" || true')
# 给进程时间退出
import time
time.sleep(2)
# 检查服务启动方式
service_out = run(ssh, 'systemctl list-units --type=service | grep -i voice || systemctl list-units --type=service | grep -i flask || true')
if 'voice' in service_out.lower() or 'flask' in service_out.lower():
service_name = service_out.split()[0] if service_out else ''
if service_name:
run(ssh, f'systemctl restart {service_name}')
else:
# 直接后台启动
run(ssh, f'cd {SERVER_BACKEND} && source venv/bin/activate && nohup python -m flask run --host=0.0.0.0 --port=5000 > /tmp/flask_cca.log 2>&1 &')
print()
print('=== CCA 部署完成 ===')
print(f'访问地址: http://{HOST}/cca.html')
print(f'API 地址: http://{HOST}/api/cca/')
sftp.close()
ssh.close()
if __name__ == '__main__':
main()
+129
View File
@@ -0,0 +1,129 @@
# CCA 唱词助手子项目 Brief
> 主项目 → 子项目的"交接宪法":红线、技术栈、出入口接口
> 起草日期:2026-07-04
> 状态:主项目签发,子项目内部不修改
---
## 一、为什么做
**痛点**:《军事科技》每期节目剪辑完成后,编导需要制作一份唱词字幕(SRT 文件)交给责编拉到大洋系统上线。目前纯手工操作——对照 A 稿听音频逐句打字幕,费时且容易出错。
**升级目标**:编导只需提供 A 稿 + 粗编人声音频,系统自动走 ASR + AI 校对 + 编导审稿确认,最终输出大洋系统可直接使用的 SRT 文件。
**紧迫性**:切实解决当前痛点,功能不复杂,两天内出可用版本。先独立部署测试,成熟后并入 TPS 主项目。
---
## 二、做什么(功能边界)
| 模块 | 简述 |
|---|---|
| 专有名词提取 | AI 从 A 稿中摘取军事专有名词(型号、人名、机构等),形成热词词典 |
| 讯飞 ASR 转写 | 音频 + 词典 → 带时间戳的 ASR 稿 |
| AI 校对比对 | ASR 稿 ⊕ A 稿比对,自动修正明显错误,标记存疑差异 |
| 编导审稿台 | 双屏对比 UI,差异高亮,编导点击确认/手动修改 |
| SRT 生成 | B 稿按拍词规则折行 + 时间戳 → 大洋格式 SRT |
**具体怎么做,子项目内部讨论,本 Brief 不预设方案。**
---
## 三、怎么用(目标流程)
1. 编导剪完节目,手头有 A 稿(docx/txt)+ 粗编纯人声音频
2. 编导上传 A 稿和音频到 CCA 系统
3. 系统自动:提取专有名词 → 讯飞 ASR → AI 校对 → 生成初版对比
4. 编导进入审稿台:查看差异、确认/修改
5. 系统输出 SRT 文件
6. 责编拿 SRT 拉到大洋系统形成唱词字幕
---
## 四、不做什么(红线)
-**不替代编导审稿**:AI 拿不准的必须过编导确认,不全自动出 SRT
-**不改 A 稿内容**:A 稿只用于提取专有名词和提供参照,不回写
-**不动主项目 backend 代码 / schema**(在吸收进主项目之前)
-**不复用主项目 backend/.env 的凭证**(子项目自己 .env
-**不用讯飞大模型版 ASR**(用录音文件转写标准版,与 doco 一致)
-**不做视频处理**(输入是已分离的纯人声音频,不是视频)
-**不做内容创作**(AI 只校对不创作,尊重 ASR 底稿 + 编导意见)
---
## 五、技术栈约束
- **运行环境**Python 3.x + Web 前端(审稿台)
- **ASR**:讯飞开放平台 录音文件转写标准版(热词偏置注入)
- **AI/LLM**:用于专有名词提取 + 校对(具体模型子项目讨论,省 token 优先)
- **输出格式**:SRT(大洋系统兼容,有样本)
- **部署**:先部署 lanhao 配音 2.0 网站测试,后续并入 TPS
- **美术风格**:参考 `ai-labeling/example/` 目录下的设计稿(`功能区划.jpg``幅面参考.jpg``页面风格.webp`),CCA 的 UI 风格与 TPS 主项目保持一致
---
## 六、交付什么(出口接口)
### 6.1 并入架构(同一服务器、同一进程)
CCA 最终**不是独立微服务**,而是代码合并进 TPS 同一套服务:
- **后端**CCA 的 API 路由并入 `backend/app/api/`(如 `cca.py`),业务逻辑并入 `backend/app/services/`(如 `cca_service.py`),共用同一个 FastAPI 进程
- **前端**CCA 页面并入 `frontend/src/pages/`(如 `CCA/` 目录),作为 TPS React 应用的一个路由页面(`/cca`
- **同一台服务器**:不存在跨服务调用,共用 TPS 的 PostgreSQL、同一个 `.env`
### 6.2 开发阶段的独立形态
并入前的开发测试阶段,CCA 以独立可运行的 Web 应用存在:
- 独立的后端(FastAPI 或轻量框架)+ 前端(审稿台 UI)
- 可单独部署到 lanhao 配音 2.0 网站跑通验证
- 代码结构从一开始就按"能干净并入 TPS"设计(目录命名、API 风格、前端组件规范对齐主项目)
### 6.3 具体交付清单
| 交付物 | 说明 |
|---|---|
| CCA 后端 API | 文件上传(A稿+音频)、ASR 任务触发/轮询、校对结果、审稿确认、SRT 下载 |
| 讯飞 ASR 适配层 | 热词注入 + 转写 + 解析,可被主项目其他模块复用(与 doco 逻辑同源) |
| 专有名词提取服务 | AI 从 A 稿提取军事专名 → 热词词典 |
| AI 校对比对服务 | ASR 稿 ⊕ A 稿差异标记(自动修正 + 存疑清单) |
| 编导审稿台前端 | 双屏对比 UI + 差异高亮 + 确认/手改交互 |
| 拍词折行引擎 | 按规则把 B 稿折行 + 结合时间戳生成 SRT |
| SRT 输出 | 大洋系统兼容格式(有样本对标) |
### 6.4 配置与凭证
- 开发阶段:cca 自己的 `.env`(讯飞 Key + LLM Key
- 并入后:凭证合并进 TPS 主项目 `backend/.env`,讯飞 Key 可与 doco 共用
- 主项目 `docs/api_credentials_inventory.md` 登记 CCA 凭证元信息
### 6.5 不接受
- 并入前,子项目自己改主项目 backend 代码、schema、迁移
- 未经主项目审批引入主项目没有的技术栈
- 并入时引入与主项目冲突的依赖版本
---
## 七、入口接口(子项目要知道的主项目现状)
- **主项目已有讯飞 ASR 经验**:doco 子项目已跑通讯飞录音文件转写标准版,签名/上传/轮询/解析全套逻辑可复用
- **主项目有 `api_credentials_inventory.md`**:凭证元信息登记表
- **主项目有跨子项目协作规则**:PRD 版本管理等,见 `跨子项目协作规则.md`
---
## 八、制片人待提供
- [ ] 拍词规则(责编拍词的具体 markdown 规则文档)
- [ ] 大洋 SRT 样本文件(一份能用的真实样本)
- [ ] 粗编音频样本(一期的真实音频,用于开发测试)
- [ ] A 稿样本(对应的编导 A 稿)
---
## 九、本 Brief 自身的修订规则
- 本 Brief 不在子项目内修改
- 红线/技术栈如有变更,**主项目这边发新版**
- 子项目可在自己的 chat 里建议调整,调整动作只能在主项目发生
+5
View File
@@ -0,0 +1,5 @@
requests>=2.28
python-dotenv>=1.0
mutagen>=1.47
openai>=1.0
python-docx>=1.0
+534
View File
@@ -0,0 +1,534 @@
# -*- coding: utf-8 -*-
"""
AI 折行引擎 — 用 DeepSeek 对 ASR 长句做语义折行
对于 ≤14 字的句子直接输出,>14 字的句子批量发给 AI 折行。
"""
import json
import os
import sys
from pathlib import Path
from typing import List, Tuple
try:
from dotenv import load_dotenv
_env_path = Path(__file__).resolve().parent.parent / ".env"
if _env_path.exists():
load_dotenv(str(_env_path), override=True)
except Exception:
pass
from openai import OpenAI
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-chat").strip()
MAX_CHARS = 14
MAX_CHARS_SOFT = 16
SILENCE_THRESHOLD_MS = 2000
SYSTEM_PROMPT = """你是电视节目唱词字幕的折行助手。你的任务是将一段文字按照以下规则折成多行:
**基本规则:**
1. 每行最多14个字(中文字符、英文字母、数字各算1个字)
2. 去掉逗号、句号、感叹号、问号、分号、冒号、省略号等标点,只保留引号(""'')和书名号(《》)
3. 折行要符合语义和阅读习惯,不能把词语切断
4. 每行不一定要凑满14字,可以是5字、8字、10字等,关键是语义完整
5. 保持原文内容不变,不增不减不改字
**折行禁忌(硬规则,违反即错误):**
- 禁止把一个词语拆到两行("过程"不能变成""在行末、""在下行首;"实际上"不能拆开)
- 禁止""""""""""""作为新行的第一个字
- 禁止""""""""作为新行的第一个字
- 禁止拆断固定搭配(如"F-35A和F-35B"保持同行,"RQ-4全球鹰"保持同行)
- 禁止把动宾结构拆成:主语+动词(折行)宾语。应折成:主语(折行)动词+宾语
- **引号不跨屏**:当引号""内的内容≤6个字时,上引号和下引号必须在同一行,不允许拆到两行。例如"鱼鹰""日向"号 必须保持在同一行内
**折行优先级(按此顺序选择折点):**
1. 最优:在句子成分边界折(主语|谓语+宾语,状语|主句)
2. 次优:在并列分句之间折
3. 可接受:在长定语与中心词之间折(但""必须跟前面,不能落到下一行开头)
4. 最末:硬切词间(仅当以上都超14字时)
**示例:**
- 正确:"重塑自身军事力量版图的" / "野心与企图"
- 错误:"重塑自身军事力量版图" / "的野心与企图"""开头)
- 正确:"日本国会参议院" / "今天上午表决通过了" / "防卫省设置法修正案等法案"
- 错误:"日本国会参议院今天上午" / "表决通过" / "了防卫省设置法修正案等法案"""开头,且拆断动宾)
- 正确:"2015年美国国务院" / "批准向日本军售RQ-4全球鹰"
- 错误:"2015年美国国务院批准" / "向日本军售RQ-4全球鹰"(主+谓(折行)宾)
- 正确:"所以发展"日向"" / "直升机护卫舰"(引号内容不拆开)
- 错误:"所以发展"日向" / "级直升机护卫舰"(引号被拆到两屏)
输出格式:每行一句,不加序号,不加标点(引号和书名号除外)。"""
USER_PROMPT_TEMPLATE = """请将以下文字折行(每行≤14字,去标点保引号,按语义断句):
{text}"""
BATCH_USER_PROMPT = """请将以下编号文字逐条折行(每行≤14字,去标点保引号,按语义断句)。
每条之间用空行分隔,保持编号对应。
{numbered_texts}"""
def _create_client() -> OpenAI:
if not DEEPSEEK_API_KEY:
raise ValueError("请在 .env 中设置 DEEPSEEK_API_KEY")
return OpenAI(api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL)
def ai_break_single(text: str, client: OpenAI) -> List[str]:
"""单句 AI 折行"""
resp = client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": USER_PROMPT_TEMPLATE.format(text=text)},
],
temperature=0.1,
max_tokens=500,
)
result = resp.choices[0].message.content.strip()
lines = [l.strip() for l in result.split("\n") if l.strip()]
return lines
def ai_break_batch(texts: List[str], client: OpenAI) -> List[List[str]]:
"""
批量 AI 折行(减少 API 调用次数)
每批最多 20 条,避免输出过长出错
"""
if not texts:
return []
numbered = "\n".join(f"[{i+1}] {t}" for i, t in enumerate(texts))
resp = client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": BATCH_USER_PROMPT.format(numbered_texts=numbered)},
],
temperature=0.1,
max_tokens=3000,
)
result = resp.choices[0].message.content.strip()
# 解析结果:按空行或编号分隔
all_results = []
current_lines = []
for line in result.split("\n"):
line = line.strip()
# 检测新编号开头 [N] 或纯空行作为分隔
if not line:
if current_lines:
all_results.append(current_lines)
current_lines = []
continue
# 去掉可能的编号前缀
import re
cleaned = re.sub(r'^\[\d+\]\s*', '', line)
if cleaned:
current_lines.append(cleaned)
if current_lines:
all_results.append(current_lines)
# 如果解析结果数量不匹配,回退到逐条处理
if len(all_results) != len(texts):
print(f"[AI折行] 批量解析不匹配 (期望{len(texts)}条,得到{len(all_results)}条),回退逐条处理")
all_results = []
for text in texts:
lines = ai_break_single(text, client)
all_results.append(lines)
return all_results
# 常见不可拆分的双字词(高频,不求全,兜底关键场景)
# 这些词如果被折行拆到两屏,观众体验极差
_COMMON_WORDS = set([
"过程", "中间", "实际", "日本", "美国", "中国", "问题", "发展",
"军事", "武器", "装备", "能力", "力量", "防御", "进攻", "导弹",
"战斗", "战机", "战争", "国家", "历史", "世界", "方面", "系统",
"技术", "任务", "目标", "计划", "项目", "部署", "改装", "航母",
"自卫", "海军", "空军", "陆军", "预算", "宪法", "和平", "安全",
"基础", "措施", "结构", "性能", "速度", "射程", "重量", "面积",
"时候", "之后", "以后", "之前", "目前", "现在", "所以", "因此",
"但是", "虽然", "而且", "或者", "如果", "这个", "那个", "已经",
"可以", "应该", "需要", "能够", "开始", "成为", "通过", "进行",
"实现", "提升", "完成", "建设", "研发", "生产", "采购", "引进",
])
def _fix_split_words(lines: List[str]) -> List[str]:
"""
检测并修复被拆到两行的词语。
如果行末1字+下行首1字构成常见双字词,把末字移到下行。
"""
if len(lines) <= 1:
return lines
fixed = list(lines)
changed = True
max_iterations = 3 # 防止无限循环
while changed and max_iterations > 0:
changed = False
max_iterations -= 1
new_fixed = [fixed[0]]
for j in range(1, len(fixed)):
prev_line = new_fixed[-1]
curr_line = fixed[j]
if not prev_line or not curr_line:
new_fixed.append(curr_line)
continue
# 检查行末字+下行首字是否构成词
pair = prev_line[-1] + curr_line[0]
if pair in _COMMON_WORDS:
# 把上一行末字移到当前行首
new_fixed[-1] = prev_line[:-1]
curr_line = prev_line[-1] + curr_line
changed = True
# 如果上一行变空了,删掉
if not new_fixed[-1].strip():
new_fixed.pop()
new_fixed.append(curr_line)
fixed = new_fixed
# 清理:过滤空行,检查是否有超长行需要重切
from line_breaker import break_sentence
result = []
for line in fixed:
line = line.strip()
if not line:
continue
if len(line) > MAX_CHARS_SOFT:
result.extend(break_sentence(line))
else:
result.append(line)
return result if result else lines
def _fix_quote_split(lines: List[str]) -> List[str]:
"""
修复引号被拆到两屏的问题。
当上引号"在一行、下引号"在下一行,且引号内内容≤6字时,合并到同一行。
"""
if len(lines) <= 1:
return lines
from line_breaker import break_sentence
fixed = [lines[0]]
i = 1
while i < len(lines):
prev = fixed[-1]
curr = lines[i]
# 检查:上一行有"但没有配对的",当前行有"
if "" in prev and "" not in prev and "" in curr:
# 找到上引号位置,计算引号内内容长度
quote_start = prev.rfind("")
# 引号内容 = 上一行从"开始的部分 + 当前行到"为止的部分
quote_end_in_curr = curr.index("")
quoted_content = prev[quote_start+1:] + curr[:quote_end_in_curr]
if len(quoted_content) <= 6:
# 合并这两行
merged = prev + curr
if len(merged) <= MAX_CHARS_SOFT:
fixed[-1] = merged
else:
# 合并后超长,重新折行
fixed[-1:] = break_sentence(merged)
i += 1
continue
fixed.append(curr)
i += 1
return fixed
def _merge_tiny_subtitle(result: List[Tuple[int, int, str]]) -> List[Tuple[int, int, str]]:
"""
合并极短字幕行(≤3字且时长<1秒)到相邻行。
避免"东海"这种两个字单独闪一屏。
"""
if len(result) <= 1:
return result
merged = []
skip_next = False
for i, (bg, ed, text) in enumerate(result):
if skip_next:
skip_next = False
continue
duration_ms = ed - bg
is_tiny = len(text) <= 3 and duration_ms < 1000 and text.strip()
if is_tiny:
# 尝试与下一行合并
if i + 1 < len(result) and result[i+1][2].strip():
next_bg, next_ed, next_text = result[i+1]
combined = text + next_text
if len(combined) <= MAX_CHARS_SOFT:
merged.append((bg, next_ed, combined))
skip_next = True
continue
# 尝试与上一行合并
if merged and merged[-1][2].strip():
prev_bg, prev_ed, prev_text = merged[-1]
combined = prev_text + text
if len(combined) <= MAX_CHARS_SOFT:
merged[-1] = (prev_bg, ed, combined)
continue
merged.append((bg, ed, text))
return merged
MERGE_THRESHOLD_CHARS = 8 # ≤8字的句子考虑合并
MERGE_GAP_MS = 800 # 句间间隔<800ms才合并(>800ms视为有意停顿)
MERGE_GAP_TINY_MS = 1200 # 极短句(≤4字)放宽间隔阈值
def _merge_short_sentences(
sentences: List[Tuple[int, int, str, int]],
) -> List[Tuple[int, int, str, int]]:
"""
合并碎片短句:专家气口造成的短碎 ASR 句,合并成完整语义单元再折行。
规则:
- 连续的短句(≤8字)且间隔<800ms → 合并为一句
- 遇到 >2s 静音 → 不合并(是真正的话题停顿)
- 如果某句已经≥14字 → 作为独立单元不参与合并
- 合并后的句子时间戳取第一句起点到最后一句终点
"""
if not sentences:
return []
from line_breaker import clean_punctuation
merged = []
buffer = [] # [(bg, ed, text, spk), ...]
def flush_buffer():
if not buffer:
return
if len(buffer) == 1:
merged.append(buffer[0])
else:
# 合并 buffer 中所有句子
bg = buffer[0][0]
ed = buffer[-1][1]
text = "".join(item[2] for item in buffer)
spk = buffer[0][3]
merged.append((bg, ed, text, spk))
for i, (bg, ed, text, spk) in enumerate(sentences):
cleaned = clean_punctuation(text)
# 检查与 buffer 最后一句的间隔
if buffer:
gap = bg - buffer[-1][1]
# 极短句(≤4字)用更宽松的间隔阈值,让气口碎片更容易合并
threshold = MERGE_GAP_TINY_MS if len(cleaned) <= 4 else MERGE_GAP_MS
if gap > threshold:
flush_buffer()
buffer = []
# 如果当前句子很长(>28字),独立处理
if len(cleaned) > MAX_CHARS * 2:
flush_buffer()
buffer = []
merged.append((bg, ed, text, spk))
continue
# 如果当前句子中等偏长(15-28字)
if len(cleaned) > MAX_CHARS:
# 如果前面buffer里有极短句(≤5字),合并进来(专家气口碎片)
if buffer and all(len(clean_punctuation(item[2])) <= 5 for item in buffer):
buffer.append((bg, ed, text, spk))
else:
flush_buffer()
buffer = []
merged.append((bg, ed, text, spk))
continue
# 短句,看是否要合并
if len(cleaned) <= MERGE_THRESHOLD_CHARS:
# 检查合并后是否太长
buffer_text = "".join(clean_punctuation(item[2]) for item in buffer) + cleaned
if len(buffer_text) > MAX_CHARS * 3: # 合并后超过3行的量就太多了
flush_buffer()
buffer = [(bg, ed, text, spk)]
else:
buffer.append((bg, ed, text, spk))
else:
# 中等长度(9-14字),如果buffer有内容就合并进去,否则独立
if buffer:
buffer_text = "".join(clean_punctuation(item[2]) for item in buffer) + cleaned
if len(buffer_text) <= MAX_CHARS * 3:
buffer.append((bg, ed, text, spk))
else:
flush_buffer()
buffer = [(bg, ed, text, spk)]
else:
merged.append((bg, ed, text, spk))
flush_buffer()
return merged
def process_sentences_with_ai(
sentences: List[Tuple[int, int, str, int]],
batch_size: int = 15,
) -> List[Tuple[int, int, str]]:
"""
用 AI 折行处理 ASR 句子列表。
输入: [(start_ms, end_ms, text, speaker_id), ...]
输出: [(start_ms, end_ms, text), ...]
策略:
- 先合并碎片短句(专家气口造成的短碎ASR句)
- ≤14 字:直接输出(去标点)
- >14 字:批量调 AI 折行
- 句间 >2秒:插入空白行
"""
from line_breaker import clean_punctuation
if not sentences:
return []
# 短句合并预处理
original_count = len(sentences)
sentences = _merge_short_sentences(sentences)
if len(sentences) != original_count:
print(f"[AI折行] 短句合并: {original_count} 句 → {len(sentences)}")
client = _create_client()
result = []
# 先收集需要 AI 折行的句子索引
needs_ai = [] # (original_index, text)
for i, (bg, ed, text, spk) in enumerate(sentences):
cleaned = clean_punctuation(text)
if len(cleaned) > MAX_CHARS:
needs_ai.append((i, cleaned))
# 批量调 AI
ai_results = {} # index -> [lines]
if needs_ai:
print(f"[AI折行] 共 {len(needs_ai)} 句需要 AI 折行...")
for batch_start in range(0, len(needs_ai), batch_size):
batch = needs_ai[batch_start:batch_start + batch_size]
batch_texts = [t for _, t in batch]
batch_indices = [idx for idx, _ in batch]
print(f"[AI折行] 处理第 {batch_start+1}-{batch_start+len(batch)} 条...")
try:
broken = ai_break_batch(batch_texts, client)
for idx, lines in zip(batch_indices, broken):
ai_results[idx] = lines
except Exception as e:
print(f"[AI折行] 批量失败: {e},回退逐条处理")
for idx, text in batch:
try:
lines = ai_break_single(text, client)
ai_results[idx] = lines
except Exception as e2:
print(f"[AI折行] 第{idx}句失败: {e2},使用机械切分")
from line_breaker import break_sentence
ai_results[idx] = break_sentence(text)
# 组装最终结果
for i, (bg, ed, text, spk) in enumerate(sentences):
# 检查空白
if i > 0:
prev_ed = sentences[i - 1][1]
gap = bg - prev_ed
if gap > SILENCE_THRESHOLD_MS:
result.append((prev_ed, bg, ""))
cleaned = clean_punctuation(text)
if not cleaned.strip():
continue
if i in ai_results:
lines = ai_results[i]
else:
lines = [cleaned]
# 后处理1:AI 偶尔返回超长行,强制二次切分
from line_breaker import break_sentence
final_lines = []
for line in lines:
if len(line) > MAX_CHARS_SOFT:
final_lines.extend(break_sentence(line))
else:
final_lines.append(line)
lines = final_lines
# 后处理2:禁忌字开头修复(把禁忌字并入上一行)
FORBIDDEN_START = set("的了着过地得和与及或")
if len(lines) > 1:
fixed_lines = [lines[0]]
for ln in lines[1:]:
if ln and ln[0] in FORBIDDEN_START and fixed_lines:
# 把这个字并回上一行
fixed_lines[-1] = fixed_lines[-1] + ln[0]
remainder = ln[1:]
if remainder:
# 检查上一行是否超长了
if len(fixed_lines[-1]) > MAX_CHARS_SOFT:
# 需要重新切分合并后的文本
merged = fixed_lines[-1] + remainder
fixed_lines[-1:] = break_sentence(merged)
else:
fixed_lines.append(remainder)
else:
fixed_lines.append(ln)
lines = fixed_lines
# 后处理3:拆词检测(行末+下行首构成常见双字词 → 调整折点)
if len(lines) > 1:
lines = _fix_split_words(lines)
# 后处理4:引号不跨屏(≤6字的引号内容不拆到两行)
if len(lines) > 1:
lines = _fix_quote_split(lines)
# 为子行分配时间戳
total_chars = sum(len(l) for l in lines)
duration = ed - bg
current_ms = bg
for line in lines:
if not line.strip():
continue
line_duration = int(duration * len(line) / total_chars) if total_chars > 0 else 0
line_end = min(current_ms + line_duration, ed)
result.append((current_ms, line_end, line))
current_ms = line_end
# 全局后处理:合并极短字幕行(≤3字+时长<1秒→并入相邻行)
result = _merge_tiny_subtitle(result)
return result
+274
View File
@@ -0,0 +1,274 @@
# -*- coding: utf-8 -*-
"""
AI 校对器 — ASR 稿与 A 稿比对 + 上下文纠错
解决的核心问题:
- ASR 同音字误识别("建制""舰只""舰手""舰艏"
- 军事术语规范化("f15j""F-15J"
- 的/地/得纠错
- 去除口语填充词("""那个""就是说"
- 专家采访段落强化去口头语
策略:
- 将 ASR 全文 + A 稿全文一起发给 DeepSeek
- AI 结合节目主题和上下文做纠错
- 返回修正后的句子列表 + 修改说明
- 专家采访段落用增强版 Prompt,更严格地删除口头语
"""
import json
import os
import sys
from pathlib import Path
from typing import List, Tuple, Dict
try:
from dotenv import load_dotenv
_env_path = Path(__file__).resolve().parent.parent / ".env"
if _env_path.exists():
load_dotenv(str(_env_path), override=True)
except Exception:
pass
PROOFREAD_SYSTEM_PROMPT = """你是电视军事节目《军事科技》的字幕校对专家。你将收到两份材料:
1. **ASR稿**:语音识别的转写结果,带有时间编号,是字幕的基础
2. **A稿**:编导写的节目文稿(仅包含解说词,不包含专家采访的具体内容)
你的任务是校对 ASR 稿中的**语音识别错误**。
**铁律(违反任何一条都算失败):**
- ASR稿是已经录好的音频的转写,内容不能改——**绝不润色语句、绝不调整语序、绝不增删实词**
- 只修下列允许的几类问题,除此之外一个字都不能动
- **A稿与ASR内容冲突时ASR优先**(配音员可能改过措辞),但专有名词的正确写法/格式按A稿
- **数字表达照抄ASR原文**:不要参考A稿调整数字的位置、格式或表述方式。ASR说"马赫数0.9"就保持"马赫数0.9",不要改成A稿的"0.9马赫"
**允许修的类别:**
1. **同音字/错别字**(ASR听错的字):如"建制""舰只""舰手""舰艏""继承""击沉""空花弹""滑翔弹""沉默""沉没"(指船只)
2. **代词纠错**:武器装备/导弹/飞机/舰艇等的代词应为""而非""。注意:指代国家时不改(国家口语中用""是可接受的)。只纠正明确指代物件(武器、军舰、飞机、导弹)的情况
3. **的/地/得纠错**(重要!ASR无法区分三个"de",你必须逐句检查并修正):
- **""用在名词前**(形容词/名词 + 的 + 名词):强大的性能、日本的军备、重要的舰只
- **""用在动词前**(副词 + 地 + 动词):不断地进行、持续地推动、快速地发展、正式地把、大规模地改装、积极地推进、不断地扩大、明确地表示
- **""用在补语前**(动词 + 得 + 补语):发展得很快、做得很好、打得很准
- 判断方法:看"de"后面跟的是名词还是动词——跟动词就用"",跟名词就用"",是评价/程度补语就用""
- 常见错误模式:"不断的进行""不断地进行""持续的推动""持续地推动""正式的把""正式地把""大力的发展""大力地发展"
4. **术语格式**:英文型号大小写+连字符("f15j""F-15J""v22""V-22""rq四""RQ-4"
5. **中文数字保留**:ASR可能把"数十"转成"数10""几百"转成"几100"——必须改回中文写法
6. **武器昵称引号**:如A稿中武器有引号昵称("鱼鹰""战斧""全球鹰"),ASR中同一武器无引号时补上中文双引号
7. **口语填充词删除**:只删"""""""那个""就是说"这类纯填充词。"这个"后面紧跟名词作指示代词("这个导弹")时保留
**绝对不许做的(哪怕你觉得改了更好也不许):**
- 不许调整语序("它在性质上就是"不许改成"它本质上就是"
- 不许替换实词("不是那么特别的顺利"不许改成"不太顺利"
- 不许参考A稿的数字表达方式来改ASR的数字写法
- 不许增删标点来改变句子结构
- 不许把口语化表达改成书面语
- 不许根据A稿的措辞替换ASR中意思相同但用词不同的表达(如A稿"陆续订购"ASR说"先后采购"→保持"先后采购"
**输出格式:**
JSON数组,每个元素:{"id": 编号, "original": "原文", "corrected": "修正后", "changes": "修改说明(无修改写空字符串)"}
只输出JSON,不要其他内容。"""
PROOFREAD_EXPERT_SYSTEM_PROMPT = """你是电视军事节目《军事科技》的字幕校对专家。你将收到两份材料:
1. **ASR稿**:语音识别的转写结果,带有时间编号,是字幕的基础。**本批全部来自专家采访段落**
2. **A稿**:编导写的节目文稿(仅包含解说词,不包含专家采访内容——专家说的话A稿里没有)
你的任务是校对 ASR 稿中的**语音识别错误**,同时**严格清除专家的口头语**。
**铁律(违反任何一条都算失败):**
- ASR稿是已经录好的音频的转写,内容不能改——**绝不润色语句、绝不调整语序、绝不增删实词**
- 只修下列允许的几类问题,除此之外一个字都不能动
- 由于是专家采访,A稿中没有对应内容,所以**不要用A稿措辞替换专家的话**,A稿只用于确认专有名词写法
**允许修的类别:**
1. **同音字/错别字**(ASR听错的字):如"建制""舰只""舰手""舰艏""继承""击沉""沉默""沉没"(指船只)
2. **代词纠错**:武器装备/导弹/飞机/舰艇等的代词应为""而非""。指代国家时不改
3. **的/地/得纠错**(重要!ASR无法区分三个"de",你必须逐句检查并修正):
- **""用在名词前**(形容词/名词 + 的 + 名词)
- **""用在动词前**(副词 + 地 + 动词):不断地进行、持续地推动、正式地把、大规模地改装
- **""用在补语前**(动词 + 得 + 补语):发展得很快
- 常见错误:"不断的进行""不断地进行""持续的推动""持续地推动"
4. **术语格式**:英文型号大小写+连字符
5. **口语填充词删除(专家采访重点!必须严格执行)**:
- **必删**:嗯、呃、唉、啊(句首或句中作语气词时)、那个、这个(非指示代词时)、那么(非表示程度时)、就是说、应该说、可以说、怎么说呢、相对来讲、相对来说
- **判断"这个/那个"**:紧跟具体名词="指示代词"保留("这个导弹");单独出现或后面是虚词/停顿=口头语删除("这个呢它是"→删"这个""发展这个日向级"→删"这个"
- **判断""**:句首"啊射程""啊这个"=口头语删除;""在感叹句末尾=保留(极少出现在专家采访中)
- **判断"那么"**"那么大""那么快"=程度副词保留;"那么它就是"=口头语删除
6. **数字表达照抄ASR原文**,不参考A稿
**绝对不许做的:**
- 不许调整语序、替换实词、把口语化改书面语
- 不许用A稿的措辞替换专家的话(专家说的内容A稿没有,不存在"参考"关系)
- 不许删除有意义的词(只删纯口头语填充词)
**输出格式:**
JSON数组,每个元素:{"id": 编号, "original": "原文", "corrected": "修正后", "changes": "修改说明(无修改写空字符串)"}
只输出JSON,不要其他内容。"""
PROOFREAD_USER_TEMPLATE = """**A稿(节目文稿,仅供参考):**
{script_text}
**ASR稿(需要校对,请逐条检查):**
{asr_text}"""
def _create_client():
api_key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
if not api_key:
raise ValueError("请在 .env 中设置 DEEPSEEK_API_KEY")
from openai import OpenAI
return OpenAI(
api_key=api_key,
base_url=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
def identify_speakers(
sentences: List[Tuple[int, int, str, int]],
) -> Dict[int, str]:
"""
识别每个 speaker_id 的角色。
规则(基于《军事科技》节目结构):
- 找到说"各位观众你们好""欢迎收看军事科技"的 speaker → 主持人(也是解说配音员)
- 导视段(最早出现的)speaker 如果和主持人不同 → 也是解说(录音环境不同导致分裂)
- 剩余的 speaker → 专家/其他(统一按"专家采访"对待,加强去口头语)
返回: {speaker_id: "narration"|"host"|"expert"}
"""
if not sentences:
return {}
speaker_texts: Dict[int, str] = {}
speaker_first_appear: Dict[int, int] = {}
for i, (bg, ed, text, spk) in enumerate(sentences):
if spk not in speaker_texts:
speaker_texts[spk] = ""
speaker_first_appear[spk] = i
speaker_texts[spk] += text
roles: Dict[int, str] = {}
# 找主持人:说过"各位观众你们好"或"欢迎收看军事科技"
host_spk = None
for spk, text in speaker_texts.items():
if "各位观众" in text or "欢迎收看" in text or "主持人" in text:
host_spk = spk
roles[spk] = "host"
break
# 最早出现的 speaker 是解说(导视段配音员)
earliest_spk = min(speaker_first_appear, key=speaker_first_appear.get)
if earliest_spk not in roles:
roles[earliest_spk] = "narration"
# 如果主持人和解说是不同 speaker,两个都标记
# 如果相同,那就是同一个人(标为 narration 即可)
if host_spk is not None and host_spk == earliest_spk:
roles[host_spk] = "narration"
# 剩余的全部标为专家/其他
for spk in speaker_texts:
if spk not in roles:
roles[spk] = "expert"
role_summary = {spk: f"{role}({len([s for s in sentences if s[3]==spk])}句)"
for spk, role in roles.items()}
print(f"[校对] Speaker 角色识别: {role_summary}")
return roles
def proofread_batch(
asr_sentences: List[Tuple[int, int, str, int]],
script_text: str,
batch_size: int = 30,
) -> List[Tuple[int, int, str, int]]:
"""
对 ASR 句子列表做 AI 校对。
专家采访段落使用增强版 Prompt(更严格的口头语清除)。
"""
if not asr_sentences:
return []
client = _create_client()
script_truncated = script_text[:8000] if len(script_text) > 8000 else script_text
# 识别说话人角色
speaker_roles = identify_speakers(asr_sentences)
corrected_sentences = list(asr_sentences)
total_changes = 0
# 按角色分组处理:专家用增强 Prompt,其余用标准 Prompt
expert_indices = []
normal_indices = []
for i, (bg, ed, text, spk) in enumerate(asr_sentences):
if speaker_roles.get(spk) == "expert":
expert_indices.append(i)
else:
normal_indices.append(i)
print(f"[校对] 解说/主持 {len(normal_indices)} 句, 专家采访 {len(expert_indices)}")
def _process_batch(indices, system_prompt, label):
nonlocal total_changes
for batch_start in range(0, len(indices), batch_size):
batch_idx = indices[batch_start:batch_start + batch_size]
asr_lines = []
for seq, idx in enumerate(batch_idx):
asr_lines.append(f"[{seq+1}] {asr_sentences[idx][2]}")
asr_text = "\n".join(asr_lines)
print(f"[校对-{label}] 处理第 {batch_start+1}-{batch_start+len(batch_idx)} 句...")
try:
resp = client.chat.completions.create(
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": PROOFREAD_USER_TEMPLATE.format(
script_text=script_truncated,
asr_text=asr_text,
)},
],
temperature=0.1,
max_tokens=4000,
)
result_text = resp.choices[0].message.content.strip()
if result_text.startswith("```"):
result_text = result_text.split("\n", 1)[1]
if result_text.endswith("```"):
result_text = result_text[:-3]
result_text = result_text.strip()
corrections = json.loads(result_text)
for item in corrections:
seq = item.get("id", 0) - 1
corrected = item.get("corrected", "")
changes = item.get("changes", "")
if 0 <= seq < len(batch_idx) and corrected and changes:
original_idx = batch_idx[seq]
bg, ed, _, spk = corrected_sentences[original_idx]
corrected_sentences[original_idx] = (bg, ed, corrected, spk)
total_changes += 1
print(f" 修正: '{item.get('original','')}''{corrected}' ({changes})")
except json.JSONDecodeError as e:
print(f"[校对-{label}] JSON解析失败,跳过本批: {e}", file=sys.stderr)
except Exception as e:
print(f"[校对-{label}] 出错: {e}", file=sys.stderr)
if normal_indices:
_process_batch(normal_indices, PROOFREAD_SYSTEM_PROMPT, "解说")
if expert_indices:
_process_batch(expert_indices, PROOFREAD_EXPERT_SYSTEM_PROMPT, "专家")
print(f"[校对] 完成,共修正 {total_changes}")
return corrected_sentences
+229
View File
@@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
"""
讯飞 ASR 客户端 — 适配自 doco/src/doco/asr_adapter.py
录音文件转写标准版: https://raasr.xfyun.cn/v2/api
"""
import base64
import hashlib
import hmac
import json
import os
import sys
import time
import wave
from pathlib import Path
from urllib.parse import quote
from typing import List, Tuple, Optional
import requests
# ========================================================================
# 凭证
# ========================================================================
try:
from dotenv import load_dotenv
_env_path = Path(__file__).resolve().parent.parent / ".env"
if _env_path.exists():
load_dotenv(str(_env_path), override=True)
except Exception:
pass
APP_ID = os.environ.get("XFYUN_APP_ID", "").strip()
SECRET_KEY = os.environ.get("XFYUN_SECRET_KEY", "").strip()
# ========================================================================
# 接口配置
# ========================================================================
HOST = "https://raasr.xfyun.cn/v2/api"
UPLOAD_URL = HOST + "/upload"
RESULT_URL = HOST + "/getResult"
LANGUAGE = "cn"
PD = "mil"
ENG_SMOOTHPROC = "true"
ENG_COLLOQPROC = "true"
ROLE_TYPE = "1"
ROLE_NUM = "0"
POLL_INTERVAL_SECONDS = 30
MAX_WAIT_MINUTES = 30
# ========================================================================
# 签名
# ========================================================================
def _make_signa(app_id: str, secret_key: str, ts: str) -> str:
base_string = (app_id + ts).encode("utf-8")
md5_str = hashlib.md5(base_string).hexdigest()
mac = hmac.new(
secret_key.encode("utf-8"),
md5_str.encode("utf-8"),
digestmod=hashlib.sha1,
)
return base64.b64encode(mac.digest()).decode("utf-8")
def _get_audio_duration_ms(filepath: str) -> int:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".wav":
with wave.open(filepath, "rb") as wf:
return int(round(wf.getnframes() / wf.getframerate() * 1000))
if ext == ".mp3":
try:
from mutagen.mp3 import MP3
return int(MP3(filepath).info.length * 1000)
except ImportError:
print("[警告] 需要 mutagen 库来读取 MP3 时长: pip install mutagen", file=sys.stderr)
return 0
raise ValueError(f"不支持的音频格式: {ext}")
# ========================================================================
# 上传
# ========================================================================
def upload_audio(filepath: str, hot_words: Optional[List[str]] = None) -> str:
if not APP_ID or not SECRET_KEY:
raise ValueError("请先在 .env 中设置 XFYUN_APP_ID 和 XFYUN_SECRET_KEY")
file_size = os.path.getsize(filepath)
file_name = os.path.basename(filepath)
duration_ms = _get_audio_duration_ms(filepath)
ts = str(int(time.time()))
signa = _make_signa(APP_ID, SECRET_KEY, ts)
params = {
"appId": APP_ID,
"signa": signa,
"ts": ts,
"fileSize": str(file_size),
"fileName": file_name,
"duration": str(duration_ms),
"language": LANGUAGE,
"pd": PD,
"eng_smoothproc": ENG_SMOOTHPROC,
"eng_colloqproc": ENG_COLLOQPROC,
"roleType": ROLE_TYPE,
"roleNum": ROLE_NUM,
}
if hot_words:
params["hotWord"] = "|".join(hot_words[:200])
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
url = f"{UPLOAD_URL}?{'&'.join(url_parts)}"
with open(filepath, "rb") as f:
audio_bytes = f.read()
print(f"[ASR] 上传音频: {file_name} ({file_size/1024/1024:.1f}MB)")
resp = requests.post(url, headers={"Content-Type": "application/json"}, data=audio_bytes, timeout=300)
data = resp.json()
if data.get("code") != "000000":
raise RuntimeError(f"上传失败: code={data.get('code')}, desc={data.get('descInfo')}")
order_id = data["content"]["orderId"]
print(f"[ASR] 上传成功, orderId={order_id}")
return order_id
# ========================================================================
# 轮询
# ========================================================================
def poll_until_done(order_id: str) -> dict:
start_time = time.time()
print(f"[ASR] 开始轮询 (每{POLL_INTERVAL_SECONDS}秒)...")
while True:
elapsed = time.time() - start_time
if elapsed > MAX_WAIT_MINUTES * 60:
raise TimeoutError(f"超过 {MAX_WAIT_MINUTES} 分钟未完成")
ts = str(int(time.time()))
signa = _make_signa(APP_ID, SECRET_KEY, ts)
params = {
"appId": APP_ID,
"signa": signa,
"ts": ts,
"orderId": order_id,
"resultType": "transfer",
}
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
url = f"{RESULT_URL}?{'&'.join(url_parts)}"
resp = requests.post(url, timeout=30)
data = resp.json()
order_info = data.get("content", {}).get("orderInfo", {})
status = order_info.get("status")
if status == 4:
print(f"[ASR] 转写完成 (耗时{int(elapsed)}秒)")
return data
if status == -1:
raise RuntimeError(f"转写失败: {data}")
print(f"[ASR] 等待中... ({int(elapsed)}秒)")
time.sleep(POLL_INTERVAL_SECONDS)
# ========================================================================
# 解析
# ========================================================================
def parse_result(order_result_str: str) -> List[Tuple[int, int, str, int]]:
"""
解析讯飞返回结果
返回 [(start_ms, end_ms, text, speaker_id), ...]
"""
if not order_result_str:
return []
cleaned = order_result_str.replace("\\\\", "\\")
outer = json.loads(cleaned)
sentences = []
for item in outer.get("lattice", []):
inner_str = item.get("json_1best", "")
if not inner_str:
continue
inner = json.loads(inner_str)
st = inner.get("st", {})
bg = int(st.get("bg", 0))
ed = int(st.get("ed", 0))
rl = int(st.get("rl", 0))
words = []
for rt in st.get("rt", []):
for ws in rt.get("ws", []):
for cw in ws.get("cw", []):
w = cw.get("w", "").strip()
wp = cw.get("wp", "n")
if w and wp != "g":
words.append(w)
sentence = "".join(words).strip()
if sentence:
sentences.append((bg, ed, sentence, rl))
return sentences
# ========================================================================
# 完整流程
# ========================================================================
def transcribe(audio_path: str, hot_words: Optional[List[str]] = None) -> Tuple[List[Tuple[int, int, str, int]], str]:
"""
完整转写: 上传 → 轮询 → 解析
返回 (sentences, raw_json_str)
sentences: [(start_ms, end_ms, text, speaker_id), ...]
"""
order_id = upload_audio(audio_path, hot_words=hot_words)
result_data = poll_until_done(order_id)
order_result_str = result_data["content"]["orderResult"]
sentences = parse_result(order_result_str)
return sentences, order_result_str
+210
View File
@@ -0,0 +1,210 @@
# -*- coding: utf-8 -*-
"""
热词提取器 — 从 A 稿中提取军事专有名词,注入讯飞 ASR 热词
两种模式:
1. 规则提取(不消耗 API): 匹配 A 稿中的武器型号、专有名词等
2. AI 提取(消耗 DeepSeek API): 让 AI 理解全文后提取专业术语
热词用途: 注入讯飞 ASR 的 hotWord 参数,提升领域识别准确率
讯飞限制: 最多 200 个热词,每个 2-16 字,用 | 分隔
"""
import os
import re
import sys
from pathlib import Path
from typing import List, Optional
# ========================================================================
# 规则提取
# ========================================================================
# 常见军事装备型号模式
MILITARY_PATTERNS = [
# 武器型号: F-35B, F-15J, RQ-4, V-22 等
re.compile(r'[A-Z]{1,3}-?\d{1,4}[A-Z]?(?:/[A-Z])?'),
# 中文+数字型号: 12式, 25式, 17式
re.compile(r'\d{1,3}式'),
# 导弹/武器名称中的英文缩写
re.compile(r'(?:MK|ESSM|LM|OPY|FCS)-?\d*[A-Z]*'),
]
# 军事领域高频词(手动维护,补充 ASR 容易错的同音词)
MILITARY_VOCAB = [
# 海军
"舰只", "舰艇", "舰载机", "护卫舰", "驱逐舰", "航空母舰", "航母",
"出云级", "出云号", "日向级", "日向号", "加贺号", "最上级",
"伊势号", "满载排水量", "飞行甲板", "舰艏", "舰艉", "舰宽",
"垂直发射系统", "近防系统", "反潜", "扫雷",
# 空军
"战斗机", "隐身战斗机", "舰载机", "无人机", "旋翼机",
"航空自卫队", "航空宇宙自卫队",
# 陆军/导弹
"巡航导弹", "反舰导弹", "高超音速", "滑翔弹",
"战斧", "陆上自卫队", "海上自卫队",
"岸基", "弹径", "弹体", "射程", "马赫数",
# 通用军事
"防卫省", "自卫队", "专守防卫", "和平宪法",
"军备", "军售", "军费", "军工", "军舰",
"进攻性", "防御性", "远程打击", "精确打击",
"作战编队", "态势感知", "火力演习",
# 人名/地名
"蓝皓", "熊本县", "南鸟岛", "东富士",
# 节目相关
"军事科技", "国防军事频道",
]
def extract_by_rules(text: str) -> List[str]:
"""用正则从文本中提取军事术语"""
found = set()
# 正则匹配
for pattern in MILITARY_PATTERNS:
for match in pattern.finditer(text):
word = match.group().strip()
if 2 <= len(word) <= 16:
found.add(word)
# 固定词表匹配(只加文本中确实出现的词)
for word in MILITARY_VOCAB:
if word in text:
found.add(word)
return sorted(found)
# ========================================================================
# AI 提取
# ========================================================================
AI_EXTRACT_PROMPT = """你是军事节目的专有名词提取助手。请从以下节目文稿中提取所有军事专有名词和术语。
提取范围:
1. 武器装备型号(如 F-35B、12式反舰导弹、战斧巡航导弹)
2. 军事单位/部队名称(如 航空自卫队、陆上自卫队)
3. 军舰/飞机名称(如 出云号、日向级)
4. 军事术语(如 垂直发射系统、高超音速滑翔弹、专守防卫)
5. 人名、地名(如 蓝皓、熊本县、南鸟岛)
6. 容易被语音识别混淆的词(如 "舰只"容易被识别为"建制""舰艏"容易被识别为"舰手"
输出格式:每行一个词,不加序号,不加解释。每个词 2-16 字。"""
def extract_by_ai(text: str) -> List[str]:
"""用 DeepSeek 从文本中提取专有名词"""
try:
from dotenv import load_dotenv
_env_path = Path(__file__).resolve().parent.parent / ".env"
if _env_path.exists():
load_dotenv(str(_env_path), override=True)
except Exception:
pass
api_key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
if not api_key:
print("[热词] DeepSeek 未配置,跳过 AI 提取", file=sys.stderr)
return []
from openai import OpenAI
client = OpenAI(
api_key=api_key,
base_url=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
# 文稿太长时截取前6000字
truncated = text[:6000] if len(text) > 6000 else text
resp = client.chat.completions.create(
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
messages=[
{"role": "system", "content": AI_EXTRACT_PROMPT},
{"role": "user", "content": truncated},
],
temperature=0.1,
max_tokens=2000,
)
result = resp.choices[0].message.content.strip()
words = []
for line in result.split("\n"):
word = line.strip().strip("-").strip("·").strip("").strip()
if word and 2 <= len(word) <= 16:
words.append(word)
return words
# ========================================================================
# 读取 A 稿
# ========================================================================
def read_docx_text(docx_path: str) -> str:
"""从 docx 文件中提取纯文本"""
try:
from docx import Document
doc = Document(docx_path)
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
except ImportError:
print("[热词] 需要 python-docx 库: pip install python-docx", file=sys.stderr)
return ""
def read_text_file(path: str) -> str:
"""读取 txt 文件"""
with open(path, "r", encoding="utf-8") as f:
return f.read()
# ========================================================================
# 主入口
# ========================================================================
def extract_hotwords(
script_path: str,
use_ai: bool = True,
max_words: int = 200,
) -> List[str]:
"""
从 A 稿提取热词列表
script_path: A 稿路径 (.docx 或 .txt)
use_ai: 是否使用 AI 提取(默认 True)
max_words: 最大热词数(讯飞限制 200)
"""
ext = os.path.splitext(script_path)[1].lower()
if ext == ".docx":
text = read_docx_text(script_path)
elif ext in (".txt", ".md"):
text = read_text_file(script_path)
else:
print(f"[热词] 不支持的文件格式: {ext}", file=sys.stderr)
return []
if not text:
return []
# 规则提取(免费)
rule_words = extract_by_rules(text)
print(f"[热词] 规则提取: {len(rule_words)}")
# AI 提取(可选)
ai_words = []
if use_ai:
print("[热词] AI 提取中...")
ai_words = extract_by_ai(text)
print(f"[热词] AI 提取: {len(ai_words)}")
# 合并去重
seen = set()
merged = []
for word in ai_words + rule_words: # AI 结果优先
if word not in seen:
seen.add(word)
merged.append(word)
# 截取前 max_words 个
result = merged[:max_words]
print(f"[热词] 最终热词: {len(result)}")
return result
+163
View File
@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
"""
折行引擎 — 将 ASR 句子按拍词规则折成 ≤14 字/行的字幕行
规则:
A. 每行 ≤ 14 字
B. ASR 中 >2 秒空白 → 插入空白行
C. 按语义断句(不机械凑满 14 字)
D. 去掉逗号/句号/感叹号/问号等标点,只保留引号
"""
import re
from typing import List, Tuple
MAX_CHARS_PER_LINE = 14
MAX_CHARS_SOFT = 16 # 找不到好断点时允许的最大宽容值
SILENCE_THRESHOLD_MS = 2000 # >2秒空白插入空行
# 保留的标点(引号类)
KEEP_PUNCTUATION = set('""''「」『』《》')
# 需要去掉的标点
REMOVE_PUNCTUATION = re.compile(r'[,。!?、;:…—·\,\.\!\?\;\:]')
# 语义断句的优先切分点(按优先级排序)
BREAK_PATTERNS = [
re.compile(r'(?<=[。!?])'),
re.compile(r'(?<=[,、;:])'),
re.compile(r'(?<=》)'),
re.compile('(?<=[”’』」])'), # 右引号后: " ' 』 」
]
def clean_punctuation(text: str) -> str:
"""去掉标点,保留引号类。顿号替换为空格(唱词中并列词用空格分隔)。保留小数点。"""
result = []
for i, ch in enumerate(text):
if ch in KEEP_PUNCTUATION:
result.append(ch)
elif ch == '':
result.append(' ')
elif ch == '.' or ch == '':
# 保留小数点(前后都是数字)
if i > 0 and i < len(text) - 1 and text[i-1].isdigit() and text[i+1].isdigit():
result.append(ch)
# 其他句号/英文句点删掉
elif REMOVE_PUNCTUATION.match(ch):
continue
else:
result.append(ch)
return "".join(result)
def break_sentence(text: str) -> List[str]:
"""
将一个句子按语义折行,每行 ≤ MAX_CHARS_PER_LINE 字。
先尝试在自然断句点切分,如果不行就硬切。
"""
if len(text) <= MAX_CHARS_PER_LINE:
return [text] if text.strip() else []
# 14-16字且找不到好断点时,允许不切(人工拍词也偶尔允许略超)
if len(text) <= MAX_CHARS_SOFT:
# 只有在有明显语义断点时才切
for pattern in BREAK_PATTERNS:
matches = list(pattern.finditer(text))
if matches:
pos = matches[-1].end()
if 3 <= pos <= len(text) - 3:
return [text[:pos].strip(), text[pos:].strip()]
return [text]
lines = []
remaining = text
while len(remaining) > MAX_CHARS_PER_LINE:
# 在前14字范围内找最佳切分点(从后往前找)
best_pos = -1
window = remaining[:MAX_CHARS_PER_LINE]
# 尝试在语义点切分
for pattern in BREAK_PATTERNS:
matches = list(pattern.finditer(window))
if matches:
pos = matches[-1].end()
if 3 <= pos <= MAX_CHARS_PER_LINE:
best_pos = pos
break
if best_pos == -1:
# 没有好的语义切分点,尝试在常见虚词前切
for i in range(min(MAX_CHARS_PER_LINE, len(remaining)) - 1, 2, -1):
ch = remaining[i]
if ch in "的了是在和与而但又或则也还却并且从向把被让给":
best_pos = i
break
if best_pos == -1:
# 实在找不到,硬切在14字
best_pos = MAX_CHARS_PER_LINE
line = remaining[:best_pos].strip()
if line:
lines.append(line)
remaining = remaining[best_pos:].strip()
if remaining.strip():
lines.append(remaining.strip())
return lines
def process_sentences(
sentences: List[Tuple[int, int, str, int]],
) -> List[Tuple[int, int, str]]:
"""
将 ASR 句子列表处理为折行后的字幕行列表。
输入: [(start_ms, end_ms, text, speaker_id), ...]
输出: [(start_ms, end_ms, text), ...] 其中 text="" 表示空白行
处理逻辑:
1. 检测句子间空白 >2秒 → 插入空白行
2. 清理标点
3. 按规则折行
4. 为折行后的子行分配时间戳(按字数比例)
"""
if not sentences:
return []
result = []
for i, (bg, ed, text, _spk) in enumerate(sentences):
# 检查与前一句的空白
if i > 0:
prev_ed = sentences[i - 1][1]
gap = bg - prev_ed
if gap > SILENCE_THRESHOLD_MS:
# 插入空白行,占据空白时段
result.append((prev_ed, bg, ""))
# 清理标点
cleaned = clean_punctuation(text)
if not cleaned.strip():
continue
# 折行
lines = break_sentence(cleaned)
if not lines:
continue
# 为每个子行按字数比例分配时间戳
total_chars = sum(len(l) for l in lines)
duration = ed - bg
current_ms = bg
for line in lines:
line_duration = int(duration * len(line) / total_chars) if total_chars > 0 else 0
line_end = min(current_ms + line_duration, ed)
result.append((current_ms, line_end, line))
current_ms = line_end
return result
+179
View File
@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
"""
节目结构切分器 — 将 ASR 结果按节目结构拆分为 5 段
结构: 导视 + 正片(3段) + 下期预告
标志词:
- 导视结束 / 正片开始: "各位观众你们好""我是主持人蓝皓"
- 正片结束: "好了观众朋友们""感谢您.*关注.*军事科技"
- 正片之后 = 下期预告
正片拆3段: 按时长大致均分,优先在角色转换处(speaker_id 变化)切分
"""
import re
from typing import List, Tuple, Optional
# 标志词模式
PATTERN_SHOW_START = re.compile(r"各位观众你们好|我是主持人蓝皓")
PATTERN_SHOW_END = re.compile(r"好了观众朋友们|感谢您.*关注.*军事科技|感谢您持续关注")
def find_segment_boundaries(
sentences: List[Tuple[int, int, str, int]],
) -> Tuple[int, int]:
"""
找到正片开始和结束的句子索引。
返回 (show_start_idx, show_end_idx)
- show_start_idx: "各位观众你们好"所在句子的索引
- show_end_idx: "好了观众朋友们"所在句子的索引
"""
show_start_idx = 0
show_end_idx = len(sentences) - 1
for i, (_, _, text, _) in enumerate(sentences):
if PATTERN_SHOW_START.search(text):
show_start_idx = i
break
for i in range(len(sentences) - 1, -1, -1):
_, _, text, _ = sentences[i]
if PATTERN_SHOW_END.search(text):
show_end_idx = i
break
return show_start_idx, show_end_idx
def split_show_into_three(
sentences: List[Tuple[int, int, str, int]],
start_idx: int,
end_idx: int,
) -> Tuple[int, int]:
"""
将正片(start_idx 到 end_idx)拆成 3 段。
返回两个切分点索引 (split1_idx, split2_idx)
策略: 按时长三等分,然后在附近找 speaker_id 变化的位置。
"""
if end_idx - start_idx < 6:
# 太短了,均分
third = (end_idx - start_idx) // 3
return start_idx + third, start_idx + 2 * third
show_start_ms = sentences[start_idx][0]
show_end_ms = sentences[end_idx][1]
total_duration = show_end_ms - show_start_ms
target1_ms = show_start_ms + total_duration // 3
target2_ms = show_start_ms + 2 * total_duration // 3
split1 = _find_best_split(sentences, start_idx, end_idx, target1_ms)
split2 = _find_best_split(sentences, split1 + 1, end_idx, target2_ms)
# 确保 split2 > split1
if split2 <= split1:
split2 = split1 + (end_idx - split1) // 2
return split1, split2
def _find_best_split(
sentences: List[Tuple[int, int, str, int]],
range_start: int,
range_end: int,
target_ms: int,
search_window: int = 15,
) -> int:
"""
在 target_ms 附近(±search_window 句)找最佳切分点。
优先找 speaker_id 变化的位置,其次找 >2秒 空白。
"""
# 先找到时间上最接近 target_ms 的句子
closest_idx = range_start
min_diff = abs(sentences[range_start][0] - target_ms)
for i in range(range_start, min(range_end + 1, len(sentences))):
diff = abs(sentences[i][0] - target_ms)
if diff < min_diff:
min_diff = diff
closest_idx = i
# 在附近找 speaker 变化点
search_lo = max(range_start + 1, closest_idx - search_window)
search_hi = min(range_end, closest_idx + search_window)
best_idx = closest_idx
best_score = 0
for i in range(search_lo, search_hi):
score = 0
# speaker 变化加分
if sentences[i][3] != sentences[i - 1][3] and sentences[i][3] != 0:
score += 10
# 空白间隔加分
gap = sentences[i][0] - sentences[i - 1][1]
if gap > 2000:
score += 5
elif gap > 1000:
score += 2
# 离目标越近加分
time_diff = abs(sentences[i][0] - target_ms)
time_score = max(0, 5 - time_diff / 10000)
score += time_score
if score > best_score:
best_score = score
best_idx = i
return best_idx
def split_into_segments(
sentences: List[Tuple[int, int, str, int]],
) -> List[Tuple[str, List[Tuple[int, int, str, int]]]]:
"""
将全部 ASR 句子拆分为 5 段。
返回: [("导视", [...]), ("正片1", [...]), ("正片2", [...]), ("正片3", [...]), ("预告", [...])]
如果找不到标志词,则只输出单段。
"""
if not sentences:
return [("正片1", [])]
show_start_idx, show_end_idx = find_segment_boundaries(sentences)
# 导视: 0 到 show_start_idx-1
intro = sentences[:show_start_idx] if show_start_idx > 0 else []
# 正片: show_start_idx 到 show_end_idx
show_sentences = sentences[show_start_idx:show_end_idx + 1]
# 预告: show_end_idx+1 到末尾
trailer = sentences[show_end_idx + 1:] if show_end_idx < len(sentences) - 1 else []
# 正片拆3段
if len(show_sentences) > 6:
split1, split2 = split_show_into_three(sentences, show_start_idx, show_end_idx)
# 转为相对于 show_sentences 的索引
rel_split1 = split1 - show_start_idx
rel_split2 = split2 - show_start_idx
show_part1 = show_sentences[:rel_split1]
show_part2 = show_sentences[rel_split1:rel_split2]
show_part3 = show_sentences[rel_split2:]
else:
show_part1 = show_sentences
show_part2 = []
show_part3 = []
segments = []
if intro:
segments.append(("导视", intro))
segments.append(("正片1", show_part1))
if show_part2:
segments.append(("正片2", show_part2))
if show_part3:
segments.append(("正片3", show_part3))
if trailer:
segments.append(("预告", trailer))
return segments
+49
View File
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""
SRT 生成器 — 将折行后的字幕行列表写成大洋系统兼容的 SRT 文件
格式参照 data/ 下的真实样本:
- 序号从 1 开始
- 时间格式: HH:MM:SS,mmm --> HH:MM:SS,mmm
- 每条字幕一行文字(不多行)
- 空白行(屏幕清字幕)写为空内容
- 条目之间用空行分隔
"""
from typing import List, Tuple
def ms_to_srt_time(ms: int) -> str:
"""毫秒 → SRT 时间格式 HH:MM:SS,mmm"""
if ms < 0:
ms = 0
hours = ms // 3600000
minutes = (ms % 3600000) // 60000
seconds = (ms % 60000) // 1000
millis = ms % 1000
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
def write_srt(
subtitle_lines: List[Tuple[int, int, str]],
output_path: str,
time_offset: int = 0,
) -> None:
"""
写入 SRT 文件
subtitle_lines: [(start_ms, end_ms, text), ...] text="" 表示空白行
output_path: 输出文件路径
time_offset: 时间偏移(用于正片拆分后各段从0开始计时的情况,这里不用,保持绝对时间)
"""
with open(output_path, "w", encoding="utf-8") as f:
for idx, (start_ms, end_ms, text) in enumerate(subtitle_lines, 1):
start = ms_to_srt_time(start_ms - time_offset)
end = ms_to_srt_time(end_ms - time_offset)
f.write(f"{idx}\n")
f.write(f"{start} --> {end}\n")
# 空白行写一个空格(参照样本中的做法)
f.write(f"{text if text else ' '}\n")
f.write("\n")
print(f"[SRT] 已写入: {output_path} ({len(subtitle_lines)} 条)")
+282
View File
@@ -0,0 +1,282 @@
# -*- coding: utf-8 -*-
"""
术语格式化器 — 正则后处理层(零 token 消耗)
在 ASR 结果出来后、AI 校对之前执行。
从 A 稿中提取正确的术语写法,构建映射表,对 ASR 文本做确定性替换。
解决的问题:
- 讯飞 ASR 丢失英文型号中的短横线(F-15J→F15J, V-22→V22
- 武器昵称引号丢失(A稿有引号但ASR没带出来)
- 中文数字被转成阿拉伯数字(数十→数10)
- 数字范围符号(~→到)
- 顿号分隔词加空格
- 小数点丢失修复(09马赫→0.9马赫)
- 军事领域高频同音字修正(建制→舰只等)
"""
import re
from typing import List, Tuple, Dict, Set
# ========================================================================
# 型号短横线修复
# ========================================================================
MODEL_PATTERN = re.compile(r'[A-Z]{1,4}-\d{1,4}[A-Z]?(?:/[A-Z])?')
def _build_model_mapping(script_text: str) -> Dict[str, str]:
mapping = {}
models = set(MODEL_PATTERN.findall(script_text))
for model in models:
no_hyphen = model.replace("-", "")
if no_hyphen != model:
mapping[no_hyphen] = model
return mapping
def _fix_model_hyphens(text: str, mapping: Dict[str, str]) -> str:
if not mapping:
return text
for no_hyphen in sorted(mapping.keys(), key=len, reverse=True):
correct = mapping[no_hyphen]
pattern = re.compile(re.escape(no_hyphen) + r'(?![A-Za-z0-9])')
text = pattern.sub(correct, text)
return text
# ========================================================================
# 武器昵称引号修复(上下文感知版)
# ========================================================================
# 匹配 A 稿中 "xxx"号 / "xxx"级 / "xxx"型 / 单独 "xxx" 的模式
QUOTED_WITH_SUFFIX = re.compile(r'“([^“”„‟""]{1,8})”([号级型式舰]?)')
def _build_quote_mapping(script_text: str) -> Dict[str, Set[str]]:
"""
从 A 稿提取引号词及其后缀上下文。
返回 {词: {出现过的后缀集合}},后缀为空字符串表示单独使用。
例: {"日向": {""}, "鱼鹰": {""}} 表示 A 稿有"日向"号但没有"日向"级,有单独的"鱼鹰"
"""
mapping: Dict[str, Set[str]] = {}
for match in QUOTED_WITH_SUFFIX.finditer(script_text):
word = match.group(1).strip()
suffix = match.group(2)
if 2 <= len(word) <= 6:
if word not in mapping:
mapping[word] = set()
mapping[word].add(suffix)
return mapping
def _check_bare_occurrences(script_text: str, word: str, suffixes: Set[str]) -> Set[str]:
"""
检查 A 稿中该词的无引号出现,看哪些后缀组合是不加引号的。
例如 A 稿有 "日向级"(无引号),说明"日向级"不该加引号。
"""
bare_suffixes = set()
for suffix in ["", "", "", "", "", ""]:
bare_pattern = word + suffix if suffix else word
quoted_pattern = f"{word}{suffix}"
# 在 A 稿中出现了无引号版本 且 没有对应的有引号版本
if bare_pattern in script_text and quoted_pattern not in script_text:
bare_suffixes.add(suffix)
return bare_suffixes
def _fix_weapon_quotes(text: str, quote_mapping: Dict[str, Set[str]], script_text: str) -> str:
"""对文本中无引号的武器昵称补上引号(上下文感知)"""
if not quote_mapping:
return text
for word in sorted(quote_mapping.keys(), key=len, reverse=True):
quoted_suffixes = quote_mapping[word]
bare_suffixes = _check_bare_occurrences(script_text, word, quoted_suffixes)
# 对每个在 A 稿中确实带引号的后缀组合,在 ASR 文本中补引号
for suffix in quoted_suffixes:
if suffix and suffix not in bare_suffixes:
# 匹配 "word+suffix"(无引号),替换为 "word"+suffix
target = word + suffix
replacement = f"{word}{suffix}"
pattern = re.compile(
r'(?<!“)' + re.escape(target) + r'(?!”)'
)
text = pattern.sub(replacement, text)
elif not suffix:
# 单独出现(无后缀),但要避免替换那些在 A 稿中不带引号的后缀组合
# 用负向前瞻排除不该加引号的后缀
exclude_chars = "".join(bare_suffixes - {""}) if bare_suffixes else ""
if exclude_chars:
lookahead = f'(?![{re.escape(exclude_chars)}])'
else:
lookahead = ''
pattern = re.compile(
r'(?<!“)(?<!《)' + re.escape(word) + lookahead + r'(?!”)(?!》)'
)
text = pattern.sub(f'{word}', text)
return text
# ========================================================================
# 中文数字修复
# ========================================================================
CHINESE_NUM_FIXES = [
(re.compile(r'数10([年架艘枚门辆台套件个发种类])'), r'数十\1'),
(re.compile(r'数100([年架艘枚门辆台套件个发种类])'), r'数百\1'),
(re.compile(r'数1000([年架艘枚门辆台套件个发种类])'), r'数千\1'),
(re.compile(r'几10([年架艘枚门辆台套件个发种类])'), r'几十\1'),
(re.compile(r'几100([年架艘枚门辆台套件个发种类])'), r'几百\1'),
]
def _fix_chinese_numbers(text: str) -> str:
for pattern, replacement in CHINESE_NUM_FIXES:
text = pattern.sub(replacement, text)
return text
# ========================================================================
# 数字范围符号修复:~ → 到
# ========================================================================
# 匹配 数字~数字 或 数字~数字 的模式
RANGE_TILDE = re.compile(r'(\d)[~](\d)')
def _fix_range_symbol(text: str) -> str:
return RANGE_TILDE.sub(r'\1到\2', text)
# ========================================================================
# 顿号→空格(唱词中并列词用空格分隔)
# ========================================================================
def _fix_enumeration_pause(text: str) -> str:
return text.replace("", " ")
# ========================================================================
# 节目名称书名号补全
# ========================================================================
# 需要带书名号的固定名称(节目名等)
# 格式: (裸名称, 带书名号版本)
BOOK_TITLE_NAMES = [
("军事科技", "《军事科技》"),
("军事报道", "《军事报道》"),
]
def _fix_book_titles(text: str) -> str:
for bare, titled in BOOK_TITLE_NAMES:
# 只替换没有被书名号包围的裸名称
pattern = re.compile(r'(?<!《)' + re.escape(bare) + r'(?!》)')
text = pattern.sub(titled, text)
return text
# ========================================================================
# 小数点丢失修复(09马赫→0.9马赫 等)
# ========================================================================
# 匹配丢失小数点的情况:
# 1. "09马赫" → "0.9马赫"(数字在单位前)
# 2. "马赫数09" → "马赫数0.9"(数字在单位后)
# 3. 通用:非正常的 0+单个数字 紧跟/紧接单位
LOST_DECIMAL_BEFORE_UNIT = re.compile(r'(?<!\d)0(\d)(\s*(?:马赫|倍|秒|米|千米|公里))')
LOST_DECIMAL_AFTER_UNIT = re.compile(r'(马赫数|倍数|速度约)0(\d)(?!\d)')
def _fix_lost_decimal(text: str) -> str:
text = LOST_DECIMAL_BEFORE_UNIT.sub(r'0.\1\2', text)
text = LOST_DECIMAL_AFTER_UNIT.sub(r'\g<1>0.\2', text)
return text
# ========================================================================
# 军事领域高频同音字修正
# ========================================================================
# 格式: (错误写法正则, 正确写法, A稿中应有的验证词)
# 只有当 A 稿中存在正确写法时才替换,避免误改
HOMOPHONE_PAIRS = [
# 海军
("建制", "舰只", "舰只"),
("舰手", "舰艏", "舰艏"),
("舰位", "舰尾", "舰尾"),
("继承", "击沉", "击沉"),
("沉默", "沉没", "沉没"),
("空花弹", "滑翔弹", "滑翔弹"),
("建支", "舰只", "舰只"),
("坚支", "舰只", "舰只"),
# 其他
("符和", "符合", "符合"),
("决意", "决议", "决议"),
]
def _build_homophone_mapping(script_text: str) -> Dict[str, str]:
mapping = {}
for wrong, correct, verify_word in HOMOPHONE_PAIRS:
if verify_word in script_text:
mapping[wrong] = correct
return mapping
def _fix_homophones(text: str, mapping: Dict[str, str]) -> str:
if not mapping:
return text
for wrong, correct in mapping.items():
text = text.replace(wrong, correct)
return text
# ========================================================================
# 主入口
# ========================================================================
def normalize_terms(
sentences: List[Tuple[int, int, str, int]],
script_text: str,
) -> List[Tuple[int, int, str, int]]:
"""
对 ASR 句子列表做术语格式化(确定性正则替换,不调 AI)。
在 ASR 结果出来后、AI 校对之前调用。
"""
if not sentences:
return []
if not script_text:
return list(sentences)
model_mapping = _build_model_mapping(script_text)
quote_mapping = _build_quote_mapping(script_text)
homophone_mapping = _build_homophone_mapping(script_text)
if model_mapping:
print(f"[术语格式化] 型号映射 {len(model_mapping)} 条: {list(model_mapping.items())[:5]}")
if quote_mapping:
print(f"[术语格式化] 引号昵称 {len(quote_mapping)} 个: {dict((k, list(v)) for k, v in list(quote_mapping.items())[:5])}")
if homophone_mapping:
print(f"[术语格式化] 同音字映射 {len(homophone_mapping)} 条: {list(homophone_mapping.items())[:5]}")
result = []
fix_count = 0
for bg, ed, text, spk in sentences:
original = text
text = _fix_model_hyphens(text, model_mapping)
text = _fix_weapon_quotes(text, quote_mapping, script_text)
text = _fix_chinese_numbers(text)
text = _fix_range_symbol(text)
text = _fix_enumeration_pause(text)
text = _fix_lost_decimal(text)
text = _fix_homophones(text, homophone_mapping)
text = _fix_book_titles(text)
if text != original:
fix_count += 1
result.append((bg, ed, text, spk))
print(f"[术语格式化] 完成,修正 {fix_count}")
return result
+136
View File
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""
本地测试 — 不需要讯飞凭证
用模拟的 ASR 数据测试折行引擎和 SRT 生成
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
from line_breaker import break_sentence, clean_punctuation, process_sentences
from srt_writer import write_srt, ms_to_srt_time
from segment_splitter import split_into_segments, find_segment_boundaries
def test_clean_punctuation():
print("=== 测试标点清理 ===")
cases = [
('对于战后日本而言,重新武装最大的限制不是工业能力,而是政治约束。', '对于战后日本而言重新武装最大的限制不是工业能力而是政治约束'),
('这是“和平宪法”的约束', '这是“和平宪法”的约束'),
('你好!世界?', '你好世界'),
]
for input_text, expected in cases:
result = clean_punctuation(input_text)
status = "" if result == expected else ""
print(f" {status} 输入: {input_text}")
print(f" 结果: {result}")
if result != expected:
print(f" 期望: {expected}")
print()
def test_break_sentence():
print("=== 测试折行 ===")
cases = [
"对于战后日本而言重新武装最大的限制不是工业能力而是政治约束",
"各位观众你们好欢迎收看军事科技我是主持人蓝皓",
"F-35B隐身战斗机",
"日本通过从美国引进F-35战机和战斧巡航导弹等先进装备迅速获得了第五代战机远程精确打击能力",
]
for text in cases:
lines = break_sentence(text)
print(f" 输入 ({len(text)}字): {text}")
for line in lines:
print(f" → [{len(line):2d}字] {line}")
# 验证每行不超过14字
for line in lines:
assert len(line) <= 14, f"超过14字: {line} ({len(line)}字)"
print()
def test_process_sentences():
print("=== 测试完整折行流程 ===")
# 模拟 ASR 句子(参照 data/1.srt 内容)
sentences = [
(8520, 8840, "", 0), # 空句
(8880, 9880, "各位观众你们好", 1),
(9920, 11320, "欢迎收看军事科技", 1),
(11360, 12280, "我是主持人蓝皓", 1),
(12320, 13120, " ", 0), # 空白
(13160, 14400, "日本国会参议院", 1),
(14440, 16000, "今天上午6月26日表决通过了", 1),
(16040, 18480, "防卫省设置法修正案等法案", 1),
# 模拟 >2秒空白
(22000, 24320, "这是日本自1954年", 1),
(24360, 29040, "成立陆上海上航空自卫队以来", 1),
]
result = process_sentences(sentences)
print(f" 输入 {len(sentences)} 句 → 输出 {len(result)}")
for start, end, text in result:
time_str = f"{ms_to_srt_time(start)} --> {ms_to_srt_time(end)}"
print(f" {time_str} {text if text else '[空白]'}")
print()
def test_segment_detection():
print("=== 测试节目结构识别 ===")
sentences = [
(0, 5000, "本期军事科技将为您介绍", 1),
(5000, 8000, "日本军备的发展历程", 1),
(8880, 9880, "各位观众你们好", 1),
(9920, 11320, "欢迎收看军事科技", 1),
(11360, 12280, "我是主持人蓝皓", 1),
(13000, 50000, "正片内容一", 1),
(50000, 100000, "正片内容二", 2),
(100000, 150000, "正片内容三", 1),
(150000, 200000, "正片内容四", 2),
(200000, 250000, "正片内容五", 1),
(250000, 300000, "正片内容六", 2),
(300000, 310000, "好了观众朋友们感谢您持续关注国防军事频道军事科技", 1),
(310000, 320000, "下期我们将继续关注", 1),
(320000, 330000, "某某话题", 1),
]
start_idx, end_idx = find_segment_boundaries(sentences)
print(f" 正片开始: 第{start_idx}句 = {sentences[start_idx][2]}")
print(f" 正片结束: 第{end_idx}句 = {sentences[end_idx][2]}")
segments = split_into_segments(sentences)
for name, seg in segments:
if seg:
print(f" [{name}] {len(seg)}句, 时间 {ms_to_srt_time(seg[0][0])} ~ {ms_to_srt_time(seg[-1][1])}")
else:
print(f" [{name}] 空")
print()
def test_srt_output():
print("=== 测试 SRT 输出 ===")
subtitle_lines = [
(8880, 9880, "各位观众你们好"),
(9920, 11320, "欢迎收看《军事科技》"),
(11360, 12280, "我是主持人蓝皓"),
(12320, 13120, ""), # 空白行
(13160, 14400, "日本国会参议院"),
]
output_path = Path(__file__).parent / "output" / "test.srt"
output_path.parent.mkdir(exist_ok=True)
write_srt(subtitle_lines, str(output_path))
# 读回来检查
content = output_path.read_text(encoding="utf-8")
print(content[:500])
print()
if __name__ == "__main__":
test_clean_punctuation()
test_break_sentence()
test_process_sentences()
test_segment_detection()
test_srt_output()
print("=== 全部测试完成 ===")
+134
View File
@@ -0,0 +1,134 @@
# 双机同步开发指南
> 单位 4090D(主力开发机) ↔ 家里电脑,通过 Gitea 云端仓库同步。
> 仓库地址:`http://101.42.29.217:3000/simonkoson/tps-dashboard.git`
---
## 到家后第一次(克隆 + 环境搭建)
如果家里电脑还没有这个项目,先克隆:
```powershell
git clone http://101.42.29.217:3000/simonkoson/tps-dashboard.git
cd tps-dashboard
```
然后搭建环境:
```powershell
# 1. 创建 Python 虚拟环境
python -m venv .venv
.\.venv\Scripts\Activate.ps1
# 2. 安装后端依赖
pip install fastapi uvicorn sqlmodel python-dotenv psycopg2-binary bcrypt==4.0.1 itsdangerous==2.2.0 python-multipart==0.0.9 openai python-docx
# 3. 安装前端依赖
cd frontend
npm install
cd ..
```
配置环境变量(**两个 .env 文件,都不在 git 里,需要手动创建**):
```powershell
# backend/.env — 从单位机器上复制内容,包含:
# DATABASE_URL=postgresql://...
# SECRET_KEY=...
# MINIMAX_EMBED_API_KEY=...
# MINIMAX_GROUP_ID=...
# DEEPSEEK_API_KEY=...
# ai-labeling/.env — 包含:
# MIMO_API_KEY=...
```
**注意**:数据库连接指向腾讯云 PostgreSQL,两台机器连的是同一个数据库,不需要本地装 PostgreSQL。
---
## 每次开工(不管在哪台机器)
```powershell
cd E:\tps-dashboard # 或者家里对应的路径
git pull origin main # 拉取对方机器推送的最新代码
```
如果有新的前端依赖(别人装了新 npm 包),还需要:
```powershell
cd frontend && npm install && cd ..
```
如果有新的 Python 依赖,激活虚拟环境后 pip install。
---
## 启动开发服务器
开两个 PowerShell 窗口:
**窗口 1 — 后端**
```powershell
cd E:\tps-dashboard
.\.venv\Scripts\Activate.ps1
cd backend
python -m uvicorn app.main:app --reload --port 8000
```
**窗口 2 — 前端**
```powershell
cd E:\tps-dashboard\frontend
npm run dev
```
浏览器访问 `http://localhost:5173`(或 5174)。
---
## 收工推送(每次下班前必做)
```powershell
cd E:\tps-dashboard
git add -A
git status # 检查一眼,确认没有不该提交的文件(.env 等)
git commit -m "你的提交信息"
git push origin main
```
---
## 常见问题
### pull 时报冲突
说明两台机器改了同一个文件。先看冲突内容:
```powershell
git diff
```
手动解决冲突后:
```powershell
git add .
git commit -m "resolve merge conflict"
git push origin main
```
### 忘记 push 就换了机器
在另一台机器上 `git pull` 会发现没有新内容。回到原来的机器先 push,再回来 pull。
### .env 文件丢失
.env 不进 git,换机器需要手动复制。建议用安全的方式(如加密 U 盘或密码管理器)在两台机器间同步 .env 内容。
---
## 给 Claude Code 的指令
如果你在家里用 Claude Code 开新 session,让它先执行:
```
请先阅读 CLAUDE.md 和 docs/dual_machine_sync.md,了解项目背景和双机开发环境。
然后阅读 docs/git_workflow.md 第 7 章了解 git 工作流。
我现在在家里的电脑上,代码已经 pull 到最新。请帮我启动开发环境并确认一切正常。
```
+70
View File
@@ -0,0 +1,70 @@
# 项目协作主控文件 (episode-intake/CLAUDE.md)
<!--
期次一条龙录入子项目主控文件。
通用协作原则、主项目技术栈、角色定位、Git 红线等
已在上一级 tps-dashboard/CLAUDE.md 中覆盖,不重复。
维护原则:增量更新,不整篇重写。
-->
---
## 🔖 状态栏(每次结束 session 前必须更新这三行)
- **最后更新**Claude Fable(顾问)| 2026-07-07
- **当前状态一句话**:**已立项,PRD v1.0 完成(含制片人三项拍板),未开发。** 等制片人排期后按 PRD §8 四刀实施。
- **下一个动手的人从这里开始**:读本文件 + `PRD_期次一条龙录入_v1.md` 全文。开发时严格按 PRD §8 分四刀,第一刀 Cline 指令已备好(见下方「⏩ 交接备注」)。
---
## 1. 子项目概览
- **项目名**:期次一条龙录入(episode-intake)— 收视分析看板的"持续运行入口"
- **目标**:把每期节目的五件事(收视录入 → 文稿入库 → AI 打标+摘要卡 → 制片人审核 → 进看板)收进责编录入页面,做成每期一张任务清单,让看板从"一次性导入的静态报告"变成"每周自动生长的持续诊断"。
- **落点页面**:责编录入(`frontend/src/pages/EditorDesk/EditorDesk.jsx`
- **完整方案**:见同目录 `PRD_期次一条龙录入_v1.md`(页面设计/API 设计/schema 变更/验收标准/分刀计划齐全)。
### 与主干的关系(特殊说明)
本子项目**实施时全部落在主干**EditorDesk 前端 + backend API/服务 + 005 迁移),不是独立部署的应用。外迁的是**方案讨论和 PRD 迭代**;动代码时走主干纪律:Cline Plan + Opus 审 + 制片人批准 Act005 迁移前必须 pg_dump 备份。
---
## 2. 关键决策(制片人已拍板,2026-07-07)
1. **文稿口径 = doco 融合A稿**。doco 以播出后唱词字幕 OCR 为最标准依据;CCA 是播出前生产工具,审片/播出后还会改,不算最终文稿。**doco 由此转入常态运行**:每期播出后跑一遍 doco 产融合A稿,作为文稿环节的固定上游。
2. **doco 22 期旧稿导入知识库时回联期次**(期次号/播出日期在文件名上,可解析匹配),挂 PRD 第二刀。
3. **责编可触发「开始 AI 处理」**(系统日常维护以责编为主)。
### 设计要点(PRD 核心结论,防跨 session 丢失)
- **任务清单模式,不是强制向导**:五环节各自独立完成,期次表格加 4 个状态点(收视/文稿/AI处理/已审核),全绿 = 进看板全部模块。
- **状态不单独存列,全部从现有字段推导**audience_share / transcript_item_id / program_format+content_digest / ai_label_confidence)。
- **审核权仅制片人**:AI 打标落库为 draft;看板象限图等标签类模块**只用 reviewed 期次**——设计哲学红线(编导绝不见 AI 草稿值)的代码落地。
- **只加一列 schema**005 迁移 `episodes.transcript_item_id`(软引用知识库条目,ON DELETE SET NULL,禁级联删)。存量 25 期 `ai_label_confidence` 回填 'reviewed'(源自制片人审定的 GT v0.6.0)。
- **全面复用**Prompt 1-4 留在 `ai-labeling/prompts/` 后端只读;打标调用逻辑移植自 `ai-labeling/scripts/`;文稿入库复用 Phase 3 知识库链路;后台任务用 BackgroundTasks+内存表(不引 celery)。
---
## 3. 待办(按 PRD §8 四刀)
- [ ] **第一刀(纯前端)**:流水线状态列 + 抽屉骨架 + 区块一(复用现有表单)
- [ ] **第二刀(后端)**005 迁移(先 pg_dump+ 文稿上传入库关联 + doco 22 期批量导入回联
- [ ] **第三刀**:AI 处理服务 + 后台任务 + 进度轮询
- [ ] **第四刀**:审核表单 + 权限 + 看板 reviewed 过滤
- 每刀独立验收(PRD §7 七条验收标准,制片人真实点页面逐条验)
---
## 4. 已完成(只追加,最新在上)
- [2026-07-07 Claude Fable] 立项:PRD v1.0 完成(含制片人三项拍板写入 §9)、子项目文件夹建立、主项目寄存条与 CLAUDE.md 登记。
---
## 5. ⏩ 交接备注
- **开发未启动**,等制片人排期。启动时从 PRD §8 第一刀开始。
- **第一刀 Cline 指令**已在 2026-07-07 主项目对话中封装好(Plan 模式:状态列+抽屉骨架+区块一+Response schema 补字段,不动 Model/不迁移)。若找不到原文,按 PRD §8 第一刀范围重新封装即可。
- **依赖提醒**:第三刀需 `backend/.env``MIMO_API_KEY`;后端确认 python-docx 依赖。
- **关联子项目**:doco(融合A稿上游,常态运行)、ai-labelingPrompt 1-4 资产所在地,继续在那边迭代)。
@@ -0,0 +1,142 @@
# PRD:期次一条龙录入(收视分析可持续化)v1.0
> 立项:2026-07-07 | 需求方:制片人 | 起草:Claude(顾问)
> 目标页面:责编录入(`frontend/src/pages/EditorDesk/EditorDesk.jsx`
> 实施方式:Cline Plan + Opus 审 + 制片人批准 Act(含一次 schema 迁移,走既定纪律:迁移前 pg_dump 备份)
---
## 1. 背景与目标
看板升级的终极目标是"每做完一期节目,系统自动消化文稿和收视成绩,持续输出诊断"。但现在 25 期数据是一次性脚本导入的,往后每周新一期没有入口——收视数据、文稿入库、AI 打标、制片人审核、摘要卡生成五件事散在各处。
**本功能把这五件事收进责编录入页面,做成一条龙:责编录数据传文稿 → 系统自动 AI 处理 → 制片人审核标签 → 该期自动进入收视分析看板全部模块。**
不做成强制向导(收视数据播出次日才有、文稿可能更早就有),做成**每期一张任务清单**:五个环节各自独立完成、随时可补,页面上一眼看出"这期还差什么"。
---
## 2. 每期五个环节(流水线定义)
| # | 环节 | 谁操作 | 做什么 | 完成判定(从现有字段推导,不加状态列) |
|---|------|--------|--------|--------------------------------------|
| ① | 基础信息+收视 | 责编 | 现有新增/编辑期次表单,收视份额可后补 | `audience_share` 非空 |
| ② | 文稿上传 | 责编 | 上传该期 **doco 融合A稿**(docx/md)→ 自动入知识库+算向量+关联期次 | `transcript_item_id` 非空(新列,见 §5 |
| ③ | AI 处理 | 责编或制片人 | 一键触发:打标(Prompt 1/2/3 → 6 个标签字段)+ 摘要卡(Prompt 4 → content_digest),后台串行跑 | `program_format` 非空 且 `content_digest` 非空 |
| ④ | 制片人审核 | **仅制片人** | 看 AI 草稿标签,逐项可改(下拉枚举),点「审核通过」 | `ai_label_confidence == 'reviewed'` |
| ⑤ | 完成 | — | 无操作,①-④全勾即完成,该期在看板全模块生效 | 前四项全满足 |
**环节依赖**:③ 依赖 ②(没文稿没法打标);④ 依赖 ③;①与②③无顺序要求。
---
## 3. 页面设计(责编录入页改造)
### 3.1 「节目期次」Tab 升级为流水线视图
现有表格增加一列「流水线」,每行显示 4 个状态点(收视 / 文稿 / AI处理 / 已审核),完成=实心绿点,未完成=空心灰点。鼠标悬停显示环节名。
表格行新增「处理」按钮 → 打开该期的**流水线抽屉(Drawer)**。
### 3.2 流水线抽屉(核心新界面)
抽屉从右侧滑出(宽约 560px),从上到下四个区块,对应环节 ①-④:
**区块一:基础信息+收视**
- 只读展示期次号/节目名/播出日期/编导/收视份额,带「编辑」按钮打开现有编辑弹窗。收视份额为空时黄色提示"收视数据待补录"。
**区块二:文稿**
- 未上传:拖拽上传框(接受 .docx / .md,单文件,≤10MB)。上传后后端自动:解析文本 → 写入知识库(source_type=manuscriptauthor=该期编导快照)→ 调 MiniMax 算 1536 维向量 → 回写 `episodes.transcript_item_id`
- 已上传:显示文件名+入库时间+「重新上传」按钮(重传=删旧知识库条目+走一遍新流程,需二次确认弹窗)。
**区块三:AI 处理**
- 前提未满足(无文稿):按钮置灰,提示"请先上传文稿"。
- 就绪:「开始 AI 处理」按钮。点击后后台任务串行跑 4 个 Prompt(分类/叙事/钩子/摘要卡),前端每 3 秒轮询进度,显示"打标中 2/4…"。全程预计 1-3 分钟,期间可关抽屉,不阻塞。
- 完成:展示 6 个标签结果 + 摘要卡生成时间;标签区显著标注 **「AI 草稿·待制片人审核」**(黄色 Tag)。
- 失败:显示错误信息 + 「重试」按钮(按 Prompt 粒度断点续跑,已成功的不重跑)。
**区块四:制片人审核**
- 仅制片人可见可操作(责编看到的是"待制片人审核"或"已审核"状态文字)。
- 6 个标签渲染为可编辑表单:program_format(单选下拉 6 值)、equipment_domain(多选 9 值)、scene_tags(多选 4 值)、tech_tags(多选 3 值)、narrative_structure(单选 2 值)、opening_hook(单选 3 值)。枚举值以 `ai-labeling/CLAUDE.md` §1.4 为准,前后端各存一份常量并对齐。
- 「审核通过」按钮:保存表单值 + 置 `ai_label_confidence='reviewed'`
- 已审核后再次打开可继续修改并重新保存(保持 reviewed)。
- 已审核的期次若重跑 AI 处理,需二次确认:"该期标签已审核,重跑将覆盖为草稿状态,确定?"
---
## 4. 后端设计
### 4.1 新增 API`backend/app/api/pipeline.py`,前缀 `/api/pipeline`
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | `/episodes/{id}/status` | 三角色可读 | 返回该期四环节完成状态(从字段推导) |
| POST | `/episodes/{id}/transcript` | 责编+制片人 | 上传文稿:解析→知识库入库→embedding→回写关联 |
| DELETE | `/episodes/{id}/transcript` | 责编+制片人 | 删除关联文稿(重传前置步骤) |
| POST | `/episodes/{id}/ai-process` | 责编+制片人 | 启动后台 AI 处理任务,返回 task_id |
| GET | `/episodes/{id}/ai-process/status` | 三角色可读 | 轮询任务进度(pending/running 2/4/done/failed+错误信息) |
| POST | `/episodes/{id}/labels/review` | **仅制片人** | 保存审核后标签 + 置 reviewed |
### 4.2 AI 处理服务(`backend/app/services/labeling_service.py`,新建)
-`ai-labeling/scripts/run_labeling.py``gen_content_digest.py` 的调用逻辑移植为后端服务函数(脚本本身保留不动,作为批量工具)。
- Prompt 文件继续从 `ai-labeling/prompts/` 读取(与 analytics.py 读 prompt5 的做法一致)——**Prompt 是活资产,继续在子项目里迭代,后端只读不复制**。
- 模型:打标+摘要卡用 **mimo-v2.5-pro**(生产模型,与子项目选型一致);`MIMO_API_KEY` 加入 `backend/.env`
- 沿用子项目已验证的技术约束:剥 `<think>` 前缀正则、不用 `response_format=json_object`、打标关 thinking。
- 后台任务用 FastAPI `BackgroundTasks` + 内存任务表(与诊断报告内存缓存同款做法,1.0 不引入 celery/redis)。
- 打标结果落库时 `ai_label_confidence='draft'`
### 4.3 文稿入库复用
- 复用 Phase 3 知识库上传/embedding 链路(`knowledge.py` 的入库+向量逻辑抽成可复用函数供 pipeline 调用)。
- docx→文本解析参考 `ai-labeling/scripts/import_transcripts.py`python-docx,后端需确认依赖已装)。
---
## 5. Schema 变更(005 迁移,需 Opus 审 + 制片人批准 + 迁移前 pg_dump)
```
005_add_transcript_link.sql
episodes 表 + 1 列:
transcript_item_id INTEGER NULL REFERENCES 知识库条目表(id) ON DELETE SET NULL
```
- 可空软引用:文稿被从知识库删除时自动断开关联(SET NULL),期次不受影响——与"软引用+快照"既有哲学一致,**禁止级联删期次**。
- 同步更新 `backend/app/models/episode.py``schemas/episode.py`
- **不加流水线状态列**——四环节状态全部从现有字段推导(见 §2 表),避免状态与事实不同步。
- 迁移同时做一次**存量数据回填**:现有 25 期中已导入 AI 标签的,`ai_label_confidence` 统一置 `'reviewed'`(这批标签源自制片人逐期审定的 ground-truth v0.6.0,视为已审)。
---
## 6. 与看板的联动规则(设计哲学红线落地)
- **看板象限图/题材对比等依赖 AI 标签的模块,只使用 `ai_label_confidence='reviewed'` 的期次**。草稿标签绝不进入编导可见的任何图表(红线:"未审核内容对编导显示待审核,绝不展示 AI 草稿值")。
- 走势图、指标卡等不依赖标签的模块照常显示全部期次(不受审核状态影响)。
- L4 诊断报告的数据组装同样只取 reviewed 期次的标签 + 摘要卡。
- v1 简化处理:未审核期次直接不出现在标签类图表中(不做按角色区分渲染),审核通过后自然出现。
---
## 7. 验收标准(制片人真实点页面逐条验,不信自检)
1. 责编新增一期 + 填收视 → 流水线列显示 1/4 完成。
2. 上传一份真实融合A稿 docx → 知识库管理页能看到该文稿、语义可检索(向量已算)、流水线 2/4。
3. 点「开始 AI 处理」→ 3 分钟内 6 个标签+摘要卡就位,标签带"AI 草稿"黄标,流水线 3/4。
4. 用责编账号看抽屉 → 审核区不可操作;用制片人账号 → 可改标签、点审核通过 → 流水线 4/4。
5. 审核通过后刷新收视分析看板 → 该期出现在双引擎象限图和题材对比中;审核前不出现。
6. AI 处理中途断网/API 失败 → 状态显示失败+可重试,已成功的 Prompt 不重跑。
7. 重跑已审核期次 → 有二次确认,确认后标签回到草稿态、看板中该期从标签类图表消失。
## 8. 分期实施建议(给 Cline 排刀)
- **第一刀(纯前端+已有API)**:流水线状态列 + 抽屉骨架 + 区块一(复用现有表单)。
- **第二刀(后端)**:005 迁移 + 文稿上传入库关联(区块二)。**doco 22 期成品批量导入知识库+回联期次(§9.2)也挂这一刀**——迁移落地后立即有真实数据可验。
- **第三刀(后端+前端)**:AI 处理服务 + 后台任务 + 进度轮询(区块三)。
- **第四刀**:审核表单 + 权限 + 看板 reviewed 过滤(区块四 + §6)。
- 每刀独立可验收,任何一刀卡住不影响已上线部分。
## 9. 已拍板决定(制片人 2026-07-07
1. **文稿口径 = doco 融合A稿**。理由(制片人):doco 以播出后唱词字幕 OCR 为最标准依据;CCA 是播出前生产唱词的工具,审片和播出后编导/责编还可能改,不是最终文稿。**由此确认 doco 转入常态运行:每期播出后跑一遍 doco 全流程产出融合A稿,作为环节②的固定上游。**
2. **旧期次文稿要回联期次**。doco 22 期批量导入知识库时,导入脚本一并回写 `transcript_item_id`。期次号与播出日期已在融合A稿文件名中注明(格式 `第XX期_YYYYMMDD_节目名_编导_融合A稿.docx`),可直接解析匹配。
3. **责编可触发「开始 AI 处理」**。系统日常维护以责编为主,权限按"责编+制片人"实施(即 §4.1 现设计,不改)。
@@ -10,18 +10,29 @@ import { generateDiagnosisReport } from '../../services/analyticsService'
* - 从 markdown 提取核心发现和行动建议 * - 从 markdown 提取核心发现和行动建议
*/ */
/** 从 markdown 中提取指定章节内容 */ /** 从 markdown 中提取指定章节内容(正则模糊匹配) */
function extractSection(markdown, startMarker, endMarkers) { function extractSection(markdown, keyword, endKeywords) {
if (!markdown) return '' if (!markdown) return ''
const startIdx = markdown.indexOf(startMarker) // 用正则匹配:任意数量的 # 开头,后面包含关键词的行
if (startIdx === -1) return '' const startRegex = new RegExp(`^#{1,6}\\s*.*${keyword}.*$`, 'm')
const afterStart = startIdx + startMarker.length const match = markdown.match(startRegex)
// 找最近的结束标记 if (!match) return ''
const afterStart = match.index + match[0].length
// 找最近的结束标记(下一个同级或更高级标题)
let endIdx = markdown.length let endIdx = markdown.length
for (const marker of endMarkers) { if (endKeywords && endKeywords.length > 0) {
const idx = markdown.indexOf(marker, afterStart) for (const kw of endKeywords) {
if (idx !== -1 && idx < endIdx) { const endRegex = new RegExp(`^#{1,6}\\s*.*${kw}.*$`, 'm')
endIdx = idx const endMatch = markdown.substring(afterStart).match(endRegex)
if (endMatch && (afterStart + endMatch.index) < endIdx) {
endIdx = afterStart + endMatch.index
}
}
} else {
// 没有 endKeywords 时,找下一个同级标题作为结束
const nextHeading = markdown.substring(afterStart).match(/^#{1,6}\s+/m)
if (nextHeading) {
endIdx = afterStart + nextHeading.index
} }
} }
return markdown.substring(afterStart, endIdx).trim() return markdown.substring(afterStart, endIdx).trim()
@@ -156,10 +167,10 @@ function DiagnosisSummary({ episodes, yearlyTarget, selectedYear }) {
// 提取核心发现和行动建议 // 提取核心发现和行动建议
const markdown = report?.report_markdown || '' const markdown = report?.report_markdown || ''
// 核心发现:从 "#### 二、核心发现" 到 "#### 三、" // 核心发现:关键词匹配"核心发现",到"深度分析"或"三、"
let coreFindings = extractSection(markdown, '#### 二、核心发现', ['#### 三、']) let coreFindings = extractSection(markdown, '核心发现', ['深度分析', '三、'])
// 行动建议:从 "#### 四、行动建议" 到末尾 // 行动建议:关键词匹配"行动建议"到末尾
let actions = extractSection(markdown, '#### 四、行动建议', []) let actions = extractSection(markdown, '行动建议', [])
const coreLines = parseMarkdownLines(coreFindings) const coreLines = parseMarkdownLines(coreFindings)
const actionLines = parseMarkdownLines(actions) const actionLines = parseMarkdownLines(actions)
+5 -1
View File
@@ -18,6 +18,8 @@
.app-main { .app-main {
min-height: 100vh; min-height: 100vh;
margin-left: 220px; margin-left: 220px;
width: calc(100vw - 220px);
overflow-x: hidden;
} }
.app-header { .app-header {
@@ -35,6 +37,8 @@
padding: 24px; padding: 24px;
background: var(--color-bg-cream); background: var(--color-bg-cream);
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
max-width: 1400px; width: 100%;
max-width: 1190px;
margin: 0 auto; margin: 0 auto;
box-sizing: border-box;
} }
+2 -2
View File
@@ -1,6 +1,6 @@
.dashboard { .dashboard {
max-width: 1200px; padding: 12px 0;
padding: 12px; box-sizing: border-box;
} }
/* ===== Banner ===== */ /* ===== Banner ===== */
@@ -86,6 +86,7 @@ function Dashboard() {
const displayEpisodes = episodes const displayEpisodes = episodes
.filter(e => e.audience_share != null) .filter(e => e.audience_share != null)
.slice(0, 12) .slice(0, 12)
.reverse()
const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0] const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0]
@@ -0,0 +1,79 @@
# 寄存条:CCA 唱词助手子项目已外迁
> 留在主 project,让未来的 Claude 一眼知道:CCA 唱词助手模块已拉出去单独立项了。
> 外迁时间:2026-07-04
---
## 外迁了什么
**CCA 唱词助手**ChangCi Assist)整体作为子项目。
聚焦:编导 A 稿专有名词提取 → 讯飞 ASR 转写 → AI 校对比对 → 编导审稿台 → 拍词折行 → 大洋格式 SRT 输出。
目的:编导剪完节目后,半自动生成唱词字幕 SRT 文件,交责编上大洋系统。
---
## 主 project 不再讨论的话题
聊到下面这些词,**先问一句"这是 CCA 子项目的事还是主 project 的事?"**——是子项目的事,提醒制片人切到 CCA 子项目 chat。
关键词:cca / 唱词助手 / changci / SRT 字幕 / 拍词规则 / 编导审稿台 / 唱词校对 / ASR 转字幕 / 大洋字幕格式 / 折行规则 / 审稿对比
---
## 主 project 仍在管什么
- Phase 0-3 已落地的主干代码(episodes、editors、收视诊断、知识库)
- backend 全部 schema、API、迁移脚本
- 前端全部 React 代码实施
- 主干 Bug、性能优化、新需求(CCA 以外的)
- CCA 成熟后并入 TPS 的对接方案(届时再定)
- 看板分析升级 / Doco 文稿整理等其他子项目
---
## 与 Doco 子项目的区别
| | Doco | CCA |
|---|---|---|
| **时间点** | 播出后 | 剪辑后、播出前 |
| **目的** | 整理终版文稿(存档/知识库) | 生成唱词字幕 SRT(上大洋播出) |
| **输入** | A稿 + 视频(含字幕+人声) | A稿 + 纯人声音频 |
| **输出** | 融合 A 稿 docx | SRT 字幕文件 |
| **ASR 角色** | 辅助参照 | 核心底稿(时间戳+内容基准) |
| **共同点** | 都用讯飞录音文件转写标准版 + 热词偏置 |
---
## 接收子项目交付物的接口
CCA 最终要并入 TPS 主项目作为子功能。接口约定:
- **独立 Web 应用**:先在 lanhao 配音 2.0 部署测试
- **并入路径**:成熟后以子功能形式嵌入 TPS 前端,走主项目路由
- **凭证管理**:子项目自己 .env,主项目 `api_credentials_inventory.md` 登记元信息
**纪律**
- 并入前,子项目不改主项目 backend 代码 / schema
- 并入方案由主项目侧审批
- 实施完成后回访子项目登记状态
---
## 开局口径
进主 project 新 chat
> "续接 TPS 主项目。读 `寄存条CCA唱词助手子项目已外迁.md` 和其他寄存条。当前要做 [具体任务]。"
---
## 当前外迁的子项目清单(随更新)
| 子项目 | 外迁日期 | 寄存条文件 | 状态 |
|---|---|---|---|
| TPS 看板分析升级 | 2026-06-12 | `寄存条_看板升级已外迁.md` | 原型阶段收工(2026-07-03 |
| Doco 文稿整理 | 2026-06-12 | `寄存条Doco文稿整理子项目已外迁.md` | 开发收尾(2026-07-03 |
| CCA 唱词助手 | 2026-07-04 | 本文件 | 已部署腾讯云,内测中(2026-07-05 |
| 期次一条龙录入 | 2026-07-07 | `寄存条期次一条龙录入子项目已外迁.md` | 立项,PRD 就绪,待开发 |
@@ -104,3 +104,4 @@ Doco 子项目交付物的接口约定,见**子项目主 Brief §六**(交付什
| ---------------- | ---------- | -------------------------- | ------------------------- | | ---------------- | ---------- | -------------------------- | ------------------------- |
| TPS 看板分析升级 | 2026-06-12 | `寄存条_看板升级已外迁.md` | 原型阶段收工(2026-07-03 | | TPS 看板分析升级 | 2026-06-12 | `寄存条_看板升级已外迁.md` | 原型阶段收工(2026-07-03 |
| Doco 文稿整理 | 2026-06-12 | 本文件 | **开发收尾(2026-07-03**22 期融合A稿已产出,成品在 `doco/deliverables/`,待批量导入主项目知识库 | | Doco 文稿整理 | 2026-06-12 | 本文件 | **开发收尾(2026-07-03**22 期融合A稿已产出,成品在 `doco/deliverables/`,待批量导入主项目知识库 |
| CCA 唱词助手 | 2026-07-04 | `寄存条CCA唱词助手子项目已外迁.md` | **立项,待开发** |
@@ -0,0 +1,70 @@
# 寄存条:期次一条龙录入子项目已外迁
> 留在主 project,让未来的 Claude 一眼知道:期次一条龙录入(收视分析持续运行入口)已单独立项。
> 外迁时间:2026-07-07
---
## 外迁了什么
**期次一条龙录入**episode-intake)作为子项目,目录 `episode-intake/`
聚焦:责编录入页升级为每期流水线任务清单——收视录入 → doco 融合A稿入库(知识库+向量+回联期次)→ AI 打标+摘要卡(一键后台跑)→ 制片人审核标签 → 该期自动进收视分析看板全部模块。
目的:让看板升级的收视分析从"一次性导入"变成"每周持续生长",补上 L1 数据沉淀层的入口。
**状态:已立项,PRD v1.0 完成(含制片人三项拍板),未开发。** 方案全文见 `episode-intake/PRD_期次一条龙录入_v1.md`
---
## 主 project 不再讨论的话题
聊到下面这些词,**先问一句"这是期次一条龙子项目的事还是主 project 的事?"**——是子项目的事,提醒制片人切到对应 chat。
关键词:一条龙 / 期次录入流水线 / 流水线状态点 / 期次任务清单 / 文稿回联期次 / transcript_item_id / 005 迁移 / AI 处理按钮 / 标签审核台 / draft-reviewed 置信度流转 / 责编录入抽屉
---
## 主 project 仍在管什么
- Phase 0-3 已落地的主干代码、全部 backend schema/API/迁移、全部前端 React 实施
- 责编录入页**现有功能**的 bug/维护(期次增删改、年度目标、Excel 批量导入)
- 收视分析看板 L1-L4 已上线功能的维护
- 其他子项目(看板升级 / doco / CCA
---
## 特殊说明:本子项目实施时落在主干
与 doco/CCA 不同,本子项目**没有独立部署物**——外迁的是方案讨论和 PRD 迭代,实施时直接改主干(EditorDesk 前端 + backend + 005 迁移)。因此实施阶段纪律:
- 走主干规矩:Cline Plan + Opus 审 + 制片人批准 Act
- **005 迁移前必须 pg_dump 备份**
- 分四刀实施(见 PRD §8),每刀独立验收,制片人真实点页面
## 已拍板决定(2026-07-07,别翻案)
1. **文稿口径 = doco 融合A稿**(CCA 是播出前工具,之后还会改,不是最终文稿)。doco 转入常态运行,每期播出后跑一遍。
2. **doco 22 期批量导入知识库时回联期次**(文件名含期次号/播出日期,解析匹配),挂第二刀。
3. **责编可触发 AI 处理**(日常维护以责编为主)。
---
## 开局口径
进主 project 新 chat
> "续接 TPS 主项目。读 `寄存条期次一条龙录入子项目已外迁.md` 和其他寄存条。当前要做 [具体任务]。"
启动本子项目开发:
> "续接期次一条龙录入子项目。读 `episode-intake/CLAUDE.md` 和 PRD。从第 N 刀开始。"
---
## 当前外迁的子项目清单(随更新)
| 子项目 | 外迁日期 | 寄存条文件 | 状态 |
|---|---|---|---|
| TPS 看板分析升级 | 2026-06-12 | `寄存条看板升级已外迁.md` | 原型收工,L1-L4 已在主干实现(2026-07-06 |
| Doco 文稿整理 | 2026-06-12 | `寄存条Doco文稿整理子项目已外迁.md` | 开发收尾(2026-07-03),转常态运行(每期播出后跑) |
| CCA 唱词助手 | 2026-07-04 | `寄存条CCA唱词助手子项目已外迁.md` | 已部署腾讯云,内测中(2026-07-05 |
| 期次一条龙录入 | 2026-07-07 | 本文件 | **立项,PRD 就绪,待开发** |