Rajan Sharma commited on
Commit
909d570
·
verified ·
1 Parent(s): eb04439

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +157 -134
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import os, re, json, traceback, pathlib
2
  from functools import lru_cache
3
 
@@ -61,23 +62,41 @@ 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
- # ---------- Fixed System Preamble for Medical Guardrails ----------
65
  SYSTEM_MASTER = """
66
  SYSTEM ROLE (fixed, always on)
67
- You are ClarityOps, a medical analytics assistant.
68
 
69
  Absolute rules:
70
- - Use ONLY information provided in this conversation (user messages + uploaded files).
71
  - Never invent data. If something required is missing after clarifications, output the literal token: INSUFFICIENT_DATA.
 
 
 
 
 
 
72
  - Prefer analytics/longitudinal recommendations (risk targeting, follow-up, clustering) over generic ops advice.
73
- - Show all calculations explicitly when computing capacity and cost.
74
  - Use correct clinical units and plausible ranges.
 
75
 
76
  Medical guardrails (always apply):
77
  - Units: BP in mmHg, A1c in %, BMI in kg/m², Total Cholesterol in mmol/L (or as provided), Percentages in %.
78
  - Plausible ranges: A1c 3–20 %, SBP 60–250 mmHg, DBP 30–150 mmHg, BMI 10–70 kg/m², Total Chol 2–12 mmol/L.
79
  - Privacy: avoid PHI; aggregate only; apply small-cell suppression where cohort < 10 (describe at a higher level).
80
  - When data includes mixed or ambiguous indicators, ask to confirm preferred indicators (e.g., obesity/metabolic syndrome vs self-reported diabetes).
 
 
 
 
 
 
 
 
 
 
 
81
  """.strip()
82
 
83
  # ---------- Helpers ----------
@@ -114,8 +133,8 @@ def _sanitize_text(s: str) -> str:
114
  return s
115
  return re2.sub(r'[\p{C}--[\n\t]]+', '', s)
116
 
117
- def _history_to_prompt(message, history, system_text):
118
- parts = [f"System: {system_text}"]
119
  for u, a in _iter_user_assistant(history):
120
  if u: parts.append(f"User: {u}")
121
  if a: parts.append(f"Assistant: {a}")
@@ -123,13 +142,55 @@ def _history_to_prompt(message, history, system_text):
123
  parts.append("Assistant:")
124
  return "\n".join(parts)
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  # ---------- Cohere first ----------
127
- def cohere_chat(message, history, system_text=SYSTEM_MASTER):
128
  if not USE_HOSTED_COHERE:
129
  return None
130
  try:
131
  client = cohere.Client(api_key=COHERE_API_KEY)
132
- prompt = _history_to_prompt(message, history, system_text)
133
  resp = client.chat(
134
  model="command-r7b-12-2024",
135
  message=prompt,
@@ -172,8 +233,8 @@ def load_local_model():
172
  mdl.config.eos_token_id = tok.eos_token_id
173
  return mdl, tok
174
 
175
- def build_inputs(tokenizer, message, history, system_text=SYSTEM_MASTER):
176
- msgs = [{"role": "system", "content": system_text}]
177
  for u, a in _iter_user_assistant(history):
178
  if u: msgs.append({"role": "user", "content": u})
179
  if a: msgs.append({"role": "assistant", "content": a})
@@ -225,65 +286,28 @@ def _mdsi_block():
225
  "outcomes_summary": outcomes
226
  }, indent=2)
227
 
