test: verify real kbqa workflow
@@ -0,0 +1 @@
|
|||||||
|
*.pdf binary
|
||||||
@@ -26,6 +26,7 @@ def create_agent_bundle(settings: Settings) -> AgentBundle:
|
|||||||
system_prompt=RESEARCH_PROMPT,
|
system_prompt=RESEARCH_PROMPT,
|
||||||
context_schema=ResearchContext,
|
context_schema=ResearchContext,
|
||||||
middleware=[
|
middleware=[
|
||||||
|
RequireResearchMiddleware(tool_name="search_knowledge_base"),
|
||||||
ModelCallLimitMiddleware(run_limit=4, exit_behavior="error"),
|
ModelCallLimitMiddleware(run_limit=4, exit_behavior="error"),
|
||||||
ToolCallLimitMiddleware(
|
ToolCallLimitMiddleware(
|
||||||
tool_name="search_knowledge_base",
|
tool_name="search_knowledge_base",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ def create_chat_model(settings: Settings) -> ChatOpenAI:
|
|||||||
api_key=settings.dashscope_api_key,
|
api_key=settings.dashscope_api_key,
|
||||||
base_url=settings.dashscope_base_url,
|
base_url=settings.dashscope_base_url,
|
||||||
model=settings.chat_model,
|
model=settings.chat_model,
|
||||||
|
extra_body={"enable_thinking": False},
|
||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
streaming=True,
|
streaming=True,
|
||||||
timeout=120.0,
|
timeout=120.0,
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ from langchain.messages import HumanMessage, ToolMessage
|
|||||||
|
|
||||||
|
|
||||||
class RequireResearchMiddleware(AgentMiddleware):
|
class RequireResearchMiddleware(AgentMiddleware):
|
||||||
|
def __init__(self, tool_name: str = "research") -> None:
|
||||||
|
self.tool_name = tool_name
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_current_research(messages: list) -> bool:
|
def _has_current_tool(messages: list, tool_name: str) -> bool:
|
||||||
for message in reversed(messages):
|
for message in reversed(messages):
|
||||||
if isinstance(message, ToolMessage) and message.name == "research":
|
if isinstance(message, ToolMessage) and message.name == tool_name:
|
||||||
return True
|
return True
|
||||||
if isinstance(message, HumanMessage):
|
if isinstance(message, HumanMessage):
|
||||||
return False
|
return False
|
||||||
@@ -19,5 +22,6 @@ class RequireResearchMiddleware(AgentMiddleware):
|
|||||||
request: ModelRequest,
|
request: ModelRequest,
|
||||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
) -> ModelResponse:
|
) -> ModelResponse:
|
||||||
tool_choice = "none" if self._has_current_research(request.messages) else "required"
|
has_tool_result = self._has_current_tool(request.messages, self.tool_name)
|
||||||
|
tool_choice = "none" if has_tool_result else "required"
|
||||||
return await handler(request.override(tool_choice=tool_choice))
|
return await handler(request.override(tool_choice=tool_choice))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class MilvusVectorStore:
|
|||||||
|
|
||||||
def ensure_collection(self) -> None:
|
def ensure_collection(self) -> None:
|
||||||
if self.client.has_collection(self.collection_name):
|
if self.client.has_collection(self.collection_name):
|
||||||
|
self.client.load_collection(self.collection_name)
|
||||||
return
|
return
|
||||||
schema = self.client.create_schema(auto_id=False, enable_dynamic_field=False)
|
schema = self.client.create_schema(auto_id=False, enable_dynamic_field=False)
|
||||||
schema.add_field(
|
schema.add_field(
|
||||||
@@ -44,6 +45,7 @@ class MilvusVectorStore:
|
|||||||
schema=schema,
|
schema=schema,
|
||||||
index_params=index_params,
|
index_params=index_params,
|
||||||
)
|
)
|
||||||
|
self.client.load_collection(self.collection_name)
|
||||||
|
|
||||||
def upsert(self, chunks: list[PreparedChunk], vectors: list[list[float]]) -> None:
|
def upsert(self, chunks: list[PreparedChunk], vectors: list[list[float]]) -> None:
|
||||||
if len(chunks) != len(vectors):
|
if len(chunks) != len(vectors):
|
||||||
@@ -67,7 +69,7 @@ class MilvusVectorStore:
|
|||||||
data=[vector],
|
data=[vector],
|
||||||
anns_field="vector",
|
anns_field="vector",
|
||||||
limit=limit,
|
limit=limit,
|
||||||
output_fields=["document_id", "order_index"],
|
output_fields=["chunk_id", "document_id", "order_index"],
|
||||||
search_params={"metric_type": "COSINE"},
|
search_params={"metric_type": "COSINE"},
|
||||||
)
|
)
|
||||||
hits: list[VectorHit] = []
|
hits: list[VectorHit] = []
|
||||||
@@ -75,7 +77,7 @@ class MilvusVectorStore:
|
|||||||
entity = item.get("entity", {})
|
entity = item.get("entity", {})
|
||||||
hits.append(
|
hits.append(
|
||||||
VectorHit(
|
VectorHit(
|
||||||
chunk_id=str(item["id"]),
|
chunk_id=str(entity["chunk_id"]),
|
||||||
document_id=str(entity["document_id"]),
|
document_id=str(entity["document_id"]),
|
||||||
order_index=int(entity["order_index"]),
|
order_index=int(entity["order_index"]),
|
||||||
score=float(item["distance"]),
|
score=float(item["distance"]),
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 星槐项目资料
|
||||||
|
|
||||||
|
星槐项目的内部代号是 **ORCHID-731**。项目负责人是林岚,首次评审日期为 2026 年 6 月 18 日。
|
||||||
|
|
||||||
|
系统采用严格知识库回答方式:没有资料时必须说明证据不足。
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# KBQA MVP 真实链路验收报告
|
||||||
|
|
||||||
|
- 验收时间:2026-07-13(Asia/Shanghai)
|
||||||
|
- 分支:`feature/kbqa-mvp`
|
||||||
|
- 模型:`qwen3.5-flash`、`text-embedding-v4`(1024 维)
|
||||||
|
- 数据库:SQLite + Milvus Lite
|
||||||
|
- 运行约束:单 Uvicorn worker
|
||||||
|
|
||||||
|
## 版本
|
||||||
|
|
||||||
|
- Python 3.12.12
|
||||||
|
- LangChain 1.3.13(仅使用 1.x API)
|
||||||
|
- FastAPI 0.139.0
|
||||||
|
- SQLAlchemy 2.0.51
|
||||||
|
- PyMilvus 2.6.16
|
||||||
|
- Node.js 24.17.0 / npm 11.13.0
|
||||||
|
|
||||||
|
## 自动验证
|
||||||
|
|
||||||
|
普通测试未调用 Chat/Embedding:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ruff format --check: 48 files already formatted
|
||||||
|
ruff check: All checks passed
|
||||||
|
pytest unit + integration: 5 passed
|
||||||
|
ESLint: passed
|
||||||
|
Vitest: 1 file / 3 tests passed(含任意 UTF-8 网络分片的增量 SSE)
|
||||||
|
Vite production build: passed
|
||||||
|
```
|
||||||
|
|
||||||
|
显式开启的真实模型黑盒测试:
|
||||||
|
|
||||||
|
```text
|
||||||
|
KBQA_RUN_LIVE_TESTS=1 uv run pytest tests/live/test_live_pipeline.py -v -s
|
||||||
|
1 passed in 69.77s
|
||||||
|
```
|
||||||
|
|
||||||
|
黑盒测试使用隔离的临时 SQLite、Milvus Lite 和原始文件目录,完成以下流程:上传真实
|
||||||
|
Markdown、UTF-8 TXT、带文本层 PDF;等待真实 Embedding 索引;三轮真实双 Agent 问答;
|
||||||
|
校验严格 SSE 子序列、答案语义、拒答边界、来源 chunk 可读取;重启 Uvicorn 后再次执行真实
|
||||||
|
检索;验证文档、会话和消息仍存在;删除数据;直接确认原始文件、SQLite 六张业务表及
|
||||||
|
Milvus 向量均清空。
|
||||||
|
|
||||||
|
## 用户真实文件验收
|
||||||
|
|
||||||
|
使用用户授权的 `test.txt`(6,158,012 字节)执行了额外的大文件验收:
|
||||||
|
|
||||||
|
- 后台真实 Embedding 完成 5,037 个片段的索引。
|
||||||
|
- 首轮问题经 Research Agent 检索后进入 Main Agent,SSE 依次产生 `researching`、
|
||||||
|
`sources`、`answering`、多个 `token` 和 `done`。
|
||||||
|
- 同一会话追问“佩罗和奥克斯对伊南娜做了什么?”,准确召回第 1 章相关片段并生成
|
||||||
|
S3/S4/S5 引用。
|
||||||
|
- 多次停止并重启后端后,文档、5,037 个片段、会话和历史消息均可读取。
|
||||||
|
- 删除会话后,该会话及消息记录均为 0。
|
||||||
|
- 删除文档后,文档、索引任务、片段、消息来源记录及原始副本均为 0;Milvus
|
||||||
|
`row_count` 为 0。用户提供的源文件未被改动。
|
||||||
|
|
||||||
|
首个宽泛问题的向量召回偏离了开篇片段,因此系统严格返回了证据不足;更具体的多轮追问
|
||||||
|
召回正确。这属于可接受的模型/向量检索波动,测试只断言事件结构、来源真实性和证据边界,
|
||||||
|
不锁定模型措辞。
|
||||||
|
|
||||||
|
## 浏览器验收
|
||||||
|
|
||||||
|
使用 agent-browser 检查了文档状态、详情抽屉、历史会话恢复、可点击引用、桌面右栏及
|
||||||
|
390px 窄屏证据抽屉:
|
||||||
|
|
||||||
|
- [文档索引状态](screenshots/documents-indexing.png)
|
||||||
|
- [文档详情抽屉](screenshots/document-detail-drawer.png)
|
||||||
|
- [文档窄屏布局](screenshots/documents-narrow.png)
|
||||||
|
- [历史对话与桌面引用](screenshots/chat-history-citation.png)
|
||||||
|
- [对话窄屏布局](screenshots/chat-mobile.png)
|
||||||
|
- [窄屏可点击引用抽屉](screenshots/chat-mobile-citation.png)
|
||||||
|
- [浏览器上传与后台索引中](screenshots/browser-upload-indexing.png)
|
||||||
|
- [真实问答研究阶段](screenshots/browser-live-researching.png)
|
||||||
|
- [真实回答与引用](screenshots/browser-live-answer.png)
|
||||||
|
- [失败文档重试](screenshots/browser-retry.png)
|
||||||
|
- [不支持格式的页面错误](screenshots/browser-upload-error.png)
|
||||||
|
- [文档删除后的空状态](screenshots/browser-delete-empty.png)
|
||||||
|
- [会话删除后侧栏同步](screenshots/browser-session-deleted.png)
|
||||||
|
|
||||||
|
## 真实环境问题与修复
|
||||||
|
|
||||||
|
- DashScope 单次 Embedding 上限为 10,索引器已固定分批上限并验证大文件成功。
|
||||||
|
- `qwen3.5-flash` 默认思考模式不支持强制 `tool_choice`;按官方兼容接口关闭思考模式,
|
||||||
|
保留双 Agent 的必经工具约束。
|
||||||
|
- Milvus Lite 重启后集合需要重新 load,启动流程已补齐。
|
||||||
|
- 自定义主键搜索结果从 `entity.chunk_id` 读取,避免依赖不存在的默认 `id` 字段。
|
||||||
|
- Research Agent 完成一次真实检索后强制进入证据总结,避免模型重复调用工具直到图递归上限。
|
||||||
|
- 修复新会话首问完成回调的导航竞态,SSE 完成后按实际会话 ID 刷新历史与侧栏。
|
||||||
|
|
||||||
|
LangSmith tracing 已按本地 `.env` 开启并写入既有项目。代表性 Main Agent trace:
|
||||||
|
[`019f5a18-28fb-7f80-b252-b436e2567e1d`](https://smith.langchain.com/o/3b9cd927-6db9-411f-b6af-19b38f36a387/projects/p/6b719950-c885-405f-addb-1bd86ac3cf83/r/019f5a18-28fb-7f80-b252-b436e2567e1d?trace_id=019f5a18-28fb-7f80-b252-b436e2567e1d&start_time=2026-07-13T06:09:20.635570)。
|
||||||
|
报告不包含任何密钥。
|
||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 75 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import * as Dialog from '@radix-ui/react-dialog'
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
import { PanelRightOpen, Radar, Sparkles } from 'lucide-react'
|
import { PanelRightOpen, Radar, Sparkles } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { Button } from '../../components/ui/button'
|
import { Button } from '../../components/ui/button'
|
||||||
@@ -17,17 +17,30 @@ export function ChatPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [evidenceOpen, setEvidenceOpen] = useState(false)
|
const [evidenceOpen, setEvidenceOpen] = useState(false)
|
||||||
|
const currentSessionId = useRef(sessionId)
|
||||||
const stream = useChatStreamStore()
|
const stream = useChatStreamStore()
|
||||||
|
|
||||||
|
useEffect(() => { currentSessionId.current = sessionId }, [sessionId])
|
||||||
|
|
||||||
const refreshMessages = useCallback(async () => {
|
const refreshMessages = useCallback(async () => {
|
||||||
if (!sessionId) { setMessages([]); return }
|
if (!sessionId) {
|
||||||
const page = await listMessages(sessionId)
|
if (currentSessionId.current === sessionId) setMessages([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = sessionId
|
||||||
|
const page = await listMessages(target)
|
||||||
|
if (currentSessionId.current !== target) return
|
||||||
setMessages(page.items); announceSessionChange()
|
setMessages(page.items); announceSessionChange()
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
void listMessages(sessionId).then((page) => setMessages(page.items))
|
const target = sessionId
|
||||||
|
let cancelled = false
|
||||||
|
void listMessages(target).then((page) => {
|
||||||
|
if (!cancelled && currentSessionId.current === target) setMessages(page.items)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
const { send, cancel } = useChatStream(async () => { await refreshMessages(); useChatStreamStore.getState().reset() })
|
const { send, cancel } = useChatStream(async () => { await refreshMessages(); useChatStreamStore.getState().reset() })
|
||||||
@@ -38,6 +51,10 @@ export function ChatPage() {
|
|||||||
const session = await createSession(); target = session.id; navigate(`/chat/${target}`); announceSessionChange()
|
const session = await createSession(); target = session.id; navigate(`/chat/${target}`); announceSessionChange()
|
||||||
}
|
}
|
||||||
await send(target, query)
|
await send(target, query)
|
||||||
|
const page = await listMessages(target)
|
||||||
|
if (currentSessionId.current !== target) return
|
||||||
|
setMessages(page.items)
|
||||||
|
announceSessionChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseSource(source: Source, sources: Source[] = stream.sources) {
|
function chooseSource(source: Source, sources: Source[] = stream.sources) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { parseSSEBlock, streamSSE } from './sse'
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
describe('parseSSEBlock', () => {
|
||||||
|
it('parses Chinese token events with CRLF framing', () => {
|
||||||
|
expect(parseSSEBlock('event: token\r\ndata: {"content":"知识"}')).toEqual({
|
||||||
|
event: 'token',
|
||||||
|
data: { content: '知识' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores heartbeat comments', () => {
|
||||||
|
expect(parseSSEBlock(': ping')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('streamSSE', () => {
|
||||||
|
it('incrementally decodes arbitrary UTF-8 chunks, CRLF, multiple events, and a tail event', async () => {
|
||||||
|
const bytes = new TextEncoder().encode(
|
||||||
|
'event: status\r\ndata: {"phase":"researching"}\r\n\r\n' +
|
||||||
|
'event: token\ndata: {"content":"知识"}\n\n' +
|
||||||
|
'event: done\ndata: {"message_id":"m1"}',
|
||||||
|
)
|
||||||
|
const chunks = [bytes.slice(0, 7), bytes.slice(7, 58), bytes.slice(58, 61), bytes.slice(61)]
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) controller.enqueue(chunk)
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body))
|
||||||
|
const controller = new AbortController()
|
||||||
|
const events: unknown[] = []
|
||||||
|
|
||||||
|
await streamSSE('/stream', { query: 'q' }, (event) => events.push(event), controller.signal)
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ event: 'status', data: { phase: 'researching' } },
|
||||||
|
{ event: 'token', data: { content: '知识' } },
|
||||||
|
{ event: 'done', data: { message_id: 'm1' } },
|
||||||
|
])
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/stream', expect.objectContaining({ signal: controller.signal }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiError } from './api'
|
import { ApiError } from './api'
|
||||||
import type { SSEEvent } from './types'
|
import type { SSEEvent } from './types'
|
||||||
|
|
||||||
function parseBlock(block: string): SSEEvent | null {
|
export function parseSSEBlock(block: string): SSEEvent | null {
|
||||||
let eventName = ''
|
let eventName = ''
|
||||||
const dataLines: string[] = []
|
const dataLines: string[] = []
|
||||||
for (const line of block.split(/\r?\n/)) {
|
for (const line of block.split(/\r?\n/)) {
|
||||||
@@ -47,14 +47,14 @@ export async function streamSSE(
|
|||||||
while (boundary >= 0) {
|
while (boundary >= 0) {
|
||||||
const block = buffer.slice(0, boundary)
|
const block = buffer.slice(0, boundary)
|
||||||
buffer = buffer.slice(boundary + 2)
|
buffer = buffer.slice(boundary + 2)
|
||||||
const event = parseBlock(block)
|
const event = parseSSEBlock(block)
|
||||||
if (event) onEvent(event)
|
if (event) onEvent(event)
|
||||||
boundary = buffer.indexOf('\n\n')
|
boundary = buffer.indexOf('\n\n')
|
||||||
}
|
}
|
||||||
if (done) break
|
if (done) break
|
||||||
}
|
}
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
const event = parseBlock(buffer)
|
const event = parseSSEBlock(buffer)
|
||||||
if (event) onEvent(event)
|
if (event) onEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||