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}