Skip to content

[Feat]: knowledge-base agent (PDF/PNG/URL → markdown → RAG → graphRAG)#38

Merged
FireTable merged 120 commits into
mainfrom
feat/13-knowledge-base-agent
Jul 20, 2026
Merged

[Feat]: knowledge-base agent (PDF/PNG/URL → markdown → RAG → graphRAG)#38
FireTable merged 120 commits into
mainfrom
feat/13-knowledge-base-agent

Conversation

@FireTable

@FireTable FireTable commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Closes #13

Problem

No way to ingest a PDF into the app. KB v1 had a JSON store + injector but it was ditched for v2, and v3 (hybrid RRF + Reranker + @-mention resolution) is what this branch actually ships.

What changed

Backend

  • backend/agent/kb-agent.ts — full ingestion pipeline: prepareKBDataNode → ocrNode → pageToMarkdownNode → generateChunkEmbedNode → rewriteMessagesNode. Wired as both a subgraph of mainAgent (chat path) and a top-level assistant (langgraph.json).
  • lib/kb/{schema,queries,ingest,resolve,search,cache,extract,entityColor,screenshot,text,env}.ts — pgvector tables, hybrid RRF (BM25 + vector + tag), search_kb tool, LRU doc cache, LightRAG-style entity/relationship/theme extraction.
  • lib/provider/model-registry.ts — five-way kind split (chat/ocr/embed/extract/rerank) + getOcrModelFromDB / getExtractModelFromDB / getEmbeddingModelFromDB / getRerankModelFromDB / RerankModel. kind= cache key prefix namespaces the LRU + round-robin counter per pool.
  • lib/observability/{transform,aggregate}.ts + app/api/threads/[id]/observability/[parentMessageId]/route.ts — root chain detection for top-level runs.create invokes (Settings KB upload), aggregateRoot now consumes the transformed spans so failedCount is wire-accurate.
  • lib/threads/{schema,queries}.tsthreads.kind ('chat' | 'kb') hides KB ingestion threads from the chat sidebar; sidebar filter is kind='chat'.
  • backend/observability/callback.tsCapturingHandler is a process-global singleton; wired on mainAgent, backgroundAgent, and kbAgent standalone compile.
  • scripts/dev-stop.sh — replaced inline pnpm dev:stop (kills tsx watch wrappers + sweeps ports 2024/3000/3001).
  • scripts/db-migrate.ts — seeded default model gets kind: ["chat"] so SELECT * FROM provider reads cleanly.

