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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +281 -165
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import os, re, json, traceback, pathlib
2
  from functools import lru_cache
3
 
@@ -61,35 +62,28 @@ USE_HOSTED_COHERE = bool(COHERE_API_KEY and _HAS_COHERE)
61
  # Larger output (Cohere + HF fallback)
62
  MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "2048"))
63
 
64
- # ---------- System Master (two-phase, LLM-only behavior) ----------
65
  SYSTEM_MASTER = """
66
  SYSTEM ROLE (fixed, always on)
67
  You are ClarityOps, a medical analytics system that interacts only via this chat.
68
 
 
 
 
 
 
 
69
  Absolute rules:
70
- - Use ONLY information provided in this conversation (scenario text + uploaded files).
71
- - Never invent data. If something required is missing after clarifications, output the literal token: INSUFFICIENT_DATA.
72
- - Always run in TWO PHASES when the user provides a medical scenario (case study / program design / evaluation):
73
- Phase 1: Ask up to 5 concise clarification questions, grouped by category (Prioritization, Capacity, Cost, Clinical, Recommendations). Then STOP and WAIT.
74
- Phase 2: After answers are provided, produce the final structured analysis exactly in the required format.
75
-
76
- Core behavior:
77
- - Read and synthesize any user-uploaded files (e.g., CSV/XLSX/PDF) relevant to the scenario.
78
- - Prefer analytics/longitudinal recommendations (risk targeting, follow-up, clustering) over generic ops advice.
79
- - Show all calculations explicitly for capacity and costs (e.g., “6 teams × 8 clients/day × 60 days = 2,880”).
80
- - Use correct clinical units and plausible ranges.
81
- - Include a brief “Provenance” section mapping each key output to scenario text, files, and/or clarified answers.
82
-
83
- Medical guardrails (always apply):
84
- - Units: BP in mmHg, A1c in %, BMI in kg/m², Total Cholesterol in mmol/L (or as provided), Percentages in %.
85
- - Plausible ranges: A1c 3–20 %, SBP 60–250 mmHg, DBP 30–150 mmHg, BMI 10–70 kg/m², Total Chol 2–12 mmol/L.
86
- - Privacy: avoid PHI; aggregate only; apply small-cell suppression where cohort < 10 (describe at a higher level).
87
- - When data includes mixed or ambiguous indicators, ask to confirm preferred indicators (e.g., obesity/metabolic syndrome vs self-reported diabetes).
88
-
89
- Formatting hard rules (only for scenarios):
90
- - Phase 1 output MUST include the header line: “Clarification Questions”
91
- - Phase 2 output MUST include the header line: “Structured Analysis”
92
- - Phase 2 MUST follow this exact section order:
93
  1. Prioritization
94
  2. Capacity
95
  3. Cost
@@ -114,52 +108,14 @@ def is_identity_query(message, history):
114
  r"\bwho\s+am\s+i\s+chatting\s+with\b",
115
  ]
116
  def match(t):
117
- return any(re.search(p, (t or "").strip().lower()) for p in patterns)
 
118
  if match(message): return True
119
  if history:
120
  last_user = history[-1][0] if isinstance(history[-1], (list, tuple)) else None
121
  if match(last_user): return True
122
  return False
123
 
124
- GREETING_RE = re.compile(
125
- r'^\s*(hi|hello|hey|yo|good\s*(morning|afternoon|evening)|howdy|sup)[\s!.\)]*$', re.I
126
- )
127
-
128
- def is_smalltalk(msg: str) -> bool:
129
- if not msg: return True
130
- if len(msg.strip()) < 6: return True
131
- if GREETING_RE.match(msg.strip()): return True
132
- # single short sentence, no punctuation complexity, no digits
133
- if len(msg.split()) < 10 and not re.search(r'[\d,:;]|(case|scenario|study|objective|dataset|csv|program)', msg, re.I):
134
- return True
135
- return False
136
-
137
- SCENARIO_MARKERS = [
138
- "background", "case study", "objective", "objectives", "available data", "data inputs",
139
- "evaluation questions", "expected output", "structured analysis", "methods", "assumptions"
140
- ]
141
- MEDICAL_TERMS = [
142
- "diabetes", "a1c", "metabolic syndrome", "obesity", "blood pressure", "cholesterol",
143
- "screening", "clinic", "patients", "prevalence", "capacity", "cost per client",
144
- "program cost", "longitudinal", "outcomes", "cohort", "settlements", "indigenous", "métis"
145
- ]
146
-
147
- def is_scenario_like(msg: str, artifacts, uploads_present: bool) -> bool:
148
- if not msg: return False
149
- low = msg.lower()
150
- # length + markers
151
- has_len = len(low) > 400 or len(low.split()) > 120
152
- has_marker = any(m in low for m in SCENARIO_MARKERS)
153
- med_hits = sum(1 for t in MEDICAL_TERMS if t in low)
154
- has_medical = med_hits >= 2
155
- csv_present = any((a.get("kind") == "csv") for a in (artifacts or []))
156
- # Declare scenario if: (length & marker & medical) OR (uploads with csv and medical) OR explicit "scenario"/"case study"
157
- explicit = ("scenario" in low) or ("case study" in low)
158
- if explicit: return True
159
- if (has_len and has_marker and has_medical): return True
160
- if (uploads_present and csv_present and has_medical): return True
161
- return False
162
-
163
  def _iter_user_assistant(history):
164
  for item in (history or []):
165
  if isinstance(item, (list, tuple)):
@@ -180,7 +136,7 @@ def _history_to_prompt(message, history):
180
  parts.append("Assistant:")
181
  return "\n".join(parts)
182
 
183
- # ---------- Cohere first ----------
184
  def cohere_chat(message, history):
185
  if not USE_HOSTED_COHERE:
186
  return None
@@ -200,7 +156,6 @@ def cohere_chat(message, history):
200
  except Exception:
201
  return None
202
 
203
- # ---------- Local model (HF) ----------
204
  @lru_cache(maxsize=1)
205
  def load_local_model():
206
  if not HF_TOKEN:
@@ -270,6 +225,102 @@ def _load_snapshot(path=SNAPSHOT_PATH):
270
  init_retriever()
271
  _session_rag = SessionRAG()
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  def _mdsi_block():
274
  base_capacity = capacity_projection(18, 48, 6)
275
  cons_capacity = capacity_projection(12, 48, 6)
@@ -282,12 +333,13 @@ def _mdsi_block():
282
  "outcomes_summary": outcomes
283
  }, indent=2)
284
 
285
- # ---------- Core chat logic (auto scenario detection) ----------
286
- def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answers=False):
287
  """
