diff --git a/.env.example b/.env.example index 778152180..59534dd93 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,8 @@ SUPABASE_IMAGE_BUCKET=clinical-images MAX_UPLOAD_MB=150 # Next Proxy has a fixed 151 MiB transport envelope (150 MiB file plus # multipart framing), so values above 150 are intentionally rejected. +MAX_CONCURRENT_UPLOADS=1 +MAX_IN_FLIGHT_UPLOAD_MB=151 MAX_IMPORT_JOBS_PER_RUN=5 MAX_IMPORT_BYTES_PER_RUN=157286400 CHUNK_SIZE=2000 diff --git a/.gitleaksignore b/.gitleaksignore index 1f3a4833a..477030dc5 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -16,3 +16,6 @@ b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic- # and an earlier allowlist comment that quoted the pattern are both pinned below. d7cfc5559f0d7a5db74eb49808f237c989b4136a:scripts/compare-retrieval-eval.ts:generic-api-key:37 d2b7b89852a2090d56ce6aed1dd39417aedf332d:.gitleaksignore:generic-api-key:13 +# False positive: two synthetic chunk identifiers in a citation-array assertion +# were parsed as a generic API key in the original remediation commit. +b30bcb030edce84e2a0704a8714ee3ef1dcf642d:tests/rag-offline-answer.test.ts:generic-api-key:205 diff --git a/docs/privacy-impact-assessment.md b/docs/privacy-impact-assessment.md index 402e3f60e..7f5969d50 100644 --- a/docs/privacy-impact-assessment.md +++ b/docs/privacy-impact-assessment.md @@ -42,15 +42,15 @@ material. **Top gaps (full register in §10):** -| ID | Risk | One-line | -| ----- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PIA-1 | High | Cross-border disclosure to OpenAI (US) lacks an executed DPA/ZDR contractual basis (APP 8); the user-facing APP 5 notice is now live (PR #513). | -| PIA-2 | High | Query-hash HMAC would downgrade to **unsalted, dictionary-reversible SHA-256** without `RAG_QUERY_HASH_SECRET` — now **enforced**: production fails closed at boot; operator must place the secret. | -| PIA-3 | Resolved | Generated answer text is no longer persisted to `rag_queries` by default; answer-text persistence is gated behind `RAG_PERSIST_ANSWER_TEXT` (default off, blocked in production readiness). | -| PIA-4 | Medium | `rag_query_misses` has **no retention/purge job** (only `rag_queries` and `rag_retrieval_logs` do). | -| PIA-5 | Medium | Privacy policy + collection notice now live (PR #513); broader data-handling documentation still maturing (APP 1, APP 5). | -| PIA-6 | Low-Med | OpenAI **prompt-cache retention is forced to 24h** for gpt-5.5 regardless of config — query + retrieved excerpts persist ≤24h at OpenAI. | -| PIA-7 | Low | `RAG_PERSIST_RAW_QUERY_TEXT=true` would store raw PHI query text with no secondary safeguard beyond the 30-day purge. | +| ID | Risk | One-line | +| ----- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PIA-1 | High | Cross-border disclosure to OpenAI (US) has no code-visible DPA/ZDR. A draft provider disclosure now ships in-product, but its wording lacks governance approval (APP 8, APP 5). | +| PIA-2 | High | Production now fails closed without `RAG_QUERY_HASH_SECRET`; the operator must still place the secret in the deploy host. | +| PIA-3 | Mitigated | Generated answer text is omitted from `rag_queries` by default. `RAG_PERSIST_ANSWER_TEXT=true` is explicit opt-in and blocked by production readiness. | +| PIA-4 | Medium | `rag_query_misses` has **no retention/purge job** (only `rag_queries` and `rag_retrieval_logs` do). | +| PIA-5 | Medium | Draft point-of-entry collection notices and a `/privacy` data-processing page ship, but no governance-approved final privacy policy exists (APP 1, APP 5). | +| PIA-6 | Low-Med | OpenAI **prompt-cache retention is forced to 24h** for gpt-5.5 regardless of config — query + retrieved excerpts persist ≤24h at OpenAI. | +| PIA-7 | Low | `RAG_PERSIST_RAW_QUERY_TEXT=true` would store raw PHI query text with no secondary safeguard beyond the 30-day purge. | --- @@ -178,12 +178,12 @@ privacy item to close before real patient use (PIA-1). All three log tables are **owner-stamped** and **RLS-enabled** (owner-read for authenticated users; service-role for writes). Redaction is applied centrally at every write site. -| Table | Raw query stored? | Redaction mechanism | Other sensitive columns | RLS | -| -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | -| `rag_queries` | No (hash placeholder) | `queryTextForStorage` / `normalizedQueryTextForStorage` ([query-privacy.ts](src/lib/query-privacy.ts)); write via `insertRagQuery` ([rag.ts](src/lib/rag.ts)) | `answer` dropped at rest unless `RAG_PERSIST_ANSWER_TEXT` (`answerTextForStorage`, PIA-3); `source_chunk_ids` (own data) | owner-read, [schema.sql:3932](supabase/schema.sql) | -| `rag_query_misses` | No (hash placeholder) | same helpers; writes in [search/route.ts:558-559](src/app/api/search/route.ts), [interaction/route.ts:88-89](src/app/api/search/interaction/route.ts) | `metadata.query_hash` | owner-read, [schema.sql:3935](supabase/schema.sql) | -| `rag_retrieval_logs` | No (hash placeholder) | same helpers; write at [search/route.ts:556-559](src/app/api/search/route.ts) | retrieval telemetry only | owner-read, [schema.sql:3938](supabase/schema.sql) | -| `audit_logs` | N/A (no query text) | action/resource metadata only; error strings pass through `redactLogValue` ([privacy.ts:5-31](src/lib/privacy.ts)) | `owner_id`, `action`, `resource_id` | service-role-only, [schema.sql:3959](supabase/schema.sql) | +| Table | Raw query stored? | Redaction mechanism | Other sensitive columns | RLS | +| -------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `rag_queries` | No (hash placeholder) | `queryTextForStorage` / `normalizedQueryTextForStorage` ([query-privacy.ts:33-39](src/lib/query-privacy.ts)); centralized write in `insertRagQuery` | `answer` is null by default and stored only with explicit `RAG_PERSIST_ANSWER_TEXT=true`; `source_chunk_ids` (own data) | owner-read, [schema.sql:3932](supabase/schema.sql) | +| `rag_query_misses` | No (hash placeholder) | same helpers; writes in [search/route.ts:558-559](src/app/api/search/route.ts), [interaction/route.ts:88-89](src/app/api/search/interaction/route.ts) | `metadata.query_hash` | owner-read, [schema.sql:3935](supabase/schema.sql) | +| `rag_retrieval_logs` | No (hash placeholder) | same helpers; write at [search/route.ts:556-559](src/app/api/search/route.ts) | retrieval telemetry only | owner-read, [schema.sql:3938](supabase/schema.sql) | +| `audit_logs` | N/A (no query text) | action/resource metadata only; error strings pass through `redactLogValue` ([privacy.ts:5-31](src/lib/privacy.ts)) | `owner_id`, `action`, `resource_id` | service-role-only, [schema.sql:3959](supabase/schema.sql) | ### 5.1 M15 HMAC query-hash fix — verified present, enforced in production @@ -308,23 +308,23 @@ the operative framework for a WA private clinician. (The WA _Privacy and Respons Act 2024_ targets WA **public-sector** entities and may apply to public-health deployments — confirm with counsel if this is deployed inside a WA Health service.) -| APP | Obligation | Status in this app | Gap | -| ----------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| **APP 1** — open & transparent management | Have a clear, up-to-date APP privacy policy | Privacy policy now ships (PR #513, [src/app/privacy/page.tsx](src/app/privacy/page.tsx)) — see PIA-1 progress note | Resolved (PIA-1) | -| **APP 3** — collection of sensitive info | Collect health info only with consent + where reasonably necessary | App does not solicit PHI; incidental via free-text. No consent gate/notice on the query box | PIA-5 | -| **APP 5** — notification of collection | Tell individuals what's collected & disclosed (incl. overseas) | Collection notice and OpenAI disclosure now surfaced (PR #513, composer notice) — see PIA-1 progress note | Resolved (PIA-1) | -| **APP 6** — use/disclosure | Use only for the primary purpose or a permitted secondary purpose | Query used for answer generation (primary). Log retention = quality/eval (secondary) — defensible but should be documented | PIA-5 | -| **APP 8** — cross-border disclosure | Discloser stays accountable for the overseas recipient unless an exception applies | Disclosure to OpenAI (US); no code-visible DPA/ZDR; accountability unclear | **PIA-1** | -| **APP 11** — security & destruction | Reasonable security; destroy/de-identify when no longer needed | Strong: Sydney residency, RLS, private storage, query hashing, 30/90-day purge, durable answer log dropped at rest by default (PIA-3 closed). Weakened by PIA-2 (conditional HMAC), PIA-4 (misses never purged), and `rag_response_cache` (read-TTL but no purge cron — see §8) | PIA-2/4 | -| **NDB scheme** (Pt IIIC) | Notify OAIC + individuals of eligible breaches of health info | No documented breach-response runbook tied to these tables | Recommend adding | +| APP | Obligation | Status in this app | Gap | +| ----------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| **APP 1** — open & transparent management | Have a clear, up-to-date APP privacy policy | A draft `/privacy` data-processing page ships, but it is explicitly governance-review-required and is not represented as the final approved APP privacy policy | PIA-5 | +| **APP 3** — collection of sensitive info | Collect health info only with consent + where reasonably necessary | App does not solicit PHI; incidental entry remains possible. “Do not enter patient-identifiable information” notices now appear beside query/upload controls, but no governance-approved consent framework is claimed | PIA-5 | +| **APP 5** — notification of collection | Tell individuals what's collected & disclosed (incl. overseas) | Draft point-of-entry notices and the `/privacy` page disclose data processing and provider use; final wording and legal/governance approval remain outstanding | PIA-1, PIA-5 | +| **APP 6** — use/disclosure | Use only for the primary purpose or a permitted secondary purpose | Query used for answer generation (primary). Log retention = quality/eval (secondary) — defensible but should be documented | PIA-5 | +| **APP 8** — cross-border disclosure | Discloser stays accountable for the overseas recipient unless an exception applies | Disclosure to OpenAI (US); no code-visible DPA/ZDR; accountability unclear | **PIA-1** | +| **APP 11** — security & destruction | Reasonable security; destroy/de-identify when no longer needed | Strong: Sydney residency, RLS, private storage, query hashing, default-null answer logs, 30/90-day purge. Remaining gaps are operator secret placement, exceptional answer-persistence governance, `rag_response_cache` expiry cleanup, and PIA-4 (misses never purged) | PIA-2/4 | +| **NDB scheme** (Pt IIIC) | Notify OAIC + individuals of eligible breaches of health info | No documented breach-response runbook tied to these tables | Recommend adding | **Overall:** the _engineering_ controls for data-at-rest are strong and largely APP-11-aligned. The -material shortfall is **governance/contractual** — the APP 8 cross-border DPA/ZDR basis (the APP 1/5 -collection notices are now live on `main`; see PIA-1 progress) — plus the **hardening** item still -open (conditional HMAC). Un-redacted answer retention (PIA-3) is now closed for the durable log; the -`rag_response_cache` still retains answers until a same-key overwrite (read-TTL only, no purge cron — -see §8). Anonymous answer caching is disabled, and the tenancy review found **zero** confirmed -cross-tenant leaks. The remaining items are compliance-posture and PHI-minimisation gaps. +material shortfalls are **governance/contractual** (APP 8 cross-border terms and final approval of the +draft APP 1/5 policy/notice wording) plus the +**hardening** items of operator HMAC-secret placement and query-miss/cache retention. Answer prose is omitted by default; +enabling its persistence is an exceptional, non-production mode requiring governance approval. Anonymous answer caching +is disabled. The tenancy review found **zero** confirmed cross-tenant leaks; the remaining items are compliance-posture +and PHI-minimisation gaps. --- @@ -333,16 +333,18 @@ cross-tenant leaks. The remaining items are compliance-posture and PHI-minimisat ### PIA-1 — Cross-border disclosure to OpenAI lacks visible DPA/ZDR + notice **(High)** - **Risk:** Health/PHI in queries and excerpts is disclosed to OpenAI (US) with no code-visible - contractual data-processing terms and no user notice → APP 8 accountability exposure and APP 5 - notification gap. + contractual data-processing terms. A draft in-product provider disclosure now exists, reducing the + point-of-entry visibility gap, but it is not governance-approved legal wording → APP 8 accountability + exposure and a residual APP 5 governance gap. - **Evidence:** plain client to `api.openai.com` ([openai.ts:69-73](src/lib/openai.ts)); raw query + excerpts sent ([rag.ts:7144, 6306](src/lib/rag.ts)); no ZDR/baseURL. - **Fix (ranked):** (1) Execute an OpenAI DPA and, ideally, obtain **ZDR** for the org; record it in - `docs/`. (2) Add an APP-5 **collection/OpenAI-disclosure notice** at the query UI and in a privacy - policy. (3) Consider an **on-query PHI reminder** ("do not enter identifiable patient details"). - (4) Optionally, a lightweight PHI-scrub / entity-strip on the outbound query as defence-in-depth. + `docs/`. (2) Obtain governance/legal approval for the shipped draft APP-5 collection/provider + disclosure and final privacy policy. (3) Retain the shipped on-query/upload PHI reminder. + (4) Optionally, add a lightweight PHI-scrub / entity-strip on the outbound query as defence-in-depth. - **Progress (2026-07-13):** fixes (2)+(3) are **live on `main`** via PR #513 - ([src/app/privacy/page.tsx](src/app/privacy/page.tsx), composer notice) — so APP 5/1 is met. Fix (1), + ([src/app/privacy/page.tsx](src/app/privacy/page.tsx), composer notice), as draft wording pending + governance approval. Fix (1), the contractual basis, is captured decision-ready in **[docs/openai-cross-border-basis.md](docs/openai-cross-border-basis.md)**, which also records that the app's egress endpoints (`/v1/responses`, `/v1/embeddings`) are **ZDR-eligible** and that OpenAI now @@ -364,7 +366,7 @@ cross-tenant leaks. The remaining items are compliance-posture and PHI-minimisat - **Remaining (operator):** place `RAG_QUERY_HASH_SECRET` in the deploy host's secret store on the live project (the code enforces its presence but cannot supply the value). -### PIA-3 — Generated answers stored un-redacted in `rag_queries` **(Resolved)** +### PIA-3 — Generated answers stored un-redacted in `rag_queries` **(Mitigated)** - **Risk:** The `answer` column held the full generated text, which can restate patient specifics echoed from the query; the query itself is hashed but the answer was not. Owner-scoped (not @@ -389,6 +391,8 @@ cross-tenant leaks. The remaining items are compliance-posture and PHI-minimisat would defeat caching, so it is intentionally not gated by this flag; bounding it properly needs a scheduled purge (a PIA-4-style follow-up). This is distinct from — and not covered by — the 30-day durable-log fix above. +- **Historical cleanup:** a migration to null existing `rag_queries.answer` values is prepared but + intentionally unexecuted pending deployment approval; this assessment does not claim live cleanup. ### PIA-4 — `rag_query_misses` never purged **(Medium)** @@ -398,16 +402,14 @@ cross-tenant leaks. The remaining items are compliance-posture and PHI-minimisat - **Fix:** Add a `pg_cron` purge for `rag_query_misses` (30–90 days) mirroring [migration 20260702120000](supabase/migrations/20260702120000_rag_retrieval_logs_retention.sql). -### PIA-5 — No privacy policy / collection notice / data-handling doc **(Medium)** +### PIA-5 — Draft notices/page ship; final approved privacy policy remains outstanding **(Medium)** -- **Risk:** APP 1 (policy) and APP 5 (notification) were unmet; users were not told what is collected, - retained, or disclosed overseas. -- **Fix:** Ship a privacy policy + in-product collection notice; document retention windows and the - OpenAI disclosure. Low engineering cost, high compliance value. -- **Progress (2026-07-13):** the privacy policy + in-product collection notice are **live on `main`** - via PR #513 ([privacy page](src/app/privacy/page.tsx), composer notice), closing the core APP 1/APP 5 - notification gap. Residual: broader data-handling documentation (retention windows, breach-response - runbook) and the APP 8 contractual basis (tracked under PIA-1). +- **Risk:** The shipped draft point-of-entry notices and `/privacy` page explain collection, retention, + and overseas/provider processing, but they are explicitly pending governance review and do not by + themselves establish an approved APP privacy policy. +- **Fix:** Have governance/legal owners review, amend and approve the draft wording; publish the final + APP privacy policy and retain the point-of-entry links/notices. Broader retention and breach-response + documentation also remains outstanding. No legal approval is claimed here. ### PIA-6 — OpenAI prompt-cache retention forced to 24h **(Low-Medium)** @@ -429,14 +431,18 @@ cross-tenant leaks. The remaining items are compliance-posture and PHI-minimisat ## 11. Recommendation Before the app is used with real patients in a WA clinical setting, close **PIA-1** (execute the -cross-border DPA/ZDR contractual basis — the APP 5 user notice is already live) and **PIA-2** (place +cross-border DPA/ZDR contractual basis and approve the shipped draft APP 5 wording) and **PIA-2** (place the mandatory HMAC secret in the deploy host's secret store; the fail-closed boot guard is now -enforced in code) as launch-blockers. **PIA-3** is closed (the durable `rag_queries.answer` log is no +enforced in code) as launch-blockers. **PIA-3** is mitigated (the durable `rag_queries.answer` log is no longer persisted by default; gated behind `RAG_PERSIST_ANSWER_TEXT`); **PIA-4** remains as a fast follow-up (purge `rag_query_misses`, and add a `rag_response_cache` purge cron), and complete the **PIA-5** residual data-handling documentation. The data-at-rest security posture (Sydney residency, RLS, private storage, query hashing, automated purge) is already strong and should be highlighted in the privacy policy as evidence of "reasonable steps" under APP 11. +PIA-3 is mitigated by default-null answer logging. If exceptional non-production answer persistence is +ever enabled, it remains governance-gated. The historical cleanup migration is prepared but unexecuted; +this assessment does not claim live cleanup or legal approval. + See the companion **[tenancy defense-in-depth review](docs/tenancy-defense-in-depth-review.md)** for the cross-tenant isolation analysis referenced above. diff --git a/docs/site-map.md b/docs/site-map.md index a4afa03e9..17c063780 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -16,7 +16,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. - `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. -- `/privacy` - Route discovered from app directory Source: `src/app/privacy/page.tsx`. +- `/privacy` - Privacy and data-processing governance draft. Source: `src/app/privacy/page.tsx`. - `/reference/colour-coding` - Route discovered from app directory Source: `src/app/reference/colour-coding/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. diff --git a/scripts/compare-retrieval-eval.ts b/scripts/compare-retrieval-eval.ts index 3b70ba9ad..134b94c37 100644 --- a/scripts/compare-retrieval-eval.ts +++ b/scripts/compare-retrieval-eval.ts @@ -44,6 +44,21 @@ const METRIC_SPECS: MetricSpec[] = [ }, { name: "latency_failed_cases", field: "latency_failed_cases", kind: "array", required: false, digits: 0 }, { name: "index_units_layer_count", field: "index_units", kind: "layer", required: false, digits: 0 }, + { name: "ndcg_at_10", field: "ndcg_at_10", kind: "number", required: false, digits: 4 }, + { + name: "irrelevant_source_rate_at_10", + field: "irrelevant_source_rate_at_10", + kind: "number", + required: false, + digits: 4, + }, + { + name: "required_signal_coverage_at_10", + field: "required_signal_coverage_at_10", + kind: "number", + required: false, + digits: 4, + }, ]; type MetricValue = { present: boolean; value: number }; diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 29e891aeb..997e727b9 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -677,6 +677,10 @@ ${markdownTable([ ["Document recall@5", retrieval.document_recall_at_5], ["Content recall@5", retrieval.content_recall_at_5], ["MRR@10", retrieval.mrr_at_10], + ["nDCG@10", retrieval.ndcg_at_10], + ["Irrelevant source rate@10", retrieval.irrelevant_source_rate_at_10], + ["Required signal coverage@10", retrieval.required_signal_coverage_at_10], + ["Signal metric cases", retrieval.signal_metric_case_count], ["Content MRR@10", retrieval.content_mrr_at_10], ["Content MRR cases", retrieval.content_mrr_case_count], ["Median latency ms", retrieval.median_latency_ms], diff --git a/scripts/eval-rag-offline.mjs b/scripts/eval-rag-offline.mjs index ff8f35f6b..8606ab77a 100644 --- a/scripts/eval-rag-offline.mjs +++ b/scripts/eval-rag-offline.mjs @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { validateOfflineContractTests } from "./rag-offline-contract.mjs"; const goldenPath = "scripts/fixtures/rag-retrieval-golden.json"; const cases = JSON.parse(readFileSync(goldenPath, "utf8")); @@ -68,13 +69,14 @@ if (failures.length > 0) { console.log(`Offline RAG fixture schema passed (${cases.length} golden retrieval cases).`); -const contractTests = [ - "tests/rag-offline-answer.test.ts", - "tests/rag-answer-fallback.test.ts", - "tests/retrieval-selection.test.ts", - "tests/citations.test.ts", - "tests/smart-rag-api.test.ts", -]; +const contractTestsPath = "scripts/fixtures/rag-offline-contract-tests.json"; +const contractTests = JSON.parse(readFileSync(contractTestsPath, "utf8")); +const contractTestFailures = validateOfflineContractTests(contractTests); +if (contractTestFailures.length > 0) { + console.error(`${contractTestsPath} failed the offline safety contract:`); + for (const failure of contractTestFailures) console.error(`- ${failure}`); + process.exit(1); +} const vitestPath = resolve("node_modules/vitest/vitest.mjs"); const offlineEnv = { ...process.env }; for (const key of [ diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index a17b87b22..f990898da 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -67,6 +67,10 @@ export type GoldenRetrievalResult = { topK: number; reciprocalRankAt10: number; contentReciprocalRankAt10: number; + declaredSignalCount: number; + ndcgAt10: number; + irrelevantSourceRateAt10: number; + requiredSignalCoverageAt10: number; latencyMs: number; retrievalStrategy: string | null; retrievalPlan: string | null; @@ -361,9 +365,9 @@ function expectedContentHits( results: SearchResult[], limit: number, ) { - const topContentText = results.slice(0, limit).map(resultContentText).join(" "); + const topResults = results.slice(0, limit); const hits = expectedTerms.filter((expectation) => - contentExpectationAlternatives(expectation).some((term) => textContainsClinicalTerm(topContentText, term)), + topResults.some((result) => contentExpectationMatchesResult(expectation, result)), ); return { hits: hits.map(contentExpectationLabel), @@ -371,6 +375,14 @@ function expectedContentHits( }; } +function contentExpectationMatchesResult( + expectation: GoldenRetrievalCase["expectedContentTerms"][number], + result: SearchResult, +) { + const contentText = resultContentText(result); + return contentExpectationAlternatives(expectation).some((term) => textContainsClinicalTerm(contentText, term)); +} + function reciprocalRankAt10(expectedSubstrings: string[], results: SearchResult[]) { if (expectedSubstrings.length === 0) return 0; const index = results.slice(0, 10).findIndex((result) => @@ -410,6 +422,60 @@ function contentReciprocalRankAt10( return total / expectedTerms.length; } +type RetrievalSignalMetrics = { + declaredSignalCount: number; + ndcgAt10: number; + irrelevantSourceRateAt10: number; + requiredSignalCoverageAt10: number; +}; + +/** + * Scores only signals explicitly declared by a golden case. Each document expectation, + * content expectation group, and required table-evidence flag is one signal. A result's + * graded relevance is the number of those signals it carries; nDCG uses the ideal ordering + * of the observed grades, so it measures ordering independently from coverage. + */ +function retrievalSignalMetricsAt10(testCase: GoldenRetrievalCase, results: SearchResult[]): RetrievalSignalMetrics { + const top = results.slice(0, 10); + const documentSignals = testCase.expectedDocumentSubstrings.map((expectation) => (result: SearchResult) => { + const text = + !clinicalDocumentAliases[expectation] && !/\.pdf$/i.test(expectation) + ? resultDocumentEvidenceText(result) + : resultDocumentText(result); + return documentExpectationAlternatives(expectation).some((alternative) => text.includes(alternative)); + }); + const contentSignals = testCase.expectedContentTerms.map( + (expectation) => (result: SearchResult) => contentExpectationMatchesResult(expectation, result), + ); + const signals = [ + ...documentSignals, + ...contentSignals, + ...(testCase.expectTableEvidence ? [(result: SearchResult) => hasTableEvidence([result], 1)] : []), + ]; + const declaredSignalCount = signals.length; + if (declaredSignalCount === 0) { + return { + declaredSignalCount: 0, + ndcgAt10: 1, + irrelevantSourceRateAt10: 0, + requiredSignalCoverageAt10: 1, + }; + } + + const grades = top.map((result) => signals.filter((matches) => matches(result)).length); + const dcg = (values: number[]) => + values.reduce((sum, grade, index) => sum + (2 ** grade - 1) / Math.log2(index + 2), 0); + const idealDcg = dcg([...grades].sort((left, right) => right - left)); + const matchedSignals = signals.filter((matches) => top.some((result) => matches(result))).length; + + return { + declaredSignalCount, + ndcgAt10: idealDcg > 0 ? dcg(grades) / idealDcg : 0, + irrelevantSourceRateAt10: top.length > 0 ? grades.filter((grade) => grade === 0).length / top.length : 0, + requiredSignalCoverageAt10: matchedSignals / declaredSignalCount, + }; +} + export function retrievalLimitForGoldenCase(testCase: GoldenRetrievalCase) { return Math.max(testCase.topK, 10); } @@ -528,6 +594,7 @@ export function evaluateGoldenRetrievalCase(args: { : contentHits.hits.length / args.testCase.expectedContentTerms.length; const tableEvidenceFound = hasTableEvidence(args.results, 5); const tableEvidenceFoundAtK = hasTableEvidence(args.results, topK); + const signalMetrics = retrievalSignalMetricsAt10(args.testCase, args.results); const actualQueryClass = args.telemetry.query_class ?? null; const failures: string[] = []; const vectorLayerCount = Object.entries(args.telemetry.retrieval_layer_counts ?? {}).reduce( @@ -581,6 +648,7 @@ export function evaluateGoldenRetrievalCase(args: { topK, reciprocalRankAt10: reciprocalRankAt10(args.testCase.expectedDocumentSubstrings, args.results), contentReciprocalRankAt10: contentReciprocalRankAt10(args.testCase.expectedContentTerms, args.results), + ...signalMetrics, latencyMs: args.latencyMs, retrievalStrategy: args.telemetry.retrieval_strategy ?? null, retrievalPlan: args.telemetry.retrieval_plan ?? null, @@ -645,6 +713,15 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] return counts; }, {}); const forceEmbeddingResults = results.filter((result) => result.forceEmbedding); + const signalMetricResults = results.filter((result) => result.declaredSignalCount > 0); + const signalMetricAverage = (select: (result: GoldenRetrievalResult) => number, neutral: number) => + signalMetricResults.length > 0 + ? Number( + (signalMetricResults.reduce((sum, result) => sum + select(result), 0) / signalMetricResults.length).toFixed( + 4, + ), + ) + : neutral; return { case_count: results.length, document_recall_at_5: Number( @@ -664,6 +741,10 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] ).toFixed(4), ), content_mrr_case_count: contentRankCases.length, + ndcg_at_10: signalMetricAverage((result) => result.ndcgAt10, 1), + irrelevant_source_rate_at_10: signalMetricAverage((result) => result.irrelevantSourceRateAt10, 0), + required_signal_coverage_at_10: signalMetricAverage((result) => result.requiredSignalCoverageAt10, 1), + signal_metric_case_count: signalMetricResults.length, median_latency_ms: percentile( results.map((result) => result.latencyMs), 50, @@ -753,6 +834,9 @@ function printHumanSummary(summary: ReturnType = { "/forms/[slug]": "Registry-backed form detail.", "/medications": "Medication index redirect.", "/medications/[slug]": "Medication detail.", + "/privacy": "Privacy and data-processing governance draft.", "/services": "Services home and search surface.", "/services/[slug]": "Registry-backed service detail.", }; diff --git a/scripts/rag-offline-contract.mjs b/scripts/rag-offline-contract.mjs new file mode 100644 index 000000000..97b12c054 --- /dev/null +++ b/scripts/rag-offline-contract.mjs @@ -0,0 +1,38 @@ +export const requiredOfflineContractTests = Object.freeze([ + "tests/rag-offline-contract.test.ts", + "tests/rag-offline-answer.test.ts", + "tests/rag-answer-fallback.test.ts", + "tests/retrieval-selection.test.ts", + "tests/citations.test.ts", + "tests/smart-rag-api.test.ts", + "tests/deep-memory.test.ts", + "tests/deep-memory-transaction-sql.test.ts", + "tests/retrieval-access-scope.test.ts", + "tests/retrieval-hydration-scope.test.ts", + "tests/answer-verification.test.ts", + "tests/rag-routing.test.ts", + "tests/cross-document-synthesis.test.ts", + "tests/rag-comparison.test.ts", + "tests/rag-claim-support.test.ts", + "tests/answer-render-policy.test.ts", + "tests/canonical-answer-table.test.ts", + "tests/privacy.test.ts", + "tests/private-rag-access.test.ts", + "tests/upload-admission.test.ts", + "tests/privacy-ui.test.ts", +]); + +export function validateOfflineContractTests(suites) { + const failures = []; + if (!Array.isArray(suites) || suites.length === 0) { + return ["contract suite list must be a non-empty array"]; + } + if (!suites.every((suite) => typeof suite === "string" && suite.trim().length > 0)) { + failures.push("contract suite list contains an invalid path"); + } + if (new Set(suites).size !== suites.length) failures.push("contract suite list contains duplicates"); + for (const required of requiredOfflineContractTests) { + if (!suites.includes(required)) failures.push(`missing required suite: ${required}`); + } + return failures; +} diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 4b226f8e4..1d815afd1 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; -import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { isDemoMode } from "@/lib/env"; import { answerQuestionWithScope } from "@/lib/rag"; import { jsonError, PublicApiError } from "@/lib/http"; import { @@ -14,6 +14,7 @@ import { classifyRagQuery } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; +import { resolveRetrievalAccessScope } from "@/lib/owner-scope"; import { hasDangerSourceGovernanceWarning, sourceGovernanceRefusalAnswer, @@ -36,7 +37,6 @@ const answerSchema = z.object({ documentIds: z.array(z.string().uuid()).max(25).optional(), filters: searchScopeFiltersSchema.optional(), queryMode: clinicalQueryModeSchema.optional().default("auto"), - skipCache: z.boolean().optional().default(false), }); type AnswerRequestBody = z.infer; @@ -82,7 +82,7 @@ export async function POST(request: Request) { const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); - const publicOnly = !access.authenticated && !isLocalNoAuthMode(); + const accessScope = resolveRetrievalAccessScope(access.ownerId); const rateLimit = await consumeSubjectApiRateLimit({ supabase, @@ -96,8 +96,7 @@ export async function POST(request: Request) { const scope = await resolveSearchScope({ supabase, - ownerId: access.ownerId, - publicOnly, + accessScope, documentIds: answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined), filters: answerBody.filters, }); @@ -127,9 +126,9 @@ export async function POST(request: Request) { answerBody.documentIds ?? (answerBody.documentId ? [answerBody.documentId] : undefined)), ownerId: access.ownerId, + accessScope, allowGlobalSearch: !access.ownerId, queryMode: answerBody.queryMode, - skipCache: answerBody.skipCache, signal: request.signal, }); const warnings = sourceGovernanceWarnings({ diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index d11ac106f..d47e89346 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; -import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { isDemoMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; import { allowRateLimitInMemoryFallbackOnUnavailable, @@ -16,6 +16,7 @@ import { annotateSearchResults, buildEvidenceRelevance } from "@/lib/evidence-re import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; +import { resolveRetrievalAccessScope, type RetrievalAccessScope } from "@/lib/owner-scope"; import { hasDangerSourceGovernanceWarning, sourceGovernanceRefusalAnswer, @@ -39,7 +40,6 @@ const answerSchema = z.object({ documentIds: z.array(z.string().uuid()).max(25).optional(), filters: searchScopeFiltersSchema.optional(), queryMode: clinicalQueryModeSchema.optional().default("auto"), - skipCache: z.boolean().optional().default(false), }); type AnswerBody = z.infer; @@ -123,7 +123,8 @@ function buildDemoStreamAnswer(body: AnswerBody, fallbackReason?: string) { }; } -function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, publicOnly = false) { +function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signal?: AbortSignal) { + const ownerId = accessScope.ownerId; const encoder = new TextEncoder(); return new Response( @@ -153,8 +154,7 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, ? null : await resolveSearchScope({ supabase: createAdminClient(), - ownerId, - publicOnly, + accessScope, documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), filters: body.filters, }); @@ -184,9 +184,9 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, ? undefined : (scope?.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined)), ownerId, + accessScope, allowGlobalSearch: !ownerId, queryMode: body.queryMode, - skipCache: body.skipCache, onProgress, onToken, onRevising, @@ -279,11 +279,10 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, export async function POST(request: Request) { try { const body = await parseJsonBody(request, answerSchema, "Invalid answer request."); - if (isDemoMode()) return streamAnswer(body, undefined, request.signal); + if (isDemoMode()) return streamAnswer(body, resolveRetrievalAccessScope(), request.signal); const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); - const publicOnly = !access.authenticated && !isLocalNoAuthMode(); const rateLimit = await consumeSubjectApiRateLimit({ supabase, @@ -293,7 +292,7 @@ export async function POST(request: Request) { }); if (rateLimit.limited) return rateLimitStream(rateLimit); - return streamAnswer(body, access.ownerId, request.signal, publicOnly); + return streamAnswer(body, resolveRetrievalAccessScope(access.ownerId), request.signal); } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(error); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 044ab1e4c..8316e88dc 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -24,6 +24,7 @@ import { publicAccessContext } from "@/lib/public-api-access"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { parseJsonBody } from "@/lib/validation/body"; import { resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; +import { resolveRetrievalAccessScope } from "@/lib/owner-scope"; import { sourceGovernanceWarnings } from "@/lib/source-governance"; import { normalizedQueryTextForStorage, @@ -681,15 +682,14 @@ async function buildScopedSearchPayload( body: SearchRequestBody, supabase: ReturnType, ownerId?: string | null, - publicOnly = false, ) { const searchFocusQuery = queryForClinicalMode(body.query, body.queryMode); const effectiveQueryClass = queryClassForClinicalMode(body.queryMode) ?? classifyRagQuery(searchFocusQuery).queryClass; + const accessScope = resolveRetrievalAccessScope(ownerId); const scope = await resolveSearchScope({ supabase, - ownerId: ownerId ?? undefined, - publicOnly, + accessScope, documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), filters: body.filters, }); @@ -734,6 +734,7 @@ async function buildScopedSearchPayload( : (body.topK ?? 8), documentIds: scope.documentIds ?? body.documentIds ?? (body.documentId ? [body.documentId] : undefined), ownerId: ownerId ?? undefined, + accessScope, allowGlobalSearch: !ownerId, queryMode: body.queryMode, }); @@ -751,6 +752,7 @@ async function buildScopedSearchPayload( ? await fetchRelatedDocuments({ supabase, ownerId: ownerId ?? undefined, + accessScope, query: searchFocusQuery, results, limit: isSourceLibrarySearchMode(body.mode) ? body.documentLimit : undefined, @@ -923,7 +925,7 @@ export async function POST(request: Request) { const key = scopedSearchKey(searchBody, ownerId, publicOnly); const { payload, coalesced } = await coalesceScopedSearch(key, () => - buildScopedSearchPayload(searchBody, supabase!, ownerId, publicOnly), + buildScopedSearchPayload(searchBody, supabase!, ownerId), ); return NextResponse.json({ ...payload, diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index f017cfbc4..6951bfb4b 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -6,17 +6,14 @@ import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env"; import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; -import { - allowRateLimitInMemoryFallbackOnUnavailable, - consumeSubjectApiRateLimit, - rateLimitJsonResponse, -} from "@/lib/api-rate-limit"; +import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { publicAccessContext } from "@/lib/public-api-access"; import { probeSupabaseHealth } from "@/lib/supabase/health"; import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data"; +import { acquireUploadAdmission, parseUploadContentLength } from "@/lib/upload-admission"; export const runtime = "nodejs"; @@ -40,6 +37,12 @@ function isContentHashDuplicateError(error: unknown) { ); } +function assertUploadNotAborted(request: Request) { + if (request.signal.aborted) { + throw new PublicApiError("Upload cancelled by client.", 499, { code: "client_cancelled" }); + } +} + async function duplicateUploadResponse(args: { supabase: ReturnType; ownerId: string; @@ -86,6 +89,7 @@ export async function POST(request: Request) { let uploadedPath: string | null = null; let insertedDocumentId: string | null = null; let insertedDocumentOwnerId: string | null = null; + let releaseAdmission: (() => void) | null = null; try { supabase = createAdminClient(); @@ -106,7 +110,7 @@ export async function POST(request: Request) { supabase: adminSupabase, subject: access.rateLimitSubject, bucket: "document_upload", - allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + allowInMemoryFallbackOnUnavailable: false, }); if (rateLimit.limited) { return rateLimitJsonResponse( @@ -115,11 +119,26 @@ export async function POST(request: Request) { ); } - const declaredLength = Number(request.headers.get("content-length")); - const multipartBudget = env.MAX_UPLOAD_MB * 1024 * 1024 + 1024 * 1024; - if (Number.isFinite(declaredLength) && declaredLength > multipartBudget) { - throw new PublicApiError("Upload request is too large.", 413, { code: "payload_too_large" }); + const multipartOverheadBytes = 1024 * 1024; + const maximumBodyBytes = env.MAX_UPLOAD_MB * 1024 * 1024 + multipartOverheadBytes; + const contentLength = parseUploadContentLength(request.headers.get("content-length")); + if (contentLength !== null && contentLength > maximumBodyBytes) { + throw new PublicApiError("Upload body exceeds the configured size limit.", 413, { + code: "upload_body_too_large", + }); + } + const admission = acquireUploadAdmission({ + bytes: contentLength ?? maximumBodyBytes, + maxConcurrent: env.MAX_CONCURRENT_UPLOADS, + maxBytes: env.MAX_IN_FLIGHT_UPLOAD_MB * 1024 * 1024, + }); + if (!admission.ok) { + throw new PublicApiError("Upload capacity is temporarily exhausted. Retry shortly.", 503, { + code: admission.reason === "bytes" ? "upload_byte_budget_exhausted" : "upload_capacity_exhausted", + }); } + releaseAdmission = admission.release; + assertUploadNotAborted(request); const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { @@ -144,6 +163,7 @@ export async function POST(request: Request) { const documentId = randomUUID(); const safeName = file.name.replace(/[^\w.\-() ]+/g, "_"); const storagePath = `${uploadOwnerId}/documents/${documentId}/${safeName}`; + assertUploadNotAborted(request); const buffer = Buffer.from(await file.arrayBuffer()); // The declared MIME type is client-supplied; verify the real byte signature // before persisting a clinical document. @@ -170,6 +190,7 @@ export async function POST(request: Request) { const health = await probeSupabaseHealth(adminSupabase); if (!health.ok) return NextResponse.json({ error: `Upload is paused. ${health.message}` }, { status: 503 }); + assertUploadNotAborted(request); const upload = await adminSupabase.storage.from(env.SUPABASE_DOCUMENT_BUCKET).upload(storagePath, buffer, { contentType: file.type, upsert: false, @@ -192,6 +213,7 @@ export async function POST(request: Request) { const description = uploadMetadata.description; const uploadedAt = new Date().toISOString(); + assertUploadNotAborted(request); const { data: document, error: documentError } = await supabase .from("documents") .insert({ @@ -248,6 +270,7 @@ export async function POST(request: Request) { insertedDocumentId = documentId; insertedDocumentOwnerId = uploadOwnerId; + assertUploadNotAborted(request); const { data: job, error: jobError } = await supabase .from("ingestion_jobs") .insert({ @@ -336,5 +359,7 @@ export async function POST(request: Request) { } return jsonError(error); + } finally { + releaseAdmission?.(); } } diff --git a/src/app/globals.css b/src/app/globals.css index 4123314b3..88624cfc7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -80,6 +80,7 @@ --text-base-minus: 0.9375rem; --text-lg-minus: 1.0625rem; --text-2xl-minus: 1.375rem; + --text-2xl-compact: 1.45rem; --text-3xl-minus: 1.625rem; /* Fluid display heading for the hero / mode-home titles (hybrid-fluid type: diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index b0c9e25f1..c84359536 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -14,15 +14,9 @@ import { privacyCopy } from "@/lib/ui-copy"; export const metadata: Metadata = { title: "Privacy & data handling — Clinical KB", - description: - "How Clinical KB handles your questions and documents: what is collected, where it is stored, what is sent to OpenAI, and how long it is kept.", + description: "Draft product information about how Clinical KB handles questions and documents.", }; -// Plain-language, code-accurate transparency page (APP-5 collection notice + APP-1 -// openness). This summarizes the engineering posture documented in -// docs/privacy-impact-assessment.md. It is not legal advice, and it is not a -// substitute for a formal privacy policy reviewed by a privacy officer — that -// review, plus the OpenAI cross-border agreement, is the outstanding PIA-1 step. type Section = { heading: string; body: ReactNode }; const SECTIONS: Section[] = [ @@ -30,71 +24,48 @@ const SECTIONS: Section[] = [ heading: "What this tool is", body: ( <> - Clinical KB is a knowledge base over clinical reference material (guidelines, drug monographs, protocols). It is{" "} - not a patient-record system and does not ask you for patient data. The main privacy - consideration is therefore incidental patient information that a clinician might type into a free-text - question. + Clinical KB is a knowledge base over clinical reference material. It is{" "} + not a patient-record system + and does not ask for patient data. The main privacy risk is incidental patient information entered into a + free-text question or uploaded document. ), }, { heading: "What is collected", - body: ( - <> - Your free-text questions, the generated answers, your account identity (email / sign-in), and any documents you - upload. Questions and uploaded documents may contain identifiable information if you put it there — which is why - the notice above asks you not to. - - ), + body: "Questions, generated answers, account identifiers, uploaded documents, retrieved excerpts, document metadata, and operational or retrieval telemetry may be processed. Free text and uploaded material can contain sensitive information if you enter it.", }, { - heading: "How your questions are handled", + heading: "How questions are handled", body: ( <> - Question text is not stored in raw form by default. Before anything is logged it is replaced - with a keyed one-way hash, so the log tables hold a pseudonym rather than your words. Generated answers are - stored against your account only, and both are automatically deleted on a schedule (see retention). + Raw question text is not written to query logs by default; logs use a keyed one-way hash. Generated answer text + is also omitted from durable query logs by default. A short-lived response cache can contain the answer while + its read TTL is valid. ), }, { - heading: "Where your data is stored", - body: ( - <> - All stored data — documents, indexed text, answers, logs, and sign-in — lives in a database and file store - hosted in Sydney, Australia (AWS ap-southeast-2). Uploaded files sit in private buckets and are - reachable only through short-lived (10-minute) links minted after an ownership check. - - ), + heading: "Where data is stored", + body: "Documents, extracted evidence, metadata, account records, and owner-scoped operational records are stored in the configured Supabase project. File buckets are private and links are time-limited. The operator must verify the deployed project region and contractual controls.", }, { - heading: "What is sent to OpenAI (United States)", + heading: "External provider processing", body: ( <> - To understand a question and generate an answer, the question text and the matching excerpts from your library - are sent to OpenAI in the United States. This is the only point where data leaves Australia. - OpenAI is asked not to retain these requests in its dashboard, and no patient identifiers are added by the app — - but any details you type are transmitted, so do not enter identifiable patient details. + When model-backed answering is enabled, the question and selected source excerpts are sent to the configured + OpenAI API. This processing may occur outside Australia. Provider mode can also return a local source-only + response. The operator must verify provider regions, retention terms, contracts, and cross-border obligations. ), }, { - heading: "How long it is kept", - body: ( - <> - Logged (hashed) questions and answers are purged after 30 days; retrieval telemetry after{" "} - 90 days. Uploaded documents and their index remain until you remove them. - - ), + heading: "Retention", + body: "Repository migrations configure 30-day retention for RAG query records and 90-day retention for retrieval logs when the database scheduler is available. Query-miss and expired response-cache cleanup require separate governance and operational controls. Uploaded documents remain until removed under the applicable process.", }, { - heading: "Security", - body: ( - <> - Data is scoped to your account, storage is private, and access is checked on every request. These are - engineering safeguards; they do not replace your own judgement about what is safe to enter. - - ), + heading: "Your responsibilities", + body: "Do not enter patient-identifiable information. Upload only material you are authorised to use, keep access credentials private, review original linked sources before relying on clinical output, and report suspected privacy or access issues through your organisation's approved process.", }, ]; @@ -109,8 +80,8 @@ export default function PrivacyPage() { {privacyCopy.pageTitle}

