marmar-context / src / clinician / context / neutral-voice.ts
neutral-voice.ts
Raw
// Patients have no pronoun on file, and Context never guesses one. The engine words a person by
// the sex recorded ("her Penicillin allergy"), so Context takes its wording from the same check
// run with no sex given, string by string, and only where the two differ in pronouns alone:
// tiers, findings, doses and every other word stay as the real check made them. Pure.
import type { CheckInput, CheckResult } from '../../domain/checks/engine.js';

const NEUTRAL: Record<string, string[]> = {
  her: ['their', 'them'], his: ['their'], him: ['them'], hers: ['theirs'], she: ['they'], he: ['they'], herself: ['themselves'], himself: ['themselves'],
};
const SUBJECTS = new Set(['she', 'he']);
/** "she feels" becomes "they feel": the verb after a subject pronoun may change with it. */
const VERB = (gendered: string, neutral: string) =>
  ({ is: 'are', was: 'were', has: 'have', does: 'do', "doesn't": "don't", "isn't": "aren't", "wasn't": "weren't", "hasn't": "haven't" } as Record<string, string>)[gendered] === neutral
  || (gendered.endsWith('s') && gendered.slice(0, -1) === neutral);

const words = (text: string) => text.split(/([^\p{L}']+)/u);

/** True when the neutral string is the gendered one with only its pronouns (and their verbs) changed. */
export function onlyPronounsDiffer(gendered: string, neutral: string): boolean {
  if (gendered === neutral) return false;
  const a = words(gendered);
  const b = words(neutral);
  if (a.length !== b.length) return false;
  let afterSubject = false;
  for (let i = 0; i < a.length; i++) {
    if (a[i] === b[i]) { if (/\p{L}/u.test(a[i])) afterSubject = false; continue; }
    const from = a[i].toLowerCase();
    const to = b[i].toLowerCase();
    if (NEUTRAL[from]?.includes(to)) { afterSubject = SUBJECTS.has(from); continue; }
    if (afterSubject && VERB(from, to)) { afterSubject = false; continue; }
    return false;
  }
  return true;
}

const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const idOf = (value: unknown) => (isObject(value) && typeof value.id === 'string' ? value.id : null);

function reword(real: unknown, neutral: unknown): unknown {
  if (typeof real === 'string') return typeof neutral === 'string' && onlyPronounsDiffer(real, neutral) ? neutral : real;
  if (Array.isArray(real)) {
    if (!Array.isArray(neutral)) return real;
    // Findings are matched by id; anything else by position, and only when the lists line up.
    if (real.every((item) => idOf(item) !== null)) return real.map((item) => reword(item, neutral.find((other) => idOf(other) === idOf(item))));
    return real.length === neutral.length ? real.map((item, index) => reword(item, neutral[index])) : real;
  }
  if (isObject(real)) {
    if (!isObject(neutral)) return real;
    return Object.fromEntries(Object.entries(real).map(([key, value]) => [key, reword(value, neutral[key])]));
  }
  return real;
}

/** The check, worded without a guessed he or she. With no sex recorded it is the check as it is. */
export function withoutGuessedPronouns<T extends CheckResult>(input: CheckInput, result: T, check: (input: CheckInput) => T): T {
  if (!input.subject.sex) return result;
  return reword(result, check({ ...input, subject: { ...input.subject, sex: null } })) as T;
}