Rajan Sharma commited on
Commit
651c3c3
·
verified ·
1 Parent(s): d4c731a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +243 -291
app.py CHANGED
@@ -1,16 +1,17 @@
1
  # app.py
2
  import os, re, json, traceback, pathlib
3
  from functools import lru_cache
 
4
 
5
  import gradio as gr
6
  import torch
7
- import regex as re2 # pip install regex
8
 
9
  from settings import SNAPSHOT_PATH, PERSIST_CONTENT
10
  from audit_log import log_event, hash_summary
11
  from privacy import redact_text
12
 
13
- # ---------- Environment / cache (Spaces-safe, writable) ----------
14
  HOME = pathlib.Path.home()
15
  HF_HOME = str(HOME / ".cache" / "huggingface")
16
  HF_HUB_CACHE = str(HOME / ".cache" / "huggingface" / "hub")
@@ -59,37 +60,28 @@ HF_TOKEN = os.getenv("HUGGINGFACE_HUB_TOKEN") or os.getenv("HF_TOKEN")
59
  COHERE_API_KEY = os.getenv("COHERE_API_KEY")
60
  USE_HOSTED_COHERE = bool(COHERE_API_KEY and _HAS_COHERE)
61
 
62
- # Larger output (Cohere + HF fallback)
63
  MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "2048"))
64
 
65
- # ---------- System Master (two-phase + normal chat) ----------
66
  SYSTEM_MASTER = """
67
- SYSTEM ROLE (fixed, always on)
68
  You are ClarityOps, a medical analytics system that interacts only via this chat.
69
 
70
- Operating modes:
71
- - Normal Chat: answer general questions naturally.
72
- - Scenario Mode (two phases, no assumptions):
73
- Phase 1: Ask up to 5 concise clarification questions, grouped by category (Prioritization, Capacity, Cost, Clinical, Recommendations). Only ask for items still missing from the scenario + uploaded data. Then STOP and WAIT.
74
- Phase 2: After answers are provided, produce the final structured analysis in the required format. If any critical input remains missing, output EXACTLY: INSUFFICIENT_DATA and list the missing fields.
75
-
76
  Absolute rules:
77
- - Use ONLY information in this conversation (scenario text + uploaded files + user answers).
78
- - Never invent data or assume defaults without explicit user confirmation.
79
- - Prefer analytics/longitudinal insights (risk targeting, follow-up, clustering) over generic ops advice.
80
- - Show calculations for capacity and costs. Use correct clinical units/ranges.
81
- - Add a short Provenance mapping each key output to its source (scenario text, files, answers).
82
-
83
- Formatting hard rules for Scenario Mode:
84
- - Phase 1 header: “Clarification Questions”
85
- - Phase 2 header: “Structured Analysis”
86
- - Phase 2 section order:
87
  1. Prioritization
88
  2. Capacity
89
  3. Cost
90
  4. Clinical Benefits
91
  5. ClarityOps Top 3 Recommendations
92
- (Include a short Provenance block at the end.)
93
  """.strip()
94
 
95
  # ---------- Helpers ----------
@@ -107,9 +99,7 @@ def is_identity_query(message, history):
107
  r"\bdescribe\s+yourself\b", r"\band\s+you\s*\?\b", r"\byour\s+name\b",
108
  r"\bwho\s+am\s+i\s+chatting\s+with\b",
109
  ]
110
- def match(t):
111
- t = (t or "").strip().lower()
112
- return any(re.search(p, t) for p in patterns)
113
  if match(message): return True
114
  if history:
115
  last_user = history[-1][0] if isinstance(history[-1], (list, tuple)) else None
@@ -124,25 +114,29 @@ def _iter_user_assistant(history):
124
  yield u, a
125
 
126
  def _sanitize_text(s: str) -> str:
127
- if not isinstance(s, str): return s
 
128
  return re2.sub(r'[\p{C}--[\n\t]]+', '', s)
129
 
130
- def _history_to_prompt(message, history):
131
- parts = [f"System: {SYSTEM_MASTER}"]
132
- for u, a in _iter_user_assistant(history):
133
- if u: parts.append(f"User: {u}")
134
- if a: parts.append(f"Assistant: {a}")
135
- parts.append(f"User: {message}")
136
- parts.append("Assistant:")
137
- return "\n".join(parts)
138
 
139
- # ---------- LLM invocation ----------
140
  def cohere_chat(message, history):
141
  if not USE_HOSTED_COHERE:
142
  return None
143
  try:
144
  client = cohere.Client(api_key=COHERE_API_KEY)
