feat(agent,report): 会话持久化与画像注入、报告反馈与预约留资——PM/开发双Agent迭代1-3
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s

- agent: ag_conversation/ag_message 表(迁移0007),上下文组装注入最近报告画像摘要
  (report.profile.latest_profile_summary 经 apps 层闭包注入,同层不互调),历史带最近
  AGENT_HISTORY_TURNS=10 轮;POST/GET /agent/conversations 与消息历史接口
- agent 修复:ChatRequest.conversation_id 改 str(雪花ID超2^53,JS Number丢精度);
  _build_context 挪进 reserve commit 后同一 try,异常走 refund 兜底
- report: rp_feedback(维度反馈 upsert,认同率/修正率 SQL 可算,T-P3-21 最小版——
  L1 记忆未建,不回写结论)+ rp_reservation(预约留资幂等,T-P3-22,预约率口径
  行数/full_report.card_view 去重曝光,门槛≥8%),迁移0008
- 测试:全量 606 passed;ruff/mypy/lint-imports 全绿;E2E 真实 deepseek-v4-flash 验证
  画像注入与历史携带
This commit is contained in:
xsl
2026-09-11 21:50:58 +08:00
parent 00ef56b08b
commit 7bcbb8d40b
25 changed files with 1860 additions and 25 deletions
+17 -1
View File
@@ -15,6 +15,7 @@ from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from packages.agent.context import PROFILE_SUMMARY_MAX_CHARS
from packages.agent.service import AgentService
from packages.analytics.service import AnalyticsService
from packages.billing.service import BillingService
@@ -34,6 +35,7 @@ from packages.llm.providers.deepseek import DeepSeekProvider
from packages.llm.providers.zhipu import ZhipuGLMProvider
from packages.llm.recording import PrometheusAICallRecorder, SqlAlchemyAICallRecorder
from packages.llm.routing import SceneRouter, build_scene_configs
from packages.report.profile import latest_profile_summary
from packages.shared.config import Settings, get_settings
from packages.shared.context import bind_context
from packages.shared.db import get_session_factory
@@ -210,8 +212,22 @@ def agent_service_dep(
session: SessionDep,
gateway: GatewayDep,
billing: Annotated[BillingService, Depends(billing_service_dep)],
settings: SettingsDep,
) -> AgentService:
return AgentService(session, gateway=gateway, billing=billing)
# agent 与 report 在 import-linter 分层里同层、不允许直接互调,画像摘要
# 以闭包在 apps 层接线;report 域内部仍只有 report.repo 触 rp_report。
async def profile_provider(user_id: int) -> str | None:
return await latest_profile_summary(
session, user_id=user_id, max_chars=PROFILE_SUMMARY_MAX_CHARS
)
return AgentService(
session,
gateway=gateway,
billing=billing,
profile_provider=profile_provider,
history_turns=settings.agent_history_turns,
)
IdentityServiceDep = Annotated[IdentityService, Depends(identity_service_dep)]
+2
View File
@@ -21,6 +21,7 @@ from apps.api.routers import (
kb,
quota,
reports,
reservations,
tasks,
uploads,
)
@@ -90,6 +91,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
uploads.router,
kb.router,
reports.router,
reservations.router,
)
for router in routers:
app.include_router(router, prefix=cfg.api_prefix)
+71 -5
View File
@@ -1,9 +1,12 @@
"""``POST /agent/chat`` — the vertical slice.
"""``POST /agent/chat`` + 会话持久化接口(FR-AGT-09 / T-P4-01)。
One request exercises: JWT auth → quota reserve → LLM gateway (routing, prompt
version, cache-prefix check, daily budget) → ``ob_ai_call`` cost row → quota
commit/refund → unified envelope. Streaming (SSE, FR-AGT-01) is intentionally
out of scope for the scaffold.
commit/refund → ``ag_message`` 双行落库 → unified envelope. Streaming (SSE,
FR-AGT-01) is intentionally out of scope for the scaffold.
会话接口:创建 / 列表 / 历史。chat 不带 conversation_id 时隐式建会话并在
响应里带回;带则做归属校验(他人会话与不存在同口径 404,防 ID 遍历)。
"""
from __future__ import annotations
@@ -13,7 +16,13 @@ import uuid
from fastapi import APIRouter
from apps.api.deps import AgentServiceDep, CurrentUserId
from apps.api.schemas import ChatRequest, ChatResponse, ChatUsage
from apps.api.schemas import (
ChatMessageItem,
ChatRequest,
ChatResponse,
ChatUsage,
ConversationResponse,
)
from packages.llm.errors import (
AllProvidersFailedError,
CachePrefixViolationError,
@@ -22,11 +31,62 @@ from packages.llm.errors import (
StructuredOutputError,
)
from packages.shared.errors import AppError, ErrorCode
from packages.shared.models import AgConversation
from packages.shared.response import ok
router = APIRouter(prefix="/agent", tags=["agent"])
def _conversation_payload(conversation: AgConversation) -> ConversationResponse:
return ConversationResponse(
conversation_id=str(conversation.conversation_id),
title=conversation.title,
message_count=conversation.message_count,
last_message_at=conversation.last_message_at,
created_at=conversation.created_at,
)
@router.post("/conversations", summary="创建空会话(FR-AGT-09)")
async def create_conversation(
user_id: CurrentUserId,
agent: AgentServiceDep,
) -> dict[str, object]:
conversation = await agent.create_conversation(user_id=user_id)
return ok(_conversation_payload(conversation).model_dump(mode="json"))
@router.get("/conversations", summary="会话列表(最近活跃倒序)")
async def list_conversations(
user_id: CurrentUserId,
agent: AgentServiceDep,
) -> dict[str, object]:
conversations = await agent.list_conversations(user_id=user_id)
return ok([_conversation_payload(c).model_dump(mode="json") for c in conversations])
@router.get("/conversations/{conversation_id}/messages", summary="会话历史(时间正序)")
async def list_messages(
conversation_id: int,
user_id: CurrentUserId,
agent: AgentServiceDep,
) -> dict[str, object]:
messages = await agent.list_messages(user_id=user_id, conversation_id=conversation_id)
return ok(
[
ChatMessageItem(
message_id=str(m.message_id),
role=m.role,
content=m.content,
model=m.model,
call_id=str(m.call_id) if m.call_id is not None else None,
created_at=m.created_at,
).model_dump(mode="json")
for m in messages
]
)
@router.post("/chat", summary="Single-turn styling chat (non-streaming)")
async def chat(
payload: ChatRequest,
@@ -35,7 +95,12 @@ async def chat(
) -> dict[str, object]:
request_key = payload.request_key or uuid.uuid4().hex
try:
result = await agent.chat(user_id=user_id, message=payload.message, request_key=request_key)
result = await agent.chat(
user_id=user_id,
message=payload.message,
request_key=request_key,
conversation_id=payload.conversation_id,
)
except DailyBudgetExceededError as exc:
raise AppError(ErrorCode.LLM_DAILY_BUDGET_EXCEEDED) from exc
except SceneDegradedError as exc:
@@ -50,6 +115,7 @@ async def chat(
return ok(
ChatResponse(
conversation_id=str(result.conversation_id),
reply=result.reply,
provider=result.provider,
model=result.model,
+57
View File
@@ -17,7 +17,11 @@ from fastapi.responses import FileResponse
from apps.api.deps import BudgetGuardDep, CurrentUserId, SessionDep, SettingsDep
from packages.report.export_service import ReportExportService
from packages.report.feedback_service import FeedbackService
from packages.report.schemas import (
FeedbackCreateRequest,
FeedbackItem,
FeedbackUpsertResponse,
ReportCreateRequest,
ReportExportRequest,
ReportExportResponse,
@@ -88,6 +92,59 @@ def report_export_service_dep(
ReportExportServiceDep = Annotated[ReportExportService, Depends(report_export_service_dep)]
def feedback_service_dep(session: SessionDep) -> FeedbackService:
return FeedbackService(session)
FeedbackServiceDep = Annotated[FeedbackService, Depends(feedback_service_dep)]
@router.post(
"/{report_id}/feedback",
summary="报告维度结论反馈(T-P3-21:同维度重复 = 覆盖更新,upsert 语义)",
)
async def upsert_report_feedback(
report_id: int,
payload: FeedbackCreateRequest,
user_id: CurrentUserId,
service: FeedbackServiceDep,
) -> dict[str, object]:
upsert = await service.upsert_feedback(
user_id=user_id,
report_id=report_id,
dimension_key=payload.dimension_key,
verdict=payload.verdict,
correction=payload.correction,
)
return ok(
FeedbackUpsertResponse(
dimension_key=upsert.feedback.dimension_key,
verdict=upsert.feedback.verdict,
updated=upsert.updated,
).model_dump()
)
@router.get("/{report_id}/feedback", summary="报告已提交的维度反馈列表(归属校验)")
async def list_report_feedback(
report_id: int,
user_id: CurrentUserId,
service: FeedbackServiceDep,
) -> dict[str, object]:
rows = await service.list_feedback(user_id=user_id, report_id=report_id)
return ok(
[
FeedbackItem(
dimension_key=row.dimension_key,
verdict=row.verdict,
correction=row.correction,
created_at=row.created_at,
).model_dump()
for row in rows
]
)
@router.post("", summary="生成第一份美学报告(终身一次,走 report_basic 配额)")
async def create_report(
payload: ReportCreateRequest,
+55
View File
@@ -0,0 +1,55 @@
"""``POST /reservations``、``GET /reservations``(T-P3-22 预约留资)。
topic 预留通用性,首期只有 full_report(全面报告上线提醒)。
幂等口径:重复预约返回已有记录(created=false,HTTP 仍 200)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from apps.api.deps import CurrentUserId, SessionDep
from packages.report.reservation_service import ReservationService
from packages.report.schemas import (
ReservationCreateRequest,
ReservationListResponse,
ReservationResponse,
)
from packages.shared.response import ok
router = APIRouter(prefix="/reservations", tags=["reservations"])
def reservation_service_dep(session: SessionDep) -> ReservationService:
return ReservationService(session)
ReservationServiceDep = Annotated[ReservationService, Depends(reservation_service_dep)]
@router.post("", summary="预约留资(幂等:重复预约 created=false)")
async def create_reservation(
payload: ReservationCreateRequest,
user_id: CurrentUserId,
service: ReservationServiceDep,
) -> dict[str, object]:
result = await service.reserve(user_id=user_id, topic=payload.topic)
return ok(
ReservationResponse(
topic=result.reservation.topic,
created=result.created,
created_at=result.reservation.created_at,
).model_dump()
)
@router.get("", summary="我的预约主题列表(topic 可选,缺省返回全部)")
async def list_reservations(
user_id: CurrentUserId,
service: ReservationServiceDep,
topic: str | None = Query(default=None, max_length=32),
) -> dict[str, object]:
topics = await service.list_topics(user_id=user_id, topic=topic)
return ok(ReservationListResponse(topics=topics).model_dump())
+26
View File
@@ -30,6 +30,12 @@ class DevTokenResponse(BaseModel):
class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=2000)
conversation_id: str | None = Field(
default=None,
max_length=32,
description="会话 ID(字符串;雪花 ID 超 2^53,JS Number 会丢精度)。"
"缺省时新建会话并在响应中返回 (FR-AGT-09)",
)
request_key: str | None = Field(
default=None,
max_length=128,
@@ -37,6 +43,25 @@ class ChatRequest(BaseModel):
)
class ConversationResponse(BaseModel):
conversation_id: str = Field(description="雪花 ID 以字符串返回,避免 JS 精度丢失")
title: str
message_count: int
last_message_at: datetime | None
created_at: datetime
class ChatMessageItem(BaseModel):
message_id: str
role: str
content: str
model: str | None
call_id: str | None = Field(
description="assistant 消息关联的 ob_ai_call 行(复盘锚点);user 消息为 null"
)
created_at: datetime
class ChatUsage(BaseModel):
tokens_in: int
tokens_out: int
@@ -46,6 +71,7 @@ class ChatUsage(BaseModel):
class ChatResponse(BaseModel):
conversation_id: str
reply: str
provider: str
model: str