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:
@@ -14,15 +14,16 @@
|
||||
| --- | --- |
|
||||
| ① 输入审核(TMS + 备案关键词库),fail-close | ❌ CR-06 |
|
||||
| ② 意图识别(含越界:医疗、身材评判) | ❌ FR-AGT-02 |
|
||||
| ③ 上下文组装(token 预算严格分配) | 部分:只有固定前缀 + 当前输入 |
|
||||
| ③ 上下文组装(token 预算严格分配) | 部分:画像摘要 + 最近 N 轮历史(`AGENT_HISTORY_TURNS`,默认 10);检索知识与会话摘要未做 |
|
||||
| ④ 混合检索 | ❌ FR-AGT-04 |
|
||||
| ⑤ LLM 生成(流式 SSE) | 部分:非流式 |
|
||||
| ⑥ 生成后校验(禁忌违反 = 0、色值在板内) | ❌ FR-AGT-05 |
|
||||
| ⑦ 输出审核 | ❌ CR-06 |
|
||||
| ⑧ 落库 message + retrieval_trace + rule_hits + 成本 | 部分:只有成本 |
|
||||
| ⑨ 异步偏好抽取 → 写 L2 记忆 | ❌ FR-MEM-03 |
|
||||
| ⑧ 落库 message + retrieval_trace + rule_hits + 成本 | 部分:ag_message 双行 + 成本已实现;retrieval_trace / rule_hits 列已建、待步骤④填充 |
|
||||
|
||||
表 `ag_conversation` / `ag_message` / `ag_feedback` 尚未创建。
|
||||
表 `ag_conversation` / `ag_message` 已建(迁移 0007,T-P4-01);`ag_feedback` 尚未创建。
|
||||
画像摘要经 `report.profile.latest_profile_summary` 由 apps 层闭包注入
|
||||
(agent 与 report 同层,不允许直接互调)。
|
||||
|
||||
## Token 预算(架构 §7.2,必须硬性限制)
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""上下文组装(架构 §7.2 步骤③)的纯逻辑:无 IO、无 LLM 调用,可单测。
|
||||
|
||||
预算分配(packages/agent/README.md 的预算表):
|
||||
|
||||
- 画像摘要 ≤ 500 token —— 按中文约 1.6 字符/token 保守取 800 字符上限,
|
||||
摘要本身的生产侧(report 域)按 ``PROFILE_SUMMARY_MAX_CHARS`` 裁剪;
|
||||
- 历史轮数默认 10 轮(``AGENT_HISTORY_TURNS``),一轮 = user + assistant 两条。
|
||||
|
||||
缓存纪律:画像与历史都是**逐用户逐轮变化**的内容,一律走 gateway 的
|
||||
``messages`` 通道排在固定 system 前缀之后,绝不进 ``system_prefix``——
|
||||
否则缓存命中单价 1/30 的红利会被画像差异完全打掉(README「两件容易踩的事」)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from packages.llm.types import Message
|
||||
|
||||
#: 画像摘要载荷上限(字符),对应 ≤ 500 token 的预算行。
|
||||
PROFILE_SUMMARY_MAX_CHARS = 800
|
||||
|
||||
#: 画像摘要的消息壳。摘要是 JSON 素材(report 域 compose_prompt_material 的
|
||||
#: 产物),需要这句说明告诉模型怎么用它;措辞保持中性,不引入新规则。
|
||||
_PROFILE_MESSAGE_TEMPLATE = (
|
||||
"以下是该用户最近一份美学诊断报告的画像摘要(JSON),"
|
||||
"回答时据此给出贴合其条件的建议;用户未问及时不要主动复述全文。\n"
|
||||
)
|
||||
|
||||
#: 单条历史消息的字符上限:用户输入已被 ChatRequest 卡在 2000 字符,
|
||||
#: assistant 回复被提示词约束在 300 字以内,这里只做兜底截断防脏数据。
|
||||
_HISTORY_MESSAGE_MAX_CHARS = 2000
|
||||
|
||||
|
||||
def build_profile_message(profile_summary: str) -> Message:
|
||||
return Message(role="system", content=_PROFILE_MESSAGE_TEMPLATE + profile_summary)
|
||||
|
||||
|
||||
def build_context_messages(
|
||||
*,
|
||||
profile_summary: str | None,
|
||||
history: Sequence[Message],
|
||||
history_turns: int,
|
||||
) -> list[Message]:
|
||||
"""组装 gateway ``messages`` 通道的内容(固定前缀与当前输入由 gateway 自己拼)。
|
||||
|
||||
- 画像摘要在最前(无画像用户为 None 即不注入,走通用建议降级路径 FR-AGT-10);
|
||||
- 历史按时间正序,只保留最近 ``history_turns`` 轮(一轮 = user+assistant 两条),
|
||||
截尾不截头——越早的轮次对当前回答价值越低。
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
if profile_summary:
|
||||
messages.append(build_profile_message(profile_summary))
|
||||
tail = list(history)[-(history_turns * 2) :] if history_turns > 0 else []
|
||||
for item in tail:
|
||||
messages.append(Message(role=item.role, content=item.content[:_HISTORY_MESSAGE_MAX_CHARS]))
|
||||
return messages
|
||||
@@ -0,0 +1,108 @@
|
||||
"""ag_conversation / ag_message 的数据访问。**只有本模块可以触这两张表**。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.shared.models import AgConversation, AgMessage
|
||||
|
||||
ROLE_USER = "user"
|
||||
ROLE_ASSISTANT = "assistant"
|
||||
|
||||
|
||||
async def create_conversation(
|
||||
session: AsyncSession, *, conversation_id: int, user_id: int
|
||||
) -> AgConversation:
|
||||
conversation = AgConversation(conversation_id=conversation_id, user_id=user_id)
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
return conversation
|
||||
|
||||
|
||||
async def get_conversation(session: AsyncSession, conversation_id: int) -> AgConversation | None:
|
||||
return await session.get(AgConversation, conversation_id)
|
||||
|
||||
|
||||
async def list_conversations(
|
||||
session: AsyncSession, *, user_id: int, limit: int = 20
|
||||
) -> list[AgConversation]:
|
||||
"""会话列表:按最近活跃倒序;从未发言的会话(last_message_at 为 NULL)排最后。"""
|
||||
result = await session.execute(
|
||||
select(AgConversation)
|
||||
.where(AgConversation.user_id == user_id)
|
||||
.order_by(AgConversation.last_message_at.desc().nulls_last())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_messages(
|
||||
session: AsyncSession, *, conversation_id: int, limit: int = 100
|
||||
) -> list[AgMessage]:
|
||||
"""历史接口用:按时间正序返回。"""
|
||||
result = await session.execute(
|
||||
select(AgMessage)
|
||||
.where(AgMessage.conversation_id == conversation_id)
|
||||
.order_by(AgMessage.created_at.asc(), AgMessage.message_id.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_recent_messages(
|
||||
session: AsyncSession, *, conversation_id: int, limit: int
|
||||
) -> list[AgMessage]:
|
||||
"""上下文组装用:取最近 ``limit`` 条,返回时按时间正序(旧→新)。"""
|
||||
result = await session.execute(
|
||||
select(AgMessage)
|
||||
.where(AgMessage.conversation_id == conversation_id)
|
||||
.order_by(AgMessage.created_at.desc(), AgMessage.message_id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(reversed(result.scalars().all()))
|
||||
|
||||
|
||||
async def append_message(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
message_id: int,
|
||||
conversation: AgConversation,
|
||||
role: str,
|
||||
content: str,
|
||||
call_id: int | None = None,
|
||||
model: str | None = None,
|
||||
tokens_in: int | None = None,
|
||||
tokens_out: int | None = None,
|
||||
tokens_cached: int | None = None,
|
||||
latency_ms: int | None = None,
|
||||
cost_cents: Decimal | None = None,
|
||||
) -> AgMessage:
|
||||
"""追加消息并推进会话的 message_count / last_message_at / title。
|
||||
|
||||
title 只在首条用户消息时落定(截断前 20 字),之后不再改。
|
||||
"""
|
||||
message = AgMessage(
|
||||
message_id=message_id,
|
||||
conversation_id=conversation.conversation_id,
|
||||
user_id=conversation.user_id,
|
||||
role=role,
|
||||
content=content,
|
||||
call_id=call_id,
|
||||
model=model,
|
||||
tokens_in=tokens_in,
|
||||
tokens_out=tokens_out,
|
||||
tokens_cached=tokens_cached,
|
||||
latency_ms=latency_ms,
|
||||
cost_cents=cost_cents,
|
||||
)
|
||||
session.add(message)
|
||||
conversation.message_count += 1
|
||||
conversation.last_message_at = datetime.now(UTC)
|
||||
if not conversation.title and role == ROLE_USER:
|
||||
conversation.title = content[:20]
|
||||
await session.flush()
|
||||
return message
|
||||
+127
-10
@@ -1,20 +1,23 @@
|
||||
"""Agent 对话编排 — the scaffold's thin vertical slice.
|
||||
|
||||
It walks the full 架构文档 §7.2 spine but only the load-bearing parts:
|
||||
"""Agent 对话编排(架构 §7.2)。
|
||||
|
||||
① 输入审核 ← NOT implemented (compliance.moderate, CR-06)
|
||||
② 意图识别 ← NOT implemented (FR-AGT-02)
|
||||
③ 上下文组装 ← only the fixed system prefix + current turn
|
||||
③ 上下文组装 ← IMPLEMENTED:画像摘要 + 最近 N 轮历史(context.py)
|
||||
④ 混合检索 ← NOT implemented (retrieval-svc, FR-AGT-04)
|
||||
⑤ LLM 生成 ← IMPLEMENTED (non-streaming)
|
||||
⑥ 生成后校验 ← NOT implemented (FR-AGT-05)
|
||||
⑦ 输出审核 ← NOT implemented (CR-06)
|
||||
⑧ 落库 + 成本 ← cost only; ag_message is not in this scaffold
|
||||
⑧ 落库 + 成本 ← IMPLEMENTED:ag_message 双行 + ob_ai_call 成本行
|
||||
⑨ 异步偏好抽取 ← NOT implemented (FR-MEM-03)
|
||||
|
||||
What *is* fully wired is the plumbing every future feature needs: JWT identity,
|
||||
the quota three-phase protocol, the LLM gateway, cost attribution, and the
|
||||
unified envelope.
|
||||
会话持久化(FR-AGT-09 / T-P4-01):会话与消息落 ``ag_conversation`` /
|
||||
``ag_message``;assistant 行的 ``call_id`` 关联 ``ob_ai_call``,每条回答可
|
||||
复盘到模型、token 与成本。检索轨迹与规则命中列已建,等步骤④落地后填充。
|
||||
|
||||
画像注入:用户最近一份 succeeded 报告的画像摘要以独立 system 消息排在固定
|
||||
前缀之后(不进缓存前缀,理由见 context.py)。摘要的读取由 apps 层以
|
||||
``profile_provider`` 闭包注入——agent 与 report 在 import-linter 分层里是
|
||||
同层,不允许直接互调。
|
||||
|
||||
Transaction discipline: the reservation is committed **before** the LLM call.
|
||||
If the process dies mid-call, the reservation survives and can be reconciled;
|
||||
@@ -24,24 +27,46 @@ API layer's rollback cannot undo it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from packages.agent import repo
|
||||
from packages.agent.context import build_context_messages
|
||||
from packages.billing.service import BillingService, Reservation
|
||||
from packages.domain.ids import next_id
|
||||
from packages.llm.gateway import LLMGateway
|
||||
from packages.llm.types import Scene
|
||||
from packages.llm.types import Message, Scene
|
||||
from packages.shared.errors import NotFoundError
|
||||
from packages.shared.logging import get_logger
|
||||
from packages.shared.models import AgConversation, AgMessage
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
QUOTA_KEY_AGENT_CHAT = "agent_chat"
|
||||
REF_TYPE = "agent_chat"
|
||||
|
||||
#: 画像提供者:user_id → 画像摘要素材(无报告用户返回 None)。由 apps 层接线。
|
||||
ProfileProvider = Callable[[int], Awaitable[str | None]]
|
||||
|
||||
|
||||
def _parse_conversation_id(raw: str) -> int:
|
||||
"""wire 侧雪花 ID 是字符串(超 2^53,JS Number 会丢精度)。
|
||||
|
||||
非法值与「会话不存在」同口径 40400:不区分「格式错」与「不存在」,
|
||||
避免给遍历者任何额外的探测信号。
|
||||
"""
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise NotFoundError("会话不存在或已删除") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ChatResult:
|
||||
conversation_id: int
|
||||
reply: str
|
||||
provider: str
|
||||
model: str
|
||||
@@ -64,12 +89,55 @@ class AgentService:
|
||||
*,
|
||||
gateway: LLMGateway,
|
||||
billing: BillingService,
|
||||
profile_provider: ProfileProvider | None = None,
|
||||
history_turns: int = 10,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._gateway = gateway
|
||||
self._billing = billing
|
||||
self._profile_provider = profile_provider
|
||||
self._history_turns = history_turns
|
||||
|
||||
async def create_conversation(self, *, user_id: int) -> AgConversation:
|
||||
conversation = await repo.create_conversation(
|
||||
self._session, conversation_id=next_id(), user_id=user_id
|
||||
)
|
||||
await self._session.commit()
|
||||
return conversation
|
||||
|
||||
async def list_conversations(self, *, user_id: int) -> list[AgConversation]:
|
||||
return await repo.list_conversations(self._session, user_id=user_id)
|
||||
|
||||
async def list_messages(self, *, user_id: int, conversation_id: int) -> list[AgMessage]:
|
||||
await self.get_owned_conversation(user_id=user_id, conversation_id=conversation_id)
|
||||
return await repo.list_messages(self._session, conversation_id=conversation_id)
|
||||
|
||||
async def get_owned_conversation(self, *, user_id: int, conversation_id: int) -> AgConversation:
|
||||
conversation = await repo.get_conversation(self._session, conversation_id)
|
||||
if conversation is None:
|
||||
raise NotFoundError("会话不存在或已删除")
|
||||
if conversation.user_id != user_id:
|
||||
# 不暴露「存在但属于别人」:与 NOT_FOUND 同口径,防会话 ID 遍历
|
||||
raise NotFoundError("会话不存在或已删除")
|
||||
return conversation
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
message: str,
|
||||
request_key: str,
|
||||
conversation_id: str | None = None,
|
||||
) -> ChatResult:
|
||||
if conversation_id is None:
|
||||
conversation = await repo.create_conversation(
|
||||
self._session, conversation_id=next_id(), user_id=user_id
|
||||
)
|
||||
else:
|
||||
conversation = await self.get_owned_conversation(
|
||||
user_id=user_id, conversation_id=_parse_conversation_id(conversation_id)
|
||||
)
|
||||
|
||||
async def chat(self, *, user_id: int, message: str, request_key: str) -> ChatResult:
|
||||
reservation = await self._billing.reserve(
|
||||
user_id=user_id,
|
||||
quota_key=QUOTA_KEY_AGENT_CHAT,
|
||||
@@ -81,9 +149,13 @@ class AgentService:
|
||||
)
|
||||
await self._session.commit()
|
||||
|
||||
# 预扣已 commit,自此到 commit/reserve 冲销之间的任何异常都必须退还——
|
||||
# 包括上下文组装(历史消息/画像查询同样是 DB 调用,也会抛)。
|
||||
try:
|
||||
context = await self._build_context(user_id=user_id, conversation=conversation)
|
||||
completion = await self._gateway.complete(
|
||||
scene=Scene.AGENT_CHAT,
|
||||
messages=context,
|
||||
prompt_vars={"user_input": message},
|
||||
user_id=user_id,
|
||||
ref_type=REF_TYPE,
|
||||
@@ -94,9 +166,31 @@ class AgentService:
|
||||
raise
|
||||
|
||||
await self._billing.commit(reservation)
|
||||
await repo.append_message(
|
||||
self._session,
|
||||
message_id=next_id(),
|
||||
conversation=conversation,
|
||||
role=repo.ROLE_USER,
|
||||
content=message,
|
||||
)
|
||||
await repo.append_message(
|
||||
self._session,
|
||||
message_id=next_id(),
|
||||
conversation=conversation,
|
||||
role=repo.ROLE_ASSISTANT,
|
||||
content=completion.text,
|
||||
call_id=completion.call_id,
|
||||
model=completion.model,
|
||||
tokens_in=completion.usage.tokens_in,
|
||||
tokens_out=completion.usage.tokens_out,
|
||||
tokens_cached=completion.usage.tokens_cached,
|
||||
latency_ms=completion.latency_ms,
|
||||
cost_cents=completion.cost_cents,
|
||||
)
|
||||
await self._session.commit()
|
||||
|
||||
return ChatResult(
|
||||
conversation_id=conversation.conversation_id,
|
||||
reply=completion.text,
|
||||
provider=completion.provider,
|
||||
model=completion.model,
|
||||
@@ -112,6 +206,29 @@ class AgentService:
|
||||
reserve_tx_id=reservation.tx_id,
|
||||
)
|
||||
|
||||
async def _build_context(self, *, user_id: int, conversation: AgConversation) -> list[Message]:
|
||||
# 画像取不到(无报告 / 读取异常)不阻断对话:降级为通用建议(FR-AGT-10)。
|
||||
# NFR-SEC-03:摘要内容本身不进日志,只记有无与长度。
|
||||
profile_summary: str | None = None
|
||||
if self._profile_provider is not None:
|
||||
try:
|
||||
profile_summary = await self._profile_provider(user_id)
|
||||
except Exception:
|
||||
logger.warning("agent_profile_load_failed", user_id=user_id)
|
||||
history: list[Message] = []
|
||||
if conversation.message_count > 0:
|
||||
recent = await repo.list_recent_messages(
|
||||
self._session,
|
||||
conversation_id=conversation.conversation_id,
|
||||
limit=self._history_turns * 2,
|
||||
)
|
||||
history = [Message(role=item.role, content=item.content) for item in recent]
|
||||
return build_context_messages(
|
||||
profile_summary=profile_summary,
|
||||
history=history,
|
||||
history_turns=self._history_turns,
|
||||
)
|
||||
|
||||
async def _refund(self, reservation: Reservation, *, reason: str) -> None:
|
||||
try:
|
||||
await self._billing.refund(reservation, reason=reason)
|
||||
|
||||
Reference in New Issue
Block a user