feat: AI 摘要持久化 — DB 缓存 + 自动加载

后端:
- 新增 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 生成摘要', 有缓存→'重新生成'
- 标题栏显示生成时间戳
This commit is contained in:
2026-06-25 11:45:04 +08:00
parent e681beba24
commit 5b38246801
6 changed files with 175 additions and 19 deletions
+47 -13
View File
@@ -1,4 +1,4 @@
"""AI summary endpoints — LLM-powered weekly report narrative.""" """AI summary endpoints — LLM-powered weekly report narrative with caching."""
import uuid import uuid
from datetime import date from datetime import date
@@ -7,34 +7,68 @@ from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.middleware.auth import get_current_user 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 = APIRouter(prefix="/ai", tags=["AI Summary"])
@router.post("/summary") def _check_role(role: str):
async def ai_summary( 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), reference_date: Optional[str] = Query(None),
period: str = Query("week", regex="^(week|month)$"), period: str = Query("week", regex="^(week|month)$"),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Generate AI-powered weekly report summary. Director/leader only.""" """Get cached AI summary for this week. Returns null if none exists."""
role = current_user["role"] _check_role(current_user["role"])
if role not in ("director", "leader"): cached = await get_cached_summary(
raise HTTPException(status_code=403, detail="仅限支局长和分管领导使用") 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: try:
ref = date.fromisoformat(reference_date) if reference_date else None return await generate_summary(
summary = await generate_summary(
db=db, db=db,
user_id=uuid.UUID(current_user["user_id"]), user_id=uuid.UUID(current_user["user_id"]),
role=role, role=current_user["role"],
reference_date=ref, reference_date=_parse_ref(reference_date),
period=period, period=period,
) )
return {"summary": summary, "period": period, "reference_date": str(ref or date.today())}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"AI 摘要生成失败:{str(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}
+2
View File
@@ -7,6 +7,7 @@ from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote from app.models.daily_note import DailyNote
from app.models.ai_summary import AISummary
__all__ = [ __all__ = [
"User", "User",
@@ -18,4 +19,5 @@ __all__ = [
"MiniBusiness", "MiniBusiness",
"KeyVisit", "KeyVisit",
"DailyNote", "DailyNote",
"AISummary",
] ]
+21
View File
@@ -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())
+83 -4
View File
@@ -5,9 +5,11 @@ import httpx
from app.config import settings from app.config import settings
from app.services.dashboard import get_weekly_report, get_week_range from app.services.dashboard import get_weekly_report, get_week_range
from app.services.light_board import get_light_board 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 sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID from uuid import UUID
from datetime import date from datetime import date, datetime
SUMMARY_SYSTEM_PROMPT = """你是一位经验丰富的政企客户经理团队的周报分析助手。你的分析将被直接提交给支局长作为工作周报的文字摘要。 SUMMARY_SYSTEM_PROMPT = """你是一位经验丰富的政企客户经理团队的周报分析助手。你的分析将被直接提交给支局长作为工作周报的文字摘要。
@@ -141,14 +143,70 @@ def build_summary_prompt(
return data_block 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( async def generate_summary(
db: AsyncSession, db: AsyncSession,
user_id: UUID, user_id: UUID,
role: str, role: str,
reference_date: date | None = None, reference_date: date | None = None,
period: str = "week", period: str = "week",
) -> str: ) -> dict:
"""Generate an AI-powered weekly summary. """Generate an AI-powered weekly summary, save to DB, return it.
Raises ValueError if AI config is missing, httpx.HTTPError on API failure. Raises ValueError if AI config is missing, httpx.HTTPError on API failure.
""" """
@@ -157,6 +215,7 @@ async def generate_summary(
# Gather data # Gather data
ref = reference_date or date.today() ref = reference_date or date.today()
monday, sunday = get_week_range(ref)
weekly_report = await get_weekly_report( weekly_report = await get_weekly_report(
db=db, user_id=user_id, role=role, reference_date=ref, db=db, user_id=user_id, role=role, reference_date=ref,
) )
@@ -187,4 +246,24 @@ async def generate_summary(
if not content: if not content:
raise ValueError("AI returned empty response") 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,
}
+3
View File
@@ -1,6 +1,9 @@
import api from './index' import api from './index'
export const aiApi = { export const aiApi = {
getSummary(params?: { reference_date?: string; period?: string }) {
return api.get('/ai/summary', { params })
},
generateSummary(params?: { reference_date?: string; period?: string }) { generateSummary(params?: { reference_date?: string; period?: string }) {
return api.post('/ai/summary', null, { params }) return api.post('/ai/summary', null, { params })
}, },
+19 -2
View File
@@ -16,6 +16,8 @@ const loading = ref(false)
const aiLoading = ref(false) const aiLoading = ref(false)
const aiSummary = ref('') const aiSummary = ref('')
const aiError = ref('') const aiError = ref('')
const aiCached = ref(false)
const aiCreatedAt = ref('')
const activeTab = ref('visits') const activeTab = ref('visits')
const filterManagerId = ref('') const filterManagerId = ref('')
const filterCustomerId = ref('') const filterCustomerId = ref('')
@@ -47,6 +49,17 @@ onMounted(async () => {
managers.value = mRes.data managers.value = mRes.data
customers.value = cRes.data.items || cRes.data customers.value = cRes.data.items || cRes.data
} catch (_) {} } 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() } function changeWeek(delta: number) { weekOffset.value += delta; loadReport() }
@@ -106,9 +119,12 @@ async function generateAISummary() {
aiLoading.value = true aiLoading.value = true
aiSummary.value = '' aiSummary.value = ''
aiError.value = '' aiError.value = ''
aiCached.value = false
try { try {
const res = await aiApi.generateSummary({ reference_date: getRefDate(), period: 'week' }) const res = await aiApi.generateSummary({ reference_date: getRefDate(), period: 'week' })
aiSummary.value = res.data.summary aiSummary.value = res.data.summary
aiCached.value = !!res.data.cached
aiCreatedAt.value = res.data.created_at || ''
} catch (e: any) { } catch (e: any) {
const detail = e.response?.data?.detail || 'AI 摘要生成失败,请检查 AI 服务配置或稍后重试' const detail = e.response?.data?.detail || 'AI 摘要生成失败,请检查 AI 服务配置或稍后重试'
aiError.value = detail aiError.value = detail
@@ -202,7 +218,7 @@ const notesByDate = computed(() => {
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon> <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg> </svg>
AI 生成摘要 {{ aiCached ? '重新生成' : 'AI 生成摘要' }}
</el-button> </el-button>
<el-button v-if="auth.isDirector" type="success" @click="handleExport"> <el-button v-if="auth.isDirector" type="success" @click="handleExport">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
@@ -245,7 +261,8 @@ const notesByDate = computed(() => {
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon> <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg> </svg>
<span class="ai-header-title">AI 周报摘要</span> <span class="ai-header-title">AI 周报摘要</span>
<span class="ai-header-hint">基于本周拜访数据自动生成仅供参考</span> <span v-if="aiCached && aiCreatedAt" class="ai-header-hint">生成于 {{ new Date(aiCreatedAt).toLocaleString('zh-CN') }} · 已缓存</span>
<span v-else class="ai-header-hint">基于本周拜访数据自动生成仅供参考</span>
</div> </div>
<div class="ai-header-actions"> <div class="ai-header-actions">
<el-button v-if="aiSummary" size="small" text @click="copySummary"> <el-button v-if="aiSummary" size="small" text @click="copySummary">