Compare commits
2 Commits
f37679530a
...
9df4868191
| Author | SHA1 | Date | |
|---|---|---|---|
| 9df4868191 | |||
| 3dd718f53d |
@@ -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 前端 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-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,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 摩擦续上)
|
||||||
|
|
||||||
- **收视分析看板前端 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 实战教训)**:
|
||||||
@@ -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. **外部因素诚实标注。** 如果某期收视异常可能受外部因素影响(如同时段重大事件、特殊节假日),但你无法确认,可以标注"可能受外部因素影响,建议结合当期播出环境分析",不要编造外部因素。
|
||||||
@@ -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():
|
||||||
"""三角色都可读"""
|
"""三角色都可读"""
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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*
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -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/`,待批量导入主项目知识库 |
|
||||||
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