diff --git a/docs/maturity-backlog-workorders.md b/docs/maturity-backlog-workorders.md index a14cae8cc..601d66cfc 100644 --- a/docs/maturity-backlog-workorders.md +++ b/docs/maturity-backlog-workorders.md @@ -7,9 +7,10 @@ files**, **risk**, **verification**, and **status**. High-risk items are deliber their own work order — the audit's rule is one dedicated PR + full-suite verification per structural change, not a single mixed PR. -**Status legend:** `DONE` (landed) · `READY` (scoped, safe to start) · `OPEN` (needs a -decision or a dedicated PR) · `PROVIDER-GATED` (touches live DB/CI/provider — needs explicit -confirmation) · `SATISFIED` (already true in the repo; no work needed). +**Status legend:** `DONE` (landed) · `IN PROGRESS` (partially landed; more PRs remain) · +`READY` (scoped, safe to start) · `OPEN` (needs a decision or a dedicated PR) · +`PROVIDER-GATED` (touches live DB/CI/provider — needs explicit confirmation) · `SATISFIED` +(already true in the repo; no work needed). --- @@ -72,16 +73,24 @@ confirmation) · `SATISFIED` (already true in the repo; no work needed). `pdf-extraction-budget` flake fails — confirmed identical on `origin/main`), `lint`, `docs:check-index`, `docs:check-links`, and maintainability budgets all pass. -### X3 · Decompose the monoliths — `OPEN` (first extraction landed #997) +### X3 · Decompose the monoliths — `IN PROGRESS` - **Outcome:** shrink the three files the maintainability ratchet caps but never reduces: `src/lib/rag/rag.ts` (5,018), `src/components/ClinicalDashboard.tsx` (4,271), - `src/components/DocumentViewer.tsx` (3,164). + `src/components/DocumentViewer.tsx` (was 3,164, now 1,733). - **Progress (#997):** extracted the evidence-gate predicates from `rag.ts` into `src/lib/rag/rag-evidence-gates.ts` (rag.ts 5,147 → 5,018), pure moves behind the existing - budgets. The two components are untouched and remain the largest open decomposition targets. -- **Approach:** extract cohesive units behind the existing budgets; `rag.ts` is the natural seam - now that X2 has landed (its ~23 siblings already exist). + budgets. +- **Progress (`DocumentViewer.tsx`):** extracted the cohesive leaf modules into + `src/components/document-viewer/` — shared row `types.ts`, `source-panels.tsx` (summary + profile, high-yield summary, source images/tables, pinned evidence, indexed-text panel), the + behaviour-bearing `manual-tag-editor.tsx` (add/rename/delete manual labels), and + `document-overview-landing.tsx`. The moves are verbatim (no logic changed); the container is + now composition-focused — it retains the detail fetch, dynamic PDF loading, and state + orchestration (3,164 → 1,733, budget ratcheted to 1,733). `ClinicalDashboard.tsx` (4,271) + and `rag.ts` remain the open decomposition targets. +- **Approach:** extract cohesive units behind the existing budgets; the components decompose + into their `*/` sibling directories, and `rag.ts` is the natural seam now that X2 has landed. - **Risk:** HIGH (behavioural surface). One file per PR. - **Verification:** `npm run typecheck` + `npm run test` (+ `npm run verify:ui` for the components). @@ -189,18 +198,18 @@ collaborators join — `AGENTS.md` + the PR template already carry that load. ## Progress summary -| Item | Priority | Status | -| ------------------------------ | -------- | ------------------------------------------ | -| N1 Dependabot grouping | Now | **DONE** (#985) | -| N2 Dependency-report decision | Now | **DONE** (#986, enabled) | -| X1 Import-boundary linter | Next | **DONE** (#986; service-role rule dropped) | -| X2 `src/lib` rag extraction | Next | **DONE** (#994) | -| X3 Monolith decomposition | Next | OPEN (first extraction landed #997) | -| X4 SAST-blocking on parser | Next | **DONE** (gate + policy check) | -| X5 ACL-migration consolidation | Next | PROVIDER-GATED (DB owner) | -| X6 Coverage floors | Next | OPEN | -| L1 Archive one-shot scripts | Later | OPEN (index shipped) | -| L2 Action-SHA uniformity | Later | **DONE** (#992) | -| L3 Single gate manifest | Later | **DONE** (#1002) | -| L4 Ledger rotation | Later | OPEN | -| L5 AI map / WCAG / RPO-RTO | Later | **DONE / SATISFIED** (#985) | +| Item | Priority | Status | +| ------------------------------ | -------- | ------------------------------------------- | +| N1 Dependabot grouping | Now | **DONE** (#985) | +| N2 Dependency-report decision | Now | **DONE** (#986, enabled) | +| X1 Import-boundary linter | Next | **DONE** (#986; service-role rule dropped) | +| X2 `src/lib` rag extraction | Next | **DONE** (#994) | +| X3 Monolith decomposition | Next | IN PROGRESS (rag #997; DocumentViewer done) | +| X4 SAST-blocking on parser | Next | **DONE** (gate + policy check) | +| X5 ACL-migration consolidation | Next | PROVIDER-GATED (DB owner) | +| X6 Coverage floors | Next | OPEN | +| L1 Archive one-shot scripts | Later | OPEN (index shipped) | +| L2 Action-SHA uniformity | Later | **DONE** (#992) | +| L3 Single gate manifest | Later | **DONE** (#1002) | +| L4 Ledger rotation | Later | OPEN | +| L5 AI map / WCAG / RPO-RTO | Later | **DONE / SATISFIED** (#985) | diff --git a/scripts/check-maintainability-budgets.mjs b/scripts/check-maintainability-budgets.mjs index 69b348098..352b509b0 100644 --- a/scripts/check-maintainability-budgets.mjs +++ b/scripts/check-maintainability-budgets.mjs @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; const budgets = new Map([ ["src/components/ClinicalDashboard.tsx", 4272], ["src/lib/rag/rag.ts", 5030], - ["src/components/DocumentViewer.tsx", 3166], + ["src/components/DocumentViewer.tsx", 1733], ["supabase/functions/indexing-v3-agent/index.ts", 2191], ]); diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 8716249f3..558335ffc 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -6,40 +6,20 @@ import { useRouter } from "next/navigation"; import { CircleAlert, ArrowLeft, - Check, - ChevronLeft, - ChevronRight, ChevronDown, Download, ExternalLink, FileImage, - FileText, Loader2, Plus, - Quote, RefreshCw, Search, Send, Sparkles, - Tag, Target, - Pencil, - Trash2, - X, - type LucideIcon, } from "lucide-react"; -import { type FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AccessibleTable, hasRenderableAccessibleTable } from "@/components/AccessibleTable"; -import { documentDisplayTitle, documentOrganizationProfile } from "@/components/DocumentOrganizationBadges"; -import { formatDocumentLabelDisplay } from "@/lib/document-tags"; -import { - DocumentActionAnchor, - DocumentActionButton, - DocumentFileTile, - DocumentMetaRow, - documentFileKind, - documentTileTone, -} from "@/components/clinical-dashboard/document-ui"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { AnswerProgressStepper } from "@/components/clinical-dashboard/answer-status"; import type { TimedAnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress"; @@ -51,21 +31,17 @@ import { cn, codeText, eyebrowText, - fieldControl, - fieldLabel, floatingControl, glassOverlaySurface, InlineNotice, LoadingPanel, panel, PanelHeading, - primaryControl, proseMeasure, sourceCard, textMuted, } from "@/components/ui-primitives"; import { BadgeCluster } from "@/components/clinical-dashboard/clinical-badge"; -import { SignedImage } from "@/components/clinical-dashboard/signed-image"; import { NonPdfSourcePreview } from "@/components/document-viewer/non-pdf-source-preview"; import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; @@ -75,7 +51,6 @@ import { isFullDocumentReload, nextLoadedDocumentKey, } from "@/lib/document-viewer-navigation"; -import { formatClinicalDate } from "@/lib/source-metadata"; import { partitionViewerImages } from "@/lib/image-filtering"; import { isLocalNoAuthMode } from "@/lib/client-env"; import { isAdministratorUser } from "@/lib/authorization"; @@ -83,29 +58,32 @@ import { useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; import { DocumentManagementActions } from "@/components/DocumentManagementActions"; import { Sheet } from "@/components/ui/sheet"; -import type { - ClinicalDocument, - ClinicalDocumentSummaryProfile, - DocumentLabel, - DocumentLabelType, - DocumentSummaryProfileItem, - RagAnswer, -} from "@/lib/types"; -import { - cleanClinicalSummaryText, - sourceTextForCompactDisplay, - sourceTextForDocumentViewer, - sourceTextForIndexedPage, -} from "@/lib/source-text-sanitizer"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; -import { flowIndexedText, parseIndexedSourceText } from "@/lib/indexed-source-formatting"; -import { - formatDocumentSummary, - type FormattedDocumentSummary as FormattedDocumentSummaryModel, -} from "@/lib/document-summary-formatting"; +import type { ClinicalDocument, DocumentLabel, RagAnswer } from "@/lib/types"; +import { cleanClinicalSummaryText } from "@/lib/source-text-sanitizer"; +import { formatDocumentSummary } from "@/lib/document-summary-formatting"; import { buildDocumentSummaryBadges } from "@/lib/document-summary-badges"; import { documentSummaryQuestion } from "@/lib/answer-contract"; import type { DocumentDetailPayload } from "@/lib/document-detail-contract"; +import type { + ChunkRow, + DocumentIndexHealth, + DocumentSearchResult, + ImageRow, + PageRow, + TableFactRow, +} from "@/components/document-viewer/types"; +import { + ClinicalSummaryProfile, + DocumentImage, + DocumentSectionSummary, + DocumentViewerAnchors, + FormattedHighYieldSummary, + IndexedTextPanel, + PinnedSourceEvidence, + TableReviewPanel, +} from "@/components/document-viewer/source-panels"; +import { DocumentManualTagEditor } from "@/components/document-viewer/manual-tag-editor"; +import { DocumentOverviewLanding } from "@/components/document-viewer/document-overview-landing"; // pdf-canvas-viewer is only needed after a source document has loaded and the // user is viewing a PDF. Keeping it out of the document route's initial client @@ -135,91 +113,6 @@ function PdfPreviewLoading() { ); } -type PageRow = { - id: string; - page_number: number; - text: string; - ocr_used: boolean; -}; - -type ImageRow = { - id: string; - page_number: number | null; - caption: string; - image_type?: string | null; - searchable?: boolean | null; - clinical_relevance_score?: number | null; - labels?: string[] | null; - source_kind?: string | null; - tableLabel?: string | null; - tableTitle?: string | null; - tableRole?: string | null; - tableTextSnippet?: string | null; - clinicalUseClass?: string | null; - clinicalUseReason?: string | null; - accessibleTableMarkdown?: string | null; - tableRows?: string[][] | null; - tableColumns?: string[] | null; -}; - -type TableFactRow = { - id: string; - document_id: string; - source_image_id: string | null; - page_number: number | null; - table_title: string | null; - row_label: string | null; - clinical_parameter: string | null; - threshold_value: string | null; - action: string | null; - metadata?: Record | null; -}; - -type ChunkRow = { - id: string; - page_number: number | null; - chunk_index: number; - section_heading: string | null; - content: string; - image_ids: string[]; - metadata?: Record | null; -}; - -type DocumentSearchResult = { - id: string; - page_number: number | null; - chunk_index: number; - section_heading: string | null; - snippet: string; - matched_terms: string[]; - image_ids: string[]; - score: number; -}; - -type DocumentIndexHealth = { - extractionQuality?: string | null; - indexedAt?: string | null; - indexVersion?: string | null; - warnings?: unknown; -}; - -const profileSectionLabels: Array<{ - key: keyof Omit; - label: string; -}> = [ - { key: "applies_to", label: "Applies to / scope" }, - { key: "key_clinical_actions", label: "Key clinical actions" }, - { key: "medication_dose_monitoring", label: "Medication, dose and monitoring" }, - { key: "thresholds_timing", label: "Thresholds and timing" }, - { key: "escalation_risk_warnings", label: "Escalation and risk warnings" }, - { key: "required_forms_documentation", label: "Required forms / documentation" }, - { key: "not_covered", label: "Not covered / source gaps" }, - { key: "important_tables_images", label: "Important tables / images" }, - { key: "best_questions", label: "Best questions this document can answer" }, - { key: "source_quality_notes", label: "Source quality notes" }, -]; - -const primaryButton = primaryControl; const secondaryButton = floatingControl; const pdfViewerModeStorageKey = "clinical-kb:pdf-viewer-mode"; const pdfViewerNativeModeBreakpoint = 820; @@ -294,1330 +187,6 @@ function rowsById(incoming: T[]) { return Array.from(rows.values()); } -function hasProfileItems(items: unknown): items is DocumentSummaryProfileItem[] { - return Array.isArray(items) && items.some((item) => item && typeof item === "object" && "text" in item); -} - -function profileItemText(item: DocumentSummaryProfileItem) { - return cleanClinicalSummaryText(item.text); -} - -function ClinicalSummaryProfile({ profile }: { profile: ClinicalDocumentSummaryProfile }) { - const sections = profileSectionLabels - .map((section) => ({ ...section, items: profile[section.key] })) - .filter((section) => hasProfileItems(section.items)); - - return ( -
- {/* The overview sentence leads the document landing card; the profile here - shows only the structured detail so the same text is not printed twice. */} - {sections.map((section) => ( -
-

- {section.label} -

-
    - {section.items.slice(0, section.key === "best_questions" ? 8 : 6).map((item, index) => { - const text = profileItemText(item); - if (!text) return null; - return ( -
  • -
  • - ); - })} -
-
- ))} -
- ); -} - -// Structured renderer for the raw stored summary text, sharing -// ClinicalSummaryProfile's visual language (lead paragraph, eyebrow section -// headings, accent-dot bullets). Collapsed by default with an explicit -// "Show full summary" toggle so nothing is silently hidden. -const collapsedSummarySectionCap = 4; -const collapsedSummaryItemCap = 5; - -function FormattedHighYieldSummary({ - formatted, - showLead = true, -}: { - formatted: FormattedDocumentSummaryModel; - showLead?: boolean; -}) { - const [expanded, setExpanded] = useState(false); - if (formatted.isEmpty) return null; - // The lead sentence already leads the document landing "Overview" card, so the - // right rail can suppress it to avoid printing the same sentence twice. - const leadVisible = showLead && Boolean(formatted.lead); - - const visibleSections = expanded - ? formatted.sections - : formatted.sections - .slice(0, collapsedSummarySectionCap) - .map((section) => ({ ...section, items: section.items.slice(0, collapsedSummaryItemCap) })); - const totalItems = formatted.sections.reduce((count, section) => count + section.items.length, 0); - const visibleItems = visibleSections.reduce((count, section) => count + section.items.length, 0); - const hasOverflow = totalItems > visibleItems || formatted.sections.length > visibleSections.length; - - return ( -
- {leadVisible ? ( -

- -

- ) : null} - {visibleSections.map((section, index) => ( -
0) && "border-t border-[color:var(--border)] pt-3")} - > -

