Files
group_fqcd_jr/app/service/agent/governance.py
T
lzf_0626 c0e5c80929 记忆系统:recall 结果接入 prompt + 可观测性(既有改动,代为提交)
## 说明

**这批改动不是本次会话写的**,它们在会话开始前就已在工作区里、一直未提交。
我做的是**验证**它确实成立,然后按你的指示代为提交。

出处:`docs/演示用/记忆系统排查报告-2026-09-14.md` 与同目录
`记忆系统修复文档-2026-09-14.md`(两份都在本次一并入库)。
排查报告的结论是「记忆系统没有坏」——库里有真实数据、170 条抽取事件全部消费成功;
真正的问题是「观测不到」+「召回结果没人消费」。

## 改动内容(按两份文档的编号)

- **F1 `RecalledMemory.content` 断头路**:`base.py` 新增 `memory_context_text()`,
  `risk_agent._agent_system_prompt` 接收并注入记忆段。无记忆时返回空串,
  因此 prompt 逐字不变 —— 这也是它能安全接线的理由。
- **F3 `governance.recall` 员工身份恒空**:补一条明确的语义日志。
  员工身份下召回的是"该用户自身作为客户"的记忆,恒为空属预期,
  但此前没有任何提示,运维看到 `count=0` 只会以为记忆坏了。
- **F4 可观测性**:`GET /api/v1/users/me/memories`(`stored` / `recalled` /
  `downstream` / `pending_events` 四段)+ 抽取与召回的 6 处日志 +
  三个只读探针 `tools/probe_memory_state.py`、`probe_memory_detail.py`、
  `probe_agent_types.py`。

**未实施**(文档明确留作待决,我也不代为决定):F2 `known` 引用校验永不触发
(需架构确认 memory 类 `source_references` 由业务填还是底座统一附加)、
F5 客服是否读写长期记忆(涉脱敏与复核,需产品+合规)。

## 我做的验证(会话内实测,非照录文档)

- 新接口 `GET /users/me/memories` 以 `cust_t` 调用 -> **HTTP 200**:

      stored:    total=2, by_status={'active': 2}
      recalled:  count=2, degraded=False
      两条记忆:preference:horizon='约三年'(0.95)、preference:risk_level='稳健型'(0.98)

  与排查报告 §〇 列出的那两条**完全吻合**。
- `pytest tests/unit tests/contract` 全绿(这批改动没有破坏既有测试)。

## 未验证的部分

`memory_context_text()` 接进 prompt 后的**端到端效果没有实测** —— 文档自己说明了
原因:当前 `risk` Agent 的召回恒空(员工身份不是客户),所以接线后行为不变,
要用测试替身才能验证注入。我没有为此编造证据。
2026-09-14 20:35:46 +08:00

