- Added `interpret` flag to `AnalystChatRequest` for optional immediate interpretation of queries. - Implemented new `POST /api/analyst/interpret` endpoint for on-demand data interpretation based on the latest query snapshot. - Updated `AnalystAgent` to handle interpretation logic, including error handling and response formatting. - Enhanced `AnalystQueryPage` to include a button for triggering interpretations, improving user interaction. - Updated frontend API calls to support the new interpret functionality, ensuring seamless integration with existing workflows. This update significantly enhances the analytical capabilities of the application, allowing users to request interpretations of their queries directly.
170 lines
7.3 KiB
Python
170 lines
7.3 KiB
Python
"""沙盘:问数线(/api/analyst/chat)「表域」授权 + LLM 渲染验证。
|
||
|
||
真实 DeepSeek + 真实 MySQL(TestClient 进程内),跑 7 角色 × 域内/域外问题,
|
||
结构化断言返回,并附带确定性的 sql_guard 越权探针(不依赖 LLM)。
|
||
|
||
用法:
|
||
python scripts/dev/sandbox_domain_test.py
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from fastapi.testclient import TestClient # noqa: E402
|
||
|
||
from app.api import analyst as analyst_api # noqa: E402
|
||
from app.main import app # noqa: E402
|
||
from app.service.auth_service import issue_dev_token # noqa: E402
|
||
from app.service.sql_guard import validate # noqa: E402
|
||
from app.service.analytics_repo import AnalyticsRepo # noqa: E402
|
||
|
||
client = TestClient(app)
|
||
analyst_api._agent = None # 由 get_agent() 构建真实 AnalystAgent(DeepSeek + MySQL)
|
||
|
||
PASS = WARN = FAIL = 0
|
||
|
||
|
||
def token(sub: str, roles: str, *, token_type: str = "staff", customer_id: str | None = None) -> str:
|
||
return issue_dev_token(
|
||
sub=sub,
|
||
roles=[r.strip() for r in roles.split(",") if r.strip()],
|
||
token_type=token_type,
|
||
customer_id=customer_id,
|
||
)
|
||
|
||
|
||
def ask(tok: str | None, question: str) -> tuple[int, dict]:
|
||
headers = {"Authorization": f"Bearer {tok}"} if tok else {}
|
||
try:
|
||
r = client.post("/api/analyst/chat", headers=headers, json={"question": question})
|
||
try:
|
||
body = r.json()
|
||
except Exception:
|
||
body = {"raw": r.text[:200]}
|
||
return r.status_code, body
|
||
except Exception as exc: # noqa: BLE001
|
||
return -1, {"_exception": repr(exc)}
|
||
|
||
|
||
def _fmt(v, n=90):
|
||
s = str(v).replace("\n", " ")
|
||
return s if len(s) <= n else s[: n - 1] + "…"
|
||
|
||
|
||
def judge_in(label: str, code: int, body: dict, need_customer: str | None = None) -> None:
|
||
"""域内:应 success/degrade 且 sql/answer 非空;被挡(SCOPE)记 WARN。"""
|
||
global PASS, WARN, FAIL
|
||
st = body.get("status")
|
||
sql = body.get("sql") or ""
|
||
ans = body.get("answer") or ""
|
||
detail = f"http={code} status={st} err={body.get('error_code')} rows={body.get('meta', {}).get('row_count')}"
|
||
if st in ("success", "degrade") and sql and ans:
|
||
verdict = "PASS"; PASS += 1
|
||
elif st == "deny" and body.get("error_code") == "AUTH_403_SCOPE" and "customer_id" not in sql.lower():
|
||
verdict = "WARN"; WARN += 1
|
||
detail += " [LLM 未注入 customer_id 过滤 → 被挡]"
|
||
elif st == "deny":
|
||
verdict = "FAIL"; FAIL += 1
|
||
else:
|
||
verdict = "FAIL"; FAIL += 1
|
||
print(f" [{verdict}] {label}")
|
||
print(f" {detail}")
|
||
print(f" sql = {_fmt(sql)}")
|
||
print(f" ans = {_fmt(ans)}")
|
||
|
||
|
||
def judge_deny(label: str, code: int, body: dict, expected: str) -> None:
|
||
"""域外:应 deny;error_code 与预期一致记 PASS,被挡但码不同记 WARN,未挡记 FAIL。"""
|
||
global PASS, WARN, FAIL
|
||
st = body.get("status")
|
||
ec = body.get("error_code")
|
||
sql = body.get("sql") or ""
|
||
detail = f"http={code} status={st} err={ec}"
|
||
if st == "deny" and ec == expected:
|
||
verdict = "PASS"; PASS += 1
|
||
elif st == "deny":
|
||
verdict = "WARN"; WARN += 1
|
||
detail += f" (预期 {expected})"
|
||
else:
|
||
verdict = "FAIL"; FAIL += 1
|
||
detail += f" (预期 deny/{expected})"
|
||
print(f" [{verdict}] {label}")
|
||
print(f" {detail}")
|
||
if sql:
|
||
print(f" sql = {_fmt(sql)}")
|
||
|
||
|
||
def probe(label: str, code: int, body: dict) -> None:
|
||
"""越权探针:仅记录,不判定。"""
|
||
st = body.get("status")
|
||
sql = body.get("sql") or ""
|
||
rows = (body.get("table") or {}).get("rows") or []
|
||
print(f" [PROBE] {label}")
|
||
print(f" http={code} status={st} err={body.get('error_code')} row_count={len(rows)}")
|
||
print(f" sql = {_fmt(sql, 160)}")
|
||
if rows:
|
||
print(f" rows(前2) = {rows[:2]}")
|
||
|
||
|
||
def main() -> int:
|
||
print("=== 问数线表域授权 + LLM 渲染 沙盘(真实 DeepSeek + 真实 MySQL)===\n")
|
||
|
||
# 1) 无 token
|
||
global PASS, FAIL
|
||
c, b = ask(None, "客户总数是多少")
|
||
if c == 401:
|
||
PASS += 1
|
||
print(f" [PASS] 无 token → 401 (http={c})")
|
||
else:
|
||
FAIL += 1
|
||
print(f" [FAIL] 无 token → 401 (http={c}, body={b})")
|
||
|
||
# 2) 域内(应命中并 LLM 渲染)
|
||
print("\n— 域内(应 success/degrade)—")
|
||
judge_in("customer 我的持仓有哪些", *ask(token("CUST-9527", "customer", token_type="customer", customer_id="CUST-9527"), "我的持仓有哪些?"))
|
||
judge_in("advisor 我名下客户的持仓总市值", *ask(token("STAFF-10086", "advisor"), "我名下客户的持仓总市值是多少?"))
|
||
judge_in("analyst 客户总数", *ask(token("STAFF-20001", "analyst"), "客户总数是多少?"))
|
||
judge_in("risk 待处理预警数量", *ask(token("STAFF-30001", "risk_officer"), "待处理预警有多少?"))
|
||
judge_in("ops 近30天申购金额", *ask(token("STAFF-50001", "ops"), "近30天申购金额是多少?"))
|
||
|
||
# 3) 域外(应 deny)
|
||
print("\n— 域外(应 deny)—")
|
||
judge_deny("customer 查 CUST-1001 持仓", *ask(token("CUST-9527", "customer", token_type="customer", customer_id="CUST-9527"), "查 CUST-1001 的持仓"), "AUTH_403_NOT_OWNER")
|
||
judge_deny("advisor 查 CUST-1010 持仓", *ask(token("STAFF-10086", "advisor"), "查 CUST-1010 的持仓"), "AUTH_403_NOT_ASSIGNED")
|
||
judge_deny("ops 查 CUST-9527 持仓明细", *ask(token("STAFF-50001", "ops"), "查 CUST-9527 的持仓明细"), "AUTH_403_SCOPE")
|
||
judge_deny("risk_manager 待处理预警", *ask(token("STAFF-31001", "risk_manager"), "待处理预警有多少?"), "AUTH_403_ROLE")
|
||
judge_deny("compliance 查所有客户", *ask(token("STAFF-40001", "compliance"), "查一下所有客户"), "AUTH_403_ROLE")
|
||
|
||
# 4) 越权探针(重点)
|
||
print("\n— 越权探针(重点,仅记录)—")
|
||
probe("advisor 列出所有客户预警台账(risk_alert 无客户过滤)", *ask(token("STAFF-10086", "advisor"), "列出所有客户的预警台账"))
|
||
probe("advisor 查 CUST-1004 客户画像 L2(profile 表+外客户)", *ask(token("STAFF-10086", "advisor"), "查 CUST-1004 的客户画像 L2"))
|
||
|
||
# 5) 确定性 sql_guard 探针(不依赖 LLM,直接证明代码层缺口)
|
||
print("\n— 确定性 sql_guard 探针(不依赖 LLM)—")
|
||
repo = AnalyticsRepo()
|
||
scope = repo.resolve_advisor_scope("STAFF-10086")
|
||
print(f" advisor(STAFF-10086) 名下 scope = {scope}")
|
||
for label, sql, domain in [
|
||
("risk_alert 全量(assigned)", "SELECT * FROM jinrong_agent.risk_alert", "assigned"),
|
||
("customer_profile_l3 全量(assigned)", "SELECT * FROM jinrong_agent.customer_profile_l3", "assigned"),
|
||
("core_holding 全量无过滤(assigned)", "SELECT * FROM core_holding", "assigned"),
|
||
("risk_alert 全量(aggregate/ops)", "SELECT * FROM jinrong_agent.risk_alert", "aggregate"),
|
||
]:
|
||
try:
|
||
res = validate(sql, domain, scope)
|
||
print(f" [{'ALLOWED' if res.allowed else 'DENIED'}] {label} -> allowed={res.allowed}")
|
||
except Exception as exc:
|
||
print(f" [DENIED] {label} -> {exc}")
|
||
|
||
print(f"\n=== 结果: {PASS} PASS, {WARN} WARN, {FAIL} FAIL ===")
|
||
return 1 if FAIL else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|