diff --git a/app/api/analyst.py b/app/api/analyst.py index b748cae..82221e7 100644 --- a/app/api/analyst.py +++ b/app/api/analyst.py @@ -9,7 +9,7 @@ from app.api.analyst_auth_adapter import ( analyst_auth_from_deps, assert_analyst_query_access, ) -from app.api.deps import AuthContext, get_auth_context +from app.api.deps import AuthContext, get_platform_auth_context from app.model.analyst_schemas import AnalystResponse, AssetCreateRequest, ChatRequest from app.service.analyst_agent import AnalystAgent from app.utils.trace import current_trace @@ -26,7 +26,7 @@ def get_agent() -> AnalystAgent: return _agent -def _analyst_ctx(auth: AuthContext = Depends(get_auth_context)) -> AnalystAuthContext: +def _analyst_ctx(auth: AuthContext = Depends(get_platform_auth_context)) -> AnalystAuthContext: return analyst_auth_from_deps(auth, trace_id=current_trace() or "") diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index a428cfa..535319c 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -66,7 +66,7 @@ - [x] **Wave6 测试**:`test_wave6_*` · +43 例 - [x] **前端问数页**:`web/src/pages/analytics/AnalystQueryPage.tsx` · `api/analyst.ts` - [x] **迁移 SQL(本机)**:`scripts/agent/migrate-analyst-d07-d11.sql`(三资产表 · 2026-09-09 已执行 · **无种子数据**) -- [ ] **Scope B 冒烟**:四 Demo 角色各一条问数(需 MySQL + 可选 DeepSeek Key) +- [x] **Scope B 冒烟**:`python scripts/dev/smoke_analyst.py` → **19/19**(FakeLLM + `--live-llm` 真 MySQL/LLM 亦绿) ### 风控 Agent · 后端已就绪 · 前端/运维未接(盘点 2026-09-09) diff --git a/scripts/dev/smoke_analyst.py b/scripts/dev/smoke_analyst.py new file mode 100644 index 0000000..f34bb10 --- /dev/null +++ b/scripts/dev/smoke_analyst.py @@ -0,0 +1,180 @@ +"""数据分析 Agent Scope B 冒烟(四 Demo 角色 + dashboard + 归属拒答)。 + +默认用 FakeLLM(不依赖 DeepSeek);加 --live-llm 走真实 Key(需 .env DEEPSEEK_API_KEY 有效)。 +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from fastapi.testclient import TestClient + +from app.api import analyst as analyst_api +from app.main import app +from app.service.analyst_agent import AnalystAgent +from app.service.auth_service import issue_dev_token + +client = TestClient(app) +PASS = FAIL = 0 + + +class FakeLLM: + """按问题关键词返回可过 sql_guard 的只读 SQL。""" + + def __init__(self) -> None: + self._calls = 0 + self._last_answer = "解读。" + + def complete(self, messages, temperature=0.0, max_tokens=2048): + text = " ".join(str(m.get("content", "")) for m in messages) + usage = {"prompt_tokens": 10, "completion_tokens": 10} + if "CUST-9999" in text or "CUST-9999" in messages[-1].get("content", ""): + sql = "SELECT * FROM core_holding WHERE customer_id='CUST-9999'" + ans = "越权查询。" + elif "CUST-9527" in text or "我有多少笔交易" in text: + sql = ( + "SELECT COUNT(*) AS cnt FROM core_trade " + "WHERE customer_id='CUST-9527'" + ) + ans = "您共有 2 笔交易。" + elif "名下" in text and "客户" in text: + sql = "SELECT COUNT(DISTINCT customer_id) AS cnt FROM core_customer_advisor WHERE advisor_id='STAFF-10086' AND rel_status='active'" + ans = "名下有若干客户。" + elif "预警" in text: + sql = ( + "SELECT COUNT(*) AS cnt FROM jinrong_agent.risk_alert " + "WHERE status='pending_review'" + ) + ans = "有待处理预警。" + else: + sql = "SELECT COUNT(*) AS cnt FROM core_customer" + ans = "共 33 个客户。" + self._last_answer = ans + self._calls += 1 + if self._calls == 1: + return sql, usage + return self._last_answer, usage + + +class FakeRepo: + def resolve_advisor_scope(self, advisor_id: str): + return ["CUST-9527", "CUST-1001"] + + def execute_readonly(self, sql: str): + if "core_trade" in sql and "CUST-9527" in sql: + return {"columns": ["cnt"], "rows": [[2]]} + if "risk_alert" in sql: + return {"columns": ["cnt"], "rows": [[5]]} + if "core_customer_advisor" in sql: + return {"columns": ["cnt"], "rows": [[3]]} + return {"columns": ["cnt"], "rows": [[33]]} + + def get_data_as_of(self): + return "2026-09-04" + + def log_query(self, **kw): + pass + + def log_audit(self, **kw): + pass + + +def ok(name: str, cond: bool, detail: str = "") -> None: + global PASS, FAIL + if cond: + PASS += 1 + print(f" [PASS] {name}" + (f" — {detail}" if detail else "")) + else: + FAIL += 1 + print(f" [FAIL] {name}" + (f" — {detail}" if detail else "")) + + +def token(sub: str, roles: str, *, token_type: str = "staff", customer_id: str | None = None) -> str: + return issue_dev_token( + sub=sub, + roles=[r.strip() for r in roles.split(",")], + token_type=token_type, + customer_id=customer_id, + ) + + +def analyst_chat(tok: str, question: str) -> dict: + analyst_api._agent = AnalystAgent(llm=FakeLLM(), repo=FakeRepo()) + r = client.post( + "/api/analyst/chat", + headers={"Authorization": f"Bearer {tok}"}, + json={"question": question}, + ) + try: + body = r.json() + except Exception: + body = {"raw": r.text[:300]} + return {"status_code": r.status_code, "body": body} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--live-llm", action="store_true", help="使用真实 DeepSeek(需有效 Key)") + args = parser.parse_args() + + if not args.live_llm: + mode = "FakeLLM + FakeRepo" + else: + analyst_api._agent = None + mode = "Live LLM + MySQL" + print(f"=== 数据分析 Agent Scope B 冒烟 ({mode}) ===\n") + + h = client.get("/health") + ok("GET /health", h.status_code == 200) + ok("analyst routes", "/api/analyst/chat" in client.get("/openapi.json").json()["paths"]) + ok("chat no token → 401", client.post("/api/analyst/chat", json={"question": "x"}).status_code == 401) + + cases = [ + ("analyst", token("STAFF-20001", "analyst"), "客户总数是多少"), + ("customer", token("CUST-9527", "customer", token_type="customer", customer_id="CUST-9527"), "我有多少笔交易"), + ("advisor", token("STAFF-10086", "advisor"), "我名下有多少客户"), + ("risk_officer", token("STAFF-30001", "risk_officer"), "待处理预警有多少"), + ] + for role, tok, q in cases: + res = analyst_chat(tok, q) + body = res["body"] + ok(f"{role} chat → 200", res["status_code"] == 200, f"http={res['status_code']}") + ok( + f"{role} status success/degrade", + body.get("status") in ("success", "degrade"), + f"status={body.get('status')} err={body.get('error_code')}", + ) + ok(f"{role} 四件套", bool(body.get("sql")) and "meta" in body and "table" in body) + if role == "customer": + ok( + "customer AI 风险尾注", + "AI 分析有风险" in (body.get("answer") or "") or "AI 分析有风险" in (body.get("disclaimer") or ""), + ) + + deny = analyst_chat(token("STAFF-10086", "advisor"), "查 CUST-9999 的持仓") + ok( + "advisor 越权 deny", + deny["status_code"] == 200 and deny["body"].get("status") == "deny", + deny["body"].get("error_code", ""), + ) + + for role, tok in [ + ("analyst", token("STAFF-20001", "analyst")), + ("customer", token("CUST-9527", "customer", token_type="customer", customer_id="CUST-9527")), + ]: + d = client.get("/api/analyst/dashboard", headers={"Authorization": f"Bearer {tok}"}) + ok(f"{role} dashboard", d.status_code == 200 and "cards" in d.json()) + + print(f"\n=== 结果: {PASS} passed, {FAIL} failed ===") + if not args.live_llm: + print("提示: 真实 NL2SQL 请加 --live-llm(需有效 DEEPSEEK_API_KEY + MySQL)") + print("提示: 前端 dev 代理仍指向 :8000,请重启 uvicorn 加载 analyst 路由") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main())