145
- prompt = _history_to_prompt(message, history)
 
 
 
 
 
 
146
  resp = client.chat(
147
  model="command-r7b-12-2024",
148
  message=prompt,
@@ -156,6 +150,7 @@ def cohere_chat(message, history):
156
  except Exception:
157
  return None
158
 
 
159
  @lru_cache(maxsize=1)
160
  def load_local_model():
161
  if not HF_TOKEN:
@@ -207,7 +202,7 @@ def local_generate(model, tokenizer, input_ids, max_new_tokens=MAX_NEW_TOKENS):
207
  gen_only = out[0, input_ids.shape[-1]:]
208
  return tokenizer.decode(gen_only, skip_special_tokens=True).strip()
209
 
210
- # ---------- Snapshot, retriever, RAG ----------
211
  def _load_snapshot(path=SNAPSHOT_PATH):
212
  try:
213
  with open(path, "r", encoding="utf-8") as f:
@@ -225,102 +220,7 @@ def _load_snapshot(path=SNAPSHOT_PATH):
225
  init_retriever()
226
  _session_rag = SessionRAG()
227
 
228
- # ---------- Scenario detection & gap analysis ----------
229
- def detect_scenario_type(text: str, artifacts):
230
- """
231
- Very simple keyword detector for now. Returns "diabetes_screening" or None.
232
- """
233
- t = (text or "").lower()
234
- joined = " ".join((a.get("name","") + " " + " ".join(a.get("columns", []))) for a in (artifacts or []))
235
- tt = f"{t} {joined}".lower()
236
- diabetes_terms = [
237
- "mobile diabetes screening", "mdsi", "a1c", "metabolic syndrome",
238
- "metis settlement", "obesity", "pre-diabetes", "screening program"
239
- ]
240
- if any(k in tt for k in diabetes_terms):
241
- return "diabetes_screening"
242
- return None
243
-
244
- def build_data_summary(artifacts):
245
- """
246
- Human-readable summary of uploaded data coverage (CSV columns & sample rows count).
247
- """
248
- lines = []
249
- for a in (artifacts or []):
250
- if a.get("kind") == "csv":
251
- cols = ", ".join(map(str, a.get("columns", []))) or "<no columns found>"
252
- lines.append(f"- **{a.get('name','(csv)')}** · columns: {cols} · sampled_rows: {a.get('n_rows_sampled',0)}")
253
- return "\n".join(lines) if lines else "_No structured CSVs detected._"
254
-
255
- def _has_cols(artifacts, name_hint, required_cols):
256
- """
257
- Check if any CSV artifact whose filename contains name_hint has all required columns.
258
- """
259
- for a in (artifacts or []):
260
- if a.get("kind") != "csv":
261
- continue
262
- if name_hint and name_hint not in a.get("name","").lower():
263
- continue
264
- cols = set(map(lambda s: s.strip().lower(), a.get("columns", [])))
265
- if all(rc.lower() in cols for rc in required_cols):
266
- return True
267
- return False
268
-
269
- def analyze_gaps(scenario_text: str, artifacts):
270
- """
271
- Returns: (missing_critical: list[str], missing_nice: list[str], scenario_note: str)
272
- Only checks what's applicable for the detected scenario.
273
- """
274
- stype = detect_scenario_type(scenario_text, artifacts)
275
- crit_missing, nice_missing = [], []
276
- note = ""
277
-
278
- if stype == "diabetes_screening":
279
- note = "Detected scenario: **Mobile Diabetes Screening in rural communities**."
280
-
281
- # Check for prioritization data coverage
282
- if not (_has_cols(artifacts, "population", ["settlement","population"]) or
283
- _has_cols(artifacts, "metis", ["settlement","population"]) or
284
- _has_cols(artifacts, "", ["settlement","population"])):
285
- crit_missing.append("Population by settlement (CSV with columns like: settlement, population)")
286
-
287
- if not (_has_cols(artifacts, "health", ["settlement","diabetes_prevalence"]) or
288
- _has_cols(artifacts, "", ["settlement","diabetes_prevalence"])):
289
- crit_missing.append("Diabetes prevalence by settlement (e.g., settlement, diabetes_prevalence)")
290
-
291
- # Risk factors
292
- if not (_has_cols(artifacts, "health", ["obesity"]) or _has_cols(artifacts, "", ["obesity"])):
293
- nice_missing.append("Obesity prevalence by settlement (e.g., obesity %)")
294
- if not (_has_cols(artifacts, "health", ["metabolic_syndrome"]) or _has_cols(artifacts, "", ["metabolic_syndrome"])):
295
- nice_missing.append("Metabolic syndrome prevalence by settlement (%)")
296
-
297
- # Capacity assumptions (teams/day)
298
- txt = scenario_text.lower()
299
- if "teams" not in txt and "mobile clinic" not in txt:
300
- crit_missing.append("Number of mobile teams and work schedule (days/week, duration)")
301
- if "clients/day" not in txt and "per day" not in txt:
302
- crit_missing.append("Throughput per team (clients per day)")
303
-
304
- # Cost
305
- if not (_has_cols(artifacts, "program_cost", ["startup_cost_per_client"]) or "startup cost" in txt):
306
- crit_missing.append("Startup cost per client")
307
- if not (_has_cols(artifacts, "program_cost", ["ongoing_cost_per_client"]) and "ongoing cost" in txt):
308
- # either file column or explicit in text is okay
309
- crit_missing.append("Ongoing cost per client")
310
-
311
- # Longitudinal outcomes (not always critical for Phase 2, but preferred)
312
- if not (_has_cols(artifacts, "longitudinal", ["a1c"]) or "a1c" in txt):
313
- nice_missing.append("Longitudinal A1c change for repeat participants")
314
- if not (_has_cols(artifacts, "longitudinal", ["systolic_bp"]) or "blood pressure" in txt):
315
- nice_missing.append("Longitudinal systolic/diastolic BP change")
316
- if not (_has_cols(artifacts, "longitudinal", ["bmi"]) or "bmi" in txt):
317
- nice_missing.append("Longitudinal BMI change")
318
- if not (_has_cols(artifacts, "longitudinal", ["cholesterol"]) or "cholesterol" in txt):
319
- nice_missing.append("Longitudinal total cholesterol change")
320
-
321
- return crit_missing, nice_missing, note
322
-
323
- # ---------- Executive pre-compute (optional) ----------
324
  def _mdsi_block():
325
  base_capacity = capacity_projection(18, 48, 6)
326
  cons_capacity = capacity_projection(12, 48, 6)
@@ -333,13 +233,126 @@ def _mdsi_block():
333
  "outcomes_summary": outcomes
334
  }, indent=2)
335
 
