e7c3081070
- system_config 新增 ai_summary_prompt 配置键 - generate_summary 从数据库加载自定义提示词,为空则使用默认 SUMMARY_SYSTEM_PROMPT - 系统设置新增「AI 周报提示词」卡片,支持编辑和清空恢复默认 Co-Authored-By: Claude <noreply@anthropic.com>
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""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 app.models.system_config import SystemConfig
|
||
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,
|
||
leaves: list | None = None,
|
||
) -> 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"
|
||
|
||
# ── Leave data ──
|
||
if leaves:
|
||
data_block += "\n## 本周请假情况\n"
|
||
for lv in leaves:
|
||
data_block += (
|
||
f"- {lv.get('manager_name', '未知')} ({lv.get('leave_type', '请假')}): "
|
||
f"{lv.get('start_date', '')} ~ {lv.get('end_date', '')} "
|
||
f"({lv.get('days', 0)}天)\n"
|
||
)
|
||
else:
|
||
data_block += "\n## 本周请假情况\n无请假记录\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)
|
||
from app.services.leaves import get_leave_overview
|
||
leave_overview = await get_leave_overview(db, str(user_id), role, ref)
|
||
user_prompt = build_summary_prompt(
|
||
weekly_report, light_board, period, str(ref),
|
||
leaves=leave_overview.get("leave_list", []),
|
||
)
|
||
|
||
# Load custom system prompt (director/leader can override via system settings)
|
||
custom_row = await db.get(SystemConfig, "ai_summary_prompt")
|
||
system_prompt = SUMMARY_SYSTEM_PROMPT
|
||
if custom_row and custom_row.value and custom_row.value.strip():
|
||
system_prompt = custom_row.value.strip()
|
||
|
||
# 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": 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,
|
||
}
|