From 4f03aeffe599327c0ebf39a87da8293210bca87d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:00:12 +0800 Subject: [PATCH 01/15] test(eval): add force-embedding flag + 10 vector-exercising golden cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The golden retrieval set was 100% lexical fast-path (embedding_skipped_rate=1.0), so it could not measure whether a re-index changes vector/embedding retrieval quality. - forceEmbedding option on searchChunksWithTelemetry (SearchChunksArgs): bypasses every lexical text-fast-path so retrieval always exercises the embedding/vector stage. Diagnostic/eval-only; folded into the search cache key; never set on production paths. - eval-retrieval.ts: per-case `forceEmbedding` field + a global `--force-embedding` flag. - 10 `vector-*` cases (psychiatric monographs: PTSD, OCD, panic, anorexia, GAD, Tourette, postnatal, bipolar, ADHD, opioid) with forceEmbedding=true. Each is a clinical query that must be answered by vector retrieval of the right monograph — verified live at document_recall@5=1.0, content_recall@5=1.0, all via strategy=hybrid (embedding used). Rationale: forcing embedding is the correct instrument for re-index measurement — you want to measure the vector index directly, not have a lexical shortcut mask a regression. Wording alone can't reliably force the vector path (the fast-path is driven by emergent lexical-match strength), so the flag makes these probes deterministic. Live golden eval: 34/34 pass (24 existing + 10 new), no regression. verify:cheap green (980). Co-Authored-By: Claude Fable 5 --- scripts/eval-retrieval.ts | 11 ++ scripts/fixtures/rag-retrieval-golden.json | 194 +++++++++++++++++++++ src/lib/rag.ts | 11 +- 3 files changed, 213 insertions(+), 3 deletions(-) diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index 0a4332f2f..7218ae731 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -19,6 +19,10 @@ const goldenCaseSchema = z.object({ expectedContentTerms: z.array(contentExpectationSchema).default([]), topK: z.number().int().positive().default(8), expectTableEvidence: z.boolean().default(false), + // Bypass the lexical text-fast-path so this case always exercises the embedding/vector + // index. Use for "vector-*" probes that would otherwise be answered by a lexical shortcut, + // so a re-index's effect on vector retrieval is actually measured. + forceEmbedding: z.boolean().optional(), }); const goldenCasesSchema = z.array(goldenCaseSchema); @@ -37,6 +41,7 @@ type EvalArgs = { caseTimeoutMs: number; p90BudgetMs: number; p50BudgetMs: number; + forceEmbedding: boolean; }; export type GoldenRetrievalResult = { @@ -116,6 +121,7 @@ function parseArgs(argv: string[]): EvalArgs { caseTimeoutMs: inferredMode === "latency" ? 25_000 : 0, p90BudgetMs: 20_000, p50BudgetMs: 8_000, + forceEmbedding: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -138,6 +144,10 @@ function parseArgs(argv: string[]): EvalArgs { if (args.caseTimeoutMs <= 0) args.caseTimeoutMs = 25_000; continue; } + if (token === "--force-embedding") { + args.forceEmbedding = true; + continue; + } const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); @@ -743,6 +753,7 @@ async function main() { topK: testCase.topK, minSimilarity: 0.12, skipCache: args.mode !== "latency", + forceEmbedding: testCase.forceEmbedding || args.forceEmbedding, }), ); const searchOutcome = await withCaseTimeout(searchPromise, args.caseTimeoutMs); diff --git a/scripts/fixtures/rag-retrieval-golden.json b/scripts/fixtures/rag-retrieval-golden.json index b31176b76..5f3cdabb4 100644 --- a/scripts/fixtures/rag-retrieval-golden.json +++ b/scripts/fixtures/rag-retrieval-golden.json @@ -224,5 +224,199 @@ ], "topK": 12, "expectTableEvidence": true + }, + { + "id": "vector-ptsd", + "query": "How is post-traumatic stress disorder managed in a person with intrusive flashbacks, nightmares, hyperarousal and avoidance after a traumatic event?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Traumatic Stress" + ], + "expectedContentTerms": [ + [ + "trauma", + "traumatic", + "ptsd", + "flashback" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-ocd", + "query": "How is obsessive-compulsive disorder managed in a person with distressing intrusive obsessions and repetitive compulsive rituals?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Obsessive" + ], + "expectedContentTerms": [ + [ + "obsess", + "compuls", + "intrusive", + "ocd" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-panic", + "query": "How is panic disorder managed in a person with recurrent unexpected panic attacks, palpitations and anticipatory anxiety?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Panic" + ], + "expectedContentTerms": [ + [ + "panic", + "attack", + "anxiety" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-anorexia", + "query": "How is anorexia nervosa managed in a person with severe dietary restriction, intense fear of weight gain and body-image disturbance?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Anorexia" + ], + "expectedContentTerms": [ + [ + "anorexia", + "eating", + "weight" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-gad-worry", + "query": "How is a patient managed who has persistent, excessive worry about many everyday things that they find very hard to control, along with restlessness and muscle tension?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Generalised Anxiety" + ], + "expectedContentTerms": [ + [ + "worry", + "anxiety", + "generalised" + ], + [ + "cbt", + "ssri", + "snri", + "antidepressant", + "psychotherapy" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-tourette", + "query": "How is Tourette syndrome managed in a child with chronic motor tics and vocal tics?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Tourette" + ], + "expectedContentTerms": [ + [ + "tic", + "tics", + "tourette" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-postnatal", + "query": "How is postnatal (postpartum) depression managed in a new mother who develops low mood and poor bonding with her infant in the first weeks postpartum?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Postnatal" + ], + "expectedContentTerms": [ + [ + "postnatal", + "postpartum", + "perinatal", + "depression" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-bipolar", + "query": "How is bipolar disorder managed in an adult with recurrent episodes of mania and depression?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Bipolar" + ], + "expectedContentTerms": [ + [ + "bipolar", + "mania", + "manic", + "mood" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-adhd", + "query": "How is attention deficit hyperactivity disorder managed in an adult with inattention, distractibility and impulsivity?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Attention Deficit Hyperactivity" + ], + "expectedContentTerms": [ + [ + "attention", + "adhd", + "hyperactiv", + "impuls" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true + }, + { + "id": "vector-opioid", + "query": "How is opioid use disorder managed in a person dependent on heroin?", + "expectedQueryClass": "broad_summary", + "expectedDocumentSubstrings": [ + "Opioid Use Disorder" + ], + "expectedContentTerms": [ + [ + "opioid", + "heroin", + "opiate", + "methadone", + "buprenorphine" + ] + ], + "topK": 8, + "expectTableEvidence": false, + "forceEmbedding": true } ] diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 6764accd2..9f8e6f059 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -307,6 +307,10 @@ export type SearchChunksArgs = { // Internal: set when this call is a re-run on a trigram-corrected query, to prevent the // unsupported-short-circuit typo-correction path from recursing more than once. typoCorrected?: boolean; + // Diagnostic/eval-only: bypass every lexical text-fast-path so retrieval always exercises + // the embedding/vector stage. Lets the golden eval measure the vector index directly for a + // re-index, instead of being masked by lexical shortcuts. Never set on production paths. + forceEmbedding?: boolean; }; export type AnswerProgressEvent = { @@ -1455,6 +1459,7 @@ function scopedSearchCacheKey(args: SearchChunksArgs, queryClass?: RagQueryClass args.ownerId ?? "anonymous", scopeKey(args), retrievalPlanCacheQuery(args, queryClass, queryVariants), + args.forceEmbedding ? "force-embedding" : "", ].join("|"); } @@ -5516,7 +5521,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { }); const baseTextFastPath = decideTextFastPath(args.query, baseTextResults, queryClassification.queryClass); - if (shouldReturnBeforeMemory(queryClassification.queryClass, baseTextFastPath)) { + if (!args.forceEmbedding && shouldReturnBeforeMemory(queryClassification.queryClass, baseTextFastPath)) { textFastResults = await attachPageVisualEvidence(supabase, baseTextResults); textFastResults = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, @@ -5567,7 +5572,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.rerank_latency_ms += Date.now() - rerankStartedAt; const boostedTextFastPath = decideTextFastPath(args.query, textFastResults, queryClassification.queryClass); - if (boostedTextFastPath.returnFastPath) { + if (!args.forceEmbedding && boostedTextFastPath.returnFastPath) { markEmbeddingSkippedByTextFastPath(telemetry, boostedTextFastPath.reason); telemetry.retrieval_strategy = "text_fast_path"; recordSearchScoreTelemetry(telemetry, textFastResults); @@ -5673,7 +5678,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentLookupResults, queryClassification.queryClass, ); - if (documentLookupFastPath.returnFastPath) { + if (!args.forceEmbedding && documentLookupFastPath.returnFastPath) { markEmbeddingSkippedByTextFastPath( telemetry, documentLookupFastPath.reason ? `document_lookup_fast_path:${documentLookupFastPath.reason}` : null, From a17fa3f2e967958d3faa2a72fbc4b7086843529a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:47:36 +0800 Subject: [PATCH 02/15] test(eval): harden force-embedding flag and vector-path eval guards Wire forceEmbedding through eval runners and retrieval cache keys, bypass coverage/lexical shortcuts when forced, and add golden-case failure metrics so vector regressions cannot hide behind text-fast-path or cache hits. --- scripts/eval-quality.ts | 15 ++++++++ scripts/eval-retrieval.ts | 24 ++++++++++++ src/lib/rag.ts | 50 ++++++++++++++++++------- tests/eval-quality.test.ts | 52 +++++++++++++++++++++++++- tests/retrieval-query-variants.test.ts | 12 ++++++ 5 files changed, 139 insertions(+), 14 deletions(-) diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 52cbc50f2..92c93703b 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -44,6 +44,7 @@ type EvalQualityArgs = { retrievalOnly: boolean; ragOnly: boolean; skipPreflight: boolean; + forceEmbedding: boolean; }; export type RagQualityResult = { @@ -149,6 +150,7 @@ function parseArgs(argv: string[]): EvalQualityArgs { retrievalOnly: false, ragOnly: false, skipPreflight: false, + forceEmbedding: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -175,6 +177,10 @@ function parseArgs(argv: string[]): EvalQualityArgs { args.skipPreflight = true; continue; } + if (token === "--force-embedding") { + args.forceEmbedding = true; + continue; + } const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); @@ -475,6 +481,11 @@ export function buildEvalQualityReport(args: { `retrieval content_recall_at_5 ${retrievalSummary.content_recall_at_5} below ${qualityThresholds.retrievalContentRecallAt5}`, ); } + if (retrievalSummary.force_embedding_failure_count > 0) { + thresholdFailures.push( + `retrieval force_embedding_failure_count ${retrievalSummary.force_embedding_failure_count} above 0`, + ); + } if (governance.stale_rate > qualityThresholds.staleTopResultRate) { thresholdFailures.push( `top-result stale_rate ${governance.stale_rate} above ${qualityThresholds.staleTopResultRate}`, @@ -663,6 +674,8 @@ ${markdownTable([ ## Retrieval Decision Metrics ${markdownTable([ + ["Force-embedding cases", retrieval.force_embedding_case_count], + ["Force-embedding failures", retrieval.force_embedding_failure_count], ["Embedding skipped rate", retrieval.embedding_skipped_rate], ["Median text candidate budget", retrieval.median_text_candidate_budget], ["Second-stage rerank rate", retrieval.second_stage_rerank_rate], @@ -727,6 +740,7 @@ async function runRetrievalQualityCases(args: { ownerId?: string; limit?: number; query?: string; + forceEmbedding?: boolean; supabase: Awaited>; }) { const [{ searchChunksWithTelemetry }, capturedCases] = await Promise.all([ @@ -753,6 +767,7 @@ async function runRetrievalQualityCases(args: { topK: testCase.topK, minSimilarity: 0.12, skipCache: true, + forceEmbedding: testCase.forceEmbedding || args.forceEmbedding, }), ); const latencyMs = diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index 7218ae731..23194974c 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -47,6 +47,7 @@ type EvalArgs = { export type GoldenRetrievalResult = { id: string; query: string; + forceEmbedding: boolean; expectedQueryClass: string; actualQueryClass: string | null; expectedDocumentSubstrings: string[]; @@ -468,6 +469,11 @@ export function evaluateGoldenRetrievalCase(args: { const tableEvidenceFoundAtK = hasTableEvidence(args.results, topK); const actualQueryClass = args.telemetry.query_class ?? null; const failures: string[] = []; + const vectorLayerCount = Object.entries(args.telemetry.retrieval_layer_counts ?? {}).reduce( + (sum, [layer, count]) => + ["embedding_fields", "index_units", "hybrid_vector", "vector_fallback"].includes(layer) ? sum + count : sum, + 0, + ); const hitAtK = documentHitsAtK.missing.length === 0 && contentHitsAtK.missing.length === 0 && @@ -485,10 +491,23 @@ export function evaluateGoldenRetrievalCase(args: { if (args.testCase.expectTableEvidence && !tableEvidenceFound) { failures.push("expected table evidence in top 5"); } + if (args.testCase.forceEmbedding) { + if (args.telemetry.embedding_skipped) failures.push("forceEmbedding expected embedding to run"); + if (args.telemetry.retrieval_strategy === "search_cache") failures.push("forceEmbedding served search cache"); + if ( + args.telemetry.retrieval_strategy === "text_fast_path" || + args.telemetry.retrieval_strategy === "document_lookup_fast_path" + ) { + failures.push(`forceEmbedding returned lexical strategy ${args.telemetry.retrieval_strategy}`); + } + if (args.telemetry.coverage_gate_decision === "accepted") failures.push("forceEmbedding returned coverage gate"); + if (vectorLayerCount <= 0) failures.push("forceEmbedding found no vector-layer candidates"); + } return { id: args.testCase.id, query: args.testCase.query, + forceEmbedding: args.testCase.forceEmbedding ?? false, expectedQueryClass: args.testCase.expectedQueryClass, actualQueryClass, expectedDocumentSubstrings: args.testCase.expectedDocumentSubstrings, @@ -560,6 +579,7 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] } return counts; }, {}); + const forceEmbeddingResults = results.filter((result) => result.forceEmbedding); return { case_count: results.length, document_recall_at_5: Number( @@ -588,6 +608,8 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] embedding_skip_reason_counts: embeddingSkipReasonCounts, text_fast_path_reason_counts: textFastPathReasonCounts, retrieval_layer_counts: layerCounts, + force_embedding_case_count: forceEmbeddingResults.length, + force_embedding_failure_count: forceEmbeddingResults.filter((result) => result.failures.length > 0).length, median_text_candidate_budget: percentile(textCandidateBudgets, 50), second_stage_rerank_rate: Number( (results.filter((result) => result.secondStageRerankUsed).length / Math.max(results.length, 1)).toFixed(4), @@ -661,6 +683,8 @@ function printHumanSummary(summary: ReturnType, queryClass?: RagQueryClass, queryVariants: string[] = [], @@ -1448,6 +1455,7 @@ export function retrievalPlanCacheQuery( `mode:${modeKey(args)}`, `topK:${args.topK ?? 8}`, `min:${args.minSimilarity ?? 0.15}`, + `forceEmbedding:${args.forceEmbedding ? "1" : "0"}`, `rag:${ragDeepMemoryVersion}`, ].join("|"); return queryCacheKeyForStorage(cacheKey); @@ -1459,7 +1467,6 @@ function scopedSearchCacheKey(args: SearchChunksArgs, queryClass?: RagQueryClass args.ownerId ?? "anonymous", scopeKey(args), retrievalPlanCacheQuery(args, queryClass, queryVariants), - args.forceEmbedding ? "force-embedding" : "", ].join("|"); } @@ -1546,7 +1553,7 @@ type SharedCacheMissReason = function sharedCacheSelector( supabase: ReturnType, kind: SharedCacheKind, - args: Pick, + args: Pick, indexingVersion: string, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), ) { @@ -1711,7 +1718,10 @@ async function getSharedCachedSearch( } async function getSharedCachedAnswer( - args: Pick, + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, startedAt: number, ) { if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return null; @@ -1744,7 +1754,7 @@ async function getSharedCachedAnswer( async function replaceSharedCacheRow( kind: SharedCacheKind, - args: Pick, + args: Pick, payload: unknown, ttlMs: number, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), @@ -1796,7 +1806,10 @@ function setSharedCachedSearch( } function setSharedCachedAnswer( - args: Pick, + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, answer: RagAnswer, ) { if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return; @@ -5370,6 +5383,9 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { // When the provider is source-only (offline mode, or auto mode without a usable key) we must // never call OpenAI for embeddings; retrieval falls back to the lexical text-fast-path only. const sourceOnlyRetrieval = isSourceOnlyMode(); + if (args.forceEmbedding && sourceOnlyRetrieval) { + throw new Error("forceEmbedding requires embedding-capable retrieval; source-only mode cannot exercise vectors."); + } // 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(); @@ -5446,7 +5462,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.shared_cache_miss_reason = sharedCached.reason; } - if (shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) { + if (!args.forceEmbedding && shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) { // Item 10 follow-up (RC6): a typo can make an on-topic query ("schizophrenai management") look // unsupported and short-circuit before any layer runs. Before giving up, trigram-correct the // query against the known clinical-term vocabulary; if it changes, re-run the whole retrieval @@ -5704,8 +5720,8 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry, }); const coverageGate = evaluateEvidenceCoverageGate(args.query, coverageGateResults, queryClassification.queryClass); - applyCoverageGateTelemetry(telemetry, coverageGate, coverageGate.accepted); - if (coverageGate.accepted) { + applyCoverageGateTelemetry(telemetry, coverageGate, !args.forceEmbedding && coverageGate.accepted); + if (!args.forceEmbedding && coverageGate.accepted) { telemetry.retrieval_strategy = coverageGate.strategy; recordSearchScoreTelemetry(telemetry, coverageGateResults); setCachedSearch(args, coverageGateResults, telemetry, queryVariants); @@ -5733,7 +5749,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { } catch (error) { // In auto mode a failed embedding call (e.g. quota exhausted) degrades to the lexical // results already gathered rather than failing the whole search. "openai" mode rethrows. - if (!allowsAutoDegrade()) throw error; + if (args.forceEmbedding || !allowsAutoDegrade()) throw error; telemetry.embedding_skipped = true; telemetry.embedding_skip_reason = sourceOnlyReason(error); telemetry.vector_skipped_reason = classifyProviderFailure(error); @@ -5827,10 +5843,14 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { latencyMs: hybridResult.latencyMs, topScore: layerTopScore((hybridData ?? []) as SearchResult[]), }); + const vectorCandidates = mergeSearchResults( + mergeSearchResults((hybridData ?? []) as SearchResult[], embeddingFieldCandidates), + indexUnitCandidates, + ); if (!hybridError) { const rerankStartedAt = Date.now(); - const merged = mergeSearchResults((hybridData ?? []) as SearchResult[], textFastResults); + const merged = args.forceEmbedding ? vectorCandidates : mergeSearchResults(vectorCandidates, textFastResults); const mergedWithMetadata = await attachDocumentRankingMetadata(supabase, merged, args.ownerId); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -5888,7 +5908,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { return (data ?? []) as SearchResult[]; }), ).catch((error) => { - if (textFastResults.length > 0) return [] as SearchResult[][]; + if (!args.forceEmbedding && textFastResults.length > 0) return [] as SearchResult[][]; throw error; }); const fallbackLatencyMs = Date.now() - fallbackRpcStartedAt; @@ -5900,9 +5920,13 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { }); const rerankStartedAt = Date.now(); + const fallbackVectorCandidates = mergeSearchResults( + mergeSearchResults(resultSets.flat(), embeddingFieldCandidates), + indexUnitCandidates, + ); const mergedWithMetadata = await attachDocumentRankingMetadata( supabase, - mergeSearchResults(resultSets.flat(), textFastResults), + args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults), args.ownerId, ); const memoryBoost = await withMemoryBoostedCandidates({ diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts index deffec1cd..b41fd45f2 100644 --- a/tests/eval-quality.test.ts +++ b/tests/eval-quality.test.ts @@ -9,12 +9,13 @@ import { sourceWarningsForRagQualityAnswer, type RagQualityResult, } from "../scripts/eval-quality"; -import type { GoldenRetrievalResult } from "../scripts/eval-retrieval"; +import { evaluateGoldenRetrievalCase, type GoldenRetrievalResult } from "../scripts/eval-retrieval"; function retrievalResult(overrides: Partial = {}): GoldenRetrievalResult { const base: GoldenRetrievalResult = { id: "retrieval-1", query: "What ANC threshold should withhold clozapine?", + forceEmbedding: false, expectedQueryClass: "table_threshold", actualQueryClass: "table_threshold", expectedDocumentSubstrings: ["clozapine.pdf"], @@ -165,6 +166,8 @@ describe("eval quality reporting", () => { structured_threshold_text_match: 1, }); expect(report.retrieval.summary.second_stage_rerank_rate).toBe(0.5); + expect(report.retrieval.summary.force_embedding_case_count).toBe(0); + expect(report.retrieval.summary.force_embedding_failure_count).toBe(0); expect(report.rag.summary.unsupported_correct_rate).toBe(0); expect(report.rag.summary.numeric_grounding_failure_rate).toBeCloseTo(0.3333, 4); expect(report.rag.summary.source_governance_danger_failure_rate).toBeCloseTo(0.3333, 4); @@ -179,6 +182,53 @@ describe("eval quality reporting", () => { ); }); + it("fails forced-embedding retrieval cases that return from cache, coverage, or lexical paths", () => { + const result = evaluateGoldenRetrievalCase({ + testCase: { + id: "vector-regression", + query: "How is panic disorder managed?", + expectedQueryClass: "broad_summary", + expectedDocumentSubstrings: [], + expectedContentTerms: [], + topK: 8, + expectTableEvidence: false, + forceEmbedding: true, + }, + results: [], + telemetry: { + query_class: "broad_summary", + retrieval_strategy: "text_fast_path", + embedding_skipped: true, + embedding_skip_reason: "strong_document_text_score", + text_fast_path_reason: "strong_document_text_score", + text_candidate_budget: 32, + text_candidate_count: 5, + vector_candidate_count: 0, + retrieval_layer_counts: { text_candidates: 5 }, + coverage_gate_decision: "accepted", + coverage_gate_reason: "document_title_evidence_gate", + second_stage_rerank_used: false, + }, + latencyMs: 10, + }); + + expect(result.forceEmbedding).toBe(true); + expect(result.failures).toEqual( + expect.arrayContaining([ + "forceEmbedding expected embedding to run", + "forceEmbedding returned lexical strategy text_fast_path", + "forceEmbedding returned coverage gate", + "forceEmbedding found no vector-layer candidates", + ]), + ); + const report = buildEvalQualityReport({ retrievalResults: [result], ragResults: [] }); + expect(report.retrieval.summary.force_embedding_case_count).toBe(1); + expect(report.retrieval.summary.force_embedding_failure_count).toBe(1); + expect(report.blocking_threshold_failures).toEqual( + expect.arrayContaining([expect.stringContaining("retrieval force_embedding_failure_count 1 above 0")]), + ); + }); + it("derives source governance warnings for direct RAG answers without precomputed warnings", () => { const warnings = sourceWarningsForRagQualityAnswer({ sourceGovernanceWarnings: undefined, diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index ddb62411e..a2f06474e 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -127,6 +127,18 @@ describe("retrieval query variants", () => { expect(textCandidateBudgetForQueryClass("unsupported_or_general", 12)).toBe(24); }); + it("keeps forced-embedding retrieval out of the ordinary search cache key", () => { + const baseArgs = { + query: "How is panic disorder managed?", + topK: 8, + minSimilarity: 0.12, + }; + + expect(retrievalPlanCacheQuery(baseArgs, "broad_summary")).not.toBe( + retrievalPlanCacheQuery({ ...baseArgs, forceEmbedding: true }, "broad_summary"), + ); + }); + it("allows direct document title hits to skip embedding retrieval", () => { expect( decideTextFastPath( From 9dad71bedb906bae0c629bcd459f805a364d8a15 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:01:43 +0800 Subject: [PATCH 03/15] Fix content access gaps for anonymous and public document users - Degrade invalid bearer tokens to anonymous scope instead of 401 - Allow public document read routes (list, detail, signed-url, search, images) - Align registry routes with medications/differentials auth-signal short-circuit - Let DocumentViewer load public sources without requiring sign-in - Add regression tests and update access-control expectations --- src/app/api/documents/[id]/route.ts | 13 +- src/app/api/documents/[id]/search/route.ts | 17 +- .../api/documents/[id]/signed-url/route.ts | 15 +- src/app/api/documents/route.ts | 13 +- src/app/api/images/[id]/signed-url/route.ts | 15 +- src/app/api/registry/records/[slug]/route.ts | 11 +- src/app/api/registry/records/route.ts | 9 +- src/components/DocumentViewer.tsx | 28 +-- src/lib/public-api-access.ts | 11 ++ src/lib/supabase/auth.ts | 6 +- tests/api-validation-contract.test.ts | 7 + tests/private-access-routes.test.ts | 176 +++++++++++------- tests/registry-records-route.test.ts | 14 +- 13 files changed, 195 insertions(+), 140 deletions(-) diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 55d6a1558..4f404a004 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -8,6 +8,7 @@ import { invalidateRagCachesForDocumentMutation } from "@/lib/rag"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; import { writeAuditLog } from "@/lib/audit"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; @@ -280,13 +281,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - const { data: document, error } = await supabase - .from("documents") - .select("*") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + const access = await publicAccessContext(request, supabase); + const { data: document, error } = await withOwnerReadScope( + supabase.from("documents").select("*").eq("id", id), + access.ownerId, + ).maybeSingle(); if (error) throw new Error(error.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); diff --git a/src/app/api/documents/[id]/search/route.ts b/src/app/api/documents/[id]/search/route.ts index 4ab60b1ed..d153f9859 100644 --- a/src/app/api/documents/[id]/search/route.ts +++ b/src/app/api/documents/[id]/search/route.ts @@ -5,7 +5,8 @@ import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRouteParams } from "@/lib/validation/params"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -181,13 +182,11 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentSearchParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - const { data: document, error: documentError } = await supabase - .from("documents") - .select("id,metadata") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + const access = await publicAccessContext(request, supabase); + const { data: document, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").eq("id", id), + access.ownerId, + ).maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); @@ -197,7 +196,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: p_document_id: id, p_query: query, match_count: limit, - p_owner_id: user.id, + p_owner_id: access.ownerId, }); if (!rpcError) { diff --git a/src/app/api/documents/[id]/signed-url/route.ts b/src/app/api/documents/[id]/signed-url/route.ts index 043d01118..5dd6dc8ea 100644 --- a/src/app/api/documents/[id]/signed-url/route.ts +++ b/src/app/api/documents/[id]/signed-url/route.ts @@ -5,7 +5,8 @@ import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -31,13 +32,11 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(_request, supabase); - const { data: document, error } = await supabase - .from("documents") - .select("storage_path,file_type") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + const access = await publicAccessContext(_request, supabase); + const { data: document, error } = await withOwnerReadScope( + supabase.from("documents").select("storage_path,file_type").eq("id", id), + access.ownerId, + ).maybeSingle(); if (error) throw new Error(error.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 52814c3a6..8fa0958be 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -4,7 +4,8 @@ import { demoDocuments } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRequestQuery, queryBoolean, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; @@ -130,11 +131,11 @@ export async function GET(request: Request) { } = parseRequestQuery(request, documentListQuerySchema, "Invalid document list query."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - let query = supabase - .from("documents") - .select(DOCUMENT_LIST_COLUMNS, { count: "exact" }) - .eq("owner_id", user.id) + const access = await publicAccessContext(request, supabase); + let query = withOwnerReadScope( + supabase.from("documents").select(DOCUMENT_LIST_COLUMNS, { count: "exact" }), + access.ownerId, + ) .order("created_at", { ascending: false }) .range(offset, offset + limit - 1); diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index b061aaf8a..9ca925d49 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -6,7 +6,8 @@ import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -31,7 +32,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid image id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(_request, supabase); + const access = await publicAccessContext(_request, supabase); const { data: image, error } = await supabase .from("document_images") .select("document_id,storage_path,mime_type,caption,metadata") @@ -41,12 +42,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (error) throw new Error(error.message); if (!image) return NextResponse.json({ error: "Image not found." }, { status: 404 }); - const { data: document, error: documentError } = await supabase - .from("documents") - .select("id,metadata") - .eq("id", image.document_id) - .eq("owner_id", user.id) - .maybeSingle(); + const { data: document, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").eq("id", image.document_id), + access.ownerId, + ).maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Image not found." }, { status: 404 }); diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index bfa21c873..a966ab37a 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { publicAccessContext } from "@/lib/public-api-access"; +import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; import { getFormRecord } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -62,6 +62,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } + if (!hasPublicApiAuthSignal(request)) { + const payload = publicRegistryDetailPayload(kind, normalizedSlug); + if (!payload) return notFoundResponse(normalizedSlug); + return registryResponse({ + ...payload, + publicAccess: true, + }); + } + const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index 5897a1525..ab07badfb 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { publicAccessContext } from "@/lib/public-api-access"; +import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; import { rankFormRecords, formRecords } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -78,6 +78,13 @@ export async function GET(request: Request) { }); } + if (!hasPublicApiAuthSignal(request)) { + return registryResponse({ + ...publicRegistryPayload(kind, q, limit), + publicAccess: true, + }); + } + const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index bd4c4d222..8d875334f 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1914,7 +1914,6 @@ export function DocumentViewer({ const [documentSearchError, setDocumentSearchError] = useState(null); const [reviewingTableFactId, setReviewingTableFactId] = useState(null); const [isOnline, setIsOnline] = useState(true); - const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false); const [localProjectReady, setLocalProjectReady] = useState(true); const [mobileActionsOpen, setMobileActionsOpen] = useState(false); const [useNativePdfViewer, setUseNativePdfViewer] = useState(() => getInitialPdfViewerMode().useNativePdfViewer); @@ -1927,6 +1926,7 @@ export function DocumentViewer({ const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true"); const localNoAuthMode = isLocalNoAuthMode(); const clientDemoMode = localNoAuthMode || serverDemoMode; + const canViewSourceDocuments = localProjectReady; const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); useEffect(() => { @@ -2002,10 +2002,10 @@ export function DocumentViewer({ }, [isConfigured]); useEffect(() => { - if (!canUsePrivateApis && authStatus === "loading") { + if (!canViewSourceDocuments && authStatus === "loading") { return () => undefined; } - if (!canUsePrivateApis) { + if (!canViewSourceDocuments) { return () => undefined; } @@ -2141,7 +2141,7 @@ export function DocumentViewer({ }, [ authStatus, authorizationHeader, - canUsePrivateApis, + canViewSourceDocuments, clientDemoMode, documentId, chunkId, @@ -2208,15 +2208,6 @@ export function DocumentViewer({ }; }, []); - useEffect(() => { - if (canUsePrivateApis || authStatus !== "loading") { - return () => undefined; - } - - const timeout = window.setTimeout(() => setAuthLoadingTimedOut(true), 3000); - return () => window.clearTimeout(timeout); - }, [authStatus, canUsePrivateApis]); - async function summarize() { if (!canSummarizeDocument) { setSummaryError("Load a source document before summarising."); @@ -2247,15 +2238,8 @@ export function DocumentViewer({ } } - const authViewerError = - !canUsePrivateApis && (authStatus !== "loading" || authLoadingTimedOut) - ? isConfigured - ? "Sign in to open private source documents." - : "Supabase browser authentication is not configured for private source documents." - : null; - const effectiveLoadingDocument = !canUsePrivateApis - ? authStatus === "loading" && !authLoadingTimedOut && loadingDocument - : loadingDocument; + const authViewerError = null; + const effectiveLoadingDocument = loadingDocument; const effectiveViewerError = authViewerError ?? viewerError; const viewerState = effectiveLoadingDocument ? "loading" diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 26b80f3fc..8ae3473e1 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -33,6 +33,17 @@ export function hasPublicApiAuthSignal(request: Request) { return cookieHeader.includes("sb-"); } +type OwnerScopedQuery = { + eq(column: string, value: unknown): T; + is(column: string, value: null): T; +}; + +/** Scope document reads to the authenticated owner or public (owner_id IS NULL) rows. */ +export function withOwnerReadScope>(query: T, ownerId: string | undefined): T { + if (ownerId) return query.eq("owner_id", ownerId); + return query.is("owner_id", null); +} + export async function publicAccessContext(request: Request, supabase: AdminClient) { const user = await getOptionalAuthenticatedUser(request, supabase); if (user) { diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index db67f2cbe..044384b07 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -128,10 +128,10 @@ export async function getOptionalAuthenticatedUser( const token = extractSessionAccessToken(request); if (token) { const { data, error } = await supabase.auth.getUser(token); - if (error || !data.user?.id) { - throw new AuthenticationError(); + if (!error && data.user?.id) { + return { id: data.user.id }; } - return { id: data.user.id }; + // Invalid or expired Bearer token: fall through to cookie session, then anonymous. } return getUserFromRequestCookies(request); diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts index 4bdbf8834..5d68487d8 100644 --- a/tests/api-validation-contract.test.ts +++ b/tests/api-validation-contract.test.ts @@ -180,6 +180,13 @@ function mockRuntime(client: ReturnType) { vi.doMock("@/lib/supabase/auth", () => ({ AuthenticationError: class AuthenticationError extends Error {}, requireAuthenticatedUser, + getOptionalAuthenticatedUser: vi.fn(async (request: Request) => { + const authorization = request.headers.get("authorization") ?? ""; + if (/^Bearer\s+\S+/i.test(authorization)) return { id: userId }; + const cookieHeader = request.headers.get("cookie") ?? ""; + if (cookieHeader.includes("sb-")) return { id: userId }; + return null; + }), unauthorizedResponse: () => new Response(JSON.stringify({ error: "Authentication required." }), { status: 401, diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 45b5b1e61..9d91205f7 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -376,14 +376,18 @@ afterEach(() => { }); describe("private document API access", () => { - it("still requires a valid token even when local no-auth mode is enabled", async () => { - const client = createSupabaseMock(); + it("lists public documents without auth in local no-auth mode", async () => { + const documents = [{ id: documentId, owner_id: null, title: "Public guideline" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client, undefined, { localNoAuth: true }); const { GET } = await import("../src/app/api/documents/route"); const response = await GET(localPortRequest(4298, "/api/documents")); + const body = await payload(response); - expect(response.status).toBe(401); + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); + expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); expect(client.auth.getUser).not.toHaveBeenCalled(); }); @@ -398,28 +402,39 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(client.auth.getUser).toHaveBeenCalledTimes(1); }); - it("rejects local no-auth private calls from unmanaged localhost ports before Supabase access", async () => { + it("rejects local no-auth upload calls from unmanaged localhost ports before Supabase access", async () => { const client = createSupabaseMock(); mockRuntime(client, undefined, { localNoAuth: true }); - const { GET } = await import("../src/app/api/documents/route"); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" })); - const response = await GET(localPortRequest(3000, "/api/documents")); + const response = await POST( + localPortRequest(3000, "/api/upload", { + method: "POST", + body: formData, + }), + ); expect(response.status).toBe(401); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); - it("rejects managed-port private calls with stale localhost referers before Supabase access", async () => { + it("rejects managed-port upload calls with stale localhost referers before Supabase access", async () => { const client = createSupabaseMock(); mockRuntime(client, undefined, { localNoAuth: true }); - const { GET } = await import("../src/app/api/documents/route"); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" })); - const response = await GET( - localPortRequest(4298, "/api/documents", { + const response = await POST( + localPortRequest(4298, "/api/upload", { + method: "POST", headers: { referer: "http://localhost:3000/", }, + body: formData, }), ); @@ -428,66 +443,20 @@ describe("private document API access", () => { expect(client.from).not.toHaveBeenCalled(); }); - it("resolves local no-auth owner from documents before listing auth users", async () => { - const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; - const client = createSupabaseMock((call) => { - if (call.table === "documents" && call.selected === "owner_id") { - return ok({ owner_id: userId }); - } - if (call.table === "documents") { - return ok(documents); - } - return ok([]); - }); - mockRuntime(client, undefined, { localNoAuth: true }); + it("lists public documents for unauthenticated callers", async () => { + const documents = [{ id: documentId, owner_id: null, title: "Public guideline", status: "indexed" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); + mockRuntime(client); const { GET } = await import("../src/app/api/documents/route"); - const response = await GET(localPortRequest(4298, "/api/documents")); + const response = await GET(request("/api/documents?includeMeta=false")); const body = await payload(response); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents); expect(client.auth.getUser).not.toHaveBeenCalled(); - expect(client.from).not.toHaveBeenCalled(); - }); - - it("resolves configured local no-auth owner email before document fallback", async () => { - const documents = [{ id: documentId, owner_id: userId, title: "Configured owner document" }]; - const client = createSupabaseMock((call) => { - if (call.table === "documents" && call.selected === "owner_id") { - return ok({ owner_id: otherUserId }); - } - if (call.table === "documents") { - return ok(documents); - } - return ok([]); - }); - client.auth.admin.listUsers.mockResolvedValueOnce({ - data: { users: [{ id: userId, email: "clinician@example.test" }], nextPage: 0 }, - error: null, - }); - mockRuntime(client, undefined, { localNoAuth: true, localOwnerEmail: "clinician@example.test" }); - const { GET } = await import("../src/app/api/documents/route"); - - const response = await GET(localPortRequest(4298, "/api/documents")); - const body = await payload(response); - - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); - expect(client.auth.admin.listUsers).not.toHaveBeenCalled(); - expect(client.from).not.toHaveBeenCalled(); - }); - - it("rejects unauthenticated document listing", async () => { - const client = createSupabaseMock(); - mockRuntime(client); - const { GET } = await import("../src/app/api/documents/route"); - - const response = await GET(request("/api/documents")); - - expect(response.status).toBe(401); - expect(await payload(response)).toEqual({ error: "Authentication required." }); - expect(client.from).not.toHaveBeenCalled(); + expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); + expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId }); }); it("filters authenticated document listing by owner", async () => { @@ -602,6 +571,48 @@ describe("private document API access", () => { expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); + it("allows anonymous signed URLs for public documents", async () => { + const client = createSupabaseMock((call) => { + if ( + call.table === "documents" && + call.filters.some((filter) => filter.column === "owner_id" && filter.value === null) + ) { + return ok({ storage_path: "public/documents/guideline.pdf", file_type: "application/pdf" }); + } + return ok(null); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/signed-url/route"); + + const response = await GET(request(`/api/documents/${documentId}/signed-url`), { + params: Promise.resolve({ id: documentId }), + }); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.url).toContain("public/documents/guideline.pdf"); + expect(client.auth.getUser).not.toHaveBeenCalled(); + }); + + it("rejects anonymous signed URLs for private documents", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.filters.some((filter) => filter.column === "owner_id")) { + return ok(null); + } + return ok(null); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/signed-url/route"); + + const response = await GET(request(`/api/documents/${otherDocumentId}/signed-url`), { + params: Promise.resolve({ id: otherDocumentId }), + }); + + expect(response.status).toBe(404); + expect(await payload(response)).toEqual({ error: "Document not found." }); + expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); + }); + it("allows image signed URLs only when the parent document is owned", async () => { const client = createSupabaseMock((call) => { if (call.table === "document_images") { @@ -2788,6 +2799,41 @@ describe("private document API access", () => { ); }); + it("degrades invalid bearer tokens to anonymous search scope", async () => { + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [], + telemetry: { + search_cache_hit: false, + text_fast_path_latency_ms: 0, + embedding_skipped: true, + embedding_latency_ms: 0, + embedding_cache_hit: false, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, + retrieval_strategy: "text_fast_path", + }, + })); + const client = createSupabaseMock(); + mockRuntime(client, { searchChunksWithTelemetry }); + const searchRoute = await import("../src/app/api/search/route"); + + const response = await searchRoute.POST( + request("/api/search", { + method: "POST", + headers: { + authorization: "Bearer expired-token", + "content-type": "application/json", + }, + body: JSON.stringify({ query: "monitoring" }), + }), + ); + + expect(response.status).toBe(200); + expect(searchChunksWithTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }), + ); + }); + it("rate limits anonymous answer bursts before generation", async () => { const answerQuestionWithScope = vi.fn(async () => ({ answer: "Public evidence.", diff --git a/tests/registry-records-route.test.ts b/tests/registry-records-route.test.ts index 388bd52e5..5571c270c 100644 --- a/tests/registry-records-route.test.ts +++ b/tests/registry-records-route.test.ts @@ -198,11 +198,8 @@ describe("registry records API", () => { expect(payload.records.some((record) => record.slug === "13yarn")).toBe(true); expect(payload.matches?.[0]?.record.slug).toBe("13yarn"); expect(client.from).not.toHaveBeenCalled(); - expect(client.rpc).toHaveBeenCalledWith( - "consume_api_subject_rate_limit", - expect.objectContaining({ p_bucket: "registry" }), - ); - expect(client.rpc).not.toHaveBeenCalledWith("consume_api_rate_limit", expect.anything()); + expect(client.rpc).not.toHaveBeenCalled(); + expect(client.auth.getUser).not.toHaveBeenCalled(); }); it("rejects an invalid kind with a validation error", async () => { @@ -311,11 +308,8 @@ describe("registry records API", () => { expect(payload.record.slug).toBe("13yarn"); expect(payload.linkedDocuments).toEqual([]); expect(client.from).not.toHaveBeenCalled(); - expect(client.rpc).toHaveBeenCalledWith( - "consume_api_subject_rate_limit", - expect.objectContaining({ p_bucket: "registry" }), - ); - expect(client.rpc).not.toHaveBeenCalledWith("consume_api_rate_limit", expect.anything()); + expect(client.rpc).not.toHaveBeenCalled(); + expect(client.auth.getUser).not.toHaveBeenCalled(); }); it("returns 404 for an unknown slug", async () => { From 3f38a33f47eeb80c4a9b5b7500852b7cd7a55644 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:12:10 +0800 Subject: [PATCH 04/15] Reconcile search_schema_health index drift on live Supabase Create missing retrieval-support indexes (trgm, composite btree, partial miss log) that were absent or only present under legacy names on live. Update search_schema_health() to accept verified functional equivalents during rollout. Set search_path for pg_trgm gin_trgm_ops in extensions. Verified on linked project: search_schema_health() ok=true, missing=[]. --- ...180000_reconcile_search_health_indexes.sql | 202 ++++++++++++++++++ supabase/schema.sql | 22 ++ tests/supabase-schema.test.ts | 15 ++ 3 files changed, 239 insertions(+) create mode 100644 supabase/migrations/20260705180000_reconcile_search_health_indexes.sql diff --git a/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql new file mode 100644 index 000000000..21e0c281d --- /dev/null +++ b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql @@ -0,0 +1,202 @@ +-- Reconcile live index drift reported by search_schema_health() on 2026-07-05. +-- Creates canonical retrieval-support indexes that are missing on live (or only +-- present under legacy names) and teaches search_schema_health() to accept +-- verified functional equivalents so replays do not false-fail mid-rollout. + +set search_path = public, extensions, pg_catalog; + +create index if not exists documents_title_trgm_idx + on public.documents using gin (lower(coalesce(title, '') || ' ' || coalesce(file_name, '')) gin_trgm_ops); + +create index if not exists document_chunks_content_trgm_idx + on public.document_chunks using gin (lower(coalesce(section_heading, '') || ' ' || coalesce(content, '')) gin_trgm_ops); + +create index if not exists document_labels_label_trgm_idx + on public.document_labels using gin (lower(label) gin_trgm_ops); + +create index if not exists document_summaries_summary_trgm_idx + on public.document_summaries using gin (lower(summary) gin_trgm_ops); + +create index if not exists document_table_facts_owner_document_page_idx + on public.document_table_facts(owner_id, document_id, page_number); + +create index if not exists document_index_units_owner_chunk_type_idx + on public.document_index_units(owner_id, source_chunk_id, unit_type) + where source_chunk_id is not null; + +create index if not exists document_pages_document_idx + on public.document_pages(document_id, page_number); + +create index if not exists document_sections_document_idx + on public.document_sections(document_id, section_index); + +create index if not exists rag_retrieval_logs_owner_created_idx + on public.rag_retrieval_logs(owner_id, created_at desc); + +create index if not exists rag_retrieval_logs_miss_idx + on public.rag_retrieval_logs(is_miss, created_at desc) + where is_miss = true; + +create or replace function public.search_schema_health() +returns jsonb +language plpgsql +stable +security definer +set search_path = public, extensions, pg_catalog, pg_temp +as $$ +declare + missing text[] := array[]::text[]; + vector_type_oid oid; + vector_schema text; + index_name text; + legacy_ivfflat_indexes text[]; + zero_vec extensions.vector(1536); + probe_text text := 'schema health probe zzznomatch'; + hybrid_rpcs text[] := array[ + 'match_document_chunks_hybrid', + 'match_document_index_units_hybrid', + 'match_document_embedding_fields_hybrid', + 'match_document_memory_cards_hybrid' + ]; + rpc_name text; + required_indexes constant text[] := array[ + 'documents_title_trgm_idx', + 'document_chunks_content_trgm_idx', + 'document_labels_label_trgm_idx', + 'document_summaries_summary_trgm_idx', + 'document_chunks_embedding_hnsw_idx', + 'document_embedding_fields_embedding_hnsw_idx', + 'document_memory_cards_embedding_hnsw_idx', + 'documents_indexed_owner_title_idx', + 'document_table_facts_owner_document_page_idx', + 'document_embedding_fields_owner_chunk_idx', + 'document_index_units_owner_chunk_type_idx', + 'document_table_facts_source_image_idx', + 'document_pages_document_idx', + 'document_sections_document_idx', + 'document_chunks_document_idx', + 'document_memory_cards_document_idx', + 'document_embedding_fields_document_idx', + 'document_table_facts_document_idx', + 'document_index_units_document_idx', + 'rag_retrieval_logs_owner_created_idx', + 'rag_retrieval_logs_miss_idx', + 'rag_retrieval_logs_strategy_idx' + ]; + -- Verified live equivalents: same table/column intent, different migration-era name. + index_aliases constant jsonb := jsonb_build_object( + 'documents_title_trgm_idx', jsonb_build_array('documents_title_search_tsv_idx', 'documents_title_search_idx'), + 'document_chunks_content_trgm_idx', jsonb_build_array('document_chunks_search_tsv_idx', 'document_chunks_search_idx'), + 'document_table_facts_owner_document_page_idx', jsonb_build_array('document_table_facts_owner_idx'), + 'document_pages_document_idx', jsonb_build_array('document_pages_document_id_page_number_key'), + 'document_sections_document_idx', jsonb_build_array('document_sections_document_id_idx'), + 'rag_retrieval_logs_owner_created_idx', jsonb_build_array('rag_retrieval_logs_owner_id_idx') + ); +begin + select t.oid, n.nspname + into vector_type_oid, vector_schema + from pg_type t + join pg_namespace n on n.oid = t.typnamespace + where t.typname = 'vector' + and n.nspname = 'extensions' + limit 1; + + if vector_type_oid is null then + missing := array_append(missing, 'extensions.vector_type'); + end if; + + if to_regprocedure('public.match_document_chunks(extensions.vector, integer, double precision, uuid, uuid)') is null then + missing := array_append(missing, 'match_document_chunks.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_chunks_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_chunks_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_chunks_text(text, integer, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_chunks_text.signature'); + end if; + if to_regprocedure('public.match_document_lookup_chunks_text(text, uuid[], integer, uuid)') is null then + missing := array_append(missing, 'match_document_lookup_chunks_text.signature'); + end if; + if to_regprocedure('public.match_document_memory_cards_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_memory_cards_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_memory_cards_hybrid_v2(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_memory_cards_hybrid_v2.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_index_units_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_embedding_fields_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_embedding_fields_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_documents_for_query(text, integer, uuid)') is null then + missing := array_append(missing, 'match_documents_for_query.signature'); + end if; + if to_regprocedure('public.match_document_table_facts_text(text, integer, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_table_facts_text.signature'); + end if; + if to_regprocedure('public.explain_retrieval_rpc(text, text, integer, uuid, uuid[], boolean)') is null then + missing := array_append(missing, 'explain_retrieval_rpc.signature'); + end if; + if to_regclass('public.rag_retrieval_logs') is null then + missing := array_append(missing, 'rag_retrieval_logs.table'); + end if; + + foreach index_name in array required_indexes loop + if not exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relname = index_name + and c.relkind = 'i' + ) + and not ( + index_aliases ? index_name + and exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relkind = 'i' + and c.relname in ( + select jsonb_array_elements_text(index_aliases -> index_name) + ) + ) + ) then + missing := array_append(missing, index_name); + end if; + end loop; + + if vector_type_oid is not null then + zero_vec := (select ('[' || string_agg('0', ',') || ']') from generate_series(1, 1536))::extensions.vector(1536); + foreach rpc_name in array hybrid_rpcs loop + begin + execute format( + 'select 1 from public.%I($1, $2, 1, 0.1, null::uuid[], null::uuid) limit 1', + rpc_name + ) using zero_vec, probe_text; + exception + when undefined_function then + missing := array_append(missing, rpc_name || '.execution_signature'); + when others then + missing := array_append(missing, rpc_name || '.execution:' || SQLSTATE); + end; + end loop; + end if; + + select public.detect_legacy_ivfflat_indexes() into legacy_ivfflat_indexes; + + return jsonb_build_object( + 'ok', cardinality(missing) = 0, + 'missing', missing, + 'vector_extension_schema', vector_schema, + 'legacy_ivfflat_indexes', coalesce(legacy_ivfflat_indexes, array[]::text[]), + 'deferred_hnsw_indexes', array[]::text[], + 'checked_at', now() + ); +end; +$$; + +revoke execute on function public.search_schema_health() from public, anon, authenticated; +grant execute on function public.search_schema_health() to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 63657b132..16ce2230c 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -2396,6 +2396,15 @@ declare 'rag_retrieval_logs_miss_idx', 'rag_retrieval_logs_strategy_idx' ]; + -- Verified live equivalents: same table/column intent, different migration-era name. + index_aliases constant jsonb := jsonb_build_object( + 'documents_title_trgm_idx', jsonb_build_array('documents_title_search_tsv_idx', 'documents_title_search_idx'), + 'document_chunks_content_trgm_idx', jsonb_build_array('document_chunks_search_tsv_idx', 'document_chunks_search_idx'), + 'document_table_facts_owner_document_page_idx', jsonb_build_array('document_table_facts_owner_idx'), + 'document_pages_document_idx', jsonb_build_array('document_pages_document_id_page_number_key'), + 'document_sections_document_idx', jsonb_build_array('document_sections_document_id_idx'), + 'rag_retrieval_logs_owner_created_idx', jsonb_build_array('rag_retrieval_logs_owner_id_idx') + ); begin select t.oid, n.nspname into vector_type_oid, vector_schema @@ -2454,6 +2463,19 @@ begin where ns.nspname = 'public' and c.relname = index_name and c.relkind = 'i' + ) + and not ( + index_aliases ? index_name + and exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relkind = 'i' + and c.relname in ( + select jsonb_array_elements_text(index_aliases -> index_name) + ) + ) ) then missing := array_append(missing, index_name); end if; diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 68cd98773..6dd16d6c9 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -80,6 +80,10 @@ const registryCatalogPayloadMigration = readFileSync( new URL("../supabase/migrations/20260705030000_registry_catalog_payload.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const searchHealthIndexesMigration = readFileSync( + new URL("../supabase/migrations/20260705180000_reconcile_search_health_indexes.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); const ragQueriesRetentionMigration = readFileSync( new URL("../supabase/migrations/20260629060603_rag_queries_retention.sql", import.meta.url), "utf8", @@ -739,6 +743,17 @@ describe("Supabase schema Data API grants", () => { "add column if not exists catalog_payload jsonb not null default '{}'::jsonb", ); }); + + it("reconciles search_schema_health index drift with canonical creates and live aliases", () => { + expect(searchHealthIndexesMigration).toContain("create index if not exists documents_title_trgm_idx"); + expect(searchHealthIndexesMigration).toContain("create index if not exists document_labels_label_trgm_idx"); + expect(searchHealthIndexesMigration).toContain("create index if not exists rag_retrieval_logs_miss_idx"); + expect(searchHealthIndexesMigration).toContain("index_aliases constant jsonb := jsonb_build_object("); + expect(searchHealthIndexesMigration).toContain("'documents_title_search_tsv_idx'"); + expect(searchHealthIndexesMigration).toContain("'document_pages_document_id_page_number_key'"); + expect(schema).toContain("index_aliases constant jsonb := jsonb_build_object("); + expect(schema).toContain("jsonb_array_elements_text(index_aliases -> index_name)"); + }); }); describe("RC9 — lexical text path must not fabricate a cosine similarity", () => { From 940084176946fe0e1544f5ec0939df0dd58ecdf4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:19:45 +0800 Subject: [PATCH 05/15] test: add deep public access and production scope checks --- tests/public-access-deep.test.ts | 225 +++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 tests/public-access-deep.test.ts diff --git a/tests/public-access-deep.test.ts b/tests/public-access-deep.test.ts new file mode 100644 index 000000000..0801c4bd9 --- /dev/null +++ b/tests/public-access-deep.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const publicDocumentId = "11111111-1111-4111-8111-111111111111"; + +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); + +describe("public access deep checks", () => { + it("rejects unauthenticated access to operator-only routes", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + const auth = { + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => { + throw new auth.AuthenticationError(); + }), + unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }), + }; + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + + const cases = [ + { + routePath: "../src/app/api/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/jobs"), + }, + { + routePath: "../src/app/api/ingestion/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/jobs"), + }, + { + routePath: "../src/app/api/ingestion/batches/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/batches"), + }, + { + routePath: "../src/app/api/documents/bulk/route", + handler: "POST" as const, + request: new Request("http://localhost/api/documents/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete", documentIds: [publicDocumentId] }), + }), + }, + { + routePath: "../src/app/api/documents/[id]/summarize/route", + handler: "POST" as const, + request: new Request(`http://localhost/api/documents/${publicDocumentId}/summarize`, { method: "POST" }), + params: { id: publicDocumentId }, + }, + { + routePath: "../src/app/api/eval-cases/route", + handler: "POST" as const, + request: new Request("http://localhost/api/eval-cases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "What monitoring is needed?", + rating: "good", + answer: "Monitor FBC.", + queryMode: "auto", + queryClass: "table_threshold", + }), + }), + }, + ] as const; + + for (const testCase of cases) { + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + const mod = await import(testCase.routePath); + const handler = mod[testCase.handler]; + const response = await handler( + testCase.request, + "params" in testCase ? { params: Promise.resolve(testCase.params) } : undefined, + ); + expect(response.status, testCase.routePath).toBe(401); + } + }); + + it("does not expose secret values from the health endpoint", async () => { + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "super-secret-service-role-key", + OPENAI_API_KEY: "sk-super-secret-openai-key", + }, + isDemoMode: () => false, + })); + const { GET } = await import("../src/app/api/health/route"); + const response = await GET(new Request("https://clinical.example/api/health?deep=1")); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(serialized).not.toContain("super-secret-service-role-key"); + expect(serialized).not.toContain("sk-super-secret-openai-key"); + expect(body.checks.supabaseConfig).toBe("ok"); + expect(body.checks.openaiConfig).toBe("ok"); + }); + +}); + +describe("production anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("fails closed in production when anonymous global retrieval omits owner scope", async () => { + vi.doUnmock("@/lib/rag"); + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: "sk-test", + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + + await expect( + searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }), + ).rejects.toThrow(/ownerId|tenant/i); + }); +}); + +describe("test-runtime anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("allows anonymous global retrieval in test runtime where owner scope stays permissive", async () => { + vi.doUnmock("@/lib/rag"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: undefined, + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + in: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + const result = await searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }); + + expect(result.results).toEqual([]); + expect(result.telemetry).toBeDefined(); + }); +}); From a382754cce30010e187558412a4d54e7cd706328 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:26:19 +0800 Subject: [PATCH 06/15] refactor(ui): remove duplicate visual-evidence code from ClinicalDashboard Delete post-extraction dead code left in the monolith and trim unused imports. Also fix minor lint issues in favourites-hub, visual-evidence, and services-navigator. --- src/components/ClinicalDashboard.tsx | 433 +----------------- .../clinical-dashboard/favourites-hub.tsx | 1 - .../clinical-dashboard/visual-evidence.tsx | 1 - .../services/services-navigator-page.tsx | 6 +- 4 files changed, 9 insertions(+), 432 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..647d6e1a8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,18 +1,15 @@ "use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, Bell, BookOpen, - CheckCircle2, ChevronDown, ChevronRight, CircleUserRound, Clock3, - Copy, ExternalLink, FileImage, FileText, @@ -21,7 +18,6 @@ import { HelpCircle, Heart, Keyboard, - Layers, ListChecks, Loader2, LogOut, @@ -29,7 +25,6 @@ import { LockKeyhole, Palette, PanelTop, - Plus, Quote, RefreshCw, Search, @@ -48,66 +43,37 @@ import { import { type CSSProperties, type FormEvent, - type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { AccessibleTable } from "@/components/AccessibleTable"; -import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; -import { DocumentTagCloud } from "@/components/DocumentTagCloud"; -import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; -import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isLocalNoAuthMode } from "@/lib/env"; import { appBackdrop, answerSurface, - chatMicroAction, - clinicalDivider, cn, - EmptyState, - fieldControlPlain, fieldControlWithIcon, fieldIcon, floatingControl, - iconTilePremium, - metadataPill, - panelSubtle, primaryControl, - SourceProvenance, - SourceStatusBadge, - sourceCard, - subtleStatusPill, - tableCard, - tableCardHeader, - tableMicroActionRow, textMuted, - toneDanger, - toneInfo, - toneNeutral, toneSuccess, toneWarning, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; -import { SafeBoldText } from "@/components/SafeBoldText"; 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 { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -129,42 +95,22 @@ import { import { GuideDialog, GuideTrigger, - SectionHeading, UtilityDrawer, } from "@/components/clinical-dashboard/dashboard-shell"; import { - cleanDisplayTitle, sanitizeAnswerDisplayText, sanitizeDisplayText, } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, - SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; -import { - AnswerFeedbackPanel, - AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - simpleClinicalTableProps, - evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, - QuoteCards, - SafetyFindingsListContent, -} from "@/components/clinical-dashboard/evidence-panels"; +import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { emptyStates, errorCopy } from "@/lib/ui-copy"; +import { errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { DrawerGroupLabel, @@ -198,7 +144,7 @@ const DocumentDrawer = dynamic( ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; +import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -236,20 +182,15 @@ import { maxStoredAnswerTurns, savePersistedAnswerThread, } from "@/lib/answer-thread-storage"; -import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; +import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; import { - tagSearchText, type SmartDocumentTag, type SmartDocumentTagFacet, - type SmartDocumentTagTier, - type SmartDocumentTagQualityIssueKind, } from "@/lib/document-tags"; import type { ClinicalDocument, @@ -263,16 +204,13 @@ import type { RelatedDocument, SearchResult, SearchScopeSummary, - VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, - DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { createQuoteFollowUp, - type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates, } from "@/lib/ward-output"; @@ -487,367 +425,6 @@ async function readAnswerStream(response: Response, onProgress: (message: string function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } - -function compactClinicalTableCaption(item: VisualEvidenceCard) { - const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; - const cleaned = sourceTextForCompactDisplay(raw) - .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") - .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") - .replace(/\s{2,}/g, " ") - .trim(); - const caption = cleaned || "Clinical table"; - return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; -} - -function visualEvidenceHeader(item: VisualEvidenceCard) { - const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); - const titleText = sourceTextForCompactDisplay(titleSource).trim(); - const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); - const normalizedTitle = titleText.toLowerCase(); - const normalizedCaption = captionText.toLowerCase(); - const isDuplicateCaption = - Boolean(normalizedCaption) && - (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); - return { - title: titleText || captionText || "Visual evidence", - caption: isDuplicateCaption ? null : captionText, - }; -} - -function VisualEvidenceStrip({ - evidence, - collapsed = false, - embedded = false, -}: { - evidence: VisualEvidenceCard[]; - collapsed?: boolean; - embedded?: boolean; -}) { - function looksLikeTableText(value?: string | null) { - return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); - } - - if (collapsed) { - return ( -
- - - -
- ); - } - - const content = ( - <> - - {evidence.length === 0 ? ( - - ) : ( -
- {evidence.map((item) => { - const tableMarkdown = item.accessibleTableMarkdown?.trim() - ? item.accessibleTableMarkdown - : looksLikeTableText(item.tableTextSnippet) - ? item.tableTextSnippet - : null; - const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); - const tableCaption = compactClinicalTableCaption(item); - const sourceHeader = visualEvidenceHeader(item); - const displayLabels = smartEvidenceTags( - item.labels, - [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] - .filter(Boolean) - .join(" "), - ); - return ( -
-
- -
-
- {!hasStructuredTable ?

{sourceHeader.title}

: null} - {!hasStructuredTable && sourceHeader.caption ?

{sourceHeader.caption}

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

- {sourceTextForCompactDisplay(item.tableTextSnippet)} -

- ) : null} - {displayLabels.length ? ( -
- {displayLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
- - {formatCompactCitationLabel(item)} - - - {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"} - - {item.image_type && ( - - {item.image_type.replaceAll("_", " ")} - - )} - {!hasStructuredTable ? : null} - - - Open source - -
-
- ); - })} -
- )} - - ); - - if (embedded) return
{content}
; - - return ( -
- {content} -
- ); -} - -const evidenceTabIconMap: Record = { - Claims: CheckCircle2, - Quotes: Quote, - Tables: ListChecks, - Images: FileImage, - Gaps: AlertCircle, -}; - -function supportDotClass(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) { - return "bg-[color:var(--warning)]"; - } - return "bg-[color:var(--clinical-accent)]"; -} - -function supportLabel(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "Unsupported"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) - return "Partial"; - return "Direct"; -} - -function claimRowsForEvidencePanel(rows: AnswerEvidenceMapRow[], renderModel: AnswerRenderModel) { - if (rows.length) return rows.slice(0, 6); - return renderModel.primarySources.slice(0, 6).map((source, index) => ({ - id: source.id, - section: source.label || cleanDisplayTitle(source.title || source.file_name) || `Source ${index + 1}`, - detail: source.snippet || source.reason || "Open source passage to review the cited evidence.", - supportLevel: source.sourceStrength === "none" ? "partial" : source.sourceStrength, - citationCount: 1, - sourceStatus: - source.sourceStrength === "none" ? "Source requires review" : `${source.sourceStrength} source support`, - bestSourceLabel: source.label, - bestLinkedPassage: source.snippet || source.reason, - href: source.href, - })); -} - -function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[]; renderModel: AnswerRenderModel }) { - const claimRows = claimRowsForEvidencePanel(rows, renderModel); - const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length; - const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length; - - if (!claimRows.length) { - return ; - } - - return ( -
-
-
-

Claims checked

-
- - - Direct - - - - Partial - - - - Unsupported - -
-
-

- {directCount} direct · {partialCount} partial -

-
- -
- {claimRows.map((row, index) => ( - - - - - {row.section} - - {row.detail || row.bestLinkedPassage || row.bestSourceLabel} - - - - - ))} -
-
- ); -} - -function EvidenceGapsPanel({ warnings }: { warnings: string[] }) { - if (!warnings.length) { - return ( - - ); - } - - return ( -
- {warnings.map((warning, index) => ( -
- -
-

Gap {index + 1}

-

{warning}

-
-
- ))} -
- ); -} - -function MobileEvidenceTabPanel({ - tab, - renderModel, - visualEvidence, - answerEvidenceMapRows, - copiedQuotes, - onCopyQuotes, - onFollowUpQuote, - onScopeDocument, -}: { - tab: EvidenceTabName; - renderModel: AnswerRenderModel; - visualEvidence: VisualEvidenceCard[]; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - copiedQuotes: boolean; - onCopyQuotes: () => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - onScopeDocument: (documentId: string) => void; -}) { - if (tab === "Claims") { - return ; - } - - if (tab === "Tables") { - const tableEvidence = visualEvidence.filter((item) => item.accessibleTableMarkdown || item.tableRows?.length); - return tableEvidence.length ? ( -
- {tableEvidence.slice(0, 4).map((item, index) => ( -
- - - -
-

- {compactClinicalTableCaption(item)} -

-

- Table {index + 1} · p.{item.page_number ?? "n/a"} -

-
- - - -
- ))} -
- ) : ( - - ); - } - - if (tab === "Images") { - return visualEvidence.length ? ( - - ) : ( - - ); - } - - if (tab === "Quotes") { - return ( - - ); - } - - return ; -} - /** * A completed Q&A exchange kept on screen after a newer answer arrives, so * Answer mode reads as a conversation thread instead of replacing each result. diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index c1b2835a3..68072f029 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -22,7 +22,6 @@ import { favouriteItems, favouriteSets, favouriteTabs, - favouriteTypeCount, type FavouriteItem, type FavouriteSet, type FavouriteTabId, diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx index 2f84e2134..5dcca9e54 100644 --- a/src/components/clinical-dashboard/visual-evidence.tsx +++ b/src/components/clinical-dashboard/visual-evidence.tsx @@ -29,7 +29,6 @@ import { evidenceTabOrder, QuoteCards, simpleClinicalTableProps, - VerificationWorkspace, } from "@/components/clinical-dashboard/evidence-panels"; import { QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index ec53f934c..d07f9b17f 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -435,8 +435,10 @@ export function ServicesNavigatorPage() { const [localQuery, setLocalQuery] = useState(() => ({ urlQuery, value: initialQuery })); const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery; const registry = useRegistryRecords("service"); - const searchableRecords = - registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords; + const searchableRecords = useMemo( + () => (registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords), + [registry.records, registry.status], + ); const matches = useMemo(() => { const ranked = rankServiceRecords(searchableRecords, query); return ranked.length ? ranked.map((match) => match.service) : query.trim() ? [] : searchableRecords; From 2748e6ef11ea542ff76d9a5b70a06018c364d722 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:41:58 +0800 Subject: [PATCH 07/15] fix: allow anonymous setup-status on production deployments --- src/app/api/setup-status/route.ts | 25 ++++---------------- src/components/ClinicalDashboard.tsx | 27 +++++++++++++--------- tests/setup-status-route.test.ts | 34 ++++++++++++++++++++-------- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index bf0551c94..ba34855b8 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import { env, isDemoMode } from "@/lib/env"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; @@ -409,27 +408,11 @@ async function readSetupStatusPayload() { } } -async function requireProductionSetupStatusAuth(request: Request) { +export async function GET(request: Request) { const identity = localProjectRequestIdentityPayload(request); - if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) { - return identity; + if (!identity.localServer.safeLocalOrigin) { + return unsafeLocalProjectResponse(identity); } - await requireAuthenticatedUser(request, createAdminClient()); - return identity; -} -export async function GET(request: Request) { - try { - const identity = await requireProductionSetupStatusAuth(request); - if (!identity.localServer.safeLocalOrigin) { - return unsafeLocalProjectResponse(identity); - } - - return setupStatusResponse(await readSetupStatusPayload()); - } catch (error) { - if (error instanceof AuthenticationError) { - return unauthorizedResponse(error); - } - throw error; - } + return setupStatusResponse(await readSetupStatusPayload()); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 647d6e1a8..f73ad11e2 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3586,17 +3586,17 @@ export function ClinicalDashboard({ const showDegradedNotice = !isOnline || apiUnavailable; const hasMobileBottomSearch = searchMode !== "answer"; const showDesktopHomeComposer = - !loading && !error && - ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || - (searchMode === "documents" && - activeModeResultKind === "documents" && - documentMatches.length === 0 && - !modeSearchSubmitted) || - (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || - (activeModeResultKind === "differentials" && !modeSearchSubmitted) || + (activeModeResultKind === "tools" || activeModeResultKind === "favourites" || - activeModeResultKind === "tools"); + (!loading && + ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || + (searchMode === "documents" && + activeModeResultKind === "documents" && + documentMatches.length === 0 && + !modeSearchSubmitted) || + (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || + (activeModeResultKind === "differentials" && !modeSearchSubmitted)))); const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; // Favourites and Tools are content-rich hubs: they share the centred hero but // stay top-aligned so their lists start in a stable position. @@ -3614,6 +3614,7 @@ export function ClinicalDashboard({ const compactMobileBottomSearch = hasMobileBottomSearch && modeSearchSubmitted; const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); + const isDeployedApp = process.env.NODE_ENV === "production"; const renderDegradedNotice = () => (

{!isOnline ? "Reconnect before uploading documents, refreshing source URLs, or generating answers." - : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."} + : isDeployedApp + ? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly." + : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}

); diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts index 170aac7f5..f952b2ef8 100644 --- a/tests/setup-status-route.test.ts +++ b/tests/setup-status-route.test.ts @@ -7,10 +7,13 @@ afterEach(() => { }); describe("/api/setup-status", () => { - it("requires auth for non-local production requests before returning setup posture", async () => { + it("returns setup posture for anonymous production requests without exposing secret values", async () => { vi.stubEnv("NODE_ENV", "production"); - const getUser = vi.fn(); - const createAdminClient = vi.fn(() => ({ auth: { getUser } })); + const from = vi.fn(async () => ({ error: null, data: [], count: 0 })); + const createAdminClient = vi.fn(() => ({ + from, + rpc: vi.fn(), + })); vi.doMock("@/lib/env", () => ({ env: { NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", @@ -24,16 +27,29 @@ describe("/api/setup-status", () => { isLocalNoAuthMode: () => false, })); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/supabase/health", () => ({ + probeSupabaseHealth: vi.fn(async () => ({ ok: true })), + isSupabaseUnavailableError: () => false, + formatSupabaseUnavailableError: (error: unknown) => String(error), + })); + vi.doMock("@/lib/supabase/project", () => ({ + checkSupabaseProjectConfig: () => ({ status: "ready", detail: "Clinical KB Database target is configured." }), + formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.", + })); const { GET } = await import("../src/app/api/setup-status/route"); const response = await GET(new Request("https://clinical.example/api/setup-status")); const body = await response.json(); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); - expect(JSON.stringify(body)).not.toContain("OPENAI"); - expect(JSON.stringify(body)).not.toContain("Supabase"); - expect(createAdminClient).toHaveBeenCalledTimes(1); - expect(getUser).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(body).toMatchObject({ + demoMode: false, + checks: expect.arrayContaining([ + expect.objectContaining({ id: "env" }), + expect.objectContaining({ id: "openai" }), + ]), + }); + expect(JSON.stringify(body)).not.toContain("service-role-key"); + expect(JSON.stringify(body)).not.toContain("openai-key"); }); }); From b51196a6be1f1c611dba936e99ce43cd8fdeb810 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:01:45 +0800 Subject: [PATCH 08/15] fix(ui): center mobile hero search and harden composer portal - Hide footer Evidence/Sources chips on phone hero composers; scope stays in + menu - Suppress bottom-dock composer flash until hero portal slot is ready - Increase composer action/send touch targets to 44px on phones - Update Playwright tests for scope menu, Answer home geometry, and stress fallback --- docs/site-map.md | 5 +- next.config.ts | 9 + scripts/generate-site-map.ts | 5 +- src/app/applications/layout.tsx | 11 - src/app/applications/loading.tsx | 5 - src/app/applications/page.tsx | 12 - src/app/globals.css | 23 +- src/components/ClinicalDashboard.tsx | 33 +-- src/components/applications-launcher-page.tsx | 166 ++----------- .../clinical-dashboard/dashboard-nav.tsx | 22 +- .../master-search-header.tsx | 221 ++---------------- .../clinical-dashboard/mode-action-popup.tsx | 17 +- tests/ui-accessibility.spec.ts | 9 +- tests/ui-smoke.spec.ts | 22 +- tests/ui-stress.spec.ts | 16 +- tests/ui-tools.spec.ts | 46 ++-- 16 files changed, 157 insertions(+), 465 deletions(-) delete mode 100644 src/app/applications/layout.tsx delete mode 100644 src/app/applications/loading.tsx delete mode 100644 src/app/applications/page.tsx diff --git a/docs/site-map.md b/docs/site-map.md index 65e6e3f52..b8a3dec8a 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -5,7 +5,6 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## Main product pages - `/` - Main Clinical KB shell. Source: `src/app/page.tsx`. -- `/applications` - Application and tool launcher. Source: `src/app/applications/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. - `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. @@ -40,7 +39,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | | Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | | Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | -| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | `/applications` launcher and tool detail panels inside tools mode. | +| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`). | ## Documents flow index @@ -601,5 +600,5 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Differentials | `src/app/differentials, src/lib/differentials.ts` | | Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | | Documents | `src/app/documents, src/lib/document-flow-routes.ts` | -| Applications and tools | `src/app/applications, src/components/applications-launcher-page.tsx` | +| Tools | `src/components/applications-launcher-page.tsx` | | Mockups | `src/app/mockups` | diff --git a/next.config.ts b/next.config.ts index a89b5d615..4bb1b02e4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -64,6 +64,15 @@ const nextConfig: NextConfig = { }, ]; }, + async redirects() { + return [ + { + source: "/applications", + destination: "/?mode=tools", + permanent: true, + }, + ]; + }, }; export default nextConfig; diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 115c74068..3e60cb961 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -40,7 +40,6 @@ type SiteMapData = { const routeDescriptions: Record = { "/": "Main Clinical KB shell.", - "/applications": "Application and tool launcher.", "/differentials": "Differentials home and search surface.", "/differentials/diagnoses": "Diagnosis stream.", "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", @@ -97,7 +96,7 @@ const routeOwnershipRows = [ ["Differentials", "src/app/differentials, src/lib/differentials.ts"], ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], ["Documents", "src/app/documents, src/lib/document-flow-routes.ts"], - ["Applications and tools", "src/app/applications, src/components/applications-launcher-page.tsx"], + ["Tools", "src/components/applications-launcher-page.tsx"], ["Mockups", "src/app/mockups"], ] as const; @@ -279,7 +278,7 @@ function renderModePageIndex() { mode: "Tools", home: appModeHomeHref("tools"), search: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), - detail: "`/applications` launcher and tool detail panels inside tools mode.", + detail: "Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`).", }, ]); } diff --git a/src/app/applications/layout.tsx b/src/app/applications/layout.tsx deleted file mode 100644 index c9a012540..000000000 --- a/src/app/applications/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { ReactNode } from "react"; - -import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; - -export default function ApplicationsLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/src/app/applications/loading.tsx b/src/app/applications/loading.tsx deleted file mode 100644 index 59334e70b..000000000 --- a/src/app/applications/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; - -export default function Loading() { - return ; -} diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx deleted file mode 100644 index 2f7fd01fd..000000000 --- a/src/app/applications/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Metadata } from "next"; - -import { ApplicationsLauncherPage } from "@/components/applications-launcher-page"; - -export const metadata: Metadata = { - title: "Applications - Clinical KB", - description: "Launch Clinical KB applications, workflows, and connected clinical tools.", -}; - -export default function ApplicationsRoute() { - return ; -} diff --git a/src/app/globals.css b/src/app/globals.css index b1873a02d..f04f05ebd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1101,14 +1101,14 @@ summary::-webkit-details-marker { .answer-footer-search-action, .answer-footer-search-send { - height: 2.05rem; - width: 2.05rem; + height: 2.75rem; + width: 2.75rem; } .answer-footer-search-action svg, .answer-footer-search-send svg { - height: 1rem; - width: 1rem; + height: 1.1rem; + width: 1.1rem; } } @@ -1172,14 +1172,21 @@ summary::-webkit-details-marker { @media (max-width: 430px) { .answer-footer-search-action, .answer-footer-search-send { - height: 2.05rem !important; - width: 2.05rem !important; + height: 2.75rem !important; + width: 2.75rem !important; + min-height: 2.75rem; + min-width: 2.75rem; } .answer-footer-search-action svg, .answer-footer-search-send svg { - height: 1rem; - width: 1rem; +<<<<<<< Updated upstream + height: 1.125rem; + width: 1.125rem; +======= + height: 1.1rem; + width: 1.1rem; +>>>>>>> Stashed changes } } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index f73ad11e2..5e8d7c75b 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1067,26 +1067,8 @@ function SettingsHelpFooter({ onClick }: { onClick: () => void }) { ); } -function ToolsHub({ - query, - onQueryChange, - desktopComposerSlotId, - showDetailPanel, -}: { - query: string; - onQueryChange: (nextQuery: string) => void; - desktopComposerSlotId?: string; - showDetailPanel?: boolean; -}) { - return ( - - ); +function ToolsHub({ query, desktopComposerSlotId }: { query: string; desktopComposerSlotId?: string }) { + return ; } type MobileSectionFabItem = { @@ -1587,7 +1569,7 @@ export function ClinicalDashboard({ const activeModeSearch = appModeSearchConfig(searchMode); const activeModeResultKind = appModeResultKind(searchMode); const requestQueryMode = appModeQueryMode(searchMode, queryMode); - const requestedRun = searchParams.get("run") === "1"; + // Record matches come from the owner-scoped registry API (mock fixtures in // demo mode); ranking stays client-side so live-typing behaviour is // unchanged and the registry is fetched once per active mode. @@ -2473,7 +2455,7 @@ export function ClinicalDashboard({ urlDocumentSearchBootstrappedRef.current = true; void executeSearch(searchText, mode, scopeFilters); // URL search intentionally runs once when the selected mode can execute. - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canRunSearch, answerThreadBootstrapped]); useEffect(() => { @@ -3958,12 +3940,7 @@ export function ClinicalDashboard({ }} /> ) : activeModeResultKind === "tools" ? ( - + ) : activeModeResultKind === "favourites" ? ( void; - onSubmit: () => void; - copy: LauncherCopy; - className?: string; -}) { - return ( -
) => { - event.preventDefault(); - onSubmit(); - }} - className={cn( - "grid min-h-13 grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-lux)] text-left shadow-[var(--shadow-card)]", - className, - )} - > - - - - - -
- ); -} - function QuickActions({ onSelect, mobile }: { onSelect: (id: string) => void; mobile?: boolean }) { return (
void; desktopComposerSlotId?: string; - showDetailPanel?: boolean; className?: string; }; export function ApplicationsLauncherWorkspace({ - variant = "standalone", - query: controlledQuery, - onQueryChange, + query = "", desktopComposerSlotId, - showDetailPanel, className, }: ApplicationsLauncherWorkspaceProps) { - const [uncontrolledQuery, setUncontrolledQuery] = useState(""); const [activeFilter, setActiveFilter] = useState("all"); - const isDashboardTools = variant === "dashboard-tools"; - const [detailOpen, setDetailOpen] = useState(!isDashboardTools && showDetailPanel === true); - const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; - const query = controlledQuery ?? uncontrolledQuery; + const composerSlotId = desktopComposerSlotId ?? modeHomeDesktopComposerSlotId; + const [selectedId, setSelectedId] = useState(() => initialToolId(query)); + const [detailOpen, setDetailOpen] = useState(false); + const copy = toolsLauncherCopy; const normalizedQuery = query.trim().toLowerCase(); - const queryDerivedId = useMemo(() => initialToolId(query), [query]); - const [selection, setSelection] = useState(() => ({ - queryKey: (controlledQuery ?? "").trim().toLowerCase(), - id: initialToolId(controlledQuery), - })); - const selectedId = selection.queryKey === normalizedQuery ? selection.id : queryDerivedId; + + useEffect(() => { + setSelectedId((current) => initialToolId(query) || current); + }, [query]); const filteredApps = useMemo(() => { return launcherApps.filter((app) => { @@ -940,34 +847,25 @@ export function ApplicationsLauncherWorkspace({ : (filteredApps[0]?.id ?? selectedId); const selectedApp = appById(effectiveSelectedId); - function updateQuery(nextQuery: string) { - if (controlledQuery === undefined) setUncontrolledQuery(nextQuery); - onQueryChange?.(nextQuery); - } - function openTool(id: string) { - setSelection({ queryKey: normalizedQuery, id }); + setSelectedId(id); setDetailOpen(true); } - function submitSearch() { - if (filteredApps[0]) openTool(filteredApps[0].id); - } - return (
@@ -975,7 +873,7 @@ export function ApplicationsLauncherWorkspace({

{copy.heading} @@ -985,22 +883,14 @@ export function ApplicationsLauncherWorkspace({

- {desktopComposerSlotId ? ( + {composerSlotId ? (
- ) : ( - - )} + ) : null} -
+
@@ -1012,7 +902,7 @@ export function ApplicationsLauncherWorkspace({
@@ -1055,15 +945,9 @@ export function ApplicationsLauncherWorkspace({

- {isDashboardTools ? ( - - ) : null} + setDetailOpen(false)} />
); } - -export function ApplicationsLauncherPage() { - return ; -} diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx index bf6621cb6..4ffe93ec2 100644 --- a/src/components/clinical-dashboard/dashboard-nav.tsx +++ b/src/components/clinical-dashboard/dashboard-nav.tsx @@ -12,26 +12,8 @@ import { cn } from "@/components/ui-primitives"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; import { type AppModeId, appModeSearchConfig } from "@/lib/app-modes"; -export function ToolsHub({ - query, - onQueryChange, - desktopComposerSlotId, - showDetailPanel, -}: { - query: string; - onQueryChange: (nextQuery: string) => void; - desktopComposerSlotId?: string; - showDetailPanel?: boolean; -}) { - return ( - - ); +export function ToolsHub({ query, desktopComposerSlotId }: { query: string; desktopComposerSlotId?: string }) { + return ; } type MobileSectionFabItem = { diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 58761d61a..624214196 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -16,17 +16,13 @@ import { createPortal } from "react-dom"; import { Activity, - BadgeCheck, CalendarDays, Check, CheckCircle2, ChevronDown, FileText, Filter, - FolderOpen, - GitBranch, Globe2, - ListChecks, Loader2, Menu, MessageSquarePlus, @@ -328,7 +324,6 @@ export function MasterSearchHeader({ [documentById, selectedDocumentIds], ); const scopeSummary = selectedDocumentIds.length === 0 ? "All documents" : `${selectedDocumentIds.length} scoped`; - const footerScopeLabel = selectedDocumentIds.length === 0 ? "All sources" : `${selectedDocumentIds.length} scoped`; const scopePreview = useMemo( () => selectedDocuments @@ -675,6 +670,7 @@ export function MasterSearchHeader({ ); let frame: number | null = null; let retryTimeout: number | null = null; + let portalRetryCount = 0; const syncTarget = () => { if (retryTimeout !== null) { window.clearTimeout(retryTimeout); @@ -682,14 +678,16 @@ export function MasterSearchHeader({ } const slot = mediaQuery.matches ? document.getElementById(desktopHomeComposerSlotId) : null; if (slot) { + portalRetryCount = 0; if (host.parentNode !== slot) slot.appendChild(host); setDesktopHomeComposerHost(host); setDesktopHomeComposerActive(true); } else { host.parentNode?.removeChild(host); setDesktopHomeComposerActive(false); - if (mediaQuery.matches) { - retryTimeout = window.setTimeout(syncTarget, 50); + if (mediaQuery.matches && portalRetryCount < 24) { + portalRetryCount += 1; + retryTimeout = window.setTimeout(syncTarget, Math.min(40 * portalRetryCount, 400)); } } }; @@ -1017,144 +1015,6 @@ export function MasterSearchHeader({ ); } - // "open-evidence" is the one footer-chip action that isn't already a mode-action - // id — every other chip dispatches through the existing runModeAction handler - // (the same dispatcher the "+" action menu already uses for these ids). - type FooterChipActionId = ModeActionId | "open-evidence"; - - type FooterActionChip = { - icon: typeof Search; - shortLabel: string; - longLabel: string; - actionId: FooterChipActionId; - ariaLabel: string; - }; - - // The first ("trust") chip on the universal small-screen footer. Every mode gets - // one, mirroring Answer's "Evidence-based" chip in tone, each wired to a real - // action from that mode's own action menu rather than being decorative. - function footerTrustChipFor(mode: AppModeId): FooterActionChip | null { - switch (mode) { - case "answer": - return { - icon: ListChecks, - shortLabel: "Evidence", - longLabel: "Evidence-based", - actionId: "open-evidence", - ariaLabel: "Open evidence-backed answer sources", - }; - case "documents": - return { - icon: BadgeCheck, - shortLabel: "Indexed", - longLabel: "Fully indexed", - actionId: "documents-collections", - ariaLabel: "Open the indexed document library", - }; - case "forms": - return { - icon: BadgeCheck, - shortLabel: "Library", - longLabel: "Form library", - actionId: "forms-records", - ariaLabel: "Open the form library", - }; - case "services": - return { - icon: BadgeCheck, - shortLabel: "Verified", - longLabel: "Verified directory", - actionId: "services-records", - ariaLabel: "Browse verified service records", - }; - case "favourites": - return { - icon: BadgeCheck, - shortLabel: "Trusted", - longLabel: "Trusted picks", - actionId: "favourites-browse", - ariaLabel: "Browse trusted favourites", - }; - case "differentials": - return { - icon: ListChecks, - shortLabel: "Evidence", - longLabel: "Evidence-linked", - actionId: "differentials-evidence", - ariaLabel: "Review cited differential evidence", - }; - case "prescribing": - return { - icon: ShieldCheck, - shortLabel: "Safety", - longLabel: "Safety-checked", - actionId: "medication-safety", - ariaLabel: "Review contraindications and cautions", - }; - case "tools": - return { - icon: BadgeCheck, - shortLabel: "Curated", - longLabel: "Curated registry", - actionId: "tools-browse", - ariaLabel: "Browse the curated tools registry", - }; - default: - return null; - } - } - - // The second footer chip. Answer/Documents/Forms use the shared document-scope - // trigger instead (see hasScopeFooterChip below) since scope is a real, existing - // concept for those three modes. Tools has no genuine second action yet, so it - // intentionally ships with a single chip rather than an invented one. - function footerSecondaryChipFor(mode: AppModeId): FooterActionChip | null { - switch (mode) { - case "services": - return { - icon: ListChecks, - shortLabel: "Pathways", - longLabel: "Pathways", - actionId: "services-pathways", - ariaLabel: "Browse referral pathways", - }; - case "favourites": - return { - icon: FolderOpen, - shortLabel: "Sets", - longLabel: "Sets", - actionId: "favourites-sets", - ariaLabel: "Open saved sets", - }; - case "differentials": - return { - icon: GitBranch, - shortLabel: "Criteria", - longLabel: "Criteria", - actionId: "differentials-criteria", - ariaLabel: "Compare distinguishing criteria", - }; - case "prescribing": - return { - icon: Activity, - shortLabel: "Monitor", - longLabel: "Monitoring", - actionId: "medication-monitoring", - ariaLabel: "Review the monitoring schedule", - }; - default: - return null; - } - } - - function runFooterChipAction(actionId: FooterChipActionId) { - if (actionId === "open-evidence") { - onOpenEvidence?.(); - return; - } - runModeAction(actionId); - } - function renderSearchComposer(placement: "default" | "desktop-home") { const isDesktopHomeComposer = placement === "desktop-home"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; @@ -1162,23 +1022,22 @@ export function MasterSearchHeader({ const usesCompactMobileBottomStyle = usesMobileBottomStyle && mobileBottomSearchVariant === "compact"; const usesBottomComposerPlacement = usesAnswerFooterStyle || (usesMobileBottomStyle && usesPhoneSearchLayout); const usesFooterChipLayout = usesBottomComposerPlacement || isDesktopHomeComposer; +<<<<<<< Updated upstream +======= // Compact search views drop the chip row on phones so the pill can sit // flush with the bottom edge; the same actions stay reachable via the // integrated "+" menu. - const showFooterSearchChips = usesFooterChipLayout && !usesCompactMobileBottomStyle; + const showFooterSearchChips = + usesFooterChipLayout && + !usesCompactMobileBottomStyle && + !(isDesktopHomeComposer && usesPhoneSearchLayout); +>>>>>>> Stashed changes // The visible footer/hero composer chrome is universal; submit semantics still // come from the active mode. const usesSendAffordance = searchMode === "answer" || usesFooterChipLayout; const usesModeIdentityAffordance = usesBottomComposerPlacement && !usesSendAffordance; const ModeIdentityIcon = appModeIcons[searchMode]; const hasScopeFooterChip = searchMode === "answer" || searchMode === "documents" || searchMode === "forms"; - const trustFooterChip = footerTrustChipFor(searchMode); - const secondaryFooterChip = footerSecondaryChipFor(searchMode); - // Fallback icons here are never rendered — both are only used inside a JSX guard - // on the corresponding chip being non-null — but keep the icon variables typed as - // components (not `| null`) so the JSX below type-checks without a cast. - const TrustFooterChipIcon = trustFooterChip?.icon ?? BadgeCheck; - const SecondaryFooterChipIcon = secondaryFooterChip?.icon ?? ListChecks; const composerPlaceholder = usesMobileBottomStyle && searchMode === "differentials" ? "Search a presentation" : queryPlaceholder; @@ -1285,6 +1144,7 @@ export function MasterSearchHeader({ onModeSelect={selectAppModeById} onPlacementChange={setActionMenuPlacement} triggerClassName="answer-footer-search-action" + triggerRef={scopeSummaryRef} integrated={usesFooterChipLayout} /> @@ -1348,53 +1208,8 @@ export function MasterSearchHeader({ - {showFooterSearchChips && (trustFooterChip || hasScopeFooterChip || secondaryFooterChip) ? ( -
- {trustFooterChip ? ( - - ) : null} - {hasScopeFooterChip ? ( - - ) : null} - {!hasScopeFooterChip && secondaryFooterChip ? ( - - ) : null} -
- ) : null} - {/* Rendered as a sibling of the chip row (not nested inside it) so the "+" - menu's "Set scope" action still opens this popover on screens where the - chip row itself is hidden (documents/forms desktop widths) — the popover - still anchors correctly since the form stays position:fixed/sticky there. */} + {/* Scope popover is a form sibling so the "+" menu's "Set scope" action can + open it even when the footer chip row is not shown. */} {hasScopeFooterChip && !usesScopeSheet && scopeOpen ? (
- {desktopHomeComposerActive && desktopHomeComposerHost ? null : renderSearchComposer("default")} + {desktopHomeComposerActive && desktopHomeComposerHost + ? null + : desktopHomeComposerSlotId + ? null + : renderSearchComposer("default")} {desktopHomeComposerActive && desktopHomeComposerHost ? createPortal(renderSearchComposer("desktop-home"), desktopHomeComposerHost) : null} diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 2e54e7886..9dd64da10 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -8,6 +8,7 @@ import { useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, + type Ref, } from "react"; import { BadgeCheck, @@ -245,6 +246,15 @@ export function modeActionItemsFor(setId: ModeActionSetId): readonly ModeActionI return modeActionSets[setId]; } +function assignTriggerRef(ref: Ref | undefined, element: HTMLButtonElement | null) { + if (!ref) return; + if (typeof ref === "function") { + ref(element); + return; + } + ref.current = element; +} + export function ModeActionPopup({ open, title, @@ -260,6 +270,7 @@ export function ModeActionPopup({ onModeSelect, onPlacementChange, triggerClassName, + triggerRef, integrated = false, }: { open: boolean; @@ -276,6 +287,7 @@ export function ModeActionPopup({ onModeSelect?: (modeId: string) => void; onPlacementChange?: (placement: ModeActionPlacement) => void; triggerClassName?: string; + triggerRef?: Ref; integrated?: boolean; }) { const buttonRef = useRef(null); @@ -643,7 +655,10 @@ export function ModeActionPopup({
{composerSlotId ? ( -
+
) : null}
diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 7192677a3..e43a125e6 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1441,14 +1441,7 @@ function stableHash(value: string) { export function retrievalPlanCacheQuery( args: Pick< SearchChunksArgs, - | "query" - | "documentId" - | "documentIds" - | "ownerId" - | "queryMode" - | "topK" - | "minSimilarity" - | "forceEmbedding" + "query" | "documentId" | "documentIds" | "ownerId" | "queryMode" | "topK" | "minSimilarity" | "forceEmbedding" >, queryClass?: RagQueryClass, queryVariants: string[] = [], @@ -5480,7 +5473,10 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.shared_cache_miss_reason = sharedCached.reason; } - if (!args.forceEmbedding && shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) { + if ( + !args.forceEmbedding && + shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions) + ) { // Item 10 follow-up (RC6): a typo can make an on-topic query ("schizophrenai management") look // unsupported and short-circuit before any layer runs. Before giving up, trigram-correct the // query against the known clinical-term vocabulary; if it changes, re-run the whole retrieval diff --git a/tests/public-access-deep.test.ts b/tests/public-access-deep.test.ts index 0801c4bd9..9d0cf39d1 100644 --- a/tests/public-access-deep.test.ts +++ b/tests/public-access-deep.test.ts @@ -111,7 +111,6 @@ describe("public access deep checks", () => { expect(body.checks.supabaseConfig).toBe("ok"); expect(body.checks.openaiConfig).toBe("ok"); }); - }); describe("production anonymous retrieval scope", () => { diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 9f9807179..f177658af 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -61,8 +61,8 @@ async function openScopeControl(page: Page) { await actionMenu.click(); const actionsMenu = page.getByTestId("daily-actions-menu"); await expect(actionsMenu).toBeVisible({ timeout: uiAssertionTimeoutMs }); - await actionsMenu.getByRole("menuitem", { name: "Scope sources" }).click(); - await expect(page.locator('[data-testid="scope-command-popover"]:visible')).toBeVisible({ + await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); + await expect(page.getByTestId("scope-command-popover")).toBeVisible({ timeout: uiAssertionTimeoutMs, }); }).toPass({ timeout: 10_000 }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 2be9075ec..e22350990 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -515,8 +515,8 @@ async function openScopeControl(page: Page) { await actionMenu.click(); const actionsMenu = page.getByTestId("daily-actions-menu"); await expect(actionsMenu).toBeVisible({ timeout: uiAssertionTimeoutMs }); - await actionsMenu.getByRole("menuitem", { name: "Scope sources" }).click(); - await expect(page.locator('[data-testid="scope-command-popover"]:visible')).toBeVisible({ + await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); + await expect(page.getByTestId("scope-command-popover")).toBeVisible({ timeout: uiAssertionTimeoutMs, }); }).toPass({ timeout: 10_000 }); @@ -1213,7 +1213,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(evidenceDrawer).toBeFocused(); await openScopeControl(page); - const scopePopover = page.locator('[data-testid="scope-command-popover"]:visible'); + const scopePopover = page.getByTestId("scope-command-popover"); await expect(scopePopover).toBeVisible(); const scopeFilter = scopePopover.locator('[data-testid="document-scope-filter"]'); await expect(scopeFilter).toBeVisible(); @@ -1554,8 +1554,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await mockDemoApi(page); await gotoApp(page, "/favourites?q=lithium%20set"); - const globalSearchInput = visibleQuestionInput(page); + const globalSearchInput = page.getByRole("textbox", { name: "Search saved favourites" }); await expect(page.getByRole("button", { name: "Mode Favourites" })).toBeVisible(); + await expect(globalSearchInput).toBeVisible({ timeout: 30_000 }); await expect(globalSearchInput).toHaveAttribute("placeholder", "Search favourites..."); await expect(globalSearchInput).toHaveValue("lithium set"); await expect(page.getByTestId("favourites-hub")).toBeVisible(); diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 1504a977f..8f0a76d8f 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -280,10 +280,13 @@ test.describe("Clinical KB long-content stress coverage", () => { const actionMenu = page.getByRole("button", { name: "Open answer options" }); await page.keyboard.press("Escape"); - await actionMenu.click(); - const actionsMenu = page.getByTestId("daily-actions-menu"); - await expect(actionsMenu).toBeVisible(); - await actionsMenu.getByRole("menuitem", { name: "Scope sources" }).click(); + await expect(async () => { + await actionMenu.click(); + const actionsMenu = page.getByTestId("daily-actions-menu"); + await expect(actionsMenu).toBeVisible(); + await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); + await expect(page.getByTestId("scope-command-popover")).toBeVisible(); + }).toPass({ timeout: 10_000 }); const scopeContainer = page.getByTestId("scope-command-popover"); await expect(scopeContainer).toBeVisible(); await expect(scopeContainer).toBeVisible(); From eacc719584160c53d6114b5fba32ccf8a39660fe Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:32:01 +0800 Subject: [PATCH 11/15] style: format ui-stress spec for prettier check --- tests/ui-stress.spec.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 8f0a76d8f..747eeb4be 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -278,18 +278,20 @@ test.describe("Clinical KB long-content stress coverage", () => { await expect(page.getByLabel("Source-backed answer")).toBeVisible(); await expect(page.getByTestId("plain-answer-response")).toBeVisible(); - const actionMenu = page.getByRole("button", { name: "Open answer options" }); await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + await expect(page.getByRole("listbox", { name: /search suggestions/i })) + .toBeHidden({ timeout: 5_000 }) + .catch(() => undefined); + + const composer = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first(); await expect(async () => { - await actionMenu.click(); - const actionsMenu = page.getByTestId("daily-actions-menu"); - await expect(actionsMenu).toBeVisible(); - await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); - await expect(page.getByTestId("scope-command-popover")).toBeVisible(); - }).toPass({ timeout: 10_000 }); + await composer.click(); + await expect(page.getByRole("option", { name: /Scope sources/i })).toBeVisible({ timeout: 3_000 }); + await page.getByRole("option", { name: /Scope sources/i }).click(); + await expect(page.getByTestId("scope-command-popover")).toBeVisible({ timeout: 3_000 }); + }).toPass({ timeout: 15_000 }); const scopeContainer = page.getByTestId("scope-command-popover"); - await expect(scopeContainer).toBeVisible(); - await expect(scopeContainer).toBeVisible(); await expect( scopeContainer.getByText(/Type to filter 24 (loaded )?documents\. Selected documents stay pinned here\./), ).toBeVisible(); From 579576a651b9ea1945b08626d1b7f323691d3da7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:44:29 +0800 Subject: [PATCH 12/15] fix(mobile): restore favourites composer slot and stabilize scope smoke tests --- .../favourites-command-library-page.tsx | 6 ++++ tests/ui-smoke.spec.ts | 34 +++++++++++++------ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 05c7f3ae2..98cfb3b0e 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -44,6 +44,7 @@ import { import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { favouriteMatchesCommandScopes } from "@/lib/search-command-surface"; import { appModeIcons } from "@/lib/app-mode-icons"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; type FavouriteType = "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form"; type ViewMode = FavouritesViewMode; @@ -952,6 +953,11 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string })
+
+
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index e22350990..c5f5465f8 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -506,20 +506,34 @@ async function expectDomIntegrity(page: Page, options: { mobileNav?: boolean; mo } } -// Document scope opens from the footer composer "+" menu. +// Scope opens from the command surface after answer submit and from the "+" menu on mode homes. async function openScopeControl(page: Page) { - const actionMenu = page.getByRole("button", { name: "Open answer options" }); - await expect(actionMenu).toBeVisible(); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + await page + .getByRole("listbox", { name: /search suggestions/i }) + .waitFor({ state: "hidden", timeout: 5_000 }) + .catch(() => undefined); + + const composer = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first(); await expect(async () => { - await actionMenu.click(); - const actionsMenu = page.getByTestId("daily-actions-menu"); - await expect(actionsMenu).toBeVisible({ timeout: uiAssertionTimeoutMs }); - await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); + await composer.click(); + const scopeOption = page.getByRole("option", { name: /Scope sources/i }); + if (await scopeOption.isVisible({ timeout: 2_000 }).catch(() => false)) { + await scopeOption.click(); + } else { + const actionMenu = page.getByRole("button", { name: "Open answer options" }); + await expect(actionMenu).toBeVisible(); + await actionMenu.click(); + const actionsMenu = page.getByTestId("daily-actions-menu"); + await expect(actionsMenu).toBeVisible({ timeout: uiAssertionTimeoutMs }); + await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); + } await expect(page.getByTestId("scope-command-popover")).toBeVisible({ timeout: uiAssertionTimeoutMs, }); - }).toPass({ timeout: 10_000 }); + }).toPass({ timeout: 15_000 }); } async function expectMinTouchTarget(locator: Locator, minSize = 44) { @@ -1235,7 +1249,7 @@ test.describe("Clinical KB UI smoke coverage", () => { expect(popoverMetrics.height).toBeLessThanOrEqual(Math.ceil(popoverMetrics.viewportHeight * 0.72)); await page.keyboard.press("Escape"); await expect(scopePopover).toBeHidden(); - await expect(page.getByRole("button", { name: "Open answer options" })).toBeFocused(); + await expect(page.getByTestId("global-search-input")).toBeFocused(); await expectNoPageHorizontalOverflow(page); }); @@ -1554,7 +1568,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await mockDemoApi(page); await gotoApp(page, "/favourites?q=lithium%20set"); - const globalSearchInput = page.getByRole("textbox", { name: "Search saved favourites" }); + const globalSearchInput = page.getByRole("combobox", { name: /Search saved favourites/ }); await expect(page.getByRole("button", { name: "Mode Favourites" })).toBeVisible(); await expect(globalSearchInput).toBeVisible({ timeout: 30_000 }); await expect(globalSearchInput).toHaveAttribute("placeholder", "Search favourites..."); From 6f5e9fa8fc2d255c1ac786bb381bd4ca09429a7e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:01:16 +0800 Subject: [PATCH 13/15] test(ui): fallback to answer options menu for desktop scope stress --- tests/ui-stress.spec.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 747eeb4be..df8512f0e 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -280,16 +280,26 @@ test.describe("Clinical KB long-content stress coverage", () => { await page.keyboard.press("Escape"); await page.keyboard.press("Escape"); - await expect(page.getByRole("listbox", { name: /search suggestions/i })) - .toBeHidden({ timeout: 5_000 }) + await page + .getByRole("listbox", { name: /search suggestions/i }) + .waitFor({ state: "hidden", timeout: 5_000 }) .catch(() => undefined); const composer = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first(); await expect(async () => { await composer.click(); - await expect(page.getByRole("option", { name: /Scope sources/i })).toBeVisible({ timeout: 3_000 }); - await page.getByRole("option", { name: /Scope sources/i }).click(); - await expect(page.getByTestId("scope-command-popover")).toBeVisible({ timeout: 3_000 }); + const scopeOption = page.getByRole("option", { name: /Scope sources/i }); + if (await scopeOption.isVisible({ timeout: 2_000 }).catch(() => false)) { + await scopeOption.click(); + } else { + const actionMenu = page.getByRole("button", { name: "Open answer options" }); + await expect(actionMenu).toBeVisible(); + await actionMenu.click(); + const actionsMenu = page.getByTestId("daily-actions-menu"); + await expect(actionsMenu).toBeVisible({ timeout: 5_000 }); + await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); + } + await expect(page.getByTestId("scope-command-popover")).toBeVisible({ timeout: 5_000 }); }).toPass({ timeout: 15_000 }); const scopeContainer = page.getByTestId("scope-command-popover"); await expect( From 21d02a278e283fc8e8f718406fadd65af2d85d17 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:24:09 +0800 Subject: [PATCH 14/15] test(ui): open scope via answer options in desktop stress path --- tests/ui-stress.spec.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index df8512f0e..de7ae99bf 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -278,6 +278,7 @@ test.describe("Clinical KB long-content stress coverage", () => { await expect(page.getByLabel("Source-backed answer")).toBeVisible(); await expect(page.getByTestId("plain-answer-response")).toBeVisible(); + await page.keyboard.press("Escape"); await page.keyboard.press("Escape"); await page.keyboard.press("Escape"); await page @@ -285,22 +286,15 @@ test.describe("Clinical KB long-content stress coverage", () => { .waitFor({ state: "hidden", timeout: 5_000 }) .catch(() => undefined); - const composer = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first(); await expect(async () => { - await composer.click(); - const scopeOption = page.getByRole("option", { name: /Scope sources/i }); - if (await scopeOption.isVisible({ timeout: 2_000 }).catch(() => false)) { - await scopeOption.click(); - } else { - const actionMenu = page.getByRole("button", { name: "Open answer options" }); - await expect(actionMenu).toBeVisible(); - await actionMenu.click(); - const actionsMenu = page.getByTestId("daily-actions-menu"); - await expect(actionsMenu).toBeVisible({ timeout: 5_000 }); - await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); - } + const actionMenu = page.getByRole("button", { name: "Open answer options" }); + await expect(actionMenu).toBeVisible(); + await actionMenu.click(); + const actionsMenu = page.getByTestId("daily-actions-menu"); + await expect(actionsMenu).toBeVisible({ timeout: 5_000 }); + await actionsMenu.getByRole("menuitem", { name: /^Scope\b/ }).click(); await expect(page.getByTestId("scope-command-popover")).toBeVisible({ timeout: 5_000 }); - }).toPass({ timeout: 15_000 }); + }).toPass({ timeout: 20_000 }); const scopeContainer = page.getByTestId("scope-command-popover"); await expect( scopeContainer.getByText(/Type to filter 24 (loaded )?documents\. Selected documents stay pinned here\./), From 12c54009c54afdf3d1af053fe8c164ee482f91fa Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:28:01 +0800 Subject: [PATCH 15/15] chore: ignore historical medications snapshot gitleaks false positives --- .gitleaksignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index 8b3bd8187..16fe1ab80 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -8,3 +8,5 @@ # generic-api-key rule on historical commit content scanned in PR history. 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:22253 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:47690 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:21520 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:46330