marmar-context / src / domain / checks / clinician-guard.ts
clinician-guard.ts
Raw
import { normalizeText } from './normalize.js';

/**
 * CHK-5: clinician wording states a fact and its basis, never an instruction (PRD principle 1). Every draft is
 * checked here in tests, and each rendered text again at run time with the medicine names masked; a text with a
 * problem is left out, never shown. MARMAR never decides, prescribes, diagnoses or recommends treatment.
 */

const word = (source: string) => new RegExp(`(?<![a-z0-9])(?:${source})(?![a-z0-9])`);

const RULES: Array<[problem: string, pattern: RegExp]> = [
  // Telling anyone what to do: a clause that starts with a verb of action.
  ['instruction', /^(?:please )?(?:stop|start|restart|resume|change|switch|swap|replace|substitute|reduce|increase|decrease|lower|raise|adjust|titrate|halve|double|give|take|use|avoid|withhold|hold|omit|skip|discontinue|continue|keep|prescribe|consider|check|recheck|monitor|measure|test|refer|order|repeat|call|phone|contact|ask|tell|inform|advise|counsel|review|reassess|confirm|arrange|admit|send|seek|go|add|remove|do|don ?t|never|ensure|make sure|be)(?![a-z0-9])/],
  ['second_person', word('you|your|yours|yourself')],
  ['advice', word('should|shouldnt|must|mustnt|ought to|need to|needs to|has to|have to|had better|it is best|it would be (?:best|better|wise)|is advised|are advised|is recommended|are recommended|please')],
  ['advice', word('recommend|recommends|recommended|recommending|advise|advises|advised|advising|suggest|suggests|suggested|suggesting|consider|considers|considered|considering')],
  ['decision', word('decide|decides|decided|deciding|decision|diagnose|diagnoses|diagnosed|diagnosing|diagnosis|prescribe|prescribes|prescribing')],
  ['stop_or_change', word('stop|stops|stopped|stopping|discontinu[a-z]*|withh(?:o|e)ld[a-z]*|switch(?:es|ed|ing)?|swap(?:s|ped|ping)?|substitut[a-z]*|taper[a-z]*|avoid[a-z]*|halv(?:e|es|ed|ing)|do not|dont|never')],
  ['dose_change', word('(?:reduc|increas|decreas|lower|rais|adjust|chang|alter|titrat|halv|doubl|cut)[a-z]*(?: [a-z]+){0,2} (?:dose|doses|dosage|dosing)')],
  ['dose_change', word('(?:dose|doses|dosage)(?: [a-z]+){0,2} (?:reduc|increas|decreas|lower|rais|adjust|chang|alter|titrat|halv|doubl)[a-z]*')],
  ['reassurance', word('safe|safely|safer|safest|safety|harmless|no problem|not a problem|nothing to worry|fine|ok|okay|alright|all right|reassur[a-z]*')],
  ['patient_voice', word('call 112|call 999|call 911|get help now|before the next dose|pharmacist today|doctor today')],
];

const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

/** Problems with a clinician title or explanation; empty means it may be shown. Names are masked first: they are data. */
export function clinicianWordingProblems(text: string, names: readonly string[] = []): string[] {
  let said = ` ${normalizeText(text)} `;
  for (const name of [...new Set(names.map(normalizeText).filter(Boolean))].sort((a, b) => b.length - a.length)) {
    said = said.replace(new RegExp(`(?<![a-z0-9])${escape(name)}(?![a-z0-9])`, 'g'), ' medicine ');
  }
  said = said.replace(/\s+/g, ' ').trim();
  // Clauses: split where the original text has sentence or clause punctuation.
  const clauses = text.split(/[.;:!?()]+/).map((part) => {
    let clause = ` ${normalizeText(part)} `;
    for (const name of names.map(normalizeText).filter(Boolean)) clause = clause.replace(new RegExp(`(?<![a-z0-9])${escape(name)}(?![a-z0-9])`, 'g'), ' medicine ');
    return clause.replace(/\s+/g, ' ').trim();
  }).filter(Boolean);
  const problems = new Set<string>();
  for (const [problem, pattern] of RULES) {
    if (problem === 'instruction') {
      if (clauses.some((clause) => pattern.test(clause))) problems.add(problem);
    } else if (pattern.test(said)) problems.add(problem);
  }
  return [...problems];
}