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
6 changes: 6 additions & 0 deletions docs/process-hardening.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -175,3 +175,9 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went
- **Eval debt (blocking merge, not development):** `npm run eval:retrieval:quality` (23/23) and `eval:quality --rag-only` (`unsupported_correct_rate` 1.0) could NOT be run in the authoring environment (no live keys) — they MUST be run before merge per the standing gate above, with special attention to the weak-match OR-augmentation (flag off restores relax-on-empty exactly) and the retrieval-selection tiebreak (tie-only by construction).
- **UI verification run:** new `tests/ui-universal-search.spec.ts` (grouped typeahead renders, item selection navigates, Enter still runs the mode search — universal endpoint mocked), full `ui-tools`/`ui-tools-task-directory` (40/40) and `ui-smoke`/`ui-overlap` suites against a live dev server in demo mode, plus a live curl of `/api/search/universal` (grouped payload, domain filter, 400 on short query).
- **Known limitation:** the typeahead spec mocks the universal endpoint; an end-to-end spec against live seeded registries needs the owner-auth Playwright project (E2E_USER_* keys).

## Cross-mode answer links workstream — verification state (2026-07-06)

- **Shipped on `claude/search-cross-mode-links-qscj1n`:** post-answer "Also in your library" strip (`src/lib/cross-mode-links.ts` + `CrossModeLinksStrip`), thread-wide entity fallback, word-boundary field matching in `rankCatalogRecords` (substring hits like "renal" inside "adrenaline" no longer count as name/title matches), `fields=index` slim mode on `/api/medications`, cross-mode click telemetry via `/api/search/interaction` (`crossMode` target, `metadata.interaction: "cross_mode_link_open"`), the same strip on documents-mode results, and answer `crossModes` command-surface parity.
- **Verification debt:** `npm run verify:release` (and its governance/eval gates) has not been run for this workstream — the authoring environment has no live Supabase/OpenAI keys. Run it from a secrets-equipped environment after merge; the cross-mode surface itself is additive/navigational, so `verify:cheap` + `verify:ui` are the load-bearing local gates.
- **Telemetry note:** cross-mode clicks write `rag_query_misses` rows with `clicked_document_id: null` and the target mode/slug in `metadata`; retrieval-quality reviews that aggregate misses by document should filter on `metadata.interaction`.
43 changes: 35 additions & 8 deletions src/app/api/medications/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,9 +14,14 @@
medicationValidationStatus,
rowGovernance,
rowToMedicationRecord,
type MedicationRecordRow,

Check warning on line 17 in src/app/api/medications/route.ts

View workflow job for this annotation

GitHub Actions/ verify

'MedicationRecordRow' is defined but never used
} from "@/lib/medication-records";
import { medicationToSearchResult, rankMedicationRecords, type MedicationSearchMatch } from "@/lib/medications";
import {
medicationToSearchResult,
rankMedicationRecords,
type MedicationRecord,
type MedicationSearchMatch,
} from "@/lib/medications";
import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
import { createAdminClient } from "@/lib/supabase/admin";
import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth";
Expand All@@ -34,8 +39,29 @@
.optional()
.transform((value) => (value ? value : undefined)),
limit: queryInteger({ fallback: 50, min: 1, max: 100 }),
fields: z.enum(["index"]).optional(),
});

// `fields=index` strips the heavy per-record content (stats/sections/quick are
// ~99% of the ~3.4 MB catalog) for callers that only need identity-level
// ranking, e.g. the answer surface's cross-mode links. The records keep the
// full MedicationRecord shape so rankers and badge helpers work unchanged.
function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] {
return records.map((record) => ({
slug: record.slug,
name: record.name,
class: record.class,
subclass: record.subclass,
category: record.category,
accent: record.accent,
tag: record.tag,
schedule: record.schedule,
stats: [],
sections: [],
quick: [],
}));
}

function medicationResponse(payload: Record<string, unknown>) {
return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } });
}
Expand All@@ -49,8 +75,8 @@
}));
}

function publicMedicationPayload(q: string | undefined, limit: number) {
const records = defaultMedicationRecords();
function publicMedicationPayload(q: string | undefined, limit: number, fields?: "index") {
const records = fields === "index" ? toIndexRecords(defaultMedicationRecords()) : defaultMedicationRecords();
const governance = Object.fromEntries(
records.map((record) => [
record.slug,
Expand All@@ -71,18 +97,18 @@

export async function GET(request: Request) {
try {
const { q, limit } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query.");
const { q, limit, fields } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query.");

if (isDemoMode() || isLocalNoAuthMode()) {
return medicationResponse({
...publicMedicationPayload(q, limit),
...publicMedicationPayload(q, limit, fields),
demoMode: true,
});
}

if (!shouldResolvePublicCatalogAccess(request)) {
return medicationResponse({
...publicMedicationPayload(q, limit),
...publicMedicationPayload(q, limit, fields),
publicAccess: true,
});
}
Expand All@@ -102,13 +128,14 @@

if (!access.ownerId) {
return medicationResponse({
...publicMedicationPayload(q, limit),
...publicMedicationPayload(q, limit, fields),
publicAccess: true,
});
}

const rows = await fetchOwnerMedicationRowsWithSeed(supabase, access.ownerId, MEDICATION_MAX_RECORDS);
const records = rows.map(rowToMedicationRecord);
const fullRecords = rows.map(rowToMedicationRecord);
const records = fields === "index" ? toIndexRecords(fullRecords) : fullRecords;
const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)]));

return medicationResponse({
Expand Down
59 changes: 53 additions & 6 deletions src/app/api/search/interaction/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,15 +15,26 @@ import { parseJsonBody } from "@/lib/validation/body";

export const runtime = "nodejs";

const interactionSchema = z.object({
query: z.string().trim().min(1).max(2000),
documentId: z.string().uuid(),
chunkId: z.string().uuid().optional(),
fileName: z.string().trim().max(240).optional(),
const crossModeTargetSchema = z.object({
mode: z.enum(["prescribing", "services", "forms", "differentials"]),
slug: z.string().trim().min(1).max(160),
title: z.string().trim().max(240).optional(),
queryClass: z.string().trim().max(80).optional(),
});

const interactionSchema = z
.object({
query: z.string().trim().min(1).max(2000),
documentId: z.string().uuid().optional(),
chunkId: z.string().uuid().optional(),
fileName: z.string().trim().max(240).optional(),
title: z.string().trim().max(240).optional(),
queryClass: z.string().trim().max(80).optional(),
crossMode: crossModeTargetSchema.optional(),
})
.refine((body) => Boolean(body.documentId || body.crossMode), {
message: "Either documentId or a crossMode target is required.",
});

function safeTelemetryText(value: string | undefined) {
const cleaned = value
?.replace(/[\u0000-\u001f\u007f]+/g, " ")
Expand DownExpand Up@@ -74,6 +85,42 @@ export async function POST(request: Request) {
// Carry the authenticated owner through so the miss row is attributable and
// owner-cleanable instead of being orphaned with owner_id: null (RET-H4).
const user = await serverAuth.requireAuthenticatedUser(request, supabase);

// Cross-mode link clicks reference registry/medication slugs, not owned
// documents; store the same privacy-hardened miss row with the target in
// metadata so retrieval-quality reviews can see which modes get used.
if (!body.documentId) {
const target = body.crossMode!;
const { error: insertError } = await supabase.from("rag_query_misses").insert({
owner_id: user.id,
query: queryTextForStorage(body.query),
normalized_query: normalizedQueryTextForStorage(body.query),
query_class: body.queryClass ?? null,
clicked_document_id: null,
clicked_chunk_id: null,
top_files: [],
top_chunk_ids: [],
miss_reason: "clicked_result",
candidate_aliases: queryDerivedTokensForStorage(normalizedClinicalSearchTokens(body.query).slice(0, 10)),
candidate_labels: [
{
label: safeTelemetryText(target.title) ?? target.slug,
label_type: "cross_mode_target",
document_id: null,
confidence: 1,
},
],
metadata: {
interaction: "cross_mode_link_open",
cross_mode_target: target.mode,
cross_mode_slug: target.slug,
...queryPrivacyMetadata(body.query),
},
});
if (insertError) throw new Error(insertError.message);
return NextResponse.json({ ok: true });
}

const hasOwnedDocument = await ownedDocumentExists({ supabase, ownerId: user.id, documentId: body.documentId });
const hasOwnedChunk = hasOwnedDocument
? await ownedChunkExists({ supabase, documentId: body.documentId, chunkId: body.chunkId })
Expand Down
10 changes: 10 additions & 0 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,6 +63,7 @@
import { Sheet } from "@/components/ui/sheet";
import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog";
import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface";
import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links";
import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results";
import { AuthPanel } from "@/components/clinical-dashboard/auth-panel";
import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed";
Expand DownExpand Up@@ -2487,7 +2488,7 @@
urlDocumentSearchBootstrappedRef.current = true;
void executeSearch(searchText, mode, scopeFilters);
// URL search intentionally runs once when the selected mode can execute.
}, [canRunSearch, answerThreadBootstrapped]);

Check warning on line 2491 in src/components/ClinicalDashboard.tsx

View workflow job for this annotation

GitHub Actions/ verify

React Hook useEffect has missing dependencies: 'executeSearch' and 'scopeFilters'. Either include them or remove the dependency array

useEffect(() => {
const updateHash = () => {
Expand DownExpand Up@@ -4044,6 +4045,9 @@
) : (
<>
<ScopeAndGovernanceNotice scope={searchScope} warnings={sourceGovernanceWarnings} />
{searchMode === "documents" && modeSearchSubmitted && (
<CrossModeLinksSection queries={[query]} onModeSearch={crossModeSearch} />
)}
<DocumentSearchResultsPanel
matches={documentMatches}
recordMatches={recordSearchMatches}
Expand DownExpand Up@@ -4137,6 +4141,12 @@

{showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null}

{activeModeResultKind === "answer" && answer && (
<CrossModeLinksSection
queries={[...priorAnswerTurns.map((turn) => turn.query), latestAnswerQuery]}
onModeSearch={crossModeSearch}
/>
)}
{activeModeResultKind === "answer" && answer && (
<RelatedDocumentsPanel
documents={relatedDocuments}
Expand Down
166 changes: 166 additions & 0 deletions src/components/clinical-dashboard/cross-mode-links.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { ChevronRight, Search } from "lucide-react";

import {
chatMicroAction,
cn,
eyebrowText,
iconTile,
semanticChipTone,
sourceCard,
subtleStatusPill,
textMuted,
type SemanticChipTone,
} from "@/components/ui-primitives";
import { logCrossModeLinkOpen } from "@/components/clinical-dashboard/source-actions";
import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog";
import { appModeIcons } from "@/lib/app-mode-icons";
import { appModeHomeHref, type AppModeId } from "@/lib/app-modes";
import {
buildCrossModeLinksForThread,
type CrossModeDifferentialCatalog,
type CrossModeLink,
type CrossModeLinkBadge,
} from "@/lib/cross-mode-links";
import { useRegistryRecords } from "@/lib/use-registry-records";

function badgeChipTone(tone: CrossModeLinkBadge["tone"]): SemanticChipTone | null {
if (!tone) return null;
return tone === "clinical" ? "info" : tone;
}

// Self-contained cross-mode links surface: owns the catalog fetching (same
// owner-scoped APIs the modes use; fixtures in demo mode), entity matching,
// and the strip. Mount it under any search-results surface and pass the
// query thread (oldest first) — it renders nothing until an entity matches.
export function CrossModeLinksSection({
queries,
enabled = true,
onModeSearch,
}: {
queries: Array<string | null | undefined>;
enabled?: boolean;
// Defaults to navigating to the target mode with the search pre-run.
onModeSearch?: (mode: AppModeId, query: string) => void;
}) {
const router = useRouter();
const services = useRegistryRecords("service", { enabled });
const forms = useRegistryRecords("form", { enabled });
// fields=index keeps this to the ~30 KB identity slice of the catalog.
const medications = useMedicationCatalog(undefined, { enabled, fields: "index" });
const [differentials, setDifferentials] = useState<CrossModeDifferentialCatalog | null>(null);
useEffect(() => {
// Dynamic import keeps the 1.2 MB differentials snapshot out of the
// dashboard bundle; the catalog is loaded once per session.
if (!enabled || differentials) return;
let cancelled = false;
import("@/lib/cross-mode-differentials").then((module) => {
if (!cancelled) setDifferentials(module.crossModeDifferentialCatalog());
});
return () => {
cancelled = true;
};
}, [enabled, differentials]);

// Memo on the thread's contents, not the (per-render) array identity.
const queriesKey = queries.filter((value): value is string => Boolean(value?.trim())).join("\u0000");
const links = useMemo(() => {
if (!enabled || !queriesKey) return [];
return buildCrossModeLinksForThread(queriesKey.split("\u0000"), {
medications: medications.data?.records ?? [],
services: services.records,
forms: forms.records,
differentials: differentials ?? undefined,
});
}, [enabled, queriesKey, medications.data, services.records, forms.records, differentials]);

if (links.length === 0) return null;

const telemetryQuery = queriesKey.split("\u0000").at(-1) ?? "";
const handleModeSearch =
onModeSearch ??
((mode: AppModeId, query: string) => {
router.push(appModeHomeHref(mode, { query, focus: true, run: true }));
});

return <CrossModeLinksStrip links={links} onModeSearch={handleModeSearch} query={telemetryQuery} />;
}

export function CrossModeLinksStrip({
links,
onModeSearch,
query = "",
}: {
links: CrossModeLink[];
onModeSearch: (mode: AppModeId, query: string) => void;
// The search text that produced the links; used only for click telemetry.
query?: string;
}) {
if (links.length === 0) return null;

return (
<section
aria-label="Related pages in other modes"
className="mx-auto w-full max-w-4xl"
data-testid="cross-mode-links"
>
<p className={cn(eyebrowText, "mb-2")}>Also in your library</p>
<div className="grid gap-2 sm:grid-cols-2">
{links.map((link) => {
const Icon = appModeIcons[link.modeId];
return (
<article key={`${link.modeId}:${link.slug}`} className={cn(sourceCard, "flex items-center gap-3 p-3")}>
<span className={iconTile}>
<Icon className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<Link
href={link.detailHref}
onClick={() => logCrossModeLinkOpen(query, link)}
className="inline-flex min-h-[44px] items-center text-sm font-semibold text-[color:var(--text)] transition hover:text-[color:var(--clinical-accent)]"
>
<span className="line-clamp-1">{link.title}</span>
</Link>
{link.subtitle ? (
<p className={cn("text-xs leading-5 line-clamp-1", textMuted)}>{link.subtitle}</p>
) : null}
{link.badges.length > 0 && (
<div className="mt-1 flex flex-wrap items-center gap-1">
{link.badges.map((badge) => (
<span
key={badge.label}
className={cn(
"inline-flex items-center rounded-md border px-1.5 py-0.5 text-2xs font-semibold",
semanticChipTone(badgeChipTone(badge.tone)),
)}
>
{badge.label}
</span>
))}
</div>
)}
</div>
<span className={cn(subtleStatusPill, "shrink-0")}>{link.modeLabel}</span>
<button
type="button"
onClick={() => {
logCrossModeLinkOpen(query, link);
onModeSearch(link.modeId, link.modeSearchQuery);
}}
aria-label={`Search ${link.title} in ${link.modeLabel}`}
className={cn(chatMicroAction, "shrink-0")}
>
<Search className="h-3.5 w-3.5" aria-hidden />
</button>
<ChevronRight className={cn("h-4 w-4 shrink-0", textMuted)} aria-hidden />
</article>
);
})}
</div>
</section>
);
}
Loading
Loading