feat(notes): editable AI output + automatic correction tracking

Restores two pieces of vanilla behavior the React port silently dropped:

1. The output divs were contenteditable in vanilla — clinicians fix
   AI mistakes inline before saving the encounter or copying out.
   The early React port rendered the output as a read-only div, so
   any edit forced a copy-paste workflow.

2. correctionTracker.js (public/js/correctionTracker.js) saved every
   meaningful inline edit to user_memories under category
   correction_<section> so the AI learns user preferences. Settings
   → Corrections still showed them, but nothing in React was *writing*
   them — the loop was broken.

New EditableResult component wraps the result body in a textarea,
captures the AI baseline on first render / after refine|shorten, and
on blur posts to /api/memories/correction (only when the edit clears
the noise threshold from vanilla — wordDiff ≥ 2 OR charDiff ≥ 20 on
outputs > 100 chars).

Wired into all 7 note pages:
  Encounter   → section: 'encounter'
  Dictation   → section: 'hpi'
  SOAP        → section: 'soap'
  SickVisit   → section: 'sickvisit'
  WellVisit   → section: 'wellvisit'
  HospitalCourse → section: null   (server enum has no correction_hospital)
  ChartReview    → section: null   (server enum has no correction_chart)

Tests:
  shared/clinical/correction-tracker.ts (+ .test.ts) — pure heuristic
  with vectors covering the noise floor (single-word swap on long
  text → skipped; new sentence → tracked; large char diff → tracked).
  Lives in shared/ so the root vitest config picks it up; the React
  component imports from there.
This commit is contained in:
Daniel 2026-04-24 04:45:04 +02:00
parent 1beabe40e4
commit e6d0e5ef8c
10 changed files with 262 additions and 120 deletions

View file

@ -0,0 +1,117 @@
// ============================================================
// EditableResult — editable AI-output box + correction tracking +
// OutputActions toolbar. Restores two pieces of vanilla behavior
// the React migration silently dropped:
//
// 1. The output divs were `contenteditable="true"` in vanilla.
// Users routinely fix AI mistakes inline before saving the
// encounter or copying out. The early React port rendered the
// output as a read-only div — copy was the only path.
//
// 2. correctionTracker.js (public/js/correctionTracker.js) saved
// every meaningful inline edit to user_memories under category
// `correction_<section>` so the AI learns user preferences.
// Settings → Corrections still shows them, but nothing in React
// was *writing* them. This component restores that loop.
//
// On blur, if the text differs from the captured "original AI
// output" by more than the noise threshold, POST /api/memories/
// correction. Mirrors the vanilla heuristic exactly so we don't
// flood memories with whitespace-only edits.
// ============================================================
import { useEffect, useRef } from 'react';
import OutputActions from '@/components/OutputActions';
import { api } from '@/lib/api';
import { isMeaningfulChange } from '@shared/clinical/correction-tracker';
// Server enum — must match VALID_CATEGORIES in src/routes/memories.ts.
export type CorrectionSection =
| 'encounter'
| 'hpi'
| 'soap'
| 'wellvisit'
| 'sickvisit';
interface Props {
text: string;
onChange: (next: string) => void;
/**
* Which note type this is selects the correction memory bucket.
* Pass `null` to skip correction tracking (Hospital Course and Chart
* Review aren't in the server's VALID_CATEGORIES enum).
*/
section: CorrectionSection | null;
/** Filename prefix for Nextcloud export (passed through to OutputActions). */
exportLabel: string;
/** docType field for Nextcloud export. */
exportType: string;
/** Optional source material — refine reads this for context. */
sourceContext?: string;
/** Title shown in the section header. */
title: string;
}
export default function EditableResult({
text, onChange, section, exportLabel, exportType, sourceContext, title,
}: Props) {
// The "AI baseline" — what the model produced last. Updated when refine
// or shorten replaces the body (handled in onUpdate below) so the next
// user edit is measured against the new baseline, not the original.
const originalRef = useRef<string>(text);
// Re-baseline whenever the text grows/changes from a non-edit source —
// i.e. when generation produced new output (parent flipped from null→text).
// We can't perfectly distinguish a parent-driven change from a user typing,
// but the typical generation flow is null → full text, so a length jump
// back to a different value is treated as a new baseline.
useEffect(() => {
if (!originalRef.current && text) originalRef.current = text;
}, [text]);
async function maybeSaveCorrection() {
if (!section) return;
if (!isMeaningfulChange(originalRef.current, text)) return;
try {
await api.post('/api/memories/correction', {
section,
original_snippet: originalRef.current,
corrected_snippet: text,
});
// Successful save → make the new text the baseline so successive
// edits get tracked against the most-recently-saved version.
originalRef.current = text;
} catch {
// Fail silently — correction tracking is best-effort, never blocks
// the user's primary copy/refine/save flow.
}
}
return (
<section className="rounded-lg border border-border bg-card" data-testid={'editable-result-' + exportType}>
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">{title}</h2>
</header>
<textarea
className="w-full p-4 text-sm bg-transparent resize-y min-h-[200px] focus:outline-none focus:ring-0 border-0 whitespace-pre-wrap"
value={text}
onChange={(e) => onChange(e.target.value)}
onBlur={maybeSaveCorrection}
data-testid={'editable-result-body-' + exportType}
spellCheck
/>
<div className="px-4 pb-4">
<OutputActions
text={text}
onUpdate={(t) => {
// Refine / shorten output replaces the body — re-baseline.
originalRef.current = t;
onChange(t);
}}
sourceContext={sourceContext}
exportLabel={exportLabel}
exportType={exportType}
/>
</div>
</section>
);
}

