Files
kbqa-system/docs/superpowers/plans/2026-07-13-kbqa-mvp-implementation.md
T
2026-07-13 11:18:02 +08:00

767 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# KBQA MVP Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the approved local single-user dual-agent knowledge-base application from document upload through real-model RAG, SSE chat, persistent history, and browser-verified cleanup/recovery.
**Architecture:** A React three-column workbench talks to one FastAPI process over REST and POST SSE. SQLite is the business source of truth, a durable SQLite-backed single-consumer queue drives indexing, Milvus Lite stores vectors, and LangChain 1.x `create_agent` composes a main agent with a research subagent exposed as a tool.
**Tech Stack:** Python 3.12, uv, FastAPI, SQLAlchemy 2.x async, aiosqlite, Alembic, LangChain 1.x, langchain-openai, PyMilvus/Milvus Lite, LangSmith, React, TypeScript, Vite, React Router, zustand, Tailwind CSS, shadcn/ui, npm, pytest, agent-browser.
## Global Constraints
- SQLite is the only relational database; do not add PostgreSQL, psycopg, JSONB, Redis, Celery, or another queue service.
- Milvus Lite uses one local file and stores no duplicate chunk正文数据.
- Use `langchain.agents.create_agent`; do not use `langgraph.prebuilt.create_react_agent` or LangChain 0.3.x APIs.
- Both agents use `qwen3.5-flash`; embeddings use `text-embedding-v4` with exactly 1024 dimensions.
- Strict mode: every knowledge answer researches first; empty evidence returns a deterministic insufficiency response.
- Store user-visible sessions/messages/sources in SQLite; internal agent/tool messages stay in LangSmith.
- Index asynchronously with a durable SQLite job; restore queued/running/deleting work at startup.
- Run exactly one Uvicorn worker.
- Support only UTF-8 Markdown/TXT and text-extractable PDF; do not add OCR.
- Do not use TDD. Implement first, then smoke-check; add automated tests only in Task 12.
- Do not mock Chat or Embedding models. Model-path tests belong to the explicitly enabled live suite.
- Use `uv` for Python and `npm` for Node.js.
- Use the user-authorized credentials from ignored `1.md` only for local real calls; map them into ignored `.env`, never print/commit them, and recommend rotation after acceptance.
- Use the ignored root `test.txt` as the user-provided large real knowledge file through `KBQA_LIVE_TEST_FILE`.
- Test the frontend with `agent-browser` against real Vite and FastAPI processes.
---
## Planned File Structure
```text
.
├── .python-version
├── .env.example
├── README.md
├── backend/
│ ├── pyproject.toml
│ ├── uv.lock
│ ├── alembic.ini
│ ├── alembic/{env.py,versions/0001_initial.py}
│ ├── src/kbqa/
│ │ ├── {main,config,database,lifecycle,models}.py
│ │ ├── api/{errors,middleware,router}.py
│ │ ├── health/routes.py
│ │ ├── documents/{models,schemas,repository,service,routes}.py
│ │ ├── indexing/{loaders,splitter,worker}.py
│ │ ├── rag/{types,embeddings,vector_store,retriever}.py
│ │ ├── chat/{models,schemas,repository,sse,service,routes}.py
│ │ └── agents/{context,llm,prompts,tools,middleware,factory}.py
│ └── tests/{fixtures,unit,integration,live}/
└── web/
├── package.json
├── vite.config.ts
├── src/{main.tsx,index.css}
├── src/app/{router,AppLayout}.tsx
├── src/lib/{api,types,sse}.ts
├── src/components/ui/
└── src/features/{documents,chat}/
```
---
### Task 1: Secure bootstrap and dependency lock
**Files:**
- Create: `.python-version`, `.env.example`, `README.md`
- Create: `backend/pyproject.toml`, `backend/src/kbqa/__init__.py`
- Modify: `.gitignore`
- Keep local and ignored: `1.md`, `test.txt`
**Interfaces:**
- Produces: Python 3.12 environment and environment-variable contract used by every later task.
- [ ] **Step 1: Prepare authorized local credentials without exposing them**
Confirm `git check-ignore -v .env 1.md test.txt` reports all paths ignored. Copy `1.md` to mode-600 `.env` without printing it, rename `DASHSCOPE_API_KEY`/`DASHSCOPE_BASE_URL`/`MODEL` keys to their `KBQA_` application names, retain standard `LANGSMITH_` names, and add `KBQA_LIVE_TEST_FILE` pointing to the absolute root `test.txt`. Never show the resulting file in tool output.
- [ ] **Step 2: Pin Python and define dependencies**
Create `.python-version` containing `3.12`. Create `backend/pyproject.toml` with Python `>=3.12,<3.13` and these bounded dependencies: `fastapi`, `uvicorn[standard]`, `sqlalchemy[asyncio]>=2,<3`, `aiosqlite`, `alembic`, `pydantic-settings`, `python-multipart`, `pypdf`, `langchain>=1,<2`, `langchain-openai>=1,<2`, `langchain-text-splitters>=1,<2`, and `pymilvus[milvus-lite]>=2.6,<3`. Add dev dependencies `httpx`, `pytest`, `pytest-asyncio`, `pytest-timeout`, and `ruff`. Configure Hatchling for `src/kbqa`, pytest marker `live`, Ruff Python 3.12 and line length 100.
- [ ] **Step 3: Define safe configuration examples**
Create `.env.example` with every variable from Spec section 16, including empty `KBQA_LIVE_TEST_FILE`. Leave keys empty; use `KBQA_` for application config and standard `LANGSMITH_` names for LangSmith.
- [ ] **Step 4: Lock and synchronize**
```bash
uv lock --project backend
uv sync --project backend --all-groups
uv run --project backend python -c "import langchain; print(langchain.__version__)"
```
Expected: dependency resolution succeeds and LangChain reports `1.x`.
- [ ] **Step 5: Document local commands**
`README.md` must cover setup, migration, backend/frontend startup, ordinary/live checks, credential rotation, single-worker constraint, and state that the approved Spec is authoritative while `architecture.md` is reference-only.
- [ ] **Step 6: Commit**
```bash
git add .python-version .env.example README.md .gitignore backend
git commit -m "chore: bootstrap kbqa workspace"
```
---
### Task 2: FastAPI foundation, settings, database, and health
**Files:**
- Create: `backend/src/kbqa/config.py`, `database.py`, `lifecycle.py`, `main.py`
- Create: `backend/src/kbqa/api/errors.py`, `middleware.py`, `router.py`
- Create: `backend/src/kbqa/health/routes.py`
**Interfaces:**
- Produces: `Settings`, `get_settings()`, `Database`, `get_session()`, `AppError`, `api_router`, `create_app()`.
- [ ] **Step 1: Implement typed settings**
Define all Spec defaults with `SettingsConfigDict(env_file=".env", env_prefix="KBQA_", extra="ignore")` and:
```python
@lru_cache
def get_settings() -> Settings: ...
def validate_live_settings(settings: Settings) -> None: ...
```
Only model paths call `validate_live_settings`; app import and `/health` must not require credentials.
- [ ] **Step 2: Implement SQLite async infrastructure**
Create `Base(DeclarativeBase)` and a `Database` holding `AsyncEngine` plus `async_sessionmaker[AsyncSession]`. A SQLAlchemy connect listener executes `PRAGMA foreign_keys=ON`, `PRAGMA journal_mode=WAL`, and `PRAGMA busy_timeout=5000`.
```python
class Database:
def __init__(self, url: str) -> None: ...
async def dispose(self) -> None: ...
async def get_session(request: Request) -> AsyncIterator[AsyncSession]: ...
```
- [ ] **Step 3: Implement safe errors and request IDs**
Define `AppError(code, message, status_code, retryable=False)`. Handle it, `RequestValidationError`, and unexpected exceptions using the approved JSON envelope. Middleware accepts/generates `X-Request-ID`, stores it on `request.state`, and returns it in headers.
- [ ] **Step 4: Implement app factory and health**
`create_app()` builds resources in lifespan, configures only explicit localhost CORS origins, includes `/api/v1`, and exposes `/health`. Before Task 4 exists, health may use a minimal `MilvusClient(uri).list_collections()` probe; Task 4 replaces that probe with `MilvusVectorStore.ping()`. It never calls Chat/Embedding.
- [ ] **Step 5: Smoke verify**
```bash
uv run --project backend ruff check backend/src
uv run --project backend uvicorn kbqa.main:app --app-dir backend/src --port 8000
curl http://127.0.0.1:8000/health
```
Expected: lint passes and health reports `app`, `sqlite`, and `milvus` states.
- [ ] **Step 6: Commit**
```bash
git add backend/src/kbqa
git commit -m "feat: add backend application foundation"
```
---
### Task 3: SQLite models, repositories, and initial migration
**Files:**
- Create: `backend/src/kbqa/documents/models.py`, `repository.py`
- Create: `backend/src/kbqa/chat/models.py`, `repository.py`
- Create: `backend/src/kbqa/models.py`
- Create: `backend/alembic.ini`, `backend/alembic/env.py`, `backend/alembic/versions/0001_initial.py`
**Interfaces:**
- Produces ORM classes `Document`, `IndexJob`, `Chunk`, `ChatSession`, `Message`, `MessageSource` and their repositories.
- [ ] **Step 1: Implement exact ORM schema**
Use SQLAlchemy 2.x `Mapped[...]`. Reproduce every field, check/unique constraint, cascade, and index from Spec sections 6.16.6. UUID strings are generated in Python; timestamps use one UTC helper.
- [ ] **Step 2: Implement document repository**
Define the input contract in `documents/repository.py`:
```python
@dataclass(frozen=True, slots=True)
class StoredUpload:
document_id: str
filename: str
stored_path: str
file_type: str
mime_type: str
file_size: int
sha256: str
```
```python
class DocumentRepository:
async def create_pending(self, upload: StoredUpload) -> Document: ...
async def page(self, status: str | None, offset: int, limit: int) -> tuple[list[Document], int]: ...
async def get(self, document_id: str) -> Document | None: ...
async def page_chunks(self, document_id: str, offset: int, limit: int) -> tuple[list[Chunk], int]: ...
async def claim_job(self, job_id: str) -> IndexJob | None: ...
async def mark_indexed(self, document_id: str, chunk_count: int) -> None: ...
async def mark_failed(self, document_id: str, code: str, message: str) -> None: ...
async def requeue_unfinished(self) -> list[str]: ...
```
`claim_job` atomically changes queued→running and pending→indexing.
- [ ] **Step 3: Implement chat repository**
Define the persistence-only source input in `chat/repository.py`:
```python
@dataclass(frozen=True, slots=True)
class MessageSourceInput:
document_id: str
chunk_id: str
citation_label: str
score: float
```
```python
class ChatRepository:
async def create_session(self, title: str | None) -> ChatSession: ...
async def page_sessions(self, offset: int, limit: int) -> tuple[list[ChatSession], int]: ...
async def load_recent_messages(self, session_id: str, limit: int) -> list[Message]: ...
async def page_messages(self, session_id: str, offset: int, limit: int) -> tuple[list[Message], int]: ...
async def begin_turn(self, session_id: str, query: str) -> tuple[Message, Message]: ...
async def complete_answer(self, message_id: str, content: str, sources: list[MessageSourceInput]) -> None: ...
async def fail_answer(self, message_id: str, error_code: str) -> None: ...
async def delete_session(self, session_id: str) -> bool: ...
```
`begin_turn` allocates consecutive order indexes and sets first-question title in one transaction.
- [ ] **Step 4: Configure Alembic**
Use async migration setup, import `kbqa.models`, and write `0001_initial.py` explicitly so names/cascades are reviewable.
- [ ] **Step 5: Verify migration**
```bash
uv run --project backend alembic -c backend/alembic.ini upgrade head
uv run --project backend python -c "import sqlite3; db=sqlite3.connect('data/kbqa.sqlite3'); print(sorted(r[0] for r in db.execute(\"select name from sqlite_master where type='table'\")))"
```
Expected: six application tables plus `alembic_version`.
- [ ] **Step 6: Commit**
```bash
git add backend/alembic.ini backend/alembic backend/src/kbqa
git commit -m "feat: add sqlite persistence model"
```
---
### Task 4: RAG storage and durable document indexing
**Files:**
- Create: `backend/src/kbqa/rag/{types,embeddings,vector_store,retriever}.py`
- Create: `backend/src/kbqa/indexing/{loaders,splitter,worker}.py`
- Create: `backend/src/kbqa/documents/service.py`
- Modify: `backend/src/kbqa/lifecycle.py`
**Interfaces:**
- Produces: `SourceCandidate`, `PreparedChunk`, `EmbeddingProvider`, `MilvusVectorStore`, `KnowledgeRetriever`, `DocumentService`, `IndexWorker`.
- [ ] **Step 1: Define RAG contracts**
```python
@dataclass(frozen=True, slots=True)
class SourceCandidate:
chunk_id: str
document_id: str
filename: str
order_index: int
page_number: int | None
content: str
score: float
@dataclass(frozen=True, slots=True)
class PreparedChunk:
id: str
document_id: str
order_index: int
page_number: int | None
content: str
@dataclass(frozen=True, slots=True)
class VectorHit:
chunk_id: str
document_id: str
order_index: int
score: float
```
- [ ] **Step 2: Implement loaders and splitter**
Validate `%PDF-`, UTF-8 text, and non-empty extracted PDF text. Preserve page numbers. Split with `RecursiveCharacterTextSplitter(chunk_size, chunk_overlap, length_function=len)` and generate deterministic UUIDv5 IDs from `document_id:order_index`.
- [ ] **Step 3: Implement real embedding provider**
Wrap `OpenAIEmbeddings` with DashScope base URL/key, `text-embedding-v4`, and `dimensions=1024`:
```python
class EmbeddingProvider:
async def embed_documents(self, texts: list[str]) -> list[list[float]]: ...
async def embed_query(self, text: str) -> list[float]: ...
```
Validate every returned vector has length 1024 and map upstream failures to stable `AppError` codes.
- [ ] **Step 4: Implement Milvus Lite wrapper**
Create `kbqa_chunks` with VARCHAR `chunk_id` primary key, VARCHAR `document_id`, INT64 `order_index`, FLOAT_VECTOR `vector`, AUTOINDEX, and COSINE.
```python
class MilvusVectorStore:
def ensure_collection(self) -> None: ...
def upsert(self, chunks: list[PreparedChunk], vectors: list[list[float]]) -> None: ...
def search(self, vector: list[float], limit: int) -> list[VectorHit]: ...
def delete_document(self, document_id: str) -> None: ...
def ping(self) -> None: ...
```
Run blocking PyMilvus calls with `asyncio.to_thread` at service boundaries.
- [ ] **Step 5: Implement retriever**
`KnowledgeRetriever.retrieve(query, top_k)` embeds the query, requests extra Milvus candidates, batch-loads SQLite chunks joined to indexed documents, preserves ranking, and returns at most `top_k` sources.
- [ ] **Step 6: Implement durable worker**
`IndexWorker.start()` requeues unfinished jobs and starts one task. `_process()` claims, clears old chunks/vectors, loads/splits, calls real embeddings, writes chunks, upserts vectors, and marks success. Catch ordinary exceptions to mark failed; re-raise `CancelledError`. `stop()` cancels and awaits the worker.
- [ ] **Step 7: Implement upload/deletion service**
`DocumentService.upload()` validates 20 MiB, extension/MIME/content, atomically writes `data/raw/{uuid}.{ext}`, creates document/job, enqueues, and returns. `delete()` marks deleting, idempotently removes vectors/file, then deletes SQLite rows. Startup recovery resumes deleting records and treats absent external data as success.
- [ ] **Step 8: Verify without model calls and commit**
Run Ruff and a script that creates the SQLite schema/Milvus collection but never calls embeddings.
```bash
git add backend/src/kbqa
git commit -m "feat: add durable document indexing pipeline"
```
---
### Task 5: Document HTTP API
**Files:**
- Create: `backend/src/kbqa/documents/schemas.py`, `routes.py`
- Modify: `backend/src/kbqa/api/router.py`
**Interfaces:**
- Produces: all Spec section 12.1 endpoints and stable `DocumentResponse`, `ChunkResponse`, and `Page[T]` shapes.
- [ ] **Step 1: Define response schemas**
Use Pydantic v2 `from_attributes=True`. `DocumentResponse` includes status/error/chunk count/timestamps; `ChunkResponse` includes order/page/content; `Page` includes `items`, `total`, `offset`, `limit`.
- [ ] **Step 2: Implement all document routes**
Implement upload/list/detail/chunks/retry/delete. Enforce `offset >= 0` and `1 <= limit <= 100`. Return 202 for upload/retry, 204 delete, 404 missing, 409 invalid retry state, 413 size, and 415 format.
- [ ] **Step 3: Smoke-check non-model branches over real HTTP**
Start Uvicorn and use curl to list, reject an unsupported extension, return 404 for a random UUID, and verify request IDs. Do not upload a valid file until Task 12 live verification.
- [ ] **Step 4: Commit**
```bash
git add backend/src/kbqa/documents backend/src/kbqa/api/router.py
git commit -m "feat: expose document management api"
```
---
### Task 6: Session persistence and history API
**Files:**
- Create: `backend/src/kbqa/chat/schemas.py`, `routes.py`
- Modify: `backend/src/kbqa/api/router.py`
**Interfaces:**
- Produces: create/list/delete sessions and paginated message history; reserves `/chat/stream` for Task 8.
- [ ] **Step 1: Define session/message/source schemas**
`MessageResponse` includes `status`, safe `error_code`, and sources joined from live documents/chunks. Parse citation labels missing source rows into `deleted_source_labels` so the frontend can render “来源已删除” without deleted content.
- [ ] **Step 2: Implement session routes**
Implement `POST /sessions`, `GET /sessions`, `GET /sessions/{id}/messages`, and `DELETE /sessions/{id}` with exact 201/200/204/404 behavior.
- [ ] **Step 3: Smoke-check restart persistence**
Create a session over HTTP, list it, restart Uvicorn, list/fetch it again, delete it, then verify it no longer exists.
- [ ] **Step 4: Commit**
```bash
git add backend/src/kbqa/chat backend/src/kbqa/api/router.py
git commit -m "feat: add persistent chat sessions"
```
---
### Task 7: LangChain 1.x dual-agent core
**Files:**
- Create: `backend/src/kbqa/agents/{context,llm,prompts,tools,middleware,factory}.py`
**Interfaces:**
- Produces: `AgentBundle.main_agent`, research agent, `research` tool, `search_knowledge_base` tool, and typed custom stream payloads.
- Consumes: `KnowledgeRetriever`, Settings, request/session metadata.
- [ ] **Step 1: Define runtime context**
```python
@dataclass(frozen=True, slots=True)
class ResearchContext:
retriever: KnowledgeRetriever
request_id: str
@dataclass(frozen=True, slots=True)
class ChatRunContext:
retriever: KnowledgeRetriever
request_id: str
session_id: str
assistant_message_id: str
```
Tool artifacts are serialized `SourceCandidate` lists; never use a global mutable collector.
- [ ] **Step 2: Implement model factories**
Build `ChatOpenAI` with DashScope-compatible URL/key, `qwen3.5-flash`, async streaming, timeout, and bounded retries. Instantiate lazily, not at import. Add LangSmith `main-agent`/`research-agent` tags and request/session/message metadata per invocation.
- [ ] **Step 3: Implement search tool**
Use `@tool(response_format="content_and_artifact")`. Accept `query: str` plus injected `ToolRuntime[ResearchContext]`, call `retrieve(query, top_k=5)`, and return readable text plus complete source artifact.
- [ ] **Step 4: Build research agent**
Use `langchain.agents.create_agent(..., tools=[search_knowledge_base], context_schema=ResearchContext)`. Limit each run to three searches. Prompt it to use only tool evidence, reformulate weak searches, and return a research summary rather than a direct user answer.
- [ ] **Step 5: Implement main-agent research tool**
The `research(question, runtime)` tool invokes the research agent with `ainvoke`, extracts every search ToolMessage artifact, deduplicates by chunk ID keeping the highest score, caps at eight, emits custom `sources` or `no_evidence` through the stream writer, and returns the summary plus `[S1]…[S8]` evidence.
- [ ] **Step 6: Enforce research-before-answer**
Implement LangChain 1.x `AgentMiddleware.awrap_model_call`. Before a successful research ToolMessage exists in the current turn, override the model with required tool choice; afterward allow the final answer. Bound model calls and reject a second research call.
- [ ] **Step 7: Build main agent**
Use `create_agent` with only `research`, strict prompt, middleware, and `ChatRunContext`. The prompt allows only supplied labels and requires inline citations. Return both compiled graphs in:
```python
@dataclass(frozen=True, slots=True)
class AgentBundle:
main_agent: CompiledStateGraph
research_agent: CompiledStateGraph
```
- [ ] **Step 8: Static-check without calling the model**
Inspect the compiled agents: main exposes only `research`, researcher exposes only `search_knowledge_base`, and repository search finds no `create_react_agent` import.
- [ ] **Step 9: Commit**
```bash
git add backend/src/kbqa/agents
git commit -m "feat: add langchain dual-agent workflow"
```
---
### Task 8: SSE chat orchestration and endpoint
**Files:**
- Create: `backend/src/kbqa/chat/sse.py`, `service.py`
- Modify: `backend/src/kbqa/chat/routes.py`, `backend/src/kbqa/lifecycle.py`
**Interfaces:**
- Produces: `ChatService.stream(session_id, query, request_id)` yielding encoded SSE bytes and `POST /api/v1/chat/stream`.
- [ ] **Step 1: Implement SSE encoding**
```python
def encode_event(event: str, data: Mapping[str, object]) -> bytes: ...
def encode_ping() -> bytes: ...
```
Serialize compact single-line UTF-8 JSON, terminate each event with two newlines, and encode heartbeat as `: ping\n\n`.
- [ ] **Step 2: Implement per-session lock registry**
Key process-local locks by session UUID. Reject concurrent generation with 409 and always release in `finally`. This is valid only because the app runs one process.
- [ ] **Step 3: Implement stream lifecycle**
Begin the SQLite turn, load 20 recent messages, invoke `main_agent.astream` with `stream_mode=["messages", "custom", "updates"]` and `version="v2"`, then map:
- immediate `status: researching`;
- source artifact → `sources`, then `status: answering`;
- empty evidence → close agent stream and emit deterministic insufficiency token;
- final main-agent text chunks after answering begins → `token`;
- 15-second inactivity → ping;
- validated/persisted answer → `done`.
Only labels in the source set are valid. Unknown labels produce `INVALID_CITATION`, mark failed, and emit `error` instead of `done`.
- [ ] **Step 4: Handle disconnects and failures**
Propagate `CancelledError`, close the LangChain async generator, mark assistant failed, and never persist partial content. Convert DashScope timeout/upstream errors to safe SSE errors. Persist success before `done`.
- [ ] **Step 5: Expose POST route**
Validate non-empty bounded query. Return `StreamingResponse` with `text/event-stream`, `Cache-Control: no-cache`, `X-Accel-Buffering: no`, and request ID.
- [ ] **Step 6: Verify protocol without model calls**
Run Ruff and feed Chinese token/source/error/ping payloads through the encoder to verify exact framing.
- [ ] **Step 7: Commit**
```bash
git add backend/src/kbqa/chat backend/src/kbqa/lifecycle.py
git commit -m "feat: stream dual-agent answers over sse"
```
---
### Task 9: React workbench foundation
**Files:**
- Create: Vite React TypeScript project under `web/`
- Create: `web/src/app/router.tsx`, `AppLayout.tsx`
- Create: `web/src/lib/api.ts`, `types.ts`, `sse.ts`
- Create/Generate: `web/src/components/ui/*`
- Modify: `web/vite.config.ts`, `web/src/index.css`
**Interfaces:**
- Produces: typed REST client, incremental POST-SSE parser, routes, responsive shell, and shared shadcn primitives.
- [ ] **Step 1: Scaffold and install**
Use `npm create vite@latest web -- --template react-ts`. Add React Router, zustand, lucide-react, Tailwind CSS, shadcn/ui dependencies, ESLint, Vitest, and button/dialog-drawer/badge/textarea/scroll-area/tooltip/skeleton primitives. Commit `package-lock.json`.
- [ ] **Step 2: Configure proxy and visual tokens**
Proxy `/api` and `/health` to `http://127.0.0.1:8000`. Implement the approved deep-green, warm-orange, off-white palette as CSS variables and use a distinct reading style for evidence text.
- [ ] **Step 3: Define shared types**
Mirror backend DTOs: `Page<T>`, `Document`, `Chunk`, `Session`, `Message`, `Source`, `ApiError`, and a discriminated SSE union for `status/sources/token/done/error`.
- [ ] **Step 4: Implement clients**
`apiRequest<T>()` parses unified errors and request ID. `streamSSE()` uses fetch POST + `ReadableStream`, retains incomplete UTF-8/lines across chunks, supports LF/CRLF, dispatches complete events, and accepts `AbortSignal`.
- [ ] **Step 5: Implement routes and shell**
Create `/chat`, `/chat/:sessionId`, `/documents`, `/documents/:documentId`. At ≥1100px show all columns; below 1100px evidence is a drawer; below 768px navigation is also a drawer.
- [ ] **Step 6: Static-check and commit**
```bash
npm --prefix web run lint
npm --prefix web run build
git add web
git commit -m "feat: add responsive react workbench"
```
---
### Task 10: Document management frontend
**Files:**
- Create: `web/src/features/documents/api.ts`, `useDocuments.ts`
- Create: `web/src/features/documents/DocumentsPage.tsx`, `DocumentList.tsx`, `DocumentDetail.tsx`, `UploadDocument.tsx`
- Modify: `web/src/app/router.tsx`, `web/src/app/AppLayout.tsx`
**Interfaces:**
- Produces: upload, list/filter, two-second active-status polling, chunk pagination, retry, deletion confirmation, and selected-document evidence panel.
- [ ] **Step 1: Implement typed document API**
Expose `uploadDocument`, `listDocuments`, `getDocument`, `listChunks`, `retryDocument`, and `deleteDocument` with exact backend paths.
- [ ] **Step 2: Implement polling state**
Poll every two seconds only while a visible document is pending/indexing/deleting; stop on unmount and terminal states. Preserve selected document route across refresh.
- [ ] **Step 3: Build approved document UI**
Left shows navigation and status counts. Center shows upload, filters, filename/type/chunk count/status and retry/delete actions. Right shows metadata and paginated chunks. Never display stack traces.
- [ ] **Step 4: Smoke-check real non-model branches**
Run both services; reject an unsupported file, show empty state, navigate to a missing document, and check responsive drawers without valid upload/model calls.
- [ ] **Step 5: Check and commit**
```bash
npm --prefix web run lint
npm --prefix web run build
git add web/src
git commit -m "feat: add document management workspace"
```
---
### Task 11: Chat, streaming, history, and evidence frontend
**Files:**
- Create: `web/src/features/chat/api.ts`, `store.ts`, `useChatStream.ts`
- Create: `web/src/features/chat/ChatPage.tsx`, `SessionList.tsx`, `MessageList.tsx`, `ChatComposer.tsx`, `EvidencePanel.tsx`
- Modify: `web/src/app/router.tsx`, `web/src/app/AppLayout.tsx`
**Interfaces:**
- Produces: persistent session navigation, one active stream, incremental answer, abort/retry, source selection, deleted-source labels, and history reload.
- [ ] **Step 1: Implement session API and store**
Expose create/list/delete sessions and message history. Store only current session ID, transient phase/text/sources/error, and selected evidence; server responses remain authoritative.
- [ ] **Step 2: Implement stream hook**
Create one `AbortController`; append tokens; replace sources on `sources`; update researching/answering phase; reload history on `done`; expose retry with original query on `error`; abort on route change/unmount.
- [ ] **Step 3: Build session and message surfaces**
Left lists sessions with new/delete. Center renders user/assistant messages, research phase, failed placeholders, citation chips, and fixed composer disabled during streaming.
- [ ] **Step 4: Build evidence panel**
Right lists current answer sources and expands one inline with filename, order/page, score, and full chunk. Deleted labels render muted “来源已删除” with no filename/content.
- [ ] **Step 5: Implement empty/responsive states**
With no indexed documents, guide to `/documents`. Evidence drawer must not destroy center conversation state on medium/mobile widths.
- [ ] **Step 6: Check and commit**
```bash
npm --prefix web run lint
npm --prefix web run build
git add web/src
git commit -m "feat: add streaming chat and evidence workspace"
```
---
### Task 12: Post-implementation tests, real-model API suite, and agent-browser E2E
**Files:**
- Create: `backend/tests/conftest.py`
- Create: `backend/tests/fixtures/{knowledge.md,knowledge.txt,knowledge.pdf}`
- Create: `backend/tests/unit/{test_file_validation,test_splitter,test_sse}.py`
- Create: `backend/tests/integration/{test_sqlite_cascades,test_session_api}.py`
- Create: `backend/tests/live/test_live_pipeline.py`
- Create: `web/src/lib/sse.test.ts`
- Create: `docs/testing/live-test-report.md`, `docs/testing/screenshots/`
**Interfaces:**
- Produces: ordinary checks, explicitly gated real API/model verification, browser evidence, and final acceptance report.
- [ ] **Step 1: Add automated tests after implementation**
Test validation, deterministic chunk IDs, SQLite constraints/cascades, session persistence, SSE framing, and frontend incremental parsing. Do not patch/fake Chat or Embedding; ordinary tests avoid those paths.
- [ ] **Step 2: Create real knowledge fixtures**
Commit UTF-8 Markdown/TXT and a real text-layer PDF with a small unique fact set suitable for a first question, pronoun-based follow-up, and absent-fact question. Generate the PDF once; do not synthesize it during the test.
- [ ] **Step 3: Implement black-box live test**
Skip unless `KBQA_RUN_LIVE_TESTS=1`. Start real Uvicorn on an available localhost port with isolated SQLite/Milvus/raw paths; use `httpx` over HTTP to:
1. upload all three real files;
2. poll until indexed and assert positive chunk counts;
3. create a session and parse real SSE;
4. assert researching→sources→answering→token→done;
5. assert sources refer to uploaded files/retrievable chunks;
6. ask a pronoun follow-up and verify persisted history;
7. ask an absent fact and verify insufficiency;
8. restart Uvicorn and verify persistence;
9. delete documents/sessions and verify files, SQLite relationships, and vectors are no longer retrievable.
Assert structure/semantic boundaries, never exact model wording.
- [ ] **Step 4: Run ordinary verification**
```bash
uv run --project backend ruff format --check backend/src backend/tests
uv run --project backend ruff check backend/src backend/tests
uv run --project backend pytest backend/tests/unit backend/tests/integration -v
npm --prefix web run lint
npm --prefix web run test -- --run
npm --prefix web run build
```
Expected: all exit 0 without contacting DashScope.
- [ ] **Step 5: Run real live verification**
```bash
KBQA_RUN_LIVE_TESTS=1 uv run --project backend pytest backend/tests/live/test_live_pipeline.py -v -s
```
Expected: real Chat/Embedding calls appear in LangSmith and the black-box flow passes.
- [ ] **Step 6: Run agent-browser E2E**
Start one Uvicorn worker and Vite. Use the `agent-browser` skill to upload, observe status, create a session, stream a real answer, expand evidence, follow up, reload history, test desktop/narrow widths, delete data, and capture screenshots.
- [ ] **Step 7: Write final report**
Record commands, versions, timestamps, LangSmith run IDs/links without secrets, screenshots, results, and non-blocking model variability in `docs/testing/live-test-report.md`.
- [ ] **Step 8: Run forbidden-marker audit**
```bash
rg -n "postgres|psycopg|JSONB|create_react_agent|langchain.*0\.3|sk-|lsv2_" --glob '!architecture.md' --glob '!docs/superpowers/**' --glob '!.env'
```
Expected: no forbidden implementation or committed secret.
- [ ] **Step 9: Commit**
```bash
git add backend/tests web/src/lib/sse.test.ts docs/testing
git commit -m "test: verify real kbqa workflow"
```
---
## Execution Checkpoints
1. After Task 3: review schema/migration before indexing depends on it.
2. After Task 5: review document state machine, recovery, and deletion.
3. After Task 8: review dual-agent/SSE strictness before frontend coupling.
4. After Task 11: review complete UI with ordinary checks passing.
5. After Task 12: review LangSmith traces, browser screenshots, cleanup, and acceptance report.
## Execution Notes
- Execute with `superpowers:executing-plans`; `subagent-driven-development` is not available in this repository/session.
- Use `1.md` credentials only under the user's explicit local-test authorization; never surface their values. Recommend rotation after acceptance.
- Never run Uvicorn with more than one worker.
- If an installed LangChain 1.x signature differs from a snippet, consult official docs/Context7, preserve this plan's interface/behavior, and record the verified signature in the implementing commit.