2026-09-09 21:02:11 +08:00
|
|
|
"""analyst_agent 编排测试(Wave 6)。"""
|
2026-09-09 18:04:45 +08:00
|
|
|
import unittest
|
|
|
|
|
|
2026-09-09 21:02:11 +08:00
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from app.api.analyst_auth_adapter import AnalystAuthContext
|
2026-09-09 18:04:45 +08:00
|
|
|
from app.service.analyst_agent import AnalystAgent
|
2026-09-10 15:22:07 +08:00
|
|
|
from app.service.template_service import QueryTemplate, TemplateService
|
2026-09-09 18:04:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-09-10 15:22:07 +08:00
|
|
|
def list_published_templates(self):
|
|
|
|
|
return []
|
|
|
|
|
|
2026-09-11 15:18:56 +08:00
|
|
|
def get_query_log_by_trace(self, trace_id):
|
|
|
|
|
return {
|
|
|
|
|
"staff_id": "STAFF-A",
|
|
|
|
|
"generated_sql": "SELECT COUNT(*) AS c FROM core_customer",
|
|
|
|
|
"exec_status": "success",
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 18:04:45 +08:00
|
|
|
|
2026-09-09 21:02:11 +08:00
|
|
|
def ctx(roles, subject="STAFF-A", *, token_type="staff", customer_id=None):
|
|
|
|
|
return AnalystAuthContext(
|
|
|
|
|
subject_id=subject,
|
|
|
|
|
token_type=token_type,
|
|
|
|
|
roles=roles,
|
|
|
|
|
customer_id=customer_id,
|
|
|
|
|
)
|
2026-09-09 18:04:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAgentOrchestration(unittest.TestCase):
|
2026-09-11 14:45:16 +08:00
|
|
|
def test_clarify_trade_flow_ambiguity(self):
|
|
|
|
|
agent = AnalystAgent(llm=FakeLLM("SELECT 1", []), repo=FakeRepo())
|
|
|
|
|
resp = agent.run("近30天交易流水是多少", ctx(["analyst"]))
|
|
|
|
|
self.assertEqual(resp.status, "clarify")
|
|
|
|
|
self.assertIn("流水", resp.answer)
|
|
|
|
|
|
2026-09-09 18:04:45 +08:00
|
|
|
def test_clarify_ambiguity(self):
|
|
|
|
|
agent = AnalystAgent(llm=FakeLLM("SELECT 1", []), repo=FakeRepo())
|
|
|
|
|
resp = agent.run("我名下的规模是多少", ctx(["analyst"]))
|
|
|
|
|
self.assertEqual(resp.status, "clarify")
|
|
|
|
|
|
2026-09-11 12:15:18 +08:00
|
|
|
def test_success_query_only(self):
|
2026-09-09 18:04:45 +08:00
|
|
|
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]])
|
2026-09-11 12:15:18 +08:00
|
|
|
self.assertEqual(resp.answer, "")
|
2026-09-09 18:04:45 +08:00
|
|
|
self.assertEqual(len(repo.logged), 1)
|
2026-09-11 12:15:18 +08:00
|
|
|
self.assertEqual(agent.llm.calls, 1)
|
|
|
|
|
|
|
|
|
|
def test_success_with_interpret_flag(self):
|
|
|
|
|
repo = FakeRepo(rows=[[33]], columns=["c"])
|
|
|
|
|
llm = FakeLLM("SELECT COUNT(*) AS c FROM core_customer", ["共 33 个客户"])
|
|
|
|
|
agent = AnalystAgent(llm=llm, repo=repo)
|
|
|
|
|
resp = agent.run("客户总数是多少", ctx(["analyst"]), interpret=True)
|
|
|
|
|
self.assertEqual(resp.status, "success")
|
|
|
|
|
self.assertIn("33", resp.answer)
|
|
|
|
|
self.assertEqual(llm.calls, 2)
|
|
|
|
|
|
|
|
|
|
def test_interpret_from_snapshot(self):
|
|
|
|
|
from app.model.analyst_schemas import InterpretRequest, Meta, TableData
|
|
|
|
|
|
|
|
|
|
class AnswerOnlyLLM:
|
|
|
|
|
def complete(self, messages, temperature=0, max_tokens=2048):
|
|
|
|
|
return "共 33 个客户", {"prompt_tokens": 1, "completion_tokens": 1}
|
|
|
|
|
|
|
|
|
|
repo = FakeRepo(rows=[[33]], columns=["c"])
|
|
|
|
|
agent = AnalystAgent(llm=AnswerOnlyLLM(), repo=repo)
|
|
|
|
|
req = InterpretRequest(
|
|
|
|
|
question="客户总数是多少",
|
|
|
|
|
status="success",
|
|
|
|
|
table=TableData(columns=["c"], rows=[[33]]),
|
|
|
|
|
sql="SELECT COUNT(*) AS c FROM core_customer",
|
|
|
|
|
meta=Meta(row_count=1),
|
|
|
|
|
)
|
|
|
|
|
resp = agent.interpret(req, ctx(["analyst"]))
|
|
|
|
|
self.assertEqual(resp.status, "success")
|
|
|
|
|
self.assertIn("33", resp.answer)
|
|
|
|
|
|
|
|
|
|
def test_interpret_echo_deny_without_llm(self):
|
|
|
|
|
from app.model.analyst_schemas import InterpretRequest
|
|
|
|
|
|
|
|
|
|
llm = FakeLLM("SELECT 1", [])
|
|
|
|
|
agent = AnalystAgent(llm=llm, repo=FakeRepo())
|
|
|
|
|
req = InterpretRequest(
|
|
|
|
|
question="查别人",
|
|
|
|
|
status="deny",
|
|
|
|
|
answer="无法执行:权限不足",
|
|
|
|
|
)
|
|
|
|
|
resp = agent.interpret(req, ctx(["customer"], "CUST-1", customer_id="CUST-1"))
|
|
|
|
|
self.assertEqual(resp.status, "deny")
|
|
|
|
|
self.assertEqual(resp.answer, "无法执行:权限不足")
|
|
|
|
|
self.assertEqual(llm.calls, 0)
|
2026-09-09 18:04:45 +08:00
|
|
|
|
|
|
|
|
def test_deny_bad_sql(self):
|
2026-09-11 14:45:16 +08:00
|
|
|
repo = FakeRepo()
|
|
|
|
|
agent = AnalystAgent(llm=FakeLLM("INSERT INTO core_customer VALUES (1)", []), repo=repo)
|
2026-09-09 18:04:45 +08:00
|
|
|
resp = agent.run("删库", ctx(["analyst"]))
|
|
|
|
|
self.assertEqual(resp.status, "deny")
|
|
|
|
|
self.assertEqual(resp.error_code, "SQL_NOT_SELECT")
|
2026-09-11 14:45:16 +08:00
|
|
|
self.assertEqual(len(repo.logged), 1)
|
|
|
|
|
self.assertEqual(repo.logged[0].get("exec_status"), "blocked")
|
2026-09-09 18:04:45 +08:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-09-11 12:15:18 +08:00
|
|
|
resp = agent.run("客户总数", ctx(["analyst"]), interpret=True)
|
2026-09-09 18:04:45 +08:00
|
|
|
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")
|
|
|
|
|
|
2026-09-09 21:02:11 +08:00
|
|
|
def test_customer_self_success(self):
|
|
|
|
|
repo = FakeRepo(rows=[[2]], columns=["cnt"])
|
|
|
|
|
agent = AnalystAgent(
|
|
|
|
|
llm=FakeLLM(
|
|
|
|
|
"SELECT COUNT(*) AS cnt FROM core_trade WHERE customer_id='CUST-9527'",
|
|
|
|
|
["近阶段共有 2 笔交易"],
|
|
|
|
|
),
|
|
|
|
|
repo=repo,
|
|
|
|
|
)
|
|
|
|
|
resp = agent.run(
|
|
|
|
|
"我有多少笔交易",
|
|
|
|
|
ctx(["customer"], "CUST-9527", token_type="customer", customer_id="CUST-9527"),
|
2026-09-11 12:15:18 +08:00
|
|
|
interpret=True,
|
2026-09-09 21:02:11 +08:00
|
|
|
)
|
|
|
|
|
self.assertEqual(resp.status, "success")
|
|
|
|
|
self.assertIn("AI 分析有风险", resp.answer)
|
2026-09-09 18:04:45 +08:00
|
|
|
|
2026-09-10 15:22:07 +08:00
|
|
|
def test_template_hit_skips_llm_sql(self):
|
|
|
|
|
tpl = QueryTemplate(
|
|
|
|
|
template_key="customer_total_count",
|
|
|
|
|
template_sql="SELECT COUNT(*) AS c FROM core_customer",
|
|
|
|
|
params_schema={"match_all": ["客户", "总数"]},
|
|
|
|
|
)
|
|
|
|
|
repo = FakeRepo(rows=[[33]], columns=["c"])
|
|
|
|
|
llm = FakeLLM("SELECT 1", ["共 33 个客户"])
|
|
|
|
|
agent = AnalystAgent(
|
|
|
|
|
llm=llm,
|
|
|
|
|
repo=repo,
|
|
|
|
|
templates=TemplateService(templates=[tpl]),
|
|
|
|
|
)
|
|
|
|
|
resp = agent.run("客户总数是多少", ctx(["analyst"]))
|
|
|
|
|
self.assertEqual(resp.status, "success")
|
|
|
|
|
self.assertTrue(resp.meta.template_hit)
|
|
|
|
|
self.assertEqual(resp.meta.template_key, "customer_total_count")
|
|
|
|
|
self.assertIn("COUNT(*)", resp.sql)
|
2026-09-11 12:15:18 +08:00
|
|
|
self.assertEqual(llm.calls, 0)
|
2026-09-10 15:22:07 +08:00
|
|
|
|
2026-09-11 15:18:56 +08:00
|
|
|
def test_sql_exec_failure_escalates(self):
|
|
|
|
|
class FailRepo(FakeRepo):
|
|
|
|
|
def execute_readonly(self, sql):
|
|
|
|
|
raise TimeoutError("read timed out")
|
|
|
|
|
|
|
|
|
|
class NoCache:
|
|
|
|
|
def permission_fingerprint(self, *a, **k):
|
|
|
|
|
return "fp"
|
|
|
|
|
|
|
|
|
|
def sql_hash(self, sql):
|
|
|
|
|
return "hash"
|
|
|
|
|
|
|
|
|
|
def get_result(self, *a, **k):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def set_result(self, *a, **k):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
agent = AnalystAgent(
|
|
|
|
|
llm=FakeLLM("SELECT COUNT(*) AS c FROM core_customer", []),
|
|
|
|
|
repo=FailRepo(),
|
|
|
|
|
templates=TemplateService(templates=[]),
|
|
|
|
|
cache=NoCache(),
|
|
|
|
|
)
|
|
|
|
|
resp = agent.run("客户总数", ctx(["analyst"]))
|
|
|
|
|
self.assertEqual(resp.status, "escalate")
|
|
|
|
|
self.assertIn(resp.error_code, ("EXEC_TIMEOUT", "EXEC_ERROR"))
|
|
|
|
|
|
|
|
|
|
def test_sample_by_trace(self):
|
|
|
|
|
repo = FakeRepo(rows=[[1]], columns=["c"])
|
|
|
|
|
agent = AnalystAgent(llm=FakeLLM("SELECT 1", []), repo=repo)
|
|
|
|
|
resp = agent.sample_by_trace("trace-1", ctx(["analyst"], subject="STAFF-A"))
|
|
|
|
|
self.assertEqual(resp.status, "success")
|
|
|
|
|
self.assertIn("LIMIT", resp.sql)
|
|
|
|
|
|
|
|
|
|
def test_escalate_audit(self):
|
|
|
|
|
repo = FakeRepo()
|
|
|
|
|
agent = AnalystAgent(llm=FakeLLM("SELECT 1", []), repo=repo)
|
|
|
|
|
out = agent.escalate("trace-x", "q", "timeout", ctx(["analyst"]))
|
|
|
|
|
self.assertTrue(out["ok"])
|
|
|
|
|
|
2026-09-09 21:02:11 +08:00
|
|
|
|
|
|
|
|
@pytest.mark.integration
|
2026-09-09 18:04:45 +08:00
|
|
|
class TestAgentReal(unittest.TestCase):
|
2026-09-09 21:02:11 +08:00
|
|
|
@pytest.mark.skip(reason="需要真实 MySQL + DeepSeek Key")
|
2026-09-09 18:04:45 +08:00
|
|
|
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)
|