#!/usr/bin/env python3 """Axis 3 (drug safety, multiple choice): LLM discovery, then question generation. Stage 1: the LLM reads the drug and constraint timelines of the evidence chain and returns standing drug constraints (class, unsafe member, safe equivalents). Stage 2: the axis-3 rules (pipeline.prompts) turn each constraint into a multiple-choice question with exactly one correct option. The gold is options[].is_correct. The options are put in an order seeded by the question id and labelled A-D in that order; the question text does not list them. synthesize() authors synthetic questions. python -m pipeline.questions.axis3 discovery -> _discover_axis3.json """ from __future__ import annotations import json import random from pathlib import Path from pipeline import prompts from pipeline.common import (BASE, Progress, has_text, lists_options, model, patient_context, patient_id, patient_line) from pipeline.llm import ChatClient, subst PATIENT = patient_id() ENGINE = model("generation") AXIS = 3 DRUG_ASPECTS = {"med_rationale_referral", "medication", "constraint_or_safety", "care_plan"} DISCOVER_SYS = ( "You read one patient's longitudinal record of medications and constraints and find standing\n" "drug-safety constraints. A constraint is a documented reason, specific to this patient, why some\n" "drugs a clinician might choose are inappropriate for this patient while a clinically reasonable\n" "alternative remains fine: an adverse reaction, an allergy, an intolerance, or an interacting\n" "medication that the patient takes. Return only JSON.") def discover_constraints(cli, timelines): """Read the drug-bearing timelines and return the patient's standing drug constraints.""" rows = [] for t in timelines: segs = t.get("segments") or [] rows.append(f"- entity={t.get('entity')!r} aspect={t.get('aspect')} dept={t.get('department')} " f"status={t.get('current_status')!r} " f"values={[str(s.get('value'))[:60] for s in segs[:4]]}") record = "\n".join(rows) usr = ( "Medication and constraint record, from the evidence chain:\n" + record + "\n\n" "Find every standing drug-safety constraint documented for this patient. A constraint qualifies\n" "only if all of these hold:\n" "- It is specific to this patient: an allergy, intolerance or adverse reaction on file, or a drug\n" " the patient takes that interacts with a drug class. General guideline caution does not count.\n" "- It makes at least three drugs a clinician might reach for inappropriate for this patient, while\n" " at least one clinically reasonable alternative stays appropriate, so that a four-option question\n" " with exactly one appropriate answer is possible.\n" "- It is standing, that is, still true at later visits, and not a one-off event.\n" "Exclude negated screening entries such as 'no known allergy', 'denied' or '(-)'.\n\n" "For each constraint give:\n" "- the drug class;\n" "- at least three drugs that are unsafe for this patient;\n" "- one to four safe alternatives;\n" "- a realistic scenario in which the class would be prescribed to the patient;\n" "- the first-line trap drugs that a clinician, or a model without memory, would reach for first.\n" " These must be among the unsafe drugs, or the question is pointless.\n\n" "Return JSON in this form:\n" '{"constraints": [{\n' ' "constraint_id": "",\n' ' "implicated_class": "",\n' ' "constraint_text": "",\n' ' "source_entity": "",\n' ' "unsafe_examples": ["", ...],\n' ' "safe_examples": ["", ...],\n' ' "scenario_hint": "",\n' ' "required_patient_info": [""],\n' ' "first_line_trap": ""\n' "}]}") r = cli.complete_json(DISCOVER_SYS, usr) return r.get("constraints") or [] def discover() -> None: """Discover the axis-3 candidates of this patient -> _discover_axis3.json.""" cli = ChatClient(model=ENGINE, reasoning_effort="medium", max_completion_tokens=6000) tl = [json.loads(l) for l in open(f"{BASE}/evidence_chain.jsonl") if l.strip()] chain = {json.loads(l)["visit_number"]: json.loads(l) for l in open(f"{BASE}/event_chain.jsonl") if l.strip()} plast = max(r["date"] for r in chain.values()) drug_tl = [t for t in tl if t.get("aspect") in DRUG_ASPECTS] with Progress("axis3 discover (LLM)", total=2, out=f"{BASE}/progress_axis3_discover.json") as pg: try: seeds = discover_constraints(cli, drug_tl) except Exception as e: print(f" discover FAILED: {str(e)[:160]}") seeds = [] pg.tick(ok=bool(seeds)) AX3 = subst(prompts.AXIS3_RULES) COMMON = subst(prompts.AXIS3_COMMON) system = subst(f"{COMMON}\n\n{AX3}") candidates = [] for i, s in enumerate(seeds): if len(s.get("unsafe_examples") or []) < 3: print(f" skip {s.get('constraint_id')}: fewer than three unsafe drugs for the traps") continue gt_visit, gt_date = _anchor(s, tl, chain) if gt_visit is None: print(f" skip {s.get('constraint_id')}: its source entity is not in the evidence chain") continue seed_json = json.dumps({ "constraint_id": s.get("constraint_id"), "implicated_class": s.get("implicated_class"), "scenario_hint": s.get("scenario_hint"), "constraint_text": s.get("constraint_text"), "unsafe_examples": s.get("unsafe_examples"), "safe_examples": s.get("safe_examples"), "required_patient_info": s.get("required_patient_info"), "first_line_trap": s.get("first_line_trap"), "gt_date": gt_date, "grounding": "record"}, ensure_ascii=False) usr = ( "Write the axis-3 multiple-choice drug-safety question for this seed, following every rule\n" "above. Return only a JSON object of the form {\"questions\": [ ... ]} with one question, or\n" "with an empty list if the rules cannot be met.\n\nSeed:\n" + seed_json) try: out = cli.complete_json(system, usr) qs = out.get("questions") or ([out] if out.get("options") else []) except Exception as e: print(f" gen[{i}] FAILED: {str(e)[:120]}") continue for q in qs: if not has_text(q): print(f" reject {s.get('constraint_id')}: the reply has no question text") continue opts = q.get("options") or [] if not (isinstance(opts, list) and all(isinstance(o, dict) for o in opts)): print(f" reject {s.get('constraint_id')}: the options are not a list of objects") continue for o in opts: if not o.get("content") and o.get("text"): o["content"] = o["text"] if lists_options(q.get("question"), opts): print(f" reject {s.get('constraint_id')}: the question text lists the options") continue if sum(1 for o in opts if o.get("is_correct")) != 1: print(f" reject {s.get('constraint_id')}: not exactly 1 correct option") continue if not all(_names(o, s.get("safe_examples") if o.get("is_correct") else s.get("unsafe_examples")) for o in opts): print(f" reject {s.get('constraint_id')}: an option is not one of the seed's safe or " f"unsafe drugs") continue qid = f"{PATIENT}/axis{AXIS}/{len(candidates):03d}" opts = shuffled(opts, qid) safe = next(o for o in opts if o.get("is_correct")) candidates.append({ "qid": qid, "patient_id": PATIENT, "axis": "3_drug_safety", "question": q.get("question"), "options": opts, "answer": safe.get("content"), "answer_type": "multiple_choice", "grounding": "record", "constraint_id": s.get("constraint_id"), "evidence_visits": [gt_visit] if gt_visit else [], "gt_visit": gt_visit, "gt_date": gt_date, "question_appearance_window": {"from": gt_date, "to": plast}, "derivation": { "evidence": [{"visit": gt_visit, "date": gt_date, "field": "evidence_chain", "atom": s.get("constraint_text")}], "steps": [f"Standing constraint: {s.get('constraint_text')!r}.", f"Of the 4 options only {safe.get('content')!r} is appropriate given it.", safe.get("explanation", "")], "gold": safe.get("content"), "rivals": [{"value": o.get("content"), "why_wrong": o.get("explanation")} for o in opts if not o.get("is_correct")], "operands": {"implicated_class": s.get("implicated_class"), "constraint_text": s.get("constraint_text"), "first_line_trap": s.get("first_line_trap")}, "derived": False}, "provenance": f"LLM-discovered constraint ({ENGINE}); appropriate/inappropriate split " f"turns on recalling {s.get('constraint_id')}", "trap_design": q.get("trap_design"), "axis_specific": {"constraint_id": s.get("constraint_id"), "required_patient_info": s.get("required_patient_info")}, }) pg.tick(ok=True) out = {"axis": AXIS, "patient_id": PATIENT, "n_record": len(candidates), "candidates": candidates} Path(f"{BASE}/_discover_axis{AXIS}.json").write_text(json.dumps(out, ensure_ascii=False, indent=1)) print(f"\npatient {PATIENT} engine={ENGINE}") print(f" drug/constraint timelines scanned : {len(drug_tl)}") print(f" constraints discovered (LLM) : {len(seeds)}") print(f" MC questions built : {len(candidates)}") for c in candidates[:3]: print(f" [{c['constraint_id']}] {str(c['question'])[:70]} -> {c['answer']}") print(f" CANDIDATES: {len(candidates)} -> {BASE}/_discover_axis{AXIS}.json") def shuffled(options, qid): """The options in an order seeded by the question id, relabelled A, B, C, ... in that order.""" out = [dict(o) for o in options] random.Random(qid).shuffle(out) for k, o in enumerate(out): o["label"] = chr(ord("A") + k) return out def _names(option, drugs): """Whether an option names one of the drugs (case-insensitive; either text may contain the other).""" text = str(option.get("content") or "").strip().lower() return bool(text) and any(d and (d in text or text in d) for d in (str(x).strip().lower() for x in drugs or [])) def _anchor(seed, tl, chain): """Anchor a constraint at the earliest visit of its source entity's timeline; (None, None) when the entity is not in the evidence chain.""" ent = seed.get("source_entity") for t in tl: if t.get("entity") == ent and t.get("segments"): vf = (t["segments"][0].get("valid_from") or {}) if vf.get("visit_number") in chain: return vf["visit_number"], vf.get("date") return None, None def synthesize(cli, chain, by_v, forbidden, n, batch): """Author a standing drug constraint with four options; the gold is the safe member (options[].is_correct).""" out, vs = [], sorted(by_v) for i in range(n): host = vs[int(len(vs) * (0.15 + 0.4 * i / max(1, n)))] dept = by_v[host]["department"] host_date = by_v[host]["date"] patient, context = patient_line(), patient_context(chain) forbidden_list = "\n".join(f"- {e}" for e in forbidden[:50]) r = cli.complete_json( "You write one drug-safety multiple-choice question for a medical-memory benchmark. Return only JSON.", f"{patient}\n{context}\n" f"Visit: v{host} · {host_date} · {dept}\n\n" "Write a standing drug constraint, such as an allergy, an intolerance or an interaction, that\n" "makes three drugs a clinician might choose unsafe for this patient while one clinically\n" "reasonable alternative stays safe. Then write four options: the three unsafe drugs and the safe\n" "one. A model without memory must be drawn to an unsafe first-line choice.\n\n" "Do not name, describe or hint at the constraint in the question or the options; the model must\n" "recall it from memory. Never use the words \"safe\", \"unsafe\", \"risk\", \"dangerous\" or \"avoid\" in\n" "the question or the option text; the options are plain drug names.\n\n" "Do not use these drugs or classes, which existing answer keys already track:\n" + forbidden_list + "\n\nReturn JSON in this form:\n" '{"atom_text": "",\n' ' "entity": "",\n' ' "topic": "",\n' ' "question": "",\n' ' "options": [{"label": "A", "content": "", "is_correct": false,\n' ' "explanation": ""},\n' " ...]}") if not (has_text(r) and has_text(r, "atom_text") and has_text(r, "entity") and r.get("options")): print(f" axis3[{i}] rejected: the reply lacks the question, its options, the fact or its entity") continue opts = r["options"] if not (isinstance(opts, list) and all(isinstance(o, dict) and has_text(o, "content") for o in opts)): print(f" axis3[{i}] rejected: an option is not an object with a drug name") continue if lists_options(r["question"], opts): print(f" axis3[{i}] rejected: the question text lists the options") continue n_correct = sum(1 for o in opts if o.get("is_correct")) if n_correct != 1: print(f" axis3[{i}] rejected: {n_correct} correct options, need exactly 1") continue qid = f"{PATIENT}/axis3/S{len(out):03d}" opts = shuffled(opts, qid) safe = next(o for o in opts if o.get("is_correct")) atom = {"atom_id": f"{batch}/{len(out):03d}", "batch_id": batch, "axis": "3", "visit_number": host, "date": by_v[host]["date"], "department": dept, "field": "doctor_extra_notes", "text": r["atom_text"], "entities": [r["entity"]], "topic": r.get("topic"), "anchors_axis": "3", "anchors_on": []} out.append({"atom": atom, "q": { "qid": qid, "patient_id": PATIENT, "axis": "3_drug_safety", "question": r["question"], "options": opts, "answer": safe["content"], "answer_type": "multiple_choice", "grounding": f"synthetic:{batch}", "evidence_visits": [host], "gt_visit": host, "gt_date": by_v[host]["date"], "question_appearance_window": {"from": by_v[host]["date"], "to": by_v[vs[-1]]["date"]}, "derivation": {"evidence": [{"visit": host, "date": by_v[host]["date"], "field": "doctor_extra_notes", "atom": r["atom_text"]}], "steps": [f"Standing constraint recorded v{host}: {r['atom_text']!r}.", f"Of the four options only {safe['content']} is compatible with it.", f"{safe.get('explanation') or ''}"], "gold": safe["content"], "rivals": [{"value": o.get("content"), "why_wrong": o.get("explanation")} for o in opts if not o.get("is_correct")], "operands": {"constraint_text": r["atom_text"], "constraint_id": r.get("topic")}, "derived": False}, "provenance": f"synthetic drug-safety constraint ({batch}); only the recalled constraint " f"distinguishes the safe member", "axis_specific": {"constraint_id": r.get("topic"), "depends_on_atoms": [atom["atom_id"]]}, }}) return out if __name__ == "__main__": discover()