228
- # ---------- Scenario auto-detection ----------
229
- _SCENARIO_HEADINGS = [
230
- "context", "background", "scenario", "case study",
231
- "data inputs", "inputs", "evaluation questions", "questions",
232
- "recommendations", "deployment strategy", "next steps", "assumptions"
233
- ]
234
- _SCENARIO_KEYWORDS = [
235
- "diabetes", "screening", "metabolic", "prevalence", "capacity",
236
- "cost", "startup", "ongoing", "clinical", "a1c", "mmhg", "bmi",
237
- "cholesterol", "settlements", "program", "mobile", "ops", "throughput"
238
- ]
239
-
240
- def _looks_like_scenario(text: str, uploaded_paths) -> bool:
241
- if not text:
242
- return False
243
- t = text.strip()
244
- low = t.lower()
245
-
246
- # 1) Length + structure signals
247
- if len(t) >= 450 and any(h in low for h in _SCENARIO_HEADINGS):
248
- return True
249
-
250
- # 2) Strong clinical/ops vocabulary density
251
- kw_hits = sum(1 for k in _SCENARIO_KEYWORDS if k in low)
252
- if len(t) >= 350 and kw_hits >= 4:
253
- return True
254
-
255
- # 3) Table/percent/metrics hints
256
- if re.search(r"\b\d{2,4}\b", low) and re.search(r"%|\bmmhg\b|\bbmi\b|\ba1c\b", low):
257
- if len(t) >= 300:
258
- return True
259
-
260
- # 4) Files attached (CSV/PDF/DOCX) + domain keywords
261
- if uploaded_paths and kw_hits >= 2:
262
- return True
263
-
264
- return False
265
-
266
- # ---------- Core chat logic (auto scenario) ----------
267
- def clarityops_reply(user_msg, history, tz, uploaded_files_paths, mode="chat"):
268
  """
269
- mode: "chat" (default) or "awaiting_answers"
270
- Returns: (updated_history, updated_mode)
 
271
  """
272
  try:
273
- log_event("user_message", None, {"sizes": {"chars": len(user_msg or "")}, "mode": mode})
274
 
275
  # Safety (input)
276
  safe_in, blocked_in, reason_in = safety_filter(user_msg, mode="input")
277
  if blocked_in:
278
  ans = refusal_reply(reason_in)
279
- return history + [(user_msg, ans)], mode
280
 
281
  # Identity short-circuit
282
  if is_identity_query(safe_in, history):
283
  ans = "I am ClarityOps, your strategic decision making AI partner."
284
- return history + [(user_msg, ans)], mode
285
 
286
- # Ingest uploads
287
  if uploaded_files_paths:
288
  ing = extract_text_from_files(uploaded_files_paths)
289
  chunks = ing.get("chunks", []) if isinstance(ing, dict) else (ing or [])
@@ -294,27 +318,54 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, mode="chat"):
294
  _session_rag.register_artifacts(artifacts)
295
  log_event("uploads_added", None, {"chunks": len(chunks), "artifacts": len(artifacts)})
296
 
297
- # Columns helper
298
  if re.search(r"\b(columns?|headers?)\b", (safe_in or "").lower()):
299
  cols = _session_rag.get_latest_csv_columns()
300
  if cols:
301
- return history + [(user_msg, "Here are the column names from your most recent CSV upload:\n\n- " + "\n- ".join(cols))], mode
 
 
 
 
 
 
 
 
 
 
 
 
302
 
303
- # Session retrieval & context
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  session_snips = "\n---\n".join(_session_rag.retrieve(
305
  "diabetes screening Indigenous Métis mobile program cost throughput outcomes logistics",
306
  k=6
307
  ))
308
-
309
  snapshot = _load_snapshot()
310
  policy_context = retrieve_context(
311
  "mobile diabetes screening Indigenous community outreach cultural safety data governance outcomes"
312
  )
313
  computed = compute_operational_numbers(snapshot)
314
-
315
  user_lower = (safe_in or "").lower()
316
  mdsi_extra = _mdsi_block() if ("diabetes" in user_lower or "mdsi" in user_lower or "mobile screening" in user_lower) else ""
317
-
318
  scenario_block = safe_in if len((safe_in or "")) > 0 else ""
319
  system_preamble = build_system_preamble(
320
  snapshot=snapshot,
@@ -324,9 +375,9 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, mode="chat"):
324
  session_snips=session_snips
325
  )