288
  awaiting_answers:
289
- - False: If message looks like a medical scenario -> Phase 1; else general chat
290
- - True: We expect the user's answers to Phase 1 -> produce Phase 2
 
291
  """
292
  try:
293
  log_event("user_message", None, {"sizes": {"chars": len(user_msg or "")}})
@@ -303,7 +355,7 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
303
  ans = "I am ClarityOps, your strategic decision making AI partner."
304
  return history + [(user_msg, ans)], awaiting_answers
305
 
306
- # Ingest uploads first (so detector can use artifacts)
307
  artifacts = []
308
  if uploaded_files_paths:
309
  ing = extract_text_from_files(uploaded_files_paths)
@@ -315,87 +367,109 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
315
  _session_rag.register_artifacts(artifacts)
316
  log_event("uploads_added", None, {"chunks": len(chunks), "artifacts": len(artifacts)})
317
 
318
- # Column helper (explicit)
319
- if re.search(r"\b(columns?|headers?)\b", (safe_in or "").lower()):
320
- cols = _session_rag.get_latest_csv_columns()
321
- if cols:
322
- return history + [(user_msg, "Here are the column names from your most recent CSV upload:\n\n- " + "\n- ".join(cols))], awaiting_answers
323
-
324
- # Decide mode
325
- uploads_present = bool(uploaded_files_paths)
326
- scenario_mode = (not awaiting_answers) and is_scenario_like(safe_in or "", artifacts, uploads_present)
327
- smalltalk = is_smalltalk(safe_in or "")
328
-
329
- # Prepare retrieval/preamble only if needed
330
- session_snips = ""
331
- system_preamble = ""
332
- phase_directive = ""
333
-
334
- if awaiting_answers:
335
- # We are in Phase 2 (user answered Phase 1); force scenario flow
336
- scenario_mode = True
337
-
338
- if scenario_mode:
339
- # Session retrieval to enrich the system preamble
340
- session_snips = "\n---\n".join(_session_rag.retrieve(
341
- "diabetes screening Indigenous Métis mobile program cost throughput outcomes logistics",
342
- k=6
343
- ))
344
- snapshot = _load_snapshot()
345
- policy_context = retrieve_context(
346
- "mobile diabetes screening Indigenous community outreach cultural safety data governance outcomes"
 
 
 
 
 
 
 
 
 
 
 
347
  )
348
- computed = compute_operational_numbers(snapshot)
349
- user_lower = (safe_in or "").lower()
350
- mdsi_extra = _mdsi_block() if ("diabetes" in user_lower or "mdsi" in user_lower or "mobile screening" in user_lower) else ""
351
- scenario_block = safe_in if len((safe_in or "")) > 0 else ""
352
- system_preamble = build_system_preamble(
353
- snapshot=snapshot,
354
- policy_context=policy_context,
355
- computed_numbers=computed,
356
- scenario_text=scenario_block + (f"\n\nExecutive Pre-Computed Blocks:\n{mdsi_extra}" if mdsi_extra else ""),
357
- session_snips=session_snips
 
 
 
358
  )
359
- if not awaiting_answers:
360
- phase_directive = (
361
- "\n\n[INSTRUCTION TO MODEL]\n"
362
- "Produce **Phase 1** only: output a header 'Clarification Questions' and ask up to 5 concise, grouped questions "
363
- "(Prioritization, Capacity, Cost, Clinical, Recommendations). Then STOP and WAIT.\n"
364
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  else:
366
- phase_directive = (
367
- "\n\n[INSTRUCTION TO MODEL]\n"
368
- "Produce **Phase 2** only: output a header 'Structured Analysis' and follow the exact section order "
369
- "(Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations). "
370
- "Use uploaded files + the user's latest answers as authoritative. Show calculations, units, and a brief Provenance.\n"
371
- )
372
 
373
- # Build final message to model
374
- if scenario_mode:
375
- augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser message:\n" + (safe_in or "") + phase_directive
376
- else:
377
- # General chat path: NO phase directive, NO heavy preamble; still keep SYSTEM_MASTER safety/medical guardrails
378
- augmented_user = (
379
- "System: You are ClarityOps, a helpful medical & operations assistant. "
380
- "Answer normally and concisely. If the user pastes a long, structured medical scenario, you will switch to the two-phase flow; "
381
- "but this message does not qualify.\n\n"
382
- f"User: {safe_in}\nAssistant:"
383
- )
384
 
385
  # Call LLM
386
  out = cohere_chat(augmented_user, history)
387
  if not out:
388
  model, tokenizer = load_local_model()
389
- # For local fallback we still use chat template with SYSTEM_MASTER included
390
- def build_inputs(tokenizer, message, history):
391
- msgs = [{"role": "system", "content": SYSTEM_MASTER}]
392
- for u, a in _iter_user_assistant(history):
393
- if u: msgs.append({"role": "user", "content": u})
394
- if a: msgs.append({"role": "assistant", "content": a})
395
- msgs.append({"role": "user", "content": message})
396
- return tokenizer.apply_chat_template(
397
- msgs, tokenize=True, add_generation_prompt=True, return_tensors="pt"
398
- )
399
  inputs = build_inputs(tokenizer, augmented_user, history)
400
  out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
401
 
@@ -411,20 +485,19 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
411
  if blocked_out:
412
  safe_out = refusal_reply(reason_out)
413
 
414
- # Flip phase state based on headers (only if we were in scenario mode)
415
- new_awaiting = awaiting_answers
416
- if scenario_mode:
417
- low = safe_out.lower()
418
- if not awaiting_answers and "clarification questions" in low:
419
  new_awaiting = True
420
- elif awaiting_answers and "structured analysis" in low:
421
  new_awaiting = False
422
 
423
  log_event("assistant_reply", None, {
424
  **hash_summary("prompt", augmented_user if not PERSIST_CONTENT else ""),
425
  **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
426
- "awaiting_next_phase": new_awaiting,
427
- "scenario_mode": scenario_mode
428
  })
429
 
430
  return history + [(user_msg, safe_out)], new_awaiting
@@ -445,7 +518,7 @@ custom_css = """
445
  html, body, .gradio-container { height: 100vh; }
