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
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -678,5 +678,6 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD
| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (parity commit review) | 1aebf02 (fix landed a3b9a54) | rag-retrieval-reviewer on the monitoring evidence-gate parity commit: REQUEST-CHANGES (soft) — P2 reproduced: inflected monitoring kind tokens (monitor\w*/annual(?:ly)?/blood tests?/ecgs?/lfts?) steal sole-dose-value sentences from the dose arm; dose-intent answers then reject the monitoring-kind fact ("Quetiapine is monitored at a dose of 200 mg daily" flipped grounded true→false, source-gap — fails CLOSED, never a wrong dose). P3: monitoring figure escape lacked the dose escape's multi-drug bare-row guard. Clean: over-admission bounded (broad vocab lives in gate/filter only, promotion still corpus-guarded, claim-support unchanged); regex cost negligible; mismatched-unit test relaxation legitimate (synopsis is corpus-verbatim; weeks pin enforced by atom identity + adjacent_context exclusion from both gate and claim corpora). | BOTH FINDINGS FIXED in a3b9a54: kind arm classifies legacy tokens byte-identically and new-inflection-only sentences fall through to the dose arm when they carry a clinicalDoseValuePattern value (both repro sentences pinned as dose-intent tests); multi-drug bare-row guard extended to monitoring_schedule with a discriminating test — red-proven both directions. Reviewer checks: formatting 38/38, focused 644/644; targeting eval deferred to the wave's live canary pair. |
| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (parity commit review) | 1aebf02 (fix landed a3b9a54) | clinical-governance-reviewer on the same commit: APPROVE-WITH-NITS. P2 (independently converged with the retrieval reviewer's P3): monitoring figure-escape lacked the dose-path multi-drug cross-entity guard — a bare wrong-drug schedule/level row in a multi-drug chunk could be entity-prefixed for a named-drug monitoring query; downstream gates verify text-vs-source presence, never attribution (worked lithium/valproate LFT path traced through finalize). FIXED in a3b9a54 exactly as its smallest-fix prescribed (guard at the :872-881 site now fires for monitoring_schedule; negative multi-drug test added, red-proven). Clean: unsupported figures impossible (admission-only change; promotion corpus guard + numeric verification + claim support all byte-unchanged); conservative failure intact (figure-bearing-only escape, schedule-free refusal pinned); adjacent-context safety held (sourceEvidenceText excludes adjacent_context; weeks refusal confirmed by probe); no PHI/provider/ranking surface. P3s: RAG impact line (present in the PR body — behaviour-change form, correct since the PR also carries the Option A retrieval change); multi-drug negative test (landed in a3b9a54). | Offline guard-chain trace + targeted vitest probes (named-drug guard, bare-figure admission, conservative gap, adjacent refusal). Provider/release gates deferred per confirmation boundary. |
| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A rescue review) | 0abf3c9 | clinical-governance-reviewer on the S3 escalation rescue: APPROVE-WITH-NITS, no P0/P1. P2 = the mandated live canary pair itself (process gate, declared in the PR body; offline-green + review-approved proven insufficient for this surface 2026-07-20). P3s: multi-drug escalation-query recall edge (titled drug + untitled drug — fast-path return can skip the vector leg; recall limitation, not misattribution, mirrors the pre-existing allowlisted-class tradeoff); reviewer probe files must stay uncommitted (relocated to scratchpad). All six clinical concerns verified safe: wrong-document impossible (alias phrases must appear in the query; per-document alias groups, no cross-drug conflation), conservative availability (S3 purely additive via keyed-union merge; sibling retention test-pinned), live expansion acceptable (title-named correct-entity SOP in every firing shape), fail-closed double layer (adversarial short-circuit precedes the predicate; injection-forge case intent=protocol cannot fire — executed), governance metadata unbypassed (same attachDocumentRankingMetadata + status=indexed + access-scope filters), no PHI/provider/schema surface. | Reviewer checks: escalation-rescue suite 8/8, injection-forge intent derivation executed, static trace of the full S3 chain. Live canary pair = the trust gate, dispatched post-merge. |
| 2026-07-21 | claude/patient-profile-input-bounds-123366 (PR #1045: FV-03 fail-safe input bounds) | 75303e8b8 | Clinical-governance verification of FV-03 (patient-profile numeric fields → medication-safety alert engine). 4-agent adversarial workflow (consumer map + suppression audit + physiological bounds + synthesis). | ADJUST→implemented. Consumer map: evaluatePatientAlerts is the ONLY numeric consumer, no dose arithmetic, sanitize() is the sole guaranteed chokepoint. Suppression audit found naive null-routing UNSAFE via the bare-renal both-null hole (medication-patient-alerts.ts:286) — nulling one out-of-range renal input while the other is present-normal → false all-clear; fixed with &&→|| (0 bare-renal contraindication rows in corpus → no-op on current data). Bounds VALIDATED (age 0-130, egfr 0-250, crcl 0-400, qtc 240-800, scr µmol/L 15-3000 unit-aware): never reject a legitimate clinical extreme. Reject-to-null (never clamp). | typecheck, lint, format:check, full unit+jsdom 349 files/3120 passed/0 failed (incl. 38 new/updated FV-03 tests), design-system-contract (baselines unchanged), type-scale, icon-scale, check:production-readiness READY. verify:ui in CI. No provider calls. |
| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A wave verdict — no code change; #1040 merged as cde6c5c) | canary run 29827012719 (#61, main cde6c5c) vs banked #60 (29800029819) | OPTION A WAVE ADOPTED — FIRST FULLY-GREEN 44-CASE CANARY IN PROGRAM HISTORY (Blocking failures: None). (1) Option A payoff EXCEEDED: citation_failure_rate 0.0227→0; the neuroleptic-side-effect-escalation case flipped from wrong-doc→failed-generation→1-citation-fallback to **strong route, successful gpt-5.6-sol generation, passed in 15.4s with no fallback marker** — the rescued S3 retrieval fixed generation itself, not just the citation count; expected_source_hit 0.6364→0.6591. (2) Golden held exactly as the blast-radius analysis promised: 36/36 PASS, content_recall 1.0, mrr@10 0.8921 BYTE-IDENTICAL to the pre-wave baseline (zero ordering movement — no golden case fires the predicate), irrelevant@10 0.1083→0.0917 (slightly better). (3) Parity payoff PARTIAL: monitoring targeting 1/5→2/5 (olanzapine-lai flipped — previously called a retrieval-depth residual; quetiapine-dose also flipped on the dose side); lithium-range (232ch) + metabolic (73ch, byte-identical answer to #60) did NOT flip despite offline-proven fixes — their live chunk sets evidently contain no admissible schedule sentence even under the widened gate → reclassified as retrieval-depth/live-content residuals joining adhd; below the ≥3/5 target but strictly improved, no regression anywhere. Dose 2/5 vs 2/4: same passing count, applicable set grew (new quality-metformin-renal-dosing miss = eval-set churn, not regression). (4) No-worse EXCEEDED: relevance 0.5333→0.6 (the two-step watch-item slide FULLY REVERSED to the #58 level), targeting_rate 0.6667→0.6957, fail_closed 0.9 held, readability/artifact_leaks 1.0, route ceilings 0, grounded 1.0, unsupported_correct 1.0, numeric 0, p95 22.8s, red_result 3/3. Adoption per the measured-gain rule: primary goal achieved, three case flips, relevance recovered, zero regressions. Residual queue: monitoring retrieval-depth trio (lithium-range/metabolic/adhd), E-3d H2 discards, weekly ANSWER_CASE_LIMIT 8→44 raise now unblocked (gate would be green), comparison-class coverage. Wave spend +~$2-4 → Phase E + Option A total ~$12-20 of ≤$20. | Evidence: run #61 job log read (Threshold Status: None; Answer Metrics; neuroleptic diagnostics row; targeting metric_rates + 6-miss list; golden 36 PASS lines + summary). Revert drill NOT triggered. |
| 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: I9 weekly coverage raise) | see PR head | ADDENDUM 5 post-green item I9 (plan-authorized "after reds fixed"): weekly scheduled canary ANSWER_CASE_LIMIT default 8→44 — the Sunday 18:00 UTC cron now guards the FULL answer-quality case set instead of the first 8 (both #57 blocking reds historically lived OUTSIDE the first 8, leaving the weekly gate blind to them). Unblocked by run #61 proving the citation gate green on the full 44. Cost: est +$1-2/week (user-authorized in the plan). Contract test pin updated in lockstep (eval-canary-workflow.test.ts). Dispatch shapes unchanged (input override still wins); operational-risk diff, plain-revert rollback. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS (20/20); eval-canary-workflow contract 4/4; prettier clean; no provider calls |
19 changes: 16 additions & 3 deletions src/components/clinical-dashboard/patient-profile-context.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,8 +2,9 @@

import { createContext, useCallback, useContext, useMemo, useSyncExternalStore } from "react";

import { isProfileEmpty, type AllergyClass, type PatientProfile } from "@/lib/medication-patient-alerts";
import { isProfileEmpty, type AllergyClass, type PatientProfile, type ScrUnit } from "@/lib/medication-patient-alerts";
import {
convertScrValue,
EMPTY_PATIENT_PROFILE,
getPatientProfileSnapshot,
getServerPatientProfileSnapshot,
Expand All@@ -14,6 +15,7 @@ import {
export type PatientProfileContextValue = {
profile: PatientProfile;
updateField: <K extends keyof PatientProfile>(key: K, value: PatientProfile[K]) => void;
setScrUnit: (unit: ScrUnit) => void;
toggleAllergy: (allergy: AllergyClass) => void;
clear: () => void;
isEmpty: boolean;
Expand All@@ -34,6 +36,17 @@ export function PatientProfileProvider({ children }: { children: React.ReactNode
writePatientProfile({ ...getPatientProfileSnapshot(), [key]: value });
}, []);

// Switching the creatinine unit must convert the stored value, not just relabel
// it — otherwise the alert engine re-reads the same number on the new scale. The
// convert + unit change is a single write so the sanitiser never sees the
// mismatched intermediate (which a two-step update would drop to null).
const setScrUnit = useCallback((unit: ScrUnit) => {
const current = getPatientProfileSnapshot();
const from = current.scrUnit ?? "umol/L";
if (from === unit) return;
writePatientProfile({ ...current, scr: convertScrValue(current.scr, from, unit), scrUnit: unit });
}, []);

const toggleAllergy = useCallback((allergy: AllergyClass) => {
const current = getPatientProfileSnapshot();
const allergies = current.allergies ?? [];
Expand All@@ -46,8 +59,8 @@ export function PatientProfileProvider({ children }: { children: React.ReactNode
}, []);

const value = useMemo<PatientProfileContextValue>(
() => ({ profile, updateField, toggleAllergy, clear, isEmpty: isProfileEmpty(profile) }),
[profile, updateField, toggleAllergy, clear],
() => ({ profile, updateField, setScrUnit, toggleAllergy, clear, isEmpty: isProfileEmpty(profile) }),
[profile, updateField, setScrUnit, toggleAllergy, clear],
);

return <PatientProfileContext.Provider value={value}>{children}</PatientProfileContext.Provider>;
Expand Down
89 changes: 83 additions & 6 deletions src/components/clinical-dashboard/patient-profile-panel.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,9 @@ import { useId, useState } from "react";

import { usePatientProfile } from "@/components/clinical-dashboard/patient-profile-context";
import { cn, fieldControlPlain, fieldLabel, ToggleSwitch } from "@/components/ui-primitives";
import { SCR_UMOL_PER_MGDL } from "@/lib/medication-patient-alerts";
import type { AllergyClass, HepaticSeverity, ScrUnit } from "@/lib/medication-patient-alerts";
import { PATIENT_PROFILE_NUMERIC_BOUNDS, PATIENT_PROFILE_SCR_UMOL_BOUNDS } from "@/lib/patient-profile-storage";

const HEPATIC_OPTIONS: { value: HepaticSeverity; label: string }[] = [
{ value: "none", label: "None" },
Expand DownExpand Up@@ -48,14 +50,34 @@ function NumberField({
value,
onChange,
testId,
min,
max,
}: {
label: string;
unit?: string;
value: number | null | undefined;
onChange: (value: number | null) => void;
testId?: string;
min: number;
max: number;
}) {
const id = useId();
const errorId = `${id}-error`;
const [text, setText] = useState(value == null ? "" : String(value));
const [syncedValue, setSyncedValue] = useState<number | null>(value ?? null);

// React-sanctioned "adjust state during render" reconciliation: when the stored
// value changes from outside this field (e.g. a cross-page store update), re-sync
// the buffer — but keep an in-progress out-of-range entry so its validation
// message stays visible. A profile Clear remounts the field via `key` instead
// (the stored value is already null there, so no prop change would fire here).
const parsed = parseNumber(text);
const outOfRange = parsed !== null && (parsed < min || parsed > max);
if ((value ?? null) !== syncedValue) {
setSyncedValue(value ?? null);
if (!outOfRange) setText(value == null ? "" : String(value));
}

return (
<div>
<label htmlFor={id} className={fieldLabel}>
Expand All@@ -66,11 +88,33 @@ function NumberField({
id={id}
type="number"
inputMode="decimal"
value={value ?? ""}
onChange={(event) => onChange(parseNumber(event.target.value))}
className={cn(fieldControlPlain, "nums")}
min={min}
max={max}
value={text}
onChange={(event) => {
const raw = event.target.value;
setText(raw);
// Commit only in-range numbers; an empty or out-of-range entry commits
// null so the alert engine treats it as a missing input (surfaced as
// "unassessed") rather than acting on a physiologically impossible value.
const next = parseNumber(raw);
onChange(next !== null && next >= min && next <= max ? next : null);
}}
aria-invalid={outOfRange || undefined}
aria-describedby={outOfRange ? errorId : undefined}
className={cn(fieldControlPlain, "nums", outOfRange && "border-[color:var(--danger-border)]")}
data-testid={testId}
/>
{outOfRange ? (
<span
id={errorId}
role="alert"
className="mt-1 block text-2xs font-medium leading-4 text-[color:var(--danger)]"
>
Enter {min}–{max}
{unit ? ` ${unit}` : ""}.
</span>
) : null}
</div>
);
}
Expand All@@ -85,10 +129,25 @@ export function PatientProfilePanel({
defaultOpen?: boolean;
className?: string;
}) {
const { profile, updateField, toggleAllergy, clear, isEmpty } = usePatientProfile();
const { profile, updateField, setScrUnit, toggleAllergy, clear, isEmpty } = usePatientProfile();
const [open, setOpen] = useState(defaultOpen ?? variant === "full");
// Bumped on Clear to remount the numeric fields, so an out-of-range entry that
// is showing a validation message (stored value already null) is reset too.
const [resetNonce, setResetNonce] = useState(0);
const allergies = new Set(profile.allergies ?? []);

// Serum-creatinine validity bounds are canonical in µmol/L; convert to the
// active display unit (rounding inward so the field and the storage-layer
// check agree on the edge). Same conversion factor the alert engine uses.
const scrUnit = profile.scrUnit ?? "umol/L";
const scrBounds =
scrUnit === "mg/dL"
? {
min: Math.ceil((PATIENT_PROFILE_SCR_UMOL_BOUNDS.min / SCR_UMOL_PER_MGDL) * 100) / 100,
max: Math.floor((PATIENT_PROFILE_SCR_UMOL_BOUNDS.max / SCR_UMOL_PER_MGDL) * 100) / 100,
}
: PATIENT_PROFILE_SCR_UMOL_BOUNDS;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<details
open={open}
Expand DownExpand Up@@ -119,39 +178,54 @@ export function PatientProfilePanel({
<div className="space-y-3 border-t border-[color:var(--border)] p-3">
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
<NumberField
key={`age-${resetNonce}`}
label="Age"
unit="years"
value={profile.ageYears}
onChange={(value) => updateField("ageYears", value)}
testId="patient-age"
min={PATIENT_PROFILE_NUMERIC_BOUNDS.ageYears.min}
max={PATIENT_PROFILE_NUMERIC_BOUNDS.ageYears.max}
/>
<NumberField
key={`egfr-${resetNonce}`}
label="eGFR"
unit="mL/min"
value={profile.egfr}
onChange={(value) => updateField("egfr", value)}
testId="patient-egfr"
min={PATIENT_PROFILE_NUMERIC_BOUNDS.egfr.min}
max={PATIENT_PROFILE_NUMERIC_BOUNDS.egfr.max}
/>
<NumberField
key={`crcl-${resetNonce}`}
label="CrCl"
unit="mL/min"
value={profile.crcl}
onChange={(value) => updateField("crcl", value)}
testId="patient-crcl"
min={PATIENT_PROFILE_NUMERIC_BOUNDS.crcl.min}
max={PATIENT_PROFILE_NUMERIC_BOUNDS.crcl.max}
/>
<NumberField
key={`qtc-${resetNonce}`}
label="QTc"
unit="ms"
value={profile.qtc}
onChange={(value) => updateField("qtc", value)}
testId="patient-qtc"
min={PATIENT_PROFILE_NUMERIC_BOUNDS.qtc.min}
max={PATIENT_PROFILE_NUMERIC_BOUNDS.qtc.max}
/>
<div className="col-span-2 sm:col-span-1">
<NumberField
key={`scr-${resetNonce}-${scrUnit}`}
label="Serum creatinine"
value={profile.scr}
onChange={(value) => updateField("scr", value)}
testId="patient-scr"
min={scrBounds.min}
max={scrBounds.max}
/>
</div>
<fieldset className="col-span-2 min-w-0 sm:col-span-1">
Expand All@@ -164,7 +238,7 @@ export function PatientProfilePanel({
key={option.value}
type="button"
aria-pressed={active}
onClick={() => updateField("scrUnit", option.value)}
onClick={() => setScrUnit(option.value)}
className={cn(segmentBase, "flex-1", active ? segmentActive : segmentIdle)}
>
{option.label}
Expand DownExpand Up@@ -236,7 +310,10 @@ export function PatientProfilePanel({
</span>
<button
type="button"
onClick={clear}
onClick={() => {
clear();
setResetNonce((nonce) => nonce + 1);
}}
disabled={isEmpty}
className="ml-auto inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--border)] px-2.5 text-2xs font-semibold text-[color:var(--text-muted)] transition hover:border-[color:var(--border-strong)] hover:text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50"
>
Expand Down
Loading