#!/usr/bin/env python3 """Axis 4 (entity scoping): LLM discovery, then question generation. Stage 1: the LLM reads candidate atoms and resolves who a condition belongs to (the patient, a named relative or a household contact) and whether a model without memory could answer. Stage 2: the axis-4 rules (pipeline.prompts) write the question and gold. python -m pipeline.questions.axis4 discovery -> _discover_axis4.json """ from __future__ import annotations import json import re from collections import Counter from pathlib import Path from pipeline import prompts from pipeline.common import BASE, Progress, model, patient_id from pipeline.llm import ChatClient, subst PATIENT = patient_id() ENGINE = model("generation") AXIS = 4 SCAN_FIELDS = ["diagnosis", "patient_self_description", "doctor_notes", "doctor_extra_notes"] DISCOVER_SYS = ( "You read one patient's chart and find facts whose owner is ambiguous or not obvious: a condition or\n" "event that belongs to a relative, such as a mother, father, son, daughter, wife, husband or\n" "sibling; a household-contact or contagion event; or the patient's own condition that a reader could\n" "take for someone else's. These facts test whether a memory method attributes a fact to the right\n" "person. Return only JSON.") RELATIVE = re.compile(r"(?i)\b(?:mother|father|mom|dad|parents?|sisters?|brothers?|siblings?|sons?|daughters?" r"|child(?:ren)?|wife|husband|spouse|family|fhx|aunt|uncle|grand\w*|niece|nephew|cousin" r"|household|contact)\b") HISTORY = re.compile(r"(?i)^\s*(?:history of|hx of|h/o|known)\b") ABBREVIATIONS = { "htn": "hypertension", "dm": "diabetes", "t2dm": "type 2 diabetes", "t1dm": "type 1 diabetes", "cad": "coronary artery disease", "chf": "heart failure", "hf": "heart failure", "mi": "myocardial infarction", "af": "atrial fibrillation", "afib": "atrial fibrillation", "cva": "stroke", "ckd": "chronic kidney disease", "esrd": "end-stage renal disease", "copd": "chronic obstructive pulmonary disease", "tb": "tuberculosis", "hbv": "hepatitis b", "hcv": "hepatitis c", "gerd": "reflux", "oa": "osteoarthritis", "ra": "rheumatoid arthritis", "bph": "benign prostatic hyperplasia", "uti": "urinary tract infection", "pud": "peptic ulcer", } def expand(text): """The lower-cased text followed by the full names of the abbreviations it contains.""" t = str(text or "").lower() extra = [full for ab, full in ABBREVIATIONS.items() if re.search(rf"(?= 4 else [] names += [full for ab, full in ABBREVIATIONS.items() if re.search(rf"(?,\n' ' "kind": "",\n' ' "owner": "",\n' ' "entity": "",\n' ' "atom_verbatim": "",\n' ' "why_memory_required": ""\n' "}]}") try: found += cli.complete_json(DISCOVER_SYS, usr).get("scopings") or [] except Exception as e: print(f" discover chunk FAILED: {str(e)[:100]}") return found def discover() -> None: """Discover the axis-4 candidates of this patient -> _discover_axis4.json.""" cli = ChatClient(model=ENGINE, reasoning_effort="medium", max_completion_tokens=6000) chain = [json.loads(l) for l in open(f"{BASE}/event_chain.jsonl") if l.strip()] by_v = {r["visit_number"]: r for r in chain} plast = max(r["date"] for r in chain) with Progress("axis4 discover (LLM)", total=1, out=f"{BASE}/progress_axis4_discover.json") as pg: scopings = discover_scopings(cli, by_v) pg.tick(ok=bool(scopings)) AX4 = subst(prompts.AXIS4_RULES) COMMON = subst(prompts.AXIS4_COMMON) system = subst(f"{COMMON}\n\n{AX4}") candidates = [] seen = set() own = own_diagnoses(chain) for s in scopings: rv = s.get("visit") if rv not in by_v: continue k = (str(s.get("owner")).lower().strip(), str(s.get("entity")).lower().strip()[:40]) if k in seen: continue seen.add(k) own_case = patient_owned(s) also = None if own_case else patient_has(s.get("entity"), own) seed = {"kind": s.get("kind"), "owner": s.get("owner"), "entity": s.get("entity"), "atom_verbatim": s.get("atom_verbatim"), "why_memory_required": s.get("why_memory_required")} if also: seed["patient_also_has"] = also[0] fact_json = json.dumps(seed, ensure_ascii=False) usr = ( "Write one entity-attribution question for this seed, following the axis-4 rules above. The\n" "question must not reveal the owner; the gold answer states it. Return only JSON:\n" '{"question": "", "answer": ""}\n\n' "Seed:\n" + fact_json) try: q = cli.complete_json(system, usr) except Exception as e: print(f" gen FAILED: {str(e)[:100]}") continue if not q.get("question") or not q.get("answer"): continue if str(q["answer"]).lstrip().startswith("Yes") != (own_case or bool(also)): print(f" reject {s.get('entity')!r}: the gold's yes/no does not match the resolved owner") continue evidence = [{"visit": rv, "date": by_v[rv]["date"], "field": field_of(by_v[rv], s.get("atom_verbatim")), "atom": s.get("atom_verbatim")}] owner_step = f"Owner resolved: {s.get('owner')} (kind={s.get('kind')})." ev_visits = [rv] if also: patom, pv, pfield = also if pv != rv: ev_visits.append(pv) evidence.append({"visit": pv, "date": by_v[pv]["date"] if pv in by_v else None, "field": pfield, "atom": patom}) owner_step = (f"The fact at v{rv} is {s.get('owner')}'s; the patient's own {s.get('entity')} is " f"documented at v{pv}.") gv = max(v for v in ev_visits if v in by_v) candidates.append({ "qid": f"{PATIENT}/axis{AXIS}/{len(candidates):03d}", "patient_id": PATIENT, "axis": "4_entity_scoping", "question": q["question"], "answer": q["answer"], "answer_type": "open_exact", "grounding": "record", "evidence_visits": ev_visits, "gt_visit": gv, "gt_date": by_v[gv]["date"], "question_appearance_window": {"from": by_v[gv]["date"], "to": plast}, "derivation": { "evidence": evidence, "steps": [f"Atom at v{rv}: {s.get('atom_verbatim')!r}.", owner_step, f"{s.get('why_memory_required')}"], "gold": q["answer"], "rivals": [], "operands": {"owner": s.get("owner"), "entity": s.get("entity"), "kind": s.get("kind"), "patient_also_has": also[0] if also else None}, "derived": False}, "provenance": f"LLM-resolved scoping ({ENGINE}); owner={s.get('owner')}", "axis_specific": {"kind": s.get("kind"), "owner": s.get("owner"), "entity": s.get("entity")}, }) 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" scopings discovered (LLM): {len(scopings)} by kind: {dict(Counter(s.get('kind') for s in scopings))}") print(f" CANDIDATES: {len(candidates)} -> {BASE}/_discover_axis{AXIS}.json") for c in candidates[:3]: print(f" Q: {c['question'][:70]} A: {str(c['answer'])[:40]}") if __name__ == "__main__": discover()