Files
qiji/backend/app/services/ai_summary.py
T
v6ole 5b38246801 feat: AI 摘要持久化 — DB 缓存 + 自动加载
后端:
- 新增 ai_summaries 表 (week_start/week_end/generated_by/summary)
- get_cached_summary(): 按周+用户查缓存
- delete_cached_summary(): 生成前清理旧缓存
- generate_summary(): 自动保存到 DB
- API: GET /ai/summary(加载) POST(生成) DELETE(清除)

前端:
- onMounted 自动 GET 缓存摘要, 有则直接展示
- 按钮: 无缓存→'AI 生成摘要', 有缓存→'重新生成'
- 标题栏显示生成时间戳
2026-06-25 11:45:04 +08:00

270 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AI-powered weekly report summary using an OpenAI-compatible LLM."""
import json
import httpx
from app.config import settings
from app.services.dashboard import get_weekly_report, get_week_range
from app.services.light_board import get_light_board
from app.models.ai_summary import AISummary
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID
from datetime import date, datetime
SUMMARY_SYSTEM_PROMPT = """你是一位经验丰富的政企客户经理团队的周报分析助手。你的分析将被直接提交给支局长作为工作周报的文字摘要。
## 约束
- 严格基于提供的数据进行分析,绝不编造数据中不存在的信息
- 使用正式但不生硬的中文,适合放入政企工作周报
- 每条分析简洁有力,1-2句话即可,避免空泛套话
- 如果某个结论是基于数据推断的,请使用"数据显示""从本周情况看"等表述
- 对于覆盖不足的情况,请明确指出具体客户名称和负责人,方便支局长跟进
## 输出格式(使用 Markdown
### 一、本周概况
[2-3句话,涵盖:拜访总量、覆盖客户数、团队参与情况、拜访方式分布]
### 二、拜访重点与客户需求
[2-3个值得关注的客户需求或沟通内容要点,有具体客户名称]
### 三、客户覆盖分析
[引用覆盖数据,明确指出:覆盖率、低于60%的经理、红灯客户名单、需要关注的客户]
### 四、下周建议
[2-3条针对性的工作建议,基于数据中暴露的问题和客户需求]"""
def build_summary_prompt(
weekly_report: dict,
light_board: dict,
period: str,
reference_date: str,
) -> str:
"""Build the user prompt with structured visit data for the LLM."""
# ── Summary stats ──
visits = weekly_report.get("visits", [])
daily_notes = weekly_report.get("daily_notes", [])
managers_involved: set[str] = set()
customers_visited: set[str] = set()
methods: dict[str, int] = {}
demands: list[str] = []
for v in visits:
managers_involved.add(v.get("manager_name", ""))
customers_visited.add(v.get("customer_name", ""))
method = v.get("visit_method", "")
methods[method] = methods.get(method, 0) + 1
demand = v.get("customer_demand", "")
if demand and demand.strip():
demands.append(f"{v.get('customer_name', '未知')}: {demand.strip()}")
# Manager breakdown
manager_visits: dict[str, list] = {}
for v in visits:
mn = v.get("manager_name", "未知")
if mn not in manager_visits:
manager_visits[mn] = []
manager_visits[mn].append({
"client": v.get("customer_name", ""),
"method": v.get("visit_method", ""),
"content": (v.get("communication_content", "") or "")[:120],
"demand": v.get("customer_demand", "") or "",
})
# Build the data block
data_block = f"""## 基本信息
- 分析周期:{period}
- 参考日期:{reference_date}
- 周范围:{weekly_report.get('week_start', '')}{weekly_report.get('week_end', '')}
## 拜访总览
- 拜访记录总数:{len(visits)}
- 覆盖客户数:{len(customers_visited)}
- 参与经理数:{len(managers_involved)}
- 拜访方式分布:{json.dumps(methods, ensure_ascii=False)}
## 各客户经理拜访明细
"""
for mn, items in manager_visits.items():
data_block += f"\n### {mn}{len(items)}条)\n"
for item in items[:10]: # cap per manager
data_block += f"- {item['method']}拜访 {item['client']}"
if item['content']:
data_block += f" — {item['content'][:100]}"
if item['demand']:
data_block += f" [需求: {item['demand'][:80]}]"
data_block += "\n"
# Customer demands
if demands:
data_block += "\n## 客户需求汇总\n"
for d in demands[:15]:
data_block += f"- {d[:200]}\n"
# Daily notes summary
notes_by_cat: dict[str, int] = {}
for n in daily_notes:
cat = n.get("category", "其他")
notes_by_cat[cat] = notes_by_cat.get(cat, 0) + 1
if notes_by_cat:
data_block += "\n## 纪要分类统计\n"
data_block += json.dumps(notes_by_cat, ensure_ascii=False) + "\n"
# Light board data
team = light_board.get("team_summary", {})
data_block += f"""
## 客户覆盖数据(亮灯表)
- 团队总客户数:{team.get('total_customers', 0)}
- 本月已拜访(绿灯):{team.get('visited_this_month', 0)}
- 仅上月拜访(黄灯):{team.get('visited_last_month_only', 0)}
- 连续未拜访(红灯):{team.get('not_visited_2months', 0)}
- 未分配客户:{team.get('unassigned', 0)}
- 整体覆盖率:{team.get('coverage_rate', 0) * 100:.1f}%
### 各经理覆盖率
"""
for m in light_board.get("managers", []):
data_block += (
f"- {m['manager_name']}: {m['coverage_rate'] * 100:.0f}% "
f"({m['visited_this_month']}/{m['total_customers']}) "
f"🟢{m['visited_this_month']} 🟡{m['visited_last_month_only']} 🔴{m['not_visited_2months']}\n"
)
# List red customers
red_customers = [c for c in m.get("customers", []) if c["status"] == "red"]
if red_customers:
data_block += " 红灯客户:\n"
for rc in red_customers[:5]:
lvd = rc.get("last_visit_date") or "从未"
data_block += f" - {rc['customer_name']}(上次拜访: {lvd}\n"
return data_block
async def get_cached_summary(
db: AsyncSession,
user_id: UUID,
reference_date: date | None = None,
period: str = "week",
) -> dict | None:
"""Load a previously generated summary for this week/user."""
ref = reference_date or date.today()
monday, sunday = get_week_range(ref)
result = await db.execute(
select(AISummary)
.where(
AISummary.week_start == monday,
AISummary.period == period,
AISummary.generated_by == user_id,
)
.order_by(AISummary.created_at.desc())
.limit(1)
)
row = result.scalar()
if not row:
return None
return {
"summary": row.summary,
"week_start": str(row.week_start),
"week_end": str(row.week_end),
"period": row.period,
"created_at": str(row.created_at),
"cached": True,
}
async def delete_cached_summary(
db: AsyncSession,
user_id: UUID,
reference_date: date | None = None,
period: str = "week",
) -> bool:
"""Delete a cached summary so it can be regenerated."""
ref = reference_date or date.today()
monday, sunday = get_week_range(ref)
result = await db.execute(
select(AISummary).where(
AISummary.week_start == monday,
AISummary.period == period,
AISummary.generated_by == user_id,
)
)
rows = result.scalars().all()
for row in rows:
await db.delete(row)
if rows:
await db.commit()
return len(rows) > 0
async def generate_summary(
db: AsyncSession,
user_id: UUID,
role: str,
reference_date: date | None = None,
period: str = "week",
) -> dict:
"""Generate an AI-powered weekly summary, save to DB, return it.
Raises ValueError if AI config is missing, httpx.HTTPError on API failure.
"""
if not settings.AI_API_URL:
raise ValueError("AI_API_URL not configured")
# Gather data
ref = reference_date or date.today()
monday, sunday = get_week_range(ref)
weekly_report = await get_weekly_report(
db=db, user_id=user_id, role=role, reference_date=ref,
)
light_board = await get_light_board(db, ref, user_id, role)
user_prompt = build_summary_prompt(weekly_report, light_board, period, str(ref))
# Call LLM
headers = {"Content-Type": "application/json"}
if settings.AI_API_KEY:
headers["Authorization"] = f"Bearer {settings.AI_API_KEY}"
payload = {
"model": settings.AI_MODEL,
"messages": [
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
"max_tokens": settings.AI_MAX_TOKENS,
"temperature": 0.3,
}
async with httpx.AsyncClient(timeout=90.0) as client:
resp = await client.post(settings.AI_API_URL, json=payload, headers=headers)
resp.raise_for_status()
result = resp.json()
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
if not content:
raise ValueError("AI returned empty response")
# Delete old cached entry for this week/user, then save new
await delete_cached_summary(db, user_id, reference_date=ref, period=period)
row = AISummary(
week_start=monday,
week_end=sunday,
period=period,
generated_by=user_id,
role=role,
summary=content,
)
db.add(row)
await db.commit()
return {
"summary": content,
"week_start": str(monday),
"week_end": str(sunday),
"period": period,
"created_at": str(row.created_at),
"cached": False,
}