[Feat]: knowledge-base agent (PDF/PNG/URL → markdown → RAG → graphRAG)#38
Conversation
…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>
…ers + screenshot Buffers
- 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'
|
@greptile-apps review please |
There was a problem hiding this comment.
All reported issues were addressed across 173 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
PR #38 Review — KB v3Reviewed the KB feature end-to-end (backend pipeline, provider 5-way Blockers
Major
Frontend / Tool-UI (CLAUDE.md rules #6, #7)
CLAUDE.md rule #1 (APIS.md sync) gaps
Other (follow-up)
Posting inline comments on the four blockers. Happy to follow up on any specific finding. |
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)).
|
Too many files changed for review. ( Bypass the limit by tagging |
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 Critical (these break primary flows)
Moderate
Verified-clean areas (so authors know what was actually checked)
Out-of-scope but worth a follow-up
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.
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 ofmainAgent(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_kbtool, LRU doc cache, LightRAG-style entity/relationship/theme extraction.lib/provider/model-registry.ts— five-waykindsplit (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-levelruns.createinvokes (Settings KB upload),aggregateRootnow consumes the transformed spans so failedCount is wire-accurate.lib/threads/{schema,queries}.ts—threads.kind('chat' | 'kb') hides KB ingestion threads from the chat sidebar; sidebar filter iskind='chat'.backend/observability/callback.ts—CapturingHandleris a process-global singleton; wired onmainAgent,backgroundAgent, andkbAgentstandalone compile.scripts/dev-stop.sh— replaced inlinepnpm dev:stop(kills tsx watch wrappers + sweeps ports 2024/3000/3001).scripts/db-migrate.ts— seededdefaultmodel getskind: ["chat"]soSELECT * FROM providerreads 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 ofsearch_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 becausecontentToPartsstrips 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 rightthreadId/parentMessageId.components/landing/{features,how-it-works,cta}.tsx— KB bento card + How-it-works explainer.API surface
/api/kb/{upload,documents,documents/[id],documents/[id]/reprocess,documents/[id]/observability,folders,folders/[id]}) — seedocs/APIS.md§ Knowledge Base.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.md—kb_folder/kb_document/kb_chunktables + 11 KB indexes.docs/APIS.md— all 9 KB endpoints +kind[]on the admin/api/admin/providers/**model endpoints.docs/OBSERVABILITY.md—KB ingestion runs (Settings → KB)section +Where capturingHandler is wired+kbAgentstandalone compile.docs/TOOLS.md—search_kb/list_documentstable + env knobs (Reranker, hybrid topK, mention budget).ALL_TOOLS→CHAT_TOOLS.docs/PROVIDERS.md— five-waykindsplit + new getters +RerankModelshape.docs/MEMORY.md— 2 graphs → 3 graphs (add kbAgent to topology).docs/ATTACHMENTS.md— link out toKNOWLEDGE_BASE.mdnow 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.ts—aggregateRoot(capturedSpans, transformedSpans)wire-accuracy.tests/lib/provider/model-registry.test.ts— five-way round-robin + cache invalidation.tests/lib/threads/queries.test.ts—kind='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_observabilityrow → popover list.Pre-commit hooks (
lint + typecheck + test) all green on the tip.Docs
Per
CLAUDE.mdrules #1 and #10 — every API endpoint underapp/api/is reflected indocs/APIS.md; every tool added is reflected indocs/TOOLS.md; new feature lives indocs/KNOWLEDGE_BASE.mdwith cross-links to DB / observability / providers / attachments. Doc index inCLAUDE.mdupdated.🤖 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
search_kbandlist_documentsreturn concise, folder‑grouped results.: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.full,chunksOnly,retryFailed,retryFailedChunks), with accurate per‑page and per‑chunk statuses.chat,ocr,embed,extract,rerank) with admin UI support, per‑kind pools, caching, and clean fallbacks; threads gainkind='chat' | 'kb'to hide ingest threads.Migration
pgvectorand create/alter KB tables/indexes (embedding dim 1024; entities tojsonb, relationships/themes added; per‑chunk status).kind[]forchat,ocr,embed,extract, and optionalrerank(admin UI supports it)..envas needed (topK, token budget, chunk size, rerank min score).rerankmodel entry; otherwise search falls back to RRF.Written for commit d94a16f. Summary will update on new commits.
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 localqvecvariable butqvecLiteralstill reads fromargs.qvec, which isnullon everysearch_kbtool call — the pgvector leg silently no-ops for all normal searches.lib/provider/model-registry.ts:getExtractModelFromDBusesreturn promiseinsidetry/catchwithoutawait; the fallback to the chat pool never runs when noextract-kind model is registered.0005createsembedding vector(1536)while the app expectsvector(1024);0006fixes 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
qveclocal variable is never used inqvecLiteral—args.qvecis checked instead, so the vector leg never fires in the normalsearch_kbcall path.getExtractModelFromDBfallback to chat pool is broken: missingawaitmeans thetry/catchnever intercepts the async rejection when no extract-tagged model exists.langGraphClient.runs.createwithmultitaskStrategy: 'interrupt'. Thread upsert, messageId stamping, and mode/source plumbing all look correct.?mode=and legacy?chunksOnly=trueparams correctly.CapturedSpan[]andWireSpanData[];countRootFailuresavoids double-counting by walking only leaf or childless-failed nodes. All callers updated correctly.embedding vector(1536)but the application usesEMBEDDING_DIM=1024; the corrective ALTER is split into a separate migration 0006, leaving a transient dimension mismatch window.kindcolumn (`'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%%{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 listPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "docs(kb): sync providers + memory topolo..." | Re-trigger Greptile