Cardiosense-AG commited on
Commit
4e46ea8
·
verified ·
1 Parent(s): 2202f86

Update src/store.py

Browse files
Files changed (1) hide show
  1. src/store.py +217 -71
src/store.py CHANGED
@@ -1,10 +1,23 @@
1
  # src/store.py
2
  from __future__ import annotations
3
- import json, time, random, string
 
 
 
 
 
 
 
 
 
 
4
  from pathlib import Path
5
  from typing import List, Dict, Optional, Any
6
 
7
  from .paths import cases_dir
 
 
 
8
 
9
  def _now_iso() -> str:
10
  return time.strftime("%Y-%m-%dT%H:%M:%S")
@@ -14,100 +27,87 @@ def _rand(n=4) -> str:
14
  alphabet = string.ascii_uppercase + string.digits
15
  return ''.join(secrets.choice(alphabet) for _ in range(n))
16
 
17
- def case_path(case_id: str) -> Path:
18
- return cases_dir() / f"{case_id}.json"
19
-
20
- def new_case_id() -> str:
21
- return f"EC-{_now_iso().replace('-','').replace(':','').replace('T','')}-{_rand(4)}"
22
-
23
  def _deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
24
  out = dict(a)
25
- for k, v in (b or {}).items():
26
  if isinstance(v, dict) and isinstance(out.get(k), dict):
27
- out[k] = _deep_merge(out[k], v)
28
  else:
29
  out[k] = v
30
  return out
31
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def _migrate_soup_to_soap(obj: Dict[str, Any]) -> Dict[str, Any]:
33
- # If older payloads used "soup_draft", migrate to "soap_draft"
34
- if isinstance(obj, dict) and "soup_draft" in obj and "soap_draft" not in obj:
35
- obj["soap_draft"] = obj.pop("soup_draft")
 
 
 
 
 
 
 
36
  return obj
37
 
38
- def list_cases(status: Optional[str] = None) -> List[Dict[str, Any]]:
39
- items = []
40
- for p in cases_dir().glob("EC-*.json"):
41
- try:
42
- obj = json.loads(p.read_text())
43
- if (status is None) or (obj.get("status") == status):
44
- items.append({
45
- "case_id": obj.get("case_id"),
46
- "status": obj.get("status"),
47
- "created_at": obj.get("created_at"),
48
- "updated_at": obj.get("updated_at"),
49
- "patient_name": (obj.get("patient") or {}).get("name"),
50
- "specialty": (obj.get("consult") or {}).get("specialty"),
51
- "question": (obj.get("consult") or {}).get("question"),
52
- })
53
- except Exception:
54
- pass
55
- items.sort(key=lambda x: x.get("created_at") or "", reverse=True)
56
- return items
 
 
 
 
 
57
 
58
  def read_case(case_id: str) -> Optional[Dict[str, Any]]:
59
  p = case_path(case_id)
60
  if not p.exists():
61
  return None
62
  try:
63
- obj = json.loads(p.read_text())
64
- obj = _migrate_soup_to_soap(obj)
65
- # drop deprecated triage field if present
66
- if isinstance(obj, dict) and 'triage' in obj:
67
- obj.pop('triage', None)
68
- return obj
69
  except Exception:
70
  return None
 
71
 
72
  def save_case(obj: Dict[str, Any]) -> None:
 
73
  obj["updated_at"] = _now_iso()
74
  p = case_path(obj["case_id"])
75
- p.write_text(json.dumps(obj, indent=2, ensure_ascii=False))
 
76
 
77
- def create_case(initial: Dict[str, Any]) -> Dict[str, Any]:
78
- case_id = new_case_id()
79
- now = _now_iso()
80
- obj = {
81
- "case_id": case_id,
82
- "status": "draft",
83
- "created_at": now,
84
- "updated_at": now,
85
- "patient": {},
86
- "pcp": {},
87
- "consult": {},
88
- "time_log": {
89
- "pcp_prep_minutes": 0,
90
- "specialist_review_minutes": 0,
91
- "specialist_discussion_minutes": 0,
92
- "specialist_doc_minutes": 0
93
- },
94
- "attestations": {
95
- "pcp_consent_obtained": False,
96
- "pcp_attests": False,
97
- "specialist_attests": False
98
- },
99
- "soap_draft": { # <-- SOAP by default
100
- "subjective": "",
101
- "objective": "",
102
- "assessment": "",
103
- "plan": "",
104
- "citations": []
105
- },
106
- "billing": {},
107
- }
108
  obj = _deep_merge(obj, initial or {})