- {section.heading ?? "Key points"} -

-
    - {section.items.map((item, itemIndex) => ( -
  • -
  • - ))} -
-
- ))} - {hasOverflow || expanded ? ( - - ) : null} - {formatted.truncatedTail ? ( -

- Summary trimmed at indexing — open the source PDF for full detail. -

- ) : null} -
- ); -} - -function looksLikeTableText(value?: string | null) { - return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); -} - -function DocumentImage({ image }: { image: ImageRow }) { - const endpoint = `/api/images/${image.id}/signed-url`; - - const tableHeading = sourceTextForCompactDisplay([image.tableLabel, image.tableTitle].filter(Boolean).join(": ")); - const cleanCaption = image.caption ? sourceTextForCompactDisplay(image.caption) : ""; - const tableMarkdown = image.accessibleTableMarkdown?.trim() - ? image.accessibleTableMarkdown - : looksLikeTableText(image.tableTextSnippet) - ? image.tableTextSnippet - : null; - // Only let the table "lead" (collapsing the source image) when AccessibleTable - // will actually render a table. Columns-only input or unparseable markdown - // render nothing, so those route to the image-first branch instead of leaving - // an empty caption above a hidden source image. - const hasStructuredTable = hasRenderableAccessibleTable({ - markdown: tableMarkdown, - rows: image.tableRows, - columns: image.tableColumns, - }); - const tableCaption = tableHeading || cleanCaption || "Document table"; - const showImageCaptionLine = cleanCaption && cleanCaption !== tableCaption; - const displayLabels = smartEvidenceTags( - image.labels, - [tableHeading, cleanCaption, image.tableTextSnippet ? sourceTextForCompactDisplay(image.tableTextSnippet) : null] - .filter(Boolean) - .join(" "), - ); - - // When a structured, accessible table exists the extracted table leads and the - // raw 4:3 table image collapses behind a "Show original" toggle, so the same - // content is not shown twice at full height. Without a structured table the - // image is the only representation, so it stays inline and prominent. - const imageBlock = ( -
- -
- ); - - const figcaptionBlock = ( -
- {tableHeading ?

{tableHeading}

: null} - {showImageCaptionLine ?

{cleanCaption}

: null} - - {!hasStructuredTable && image.tableTextSnippet ? ( -

{image.tableTextSnippet}

- ) : null} - {image.clinicalUseClass && image.clinicalUseClass !== "clinical_evidence" && image.clinicalUseReason ? ( -

{image.clinicalUseReason}

- ) : null} -
- ); - return ( -
-

- page {image.page_number ?? "n/a"} - {image.image_type ? ` · ${image.image_type.replaceAll("_", " ")}` : ""} - {image.tableRole ? ` · ${image.tableRole}` : ""} - {image.clinicalUseClass && image.clinicalUseClass !== "clinical_evidence" - ? ` · ${image.clinicalUseClass.replaceAll("_", " ")}` - : ""} -

- {hasStructuredTable ? ( - <> - {figcaptionBlock} -
- - -
{imageBlock}
-
- - ) : ( - <> -
{imageBlock}
- {figcaptionBlock} - - )} - {displayLabels.length ? ( -
- {displayLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
- ); -} - -function TableReviewPanel({ - tableFacts, - canReview, - busyFactId, - onReview, -}: { - tableFacts: TableFactRow[]; - canReview: boolean; - busyFactId: string | null; - onReview: (fact: TableFactRow, reviewClass: string) => void; -}) { - if (!tableFacts.length) return null; - return ( -
- - Table review queue ({tableFacts.length}) - -
- {tableFacts.slice(0, 12).map((fact) => { - const metadata = fact.metadata ?? {}; - const reviewClass = typeof metadata.review_class === "string" ? metadata.review_class : "unreviewed"; - const text = [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action] - .filter(Boolean) - .join(" | "); - return ( -
-

- Page {fact.page_number ?? "n/a"} · {reviewClass.replaceAll("_", " ")} -

-

{text || "Table fact has no text."}

-
- {[ - ["clinical_useful", "Clinical"], - ["administrative", "Admin"], - ["reference", "Reference"], - ["unrelated", "Unrelated"], - ["bad_extraction", "Bad extraction"], - ].map(([value, label]) => ( - - ))} -
-
- ); - })} -
-
- ); -} - -function DocumentViewerAnchors({ - evidenceHref, - textHref, - className, -}: { - evidenceHref: "#source-evidence" | "#source-evidence-rail"; - textHref: "#source-text"; - className?: string; -}) { - const anchors = [ - { label: "PDF", href: "#pdf-preview-section", icon: FileText }, - { label: "Evidence", href: evidenceHref, icon: Quote }, - { label: "Text", href: textHref, icon: Search }, - { label: "Summary", href: "#source-summary", icon: Sparkles }, - { label: "Images", href: "#source-images", icon: FileImage }, - ]; - - return ( - - ); -} - -function DocumentSectionSummary({ - icon: Icon, - title, - description, -}: { - icon: LucideIcon; - title: string; - description: string; -}) { - return ( - - - - - - - {title} - - {description} - - - - ); -} - -function PinnedSourceEvidence({ - loading, - chunk, - compact = false, - sectionId = "source-evidence", -}: { - loading: boolean; - chunk: ChunkRow | undefined; - compact?: boolean; - sectionId?: "source-evidence" | "source-evidence-rail"; -}) { - const displayContent = chunk ? flowIndexedText(sourceTextForDocumentViewer(chunk.content)) : ""; - const previewLimit = compact ? 220 : 300; - const [expandedChunkId, setExpandedChunkId] = useState(null); - const isLong = displayContent.length > previewLimit; - const expanded = !compact || (chunk?.id ? expandedChunkId === chunk.id : false); - const showingPreview = compact && isLong && !expanded; - const visibleContent = showingPreview ? `${displayContent.slice(0, previewLimit).trim()}...` : displayContent; - const chunkMeta = chunk - ? [`Page ${chunk.page_number ?? "n/a"}`, `chunk ${chunk.chunk_index}`].filter(Boolean).join(" · ") - : ""; - - if (!loading && !chunk) { - // Nothing is pinned (e.g. a direct visit, not arrived-at via a citation), so - // this stays a quiet one-line hint rather than a full card taking prime space. - return ( -

-

- ); - } - - return ( -
- - {loading ? ( - - ) : chunk ? ( -
-
-

-

- {chunkMeta ?

{chunkMeta}

: null} -
- {chunk.section_heading && ( -

{chunk.section_heading}

- )} -
- {visibleContent || "No displayable clinical text was available for this indexed passage."} -
-
- - - {compact && isLong ? ( - - ) : null} -
- {compact ? ( -

- Full indexed page text remains available in the source text section. -

- ) : null} -
- ) : ( -

- Open a citation from an answer to see the exact indexed passage. -

- )} -
- ); -} - -function IndexedSourceText({ - text, - emptyText, - compact = false, -}: { - text: string; - emptyText: string; - compact?: boolean; -}) { - const blocks = parseIndexedSourceText(text); - if (blocks.length === 0) { - return

{emptyText}

; - } - - return ( -
- {blocks.map((block) => { - if (block.type === "heading") { - return block.level === "title" ? ( -

- {block.text} -

- ) : ( -

- {block.text} -

- ); - } - - if (block.type === "list") { - return ( -
    - {block.items.map((item, index) => ( -
  • - {item} -
  • - ))} -
- ); - } - - if (block.type === "table") { - return ( - - ); - } - - return ( -

- {block.text} -

- ); - })} -
- ); -} - -function highlightTermsFor(terms: string[], fallback: string) { - const fallbackTerms = fallback - .toLowerCase() - .split(/\W+/) - .filter((term) => term.length >= 3); - return Array.from(new Set((terms.length ? terms : fallbackTerms).map((term) => term.toLowerCase()).filter(Boolean))); -} - -function HighlightedSearchText({ text, terms }: { text: string; terms: string[] }) { - if (!text.trim() || terms.length === 0) return <>{text}; - const escaped = terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).filter(Boolean); - if (!escaped.length) return <>{text}; - const matcher = new RegExp(`(${escaped.join("|")})`, "gi"); - return ( - <> - {text.split(matcher).map((part, index) => - terms.some((term) => part.toLowerCase() === term.toLowerCase()) ? ( - - {part} - - ) : ( - {part} - ), - )} - - ); -} - -// Memoised: both the mobile
and desktop copies stay mounted and are -// CSS-toggled, so without this every unrelated parent re-render (e.g. composer -// typing) re-rendered both instances. All props are referentially stable across -// those renders (onSearchChange is a stable setState), so memo actually elides them. -const IndexedTextPanel = memo(function IndexedTextPanel({ - loading, - selectedPage, - chunks, - search, - documentSearchResults, - searchingDocument, - documentSearchError, - idPrefix, - sectionId, - selectedChunkId, - onSearchChange, -}: { - loading: boolean; - selectedPage: PageRow | undefined; - chunks: ChunkRow[]; - search: string; - documentSearchResults: DocumentSearchResult[]; - searchingDocument: boolean; - documentSearchError: string | null; - idPrefix: string; - sectionId?: "source-text"; - selectedChunkId?: string; - onSearchChange: (value: string) => void; -}) { - const normalizedSearch = search.trim().toLowerCase(); - const displayChunks = chunks.map((chunk) => ({ - ...chunk, - displayContent: sourceTextForDocumentViewer(chunk.content), - })); - const loadedChunkById = new Map(displayChunks.map((chunk) => [chunk.id, chunk])); - const visibleChunks = normalizedSearch - ? documentSearchResults.map((result) => { - const loadedChunk = loadedChunkById.get(result.id); - return { - id: result.id, - page_number: result.page_number, - chunk_index: result.chunk_index, - section_heading: result.section_heading, - displayContent: loadedChunk?.displayContent ?? result.snippet, - matchedTerms: result.matched_terms, - serverRanked: true, - }; - }) - : displayChunks.slice(0, 8).map((chunk) => ({ ...chunk, matchedTerms: [], serverRanked: false })); - const [activeHitIndex, setActiveHitIndex] = useState(0); - const clampedActiveHitIndex = visibleChunks.length ? Math.min(activeHitIndex, visibleChunks.length - 1) : 0; - const activeHit = normalizedSearch && visibleChunks.length ? visibleChunks[clampedActiveHitIndex] : null; - const pageHitCounts = visibleChunks.reduce>((counts, chunk) => { - if (!chunk.page_number) return counts; - counts.set(chunk.page_number, (counts.get(chunk.page_number) ?? 0) + 1); - return counts; - }, new Map()); - const pageHitSummary = Array.from(pageHitCounts.entries()) - .sort((a, b) => a[0] - b[0]) - .slice(0, 5) - .map(([page, count]) => `p${page}: ${count}`) - .join(" · "); - const selectedPageText = selectedPage ? sourceTextForIndexedPage(selectedPage.text) : ""; - - useEffect(() => { - if (!activeHit) return; - document.getElementById(`${idPrefix}-${activeHit.id}`)?.scrollIntoView({ block: "nearest", behavior: "smooth" }); - }, [activeHit, idPrefix]); - - function moveHit(delta: number) { - if (visibleChunks.length === 0) return; - setActiveHitIndex((current) => (current + delta + visibleChunks.length) % visibleChunks.length); - } - - return ( -
- - - {loading ? ( - - ) : selectedPage ? ( - - ) : ( -

No extracted text has been indexed for this page yet.

- )} -
-
-

