2026-07-03 10:19:27 +08:00
|
|
|
|
"""
|
|
|
|
|
|
收视分析 API — 提供收视走势和指标卡数据
|
|
|
|
|
|
|
|
|
|
|
|
端点:
|
|
|
|
|
|
GET /api/analytics/years → 有收视数据的年份列表(去重降序)
|
|
|
|
|
|
GET /api/analytics/episodes?year=2026 → 指定年份所有期次的收视数据 + 年度目标
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-07-03 21:04:08 +08:00
|
|
|
|
import json
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
2026-07-03 10:19:27 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
2026-07-03 21:04:08 +08:00
|
|
|
|
from pydantic import BaseModel
|
2026-07-03 10:19:27 +08:00
|
|
|
|
from sqlalchemy import extract
|
|
|
|
|
|
from sqlalchemy import distinct
|
|
|
|
|
|
from sqlmodel import Session, select
|
2026-07-03 21:04:08 +08:00
|
|
|
|
from openai import OpenAI
|
2026-07-03 10:19:27 +08:00
|
|
|
|
|
2026-07-03 21:04:08 +08:00
|
|
|
|
from app.core.config import settings
|
2026-07-03 10:19:27 +08:00
|
|
|
|
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=["收视分析"])
|
|
|
|
|
|
|
2026-07-03 21:04:08 +08:00
|
|
|
|
# 诊断报告缓存(内存,重启清空)
|
|
|
|
|
|
_report_cache = {}
|
|
|
|
|
|
|
|
|
|
|
|
# prompt5 文件路径
|
|
|
|
|
|
_PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
|
|
|
|
|
|
_PROMPT5_PATH = _PROJECT_ROOT / "ai-labeling" / "prompts" / "prompt5_diagnosis_report.md"
|
|
|
|
|
|
|
2026-07-03 10:19:27 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-03 17:48:38 +08:00
|
|
|
|
"program_format": ep.program_format,
|
|
|
|
|
|
"narrative_structure": ep.narrative_structure,
|
|
|
|
|
|
"opening_hook": ep.opening_hook,
|
|
|
|
|
|
"equipment_domain": ep.equipment_domain,
|
2026-07-03 10:19:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-07-03 21:04:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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
|