Merge origin/NL_develop:客服画像出口、知识管理三端点、合规语境与知识向量链路

NL 线(含其并入的袁聪场外/推广域)。唯一冲突是 .gitignore —— 双方都往同一区域加了
.workdir/,取对方版本(他的更完整,含 .tmp/ 与说明),顺带修掉我之前用
Add-Content -Encoding utf8 造成的编码混合(read 工具当时报 invalid UTF-8)。

合并后修的问题 —— 都不是"改别人业务逻辑",是让门禁能绿:

1. 缺运行依赖 python-docx。document_parser.py 解析 .docx 用它,但 requirements.txt 与
   pyproject.toml 都没声明 —— 别人环境跑知识入库会直接
   ModuleNotFoundError: No module named 'docx'。已补声明。
2. ruff 7 项:其中 tests/conftest.py 的 F821 Undefined name 'Path'(他的 tmp_path 修复
   写了字符串注解 "Path" 却漏 import,运行时不求值所以没炸,但 mypy/ruff 会抓)、
   tools/publish_customer_service_config.py 的 F841 inherited_keys 死变量(他改同 key
   覆盖、换成 inherited_only 后忘删旧的)、3 处 E501,另 2 项 ruff --fix 自动修复。
3. 合规基线种子未跑:integration 的 test_compliance_seed_mysql 4 个用例要求
   agent_negative_word 有 7 条 active 且已复核、agent_reply_template 覆盖 6 场景。
   跑 tools/seed_compliance_baseline.py(11 条 active 规则 / 6 个场景模板)后 80 passed。

验证:ruff 干净 / mypy 180 文件 0 错 / unit+contract 1140 passed /
integration 80 passed / 表数 68(alembic 已在 20260911_merge_risk_heads)。