109
- obj = _migrate_soup_to_soap(obj)
110
- # no `triage` in new schema
111
  save_case(obj)
112
  return obj
113
 
@@ -119,4 +119,150 @@ def update_case(case_id: str, patch: Dict[str, Any]) -> Optional[Dict[str, Any]]
119
  save_case(updated)
120
  return updated
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
 
1
  # src/store.py
2
  from __future__ import annotations
3
+ """Case persistence helpers (Schema v2) with legacy API compatibility.
4
+
5
+ Public API preserved from V1:
6
+ - new_case_id(), case_path(), read_case(), save_case(), create_case(), update_case(), list_cases()
7
+
8
+ Additions in V2:
9
+ - upgrade_to_v2(obj), set_status(case_id, status), touch(case_id)
10
+ - seed_cases(reset: bool = False)
11
+ """
12
+
13
+ import json, time, string
14
  from pathlib import Path
15
  from typing import List, Dict, Optional, Any
16
 
17
  from .paths import cases_dir
18
+ from .config import SCHEMA_VERSION
19
+
20
+ # --------------------------- utils ---------------------------
21
 
22
  def _now_iso() -> str:
23
  return time.strftime("%Y-%m-%dT%H:%M:%S")
 
27
  alphabet = string.ascii_uppercase + string.digits
28
  return ''.join(secrets.choice(alphabet) for _ in range(n))
29
 
 
 
 
 
 
 
30
  def _deep_merge(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
31
  out = dict(a)
32
+ for k, v in b.items():
33
  if isinstance(v, dict) and isinstance(out.get(k), dict):
34
+ out[k] = _deep_merge(out[k], v) # type: ignore
35
  else:
36
  out[k] = v
37
  return out
38
 
39
+ # --------------------------- paths ---------------------------
40
+
41
+ def case_path(case_id: str) -> Path:
42
+ d = cases_dir()
43
+ d.mkdir(parents=True, exist_ok=True)
44
+ return d / f"{case_id}.json"
45
+
46
+ def new_case_id() -> str:
47
+ return f"EC{_rand(6)}"
48
+
49
+ # ------------------------ migrations -------------------------
50
+
51
  def _migrate_soup_to_soap(obj: Dict[str, Any]) -> Dict[str, Any]:
52
+ """Ensure SOAP shape; migrate legacy 'soup' key to 'soap_draft'."""
53
+ if isinstance(obj, dict) and "soup" in obj and "soap_draft" not in obj:
54
+ soup = obj.pop("soup", {})
55
+ if isinstance(soup, dict):
56
+ obj["soap_draft"] = {
57
+ "subjective": soup.get("subjective", ""),
58
+ "objective": soup.get("objective", ""),
59
+ "assessment": soup.get("assessment", ""),
60
+ "plan": soup.get("plan", ""),
61
+ }
62
  return obj
63
 
64
+ def _ensure_v2_fields(obj: Dict[str, Any]) -> Dict[str, Any]:
65
+ """Add Schema v2 fields if missing."""
66
+ obj.setdefault("schema_version", SCHEMA_VERSION)
67
+ obj.setdefault("created_at", _now_iso())
68
+ obj.setdefault("updated_at", _now_iso())
69
+ obj.setdefault("status", "draft")
70
+ obj.setdefault("review", {"state": None, "notes": ""})
71
+ obj.setdefault("billing", {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False})
72
+ obj.setdefault("explainability", {"manually_modified": False})
73
+ obj.setdefault("patient", {"name": "", "age": None, "sex": ""})
74
+ obj.setdefault("consult", {"question": "", "history": "", "medications": "", "labs": ""})
75
+ obj.setdefault("soap_draft", {"subjective": "", "objective": "", "assessment": "", "plan": ""})
76
+ return obj
77
+
78
+ def upgrade_to_v2(obj: Dict[str, Any]) -> Dict[str, Any]:
79
+ """Idempotent upgrader to Schema v2."""
80
+ if obj is None:
81
+ return obj
82
+ obj = _migrate_soup_to_soap(obj)
83
+ obj = _ensure_v2_fields(obj)
84
+ obj["schema_version"] = SCHEMA_VERSION
85
+ return obj
86
+
87
+ # ------------------------ core ops ---------------------------
88
 
89
  def read_case(case_id: str) -> Optional[Dict[str, Any]]:
90
  p = case_path(case_id)
91
  if not p.exists():
92
  return None
93
  try:
94
+ data = json.loads(p.read_text(encoding='utf-8'))
 
 
 
 
 
95
  except Exception:
96
  return None
97
+ return upgrade_to_v2(data)
98
 
99
  def save_case(obj: Dict[str, Any]) -> None:
100
+ obj = upgrade_to_v2(obj or {})
101
  obj["updated_at"] = _now_iso()
102
  p = case_path(obj["case_id"])
103
+ p.parent.mkdir(parents=True, exist_ok=True)
104
+ p.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding='utf-8')
105
 