326
 
327
- # -------- Auto-routing --------
328
- if mode == "awaiting_answers":
329
- # Any reply now triggers Phase 2
330
  phase_directive = (
331
  "\n\n[INSTRUCTION TO MODEL]\n"
332
  "Produce **Phase 2** only: output a header 'Structured Analysis' and follow the exact section order "
@@ -356,15 +407,21 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, mode="chat"):
356
  **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
357
  "awaiting_next_phase": False
358
  })
359
- return history + [(user_msg, safe_out)], "chat"
360
 
361
- # Normal chat — unless it looks like a scenario
362
- if not _looks_like_scenario(safe_in, uploaded_files_paths):
363
- normal_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser message:\n" + safe_in
364
- out = cohere_chat(normal_user, history)
 
 
 
 
 
 
365
  if not out:
366
  model, tokenizer = load_local_model()
367
- inputs = build_inputs(tokenizer, normal_user, history)
368
  out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
369
 
370
  if isinstance(out, str):
@@ -378,42 +435,11 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, mode="chat"):
378
  safe_out = refusal_reply(reason_out)
379
 
380
  log_event("assistant_reply", None, {
381
- **hash_summary("prompt", normal_user if not PERSIST_CONTENT else ""),
382
  **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
383
- "awaiting_next_phase": False
384
  })
385
- return history + [(user_msg, safe_out)], "chat"
386
-
387
- # Scenario detected -> Phase 1
388
- phase_directive = (
389
- "\n\n[INSTRUCTION TO MODEL]\n"
390
- "Produce **Phase 1** only: output a header 'Clarification Questions' and ask up to 5 concise, grouped questions "
391
- "(Prioritization, Capacity, Cost, Clinical, Recommendations). Then STOP and WAIT.\n"
392
- )
393
- augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser scenario:\n" + safe_in + phase_directive
394
-
395
- out = cohere_chat(augmented_user, history)
396
- if not out:
397
- model, tokenizer = load_local_model()
398
- inputs = build_inputs(tokenizer, augmented_user, history)
399
- out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
400
-
401
- if isinstance(out, str):
402
- for tag in ("Assistant:", "System:", "User:"):
403
- if out.startswith(tag):
404
- out = out[len(tag):].strip()
405
- out = _sanitize_text(out)
406
-
407
- safe_out, blocked_out, reason_out = safety_filter(out, mode="output")
408
- if blocked_out:
409
- safe_out = refusal_reply(reason_out)
410
-
411
- log_event("assistant_reply", None, {
412
- **hash_summary("prompt", augmented_user if not PERSIST_CONTENT else ""),
413
- **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
414
- "awaiting_next_phase": True
415
- })
416
- return history + [(user_msg, safe_out)], "awaiting_answers"
417
 
418
  except Exception as e:
419
  err = f"Error: {e}"
@@ -421,7 +447,7 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, mode="chat"):
421
  traceback.print_exc()
422
  except Exception:
423
  pass
424
- return history + [(user_msg, err)], mode
425
 
426
  # ---------- Theme & CSS ----------
427
  theme = gr.themes.Soft(primary_hue="teal", neutral_hue="slate", radius_size=gr.themes.sizes.radius_lg)
@@ -454,17 +480,13 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
454
  gr.HTML("<h2>What can I help with?</h2>")
455
  with gr.Row(elem_classes="search-row"):
456
  hero_msg = gr.Textbox(
457
- placeholder="Ask anything paste a scenario (and attach files) to trigger structured analysis.",
458
  show_label=False,
459
  lines=1,
460
  elem_classes="hero-box"
461
  )
462
  hero_send = gr.Button("➤", scale=0)
463
- gr.Markdown(
464
- '<div class="hint">Tip: Pasting a structured medical scenario (with sections like '
465
- '<i>Context, Data Inputs, Evaluation Questions</i>) will auto-trigger clarifications first, '
466
- 'then the final analysis. CSVs are auto-summarized.</div>'
467
- )
468
 