唯一失败 tests/unit/repository/test_fund_readonly_contract.py 是双方一致的既有缺陷:
它断言 Base.metadata 里的 fin_* 表集合,而实测为空集 —— 即该测试依赖别的测试先导入模型的
副作用,单独跑必失败。NL 方也明确"不修不报",此处照办,仅记录。
This commit is contained in:
2026-09-11 20:22:59 +08:00
99 changed files with 15778 additions and 461 deletions
@@ -13,14 +13,17 @@
四条出口:
`faq` → 检索直返(不经模型)|`product_inquiry` / `policy_explain` → 检索直返 + 来源引用
|`chitchat` → 模型生成(提示词走发布配置)|其余与异常 → 引导人工客服
另有**出口零:本人画像**(确定性关键词识别,先于意图分发执行,见 `is_profile_question`)。
"""
import logging
from typing import Any
from app.core.contracts import (
AgentDefinition,
AgentRequest,
CoreResult,
IntentResult,
RequestContext,
SourceReference,
)
@@ -29,6 +32,8 @@ from app.service.agent.base import BaseAgent
from app.service.model_gateway import DatabaseModelEndpointResolver
from app.service.runtime_config_service import load_active_prompt
logger = logging.getLogger(__name__)
AGENT_TYPE = "customer_service"
# 意图码必须三处对齐:AgentDefinition.supported_intents、agent_intent_config 的
@@ -53,6 +58,80 @@ RISK_LEVEL_NAMES = {
4: "R4(中高风险)", 5: "R5(高风险)",
}
# 客户风险测评等级(C 级)到中文表述。与上面的 `RISK_LEVEL_NAMES`(产品 R 级)是两套编码:
# 产品讲 R1–R5、客户测评讲 C1–C5,回答里**不能混用**,否则客户会误读自己的等级。
RISK_LEVEL_LABELS: dict[str, str] = {
"C1": "保守型(C1)",
"C2": "稳健型(C2)",
"C3": "平衡型(C3)",
"C4": "成长型(C4)",
"C5": "进取型(C5)",
}
# ---------------------------------------------------------------------------
# 画像查询(确定性关键词识别,不经过 LLM 意图分类)
# ---------------------------------------------------------------------------
#
# 为什么用**确定性关键词**而不是交给意图分类器:画像问题要求"要么给出该客户的真实数据、
# 要么明确说查不到"。走分类器会引入"模型把「风险等级怎么划分」也判成画像问题"的风险,
# 而那一类问的是**规则**、该由知识库回答(见下面的负向词表)。
PROFILE_TOOL_NAME = "query_customer_profile"
PROFILE_KEYWORDS: tuple[str, ...] = (
"我的风险等级", "我的风险测评", "我是什么风险", "我的投资者类型", "我的投资类型",
"我的风险承受", "测评过期", "测评到期", "我的测评", "我的画像",
"我的投资偏好", "我的偏好", "我的投资期限", "我的交易频率",
)
#: 问题里出现这些词时**不**按画像处理(问的是政策规则,不是本人数据)。
PROFILE_NEGATIVE_KEYWORDS: tuple[str, ...] = (
"怎么划分", "如何划分", "什么标准", "分类标准", "怎么分", "如何分",
"有哪些等级", "等级划分", "怎么定义", "如何定义",
)
#: 调用画像工具时用的意图 key。必须复用**已发布**的 `faq`:
#: `call_tool(intent=...)` 决定"当前意图允许哪些工具",而发布配置里只有
#: `agent_tools/customer_service:faq` 一个 key;换新意图码会让交集为空 →
#: `AGENT_PERMISSION_DENIED`。这与"知识检索用哪个意图"是两个层面,不冲突。
PROFILE_WHITELIST_INTENT = INTENT_FAQ
def is_profile_question(message: str) -> bool:
"""是否在问**本人画像**(确定性判定,模型不参与)。"""
if any(word in message for word in PROFILE_NEGATIVE_KEYWORDS):
return False
return any(word in message for word in PROFILE_KEYWORDS)
def render_profile(profile: dict[str, object]) -> str:
"""把画像投影渲染成客户可读的一段话。
**只陈述该客户自己的字段**,不推断、不承诺收益;测评过期时**必须明说**并引导重新测评
(与适当性服务的 `ASSESSMENT_EXPIRED` 失败关闭口径一致)。
"""
lines: list[str] = []
investor_type = str(profile.get("investor_type") or "").strip().upper()
if investor_type:
lines.append(f"您的风险测评等级是 {RISK_LEVEL_LABELS.get(investor_type, investor_type)}。")
horizon = profile.get("investment_horizon")
if horizon:
lines.append(f"投资期限偏好:{horizon}。")
frequency = profile.get("trading_frequency")
if frequency:
lines.append(f"交易频率:{frequency}。")
assets = profile.get("preferred_asset_class")
if isinstance(assets, (list, tuple)) and assets:
lines.append("偏好资产类别:" + "、".join(str(a) for a in assets) + "。")
tier = profile.get("customer_tier")
if tier:
lines.append(f"客户分层:{tier}。")
if profile.get("assessment_expired") is True:
lines.append("注意:您的风险测评已过有效期,需要重新完成测评后才能继续匹配产品风险等级。")
if not lines:
return "暂时查不到您的画像信息,建议转人工客服核实。"
return "\n".join(lines)
# 三档置信阈值:方案 §2.3 要求的是「绝对阈值 AND(相对间隙 OR 分布优势)」混合判定,
# 只做绝对阈值会把口语化问法误判成"答不了"(这是实测踩到的坑)。
#
@@ -80,9 +159,16 @@ FALLBACK_TEMPLATE = (
"抱歉,这个问题我暂时无法给出准确答复。为避免给您错误信息,"
f"建议您拨打客服热线 {HOTLINE}({SERVICE_HOURS})转人工客服咨询。"
)
# 客户侧只展示这一句固定话术(业务方确定)。原中置信的"信息可能不完整"提示已按此移除,
# 即中置信回答不再对客户标注不确定性——这是调整时知情的取舍,不是遗漏。
DISCLAIMER = "(以上内容由智能客服依据公司公开资料整理,不构成投资建议)"
# 免责声明**只由治理层注入**(`PlatformGovernance.review` → `review_output`,话术取自
# `agent_reply_template` 的 `TPL_DISCLAIMER`,取不到时退回 `FALLBACK_DISCLAIMER`)。
#
# 这里曾自行拼一句 `DISCLAIMER`,合并后与治理层的话术**同时出现**,客户会看到两条
# 意思重复的声明的(实测复现)。更重要的是分工问题:话术是合规文案,属于**发布配置**,
# 改文案不该改代码;Agent 自己拼等于把可配置的合规文案硬编码进业务逻辑,
# 而且治理层无法判断"业务是不是已经加过了"(它只认自己追加过的那个形状)。
# 因此本文件不再定义、也不再引用任何免责声明常量。
# 客服热线:正式号码确定后改这里(或改为读配置项,避免改代码)
CHITCHAT_PROMPT_CODE = "customer_service_chitchat"
CHITCHAT_TASK_TYPE = "chat"
@@ -101,7 +187,7 @@ class CustomerServiceAgent(BaseAgent):
allowed_roles=("customer",),
allowed_portals=("api",),
# 代码上限:实际可用范围由发布配置的意图白名单收窄(两者取交集)
allowed_tools=(TOOL_NAME, SUITABILITY_TOOL),
allowed_tools=(TOOL_NAME, SUITABILITY_TOOL, PROFILE_TOOL_NAME),
supported_intents=(
INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY, INTENT_SUITABILITY,
INTENT_CHITCHAT, INTENT_TRANSFER,
@@ -109,6 +195,12 @@ class CustomerServiceAgent(BaseAgent):
)
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
# 画像问题优先处理(确定性关键词,不走意图分类):知识库答不了"我的风险等级是多少",
# 那需要读该客户的画像数据,必须走 `query_customer_profile` 工具取权威字段。
# 放在意图分发**之前**是有意的:让画像能力不依赖意图分类是否恰好给出 faq。
# 连带效果:该分支的 `intent` 恒为 `faq`,与 `PROFILE_WHITELIST_INTENT` 同源。
if is_profile_question(request.message):
return await self._answer_profile(request, context)
intent = self._intent_code()
if intent == INTENT_CHITCHAT:
return await self._chitchat(request)
@@ -123,6 +215,42 @@ class CustomerServiceAgent(BaseAgent):
return await self._answer_suitability(request, context)
return await self._answer_from_knowledge(request, context, intent)
# ---- 出口零:本人画像(确定性识别 + 权威字段) ----
async def _answer_profile(
self, request: AgentRequest, context: RequestContext
) -> CoreResult:
"""查本人画像并渲染。
**只查 `context.user_id`**,不从用户消息里取编号(否则客户可以靠一句话读别人的画像)。
`customer_id` 仍然显式传给工具:底座会把它落进工具审计,事后能追溯"谁读了谁的画像"。
工具内部还会再做一次数据范围校验(`self` / `own_customers` / `all`)。
查不到时**失败关闭为转人工**,绝不猜一个等级出来。
"""
del request
try:
output = await self.call_tool(
PROFILE_TOOL_NAME,
{"customer_id": str(context.user_id)},
intent=PROFILE_WHITELIST_INTENT,
context=context,
)
except ForbiddenAgentError:
# 权限/白名单类失败必须冒泡(与知识出口同一口径):那是配置错误,
# 用兜底话术吞掉会让"工具没被授权"表现成"客户画像查不到"。
raise
except Exception:
logger.warning("画像查询失败,改为引导人工客服", exc_info=True)
return self._guide_to_human("画像查询失败")
profile = output.get("profile") if isinstance(output, dict) else None
if not isinstance(profile, dict) or not profile:
logger.info("画像为空或不可用,改为引导人工客服")
return self._guide_to_human("画像为空或不可用")
return CoreResult(
text=render_profile(profile),
intent=IntentResult(intent=PROFILE_WHITELIST_INTENT, confidence=1.0),
)
# ---- 出口一:知识直返(faq / 产品 / 政策) ----
async def _answer_from_knowledge(
@@ -170,15 +298,15 @@ class CustomerServiceAgent(BaseAgent):
if not content:
return self._guide_to_human("命中内容为空")
# 正文只保留「答案 + 固定免责声明」:业务方要求客户侧只看到这一句固定话术,
# 因此不确定性提示与出处行都不再出现在正文里。
# 正文只保留答案本身:固定免责声明由**治理层**统一追加(见文件头 `DISCLAIMER` 说明),
# 业务代码不再拼字符串——否则会出现两条重复声明,且合规文案变成不可配置的硬编码。
#
# 可追溯性不受影响:本次命中哪个知识块仍由审计(agent.tool_executed 的工具调用记录)
# 与消息表留痕,只是不面向客户展示。若将来要把出处给客户看,应当走
# source_references 的 knowledge 类型(需先让 ToolExecutor 登记本次可引用的 doc_id),
# 而不是继续往正文里拼字符串。
return CoreResult(
text=f"{content[:MAX_ANSWER_CHARS]}\n{DISCLAIMER}",
text=content[:MAX_ANSWER_CHARS],
intent=self._classified_intent,
)
@@ -262,7 +390,7 @@ class CustomerServiceAgent(BaseAgent):
f"风险测评结果为准,不以本次自述为准。\n{text}"
)
return CoreResult(
text=f"{text}\n{DISCLAIMER}",
text=text,
intent=self._classified_intent,
)
@@ -403,7 +531,7 @@ class CustomerServiceAgent(BaseAgent):
if not text:
return self._guide_to_human("模型返回为空")
return CoreResult(
text=f"{text[:MAX_ANSWER_CHARS]}\n{DISCLAIMER}",
text=text[:MAX_ANSWER_CHARS],
intent=self._classified_intent,
)