336
- # ---------- Core chat logic (auto scenario; no assumptions) ----------
337
- def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answers=False, force_phase=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  """
339
  awaiting_answers:
340
- - False: not waiting for Phase 2 answers
341
- - True: expecting answers to clarifications for Scenario Mode
342
- force_phase: None | "clarify" | "analyze" (internal, used by upload handler)
343
  """
344
  try:
345
  log_event("user_message", None, {"sizes": {"chars": len(user_msg or "")}})
@@ -355,7 +368,7 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
355
  ans = "I am ClarityOps, your strategic decision making AI partner."
356
  return history + [(user_msg, ans)], awaiting_answers
357
 
358
- # Ingest uploads if paths present (also handled in upload event; safe to repeat)
359
  artifacts = []
360
  if uploaded_files_paths:
361
  ing = extract_text_from_files(uploaded_files_paths)
@@ -367,7 +380,54 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
367
  _session_rag.register_artifacts(artifacts)
368
  log_event("uploads_added", None, {"chunks": len(chunks), "artifacts": len(artifacts)})
369
 
370
- # Session retrieval context
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  session_snips = "\n---\n".join(_session_rag.retrieve(
372
  "diabetes screening Indigenous Métis mobile program cost throughput outcomes logistics",
373
  k=6
@@ -382,125 +442,62 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
382
  user_lower = (safe_in or "").lower()
383
  mdsi_extra = _mdsi_block() if ("diabetes" in user_lower or "mdsi" in user_lower or "mobile screening" in user_lower) else ""
384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  scenario_block = safe_in if len((safe_in or "")) > 0 else ""
386
  system_preamble = build_system_preamble(
387
  snapshot=snapshot,
388
  policy_context=policy_context,
389
  computed_numbers=computed,
390
- scenario_text=scenario_block + (f"\n\nExecutive Pre-Computed Blocks:\n{mdsi_extra}" if mdsi_extra else ""),
391
  session_snips=session_snips
392
  )
393
 
394
- # Decide mode (normal vs scenario)
395
- stype = detect_scenario_type(safe_in, _session_rag.artifacts)
396
- in_scenario = bool(stype)
397
-
398
- # Gap analysis
399
- crit_missing, nice_missing, det_note = analyze_gaps(safe_in, _session_rag.artifacts)
400
-
401
- # Determine phase directive
402
- if force_phase == "clarify":
403
- awaiting = True
404
- directive = (
405
- "\n\n[INSTRUCTION TO MODEL]\n"
406
- "Produce **Phase 1** only:\n"
407
- "- Header: 'Clarification Questions'\n"
408
- "- Ask ONLY for the items listed as missing (critical first, then optional). Group by category.\n"
409
- "- Then STOP and WAIT.\n"
410
- )
411
- elif force_phase == "analyze":
412
- awaiting = False
413
- if crit_missing:
414
- # hard block
415
- return history + [(user_msg,
416
- "INSUFFICIENT_DATA\n\nMissing critical inputs:\n- " + "\n- ".join(crit_missing)
417
- )], False
418
- directive = (
419
- "\n\n[INSTRUCTION TO MODEL]\n"
420
- "Produce **Phase 2** only:\n"
421
- "- Header: 'Structured Analysis'\n"
422
- "- Follow the exact section order (Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations).\n"
423
- "- Use uploaded files + the user's latest answers as authoritative. Show calculations, units, and a brief Provenance.\n"
424
- )
425
- else:
426
- # Auto-decide
427
- if in_scenario:
428
- if not awaiting_answers:
429
- # entering Phase 1 if there are any missing fields; if nothing missing, we can go to Phase 2 immediately
430
- if crit_missing:
431
- awaiting = True
432
- directive = (
433
- "\n\n[INSTRUCTION TO MODEL]\n"
434
- "Produce **Phase 1** only:\n"
435
- "- Header: 'Clarification Questions'\n"
436
- "- Ask ONLY for the items listed as missing (critical first, then optional). Group by category.\n"
437
- "- Then STOP and WAIT.\n"
438
- )
439
- else:
440
- awaiting = False
441
- directive = (
442
- "\n\n[INSTRUCTION TO MODEL]\n"
443
- "Produce **Phase 2** only:\n"
444
- "- Header: 'Structured Analysis'\n"
445
- "- Follow the exact section order (Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations).\n"
446
- "- Use uploaded files + the user's latest answers as authoritative. Show calculations, units, and a brief Provenance.\n"
447
- )
448
- else:
449
- # expecting answers; attempt Phase 2 but block if still missing critical
450
- if crit_missing:
451
- return history + [(user_msg,
452
- "INSUFFICIENT_DATA\n\nMissing critical inputs:\n- " + "\n- ".join(crit_missing)
453
- )], True
454
- awaiting = False
455
- directive = (
456
- "\n\n[INSTRUCTION TO MODEL]\n"
457
- "Produce **Phase 2** only:\n"
458
- "- Header: 'Structured Analysis'\n"
459
- "- Follow the exact section order (Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations).\n"
460
- "- Use uploaded files + the user's latest answers as authoritative. Show calculations, units, and a brief Provenance.\n"
461
- )
462
- else:
463
- # Normal chat mode
464
- awaiting = awaiting_answers
465
- directive = "\n\n[INSTRUCTION TO MODEL]\nAnswer normally as a helpful assistant.\n"
466
-
467
- augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser message:\n" + safe_in + directive
468
-
469
- # Call LLM
470
  out = cohere_chat(augmented_user, history)
471
  if not out:
472
  model, tokenizer = load_local_model()
473
  inputs = build_inputs(tokenizer, augmented_user, history)
474
  out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
475
 
476
- # Clean + sanitize
477
  if isinstance(out, str):
478
  for tag in ("Assistant:", "System:", "User:"):
479
  if out.startswith(tag):
480
  out = out[len(tag):].strip()
481
- out = _sanitize_text(out)
482
 
483
- # Safety (output)
484
  safe_out, blocked_out, reason_out = safety_filter(out, mode="output")
485
  if blocked_out:
486
  safe_out = refusal_reply(reason_out)
487
 
488
- # Flip phase state based on headers (scenario only)
489
- new_awaiting = awaiting
490
- if in_scenario:
491
- low = (safe_out or "").lower()
492
- if "clarification questions" in low:
493
- new_awaiting = True
494
- elif "structured analysis" in low:
495
- new_awaiting = False
496
-
497
  log_event("assistant_reply", None, {
498
  **hash_summary("prompt", augmented_user if not PERSIST_CONTENT else ""),
499
  **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
500
- "awaiting_next_phase": new_awaiting
 
501
  })
502
 
503
- return history + [(user_msg, safe_out)], new_awaiting
504
 
505
  except Exception as e:
506
  err = f"Error: {e}"
@@ -514,11 +511,10 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
514
  theme = gr.themes.Soft(primary_hue="teal", neutral_hue="slate", radius_size=gr.themes.sizes.radius_lg)
515
  custom_css = """
516
  :root { --brand-bg: #e6f7f8; --brand-accent: #0d9488; --brand-text: #0f172a; --brand-text-light: #ffffff; }
517
-
518
  html, body, .gradio-container { height: 100vh; }
519
  .gradio-container { background: var(--brand-bg); display: flex; flex-direction: column; }
520
 
521
- /* HERO (initial Google-like screen) */
522
  #hero-wrap { height: 70vh; display: grid; place-items: center; }
523
  #hero { text-align: center; }
524
  #hero h2 { color: #0f172a; font-weight: 800; font-size: 32px; margin-bottom: 22px; }
@@ -528,28 +524,28 @@ html, body, .gradio-container { height: 100vh; }
528
 
529
  /* CHAT */
530
  #chat-container { position: relative; }
531
- .message.user, .message.bot { background: var(--brand-accent) !important; color: var(--brand-text-light) !important; border-radius: 12px !important; padding: 8px 12px !important; }
532
  .chatbot header, .chatbot .label, .chatbot .label-wrap { display: none !important; }
 
533
  textarea, input, .gr-input { border-radius: 12px !important; }
534
  """
535
 
536
  # ---------- UI ----------
537
  with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
538
- # --- HERO (landing) ---
539
  with gr.Column(elem_id="hero-wrap", visible=True) as hero_wrap:
540
  with gr.Column(elem_id="hero"):
541
  gr.HTML("<h2>What can I help with?</h2>")
542
  with gr.Row(elem_classes="search-row"):
543
  hero_msg = gr.Textbox(
544
- placeholder="Ask anything (paste scenarios here; you can attach files after)...",
545
  show_label=False,
546
  lines=1,
547
  elem_classes="hero-box"
548
  )
549
  hero_send = gr.Button("➤", scale=0)
550
- gr.Markdown('<div class="hint">ClarityOps will parse uploads, compare to your scenario, ask only for what’s missing (no assumptions), then produce a structured analysis.</div>')
551
 
552
- # --- MAIN APP ---
553
  with gr.Column(elem_id="chat-container", visible=False) as app_wrap:
554
  chat = gr.Chatbot(label="", show_label=False, height="64vh")
555
  with gr.Row():
@@ -561,7 +557,7 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
561
  msg = gr.Textbox(
562
  label="",
563
  show_label=False,
564
- placeholder="Continue here. Paste scenario details, add files below.",
565
  scale=10
566
  )
567
  send = gr.Button("Send", scale=1)
@@ -570,55 +566,16 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
570
  # ---- State
571
  state_history = gr.State(value=[])
572
  state_uploaded = gr.State(value=[])
573
- state_awaiting = gr.State(value=False) # False -> not waiting; True -> expecting answers to clarifications
574
 
575
- # ---- Upload handler (immediate ingest + gap summary to chat)
576
- def _on_upload(files, history, uploaded_paths):
577
- new_paths = []
578
  for f in (files or []):
579
- new_paths.append(getattr(f, "name", None) or f)
580
- all_paths = (uploaded_paths or []) + new_paths
581
-
582
- # Ingest now
583
- ing = extract_text_from_files(new_paths)
584
- chunks = ing.get("chunks", []) if isinstance(ing, dict) else (ing or [])
585
- arts = ing.get("artifacts", []) if isinstance(ing, dict) else []
586
- if chunks:
587
- _session_rag.add_docs(chunks)
588
- if arts:
589
- _session_rag.register_artifacts(arts)
590
 
591
- # Build coverage & gap view using the last user scenario message if any
592
- last_user_msg = ""
593
- for u, a in _iter_user_assistant(history):
594
- if u:
595
- last_user_msg = u # take the latest user utterance
596
-
597
- crit_missing, nice_missing, note = analyze_gaps(last_user_msg, _session_rag.artifacts)
598
- coverage = build_data_summary(_session_rag.artifacts)
599
-
600
- # Compose bot message
601
- parts = ["**Data Intake Summary**"]
602
- if note: parts.append(note)
603
- parts.append("**Files parsed & coverage:**\n" + (coverage or "_No files parsed._"))
604
- if crit_missing:
605
- parts.append("**Missing (critical):**\n- " + "\n- ".join(crit_missing))
606
- if nice_missing:
607
- parts.append("**Missing (optional but useful):**\n- " + "\n- ".join(nice_missing))
608
- parts.append("\nIf you can, provide the missing details now. Otherwise, say “proceed” and I’ll continue (but Phase 2 will block if critical items remain).")
609
- bot_msg = "\n\n".join(parts)
610
-
611
- new_hist = (history or []) + [("", bot_msg)]
612
- # If there are critical gaps AND we are in scenario context already, set awaiting=True (Phase 1)
613
- awaiting = bool(crit_missing and detect_scenario_type(last_user_msg, _session_rag.artifacts))
614
-
615
- return all_paths, new_hist, awaiting
616
-
617
- uploads.change(
618
- _on_upload,
619
- inputs=[uploads, state_history, state_uploaded],
620
- outputs=[state_uploaded, state_history, state_awaiting]
621
- )
622
 
623
  # ---- Core send (used by both hero input and chat input)
624
  def _on_send(user_msg, history, up_paths, awaiting):
@@ -668,11 +625,7 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
668
  concurrency_limit=2, queue=True)
669
 
670
  def _on_clear():
671
- # fresh session (clears RAG too)
672
- try:
673
- _session_rag.clear()
674
- except Exception:
675
- pass
676
  return (
677
  [], "", [], False,
678
  gr.update(visible=True), # show hero
@@ -685,4 +638,3 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
685
  if __name__ == "__main__":
686
  port = int(os.environ.get("PORT", "7860"))
687
  demo.launch(server_name="0.0.0.0", server_port=port, show_api=False, max_threads=8)
688
-
 
1
  # app.py
2
  import os, re, json, traceback, pathlib
3
  from functools import lru_cache
4
+ from typing import List, Dict, Any, Tuple
5
 
6
  import gradio as gr
7
  import torch
8
+ import regex as re2 # robust control-char sanitizer
9
 
10
  from settings import SNAPSHOT_PATH, PERSIST_CONTENT
11
  from audit_log import log_event, hash_summary
12
  from privacy import redact_text
13
 
14
+ # ---------- Writable caches (HF Spaces-safe) ----------
15
  HOME = pathlib.Path.home()
16
  HF_HOME = str(HOME / ".cache" / "huggingface")
17
  HF_HUB_CACHE = str(HOME / ".cache" / "huggingface" / "hub")
 
60
  COHERE_API_KEY = os.getenv("COHERE_API_KEY")
61
  USE_HOSTED_COHERE = bool(COHERE_API_KEY and _HAS_COHERE)
62
 
63
+ # Larger output budget for Phase 2
64
  MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "2048"))
