Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fbb7bb0
Clozapine overview: say treatment requires registration and monitoring
claude Oct 15, 2026
4751f96
Specifiers: a product name is not a clinician sign-off
claude Oct 15, 2026
b58bbbc
fix(rag): refuse consumer questions without waiting on the classifier
claude Oct 15, 2026
8a0ae69
fix: Withhold unsigned specifier definitions
qodo-code-review[bot] Oct 15, 2026
3e09d46
fix: 2 findings — Align specifier sign-off eligibility; Reconcile pendin
qodo-code-review[bot] Oct 15, 2026
83a7440
fix: Preserve the Clozapine overview
qodo-code-review[bot] Oct 15, 2026
10f5952
Keep the classifier for consumer queries that carry a clinical word
claude Oct 15, 2026
07f658d
Merge remote-tracking branch 'origin/claude/clinical-trust-fixes-tjkl…
claude Oct 15, 2026
2ba37a6
Repair the review contract and make specifier re-signing work
claude Oct 15, 2026
c0ee47c
Merge remote-tracking branch 'origin/main' into claude/clinical-trust…
claude Oct 15, 2026
d099d80
Record the specifier relabel decision and refresh the manifest baseline
claude Oct 15, 2026
f83c416
fix: Repair clinical-record-review-contract syntax and unnamed specif…
cubic-dev-ai[bot] Oct 15, 2026
f9bf148
fix: Refresh static site-content manifest baseline for unsigned speci…
cubic-dev-ai[bot] Oct 15, 2026
2d164cf
Merge remote-tracking branch 'origin/claude/clinical-trust-fixes-tjkl…
claude Oct 15, 2026
f67d43e
Drop an unused import left by the merge
claude Oct 15, 2026
3922c16
Make the review tool judge reviewer names exactly as the site does
claude Oct 15, 2026
9acf619
Refresh the repo-awareness snapshot for the new decision entry
claude Oct 15, 2026
59f086f
Skip the classifier only when every word is plainly non-clinical
claude Oct 15, 2026
0c814d3
fix: Refresh repo-awareness snapshot for the specifier decision record
cubic-dev-ai[bot] Oct 15, 2026
7057a28
Merge remote-tracking branch 'origin/claude/clinical-trust-fixes-tjkl…
claude Oct 15, 2026
46cefa0
Merge branch 'main' into claude/clinical-trust-fixes-tjkl77
BigSimmo Oct 15, 2026
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
2 changes: 1 addition & 1 deletion data/medications-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -79085,7 +79085,7 @@
"rows": [
{
"key": "Overview",
"val": "Antipsychotic for treatment-resistant schizophrenia. Serious, potentially fatal adverse effects require registration and blood monitoring through the clozapine monitoring service.",
"val": "Antipsychotic for treatment-resistant schizophrenia. Treatment requires registration and blood monitoring through the clozapine monitoring service because of serious, potentially fatal adverse effects.",
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
"tags": []
},
{
Expand Down
5 changes: 5 additions & 0 deletions data/repo-awareness-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -4945,6 +4945,11 @@
"section": "superpowers",
"catalogued": true
},
{
"path": "docs/decisions/2026-10-15-specifiers-need-a-named-sign-off.md",
"section": "decisions",
"catalogued": true
},
{
"path": "docs/api-jobs-ops-surface.md",
"section": "root",
Expand Down
2 changes: 1 addition & 1 deletion data/specifiers-content.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"specifierItems": 585,
"universalSpecifiers": 18,
"itemsWithDefinitions": 71,
"itemsPendingClinicianReview": 514
"itemsPendingClinicianReview": 585
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
"authoritativeSources": [
{
Expand Down
10 changes: 10 additions & 0 deletions docs/decisions/2026-10-15-specifiers-need-a-named-sign-off.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Decision: DSM specifiers count as signed off only under a named clinician

- **Status:** decided 2026-10-15
- **Source:** the owner, in chat

The 89 DSM specifier records stamped with the product name PsychSift (71 items and 18 universal
specifiers) show as not signed off, and their definitions stay withheld, until the owner re-signs each one in
his own full name with the sign-off tool. A product name is not accepted as a clinician sign-off.

If this summary and the source ever differ, the source wins.
4 changes: 4 additions & 0 deletions docs/evidence/pr-3143-owner-signoff-confirmation.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# PR #3143: clinical owner's sign-off confirmation

> **Superseded for DSM specifiers on 15 October 2026.** The owner chose to show the specifiers
> stamped `PsychSift` as not signed off until he re-signs each one in his own name. See
> [`../decisions/2026-10-15-specifiers-need-a-named-sign-off.md`](../decisions/2026-10-15-specifiers-need-a-named-sign-off.md).

On 2 October 2026, Josh, PsychSift's clinical owner, confirmed in the PR-completion
request: "I have signed off on all of these so make that clear."

Expand Down
28 changes: 26 additions & 2 deletions scripts/import-medications-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,33 @@ function outputPath() {
return path.resolve(process.cwd(), "data/medications-snapshot.json");
}

const CLOZAPINE_OVERVIEW =
"Antipsychotic for treatment-resistant schizophrenia. Treatment requires registration and blood monitoring through the clozapine monitoring service because of serious, potentially fatal adverse effects.";

function normalizeRecord(record: MedicationExportRecord): MedicationExportRecord {
const slug = String(record.slug).trim().toLowerCase();
const sections = Array.isArray(record.sections) ? record.sections : [];
// Keep the clinically signed-off wording while the canonical Medications
// snapshot catches up; otherwise every refresh silently reintroduces the old overview.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const normalizedSections =
slug === "clozapine"
? sections.map((section) => {
if (!section || typeof section !== "object" || Array.isArray(section)) return section;
const entry = section as { rows?: unknown[]; [key: string]: unknown };
return {
...entry,
rows: Array.isArray(entry.rows)
? entry.rows.map((row) =>
row && typeof row === "object" && !Array.isArray(row) && (row as { key?: unknown }).key === "Overview"
? { ...row, val: CLOZAPINE_OVERVIEW }
: row,
)
: entry.rows,
};
})
: sections;
return {
slug: String(record.slug).trim().toLowerCase(),
slug,
name: String(record.name).trim(),
class: String(record.class ?? "").trim(),
subclass: String(record.subclass ?? "").trim(),
Expand All @@ -39,7 +63,7 @@ function normalizeRecord(record: MedicationExportRecord): MedicationExportRecord
tag: String(record.tag ?? "").trim(),
schedule: String(record.schedule ?? "").trim(),
stats: Array.isArray(record.stats) ? record.stats : [],
sections: Array.isArray(record.sections) ? record.sections : [],
sections: normalizedSections,
quick: Array.isArray(record.quick) ? record.quick : [],
};
}
Expand Down
91 changes: 86 additions & 5 deletions scripts/lib/clinical-record-review-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,68 @@ function isPlainRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

// Keep the review tool aligned with the site's specifier gate: this mirrors isNamedPerson and its
// NOT_A_PERSON list in src/lib/admin/rule-sign-off.ts (a parity test pins the two together).
// Product or role attributions (for example, "PsychSift" or "Clinical Team") are not sign-offs.
const NOT_A_PERSON = new Set([
"psychsift",
"system",
"admin",
"administrator",
"owner",
"the owner",
"claude",
"codex",
"agent",
"ai",
"bot",
"automation",
"locally reviewed",
"reviewed",
"clinician",
"psychiatrist",
"consultant",
"test",
"unknown",
"lead",
"director",
"registrar",
"manager",
"call",
"governance",
"committee",
"team",
"service",
"department",
"signed",
"unsigned",
"tbd",
"tba",
"pending",
"placeholder",
"example clinician",
]);

export function isNamedSpecifierReviewer(value) {
if (typeof value !== "string") return false;
const trimmed = value.trim().replace(/\s+/g, " ");
if (NOT_A_PERSON.has(trimmed.toLowerCase())) return false;
const words = trimmed.split(" ").filter((word) => /\p{L}/u.test(word));
const titles = new Set(["dr", "dr.", "prof", "prof.", "professor", "a/prof", "mr", "ms", "mrs", "mx"]);
const nameWords = words.filter((word) => !titles.has(word.toLowerCase()));
return nameWords.length >= 2 && !nameWords.some((word) => NOT_A_PERSON.has(word.toLowerCase()));
}

function isSpecifierClinicianReviewed(review) {
return (
review?.clinicianReviewStatus === "clinician-reviewed" &&
isNamedSpecifierReviewer(review?.reviewedBy) &&
typeof review?.reviewedAt === "string" &&
UTC_ISO_TIMESTAMP.test(review.reviewedAt) &&
Number.isFinite(Date.parse(review.reviewedAt))
);
}

function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (!isPlainRecord(value)) return value;
Expand Down Expand Up @@ -488,9 +550,14 @@ function specifierRecords(document) {
function specifierView(native, placement) {
const review = isPlainRecord(native.review) ? native.review : {};
const signable = placement.kind === "universal" || native.definitionStatus === "defined";
const unnamedStamp = review.clinicianReviewStatus === "clinician-reviewed" && !isSpecifierClinicianReviewed(review);
const status =
review.clinicianReviewStatus === "clinician-reviewed"
? "reviewed"
? unnamedStamp
? signable
? "drafted"
: "pending"
: "reviewed"
: review.clinicianReviewStatus === "clinician-review-pending"
? signable
? "drafted"
Expand All @@ -507,9 +574,15 @@ function specifierView(native, placement) {
sourceVerificationStatus: review.sourceVerificationStatus ?? null,
sourceFamily: review.sourceFamily ?? null,
status,
reviewedBy: review.reviewedBy ?? null,
reviewedAt: review.reviewedAt ?? null,
reviewedContentSha256: review.reviewedContentSha256 ?? null,
// A product-attributed stamp ("PsychSift") is not a signature, so the view carries none and
// the record is offered for a named clinician to sign.
...(status === "reviewed" || review.clinicianReviewStatus !== "clinician-reviewed"
? {
reviewedBy: review.reviewedBy ?? null,
reviewedAt: review.reviewedAt ?? null,
reviewedContentSha256: review.reviewedContentSha256 ?? null,
}
: { reviewedBy: null, reviewedAt: null, reviewedContentSha256: null }),
};
}

Expand Down Expand Up @@ -542,7 +615,7 @@ function writeSpecifierReview(document, view) {
const items = categories.flatMap((category) =>
category.disorders.flatMap((disorder) => disorder.groups.flatMap((group) => group.items)),
);
const pending = items.filter((item) => item?.review?.clinicianReviewStatus !== "clinician-reviewed").length;
const pending = items.filter((item) => !isSpecifierClinicianReviewed(item?.review)).length;
const stats = isPlainRecord(document.stats)
? { ...document.stats, itemsPendingClinicianReview: pending }
: document.stats;
Expand Down Expand Up @@ -895,6 +968,9 @@ export function signOffQueue(kind, records, context = {}) {
const waiting = records.filter(
(record) =>
(record?.status === "drafted" ||
(resolved.kind === "specifier" &&
record?.status === "reviewed" &&
!isNamedSpecifierReviewer(record?.reviewedBy)) ||
(record?.status === "reviewed" && recordPinState(record, resolved, context) === "stale")) &&
!indigenousContentIn(record, resolved, context),
);
Expand Down Expand Up @@ -924,6 +1000,11 @@ export function finalizeClinicalReview(record, kind, { reviewedBy, reviewedAt, c
if (!isPlainRecord(record)) throw new TypeError("A clinical record must be an object.");
const attributionProblem = reviewerAttributionProblem(reviewedBy);
if (attributionProblem) throw new Error(attributionProblem);
if (resolved.kind === "specifier" && !isNamedSpecifierReviewer(reviewedBy)) {
throw new Error(
"A specifier sign-off needs the clinician's full name (a first name and surname), because the site treats anything else as not signed off.",
);
}
const timestampProblem = utcTimestampProblem(reviewedAt, now);
if (timestampProblem) throw new Error(timestampProblem);
const eligibility = signOffEligibilityProblem(record, resolved, context);
Expand Down
22 changes: 14 additions & 8 deletions src/lib/clinical-ask/catalogue-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,20 +188,26 @@ function specifierEvidence(request: ClinicalAskRequest) {
.map((item, index) => ({ item, rank: rankedLabels.get(item.label) ?? rankedLabels.size + index }))
.sort((left, right) => left.rank - right.rank)
.slice(0, RESULT_LIMIT);
return candidates.map(({ item }) =>
evidence(request, item.slug, {
return candidates.map(({ item }) => {
const clinicianReviewed = isSpecifierClinicianReviewed(item.review);
return evidence(request, item.slug, {
title: item.label,
// Same defect as the DSM label above: a fallback must not assert an
// authority the item does not carry. The review state below is derived.
publisher: item.definition?.sourceFamily || "Specifier catalogue",
href: `/specifiers/${item.slug}`,
extract: text([item.disorderName, item.definition?.meaning, item.definition?.clinicalNote, item.icd11Context]),
// Keep the structural context searchable, but never send unsigned
// definition wording to synthesis.
extract: text([
item.disorderName,
clinicianReviewed ? item.definition?.meaning : undefined,
clinicianReviewed ? item.definition?.clinicalNote : undefined,
item.icd11Context,
]),
reviewState:
item.review.sourceVerificationStatus.includes("needs") || !isSpecifierClinicianReviewed(item.review)
? "needs_review"
: "reviewed",
}),
);
item.review.sourceVerificationStatus.includes("needs") || !clinicianReviewed ? "needs_review" : "reviewed",
});
});
}

function therapyEvidence(request: ClinicalAskRequest) {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/developer-area/sign-off-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ function specifiersFamily(): SignOffFamily {
name: "Specifiers",
source: "data/specifiers-content.json",
nativeField: "review.clinicianReviewStatus",
note: `The export's own stats record ${content.stats.itemsPendingClinicianReview} specifier items pending clinician review, and a further ${universals.length} of the ${content.universalSpecifiers.length} universal specifiers carry the same pending status outside the per-disorder catalogue. Both are listed here; the universals are the rows with no link, because no route renders one. Specifiers is an aide-memoire reference surface, not automated clinical decision support.`,
note: `The queue lists ${items.length} per-disorder specifier items pending clinician review, and a further ${universals.length} of the ${content.universalSpecifiers.length} universal specifiers carry the same pending status outside the per-disorder catalogue. Both are listed here; the universals are the rows with no link, because no route renders one. Specifiers is an aide-memoire reference surface, not automated clinical decision support.`,
unrouted: universals.length > 0,
rows: [...items, ...universals],
};
Expand Down
6 changes: 5 additions & 1 deletion src/lib/rag/rag-classifier-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ragQueryClassifierPromptVersion } from "@/lib/rag/rag-versioning";
import { hasAdversarialManipulationIntent } from "@/lib/rag/rag-routing";
import {
clearlyOutsideCorpusMedicalPattern,
isDeterministicNonClinicalConsumerRefusal,
isUnsupportedSoftTailAnalysis,
unavailableDocumentNoisePattern,
} from "@/lib/rag/rag-query-guard";
Expand Down Expand Up @@ -185,7 +186,10 @@ export async function analyzeQueryWithClassifierFallback(
// an injection surface.)
hasAdversarialManipulationIntent(query) ||
unavailableDocumentNoisePattern.test(query) ||
(clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0)
(clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) ||
// A plainly non-clinical consumer question ("best coffee machine") with no clinical word the
// model could rescue it on: the classifier would only add latency before the same refusal.
isDeterministicNonClinicalConsumerRefusal(query, analysis)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
) {
return { ...analysis, needsClassifierFallback: false } satisfies ClinicalQueryAnalysis;
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
}
Expand Down
31 changes: 31 additions & 0 deletions src/lib/rag/rag-query-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,37 @@ function isNonClinicalConsumerQuery(query: string, analysis: ClinicalQueryAnalys
return true;
}

/**
* Every word a query may contain and still skip the classifier. The list is deliberately closed:
* a list of clinical words to look out for can never be complete ("holiday leave for akathisia"),
* so any word not listed here, clinical or not, keeps the classifier in the loop and the bypass
* fails safe. It covers everyday shopping and household questions only.
*/
const plainlyNonClinicalWords = new Set(
(
"a an the and or of for to in on at with my me i we our your you it is are be can should would could " +
"what which who where when how why do does did please give tell show find recommend recommendation " +
"best good cheap cheapest top new buy buying purchase price prices cost review reviews compare " +
"small large big home house kitchen coffee machine espresso air fryer recipe recipes oats overnight " +
"high protein breakfast dinner lunch meal cook cooking bake baking cake hotel restaurant holiday " +
"flight flights booking airline car finance mortgage insurance laptop phone sale television tv show " +
"washing vacuum cleaner"
).split(" "),
);

/**
* True when the deterministic analysis already refuses the query as a non-clinical consumer
* question (coffee machine, recipe, air fryer) and every word in it is plainly non-clinical, so the
* classifier has nothing it could rescue the query on. `analyzeQueryWithClassifierFallback` uses it
* to skip the LLM classifier: asking the model cost these refusals 1.5 to 6.4 s of the 4 s
* unsupported-route budget (eval canary run 38419027058).
*/
export function isDeterministicNonClinicalConsumerRefusal(query: string, analysis: ClinicalQueryAnalysis) {
const words = query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
if (words.length === 0 || !words.every((word) => plainlyNonClinicalWords.has(word))) return false;
return unsupportedSoftTailEligible(analysis) && isNonClinicalConsumerQuery(query, analysis);
}

export function shouldShortCircuitUnsupportedSearch(query: string, analysis: ClinicalQueryAnalysis) {
if (unavailableDocumentNoisePattern.test(query)) return true;
if (clearlyOutsideCorpusMedicalPattern.test(normalizeGuardQuery(query)) && analysis.documentTitleTerms.length === 0)
Expand Down
13 changes: 8 additions & 5 deletions src/lib/specifiers-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import specifiersContent from "../../data/specifiers-content.json";

import { isNamedPerson } from "@/lib/admin/rule-sign-off";
import { findSpecifier, specifierRecords, type SpecifierRecord } from "@/lib/specifiers";
import {
SPECIFIERS_IS_AUTOMATED_DECISION_SUPPORT,
Expand Down Expand Up @@ -70,15 +71,17 @@ function parsedReviewDate(value: string | null | undefined): Date | null {
type SpecifierReviewFields = Pick<SpecifierReview, "clinicianReviewStatus" | "reviewedBy" | "reviewedAt">;

/**
* True only for a complete clinician sign-off: the reviewed status, a non-empty reviewer
* name and a parseable ISO timestamp. Anything less fails closed to pending, so a partial or
* hand-edited attestation can never make an entry read as reviewed.
* True only for a complete clinician sign-off: the reviewed status, a named person as reviewer
* (`isNamedPerson`, the same test the site-wide sign-off line uses) and a parseable ISO
* timestamp. Anything less fails closed to pending, so a partial or hand-edited attestation can
* never make an entry read as reviewed. A product attribution such as "PsychSift" is not a
* clinician: the 71 entries the 2026-09-26 reconcile stamped that way read as pending until a
* named clinician signs them.
*/
export function isSpecifierClinicianReviewed(review: SpecifierReviewFields): boolean {
return (
review.clinicianReviewStatus === SPECIFIER_CLINICIAN_REVIEWED_STATUS &&
typeof review.reviewedBy === "string" &&
review.reviewedBy.trim().length > 0 &&
isNamedPerson(review.reviewedBy) &&
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
parsedReviewDate(review.reviewedAt) !== null
);
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
Expand Down
18 changes: 18 additions & 0 deletions tests/clinical-ask-catalogue-evidence.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import { retrieveCatalogueEvidence } from "@/lib/clinical-ask/catalogue-evidence";
import { isSpecifierClinicianReviewed, specifierCatalogItems } from "@/lib/specifiers-content";
import { clinicalAskCases } from "./fixtures/clinical-ask-cases";

describe("retrieveCatalogueEvidence", () => {
Expand Down Expand Up @@ -51,6 +52,23 @@ describe("retrieveCatalogueEvidence", () => {
);
});

it("never sends an unsigned specifier definition to synthesis", async () => {
const unsigned = specifierCatalogItems().find(
(item) => item.definition?.meaning && !isSpecifierClinicianReviewed(item.review),
);
expect(unsigned).toBeDefined();
const request = clinicalAskCases.find(({ mode }) => mode === "specifiers")!;
const evidence = await retrieveCatalogueEvidence(
{ ...request, question: unsigned!.label },
new AbortController().signal,
);

const match = evidence.find((item) => item.href === `/specifiers/${unsigned!.slug}`);
expect(match).toBeDefined();
expect(match!.reviewState).toBe("needs_review");
expect(match!.extract).not.toContain(unsigned!.definition!.meaning);
});

it("fails with AbortError before catalogue work", async () => {
const controller = new AbortController();
controller.abort();
Expand Down
Loading
Loading