106
+ def create_case(initial: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
107
+ case_id = (initial or {}).get("case_id") or new_case_id()
108
+ obj: Dict[str, Any] = {"case_id": case_id}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  obj = _deep_merge(obj, initial or {})
110
+ obj = upgrade_to_v2(obj)
 
111
  save_case(obj)
112
  return obj
113
 
 
119
  save_case(updated)
120
  return updated
121
 
122
+ def list_cases(status: Optional[str] = None) -> List[Dict[str, Any]]:
123
+ items: List[Dict[str, Any]] = []
124
+ d = cases_dir()
125
+ d.mkdir(parents=True, exist_ok=True)
126
+ for p in d.glob("*.json"):
127
+ try:
128
+ obj = json.loads(p.read_text(encoding='utf-8'))
129
+ obj = upgrade_to_v2(obj)
130
+ if (status is None) or (obj.get("status") == status):
131
+ items.append({
132
+ "case_id": obj.get("case_id"),
133
+ "status": obj.get("status"),
134
+ "created_at": obj.get("created_at"),
135
+ "updated_at": obj.get("updated_at"),
136
+ "patient_name": (obj.get("patient") or {}).get("name"),
137
+ "specialty": (obj.get("consult") or {}).get("specialty"),
138
+ "question": (obj.get("consult") or {}).get("question"),
139
+ })
140
+ except Exception:
141
+ continue
142
+ items.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
143
+ return items
144
+
145
+ def set_status(case_id: str, status: str) -> Optional[Dict[str, Any]]:
146
+ """Set status with simple tri-state guard: draft -> submitted -> completed."""
147
+ allowed = ["draft", "submitted", "completed"]
148
+ if status not in allowed:
149
+ raise ValueError(f"Invalid status: {status}")
150
+ cur = read_case(case_id)
151
+ if cur is None:
152
+ return None
153
+ current = cur.get("status", "draft")
154
+ order = {s:i for i, s in enumerate(allowed)}
155
+ if order[status] < order.get(current, 0):
156
+ return cur # disallow backward moves
157
+ cur["status"] = status
158
+ save_case(cur)
159
+ return cur
160
+
161
+ def touch(case_id: str) -> None:
162
+ obj = read_case(case_id)
163
+ if obj:
164
+ obj["updated_at"] = _now_iso()
165
+ save_case(obj)
166
+
167
+ # ------------------------ seeding ----------------------------
168
+
169
+ def _seed_payloads() -> Dict[str, Dict[str, Any]]:
170
+ """Construct seed cases as per spec: 3 Draft, 1 In Review (submitted)."""
171
+ seeds: Dict[str, Dict[str, Any]] = {}
172
+
173
+ # Cardiology — LDL/statin intolerance (In Review + mock SOAP)
174
+ cid = "CARD-LDL"
175
+ seeds[cid] = {
176
+ "case_id": cid,
177
+ "status": "submitted", # UI can render as "In Review"
178
+ "review": {"state": "in_review", "notes": "Specialist reviewing; mock SOAP present."},
179
+ "patient": {"name": "Pat Demo", "age": 58, "sex": "F"},
180
+ "consult": {
181
+ "specialty": "Cardiology",
182
+ "question": "LDL management with statin intolerance?",
183
+ "history": "Primary hyperlipidemia; myalgias with atorvastatin and simvastatin.",
184
+ "medications": "Ezetimibe 10 mg daily",
185
+ "labs": "LDL 162 mg/dL; HDL 48 mg/dL; TG 190 mg/dL",
186
+ "consent_obtained": True,
187
+ },
188
+ "soap_draft": {
189
+ "subjective": "Reports muscle aches with prior statins; adherent to ezetimibe.",
190
+ "objective": "LDL 162 mg/dL; ASCVD risk elevated; BP 132/78; weight stable.",
191
+ "assessment": "Statin intolerance vs nocebo; persistent LDL elevation despite ezetimibe.",
192
+ "plan": "Consider PCSK9 inhibitor or inclisiran; reinforce lifestyle; check CK/Vit D; baseline LFTs; shared decision-making.",
193
+ },
194
+ "billing": {"minutes": 12, "spoke": True, "cpt_code": None, "attested": False},
195
+ "explainability": {"manually_modified": False},
196
+ }
197
+
198
+ # Endocrinology — T2DM + CKD (Draft)
199
+ cid = "ENDO-T2DM-CKD"
200
+ seeds[cid] = {
201
+ "case_id": cid,
202
+ "status": "draft",
203
+ "patient": {"name": "Alex Demo", "age": 67, "sex": "M"},
204
+ "consult": {
205
+ "specialty": "Endocrinology",
206
+ "question": "Optimization of T2DM regimen in CKD stage 3?",
207
+ "history": "A1c drifting up; eGFR 48; metformin on board; no SGLT2 yet.",
208
+ "medications": "Metformin 1000 mg BID",
209
+ "labs": "A1c 8.2%; eGFR 48 mL/min/1.73m²; K 4.6",
210
+ },
211
+ "soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""},
212
+ "billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False},
213
+ "explainability": {"manually_modified": False},
214
+ }
215
+
216
+ # Dermatology — chronic rash (Draft)
217
+ cid = "DERM-RASH-CHRONIC"
218
+ seeds[cid] = {
219
+ "case_id": cid,
220
+ "status": "draft",
221
+ "patient": {"name": "Sam Demo", "age": 44, "sex": "M"},
222
+ "consult": {
223
+ "specialty": "Dermatology",
224
+ "question": "Chronic pruritic rash on forearms; failed topical steroid.",
225
+ "history": "6 months intermittent erythematous plaques; worse with sun exposure.",
226
+ "medications": "Topical triamcinolone 0.1%",
227
+ "labs": "CBC normal; no eosinophilia",
228
+ },
229
+ "soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""},
230
+ "billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False},
231
+ "explainability": {"manually_modified": False},
232
+ }
233
+
234
+ # Infectious Disease — diabetic foot infection (Draft)
235
+ cid = "ID-DFI"
236
+ seeds[cid] = {
237
+ "case_id": cid,
238
+ "status": "draft",
239
+ "patient": {"name": "Jamie Demo", "age": 72, "sex": "F"},
240
+ "consult": {
241
+ "specialty": "Infectious Disease",
242
+ "question": "Antibiotic strategy for diabetic foot infection with poor perfusion?",
243
+ "history": "Ulcer at plantar surface; prior MRSA colonization; neuropathy.",
244
+ "medications": "No abx started",
245
+ "labs": "ESR 44; CRP 2.1; WBC 9.8",
246
+ },
247
+ "soap_draft": {"subjective": "", "objective": "", "assessment": "", "plan": ""},
248
+ "billing": {"minutes": 5, "spoke": False, "cpt_code": None, "attested": False},
249
+ "explainability": {"manually_modified": False},
250
+ }
251
+
252
+ return seeds
253
+
254
+ def seed_cases(reset: bool = False) -> List[str]:
255
+ """Create four seed cases; returns created/updated case_ids."""
256
+ d = cases_dir()
257
+ d.mkdir(parents=True, exist_ok=True)
258
+ if reset:
259
+ for p in d.glob("*.json"):
260
+ p.unlink()
261
+ ids: List[str] = []
262
+ for cid, payload in _seed_payloads().items():
263
+ save_case(payload)
264
+ ids.append(cid)
265
+ return ids
266
+
267
+
268