feat: 添加 L4 AI 诊断报告(后端 DeepSeek 端点 + 前端摘要块与详情页)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user