446
  .gradio-container { background: var(--brand-bg); display: flex; flex-direction: column; }
447
 
448
- /* HERO (landing) */
449
  #hero-wrap { height: 70vh; display: grid; place-items: center; }
450
  #hero { text-align: center; }
451
  #hero h2 { color: #0f172a; font-weight: 800; font-size: 32px; margin-bottom: 22px; }
@@ -462,7 +535,7 @@ textarea, input, .gr-input { border-radius: 12px !important; }
462
 
463
  # ---------- UI ----------
464
  with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
465
- # --- HERO (initial Google-like screen) ---
466
  with gr.Column(elem_id="hero-wrap", visible=True) as hero_wrap:
467
  with gr.Column(elem_id="hero"):
468
  gr.HTML("<h2>What can I help with?</h2>")
@@ -474,9 +547,9 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
474
  elem_classes="hero-box"
475
  )
476
  hero_send = gr.Button("➤", scale=0)
477
- gr.Markdown('<div class="hint">ClarityOps will first ask up to 5 clarifications for long medical scenarios, then produce a structured analysis.</div>')
478
 
479
- # --- MAIN APP (hidden until first message) ---
480
  with gr.Column(elem_id="chat-container", visible=False) as app_wrap:
481
  chat = gr.Chatbot(label="", show_label=False, height="64vh")
