Files
group_fqcd_jr/tests/integration/test_financial_nl2sql_http_wiring.py
T
张胜宇 7cacb16252 test(W29-d): 补上「数据落库 + HTTP 链路」的端到端验证(此前只测到进程内直调)
## 补的是什么空白

W28 / W29 的全部验证都是**进程内直调** —— 直接构造 ToolExecutor、直接调 Agent,
或者**手写** configured_tools。**从来没有走过「HTTP 受理 → Worker 执行 → 落库」这条真实链路。**

补测前的实测证据:

    conversation_message 里 tool_calls LIKE '%trend_chart%'  -> 0 条
    conversation_message 里 tool_calls LIKE '%svc_topic%'    -> 0 条(W28 事项码)
    agent_run 里 agent_type='financial_nl2sql'               -> 0 条
    最近一条真实 run                                          -> 2026-09-21 14:46(早于这两批改动)

而**前端取图完全依赖这条路**:前端读的是 `result.tool_calls.data.trend_chart`,
它由 `agent_persistence_service` 写进 `conversation_message.tool_calls` 这个 JSON 列。
**这条路不通,图就是「代码里有、页面上没有」。**

## 两个测试

1. `test_customer_service_trend_chart_persistence.py`
   走势问句走 HTTP + Worker,断言 `trend_chart` 与 `svc_topic` 同时出现在**出参与库里**;
   并逐条断言图数据中的涨跌数字**出现在答复正文里**(`INV-8` 的落库侧前提)。

2. `test_financial_nl2sql_http_wiring.py`
   接线后 `financial_nl2sql` 能否在**真实 HTTP 链路**上出结果。
   此前的「接线验证」是**手写 configured_tools** —— 等于把要验证的东西自己填好了:
   它能证明「工具逻辑对」,但**证明不了「接线生效」**(白名单是从 `config_release` 装配的)。
   断言 `data` / `sql` 出参 + SELECT-only 自证 + `data.total`(证明**真的执行了**,不是只编译)。

两个测试都按 `session_id`(带 uuid)隔离并在 finally 清理,不污染历史数据。

## 结果

- 两条单跑均 passed(4.12s / 2.84s)
- 全量 pytest **2547 passed / 3 skipped / 0 failed**(2545 + 2)
- ruff:两个新文件 0 告警

## 一句留给后人的话

写这两个测试时的第一个失败是 `TypeError: object RequestContext can't be used in
'await' expression` —— `WorkerRuntime.restore_context` 会 `await self.resolve_identity(...)`,
必须传**协程函数**,不能传 lambda。已在两处代码里注明,免得下一个人再踩。
2026-09-22 10:47:48 +08:00

106 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""`W29-d` · NL2SQL 接线后的 **HTTP 链路**验证(此前只测到服务层)。
## 为什么需要它
`W29` 把 `agent_tools/financial_nl2sql:financial_query` 发布出去之后,验证只做到两层:
- **服务层直调**:`FinancialNL2SQLService().query(...)`;
- **工具层**:自建 `ToolExecutor` + **手写**
`configured_tools={"financial_query": ("query_financial_data",)}`。
这两层都**绕过了**真实链路最关键的一段:`agent_tools` 白名单是**从库里装配**的
(`AgentFactory` 读 `config_release` 的生效快照)。手写 `configured_tools` 等于
**把要验证的东西自己填好了** —— 它能证明"工具逻辑对",但**证明不了"接线生效"**。
库里的证据也印证了这一点:`agent_run` 表里 `agent_type='financial_nl2sql'` **0 条**。
本文件走完整链路:HTTP 受理 → Worker 从**生效配置**装配白名单 → 工具执行 → 落库 → 读出参。
"""
from __future__ import annotations
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import delete
from app.api.dependencies.auth import build_request_context
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory
from app.main import create_app
from app.model.conversation import ConversationMessage
from app.model.platform import AgentRun
from app.service.agent.bootstrap import get_agent_factory
from app.worker.runtime import WorkerRuntime
WRITE_WORDS = ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE")
@pytest.mark.integration
async def test_financial_nl2sql_serves_over_http_after_wiring() -> None:
"""接线后,`financial_nl2sql` 必须能在**真实 HTTP 链路**上出结果。
断言的是"端到端可用",不是"工具逻辑正确"(后者由 `test_financial_nl2sql_*.py` 覆盖)。
两者会分别失败,所以分开测 —— 否则"NL2SQL 坏了"这句话定位不到是哪一层。
"""
context = RequestContext(
user_id="1",
trace_id=str(uuid4()),
roles=("operator",),
data_scope="all",
portal="api",
permissions=("agent:run", "financial:nl2sql:read"),
)
app = create_app()
app.dependency_overrides[build_request_context] = lambda: context
session_id = f"w29d-nl2sql-{uuid4()}"
run_id: str | None = None
async def identity(value: object) -> RequestContext:
# 必须是协程函数:`restore_context` 会 `await` 它。
del value
return context
runtime = WorkerRuntime(get_agent_factory(), resolve_identity=identity)
try:
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
accepted = await client.post("/api/v1/agent-runs", json={
"agent_type": "financial_nl2sql",
"message": "查询近30天净值",
"session_id": session_id,
"idempotency_key": str(uuid4()),
})
assert accepted.status_code == 202, accepted.text
run_id = accepted.json()["data"]["run_id"]
assert await runtime.dispatch_one(run_id=run_id), "run 未派发成功"
await runtime.execute(run_id)
snapshot = (await client.get(f"/api/v1/agent-runs/{run_id}")).json()["data"]
assert snapshot["status"] == "succeeded", snapshot
result = snapshot["result"] or {}
# ① 出参:`RunQueryService` **只**对 `financial_nl2sql` 暴露 `data` 与 `sql`
assert isinstance(result.get("data"), dict), f"出参缺 data:{sorted(result)}"
assert isinstance(result.get("sql"), str), f"出参缺 sql:{sorted(result)}"
# ② 只读自证(工具声明 `read_only=True`,这里验它真的没产出写语句)
sql = result["sql"]
upper = sql.upper()
assert upper.startswith("SELECT"), f"只读工具产出了非 SELECT:{sql}"
assert not any(word in upper for word in WRITE_WORDS), sql
assert "*" not in sql, f"禁止 SELECT *:{sql}"
assert "LIMIT" in upper, f"缺 LIMIT:{sql}"
# ③ 真的**执行**了,而不是只编译完就返回 —— `data.total` 只有执行后才填
assert "total" in result["data"], f"查询未真正执行:{result['data']}"
finally:
async with SessionFactory() as session, session.begin():
await session.execute(
delete(ConversationMessage).where(ConversationMessage.session_id == session_id)
)
if run_id:
await session.execute(delete(AgentRun).where(AgentRun.run_id == run_id))