Source passages

- {normalizedSearch ? ( - - {searchingDocument - ? "Searching all indexed passages" - : `${visibleChunks.length} full-document hit${visibleChunks.length === 1 ? "" : "s"}`} - - ) : null} -
- {normalizedSearch && visibleChunks.length > 0 && !searchingDocument ? ( -
-
-

- Hit {clampedActiveHitIndex + 1} of {visibleChunks.length} -

-

- {pageHitSummary || "No page numbers indexed for these hits"} -

-
-
- - -
-
- ) : null} - {documentSearchError ? ( -

- {documentSearchError} -

- ) : null} -
- {searchingDocument ? ( - - ) : visibleChunks.length === 0 ? ( -

No indexed passage matched that search.

- ) : ( - visibleChunks.map((chunk) => ( -
-
-

- {selectedChunkId === chunk.id - ? "Highlighted quoted passage" - : activeHit?.id === chunk.id - ? "Active search hit" - : "Source passage"} -

-

- Page {chunk.page_number ?? "n/a"} · chunk {chunk.chunk_index} - {chunk.serverRanked ? " · full-document search" : ""} -

- {chunk.section_heading && ( -

{chunk.section_heading}

- )} - {chunk.matchedTerms.length ? ( -
- {chunk.matchedTerms.slice(0, 5).map((term) => ( - - {term} - - ))} -
- ) : null} -
-
-

- Excerpt -

- {normalizedSearch ? ( -

- -

- ) : ( - - )} -
-
- )) - )} -
-
-
- ); -}); - -const manualLabelTypeOptions: Array<{ value: DocumentLabelType; label: string }> = [ - { value: "site", label: "Site" }, - { value: "topic", label: "Topic" }, - { value: "medication", label: "Medication" }, - { value: "risk", label: "Risk" }, - { value: "workflow", label: "Workflow" }, - { value: "setting", label: "Setting" }, - { value: "service", label: "Service" }, - { value: "document_type", label: "Document type" }, - { value: "population", label: "Population" }, - { value: "clinical_action", label: "Clinical action" }, - { value: "care_phase", label: "Care phase" }, - { value: "document_intent", label: "Document intent" }, - { value: "content_feature", label: "Content feature" }, - { value: "custom", label: "Manual" }, -]; - -function manualLabelTypeLabel(value: DocumentLabelType) { - return manualLabelTypeOptions.find((option) => option.value === value)?.label ?? "Manual"; -} - -function DocumentManualTagEditor({ - document, - canManage, - clientDemoMode, - authorizationHeader, - onLabelsUpdated, - onUnauthorized, -}: { - document: ClinicalDocument; - canManage: boolean; - clientDemoMode: boolean; - authorizationHeader: Record; - onLabelsUpdated: (labels: DocumentLabel[]) => void; - onUnauthorized: () => void; -}) { - const manualLabels = (document.labels ?? []).filter((label) => label.source === "manual"); - const [draftLabel, setDraftLabel] = useState(""); - const [draftType, setDraftType] = useState("topic"); - const [editingId, setEditingId] = useState(null); - const [editingLabel, setEditingLabel] = useState(""); - const [editingType, setEditingType] = useState("topic"); - const [busyAction, setBusyAction] = useState(null); - const [error, setError] = useState(null); - - async function submitManualTag(method: "POST" | "PATCH" | "DELETE", body: Record, action: string) { - setBusyAction(action); - setError(null); - try { - const response = await fetch(`/api/documents/${document.id}/labels`, { - method, - headers: { - "Content-Type": "application/json", - ...(clientDemoMode ? {} : authorizationHeader), - }, - body: JSON.stringify(body), - }); - const payload = await response.json().catch(() => ({})); - if (response.status === 401) onUnauthorized(); - if (!response.ok) throw new Error(typeof payload?.error === "string" ? payload.error : "Tag update failed."); - if (Array.isArray(payload.labels)) onLabelsUpdated(payload.labels as DocumentLabel[]); - return true; - } catch (tagError) { - setError(tagError instanceof Error ? tagError.message : "Tag update failed."); - return false; - } finally { - setBusyAction(null); - } - } - - async function addManualTag(event: FormEvent) { - event.preventDefault(); - const added = await submitManualTag("POST", { label: draftLabel, label_type: draftType }, "add"); - if (added) { - setDraftLabel(""); - setDraftType("topic"); - } - } - - async function saveManualTag(label: DocumentLabel) { - const saved = await submitManualTag( - "PATCH", - { labelId: label.id, label: editingLabel, label_type: editingType }, - `edit:${label.id}`, - ); - if (saved) { - setEditingId(null); - setEditingLabel(""); - } - } - - async function deleteManualTag(label: DocumentLabel) { - await submitManualTag("DELETE", { labelId: label.id }, `delete:${label.id}`); - } - - return ( -
-
-

-

- - {manualLabels.length ? `${manualLabels.length} curated` : "Generated tags are read-only"} - -
- -
- - setDraftLabel(event.target.value)} - placeholder="Add clean manual tag" - disabled={!canManage || busyAction !== null} - className={fieldControl} - aria-label="Manual tag" - /> - - -
- - {error ? ( -

- {error} -

- ) : null} - - {manualLabels.length ? ( -
- {manualLabels.map((label) => { - const editing = editingId === label.id; - return ( -
- {editing ? ( -
- setEditingLabel(event.target.value)} - className={fieldControl} - aria-label="Manual tag label" - /> - -
- ) : ( -
-

{label.label}

-

{manualLabelTypeLabel(label.label_type)}

-
- )} -
- {editing ? ( - <> - - - - ) : ( - <> - - - - )} -
-
- ); - })} -
- ) : null} -
- ); -} - -function compactDocumentType(document: ClinicalDocument) { - return documentFileKind(document.file_name, "PDF"); -} - -// Derive the header eyebrow from the document's real type instead of asserting -// every document is a "Clinical guideline". Prefers the organization profile's -// document_type, then a high-confidence document_type label, then a neutral fallback. -function documentTypeEyebrow(document: ClinicalDocument) { - const profile = documentOrganizationProfile(document); - const profileType = - typeof profile?.document_type?.label === "string" && profile.document_type.label !== "unknown" - ? profile.document_type.label - : null; - const labelType = document.labels?.find( - (label) => label.label_type === "document_type" && (label.confidence ?? 0) >= 0.5, - )?.label; - const typeLabel = profileType ?? labelType; - return typeLabel ? formatDocumentLabelDisplay(typeLabel, "document_type") : "Clinical document"; -} - -function documentOverviewText(document: ClinicalDocument) { - const profile = document.summary?.clinical_specifics?.profile; - // The stored raw summary opens with PDF-header boilerplate on many live - // documents, so route it through the smart formatter and show its lead - // sentences instead of the raw string. - const formattedSummary = profile?.overview ? null : formatDocumentSummary(document.summary?.summary); - const overview = profile?.overview - ? cleanClinicalSummaryText(profile.overview) - : (formattedSummary?.lead ?? formattedSummary?.sections[0]?.items.join(" ") ?? ""); - if (overview && !/source-backed review/i.test(overview)) return overview; - return "A clear overview of this document, useful pages, and source PDF access."; -} - -function documentKeySections(document: ClinicalDocument) { - const labels = (document.labels ?? []).map((label) => label.label).filter(Boolean); - return Array.from(new Set(labels)).slice(0, 3); -} - -function DocumentPagePreview({ - href, - pageNumber, - onNavigate, -}: { - href: string; - pageNumber: number | null; - onNavigate: (page: number) => void; -}) { - // A real "jump to page" chip rather than a fake wireframe thumbnail that looks - // like a skeleton that never resolves. - return ( - { - if ( - pageNumber === null || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) - return; - event.preventDefault(); - onNavigate(pageNumber); - }} - className="inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent)]/40 hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)]" - > - - ); -} -function usefulDocumentPages(initialPage: number, pages: PageRow[]) { - return Array.from(new Set([initialPage, ...pages.map((page) => page.page_number)])) - .filter((page) => Number.isFinite(page)) - .slice(0, 3); -} - -function DocumentOverviewLanding({ - document, - initialPage, - signedUrl, - pages, - pageHref, - onPageChange, - onAskFromDocument, - onAddToScope, - onDownload, - downloading, - canSummarizeDocument, -}: { - document: ClinicalDocument; - initialPage: number; - signedUrl: string | null; - pages: PageRow[]; - pageHref: (page: number) => string; - onPageChange: (page: number) => void; - onAskFromDocument: () => void; - onAddToScope: () => void; - onDownload: () => void; - downloading: boolean; - canSummarizeDocument: boolean; -}) { - const keySections = documentKeySections(document); - const usefulPages = usefulDocumentPages(initialPage, pages); - const documentType = compactDocumentType(document); - const overviewText = documentOverviewText(document); - - return ( -
-
-
- -
-

