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

317 lines
13 KiB
Python

"""组合召回:MySQL 权威召回 + 可选向量召回,Redis 只做可重建的加速层。
设计取舍(对应 P3 缺口 1):
1. `VectorMemoryAdapter` 只有"向量 → 命中"的能力,既不做文本向量化,也不回表。
而本项目没有 text-embedding 端点配置(`Settings` 里只有 Milvus 连接信息),
因此语义召回缺少"文本 → 向量"的第一步。这里不猜造 embedding:调用方须注入
`embed`(生产接模型网关),未注入时向量通道显式降级并记录原因,
MySQL 结构化召回照常返回。适配器侧补 `parse_hits`,把 pymilvus 的嵌套命中
折叠成可回表的 `VectorHit`,避免服务层去猜第三方的返回结构。
2. `MemoryCacheAdapter` 的 get/set 语义可用,但缺"按前缀删除",而客户级联失效
需要清缓存键,因此在适配器上新增 `delete(*keys)`;写入失败仍只返回 False。
"""
import hashlib
import json
import logging
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass, field
from typing import Any, Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.infrastructure.memory_cache import MemoryCacheAdapter
from app.infrastructure.vector_memory import VectorMemoryAdapter
from app.model.memory import MemoryUnit
from app.service.memory_service import MemoryService
logger = logging.getLogger(__name__)
CACHE_KEY_PREFIX = "mem:recall"
CACHE_TTL_SECONDS = 300
# 客户级缓存失效需要枚举出真实存在的键:limit 与查询摘要都由调用方决定,
# 因此把常用组合固化为常量,供 `cache_keys` 与生命周期服务共用。
COMMON_LIMITS = (5, 10, 20)
# 空查询与 None 归一化后同键,只保留一个代表值。
COMMON_QUERIES: tuple[str | None, ...] = (None,)
# 向量相似度在综合置信度中的权重上限:语义召回的置信度不得与结构化证据等价。
VECTOR_WEIGHT = 0.7
STRUCTURED_SOURCE = "mysql"
VECTOR_SOURCE = "milvus"
EmbeddingProvider = Callable[[str], Awaitable[list[float]]]
class CacheAdapter(Protocol):
async def get(self, key: str) -> Any: ...
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> bool: ...
async def delete(self, *keys: str) -> int: ...
@dataclass(frozen=True)
class RecallItem:
"""一条召回结果;`sources` 记录它来自哪些通道,`confidence` 是综合置信度。"""
memory_uuid: str
memory_key: str
content: str
memory_type: str
confidence: float
sources: tuple[str, ...]
evidence: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class RecallResult:
customer_id: int
items: tuple[RecallItem, ...]
degraded: bool = False
degraded_reasons: tuple[str, ...] = ()
from_cache: bool = False
class MemoryRecallService:
"""组合召回入口;任何外部依赖(Milvus/Redis)故障都只降级,不阻塞主流程。"""
def __init__(
self,
session: AsyncSession,
*,
vector: VectorMemoryAdapter | None = None,
cache: MemoryCacheAdapter | CacheAdapter | None = None,
embed: EmbeddingProvider | None = None,
cache_ttl_seconds: int = CACHE_TTL_SECONDS,
) -> None:
self.session = session
self.memory = MemoryService(session)
self.vector = vector
self.cache = cache
self.embed = embed
self.cache_ttl_seconds = cache_ttl_seconds
async def recall(
self,
customer_id: int,
query: str | None = None,
*,
limit: int = 10,
use_cache: bool = True,
) -> RecallResult:
bounded = max(1, min(limit, 100))
reasons: list[str] = []
cache_key = self.cache_key(customer_id, query, bounded)
if use_cache:
cached, cache_degraded = await self._cache_read(cache_key)
if cached is not None:
return cached
if cache_degraded:
reasons.append("redis_unavailable")
structured = await self._structured(customer_id, query, bounded)
vector_items, vector_reasons = await self._vector(customer_id, query, bounded)
reasons.extend(vector_reasons)
merged = self._merge(structured, vector_items, bounded)
degraded = bool(reasons)
result = RecallResult(
customer_id=customer_id,
items=tuple(merged),
degraded=degraded,
degraded_reasons=tuple(dict.fromkeys(reasons)),
)
if use_cache and self.cache is not None:
await self._cache_write(cache_key, result)
return result
@staticmethod
def cache_key(customer_id: int, query: str | None, limit: int) -> str:
digest = hashlib.sha256((query or "").strip().lower().encode("utf-8")).hexdigest()[:16]
return f"{CACHE_KEY_PREFIX}:{customer_id}:{limit}:{digest}"
@classmethod
def cache_keys(cls, customer_id: int) -> list[str]:
"""某客户常见参数组合下的热缓存键;供客户级失效枚举使用。"""
return [
cls.cache_key(customer_id, query, limit)
for limit in COMMON_LIMITS
for query in COMMON_QUERIES
]
async def invalidate_cache(self, customer_id: int) -> int:
if self.cache is None:
return 0
return await self.cache.delete(*self.cache_keys(customer_id))
async def _structured(
self, customer_id: int, query: str | None, limit: int
) -> list[MemoryUnit]:
"""复用 `MemoryService.recall_with_decay`(含关键词过滤与时间衰减)。"""
return await self.memory.recall_with_decay(customer_id, query, limit=limit)
async def _vector(
self, customer_id: int, query: str | None, limit: int
) -> tuple[list[RecallItem], list[str]]:
if self.vector is None:
return [], []
text = (query or "").strip()
if not text:
return [], []
if self.embed is None:
# 缺少文本向量化能力时,语义通道不可用;这是配置缺口,不是运行时故障。
logger.warning("vector recall skipped: embedding provider not configured")
return [], ["embedding_unavailable"]
try:
embedding = await self.embed(text)
except Exception:
logger.warning("vector recall degraded: embedding failed")
return [], ["embedding_failed"]
if not embedding:
return [], ["embedding_failed"]
# 纵深防御:适配器契约上不抛异常,但召回服务自己对外承诺"外部依赖故障只降级、
# 不阻塞主流程",这条承诺不能寄托在适配器实现上。
try:
found = self.vector.search(embedding, limit=limit)
if found.degraded:
logger.warning("vector recall degraded: milvus unavailable collection=%s",
self.vector.collection)
return [], ["milvus_unavailable"]
hits = self.vector.parse_hits(found.hits)
except Exception:
logger.warning("vector recall degraded: vector backend raised collection=%s",
self.vector.collection)
return [], ["milvus_unavailable"]
if not hits:
return [], []
scores = {hit.memory_uuid: hit.score for hit in hits}
rows: list[RecallItem] = []
memories = await self._load_by_uuids(customer_id, list(scores))
for memory in memories:
score = scores.get(memory.memory_uuid, 0.0)
rows.append(self._item(
memory,
sources=(VECTOR_SOURCE,),
confidence=min(1.0, abs(score) * VECTOR_WEIGHT),
evidence={"vector_score": round(score, 6)},
))
return rows, []
async def _load_by_uuids(self, customer_id: int, uuids: list[str]) -> list[MemoryUnit]:
if not uuids:
return []
found = await self.session.scalars(
select(MemoryUnit).where(
MemoryUnit.customer_id == customer_id,
MemoryUnit.memory_uuid.in_(uuids),
MemoryUnit.status == "active",
)
)
return list(found)
def _merge(
self, structured: list[MemoryUnit], vector_items: list[RecallItem], limit: int
) -> list[RecallItem]:
"""以 memory_uuid 为身份合并:结构化结果保留,向量命中追加并取较高置信度。"""
merged: dict[str, RecallItem] = {}
for memory in structured:
item = self._item(memory, sources=(STRUCTURED_SOURCE,), confidence=float(
memory.confidence))
merged[item.memory_uuid] = item
for item in vector_items:
existing = merged.get(item.memory_uuid)
if existing is None:
merged[item.memory_uuid] = item
continue
merged[item.memory_uuid] = RecallItem(
memory_uuid=existing.memory_uuid,
memory_key=existing.memory_key,
content=existing.content,
memory_type=existing.memory_type,
confidence=max(existing.confidence, item.confidence),
sources=(STRUCTURED_SOURCE, VECTOR_SOURCE),
evidence={**existing.evidence, **item.evidence},
)
ordered = sorted(merged.values(), key=lambda item: item.confidence, reverse=True)
return ordered[:limit]
def _item(
self,
memory: MemoryUnit,
*,
sources: tuple[str, ...],
confidence: float,
evidence: dict[str, Any] | None = None,
) -> RecallItem:
return RecallItem(
memory_uuid=memory.memory_uuid,
memory_key=memory.memory_key,
content=memory.content,
memory_type=memory.memory_type,
confidence=round(min(1.0, max(0.0, confidence)), 6),
sources=sources,
evidence=evidence or {"mysql_confidence": float(memory.confidence)},
)
async def _cache_read(self, key: str) -> tuple[RecallResult | None, bool]:
"""返回 (命中结果, 是否降级);`degraded` 只在适配器区分出故障时为真。"""
if self.cache is None:
return None, False
try:
cached = await self.cache.get(key)
except Exception:
# 自定义适配器未按契约吞异常时,缓存故障仍不得阻塞主流程。
logger.warning("recall cache read degraded key=%s", key)
return None, True
if getattr(cached, "degraded", False):
logger.warning("recall cache read degraded key=%s", key)
return None, True
raw = getattr(cached, "value", None)
if raw is None:
return None, False
try:
payload = json.loads(raw if isinstance(raw, str) else str(raw))
items = tuple(
RecallItem(
memory_uuid=str(entry["memory_uuid"]),
memory_key=str(entry["memory_key"]),
content=str(entry["content"]),
memory_type=str(entry["memory_type"]),
confidence=float(entry["confidence"]),
sources=tuple(str(source) for source in entry["sources"]),
evidence=dict(entry.get("evidence") or {}),
)
for entry in payload["items"]
)
except (KeyError, TypeError, ValueError):
# 缓存格式不可信:按未命中处理并覆盖回填,绝不返回半解析结果。
logger.warning("recall cache payload ignored key=%s", key)
return None, False
# 降级状态必须与结果一起从缓存恢复,否则缓存命中会把"降级数据"伪装成正常结果。
return RecallResult(
customer_id=int(payload["customer_id"]),
items=items,
degraded=bool(payload.get("degraded")),
degraded_reasons=tuple(
str(reason) for reason in (payload.get("degraded_reasons") or ())
),
from_cache=True,
), False
async def _cache_write(self, key: str, result: RecallResult) -> bool:
if self.cache is None:
return False
payload = {
"customer_id": result.customer_id,
"degraded": result.degraded,
"degraded_reasons": list(result.degraded_reasons),
"items": [asdict(item) for item in result.items],
}
try:
return await self.cache.set(
key, json.dumps(payload, ensure_ascii=False), ttl_seconds=self.cache_ttl_seconds
)
except Exception:
logger.warning("recall cache write degraded key=%s", key)
return False