9df4868191
- 修正 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>
332 lines
12 KiB
Python
332 lines
12 KiB
Python
"""
|
||
收视分析 API — 提供收视走势和指标卡数据
|
||
|
||
端点:
|
||
GET /api/analytics/years → 有收视数据的年份列表(去重降序)
|
||
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
|
||
from app.models.yearly_target import YearlyTarget
|
||
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():
|
||
"""三角色都可读"""
|
||
return require_role(UserRole.zhipianren, UserRole.zebian, UserRole.biandao)
|
||
|
||
|
||
@router.get("/years")
|
||
def get_available_years(
|
||
session: Session = Depends(get_session),
|
||
current_user=Depends(_require_read()),
|
||
):
|
||
"""返回有收视数据的年份列表(去重,降序)。"""
|
||
statement = (
|
||
select(distinct(extract("year", Episode.air_date).label("year")))
|
||
.where(Episode.audience_share.is_not(None))
|
||
.order_by(extract("year", Episode.air_date).desc())
|
||
)
|
||
result = session.exec(statement).all()
|
||
# extract 返回 float,转 int
|
||
return [int(y) for y in result]
|
||
|
||
|
||
@router.get("/episodes")
|
||
def get_analytics_episodes(
|
||
year: int | None = Query(None, description="年份,不传则取最近有数据的年份"),
|
||
session: Session = Depends(get_session),
|
||
current_user=Depends(_require_read()),
|
||
):
|
||
"""返回指定年份所有期次的收视数据(按 air_date 升序),附带该年年度目标。
|
||
|
||
返回格式:
|
||
{
|
||
"year": 2026,
|
||
"yearly_target": { "base_target": 0.6448, "stretch_target": 0.8989 } | null,
|
||
"episodes": [ { id, episode_number, program_name, air_date, editor_name_snapshot,
|
||
audience_share, audience_rating }, ... ]
|
||
}
|
||
"""
|
||
# 如果没传 year,找最近有收视数据的年份
|
||
if year is None:
|
||
year_stmt = (
|
||
select(extract("year", Episode.air_date).label("y"))
|
||
.where(Episode.audience_share.is_not(None))
|
||
.order_by(extract("year", Episode.air_date).desc())
|
||
.limit(1)
|
||
)
|
||
latest_year = session.exec(year_stmt).first()
|
||
if latest_year is None:
|
||
return {"year": None, "yearly_target": None, "episodes": []}
|
||
year = int(latest_year)
|
||
|
||
# 查询该年份的期次(按 air_date 升序)
|
||
ep_stmt = (
|
||
select(Episode)
|
||
.where(extract("year", Episode.air_date) == year)
|
||
.order_by(Episode.air_date.asc())
|
||
)
|
||
episodes = session.exec(ep_stmt).all()
|
||
|
||
# 查询该年份的年度目标
|
||
target_stmt = select(YearlyTarget).where(YearlyTarget.year == year)
|
||
target = session.exec(target_stmt).first()
|
||
|
||
# 组装返回
|
||
ep_list = [
|
||
{
|
||
"id": ep.id,
|
||
"episode_number": ep.episode_number,
|
||
"program_name": ep.program_name,
|
||
"air_date": ep.air_date.isoformat() if ep.air_date else None,
|
||
"editor_name_snapshot": ep.editor_name_snapshot,
|
||
"audience_share": ep.audience_share,
|
||
"audience_rating": ep.audience_rating,
|
||
"program_format": ep.program_format,
|
||
"narrative_structure": ep.narrative_structure,
|
||
"opening_hook": ep.opening_hook,
|
||
"equipment_domain": ep.equipment_domain,
|
||
}
|
||
for ep in episodes
|
||
]
|
||
|
||
yearly_target = None
|
||
if target:
|
||
yearly_target = {
|
||
"base_target": target.base_target,
|
||
"stretch_target": target.stretch_target,
|
||
}
|
||
|
||
return {
|
||
"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-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
|