- {documentTypeEyebrow(document)} -

-

- {documentDisplayTitle(document)} -

- - {overviewText ? ( -

{overviewText}

- ) : null} - {/* Search relevance badges are rendered in document search results; the viewer has no ranking context. */} -
-
-
- {signedUrl ? ( - - Open PDF - - ) : ( - - Open preview - - )} - - {downloading ? "Preparing" : "Download"} - - - Add to scope - - - Answer from this - -
-
- -
-
- - -
-

Overview

-

{documentOverviewText(document)}

-
-
-
- -
-
- - -
-

Key sections

-
- {(keySections.length ? keySections : ["Overview", "Useful pages", "Source PDF"]).map((section) => ( - - {section} - - ))} -
-
-
-
- -
-
- - -
-

Useful pages

-

Most relevant pages for this document.

-
- {(usefulPages.length ? usefulPages : [initialPage]).map((page) => ( - - ))} -
-
-
-
-
- ); -} - /** * Renders the clinical document viewer with source previews, extracted content, summaries, and document tools. * diff --git a/src/components/document-viewer/document-overview-landing.tsx b/src/components/document-viewer/document-overview-landing.tsx new file mode 100644 index 000000000..d1e4e1444 --- /dev/null +++ b/src/components/document-viewer/document-overview-landing.tsx @@ -0,0 +1,258 @@ +// Overview landing for the document viewer: the header card, quick actions, +// overview/key-sections/useful-pages tiles, and page-jump chips. Extracted from +// DocumentViewer.tsx (maturity X3) as a pure move. +import { Download, FileText, Loader2, Sparkles, Tag, Target } from "lucide-react"; +import { documentDisplayTitle, documentOrganizationProfile } from "@/components/DocumentOrganizationBadges"; +import { formatDocumentLabelDisplay } from "@/lib/document-tags"; +import { + DocumentActionAnchor, + DocumentActionButton, + DocumentFileTile, + DocumentMetaRow, + documentFileKind, + documentTileTone, +} from "@/components/clinical-dashboard/document-ui"; +import { cn, panel, floatingControl, primaryControl, sourceCard, textMuted } from "@/components/ui-primitives"; +import { cleanClinicalSummaryText } from "@/lib/source-text-sanitizer"; +import { formatDocumentSummary } from "@/lib/document-summary-formatting"; +import { formatClinicalDate } from "@/lib/source-metadata"; +import type { ClinicalDocument } from "@/lib/types"; +import type { PageRow } from "./types"; + +const primaryButton = primaryControl; +const secondaryButton = floatingControl; + +function compactDocumentType(document: ClinicalDocument) { + return documentFileKind(document.file_name, "PDF"); +} + +// Derive the header eyebrow from the document's real type instead of asserting +// every document is a "Clinical guideline". Prefers the organization profile's +// document_type, then a high-confidence document_type label, then a neutral fallback. +function documentTypeEyebrow(document: ClinicalDocument) { + const profile = documentOrganizationProfile(document); + const profileType = + typeof profile?.document_type?.label === "string" && profile.document_type.label !== "unknown" + ? profile.document_type.label + : null; + const labelType = document.labels?.find( + (label) => label.label_type === "document_type" && (label.confidence ?? 0) >= 0.5, + )?.label; + const typeLabel = profileType ?? labelType; + return typeLabel ? formatDocumentLabelDisplay(typeLabel, "document_type") : "Clinical document"; +} + +function documentOverviewText(document: ClinicalDocument) { + const profile = document.summary?.clinical_specifics?.profile; + // The stored raw summary opens with PDF-header boilerplate on many live + // documents, so route it through the smart formatter and show its lead + // sentences instead of the raw string. + const formattedSummary = profile?.overview ? null : formatDocumentSummary(document.summary?.summary); + const overview = profile?.overview + ? cleanClinicalSummaryText(profile.overview) + : (formattedSummary?.lead ?? formattedSummary?.sections[0]?.items.join(" ") ?? ""); + if (overview && !/source-backed review/i.test(overview)) return overview; + return "A clear overview of this document, useful pages, and source PDF access."; +} + +function documentKeySections(document: ClinicalDocument) { + const labels = (document.labels ?? []).map((label) => label.label).filter(Boolean); + return Array.from(new Set(labels)).slice(0, 3); +} + +function DocumentPagePreview({ + href, + pageNumber, + onNavigate, +}: { + href: string; + pageNumber: number | null; + onNavigate: (page: number) => void; +}) { + // A real "jump to page" chip rather than a fake wireframe thumbnail that looks + // like a skeleton that never resolves. + return ( + { + if ( + pageNumber === null || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + event.preventDefault(); + onNavigate(pageNumber); + }} + className="inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent)]/40 hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)]" + > + + ); +} +function usefulDocumentPages(initialPage: number, pages: PageRow[]) { + return Array.from(new Set([initialPage, ...pages.map((page) => page.page_number)])) + .filter((page) => Number.isFinite(page)) + .slice(0, 3); +} + +export function DocumentOverviewLanding({ + document, + initialPage, + signedUrl, + pages, + pageHref, + onPageChange, + onAskFromDocument, + onAddToScope, + onDownload, + downloading, + canSummarizeDocument, +}: { + document: ClinicalDocument; + initialPage: number; + signedUrl: string | null; + pages: PageRow[]; + pageHref: (page: number) => string; + onPageChange: (page: number) => void; + onAskFromDocument: () => void; + onAddToScope: () => void; + onDownload: () => void; + downloading: boolean; + canSummarizeDocument: boolean; +}) { + const keySections = documentKeySections(document); + const usefulPages = usefulDocumentPages(initialPage, pages); + const documentType = compactDocumentType(document); + const overviewText = documentOverviewText(document); + + return ( +
+
+
+ +
+

+ {documentTypeEyebrow(document)} +

+

+ {documentDisplayTitle(document)} +

+ + {overviewText ? ( +

{overviewText}

+ ) : null} + {/* Search relevance badges are rendered in document search results; the viewer has no ranking context. */} +
+
+
+ {signedUrl ? ( + + Open PDF + + ) : ( + + Open preview + + )} + + {downloading ? "Preparing" : "Download"} + + + Add to scope + + + Answer from this + +
+
+ +
+
+ + +
+

