From fd9464db0ad2f3133e389af54d31a3289eaeb6aa Mon Sep 17 00:00:00 2001 From: zyi <501747489@qq.com> Date: Mon, 7 Sep 2026 16:06:18 +0800 Subject: [PATCH 1/2] Append Zhong Zhichao data-analyst work notes to user story doc. EOF Co-authored-by: Cursor --- docs/需求拆解/用户故事/03-数据分析师.docx | Bin 11537 -> 11278 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/需求拆解/用户故事/03-数据分析师.docx b/docs/需求拆解/用户故事/03-数据分析师.docx index fdf3499949f202c150518d57ca005bf26a93f58d..91abdf49de8b032f1400aff9abd089c630bea70f 100644 GIT binary patch literal 11278 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 ep8p2^T~1Mwg?N3xzc84Q0pl Date: Wed, 9 Sep 2026 18:04:45 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E5=88=86?= =?UTF-8?q?=E6=9E=90=20Agent=20=E5=AE=9E=E7=8E=B0=EF=BC=88API/=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1/=E8=A1=A8=E7=BB=93=E6=9E=84=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE/=E6=96=87=E6=A1=A3/=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 app/api、app/service 数据分析 Agent 全套服务与接口 - schemas.py 重构为 schemas 包(analyst schema) - 新增 SQL 防注入、guardrail、缓存、字典、LLM 等服务 - 新增 tests 测试套件与 scripts/dev、scripts/setup 脚本 - 补充需求规格、架构说明书、开发清单、表设计等文档 --- app/api/analyst.py | 93 +++ app/api/deps.py | 22 + app/config/settings.py | 7 + app/main.py | 6 +- app/model/schemas.py | 1 - app/model/schemas/__init__.py | 20 + app/model/schemas/analyst.py | 58 ++ app/service/analyst_agent.py | 292 +++++++++ app/service/analytics_repo.py | 176 +++++ app/service/cache_service.py | 132 ++++ app/service/dict_service.py | 85 +++ app/service/guardrail.py | 96 +++ app/service/llm.py | 73 +++ app/service/schema_meta.py | 28 + app/service/sql_guard.py | 173 +++++ app/utils/auth.py | 110 ++++ docs/memory/TODO.md | 1 + docs/数据分析Agent一页总结.md | 51 ++ docs/需求拆解/01-数据分析Agent需求规格.md | 458 +++++++++++++ .../技术选型和版本/01-技术栈与版本.md | 2 + docs/项目框架设计/数据分析Agent开发清单.md | 117 ++++ docs/项目框架设计/数据分析Agent架构说明书.md | 606 ++++++++++++++++++ docs/项目框架设计/表设计/00-架构总览.md | 16 +- .../表设计/03-mysql-analyst专用.sql | 65 ++ .../表设计/05-多Agent共用底座清单.md | 19 +- requirements.txt | 3 + scripts/dev/demo.py | 39 ++ scripts/dev/diag_question.py | 30 + scripts/dev/dump_schema.py | 55 ++ scripts/dev/inspect_seed.py | 24 + scripts/setup/apply_sql_file.py | 51 ++ tests/__init__.py | 0 tests/test_agent.py | 106 +++ tests/test_api.py | 81 +++ tests/test_cache_service.py | 49 ++ tests/test_dict_service.py | 31 + tests/test_guardrail.py | 51 ++ tests/test_integration.py | 73 +++ tests/test_llm.py | 33 + tests/test_repo.py | 63 ++ tests/test_sql_guard.py | 83 +++ 41 files changed, 3463 insertions(+), 16 deletions(-) create mode 100644 app/api/analyst.py create mode 100644 app/api/deps.py delete mode 100644 app/model/schemas.py create mode 100644 app/model/schemas/__init__.py create mode 100644 app/model/schemas/analyst.py create mode 100644 app/service/analyst_agent.py create mode 100644 app/service/analytics_repo.py create mode 100644 app/service/cache_service.py create mode 100644 app/service/dict_service.py create mode 100644 app/service/guardrail.py create mode 100644 app/service/llm.py create mode 100644 app/service/schema_meta.py create mode 100644 app/service/sql_guard.py create mode 100644 app/utils/auth.py create mode 100644 docs/数据分析Agent一页总结.md create mode 100644 docs/需求拆解/01-数据分析Agent需求规格.md create mode 100644 docs/项目框架设计/数据分析Agent开发清单.md create mode 100644 docs/项目框架设计/数据分析Agent架构说明书.md create mode 100644 docs/项目框架设计/表设计/03-mysql-analyst专用.sql create mode 100644 scripts/dev/demo.py create mode 100644 scripts/dev/diag_question.py create mode 100644 scripts/dev/dump_schema.py create mode 100644 scripts/dev/inspect_seed.py create mode 100644 scripts/setup/apply_sql_file.py create mode 100644 tests/__init__.py create mode 100644 tests/test_agent.py create mode 100644 tests/test_api.py create mode 100644 tests/test_cache_service.py create mode 100644 tests/test_dict_service.py create mode 100644 tests/test_guardrail.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_llm.py create mode 100644 tests/test_repo.py create mode 100644 tests/test_sql_guard.py diff --git a/app/api/analyst.py b/app/api/analyst.py new file mode 100644 index 0000000..a54824f --- /dev/null +++ b/app/api/analyst.py @@ -0,0 +1,93 @@ +"""数据分析 Agent 路由(D-01~D-12 / N-03/07/08)。""" +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.service.analyst_agent import AnalystAgent +from app.utils.auth import AuthContext, assert_analyst_access + +router = APIRouter(prefix="/api/analyst", tags=["analyst"]) + +_agent: AnalystAgent | None = None + + +def get_agent() -> AnalystAgent: + global _agent + if _agent is None: + _agent = AnalystAgent() + return _agent + + +@router.post("/chat", response_model=AnalystResponse) +def chat( + req: ChatRequest, + auth: AuthContext = Depends(get_auth_context), + agent: AnalystAgent = Depends(get_agent), +) -> AnalystResponse: + return agent.run(req.question, auth, req.session_id, req.trace_id) + + +@router.get("/dashboard") +def dashboard( + auth: AuthContext = Depends(get_auth_context), + 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") + cards = { + "analyst": ["客户总数", "总持仓规模", "今日交易笔数与金额", "待处理预警数", "口径字典资产数"], + "advisor": ["名下客户数", "名下资产规模", "盈亏分布", "风险等级分布"], + "risk_officer": ["待处理预警数", "预警按类型分布", "近7天新增趋势"], + "ops": ["近30天申购金额", "近30天赎回金额", "各产品类型规模TOP"], + }.get(role, []) + metrics: dict = {} + if role == "analyst": + try: + res = agent.repo.execute_readonly( + "SELECT (SELECT COUNT(*) FROM core_customer) AS customers, " + "(SELECT COALESCE(SUM(market_value),0) FROM core_holding) AS holdings, " + "(SELECT COUNT(*) FROM jinrong_agent.risk_alert WHERE status='pending_review') AS pending" + ) + if res["rows"]: + metrics = dict(zip(res["columns"], res["rows"][0])) + except Exception: # noqa: BLE001 + pass + return {"role": role, "domain": domain, "cards": cards, "metrics": metrics} + + +@router.post("/assets") +def create_asset( + req: AssetCreateRequest, + auth: AuthContext = Depends(get_auth_context), + agent: AnalystAgent = Depends(get_agent), +): + """沉淀资产(D-11):仅分析师可写。""" + if "analyst" not in auth.roles: + raise HTTPException(status_code=403, detail="仅分析师可沉淀资产") + if req.kind not in ("dict", "few_shot", "template"): + raise HTTPException(status_code=400, detail="kind 必须是 dict/few_shot/template") + asset_id = agent.repo.insert_asset(req.kind, req.payload, auth.subject_id) + return {"ok": True, "id": asset_id, "kind": req.kind} + + +@router.get("/ops/metrics") +def ops_metrics( + auth: AuthContext = Depends(get_auth_context), + agent: AnalystAgent = Depends(get_agent), +): + """运营指标(N-08,P1 最小版)。""" + if "analyst" not in auth.roles: + raise HTTPException(status_code=403, detail="仅分析师可查看运营指标") + res = agent.repo.execute_readonly( + "SELECT COUNT(*) AS total, " + "SUM(exec_status='blocked') AS blocked " + "FROM jinrong_agent.analytics_query_log" + ) + return res["rows"][0] if res["rows"] else {"total": 0, "blocked": 0} diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..3b3d70a --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,22 @@ +"""FastAPI 鉴权依赖:解析 Bearer Token → AuthContext。""" +from __future__ import annotations + +from fastapi import Header, HTTPException + +from app.utils.auth import AuthContext, AuthError, verify_token + + +def get_auth_context( + authorization: str = Header(default=""), + x_trace_id: str = Header(default=""), +) -> AuthContext: + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="缺少 Bearer Token") + token = authorization[len("Bearer "):] + try: + ctx = verify_token(token) + except AuthError as exc: + raise HTTPException(status_code=401, detail=exc.message) from exc + if x_trace_id: + ctx.trace_id = x_trace_id + return ctx diff --git a/app/config/settings.py b/app/config/settings.py index b96c208..96c5d7d 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -29,5 +29,12 @@ class Settings(BaseSettings): deepseek_api_key: str = "" deepseek_base_url: str = "https://api.deepseek.com" + # 数据分析 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 + settings = Settings() diff --git a/app/main.py b/app/main.py index 12fbe63..4a79c2e 100644 --- a/app/main.py +++ b/app/main.py @@ -2,14 +2,14 @@ from fastapi import FastAPI +from app.api.analyst import router as analyst_router from app.config.settings import settings app = FastAPI(title="JinRong Agent Platform", version="0.1.0") +app.include_router(analyst_router) + @app.get("/health") def health(): return {"status": "ok", "env": settings.app_env} - - -# TODO: 挂载 app.api 路由;接入 Auth SDK 中间件 diff --git a/app/model/schemas.py b/app/model/schemas.py deleted file mode 100644 index ffcf983..0000000 --- a/app/model/schemas.py +++ /dev/null @@ -1 +0,0 @@ -"""Pydantic 请求/响应模型、AuthContext 等。""" diff --git a/app/model/schemas/__init__.py b/app/model/schemas/__init__.py new file mode 100644 index 0000000..4875eba --- /dev/null +++ b/app/model/schemas/__init__.py @@ -0,0 +1,20 @@ +"""数据分析 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/model/schemas/analyst.py b/app/model/schemas/analyst.py new file mode 100644 index 0000000..fe5b3ce --- /dev/null +++ b/app/model/schemas/analyst.py @@ -0,0 +1,58 @@ +"""数据分析 Agent 请求/响应 Pydantic 模型(输出四件套,需求规格 §3 全局约定)。""" +from __future__ import annotations + +from pydantic import BaseModel, Field + +DISCLAIMER = ( + "本内容仅为投资分析参考,不构成任何直接投资建议,不构成对任何产品的收益承诺," + "据此操作风险自负,请谨慎对待。" +) + + +class ChatRequest(BaseModel): + question: str = Field(..., description="自然语言问题") + session_id: str | None = None + trace_id: str | None = None + + +class TableData(BaseModel): + columns: list[str] = Field(default_factory=list) + rows: list[list] = Field(default_factory=list) + + +class Meta(BaseModel): + exec_ms: int = 0 + row_count: int = 0 + cache_hit: bool = False + data_as_of: str | None = None + source: str = "" + cost_est: float = 0.0 + + +class AnalystResponse(BaseModel): + """统一输出四件套 + 状态/错误信息。""" + + answer: str = "" + table: TableData = Field(default_factory=TableData) + sql: str = "" + meta: Meta = Field(default_factory=Meta) + disclaimer: str = DISCLAIMER + status: str = "success" # success / clarify / deny / degrade / error / escalate + error_code: str | None = None + suggestions: list[str] | None = Field( + default=None, description="权限拒绝时的可查范围引导(D-08)" + ) + trace_id: str | None = None + + +class AssetCreateRequest(BaseModel): + """沉淀资产(D-11):few_shot / dict / template。""" + + kind: str = Field(..., description="few_shot / dict / template") + payload: dict = Field(default_factory=dict) + + +class SampleRequest(BaseModel): + """抽样明细(N-03)。""" + + limit: int = Field(default=5, ge=1, le=20) diff --git a/app/service/analyst_agent.py b/app/service/analyst_agent.py new file mode 100644 index 0000000..b4f51d4 --- /dev/null +++ b/app/service/analyst_agent.py @@ -0,0 +1,292 @@ +"""数据分析 Agent 编排:NL → 消歧 → 生成 SQL → 校验 → 执行 → 解读 → 护栏 → 留痕。 + +与架构说明书 §6 的节点一一对应。核心逻辑用可测试的类实现, +`build_graph()` 提供 LangGraph StateGraph 适配(架构对齐)。 +""" +from __future__ import annotations + +import hashlib +import time +import uuid +from typing import Any + +from app.model.schemas.analyst import AnalystResponse, Meta, TableData +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 +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。" + "严格遵守:1) 只输出 SQL,不要代码块、不要解释、不要分号结尾外的多余内容;" + "2) 只能使用给定表;3) 不生成任何写操作;4) 涉及金额时用原始字段不要自行换算单位。" +) + +ANSWER_SYSTEM = ( + "你是金融数据分析助手。根据 SQL 与查询结果,用简洁人话解读,数字必须与结果完全一致,不得编造。" + "结尾无需重复免责声明(由系统统一附带)。若结果为空,如实说明。" +) + + +class AnalystAgent: + def __init__( + self, + llm: DeepSeekLLM | None = None, + repo: AnalyticsRepo | None = None, + cache: CacheService | None = None, + registry: MetricRegistry | None = None, + ) -> None: + self.llm = llm or DeepSeekLLM() + self.repo = repo or AnalyticsRepo() + self.cache = cache or CacheService.auto() + self.registry = registry or default_registry() + + # ---------- 主入口 ---------- + def run( + self, + question: str, + auth: AuthContext, + session_id: str | None = None, + trace_id: str | None = None, + ) -> AnalystResponse: + trace_id = trace_id or f"trace-{uuid.uuid4().hex[:16]}" + session_id = session_id or f"sess-{uuid.uuid4().hex[:12]}" + auth.trace_id = trace_id + started = time.time() + 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) + + scope: list[str] = [] + if domain == "assigned": + scope = self.repo.resolve_advisor_scope(auth.subject_id) + + # 1) 指标消歧(N-01) + amb = self._detect_ambiguity(question) + if amb is not None: + return self._clarify(amb, trace_id) + + # 2) 生成 SQL + sql_text, usage = self._generate_sql(question, domain, scope) + cost_est += estimate_cost(usage) + + # 3) 校验(五层) + try: + vres = validate(sql_text, domain, scope) + except SqlGuardError as exc: + return self._deny(exc.error_code, exc.message, trace_id, domain) + + # 4) 执行(缓存优先) + perm_fp = self.cache.permission_fingerprint(auth.subject_id, domain, scope) + sql_hash = self.cache.sql_hash(sql_text) + cached = self.cache.get_result(perm_fp, sql_text) + cache_hit = cached is not None + if cache_hit: + exec_result, data_as_of = cached + latency = 0 + else: + t0 = time.time() + try: + exec_result = self.repo.execute_readonly(sql_text) + data_as_of = self.repo.get_data_as_of() + latency = int((time.time() - t0) * 1000) + self.cache.set_result(perm_fp, sql_text, (exec_result, data_as_of), vres.tables) + except Exception as exc: # noqa: BLE001 + return self._error(f"SQL 执行失败:{exc}", trace_id) + + 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 + ) + cost_est += estimate_cost(g_usage) + except Exception as exc: # noqa: BLE001 + return self._error(f"解读生成失败:{exc}", trace_id) + + # 6) 组装输出(护栏不通过 → 降级:只给表格,符合 D-10) + status = "degrade" if (guard_result is not None and not guard_result.passed) else "success" + if status == "degrade": + answer = "解读校验未通过,请以下方表格数据为准。" + resp = AnalystResponse( + answer=answer, + table=table, + sql=sql_text, + meta=Meta( + exec_ms=latency, + row_count=len(exec_result["rows"]), + cache_hit=cache_hit, + data_as_of=data_as_of, + source="jinrong_core", + cost_est=round(cost_est, 6), + ), + status=status, + trace_id=trace_id, + ) + + # 7) 留痕(D-04) + self._persist(question, sql_text, sql_hash, resp, auth, session_id, trace_id, + latency, empty_state, guard_result) + return resp + + # ---------- 各步骤 ---------- + def _detect_ambiguity(self, question: str) -> Ambiguity | None: + terms = self._metric_terms(question) + if not terms: + return None + # 只取最长(最具体)的指标词判定,避免"持仓规模"里的"规模"误触发歧义 + resolved = self.registry.resolve(terms[0]) + return resolved if isinstance(resolved, Ambiguity) else None + + def _metric_terms(self, question: str) -> list[str]: + """从问题里捞出可能的指标词(口径字典别名)。""" + terms: list[str] = [] + for m in self.registry.all(): + for n in [m.name, *m.aliases]: + if n and n in question: + terms.append(n) + return sorted(terms, key=len, reverse=True) + + def _generate_sql(self, question: str, domain: str, scope: list[str]) -> tuple[str, dict]: + dict_hint = "\n".join(f"- {m.name}({m.key}):{m.definition}" for m in self.registry.all()) + scope_hint = "无限制" + if domain == "assigned": + scope_hint = f"只能查询以下客户:customer_id IN ({', '.join(scope)});涉及客户的查询必须带此过滤" + elif domain == "aggregate": + scope_hint = "只能输出聚合结果,禁止查单个客户或按 customer_id 分组/筛选" + prompt = ( + f"{SQL_GEN_SYSTEM}\n\n表结构:\n{SCHEMA_PROMPT}\n\n口径字典:\n{dict_hint}\n\n" + f"权限约束:{scope_hint}\n\n用户问题:{question}\n\nSQL:" + ) + text, usage = self.llm.complete( + [{"role": "system", "content": SQL_GEN_SYSTEM}, {"role": "user", "content": prompt}], + temperature=0.0, + max_tokens=800, + ) + return extract_sql(text), usage + + def _generate_verified_answer( + self, question: str, sql: str, table: TableData, empty_state: str + ) -> 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) + guard = verify(answer, table, None) + if not guard.passed: + # 重试 1 次(加强提示) + answer2, usage2 = self._generate_answer( + question, sql, summary, empty_note + " 特别注意:所有数字必须与结果逐字一致。" + ) + for k, v in usage2.items(): + cur = usage.get(k) + if isinstance(cur, (int, float)) and isinstance(v, (int, float)): + usage[k] = cur + v + guard2 = verify(answer2, table, None) + if guard2.passed: + return answer2, guard2, usage + return answer2, guard2, usage + return answer, guard, usage + + def _generate_answer(self, question: str, sql: str, summary: str, note: str) -> tuple[str, dict]: + 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}], + temperature=0.2, + max_tokens=500, + ) + + def _summarize(self, table: TableData, max_rows: int = 10) -> str: + if not table.rows: + return "(空)" + head = table.rows[:max_rows] + return f"列={table.columns} 行数={len(table.rows)} 前{len(head)}行={head}" + + def _empty_note(self, empty_state: str) -> str: + return { + "zero": "结果为 0(确有数据,聚合值为 0)。", + "no_data": "源无此数据。", + "not_match": "查询条件未命中任何记录。", + "has_data": "", + }.get(empty_state, "") + + def _clarify(self, amb: Ambiguity, trace_id: str) -> AnalystResponse: + cands = ";".join(f"{c.name}({c.definition})" for c in amb.candidates) + return AnalystResponse( + answer=f"“{amb.term}”有多个口径,请确认您指哪一个:{cands}", + status="clarify", + trace_id=trace_id, + ) + + def _deny(self, code: str, msg: str, trace_id: str, domain: str = "") -> AnalystResponse: + suggestions = { + "AUTH_403_NOT_ASSIGNED": ["你仅能查名下客户的数据,可尝试问自己名下客户的持仓、风险分布等。"], + "AUTH_403_SCOPE": ["你仅能查聚合数据,如近30天申购金额、各产品类型规模等。"], + }.get(code) + return AnalystResponse( + answer=f"无法执行:{msg}", + status="deny", + error_code=code, + suggestions=suggestions, + trace_id=trace_id, + ) + + def _error(self, msg: str, trace_id: str) -> AnalystResponse: + return AnalystResponse( + answer=f"本次查询未能完成:{msg}。可一键转交分析师人工处理。", + status="error", + error_code="EXEC_ERROR", + trace_id=trace_id, + ) + + def _persist(self, question, sql, sql_hash, resp, auth, session_id, trace_id, + latency, empty_state, guard_result) -> None: + summary = { + "status": resp.status, + "empty_state": empty_state, + "guardrail": "passed" if (guard_result is None or guard_result.passed) else "degraded", + "source": "llm" if not resp.meta.cache_hit else "cache", + } + try: + self.repo.log_query( + session_id=session_id, trace_id=trace_id, staff_id=auth.subject_id, + nl_question=question, generated_sql=sql, sql_hash=sql_hash, + row_count=resp.meta.row_count, + exec_status="success" if resp.status in ("success", "degrade") else "blocked", + result_summary=summary, exec_latency_ms=latency, + has_disclaimer=True, + ) + self.repo.log_audit( + trace_id=trace_id, event_type="analyst_query", actor_id=auth.subject_id, + decision=resp.status, input_summary={"question": question}, + ) + except Exception: # noqa: BLE001 + pass + + +def build_graph(agent: AnalystAgent): + """LangGraph StateGraph 适配(架构对齐用;核心逻辑仍在 run())。""" + from langgraph.graph import END, StateGraph + + def node_run(state: dict) -> dict: + resp = agent.run( + state["question"], state["auth"], state.get("session_id"), state.get("trace_id") + ) + return {"response": resp} + + g = StateGraph(dict) + g.add_node("run", node_run) + g.set_entry_point("run") + g.add_edge("run", END) + return g.compile() diff --git a/app/service/analytics_repo.py b/app/service/analytics_repo.py new file mode 100644 index 0000000..4c12712 --- /dev/null +++ b/app/service/analytics_repo.py @@ -0,0 +1,176 @@ +"""数据分析 Agent 数据访问:只读执行、归属白名单、留痕、资产沉淀。 + +统一走 pymysql(双库:jinrong_core 只读 + jinrong_agent 只增/资产)。 +""" +from __future__ import annotations + +import json +from datetime import date, datetime +from typing import Any + +import pymysql + +from app.config.settings import settings + + +class AnalyticsRepo: + def __init__(self, host=None, port=None, user=None, password=None) -> None: + self.host = host or settings.mysql_host + self.port = int(port or settings.mysql_port) + self.user = user or settings.mysql_user + self.password = password or settings.mysql_password + + def _conn(self, database: str): + return pymysql.connect( + host=self.host, + port=self.port, + user=self.user, + password=self.password, + database=database, + charset="utf8mb4", + autocommit=True, + cursorclass=pymysql.cursors.DictCursor, + ) + + # ---------- 只读执行 ---------- + def execute_readonly(self, sql: str) -> dict[str, Any]: + """在 jinrong_core 上执行只读 SELECT(agent 库表用全限定名 jinrong_agent.xxx)。""" + conn = self._conn(settings.mysql_core_database) + try: + with conn.cursor() as cur: + cur.execute(sql) + rows = cur.fetchall() + columns = [d[0] for d in cur.description] if cur.description else [] + finally: + conn.close() + return {"columns": columns, "rows": [list(r.values()) for r in rows]} + + def get_data_as_of(self) -> str: + """数据截至时间:取持仓 as_of 最大值,否则今天。""" + conn = self._conn(settings.mysql_core_database) + try: + with conn.cursor() as cur: + cur.execute("SELECT MAX(as_of) AS d FROM core_holding") + row = cur.fetchone() + finally: + conn.close() + if row and row.get("d"): + return str(row["d"]) + return date.today().isoformat() + + # ---------- 归属白名单 ---------- + def resolve_advisor_scope(self, advisor_id: str) -> list[str]: + """顾问名下 active 客户(实时取自归属表)。""" + conn = self._conn(settings.mysql_core_database) + try: + with conn.cursor() as cur: + cur.execute( + "SELECT customer_id FROM core_customer_advisor " + "WHERE advisor_id=%s AND rel_status='active'", + (advisor_id,), + ) + return [r["customer_id"] for r in cur.fetchall()] + finally: + conn.close() + + def get_staff(self, staff_id: str) -> dict[str, Any] | None: + conn = self._conn(settings.mysql_core_database) + try: + with conn.cursor() as cur: + cur.execute( + "SELECT staff_id, display_name, staff_type, roles FROM core_staff " + "WHERE staff_id=%s AND is_active=1", + (staff_id,), + ) + return cur.fetchone() + finally: + conn.close() + + # ---------- 留痕 ---------- + def log_query( + self, + session_id: str, + trace_id: str, + staff_id: str, + nl_question: str, + generated_sql: str, + sql_hash: str, + row_count: int, + exec_status: str, + result_summary: dict | None, + exec_latency_ms: int = 0, + has_disclaimer: bool = True, + error_message: str | None = None, + ) -> None: + conn = self._conn(settings.mysql_database) + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO analytics_query_log " + "(session_id, trace_id, staff_id, nl_question, generated_sql, sql_hash, " + " row_count, exec_status, exec_latency_ms, result_summary, has_disclaimer, error_message) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + session_id, trace_id, staff_id, nl_question, generated_sql, sql_hash, + row_count, exec_status, exec_latency_ms, + json.dumps(result_summary, ensure_ascii=False, default=str) if result_summary else None, + int(has_disclaimer), error_message, + ), + ) + finally: + conn.close() + + def log_audit(self, trace_id: str, event_type: str, actor_id: str, decision: str, + customer_id: str | None = None, input_summary: dict | None = None) -> None: + conn = self._conn(settings.mysql_database) + try: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO audit_log (trace_id, event_type, agent_type, actor_id, customer_id, input_summary, decision) " + "VALUES (%s,%s,'analyst',%s,%s,%s,%s)", + ( + trace_id, event_type, actor_id, customer_id, + json.dumps(input_summary, ensure_ascii=False, default=str) if input_summary else None, + decision, + ), + ) + finally: + conn.close() + + # ---------- 资产沉淀(D-11) ---------- + def insert_asset(self, kind: str, payload: dict, created_by: str) -> int: + table = {"dict": "analytics_metric_dict", "few_shot": "analytics_few_shot", "template": "analytics_query_template"}[kind] + conn = self._conn(settings.mysql_database) + try: + with conn.cursor() as cur: + if kind == "dict": + cur.execute( + f"INSERT INTO {table} (metric_key, metric_name, definition, created_by) VALUES (%s,%s,%s,%s)", + (payload.get("metric_key"), payload.get("metric_name"), payload.get("definition"), created_by), + ) + elif kind == "few_shot": + cur.execute( + f"INSERT INTO {table} (question, sql_text, created_by) VALUES (%s,%s,%s)", + (payload.get("question"), payload.get("sql_text"), created_by), + ) + else: + cur.execute( + f"INSERT INTO {table} (template_key, template_sql, created_by) VALUES (%s,%s,%s)", + (payload.get("template_key"), payload.get("template_sql"), created_by), + ) + return int(cur.lastrowid or 0) + finally: + conn.close() + + +def classify_empty(rows: list, sql: str) -> str: + """空结果三态区分(N-02):zero / no_data / not_match。""" + if rows: + return "has_data" + low = sql.lower() + if "customer_id" in low or "product_id" in low: + # 点查型条件不命中 + return "not_match" + if "count(" in low or "sum(" in low or "avg(" in low: + return "zero" + return "no_data" diff --git a/app/service/cache_service.py b/app/service/cache_service.py new file mode 100644 index 0000000..5960eb3 --- /dev/null +++ b/app/service/cache_service.py @@ -0,0 +1,132 @@ +"""查询缓存(D-06):结果缓存 + 模板缓存,Redis 可选(未连上则用内存降级)。 + +键构造包含权限指纹,TTL 按表分层:交易类 5min / 台账类 1h / 基础信息类当日。 +""" +from __future__ import annotations + +import hashlib +import json +import time +from dataclasses import dataclass +from typing import Any + +from app.config.settings import settings + +# 表 → 缓存 TTL(秒) +TABLES_TTL = { + "core_trade": 5 * 60, + "core_cash_flow": 5 * 60, + "core_holding": 5 * 60, + "risk_alert": 60 * 60, + "core_customer": 24 * 60 * 60, + "core_product": 24 * 60 * 60, + "core_product_nav": 24 * 60 * 60, +} +DEFAULT_TTL = 60 * 60 + + +class CacheBackend: + def get(self, key: str) -> Any | None: + raise NotImplementedError + + def set(self, key: str, value: Any, ttl: int) -> None: + raise NotImplementedError + + def delete(self, key: str) -> None: + raise NotImplementedError + + +class InMemoryBackend(CacheBackend): + """内存缓存(Redis 不可用时的降级)。""" + + def __init__(self) -> None: + self._store: dict[str, tuple[float, Any]] = {} + + def get(self, key: str) -> Any | None: + item = self._store.get(key) + if not item: + return None + exp, value = item + if time.time() > exp: + self._store.pop(key, None) + return None + return value + + def set(self, key: str, value: Any, ttl: int) -> None: + self._store[key] = (time.time() + ttl, value) + + def delete(self, key: str) -> None: + self._store.pop(key, None) + + +class RedisBackend(CacheBackend): + def __init__(self, url: str) -> None: + import redis # 延迟导入,避免无 redis 环境报错 + + self._r = redis.Redis.from_url(url, socket_connect_timeout=2, socket_timeout=2, decode_responses=False) + + def get(self, key: str) -> Any | None: + try: + raw = self._r.get(key) + except Exception: + return None + if raw is None: + return None + try: + return json.loads(raw) + except Exception: + return None + + def set(self, key: str, value: Any, ttl: int) -> None: + try: + self._r.set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl) + except Exception: + pass + + def delete(self, key: str) -> None: + try: + self._r.delete(key) + except Exception: + pass + + +@dataclass +class CacheService: + backend: CacheBackend + + @classmethod + def auto(cls) -> "CacheService": + """优先 Redis,连不上则降级内存。""" + try: + import redis + + r = redis.Redis.from_url(settings.redis_url, socket_connect_timeout=1, socket_timeout=1) + r.ping() + return cls(RedisBackend(settings.redis_url)) + except Exception: + return cls(InMemoryBackend()) + + def sql_hash(self, sql: str) -> str: + return hashlib.sha256(sql.strip().encode("utf-8")).hexdigest()[:16] + + def permission_fingerprint(self, subject_id: str, domain: str, scope: list[str] | None = None) -> str: + raw = f"{subject_id}|{domain}|{','.join(sorted(scope or []))}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12] + + def result_key(self, perm_fp: str, sql_hash: str) -> str: + return f"cache:analyst:result:{perm_fp}:{sql_hash}" + + def ttl_for(self, tables: list[str]) -> int: + for t in tables: + if t in TABLES_TTL: + return TABLES_TTL[t] + return DEFAULT_TTL + + def get_result(self, perm_fp: str, sql: str) -> Any | None: + return self.backend.get(self.result_key(perm_fp, self.sql_hash(sql))) + + def set_result(self, perm_fp: str, sql: str, value: Any, tables: list[str]) -> None: + self.backend.set(self.result_key(perm_fp, self.sql_hash(sql)), value, self.ttl_for(tables)) + + def invalidate_by_sql(self, perm_fp: str, sql: str) -> None: + self.backend.delete(self.result_key(perm_fp, self.sql_hash(sql))) diff --git a/app/service/dict_service.py b/app/service/dict_service.py new file mode 100644 index 0000000..3c55625 --- /dev/null +++ b/app/service/dict_service.py @@ -0,0 +1,85 @@ +"""口径字典(D-07 / N-01):指标名 → 定义/公式/适用表;多义词消歧。""" +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class Metric: + key: str + name: str + aliases: list[str] = field(default_factory=list) + definition: str = "" + unit: str = "" + time_window: str = "" + + def to_dict(self) -> dict: + return { + "key": self.key, + "name": self.name, + "aliases": self.aliases, + "definition": self.definition, + "unit": self.unit, + "time_window": self.time_window, + } + + +@dataclass +class Ambiguity: + """指标多义(N-01):同一说法命中多个口径。""" + + term: str + candidates: list[Metric] = field(default_factory=list) + + +class MetricRegistry: + """内存口径字典;后续可从 analytics_metric_dict 表加载(DB 版由 analytics_repo 提供)。""" + + def __init__(self) -> None: + self._metrics: list[Metric] = [] + + def add(self, metric: Metric) -> None: + self._metrics.append(metric) + + def all(self) -> list[Metric]: + return list(self._metrics) + + def _match(self, term: str) -> list[Metric]: + t = term.strip() + exact: list[Metric] = [] + contains: list[Metric] = [] + for m in self._metrics: + names = [m.key, m.name, *m.aliases] + if t in names: + exact.append(m) + elif any(n in t for n in names): + contains.append(m) + # 优先精确命中;无精确时才退回包含匹配 + return exact if exact else contains + + def resolve(self, term: str) -> Metric | Ambiguity | None: + """返回单个口径 / 歧义候选 / None(无此口径)。""" + hits = self._match(term) + if not hits: + return None + if len(hits) > 1: + return Ambiguity(term=term, candidates=hits) + return hits[0] + + +def default_registry() -> MetricRegistry: + """内置口径种子(与需求 D-07 关键指标对齐)。""" + reg = MetricRegistry() + reg.add(Metric("holding_scale", "持仓规模", ["规模", "市值", "持仓市值"], + "客户当前持仓市值合计", "元", "最新交易日")) + reg.add(Metric("product_scale", "产品规模", ["规模", "产品规模"], + "产品管理规模合计", "元", "最新交易日")) + reg.add(Metric("trade_amount", "申购金额", ["申购金额", "申购额"], + "申购交易金额合计", "元", "近30天")) + reg.add(Metric("redeem_amount", "赎回金额", ["赎回金额", "赎回额"], + "赎回交易金额合计", "元", "近30天")) + reg.add(Metric("customer_count", "客户数", ["客户数量", "客户数"], + "客户数量", "个", "当前")) + reg.add(Metric("risk_count", "高风险客户数", ["高风险客户数", "高风险数量"], + "风险等级为 C4/C5 的客户数量", "个", "当前")) + return reg diff --git a/app/service/guardrail.py b/app/service/guardrail.py new file mode 100644 index 0000000..1edd2de --- /dev/null +++ b/app/service/guardrail.py @@ -0,0 +1,96 @@ +"""数字护栏(D-10):解读数字与 SQL 结果逐字比对,防止 LLM 说错数字。 + +四道校验中,①数字比对在本模块做实实现;②单位/币种、③时间窗、④抽样复核 +由 agent 编排层配合完成(重试 1 次 → 降级输出),本模块提供可测试的核心。 +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +NUMBER_RE = re.compile(r"-?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?") +PERCENT_RE = re.compile(r"-?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?\s*%") + + +@dataclass +class GuardrailResult: + passed: bool + issues: list[float] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +def extract_numbers(text: str) -> list[float]: + """提取文本中的数字(含小数、负数、千分位逗号)。""" + return [float(x.replace(",", "")) for x in NUMBER_RE.findall(text or "")] + + +def extract_percent_numbers(text: str) -> list[float]: + """提取百分比数字(去掉 %)。""" + return [float(m[:-1]) for m in PERCENT_RE.findall(text or "")] + + +def _to_float(v) -> float | None: + if isinstance(v, bool): + return None + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + try: + return float(v.replace(",", "").replace("%", "").strip()) + except ValueError: + return None + return None + + +def result_numbers(table) -> set[float]: + """从结果表推导「合法数字集合」:单元格值 + 数值列求和 + 行数(0 恒为合法)。""" + nums: set[float] = {0.0} + if not table or not table.rows: + return nums + rows = table.rows + nums.add(float(len(rows))) + ncols = len(rows[0]) + for row in rows: + for cell in row: + f = _to_float(cell) + if f is not None: + nums.add(round(f, 4)) + for ci in range(ncols): + col = [_to_float(row[ci]) for row in rows] + if all(v is not None for v in col): + nums.add(round(sum(v for v in col if v is not None), 4)) + return nums + + +def _approx_in(n: float, allowed: set[float], tol: float = 0.01) -> bool: + for a in allowed: + if abs(a - n) <= tol * max(1.0, abs(a)): + return True + return False + + +def check_numbers(answer: str, table) -> list[float]: + """返回解读中出现、但结果表推导不出的数字(视为疑似说错)。 + + 支持常见单位换算:解读带「万/亿」时,同时尝试按 1e4 / 1e8 放大比对。 + """ + allowed = result_numbers(table) + scales = [1.0] + if "万" in answer: + scales.append(10000.0) + if "亿" in answer: + scales.append(100000000.0) + issues: list[float] = [] + for n in extract_numbers(answer): + if not any(_approx_in(n * s, allowed) for s in scales): + issues.append(n) + return issues + + +def verify(answer: str, table, data_as_of: str | None = None) -> GuardrailResult: + """四道校验入口(当前实做①数字比对;其余由编排层兜底)。""" + issues = check_numbers(answer, table) + notes: list[str] = [] + if data_as_of: + notes.append(f"data_as_of={data_as_of}") + return GuardrailResult(passed=len(issues) == 0, issues=issues, notes=notes) diff --git a/app/service/llm.py b/app/service/llm.py new file mode 100644 index 0000000..c156c82 --- /dev/null +++ b/app/service/llm.py @@ -0,0 +1,73 @@ +"""DeepSeek LLM 客户端(对话 / NL2SQL / 解读)。""" +from __future__ import annotations + +import re + +import httpx + +from app.config.settings import settings + + +class LLMError(Exception): + pass + + +class DeepSeekLLM: + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + model: str = "deepseek-chat", + ) -> None: + self.api_key = api_key or settings.deepseek_api_key + self.base_url = (base_url or settings.deepseek_base_url).rstrip("/") + self.model = model + + def complete( + self, + messages: list[dict], + temperature: float = 0.0, + max_tokens: int = 2048, + ) -> tuple[str, dict]: + """返回 (文本, usage)。""" + if not self.api_key: + raise LLMError("缺少 DEEPSEEK_API_KEY") + r = httpx.post( + f"{self.base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json={ + "model": self.model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + }, + timeout=60, + ) + if r.status_code != 200: + raise LLMError(f"LLM HTTP {r.status_code}: {r.text[:300]}") + data = r.json() + content = data["choices"][0]["message"]["content"] + usage = data.get("usage", {}) + return content, usage + + def chat(self, messages: list[dict], temperature: float = 0.0, max_tokens: int = 2048) -> str: + text, _ = self.complete(messages, temperature, max_tokens) + return text + + +def extract_sql(text: str) -> str: + """从 LLM 回复中提取 SQL(去掉 ```sql 代码块等)。""" + m = re.search(r"```(?:sql)?\s*(.*?)```", text, re.IGNORECASE | re.DOTALL) + if m: + return m.group(1).strip() + return text.strip() + + +def estimate_cost(usage: dict) -> float: + """粗略成本估算(元),按 DeepSeek 通用单价量级。""" + prompt = int(usage.get("prompt_tokens", 0) or 0) + completion = int(usage.get("completion_tokens", 0) or 0) + return round((prompt * 1.0 + completion * 2.0) / 1_000_000, 6) diff --git a/app/service/schema_meta.py b/app/service/schema_meta.py new file mode 100644 index 0000000..ccf020c --- /dev/null +++ b/app/service/schema_meta.py @@ -0,0 +1,28 @@ +"""NL2SQL 的 schema 元数据注入(表/列说明,供 LLM 生成只读 SQL 时使用)。""" + +SCHEMA_PROMPT = """可用数据表(全部只读,禁止任何写操作): + +1. core_customer 客户主档:customer_id(客户编号), display_name(姓名), gender, age, occupation, annual_income(年收入), financial_asset(金融资产), is_hnw(高净值), service_tier, aml_risk_level, open_date, is_active +2. core_customer_risk 客户风险测评:customer_id, risk_code(C1~C5), max_loss_tolerance_pct, investment_goal, evaluated_at, is_authoritative +3. core_customer_advisor 客户-顾问归属:customer_id, advisor_id, rel_status(active/transferred/closed), effective_from +4. core_holding 持仓:customer_id, product_id, qty(份额), cost_amount(成本), market_value(市值), pnl_pct(盈亏百分比), as_of(截至日期) +5. core_trade 交易:trade_id, customer_id, product_id, trade_type('subscribe'=申购, 'redeem'=赎回), amount(金额), qty, channel, traded_at +6. core_cash_flow 资金流水:customer_id, flow_type('in'=入金, 'out'=出金), flow_subtype, amount, occurred_at +7. core_product 产品:product_id, product_name, product_type(公募基金/私募/理财/固收/信托/保险/贵金属), min_risk_code, term_days, fee_rate, is_open +8. core_product_nav 净值:product_id, nav(净值), daily_chg_pct, nav_date +9. core_staff 员工:staff_id, display_name, staff_type, roles +10. core_risk_grade 风险等级字典:code, grade_type, display_name, sort_order +11. jinrong_agent.risk_alert 预警台账:alert_id, customer_id, alert_type('large_amount'=大额/'freq_trade'=频繁/'suitability'=适当性/'aml'=反洗钱/'pattern'=模式), status('pending_review'=待处理/'confirmed_normal'/'confirmed_suspicious'/'reported'), risk_score, created_at + +关系提示: +- 客户风险等级 = core_customer_risk.risk_code(C1~C5),关联 core_customer.customer_id。 +- 客户持仓盈亏 = core_holding.pnl_pct;持仓市值 = core_holding.market_value。 +- 产品类型/名称 = core_product(按 product_id 关联持仓/交易)。 +- 顾问名下客户 = core_customer_advisor 中 rel_status='active' 的记录。 +- 预警状态统计 = jinrong_agent.risk_alert.status。 +- 时间用 traded_at / occurred_at / evaluated_at / nav_date / as_of。 + +MySQL 8 语法注意: +- 时间过滤请写 `traded_at >= CURDATE() - INTERVAL 30 DAY`(INTERVAL 后不带引号,单位用 DAY/MONTH)。 +- 不要写 `INTERVAL '30 days'`(那是 ANSI 写法,MySQL 不认)。 +""" diff --git a/app/service/sql_guard.py b/app/service/sql_guard.py new file mode 100644 index 0000000..018e623 --- /dev/null +++ b/app/service/sql_guard.py @@ -0,0 +1,173 @@ +"""SQL 五层校验(只读白名单 / 多语句拦截 / 表白名单 / 行级归属 / 粒度控制)。 + +说明:本环境无法安装 sqlglot,此处用「关键字 + 正则 + 白名单」实现自包含的只读强校验; +生产环境在数据库层叠加只读账号(双保险),并建议替换为 sqlglot AST 解析。 +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +# 禁止出现的写操作/危险关键字 +FORBIDDEN_KEYWORDS = ( + "insert", "update", "delete", "drop", "alter", "create", "truncate", + "grant", "revoke", "replace", "call", "exec", "load_file", + "into outfile", "into dumpfile", "sleep(", +) + +# 表白名单(数据分析 Agent 可读的表) +CORE_TABLES = { + "core_customer", "core_customer_risk", "core_customer_advisor", + "core_holding", "core_trade", "core_cash_flow", "core_product", + "core_product_nav", "core_staff", "core_risk_grade", + "core_suitability_rule", "core_industry", +} +AGENT_TABLES = { + "risk_alert", "customer_profile_l1", "customer_profile_l2", "customer_profile_l3", +} +ALL_TABLES = CORE_TABLES | AGENT_TABLES + +# 涉及客户维度的表(行级归属 / 粒度控制用) +CUSTOMER_TABLES = { + "core_customer", "core_customer_risk", "core_customer_advisor", + "core_holding", "core_trade", "core_cash_flow", +} + +# 敏感列(存储层已脱敏;此处供下游解读/日志二次校验,避免引用明文) +SENSITIVE_COLUMNS = { + "mobile", "phone", "id_card", "id_no", "idcard", "bank_card", "card_no", + "display_name", "real_name", "customer_name", +} + + +class SqlGuardError(Exception): + """SQL 校验失败(403)。""" + + def __init__(self, error_code: str, message: str) -> None: + super().__init__(message) + self.error_code = error_code + self.message = message + + +@dataclass +class ValidationResult: + allowed: bool + error_code: str = "" + message: str = "" + tables: list[str] = field(default_factory=list) + has_customer_detail: bool = False + + +def _split_statements(sql: str) -> list[str]: + return [s.strip() for s in sql.split(";") if s.strip()] + + +def extract_tables(sql: str) -> list[str]: + """从 FROM/JOIN 提取表名(去 db 前缀、反引号)。""" + names: list[str] = [] + for m in re.finditer(r"\b(?:from|join)\s+([`\w.]+)", sql, re.IGNORECASE): + t = m.group(1).strip("`") + names.append(t.split(".")[-1]) + return names + + +def extract_cte_names(sql: str) -> set[str]: + """提取 WITH ... AS 定义的 CTE 别名(这些不是物理表,应从白名单校验中排除)。""" + low = sql.lower() + m = re.search(r"\bwith\b(.+?)\bselect\b", low, re.DOTALL) + if not m: + return set() + names: set[str] = set() + for part in re.split(r",", m.group(1)): + mm = re.search(r"([\w]+)\s+as\s*\(", part) + if mm: + names.add(mm.group(1).lower()) + return names + + +def extract_customer_literals(sql: str) -> list[str]: + """提取 SQL 中出现的客户编号字面量。""" + return re.findall(r"CUST-[\w-]+", sql, re.IGNORECASE) + + +def _is_select(sql: str) -> bool: + s = sql.lstrip().lower() + return s.startswith("select") or s.startswith("with") + + +def _ops_has_customer_detail(sql: str) -> bool: + """运营(ops)粒度控制:剔除 COUNT(DISTINCT customer_id) 后仍出现 customer_id 即视为下钻客户维度。""" + low = sql.lower() + cleaned = re.sub(r"count\s*\(\s*(distinct\s+)?customer_id\s*\)", "", low) + return "customer_id" in cleaned + + +def validate(sql: str, domain: str, scope_customer_ids: list[str] | None = None) -> ValidationResult: + """对生成/改写的 SQL 做只读 + 表白名单 + 域规则校验。 + + domain: full(analyst) / assigned(advisor) / risk(risk_officer) / aggregate(ops) + """ + if not sql or not sql.strip(): + raise SqlGuardError("SQL_EMPTY", "SQL 为空") + + # 1) 只读 + 单语句 + stmts = _split_statements(sql) + if len(stmts) != 1: + raise SqlGuardError("SQL_MULTI_STATEMENT", "禁止多语句") + if not _is_select(stmts[0]): + raise SqlGuardError("SQL_NOT_SELECT", "仅允许 SELECT 只读查询") + low = sql.lower() + for kw in FORBIDDEN_KEYWORDS: + if kw in low: + raise SqlGuardError("SQL_FORBIDDEN", f"检测到危险关键字:{kw}") + + # 2) 表白名单(CTE 别名除外) + tables = extract_tables(sql) + cte_names = extract_cte_names(sql) + unknown = [t for t in tables if t not in ALL_TABLES and t.lower() not in cte_names] + if unknown: + raise SqlGuardError("SQL_TABLE_NOT_ALLOWED", f"表不在白名单:{', '.join(unknown)}") + + result = ValidationResult(allowed=True, tables=tables) + + # 3) 域规则 + if domain == "ops" or domain == "aggregate": + if _ops_has_customer_detail(sql): + raise SqlGuardError("AUTH_403_SCOPE", "运营角色仅可查客户维度之上的聚合结果") + if extract_customer_literals(sql): + raise SqlGuardError("AUTH_403_SCOPE", "运营角色不可查指定客户") + + elif domain == "advisor" or domain == "assigned": + literals = extract_customer_literals(sql) + scope = set(scope_customer_ids or []) + out = [c for c in literals if c.upper() not in {s.upper() for s in scope}] + if out: + raise SqlGuardError("AUTH_403_NOT_ASSIGNED", f"无权访问客户:{', '.join(out)}") + # 涉及客户表的查询必须显式带归属过滤(customer_id / advisor_id),否则视为未收敛范围 + touches_customer = any(t in CUSTOMER_TABLES for t in tables) + if touches_customer and "customer_id" not in low and "advisor_id" not in low: + raise SqlGuardError("AUTH_403_SCOPE", "涉及客户数据的查询必须包含归属过滤条件") + 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) + + elif domain == "full" or domain == "analyst": + result.has_customer_detail = any(t in CUSTOMER_TABLES for t in tables) + + else: + raise SqlGuardError("AUTH_403_ROLE", f"未知数据域:{domain}") + + return result + + +def inject_ownership(sql: str, customer_ids: list[str]) -> str: + """行级归属强制注入(advisor):把名下客户白名单包成子查询过滤。 + + 仅当 SQL 结果暴露 customer_id 时可安全包裹;否则由生成阶段的 prompt 注入口径。 + """ + if not customer_ids: + raise SqlGuardError("AUTH_403_SCOPE", "无可用归属白名单") + id_list = ", ".join(f"'{c}'" for c in customer_ids) + return f"SELECT * FROM ({sql.strip().rstrip(';')}) AS _scoped WHERE customer_id IN ({id_list})" diff --git a/app/utils/auth.py b/app/utils/auth.py new file mode 100644 index 0000000..5b5cf79 --- /dev/null +++ b/app/utils/auth.py @@ -0,0 +1,110 @@ +"""鉴权: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 67b997b..cea8607 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -27,3 +27,4 @@ - [x] 2026-09-05 `core_ro.py` + `settings.mysql_core_database` + sync 脚本 - [x] 2026-09-05 Agent 编排依赖改为 LangGraph(requirements.txt) - [x] 2026-09-05 memory 文件夹更新(新 Agent 交接清单) +- [x] 2026-09-09 数据分析 Agent 需求规格定稿 v1.1(`docs/需求拆解/01-数据分析Agent需求规格.md`,含 D-01~D-12 + 缓存/三层记忆方案) diff --git a/docs/数据分析Agent一页总结.md b/docs/数据分析Agent一页总结.md new file mode 100644 index 0000000..c2d82f5 --- /dev/null +++ b/docs/数据分析Agent一页总结.md @@ -0,0 +1,51 @@ +# 数据分析 Agent · 一页总结(白话版) + +## 这是什么? + +一个**帮你查数据的智能助手**。你像跟同事聊天一样打字问问题,它就去数据库里把数查出来,用**人话 + 表格**回答你。你**不用会写代码、不用等审批工单**。 + +## 它解决什么问题? + +**现在的麻烦:** 大部分员工不会写查询语句,想查个数得提工单 → 层层审批 → 等专门的人写 → 一等就是 1~3 天。 + +**有了它之后:** 张嘴问,分钟级出结果。比如: + +> 你问:"我名下高风险客户有几个?" +> 它答:人话解释 + 一张表格 + 数据来源。 + +## 它是怎么工作的?(5 步,简化版) + +``` +1. 你打字问问题 +2. 它先确认"你是谁、能看哪些数据"(权限) +3. 把你说的话翻译成"查数据的指令" +4. 去数据库查,并检查有没有查错、数字对不对 +5. 用人话 + 表格回答你,同时留下记录 +``` + +## 安全怎么保证?(金融公司最看重的 3 条) + +1. **只能查,不能改** —— 数据是"只读"的,谁也别想改、别想删。 +2. **该看多少看多少** —— 理财顾问只能看自己名下的客户;查别人的会被系统拒绝。 +3. **隐私打码** —— 身份证、手机号这些,从源头就只露一点点。 + +## 我们已经准备好了哪些东西? + +| 东西 | 作用 | +|---|---| +| 需求文档 | 说清楚"要做成什么样" | +| 架构说明书 | 说清楚"具体怎么搭"(给开发看的施工图) | +| 建表文件 | 3 张新表,用来存"好问题、指标解释、模板" | +| 检查工具 | 一个专门查"数据指令安不安全"的小工具 | + +## 下一步要做什么? + +真正动手写功能时,开发人员会: + +1. 把那个检查工具装到电脑上 +2. 在数据库里建出那 3 张新表 +3. 照着架构说明书,一块一块把功能做出来 + +--- + +> 一句话记住:**让不会写代码的人,也能安全、快速地查数据。** diff --git a/docs/需求拆解/01-数据分析Agent需求规格.md b/docs/需求拆解/01-数据分析Agent需求规格.md new file mode 100644 index 0000000..1b6446a --- /dev/null +++ b/docs/需求拆解/01-数据分析Agent需求规格.md @@ -0,0 +1,458 @@ +# 数据分析 Agent 需求规格(答辩版 v1.2) + +> 定位:数据分析 Agent 专项需求规格 · 答辩展示用 · 需求基线以 JinRong 仓库已落地拆解为准 +> 范围声明:本文档只描述**数据分析 Agent**;其余 Agent 不在本文档范围内 +> 状态:§0~§9 已定稿(§9 含缓存+三层记忆方案);v1.2 新增 §0.4~§0.8 背景论证、§1 痛点补全、§3 N-01~N-08 用例、§5 非功能补充;"新表结构"细节留待开发期 + +--- + +## §0 项目背景 + +### 0.1 公司业务 + +以**代销**为主的金融公司——产品线覆盖公募基金、私募基金、理财、固收、信托、保险、贵金属。本质是帮持牌机构代销产品、赚取服务费。**数据资产是业务的血液**:客户的持仓、交易、风险等级、产品销量,天天有人要。 + +### 0.2 现状:两类"查数人",两种困境 + +数据需求方天然分成两类——**少数会写 SQL 的,和大多数不会写 SQL 的**。 + +**① 视角 A:会写 SQL 的数据分析师**(数据中心/数据组的稀缺资源) + +``` +业务方提需求 → 分析师写 SQL 取数(同类问题反复写)→ 交付一堆表格 → 业务看不懂 → 分析师口头解释 +``` + +痛点: +1. **人肉取数机**:重复取数需求占日常大头,深度分析时间被挤占 +2. **口径靠个人**:同一问题不同分析师写出的 SQL 不同、结果对不上,为口径反复返工 +3. **交付即表格**:还要逐条解释给业务听 +4. **安全顾虑**:不敢把数据库开放给业务自助写 SQL——怕乱写、注入、误操作 + +**② 视角 B:不会写 SQL 的内部员工**(业务、运营、理财经理等大多数) + +``` +员工想查数 → 自助报表覆盖不了 → 提工单 → 数据专员/主管审批 +→ 敏感/超权限需求 → 安全部/信息部审计加签 → IT 数据管理接单 → 写 SQL → SQL 安全审核 +→ 交付 → 关闭工单 → 全程留痕 +``` + +痛点: +1. **慢**:审批链走完急数等不起 +2. **依赖人**:能否查到、对不对,全看写 SQL 的人 +3. **权限模糊**:不知道"我能看什么",敏感数据加签沟通成本高 +4. **留痕体系笨重**:安全审计正确但流程冗长 + +### 0.3 结构性矛盾 + +> 需求在绝大多数人手里,供给却在极少数人手里,中间还隔着一整条审批工单链。 +> 数据分析 Agent 要做的:**把"会写 SQL 的人"的生产力,变成所有人都能自助调用的服务**——会写 SQL 的解放出来养 Agent,不会写 SQL 的自助查数,权限与安全由系统硬约束兜底。 + +### 0.4 监管合规背景(一切硬约束的来源) + +以**代销**为主的金融公司,业务直接受证监会/基金业协会监管,客户数据与信息同时受《个人信息保护法》《数据安全法》《网络安全法》约束。**"只读不写、列级脱敏、全链路留痕、适当性不可绕过、面向客户内容须人工审核"这些在本文档里是功能需求,本质上都是监管红线**——不是加分项,而是上线前提。这也解释了为什么数据分析 Agent 把安全合规(§2 五层隔离、§5 审计留痕)放在与查数能力同等甚至更高的位置。 + +### 0.5 为什么是 Agent,而不是现有 BI + +公司已有(或存在)帆软 / PowerBI / Tableau 一类固定报表工具,或数据中台。BI 的本质是**"按预定义维度看固定模板"**:报表维度、口径、下钻路径都是 IT 预先固化的,业务只能在框内点选。而数据分析 Agent 补的是 BI 覆盖不到的**"任意组合维度 + 即时追问 + 人话解读"**——「我名下 C3 以上且持有股票型基金又亏超 5% 的客户」这种临时组合,BI 无法预置,只能找分析师写 SQL。二者是互补关系:**BI 管"已知要看的固定报表",Agent 管"临时冒出来的新问题"**。 + +### 0.6 量化现状与目标基线 + +> 现状为估算口径,上线后以真实运营数据回填作为效果基线。 + +| 指标 | 现状(估) | 目标(一期上线后) | +|---|---|---| +| 单次取数工单平均闭环时长 | 1~3 天(含审批/加签/排期) | 分钟级自助出数 | +| 重复/同形态取数占比 | 占取数需求大头 | 缓存命中率 ≥ 60% | +| 分析师 : 业务需求人数比 | 1 : N(严重倒挂) | 分析师转向"养 Agent" | +| 口径不一致返工 | 同类问题反复对账 | 关键指标一问一义(D-07) | + +### 0.7 为什么是现在 + +过去 NL2SQL 不敢上生产,是因为模型准确率不足、幻觉不可控、权限兜不住。现在**大模型 NL2SQL 能力 + 只读强校验 + 五层权限隔离 + 数字护栏(D-10)** 同时到位,让"可审计地让业务自助问数"从风险变成可控工程——这正是本项目的时点窗口。 + +### 0.8 为什么先做数据分析 Agent(四 Agent 全局定位) + +四个 Agent(客户财富 / 代理人 / 数据分析 / 风控)共用数据层与合规底座。数据分析 Agent 被排在第一波,是因为它**只读、不碰客户资金、不触达 C 端、合规面最小、闭环最快**——是四个里风险最低、最容易打样的一个;跑通后其口径字典 / 缓存 / 权限隔离可复用到其余 Agent。 + +--- + +## §1 痛点 → AI 价值映射 + +| # | 视角 | 痛点 | Agent 做什么 | 可验证收益 | +|---|---|---|---|---| +| 1 | A 分析师 | 人肉取数机,重复写 SQL | NL2SQL 自助查数 | 从"写 SQL"转向"养 Agent"(口径字典/few-shot) | +| 2 | A 分析师 | 口径不统一,结果打架 | 口径字典+元数据注入,一问一义 | 减少返工与扯皮 | +| 3 | A 分析师 | 交付表格还要费心解释 | 人话结论解读 | 业务看答案即懂 | +| 4 | A 分析师/安全 | 业务方乱写 SQL 有破坏风险 | 只读强校验,仅允许 SELECT | 数据库只读兜底 | +| 5 | B 员工 | 工单审批排队,急数等不起 | 自然语言即时问答 | 分钟级出数,免排队 | +| 6 | B 员工 | 权限模糊、敏感数据全靠人工加签 | RBAC 行级权限自动裁决 | 该看的秒查,不该看的 403,全程留痕 | +| 7 | 公司 | 重复查询反复烧资源 | 查询缓存 | 省 LLM 与数据库成本 | +| 8 | B 员工/公司 | 指标口径二义性("规模/盈亏/近30天"多定义) | 指标消歧反问 + 口径字典一问一义 | 从根上减少返工扯皮 | +| 9 | B 员工 | 数据新鲜度不可感知,拿过期数当实时用 | 响应带 data_as_of 数据截至时间 | 决策不再踩"过时数据"的坑 | +| 10 | B 员工 | "查不到"与"结果为 0"混淆 | 空结果/零结果区分(N-02) | 避免把"没数据"误判成"没有" | +| 11 | 分析师/合规 | 唯一能看明文的人,管控却最弱 | 明文查看二次确认 + 时效授权 + 单独审计 | 最大风险点被硬约束 | +| 12 | 公司 | 查询成本不可感知、无节制提问 | 成本计量 + 配额限流(N-04) | 成本可见可控 | +| 13 | B 员工 | 好结果不能复用/分享/订阅 | 结果保存/分享/订阅(N-05) | 一次查询,全员复用 | +| 14 | B 员工 | 数字不可信、无法溯源明细 | 聚合可钻取抽样明细(N-03) | 业务能自证,AI 数才敢用 | +| 15 | 公司 | 分析师离职,口径失传 | 资产半自动沉淀 + 版本化/回滚(D-11) | 口径资产不随人走 | + +--- + +## §2 角色与权限矩阵(含权限隔离设计) + +### 2.1 角色矩阵 + +| 角色 | staff_type | 数据域 | 明细粒度 | 敏感列 | +|---|---|---|---|---| +| 数据分析师 | analyst | 全量 | 全明细 | ✅ 可见(唯一可维护口径字典者) | +| 理财顾问 | advisor | **仅名下客户**(core_customer_advisor active 归属) | 名下客户明细 | 手机号等**脱敏** | +| 风控专员 | risk_officer | 台账全量 + 客户只读 | 台账含触发规则/风险分 | 客户脱敏 | +| 运营/业务等 | ops 等 | **无客户维度** | 仅聚合(产品/时间/风险等级) | 一律不可见 | + +> 合规官(compliance):一期不开放直接查数,权限后续另行设计;越权行为由 RBAC 隔离兜底。 + +### 2.2 权限隔离:五层纵深 + +| 层 | 机制 | 说明 | +|---|---|---| +| 1 身份 | Mock JWT(HS256,24h 续期) | Payload 含 user_type / employee_role,确定"你是谁" | +| 2 角色 | RBAC | 角色决定数据域(可碰哪些表)与操作(全部只读) | +| 3 **行级归属** | **归属强制注入 + 二次校验** | 顾问查询在 SQL 生成阶段强制注入 `WHERE customer_id IN (名下 active 客户)`,非模型自觉;归属白名单实时取自归属表(转岗/离职立即失效);AST 校验阶段检查查询范围是否超授权,超范围 → 403 | +| 4 列级脱敏 | 存储层已脱敏 | 身份证前3后4、手机号前3后4、姓名留姓、卡号留后4;API/日志/归档全链路一致 | +| 5 粒度控制 | 聚合 vs 明细 | 运营只能拿"客户维度之上"的聚合;任何下钻到客户维度 → 拒绝 + 说明原因 | + +**越权处理**:403 + 双留痕(`audit_log` 总账 + `analytics_query_log` 记录问题与拒绝原因),可演示"顾问 A 问顾问 B 的客户 → 被拒且留痕可查"。 + +**答辩一句话**:权限隔离不是"提示词别越权",而是身份 → 角色 → 行级归属注入 → 列级脱敏 → 粒度控制五层纵深;行级由系统强制注入并二次校验,列级在存储层就脱敏,越权行为全量留痕。 + +--- + +## §3 需求用例明细 + +### 输出结构(全局约定) + +```json +{ + "answer": "人话解读…", + "table": { "columns": ["risk_code", "cnt"], "rows": [["C1", 4], ...] }, + "sql": "SELECT …", + "meta": { "exec_ms": 45, "row_count": 5, "cache_hit": false, "data_as_of": "2026-09-04", "source": "jinrong_core", "cost_est": 0.002 }, + "disclaimer": "本内容仅为投资分析参考,不构成任何直接投资建议,不构成对任何产品的收益承诺,据此操作风险自负,请谨慎对待。" +} +``` + +### D-01 客户侧查数(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为理财顾问/分析师,我想用大白话查客户持仓、流水、规模、风险分布,不写 SQL 即掌握客户现状 | +| 输入示例 | 「我名下 C3 及以上风险等级的客户有几个?」「CUST-9527 的持仓和盈亏是多少?」 | +| 期望输出 | 人话解读 + 表格 + SQL + 来源 | +| 拒绝/边界 | 越权客户 → 403 留痕;语句含买卖倾向 → 拒答 | +| 验收口径 | 两类示例问题返回「解读+表格+SQL」;顾问查非名下客户得到明确拒绝且审计可查 | + +### D-02 产品侧查数(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为内部员工,我想查产品的销售/规模/净值等事实数据 | +| 输入示例 | 「近 30 天各类型产品申购金额排名」「PROD-005827 最新净值多少」 | +| 期望输出 | 销售/规模/净值**事实**,可溯源 | +| 拒绝/边界 | 不做优劣对比/推荐——「哪只产品值得买」→ 拒答并说明 | +| 验收口径 | 事实回答正确;推荐类问题被拒 | + +### D-03 风险台账统计(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为风控专员/分析师,我想查预警台账的数量、状态、类型分布 | +| 输入示例 | 「当前未处理的预警有多少?」「预警按类型怎么分布?」 | +| 期望输出 | 数量/状态/类型分布统计 | +| 拒绝/边界 | 可看不可处置——「把 XX 预警标记为确认可疑」→ 拒答 | +| 验收口径 | 统计正确;处置意图被拒且留痕 | + +### D-04 留痕与输出边界(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为合规/分析师,我需要每次查数过程可追溯、输出不越界 | +| 机制 | 每次问答、生成的 SQL、结果摘要强制落 `analytics_query_log`(trace_id 可还原全链路);对外风格输出带免责声明;不生成可执行的买卖/处置指令 | +| 验收口径 | 任意一次问答可按 trace_id 还原「问题→SQL→结果→判定」 | + +### D-05 复杂交叉问数(P1) + +| 项 | 内容 | +|---|---| +| 输入示例 | 「名下客户中,持有股票型基金且亏损超 5% 的,按持仓规模排序」 | +| 机制 | 仅只读多表聚合;超权限字段拒绝 | +| 优先级 | P1(答辩提一句) | + +### D-06 查询缓存(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 重复查询不重复消耗 LLM/数据库资源 | +| 机制 | 结果缓存(同形态独立问题秒回)+ 模板缓存(同形态问题填参执行);命中留痕标 `cache_hit=true`;缓存键含权限指纹;PII 结果不落缓存或脱敏;TTL 分层(交易类 5min / 台账类 1h / 基础信息类当日);**源数据更新时主动失效**(交易/持仓/归属等事实表变更时 DEL 对应缓存键,不等 TTL 自然过期);响应 `meta` 增加 `data_as_of`(数据截至时间,区分 T+1 / 准实时) | +| 验收口径 | 同形态问题第二次响应显著加快,且留痕可见 cache_hit=true;源数据刷新后旧缓存不再被命中;响应可见 data_as_of | + +### D-07 口径统一(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 同一指标所有人查出口径一致,且口径定义可查 | +| 机制 | 口径字典(指标名→定义/公式/适用表)+ schema 元数据注入 + few-shot | +| 验收口径 | 「持仓规模」「在途资金」等关键指标跨角色查询结果一致、定义可调出 | + +### D-08 兜底与拒答(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 查不了/权限不足/问题含糊时,系统明确说明原因,绝不编造 | +| 机制 | 数据不在范围/权限不足/问题含糊/敏感意图 → 说明性拒绝 + 留痕;权限不足类拒答附带**可查范围引导**(如"你仅能查名下客户,可尝试问 X 类问题"),降低挫败、避免反复误触越权 | +| 验收口径 | 超范围问题得到说明性拒绝,而非编造数据;权限拒绝时附范围引导 | + +### D-09 多轮追问(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 支持基于上文的追加查询 | +| 输入示例 | 先问「我名下客户的风险等级分布」,再问「那再按产品类型分一下呢?」 | +| 机制 | 短期记忆承载会话上下文;追问走**上轮 SQL 受限改写**(非模板命中),改写失败自动降级为全新生成;**追问继承上轮口径四件套**(指标定义、时间窗、维度、脱敏级别),降级重生成时同样带上,避免"同一指标前后两个定义";看板钻取复用此链路 | +| 验收口径 | 追问可正确承接上文语义并给出对应结果 | + +### D-10 数字护栏:解读与结果一致性校验(P0 · 亮点) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为查数员工,我担心 AI 解读时说错数字;作为合规/分析师,我需要输出与数据**逐字对得上** | +| 机制 | 解读生成后强制校验,四道:① 提取解读中全部数字/金额,与 SQL 结果逐项比对;② **单位/币种/百分比一致性**(元 vs 万元、% vs bp);③ **时间窗一致性**("近 30 天"按自然日还是交易日解析正确);④ **聚合结果抽样复核**(抽 1~2 行回源明细核对)。任一不一致 → 重生成(默认 1 次)→ 仍不一致 → **降级输出**(只给表格 + "解读校验未通过,以数据为准"),绝不带错字 | +| 期望输出 | 正常:解读+表格+校验通过标记;降级:表格+校验失败提示 | +| 留痕 | 校验结果(通过/重试/降级)写入 result_summary + audit | +| 验收口径 | 造"解读故意写错数字"的用例必须被拦截并降级;正常用例校验全部通过 | +| 答辩台词 | **"LLM 有幻觉,但我们不赌它不犯——我们校验它。"** | + +### D-11 "养 Agent"闭环:Query → Sample → Dict 沉淀(P0 · 亮点) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为数据分析师,我不再给每个人写 SQL,而是把一次好查询变成 Agent 的长期资产 | +| 机制 | 每次查询结束后分析师可沉淀三类资产:① few-shot 示例(问题→正确 SQL 对)② 口径字典条目(指标名→定义/公式/适用表)③ 快速模板(参数化 SQL 模板);**沉淀后需分析师确认发布才生效**;生效后同类问题命中新资产(与 D-06/D-07 联动,喂养模板缓存);**资产版本化 + 可回滚 + 可灰度**(发布错一键回退,先灰度部分用户验证再全量);系统从每次已执行 SQL 中**半自动提炼候选模板**,分析师仅确认,降低沉淀成本 | +| 边界 | **仅 analyst 角色可写**;每次沉淀操作审计留痕;沉淀内容可溯源 | +| 涉及存储 | 新增 `analytics_few_shot` + `analytics_query_template` 表(或与字典合一,**表结构开发期确认**) | +| 验收口径 | 分析师沉淀一条 few-shot 并发布后,同类新问题回答引用到它(留痕可见 source=沉淀资产);字典条目可被解读引用并附链路 | + +### D-12 智能看数板(P0 · 亮点) + +| 项 | 内容 | +|---|---| +| 定位 | 数据分析 Agent 的**零门槛入口**:不用提问,进来先看"我关心的关键指标"概览;是"预聚合概览 + 钻取入口",不是独立报表系统 | +| 角色化卡片 | 分析师:客户总数/总持仓规模/今日交易笔数与金额/待处理预警数/口径字典资产数;理财顾问:名下客户数/名下资产规模/盈亏分布/风险等级分布(仅名下);风控专员:待处理预警数/按类型分布/近 7 天新增趋势;运营:近 30 天申购/赎回金额/各产品类型规模 TOP(仅聚合) | +| 关键交互 | 卡片可**钻取追问**——点「待处理预警数」→ 进入「按类型分布一下」→ 复用 D-09 多轮追问链路,指标与对话打通 | +| 边界 | 看板数据同样走只读层 + 角色权限(顾问看板仅名下聚合,运营看板仅聚合);加载与钻取留痕;不做实时刷新(准实时/按需) | +| 本期形态 | 后端提供看板数据接口(结构化 JSON);前端下轮渲染可视化面板 | + +### N-01 指标消歧反问(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为查数员工,我用的词(规模/盈亏/近30天)有多个口径,系统应先跟我确认,而不是猜 | +| 机制 | 问题含多义指标时,Agent 列出候选口径并反问澄清("持仓规模是指市值还是成本?");澄清后绑定到本轮会话口径上下文,追问继承(联动 D-09) | +| 验收口径 | "规模"类多义问题被反问澄清,澄清后结果口径与所选定义一致 | + +### N-02 空结果 vs 零结果区分(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 查不到数时,我要知道是"确实为 0"、"无数据"、还是"口径条件不命中" | +| 机制 | 查询无数据时,区分三态:`zero`(真为 0)/ `no_data`(源无此数据)/ `not_match`(条件不命中),写入解读与留痕;绝不把"查不到"说成"没有" | +| 验收口径 | 三类空态分别得到准确说明,且留痕可见状态标记 | + +### N-03 溯源到明细(P0) + +| 项 | 内容 | +|---|---| +| 用户故事 | 聚合数字我要能钻到抽样明细自证,才敢信 AI 出的数 | +| 机制 | 聚合结果可钻取抽样明细(脱敏后,行数受限),回源可核对;与 D-10 护栏抽样复核共用链路 | +| 验收口径 | 任意聚合结果可按需展开抽样明细,明细与聚合一致 | + +### N-04 查询成本计量与配额(P1) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为系统负责人,我要知道谁在烧钱、每次问数成本多少,并能限流 | +| 机制 | 按用户/角色设查询预算与限流(复用 `guard:rate`);响应 `meta` 带本次成本估算;月度用量进运营看板 | +| 验收口径 | 超配额被限流并提示;meta 可见成本估算;用量可汇总 | + +### N-05 结果保存/分享/订阅(P1) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为查数员工,我查到的好结果想存下来、分享给同事、或订阅每日刷新 | +| 机制 | 普通员工可"存为我的常用 / 分享给同事(继承接收方权限)/ 订阅刷新";订阅走模板缓存填参执行,仅聚合、脱敏后落档 | +| 验收口径 | 保存/分享/订阅结果可复用,且分享结果不突破接收方权限 | + +### N-06 数据质量提示(P1) + +| 项 | 内容 | +|---|---| +| 用户故事 | 我要知道这数字里有没有脏数据(空值/异常/未归因),而不是被当成干净结论 | +| 机制 | 对空值、异常值、口径边缘(如"该指标含 X 条未归因数据")在解读中给提示,不掩盖数据质量问题 | +| 验收口径 | 含脏数据的指标查询,解读附带质量提示 | + +### N-07 人机协同兜底(P0 · 加分) + +| 项 | 内容 | +|---|---| +| 用户故事 | SQL 生成失败/超时/报错时,我不想卡死,要能一键转人工 | +| 机制 | 生成失败/超时/查不到表 → 除说明性拒答外,提供"一键转交分析师人工改写"入口,落审计(trace_id 串联);分析师改写结果可回填沉淀为 D-11 资产 | +| 验收口径 | 失败场景可转人工,且全链路留痕可还原 | + +### N-08 可观测性指标面板(P1) + +| 项 | 内容 | +|---|---| +| 用户故事 | 作为运营/负责人,我要看到 Agent 用得好不好、哪里要养 | +| 机制 | 运营指标面板:SQL 一次通过率、缓存命中率、拒答率、平均耗时、月成本、口径字典使用率、转人工率 | +| 验收口径 | 指标可实时/准实时查看,作为"养 Agent"决策依据 | + +--- + +## §4 数据范围与口径 + +### 4.1 数据源(一期 L0 模拟库,已核对种子) + +| 数据域 | 表 | 实况规模 | 写入方 → 读取 | +|---|---|---|---| +| 客户主档+风险等级 | `core_customer` + `core_customer_risk` | 28 位客户(C1~C5、高龄、大额特例) | Core → 只读 | +| 客户-顾问归属 | `core_customer_advisor` | 5 位顾问分属 10/6/5/4/3 人 | Core → 只读(RBAC 依据) | +| 持仓 | `core_holding` | ~45 条,as_of 2026-09-04 | Core → 只读 | +| 交易+资金流水 | `core_trade` / `core_cash_flow` | 24 笔交易(含大额样例)+ 7 条流水 | Core → 只读 | +| 产品+净值 | `core_product` / `core_product_nav` | 多只产品 + 净值 | Core → 只读 | +| 员工账号 | `core_staff` | 5 类角色种子 | Core → 只读 | +| 预警台账 | `jinrong_agent.risk_alert` | 由风控侧写入 | 只读(本 Agent 不写) | + +**答辩口径**:一期无真实 Core,用 L0 模拟库供给全部数据;真实环境换成正式 Core 只读连接,Agent 层代码零改动。 + +### 4.2 读写权限 + +- 只读:`jinrong_core` 全部、`risk_alert` +- 只增:`analytics_query_log`(审计,禁止修改删除) +- 分析师可写:口径字典表、few-shot/模板表(D-11,需确认发布生效) + +### 4.3 脱敏规则 + +身份证前3后4 / 手机号前3后4(种子已存 mask)/ 姓名留姓 / 卡号留后4;API 响应、日志、对话归档全链路一致。 + +--- + +## §5 非功能需求 + +| 类别 | 需求 | +|---|---| +| 安全 | 全部查询只读;注入/多语句拦截(AST 白名单 + 只读账号双保险);越权拒绝留痕;敏感字段脱敏 | +| 合规 | 免责声明(涉及建议倾向时);不处置预警;不做产品推荐;不对客直发 | +| 性能 | 查询超时与行数上限(默认 1000 行 / 10s,可调);重复查询走缓存 | +| 审计 | trace_id 全链路可还原(对话→SQL→结果→判定) | +| 可运营 | 口径字典、schema、few-shot 可维护可迭代(养 Agent,D-11),资产版本化可回滚可灰度 | +| 数据新鲜度 | 响应带 `data_as_of`;交易/持仓等事实数据源更新时缓存主动失效,不交付过期数 | +| 成本 | 查询成本可计量,按角色配额限流(N-04),月度用量可汇总 | +| 可观测性 | 运营指标面板(SQL 通过率/缓存命中率/拒答率/成本/转人工率,N-08) | +| 可用性 | 并发与 SLA:明确超时降级(SQL 超时/LLM 超时转拒答或转人工 N-07),不拖垮只读库 | + +--- + +## §6 边界(明确不做) + +- ❌ 任何写操作(改数、处置预警、改风险等级) +- ❌ 投顾推荐(「该买哪只」→ 拒答并说明) +- ❌ 对外输出(不直接对接客户) +- ❌ 自动化报表/定时任务(一期) +- ❌ 实时行情(用模拟净值数据) +- ❌ 导出 CSV/文件(P1 再议) +- ❌ 合规官单独查数角色(一期,靠 RBAC 隔离) + +--- + +## §7 验收场景(兼答辩演示脚本) + +每类人群 2~3 条端到端问答,演示即验收: + +| 角色 | 演示问答 | 期望结果 | +|---|---|---| +| 分析师 | 「按产品类型统计总持仓规模」 | 表格+解读+SQL;正确 | +| 分析师 | 「把 CUST-9527 持仓按盈亏排序」 | 表格+解读;无买卖倾向输出 | +| 理财顾问 | 「我名下客户有多少位高风险?」 | 仅名下统计;表格+解读 | +| 理财顾问 | 「查一下 CUST-1004(非名下)的持仓」 | **403 拒绝 + 审计留痕** | +| 风控专员 | 「当前待处理预警中属于大额的占比」 | 统计+解读;不可处置 | +| 运营 | 「近一个月申购金额总额」 | 聚合结果;无客户明细 | +| 任意角色 | 同形态问题重复问 | 第二次 cache_hit=true | +| 任意角色 | 「CUST-9527 持仓中金额加起来是 123 万元吗」 | 解读数字与表格一致(数字护栏) | +| 分析师 | 沉淀一条 few-shot 并发布后问同类问题 | 命中沉淀资产(留痕 source) | +| 任意角色 | 打开看数板 → 点卡片钻取 | 按角色出卡片;钻取进入对话 | +| 任意角色 | 先问风险分布,再追问「按产品类型分一下」 | 追问承接上文(D-09 改写链路) | + +--- + +## §8 合规定位 + +- AI 仅作**投资分析辅助**,不代客交易、不直接触及客户资金与下单环节 +- 面向客户的内容须由持证投顾/客户经理**审核确认**后对接客户 +- 一切投资分析、财富报告、行业分析等输出必须备注免责声明(见 §3 输出结构) +- 适当性匹配校验不可绕过(客户风险等级与产品风险等级匹配) + +--- + +## §9 待定项与决策记录 + +### 9.1 缓存 + 三层记忆方案(已定稿) + +``` +短期记忆(Redis·会话级·30min)→ D-09 追问上下文 / D-12 钻取链路 / 数字护栏重试 +中期记忆(Redis·用户级·轻量版)→ 常用查询 TOP + 偏好口径,调优缓存 TTL 与看板排序 +长期记忆(MySQL 资产表)→ D-11 沉淀资产(few-shot/字典/模板),喂养模板缓存 + +结果缓存:同形态独立问题直接秒回(键含权限指纹;PII 不落缓存或脱敏;命中标 cache_hit=true) +模板缓存:跨会话同形态问题填参执行(资产发布后自动入池) +追问变体:不走命中,走上轮 SQL 受限改写(Constrained Edit),失败自动降级为全新生成 +``` + +### 9.2 待开发期确认项 + +| # | 待定项 | 当前立场 | +|---|---|---| +| 1 | 口径字典表结构(`analytics_metric_dict` / `analytics_few_shot` / `analytics_query_template`) | 需求已定(D-07/D-11),表结构开发期确认,需用户确认改表 | +| 2 | D-12 钻取实现深度 | 已定做钻取;实现细节开发期细化 | +| 3 | 看板刷新策略 | 已定准实时/按需;不做实时推送 | + +### 9.3 决策记录 + +| 决策点 | 结论 | +|---|---| +| 范围 | 仅数据分析 Agent;基线=JinRong 拆解 | +| 缓存+口径 | 均纳入 P0 | +| SQL 安全 | AST 白名单 + 只读连接双保险 | +| P0 完成标准 | D-01~D-04 闭环(后扩展 D-09~D-12 + N-01/N-02/N-03/N-07 P0 增强) | +| 权限 | 四类角色分层;五层隔离(身份/角色/行级归属/列级脱敏/粒度) | +| 输出 | 四件套 answer+table+sql+meta;导出不做;合规官一期不开放 | +| D-09 | 做 P0,追问走 SQL 改写 | +| D-10 | 数字护栏,重试 1 次后降级 | +| D-11 | 养 Agent 闭环,确认发布后生效 | +| D-12 | 智能看数板,角色化卡片 + 钻取 | +| 缓存+记忆 | 结果/模板双层缓存 + 短期/中期/长期三层记忆(见 9.1) | +| 背景补全 | 监管合规背景 + "Agent vs BI"定位 + 量化基线 + 时点论证(§0.4~§0.8) | +| N-01~N-08 | 消歧反问 / 空零区分 / 溯源明细 / 成本配额 / 复用分享 / 数据质量 / 人机协同 / 可观测性 纳入需求基线(P0/P1 见 §3) | +| D-06/D-08/D-09/D-10/D-11 强化 | 缓存事件失效 + data_as_of;拒答引导;口径继承;护栏四道校验;资产版本化回滚灰度 | + +--- + +*本文档为需求基线,用例 ID 与仓库 `业务场景优先级清单.md` / `数据交互矩阵.md` 体系衔接;技术实现细节落地前需另行确认。* + +--- + +## 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| v1.0 | 2026-09 | 初版:D-01~D-05 + 五层权限隔离 | +| v1.1 | 2026-09 | 定稿 D-01~D-12 + 缓存/三层记忆方案(§9) | +| v1.2 | 2026-09 | 补监管背景与"Agent vs BI"论证(§0.4~§0.8);痛点补全(§1);新增 N-01~N-08;强化 D-06/D-08/D-09/D-10/D-11;§5 补数据新鲜度/成本/可观测性/可用性 | diff --git a/docs/项目框架设计/技术选型和版本/01-技术栈与版本.md b/docs/项目框架设计/技术选型和版本/01-技术栈与版本.md index af0628b..539899d 100644 --- a/docs/项目框架设计/技术选型和版本/01-技术栈与版本.md +++ b/docs/项目框架设计/技术选型和版本/01-技术栈与版本.md @@ -11,6 +11,7 @@ | 组件 | 版本 / 状态 | 备注 | | --- | --- | --- | | **后端** | Python 3.13.14 + FastAPI | 系统 Python;Agent 编排 **LangGraph** 1.2.x + `langchain-core` / `langchain-openai`(DeepSeek)/ `pymilvus` 3.0.1 | +| **SQL 安全** | `sqlglot`(AST 解析白名单) | 数据分析 Agent 只读 SQL 校验;与只读账号双保险(已确认新增) | | **关系库** | MySQL 8.0.46 | 原生安装,端口 **3306** ✓ | | **缓存** | Redis 8.10.1 | 原生安装,路径 `F:\Redis\...`,端口 **6379** ✓ | | **图库** | Neo4j 5.26.19(Enterprise) | Neo4j Desktop 2,已建库 ✓ | @@ -127,3 +128,4 @@ Agent 业务库脚本:[01-mysql-共用底座.sql](../项目框架设计/表设 | 日期 | 变更 | | --- | --- | | 2026-09-05 | 初版:Windows 原生 + Milvus Lite + 不用 MinIO + React 前端栈 | +| 2026-09 | 新增 `sqlglot`(数据分析 Agent SQL AST 白名单校验,已确认) | diff --git a/docs/项目框架设计/数据分析Agent开发清单.md b/docs/项目框架设计/数据分析Agent开发清单.md new file mode 100644 index 0000000..18cd955 --- /dev/null +++ b/docs/项目框架设计/数据分析Agent开发清单.md @@ -0,0 +1,117 @@ +# 数据分析 Agent · 开发 TODO 清单 + +> 依据:`docs/需求拆解/01-数据分析Agent需求规格.md`(v1.2)+ `docs/项目框架设计/数据分析Agent架构说明书.md`(v1.1) +> 用途:开发启动后的施工顺序清单;完成一项打钩、验证通过再往下。 +> 状态图例:⬜ 待办 · 🚧 进行中 · ✅ 完成 + +--- + +## 0. 开工前须知 + +1. **先看依赖**:阶段 0 的 4 项是平台组的地基,没做完,分析 Agent 动不了(对应仓库 `docs/memory/TODO.md` 的 T-01/T-02/T-05/T-06)。 +2. **只读铁律**:所有查数只 `SELECT`;`analytics_query_log` 与 `audit_log` 只 INSERT;分析师才能写资产表。 +3. **每完成一个阶段**:对照最下方「验收对照表」跑一遍演示用例,全过再进下一阶段。 + +--- + +## 阶段 0 · 前置地基(平台组,需先完成) + +| 编号 | 任务 | 对应需求 | 验收要点 | 状态 | +|---|---|---|---|---| +| P0-1 | JWT + RBAC 中间件(Mock JWT HS256) | F-01 | 越权 403 + 留痕;`X-Agent-Type: analyst` 准入 | ⬜ | +| P0-2 | 审计贯通:`trace_id` 全链路 + `audit_log` | F-02 | 任意问答可按 trace_id 还原 | ⬜ | +| P0-3 | 灌库:Core 模拟 + 共用底座 11 表 + sync | R0-CORE/R0-DB | `jinrong_core` 有种子;`customer_advisor_rel` 有归属 | ⬜ | +| P0-4 | 挂载路由 + LangGraph `agent_service` 骨架 | — | `/health` 之外能走通空 chat 链路 | ⬜ | + +--- + +## 阶段 1 · P0 最小闭环(D-01~D-04) + +**目标:能问、能查、能答、能留痕。** + +| 编号 | 任务 | 对应需求 | 架构落点 | 验收要点 | 依赖 | 状态 | +|---|---|---|---|---|---|---| +| A-01 | 建分析专用表(含 3 张新表) | D-11 | `03-mysql-analyst专用.sql` | 4 张 `analytics_*` 表可查 | P0-3 | ⬜ | +| A-02 | SQL 五层校验 `sql_guard` | D-04 / §2.2 | `service/sql_guard.py` | 只 SELECT;越权 403;归属注入 | P0-1 | ⬜ | +| A-03 | 只读执行 `sql_tool` + `core_ro_tool` | D-01~D-03 | `tool/sql_tool.py` `core_ro_tool.py` | 行数≤1000、超时 10s、空态标记 | A-02 | ⬜ | +| A-04 | 主链路 `analyst_agent`(StateGraph 节点串联) | D-01 | `service/analyst_agent.py` | NL→SQL→执行→解读 串通 | P0-4 | ⬜ | +| A-05 | 路由 `/api/analyst/chat` | D-01 | `api/analyst.py` | 请求进出、错误码规范 | A-04 | ⬜ | +| A-06 | 输出四件套 + 免责声明 | §3 全局约定 | `model/schemas/analyst.py` | answer+table+sql+meta+disclaimer | A-04 | ⬜ | +| A-07 | 留痕(query_log + tool_call + audit) | D-04 | `service/analytics_repo.py` | trace_id 串联可还原 | A-05 | ⬜ | + +**阶段 1 验收**:D-01 两类问数正确;顾问查非名下客户 403 且可查;预警只读不可处置。 + +--- + +## 阶段 2 · P0 增强(D-05~D-09) + +| 编号 | 任务 | 对应需求 | 架构落点 | 验收要点 | 依赖 | 状态 | +|---|---|---|---|---|---|---| +| A-08 | 口径字典 + schema 元数据注入 | D-07 | `service/dict_service.py` | 关键指标跨角色一问一义 | A-01 | ⬜ | +| A-09 | 结果/模板双层缓存 + data_as_of | D-06 | `service/cache_service.py` | 二答秒回、`cache_hit=true`、主动失效 | A-03 | ⬜ | +| A-10 | 多轮追问(Constrained Edit 改写) | D-09 | `analyst_agent` 改写节点 | 追问承接上文,口径继承 | A-04 | ⬜ | +| A-11 | 兜底拒答 + 可查范围引导 | D-08 | `analyst_agent` deny 分支 | 超范围说明性拒绝,权限类附引导 | A-02 | ⬜ | +| A-12 | 复杂交叉问数(多表只读聚合) | D-05 | `analyst_agent` | 交叉维度正确、超权限字段拒绝 | A-02 | ⬜ | + +**阶段 2 验收**:D-06 缓存命中可见;D-07 口径一致;D-09 追问正确;D-08 拒答清晰。 + +--- + +## 阶段 3 · P0 亮点(D-10~D-12 + N-01/02/03/07) + +| 编号 | 任务 | 对应需求 | 架构落点 | 验收要点 | 依赖 | 状态 | +|---|---|---|---|---|---|---| +| A-13 | 数字护栏四道校验 | D-10 | `service/guardrail.py` | 错数字被拦截,重试 1 次后降级 | A-06 | ⬜ | +| A-14 | 养 Agent 资产沉淀(few-shot/字典/模板) | D-11 | `analytics_repo` + assets 路由 | 沉淀后同类问题命中,可回滚可灰度 | A-08 | ⬜ | +| A-15 | 智能看数板后端接口 | D-12 | `/api/analyst/dashboard` | 按角色出卡片、钻取进对话 | A-04 | ⬜ | +| A-16 | 指标消歧反问 | N-01 | `analyst_agent` ambiguity 节点 | 多义词先反问,澄清后口径一致 | A-08 | ⬜ | +| A-17 | 空/零/不命中三态区分 | N-02 | `sql_tool` | 三种空态分别准确说明 | A-03 | ⬜ | +| A-18 | 聚合抽样明细溯源 | N-03 | `/api/analyst/query/{trace_id}/sample` | 明细与聚合一致 | A-07 | ⬜ | +| A-19 | 转人工兜底 | N-07 | `/api/analyst/escalate` | 失败场景一键转人工、留痕可还原 | A-07 | ⬜ | + +**阶段 3 验收**:D-10 造错用例被拦;D-11 沉淀生效;D-12 卡片钻取;N-01/02/03/07 各跑通。 + +--- + +## 阶段 4 · P1(N-04/05/06/08) + +| 编号 | 任务 | 对应需求 | 架构落点 | 验收要点 | 依赖 | 状态 | +|---|---|---|---|---|---|---| +| A-20 | 成本计量 + 配额限流 | N-04 | `meta.cost_est` + `guard:rate` | 超配额限流、成本可见 | A-07 | ⬜ | +| A-21 | 保存/分享/订阅 | N-05 | save/share/subscribe 路由 | 分享不突破接收方权限 | A-09 | ⬜ | +| A-22 | 数据质量提示 | N-06 | `answer_compose` | 脏数据附带质量提示 | A-06 | ⬜ | +| A-23 | 运营指标面板 | N-08 | `/api/analyst/ops/metrics` | 通过率/命中率/拒答率/成本/转人工率可查 | A-07 | ⬜ | + +**阶段 4 验收**:N-04~N-08 各自跑通。 + +--- + +## 验收对照表(演示即验收) + +| 演示问答 | 期望 | 对应阶段 | +|---|---|---| +| 分析师「按产品类型统计总持仓规模」 | 表格+解读+SQL,正确 | 1 | +| 顾问「查 CUST-1004(非名下)持仓」 | 403 + 审计留痕 | 1 | +| 风控「当前待处理大额预警占比」 | 统计+解读,不可处置 | 1 | +| 运营「近一月申购金额总额」 | 仅聚合,无客户明细 | 1 | +| 同形态问题重复问 | 第二次 `cache_hit=true` | 2 | +| 「数字加起来是 123 万吗」 | 数字护栏拦截或通过 | 3 | +| 沉淀 few-shot 后问同类问题 | 命中沉淀资产 | 3 | +| 点看板卡片钻取 | 进入对话、承接上文 | 3 | + +--- + +## 附:如何推进(建议节奏) + +1. **先钉阶段 0**:平台组 4 项是硬前置,未完成前分析组只做 A-01(建表)等不依赖接口的准备工作。 +2. **阶段 1 打通就"能用"**:D-01~D-04 是最小闭环,建议作为第一个里程碑对外演示。 +3. **阶段 2/3 是亮点**:缓存、口径、数字护栏、养 Agent、看数板是答辩得分点,务必做扎实。 +4. **阶段 4 可后置**:P1 项不影响一期上线,按人力排期。 + +--- + +## 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| v1.0 | 2026-09 | 初版:按需求 v1.2 + 架构说明书 v1.1 拆解为 0~4 阶段 23 项任务 | diff --git a/docs/项目框架设计/数据分析Agent架构说明书.md b/docs/项目框架设计/数据分析Agent架构说明书.md new file mode 100644 index 0000000..dded648 --- /dev/null +++ b/docs/项目框架设计/数据分析Agent架构说明书.md @@ -0,0 +1,606 @@ +# 数据分析 Agent 架构说明书 + +> 版本:v1.1 · 状态:评审稿(开发期随落地细化) +> 上游需求:《数据分析 Agent 需求规格(答辩版 v1.2)》`docs/需求拆解/01-数据分析Agent需求规格.md` +> 对齐约束:`docs/memory/FRAMEWORK.md`(分层/选型)、`docs/项目框架设计/表设计/`(16 表 + Redis Key)、`docs/项目框架设计/技术选型和版本/02-JWT-RBAC鉴权手册.md`、`docs/项目框架设计/Core模拟底座/` +> 范围声明:本文只描述**数据分析 Agent**;其余 Agent 仅作为「共用底座 / 数据供给方」出现,不在本文范围。 + +--- + +## 0. 文档说明 + +### 0.1 目的 + +把需求规格(D-01~D-12、N-01~N-08)翻译成**可实现的技术架构**:说清楚数据分析 Agent 由哪些模块组成、数据怎么流、权限怎么兜、SQL 怎么被约束、结果怎么被校验、资产怎么被沉淀,以及每一块落在仓库的哪个目录、哪张表、哪个 Key。 + +### 0.2 读者 + +| 读者 | 关注章节 | +|---|---| +| 开发(Wave 1 分析组) | §4 核心链路、§5 模块划分、§6 LangGraph 编排、§7 数据与存储、§13 接口 | +| 平台/底座组 | §8 权限与安全、§7.1 复用底座、§13 接口 | +| 产品/项目经理 | §1 原则、§2 总体架构、§15 落地顺序、§16 验收对照 | +| 合规/答辩 | §8 五层隔离、§10 数字护栏、§16 验收对照 | + +### 0.3 需求 ID 对照速查 + +| 需求 | 含义 | 架构落点 | +|---|---|---| +| D-01~D-04 | 客户/产品/预警查数 + 留痕边界 | §4 主链路、§8 权限 | +| D-05 | 复杂交叉问数 | §4 多表只读聚合 | +| D-06 | 查询缓存 | §9 双层缓存 | +| D-07 | 口径统一 | §7.2 口径字典、§4 元数据注入 | +| D-08 | 兜底与拒答 | §4 失败分支、§6 节点 | +| D-09 | 多轮追问 | §6 Constrained Edit、§9 短期记忆 | +| D-10 | 数字护栏 | §10 | +| D-11 | 养 Agent 闭环 | §11 | +| D-12 | 智能看数板 | §12 | +| N-01~N-08 | 消歧/空零/溯源/配额/复用/质量/转人工/可观测 | §4/§6/§7/§13 对应小节 | + +--- + +## 1. 架构定位与设计原则 + +### 1.1 定位(一句话) + +数据分析 Agent = 把「会写 SQL 的人」的生产力变成**所有人都能自助调用的只读查数服务**:自然语言进、安全 SQL 出、人话解读回、全程可审计、好查询沉淀为资产。 + +### 1.2 设计原则 + +| # | 原则 | 说明 | 对应需求 | +|---|---|---|---| +| P1 | **只读兜底** | 数据库只读账号 + AST 白名单双保险,任何写操作在生成与执行两层都被拦截 | D-04、§5 安全 | +| P2 | **权限硬约束,不靠提示词自觉** | 归属注入是系统行为,模型产出的 SQL 必须再被 AST 二次校验 | §2.2 五层隔离 | +| P3 | **数字不赌不猜** | 解读中的数字必须与 SQL 结果逐字比对,对不上就重生成或降级 | D-10 | +| P4 | **一问一义** | 指标先经口径字典消歧,多义先反问再生成 | D-07、N-01 | +| P5 | **全链路留痕** | `trace_id` 串联 问题→SQL→结果→判定,审计只 INSERT | D-04、F-02 | +| P6 | **资产可沉淀可回滚** | 好查询 → 分析师确认 → 版本化发布 → 可灰度可回退 | D-11 | +| P7 | **成本可控** | 双层缓存 + 配额限流,成本可计量 | D-06、N-04 | +| P8 | **可降级** | LLM 超时/报错/校验失败 → 说明性拒答或转人工,不拖垮只读库 | N-07、§5 可用性 | + +--- + +## 2. 总体架构 + +### 2.1 分层图 + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ 客户端(内部工作台) │ +│ 聊天窗 · 智能看数板卡片 · 资产沉淀台 · 运营指标面板 │ +└──────────────────────────────┬──────────────────────────────────────┘ + │ HTTPS + Bearer JWT + │ X-Agent-Type: analyst · X-Trace-Id +┌──────────────────────────────▼──────────────────────────────────────┐ +│ Agent Gateway / Auth SDK(共用底座) │ +│ 验签 → 角色准入 → 注入 AuthContext → 越权 401/403 写审计 │ +└──────────────────────────────┬──────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ app/api/analyst.py(薄路由) │ +│ /chat · /dashboard · /assets · /sample · /escalate · /ops │ +└──────────────────────────────┬──────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ app/service/analyst_agent.py(LangGraph StateGraph) │ +│ │ +│ input_guard → scope_resolve → ambiguity_check → context_retrieve │ +│ → sql_generate → sql_validate → execute → guardrail_check │ +│ → answer_compose → audit_persist ──(analyst)──> asset_candidate │ +│ │ +│ 组件:sql_guard(五层) · guardrail(数字护栏) · dict_service(口径) │ +│ cache_service(双层缓存) · analytics_repo(资产/留痕) │ +└──────┬───────────────┬───────────────┬───────────────┬──────────────┘ + ▼ ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Core RO │ │ agent 库 │ │ Redis │ │ LLM │ +│ 只读 Repository│ │ 只读/只增/资产│ │ 会话/缓存/限流│ │ DeepSeek │ +│ (jinrong_core)│ │ (jinrong_agent)│ │ │ │ NL2SQL+解读 │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +### 2.2 与四 Agent 共享底座的关系 + +数据分析 Agent **不建第二套账号/JWT/RBAC**,也不自建 Core 副本: + +- 身份/角色/归属 → 复用 Gateway + `customer_advisor_rel`(Core 同步)。 +- 事实数据 → `CoreReadOnlyRepository` 只读 `jinrong_core`。 +- 会话/消息/审计 → 复用 `agent_session` / `agent_message` / `agent_tool_call` / `audit_log` / `input_guard_log`。 +- 画像/预警 → 只读 `customer_profile_l1/l2/l3`、`risk_alert`(做统计,不可处置)。 +- 专属数据 → `analytics_query_log`(已定)+ §7.2 新增口径/示例/模板表。 + +--- + +## 3. 技术栈(引用既有选型,不新增) + +| 层 | 选型 | 说明 | +|---|---|---| +| 服务 | Python 3.13 + FastAPI | 薄路由,不含业务 | +| 编排 | **LangGraph 1.2.x** StateGraph + DeepSeek API | 状态机图,Tool 节点化 | +| 关系库 | MySQL 8(`jinrong_agent` + `jinrong_core` 双库) | 权威落盘 + Core 只读 | +| 缓存 | Redis 8 | 会话短期记忆 + 结果/模板缓存 + 限流 | +| SQL 安全 | `sqlglot`(AST 解析白名单) + 只读账号 | 已确认新增,登记于技术选型文档 | +| 图库/向量库 | Neo4j / Milvus(**一期分析 Agent 不依赖**) | 预留;关系与 RAG 归其他 Agent | + +> 一期数据分析 Agent 强依赖:MySQL 双库 + Redis + DeepSeek + `sqlglot`;Neo4j/Milvus 不阻塞。 + +--- + +## 4. 核心链路:NL → SQL → 解读 + +### 4.1 全流程(一次问答) + +```text +用户提问(自然语言) + │ + ▼ +① 输入防护 guard:rate 限流 → 注入/超长拦截 → input_guard_log + ▼ +② 权限上下文 解析 AuthContext;顾问/风控/运营的归属白名单/数据域 + scope_resolve(顾问白名单实时取 core_customer_advisor active) + ▼ +③ 指标消歧 命中口径字典的多义指标 → 反问候选口径(N-01) + ambiguity_check (有歧义则暂停,本轮不生成 SQL) + ▼ +④ 上下文组装 短期记忆(Redis 会话) + 口径字典条目 + schema 元数据 + few-shot + context_retrieve + ▼ +⑤ SQL 生成 LLM 生成只读 SELECT(多表只读聚合 D-05) + sql_generate + ▼ +⑥ SQL 校验 五层:白名单/归属注入/AST 越权/列级脱敏/粒度 → 不通过 403 + sql_validate + ▼ +⑦ 执行 查结果缓存(命中标 cache_hit)→ 未命中执行 → 写缓存 + execute 行数上限 1000 / 超时 10s;空结果三态区分(N-02) + ▼ +⑧ 数字护栏 四道校验:数字/单位/时间窗/抽样复核 → 重生成1次 → 降级(D-10) + guardrail_check + ▼ +⑨ 组装输出 answer + table + sql + meta(data_as_of/cost/cache_hit) + disclaimer + answer_compose + ▼ +⑩ 留痕 analytics_query_log + agent_tool_call + audit_log(同 trace_id) + audit_persist + ▼ +⑪ 资产候选 (仅 analyst)半自动提炼 few-shot/模板候选 → 分析师确认发布(D-11) + asset_candidate +``` + +### 4.2 失败与降级分支 + +| 场景 | 处理 | 留痕 | +|---|---|---| +| 越权(顾问查他人客户/运营下钻客户维度) | 403 + 说明性拒答 + 可查范围引导 | `analytics_query_log(blocked)` + `audit_log` | +| SQL 生成失败 / LLM 超时 / 查不到表 | 说明性拒答 + 一键转人工(N-07) | `analytics_query_log(error)` + `audit_log` | +| 数字护栏仍不一致(重试 1 次后) | 降级输出:只给表格 +「以数据为准」 | `result_summary` 标 `guardrail_failed` | +| 空结果 | 区分 `zero` / `no_data` / `not_match`(N-02) | 写入解读 + `result_summary` | +| 缓存命中 | 直接返回,标 `cache_hit=true` + `data_as_of` | `analytics_query_log` | + +--- + +## 5. 模块划分(app/) + +> 遵守 FRAMEWORK §3 分层:`api → service → tool / repository / model / config`,api 不写业务,tool 不写流程。 + +```text +app/ +├─ api/ +│ └─ analyst.py # 路由:/chat /dashboard /assets /sample /escalate /ops 【待实现】 +├─ service/ +│ ├─ analyst_agent.py # LangGraph StateGraph 编排 + 各节点 【待实现】 +│ ├─ sql_guard.py # 只读白名单 / AST 校验 / 归属注入 / 粒度 【待实现】 +│ ├─ guardrail.py # 数字护栏四道校验 【待实现】 +│ ├─ dict_service.py # 口径字典读写 + 消歧 【待实现】 +│ ├─ cache_service.py # 结果缓存 / 模板缓存 / data_as_of 【待实现】 +│ └─ analytics_repo.py # analytics_* 表读写(留痕/资产),不写 Core 【待实现】 +├─ tool/ +│ ├─ core_ro_tool.py # 包装 CoreReadOnlyRepository 为 Tool 节点 【待实现】 +│ └─ sql_tool.py # 只读执行 + 行数/超时控制 + 空态标记 【待实现】 +├─ repository/ +│ └─ core_ro.py # Core 只读 SELECT(jinrong_core) 【已实现】 +├─ model/ +│ ├─ schemas/analyst.py # 请求/响应/输出四件套 Pydantic 【占位】 +│ └─ entities/analytics.py # analytics_* ORM 【占位】 +├─ config/ # settings / database(双库连接) 【settings 已实现】 +└─ utils/ # response / exceptions / logger / trace 【占位】 +``` + +### 5.1 关键模块职责边界 + +| 模块 | 做什么 | 不做什么 | +|---|---|---| +| `sql_guard` | SELECT 白名单、AST 越权、归属注入、粒度、脱敏列清单 | 不执行 SQL、不解析业务语义 | +| `guardrail` | 解读数字与结果一致性校验 | 不生成 SQL | +| `dict_service` | 指标定义/公式/适用表 + 消歧反问 | 不直接改口径(仅 analyst 经确认发布) | +| `cache_service` | 键构造(含权限指纹)、TTL 分层、主动失效 | 不落 PII 明文 | +| `analytics_repo` | `analytics_*` 增改查、版本/灰度状态 | 不写 `jinrong_core`,不改审计表 | + +--- + +## 6. LangGraph 编排(StateGraph) + +### 6.1 图结构 + +```text + ┌──────────────┐ + │ input_guard │ + └──────┬───────┘ + │ passed + ┌──────▼────────┐ + │ scope_resolve │ + └──────┬────────┘ + │ + ┌──────▼──────────┐ 歧义 + │ ambiguity_check ├─────────► clarify(反问口径,写会话,结束本轮) + └──────┬──────────┘ + │ 无歧义 + ┌──────▼──────────┐ + │ context_retrieve│ + └──────┬──────────┘ + │ + ┌──────▼──────────┐ + │ sql_generate │ + └──────┬──────────┘ + │ + ┌──────▼──────────┐ 拒绝(403) + │ sql_validate ├─────────► deny(拒答+范围引导+留痕,结束) + └──────┬──────────┘ + │ 通过 + ┌──────▼──────────┐ + │ execute │ + └──────┬──────────┘ + │ + ┌──────▼──────────┐ + │ guardrail_check │──重试1次──► 回 sql_generate + └──────┬──────────┘ + │ 通过 / 降级 + ┌──────▼──────────┐ + │ answer_compose │ + └──────┬──────────┘ + │ + ┌──────▼──────────┐ + │ audit_persist │ + └──────┬──────────┘ + │ (analyst 角色) + ┌──────▼──────────┐ + │ asset_candidate │──► 半自动候选(仅提示,不自动生效) + └─────────────────┘ +``` + +### 6.2 State 概要(Pydantic) + +```python +class AnalystState(TypedDict): + trace_id: str + session_id: str + auth: AuthContext # sub/roles/permissions/agent_type + nl_question: str # 本轮问题(追问已带上下文) + scope: ScopeContext # 角色数据域 + 归属白名单 customer_id 集合 + ambiguity: Ambiguity | None # N-01 消歧候选 + context: PromptContext # schema 元数据 + 口径条目 + few-shot + sql: str # 生成/改写后的 SQL + validation: ValidationResult # sql_guard 判定 + result: QueryResult # rows/columns/空态/data_as_of/cache_hit + guardrail: GuardrailResult # 四道校验结果 + answer: AnalystResponse # 四件套 + status: str # success/clarify/deny/degrade/error/escalate +``` + +### 6.3 多轮追问(D-09)—— 与首轮的区别 + +追问**不走结果缓存命中**,而是: + +1. 从短期记忆取出**上轮 SQL + 上轮口径四件套**(指标定义、时间窗、维度、脱敏级别)。 +2. 对 SQL 做**受限改写(Constrained Edit)**:只允许追加/替换分组、过滤、排序,禁止更换事实表。 +3. 改写失败 → 自动降级为**全新生成**,但必须携带同一口径四件套,保证「同一指标前后一个定义」。 +4. 看板钻取(D-12)复用此链路。 + +--- + +## 7. 数据与存储 + +### 7.1 复用底座(11 表,只读/只增) + +| 表 | 用途 | 访问 | +|---|---|---| +| `agent_session` / `agent_message` / `agent_tool_call` | 会话与消息、Tool 调用 | 读 + 正常业务写 | +| `audit_log` | 审计总账 | 只 INSERT | +| `input_guard_log` | 输入防护 | 只 INSERT | +| `customer_advisor_rel` | 顾问归属白名单(RBAC 权威) | 只读 | +| `customer_profile_l1/l2/l3` | 画像(统计口径可选读) | 只读(脱敏/聚合) | +| `risk_alert` | 预警台账 | 只读,不可处置 | +| `risk_suitability_log` | 适当性 | 只读 | + +### 7.2 专属表 + +**已有(`02-mysql-agent专用.sql` 已定义):** + +- `analytics_query_log`:NL→SQL→结果留痕,含 `sql_hash` / `exec_status` / `result_summary`。 + +**新增(已定稿 · 独立文件 `docs/项目框架设计/表设计/03-mysql-analyst专用.sql`):** + +```sql +-- 口径字典:指标名 → 定义/公式/适用表(D-07 / D-11) +CREATE TABLE 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)'; + +-- few-shot 示例:问题 → 正确 SQL 对(D-11) +CREATE TABLE 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)'; + +-- 快速模板:参数化 SQL(D-06 模板缓存 / D-11) +CREATE TABLE 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)'; +``` + +> 三表分建(已定);灰度字段 `gray_roles` 用于「先灰度部分角色验证再全量」。建表 SQL 见 `docs/项目框架设计/表设计/03-mysql-analyst专用.sql`。 + +### 7.3 Redis Key(分析 Agent 新增部分) + +沿用 `{domain}:{agent}:{entity}:{id}` 规范: + +| Key | 类型 | TTL | 说明 | +|---|---|---|---| +| `sess:analyst:{session_id}:ctx` | Hash | 2h | 追问上下文 + 上轮 SQL + 口径四件套 | +| `sess:analyst:{session_id}:msgs` | List | 2h | 最近 N 轮窗口 | +| `cache:analyst:result:{perm_fp}:{sql_hash}` | String(JSON) | 分层 | 结果缓存(键含权限指纹) | +| `cache:analyst:scope:{staff_id}` | String(JSON) | 5m | 顾问名下客户白名单(转岗/离职立即失效) | +| `cache:analyst:top:{staff_id}` | String(JSON) | 24h | 中期记忆:常用查询 TOP + 偏好口径 | +| `guard:rate:{actor_id}:analyst` | String INCR | 1m | 限流(复用 F-03) | + +**TTL 分层(D-06):** 交易类 5min / 台账类 1h / 基础信息类当日。 + +### 7.4 缓存失效(data_as_of 与主动失效) + +- 响应 `meta.data_as_of` 标明数据截至时间(区分 T+1 / 准实时)。 +- 交易/持仓/归属等**事实表变更时主动 DEL** 对应缓存键,不等 TTL。 +- 一期模拟库:由 `scripts/sync/sync_advisor_rel.py`(归属)与 Core reset/更新流程广播失效;生产切换 CDC/binlog 订阅,Agent 层失效接口不变。 + +--- + +## 8. 权限与安全:五层隔离实现 + +需求 §2.2 五层 → 实现组件映射: + +| 层 | 需求 | 实现组件 | 校验时机 | +|---|---|---|---| +| 1 身份 | Mock JWT(HS256,24h 续期) | Gateway / Auth SDK;payload 含 user_type/employee_role | 入口 | +| 2 角色 | RBAC 数据域 | `RbacGuard.can(ctx, perm)`;analyst 角色 → `sql:execute:readonly` | 入口 + Tool | +| 3 行级归属 | 归属强制注入 + 二次校验 | `scope_resolve` 取白名单 → `sql_guard` 在生成阶段注入 `WHERE customer_id IN (:scope)` → AST 校验超范围 403 | 生成 + 校验 | +| 4 列级脱敏 | 存储层已脱敏 | 种子已存 mask;API/日志/归档同源;脱敏列清单由 `sql_guard` 维护 | 读取 | +| 5 粒度控制 | 聚合 vs 明细 | `sql_guard` 检查是否含客户级下钻;ops 无客户维度 → 拒绝 + 说明 | 校验 | + +**越权处理:** 403 + 双留痕(`audit_log` 总账 + `analytics_query_log` 记录问题与拒绝原因),可演示「顾问 A 问顾问 B 的客户 → 被拒且留痕可查」。 + +**SQL 安全双保险(D-04 / §5 安全):** + +1. 生成侧:`sql_guard` 用 `sqlglot` 解析 AST → 仅允许 SELECT(含 WITH/子查询白名单)→ 拦截多语句/写语句/DDL。 +2. 执行侧:数据库连接使用**只读账号**(`jinrong_core` 只读;`jinrong_agent` 业务表按角色授权)。 +3. 资源侧:`LIMIT 1000` / `10s` 超时。 + +--- + +## 9. 缓存与记忆(D-06 / §9.1 定稿方案) + +### 9.1 双层缓存 + +| 层 | 命中对象 | 机制 | +|---|---|---| +| 结果缓存 | 同形态**独立问题** | 键 = 权限指纹 + sql_hash;直接秒回;命中标 `cache_hit=true`;PII 不落或脱敏 | +| 模板缓存 | 跨会话**同形态问题** | 命中参数化模板 → 填参执行;资产发布后自动入池(D-11 联动) | + +**追问变体**不走缓存命中,走上轮 SQL 受限改写(见 §6.3)。 + +### 9.2 三层记忆 + +| 层 | 载体 | 内容 | 用途 | +|---|---|---|---| +| 短期 | Redis 会话级 30min | 追问上下文、上轮 SQL、口径四件套 | D-09 追问 / D-12 钻取 / D-10 重试 | +| 中期 | Redis 用户级 24h | 常用查询 TOP + 偏好口径 | 调优缓存 TTL、看板排序 | +| 长期 | MySQL 资产表 | few-shot / 字典 / 模板 | D-11 资产,喂养模板缓存 | + +--- + +## 10. 数字护栏(D-10 · 亮点) + +`guardrail_check` 对解读执行四道校验,任一失败 → 重生成(默认 1 次)→ 仍失败 → **降级输出**(只给表格 +「解读校验未通过,以数据为准」): + +1. **数字比对**:提取解读中全部数字/金额,与 SQL 结果逐项比对。 +2. **单位/币种/百分比**:元 vs 万元、% vs bp。 +3. **时间窗一致性**:「近 30 天」按自然日/交易日解析正确。 +4. **抽样复核**:抽 1~2 行回源明细核对(与 N-03 共用链路)。 + +校验结果(通过/重试/降级)写入 `result_summary` + `audit_log`。 + +> 答辩台词:**「LLM 有幻觉,但我们不赌它不犯——我们校验它。」** + +--- + +## 11. "养 Agent" 闭环(D-11 · 亮点) + +```text +分析师问答 → 已执行 SQL → 半自动提炼候选(few-shot/模板/字典) + │ + ▼ +分析师确认 → 发布(版本化)→ 灰度(gray_roles)→ 生效 → 喂模板缓存 + │ + ▼ +出问题 → 一键回滚(status=rolled_back,回到上一版本) +``` + +- **仅 `analyst` 角色可写**;每次沉淀操作审计留痕。 +- 资产三类:few-shot 示例 / 口径字典条目 / 快速模板。 +- 版本化 + 可回滚 + 可灰度;系统从每次已执行 SQL 半自动提炼候选,分析师仅确认。 +- 生效后同类问题命中新资产,`analytics_query_log.result_summary` 标 `source=沉淀资产`。 + +--- + +## 12. 智能看数板(D-12 · 亮点) + +- 定位:**预聚合概览 + 钻取入口**,不是独立报表系统。 +- 角色化卡片(后端返回结构化 JSON,前端下轮渲染): + - analyst:客户总数 / 总持仓规模 / 今日交易笔数金额 / 待处理预警数 / 口径字典资产数 + - advisor:名下客户数 / 名下资产规模 / 盈亏分布 / 风险等级分布(仅名下) + - risk_officer:待处理预警数 / 按类型分布 / 近 7 天新增趋势 + - ops:近 30 天申购赎回金额 / 各产品类型规模 TOP(仅聚合) +- 卡片钻取 → 复用 D-09 追问链路,指标与对话打通。 +- 边界:同样走只读层 + 角色权限;加载与钻取留痕;准实时/按需,不做实时推送。 + +--- + +## 13. 接口设计(REST,`X-Agent-Type: analyst`) + +| 方法 | 路径 | 说明 | 需求 | +|---|---|---|---| +| POST | `/api/analyst/chat` | 主对话/追问(SSE 可选,首版 JSON) | D-01~D-05/D-09 | +| GET | `/api/analyst/sessions/{id}/messages` | 会话消息回放 | D-04 | +| GET | `/api/analyst/dashboard` | 看板数据(按角色) | D-12 | +| GET | `/api/analyst/query/{trace_id}/sample` | 聚合结果抽样明细(脱敏) | N-03 | +| POST | `/api/analyst/assets/{dict\|few_shot\|template}` | 沉淀资产(仅 analyst) | D-11 | +| POST | `/api/analyst/assets/{kind}/{id}/publish` | 发布/灰度 | D-11 | +| POST | `/api/analyst/assets/{kind}/{id}/rollback` | 回滚 | D-11 | +| POST | `/api/analyst/query/{trace_id}/save` | 存为常用(P1) | N-05 | +| POST | `/api/analyst/query/{trace_id}/share` | 分享(继承接收方权限,P1) | N-05 | +| POST | `/api/analyst/query/{trace_id}/subscribe` | 订阅刷新(P1) | N-05 | +| POST | `/api/analyst/escalate` | 一键转人工(写审计) | N-07 | +| GET | `/api/analyst/ops/metrics` | 运营指标(P1) | N-08 | + +**统一输出四件套(§3 全局约定):** + +```json +{ + "answer": "人话解读…", + "table": { "columns": ["risk_code","cnt"], "rows": [["C1",4]] }, + "sql": "SELECT …", + "meta": { "exec_ms":45, "row_count":5, "cache_hit":false, "data_as_of":"2026-09-04", "source":"jinrong_core", "cost_est":0.002 }, + "disclaimer": "本内容仅为投资分析参考,不构成任何直接投资建议…" +} +``` + +--- + +## 14. 非功能需求落点 + +| 类别 | 需求 | 落点 | +|---|---|---| +| 安全 | 只读/注入拦截/越权拒绝/脱敏 | §8 sql_guard + 只读账号 | +| 合规 | 免责声明/不处置/不推荐/不对客 | answer_compose 固定 disclaimer + D-02/D-03 边界 | +| 性能 | 超时与行数上限 1000/10s + 缓存 | §4 execute、§9 缓存 | +| 审计 | trace_id 全链路可还原 | §4 audit_persist | +| 可运营 | 资产可维护、版本化回滚灰度 | §11 | +| 数据新鲜度 | data_as_of + 主动失效 | §7.4 | +| 成本 | 成本计量 + 配额限流 | `meta.cost_est` + `guard:rate` + N-04 | +| 可观测 | 通过率/命中率/拒答率/成本/转人工率 | §13 /ops/metrics(N-08) | +| 可用性 | 超时降级,不拖垮只读库 | §4.2 失败分支 + N-07 | + +--- + +## 15. 落地顺序与实现状态 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| Wave 0(平台) | JWT/RBAC(T-01)、审计贯通(T-02)、agent 库灌库(T-05) | 未做(复用,非分析组) | +| Wave 1-A(P0 闭环) | `analytics_query_log` + `sql_guard` + `analyst_agent` 主链路 → D-01~D-04 闭环 | 未做 | +| Wave 1-B(P0 增强) | 口径字典(D-07)、缓存+记忆(D-06)、追问改写(D-09)、数字护栏(D-10) | 未做 | +| Wave 1-C(P0 亮点) | 养 Agent 资产沉淀(D-11)、智能看数板后端(D-12)、消歧(N-01)、空零(N-02)、溯源(N-03)、转人工(N-07) | 未做 | +| Wave 2(P1) | 配额(N-04)、保存/分享/订阅(N-05)、质量提示(N-06)、运营面板(N-08) | 未做 | + +> 已有实现:`CoreReadOnlyRepository`(只读 SELECT)、`settings` 双库、Core 模拟库脚本、`customer_advisor_rel` 同步脚本。分析 Agent 业务层尚未实现。 + +--- + +## 16. 验收对照(演示即验收) + +| 需求演示 | 架构断言 | +|---|---| +| 「按产品类型统计总持仓规模」 | sql_validate 通过 → execute → 四件套正确 | +| 「查 CUST-1004(非名下)持仓」 | scope_resolve 白名单不含 → 403 + 双留痕 | +| 「当前待处理大额预警占比」 | risk_alert 只读统计,处置意图被拒 | +| 「近一月申购金额总额」(运营) | 粒度控制:仅聚合,无客户明细 | +| 同形态问题重复问 | 结果缓存命中 `cache_hit=true` | +| 「数字加起来是 123 万吗」 | guardrail 四道校验拦截或通过 | +| 沉淀 few-shot 后问同类问题 | 命中沉淀资产,`source=沉淀资产` | +| 点看板卡片钻取 | 复用 D-09 改写链路 | + +--- + +## 17. 待定项 + +| # | 待定项 | 当前立场 | +|---|---|---| +| 1 | 口径字典/few-shot/模板三表是否合一 | 已定:三表分建,见 `03-mysql-analyst专用.sql` | +| 2 | 新增依赖 `sqlglot`(AST 白名单) | 已确认,登记于技术选型文档与 requirements.txt | +| 3 | D-12 钻取实现深度 | 已定做钻取,细节开发期细化 | +| 4 | 看板刷新策略 | 准实时/按需,不做实时推送 | +| 5 | 结果缓存失效通道(CDC vs 同步脚本) | 一期用同步脚本广播,生产切 CDC | + +--- + +## 18. 决策记录 + +| 决策点 | 结论 | +|---|---| +| 编排 | LangGraph StateGraph,节点化 Tool | +| SQL 安全 | `sqlglot` AST 白名单 + 只读账号双保险 | +| 权限 | 五层隔离,行级归属系统注入 + 二次校验 | +| 缓存 | 结果/模板双层 + 短期/中期/长期三层记忆 | +| 数字护栏 | 四道校验,重试 1 次后降级 | +| 资产 | 仅 analyst 可写,版本化/回滚/灰度(`gray_roles`) | +| 专属表 | 口径字典/few-shot/模板三表分建,独立 `03-mysql-analyst专用.sql` | +| 看数板 | 后端 JSON 接口先行,前端下轮渲染 | +| 一期依赖 | 不依赖 Neo4j/Milvus | + +--- + +## 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| v1.0 | 2026-09 | 初版:基于需求规格 v1.2 生成,对齐 FRAMEWORK/表设计/JWT 手册/Core 模拟 | +| v1.1 | 2026-09 | 确认新增 `sqlglot`;口径字典/few-shot/模板三表分建,DDL 独立为 `03-mysql-analyst专用.sql` | diff --git a/docs/项目框架设计/表设计/00-架构总览.md b/docs/项目框架设计/表设计/00-架构总览.md index d6cd3e3..4e0f127 100644 --- a/docs/项目框架设计/表设计/00-架构总览.md +++ b/docs/项目框架设计/表设计/00-架构总览.md @@ -14,7 +14,7 @@ | 你要建的 | 包含什么 | SQL | | --- | --- | --- | | **共用底座(先建)** | MySQL **11 张** + Redis 会话/画像 + Milvus 产品库 + Neo4j | [01-mysql-共用底座.sql](./01-mysql-共用底座.sql) | -| **各 Agent 专用(后建)** | MySQL **5 张** | [02-mysql-agent专用.sql](./02-mysql-agent专用.sql) | +| **各 Agent 专用(后建)** | MySQL **8 张** | [02-mysql-agent专用.sql](./02-mysql-agent专用.sql) + [03-mysql-analyst专用.sql](./03-mysql-analyst专用.sql) | | **技术栈与版本** | 组件版本、Windows 原生部署 | [01-技术栈与版本.md](../../技术选型和版本/01-技术栈与版本.md) | | **业务记忆管理** | 短期/长期记忆、Redis vs SQL vs 图 vs 向量 | [业务记忆管理手册.md](../../业务记忆管理/业务记忆管理手册.md) | @@ -130,7 +130,7 @@ --- -## MySQL 16 张表:每张表 **干什么**(无编号版) +## MySQL 19 张表:每张表 **干什么**(无编号版) > **共用 vs 专用** 的完整矩阵见 [05-多Agent共用底座清单.md](./05-多Agent共用底座清单.md) 第九节。 @@ -160,7 +160,7 @@ | `risk_alert` | 风控 Agent | 分析 Agent、风控专员 | 预警单(待人工审核) | | `risk_suitability_log` | 风控 Agent | 客户、代理人 Agent | 买的产品是否匹配、可否拦截 | -### 四、各 Agent 专用(5 张)⚪ 各组自建,不算公共底座 +### 四、各 Agent 专用(8 张)⚪ 各组自建,不算公共底座 | 表名 | 哪个 Agent | 干什么 | | --- | --- | --- | @@ -169,8 +169,11 @@ | `advisor_draft` | 代理人 | 话术/跟进草稿 | | `compliance_hit_log` | 代理人 | 违规话术检测 | | `analytics_query_log` | 分析 | 查数 SQL 留痕 | +| `analytics_metric_dict` | 分析 | 口径字典(指标定义/公式/适用表) | +| `analytics_few_shot` | 分析 | 问题→正确 SQL 示例 | +| `analytics_query_template` | 分析 | 参数化 SQL 模板 | -> 完整字段见 [01-mysql-共用底座.sql](./01-mysql-共用底座.sql) + [02-mysql-agent专用.sql](./02-mysql-agent专用.sql) +> 完整字段见 [01-mysql-共用底座.sql](./01-mysql-共用底座.sql) + [02-mysql-agent专用.sql](./02-mysql-agent专用.sql) + [03-mysql-analyst专用.sql](./03-mysql-analyst专用.sql) --- @@ -180,7 +183,7 @@ | --- | --- | --- | | **客户财富** | L1 画像、亏损阈值、提醒记录 | 原有系统持仓;风控的适当性结论(只读) | | **代理人助手** | L2 画像、话术草稿、违规记录 | L1 画像(了解客户偏好);产品文档(Milvus) | -| **数据分析** | 查数日志 | L1/L2/L3 做统计;预警单做「还有多少条没处理」 | +| **数据分析** | 查数日志、口径字典、few-shot、模板 | L1/L2/L3 做统计;预警单做「还有多少条没处理」 | | **风控监测** | L3 画像、预警单、适当性记录 | L1/L2 辅助判断;原有系统交易流水 | | **平台** | 会话、审计、权限关系 | — | @@ -228,7 +231,7 @@ | --- | --- | --- | | **目标** | 把多 Agent 要一起用的表/缓存/向量库建好 | 各组写自己的 Agent 逻辑 | | **谁做** | 平台组统一做 | 客户/代理人/分析/风控 四组 | -| **建什么** | MySQL 11 张 + Redis + Milvus(产品) + Neo4j | 各自专用 5 张表 + 业务代码 | +| **建什么** | MySQL 11 张 + Redis + Milvus(产品) + Neo4j | 各自专用 8 张表 + 业务代码 | | **详细清单** | [05-多Agent共用底座清单.md](./05-多Agent共用底座清单.md) | 同上第五节「专用」 | --- @@ -269,6 +272,7 @@ | **[05-多Agent共用底座清单.md](./05-多Agent共用底座清单.md)** | **先搭底座:哪些表多 Agent 共用** | | [01-mysql-共用底座.sql](./01-mysql-共用底座.sql) | 执行建库:共用 11 张 | | [02-mysql-agent专用.sql](./02-mysql-agent专用.sql) | 各 Agent 专用 5 张 | +| [03-mysql-analyst专用.sql](./03-mysql-analyst专用.sql) | 分析 Agent 专用 3 张 | | [02-redis-keys.md](./02-redis-keys.md) | 做会话缓存、画像缓存 | | [03-milvus-collections.md](./03-milvus-collections.md) | 做产品/制度文档检索 | | [04-neo4j-model.md](./04-neo4j-model.md) | 做持仓关系、适当性关系查询 | diff --git a/docs/项目框架设计/表设计/03-mysql-analyst专用.sql b/docs/项目框架设计/表设计/03-mysql-analyst专用.sql new file mode 100644 index 0000000..5316798 --- /dev/null +++ b/docs/项目框架设计/表设计/03-mysql-analyst专用.sql @@ -0,0 +1,65 @@ +-- ============================================================================= +-- 数据分析 Agent 专用 MySQL(3 张 · 养 Agent 资产) +-- 执行时机:数据分析 Agent 组开发 D-07 口径统一 / D-11 资产沉淀时创建 +-- 前置:共用底座(01)已建;analytics_query_log 见 02-mysql-agent专用.sql +-- 需求来源:docs/需求拆解/01-数据分析Agent需求规格.md §3 D-07 / D-11 +-- ============================================================================= + +USE jinrong_agent; + +-- ⚪ 口径字典:指标名 → 定义/公式/适用表(D-07 一问一义 / D-11 资产) +CREATE TABLE 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)'; + +-- ⚪ few-shot 示例:问题 → 正确 SQL 对(D-11 资产) +CREATE TABLE 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)'; + +-- ⚪ 快速模板:参数化 SQL(D-06 模板缓存 / D-11 资产) +CREATE TABLE 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/docs/项目框架设计/表设计/05-多Agent共用底座清单.md b/docs/项目框架设计/表设计/05-多Agent共用底座清单.md index ef4507f..07a983a 100644 --- a/docs/项目框架设计/表设计/05-多Agent共用底座清单.md +++ b/docs/项目框架设计/表设计/05-多Agent共用底座清单.md @@ -26,7 +26,7 @@ │ 【第二批 · 跨 Agent 交换】MySQL 5 张 + Redis 画像缓存 + Milvus 1 库 + Neo4j 图 │ -【第三批 · 各 Agent 专用】MySQL 5 张 + Milvus 1 库(仅代理人) + Redis 代理人快照 +【第三批 · 各 Agent 专用】MySQL 8 张 + Milvus 1 库(仅代理人) + Redis 代理人快照 │ 四个 Agent 并行开发 ``` @@ -35,8 +35,8 @@ | --- | --- | --- | --- | --- | | **第一批** | 6 张 | 2 类 Key | — | — | | **第二批** | 5 张 | 3 类 Key | 1 个 Collection | 整图 P0 | -| **第三批** | 5 张 | 1 类 Key | 1 个 Collection | — | -| **合计** | **16 张** | 6 类 | 2 个 | 1 套 | +| **第三批** | 8 张 | 1 类 Key | 1 个 Collection | — | +| **合计** | **19 张** | 6 类 | 2 个 | 1 套 | --- @@ -69,7 +69,7 @@ | `risk_alert` | **2~3** | **风控** Agent | 风控、**分析**、合规 | 预警单;分析统计「还有多少没审」 | | `risk_suitability_log` | **3** | **风控** Agent | **客户**、**代理人**、交易钩子 | 买的产品是否匹配;可否拦截购买 | -### ⚪ 第三批 — 单 Agent 专用(5 张,各组自建) +### ⚪ 第三批 — 单 Agent 专用(8 张,各组自建) > **不算公共底座**;对应组开发自己的 Agent 时再建即可。 > SQL 文件:[02-mysql-agent专用.sql](./02-mysql-agent专用.sql) @@ -81,6 +81,9 @@ | `advisor_draft` | 仅代理人 Agent | 话术/跟进草稿 | 只有代理人用,不外泄给其他 Agent | | `compliance_hit_log` | 仅代理人 Agent | 违规话术检测 | 主要服务代理人合规 | | `analytics_query_log` | 仅分析 Agent | 人话查数 SQL 留痕 | 只有分析 Agent 写 | +| `analytics_metric_dict` | 仅分析 Agent | 口径字典(指标定义/公式/适用表) | 只有分析 Agent 写 | +| `analytics_few_shot` | 仅分析 Agent | 问题→正确 SQL 示例 | 只有分析 Agent 写 | +| `analytics_query_template` | 仅分析 Agent | 参数化 SQL 模板 | 只有分析 Agent 写 | --- @@ -165,12 +168,12 @@ - [ ] 客户组:`customer_threshold_config`、`customer_notify_log` - [ ] 代理人组:`advisor_draft`、`compliance_hit_log` + Milvus `kb_business_ops` -- [ ] 分析组:`analytics_query_log` +- [ ] 分析组:`analytics_query_log`、`analytics_metric_dict`、`analytics_few_shot`、`analytics_query_template` - [ ] 风控组:`risk:dedup`(可选,内部优化) --- -## 九、一张矩阵:16 张 MySQL 表 × 4 个 Agent +## 九、一张矩阵:19 张 MySQL 表 × 4 个 Agent 图例:**W**=写入 **R**=读取 **·**=不用 @@ -192,6 +195,9 @@ | `advisor_draft` | · | **W**/R | · | · | ⚪ | | `compliance_hit_log` | · | **W** | · | · | ⚪ | | `analytics_query_log` | · | · | **W** | · | ⚪ | +| `analytics_metric_dict` | · | · | **W** | · | ⚪ | +| `analytics_few_shot` | · | · | **W** | · | ⚪ | +| `analytics_query_template` | · | · | **W** | · | ⚪ | **底座 = 上表所有 🔴 + 🟡 行 = MySQL 11 张表** @@ -203,6 +209,7 @@ | --- | --- | --- | | [01-mysql-共用底座.sql](./01-mysql-共用底座.sql) | 🔴6 + 🟡5 = **11 张** | **并行开发前统一执行** | | [02-mysql-agent专用.sql](./02-mysql-agent专用.sql) | ⚪ **5 张** | 各 Agent 组开发时执行(或一次性全建) | +| [03-mysql-analyst专用.sql](./03-mysql-analyst专用.sql) | ⚪ 分析 **3 张** | 分析组开发时执行(口径/示例/模板) | --- diff --git a/requirements.txt b/requirements.txt index 83d473f..c060ed4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,9 @@ pymysql>=1.1.1 redis>=5.2.0 neo4j>=5.26.0 +# SQL safety(数据分析 Agent:只读 SQL AST 白名单校验) +sqlglot>=25.0.0 + # Vector & LLM / Agent 编排(LangGraph) pymilvus>=3.0.1 langgraph>=1.2.11 diff --git a/scripts/dev/demo.py b/scripts/dev/demo.py new file mode 100644 index 0000000..6036549 --- /dev/null +++ b/scripts/dev/demo.py @@ -0,0 +1,39 @@ +"""演示:用真实 LLM + 真实 MySQL 跑数据分析 Agent 的典型问答。""" +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from app.service.analyst_agent import AnalystAgent +from app.utils.auth import AuthContext + + +def run(question, roles, subject): + agent = AnalystAgent() + ctx = AuthContext(subject_id=subject, token_type="staff", roles=roles, staff_type=roles[0]) + resp = agent.run(question, ctx) + print(f"\n=== 问题:{question}(角色 {roles[0]})===") + print(f"状态:{resp.status} 缓存命中:{resp.meta.cache_hit}") + print(f"解读:{resp.answer}") + print(f"表格:{json.dumps(resp.table.model_dump(), ensure_ascii=False, default=str)}") + print(f"SQL:{resp.sql}") + if resp.error_code: + print(f"错误码:{resp.error_code} 建议:{resp.suggestions}") + return resp + + +if __name__ == "__main__": + # 先拿到一个顾问和他的非名下客户 + agent = AnalystAgent() + advisor = agent.repo.execute_readonly( + "SELECT staff_id FROM core_staff WHERE staff_type='advisor' AND is_active=1 LIMIT 1" + )["rows"][0][0] + scope = agent.repo.resolve_advisor_scope(advisor) + out_customer = next( + c for c in agent.repo.execute_readonly("SELECT customer_id FROM core_customer LIMIT 100")["rows"] + if c[0] not in scope + )[0] + + run("按产品类型统计总持仓规模", ["analyst"], "STAFF-20001") + run(f"查 {out_customer} 的持仓", ["advisor"], advisor) diff --git a/scripts/dev/diag_question.py b/scripts/dev/diag_question.py new file mode 100644 index 0000000..ba9b4aa --- /dev/null +++ b/scripts/dev/diag_question.py @@ -0,0 +1,30 @@ +"""诊断单个问题的 SQL 生成与执行(用法:python scripts/dev/diag_question.py "问题" 角色)。""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from app.service.analyst_agent import AnalystAgent +from app.utils.auth import AuthContext + + +def main(): + question = sys.argv[1] if len(sys.argv) > 1 else "近30天申购金额总额" + role = sys.argv[2] if len(sys.argv) > 2 else "ops" + agent = AnalystAgent() + ctx = AuthContext(subject_id=f"STAFF-{role}", token_type="staff", roles=[role], staff_type=role) + domain = {"analyst": "full", "advisor": "assigned", "risk_officer": "risk", "ops": "aggregate"}[role] + scope = [] + if domain == "assigned": + scope = agent.repo.resolve_advisor_scope(ctx.subject_id) + sql, _ = agent._generate_sql(question, domain, scope) + print("SQL:", sql) + try: + res = agent.repo.execute_readonly(sql) + print("OK cols=", res["columns"], "rows[:3]=", res["rows"][:3]) + except Exception as exc: # noqa: BLE001 + print("EXEC ERROR:", type(exc).__name__, exc) + + +if __name__ == "__main__": + main() diff --git a/scripts/dev/dump_schema.py b/scripts/dev/dump_schema.py new file mode 100644 index 0000000..ee68c2a --- /dev/null +++ b/scripts/dev/dump_schema.py @@ -0,0 +1,55 @@ +"""导出数据分析 Agent 可读表的列结构(用于 NL2SQL 的 schema 元数据注入)。""" +import pathlib + +import pymysql + + +def load_env(path=".env"): + env = {} + for line in pathlib.Path(path).read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k.strip()] = v.strip() + return env + + +TABLES = [ + ("jinrong_core", "core_customer"), + ("jinrong_core", "core_customer_risk"), + ("jinrong_core", "core_customer_advisor"), + ("jinrong_core", "core_holding"), + ("jinrong_core", "core_trade"), + ("jinrong_core", "core_cash_flow"), + ("jinrong_core", "core_product"), + ("jinrong_core", "core_product_nav"), + ("jinrong_core", "core_staff"), + ("jinrong_core", "core_risk_grade"), + ("jinrong_agent", "risk_alert"), +] + + +def main(): + env = load_env() + conn = pymysql.connect( + host=env.get("MYSQL_HOST", "127.0.0.1"), + port=int(env.get("MYSQL_PORT", "3306")), + user=env.get("MYSQL_USER", "root"), + password=env.get("MYSQL_PASSWORD", ""), + charset="utf8mb4", + ) + with conn.cursor() as cur: + for db, tbl in TABLES: + cur.execute( + "SELECT column_name, data_type FROM information_schema.columns " + "WHERE table_schema=%s AND table_name=%s ORDER BY ordinal_position", + (db, tbl), + ) + cols = [f"{c[0]}:{c[1]}" for c in cur.fetchall()] + print(f"{tbl} ({db}) -> {', '.join(cols)}") + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/dev/inspect_seed.py b/scripts/dev/inspect_seed.py new file mode 100644 index 0000000..c4d8e67 --- /dev/null +++ b/scripts/dev/inspect_seed.py @@ -0,0 +1,24 @@ +"""查看种子数据关键事实(用于集成测试与演示)。""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from app.service.analytics_repo import AnalyticsRepo + + +def main(): + repo = AnalyticsRepo() + q = lambda sql: repo.execute_readonly(sql) # noqa: E731 + print("advisor ids:", [r[0] for r in q("SELECT staff_id FROM core_staff WHERE staff_type='advisor' AND is_active=1 LIMIT 5")["rows"]]) + print("risk_officer ids:", [r[0] for r in q("SELECT staff_id FROM core_staff WHERE staff_type='risk_officer' AND is_active=1 LIMIT 5")["rows"]]) + print("ops ids:", [r[0] for r in q("SELECT staff_id FROM core_staff WHERE staff_type='ops' AND is_active=1 LIMIT 5")["rows"]]) + print("analyst ids:", [r[0] for r in q("SELECT staff_id FROM core_staff WHERE staff_type='analyst' AND is_active=1 LIMIT 5")["rows"]]) + print("risk codes:", q("SELECT risk_code, COUNT(*) c FROM core_customer_risk GROUP BY risk_code ORDER BY risk_code")["rows"]) + print("product types:", q("SELECT product_type, COUNT(*) c FROM core_product GROUP BY product_type")["rows"]) + print("alert status:", q("SELECT status, COUNT(*) c FROM jinrong_agent.risk_alert GROUP BY status")["rows"]) + print("customers sample:", [r[0] for r in q("SELECT customer_id FROM core_customer LIMIT 8")["rows"]]) + + +if __name__ == "__main__": + main() diff --git a/scripts/setup/apply_sql_file.py b/scripts/setup/apply_sql_file.py new file mode 100644 index 0000000..4f1f9fa --- /dev/null +++ b/scripts/setup/apply_sql_file.py @@ -0,0 +1,51 @@ +"""执行一个 SQL 文件(UTF-8,按分号切分)。 + +用法:python scripts/setup/apply_sql_file.py +用途:把 docs/项目框架设计/表设计/ 下的建表 SQL 灌进 jinrong_agent(避免命令行中文路径/编码问题)。 +""" +import sys +import pathlib + +import pymysql + + +def load_env(path=".env"): + env = {} + p = pathlib.Path(path) + if p.exists(): + for line in p.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k.strip()] = v.strip() + return env + + +def main() -> None: + if len(sys.argv) < 2: + print("用法:python scripts/setup/apply_sql_file.py ") + sys.exit(2) + sql_file = sys.argv[1] + env = load_env() + conn = pymysql.connect( + host=env.get("MYSQL_HOST", "127.0.0.1"), + port=int(env.get("MYSQL_PORT", "3306")), + user=env.get("MYSQL_USER", "root"), + password=env.get("MYSQL_PASSWORD", ""), + charset="utf8mb4", + autocommit=True, + ) + raw = pathlib.Path(sql_file).read_text(encoding="utf-8") + # 去掉 -- 注释行后按分号切分 + text = "\n".join(l for l in raw.splitlines() if not l.strip().startswith("--")) + stmts = [s.strip() for s in text.split(";") if s.strip()] + with conn.cursor() as cur: + for s in stmts: + cur.execute(s) + conn.close() + print(f"OK: 执行了 {len(stmts)} 条语句 <- {sql_file}") + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..0b27ddd --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,106 @@ +"""analyst_agent 编排测试:确定性假件 + 一条真实端到端。""" +import unittest + +from app.service.analyst_agent import AnalystAgent +from app.utils.auth import AuthContext + + +class FakeLLM: + def __init__(self, sql, answers): + self.sql = sql + self.answers = list(answers) + self.calls = 0 + + def complete(self, messages, temperature=0, max_tokens=2048): + self.calls += 1 + usage = {"prompt_tokens": 10, "completion_tokens": 10} + if self.calls == 1: + return self.sql, usage + ans = self.answers.pop(0) if self.answers else "无解读" + return ans, usage + + +class FakeRepo: + def __init__(self, rows=(), columns=(), scope=None): + self.rows = list(rows) + self.columns = list(columns) + self.scope = scope or [] + self.logged = [] + + def resolve_advisor_scope(self, sid): + return self.scope + + def execute_readonly(self, sql): + return {"columns": self.columns, "rows": self.rows} + + def get_data_as_of(self): + return "2026-09-04" + + def log_query(self, **kw): + self.logged.append(kw) + + def log_audit(self, **kw): + pass + + +def ctx(roles, subject="STAFF-A"): + return AuthContext(subject_id=subject, token_type="staff", roles=roles, staff_type=roles[0] if roles else "") + + +class TestAgentOrchestration(unittest.TestCase): + def test_clarify_ambiguity(self): + agent = AnalystAgent(llm=FakeLLM("SELECT 1", []), repo=FakeRepo()) + resp = agent.run("我名下的规模是多少", ctx(["analyst"])) + self.assertEqual(resp.status, "clarify") + + def test_success(self): + repo = FakeRepo(rows=[[33]], columns=["c"]) + agent = AnalystAgent( + llm=FakeLLM("SELECT COUNT(*) AS c FROM core_customer", ["共 33 个客户"]), + repo=repo, + ) + resp = agent.run("客户总数是多少", ctx(["analyst"])) + self.assertEqual(resp.status, "success") + self.assertEqual(resp.table.rows, [[33]]) + self.assertEqual(len(repo.logged), 1) + + def test_deny_bad_sql(self): + agent = AnalystAgent(llm=FakeLLM("INSERT INTO core_customer VALUES (1)", []), repo=FakeRepo()) + resp = agent.run("删库", ctx(["analyst"])) + self.assertEqual(resp.status, "deny") + self.assertEqual(resp.error_code, "SQL_NOT_SELECT") + + def test_degrade_wrong_number(self): + repo = FakeRepo(rows=[[33]], columns=["c"]) + agent = AnalystAgent( + llm=FakeLLM("SELECT COUNT(*) FROM core_customer", ["共 999 个客户", "共 999 个客户"]), + repo=repo, + ) + resp = agent.run("客户总数", ctx(["analyst"])) + self.assertEqual(resp.status, "degrade") + + def test_advisor_out_of_scope_deny(self): + repo = FakeRepo(scope=["CUST-1001"]) + agent = AnalystAgent( + llm=FakeLLM("SELECT * FROM core_holding WHERE customer_id='CUST-1004'", []), + repo=repo, + ) + resp = agent.run("查 CUST-1004 持仓", ctx(["advisor"], "STAFF-B")) + self.assertEqual(resp.status, "deny") + self.assertEqual(resp.error_code, "AUTH_403_NOT_ASSIGNED") + + +class TestAgentReal(unittest.TestCase): + def test_real_end_to_end(self): + from app.service.analytics_repo import AnalyticsRepo + from app.service.llm import DeepSeekLLM + + agent = AnalystAgent(llm=DeepSeekLLM(), repo=AnalyticsRepo()) + resp = agent.run("客户总数是多少", ctx(["analyst"])) + 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_api.py b/tests/test_api.py new file mode 100644 index 0000000..5fac71b --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,81 @@ +"""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_cache_service.py b/tests/test_cache_service.py new file mode 100644 index 0000000..b6170da --- /dev/null +++ b/tests/test_cache_service.py @@ -0,0 +1,49 @@ +"""cache_service 缓存单元测试(内存后端,无需 Redis)。""" +import time +import unittest + +from app.service.cache_service import CacheService, InMemoryBackend + + +class TestCacheService(unittest.TestCase): + def setUp(self): + self.svc = CacheService(backend=InMemoryBackend()) + + def test_sql_hash_deterministic(self): + self.assertEqual(self.svc.sql_hash("SELECT 1"), self.svc.sql_hash("SELECT 1")) + self.assertNotEqual(self.svc.sql_hash("SELECT 1"), self.svc.sql_hash("SELECT 2")) + + def test_permission_fingerprint_differs_by_scope(self): + a = self.svc.permission_fingerprint("S1", "assigned", ["CUST-1"]) + b = self.svc.permission_fingerprint("S1", "assigned", ["CUST-2"]) + self.assertNotEqual(a, b) + + def test_ttl_layering(self): + self.assertEqual(self.svc.ttl_for(["core_trade"]), 5 * 60) + self.assertEqual(self.svc.ttl_for(["risk_alert"]), 60 * 60) + self.assertEqual(self.svc.ttl_for(["core_customer"]), 24 * 60 * 60) + + def test_roundtrip(self): + fp = self.svc.permission_fingerprint("S1", "full") + sql = "SELECT COUNT(*) FROM core_customer" + self.assertIsNone(self.svc.get_result(fp, sql)) + self.svc.set_result(fp, sql, {"cnt": 33}, ["core_customer"]) + self.assertEqual(self.svc.get_result(fp, sql), {"cnt": 33}) + + def test_invalidate(self): + fp = self.svc.permission_fingerprint("S1", "full") + sql = "SELECT 1" + self.svc.set_result(fp, sql, {"x": 1}, ["core_customer"]) + self.svc.invalidate_by_sql(fp, sql) + self.assertIsNone(self.svc.get_result(fp, sql)) + + def test_inmemory_expiry(self): + b = InMemoryBackend() + b.set("k", "v", ttl=1) + self.assertEqual(b.get("k"), "v") + time.sleep(1.2) + self.assertIsNone(b.get("k")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dict_service.py b/tests/test_dict_service.py new file mode 100644 index 0000000..7f727f6 --- /dev/null +++ b/tests/test_dict_service.py @@ -0,0 +1,31 @@ +"""dict_service 口径字典单元测试。""" +import unittest + +from app.service.dict_service import Ambiguity, Metric, default_registry + + +class TestDictService(unittest.TestCase): + def setUp(self): + self.reg = default_registry() + + def test_resolve_unambiguous(self): + m = self.reg.resolve("持仓规模") + self.assertIsInstance(m, Metric) + self.assertEqual(m.key, "holding_scale") + + def test_resolve_ambiguous(self): + m = self.reg.resolve("规模") + self.assertIsInstance(m, Ambiguity) + self.assertEqual(len(m.candidates), 2) + + def test_resolve_missing(self): + self.assertIsNone(self.reg.resolve("不存在的指标")) + + def test_alias_match(self): + m = self.reg.resolve("市值") + self.assertIsInstance(m, Metric) + self.assertEqual(m.key, "holding_scale") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_guardrail.py b/tests/test_guardrail.py new file mode 100644 index 0000000..6e48ad3 --- /dev/null +++ b/tests/test_guardrail.py @@ -0,0 +1,51 @@ +"""guardrail 数字护栏单元测试。""" +import unittest + +from app.model.schemas.analyst import TableData +from app.service.guardrail import check_numbers, extract_numbers, result_numbers, verify + + +class TestGuardrail(unittest.TestCase): + def _table(self): + return TableData( + columns=["risk_code", "cnt"], + rows=[["C1", 4], ["C2", 6]], + ) + + def test_extract_numbers(self): + self.assertEqual(extract_numbers("共 2 个,高风险 6 人,占比 5%"), [2.0, 6.0, 5.0]) + + def test_extract_comma_numbers(self): + self.assertEqual(extract_numbers("2,625,000.00 元 和 1,229,150 元"), [2625000.0, 1229150.0]) + + def test_correct_answer_no_issues(self): + self.assertEqual(check_numbers("共 2 个风险等级,高风险 6 人", self._table()), []) + + def test_wrong_number_flagged(self): + # 表格只有 4/6/合计10/行数2,答案说 123 应被拦截 + issues = check_numbers("金额加起来是 123 万元", self._table()) + self.assertIn(123.0, issues) + + def test_result_numbers(self): + nums = result_numbers(self._table()) + self.assertIn(2.0, nums) # 行数 + self.assertIn(4.0, nums) + self.assertIn(6.0, nums) + self.assertIn(10.0, nums) # 数值列求和 + + def test_verify_wrong_fails(self): + r = verify("共 999 个", self._table(), data_as_of="2026-09-04") + self.assertFalse(r.passed) + self.assertIn(999.0, r.issues) + + def test_zero_valid(self): + t = TableData(columns=["c"], rows=[]) + self.assertEqual(check_numbers("结果为 0", t), []) + + def test_wan_scale_valid(self): + t = TableData(columns=["v"], rows=[[1234567]]) + self.assertEqual(check_numbers("约 123 万元", t), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..6b68d53 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,73 @@ +"""集成测试:真实 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_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..5d82eba --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,33 @@ +"""llm 客户端测试(extract_sql 纯逻辑 + DeepSeek 真实冒烟)。""" +import unittest + +from app.service.llm import DeepSeekLLM, extract_sql + + +class TestExtractSql(unittest.TestCase): + def test_plain(self): + self.assertEqual(extract_sql("SELECT 1"), "SELECT 1") + + def test_fenced(self): + self.assertEqual( + extract_sql("结果如下:\n```sql\nSELECT 1\n```"), + "SELECT 1", + ) + + def test_fenced_no_lang(self): + 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_repo.py new file mode 100644 index 0000000..c7116b1 --- /dev/null +++ b/tests/test_repo.py @@ -0,0 +1,63 @@ +"""analytics_repo 数据访问测试(真实 MySQL)。""" +import unittest + +from app.service.analytics_repo import AnalyticsRepo, classify_empty + + +class TestClassifyEmpty(unittest.TestCase): + def test_zero(self): + self.assertEqual(classify_empty([], "SELECT COUNT(*) FROM core_customer"), "zero") + + def test_not_match(self): + self.assertEqual( + classify_empty([], "SELECT * FROM core_holding WHERE customer_id='CUST-X'"), "not_match" + ) + + def test_no_data(self): + self.assertEqual(classify_empty([], "SELECT * FROM core_product"), "no_data") + + def test_has_data(self): + self.assertEqual(classify_empty([["a"]], "SELECT 1"), "has_data") + + +class TestRepoMySQL(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.repo = AnalyticsRepo() + + def test_execute_readonly_aggregate(self): + res = self.repo.execute_readonly( + "SELECT risk_code, COUNT(*) AS c FROM core_customer_risk GROUP BY risk_code ORDER BY risk_code" + ) + self.assertIn("risk_code", res["columns"]) + self.assertGreater(len(res["rows"]), 0) + + def test_resolve_advisor_scope(self): + res = self.repo.execute_readonly( + "SELECT staff_id FROM core_staff WHERE staff_type='advisor' AND is_active=1 LIMIT 1" + ) + self.assertTrue(res["rows"], "种子中应存在 advisor") + advisor_id = res["rows"][0][0] + scope = self.repo.resolve_advisor_scope(advisor_id) + self.assertGreater(len(scope), 0) + + def test_data_as_of(self): + self.assertIsInstance(self.repo.get_data_as_of(), str) + + def test_log_query_roundtrip(self): + import uuid + trace = "test-" + uuid.uuid4().hex[:12] + self.repo.log_query( + session_id="sess-test", trace_id=trace, staff_id="STAFF-TEST", + nl_question="测试", generated_sql="SELECT 1", sql_hash="hash", + row_count=1, exec_status="success", result_summary={"ok": True}, + exec_latency_ms=5, has_disclaimer=True, + ) + res = self.repo.execute_readonly( + f"SELECT trace_id FROM jinrong_agent.analytics_query_log WHERE trace_id='{trace}'" + ) + self.assertEqual(len(res["rows"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sql_guard.py b/tests/test_sql_guard.py new file mode 100644 index 0000000..bec4697 --- /dev/null +++ b/tests/test_sql_guard.py @@ -0,0 +1,83 @@ +"""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()