234 lines
8.5 KiB
Python
234 lines
8.5 KiB
Python
import json
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import sqlite3
|
|
import subprocess
|
|
import time
|
|
|
|
import httpx
|
|
import pytest
|
|
from pymilvus import MilvusClient
|
|
|
|
|
|
pytestmark = pytest.mark.live
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
FIXTURES = ROOT / "backend" / "tests" / "fixtures"
|
|
FIXTURE_PATHS = tuple(
|
|
FIXTURES / name for name in ("knowledge.md", "knowledge.txt", "knowledge.pdf")
|
|
)
|
|
|
|
|
|
def _port() -> int:
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
def _start_server(port: int, env: dict[str, str]) -> subprocess.Popen[str]:
|
|
process = subprocess.Popen(
|
|
[
|
|
str(ROOT / "backend" / ".venv" / "bin" / "python"),
|
|
"-m",
|
|
"uvicorn",
|
|
"kbqa.main:app",
|
|
"--app-dir",
|
|
"backend/src",
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
str(port),
|
|
],
|
|
cwd=ROOT,
|
|
env=env,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
for _ in range(120):
|
|
if process.poll() is not None:
|
|
raise RuntimeError("live Uvicorn exited during startup")
|
|
try:
|
|
if httpx.get(f"{base_url}/health", timeout=1).status_code == 200:
|
|
return process
|
|
except httpx.HTTPError:
|
|
pass
|
|
time.sleep(0.25)
|
|
process.terminate()
|
|
raise TimeoutError("live Uvicorn did not become healthy")
|
|
|
|
|
|
def _stop_server(process: subprocess.Popen[str]) -> None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=15)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
|
|
|
|
def _stream(client: httpx.Client, session_id: str, query: str) -> list[tuple[str, dict]]:
|
|
events: list[tuple[str, dict]] = []
|
|
with client.stream(
|
|
"POST", "/api/v1/chat/stream", json={"session_id": session_id, "query": query}, timeout=180
|
|
) as response:
|
|
response.raise_for_status()
|
|
event_name = ""
|
|
for line in response.iter_lines():
|
|
if line.startswith("event: "):
|
|
event_name = line.removeprefix("event: ")
|
|
elif line.startswith("data: "):
|
|
events.append((event_name, json.loads(line.removeprefix("data: "))))
|
|
return events
|
|
|
|
|
|
def _answer(events: list[tuple[str, dict]]) -> str:
|
|
return "".join(data["content"] for name, data in events if name == "token")
|
|
|
|
|
|
def _assert_flow(events: list[tuple[str, dict]]) -> None:
|
|
stages = [
|
|
f"status:{data['phase']}" if name == "status" else name
|
|
for name, data in events
|
|
if name != "ping"
|
|
]
|
|
expected = ["status:researching", "sources", "status:answering", "token", "done"]
|
|
positions = [stages.index(stage) for stage in expected]
|
|
assert positions == sorted(positions), stages
|
|
|
|
|
|
def test_real_model_pipeline_survives_restart_and_cleans_up(tmp_path) -> None:
|
|
if os.environ.get("KBQA_RUN_LIVE_TESTS") != "1":
|
|
pytest.skip("set KBQA_RUN_LIVE_TESTS=1 to call real models")
|
|
|
|
database = tmp_path / "kbqa.sqlite3"
|
|
milvus = tmp_path / "milvus.db"
|
|
raw = tmp_path / "raw"
|
|
env = os.environ.copy()
|
|
env.update(
|
|
{
|
|
"KBQA_DATABASE_URL": f"sqlite+aiosqlite:///{database}",
|
|
"KBQA_MILVUS_URI": str(milvus),
|
|
"KBQA_RAW_DATA_DIR": str(raw),
|
|
"KBQA_RUN_LIVE_TESTS": "1",
|
|
}
|
|
)
|
|
subprocess.run(
|
|
[
|
|
str(ROOT / "backend" / ".venv" / "bin" / "alembic"),
|
|
"-c",
|
|
"backend/alembic.ini",
|
|
"upgrade",
|
|
"head",
|
|
],
|
|
cwd=ROOT,
|
|
env=env,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
port = _port()
|
|
process = _start_server(port, env)
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
document_ids: list[str] = []
|
|
try:
|
|
with httpx.Client(base_url=base_url, timeout=30) as client:
|
|
for path in FIXTURE_PATHS:
|
|
with path.open("rb") as file_handle:
|
|
response = client.post(
|
|
"/api/v1/documents", files={"file": (path.name, file_handle)}
|
|
)
|
|
response.raise_for_status()
|
|
document_ids.append(response.json()["id"])
|
|
|
|
deadline = time.monotonic() + 120
|
|
while time.monotonic() < deadline:
|
|
documents = client.get("/api/v1/documents", params={"limit": 100}).json()["items"]
|
|
if all(item["status"] == "indexed" for item in documents):
|
|
break
|
|
assert not any(item["status"] == "failed" for item in documents), documents
|
|
time.sleep(0.5)
|
|
else:
|
|
pytest.fail("documents did not finish indexing")
|
|
assert len(documents) == 3
|
|
assert all(item["chunk_count"] > 0 for item in documents)
|
|
|
|
session = client.post("/api/v1/sessions", json={"title": "真实模型测试"}).json()
|
|
session_id = session["id"]
|
|
first = _stream(client, session_id, "星槐项目的内部代号和负责人分别是什么?")
|
|
_assert_flow(first)
|
|
first_answer = _answer(first)
|
|
assert "ORCHID-731" in first_answer
|
|
assert "林岚" in first_answer
|
|
first_sources = [
|
|
source for name, data in first if name == "sources" for source in data["sources"]
|
|
]
|
|
assert first_sources
|
|
assert {source["filename"] for source in first_sources} <= {
|
|
path.name for path in FIXTURE_PATHS
|
|
}
|
|
chunks_by_document: dict[str, set[str]] = {}
|
|
for source in first_sources:
|
|
document_id = source["document_id"]
|
|
if document_id not in chunks_by_document:
|
|
page = client.get(
|
|
f"/api/v1/documents/{document_id}/chunks", params={"limit": 100}
|
|
).json()
|
|
chunks_by_document[document_id] = {item["id"] for item in page["items"]}
|
|
assert source["chunk_id"] in chunks_by_document[document_id]
|
|
|
|
follow_up = _stream(client, session_id, "她负责的项目首次评审日期是什么?")
|
|
_assert_flow(follow_up)
|
|
follow_up_answer = _answer(follow_up)
|
|
assert "2026" in follow_up_answer and "18" in follow_up_answer
|
|
absent = _stream(client, session_id, "星槐项目部署在哪个月球基地?")
|
|
_assert_flow(absent)
|
|
assert any(marker in _answer(absent) for marker in ("不足", "没有", "未检索"))
|
|
history = client.get(
|
|
f"/api/v1/sessions/{session_id}/messages", params={"limit": 100}
|
|
).json()
|
|
assert history["total"] == 6
|
|
assert all(item["status"] == "completed" for item in history["items"])
|
|
assert history["items"][1]["sources"]
|
|
finally:
|
|
_stop_server(process)
|
|
|
|
process = _start_server(port, env)
|
|
try:
|
|
with httpx.Client(base_url=base_url, timeout=30) as client:
|
|
assert client.get("/api/v1/documents").json()["total"] == 3
|
|
history = client.get(
|
|
f"/api/v1/sessions/{session_id}/messages", params={"limit": 100}
|
|
).json()
|
|
assert history["total"] == 6
|
|
after_restart = _stream(client, session_id, "青屿实验的校准周期是多久?")
|
|
_assert_flow(after_restart)
|
|
assert "14" in _answer(after_restart)
|
|
for document_id in document_ids:
|
|
assert client.delete(f"/api/v1/documents/{document_id}").status_code == 204
|
|
assert client.delete(f"/api/v1/sessions/{session_id}").status_code == 204
|
|
assert client.get("/api/v1/documents").json()["total"] == 0
|
|
assert client.get(f"/api/v1/sessions/{session_id}/messages").status_code == 404
|
|
finally:
|
|
_stop_server(process)
|
|
|
|
with sqlite3.connect(database) as connection:
|
|
for table in (
|
|
"documents",
|
|
"index_jobs",
|
|
"chunks",
|
|
"chat_sessions",
|
|
"messages",
|
|
"message_sources",
|
|
):
|
|
assert connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0] == 0
|
|
vector_client = MilvusClient(str(milvus))
|
|
try:
|
|
vector_client.load_collection("kbqa_chunks")
|
|
assert int(vector_client.get_collection_stats("kbqa_chunks")["row_count"]) == 0
|
|
finally:
|
|
vector_client.close()
|
|
assert not list(raw.glob("*"))
|