Overview

+

{documentOverviewText(document)}

+
+
+
+ +
+
+ + +
+

Key sections

+
+ {(keySections.length ? keySections : ["Overview", "Useful pages", "Source PDF"]).map((section) => ( + + {section} + + ))} +
+
+
+
+ +
+
+ + +
+

Useful pages

+

Most relevant pages for this document.

+
+ {(usefulPages.length ? usefulPages : [initialPage]).map((page) => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/src/components/document-viewer/manual-tag-editor.tsx b/src/components/document-viewer/manual-tag-editor.tsx new file mode 100644 index 000000000..36833f3bb --- /dev/null +++ b/src/components/document-viewer/manual-tag-editor.tsx @@ -0,0 +1,265 @@ +// Manual-tag editor for the document viewer: add/rename/delete curated manual +// labels via the document labels API. Extracted from DocumentViewer.tsx +// (maturity X3) as a pure move. +import { Check, Loader2, Pencil, Plus, Tag, Trash2, X } from "lucide-react"; +import { type FormEvent, useState } from "react"; +import { cn, fieldControl, floatingControl, primaryControl, sourceCard, textMuted } from "@/components/ui-primitives"; +import type { ClinicalDocument, DocumentLabel, DocumentLabelType } from "@/lib/types"; + +const primaryButton = primaryControl; +const secondaryButton = floatingControl; + +const manualLabelTypeOptions: Array<{ value: DocumentLabelType; label: string }> = [ + { value: "site", label: "Site" }, + { value: "topic", label: "Topic" }, + { value: "medication", label: "Medication" }, + { value: "risk", label: "Risk" }, + { value: "workflow", label: "Workflow" }, + { value: "setting", label: "Setting" }, + { value: "service", label: "Service" }, + { value: "document_type", label: "Document type" }, + { value: "population", label: "Population" }, + { value: "clinical_action", label: "Clinical action" }, + { value: "care_phase", label: "Care phase" }, + { value: "document_intent", label: "Document intent" }, + { value: "content_feature", label: "Content feature" }, + { value: "custom", label: "Manual" }, +]; + +function manualLabelTypeLabel(value: DocumentLabelType) { + return manualLabelTypeOptions.find((option) => option.value === value)?.label ?? "Manual"; +} + +export function DocumentManualTagEditor({ + document, + canManage, + clientDemoMode, + authorizationHeader, + onLabelsUpdated, + onUnauthorized, +}: { + document: ClinicalDocument; + canManage: boolean; + clientDemoMode: boolean; + authorizationHeader: Record; + onLabelsUpdated: (labels: DocumentLabel[]) => void; + onUnauthorized: () => void; +}) { + const manualLabels = (document.labels ?? []).filter((label) => label.source === "manual"); + const [draftLabel, setDraftLabel] = useState(""); + const [draftType, setDraftType] = useState("topic"); + const [editingId, setEditingId] = useState(null); + const [editingLabel, setEditingLabel] = useState(""); + const [editingType, setEditingType] = useState("topic"); + const [busyAction, setBusyAction] = useState(null); + const [error, setError] = useState(null); + + async function submitManualTag(method: "POST" | "PATCH" | "DELETE", body: Record, action: string) { + setBusyAction(action); + setError(null); + try { + const response = await fetch(`/api/documents/${document.id}/labels`, { + method, + headers: { + "Content-Type": "application/json", + ...(clientDemoMode ? {} : authorizationHeader), + }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (response.status === 401) onUnauthorized(); + if (!response.ok) throw new Error(typeof payload?.error === "string" ? payload.error : "Tag update failed."); + if (Array.isArray(payload.labels)) onLabelsUpdated(payload.labels as DocumentLabel[]); + return true; + } catch (tagError) { + setError(tagError instanceof Error ? tagError.message : "Tag update failed."); + return false; + } finally { + setBusyAction(null); + } + } + + async function addManualTag(event: FormEvent) { + event.preventDefault(); + const added = await submitManualTag("POST", { label: draftLabel, label_type: draftType }, "add"); + if (added) { + setDraftLabel(""); + setDraftType("topic"); + } + } + + async function saveManualTag(label: DocumentLabel) { + const saved = await submitManualTag( + "PATCH", + { labelId: label.id, label: editingLabel, label_type: editingType }, + `edit:${label.id}`, + ); + if (saved) { + setEditingId(null); + setEditingLabel(""); + } + } + + async function deleteManualTag(label: DocumentLabel) { + await submitManualTag("DELETE", { labelId: label.id }, `delete:${label.id}`); + } + + return ( +
+
+

+

+ + {manualLabels.length ? `${manualLabels.length} curated` : "Generated tags are read-only"} + +
+ +
+ + setDraftLabel(event.target.value)} + placeholder="Add clean manual tag" + disabled={!canManage || busyAction !== null} + className={fieldControl} + aria-label="Manual tag" + /> + + +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {manualLabels.length ? ( +
+ {manualLabels.map((label) => { + const editing = editingId === label.id; + return ( +
+ {editing ? ( +
+ setEditingLabel(event.target.value)} + className={fieldControl} + aria-label="Manual tag label" + /> + +
+ ) : ( +
+

{label.label}

+

{manualLabelTypeLabel(label.label_type)}

+
+ )} +
+ {editing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ); + })} +
+ ) : null} +
+ ); +} diff --git a/src/components/document-viewer/source-panels.tsx b/src/components/document-viewer/source-panels.tsx new file mode 100644 index 000000000..3d2804146 --- /dev/null +++ b/src/components/document-viewer/source-panels.tsx @@ -0,0 +1,902 @@ +// Presentational panels for the document viewer: clinical summary profile, +// high-yield summary, source images/tables, pinned evidence, and the indexed +// source-text search panel. Extracted from DocumentViewer.tsx (maturity X3) as a +// pure move — the DocumentViewer container composes these leaf components. +import { + ChevronDown, + ChevronLeft, + ChevronRight, + ExternalLink, + FileImage, + FileText, + Loader2, + Quote, + Search, + Sparkles, + Target, + type LucideIcon, +} from "lucide-react"; +import { memo, useEffect, useState } from "react"; +import { AccessibleTable, hasRenderableAccessibleTable } from "@/components/AccessibleTable"; +import { SignedImage } from "@/components/clinical-dashboard/signed-image"; +import { SafeBoldText } from "@/components/SafeBoldText"; +import { + clinicalDivider, + cn, + codeText, + eyebrowText, + fieldControl, + fieldLabel, + floatingControl, + LoadingPanel, + panel, + PanelHeading, + primaryControl, + proseMeasure, + sourceCard, + textMuted, +} from "@/components/ui-primitives"; +import { + cleanClinicalSummaryText, + sourceTextForCompactDisplay, + sourceTextForDocumentViewer, + sourceTextForIndexedPage, +} from "@/lib/source-text-sanitizer"; +import { smartEvidenceTags } from "@/lib/evidence-tags"; +import { flowIndexedText, parseIndexedSourceText } from "@/lib/indexed-source-formatting"; +import type { ClinicalDocumentSummaryProfile, DocumentSummaryProfileItem } from "@/lib/types"; +import type { FormattedDocumentSummary as FormattedDocumentSummaryModel } from "@/lib/document-summary-formatting"; +import type { ChunkRow, DocumentSearchResult, ImageRow, PageRow, TableFactRow } from "./types"; + +const primaryButton = primaryControl; +const secondaryButton = floatingControl; + +const profileSectionLabels: Array<{ + key: keyof Omit; + label: string; +}> = [ + { key: "applies_to", label: "Applies to / scope" }, + { key: "key_clinical_actions", label: "Key clinical actions" }, + { key: "medication_dose_monitoring", label: "Medication, dose and monitoring" }, + { key: "thresholds_timing", label: "Thresholds and timing" }, + { key: "escalation_risk_warnings", label: "Escalation and risk warnings" }, + { key: "required_forms_documentation", label: "Required forms / documentation" }, + { key: "not_covered", label: "Not covered / source gaps" }, + { key: "important_tables_images", label: "Important tables / images" }, + { key: "best_questions", label: "Best questions this document can answer" }, + { key: "source_quality_notes", label: "Source quality notes" }, +]; + +function hasProfileItems(items: unknown): items is DocumentSummaryProfileItem[] { + return Array.isArray(items) && items.some((item) => item && typeof item === "object" && "text" in item); +} + +function profileItemText(item: DocumentSummaryProfileItem) { + return cleanClinicalSummaryText(item.text); +} + +export function ClinicalSummaryProfile({ profile }: { profile: ClinicalDocumentSummaryProfile }) { + const sections = profileSectionLabels + .map((section) => ({ ...section, items: profile[section.key] })) + .filter((section) => hasProfileItems(section.items)); + + return ( +
+ {/* The overview sentence leads the document landing card; the profile here + shows only the structured detail so the same text is not printed twice. */} + {sections.map((section) => ( +
+

+ {section.label} +

+
    + {section.items.slice(0, section.key === "best_questions" ? 8 : 6).map((item, index) => { + const text = profileItemText(item); + if (!text) return null; + return ( +
  • +
  • + ); + })} +
+
+ ))} +
+ ); +} + +// Structured renderer for the raw stored summary text, sharing +// ClinicalSummaryProfile's visual language (lead paragraph, eyebrow section +// headings, accent-dot bullets). Collapsed by default with an explicit +// "Show full summary" toggle so nothing is silently hidden. +const collapsedSummarySectionCap = 4; +const collapsedSummaryItemCap = 5; + +export function FormattedHighYieldSummary({ + formatted, + showLead = true, +}: { + formatted: FormattedDocumentSummaryModel; + showLead?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + if (formatted.isEmpty) return null; + // The lead sentence already leads the document landing "Overview" card, so the + // right rail can suppress it to avoid printing the same sentence twice. + const leadVisible = showLead && Boolean(formatted.lead); + + const visibleSections = expanded + ? formatted.sections + : formatted.sections + .slice(0, collapsedSummarySectionCap) + .map((section) => ({ ...section, items: section.items.slice(0, collapsedSummaryItemCap) })); + const totalItems = formatted.sections.reduce((count, section) => count + section.items.length, 0); + const visibleItems = visibleSections.reduce((count, section) => count + section.items.length, 0); + const hasOverflow = totalItems > visibleItems || formatted.sections.length > visibleSections.length; + + return ( +
+ {leadVisible ? ( +

+ +

+ ) : null} + {visibleSections.map((section, index) => ( +
0) && "border-t border-[color:var(--border)] pt-3")} + > +

