feat: 添加 L4 AI 诊断报告(后端 DeepSeek 端点 + 前端摘要块与详情页)

This commit is contained in:
simonkoson
2026-07-03 21:04:08 +08:00
parent f37679530a
commit 3dd718f53d
10 changed files with 917 additions and 7 deletions
+218 -1
View File
@@ -6,11 +6,19 @@
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 pydantic import BaseModel
from sqlalchemy import extract
from sqlalchemy import distinct
from sqlmodel import Session, select
from openai import OpenAI
from app.core.config import settings
from app.core.deps import require_role
from app.db.session import get_session
from app.models.episode import Episode
@@ -19,6 +27,13 @@ from app.models.user import UserRole
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():
"""三角色都可读"""
@@ -111,4 +126,206 @@ def get_analytics_episodes(
"year": year,
"yearly_target": yearly_target,
"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-chat",
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-chat",
"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_GROUP_ID = os.environ.get("MINIMAX_GROUP_ID", "")
# DeepSeek API 凭证(诊断报告用)
_DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
# 验证必需配置
if not _DATABASE_URL:
raise RuntimeError(f"[config] DATABASE_URL 未设置。请检查 {_env_path} 是否存在且内容正确。")
@@ -31,6 +34,7 @@ class Settings:
SESSION_MAX_AGE: int = _SESSION_MAX_AGE
MINIMAX_EMBED_API_KEY: str = _MINIMAX_EMBED_API_KEY
MINIMAX_GROUP_ID: str = _MINIMAX_GROUP_ID
DEEPSEEK_API_KEY: str = _DEEPSEEK_API_KEY
settings = Settings()
+2 -1
View File
@@ -11,4 +11,5 @@ python-dotenv==1.0.1
httpx==0.27.0
python-frontmatter==1.1.0
pandas>=2.0.0
openpyxl>=3.1.0
openpyxl>=3.1.0
openai>=1.0.0,<1.55.0