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>
This commit is contained in:
2026-06-24 12:03:00 +08:00
parent b79a5296c4
commit 305ee5df09
13 changed files with 1100 additions and 1 deletions
+190
View File
@@ -0,0 +1,190 @@
"""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