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' }, ], }, {