"""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, }