Files
group_fqcd_jr/app/service/memory_extraction_service.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

206 lines
9.0 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.
"""记忆抽取:模型严格 JSON + Pydantic 校验 + 受控词表。
抽取是"从用户消息里读出结构化事实",不是"把用户原文存下来":模型必须返回
`memory_key` / `value` / `memory_type` / `confidence` 四字段的严格 JSON,任何
契约违规(JSON 非法、字段缺失、置信度越界、键不在受控词表、键与类型不一致)
一律失败关闭,抛 `RecoverableAgentError`,由事件层决定重试,绝不落库。
端点解析复用现有 resolver 风格(`DatabaseModelEndpointResolver`),task_type 固定为
`memory_extraction`,与意图分类走同一套模型路由入口。
"""
import json
import logging
from collections.abc import Awaitable, Callable
from functools import lru_cache
from typing import Any, Protocol
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from sqlalchemy import select
from app.core.errors import RecoverableAgentError
from app.infrastructure.db import SessionFactory
from app.model.configuration import ConfigRelease, PlatformConfigItem
from app.service.memory_taxonomy import (
DEFAULT_MEMORY_KEYS,
MEMORY_KEY_CONFIG_KEY,
MEMORY_KEY_FIELD,
MEMORY_KEY_NAMESPACE,
MEMORY_TYPES,
)
from app.service.model_gateway import DatabaseModelEndpointResolver, ModelGenerationService
logger = logging.getLogger(__name__)
TASK_TYPE = "memory_extraction"
DEFAULT_AGENT_TYPE = "customer_service"
DEFAULT_MIN_CONFIDENCE = 0.6
VALUE_MAX_LENGTH = 200
class ExtractionEndpointResolver(Protocol):
async def resolve(self, *, agent_type: str, task_type: str) -> list[Any]: ...
class _ExtractionPayload(BaseModel):
"""模型输出的唯一合法形状;额外字段一律拒绝(extra="forbid")。"""
model_config = ConfigDict(extra="forbid")
memory_key: str | None
value: str | None
memory_type: str | None
confidence: float = Field(ge=0, le=1)
class ExtractedMemory(BaseModel):
"""校验通过的结构化记忆;`value` 是短语,不是用户原文整句。"""
model_config = ConfigDict(extra="forbid", frozen=True)
memory_key: str = Field(min_length=1, max_length=128)
value: str = Field(min_length=1, max_length=VALUE_MAX_LENGTH)
memory_type: str = Field(min_length=1, max_length=24)
confidence: float = Field(ge=0, le=1)
async def load_memory_keys() -> frozenset[str]:
"""从配置中心 `namespace=memory` 扩展受控词表,读不到就用代码默认词表。
配置中心异常绝不能让记忆整体不可用,因此这里不向上抛错,只记录告警。
"""
try:
async with SessionFactory() as session:
release = await session.scalar(
select(ConfigRelease).where(ConfigRelease.status == "active")
)
if release is None:
return DEFAULT_MEMORY_KEYS
item = await session.scalar(select(PlatformConfigItem).where(
PlatformConfigItem.release_id == release.id,
PlatformConfigItem.namespace == MEMORY_KEY_NAMESPACE,
PlatformConfigItem.config_key == MEMORY_KEY_CONFIG_KEY,
))
except Exception: # 配置中心不可用(网络/表缺失)时降级,不影响记忆可用性。
logger.warning("memory key vocabulary unavailable; using code defaults")
return DEFAULT_MEMORY_KEYS
if item is None:
return DEFAULT_MEMORY_KEYS
raw = item.value_json.get(MEMORY_KEY_FIELD, [])
if not isinstance(raw, list):
logger.warning("memory key vocabulary config ignored: %s is not a list", MEMORY_KEY_FIELD)
return DEFAULT_MEMORY_KEYS
extra = frozenset(
entry.strip() for entry in raw if isinstance(entry, str) and entry.strip()
)
return DEFAULT_MEMORY_KEYS | extra if extra else DEFAULT_MEMORY_KEYS
class MemoryExtractionService:
"""把用户消息抽取为受控键 + 结构化值;不合规输出一律失败关闭。"""
def __init__(
self,
model_service: ModelGenerationService,
endpoint_resolver: ExtractionEndpointResolver,
*,
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
vocabulary_loader: Callable[[], Awaitable[frozenset[str]]] | None = None,
) -> None:
if not 0 <= min_confidence <= 1:
raise ValueError("min_confidence must be between 0 and 1")
self.model_service = model_service
self.endpoint_resolver = endpoint_resolver
self.min_confidence = min_confidence
self.vocabulary_loader = vocabulary_loader or load_memory_keys
async def extract(
self, *, message: str, agent_type: str = DEFAULT_AGENT_TYPE
) -> ExtractedMemory | None:
"""返回校验通过的记忆;模型显式声明"无持久事实"时返回 None。
失败关闭:端点缺失、模型调用失败、输出违约一律抛 `RecoverableAgentError`,
调用方不得写入任何记忆。
"""
text = message.strip()
if not text:
return None
vocabulary = await self._vocabulary()
endpoints = await self.endpoint_resolver.resolve(
agent_type=agent_type, task_type=TASK_TYPE
)
if not endpoints:
raise RecoverableAgentError("没有可用的记忆抽取模型端点")
execution = await self.model_service.generate(endpoints, self._prompt(text, vocabulary))
return self._validate(self._parse(execution.text), vocabulary)
async def _vocabulary(self) -> frozenset[str]:
keys = await self.vocabulary_loader()
return keys if keys else DEFAULT_MEMORY_KEYS
@staticmethod
def _prompt(message: str, vocabulary: frozenset[str]) -> str:
keys = ", ".join(sorted(vocabulary))
types = ", ".join(sorted(MEMORY_TYPES))
return (
"你是金融客户记忆抽取器。只输出一个 JSON 对象,不要 Markdown,不要解释。"
"字段固定为 memory_key、value、memory_type、confidence,不得增删字段。"
f"memory_key 只能取以下之一:{keys}。"
f"memory_type 取 memory_key 冒号前的前缀,只能是以下之一:{types}。"
"confidence 是 0 到 1 的数字。"
f"value 必须是指代明确的结构化短语(例如 \"稳健型\"、\"随时可赎回\"),"
f"不得复制用户原句,长度不超过 {VALUE_MAX_LENGTH} 字。"
"只有用户明确陈述的长期偏好、约束、身份或目标才抽取;"
"普通提问、寒暄、一次性操作意图一律不抽取,此时 memory_key、value、"
"memory_type 三个字段输出 null,confidence 输出 0。"
f"用户消息:{message}"
)
@staticmethod
def _parse(text: str) -> _ExtractionPayload:
candidate = text.strip()
if candidate.startswith("```"):
candidate = candidate.removeprefix("```")
candidate = candidate.removeprefix("json").removesuffix("```").strip()
try:
return _ExtractionPayload.model_validate(json.loads(candidate))
except (json.JSONDecodeError, ValidationError, TypeError) as exc:
raise RecoverableAgentError("模型记忆抽取输出不是有效 JSON") from exc
def _validate(
self, payload: _ExtractionPayload, vocabulary: frozenset[str]
) -> ExtractedMemory | None:
if payload.memory_key is None and payload.value is None and payload.memory_type is None:
# 模型显式声明"没有持久事实":合法的空结果,不是失败。
if payload.confidence != 0:
raise RecoverableAgentError("模型空结果的置信度必须为 0")
return None
if payload.memory_key is None or payload.value is None or payload.memory_type is None:
raise RecoverableAgentError("模型记忆抽取字段缺失")
if payload.memory_key not in vocabulary:
raise RecoverableAgentError("模型返回的记忆键不在受控词表中")
if payload.memory_type not in MEMORY_TYPES:
raise RecoverableAgentError("模型返回的记忆类型不在受控集合中")
prefix = payload.memory_key.split(":", 1)[0]
if prefix != payload.memory_type:
raise RecoverableAgentError("模型返回的记忆键与记忆类型不一致")
if payload.confidence < self.min_confidence:
raise RecoverableAgentError("模型抽取置信度低于阈值")
try:
return ExtractedMemory(
memory_key=payload.memory_key,
value=payload.value.strip(),
memory_type=payload.memory_type,
confidence=payload.confidence,
)
except ValidationError as exc:
raise RecoverableAgentError("模型记忆抽取内容不合规") from exc
@lru_cache(maxsize=1)
def get_memory_extraction_service() -> MemoryExtractionService:
"""生产装配:记忆抽取与业务 Agent 共用同一个 ModelGenerationService。"""
from app.service.agent.bootstrap import get_model_service
return MemoryExtractionService(get_model_service(), DatabaseModelEndpointResolver())