clinical-agent-memory-bench's picture
Upload 99 files
750b88d verified
Raw History Blame Contribute Delete
9.98 kB
#!/usr/bin/env python3
"""Stage A, preprocessing: one patient's synthetic timeline -> per-visit records (deterministic).
export visit table -> ALL_visits.txt (term glossary applied)
parse ALL_visits.txt -> visits.jsonl
split visits.jsonl -> visits/v*.json + visit_manifest.json (the extraction manifest)
vitals visit table -> vitals.json + vitals_timeline.json
facts patient.env: the per-patient values the prompts are filled with
"""
from __future__ import annotations
import argparse
import json
import re
import shlex
import sys
import unicodedata
import pandas as pd
from pipeline import common
from pipeline.common import BASE, VISIT_TABLE, patient_id
PATIENT = patient_id()
PID, DEPT, ISO, VT = "record_id", "department", "visit_date", "visit_type"
S, O, A, P = "subjective", "objective", "assessment", "plan"
ICDS = ["icd_1", "icd_2", "icd_3", "icd_4", "icd_5", "icd_6"]
def caseless_letters(text):
"""The number of letters without case (Unicode category Lo) in `text`."""
return sum(1 for ch in text if unicodedata.category(ch) == "Lo")
def norm(x):
"""Collapse all whitespace (incl. embedded newlines) to single spaces; strip."""
return " ".join(str(x).split())
def build_raw_text(df, patient):
sub = df[df[PID] == patient].sort_values([ISO, "visit_no"])
lines = []
for _, r in sub.iterrows():
icds = [str(r[c]).strip() for c in ICDS if str(r[c]).strip()]
header = f"### {r[ISO]} | {norm(r[DEPT])} | {VT}:{norm(r[VT])} | ICD:[{','.join(icds)}]"
lines.append(header)
lines.append(f"S: {norm(r[S])}")
lines.append(f"O: {norm(r[O])}")
lines.append(f"A: {norm(r[A])}")
lines.append(f"P: {norm(r[P])}")
return "\n".join(lines) + "\n", len(sub)
def export_visits(patient: int, out_path: str) -> None:
"""Visit table rows of one patient -> all-visits file.
One block per visit (header line plus S/O/A/P); the term glossary turns every term into
"normalized form (term)".
"""
df = pd.read_parquet(VISIT_TABLE)
for c in [S, O, A, P, DEPT, VT] + ICDS:
df[c] = df[c].fillna("")
raw, n = build_raw_text(df, patient)
g = common.load_glossary()
pat = common.glossary_re(g)
out = common.apply_glossary(raw, g, pat)
left = caseless_letters(re.sub(r"\([^()]*\)", "", out))
open(out_path, "w", encoding="utf-8").write(out)
print(f"patient={patient} visits={n} lines={out.count(chr(10))} caseless_letters_outside_parens={left}")
print(f"wrote {out_path}")
HEADER_RE = re.compile(
r"^###\s+(\d{4}-\d{2}-\d{2})\s+\|\s+(.+?)\s+\|\s+visit_type:([^|]*?)\s+\|\s+ICD:\[(.*?)\]\s*$"
)
SOAP_RE = re.compile(r"^([SOAP]):\s?(.*)$")
def dept_key(dept):
base = re.sub(r"\s*\(.*?\)\s*", "", dept).strip().lower()
return re.sub(r"[^a-z0-9]+", "_", base).strip("_")
def parse(path):
visits = []
cur = None
field = None
for line in open(path, encoding="utf-8"):
line = line.rstrip("\n")
m = HEADER_RE.match(line)
if m:
if cur:
visits.append(cur)
date, dept, vt, icd = m.groups()
codes = [c.strip() for c in icd.split(",") if c.strip()]
cur = {
"visit_number": len(visits) + 1,
"date": date,
"department": dept,
"department_key": dept_key(dept),
"visit_type": vt,
"icd": codes,
"soap": {"S": "", "O": "", "A": "", "P": ""},
}
field = None
continue
sm = SOAP_RE.match(line)
if sm and cur is not None:
field = sm.group(1)
cur["soap"][field] = sm.group(2)
elif cur is not None and field:
cur["soap"][field] += " " + line
if cur:
visits.append(cur)
return visits
def parse_visits(inp: str, out: str) -> None:
"""ALL_visits.txt -> deterministic per-visit JSONL records."""
visits = parse(inp)
with open(out, "w", encoding="utf-8") as f:
for v in visits:
f.write(json.dumps(v, ensure_ascii=False) + "\n")
depts = {}
for v in visits:
depts[v["department"]] = depts.get(v["department"], 0) + 1
print(f"parsed {len(visits)} visits -> {out}")
print("departments:", json.dumps(depts, ensure_ascii=False))
print("first record:", json.dumps(visits[0], ensure_ascii=False)[:300])
def split() -> None:
"""Split visits.jsonl into one file per visit and write the extraction manifest (absolute paths)."""
BASE = common.BASE.resolve()
src = BASE / "visits.jsonl"
if not src.exists():
raise SystemExit(f"FATAL: {src} not found; run the parse step first")
rows = [json.loads(l) for l in open(src, encoding="utf-8") if l.strip()]
if not rows:
raise SystemExit(f"FATAL: {src} is empty")
out = BASE / "visits"
out.mkdir(parents=True, exist_ok=True)
vn = [r["visit_number"] for r in rows]
if len(set(vn)) != len(vn):
raise SystemExit(f"FATAL: duplicate visit_number in {src}; numbering is the join key for the "
f"whole pipeline (vitals, chain, questions all index by it)")
manifest = []
for r in sorted(rows, key=lambda x: x["visit_number"]):
p = out / f"v{r['visit_number']}.json"
p.write_text(json.dumps(r, ensure_ascii=False, indent=1), encoding="utf-8")
manifest.append({"visit_number": r["visit_number"], "path": str(p)})
(BASE / "visit_manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
print(f"patient {PATIENT}: {len(manifest)} visits -> {out}/v*.json")
print(f" v{min(vn)}..v{max(vn)} ({min(r['date'] for r in rows)} .. {max(r['date'] for r in rows)})")
print(f"-> {BASE/'visit_manifest.json'}")
COLS = {"weight": "weight_kg", "height": "height_cm", "blood_pressure": "BP", "pulse": "pulse"}
def clean(x):
x = str(x).strip()
return x if x not in ("", "-", "nan", "None", "NaN") else None
def vitals() -> None:
"""Structured vitals (height, weight, blood pressure, pulse) from the visit table.
Visits are numbered by the same (date, visit_no) sort as visits.jsonl, and the row count is checked
against visits.jsonl. Writes vitals.json and vitals_timeline.json.
"""
df = pd.read_parquet(VISIT_TABLE)
sub = (df[df["record_id"] == PATIENT]
.sort_values(["visit_date", "visit_no"])
.reset_index(drop=True))
if sub.empty:
sys.exit(f"FATAL: patient {PATIENT} not in {VISIT_TABLE}")
sub["vn"] = range(1, len(sub) + 1)
vpath = BASE / "visits.jsonl"
if vpath.exists():
visits = {json.loads(l)["visit_number"]: json.loads(l) for l in open(vpath)}
if len(visits) != len(sub):
sys.exit(f"FATAL: visit-count mismatch; the visit table has {len(sub)} rows, "
f"{vpath} has {len(visits)}. Numbering would drift; refusing to write.")
bad = [int(r["vn"]) for _, r in sub.iterrows()
if visits[int(r["vn"])]["date"] != str(r["visit_date"])[:10]]
if bad:
sys.exit(f"FATAL: date misalignment on {len(bad)} visits (first: {bad[:5]}). "
f"visit-table sort != visits.jsonl sort; refusing to write.")
else:
print(f" note: {vpath} absent; skipping the alignment check")
vit = {}
for _, r in sub.iterrows():
v = {name: clean(r[col]) for col, name in COLS.items() if clean(r[col])}
if v:
vit[int(r["vn"])] = v
(BASE / "vitals.json").write_text(
json.dumps({str(k): v for k, v in sorted(vit.items())}, ensure_ascii=False, indent=1))
timelines: dict = {}
for vn in sorted(vit):
date = str(sub.loc[sub["vn"] == vn, "visit_date"].iloc[0])[:10]
for measure, val in vit[vn].items():
timelines.setdefault(measure, []).append(
{"visit_number": vn, "date": date, "value": val})
(BASE / "vitals_timeline.json").write_text(json.dumps(timelines, ensure_ascii=False, indent=1))
print(f"patient {PATIENT}: {len(sub)} visits, {len(vit)} with >=1 structured vital")
for m, series in timelines.items():
vals = [s["value"] for s in series]
print(f" {m:<10} {len(series):>3} points {series[0]['date']}..{series[-1]['date']} "
f"{vals[:6]}{'...' if len(vals) > 6 else ''}")
if not timelines:
print(" no structured vitals")
print(f"-> {BASE/'vitals.json'}\n-> {BASE/'vitals_timeline.json'}")
def facts(pdesc: str) -> None:
"""Write patient.env: visit count, first and last visit date, and the clinical descriptor."""
W = BASE
v = [json.loads(l) for l in open(f"{W}/visits.jsonl")]
env = {"MEMEBENCH_WORK": W, "MEMEBENCH_PATIENT": PATIENT, "MEMEBENCH_PDESC": pdesc or common.DEFAULT_PDESC,
"MEMEBENCH_NVISITS": len(v), "MEMEBENCH_PFIRST": min(x["date"] for x in v),
"MEMEBENCH_PLAST": max(x["date"] for x in v)}
open(f"{W}/patient.env", "w").write("".join(f"export {k}={shlex.quote(str(x))}\n" for k, x in env.items()))
print(f" patient.env: {len(v)} visits")
def main():
ap = argparse.ArgumentParser(description="Stage A: preprocess one patient's synthetic timeline.")
sp = ap.add_subparsers(dest="cmd", required=True)
sp.add_parser("export").add_argument("--out", required=True)
p = sp.add_parser("parse")
p.add_argument("--inp", default="ALL_visits.txt")
p.add_argument("--out", default="visits.jsonl")
sp.add_parser("split")
sp.add_parser("vitals")
sp.add_parser("facts").add_argument("--pdesc", default="")
a = ap.parse_args()
if a.cmd == "export":
export_visits(PATIENT, a.out)
elif a.cmd == "parse":
parse_visits(a.inp, a.out)
elif a.cmd == "split":
split()
elif a.cmd == "vitals":
vitals()
else:
facts(a.pdesc)
if __name__ == "__main__":
main()