#!/usr/bin/env python3 """Stage F: two-agent role-play dialogue generation over the event chain. Per visit, the doctor agent sees the visit's atoms (flagged NEW or CARRIED), up to two prior visits in the same department and a checklist of atoms to convey; the patient agent sees its own complaints, prior same-department visits and an optional cross-department recap. The agents alternate as separate LLM calls sharing the transcript until every atom is conveyed or the turn limit is reached; short visits get a minimum length and every dialogue ends with a closing turn. The prompts are DIALOGUE_RULES, DOCTOR_SYSTEM and PATIENT_SYSTEM in pipeline.prompts. generate --all --workers N --out dialogues.jsonl count-empty number of visits whose dialogue has no turns """ from __future__ import annotations import argparse import json import os import re import threading import unicodedata from concurrent.futures import ThreadPoolExecutor from pathlib import Path from pipeline import prompts from pipeline.common import BASE, FIELDS, model from pipeline.llm import ChatClient DATA = BASE MODEL = model("dialogue") SHARED = prompts.DIALOGUE_RULES DOC_SYS = SHARED + "\n\n" + prompts.DOCTOR_SYSTEM PAT_SYS = SHARED + "\n\n" + prompts.PATIENT_SYSTEM _STOP = {"the", "of", "a", "an", "and", "or", "to", "with", "for", "no", "on", "at", "in", "by", "is", "was", "are", "s", "p", "his", "her", "you", "your"} _ALIASES = { "lbp": "low back pain", "oa": "osteoarthritis", "mmt": "muscle strength", "pt": "physical therapy", "ot": "occupational therapy", "st": "speech therapy", "nsaid": "painkiller", "vas": "pain score", "r/o": "rule out", "bil": "bilateral", "gerd": "reflux", "hivd": "disc herniation", "ar": "allergic rhinitis", "u/e": "upper limb", "ldl": "cholesterol", "ck": "muscle enzyme", "rom": "range of motion", "bw": "weight", "bp": "blood pressure", } SMALL_TALK_TOPICS = ("traffic", "waiting time", "the hospital being busy", "feeling mildly nervous", "coming from work", "the weather", "being worried while waiting for results") PATIENT_SMALL_TALK_TOPICS = SMALL_TALK_TOPICS + ("getting to the clinic",) _NUMBER = re.compile(r"\d+(?:\.\d+)?") _VITAL_NAMES = {"BP": ("bp", "blood pressure"), "pulse": ("pulse", "hr", "heart rate", "pr"), "weight_kg": ("bw", "weight", "wt"), "height_cm": ("height", "ht", "bh")} def norm(s: str) -> str: s = str(s).lower() for k, v in _ALIASES.items(): s = re.sub(rf"(? set: return {t for t in re.findall(r"[a-z0-9]+", norm(s)) if len(t) > 2 and t not in _STOP} def write_jsonl(path, records): with open(path, "w", encoding="utf-8") as fh: for r in records: fh.write(json.dumps(r, ensure_ascii=False) + "\n") return len(records) def drop_caseless_letters(s): """The text without letters that have no case (Unicode category Lo), with empty parentheses and repeated spaces removed.""" s = "".join(ch for ch in str(s) if unicodedata.category(ch) != "Lo") s = re.sub(r"\(\s*\)", "", s) return re.sub(r"\s{2,}", " ", re.sub(r"\s+\)", ")", re.sub(r"\(\s+", "(", s))).strip() recs, tags = {}, {} def load_chain(): """Read the patient's event chain and change tags.""" recs.update({r["visit_number"]: r for r in (json.loads(l) for l in open(DATA / "event_chain.jsonl"))}) tags.update({r["visit_number"]: r for r in (json.loads(l) for l in open(DATA / "tags.jsonl"))}) def atoms_of(rec): out = [] for f in FIELDS: for i, a in enumerate(rec.get(f) or []): out.append({"atom_id": f"v{rec['visit_number']}:{f}:{i}", "field": f, "text": drop_caseless_letters(a)}) for k, val in (rec.get("vitals") or {}).items(): if in_doctor_notes(rec, k, val): continue out.append({"atom_id": f"v{rec['visit_number']}:vitals:{k}", "field": "vitals", "text": f"{k}={val}"}) return [a for a in out if a["text"]] def in_doctor_notes(rec, key, val): """Whether a doctor_notes atom of the visit already records this vital sign: it names the sign and gives the same reading.""" names = _VITAL_NAMES.get(key, (key.lower(),)) value = re.compile(rf"(?= 0.7 and an <= pn for pt, pn in prior.get(a["field"], [])) else "NEW" def window_txt(vn): prev = tags.get(vn, {}).get("prev_visits", []) or [] if not prev: return " none: this is the patient's first visit to this department." out = [] for tag, pv in zip(("most recent", "one before"), reversed(prev[-2:])): if pv in recs: out.append(f" [{tag}: v{pv} {recs[pv]['date']}] " + " | ".join(a["text"] for a in atoms_of(recs[pv]))) return "\n".join(out) def doctor_blob(rec, atoms): department, date = drop_caseless_letters(rec["department"]), rec["date"] p = [f"You are a {department} physician. Today's date is {date}.", "\n: today's full record, one fact per line with its novelty flag. Put the atom " "id in covers."] for a in atoms: fl = "NEW " if a["novelty"] == "NEW" else "CARRIED" atom_id, field, text = a["atom_id"], a["field"], a["text"] p.append(f" {fl} | {atom_id} [{field}] {text}") p.append("\n: your previous visits in this department, the source for recaps.") p.append(window_txt(rec["visit_number"])) p.append("\n: every atom id above must be conveyed by the end of the visit.") return "\n".join(p) def patient_blob(rec, atoms): p = ["You are the patient. You do not know today's exam findings, results, diagnosis or plan until the doctor " "tells you.", "\n: your own complaints for today, in your own words. You may bring " "these up."] reasons = [(a["atom_id"], a["text"]) for a in atoms if a["field"] == "patient_self_description"] for atom_id, text in reasons: p.append(f" {atom_id} {text}") if not reasons: p.append(" none: a routine follow-up with no new complaint of your own") p.append("\n: your previous visits in this department. You lived through these.") p.append(window_txt(rec["visit_number"])) return "\n".join(p) def cross_dept_recap(rec): """The patient's most recent visit to another department, as the line of the patient's opening , or ''.""" cd = tags.get(rec["visit_number"], {}).get("prev_cross_dept_visit") if not (cd and cd in recs): return "" other_department, other_date = drop_caseless_letters(recs[cd]["department"]), recs[cd]["date"] return (f"\n: you recently saw a different department, {other_department}, on {other_date}. " "You may briefly mention that you went there; you know none of its findings or results.") def agent_turn(client, system, blob, transcript, steering): conversation = "\n".join(f"{x['speaker']}: {x['text']}" for x in transcript) or "(the visit is just beginning)" user = (f"{blob}\n\n=== Conversation so far ===\n{conversation}\n\n=== Your turn: one short spoken turn ===\n" f"{steering}") o = client.complete_json(system, user) if not isinstance(o, dict) or "text" not in o: return {"text": str(o)[:200], "covers": [], "type": "clinical", "end_ok": False} cov = o.get("covers") or [] return {"text": drop_caseless_letters(o.get("text", "")), "covers": [str(c) for c in cov] if isinstance(cov, list) else [], "type": str(o.get("type", "clinical")), "end_ok": bool(o.get("end_ok", False))} WRAP = " : this is the final wrap-up turn; you may set end_ok=true." NOWRAP = " : this is not the wrap-up turn; set end_ok=false." def generate_dialogue(vn, client): rec = recs[vn] atoms = atoms_of(rec) flag_novelty(vn, atoms) ids = {a["atom_id"] for a in atoms} a_by_id = {a["atom_id"]: a for a in atoms} n_new = sum(1 for a in atoms if a["novelty"] == "NEW") small_talk_budget = int(tags.get(vn, {}).get("noise_score", 0) or 0) turns_target = max(5, min(48, 2 + 2 * min(n_new, 6) + (len(atoms) - n_new + 3) // 4 + small_talk_budget)) d_blob, p_blob = doctor_blob(rec, atoms), patient_blob(rec, atoms) transcript, covered = [], set() small_talk_left = [small_talk_budget] used_topics = set() def small_talk_hint(patient=False): """The small-talk sentence of a turn: the speaker's next topic not yet used in the visit, starting at a position set by the visit number, while the visit's budget lasts.""" if small_talk_left[0] > 0: small_talk_left[0] -= 1 topics = PATIENT_SMALL_TALK_TOPICS if patient else SMALL_TALK_TOPICS order = [topics[(vn + k) % len(topics)] for k in range(len(topics))] t = next((x for x in order if x not in used_topics), order[0]) used_topics.add(t) return (f" You may add one brief bit of small talk about {t}, an allowed topic. Keep it natural, and do " "not repeat a small-talk topic already used.") return " No small talk this turn." def add(speaker, turn): covered.update(c for c in turn["covers"] if c in ids) transcript.append({"speaker": speaker, "type": turn.get("type", "clinical"), "text": turn["text"], "covers": [c for c in turn["covers"] if c in ids]}) def uncovered(): u = [a for a in atoms if a["atom_id"] not in covered] return sorted(u, key=lambda a: (a["novelty"] != "NEW", a["field"] != "doctor_extra_notes")) def reasons(): return "; ".join(f"[{a['atom_id']}] {a['text']}" for a in uncovered() if a["field"] == "patient_self_description") def react(): """The patient's turn after a doctor's clinical turn.""" t = (": phase=react. React to what the doctor just said, with a lay reaction or a short " "question.") own = reasons() if own: t += f" If you have not said them yet, you may voice these reasons and list their ids in covers: {own}." add("Patient", agent_turn(client, PAT_SYS, p_blob, transcript, t + small_talk_hint(patient=True) + "\n" + NOWRAP)) def task(batch, phase, wrap=False): facts = "; ".join(f"[{a['atom_id']}] {a['text']}" for a in batch) t = f": phase={phase}." if facts: t += f" Convey only these facts in this turn, and list their ids in covers: {facts}." return t + small_talk_hint() + ("\n" + (WRAP if wrap else NOWRAP)) own = reasons() add("Patient", agent_turn(client, PAT_SYS, p_blob, transcript, ": phase=intake. Open the visit: say in your own everyday words why you are here today." + (f" Voice your reasons for today and list their ids in covers: {own}." if own else "") + small_talk_hint(patient=True) + cross_dept_recap(rec) + "\n" + NOWRAP)) add("Doctor", agent_turn(client, DOC_SYS, d_blob, transcript, task([a for a in uncovered() if a["field"] != "patient_self_description"][:1], "intake"))) guard = 0 while uncovered() and len(transcript) < turns_target + 8 and guard < 40: guard += 1 u = uncovered() batch = u[:1] if u[0]["novelty"] == "NEW" else u[:3] add("Doctor", agent_turn(client, DOC_SYS, d_blob, transcript, task(batch, "clinical"))) if uncovered(): react() n_recap = 0 while uncovered() and n_recap < 6: n_recap += 1 add("Doctor", agent_turn(client, DOC_SYS, d_blob, transcript, task(uncovered()[:5], "recap"))) while len(transcript) < 4: add("Doctor", agent_turn(client, DOC_SYS, d_blob, transcript, ": phase=plan. Briefly restate today's plan or next step in plain words, using only plans " "already in . Do not add new instructions, tests or follow-up intervals." + small_talk_hint() + "\n" + NOWRAP)) add("Doctor", agent_turn(client, DOC_SYS, d_blob, transcript, ": phase=closing. Wrap the visit up warmly in one short line, without new instructions, " "tests or follow-up intervals." + WRAP)) return {"visit_number": vn, "date": rec["date"], "department": rec["department"], "session_id": f"v{vn}", "turns": transcript, "meta": {"tag": tags.get(vn, {}).get("tag"), "noise_score": small_talk_budget, "turns_target": turns_target, "n_atoms": len(atoms), "n_new": n_new, "engine": MODEL, "atom_index": {a["atom_id"]: {"text": a["text"], "field": a["field"], "novelty": a["novelty"]} for a in atoms}, "coverage": {"ledger": len(ids), "covered": len(covered), "uncovered": [a_by_id[i]["atom_id"] for i in ids - covered]}}} def generate(args) -> None: """Generate the dialogues of the selected visits, resuming from any existing output.""" load_chain() vns = sorted(recs) if args.all else [int(x) for x in args.visits.split(",") if x.strip()] outpath = Path(args.out) if os.path.isabs(args.out) else DATA / args.out done = {} if outpath.exists() and not args.fresh: done = {r["visit_number"]: r for r in (json.loads(l) for l in open(outpath) if l.strip()) if r.get("turns")} todo = [v for v in vns if v not in done] print(f"visits: {len(vns)} total | already done {len(done)} | to generate {len(todo)}") client = ChatClient(model=MODEL, reasoning_effort="low", max_completion_tokens=800) lock = threading.Lock() n_ok = [0] n_err = [0] fh = open(outpath, "a", encoding="utf-8") def one(vn): try: r = generate_dialogue(vn, client) except Exception as e: r = {"visit_number": vn, "error": str(e)[:150], "turns": []} with lock: fh.write(json.dumps(r, ensure_ascii=False) + "\n") fh.flush() (n_ok if r.get("turns") else n_err)[0] += 1 if (n_ok[0] + n_err[0]) % 25 == 0: print(f" progress {n_ok[0]+n_err[0]}/{len(todo)} (ok {n_ok[0]}, err {n_err[0]})", flush=True) return r with ThreadPoolExecutor(max_workers=args.workers) as ex: list(ex.map(one, todo)) fh.close() allr = {r["visit_number"]: r for r in (json.loads(l) for l in open(outpath) if l.strip())} out = sorted(allr.values(), key=lambda r: r["visit_number"]) write_jsonl(outpath, out) ok = [r for r in out if r.get("turns")] err = [r for r in out if not r.get("turns")] tc = [len(r["turns"]) for r in ok] cov = [r["meta"]["coverage"] for r in ok if "meta" in r] print(f"\ndialogues: {len(ok)} ok, {len(err)} error/empty turns min/med/max = " f"{min(tc)}/{sorted(tc)[len(tc)//2]}/{max(tc)}") print(f"visits <5 turns: {sum(1 for t in tc if t < 5)}") print(f"atom coverage: {sum(c['covered'] for c in cov)}/{sum(c['ledger'] for c in cov)} " f"({100*sum(c['covered'] for c in cov)/max(1,sum(c['ledger'] for c in cov)):.1f}%)") if err: print("visits with errors (retried on the next run):", [r["visit_number"] for r in err][:30]) print(f"wrote {args.out}") def count_empty() -> None: """Print the number of visits whose dialogue has no turns.""" print(sum(1 for l in open(BASE / "dialogues.jsonl") if l.strip() and not json.loads(l).get("turns"))) def main(): ap = argparse.ArgumentParser(description="Stage F: dialogue generation.") sp = ap.add_subparsers(dest="cmd", required=True) g = sp.add_parser("generate") g.add_argument("--all", action="store_true") g.add_argument("--visits", default="") g.add_argument("--workers", type=int, default=12) g.add_argument("--out", default="dialogues.jsonl") g.add_argument("--fresh", action="store_true", help="ignore any existing output and regenerate all") sp.add_parser("count-empty") a = ap.parse_args() generate(a) if a.cmd == "generate" else count_empty() if __name__ == "__main__": main()