diff --git a/CONTEXT.md b/CONTEXT.md index 80a0498..55f83eb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -133,7 +133,7 @@ summary, keywords, and connection metadata. ## Parsed Chunk Card A Parsed Chunk Card renders one Parsed Chunk. It owns chunk source metadata, -content rendering, summaries, keywords, artifact references, and sanitized +content rendering, extracted entities, artifact references, and sanitized table HTML for that card only. ## Chat Thread diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts index a56da3f..3eb138a 100644 --- a/src/components/chunks-panel.test.ts +++ b/src/components/chunks-panel.test.ts @@ -119,7 +119,8 @@ describe("ChunksPanel", () => { expect( await screen.findByRole("img", { name: "Page 4" }), ).toBeTruthy(); - expect(screen.getByText("image/png")).toBeTruthy(); + expect(screen.queryByText("Page image")).toBeNull(); + expect(screen.queryByText("image/png")).toBeNull(); expect(screen.queryByText("Budget")).toBeNull(); expect(screen.queryByTestId("chunk-card-shell-table_1")).toBeNull(); expect( @@ -885,11 +886,10 @@ describe("ChunksPanel", () => { selectListView(); const openOriginalButton = screen.getByRole("button", { - name: "Open original file", + name: "Original", }); - expect(openOriginalButton.className).toContain("font-normal"); - expect(openOriginalButton.className).not.toContain("font-semibold"); + expect(openOriginalButton).toBeTruthy(); await user.click(openOriginalButton); @@ -941,7 +941,7 @@ describe("ChunksPanel", () => { ); }); - it("opens the original PDF preview at the clicked chunk page", async () => { + it("opens the original PDF preview from the Original tab", async () => { mockVisibleVirtualViewport(); const user = userEvent.setup(); vi.stubGlobal( @@ -973,14 +973,10 @@ describe("ChunksPanel", () => { ); selectListView(); - await user.click( - screen.getByRole("button", { name: "Open page 2 in original file" }), - ); + await user.click(screen.getByRole("button", { name: "Original" })); expect(screen.getByRole("heading", { name: "Original File" })).toBeTruthy(); - expect(screen.getByTestId("source-original-preview").getAttribute( - "data-target-page", - )).toBe("2"); + expect(screen.getByTestId("source-original-preview")).toBeTruthy(); }); it("keeps the original PDF preview mounted when switching back to parsed chunks", async () => { @@ -1015,9 +1011,7 @@ describe("ChunksPanel", () => { ); selectListView(); - await user.click( - screen.getByRole("button", { name: "Open page 2 in original file" }), - ); + await user.click(screen.getByRole("button", { name: "Original" })); const mountedOriginalPreview = screen.getByTestId("source-original-preview"); @@ -1194,8 +1188,9 @@ describe("ChunksPanel", () => { expect(financialSourcePanel.textContent).not.toContain( "TSLA-Q4-2025-Update.pdf", ); + expect(storageSourcePanel.textContent).toContain("OPERATIONAL SUMMARY"); expect(storageSourcePanel.textContent).toContain( - "OPERATIONAL SUMMARY / Energy generation and storage", + "Energy generation and storage", ); expect(storageSourcePanel.textContent).not.toContain("Default_Root"); expect(storageSourcePanel.textContent).not.toContain( @@ -1203,7 +1198,7 @@ describe("ChunksPanel", () => { ); }); - it("renders text chunks with structured source, summary, content, and keyword sections", () => { + it("renders text chunks with source, unlabeled content, and entity tags", () => { mockVisibleVirtualViewport(); render( @@ -1231,20 +1226,18 @@ describe("ChunksPanel", () => { expect( screen.getByTestId("chunk-source-panel-text_1").textContent, ).not.toContain("TSLA-Q4-2025-UPDATE.PDF"); - expect(screen.getByTestId("chunk-summary-panel-text_1").textContent).toContain( - "Tesla continues to use its North American footprint", - ); + expect(screen.queryByTestId("chunk-summary-panel-text_1")).toBeNull(); + expect(screen.queryByText("Summary")).toBeNull(); + expect(screen.queryByText("Content")).toBeNull(); + expect(screen.queryByText("Keywords")).toBeNull(); expect(screen.getByTestId("chunk-content-panel-text_1").textContent).toContain( "Tesla is adding Supercharging and AI training capacity.", ); - expect(screen.getByTestId("chunk-keywords-panel-text_1").textContent).toContain( + expect(screen.getByTestId("chunk-entities-panel-text_1").textContent).toContain( "AI training capacity", ); - expect( - screen.getByTestId("chunk-keywords-panel-text_1").className, - ).toContain("bg-emerald-50/70"); - expect(screen.getByText("Robotaxi").className).toContain("bg-emerald-100/90"); - expect(screen.getByText("Robotaxi").className).toContain("text-emerald-800"); + expect(screen.getByText("Robotaxi").className).toContain("text-primary"); + expect(screen.getByText("Robotaxi").className).toContain("border-primary/35"); }); it("allows horizontal scrolling for wide chunk content", async () => { diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx index 84298bd..e9286dd 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -41,7 +41,6 @@ import type { ChatImageHighlightBox } from "@/domains/chat/types"; import { chunksPanelState } from "@/components/chunks-panel-state"; import { MAX_UPLOAD_MB } from "@/domains/sources/validation"; import { useSourceOriginalPreviewWarmup } from "@/components/source-original-preview-warmup"; -import { sourceOriginalPreviewModel } from "@/components/source-original-preview-model"; import type { ParsedChunkView } from "@/domains/chunks/types"; import type { SourceOriginalFileView, @@ -122,12 +121,6 @@ export function ChunksPanel({ ); const effectiveVisibleView = isPageAssetSource ? "parsed" : undefined; const originalPreviewCacheKey = selectedSourceFile?.url ?? null; - const isOriginalPreviewAvailable = - !isPageAssetSource && - sourceOriginalPreviewModel.canPreviewOriginalFile( - selectedSource, - selectedSourceFile, - ); const [mountedOriginalPreviewKey, setMountedOriginalPreviewKey] = useState< string | null >(null); @@ -142,11 +135,9 @@ export function ChunksPanel({ useState(sectionTreeDefaultZoomPercent); const { activeFocusedChunkId, - handleChunkSelected: selectChunk, handleOriginalViewSelected: selectOriginalView, handleParsedViewSelected, handleViewportScroll, - hasOriginalFile, hasOriginalView, measureVirtualChunkElement, originalTargetPageNumber, @@ -183,13 +174,6 @@ export function ChunksPanel({ } }, [originalPreviewCacheKey]); - const handleChunkSelected = useCallback( - (chunk: ParsedChunkView): void => { - rememberOriginalPreview(); - selectChunk(chunk); - }, - [rememberOriginalPreview, selectChunk], - ); const handleOriginalViewSelected = useCallback((): void => { rememberOriginalPreview(); selectOriginalView(); @@ -438,13 +422,7 @@ export function ChunksPanel({ focusedPageNumber={focusedPageNumber} focusedPageRequestId={focusedPageRequestId} highlightRegions={focusedHighlightRegions} - isOriginalPreviewAvailable={isOriginalPreviewAvailable} measureElement={measureVirtualChunkElement} - onChunkClick={ - hasOriginalFile && !isPageAssetSource - ? handleChunkSelected - : undefined - } onReferenceClick={requestChunkFocus} selectedSourceFile={selectedSourceFile} /> @@ -1223,9 +1201,7 @@ function VirtualChunkRow({ focusedPageNumber, focusedPageRequestId, highlightRegions, - isOriginalPreviewAvailable, measureElement, - onChunkClick, onReferenceClick, selectedSourceFile, }: { @@ -1236,9 +1212,7 @@ function VirtualChunkRow({ focusedPageNumber: number | null; focusedPageRequestId: number; highlightRegions: readonly ChatImageHighlightBox[]; - isOriginalPreviewAvailable: boolean; measureElement: (node: HTMLDivElement | null) => void; - onChunkClick?: (chunk: ParsedChunkView) => void; onReferenceClick: (chunkId: string) => void; selectedSourceFile: SourceOriginalFileView | null; }): ReactNode { @@ -1268,8 +1242,6 @@ function VirtualChunkRow({ focusedPageNumber={focusedPageNumber} focusedPageRequestId={focusedPageRequestId} highlightRegions={highlightRegions} - isOriginalPreviewAvailable={isOriginalPreviewAvailable} - onChunkClick={onChunkClick} onReferenceClick={onReferenceClick} sourceOriginalFile={selectedSourceFile} /> diff --git a/src/components/parsed-chunk-card-model.test.ts b/src/components/parsed-chunk-card-model.test.ts index 8384c5f..dd559b4 100644 --- a/src/components/parsed-chunk-card-model.test.ts +++ b/src/components/parsed-chunk-card-model.test.ts @@ -17,8 +17,10 @@ describe("parsedChunkCardModel", () => { expect(metadata).toEqual({ pageLabel: "Pages 2, 4, 8-9", - sectionLabel: "FINANCIAL SUMMARY", + sectionLabel: "Root / FINANCIAL SUMMARY", + sectionSegments: ["Root", "FINANCIAL SUMMARY"], typeLabel: "Text", + leadLabel: "Pages 2, 4, 8-9", }) }) @@ -33,18 +35,21 @@ describe("parsedChunkCardModel", () => { expect(metadata).toEqual({ pageLabel: "Pages 4-6", - sectionLabel: "pages/4-6", + sectionLabel: "Root / pages/4-6", + sectionSegments: ["Root", "pages/4-6"], typeLabel: "Page", + leadLabel: "Pages 4-6", }) }) - it("uses Page N and the parse path for page-asset cards", () => { + it("uses Page N and the hierarchical section path for page-asset cards", () => { const metadata = parsedChunkCardModel.getSourceMetadata( makeChunk({ type: "page", pageNums: [4], filePath: "pages/page-000004.png", - sectionPath: "Page 4", + sectionPath: + "Default_Root/Micron Q1-26 Earnings Deck_R.pdf-->Safe harbor statement", pageAssets: [ { pageNumber: 4, @@ -57,11 +62,48 @@ describe("parsedChunkCardModel", () => { expect(metadata).toEqual({ pageLabel: "Page 4", - sectionLabel: "pages/page-000004.png", + sectionLabel: "Root / Safe harbor statement", + sectionSegments: ["Root", "Safe harbor statement"], typeLabel: "Page", + leadLabel: "Page 4", }) }) + it("extracts unique entity tags from typed Knowhere entities", () => { + const tags = parsedChunkCardModel.getEntityTags( + makeChunk({ + keywords: ["Robotaxi"], + entities: [ + { text: "Securities and Exchange Commission", type: "organization" }, + { text: "Form 10-K", type: "document" }, + { text: "Form 10-K", type: "document" }, + { text: " ", type: "organization" }, + ], + }), + ) + + expect(tags).toEqual([ + { + text: "Securities and Exchange Commission", + type: "organization", + }, + { text: "Form 10-K", type: "document" }, + ]) + }) + + it("falls back to keyword texts as entity tags when entities are missing", () => { + const tags = parsedChunkCardModel.getEntityTags( + makeChunk({ + keywords: ["Robotaxi", "Supercharging", "Robotaxi", " "], + }), + ) + + expect(tags).toEqual([ + { text: "Robotaxi", type: null }, + { text: "Supercharging", type: null }, + ]) + }) + it("splits text content into text and reference parts with display-ready labels", () => { const parts = parsedChunkCardModel.getTextContentParts( makeChunk({ diff --git a/src/components/parsed-chunk-card-model.ts b/src/components/parsed-chunk-card-model.ts index 7b5d652..49696d4 100644 --- a/src/components/parsed-chunk-card-model.ts +++ b/src/components/parsed-chunk-card-model.ts @@ -9,7 +9,14 @@ import type { type ChunkSourceMetadata = { readonly pageLabel: string | null readonly sectionLabel: string | null + readonly sectionSegments: readonly string[] readonly typeLabel: string + readonly leadLabel: string +} + +type ChunkEntityTag = { + readonly text: string + readonly type: string | null } type TextChunkContentPart = @@ -28,6 +35,7 @@ type TextChunkContentPart = type ParsedChunkCardModelModule = { readonly getChunkTypeLabel: (type: ParsedChunkView["type"]) => string + readonly getEntityTags: (chunk: ParsedChunkView) => readonly ChunkEntityTag[] readonly getFocusCardClasses: (isFocused: boolean) => string readonly getSanitizedTableHtml: (content: string) => string | null readonly getSourceMetadata: (chunk: ParsedChunkView) => ChunkSourceMetadata @@ -57,31 +65,87 @@ const tableAllowedAttributes = [ ] as const function getSourceMetadata(chunk: ParsedChunkView): ChunkSourceMetadata { - if (chunk.type === "page") { - const pageFromAssets = chunk.pageAssets?.[0]?.pageNumber - const parsePath = - getTrimmedParsePath(chunk.filePath) ?? - chunksPanelState.formatChunkSectionPath(chunk.sectionPath) - - return { - pageLabel: pageFromAssets - ? `Page ${pageFromAssets}` - : formatPageNumbers(chunk.pageNums), - sectionLabel: parsePath, - typeLabel: getChunkTypeLabel(chunk.type), - } - } + const typeLabel = getChunkTypeLabel(chunk.type) + const pageFromAssets = + chunk.type === "page" ? chunk.pageAssets?.[0]?.pageNumber : undefined + const pageLabel = pageFromAssets + ? `Page ${pageFromAssets}` + : formatPageNumbers(chunk.pageNums) + const sectionLabel = formatCardSectionPath(chunk) return { - pageLabel: formatPageNumbers(chunk.pageNums), - sectionLabel: chunksPanelState.formatChunkSectionPath(chunk.sectionPath), - typeLabel: getChunkTypeLabel(chunk.type), + pageLabel, + sectionLabel, + sectionSegments: getSectionPathSegments(sectionLabel), + typeLabel, + leadLabel: pageLabel ?? typeLabel, } } -function getTrimmedParsePath(value: string | null | undefined): string | null { - const trimmed = value?.trim() ?? "" - return trimmed.length > 0 ? trimmed : null +function getSectionPathSegments( + sectionLabel: string | null, +): readonly string[] { + if (!sectionLabel) return [] + + return sectionLabel + .split(" / ") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) +} + +function formatCardSectionPath(chunk: ParsedChunkView): string | null { + const formattedPath = chunksPanelState.formatChunkSectionPath(chunk.sectionPath) + if (formattedPath) { + return prefixKnowhereRoot(chunk.sectionPath, formattedPath) + } + + const parsePath = chunk.filePath?.trim() ?? "" + return parsePath.length > 0 ? parsePath : null +} + +function prefixKnowhereRoot( + sectionPath: ParsedChunkView["sectionPath"], + formattedPath: string, +): string { + const trimmedSectionPath = sectionPath?.trim() ?? "" + if (!trimmedSectionPath.startsWith("Default_Root")) return formattedPath + if (formattedPath === "Root" || formattedPath.startsWith("Root / ")) { + return formattedPath + } + + return `Root / ${formattedPath}` +} + +function getEntityTags(chunk: ParsedChunkView): readonly ChunkEntityTag[] { + const seenTexts = new Set() + const tags: ChunkEntityTag[] = [] + + for (const entity of chunk.entities ?? []) { + const text = entity.text.trim() + if (!text) continue + + const dedupeKey = text.toLowerCase() + if (seenTexts.has(dedupeKey)) continue + seenTexts.add(dedupeKey) + + const type = entity.type?.trim() || null + tags.push({ text, type }) + } + + if (tags.length > 0) return tags + + for (const keyword of chunk.keywords ?? []) { + const text = keyword.trim() + if (!text) continue + + const dedupeKey = text.toLowerCase() + if (seenTexts.has(dedupeKey)) continue + seenTexts.add(dedupeKey) + + tags.push({ text, type: null }) + } + + return tags } function getTextContentParts( @@ -181,6 +245,7 @@ function formatPageNumbers( export const parsedChunkCardModel: ParsedChunkCardModelModule = { getChunkTypeLabel, + getEntityTags, getFocusCardClasses, getSanitizedTableHtml, getSourceMetadata, diff --git a/src/components/parsed-chunk-card.test.ts b/src/components/parsed-chunk-card.test.ts index 8eab32e..544bc61 100644 --- a/src/components/parsed-chunk-card.test.ts +++ b/src/components/parsed-chunk-card.test.ts @@ -11,7 +11,7 @@ describe("ParsedChunkCard", () => { cleanup(); }); - it("renders text chunks with source, summary, content, and keywords", () => { + it("renders text chunks with source, content, and entity tags", () => { render( React.createElement(ParsedChunkCard, { chunk: { @@ -31,13 +31,14 @@ describe("ParsedChunkCard", () => { expect(screen.getByTestId("chunk-source-panel-text_1").textContent).toContain( "Capacity", ); - expect( - screen.getByTestId("chunk-summary-panel-text_1").textContent, - ).toContain("Tesla continues to add capacity."); + expect(screen.queryByTestId("chunk-summary-panel-text_1")).toBeNull(); + expect(screen.queryByText("Summary")).toBeNull(); + expect(screen.queryByText("Content")).toBeNull(); + expect(screen.queryByText("Keywords")).toBeNull(); expect(screen.getByTestId("chunk-content-panel-text_1").textContent).toContain( "Tesla is adding Supercharging and AI training capacity.", ); - expect(screen.getByTestId("chunk-keywords-panel-text_1").textContent).toContain( + expect(screen.getByTestId("chunk-entities-panel-text_1").textContent).toContain( "AI training capacity", ); expect(screen.getByTestId("chunk-card-shell-text_1").className).toContain( @@ -57,28 +58,82 @@ describe("ParsedChunkCard", () => { sourceTitle: "manual.pdf", sectionPath: "Default_Root/manual.pdf-->pages/4-6", pageNums: [4, 5, 6], - entities: [{ text: "refund", label: "topic" }], + entities: [{ text: "refund", type: "topic" }], }, isFocused: false, - isOriginalPreviewAvailable: true, - onChunkClick: vi.fn(), onReferenceClick: vi.fn(), }), ); expect(screen.getByTestId("chunk-source-panel-page_1").textContent).toContain( - "Page", + "Pages 4-6", ); expect(screen.getByTestId("chunk-source-panel-page_1").textContent).toContain( - "Pages 4-6", + "Root", + ); + expect(screen.getByTestId("chunk-source-panel-page_1").textContent).toContain( + "pages/4-6", ); expect(screen.getByTestId("chunk-content-panel-page_1").textContent).toContain( "The refund policy is summarized", ); + expect(screen.getByTestId("chunk-entities-panel-page_1").textContent).toContain( + "refund", + ); expect(screen.queryByTestId("chunk-summary-panel-page_1")).toBeNull(); expect( - screen.getByRole("button", { name: "Open page 4 in original file" }), - ).toBeTruthy(); + screen.queryByRole("button", { name: /original file/i }), + ).toBeNull(); + }); + + it("renders extracted entities as tags and hides duplicate keyword rows", () => { + render( + React.createElement(ParsedChunkCard, { + chunk: { + chunkId: "page_2", + type: "page", + content: "Safe harbor statement", + sourceTitle: "Micron Q1-26 Earnings Deck_R.pdf", + sectionPath: + "Default_Root/Micron Q1-26 Earnings Deck_R.pdf-->Safe harbor statement", + pageNums: [2], + keywords: [ + "Securities and Exchange Commission", + "Form 10-K", + "Forms ID-9", + ], + entities: [ + { text: "Securities and Exchange Commission", type: "organization" }, + { text: "Form 10-K", type: "document" }, + { text: "Forms ID-9", type: "document" }, + ], + pageAssets: [ + { + pageNumber: 2, + assetUrl: "https://assets.example/page-2.png", + contentType: "image/png", + }, + ], + }, + isFocused: false, + onReferenceClick: vi.fn(), + }), + ); + + const sourcePanel = screen.getByTestId("chunk-source-panel-page_2"); + expect(sourcePanel.textContent).toContain("Page 2"); + expect(sourcePanel.textContent).toContain("Root"); + expect(sourcePanel.textContent).toContain("Safe harbor statement"); + expect(sourcePanel.textContent).not.toContain("PAGE"); + expect(screen.getByRole("img", { name: "Page 2" })).toBeTruthy(); + expect(screen.queryByText("Page image")).toBeNull(); + expect(screen.queryByText("image/png")).toBeNull(); + expect(screen.getByTestId("chunk-entities-panel-page_2").textContent).toContain( + "Securities and Exchange Commission", + ); + expect(screen.getByText("Form 10-K")).toBeTruthy(); + expect(screen.getByText("Forms ID-9")).toBeTruthy(); + expect(screen.queryByTestId("chunk-keywords-panel-page_2")).toBeNull(); }); it("renders page citation assets instead of page summary content", () => { @@ -102,8 +157,6 @@ describe("ParsedChunkCard", () => { ], }, isFocused: false, - isOriginalPreviewAvailable: true, - onChunkClick: vi.fn(), onReferenceClick: vi.fn(), }), ); @@ -113,7 +166,8 @@ describe("ParsedChunkCard", () => { expect(pageImage.getAttribute("src")).toBe( "https://assets.example/page-4.png", ); - expect(screen.getByText("image/png")).toBeTruthy(); + expect(screen.queryByText("Page image")).toBeNull(); + expect(screen.queryByText("image/png")).toBeNull(); expect(screen.queryByText(/summary should not be primary/i)).toBeNull(); expect( screen.queryByRole("button", { name: /original file/i }), @@ -286,6 +340,8 @@ describe("ParsedChunkCard", () => { expect(image.getAttribute("src")).toBe( `/api/parsed-assets/inline?url=${encodeURIComponent(assetUrl)}`, ); + expect(screen.queryByText("Content")).toBeNull(); + expect(screen.queryByTestId("chunk-summary-panel-image_1")).toBeNull(); }); it("routes resolved artifact reference clicks to the target chunk", async () => { @@ -318,51 +374,17 @@ describe("ParsedChunkCard", () => { expect(onReferenceClick).toHaveBeenCalledWith("image_1"); }); - it("shows an explicit original preview button when chunk preview is available", async () => { - const user = userEvent.setup(); - const chunk = { - chunkId: "text_1", - type: "text" as const, - content: "Revenue details live on the second page.", - sourceTitle: "report.pdf", - pageNums: [2], - }; - const onChunkClick = vi.fn(); - - render( - React.createElement(ParsedChunkCard, { - chunk, - isFocused: false, - isOriginalPreviewAvailable: true, - onChunkClick, - onReferenceClick: vi.fn(), - }), - ); - - const openOriginalButton = screen.getByRole("button", { - name: "Open page 2 in original file", - }); - - expect(openOriginalButton.className).toContain("font-semibold"); - expect(openOriginalButton.className).toContain("text-primary"); - - await user.click(openOriginalButton); - expect(onChunkClick).toHaveBeenCalledWith(chunk); - expect(screen.getByTestId("chunk-card-shell-text_1").getAttribute("role")).toBeNull(); - }); - - it("hides the original file button when a chunk has no page numbers", () => { + it("does not show an original file button on parsed cards", () => { render( React.createElement(ParsedChunkCard, { chunk: { chunkId: "text_1", type: "text", - content: "Revenue details do not include page metadata.", + content: "Revenue details live on the second page.", sourceTitle: "report.pdf", + pageNums: [2], }, isFocused: false, - isOriginalPreviewAvailable: true, - onChunkClick: vi.fn(), onReferenceClick: vi.fn(), }), ); @@ -372,38 +394,6 @@ describe("ParsedChunkCard", () => { ).toBeNull(); }); - it("keeps original file buttons quiet when preview is not supported", async () => { - const user = userEvent.setup(); - const chunk = { - chunkId: "text_1", - type: "text" as const, - content: "Legacy report details.", - sourceTitle: "report.doc", - pageNums: [2], - }; - const onChunkClick = vi.fn(); - - render( - React.createElement(ParsedChunkCard, { - chunk, - isFocused: false, - isOriginalPreviewAvailable: false, - onChunkClick, - onReferenceClick: vi.fn(), - }), - ); - - const openOriginalButton = screen.getByRole("button", { - name: "Open original file", - }); - - expect(openOriginalButton.className).toContain("font-normal"); - expect(openOriginalButton.className).not.toContain("font-semibold"); - - await user.click(openOriginalButton); - expect(onChunkClick).toHaveBeenCalledWith(chunk); - }); - it("sanitizes table HTML before rendering", () => { render( React.createElement(ParsedChunkCard, { diff --git a/src/components/parsed-chunk-card.tsx b/src/components/parsed-chunk-card.tsx index e32a664..28f8bc0 100644 --- a/src/components/parsed-chunk-card.tsx +++ b/src/components/parsed-chunk-card.tsx @@ -1,18 +1,9 @@ "use client"; import { useMemo, useState, type MouseEvent, type ReactNode } from "react"; -import { - FileSearch, - FileText, - ImageIcon, - ImageOff, - Table2, - Tags, - TextQuote, -} from "lucide-react"; +import { ImageIcon, ImageOff, Link2, Table2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { parsedChunkCardModel } from "@/components/parsed-chunk-card-model"; import { CitationRegionHighlight } from "@/components/citation-region-highlight"; @@ -21,10 +12,8 @@ import type { ParsedChunkView } from "@/domains/chunks/types"; import type { SourceOriginalFileView } from "@/domains/sources/types"; import { cn } from "@/lib/utils"; -const keywordPanelClassName = - "rounded-lg border border-emerald-200/70 bg-emerald-50/70 p-3 shadow-[0_1px_0_rgba(16,185,129,0.08)] dark:border-emerald-400/20 dark:bg-emerald-950/20"; -const keywordBadgeClassName = - "rounded-md border border-emerald-200/80 bg-emerald-100/90 px-2 py-0.5 text-[11px] font-semibold text-emerald-800 shadow-[0_1px_0_rgba(16,185,129,0.10)] hover:bg-emerald-100 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-200"; +const entityBadgeClassName = + "rounded-md border border-primary/35 bg-background px-2 py-0.5 text-[11px] font-medium text-primary"; type TextChunkReferencePart = Extract< ReturnType[number], { readonly type: "reference" } @@ -37,8 +26,6 @@ export function ParsedChunkCard({ focusedPageNumber = null, focusedPageRequestId = 0, highlightRegions = [], - isOriginalPreviewAvailable = false, - onChunkClick, onReferenceClick, sourceOriginalFile = null, }: { @@ -48,8 +35,6 @@ export function ParsedChunkCard({ readonly focusedPageNumber?: number | null; readonly focusedPageRequestId?: number; readonly highlightRegions?: readonly ChatImageHighlightBox[]; - readonly isOriginalPreviewAvailable?: boolean; - readonly onChunkClick?: (chunk: ParsedChunkView) => void; readonly onReferenceClick: (chunkId: string) => void; readonly sourceOriginalFile?: SourceOriginalFileView | null; }): ReactNode { @@ -63,8 +48,6 @@ export function ParsedChunkCard({ focusedPageNumber={focusedPageNumber} focusedPageRequestId={focusedPageRequestId} highlightRegions={highlightRegions} - isOriginalPreviewAvailable={isOriginalPreviewAvailable} - onChunkClick={onChunkClick} /> ); @@ -77,8 +60,6 @@ export function ParsedChunkCard({ isFocused={isFocused} focusedPageRequestId={focusedPageRequestId} highlightRegions={highlightRegions} - isOriginalPreviewAvailable={isOriginalPreviewAvailable} - onChunkClick={onChunkClick} sourceOriginalFile={sourceOriginalFile} /> @@ -87,12 +68,7 @@ export function ParsedChunkCard({ if (chunk.type === "table") { return ( - + ); } @@ -101,8 +77,6 @@ export function ParsedChunkCard({ @@ -129,29 +103,21 @@ function ChunkCardShell({ function ChunkCardFrame({ chunk, isFocused, - isOriginalPreviewAvailable, - onChunkClick, children, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; - readonly isOriginalPreviewAvailable: boolean; - readonly onChunkClick?: (chunk: ParsedChunkView) => void; readonly children: ReactNode; }): ReactNode { return ( + - {children} @@ -160,185 +126,99 @@ function ChunkCardFrame({ function ChunkSourcePanel({ chunk, - isOriginalPreviewAvailable, - onChunkClick, }: { readonly chunk: ParsedChunkView; - readonly isOriginalPreviewAvailable: boolean; - readonly onChunkClick?: (chunk: ParsedChunkView) => void; }): ReactNode { const sourceMetadata = parsedChunkCardModel.getSourceMetadata(chunk); - const firstPageNumber = getFirstValidPageNumber(chunk); + const lastSegmentIndex = sourceMetadata.sectionSegments.length - 1; return (
-
-
-
- {renderChunkIcon(chunk.type)} -
-
-
- + {sourceMetadata.leadLabel} + + {sourceMetadata.sectionSegments.length > 0 ? ( +
- {sourceMetadata.sectionLabel ? ( -

- {sourceMetadata.sectionLabel} -

- ) : null} -
-
- {onChunkClick && - firstPageNumber !== null && - !hasPageCitationAssets(chunk) ? ( - - ) : null} -
-
- ); -} - -function getFirstValidPageNumber(chunk: ParsedChunkView): number | null { - const pageNums = chunk.pageNums ?? []; - const validPageNums = pageNums.filter( - (pageNum) => Number.isFinite(pageNum) && pageNum > 0, - ); - if (validPageNums.length === 0) return null; - - return Math.min(...validPageNums); -} - -function OpenOriginalButton({ - chunk, - firstPageNumber, - isOriginalPreviewAvailable, - onChunkClick, -}: { - readonly chunk: ParsedChunkView; - readonly firstPageNumber: number; - readonly isOriginalPreviewAvailable: boolean; - readonly onChunkClick: (chunk: ParsedChunkView) => void; -}): ReactNode { - return ( - - ); -} - -function getOpenOriginalButtonLabel( - firstPageNumber: number, - isOriginalPreviewAvailable: boolean, -): string { - if (!isOriginalPreviewAvailable) return "Open original file"; - - return `Open page ${firstPageNumber} in original file`; -} - -function ChunkSummaryPanel({ - chunk, -}: { - readonly chunk: ParsedChunkView; -}): ReactNode { - if (!chunk.summary) return null; - - return ( -
- } label="Summary" /> -

- {chunk.summary} -

); } function ChunkContentPanel({ chunk, - label = "Content", children, }: { readonly chunk: ParsedChunkView; - readonly label?: string; readonly children: ReactNode; }): ReactNode { return (
- } label={label} /> -
{children}
+ {children}
); } -function ChunkKeywords({ +function ChunkEntities({ chunk, }: { readonly chunk: ParsedChunkView; }): ReactNode { - if (!chunk.keywords || chunk.keywords.length === 0) return null; + const entities = parsedChunkCardModel.getEntityTags(chunk); + if (entities.length === 0) return null; return (
} - label="Keywords" - className="text-emerald-800 dark:text-emerald-200" - iconClassName="text-emerald-600 dark:text-emerald-300" + icon={} + label="Entities" />
- {chunk.keywords.map((keyword) => ( + {entities.map((entity) => ( - {keyword} + {entity.text} ))}
@@ -349,22 +229,13 @@ function ChunkKeywords({ function SectionLabel({ icon, label, - className, - iconClassName, }: { readonly icon: ReactNode; readonly label: string; - readonly className?: string; - readonly iconClassName?: string; }): ReactNode { return ( -
- {icon} +
+ {icon} {label}
); @@ -373,30 +244,20 @@ function SectionLabel({ function TextChunkCard({ chunk, isFocused, - isOriginalPreviewAvailable, - onChunkClick, onReferenceClick, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; - readonly isOriginalPreviewAvailable: boolean; - readonly onChunkClick?: (chunk: ParsedChunkView) => void; readonly onReferenceClick: (chunkId: string) => void; }): ReactNode { return ( - - +
           {renderTextChunkContent(chunk, onReferenceClick)}
         
- +
); } @@ -408,8 +269,6 @@ function PageChunkCard({ focusedPageNumber, focusedPageRequestId, highlightRegions, - isOriginalPreviewAvailable, - onChunkClick, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; @@ -417,23 +276,13 @@ function PageChunkCard({ readonly focusedPageNumber: number | null; readonly focusedPageRequestId: number; readonly highlightRegions: readonly ChatImageHighlightBox[]; - readonly isOriginalPreviewAvailable: boolean; - readonly onChunkClick?: (chunk: ParsedChunkView) => void; }): ReactNode { const pageAssets = chunk.pageAssets ?? []; return ( - + {pageAssets.length > 0 ? ( - + ) : ( - +

{chunk.readableContent ?? chunk.content}

)} - +
); } @@ -506,13 +355,9 @@ function PageCitationAssetImage({ const isImageLoaded = loadedAssetUrl === imageAssetUrl; return ( -
-
- Page {asset.pageNumber} - {asset.contentType} -
+
@@ -572,16 +417,12 @@ function ImageChunkCard({ isFocused, focusedPageRequestId, highlightRegions, - isOriginalPreviewAvailable, - onChunkClick, sourceOriginalFile, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; readonly focusedPageRequestId: number; readonly highlightRegions: readonly ChatImageHighlightBox[]; - readonly isOriginalPreviewAvailable: boolean; - readonly onChunkClick?: (chunk: ParsedChunkView) => void; readonly sourceOriginalFile: SourceOriginalFileView | null; }): ReactNode { const [loadedAssetUrl, setLoadedAssetUrl] = useState(null); @@ -592,16 +433,10 @@ function ImageChunkCard({ const isImageLoaded = loadedAssetUrl === inlineImageAssetUrl; return ( - - - - {inlineImageAssetUrl ? ( -
+ + {inlineImageAssetUrl ? ( + +
- ) : ( +
+ ) : ( +
@@ -637,9 +474,9 @@ function ImageChunkCard({

- )} -
- + + )} +
); } @@ -677,10 +514,6 @@ function isNotebookBlobAssetUrl(assetUrl: string): boolean { } } -function hasPageCitationAssets(chunk: ParsedChunkView): boolean { - return (chunk.pageAssets?.length ?? 0) > 0; -} - function renderTextChunkContent( chunk: ParsedChunkView, onReferenceClick: (chunkId: string) => void, @@ -731,13 +564,9 @@ function ChunkReferenceButton({ function TableChunkCard({ chunk, isFocused, - isOriginalPreviewAvailable, - onChunkClick, }: { readonly chunk: ParsedChunkView; readonly isFocused: boolean; - readonly isOriginalPreviewAvailable: boolean; - readonly onChunkClick?: (chunk: ParsedChunkView) => void; }): ReactNode { const safeHtml = useMemo( () => parsedChunkCardModel.getSanitizedTableHtml(chunk.content), @@ -745,13 +574,7 @@ function TableChunkCard({ ); return ( - - + {safeHtml ? (
)} - + ); } - -function renderChunkIcon(type: ParsedChunkView["type"]): ReactNode { - if (type === "page") return ; - if (type === "image") return ; - if (type === "table") return ; - return ; -} - -function getChunkIconClasses(type: ParsedChunkView["type"]): string { - if (type === "page") { - return "border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300"; - } - if (type === "image") { - return "border-violet-500/15 bg-violet-500/10 text-violet-600 dark:text-violet-300"; - } - if (type === "table") { - return "border-primary/15 bg-primary/10 text-primary"; - } - return "border-border bg-muted/60 text-muted-foreground"; -} diff --git a/src/domains/chunks/index.test.ts b/src/domains/chunks/index.test.ts index e4ab72c..873bd13 100644 --- a/src/domains/chunks/index.test.ts +++ b/src/domains/chunks/index.test.ts @@ -149,7 +149,7 @@ describe("toParsedChunkView", () => { metadata: { summary: "Refund eligibility is summarized across pages 4 to 6.", page_nums: [4, 5, 6], - entities: [{ text: "refund", label: "topic" }], + entities: [{ text: "refund", type: "topic" }], }, assetUrl: "https://assets.example/crop.pdf", }), @@ -164,11 +164,44 @@ describe("toParsedChunkView", () => { content: "Refund eligibility is summarized across pages 4 to 6.", readableContent: "Refund eligibility is summarized across pages 4 to 6.", pageNums: [4, 5, 6], - entities: [{ text: "refund", label: "topic" }], + entities: [{ text: "refund", type: "topic" }], assetUrl: "https://assets.example/crop.pdf", }); }); + it("normalizes Knowhere entity metadata into typed entity tags", () => { + const chunk = makeDocumentChunk({ + metadata: { + entities: [ + { text: "Form 10-K", type: "document" }, + { text: "Alphabet Inc.", label: "organization" }, + { text: " " }, + "Nasdaq", + ], + }, + }); + + expect(toParsedChunkView(chunk, "manual.pdf", "doc_123").entities).toEqual([ + { text: "Form 10-K", type: "document" }, + { text: "Alphabet Inc.", type: "organization" }, + { text: "Nasdaq" }, + ]); + }); + + it("parses JSON-string entity metadata from parser rows", () => { + const chunk = makeDocumentChunk({ + metadata: { + entities: JSON.stringify([ + { text: "Nasdaq", type: "organization" }, + ]), + }, + }); + + expect(toParsedChunkView(chunk, "manual.pdf", "doc_123").entities).toEqual([ + { text: "Nasdaq", type: "organization" }, + ]); + }); + it("maps usable page citation assets on page chunks", () => { const chunk = makeDocumentChunk({ id: "document_page_1", diff --git a/src/domains/chunks/normalization.ts b/src/domains/chunks/normalization.ts index 53fe88b..4e39ac5 100644 --- a/src/domains/chunks/normalization.ts +++ b/src/domains/chunks/normalization.ts @@ -2,6 +2,7 @@ import type { ChatCitationView } from "@/domains/chat/types" import type { ChunkType, ParsedChunkConnection, + ParsedChunkEntity, ParsedChunkView, } from "@/domains/chunks/types" @@ -304,15 +305,42 @@ function getStringArrayMetadata( return strings.length > 0 ? strings : undefined } -function getEntities( - value: unknown, -): readonly Readonly>[] | undefined { - if (!Array.isArray(value)) return undefined +function getEntities(value: unknown): readonly ParsedChunkEntity[] | undefined { + const parsedValue = parseEntityPayload(value) + if (!Array.isArray(parsedValue)) return undefined - const entities = value.filter(isRecord) + const entities = parsedValue.flatMap(toParsedChunkEntity) return entities.length > 0 ? entities : undefined } +function parseEntityPayload(value: unknown): unknown { + if (typeof value !== "string") return value + + const trimmed = value.trim() + if (!trimmed) return undefined + + try { + const parsed: unknown = JSON.parse(trimmed) + return parsed + } catch { + return undefined + } +} + +function toParsedChunkEntity(value: unknown): readonly ParsedChunkEntity[] { + if (typeof value === "string") { + const text = getString(value) + return text ? [{ text }] : [] + } + if (!isRecord(value)) return [] + + const text = getString(value["text"]) ?? getString(value["name"]) + if (!text) return [] + + const type = getString(value["type"]) ?? getString(value["label"]) + return type ? [{ text, type }] : [{ text }] +} + function getPageNumbers(value: unknown): number[] | undefined { if (!Array.isArray(value)) return undefined diff --git a/src/domains/chunks/types.ts b/src/domains/chunks/types.ts index fedb17a..23129a3 100644 --- a/src/domains/chunks/types.ts +++ b/src/domains/chunks/types.ts @@ -13,6 +13,11 @@ export type ParsedChunkConnection = { } } +export type ParsedChunkEntity = { + readonly text: string + readonly type?: string +} + /** * Parsed Content panel row. Mirrors the Knowhere document-chunk shape. */ @@ -36,7 +41,7 @@ export type ParsedChunkView = { readonly summary?: string readonly keywords?: readonly string[] readonly pageNums?: readonly number[] - readonly entities?: readonly Readonly>[] + readonly entities?: readonly ParsedChunkEntity[] readonly connections?: readonly ParsedChunkConnection[] /** Display-only attribution. */ readonly sourceTitle: string