65
 
66
+ # ---------- System Master (Phase 2) ----------
67
  SYSTEM_MASTER = """
68
+ SYSTEM ROLE
69
  You are ClarityOps, a medical analytics system that interacts only via this chat.
70
 
 
 
 
 
 
 
71
  Absolute rules:
72
+ - Use ONLY information provided in this conversation (scenario text + uploaded files + user answers).
73
+ - Never invent data. If something required is missing after clarifications, write the literal token: INSUFFICIENT_DATA.
74
+ - Produce clear calculations (show multipliers and totals), follow medical units, and keep privacy safeguards (aggregate; suppress cohorts <10).
75
+
76
+ Formatting hard rules for Phase 2:
77
+ - Start with the header: “Structured Analysis”
78
+ - Follow this section order:
 
 
 
79
  1. Prioritization
80
  2. Capacity
81
  3. Cost
82
  4. Clinical Benefits
83
  5. ClarityOps Top 3 Recommendations
84
+ - End with a brief Provenance mapping outputs to scenario text, uploaded files, and answers.
85
  """.strip()
86
 
87
  # ---------- Helpers ----------
 
99
  r"\bdescribe\s+yourself\b", r"\band\s+you\s*\?\b", r"\byour\s+name\b",
100
  r"\bwho\s+am\s+i\s+chatting\s+with\b",
