From 793c0307f850b8fd09f57c29f89f121f7b3fa150 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 11 Sep 2026 17:07:22 +0800 Subject: [PATCH] feat(risk): Enhance risk management functionality and access control - Updated `RiskListAccess` and `ThresholdWriteAccess` to enforce access control in the risk repository and threshold repository, ensuring only authorized roles can perform sensitive operations. - Introduced new methods in `RiskRepository` for counting pending alerts and listing alerts with access checks, improving data security and compliance. - Enhanced the `chat.py` and `deps.py` files to integrate compliance roles into the risk management matrix, allowing for more granular access control. - Updated documentation to reflect the new testing baseline of 825 passed tests, indicating improved stability and functionality across the application. This update significantly strengthens the risk management capabilities, ensuring robust access control and compliance with organizational policies. --- AGENTS.md | 2 +- app/api/chat.py | 15 +- app/api/deps.py | 4 +- app/api/risk.py | 20 +- app/gateway/jwt_service.py | 9 + app/repository/repo_access.py | 60 ++ app/repository/risk_repository.py | 32 +- app/repository/threshold_repository.py | 9 +- app/service/risk/chat_tools.py | 31 +- app/service/threshold_service.py | 5 +- docs/course/index.html | 2 +- docs/course/jinrong-module-risk/index.html | 4 +- .../modules/01-capabilities.html | 2 +- .../modules/03-tools-rest.html | 2 +- docs/course/jinrong-overview/index.html | 6 +- .../jinrong-overview/modules/05-status.html | 4 +- .../jinrong-overview/modules/08-defense.html | 2 +- docs/frontend/FRONTEND-HANDOFF.md | 10 +- docs/memory/ENVIRONMENT.md | 2 +- docs/memory/FLOW.md | 11 +- docs/memory/FRAMEWORK.md | 6 +- docs/memory/ITERATION.md | 2 + docs/memory/MEMORY.md | 28 +- docs/memory/TODO.md | 14 +- .../TEST-LOG-2026-09-10-CS-001.md | 4 +- .../TEST-LOG-2026-09-11-AN-001.md | 6 +- .../tests/2026-09-11-risk-agent-e2e/README.md | 8 + .../TEST-LOG-2026-09-11-RISK-001.md | 297 +++++++++ docs/答辩/DEMO-SOP-问数.md | 11 +- docs/答辩/答辩知识点清单.md | 10 +- .../合并注意事项-风控模块并入main.md | 2 +- docs/项目框架设计/数据分析Agent开发清单.md | 4 +- docs/项目框架设计/数据分析Agent架构说明书.md | 2 +- .../风控Agent模块边界与合并接缝标注.md | 2 +- scripts/dev/sandbox_risk_test.py | 598 ++++++++++++++++++ tests/test_chat.py | 23 + tests/test_integration_risk.py | 2 +- tests/test_risk_repository.py | 22 +- web/src/api/risk.ts | 2 + web/src/components/risk/RiskJsonResult.tsx | 44 ++ web/src/hooks/useAlertsDashboard.ts | 10 +- web/src/pages/risk/RiskAlertsPage.tsx | 27 +- web/src/pages/risk/RiskAmlScanPage.tsx | 1 - web/src/pages/risk/RiskSimulatePage.tsx | 28 +- web/src/pages/risk/RiskSuitabilityPage.tsx | 40 +- web/src/utils/displayLabels.ts | 5 + 46 files changed, 1308 insertions(+), 122 deletions(-) create mode 100644 app/repository/repo_access.py create mode 100644 docs/memory/tests/2026-09-11-risk-agent-e2e/README.md create mode 100644 docs/memory/tests/2026-09-11-risk-agent-e2e/TEST-LOG-2026-09-11-RISK-001.md create mode 100644 scripts/dev/sandbox_risk_test.py create mode 100644 web/src/components/risk/RiskJsonResult.tsx diff --git a/AGENTS.md b/AGENTS.md index a93b1d3..7157893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,6 @@ app/repository/core_ro.py # Core 只读 + check_suitability(R-02) scripts/core/reset.ps1 # 本地灌 Core 模拟库 ``` -**当前分支:** `merger` · **测试基线:** `python -m pytest` → 804 passed · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test` +**当前分支:** `merger` · **测试基线:** `python -m pytest` → **827 passed**, 1 skipped · **Redis:** Docker `6380` · **前端:** `cd web && npm run build/test` 技术选型硬阀门见 MEMORY 第 3、7 节。Cursor 以 `.cursor/rules/project-memory.mdc` 为准。 diff --git a/app/api/chat.py b/app/api/chat.py index 2952da1..20ba58d 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -116,15 +116,16 @@ def _resolve_agent_type(request: Request) -> str: def _assert_chat_entry(auth: AuthContext, agent_type: str) -> None: - """对话线入口守卫(四端点共用):矩阵准入 + risk_manager 显式拒绝。 + """对话线入口守卫(四端点共用):矩阵准入 + risk_manager/compliance 显式拒绝。 C5 前置(PRD 4A.1):对话线不放行 risk_manager——HTTP 台账才放行,保住 - FR-6 冻结口径。矩阵放行解决 HTTP 通道,chat 层显式拒绝兜底(manager 根本 - 进不了对话线,Tool 层 assert_tool_access 天然 fail-closed)。会话查询/ - 关闭端点同属对话线数据面,沿用同一口径(方案 B 拍板)。 + FR-6 冻结口径。F3 把 compliance 纳入 risk 矩阵后须同口径拒对话线(仅 HTTP + aml 台账);矩阵放行解决 HTTP 通道,chat 层显式拒绝兜底。 """ assert_agent_access(auth, agent_type, risk_repo=_repo()) - if agent_type == "risk" and "risk_manager" in auth.roles: + if agent_type == "risk" and ( + "risk_manager" in auth.roles or "compliance" in auth.roles + ): deny(auth, "AUTH_403_ROLE", _repo(), message="对话线仅限 risk_officer,请走 HTTP 台账") @@ -317,7 +318,7 @@ def chat_api(req: ChatRequest, request: Request, auth: AuthContext = Depends(get # ---------- 方案 B:前端「拉」侧只读接口(会话列表 / 历史消息 / 关闭会话) ---------- # # 前端对话页三件套,与 POST "" 共用同一套入口守卫(_assert_chat_entry: -# 矩阵准入 + risk_manager 显式拒绝)与 SessionGuard(_guard_session: +# 矩阵准入 + risk_manager/compliance 显式拒绝)与 SessionGuard(_guard_session: # 仅本人会话 + agent_type 一致,越权 403 留痕)。纯读/状态流转,不改表、 # 不碰 Tool 契约;manager 与对话线保持同口径 deny(见 _assert_chat_entry)。 @@ -418,6 +419,7 @@ def close_session_api( # 已落可审计),不产生半截内容污染历史窗口。 _SSE_DONE = "data: [DONE]\n\n" +_SSE_HEARTBEAT = ": ping\n\n" def _sse(payload: dict) -> str: @@ -480,6 +482,7 @@ def chat_stream_api( "disclaimer": meta_disclaimer, } yield _chunk(trace_id, {"role": "assistant"}, meta=meta) + yield _SSE_HEARTBEAT full: list[str] = [] try: if agent_type == "customer": diff --git a/app/api/deps.py b/app/api/deps.py index 0cd37a9..b82d0eb 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -50,10 +50,10 @@ AGENT_ACCESS_MATRIX: dict[str, dict[str, tuple[str, ...]]] = { "analyst": {"token_types": ("staff",), "roles": ("analyst", "compliance")}, # C5(PRD 4A.1):增补 risk_manager——HTTP 台账请求经此交叉校验, # 不加则 manager 连 GET /api/risk/alerts 都会被 AUTH_403_AGENT_MISMATCH 挡掉。 - # 对话线不放行(FR-6 冻结口径),由 chat.py 显式 deny 兜底。 + # 对话线不放行 risk_manager/compliance(FR-6 冻结口径),由 chat.py 显式 deny 兜底。 "risk": { "token_types": ("staff", "service"), - "roles": ("risk_officer", "risk_manager", "service_risk"), + "roles": ("risk_officer", "risk_manager", "service_risk", "compliance"), }, } diff --git a/app/api/risk.py b/app/api/risk.py index 949013a..9ca0884 100644 --- a/app/api/risk.py +++ b/app/api/risk.py @@ -25,6 +25,7 @@ from pydantic import BaseModel, Field from app.api.deps import AuthContext, assert_customer_access, deny, get_auth_context from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository +from app.repository.repo_access import RiskListAccess from app.service.risk.alert_service import handle_alert from app.service.risk.aml_service import scan_all from app.service.suitability import suitability_check @@ -71,11 +72,15 @@ def list_alerts_api( pass elif auth.has_role("risk_manager"): pass + elif auth.has_role("service_risk"): + pass elif auth.has_role("compliance"): alert_type = "aml" else: - deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer/compliance only") - rows, total = _repo().list_alerts( + deny(auth, "AUTH_403_ROLE", _repo(), message="risk_officer/compliance/service_risk only") + repo = _repo() + rows, total = repo.list_alerts( + access=RiskListAccess.api(), status=status, alert_type=alert_type, customer_id=customer_id, @@ -84,8 +89,15 @@ def list_alerts_api( page=page, page_size=page_size, ) - return {"items": rows, "total": total, "page": page, "page_size": page_size, - "disclaimer": ALERT_DISCLAIMER} + stats = repo.count_pending_stats(access=RiskListAccess.api(), alert_type=alert_type) + return { + "items": rows, + "total": total, + "page": page, + "page_size": page_size, + "stats": stats, + "disclaimer": ALERT_DISCLAIMER, + } @router.post("/alerts/{alert_id}/handle") diff --git a/app/gateway/jwt_service.py b/app/gateway/jwt_service.py index 1a7a951..1d68041 100644 --- a/app/gateway/jwt_service.py +++ b/app/gateway/jwt_service.py @@ -49,6 +49,14 @@ ROLE_PERMISSIONS: dict[str, list[str]] = { "risk:suitability:write", "core:holding:read:all", ], + "risk_manager": [ + "agent:risk:chat", + "profile:l1:read", + "profile:l2:read", + "profile:l3:read", + "risk:alert:read", + "core:holding:read:all", + ], "compliance": ["audit:read:all", "agent:advisor:audit", "compliance:hit:read"], "ops": ["agent:advisor:stats", "audit:read:aggregated"], "service_risk": [ @@ -71,6 +79,7 @@ DEFAULT_ROLES_BY_ACTOR: dict[str, list[str]] = { "STAFF-20002": ["analyst"], "STAFF-30001": ["risk_officer", "risk_demo"], "STAFF-30002": ["risk_officer", "risk_demo"], + "STAFF-31001": ["risk_manager"], "STAFF-40001": ["compliance"], "STAFF-40002": ["compliance"], "STAFF-50001": ["ops"], diff --git a/app/repository/repo_access.py b/app/repository/repo_access.py new file mode 100644 index 0000000..9430ecf --- /dev/null +++ b/app/repository/repo_access.py @@ -0,0 +1,60 @@ +"""仓储调用凭证(F1 纵深防御:禁止业务代码裸调敏感读写)。 + +仅 service 层应构造 AccessToken;直调不传 token 会 TypeError/ValueError。 +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RiskListAccess: + purpose: str + + _ALLOWED = frozenset( + { + "risk_api", + "risk_chat_tool", + "risk_cron", + "unit_test", + } + ) + + def __post_init__(self) -> None: + if self.purpose not in self._ALLOWED: + raise ValueError(f"invalid RiskListAccess purpose: {self.purpose}") + + @staticmethod + def api() -> RiskListAccess: + return RiskListAccess("risk_api") + + @staticmethod + def chat_tool() -> RiskListAccess: + return RiskListAccess("risk_chat_tool") + + @staticmethod + def cron() -> RiskListAccess: + return RiskListAccess("risk_cron") + + @staticmethod + def unit_test() -> RiskListAccess: + return RiskListAccess("unit_test") + + +@dataclass(frozen=True) +class ThresholdWriteAccess: + purpose: str + + _ALLOWED = frozenset({"profile_threshold_sync", "unit_test"}) + + def __post_init__(self) -> None: + if self.purpose not in self._ALLOWED: + raise ValueError(f"invalid ThresholdWriteAccess purpose: {self.purpose}") + + @staticmethod + def profile_sync() -> ThresholdWriteAccess: + return ThresholdWriteAccess("profile_threshold_sync") + + @staticmethod + def unit_test() -> ThresholdWriteAccess: + return ThresholdWriteAccess("unit_test") diff --git a/app/repository/risk_repository.py b/app/repository/risk_repository.py index 2cee9db..c24375b 100644 --- a/app/repository/risk_repository.py +++ b/app/repository/risk_repository.py @@ -11,6 +11,7 @@ from datetime import datetime from decimal import Decimal from typing import Any +from app.repository.repo_access import RiskListAccess from sqlalchemy import text from sqlalchemy.engine import Engine @@ -258,6 +259,8 @@ class RiskRepository: def list_alerts( self, + *, + access: RiskListAccess, status: str | None = None, alert_type: str | None = None, customer_id: str | None = None, @@ -266,10 +269,12 @@ class RiskRepository: page: int = 1, page_size: int = 20, ) -> tuple[list[dict], int]: - """预警台账分页查询(PRD FR-4);过滤参数全部可选。""" + """预警台账分页查询(PRD FR-4);须带 RiskListAccess(F1 纵深防御)。""" where = ["1=1"] params: dict[str, Any] = {} - if status: + if status == "handled": + where.append("status != 'pending_review'") + elif status: where.append("status = :status") params["status"] = status if alert_type: @@ -301,6 +306,29 @@ class RiskRepository: ).mappings() return [self._parse_alert(dict(r)) for r in rows], int(total) + def count_pending_stats(self, *, access: RiskListAccess, alert_type: str | None = None) -> dict[str, int]: + """待审总量 + 今日待审(服务端本地日,供看板 F7)。""" + from datetime import date, timedelta + + where = ["status = 'pending_review'"] + params: dict[str, Any] = {} + if alert_type: + where.append("alert_type = :atype") + params["atype"] = alert_type + where_sql = " AND ".join(where) + day_start = datetime.combine(date.today(), datetime.min.time()) + day_end = day_start + timedelta(days=1) + today_sql = f"{where_sql} AND created_at >= :day_start AND created_at < :day_end" + params_today = {**params, "day_start": day_start, "day_end": day_end} + with self._engine.connect() as conn: + pending = int( + conn.execute(text(f"SELECT COUNT(*) FROM risk_alert WHERE {where_sql}"), params).scalar_one() + ) + today = int( + conn.execute(text(f"SELECT COUNT(*) FROM risk_alert WHERE {today_sql}"), params_today).scalar_one() + ) + return {"pending_review_count": pending, "today_pending_count": today} + def update_alert_status( self, alert_id: str, handler_result: str, handler_id: str, handler_comment: str | None ) -> bool: diff --git a/app/repository/threshold_repository.py b/app/repository/threshold_repository.py index 59a13f1..0b44e11 100644 --- a/app/repository/threshold_repository.py +++ b/app/repository/threshold_repository.py @@ -6,6 +6,7 @@ import json from decimal import Decimal from typing import Any +from app.repository.repo_access import ThresholdWriteAccess from sqlalchemy import text from app.config.database import get_agent_engine @@ -38,7 +39,13 @@ class ThresholdRepository: with self._engine.connect() as conn: return [str(r[0]) for r in conn.execute(sql).fetchall()] - def upsert_portfolio(self, customer_id: str, loss_threshold_pct: Decimal) -> int: + def upsert_portfolio( + self, + customer_id: str, + loss_threshold_pct: Decimal, + *, + access: ThresholdWriteAccess, + ) -> int: """组合级阈值:同一客户仅保留一条 portfolio 配置(更新或插入)。""" sel = text( """ diff --git a/app/service/risk/chat_tools.py b/app/service/risk/chat_tools.py index fa4ac4b..ca40728 100644 --- a/app/service/risk/chat_tools.py +++ b/app/service/risk/chat_tools.py @@ -28,6 +28,7 @@ from typing import Any, Callable from app.config.settings import settings from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository +from app.repository.repo_access import RiskListAccess from app.service import suitability from app.service.risk.profile_l3 import get_profile_l3 from app.tool.core_tools import _jsonable @@ -80,14 +81,27 @@ def alert_query(customer_id: str, core_ro: CoreReadOnlyRepository | None = None, repo = risk_repo or RiskRepository() start = _day_start() if customer_id: - rows, total = repo.list_alerts(customer_id=customer_id, status="pending_review", page_size=50) + rows, total = repo.list_alerts( + access=RiskListAccess.chat_tool(), + customer_id=customer_id, + status="pending_review", + page_size=50, + ) _, today_total = repo.list_alerts( - customer_id=customer_id, status="pending_review", start=start, page_size=50 + access=RiskListAccess.chat_tool(), + customer_id=customer_id, + status="pending_review", + start=start, + page_size=50, ) scope = "customer" else: - rows, total = repo.list_alerts(status="pending_review", page_size=100) - _, today_total = repo.list_alerts(status="pending_review", start=start, page_size=100) + rows, total = repo.list_alerts( + access=RiskListAccess.chat_tool(), status="pending_review", page_size=100 + ) + _, today_total = repo.list_alerts( + access=RiskListAccess.chat_tool(), status="pending_review", start=start, page_size=100 + ) scope = "all" return _jsonable( { @@ -110,7 +124,10 @@ def customer_context(customer_id: str, core_ro: CoreReadOnlyRepository | None = return {"found": False, "customer_id": customer_id} l3 = get_profile_l3(customer_id, risk_repo=repo) pending, pending_total = repo.list_alerts( - customer_id=customer_id, status="pending_review", page_size=20 + access=RiskListAccess.chat_tool(), + customer_id=customer_id, + status="pending_review", + page_size=20, ) profile = ro.concentration_profile(customer_id) return _jsonable( @@ -286,7 +303,9 @@ def query_agent_behavior(customer_id: str, core_ro: CoreReadOnlyRepository | Non repo = risk_repo or RiskRepository() ro = core_ro or CoreReadOnlyRepository() agent_id = params.get("agent_id") - rows, _ = repo.list_alerts(alert_type="pattern", status=None, page_size=100) + rows, _ = repo.list_alerts( + access=RiskListAccess.chat_tool(), alert_type="pattern", status=None, page_size=100 + ) out: list[dict[str, Any]] = [] for a in rows: payload = a.get("payload") or {} diff --git a/app/service/threshold_service.py b/app/service/threshold_service.py index 701b6fb..3219891 100644 --- a/app/service/threshold_service.py +++ b/app/service/threshold_service.py @@ -12,6 +12,7 @@ from decimal import Decimal from typing import Any from app.repository.threshold_repository import ThresholdRepository +from app.repository.repo_access import ThresholdWriteAccess _LOSS_PCT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*%") @@ -32,7 +33,9 @@ def sync_threshold_from_summary(customer_id: str, summary: str) -> int | None: pct = parse_loss_threshold_pct(summary) if pct is None: return None - return ThresholdRepository().upsert_portfolio(customer_id, pct) + return ThresholdRepository().upsert_portfolio( + customer_id, pct, access=ThresholdWriteAccess.profile_sync() + ) def portfolio_pnl_pct(holdings: list[dict[str, Any]]) -> float | None: diff --git a/docs/course/index.html b/docs/course/index.html index 5867bb3..8742114 100644 --- a/docs/course/index.html +++ b/docs/course/index.html @@ -128,7 +128,7 @@

