Files
group_fqcd_jr/app/service/agent/governance.py
T

249 lines
14 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 = (
"本内容仅为投资分析参考,不构成任何直接投资建议,不构成对任何产品的收益承诺,"
"据此操作风险自负,请谨慎对待。"
)
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, ...],
) -> 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, ...],
) -> AgentResult:
# 裁定 1:读库是异步的,由本方法(异步层)做;`review_output` 保持同步、不接触数据库,
# 只把拿到的文本追加到输出末尾。
disclaimer = await _load_template_text(DISCLAIMER_TEMPLATE_CODE)
return review_output(result, context, config, memories, disclaimer=disclaimer)
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,
) -> AgentResult:
"""同步治理:引用校验 → 负面词判定/替换 → 脱敏 → 追加固定免责声明。
`disclaimer` 由异步层(`PlatformGovernance.review`)注入库内话术;本函数是同步的、
不接触数据库。默认 `None` 表示"调用方未提供",此时用 `FALLBACK_DISCLAIMER` 兜底——
因此既有调用点不必改签名也能拿到固定话术(门禁 F5 的 100% 覆盖)。
"""
content = result.result
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 not content.text.endswith(appended_shape):
content = content.model_copy(update={"text": f"{content.text}{appended_shape}"})
return result.model_copy(update={"result": content})