- A plain-language summary of how Clinical KB handles your questions and documents. It reflects how the - software behaves today; it is not legal advice, and a formal privacy policy is still under review. + This is draft product information based on the repository's configured behaviour. It is not legal + advice, a final privacy policy, or an assertion of governance approval.

@@ -120,8 +91,8 @@ export default function PrivacyPage() {

- Do not enter identifiable patient details (names, dates of birth, record numbers) into your questions. - Your question text is sent to OpenAI in the United States to generate an answer. + Do not enter identifiable patient details such as names, dates of birth, or record numbers. When + model-backed answering is enabled, your question is sent to the configured OpenAI API.

diff --git a/src/components/AccessibleTable.tsx b/src/components/AccessibleTable.tsx index dd9fb916f..a5d2979cc 100644 --- a/src/components/AccessibleTable.tsx +++ b/src/components/AccessibleTable.tsx @@ -322,6 +322,7 @@ export function AccessibleTable({ markdown, rows, columns, + normalizedTable, compact = false, expandOnMobile = false, previewRows, @@ -338,6 +339,7 @@ export function AccessibleTable({ markdown?: string | null; rows?: string[][] | null; columns?: string[] | null; + normalizedTable?: NormalizedAccessibleTable | null; compact?: boolean; expandOnMobile?: boolean; previewRows?: number; @@ -365,6 +367,7 @@ export function AccessibleTable({ return hasExplicitRows ? rows : parseMarkdownTable(markdown); }, [hasExplicitRows, rows, markdown]); const normalized = useMemo(() => { + if (normalizedTable) return normalizedTable; if (!parsed?.length) return null; // Audit M8/H4 parity (diff review): markdown-parsed rows include their // own header line as row 0 — passing explicit columns alongside them made @@ -376,7 +379,7 @@ export function AccessibleTable({ const table = normalizeAccessibleTable(parsed, hasExplicitRows ? columns : null); if (!table) return null; return clinicalOnly ? clinicalOnlyTable(table) : table; - }, [clinicalOnly, columns, hasExplicitRows, parsed]); + }, [clinicalOnly, columns, hasExplicitRows, normalizedTable, parsed]); const dialogOpen = open; diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 204ff6cf7..8dd33e92e 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -20,6 +20,7 @@ import { import { cleanDisplayTitle } from "@/components/clinical-dashboard/display-text"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; import { StatusBadge } from "@/components/clinical-dashboard/badges"; +import { PrivacyInputNotice } from "@/components/privacy-input-notice"; import type { ClinicalDocument, IngestionJob, ImportBatch } from "@/lib/types"; // Setup and quality types @@ -389,6 +390,7 @@ export function UploadPanel({ return (
+