Cardiosense-AG's picture
Update src/store.py
4e46ea8 verified
raw
history blame
10.4 kB
# src/store.py
from __future__ import annotations
"""Case persistence helpers (Schema v2) with legacy API compatibility.
Public API preserved from V1:
- new_case_id(), case_path(), read_case(), save_case(), create_case(), update_case(), list_cases()
Additions in V2:
- upgrade_to_v2(obj), set_status(case_id, status), touch(case_id)
- seed_cases(reset: bool = False)
"""
import json, time, string
from pathlib import Path
from typing import List, Dict, Optional, Any
from .paths import cases_dir
from .config import SCHEMA_VERSION
# --------------------------- utils ---------------------------
def _now_iso() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%S")
def _rand(n=4) -> str:
import secrets
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(n))
def _deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
out = dict(a)
for k, v in b.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = _deep_merge(out[k], v) # type: ignore
else:
out[k] = v
return out
# --------------------------- paths ---------------------------
def case_path(case_id: str) -> Path:
d = cases_dir()
d.mkdir(parents=True, exist_ok=True)
return d / f"{case_id}.json"
def new_case_id() -> str:
return f"EC{_rand(6)}"
# ------------------------ migrations -------------------------
def _migrate_soup_to_soap(obj: Dict[str, Any]) -> Dict[str, Any]:
"""Ensure SOAP shape; migrate legacy 'soup' key to 'soap_draft'."""
if isinstance(obj, dict) and "soup" in obj and "soap_draft" not in obj:
soup = obj.pop("soup", {})
if isinstance(soup, dict):
obj["soap_draft"] = {
"subjective": soup.get("subjective", ""),
"objective": soup.get("objective", ""),
"assessment": soup.get("assessment", ""),
"plan": soup.get("plan", ""),
}
return obj
def _ensure_v2_fields(obj: Dict[str, Any]) -> Dict[str, Any]:
"""Add Schema v2 fields if missing."""
obj.setdefault("schema_version", SCHEMA_VERSION)
obj.setdefault("created_at", _now_iso())
obj.setdefault("updated_at", _now_iso())
obj.setdefault("status", "draft")
obj.setdefault("review", {"state": None, "notes": ""})
obj.setdefault("billing", {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False})
obj.setdefault("explainability", {"manually_modified": False})
obj.setdefault("patient", {"name": "", "age": None, "sex": ""})
obj.setdefault("consult", {"question": "", "history": "", "medications": "", "labs": ""})
obj.setdefault("soap_draft", {"subjective": "", "objective": "", "assessment": "", "plan": ""})
return obj
def upgrade_to_v2(obj: Dict[str, Any]) -> Dict[str, Any]:
"""Idempotent upgrader to Schema v2."""
if obj is None:
return obj
obj = _migrate_soup_to_soap(obj)
obj = _ensure_v2_fields(obj)
obj["schema_version"] = SCHEMA_VERSION
return obj
# ------------------------ core ops ---------------------------
def read_case(case_id: str) -> Optional[Dict[str, Any]]:
p = case_path(case_id)
if not p.exists():
return None
try:
data = json.loads(p.read_text(encoding='utf-8'))
except Exception:
return None
return upgrade_to_v2(data)
def save_case(obj: Dict[str, Any]) -> None:
obj = upgrade_to_v2(obj or {})
obj["updated_at"] = _now_iso()
p = case_path(obj["case_id"])
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding='utf-8')
def create_case(initial: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
case_id = (initial or {}).get("case_id") or new_case_id()
obj: Dict[str, Any] = {"case_id": case_id}
obj = _deep_merge(obj, initial or {})
obj = upgrade_to_v2(obj)
save_case(obj)
return obj
def update_case(case_id: str, patch: Dict[str, Any]) -> Optional[Dict[str, Any]]:
cur = read_case(case_id)
if cur is None:
return None
updated = _deep_merge(cur, patch)
save_case(updated)
return updated
def list_cases(status: Optional[str] = None) -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
d = cases_dir()
d.mkdir(parents=True, exist_ok=True)
for p in d.glob("*.json"):
try:
obj = json.loads(p.read_text(encoding='utf-8'))
obj = upgrade_to_v2(obj)
if (status is None) or (obj.get("status") == status):
items.append({
"case_id": obj.get("case_id"),
"status": obj.get("status"),
"created_at": obj.get("created_at"),
"updated_at": obj.get("updated_at"),
"patient_name": (obj.get("patient") or {}).get("name"),
"specialty": (obj.get("consult") or {}).get("specialty"),
"question": (obj.get("consult") or {}).get("question"),
})
except Exception:
continue
items.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
return items
def set_status(case_id: str, status: str) -> Optional[Dict[str, Any]]:
"""Set status with simple tri-state guard: draft -> submitted -> completed."""
allowed = ["draft", "submitted", "completed"]
if status not in allowed:
raise ValueError(f"Invalid status: {status}")
cur = read_case(case_id)
if cur is None:
return None
current = cur.get("status", "draft")
order = {s:i for i, s in enumerate(allowed)}
if order[status] < order.get(current, 0):
return cur # disallow backward moves
cur["status"] = status
save_case(cur)
return cur
def touch(case_id: str) -> None:
obj = read_case(case_id)
if obj:
obj["updated_at"] = _now_iso()
save_case(obj)
# ------------------------ seeding ----------------------------
def _seed_payloads() -> Dict[str, Dict[str, Any]]:
"""Construct seed cases as per spec: 3 Draft, 1 In Review (submitted)."""
seeds: Dict[str, Dict[str, Any]] = {}
# Cardiology β€” LDL/statin intolerance (In Review + mock SOAP)
cid = "CARD-LDL"
seeds[cid] = {
"case_id": cid,
"status": "submitted", # UI can render as "In Review"
"review": {"state": "in_review", "notes": "Specialist reviewing; mock SOAP present."},
"patient": {"name": "Pat Demo", "age": 58, "sex": "F"},
"consult": {
"specialty": "Cardiology",
"question": "LDL management with statin intolerance?",
"history": "Primary hyperlipidemia; myalgias with atorvastatin and simvastatin.",
"medications": "Ezetimibe 10 mg daily",
"labs": "LDL 162 mg/dL; HDL 48 mg/dL; TG 190 mg/dL",
"consent_obtained": True,
},
"soap_draft": {
"subjective": "Reports muscle aches with prior statins; adherent to ezetimibe.",
"objective": "LDL 162 mg/dL; ASCVD risk elevated; BP 132/78; weight stable.",
"assessment": "Statin intolerance vs nocebo; persistent LDL elevation despite ezetimibe.",
"plan": "Consider PCSK9 inhibitor or inclisiran; reinforce lifestyle; check CK/Vit D; baseline LFTs; shared decision-making.",
},
"billing": {"minutes": 12, "spoke": True, "cpt_code": None, "attested": False},
"explainability": {"manually_modified": False},
}
# Endocrinology β€” T2DM + CKD (Draft)
cid = "ENDO-T2DM-CKD"
seeds[cid] = {
"case_id": cid,
"status": "draft",
"patient": {"name": "Alex Demo", "age": 67, "sex": "M"},
"consult": {
"specialty": "Endocrinology",
"question": "Optimization of T2DM regimen in CKD stage 3?",
"history": "A1c drifting up; eGFR 48; metformin on board; no SGLT2 yet.",
"medications": "Metformin 1000 mg BID",
"labs": "A1c 8.2%; eGFR 48 mL/min/1.73mΒ²; K 4.6",
},
"soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""},
"billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False},
"explainability": {"manually_modified": False},
}
# Dermatology β€” chronic rash (Draft)
cid = "DERM-RASH-CHRONIC"
seeds[cid] = {
"case_id": cid,
"status": "draft",
"patient": {"name": "Sam Demo", "age": 44, "sex": "M"},
"consult": {
"specialty": "Dermatology",
"question": "Chronic pruritic rash on forearms; failed topical steroid.",
"history": "6 months intermittent erythematous plaques; worse with sun exposure.",
"medications": "Topical triamcinolone 0.1%",
"labs": "CBC normal; no eosinophilia",
},
"soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""},
"billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False},
"explainability": {"manually_modified": False},
}
# Infectious Disease β€” diabetic foot infection (Draft)
cid = "ID-DFI"
seeds[cid] = {
"case_id": cid,
"status": "draft",
"patient": {"name": "Jamie Demo", "age": 72, "sex": "F"},
"consult": {
"specialty": "Infectious Disease",
"question": "Antibiotic strategy for diabetic foot infection with poor perfusion?",
"history": "Ulcer at plantar surface; prior MRSA colonization; neuropathy.",
"medications": "No abx started",
"labs": "ESR 44; CRP 2.1; WBC 9.8",
},
"soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""},
"billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False},
"explainability": {"manually_modified": False},
}
return seeds
def seed_cases(reset: bool = False) -> List[str]:
"""Create four seed cases; returns created/updated case_ids."""
d = cases_dir()
d.mkdir(parents=True, exist_ok=True)
if reset:
for p in d.glob("*.json"):
p.unlink()
ids: List[str] = []
for cid, payload in _seed_payloads().items():
save_case(payload)
ids.append(cid)
return ids