diff --git a/backend/.env.example b/backend/.env.example index d9ca1c2..4ea5da1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -37,5 +37,12 @@ WECOM_SECRET=your-app-secret WECOM_TOKEN=your-token WECOM_ENCODING_AES_KEY=your-encoding-aes-key +# ── AI / LLM (OpenAI 兼容接口,用于周报摘要) ── +# 支持 OpenAI / DeepSeek / 通义千问 / 本地 Ollama 等 +AI_API_URL=https://api.openai.com/v1/chat/completions +AI_API_KEY=sk-your-api-key +AI_MODEL=gpt-4o +AI_MAX_TOKENS=2000 + # ── CORS (前端地址) ── CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] diff --git a/backend/app/api/ai_summary.py b/backend/app/api/ai_summary.py new file mode 100644 index 0000000..9e895e9 --- /dev/null +++ b/backend/app/api/ai_summary.py @@ -0,0 +1,40 @@ +"""AI summary endpoints — LLM-powered weekly report narrative.""" + +import uuid +from datetime import date +from typing import Optional +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.middleware.auth import get_current_user +from app.services.ai_summary import generate_summary + +router = APIRouter(prefix="/ai", tags=["AI Summary"]) + + +@router.post("/summary") +async def ai_summary( + reference_date: Optional[str] = Query(None), + period: str = Query("week", regex="^(week|month)$"), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Generate AI-powered weekly report summary. Director/leader only.""" + role = current_user["role"] + if role not in ("director", "leader"): + raise HTTPException(status_code=403, detail="仅限支局长和分管领导使用") + + try: + ref = date.fromisoformat(reference_date) if reference_date else None + summary = await generate_summary( + db=db, + user_id=uuid.UUID(current_user["user_id"]), + role=role, + reference_date=ref, + period=period, + ) + return {"summary": summary, "period": period, "reference_date": str(ref or date.today())} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"AI 摘要生成失败:{str(e)}") diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index b04a1e1..d9981f9 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.middleware.auth import get_current_user from app.services.dashboard import get_dashboard_stats, get_reporting_progress, get_weekly_report +from app.services.light_board import get_light_board router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) @@ -51,3 +52,14 @@ async def weekly_report( filter_customer_id=uuid.UUID(customer_id) if customer_id else None, reference_date=ref, ) + + +@router.get("/light-board") +async def light_board( + reference_date: Optional[str] = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get customer visit coverage matrix (light board). Director/leader only.""" + ref = date.fromisoformat(reference_date) if reference_date else None + return await get_light_board(db, ref) diff --git a/backend/app/config.py b/backend/app/config.py index 76b7398..d9e8298 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,6 +37,12 @@ class Settings(BaseSettings): WECOM_TOKEN: str = "" WECOM_ENCODING_AES_KEY: str = "" + # AI / LLM (OpenAI-compatible) + AI_API_URL: str = "" + AI_API_KEY: str = "" + AI_MODEL: str = "gpt-4o" + AI_MAX_TOKENS: int = 2000 + # CORS CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"] diff --git a/backend/app/main.py b/backend/app/main.py index baa3458..6c394c1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from app.config import settings from app.database import engine, Base from app.api import router as api_router from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits -from app.api import dashboard, upload, export, import_data, wecom, daily_notes +from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary @asynccontextmanager @@ -62,6 +62,7 @@ app.include_router(export.router, prefix="/api") app.include_router(import_data.router, prefix="/api") app.include_router(wecom.router, prefix="/api") app.include_router(daily_notes.router, prefix="/api") +app.include_router(ai_summary.router, prefix="/api") @app.get("/health") diff --git a/backend/app/services/ai_summary.py b/backend/app/services/ai_summary.py new file mode 100644 index 0000000..f2c0e4d --- /dev/null +++ b/backend/app/services/ai_summary.py @@ -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 diff --git a/backend/app/services/light_board.py b/backend/app/services/light_board.py new file mode 100644 index 0000000..bbe684c --- /dev/null +++ b/backend/app/services/light_board.py @@ -0,0 +1,198 @@ +"""Customer light board — visit coverage matrix per manager. + +Status: + green — visited this month (亮灯) + yellow — visited last month but not this month (临期) + red — not visited in 2+ months, or never visited (灭灯+警示) + gray — customer has no assigned primary manager (未分配) +""" + +from datetime import date, timedelta +from uuid import UUID +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.user import User +from app.models.customer import Customer +from app.models.customer_assignment import CustomerAssignment +from app.models.visit import Visit +from app.utils.timezone import today_cst + + +def month_start(ref: date) -> date: + return ref.replace(day=1) + + +def month_end(ref: date) -> date: + nxt = ref.replace(day=28) + timedelta(days=4) + return nxt - timedelta(days=nxt.day) + + +def classify(last_visit_date: date | None, ref: date) -> tuple[str, int]: + """Return (status, consecutive_missed_months).""" + if last_visit_date is None: + return ("red", 99) # never visited + + this_month = month_start(ref) + last_month = month_start(this_month - timedelta(days=1)) + + if last_visit_date >= this_month: + return ("green", 0) + elif last_visit_date >= last_month: + return ("yellow", 0) + + # Count how many consecutive months missed + cursor = month_start(ref) + missed = 0 + while True: + cursor = month_start(cursor - timedelta(days=1)) + if last_visit_date >= cursor: + break + missed += 1 + if missed > 24: # safety cap + break + return ("red", missed) + + +async def get_light_board(db: AsyncSession, reference_date: date | None = None) -> dict: + """Get per-manager customer visit coverage for the light board.""" + ref = reference_date or today_cst() + + # ── All managers ── + managers_result = await db.execute(select(User).where(User.role == "manager")) + managers = managers_result.scalars().all() + manager_map: dict[UUID, dict] = { + m.id: { + "manager_id": str(m.id), + "manager_name": m.name, + "total_customers": 0, + "visited_this_month": 0, + "visited_last_month_only": 0, + "not_visited_2months": 0, + "coverage_rate": 0.0, + "customers": [], + } + for m in managers + } + + # ── All customers with their last visit per manager ── + # Subquery: latest visit date + method per (manager_id, customer_id) + latest_visit = ( + select( + Visit.manager_id, + Visit.customer_id, + func.max(Visit.visit_date).label("last_visit_date"), + ) + .group_by(Visit.manager_id, Visit.customer_id) + .subquery() + ) + + # Join: assignments → customers → latest_visit → visits (for method) + rows = await db.execute( + select( + CustomerAssignment.manager_id, + CustomerAssignment.customer_id, + Customer.id.label("c_id"), + Customer.name.label("c_name"), + Customer.industry, + Customer.in_use_services, + Customer.monthly_fee, + latest_visit.c.last_visit_date, + Visit.visit_method, + ) + .join(Customer, Customer.id == CustomerAssignment.customer_id) + .outerjoin(latest_visit, and_( + latest_visit.c.manager_id == CustomerAssignment.manager_id, + latest_visit.c.customer_id == CustomerAssignment.customer_id, + )) + .outerjoin(Visit, and_( + Visit.manager_id == CustomerAssignment.manager_id, + Visit.customer_id == CustomerAssignment.customer_id, + Visit.visit_date == latest_visit.c.last_visit_date, + )) + .where(CustomerAssignment.role == "primary") + .order_by(Customer.name) + ) + + assigned_customer_ids: set[UUID] = set() + + for row in rows.all(): + mgr_id, cust_id, c_id, c_name, industry, services, fee, lvd, method = row + assigned_customer_ids.add(c_id) + + status, missed = classify(lvd, ref) + cust_entry = { + "customer_id": str(c_id), + "customer_name": c_name, + "industry": industry or "", + "in_use_services": services or "", + "monthly_fee": str(fee) if fee else "", + "last_visit_date": str(lvd) if lvd else None, + "last_visit_method": method or "", + "status": status, + "consecutive_missed_months": missed, + } + + mgr_entry = manager_map.get(mgr_id) + if mgr_entry: + mgr_entry["customers"].append(cust_entry) + mgr_entry["total_customers"] += 1 + if status == "green": + mgr_entry["visited_this_month"] += 1 + elif status == "yellow": + mgr_entry["visited_last_month_only"] += 1 + else: + mgr_entry["not_visited_2months"] += 1 + + # ── Calculate coverage rates ── + for mgr_entry in manager_map.values(): + total = mgr_entry["total_customers"] + if total > 0: + mgr_entry["coverage_rate"] = round(mgr_entry["visited_this_month"] / total, 3) + + # Sort managers: lowest coverage first (most problematic first) + manager_list = sorted(manager_map.values(), key=lambda m: m["coverage_rate"]) + + # ── Unassigned customers (no primary manager) ── + unassigned_rows = await db.execute( + select(Customer) + .outerjoin(CustomerAssignment, and_( + CustomerAssignment.customer_id == Customer.id, + CustomerAssignment.role == "primary", + )) + .where(CustomerAssignment.id == None) + .order_by(Customer.name) + ) + unassigned = [] + for c in unassigned_rows.scalars(): + unassigned.append({ + "customer_id": str(c.id), + "customer_name": c.name, + "industry": c.industry or "", + "in_use_services": c.in_use_services or "", + "monthly_fee": str(c.monthly_fee) if c.monthly_fee else "", + "last_visit_date": None, + "last_visit_method": "", + "status": "gray", + "consecutive_missed_months": 0, + }) + + # ── Team summary ── + all_total = sum(m["total_customers"] for m in manager_list) + all_green = sum(m["visited_this_month"] for m in manager_list) + all_yellow = sum(m["visited_last_month_only"] for m in manager_list) + all_red = sum(m["not_visited_2months"] for m in manager_list) + team_summary = { + "total_customers": all_total, + "visited_this_month": all_green, + "visited_last_month_only": all_yellow, + "not_visited_2months": all_red, + "unassigned": len(unassigned), + "coverage_rate": round(all_green / all_total, 3) if all_total > 0 else 0.0, + } + + return { + "reference_month": ref.strftime("%Y-%m"), + "managers": manager_list, + "unassigned_customers": unassigned, + "team_summary": team_summary, + } diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts new file mode 100644 index 0000000..aa5b17c --- /dev/null +++ b/frontend/src/api/ai.ts @@ -0,0 +1,7 @@ +import api from './index' + +export const aiApi = { + generateSummary(params?: { reference_date?: string; period?: string }) { + return api.post('/ai/summary', null, { params }) + }, +} diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index b8bad3b..1094215 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -10,4 +10,7 @@ export const dashboardApi = { getWeeklyReport(params?: any) { return api.get('/dashboard/weekly-report', { params }) }, + getLightBoard(params?: any) { + return api.get('/dashboard/light-board', { params }) + }, } diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index b17bae1..f7ed9b8 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -17,6 +17,7 @@ const menuGroups = computed(() => { items: [ { path: '/', label: '仪表盘', icon: '' }, { path: '/weekly-report', label: '周报', icon: '' }, + { path: '/light-board', label: '亮灯表', icon: '' }, ], }, { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 40b8b65..3533947 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -38,6 +38,7 @@ const router = createRouter({ children: [ { path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') }, { path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') }, + { path: 'light-board', name: 'LightBoard', component: () => import('@/views/desktop/LightBoard.vue') }, { path: 'work-plans', name: 'WorkPlans', component: () => import('@/views/desktop/WorkPlans.vue') }, { path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') }, { path: 'key-visits', name: 'KeyVisits', component: () => import('@/views/desktop/KeyVisits.vue') }, diff --git a/frontend/src/views/desktop/LightBoard.vue b/frontend/src/views/desktop/LightBoard.vue new file mode 100644 index 0000000..835592e --- /dev/null +++ b/frontend/src/views/desktop/LightBoard.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/frontend/src/views/desktop/WeeklyReport.vue b/frontend/src/views/desktop/WeeklyReport.vue index 3dbc83e..8c9150e 100644 --- a/frontend/src/views/desktop/WeeklyReport.vue +++ b/frontend/src/views/desktop/WeeklyReport.vue @@ -4,6 +4,7 @@ import { useRoute } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { dashboardApi } from '@/api/dashboard' import { uploadApi } from '@/api/upload' +import { aiApi } from '@/api/ai' import { ElMessage } from 'element-plus' import ImagePreview from '@/components/ImagePreview.vue' import api from '@/api/index' @@ -11,6 +12,9 @@ import api from '@/api/index' const auth = useAuthStore() const route = useRoute() const loading = ref(false) +const aiLoading = ref(false) +const aiSummary = ref('') +const aiError = ref('') const activeTab = ref('visits') const filterManagerId = ref('') const filterCustomerId = ref('') @@ -91,6 +95,54 @@ function viewPhoto(url: string) { photoDialogVisible.value = true } +async function generateAISummary() { + aiLoading.value = true + aiSummary.value = '' + aiError.value = '' + try { + const res = await aiApi.generateSummary({ reference_date: getRefDate(), period: 'week' }) + aiSummary.value = res.data.summary + } catch (e: any) { + const detail = e.response?.data?.detail || 'AI 摘要生成失败,请检查 AI 服务配置或稍后重试' + aiError.value = detail + ElMessage.error(detail) + } finally { + aiLoading.value = false + } +} + +async function copySummary() { + if (!aiSummary.value) return + try { + // Strip markdown markers for plain text + const plain = aiSummary.value.replace(/^#{1,4}\s+/gm, '').replace(/\*\*/g, '').replace(/\*/g, '') + await navigator.clipboard.writeText(plain) + ElMessage.success('摘要已复制到剪贴板') + } catch { + ElMessage.error('复制失败') + } +} + +function renderMarkdown(md: string): string { + if (!md) return '' + let html = md + // Headers + .replace(/^#### (.+)$/gm, '

$1

') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold + .replace(/\*\*(.+?)\*\*/g, '$1') + // Unordered lists + .replace(/^- (.+)$/gm, '
  • $1
  • ') + // Wrap consecutive
  • in