test: verify real kbqa workflow
This commit is contained in:
@@ -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()
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# 星槐项目资料
|
||||
|
||||
星槐项目的内部代号是 **ORCHID-731**。项目负责人是林岚,首次评审日期为 2026 年 6 月 18 日。
|
||||
|
||||
系统采用严格知识库回答方式:没有资料时必须说明证据不足。
|
||||
Vendored
+89
@@ -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
|
||||
Vendored
+2
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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)))
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user