from dataclasses import dataclass from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator class AgentRequestMetadata(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) locale: str | None = None client_version: str | None = None ui_entry: str | None = None # 仅由受理服务写入 Outbox,客户端 API 不接收该字段。 chitchat_streak: int = Field(default=0, ge=0, le=5) # 客服澄清轮次只来自服务端会话行,客户端不得提交或覆盖。 clarification_round: int = Field(default=0, ge=0, le=2) # 仅供客服在当前短期会话内消解指代的已脱敏上下文,不是长期记忆或客户画像。 session_context: tuple[str, ...] = Field(default_factory=tuple, max_length=6) class ConversationTurn(BaseModel): """会话中的一轮对话(短期记忆)。 只保留角色与正文:模型用它解析指代("那它风险高吗"里的"它"指哪只基金), 不需要意图、置信度这类内部字段——把内部字段一并喂给模型既增加噪声, 也扩大了"模型看到不该看的东西"的面。 `subject` 是 `E-05` 的**显式主题声明**:由产生这轮回答的出口声明(产品 / 类目 / 条款名),读侧直接取用,不再从回答正文反解。默认为空串——存量消息 (本次改动之前落库的行)没有该字段,读侧会回落到原来的文本反解,因此 老数据与既有用例的行为一字不变。 """ model_config = ConfigDict(frozen=True) role: Literal["user", "assistant"] content: str subject: str = "" class AgentRequest(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) agent_type: str message: str session_id: str idempotency_key: str metadata: AgentRequestMetadata = Field(default_factory=AgentRequestMetadata) # 本会话中**本次之前**的对话,按时间正序(旧 → 新)。 # 默认空元组:既有构造点(API 受理路径、单测、验收脚本)无需改动即可继续工作。 history: tuple[ConversationTurn, ...] = () @field_validator("message") @classmethod def message_must_not_be_blank(cls, value: str) -> str: if not value.strip(): raise ValueError("message must not be blank") return value @field_validator("idempotency_key") @classmethod def idempotency_key_must_be_valid(cls, value: str) -> str: if not 16 <= len(value) <= 128 or not value.replace("-", "").replace("_", "").isalnum(): raise ValueError("idempotency_key must be 16-128 alphanumeric characters") return value class RequestContext(BaseModel): model_config = ConfigDict(frozen=True) user_id: str trace_id: str roles: tuple[str, ...] = () customer_ids: tuple[str, ...] = () data_scope: str = "self" portal: str = "api" clarification_round: int = Field(default=0, ge=0, le=10) permissions: tuple[str, ...] = () permission_scopes: dict[str, str] = Field(default_factory=dict) class AgentDefinition(BaseModel): model_config = ConfigDict(frozen=True) agent_type: str version: str allowed_tools: tuple[str, ...] = () allowed_roles: tuple[str, ...] = () allowed_portals: tuple[str, ...] = () supported_intents: tuple[str, ...] = ("general",) requires_model_intent_classification: bool = True # 长期/画像记忆属于客户数据能力;默认保留既有 Agent 行为,客服需显式关闭。 recalls_customer_memory: bool = True class ResolvedAgentConfig(BaseModel): model_config = ConfigDict(frozen=True) config_version: str prompt_version: str model_endpoint: str allowed_tools: tuple[str, ...] = () timeout_seconds: int = Field(default=60, gt=0) release_id: int | None = None allowed_tools_by_intent: dict[str, tuple[str, ...]] = Field(default_factory=dict) negative_rules: tuple[tuple[str, str], ...] = () class RecalledMemory(BaseModel): model_config = ConfigDict(frozen=True) memory_uuid: str customer_id: str content: str class SourceReference(BaseModel): model_config = ConfigDict(frozen=True) source_type: Literal["knowledge", "memory", "relationship", "tool"] source_id: str title: str | None = None score: float | None = Field(default=None, ge=0, le=1) class ToolCallRecord(BaseModel): model_config = ConfigDict(frozen=True) tool_name: str status: Literal["succeeded", "failed", "denied"] input_summary: dict[str, Any] = Field(default_factory=dict) output_summary: dict[str, Any] = Field(default_factory=dict) class IntentResult(BaseModel): model_config = ConfigDict(frozen=True) intent: str confidence: float = Field(ge=0, le=1) needs_clarification: bool = False class CoreResult(BaseModel): model_config = ConfigDict(frozen=True) text: str # 业务 Agent 可返回结构化结果;默认为空,兼容只返回文本的其他 Agent。 data: dict[str, Any] = Field(default_factory=dict) sql: str | None = None intent: IntentResult | None = None source_references: tuple[SourceReference, ...] = () tool_calls: tuple[ToolCallRecord, ...] = () # 请求澄清时由持久化层安全递增会话轮次;达到上限后必须改为人工转接。 clarification_required: bool = False transfer_required: bool = False transfer_reason: str | None = None # `E-05`:本轮答复在说哪个主语(出口声明,不是读侧反解)。 # 落库进消息的 `tool_calls` JSON 列,下一轮作为 `ConversationTurn.subject` 读回。 topic: str | None = None class AgentResult(BaseModel): model_config = ConfigDict(frozen=True) run_id: str result: CoreResult usage: dict[str, int] = Field(default_factory=dict) class RunProgressEvent(BaseModel): model_config = ConfigDict(frozen=True) event_type: Literal["start", "tools", "delta", "replace", "done", "error"] run_id: str payload: dict[str, Any] = Field(default_factory=dict) @dataclass(frozen=True) class DomainEvent: event_id: str event_type: str aggregate_type: str aggregate_id: str trace_id: str payload: dict[str, Any] occurred_at: datetime