2 Commits

Author SHA1 Message Date
simonkoson 9df4868191 feat: L4 AI 诊断报告优化 + Prompt/摘要卡/迁移/文档完善
- 修正 DeepSeek 模型名 deepseek-chat → deepseek-v4-pro
- 摘要块:修复 **粗体** markdown 渲染、左右块可滚动、左块固定粉红色系
- 新增 prompt4(内容摘要卡)+ prompt5(诊断报告)+ 三处 prompt5 优化
- 新增 004 迁移(episodes +content_digest JSONB)、导入脚本、摘要卡生成脚本
- 更新 CLAUDE.md 状态栏/进度/交接备注/关键决策

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-03 21:25:38 +08:00
simonkoson 3dd718f53d feat: 添加 L4 AI 诊断报告(后端 DeepSeek 端点 + 前端摘要块与详情页) 2026-07-03 21:04:08 +08:00
22 changed files with 2889 additions and 28 deletions
+19 -11
View File
@@ -14,7 +14,7 @@
## 🔖 状态栏(每次结束 session 前必须更新这三行) ## 🔖 状态栏(每次结束 session 前必须更新这三行)
- **最后更新**Claude(顾问+动手)| 2026-07-03 - **最后更新**Claude(顾问+动手)| 2026-07-03
- **当前状态一句话**收视分析看板 React 前端 L1–L4 已实现并验收(指标卡+走势图+季度/编导/题材对比+双引擎象限图)。25 期真实收视+AI 标签已导入。下一步:L4 AI 诊断报告(新 session 讨论) - **当前状态一句话**L4 AI 诊断报告已实现(DeepSeek V4 Pro 生成、摘要块+详情页、三档自适应、22期内容摘要卡已入库)。收视分析看板 L1-L4 全部完成
- **下一个动手的人从这里开始**:见下方「⏩ 交接备注」 - **下一个动手的人从这里开始**:见下方「⏩ 交接备注」
--- ---
@@ -74,16 +74,17 @@
## 3. 当前进度(动态,核心交接区 — 以最新快照为准) ## 3. 当前进度(动态,核心交接区 — 以最新快照为准)
- **已完成至**:收视分析看板 React 前端 L1L4(指标卡、走势图、季度/编导/题材对比、双引擎象限图),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-03] **L4 AI 诊断报告**:① Prompt 4(内容摘要卡,MiMo 生成)+ Prompt 5(诊断报告,DeepSeek V4 Pro)撰写完成;② 22 期文稿摘要卡批量生成并导入 episodes.content_digest004 迁移);③ 后端 POST /api/analytics/diagnosis-report 端点(组装数据+调 DeepSeek+内存缓存);④ 前端摘要块 DiagnosisSummary(左右两块、三档自适应色调、可滚动、粗体渲染)+ 详情页 DiagnosisReportreact-markdown 渲染+重新生成+免责声明);⑤ 话题性(社交货币)三维框架设计(大众认知度/降维切口/惊奇密度)。模型选型:DeepSeek V4 Pro 胜出(vs Qwen-Max 对比测试)
- [2026-07-03] **收视分析看板 React 前端实现(L1–L3)**:① 指标卡 5 列(均值/基础达标率/摸高完成率含四档动画/期次数/年度目标);② 走势折线图(dataZoom 滑块+确认按钮范围过滤,下游模块联动);③ 双列对比区(左列季度+编导、右列题材饼图+条形图);④ 双引擎象限图(题材热度×叙事结构散点,气泡编码份额/三色/钩子强度)。附带:25 期真实收视+AI 标签数据导入、侧边栏 fixed 定位、滑块滚轮冲突修复。后端 analytics API 增返 4 个 AI 标签字段。
- [2026-07-03] 看板升级子项目原型阶段收工: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,7 +97,7 @@
## 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 审方案,优先级较高);③ 界面像素级打磨+视觉规范统一。
- [ ] PDF 大文件存储:**大文件不入库,单独文件仓库,DB 只存地址指针**(md 正文+向量留库参与检索;pdf 仅按需调阅)。地基一次定对,避免上云返工。 - [ ] PDF 大文件存储:**大文件不入库,单独文件仓库,DB 只存地址指针**(md 正文+向量留库参与检索;pdf 仅按需调阅)。地基一次定对,避免上云返工。
- [ ] 200+ Obsidian md 批量录入(**建议在 PDF 存储方案定后做**;先试 10 篇验证解析/落位再全量,每篇都真调 MiniMax 耗额度)。 - [ ] 200+ Obsidian md 批量录入(**建议在 PDF 存储方案定后做**;先试 10 篇验证解析/落位再全量,每篇都真调 MiniMax 耗额度)。
@@ -124,14 +125,21 @@
- 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期《节目名》」格式;② 话题性必须独立分析段;③ 军事装备分类须准确(枪械≠冷兵器)。
--- ---
## 7. ⏩ 交接备注(换人/换账号 0 摩擦续上) ## 7. ⏩ 交接备注(换人/换账号 0 摩擦续上)
- **收视分析看板前端 L1L4 已验收**,下一步是 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 实战教训)**
@@ -151,6 +159,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. **外部因素诚实标注。** 如果某期收视异常可能受外部因素影响(如同时段重大事件、特殊节假日),但你无法确认,可以标注"可能受外部因素影响,建议结合当期播出环境分析",不要编造外部因素。
+260
View File
@@ -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()
+217
View File
@@ -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():
"""三角色都可读""" """三角色都可读"""
@@ -112,3 +127,205 @@ def get_analytics_episodes(
"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
+4
View File
@@ -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()
+3
View File
@@ -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()),
+1
View File
@@ -12,3 +12,4 @@ 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
+9
View File
@@ -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:核心切口/叙事亮点/观众门槛/话题性/潜在弱点/时效关联)';
+4 -3
View File
@@ -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 下标),手写骨架 JSON32 段=26 normal+6 ignore 粗体章节标题)通过校验;ep022 骨架 LLM 自动通过(38 段全 normalinline header);ep021 C3 review 13 条(均 OCR 修正,专名零替换)、C4 punct_ok 25/26ep022 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 下标),手写骨架 JSON32 段=26 normal+6 ignore 粗体章节标题)通过校验;ep022 骨架 LLM 自动通过(38 段全 normalinline header);ep021 C3 review 13 条(均 OCR 修正,专名零替换)、C4 punct_ok 25/26ep022 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.80batch_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.80batch_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 处高亮标签),内容本身正确。**结论(通哥拍板)**:治本靠编导给贴近播出版的稿子,现阶段接受"程序保证文字零改 + 编导手调分段"的模式。
+4 -4
View File
@@ -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 本地 OCR16 路并发,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*
+2
View File
@@ -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,231 @@
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, startMarker, endMarkers) {
if (!markdown) return ''
const startIdx = markdown.indexOf(startMarker)
if (startIdx === -1) return ''
const afterStart = startIdx + startMarker.length
// 找最近的结束标记
let endIdx = markdown.length
for (const marker of endMarkers) {
const idx = markdown.indexOf(marker, afterStart)
if (idx !== -1 && idx < endIdx) {
endIdx = idx
}
}
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
@@ -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 -5
View File
@@ -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 读取 yearstartend
* - 调用 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
+12
View File
@@ -20,3 +20,15 @@ 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
}
@@ -102,5 +102,5 @@ 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/`,待批量导入主项目知识库 |
+1290 -1
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"dependencies": {
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"react-markdown": "^10.1.0"
}
}
+78
View File
@@ -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}")