diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d72fd52 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.pdf binary diff --git a/backend/src/kbqa/agents/factory.py b/backend/src/kbqa/agents/factory.py index b0cdf48..801eb3c 100644 --- a/backend/src/kbqa/agents/factory.py +++ b/backend/src/kbqa/agents/factory.py @@ -26,6 +26,7 @@ def create_agent_bundle(settings: Settings) -> AgentBundle: system_prompt=RESEARCH_PROMPT, context_schema=ResearchContext, middleware=[ + RequireResearchMiddleware(tool_name="search_knowledge_base"), ModelCallLimitMiddleware(run_limit=4, exit_behavior="error"), ToolCallLimitMiddleware( tool_name="search_knowledge_base", diff --git a/backend/src/kbqa/agents/llm.py b/backend/src/kbqa/agents/llm.py index 629d566..e127840 100644 --- a/backend/src/kbqa/agents/llm.py +++ b/backend/src/kbqa/agents/llm.py @@ -9,6 +9,7 @@ def create_chat_model(settings: Settings) -> ChatOpenAI: api_key=settings.dashscope_api_key, base_url=settings.dashscope_base_url, model=settings.chat_model, + extra_body={"enable_thinking": False}, temperature=0.2, streaming=True, timeout=120.0, diff --git a/backend/src/kbqa/agents/middleware.py b/backend/src/kbqa/agents/middleware.py index 7fec705..47efe87 100644 --- a/backend/src/kbqa/agents/middleware.py +++ b/backend/src/kbqa/agents/middleware.py @@ -5,10 +5,13 @@ from langchain.messages import HumanMessage, ToolMessage class RequireResearchMiddleware(AgentMiddleware): + def __init__(self, tool_name: str = "research") -> None: + self.tool_name = tool_name + @staticmethod - def _has_current_research(messages: list) -> bool: + def _has_current_tool(messages: list, tool_name: str) -> bool: for message in reversed(messages): - if isinstance(message, ToolMessage) and message.name == "research": + if isinstance(message, ToolMessage) and message.name == tool_name: return True if isinstance(message, HumanMessage): return False @@ -19,5 +22,6 @@ class RequireResearchMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[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)) diff --git a/backend/src/kbqa/rag/vector_store.py b/backend/src/kbqa/rag/vector_store.py index e497648..9e7cddf 100644 --- a/backend/src/kbqa/rag/vector_store.py +++ b/backend/src/kbqa/rag/vector_store.py @@ -14,6 +14,7 @@ class MilvusVectorStore: def ensure_collection(self) -> None: if self.client.has_collection(self.collection_name): + self.client.load_collection(self.collection_name) return schema = self.client.create_schema(auto_id=False, enable_dynamic_field=False) schema.add_field( @@ -44,6 +45,7 @@ class MilvusVectorStore: schema=schema, index_params=index_params, ) + self.client.load_collection(self.collection_name) def upsert(self, chunks: list[PreparedChunk], vectors: list[list[float]]) -> None: if len(chunks) != len(vectors): @@ -67,7 +69,7 @@ class MilvusVectorStore: data=[vector], anns_field="vector", limit=limit, - output_fields=["document_id", "order_index"], + output_fields=["chunk_id", "document_id", "order_index"], search_params={"metric_type": "COSINE"}, ) hits: list[VectorHit] = [] @@ -75,7 +77,7 @@ class MilvusVectorStore: entity = item.get("entity", {}) hits.append( VectorHit( - chunk_id=str(item["id"]), + chunk_id=str(entity["chunk_id"]), document_id=str(entity["document_id"]), order_index=int(entity["order_index"]), score=float(item["distance"]), diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..c506aa0 --- /dev/null +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/fixtures/knowledge.md b/backend/tests/fixtures/knowledge.md new file mode 100644 index 0000000..f88828b --- /dev/null +++ b/backend/tests/fixtures/knowledge.md @@ -0,0 +1,5 @@ +# 星槐项目资料 + +星槐项目的内部代号是 **ORCHID-731**。项目负责人是林岚,首次评审日期为 2026 年 6 月 18 日。 + +系统采用严格知识库回答方式:没有资料时必须说明证据不足。 diff --git a/backend/tests/fixtures/knowledge.pdf b/backend/tests/fixtures/knowledge.pdf new file mode 100644 index 0000000..ee62cc9 --- /dev/null +++ b/backend/tests/fixtures/knowledge.pdf @@ -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^.Q?/6nV[TF=DbJ_lha*>qb8)_]&^pQdj,t'O0XuO?Pendstream +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 diff --git a/backend/tests/fixtures/knowledge.txt b/backend/tests/fixtures/knowledge.txt new file mode 100644 index 0000000..813df2b --- /dev/null +++ b/backend/tests/fixtures/knowledge.txt @@ -0,0 +1,2 @@ +青屿实验的校准周期是每 14 天一次。校准负责人是周禾。 +如果温度超过 32 摄氏度,应暂停实验并记录环境状态。 diff --git a/backend/tests/integration/test_session_api.py b/backend/tests/integration/test_session_api.py new file mode 100644 index 0000000..25fb964 --- /dev/null +++ b/backend/tests/integration/test_session_api.py @@ -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 diff --git a/backend/tests/integration/test_sqlite_cascades.py b/backend/tests/integration/test_sqlite_cascades.py new file mode 100644 index 0000000..a735135 --- /dev/null +++ b/backend/tests/integration/test_sqlite_cascades.py @@ -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() diff --git a/backend/tests/live/test_live_pipeline.py b/backend/tests/live/test_live_pipeline.py new file mode 100644 index 0000000..1d4396e --- /dev/null +++ b/backend/tests/live/test_live_pipeline.py @@ -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("*")) diff --git a/backend/tests/unit/test_file_validation.py b/backend/tests/unit/test_file_validation.py new file mode 100644 index 0000000..3122f58 --- /dev/null +++ b/backend/tests/unit/test_file_validation.py @@ -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") diff --git a/backend/tests/unit/test_splitter.py b/backend/tests/unit/test_splitter.py new file mode 100644 index 0000000..3f91659 --- /dev/null +++ b/backend/tests/unit/test_splitter.py @@ -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))) diff --git a/backend/tests/unit/test_sse.py b/backend/tests/unit/test_sse.py new file mode 100644 index 0000000..13ec23d --- /dev/null +++ b/backend/tests/unit/test_sse.py @@ -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" diff --git a/docs/testing/live-test-report.md b/docs/testing/live-test-report.md new file mode 100644 index 0000000..33b1936 --- /dev/null +++ b/docs/testing/live-test-report.md @@ -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)。 +报告不包含任何密钥。 diff --git a/docs/testing/screenshots/browser-delete-empty.png b/docs/testing/screenshots/browser-delete-empty.png new file mode 100644 index 0000000..55e6cbb Binary files /dev/null and b/docs/testing/screenshots/browser-delete-empty.png differ diff --git a/docs/testing/screenshots/browser-live-answer.png b/docs/testing/screenshots/browser-live-answer.png new file mode 100644 index 0000000..d4bb75c Binary files /dev/null and b/docs/testing/screenshots/browser-live-answer.png differ diff --git a/docs/testing/screenshots/browser-live-researching.png b/docs/testing/screenshots/browser-live-researching.png new file mode 100644 index 0000000..3284603 Binary files /dev/null and b/docs/testing/screenshots/browser-live-researching.png differ diff --git a/docs/testing/screenshots/browser-retry.png b/docs/testing/screenshots/browser-retry.png new file mode 100644 index 0000000..81c08ee Binary files /dev/null and b/docs/testing/screenshots/browser-retry.png differ diff --git a/docs/testing/screenshots/browser-session-deleted.png b/docs/testing/screenshots/browser-session-deleted.png new file mode 100644 index 0000000..2ba5ba7 Binary files /dev/null and b/docs/testing/screenshots/browser-session-deleted.png differ diff --git a/docs/testing/screenshots/browser-upload-error.png b/docs/testing/screenshots/browser-upload-error.png new file mode 100644 index 0000000..eccd52b Binary files /dev/null and b/docs/testing/screenshots/browser-upload-error.png differ diff --git a/docs/testing/screenshots/browser-upload-indexing.png b/docs/testing/screenshots/browser-upload-indexing.png new file mode 100644 index 0000000..c89cdc8 Binary files /dev/null and b/docs/testing/screenshots/browser-upload-indexing.png differ diff --git a/docs/testing/screenshots/chat-history-citation.png b/docs/testing/screenshots/chat-history-citation.png new file mode 100644 index 0000000..1fa1951 Binary files /dev/null and b/docs/testing/screenshots/chat-history-citation.png differ diff --git a/docs/testing/screenshots/chat-mobile-citation.png b/docs/testing/screenshots/chat-mobile-citation.png new file mode 100644 index 0000000..fdc0f0a Binary files /dev/null and b/docs/testing/screenshots/chat-mobile-citation.png differ diff --git a/docs/testing/screenshots/chat-mobile.png b/docs/testing/screenshots/chat-mobile.png new file mode 100644 index 0000000..b9065c5 Binary files /dev/null and b/docs/testing/screenshots/chat-mobile.png differ diff --git a/docs/testing/screenshots/document-detail-drawer.png b/docs/testing/screenshots/document-detail-drawer.png new file mode 100644 index 0000000..197d53e Binary files /dev/null and b/docs/testing/screenshots/document-detail-drawer.png differ diff --git a/docs/testing/screenshots/documents-indexing.png b/docs/testing/screenshots/documents-indexing.png new file mode 100644 index 0000000..501e02d Binary files /dev/null and b/docs/testing/screenshots/documents-indexing.png differ diff --git a/docs/testing/screenshots/documents-narrow.png b/docs/testing/screenshots/documents-narrow.png new file mode 100644 index 0000000..76f0551 Binary files /dev/null and b/docs/testing/screenshots/documents-narrow.png differ diff --git a/web/src/features/chat/ChatPage.tsx b/web/src/features/chat/ChatPage.tsx index 05e6647..fac0a95 100644 --- a/web/src/features/chat/ChatPage.tsx +++ b/web/src/features/chat/ChatPage.tsx @@ -1,6 +1,6 @@ import * as Dialog from '@radix-ui/react-dialog' 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 { Button } from '../../components/ui/button' @@ -17,17 +17,30 @@ export function ChatPage() { const navigate = useNavigate() const [messages, setMessages] = useState([]) const [evidenceOpen, setEvidenceOpen] = useState(false) + const currentSessionId = useRef(sessionId) const stream = useChatStreamStore() + useEffect(() => { currentSessionId.current = sessionId }, [sessionId]) + const refreshMessages = useCallback(async () => { - if (!sessionId) { setMessages([]); return } - const page = await listMessages(sessionId) + if (!sessionId) { + if (currentSessionId.current === sessionId) setMessages([]) + return + } + const target = sessionId + const page = await listMessages(target) + if (currentSessionId.current !== target) return setMessages(page.items); announceSessionChange() }, [sessionId]) useEffect(() => { 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]) 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() } 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) { diff --git a/web/src/lib/sse.test.ts b/web/src/lib/sse.test.ts new file mode 100644 index 0000000..09033f0 --- /dev/null +++ b/web/src/lib/sse.test.ts @@ -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({ + 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 })) + }) +}) diff --git a/web/src/lib/sse.ts b/web/src/lib/sse.ts index 2109d66..189be0a 100644 --- a/web/src/lib/sse.ts +++ b/web/src/lib/sse.ts @@ -1,7 +1,7 @@ import { ApiError } from './api' import type { SSEEvent } from './types' -function parseBlock(block: string): SSEEvent | null { +export function parseSSEBlock(block: string): SSEEvent | null { let eventName = '' const dataLines: string[] = [] for (const line of block.split(/\r?\n/)) { @@ -47,14 +47,14 @@ export async function streamSSE( while (boundary >= 0) { const block = buffer.slice(0, boundary) buffer = buffer.slice(boundary + 2) - const event = parseBlock(block) + const event = parseSSEBlock(block) if (event) onEvent(event) boundary = buffer.indexOf('\n\n') } if (done) break } if (buffer.trim()) { - const event = parseBlock(buffer) + const event = parseSSEBlock(buffer) if (event) onEvent(event) } }