From 38da477be470b6f044b34f60c37ddd71b49d81bb Mon Sep 17 00:00:00 2001 From: YUAN Date: Mon, 7 Sep 2026 17:39:04 +0800 Subject: [PATCH] =?UTF-8?q?AL-06:=20=E5=AF=B9=E8=AF=9D=20Tool=20=E4=B8=8E?= =?UTF-8?q?=20API=20=E5=93=8D=E5=BA=94=E5=AF=B9=E9=BD=90=20main=20?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E2=80=94=E2=80=94chat=5Ftools.suitability=5F?= =?UTF-8?q?check=20=E8=BF=94=E5=9B=9E=E4=BD=93=E6=89=A9=E5=B1=95=20match?= =?UTF-8?q?=5Fresult/mismatch=5Ftype/requires=5Fdisclosure/needs=5Fbranch?= =?UTF-8?q?=5Fconfirm/block=5Fresponse=5Fcode/rule=5Frefs(=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=20rule=5Fid/reasons/effective=5Flevel=20=E5=B1=95?= =?UTF-8?q?=E7=A4=BA)=EF=BC=8Ccheck=5Fsource=3D'r02=5Fchat'=EF=BC=8Cnot=5F?= =?UTF-8?q?found=20=E8=BD=AC=E7=BB=93=E6=9E=84=E5=8C=96=20found=3DFalse(No?= =?UTF-8?q?tFoundError=20except=20=E6=AD=BB=E4=BB=A3=E7=A0=81=E6=B8=85?= =?UTF-8?q?=E9=99=A4)=EF=BC=9Btool=5Fservice.summarize=20suitability=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=E6=8C=89=E6=96=B0=E6=9E=9A=E4=B8=BE=E9=87=8D?= =?UTF-8?q?=E5=86=99=E6=96=87=E6=A1=88(=E7=BD=91=E7=82=B9=E5=BD=93?= =?UTF-8?q?=E9=9D=A2=E7=A1=AE=E8=AE=A4=20FM-01/=E7=AD=BE=E7=BD=B2=E9=A3=8E?= =?UTF-8?q?=E9=99=A9=E6=8F=AD=E7=A4=BA=E4=B9=A6/=E6=B5=8B=E8=AF=84?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=20FM-03/=E4=B8=93=E4=B8=9A=E8=B1=81=E5=85=8D?= =?UTF-8?q?,=E9=99=84=20rule=5Frefs=20=E4=BE=9D=E6=8D=AE)=EF=BC=9Brisk=5Fa?= =?UTF-8?q?pi=20=E6=96=AD=E8=A8=80=E6=94=B9=20block=5Fresponse=5Fcode?= =?UTF-8?q?=E3=80=82=E5=85=A8=E9=87=8F=20373=20passed=200=20failed(1=20ski?= =?UTF-8?q?p=3Dtest=5Fsuitability=20=E5=8D=A0=E4=BD=8D=E5=BE=85=20AL-07)?= =?UTF-8?q?=E3=80=82=E9=81=97=E7=95=99=EF=BC=9Achat=20=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=20risk=5Fsuitability=5Flog.actor=5Fid=20=E6=9A=82=E8=90=BD=20S?= =?UTF-8?q?YSTEM(run=5Ftool=20=E7=BB=9F=E4=B8=80=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=E9=9C=80=E5=8A=A8=20core=5Ftools/kb=5Ftools=20=E5=86=BB?= =?UTF-8?q?=E7=BB=93=20func=20=E5=A5=91=E7=BA=A6,=E5=8F=91=E8=B5=B7?= =?UTF-8?q?=E8=80=85=E7=95=99=E7=97=95=E5=B7=B2=E7=94=B1=20agent=5Ftool=5F?= =?UTF-8?q?call=20=E8=A6=86=E7=9B=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/service/risk/chat_tools.py | 27 ++++++++++++++++----------- app/service/tool_service.py | 18 ++++++++++++++++-- tests/test_risk_api.py | 3 ++- tests/test_risk_chat_tools.py | 7 +++++-- 4 files changed, 39 insertions(+), 16 deletions(-) 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):