469
  # --- MAIN APP (hidden until first message) ---
470
  with gr.Column(elem_id="chat-container", visible=False) as app_wrap:
@@ -478,7 +500,7 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
478
  msg = gr.Textbox(
479
  label="",
480
  show_label=False,
481
- placeholder="Chat freely… Paste a scenario to auto-start clarifications.",
482
  scale=10
483
  )
484
  send = gr.Button("Send", scale=1)
@@ -487,7 +509,7 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
487
  # ---- State
488
  state_history = gr.State(value=[])
489
  state_uploaded = gr.State(value=[])
490
- state_mode = gr.State(value="chat") # "chat" or "awaiting_answers"
491
 
492
  # ---- Uploads
493
  def _store_uploads(files, current):
@@ -499,26 +521,27 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
499
  uploads.change(fn=_store_uploads, inputs=[uploads, state_uploaded], outputs=state_uploaded)
500
 
501
  # ---- Core send (used by both hero input and chat input)
502
- def _on_send(user_msg, history, up_paths, mode):
503
  try:
504
  if not user_msg or not user_msg.strip():
505
- return history, "", history, mode
506
- new_history, new_mode = clarityops_reply(
507
- user_msg.strip(), history or [], None, up_paths or [], mode=mode
 
508
  )
509
- return new_history, "", new_history, new_mode
510
  except Exception as e:
511
  err = f"Error: {e}"
512
  try: traceback.print_exc()
513
  except Exception: pass
514
  new_hist = (history or []) + [(user_msg or "", err)]
515
- return new_hist, "", new_hist, mode
516
 
517
  # ---- Hero -> App transition + first send
518
- def _hero_start(user_msg, history, up_paths, mode):
519
- chat_o, msg_o, hist_o, mode_o = _on_send(user_msg, history, up_paths, mode)
520
  return (
521
- chat_o, msg_o, hist_o, mode_o,
522
  gr.update(visible=False), # hide hero
523
  gr.update(visible=True), # show app
524
  "" # clear hero box
@@ -526,35 +549,35 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
526
 
527
  hero_send.click(
528
  _hero_start,
529
- inputs=[hero_msg, state_history, state_uploaded, state_mode],
530
- outputs=[chat, msg, state_history, state_mode, hero_wrap, app_wrap, hero_msg],
531
  concurrency_limit=2, queue=True
532
  )
533
  hero_msg.submit(
534
  _hero_start,
535
- inputs=[hero_msg, state_history, state_uploaded, state_mode],
536
- outputs=[chat, msg, state_history, state_mode, hero_wrap, app_wrap, hero_msg],
537
  concurrency_limit=2, queue=True
538
  )
539
 
540
  # ---- Normal chat interactions after hero is gone
541
- send.click(_on_send, inputs=[msg, state_history, state_uploaded, state_mode],
542
- outputs=[chat, msg, state_history, state_mode],
543
  concurrency_limit=2, queue=True)
544
- msg.submit(_on_send, inputs=[msg, state_history, state_uploaded, state_mode],
545
- outputs=[chat, msg, state_history, state_mode],
546
  concurrency_limit=2, queue=True)
547
 
548
  def _on_clear():
549
- # reset to fresh hero screen and chat mode
550
  return (
551
- [], "", [], "chat",
552
  gr.update(visible=True), # show hero
553
  gr.update(visible=False), # hide app
554
  "" # clear hero input
555
  )
556
 
557
- clear.click(_on_clear, None, [chat, msg, state_history, state_mode, hero_wrap, app_wrap, hero_msg])
558
 
559
  if __name__ == "__main__":
560
  port = int(os.environ.get("PORT", "7860"))
 
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, LLM-only behavior) ----------
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
  Absolute rules:
71
+ - Use ONLY information provided in this conversation (scenario text + uploaded files).
72
  - Never invent data. If something required is missing after clarifications, output the literal token: INSUFFICIENT_DATA.
