You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Now that users can upload attachments (see #12), the next step is to make those attachments searchable. This issue is for the knowledge-base pipeline that ingests uploaded files, converts them to clean markdown, runs RAG + graph-RAG over the chunks, and exposes a tool-call surface so the chat agent can ground its answers.
Relates to #12 — the KB is a downstream consumer of the R2 attachments. #12 ships the storage half; this issue ships the ingestion + retrieval half.
Goals
Ingestion pipeline: take an attachment row from [Feat]: chat attachments backed by Cloudflare R2 #12 (or a fresh URL), run it through PDF/OCR → markdown → chunks → embeddings → graph edges, and persist everything in Postgres.
Hybrid search: combine BM25 (Postgres tsvector) + vector similarity (pgvector) via Reciprocal Rank Fusion. Pure vector or pure BM25 is not enough; we want both.
Graph RAG: extract entities + relationships during ingestion, store an explicit edge graph in Postgres (no separate Neo4j). Retrieval should be able to traverse the graph starting from chunk hits.
Reuse community standards at every stage — don't write a PDF parser, don't write a chunker, don't write a graph extractor. Ship wiring code only.
Pipeline overview
[attachment row from #12 / new URL]
│
▼
(1) Source fetch ← R2 GET for uploaded files; HTTP fetch for URLs
│
▼
(2) Format detection ← content-type + magic bytes (libmagic)
│
▼
(3) Clean → Markdown ← Marker (PDF, esp. with images/formulas) / Turndown (HTML) /
│ plain read (txt, md); OCR via PaddleOCR / Tesseract for scans
▼
(4) Chunk ← semantic / recursive chunker; preserves heading + page boundaries
│
▼
(5) Embed ← OpenAI text-embedding-3-small (or BGE-M3 self-hosted)
│
▼
(6) Graph extract ← LightRAG or Microsoft GraphRAG entity/relation extraction;
│ OR a lighter prompt-based extractor for low-volume docs
▼
(7) Persist ← kb_documents / kb_chunks (pgvector) / kb_edges / kb_entities
│
▼
(8) Background re-index ← nightly job recomputes stale chunks (not part of MVP, separate issue)
Steps (1)-(7) run as a background job triggered by the upload webhook from #12, so the chat UI never blocks on ingestion. Progress is observable in the Memory tab (or a new "Knowledge" tab — design decision).
Stage-by-stage — community-standard picks
(3) Clean → Markdown
This is the highest-friction stage; PDF parsing with images + formulas is notoriously bad if you roll your own. Community standard in 2026:
PDF/DOCX/PPTX → structured markdown, deep table understanding
Strong on enterprise layouts (invoices, reports). Heavier than marker.
pymupdf4llm
Fast PDF → markdown via PyMuPDF; loses some structure
Good for trivial PDFs (text-only). Not enough for scanned or formula-heavy.
marker-pdf (newer name)
Same as marker
Track under the same project.
Pick: marker as the default; fall back to docling for documents where marker chokes (tables with merged cells). Don't write our own. Both are MIT/Apache.
OCR for scanned PDFs / images
The project is TS-driven, but the PDF/OCR ecosystem is Python. Three options:
Option
Pros
Cons
A. Sidecar Python service in docker-compose.yml
Run marker + PaddleOCR as a separate container, expose a small HTTP API. Most flexible.
One more service to operate.
B. Deno Deploy Sandbox for execute_code (already wired)
Already have it via #10 rule; can we run Python in it?
Deno Deploy Sandbox is TS-first; Python requires their Python sandbox beta — verify it handles PyTorch / heavy ML libs.
Recommendation: option A — a tiny Python sidecar (backend/kb/) running marker + a thin FastAPI. PyTorch image is heavy but well-known; Caddy / start.sh already manage multiple services, so adding one more is small. Mounted volume for any model weights.
Modern, very fast, semantic + recursive chunkers. Py-first.
LangChain RecursiveCharacterTextSplitter (TS)
Available today in our TS stack.
LlamaIndex SentenceSplitter
Py; ships with full LlamaIndex pipeline.
Pick: chonkie for its semantic chunker (preserves heading hierarchy from marker output). The TS LangChain splitter is fine as a fallback for pure-text inputs.
(5) Embeddings
Hosted: OpenAI text-embedding-3-small (1536 dims, cheap). Self-host: BGE-M3 via Hugging Face TEI.
Recommendation: hosted OpenAI for MVP (we already depend on it for the chat model — one fewer moving part). BGE-M3 swap is a config change later.
Tiny single-file implementation; educational, not production.
Pick: LightRAG for the MVP. Microsoft GraphRAG's community detection is overkill until we know our corpus shape. LightRAG also handles incremental updates, which we need because new uploads arrive continuously.
Entity + edge extraction happens during ingestion (LLM call per chunk batch), then persisted to kb_entities + kb_edges. Retrieval uses the resulting graph to expand a query's neighborhood.
Built-in tsvector + pg_trgm — BM25-style full-text + fuzzy. Don't pull in a separate search engine.
Reciprocal Rank Fusion (RRF) in a single SQL query or in TS — pick SQL so the SQL stays the source of truth for ranking.
DB schema additions
All under lib/kb/schema.ts (mirrors lib/memory/, lib/threads/). New tables:
// One row per ingested source.exportconstkbDocument=pgTable("kb_document",{id: text("id").primaryKey(),userId: text("user_id").notNull().references(()=>user.id,{onDelete: "cascade"}),// FK to attachment.id (#12) when ingested from an upload; null for URL-sourced.attachmentId: text("attachment_id").references(()=>attachment.id,{onDelete: "set null"}),// Source identity — null when attachmentId is set.sourceUrl: text("source_url"),title: text("title").notNull(),contentType: text("content_type").notNull(),markdown: text("markdown").notNull(),// cleaned output from (3)status: text("status").notNull(),// 'pending' | 'ingesting' | 'ready' | 'failed'errorMessage: text("error_message"),createdAt: timestamp("created_at").defaultNow().notNull(),updatedAt: timestamp("updated_at").defaultNow().notNull(),},(t)=>[index("kb_document_user_idx").on(t.userId)]);// Chunks with their embedding + tsvector.exportconstkbChunk=pgTable("kb_chunk",{id: text("id").primaryKey(),documentId: text("document_id").notNull().references(()=>kbDocument.id,{onDelete: "cascade"}),ordinal: integer("ordinal").notNull(),// order within the documentcontent: text("content").notNull(),embedding: vector("embedding",{dimensions: 1536}),// pgvectortsv: generatedAlwaysAs(tsvector("content")),// Postgres generated tsvectorcreatedAt: timestamp("created_at").defaultNow().notNull(),},(t)=>[index("kb_chunk_document_idx").on(t.documentId),index("kb_chunk_embedding_idx").using("hnsw",t.embedding.op("vector_cosine_ops")),// pgvector HNSWindex("kb_chunk_tsv_idx").using("gin",t.tsv),// GIN for tsvector]);// Entities extracted by LightRAG / equivalent.exportconstkbEntity=pgTable("kb_entity",{id: text("id").primaryKey(),documentId: text("document_id").notNull().references(()=>kbDocument.id,{onDelete: "cascade"}),name: text("name").notNull(),type: text("type"),// 'person' | 'org' | 'concept' | …description: text("description"),},(t)=>[index("kb_entity_document_idx").on(t.documentId),index("kb_entity_name_idx").on(t.name),]);// Edges (relations) between entities.exportconstkbEdge=pgTable("kb_edge",{id: text("id").primaryKey(),documentId: text("document_id").notNull().references(()=>kbDocument.id,{onDelete: "cascade"}),fromEntityId: text("from_entity_id").notNull().references(()=>kbEntity.id,{onDelete: "cascade"}),toEntityId: text("to_entity_id").notNull().references(()=>kbEntity.id,{onDelete: "cascade"}),relation: text("relation").notNull(),// 'works_for', 'cites', 'mentions', …weight: real("weight").default(1.0),},(t)=>[index("kb_edge_from_idx").on(t.fromEntityId),index("kb_edge_to_idx").on(t.toEntityId)]);// Citation back-reference — connects a chat message to the chunks that grounded it.exportconstkbCitation=pgTable("kb_citation",{id: text("id").primaryKey(),messageId: text("message_id").notNull(),// LangGraph message idchunkId: text("chunk_id").notNull().references(()=>kbChunk.id,{onDelete: "cascade"}),score: real("score").notNull(),// similarity / RRF scorerank: integer("rank").notNull(),// 0-indexed position in the merged ranking},(t)=>[index("kb_citation_message_idx").on(t.messageId),index("kb_citation_chunk_idx").on(t.chunkId)]);
user_id is on kb_document (not duplicated on chunks) — per-user isolation is enforced by joining through kb_document. Cross-user reads return 404 (rule from docs/AUTH.md).
// null when pgvector extension isn't available or KB tables are empty.exportconstsearchKbTool: StructuredTool|null=process.env.PG_EXTENSION_PGVECTOR
? tool(/* hybrid RRF query: BM25 + vector + graph expansion */,{name: "search_kb",schema: …})
: null;exportconstsearchGraphTool: StructuredTool|null=process.env.PG_EXTENSION_PGVECTOR
? tool(/* entity-seeded traversal */,{name: "search_graph",schema: …})
: null;exportconstlistDocumentsTool: StructuredTool|null=tool(/* paginated list of kb_document rows */,…);
When KB is empty / unconfigured, the model gracefully degrades — search_kb returns [], the agent continues without grounding (existing behavior). Per rule #10.
When the model cites a chunk, the ToolMessage payload should include the citation row ids so the frontend (or the citation-saving side-effect on triggerBackgroundAgentNode) can persist kb_citation rows. Update docs/TOOLS.md (rule #10) and docs/INTERRUPT.md (rule #10).
Frontend — Settings → Knowledge Base tab
Add a Knowledge Base tab under /settings, alongside the existing Memory tab. Style mirrors components/settings/memory-view.tsx (same bg-muted/30 chrome, same collapsible sections, same shadcn primitives — rule #11).
What it shows
For the current user only — userId is the only filter applied at the API and at the render layer:
Document list (paginated): one row per kb_document, columns
Per-user scoping is enforced server-side (where(eq(kbDocument.userId, user.id))), but the UI never suggests otherwise — there's no "share document" button, no org filter, no global search.
The lazy-loaded row detail matches lib/observability/transform.ts + components/observability/ — we don't ship the entire chunk list with every list response.
Routing
app/settings/page.tsx already hosts the tabs (Memory is one). The new tab uses the same basePaths={{ settings: "/settings" }} plumbing from app/auth-shell.tsx (which is currently ["memory"]-only) — extend the tabs config to include "knowledge-base", then mirror the memory-view.tsx component as components/settings/kb-view.tsx.
The AuthProvider's settings-tab system supports multiple tabs via the plugin pattern (see how memorySettingsPlugin is registered today); the KB tab is a sibling plugin / extension — keep it consistent with whatever pattern Memory uses, don't fork the layout.
Chat composer — @ mention a knowledge-base document
In addition to the settings tab, the chat composer must let users mention a KB document inline (à la Slack / Notion). Typing @ in the composer opens a popover listing the user's ready documents; selecting one inserts a chip that travels with the message and grounds the model's response.
assistant-ui's built-in mention system
Verified against @assistant-ui/react@0.14.26/dist/unstable/useMentionAdapter.d.ts (installed in this repo):
This returns { adapter, directive, iconMap, fallbackIcon } which spreads into <ComposerPrimitive.Unstable_TriggerPopover char="@" {...kbMention} />. The popover + arrow-key navigation + filtering come for free.
The mention directive rides the message as a directive string (e.g. @[kb-document:abc123]). Two viable resolutions; pick one and document it:
Server-side pre-fetch (recommended): the chat runtime intercepts the directive in backend/agent.ts (likely in callModelNode or a new pre-model hook), resolves the IDs against kb_document (user-scoped 404 check), runs the hybrid search / top-k chunk retrieval, and injects the chunks into the model context as a <mentioned-documents> block. Pro: deterministic, no tool round-trip latency, works with any model. Con: chat latency grows with top_k × chunk_size.
Tool-call resolution: strip the directive before sending, let the model see the bare @kb-document:abc123 token, and have it call search_kb({ documentId: "abc123" }). Pro: model picks what to read. Con: extra round-trips, and the model might skip the call.
Recommendation: option 1 — pre-fetch the first K chunks per mentioned doc, append a <mentioned-documents> system block. Keep top_k small (default 4) so latency stays bounded. The search_kb tool stays available for the model to dive deeper when the user asks follow-ups.
Edge cases
Multiple mentions in one message — pre-fetch each in parallel; budget the total chunks (e.g. min(top_k, 32 / mentionCount)) so the prompt doesn't explode.
Stale doc — if a kb_document is failed or deleted between mention and send, the directive resolves to a no-op + a soft warning appended to the system message.
Cross-user mention — already impossible: the popover only lists kb_document rows for the current userId; even if a crafted message arrived, the resolution query enforces the user filter (returns 404 → silently dropped).
Self-reference — a user mentioning their own document is the common case; no special handling needed.
Composer focus + accessibility — the popover is keyboard-navigable out of the box (assistant-ui's popover handles arrow keys, Enter, Esc). Don't override.
TDD
tests/frontend/chat/kb-mention.test.tsx — render the composer with unstable_useMentionAdapter mock-injected with two fake docs; type @; assert the popover shows the labels; arrow-down + Enter; assert the chip is inserted.
tests/backend/chat/resolve-mentions.test.ts — given a message containing @[kb-document:abc123]; assert the resolver calls searchKbTool's hybrid search and the resulting chunks land in the system message.
tests/backend/chat/resolve-mentions.test.ts — given an unknown / deleted / other-user's doc id; assert the mention is silently dropped + no tool call is made.
XSS: titles in the popover are rendered as text (assistant-ui handles this), but a render test confirms no <script> survives the metadata round-trip.
Acceptance (chat-mention specific)
Typing @ in the composer opens a popover listing only the current user's ready documents.
Selecting a doc inserts a chip; the chip travels with the message.
On send, the model receives the document's relevant chunks and cites them in its reply.
Settings → Knowledge Base tab renders with the same chrome as the Memory tab.
Lists only the current user's documents; no UI affordance to view other users' data.
Status icons match the four states; failed rows expose error_message on hover.
Row click lazy-loads the detail (chunks + entities + markdown preview); not in the initial list payload.
Empty state matches the existing memory-view.tsx empty-state style.
Delete + Retry actions call the right API routes (DELETE /api/kb/documents/[id], POST /api/kb/documents/[id]/retry).
XSS: title, source URL, and markdown preview are rendered as text (or sanitized markdown) — never as raw HTML. Add a render test with <script> in title.
Architecture choices to confirm before implementing
These are real decisions, not bikeshedding. Surface them so the user (or this issue's reviewer) can pick:
Python sidecar vs Deno Deploy Sandbox: confirmed sidecar — but verify Deno Deploy's Python sandbox tier can run marker + PyTorch before committing. If yes, drop the sidecar.
Embedding model: OpenAI text-embedding-3-small for MVP. BGE-M3 swap requires TEI container (one more service).
Graph RAG flavor: LightRAG. Microsoft GraphRAG's community detection is heavy and our corpus shape is unknown.
Chunking: chonkie semantic chunker, with LangChain recursive as a pure-text fallback.
Where the ingestion job runs: the existing background_agent graph (extend it) vs a new ingest_kb_agent graph registered in langgraph.json. Prefer the latter — clean separation, parallel branch.
Out of scope
OCR on screenshots inside PDFs — marker's image pipeline handles extraction, but we do NOT OCR the extracted images for inline text in this MVP. Future issue.
Multi-tenant scoping — KB is per-user only; no orgs / shared workspaces yet.
Incremental re-ingestion on file edit — re-ingestion is whole-document for now.
Citation UI in the chat thread — the data model is in place; rendering is a follow-up polish issue.
Query rewriting / HyDE / re-ranking — start with raw hybrid + graph expansion; add re-ranking once we have evaluation data.
Acceptance criteria
User uploads a PDF (the kind with tables, images, formulas) → it appears in the KB with searchable markdown.
User uploads a PNG / scanned image → OCR runs, text is searchable.
User pastes a URL → markdown is extracted and stored.
Document appears in the Settings → Knowledge Base tab with the correct status, scoped to the current userId only.
search_kb returns relevant chunks via hybrid (vector + BM25), and search_graph expands with entity neighbors.
Tool calls produce kb_citation rows linking chat message → chunk.
Per-user isolation: user A cannot see user B's documents (404, matching docs/AUTH.md).
KB unconfigured → tools gracefully no-op, model behavior unchanged.
Ingestion test (RED → GREEN): tests/api/kb/ingest.test.ts — mock the Python sidecar; assert that on POST /api/kb/ingest with an attachment id, the orchestrator:
Hybrid search test: tests/api/kb/search.test.ts — seed two documents with known chunks; query with a keyword + a paraphrased question; assert both BM25 and vector hits appear in the merged result, and that RRF rank is monotonic.
Graph search test: tests/api/kb/graph.test.ts — seed an entity graph; query with an entity mention; assert the traversal returns neighbor chunks.
Tool-call test: tests/backend/tools/search-kb.test.ts — assert searchKbTool is null when PG_EXTENSION_PGVECTOR is unset, and a working tool otherwise; mock the DB and assert the tool returns the expected schema.
Citation test: tests/api/kb/citations.test.ts — when search_kb is invoked via the chat runtime, the resulting ToolMessage carries citation ids; triggerBackgroundAgent (or the new ingest_kb_agent) persists them.
XSS coverage (carried over from [Feat]: chat attachments backed by Cloudflare R2 #12): markdown rendered from user-uploaded documents is sanitized before being inserted into a future thread UI; for now, the markdown itself is just stored, but list_documents returns titles / snippets that the UI must render as text, not HTML. Add a render test for that.
Frontend test: tests/frontend/settings/kb-view.test.tsx — render with a mocked list response (2 ready docs, 1 failed); assert status icons + per-user scoping (no other user's rows appear even if API returns them) + delete action. RED → GREEN.
Chat-mention test: tests/frontend/chat/kb-mention.test.tsx — typing @ opens the popover; arrow + Enter inserts a chip. RED → GREEN.
Mention resolver test: tests/backend/chat/resolve-mentions.test.ts — message with @[kb-document:abc123] resolves to chunks in the system prompt; unknown ids are dropped silently. RED → GREEN.
Notes for the implementer
Don't write a PDF parser. If marker doesn't fit on a document, swap to docling, but never write our own.
Don't write a chunker. chonkie + LangChain cover the cases. The "one true chunker" debate is a tarpit.
Don't write a graph extractor from scratch. LightRAG / GraphRAG are mature; even the prompt template is opinionated and worth reusing.
The Python sidecar is a thin shim — it should do marker --output md, embed via HTTP to OpenAI, and return JSON. The TS orchestrator owns chunking + DB writes. That keeps the hot path in our stack and the ML model I/O in Python.
Citation back-references are the bridge between this issue and [Feat]: chat attachments backed by Cloudflare R2 #12 — when kb_citation lands in the DB, the frontend can render hover-cards linking the message back to the source chunk / original file.
Per-user isolation is non-negotiable; cross-user reads must 404 (matches the existing thread-isolation pattern).
Related
[Feat]: chat attachments backed by Cloudflare R2 #12 — chat attachments backed by Cloudflare R2 (the upstream source of attachment rows this issue ingests; the chat-mention popover reads from this same storage pipeline once KB ingestion is wired).
backend/store.ts — existing Postgres-backed store for memory + thread summaries; KB reuses the same DB but adds the pgvector extension.
backend/background-agent.ts — existing turn-end side-effect graph; the new ingest_kb_agent mirrors this pattern.
Feature: knowledge-base agent (PDF/PNG/URL → markdown → RAG → graphRAG)
Now that users can upload attachments (see #12), the next step is to make those attachments searchable. This issue is for the knowledge-base pipeline that ingests uploaded files, converts them to clean markdown, runs RAG + graph-RAG over the chunks, and exposes a tool-call surface so the chat agent can ground its answers.
Relates to #12 — the KB is a downstream consumer of the R2 attachments. #12 ships the storage half; this issue ships the ingestion + retrieval half.
Goals
attachmentrow from [Feat]: chat attachments backed by Cloudflare R2 #12 (or a fresh URL), run it through PDF/OCR → markdown → chunks → embeddings → graph edges, and persist everything in Postgres.tsvector) + vector similarity (pgvector) via Reciprocal Rank Fusion. Pure vector or pure BM25 is not enough; we want both.search_kb,search_graph,list_documents) that query the KB. Lazy-registered per rule [Bug]: Memory tab Socials row missing email per linked provider #10.Pipeline overview
Steps (1)-(7) run as a background job triggered by the upload webhook from #12, so the chat UI never blocks on ingestion. Progress is observable in the Memory tab (or a new "Knowledge" tab — design decision).
Stage-by-stage — community-standard picks
(3) Clean → Markdown
This is the highest-friction stage; PDF parsing with images + formulas is notoriously bad if you roll your own. Community standard in 2026:
markerby DataLabdoclingby IBMpymupdf4llmmarker-pdf(newer name)Pick:
markeras the default; fall back todoclingfor documents where marker chokes (tables with merged cells). Don't write our own. Both are MIT/Apache.OCR for scanned PDFs / images
The project is TS-driven, but the PDF/OCR ecosystem is Python. Three options:
docker-compose.ymlexecute_code(already wired)Recommendation: option A — a tiny Python sidecar (
backend/kb/) running marker + a thin FastAPI. PyTorch image is heavy but well-known; Caddy / start.sh already manage multiple services, so adding one more is small. Mounted volume for any model weights.(4) Chunking
chonkieRecursiveCharacterTextSplitter(TS)SentenceSplitterPick: chonkie for its semantic chunker (preserves heading hierarchy from marker output). The TS LangChain splitter is fine as a fallback for pure-text inputs.
(5) Embeddings
Hosted: OpenAI
text-embedding-3-small(1536 dims, cheap). Self-host: BGE-M3 via Hugging Face TEI.Recommendation: hosted OpenAI for MVP (we already depend on it for the chat model — one fewer moving part). BGE-M3 swap is a config change later.
(6) Graph RAG
This is where it's easy to over-engineer.
PropertyGraphIndex(Py)Pick: LightRAG for the MVP. Microsoft GraphRAG's community detection is overkill until we know our corpus shape. LightRAG also handles incremental updates, which we need because new uploads arrive continuously.
Entity + edge extraction happens during ingestion (LLM call per chunk batch), then persisted to
kb_entities+kb_edges. Retrieval uses the resulting graph to expand a query's neighborhood.Vector + BM25 store
Already running Postgres 16. Add:
pgvectorextension — vector similarity (<=>operator).tsvector+pg_trgm— BM25-style full-text + fuzzy. Don't pull in a separate search engine.Reciprocal Rank Fusion (RRF) in a single SQL query or in TS — pick SQL so the SQL stays the source of truth for ranking.
DB schema additions
All under
lib/kb/schema.ts(mirrorslib/memory/,lib/threads/). New tables:user_idis onkb_document(not duplicated on chunks) — per-user isolation is enforced by joining throughkb_document. Cross-user reads return 404 (rule fromdocs/AUTH.md).Add migrations; update
docs/DB.md(rule #1 spirit).Tool-call surface (rule #10 — lazy-register)
Add to
backend/tool/:When KB is empty / unconfigured, the model gracefully degrades —
search_kbreturns[], the agent continues without grounding (existing behavior). Per rule #10.When the model cites a chunk, the
ToolMessagepayload should include the citation row ids so the frontend (or the citation-saving side-effect ontriggerBackgroundAgentNode) can persistkb_citationrows. Updatedocs/TOOLS.md(rule #10) anddocs/INTERRUPT.md(rule #10).Frontend — Settings → Knowledge Base tab
Add a Knowledge Base tab under
/settings, alongside the existing Memory tab. Style mirrorscomponents/settings/memory-view.tsx(samebg-muted/30chrome, same collapsible sections, same shadcn primitives — rule #11).What it shows
For the current user only —
userIdis the only filter applied at the API and at the render layer:kb_document, columnsattachment(from [Feat]: chat attachments backed by Cloudflare R2 #12) orURLpending/ingesting/ready/failedwith iconpending—<Loader2 />spinneringesting—<Loader2 />+ progress bar (from the sidecar's progress callback if available)ready—<CheckCircle2 />failed—<AlertCircle />+ hover forerror_messagecreatedAt(relative —formatDistanceToNowstyle we already use elsewhere)components/observability/):search_graphaggregate.ts)Why this layout
memory-view.tsxso users (and the implementation cost) don't have to learn a new pattern. rule [Feat]: Public landing page at / (replace auto-redirect to /chat) #11 says "use the existing primitives."where(eq(kbDocument.userId, user.id))), but the UI never suggests otherwise — there's no "share document" button, no org filter, no global search.lib/observability/transform.ts+components/observability/— we don't ship the entire chunk list with every list response.Routing
app/settings/page.tsxalready hosts the tabs (Memory is one). The new tab uses the samebasePaths={{ settings: "/settings" }}plumbing fromapp/auth-shell.tsx(which is currently["memory"]-only) — extend the tabs config to include"knowledge-base", then mirror thememory-view.tsxcomponent ascomponents/settings/kb-view.tsx.The
AuthProvider's settings-tab system supports multiple tabs via the plugin pattern (see howmemorySettingsPluginis registered today); the KB tab is a sibling plugin / extension — keep it consistent with whatever pattern Memory uses, don't fork the layout.Chat composer —
@mention a knowledge-base documentIn addition to the settings tab, the chat composer must let users mention a KB document inline (à la Slack / Notion). Typing
@in the composer opens a popover listing the user'sreadydocuments; selecting one inserts a chip that travels with the message and grounds the model's response.assistant-ui's built-in mention system
Verified against
@assistant-ui/react@0.14.26/dist/unstable/useMentionAdapter.d.ts(installed in this repo):This returns
{ adapter, directive, iconMap, fallbackIcon }which spreads into<ComposerPrimitive.Unstable_TriggerPopover char="@" {...kbMention} />. The popover + arrow-key navigation + filtering come for free.References:
unstable_useMentionAdapter—dist/unstable/useMentionAdapter.d.ts:66-71.Unstable_Mentionshape —id | type | label | description? | icon? | metadata?.Unstable_TriggerPopoverprimitives —dist/primitives/composer.d.ts:23(underComposerPrimitive).Backend-side resolution of the mention
The mention directive rides the message as a directive string (e.g.
@[kb-document:abc123]). Two viable resolutions; pick one and document it:backend/agent.ts(likely incallModelNodeor a new pre-model hook), resolves the IDs againstkb_document(user-scoped 404 check), runs the hybrid search / top-k chunk retrieval, and injects the chunks into the model context as a<mentioned-documents>block. Pro: deterministic, no tool round-trip latency, works with any model. Con: chat latency grows withtop_k×chunk_size.@kb-document:abc123token, and have it callsearch_kb({ documentId: "abc123" }). Pro: model picks what to read. Con: extra round-trips, and the model might skip the call.Recommendation: option 1 — pre-fetch the first K chunks per mentioned doc, append a
<mentioned-documents>system block. Keeptop_ksmall (default 4) so latency stays bounded. Thesearch_kbtool stays available for the model to dive deeper when the user asks follow-ups.Edge cases
min(top_k, 32 / mentionCount)) so the prompt doesn't explode.kb_documentisfailedor deleted between mention and send, the directive resolves to a no-op + a soft warning appended to the system message.kb_documentrows for the currentuserId; even if a crafted message arrived, the resolution query enforces the user filter (returns 404 → silently dropped).TDD
tests/frontend/chat/kb-mention.test.tsx— render the composer withunstable_useMentionAdaptermock-injected with two fake docs; type@; assert the popover shows the labels; arrow-down + Enter; assert the chip is inserted.tests/backend/chat/resolve-mentions.test.ts— given a message containing@[kb-document:abc123]; assert the resolver callssearchKbTool's hybrid search and the resulting chunks land in the system message.tests/backend/chat/resolve-mentions.test.ts— given an unknown / deleted / other-user's doc id; assert the mention is silently dropped + no tool call is made.<script>survives the metadata round-trip.Acceptance (chat-mention specific)
@in the composer opens a popover listing only the current user'sreadydocuments.Acceptance (frontend-specific)
Settings → Knowledge Basetab renders with the same chrome as the Memory tab.error_messageon hover.memory-view.tsxempty-state style.DELETE /api/kb/documents/[id],POST /api/kb/documents/[id]/retry).<script>in title.Architecture choices to confirm before implementing
These are real decisions, not bikeshedding. Surface them so the user (or this issue's reviewer) can pick:
text-embedding-3-smallfor MVP. BGE-M3 swap requires TEI container (one more service).background_agentgraph (extend it) vs a newingest_kb_agentgraph registered inlanggraph.json. Prefer the latter — clean separation, parallel branch.Out of scope
Acceptance criteria
userIdonly.search_kbreturns relevant chunks via hybrid (vector + BM25), andsearch_graphexpands with entity neighbors.kb_citationrows linking chat message → chunk.docs/AUTH.md).pnpm lint,pnpm typecheck,pnpm testgreen; new tests cover ingestion (mocked Python sidecar) + hybrid search SQL + tool-call lazy registration.docs/APIS.mdupdated if new routes added (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).docs/TOOLS.mdupdated with the new tools (rule [Bug]: Memory tab Socials row missing email per linked provider #10).docs/DB.mdupdated with the new schema.docs/INTERRUPT.mdupdated if the citation save side-effect changes the interrupt flow.TDD plan (rule #2)
tests/api/kb/ingest.test.ts— mock the Python sidecar; assert that onPOST /api/kb/ingestwith an attachment id, the orchestrator:/cleanendpoint.kb_document,kb_chunk,kb_entity,kb_edgerows.ready.tests/api/kb/search.test.ts— seed two documents with known chunks; query with a keyword + a paraphrased question; assert both BM25 and vector hits appear in the merged result, and that RRF rank is monotonic.tests/api/kb/graph.test.ts— seed an entity graph; query with an entity mention; assert the traversal returns neighbor chunks.tests/backend/tools/search-kb.test.ts— assertsearchKbToolisnullwhenPG_EXTENSION_PGVECTORis unset, and a working tool otherwise; mock the DB and assert the tool returns the expected schema.tests/api/kb/citations.test.ts— whensearch_kbis invoked via the chat runtime, the resultingToolMessagecarries citation ids;triggerBackgroundAgent(or the newingest_kb_agent) persists them.list_documentsreturns titles / snippets that the UI must render as text, not HTML. Add a render test for that.tests/frontend/settings/kb-view.test.tsx— render with a mocked list response (2 ready docs, 1 failed); assert status icons + per-user scoping (no other user's rows appear even if API returns them) + delete action. RED → GREEN.tests/frontend/chat/kb-mention.test.tsx— typing@opens the popover; arrow + Enter inserts a chip. RED → GREEN.tests/backend/chat/resolve-mentions.test.ts— message with@[kb-document:abc123]resolves to chunks in the system prompt; unknown ids are dropped silently. RED → GREEN.Notes for the implementer
markerdoesn't fit on a document, swap todocling, but never write our own.marker --output md, embed via HTTP to OpenAI, and return JSON. The TS orchestrator owns chunking + DB writes. That keeps the hot path in our stack and the ML model I/O in Python.nullwhen the KB isn't ready. The model should never see a tool that 500s.kb_citationlands in the DB, the frontend can render hover-cards linking the message back to the source chunk / original file.Related
attachmentrows this issue ingests; the chat-mention popover reads from this same storage pipeline once KB ingestion is wired).backend/store.ts— existing Postgres-backed store for memory + thread summaries; KB reuses the same DB but adds thepgvectorextension.backend/background-agent.ts— existing turn-end side-effect graph; the newingest_kb_agentmirrors this pattern.backend/tool/— lazy-registration site (rule [Bug]: Memory tab Socials row missing email per linked provider #10).docs/AUTH.md§ Data isolation — cross-user 404 rule.docs/DB.md— schema conventions; new tables follow the same shape.docs/TOOLS.md— tool inventory; must be updated with the new tools.docs/APIS.md— new ingest / search endpoints (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).docs/INTERRUPT.md— interrupt contract for the new tools.@assistant-ui/reactContentPart— citation UI will eventually render here.