marmar-context / web / context / src / logic / questions.ts
questions.ts
Raw
import type { CheckInPlanTemplate, ClosingLine, PlanQuestion, QuestionSuggestion } from "../api/types";

export const MAX_OWN_QUESTIONS = 3;
export const MAX_QUESTION_LENGTH = 200;

export type Precheck = { ok: true; text: string } | { ok: false; reason: string; rule: string };

const imperative =
  /^(please\s+)?(stop|start|take|keep|continue|reduce|increase|double|halve|cut|avoid|change|switch|use|don'?t|do not|never|always|come|go|call|book|drink|eat|finish|skip|hold|restart|resume)\b/i;
const request =
  /\b(can|could|would|will) you (please )?(stop|start|take|keep|continue|reduce|increase|double|halve|avoid|change|switch|skip|hold|restart|resume|come)\b/i;
const advice =
  /\b(you should|you must|you need to|you have to|you ought to|make sure|i want you to|i'd like you to|it is best to|it's best to)\b/i;
const reassurance = /\b(that'?s normal|nothing to worry|don'?t worry|it'?s fine|it is fine|you'?re fine|you are fine|it'?s safe|it is safe)\b/i;
const result =
  /\b(your|the)\s+(results?|egfr|kidney (test|function|result)s?|potassium|inr|creatinine|blood tests?|scan|x-ray|lab(oratory)? results?)\b.*\b(is|are|was|were|shows?|showed|came back|looks?)\b/i;
const diagnosis = /\b(you (have|may have|might have|probably have|definitely have|are suffering from)|diagnos[ei]s|diagnosed)\b/i;

/**
 * YCI-4 pre-check, before the server's own check: one question, under 200 characters,
 * and no advice, instructions, results, diagnosis or medicine change.
 */
export function precheckQuestion(raw: string, patientFirstName: string, object = "them"): Precheck {
  const text = raw.replace(/\s+/g, " ").trim();
  const call = `Call ${patientFirstName} to tell ${object} this.`;
  if (!text) return { ok: false, rule: "empty", reason: "Write a question for Yasmar to ask." };
  if (text.length > MAX_QUESTION_LENGTH)
    return {
      ok: false,
      rule: "too_long",
      reason: `Keep it to ${MAX_QUESTION_LENGTH} characters. This one has ${text.length}.`,
    };
  if (imperative.test(text) || request.test(text) || advice.test(text))
    return {
      ok: false,
      rule: "instruction",
      reason: `Yasmar can ask questions but cannot give instructions. ${call}`,
    };
  if (reassurance.test(text))
    return { ok: false, rule: "reassurance", reason: `Yasmar cannot reassure or give advice. ${call}` };
  if (result.test(text))
    return { ok: false, rule: "result", reason: `Yasmar cannot share or explain results. ${call}` };
  if (diagnosis.test(text))
    return { ok: false, rule: "diagnosis", reason: `Yasmar cannot give a diagnosis. ${call}` };
  const marks = (text.match(/\?/g) ?? []).length;
  if (!text.endsWith("?"))
    return { ok: false, rule: "not_question", reason: "Write it as a question that ends with a question mark." };
  if (marks > 1)
    return { ok: false, rule: "several", reason: "Ask one thing at a time. Split this into separate questions." };
  return { ok: true, text };
}

export const fromSuggestion = (s: QuestionSuggestion): PlanQuestion => ({
  id: s.id,
  text: s.text,
  origin: "bank",
  bankId: s.bankId,
  reason: s.reason,
  findingId: s.source.kind === "finding" ? s.source.findingId : null,
});

/** YCI-20: "I'll let Doctor Okafor know today" only if the clinician will read them today. */
export function closingAvailable(line: ClosingLine, readToday: boolean, reminder = false): boolean {
  if (line.requires === "read_today") return readToday;
  if (line.requires === "reminder") return reminder;
  return true;
}

export type ScriptLine = {
  part: "opening" | "identity" | "consent" | "feeling" | "question" | "last" | "closing" | "safety";
  text: string;
  reason: string | null;
  editable: boolean;
};

/** YCI-2: exactly what Yasmar will say, in order. Fixed lines are marked not editable. */
export function buildScript(
  template: Pick<
    CheckInPlanTemplate,
    "opening" | "identity" | "consent" | "feeling" | "alwaysLast" | "closings" | "safetyNet"
  >,
  questions: PlanQuestion[],
  closingIds: string[],
  readToday: boolean,
): ScriptLine[] {
  const fixed = (part: ScriptLine["part"], text: string): ScriptLine => ({ part, text, reason: null, editable: false });
  const closings = template.closings.filter(
    (c) => (c.fixed || closingIds.includes(c.id)) && closingAvailable(c, readToday),
  );
  return [
    ...template.opening.map((t) => fixed("opening", t)),
    ...template.identity.map((t) => fixed("identity", t)),
    fixed("consent", template.consent),
    fixed("feeling", template.feeling),
    ...questions.map((q) => ({ part: "question" as const, text: q.text, reason: q.reason, editable: true })),
    fixed("last", template.alwaysLast),
    ...closings.map((c) => ({ part: "closing" as const, text: c.text, reason: null, editable: !c.fixed })),
    fixed("safety", template.safetyNet),
  ];
}

/** Move a question up or down in the clinician's order (YCI-16: Yasmar keeps this order). */
export function moveQuestion(list: PlanQuestion[], id: string, by: -1 | 1): PlanQuestion[] {
  const i = list.findIndex((q) => q.id === id);
  const j = i + by;
  if (i < 0 || j < 0 || j >= list.length) return list;
  const next = [...list];
  [next[i], next[j]] = [next[j], next[i]];
  return next;
}