73
+ - When a SCENARIO is detected, always run in TWO PHASES:
74
+ Phase 1: Ask up to 5 concise clarification questions, grouped by category (Prioritization, Capacity, Cost, Clinical, Recommendations). Then STOP and WAIT.
75
+ Phase 2: After answers are provided, produce the final structured analysis exactly in the required format.
76
+
77
+ Core behavior:
78
+ - Read and synthesize any user-uploaded files (e.g., CSV/XLSX/PDF) relevant to the scenario.
79
  - Prefer analytics/longitudinal recommendations (risk targeting, follow-up, clustering) over generic ops advice.
80
+ - Show all calculations explicitly for capacity and costs (e.g., “6 teams × 8 clients/day × 60 days = 2,880”).
81
  - Use correct clinical units and plausible ranges.
82
+ - Include a brief “Provenance” section mapping each key output to scenario text, files, and/or clarified answers.
83
 
84
  Medical guardrails (always apply):
85
  - Units: BP in mmHg, A1c in %, BMI in kg/m², Total Cholesterol in mmol/L (or as provided), Percentages in %.
86
  - Plausible ranges: A1c 3–20 %, SBP 60–250 mmHg, DBP 30–150 mmHg, BMI 10–70 kg/m², Total Chol 2–12 mmol/L.
87
  - Privacy: avoid PHI; aggregate only; apply small-cell suppression where cohort < 10 (describe at a higher level).
88
  - When data includes mixed or ambiguous indicators, ask to confirm preferred indicators (e.g., obesity/metabolic syndrome vs self-reported diabetes).
