Slaiwala commited on
Commit
66b99bd
·
verified ·
1 Parent(s): f15b975

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +210 -525
app.py CHANGED
@@ -1,11 +1,11 @@
1
  # app.py — Spine Coder (Chatbot + Feedback + Session Logs) — Gradio 4.x
2
  # ------------------------------------------------------------------------------
3
- # FINAL-v2.1 UI build (hardened) + LIVE DATASET LOGGING (no restarts):
4
- # - Purge caches, dynamic file loader (SPINE_CORE_PATH override) + SHA print
5
- # - Startup PROBE to logs + in-UI Diagnostics/Probe
6
- # - Clean CPT table (no per-row modifier columns) + Case Modifiers panel
7
- # - Build/Core chips, structured logs/export, modern Gradio usage
8
- # - NEW: per-request log commits to a separate *dataset* repo (no Space rebuilds)
9
  # ------------------------------------------------------------------------------
10
 
11
  import os
@@ -16,15 +16,14 @@ import pathlib
16
  import traceback
17
  from datetime import datetime, timezone
18
  from typing import Any, Dict, List, Tuple
19
-
20
  import pandas as pd
21
  import gradio as gr
22
 
23
- # ==== Bulletproof Core Import ==================================================
24
  import importlib, importlib.util, importlib.machinery
25
  import sys as __sys, os as __os, inspect as __inspect, hashlib
26
 
27
- __sys.path.insert(0, __os.path.abspath(".")) # ensure repo root is on sys.path
28
 
29
  def _purge_spine_modules():
30
  for k in list(__sys.modules):
@@ -34,6 +33,7 @@ def _purge_spine_modules():
34
 
35
  def _sha256(path: str) -> str:
36
  try:
 
37
  h = hashlib.sha256()
38
  with open(path, "rb") as f:
39
  for chunk in iter(lambda: f.read(8192), b""):
@@ -43,10 +43,6 @@ def _sha256(path: str) -> str:
43
  return "unknown"
44
 
45
  def _load_core_from_file(path: str):
46
- """
47
- Load spine_coder_core.py directly from a given path (bypass module cache).
48
- Register in sys.modules BEFORE exec to enable imports inside the core.
49
- """
50
  path = __os.path.abspath(path)
51
  if not __os.path.exists(path):
52
  return None
@@ -59,18 +55,13 @@ def _load_core_from_file(path: str):
59
  return mod
60
 
61
  def _force_import_core():
62
- """Order: purge caches → SPINE_CORE_PATH → local files → package modules."""
63
  _purge_spine_modules()
64
-
65
- # 1) Explicit override
66
  forced = __os.environ.get("SPINE_CORE_PATH")
67
  if forced and __os.path.exists(forced):
68
  mod = _load_core_from_file(forced)
69
  if mod:
70
  print("[CORE] forced path:", forced, "sha:", _sha256(forced))
71
  return mod
