Files
PlantAI-rag-new/sdk/app/routers/test_qa.py
T
jerryandClaude Fable 5 0875bb036c P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架
- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
  历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
  单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
  tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
  生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:56:47 +08:00

261 lines
9.1 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.
"""test_qa.py —— /api/qa/* 路由单元测试(A2 归属)。
通过 httpx.MockTransport 注入假上游(把 sse_relay.get_qa_client 替换为指向
MockTransport 的 client),不依赖真实 7920。
"""
from __future__ import annotations
import json
import httpx
import pytest
from starlette.testclient import TestClient
from app.main import app
from app.services import sse_relay
def _transport_handler(handler) -> httpx.AsyncClient:
"""以 MockTransport 构造一个 client。"""
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
@pytest.fixture
def bff_client():
return TestClient(app)
UPSTREAM_INFO = {
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
"version": "1.1.73-assertion-anchored",
"pipeline": ["question", "provisional answer"],
"config": {"max_evidence": 40, "bge_rerank": True},
}
def _json_response(payload, status=200) -> httpx.Response:
return httpx.Response(status, json=payload)
def test_info_passthrough_trimmed(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert request.url.path.endswith("/api/v1/qa-v1-1-73/info")
return _json_response(UPSTREAM_INFO)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.get("/api/qa/info")
assert resp.status_code == 200
data = resp.json()
assert data == UPSTREAM_INFO
assert set(data.keys()) == {"name", "version", "pipeline", "config"}
def test_info_upstream_down_503(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down", request=request)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.get("/api/qa/info")
assert resp.status_code == 503
data = resp.json()
assert data["ok"] is False
assert data["error"]["code"] == "UPSTREAM_DOWN"
assert data["error"]["degraded"] is True
def test_info_wrapped_info_field_tolerated(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
return _json_response({"info": UPSTREAM_INFO})
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.get("/api/qa/info")
assert resp.status_code == 200
assert resp.json()["name"] == UPSTREAM_INFO["name"]
def test_chat_ok_normalized(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
assert request.url.path.endswith("/api/v1/qa-v1-1-73/chat")
return _json_response({"answer": "some answer", "sources": []})
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
body = {"question": "水稻SPL?", "use_llm": True, "chain_top_k": 3}
resp = bff_client.post("/api/qa/chat", json=body)
assert resp.status_code == 200
data = resp.json()
assert data["mode"] == "chat"
assert data["answer"] == "some answer"
# 请求体透传,不增删字段
sent = json.loads(calls[0].content)
assert sent == body
def test_chat_invalid_citation_retries_once(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
if len(calls) == 1:
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
return _json_response({"answer": "retry ok", "sources": []})
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 200
assert resp.json()["answer"] == "retry ok"
assert len(calls) == 2
def test_chat_invalid_citation_retry_still_fails_502(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 502
data = resp.json()
assert data["error"]["code"] == "INVALID_CITATION"
assert data["error"]["message"] == "引用校验失败,请重试"
assert len(calls) == 2 # 重试仅一次
def test_chat_upstream_500_no_retry(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return _json_response({"detail": "boom"}, status=500)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 502
assert resp.json()["error"]["code"] == "UPSTREAM_ERROR"
assert len(calls) == 1
def test_search_forces_use_llm_false_and_mode_search(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
assert request.url.path.endswith("/api/v1/qa-v1-1-73/search")
return _json_response({"answer": "SHOULD BE WIPED", "sources": [{"title": "T"}]})
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/search", json={"question": "q", "use_llm": True})
assert resp.status_code == 200
data = resp.json()
assert data["mode"] == "search"
assert data["answer"] == ""
sent = json.loads(calls[0].content)
assert sent["use_llm"] is False # 强制
assert sent["question"] == "q" # 其余透传
def test_search_no_retry_on_invalid_citation(bff_client, monkeypatch):
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/search", json={"question": "q"})
assert resp.status_code == 502
assert len(calls) == 1 # 不重试
def test_chat_upstream_down_503(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down", request=request)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
def test_chat_upstream_read_timeout_504(bff_client, monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("slow", request=request)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
resp = bff_client.post("/api/qa/chat", json={"question": "q"})
assert resp.status_code == 504
assert resp.json()["error"]["code"] == "TIMEOUT"
def test_chat_stream_headers_and_relay(bff_client, monkeypatch):
"""SSE 端到端:MockTransport 吐 SSE 字节,断言契约帧与响应头。"""
upstream_bytes = (
b'data: {"stage":"entities_identified","details":{"entities":["OsSPL14"]}}\n\n'
b'event: complete\ndata: {"answer":"A","sources":[]}\n\n'
)
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path.endswith("/api/v1/qa-v1-1-73/chat/stream")
return httpx.Response(200, stream=httpx.ByteStream(upstream_bytes))
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
with bff_client.stream("POST", "/api/qa/chat/stream", json={"question": "q"}) as resp:
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/event-stream")
assert resp.headers["cache-control"] == "no-cache"
assert resp.headers["x-accel-buffering"] == "no"
body = "".join(resp.iter_text())
assert "event: progress" in body
assert '"phase": "planning"' in body
assert "event: complete" in body
assert "event: error" not in body
def test_chat_stream_invalid_citation_retry_then_error(bff_client, monkeypatch):
"""上游头阶段 402 → 重试一次 → 仍 402 → event: error。"""
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
return _json_response({"detail": {"error_code": "invalid_llm_citation"}}, status=402)
client = _transport_handler(handler)
monkeypatch.setattr(sse_relay, "get_qa_client", lambda: client)
with bff_client.stream("POST", "/api/qa/chat/stream", json={"question": "q"}) as resp:
body = "".join(resp.iter_text())
assert "event: error" in body
assert '"code": "INVALID_CITATION"' in body
assert len(calls) == 2