- 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>
261 lines
9.1 KiB
Python
261 lines
9.1 KiB
Python
"""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
|