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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -365
app.py CHANGED
@@ -1,365 +0,0 @@
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
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
- .table-wrap .dataframe {
263
- font-size: 14px;
264
- width: 100% !important;
265
- table-layout: fixed !important; /* distribute width evenly */
266
- border-collapse: collapse;
267
- }
268
- .table-wrap .dataframe th,
269
- .table-wrap .dataframe td {
270
- white-space: normal !important; /* allow wrapping */
271
- word-wrap: break-word !important;
272
- max-width: 240px !important; /* readable column width */
273
- text-align: left;
274
- vertical-align: top;
275
- padding: 6px 8px;
276
- }
277
- .footer-note { color:#64748b; font-size:12px; }
278
- """
279
-
280
- # ==== App Layout ==============================================================
281
- with gr.Blocks(theme=THEME, css=CUSTOM_CSS, title="Spine Coder — CPT Billing") as demo:
282
- session_id = gr.State(new_session())
283
-
284
- with gr.Row():
285
- with gr.Column():
286
- gr.Markdown("### 🦴 Spine Coder — CPT Billing & Operative Note NLP")
287
- gr.Markdown(
288
- '<div class="header-card">Structured CPT suggestions from spine operative notes — with payer-aware '
289
- 'modifiers, laterality detection, and rationales.<br/>'
290
- '<span class="footer-note">No PHI is stored; inputs are session-scoped and ephemeral.</span></div>'
291
- )
292
-
293
- with gr.Row():
294
- # Inputs
295
- with gr.Column(scale=5):
296
- note_in = gr.Textbox(
297
- label="Operative Note",
298
- placeholder="Paste an operative note here…",
299
- lines=14,
300
- autofocus=True,
301
- )
302
- with gr.Row():
303
- payer_dd = gr.Dropdown(PAYER_CHOICES, value="Medicare", label="Payer")
304
- topk = gr.Slider(1, 15, value=10, step=1, label="Top-K suggestions")
305
- with gr.Row():
306
- run_btn = gr.Button("Analyze Note", variant="primary")
307
- clear_btn = gr.Button("Clear")
308
-
309
- with gr.Accordion("Quick Examples", open=False):
310
- gr.Examples(
311
- examples=EXAMPLES,
312
- inputs=[note_in, payer_dd, topk],
313
- label="Click a row to load an example"
314
- )
315
-
316
- with gr.Accordion("Feedback", open=False):
317
- fb_choice = gr.Radio(choices=["👍", "👎"], label="Was this helpful?")
318
- fb_text = gr.Textbox(label="Optional comment", lines=2, placeholder="Tell us what worked or what missed…")
319
- fb_submit = gr.Button("Submit Feedback")
320
- fb_status = gr.Markdown("")
321
-
322
- with gr.Accordion("Session", open=False):
323
- sid_show = gr.Textbox(label="Session ID", value="", interactive=False)
324
- export_btn = gr.Button("Export Session as JSON")
325
- export_file = gr.File(label="Download", interactive=False)
326
-
327
- # Results
328
- with gr.Column(scale=6):
329
- gr.Markdown("#### Results")
330
- summary_md = gr.Markdown("—", elem_classes=["badge-row"])
331
- table = gr.Dataframe(
332
- value=EMPTY_DF,
333
- label="Suggestions",
334
- interactive=False,
335
- row_count=(0, "dynamic"),
336
- wrap=True,
337
- height=400, # taller, easier to read
338
- elem_classes=["table-wrap"],
339
- )
340
- with gr.Accordion("Raw JSON", open=False):
341
- json_out = gr.Code(language="json", value="", interactive=False)
342
- warn_md = gr.Markdown("")
343
-
344
- # ---- Events / Wiring ----
345
- def _on_load():
346
- sid = new_session()
347
- return sid, sid
348
-
349
- demo.load(_on_load, outputs=[session_id, sid_show])
350
-
351
- run_inputs = [note_in, payer_dd, topk, session_id]
352
- run_outputs = [table, summary_md, json_out, warn_md, session_id]
353
-
354
- run_btn.click(run_inference, inputs=run_inputs, outputs=run_outputs)
355
- note_in.submit(run_inference, inputs=run_inputs, outputs=run_outputs)
356
-
357
- clear_btn.click(on_clear, outputs=[note_in, payer_dd, topk, table, summary_md, json_out, warn_md, session_id])
358
-
359
- fb_submit.click(record_feedback, inputs=[session_id, fb_choice, fb_text], outputs=fb_status)
360
-
361
- export_btn.click(do_export, inputs=[session_id], outputs=[export_file])
362
-
363
- if __name__ == "__main__":
364
- # If running locally: set server_name to 0.0.0.0 for external access; Space ignores.
365
- demo.launch()