Files
group_fqcd_jr/app/service/memory_taxonomy.py
T
lzf_0626 6516ccb385 feat: 第二版——接口契约对齐 docs/05,修复静默故障与数据库基线
相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。

一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
   approve→reviews(需 body decision)、activate→activations、
   rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
   UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
   RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
   输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
   {data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
   不再返回 FastAPI 默认的 {"detail": ...}。

二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。

三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
  顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
  但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
  召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
  .env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。

四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。

五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。

验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
2026-09-10 15:55:54 +08:00

105 lines
4.2 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.
"""记忆语义键的受控词表与显式信号识别。
触发判定(`MemoryService.should_extract_memory`)与模型抽取校验
(`MemoryExtractionService`)共用同一份定义,避免出现"能触发但写不进"或
"写进去但召回时无人认识"的错位。词表只描述持久事实/偏好/约束/目标,
不含会话流水信息,因此不需要新增库表或库列。
"""
DEFAULT_MEMORY_KEYS: frozenset[str] = frozenset({
"preference:risk_level",
"preference:horizon",
"preference:product_type",
"preference:communication",
"constraint:liquidity",
"constraint:loss_tolerance",
"constraint:exclusion",
"profile:occupation",
"profile:family",
"profile:income_stability",
"profile:investment_experience",
"goal:target",
"goal:retirement",
})
# memory_unit.memory_type 是 String(24),取值必须落在该集合内,与键前缀同构。
MEMORY_TYPES: frozenset[str] = frozenset({
"preference", "constraint", "profile", "goal", "fact",
})
# 只有这些业务事件本身构成持久事实,普通运行事件不得触发记忆写入。
BUSINESS_EVENT_TYPES: frozenset[str] = frozenset({
"risk.assessment_completed",
"suitability.assessment_completed",
"trade.completed",
})
# 受控键 -> 用户明确陈述时使用的表述。模式必须足够具体:像"我想买基金"这类
# 意图、以及任何普通问答都不应命中,否则记忆会被会话噪音污染。
SIGNAL_PATTERNS: dict[str, tuple[str, ...]] = {
"preference:risk_level": (
"风险偏好", "风险承受", "风险等级", "稳健型", "保守型", "激进型", "平衡型",
"低风险", "中风险", "中高风险", "高风险",
),
"preference:horizon": (
"投资期限", "投资周期", "期限是", "长期持有", "短期持有", "长期投资", "短期投资",
"年内不用", "三年内不用", "不赎回", "打算持有", "计划持有", "持有几年",
),
# 不得使用宽泛的"偏好":它会与"风险偏好"重叠,导致从风险偏好陈述里误抽出
# 产品类型偏好。产品类型只认明确列举或"只买/只投"这类排他表述。
"preference:product_type": (
"只买", "只投", "只做", "倾向买", "倾向于买", "更喜欢买",
"偏好股票", "偏好债券", "偏好货币", "偏好指数",
),
"preference:communication": (
"沟通方式", "联系我", "推送", "短信通知",
),
"constraint:liquidity": (
"流动性", "随时赎回", "随时取", "不能锁定", "急用钱",
),
"constraint:loss_tolerance": (
"不能亏", "不想亏", "怕亏", "亏损承受", "最大回撤", "回撤",
"不接受亏损", "接受不了亏损", "不能接受亏损", "受不了亏损",
),
"constraint:exclusion": (
"不买", "不投", "别推荐", "不要推荐", "禁止",
),
"profile:occupation": (
"我从事", "我的职业", "职业是", "我是做",
),
"profile:family": (
"已婚", "未婚", "结婚", "有孩子", "孩子", "子女", "配偶",
"家庭负担", "赡养", "家庭", "养家",
),
"profile:income_stability": (
"收入稳定", "月收入", "工资", "收入来源",
),
"profile:investment_experience": (
"投资经验", "新手", "第一次买基金", "做过股票",
),
# 去掉裸"打算":它在"我打算三年内不赎回"这类期限陈述里不构成目标收益目标。
"goal:target": (
"我的目标是", "计划在", "打算买", "打算投", "攒够", "目标收益", "买房", "留学",
),
"goal:retirement": (
"养老", "退休",
),
}
# 抽取结果必须落在词表内;配置中心可在 default 之外追加受控键。
MEMORY_KEY_NAMESPACE = "memory"
MEMORY_KEY_CONFIG_KEY = "vocabulary"
MEMORY_KEY_FIELD = "memory_keys"
def detect_memory_signals(content: str) -> tuple[str, ...]:
"""识别消息中明确陈述的持久事实/偏好,返回命中的受控键(按词表顺序稳定输出)。"""
normalized = content.strip()
if not normalized:
return ()
return tuple(
key
for key, patterns in SIGNAL_PATTERNS.items()
if any(pattern in normalized for pattern in patterns)
)