From e6d0e5ef8c8755416203dda49b4bc583109ee0a1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 24 Apr 2026 04:45:04 +0200 Subject: [PATCH] feat(notes): editable AI output + automatic correction tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_
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. --- client/src/components/EditableResult.tsx | 117 +++++++++++++++++++++ client/src/pages/ChartReview.tsx | 28 ++--- client/src/pages/Dictation.tsx | 28 ++--- client/src/pages/Encounter.tsx | 28 ++--- client/src/pages/HospitalCourse.tsx | 28 ++--- client/src/pages/SickVisit.tsx | 28 ++--- client/src/pages/Soap.tsx | 28 ++--- client/src/pages/wellvisit/VisitNote.tsx | 28 ++--- shared/clinical/correction-tracker.test.ts | 50 +++++++++ shared/clinical/correction-tracker.ts | 19 ++++ 10 files changed, 262 insertions(+), 120 deletions(-) create mode 100644 client/src/components/EditableResult.tsx create mode 100644 shared/clinical/correction-tracker.test.ts create mode 100644 shared/clinical/correction-tracker.ts diff --git a/client/src/components/EditableResult.tsx b/client/src/components/EditableResult.tsx new file mode 100644 index 0000000..6f0b148 --- /dev/null +++ b/client/src/components/EditableResult.tsx @@ -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_
` 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(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 ( +
+
+

{title}

+
+