Files
qiji/backend/app/services/ai_summary.py
T
v6ole 305ee5df09 feat: 客户亮灯表 + AI 周报摘要 — v0.2
亮灯表:
- 四色覆盖矩阵: 绿亮灯/黄临期/红灭灯/灰未分配
- 按客户经理折叠卡片流, 覆盖率进度条, 团队总览统计
- 红灯客户显示连续未拜访月份, 未分配客户专区
- 后端: light_board.py service + GET /api/dashboard/light-board
- 前端: LightBoard.vue + 路由 /light-board + 汇总侧边栏

AI 周报摘要:
- 接入 OpenAI 兼容大模型, 注入拜访数据+亮灯表覆盖数据
- 四段式结构化输出: 概况/需求/覆盖分析/建议
- 一键生成+Markdown渲染+复制纯文本
- 支局长/分管领导专用, 支持配置内部模型
- 后端: ai_summary.py service + POST /api/ai/summary
- 前端: WeeklyReport 集成按钮+结果面板
- 新增配置: AI_API_URL / AI_API_KEY / AI_MODEL

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 12:03:00 +08:00

191 lines
6.9 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 sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID
from datetime import date
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 generate_summary(
db: AsyncSession,
user_id: UUID,
role: str,
reference_date: date | None = None,
period: str = "week",
) -> str:
"""Generate an AI-powered weekly summary.
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()
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_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")
return content