307 lines
18 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.
import logging
import re
from collections.abc import Callable
from typing import Protocol
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.compliance_context import first_violation as _first_violation
from app.core.contracts import (
AgentDefinition,
AgentResult,
RecalledMemory,
RequestContext,
ResolvedAgentConfig,
)
from app.core.errors import ForbiddenAgentError, RecoverableAgentError
from app.infrastructure.db import SessionFactory
from app.model.configuration import ConfigRelease
from app.service.memory_recall_service import MemoryRecallService
from app.service.runtime_config_service import RuntimeConfigService
logger = logging.getLogger(__name__)
# 组装层注入的召回服务工厂:允许带 Redis 缓存与向量库,缺省退化为纯结构化召回。
RecallServiceFactory = Callable[[AsyncSession], MemoryRecallService]
#: 固定免责声明在 `agent_reply_template` 里的 `template_code`(Task 1 已种入并审核通过)。
#: 按 `template_code` 解析而不是按 `scene`:唯一键 `uk_reply_template_active_one` 保证的是
#: "同一 template_code 只有一条 active",`scene` 上没有唯一性——按 scene 取会在同场景多模板时
#: 取到哪条不确定,而免责声明是必须逐字固定的合规话术。
DISCLAIMER_TEMPLATE_CODE = "TPL_DISCLAIMER"
#: 平台对外客服热线:**公开业务号码**,必须原样出现在面向客户的回复里
#: (spec B §5 要求固定联系方式)。脱敏规则专门放行它,
#: 否则 `TPL_TRANSFER_HUMAN` 与安全路由的 P2 话术会被打成 "[手机号已脱敏]",
#: 客户拿不到联系方式(已实测复现)。与 `app/core/customer_service_rules.CONTACT_PHONE`
#: 是同一个号码,改一处必须同步另一处。
CUSTOMER_SERVICE_HOTLINE = "15936583816"
#: 代码兜底免责声明,逐字等于 `TPL_DISCLAIMER` 的已审文案。
#: 免责声明是安全关键路径(上线门禁 F5:面向客户输出 100% 附固定话术),所以**不允许**
#: "库里查不到就没有话术"这条路径——取数失败或话术缺失时用本常量,宁可多一次失败的查询,
#: 也不能少一句话术。文案本身不得命中任何禁用字面(`agent_negative_word` 规则与下方
#: `hard_patterns`):治理层是朴素子串匹配、没有否定式豁免,写过「非保本」的合规话术会被
#: 自己的规则拦下(Task 1 已修复过一次的"自绊")。
FALLBACK_DISCLAIMER = (
"本内容仅为投资分析参考,不构成任何直接投资建议,不构成对任何产品的收益承诺,"
"据此操作风险自负,请谨慎对待。"
)
#: **内部** Agent 清单:其输出不面向客户,因此不追加面向客户的固定免责声明。
#: 判据是"输出形态"而不是"重要性"——风控/投顾分析的输出是字段化摘要
#: (预警编号、级别、建议动作),追加一句面向投资者的免责声明会破坏其字段契约,
#: 下游解析与 `tests/contract/test_risk_agent_contract.py` 都会因此失败(已实测)。
INTERNAL_AGENT_TYPES = frozenset({"risk"})
#: **确认面向客户**的 Agent 清单:门禁 F5(面向客户输出 100% 附固定话术)只对这些生效。
#: 为什么用"确认式"而不是"未知即注入":`review_output` 是同步纯函数,它的调用方里既有
#: 生产装配的 `PlatformGovernance`(能反查发布版本的 `agent_type`),也有各测试的治理替身
#: (**刻意不连库**,因此无从得知 agent 类型)。若把"未知"当成面向客户,每个替身测试都会被
#: 塞进一句话术,等于用测试噪声换一个假的安全感;而这些测试恰恰是在断言 Agent 的结构化输出。
#: 生产路径下客服 Agent 必然带发布版本(`config_release` 有 agent_tools 白名单),
#: 所以 F5 的覆盖不受影响——**未发布配置的 Agent 本来就没有可用工具、也不接受验收**。
CUSTOMER_FACING_AGENT_TYPES = frozenset({"customer_service", "fund_query_demo"})
class AgentGovernance(Protocol):
async def resolve(
self, definition: AgentDefinition, context: RequestContext
) -> ResolvedAgentConfig: ...
async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]: ...
async def review(
self, result: AgentResult, context: RequestContext, config: ResolvedAgentConfig,
memories: tuple[RecalledMemory, ...],
*,
agent_type: str = "",
) -> AgentResult: ...
class PlatformGovernance:
"""Request-scoped snapshots; no AsyncSession or customer state retained on the singleton."""
def __init__(self, recall_factory: RecallServiceFactory | None = None) -> None:
# 组装层可注入带缓存/向量库的召回服务;缺省退化为纯结构化召回,
# 依赖缺失或故障只降级,不让记忆功能整体不可用。
self._recall_factory = recall_factory
async def resolve(
self, definition: AgentDefinition, context: RequestContext
) -> ResolvedAgentConfig:
async with SessionFactory() as session:
release = await session.scalar(select(ConfigRelease).where(
ConfigRelease.status == "active"))
tools: dict[str, tuple[str, ...]] = {}
if release is not None:
service = RuntimeConfigService(session)
for intent in definition.supported_intents:
configured = await service.allowed_tools(
release.id, definition.agent_type, intent)
tools[intent] = tuple(sorted(set(configured) & set(definition.allowed_tools)))
rows = (await session.execute(text("""
SELECT match_type, word_pattern, applicable_agents FROM agent_negative_word
WHERE status='active' AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL
"""))).mappings().all()
rules: list[tuple[str, str]] = []
import json
for row in rows:
agents = row["applicable_agents"]
if isinstance(agents, str):
agents = json.loads(agents)
if not agents or definition.agent_type not in agents:
# 失败关闭:`applicable_agents` 为空数组/NULL 表示"不适用任何 Agent",
# 跳过该规则。
# 旧判据 `if agents and ...` 在空取值时整条 `continue` 不执行,规则因此**溢出到
# 所有 Agent**(实测 advisor 也加载到 11 条):配置为空不是失效,而是扩大适用
# 范围,与项目一贯的失败关闭原则相反。
continue
if row["match_type"] not in {"exact", "contains"}:
# Python re has no execution timeout: never run unchecked admin regex.
raise RecoverableAgentError("禁止表达正则规则需要受限匹配器")
rules.append((str(row["match_type"]), str(row["word_pattern"])))
return ResolvedAgentConfig(
config_version=release.release_no if release else f"code:{definition.version}",
release_id=release.id if release else None,
prompt_version="none", model_endpoint="", allowed_tools=(),
allowed_tools_by_intent=tools, negative_rules=tuple(rules),
)
async def recall(self, context: RequestContext) -> tuple[RecalledMemory, ...]:
async with SessionFactory() as session:
service = (
self._recall_factory(session)
if self._recall_factory is not None
else MemoryRecallService(session)
)
customer_id = int(context.user_id)
result = await service.recall(customer_id)
# 召回结果此前完全没有出口:即使召回到内容也无人消费,运维无法判断
# "库里没有记忆"与"召回了但被丢弃"。这里把条数、来源与摘要打出来。
logger.info(
"memory recall customer_id=%s count=%s from_cache=%s degraded=%s reasons=%s "
"items=%s",
customer_id, len(result.items), result.from_cache, result.degraded,
",".join(result.degraded_reasons) or "-",
[f"{item.memory_key}={item.content[:40]}({'+'.join(item.sources)})"
for item in result.items[:5]],
)
if result.degraded:
logger.warning("memory recall degraded customer_id=%s reasons=%s",
customer_id, ",".join(result.degraded_reasons))
if not result.items and not {"customer", "authenticated_user"}.intersection(
context.roles
):
# 关键语义:`recall` 查的是"当前登录者作为客户"的记忆(customer_id =
# context.user_id)。风控专员/投顾等员工身份自己不是客户,因此这里
# **恒为空**,不是故障。此前没有任何提示,运维会把"设计如此"误判成
# "记忆坏了"。真正需要查某个客户时,应走 `query_customer_profile` 工具。
logger.info(
"memory recall empty: 当前身份 roles=%s 不是客户,"
"召回的是该用户自身的客户记忆(恒为空属预期);"
"查指定客户请走 query_customer_profile 工具",
list(context.roles),
)
return tuple(
RecalledMemory(memory_uuid=item.memory_uuid, customer_id=str(customer_id),
content=item.content)
for item in result.items
)
async def review(
self, result: AgentResult, context: RequestContext, config: ResolvedAgentConfig,
memories: tuple[RecalledMemory, ...],
*,
agent_type: str = "",
) -> AgentResult:
# 裁定 1:读库是异步的,由本方法(异步层)做;`review_output` 保持同步、不接触数据库,
# 只把拿到的文本追加到输出末尾。
#
# `agent_type` 由 `BaseAgent._execute_governed()` 从**定义**传入(不是查库):它决定
# 门禁 F5 是否适用(见 `CUSTOMER_FACING_AGENT_TYPES`)。给了默认值是为了让既有的
# 治理替身按旧签名调用时仍能工作——那种情况下按"未声明"处理,不注入话术。
disclaimer = await _load_template_text(DISCLAIMER_TEMPLATE_CODE)
return review_output(result, context, config, memories, disclaimer=disclaimer,
agent_type=agent_type)
async def _load_template_text(template_code: str) -> str | None:
"""按 `template_code` 读取生效话术;查不到或取数失败返回 None,由调用方退回代码兜底。
取数条件与 `resolve()` 的规则查询同口径(`status='active'` 且审核字段非空),
避免"库里改了状态但代码照用"的偏差。异常一律吞掉并记日志:免责声明必须 100% 注入,
数据库故障时"少一句话术"比"整条回复失败"更糟,退回 `FALLBACK_DISCLAIMER` 即可满足门禁。
"""
try:
async with SessionFactory() as session:
content: str | None = await session.scalar(
text("""
SELECT content_text FROM agent_reply_template
WHERE template_code = :code AND status='active'
AND reviewer_id IS NOT NULL AND reviewed_at IS NOT NULL
ORDER BY version DESC LIMIT 1
"""),
{"code": template_code},
)
except Exception:
logger.warning("免责声明话术查询失败,退回代码兜底 template_code=%s",
template_code, exc_info=True)
return None
if content is None or not str(content).strip():
logger.warning("免责声明话术不存在或为空,退回代码兜底 template_code=%s", template_code)
return None
return str(content)
def review_output(
result: AgentResult, context: RequestContext, config: ResolvedAgentConfig,
memories: tuple[RecalledMemory, ...],
*,
disclaimer: str | None = None,
agent_type: str = "",
) -> AgentResult:
"""同步治理:引用校验 → 负面词判定/替换 → 脱敏 → 追加固定免责声明。
`disclaimer` 由异步层(`PlatformGovernance.review`)注入库内话术;本函数是同步的、
不接触数据库。默认 `None` 表示"调用方未提供",此时用 `FALLBACK_DISCLAIMER` 兜底——
因此既有调用点不必改签名也能拿到固定话术(门禁 F5 的 100% 覆盖)。
`agent_type` 用于判定**是否面向客户**:门禁 F5 要求的是"客服答复 100% 附固定话术",
而内部 Agent(风控预警、投顾分析)的输出是结构化摘要,追加话术会破坏它的字段契约
(`test_risk_agent_contract` 实测因此失败)。空串按"调用方未声明"处理,**保守照旧追加**,
避免漏加;只有明确列入内部清单的 Agent 才跳过。
"""
content = result.result
# 门禁 F5 的适用范围:**已确认面向客户**的 Agent。判据来自 `agent_type`,它由
# `PlatformGovernance.review()` 从发布版本反查(`tests` 的治理替身不连库 → 空串 →
# 不注入,与它们的断言一致)。空串/未知一律**不注入**而不是注入,理由见
# `CUSTOMER_FACING_AGENT_TYPES` 的说明。
customer_facing = agent_type in CUSTOMER_FACING_AGENT_TYPES
issued_tools = {f"{context.trace_id}:{record.tool_name}" for record in content.tool_calls
if record.status == "succeeded"}
known = {memory.memory_uuid for memory in memories if memory.customer_id == context.user_id}
for reference in content.source_references:
valid = ((reference.source_type == "memory" and reference.source_id in known)
or (reference.source_type == "tool" and reference.source_id in issued_tools))
if not valid:
raise ForbiddenAgentError("引用未来自本次已授权召回结果")
output = content.text
hard_patterns = ("保证收益", "稳赚不赔", "保本保收益", "已为您下单", "已替您交易")
# 语境豁免(详见 `app/core/compliance_context.py`):零容忍规则是朴素子串匹配,
# 无法区分"作出承诺"与"禁止承诺/谈论该表述"。政策类问答会被误伤 —— 实测
# 「基金销售有哪些合规要求」的答案因含"严禁承诺保本保收益""禁止使用『保证收益』"
# 被整条替换,答复从 1097 字掉到 83 字,合规问答反而答不出来。
# 判定:命中处附近出现否定/禁止/引用线索才豁免,否则照旧拦(宁可误拦)。
blocked = _first_violation(output, hard_patterns) is not None
for kind, pattern in config.negative_rules:
if kind == "exact":
blocked |= output == pattern
else:
blocked |= _first_violation(output, (pattern,)) is not None
if blocked:
content = content.model_copy(update={
"text": "该内容需要人工核实。基金投资存在风险,本系统不代客交易。",
"source_references": (), "transfer_required": True,
"transfer_reason": "compliance_review_required",
})
else:
# Apply to text and citation titles, not only to the displayed answer.
def redact(value: str) -> str:
def _mask_mobile(match: re.Match[str]) -> str:
# 平台对外的客服热线是**公开业务号码**,不是客户个人手机号:
# 它必须原样出现在面向客户的回复里(spec B §5 要求固定联系方式),
# 否则 `TPL_TRANSFER_HUMAN` / 安全路由的 P2 话术会被脱敏成
# "[手机号已脱敏]",客户拿不到联系方式(实测过)。
if match.group(0) == CUSTOMER_SERVICE_HOTLINE:
return match.group(0)
return "[手机号已脱敏]"
value = re.sub(r"(?<!\d)1[3-9]\d{9}(?!\d)", _mask_mobile, value)
return re.sub(r"(?<!\d)\d{15,19}[Xx]?(?!\d)", "[敏感号码已脱敏]", value)
content = content.model_copy(update={
"text": redact(output),
"source_references": tuple(ref.model_copy(update={
"title": redact(ref.title) if ref.title else None,
}) for ref in content.source_references),
})
# 门禁 F5:面向客户输出 100% 附固定话术,所以**拦截分支与正常分支都要走到这里**——
# 被负面词替换掉的回复同样是面向客户的输出。位置固定在最后:文案不参与上面的负面词
# 判定,也不会被替换逻辑吃掉。
# 幂等:只认**我们自己追加过的那个形状**("\n\n" + 话术),而不是"文本以话术结尾"。
# 比 `endswith(disclaimer_text)` 更紧的原因:后者在**退化文案**下仍会漏加——管理员把文案
# 发布成"。"、答案恰好是"您好。"时,`endswith("。")` 为真 → 不追加,客户一条话术都拿不到;
# 认形状后这种文本必然被追加。失败形态因此从"合规漏加"变成"极端情况下多追加一次",
# 这正是门禁 F5(面向客户输出 100% 附固定话术)要的方向。正文中间出现同样文字也不会被
# 误判成"已追加"——只有**末尾这个形状**才算。
# 空串不算话术:显式传空一律退回代码兜底,避免"传了但传空"成为绕过强制注入的后门。
disclaimer_text = (disclaimer or "").strip() or FALLBACK_DISCLAIMER
# 追加形状只在这里定义一次:判据与追加共用同一份,避免两处各写一个分隔符而漂移。
appended_shape = f"\n\n{disclaimer_text}"
if customer_facing and not content.text.endswith(appended_shape):
content = content.model_copy(update={"text": f"{content.text}{appended_shape}"})
return result.model_copy(update={"result": content})