89
+
90
+ Formatting hard rules (SCENARIO mode only):
91
+ - Phase 1 output MUST include the header line: “Clarification Questions”
92
+ - Phase 2 output MUST include the header line: “Structured Analysis”
93
+ - Phase 2 MUST follow this exact section order:
94
+ 1. Prioritization
95
+ 2. Capacity
96
+ 3. Cost
97
+ 4. Clinical Benefits
98
+ 5. ClarityOps Top 3 Recommendations
99
+ (Include a short Provenance block at the end.)
100
  """.strip()
101
 
102
  # ---------- Helpers ----------
 
133
  return s
134
  return re2.sub(r'[\p{C}--[\n\t]]+', '', s)
135
 
136
+ def _history_to_prompt(message, history):
137
+ parts = [f"System: {SYSTEM_MASTER}"]
138
  for u, a in _iter_user_assistant(history):
139
  if u: parts.append(f"User: {u}")
140
  if a: parts.append(f"Assistant: {a}")
 
142
  parts.append("Assistant:")
143
  return "\n".join(parts)
144
 
145
+ # ---------- Scenario auto-detection (stricter) ----------
146
+ _SCENARIO_HEADINGS = [
147
+ "context", "background", "scenario", "case study",
148
+ "data inputs", "inputs", "evaluation questions", "questions",
149
+ "recommendations", "deployment strategy", "next steps", "assumptions"
150
+ ]
151
+ _SCENARIO_KEYWORDS = [
152
+ "diabetes", "screening", "metabolic", "prevalence", "settlements",
153
+ "capacity", "throughput", "cost", "startup", "ongoing",
154
+ "clinical", "a1c", "mmhg", "bmi", "cholesterol",
155
+ "mobile", "program", "mdsi", "ops"
156
+ ]
157
+
158
+ def _looks_like_scenario(text: str, uploaded_paths) -> bool:
159
+ """
160
+ Conservative trigger: only enter scenario mode on clearly structured,
161
+ domain-heavy content or when substantial files are attached.
162
+ """
163
+ if not text:
164
+ return False
165
+ t = text.strip()
166
+ low = t.lower()
167
+ n = len(t)
168
+
169
+ headings = sum(1 for h in _SCENARIO_HEADINGS if h in low)
170
+ kw_hits = sum(1 for k in _SCENARIO_KEYWORDS if k in low)
171
+ has_metrics = (
172
+ bool(re.search(r"\b\d{2,4}\b", low)) and
173
+ bool(re.search(r"%|\bmmhg\b|\bbmi\b|\ba1c\b", low))
174
+ )
175
+
176
+ # File trigger if some substance exists
177
+ if uploaded_paths and (n >= 200 or kw_hits >= 3):
178
+ return True
179
+ if n >= 700 and headings >= 1:
180
+ return True
181
+ if n >= 450 and headings >= 2:
182
+ return True
183
+ if n >= 500 and kw_hits >= 6 and has_metrics:
184
+ return True
185
+ return False
186
+
187
  # ---------- Cohere first ----------
188
+ def cohere_chat(message, history):
189
  if not USE_HOSTED_COHERE:
190
  return None
191
  try:
192
  client = cohere.Client(api_key=COHERE_API_KEY)
193
+ prompt = _history_to_prompt(message, history)
194
  resp = client.chat(
195
  model="command-r7b-12-2024",
196
  message=prompt,
 
233
  mdl.config.eos_token_id = tok.eos_token_id
234
  return mdl, tok
235
 
236
+ def build_inputs(tokenizer, message, history):
237
+ msgs = [{"role": "system", "content": SYSTEM_MASTER}]
238
  for u, a in _iter_user_assistant(history):
239
  if u: msgs.append({"role": "user", "content": u})
240
  if a: msgs.append({"role": "assistant", "content": a})
 
286
  "outcomes_summary": outcomes
287
  }, indent=2)
288
 
289
+ # ---------- Core chat logic (auto scenario routing) ----------
290
+ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answers=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  """
292
+ awaiting_answers (bool state):
293
+ - False: Normal chat route OR Scenario Phase 1 (if detected)
294
+ - True : Scenario Phase 2 (consume answers -> produce Structured Analysis)
295
  """
296
  try:
297
+ log_event("user_message", None, {"sizes": {"chars": len(user_msg or "")}})
298
 
299
  # Safety (input)
300
  safe_in, blocked_in, reason_in = safety_filter(user_msg, mode="input")
301
  if blocked_in:
302
  ans = refusal_reply(reason_in)
303
+ return history + [(user_msg, ans)], False # never hold for next phase on refusal
304
 
305
  # Identity short-circuit
306
  if is_identity_query(safe_in, history):
307
  ans = "I am ClarityOps, your strategic decision making AI partner."
308
+ return history + [(user_msg, ans)], False
309
 
310
+ # Ingest uploads (text + artifacts like CSV headers/summary)
311
  if uploaded_files_paths:
312
  ing = extract_text_from_files(uploaded_files_paths)
313
  chunks = ing.get("chunks", []) if isinstance(ing, dict) else (ing or [])
 
318
  _session_rag.register_artifacts(artifacts)
319
  log_event("uploads_added", None, {"chunks": len(chunks), "artifacts": len(artifacts)})
320
 
321
+ # Columns helper (quick utility)
322
  if re.search(r"\b(columns?|headers?)\b", (safe_in or "").lower()):
323
  cols = _session_rag.get_latest_csv_columns()
324
  if cols:
325
+ return history + [(user_msg, "Here are the column names from your most recent CSV upload:\n\n- " + "\n- ".join(cols))], awaiting_answers
326
+
327
+ # -------- Decide routing early --------
328
+ is_scenario = awaiting_answers or _looks_like_scenario(safe_in or "", uploaded_files_paths)
329
+
330
+ # ===== NORMAL CHAT =====
331
+ if not is_scenario:
332
+ normal_user = SYSTEM_MASTER + "\n\nUser message:\n" + (safe_in or "")
333
+ out = cohere_chat(normal_user, history)
334
+ if not out:
335
+ model, tokenizer = load_local_model()
336
+ inputs = build_inputs(tokenizer, normal_user, history)
337
+ out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
338
 
339
+ if isinstance(out, str):
340
+ for tag in ("Assistant:", "System:", "User:"):
341
+ if out.startswith(tag):
342
+ out = out[len(tag):].strip()
343
+ out = _sanitize_text(out)
344
+
345
+ safe_out, blocked_out, reason_out = safety_filter(out, mode="output")
346
+ if blocked_out:
347
+ safe_out = refusal_reply(reason_out)
348
+
349
+ log_event("assistant_reply", None, {
350
+ **hash_summary("prompt", normal_user if not PERSIST_CONTENT else ""),
351
+ **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
352
+ "awaiting_next_phase": False
353
+ })
354
+ return history + [(user_msg, safe_out)], False
355
+
356
+ # ===== SCENARIO MODE (Phase 1 or Phase 2) =====
357
+ # Build context ONLY for scenario path
358
  session_snips = "\n---\n".join(_session_rag.retrieve(
359
  "diabetes screening Indigenous Métis mobile program cost throughput outcomes logistics",
360
  k=6
361
  ))
 
362
  snapshot = _load_snapshot()
363
  policy_context = retrieve_context(
364
  "mobile diabetes screening Indigenous community outreach cultural safety data governance outcomes"
365
  )
366
  computed = compute_operational_numbers(snapshot)
 
367
  user_lower = (safe_in or "").lower()
368
  mdsi_extra = _mdsi_block() if ("diabetes" in user_lower or "mdsi" in user_lower or "mobile screening" in user_lower) else ""
 
369
  scenario_block = safe_in if len((safe_in or "")) > 0 else ""
370
  system_preamble = build_system_preamble(
371
  snapshot=snapshot,
 
375
  session_snips=session_snips
376
  )
377
 
378
+ # Phase selection
379
+ if awaiting_answers:
380
+ # -------- Phase 2 --------
381
  phase_directive = (
382
  "\n\n[INSTRUCTION TO MODEL]\n"
383
  "Produce **Phase 2** only: output a header 'Structured Analysis' and follow the exact section order "
 
407
  **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
408
  "awaiting_next_phase": False
409
  })
410
+ return history + [(user_msg, safe_out)], False
411
 
412
+ else:
413
+ # -------- Phase 1 --------
414
+ phase_directive = (
415
+ "\n\n[INSTRUCTION TO MODEL]\n"
416
+ "Produce **Phase 1** only: output a header 'Clarification Questions' and ask up to 5 concise, grouped questions "
417
+ "(Prioritization, Capacity, Cost, Clinical, Recommendations). Then STOP and WAIT.\n"
418
+ )
419
+ augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser scenario:\n" + (safe_in or "") + phase_directive
420
+
421
+ out = cohere_chat(augmented_user, history)
422
  if not out:
423
  model, tokenizer = load_local_model()
424
+ inputs = build_inputs(tokenizer, augmented_user, history)
425
  out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
426
 
427
  if isinstance(out, str):
 
435
  safe_out = refusal_reply(reason_out)
436
 
437
  log_event("assistant_reply", None, {
438
+ **hash_summary("prompt", augmented_user if not PERSIST_CONTENT else ""),
439
  **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
440
+ "awaiting_next_phase": True
441
  })
442
+ return history + [(user_msg, safe_out)], True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
 
444
  except Exception as e:
445
  err = f"Error: {e}"
 
447
  traceback.print_exc()
448
  except Exception:
449
  pass
450
+ return history + [(user_msg, err)], False
451
 
452
  # ---------- Theme & CSS ----------
453
  theme = gr.themes.Soft(primary_hue="teal", neutral_hue="slate", radius_size=gr.themes.sizes.radius_lg)
 
480
  gr.HTML("<h2>What can I help with?</h2>")
481
  with gr.Row(elem_classes="search-row"):
482
  hero_msg = gr.Textbox(
483
+ placeholder="Ask anything (paste scenarios here; you can attach files after)...",
484
  show_label=False,
485
  lines=1,
486
  elem_classes="hero-box"
487
  )
488
  hero_send = gr.Button("➤", scale=0)
489
+ gr.Markdown('<div class="hint">ClarityOps will first ask up to 5 clarifications (only for scenarios), then produce a structured analysis.</div>')
 
 
 
 
490
 
491
  # --- MAIN APP (hidden until first message) ---
492
  with gr.Column(elem_id="chat-container", visible=False) as app_wrap:
 
500
  msg = gr.Textbox(
501
  label="",
502
  show_label=False,
503
+ placeholder="Continue here. Paste scenario details, add files below.",
504
  scale=10
505
  )
506
  send = gr.Button("Send", scale=1)
 
509
  # ---- State
510
  state_history = gr.State(value=[])
511
  state_uploaded = gr.State(value=[])
512
+ state_awaiting = gr.State(value=False) # False -> normal or Phase 1 next; True -> expecting Phase 2 answers
513
 
514
  # ---- Uploads
515
  def _store_uploads(files, current):
 
521
  uploads.change(fn=_store_uploads, inputs=[uploads, state_uploaded], outputs=state_uploaded)
522
 
523
  # ---- Core send (used by both hero input and chat input)
524
+ def _on_send(user_msg, history, up_paths, awaiting):
525
  try:
526
  if not user_msg or not user_msg.strip():
527
+ # no toggle on empty
528
+ return history, "", history, awaiting
529
+ new_history, new_awaiting = clarityops_reply(
530
+ user_msg.strip(), history or [], None, up_paths or [], awaiting_answers=awaiting
531
  )
532
+ return new_history, "", new_history, new_awaiting
533
  except Exception as e:
534
  err = f"Error: {e}"
535
  try: traceback.print_exc()
536
  except Exception: pass
537
  new_hist = (history or []) + [(user_msg or "", err)]
538
+ return new_hist, "", new_hist, awaiting
539
 
540
  # ---- Hero -> App transition + first send
541
+ def _hero_start(user_msg, history, up_paths, awaiting):
542
+ chat_o, msg_o, hist_o, await_o = _on_send(user_msg, history, up_paths, awaiting)
543
  return (
544
+ chat_o, msg_o, hist_o, await_o,
545
  gr.update(visible=False), # hide hero
546
  gr.update(visible=True), # show app
547
  "" # clear hero box
 
549
 
550
  hero_send.click(
551
  _hero_start,
552
+ inputs=[hero_msg, state_history, state_uploaded, state_awaiting],
553
+ outputs=[chat, msg, state_history, state_awaiting, hero_wrap, app_wrap, hero_msg],
554
  concurrency_limit=2, queue=True
555
  )
556
  hero_msg.submit(
557
  _hero_start,
558
+ inputs=[hero_msg, state_history, state_uploaded, state_awaiting],
559
+ outputs=[chat, msg, state_history, state_awaiting, hero_wrap, app_wrap, hero_msg],
560
  concurrency_limit=2, queue=True
561
  )
562
 
563
  # ---- Normal chat interactions after hero is gone
564
+ send.click(_on_send, inputs=[msg, state_history, state_uploaded, state_awaiting],
565
+ outputs=[chat, msg, state_history, state_awaiting],
566
  concurrency_limit=2, queue=True)
567
+ msg.submit(_on_send, inputs=[msg, state_history, state_uploaded, state_awaiting],
568
+ outputs=[chat, msg, state_history, state_awaiting],
569
  concurrency_limit=2, queue=True)
570
 
571
  def _on_clear():
572
+ # reset to fresh hero screen
573
  return (
574
+ [], "", [], False,
575
  gr.update(visible=True), # show hero
576
  gr.update(visible=False), # hide app
577
  "" # clear hero input
578
  )
579
 
580
+ clear.click(_on_clear, None, [chat, msg, state_history, state_awaiting, hero_wrap, app_wrap, hero_msg])
581
 
582
  if __name__ == "__main__":
583
  port = int(os.environ.get("PORT", "7860"))