Skip to content
43 changes: 27 additions & 16 deletions src/app/api/differentials/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,13 @@ import {
type DifferentialRecordRow,
} from "@/lib/differential-records";
import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed";
import { differentialRecords, searchDifferentialRecords, searchPresentationWorkflows } from "@/lib/differentials";
import {
differentialRecords,
rankDifferentialRecords,
rankPresentationWorkflows,
type DifferentialPresentationMatch,
type DifferentialRecordMatch,
} from "@/lib/differentials";
import { isDemoMode, isLocalNoAuthMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access";
Expand All@@ -42,20 +48,31 @@ function differentialResponse(payload: Record<string, unknown>) {
return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } });
}

function recordMatchesPayload(matches: DifferentialRecordMatch[]) {
return matches.map((match) => ({ record: match.record, score: match.score, reasons: match.reasons }));
}

function presentationMatchesPayload(matches: DifferentialPresentationMatch[]) {
return matches.map((match) => ({ workflow: match.workflow, score: match.score, reasons: match.reasons }));
}

function publicDifferentialPayload(kind: DifferentialRecordKind, q: string | undefined, limit: number) {
const snapshot = loadDifferentialSnapshot();
const governance = deriveGovernanceFromSnapshot(snapshot);
if (kind === "presentation") {
const presentations = q ? searchPresentationWorkflows(q).slice(0, limit) : snapshot.presentations;
const ranked = q ? rankPresentationWorkflows(snapshot.presentations, q, limit) : null;
return {
presentations,
presentations: ranked ? ranked.map((match) => match.workflow) : snapshot.presentations,
matches: ranked ? presentationMatchesPayload(ranked) : undefined,
total: snapshot.presentations.length,
governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
};
}
const records = q ? searchDifferentialRecords(q).slice(0, limit) : differentialRecords;
const ranked = q ? rankDifferentialRecords(differentialRecords, q, limit) : null;
const records = ranked ? ranked.map((match) => match.record) : differentialRecords;
return {
records,
matches: ranked ? recordMatchesPayload(ranked) : undefined,
total: records.length,
governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
};
Expand DownExpand Up@@ -123,26 +140,20 @@ export async function GET(request: Request) {

if (kind === "presentation") {
const presentations = rows.map(rowToPresentationWorkflow);
const filtered = q
? searchPresentationWorkflows(q)
.filter((presentation) => presentations.some((row) => row.id === presentation.id))
.slice(0, limit)
: presentations;
const ranked = q ? rankPresentationWorkflows(presentations, q, limit) : null;
return differentialResponse({
presentations: filtered,
presentations: ranked ? ranked.map((match) => match.workflow) : presentations,
matches: ranked ? presentationMatchesPayload(ranked) : undefined,
total: rows.length,
governance: Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])),
});
}

const records = rows.map(rowToDifferentialRecord);
const filtered = q
? searchDifferentialRecords(q)
.filter((record) => records.some((row) => row.slug === record.slug))
.slice(0, limit)
: records;
const ranked = q ? rankDifferentialRecords(records, q, limit) : null;
return differentialResponse({
records: filtered,
records: ranked ? ranked.map((match) => match.record) : records,
matches: ranked ? recordMatchesPayload(ranked) : undefined,
total: rows.length,
governance: Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])),
});
Expand Down
18 changes: 18 additions & 0 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1671,6 +1671,10 @@
return next;
});
}
// The query the current documentMatches were fetched for, so the
// differentials results view can tell live-edited catalogue results apart
// from evidence that belongs to a previously submitted search.
const [differentialEvidenceQuery, setDifferentialEvidenceQuery] = useState<string | null>(null);
const clearDifferentialModeResultState = useCallback(() => {
resetAnswerThread();
setAnswer(null);
Expand All@@ -1682,6 +1686,7 @@
setSourceGovernanceWarnings([]);
setError(null);
setAnswerProgress(null);
setDifferentialEvidenceQuery(null);
}, [resetAnswerThread]);
const [scopeFilters, setScopeFilters] = useState<SearchScopeFilters>({});
const [searchScope, setSearchScope] = useState<SearchScopeSummary | null>(null);
Expand DownExpand Up@@ -2482,7 +2487,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 2490 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@@ -2804,6 +2809,11 @@
try {
let successfulPayload: SearchResultModePayload | null = null;
let lastError: SearchError | null = null;
// Differentials mode: the ranked catalogue results are the primary
// content and load independently of this document-evidence search, so an
// empty corpus result is applied (empty evidence) rather than surfaced
// as an error that would hide the catalogue view.
let emptyDifferentialsPayload: SearchResultModePayload | null = null;

for (const entry of queryPlan) {
if (entry.isKeyword) onProgress("Trying keyword-based search...");
Expand All@@ -2821,6 +2831,7 @@
);

if (!resultUsable(payload)) {
if (modeSearch.kind === "differentials") emptyDifferentialsPayload = payload;
lastError = makeSearchError("No usable results were found.", 404, false);
if (!entry.isKeyword) {
continue;
Expand All@@ -2839,6 +2850,10 @@
}
}

if (!successfulPayload && emptyDifferentialsPayload) {
successfulPayload = emptyDifferentialsPayload;
}

if (!successfulPayload) {
if (lastError) throw lastError;
throw new Error("Search did not return usable results.");
Expand All@@ -2847,6 +2862,7 @@
// M10: discard a stale response — a newer search owns the UI state.
if (requestId === searchRequestSeqRef.current) {
applySearchResult(successfulPayload, trimmedQuery);
if (isDifferentialsMode) setDifferentialEvidenceQuery(trimmedQuery);
if (successfulPayload.kind === "answer") {
// The composer is a draft box in a conversation: clear it so the
// user can type the next follow-up immediately.
Expand DownExpand Up@@ -3968,6 +3984,8 @@
<DifferentialsHome
query={query}
loading={loading}
searchSubmitted={modeSearchSubmitted}
evidenceQuery={differentialEvidenceQuery}
documentMatches={documentMatches}
realDataReady={canRunSearch}
authUnavailable={false}
Expand Down
Loading
Loading