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("*"))