Files
tps-dashboard/backend/app/api/analytics.py
T

332 lines
12 KiB
Python
Raw Normal View History

"""
收视分析 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