482
  with gr.Row():
@@ -497,22 +570,60 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
497
  # ---- State
498
  state_history = gr.State(value=[])
499
  state_uploaded = gr.State(value=[])
500
- state_awaiting = gr.State(value=False) # False -> Phase 1 next if scenario; True -> awaiting answers for Phase 2
501
 
502
- # ---- Uploads
503
- def _store_uploads(files, current):
504
- paths = []
505
  for f in (files or []):
506
- paths.append(getattr(f, "name", None) or f)
507
- return (current or []) + paths
508
-
509
- uploads.change(fn=_store_uploads, inputs=[uploads, state_uploaded], outputs=state_uploaded)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
 
511
  # ---- Core send (used by both hero input and chat input)
512
  def _on_send(user_msg, history, up_paths, awaiting):
513
  try:
514
  if not user_msg or not user_msg.strip():
515
- # no toggle on empty
516
  return history, "", history, awaiting
517
  new_history, new_awaiting = clarityops_reply(
518
  user_msg.strip(), history or [], None, up_paths or [], awaiting_answers=awaiting
@@ -557,7 +668,11 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
557
  concurrency_limit=2, queue=True)
558
 
559
  def _on_clear():
560
- # reset to fresh hero screen
 
 
 
 
561
  return (
562
  [], "", [], False,
563
  gr.update(visible=True), # show hero
@@ -570,3 +685,4 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
570
  if __name__ == "__main__":
571
  port = int(os.environ.get("PORT", "7860"))
572
  demo.launch(server_name="0.0.0.0", server_port=port, show_api=False, max_threads=8)
 
 
1
+ # app.py
2
  import os, re, json, traceback, pathlib
3
  from functools import lru_cache
4
 
 
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
 
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
116
  if match(last_user): return True
117
  return False
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def _iter_user_assistant(history):
120
  for item in (history or []):
121
  if isinstance(item, (list, tuple)):
 
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
 
156
  except Exception:
157
  return None
158
 
 
159
  @lru_cache(maxsize=1)
160
  def load_local_model():
161
  if not HF_TOKEN:
 
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
  "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
  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
  _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
374
+ ))
375
+
376
+ snapshot = _load_snapshot()
377
+ policy_context = retrieve_context(
378
+ "mobile diabetes screening Indigenous community outreach cultural safety data governance outcomes"
379
+ )
380
+ computed = compute_operational_numbers(snapshot)
381
+
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
 
 
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
 
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; }
 
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>")
 
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():
 
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):
625
  try:
626
  if not user_msg or not user_msg.strip():
 
627
  return history, "", history, awaiting
628
  new_history, new_awaiting = clarityops_reply(
629
  user_msg.strip(), history or [], None, up_paths or [], awaiting_answers=awaiting
 
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
  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
+