feat(agent,report): 会话持久化与画像注入、报告反馈与预约留资——PM/开发双Agent迭代1-3
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
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:
+17
-1
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user