AL-06: 对话 Tool 与 API 响应对齐 main 基准——chat_tools.suitability_check 返回体扩展 match_result/mismatch_type/requires_disclosure/needs_branch_confirm/block_response_code/rule_refs(替代 rule_id/reasons/effective_level 展示),check_source='r02_chat',not_found 转结构化 found=False(NotFoundError except 死代码清除);tool_service.summarize suitability 分支按新枚举重写文案(网点当面确认 FM-01/签署风险揭示书/测评过期 FM-03/专业豁免,附 rule_refs 依据);risk_api 断言改 block_response_code。全量 373 passed 0 failed(1 skip=test_suitability 占位待 AL-07)。遗留:chat 链路 risk_suitability_log.actor_id 暂落 SYSTEM(run_tool 统一透传需动 core_tools/kb_tools 冻结 func 契约,发起者留痕已由 agent_tool_call 覆盖)

This commit is contained in:
2026-09-07 17:39:04 +08:00
parent adf5e8013a
commit 38da477be4
4 changed files with 39 additions and 16 deletions
+16 -11
View File
@@ -29,7 +29,6 @@ from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service import suitability
from app.tool.core_tools import _jsonable
from app.utils.exceptions import NotFoundError
class RiskToolSpec(dict):
@@ -132,34 +131,40 @@ def customer_context(customer_id: str, core_ro: CoreReadOnlyRepository | None =
def suitability_check(customer_id: str, core_ro: CoreReadOnlyRepository | None = None,
risk_repo: RiskRepository | None = None, **params: Any) -> dict[str, Any]:
"""适当性校验(SUIT-001~008):给定产品判断客户是否可购(只读核查,落审计日志)。
"""适当性校验(main 矩阵契约):给定产品判断客户是否可购(只读核查,落审计日志)。
product_id 由 LLM/调用方经白名单传入;复用 suitability.suitability_check
走原审计落库(risk_suitability_log 仅 INSERT)。NotFound(客户/产品缺失)
转为结构化 found=False,不抛异常污染对话链路(run_tool 仍记 success 落库)。
走原审计落库(risk_suitability_log 仅 INSERT,check_source='r02_chat')。
AL-06 对齐 main 契约:返回体为 match_result 五值 / mismatch_type 七值 /
requires_disclosure / needs_branch_confirm / block_response_code / rule_refs;
客户或产品缺失为 not_found 结构(不再抛异常),对外仍表达为 found=False。
"""
product_id = params.get("product_id")
if not product_id:
return {"found": False, "error": "missing_product_id"}
ro = core_ro or CoreReadOnlyRepository()
repo = risk_repo or RiskRepository()
try:
result = suitability.suitability_check(customer_id, product_id, core_ro=ro, risk_repo=repo)
except NotFoundError as exc:
return {"found": False, "error": str(exc)}
result = suitability.suitability_check(
customer_id, product_id, core_ro=ro, risk_repo=repo, check_source="r02_chat"
)
if result.mismatch_type == "not_found":
return {"found": False, "error": "customer or product not found"}
return _jsonable(
{
"found": True,
"customer_id": customer_id,
"product_id": product_id,
"customer_risk_level": result.customer_level,
"effective_level": result.effective_level,
"product_risk_level": result.product_level,
"match_result": result.match_result,
"mismatch_type": result.mismatch_type,
"is_matched": result.is_matched,
"blocked": result.blocked,
"rule_id": result.rule_id,
"requires_disclosure": result.requires_disclosure,
"needs_branch_confirm": result.needs_branch_confirm,
"block_response_code": result.block_response_code,
"rule_refs": result.rule_refs,
"block_reason": result.block_reason,
"reasons": result.reasons,
}
)
+16 -2
View File
@@ -389,10 +389,24 @@ def summarize(record: dict[str, Any]) -> str:
if name == "suitability_check":
if not data.get("found"):
return f"(适当性校验:{data.get('error') or '未找到客户或产品'})"
verdict = "可购" if (data.get("is_matched") and not data.get("blocked")) else "不可购"
# AL-06 对齐 main 契约:按 match_result/mismatch_type 新枚举重写文案
if data.get("needs_branch_confirm"):
verdict = "不可直接购买,需网点当面确认(FM-01)"
elif data.get("match_result") == "professional_exempt":
verdict = "可购(专业投资者豁免适当性匹配)"
elif data.get("match_result") == "risk_expired":
verdict = "不可购,风险测评已过期(FM-03),须重新测评"
elif data.get("match_result") == "allowed_with_disclosure" and not data.get("blocked"):
verdict = "可购,需签署风险揭示书"
elif data.get("match_result") == "allowed" and not data.get("blocked"):
verdict = "可购"
else:
verdict = "不可购(客户风险等级与产品风险等级不匹配)"
refs = data.get("rule_refs") or []
ref_txt = f";依据 {'、'.join(refs)}" if refs else ""
return (
f"(适当性校验:客户 {data.get('customer_risk_level')} 对产品 "
f"{data.get('product_risk_level')} {verdict}({data.get('rule_id')});"
f"{data.get('product_risk_level')} {verdict}{ref_txt};"
f"仅供参考,最终以持证投顾审核为准)"
)
if name == "aml_lookup":
+2 -1
View File
@@ -235,7 +235,8 @@ def test_suitability_check_by_owner_officer_and_assigned_advisor(client, env):
)
assert r.status_code == 200
body = r.json()
assert body["blocked"] is True and body["rule_id"] == "SUIT-001" # C1+R4
# AL-06 main 契约:机器码断言改 block_response_code(SUIT-001 退役)
assert body["blocked"] is True and body["block_response_code"] == "SUIT_RISK_MISMATCH" # C1+R4
r = client.post(
"/api/risk/suitability/check",
json={"customer_id": "CUST-3001", "product_id": "PROD-510300"},
+5 -2
View File
@@ -207,7 +207,8 @@ def test_suitability_check_matched(risk_env):
d = record["data"]
assert d["found"] is True
assert d["is_matched"] is True and d["blocked"] is False
assert d["rule_id"] == "SUIT-PASS"
assert d["match_result"] == "allowed" # AL-06 main 契约:C1×R1 矩阵 allowed
assert d["block_response_code"] == "SUIT_OK"
def test_suitability_check_blocked_not_matched(risk_env):
@@ -218,7 +219,9 @@ def test_suitability_check_blocked_not_matched(risk_env):
assert record["status"] == "success"
d = record["data"]
assert d["is_matched"] is False and d["blocked"] is True
assert d["rule_id"] == "SUIT-001"
assert d["match_result"] == "forbidden" and d["mismatch_type"] == "risk_level"
assert d["block_response_code"] == "SUIT_RISK_MISMATCH"
assert d["rule_refs"] == ["JR-AST-012"]
def test_suitability_check_product_not_found(risk_env):