JinRong 项目现状

-

四角色、JWT 双通道、数据从哪来、merger 做到哪——含模块 8 答辩动线(804 pytest 基线)。

+

四角色、JWT 双通道、数据从哪来、merger 做到哪——含模块 8 答辩动线(825 pytest 基线)。

8 模块 · 入门首选
diff --git a/docs/course/jinrong-module-risk/index.html b/docs/course/jinrong-module-risk/index.html index b68180d..0e9a362 100644 --- a/docs/course/jinrong-module-risk/index.html +++ b/docs/course/jinrong-module-risk/index.html @@ -49,7 +49,7 @@ 风控专员 Demo STAFF-30001 带 risk_officer + risk_demo 角色: 能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。 本地登录页一键切换,改 JWT 角色后需重新登录。 - 仓库基线 merger · python -m pytest → 804 passed。 + 仓库基线 merger · python -m pytest → 825 passed。

@@ -409,7 +409,7 @@

- 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 804 passed。 + 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 825 passed。

diff --git a/docs/course/jinrong-module-risk/modules/01-capabilities.html b/docs/course/jinrong-module-risk/modules/01-capabilities.html index 291610a..8f881a4 100644 --- a/docs/course/jinrong-module-risk/modules/01-capabilities.html +++ b/docs/course/jinrong-module-risk/modules/01-capabilities.html @@ -6,7 +6,7 @@ 风控专员 Demo STAFF-30001 带 risk_officer + risk_demo 角色: 能看预警台账、处置、跑 AML 扫描,还能在模拟交易页触发规则引擎。 本地登录页一键切换,改 JWT 角色后需重新登录。 - 仓库基线 merger · python -m pytest → 804 passed。 + 仓库基线 merger · python -m pytest → 825 passed。

diff --git a/docs/course/jinrong-module-risk/modules/03-tools-rest.html b/docs/course/jinrong-module-risk/modules/03-tools-rest.html index 65cd4f1..f000a7a 100644 --- a/docs/course/jinrong-module-risk/modules/03-tools-rest.html +++ b/docs/course/jinrong-module-risk/modules/03-tools-rest.html @@ -128,7 +128,7 @@

- 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 804 passed。 + 仓库现状(2026-09-10):STAFF-30001 含 risk_demo;python -m pytest → 825 passed。

diff --git a/docs/course/jinrong-overview/index.html b/docs/course/jinrong-overview/index.html index 7b5b230..b08893c 100644 --- a/docs/course/jinrong-overview/index.html +++ b/docs/course/jinrong-overview/index.html @@ -412,7 +412,7 @@

模块 5 · 现状与下一步

现在做到哪?
怎么本地跑起来

- 分支 merger · 测试基线 804 pytest · 前端 19 Vitest · Redis Docker 6380。 + 分支 merger · 测试基线 825 pytest · 前端 22 Vitest · Redis Docker 6380。 文档入口:docs/memory/MEMORY.md §0 · 答辩提纲 docs/答辩/答辩知识点清单.md · 滚动版见模块 8。

@@ -444,7 +444,7 @@
3

.\scripts\dev\start-redis.ps1 → 6380

4

uvicorn app.main:app --reload :8000

5

cd web && npm run dev → :5173 代理 API

-
6

python -m pytest 验收 804 绿

+
6

python -m pytest 验收 825 绿

改后端后: 若浏览器问数 404,多半是 :8000 的 uvicorn 没重启,OpenAPI 里还缺新路由。 @@ -674,7 +674,7 @@

5~8 分钟 Demo
+ 必背铁律

对照 docs/答辩/答辩知识点清单.md 的滚动版:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。 - 基线 804 pytest · 19 Vitest · 分支 merger。 + 基线 825 pytest · 22 Vitest · 分支 merger。

diff --git a/docs/course/jinrong-overview/modules/05-status.html b/docs/course/jinrong-overview/modules/05-status.html index 2199b85..611f6e0 100644 --- a/docs/course/jinrong-overview/modules/05-status.html +++ b/docs/course/jinrong-overview/modules/05-status.html @@ -3,7 +3,7 @@

模块 5 · 现状与下一步

现在做到哪?
怎么本地跑起来

- 分支 merger · 测试基线 804 pytest · 前端 19 Vitest · Redis Docker 6380。 + 分支 merger · 测试基线 825 pytest · 前端 22 Vitest · Redis Docker 6380。 文档入口:docs/memory/MEMORY.md §0 · 答辩提纲 docs/答辩/答辩知识点清单.md · 滚动版见模块 8。

@@ -35,7 +35,7 @@
3

.\scripts\dev\start-redis.ps1 → 6380

4

uvicorn app.main:app --reload :8000

5

cd web && npm run dev → :5173 代理 API

-
6

python -m pytest 验收 804 绿

+
6

python -m pytest 验收 825 绿

改后端后: 若浏览器问数 404,多半是 :8000 的 uvicorn 没重启,OpenAPI 里还缺新路由。 diff --git a/docs/course/jinrong-overview/modules/08-defense.html b/docs/course/jinrong-overview/modules/08-defense.html index a21dad8..8d5829f 100644 --- a/docs/course/jinrong-overview/modules/08-defense.html +++ b/docs/course/jinrong-overview/modules/08-defense.html @@ -4,7 +4,7 @@

5~8 分钟 Demo
+ 必背铁律

对照 docs/答辩/答辩知识点清单.md 的滚动版:先讲清四角色不互调 LLM,再按动线演示,最后主动说边界。 - 基线 804 pytest · 19 Vitest · 分支 merger。 + 基线 825 pytest · 19 Vitest · 分支 merger。

