Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -6821,8 +6821,6 @@ export function ClinicalDashboard() {
apiUnavailable={apiUnavailable}
setupWarning={setupWarning}
facets={searchFacets}
onQueryChange={setQuery}
onSearch={ask}
onScopeDocument={scopeOnlyDocument}
onAnswerFromDocument={answerFromDocument}
onTagSearch={handleTagSearch}
Expand Down
106 changes: 10 additions & 96 deletions src/components/clinical-dashboard/document-search-results.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,11 +5,8 @@ import { useMemo, useState } from "react";
import {
AlertCircle,
ChevronDown,
Clock,
ExternalLink,
FileText,
Filter,
FolderOpen,
ListChecks,
ShieldAlert,
SlidersHorizontal,
Expand DownExpand Up@@ -257,36 +254,10 @@ function documentOpenHref(document: DocumentMatch) {
return `/documents/${document.document_id}?${params.toString()}`;
}

const startRows = [
{
title: "Recent documents",
icon: Clock,
query: "recent documents",
},
{
title: "Browse library",
icon: FolderOpen,
query: "clinical guideline",
},
{
title: "Open a source PDF",
icon: ExternalLink,
query: "PDF",
},
] as const;

function DocumentSearchHome({
documentCount,
onSuggestedSearch,
}: {
documentCount: number;
onSuggestedSearch: (query: string) => void;
}) {
const suggestedSearches = ["lithium", "clozapine", "ECT pathway", "monitoring"];

function DocumentSearchHome({ documentCount }: { documentCount: number }) {
return (
<div className="mx-auto grid min-h-[calc(100dvh-210px)] w-full min-w-0 max-w-[44rem] content-start gap-5 px-3 pb-36 pt-6 sm:min-h-[calc(100dvh-230px)] sm:gap-5 sm:px-0 sm:pb-40 sm:pt-10 lg:pb-32">
<section data-testid="document-home-overview" className="min-w-0 text-center">
<div className="mx-auto grid min-h-[calc(100dvh-210px)] w-full min-w-0 max-w-[34rem] place-items-start px-3 pb-36 pt-10 sm:min-h-[calc(100dvh-230px)] sm:px-0 sm:pb-40 sm:pt-16 lg:pb-32">
<section data-testid="document-search-empty-state" className="min-w-0 justify-self-center text-center">
<span className="mx-auto grid h-16 w-16 place-items-center rounded-[1.15rem] bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)] sm:h-[4.5rem] sm:w-[4.5rem] sm:rounded-[1.25rem]">
<FileText className="h-8 w-8" />
</span>
Expand All@@ -296,61 +267,12 @@ function DocumentSearchHome({
<p className={cn("mx-auto mt-2 max-w-[34rem] text-base leading-6 sm:leading-7", textMuted)}>
Find guidelines, policies, forms, and source PDFs.
</p>
<button
type="button"
onClick={() => onSuggestedSearch("Lithium monitoring guideline")}
className={cn(
metadataPill,
"mx-auto mt-3 w-full min-w-0 max-w-[calc(100vw-3rem)] justify-start gap-2 overflow-hidden rounded-lg px-3 text-sm font-semibold text-[color:var(--text-muted)] sm:mt-5 sm:w-fit sm:max-w-full",
)}
>
<Clock className="h-4 w-4 text-[color:var(--clinical-chat-teal)]" />
<span className="shrink-0 text-[color:var(--clinical-chat-teal)]">Resume</span>
<span className="min-w-0 truncate">Lithium monitoring guideline</span>
</button>
{documentCount > 0 ? <span className="sr-only">{documentCount.toLocaleString()} documents indexed</span> : null}
</section>

<section data-testid="document-home-recent-sources" className="grid gap-3" aria-label="Document shortcuts">
{startRows.map((row) => {
const Icon = row.icon;
return (
<button
key={row.title}
type="button"
onClick={() => onSuggestedSearch(row.query)}
className={cn(
panelSubtle,
"grid min-h-[76px] w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-4 rounded-xl px-4 text-left shadow-[0_8px_24px_rgb(15_27_45_/_4%)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] sm:min-h-[76px] sm:px-5",
)}
>
<span className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-[color:var(--clinical-chat-teal-soft)] text-[color:var(--clinical-chat-teal)] shadow-[var(--shadow-inset)]">
<Icon className="h-5 w-5 sm:h-6 sm:w-6" />
</span>
<span className="min-w-0">
<span className="block truncate text-lg font-semibold text-[color:var(--text-heading)]">
{row.title}
</span>
</span>
<ChevronDown className="h-6 w-6 -rotate-90 text-[color:var(--text-muted)]" />
</button>
);
})}
</section>

<section aria-label="Suggested searches" className="pt-1">
<p className={cn("mb-3 text-base font-medium", textMuted)}>Suggested</p>
<div className="flex min-w-0 flex-wrap gap-3">
{suggestedSearches.map((search) => (
<button
key={search}
type="button"
onClick={() => onSuggestedSearch(search)}
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface)] px-5 text-base font-medium text-[color:var(--text-heading)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)]"
>
{search}
</button>
))}
<div className="mt-4 flex min-w-0 flex-wrap justify-center gap-2">
<DocumentBadge variant="neutral" icon={FileText} className="min-h-8 rounded-lg px-3 text-xs">
{documentCount > 0
? `${documentCount.toLocaleString()} source${documentCount === 1 ? "" : "s"} indexed`
: "No indexed sources"}
</DocumentBadge>
</div>
</section>
</div>
Expand DownExpand Up@@ -436,8 +358,6 @@ export function DocumentSearchResultsPanel({
apiUnavailable,
setupWarning,
facets: _facets,
onQueryChange,
onSearch,
onScopeDocument,
onAnswerFromDocument,
onTagSearch,
Expand All@@ -451,8 +371,6 @@ export function DocumentSearchResultsPanel({
apiUnavailable: boolean;
setupWarning: string | null;
facets?: SearchFacets | null;
onQueryChange: (query: string) => void;
onSearch: () => void;
onScopeDocument: (documentId: string) => void;
onAnswerFromDocument: (documentId: string) => void;
onTagSearch: (tag: SmartDocumentTag | SmartDocumentTagFacet) => void;
Expand DownExpand Up@@ -503,10 +421,6 @@ export function DocumentSearchResultsPanel({
: trimmedQuery
? "No matching documents"
: `${documentCount} document${documentCount === 1 ? "" : "s"}`;
const runSuggestedSearch = (nextQuery: string) => {
onQueryChange(nextQuery);
window.setTimeout(() => onSearch(), 0);
};

return (
<div data-testid="document-search-workspace" className="space-y-3">
Expand DownExpand Up@@ -542,7 +456,7 @@ export function DocumentSearchResultsPanel({
</div>
</div>
) : (
<DocumentSearchHome documentCount={documentCount} onSuggestedSearch={runSuggestedSearch} />
<DocumentSearchHome documentCount={documentCount} />
)
) : (
<>
Expand Down
1 change: 1 addition & 0 deletions src/lib/document-index-units.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,6 +320,7 @@ function visualUnit(args: {
extraction_mode: args.profile.confidence >= 0.65 ? "hybrid" : "deterministic",
metadata: {
source: "visual_intelligence",
generated_by: "local-worker",
visual_intelligence_version: visualIntelligenceVersion,
image_type: args.image.imageType ?? null,
source_kind: args.image.sourceKind ?? null,
Expand Down
124 changes: 80 additions & 44 deletions src/lib/rag.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2472,6 +2472,8 @@ async function searchIndexUnitCandidates(args: {
return loadChunksForSignalMatches({ supabase: args.supabase, matches, ownerId: args.ownerId });
}

type MemoryCardCache = Map<string, ReturnType<typeof fetchMemoryCardsForQuery>>;

async function withMemoryBoostedCandidates(args: {
supabase: ReturnType<typeof createAdminClient>;
query: string;
Expand All@@ -2480,15 +2482,26 @@ async function withMemoryBoostedCandidates(args: {
ownerId?: string;
documentIds?: string[];
matchCount: number;
cardCache?: MemoryCardCache;
}) {
const cards = await fetchMemoryCardsForQuery({
supabase: args.supabase,
query: args.query,
queryEmbedding: args.queryEmbedding,
ownerId: args.ownerId,
documentIds: args.documentIds,
matchCount: Math.max(args.matchCount, 48),
});
// A3: the memory-card fetch is invoked at several waterfall stages for the same query/owner.
// Memoize per request, keyed by the inputs that actually vary within a request — the query,
// whether an embedding is supplied (embedding vs text-only fetches differ), and the count.
const effectiveMatchCount = Math.max(args.matchCount, 48);
const cacheKey = `${args.query}\0${args.queryEmbedding?.length ? "vec" : "text"}\0${effectiveMatchCount}`;
let cardsPromise = args.cardCache?.get(cacheKey);
Comment on lines +2490 to +2492
if (!cardsPromise) {
cardsPromise = fetchMemoryCardsForQuery({
supabase: args.supabase,
query: args.query,
queryEmbedding: args.queryEmbedding,
ownerId: args.ownerId,
documentIds: args.documentIds,
matchCount: effectiveMatchCount,
});
args.cardCache?.set(cacheKey, cardsPromise);
}
const cards = await cardsPromise;
if (cards.length === 0) return { results: args.candidates, cards };

const memoryChunkResults = await loadChunksForMemoryCards(args.supabase, cards, args.ownerId);
Expand DownExpand Up@@ -3646,6 +3659,9 @@ function isEssentialSimpleQuestionSection(section: Pick<AnswerSection, "heading"
export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
assertGlobalSearchAllowed(args);
const supabase = createAdminClient();
// A3: shared across every withMemoryBoostedCandidates call in this request so the same
// owner/query memory cards are fetched at most once per (query, embedding-present, count).
const memoryCardCache: MemoryCardCache = new Map();
const retrievalQuery = queryForClinicalMode(args.query, args.queryMode ?? "auto");
const modeQueryClass = queryClassForClinicalMode(args.queryMode ?? "auto");
const queryAnalysis = await analyzeQueryWithClassifierFallback(retrievalQuery, analyzeClinicalQuery(retrievalQuery));
Expand DownExpand Up@@ -3794,6 +3810,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
ownerId: args.ownerId,
documentIds: documentFilterList,
matchCount: candidateCount,
cardCache: memoryCardCache,
});
telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length);
telemetry.memory_top_score = Math.max(
Expand DownExpand Up@@ -3886,6 +3903,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
ownerId: args.ownerId,
documentIds: documentFilterList,
matchCount: candidateCount,
cardCache: memoryCardCache,
});
telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length);
telemetry.memory_top_score = Math.max(
Expand DownExpand Up@@ -3966,37 +3984,63 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
latencyMs: telemetry.embedding_latency_ms,
});

const embeddingFieldStartedAt = Date.now();
const embeddingFieldCandidates = await searchEmbeddingFieldCandidates({
supabase,
query: args.query,
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
matchCount: Math.min(candidateCount, 48),
});
// A1: the embedding-field, index-unit, and chunk-hybrid RPCs each depend only on the
// already-computed query embedding and have no data dependency on one another, so run
// them concurrently instead of as three sequential Supabase round-trips. The two helper
// functions swallow their own RPC errors and resolve to [], so Promise.all cannot reject.
const parallelRpcStartedAt = Date.now();
const [embeddingFieldResult, indexUnitResult, hybridResult] = await Promise.all([
(async () => {
const startedAt = Date.now();
const candidates = await searchEmbeddingFieldCandidates({
supabase,
query: args.query,
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
matchCount: Math.min(candidateCount, 48),
});
return { candidates, latencyMs: Date.now() - startedAt };
})(),
(async () => {
const startedAt = Date.now();
const candidates = await searchIndexUnitCandidates({
supabase,
query: args.query,
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
matchCount: Math.min(candidateCount, 64),
});
return { candidates, latencyMs: Date.now() - startedAt };
})(),
(async () => {
const startedAt = Date.now();
const { data, error } = await supabase.rpc("match_document_chunks_hybrid", {
query_embedding: embedding,
query_text: textSearchQuery,
match_count: candidateCount,
min_similarity: minSimilarity,
document_filters: documentFilterList ?? null,
owner_filter: args.ownerId ?? null,
});
return { data, error, latencyMs: Date.now() - startedAt };
})(),
]);
// The three calls overlap, so charge wall-clock once rather than summing per-call latencies.
telemetry.supabase_rpc_latency_ms += Date.now() - parallelRpcStartedAt;

const embeddingFieldCandidates = embeddingFieldResult.candidates;
telemetry.embedding_field_count = embeddingFieldCandidates.length;
const embeddingFieldLatencyMs = Date.now() - embeddingFieldStartedAt;
telemetry.supabase_rpc_latency_ms += embeddingFieldLatencyMs;
recordRetrievalLayer(telemetry, "embedding_fields", embeddingFieldCandidates.length, {
latencyMs: embeddingFieldLatencyMs,
latencyMs: embeddingFieldResult.latencyMs,
topScore: layerTopScore(embeddingFieldCandidates),
});
if (embeddingFieldCandidates.length > 0) {
textFastResults = mergeSearchResults(embeddingFieldCandidates, textFastResults);
}

const indexUnitStartedAt = Date.now();
const indexUnitCandidates = await searchIndexUnitCandidates({
supabase,
query: args.query,
queryEmbedding: embedding,
ownerId: args.ownerId,
documentIds: documentFilterList,
matchCount: Math.min(candidateCount, 64),
});
const indexUnitLatencyMs = Date.now() - indexUnitStartedAt;
telemetry.supabase_rpc_latency_ms += indexUnitLatencyMs;
const indexUnitCandidates = indexUnitResult.candidates;
telemetry.index_unit_count = indexUnitCandidates.length;
telemetry.index_unit_top_score = Number(
Math.max(0, ...indexUnitCandidates.map((result) => result.hybrid_score ?? result.similarity ?? 0)).toFixed(4),
Expand All@@ -4005,24 +4049,14 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
textFastResults = mergeSearchResults(indexUnitCandidates, textFastResults);
}
recordRetrievalLayer(telemetry, "index_units", indexUnitCandidates.length, {
latencyMs: indexUnitLatencyMs,
latencyMs: indexUnitResult.latencyMs,
topScore: telemetry.index_unit_top_score,
});

const hybridRpcStartedAt = Date.now();
const { data: hybridData, error: hybridError } = await supabase.rpc("match_document_chunks_hybrid", {
query_embedding: embedding,
query_text: textSearchQuery,
match_count: candidateCount,
min_similarity: minSimilarity,
document_filters: documentFilterList ?? null,
owner_filter: args.ownerId ?? null,
});
const hybridLatencyMs = Date.now() - hybridRpcStartedAt;
telemetry.supabase_rpc_latency_ms += hybridLatencyMs;
const { data: hybridData, error: hybridError } = hybridResult;
telemetry.vector_candidate_count = hybridData?.length ?? 0;
recordRetrievalLayer(telemetry, "hybrid_vector", hybridData?.length ?? 0, {
latencyMs: hybridLatencyMs,
latencyMs: hybridResult.latencyMs,
topScore: layerTopScore((hybridData ?? []) as SearchResult[]),
});

Expand All@@ -4038,6 +4072,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
ownerId: args.ownerId,
documentIds: documentFilterList,
matchCount: candidateCount,
cardCache: memoryCardCache,
});
telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length);
telemetry.memory_top_score = Math.max(
Expand DownExpand Up@@ -4108,6 +4143,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
ownerId: args.ownerId,
documentIds: documentFilterList,
matchCount: candidateCount,
cardCache: memoryCardCache,
});
telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length);
telemetry.memory_top_score = Math.max(
Expand Down
6 changes: 5 additions & 1 deletion supabase/functions/indexing-v3-agent/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1834,7 +1834,11 @@ async function needsVisualArtifacts(job: ClaimedJob): Promise<boolean> {
from public.document_index_units
where document_id = ${job.document_id}::uuid
and source_image_id is not null
and metadata->>'generated_by' = ${GENERATED_BY}
and (
metadata->>'generated_by' = ${GENERATED_BY}
or metadata->>'generated_by' = 'local-worker'
or metadata->>'source' = 'visual_intelligence'
)
) as generated_visual_units
`;
const row = rows[0] ?? { eligible_images: 0, generated_visual_units: 0 };
Expand Down
Loading
Loading