+ {section.heading ?? "Key points"} +

+
    + {section.items.map((item, itemIndex) => ( +
  • +
  • + ))} +
+
+ ))} + {hasOverflow || expanded ? ( + + ) : null} + {formatted.truncatedTail ? ( +

+ Summary trimmed at indexing — open the source PDF for full detail. +

+ ) : null} +
+ ); +} + +function looksLikeTableText(value?: string | null) { + return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); +} + +export function DocumentImage({ image }: { image: ImageRow }) { + const endpoint = `/api/images/${image.id}/signed-url`; + + const tableHeading = sourceTextForCompactDisplay([image.tableLabel, image.tableTitle].filter(Boolean).join(": ")); + const cleanCaption = image.caption ? sourceTextForCompactDisplay(image.caption) : ""; + const tableMarkdown = image.accessibleTableMarkdown?.trim() + ? image.accessibleTableMarkdown + : looksLikeTableText(image.tableTextSnippet) + ? image.tableTextSnippet + : null; + // Only let the table "lead" (collapsing the source image) when AccessibleTable + // will actually render a table. Columns-only input or unparseable markdown + // render nothing, so those route to the image-first branch instead of leaving + // an empty caption above a hidden source image. + const hasStructuredTable = hasRenderableAccessibleTable({ + markdown: tableMarkdown, + rows: image.tableRows, + columns: image.tableColumns, + }); + const tableCaption = tableHeading || cleanCaption || "Document table"; + const showImageCaptionLine = cleanCaption && cleanCaption !== tableCaption; + const displayLabels = smartEvidenceTags( + image.labels, + [tableHeading, cleanCaption, image.tableTextSnippet ? sourceTextForCompactDisplay(image.tableTextSnippet) : null] + .filter(Boolean) + .join(" "), + ); + + // When a structured, accessible table exists the extracted table leads and the + // raw 4:3 table image collapses behind a "Show original" toggle, so the same + // content is not shown twice at full height. Without a structured table the + // image is the only representation, so it stays inline and prominent. + const imageBlock = ( +
+ +
+ ); + + const figcaptionBlock = ( +
+ {tableHeading ?

{tableHeading}

: null} + {showImageCaptionLine ?

{cleanCaption}

: null} + + {!hasStructuredTable && image.tableTextSnippet ? ( +

{image.tableTextSnippet}

+ ) : null} + {image.clinicalUseClass && image.clinicalUseClass !== "clinical_evidence" && image.clinicalUseReason ? ( +

{image.clinicalUseReason}

+ ) : null} +
+ ); + return ( +
+

+ page {image.page_number ?? "n/a"} + {image.image_type ? ` · ${image.image_type.replaceAll("_", " ")}` : ""} + {image.tableRole ? ` · ${image.tableRole}` : ""} + {image.clinicalUseClass && image.clinicalUseClass !== "clinical_evidence" + ? ` · ${image.clinicalUseClass.replaceAll("_", " ")}` + : ""} +

+ {hasStructuredTable ? ( + <> + {figcaptionBlock} +
+ + +
{imageBlock}
+
+ + ) : ( + <> +
{imageBlock}
+ {figcaptionBlock} + + )} + {displayLabels.length ? ( +
+ {displayLabels.map((label) => ( + + {label} + + ))} +
+ ) : null} +
+ ); +} + +export function TableReviewPanel({ + tableFacts, + canReview, + busyFactId, + onReview, +}: { + tableFacts: TableFactRow[]; + canReview: boolean; + busyFactId: string | null; + onReview: (fact: TableFactRow, reviewClass: string) => void; +}) { + if (!tableFacts.length) return null; + return ( +
+ + Table review queue ({tableFacts.length}) + +
+ {tableFacts.slice(0, 12).map((fact) => { + const metadata = fact.metadata ?? {}; + const reviewClass = typeof metadata.review_class === "string" ? metadata.review_class : "unreviewed"; + const text = [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action] + .filter(Boolean) + .join(" | "); + return ( +
+

+ Page {fact.page_number ?? "n/a"} · {reviewClass.replaceAll("_", " ")} +

+

{text || "Table fact has no text."}

+
+ {[ + ["clinical_useful", "Clinical"], + ["administrative", "Admin"], + ["reference", "Reference"], + ["unrelated", "Unrelated"], + ["bad_extraction", "Bad extraction"], + ].map(([value, label]) => ( + + ))} +
+
+ ); + })} +
+
+ ); +} + +export function DocumentViewerAnchors({ + evidenceHref, + textHref, + className, +}: { + evidenceHref: "#source-evidence" | "#source-evidence-rail"; + textHref: "#source-text"; + className?: string; +}) { + const anchors = [ + { label: "PDF", href: "#pdf-preview-section", icon: FileText }, + { label: "Evidence", href: evidenceHref, icon: Quote }, + { label: "Text", href: textHref, icon: Search }, + { label: "Summary", href: "#source-summary", icon: Sparkles }, + { label: "Images", href: "#source-images", icon: FileImage }, + ]; + + return ( + + ); +} + +export function DocumentSectionSummary({ + icon: Icon, + title, + description, +}: { + icon: LucideIcon; + title: string; + description: string; +}) { + return ( + + + + + + + {title} + + {description} + + + + ); +} + +export function PinnedSourceEvidence({ + loading, + chunk, + compact = false, + sectionId = "source-evidence", +}: { + loading: boolean; + chunk: ChunkRow | undefined; + compact?: boolean; + sectionId?: "source-evidence" | "source-evidence-rail"; +}) { + const displayContent = chunk ? flowIndexedText(sourceTextForDocumentViewer(chunk.content)) : ""; + const previewLimit = compact ? 220 : 300; + const [expandedChunkId, setExpandedChunkId] = useState(null); + const isLong = displayContent.length > previewLimit; + const expanded = !compact || (chunk?.id ? expandedChunkId === chunk.id : false); + const showingPreview = compact && isLong && !expanded; + const visibleContent = showingPreview ? `${displayContent.slice(0, previewLimit).trim()}...` : displayContent; + const chunkMeta = chunk + ? [`Page ${chunk.page_number ?? "n/a"}`, `chunk ${chunk.chunk_index}`].filter(Boolean).join(" · ") + : ""; + + if (!loading && !chunk) { + // Nothing is pinned (e.g. a direct visit, not arrived-at via a citation), so + // this stays a quiet one-line hint rather than a full card taking prime space. + return ( +

+

+ ); + } + + return ( +
+ + {loading ? ( + + ) : chunk ? ( +
+
+

+

+ {chunkMeta ?

{chunkMeta}

: null} +
+ {chunk.section_heading && ( +

{chunk.section_heading}

+ )} +
+ {visibleContent || "No displayable clinical text was available for this indexed passage."} +
+
+ + + {compact && isLong ? ( + + ) : null} +
+ {compact ? ( +

+ Full indexed page text remains available in the source text section. +

+ ) : null} +
+ ) : ( +

+ Open a citation from an answer to see the exact indexed passage. +

+ )} +
+ ); +} + +function IndexedSourceText({ + text, + emptyText, + compact = false, +}: { + text: string; + emptyText: string; + compact?: boolean; +}) { + const blocks = parseIndexedSourceText(text); + if (blocks.length === 0) { + return

{emptyText}

; + } + + return ( +
+ {blocks.map((block) => { + if (block.type === "heading") { + return block.level === "title" ? ( +

+ {block.text} +

+ ) : ( +

+ {block.text} +

+ ); + } + + if (block.type === "list") { + return ( +
    + {block.items.map((item, index) => ( +
  • + {item} +
  • + ))} +
+ ); + } + + if (block.type === "table") { + return ( + + ); + } + + return ( +

+ {block.text} +

+ ); + })} +
+ ); +} + +function highlightTermsFor(terms: string[], fallback: string) { + const fallbackTerms = fallback + .toLowerCase() + .split(/\W+/) + .filter((term) => term.length >= 3); + return Array.from(new Set((terms.length ? terms : fallbackTerms).map((term) => term.toLowerCase()).filter(Boolean))); +} + +function HighlightedSearchText({ text, terms }: { text: string; terms: string[] }) { + if (!text.trim() || terms.length === 0) return <>{text}; + const escaped = terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).filter(Boolean); + if (!escaped.length) return <>{text}; + const matcher = new RegExp(`(${escaped.join("|")})`, "gi"); + return ( + <> + {text.split(matcher).map((part, index) => + terms.some((term) => part.toLowerCase() === term.toLowerCase()) ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +} + +// Memoised: both the mobile
and desktop copies stay mounted and are +// CSS-toggled, so without this every unrelated parent re-render (e.g. composer +// typing) re-rendered both instances. All props are referentially stable across +// those renders (onSearchChange is a stable setState), so memo actually elides them. +export const IndexedTextPanel = memo(function IndexedTextPanel({ + loading, + selectedPage, + chunks, + search, + documentSearchResults, + searchingDocument, + documentSearchError, + idPrefix, + sectionId, + selectedChunkId, + onSearchChange, +}: { + loading: boolean; + selectedPage: PageRow | undefined; + chunks: ChunkRow[]; + search: string; + documentSearchResults: DocumentSearchResult[]; + searchingDocument: boolean; + documentSearchError: string | null; + idPrefix: string; + sectionId?: "source-text"; + selectedChunkId?: string; + onSearchChange: (value: string) => void; +}) { + const normalizedSearch = search.trim().toLowerCase(); + const displayChunks = chunks.map((chunk) => ({ + ...chunk, + displayContent: sourceTextForDocumentViewer(chunk.content), + })); + const loadedChunkById = new Map(displayChunks.map((chunk) => [chunk.id, chunk])); + const visibleChunks = normalizedSearch + ? documentSearchResults.map((result) => { + const loadedChunk = loadedChunkById.get(result.id); + return { + id: result.id, + page_number: result.page_number, + chunk_index: result.chunk_index, + section_heading: result.section_heading, + displayContent: loadedChunk?.displayContent ?? result.snippet, + matchedTerms: result.matched_terms, + serverRanked: true, + }; + }) + : displayChunks.slice(0, 8).map((chunk) => ({ ...chunk, matchedTerms: [], serverRanked: false })); + const [activeHitIndex, setActiveHitIndex] = useState(0); + const clampedActiveHitIndex = visibleChunks.length ? Math.min(activeHitIndex, visibleChunks.length - 1) : 0; + const activeHit = normalizedSearch && visibleChunks.length ? visibleChunks[clampedActiveHitIndex] : null; + const pageHitCounts = visibleChunks.reduce>((counts, chunk) => { + if (!chunk.page_number) return counts; + counts.set(chunk.page_number, (counts.get(chunk.page_number) ?? 0) + 1); + return counts; + }, new Map()); + const pageHitSummary = Array.from(pageHitCounts.entries()) + .sort((a, b) => a[0] - b[0]) + .slice(0, 5) + .map(([page, count]) => `p${page}: ${count}`) + .join(" · "); + const selectedPageText = selectedPage ? sourceTextForIndexedPage(selectedPage.text) : ""; + + useEffect(() => { + if (!activeHit) return; + document.getElementById(`${idPrefix}-${activeHit.id}`)?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }, [activeHit, idPrefix]); + + function moveHit(delta: number) { + if (visibleChunks.length === 0) return; + setActiveHitIndex((current) => (current + delta + visibleChunks.length) % visibleChunks.length); + } + + return ( +
+ + + {loading ? ( + + ) : selectedPage ? ( + + ) : ( +

No extracted text has been indexed for this page yet.

+ )} +
+
+

Source passages

+ {normalizedSearch ? ( + + {searchingDocument + ? "Searching all indexed passages" + : `${visibleChunks.length} full-document hit${visibleChunks.length === 1 ? "" : "s"}`} + + ) : null} +
+ {normalizedSearch && visibleChunks.length > 0 && !searchingDocument ? ( +
+
+

+ Hit {clampedActiveHitIndex + 1} of {visibleChunks.length} +

+

+ {pageHitSummary || "No page numbers indexed for these hits"} +

+
+
+ + +
+
+ ) : null} + {documentSearchError ? ( +

+ {documentSearchError} +

+ ) : null} +
+ {searchingDocument ? ( + + ) : visibleChunks.length === 0 ? ( +

No indexed passage matched that search.

+ ) : ( + visibleChunks.map((chunk) => ( +
+
+

+ {selectedChunkId === chunk.id + ? "Highlighted quoted passage" + : activeHit?.id === chunk.id + ? "Active search hit" + : "Source passage"} +

+

+ Page {chunk.page_number ?? "n/a"} · chunk {chunk.chunk_index} + {chunk.serverRanked ? " · full-document search" : ""} +

+ {chunk.section_heading && ( +

{chunk.section_heading}

+ )} + {chunk.matchedTerms.length ? ( +
+ {chunk.matchedTerms.slice(0, 5).map((term) => ( + + {term} + + ))} +
+ ) : null} +
+
+

+ Excerpt +

+ {normalizedSearch ? ( +

+ +

+ ) : ( + + )} +
+
+ )) + )} +
+
+
+ ); +}); diff --git a/src/components/document-viewer/types.ts b/src/components/document-viewer/types.ts new file mode 100644 index 000000000..386e3f7a1 --- /dev/null +++ b/src/components/document-viewer/types.ts @@ -0,0 +1,71 @@ +// Row shapes for the document viewer's fetched detail payload (pages, images, +// table facts, chunks, full-document search hits, index health). Shared by the +// DocumentViewer container and its extracted presentation modules. + +export type PageRow = { + id: string; + page_number: number; + text: string; + ocr_used: boolean; +}; + +export type ImageRow = { + id: string; + page_number: number | null; + caption: string; + image_type?: string | null; + searchable?: boolean | null; + clinical_relevance_score?: number | null; + labels?: string[] | null; + source_kind?: string | null; + tableLabel?: string | null; + tableTitle?: string | null; + tableRole?: string | null; + tableTextSnippet?: string | null; + clinicalUseClass?: string | null; + clinicalUseReason?: string | null; + accessibleTableMarkdown?: string | null; + tableRows?: string[][] | null; + tableColumns?: string[] | null; +}; + +export type TableFactRow = { + id: string; + document_id: string; + source_image_id: string | null; + page_number: number | null; + table_title: string | null; + row_label: string | null; + clinical_parameter: string | null; + threshold_value: string | null; + action: string | null; + metadata?: Record | null; +}; + +export type ChunkRow = { + id: string; + page_number: number | null; + chunk_index: number; + section_heading: string | null; + content: string; + image_ids: string[]; + metadata?: Record | null; +}; + +export type DocumentSearchResult = { + id: string; + page_number: number | null; + chunk_index: number; + section_heading: string | null; + snippet: string; + matched_terms: string[]; + image_ids: string[]; + score: number; +}; + +export type DocumentIndexHealth = { + extractionQuality?: string | null; + indexedAt?: string | null; + indexVersion?: string | null; + warnings?: unknown; +}; diff --git a/tests/rendered-text-formatting.test.ts b/tests/rendered-text-formatting.test.ts index d9588f3bb..9045fed3d 100644 --- a/tests/rendered-text-formatting.test.ts +++ b/tests/rendered-text-formatting.test.ts @@ -14,6 +14,13 @@ function componentSource(relativePath: string) { describe("document-derived text must route through a formatter", () => { const dashboard = componentSource("ClinicalDashboard.tsx"); const documentViewer = componentSource("DocumentViewer.tsx"); + // Document-viewer render surfaces (source images/tables, pinned evidence, the + // indexed-text panel, and the overview landing) now live in extracted modules + // under document-viewer/. Scan them alongside the container so the raw-render + // guards travel with the code as it moves out. + const documentViewerSourcePanels = componentSource("document-viewer/source-panels.tsx"); + const documentViewerLanding = componentSource("document-viewer/document-overview-landing.tsx"); + const documentViewerSurfaces = `${documentViewer}\n${documentViewerSourcePanels}\n${documentViewerLanding}`; // Answer/evidence render surfaces (SourceImage, SourcePreviewContent, // NaturalLanguageAnswer, QuoteCards, EvidenceMapTable, …) now live in extracted // modules. Scan them alongside the monolith so the raw-render guards travel with @@ -57,8 +64,8 @@ describe("document-derived text must route through a formatter", () => { }); it("renders document-viewer image captions through a formatter, never raw", () => { - expect(documentViewer).not.toMatch(/\{image\.caption\}/); - expect(documentViewer).toContain("sourceTextForCompactDisplay(image.caption)"); + expect(documentViewerSurfaces).not.toMatch(/\{image\.caption\}/); + expect(documentViewerSurfaces).toContain("sourceTextForCompactDisplay(image.caption)"); }); it("renders visual-evidence titles and alt text through formatters, never raw", () => {