View file

@ -7,7 +7,7 @@ import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { ChartReviewOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import EditableResult from '@/components/EditableResult';
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
const TYPE = 'chart' as const;
@ -181,22 +181,16 @@ export default function ChartReview() {
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Chart Review</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={visits.map((v) => v.content).filter(Boolean).join('\n\n')}
exportLabel="chart-review"
exportType="chart-review"
/>
</div>
</section>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section={null}
title="Chart Review"
exportLabel="chart-review"
exportType="chart-review"
sourceContext={visits.map((v) => v.content).filter(Boolean).join('\n\n')}
/>
)}
</div>
);

View file

@ -15,7 +15,7 @@ import type { HpiOk } from '@/shared/types';
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import EditableResult from '@/components/EditableResult';
type Setting = 'outpatient' | 'inpatient';
const TYPE = 'dictation' as const;
@ -155,22 +155,16 @@ export default function Dictation() {
</div>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Generated HPI</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="hpi-dictation"
exportType="hpi"
/>
</div>
</section>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="hpi"
title="Generated HPI"
exportLabel="hpi-dictation"
exportType="hpi"
sourceContext={displayedTranscript}
/>
)}
</div>
);

View file

@ -12,7 +12,7 @@ import type { HpiOk } from '@/shared/types';
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import EditableResult from '@/components/EditableResult';
type Setting = 'outpatient' | 'inpatient';
const TYPE = 'encounter' as const;
@ -140,22 +140,16 @@ export default function Encounter() {
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Generated HPI</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="hpi-encounter"
exportType="hpi"
/>
</div>
</section>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="encounter"
title="Generated HPI"
exportLabel="hpi-encounter"
exportType="hpi"
sourceContext={displayedTranscript}
/>
)}
</div>
);

View file

@ -8,7 +8,7 @@ import { api, ApiError } from '@/lib/api';
import type { HospitalCourseOk } from '@/shared/types';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import EditableResult from '@/components/EditableResult';
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
@ -183,23 +183,15 @@ export default function HospitalCourse() {
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">
Hospital Course <span className="text-xs font-normal text-muted-foreground">({result.format})</span>
</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result.hospitalCourse}</div>
<div className="px-4 pb-4">
<OutputActions
text={result.hospitalCourse}
onUpdate={(t) => setResult({ hospitalCourse: t, format: result.format })}
sourceContext={notesText}
exportLabel="hospital-course"
exportType="hospital-course"
/>
</div>
</section>
<EditableResult
text={result.hospitalCourse}
onChange={(t) => setResult({ hospitalCourse: t, format: result.format })}
section={null}
title={'Hospital Course (' + result.format + ')'}
exportLabel="hospital-course"
exportType="hospital-course"
sourceContext={notesText}
/>
)}
</div>
);

View file

@ -9,7 +9,7 @@ import type { VisitNoteOk } from '@/shared/types';
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import EditableResult from '@/components/EditableResult';
const TYPE = 'sickvisit' as const;
@ -131,22 +131,16 @@ export default function SickVisit() {
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Sick Visit Note</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="sick-visit-note"
exportType="sick-visit"
/>
</div>
</section>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="sickvisit"
title="Sick Visit Note"
exportLabel="sick-visit-note"
exportType="sick-visit"
sourceContext={displayedTranscript}
/>
)}
</div>
);