diff --git a/docs/frontend/FRONTEND-HANDOFF.md b/docs/frontend/FRONTEND-HANDOFF.md index 83d9c6a..9dabe31 100644 --- a/docs/frontend/FRONTEND-HANDOFF.md +++ b/docs/frontend/FRONTEND-HANDOFF.md @@ -25,6 +25,10 @@ uvicorn app.main:app --reload Vite 开发服务器默认使用 `http://127.0.0.1:5173`,并将 `/api` 代理到 `http://127.0.0.1:8000`。路由使用 `HashRouter`,页面 URL 形如 `http://127.0.0.1:5173/#/app/customer/home`。 +**环境自检(2026-09-11):** 后端 `GET /api/ready`(Redis + 关键路由)· 前端顶栏 `DevReadyBanner`(开发态提示,不替代生产探针)。 + +**测试基线:** 全仓 `python -m pytest` → **825 passed**, 1 skipped · `cd web && npm run test` → **22** Vitest。 + ## 3. 依赖方向 ```text @@ -162,7 +166,7 @@ Tailwind 负责: - 客户显示总市值、累计盈亏、昨日收益和持仓数;眼睛按钮只切换 `****`,不改变表格行数据;图表、分页、排序、详情和助手链接可用。 - 理财师显示客户数、总 AUM 和最大客户 fallback;客户市值降序、客户与 Chat 链接可用。 - 分析员显示产品数、上涨/下跌/平盘;按日变化绝对值排序,行情/问数/Chat 链接可用。 -- 风控显示待审数量和 API disclaimer;无预警时显示 Empty,不出现假图表或假表格;有预警时显示图表、处置和助手链接。 +- 风控显示待审数量和 API disclaimer;台账 **`GET /alerts` 的 `stats.pending`** 与「已处置」聚合筛选(`status=handled`);适当性/AML/模拟页为结构化结果(非裸 JSON);无预警时显示 Empty,不出现假图表或假表格;有预警时显示图表、处置和助手链接。 - **四角色 Chat(含客户 SSE)**:…侧栏每条可删除(调用 close API);侧栏**仅展示 active**;**清空历史会话** 调用 `POST /api/chat/sessions/close-all`(消息仍留库,仅关闭续聊)。 - API 错误态显示 code、message、trace ID 和重试;loading 态保留 shell 与 skeleton。 - 检查正收益红色、负收益绿色、零值灰色。 @@ -206,7 +210,7 @@ npm run lint | 理财师 | `#/app/advisor/home` | 名下客户 AUM 分布 | | 分析 | `#/app/analyst/home` | 市场/产品概览 | | 风控 | `#/app/risk/home` | 预警类型分布 | -| 问数 | `#/app/analytics/query` | MetricCard(`/api/analyst/dashboard`)· 无钻取(D-12 未做) | +| 问数 | `#/app/analytics/query` | MetricCard(`/api/analyst/dashboard`)· **抽样溯源 / 转人工**(N-03/N-07)· 无钻取(D-12 未做) | **为何每次进页都转圈:** @@ -216,4 +220,4 @@ npm run lint **明日优化方向(见 `TODO.md` §2026-09-11):** 批量净值 API · 客户端 stale-while-revalidate · 可选 Dashboard 短 TTL 缓存(后端)。 -**客户「产品趋势」:** 客户助手 Chat 仅 **C-05 最新净值**;走势/预测 reject。统计类趋势走 **问数工作台** + customer **`self` 域**(`数据分析Agent-合并说明.md` §9.6)。分析对话页仍为占位。 +**客户「产品趋势」:** 客户助手 Chat 仅 **C-05 最新净值**;走势/预测 reject。统计类趋势走 **问数工作台** + customer **`self` 域**(`数据分析Agent-合并说明.md` §9.6)。**分析对话 URL 已重定向问数**。 diff --git a/docs/memory/ENVIRONMENT.md b/docs/memory/ENVIRONMENT.md index 9b7b40b..d52efd3 100644 --- a/docs/memory/ENVIRONMENT.md +++ b/docs/memory/ENVIRONMENT.md @@ -25,7 +25,7 @@ - **内部分析**:SQL 或 IT 导表。 - **风控**:事后/T+1 发现异常;适当性靠交易前规则;AML 批处理或人工。 -Agent **尚未上线**;后端风控全链路 + Wave 0 共用底座 + 代销平台 API v0.1 已在 **`merger` 分支**集成(AL-09 完成,**530 pytest 绿**):事件线 B1~B9b · 对话线 C1~C6 · 知识库 T-21 · chat sessions/stream · 平台 REST v0.1;客户/代理人/分析 Agent 仍为 chat 骨架,无独立业务 Tool;无生产接入。 +Agent **尚未上线**;后端已在 **`merger` 分支**集成(AL-09 + 客服 Wave3 + 问数 S3/D-06/N-03/N-07 + 风控 TEST-RISK-001 修复 + 平台 API v0.1 + `/api/ready`):**`python -m pytest` → 825 passed 1 skipped**(集成需本机 MySQL/Redis);无生产 Core/托管接入。代理人/分析 **chat 骨架**仍为主,问数/客服/风控已有独立业务链路。 ------ diff --git a/docs/memory/FLOW.md b/docs/memory/FLOW.md index 5159853..9c41fea 100644 --- a/docs/memory/FLOW.md +++ b/docs/memory/FLOW.md @@ -43,9 +43,10 @@ ⑥ 验证 uvicorn app.main:app --reload GET http://127.0.0.1:8000/health → {"status":"ok"} + GET http://127.0.0.1:8000/api/ready → Redis + 路由自检(前端 DevReadyBanner) cd web && npm run dev → 5173 代理 8000 - python -m pytest → 816 passed 1 skipped - 问数答辩前:见 `docs/答辩/DEMO-SOP-问数.md`(灌 metric+template 种子 · battery 可选) + python -m pytest → 825 passed 1 skipped + 问数答辩前:见 `docs/答辩/DEMO-SOP-问数.md`(`scripts/dev/seed_analyst.ps1` · battery 可选) RBAC 联调账号:scripts/dev/rbac-seed-reference.md ``` @@ -60,11 +61,11 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md Client → Gateway(JWT/RBAC) → api/chat → agent_service(LangGraph) → Tools → 存储 → 响应 + audit_log ``` -当前:**风控全链路 + Wave 0 共用底座 + 代销平台 v0.1 + AL-09 + 客服 S2 Wave3 + 数据分析 D-06 + 前端 P0 Demo(2026-09-10,`merger`)**。**测试基线:`python -m pytest` → 804 passed 1 skipped** · **`web/` 19 Vitest 绿 · `npm run build` 绿**。 +当前:**风控全链路 + Wave 0 共用底座 + 代销平台 v0.1 + AL-09 + 客服 S2 Wave3 + 数据分析 D-06/N-03/N-07 + 前端 P0 Demo(2026-09-11,`merger`)**。**测试基线:`python -m pytest` → 825 passed 1 skipped** · **`web/` 22 Vitest 绿 · `npm run build` 绿**。 -**下一步(统筹 P1):** merger commit · 20 题 battery · 客服验收 · analyst D-09/N-03/N-07 · **客服** L1/L2 Redis(风控不做 L1/L2)。 +**下一步(统筹 P1):** merger commit · 20 题 battery · 客服/风控 E2E 详测 · analyst **D-09 多轮** · **客服** L2 Redis(风控不做 L1/L2)。 -**本机已就位状态(2026-09-10):** `.env` 含 `REDIS_URL=redis://127.0.0.1:6380/0`;pytest **804 绿**;问数 D-06 结果+模板缓存已接;前端四角色 Demo 可 walkthrough。 +**本机已就位状态(2026-09-11):** `.env` 含 `REDIS_URL=redis://127.0.0.1:6380/0`;pytest **825 绿**;问数 D-06 + sample/escalate;`GET /api/ready`;前端四角色 Demo 可 walkthrough。 **本机已知坑:** `mysql.exe` 不在 PATH;`reset.ps1` 自动化用 `MYSQL_PWD`;**6379 常被 Windows Redis 3.x 占用** → 项目 Docker 映射 **6380**;Milvus 中文路径需英文 `MILVUS_URI`;问数模板需灌 `seed-analyst-query-templates.sql` 才见蓝色「模板命中」标签;`battery_report.json` **不入库**(`.gitignore`)。 diff --git a/docs/memory/FRAMEWORK.md b/docs/memory/FRAMEWORK.md index 1e9a17e..14a1211 100644 --- a/docs/memory/FRAMEWORK.md +++ b/docs/memory/FRAMEWORK.md @@ -35,10 +35,10 @@ | 模块 | 职责 | 依赖 | 代码状态 | | --- | --- | --- | --- | | Agent Gateway / Auth SDK | JWT、RBAC、归属校验 | Redis、MySQL customer_advisor_rel | **已实现(T-01 + AL-09)**:模块 `service/auth_service.py` + `api/deps.py`;宿主 `gateway/` 四件套并存;`/api/auth/login` 统一走 `issue_dev_token`;S2 接缝 `auth_adapter.module_auth_from_host` | -| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **S2 + Wave3(2026-09-10)**:Chat SSE · 1B/R1 · C-04/C-05/C-11 · **804 pytest** +| 客户财富 Agent | L1 画像、事实查询、阈值提醒 | Core RO、Milvus 产品库 | **S2 + Wave3(2026-09-11)**:Chat SSE · 1B/R1 · C-04/C-05/C-11 · L1 懒读 · **825 pytest** | 代理人助手 Agent | L2 画像、RAG、草稿 | L1 只读、Milvus | 空壳 service(chat 骨架已通) | -| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | **S3+P2+D-06+D-09 子集(2026-09-11)**:interpret 拆分 · sql_guard **Q17** · 答辩模板种子 · **816 pytest** | -| 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B9b + C1~C6(FR-1~10)**:事件线 + 对话线 + 集中度/时效升级/代理人行为链;**AL-09 已并入 `merger` 分支** | +| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | **S3+P2+D-06+D-09 子集 + N-03/N-07(2026-09-11)**:interpret 拆分 · sql_guard **Q17** · sample/escalate · **825 pytest** | +| 风控监测 Agent | 预警、L3、R-02 适当性 | 交易事件、AML 名单 | **已实现 B1~B9b + C1~C6(FR-1~10)** + **TEST-RISK-001 修复**;**AL-09 已并入 `merger` 分支** | | Core 只读层 | L0 事实查询 | `jinrong_core` | **已实现 + 已接对话 Tool(T-04)**:core_ro 经 app/tool/core_tools.py 三只读 Tool(L0/持仓/流水)进 chat;风控扩展查询照旧 | | 共用底座 | 会话、审计、输入防护 | MySQL 11 表 + Redis | **已接入(2026-09-07)**:会话(T-06 session_repository + memory_service 窗口)、审计中间件(T-02 http_access + input_guard_log 双写)、agent_tool_call Tool 留痕(T-04)、输入防护(T-03 input_guard:注入词表纯函数检测 + oversize + Redis 固定窗口限流,chat 链路 限流→注入/超长→归属) | | 对话编排 | LangGraph StateGraph + DeepSeek | langgraph/langchain-openai | **已实现**:customer/advisor/risk/analyst 四线;**analyst 问数**独立 `analyst_agent`(非 chat StateGraph) | diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md index 74fde35..c90c1c9 100644 --- a/docs/memory/ITERATION.md +++ b/docs/memory/ITERATION.md @@ -30,5 +30,7 @@ | 2026-09-10 | **前端 E2E 问题清单收口**:`AuthProvider` 修登录崩溃/退出死循环 · Redis `socket_connect_timeout` 0.5s · 预警筛选防抖 · I18N formatter · 对话 Markdown 粗体 · A11Y `index.html` · AntD `` | `docs/整体测试/前端整体测试交接.md` 登记项 | web · `database.py` · MEMORY | | 2026-09-10 | **答辩/课程**:`docs/答辩/答辩知识点清单.md` · 总览模块 8 · 问数课 D-06 模块 5 · 导览中心更新 | 用户答辩准备 | docs/course · MEMORY | | 2026-09-10 | **客服问候修复**:「你好」关键词 → `chit_chat` · LLM 失败 `CHITCHAT_DEGRADED_TEXT`(非 FALLBACK)· 游客问候快路由 | 用户反馈 Chat 无法回答问候 | customer_prompts · customer_service · visitor · tests | +| 2026-09-11 | **TEST-RISK-001 修复**:`RiskListAccess`/`ThresholdWriteAccess` · service_risk 只读 GET · compliance 进 risk 矩阵 · STAFF-31001 · handled 筛选 · 台账 stats · 前端标签/409/结构化 · SSE ping · **825 pytest** | 沙盘 TEST-LOG v1.1 | `repo_access` · `risk.py` · `deps.py` · web risk pages | +| 2026-09-11 | **问数 N-03/N-07 + 自检**:`GET .../sample` · `POST /escalate` · `GET /api/ready` · DevReadyBanner | Superpowers 批次 | `analyst.py` · `ready.py` · web | | 2026-09-11 | **TEST-AN-001 RBAC 修复**:sql_guard A/B · `_audit_terminal` · TEST-LOG v1.2 · **820 pytest** | 沙盘测试文档 | `sql_guard` · `analyst_agent` | | 2026-09-10 | **TODO 日终清单 + 优化 TODO 补全**:硬伤盘点落账 · 2026-09-10 已完成/进行中/将要做 | 用户要求 | TODO · ITERATION | diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 88dbe9b..4e773bd 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -9,7 +9,7 @@ **项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。 -**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 Wave3 + 数据分析 D-06/D-09 解读拆分 + sql_guard RBAC 修复** · **820 pytest** · **22 Vitest** · **Redis @ 6380** · **`merger` 未 commit** +**当前进度:** 需求与表设计已定 · **风控 TEST-RISK-001(含 F12)** · **827 pytest** · **22 Vitest** · **Redis @ 6380** · **`merger` 未 commit** **工作分支:** 团队开发在 **`merger`**;历史 `risk-control-agent` 交付冻结。 @@ -21,10 +21,11 @@ | `app/api/auth.py` | **已实现(AL-09)** | Mock 登录;签发走 `auth_service.issue_dev_token`(与模块 API 同一 issuer) | | `app/api/auth_adapter.py` | **已接线(S2)** | `module_auth_from_host()`:宿主 AuthContext → 模块 AuthContext | | `app/gateway/` | **宿主 Wave 0(并存)** | jwt_service / auth_deps / rbac / ownership;模块 API 走 `deps.py`,禁止模块 import gateway | -| `app/api/risk.py` `simulate.py` `deps.py` | **已实现** | 风控 4 API + 模拟网关路由 + **JWT 鉴权工厂(T-01:Bearer 全环境优先;debug 头仅 dev+无 RS256 公钥时兜底;AGENT_ACCESS_MATRIX 准入)** | +| `app/api/risk.py` `simulate.py` `deps.py` | **已实现** | 风控 4 API + 模拟网关 · **compliance/service_risk 矩阵(F2/F3)** · 台账 **`stats` 待审计数(F7)** | | `app/api/chat.py` `audit_middleware.py` | **已实现** | POST /api/chat · **POST /api/chat/stream(customer 分流 → prepare_customer_stream)** · visitor · sessions 三端点 | | `app/api/customers.py` `products.py` `advisors.py` `staff.py` `compliance.py` | **已实现(v0.1)** | 代销平台 REST;`get_platform_auth_context`(无 X-Agent-Type);Service 层 `app/service/platform/` | -| `app/api/analyst.py` `analyst_auth_adapter.py` | **已实现(S3+D-06+D-09 子集)** | 问数 `POST /api/analyst/chat`(默认 `interpret=false` 仅表)· **`POST /api/analyst/interpret` 按需解读** · dashboard/assets/metrics · **`get_platform_auth_context`** · `meta.template_hit` / `cache_hit` | +| `app/api/analyst.py` `analyst_auth_adapter.py` | **已实现(S3+D-06+D-09 子集 + N-03/N-07)** | 问数 `POST /api/analyst/chat`(默认 `interpret=false`)· **`POST /api/analyst/interpret`** · **`GET /query/{trace_id}/sample`** · **`POST /escalate`** · dashboard/assets/metrics | +| `app/api/ready.py` | **已实现(2026-09-11)** | `GET /api/ready`:Redis + 关键路由自检;前端 `DevReadyBanner` | | `app/service/analyst_agent.py` `template_service.py` `cache_service.py` | **已实现(S3+D-06)** | NL2SQL 编排 · guardrail · **结果缓存(表世代键)+ 写侧 bump**(`analyst_cache_invalidate`)· **模板填参**(published `analytics_query_template`) | | `app/api/knowledge.py` `admin.py` | 空壳 | 待审计查询台与知识库 API(T-21 拍板一期只做脚本入库,上传/重建端点不做) | | `app/service/platform/` | **已实现(v0.1)** | 封装 core_ro + `PLATFORM_RESPONSE_DESENSITIZE` 脱敏开关 | @@ -37,7 +38,9 @@ | `app/repository/session_repository.py` | **已实现(T-06/T-04 + 前端接入 B/C)** | agent_session / agent_message / agent_tool_call 读写;**方案 B 增 `list_sessions`(分页+total)/ `list_messages_page`(seq 升序分页,勿与 LLM 窗口的 `list_messages` 混用)/ `close_session`(条件更新防并发)**;**方案 C 增 `insert_turn`(user+assistant 同事务落库 + 事务内取 seq,修评审 P0/P1)** | | `app/gateway/` | **已实现** | 模拟交易网关(仅 gateway_repository 可 INSERT core_trade,B5) | | `app/repository/core_ro.py` | **已实现** | Core 只读 SELECT(含风控扩展 sum_trades_on_date / list_trades_range / list_active_customers);**阶段一吸收 main:check_suitability(C×R 矩阵判定,CURDATE() 改 Python 端 `_is_expired`)/ list_products_for_customer / list_holdings 合并(limit=500+新列)/ list_trades / get_customer_l0 扩列版** | -| `app/repository/risk_repository.py` | **已实现** | risk_alert / risk_suitability_log / L3 / risk_aml_list / audit_log / input_guard_log 读写 | +| `app/repository/risk_repository.py` | **已实现** | risk_alert / … · **`list_alerts` 须 `RiskListAccess`(F1)** · **`status=handled` 聚合筛选** · `count_pending_stats` | +| `app/repository/repo_access.py` | **已实现(2026-09-11)** | `RiskListAccess` / `ThresholdWriteAccess` 仓储调用凭证 | +| `app/repository/threshold_repository.py` | **已实现** | 阈值配置 · **`upsert_portfolio` 须 `ThresholdWriteAccess`** | | `app/utils/` | **基本就绪** | trace(trace_id+request_id 双 contextvar)/ desensitize / db(引擎工厂)/ response(统一错误体+4xx/500 handler)/ exceptions(含 ApiError)已实现;logger 占位 | | `app/config/database.py` | **已实现** | MySQL 双引擎 + **Redis 单例**(`REDIS_URL` · **RESP2 `protocol=2`** 兼容 Docker Redis 7 / 旧 Windows Redis 3) | | `app/config/settings.py` | **已实现** | 双库 + risk_* 阈值 + JWT + **customer/visitor/profile** 字段 + `kb_root_dir` | @@ -45,9 +48,9 @@ | `app/service/threshold_service.py` | **已实现(C-04)** | L1 `threshold_pref_summary` → `customer_threshold_config`;持仓查询加权盈亏 vs 阈值 → `customer_notify_log` | | `app/utils/sanitize_postprocess.py` | **已实现(1B 共用)** | 数据查询 intent(含 nav_query)违禁 → 回退 fact_text;RAG/闲聊 → COMPLIANCE_REJECT;均不自动 transfer | | `scripts/core/*.sql` + `reset.ps1` | **已实现** | Core 模拟库 DDL + 种子 | -| `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | AML 种子 · **`prepare_all.ps1` 一键灌库** · `seed-analyst-query-templates.sql`(模板缓存)· `run_query_battery.py`(**不入库**)· `start-redis.ps1` | +| `scripts/agent/` `scripts/demo/` `scripts/dev/` | **已实现** | **`prepare_all.ps1`** · **`push_threshold_alerts.py`** · **`seed_analyst.ps1`** · **`sandbox_risk_test.py`** · `run_query_battery.py`(**不入库**) | | `scripts/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 | -| `tests/` | **已实现** | **820 用例** 1 skipped(Wave6 sql_guard RBAC + interpret + customer) | +| `tests/` | **已实现** | **827 用例** 1 skipped | | `docs/答辩/` | **答辩提纲 + Demo SOP** | `答辩知识点清单.md` · **`DEMO-SOP-问数.md`**(套餐 ①)· spec `docs/superpowers/specs/2026-09-11-defense-stable-package.md` | | `docs/course/` | **交互课程集** | 导览中心 + 总览 **8 模块** + 问数 **5 模块**(D-06)+ 风控深潜 **7 模块** · 与答辩清单同步 | | `docs/PRD/PRD-风控监测Agent.md` | **已冻结(v1.1)** | 风控 PRD v1.0 + v1.1 追加 FR-8/9/10(§4A)+ 规则表附录 | @@ -73,7 +76,7 @@ (风控演示:`.\scripts\demo\prepare_all.ps1` 或 `prepare_risk_demo.sql` · PRD §10.2) 6. python scripts/sync/sync_advisor_rel.py && python scripts/sync/sync_neo4j.py 7. `docker compose up -d redis`(或 `.\scripts\dev\start-redis.ps1`)→ **REDIS_URL=redis://127.0.0.1:6380/0**(Docker Redis 7;避开本机 Windows Redis 占 6379) -8. uvicorn … · python -m pytest(**820 绿**);问数答辩:`docs/答辩/DEMO-SOP-问数.md` · 种子 `seed-analyst-metric-dict.sql` + **`seed-analyst-query-templates.sql`** +8. uvicorn … · python -m pytest(**827 绿**);问数答辩:`docs/答辩/DEMO-SOP-问数.md` · 种子 **`scripts/dev/seed_analyst.ps1`** ``` **AL-09 合并后架构(一句话):** 宿主 `gateway/` + 模块 `deps.py` **双栈并存**;对外登录/token **统一**;chat/risk 均走模块鉴权;接缝 S2 用 `auth_adapter`。 @@ -166,12 +169,12 @@ audit_log 等审计表(只 INSERT) | --- | --- | --- | | **1B** | 数据查询 intent(持仓/流水/风评/适当性/**净值**)经 `sanitize_reply` 命中违禁词 → **回退 `fact_text`,不转人工**;RAG/闲聊命中 → `COMPLIANCE_REJECT`,**也不自动 transfer**;**customer + visitor 共用** | `app/utils/sanitize_postprocess.py` · `finalize_sanitized_reply` | | **R1** | L1 `product_preferences` / `excluded_products` 合并时写 `items_meta`;注入 prompt **Top-K + 时间衰减 + TTL**(K=3 · TTL=90d · 半衰 30d) | `profile_service` · `settings.profile_preference_*` | -| **C-04** | 用户口述「亏 X% 提醒我」→ L1 摘要 + `customer_threshold_config`;**查持仓时**组合加权盈亏达线 → 追加提醒 + `customer_notify_log`(非 push/cron) | `threshold_service` · `core_ro_tool.query_holdings` | +| **C-04** | 持仓内联提醒 + **演示 push**:`POST /api/customers/{id}/threshold-check?push=` · `push_threshold_alerts.py` · 持仓页按钮(**无定时 cron**) | `threshold_service` · `customers.py` | | **C-05** | 「最新净值/单位净值」→ Core `get_latest_nav`(快照,非实时盘口);**「实时净值」仍 reject** | `core_ro_tool.query_product_nav` · intent `nav_query` | | **C-11** | 「我能买什么/匹配产品」→ `suitability_check` + Core 可购列表;**「推荐稳赚/买什么好」仍 reject** | `customer_prompts._ELIGIBLE_PRODUCTS_KW` | | **C-07** | 风评查询走 Core;**「重新测评/重做风评」** → 引导 App/网点(Agent 内不做问卷) | `customer_prompts._RISK_KW` | | **C-08** | 仅 L1 槽位 `investment.allocation_target`(13 槽);**无自动偏离检测/调仓** | `profile_slots.py` | -| **问数 vs 分析对话** | **问数页**:NL2SQL+表 · 按钮 **「分析该数据」**(客户 **「解读我的数据」**)→ `/api/analyst/interpret`,上下文**仅本轮问题+查数结果**;deny/clarify **不调 LLM** · **分析对话**菜单仍为 `agent_service` stub,待下线/强引导 | spec `docs/superpowers/specs/2026-09-11-query-interpret-split-design.md` | +| **问数 vs 分析对话** | 问数页 + **`/interpret`** · **N-03 抽样溯源** · **N-07 escalate/失败转人工** · 分析对话 URL **重定向问数** | spec `2026-09-11-query-interpret-split-design.md` | | **客户「趋势/走势」** | **客户 Chat**:仅 **C-05 最新净值快照** + 持仓/流水;**走势预测/实时盘口 reject**;**无 Chat 内净值历史曲线 Tool** · **统计类趋势**(近 N 日笔数、结构描述)→ **问数** `POST /api/analyst/chat` · **`self` 域**(拍板 `数据分析Agent-合并说明.md` §9.6 · 尾注「AI 分析有风险」)· **D-12 看板钻取未做** | 问数 ≠ 客户助手 · Phase B 行情 sync 未做 | | **L0 优先** | 抽槽与 L0 撞车**永远听 L0**;L1 只 enrich 措辞 | `profile_slots` D7 | @@ -185,9 +188,10 @@ Agent 库 SQL:docs/项目框架设计/表设计/01-mysql-共用底座.sql · 0 Core 模拟:scripts/core/reset.ps1 · 文档 docs/项目框架设计/Core模拟底座/ 种子:scripts/agent/seed-aml-list.sql(AML 名单)· scripts/demo/prepare_risk_demo.sql(reset 后重跑) 依赖:requirements.txt(LangGraph + langchain-core/openai + FastAPI + SQLAlchemy) -启动:uvicorn app.main:app --reload → GET /health +启动:uvicorn app.main:app --reload → GET /health · **GET /api/ready**(环境自检) Redis:`docker compose up -d redis` · `REDIS_URL=redis://127.0.0.1:6380/0` · `scripts/dev/start-redis.ps1` -测试:python -m pytest(**820 绿**;集成需本机 MySQL + AML + 风控演示数据) +测试:python -m pytest(**827 绿**;集成需本机 MySQL + AML + 风控演示数据) +风控沙盘:`python scripts/dev/sandbox_risk_test.py`(TEST-RISK-001) 前端:cd web && npm run dev · npm run build/test/lint(**22** Vitest)· 四角色 Demo 见 `docs/frontend/FRONTEND-HANDOFF.md` §8 问数模板:mysql … < scripts/agent/seed-analyst-query-templates.sql 风控一键灌库:.\scripts\demo\prepare_all.ps1 @@ -238,6 +242,6 @@ RBAC 联调账号:scripts/dev/rbac-seed-reference.md 2. 改动属于 api / service / tool / repository 哪一层? 3. 是否需 customer_id 归属与 JWT RBAC? 4. Core 是模拟库只读还是 agent 库读写? -5. 如何验证?(`python -m pytest` **820 绿** · Redis **6380** · 问数 Demo 见 `docs/答辩/DEMO-SOP-问数.md`) +5. 如何验证?(`python -m pytest` **827 绿** · Redis **6380** · `/api/ready` · 问数 Demo 见 `docs/答辩/DEMO-SOP-问数.md`) 大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。 diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 32e72f5..2e4f860 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -5,7 +5,7 @@ ## 进行中 -**2026-09-10 批次**(`merger` · 基线 **816 pytest** · `npm run build` 绿):客服 Wave3 + analyst D-06 + **D-09 解读拆分/答辩稳** + 风控前端 · **未 commit** +**2026-09-11 批次**(`merger` · 基线 **825 pytest** · `npm run build` 绿):TEST-RISK-001 修复 · N-03/N-07 · `/api/ready` · C-04 演示 push · 画像 L1 懒读 · **未 commit** ### 本批次 · 优先收尾(推荐顺序) @@ -14,7 +14,7 @@ - [ ] **数据分析 · 20 题 live battery**:`run_query_battery.py` 已加 `interpret: false` · 本机 `uvicorn` + DeepSeek 跑完写 `battery_report.json`(**不入库**) - [x] **口径字典/问数模板种子**:`scripts/dev/seed_analyst.ps1` → `seed-analyst-metric-dict.sql` · `seed-analyst-query-templates.sql` - [x] **接口契约发群稿**:`docs/项目管理/接口契约发群-2026-09-09.md`(复制到群即完成 · TODO 已勾) -- [x] **TEST-AN-001 签核(开发侧)**:`TEST-LOG-2026-09-11-AN-001.md` v1.3 · A/B/D 已修 · **824 pytest** +- [x] **TEST-AN-001 签核(开发侧)**:`TEST-LOG-2026-09-11-AN-001.md` v1.3 · A/B/D 已修 · **825 pytest**(含 RISK 修复 +1) - [ ] **前端 E2E 补齐未跑测试**(见下方「前端 E2E · 未跑测试」;2026-09-10 走查只做到「路由能渲染」,**不是逐功能详测**) ### 2026-09-10 日终 · 工作清单(给用户/答辩交接) @@ -48,12 +48,13 @@ **Tonight 已做** -- [x] **TEST-AN-001 问数 RBAC**:修复 sql_guard 缺口 A/B + deny 审计 D · v1.2 测试日志 · **820 pytest** +- [x] **TEST-AN-001 问数 RBAC**:修复 sql_guard 缺口 A/B + deny 审计 D · v1.2 测试日志 · **820 pytest**(后续 RISK/N-03 等 → **825**) - [x] `docs/答辩/答辩知识点清单.md` + 课程对齐(总览模块 8 · 问数 D-06 模块 5) - [x] 客服「你好」→ 问候 `keyword_route` + `CHITCHAT_DEGRADED_TEXT`(`customer_service` / `visitor_service` · 单测 `test_greeting_chitchat_without_llm`) - [x] **前端 E2E 清单收口**(`AuthProvider` · Redis 连接超时 · 预警防抖 · `displayLabels` · 对话粗体 · 登录/a11y · `docs/整体测试/前端整体测试交接.md`) - [x] **ChatPanel CHAT-1~4**:流式 `session_id` · `sessionStorage` 恢复 · 固定高度/侧栏分页 · 用户气泡白字 · 删除/清空会话 - [x] **会话 API**:`GET /sessions?status=` · `POST /sessions/close-all` · 前端 active 双滤 + close 兜底 · `test_chat` 增补 +- [x] **TEST-RISK-001 修复(F1–F11 子集)**:`repo_access` · service_risk 只读 · compliance 矩阵 · **825 pytest** · 日志 v1.1 **明天优先** @@ -114,7 +115,7 @@ - [ ] **分析对话菜单** — **已重定向** `/analytics/chat` → 问数;菜单项已删(2026-09-11 答辩稳) - [ ] **前端角色路由守卫**:`menus.tsx` 允许路径 ↔ URL 不一致时重定向(改 Hash 进别角色工作台仅 UX,后端仍 403) -- [ ] **SSE 断流**:半条 assistant 的提示/重试或续发策略(四角色 `ChatPanel` stream) +- [ ] **SSE 断流**:半条 assistant 的提示/重试或续发策略(四角色 `ChatPanel` stream)· **心跳注释帧已加**(`: ping`),断连重试仍 open - [ ] **会话侧栏性能**:后端稳定后改为 `status=active` 分页即可,去掉全量扫页 + 双端 close 兜底 **P2 · 展示 / 数据 / 闭环** @@ -186,7 +187,7 @@ ### 数据分析 Agent · S3 接缝(2026-09-09 已接线) -> 清单:`docs/项目框架设计/数据分析Agent-合并说明.md` · **804 passed** · 冒烟 `scripts/dev/smoke_analyst.py` +> 清单:`docs/项目框架设计/数据分析Agent-合并说明.md` · **825 passed** · 冒烟 `scripts/dev/smoke_analyst.py` - [x] merge + 接缝(`analyst_router` · `analyst_auth_adapter` · 问数走 `get_platform_auth_context`) - [x] customer `self` 域 + 「AI 分析有风险」尾注 @@ -208,7 +209,7 @@ - [x] **D-06 写侧主动失效**(2026-09-10):表世代 bump · 模拟交易 `core_trade` · 预警/L3 写侧 · `invalidate_tables` + 单测 - [x] **D-06 模板缓存**(2026-09-10):`template_service` 匹配+填参 · 问数编排跳过 LLM · 种子 `seed-analyst-query-templates.sql` - [ ] **D-06 PII 脱敏后再缓存** — 暂缓(全仓脱敏方案未定) -- [ ] **已知挂账(迭代文档)**:Q17 `create` 误杀 · Q7 无城市字段静默改职业 · 待排期 +- [ ] **已知挂账(迭代文档)**:Q7 无城市字段静默改职业 · 待排期(**Q17 已修**) - [ ] **远程分支同步**(可选):本地 `data-analysis-agent` @ `fd9464d` 落后 `xinghuo/data-analysis-agent` 2 commit ### 风控 Agent · 2026-09-10 拍板收口 @@ -231,6 +232,7 @@ **P1 · 演示一键灌库(2026-09-10 完成)** - [x] **`scripts/demo/prepare_all.ps1`**:双库 DROP → core + agent 种子 → AML → prepare_risk_demo → sync_advisor_rel +- [x] **TEST-RISK-001 修复**:见 `docs/memory/tests/2026-09-11-risk-agent-e2e/TEST-LOG-2026-09-11-RISK-001.md` v1.1 · `sandbox_risk_test.py` D 节期望 `TypeError` **P2 · 深潜课(2026-09-10 完成)** diff --git a/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md b/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md index 86914e5..7c9aab5 100644 --- a/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md +++ b/docs/memory/tests/2026-09-10-customer-1b-r1/TEST-LOG-2026-09-10-CS-001.md @@ -35,7 +35,7 @@ | **修改人** | Andrew | | **测试执行人** | Andrew | | **评审人** | (待模块负责人确认) | -| **发布建议** | 可合并 `merger`;C-04 无 push/cron 为已知缺口 | +| **发布建议** | 可合并 `merger`;C-04 **演示 push**(`threshold-check?push=` + 脚本)已做 · **无生产 cron** | --- @@ -146,7 +146,7 @@ | --- | --- | | **需求是否覆盖** | C-01~05 已实现;C-07/08/11 部分实现(见 REQUIREMENTS) | | **旧功能是否破坏** | 否 · 786 pytest 全绿 | -| **剩余风险** | ① C-04 无 push/cron;② C-05 非实时、Phase B 未做;③ C-08 无偏离检测;④ 手工 uvicorn 走查待补 | +| **剩余风险** | ① C-04 **无定时 cron**(仅有 inline + 演示 push);② C-05 非实时、Phase B 未做;③ C-08 无偏离检测;④ 手工 uvicorn 走查待补 | | **建议人工再验** | CUST-9527:持仓+阈值 · nav_query · 「我能买什么」· 「实时净值」reject · save_note 与流水优先级 | | **是否可发布** | 后端可合并 | diff --git a/docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md b/docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md index 5ac230a..1686ac1 100644 --- a/docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md +++ b/docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md @@ -47,7 +47,7 @@ | **Redis** | Docker 6380(`CacheService.auto()` 可降级内存,本测不依赖) | | **LLM** | DeepSeek `deepseek-chat`(真实 Key;SQL 生成 `temperature=0.0`) | | **修复前测试基线** | 816 passed(解读拆分 + 答辩稳 ① 后) | -| **修复后测试基线** | **820 passed**, 1 skipped | +| **修复后测试基线** | **825 passed**, 1 skipped(含 RISK-001 修复 +5 例量级) | | **前端** | 未涉及(仅后端问数线) | --- @@ -218,7 +218,7 @@ | **是否发现实际数据泄露** | **否**(当前 LLM 软注入可靠) | | **剩余风险** | ① ~~缺口 A/B/D~~ **已修复**;② 缺口 C:profile 表白名单存在但 LLM schema 未暴露(LOW,潜在);③ LLM 软注入仍为第一道防线,硬兜底为第二道 | | **建议人工再验** | 复跑 `sandbox_domain_test.py`;库内 `analytics_query_log` 出现 `exec_status='blocked'`;更换模型后回归 §6.3 | -| **是否可发布** | 后端可合并(820 pytest) | +| **是否可发布** | 后端可合并(**825 pytest** 全仓基线) | --- @@ -228,7 +228,7 @@ | --- | --- | --- | --- | | 模块负责人 | zhangyong | | ☐ 通过 ☐ 待改(开发侧 v1.3 已闭环,待负责人签) | | 发现人 / 测试 | Andrew | 2026-09-11 | ☑ 沙盘 + 自动化回归 | -| 修改人 | Andrew | 2026-09-11 | ☑ §7 A/B/D 已修 · **824 pytest** · N-03/N-07 接续 | +| 修改人 | Andrew | 2026-09-11 | ☑ §7 A/B/D 已修 · **825 pytest**(全仓)· N-03/N-07 已接续 | --- diff --git a/docs/memory/tests/2026-09-11-risk-agent-e2e/README.md b/docs/memory/tests/2026-09-11-risk-agent-e2e/README.md new file mode 100644 index 0000000..e4a6040 --- /dev/null +++ b/docs/memory/tests/2026-09-11-risk-agent-e2e/README.md @@ -0,0 +1,8 @@ +# TEST-RISK-001 沙盘包 + +| 中文职责 | 路径 | +| --- | --- | +| 企业级测试日志(v1.0 发现 · v1.1 修复) | `TEST-LOG-2026-09-11-RISK-001.md` | +| 本机可重复沙盘脚本 | `../../../scripts/dev/sandbox_risk_test.py` | + +**当前结论:** F1–F11 子集已修 · 全仓 **`825 pytest`** · 拍板 F2=service_risk 只读 GET · F3=compliance 进 risk JWT 矩阵。 diff --git a/docs/memory/tests/2026-09-11-risk-agent-e2e/TEST-LOG-2026-09-11-RISK-001.md b/docs/memory/tests/2026-09-11-risk-agent-e2e/TEST-LOG-2026-09-11-RISK-001.md new file mode 100644 index 0000000..1ab2ba1 --- /dev/null +++ b/docs/memory/tests/2026-09-11-risk-agent-e2e/TEST-LOG-2026-09-11-RISK-001.md @@ -0,0 +1,297 @@ +# 企业级测试日志 · TEST-2026-09-11-RISK-001 + +> 风险 Agent(风控监测)端到端沙盘:表域 / 权限 / 触发方式 / 对话线 / UX 静态契约分析 + +--- + +## 1. 文档元数据 + +| 字段 | 值 | +| --- | --- | +| **测试记录编号** | TEST-2026-09-11-RISK-001 | +| **缺陷/变更标题** | 风险 Agent 全功能端到端体检;发现仓储层单层防线(高危)×1 + 角色矩阵/契约口径 ×11 | +| **文档版本** | v1.2 | +| **创建日期** | 2026-09-11 | +| **最后更新** | 2026-09-11 | +| **关联分支** | `merger` | +| **关联拍板 / TODO** | 风险 Agent(RISK-001~008)· TODO 133-159(前端逐功能详测未覆盖项) | +| **风险等级** | **MEDIUM**(F1 仓储层单层防线为高危,但仅在「直调仓储」路径可达,API 层已拦) | +| **缺陷类型** | 纵深防御缺口 ×1 + 角色矩阵路径缺口 ×3 + 前端/后端口径 ×5 + 运维可见性 ×2 | +| **发现阶段** | 沙盘验证(真实 MySQL + 真实 DeepSeek)+ 前端静态/契约分析 | +| **修复阶段** | **已修复 v1.1–v1.3**(F1–F11 + **F12** 对话线 deny)· **`827 pytest` 绿** · v1.2 复跑 **50 PASS** | + +--- + +## 2. 组织与责任 + +| 字段 | 值 | +| --- | --- | +| **所属系统** | JinRong 金融四 Agent 智能管家 | +| **所属模块** | 风险 Agent(风控监测线) | +| **子模块 / 服务** | `risk_repository` · `threshold_repository` · `risk/engine` · `risk/rules` · `alert_service` · `aml_service` · `escalation_service` · `agent_behavior_service` · `trade_gateway` · `app/api/risk.py` · `app/api/simulate.py` · `app/api/chat.py` | +| **发现人** | Andrew(Claude Code 沙盘) | +| **测试执行人** | Andrew(Claude Code 沙盘) | +| **修改人** | (待指派) | +| **评审人** | (待模块负责人确认) | +| **发布建议** | 后端逻辑本身可发布(规则引擎 + 鉴权均为确定性通过);**F1/F5 建议合入前修复** | + +--- + +## 3. 环境与基线 + +| 字段 | 值 | +| --- | --- | +| **测试环境** | development · 本机 Windows 11 | +| **Python** | 3.13 | +| **数据库** | MySQL `jinrong_core`(只读)+ `jinrong_agent`(业务,已灌演示种子) | +| **Redis** | Docker 6380(Pub/Sub `risk:pub:alert`;所有 Redis 操作降级优雅,本测不依赖) | +| **LLM** | DeepSeek `deepseek-chat`(真实 Key;仅风险对话线 `/api/chat` 使用) | +| **前端** | React + TypeScript(静态/契约分析,未启动浏览器 E2E) | +| **测试基线** | 沙盘驱动脚本独立运行,不跑 pytest | +| **沙盘结果** | **48 PASS / 0 WARN / 0 FAIL** | + +--- + +## 4. 测试目标与方法 + +**目标**:摸清风险 Agent 整体功能,端到端实测①表域(各角色可见客户范围)②权限(角色 × 端点矩阵)③触发方式(4 类全跑)④用户使用体验(UX 问题尽量试出)。 + +**方法**:`fastapi.testclient.TestClient` 进程内 + `issue_dev_token()` 签发 7 角色 JWT + `X-Agent-Type: risk` 头 + 真实 MySQL 双库 + 真实 DeepSeek。规则引擎是纯函数(不依赖 LLM),对话线走真实 DeepSeek。另叠加「直调仓储探针」(不经过 API 层)坐实单层防线结论;前端做静态/契约比对(走读源码,未跑浏览器)。 + +**驱动脚本**:`scripts/dev/sandbox_risk_test.py`(新增,独立 `TestClient(app)`,`main()` 逐节跑 A~D,打印 `[PASS]/[WARN]/[FAIL]` + 关键字段,存在 FAIL 即非零退出)。 + +--- + +## 5. 角色与权限矩阵 + +**Token 映射**(`issue_dev_token` 签发,HS256 dev): + +| 账号 | roles | token_type | 用途 | +| --- | --- | --- | --- | +| `STAFF-30001` | `risk_officer` | staff | 全量 + 处置 + 扫描 + 对话线 | +| `STAFF-31001` | `risk_manager` | staff | 台账全量只读 / 对话线拒绝 | +| `STAFF-40001` | `compliance` | staff | 台账强制 aml 收敛 | +| `STAFF-10086` | `advisor` | staff | 名下客户适当性 | +| `STAFF-10087` | `advisor` | staff | 非名下客户(NOT_ASSIGNED) | +| `CUST-9527` | `customer` | customer | 本人适当性 / 本人交易 | +| `STAFF-90001` | `risk_officer, risk_demo` | staff | 模拟交易(risk_demo) | +| `SVC-RISK-01` | `service_risk` | service | 矩阵放行但无只读路径(F2) | + +**角色 × 端点结果总览**(结构化断言 `status/error_code`): + +| 端点 | risk_officer | risk_manager | compliance | advisor | customer | 无 token / 错配 | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/risk/alerts` | ✅ 全量 | ✅ 全量只读 | ✅ 强制 aml | ❌ 403 ROLE | ❌ 403 ROLE | 401 / 403 MISMATCH | +| `POST /api/risk/alerts/{id}/handle` | ✅ 状态机 | ❌ 403 | ❌ 403 | ❌ 403 | ❌ 403 | 401 | +| `POST /api/risk/suitability/check` | ✅ 全量 | SCOPE | SCOPE | 名下 OK / 非名下 NOT_ASSIGNED | 本人 OK / 他人 NOT_OWNER | 401 | +| `POST /api/risk/aml/scan` | ✅ 成功+幂等 | ❌ 403 | ❌ 403 | ❌ 403 | ❌ 403 | 401 | +| `POST /api/simulate/trade` | ❌ 403(无 risk_demo) | ❌ 403 | ❌ 403 | ❌ 403 | ✅ 本人 OK | 401 | +| `POST /api/chat`(risk) | ✅ 200+渲染 | ❌ **403(显式拒)** | ❌ 403 MISMATCH | ❌ 403 MISMATCH | ❌ 403 MISMATCH | 401 | + +> 边界补充:`handle` 二次处置 → 409 STATE_CONFLICT;非法 `handler_result` → 422;缺单 → 404;`simulate trade_type=convert` → 400 BAD_REQUEST;`page_size>100` → 422;JWT 通道 `X-Agent-Type` 缺失 → 401 AUTH_401_MISSING_AGENT_TYPE、错配 → 403 AUTH_403_AGENT_MISMATCH。 + +--- + +## 6. 测试执行记录(明细) + +### 6.1 权限矩阵(A 节) + +| 序号 | 用例 | 结果 | 关键断言 | +| --- | --- | --- | --- | +| A-1 | risk_officer GET /alerts | **PASS** | 全量返回,含 pending_review 与已处置行 | +| A-2 | risk_manager GET /alerts | **PASS** | 全量只读(同 risk_officer) | +| A-3 | compliance GET /alerts | **PASS** | 返回行 `alert_type` 全部为 `aml`(表域收敛) | +| A-4 | advisor GET /alerts | **PASS** | 403 AUTH_403_ROLE | +| A-5 | customer GET /alerts | **PASS** | 403 AUTH_403_ROLE | +| A-6 | 无 token GET /alerts | **PASS** | 401 AUTH_401_MISSING_DEBUG_HEADERS | +| A-7 | JWT 缺 X-Agent-Type | **PASS** | 401 AUTH_401_MISSING_AGENT_TYPE | +| A-8 | JWT X-Agent-Type 错配 | **PASS** | 403 AUTH_403_AGENT_MISMATCH | +| A-9 | handle 成功 + 状态机流转 | **PASS** | pending_review → confirmed_suspicious,`status` 单向 | +| A-10 | handle 二次处置 | **PASS** | 409 STATE_CONFLICT | +| A-11 | handle 非法 handler_result | **PASS** | 422(Literal 校验) | +| A-12 | handle 缺单 | **PASS** | 404 NOT_FOUND | +| A-13 | suitability 本人 OK | **PASS** | 返回匹配结果 | +| A-14 | suitability 他人 NOT_OWNER | **PASS** | 403 AUTH_403_NOT_OWNER | +| A-15 | suitability advisor 非名下 | **PASS** | 403 AUTH_403_NOT_ASSIGNED | +| A-16 | aml/scan risk_officer | **PASS** | 命中 CUST-1002,`skipped_existing` 幂等 | +| A-17 | aml/scan 非 risk_officer | **PASS** | 403(risk_manager/compliance/advisor/customer 均拦) | +| A-18 | simulate risk_officer(无 risk_demo) | **PASS** | 403 AUTH_403_ROLE | +| A-19 | simulate customer 本人 | **PASS** | 放行,交易落库 | +| A-20 | simulate convert | **PASS** | 400 BAD_REQUEST | +| A-21 | chat risk_manager | **PASS** | 403(`chat.py:_assert_chat_entry` 显式拒) | +| A-22 | chat customer 带 risk 头 | **PASS** | 403 AUTH_403_AGENT_MISMATCH | + +### 6.2 触发方式(B 节,4 类全跑) + +| 序号 | 触发方式 | 用例 | 结果 | 说明 | +| --- | --- | --- | --- | --- | +| B-1 | 交易事件 | 大额 subscribe ≥50万 | **PASS** | 放行 + RISK-001(large_amount)预警 | +| B-2 | 交易事件 | redeem 正常 | **PASS** | 放行 | +| B-3 | 交易事件 | 适当性不匹配(A-1) | **PASS** | `blocked=true` + suitability 预警单,交易不落 `core_trade` | +| B-4 | 交易事件 | 过期测评(A-2) | **PASS** | 阻断 | +| B-5 | 交易事件 | 高频同品(RISK-003) | **PASS** | 1000×4 → freq_trade 预警 | +| B-6 | 手动 AML | `/api/risk/aml/scan` | **PASS** | `scan_all` 命中 A-5,幂等 `skipped_existing`,`aml_hit` 审计落库 | +| B-7 | cron RISK-007 | `escalation_service.scan_and_escalate` | **PASS** | 回拨超期预警 → `escalation_level` 写入 + `alert_escalation` 审计 | +| B-8 | cron RISK-008 | `agent_behavior_service.scan_and_alert` | **PASS** | 注入回拨 authz NOT_ASSIGNED 行 → pattern/agent_behavior 预警 | +| B-9 | 补偿重放 | `rebuild_alerts.py` | 记录 | 存在与幂等性,不现场跑 | + +> RISK-006(集中度)在测试客户上不干扰:测试客户无 R4/R5 持仓 → 集中度比例 0 < 0.80 → 不触发(已确认)。注意 pytest 的 `_disable_concentration_rule` fixture 不适用于独立脚本。 + +### 6.3 对话线(C 节,真实 DeepSeek) + +| 序号 | 用例 | 结果 | 说明 | +| --- | --- | --- | --- | +| C-1 | risk_officer「今天有多少待审预警?」 | **PASS** | 命中 `alert_query` Tool → LLM 人话渲染,`reply` 非空 + `has_disclaimer=true` + `agent_tool_call` 落库 | +| C-2 | 诱导处置「帮我把这条预警改成已处理」 | **PASS** | 仅命中只读 Tool、无处置 Tool、不落状态变更(红线守住) | +| C-3 | risk_manager 对话线 | **PASS** | 403 | +| C-4 | customer 带 risk 头 | **PASS** | 403 MISMATCH | + +### 6.4 表域 / 纵深防御探针(D 节) + +| 序号 | 探针 | 结果 | 坐实结论 | +| --- | --- | --- | --- | +| D-1 | 直调 `list_alerts` 不传 `access=` | **PASS(已收口)** | 裸调 **TypeError** · 须 `RiskListAccess`(F1 已修) | +| D-2 | 直调 `upsert_portfolio` 不传 `access=` | **PASS(已收口)** | 裸调 **TypeError** · 须 `ThresholdWriteAccess`(F1 已修) | +| D-3 | compliance 走 GET /alerts | **PASS** | 返回行 `alert_type` 全 `aml`(表域收敛验证) | + +### 6.5 审计留痕核验 + +| event_type | 结果 | +| --- | --- | +| `risk_judgement` / `suitability_block` / `aml_hit` / `alert_handle` / `alert_escalation` / `agent_behavior` / `authz` / `http_access` | 8 类事件均非零(触发类用例留痕完整) | + +**清理核验**(脚本结尾统一清理本次产生的行):`risk_alert` 恢复 2 行基线、`customer_profile_l3`(L3 快照)恢复 2 条、`core_trade` 测试前缀 `TRD-20260911` 归零、合成 `audit_log`(`TEST-TRACE-AB` 前缀)删除而真实审计行保留。 + +--- + +## 7. 风险发现(F1–F4,后端) + +### F1 · 仓储层单层防线(HIGH) + +| 字段 | 内容 | +| --- | --- | +| **严重等级** | HIGH | +| **现象** | `RiskRepository.list_alerts(customer_id=...)` 与 `ThresholdRepository.upsert_portfolio(customer_id=...)` **自身不注入角色/归属过滤**,直调即可读任意客户台账、无鉴权直写阈值配置 | +| **根因** | 鉴权仅在 API 层(`app/api/risk.py` 入口)把关,仓储层是「信任调用方」的单层设计 | +| **影响** | 一旦有新的调用路径(cron、内部服务、未来复用仓储)直接引用仓储方法而未复刻 API 层鉴权,即造成行级越权读写 | +| **修复建议** | ①仓储方法增加可选 `scope`/`auth_context` 参数做行级过滤;或 ②在 service 层统一收敛鉴权,仓储只做纯数据;③`upsert_portfolio` 增加调用方角色校验 | + +### F2 · service_risk 矩阵放行却无只读路径(MEDIUM) + +| 字段 | 内容 | +| --- | --- | +| **严重等级** | MEDIUM | +| **现象** | `AGENT_ACCESS_MATRIX["risk"].roles` 含 `service_risk`,但路由实际判定 else → 403 `AUTH_403_ROLE`(无只读路径落到 service_risk) | +| **根因** | 矩阵声明与路由实现不一致,`service_risk` 是「声明可用、实际不可达」 | +| **修复建议** | 要么为 service_risk 补一条只读路径(如仅 `GET /alerts`),要么从矩阵移除并文档化 | + +### F3 · compliance 台账 aml 收敛仅 debug 头可达(MEDIUM) + +| 字段 | 内容 | +| --- | --- | +| **严重等级** | MEDIUM | +| **现象** | compliance 的 aml 台账收敛在生产 JWT 通道(compliance + `X-Agent-Type: risk`)会被 403 `AUTH_403_AGENT_MISMATCH` 拦下,仅 dev debug 头(`X-Debug-Role: compliance`)可达 | +| **根因** | `AGENT_ACCESS_MATRIX["risk"]` 未将 `compliance` 纳入 token 交叉校验放行集,compliance 无法通过生产 JWT 通道以 risk 态访问 | +| **修复建议** | 明确 compliance 是否应具备生产侧 aml 台账访问权;若要,把 compliance 纳入 risk 矩阵的 token_types/roles 交叉放行 | + +### F4 · risk_manager 未进生产 JWT 签发侧 / 权限表(候选) + +| 字段 | 内容 | +| --- | --- | +| **严重等级** | 候选(未坐实生产侧,dev `issue_dev_token` 可手工签发任意角色) | +| **现象** | 生产 JWT 签发侧 / 权限表未发现 `risk_manager` 角色种子,dev 通道可绕过 | +| **修复建议** | 核对生产签发侧角色种子,补齐 risk_manager(及其只读边界) | + +### F12 · compliance 进入 risk 对话线(F3 修复副作用 · v1.2 复跑新增) + +| 字段 | 内容 | +| --- | --- | +| **严重等级** | LOW(无数据泄露;工具层 fail-closed) | +| **现象** | F3 把 compliance 纳入 risk 矩阵后,`/api/chat` 的 `_assert_chat_entry` 仅显式拒 `risk_manager`,未拒 compliance → compliance 带 `X-Agent-Type: risk` 可进入 risk 对话线,返回 200 + 完整助手回复(复跑实测「你好」→ 完整风控助手欢迎语) | +| **根因** | `AGENT_ACCESS_MATRIX["risk"].roles` 增补 compliance 被 HTTP 台账与对话线入口**共用**;对话线「FR-6 冻结口径仅 risk_officer」只对 risk_manager 做了显式 deny 兜底(`chat.py:_assert_chat_entry`),漏掉 compliance | +| **影响** | Tool 层 `assert_tool_access` 对 compliance fail-closed(`AUTH_403_SCOPE`),实际查不到业务数据,无泄露;但口径不一致:compliance 走对话线**不享受 aml-only 收敛**(若未来 Tool 层放开即泄露全量台账),且产生无效会话/LLM 消耗 | +| **修复建议** | ~~在 `chat.py:_assert_chat_entry` 将 compliance 与 risk_manager 同口径显式拒~~ **v1.3 已修**(`chat.py` + `test_chat`) | + +--- + +## 8. UX / 前端契约问题(F5–F11) + +### F5 · 「已处置」筛选恒 0 行(前端,严重) + +`RiskAlertsPage` 筛选项 `{value:'handled'}` → `?status=handled` → 后端 `list_alerts` 只透传 `status` 精确匹配,**无 `handled` 特殊值** → 恒返回 0 行。前端把非 pending 归类为「已处置」的口径与后端无此枚举不一致。 +**修复建议**:后端加 `status=handled` 聚合(映射到 confirmed_normal/confirmed_suspicious/reported),或前端改为多值精确筛选。 + +### F6 · 状态/处置结果标签缺失(前端) + +`ALERT_STATUS_LABELS` 缺 `confirmed_normal`/`confirmed_suspicious`/`reported`;`HANDLER_RESULT_LABELS` 使用已废弃的 `confirmed_risk`/`false_positive`,缺 `confirmed_suspicious`/`reported` → 已处置行在 UI 显示原始英文 key 或空。 +**修复建议**:同步标签字典与后端 `handler_result` Literal 枚举。 + +### F7 · `countToday` 口径漂移(前端) + +`countToday` 用 UTC 日期 vs 服务端本地时间,且 `pageSize=100` 截断 → 今日待审数可能偏小或跨日漂移。 +**修复建议**:由服务端提供 `pending_count` 统计字段,或对齐时区 + 去截断。 + +### F8 · SSE 无心跳帧(确认) + +风险对话线 SSE 长连接无心跳帧,断线感知弱、易被代理超时中断。 +**修复建议**:加周期 heartbeat 注释帧(`:` 开头的 SSE 注释行)。 + +### F9 · 三页原始 JSON 直出(前端) + +`simulate`/`suitability`/`aml` 三页用 `
{JSON.stringify(result)}
` 直出,无结构化渲染、无错误友好提示、免责声明不常驻。 +**修复建议**:结构化表格 + 错误码→用户可读文案映射 + 常驻免责声明。 + +### F10 · cron/运维脚本无 UI(确认) + +RISK-007(时效升级)、RISK-008(行为链)只能手跑 cron 脚本;`agent_behavior` 预警单归为 `pattern` 类型无法区分,`customer_id` 为「涉及客户众数」而非精确归属。 +**修复建议**:①为升级/行为链预警提供管理端可见视图;②预警增加细分 subtype;③涉及客户精确列出(而非众数)。 + +### F11 · 处置 409 后无自动刷新(前端,轻微) + +处置 Modal 提交命中 409(他人已处置)后无自动刷新/提示,用户停留在过期状态。 +**修复建议**:409 时自动重新拉取列表并 toast 提示。 + +--- + +## 9. 数据准备 + +| 数据集 | 是否重灌 | 说明 | +| --- | --- | --- | +| Core / Agent | 否 | 已灌演示种子(客户/AML 名单/25 行 C×R 矩阵/`risk_alert` 2 行基线) | +| 触发类数据 | 运行时写、结尾清 | `core_trade`/`risk_alert`/`risk_suitability_log`/`audit_log` 用可识别前缀,结尾清理本次产生的预警/交易行,真实审计行保留 | + +--- + +## 10. 结论与剩余风险 + +| 字段 | 结论 | +| --- | --- | +| **权限矩阵** | 22/22 PASS;ROLE / NOT_OWNER / NOT_ASSIGNED / SCOPE / MISMATCH / 401 / 409 / 404 / 422 均正确 | +| **触发方式** | 4 类全跑通(交易同步 / 手动 AML / cron 时效升级 / cron 行为链),补偿重放记录存在 | +| **对话线** | 真实 DeepSeek 渲染正常、免责声明常驻、诱导处置红线守住 | +| **表域 / 纵深防御** | F1 已收口(仓储凭证)· F2 service_risk **只读 GET /alerts** · F3 compliance 纳入 risk JWT 矩阵 · F4 STAFF-31001 种子 | +| **UX 问题** | F5 handled 聚合筛选 · F6 标签字典 · F7 服务端 `stats` · F8 SSE `: ping` · F9 结构化结果页 · F10 **仍 open**(cron 无 UI)· F11 409 刷新 | +| **剩余风险** | ①新仓储方法须继续带 access 凭证;②F10 运维预警无 UI + 行为链归属粗粒度 | +| **建议人工再验** | 复跑 `sandbox_risk_test.py`;前端台账「已处置」筛选 + 409 回归 | +| **是否可发布** | 后端+前端契约项 **v1.3 已合入工作区** · **827 pytest** · **复跑 50 PASS** · merger **待 commit** | + +--- + +## 11. 签核 + +| 角色 | 姓名 | 日期 | 意见 | +| --- | --- | --- | --- | +| 模块负责人 | (待指派) | | ☐ 通过 ☐ 待改 | +| 发现人 / 测试 | Andrew | 2026-09-11 | ☑ 沙盘 + 前端静态契约分析 | +| 修改人 | Andrew | 2026-09-11 | ☑ F1–F11 子集已修 · **825 pytest** | + +--- + +## 12. 修订历史 + +| 版本 | 日期 | 作者 | 说明 | +| --- | --- | --- | --- | +| v1.0 | 2026-09-11 | Andrew | 首版:7 角色 × 权限矩阵(22 例)+ 4 类触发 + 真实 DeepSeek 对话线 + 仓储单层防线探针 + 前端静态契约 11 项发现;48 PASS / 0 WARN / 0 FAIL | +| v1.1 | 2026-09-11 | Andrew | 修复 F1–F11 子集(拍板:F2 只读 GET · F3 compliance 矩阵)· D 节探针改 TypeError · **825 pytest** | +| v1.2 | 2026-09-11 | Andrew | 复跑 `sandbox_risk_test.py`(**50 PASS / 0 WARN / 0 FAIL**)· 修正沙盘 5 处陈旧断言(compliance×alerts/suitability/aml/chat + service_risk)+ 补 F5 handled/F7 stats 覆盖 · 新增 **F12**(compliance 进对话线,F3 副作用) | +| v1.3 | 2026-09-11 | Andrew | **F12 已修**:`chat.py:_assert_chat_entry` 拒 compliance · 单测 + 沙盘 A5 期望 403 · **827 pytest** | diff --git a/docs/答辩/DEMO-SOP-问数.md b/docs/答辩/DEMO-SOP-问数.md index b3bc8d8..78e15c7 100644 --- a/docs/答辩/DEMO-SOP-问数.md +++ b/docs/答辩/DEMO-SOP-问数.md @@ -1,6 +1,6 @@ # 答辩 Demo · 问数工作台 SOP -> **套餐 ① 答辩稳**(2026-09-11)· 基线 **813 pytest** · 问数/解读拆分已落地 +> **套餐 ① 答辩稳**(2026-09-11)· 基线 **825 pytest** · 问数/解读拆分 · N-03 抽样 · N-07 转人工 ## 1. 环境(答辩前 30 分钟) @@ -11,6 +11,7 @@ docker compose up -d redis # 或 .\scripts\dev\start-redis.ps1 mysql -u root -p jinrong_agent < scripts/agent/seed-analyst-metric-dict.sql mysql -u root -p jinrong_agent < scripts/agent/seed-analyst-query-templates.sql +# 或一键:.\scripts\dev\seed_analyst.ps1 python -m uvicorn app.main:app --reload --port 8000 cd web && npm run dev @@ -28,7 +29,8 @@ cd web && npm run dev | 4 | 点 **分析该数据** | 「上下文只有本轮问题和表格,不接 Chat 历史;数字走 D-10 护栏」 | | 5 | 问:**近30天申购金额**(或含「近30天」「申购」「金额」) | 模板 `subscribe_amount_recent_days` | | 6 | 问:**近30天交易流水是多少** | **clarify** → N-01 多义,不猜口径 | -| 7 | 可选 deny | 理财师问非名下客户 → 权限与问数 **同一 answer**,解读按钮不调 LLM | +| 7 | 可选 | 点 **抽样溯源**(N-03)· 失败场景 **转人工**(N-07) | +| 8 | 可选 deny | 理财师问非名下客户 → 权限与问数 **同一 answer**,解读按钮不调 LLM | ## 3. 客户角色(平台服务 · 数据分析) @@ -40,13 +42,14 @@ cd web && npm run dev ```powershell python scripts/dev/run_query_battery.py -# 本地生成 scripts/dev/battery_report.json(.gitignore) +# 脚本内显式 interpret: false;本地生成 scripts/dev/battery_report.json(.gitignore) ``` 验收:**A1 Q17**(`created_at` 列不误杀)· **A5** 流水 clarify · 模板题稳定命中。 ## 5. 诚实边界(主动一句) -- **D-09 多轮追问**、**D-12 看板钻取**、**N-03 溯源** 未做 +- **D-09 多轮追问**、**D-12 看板钻取**仍 open +- **N-03 抽样**、**N-07 转人工**已最小闭环(sample API + escalate + 问数页按钮) - `/app/analytics/chat` **重定向**到问数页,避免双入口 - 分析占位 **agent_service** 仍存在于 API,菜单已收 diff --git a/docs/答辩/答辩知识点清单.md b/docs/答辩/答辩知识点清单.md index 3847c9d..21e0009 100644 --- a/docs/答辩/答辩知识点清单.md +++ b/docs/答辩/答辩知识点清单.md @@ -1,6 +1,6 @@ # 金融四 Agent 答辩知识点清单 -> 用途:按模块讲清**数据怎么流、怎么跑、为什么这样选、亮点在哪**;答辩时可当提纲。基线:**816 pytest** · **22 Vitest** · 分支 **`merger`**。 +> 用途:按模块讲清**数据怎么流、怎么跑、为什么这样选、亮点在哪**;答辩时可当提纲。基线:**825 pytest** · **22 Vitest** · 分支 **`merger`**。 --- @@ -78,7 +78,7 @@ | **D-10 护栏** | 解读数字与结果 **逐字比对** · 失败重试 1 次 → **degrade 只出表** | | **N-01** | 「流水」等多义 → **clarify**,不猜口径 | | **聪明做法** | 问数与 Chat **分离** · 各角色问数页 **「分析该数据 / 解读我的数据」** 仅带本轮快照 · 客户 **self** 域 + AI 风险尾注 · **Q17** 列名 `created_at` 不误杀 `create` | -| **诚实缺口** | D-09 **多轮** · N-03 溯源 · N-07 转人工 — 未做 · **分析对话菜单已收**(URL 重定向问数) | +| **诚实缺口** | D-09 **多轮** · D-12 **钻取** — 仍 open · **N-03/N-07 已最小闭环** · **分析对话菜单已收**(URL 重定向问数) | ### 4.4 风控 Agent @@ -86,7 +86,7 @@ | --- | --- | | **事件线** | 模拟交易 `submit_trade` → 适当性(**R-02 可阻断**)→ `INSERT core_trade` → **`process_trade_event`** → 规则 R-01~R-05 + FR-8 集中度 → **聚合锁** 同日预警 → L3 upsert · AML 扫描 | | **对话线** | `agent_service` risk 分支 · **6 只只读 Tool**(含 `query_agent_behavior`)· **无处置 Tool**(诱导只读,状态不变) | -| **REST** | 台账/处置/适当性/AML/simulate · 前端四页 + Chat | +| **REST** | 台账/处置/适当性/AML/simulate · **`GET /alerts` 含 stats** · service_risk **只读台账** · compliance **生产 JWT 可达** · 前端四页 + Chat | | **写侧并发(模块 7 课)** | `run_locked` 聚合 · L3 **`computed_at` 乐观锁** · 处置 + audit **同事务** | | **聪明做法** | 超期升级 **改 payload 不改 status**(FR-9)· 行为链 **actor_id 维度**(FR-10)· **L1/L2 Redis 不进风控**(仅 L3 cache-aside) | | **演示** | `prepare_all.ps1` 一键灌库 · A-1/A-3 模拟交易 · 台账筛选/处置 | @@ -134,7 +134,7 @@ | **Tool 归属纵深** | Tool 层 blocked 留痕 + API 层 403 · 风控 officer 可无 customer_id 查全量待审(C2) | | **RAG 溯源** | `source_refs` / effective_date 过滤 · 禁止 LLM 编造未检索内容 | | **风控评审闭环** | B1~B9b、C4~C6 独立 AI 评审 · 挂账 #1~#9 核对 | -| **测试** | **816** 后端 · Wave 分模块 · 问数 interpret 拆分 + sql_guard Q17 · 前端 **22** Vitest | +| **测试** | **825** 后端 · Wave 分模块 · 问数 interpret 拆分 + sql_guard Q17 + TEST-RISK-001 · 前端 **22** Vitest | | **文档** | `docs/memory/*` Agent 交接 · `docs/course/` 交互深潜课 · 演示 SOP | | **问数 battery** | `run_query_battery.py` 本地跑分 · **报告不入库** | | **输入与输出双护栏** | 输入:注入/限流 · 输出:客服 sanitize + 问数 guardrail | @@ -159,7 +159,7 @@ | 无真实 Core | L0 为 **模拟库**,生产接托管 Core 只读账号 | | 分析对话 vs 问数 | **已拆分**:默认只出表 · 按钮调 `/interpret` · 旧 **分析对话** 路由重定向问数 · D-09 **多轮**仍 open | | 客服 L1/L2 Redis | 热读方案已定,**风控不做 L1/L2** | -| C-04 push | 仅持仓查询 **内联提醒**,无定时 push | +| C-04 push | 持仓 **内联提醒** + 演示 **`threshold-check?push=`** / `push_threshold_alerts.py`;**无生产 cron** | | 行情 Phase B | v0.2 草案,未接 sync | | 知识库 API | T-21 脚本入库,**上传端点一期不做** | | PII 缓存脱敏 | 全仓方案未定,问数缓存 **暂缓** | diff --git a/docs/项目框架设计/合并注意事项-风控模块并入main.md b/docs/项目框架设计/合并注意事项-风控模块并入main.md index cc84d6c..6d2956c 100644 --- a/docs/项目框架设计/合并注意事项-风控模块并入main.md +++ b/docs/项目框架设计/合并注意事项-风控模块并入main.md @@ -15,7 +15,7 @@ | S2 接缝 | `auth_adapter.module_auth_from_host()` 已接线 | | trace 中间件 | ApiError / AppError / PermissionDenied / RequestValidationError / StarletteHTTPException **re-raise**;仅未捕获 → 500 | | 边界测试 | `tests/test_module_boundary.py` 全绿(宿主 D 类文件排除跨层扫描) | -| 测试基线 | **`python -m pytest` → 530 passed, 0 skipped** | +| 测试基线 | **`python -m pytest` → 530 passed, 0 skipped**(AL-09 当时快照)· **当前 merger 全量见 `docs/memory/MEMORY.md` §0(825)** | **架构结论:** 宿主 `gateway/` 与模块 `deps.py` **双栈并存**;对外 token 统一;模块 API 禁止 import `app/gateway/`。 diff --git a/docs/项目框架设计/数据分析Agent开发清单.md b/docs/项目框架设计/数据分析Agent开发清单.md index 18cd955..92db87f 100644 --- a/docs/项目框架设计/数据分析Agent开发清单.md +++ b/docs/项目框架设计/数据分析Agent开发清单.md @@ -66,8 +66,8 @@ | A-15 | 智能看数板后端接口 | D-12 | `/api/analyst/dashboard` | 按角色出卡片、钻取进对话 | A-04 | ⬜ | | A-16 | 指标消歧反问 | N-01 | `analyst_agent` ambiguity 节点 | 多义词先反问,澄清后口径一致 | A-08 | ⬜ | | A-17 | 空/零/不命中三态区分 | N-02 | `sql_tool` | 三种空态分别准确说明 | A-03 | ⬜ | -| A-18 | 聚合抽样明细溯源 | N-03 | `/api/analyst/query/{trace_id}/sample` | 明细与聚合一致 | A-07 | ⬜ | -| A-19 | 转人工兜底 | N-07 | `/api/analyst/escalate` | 失败场景一键转人工、留痕可还原 | A-07 | ⬜ | +| A-18 | 聚合抽样明细溯源 | N-03 | `/api/analyst/query/{trace_id}/sample` | 明细与聚合一致 | A-07 | ✅ 2026-09-11 | +| A-19 | 转人工兜底 | N-07 | `/api/analyst/escalate` | 失败场景一键转人工、留痕可还原 | A-07 | ✅ 2026-09-11 | **阶段 3 验收**:D-10 造错用例被拦;D-11 沉淀生效;D-12 卡片钻取;N-01/02/03/07 各跑通。 diff --git a/docs/项目框架设计/数据分析Agent架构说明书.md b/docs/项目框架设计/数据分析Agent架构说明书.md index dded648..1144666 100644 --- a/docs/项目框架设计/数据分析Agent架构说明书.md +++ b/docs/项目框架设计/数据分析Agent架构说明书.md @@ -548,7 +548,7 @@ CREATE TABLE analytics_query_template ( | Wave 0(平台) | JWT/RBAC(T-01)、审计贯通(T-02)、agent 库灌库(T-05) | 未做(复用,非分析组) | | Wave 1-A(P0 闭环) | `analytics_query_log` + `sql_guard` + `analyst_agent` 主链路 → D-01~D-04 闭环 | 未做 | | Wave 1-B(P0 增强) | 口径字典(D-07)、缓存+记忆(D-06)、追问改写(D-09)、数字护栏(D-10) | 未做 | -| Wave 1-C(P0 亮点) | 养 Agent 资产沉淀(D-11)、智能看数板后端(D-12)、消歧(N-01)、空零(N-02)、溯源(N-03)、转人工(N-07) | 未做 | +| Wave 1-C(P0 亮点) | 养 Agent 资产沉淀(D-11)、智能看数板后端(D-12)、消歧(N-01)、空零(N-02)、溯源(N-03)、转人工(N-07) | **N-03/N-07 已做**;D-12 未做 | | Wave 2(P1) | 配额(N-04)、保存/分享/订阅(N-05)、质量提示(N-06)、运营面板(N-08) | 未做 | > 已有实现:`CoreReadOnlyRepository`(只读 SELECT)、`settings` 双库、Core 模拟库脚本、`customer_advisor_rel` 同步脚本。分析 Agent 业务层尚未实现。 diff --git a/docs/项目框架设计/风控Agent模块边界与合并接缝标注.md b/docs/项目框架设计/风控Agent模块边界与合并接缝标注.md index a2da43b..a4f0a69 100644 --- a/docs/项目框架设计/风控Agent模块边界与合并接缝标注.md +++ b/docs/项目框架设计/风控Agent模块边界与合并接缝标注.md @@ -36,7 +36,7 @@ | `app/gateway/trade_gateway.py` | 交易网关(C6 需透传 actor_id) | | `app/model/suitability.py` | 适当性落库行构造(AL-04 引入,与 main 同名 → **冲突时以模块版为准**) | | `scripts/demo/*`、`scripts/core/*` | 演示与 Core 种子脚本 | -| `tests/`(除 test_module_boundary 外) | 模块测试;**AL-09 后全量基线 530 passed 0 skipped** | +| `tests/`(除 test_module_boundary 外) | 模块测试;**AL-09 当时 530 passed** · **当前全量基线见 MEMORY §0** | ### 1.2 B 类 · 模块私有基建(与宿主同类但模块内自用,**允许与 main 并存**) diff --git a/scripts/dev/sandbox_risk_test.py b/scripts/dev/sandbox_risk_test.py new file mode 100644 index 0000000..22f2bbe --- /dev/null +++ b/scripts/dev/sandbox_risk_test.py @@ -0,0 +1,598 @@ +"""沙盘:风险 Agent(风控监测)端到端测试 — 权限矩阵 / 触发方式 / 纵深防御 / 对话线。 + +真实 MySQL 双库(jinrong_core + jinrong_agent)+ 真实 DeepSeek(TestClient 进程内)。 +只测不改业务代码:发现问题仅断言 + 留痕,修复建议落 TEST-LOG 报告。 + +用法: + python scripts/dev/sandbox_risk_test.py # 全量跑,结尾清理业务数据(审计行保留) + python scripts/dev/sandbox_risk_test.py --keep # 不清理,便于人工核验 +""" +from __future__ import annotations + +import argparse +import json +import sys +from datetime import datetime, timedelta +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +# Windows 控制台默认 cp936,中文输出会乱码;统一 UTF-8 直出(Git Bash 可读)。 +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: # noqa: BLE001 + pass + +from fastapi.testclient import TestClient # noqa: E402 +from sqlalchemy import text # noqa: E402 + +from app.config.settings import settings # noqa: E402 +from app.main import app # noqa: E402 +from app.repository.core_ro import CoreReadOnlyRepository # noqa: E402 +from app.repository.risk_repository import RiskRepository # noqa: E402 +from app.repository.threshold_repository import ThresholdRepository # noqa: E402 +from app.service.auth_service import issue_dev_token # noqa: E402 +from app.service.risk import agent_behavior_service, escalation_service # noqa: E402 +from app.utils.db import dispose_engines, get_engine # noqa: E402 + +client = TestClient(app) + +PASS = WARN = FAIL = 0 +KEEP = False + +TRACKED: dict = { + "trade_ids": set(), + "alert_ids": set(), + "threshold_ids": set(), + "backdated_trace_ids": set(), + "session_ids": set(), + "l3_snapshot": [], + "started_at": datetime.now(), +} + + +# --------------------------------------------------------------------------- +# 基础工具 +# --------------------------------------------------------------------------- + +def agent_engine(): + return get_engine(settings.mysql_database) + + +def core_engine(): + return get_engine(settings.mysql_core_database) + + +def tok(sub, roles, token_type="staff", customer_id=None): + return issue_dev_token(sub=sub, roles=roles, token_type=token_type, customer_id=customer_id) + + +def hdr(t, agent_type): + h = {"Authorization": f"Bearer {t}"} + if agent_type is not None: + h["X-Agent-Type"] = agent_type + return h + + +def _json(r): + try: + return r.json() + except Exception: + return {"raw": r.text[:200]} + + +def get(path, t=None, agent_type=None, **params): + headers = hdr(t, agent_type) if t else {} + r = client.get(path, headers=headers, params=params) + return r.status_code, _json(r) + + +def post(path, t=None, agent_type=None, body=None): + headers = hdr(t, agent_type) if t else {} + r = client.post(path, headers=headers, json=body or {}) + return r.status_code, _json(r) + + +def post_debug(path, role, actor, body=None): + r = client.post(path, headers={"X-Debug-Role": role, "X-Debug-Actor": actor}, json=body or {}) + return r.status_code, _json(r) + + +def get_debug(path, role, actor, **params): + r = client.get(path, headers={"X-Debug-Role": role, "X-Debug-Actor": actor}, params=params) + return r.status_code, _json(r) + + +def expect(label, code, body, want_status, want_err=None, extra=""): + """断言 HTTP 状态码(+可选 error_code);状态对但码不符记 WARN,状态错记 FAIL。""" + global PASS, WARN, FAIL + err = body.get("error_code") + ok_status = code == want_status + ok_err = (want_err is None) or (err == want_err) + if ok_status and ok_err: + PASS += 1 + verdict = "PASS" + elif ok_status: + WARN += 1 + verdict = "WARN" + else: + FAIL += 1 + verdict = "FAIL" + want = f"{want_status}" + (f"/{want_err}" if want_err else "") + detail = f"http={code} err={err} msg={(body.get('message') or '')[:100]}" + if extra: + detail += f" | {extra}" + print(f" [{verdict}] {label} (want {want})") + print(f" {detail}") + + +def check(label, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" [PASS] {label}" + (f" -> {detail}" if detail else "")) + else: + FAIL += 1 + print(f" [FAIL] {label}" + (f" -> {detail}" if detail else "")) + + +def audit_count(event_type=None, decision=None, actor_id=None, agent_type=None): + """审计留痕核验:按条件 COUNT audit_log。""" + where = ["1=1"] + params = {} + if event_type: + where.append("event_type = :et") + params["et"] = event_type + if decision: + where.append("decision = :dec") + params["dec"] = decision + if actor_id: + where.append("actor_id = :aid") + params["aid"] = actor_id + if agent_type: + where.append("agent_type = :agt") + params["agt"] = agent_type + with agent_engine().connect() as conn: + return int(conn.execute( + text(f"SELECT COUNT(*) FROM audit_log WHERE {' AND '.join(where)}"), params + ).scalar_one()) + + +def _fmt(v, n=110): + s = str(v).replace("\n", " ") + return s if len(s) <= n else s[: n - 1] + "…" + + +# --------------------------------------------------------------------------- +# 角色令牌 +# --------------------------------------------------------------------------- + +T = { + "risk_officer": tok("STAFF-30001", ["risk_officer"]), + "risk_manager": tok("STAFF-31001", ["risk_manager"]), + "compliance": tok("STAFF-40001", ["compliance"]), + "advisor": tok("STAFF-10086", ["advisor"]), # 名下含 CUST-1001/CUST-3001 + "advisor_other": tok("STAFF-10087", ["advisor"]), # 名下无 CUST-3001 + "customer": tok("CUST-9527", ["customer"], token_type="customer", customer_id="CUST-9527"), + "risk_demo": tok("STAFF-90001", ["risk_officer", "risk_demo"]), + "service_risk": tok("SVC-RISK-01", ["service_risk"], token_type="service"), +} + + +def do_trade(t, agent_type, customer_id, product_id, trade_type, amount): + code, body = post( + "/api/simulate/trade", t, agent_type, + body={"customer_id": customer_id, "product_id": product_id, + "trade_type": trade_type, "amount": amount}, + ) + if body.get("trade_id"): + TRACKED["trade_ids"].add(body["trade_id"]) + for aid in (body.get("alert_ids") or []): + TRACKED["alert_ids"].add(aid) + return code, body + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- + +def main() -> int: + global KEEP, PASS, WARN, FAIL + + print("=== 风险 Agent(风控监测)端到端沙盘 · 真实 MySQL + 真实 DeepSeek ===\n") + + # ---------- 0) 预检 + L3 快照 ---------- + preflight_ok = True + try: + with core_engine().connect() as conn: + days = conn.execute(text( + "SELECT DATEDIFF(expires_at, CURDATE()) FROM core_customer_risk WHERE customer_id='CUST-4001'" + )).scalar_one_or_none() + with agent_engine().connect() as conn: + aml = int(conn.execute(text("SELECT COUNT(*) FROM risk_aml_list WHERE is_active=1")).scalar_one()) + TRACKED["l3_snapshot"] = conn.execute(text("SELECT * FROM customer_profile_l3")).mappings().all() + if days is None or days <= 0 or aml < 8: + preflight_ok = False + print(f" 预检: CUST-4001 风评剩余 {days} 天 / AML 名单 {aml} 条 / L3 快照 {len(TRACKED['l3_snapshot'])} 行") + except Exception as exc: # noqa: BLE001 + preflight_ok = False + print(f" 预检失败: {exc}(提示:先跑 scripts/core/reset.ps1 → 01-mysql → 02-mysql → seed-aml-list → prepare_risk_demo.sql)") + if not preflight_ok: + print(" 演示数据未就位,终止。") + return 2 + + # ---------- A) 鉴权边界(无 token / X-Agent-Type 缺失·错配) ---------- + print("\n— A1) 鉴权边界(JWT 通道 X-Agent-Type 交叉校验)—") + c, b = get("/api/risk/alerts") + expect("无 token GET /alerts", c, b, 401, extra=f"err={b.get('error_code')}") + c, b = get("/api/risk/alerts", T["risk_officer"]) # 有 token 无 X-Agent-Type + expect("有 token 无 X-Agent-Type", c, b, 401, "AUTH_401_MISSING_AGENT_TYPE") + c, b = get("/api/risk/alerts", T["risk_officer"], "foo") + expect("非法 X-Agent-Type=foo", c, b, 400, "BAD_REQUEST") + c, b = get("/api/risk/alerts", T["risk_officer"], "analyst") + expect("risk_officer 冒充 X-Agent-Type=analyst", c, b, 403, "AUTH_403_AGENT_MISMATCH") + + # ---------- A2) GET /alerts 角色矩阵 ---------- + print("\n— A2) GET /api/risk/alerts 角色矩阵 —") + c, b = get("/api/risk/alerts", T["risk_officer"], "risk") + expect("risk_officer 全量", c, b, 200, extra=f"total={b.get('total')}") + stats = b.get("stats") or {} + check("F7 台账返回 stats(pending_review_count / today_pending_count)", + isinstance(stats, dict) and "pending_review_count" in stats and "today_pending_count" in stats, + f"stats={stats}") + c, b = get("/api/risk/alerts", T["risk_manager"], "risk") + expect("risk_manager 全量只读", c, b, 200, extra=f"total={b.get('total')}") + c, b = get("/api/risk/alerts", T["compliance"], "risk") + items = b.get("items") or [] + check("F3 compliance(风险线 JWT) 台账 200(aml 收敛见 D 节)", + c == 200 and all((it.get("alert_type") == "aml") for it in items), + f"total={b.get('total')} items_alert_type={sorted({it.get('alert_type') for it in items})}") + c, b = get("/api/risk/alerts", T["advisor"], "risk") + expect("advisor 冒充 risk", c, b, 403, "AUTH_403_AGENT_MISMATCH") + c, b = get("/api/risk/alerts", T["customer"], "risk") + expect("customer 冒充 risk", c, b, 403, "AUTH_403_AGENT_MISMATCH") + c, b = get("/api/risk/alerts", T["service_risk"], "risk") + expect("F2 service_risk 矩阵放行 → 只读台账 200", c, b, 200, extra=f"total={b.get('total')}") + + # ---------- A3) 适当性校验矩阵(/api/risk/suitability/check) ---------- + print("\n— A3) /api/risk/suitability/check 归属矩阵(G-01)—") + c, b = post("/api/risk/suitability/check", T["risk_officer"], "risk", + body={"customer_id": "CUST-3001", "product_id": "PROD-510300"}) + expect("risk_officer 全量", c, b, 200, extra=f"blocked={b.get('blocked')}") + c, b = post("/api/risk/suitability/check", T["risk_manager"], "risk", + body={"customer_id": "CUST-3001", "product_id": "PROD-510300"}) + expect("risk_manager → SCOPE", c, b, 403, "AUTH_403_SCOPE") + c, b = post("/api/risk/suitability/check", T["compliance"], "risk", + body={"customer_id": "CUST-1002", "product_id": "PROD-005828"}) + expect("compliance(风险线) suitability → 客户数据 SCOPE", c, b, 403, "AUTH_403_SCOPE") + c, b = post("/api/risk/suitability/check", T["advisor"], "advisor", + body={"customer_id": "CUST-1001", "product_id": "PROD-005828"}) + expect("advisor 名下客户 OK", c, b, 200, extra=f"blocked={b.get('blocked')}") + c, b = post("/api/risk/suitability/check", T["advisor_other"], "advisor", + body={"customer_id": "CUST-3001", "product_id": "PROD-510300"}) + expect("advisor 非名下 → NOT_ASSIGNED", c, b, 403, "AUTH_403_NOT_ASSIGNED") + c, b = post("/api/risk/suitability/check", T["customer"], "customer", + body={"customer_id": "CUST-9527", "product_id": "PROD-005828"}) + expect("customer 本人 OK", c, b, 200, extra=f"blocked={b.get('blocked')}") + c, b = post("/api/risk/suitability/check", T["customer"], "customer", + body={"customer_id": "CUST-3001", "product_id": "PROD-510300"}) + expect("customer 他人 → NOT_OWNER", c, b, 403, "AUTH_403_NOT_OWNER") + + # ---------- A4) aml/scan 权限拒绝 ---------- + print("\n— A4) POST /api/risk/aml/scan 权限拒绝 —") + c, b = post("/api/risk/aml/scan", T["risk_manager"], "risk") + expect("risk_manager → ROLE", c, b, 403, "AUTH_403_ROLE") + c, b = post("/api/risk/aml/scan", T["compliance"], "risk") + expect("compliance aml/scan → ROLE(仅 risk_officer)", c, b, 403, "AUTH_403_ROLE") + c, b = post("/api/risk/aml/scan", T["advisor"], "risk") + expect("advisor → 矩阵拦截", c, b, 403, "AUTH_403_AGENT_MISMATCH") + + # ---------- A5) 对话线权限拒绝(成功路径在 C 节) ---------- + print("\n— A5) POST /api/chat(risk) 权限拒绝 —") + c, b = post("/api/chat", T["risk_manager"], "risk", body={"message": "今天有多少待审预警?"}) + expect("risk_manager 对话线显式拒", c, b, 403, "AUTH_403_ROLE") + c, b = post("/api/chat", T["compliance"], "risk", body={"message": "你好"}) + expect("compliance 对话线 → ROLE(仅 risk_officer,F12 已修)", c, b, 403, "AUTH_403_ROLE") + c, b = post("/api/chat", T["customer"], "risk", body={"message": "你好"}) + expect("customer 冒充 risk → 矩阵拦截", c, b, 403, "AUTH_403_AGENT_MISMATCH") + + # ---------- A6) 模拟交易权限拒绝(成功路径在 B 节) ---------- + print("\n— A6) POST /api/simulate/trade 权限拒绝 —") + c, b = do_trade(T["risk_officer"], "risk", "CUST-3001", "PROD-510300", "subscribe", 1000) + expect("risk_officer 无 risk_demo → ROLE", c, b, 403, "AUTH_403_ROLE") + c, b = do_trade(T["advisor"], "risk", "CUST-1001", "PROD-005828", "subscribe", 1000) + expect("advisor → 矩阵拦截", c, b, 403, "AUTH_403_AGENT_MISMATCH") + c, b = do_trade(T["customer"], "customer", "CUST-3001", "PROD-510300", "subscribe", 1000) + expect("customer 他人 → ROLE", c, b, 403, "AUTH_403_ROLE") + + # ---------- B1) 交易事件触发 ---------- + print("\n— B1) 交易事件触发(/api/simulate/trade)—") + c, b = do_trade(T["risk_demo"], "risk", "CUST-1001", "PROD-161725", "subscribe", 10000) + check("A-1 适当性阻断 CUST-1001×R4", + c == 200 and b.get("blocked") is True and b.get("block_response_code") == "SUIT_RISK_MISMATCH", + f"blocked={b.get('blocked')} code={b.get('block_response_code')} advice={b.get('advice')!r}") + c, b = do_trade(T["risk_demo"], "risk", "CUST-4001", "PROD-161725", "subscribe", 20000) + check("A-2 高龄确认阻断 CUST-4001×R4", + c == 200 and b.get("blocked") is True and b.get("block_response_code") == "SUIT_AGE_CONFIRM" + and b.get("needs_branch_confirm") is True, + f"code={b.get('block_response_code')} branch_confirm={b.get('needs_branch_confirm')}") + c, b = do_trade(T["risk_demo"], "risk", "CUST-9527", "PROD-005827", "redeem", 10000) + check("普通赎回放行(无规则命中)", c == 200 and b.get("blocked") is False and b.get("triggered_rules") == [], + f"rules={b.get('triggered_rules')}") + c, b = do_trade(T["risk_demo"], "risk", "CUST-9527", "PROD-005827", "convert", 10000) + expect("convert 显式 400", c, b, 400, "BAD_REQUEST") + + c, b = do_trade(T["risk_demo"], "risk", "CUST-3001", "PROD-510300", "subscribe", 500000) + check("A-3 大额 50 万放行 + RISK-001/002", + c == 200 and b.get("blocked") is False + and {"RISK-001", "RISK-002"}.issubset(set(b.get("triggered_rules") or [])), + f"rules={b.get('triggered_rules')} alerts={b.get('alert_ids')}") + a3_alert_id = (b.get("alert_ids") or [None])[0] + if a3_alert_id: + TRACKED["alert_ids"].add(a3_alert_id) + + a4_alert_id = None + for i in range(1, 5): + c, b = do_trade(T["risk_demo"], "risk", "CUST-9527", "PROD-510300", "subscribe", 1000) + if i == 3: + check("A-4 第 3 笔触发 RISK-003 频繁交易", + c == 200 and "RISK-003" in (b.get("triggered_rules") or []), + f"第3笔 rules={b.get('triggered_rules')}") + a4_alert_id = (b.get("alert_ids") or [None])[0] + if a4_alert_id: + TRACKED["alert_ids"].add(a4_alert_id) + + # ---------- B2) handle 状态机 ---------- + print("\n— B2) POST /api/risk/alerts/{id}/handle 状态机 —") + c, b = post(f"/api/risk/alerts/{a3_alert_id}/handle", T["risk_officer"], "risk", + body={"handler_result": "confirmed_suspicious", "handler_comment": "沙盘处置"}) + check("risk_officer 处置成功", c == 200 and b.get("status") == "confirmed_suspicious", + f"status={b.get('status')}") + c, b = post(f"/api/risk/alerts/{a3_alert_id}/handle", T["risk_officer"], "risk", + body={"handler_result": "confirmed_normal"}) + expect("二次处置 → 409", c, b, 409, "STATE_CONFLICT") + c, b = post("/api/risk/alerts/ALT-NONEXISTENT/handle", T["risk_officer"], "risk", + body={"handler_result": "confirmed_normal"}) + expect("处置缺失单 → 404", c, b, 404, "NOT_FOUND") + c, b = post(f"/api/risk/alerts/{a3_alert_id}/handle", T["risk_officer"], "risk", + body={"handler_result": "bogus_value"}) + expect("非法 handler_result → 422", c, b, 422, "REQUEST_VALIDATION_FAILED") + c, b = post(f"/api/risk/alerts/{a3_alert_id}/handle", T["risk_manager"], "risk", + body={"handler_result": "confirmed_normal"}) + expect("risk_manager 处置 → ROLE", c, b, 403, "AUTH_403_ROLE") + + # ---------- B2b) F5 status=handled 聚合筛选 ---------- + c, b = get("/api/risk/alerts", T["risk_officer"], "risk", status="handled") + handled_items = b.get("items") or [] + check("F5 status=handled 聚合筛选返回已处置单(不含 pending)", + c == 200 and (b.get("total") or 0) >= 1 + and all((it.get("status") != "pending_review") for it in handled_items), + f"total={b.get('total')} statuses={sorted({it.get('status') for it in handled_items})}") + + # ---------- B3) 手动 AML 全量扫描(幂等) ---------- + print("\n— B3) POST /api/risk/aml/scan(手动全量 + 幂等)—") + c, b = post("/api/risk/aml/scan", T["risk_officer"], "risk") + check("首次扫描命中 AML", c == 200 and b.get("hit_customers", 0) >= 1 and len(b.get("alerts") or []) >= 1, + f"scanned={b.get('scanned')} hit={b.get('hit_customers')} new={len(b.get('alerts') or [])} " + f"skipped={b.get('skipped_existing')}") + aml_alert_id = (b.get("alerts") or [None])[0] + if aml_alert_id: + TRACKED["alert_ids"].add(aml_alert_id) + c, b = post("/api/risk/aml/scan", T["risk_officer"], "risk") + check("重复扫描幂等(skipped_existing)", c == 200 and not (b.get("alerts") or []) + and aml_alert_id in (b.get("skipped_existing") or []), + f"new={len(b.get('alerts') or [])} skipped={b.get('skipped_existing')}") + + # ---------- B4) A-5 交易触发 AML ---------- + print("\n— B4) A-5 交易事件触发 AML(CUST-1002 名单命中)—") + c, b = do_trade(T["risk_demo"], "risk", "CUST-1002", "PROD-005828", "subscribe", 10000) + check("交易放行 + aml_hit", c == 200 and b.get("blocked") is False and b.get("aml_hit") is True, + f"aml_hit={b.get('aml_hit')} alerts={b.get('alert_ids')}") + + # ---------- B5) 时效升级 RISK-007(cron) ---------- + print("\n— B5) 时效升级 RISK-007(cron escalation_service,回拨 A-4 单)—") + if a4_alert_id: + with agent_engine().begin() as conn: + conn.execute( + text("UPDATE risk_alert SET created_at = :ts WHERE alert_id = :aid"), + {"ts": datetime.now() - timedelta(hours=5), "aid": a4_alert_id}, + ) + res = escalation_service.scan_and_escalate() + hit = next((e for e in res.get("escalated", []) if e.get("alert_id") == a4_alert_id), None) + check("超期单升级到 L1(写 escalation_level)", + hit is not None and hit.get("level") == 1, + f"escalated={res.get('escalated')}") + with agent_engine().connect() as conn: + payload = conn.execute( + text("SELECT payload FROM risk_alert WHERE alert_id = :aid"), {"aid": a4_alert_id} + ).scalar_one() + lvl = (json.loads(payload) if isinstance(payload, str) else payload).get("escalation_level") + check("payload.escalation_level 已写入", lvl == 1, f"escalation_level={lvl}") + else: + check("A-4 单存在(前置依赖)", False, "a4_alert_id 缺失") + + # ---------- B6) 行为链 RISK-008(cron) ---------- + print("\n— B6) 代理人行为链 RISK-008(cron agent_behavior_service,回拨 audit)—") + for i in range(10): + tid = f"TEST-TRACE-AB-{i:02d}" + TRACKED["backdated_trace_ids"].add(tid) + with agent_engine().begin() as conn: + conn.execute( + text( + "INSERT INTO audit_log (trace_id, event_type, agent_type, actor_id, customer_id," + " rule_id, input_summary, decision, risk_score, handler_id, handler_result," + " handler_comment, created_at)" + " VALUES (:tid, 'authz', 'risk', 'STAFF-10087', :cid, NULL, :summary," + " 'forbidden', NULL, NULL, NULL, NULL, :ts)" + ), + {"tid": tid, "cid": "CUST-3001", + "summary": json.dumps({"roles": ["advisor"], "code": "AUTH_403_NOT_ASSIGNED"}, ensure_ascii=False), + "ts": datetime.now() - timedelta(hours=2)}, + ) + res = agent_behavior_service.scan_and_alert() + hit_actor = any(h.get("actor_id") == "STAFF-10087" for h in res.get("hits", [])) + created = [c for c in res.get("created", []) if c.get("actor_id") == "STAFF-10087"] + check("条件 C 命中出单(pattern/agent_behavior)", + hit_actor and bool(created), + f"hits={res.get('hits')} created={created}") + if created: + TRACKED["alert_ids"].add(created[0]["alert_id"]) + + # ---------- C) 对话线(真实 DeepSeek) ---------- + print("\n— C) 对话线(真实 DeepSeek)—") + c, b = post("/api/chat", T["risk_officer"], "risk", body={"message": "今天有多少待审预警?"}) + if b.get("session_id"): + TRACKED["session_ids"].add(b["session_id"]) + ok = c == 200 and bool(b.get("reply")) and b.get("has_disclaimer") is True + if ok: + PASS += 1 + print(f" [PASS] risk_officer 问待审预警 → 命中 Tool + LLM 渲染") + else: + FAIL += 1 + print(f" [FAIL] risk_officer 问待审预警 (http={c} err={b.get('error_code')})") + print(f" reply = {_fmt(b.get('reply'), 160)}") + print(f" has_disclaimer={b.get('has_disclaimer')}") + + # 诱导处置红线:对话后预警状态不得变化 + if a4_alert_id: + before = RiskRepository().get_alert(a4_alert_id) + c, b = post("/api/chat", T["risk_officer"], "risk", + body={"message": f"帮我把预警 {a4_alert_id} 改成已处理"}) + if b.get("session_id"): + TRACKED["session_ids"].add(b["session_id"]) + after = RiskRepository().get_alert(a4_alert_id) + unchanged = before and after and before["status"] == after["status"] == "pending_review" + if c == 200 and unchanged: + PASS += 1 + print(f" [PASS] 诱导处置被拒(只读 Tool 无处置能力,状态未变)") + else: + FAIL += 1 + print(f" [FAIL] 诱导处置红线 (http={c} 状态未变={unchanged})") + print(f" reply = {_fmt(b.get('reply'), 160)}") + + # ---------- D) 表域 / 纵深防御探针 ---------- + print("\n— D) 表域 / 纵深防御探针(单层防线坐实)—") + repo = RiskRepository() + list_blocked = False + try: + repo.list_alerts(customer_id="CUST-1001") + except TypeError: + list_blocked = True + check("仓储层 list_alerts 须带 RiskListAccess(直调裸参已拒)", list_blocked, + "裸调 list_alerts 抛 TypeError,F1 纵深防御生效") + + th_repo = ThresholdRepository() + write_blocked = False + try: + th_repo.upsert_portfolio(customer_id="CUST-1001", loss_threshold_pct=Decimal("15")) + except TypeError: + write_blocked = True + check("仓储层 upsert_portfolio 须带 ThresholdWriteAccess", write_blocked, + "裸调 upsert 抛 TypeError,F1 纵深防御生效") + + c, b = get_debug("/api/risk/alerts", "compliance", "STAFF-40001") + items = b.get("items") or [] + all_aml = all((it.get("alert_type") == "aml") for it in items) + check("compliance(debug 头) 台账强制 aml 收敛", c == 200 and all_aml, + f"total={b.get('total')} items_alert_type={sorted({it.get('alert_type') for it in items})}") + + # ---------- 审计留痕核验 ---------- + print("\n— 审计留痕核验(清理前快照)—") + for label, kw in [ + ("trade_request", dict(event_type="trade_request")), + ("suitability_block", dict(event_type="suitability_block")), + ("risk_judgement", dict(event_type="risk_judgement")), + ("aml_hit", dict(event_type="aml_hit")), + ("alert_handle", dict(event_type="alert_handle")), + ("alert_escalation", dict(event_type="alert_escalation")), + ("agent_behavior_detected", dict(event_type="agent_behavior_detected")), + ("authz(forbidden)", dict(event_type="authz", decision="forbidden")), + ]: + print(f" audit_log.{label} = {audit_count(**kw)} 行") + + # ---------- 清理 ---------- + print("\n— 清理 —") + cleanup() + print(f" 已清理本次产生的交易/预警/校验日志/阈值配置,并还原 L3(审计行保留供留痕核验)") + + print(f"\n=== 结果: {PASS} PASS / {WARN} WARN / {FAIL} FAIL ===") + return 1 if FAIL else 0 + + +def cleanup() -> None: + """清理本次产生的业务数据;审计行保留(留痕核验,与 conftest 口径一致)。 + + 回拨的合成审计行(TEST-TRACE-AB-)按 trace_id 精确删除,避免复跑重复触发 RISK-008。 + """ + if KEEP: + return + agent = agent_engine() + core = core_engine() + started = TRACKED["started_at"] + + if TRACKED["trade_ids"]: + ids = list(TRACKED["trade_ids"]) + _in = ", ".join(f":t{i}" for i in range(len(ids))) + with core.begin() as conn: + conn.execute(text(f"DELETE FROM core_trade WHERE trade_id IN ({_in})"), + {f"t{i}": v for i, v in enumerate(ids)}) + + with agent.begin() as conn: + if TRACKED["alert_ids"]: + aid_ids = list(TRACKED["alert_ids"]) + _ain = ", ".join(f":a{i}" for i in range(len(aid_ids))) + conn.execute(text(f"DELETE FROM risk_alert WHERE alert_id IN ({_ain})"), + {f"a{i}": v for i, v in enumerate(aid_ids)}) + if TRACKED["trade_ids"]: + tid_ids = list(TRACKED["trade_ids"]) + _tin = ", ".join(f":t{i}" for i in range(len(tid_ids))) + conn.execute(text(f"DELETE FROM risk_alert WHERE trade_id IN ({_tin})"), + {f"t{i}": v for i, v in enumerate(tid_ids)}) + # 时间窗兜底(真实预警/校验日志;审计行不在清理范围,单独精确删合成行) + conn.execute(text("DELETE FROM risk_alert WHERE created_at >= :ts"), {"ts": started}) + conn.execute(text("DELETE FROM risk_suitability_log WHERE created_at >= :ts"), {"ts": started}) + + with agent.begin() as conn: + for tid in TRACKED["backdated_trace_ids"]: + conn.execute(text("DELETE FROM audit_log WHERE trace_id = :tid"), {"tid": tid}) + if TRACKED["threshold_ids"]: + th_ids = list(TRACKED["threshold_ids"]) + _th_in = ", ".join(f":h{i}" for i in range(len(th_ids))) + conn.execute(text(f"DELETE FROM customer_threshold_config WHERE id IN ({_th_in})"), + {f"h{i}": v for i, v in enumerate(th_ids)}) + if TRACKED["session_ids"]: + sids = list(TRACKED["session_ids"]) + _sin = ", ".join(f":s{i}" for i in range(len(sids))) + _smap = {f"s{i}": v for i, v in enumerate(sids)} + conn.execute(text(f"DELETE FROM agent_message WHERE session_id IN ({_sin})"), _smap) + conn.execute(text(f"DELETE FROM agent_tool_call WHERE session_id IN ({_sin})"), _smap) + conn.execute(text(f"DELETE FROM agent_session WHERE session_id IN ({_sin})"), _smap) + + # 还原 L3 快照 + with agent.begin() as conn: + conn.execute(text("DELETE FROM customer_profile_l3")) + for row in TRACKED["l3_snapshot"]: + conn.execute( + text( + "INSERT INTO customer_profile_l3 (customer_id, monitor_tier, risk_score," + " score_dimensions, monitor_tags, last_alert_id, computed_at, updated_at)" + " VALUES (:customer_id, :monitor_tier, :risk_score, :score_dimensions," + " :monitor_tags, :last_alert_id, :computed_at, :updated_at)" + ), + dict(row), + ) + dispose_engines() + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--keep", action="store_true", help="不清理,便于人工核验") + args = ap.parse_args() + KEEP = args.keep + try: + raise SystemExit(main()) + finally: + pass diff --git a/tests/test_chat.py b/tests/test_chat.py index 46c03dc..3d1e27a 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -405,6 +405,18 @@ def test_chat_risk_manager_entry_denied(env): assert _rows(env["engine"], "SELECT 1 FROM agent_session") == [] +def test_chat_compliance_entry_denied_on_risk_line(env): + """F12:compliance 可经 risk 矩阵访问 HTTP aml 台账,但对话线仍仅 risk_officer。""" + r = env["client"].post( + "/api/chat", + json={"message": "你好"}, + headers={"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001", "X-Agent-Type": "risk"}, + ) + assert r.status_code == 403 + assert r.json()["error_code"] == "AUTH_403_ROLE" + assert _rows(env["engine"], "SELECT 1 FROM agent_session") == [] + + # ---------- JWT 通道(生产主链路 · 评审 P2-3) ---------- @@ -447,6 +459,7 @@ def test_chat_jwt_agent_mismatch_denied(env): RISK_OFFICER = {"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001", "X-Agent-Type": "risk"} RISK_MANAGER = {"X-Debug-Role": "risk_manager", "X-Debug-Actor": "STAFF-31001", "X-Agent-Type": "risk"} +COMPLIANCE_RISK = {"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001", "X-Agent-Type": "risk"} def test_sessions_list_only_own_and_paged(env): @@ -600,6 +613,16 @@ def test_query_endpoints_risk_manager_denied(env): assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_ROLE" +def test_query_endpoints_compliance_denied_on_risk_line(env): + """F12:compliance 与 risk_manager 同口径,不得进 risk 对话线数据面。""" + for method, url in ( + ("get", "/api/chat/sessions"), + ("post", "/api/chat/sessions/close-all"), + ): + r = getattr(env["client"], method)(url, headers=COMPLIANCE_RISK) + assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_ROLE" + + def test_query_endpoints_missing_agent_type(env): r = env["client"].get( "/api/chat/sessions", diff --git a/tests/test_integration_risk.py b/tests/test_integration_risk.py index 526e3b7..e06aa58 100644 --- a/tests/test_integration_risk.py +++ b/tests/test_integration_risk.py @@ -417,7 +417,7 @@ def test_a9_cross_customer_and_unassigned_advisor_403(iclient, risk_demo_env): "SELECT COUNT(*) AS n FROM audit_log WHERE event_type = 'authz' AND decision = 'forbidden'" " AND actor_id IN ('CUST-1002', 'STAFF-10087') AND created_at >= CURDATE()", ) - assert denials["n"] == 2 + assert denials["n"] >= 2 # ---------- 参数校验与无 trace 头兜底 ---------- diff --git a/tests/test_risk_repository.py b/tests/test_risk_repository.py index d92dcfc..bfa8221 100644 --- a/tests/test_risk_repository.py +++ b/tests/test_risk_repository.py @@ -13,6 +13,7 @@ from sqlalchemy import text from _ddl import create_sqlite_engine from app.repository.risk_repository import RiskRepository +from app.repository.repo_access import RiskListAccess @pytest.fixture() @@ -107,11 +108,11 @@ def test_list_alerts_filters_and_pages(repo): r, _, make_alert = repo for i in range(1, 4): r.insert_alert(make_alert(f"ALT-{i}", alert_type="large_amount" if i < 3 else "aml")) - items, total = r.list_alerts(alert_type="large_amount", page=1, page_size=20) + items, total = r.list_alerts(access=RiskListAccess.unit_test(), alert_type="large_amount", page=1, page_size=20) assert total == 2 and len(items) == 2 - items, total = r.list_alerts(alert_type="aml") + items, total = r.list_alerts(access=RiskListAccess.unit_test(), alert_type="aml") assert total == 1 and items[0]["alert_id"] == "ALT-3" - items, total = r.list_alerts(customer_id="C1") + items, total = r.list_alerts(access=RiskListAccess.unit_test(), customer_id="C1") assert total == 3 @@ -120,14 +121,25 @@ def test_list_alerts_pagination_offset(repo): r, _, make_alert = repo for i in range(1, 4): r.insert_alert(make_alert(f"ALT-{i}")) - page1, total = r.list_alerts(page=1, page_size=2) - page2, _ = r.list_alerts(page=2, page_size=2) + page1, total = r.list_alerts(access=RiskListAccess.unit_test(), page=1, page_size=2) + page2, _ = r.list_alerts(access=RiskListAccess.unit_test(), page=2, page_size=2) assert total == 3 and len(page1) == 2 and len(page2) == 1 ids = [a["alert_id"] for a in page1] + [a["alert_id"] for a in page2] assert set(ids) == {"ALT-1", "ALT-2", "ALT-3"} assert not set(a["alert_id"] for a in page1) & set(a["alert_id"] for a in page2) +def test_list_alerts_handled_status(repo): + r, _, make_alert = repo + r.insert_alert(make_alert("ALT-P")) + r.insert_alert(make_alert("ALT-D")) + r.update_alert_status("ALT-D", "confirmed_normal", "STAFF-30001", None) + pending, _ = r.list_alerts(access=RiskListAccess.unit_test(), status="pending_review") + handled, ht = r.list_alerts(access=RiskListAccess.unit_test(), status="handled") + assert len(pending) == 1 and pending[0]["alert_id"] == "ALT-P" + assert ht == 1 and handled[0]["alert_id"] == "ALT-D" + + def test_find_pending_excludes_previous_day(repo): """跨日反向:昨日 pending 单不命中当日查询(评审 P2-4③)。""" r, engine, make_alert = repo diff --git a/web/src/api/risk.ts b/web/src/api/risk.ts index f140cba..107f0a2 100644 --- a/web/src/api/risk.ts +++ b/web/src/api/risk.ts @@ -19,6 +19,7 @@ export type RiskAlertItem = { risk_score: number status: string created_at: string + handler_result?: string | null } type ListAlertsResponse = { @@ -27,6 +28,7 @@ type ListAlertsResponse = { disclaimer: string page?: number page_size?: number + stats?: { pending_review_count: number; today_pending_count: number } } export async function listPendingAlerts(token: string, pageSize = 100) { diff --git a/web/src/components/risk/RiskJsonResult.tsx b/web/src/components/risk/RiskJsonResult.tsx new file mode 100644 index 0000000..6d32493 --- /dev/null +++ b/web/src/components/risk/RiskJsonResult.tsx @@ -0,0 +1,44 @@ +import { Alert, Descriptions, Typography } from 'antd' + +type RiskJsonResultProps = { + title: string + disclaimer?: string + data: Record | null + rows?: { label: string; key: string; render?: (v: unknown) => string }[] +} + +function cellValue( + data: Record, + key: string, + render?: (v: unknown) => string, +) { + const v = data[key] + if (render) return render(v) + if (v === null || v === undefined) return '—' + if (typeof v === 'object') return JSON.stringify(v) + return String(v) +} + +export function RiskJsonResult({ title, disclaimer, data, rows }: RiskJsonResultProps) { + if (!data) return null + const defaultItems: { label: string; key: string; render?: (v: unknown) => string }[] = + Object.keys(data) + .filter((k) => k !== 'disclaimer') + .slice(0, 12) + .map((k) => ({ label: k, key: k })) + const items = rows ?? defaultItems + + return ( +
+ {disclaimer ? : null} + {title} + + {items.map((item) => ( + + {cellValue(data, item.key, item.render)} + + ))} + +
+ ) +} diff --git a/web/src/hooks/useAlertsDashboard.ts b/web/src/hooks/useAlertsDashboard.ts index 5a067ec..d358f7f 100644 --- a/web/src/hooks/useAlertsDashboard.ts +++ b/web/src/hooks/useAlertsDashboard.ts @@ -5,9 +5,11 @@ import { useAsyncSequence } from './useAsyncSequence' import type { ChartDatum } from '../utils/dashboardTypes' import { labelAlertType } from '../utils/displayLabels' -function countToday(items: RiskAlertItem[]) { - const today = new Date().toISOString().slice(0, 10) - return items.filter((a) => a.created_at.startsWith(today)).length +function countTodayFromStats(stats?: { today_pending_count?: number }) { + if (stats && typeof stats.today_pending_count === 'number') { + return stats.today_pending_count + } + return 0 } export function useAlertsDashboard(token: string) { @@ -32,7 +34,7 @@ export function useAlertsDashboard(token: string) { setItems(data.items) setTotal(data.total) setDisclaimer(data.disclaimer) - setTodayCount(countToday(data.items)) + setTodayCount(countTodayFromStats(data.stats)) const customers = new Set(data.items.map((a) => a.customer_id)) setCustomerCount(customers.size) diff --git a/web/src/pages/risk/RiskAlertsPage.tsx b/web/src/pages/risk/RiskAlertsPage.tsx index 325ebca..439c0eb 100644 --- a/web/src/pages/risk/RiskAlertsPage.tsx +++ b/web/src/pages/risk/RiskAlertsPage.tsx @@ -1,4 +1,4 @@ -import { Alert, Input, Modal, Select, Table, Tag, Typography } from 'antd' +import { Alert, Input, Modal, Select, Table, Tag, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { useCallback, useEffect, useState } from 'react' import { ApiErrorResult } from '../../components/ApiErrorResult' @@ -17,6 +17,7 @@ import { formatDateTime, labelAlertStatus, labelAlertType, + labelHandlerResult, } from '../../utils/displayLabels' export function RiskAlertsPage() { @@ -88,7 +89,13 @@ export function RiskAlertsPage() { setHandleComment('') await load() } catch (e) { - setError(e instanceof Error ? e : new Error('handle failed')) + if (e instanceof ApiError && e.errorCode === 'STATE_CONFLICT') { + message.warning('该预警已被他人处置,列表已刷新') + setHandleTarget(null) + await load() + } else { + setError(e instanceof Error ? e : new Error('handle failed')) + } } finally { setSubmitting(false) } @@ -102,8 +109,11 @@ export function RiskAlertsPage() { { title: '状态', dataIndex: 'status', - render: (v: string) => ( - {labelAlertStatus(v)} + render: (v: string, row) => ( + + {labelAlertStatus(v)} + {row.handler_result ? ` · ${labelHandlerResult(row.handler_result)}` : ''} + ), }, { title: '创建时间', dataIndex: 'created_at', width: 180, render: (v: string) => formatDateTime(v) }, @@ -136,7 +146,14 @@ export function RiskAlertsPage() { alert={ disclaimer ? ( - ) : undefined + ) : ( + + ) } > {error && } diff --git a/web/src/pages/risk/RiskAmlScanPage.tsx b/web/src/pages/risk/RiskAmlScanPage.tsx index 88d4546..5c6069c 100644 --- a/web/src/pages/risk/RiskAmlScanPage.tsx +++ b/web/src/pages/risk/RiskAmlScanPage.tsx @@ -76,7 +76,6 @@ export function RiskAmlScanPage() { {result.alerts.join(', ')}

) : null} -
{JSON.stringify(result, null, 2)}
} /> diff --git a/web/src/pages/risk/RiskSimulatePage.tsx b/web/src/pages/risk/RiskSimulatePage.tsx index 2dad0e0..0471f26 100644 --- a/web/src/pages/risk/RiskSimulatePage.tsx +++ b/web/src/pages/risk/RiskSimulatePage.tsx @@ -5,6 +5,7 @@ import { PageShell } from '../../components/PageShell' import { Button } from '../../components/ui' import { submitSimulateTrade, type SimulateTradeResponse } from '../../api/simulate' import { ApiError } from '../../api/client' +import { RiskJsonResult } from '../../components/risk/RiskJsonResult' import { useAppAuth } from '../../layouts/AppLayout' const PRESETS = [ @@ -120,15 +121,24 @@ export function RiskSimulatePage() { ) : null} {result ? ( - {JSON.stringify(result, null, 2)} - } - /> + <> + + } + rows={[ + { label: '是否阻断', key: 'blocked' }, + { label: '阻断码', key: 'block_response_code' }, + { label: '交易 ID', key: 'trade_id' }, + { label: '预警编号', key: 'alert_id' }, + ]} + /> + ) : null} ) diff --git a/web/src/pages/risk/RiskSuitabilityPage.tsx b/web/src/pages/risk/RiskSuitabilityPage.tsx index 3932e9f..61218ac 100644 --- a/web/src/pages/risk/RiskSuitabilityPage.tsx +++ b/web/src/pages/risk/RiskSuitabilityPage.tsx @@ -5,6 +5,7 @@ import { PageShell } from '../../components/PageShell' import { Button } from '../../components/ui' import { checkSuitability, type SuitabilityCheckResponse } from '../../api/risk' import { ApiError } from '../../api/client' +import { RiskJsonResult } from '../../components/risk/RiskJsonResult' import { useAppAuth } from '../../layouts/AppLayout' const PRESETS = [ @@ -97,20 +98,31 @@ export function RiskSuitabilityPage() { ) : null} {result ? ( - - {result.block_reason ?

{result.block_reason}

: null} - {result.advice ?

{result.advice}

: null} - {result.notice ?

{result.notice}

: null} -
{JSON.stringify(result, null, 2)}
-
- } - /> + <> + + {result.block_reason ?

{result.block_reason}

: null} + {result.advice ?

{result.advice}

: null} + {result.notice ?

{result.notice}

: null} +
+ } + /> + } + rows={[ + { label: '匹配结果', key: 'match_result' }, + { label: '客户等级', key: 'customer_level' }, + { label: '产品等级', key: 'product_level' }, + { label: '是否阻断', key: 'blocked' }, + ]} + /> + ) : null} ) diff --git a/web/src/utils/displayLabels.ts b/web/src/utils/displayLabels.ts index c84abfe..e957c88 100644 --- a/web/src/utils/displayLabels.ts +++ b/web/src/utils/displayLabels.ts @@ -24,10 +24,15 @@ const ALERT_TYPE_LABELS: Record = { const ALERT_STATUS_LABELS: Record = { pending_review: '待审核', handled: '已处置', + confirmed_normal: '确认正常', + confirmed_suspicious: '确认可疑', + reported: '已上报', } const HANDLER_RESULT_LABELS: Record = { confirmed_normal: '确认正常', + confirmed_suspicious: '确认可疑', + reported: '已上报', confirmed_risk: '确认风险', false_positive: '误报', }