diff --git a/app/service/risk/chat_tools.py b/app/service/risk/chat_tools.py index e93a5e6..36c3164 100644 --- a/app/service/risk/chat_tools.py +++ b/app/service/risk/chat_tools.py @@ -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, } ) diff --git a/app/service/tool_service.py b/app/service/tool_service.py index dc0ffc6..b0b8240 100644 --- a/app/service/tool_service.py +++ b/app/service/tool_service.py @@ -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": diff --git a/tests/test_risk_api.py b/tests/test_risk_api.py index c711f35..f649a7a 100644 --- a/tests/test_risk_api.py +++ b/tests/test_risk_api.py @@ -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"}, diff --git a/tests/test_risk_chat_tools.py b/tests/test_risk_chat_tools.py index 7e92661..7db3986 100644 --- a/tests/test_risk_chat_tools.py +++ b/tests/test_risk_chat_tools.py @@ -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):