"""Agent 对话编排(架构 §7.2)。 ① 输入审核 ← NOT implemented (compliance.moderate, CR-06) ② 意图识别 ← NOT implemented (FR-AGT-02) ③ 上下文组装 ← IMPLEMENTED:画像摘要 + 最近 N 轮历史(context.py) ④ 混合检索 ← NOT implemented (retrieval-svc, FR-AGT-04) ⑤ LLM 生成 ← IMPLEMENTED (non-streaming) ⑥ 生成后校验 ← NOT implemented (FR-AGT-05) ⑦ 输出审核 ← NOT implemented (CR-06) ⑧ 落库 + 成本 ← IMPLEMENTED:ag_message 双行 + ob_ai_call 成本行 ⑨ 异步偏好抽取 ← NOT implemented (FR-MEM-03) 会话持久化(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; if the call fails, the refund is committed before the error propagates, so the 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 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 prompt_id: str | None prompt_version: str | None call_id: int tokens_in: int tokens_out: int tokens_cached: int latency_ms: int cost_cents: Decimal quota_remaining: int reserve_tx_id: int class AgentService: def __init__( self, session: AsyncSession, *, 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) ) reservation = await self._billing.reserve( user_id=user_id, quota_key=QUOTA_KEY_AGENT_CHAT, request_key=request_key, amount=1, reason="agent chat turn", ref_type=REF_TYPE, ref_id=request_key, ) 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, ref_id=request_key, ) except Exception as exc: await self._refund(reservation, reason=type(exc).__name__) 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, prompt_id=completion.prompt_id, prompt_version=completion.prompt_version, call_id=completion.call_id, 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, quota_remaining=reservation.remaining, 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) await self._session.commit() except Exception: await self._session.rollback() logger.exception("quota_refund_failed", tx_id=reservation.tx_id)