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.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# `packages/agent/` — 穿搭 Agent
|
||||
|
||||
**职责**(架构 §4.1):会话管理、意图识别、上下文组装、生成编排。
|
||||
**不负责**:知识检索实现(调 `packages/retrieval`)。
|
||||
|
||||
## 已实现
|
||||
|
||||
只有 `service.py` 里那条最小编排:配额预扣 → LLM 网关 → 成本落库 →
|
||||
提交或退还。这是**垂直切片**,用来证明地基通了,不是 Agent 功能。
|
||||
|
||||
## 架构 §7.2 的完整链路(九步,目前只做了第 5 步)
|
||||
|
||||
| 步骤 | 状态 |
|
||||
| --- | --- |
|
||||
| ① 输入审核(TMS + 备案关键词库),fail-close | ❌ CR-06 |
|
||||
| ② 意图识别(含越界:医疗、身材评判) | ❌ FR-AGT-02 |
|
||||
| ③ 上下文组装(token 预算严格分配) | 部分:只有固定前缀 + 当前输入 |
|
||||
| ④ 混合检索 | ❌ FR-AGT-04 |
|
||||
| ⑤ LLM 生成(流式 SSE) | 部分:非流式 |
|
||||
| ⑥ 生成后校验(禁忌违反 = 0、色值在板内) | ❌ FR-AGT-05 |
|
||||
| ⑦ 输出审核 | ❌ CR-06 |
|
||||
| ⑧ 落库 message + retrieval_trace + rule_hits + 成本 | 部分:只有成本 |
|
||||
| ⑨ 异步偏好抽取 → 写 L2 记忆 | ❌ FR-MEM-03 |
|
||||
|
||||
表 `ag_conversation` / `ag_message` / `ag_feedback` 尚未创建。
|
||||
|
||||
## Token 预算(架构 §7.2,必须硬性限制)
|
||||
|
||||
| 部分 | 预算 |
|
||||
| --- | --- |
|
||||
| 系统提示词(固定前缀,放最前以命中缓存) | ≤ 800 |
|
||||
| 画像摘要 | ≤ 500 |
|
||||
| 会话摘要 | ≤ 400 |
|
||||
| 检索知识 | ≤ 1500 |
|
||||
| 用户当前输入 | ≤ 500 |
|
||||
| **输入合计** | **≤ 3700** |
|
||||
| 输出 | ≤ 800 |
|
||||
|
||||
超预算时**按优先级裁剪而不是报错**(FR-AGT-03)。
|
||||
|
||||
## Prompt 注入防护(NFR-SEC-04)
|
||||
|
||||
用户输入与系统提示词严格分区;输出侧校验;**禁止用户内容影响工具调用决策**。
|
||||
@@ -0,0 +1 @@
|
||||
"""agent 域:会话管理、上下文组装、生成编排。"""
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user