Skip to content
27 changes: 25 additions & 2 deletions app/api/kb/documents/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { NextResponse } from "next/server";

import { withAuth } from "@/lib/auth/with-auth";
import { db } from "@/db/client";
import {
deleteKbDocumentForUser,
findKbChunksContentByDocumentId,
findKbDocumentById,
type KbChunkPreview,
} from "@/lib/kb/queries";
import { kbEntity, kbRelationship } from "@/lib/kb/schema";
import { and, eq, sql } from "drizzle-orm";

// ponytail: Settings → KB → doc detail (right pane). Returns the
// kb_document row + slim chunk preview (no 1536-dim embedding, no
Expand All @@ -19,8 +22,26 @@ export const GET = withAuth<{ id: string }>(async (_req, { user, params }) => {
return NextResponse.json({ code: "NOT_FOUND" }, { status: 404 });
}

const chunks: KbChunkPreview[] =
doc.status === "success" ? await findKbChunksContentByDocumentId(user.id, doc.id) : [];
const [chunks, entityCount, relationshipCount] = await Promise.all([

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Good fix on the cartesian-inflation issue. The two independent subqueries + coalesce(...,0) correctly give entityCount and relationshipCount as their true values, including when one side has zero rows. Confirmed via tests/api/kb/documents/[id].test.ts cases 1–3.

Two adjacent points worth flagging while this file is open, even if out of scope for the PR:

  1. status === "success" guard on findKbChunksContentByDocumentId is the only status filter — if a doc is in parsing/failed, chunks returns [], but entityCount/relationshipCount still query. For parsing/failed docs that's fine (the badge shows "Pending"/"Not Indexed" anyway), but the badge condition in components/tool-ui/kb/status-badge.tsx uses entityCount === 0 && relationshipCount === 0 → hasGraph=false to flip the badge to "Embedding". For a fresh doc with 0 chunks yet, entityCount/relationshipCount will both be 0, but the badge will render based on the doc-status branch (pending/parsing) above this and show "Pending" — which is correct by accident, not by design. A status-conditional gating on the count query would make the intent explicit.

  2. The Promise.all([...]).then((rows) => rows[0]?.n ?? 0) micro-promise pattern works but count(*) always returns a row in PG, so rows[0]?.n ?? 0 is paranoid — the ?? 0 is unreachable. Dropping it (or using Number(rows[0]!.n)) removes dead fallback code.

doc.status === "success"
? findKbChunksContentByDocumentId(user.id, doc.id)
: Promise.resolve([] as KbChunkPreview[]),
// ponytail: parallel counts for the doc-detail badge so the
// "Indexed" check can require entity/relationship rows in addition
// to embeddings (hybrid-search third leg). Two separate queries
// (not a leftJoin) — joining two one-to-many tables would cartesian
// the rows and inflate both counts.
db
Comment thread
FireTable marked this conversation as resolved.
Resolved
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
.select({ n: sql<number>`count(*)::int` })
.from(kbEntity)
.where(and(eq(kbEntity.documentId, doc.id), eq(kbEntity.userId, user.id)))
.then((rows) => rows[0]!.n),
db
.select({ n: sql<number>`count(*)::int` })
.from(kbRelationship)
.where(and(eq(kbRelationship.documentId, doc.id), eq(kbRelationship.userId, user.id)))
.then((rows) => rows[0]!.n),
]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

return NextResponse.json({
doc: {
Expand All @@ -35,6 +56,8 @@ export const GET = withAuth<{ id: string }>(async (_req, { user, params }) => {
pages: doc.pages,
createdAt: doc.createdAt.toISOString(),
updatedAt: doc.updatedAt.toISOString(),
entityCount,
relationshipCount,
},
chunks,
});
Expand Down
6 changes: 6 additions & 0 deletions app/api/kb/documents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ type GroupedDoc = {
updatedAt: string;
totalChunks?: number;
successChunks?: number;
embeddingPendingChunks?: number;
failedChunks?: number;
pendingChunks?: number;
parsingChunks?: number;
entityCount?: number;
relationshipCount?: number;
totalPages?: number;
failedPages?: number;
pendingPages?: number;
Expand Down Expand Up @@ -98,9 +101,12 @@ export const GET = withAuth(async (req: Request, { user }) => {
updatedAt: d.updatedAt.toISOString(),
totalChunks: d.totalChunks,
successChunks: d.successChunks,
embeddingPendingChunks: d.embeddingPendingChunks,
failedChunks: d.failedChunks,
pendingChunks: d.pendingChunks,
parsingChunks: d.parsingChunks,
entityCount: d.entityCount,
relationshipCount: d.relationshipCount,
totalPages: d.totalPages,
failedPages: d.failedPages,
pendingPages: d.pendingPages,
Expand Down
17 changes: 12 additions & 5 deletions app/api/kb/folders/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,23 @@ export const PATCH = withAuth<{ id: string }>(async (req, { user, params }) => {
// folder that still has docs. We surface that as 409 NON_EMPTY with
// a doc count, so the UI can render "delete its 3 docs first" instead
// of a generic FK-violation message.
export const DELETE = withAuth<{ id: string }>(async (_req, { user, params }) => {
export const DELETE = withAuth<{ id: string }>(async (req, { user, params }) => {
const folder = await findKbFolderById(user.id, params.id);
if (!folder) {
return NextResponse.json({ code: "NOT_FOUND" }, { status: 404 });
}
const docs = await listKbDocumentsByFolder(user.id, folder.id);
if (docs.length > 0) {
return NextResponse.json({ code: "NON_EMPTY", docCount: docs.length }, { status: 409 });

const url = new URL(req.url);
const deleteAll = url.searchParams.get("deleteAll") === "true";

if (!deleteAll) {
const docs = await listKbDocumentsByFolder(user.id, folder.id);
if (docs.length > 0) {
return NextResponse.json({ code: "NON_EMPTY", docCount: docs.length }, { status: 409 });
}
}
const deleted = await deleteKbFolderForUser(user.id, folder.id);

const deleted = await deleteKbFolderForUser(user.id, folder.id, { deleteAllDocs: deleteAll });
if (!deleted) {
return NextResponse.json({ code: "NOT_FOUND" }, { status: 404 });
}
Expand Down
13 changes: 12 additions & 1 deletion backend/node/kb/page-to-markdown-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,18 @@ export const ocrPageSchema = z.object({
export async function pageToMarkdownNode(
state: KbAgentStateShape,
): Promise<Partial<KbAgentStateShape>> {
if (state.mode === "chunksOnly") {
if (state.mode === "chunksOnly" || state.mode === "retryFailedChunks") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👀 Quick clarification on the comment-vs-code matchup on line 24:

if (state.mode === "chunksOnly" || state.mode === "retryFailedChunks") {
  // ... `retryFailed` is intentionally NOT in this list — its whole point is
  // to re-OCR the failed pages, so this node has to run.

state.retryFailed falls through to the OCR branch, so the comment is accurate. ✅

But the OCR branch's per-page skip —

if ((p.markdown ?? "").trim().length > 0 && !p.errorMessage) {
  return p;
}

— only skips successful pages. Pages in status: "failed" (with empty markdown) get sent through OCR again, which is what we want for retryFailed. However, pages with markdown.length > 0 but status: "failed" will be silently skipped (the !p.errorMessage guard passes either way). Worth confirming: in retryFailed we want to re-OCR any page whose prior markdown is empty or whose prior attempt errored — the current logic does this, but pages where the OCR returned non-empty markdown AND set an errorMessage are treated as fine. Tiny edge case, but probably worth a sentence in the mode comment.

Lower priority: split-file-page-node.ts is also skipped for retryFailed, so the pre-condition is that pagesByDocId is already populated by prepareKBDataNode → pulled from doc.pages. The pages column may include mixed-status pages; the new test in tests/backend/node/kb/page-to-markdown-node.test.ts exercises just one failed page — a test that mixes successful + failed pages in pagesByDocId would prove the per-page skip preserves the cached ones while re-OCRing only the failed ones.

// ponytail: `chunksOnly` reuses the cached pages[].markdown verbatim.
// `retryFailedChunks` keeps doc.status='success' so all pages have
// markdown; only failed chunks (per-chunk UPDATE pre-applied by the
// route) need re-extraction. `retryFailed` is intentionally NOT in
// this list — its whole point is to re-OCR the failed pages, so this
// node has to run. The per-page OCR skip below (markdown.trim() > 0
// AND no errorMessage) preserves successful pages while re-running
// OCR on any page with empty markdown OR a prior errorMessage,
// regardless of `status` — so `retryFailed`'s mixed-status
// pagesByDocId (some success, some failed) lands in the right lane
// per page.
return {
pagesByDocId: state.pagesByDocId,
processedFiles: state.processedFiles,
Expand Down
3 changes: 2 additions & 1 deletion backend/node/kb/prepare-kb-data-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export async function prepareKBDataNode(
if (!userId) return makeError("user not provided");

const source = config?.configurable?.source ?? "chat";
const parentMessageId = lastHumanMessageId(state.messages);
const parentMessageId =
config?.configurable?.parent_message_id ?? lastHumanMessageId(state.messages);
if (!parentMessageId) {
return makeError("kbAgent prepareKBDataNode: no HumanMessage to anchor parent_message_id on");
}
Expand Down
11 changes: 11 additions & 0 deletions backend/node/kb/split-file-page-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ import { updateKbDocumentStatus } from "@/lib/kb/queries";
export async function splitFileToPageNode(
state: KbAgentStateShape,
): Promise<Partial<KbAgentStateShape>> {
if (
state.mode === "chunksOnly" ||
state.mode === "retryFailed" ||
state.mode === "retryFailedChunks"
) {
return {
pagesByDocId: state.pagesByDocId,
processedFiles: state.processedFiles,
};
}

const newDocs = state.processedFiles.filter(
(p) => p.pipelineStatus === "new" && p.docId !== null && p.r2Key !== null,
);
Expand Down
6 changes: 6 additions & 0 deletions backend/tool/kb/list-documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@ function toListDoc(row: {
pendingPages?: number;
totalChunks?: number;
successChunks?: number;
embeddingPendingChunks?: number;
failedChunks?: number;
pendingChunks?: number;
parsingChunks?: number;
entityCount?: number;
relationshipCount?: number;
}): ListDocumentsDoc {
const totalPages = row.totalPages ?? 0;
const pending = row.pendingPages ?? 0;
Expand All @@ -80,9 +83,12 @@ function toListDoc(row: {
pendingPages: pending,
totalChunks: row.totalChunks ?? 0,
successChunks: row.successChunks ?? 0,
embeddingPendingChunks: row.embeddingPendingChunks ?? 0,
failedChunks: row.failedChunks ?? 0,
pendingChunks: row.pendingChunks ?? 0,
parsingChunks: row.parsingChunks ?? 0,
entityCount: row.entityCount ?? 0,
relationshipCount: row.relationshipCount ?? 0,
};
}

Expand Down
3 changes: 3 additions & 0 deletions backend/tool/kb/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,12 @@ export type ListDocumentsDoc = {
pendingPages: number;
totalChunks: number;
successChunks: number;
embeddingPendingChunks: number;
failedChunks: number;
pendingChunks: number;
parsingChunks: number;
entityCount: number;
relationshipCount: number;
};

export type ListDocumentsFolder = {
Expand Down
6 changes: 3 additions & 3 deletions components/assistant-ui/kb-mention.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,15 @@ const KbMentionPopoverHeader = () => {

if (scope.activeCategoryId) {
return (
<ComposerPrimitive.Unstable_TriggerPopoverBack className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-bold capitalize tracking-wider text-muted-foreground/60 border-b border-border/40 mb-1 hover:text-foreground transition-colors cursor-pointer sticky top-0 bg-popover z-10 shrink-0">
<ComposerPrimitive.Unstable_TriggerPopoverBack className="flex w-full items-center gap-1.5 px-2.5 py-2 text-[10px] font-bold capitalize tracking-wider text-muted-foreground/60 border-b border-border/40 mb-1 hover:text-foreground transition-colors cursor-pointer sticky top-0 bg-popover z-10 shrink-0">
<ChevronLeftIcon className="size-3" />
<span>{"Back"}</span>
</ComposerPrimitive.Unstable_TriggerPopoverBack>
);
}

return (
<div className="px-2.5 py-1.5 text-[10px] font-bold capitalize tracking-wider text-muted-foreground/60 border-b border-border/40 mb-1 sticky top-0 bg-popover z-10 shrink-0">
<div className="px-2.5 py-2 text-[10px] font-bold capitalize tracking-wider text-muted-foreground/60 border-b border-border/40 mb-1 sticky top-0 bg-popover z-10 shrink-0">
Knowledge Base
</div>
);
Expand Down Expand Up @@ -288,7 +288,7 @@ export const KbMentionPopover = memo(function KbMentionPopover() {
<ComposerPrimitive.Unstable_TriggerPopover
char="@"
adapter={bundle.adapter}
className="aui-composer-trigger-popover bg-popover text-popover-foreground absolute bottom-full left-0 z-50 mb-4 w-72 max-h-72 overflow-y-auto rounded-xl border bg-background p-1 shadow-lg flex flex-col gap-0.5"
className="aui-composer-trigger-popover bg-popover text-popover-foreground absolute bottom-full left-0 z-50 mb-4 w-72 max-h-72 overflow-y-auto rounded-xl border bg-background px-1 shadow-lg flex flex-col gap-0.5 pb-1"
>
<PopoverOpenWatcher onOpen={refetch} />
{/* Directive must always be mounted so the popover can open when @ is typed */}
Expand Down
Loading
Loading