|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations |
|
|
import json |
|
|
import shutil |
|
|
from pathlib import Path |
|
|
from typing import Any, Dict, List, Optional |
|
|
|
|
|
from . import paths |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cases_dir() -> Path: |
|
|
"""Return path to the cases directory defined in paths.py (function or var).""" |
|
|
d = getattr(paths, "CASES_DIR", None) or getattr(paths, "cases_dir", None) |
|
|
|
|
|
|
|
|
if callable(d): |
|
|
d = d() |
|
|
|
|
|
|
|
|
if d is None: |
|
|
d = "data/cases" |
|
|
|
|
|
p = Path(d) |
|
|
p.mkdir(parents=True, exist_ok=True) |
|
|
return p |
|
|
|
|
|
|
|
|
def _case_path(case_id: str) -> Path: |
|
|
return _cases_dir() / f"{case_id}.json" |
|
|
|
|
|
|
|
|
def list_cases() -> List[Dict[str, Any]]: |
|
|
"""List all cases with basic metadata for selector.""" |
|
|
out: List[Dict[str, Any]] = [] |
|
|
for f in sorted(_cases_dir().glob("*.json")): |
|
|
try: |
|
|
data = json.loads(f.read_text(encoding="utf-8")) |
|
|
out.append({ |
|
|
"case_id": data.get("case_id"), |
|
|
"patient_name": data.get("patient", {}).get("name", ""), |
|
|
"status": data.get("status", "draft"), |
|
|
"specialty": data.get("consult", {}).get("specialty", ""), |
|
|
}) |
|
|
except Exception: |
|
|
continue |
|
|
return out |
|
|
|
|
|
|
|
|
def read_case(case_id: str) -> Optional[Dict[str, Any]]: |
|
|
p = _case_path(case_id) |
|
|
if not p.exists(): |
|
|
return None |
|
|
try: |
|
|
return json.loads(p.read_text(encoding="utf-8")) |
|
|
except Exception: |
|
|
return None |
|
|
|
|
|
|
|
|
def write_case(case: Dict[str, Any]) -> None: |
|
|
"""Persist one case dict to JSON (overwrite safe).""" |
|
|
cid = case.get("case_id") |
|
|
if not cid: |
|
|
raise ValueError("Case missing 'case_id'") |
|
|
p = _case_path(cid) |
|
|
p.write_text(json.dumps(case, indent=2, ensure_ascii=False), encoding="utf-8") |
|
|
|
|
|
|
|
|
def update_case(case_id: str, patch: Dict[str, Any]) -> None: |
|
|
"""Merge patch into existing case and save.""" |
|
|
existing = read_case(case_id) or {} |
|
|
for k, v in patch.items(): |
|
|
if isinstance(v, dict) and isinstance(existing.get(k), dict): |
|
|
existing[k].update(v) |
|
|
else: |
|
|
existing[k] = v |
|
|
write_case(existing) |
|
|
|
|
|
|
|
|
def set_status(case_id: str, status: str) -> None: |
|
|
c = read_case(case_id) |
|
|
if not c: |
|
|
return |
|
|
c["status"] = status |
|
|
write_case(c) |
|
|
|
|
|
|
|
|
def new_case_id() -> str: |
|
|
"""Generate next incremental EC-### style ID.""" |
|
|
existing = [f.stem for f in _cases_dir().glob("*.json") if f.stem.startswith("EC-")] |
|
|
nums = [int(x.split("-")[1]) for x in existing if len(x.split("-")) > 1 and x.split("-")[1].isdigit()] |
|
|
nxt = max(nums) + 1 if nums else 3 |
|
|
return f"EC-{nxt:03d}" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def seed_cases(reset: bool = False) -> None: |
|
|
""" |
|
|
Create baseline demo cases (clears directory if reset=True). |
|
|
Adds 3 extra PCP draft cardiology cases for heart failure. |
|
|
""" |
|
|
cases_dir = _cases_dir() |
|
|
if reset: |
|
|
shutil.rmtree(cases_dir, ignore_errors=True) |
|
|
cases_dir.mkdir(parents=True, exist_ok=True) |
|
|
|
|
|
base_cases = [ |
|
|
{ |
|
|
"case_id": "EC-001", |
|
|
"status": "draft", |
|
|
"patient": {"name": "John Doe", "age": 68, "sex": "M"}, |
|
|
"consult": { |
|
|
"specialty": "Cardiology", |
|
|
"question": "Medication optimization for stable ischemic heart disease.", |
|
|
"summary": "Stable angina and hypertension. On beta-blocker and ACE inhibitor.", |
|
|
"medications": "Metoprolol, Lisinopril, ASA 81 mg.", |
|
|
"labs": "Cr 1.0 mg/dL, eGFR 70, LDL 88.", |
|
|
"consent_obtained": True, |
|
|
}, |
|
|
"soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""}, |
|
|
"billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False}, |
|
|
"explainability": {"baseline": {"assessment_hash": "", "plan_hash": ""}, "guideline_rationale": []}, |
|
|
}, |
|
|
{ |
|
|
"case_id": "EC-002", |
|
|
"status": "draft", |
|
|
"patient": {"name": "Jane Smith", "age": 72, "sex": "F"}, |
|
|
"consult": { |
|
|
"specialty": "Endocrinology", |
|
|
"question": "Diabetes control on basal/bolus insulin.", |
|
|
"summary": "A1c 8.2%. Recurrent nocturnal hypoglycemia.", |
|
|
"medications": "Insulin glargine 20 U HS, Lispro with meals.", |
|
|
"labs": "Cr 0.9 mg/dL, eGFR 75.", |
|
|
"consent_obtained": True, |
|
|
}, |
|
|
"soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""}, |
|
|
"billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False}, |
|
|
"explainability": {"baseline": {"assessment_hash": "", "plan_hash": ""}, "guideline_rationale": []}, |
|
|
}, |
|
|
] |
|
|
|
|
|
hf_cases = [ |
|
|
{ |
|
|
"case_id": "HF-101", |
|
|
"status": "draft", |
|
|
"patient": {"name": "Robert Miller", "age": 74, "sex": "M"}, |
|
|
"consult": { |
|
|
"specialty": "Cardiology", |
|
|
"question": "Optimize GDMT for HFrEF (EF 30%).", |
|
|
"summary": ( |
|
|
"Stable NYHA III HFrEF (EF 30%) post-MI. BP 110/70 mmHg, HR 68. " |
|
|
"Mild exertional dyspnea, no edema. On carvedilol 12.5 mg BID, " |
|
|
"lisinopril 20 mg QD, furosemide 20 mg PRN." |
|
|
), |
|
|
"medications": "Carvedilol 12.5 mg BID, Lisinopril 20 mg QD, Furosemide 20 mg PRN.", |
|
|
"labs": "Na 138, K 4.6, Cr 1.1, BNP 420 pg/mL.", |
|
|
"consent_obtained": True, |
|
|
}, |
|
|
"soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""}, |
|
|
"billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False}, |
|
|
"explainability": {"baseline": {"assessment_hash": "", "plan_hash": ""}, "guideline_rationale": []}, |
|
|
}, |
|
|
{ |
|
|
"case_id": "HF-102", |
|
|
"status": "draft", |
|
|
"patient": {"name": "Linda Chen", "age": 69, "sex": "F"}, |
|
|
"consult": { |
|
|
"specialty": "Cardiology", |
|
|
"question": "Evaluate possible HFpEF with exertional dyspnea.", |
|
|
"summary": ( |
|
|
"Progressive exertional SOB, preserved EF 60%. Mild concentric LVH on echo. " |
|
|
"BNP 180 pg/mL. Considering SGLT2 inhibitor initiation." |
|
|
), |
|
|
"medications": "Amlodipine 5 mg QD, HCTZ 12.5 mg QD.", |
|
|
"labs": "Na 140, K 4.2, Cr 0.9, BNP 180 pg/mL.", |
|
|
"consent_obtained": True, |
|
|
}, |
|
|
"soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""}, |
|
|
"billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False}, |
|
|
"explainability": {"baseline": {"assessment_hash": "", "plan_hash": ""}, "guideline_rationale": []}, |
|
|
}, |
|
|
{ |
|
|
"case_id": "HF-103", |
|
|
"status": "draft", |
|
|
"patient": {"name": "Carlos Ramirez", "age": 81, "sex": "M"}, |
|
|
"consult": { |
|
|
"specialty": "Cardiology", |
|
|
"question": "Diuretic titration for volume overload in chronic HFrEF.", |
|
|
"summary": ( |
|
|
"Chronic HFrEF (EF 25%) with mild lower-extremity edema and 3 lb weight gain. " |
|
|
"Currently on furosemide 40 mg QD and spironolactone 25 mg QD." |
|
|
), |
|
|
"medications": "Furosemide 40 mg QD, Spironolactone 25 mg QD, Metoprolol 50 mg BID.", |
|
|
"labs": "Na 136, K 4.8, Cr 1.4, BNP 520 pg/mL.", |
|
|
"consent_obtained": True, |
|
|
}, |
|
|
"soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""}, |
|
|
"billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False}, |
|
|
"explainability": {"baseline": {"assessment_hash": "", "plan_hash": ""}, "guideline_rationale": []}, |
|
|
}, |
|
|
] |
|
|
|
|
|
all_cases = base_cases + hf_cases |
|
|
for c in all_cases: |
|
|
path = _case_path(c["case_id"]) |
|
|
if not path.exists(): |
|
|
path.write_text(json.dumps(c, indent=2, ensure_ascii=False), encoding="utf-8") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|