101
  ]
102
+ def match(t): return any(re.search(p, (t or "").strip().lower()) for p in patterns)
 
 
103
  if match(message): return True
104
  if history:
105
  last_user = history[-1][0] if isinstance(history[-1], (list, tuple)) else None
 
114
  yield u, a
115
 
116
  def _sanitize_text(s: str) -> str:
117
+ if not isinstance(s, str):
118
+ return s
119
  return re2.sub(r'[\p{C}--[\n\t]]+', '', s)
120
 
121
+ def is_scenario_triggered(text: str, uploaded_files_paths) -> bool:
122
+ t = (text or "").lower()
123
+ has_keyword = "scenario" in t
124
+ has_files = bool(uploaded_files_paths)
125
+ return has_keyword or has_files
 
 
 
126
 
127
+ # ---------- Cohere first ----------
128
  def cohere_chat(message, history):
129
  if not USE_HOSTED_COHERE:
130
  return None
131
  try:
132
  client = cohere.Client(api_key=COHERE_API_KEY)
133
+ # Build a simple conversational prompt (history included)
134
+ parts = []
135
+ for u, a in _iter_user_assistant(history):
136
+ if u: parts.append(f"User: {u}")
137
+ if a: parts.append(f"Assistant: {a}")
138
+ parts.append(f"User: {message}")
139
+ prompt = "\n".join(parts) + "\nAssistant:"
140
  resp = client.chat(
141
  model="command-r7b-12-2024",
142
  message=prompt,
 
150
  except Exception:
151
  return None
152
 
153
+ # ---------- Local model (HF) ----------
154
  @lru_cache(maxsize=1)
155
  def load_local_model():
156
  if not HF_TOKEN:
 
202
  gen_only = out[0, input_ids.shape[-1]:]
203
  return tokenizer.decode(gen_only, skip_special_tokens=True).strip()
204
 
205
+ # ---------- Snapshot & retrieval ----------
206
  def _load_snapshot(path=SNAPSHOT_PATH):
207
  try:
208
  with open(path, "r", encoding="utf-8") as f:
 
220
  init_retriever()
221
  _session_rag = SessionRAG()
222
 
223
+ # ---------- Executive pre-compute (MDSi block) ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  def _mdsi_block():
225
  base_capacity = capacity_projection(18, 48, 6)
226
  cons_capacity = capacity_projection(12, 48, 6)
 
233
  "outcomes_summary": outcomes
234
  }, indent=2)
