/** * S6 - turning a generated draft into the text a practitioner is offered. * * THE ONE DECISION THIS FILE MAKES * * The server keeps a sentence the model could not cite and FLAGS it (`unsupported`), rather than dropping * it - dropping would remove clinical content without anyone knowing. This is where that flag has to * become a decision, and the decision is: * * **An unsupported sentence is WITHHELD from the offered text and shown separately, with its own * insert affordance. Nothing is hidden; nothing uncited reaches the chart without a deliberate act.** * * Why not simply include everything and let the reviewer delete what is wrong: because "Use this * draft" then pre-fills invented sentences into a note that a busy clinician signs. Acceptance-by-default * is exactly how a plausible fabrication becomes a clinical record. The asymmetry is the same one the * whole card runs on - a withheld true sentence costs a click, an included false one costs a chart. * * Why not silently drop them: that reproduces the failure the server deliberately avoided one layer * up. `withheld` carries every one of them out to the review panel, which shows them. * * CITATIONS DO NOT GO INTO THE CHART * * They are review-time evidence - "this sentence came from segments 3 and 7" - and they are what the * panel uses to let a clinician check a claim against the transcript. In the committed note they would be * meaningless numbers referring to a transcript that is deleted on acceptance. * * PURE - no React, no network, no clock. Everything here is provable from its inputs. */ export interface DraftSentence { text: string; citations: number[]; unsupported: boolean; } export interface DraftNoteSection { heading: string; sentences: DraftSentence[]; } export interface DraftNote { sections: DraftNoteSection[]; droppedCitationCount: number; unsupportedSentenceCount: number; } export interface RenderedDraft { /** The prose offered to the editor. Empty when nothing was supported. */ text: string; /** Sentences held back because the recording does not support them, in document order. */ withheld: WithheldSentence[]; } export interface WithheldSentence { heading: string; text: string; } /** * Render the supported half of a draft as plain note text. * * A section whose every sentence was withheld is omitted from the text entirely rather than left as a * bare heading - an empty "Assessment:" reads as "there was no assessment", which is a different and * false claim from "nothing here could be traced to the recording". */ export function renderDraftNote(note: DraftNote | null | undefined): RenderedDraft { const sections = note?.sections ?? []; const withheld: WithheldSentence[] = []; const blocks: string[] = []; for (const section of sections) { const heading = (section?.heading ?? '').trim(); const sentences = section?.sentences ?? []; const supported: string[] = []; for (const sentence of sentences) { const text = (sentence?.text ?? '').trim(); if (!text) continue; // `unsupported` is trusted only as a POSITIVE signal; the citation array is checked too. // A draft that reached the client with the flag missing (an older row, a partial response) // would otherwise default to "supported" and insert an uncited sentence - the fail-open // direction. Absent evidence is treated as absent, not as permission. const isSupported = sentence?.unsupported === false && (sentence?.citations?.length ?? 0) > 0; if (isSupported) { supported.push(text); } else { withheld.push({ heading, text }); } } if (supported.length > 0) { blocks.push(heading ? `${heading}\n${supported.join(' ')}` : supported.join(' ')); } } return { text: blocks.join('\n\n'), withheld }; } /** * The line that marks where the machine's text begins, inside the note itself. * * E2 - IT GOES IN THE NOTE TEXT, NOT IN THE UI AROUND IT, and I had this the wrong way round * at first. My objection was "words nobody wrote should not enter a clinical record" - but that objection * proves too much: the whole appended draft is words nobody wrote entering a clinical record, and that is * the feature. Once the machine's paragraphs are in the record, a line naming them is strictly LESS * questionable than the paragraphs, and it is the only thing that makes them identifiable afterwards. * * What makes it load-bearing rather than decorative is E3: the note will auto-save server-side and a * colleague may open it before it is signed. An unsigned note that is entirely machine-drafted with * nothing saying so is the worst state this feature can produce. A UI badge cannot help there - the badge * lives on the screen, and the text is what travels. * * Honest limits, stated so nobody mistakes this for provenance: the clinician can edit or delete it * like any other text, and text they type *below* it is covered by it. It is a reading aid, not an audit * record - the audit record is the resolved draft and its anchors. */ export const DRAFT_ORIGIN_MARKER = '— added from recording —'; /** * Append a generated draft to whatever the practitioner currently has. * * APPEND, NEVER OVERWRITE. Under E2 this runs on its own, 60-90 seconds after the recorder finished - * so it lands while the clinician may be mid-sentence. Their words are the record; the machine's are a * suggestion arriving late, and a suggestion does not get to delete a sentence somebody wrote. * * The marker is emitted even when the note is empty, which looks redundant and is not: a note that is * 100% machine-drafted is exactly the one a later reader most needs told. */ export function appendDraftNote(currentText: string, draftText: string): string { const body = draftText.trim(); if (!body) return currentText; const addition = `${DRAFT_ORIGIN_MARKER}\n${body}`; const base = currentText.replace(/\s+$/, ''); return base ? `${base}\n\n${addition}` : addition; } /** * Append a withheld sentence to whatever the practitioner currently has, under its own heading. * * Appends rather than splices into the matching section: the text is a free-text box a human has been * editing, so there is no reliable structure left to splice into, and guessing wrong would corrupt a note * somebody wrote. It lands at the end, under its heading, where they can move it. */ export function appendWithheldSentence(currentText: string, sentence: WithheldSentence): string { const addition = sentence.heading ? `${sentence.heading}\n${sentence.text}` : sentence.text; const base = currentText.replace(/\s+$/, ''); return base ? `${base}\n\n${addition}` : addition; }