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:
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user