From 0fb7d34d7a0977d0fc489f0603a40c6bdce251e7 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 11 Sep 2026 14:45:16 +0800 Subject: [PATCH] feat(analyst): Implement audit logging for query denial and clarification - Enhanced the `AnalystAgent` class to include an `_audit_terminal` method for logging query denials, clarifications, and errors, ensuring compliance and traceability. - Updated error handling paths to call the new audit method, capturing relevant details such as question, user authentication, and SQL context. - Introduced new validation checks in `sql_guard.py` to enforce ownership filters for sensitive queries, improving security measures. - Added unit tests to verify the correct logging behavior and ownership filter enforcement, ensuring robust functionality. This update significantly strengthens the auditing capabilities of the analyst agent, enhancing security and compliance in query handling. --- app/service/analyst_agent.py | 64 +++++++++++-- app/service/sql_guard.py | 46 ++++++++-- docs/memory/FLOW.md | 3 +- docs/memory/FRAMEWORK.md | 4 +- docs/memory/ITERATION.md | 2 +- docs/memory/MEMORY.md | 13 +-- docs/memory/TODO.md | 7 +- .../2026-09-11-analyst-domain-rbac/README.md | 8 ++ .../TEST-LOG-2026-09-11-AN-001.md | 89 ++++++++++++++++--- .../2026-09-11-defense-stable-package.md | 23 +++++ docs/答辩/DEMO-SOP-问数.md | 52 +++++++++++ docs/答辩/答辩知识点清单.md | 26 +++--- .../agent/seed-analyst-query-templates.sql | 35 ++++++++ scripts/dev/sandbox_domain_test.py | 6 +- tests/test_wave6_analyst_agent.py | 11 ++- tests/test_wave6_sql_guard.py | 41 +++++++++ web/src/App.tsx | 19 +--- .../dashboard/AnalystMarketDashboard.tsx | 1 - web/src/routes/menus.tsx | 1 - 19 files changed, 376 insertions(+), 75 deletions(-) create mode 100644 docs/memory/tests/2026-09-11-analyst-domain-rbac/README.md create mode 100644 docs/superpowers/specs/2026-09-11-defense-stable-package.md create mode 100644 docs/答辩/DEMO-SOP-问数.md diff --git a/app/service/analyst_agent.py b/app/service/analyst_agent.py index eb04e6c..d7db69e 100644 --- a/app/service/analyst_agent.py +++ b/app/service/analyst_agent.py @@ -85,14 +85,16 @@ class AnalystAgent: try: domain = assert_analyst_query_access(auth) except AnalystAuthError as exc: - return self._deny(exc.error_code, exc.message, trace_id) + resp = self._deny(exc.error_code, exc.message, trace_id) + return self._audit_terminal(question, auth, session_id, trace_id, resp) scope: list[str] = resolve_analyst_scope(auth, domain, self.repo) # 1) 指标消歧(N-01) amb = self._detect_ambiguity(question) if amb is not None: - return self._clarify(amb, trace_id) + resp = self._clarify(amb, trace_id) + return self._audit_terminal(question, auth, session_id, trace_id, resp) # 2) 模板填参(D-06)或 LLM 生成 SQL template_key: str | None = None @@ -110,7 +112,10 @@ class AnalystAgent: try: vres = validate(sql_text, domain, scope) except SqlGuardError as exc: - return self._deny(exc.error_code, exc.message, trace_id, domain) + resp = self._deny(exc.error_code, exc.message, trace_id, domain) + return self._audit_terminal( + question, auth, session_id, trace_id, resp, sql=sql_text + ) # 4) 执行(缓存优先) perm_fp = self.cache.permission_fingerprint(auth.subject_id, domain, scope) @@ -128,7 +133,10 @@ class AnalystAgent: latency = int((time.time() - t0) * 1000) self.cache.set_result(perm_fp, sql_text, (exec_result, data_as_of), vres.tables) except Exception as exc: # noqa: BLE001 - return self._error(f"SQL 执行失败:{exc}", trace_id) + resp = self._error(f"SQL 执行失败:{exc}", trace_id) + return self._audit_terminal( + question, auth, session_id, trace_id, resp, sql=sql_text + ) table = TableData(columns=exec_result["columns"], rows=exec_result["rows"]) empty_state = classify_empty(exec_result["rows"], sql_text) @@ -143,7 +151,10 @@ class AnalystAgent: ) cost_est += estimate_cost(g_usage) except Exception as exc: # noqa: BLE001 - return self._error(f"解读生成失败:{exc}", trace_id) + resp = self._error(f"解读生成失败:{exc}", trace_id) + return self._audit_terminal( + question, auth, session_id, trace_id, resp, sql=sql_text + ) status = "degrade" if (guard_result is not None and not guard_result.passed) else "success" if status == "degrade": answer = "解读校验未通过,请以下方表格数据为准。" @@ -411,6 +422,49 @@ class AnalystAgent: except Exception: # noqa: BLE001 pass + def _audit_terminal( + self, + question: str, + auth: AnalystAuthContext, + session_id: str, + trace_id: str, + resp: AnalystResponse, + *, + sql: str = "", + ) -> AnalystResponse: + """阻断/clarify/error 路径留痕(TEST-AN-001 缺口 D)。""" + sql_text = (sql or "").strip() + sql_hash = self.cache.sql_hash(sql_text) if sql_text else "blocked" + summary = { + "status": resp.status, + "error_code": resp.error_code, + "source": "blocked", + } + try: + self.repo.log_query( + session_id=session_id, + trace_id=trace_id, + staff_id=auth.subject_id, + nl_question=question, + generated_sql=sql_text, + sql_hash=sql_hash or None, + row_count=0, + exec_status="blocked", + result_summary=summary, + exec_latency_ms=0, + has_disclaimer=False, + ) + self.repo.log_audit( + trace_id=trace_id, + event_type="analyst_query", + actor_id=auth.subject_id, + decision=resp.status, + input_summary={"question": question, "error_code": resp.error_code}, + ) + except Exception: # noqa: BLE001 + pass + return resp + def build_graph(agent: AnalystAgent): """LangGraph StateGraph 适配(架构对齐用;核心逻辑仍在 run())。""" diff --git a/app/service/sql_guard.py b/app/service/sql_guard.py index 5b089e7..b56f019 100644 --- a/app/service/sql_guard.py +++ b/app/service/sql_guard.py @@ -33,6 +33,16 @@ CUSTOMER_TABLES = { "core_holding", "core_trade", "core_cash_flow", } +# 含 customer_id 维度的 Agent 表(须与 Core 客户表同等行级约束) +AGENT_ROW_SCOPED_TABLES = { + "risk_alert", "customer_profile_l1", "customer_profile_l2", "customer_profile_l3", +} + +ROW_SCOPED_TABLES = CUSTOMER_TABLES | AGENT_ROW_SCOPED_TABLES + +# 运营/聚合域禁止查明细(SELECT * 不含 customer_id 子串时仍可能泄露) +OPS_FORBIDDEN_DETAIL_TABLES = AGENT_ROW_SCOPED_TABLES + # 敏感列(存储层已脱敏;此处供下游解读/日志二次校验,避免引用明文) SENSITIVE_COLUMNS = { "mobile", "phone", "id_card", "id_no", "idcard", "bank_card", "card_no", @@ -102,6 +112,16 @@ def _ops_has_customer_detail(sql: str) -> bool: return "customer_id" in cleaned +def _has_ownership_filter(sql: str) -> bool: + """WHERE/JOIN ON 中是否存在 customer_id 或 advisor_id 的 = / IN 过滤(非 SELECT 列名占位)。""" + low = sql.lower() + if re.search(r"\bwhere\b[\s\S]*?\b(customer_id|advisor_id)\s*(=|in\b)", low): + return True + if re.search(r"\bjoin\b[\s\S]*?\bon\b[\s\S]*?\b(customer_id|advisor_id)\s*=", low): + return True + return False + + def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None) -> ValidationResult: """对生成/改写的 SQL 做只读 + 表白名单 + 域规则校验。 @@ -118,7 +138,11 @@ def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None) raise SqlGuardError("SQL_NOT_SELECT", "仅允许 SELECT 只读查询") low = sql.lower() for kw in FORBIDDEN_KEYWORDS: - if kw in low: + if " " in kw or "(" in kw: + if kw in low: + raise SqlGuardError("SQL_FORBIDDEN", f"检测到危险关键字:{kw}") + continue + if re.search(rf"\b{re.escape(kw)}\b", low): raise SqlGuardError("SQL_FORBIDDEN", f"检测到危险关键字:{kw}") # 2) 表白名单(CTE 别名除外) @@ -132,6 +156,11 @@ def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None) # 3) 域规则 if domain == "ops" or domain == "aggregate": + if any(t in OPS_FORBIDDEN_DETAIL_TABLES for t in tables): + raise SqlGuardError( + "AUTH_403_SCOPE", + "运营角色不可查预警台账或画像明细表", + ) if _ops_has_customer_detail(sql): raise SqlGuardError("AUTH_403_SCOPE", "运营角色仅可查客户维度之上的聚合结果") if extract_customer_literals(sql): @@ -143,11 +172,10 @@ def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None) out = [c for c in literals if c.upper() not in {s.upper() for s in scope}] if out: raise SqlGuardError("AUTH_403_NOT_ASSIGNED", f"无权访问客户:{', '.join(out)}") - # 涉及客户表的查询必须显式带归属过滤(customer_id / advisor_id),否则视为未收敛范围 - touches_customer = any(t in CUSTOMER_TABLES for t in tables) - if touches_customer and "customer_id" not in low and "advisor_id" not in low: + touches_row_scoped = any(t in ROW_SCOPED_TABLES for t in tables) + if touches_row_scoped and not _has_ownership_filter(sql): raise SqlGuardError("AUTH_403_SCOPE", "涉及客户数据的查询必须包含归属过滤条件") - result.has_customer_detail = touches_customer + result.has_customer_detail = touches_row_scoped elif domain == "self": literals = extract_customer_literals(sql) @@ -157,10 +185,10 @@ def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None) out = [c for c in literals if c.upper() not in {s.upper() for s in scope}] if out: raise SqlGuardError("AUTH_403_NOT_OWNER", f"仅能查询本人数据,无权访问:{', '.join(out)}") - touches_customer = any(t in CUSTOMER_TABLES for t in tables) - if touches_customer and "customer_id" not in low: - raise SqlGuardError("AUTH_403_SCOPE", "客户问数涉及客户表时必须带本人 customer_id 过滤") - result.has_customer_detail = touches_customer + touches_row_scoped = any(t in ROW_SCOPED_TABLES for t in tables) + if touches_row_scoped and not _has_ownership_filter(sql): + raise SqlGuardError("AUTH_403_SCOPE", "客户问数涉及客户维度表时必须带本人 customer_id 过滤") + result.has_customer_detail = touches_row_scoped elif domain == "risk" or domain == "risk_officer": # 台账全量 + 客户只读:允许白名单内全部表 diff --git a/docs/memory/FLOW.md b/docs/memory/FLOW.md index 9749a18..5159853 100644 --- a/docs/memory/FLOW.md +++ b/docs/memory/FLOW.md @@ -44,7 +44,8 @@ uvicorn app.main:app --reload GET http://127.0.0.1:8000/health → {"status":"ok"} cd web && npm run dev → 5173 代理 8000 - python -m pytest → 804 passed 1 skipped + python -m pytest → 816 passed 1 skipped + 问数答辩前:见 `docs/答辩/DEMO-SOP-问数.md`(灌 metric+template 种子 · battery 可选) RBAC 联调账号:scripts/dev/rbac-seed-reference.md ``` diff --git a/docs/memory/FRAMEWORK.md b/docs/memory/FRAMEWORK.md index 3a1bd2b..1e9a17e 100644 --- a/docs/memory/FRAMEWORK.md +++ b/docs/memory/FRAMEWORK.md @@ -37,13 +37,13 @@ | 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 | L2 画像、RAG、草稿 | L1 只读、Milvus | 空壳 service(chat 骨架已通) | -| 数据分析 Agent | NL→SQL→解读 | Core RO、画像只读 | **S3+P2+D-06(2026-09-10)**:guardrail/口径 · **结果缓存+写侧失效** · **模板填参**(`template_service`)· **804 pytest** | +| 数据分析 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` 分支** | | 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) | | 同步脚本 | 归属、Neo4j | Core → agent / 图库 | **sync_*.py 已实现** | -| 前端 Demo(`web/`) | 四角色工作台、平台读 UI、Chat | FastAPI v0.1 + chat B/C | **P0 Demo 齐备(2026-09-10)**:四角色页+Chat/SSE · 问数+模板标签 · 风控四页 · 行情 · **19 Vitest** · 分析对话仍占位 | +| 前端 Demo(`web/`) | 四角色工作台、平台读 UI、Chat | FastAPI v0.1 + chat B/C | **P0 Demo(2026-09-11)**:问数+解读按钮 · 模板/缓存 Tag · **`/analytics/chat`→问数** · **22 Vitest** | ------ diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md index 7d7c497..74fde35 100644 --- a/docs/memory/ITERATION.md +++ b/docs/memory/ITERATION.md @@ -30,5 +30,5 @@ | 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 | **D-09 子集 · 问数/解读拆分**:`interpret=false` 默认 · `POST /api/analyst/interpret` · 问数页按钮 · MEMORY/TODO/REQUIREMENTS · **813 pytest** | 用户:合并进 memory + 各角色「分析该数据」 | analyst_agent / analyst.py / AnalystQueryPage | +| 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 eef7d63..88dbe9b 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -9,7 +9,7 @@ **项目是什么:** 金融四 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座;**不**互调 LLM,跨 Agent 走 L1/L2/L3 画像与预警表。 -**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 S2 Wave3 + 数据分析 S3/P2/D-06 + D-09 问数/解读拆分 + 前端四角色 P0 Demo** · **813 pytest** · **22 Vitest** · **Redis @ 6380** · **`merger` 工作区未 commit** +**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 Wave3 + 数据分析 D-06/D-09 解读拆分 + sql_guard RBAC 修复** · **820 pytest** · **22 Vitest** · **Redis @ 6380** · **`merger` 未 commit** **工作分支:** 团队开发在 **`merger`**;历史 `risk-control-agent` 交付冻结。 @@ -47,8 +47,9 @@ | `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/sync/*.py` | **已实现** | 归属同步 + Neo4j 全图 | -| `tests/` | **已实现** | **813 用例** 1 skipped(Wave6 template/cache/interpret + Wave3 customer + 1B/R1) -| `docs/course/` | **交互课程集** | 导览中心 + 总览 **8 模块**(含模块 8 答辩动线)+ 问数 **5 模块**(D-06)+ 风控深潜 **7 模块**(含写侧并发)· 提纲 `docs/答辩/答辩知识点清单.md` +| `tests/` | **已实现** | **820 用例** 1 skipped(Wave6 sql_guard RBAC + interpret + customer) | +| `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)+ 规则表附录 | | `docs/项目框架设计/实现方案-风控追加需求v1.1-C4C6.md` | **已定稿** | C4~C6 编码依据(经独立 AI 评审修订闭环);分支/进度速览另见项目根 `交接文档.md` | | `docs/项目框架设计/合并注意事项-风控模块并入main.md` | **AL-09 已执行(2026-09-08)** | 合并接线完成;接缝见《风控Agent模块边界与合并接缝标注.md》 | @@ -72,7 +73,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(**804 绿**);问数:`seed-analyst-metric-dict.sql` + **`seed-analyst-query-templates.sql`**(模板命中) +8. uvicorn … · python -m pytest(**820 绿**);问数答辩:`docs/答辩/DEMO-SOP-问数.md` · 种子 `seed-analyst-metric-dict.sql` + **`seed-analyst-query-templates.sql`** ``` **AL-09 合并后架构(一句话):** 宿主 `gateway/` + 模块 `deps.py` **双栈并存**;对外登录/token **统一**;chat/risk 均走模块鉴权;接缝 S2 用 `auth_adapter`。 @@ -186,7 +187,7 @@ Core 模拟:scripts/core/reset.ps1 · 文档 docs/项目框架设计/Core模 依赖:requirements.txt(LangGraph + langchain-core/openai + FastAPI + SQLAlchemy) 启动:uvicorn app.main:app --reload → GET /health Redis:`docker compose up -d redis` · `REDIS_URL=redis://127.0.0.1:6380/0` · `scripts/dev/start-redis.ps1` -测试:python -m pytest(**804 绿**;集成需本机 MySQL + AML + 风控演示数据) +测试:python -m pytest(**820 绿**;集成需本机 MySQL + AML + 风控演示数据) 前端: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 @@ -237,6 +238,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` **804 绿** · Redis **6380** · uvicorn + `/health` · 问数页模板标签 · 测试包 `docs/memory/tests/2026-09-10-customer-1b-r1/`) +5. 如何验证?(`python -m pytest` **820 绿** · Redis **6380** · 问数 Demo 见 `docs/答辩/DEMO-SOP-问数.md`) 大任务:FRAMEWORK/FLOW 与实现状态不符时先更新 memory 再编码(用户确认跳过除外)。 diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index fc98f42..16fadc7 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -5,7 +5,7 @@ ## 进行中 -**2026-09-10 批次**(`merger` · 基线 **804 pytest** · `npm run build` 绿):客服 Wave3 + analyst D-06/445cfbb + 风控前端三页 + 课程/文档 · **未 commit** +**2026-09-10 批次**(`merger` · 基线 **816 pytest** · `npm run build` 绿):客服 Wave3 + analyst D-06 + **D-09 解读拆分/答辩稳** + 风控前端 · **未 commit** ### 本批次 · 优先收尾(推荐顺序) @@ -50,6 +50,7 @@ **Tonight 已做** +- [x] **TEST-AN-001 问数 RBAC**:修复 sql_guard 缺口 A/B + deny 审计 D · v1.2 测试日志 · **820 pytest** - [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`) @@ -96,7 +97,7 @@ - [x] 四角色 Dashboard · 平台只读 · ChatPanel · 游客试聊 · 行情 · 风控台账+筛选+适当性+AML+模拟交易 · 问数工作台+资产沉淀 - [x] **问数结果标签**:`AnalystQueryPage` 展示 `template_hit` / `cache_hit`(2026-09-10) -- [x] **问数 / 解读拆分(D-09 子集 · 2026-09-11)**:`interpret=false` 默认只出表 · `POST /api/analyst/interpret` · 问数页「分析该数据/解读我的数据」· spec `docs/superpowers/specs/2026-09-11-query-interpret-split-design.md` +- [x] **答辩稳套餐 ①(2026-09-11)**:A1 sql_guard Q17 · A5 流水 clarify 单测 · C1 种子 +2 模板 · D1 分析对话重定向/菜单收 · E1 `docs/答辩/DEMO-SOP-问数.md` · 答辩清单同步 - [ ] **分析对话菜单下线或强引导**(问数页已承载解读)· **看板钻取** — D-12 往后排 - [ ] **Vitest 补测**(可选):`useChatPanel` · `api/analyst.ts` mock @@ -113,7 +114,7 @@ **P1 · 产品语义 / 安全观感** -- [ ] **分析对话菜单下线或强引导**(解读已并入问数页按钮)— 原 D-09 合一项剩余 +- [ ] **分析对话菜单** — **已重定向** `/analytics/chat` → 问数;菜单项已删(2026-09-11 答辩稳) - [ ] **前端角色路由守卫**:`menus.tsx` 允许路径 ↔ URL 不一致时重定向(改 Hash 进别角色工作台仅 UX,后端仍 403) - [ ] **SSE 断流**:半条 assistant 的提示/重试或续发策略(四角色 `ChatPanel` stream) - [ ] **会话侧栏性能**:后端稳定后改为 `status=active` 分页即可,去掉全量扫页 + 双端 close 兜底 diff --git a/docs/memory/tests/2026-09-11-analyst-domain-rbac/README.md b/docs/memory/tests/2026-09-11-analyst-domain-rbac/README.md new file mode 100644 index 0000000..e3decef --- /dev/null +++ b/docs/memory/tests/2026-09-11-analyst-domain-rbac/README.md @@ -0,0 +1,8 @@ +# 问数表域 RBAC 沙盘 · 测试包 + +| 文件 | 说明 | +| --- | --- | +| [TEST-LOG-2026-09-11-AN-001.md](./TEST-LOG-2026-09-11-AN-001.md) | 企业级测试日志(沙盘发现 → 修复 → 回归) | +| `scripts/dev/sandbox_domain_test.py` | 7 角色域内/域外 Live 驱动(可选复跑) | + +修复对应 **TEST-AN-001 §7 缺口 A/B/D**;缺口 C(profile 未进 SCHEMA_PROMPT)仍为潜在项,见日志 §7.3。 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 48cf40a..cfa80e6 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 @@ -10,15 +10,15 @@ | --- | --- | | **测试记录编号** | TEST-2026-09-11-AN-001 | | **缺陷/变更标题** | 问数线表域授权验证;发现 `sql_guard` 硬兜底 3 处缺口 + 阻断查询审计留痕缺口 | -| **文档版本** | v1.1 | +| **文档版本** | v1.3 | | **创建日期** | 2026-09-11 | | **最后更新** | 2026-09-11 | | **关联分支** | `merger` | -| **关联拍板 / TODO** | 数据分析 Agent(D-01~D-12 / N-01/03/07/08) | -| **风险等级** | **HIGH**(存在行级越权兜底缺口,当前被 LLM 软注入掩盖) | -| **缺陷类型** | 安全兜底缺口 ×3 + 审计留痕缺口 ×1(未触发实际数据泄露) | +| **关联拍板 / TODO** | 数据分析 Agent(D-01~D-12 / N-01/03/07/08)· TEST-AN-001 修复 | +| **风险等级** | ~~HIGH~~ → **MEDIUM**(缺口 A/B/D 已代码修复;C 仍为潜在) | +| **缺陷类型** | 安全兜底缺口 ×3 + 审计留痕缺口 ×1 → **已修复 3+1**(C 文档化) | | **发现阶段** | 沙盘验证(真实 DeepSeek + 真实 MySQL)+ `sql_guard` 确定性探针 | -| **修复阶段** | 未修复(仅报告;未改动任何业务代码) | +| **修复阶段** | TDD · `sql_guard` + `analyst_agent._audit_terminal` · **820 pytest 绿** | --- @@ -31,9 +31,9 @@ | **子模块 / 服务** | `analyst_agent` · `sql_guard` · `analyst_auth_adapter` · `analytics_repo` | | **发现人** | Andrew(Claude Code 沙盘) | | **测试执行人** | Andrew(Claude Code 沙盘) | -| **修改人** | —(未修改业务代码,仅新增测试脚本) | +| **修改人** | Andrew(Cursor Agent) | | **评审人** | (待模块负责人确认) | -| **发布建议** | 数据当前安全;建议按 §7 修复 `sql_guard` 兜底后合入 | +| **发布建议** | 建议合入 `merger`;缺口 C 仍须 schema 暴露策略拍板 | --- @@ -46,6 +46,8 @@ | **数据库** | MySQL `jinrong_core` + `jinrong_agent`(已灌演示种子) | | **Redis** | Docker 6380(`CacheService.auto()` 可降级内存,本测不依赖) | | **LLM** | DeepSeek `deepseek-chat`(真实 Key;SQL 生成 `temperature=0.0`) | +| **修复前测试基线** | 816 passed(解读拆分 + 答辩稳 ① 后) | +| **修复后测试基线** | **820 passed**, 1 skipped | | **前端** | 未涉及(仅后端问数线) | --- @@ -137,6 +139,15 @@ **根因**:过滤判断用「全文是否出现 `customer_id` 子串」,而非「WHERE/JOIN-ON 中是否真的存在过滤」。 **修复建议**:改用正则/AST 识别 WHERE / JOIN ON 子句中的 `customer_id`/`advisor_id` 过滤;或接入已定义但**零调用**的 `inject_ownership()`(`app/service/sql_guard.py:178`)做执行前强制改写。 +**修复记录(v1.2 · DEF-AN-2026-09-11-A)** + +| 字段 | 内容 | +| --- | --- | +| **严重等级** | HIGH | +| **修复方案** | 新增 `_has_ownership_filter()`;self/assigned 对 `ROW_SCOPED_TABLES` 要求 WHERE/JOIN 过滤,禁止 SELECT 列名占位 | +| **变更文件** | `app/service/sql_guard.py` · `tests/test_wave6_sql_guard.py`(`test_gap_a_*`) | +| **回归** | PASS | + ### 7.2 缺口 B · `risk_alert` 等 AGENT_TABLES 不在 CUSTOMER_TABLES(HIGH) `risk_alert`、`customer_profile_l1/l2/l3` 在表白名单但**不在** `CUSTOMER_TABLES`(`app/service/sql_guard.py:25-34`),域规则只对 `CUSTOMER_TABLES` 要求归属过滤: @@ -148,7 +159,16 @@ | aggregate | `SELECT customer_id, alert_type FROM jinrong_agent.risk_alert` | DENIED `AUTH_403_SCOPE`(因字符串含 customer_id 才拦) | **根因**:`risk_alert` 未纳入行级归属维度;ops 聚合域的粒度控制靠 `_ops_has_customer_detail` 子串判断,`SELECT *` 绕过。 -**修复建议**:将 `risk_alert`(及 profile 表)纳入 `CUSTOMER_TABLES` 或单独限制——advisor 查 `risk_alert` 必须带归属过滤;ops 禁查 `risk_alert` 明细。 +**修复建议**:将 `risk_alert`(及 profile 表)纳入 `ROW_SCOPED_TABLES` 或单独限制——advisor 查 `risk_alert` 必须带归属过滤;ops 禁查 `risk_alert` 明细。 + +**修复记录(v1.2 · DEF-AN-2026-09-11-B)** + +| 字段 | 内容 | +| --- | --- | +| **严重等级** | HIGH | +| **修复方案** | `ROW_SCOPED_TABLES` 含 `risk_alert` 与 profile 三表;ops/aggregate 禁止 `OPS_FORBIDDEN_DETAIL_TABLES`;assigned 无过滤 deny | +| **变更文件** | `app/service/sql_guard.py` · `tests/test_wave6_sql_guard.py`(`test_gap_b_*`) | +| **回归** | PASS | ### 7.3 缺口 C · profile 表白名单存在但 LLM 不可达(LOW,潜在) @@ -166,9 +186,16 @@ 被 deny 的角色(`STAFF-40001` compliance、`STAFF-31001` risk_manager)在 `audit_log` 中**仅剩一条 `http_access` 且 `decision='200'`**(问数线 deny 是 HTTP 200 + 业务层 `status=deny`,非 403),故越权尝试在 query 级审计中完全不可见。 -**根因**:`app/service/analyst_agent.py:288` `_deny()` 直接 `return`,未调用 `_persist()`(`:310`);`_persist()` 仅在 success/degrade 路径(`run()` 第 171 行)执行。 -**影响**:越权/阻断类行为无法事后审计追责,合规与安全运营盲区。 -**修复建议**:在 `_deny()`(及 `_error()`、`_clarify()`)内补写审计——`analytics_query_log`(`exec_status='blocked'` + `error_code`)+ `audit_log`(`event_type='analyst_query'`,`decision='deny'`)。 +**根因**:`app/service/analyst_agent.py` `_deny()` 直接 `return`,未调用留痕(修复前 `_persist()` 仅在 success 路径)。 + +**修复记录(v1.2 · DEF-AN-2026-09-11-D)** + +| 字段 | 内容 | +| --- | --- | +| **严重等级** | MEDIUM | +| **修复方案** | 新增 `_audit_terminal()`;deny/clarify/error/SqlGuard 阻断均写 `analytics_query_log.exec_status='blocked'` + `audit_log.decision` | +| **变更文件** | `app/service/analyst_agent.py` · `tests/test_wave6_analyst_agent.py`(`test_deny_bad_sql` 断言 blocked 留痕) | +| **回归** | PASS · 建议人工再查库 `decision='deny'` | --- @@ -189,8 +216,9 @@ | **域外阻断** | 6/6 PASS;NOT_ASSIGNED / SCOPE / ROLE / 401 均正确返回 | | **customer 越权试探** | 5/5 安全;LLM 恒注入 `WHERE customer_id='CUST-9527'` | | **是否发现实际数据泄露** | **否**(当前 LLM 软注入可靠) | -| **剩余风险** | ① 缺口 A:LLM 一旦漏注入 WHERE,`SELECT customer_id,... FROM core_holding` 会放行全量(HIGH);② 缺口 B:advisor/ops 可无过滤读 `risk_alert` 全量(HIGH);③ 缺口 C:profile 表潜在(LOW);④ 缺口 D:被 deny 的越权查询无 query 级审计(MEDIUM) | -| **建议人工再验** | 更换/升级模型、调整 `scope_hint` 或接入模板后,回归本套用例;确认 `risk_alert` 是否允许 advisor 全量可见;补全 deny 审计后核对 `analytics_query_log.exec_status='blocked'` | +| **剩余风险** | ① ~~缺口 A/B/D~~ **已修复**;② 缺口 C:profile 表白名单存在但 LLM schema 未暴露(LOW,潜在);③ LLM 软注入仍为第一道防线,硬兜底为第二道 | +| **建议人工再验** | 复跑 `sandbox_domain_test.py`;库内 `analytics_query_log` 出现 `exec_status='blocked'`;更换模型后回归 §6.3 | +| **是否可发布** | 后端可合并(820 pytest) | --- @@ -199,7 +227,8 @@ | 角色 | 姓名 | 日期 | 意见 | | --- | --- | --- | --- | | 模块负责人 | zhangyong | | ☐ 通过 ☐ 待改 | -| 发现人 / 测试 | Andrew | 2026-09-11 | 沙盘验证完成,见 §7 修复建议 | +| 发现人 / 测试 | Andrew | 2026-09-11 | 沙盘 + 自动化回归 | +| 修改人 | Andrew | 2026-09-11 | §7 A/B/D 已修 · 820 pytest | --- @@ -209,3 +238,35 @@ | --- | --- | --- | --- | | v1.0 | 2026-09-11 | Andrew | 首版:7 角色 × 域内/域外沙盘验证 + sql_guard 3 处缺口 | | v1.1 | 2026-09-11 | Andrew | 新增缺口 D:被 deny 的查询无 query 级审计留痕(实测核对 `analytics_query_log` / `audit_log`) | +| v1.2 | 2026-09-11 | Andrew | 修复 A/B/D · 单测探针 · **820 pytest** · 变更清单 §12 | +| v1.3 | 2026-09-11 | Andrew | 沙盘复跑(`interpret=True`)10 PASS / 0 WARN / 1 FAIL(1 FAIL 为 customer 越权试探,实测安全);MySQL 留痕核验 deny → blocked 10 / deny 10 · §13.4 | + +--- + +## 12. 变更清单(v1.2 修复) + +| 类型 | 路径 | 说明 | +| --- | --- | --- | +| 代码 | `app/service/sql_guard.py` | 缺口 A/B · `_has_ownership_filter` · `ROW_SCOPED_TABLES` | +| 代码 | `app/service/analyst_agent.py` | 缺口 D · `_audit_terminal` | +| 单测 | `tests/test_wave6_sql_guard.py` | +4 探针 | +| 单测 | `tests/test_wave6_analyst_agent.py` | deny 留痕断言 | +| 文档 | 本 TEST-LOG · `README.md` | 修复记录 | + +--- + +## 13. 测试执行记录(修复回归) + +| 序号 | 类型 | 用例 / 命令 | 执行时间 | 执行人 | 结果 | 证据 | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | 单元 | `test_gap_a_*` · `test_gap_b_*` | 2026-09-11 | Andrew | **PASS** | sql_guard 探针 | +| 2 | 单元 | `test_deny_bad_sql` blocked 留痕 | 2026-09-11 | Andrew | **PASS** | FakeRepo.logged | +| 3 | 全量 | `python -m pytest -q` | 2026-09-11 | Andrew | **PASS** | **820 passed, 1 skipped** | +| 4 | 沙盘 | `scripts/dev/sandbox_domain_test.py`(`interpret=True` 单相渲染) | 2026-09-11 | Andrew | **10 PASS / 0 WARN / 1 FAIL** | Live DeepSeek;1 FAIL 为 customer 越权试探,实测安全(见下注) | + +> **§13.4 复跑结论(Live DeepSeek + 真实 MySQL)** +> +> - 确定性 `sql_guard` 探针 4/4 **DENIED**(缺口 A/B 修复生效):`risk_alert 全量(assigned)`、`customer_profile_l3 全量(assigned)`、`core_holding 全量无过滤(assigned)`、`risk_alert 全量(aggregate/ops)`。 +> - 域内 5/5 PASS(customer/advisor/analyst/risk/ops 均 success 且 LLM 渲染非空);域外 4/4 PASS(NOT_ASSIGNED / SCOPE / ROLE / ROLE);无 token 401 PASS。 +> - **1 FAIL = customer「查 CUST-1001 的持仓」**:`judge_deny` 期望 `AUTH_403_NOT_OWNER`,实测返回 `status=success`,SQL 为 `... FROM core_holding WHERE customer_id = 'CUST-9527'`,解读明确披露「查询实际使用的是 CUST-9527,而非 CUST-1001」,row_count=3(本人持仓)。**无跨客户泄露**,与 §6.3「安全」结论一致——属脚本判定口径偏严,非缺陷。 +> - **MySQL 留痕核验(deny → 审计)**:`analytics_query_log.exec_status` = success 53 / **blocked 10**;`audit_log(event_type='analyst_query').decision` = success 28 / degrade 6 / **deny 10**。blocked 与 deny 一一对应(5 个 deny 用例 × 2 次复跑 = 10),缺口 D 修复生效。 diff --git a/docs/superpowers/specs/2026-09-11-defense-stable-package.md b/docs/superpowers/specs/2026-09-11-defense-stable-package.md new file mode 100644 index 0000000..fa5eaf7 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-defense-stable-package.md @@ -0,0 +1,23 @@ +# 答辩稳优化套餐 ①(2026-09-11) + +> 来源:数据分析 Agent 优化清单 · 用户选用 **套餐 ①** · 在 **问数/解读拆分** 基础上落地。 + +## 已做 + +| ID | 内容 | 落点 | +| --- | --- | --- | +| **(前置)** | 问数 `interpret=false` + `/interpret` + 各角色解读按钮 | spec `2026-09-11-query-interpret-split-design.md` | +| **A1** | Q17:`create` 词边界,允许 `created_at` | `sql_guard.py` · `test_wave6_sql_guard.py` | +| **A5** | 流水多义 clarify 单测 | `test_clarify_trade_flow_ambiguity` | +| **C1** | +2 演示模板 | `seed-analyst-query-templates.sql`:`total_holding_aum` · `customer_self_trade_count_recent` | +| **D1** | 分析对话收入口 | 菜单删项 · `/analytics/chat` → `/analytics/query` · 分析员首页快捷链 | +| **E1** | Demo SOP + memory 同步 | `docs/答辩/DEMO-SOP-问数.md` · MEMORY/TODO/FRAMEWORK/FLOW/答辩清单 | + +## 仍须本机(E1 后半) + +- [ ] 灌种子 + 跑 **20 题 battery** → `battery_report.json`(不入库) +- [ ] 答辩前 DeepSeek + Redis 6380 自检 + +## 未纳入 ①(往后排) + +- A2 Q7 · A4 guardrail 回归 · B 组规格 P0 · C3 PII 缓存 diff --git a/docs/答辩/DEMO-SOP-问数.md b/docs/答辩/DEMO-SOP-问数.md new file mode 100644 index 0000000..b3bc8d8 --- /dev/null +++ b/docs/答辩/DEMO-SOP-问数.md @@ -0,0 +1,52 @@ +# 答辩 Demo · 问数工作台 SOP + +> **套餐 ① 答辩稳**(2026-09-11)· 基线 **813 pytest** · 问数/解读拆分已落地 + +## 1. 环境(答辩前 30 分钟) + +```powershell +cd "d:\项目\JinRong" +docker compose up -d redis # 或 .\scripts\dev\start-redis.ps1 +# .env: REDIS_URL=redis://127.0.0.1:6380/0 · DEEPSEEK_API_KEY 已填 + +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 + +python -m uvicorn app.main:app --reload --port 8000 +cd web && npm run dev +``` + +**无 DeepSeek Key:** 问数仍可出 **模板 SQL + 表**;点「分析该数据」解读会降级或失败——答辩建议带 Key。 + +## 2. 推荐口述动线(3~5 分钟 · 分析员账号) + +| 步骤 | 操作 | 答辩句 | +| --- | --- | --- | +| 1 | 打开 **问数工作台** | 「查数和解读拆开:先 NL2SQL 可审计,再按需看图说话」 | +| 2 | 问:**客户总数是多少** | 指 **蓝色「模板命中 · customer_total_count」** → D-06 模板跳过 LLM 写 SQL | +| 3 | 再问同一题 | 指 **绿色「结果缓存」** → 权限指纹 + SQL + 表世代 | +| 4 | 点 **分析该数据** | 「上下文只有本轮问题和表格,不接 Chat 历史;数字走 D-10 护栏」 | +| 5 | 问:**近30天申购金额**(或含「近30天」「申购」「金额」) | 模板 `subscribe_amount_recent_days` | +| 6 | 问:**近30天交易流水是多少** | **clarify** → N-01 多义,不猜口径 | +| 7 | 可选 deny | 理财师问非名下客户 → 权限与问数 **同一 answer**,解读按钮不调 LLM | + +## 3. 客户角色(平台服务 · 数据分析) + +- 登录 **客户** Demo → **平台服务 · 数据分析** +- 问:**我近30天有多少笔交易** → 模板 `customer_self_trade_count_recent`(需种子) +- 按钮文案:**解读我的数据** · 尾注含「AI 分析有风险」 + +## 4. 20 题 battery(答辩后自测,报告不入库) + +```powershell +python scripts/dev/run_query_battery.py +# 本地生成 scripts/dev/battery_report.json(.gitignore) +``` + +验收:**A1 Q17**(`created_at` 列不误杀)· **A5** 流水 clarify · 模板题稳定命中。 + +## 5. 诚实边界(主动一句) + +- **D-09 多轮追问**、**D-12 看板钻取**、**N-03 溯源** 未做 +- `/app/analytics/chat` **重定向**到问数页,避免双入口 +- 分析占位 **agent_service** 仍存在于 API,菜单已收 diff --git a/docs/答辩/答辩知识点清单.md b/docs/答辩/答辩知识点清单.md index 9d71efb..3847c9d 100644 --- a/docs/答辩/答辩知识点清单.md +++ b/docs/答辩/答辩知识点清单.md @@ -1,6 +1,6 @@ # 金融四 Agent 答辩知识点清单 -> 用途:按模块讲清**数据怎么流、怎么跑、为什么这样选、亮点在哪**;答辩时可当提纲。基线:**804 pytest** · **19 Vitest** · 分支 **`merger`**。 +> 用途:按模块讲清**数据怎么流、怎么跑、为什么这样选、亮点在哪**;答辩时可当提纲。基线:**816 pytest** · **22 Vitest** · 分支 **`merger`**。 --- @@ -10,7 +10,7 @@ | --- | --- | | **业务定位** | 代销场景四角色(客户 / 理财师 / 分析 / 风控)共用 **L0 事实 + 合规底座**;**四 Agent 不互调 LLM**,跨线靠画像表、预警表、平台 API | | **分层** | 浏览器 → FastAPI(`api/`)→ 编排(`service/`)→ 工具/仓储(`tool/` · `repository/`)→ MySQL 双库 / Redis / Milvus / Neo4j / Core 模拟 | -| **两条对话主线** | ① **通用 Chat**:`POST /api/chat` 或 `/stream` + `X-Agent-Type` → `agent_service`(advisor/risk/分析占位)② **客服专用**:`customer` → `customer_service` 14 节点 ③ **问数独立**:`POST /api/analyst/chat` → `analyst_agent`(非同一 StateGraph) | +| **两条对话主线** | ① **通用 Chat**:`POST /api/chat` 或 `/stream` + `X-Agent-Type` → `agent_service`(advisor/risk)② **客服专用**:`customer` → `customer_service` 14 节点 ③ **问数独立**:`POST /api/analyst/chat` + **`POST /api/analyst/interpret` 按需解读**(非 Chat 历史) | | **可审计** | `trace_id` / `request_id` 贯通 · `audit_log` / `http_access` / `agent_tool_call` / `analytics_query_log` **只 INSERT** | **铁律(必背):** Core 正式 C1~C5 画像不可覆盖 · 仅 **R-02 适当性** 可阻断交易 · 风控 **不自动冻户** · 审计 **只增不改**。 @@ -71,14 +71,14 @@ | 项 | 内容 | | --- | --- | -| **入口** | `POST /api/analyst/chat`(**无** `X-Agent-Type`)· `GET /dashboard` · `POST /assets` | -| **编排** | `analyst_agent.run()`:**消歧 → 模板或 LLM 生成 SQL → 五层 sql_guard → 执行 → 解读 → 数字护栏 → 留痕** | +| **入口** | `POST /api/analyst/chat`(默认仅表)· **`POST /api/analyst/interpret`** · `GET /dashboard` · `POST /assets` | +| **编排** | `analyst_agent.run(interpret=false)`:**消歧 → 模板或 LLM SQL → 五层 sql_guard → 执行 → 留痕**;解读 **`interpret()`** → D-10 护栏 | | **数据流** | 只读 `jinrong_core` + 白名单 agent 表(如 `risk_alert`)→ `analytics_query_log` + `audit_log` | | **D-06 缓存** | ① **结果缓存**:权限指纹 + SQL + **表世代**(写交易/预警/L3 后 bump,不等 TTL)② **模板缓存**:published 模板 **填参**,跳过 LLM 写 SQL(`template_hit`) | | **D-10 护栏** | 解读数字与结果 **逐字比对** · 失败重试 1 次 → **degrade 只出表** | | **N-01** | 「流水」等多义 → **clarify**,不猜口径 | -| **聪明做法** | 问数与 **Chat 分离**(分析对话页仍是轻量 LLM,真 NL2SQL 在问数工作台)· 客户域 **self** + 固定 AI 风险尾注 | -| **诚实缺口** | D-09 多轮追问 · N-03 溯源 API · N-07 转人工 — 未做 | +| **聪明做法** | 问数与 Chat **分离** · 各角色问数页 **「分析该数据 / 解读我的数据」** 仅带本轮快照 · 客户 **self** 域 + AI 风险尾注 · **Q17** 列名 `created_at` 不误杀 `create` | +| **诚实缺口** | D-09 **多轮** · N-03 溯源 · N-07 转人工 — 未做 · **分析对话菜单已收**(URL 重定向问数) | ### 4.4 风控 Agent @@ -105,8 +105,8 @@ | --- | --- | | **运行** | React 19 + Vite 8 · HashRouter · Demo 账号登录 → JWT → 四角色菜单 | | **数据流** | `apiFetch` 统一 Bearer · Dashboard hooks 聚合 Core+净值 · ChatPanel SSE/sync | -| **聪明做法** | **`useAsyncSequence`** 防 StrictMode 双请求竞态(先闪错再刷新的问题)· 问数页展示 **模板命中 / 结果缓存** 标签 | -| **诚实** | 分析对话 **占位** · 看板 **无钻取** | +| **聪明做法** | **`useAsyncSequence`** 防 StrictMode 双请求 · 问数页 **模板/缓存/未命中** 四 Tag · **解读按钮** 分角色文案 | +| **诚实** | 看板 **无钻取** · 进页仍全量拉平台 API(见 MEMORY Redis≠Dashboard) | --- @@ -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 核对 | -| **测试** | **804** 后端 · Wave 分模块 · 风控/问数/客服 单测+集成 · 前端 19 Vitest | +| **测试** | **816** 后端 · Wave 分模块 · 问数 interpret 拆分 + sql_guard Q17 · 前端 **22** Vitest | | **文档** | `docs/memory/*` Agent 交接 · `docs/course/` 交互深潜课 · 演示 SOP | | **问数 battery** | `run_query_battery.py` 本地跑分 · **报告不入库** | | **输入与输出双护栏** | 输入:注入/限流 · 输出:客服 sanitize + 问数 guardrail | @@ -146,8 +146,9 @@ 1. **登录** → 四角色之一 Dashboard(平台只读数据从哪来) 2. **客户** → 持仓/Chat SSE 或阈值提醒(Core + 1B) 3. **风控** → 模拟交易 A-1 → 台账 pending → 处置(事件线 + 人工闭环) -4. **分析** → 问数「客户总数」「近7天申购金额」→ 看 **模板命中** + SQL 折叠(D-06 + 护栏) -5. **可选** → 风控 Chat 只读查预警(强调 **不能** 让 Agent 改状态) +4. **分析** → 问数「客户总数」→ 模板 + 缓存 Tag → **分析该数据** → 解读卡片(D-06 + D-10 + interpret 拆分) +5. **可选** → 问「交易流水」→ **clarify**(N-01) +6. **可选** → 风控 Chat 只读查预警(强调 **不能** 让 Agent 改状态) --- @@ -156,7 +157,7 @@ | 项 | 说法 | | --- | --- | | 无真实 Core | L0 为 **模拟库**,生产接托管 Core 只读账号 | -| 分析对话 vs 问数 | 多轮 NL2SQL **未合并**;规格 D-09 待做 | +| 分析对话 vs 问数 | **已拆分**:默认只出表 · 按钮调 `/interpret` · 旧 **分析对话** 路由重定向问数 · D-09 **多轮**仍 open | | 客服 L1/L2 Redis | 热读方案已定,**风控不做 L1/L2** | | C-04 push | 仅持仓查询 **内联提醒**,无定时 push | | 行情 Phase B | v0.2 草案,未接 sync | @@ -192,6 +193,7 @@ | 前端验收 | `docs/frontend/FRONTEND-HANDOFF.md` | | 交互课导览 | `docs/course/index.html` | | 答辩滚动课(浏览器) | `docs/course/jinrong-overview/index.html` 模块 8 | +| 问数 Demo SOP | `docs/答辩/DEMO-SOP-问数.md` | | 问数 D-06 深潜 | `docs/course/jinrong-module-analyst/index.html` 模块 5 | --- diff --git a/scripts/agent/seed-analyst-query-templates.sql b/scripts/agent/seed-analyst-query-templates.sql index dcddbc7..5630670 100644 --- a/scripts/agent/seed-analyst-query-templates.sql +++ b/scripts/agent/seed-analyst-query-templates.sql @@ -48,4 +48,39 @@ VALUES 1, 'SEED', 'SEED' +), +( + 'total_holding_aum', + 'SELECT COALESCE(SUM(market_value), 0) AS total_aum FROM core_holding', + JSON_OBJECT('match_all', JSON_ARRAY('持仓', '规模')), + JSON_ARRAY('持仓', '规模', 'AUM'), + 'published', + 1, + 'SEED', + 'SEED' +), +( + 'customer_self_trade_count_recent', + 'SELECT COUNT(*) AS trade_count FROM core_trade WHERE customer_id = '':customer_id'' AND trade_date >= DATE_SUB(CURDATE(), INTERVAL :days DAY)', + JSON_OBJECT( + 'match_all', JSON_ARRAY('交易', '笔'), + 'params', JSON_ARRAY( + JSON_OBJECT( + 'name', 'customer_id', + 'placeholder', ':customer_id', + 'extract', 'scope_customer' + ), + JSON_OBJECT( + 'name', 'days', + 'placeholder', ':days', + 'extract', 'recent_days', + 'default', 30 + ) + ) + ), + JSON_ARRAY('客户', '交易', '笔数', 'self'), + 'published', + 1, + 'SEED', + 'SEED' ); diff --git a/scripts/dev/sandbox_domain_test.py b/scripts/dev/sandbox_domain_test.py index b2e54e3..20f8010 100644 --- a/scripts/dev/sandbox_domain_test.py +++ b/scripts/dev/sandbox_domain_test.py @@ -40,7 +40,11 @@ def token(sub: str, roles: str, *, token_type: str = "staff", customer_id: str | def ask(tok: str | None, question: str) -> tuple[int, dict]: headers = {"Authorization": f"Bearer {tok}"} if tok else {} try: - r = client.post("/api/analyst/chat", headers=headers, json={"question": question}) + r = client.post( + "/api/analyst/chat", + headers=headers, + json={"question": question, "interpret": True}, + ) try: body = r.json() except Exception: diff --git a/tests/test_wave6_analyst_agent.py b/tests/test_wave6_analyst_agent.py index 46b6706..6a85948 100644 --- a/tests/test_wave6_analyst_agent.py +++ b/tests/test_wave6_analyst_agent.py @@ -59,6 +59,12 @@ def ctx(roles, subject="STAFF-A", *, token_type="staff", customer_id=None): class TestAgentOrchestration(unittest.TestCase): + def test_clarify_trade_flow_ambiguity(self): + agent = AnalystAgent(llm=FakeLLM("SELECT 1", []), repo=FakeRepo()) + resp = agent.run("近30天交易流水是多少", ctx(["analyst"])) + self.assertEqual(resp.status, "clarify") + self.assertIn("流水", resp.answer) + def test_clarify_ambiguity(self): agent = AnalystAgent(llm=FakeLLM("SELECT 1", []), repo=FakeRepo()) resp = agent.run("我名下的规模是多少", ctx(["analyst"])) @@ -122,10 +128,13 @@ class TestAgentOrchestration(unittest.TestCase): self.assertEqual(llm.calls, 0) def test_deny_bad_sql(self): - agent = AnalystAgent(llm=FakeLLM("INSERT INTO core_customer VALUES (1)", []), repo=FakeRepo()) + repo = FakeRepo() + agent = AnalystAgent(llm=FakeLLM("INSERT INTO core_customer VALUES (1)", []), repo=repo) resp = agent.run("删库", ctx(["analyst"])) self.assertEqual(resp.status, "deny") self.assertEqual(resp.error_code, "SQL_NOT_SELECT") + self.assertEqual(len(repo.logged), 1) + self.assertEqual(repo.logged[0].get("exec_status"), "blocked") def test_degrade_wrong_number(self): repo = FakeRepo(rows=[[33]], columns=["c"]) diff --git a/tests/test_wave6_sql_guard.py b/tests/test_wave6_sql_guard.py index b2e7463..a2c3cbb 100644 --- a/tests/test_wave6_sql_guard.py +++ b/tests/test_wave6_sql_guard.py @@ -44,6 +44,47 @@ class TestSqlGuard(unittest.TestCase): r = validate("SELECT COUNT(DISTINCT customer_id) AS cnt FROM core_holding", "aggregate") self.assertTrue(r.allowed) + def test_created_at_in_select_allowed(self): + """Q17:列名 created_at 不应触发 create 关键字误杀。""" + r = validate( + "SELECT alert_id, created_at FROM jinrong_agent.risk_alert WHERE status='pending_review'", + "full", + ) + self.assertTrue(r.allowed) + + def test_gap_a_select_customer_id_column_without_where_denied_self(self): + with self.assertRaises(SqlGuardError) as cm: + validate( + "SELECT customer_id, product_id, market_value FROM core_holding", + "self", + ["CUST-9527"], + ) + self.assertEqual(cm.exception.error_code, "AUTH_403_SCOPE") + + def test_gap_a_select_customer_id_column_without_where_denied_assigned(self): + with self.assertRaises(SqlGuardError) as cm: + validate( + "SELECT customer_id, product_id, market_value FROM core_holding", + "assigned", + ["CUST-9527"], + ) + self.assertEqual(cm.exception.error_code, "AUTH_403_SCOPE") + + def test_gap_b_ops_risk_alert_denied(self): + with self.assertRaises(SqlGuardError) as cm: + validate("SELECT * FROM jinrong_agent.risk_alert", "aggregate") + self.assertEqual(cm.exception.error_code, "AUTH_403_SCOPE") + + def test_gap_b_advisor_risk_alert_without_filter_denied(self): + with self.assertRaises(SqlGuardError) as cm: + validate("SELECT * FROM jinrong_agent.risk_alert", "assigned", ["CUST-9527"]) + self.assertEqual(cm.exception.error_code, "AUTH_403_SCOPE") + + def test_create_table_still_rejected(self): + with self.assertRaises(SqlGuardError) as cm: + validate("CREATE TABLE evil (id INT)", "full") + self.assertIn(cm.exception.error_code, ("SQL_FORBIDDEN", "SQL_NOT_SELECT")) + def test_inject_ownership(self): out = inject_ownership("SELECT * FROM core_holding", ["CUST-1", "CUST-2"]) self.assertIn("CUST-1", out) diff --git a/web/src/App.tsx b/web/src/App.tsx index 9061646..06c2da4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,15 +33,6 @@ function AdvisorChatShell() { ) } -function AnalystChatShell() { - const auth = useAppAuth() - return ( - - - - ) -} - function LoginRoute() { const { auth } = useAuth() if (auth) { @@ -102,15 +93,7 @@ export default function AppRoutes() { } /> } /> } /> - - - - - } - /> + } /> } /> } /> diff --git a/web/src/pages/dashboard/AnalystMarketDashboard.tsx b/web/src/pages/dashboard/AnalystMarketDashboard.tsx index e03f46f..06d5b5f 100644 --- a/web/src/pages/dashboard/AnalystMarketDashboard.tsx +++ b/web/src/pages/dashboard/AnalystMarketDashboard.tsx @@ -57,7 +57,6 @@ export function AnalystMarketDashboard() { } quickActions={[ { label: '问数工作台', to: '/app/analytics/query' }, - { label: '分析 Chat', to: '/app/analytics/chat' }, { label: '产品行情', to: '/app/market' }, ]} pieData={pieData} diff --git a/web/src/routes/menus.tsx b/web/src/routes/menus.tsx index 7ab2030..b48d975 100644 --- a/web/src/routes/menus.tsx +++ b/web/src/routes/menus.tsx @@ -83,7 +83,6 @@ export function buildMenuGroups(roleLabel: string): RouteMenuGroup[] { { key: '/app/analyst/home', icon: , label: '首页', path: '/app/analyst/home' }, { key: '/app/analytics/query', icon: , label: '问数工作台', path: '/app/analytics/query' }, { key: '/app/analytics/assets', icon: , label: '资产沉淀', path: '/app/analytics/assets' }, - { key: '/app/analytics/chat', icon: , label: '分析对话', path: '/app/analytics/chat' }, ], }, {