## 补的是什么空白
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。已在两处代码里注明,免得下一个人再踩。
106 lines
4.6 KiB
Python
106 lines
4.6 KiB
Python
"""`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))
|