235
 
236
+ # ---------- Dynamic Phase 1 question generator ----------
237
+ def _extract_present_domains(artifacts: List[Dict[str, Any]]) -> Dict[str, bool]:
238
+ """
239
+ Inspect artifact names/columns to see which domains are present.
240
+ Returns flags for: population, cost, clinical, capacity/logistics.
241
+ """
242
+ flags = dict(population=False, cost=False, clinical=False, capacity=False)
243
+ for a in artifacts or []:
244
+ name = (a.get("name") or "").lower()
245
+ cols = [c.lower() for c in (a.get("columns") or [])]
246
+ if any(k in name for k in ["population", "census", "membership"]) or any(
247
+ k in ",".join(cols) for k in ["population", "census", "residence", "settlement", "age"]
248
+ ):
249
+ flags["population"] = True
250
+ if any(k in name for k in ["cost", "finance", "budget"]) or any(
251
+ k in ",".join(cols) for k in ["cost", "startup", "ongoing", "per_client", "per-visit"]
252
+ ):
253
+ flags["cost"] = True
254
+ if any(k in name for k in ["a1c", "outcome", "bp", "chol"]) or any(
255
+ k in ",".join(cols) for k in ["a1c", "bmi", "bp", "chol", "outcome"]
256
+ ):
257
+ flags["clinical"] = True
258
+ if any(k in name for k in ["ops", "capacity", "throughput", "volume"]) or any(
259
+ k in ",".join(cols) for k in ["clients_per_day", "teams", "visits", "throughput"]
260
+ ):
261
+ flags["capacity"] = True
262
+ return flags
263
+
264
+ def _domain_from_text(text: str) -> Dict[str, bool]:
265
+ t = (text or "").lower()
266
+ return {
267
+ "population": any(k in t for k in ["population", "census", "settlement", "membership"]),
268
+ "cost": any(k in t for k in ["cost", "budget", "startup", "per client", "per-client", "ongoing"]),
269
+ "clinical": any(k in t for k in ["a1c", "bmi", "blood pressure", "bp", "cholesterol", "outcome"]),
270
+ "capacity": any(k in t for k in ["capacity", "throughput", "clients per day", "teams", "screen", "volume"]),
271
+ }
272
+
273
+ def _is_mdsi_diabetes(text: str) -> bool:
274
+ t = (text or "").lower()
275
+ return any(k in t for k in ["mdsi", "mobile diabetes", "diabetes", "metabolic", "a1c", "metis"])
276
+
277
+ def build_dynamic_clarifications(scenario_text: str, artifacts: List[Dict[str, Any]]) -> str:
278
+ """
279
+ Build up to 5 grouped clarification questions based on what's MISSING.
280
+ Groups: Prioritization, Capacity, Cost, Clinical, Recommendations.
281
+ Only ask for domains not covered by uploads/scenario text.
282
+ """
283
+ flags_from_files = _extract_present_domains(artifacts)
284
+ flags_from_text = _domain_from_text(scenario_text)
285
+ missing = {
286
+ k: not (flags_from_files.get(k) or flags_from_text.get(k))
287
+ for k in ["population", "capacity", "cost", "clinical"]
288
+ }
289
+
290
+ qs: List[Tuple[str, str]] = []
291
+ is_mdsi = _is_mdsi_diabetes(scenario_text)
292
+
293
+ # Prioritization
294
+ if missing["population"]:
295
+ if is_mdsi:
296
+ qs.append(("Prioritization",
297
+ "Confirm prioritization inputs: settlement membership living on-settlement (latest), obesity/metabolic syndrome prevalence, and any access-to-care constraints to weigh."))
298
+ else:
299
+ qs.append(("Prioritization",
300
+ "Which population/risk indicators should drive prioritization (size, prevalence, access, equity factors)?"))
301
+
302
+ # Capacity
303
+ if missing["capacity"]:
304
+ if is_mdsi:
305
+ qs.append(("Capacity",
306
+ "What is the realistic per-team screening rate (clients/day) and operating schedule (days/week, weeks/3-month window)?"))
307
+ else:
308
+ qs.append(("Capacity",
309
+ "What per-team throughput and operating schedule should be used for capacity calculations?"))
310
+
311
+ # Cost
312
+ if missing["cost"]:
313
+ if is_mdsi:
314
+ qs.append(("Cost",
315
+ "Provide startup cost per client and ongoing cost per client/visit (or total program costs) to price scenarios like 1,200 screens."))
316
+ else:
317
+ qs.append(("Cost",
318
+ "Provide fixed setup costs and variable cost per client to model total program spend."))
319
+
320
+ # Clinical
321
+ if missing["clinical"]:
322
+ if is_mdsi:
323
+ qs.append(("Clinical",
324
+ "What longitudinal deltas should we expect (e.g., ΔA1c, ΔBP, ΔBMI, lipids) from repeat screenings, and over what interval?"))
325
+ else:
326
+ qs.append(("Clinical",
327
+ "Which clinical indicators and expected effect sizes should be tracked for outcomes?"))
328
+
329
+ # Recommendations – always ask one targeted planning question last
330
+ if is_mdsi:
331
+ qs.append(("Recommendations",
332
+ "Are there community constraints (events/seasonality/cultural protocols) that should shape routing and visit cadence?"))
333
+ else:
334
+ qs.append(("Recommendations",
335
+ "Any operational constraints (scheduling, staffing, partnerships) we should incorporate into deployment modeling?"))
336
+
337
+ # Cap at 5 groups
338
+ qs = qs[:5]
339
+
340
+ # Assemble markdown
341
+ out = ["**Clarification Questions**"]
342
+ current_group = None
343
+ for grp, q in qs:
344
+ if grp != current_group:
345
+ out.append(f"\n**{grp}:**")
346
+ current_group = grp
347
+ out.append(f"- {q}")
348
+ return "\n".join(out)
349
+
350
+ # ---------- Core chat logic (auto scenario, dynamic Phase 1) ----------
351
+ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answers=False):
352
  """
353
  awaiting_answers:
354
+ - False: If scenario triggered -> Phase 1 (dynamic questions). Else normal chat.
355
+ - True: If scenario triggered -> Phase 2 (structured analysis). Else normal chat.
 
356
  """