Frontend

  • components/settings/kb-view/** — KB Settings tab (folder sidebar, doc table, doc detail, folder-level knowledge graph, observability list popover, live poll indicator).
  • components/tool-ui/kb/** — chat-side rendering of search_kb (collapsible chunk list, leg badges, score formatting) + list_documents.
  • components/assistant-ui/{directive-chip,directive-text,kb-mention,kb-mention-formatter}.{ts,tsx}:kb-document[label]{documentId=…} / :kb-folder[label]{folderId=…} directive tokens survive the SDK wire because contentToParts strips sibling fields and filters unknown types.
  • components/observability/{sheet,panel}.tsx — KB page wraps in <ObservabilitySheetProvider>; per-row Activity icon on <DocRow> opens the singleton Sheet against the right threadId/parentMessageId.
  • components/landing/{features,how-it-works,cta}.tsx — KB bento card + How-it-works explainer.

API surface

  • 9 KB endpoints (/api/kb/{upload,documents,documents/[id],documents/[id]/reprocess,documents/[id]/observability,folders,folders/[id]}) — see docs/APIS.md § Knowledge Base.
  • 4 reprocess modes (full | chunksOnly | retryFailed | retryFailedChunks) with discriminated 4xx (409 PROCESSING / 409 ATTACHMENT_MISSING / 409 NOT_READY).

Docs

  • docs/KNOWLEDGE_BASE.md — new (433 lines): pipeline, schema rationale, RRF math, Reranker stage, mention resolution, 4 reprocess modes, failure modes, env knob table, per-doc observability popover.
  • docs/DB.mdkb_folder / kb_document / kb_chunk tables + 11 KB indexes.
  • docs/APIS.md — all 9 KB endpoints + kind[] on the admin /api/admin/providers/** model endpoints.
  • docs/OBSERVABILITY.mdKB ingestion runs (Settings → KB) section + Where capturingHandler is wired + kbAgent standalone compile.
  • docs/TOOLS.mdsearch_kb / list_documents table + env knobs (Reranker, hybrid topK, mention budget). ALL_TOOLSCHAT_TOOLS.
  • docs/PROVIDERS.md — five-way kind split + new getters + RerankModel shape.
  • docs/MEMORY.md — 2 graphs → 3 graphs (add kbAgent to topology).
  • docs/ATTACHMENTS.md — link out to KNOWLEDGE_BASE.md now that KB v3 ships.
  • docs/LANDING.md, README.md, CLAUDE.md — KB feature bullet + doc index entry.

Tests

1165/1165 passing (pnpm test, vitest). New coverage:

  • tests/api/kb/{documents,folder,reprocess,documents/observability}.test.ts — every status code path on each KB route.
  • tests/backend/{kb-agent,tool/kb,agent/resolve-entity-aliases}.test.ts — pipeline + entity alias resolution.
  • tests/lib/kb/{cache,extract,ingest,queries,resolve,screenshot,search,entity-color}.test.ts — DB helpers, extract heuristics, ingest dispatch, resolve text rewriting.
  • tests/lib/observability/aggregate.test.tsaggregateRoot(capturedSpans, transformedSpans) wire-accuracy.
  • tests/lib/provider/model-registry.test.ts — five-way round-robin + cache invalidation.
  • tests/lib/threads/queries.test.tskind='chat' sidebar filter.
  • tests/frontend/{directive-chip,directive-text,kb-list-documents-card,live-poll-indicator,landing/sections,settings/memory-view-group-threads}.test.tsx.
  • tests/integration/kb-observability-roundtrip.test.ts — end-to-end upload → kb_observability row → popover list.

Pre-commit hooks (lint + typecheck + test) all green on the tip.

Docs

Per CLAUDE.md rules #1 and #10 — every API endpoint under app/api/ is reflected in docs/APIS.md; every tool added is reflected in docs/TOOLS.md; new feature lives in docs/KNOWLEDGE_BASE.md with cross-links to DB / observability / providers / attachments. Doc index in CLAUDE.md updated.

🤖 Generated with Claude Code


Summary by cubic

Ships the v3 Knowledge Base (issue #13): ingest PDFs to markdown with persisted pages, index chunks with 1024‑dim embeddings and LightRAG entities/relationships/themes, and search via hybrid RRF with optional Reranker, folder/doc filters, and graph views. Also fixes the vector-search leg and extract‑model fallback, and adds folder‑scoped lists, live polling, and tighter observability.

  • New Features

    • kbAgent pipeline (OCR → markdown → chunks → embeddings → entities/relationships/themes) with concurrency, strict structured-output, and background indexing; pages are persisted for preview and fallback.
    • Hybrid search (BM25 + vector + entity-tag) now embeds the query and supports an optional Reranker with min‑score filtering; tools search_kb and list_documents return concise, folder‑grouped results.
    • KB Settings: folder sidebar, doc table, per‑doc detail (Markdown/Pages/Chunks/Graph), folder‑level knowledge graph with graphRAG coloring, hover tooltips, live polling indicator, and per‑doc run history popover; chunk/page badges show pending/parsing/success/failed.
    • Composer @‑mentions (:kb-document[label]{id=…} / :kb-folder[label]{id=…}) resolve to tool calls before routing; tool results inject numbered chunk context and sources render inline in chat.
    • 9 KB API routes (upload, folders, documents, reprocess, observability) and 4 reprocess modes (full, chunksOnly, retryFailed, retryFailedChunks), with accurate per‑page and per‑chunk statuses.
    • Model registry split into five kinds (chat, ocr, embed, extract, rerank) with admin UI support, per‑kind pools, caching, and clean fallbacks; threads gain kind='chat' | 'kb' to hide ingest threads.
    • Observability: wire‑accurate failed‑count aggregation, per‑turn sheets, doc‑scoped run lists, and inline failed‑span markers.
    • Docs updated: Knowledge Base design (pipeline, hybrid/Reranker, mentions, graphs), DB schema, APIs, tools, providers, observability, attachments, landing.
  • Migration

    • Run DB migrations to install pgvector and create/alter KB tables/indexes (embedding dim 1024; entities to jsonb, relationships/themes added; per‑chunk status).
    • Configure provider models with kind[] for chat, ocr, embed, extract, and optional rerank (admin UI supports it).
    • Set KB env knobs in .env as needed (topK, token budget, chunk size, rerank min score).
    • If enabling Reranker, add a rerank model entry; otherwise search falls back to RRF.

Written for commit d94a16f. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR ships the v3 Knowledge Base feature end-to-end: PDF/PNG/URL → OCR → markdown → chunks → pgvector embeddings → hybrid RRF search (BM25 + vector + entity-tag) + optional Reranker, with a full Settings UI, @-mention resolution in chat, and 9 new API endpoints. Two bugs in the search/model layer need fixing before the vector leg and extract-model fallback work as intended.

  • lib/kb/search.ts: The auto-embedded query vector is stored in a local qvec variable but qvecLiteral still reads from args.qvec, which is null on every search_kb tool call — the pgvector leg silently no-ops for all normal searches.
  • lib/provider/model-registry.ts: getExtractModelFromDB uses return promise inside try/catch without await; the fallback to the chat pool never runs when no extract-kind model is registered.
  • Migration gap: 0005 creates embedding vector(1536) while the app expects vector(1024); 0006 fixes it, but a DB state where only the first migration has applied will reject every chunk insert.

Confidence Score: 3/5

The core RAG pipeline ships with the vector search leg completely disabled for all normal tool calls, and the extract-model fallback silently broken — both in new code that has not existed before in production.

The search_kb tool never activates the pgvector leg in any real call because qvecLiteral reads args.qvec (always null from the tool) instead of the locally-computed qvec. Every knowledge-base query runs keyword+tag only, silently degrading retrieval quality without surfacing any error. The extract-model fallback in getExtractModelFromDB is similarly inert: the missing await means the catch block is unreachable for async rejections, so deployments without an extract-tagged model will error instead of gracefully falling back to chat. Both issues are confined to two small code sites with straightforward one-line fixes, but they affect the primary value-add paths of the entire KB feature.

lib/kb/search.ts (vector leg disabled) and lib/provider/model-registry.ts (extract fallback broken) are the two files that need fixes before the KB feature works as described. The migration in db/migrations/0005_past_grey_gargoyle.sql is worth a second look for environments that run migrations incrementally.

Important Files Changed

Filename Overview
lib/kb/search.ts Hybrid RRF search (BM25 + vector + entity-tag). Critical bug: auto-embedded qvec local variable is never used in qvecLiteralargs.qvec is checked instead, so the vector leg never fires in the normal search_kb call path.
lib/provider/model-registry.ts Five-way model pool (chat/ocr/embed/extract/rerank) with round-robin. getExtractModelFromDB fallback to chat pool is broken: missing await means the try/catch never intercepts the async rejection when no extract-tagged model exists.
backend/agent/kb-agent.ts 1399-line ingestion pipeline (prepareKBData → OCR → pageToMarkdown → generateChunkEmbed → rewriteMessages). Logic appears sound; relies on the search/extract fixes elsewhere.
lib/kb/ingest.ts Shared fire-and-forget ingestion dispatch via langGraphClient.runs.create with multitaskStrategy: 'interrupt'. Thread upsert, messageId stamping, and mode/source plumbing all look correct.
app/api/kb/upload/route.ts Upload endpoint: attachment ownership check, folder ownership check, content-hash dedup, doc row creation, fire-and-forget dispatch. Status guards and error handling look correct.
app/api/kb/documents/[id]/reprocess/route.ts Four reprocess modes (full/chunksOnly/retryFailed/retryFailedChunks) with discriminated 409 guards. Mode parsing handles both ?mode= and legacy ?chunksOnly=true params correctly.
lib/observability/aggregate.ts Signature extended to accept both CapturedSpan[] and WireSpanData[]; countRootFailures avoids double-counting by walking only leaf or childless-failed nodes. All callers updated correctly.
db/migrations/0005_past_grey_gargoyle.sql Initial KB table DDL creates embedding vector(1536) but the application uses EMBEDDING_DIM=1024; the corrective ALTER is split into a separate migration 0006, leaving a transient dimension mismatch window.
lib/kb/env.ts Typed env reader for KB knobs with sensible defaults. Module-level cache with test-only reset hook; correct use of parseInt/parseFloat with finite/positive guards.
lib/threads/schema.ts Adds kind column (`'chat'

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as Settings / Chat UI
    participant Upload as POST /api/kb/upload
    participant Ingest as lib/kb/ingest.ts
    participant kbAgent as kbAgent (LangGraph)
    participant OCR as ocrNode (vision LLM)
    participant Embed as generateChunkEmbedNode
    participant PG as Postgres (pgvector)
    participant Search as search_kb tool
    participant Rerank as RerankModel (optional)

    UI->>Upload: folderId attachmentId title
    Upload->>PG: "INSERT kb_document status=pending"
    Upload->>Ingest: fireIngestionRun
    Ingest->>kbAgent: "runs.create multitaskStrategy=interrupt"
    Upload-->>UI: 202 docId
    kbAgent->>OCR: PDF pages to markdown
    OCR-->>kbAgent: pages markdown
    kbAgent->>Embed: split chunks embeddings entity extract
    Embed->>PG: INSERT kb_chunk embedding entities themes tsv
    Embed->>PG: "UPDATE kb_document status=success"
    UI->>Search: search_kb query documentId
    Search->>PG: hybrid RRF BM25 kw vector vec entity tag
    PG-->>Search: ranked chunks
    Search->>Rerank: rerank query chunks if configured
    Rerank-->>Search: scored filtered results
    Search-->>UI: formatted chunk list
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as Settings / Chat UI
    participant Upload as POST /api/kb/upload
    participant Ingest as lib/kb/ingest.ts
    participant kbAgent as kbAgent (LangGraph)
    participant OCR as ocrNode (vision LLM)
    participant Embed as generateChunkEmbedNode
    participant PG as Postgres (pgvector)
    participant Search as search_kb tool
    participant Rerank as RerankModel (optional)

    UI->>Upload: folderId attachmentId title
    Upload->>PG: "INSERT kb_document status=pending"
    Upload->>Ingest: fireIngestionRun
    Ingest->>kbAgent: "runs.create multitaskStrategy=interrupt"
    Upload-->>UI: 202 docId
    kbAgent->>OCR: PDF pages to markdown
    OCR-->>kbAgent: pages markdown
    kbAgent->>Embed: split chunks embeddings entity extract
    Embed->>PG: INSERT kb_chunk embedding entities themes tsv
    Embed->>PG: "UPDATE kb_document status=success"
    UI->>Search: search_kb query documentId
    Search->>PG: hybrid RRF BM25 kw vector vec entity tag
    PG-->>Search: ranked chunks
    Search->>Rerank: rerank query chunks if configured
    Rerank-->>Search: scored filtered results
    Search-->>UI: formatted chunk list
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
lib/kb/search.ts:191
**Vector search leg silently no-ops on every `search_kb` call.** The auto-embedded vector is stored in the local `qvec` variable but `qvecLiteral` reads from `args.qvec`, which is always `null` when `search_kb` calls `hybridSearch` without pre-computing an embedding. As a result `hasVec` is always `false`, the `vec` CTE is never injected, and the hybrid search runs as keyword+tag only — the entire pgvector index is dead code for the tool path.

```suggestion
  const qvecLiteral = qvec != null ? vectorLiteral(qvec) : null;
```

### Issue 2 of 3
lib/provider/model-registry.ts:118-127
**`try/catch` never catches the async rejection — fallback to `chat` pool is dead code.** `getModelFromDB` is an `async` function that returns a `Promise`. Without `await`, the `try` block exits after handing back the Promise; if that Promise later rejects (e.g. no `extract`-tagged model registered), the rejection propagates up from the async function directly and the `catch` block is never entered. The designed fallback to the chat pool therefore never runs, and callers see an unhandled rejection instead.

```suggestion
export async function getExtractModelFromDB(opts: GetModelOpts = {}): Promise<BaseChatModel> {
  try {
    return await (getModelFromDB({ ...opts, kind: "extract" }) as Promise<BaseChatModel>);
  } catch (err) {
    // ponytail: no extract-tagged model registered yet → fall back to
    // chat pool. Same retry loop exists in the chat getter, so this
    // is the right pattern for "treat the pool as a soft contract".
    return getModelFromDB({ ...opts, kind: "chat" }) as Promise<BaseChatModel>;
  }
}
```

### Issue 3 of 3
db/migrations/0005_past_grey_gargoyle.sql:8
**Embedding column dimension mismatch between migrations.** `0005` creates `kb_chunk.embedding` as `vector(1536)`, but `lib/kb/schema.ts` sets `EMBEDDING_DIM = 1024` and migration `0006` immediately issues `ALTER COLUMN … SET DATA TYPE vector(1024)`. This two-step is harmless once both migrations run, but any environment that runs only `0005` ends up with a `1536`-dim column while the application inserts `1024`-dim vectors, producing a pgvector `22P02` error on every chunk insert.

Reviews (1): Last reviewed commit: "docs(kb): sync providers + memory topolo..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

FireTable and others added 30 commits July 14, 2026 17:04
…13)

Provider registry now partitions tuples by ModelKind ("chat" | "vlm" | "embed").
Chat-only models are excluded from VLM/embed pools; dual-capable models
(gpt-4o-mini for chat+vlm) round-robin independently per kind.

- ModelConfig.kind? (optional, defaults to ["chat"] for back-compat)
- cacheKey prefix "kind=chat|vlm|embed" → chat and vlm traffic don't share
  round-robin counters
- OpenAIEmbeddings instantiated from picked tuple for embed kind
- backend/model.ts: getChatModel / getVlmModel / getEmbeddingModel
  each with DB→env fallback (env path uses OPENAI_EMBEDDING_MODEL /
  text-embedding-3-small default)

Tests: 5 new cases (vlm filter, embed instance shape, kind-missing throw,
default chat back-compat, counter independence). All 884 pass; typecheck
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1 KB storage is JSON files under .kb-store/<userId>/<docId>.json. The
shape is intentionally narrow (one record = one document with pages +
chunks) so the v2 migration is a 1:1 dump into kb_document + kb_chunk.

- writeKbDoc: atomic via writeFile(.tmp) + rename (POSIX atomic)
- readKbDoc: returns null on ENOENT, throws on other I/O errors
- listKbDocs: per-user dir read, sorted by createdAt desc
- deleteKbDoc: idempotent; cross-user path means caller can't reach
  another user's file even with a guessed docId
- setKbStoreRoot: test seam (mkdtemp scratch dir per test)
- KB_STORE_ROOT env override for containerized deploys
- .kb-store/ gitignored (lives across restarts in dev, replaced by
  Postgres in v2)

Tests: 10 new cases (round-trip, missing, cross-user null, atomic
write no .tmp leftover, list sort+isolation, delete idempotent).
All 894 pass; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renders each PDF page to a PNG the VLM step can read. Output goes to
the caller-supplied outputDir and the path is recorded in the JSON
record (imagePath per page). V2 will move the binary to R2 alongside
the source attachment; the screenshot module itself is unchanged.

- mupdf@1.28.0 added as a direct dep (was transitive via M1 attempt)
- Matrix.scale(dpi/72, dpi/72) — 200 DPI matches the M1 fidelity target
- throws on non-PDF bytes with a clear message
- 1-page hand-rolled PDF fixture generated inline in the test (no
  checked-in binary)

Tests: 4 new cases (1-page happy path, PNG magic bytes, page-N.png
path convention, DPI scaling, non-PDF throws). All 898 pass; typecheck
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#13 v1)

Three-node linear subgraph registered in langgraph.json. Each node
updates a shared KbAgentState; the final node writes the JSON record
so a v2 migration to Postgres is a 1:1 dump.

- screenshot: mupdf renders PDF → N PNGs in a tmp dir
- vlm: per-page GPT-4o-mini call extracts clean markdown from each
  PNG (image_url data URL). On error: status=failed + errorMessage,
  no throw (chat runtime inspects state.status)
- chunkEmbedStore: 1 chunk per page (v1 simplification; chonkie /
  LangChain splitter is v2). Empty pages skipped. Embeddings come
  from getEmbeddingModel(). Writes KbDocRecord via writeKbDoc, then
  best-effort cleans the tmp PNG dir.

State.pdfBytes typed as Buffer (Node-idiomatic) — the
Uint8Array<ArrayBufferLike> vs Uint8Array<ArrayBuffer> TS strict
generic mismatch is a known wart; Buffer sidesteps it.

Tests: 3 cases (1-page happy path, 2-page VLM-count check, VLM-
thrown → status=failed). All 901 pass; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chat-agent now runs the injector before chatModelNode. Scans the most
recent HumanMessage for PDF file content parts; for each, looks up
the attachment by R2 key, fetches bytes, synchronously invokes
kb_agent, and rewrites the file part in state.messages with a text
content part carrying the per-page markdown.

- R2 getObject() helper added (full-buffer read; KB pipeline ingests
  PDFs in one pass for mupdf)
- findAttachmentByR2Key: per-user lookup by r2Key (vs the existing
  sha-based dedup probe) — used to back-resolve a publicUrl from a
  file content part to an attachment row
- chat-agent topology: START → injectAttachments → chatModel →
  {chatTools, END} → chatModel
- Injector is a no-op when the latest user message has no file parts,
  so the existing chat history replay path is unchanged for non-PDF
  messages (every existing chat turn today)
- kb_agent status=failed is surfaced as a "KB processing failed: …"
  text part so the chat continues without grounding
- Cross-user URL → findAttachmentByR2Key returns null → silently
  dropped (no leaked reference in the model input)

Tests: 5 cases (no file part, non-PDF pass-through, PDF happy path,
cross-user drop, status=failed text). All 906 pass; typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…m chat-agent

- Move backend/kb-agent.ts -> backend/agent/kb-agent.ts (colocate with other
  agent subgraphs; chat-agent already lives at backend/agent/chat-agent.ts)
- Unregister kb_agent from langgraph.json: it is invoked internally via
  kbAgentGraph.invoke(...) from inside the top-level agent graph, not as a
  standalone deployable endpoint like agent/background_agent
- Drop injectAttachments node from chat-agent (will be wired into the
  top-level backend/agent.ts so the KB pipeline runs once per top-level
  invocation, not once per chat subgraph retry)
- Update import paths in tests + injector node to match new kb-agent location

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…wire format

attachment-kb-injector-node.ts was reading part.mimeType (camelCase), but
the @assistant-ui/react-langgraph runtime's toLangGraphUserMessage flattens
content parts into LangGraph SDK's MessageContent shape, which uses
snake_case fields (mime_type, source_type). Every PDF was silently dropped
at the non-PDF branch since part.mimeType was always undefined.

The bug was hidden because the r2-adapter builds camelCase mimeType per the
assistant-ui internal contract — but by the time the content reaches the
LangGraph server (via /api/threads/.../runs/stream), it has been
serialized to snake_case by the SDK. The injector lives server-side, so
it must read the wire format, not the frontend builder format.

Update the FilePart type + the PDF check to use mime_type, and rewrite
the filePart() test helper to construct snake_case message parts that
match what the runtime actually delivers. Verified end-to-end:
PDF → kb_agent → .kb-store/<userId>/<docId>.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- RouterNode's LLM-path strip file content parts from messages
  (apimart/Azure Responses API rejects image_url with non-base64 data,
  even though the router should short-circuit on PDFs in practice)
- kbAgent.vlmNode uses base64 data URL from the in-memory Buffer
  (apimart Azure gateway can't fetch HTTPS URLs through image_url)
- insertKbChunks casts through unknown to drop the inferred-required
  tsv field (it's a Postgres generated column — non-DEFAULT writes
  are rejected)
drizzle-orm 0.45 wraps the driver error in DrizzleQueryError with the
original PostgresError on .cause. The 23505 retry-after-race branch
only checked err.code, so a concurrent first-folder race would silently
rethrow instead of re-reading via findKbFolderByName.

Tests: tests/lib/kb/queries.test.ts (new) exercises the race and the
rethrow branches with real DB errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ent)

Closes rule #2 (TDD) for issue #13.

- tests/lib/kb/extract.test.ts — pure helpers (isFilePart/isKbRefPart/
  isPdfAttachment/findLastHumanMessage/extractFilePart/extractKbRef/
  appendKbRef). 31 cases, all branches.
- tests/lib/kb/cache.test.ts — LRU hit/miss/TTL/invalidate/per-user-
  isolation + capacity ceiling. Mocks findKbDocumentById +
  findKbChunksByDocumentId; uses _kbCacheForTest escape hatch.
- tests/lib/kb/resolve.test.ts — 4 status placeholders (success/
  parsing/pending/failed) + not-found → stripKbRef + per-user scoping.
- tests/lib/kb/queries.test.ts — ≥90% coverage required by rule #2.
  All 9 exports + 23505 race retry + cross-user 404 semantics + atomic
  withKbTx insertKbChunks. Real DB; uses helpers/auth TEST_USER.
- tests/backend/kb-agent.test.ts — 6 paths: new PDF / success dedup /
  failed dedup / parsing dedup / unsupported mime / attachment missing +
  VLM error + empty-markdown error. Mocks model factory, screenshot,
  R2 helpers, attachment lookup, DB queries.

queries.ts: 92.85% statements / 94.59% lines (above ≥90%).
Full suite: 993/993 passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two-column layout (folder sidebar + doc table) with rich per-row info
(status chip, type, updated, magnifier/delete/open-source actions, source URL).
New folder + folder delete (DropdownMenu + AlertDialog-style Dialog). New
doc with backend ingestion trigger (LangGraph SDK fires kbAgent in the
background; UI polls while pending/parsing). Doc detail (chunks preview)
and delete (cascades to chunks). Mobile-friendly responsive grid with
minmax(0, 1fr) so the title column doesn't collapse.

Backend: POST /api/kb/folders + DELETE /api/kb/folders/[id] (RESTRICT
→ 409 NON_EMPTY with doc count). POST /api/kb/upload takes an
uploaded attachmentId + folderId, validates, dedups by contentHash,
creates the kb_document row, and fires a fire-and-forget LangGraph run
that re-uses kbAgent. GET /api/kb/documents/:id returns slim chunks
(no 1536-dim embedding in payload). DELETE /api/kb/documents/[id]
cascades to chunks. lib/kb/queries gains findKbFolderById,
listKbDocumentsByFolderWithAttachment (JOIN for publicUrl), slim
KbChunkPreview type.

R2: server-side uploads (Settings Add Doc) now set
Content-Disposition: inline so the link opens the PDF in the browser
viewer. Chat uploads (presigned PUT) still download — needs v3
CopyObject backfill.
…ng, rename fanout

vlmNode: cap at concurrency 5 via p-queue so a 30-page PDF doesn't
fire 30 simultaneous apimart calls; add tags: ['nostream'] to keep
partial-token spans out of the observability waterfall. Stop storing
PNG Buffer in PageResult (would have blown past LangGraph's per-run
~50 MB budget) — VLM now consumes the publicUrl directly via the
assistant-ui image-part shape that r2-adapter already uses for chat.
v2 KB ingest stays at ~6 KB state instead of ~60 MB.

chunkEmbedStoreNode: entity extraction now uses a p-queue concurrency
5 instead of a serial for-loop (30 chunks × 2 s = 60 s → ~12 s).
embedder.embedDocuments remains a single batched call.

Graph topology: routeToSubAgent + shouldRenameRouter collapse into
routeAndMaybeRename (langgraph forbids two conditions on the same
source node). Returns an array of destinations to fanout the rename
parallel to the sub-agent; the rename LLM runs concurrently with
chatAgent so the graph return has no added latency and the frontend
can pull the thread name on the same tick. routeAndMaybeRename skips
while a raw file part is present (PDF first-pass, before kbAgent has
rewritten it) so the rename LLM never chokes on a file content part.
Sequential rename on a parallel leaf off START (the original design)
was rejected because the frontend pulls the thread name when the
graph returns, and only END-of-trigger guarantees that.
Race-safe against concurrent renames: SELECT-then-UPDATE 23505 path returns 409 DUPLICATE instead of 500. Same-name submit short-circuits to 200 with the existing row. Ownership scoped via userId + folderId — callers can't rename another user's folder. Adds updateKbFolderNameForUser to lib/kb/queries.ts.
Rule #1 catch-up — the whole KB section was missing from earlier commits. Adds folder + documents + upload entries in one coherent block: POST/PATCH/DELETE /api/kb/folders[/id], GET /api/kb/documents[/id], POST /api/kb/upload. Notes the fire-and-forget kbAgent dispatch, the 23505→409 race-safe rename path, and the ON DELETE RESTRICT / CASCADE chain.
NewFolderDialog → FolderNameDialog with mode: "create" | "edit" — same input + 409 UI for both, reuses the dialog shell. Edit is dispatched from a new folder-row dropdown item. Doc row gets flex items-center on the three plain-text cells (title / type / timestamp) so they share the same vertical center as the StatusBadge + size-7 icon buttons.
Single a11y-safe rule on [role="tab"] > span:not(.sr-only) hides tab text below 768px while keeping it in the a11y tree (Tailwind sr-only technique: clip-path + absolute position). Replaces the per-tab dual-span pattern (hidden md:inline + sr-only md:hidden) with one <span>Text</span> across all 5 settings tabs.
Move the VLM_PAGE_PROMPT into backend/prompt/system.ts as KB_VLM_PAGE_PROMPT (alongside the other agent prompts) and assemble the per-page call with the langchain SystemMessage + HumanMessage constructors instead of hand-written {role,content} arrays. The system message is hoisted out of the per-page loop — every page shares the same prompt. Also drops the unused filename var and the leftover console.warn debug logs from vlmNode / screenshotNode.

Bundles the chunkEmbedStoreNode loop fix from earlier in this branch: appendKbRef now lands BEFORE the success/failure branch instead of only on the success + skipPipeline paths. The "empty markdown after VLM" failure path used to skip the kb_ref append — which made the parent router re-route to kbAgent on the next pass (file part still present), looping forever. The fix is in the same diff because the vlmNode refactor exposed the symptom.
Replace the vlmNode response-shape heuristic (string|array-of-blocks|empty-string) with a zod-typed withStructuredOutput(vlmPageSchema, {method:'jsonSchema'}) binding. The schema's .describe() carries the same markdown-extraction instructions as KB_VLM_PAGE_PROMPT, so the JSON output is guaranteed to be {markdown: string} (or the call throws) — no silent empty-markdown footgun from a shape mismatch.

Mirrors the pattern in backend/node/thread-summarize-node.ts:319-331 (withStructuredOutput(summaryOutputSchema, {method:'jsonSchema'}). Same shape, same method — keeps the LLM-call surface uniform across nodes that need structured output.
…ug logs

renameThreadAgent is a parallel-fanout terminal node — it's only ever entered as part of routeAndMaybeRename's [renameThreadAgent, subAgent] array. Terminal nodes with no outgoing edge don't need an explicit .addEdge(node, END); langgraph treats them as implicit sinks and waits for sibling branches (chatAgent → triggerBackgroundAgent → END) to finish before terminating. Keeps the topology comment in sync with the actual graph.

Also clears the two console.warn(1111, ...) debug logs from routerAgentNode — same leftover-debug cleanup as c334888 did for kb-agent.ts.
The prompt describes the OCR task (extract text from a PDF page image, output markdown), not generic VLM capability. KB_OCR_* is task-scoped; getVlmModel() stays as the model-layer capability name (still a vision-capable chat model, could be reused for non-OCR vision tasks later). Adds a one-line comment in system.ts naming the split so the next person doesn't try to 'fix' the inconsistency.
The kb agent's only use of the model-layer vision capability is OCR — extract markdown text from rendered PDF page images. Rename every related symbol to make that task scope explicit:

- ModelKind enum: "vlm" → "ocr" (lib/provider/schema.ts). Ripples through provider.models.kind jsonb arrays — existing dev rows: zero (verified, tests self-isolate in beforeEach). New admin UI rows will use kind:"ocr" via the registry filter.
- Registry: getVlmModelFromDB → getOcrModelFromDB, kind:"vlm" → kind:"ocr" filter (lib/provider/model-registry.ts).
- Factory: getVlmModel → getOcrModel, buildEnvVlmModel → buildEnvOcrModel (backend/model.ts).
- Consumer: vlmNode → ocrNode, vlmPageSchema → ocrPageSchema, VLM_CONCURRENCY → OCR_CONCURRENCY, graph node id "vlm" → "ocr" (backend/agent/kb-agent.ts). Route function return type + conditional edges + addEdge all updated.
- Tests: kb-agent.test.ts + provider/model-registry.test.ts + kb/resolve.test.ts all updated to use the OCR names + fixtures ("OCR timed out" / "OCR error").

Comment-only sweeps in lib/kb/screenshot.ts (R2 upload feeds OCR model) + lib/r2/client.ts (one-shot PUT for OCR page renders) + backend/prompt/system.ts (KB_OCR_PAGE_PROMPT doc updated to reference ocrNode + getOcrModel).
Previously kbAgent processed only the LAST HumanMessage's PDF, and
resolveKbRefs only handled the LAST HumanMessage's kb_ref. Multi-PDF
messages and replayed-state scenarios silently dropped earlier content.

- resolveKbRefs now iterates every HumanMessage via collectKbRefs.
- kbAgent state is per-doc (pagesByDocId / chunksByDocId / processedFiles).
- Every PDF in every HumanMessage ends as kb_ref or stripped, atomically.
…sedPdf

After the kbAgent rewrite (previous commit), findLastHumanMessage,
getLastHumanContent, and appendKbRef are zero-callers in production
code. extractFilePart / extractKbRef were last-only signals that
missed unprocessed PDFs sitting in earlier HumanMessages.

- Router now checks all HumanMessages via hasUnprocessedPdf.
- Extract module slimmed: 11 public exports to 6 (5 deleted, 1 added).
- Net: -129 lines from extract.ts plus tests.
issue #13 - the runtime had three model pools (chat / ocr / embed) and
a 'kind: ModelKind[]' field on ModelConfig, but no admin surface to set
it and no seed to populate it. Old rows simply fell through the
registry's 'm.kind ?? ["chat"]' back-compat default, which made every
model chat-only and unreachable from getOcrModel / getEmbeddingModel.

- Zod: modelConfigSchema gains kind: ModelKind[].default(['chat']);
  POST fills the default server-side. New shared modelPatchSchema
  replaces the inline PATCH body - accepts an optional kind[] (min 1),
  no default, so PATCH stays idempotent and never overwrites.
- Seed runner: scripts/db-migrate.ts modelJson now writes
  { kind: ['chat'], ... } for the OPENAI_MODEL entry.
- API: PATCH /api/admin/providers/[id]/models/[modelName] imports the
  shared modelPatchSchema. 6 new tests cover POST default, POST
  multi-kind, POST/PATCH unknown-kind 400, PATCH persists, PATCH leaves
  kind untouched when omitted.
- Admin UI: /admin - Providers - Models section gains a Kind column
  (badges per kind, falls back to ['chat'] on legacy rows) and the
  ModelDialog gets a 3-checkbox multi-select. The 'uncheck the last
  box' gesture is a no-op so the API never receives kind: [].
- docs/APIS.md: documented kind field on POST + PATCH with the ['chat']
  default and PATCH's no-default semantics.

Verified: tsc clean, 1012/1012 tests (+6). Admin UI not yet
visually confirmed in a browser - surface for /admin check
(CLAUDE.md rule 4).
…block

- backend/agent/kb-agent.ts: hoist insertKbObservability out of the chunksOnly branch — both modes now push docId(s) onto observabilityDocIds and the function tail inserts one row per id. full-mode ingest now records kb_observability (was only chunksOnly/retryFailed before, so Settings uploads left the popover empty).
- components/settings/kb-view/dialogs.tsx: swap DocDeleteDialog's DialogDescription to <div> via asChild. The description contains <ol> + multiple <p> blocks which can't sit inside the default <p> rendering — triggered <div>-inside-<p> hydration errors.
- backend/agent/kb-agent.ts: collapse the waitForChunks decision from 'isStandalonePath = source in {kb-upload, kb-reprocess}' to 'source !== "chat"'. Defaults wait to true for every non-chat source; chat subgraph (which doesn't stamp source) falls through to the 'chat' default and keeps fire-and-forget so the reply flow isn't blocked. waitFromConfig stays as an escape hatch.
- lib/kb/ingest.ts: drop the explicit `waitForChunks: true` override — the source stamp is now the only signal the standalone path needs.
- lib/observability/transform.ts: fix two bugs in the root chain detector that made the KB Settings observability popover render 'No traces captured' despite aggregates (LLM calls, tokens) computing correctly:
  - collectRootChains: add a fallback that recognises top-level RunnableSequence wrappers (parent_span_id='__start__', langgraph_node='__start__', langgraph_step=0) as root chains. The primary filter only catches CompiledStateGraph wrappers from subgraph invokes, which LC doesn't fire for runs.create top-level invokes.
  - rootIdByRunId: key the lookup map by meta.run_id instead of span_id. Subgraph wrappers happen to satisfy span_id === run_id so the old keying worked by coincidence; top-level wrappers have an LC-generated span_id that never matches the step's run_id.
- PROVIDERS.md: kind 5-way split (chat/ocr/embed/extract/rerank),
  getOcr/Extract/Embedding/RerankModelFromDB wrappers, RerankModel,
  kind-prefixed cache key
- MEMORY.md: 2 graphs -> 3 graphs (add kbAgent to topology)
- KNOWLEDGE_BASE.md: fix lib/kb/resolve.ts path (was resolve-mentions.ts,
  file does not exist), MarkdownTextSplitter (was RecursiveCharacter),
  re-order misplaced Per-Doc Observability as section 11
- ATTACHMENTS.md: link out to KNOWLEDGE_BASE.md now that KB v3 ships
- CLAUDE.md: note kbAgent id-sync rule alongside 'agent'
@FireTable

Copy link
Copy Markdown
Owner Author

@greptile-apps review please

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 173 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread db/migrations/0009_serious_black_queen.sql
Comment thread db/migrations/0006_strong_karen_page.sql
Comment thread lib/kb/search.ts Outdated
Comment thread components/assistant-ui/kb-mention.tsx
Comment thread lib/kb/text.ts
Comment thread lib/kb/queries.ts
Comment thread lib/observability/aggregate.ts
Comment thread scripts/dev-stop.sh
Comment thread components/settings/kb-view/knowledge-graph.tsx
Comment thread backend/agent.ts
Comment thread lib/kb/search.ts Outdated
Comment thread lib/provider/model-registry.ts
Comment thread db/migrations/0005_past_grey_gargoyle.sql
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

PR #38 Review — KB v3

Reviewed the KB feature end-to-end (backend pipeline, provider 5-way kind split, observability, threads filter, API endpoints, frontend, CLAUDE.md compliance). The scope is large; design is solid (dedup, reprocess modes, RRF + Reranker, per-doc observability). Four blockers and several majors below should land before merge.

Blockers

  • B1. kbAgent standalone graph has no auth boundary. The Next.js route layer (POST /api/kb/upload, reprocess) is withAuth-wrapped, but langGraphClient.runs.create(..., "kbAgent", { config: { configurable: { userId } } }) trusts whatever userId is on the wire. A caller that can reach LANGGRAPH_API_URL can spoof config.configurable.userId = "victim-id" and ingest attacker-chosen content into the victim's KB namespace (data corruption / cross-user namespace injection). The chat resolver drops unknown kb_refs so this is corruption, not exfil — but it still violates the per-user boundary. Fix: have prepareKBDataNode (or a middleware) verify threads.userId === configurable.userId AND threads.kind === "kb".

  • B2. mode=retryFailed is a no-op due to the chunksOnly short-circuit at backend/agent/kb-agent.ts:521. The reprocess route returns 202 "OCR re-running on failed pages", but the early-return swallows it. generateChunkEmbedNode's isRetryFailedChunks branch finds zero status='parsing' chunks and exits. The doc is left at status='parsing' with no OCR work having run. Fix: narrow the chunksOnly guard, or drop mode=retryFailed and forward to mode=full.

  • B3. Full reprocess wipes data before validating the attachment (app/api/kb/documents/[id]/reprocess/route.ts:196-211). Order: (1) withKbTx { deleteKbChunksByDocumentId; resetKbDocumentForReprocess }, then (2) if (!doc.attachmentId) return 409 ATTACHMENT_MISSING. A 409 here means the derived data was already destroyed. Two clicks → permanent loss of indexed chunks. The 409 must be side-effect-free — validate attachment BEFORE entering the reset tx.

  • B4. modelKwargs: { reasoning_split: true } is hard-coded for every chat/ocr pick (lib/provider/model-registry.ts:250). CLAUDE.md says "strip when switching providers". The inline comment "only minimax reads this" understates the impact: the kwarg is sent for OpenAI/Anthropic/Google when an admin adds them via the provider UI. Strict upstreams (or future 4xx-on-unknown-fields adapters) will break. Mirror lib/credit/build-model.ts:40-46 and gate on picked.providerId === "minimax".

Major

  • M1. currentUserId module-level mutable in backend/tool/kb/user-id.ts is dead code in production (setKbToolUserId has zero prod callers) AND is a TOCTOU hazard under concurrent requests. Remove or wire up.
  • M2. CapturingHandler singleton race on currentParentMessageId (lib/observability/callback.ts:260 + :281-283 + :531) — concurrent invokes overwrite each other's parent message id. The DB backfill only handles NULL cases, not misattribution. Use Map<runId, string | null> keyed by ancestor chain.
  • M3. Missing FK on kb_observability.thread_id (db/migrations/0010_happy_thanos.sql:7 + lib/kb/schema.ts:233). Thread delete cascades observability_spans but leaves kb_observability rows pointing at a deleted thread — the popover returns a phantom threadId.
  • M4. invalidateModelCache(key) clears ALL round-robin counters (lib/provider/model-registry.ts:259-266) — wiping unrelated pool rotation state. nextTupleIndexByKey.delete(key) instead.
  • M5. getExtractModel lacks env fallback (backend/model.ts:110-112) — fresh deploy with no DB providers crashes on first ingest. Mirror getChatModel shape.
  • M6. RerankModel defaults to Cohere URL for non-Jina providers (lib/provider/model-registry.ts:150-157). Adding a Voyage provider silently 401s on Cohere. Require baseUrl on the picked tuple.
  • M7. R2 page PNGs are never cleaned up after doc delete/reprocess (lib/kb/queries.ts:613-636 + backend/agent/kb-agent.ts:457-459). Heavy KB users balloon R2 quota. Enqueue a R2 batch-delete on doc delete.
  • M8. mode query param is not validated — unknown values silently default to "full" (app/api/kb/documents/[id]/reprocess/route.ts:46-62). A typo retryFailedChunk triggers destructive full reprocess. Parse with z.enum([...]), return 400 INVALID_MODE.
  • M9. fireIngestionRun failure returns 202 — leaves the doc in a permanently unretryable state (app/api/kb/documents/[id]/reprocess/route.ts:106-129,168-188,212-228 + app/api/kb/upload/route.ts:92-104). Row stays at pending/parsing, then 409 PROCESSING blocks retry. Either mark failed and return 502/503, or only 202 when runs.create resolved.
  • M10. application/pdf MIME not enforced in /api/kb/upload (app/api/kb/upload/route.ts:35-42 + .env.example:172) — non-PDFs are accepted and stuck pending. Default R2_ALLOWED_CONTENT_TYPES excludes PDF, so Settings upload rejects PDFs at presign under default config. Add application/pdf to allowlist default + reject non-PDF in /api/kb/upload with 400 UNSUPPORTED_CONTENT_TYPE.

Frontend / Tool-UI (CLAUDE.md rules #6, #7)

  • components/settings/kb-view/doc-detail-dialog.tsx:490,564,713shadow-sm/hover:shadow-md on cards. Same in components/settings/kb-view/knowledge-graph.tsx:555,608,654,724,774.
  • components/tool-ui/kb/list-documents-card.tsx:47-61CollapsibleTrigger renders ChevronRightIcon + FolderIcon + folder name + count inside a single button (rule feat(memory): cross-conversation memory + thread summarize + dual-graph runtime #7).
  • components/settings/kb-view/doc-detail-dialog.tsx:449-464 — Copy button renders <Copy /> + "Copy" inside one button.
  • components/settings/kb-view/folder-sidebar.tsx:127-158 — dropdown trigger is opacity-0 until row-hover; keyboard users never see it. Add group-focus-within/folder:opacity-100.
  • components/landing/how-it-works.tsx:7-12 — all six landing demos (incl. new 499-line kb-explainer-demo + 8 lucide icons + framer-motion) are statically imported. Wrap with next/dynamic({ ssr: false }).

CLAUDE.md rule #1 (APIS.md sync) gaps

  • ?mention=1 documented as flat {docs:[…]} but route returns grouped {folders:[…]}.
  • Single-doc GET docs miss pages, entities, relationships, themes, per-chunk status/errorMessage, and call the position index instead of ordinal.
  • Full reprocess docs claim 202 returns mode but route returns {docId} only.
  • GET /api/kb/folders/[id] is undocumented.
  • in_flight_runs docs include thread_id/passthrough metadata but the route strips both.

Other (follow-up)

  • lib/kb/queries.ts:529-534 markAllKbChunksParsingForDocInTx and :572-578 getKbChunkFailureCount are exported but unreferenced in the diff — dead or future use; remove if unused.
  • app/api/kb/folders/route.ts:14-16 + app/api/kb/folders/[id]/route.ts:37-39 Zod .min(1).max(64).trim() ordering — .trim() runs AFTER length checks, so a 64-space string passes and gets normalized to empty. Use .trim().min(1).max(64).
  • app/api/kb/upload/route.ts:50-88 content-hash dedup race — two concurrent uploads both miss lookup, one gets 23505 instead of deduped 200. Handle wrapped .cause.code === "23505".
  • app/api/kb/folders/[id]/route.ts:86-99 folder delete race — concurrent doc insert triggers 23503 RESTRICT → undocumented 500. Use tx or catch wrapped 23503.
  • app/api/kb/folders/route.ts:37-44 + :72-75 dup-name handlers check only err.code; drizzle wraps 23505 under err.cause.code.
  • app/api/kb/documents/[id]/reprocess/route.ts:147-188 retryFailedChunks returns 202 on no-op when no failed chunks exist.
  • lib/kb/search.ts:411-414 textArrayLiteral escapes \\ and " only — defense-in-depth: also escape \n.
  • lib/kb/search.ts:404-406 vectorLiteral does not check Number.isFinite(v) — NaN/Infinity produces [NaN,Infinity,…] which pgvector rejects as 22P02.
  • lib/observability/queries.ts:13-44 FORBIDDEN_VALUE_RE is narrower than FORBIDDEN detection — a custom apikey/secret_key key logs a warn but is not actually masked.
  • lib/provider/model-registry.ts:159-171 RerankModel.rerank fetch has no AbortSignal.timeout.
  • lib/provider/model-registry.ts:351-357 (in search.ts) rerankResult.map(item => normalizedRows[item.index]) has no out-of-bounds guard.
  • lib/provider/model-registry.ts:67 stale "Four prefixes" comment — should be five.
  • lib/provider/model-registry.ts:274-276 resetRoundRobinCounters is redundant with invalidateModelCache().
  • backend/model.ts:118-124 getRerankModel wrapper is dead code (no importers).
  • backend/agent/kb-agent.ts:458 state.userId interpolated into R2 key path with no format validation (currently safe; defense-in-depth missing).
  • lib/kb/screenshot.ts:21-40 + lib/kb/text.ts:27-44 mupdf Document never destroyed — native memory leak per ingest.
  • app/api/threads/[id]/observability/[parentMessageId]/route.ts:26-39 + lib/observability/callback.ts:585-596 route comments claim spans persist on Start; callback only persists on End/Error. Pick a coherent lifecycle and update docs.
  • tests/lib/provider/model-registry.test.ts:245 describe block titled "kind partition (ocr / embed)" — extract + rerank paths uncovered.
  • components/assistant-ui/lexical-directive-chip.tsx:1 missing "use client" directive.
  • components/settings/kb-view/doc-detail-dialog.tsx:156-245 skeleton dialog missing aria-busy="true" on <DialogContent>.
  • components/settings/kb-view/doc-table.tsx:247-308 role="row" on wrapper div with no table/rowgroup ancestor (dangling ARIA).
  • components/settings/kb-view/knowledge-graph.tsx:236-254 dedupFingerprint recomputes on every poll.

Posting inline comments on the four blockers. Happy to follow up on any specific finding.

Comment thread backend/agent/kb-agent.ts
Comment thread app/api/kb/documents/[id]/reprocess/route.ts Outdated
Comment thread lib/provider/model-registry.ts
Comment thread lib/kb/ingest.ts
Comment thread app/api/kb/documents/[id]/reprocess/route.ts
Comment thread backend/tool/kb/user-id.ts
Comment thread components/tool-ui/kb/list-documents-card.tsx
Comment thread components/assistant-ui/lexical-directive-chip.tsx
Comment thread components/landing/how-it-works.tsx
Comment thread app/api/kb/upload/route.ts
Comment thread components/settings/kb-view/doc-detail-dialog.tsx
db/migrations/0005_*.sql runs CREATE EXTENSION IF NOT EXISTS vector,
which stock postgres:16-alpine does not bundle. KB v3 (issue #13)
added this migration; before the swap, every docker compose up / CI
build job died with `extension "vector" is not available`.

pgvector/pgvector:pg16 is the official pgvector image (FROM
postgres:16-bookworm + pgvector built from source). Wire-format
(POSTGRES_*, PGDATA, /docker-entrypoint-initdb.d/) is inherited
unchanged from upstream so no env / init scripts / migrations need
to change.

- docker-compose.yml: postgres service image + comment
- .github/workflows/CI.yml: build + test services
- docs/DEPLOY.md: deploy compose snippet + inline rationale
- docs/CI.md: local-verify build-pg + CI service refs
- docs/DB.md: image requirement callout under kb_chunk table
- skills/langgraph-app-maintain.md §5.6: bump-to-pg17 hint updated
  to track pgvector image line

Locally validated: 1165/1165 vitest pass against
pgvector/pgvector:pg16 with all 12 migrations applied (incl.
0005_*.sql CREATE EXTENSION vector + 0006 ALTER TYPE vector(1024)).
@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown

Too many files changed for review. (178 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

Comment thread lib/kb/search.ts Outdated
Comment thread lib/provider/model-registry.ts
Comment thread db/migrations/0005_past_grey_gargoyle.sql
Comment thread tests/lib/kb/search.test.ts
Comment thread lib/kb/queries.ts
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

PR 38 review (KB v3)

A lot of moving parts in this PR (~31k adds, 2 new graphs, 9 new routes, 3 split migrations). The KB feature is well-scoped overall — auth wrapping, cross-user 404s, dedup + idempotency on uploads, and the claude-runtime + withAuth patterns are all in place. Five things worth fixing before merge; the top three affect the primary value-add paths.

Critical (these break primary flows)

  1. lib/kb/search.ts:191 — vector leg dead code in search_kb tool path. Auto-embedded qvec is local; qvecLiteral reads args.qvec (always null from the tool handler). hasVec is false, the vec CTE is dropped, every search_kb call degrades to BM25 + tag only. Fix: const qvecLiteral = qvec != null ? vectorLiteral(qvec) : null;

  2. lib/provider/model-registry.ts:118-127getExtractModelFromDB chat fallback is unreachable. Missing await on getModelFromDB(...); the returned Promise's rejection bypasses the try/catch. Deployments without an extract-kind model error instead of falling back to chat. getChatModelFromDB works only because it's a one-liner async return (auto-unwraps); the bug is unique to the two-step fallback.

  3. db/migrations/0005_past_grey_gargoyle.sql:8 vs 0006_strong_karen_page.sql — column created as vector(1536) then immediately ALTER … SET DATA TYPE vector(1024). Normal pnpm db:migrate runs both so it's invisible, but a partial migration by hand leaves the schema on 1536 while the app inserts 1024-dim vectors → pgvector 22P02 on every chunk insert. Same user-visible failure as issue feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1 (vector leg silently disabled) from a totally different angle. Fix: collapse into one migration at the right dim, or add a loud dependency note.

Moderate

  1. tests/lib/kb/search.test.ts — the qvec bug has no test coverage. Every case passes qvec: makeEmbedding(...) explicitly, so the auto-embed path through the tool handler is never exercised. Mocking getEmbeddingModel is in place (line 17-27) but unused. A single test that calls hybridSearch({ query, userId }) without qvec and expects legsHit: ["vec"] would have caught this.

  2. lib/kb/queries.ts:469 — comment still says "1536-dim embedding". Schema is 1024. Cosmetic, but the dim reference in a comment will mislead the next person who touches findKbChunksContentByDocumentId.

Verified-clean areas (so authors know what was actually checked)

  • API auth: all 9 /api/kb/** routes wrapped with withAuth, cross-user doc/folder ids return 404, no auth leaks on the reprocess/observability routes.
  • User scoping: lib/kb/search.ts (valid_docs CTE, lines 301-304), lib/kb/queries.ts findKbDocumentById / findKbChunksByDocumentId / listKbDocumentsGroupedWithAttachment, and lib/kb/cache.ts (keyed by ${userId}:${docId}) all honor user.id. Cross-user kb_ref mentions are dropped to null in lib/kb/resolve.ts:124-128.
  • Docs sync (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1, [Bug]: Memory tab Socials row missing email per linked provider #10): docs/APIS.md § Knowledge Base covers all 9 endpoints with status codes + 409 discrimination; docs/TOOLS.md table updated for search_kb / list_documents; new docs/KNOWLEDGE_BASE.md (433 lines) plus cross-links in DB.md / OBSERVABILITY.md / PROVIDERS.md / MEMORY.md / ATTACHMENTS.md; CLAUDE.md doc-index row added; README.md bullet + link present. Two minor doc nits:
    • docs/TOOLS.md:109-110 references the source file as kb.ts, but the tool sources are now in backend/tool/kb/ (search-kb.ts, list-documents.ts).
  • Env contract (rule [Feat]: chat attachments backed by Cloudflare R2 #12): KB env reads go through lib/kb/env.ts (typed, validated, defaults). No new NEXT_PUBLIC_* introduced — R2_PUBLIC_BASE_URL is server-only (NextResponse + fireIngestionRun).
  • Reprocess modes: all 4 modes (full, chunksOnly, retryFailed, retryFailedChunks) gated cleanly with the documented 409 shapes (PROCESSING / ATTACHMENT_MISSING / NOT_READY); legacy ?chunksOnly=true still parsed.
  • fireIngestionRun uses multitaskStrategy: 'interrupt' (latest-wins on same thread) — correct vs triggerBackgroundAgentNode which uses 'enqueue'.

Out-of-scope but worth a follow-up

  • lib/kb/search.ts:33-38 flags kb_chunk.page_numbers as a missing column that's punted. UI degrades to "Page N" hover from kb_document.pages only. Fine for v3, will bite v4.
  • app/api/kb/documents/[id]/route.ts:46 — DELETE on a doc leaves the R2 attachment + kb-tmp/* PNGs behind ("v3 retention sweep can clean them up"). Worth filing as a leak until the sweep exists.

Posted inline comments at the four critical lines above.

…xtract fallback

B3 (claude review): full reprocess wiped chunks before validating
attachment. A doc whose R2 source was missing would lose its indexed
chunks on the first 409. Move the attachment check above the
destructive tx so 409 ATTACHMENT_MISSING is side-effect-free.

P1-1 (cubic): search_kb vector leg was dead code. qvecLiteral read
args.qvec directly, but the tool handler never pre-embeds — every
search_kb call degraded to BM25 + tag only. Use the local `qvec`
(which falls back to the auto-embedded value) so the vector leg
fires.

P1-2 (claude review): getExtractModelFromDB's chat fallback was
unreachable. The Promise from getModelFromDB was returned without
await inside try/catch, so the rejection escaped. Add `await` so
fresh installs without an extract-tagged model fall back to chat
instead of crashing.

Update tests/lib/kb/search.test.ts: the kw-only test pinned the
bug behavior (asserted legsHit excludes "vec"); auto-embed is
mocked deterministically so the same chunk now hits all 3 legs.

1165/1165 vitest pass locally against pgvector/pgvector:pg16.
@FireTable
FireTable merged commit cd0781a into main Jul 20, 2026
169 of 170 checks passed
@FireTable
FireTable deleted the feat/13-knowledge-base-agent branch July 20, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat]: knowledge-base agent (PDF/PNG/URL → markdown → RAG → graphRAG)

1 participant