Files
group_fqcd_jr/app/core/contracts.py
T
张胜宇 da1d14cb31 chore(sync): zsy_developcc 全量同步至 qyqy_develop(W27 口径)
- 分支内容对齐 qyqy_develop 3e24033,树完全一致(同步后 git diff 为空)
- 覆盖本轮 W27 交付:L0 表层判定层 + 出口 E6 行情 + 收益过滤槽位白名单
  + 免责声明分档 + 金标扩容至 55 条(全绿)+ 配置版本 244 已发布
- 新增 app/core/exit_codes.py、app/service/fund_trend_service.py 及 3 个测试文件
- 新增 开发文档\D3.9-客服Agent智能路由与行情出口设计-2026-09-21.md
- 基线:e239eb7(2026-09-17 品牌口径统一快照),本提交为其直接后继
2026-09-21 22:33:09 +08:00

194 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`:本轮答复在说哪个主语(出口声明,不是读侧反解)。
# ✅ `W27`:本轮命中的**出口码**(`E0`—`E6` / `P0`—`P2` / `LOGIN` / `CONTACT` / `CHAT`,
# 定义见 `app/core/exit_codes.py`)。**声明式**字段:由出口自己填,读侧不反解文本。
# 唯一消费方是门禁 `F5` 的**免责声明分档**(`DEC-W27-10`):
# 业务出口附完整投资免责话术,非业务出口(闲聊 / 联系方式 / 引导登录 / 澄清)只附轻量提示。
# 为什么用出口码而不用“文本里有没有数字”这类间接判据:后者会把 `E5b` 空答误判成非业务,
# 从而静默掉档(客户拿不到本应拿到的投资风险提示)。
exit_code: str | None = None
# 落库进消息的 `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