357
  try:
358
  log_event("user_message", None, {"sizes": {"chars": len(user_msg or "")}})
 
368
  ans = "I am ClarityOps, your strategic decision making AI partner."
369
  return history + [(user_msg, ans)], awaiting_answers
370
 
371
+ # Ingest uploads FIRST (files alone can trigger scenario mode)
372
  artifacts = []
373
  if uploaded_files_paths:
374
  ing = extract_text_from_files(uploaded_files_paths)
 
380
  _session_rag.register_artifacts(artifacts)
381
  log_event("uploads_added", None, {"chunks": len(chunks), "artifacts": len(artifacts)})
382
 
383
+ # CSV columns helper (works in both modes)
384
+ if re.search(r"\b(columns?|headers?)\b", (safe_in or "").lower()):
385
+ cols = _session_rag.get_latest_csv_columns()
386
+ if cols:
387
+ return history + [(user_msg, "Here are the column names from your most recent CSV upload:\n\n- " + "\n- ".join(cols))], awaiting_answers
388
+
389
+ # Decide mode
390
+ scenario_mode = is_scenario_triggered(safe_in, uploaded_files_paths)
391
+
392
+ if not scenario_mode:
393
+ # ---------- Normal conversational chat ----------
394
+ out = cohere_chat(safe_in, history) if USE_HOSTED_COHERE else None
395
+ if not out:
396
+ # Small system nudge for normal chat
397
+ model, tokenizer = load_local_model()
398
+ tiny = [{"role": "system", "content": "You are a helpful assistant."}]
399
+ for u, a in _iter_user_assistant(history):
400
+ if u: tiny.append({"role": "user", "content": u})
401
+ if a: tiny.append({"role": "assistant", "content": a})
402
+ tiny.append({"role": "user", "content": safe_in})
403
+ inputs = tokenizer.apply_chat_template(tiny, tokenize=True, add_generation_prompt=True, return_tensors="pt")
404
+ out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
405
+
406
+ out = _sanitize_text(out or "")
407
+ safe_out, blocked_out, reason_out = safety_filter(out, mode="output")
408
+ if blocked_out:
409
+ safe_out = refusal_reply(reason_out)
410
+ log_event("assistant_reply", None, {
411
+ **hash_summary("prompt", safe_in if not PERSIST_CONTENT else ""),
412
+ **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
413
+ "mode": "normal_chat",
414
+ })
415
+ return history + [(user_msg, safe_out)], awaiting_answers
416
+
417
+ # ---------- Scenario Mode ----------
418
+ if not awaiting_answers:
419
+ # PHASE 1: generate dynamic questions here (no assumptions)
420
+ phase1 = build_dynamic_clarifications(scenario_text=safe_in, artifacts=artifacts or _session_rag.artifacts)
421
+ phase1 = _sanitize_text(phase1)
422
+ log_event("assistant_reply", None, {
423
+ **hash_summary("prompt", safe_in if not PERSIST_CONTENT else ""),
424
+ **hash_summary("reply", phase1 if not PERSIST_CONTENT else ""),
425
+ "mode": "scenario_phase1",
426
+ "awaiting_next_phase": True
427
+ })
428
+ return history + [(user_msg, phase1)], True
429
+
430
+ # PHASE 2: build rich system preamble + feed to LLM
431
  session_snips = "\n---\n".join(_session_rag.retrieve(
432
  "diabetes screening Indigenous Métis mobile program cost throughput outcomes logistics",
433
  k=6
 
442
  user_lower = (safe_in or "").lower()
443
  mdsi_extra = _mdsi_block() if ("diabetes" in user_lower or "mdsi" in user_lower or "mobile screening" in user_lower) else ""
444
 
445
+ # Summarize artifacts for the model (concise, structured)
446
+ arts = _session_rag.artifacts or []
447
+ if arts:
448
+ arts_summ = []
449
+ for a in arts:
450
+ nm = a.get("name") or "<unnamed>"
451
+ cols = ", ".join(a.get("columns") or [])[:600]
452
+ rows = a.get("n_rows_sampled") or 0
453
+ arts_summ.append(f"- {nm}: columns[{cols}] sample_rows={rows}")
454
+ artifact_block = "Uploaded Data Files (summarized):\n" + "\n".join(arts_summ)
455
+ else:
456
+ artifact_block = "Uploaded Data Files (summarized):\n- <none>"
457
+
458
+ # Build system preamble
459
  scenario_block = safe_in if len((safe_in or "")) > 0 else ""
460
  system_preamble = build_system_preamble(
461
  snapshot=snapshot,
462
  policy_context=policy_context,
463
  computed_numbers=computed,
464
+ scenario_text=scenario_block + f"\n\n{artifact_block}" + (f"\n\nExecutive Pre-Computed Blocks:\n{mdsi_extra}" if mdsi_extra else ""),
465
  session_snips=session_snips
466
  )
467
 
468
+ directive = (
469
+ "\n\n[INSTRUCTION TO MODEL]\n"
470
+ "Produce **Phase 2** only now: start with 'Structured Analysis' and follow the exact section order "
471
+ "(Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations). "
472
+ "Use uploaded files + the user's latest answers as authoritative. Show calculations, units, and a brief Provenance.\n"
473
+ )
474
+
475
+ augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser scenario & answers:\n" + safe_in + directive
476
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  out = cohere_chat(augmented_user, history)
478
  if not out:
479
  model, tokenizer = load_local_model()
480
  inputs = build_inputs(tokenizer, augmented_user, history)
481
  out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
482
 
 
483
  if isinstance(out, str):
484
  for tag in ("Assistant:", "System:", "User:"):
485
  if out.startswith(tag):
486
  out = out[len(tag):].strip()
487
+ out = _sanitize_text(out or "")
488
 
 
489
  safe_out, blocked_out, reason_out = safety_filter(out, mode="output")
490
  if blocked_out:
491
  safe_out = refusal_reply(reason_out)
492
 
 
 
 
 
 
 
 
 
 
493
  log_event("assistant_reply", None, {
494
  **hash_summary("prompt", augmented_user if not PERSIST_CONTENT else ""),
495
  **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
496
+ "mode": "scenario_phase2",
497
+ "awaiting_next_phase": False
498
  })
499
 
500
+ return history + [(user_msg, safe_out)], False
501
 
502
  except Exception as e:
503
  err = f"Error: {e}"
 
511
  theme = gr.themes.Soft(primary_hue="teal", neutral_hue="slate", radius_size=gr.themes.sizes.radius_lg)
512
  custom_css = """
513
  :root { --brand-bg: #e6f7f8; --brand-accent: #0d9488; --brand-text: #0f172a; --brand-text-light: #ffffff; }
 
514
  html, body, .gradio-container { height: 100vh; }
515
  .gradio-container { background: var(--brand-bg); display: flex; flex-direction: column; }
516
 
517
+ /* HERO (landing) */
518
  #hero-wrap { height: 70vh; display: grid; place-items: center; }
519
  #hero { text-align: center; }
520
  #hero h2 { color: #0f172a; font-weight: 800; font-size: 32px; margin-bottom: 22px; }
 
524
 
525
  /* CHAT */
526
  #chat-container { position: relative; }
 
527
  .chatbot header, .chatbot .label, .chatbot .label-wrap { display: none !important; }
528
+ .message.user, .message.bot { background: var(--brand-accent) !important; color: var(--brand-text-light) !important; border-radius: 12px !important; padding: 8px 12px !important; }
529
  textarea, input, .gr-input { border-radius: 12px !important; }
530
  """
531
 
532
  # ---------- UI ----------
533
  with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
534
+ # --- HERO (initial Google-like screen) ---
535
  with gr.Column(elem_id="hero-wrap", visible=True) as hero_wrap:
536
  with gr.Column(elem_id="hero"):
537
  gr.HTML("<h2>What can I help with?</h2>")
538
  with gr.Row(elem_classes="search-row"):
539
  hero_msg = gr.Textbox(
540
+ placeholder="Ask anything (type 'scenario' and/or attach files for Scenario Mode)",
541
  show_label=False,
542
  lines=1,
543
  elem_classes="hero-box"
544
  )
545
  hero_send = gr.Button("➤", scale=0)
546
+ gr.Markdown('<div class="hint">Scenario Mode triggers when you type the word <b>scenario</b> or upload files. Phase&nbsp;1 asks dynamic clarifications; Phase&nbsp;2 returns a structured analysis.</div>')
547
 
548
+ # --- MAIN APP (hidden until first message) ---
549
  with gr.Column(elem_id="chat-container", visible=False) as app_wrap:
550
  chat = gr.Chatbot(label="", show_label=False, height="64vh")
551
  with gr.Row():
 
557
  msg = gr.Textbox(
558
  label="",
559
  show_label=False,
560
+ placeholder="Continue here. Paste scenario details (include the word 'scenario' to trigger), add files below.",
561
  scale=10
562
  )
563
  send = gr.Button("Send", scale=1)
 
566
  # ---- State
567
  state_history = gr.State(value=[])
568
  state_uploaded = gr.State(value=[])
569
+ state_awaiting = gr.State(value=False) # False -> Phase 1 next; True -> Phase 2 next (awaiting answers)
570
 
571
+ # ---- Uploads
572
+ def _store_uploads(files, current):
573
+ paths = []
574
  for f in (files or []):
575
+ paths.append(getattr(f, "name", None) or f)
576
+ return (current or []) + paths
 
 
 
 
 
 
 
 
 
577
 
578
+ uploads.change(fn=_store_uploads, inputs=[uploads, state_uploaded], outputs=state_uploaded)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
579
 
580
  # ---- Core send (used by both hero input and chat input)
581
  def _on_send(user_msg, history, up_paths, awaiting):
 
625
  concurrency_limit=2, queue=True)
626
 
627
  def _on_clear():
628
+ # Reset to fresh hero screen
 
 
 
 
629
  return (
630
  [], "", [], False,
631
  gr.update(visible=True), # show hero
 
638
  if __name__ == "__main__":
639
  port = int(os.environ.get("PORT", "7860"))
640
  demo.launch(server_name="0.0.0.0", server_port=port, show_api=False, max_threads=8)