test: verify real kbqa workflow

This commit is contained in:
gqt
2026-07-13 14:23:06 +08:00
parent 52184aa00e
commit 2361d62867
32 changed files with 656 additions and 12 deletions
+22
View File
@@ -0,0 +1,22 @@
from collections.abc import AsyncIterator
import pytest_asyncio
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from kbqa.database import Base
import kbqa.models # noqa: F401
@pytest_asyncio.fixture
async def sqlite_engine(tmp_path) -> AsyncIterator[AsyncEngine]:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.sqlite3'}")
@event.listens_for(engine.sync_engine, "connect")
def enable_foreign_keys(connection, _record) -> None:
connection.execute("PRAGMA foreign_keys=ON")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
+5
View File
@@ -0,0 +1,5 @@
# 星槐项目资料
星槐项目的内部代号是 **ORCHID-731**。项目负责人是林岚,首次评审日期为 2026 年 6 月 18 日。
系统采用严格知识库回答方式:没有资料时必须说明证据不足。
+89
View File
@@ -0,0 +1,89 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /STSong-Light /DescendantFonts [ <<
/BaseFont /STSong-Light /CIDSystemInfo <<
/Ordering (GB1) /Registry (Adobe) /Supplement 0
>> /DW 1000 /FontDescriptor <<
/Ascent 752 /CapHeight 737 /Descent -271 /Flags 6 /FontBBox [ -25 -254 1000 880 ] /FontName /STSongStd-Light
/ItalicAngle 0 /Leading 148 /MaxWidth 1000 /MissingWidth 500 /StemH 91 /StemV 58
/Type /FontDescriptor /XHeight 553
>> /Subtype /CIDFontType0 /Type /Font
/W [ 1 [ 207 270 342 467 462 797 710 239 374 ] 10 [ 374 423 605 238 375 238 334 462 ] 18 26 462 27 28 238
29 31 605 32 [ 344 748 684 560 695 739 563 511 729 793
318 312 666 526 896 758 772 544 772 628
465 607 753 711 972 647 620 607 374 333
374 606 500 239 417 503 427 529 415 264
444 518 241 230 495 228 793 527 524 ] 81 [ 524 504 338 336 277 517 450 652 466 452
407 370 258 370 605 ] ]
>> ] /Encoding /UniGB-UCS2-H /Name /F2 /Subtype /Type0 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20260713120326+08'00') /Creator (anonymous) /Keywords () /ModDate (D:20260713120326+08'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (Northwind Archive) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 324
>>
stream
GarnP9hPRS%))C:jJ[ZI5%\uh*fCe"=f%[YTn5r';V54D,5BneL:V6(1@QXO-1AMYTKOdWp&ME-P;rXuj:+p&#KVsFQUdmu5dBtI6^RKJM'40ugn2hs//:/o$`[%R[G?92TM2)sdJeC&2NBC5D]rI.HVr1P1&pVfEH""-4SS_L`pgS'>,@IBXHk>9g.W1m_4bk94'XkO"$-R?>/f)A4K1mL<"AQ'-t^<GF#3K#=m^U.:Tl`Q9DD:d&,>.Q?/6nV[TF=DbJ_lha*>qb8)_]&^pQdj,t'O0XuO?P<E4k*DW#$(Cl<8rh3=q\rB:3Nhp7=2eG~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000001142 00000 n
0000001345 00000 n
0000001413 00000 n
0000001683 00000 n
0000001742 00000 n
trailer
<<
/ID
[<2d05274fa2bda2ab799503a41bce39e6><2d05274fa2bda2ab799503a41bce39e6>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
2156
%%EOF
+2
View File
@@ -0,0 +1,2 @@
青屿实验的校准周期是每 14 天一次。校准负责人是周禾。
如果温度超过 32 摄氏度,应暂停实验并记录环境状态。
@@ -0,0 +1,38 @@
from collections.abc import AsyncIterator
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from kbqa.api.errors import register_exception_handlers
from kbqa.api.middleware import RequestIDMiddleware
from kbqa.chat.routes import router
from kbqa.database import get_session
async def test_session_api_persists_and_deletes(sqlite_engine: AsyncEngine) -> None:
factory = async_sessionmaker(sqlite_engine, expire_on_commit=False)
app = FastAPI()
app.add_middleware(RequestIDMiddleware)
register_exception_handlers(app)
app.include_router(router, prefix="/api/v1")
async def session_override() -> AsyncIterator[AsyncSession]:
async with factory() as session:
yield session
app.dependency_overrides[get_session] = session_override
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
created = await client.post("/api/v1/sessions", json={"title": "持久化测试"})
assert created.status_code == 201
session_id = created.json()["id"]
listed = await client.get("/api/v1/sessions")
assert listed.json()["items"][0]["id"] == session_id
messages = await client.get(f"/api/v1/sessions/{session_id}/messages")
assert messages.json()["items"] == []
deleted = await client.delete(f"/api/v1/sessions/{session_id}")
assert deleted.status_code == 204
missing = await client.get(f"/api/v1/sessions/{session_id}/messages")
assert missing.status_code == 404
@@ -0,0 +1,56 @@
from sqlalchemy import event, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from kbqa.chat.models import ChatSession, Message
from kbqa.database import Base
from kbqa.documents.models import Document, IndexJob
import kbqa.models # noqa: F401
async def test_sqlite_cascades_document_and_session(tmp_path) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
@event.listens_for(engine.sync_engine, "connect")
def enable_foreign_keys(connection, _record) -> None:
connection.execute("PRAGMA foreign_keys=ON")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
async with factory() as session:
document = Document(
id="doc",
filename="a.txt",
stored_path="a",
file_type="txt",
mime_type="text/plain",
file_size=1,
sha256="0" * 64,
status="pending",
chunk_count=0,
)
session.add_all(
[
document,
IndexJob(id="job", document_id="doc", status="queued", attempt_count=0),
ChatSession(id="session"),
]
)
await session.commit()
session.add(
Message(
id="message",
session_id="session",
role="user",
content="q",
status="completed",
order_index=0,
)
)
await session.commit()
await session.delete(document)
await session.delete(await session.get(ChatSession, "session"))
await session.commit()
assert await session.scalar(select(IndexJob).where(IndexJob.id == "job")) is None
assert await session.scalar(select(Message).where(Message.id == "message")) is None
await engine.dispose()
+233
View File
@@ -0,0 +1,233 @@
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("*"))
@@ -0,0 +1,13 @@
from pathlib import Path
import pytest
from kbqa.api.errors import AppError
from kbqa.indexing.loaders import load_document
def test_text_loader_rejects_non_utf8(tmp_path: Path) -> None:
path = tmp_path / "bad.txt"
path.write_bytes(b"\xff\xfe")
with pytest.raises(AppError, match="UTF-8"):
load_document(path, "txt")
+12
View File
@@ -0,0 +1,12 @@
from kbqa.indexing.loaders import LoadedPage
from kbqa.indexing.splitter import split_pages
def test_split_pages_is_deterministic() -> None:
document_id = "12345678-1234-5678-1234-567812345678"
pages = [LoadedPage(content="知识库" * 300, page_number=1)]
first = split_pages(document_id, pages, 100, 10)
second = split_pages(document_id, pages, 100, 10)
assert [chunk.id for chunk in first] == [chunk.id for chunk in second]
assert first[0].page_number == 1
assert [chunk.order_index for chunk in first] == list(range(len(first)))
+8
View File
@@ -0,0 +1,8 @@
from kbqa.chat.sse import encode_event, encode_ping
def test_sse_encodes_single_line_utf8_json() -> None:
assert encode_event("token", {"content": "知识"}) == (
'event: token\ndata: {"content":"知识"}\n\n'.encode()
)
assert encode_ping() == b": ping\n\n"