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 生成摘要' }}
-
+
+