Slaiwala commited on
Commit
c159806
·
verified ·
1 Parent(s): 5c9f0ca

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +370 -0
app.py CHANGED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py — Spine Coder (Chatbot + Feedback + Session Logs) — Gradio 4.x
2
+ # ------------------------------------------------------------------------------
3
+ # Stable build: FINAL-v2.0 core wired, transitional regions, flags, laterality,
4
+ # structured logs, graceful errors, and a clean UI.
5
+ # ------------------------------------------------------------------------------
6
+
7
+ import os
8
+ import json
9
+ import uuid
10
+ import pathlib
11
+ import traceback
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Dict, List, Tuple
14
+
15
+ import pandas as pd
16
+ import gradio as gr
17
+
18
+ # ---- Core import --------------------------------------------------------------
19
+ import os as _os, sys as _sys, inspect as _inspect
20
+ _sys.path.insert(0, _os.path.abspath(".")) # ensure repo root is first on sys.path
21
+
22
+ try:
23
+ # If your package layout is spine_coder/spine_coder/spine_coder_core.py
24
+ from spine_coder.spine_coder.spine_coder_core import suggest_with_cpt_billing
25
+ except ImportError:
26
+ # Fallback to flat layout if you later flatten the folder tree
27
+ from spine_coder.spine_coder_core import suggest_with_cpt_billing
28
+
29
+ # Prove which file is actually loaded (shows in Space logs)
30
+ try:
31
+ print("[CORE] loaded from:", _inspect.getsourcefile(suggest_with_cpt_billing))
32
+ except Exception:
33
+ pass
34
+
35
+
36
+ # ==== Config ==================================================================
37
+ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
38
+ DEBUG = os.environ.get("DEBUG", "0") == "1"
39
+ PAYER_CHOICES = ["Medicare", "BCBS", "Aetna", "Cigna", "UnitedHealthcare", "Other"]
40
+
41
+ # ==== Logging =================================================================
42
+ LOG_DIR = os.environ.get("LOG_DIR", "logs")
43
+ pathlib.Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
44
+
45
+ def _log_path(session_id: str) -> str:
46
+ return os.path.join(LOG_DIR, f"{session_id}.jsonl")
47
+
48
+ def _utcnow_iso() -> str:
49
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
50
+
51
+ def _append_log(session_id: str, entry: Dict[str, Any]) -> None:
52
+ entry = {"ts": _utcnow_iso(), "session_id": session_id, **entry}
53
+ with open(_log_path(session_id), "a", encoding="utf-8") as f:
54
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
55
+
56
+ def export_session(session_id: str) -> str:
57
+ src = _log_path(session_id)
58
+ data: List[Dict[str, Any]] = []
59
+ if os.path.exists(src):
60
+ with open(src, "r", encoding="utf-8") as f:
61
+ data = [json.loads(l) for l in f if l.strip()]
62
+ out_path = os.path.join(LOG_DIR, f"export_{session_id}.json")
63
+ with open(out_path, "w", encoding="utf-8") as f:
64
+ json.dump(data, f, indent=2, ensure_ascii=False)
65
+ return out_path
66
+
67
+ # ==== UI helpers ==============================================================
68
+
69
+ TABLE_COLS = [
70
+ "CPT","Modifier","Modifiers","Description","Rationale",
71
+ "Confidence","Primary","Category","Laterality","Units"
72
+ ]
73
+ EMPTY_DF = pd.DataFrame(columns=TABLE_COLS)
74
+
75
+ def _coalesce_suggestions(result: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
76
+ """
77
+ Return (rows_for_table, meta_badges) from core result.
78
+ Robust to minor schema differences (list vs dict for levels/flags).
79
+ """
80
+ if not isinstance(result, dict):
81
+ return [], {}
82
+
83
+ # ---------- rows ----------
84
+ sugg = result.get("suggestions") or []
85
+ rows: List[Dict[str, Any]] = []
86
+ case_lat = (result.get("laterality") or "").strip().lower()
87
+
88
+ for s in sugg:
89
+ if not isinstance(s, dict):
90
+ continue
91
+
92
+ # derive row laterality from explicit field, LT/RT modifiers, or case-level fallback
93
+ row_lat = (s.get("laterality") or "").strip().lower()
94
+ mods = s.get("modifiers", []) or []
95
+ if not row_lat:
96
+ if isinstance(mods, list) and "LT" in mods:
97
+ row_lat = "left"
98
+ elif isinstance(mods, list) and "RT" in mods:
99
+ row_lat = "right"
100
+ elif case_lat in ("left", "right", "bilateral"):
101
+ row_lat = case_lat
102
+
103
+ conf_val = s.get("confidence")
104
+ conf_out = round(float(conf_val), 2) if isinstance(conf_val, (int, float)) else (conf_val or "")
105
+
106
+ rows.append({
107
+ "CPT": s.get("cpt", ""),
108
+ "Modifier": s.get("modifier") or "",
109
+ "Modifiers": ", ".join(mods) if isinstance(mods, list) else (s.get("modifiers") or ""),
110
+ "Description": s.get("desc", ""),
111
+ "Rationale": s.get("rationale", ""),
112
+ "Confidence": conf_out,
113
+ "Primary": "✓" if s.get("primary") else "",
114
+ "Category": s.get("category", ""),
115
+ "Laterality": row_lat,
116
+ "Units": s.get("units", 1),
117
+ })
118
+
119
+ # ---------- normalize levels ----------
120
+ segs: List[str] = []
121
+ inters_list: List[str] = []
122
+ lvl_lat: str = ""
123
+
124
+ levels_obj = result.get("levels")
125
+ if isinstance(levels_obj, dict):
126
+ segs = list(levels_obj.get("segments") or [])
127
+ inters_list = list(levels_obj.get("interspaces") or [])
128
+ lvl_lat = levels_obj.get("laterality", "") or ""
129
+ elif isinstance(levels_obj, list):
130
+ segs = [str(x) for x in levels_obj]
131
+
132
+ # ---------- normalize flags ----------
133
+ flags_obj = result.get("flags")
134
+ if isinstance(flags_obj, list):
135
+ flags_list = [str(x) for x in flags_obj]
136
+ elif isinstance(flags_obj, dict):
137
+ flags_list = [k for k, v in flags_obj.items() if v]
138
+ else:
139
+ flags_list = []
140
+
141
+ # ---------- meta ----------
142
+ meta = {
143
+ "payer": result.get("payer", ""),
144
+ "region": result.get("region", ""),
145
+ "laterality": result.get("laterality", "") or lvl_lat,
146
+ "levels_segments": ", ".join(segs),
147
+ # Prefer numeric estimate if present; else join the interspaces list
148
+ "levels_interspaces": (
149
+ str(result.get("interspaces_est", "")) if "interspaces_est" in result
150
+ else ", ".join(inters_list)
151
+ ),
152
+ "flags": ", ".join(sorted(flags_list)),
153
+ "build": result.get("build", ""),
154
+ "mode": result.get("mode", ""),
155
+ }
156
+ return rows, meta
157
+
158
+ def _summary_md(meta: Dict[str, Any]) -> str:
159
+ chips = []
160
+ if meta.get("region"): chips.append(f"`Region: {meta['region']}`")
161
+ if meta.get("laterality"): chips.append(f"`Laterality: {meta['laterality']}`")
162
+ if meta.get("levels_segments"): chips.append(f"`Segments: {meta['levels_segments']}`")
163
+ if meta.get("levels_interspaces"): chips.append(f"`Interspaces: {meta['levels_interspaces']}`")
164
+ if meta.get("flags"): chips.append(f"`Flags: {meta['flags']}`")
165
+ if meta.get("build"): chips.append(f"`Build: {meta['build']}`")
166
+ if meta.get("mode"): chips.append(f"`Mode: {meta['mode']}`")
167
+ return " ".join(chips) if chips else "—"
168
+
169
+ def new_session() -> str:
170
+ return str(uuid.uuid4())[:8]
171
+
172
+ # ==== Core actions ============================================================
173
+
174
+ def run_inference(note: str, payer: str, top_k: int, session_id: str):
175
+ if not note.strip():
176
+ return EMPTY_DF, "—", "", "", session_id
177
+
178
+ _append_log(session_id, {"event": "request", "payer": payer, "top_k": top_k, "note": note})
179
+ warn_text = ""
180
+ try:
181
+ result = suggest_with_cpt_billing(note=note, payer=payer, top_k=top_k)
182
+ if DEBUG:
183
+ print("[DEBUG] build/region/laterality/flags:",
184
+ result.get("build"), result.get("region"),
185
+ result.get("laterality"), result.get("flags"))
186
+ except Exception as e:
187
+ tb = traceback.format_exc()
188
+ _append_log(session_id, {"event": "error", "error": repr(e), "traceback": tb})
189
+ warn = f"⚠️ Error: {e}"
190
+ if DEBUG:
191
+ warn += f"\n\n```traceback\n{tb}\n```"
192
+ return EMPTY_DF, "—", "", warn, session_id
193
+
194
+ rows, meta = _coalesce_suggestions(result)
195
+ df = EMPTY_DF if not rows else pd.DataFrame(rows)
196
+ if not df.empty:
197
+ # ensure consistent column order
198
+ for col in TABLE_COLS:
199
+ if col not in df.columns:
200
+ df[col] = ""
201
+ df = df[TABLE_COLS]
202
+
203
+ summary = _summary_md(meta)
204
+ json_pretty = json.dumps(result, indent=2, ensure_ascii=False)
205
+
206
+ _append_log(session_id, {"event": "response", "meta": meta, "rows_len": len(rows)})
207
+ return df, summary, json_pretty, warn_text, session_id
208
+
209
+ def record_feedback(session_id: str, vote: str, text: str):
210
+ if not vote and not text:
211
+ return "Please choose 👍/👎 or add a short note."
212
+ _append_log(session_id, {"event": "feedback", "vote": vote, "text": text})
213
+ return "Thanks! Your feedback was recorded."
214
+
215
+ def do_export(session_id: str):
216
+ path = export_session(session_id)
217
+ _append_log(session_id, {"event": "export", "path": path})
218
+ return path
219
+
220
+ def on_clear():
221
+ return "", "Medicare", 10, EMPTY_DF, "—", "", "", new_session()
222
+
223
+ # ==== Examples ================================================================
224
+
225
+ EXAMPLES = [
226
+ ["Left-sided TLIF L4–L5 with pedicle screws and interbody cage; posterolateral fusion performed. Navigation used.", "Medicare", 10],
227
+ ["ACDF C5–C6 with PEEK cage and anterior plate; microscope and neuromonitoring used.", "Medicare", 10],
228
+ ["Posterior cervical foraminotomy right C6–C7; no fusion or instrumentation.", "Medicare", 10],
229
+ ["ALIF L5–S1 with structural allograft; non-segmental instrumentation placed.", "Medicare", 10],
230
+ ["Removal of posterior segmental instrumentation T10–L2; no new hardware placed.", "Medicare", 10],
231
+ ]
232
+
233
+ # ==== Theme / CSS =============================================================
234
+
235
+ THEME = gr.themes.Soft(
236
+ primary_hue="indigo",
237
+ secondary_hue="blue",
238
+ neutral_hue="slate",
239
+ ).set(
240
+ body_text_color="#0f172a",
241
+ background_fill_primary="#ffffff",
242
+ button_primary_background_fill="#4f46e5",
243
+ input_background_fill="#ffffff",
244
+ )
245
+
246
+ # Wider, more readable table (no Dataframe.height arg; do it via CSS)
247
+ CUSTOM_CSS = """
248
+ :root { --radius-lg: 16px; }
249
+ .gradio-container { font-family: ui-sans-serif, system-ui, -apple-system; }
250
+ .header-card {
251
+ border-radius: 18px;
252
+ padding: 18px;
253
+ border: 1px solid #e5e7eb;
254
+ background: linear-gradient(180deg,#fafafa,#ffffff);
255
+ }
256
+ .badge-row code {
257
+ margin-right: 8px;
258
+ border-radius: 12px;
259
+ padding: 2px 8px;
260
+ background: #f1f5f9;
261
+ }
262
+
263
+ /* Make the dataframe area taller & scrollable */
264
+ .table-wrap { max-height: 420px; overflow: auto; }
265
+
266
+ /* Improve readability inside the table */
267
+ .table-wrap .dataframe {
268
+ font-size: 14px;
269
+ width: 100% !important;
270
+ table-layout: fixed !important;
271
+ border-collapse: collapse;
272
+ }
273
+ .table-wrap .dataframe th,
274
+ .table-wrap .dataframe td {
275
+ white-space: normal !important;
276
+ word-wrap: break-word !important;
277
+ max-width: 240px !important;
278
+ text-align: left;
279
+ vertical-align: top;
280
+ padding: 6px 8px;
281
+ }
282
+
283
+ .footer-note { color:#64748b; font-size:12px; }
284
+ """
285
+
286
+ # ==== App Layout ==============================================================
287
+ with gr.Blocks(theme=THEME, css=CUSTOM_CSS, title="Spine Coder — CPT Billing") as demo:
288
+ session_id = gr.State(new_session())
289
+
290
+ with gr.Row():
291
+ with gr.Column():
292
+ gr.Markdown("### 🦴 Spine Coder — CPT Billing & Operative Note NLP")
293
+ gr.Markdown(
294
+ '<div class="header-card">Structured CPT suggestions from spine operative notes — with payer-aware '
295
+ 'modifiers, laterality detection, and rationales.<br/>'
296
+ '<span class="footer-note">No PHI is stored; inputs are session-scoped and ephemeral.</span></div>'
297
+ )
298
+
299
+ with gr.Row():
300
+ # Inputs
301
+ with gr.Column(scale=5):
302
+ note_in = gr.Textbox(
303
+ label="Operative Note",
304
+ placeholder="Paste an operative note here…",
305
+ lines=14,
306
+ autofocus=True,
307
+ )
308
+ with gr.Row():
309
+ payer_dd = gr.Dropdown(PAYER_CHOICES, value="Medicare", label="Payer")
310
+ topk = gr.Slider(1, 15, value=10, step=1, label="Top-K suggestions")
311
+ with gr.Row():
312
+ run_btn = gr.Button("Analyze Note", variant="primary")
313
+ clear_btn = gr.Button("Clear")
314
+
315
+ with gr.Accordion("Quick Examples", open=False):
316
+ gr.Examples(
317
+ examples=EXAMPLES,
318
+ inputs=[note_in, payer_dd, topk],
319
+ label="Click a row to load an example"
320
+ )
321
+
322
+ with gr.Accordion("Feedback", open=False):
323
+ fb_choice = gr.Radio(choices=["👍", "👎"], label="Was this helpful?")
324
+ fb_text = gr.Textbox(label="Optional comment", lines=2, placeholder="Tell us what worked or what missed…")
325
+ fb_submit = gr.Button("Submit Feedback")
326
+ fb_status = gr.Markdown("")
327
+
328
+ with gr.Accordion("Session", open=False):
329
+ sid_show = gr.Textbox(label="Session ID", value="", interactive=False)
330
+ export_btn = gr.Button("Export Session as JSON")
331
+ export_file = gr.File(label="Download", interactive=False)
332
+
333
+ # Results
334
+ with gr.Column(scale=6):
335
+ gr.Markdown("#### Results")
336
+ summary_md = gr.Markdown("—", elem_classes=["badge-row"])
337
+ table = gr.Dataframe(
338
+ value=EMPTY_DF,
339
+ label="Suggestions",
340
+ interactive=False,
341
+ row_count=(0, "dynamic"),
342
+ wrap=True,
343
+ elem_classes=["table-wrap"], # CSS above makes it tall/scrollable
344
+ )
345
+ with gr.Accordion("Raw JSON", open=False):
346
+ json_out = gr.Code(language="json", value="", interactive=False)
347
+ warn_md = gr.Markdown("")
348
+
349
+ # ---- Events / Wiring ----
350
+ def _on_load():
351
+ sid = new_session()
352
+ return sid, sid
353
+
354
+ demo.load(_on_load, outputs=[session_id, sid_show])
355
+
356
+ run_inputs = [note_in, payer_dd, topk, session_id]
357
+ run_outputs = [table, summary_md, json_out, warn_md, session_id]
358
+
359
+ run_btn.click(run_inference, inputs=run_inputs, outputs=run_outputs)
360
+ note_in.submit(run_inference, inputs=run_inputs, outputs=run_outputs)
361
+
362
+ clear_btn.click(on_clear, outputs=[note_in, payer_dd, topk, table, summary_md, json_out, warn_md, session_id])
363
+
364
+ fb_submit.click(record_feedback, inputs=[session_id, fb_choice, fb_text], outputs=fb_status)
365
+
366
+ export_btn.click(do_export, inputs=[session_id], outputs=[export_file])
367
+
368
+ if __name__ == "__main__":
369
+ # If running locally: set server_name to 0.0.0.0 for external access; Space ignores.
370
+ demo.launch()