From aea97a243ccc213cfbe6735f4d3cbbfedd651d06 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 11 Sep 2026 12:15:18 +0800 Subject: [PATCH] feat(analyst): Introduce interpret functionality and enhance chat API - Added `interpret` flag to `AnalystChatRequest` for optional immediate interpretation of queries. - Implemented new `POST /api/analyst/interpret` endpoint for on-demand data interpretation based on the latest query snapshot. - Updated `AnalystAgent` to handle interpretation logic, including error handling and response formatting. - Enhanced `AnalystQueryPage` to include a button for triggering interpretations, improving user interaction. - Updated frontend API calls to support the new interpret functionality, ensuring seamless integration with existing workflows. This update significantly enhances the analytical capabilities of the application, allowing users to request interpretations of their queries directly. --- app/api/analyst.py | 20 +- app/model/analyst_schemas.py | 16 ++ app/service/analyst_agent.py | 86 +++++-- docs/memory/ITERATION.md | 2 +- docs/memory/MEMORY.md | 9 +- docs/memory/REQUIREMENTS.md | 2 +- docs/memory/TODO.md | 5 +- .../TEST-LOG-2026-09-11-AN-001.md | 211 ++++++++++++++++++ .../plans/2026-09-11-query-interpret-split.md | 33 +++ ...2026-09-11-query-interpret-split-design.md | 43 ++++ scripts/dev/sandbox_domain_test.py | 169 ++++++++++++++ tests/test_main.py | 5 +- tests/test_wave6_analyst_agent.py | 53 ++++- web/src/api/analyst.ts | 40 +++- web/src/pages/analytics/AnalystQueryPage.tsx | 63 +++++- 15 files changed, 715 insertions(+), 42 deletions(-) create mode 100644 docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md create mode 100644 docs/superpowers/plans/2026-09-11-query-interpret-split.md create mode 100644 docs/superpowers/specs/2026-09-11-query-interpret-split-design.md create mode 100644 scripts/dev/sandbox_domain_test.py diff --git a/app/api/analyst.py b/app/api/analyst.py index b6b5404..2cd8100 100644 --- a/app/api/analyst.py +++ b/app/api/analyst.py @@ -10,7 +10,7 @@ from app.api.analyst_auth_adapter import ( assert_analyst_query_access, ) from app.api.deps import AuthContext, get_platform_auth_context -from app.model.analyst_schemas import AnalystResponse, AssetCreateRequest, ChatRequest +from app.model.analyst_schemas import AnalystResponse, AssetCreateRequest, ChatRequest, InterpretRequest from app.service.analyst_agent import AnalystAgent from app.utils.trace import current_trace @@ -36,7 +36,23 @@ def chat( auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ) -> AnalystResponse: - return agent.run(req.question, auth, req.session_id, req.trace_id) + return agent.run( + req.question, + auth, + req.session_id, + req.trace_id, + interpret=req.interpret, + ) + + +@router.post("/interpret", response_model=AnalystResponse) +def interpret( + req: InterpretRequest, + auth: AnalystAuthContext = Depends(_analyst_ctx), + agent: AnalystAgent = Depends(get_agent), +) -> AnalystResponse: + """按需解读:仅接受最近一次问数快照 + 原问题(看图说话)。""" + return agent.interpret(req, auth) @router.get("/dashboard") diff --git a/app/model/analyst_schemas.py b/app/model/analyst_schemas.py index 9d7945d..414cda8 100644 --- a/app/model/analyst_schemas.py +++ b/app/model/analyst_schemas.py @@ -15,6 +15,10 @@ class ChatRequest(BaseModel): question: str = Field(..., description="自然语言问题") session_id: str | None = None trace_id: str | None = None + interpret: bool = Field( + default=False, + description="true=问数后立即 LLM 解读;false=仅 SQL+表格(解读走 /interpret)", + ) class TableData(BaseModel): @@ -33,6 +37,18 @@ class Meta(BaseModel): cost_est: float = 0.0 +class InterpretRequest(BaseModel): + """按需解读:上下文仅本轮问数快照(看图说话)。""" + + question: str + status: str + answer: str = "" + table: TableData = Field(default_factory=TableData) + sql: str = "" + trace_id: str | None = None + meta: Meta = Field(default_factory=Meta) + + class AnalystResponse(BaseModel): """统一输出四件套 + 状态/错误信息。""" diff --git a/app/service/analyst_agent.py b/app/service/analyst_agent.py index 5f86291..eb04e6c 100644 --- a/app/service/analyst_agent.py +++ b/app/service/analyst_agent.py @@ -14,6 +14,7 @@ from app.model.analyst_schemas import ( CUSTOMER_AI_RISK_NOTE, DISCLAIMER, AnalystResponse, + InterpretRequest, Meta, TableData, ) @@ -72,6 +73,8 @@ class AnalystAgent: auth: AnalystAuthContext, session_id: str | None = None, trace_id: str | None = None, + *, + interpret: bool = False, ) -> AnalystResponse: trace_id = trace_id or f"trace-{uuid.uuid4().hex[:16]}" session_id = session_id or f"sess-{uuid.uuid4().hex[:12]}" @@ -130,23 +133,25 @@ class AnalystAgent: table = TableData(columns=exec_result["columns"], rows=exec_result["rows"]) empty_state = classify_empty(exec_result["rows"], sql_text) - # 5) 解读 + 数字护栏(D-10) - try: - answer, guard_result, g_usage = self._generate_verified_answer( - question, sql_text, table, empty_state, domain=domain - ) - cost_est += estimate_cost(g_usage) - except Exception as exc: # noqa: BLE001 - return self._error(f"解读生成失败:{exc}", trace_id) + guard_result: GuardrailResult | None = None + answer = "" + status = "success" + if interpret: + try: + answer, guard_result, g_usage = self._generate_verified_answer( + question, sql_text, table, empty_state, domain=domain + ) + cost_est += estimate_cost(g_usage) + except Exception as exc: # noqa: BLE001 + return self._error(f"解读生成失败:{exc}", trace_id) + status = "degrade" if (guard_result is not None and not guard_result.passed) else "success" + if status == "degrade": + answer = "解读校验未通过,请以下方表格数据为准。" - # 6) 组装输出(护栏不通过 → 降级:只给表格,符合 D-10) - status = "degrade" if (guard_result is not None and not guard_result.passed) else "success" - if status == "degrade": - answer = "解读校验未通过,请以下方表格数据为准。" disclaimer = DISCLAIMER if domain == "self": disclaimer = f"{DISCLAIMER} {CUSTOMER_AI_RISK_NOTE}" - if CUSTOMER_AI_RISK_NOTE not in answer: + if interpret and answer and CUSTOMER_AI_RISK_NOTE not in answer: answer = f"{answer.rstrip()} {CUSTOMER_AI_RISK_NOTE}" resp = AnalystResponse( answer=answer, @@ -183,6 +188,61 @@ class AnalystAgent: ) return resp + def interpret( + self, + req: InterpretRequest, + auth: AnalystAuthContext, + ) -> AnalystResponse: + """仅解读:上下文为客户端提交的上一次问数快照(无 Chat 历史)。""" + trace_id = req.trace_id or f"trace-{uuid.uuid4().hex[:16]}" + auth.trace_id = trace_id + try: + domain = assert_analyst_query_access(auth) + except AnalystAuthError as exc: + return self._deny(exc.error_code, exc.message, trace_id) + + terminal = {"clarify", "deny", "error", "escalate"} + if req.status in terminal: + return AnalystResponse( + answer=req.answer, + table=req.table, + sql=req.sql, + meta=req.meta, + disclaimer=DISCLAIMER, + status=req.status, + trace_id=trace_id, + ) + + table = req.table + empty_state = classify_empty(table.rows, req.sql) + cost_est = float(req.meta.cost_est or 0.0) + try: + answer, guard_result, g_usage = self._generate_verified_answer( + req.question, req.sql, table, empty_state, domain=domain + ) + cost_est += estimate_cost(g_usage) + except Exception as exc: # noqa: BLE001 + return self._error(f"解读生成失败:{exc}", trace_id) + + status = "degrade" if (guard_result is not None and not guard_result.passed) else "success" + if status == "degrade": + answer = "解读校验未通过,请以下方表格数据为准。" + disclaimer = DISCLAIMER + if domain == "self": + disclaimer = f"{DISCLAIMER} {CUSTOMER_AI_RISK_NOTE}" + if answer and CUSTOMER_AI_RISK_NOTE not in answer: + answer = f"{answer.rstrip()} {CUSTOMER_AI_RISK_NOTE}" + meta = req.meta.model_copy(update={"cost_est": round(cost_est, 6)}) + return AnalystResponse( + answer=answer, + table=table, + sql=req.sql, + meta=meta, + disclaimer=disclaimer, + status=status, + trace_id=trace_id, + ) + # ---------- 各步骤 ---------- def _detect_ambiguity(self, question: str) -> Ambiguity | None: terms = self._metric_terms(question) diff --git a/docs/memory/ITERATION.md b/docs/memory/ITERATION.md index 4259305..7d7c497 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-10 | **产品口径落账**:Redis≠Dashboard 缓存 · 四角色 home 有图无 BI · 客户趋势=问数 self 域非 Chat 曲线(§9.6) | 用户问答澄清 | MEMORY §0/§3 · TODO §2026-09-11 · FRONTEND-HANDOFF §10 | +| 2026-09-11 | **D-09 子集 · 问数/解读拆分**:`interpret=false` 默认 · `POST /api/analyst/interpret` · 问数页按钮 · MEMORY/TODO/REQUIREMENTS · **813 pytest** | 用户:合并进 memory + 各角色「分析该数据」 | analyst_agent / analyst.py / AnalystQueryPage | | 2026-09-10 | **TODO 日终清单 + 优化 TODO 补全**:硬伤盘点落账 · 2026-09-10 已完成/进行中/将要做 | 用户要求 | TODO · ITERATION | diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index ce55b7c..eef7d63 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 + 前端四角色 P0 Demo** · **804 pytest** · **22 Vitest** · **Redis @ 6380** · **`merger` 工作区未 commit** +**当前进度:** 需求与表设计已定 · **风控 + 平台 API + 客服 S2 Wave3 + 数据分析 S3/P2/D-06 + D-09 问数/解读拆分 + 前端四角色 P0 Demo** · **813 pytest** · **22 Vitest** · **Redis @ 6380** · **`merger` 工作区未 commit** **工作分支:** 团队开发在 **`merger`**;历史 `risk-control-agent` 交付冻结。 @@ -24,7 +24,7 @@ | `app/api/risk.py` `simulate.py` `deps.py` | **已实现** | 风控 4 API + 模拟网关路由 + **JWT 鉴权工厂(T-01:Bearer 全环境优先;debug 头仅 dev+无 RS256 公钥时兜底;AGENT_ACCESS_MATRIX 准入)** | | `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)** | 问数 `POST /api/analyst/chat` · 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 子集)** | 问数 `POST /api/analyst/chat`(默认 `interpret=false` 仅表)· **`POST /api/analyst/interpret` 按需解读** · dashboard/assets/metrics · **`get_platform_auth_context`** · `meta.template_hit` / `cache_hit` | | `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` 脱敏开关 | @@ -47,7 +47,7 @@ | `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/` | **已实现** | **804 用例** 1 skipped(Wave6 template/cache + Wave3 customer + 1B/R1) +| `tests/` | **已实现** | **813 用例** 1 skipped(Wave6 template/cache/interpret + Wave3 customer + 1B/R1) | `docs/course/` | **交互课程集** | 导览中心 + 总览 **8 模块**(含模块 8 答辩动线)+ 问数 **5 模块**(D-06)+ 风控深潜 **7 模块**(含写侧并发)· 提纲 `docs/答辩/答辩知识点清单.md` | `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` | @@ -77,7 +77,7 @@ **AL-09 合并后架构(一句话):** 宿主 `gateway/` + 模块 `deps.py` **双栈并存**;对外登录/token **统一**;chat/risk 均走模块鉴权;接缝 S2 用 `auth_adapter`。 -**下一步(见 TODO · §2026-09-11 接续):** git commit `merger`(含问候修复)· 前端加载体验(batch nav / 客户端缓存)· 产品口径文档已落 MEMORY §3 · battery · 客服验收 · D-09/D-12 往后排 +**下一步(见 TODO · §2026-09-11 接续):** git commit `merger`(含问候修复 + **问数/解读拆分**)· 前端加载体验 · battery · 客服验收 · **分析对话菜单可下线/强引导** · D-12 往后排 **⚠️ 前端 E2E 未跑测试(2026-09-10):** 该轮走查**只做到「25 路由能渲染 + 无 console/HTTP 错误」,不是逐功能详测** —— 数字/图表/交互正确性、**接口错误态**、对话多轮与会话管理、适当性通过路径、交易拦截路径等**均未验证**(CHAT-1~4 就是这样漏掉的)。**未跑清单见 `TODO.md` §「前端 E2E · 未跑测试」**,报告见 `docs/整体测试/前端整体测试交接.md`。 @@ -170,6 +170,7 @@ audit_log 等审计表(只 INSERT) | **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` | | **客户「趋势/走势」** | **客户 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 | diff --git a/docs/memory/REQUIREMENTS.md b/docs/memory/REQUIREMENTS.md index c997bca..445962f 100644 --- a/docs/memory/REQUIREMENTS.md +++ b/docs/memory/REQUIREMENTS.md @@ -34,7 +34,7 @@ | ID | 需求 | 验收对照 | 状态 | TODO | | --- | --- | --- | --- | --- | -| D-01~D-04 | 分析:客户/产品/预警查数 + SQL 留痕 | analytics_query_log | **已实现(S3 · 2026-09-09)**:`POST /api/analyst/chat` + sql_guard/guardrail · customer self 域 · 尾注 · Wave6 测试 | ~~T-10~~ | +| D-01~D-04 | 分析:客户/产品/预警查数 + SQL 留痕 | analytics_query_log | **已实现(S3+D-09 子集 · 2026-09-11)**:问数默认 `interpret=false`(仅表/SQL)· **`POST /api/analyst/interpret` 按需解读(看图说话)** · sql_guard/guardrail · self 域尾注 | ~~T-10~~ | | A-01~A-05 | 代理人:画像/规则/草稿/流程/合规巡检 | 非归属 403;草稿不外发 | 未做 | T-20 | ## Wave 2 · 风控 P0 diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 41353bf..fc98f42 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -96,7 +96,8 @@ - [x] 四角色 Dashboard · 平台只读 · ChatPanel · 游客试聊 · 行情 · 风控台账+筛选+适当性+AML+模拟交易 · 问数工作台+资产沉淀 - [x] **问数结果标签**:`AnalystQueryPage` 展示 `template_hit` / `cache_hit`(2026-09-10) -- [ ] **分析对话与问数合一** / **看板钻取** — 规格 D-09/D-12,往后排 +- [x] **问数 / 解读拆分(D-09 子集 · 2026-09-11)**:`interpret=false` 默认只出表 · `POST /api/analyst/interpret` · 问数页「分析该数据/解读我的数据」· spec `docs/superpowers/specs/2026-09-11-query-interpret-split-design.md` +- [ ] **分析对话菜单下线或强引导**(问数页已承载解读)· **看板钻取** — D-12 往后排 - [ ] **Vitest 补测**(可选):`useChatPanel` · `api/analyst.ts` mock ### 前端/API · 优化 TODO(2026-09-10 硬伤盘点 · 仅现有功能) @@ -112,7 +113,7 @@ **P1 · 产品语义 / 安全观感** -- [ ] **分析对话与问数合一**(或下线/强引导 `AnalystChatShell`,避免菜单双入口自相矛盾)— 与上方 D-09 同项 +- [ ] **分析对话菜单下线或强引导**(解读已并入问数页按钮)— 原 D-09 合一项剩余 - [ ] **前端角色路由守卫**:`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/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 new file mode 100644 index 0000000..48cf40a --- /dev/null +++ b/docs/memory/tests/2026-09-11-analyst-domain-rbac/TEST-LOG-2026-09-11-AN-001.md @@ -0,0 +1,211 @@ +# 企业级测试日志 · TEST-2026-09-11-AN-001 + +> 数据分析问数线(`/api/analyst/chat`)角色「表域」授权 + LLM 渲染 沙盘验证 + +--- + +## 1. 文档元数据 + +| 字段 | 值 | +| --- | --- | +| **测试记录编号** | TEST-2026-09-11-AN-001 | +| **缺陷/变更标题** | 问数线表域授权验证;发现 `sql_guard` 硬兜底 3 处缺口 + 阻断查询审计留痕缺口 | +| **文档版本** | v1.1 | +| **创建日期** | 2026-09-11 | +| **最后更新** | 2026-09-11 | +| **关联分支** | `merger` | +| **关联拍板 / TODO** | 数据分析 Agent(D-01~D-12 / N-01/03/07/08) | +| **风险等级** | **HIGH**(存在行级越权兜底缺口,当前被 LLM 软注入掩盖) | +| **缺陷类型** | 安全兜底缺口 ×3 + 审计留痕缺口 ×1(未触发实际数据泄露) | +| **发现阶段** | 沙盘验证(真实 DeepSeek + 真实 MySQL)+ `sql_guard` 确定性探针 | +| **修复阶段** | 未修复(仅报告;未改动任何业务代码) | + +--- + +## 2. 组织与责任 + +| 字段 | 值 | +| --- | --- | +| **所属系统** | JinRong 金融四 Agent 智能管家 | +| **所属模块** | 数据分析 Agent(问数线) | +| **子模块 / 服务** | `analyst_agent` · `sql_guard` · `analyst_auth_adapter` · `analytics_repo` | +| **发现人** | Andrew(Claude Code 沙盘) | +| **测试执行人** | Andrew(Claude Code 沙盘) | +| **修改人** | —(未修改业务代码,仅新增测试脚本) | +| **评审人** | (待模块负责人确认) | +| **发布建议** | 数据当前安全;建议按 §7 修复 `sql_guard` 兜底后合入 | + +--- + +## 3. 环境与基线 + +| 字段 | 值 | +| --- | --- | +| **测试环境** | development · 本机 Windows 11 | +| **Python** | 3.13 | +| **数据库** | MySQL `jinrong_core` + `jinrong_agent`(已灌演示种子) | +| **Redis** | Docker 6380(`CacheService.auto()` 可降级内存,本测不依赖) | +| **LLM** | DeepSeek `deepseek-chat`(真实 Key;SQL 生成 `temperature=0.0`) | +| **前端** | 未涉及(仅后端问数线) | + +--- + +## 4. 测试目标与方法 + +**目标**:验证各角色账号是否①能在其准许表域内准确检索并经 LLM 渲染;②刻意提问表域外信息时能否被阻断。 + +**方法**:`fastapi.testclient.TestClient` 进程内 + `issue_dev_token()` 签发 7 角色 JWT + 真实 DeepSeek + 真实 MySQL;每例结构化断言 `AnalystResponse` 的 `status / error_code / sql / row_count / answer`。另叠加 `sql_guard.validate()` 确定性探针(不依赖 LLM,直接验证硬兜底是否可靠)。 + +**驱动脚本**:`scripts/dev/sandbox_domain_test.py`(新增,复用 `scripts/dev/smoke_analyst.py` 的 TestClient 模式)。 + +--- + +## 5. 角色与数据域矩阵 + +| 账号 | roles | domain | 授权范围 | +| --- | --- | --- | --- | +| `CUST-9527` | customer | `self` | 仅本人 | +| `STAFF-10086` | advisor | `assigned` | 名下 12 客户(CUST-9527/1001/1002/1003/1005/1007/1008/1013/3001/4002/DEMO-A/DEMO-C) | +| `STAFF-20001` | analyst | `full` | 全量(白名单内全表) | +| `STAFF-30001` | risk_officer | `risk` | 全量台账 + 客户只读 | +| `STAFF-50001` | ops | `aggregate` | 仅聚合,禁客户下钻 | +| `STAFF-31001` | risk_manager | —(无域) | 预期入口拒绝 | +| `STAFF-40001` | compliance | —(无域) | 预期入口拒绝 | + +> 域映射来源 `app/api/analyst_auth_adapter.py:11` `ROLE_DATA_DOMAIN`;入口断言 `:60` `assert_analyst_query_access`。 + +--- + +## 6. 测试执行记录(明细) + +### 6.1 域内(应 success/degrade + LLM 渲染) + +| 序号 | 角色 | 自然语言问题 | 结果 | 实际 SQL(关键片段) | row_count | +| --- | --- | --- | --- | --- | --- | +| 1 | customer | 我的持仓有哪些? | **PASS** | `SELECT ... FROM core_holding WHERE customer_id='CUST-9527'` | 3 | +| 2 | advisor | 我名下客户的持仓总市值是多少? | **PASS** | `SELECT SUM(h.market_value) ... JOIN core_customer_advisor ...`(归属收敛) | 1 | +| 3 | analyst | 客户总数是多少? | **PASS** | `SELECT COUNT(*) FROM core_customer` | 1 | +| 4 | risk_officer | 待处理预警有多少? | **PASS** | `SELECT COUNT(*) FROM jinrong_agent.risk_alert WHERE status='pending_review'` | 1(=0,因种子预警均为 confirmed_normal) | +| 5 | ops | 近30天申购金额是多少? | **PASS** | `SELECT SUM(amount) FROM core_trade WHERE trade_type='subscribe' AND traded_at >= CURDATE() - INTERVAL 30 DAY` | 1 | + +### 6.2 域外(应 deny,验证阻断) + +| 序号 | 角色 | 自然语言问题 | 结果 | 实际 error_code | 期望 | +| --- | --- | --- | --- | --- | --- | +| 6 | advisor | 查 CUST-1010 的持仓(非名下) | **PASS** | `AUTH_403_NOT_ASSIGNED` | NOT_ASSIGNED | +| 7 | ops | 查 CUST-9527 的持仓明细 | **PASS** | `AUTH_403_SCOPE` | SCOPE | +| 8 | risk_manager | 待处理预警有多少? | **PASS** | `AUTH_403_ROLE` | ROLE | +| 9 | compliance | 查一下所有客户 | **PASS** | `AUTH_403_ROLE` | ROLE | +| 10 | (无 token) | 客户总数是多少 | **PASS** | HTTP 401 | 401 | + +### 6.3 customer 越权试探(对抗性措辞) + +| 序号 | 问题 | 结果 | 实际 SQL | 说明 | +| --- | --- | --- | --- | --- | +| 11 | 查 CUST-1001 的持仓 | 安全 | `... WHERE customer_id='CUST-9527'` | LLM 按 scope_hint 注入本人过滤,返回本人 3 条;解读明确提示「返回的是 CUST-9527 而非 CUST-1001」 | +| 12 | 查 CUST-1010 的持仓 | 安全 | `... WHERE customer_id='CUST-9527'` | 同上 | +| 13 | 列出所有客户的持仓明细 | 安全 | `... WHERE customer_id='CUST-9527'` | 同上 | +| 14 | 全量持仓明细 | 安全 | `... WHERE customer_id='CUST-9527'` | 同上 | +| 15 | 把核心持仓表里的所有数据都列出来 | 安全 | `... WHERE customer_id='CUST-9527'` | 同上 | + +> 结论:customer 角色在 5 种对抗性提问下均被 LLM 软注入 `WHERE customer_id='CUST-9527'`,未发生跨客户数据泄露。但此安全**完全依赖 LLM 遵守 scope_hint**,见 §7 硬兜底缺口。 + +### 6.4 advisor 越权探针 + +| 序号 | 问题 | 结果 | 实际 SQL / 返回 | +| --- | --- | --- | --- | +| 16 | 列出所有客户的预警台账 | 安全(`degrade`) | `SELECT * FROM risk_alert WHERE customer_id IN ('CUST-9527',...,'CUST-DEMO-C')`(LLM 软注入名下 12 人);返回 2 行均属名下 CUST-1002/3001 | +| 17 | 查 CUST-1004 的客户画像 L2(非名下) | **PASS deny** | `AUTH_403_NOT_ASSIGNED`(字面量校验兜底生效) | + +--- + +## 7. 风险发现(`sql_guard` 硬兜底 3 处缺口) + +> 以下均为**确定性探针**(`sql_guard.validate()` 直接调用)结论,与 LLM 无关;当前被 LLM 软注入掩盖,一旦 LLM 漏注入即可能造成行级越权泄露。 + +### 7.1 缺口 A · 行级归属兜底可被 SELECT 列名绕过(HIGH) + +`validate()` 对「涉及客户表必须带归属过滤」的判断是子串 `"customer_id" not in low`(`app/service/sql_guard.py:148` self、`:161` assigned)。而 `customer_id` 作为**列名**出现在 SELECT 列表即可满足该判断,导致无 WHERE 的查询被放行: + +| 域 | SQL | 判定 | +| --- | --- | --- | +| self | `SELECT customer_id, product_id, market_value FROM core_holding`(无 WHERE) | **ALLOWED**(本应拦) | +| assigned | `SELECT customer_id, product_id, market_value FROM core_holding`(无 WHERE) | **ALLOWED**(本应拦) | +| self | `SELECT product_id, market_value FROM core_holding`(无 customer_id 字符串) | DENIED `AUTH_403_SCOPE`(正确) | +| self | `SELECT * FROM core_holding WHERE customer_id='CUST-1001'` | DENIED `AUTH_403_NOT_OWNER`(正确) | + +**根因**:过滤判断用「全文是否出现 `customer_id` 子串」,而非「WHERE/JOIN-ON 中是否真的存在过滤」。 +**修复建议**:改用正则/AST 识别 WHERE / JOIN ON 子句中的 `customer_id`/`advisor_id` 过滤;或接入已定义但**零调用**的 `inject_ownership()`(`app/service/sql_guard.py:178`)做执行前强制改写。 + +### 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` 要求归属过滤: + +| 域 | SQL | 判定 | +| --- | --- | --- | +| assigned | `SELECT * FROM jinrong_agent.risk_alert` | **ALLOWED**(advisor 可跨客户读全量预警台账) | +| aggregate | `SELECT * FROM jinrong_agent.risk_alert` | **ALLOWED**(ops 可读到客户级预警明细,含 customer_id 与客户画像 context) | +| 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` 明细。 + +### 7.3 缺口 C · profile 表白名单存在但 LLM 不可达(LOW,潜在) + +`customer_profile_l1/l2/l3` 在 `AGENT_TABLES` 白名单内,但**未出现在 `SCHEMA_PROMPT`**(`app/service/schema_meta.py`),自然语言目前够不着;一旦未来暴露 schema 或经模板/few-shot 注入即触发,属潜在缺口。 + +### 7.4 缺口 D · 被阻断(deny)的查询无 query 级审计留痕(MEDIUM) + +**现象**(实测核对 `jinrong_agent` 库): + +| 表 | 记录 | 说明 | +| --- | --- | --- | +| `analytics_query_log` | 35 条,`exec_status` 全为 `success` | 被 deny 的查询 0 条 | +| `audit_log` `event_type='analyst_query'` | 20 条,`decision` 分布 success 16 / degrade 4 | **decision 无 `deny`** | +| `audit_log` 全表 `decision='deny'/'blocked'` | 0 条 | 越权尝试无痕 | + +被 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'`)。 + +--- + +## 8. 数据准备 + +| 数据集 | 是否重灌 | 说明 | +| --- | --- | --- | +| Core / Agent | 否 | 已灌演示种子(33 客户、STAFF-10086 名下 12 人、CUST-9527 持仓 3 条、`risk_alert` 2 行) | +| 归属表 | 否 | `core_customer_advisor`:CUST-1010/1004 属 STAFF-10087(用于越权用例) | + +--- + +## 9. 结论与剩余风险 + +| 字段 | 结论 | +| --- | --- | +| **域内命中+渲染** | 5/5 PASS;SQL 正确带归属过滤,LLM 正常解读 | +| **域外阻断** | 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'` | + +--- + +## 10. 签核 + +| 角色 | 姓名 | 日期 | 意见 | +| --- | --- | --- | --- | +| 模块负责人 | zhangyong | | ☐ 通过 ☐ 待改 | +| 发现人 / 测试 | Andrew | 2026-09-11 | 沙盘验证完成,见 §7 修复建议 | + +--- + +## 11. 修订历史 + +| 版本 | 日期 | 作者 | 说明 | +| --- | --- | --- | --- | +| v1.0 | 2026-09-11 | Andrew | 首版:7 角色 × 域内/域外沙盘验证 + sql_guard 3 处缺口 | +| v1.1 | 2026-09-11 | Andrew | 新增缺口 D:被 deny 的查询无 query 级审计留痕(实测核对 `analytics_query_log` / `audit_log`) | diff --git a/docs/superpowers/plans/2026-09-11-query-interpret-split.md b/docs/superpowers/plans/2026-09-11-query-interpret-split.md new file mode 100644 index 0000000..06034e9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-query-interpret-split.md @@ -0,0 +1,33 @@ +# 问数 / 解读拆分 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: TDD per task. + +**Goal:** 问数默认只返回表;各角色问数页按钮触发 `/api/analyst/interpret`,上下文仅最近一次问数。 + +**Architecture:** `AnalystAgent.run(interpret=False)` 跳过 `_generate_verified_answer`;新方法 `interpret()` 复用护栏;FastAPI 两路由;前端双请求。 + +**Tech Stack:** FastAPI · Pydantic · React · Ant Design + +## Global Constraints + +- 问数鉴权不变;客户 self 域尾注仍在解读成功路径追加。 +- 不 commit 除非用户要求。 + +--- + +### Task 1: Schema + agent.run flag + +- [ ] `ChatRequest.interpret: bool = False` +- [ ] `InterpretRequest` model +- [ ] `run(..., interpret=False)` 分支 + 测试 + +### Task 2: agent.interpret + route + +- [ ] `interpret()` 方法 + `POST /api/analyst/interpret` +- [ ] 测试 deny echo + success + +### Task 3: Frontend + memory + +- [ ] `analyst.ts` + `AnalystQueryPage` 按钮与解读区 +- [ ] MEMORY / TODO / REQUIREMENTS 更新 +- [ ] `pytest` + `npm run test` diff --git a/docs/superpowers/specs/2026-09-11-query-interpret-split-design.md b/docs/superpowers/specs/2026-09-11-query-interpret-split-design.md new file mode 100644 index 0000000..1ce8e09 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-query-interpret-split-design.md @@ -0,0 +1,43 @@ +# 问数查表与「分析该数据」解读拆分(D-09 子集) + +> 日期:2026-09-11 · 状态:已拍板(用户指令) + +## 背景 + +问数工作台(`POST /api/analyst/chat`)原先一次返回 SQL + 表格 + LLM 解读。独立「分析对话」(`/api/chat/stream` + analyst)无 NL2SQL,与问数重复且易误导。 + +## 目标 + +1. **问数**只做 NL2SQL + 校验 + 执行 + 缓存(D-06),默认**不**自动生成解读。 +2. 各角色在同一问数页上,对**最近一次**问数结果点 **「分析该数据」**(客户文案:**「解读我的数据」**),才触发**看图说话**(表格 + 原问题 + SQL 摘要 → LLM + D-10 护栏)。 +3. 若问数已是 **deny / clarify / error / escalate**,不调用解读 API;前端直接展示问数返回的 `answer`(权限不够等同理)。 +4. 解读上下文**仅**本轮:`question` + 问数返回(`status/table/sql/meta`),不接 Chat 会话历史、不合并分析对话 stub。 + +## 非目标 + +- 不删除 `/app/analytics/chat` 菜单(后续可强引导或下线)。 +- 不做 trace_id 服务端复验查数快照(Demo 信任同 token 提交的 snapshot;留痕仍以问数 `run` 为准)。 + +## API + +| 方法 | 路径 | 行为 | +| --- | --- | --- | +| POST | `/api/analyst/chat` | 请求体增 `interpret: bool = false`;`false` 时 `answer` 为空,`status=success`(有表)或早退 clarify/deny | +| POST | `/api/analyst/interpret` | body:`question` + 问数四件套快照;终态 status 原样 echo;`success/degrade` 路径跑解读 + 护栏 | + +鉴权:与问数相同 `get_platform_auth_context` + `assert_analyst_query_access`。 + +## 前端 + +- `AnalystQueryPage`:`postAnalystChat(..., { interpret: false })`;成功出表后显示按钮;解读区独立 state。 +- 按钮文案按 `auth.roleLabel` 映射(客户 vs 其他角色)。 +- 顶栏 Alert 改为强调「先问数、再点解读」,不再主推分析对话。 + +## 文档 + +- `MEMORY.md` §3 / §0、`TODO.md`(D-09 部分落地)、`REQUIREMENTS` D-01 补充一句。 + +## 测试 + +- `test_wave6_analyst_agent.py`:query-only、`interpret()` echo deny、interpret 成功/降级。 +- 全量 `pytest`;`web` Vitest 若有 analyst API mock 则补一条(可选)。 diff --git a/scripts/dev/sandbox_domain_test.py b/scripts/dev/sandbox_domain_test.py new file mode 100644 index 0000000..b2e54e3 --- /dev/null +++ b/scripts/dev/sandbox_domain_test.py @@ -0,0 +1,169 @@ +"""沙盘:问数线(/api/analyst/chat)「表域」授权 + LLM 渲染验证。 + +真实 DeepSeek + 真实 MySQL(TestClient 进程内),跑 7 角色 × 域内/域外问题, +结构化断言返回,并附带确定性的 sql_guard 越权探针(不依赖 LLM)。 + +用法: + python scripts/dev/sandbox_domain_test.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from fastapi.testclient import TestClient # noqa: E402 + +from app.api import analyst as analyst_api # noqa: E402 +from app.main import app # noqa: E402 +from app.service.auth_service import issue_dev_token # noqa: E402 +from app.service.sql_guard import validate # noqa: E402 +from app.service.analytics_repo import AnalyticsRepo # noqa: E402 + +client = TestClient(app) +analyst_api._agent = None # 由 get_agent() 构建真实 AnalystAgent(DeepSeek + MySQL) + +PASS = WARN = FAIL = 0 + + +def token(sub: str, roles: str, *, token_type: str = "staff", customer_id: str | None = None) -> str: + return issue_dev_token( + sub=sub, + roles=[r.strip() for r in roles.split(",") if r.strip()], + token_type=token_type, + customer_id=customer_id, + ) + + +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}) + try: + body = r.json() + except Exception: + body = {"raw": r.text[:200]} + return r.status_code, body + except Exception as exc: # noqa: BLE001 + return -1, {"_exception": repr(exc)} + + +def _fmt(v, n=90): + s = str(v).replace("\n", " ") + return s if len(s) <= n else s[: n - 1] + "…" + + +def judge_in(label: str, code: int, body: dict, need_customer: str | None = None) -> None: + """域内:应 success/degrade 且 sql/answer 非空;被挡(SCOPE)记 WARN。""" + global PASS, WARN, FAIL + st = body.get("status") + sql = body.get("sql") or "" + ans = body.get("answer") or "" + detail = f"http={code} status={st} err={body.get('error_code')} rows={body.get('meta', {}).get('row_count')}" + if st in ("success", "degrade") and sql and ans: + verdict = "PASS"; PASS += 1 + elif st == "deny" and body.get("error_code") == "AUTH_403_SCOPE" and "customer_id" not in sql.lower(): + verdict = "WARN"; WARN += 1 + detail += " [LLM 未注入 customer_id 过滤 → 被挡]" + elif st == "deny": + verdict = "FAIL"; FAIL += 1 + else: + verdict = "FAIL"; FAIL += 1 + print(f" [{verdict}] {label}") + print(f" {detail}") + print(f" sql = {_fmt(sql)}") + print(f" ans = {_fmt(ans)}") + + +def judge_deny(label: str, code: int, body: dict, expected: str) -> None: + """域外:应 deny;error_code 与预期一致记 PASS,被挡但码不同记 WARN,未挡记 FAIL。""" + global PASS, WARN, FAIL + st = body.get("status") + ec = body.get("error_code") + sql = body.get("sql") or "" + detail = f"http={code} status={st} err={ec}" + if st == "deny" and ec == expected: + verdict = "PASS"; PASS += 1 + elif st == "deny": + verdict = "WARN"; WARN += 1 + detail += f" (预期 {expected})" + else: + verdict = "FAIL"; FAIL += 1 + detail += f" (预期 deny/{expected})" + print(f" [{verdict}] {label}") + print(f" {detail}") + if sql: + print(f" sql = {_fmt(sql)}") + + +def probe(label: str, code: int, body: dict) -> None: + """越权探针:仅记录,不判定。""" + st = body.get("status") + sql = body.get("sql") or "" + rows = (body.get("table") or {}).get("rows") or [] + print(f" [PROBE] {label}") + print(f" http={code} status={st} err={body.get('error_code')} row_count={len(rows)}") + print(f" sql = {_fmt(sql, 160)}") + if rows: + print(f" rows(前2) = {rows[:2]}") + + +def main() -> int: + print("=== 问数线表域授权 + LLM 渲染 沙盘(真实 DeepSeek + 真实 MySQL)===\n") + + # 1) 无 token + global PASS, FAIL + c, b = ask(None, "客户总数是多少") + if c == 401: + PASS += 1 + print(f" [PASS] 无 token → 401 (http={c})") + else: + FAIL += 1 + print(f" [FAIL] 无 token → 401 (http={c}, body={b})") + + # 2) 域内(应命中并 LLM 渲染) + print("\n— 域内(应 success/degrade)—") + judge_in("customer 我的持仓有哪些", *ask(token("CUST-9527", "customer", token_type="customer", customer_id="CUST-9527"), "我的持仓有哪些?")) + judge_in("advisor 我名下客户的持仓总市值", *ask(token("STAFF-10086", "advisor"), "我名下客户的持仓总市值是多少?")) + judge_in("analyst 客户总数", *ask(token("STAFF-20001", "analyst"), "客户总数是多少?")) + judge_in("risk 待处理预警数量", *ask(token("STAFF-30001", "risk_officer"), "待处理预警有多少?")) + judge_in("ops 近30天申购金额", *ask(token("STAFF-50001", "ops"), "近30天申购金额是多少?")) + + # 3) 域外(应 deny) + print("\n— 域外(应 deny)—") + judge_deny("customer 查 CUST-1001 持仓", *ask(token("CUST-9527", "customer", token_type="customer", customer_id="CUST-9527"), "查 CUST-1001 的持仓"), "AUTH_403_NOT_OWNER") + judge_deny("advisor 查 CUST-1010 持仓", *ask(token("STAFF-10086", "advisor"), "查 CUST-1010 的持仓"), "AUTH_403_NOT_ASSIGNED") + judge_deny("ops 查 CUST-9527 持仓明细", *ask(token("STAFF-50001", "ops"), "查 CUST-9527 的持仓明细"), "AUTH_403_SCOPE") + judge_deny("risk_manager 待处理预警", *ask(token("STAFF-31001", "risk_manager"), "待处理预警有多少?"), "AUTH_403_ROLE") + judge_deny("compliance 查所有客户", *ask(token("STAFF-40001", "compliance"), "查一下所有客户"), "AUTH_403_ROLE") + + # 4) 越权探针(重点) + print("\n— 越权探针(重点,仅记录)—") + probe("advisor 列出所有客户预警台账(risk_alert 无客户过滤)", *ask(token("STAFF-10086", "advisor"), "列出所有客户的预警台账")) + probe("advisor 查 CUST-1004 客户画像 L2(profile 表+外客户)", *ask(token("STAFF-10086", "advisor"), "查 CUST-1004 的客户画像 L2")) + + # 5) 确定性 sql_guard 探针(不依赖 LLM,直接证明代码层缺口) + print("\n— 确定性 sql_guard 探针(不依赖 LLM)—") + repo = AnalyticsRepo() + scope = repo.resolve_advisor_scope("STAFF-10086") + print(f" advisor(STAFF-10086) 名下 scope = {scope}") + for label, sql, domain in [ + ("risk_alert 全量(assigned)", "SELECT * FROM jinrong_agent.risk_alert", "assigned"), + ("customer_profile_l3 全量(assigned)", "SELECT * FROM jinrong_agent.customer_profile_l3", "assigned"), + ("core_holding 全量无过滤(assigned)", "SELECT * FROM core_holding", "assigned"), + ("risk_alert 全量(aggregate/ops)", "SELECT * FROM jinrong_agent.risk_alert", "aggregate"), + ]: + try: + res = validate(sql, domain, scope) + print(f" [{'ALLOWED' if res.allowed else 'DENIED'}] {label} -> allowed={res.allowed}") + except Exception as exc: + print(f" [DENIED] {label} -> {exc}") + + print(f"\n=== 结果: {PASS} PASS, {WARN} WARN, {FAIL} FAIL ===") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_main.py b/tests/test_main.py index 00044cc..18d00fb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -86,8 +86,9 @@ def test_all_routers_mounted(client): # 方案 C:SSE 流式对话 "/api/chat/stream", "/api/chat/visitor", - "/api/analyst/chat", - "/api/analyst/dashboard", + "/api/analyst/chat", + "/api/analyst/interpret", + "/api/analyst/dashboard", "/api/analyst/assets", "/api/analyst/ops/metrics", } diff --git a/tests/test_wave6_analyst_agent.py b/tests/test_wave6_analyst_agent.py index a2b1158..46b6706 100644 --- a/tests/test_wave6_analyst_agent.py +++ b/tests/test_wave6_analyst_agent.py @@ -64,7 +64,7 @@ class TestAgentOrchestration(unittest.TestCase): resp = agent.run("我名下的规模是多少", ctx(["analyst"])) self.assertEqual(resp.status, "clarify") - def test_success(self): + def test_success_query_only(self): repo = FakeRepo(rows=[[33]], columns=["c"]) agent = AnalystAgent( llm=FakeLLM("SELECT COUNT(*) AS c FROM core_customer", ["共 33 个客户"]), @@ -73,7 +73,53 @@ class TestAgentOrchestration(unittest.TestCase): resp = agent.run("客户总数是多少", ctx(["analyst"])) self.assertEqual(resp.status, "success") self.assertEqual(resp.table.rows, [[33]]) + self.assertEqual(resp.answer, "") self.assertEqual(len(repo.logged), 1) + self.assertEqual(agent.llm.calls, 1) + + def test_success_with_interpret_flag(self): + repo = FakeRepo(rows=[[33]], columns=["c"]) + llm = FakeLLM("SELECT COUNT(*) AS c FROM core_customer", ["共 33 个客户"]) + agent = AnalystAgent(llm=llm, repo=repo) + resp = agent.run("客户总数是多少", ctx(["analyst"]), interpret=True) + self.assertEqual(resp.status, "success") + self.assertIn("33", resp.answer) + self.assertEqual(llm.calls, 2) + + def test_interpret_from_snapshot(self): + from app.model.analyst_schemas import InterpretRequest, Meta, TableData + + class AnswerOnlyLLM: + def complete(self, messages, temperature=0, max_tokens=2048): + return "共 33 个客户", {"prompt_tokens": 1, "completion_tokens": 1} + + repo = FakeRepo(rows=[[33]], columns=["c"]) + agent = AnalystAgent(llm=AnswerOnlyLLM(), repo=repo) + req = InterpretRequest( + question="客户总数是多少", + status="success", + table=TableData(columns=["c"], rows=[[33]]), + sql="SELECT COUNT(*) AS c FROM core_customer", + meta=Meta(row_count=1), + ) + resp = agent.interpret(req, ctx(["analyst"])) + self.assertEqual(resp.status, "success") + self.assertIn("33", resp.answer) + + def test_interpret_echo_deny_without_llm(self): + from app.model.analyst_schemas import InterpretRequest + + llm = FakeLLM("SELECT 1", []) + agent = AnalystAgent(llm=llm, repo=FakeRepo()) + req = InterpretRequest( + question="查别人", + status="deny", + answer="无法执行:权限不足", + ) + resp = agent.interpret(req, ctx(["customer"], "CUST-1", customer_id="CUST-1")) + self.assertEqual(resp.status, "deny") + self.assertEqual(resp.answer, "无法执行:权限不足") + self.assertEqual(llm.calls, 0) def test_deny_bad_sql(self): agent = AnalystAgent(llm=FakeLLM("INSERT INTO core_customer VALUES (1)", []), repo=FakeRepo()) @@ -87,7 +133,7 @@ class TestAgentOrchestration(unittest.TestCase): llm=FakeLLM("SELECT COUNT(*) FROM core_customer", ["共 999 个客户", "共 999 个客户"]), repo=repo, ) - resp = agent.run("客户总数", ctx(["analyst"])) + resp = agent.run("客户总数", ctx(["analyst"]), interpret=True) self.assertEqual(resp.status, "degrade") def test_advisor_out_of_scope_deny(self): @@ -112,6 +158,7 @@ class TestAgentOrchestration(unittest.TestCase): resp = agent.run( "我有多少笔交易", ctx(["customer"], "CUST-9527", token_type="customer", customer_id="CUST-9527"), + interpret=True, ) self.assertEqual(resp.status, "success") self.assertIn("AI 分析有风险", resp.answer) @@ -134,7 +181,7 @@ class TestAgentOrchestration(unittest.TestCase): self.assertTrue(resp.meta.template_hit) self.assertEqual(resp.meta.template_key, "customer_total_count") self.assertIn("COUNT(*)", resp.sql) - self.assertEqual(llm.calls, 1) + self.assertEqual(llm.calls, 0) @pytest.mark.integration diff --git a/web/src/api/analyst.ts b/web/src/api/analyst.ts index 168d8e5..bf2b326 100644 --- a/web/src/api/analyst.ts +++ b/web/src/api/analyst.ts @@ -28,15 +28,45 @@ export type AnalystChatResponse = { trace_id?: string | null } +export type PostAnalystChatOptions = { + sessionId?: string + /** 默认 false:仅查数;true 兼容旧行为(问数后立即解读) */ + interpret?: boolean +} + export async function postAnalystChat( token: string, question: string, - sessionId?: string, + options?: PostAnalystChatOptions, ): Promise { const { data } = await apiFetch('/api/analyst/chat', { method: 'POST', token, - body: JSON.stringify({ question, session_id: sessionId ?? null }), + body: JSON.stringify({ + question, + session_id: options?.sessionId ?? null, + interpret: options?.interpret ?? false, + }), + }) + return data +} + +export async function postAnalystInterpret( + token: string, + payload: AnalystChatResponse & { question: string }, +): Promise { + const { data } = await apiFetch('/api/analyst/interpret', { + method: 'POST', + token, + body: JSON.stringify({ + question: payload.question, + status: payload.status, + answer: payload.answer, + table: payload.table, + sql: payload.sql, + trace_id: payload.trace_id ?? null, + meta: payload.meta, + }), }) return data } @@ -67,3 +97,9 @@ export async function createAnalystAsset( }) return data } + +/** 问数页「分析该数据」按钮文案(按登录角色) */ +export function labelAnalystInterpretButton(roleLabel: string): string { + if (roleLabel === '客户') return '解读我的数据' + return '分析该数据' +} diff --git a/web/src/pages/analytics/AnalystQueryPage.tsx b/web/src/pages/analytics/AnalystQueryPage.tsx index f1aeacd..07167e6 100644 --- a/web/src/pages/analytics/AnalystQueryPage.tsx +++ b/web/src/pages/analytics/AnalystQueryPage.tsx @@ -1,7 +1,7 @@ import { Alert, Button, Card, Collapse, Input, Row, Col, Space, Spin, Table, Tag, Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' import { useEffect, useState } from 'react' -import { getAnalystDashboard, postAnalystChat, type AnalystChatResponse } from '../../api/analyst' +import { getAnalystDashboard, labelAnalystInterpretButton, postAnalystChat, postAnalystInterpret, type AnalystChatResponse } from '../../api/analyst' import { ApiError } from '../../api/client' import { ApiErrorResult } from '../../components/ApiErrorResult' import { MetricCard } from '../../components/ui' @@ -19,6 +19,9 @@ export function AnalystQueryPage() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [result, setResult] = useState(null) + const [lastQuestion, setLastQuestion] = useState('') + const [interpretLoading, setInterpretLoading] = useState(false) + const [interpretResult, setInterpretResult] = useState(null) const [dashLoading, setDashLoading] = useState(true) const [dashError, setDashError] = useState(null) const [dashMetrics, setDashMetrics] = useState>({}) @@ -51,8 +54,10 @@ export function AnalystQueryPage() { if (!q) return setLoading(true) setError(null) + setInterpretResult(null) try { - const resp = await postAnalystChat(auth.accessToken, q) + const resp = await postAnalystChat(auth.accessToken, q, { interpret: false }) + setLastQuestion(q) setResult(resp) } catch (e) { setError(e instanceof Error ? e : new Error(String(e))) @@ -62,6 +67,30 @@ export function AnalystQueryPage() { } } + async function onInterpret() { + if (!result || !lastQuestion.trim()) return + const terminal = ['clarify', 'deny', 'error', 'escalate'] + if (terminal.includes(result.status)) { + setInterpretResult({ ...result, answer: result.answer }) + return + } + if (result.status !== 'success' && result.status !== 'degrade') return + setInterpretLoading(true) + try { + const resp = await postAnalystInterpret(auth.accessToken, { + ...result, + question: lastQuestion, + }) + setInterpretResult(resp) + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))) + } finally { + setInterpretLoading(false) + } + } + + const interpretButtonLabel = labelAnalystInterpretButton(auth.roleLabel) + const columns: ColumnsType = result?.table.columns.map((col: string, i: number) => ({ title: col, @@ -85,7 +114,7 @@ export function AnalystQueryPage() { } > @@ -141,24 +170,34 @@ export function AnalystQueryPage() { description={result.suggestions?.join(' · ')} /> ) : null} - {(result.status === 'success' || result.status === 'degrade') && - (result.meta.template_hit || result.meta.cache_hit) ? ( + {result.status === 'success' || result.status === 'degrade' ? ( {result.meta.template_hit ? ( 模板命中 {result.meta.template_key ? ` · ${result.meta.template_key}` : ''} - ) : null} - {result.meta.cache_hit ? 结果缓存 : null} + ) : ( + 未命中模板 + )} + {result.meta.cache_hit ? ( + 结果缓存 + ) : ( + 未命中结果缓存 + )} ) : null} - {(result.status === 'success' || result.status === 'degrade') && result.answer ? ( - - {result.answer} - {result.disclaimer ? ( + {['success', 'degrade', 'clarify', 'deny', 'error'].includes(result.status) ? ( + + ) : null} + {interpretResult?.answer ? ( + + {interpretResult.answer} + {interpretResult.disclaimer ? ( - {result.disclaimer} + {interpretResult.disclaimer} ) : null}