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