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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ RAG_AWAIT_QUERY_LOGS=false
#NEXT_PUBLIC_MOCKUPS_ENABLED=false
# Persist raw clinical query text in logs. Default off; blocked in production readiness.
#RAG_PERSIST_RAW_QUERY_TEXT=false
# Persist the full generated answer text (rag_queries.answer and promoted eval-case
# metadata). It is PHI-derived and can restate patient specifics, so it is dropped at
# rest by default. The offline eval pipeline reads the in-memory answer, not this
# column. Default off; blocked in production readiness (PIA-3).
#RAG_PERSIST_ANSWER_TEXT=false
# Server-side key for the redacted query-hash placeholder (min 16 chars).
# When set, stored query hashes are HMAC-SHA256 keyed pseudonyms — not
# offline-reversible and not correlatable outside this deployment. REQUIRED in
Expand Down
88 changes: 48 additions & 40 deletions docs/privacy-impact-assessment.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions scripts/production-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@ function recordRawQueryPersistenceProductionCheck() {
}
}

function recordAnswerPersistenceProductionCheck() {
if (
(process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production") &&
process.env.RAG_PERSIST_ANSWER_TEXT === "true"
) {
result.failures.push("RAG_PERSIST_ANSWER_TEXT=true is not allowed in a production-like environment.");
}
}

async function checkFileForServiceRoleExposure() {
const envFiles = [".env", ".env.production", ".env.development"];
for (const fileName of envFiles) {
Expand All @@ -132,6 +141,7 @@ async function main() {
recordNoAuthProductionCheck();
recordDemoModeProductionCheck();
recordRawQueryPersistenceProductionCheck();
recordAnswerPersistenceProductionCheck();
await checkFileForServiceRoleExposure();

if (!(await checkRequiredFile(path.join(process.cwd(), "package-lock.json"), "package-lock.json is required"))) {
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/eval-cases/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { clinicalQueryModeSchema } from "@/lib/clinical-query-mode";
import { env, isDemoMode } from "@/lib/env";
import { jsonError, PublicApiError } from "@/lib/http";
import {
answerPrivacyMetadata,
answerTextForStorage,
normalizedQueryTextForStorage,
queryDerivedTokensForStorage,
queryPrivacyMetadata,
Expand Down Expand Up @@ -164,7 +166,10 @@ export async function POST(request: Request) {
rating,
feedback_type: parsed.feedbackType ?? null,
note: env.RAG_PERSIST_RAW_QUERY_TEXT ? parsed.note : null,
answer: env.RAG_PERSIST_RAW_QUERY_TEXT ? parsed.answer : null,
// PIA-3: the promoted answer is generated clinical text — gate it on the
// dedicated answer-retention flag, not the raw-query flag, so one switch
// governs answer-text persistence everywhere.
answer: answerTextForStorage(parsed.answer),
query_class: parsed.queryClass ?? null,
query_mode: parsed.queryMode,
filters: parsed.filters ?? {},
Expand All @@ -174,6 +179,7 @@ export async function POST(request: Request) {
cited_chunk_ids_rejected: parsed.citedChunkIds.length - citedChunkIds.length,
captured_at: new Date().toISOString(),
...queryPrivacyMetadata(parsed.query),
...answerPrivacyMetadata(),
},
})
.select("id")
Expand Down
11 changes: 11 additions & 0 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ const envSchema = z.object({
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
// PIA-3: rag_queries.answer holds the full generated answer text, which can
// restate patient specifics echoed from the query (the query is hashed, the
// answer is not). Default OFF: do not persist generated answer text at rest.
// The offline eval/quality pipeline reads the in-memory answer (logQuery:false)
// and never reads this column back, so persistence-off is safe. Set true only
// where retaining answer text is permitted and a retention policy exists
// (owner-scoped + 30-day purge). Blocked in production readiness.
RAG_PERSIST_ANSWER_TEXT: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
// Audit M15: server-side key for the redacted query hash. When set, stored
// query hashes are HMAC-SHA256 (not offline-reversible, not correlatable
// outside this deployment). When unset, the legacy unsalted SHA-256 is kept
Expand Down
16 changes: 16 additions & 0 deletions src/lib/query-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ export function normalizedQueryTextForStorage(query: string): string {
return env.RAG_PERSIST_RAW_QUERY_TEXT ? normalizeQueryText(query) : queryHashStorageText(query);
}

// PIA-3: the generated answer is stored verbatim in rag_queries.answer (and in
// promoted-eval-case metadata), so it can restate patient specifics echoed from
// the query. Unless answer retention is explicitly enabled, drop the answer text
// at rest and keep only the row's non-PHI telemetry (source chunk ids, model,
// hash metadata). The column is nullable, so null is the valid "not retained"
// marker. This is the single chokepoint governing answer-text persistence.
export function answerTextForStorage(answer: string | null | undefined): string | null {
return env.RAG_PERSIST_ANSWER_TEXT ? (answer ?? null) : null;
}

// Privacy metadata to fold into a persisted row that carries an answer: records
// whether the generated answer text was retained, mirroring raw_query_retained.
export function answerPrivacyMetadata() {
return { answer_retained: env.RAG_PERSIST_ANSWER_TEXT };
}

export function queryCacheKeyForStorage(cacheKey: string): string {
return env.RAG_PERSIST_RAW_QUERY_TEXT ? cacheKey : `redacted-cache:${hashQueryText(cacheKey)}`;
}
Expand Down
16 changes: 14 additions & 2 deletions src/lib/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ import {
} from "@/lib/clinical-search";
import { env, requestedOpenAIAnswerModels } from "@/lib/env";
import { logger } from "@/lib/logger";
import { queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy";
import {
answerPrivacyMetadata,
answerTextForStorage,
queryPrivacyMetadata,
queryTextForStorage,
} from "@/lib/query-privacy";
import { normalizeSourceMetadata } from "@/lib/source-metadata";
import { isReviewedTablePromotable } from "@/lib/table-review";
import { isClinicalImageEvidence, normalizeImageBbox } from "@/lib/image-filtering";
Expand Down Expand Up @@ -1299,11 +1304,18 @@ async function insertRagQuery(row: RagQueryInsert) {
const supabase = createAdminClient();
// Redact potential-PHI raw query text centrally so every logRagQuery caller is
// covered, and fold a stable hash + retention flag into metadata (RET-H4).
// The generated answer can restate patient specifics, so it is dropped at rest
// unless answer retention is explicitly enabled (PIA-3, default off).
const rawQuery = typeof row.query === "string" ? row.query : "";
const safeRow = {
...row,
query: queryTextForStorage(rawQuery),
metadata: { ...(row.metadata ?? {}), ...queryPrivacyMetadata(rawQuery) } as Json,
answer: answerTextForStorage(row.answer),
metadata: {
...(row.metadata ?? {}),
...queryPrivacyMetadata(rawQuery),
...answerPrivacyMetadata(),
} as Json,
};
await supabase.from("rag_queries").insert(safeRow);
}
Expand Down
49 changes: 46 additions & 3 deletions tests/eval-cases-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ afterEach(() => {
});

function mockEnv(overrides: Record<string, unknown> = {}) {
return { isDemoMode: () => false, env: { RAG_PERSIST_RAW_QUERY_TEXT: false, ...overrides } };
return {
isDemoMode: () => false,
env: { RAG_PERSIST_RAW_QUERY_TEXT: false, RAG_PERSIST_ANSWER_TEXT: false, ...overrides },
};
}

describe("/api/eval-cases", () => {
Expand Down Expand Up @@ -163,8 +166,43 @@ describe("/api/eval-cases", () => {
expect(serialized).not.toContain("clozapine");
});

it("retains raw query and answer when RAG_PERSIST_RAW_QUERY_TEXT is true", async () => {
it("retains raw query and answer when both retention flags are enabled", async () => {
const { client, insert } = createInsertMock();
vi.doMock("@/lib/env", () => mockEnv({ RAG_PERSIST_RAW_QUERY_TEXT: true, RAG_PERSIST_ANSWER_TEXT: true }));
vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client }));
vi.doMock("@/lib/supabase/auth", () => ({
AuthenticationError: class AuthenticationError extends Error {},
requireAuthenticatedUser: vi.fn(async () => ({ id: userId })),
unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }),
}));
const { POST } = await import("../src/app/api/eval-cases/route");

const response = await POST(
request({
query: "What monitoring is needed for clozapine?",
rating: "good",
answer: "Monitor FBC.",
queryMode: "auto",
queryClass: "table_threshold",
sourceChunkIds: [],
citedChunkIds: [],
}),
);
const payload = insert.mock.calls[0]?.[0] as Record<string, unknown>;

expect(response.status).toBe(201);
expect(payload).toMatchObject({ query: "What monitoring is needed for clozapine?" });
expect(payload.metadata).toMatchObject({
answer: "Monitor FBC.",
raw_query_retained: true,
answer_retained: true,
});
});

it("gates answer retention on RAG_PERSIST_ANSWER_TEXT independently of the raw-query flag (PIA-3)", async () => {
const { client, insert } = createInsertMock();
// Raw query retention on, answer retention off: the query text is kept but the
// generated answer is still dropped — the two are decoupled.
vi.doMock("@/lib/env", () => mockEnv({ RAG_PERSIST_RAW_QUERY_TEXT: true }));
vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client }));
vi.doMock("@/lib/supabase/auth", () => ({
Expand All @@ -189,7 +227,12 @@ describe("/api/eval-cases", () => {

expect(response.status).toBe(201);
expect(payload).toMatchObject({ query: "What monitoring is needed for clozapine?" });
expect(payload.metadata).toMatchObject({ answer: "Monitor FBC.", raw_query_retained: true });
expect(payload.metadata).toMatchObject({
answer: null,
raw_query_retained: true,
answer_retained: false,
});
expect(JSON.stringify(payload.metadata)).not.toContain("Monitor FBC.");
});

it("captures a needs-fixing answer without requiring expected UUID fields", async () => {
Expand Down
26 changes: 26 additions & 0 deletions tests/privacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,32 @@ describe("query privacy storage helpers", () => {
});
});

describe("answer persistence storage helper (PIA-3)", () => {
it("drops the generated answer text at rest by default", async () => {
vi.doMock("@/lib/env", () => ({ env: { RAG_PERSIST_ANSWER_TEXT: false } }));
const { answerTextForStorage, answerPrivacyMetadata } = await import("../src/lib/query-privacy");

// A generated answer can restate patient specifics echoed from the query.
const phiAnswer = "Jane Citizen (MRN 123456) should have clozapine withheld below an ANC of 1.5.";
expect(answerTextForStorage(phiAnswer)).toBeNull();
expect(answerTextForStorage("")).toBeNull();
expect(answerTextForStorage(null)).toBeNull();
expect(answerTextForStorage(undefined)).toBeNull();
expect(answerPrivacyMetadata()).toEqual({ answer_retained: false });
});

it("retains the answer text only when answer retention is explicitly enabled", async () => {
vi.doMock("@/lib/env", () => ({ env: { RAG_PERSIST_ANSWER_TEXT: true } }));
const { answerTextForStorage, answerPrivacyMetadata } = await import("../src/lib/query-privacy");

expect(answerTextForStorage("Monitor FBC weekly for the first 18 weeks.")).toBe(
"Monitor FBC weekly for the first 18 weeks.",
);
expect(answerTextForStorage(null)).toBeNull();
expect(answerPrivacyMetadata()).toEqual({ answer_retained: true });
});
});

describe("queryVocabularyAliasesForStorage (RET-H4-safe candidate aliases)", () => {
it("returns only curated vocabulary canonicals matched by the query, never query text", async () => {
vi.doMock("@/lib/env", () => ({ env: { RAG_PERSIST_RAW_QUERY_TEXT: false } }));
Expand Down
13 changes: 11 additions & 2 deletions tests/rag-answer-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,10 +828,19 @@ describe("RAG structured-output fallback", () => {
expect(answer.routingReason).toContain("strong_quality_retry");
expect(answer.openAIRequestIds).toEqual(["req_fast_template", "req_strong_template", "req_strong_quality"]);
expect(insert).toHaveBeenCalledTimes(1);
const insertCalls = insert.mock.calls as unknown as Array<[{ metadata?: Record<string, unknown> }]>;
const loggedMetadata = insertCalls[0]?.[0]?.metadata ?? {};
const insertCalls = insert.mock.calls as unknown as Array<
[{ answer?: unknown; metadata?: Record<string, unknown> }]
>;
const loggedRow = insertCalls[0]?.[0] ?? {};
const loggedMetadata = loggedRow.metadata ?? {};
expect(loggedMetadata.answer_retry_count).toBe(2);
expect(loggedMetadata.answer_retry_reasons).toEqual(["fast_template_retry_strong", "strong_quality_retry"]);
// PIA-3: the generated answer text must not be persisted to rag_queries.answer
// unless RAG_PERSIST_ANSWER_TEXT is enabled (default off), and the row records
// that the answer was not retained.
expect(loggedRow.answer).toBeNull();
expect(loggedMetadata.answer_retained).toBe(false);
expect(JSON.stringify(loggedRow)).not.toContain("review intervals");
});

it("filters table-caption metadata from extractive answer points", async () => {
Expand Down