From 5b38246801dc5451d5ac7a82496f6f633b405854 Mon Sep 17 00:00:00 2001 From: v6ole Date: Thu, 25 Jun 2026 11:45:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E6=91=98=E8=A6=81=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20=E2=80=94=20DB=20=E7=BC=93=E5=AD=98=20+=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 ai_summaries 表 (week_start/week_end/generated_by/summary) - get_cached_summary(): 按周+用户查缓存 - delete_cached_summary(): 生成前清理旧缓存 - generate_summary(): 自动保存到 DB - API: GET /ai/summary(加载) POST(生成) DELETE(清除) 前端: - onMounted 自动 GET 缓存摘要, 有则直接展示 - 按钮: 无缓存→'AI 生成摘要', 有缓存→'重新生成' - 标题栏显示生成时间戳 --- backend/app/api/ai_summary.py | 60 +++++++++++--- backend/app/models/__init__.py | 2 + backend/app/models/ai_summary.py | 21 +++++ backend/app/services/ai_summary.py | 87 ++++++++++++++++++++- frontend/src/api/ai.ts | 3 + frontend/src/views/desktop/WeeklyReport.vue | 21 ++++- 6 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 backend/app/models/ai_summary.py diff --git a/backend/app/api/ai_summary.py b/backend/app/api/ai_summary.py index 9e895e9..f6b16e2 100644 --- a/backend/app/api/ai_summary.py +++ b/backend/app/api/ai_summary.py @@ -1,4 +1,4 @@ -"""AI summary endpoints — LLM-powered weekly report narrative.""" +"""AI summary endpoints — LLM-powered weekly report narrative with caching.""" import uuid from datetime import date @@ -7,34 +7,68 @@ 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 +from app.services.ai_summary import generate_summary, get_cached_summary, delete_cached_summary router = APIRouter(prefix="/ai", tags=["AI Summary"]) -@router.post("/summary") -async def ai_summary( +def _check_role(role: str): + if role not in ("director", "leader"): + raise HTTPException(status_code=403, detail="仅限支局长和分管领导使用") + + +def _parse_ref(reference_date: Optional[str]) -> date | None: + return date.fromisoformat(reference_date) if reference_date else None + + +@router.get("/summary") +async def get_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="仅限支局长和分管领导使用") + """Get cached AI summary for this week. Returns null if none exists.""" + _check_role(current_user["role"]) + cached = await get_cached_summary( + db, uuid.UUID(current_user["user_id"]), _parse_ref(reference_date), period, + ) + return cached or {"summary": None} + +@router.post("/summary") +async def create_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 (or regenerate) AI summary. Saves to DB automatically.""" + _check_role(current_user["role"]) try: - ref = date.fromisoformat(reference_date) if reference_date else None - summary = await generate_summary( + return await generate_summary( db=db, user_id=uuid.UUID(current_user["user_id"]), - role=role, - reference_date=ref, + role=current_user["role"], + reference_date=_parse_ref(reference_date), 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)}") + + +@router.delete("/summary") +async def delete_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), +): + """Delete cached summary so it can be regenerated fresh.""" + _check_role(current_user["role"]) + deleted = await delete_cached_summary( + db, uuid.UUID(current_user["user_id"]), _parse_ref(reference_date), period, + ) + return {"deleted": deleted} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d5632f1..ac27995 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.work_plan import WorkPlan from app.models.mini_business import MiniBusiness from app.models.key_visit import KeyVisit from app.models.daily_note import DailyNote +from app.models.ai_summary import AISummary __all__ = [ "User", @@ -18,4 +19,5 @@ __all__ = [ "MiniBusiness", "KeyVisit", "DailyNote", + "AISummary", ] diff --git a/backend/app/models/ai_summary.py b/backend/app/models/ai_summary.py new file mode 100644 index 0000000..a894e17 --- /dev/null +++ b/backend/app/models/ai_summary.py @@ -0,0 +1,21 @@ +"""AI-generated weekly report summary — cached per week per user.""" + +import uuid +from datetime import date, datetime +from sqlalchemy import String, Text, DateTime, Date, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class AISummary(Base): + __tablename__ = "ai_summaries" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + week_start: Mapped[date] = mapped_column(Date, index=True) + week_end: Mapped[date] = mapped_column(Date) + period: Mapped[str] = mapped_column(String(10), default="week") # 'week' / 'month' + generated_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True) + role: Mapped[str] = mapped_column(String(20)) + summary: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/services/ai_summary.py b/backend/app/services/ai_summary.py index cdaeca0..c0ccc85 100644 --- a/backend/app/services/ai_summary.py +++ b/backend/app/services/ai_summary.py @@ -5,9 +5,11 @@ 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 +from datetime import date, datetime SUMMARY_SYSTEM_PROMPT = """你是一位经验丰富的政企客户经理团队的周报分析助手。你的分析将被直接提交给支局长作为工作周报的文字摘要。 @@ -141,14 +143,70 @@ def build_summary_prompt( 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", -) -> str: - """Generate an AI-powered weekly summary. +) -> dict: + """Generate an AI-powered weekly summary, save to DB, return it. Raises ValueError if AI config is missing, httpx.HTTPError on API failure. """ @@ -157,6 +215,7 @@ async def generate_summary( # 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, ) @@ -187,4 +246,24 @@ async def generate_summary( if not content: raise ValueError("AI returned empty response") - return content + # 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, + } diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index aa5b17c..338fc94 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -1,6 +1,9 @@ import api from './index' export const aiApi = { + getSummary(params?: { reference_date?: string; period?: string }) { + return api.get('/ai/summary', { params }) + }, generateSummary(params?: { reference_date?: string; period?: string }) { return api.post('/ai/summary', null, { params }) }, diff --git a/frontend/src/views/desktop/WeeklyReport.vue b/frontend/src/views/desktop/WeeklyReport.vue index 4d266fe..f824a78 100644 --- a/frontend/src/views/desktop/WeeklyReport.vue +++ b/frontend/src/views/desktop/WeeklyReport.vue @@ -16,6 +16,8 @@ const loading = ref(false) const aiLoading = ref(false) const aiSummary = ref('') const aiError = ref('') +const aiCached = ref(false) +const aiCreatedAt = ref('') const activeTab = ref('visits') const filterManagerId = ref('') const filterCustomerId = ref('') @@ -47,6 +49,17 @@ onMounted(async () => { managers.value = mRes.data customers.value = cRes.data.items || cRes.data } catch (_) {} + // Auto-load cached AI summary + if (auth.isDirector || auth.isLeader) { + try { + const cached = await aiApi.getSummary({ reference_date: getRefDate(), period: 'week' }) + if (cached.data?.summary) { + aiSummary.value = cached.data.summary + aiCached.value = !!cached.data.cached + aiCreatedAt.value = cached.data.created_at || '' + } + } catch (_) {} + } }) function changeWeek(delta: number) { weekOffset.value += delta; loadReport() } @@ -106,9 +119,12 @@ async function generateAISummary() { aiLoading.value = true aiSummary.value = '' aiError.value = '' + aiCached.value = false try { const res = await aiApi.generateSummary({ reference_date: getRefDate(), period: 'week' }) aiSummary.value = res.data.summary + aiCached.value = !!res.data.cached + aiCreatedAt.value = res.data.created_at || '' } catch (e: any) { const detail = e.response?.data?.detail || 'AI 摘要生成失败,请检查 AI 服务配置或稍后重试' aiError.value = detail @@ -202,7 +218,7 @@ const notesByDate = computed(() => { - AI 生成摘要 + {{ aiCached ? '重新生成' : 'AI 生成摘要' }} @@ -245,7 +261,8 @@ const notesByDate = computed(() => { AI 周报摘要 - 基于本周拜访数据自动生成,仅供参考 + 生成于 {{ new Date(aiCreatedAt).toLocaleString('zh-CN') }} · 已缓存 + 基于本周拜访数据自动生成,仅供参考