View file

@ -9,7 +9,7 @@ import type { SoapOk } from '@/shared/types';
import { SoapRequestSchema, type SoapRequest } from '@/shared/schemas';
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import EditableResult from '@/components/EditableResult';
type SoapType = 'full' | 'subjective';
const TYPE = 'soap' as const;
@ -149,22 +149,16 @@ export default function Soap() {
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Generated SOAP</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="soap-note"
exportType="soap"
/>
</div>
</section>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="soap"
title="Generated SOAP"
exportLabel="soap-note"
exportType="soap"
sourceContext={displayedTranscript}
/>
)}
</div>
);

View file

@ -10,7 +10,7 @@ import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import EditableResult from '@/components/EditableResult';
import Recorder from '@/components/Recorder';
const TYPE = 'wellvisit' as const;
@ -220,22 +220,16 @@ export default function VisitNote() {
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Well Visit Note</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="well-visit-note"
exportType="well-visit"
/>
</div>
</section>
{result !== null && (
<EditableResult
text={result}
onChange={setResult}
section="wellvisit"
title="Well Visit Note"
exportLabel="well-visit-note"
exportType="well-visit"
sourceContext={displayedTranscript}
/>
)}
</div>
);

View file

@ -0,0 +1,50 @@
// Tests for the correction-tracking heuristic — should not flood the
// memories table with whitespace-only or single-word fixes on long
// outputs, but must capture all real edits.
import { describe, expect, it } from 'vitest';
import { isMeaningfulChange } from './correction-tracker';
describe('isMeaningfulChange', () => {
it('returns false for empty strings', () => {
expect(isMeaningfulChange('', 'anything')).toBe(false);
expect(isMeaningfulChange('anything', '')).toBe(false);
});
it('returns false when the trimmed text is identical', () => {
expect(isMeaningfulChange('hello', 'hello')).toBe(false);
expect(isMeaningfulChange('hello\n', ' hello ')).toBe(false);
});
it('returns true for short edits even with small word/char diffs', () => {
// The 100-char threshold guards against noise on LONG outputs only.
// Short edits always count.
expect(isMeaningfulChange('hi', 'hi.')).toBe(true);
expect(isMeaningfulChange('hi', 'hello')).toBe(true);
});
it('rejects single-word, ≤20-char edits on long original output', () => {
const long = 'Patient is a 5-year-old male presenting with fever for two days. ' +
'No respiratory symptoms. No GI symptoms. Vitals stable. Exam unremarkable. ' +
'Plan: supportive care, return precautions reviewed.';
// Same length, swap one word — wordDiff=0, charDiff=0 → noise, skip.
expect(isMeaningfulChange(long, long.replace('male', 'mail'))).toBe(false);
// Same length, swap one word for similar length — still noise, skip.
expect(isMeaningfulChange(long, long.replace('two days', 'one day '))).toBe(false);
});
it('accepts multi-word edits even on long output', () => {
const long = 'Patient is a 5-year-old male presenting with fever for two days. ' +
'No respiratory symptoms. No GI symptoms. Vitals stable. Exam unremarkable. ' +
'Plan: supportive care, return precautions reviewed.';
// Inserting a sentence is a real edit — track it.
const edited = long + ' Will follow up in 48 hours if symptoms persist.';
expect(isMeaningfulChange(long, edited)).toBe(true);
});
it('accepts single-word edits when the char diff is large enough', () => {
const long = 'a'.repeat(150);
// 30-char insertion — over the 20-char noise floor.
expect(isMeaningfulChange(long, long + 'x'.repeat(30))).toBe(true);
});
});

View file

@ -0,0 +1,19 @@
// Pure heuristic from public/js/correctionTracker.js — used by
// EditableResult to decide whether a user's inline edit to AI output
// is "meaningful" enough to push to /api/memories/correction.
//
// The threshold matches vanilla exactly so we don't drift behaviour
// between the two clients during the migration window.
export function isMeaningfulChange(original: string, current: string): boolean {
if (!original || !current) return false;
if (original.trim() === current.trim()) return false;
const origWords = original.split(/\s+/).length;
const currWords = current.split(/\s+/).length;
const wordDiff = Math.abs(origWords - currWords);
if (wordDiff < 2 && original.length > 100) {
const charDiff = Math.abs(original.length - current.length);
if (charDiff < 20) return false;
}
return true;
}