72
-
73
- # 2) Likely file paths near app
74
  for rel in [
75
  "spine_coder_core.py",
76
  "spine_coder/spine_coder_core.py",
@@ -82,8 +73,6 @@ def _force_import_core():
82
  if mod:
83
  print("[CORE] loaded file:", p, "sha:", _sha256(p))
84
  return mod
85
-
86
- # 3) Package modules
87
  for modname in [
88
  "spine_coder.spine_coder.spine_coder_core",
89
  "spine_coder.spine_coder_core",
@@ -91,29 +80,19 @@ def _force_import_core():
91
  try:
92
  mod = importlib.import_module(modname)
93
  mod = importlib.reload(mod)
94
- src = __inspect.getsourcefile(mod.suggest_with_cpt_billing) or "unknown"
95
- print("[CORE] loaded module:", modname, "from:", src)
96
  return mod
97
  except Exception:
98
  pass
99
-
100
- raise ImportError("Unable to locate spine_coder_core.py via modules or file paths.")
101
 
102
  _core = _force_import_core()
103
  suggest_with_cpt_billing = _core.suggest_with_cpt_billing
104
 
105
- try:
106
- _active_src = __inspect.getsourcefile(suggest_with_cpt_billing) or "unknown"
107
- print("[CORE] active source:", _active_src)
108
- except Exception:
109
- _active_src = "unknown"
110
-
111
- # ---- One-time startup probe (prints to Space logs) ----------------------------
112
  try:
113
  _probe_note = "Discectomies at C4–C5, C5–C6, and C6–C7. Interbody cages and anterior plate spanning C4–C7."
114
  _probe = suggest_with_cpt_billing(_probe_note, payer="Medicare", top_k=5)
115
  print("[PROBE] build:", _probe.get("build"))
116
- print("[PROBE] first_suggestion:", (_probe.get("suggestions") or [{}])[0])
117
  except Exception as e:
118
  print("[PROBE] failed:", e)
119
 
@@ -122,19 +101,13 @@ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
122
  DEBUG = os.environ.get("DEBUG", "0") == "1"
123
  PAYER_CHOICES = ["Medicare", "BCBS", "Aetna", "Cigna", "UnitedHealthcare", "Other"]
124
 
125
- # ==== Remote log push settings (dataset repo to avoid restarts) ===============
126
- # Required env:
127
- # LOG_PUSH_ENABLE=1
128
- # HF_TARGET_REPO=Slaiwala/spinecoder-logs
129
- # HF_REPO_TYPE=dataset
130
- # HF_TOKEN (or HUGGINGFACEHUB_API_TOKEN) with write access
131
  LOG_PUSH_ENABLE = os.environ.get("LOG_PUSH_ENABLE", "0") == "1"
132
- HF_TARGET_REPO = os.environ.get("HF_TARGET_REPO") # must be provided; no fallback to SPACE_ID
133
- HF_REPO_TYPE = os.environ.get("HF_REPO_TYPE", "dataset") # keep 'dataset' to prevent Space rebuilds
134
- HF_WRITE_TOKEN = (
135
- os.environ.get("HF_TOKEN")
136
- or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
137
- )
138
 
139
  _hf_api = None
140
  def _get_hf_api():
@@ -144,529 +117,241 @@ def _get_hf_api():
144
  _hf_api = HfApi(token=HF_WRITE_TOKEN)
145
  return _hf_api
146
 
147
- # ==== Local logging ============================================================
148
- LOG_DIR = os.environ.get("LOG_DIR", "logs")
149
- pathlib.Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
150
-
151
- def _log_path(session_id: str) -> str:
152
- return os.path.join(LOG_DIR, f"{session_id}.jsonl")
153
-
154
- def _utcnow_iso() -> str:
155
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
156
-
157
- # ---- push a single line to dataset repo (no Space restart) -------------------
158
- def _push_log_line_to_repo(entry: Dict[str, Any]) -> None:
159
- if not LOG_PUSH_ENABLE:
160
- return
161
- if not HF_TARGET_REPO or not HF_WRITE_TOKEN:
162
- # Misconfigured: silently skip rather than touching the Space repo
163
- return
164
  try:
165
- from huggingface_hub import CommitOperationAdd, hf_hub_download
166
  api = _get_hf_api()
 
 
 
 
 
167
 
168
- day = datetime.utcnow().strftime("%Y-%m-%d")
169
- path_in_repo = f"logs-live/{day}.jsonl"
170
-
171
- # Try to download existing file (may not exist yet)
172
- existing = ""
173
- try:
174
- local_fp = hf_hub_download(
175
- repo_id=HF_TARGET_REPO,
176
- filename=path_in_repo,
177
- repo_type=HF_REPO_TYPE, # 'dataset'
178
- token=HF_WRITE_TOKEN,
179
- local_dir="/tmp",
180
- local_dir_use_symlinks=False,
181
- force_download=False,
182
- )
183
- with open(local_fp, "r", encoding="utf-8") as f:
184
- existing = f.read()
185
- except Exception:
186
- existing = ""
187
-
188
- # Append one line
189
- new_line = json.dumps(entry, ensure_ascii=False)
190
- if existing and not existing.endswith("\n"):
191
- existing += "\n"
192
- merged = (existing + new_line + "\n").encode("utf-8")
193
-
194
- api.create_commit(
195
  repo_id=HF_TARGET_REPO,
196
- repo_type=HF_REPO_TYPE, # 'dataset'
197
- operations=[CommitOperationAdd(path_in_repo=path_in_repo, path_or_fileobj=io.BytesIO(merged))],
198
- commit_message=f"logs: append {day} ({entry.get('event','')})",
 
 
 
199
  )
200
- except Exception as e:
201
- print("[LOG PUSH] failed:", e)
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
- def _append_log(session_id: str, entry: Dict[str, Any]) -> None:
204
- entry = {"ts": _utcnow_iso(), "session_id": session_id, **entry}
205
- # Local JSONL (unchanged)
206
- with open(_log_path(session_id), "a", encoding="utf-8") as f:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  f.write(json.dumps(entry, ensure_ascii=False) + "\n")
208
- # Dataset push (no restart)
209
  _push_log_line_to_repo(entry)
210
 
211
- def export_session(session_id: str) -> str:
212
- src = _log_path(session_id)
213
- data: List[Dict[str, Any]] = []
214
- if os.path.exists(src):
215
- with open(src, "r", encoding="utf-8") as f:
216
- data = [json.loads(l) for l in f if l.strip()]
217
- out_path = os.path.join(LOG_DIR, f"export_{session_id}.json")
218
- with open(out_path, "w", encoding="utf-8") as f:
219
- json.dump(data, f, indent=2, ensure_ascii=False)
220
- return out_path
221
-
222
  # ==== UI Helpers ==============================================================
 
 
 
223
 
224
- def _core_path() -> str:
225
- try:
226
- return __inspect.getsourcefile(suggest_with_cpt_billing) or "unknown"
227
- except Exception:
228
- return "unknown"
229
-
230
- # Display columns (per-row modifiers are deliberately not shown here)
231
- SUGG_COLS = [
232
- "CPT", "Description", "Rationale",
233
- "Confidence", "Primary", "Category", "Laterality", "Units"
234
- ]
235
  EMPTY_SUGG_DF = pd.DataFrame(columns=SUGG_COLS)
236
- EMPTY_MODS_DF = pd.DataFrame([{"modifier": "—", "reason": ""}])
237
-
238
- def _coalesce_rows(result: Dict[str, Any]) -> Tuple[pd.DataFrame, Dict[str, Any]]:
239
- """Build the suggestions table (no modifier columns) and meta badges."""
240
- if not isinstance(result, dict):
241
- return EMPTY_SUGG_DF, {}
242
-
243
- sugg = result.get("suggestions") or []
244
- rows: List[Dict[str, Any]] = []
245
- case_lat = (result.get("laterality") or "").strip().lower()
246
-
247
- for s in sugg:
248
- if not isinstance(s, dict):
249
- continue
250
- mods = s.get("modifiers", []) or []
251
-
252
- # Derive row laterality
253
- row_lat = (s.get("laterality") or "").strip().lower()
254
- if not row_lat:
255
- if isinstance(mods, list) and "LT" in mods:
256
- row_lat = "left"
257
- elif isinstance(mods, list) and "RT" in mods:
258
- row_lat = "right"
259
- elif case_lat in ("left", "right", "bilateral"):
260
- row_lat = case_lat
261
-
262
- conf_val = s.get("confidence")
263
- conf_out = round(float(conf_val), 2) if isinstance(conf_val, (int, float)) else (conf_val or "")
264
 
 
 
 
 
265
  rows.append({
266
- "CPT": s.get("cpt", ""),
267
- "Description": s.get("desc", ""),
268
- "Rationale": s.get("rationale", ""),
269
- "Confidence": conf_out,
270
  "Primary": "✓" if s.get("primary") else "",
271
- "Category": s.get("category", ""),
272
- "Laterality": row_lat,
273
- "Units": s.get("units", 1),
274
  })
275
-
276
- # Normalize levels
277
- segs: List[str] = []
278
- inters_list: List[str] = []
279
- lvl_lat = ""
280
- levels_obj = result.get("levels")
281
- if isinstance(levels_obj, dict):
282
- segs = list(levels_obj.get("segments") or [])
283
- inters_list = list(levels_obj.get("interspaces") or [])
284
- lvl_lat = levels_obj.get("laterality", "") or ""
285
- elif isinstance(levels_obj, list):
286
- segs = [str(x) for x in levels_obj]
287
-
288
- # Normalize flags
289
- flags_obj = result.get("flags")
290
- if isinstance(flags_obj, list):
291
- flags_list = [str(x) for x in flags_obj]
292
- elif isinstance(flags_obj, dict):
293
- flags_list = [k for k, v in flags_obj.items() if v]
294
- else:
295
- flags_list = []
296
-
297
  meta = {
298
- "payer": result.get("payer", ""),
299
- "region": result.get("region", ""),
300
- "laterality": result.get("laterality", "") or lvl_lat,
301
- "levels_segments": ", ".join(segs),
302
- "levels_interspaces": (
303
- str(result.get("interspaces_est", "")) if "interspaces_est" in result
304
- else ", ".join(inters_list)
305
- ),
306
- "flags": ", ".join(sorted(flags_list)),
307
- "build": result.get("build", ""),
308
- "mode": result.get("mode", ""),
309
- "core_path": _core_path(),
310
  }
311
-
312
- df = EMPTY_SUGG_DF if not rows else pd.DataFrame(rows)
313
- if not df.empty:
314
- for col in SUGG_COLS:
315
- if col not in df.columns:
316
- df[col] = ""
317
- df = df[SUGG_COLS]
318
  return df, meta
319
 
320
- def _case_mods_df(result: Dict[str, Any]) -> pd.DataFrame:
321
- mods = result.get("case_modifiers", []) or []
322
- if not mods:
323
- return EMPTY_MODS_DF.copy()
324
- return pd.DataFrame([{"modifier": f"-{m.get('modifier','')}", "reason": m.get("reason","")} for m in mods])
325
-
326
- def _summary_md(meta: Dict[str, Any]) -> str:
327
- chips = []
328
- if meta.get("region"): chips.append(f"`Region: {meta['region']}`")
329
- if meta.get("laterality"): chips.append(f"`Laterality: {meta['laterality']}`")
330
- if meta.get("levels_segments"): chips.append(f"`Segments: {meta['levels_segments']}`")
331
- if meta.get("levels_interspaces"): chips.append(f"`Interspaces: {meta['levels_interspaces']}`")
332
- if meta.get("flags"): chips.append(f"`Flags: {meta['flags']}`")
333
- if meta.get("build"): chips.append(f"`Build: {meta['build']}`")
334
- if meta.get("mode"): chips.append(f"`Mode: {meta['mode']}`")
335
- try:
336
- core_base = os.path.basename(meta.get("core_path","")) if meta.get("core_path") else ""
337
- if core_base:
338
- chips.append(f"`Core: {core_base}`")
339
- except Exception:
340
- pass
341
- return " ".join(chips) if chips else "—"
342
-
343
- def new_session() -> str:
344
- return str(uuid.uuid4())[:8]
345
 
346
- # ==== Core actions ============================================================
 
 
 
 
347
 
348
- def run_inference(note: str, payer: str, top_k: int, session_id: str):
349
- if not note.strip():
350
- return (
351
- EMPTY_SUGG_DF,
352
- EMPTY_MODS_DF.copy(),
353
- "—", "", "", session_id
354
- )
355
 
356
- _append_log(session_id, {"event": "request", "payer": payer, "top_k": top_k, "note": note})
 
 
 
357
  try:
358
- result = suggest_with_cpt_billing(note=note, payer=payer, top_k=top_k)
359
- if DEBUG:
360
- print("[DEBUG] build/region/laterality/flags]:",
361
- result.get("build"), result.get("region"),
362
- result.get("laterality"), result.get("flags"))
363
  except Exception as e:
364
- tb = traceback.format_exc()
365
- _append_log(session_id, {"event": "error", "error": repr(e), "traceback": tb})
366
- warn = f"⚠️ Error: {e}"
367
- if DEBUG:
368
- warn += f"\n\n```traceback\n{tb}\n```"
369
- return (
370
- EMPTY_SUGG_DF,
371
- EMPTY_MODS_DF.copy(),
372
- "—", "", warn, session_id
373
- )
374
-
375
- sugg_df, meta = _coalesce_rows(result)
376
- case_mods_df = _case_mods_df(result)
377
- summary = _summary_md(meta)
378
- json_pretty = json.dumps(result, indent=2, ensure_ascii=False)
379
-
380
- _append_log(session_id, {
381
- "event": "response",
382
- "meta": {
383
- **meta,
384
- "case_modifiers": ", ".join([f"-{m}" for m in [cm.get("modifier","") for cm in (result.get("case_modifiers") or [])] if m]) or ""
385
- },
386
- "rows_len": int(len(sugg_df) if hasattr(sugg_df, "__len__") else 0)
387
- })
388
- return sugg_df, case_mods_df, summary, json_pretty, "", session_id
389
-
390
- def record_feedback(session_id: str, vote: str, text: str):
391
- if not vote and not text:
392
- return "Please choose 👍/👎 or add a short note."
393
- _append_log(session_id, {"event": "feedback", "vote": vote, "text": text})
394
- return "Thanks! Your feedback was recorded."
395
-
396
- def do_export(session_id: str):
397
- path = export_session(session_id)
398
- _append_log(session_id, {"event": "export", "path": path})
399
- return path
400
 
401
  def on_clear():
402
- return (
403
- "", "Medicare", 10,
404
- EMPTY_SUGG_DF,
405
- EMPTY_MODS_DF.copy(),
406
- "—", "", "",
407
- new_session()
408
- )
409
 
410
- # ==== Diagnostics / Probe =====================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
 
412
- def run_probe(session_id: str) -> Tuple[str, str]:
413
- """Run a live diagnostic: show core path & build and 3 smoke tests."""
414
- info_lines = []
415
  try:
416
- core_path = _core_path()
417
- tests = [
418
- ("Implicit TLIF",
419
- "Left facetectomy L4–L5 with PEEK interbody cage and pedicle screws; rods secured.",
420
- ["22633"], # expect
421
- ),
422
- ("ACDF chain w/ plate",
423
- "Discectomies at C4–C5, C5–C6, and C6–C7. Interbody cages and anterior plate spanning C4–C7.",
424
- ["22551","22552","22846"],
425
- ),
426
- ("Exposure-only",
427
- "Anterior exposure of L4–S1 performed by vascular surgeon for access. No fusion performed.",
428
- ["00000"],
429
- ),
430
- ]
431
- # Run once to get build
432
- probe = suggest_with_cpt_billing(tests[1][1], payer="Medicare", top_k=10)
433
- build = probe.get("build","")
434
- info_lines.append(f"**Core path:** `{core_path}` \n**Build:** `{build}`")
435
-
436
- # Execute each test
437
- for label, note, expect_codes in tests:
438
- res = suggest_with_cpt_billing(note, payer="Medicare", top_k=10)
439
- codes = [s.get("cpt") for s in (res.get("suggestions") or [])]
440
- ok = all(any(ec == c for c in codes) for ec in expect_codes)
441
- info_lines.append(f"- **{label}** → codes: `{', '.join(codes) or '∅'}` — **{'PASS' if ok else 'CHECK'}**")
442
- md = "\n".join(info_lines)
443
- _append_log(session_id, {"event":"probe","details": md})
444
- return md, ""
445
  except Exception as e:
446
- tb = traceback.format_exc()
447
- _append_log(session_id, {"event":"probe_error","error":repr(e),"traceback":tb})
448
- return "", f"⚠️ Probe failed: {e}"
449
 
450
  # ==== Examples ================================================================
451
-
452
- EXAMPLES = [
453
- ["Left-sided TLIF L4L5 with pedicle screws and interbody cage; posterolateral fusion performed. Navigation used.", "Medicare", 10],
454
- ["ACDF C5–C6 with PEEK cage and anterior plate; microscope and neuromonitoring used.", "Medicare", 10],
455
- ["Posterior cervical foraminotomy right C6–C7; no fusion or instrumentation.", "Medicare", 10],
456
- ["ALIF L5–S1 with structural allograft; non-segmental instrumentation placed.", "Medicare", 10],
457
- ["Removal of posterior segmental instrumentation T10–L2; no new hardware placed.", "Medicare", 10],
458
- # Case-modifier smoke tests:
459
- ["TLIF L4–L5 was initiated but aborted midway due to neuromonitoring changes.", "Medicare", 10], # -53
460
- ["Bilateral decompression and foraminotomy at L4–L5 and L5–S1.", "Medicare", 10], # -50
461
- ["Assistant surgeon present; resident not available.", "Medicare", 10], # -82
462
- ["Complex exposure with severe deformity and adhesiolysis.", "Medicare", 10], # -22
463
  ]
464
 
465
  # ==== Theme / CSS =============================================================
466
-
467
- THEME = gr.themes.Soft(
468
- primary_hue="indigo",
469
- secondary_hue="blue",
470
- neutral_hue="slate",
471
- ).set(
472
- body_text_color="#0f172a",
473
- background_fill_primary="#ffffff",
474
- button_primary_background_fill="#4f46e5",
475
- input_background_fill="#ffffff",
476
- )
477
-
478
- CUSTOM_CSS = """
479
- :root { --radius-lg: 16px; }
480
- .gradio-container { font-family: ui-sans-serif, system-ui, -apple-system; }
481
-
482
- /* Header card */
483
- .header-card {
484
- border-radius: 18px;
485
- padding: 18px;
486
- border: 1px solid #1f2937;
487
- background: linear-gradient(180deg,#0f172a,#0b1220);
488
- color: #e5e7eb;
489
- }
490
- .badge-row code {
491
- margin-right: 8px;
492
- border-radius: 12px;
493
- padding: 2px 8px;
494
- background: #111827;
495
- color: #e5e7eb;
496
- }
497
-
498
- /* Table container */
499
- .table-wrap { max-height: 520px; overflow: auto; }
500
-
501
- /* Suggestions table target */
502
- #suggestions_table .dataframe {
503
- font-size: 15px;
504
- width: 100% !important;
505
- table-layout: auto !important;
506
- border-collapse: collapse;
507
- }
508
- #suggestions_table .dataframe th,
509
- #suggestions_table .dataframe td {
510
- white-space: normal;
511
- word-wrap: break-word;
512
- text-align: left;
513
- vertical-align: top;
514
- padding: 10px 12px;
515
- }
516
-
517
- /* Column sizing (1-indexed):
518
- 1=CPT, 2=Description, 3=Rationale, 4=Confidence,
519
- 5=Primary, 6=Category, 7=Laterality, 8=Units */
520
-
521
- /* CPT — wider & no wrap, monospace, centered */
522
- #suggestions_table .dataframe th:nth-child(1),
523
- #suggestions_table .dataframe td:nth-child(1) {
524
- min-width: 120px;
525
- max-width: 140px;
526
- white-space: nowrap !important;
527
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
528
- text-align: center;
529
- }
530
-
531
- /* Description — roomy */
532
- #suggestions_table .dataframe th:nth-child(2),
533
- #suggestions_table .dataframe td:nth-child(2) {
534
- min-width: 360px;
535
- max-width: 560px;
536
- }
537
-
538
- /* Rationale — roomy */
539
- #suggestions_table .dataframe th:nth-child(3),
540
- #suggestions_table .dataframe td:nth-child(3) {
541
- min-width: 320px;
542
- max-width: 520px;
543
- }
544
-
545
- /* Category — a bit wider */
546
- #suggestions_table .dataframe th:nth-child(6),
547
- #suggestions_table .dataframe td:nth-child(6) {
548
- min-width: 180px;
549
- }
550
-
551
- /* Keep tiny columns compact */
552
- #suggestions_table .dataframe th:nth-child(4),
553
- #suggestions_table .dataframe td:nth-child(4),
554
- #suggestions_table .dataframe th:nth-child(5),
555
- #suggestions_table .dataframe td:nth-child(5),
556
- #suggestions_table .dataframe th:nth-child(7),
557
- #suggestions_table .dataframe td:nth-child(7),
558
- #suggestions_table .dataframe th:nth-child(8),
559
- #suggestions_table .dataframe td:nth-child(8) {
560
- min-width: 90px;
561
- }
562
-
563
- .footer-note { color:#94a3b8; font-size:12px; }
564
  """
565
 
566
- # ==== App Layout ==============================================================
567
- with gr.Blocks(theme=THEME, css=CUSTOM_CSS, title="Spine Coder — CPT Billing") as demo:
568
- session_id = gr.State(new_session())
569
-
570
  with gr.Row():
571
  with gr.Column():
572
  gr.Markdown("### 🦴 Spine Coder — CPT Billing & Operative Note NLP")
573
- gr.Markdown(
574
- '<div class="header-card">Structured CPT suggestions from spine operative notes — with payer-aware '
575
- 'modifiers, laterality detection, and rationales.<br/>'
576
- '<span class="footer-note">No PHI is stored; inputs are session-scoped and ephemeral.</span></div>'
577
- )
578
-
579
  with gr.Row():
580
- # Inputs
581
  with gr.Column(scale=5):
582
- note_in = gr.Textbox(
583
- label="Operative Note",
584
- placeholder="Paste an operative note here…",
585
- lines=14,
586
- autofocus=True,
587
- )
588
- with gr.Row():
589
- payer_dd = gr.Dropdown(PAYER_CHOICES, value="Medicare", label="Payer")
590
- topk = gr.Slider(1, 15, value=10, step=1, label="Top-K suggestions")
591
- with gr.Row():
592
- run_btn = gr.Button("Analyze Note", variant="primary")
593
- clear_btn = gr.Button("Clear")
594
-
595
- with gr.Accordion("Quick Examples", open=False):
596
- gr.Examples(
597
- examples=EXAMPLES,
598
- inputs=[note_in, payer_dd, topk],
599
- label="Click a row to load an example"
600
- )
601
-
602
- with gr.Accordion("Feedback", open=False):
603
- fb_choice = gr.Radio(choices=["👍", "👎"], label="Was this helpful?")
604
- fb_text = gr.Textbox(label="Optional comment", lines=2, placeholder="Tell us what worked or what missed…")
605
- fb_submit = gr.Button("Submit Feedback")
606
- fb_status = gr.Markdown("")
607
-
608
- with gr.Accordion("Session", open=False):
609
- sid_show = gr.Textbox(label="Session ID", value="", interactive=False)
610
- export_btn = gr.Button("Export Session as JSON")
611
- export_file = gr.File(label="Download", interactive=False)
612
-
613
- with gr.Accordion("Diagnostics", open=False):
614
- probe_btn = gr.Button("Run Probe (core path + 3 tests)")
615
- probe_md = gr.Markdown("")
616
-
617
- # Results
618
  with gr.Column(scale=7):
619
  gr.Markdown("#### Results")
620
- summary_md = gr.Markdown("—", elem_classes=["badge-row"])
621
-
622
- # Suggestions table (no modifier columns)
623
- table = gr.Dataframe(
624
- value=EMPTY_SUGG_DF,
625
- label="CPT Suggestions",
626
- interactive=False,
627
- row_count=(0, "dynamic"),
628
- wrap=True,
629
- elem_classes=["table-wrap"],
630
- elem_id="suggestions_table",
631
- )
632
-
633
- # Case-level modifiers table
634
- gr.Markdown("### Case Modifiers (visit-level)")
635
- case_mods_table = gr.Dataframe(
636
- value=EMPTY_MODS_DF.copy(),
637
- headers=["modifier","reason"],
638
- interactive=False,
639
- wrap=True,
640
- label="Case Modifiers",
641
- )
642
-
643
- with gr.Accordion("Raw JSON", open=False):
644
- json_out = gr.Code(language="json", value="", interactive=False)
645
- warn_md = gr.Markdown("")
646
-
647
- # ---- Events / Wiring ----
648
- def _on_load():
649
- sid = new_session()
650
- return sid, sid
651
-
652
- demo.load(_on_load, outputs=[session_id, sid_show])
653
-
654
- run_inputs = [note_in, payer_dd, topk, session_id]
655
- run_outputs = [table, case_mods_table, summary_md, json_out, warn_md, session_id]
656
-
657
- run_btn.click(run_inference, inputs=run_inputs, outputs=run_outputs)
658
- note_in.submit(run_inference, inputs=run_inputs, outputs=run_outputs)
659
-
660
- clear_btn.click(
661
- on_clear,
662
- outputs=[note_in, payer_dd, topk, table, case_mods_table, summary_md, json_out, warn_md, session_id]
663
- )
664
-
665
- fb_submit.click(record_feedback, inputs=[session_id, fb_choice, fb_text], outputs=fb_status)
666
- export_btn.click(do_export, inputs=[session_id], outputs=[export_file])
667
-
668
- probe_btn.click(run_probe, inputs=[session_id], outputs=[probe_md, warn_md])
669
-
670
- if __name__ == "__main__":
671
- # If running locally: set server_name to 0.0.0.0 for external access; Space ignores.
672
- demo.launch()
 
1
  # app.py — Spine Coder (Chatbot + Feedback + Session Logs) — Gradio 4.x
2
  # ------------------------------------------------------------------------------
3
+ # FINAL-v3.0 (Stable)
4
+ # - Bulletproof core import (SPINE_CORE_PATH override + SHA print)
5
+ # - Structured local JSONL logging per session
6
+ # - Live dataset logging to a Hugging Face dataset repo (no Space rebuilds)
7
+ # - Diagnostics panel with Probe + Self-Test for live logging
8
+ # - Clean UI with Case Modifiers, raw JSON, and feedback
9
  # ------------------------------------------------------------------------------
10
 
11
  import os
 
16
  import traceback
17
  from datetime import datetime, timezone
18
  from typing import Any, Dict, List, Tuple
 
19
  import pandas as pd
20
  import gradio as gr
21
 
22
+ # ==== Core Import =============================================================
23
  import importlib, importlib.util, importlib.machinery
24
  import sys as __sys, os as __os, inspect as __inspect, hashlib
25
 
26
+ __sys.path.insert(0, __os.path.abspath("."))
27
 
28
  def _purge_spine_modules():
29
  for k in list(__sys.modules):
 
33
 
34
  def _sha256(path: str) -> str:
35
  try:
36
+ import hashlib
37
  h = hashlib.sha256()
38
  with open(path, "rb") as f:
39
  for chunk in iter(lambda: f.read(8192), b""):
 
43
  return "unknown"
44
 
45
  def _load_core_from_file(path: str):
 
 
 
 
46
  path = __os.path.abspath(path)
47
  if not __os.path.exists(path):
48
  return None
 
55
  return mod
56
 
57
  def _force_import_core():
 
58
  _purge_spine_modules()
 
 
59
  forced = __os.environ.get("SPINE_CORE_PATH")
60
  if forced and __os.path.exists(forced):
61
  mod = _load_core_from_file(forced)
62
  if mod:
63
  print("[CORE] forced path:", forced, "sha:", _sha256(forced))
64
  return mod
 
 
65
  for rel in [
66
  "spine_coder_core.py",
67
  "spine_coder/spine_coder_core.py",
 
73
  if mod:
74
  print("[CORE] loaded file:", p, "sha:", _sha256(p))
75
  return mod
 
 
76
  for modname in [
77
  "spine_coder.spine_coder.spine_coder_core",
78
  "spine_coder.spine_coder_core",
 
80
  try:
81
  mod = importlib.import_module(modname)
82
  mod = importlib.reload(mod)
 
 
83
  return mod
84
  except Exception:
85
  pass
86
+ raise ImportError("Unable to locate spine_coder_core.py")
 
87
 
88
  _core = _force_import_core()
89
  suggest_with_cpt_billing = _core.suggest_with_cpt_billing
90
 
91
+ # ==== Startup Probe ===========================================================
 
 
 
 
 
 
92
  try:
93
  _probe_note = "Discectomies at C4–C5, C5–C6, and C6–C7. Interbody cages and anterior plate spanning C4–C7."
94
  _probe = suggest_with_cpt_billing(_probe_note, payer="Medicare", top_k=5)
95
  print("[PROBE] build:", _probe.get("build"))
 
96
  except Exception as e:
97
  print("[PROBE] failed:", e)
98
 
 
101
  DEBUG = os.environ.get("DEBUG", "0") == "1"
102
  PAYER_CHOICES = ["Medicare", "BCBS", "Aetna", "Cigna", "UnitedHealthcare", "Other"]
103
 
104
+ # ==== Live Logging Config =====================================================
 
 
 
 
 
105
  LOG_PUSH_ENABLE = os.environ.get("LOG_PUSH_ENABLE", "0") == "1"
106
+ HF_TARGET_REPO = os.environ.get("HF_TARGET_REPO")
107
+ HF_REPO_TYPE = os.environ.get("HF_REPO_TYPE", "dataset")
108
+ HF_WRITE_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
109
+
110
+ print(f"[LOG CFG] enable={LOG_PUSH_ENABLE} repo={HF_TARGET_REPO} type={HF_REPO_TYPE} token_len={(len(HF_WRITE_TOKEN) if HF_WRITE_TOKEN else 0)}")
 
111
 
112
  _hf_api = None
113
  def _get_hf_api():
 
117
  _hf_api = HfApi(token=HF_WRITE_TOKEN)
118
  return _hf_api
119
 
120
+ def _ensure_logs_repo() -> bool:
121
+ if not LOG_PUSH_ENABLE or not HF_TARGET_REPO or not HF_WRITE_TOKEN:
122
+ return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  try:
 
124
  api = _get_hf_api()
125
+ api.create_repo(HF_TARGET_REPO, repo_type=HF_REPO_TYPE, private=True, exist_ok=True)
126
+ return True
127
+ except Exception as e:
128
+ print("[LOG PUSH] repo check failed:", e)
129
+ return False
130
 
131
+ def _fetch_existing_day_blob(path_in_repo: str) -> bytes:
132
+ try:
133
+ from huggingface_hub import hf_hub_download
134
+ fp = hf_hub_download(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  repo_id=HF_TARGET_REPO,
136
+ filename=path_in_repo,
137
+ repo_type=HF_REPO_TYPE,
138
+ token=HF_WRITE_TOKEN,
139
+ local_dir="/tmp",
140
+ local_dir_use_symlinks=False,
141
+ force_download=True,
142
  )
143
+ with open(fp, "rb") as f:
144
+ return f.read()
145
+ except Exception:
146
+ return b""
147
+
148
+ def _commit_bytes(path_in_repo: str, data: bytes, msg: str) -> None:
149
+ from huggingface_hub import CommitOperationAdd
150
+ api = _get_hf_api()
151
+ api.create_commit(
152
+ repo_id=HF_TARGET_REPO,
153
+ repo_type=HF_REPO_TYPE,
154
+ operations=[CommitOperationAdd(path_in_repo=path_in_repo, path_or_fileobj=io.BytesIO(data))],
155
+ commit_message=msg,
156
+ )
157
 
158
+ def _push_log_line_to_repo(entry: Dict[str, Any]) -> None:
159
+ if not LOG_PUSH_ENABLE or not HF_TARGET_REPO or not HF_WRITE_TOKEN:
160
+ return
161
+ if not _ensure_logs_repo():
162
+ return
163
+ day = datetime.utcnow().strftime("%Y-%m-%d")
164
+ path_in_repo = f"logs-live/{day}.jsonl"
165
+ line = (json.dumps(entry, ensure_ascii=False) + "\n").encode("utf-8")
166
+ for attempt in range(2):
167
+ try:
168
+ existing = _fetch_existing_day_blob(path_in_repo)
169
+ if existing and not existing.endswith(b"\n"):
170
+ existing += b"\n"
171
+ data = existing + line
172
+ _commit_bytes(path_in_repo, data, f"append {day} ({entry.get('event','')})")
173
+ return
174
+ except Exception as e:
175
+ print(f"[LOG PUSH] attempt {attempt+1} failed:", e)
176
+
177
+ # ==== Local Logging ===========================================================
178
+ LOG_DIR = "logs"
179
+ pathlib.Path(LOG_DIR).mkdir(exist_ok=True)
180
+
181
+ def _log_path(sid): return os.path.join(LOG_DIR, f"{sid}.jsonl")
182
+ def _utcnow_iso(): return datetime.now(timezone.utc).isoformat(timespec="seconds")
183
+
184
+ def _append_log(sid: str, entry: Dict[str, Any]):
185
+ entry = {"ts": _utcnow_iso(), "session_id": sid, **entry}
186
+ with open(_log_path(sid), "a", encoding="utf-8") as f:
187
  f.write(json.dumps(entry, ensure_ascii=False) + "\n")
 
188
  _push_log_line_to_repo(entry)
189
 
 
 
 
 
 
 
 
 
 
 
 
190
  # ==== UI Helpers ==============================================================
191
+ def _core_path():
192
+ try: return __inspect.getsourcefile(suggest_with_cpt_billing) or "unknown"
193
+ except Exception: return "unknown"
194
 
195
+ SUGG_COLS = ["CPT","Description","Rationale","Confidence","Primary","Category","Laterality","Units"]
 
 
 
 
 
 
 
 
 
 
196
  EMPTY_SUGG_DF = pd.DataFrame(columns=SUGG_COLS)
197
+ EMPTY_MODS_DF = pd.DataFrame([{"modifier":"—","reason":""}])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
+ def _coalesce_rows(res: Dict[str,Any]):
200
+ if not isinstance(res, dict): return EMPTY_SUGG_DF, {}
201
+ rows = []
202
+ for s in res.get("suggestions", []) or []:
203
  rows.append({
204
+ "CPT": s.get("cpt",""),
205
+ "Description": s.get("desc",""),
206
+ "Rationale": s.get("rationale",""),
207
+ "Confidence": round(float(s.get("confidence",0)),2) if s.get("confidence") else "",
208
  "Primary": "✓" if s.get("primary") else "",
209
+ "Category": s.get("category",""),
210
+ "Laterality": s.get("laterality",""),
211
+ "Units": s.get("units",1)
212
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  meta = {
214
+ "payer": res.get("payer",""),
215
+ "region": res.get("region",""),
216
+ "laterality": res.get("laterality",""),
217
+ "build": res.get("build",""),
218
+ "mode": res.get("mode",""),
219
+ "core_path": _core_path()
 
 
 
 
 
 
220
  }
221
+ df = pd.DataFrame(rows) if rows else EMPTY_SUGG_DF
 
 
 
 
 
 
222
  return df, meta
223
 
224
+ def _case_mods_df(res):
225
+ mods = res.get("case_modifiers",[]) or []
226
+ if not mods: return EMPTY_MODS_DF.copy()
227
+ return pd.DataFrame([{"modifier":f"-{m.get('modifier','')}", "reason":m.get("reason","")} for m in mods])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
+ def _summary_md(meta):
230
+ chips=[]
231
+ for k,v in meta.items():
232
+ if v: chips.append(f"`{k}: {v}`")
233
+ return " ".join(chips) if chips else "—"
234
 
235
+ def new_session(): return str(uuid.uuid4())[:8]
 
 
 
 
 
 
236
 
237
+ # ==== Core Actions ============================================================
238
+ def run_inference(note,payer,top_k,sid):
239
+ if not note.strip(): return EMPTY_SUGG_DF,EMPTY_MODS_DF.copy(),"—","", "",sid
240
+ _append_log(sid,{"event":"request","payer":payer,"note":note})
241
  try:
242
+ res = suggest_with_cpt_billing(note=note,payer=payer,top_k=top_k)
 
 
 
 
243
  except Exception as e:
244
+ tb=traceback.format_exc()
245
+ _append_log(sid,{"event":"error","error":repr(e),"trace":tb})
246
+ return EMPTY_SUGG_DF,EMPTY_MODS_DF.copy(),"—","",f"⚠️ {e}",sid
247
+ df,meta=_coalesce_rows(res)
248
+ mods=_case_mods_df(res)
249
+ summary=_summary_md(meta)
250
+ jsonp=json.dumps(res,indent=2,ensure_ascii=False)
251
+ _append_log(sid,{"event":"response","meta":meta,"rows":len(df)})
252
+ return df,mods,summary,jsonp,"",sid
253
+
254
+ def record_feedback(sid,vote,text):
255
+ if not vote and not text: return "Please choose 👍/👎 or add a short note."
256
+ _append_log(sid,{"event":"feedback","vote":vote,"text":text})
257
+ return "Thanks! Feedback saved."
258
+
259
+ def do_export(sid):
260
+ p=_log_path(sid)
261
+ out=os.path.join(LOG_DIR,f"export_{sid}.json")
262
+ if os.path.exists(p):
263
+ with open(p) as f: data=[json.loads(l) for l in f if l.strip()]
264
+ json.dump(data,open(out,"w"),indent=2,ensure_ascii=False)
265
+ _append_log(sid,{"event":"export","path":out})
266
+ return out
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
  def on_clear():
269
+ return "","Medicare",10,EMPTY_SUGG_DF,EMPTY_MODS_DF.copy(),"—","","",new_session()
 
 
 
 
 
 
270
 
271
+ # ==== Diagnostics =============================================================
272
+ def run_probe(sid):
273
+ info=[]
274
+ try:
275
+ t=[("Implicit TLIF","Left facetectomy L4–L5 with PEEK interbody cage and pedicle screws; rods secured.",["22633"]),
276
+ ("ACDF chain w/ plate","Discectomies at C4–C5, C5–C6, and C6–C7. Interbody cages and anterior plate spanning C4–C7.",["22551","22552","22846"]),
277
+ ("Exposure-only","Anterior exposure of L4–S1 performed by vascular surgeon for access. No fusion performed.",["00000"])]
278
+ for lbl,note,exp in t:
279
+ r=suggest_with_cpt_billing(note,payer="Medicare",top_k=10)
280
+ codes=[s.get("cpt") for s in r.get("suggestions",[])]
281
+ ok=all(ec in codes for ec in exp)
282
+ info.append(f"- **{lbl}** → `{', '.join(codes) or '∅'}` — {'✅PASS' if ok else '❌CHECK'}")
283
+ md="\n".join(info)
284
+ _append_log(sid,{"event":"probe","details":md})
285
+ return md,""
286
+ except Exception as e:
287
+ tb=traceback.format_exc()
288
+ _append_log(sid,{"event":"probe_error","error":repr(e),"trace":tb})
289
+ return "",f"⚠️ Probe failed: {e}"
290
 
291
+ def run_log_selftest(sid):
 
 
292
  try:
293
+ if not LOG_PUSH_ENABLE: return "","⚠️ LOG_PUSH_ENABLE=0"
294
+ if not HF_TARGET_REPO: return "","⚠️ HF_TARGET_REPO missing"
295
+ if not HF_WRITE_TOKEN: return "","⚠️ HF_TOKEN missing"
296
+ entry={"ts":_utcnow_iso(),"session_id":sid,"event":"selftest","msg":"hello"}
297
+ _push_log_line_to_repo(entry)
298
+ day=datetime.utcnow().strftime("%Y-%m-%d")
299
+ msg=f" Live log push working!\n→ `{HF_TARGET_REPO}/logs-live/{day}.jsonl`"
300
+ _append_log(sid,{"event":"selftest_ok"})
301
+ return msg,""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  except Exception as e:
303
+ tb=traceback.format_exc()
304
+ _append_log(sid,{"event":"selftest_err","err":repr(e),"trace":tb})
305
+ return "",f"⚠️ Self-test failed: {e}"
306
 
307
  # ==== Examples ================================================================
308
+ EXAMPLES=[
309
+ ["Left-sided TLIF L4–L5 with pedicle screws and interbody cage; posterolateral fusion performed.","Medicare",10],
310
+ ["ACDF C5C6 with PEEK cage and anterior plate.","Medicare",10],
311
+ ["Posterior cervical foraminotomy right C6–C7; no fusion.","Medicare",10]
 
 
 
 
 
 
 
 
312
  ]
313
 
314
  # ==== Theme / CSS =============================================================
315
+ THEME=gr.themes.Soft(primary_hue="indigo").set(background_fill_primary="#fff",button_primary_background_fill="#4f46e5")
316
+ CUSTOM_CSS="""
317
+ :root{--radius-lg:16px;}
318
+ .gradio-container{font-family:ui-sans-serif;}
319
+ .header-card{border-radius:18px;padding:18px;border:1px solid #1f2937;background:linear-gradient(180deg,#0f172a,#0b1220);color:#e5e7eb;}
320
+ .badge-row code{margin-right:8px;border-radius:12px;padding:2px 8px;background:#111827;color:#e5e7eb;}
321
+ .table-wrap{max-height:520px;overflow:auto;}
322
+ .footer-note{color:#94a3b8;font-size:12px;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  """
324
 
325
+ # ==== UI Layout ===============================================================
326
+ with gr.Blocks(theme=THEME,css=CUSTOM_CSS,title="Spine Coder — CPT Billing") as demo:
327
+ sid=gr.State(new_session())
 
328
  with gr.Row():
329
  with gr.Column():
330
  gr.Markdown("### 🦴 Spine Coder — CPT Billing & Operative Note NLP")
331
+ gr.Markdown('<div class="header-card">Structured CPT suggestions from spine operative notes — payer-aware modifiers & rationales.<br/><span class="footer-note">No PHI stored.</span></div>')
 
 
 
 
 
332
  with gr.Row():
 
333
  with gr.Column(scale=5):
334
+ note=gr.Textbox(label="Operative Note",lines=14,placeholder="Paste an operative note…")
335
+ payer=gr.Dropdown(PAYER_CHOICES,value="Medicare",label="Payer")
336
+ topk=gr.Slider(1,15,value=10,step=1,label="Top-K")
337
+ run=gr.Button("Analyze Note",variant="primary"); clear=gr.Button("Clear")
338
+ gr.Examples(EXAMPLES,inputs=[note,payer,topk],label="Quick Examples")
339
+ fb_choice=gr.Radio(["👍","👎"],label="Helpful?")
340
+ fb_text=gr.Textbox(label="Optional comment",lines=2)
341
+ fb_btn=gr.Button("Submit Feedback"); fb_status=gr.Markdown("")
342
+ sid_show=gr.Textbox(label="Session ID",interactive=False)
343
+ export=gr.Button("Export JSON"); export_file=gr.File(label="Download",interactive=False)
344
+ probe_btn=gr.Button("Run Probe"); probe_md=gr.Markdown("")
345
+ test_btn=gr.Button("Test Live Log Push"); test_md=gr.Markdown("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  with gr.Column(scale=7):
347
  gr.Markdown("#### Results")
348
+ summary=gr.Markdown("—",elem_classes=["badge-row"])
349
+ table=gr.Dataframe(value=EMPTY_SUGG_DF,label="CPT Suggestions",interactive=False,elem_classes=["table-wrap"])
350
+ gr.Markdown("### Case Modifiers")
351
+ mods=gr.Dataframe(value=EMPTY_MODS_DF.copy(),interactive=False)
352
+ json_out=gr.Code(language="json",value="",interactive=False)
353
+ warn=gr.Markdown("")
354
+ demo.load(lambda: (new_session(),new_session()),outputs=[sid,sid_show])
355
+ run.click(run_inference,inputs=[note,payer,topk,sid],outputs=[table,mods,summary,json_out,warn,sid])
356
+ note.submit(run_inference,inputs=[note,payer,topk,sid],outputs=[table,mods,summary,json_out,warn,sid])
357
+ clear.click(on_clear,outputs=[note,payer,top