Compare commits
7 Commits
f37679530a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1578163602 | |||
| 7a48af3e9e | |||
| 7eb6511169 | |||
| ede30d3043 | |||
| 1ccb37f0c7 | |||
| 9df4868191 | |||
| 3dd718f53d |
@@ -13,15 +13,15 @@
|
|||||||
|
|
||||||
## 🔖 状态栏(每次结束 session 前必须更新这三行)
|
## 🔖 状态栏(每次结束 session 前必须更新这三行)
|
||||||
|
|
||||||
- **最后更新**:Claude(顾问+动手)| 2026-07-03
|
- **最后更新**:Claude Fable(顾问)| 2026-07-07
|
||||||
- **当前状态一句话**:收视分析看板 React 前端 L1–L4 已实现并验收(指标卡+走势图+季度/编导/题材对比+双引擎象限图)。25 期真实收视+AI 标签已导入。下一步:L4 AI 诊断报告(新 session 讨论)。
|
- **当前状态一句话**:**期次一条龙录入子项目立项**(收视分析持续运行入口,落点责编录入页)——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 0–3 主干代码、全部 backend schema/API/迁移、全部前端 React 实施、主干 bug/性能/新需求、Cline 的全部 Plan+Act。
|
**主干仍管**:Phase 0–3 主干代码、全部 backend schema/API/迁移、全部前端 React 实施、主干 bug/性能/新需求、Cline 的全部 Plan+Act。
|
||||||
|
|
||||||
@@ -74,16 +76,19 @@
|
|||||||
|
|
||||||
## 3. 当前进度(动态,核心交接区 — 以最新快照为准)
|
## 3. 当前进度(动态,核心交接区 — 以最新快照为准)
|
||||||
|
|
||||||
- **已完成至**:收视分析看板 React 前端 L1–L4(指标卡、走势图、季度/编导/题材对比、双引擎象限图),25 期真实数据已导入。
|
- **已完成至**:收视分析看板 L1-L4 全部完成,含 L4 AI 诊断报告(DeepSeek V4 Pro 生成、摘要块三档自适应+详情页 Markdown 渲染、22期内容摘要卡入库)。
|
||||||
- **正在做**:无。下一步为 L4 AI 诊断报告,制片人将开新 session 讨论。
|
- **正在做**:无。
|
||||||
- **卡点/待解**:AI 诊断报告方案待细化(双步骤生成+制片人人工验证,方案草稿见 `memory/project_ai_diagnosis_plan.md`)。
|
- **卡点/待解**:无硬卡点。下一刀候选见待办。
|
||||||
- **Schema 状态**:知识库两表 schema 已就绪,**Phase 3 不再改表**。episodes 表已通过 003 迁移加 7 列 AI 标签字段(看板升级子项目 2026-06-26 执行),25 期数据含 AI 标签已就位。
|
- **Schema 状态**:episodes 表已通过 003(+7 AI 标签列)和 004(+content_digest JSONB)迁移。知识库两表不再改。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 已完成(只追加,最新在上)
|
## 4. 已完成(只追加,最新在上)
|
||||||
|
|
||||||
- [2026-07-03] **收视分析看板 React 前端实现(L1–L4)**:① 指标卡 5 列(均值/基础达标率/摸高完成率含四档动画/期次数/年度目标);② 走势折线图(dataZoom 滑块+确认按钮范围过滤,下游模块联动);③ 双列对比区(左列季度+编导、右列题材饼图+条形图);④ 双引擎象限图(题材热度×叙事结构散点,气泡编码份额/三色/钩子强度)。附带:25 期真实收视+AI 标签数据导入、侧边栏 fixed 定位、滑块滚轮冲突修复。后端 analytics API 增返 4 个 AI 标签字段。
|
- [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_digest(004 迁移);③ 后端 POST /api/analytics/diagnosis-report 端点(组装数据+调 DeepSeek+内存缓存);④ 前端摘要块 DiagnosisSummary(左右两块、三档自适应色调、可滚动、粗体渲染)+ 详情页 DiagnosisReport(react-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] 看板升级子项目原型阶段收工: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 标签列)。交付清单见寄存条。
|
||||||
- [2026-05-27] Phase 3 Task 3/3.1/3.2:知识库树形视图(按来源大类→二级维度分组、左右布局、节点联动、展开收起;修出处筛选/宽度跳动/排版)。commit `3409d48`。
|
- [2026-05-27] Phase 3 Task 3/3.1/3.2:知识库树形视图(按来源大类→二级维度分组、左右布局、节点联动、展开收起;修出处筛选/宽度跳动/排版)。commit `3409d48`。
|
||||||
- [2026-05-27] Phase 3 Task 2:知识库管理后台最小版(上传/列表/删除)。
|
- [2026-05-27] Phase 3 Task 2:知识库管理后台最小版(上传/列表/删除)。
|
||||||
@@ -96,8 +101,9 @@
|
|||||||
|
|
||||||
## 5. 待办(下一刀候选,开局前定先做哪件)
|
## 5. 待办(下一刀候选,开局前定先做哪件)
|
||||||
|
|
||||||
- [ ] **L4 AI 诊断报告**:收视分析看板最后一个模块,双步骤生成(先 AI 出草稿→制片人审核发布),方案草稿见 `memory/project_ai_diagnosis_plan.md`,制片人将开新 session 讨论。
|
- [x] ~~L4 AI 诊断报告~~ → 已完成(2026-07-03)
|
||||||
- [ ] **下一刀三选一**:① 语义搜索界面(不依赖任何材料,随时能开,是 Phase 4a 硬门槛);② PDF 原文关联 + 大文件存储架构(需 Opus 审方案,优先级较高);③ 界面像素级打磨+视觉规范统一。
|
- [ ] **下一刀三选一**:① 语义搜索界面(不依赖任何材料,随时能开,是 Phase 4a 硬门槛);② PDF 原文关联 + 大文件存储架构(需 Opus 审方案,优先级较高);③ 界面像素级打磨+视觉规范统一。
|
||||||
|
- [ ] **期次一条龙录入开发**(子项目已立项 2026-07-07,PRD 就绪在 `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 重构时一并处理,从来源树里迁出来)。
|
||||||
@@ -124,14 +130,22 @@
|
|||||||
- embedding 用 **MiniMax embo-01**:请求用 `texts` 数组 + `type`(db/query),返回在 `vectors` 字段,需 API Key + GroupId;花 MiniMax 账号额度(与 Claude token 两笔账,一篇几厘钱)。
|
- embedding 用 **MiniMax embo-01**:请求用 `texts` 数组 + `type`(db/query),返回在 `vectors` 字段,需 API Key + GroupId;花 MiniMax 账号额度(与 Claude token 两笔账,一篇几厘钱)。
|
||||||
- **PDF 等大文件不入数据库**,单独文件仓库 + DB 存指针(详见待办,待立项)。
|
- **PDF 等大文件不入数据库**,单独文件仓库 + DB 存指针(详见待办,待立项)。
|
||||||
- **收视分析看板 dataZoom 模式**:只用 slider(禁 `type:'inside'` 避免滚轮冲突),拖滑块更新 `zoomRange` 但不立即过滤,用户点「应用此范围」按钮才写入 `appliedRange` → `filteredEpisodes`;走势图始终用全量 `episodes`,其余模块(指标卡/对比/象限图)用 `filteredEpisodes`。
|
- **收视分析看板 dataZoom 模式**:只用 slider(禁 `type:'inside'` 避免滚轮冲突),拖滑块更新 `zoomRange` 但不立即过滤,用户点「应用此范围」按钮才写入 `appliedRange` → `filteredEpisodes`;走势图始终用全量 `episodes`,其余模块(指标卡/对比/象限图)用 `filteredEpisodes`。
|
||||||
- **收视分析看板页面顺序**:指标卡 → 走势图 → AI 诊断报告(占位) → 双引擎象限图 → 双列对比(左列季度+编导堆叠、右列题材跨两行)。
|
- **收视分析看板页面顺序**:指标卡 → 走势图 → AI 诊断报告(摘要块) → 双引擎象限图 → 双列对比(左列季度+编导堆叠、右列题材跨两行)。
|
||||||
|
- **L4 AI 诊断报告模型选型**:DeepSeek V4 Pro(胜出,分析深度+论证结构优于 Qwen-Max)。不搞多模型共识——诊断报告是完整分析文章,逻辑连贯性比多家一致更重要。
|
||||||
|
- **L4 摘要块视觉**:左块固定粉红色系(`#c0584f`),右块跟 tier 三档变色(绿/蓝/红)。两块均可滚动查看完整内容。
|
||||||
|
- **L4 话题性框架**:社交货币理论三维(大众认知度/降维切口/惊奇密度),写入摘要卡不作为独立 AI 标签字段。
|
||||||
|
- **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,宽度锁死无法调节)。制片人直接说"调这个参数"即可。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. ⏩ 交接备注(换人/换账号 0 摩擦续上)
|
## 7. ⏩ 交接备注(换人/换账号 0 摩擦续上)
|
||||||
|
|
||||||
- **收视分析看板前端 L1–L4 已验收**,下一步是 L4 AI 诊断报告(新 session)。页面布局顺序已定稿:指标卡 → 走势图 → AI 诊断报告(占位) → 双引擎象限图 → 双列对比(左:季度+编导, 右:题材)。
|
- **收视分析看板 L1-L4 全部完成**。页面布局:指标卡 → 走势图 → AI 诊断报告(摘要块) → 双引擎象限图 → 双列对比(左:季度+编导, 右:题材)。
|
||||||
- **看板前端新增文件**:`frontend/src/components/Analytics/`(QuarterCompare / EditorCompare / TopicCompare / QuadrantChart);`scripts/`(import_real_episodes.py / fix_label_mapping.py,一次性导入脚本,已用完)。
|
- **L4 AI 诊断报告新增文件**:后端 `backend/app/api/analytics.py`(POST 端点);前端 `DiagnosisSummary.jsx`(摘要块)+ `DiagnosisReport.jsx/.css`(详情页);Prompt 文件 `ai-labeling/prompts/prompt4_content_digest.md` + `prompt5_diagnosis_report.md`;批量脚本 `ai-labeling/scripts/gen_content_digest.py` + `scripts/import_content_digests.py`。
|
||||||
|
- **L4 依赖**:`backend/.env` 需配 `DEEPSEEK_API_KEY`;前端需 `react-markdown` 包(已装);后端需 `openai` 包(已装)。
|
||||||
|
- **004 迁移已执行**:episodes 表 +content_digest JSONB 列,22 期摘要卡已导入。
|
||||||
|
- **看板前端其他文件**:`frontend/src/components/Analytics/`(QuarterCompare / EditorCompare / TopicCompare / QuadrantChart);`scripts/`(import_real_episodes.py / fix_label_mapping.py / import_content_digests.py,一次性导入脚本)。
|
||||||
- **schema 红线**:Phase 3 不改知识库两表;`topics` / `topic_embeddings` 属 Phase 4a,**勿碰**;五类 `source_type` 枚举固定不增减,细分杂志/军报=改表=须 Opus 审。
|
- **schema 红线**:Phase 3 不改知识库两表;`topics` / `topic_embeddings` 属 Phase 4a,**勿碰**;五类 `source_type` 枚举固定不增减,细分杂志/军报=改表=须 Opus 审。
|
||||||
- **`logs/` 是受保护资产**:Act 模式严禁 Cline 改;phase 日志由顾问协助生成。Task 3 三轮内容待并入 `phase3_log`(Task 3 段)。
|
- **`logs/` 是受保护资产**:Act 模式严禁 Cline 改;phase 日志由顾问协助生成。Task 3 三轮内容待并入 `phase3_log`(Task 3 段)。
|
||||||
- **避雷(Phase 3 实战教训)**:
|
- **避雷(Phase 3 实战教训)**:
|
||||||
@@ -140,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%`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,6 +166,6 @@
|
|||||||
|
|
||||||
## 9. 待确认 / 开放问题(需制片人拍板,AI 别自行假设)
|
## 9. 待确认 / 开放问题(需制片人拍板,AI 别自行假设)
|
||||||
|
|
||||||
- [ ] AI 诊断报告(L4)方案细化(新 session 讨论)。
|
- [x] ~~AI 诊断报告(L4)方案细化~~ → 已完成(2026-07-03)
|
||||||
- [ ] PDF 文件存哪、笔记如何关联中台内文件位置(Backlog #2 的前提)。
|
- [ ] PDF 文件存哪、笔记如何关联中台内文件位置(Backlog #2 的前提)。
|
||||||
- [ ] 视觉风格参考图、6 名编导初始画像(各 200 字)、甘特图样本 —— 后续 Phase 需要。
|
- [ ] 视觉风格参考图、6 名编导初始画像(各 200 字)、甘特图样本 —— 后续 Phase 需要。
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Prompt 4:节目内容摘要卡生成
|
||||||
|
|
||||||
|
## 角色
|
||||||
|
|
||||||
|
你是一位资深电视节目分析师,擅长从军事科技类节目文稿中提炼核心信息。你的任务是将一篇 5000-7000 字的节目文稿压缩为一份 150-200 字的结构化摘要卡,供后续的收视诊断分析系统使用。
|
||||||
|
|
||||||
|
## 背景知识
|
||||||
|
|
||||||
|
你分析的是央视《军事科技》栏目的节目文稿。该栏目每周一期,每期约 27 分钟,面向对军事装备和国防科技感兴趣的大众观众(非专业军事人员)。
|
||||||
|
|
||||||
|
文稿由以下段落类型构成:
|
||||||
|
- 【导视】:片头预告,通常包含本期核心悬念或看点
|
||||||
|
- 【主持人N】:演播室主持人串场,负责起承转合
|
||||||
|
- 【解说N】:画外音解说,承载主要信息量
|
||||||
|
- 【三维动画解说N】:配合三维动画的技术原理讲解
|
||||||
|
- 【专家N】:专家访谈,提供专业解读
|
||||||
|
- 【街采】:街头采访普通观众(部分期次有)
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
请严格按以下 JSON 格式输出,不要输出任何其他内容:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"核心切口": "一句话(20-40字),概括本期节目用什么角度切入军事科技话题",
|
||||||
|
"叙事亮点": [
|
||||||
|
"第一个亮点(30-50字):本期最出彩的段落、转折、对比或论证设计",
|
||||||
|
"第二个亮点(30-50字,可选):如有第二个突出的叙事设计"
|
||||||
|
],
|
||||||
|
"观众门槛": "低/中/高 — 一句话说明(20-30字)",
|
||||||
|
"话题性": {
|
||||||
|
"总评": "强/中/弱",
|
||||||
|
"大众认知度": "高/中/低 — 一句话理由(15-25字)",
|
||||||
|
"降维切口": "强/中/弱 — 一句话理由(15-25字)",
|
||||||
|
"惊奇密度": "高/中/低 — 一句话理由(15-25字)"
|
||||||
|
},
|
||||||
|
"潜在弱点": [
|
||||||
|
"第一个弱点(30-50字):节奏、深度、趣味性、信息密度等方面的不足"
|
||||||
|
],
|
||||||
|
"时效关联": "有/无 — 如有则说明借势了什么热点/节日/新装备亮相(20-40字)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 各字段评估标准
|
||||||
|
|
||||||
|
### 核心切口
|
||||||
|
提炼本期节目的"一句话卖点"——制片人用什么角度把军事科技话题包装成观众愿意点开的内容。重点是"角度"而非"题材"。
|
||||||
|
|
||||||
|
### 叙事亮点
|
||||||
|
关注文稿中最能抓住观众的叙事设计,例如:
|
||||||
|
- 出人意料的对比(如丑装备 vs 美装备、竞标失败者 vs 成功者)
|
||||||
|
- 引人入胜的历史故事或真实事件
|
||||||
|
- 巧妙的类比或降维解释
|
||||||
|
- 层层递进的悬念设置
|
||||||
|
- 街头采访带来的反差或趣味
|
||||||
|
|
||||||
|
### 观众门槛
|
||||||
|
评估一个对军事完全不了解的普通观众,能否顺畅理解本期内容:
|
||||||
|
- **低**:日常概念切入(如颜值、仿生、玩具),无需军事知识储备
|
||||||
|
- **中**:需要一定常识(如知道航母是什么、战斗机分代),但节目有充分铺垫
|
||||||
|
- **高**:涉及专业概念(如气动布局、相控阵雷达原理),且节目未充分降维
|
||||||
|
|
||||||
|
### 话题性(社交货币评估)
|
||||||
|
话题性的本质是:观众看完这期节目后,在多大程度上愿意主动跟别人聊起它——在微信群、饭桌上、茶歇时说"你看了昨天那期军事科技吗"。
|
||||||
|
|
||||||
|
三个判据:
|
||||||
|
|
||||||
|
**大众认知度**:节目涉及的核心装备或概念,普通人听没听说过?
|
||||||
|
- 高:航母、坦克、AK47、隐身战机、核潜艇——街上随便拉个人都知道
|
||||||
|
- 中:预警机、驱逐舰、无人机——看过新闻的人大概知道
|
||||||
|
- 低:鸭翼气动布局、舰载相控阵雷达、弹道导弹防御层级——需要军事爱好者才懂
|
||||||
|
|
||||||
|
**降维切口**:节目的切入角度,不懂军事的人能不能秒懂、想聊?
|
||||||
|
- 强:用日常概念(颜值、仿生、玩具、进化论)包装军事话题,任何人都能参与讨论
|
||||||
|
- 中:切口有一定趣味(竞标失败者的命运、谁更强),但仍需基本军事兴趣
|
||||||
|
- 弱:直接讲技术原理或装备参数,非爱好者难以产生讨论欲
|
||||||
|
|
||||||
|
**惊奇密度**:全片中有几个让观众"哇!"的瞬间?
|
||||||
|
- 高:3个以上反直觉事实、震撼对比、意外反转(如"丑的反而打赢了""这个国家竟然拦截过黑鸟")
|
||||||
|
- 中:1-2个有趣的知识点或对比,但整体平稳
|
||||||
|
- 低:全片信息性为主,缺少让人惊讶或想转述的瞬间
|
||||||
|
|
||||||
|
三项中占 2 项以上为"强",占 1 项为"中",0 项为"弱"。
|
||||||
|
|
||||||
|
### 潜在弱点
|
||||||
|
从"观众会不会中途换台"的角度审视,常见弱点包括:
|
||||||
|
- 信息密度过高,观众消化不了
|
||||||
|
- 中段节奏拖沓,缺乏新的刺激点
|
||||||
|
- 技术讲解过于抽象,缺少具象化呈现
|
||||||
|
- 案例之间缺乏递进关系,像在念清单
|
||||||
|
- 结论过于笼统,缺少有记忆点的金句
|
||||||
|
- 同类题材短期内重复出现(如连续多期讲枪械)
|
||||||
|
|
||||||
|
### 时效关联
|
||||||
|
判断本期播出时是否借势了外部事件:
|
||||||
|
- 新装备亮相/首飞/下水/交付
|
||||||
|
- 军事冲突或国际安全事件
|
||||||
|
- 国庆/建军节等军事相关节日
|
||||||
|
- 航展/军事展会
|
||||||
|
- 如文稿中未提及任何时事背景,标"无"即可
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 摘要卡的读者不是观众,而是后续的 AI 诊断系统——用分析性语言,不要用宣传性语言。
|
||||||
|
2. 每个字段的评估必须基于文稿内容本身,不要凭题目猜测。
|
||||||
|
3. 话题性评估关注的是"传播潜力"而非"内容质量"——一期制作精良但话题封闭的节目可能话题性弱,一期制作普通但切口有趣的节目可能话题性强。
|
||||||
|
4. 潜在弱点要具体到本期内容,不要给泛泛的评价。
|
||||||
|
5. 总字数控制在 150-200 字(JSON 内纯文本,不含 key 和格式符号)。
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Prompt 5:收视诊断报告生成
|
||||||
|
|
||||||
|
## 角色
|
||||||
|
|
||||||
|
你是央视《军事科技》栏目的资深收视分析顾问。你的任务是基于一组节目的收视数据、AI 标签和内容摘要,撰写一份有深度、有细节的收视诊断分析报告。
|
||||||
|
|
||||||
|
你的分析对象是"节目",不是"人"。报告中不对任何编导进行评价、排名或点名。
|
||||||
|
|
||||||
|
## 背景知识
|
||||||
|
|
||||||
|
### 栏目基本情况
|
||||||
|
《军事科技》是央视国防军事频道的周播节目,每期约 27 分钟,面向对军事装备和国防科技感兴趣的大众观众。栏目 8 人团队(制片人、责编、6 名编导)。
|
||||||
|
|
||||||
|
### 收视颜色判定(与通常直觉相反)
|
||||||
|
- 🔴 红色 = 优秀:收视份额 **高于** 摸高目标
|
||||||
|
- 🔵 蓝色 = 达标:收视份额介于基础目标与摸高目标之间
|
||||||
|
- 🟢 绿色 = 待提升:收视份额 **低于** 基础目标
|
||||||
|
|
||||||
|
### 核心分析框架:双引擎模型
|
||||||
|
收视表现由两个"引擎"驱动,加上一个独立维度:
|
||||||
|
|
||||||
|
**引擎 1:题材热度(地基)**
|
||||||
|
热门装备(航母、隐身战机)、热点事件自带观众基础。即使叙事一般,热门题材也能撑住收视。
|
||||||
|
|
||||||
|
**引擎 2:叙事结构(放大器)**
|
||||||
|
有贯穿全片的主线悬念(主线演进)能放大收视,板块式并列结构则依赖每个板块自身吸引力。
|
||||||
|
|
||||||
|
**独立维度:开篇钩子**
|
||||||
|
前 1-2 分钟决定观众是否换台。强钩子能挽留犹豫的观众,弱钩子让潜在观众流失。
|
||||||
|
|
||||||
|
**核心规律:两条腿至少占一条。** 冷门题材 + 并列结构 = 高风险组合。
|
||||||
|
|
||||||
|
### 话题性(社交货币)
|
||||||
|
话题性 = 观众看完后愿不愿意主动跟别人聊这期节目。由三个因素决定:
|
||||||
|
- 大众认知度:核心装备/概念普通人知不知道
|
||||||
|
- 降维切口:节目角度非军迷能不能秒懂、想聊
|
||||||
|
- 惊奇密度:全片有多少个让人"哇!"的瞬间
|
||||||
|
|
||||||
|
## 输入数据格式
|
||||||
|
|
||||||
|
你会收到以下结构化数据:
|
||||||
|
|
||||||
|
```
|
||||||
|
分析范围:第X期 至 第Y期(共N期)
|
||||||
|
年度目标:基础目标 0.XXXX,摸高目标 0.XXXX
|
||||||
|
整体统计:平均份额、达标率、摸高完成率等
|
||||||
|
|
||||||
|
逐期数据:
|
||||||
|
| 期号 | 节目名 | 份额 | 判定 | 题材类型 | 叙事结构 | 钩子强度 | 话题性 |
|
||||||
|
| ... |
|
||||||
|
|
||||||
|
各期内容摘要卡:
|
||||||
|
第X期《节目名》:
|
||||||
|
核心切口:...
|
||||||
|
叙事亮点:...
|
||||||
|
观众门槛:...
|
||||||
|
话题性:总评/大众认知度/降维切口/惊奇密度
|
||||||
|
潜在弱点:...
|
||||||
|
时效关联:...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 输出要求
|
||||||
|
|
||||||
|
### 报告色调(三档自适应)
|
||||||
|
|
||||||
|
根据所选范围的平均收视份额,自动切换分析语气:
|
||||||
|
|
||||||
|
**当平均份额 < 基础目标时(危险区):**
|
||||||
|
- 语气严厉、直接,不回避问题
|
||||||
|
- 用词示例:"必须正视""亮红灯""持续恶化""如不及时调整"
|
||||||
|
- 重点放在问题诊断和止损建议
|
||||||
|
|
||||||
|
**当平均份额介于基础目标与摸高目标之间时(达标区):**
|
||||||
|
- 语气客观、偏鼓励,帮助团队找准发力方向
|
||||||
|
- 用词示例:"整体达标但仍有提升空间""值得关注的是""建议进一步"
|
||||||
|
- 重点放在原因分析和提振方向
|
||||||
|
|
||||||
|
**当平均份额 > 摸高目标时(优秀区):**
|
||||||
|
- 语气肯定、激励,提炼可复用的成功经验
|
||||||
|
- 用词示例:"表现亮眼""成功经验值得推广""再接再厉"
|
||||||
|
- 重点放在经验总结和保持势头
|
||||||
|
|
||||||
|
### 报告结构
|
||||||
|
|
||||||
|
请按以下结构输出(用 Markdown 格式):
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 一、总体判断(2-3句话)
|
||||||
|
|
||||||
|
用一句话定调整体表现,再用 1-2 句话点出最突出的特征或趋势。要求:精准、有冲击力、能让读者立刻把握全局。
|
||||||
|
|
||||||
|
#### 二、核心发现(3-5条)
|
||||||
|
|
||||||
|
每条发现必须:
|
||||||
|
- 以加粗的结论性判断开头
|
||||||
|
- 紧跟具体数据佐证(期号、份额、百分比)
|
||||||
|
- 点到具体节目名称(**必须用"第X期《节目名》"格式,禁止只写期号不带节目名**——读者记不住期号对应哪期节目)
|
||||||
|
- 50-80 字/条
|
||||||
|
|
||||||
|
核心发现应覆盖以下维度(不必全覆盖,选最有价值的):
|
||||||
|
- 收视走势的关键转折点或趋势
|
||||||
|
- 题材类型与收视的关联规律
|
||||||
|
- 叙事结构(主线演进 vs 并列结构)的效果差异
|
||||||
|
- 钩子强度与观众留存的关系
|
||||||
|
- 话题性与收视的对应关系
|
||||||
|
- 异常值(特别高或特别低)的原因
|
||||||
|
|
||||||
|
#### 三、深度分析(3-4段)
|
||||||
|
|
||||||
|
这是报告的核心价值区。每段 80-120 字,要求:
|
||||||
|
- 不是简单地重复数据,而是**解释为什么**
|
||||||
|
- 结合内容摘要卡中的具体信息(节目的切口、叙事亮点、观众门槛、潜在弱点)
|
||||||
|
- 运用双引擎模型解释收视高低的因果关系
|
||||||
|
- 指出**可迁移的规律**,而不是就事论事
|
||||||
|
- **必须进行跨期归纳**:这份报告分析的是一段时间内的多期节目,你要找出这些节目的共性规律。如果整体表现好,要总结"这批节目做对了什么、共同特征是什么";如果整体表现差,要总结"这批节目共同踩了什么坑";如果好坏参半,要对比分析"好的那几期和差的那几期,差异到底在哪"。逐期点评是数据罗列,跨期归纳才是分析价值。
|
||||||
|
|
||||||
|
分析角度示例:
|
||||||
|
- "第X期《节目名》虽然是冷门题材(XX),但凭借'XX'的降维切口和强钩子逆袭到XX份额,证明切口设计能弥补题材劣势"
|
||||||
|
- "第X期《节目名》至第Y期《节目名》连续N期低于基础线,共同特征是XX类题材+并列结构+XX门槛,说明……"
|
||||||
|
- "对比同为XX题材的第X期《节目名》(份额XX)和第Y期《节目名》(份额XX),差异主要在于……"
|
||||||
|
|
||||||
|
**话题性/社交货币维度必须独立分析:** 深度分析中必须有一段专门分析话题性(社交货币)维度。不要笼统地说"话题性偏弱",而要拆到大众认知度、降维切口、惊奇密度三个子维度,指出这批节目在哪个子维度上集体失分、哪个子维度是个别节目的亮点。话题性分析的价值在于:告诉制片人"观众为什么看完不想跟人聊",而非重复收视数字。
|
||||||
|
|
||||||
|
#### 四、行动建议(2-3条)
|
||||||
|
|
||||||
|
每条建议必须:
|
||||||
|
- 具体可执行,不说正确的废话
|
||||||
|
- 基于前面的分析逻辑推导出来
|
||||||
|
- 面向选题策划和节目制作环节
|
||||||
|
- 40-60 字/条
|
||||||
|
|
||||||
|
好的建议示例:
|
||||||
|
- "控制XX类题材的连续出现频次,建议间隔至少N期,中间插入XX类题材缓冲"
|
||||||
|
- "XX类节目建议优先采用主线演进结构,第X期已验证该组合的收视拉动效果"
|
||||||
|
|
||||||
|
差的建议示例(避免):
|
||||||
|
- "提高节目质量"(太空)
|
||||||
|
- "加强选题策划"(无具体方向)
|
||||||
|
- "某编导应该……"(不评价人)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **只分析节目,不评价编导。** 报告中不出现任何编导姓名,不对编导进行排名、评价或建议。
|
||||||
|
1.5. **全文使用"第X期《节目名》"格式。** 每次提及具体期次都必须同时带期号和节目名,不允许只写"第X期"而不带节目名。读者不会记住期号与节目的对应关系。
|
||||||
|
1.6. **军事装备分类须准确。** 枪械(机枪、步枪、冲锋枪等)属于轻武器/热兵器,不是冷兵器;舰炮属于海军武器系统,不是"冷兵器"。请使用准确的军事分类术语。
|
||||||
|
2. **数据驱动,不凭感觉。** 每个判断都要有数据或具体案例支撑。
|
||||||
|
3. **写给制片人看。** 语言简洁有力,不堆术语,不写学术论文。制片人熟悉每一期节目,不需要你复述剧情,只需要你指出规律和原因。
|
||||||
|
4. **实事求是。** 数据好就说好,数据差就说差,不要为了平衡而硬找优点或硬找缺点。
|
||||||
|
5. **报告总字数 600-900 字。** 这是一份内部工作文档,不是公开发表的研究报告。
|
||||||
|
6. **外部因素诚实标注。** 如果某期收视异常可能受外部因素影响(如同时段重大事件、特殊节假日),但你无法确认,可以标注"可能受外部因素影响,建议结合当期播出环境分析",不要编造外部因素。
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
gen_content_digest.py - 批量生成 22 期节目内容摘要卡
|
||||||
|
用法:
|
||||||
|
cd E:\tps-dashboard\ai-labeling
|
||||||
|
python scripts/gen_content_digest.py
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 读取 prompt4_content_digest.md 作为 system prompt
|
||||||
|
- 遍历 doco/deliverables/ 下所有融合A稿 .docx 文件
|
||||||
|
- 用 python-docx 提取文稿文本,调用 MiMo API 生成结构化摘要卡
|
||||||
|
- 支持断点续跑(已存在的 digest 文件自动跳过)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from openai import OpenAI
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
# 加载 .env(优先加载 ai-labeling 目录下的 .env)
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
BASE_DIR = SCRIPT_DIR.parent # ai-labeling/
|
||||||
|
load_dotenv(BASE_DIR / ".env")
|
||||||
|
load_dotenv() # 也尝试加载项目根目录的 .env
|
||||||
|
|
||||||
|
# 目录配置
|
||||||
|
DELIVERABLES_DIR = BASE_DIR.parent / "doco" / "deliverables"
|
||||||
|
PROMPTS_DIR = BASE_DIR / "prompts"
|
||||||
|
EXPERIMENTS_DIR = BASE_DIR / "experiments" / "content_digests"
|
||||||
|
|
||||||
|
# MiMo API 配置(与 run_labeling.py 一致)
|
||||||
|
MIMO_CONFIG = {
|
||||||
|
"base_url": "https://api.xiaomimimo.com/v1",
|
||||||
|
"model_name": "mimo-v2.5-pro",
|
||||||
|
"api_key_env": "MIMO_API_KEY",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_system_prompt() -> str:
|
||||||
|
"""加载 prompt4_content_digest.md 作为 system prompt。"""
|
||||||
|
prompt_file = PROMPTS_DIR / "prompt4_content_digest.md"
|
||||||
|
if not prompt_file.exists():
|
||||||
|
raise FileNotFoundError(f"找不到 prompt 文件: {prompt_file}")
|
||||||
|
return prompt_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_filename(filename: str) -> dict:
|
||||||
|
"""
|
||||||
|
从文件名解析元信息。
|
||||||
|
文件名格式: 第02期_20260113_武器进化论:海战颠覆者_付天雨_融合A稿.docx
|
||||||
|
返回: {"ep": 2, "date": "2026-01-13", "title": "...", "editor": "..."}
|
||||||
|
"""
|
||||||
|
name = filename.replace(".docx", "")
|
||||||
|
parts = name.split("_")
|
||||||
|
if len(parts) < 4:
|
||||||
|
return None
|
||||||
|
# 解析期号
|
||||||
|
ep_match = re.search(r'第(\d+)期', parts[0])
|
||||||
|
if not ep_match:
|
||||||
|
return None
|
||||||
|
ep = int(ep_match.group(1))
|
||||||
|
# 解析日期(YYYYMMDD -> YYYY-MM-DD)
|
||||||
|
raw_date = parts[1]
|
||||||
|
if len(raw_date) == 8 and raw_date.isdigit():
|
||||||
|
date = f"{raw_date[:4]}-{raw_date[4:6]}-{raw_date[6:8]}"
|
||||||
|
else:
|
||||||
|
date = raw_date
|
||||||
|
title = parts[2]
|
||||||
|
editor = parts[3]
|
||||||
|
return {"ep": ep, "date": date, "title": title, "editor": editor}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_docx_text(filepath: Path) -> str:
|
||||||
|
"""用 python-docx 提取 .docx 文件的全部文本,逐段拼接。"""
|
||||||
|
doc = Document(str(filepath))
|
||||||
|
paragraphs = []
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
text = para.text.strip()
|
||||||
|
if text:
|
||||||
|
paragraphs.append(text)
|
||||||
|
return "\n".join(paragraphs)
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_message(meta: dict, body: str) -> str:
|
||||||
|
"""构造 user message:元信息 + 文稿全文。"""
|
||||||
|
return (
|
||||||
|
f"期号:第{meta['ep']:02d}期\n"
|
||||||
|
f"播出日期:{meta['date']}\n"
|
||||||
|
f"节目名:{meta['title']}\n"
|
||||||
|
f"编导:{meta['editor']}\n"
|
||||||
|
f"\n以下是节目文稿全文:\n\n{body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_json_from_response(raw: str) -> dict:
|
||||||
|
"""从模型响应中提取 JSON,兼容推理模型的<think>...输出。"""
|
||||||
|
# 先去掉<think>...标签及其内容
|
||||||
|
text = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL)
|
||||||
|
text = text.strip()
|
||||||
|
# 去掉 markdown 代码块
|
||||||
|
text = re.sub(r'^```(?:json)?\s*', '', text)
|
||||||
|
text = re.sub(r'\s*```$', '', text)
|
||||||
|
text = text.strip()
|
||||||
|
# 从第一个 { 开始,到最后一个 } 结束
|
||||||
|
first_brace = text.find('{')
|
||||||
|
last_brace = text.rfind('}')
|
||||||
|
if first_brace != -1 and last_brace != -1 and last_brace >= first_brace:
|
||||||
|
json_str = text[first_brace:last_brace + 1]
|
||||||
|
return json.loads(json_str)
|
||||||
|
# 兜底:直接尝试解析
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
def call_mimo(system_prompt: str, user_prompt: str) -> dict:
|
||||||
|
"""调用 MiMo API 生成摘要卡,返回解析后的 JSON dict。"""
|
||||||
|
api_key = os.environ.get(MIMO_CONFIG["api_key_env"])
|
||||||
|
if not api_key:
|
||||||
|
raise EnvironmentError(
|
||||||
|
f"环境变量 {MIMO_CONFIG['api_key_env']} 未设置,请检查 ai-labeling/.env 或根目录 .env 文件"
|
||||||
|
)
|
||||||
|
client = OpenAI(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url=MIMO_CONFIG["base_url"],
|
||||||
|
)
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model=MIMO_CONFIG["model_name"],
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
temperature=0.0,
|
||||||
|
# 关闭 thinking(与 run_labeling.py 一致)
|
||||||
|
extra_body={"thinking": {"type": "disabled"}},
|
||||||
|
)
|
||||||
|
raw = response.choices[0].message.content
|
||||||
|
return extract_json_from_response(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_docx_files() -> list[dict]:
|
||||||
|
"""收集所有 .docx 文件并按期号排序。"""
|
||||||
|
files = []
|
||||||
|
for f in sorted(DELIVERABLES_DIR.iterdir()):
|
||||||
|
if not f.name.endswith(".docx"):
|
||||||
|
continue
|
||||||
|
meta = parse_filename(f.name)
|
||||||
|
if meta is None:
|
||||||
|
print(f"⚠️ 跳过无法解析的文件: {f.name}")
|
||||||
|
continue
|
||||||
|
meta["filepath"] = f
|
||||||
|
files.append(meta)
|
||||||
|
# 按期号排序
|
||||||
|
files.sort(key=lambda x: x["ep"])
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 确保输出目录存在
|
||||||
|
EXPERIMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 加载 system prompt
|
||||||
|
system_prompt = load_system_prompt()
|
||||||
|
print(f"✅ 已加载 system prompt: prompt4_content_digest.md")
|
||||||
|
|
||||||
|
# 收集 docx 文件
|
||||||
|
docx_files = collect_docx_files()
|
||||||
|
print(f"✅ 找到 {len(docx_files)} 个融合A稿 docx 文件\n")
|
||||||
|
|
||||||
|
all_digests = []
|
||||||
|
skipped = 0
|
||||||
|
success = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for i, meta in enumerate(docx_files):
|
||||||
|
ep = meta["ep"]
|
||||||
|
out_file = EXPERIMENTS_DIR / f"ep{ep:02d}_digest.json"
|
||||||
|
|
||||||
|
# 断点续跳:已存在则跳过
|
||||||
|
if out_file.exists():
|
||||||
|
print(f"[{i+1}/{len(docx_files)}] ep{ep:02d} 已存在,跳过")
|
||||||
|
try:
|
||||||
|
existing = json.loads(out_file.read_text(encoding="utf-8"))
|
||||||
|
all_digests.append(existing)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[{i+1}/{len(docx_files)}] 正在处理 ep{ep:02d} - {meta['title']} ...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 提取 docx 文本
|
||||||
|
body = extract_docx_text(meta["filepath"])
|
||||||
|
if not body.strip():
|
||||||
|
print(f" ⚠️ ep{ep:02d} 文稿内容为空,跳过")
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 构造 user message
|
||||||
|
user_msg = build_user_message(meta, body)
|
||||||
|
|
||||||
|
# 调用 MiMo
|
||||||
|
result = call_mimo(system_prompt, user_msg)
|
||||||
|
|
||||||
|
# 构造输出
|
||||||
|
output = {
|
||||||
|
"ep": ep,
|
||||||
|
"date": meta["date"],
|
||||||
|
"title": meta["title"],
|
||||||
|
"editor": meta["editor"],
|
||||||
|
"filename": meta["filepath"].name,
|
||||||
|
"digest": result,
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 写入单期文件
|
||||||
|
out_file.write_text(
|
||||||
|
json.dumps(output, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
all_digests.append(output)
|
||||||
|
print(f" ✅ ep{ep:02d} 摘要卡已生成 -> {out_file.name}")
|
||||||
|
success += 1
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f" ⚠️ ep{ep:02d} LLM 返回的不是合法 JSON: {e}")
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ ep{ep:02d} 处理失败: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# 限流保护:每期之间 sleep 1 秒
|
||||||
|
if i < len(docx_files) - 1:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 写入汇总文件
|
||||||
|
summary_file = EXPERIMENTS_DIR / "_all_digests.json"
|
||||||
|
summary_file.write_text(
|
||||||
|
json.dumps(all_digests, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"📊 处理完成:")
|
||||||
|
print(f" 成功: {success}")
|
||||||
|
print(f" 跳过(已存在): {skipped}")
|
||||||
|
print(f" 失败: {failed}")
|
||||||
|
print(f" 汇总文件: {summary_file}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,11 +6,19 @@
|
|||||||
GET /api/analytics/episodes?year=2026 → 指定年份所有期次的收视数据 + 年度目标
|
GET /api/analytics/episodes?year=2026 → 指定年份所有期次的收视数据 + 年度目标
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import extract
|
from sqlalchemy import extract
|
||||||
from sqlalchemy import distinct
|
from sqlalchemy import distinct
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.episode import Episode
|
from app.models.episode import Episode
|
||||||
@@ -19,6 +27,13 @@ from app.models.user import UserRole
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/analytics", tags=["收视分析"])
|
router = APIRouter(prefix="/api/analytics", tags=["收视分析"])
|
||||||
|
|
||||||
|
# 诊断报告缓存(内存,重启清空)
|
||||||
|
_report_cache = {}
|
||||||
|
|
||||||
|
# prompt5 文件路径
|
||||||
|
_PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
|
||||||
|
_PROMPT5_PATH = _PROJECT_ROOT / "ai-labeling" / "prompts" / "prompt5_diagnosis_report.md"
|
||||||
|
|
||||||
|
|
||||||
def _require_read():
|
def _require_read():
|
||||||
"""三角色都可读"""
|
"""三角色都可读"""
|
||||||
@@ -111,4 +126,206 @@ def get_analytics_episodes(
|
|||||||
"year": year,
|
"year": year,
|
||||||
"yearly_target": yearly_target,
|
"yearly_target": yearly_target,
|
||||||
"episodes": ep_list,
|
"episodes": ep_list,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI 诊断报告 ──
|
||||||
|
|
||||||
|
class DiagnosisRequest(BaseModel):
|
||||||
|
year: int
|
||||||
|
ep_start: int
|
||||||
|
ep_end: int
|
||||||
|
force: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_message(episodes, base_target, stretch_target, avg_share, pass_count, max_ep, min_ep):
|
||||||
|
"""组装给 DeepSeek 的 user message,格式对齐 prompt5 的输入规范。"""
|
||||||
|
first_ep = episodes[0]
|
||||||
|
last_ep = episodes[-1]
|
||||||
|
count = len(episodes)
|
||||||
|
|
||||||
|
# 判色函数
|
||||||
|
def judge(share):
|
||||||
|
if share >= stretch_target:
|
||||||
|
return "优秀"
|
||||||
|
elif share >= base_target:
|
||||||
|
return "达标"
|
||||||
|
else:
|
||||||
|
return "待提升"
|
||||||
|
|
||||||
|
# 摸高完成率
|
||||||
|
stretch_pct = round(avg_share / stretch_target * 100, 1) if stretch_target > 0 else 0
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("请根据以下数据,撰写收视诊断分析报告。\n")
|
||||||
|
|
||||||
|
# 分析范围
|
||||||
|
lines.append("## 分析范围\n")
|
||||||
|
lines.append(
|
||||||
|
f"第{first_ep.episode_number}期《{first_ep.program_name}》至 "
|
||||||
|
f"第{last_ep.episode_number}期《{last_ep.program_name}》(共{count}期),"
|
||||||
|
f"{first_ep.air_date}至{last_ep.air_date}播出"
|
||||||
|
)
|
||||||
|
lines.append(f"年度目标:基础目标 {base_target},摸高目标 {stretch_target}\n")
|
||||||
|
|
||||||
|
# 整体统计
|
||||||
|
lines.append("## 整体统计\n")
|
||||||
|
lines.append(f"- 平均份额:{avg_share}(摸高完成率 {stretch_pct}%)")
|
||||||
|
lines.append(f"- 达标期数:{pass_count}/{count}")
|
||||||
|
lines.append(f"- 最高份额:{float(max_ep.audience_share)}(第{max_ep.episode_number}期《{max_ep.program_name}》)")
|
||||||
|
lines.append(f"- 最低份额:{float(min_ep.audience_share)}(第{min_ep.episode_number}期《{min_ep.program_name}》)\n")
|
||||||
|
|
||||||
|
# 逐期数据表格
|
||||||
|
lines.append("## 逐期数据\n")
|
||||||
|
lines.append("| 播出期号 | 节目名 | 份额 | 判定 | 题材类型 | 叙事结构 | 钩子强度 | 装备领域 |")
|
||||||
|
lines.append("|---------|-------|------|------|---------|---------|---------|---------|")
|
||||||
|
for ep in episodes:
|
||||||
|
share = float(ep.audience_share)
|
||||||
|
domain_str = "、".join(ep.equipment_domain) if ep.equipment_domain else "-"
|
||||||
|
lines.append(
|
||||||
|
f"| 第{ep.episode_number}期 | {ep.program_name} | {share} | {judge(share)} "
|
||||||
|
f"| {ep.program_format or '-'} | {ep.narrative_structure or '-'} "
|
||||||
|
f"| {ep.opening_hook or '-'} | {domain_str} |"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# 各期内容摘要卡
|
||||||
|
lines.append("## 各期内容摘要卡\n")
|
||||||
|
for ep in episodes:
|
||||||
|
share = float(ep.audience_share)
|
||||||
|
lines.append(f"### 第{ep.episode_number}期《{ep.program_name}》(份额 {share})")
|
||||||
|
digest = ep.content_digest
|
||||||
|
if digest:
|
||||||
|
lines.append(f"- 核心切口:{digest.get('核心切口', '-')}")
|
||||||
|
# 叙事亮点可能是数组
|
||||||
|
highlights = digest.get('叙事亮点', [])
|
||||||
|
if isinstance(highlights, list):
|
||||||
|
lines.append(f"- 叙事亮点:{';'.join(highlights)}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- 叙事亮点:{highlights}")
|
||||||
|
lines.append(f"- 观众门槛:{digest.get('观众门槛', '-')}")
|
||||||
|
# 话题性是嵌套结构
|
||||||
|
topic = digest.get('话题性', {})
|
||||||
|
if isinstance(topic, dict):
|
||||||
|
lines.append(
|
||||||
|
f"- 话题性:{topic.get('总评', '-')} — "
|
||||||
|
f"大众认知度:{topic.get('大众认知度', '-')};"
|
||||||
|
f"降维切口:{topic.get('降维切口', '-')};"
|
||||||
|
f"惊奇密度:{topic.get('惊奇密度', '-')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f"- 话题性:{topic}")
|
||||||
|
# 潜在弱点可能是数组
|
||||||
|
weaknesses = digest.get('潜在弱点', [])
|
||||||
|
if isinstance(weaknesses, list):
|
||||||
|
lines.append(f"- 潜在弱点:{';'.join(weaknesses)}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- 潜在弱点:{weaknesses}")
|
||||||
|
lines.append(f"- 时效关联:{digest.get('时效关联', '-')}")
|
||||||
|
else:
|
||||||
|
lines.append("- (无文稿摘要)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/diagnosis-report")
|
||||||
|
def generate_diagnosis_report(
|
||||||
|
req: DiagnosisRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
current_user=Depends(_require_read()),
|
||||||
|
):
|
||||||
|
"""生成 AI 诊断报告。同一范围缓存结果,force=True 时重新生成。"""
|
||||||
|
cache_key = f"{req.year}_{req.ep_start}_{req.ep_end}"
|
||||||
|
|
||||||
|
# 检查缓存
|
||||||
|
if not req.force and cache_key in _report_cache:
|
||||||
|
return _report_cache[cache_key]
|
||||||
|
|
||||||
|
# 1. 查询所选范围的 episodes
|
||||||
|
ep_stmt = (
|
||||||
|
select(Episode)
|
||||||
|
.where(extract("year", Episode.air_date) == req.year)
|
||||||
|
.where(Episode.episode_number >= req.ep_start)
|
||||||
|
.where(Episode.episode_number <= req.ep_end)
|
||||||
|
.where(Episode.audience_share.is_not(None))
|
||||||
|
.order_by(Episode.episode_number.asc())
|
||||||
|
)
|
||||||
|
episodes = session.exec(ep_stmt).all()
|
||||||
|
|
||||||
|
if not episodes:
|
||||||
|
return {"error": "所选范围内没有收视数据"}
|
||||||
|
|
||||||
|
# 2. 查年度目标
|
||||||
|
target_stmt = select(YearlyTarget).where(YearlyTarget.year == req.year)
|
||||||
|
target = session.exec(target_stmt).first()
|
||||||
|
if not target:
|
||||||
|
return {"error": f"{req.year}年没有设置年度目标"}
|
||||||
|
|
||||||
|
base_target = float(target.base_target)
|
||||||
|
stretch_target = float(target.stretch_target)
|
||||||
|
|
||||||
|
# 3. 计算统计数据
|
||||||
|
shares = [float(ep.audience_share) for ep in episodes]
|
||||||
|
avg_share = round(sum(shares) / len(shares), 4) if shares else 0
|
||||||
|
pass_count = sum(1 for s in shares if s >= base_target)
|
||||||
|
max_ep = max(episodes, key=lambda e: float(e.audience_share))
|
||||||
|
min_ep = min(episodes, key=lambda e: float(e.audience_share))
|
||||||
|
|
||||||
|
# 三档判定
|
||||||
|
if avg_share >= stretch_target:
|
||||||
|
tier = "excellent"
|
||||||
|
elif avg_share >= base_target:
|
||||||
|
tier = "on_target"
|
||||||
|
else:
|
||||||
|
tier = "danger"
|
||||||
|
|
||||||
|
# 4. 组装 user message
|
||||||
|
user_message = _build_user_message(episodes, base_target, stretch_target, avg_share, pass_count, max_ep, min_ep)
|
||||||
|
|
||||||
|
# 5. 读 system prompt
|
||||||
|
system_prompt = _PROMPT5_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# 6. 调 DeepSeek
|
||||||
|
if not settings.DEEPSEEK_API_KEY:
|
||||||
|
return {"error": "DEEPSEEK_API_KEY 未配置"}
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
api_key=settings.DEEPSEEK_API_KEY,
|
||||||
|
base_url="https://api.deepseek.com",
|
||||||
|
)
|
||||||
|
response = client.chat.completions.create(
|
||||||
|
model="deepseek-v4-pro",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_message},
|
||||||
|
],
|
||||||
|
temperature=0.3,
|
||||||
|
)
|
||||||
|
report_markdown = response.choices[0].message.content
|
||||||
|
|
||||||
|
# 7. 组装返回
|
||||||
|
result = {
|
||||||
|
"tier": tier,
|
||||||
|
"avg_share": avg_share,
|
||||||
|
"episode_count": len(episodes),
|
||||||
|
"pass_count": pass_count,
|
||||||
|
"highest": {
|
||||||
|
"ep": max_ep.episode_number,
|
||||||
|
"name": max_ep.program_name,
|
||||||
|
"share": float(max_ep.audience_share),
|
||||||
|
},
|
||||||
|
"lowest": {
|
||||||
|
"ep": min_ep.episode_number,
|
||||||
|
"name": min_ep.program_name,
|
||||||
|
"share": float(min_ep.audience_share),
|
||||||
|
},
|
||||||
|
"report_markdown": report_markdown,
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"disclaimer": "本报告基于已入库的收视数据、节目标签及内容摘要生成,未纳入同时段竞品、社会热点等外部因素。分析结论难免挂一漏万,仅供栏目内部讨论参考,不构成节目决策依据。",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 8. 缓存
|
||||||
|
_report_cache[cache_key] = result
|
||||||
|
return result
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ _SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", "86400"))
|
|||||||
_MINIMAX_EMBED_API_KEY = os.environ.get("MINIMAX_EMBED_API_KEY", "")
|
_MINIMAX_EMBED_API_KEY = os.environ.get("MINIMAX_EMBED_API_KEY", "")
|
||||||
_MINIMAX_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
|
_MINIMAX_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
|
||||||
|
|
||||||
|
# DeepSeek API 凭证(诊断报告用)
|
||||||
|
_DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
|
||||||
|
|
||||||
# 验证必需配置
|
# 验证必需配置
|
||||||
if not _DATABASE_URL:
|
if not _DATABASE_URL:
|
||||||
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
|
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
|
||||||
@@ -31,6 +34,7 @@ class Settings:
|
|||||||
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
|
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
|
||||||
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
|
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
|
||||||
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
|
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
|
||||||
|
DEEPSEEK_API_KEY: str = _DEEPSEEK_API_KEY
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class Episode(SQLModel, table=True):
|
|||||||
opening_hook: str | None = Field(default=None, max_length=10) # 开篇钩子(单选)
|
opening_hook: str | None = Field(default=None, max_length=10) # 开篇钩子(单选)
|
||||||
ai_label_confidence: str | None = Field(default=None, max_length=10) # draft/reviewed/inferred
|
ai_label_confidence: str | None = Field(default=None, max_length=10) # draft/reviewed/inferred
|
||||||
|
|
||||||
|
# ── 内容摘要卡(AI 生成,供诊断报告输入)──
|
||||||
|
content_digest: Any | None = Field(default=None, sa_column=Column(JSONB))
|
||||||
|
|
||||||
created_at: datetime | None = Field(
|
created_at: datetime | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ python-dotenv==1.0.1
|
|||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
python-frontmatter==1.1.0
|
python-frontmatter==1.1.0
|
||||||
pandas>=2.0.0
|
pandas>=2.0.0
|
||||||
openpyxl>=3.1.0
|
openpyxl>=3.1.0
|
||||||
|
openai>=1.0.0,<1.55.0
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- 004_add_content_digest.sql
|
||||||
|
-- 给 episodes 表添加内容摘要卡字段(AI 生成的节目内容结构化摘要,供诊断报告使用)
|
||||||
|
-- 前置:执行前先 pg_dump 备份
|
||||||
|
|
||||||
|
ALTER TABLE episodes
|
||||||
|
ADD COLUMN IF NOT EXISTS content_digest JSONB;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN episodes.content_digest
|
||||||
|
IS '节目内容摘要卡(AI生成JSONB:核心切口/叙事亮点/观众门槛/话题性/潜在弱点/时效关联)';
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
output*/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
pr/
|
||||||
+160
@@ -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.py(Flask 蓝图,6 个 API 端点:upload/status/review/save/generate/download);② deploy/cca.html(4 步单页流程:上传→处理→审稿→下载,暗色主题匹配配音系统);③ 审稿台功能:左栏 ASR 原文 vs 右栏 AI 校对稿对比、逐句编辑确认、仅看修改过滤、全部确认、**查找替换**(Ctrl+H,支持逐个/全部替换+高亮定位);④ WAV 大文件自动 ffmpeg 转 MP3(解决讯飞上传超时);⑤ 服务器架构:Nginx→静态 HTML + Flask:5000,CCA 源码在 `/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.py(AI校对)、ai_line_breaker.py(AI折行)。
|
||||||
|
- [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.html,2026-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 选型已定**:校对+折行+热词提取统一用 DeepSeek(deepseek-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.217,Nginx: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 选型~~ → DeepSeek(deepseek-chat),已验证效果好、价格低
|
||||||
|
- [x] ~~的地得纠错~~ → 已加入校对 Prompt(v5)
|
||||||
|
- [x] ~~前端审稿台技术选型~~ → 纯 HTML/JS 单页,无框架(2026-07-05)
|
||||||
|
- [ ] 大洋系统 SRT 导入兼容性(待验证)
|
||||||
|
- [ ] 跨句固定搭配拆词("第二次/世界大战"类问题,需大短语词典,优先级低)
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1252
File diff suppressed because it is too large
Load Diff
+1112
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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">🎤</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">📄</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="上一个">▲</button>
|
||||||
|
<button class="replace-btn" onclick="findNext()" title="下一个">▼</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">✅</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 ? '✓' : ''}
|
||||||
|
</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, '"').replace(/</g, '<'); }
|
||||||
|
|
||||||
|
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 ? '✓' : '';
|
||||||
|
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 ? '✓' : ''}
|
||||||
|
</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>
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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 里建议调整,调整动作只能在主项目发生
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
requests>=2.28
|
||||||
|
python-dotenv>=1.0
|
||||||
|
mutagen>=1.47
|
||||||
|
openai>=1.0
|
||||||
|
python-docx>=1.0
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)} 条)")
|
||||||
@@ -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
|
||||||
@@ -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("=== 全部测试完成 ===")
|
||||||
+4
-3
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
|
## 🔖 状态栏 (STATUS — 每次结束 session 前必须更新这三行)
|
||||||
|
|
||||||
- **最后更新**:Claude Code | 2026-06-26
|
- **最后更新**:Claude Code | 2026-07-03
|
||||||
- **当前状态一句话**:**doco 子项目开发收尾。** 22 期融合A稿全部产出(含说话人区分重跑),已归拢至 `doco/deliverables/`。第01期(武器装备里的形状规律)缺A稿暂跳过。下一步:带 22 期成品回 TPS 主项目知识库批量导入。
|
- **当前状态一句话**:**doco 子项目阶段性收工。** 22 期融合A稿全部产出,成品在 `doco/deliverables/`。主项目寄存条交接核查已完成(2026-07-03):寄存条状态表已更新、api_credentials_inventory 已更新(MiMo/讯飞)。下一步由主项目侧执行批量导入知识库。
|
||||||
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
|
- **下一个动手的人从这里开始**:doco 开发完成,无待办。主项目侧批量导入走 Phase 3 上传/embedding 链路。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
|
|
||||||
## 4. 已完成(只追加,最新在最上)
|
## 4. 已完成(只追加,最新在最上)
|
||||||
|
|
||||||
|
- [2026-07-03 |Claude Code] **主项目寄存条交接核查完成。** 更新内容:① 主项目 `note/寄存条Doco文稿整理子项目已外迁.md` 底部状态表从"等子项目出 PRD v3 启动 P1"更新为"开发收尾(2026-07-03)";② `docs/api_credentials_inventory.md` doco 节更新——讯飞改已激活、DeepSeek Vision 改为 Ollama 本地 DeepSeek-OCR、新增小米 MiMo 2.5 Pro(替代原 Anthropic Claude API 条目);③ doco CLAUDE.md 状态栏更新。交付物核查:22 份 docx + 20 份 md 在 `deliverables/`、术语词典 `term_dict.json` 10600 行、测试 `test_video_split.py` 存在。
|
||||||
- [2026-06-26 |Claude Code] **doco 子项目收尾:ep021/022 补稿 + 说话人重跑 + 成品归拢。** 本 session 完成:① **ep021(海战颠覆者)、ep022(硬核脑洞)两期新补 A 稿全流程 P1→C4 跑通**——ep021 骨架 LLM 自动生成失败(`extract_a_paragraphs()` 过滤空段后重编号,LLM 用了 raw docx 下标),手写骨架 JSON(32 段=26 normal+6 ignore 粗体章节标题)通过校验;ep022 骨架 LLM 自动通过(38 段全 normal,inline header);ep021 C3 review 13 条(均 OCR 修正,专名零替换)、C4 punct_ok 25/26;ep022 C3 review 5 条、C4 punct_ok 全过;② **cli.py 路径 bug 修复**:`episode_dir = Path("programs")/episode_id` 相对路径在子进程中二次解析导致路径翻倍(`programs/ep021/programs/ep021/...`),加 `.resolve()` 修复——此 bug 从未触发因 ep004 用 `--skip-p1` 且 `_batch_run.py` 用绝对路径;③ **ep007、ep010 C4 说话人区分重跑**(06-24 批量重跑时这两期被遗漏,融合A稿时间戳仍为 06-23),重跑完成、产物已更新;④ **排播对照表** `doco/data/2026episode_list.xlsx`(25 期播出日期/期次/节目名/编导/收视)导入,建立 ep→播出顺序完整映射;⑤ **成品归拢** `doco/deliverables/` 文件夹:22 份融合A稿按播出顺序统一命名(`第XX期_YYYYMMDD_节目名_编导_融合A稿.docx`),缺第01期(武器装备里的形状规律,无A稿);⑥ 通哥人工核验 ep021/022 质量高,仅两处小毛刺(ep021 一段话归错段落、ep022 一个标签应为三维动画),确认技术路径有效。
|
- [2026-06-26 |Claude Code] **doco 子项目收尾:ep021/022 补稿 + 说话人重跑 + 成品归拢。** 本 session 完成:① **ep021(海战颠覆者)、ep022(硬核脑洞)两期新补 A 稿全流程 P1→C4 跑通**——ep021 骨架 LLM 自动生成失败(`extract_a_paragraphs()` 过滤空段后重编号,LLM 用了 raw docx 下标),手写骨架 JSON(32 段=26 normal+6 ignore 粗体章节标题)通过校验;ep022 骨架 LLM 自动通过(38 段全 normal,inline header);ep021 C3 review 13 条(均 OCR 修正,专名零替换)、C4 punct_ok 25/26;ep022 C3 review 5 条、C4 punct_ok 全过;② **cli.py 路径 bug 修复**:`episode_dir = Path("programs")/episode_id` 相对路径在子进程中二次解析导致路径翻倍(`programs/ep021/programs/ep021/...`),加 `.resolve()` 修复——此 bug 从未触发因 ep004 用 `--skip-p1` 且 `_batch_run.py` 用绝对路径;③ **ep007、ep010 C4 说话人区分重跑**(06-24 批量重跑时这两期被遗漏,融合A稿时间戳仍为 06-23),重跑完成、产物已更新;④ **排播对照表** `doco/data/2026episode_list.xlsx`(25 期播出日期/期次/节目名/编导/收视)导入,建立 ep→播出顺序完整映射;⑤ **成品归拢** `doco/deliverables/` 文件夹:22 份融合A稿按播出顺序统一命名(`第XX期_YYYYMMDD_节目名_编导_融合A稿.docx`),缺第01期(武器装备里的形状规律,无A稿);⑥ 通哥人工核验 ep021/022 质量高,仅两处小毛刺(ep021 一段话归错段落、ep022 一个标签应为三维动画),确认技术路径有效。
|
||||||
- [2026-06-23 |Claude Code] **批量化基础设施完工 + ep004 全流程通过 + 16 期批量跑启动**。本 session 完成:① ep004(枪王对决,最难·小剧场密·58段)全流程 P1→C4 一次通过,C4 punct_ok 58/58 全过、confidence 全 ≥0.80(batch_size=25 效果显著,无批次回退);② 新增 `doco run` 一键全流程命令(Cline 实现,Opus 审核),参数 `--episode-id / --a-script / --input-video / --batch-size / --skip-p1`,ep004 `--skip-p1` 验证通过;③ C3 SYSTEM_PROMPT 收紧专名铁律(防 ASR 同音字替换权威 B稿专名);④ Ollama 并发提至 16 路(`OLLAMA_NUM_PARALLEL=16`,8 路实测 GPU 96%、显存充裕);⑤ 16 期新目录建立 + 文件拷贝 + 骨架批量生成并集中核验(真名零泄露、隔断/ignore 正确);⑥ `_batch_run.py` 批量脚本启动跑 ep005-ep020。**doco PRD 完成标准已与通哥确定**:20 期全出稿 → 带成品回 TPS 主项目知识库批量导入。
|
- [2026-06-23 |Claude Code] **批量化基础设施完工 + ep004 全流程通过 + 16 期批量跑启动**。本 session 完成:① ep004(枪王对决,最难·小剧场密·58段)全流程 P1→C4 一次通过,C4 punct_ok 58/58 全过、confidence 全 ≥0.80(batch_size=25 效果显著,无批次回退);② 新增 `doco run` 一键全流程命令(Cline 实现,Opus 审核),参数 `--episode-id / --a-script / --input-video / --batch-size / --skip-p1`,ep004 `--skip-p1` 验证通过;③ C3 SYSTEM_PROMPT 收紧专名铁律(防 ASR 同音字替换权威 B稿专名);④ Ollama 并发提至 16 路(`OLLAMA_NUM_PARALLEL=16`,8 路实测 GPU 96%、显存充裕);⑤ 16 期新目录建立 + 文件拷贝 + 骨架批量生成并集中核验(真名零泄露、隔断/ignore 正确);⑥ `_batch_run.py` 批量脚本启动跑 ep005-ep020。**doco PRD 完成标准已与通哥确定**:20 期全出稿 → 带成品回 TPS 主项目知识库批量导入。
|
||||||
- [2026-06-22 晚|Claude Code] **ep002 C4 审核完毕,全流程收工**。Cline 跑完 C4 compose 出稿 `20260127潜艇的仿生之路_穆佩弦_融合A稿.docx`。Opus 审核核验:硬校验(汉字零改)全过、733 行全覆盖、标点回退 0 段。**但发现两个问题**:① Cline 自报空段名称错了两个(报"解说3/解说6",实际是"三维动画解说3/解说8")、隔断数也报错(报 3 个,实际 4 个)——再次印证不信 Cline 自检;② **9/19 批次(47%)LLM 对齐失败走回退**(全 confidence=0.30),本次因失败批次恰在大段中间未翻车,但属黄色预警。**分段偏差根因**:A 稿是拍摄前剧本,专家采访段与实际播出内容差异巨大(A 稿一句话提纲 vs 专家自由发挥两分钟),LLM 无法正确匹配——这是信息不对应,非算法问题。通哥手动批改分段(~10 处高亮标签),内容本身正确。**结论(通哥拍板)**:治本靠编导给贴近播出版的稿子,现阶段接受"程序保证文字零改 + 编导手调分段"的模式。
|
- [2026-06-22 晚|Claude Code] **ep002 C4 审核完毕,全流程收工**。Cline 跑完 C4 compose 出稿 `20260127潜艇的仿生之路_穆佩弦_融合A稿.docx`。Opus 审核核验:硬校验(汉字零改)全过、733 行全覆盖、标点回退 0 段。**但发现两个问题**:① Cline 自报空段名称错了两个(报"解说3/解说6",实际是"三维动画解说3/解说8")、隔断数也报错(报 3 个,实际 4 个)——再次印证不信 Cline 自检;② **9/19 批次(47%)LLM 对齐失败走回退**(全 confidence=0.30),本次因失败批次恰在大段中间未翻车,但属黄色预警。**分段偏差根因**:A 稿是拍摄前剧本,专家采访段与实际播出内容差异巨大(A 稿一句话提纲 vs 专家自由发挥两分钟),LLM 无法正确匹配——这是信息不对应,非算法问题。通哥手动批改分段(~10 处高亮标签),内容本身正确。**结论(通哥拍板)**:治本靠编导给贴近播出版的稿子,现阶段接受"程序保证文字零改 + 编导手调分段"的模式。
|
||||||
|
|||||||
@@ -25,9 +25,9 @@
|
|||||||
|
|
||||||
| 子模块 | API 服务 | Key 类型 | 开通日 | 激活状态 | 到期日 | 责任人 | 备注 |
|
| 子模块 | API 服务 | Key 类型 | 开通日 | 激活状态 | 到期日 | 责任人 | 备注 |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| doco | 讯飞开放平台 - 录音文件转写(标准版) | APP_ID + SECRET_KEY | 2026-06-12 | 待激活(需走 0 元购买) | 2027-06-12 | 制片人 | demo 凭证已过期,需新申请 |
|
| doco | 讯飞开放平台 - 录音文件转写(标准版) | APP_ID + SECRET_KEY | 2026-06-12 | 已激活 | 2027-06-12 | 制片人 | C2 ASR 转写,22 期已跑完 |
|
||||||
| doco | DeepSeek Vision | API_KEY | 2026-06-12 | 已激活 | — | 制片人 | doco OCR 用 |
|
| doco | Ollama 本地 DeepSeek-OCR | 本地部署 | 2026-06-12 | 已激活 | — | 制片人 | P2 本地 OCR,16 路并发,4090D 24GB |
|
||||||
| doco | Anthropic Claude API | API_KEY | 2026-06-12 | 已激活 | — | 制片人 | AI 融合层(P3) |
|
| doco | 小米 MiMo 2.5 Pro | API_KEY | 2026-06-22 | 已激活 | — | 制片人 | AI 融合层(C1/C3/C4),OpenAI 兼容端,替代原 DeepSeek v4-pro |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,4 +41,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*最后更新: 2026-06-12*
|
*最后更新: 2026-07-03*
|
||||||
|
|||||||
@@ -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 到最新。请帮我启动开发环境并确认一切正常。
|
||||||
|
```
|
||||||
@@ -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 审 + 制片人批准 Act,005 迁移前必须 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-labeling(Prompt 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=manuscript,author=该期编导快照)→ 调 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 现设计,不改)。
|
||||||
@@ -11,6 +11,7 @@ import Doco from './pages/Doco/Doco'
|
|||||||
import UserManage from './pages/UserManage/UserManage'
|
import UserManage from './pages/UserManage/UserManage'
|
||||||
import EditorDesk from './pages/EditorDesk/EditorDesk'
|
import EditorDesk from './pages/EditorDesk/EditorDesk'
|
||||||
import Analytics from './pages/Analytics/Analytics'
|
import Analytics from './pages/Analytics/Analytics'
|
||||||
|
import DiagnosisReport from './pages/Analytics/DiagnosisReport'
|
||||||
import AuthGuard from './components/AuthGuard/AuthGuard'
|
import AuthGuard from './components/AuthGuard/AuthGuard'
|
||||||
import RoleGuard from './components/AuthGuard/RoleGuard'
|
import RoleGuard from './components/AuthGuard/RoleGuard'
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ function App() {
|
|||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
<Route path="analytics" element={<Analytics />} />
|
<Route path="analytics" element={<Analytics />} />
|
||||||
|
<Route path="analytics/report" element={<DiagnosisReport />} />
|
||||||
<Route path="tps" element={<TPS />} />
|
<Route path="tps" element={<TPS />} />
|
||||||
<Route path="knowledge" element={<KnowledgeBase />} />
|
<Route path="knowledge" element={<KnowledgeBase />} />
|
||||||
<Route path="doco" element={<Doco />} />
|
<Route path="doco" element={<Doco />} />
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { generateDiagnosisReport } from '../../services/analyticsService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 诊断报告摘要块 — 收视分析页内嵌
|
||||||
|
* - 根据 avgShare 判定三档 tier
|
||||||
|
* - 调用 DeepSeek 生成报告
|
||||||
|
* - 从 markdown 提取核心发现和行动建议
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 从 markdown 中提取指定章节内容(正则模糊匹配) */
|
||||||
|
function extractSection(markdown, keyword, endKeywords) {
|
||||||
|
if (!markdown) return ''
|
||||||
|
// 用正则匹配:任意数量的 # 开头,后面包含关键词的行
|
||||||
|
const startRegex = new RegExp(`^#{1,6}\\s*.*${keyword}.*$`, 'm')
|
||||||
|
const match = markdown.match(startRegex)
|
||||||
|
if (!match) return ''
|
||||||
|
const afterStart = match.index + match[0].length
|
||||||
|
// 找最近的结束标记(下一个同级或更高级标题)
|
||||||
|
let endIdx = markdown.length
|
||||||
|
if (endKeywords && endKeywords.length > 0) {
|
||||||
|
for (const kw of endKeywords) {
|
||||||
|
const endRegex = new RegExp(`^#{1,6}\\s*.*${kw}.*$`, 'm')
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 markdown 文本按行拆分,保留 **bold** 为 <strong> */
|
||||||
|
function parseMarkdownLines(text) {
|
||||||
|
if (!text) return []
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^#+\s*/, '').trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将行内 **text** 转为 React 元素 */
|
||||||
|
function renderInlineMarkdown(text) {
|
||||||
|
const parts = text.split(/(\*\*[^*]+\*\*)/)
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
const boldMatch = part.match(/^\*\*(.+)\*\*$/)
|
||||||
|
if (boldMatch) {
|
||||||
|
return <strong key={i}>{boldMatch[1]}</strong>
|
||||||
|
}
|
||||||
|
return part
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEFT_STYLE = {
|
||||||
|
color: '#c0584f',
|
||||||
|
bg: 'rgba(192,88,79,0.06)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_CONFIG = {
|
||||||
|
danger: {
|
||||||
|
leftTitle: '问题聚焦',
|
||||||
|
rightTitle: '病因与预警',
|
||||||
|
rightColor: '#7aa874',
|
||||||
|
rightBg: 'rgba(122,168,116,0.06)',
|
||||||
|
},
|
||||||
|
on_target: {
|
||||||
|
leftTitle: '核心发现',
|
||||||
|
rightTitle: '病因与提振建议',
|
||||||
|
rightColor: '#5b8db8',
|
||||||
|
rightBg: 'rgba(91,141,184,0.06)',
|
||||||
|
},
|
||||||
|
excellent: {
|
||||||
|
leftTitle: '高光复盘',
|
||||||
|
rightTitle: '经验总结',
|
||||||
|
rightColor: '#c0584f',
|
||||||
|
rightBg: 'rgba(192,88,79,0.06)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnosisSummary({ episodes, yearlyTarget, selectedYear }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [report, setReport] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
// 计算平均份额和 tier
|
||||||
|
const { avgShare, tier, epStart, epEnd } = useMemo(() => {
|
||||||
|
if (!episodes || episodes.length === 0 || !yearlyTarget) {
|
||||||
|
return { avgShare: 0, tier: null, epStart: 0, epEnd: 0 }
|
||||||
|
}
|
||||||
|
const withShare = episodes.filter((ep) => ep.audience_share != null)
|
||||||
|
if (withShare.length === 0) {
|
||||||
|
return { avgShare: 0, tier: null, epStart: 0, epEnd: 0 }
|
||||||
|
}
|
||||||
|
const sum = withShare.reduce((acc, ep) => acc + Number(ep.audience_share), 0)
|
||||||
|
const avg = sum / withShare.length
|
||||||
|
const base = Number(yearlyTarget.base_target)
|
||||||
|
const stretch = Number(yearlyTarget.stretch_target)
|
||||||
|
|
||||||
|
let t = 'danger'
|
||||||
|
if (avg > stretch) t = 'excellent'
|
||||||
|
else if (avg >= base) t = 'on_target'
|
||||||
|
|
||||||
|
const nums = withShare.map((ep) => ep.episode_number)
|
||||||
|
return {
|
||||||
|
avgShare: avg,
|
||||||
|
tier: t,
|
||||||
|
epStart: Math.min(...nums),
|
||||||
|
epEnd: Math.max(...nums),
|
||||||
|
}
|
||||||
|
}, [episodes, yearlyTarget])
|
||||||
|
|
||||||
|
const config = tier ? TIER_CONFIG[tier] : null
|
||||||
|
|
||||||
|
// 调用 API
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tier || !episodes || episodes.length === 0) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setReport(null)
|
||||||
|
|
||||||
|
generateDiagnosisReport({
|
||||||
|
year: selectedYear,
|
||||||
|
ep_start: epStart,
|
||||||
|
ep_end: epEnd,
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error)
|
||||||
|
} else {
|
||||||
|
setReport(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setError(err?.response?.data?.detail || '报告生成失败')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [selectedYear, epStart, epEnd, tier, episodes])
|
||||||
|
|
||||||
|
// 空态
|
||||||
|
if (!episodes || episodes.length === 0 || !tier) {
|
||||||
|
return (
|
||||||
|
<div className="analytics-chart-card" style={{ textAlign: 'center', padding: '40px 24px' }}>
|
||||||
|
<h2 className="analytics-chart-title" style={{ textAlign: 'left' }}>AI 诊断报告</h2>
|
||||||
|
<p style={{ color: '#aaa', fontSize: 14 }}>请先选择包含收视数据的期次范围</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取核心发现和行动建议
|
||||||
|
const markdown = report?.report_markdown || ''
|
||||||
|
// 核心发现:关键词匹配"核心发现",到"深度分析"或"三、"
|
||||||
|
let coreFindings = extractSection(markdown, '核心发现', ['深度分析', '三、'])
|
||||||
|
// 行动建议:关键词匹配"行动建议",到末尾
|
||||||
|
let actions = extractSection(markdown, '行动建议', [])
|
||||||
|
|
||||||
|
const coreLines = parseMarkdownLines(coreFindings)
|
||||||
|
const actionLines = parseMarkdownLines(actions)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="analytics-chart-card">
|
||||||
|
{/* 标题行 */}
|
||||||
|
<div className="diagnosis-summary-header">
|
||||||
|
<h2 className="analytics-chart-title" style={{ marginBottom: 0 }}>AI 诊断报告</h2>
|
||||||
|
{report && !loading && (
|
||||||
|
<span
|
||||||
|
className="diagnosis-summary-link"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/analytics/report?year=${selectedYear}&start=${epStart}&end=${epEnd}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
查看完整报告 →
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="diagnosis-loading">
|
||||||
|
<Spin size="small" />
|
||||||
|
<span style={{ marginLeft: 8 }}>正在生成 AI 诊断报告…</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="diagnosis-loading" style={{ color: '#c0584f' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : report ? (
|
||||||
|
<div className="diagnosis-summary-content">
|
||||||
|
{/* 左块(固定粉红色系) */}
|
||||||
|
<div
|
||||||
|
className="diagnosis-summary-left"
|
||||||
|
style={{ borderColor: LEFT_STYLE.color, background: LEFT_STYLE.bg }}
|
||||||
|
>
|
||||||
|
<div className="diagnosis-block-title" style={{ color: LEFT_STYLE.color }}>{config.leftTitle}</div>
|
||||||
|
{coreLines.length > 0 ? (
|
||||||
|
coreLines.map((line, i) => (
|
||||||
|
<p key={i} style={{ margin: '0 0 8px 0' }}>{renderInlineMarkdown(line)}</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>报告已生成,请查看完整报告</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右块(跟随 tier 变色) */}
|
||||||
|
<div
|
||||||
|
className="diagnosis-summary-right"
|
||||||
|
style={{ borderColor: config.rightColor, background: config.rightBg }}
|
||||||
|
>
|
||||||
|
<div className="diagnosis-block-title" style={{ color: config.rightColor }}>{config.rightTitle}</div>
|
||||||
|
{actionLines.length > 0 ? (
|
||||||
|
actionLines.map((line, i) => (
|
||||||
|
<p key={i} style={{ margin: '0 0 8px 0' }}>{renderInlineMarkdown(line)}</p>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>报告已生成,请查看完整报告</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiagnosisSummary
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,6 +261,81 @@
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── AI 诊断报告摘要块 ── */
|
||||||
|
.diagnosis-summary-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left,
|
||||||
|
.diagnosis-summary-right {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #444;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left::-webkit-scrollbar,
|
||||||
|
.diagnosis-summary-right::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left::-webkit-scrollbar-thumb,
|
||||||
|
.diagnosis-summary-right::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0,0,0,0.12);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left::-webkit-scrollbar-track,
|
||||||
|
.diagnosis-summary-right::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-left {
|
||||||
|
border-left: 3px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-right {
|
||||||
|
border-left: 3px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-block-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b8db8;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-summary-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 响应式 ===== */
|
/* ===== 响应式 ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.analytics-page {
|
.analytics-page {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import EditorCompare from '../../components/Analytics/EditorCompare'
|
|||||||
import QuarterCompare from '../../components/Analytics/QuarterCompare'
|
import QuarterCompare from '../../components/Analytics/QuarterCompare'
|
||||||
import TopicCompare from '../../components/Analytics/TopicCompare'
|
import TopicCompare from '../../components/Analytics/TopicCompare'
|
||||||
import QuadrantChart from '../../components/Analytics/QuadrantChart'
|
import QuadrantChart from '../../components/Analytics/QuadrantChart'
|
||||||
|
import DiagnosisSummary from '../../components/Analytics/DiagnosisSummary'
|
||||||
import './Analytics.css'
|
import './Analytics.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -565,11 +566,12 @@ function Analytics() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── AI 诊断报告占位 ── */}
|
{/* ── AI 诊断报告 ── */}
|
||||||
<div className="analytics-chart-card" style={{ textAlign: 'center', padding: '40px 24px' }}>
|
<DiagnosisSummary
|
||||||
<h2 className="analytics-chart-title" style={{ textAlign: 'left' }}>AI 诊断报告</h2>
|
episodes={filteredEpisodes}
|
||||||
<p style={{ color: '#aaa', fontSize: 14 }}>即将上线 - 基于双引擎模型的智能收视诊断</p>
|
yearlyTarget={yearlyTarget}
|
||||||
</div>
|
selectedYear={selectedYear}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── 双引擎象限图 ── */}
|
{/* ── 双引擎象限图 ── */}
|
||||||
<div className="analytics-chart-card">
|
<div className="analytics-chart-card">
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/* ── AI 诊断报告详情页 ── */
|
||||||
|
.diagnosis-report-page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||||
|
background: linear-gradient(135deg, #f5f0e8 0%, #e8e4d8 30%, #f0ece2 60%, #ebe5d5 100%);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 返回链接 ── */
|
||||||
|
.diagnosis-report-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #5b8db8;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-back:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 头部信息 ── */
|
||||||
|
.diagnosis-report-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-header h1 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4a6741;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-header .diagnosis-report-range {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-header .diagnosis-report-date {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 指标条 ── */
|
||||||
|
.diagnosis-report-kpi-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-kpi-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-kpi-item .kpi-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-kpi-item .kpi-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 报告正文卡片 ── */
|
||||||
|
.diagnosis-report-body {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 36px 40px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
line-height: 1.9;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body h4 {
|
||||||
|
color: #4a6741;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 28px 0 12px 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #e8e4d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body h4:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body strong {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body ul,
|
||||||
|
.diagnosis-report-body ol {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body th,
|
||||||
|
.diagnosis-report-body td {
|
||||||
|
border: 1px solid #e0ddd4;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body th {
|
||||||
|
background: #f5f3eb;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 重新生成按钮 ── */
|
||||||
|
.diagnosis-report-actions {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-actions button {
|
||||||
|
background: #6b8e6b;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-actions button:hover {
|
||||||
|
background: #5a7d5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-actions button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 免责声明 ── */
|
||||||
|
.diagnosis-report-disclaimer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading ── */
|
||||||
|
.diagnosis-report-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-loading p {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 错误 ── */
|
||||||
|
.diagnosis-report-error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: #c0584f;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 响应式 ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.diagnosis-report-page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-body {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnosis-report-kpi-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
import Markdown from 'react-markdown'
|
||||||
|
import { generateDiagnosisReport } from '../../services/analyticsService'
|
||||||
|
import './DiagnosisReport.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 诊断报告详情页
|
||||||
|
* - 从 URL query 读取 year、start、end
|
||||||
|
* - 调用 generateDiagnosisReport(命中缓存则秒出)
|
||||||
|
* - 用 react-markdown 渲染完整报告
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TIER_COLOR = {
|
||||||
|
danger: '#7aa874',
|
||||||
|
on_target: '#5b8db8',
|
||||||
|
excellent: '#c0584f',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiagnosisReport() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const year = Number(searchParams.get('year'))
|
||||||
|
const start = Number(searchParams.get('start'))
|
||||||
|
const end = Number(searchParams.get('end'))
|
||||||
|
|
||||||
|
const [report, setReport] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [regenerating, setRegenerating] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const fetchReport = (force = false) => {
|
||||||
|
if (!year || !start || !end) {
|
||||||
|
setError('缺少必要参数(year / start / end)')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
setRegenerating(true)
|
||||||
|
} else {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
generateDiagnosisReport({ year, ep_start: start, ep_end: end, force })
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error)
|
||||||
|
} else {
|
||||||
|
setReport(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err?.response?.data?.detail || '报告生成失败')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false)
|
||||||
|
setRegenerating(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReport(false)
|
||||||
|
}, [year, start, end])
|
||||||
|
|
||||||
|
// 摸高完成率
|
||||||
|
const stretchPct =
|
||||||
|
report?.avg_share && report?.highest
|
||||||
|
? null // 没有 stretch_target 直接信息,从 tier 推断
|
||||||
|
: null
|
||||||
|
|
||||||
|
// 从 report 中计算摸高完成率(如果有 avg_share 和 episode_count)
|
||||||
|
// 实际上后端没返回 stretch_target,所以这里用 pass_count / episode_count 做达标率展示
|
||||||
|
const passRate =
|
||||||
|
report?.episode_count > 0
|
||||||
|
? `${report.pass_count}/${report.episode_count}`
|
||||||
|
: '—'
|
||||||
|
|
||||||
|
const tierColor = report?.tier ? TIER_COLOR[report.tier] : '#999'
|
||||||
|
|
||||||
|
// 从 report_markdown 中提取第一句作为范围描述的补充
|
||||||
|
const startDate = report?.report_markdown
|
||||||
|
? '' // 已经在 header 中展示了期号范围
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="diagnosis-report-page">
|
||||||
|
<div className="diagnosis-report-loading">
|
||||||
|
<Spin size="large" />
|
||||||
|
<p>正在生成诊断报告,预计 15-30 秒…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="diagnosis-report-page">
|
||||||
|
<span
|
||||||
|
className="diagnosis-report-back"
|
||||||
|
onClick={() => navigate('/analytics')}
|
||||||
|
>
|
||||||
|
← 返回收视分析
|
||||||
|
</span>
|
||||||
|
<div className="diagnosis-report-error">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="diagnosis-report-page">
|
||||||
|
{/* 返回链接 */}
|
||||||
|
<span
|
||||||
|
className="diagnosis-report-back"
|
||||||
|
onClick={() => navigate('/analytics')}
|
||||||
|
>
|
||||||
|
← 返回收视分析
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="diagnosis-report-header">
|
||||||
|
<h1>收视诊断报告</h1>
|
||||||
|
<p className="diagnosis-report-range">
|
||||||
|
第{start}期 — 第{end}期 | {year}年 | 共{report?.episode_count}期
|
||||||
|
</p>
|
||||||
|
{report?.generated_at && (
|
||||||
|
<p className="diagnosis-report-date">
|
||||||
|
生成时间:{new Date(report.generated_at).toLocaleString('zh-CN')}
|
||||||
|
{report?.model && ` | 模型:${report.model}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 指标条 */}
|
||||||
|
<div className="diagnosis-report-kpi-bar">
|
||||||
|
<div className="diagnosis-report-kpi-item">
|
||||||
|
<div className="kpi-value" style={{ color: tierColor }}>
|
||||||
|
{report?.avg_share?.toFixed(4) || '—'}
|
||||||
|
</div>
|
||||||
|
<div className="kpi-label">平均份额</div>
|
||||||
|
</div>
|
||||||
|
<div className="diagnosis-report-kpi-item">
|
||||||
|
<div className="kpi-value">{passRate}</div>
|
||||||
|
<div className="kpi-label">达标期数</div>
|
||||||
|
</div>
|
||||||
|
<div className="diagnosis-report-kpi-item">
|
||||||
|
<div className="kpi-value" style={{ color: '#c0584f' }}>
|
||||||
|
{report?.highest
|
||||||
|
? `${report.highest.share.toFixed(3)}(第${report.highest.ep}期)`
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div className="kpi-label">最高份额</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 报告正文 */}
|
||||||
|
<div className="diagnosis-report-body">
|
||||||
|
<Markdown>{report?.report_markdown || ''}</Markdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 重新生成 */}
|
||||||
|
<div className="diagnosis-report-actions">
|
||||||
|
<button onClick={() => fetchReport(true)} disabled={regenerating}>
|
||||||
|
{regenerating ? '正在重新生成…' : '重新生成'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 免责声明 */}
|
||||||
|
{report?.disclaimer && (
|
||||||
|
<p className="diagnosis-report-disclaimer">
|
||||||
|
⚠️ {report.disclaimer}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiagnosisReport
|
||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,16 @@ export async function getAnalyticsEpisodes(year) {
|
|||||||
export async function getAvailableYears() {
|
export async function getAvailableYears() {
|
||||||
const response = await http.get('/analytics/years')
|
const response = await http.get('/analytics/years')
|
||||||
return response.data
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 AI 诊断报告
|
||||||
|
* @param {Object} params - { year, ep_start, ep_end, force? }
|
||||||
|
* @returns {Promise<Object>} - { tier, avg_share, episode_count, pass_count, highest, lowest, report_markdown, generated_at, model, disclaimer }
|
||||||
|
*/
|
||||||
|
export async function generateDiagnosisReport(params) {
|
||||||
|
const response = await http.post('/analytics/diagnosis-report', params, {
|
||||||
|
timeout: 120000, // DeepSeek 可能需要 30-60 秒
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
}
|
}
|
||||||
@@ -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 就绪,待开发 |
|
||||||
@@ -102,5 +102,6 @@ Doco 子项目交付物的接口约定,见**子项目主 Brief §六**(交付什
|
|||||||
|
|
||||||
| 子项目 | 外迁日期 | 寄存条文件 | 状态 |
|
| 子项目 | 外迁日期 | 寄存条文件 | 状态 |
|
||||||
| ---------------- | ---------- | -------------------------- | ------------------------- |
|
| ---------------- | ---------- | -------------------------- | ------------------------- |
|
||||||
| TPS 看板分析升级 | 2026-06-12 | `寄存条_看板升级已外迁.md` | 设计阶段 |
|
| TPS 看板分析升级 | 2026-06-12 | `寄存条_看板升级已外迁.md` | 原型阶段收工(2026-07-03) |
|
||||||
| Doco 文稿整理 | 2026-06-12 | 本文件 | 等子项目出 PRD v3 启动 P1 |
|
| 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 就绪,待开发** |
|
||||||
Generated
+1290
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
|
"react-markdown": "^10.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
一次性脚本:将 AI 生成的内容摘要卡写入 episodes 表的 content_digest 字段
|
||||||
|
数据来源:ai-labeling/experiments/content_digests/_all_digests.json
|
||||||
|
前置:先手动执行 backend/sql/004_add_content_digest.sql 添加字段
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
# Windows 终端中文输出
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
|
# 加 backend 到 path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'backend'))
|
||||||
|
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.episode import Episode
|
||||||
|
from app.models.user import User # FK 解析需要
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
# ── 1. 读 digest 数据 ──
|
||||||
|
project_root = os.path.join(os.path.dirname(__file__), '..')
|
||||||
|
digest_file = os.path.join(project_root, 'ai-labeling', 'experiments', 'content_digests', '_all_digests.json')
|
||||||
|
|
||||||
|
with open(digest_file, 'r', encoding='utf-8') as f:
|
||||||
|
all_digests = json.load(f)
|
||||||
|
|
||||||
|
print(f"读入 {len(all_digests)} 条摘要记录")
|
||||||
|
|
||||||
|
# ── 2. 逐条匹配并写入 ──
|
||||||
|
session = next(get_session())
|
||||||
|
success = 0
|
||||||
|
skipped = 0
|
||||||
|
mismatch = 0
|
||||||
|
|
||||||
|
for d in all_digests:
|
||||||
|
ep_num = d['ep']
|
||||||
|
digest_date = d['date'] # "2026-04-21" 格式
|
||||||
|
|
||||||
|
# 查 episode
|
||||||
|
ep = session.exec(select(Episode).where(Episode.episode_number == ep_num)).first()
|
||||||
|
if not ep:
|
||||||
|
print(f"⚠️ ep{ep_num:02d} 在数据库中不存在,跳过")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 校验日期
|
||||||
|
if str(ep.air_date) != digest_date:
|
||||||
|
print(f"⚠️ ep{ep_num:02d} 日期不匹配: DB={ep.air_date}, digest={digest_date},跳过")
|
||||||
|
mismatch += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 写入(只存 digest 字段,不存外壳的 ep/date/title/editor/filename/generated_at)
|
||||||
|
ep.content_digest = d['digest']
|
||||||
|
session.add(ep)
|
||||||
|
success += 1
|
||||||
|
print(f"✅ ep{ep_num:02d} {ep.program_name} <- digest 已写入")
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# ── 3. 打印统计 ──
|
||||||
|
print()
|
||||||
|
print(f"=== 统计 ===")
|
||||||
|
print(f"成功写入: {success}")
|
||||||
|
print(f"跳过(不存在): {skipped}")
|
||||||
|
print(f"跳过(日期不匹配): {mismatch}")
|
||||||
|
print(f"总记录: {len(all_digests)}")
|
||||||
|
|
||||||
|
# ── 4. 验证:所有 episodes 的 content_digest 状态 ──
|
||||||
|
print()
|
||||||
|
print(f"=== 验证: episodes content_digest 状态 ===")
|
||||||
|
all_eps = session.exec(select(Episode).order_by(Episode.episode_number)).all()
|
||||||
|
for e in all_eps:
|
||||||
|
has_digest = "✅" if e.content_digest is not None else "❌"
|
||||||
|
print(f"ep{e.episode_number:02d} | {e.program_name} | {e.air_date} | digest: {has_digest}")
|
||||||
Reference in New Issue
Block a user