# 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.1–6.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.
