Files
group_fqcd_jr/app/service/agent/governance.py
T
qyqy 57c4add7d8 merge: 客服Agent+RAG+画像 与 架构师最新 qyqy_develop 合并
- customer_service.py 以架构师实现为骨架(三档置信/适当性/会话记忆/话题矩阵),嫁接本人画像出口
- 知识检索契约合并两条链路:架构师 search_knowledge(KnowledgeSearchInput) + 本线
  query_knowledge 链路所需常量(ALLOWED_CONSTANTS/VECTOR_DIM/intent_for_qa_id)
- bootstrap 保留架构师 6 工具/3 Agent,补回 query_customer_profile 与 get_milvus_knowledge_writer
- model_gateway 能力映射修正 intent_classification→chat,保留空集回退兜底
- governance 免责声明限定面向客户 Agent(agent_type 由定义透传),风控结构化输出不再被追加
- 修 JWT 密钥路径(config/jwt/dev)、文档 21 号撞号→25
- 测试基线 934 passed / 1 failed(既有空集缺陷)
2026-09-11 15:06:44 +08:00

284 lines
16 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)
if result.degraded:
logger.warning("memory recall degraded customer_id=%s reasons=%s",
customer_id, ",".join(result.degraded_reasons))
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})