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:
parent
1beabe40e4
commit
e6d0e5ef8c
10 changed files with 262 additions and 120 deletions
117
client/src/components/EditableResult.tsx
Normal file
117
client/src/components/EditableResult.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
50
shared/clinical/correction-tracker.test.ts
Normal file
50
shared/clinical/correction-tracker.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
shared/clinical/correction-tracker.ts
Normal file
19
shared/clinical/correction-tracker.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue