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 { api, ApiError } from '@/lib/api';
|
||||||
import type { ChartReviewOk } from '@/shared/types';
|
import type { ChartReviewOk } from '@/shared/types';
|
||||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||||
import OutputActions from '@/components/OutputActions';
|
import EditableResult from '@/components/EditableResult';
|
||||||
|
|
||||||
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
|
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
|
||||||
const TYPE = 'chart' as const;
|
const TYPE = 'chart' as const;
|
||||||
|
|
@ -181,22 +181,16 @@ export default function ChartReview() {
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{result && (
|
{result !== null && (
|
||||||
<section className="rounded-lg border border-border bg-card">
|
<EditableResult
|
||||||
<header className="px-4 py-2 border-b border-border bg-muted/40">
|
text={result}
|
||||||
<h2 className="text-sm font-semibold">Chart Review</h2>
|
onChange={setResult}
|
||||||
</header>
|
section={null}
|
||||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
title="Chart Review"
|
||||||
<div className="px-4 pb-4">
|
exportLabel="chart-review"
|
||||||
<OutputActions
|
exportType="chart-review"
|
||||||
text={result}
|
sourceContext={visits.map((v) => v.content).filter(Boolean).join('\n\n')}
|
||||||
onUpdate={setResult}
|
/>
|
||||||
sourceContext={visits.map((v) => v.content).filter(Boolean).join('\n\n')}
|
|
||||||
exportLabel="chart-review"
|
|
||||||
exportType="chart-review"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import type { HpiOk } from '@/shared/types';
|
||||||
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
|
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
|
||||||
import Recorder from '@/components/Recorder';
|
import Recorder from '@/components/Recorder';
|
||||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||||
import OutputActions from '@/components/OutputActions';
|
import EditableResult from '@/components/EditableResult';
|
||||||
|
|
||||||
type Setting = 'outpatient' | 'inpatient';
|
type Setting = 'outpatient' | 'inpatient';
|
||||||
const TYPE = 'dictation' as const;
|
const TYPE = 'dictation' as const;
|
||||||
|
|
@ -155,22 +155,16 @@ export default function Dictation() {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{result && (
|
{result !== null && (
|
||||||
<section className="rounded-lg border border-border bg-card">
|
<EditableResult
|
||||||
<header className="px-4 py-2 border-b border-border bg-muted/40">
|
text={result}
|
||||||
<h2 className="text-sm font-semibold">Generated HPI</h2>
|
onChange={setResult}
|
||||||
</header>
|
section="hpi"
|
||||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
title="Generated HPI"
|
||||||
<div className="px-4 pb-4">
|
exportLabel="hpi-dictation"
|
||||||
<OutputActions
|
exportType="hpi"
|
||||||
text={result}
|
sourceContext={displayedTranscript}
|
||||||
onUpdate={setResult}
|
/>
|
||||||
sourceContext={displayedTranscript}
|
|
||||||
exportLabel="hpi-dictation"
|
|
||||||
exportType="hpi"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import type { HpiOk } from '@/shared/types';
|
||||||
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
|
import { HpiEncounterRequestSchema, type HpiEncounterRequest } from '@/shared/schemas';
|
||||||
import Recorder from '@/components/Recorder';
|
import Recorder from '@/components/Recorder';
|
||||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||||
import OutputActions from '@/components/OutputActions';
|
import EditableResult from '@/components/EditableResult';
|
||||||
|
|
||||||
type Setting = 'outpatient' | 'inpatient';
|
type Setting = 'outpatient' | 'inpatient';
|
||||||
const TYPE = 'encounter' as const;
|
const TYPE = 'encounter' as const;
|
||||||
|
|
@ -140,22 +140,16 @@ export default function Encounter() {
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{result && (
|
{result !== null && (
|
||||||
<section className="rounded-lg border border-border bg-card">
|
<EditableResult
|
||||||
<header className="px-4 py-2 border-b border-border bg-muted/40">
|
text={result}
|
||||||
<h2 className="text-sm font-semibold">Generated HPI</h2>
|
onChange={setResult}
|
||||||
</header>
|
section="encounter"
|
||||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
title="Generated HPI"
|
||||||
<div className="px-4 pb-4">
|
exportLabel="hpi-encounter"
|
||||||
<OutputActions
|
exportType="hpi"
|
||||||
text={result}
|
sourceContext={displayedTranscript}
|
||||||
onUpdate={setResult}
|
/>
|
||||||
sourceContext={displayedTranscript}
|
|
||||||
exportLabel="hpi-encounter"
|
|
||||||
exportType="hpi"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { api, ApiError } from '@/lib/api';
|
||||||
import type { HospitalCourseOk } from '@/shared/types';
|
import type { HospitalCourseOk } from '@/shared/types';
|
||||||
import Recorder from '@/components/Recorder';
|
import Recorder from '@/components/Recorder';
|
||||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||||
import OutputActions from '@/components/OutputActions';
|
import EditableResult from '@/components/EditableResult';
|
||||||
|
|
||||||
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
|
type SettingKind = 'floor' | 'picu' | 'nicu' | 'psych';
|
||||||
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
|
type FormatKind = 'auto' | 'prose' | 'dayByDay' | 'organSystem';
|
||||||
|
|
@ -183,23 +183,15 @@ export default function HospitalCourse() {
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<section className="rounded-lg border border-border bg-card">
|
<EditableResult
|
||||||
<header className="px-4 py-2 border-b border-border bg-muted/40">
|
text={result.hospitalCourse}
|
||||||
<h2 className="text-sm font-semibold">
|
onChange={(t) => setResult({ hospitalCourse: t, format: result.format })}
|
||||||
Hospital Course <span className="text-xs font-normal text-muted-foreground">({result.format})</span>
|
section={null}
|
||||||
</h2>
|
title={'Hospital Course (' + result.format + ')'}
|
||||||
</header>
|
exportLabel="hospital-course"
|
||||||
<div className="p-4 whitespace-pre-wrap text-sm">{result.hospitalCourse}</div>
|
exportType="hospital-course"
|
||||||
<div className="px-4 pb-4">
|
sourceContext={notesText}
|
||||||
<OutputActions
|
/>
|
||||||
text={result.hospitalCourse}
|
|
||||||
onUpdate={(t) => setResult({ hospitalCourse: t, format: result.format })}
|
|
||||||
sourceContext={notesText}
|
|
||||||
exportLabel="hospital-course"
|
|
||||||
exportType="hospital-course"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import type { VisitNoteOk } from '@/shared/types';
|
||||||
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
|
import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas';
|
||||||
import Recorder from '@/components/Recorder';
|
import Recorder from '@/components/Recorder';
|
||||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||||
import OutputActions from '@/components/OutputActions';
|
import EditableResult from '@/components/EditableResult';
|
||||||
|
|
||||||
const TYPE = 'sickvisit' as const;
|
const TYPE = 'sickvisit' as const;
|
||||||
|
|
||||||
|
|
@ -131,22 +131,16 @@ export default function SickVisit() {
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{result && (
|
{result !== null && (
|
||||||
<section className="rounded-lg border border-border bg-card">
|
<EditableResult
|
||||||
<header className="px-4 py-2 border-b border-border bg-muted/40">
|
text={result}
|
||||||
<h2 className="text-sm font-semibold">Sick Visit Note</h2>
|
onChange={setResult}
|
||||||
</header>
|
section="sickvisit"
|
||||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
title="Sick Visit Note"
|
||||||
<div className="px-4 pb-4">
|
exportLabel="sick-visit-note"
|
||||||
<OutputActions
|
exportType="sick-visit"
|
||||||
text={result}
|
sourceContext={displayedTranscript}
|
||||||
onUpdate={setResult}
|
/>
|
||||||
sourceContext={displayedTranscript}
|
|
||||||
exportLabel="sick-visit-note"
|
|
||||||
exportType="sick-visit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import type { SoapOk } from '@/shared/types';
|
||||||
import { SoapRequestSchema, type SoapRequest } from '@/shared/schemas';
|
import { SoapRequestSchema, type SoapRequest } from '@/shared/schemas';
|
||||||
import Recorder from '@/components/Recorder';
|
import Recorder from '@/components/Recorder';
|
||||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||||
import OutputActions from '@/components/OutputActions';
|
import EditableResult from '@/components/EditableResult';
|
||||||
|
|
||||||
type SoapType = 'full' | 'subjective';
|
type SoapType = 'full' | 'subjective';
|
||||||
const TYPE = 'soap' as const;
|
const TYPE = 'soap' as const;
|
||||||
|
|
@ -149,22 +149,16 @@ export default function Soap() {
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{result && (
|
{result !== null && (
|
||||||
<section className="rounded-lg border border-border bg-card">
|
<EditableResult
|
||||||
<header className="px-4 py-2 border-b border-border bg-muted/40">
|
text={result}
|
||||||
<h2 className="text-sm font-semibold">Generated SOAP</h2>
|
onChange={setResult}
|
||||||
</header>
|
section="soap"
|
||||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
title="Generated SOAP"
|
||||||
<div className="px-4 pb-4">
|
exportLabel="soap-note"
|
||||||
<OutputActions
|
exportType="soap"
|
||||||
text={result}
|
sourceContext={displayedTranscript}
|
||||||
onUpdate={setResult}
|
/>
|
||||||
sourceContext={displayedTranscript}
|
|
||||||
exportLabel="soap-note"
|
|
||||||
exportType="soap"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useMutation } from '@tanstack/react-query';
|
||||||
import { api, ApiError } from '@/lib/api';
|
import { api, ApiError } from '@/lib/api';
|
||||||
import type { VisitNoteOk } from '@/shared/types';
|
import type { VisitNoteOk } from '@/shared/types';
|
||||||
import EncounterToolbar from '@/components/EncounterToolbar';
|
import EncounterToolbar from '@/components/EncounterToolbar';
|
||||||
import OutputActions from '@/components/OutputActions';
|
import EditableResult from '@/components/EditableResult';
|
||||||
import Recorder from '@/components/Recorder';
|
import Recorder from '@/components/Recorder';
|
||||||
|
|
||||||
const TYPE = 'wellvisit' as const;
|
const TYPE = 'wellvisit' as const;
|
||||||
|
|
@ -220,22 +220,16 @@ export default function VisitNote() {
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{result && (
|
{result !== null && (
|
||||||
<section className="rounded-lg border border-border bg-card">
|
<EditableResult
|
||||||
<header className="px-4 py-2 border-b border-border bg-muted/40">
|
text={result}
|
||||||
<h2 className="text-sm font-semibold">Well Visit Note</h2>
|
onChange={setResult}
|
||||||
</header>
|
section="wellvisit"
|
||||||
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
|
title="Well Visit Note"
|
||||||
<div className="px-4 pb-4">
|
exportLabel="well-visit-note"
|
||||||
<OutputActions
|
exportType="well-visit"
|
||||||
text={result}
|
sourceContext={displayedTranscript}
|
||||||
onUpdate={setResult}
|
/>
|
||||||
sourceContext={displayedTranscript}
|
|
||||||
exportLabel="well-visit-note"
|
|
||||||
exportType="well-visit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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