Files
meida_backend/packages/agent/service.py
T
xsl 7c67506474 feat(domains): identity, billing, task services and domain placeholders
Billing implements reserve -> commit -> refund against bl_quota and bl_ledger
with row-level locking and idempotency keys; the refund commits on its own
transaction so a failed request cannot silently keep a user's quota. Task
implements the unified task centre over the state machine. Identity issues and
resolves users. Remaining domains are placeholders whose READMEs state what
belongs there and what must not.
2026-08-14 00:26:39 +08:00

122 lines
4.1 KiB
Python
Executable File

"""Agent 对话编排 — the scaffold's thin vertical slice.
It walks the full 架构文档 §7.2 spine but only the load-bearing parts:
① 输入审核 ← NOT implemented (compliance.moderate, CR-06)
② 意图识别 ← NOT implemented (FR-AGT-02)
③ 上下文组装 ← only the fixed system prefix + current turn
④ 混合检索 ← 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
⑨ 异步偏好抽取 ← 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.
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 dataclasses import dataclass
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from packages.billing.service import BillingService, Reservation
from packages.llm.gateway import LLMGateway
from packages.llm.types import Scene
from packages.shared.logging import get_logger
logger = get_logger(__name__)
QUOTA_KEY_AGENT_CHAT = "agent_chat"
REF_TYPE = "agent_chat"
@dataclass(frozen=True, slots=True)
class ChatResult:
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,
) -> None:
self._session = session
self._gateway = gateway
self._billing = billing
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,
request_key=request_key,
amount=1,
reason="agent chat turn",
ref_type=REF_TYPE,
ref_id=request_key,
)
await self._session.commit()
try:
completion = await self._gateway.complete(
scene=Scene.AGENT_CHAT,
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 self._session.commit()
return ChatResult(
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 _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)