diff --git a/app/api/analyst.py b/app/api/analyst.py index a54824f..b748cae 100644 --- a/app/api/analyst.py +++ b/app/api/analyst.py @@ -3,14 +3,16 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException -from app.api.deps import get_auth_context -from app.model.schemas.analyst import ( - AnalystResponse, - AssetCreateRequest, - ChatRequest, +from app.api.analyst_auth_adapter import ( + AnalystAuthContext, + AnalystAuthError, + analyst_auth_from_deps, + assert_analyst_query_access, ) +from app.api.deps import AuthContext, get_auth_context +from app.model.analyst_schemas import AnalystResponse, AssetCreateRequest, ChatRequest from app.service.analyst_agent import AnalystAgent -from app.utils.auth import AuthContext, assert_analyst_access +from app.utils.trace import current_trace router = APIRouter(prefix="/api/analyst", tags=["analyst"]) @@ -24,10 +26,14 @@ def get_agent() -> AnalystAgent: return _agent +def _analyst_ctx(auth: AuthContext = Depends(get_auth_context)) -> AnalystAuthContext: + return analyst_auth_from_deps(auth, trace_id=current_trace() or "") + + @router.post("/chat", response_model=AnalystResponse) def chat( req: ChatRequest, - auth: AuthContext = Depends(get_auth_context), + auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ) -> AnalystResponse: return agent.run(req.question, auth, req.session_id, req.trace_id) @@ -35,16 +41,23 @@ def chat( @router.get("/dashboard") def dashboard( - auth: AuthContext = Depends(get_auth_context), + auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """智能看数板(D-12)后端:按角色返回卡片。""" - domain = assert_analyst_access(auth) - role = next((r for r in auth.roles if r in ("analyst", "advisor", "risk_officer", "ops")), "analyst") + try: + domain = assert_analyst_query_access(auth) + except AnalystAuthError as exc: + raise HTTPException(status_code=403, detail=exc.message) from exc + role = next( + (r for r in auth.roles if r in ("analyst", "advisor", "risk_officer", "ops", "customer")), + "analyst", + ) cards = { "analyst": ["客户总数", "总持仓规模", "今日交易笔数与金额", "待处理预警数", "口径字典资产数"], "advisor": ["名下客户数", "名下资产规模", "盈亏分布", "风险等级分布"], "risk_officer": ["待处理预警数", "预警按类型分布", "近7天新增趋势"], + "customer": ["我的持仓规模", "近30日交易笔数", "风险等级", "盈亏概览"], "ops": ["近30天申购金额", "近30天赎回金额", "各产品类型规模TOP"], }.get(role, []) metrics: dict = {} @@ -65,7 +78,7 @@ def dashboard( @router.post("/assets") def create_asset( req: AssetCreateRequest, - auth: AuthContext = Depends(get_auth_context), + auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """沉淀资产(D-11):仅分析师可写。""" @@ -79,7 +92,7 @@ def create_asset( @router.get("/ops/metrics") def ops_metrics( - auth: AuthContext = Depends(get_auth_context), + auth: AnalystAuthContext = Depends(_analyst_ctx), agent: AnalystAgent = Depends(get_agent), ): """运营指标(N-08,P1 最小版)。""" diff --git a/app/api/analyst_auth_adapter.py b/app/api/analyst_auth_adapter.py new file mode 100644 index 0000000..1c0e404 --- /dev/null +++ b/app/api/analyst_auth_adapter.py @@ -0,0 +1,81 @@ +"""deps.AuthContext → 数据分析 Agent 内部视图(S3 接缝)。""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from app.api.deps import AuthContext as DepsAuthContext +from app.utils.trace import current_trace + +# Demo 四角色 + ops 预留;问数线宽于 chat 矩阵(customer 可进) +ROLE_DATA_DOMAIN: dict[str, str] = { + "customer": "self", + "advisor": "assigned", + "analyst": "full", + "risk_officer": "risk", + "ops": "aggregate", +} + +_ROLE_PRIORITY = ("customer", "advisor", "analyst", "risk_officer", "ops") + + +class AnalystAuthError(Exception): + def __init__(self, error_code: str, message: str) -> None: + super().__init__(message) + self.error_code = error_code + self.message = message + + +@dataclass +class AnalystAuthContext: + """分析线内部身份(subject_id 对齐 deps.actor_id)。""" + + subject_id: str + token_type: str + roles: list[str] + permissions: list[str] = field(default_factory=list) + customer_id: str | None = None + trace_id: str = "" + agent_type: str = "analyst" + + def has_role(self, role: str) -> bool: + return role in self.roles + + +def analyst_auth_from_deps( + ctx: DepsAuthContext, + *, + trace_id: str | None = None, +) -> AnalystAuthContext: + return AnalystAuthContext( + subject_id=ctx.actor_id, + token_type=ctx.token_type, + roles=list(ctx.roles), + permissions=list(ctx.permissions), + customer_id=ctx.customer_id, + trace_id=trace_id or current_trace() or "", + ) + + +def assert_analyst_query_access(ctx: AnalystAuthContext) -> str: + """问数入口鉴权:返回 sql_guard 数据域 key。失败抛 AnalystAuthError。""" + if ctx.token_type == "customer" or "customer" in ctx.roles: + if not ctx.customer_id: + raise AnalystAuthError("AUTH_403_ROLE", "客户 token 缺少 customer_id") + return "self" + if ctx.token_type != "staff": + raise AnalystAuthError("AUTH_403_ROLE", "数据分析问数需有效登录身份") + for role in _ROLE_PRIORITY: + if role in ctx.roles and role in ROLE_DATA_DOMAIN and role != "customer": + return ROLE_DATA_DOMAIN[role] + raise AnalystAuthError("AUTH_403_ROLE", "当前角色无权使用数据分析问数") + + +def resolve_analyst_scope(ctx: AnalystAuthContext, domain: str, repo: Any) -> list[str]: + """按域解析 customer_id 白名单(供 sql_guard)。""" + if domain == "self": + cid = ctx.customer_id or ctx.subject_id + return [cid] if cid else [] + if domain == "assigned": + return repo.resolve_advisor_scope(ctx.subject_id) + return [] diff --git a/app/config/settings.py b/app/config/settings.py index cc03e14..17ea701 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -103,12 +103,10 @@ class Settings(BaseSettings): # ===== 代销平台 API(v0.1)===== platform_response_desensitize: bool = False - # 数据分析 Agent - jwt_dev_secret: str = "dev-only-change-me" - jwt_token_ttl_hours: int = 24 - sql_max_rows: int = 1000 - sql_timeout_s: int = 10 - guardrail_retry_times: int = 1 + # ===== 数据分析 Agent(NL2SQL 问数线)===== + analyst_sql_max_rows: int = 1000 + analyst_sql_timeout_s: int = 10 + analyst_guardrail_retry_times: int = 1 settings = Settings() diff --git a/app/model/schemas/analyst.py b/app/model/analyst_schemas.py similarity index 96% rename from app/model/schemas/analyst.py rename to app/model/analyst_schemas.py index fe5b3ce..2535a39 100644 --- a/app/model/schemas/analyst.py +++ b/app/model/analyst_schemas.py @@ -8,6 +8,8 @@ DISCLAIMER = ( "据此操作风险自负,请谨慎对待。" ) +CUSTOMER_AI_RISK_NOTE = "AI 分析有风险,仅供参考。" + class ChatRequest(BaseModel): question: str = Field(..., description="自然语言问题") diff --git a/app/model/schemas/__init__.py b/app/model/schemas/__init__.py deleted file mode 100644 index 4875eba..0000000 --- a/app/model/schemas/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""数据分析 Agent Pydantic 模型(输出四件套等)。""" -from app.model.schemas.analyst import ( - DISCLAIMER, - AnalystResponse, - AssetCreateRequest, - ChatRequest, - Meta, - SampleRequest, - TableData, -) - -__all__ = [ - "DISCLAIMER", - "AnalystResponse", - "AssetCreateRequest", - "ChatRequest", - "Meta", - "SampleRequest", - "TableData", -] diff --git a/app/service/analyst_agent.py b/app/service/analyst_agent.py index b4f51d4..60e7e13 100644 --- a/app/service/analyst_agent.py +++ b/app/service/analyst_agent.py @@ -10,7 +10,19 @@ import time import uuid from typing import Any -from app.model.schemas.analyst import AnalystResponse, Meta, TableData +from app.model.analyst_schemas import ( + CUSTOMER_AI_RISK_NOTE, + DISCLAIMER, + AnalystResponse, + Meta, + TableData, +) +from app.api.analyst_auth_adapter import ( + AnalystAuthContext, + AnalystAuthError, + assert_analyst_query_access, + resolve_analyst_scope, +) from app.service.analytics_repo import AnalyticsRepo, classify_empty from app.service.cache_service import CacheService from app.service.dict_service import Ambiguity, MetricRegistry, default_registry @@ -18,7 +30,6 @@ from app.service.guardrail import GuardrailResult, verify from app.service.llm import DeepSeekLLM, estimate_cost, extract_sql from app.service.schema_meta import SCHEMA_PROMPT from app.service.sql_guard import SqlGuardError, validate -from app.utils.auth import AuthContext, assert_analyst_access SQL_GEN_SYSTEM = ( "你是金融数据查询助手。根据给定表结构与口径,把用户问题翻译成【一条】只读 SELECT SQL。" @@ -31,6 +42,12 @@ ANSWER_SYSTEM = ( "结尾无需重复免责声明(由系统统一附带)。若结果为空,如实说明。" ) +CUSTOMER_ANSWER_SYSTEM = ( + "你是面向个人客户的财富数据解读助手。只能根据查询结果描述趋势、分布与数量," + "不得给出投资建议、收益承诺、买卖时点或产品推荐。若用户问题涉及建议,只描述数据事实。" + "数字必须与结果完全一致,不得编造。" +) + class AnalystAgent: def __init__( @@ -49,7 +66,7 @@ class AnalystAgent: def run( self, question: str, - auth: AuthContext, + auth: AnalystAuthContext, session_id: str | None = None, trace_id: str | None = None, ) -> AnalystResponse: @@ -60,13 +77,11 @@ class AnalystAgent: cost_est = 0.0 try: - domain = assert_analyst_access(auth) - except Exception as exc: # noqa: BLE001 - return self._deny(str(getattr(exc, "error_code", "AUTH_403_ROLE")), str(exc), trace_id) + domain = assert_analyst_query_access(auth) + except AnalystAuthError as exc: + return self._deny(exc.error_code, exc.message, trace_id) - scope: list[str] = [] - if domain == "assigned": - scope = self.repo.resolve_advisor_scope(auth.subject_id) + scope: list[str] = resolve_analyst_scope(auth, domain, self.repo) # 1) 指标消歧(N-01) amb = self._detect_ambiguity(question) @@ -107,7 +122,7 @@ class AnalystAgent: # 5) 解读 + 数字护栏(D-10) try: answer, guard_result, g_usage = self._generate_verified_answer( - question, sql_text, table, empty_state + question, sql_text, table, empty_state, domain=domain ) cost_est += estimate_cost(g_usage) except Exception as exc: # noqa: BLE001 @@ -117,6 +132,11 @@ class AnalystAgent: 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: + answer = f"{answer.rstrip()} {CUSTOMER_AI_RISK_NOTE}" resp = AnalystResponse( answer=answer, table=table, @@ -129,6 +149,7 @@ class AnalystAgent: source="jinrong_core", cost_est=round(cost_est, 6), ), + disclaimer=disclaimer, status=status, trace_id=trace_id, ) @@ -161,6 +182,10 @@ class AnalystAgent: scope_hint = "无限制" if domain == "assigned": scope_hint = f"只能查询以下客户:customer_id IN ({', '.join(scope)});涉及客户的查询必须带此过滤" + elif domain == "self" and scope: + scope_hint = ( + f"只能查询客户 {scope[0]} 本人的数据;涉及客户表必须带 customer_id = '{scope[0]}' 条件" + ) elif domain == "aggregate": scope_hint = "只能输出聚合结果,禁止查单个客户或按 customer_id 分组/筛选" prompt = ( @@ -175,16 +200,20 @@ class AnalystAgent: return extract_sql(text), usage def _generate_verified_answer( - self, question: str, sql: str, table: TableData, empty_state: str + self, question: str, sql: str, table: TableData, empty_state: str, *, domain: str = "full" ) -> tuple[str, GuardrailResult | None, dict]: summary = self._summarize(table) empty_note = self._empty_note(empty_state) - answer, usage = self._generate_answer(question, sql, summary, empty_note) + answer, usage = self._generate_answer(question, sql, summary, empty_note, domain=domain) guard = verify(answer, table, None) if not guard.passed: # 重试 1 次(加强提示) answer2, usage2 = self._generate_answer( - question, sql, summary, empty_note + " 特别注意:所有数字必须与结果逐字一致。" + question, + sql, + summary, + empty_note + " 特别注意:所有数字必须与结果逐字一致。", + domain=domain, ) for k, v in usage2.items(): cur = usage.get(k) @@ -196,13 +225,16 @@ class AnalystAgent: return answer2, guard2, usage return answer, guard, usage - def _generate_answer(self, question: str, sql: str, summary: str, note: str) -> tuple[str, dict]: + def _generate_answer( + self, question: str, sql: str, summary: str, note: str, *, domain: str = "full" + ) -> tuple[str, dict]: + system = CUSTOMER_ANSWER_SYSTEM if domain == "self" else ANSWER_SYSTEM prompt = ( f"问题:{question}\nSQL:{sql}\n查询结果摘要:{summary}\n空态说明:{note}\n" f"请用 1~3 句人话解读:" ) return self.llm.complete( - [{"role": "system", "content": ANSWER_SYSTEM}, {"role": "user", "content": prompt}], + [{"role": "system", "content": system}, {"role": "user", "content": prompt}], temperature=0.2, max_tokens=500, ) @@ -232,6 +264,7 @@ class AnalystAgent: def _deny(self, code: str, msg: str, trace_id: str, domain: str = "") -> AnalystResponse: suggestions = { "AUTH_403_NOT_ASSIGNED": ["你仅能查名下客户的数据,可尝试问自己名下客户的持仓、风险分布等。"], + "AUTH_403_NOT_OWNER": ["你仅能查本人数据,可尝试问我的持仓、近30日交易笔数、风险等级等。"], "AUTH_403_SCOPE": ["你仅能查聚合数据,如近30天申购金额、各产品类型规模等。"], }.get(code) return AnalystResponse( diff --git a/app/service/sql_guard.py b/app/service/sql_guard.py index 018e623..5b089e7 100644 --- a/app/service/sql_guard.py +++ b/app/service/sql_guard.py @@ -149,6 +149,19 @@ def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None) raise SqlGuardError("AUTH_403_SCOPE", "涉及客户数据的查询必须包含归属过滤条件") result.has_customer_detail = touches_customer + elif domain == "self": + literals = extract_customer_literals(sql) + scope = set(scope_customer_ids or []) + if not scope: + raise SqlGuardError("AUTH_403_SCOPE", "客户问数缺少本人 customer_id") + 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 + elif domain == "risk" or domain == "risk_officer": # 台账全量 + 客户只读:允许白名单内全部表 result.has_customer_detail = any(t in CUSTOMER_TABLES for t in tables) diff --git a/app/utils/auth.py b/app/utils/auth.py deleted file mode 100644 index 5b5cf79..0000000 --- a/app/utils/auth.py +++ /dev/null @@ -1,110 +0,0 @@ -"""鉴权:Mock JWT(HS256,开发用)+ AuthContext + RBAC 角色判定。 - -生产对齐 docs/项目框架设计/技术选型和版本/02-JWT-RBAC鉴权手册.md(RS256 + IdP); -本模块只覆盖数据分析 Agent 开发/联调所需的最小身份与角色能力。 -""" -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from jose import jwt, JWTError - -from app.config.settings import settings - -ALGORITHM = "HS256" - - -class AuthError(Exception): - """鉴权失败,携带错误码(对齐 JWT 手册 §10)。""" - - def __init__(self, error_code: str, message: str) -> None: - super().__init__(message) - self.error_code = error_code - self.message = message - - -@dataclass -class AuthContext: - """注入给业务层的最小身份上下文。""" - - subject_id: str - token_type: str - roles: list[str] - permissions: list[str] = field(default_factory=list) - staff_type: str = "" - trace_id: str = "" - agent_type: str = "analyst" - - def has_role(self, role: str) -> bool: - return role in self.roles - - def has_perm(self, perm: str) -> bool: - return perm in self.permissions - - def to_dict(self) -> dict[str, Any]: - return { - "subject_id": self.subject_id, - "token_type": self.token_type, - "roles": self.roles, - "permissions": self.permissions, - "staff_type": self.staff_type, - "trace_id": self.trace_id, - "agent_type": self.agent_type, - } - - -def create_dev_token( - subject_id: str, - roles: list[str], - staff_type: str = "", - permissions: list[str] | None = None, - expires_hours: int | None = None, -) -> str: - """开发/联调用:签发员工 Token(HS256)。""" - ttl = expires_hours or settings.jwt_token_ttl_hours - claims: dict[str, Any] = { - "sub": subject_id, - "token_type": "staff", - "roles": roles, - "staff_type": staff_type, - "permissions": permissions or [], - } - return jwt.encode(claims, settings.jwt_dev_secret, algorithm=ALGORITHM) - - -def verify_token(token: str) -> AuthContext: - """验签并解析为 AuthContext;失败抛 AuthError(401)。""" - try: - claims = jwt.decode(token, settings.jwt_dev_secret, algorithms=[ALGORITHM]) - except JWTError as exc: - raise AuthError("AUTH_401_INVALID_TOKEN", "token 无效或已过期") from exc - return AuthContext( - subject_id=str(claims.get("sub", "")), - token_type=str(claims.get("token_type", "staff")), - roles=list(claims.get("roles", [])), - permissions=list(claims.get("permissions", [])), - staff_type=str(claims.get("staff_type", "")), - ) - - -# 数据分析 Agent 允许进入的员工角色(需求规格 §2.1 角色矩阵) -ANALYST_ROLES = {"analyst", "advisor", "risk_officer", "ops"} - -# 角色 → 需求规格 §2.1 的数据域 -ROLE_DATA_DOMAIN = { - "analyst": "full", # 全量 + 全明细 + 敏感列可见 - "advisor": "assigned", # 仅名下客户 + 明细 + 脱敏 - "risk_officer": "risk", # 台账全量 + 客户只读 + 客户脱敏 - "ops": "aggregate", # 无客户维度 + 仅聚合 -} - - -def assert_analyst_access(ctx: AuthContext) -> str: - """校验角色可进入数据分析 Agent,返回数据域。失败抛 AuthError(403)。""" - if ctx.token_type != "staff": - raise AuthError("AUTH_403_ROLE", "数据分析 Agent 仅限内部员工使用") - for role in ctx.roles: - if role in ANALYST_ROLES: - return ROLE_DATA_DOMAIN[role] - raise AuthError("AUTH_403_ROLE", "当前角色无权使用数据分析 Agent") diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index 9687828..a428cfa 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -5,7 +5,7 @@ ## 进行中 -**前端 P0 主链路真接(2026-09-09)**:ChatPanel · 游客/客户/风控/顾问/分析 Chat · 平台只读页 · 行情 · 风控台账处置 · **730 passed** · **19 Vitest** ✓ +**前端 P0 主链路真接(2026-09-09)**:ChatPanel · 游客/客户/风控/顾问/分析 Chat · 平台只读页 · 行情 · 风控台账处置 · **773 passed** · **19 Vitest** · **analytics 问数页已接** **Redis Docker(2026-09-09)**:`jinrong-redis` @ **6380** · `.env REDIS_URL` 已对齐 · `scripts/dev/start-redis.ps1` ✓ @@ -14,7 +14,7 @@ - [x] **代销平台 API v0.1 实现**(2026-09-08):`customers/products/advisors/staff/compliance` + `service/platform/` + `PLATFORM_RESPONSE_DESENSITIZE` · **530 passed 0 skipped** - [x] **`web/` P0 脚手架 init**(2026-09-09):Vite+AntD+HashRouter · 登录真接 · AppLayout · 四角色路由 · 占位页 · 见 `web/README.md` · 设计 `docs/superpowers/specs/2026-09-09-frontend-p0-design.md` - [ ] **接口契约发群**(login + 平台读 API + simulate/trade;强调重复功能以平台路径为准) -- [ ] **前端 P0 真接**(主链路已完成;余 analytics 问数 · 契约发群 · simulate 说明) +- [ ] **前端 P0 真接**(analytics 问数已接 · 契约发群 · simulate 说明) - [ ] 同步 `MEMORY/REQUIREMENTS/FRAMEWORK` 与各 Agent 负责人联调节奏 ### 前端 `web/` · P0 待接(后端已就绪) @@ -32,7 +32,7 @@ - [x] **risk 预警台账全页**:`GET /api/risk/alerts` 筛选/分页表格 + `POST .../handle` 处置 UI - [x] **advisor Chat**:SSE + AgentBanner · 名下客户列表页 - [x] **`/app/analytics/chat`**:SSE · `X-Agent-Type: analyst` -- [ ] **`/app/analytics/query`**:PermissionGate + 问数 Mock/403 展示 +- [x] **`/app/analytics/query`**:`AnalystQueryPanel` · `POST /api/analyst/chat` · 四角色权限内问数 - [ ] **Vitest**:`authStore` · SSE 解析已绿(19 例)· 可补 ChatPanel hook 测试 - [ ] **前端 P0 收尾**:接口契约发群 · simulate 交易 Disabled 菜单说明 @@ -57,6 +57,17 @@ - [x] **客服 RAG 接 fin_* 三库**:`VisitorRagService` → `search_cs_knowledge`(`fin_product` / `fin_policy` / `fin_faq`);宿主 Tool 仍走 `kb_product_rules`(T-21) - [x] **tool 层实现 + 种子文档**:`document_parser` / `embedding_tool` / `milvus_tool` + `data/kb_collections/*`(2026-09-09) +### 数据分析 Agent · S3 接缝(2026-09-09 已接线) + +> 清单:`docs/项目框架设计/数据分析Agent-合并说明.md` · merge `data-analysis-agent-work` · **773 passed** + +- [x] **merge + 接缝**:`analyst_router` · `analyst_auth_adapter` · `analyst_schemas` · 废弃 `utils/auth.py` +- [x] **鉴权 G1–G4 + G8**:`assert_analyst_query_access` · customer `self` 域 · 尾注「AI 分析有风险」 +- [x] **Wave6 测试**:`test_wave6_*` · +43 例 +- [x] **前端问数页**:`web/src/pages/analytics/AnalystQueryPage.tsx` · `api/analyst.ts` +- [x] **迁移 SQL(本机)**:`scripts/agent/migrate-analyst-d07-d11.sql`(三资产表 · 2026-09-09 已执行 · **无种子数据**) +- [ ] **Scope B 冒烟**:四 Demo 角色各一条问数(需 MySQL + 可选 DeepSeek Key) + ### 风控 Agent · 后端已就绪 · 前端/运维未接(盘点 2026-09-09) **HTTP 接口(后端已实现 · 前端/演示未接)** diff --git a/docs/项目框架设计/数据分析Agent-合并说明.md b/docs/项目框架设计/数据分析Agent-合并说明.md new file mode 100644 index 0000000..1203f10 --- /dev/null +++ b/docs/项目框架设计/数据分析Agent-合并说明.md @@ -0,0 +1,358 @@ +# 数据分析 Agent · 从 `data-analysis-agent-work` 合并说明 + +> 日期:2026-09-09 +> 远程分支:`xinghuo/data-analysis-agent-work` @ `b19a241`(zyi) +> 原则:**只增不盖**;与 `merger` 同路径冲突时 **以 merger 为准**(不覆盖 `main.py` / `deps.py` / `chat.py` / `agent_service.py` 宿主骨架)。 +> 状态:**接缝设计稿(未 merge、未接线)** + +--- + +## 0. 一句话定位 + +数据分析 Agent = **NL2SQL 只读查数线**:自然语言 → 安全 SQL → 表格 + 解读 + 留痕;与宿主 **通用对话线**(`agent_service` 轻提示词 Chat)是两条不同产物,接缝上 **并行挂载**,不替换 `agent_service.py`。 + +--- + +## 1. 合并什么(新增文件清单) + +从 `data-analysis-agent-work` **整包迁入**(无冲突或仅新增路径): + +| 类别 | 路径 | 作用 | +| --- | --- | --- | +| 路由 | `app/api/analyst.py` | `/api/analyst/chat` · `/dashboard` · `/assets` · `/ops/metrics` | +| 编排 | `app/service/analyst_agent.py` | LangGraph StateGraph(input_guard → … → audit_persist) | +| 安全 | `app/service/sql_guard.py` `guardrail.py` | 五层 SQL 白名单 + 数字护栏 | +| 支撑 | `analytics_repo.py` `dict_service.py` `cache_service.py` `schema_meta.py` | 留痕/口径/缓存/元数据 | +| LLM | `app/service/llm.py` | DeepSeek 封装(分析线专用;与 merger `embedding.py` 不冲突) | +| 模型 | `app/model/schemas/analyst.py` | 输出四件套 Pydantic(answer/table/sql/meta) | +| 实体 | `app/model/entities/analytics.py`(若远程有) | analytics_* ORM 映射 | +| 工具 | `app/tool/sql_tool.py`(若远程有) | 只读执行 + 行数/超时 | +| 文档 | `数据分析Agent架构说明书.md` `数据分析Agent开发清单.md` `03-mysql-analyst专用.sql` | 架构 / 任务 / DDL | +| 测试 | 见 §6 | 重命名后迁入,避免覆盖 merger 730 基线 | + +**不迁入(冲突 · 保留 merger):** + +| 文件 | 原因 | +| --- | --- | +| `app/main.py` | AL-09 全量宿主入口;仅 **追加** `include_router(analyst_router)` | +| `app/api/deps.py` | merger 完整 JWT + 归属 + 矩阵(382 行);远程版被砍成 22 行 stub | +| `app/api/chat.py` `app/service/agent_service.py` | 四 Agent 通用对话线已集成 | +| `app/config/settings.py` | merger 含 risk/customer/visitor/JWT 全量;**追加** `analyst_*` 字段 | +| `app/model/schemas.py` | 保留单文件;分析专用模型放 **`schemas/analyst.py` 子模块**(见 §3.3) | +| `app/repository/core_ro.py` | merger 版含 `check_suitability` 等平台能力;分析线只 **调用** 不覆盖 | +| `app/utils/auth.py` | 远程第二套鉴权(`subject_id`);**废弃**,改 deps 适配(§4) | +| `docs/memory/*`、前端 `web/`、风控/平台 API | 全部保留 merger | + +--- + +## 2. 数据库(需手工执行) + +### 2.1 已有(merger canonical) + +`docs/项目框架设计/表设计/02-mysql-agent专用.sql` 已含 **`analytics_query_log`**(NL2SQL 留痕)。merger `entities.py` 已有 ORM。**无需重复建表**(若本机已跑过 02 迁移)。 + +### 2.2 新增(分析资产三表 · D-07 / D-11) + +远程 `03-mysql-analyst专用.sql` → 独立迁移脚本(不修改 canonical 02 文件): + +```text +scripts/agent/migrate-analyst-d07-d11.sql + → analytics_metric_dict 口径字典 + → analytics_few_shot 问题→SQL 示例 + → analytics_query_template 参数化模板 +``` + +执行时机:merge 代码后、跑分析线单测/E2E 前;库 `jinrong_agent`,前置 01/02 共用底座已存在。 + +### 2.3 Redis + +复用 merger Docker Redis(`:6380`)。分析线 `cache_service` 使用独立 key 前缀(对齐 `02-redis-keys.md` 分析段);与客服/游客/风控 key **命名空间隔离**,同实例不冲突。 + +--- + +## 3. S3 接缝设计(推荐方案 · 待你确认 §8) + +### 3.1 入口策略:**并行双轨**(推荐) + +| 入口 | 行为 | 前端页面 | +| --- | --- | --- | +| **`POST /api/analyst/chat`** | `AnalystAgent.run()` → JSON 四件套(table/sql/meta/disclaimer) | **`/app/analytics/query` 问数工作台**(主链路) | +| `GET /api/analyst/dashboard` | D-12 智能看数卡片 | `/app/analyst/home` 可接 metrics | +| `POST /api/analyst/assets` | D-11 资产沉淀 | 问数页「沉淀」按钮(P1 后做) | +| `GET /api/analyst/ops/metrics` | N-08 运营指标 | 分析员运维面板(P1 后做) | +| `POST /api/chat` + `X-Agent-Type: analyst` | **仍走 `agent_service`**(轻量对话 stub,无 NL2SQL) | **`/app/analytics/chat` 分析对话**(可选保留) | + +**为何不把 analyst 并入 `chat.py` 分流?** + +- 问数响应含 **结构化 table + sql + meta**,与 Chat 的 `{reply, has_disclaimer}` 形状不同;硬塞进 SSE 需额外 adapter 层,收益低。 +- 架构说明书 §13 已定义独立 `/api/analyst/*`;与客服 S2「customer 分流」场景不同(customer 仍是 Chat 形态)。 + +**`main.py` 改动(唯一宿主挂载点):** + +```python +from app.api.analyst import router as analyst_router +app.include_router(analyst_router) # prefix 已在 analyst.py 内:/api/analyst +``` + +### 3.2 鉴权接缝:**deps 为准 + 薄适配** + +远程代码混用 `deps.get_auth_context` 与 `utils.auth.AuthContext`(字段 `subject_id` vs `actor_id`)。合并后: + +1. **删除**迁入后的 `app/utils/auth.py`(或保留空 shim 仅 re-export,最终删除)。 +2. 新增 `app/api/analyst_auth_adapter.py`(或扩展现有 `auth_adapter.py`): + +```python +def analyst_auth_from_deps(ctx: deps.AuthContext) -> AnalystAuthView: + """deps.AuthContext → 分析线内部视图(仅字段映射,不做权限判定)。""" + return AnalystAuthView( + subject_id=ctx.actor_id, # 分析 repo 写 staff_id 用 + roles=ctx.roles, + permissions=ctx.permissions, + token_type=ctx.token_type, + trace_id=current_trace() or "", + agent_type="analyst", + ) +``` + +3. `analyst.py` 路由层:`auth = Depends(get_auth_context)` → 适配后传入 `AnalystAgent.run()`。 +4. 远程 `assert_analyst_access(auth)` 逻辑 **迁入** `deps.py` 或 `app/utils/authz.py`(与 `assert_customer_access` 并列),使用 `actor_id` 命名。 +5. **`X-Agent-Type`**:`/api/analyst/*` **不要求**该头(路由即 analyst);JWT 矩阵仍校验 token 角色含 `analyst`/`compliance`/`advisor`/`risk_officer`/`ops`(按远程 `assert_analyst_access` 域划分)。 + +### 3.3 Schemas:**单文件 + 子模块共存** + +merger 当前 `app/model/schemas.py` 为单文件。合并策略: + +- **新增** `app/model/schemas/analyst.py`(仅分析请求/响应)。 +- **不**把 `schemas.py` 改成包(避免大面积 import 破坏);`analyst.py` 路由 `from app.model.schemas.analyst import ...` 直引子路径。 +- 若 Python 包解析与 `schemas.py` 文件冲突:将子模块改为 `app/model/analyst_schemas.py`(备选,merge 时二选一)。 + +### 3.4 settings 追加字段(合并冲突手工解) + +在 merger `settings.py` **追加**(取自远程,命名对齐架构说明书): + +| 字段 | 用途 | +| --- | --- | +| `analyst_llm_model` / `analyst_llm_timeout` | DeepSeek NL2SQL | +| `analyst_sql_row_limit` / `analyst_sql_timeout_sec` | 只读执行上限 | +| `analyst_cache_ttl_sec` | 结果缓存 | +| `analyst_rate_limit_per_min` | 问数限流 | + +`.env.example` 同步追加;**不**覆盖 merger 已有 `jwt_*` / `risk_*` / `customer_*`。 + +### 3.5 requirements + +merger 已有 `langgraph`;**追加** `sqlglot>=25.0.0`(远程新增,SQL AST 白名单硬依赖)。 + +### 3.6 core_ro / llm 边界 + +| 模块 | 策略 | +| --- | --- | +| `core_ro.py` | 保留 merger;`analytics_repo.execute_readonly` 走 **agent 库只读账号** 或现有 Core RO 连接,不 duplicate | +| `llm.py` | 迁入分析线专用;`AnalystAgent` 只 import 此模块 | +| `input_guard` | 分析线复用 merger `app/service/input_guard.py`(限流/注入),不保留远程 duplicate `utils/input_guard.py` | + +--- + +## 4. 前端接缝(merger 现状 → 目标) + +### 4.1 现状 + +| 路由 | 现状 | +| --- | --- | +| `/app/analytics/query` | **PlaceholderPage**(待 NL2SQL) | +| `/app/analytics/chat` | `ChatPanel` + `agentType="analyst"` → `POST /api/chat/stream`(无 table/sql) | + +### 4.2 推荐(与 §3.1 双轨一致) + +| 页面 | API | UI | +| --- | --- | --- | +| **问数工作台** `/app/analytics/query` | `POST /api/analyst/chat` | 新问题组件:`AnalystQueryPanel`(问题框 + 解读 + Ant Design Table + SQL 折叠 + disclaimer) | +| **分析对话** `/app/analytics/chat` | 暂保留 `ChatPanel` SSE | 页顶 Banner 提示:「复杂查数请用问数工作台」;P2 可下线或改跳转 | + +新增 `web/src/api/analyst.ts`: + +```typescript +export interface AnalystChatResponse { + answer: string + table: { columns: string[]; rows: unknown[][] } + sql: string + meta: { exec_ms: number; row_count: number; cache_hit: boolean; data_as_of?: string } + disclaimer: string + status: string + trace_id?: string +} +``` + +**不**强行让 `ChatPanel` 解析四件套(职责分离)。 + +### 4.3 首页看板 + +`AnalystMarketDashboard` QuickAction「问数工作台」已指向 `/app/analytics/query`;merge 后可接 `GET /api/analyst/dashboard` 填充 metrics 卡片(P1)。 + +--- + +## 5. 需求 ID 对齐 + +| 需求 | 合并后落点 | 阶段 | +| --- | --- | --- | +| D-01~D-04 | `analyst_agent` + `sql_guard` + `analytics_query_log` | merge 后冒烟 | +| D-05 | 多表只读聚合(sql_generate 节点) | 同上 | +| D-06 | `cache_service` 双层缓存 | 同上 | +| D-07 | `dict_service` + `analytics_metric_dict` | 需跑 migrate-analyst | +| D-08~D-10 | 拒答分支 + `guardrail` | 同上 | +| D-11 | `/assets` + 三资产表 | migrate + 前端 P1 | +| D-12 | `/dashboard` | 首页 P1 | +| N-01~N-08 | 见架构说明书 §4/§6 | 单测覆盖 | + +--- + +## 6. 测试策略 + +### 6.1 远程测试文件 → 迁入命名 + +| 远程 | 迁入后 | 说明 | +| --- | --- | --- | +| `tests/test_agent.py` | `tests/test_wave6_analyst_agent.py` | 改 import:`utils.auth` → `analyst_auth_adapter` | +| `tests/test_sql_guard.py` | `tests/test_wave6_sql_guard.py` | 无冲突,直迁 | +| `tests/test_guardrail.py` | `tests/test_wave6_guardrail.py` | 若 merger 已有同名则合并 case | +| `tests/test_dict_service.py` | `tests/test_wave6_dict_service.py` | | +| `tests/test_cache_service.py` | `tests/test_wave6_cache_service.py` | merger 已有 `test_cache_service.py` → **合并或 rename 远程** | +| `tests/test_llm.py` | `tests/test_wave6_analyst_llm.py` | 避免与 merger LLM 测试混淆 | + +### 6.2 基线要求 + +- merge + 适配完成后:`python -m pytest` → **730 + N passed**(N = 新增 wave6 条数)。 +- 禁止恢复远程对 `deps.py` / `conftest.py` 的删减。 + +### 6.3 冒烟清单(Scope B · merge 后) + +1. `POST /api/analyst/chat` + analyst JWT → 200 + 四件套 JSON +2. 顾问 token 查他人客户 → 403 + `analytics_query_log(blocked)` +3. `GET /api/analyst/dashboard` → 200 + cards +4. 前端 `/app/analytics/query` 真跑一条问数 +5. `POST /api/chat/stream` + analyst → 仍 200(stub 未回归) + +--- + +## 7. Merge 操作顺序(执行 SOP · 尚未做) + +```text +1. git worktree add ../JinRong-analyst-merge data-analysis-agent-work # 可选隔离 +2. git merge data-analysis-agent-work --no-commit # 或 cherry-pick 新增文件 +3. 冲突文件按 §1「不迁入」表逐项保留 merger 版 +4. 手工:analyst_auth_adapter · main.py include_router · settings 追加 · requirements sqlglot +5. 删除 utils/auth.py · 改 analyst.py / analyst_agent.py import +6. 执行 scripts/agent/migrate-analyst-d07-d11.sql +7. pytest 全绿 → 前端 AnalystQueryPanel → Scope B 冒烟 +8. 更新 docs/memory/TODO.md · FRAMEWORK 实现状态表 +``` + +--- + +## 8. 已确认决策(2026-09-09) + +| # | 决策 | 结论 | +| --- | --- | --- | +| **Q1** | 问数主入口 | **A · 独立 `POST /api/analyst/chat`**;各角色在权限范围内自助查数(见 §9) | +| **Q2** | `/app/analytics/chat` | **A · 保留** ChatPanel stub + Banner 引导问数工作台 | +| **Q3** | schemas 路径 | **A · `app/model/schemas/analyst.py`**(merge 时若与 `schemas.py` 包冲突再改 `analyst_schemas.py`) | +| **Q4** | merge 方式 | **A · `git merge` + 手工解冲突**(默认,未单独拍板则按此执行) | + +--- + +## 9. 鉴权接缝 · 角色矩阵与缺口(**merge 前必须补**) + +### 9.1 目标口径(产品确认) + +> **每个 Demo 账户角色都能调用数据分析模块,只看其权限范围内的数据。** + +前端已把「数据分析」放进 **客户 / 理财师 / 风控** 的 `sharedPlatform` 菜单(`/app/analytics/query`);后端远程实现 **尚未覆盖 customer**,且与 merger `deps` 矩阵不一致,需在 S3 接缝一并补齐。 + +### 9.2 目标角色 × 数据域(接缝后应达到) + +> Demo 仅四角色:**customer · advisor · analyst · risk_officer**(无 compliance / risk_manager 独立账号;问数线不单独开域)。 + +| 角色 | token | 数据域 key | 可见范围 | sql_guard 要点 | +| --- | --- | --- | --- | --- | +| **customer** | customer | **`self`(待实现)** | 仅本人可读数据(持仓/流水/风评/净值趋势等) | 禁止其他 `CUST-*`;强制 `customer_id = auth.customer_id`;**见 §9.6 产品约束** | +| **advisor** | staff | `assigned` | 名下客户(`core_customer_advisor` active) | 远程已有;`inject_ownership` + 白名单 | +| **analyst** | staff | `full` | 全量 + 敏感列策略 | 远程已有 | +| **risk_officer** | staff | `risk` | 预警台账全量 + 客户/持仓/交易只读(脱敏列) | 远程已有;**表域评估见 §9.7** | +| **ops** | staff | `aggregate` | 无客户维度,仅聚合 | 远程已有(非 Demo 角色,预留) | + +### 9.6 客户问数 · 产品约束(2026-09-09 确认) + +客户走 **`self` 域**,能力边界如下(merge 时在 `analyst_agent` 解读阶段 + prompt 硬约束): + +| 项 | 口径 | +| --- | --- | +| **允许** | 基于本人可读数据的**趋势总结、分布描述、数量统计**(如持仓结构、近 N 日交易笔数、盈亏区间描述) | +| **禁止** | **投资建议、收益承诺、买卖时点、产品推荐**;命中则拒答或降级为「仅展示表格」 | +| **尾部声明** | 在标准 `disclaimer` 之外,客户域回复**追加**:「**AI 分析有风险,仅供参考。**」 | +| **留痕** | `analytics_query_log.actor_id = customer_id`,`actor_role = customer` | + +实现落点(merge 时): + +- `scope_resolve`:`token_type=customer` → `domain=self`,`scope=[auth.customer_id]` +- `sql_guard.validate(domain=self)`:同 advisor 归属逻辑,但 scope 固定单人 +- `_generate_sql` / `ANSWER_SYSTEM`:注入「仅描述数据、不给建议」 +- `answer_compose`:`domain=self` 时 append 客户专用尾注 + +### 9.7 风控专员 · 表域是否够用(2026-09-09 评估) + +**结论:够用。** 风控 Agent **自动监督触发的结果**(规则引擎出单、AML 命中、适当性拦截、集中度/代理人行为链等)**权威落点都在现有白名单内**;风控专员问数线是**只读查台账与关联上下文**,不通过 NL2SQL 触发处置或扫名单。 + +| 监督链路(风控 Agent / 引擎) | 落库/可读表 | 已在 sql_guard 白名单 | +| --- | --- | --- | +| RISK-001~006 交易/持仓规则 | `risk_alert`(`alert_type` + `payload`/`triggered_rules`) | ✅ | +| RISK-006 集中度 | `risk_alert`(`pattern` + `payload.alert_subtype=concentration`)+ `core_holding` | ✅ | +| RISK-008 代理人行为链 | `risk_alert`(`pattern` + `payload.alert_subtype=agent_behavior`) | ✅ | +| AML 命中 | `risk_alert`(`alert_type=aml`)+ `customer_profile_l3` 标记 | ✅ | +| 适当性拦截 | `risk_alert`(`alert_type=suitability`) | ✅ | +| 客户监测画像 | `customer_profile_l1/l2/l3` | ✅ | +| 关联上下文(客户/持仓/交易/产品) | `core_*` 系列 | ✅ | + +**刻意不进白名单(安全/职责分离):** + +| 表 | 原因 | +| --- | --- | +| `risk_aml_list` | 反洗钱**名单本体**;命中结果已在 `risk_alert`,不应 NL2SQL 直查名单库 | +| `audit_log` | 审计明细走 HTTP/专用 Tool;问数以 `risk_alert` 聚合即可 | + +**可选 P2(非阻塞 merge):** 若后续要问「适当性判定历史通过率/明细」,可把 `risk_suitability_log` 加入白名单并在 `risk` 域只读;当前 Demo 问数(待处理预警数、按类型分布、客户监测分布)**不依赖**该表。 + +**域规则:** 维持远程 `domain=risk`(台账全量 + 白名单内客户只读),**不新增** compliance/manager 域;与现网风控 Demo(`STAFF-30001` · risk_officer)一致即可。 + +### 9.3 远程 vs merger 缺口清单 + +| 缺口 | 远程现状 | merger 现状 | 接缝动作(记入 TODO) | +| --- | --- | --- | --- | +| **G1 customer 不可用** | `assert_analyst_access` 要求 `token_type=staff` | 平台 G-01 支持 customer 本人 | 新增域 `self`;`/api/analyst/*` 入口允许 customer token | +| **G2 sql_guard 无 self 域** | 仅 full/assigned/risk/aggregate | — | `validate()` + `inject_ownership()` 支持 `self` + 强制 `customer_id = auth.customer_id` | +| **G3 双套 AuthContext** | `subject_id` + `utils/auth.py` | `actor_id` + `deps.py` | §3.2 适配器;**废弃** remote auth | +| **G4 入口矩阵分裂** | `/api/analyst/*` 用 `ANALYST_ROLES` 四角色 | `AGENT_ACCESS_MATRIX["analyst"]` 仅 analyst+compliance(**对话线**) | 问数路由 **不走** chat 矩阵;`assert_analyst_query_access()` 覆盖 **customer/advisor/analyst/risk_officer** | +| ~~**G5**~~ | — | — | **已关闭**:无 compliance/risk_manager Demo 角色,不单独开域 | +| **G6 留痕 actor** | `staff_id=subject_id` | customer 无 staff_id | `analytics_query_log` 写 `actor_id` + `actor_role`;customer 写 `customer_id` | +| **G7 前端 PermissionGate** | — | 问数页仍 Placeholder | 403 展示可查范围引导(对齐 `AnalystResponse.suggestions`) | +| **G8 客户解读约束** | 远程无 customer 域 | — | §9.6:趋势总结 · 禁建议 · 尾部「AI 分析有风险」 | + +### 9.4 推荐实现落点(merge 时) + +```text +app/utils/authz.py # 新增 assert_analyst_query_access(auth) → domain +app/service/sql_guard.py # 扩展 domain=self(§9.6 客户约束) +app/api/analyst.py # Depends(get_auth_context) → assert_analyst_query_access → adapter → agent.run +app/api/analyst_auth_adapter.py # actor_id ↔ 分析线内部视图(可选 NamedTuple) +``` + +**`/api/chat` analyst stub** 仍走现有 `AGENT_ACCESS_MATRIX`(analyst/compliance 轻聊,Demo 无 compliance 账号);与问数线 **刻意分离**。 + +--- + +## 10. 修订记录 + +| 日期 | 说明 | +| --- | --- | +| 2026-09-09 | 初稿:远程 `b19a241` 审阅 + merger 730 基线对照;**未 merge** | +| 2026-09-09 | Q1=A Q2=A;§9 鉴权缺口与角色矩阵(含 customer self 域待实现) | +| 2026-09-09 | 产品拍板:§9.6 客户趋势总结/禁建议/AI 风险尾注;§9.7 risk 表域够用;关闭 G5 | diff --git a/scripts/agent/migrate-analyst-d07-d11.sql b/scripts/agent/migrate-analyst-d07-d11.sql new file mode 100644 index 0000000..89c14bd --- /dev/null +++ b/scripts/agent/migrate-analyst-d07-d11.sql @@ -0,0 +1,61 @@ +-- ============================================================================= +-- 数据分析 Agent 专用 MySQL(3 张 · 养 Agent 资产) +-- 执行:merge 数据分析 Agent 后 · jinrong_agent 库 +-- 来源:docs/项目框架设计/表设计/03-mysql-analyst专用.sql +-- ============================================================================= + +USE jinrong_agent; + +CREATE TABLE IF NOT EXISTS analytics_metric_dict ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + metric_key VARCHAR(128) NOT NULL COMMENT '唯一键,如 holding_scale', + metric_name VARCHAR(128) NOT NULL COMMENT '持仓规模', + aliases JSON NULL COMMENT '同义词:["规模","市值"]', + definition TEXT NOT NULL COMMENT '口径定义', + formula TEXT NULL COMMENT '计算公式', + applicable_tables JSON NULL COMMENT '适用表清单', + default_time_window VARCHAR(64) NULL COMMENT '默认时间窗', + unit VARCHAR(32) NULL COMMENT '单位:元/万元/%', + status ENUM('draft','published','rolled_back') NOT NULL DEFAULT 'draft', + version INT UNSIGNED NOT NULL DEFAULT 1, + gray_roles JSON NULL COMMENT '灰度:null=全量,["advisor"]=部分角色', + created_by VARCHAR(64) NOT NULL, + published_by VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_metric (metric_key, version), + KEY idx_status (status, metric_key) +) ENGINE=InnoDB COMMENT='【分析专用】口径字典(D-07/D-11)'; + +CREATE TABLE IF NOT EXISTS analytics_few_shot ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + question TEXT NOT NULL, + sql_text TEXT NOT NULL, + tags JSON NULL, + source_session_id VARCHAR(64) NULL COMMENT '溯源会话', + status ENUM('draft','published','rolled_back') NOT NULL DEFAULT 'draft', + version INT UNSIGNED NOT NULL DEFAULT 1, + gray_roles JSON NULL, + created_by VARCHAR(64) NOT NULL, + published_by VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + KEY idx_status (status, id) +) ENGINE=InnoDB COMMENT='【分析专用】few-shot 示例(D-11)'; + +CREATE TABLE IF NOT EXISTS analytics_query_template ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + template_key VARCHAR(128) NOT NULL, + template_sql TEXT NOT NULL COMMENT '参数化 SQL,如 WHERE risk_code IN (:risk_codes)', + params_schema JSON NULL COMMENT '参数定义', + tags JSON NULL, + source_session_id VARCHAR(64) NULL, + status ENUM('draft','published','rolled_back') NOT NULL DEFAULT 'draft', + version INT UNSIGNED NOT NULL DEFAULT 1, + gray_roles JSON NULL, + created_by VARCHAR(64) NOT NULL, + published_by VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + KEY idx_status (status, template_key) +) ENGINE=InnoDB COMMENT='【分析专用】快速模板(D-06/D-11)'; diff --git a/scripts/agent/reset-analyst-d07-d11.sql b/scripts/agent/reset-analyst-d07-d11.sql new file mode 100644 index 0000000..3adfe50 --- /dev/null +++ b/scripts/agent/reset-analyst-d07-d11.sql @@ -0,0 +1,14 @@ +-- ============================================================================= +-- 数据分析 Agent 资产表 · 清数据(不删表结构) +-- 用途:开发/演示重置口径字典、few-shot、模板;不影响其它 agent 表 +-- 问数留痕 analytics_query_log 在 02 底座里,可选一并清空(见下方注释) +-- ============================================================================= + +USE jinrong_agent; + +TRUNCATE TABLE analytics_metric_dict; +TRUNCATE TABLE analytics_few_shot; +TRUNCATE TABLE analytics_query_template; + +-- 可选:清空 NL2SQL 问数留痕(取消注释即可) +-- TRUNCATE TABLE analytics_query_log; diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 5fac71b..0000000 --- a/tests/test_api.py +++ /dev/null @@ -1,81 +0,0 @@ -"""API 层测试(FastAPI TestClient)。""" -import unittest - -from fastapi.testclient import TestClient - -from app.main import app -from app.utils.auth import create_dev_token - -client = TestClient(app) - - -def analyst_token(): - return create_dev_token("STAFF-API", ["analyst"], "analyst") - - -def advisor_token(): - return create_dev_token("STAFF-ADV", ["advisor"], "advisor") - - -class TestApi(unittest.TestCase): - def test_health(self): - r = client.get("/health") - self.assertEqual(r.status_code, 200) - - def test_chat_no_token(self): - r = client.post("/api/analyst/chat", json={"question": "客户总数"}) - self.assertEqual(r.status_code, 401) - - def test_chat_bad_token(self): - r = client.post( - "/api/analyst/chat", - json={"question": "客户总数"}, - headers={"Authorization": "Bearer bad"}, - ) - self.assertEqual(r.status_code, 401) - - def test_chat_success(self): - r = client.post( - "/api/analyst/chat", - json={"question": "客户总数是多少"}, - headers={"Authorization": f"Bearer {analyst_token()}"}, - ) - self.assertEqual(r.status_code, 200) - data = r.json() - self.assertIn(data["status"], ("success", "degrade")) - self.assertIn("answer", data) - self.assertIn("table", data) - - def test_dashboard(self): - r = client.get( - "/api/analyst/dashboard", - headers={"Authorization": f"Bearer {analyst_token()}"}, - ) - self.assertEqual(r.status_code, 200) - self.assertIn("cards", r.json()) - - def test_assets_advisor_forbidden(self): - r = client.post( - "/api/analyst/assets", - json={"kind": "dict", "payload": {"metric_key": "x"}}, - headers={"Authorization": f"Bearer {advisor_token()}"}, - ) - self.assertEqual(r.status_code, 403) - - def test_assets_analyst_ok(self): - import uuid - key = f"test_k_{uuid.uuid4().hex[:8]}" - r = client.post( - "/api/analyst/assets", - json={ - "kind": "dict", - "payload": {"metric_key": key, "metric_name": "测试指标", "definition": "测试口径"}, - }, - headers={"Authorization": f"Bearer {analyst_token()}"}, - ) - self.assertEqual(r.status_code, 200) - self.assertTrue(r.json()["ok"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 6b68d53..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,73 +0,0 @@ -"""集成测试:真实 MySQL + 真实 DeepSeek,覆盖 §7 验收场景核心路径。""" -import unittest - -from app.service.analyst_agent import AnalystAgent -from app.service.analytics_repo import AnalyticsRepo -from app.service.llm import DeepSeekLLM -from app.utils.auth import AuthContext - - -class TestIntegration(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.repo = AnalyticsRepo() - cls.agent = AnalystAgent(llm=DeepSeekLLM(), repo=cls.repo) - r = cls.repo.execute_readonly( - "SELECT staff_id FROM core_staff WHERE staff_type='advisor' AND is_active=1 LIMIT 1" - ) - cls.advisor_id = r["rows"][0][0] - cls.scope = cls.repo.resolve_advisor_scope(cls.advisor_id) - all_cust = [ - row[0] - for row in cls.repo.execute_readonly( - "SELECT customer_id FROM core_customer WHERE is_active=1 LIMIT 100" - )["rows"] - ] - cls.out_customer = next(c for c in all_cust if c not in cls.scope) - - def _ctx(self, roles, subject): - return AuthContext(subject_id=subject, token_type="staff", roles=roles, staff_type=roles[0]) - - def test_analyst_holding_by_product(self): - resp = self.agent.run("按产品类型统计总持仓规模", self._ctx(["analyst"], "STAFF-20001")) - self.assertIn(resp.status, ("success", "degrade")) - self.assertTrue(resp.sql) - - def test_analyst_holding_pnl_sort(self): - resp = self.agent.run("把客户持仓按盈亏排序", self._ctx(["analyst"], "STAFF-20001")) - self.assertIn(resp.status, ("success", "degrade")) - self.assertTrue(resp.sql) - - def test_advisor_own_scope(self): - resp = self.agent.run("我名下客户有多少高风险", self._ctx(["advisor"], self.advisor_id)) - self.assertIn(resp.status, ("success", "degrade")) - - def test_advisor_out_of_scope_denied(self): - resp = self.agent.run(f"查 {self.out_customer} 的持仓", self._ctx(["advisor"], self.advisor_id)) - self.assertEqual(resp.status, "deny") - self.assertEqual(resp.error_code, "AUTH_403_NOT_ASSIGNED") - - def test_risk_officer_pending_alerts(self): - resp = self.agent.run("当前待处理预警有多少", self._ctx(["risk_officer"], "STAFF-30001")) - self.assertIn(resp.status, ("success", "degrade")) - - def test_ops_aggregate(self): - resp = self.agent.run("近30天申购金额总额", self._ctx(["ops"], "STAFF-50001")) - self.assertIn(resp.status, ("success", "degrade")) - - def test_cache_hit(self): - q = "客户总数是多少" - r1 = self.agent.run(q, self._ctx(["analyst"], "STAFF-20001")) - r2 = self.agent.run(q, self._ctx(["analyst"], "STAFF-20001")) - self.assertTrue(r2.meta.cache_hit) - - def test_trace_audit_recorded(self): - resp = self.agent.run("客户总数是多少", self._ctx(["analyst"], "STAFF-20001")) - rows = self.repo.execute_readonly( - f"SELECT trace_id FROM jinrong_agent.analytics_query_log WHERE trace_id='{resp.trace_id}'" - )["rows"] - self.assertGreaterEqual(len(rows), 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py index 30c077e..cb3d616 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -85,6 +85,10 @@ def test_all_routers_mounted(client): # 方案 C:SSE 流式对话 "/api/chat/stream", "/api/chat/visitor", + "/api/analyst/chat", + "/api/analyst/dashboard", + "/api/analyst/assets", + "/api/analyst/ops/metrics", } diff --git a/tests/test_module_boundary.py b/tests/test_module_boundary.py index 1413126..e747e76 100644 --- a/tests/test_module_boundary.py +++ b/tests/test_module_boundary.py @@ -95,6 +95,25 @@ HOST_ONLY_SKIP_PREFIXES = ( "app/utils/exceptions.py", ) +# 数据分析 Agent S3 接缝 — 走 deps + analyst_auth_adapter +ANALYST_AGENT_SEAM_SKIP = ( + "app/api/analyst.py", + "app/api/analyst_auth_adapter.py", + "app/service/analyst_agent.py", + "app/service/analytics_repo.py", + "app/service/cache_service.py", + "app/service/dict_service.py", + "app/service/guardrail.py", + "app/service/llm.py", + "app/service/schema_meta.py", + "app/service/sql_guard.py", +) + +# B7 Redis 网关惰性连接需 database._redis_kwargs(RESP2 口径) +INFRA_SKIP = ( + "app/service/risk/redis_gateway.py", +) + # 客服 Agent S2 接缝(docs/项目框架设计/客服Agent-合并说明.md)— 允许 import 宿主 schemas CUSTOMER_AGENT_SEAM_SKIP = ( "app/api/auth_adapter.py", @@ -116,7 +135,7 @@ CUSTOMER_AGENT_SEAM_SKIP = ( def _is_host_only_file(path: Path) -> bool: rel = path.relative_to(APP_DIR.parent).as_posix() - if rel in CUSTOMER_AGENT_SEAM_SKIP: + if rel in CUSTOMER_AGENT_SEAM_SKIP or rel in ANALYST_AGENT_SEAM_SKIP or rel in INFRA_SKIP: return True return rel.startswith(HOST_ONLY_SKIP_PREFIXES) diff --git a/tests/test_sql_guard.py b/tests/test_sql_guard.py deleted file mode 100644 index bec4697..0000000 --- a/tests/test_sql_guard.py +++ /dev/null @@ -1,83 +0,0 @@ -"""sql_guard 单元测试(unittest,无需外部服务)。""" -import unittest - -from app.service.sql_guard import ( - SqlGuardError, - extract_tables, - inject_ownership, - validate, -) - - -class TestSqlGuard(unittest.TestCase): - def test_select_allowed(self): - r = validate("SELECT risk_code, COUNT(*) AS c FROM core_customer GROUP BY risk_code", "full") - self.assertTrue(r.allowed) - self.assertIn("core_customer", r.tables) - - def test_with_select_allowed(self): - r = validate("WITH t AS (SELECT * FROM core_holding) SELECT * FROM t", "full") - self.assertTrue(r.allowed) - - def test_insert_rejected(self): - with self.assertRaises(SqlGuardError) as cm: - validate("INSERT INTO core_customer VALUES (1)", "full") - self.assertEqual(cm.exception.error_code, "SQL_NOT_SELECT") - - def test_multi_statement_rejected(self): - with self.assertRaises(SqlGuardError) as cm: - validate("SELECT 1; DROP TABLE core_customer;", "full") - self.assertEqual(cm.exception.error_code, "SQL_MULTI_STATEMENT") - - def test_drop_rejected(self): - with self.assertRaises(SqlGuardError): - validate("SELECT * FROM core_customer; DROP TABLE core_customer", "full") - - def test_unknown_table_rejected(self): - with self.assertRaises(SqlGuardError) as cm: - validate("SELECT * FROM mysql.user", "full") - self.assertEqual(cm.exception.error_code, "SQL_TABLE_NOT_ALLOWED") - - def test_advisor_out_of_scope_rejected(self): - with self.assertRaises(SqlGuardError) as cm: - validate( - "SELECT * FROM core_holding WHERE customer_id = 'CUST-1004'", - "assigned", ["CUST-1001"], - ) - self.assertEqual(cm.exception.error_code, "AUTH_403_NOT_ASSIGNED") - - def test_advisor_in_scope_allowed(self): - r = validate( - "SELECT * FROM core_holding WHERE customer_id = 'CUST-1001'", - "assigned", ["CUST-1001", "CUST-1002"], - ) - self.assertTrue(r.allowed) - self.assertTrue(r.has_customer_detail) - - def test_ops_customer_detail_rejected(self): - with self.assertRaises(SqlGuardError) as cm: - validate("SELECT customer_id, market_value FROM core_holding", "aggregate") - self.assertEqual(cm.exception.error_code, "AUTH_403_SCOPE") - - def test_ops_aggregate_allowed(self): - r = validate("SELECT COUNT(DISTINCT customer_id) AS cnt FROM core_holding", "aggregate") - self.assertTrue(r.allowed) - - def test_ops_specific_customer_rejected(self): - with self.assertRaises(SqlGuardError): - validate("SELECT * FROM core_holding WHERE customer_id='CUST-1001'", "aggregate") - - def test_inject_ownership(self): - out = inject_ownership("SELECT * FROM core_holding", ["CUST-1", "CUST-2"]) - self.assertIn("CUST-1", out) - self.assertIn("WHERE customer_id IN", out) - - def test_extract_tables(self): - self.assertEqual( - extract_tables("SELECT * FROM core_customer c JOIN core_holding h ON h.customer_id=c.customer_id"), - ["core_customer", "core_holding"], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_agent.py b/tests/test_wave6_analyst_agent.py similarity index 76% rename from tests/test_agent.py rename to tests/test_wave6_analyst_agent.py index 0b27ddd..f4beb5b 100644 --- a/tests/test_agent.py +++ b/tests/test_wave6_analyst_agent.py @@ -1,8 +1,10 @@ -"""analyst_agent 编排测试:确定性假件 + 一条真实端到端。""" +"""analyst_agent 编排测试(Wave 6)。""" import unittest +import pytest + +from app.api.analyst_auth_adapter import AnalystAuthContext from app.service.analyst_agent import AnalystAgent -from app.utils.auth import AuthContext class FakeLLM: @@ -43,8 +45,13 @@ class FakeRepo: pass -def ctx(roles, subject="STAFF-A"): - return AuthContext(subject_id=subject, token_type="staff", roles=roles, staff_type=roles[0] if roles else "") +def ctx(roles, subject="STAFF-A", *, token_type="staff", customer_id=None): + return AnalystAuthContext( + subject_id=subject, + token_type=token_type, + roles=roles, + customer_id=customer_id, + ) class TestAgentOrchestration(unittest.TestCase): @@ -89,8 +96,26 @@ class TestAgentOrchestration(unittest.TestCase): self.assertEqual(resp.status, "deny") self.assertEqual(resp.error_code, "AUTH_403_NOT_ASSIGNED") + def test_customer_self_success(self): + repo = FakeRepo(rows=[[2]], columns=["cnt"]) + agent = AnalystAgent( + llm=FakeLLM( + "SELECT COUNT(*) AS cnt FROM core_trade WHERE customer_id='CUST-9527'", + ["近阶段共有 2 笔交易"], + ), + repo=repo, + ) + resp = agent.run( + "我有多少笔交易", + ctx(["customer"], "CUST-9527", token_type="customer", customer_id="CUST-9527"), + ) + self.assertEqual(resp.status, "success") + self.assertIn("AI 分析有风险", resp.answer) + +@pytest.mark.integration class TestAgentReal(unittest.TestCase): + @pytest.mark.skip(reason="需要真实 MySQL + DeepSeek Key") def test_real_end_to_end(self): from app.service.analytics_repo import AnalyticsRepo from app.service.llm import DeepSeekLLM @@ -100,7 +125,3 @@ class TestAgentReal(unittest.TestCase): self.assertIn(resp.status, ("success", "degrade")) self.assertTrue(resp.sql) self.assertGreater(resp.meta.row_count, 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_cache_service.py b/tests/test_wave6_analyst_cache.py similarity index 100% rename from tests/test_cache_service.py rename to tests/test_wave6_analyst_cache.py diff --git a/tests/test_llm.py b/tests/test_wave6_analyst_llm.py similarity index 58% rename from tests/test_llm.py rename to tests/test_wave6_analyst_llm.py index 5d82eba..0033e69 100644 --- a/tests/test_llm.py +++ b/tests/test_wave6_analyst_llm.py @@ -1,7 +1,7 @@ """llm 客户端测试(extract_sql 纯逻辑 + DeepSeek 真实冒烟)。""" import unittest -from app.service.llm import DeepSeekLLM, extract_sql +from app.service.llm import extract_sql class TestExtractSql(unittest.TestCase): @@ -18,16 +18,5 @@ class TestExtractSql(unittest.TestCase): self.assertEqual(extract_sql("```\nSELECT 2\n```"), "SELECT 2") -class TestDeepSeekSmoke(unittest.TestCase): - def test_complete(self): - llm = DeepSeekLLM() - text, usage = llm.complete( - [{"role": "user", "content": "只回复两个字:正常"}], - max_tokens=8, - ) - self.assertTrue(text.strip()) - self.assertIn("prompt_tokens", usage) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_repo.py b/tests/test_wave6_analytics_repo.py similarity index 100% rename from tests/test_repo.py rename to tests/test_wave6_analytics_repo.py diff --git a/tests/test_dict_service.py b/tests/test_wave6_dict_service.py similarity index 100% rename from tests/test_dict_service.py rename to tests/test_wave6_dict_service.py diff --git a/tests/test_guardrail.py b/tests/test_wave6_guardrail.py similarity index 97% rename from tests/test_guardrail.py rename to tests/test_wave6_guardrail.py index 6e48ad3..fe84dc8 100644 --- a/tests/test_guardrail.py +++ b/tests/test_wave6_guardrail.py @@ -1,7 +1,7 @@ """guardrail 数字护栏单元测试。""" import unittest -from app.model.schemas.analyst import TableData +from app.model.analyst_schemas import TableData from app.service.guardrail import check_numbers, extract_numbers, result_numbers, verify diff --git a/tests/test_wave6_sql_guard.py b/tests/test_wave6_sql_guard.py new file mode 100644 index 0000000..b2e7463 --- /dev/null +++ b/tests/test_wave6_sql_guard.py @@ -0,0 +1,55 @@ +"""sql_guard 单元测试(Wave 6)。""" +import unittest + +from app.service.sql_guard import SqlGuardError, extract_tables, inject_ownership, validate + + +class TestSqlGuard(unittest.TestCase): + def test_select_allowed(self): + r = validate("SELECT risk_code, COUNT(*) AS c FROM core_customer GROUP BY risk_code", "full") + self.assertTrue(r.allowed) + + def test_customer_self_in_scope(self): + r = validate( + "SELECT COUNT(*) FROM core_holding WHERE customer_id='CUST-9527'", + "self", + ["CUST-9527"], + ) + self.assertTrue(r.allowed) + + def test_customer_self_out_of_scope(self): + with self.assertRaises(SqlGuardError) as cm: + validate( + "SELECT * FROM core_holding WHERE customer_id='CUST-1001'", + "self", + ["CUST-9527"], + ) + self.assertEqual(cm.exception.error_code, "AUTH_403_NOT_OWNER") + + def test_insert_rejected(self): + with self.assertRaises(SqlGuardError) as cm: + validate("INSERT INTO core_customer VALUES (1)", "full") + self.assertEqual(cm.exception.error_code, "SQL_NOT_SELECT") + + def test_advisor_out_of_scope_rejected(self): + with self.assertRaises(SqlGuardError) as cm: + validate( + "SELECT * FROM core_holding WHERE customer_id = 'CUST-1004'", + "assigned", + ["CUST-1001"], + ) + self.assertEqual(cm.exception.error_code, "AUTH_403_NOT_ASSIGNED") + + def test_ops_aggregate_allowed(self): + r = validate("SELECT COUNT(DISTINCT customer_id) AS cnt FROM core_holding", "aggregate") + self.assertTrue(r.allowed) + + def test_inject_ownership(self): + out = inject_ownership("SELECT * FROM core_holding", ["CUST-1", "CUST-2"]) + self.assertIn("CUST-1", out) + + def test_extract_tables(self): + self.assertEqual( + extract_tables("SELECT * FROM core_customer c JOIN core_holding h ON h.customer_id=c.customer_id"), + ["core_customer", "core_holding"], + ) diff --git a/web/src/App.tsx b/web/src/App.tsx index fbc47b5..0e77269 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { AppLayout } from './layouts/AppLayout' import { LoginPage } from './pages/login/LoginPage' -import { PlaceholderPage } from './pages/PlaceholderPage' +import { AnalystQueryPage } from './pages/analytics/AnalystQueryPage' import { loadAuth } from './stores/authStore' import { AgentBanner } from './components/AgentBanner' import { CustomerWealthDashboard } from './pages/dashboard/CustomerWealthDashboard' @@ -87,12 +87,12 @@ export default function AppRoutes() { /> } /> - } /> + } /> - + } diff --git a/web/src/api/analyst.ts b/web/src/api/analyst.ts new file mode 100644 index 0000000..53a81f6 --- /dev/null +++ b/web/src/api/analyst.ts @@ -0,0 +1,40 @@ +import { apiFetch } from './client' + +export type AnalystTable = { + columns: string[] + rows: unknown[][] +} + +export type AnalystMeta = { + exec_ms: number + row_count: number + cache_hit: boolean + data_as_of?: string + source?: string + cost_est?: number +} + +export type AnalystChatResponse = { + answer: string + table: AnalystTable + sql: string + meta: AnalystMeta + disclaimer: string + status: string + error_code?: string | null + suggestions?: string[] | null + trace_id?: string | null +} + +export async function postAnalystChat( + token: string, + question: string, + sessionId?: string, +): Promise { + const { data } = await apiFetch('/api/analyst/chat', { + method: 'POST', + token, + body: JSON.stringify({ question, session_id: sessionId ?? null }), + }) + return data +} diff --git a/web/src/pages/analytics/AnalystQueryPage.tsx b/web/src/pages/analytics/AnalystQueryPage.tsx new file mode 100644 index 0000000..32aeb63 --- /dev/null +++ b/web/src/pages/analytics/AnalystQueryPage.tsx @@ -0,0 +1,138 @@ +import { Alert, Button, Card, Collapse, Input, Space, Spin, Table, Typography } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useState } from 'react' +import { postAnalystChat, type AnalystChatResponse } from '../../api/analyst' +import { ApiError } from '../../api/client' +import { ApiErrorResult } from '../../components/ApiErrorResult' +import { PageShell } from '../../components/PageShell' +import { useAppAuth } from '../../layouts/AppLayout' + +const { Text, Paragraph } = Typography + +type RowRecord = Record & { key: number } + +export function AnalystQueryPage() { + const auth = useAppAuth() + const [question, setQuestion] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + + async function onAsk() { + const q = question.trim() + if (!q) return + setLoading(true) + setError(null) + try { + const resp = await postAnalystChat(auth.accessToken, q) + setResult(resp) + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))) + setResult(null) + } finally { + setLoading(false) + } + } + + const columns: ColumnsType = + result?.table.columns.map((col: string, i: number) => ({ + title: col, + key: `${col}-${i}`, + render: (_: unknown, record: RowRecord) => String(record[`c${i}`] ?? ''), + })) ?? [] + + const dataSource: RowRecord[] = + result?.table.rows.map((row: unknown[], idx: number) => { + const record: RowRecord = { key: idx } + row.forEach((cell: unknown, i: number) => { + record[`c${i}`] = cell + }) + return record + }) ?? [] + + return ( + + } + > + + + setQuestion(e.target.value)} + placeholder="例如:我近30天有多少笔交易? / 待处理预警按类型统计" + onPressEnter={(e) => { + if (!e.shiftKey) { + e.preventDefault() + void onAsk() + } + }} + /> + + + + {error ? : null} + + {loading ? : null} + + {result ? ( + <> + {result.status === 'clarify' ? : null} + {result.status === 'deny' ? ( + + ) : null} + {(result.status === 'success' || result.status === 'degrade') && result.answer ? ( + + {result.answer} + {result.disclaimer ? ( + + {result.disclaimer} + + ) : null} + + ) : null} + {result.table.rows.length > 0 ? ( + + + size="small" + rowKey="key" + pagination={{ pageSize: 10 }} + columns={columns} + dataSource={dataSource} + scroll={{ x: true }} + /> + + ) : null} + {result.sql ? ( + {result.sql}, + }, + ]} + /> + ) : null} + + ) : null} + + + ) +}