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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +140 -131
app.py CHANGED
@@ -1,4 +1,3 @@
1
- # app.py
2
  import os, re, json, traceback, pathlib
3
  from functools import lru_cache
4
 
@@ -11,7 +10,7 @@ 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() # e.g., /home/user
15
  HF_HOME = str(HOME / ".cache" / "huggingface")
16
  HF_HUB_CACHE = str(HOME / ".cache" / "huggingface" / "hub")
17
  HF_TRANSFORMERS = str(HOME / ".cache" / "huggingface" / "transformers")
@@ -21,7 +20,7 @@ GRADIO_CACHE = GRADIO_TMP
21
 
22
  os.environ.setdefault("HF_HOME", HF_HOME)
23
  os.environ.setdefault("HF_HUB_CACHE", HF_HUB_CACHE)
24
- os.environ.setdefault("TRANSFORMERS_CACHE", HF_TRANSFORMERS) # deprecation warning is harmless
25
  os.environ.setdefault("SENTENCE_TRANSFORMERS_HOME", ST_HOME)
26
  os.environ.setdefault("GRADIO_TEMP_DIR", GRADIO_TMP)
27
  os.environ.setdefault("GRADIO_CACHE_DIR", GRADIO_CACHE)
@@ -34,7 +33,7 @@ for p in [HF_HOME, HF_HUB_CACHE, HF_TRANSFORMERS, ST_HOME, GRADIO_TMP, GRADIO_CA
34
  except Exception:
35
  pass
36
 
37
- # Optional Cohere (preferred path)
38
  try:
39
  import cohere
40
  _HAS_COHERE = True
@@ -53,36 +52,50 @@ from session_rag import SessionRAG
53
  from mdsi_analysis import capacity_projection, cost_estimate, outcomes_summary
54
 
55
  # ---------- Config ----------
56
- MODEL_ID = os.getenv("MODEL_ID", "microsoft/Phi-3-mini-4k-instruct") # HF fallback
57
  HF_TOKEN = os.getenv("HUGGINGFACE_HUB_TOKEN") or os.getenv("HF_TOKEN")
58
 
59
  COHERE_API_KEY = os.getenv("COHERE_API_KEY")
60
  USE_HOSTED_COHERE = bool(COHERE_API_KEY and _HAS_COHERE)
61
 
62
- # Generous output (Cohere + HF)
63
  MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "2048"))
64
 
65
- # ---------- System Master (baseline guardrails; scenario-specific rules are injected only when needed) ----------
66
  SYSTEM_MASTER = """
67
- SYSTEM ROLE
68
  You are ClarityOps, a medical analytics system that interacts only via this chat.
69
 
70
- Core guardrails (always active):
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
- - Use correct medical units and plausible ranges.
74
- - Avoid PHI; aggregate only; apply small-cell suppression when cohort < 10.
75
-
76
- Scenario mode (when a scenario is detected):
77
- - Run in TWO PHASES:
78
  Phase 1: Ask up to 5 concise clarification questions, grouped by category (Prioritization, Capacity, Cost, Clinical, Recommendations). Then STOP and WAIT.
79
- Phase 2: After answers are provided, produce the final structured analysis in this exact order:
80
- 1. Prioritization
81
- 2. Capacity
82
- 3. Cost
83
- 4. Clinical Benefits
84
- 5. ClarityOps Top 3 Recommendations
85
- Include a short “Provenance” mapping each key output to scenario text, files, and/or clarified answers.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  """.strip()
87
 
88
  # ---------- Helpers ----------
@@ -100,16 +113,51 @@ def is_identity_query(message, history):
100
  r"\bdescribe\s+yourself\b", r"\band\s+you\s*\?\b", r"\byour\s+name\b",
101
  r"\bwho\s+am\s+i\s+chatting\s+with\b",
102
  ]
103
-
104
  def match(t):
105
  return any(re.search(p, (t or "").strip().lower()) for p in patterns)
106
-
107
- if match(message):
108
- return True
109
  if history:
110
  last_user = history[-1][0] if isinstance(history[-1], (list, tuple)) else None
111
- if match(last_user):
112
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  return False
114
 
115
  def _iter_user_assistant(history):
@@ -120,8 +168,7 @@ def _iter_user_assistant(history):
120
  yield u, a
121
 
122
  def _sanitize_text(s: str) -> str:
123
- if not isinstance(s, str):
124
- return s
125
  return re2.sub(r'[\p{C}--[\n\t]]+', '', s)
126
 
127
  def _history_to_prompt(message, history):
@@ -133,46 +180,6 @@ def _history_to_prompt(message, history):
133
  parts.append("Assistant:")
134
  return "\n".join(parts)
135
 
136
- def _summarize_artifacts(arts):
137
- """Turn parsed artifacts (CSV, etc.) into a compact, model-friendly text block."""
138
- if not arts:
139
- return ""
140
- blocks = []
141
- for a in arts:
142
- kind = a.get("kind")
143
- name = a.get("name") or a.get("path") or "<file>"
144
- if kind == "csv":
145
- cols = ", ".join(map(str, a.get("columns", [])[:40])) or "<no columns>"
146
- n_rows = a.get("n_rows_sampled", 0)
147
- sample_rows = a.get("preview_rows") or []
148
- first = sample_rows[0] if sample_rows else {}
149
- first_str = ", ".join(f"{k}={str(v)[:60]}" for k, v in first.items()) if first else "<no sample>"
150
- blocks.append(
151
- f"FILE: {name}\nTYPE: CSV\nCOLUMNS: {cols}\nSAMPLED_ROWS: {n_rows}\nFIRST_ROW: {first_str}"
152
- )
153
- else:
154
- text = a.get("text", "")
155
- if text:
156
- blocks.append(f"FILE: {name}\nTYPE: {kind}\nEXTRACT (truncated): {text[:800]}")
157
- return "## Data File Summaries\n" + "\n\n".join(blocks)
158
-
159
- def _user_requested_files(text: str) -> bool:
160
- low = (text or "").lower()
161
- return "use the data files" in low or "use files" in low or "use uploaded" in low
162
-
163
- def _looks_like_scenario(text: str) -> bool:
164
- """Heuristics to detect scenario/case-study inputs and avoid triggering on greetings."""
165
- low = (text or "").lower().strip()
166
- if not low:
167
- return False
168
- if len(low) > 600:
169
- return True
170
- hit_words = sum(w in low for w in [
171
- "case study", "scenario", "background", "objective", "evaluation questions",
172
- "expected output", "structured analysis", "prioritization", "capacity", "clinical benefits"
173
- ])
174
- return hit_words >= 2
175
-
176
  # ---------- Cohere first ----------
177
  def cohere_chat(message, history):
178
  if not USE_HOSTED_COHERE:
@@ -275,12 +282,12 @@ def _mdsi_block():
275
  "outcomes_summary": outcomes
276
  }, indent=2)
277
 
278
- # ---------- Core chat logic (auto scenario detection + two-phase when applicable) ----------
279
  def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answers=False):
280
  """
281
  awaiting_answers:
282
- - False: normal chat or Phase 1 (if scenario)
283
- - True: Phase 2 (if scenario)
284
  """
285
  try:
286
  log_event("user_message", None, {"sizes": {"chars": len(user_msg or "")}})
@@ -296,7 +303,8 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
296
  ans = "I am ClarityOps, your strategic decision making AI partner."
297
  return history + [(user_msg, ans)], awaiting_answers
298
 
299
- # Ingest uploads
 
300
  if uploaded_files_paths:
301
  ing = extract_text_from_files(uploaded_files_paths)
302
  chunks = ing.get("chunks", []) if isinstance(ing, dict) else (ing or [])
@@ -307,65 +315,51 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
307
  _session_rag.register_artifacts(artifacts)
308
  log_event("uploads_added", None, {"chunks": len(chunks), "artifacts": len(artifacts)})
309
 
310
- # Prepare artifact summary for prompt use
311
- data_summary_text = _summarize_artifacts(_session_rag.artifacts)
312
-
313
- # If user asks to "use files" but none parsed, ask for them explicitly
314
- if _user_requested_files(safe_in) and not _session_rag.artifacts:
315
- msg = ("I don’t see any parsed data files yet. Please attach your CSV/XLSX/PDF first, "
316
- "then resend your scenario. I’ll auto-summarize the files and use them in the analysis.")
317
- return history + [(user_msg, msg)], False
318
-
319
- # Quick helper: show latest CSV columns if asked
320
  if re.search(r"\b(columns?|headers?)\b", (safe_in or "").lower()):
321
  cols = _session_rag.get_latest_csv_columns()
322
  if cols:
323
  return history + [(user_msg, "Here are the column names from your most recent CSV upload:\n\n- " + "\n- ".join(cols))], awaiting_answers
324
 
325
- # Retrieval snippets
326
- session_snips = "\n---\n".join(_session_rag.retrieve(
327
- "diabetes screening Indigenous Métis mobile program cost throughput outcomes logistics",
328
- k=6
329
- ))
330
 
331
- # Background computation/policy context
332
- snapshot = _load_snapshot()
333
- policy_context = retrieve_context(
334
- "mobile diabetes screening Indigenous community outreach cultural safety data governance outcomes"
335
- )
336
- computed = compute_operational_numbers(snapshot)
337
-
338
- # MDSi extras if relevant words present
339
- user_lower = (safe_in or "").lower()
340
- mdsi_extra = _mdsi_block() if ("diabetes" in user_lower or "mdsi" in user_lower or "mobile screening" in user_lower) else ""
341
-
342
- # Build scenario-text (always include file summaries if present)
343
- scenario_block = safe_in if len((safe_in or "")) > 0 else ""
344
- scenario_text_full = (
345
- scenario_block
346
- + (f"\n\nExecutive Pre-Computed Blocks:\n{mdsi_extra}" if mdsi_extra else "")
347
- + ("\n\n" + data_summary_text if data_summary_text else "")
348
- )
349
 
350
- # Build the common system preamble
351
- system_preamble = build_system_preamble(
352
- snapshot=snapshot,
353
- policy_context=policy_context,
354
- computed_numbers=computed,
355
- scenario_text=scenario_text_full,
356
- session_snips=session_snips
357
- )
358
-
359
- # Decide if this turn should be in scenario mode
360
- scenario_mode = awaiting_answers or _looks_like_scenario(safe_in)
361
 
362
- # Phase directive (only if scenario mode)
363
  if scenario_mode:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  if not awaiting_answers:
365
  phase_directive = (
366
  "\n\n[INSTRUCTION TO MODEL]\n"
367
- "Produce **Phase 1** only: First show a brief 'Data Snapshot' summarizing any parsed files (max 6 bullets), "
368
- "then output a header 'Clarification Questions' and ask up to 5 concise, grouped questions "
369
  "(Prioritization, Capacity, Cost, Clinical, Recommendations). Then STOP and WAIT.\n"
370
  )
371
  else:
@@ -375,16 +369,33 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
375
  "(Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations). "
376
  "Use uploaded files + the user's latest answers as authoritative. Show calculations, units, and a brief Provenance.\n"
377
  )
378
- else:
379
- # Normal chat: no phase instruction
380
- phase_directive = ""
381
 
382
- augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser message:\n" + safe_in + phase_directive
 
 
 
 
 
 
 
 
 
 
383
 
384
- # Call LLM (Cohere preferred, HF fallback)
385
  out = cohere_chat(augmented_user, history)
386
  if not out:
387
  model, tokenizer = load_local_model()
 
 
 
 
 
 
 
 
 
 
388
  inputs = build_inputs(tokenizer, augmented_user, history)
389
  out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
390
 
@@ -400,7 +411,7 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
400
  if blocked_out:
401
  safe_out = refusal_reply(reason_out)
402
 
403
- # Flip phase state only if we were in scenario mode
404
  new_awaiting = awaiting_answers
405
  if scenario_mode:
406
  low = safe_out.lower()
@@ -408,10 +419,7 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
408
  new_awaiting = True
409
  elif awaiting_answers and "structured analysis" in low:
410
  new_awaiting = False
411
- else:
412
- new_awaiting = False # normal chat never toggles scenario phase
413
 
414
- # Audit (content-free fingerprints)
415
  log_event("assistant_reply", None, {
416
  **hash_summary("prompt", augmented_user if not PERSIST_CONTENT else ""),
417
  **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
@@ -466,7 +474,7 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
466
  elem_classes="hero-box"
467
  )
468
  hero_send = gr.Button("➤", scale=0)
469
- gr.Markdown('<div class="hint">ClarityOps will first ask up to 5 clarifications (only if it detects a scenario), then produce a structured analysis.</div>')
470
 
471
  # --- MAIN APP (hidden until first message) ---
472
  with gr.Column(elem_id="chat-container", visible=False) as app_wrap:
@@ -489,7 +497,7 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
489
  # ---- State
490
  state_history = gr.State(value=[])
491
  state_uploaded = gr.State(value=[])
492
- state_awaiting = gr.State(value=False) # False -> no pending Phase-2; True -> expecting Phase-2 answers
493
 
494
  # ---- Uploads
495
  def _store_uploads(files, current):
@@ -504,6 +512,7 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
504
  def _on_send(user_msg, history, up_paths, awaiting):
505
  try:
506
  if not user_msg or not user_msg.strip():
 
507
  return history, "", history, awaiting
508
  new_history, new_awaiting = clarityops_reply(
509
  user_msg.strip(), history or [], None, up_paths or [], awaiting_answers=awaiting
 
 
1
  import os, re, json, traceback, pathlib
2
  from functools import lru_cache
3
 
 
10
  from privacy import redact_text
11
 
12
  # ---------- Environment / cache (Spaces-safe, writable) ----------
13
+ HOME = pathlib.Path.home()
14
  HF_HOME = str(HOME / ".cache" / "huggingface")
15
  HF_HUB_CACHE = str(HOME / ".cache" / "huggingface" / "hub")
16
  HF_TRANSFORMERS = str(HOME / ".cache" / "huggingface" / "transformers")
 
20
 
21
  os.environ.setdefault("HF_HOME", HF_HOME)
22
  os.environ.setdefault("HF_HUB_CACHE", HF_HUB_CACHE)
23
+ os.environ.setdefault("TRANSFORMERS_CACHE", HF_TRANSFORMERS)
24
  os.environ.setdefault("SENTENCE_TRANSFORMERS_HOME", ST_HOME)
25
  os.environ.setdefault("GRADIO_TEMP_DIR", GRADIO_TMP)
26
  os.environ.setdefault("GRADIO_CACHE_DIR", GRADIO_CACHE)
 
33
  except Exception:
34
  pass
35
 
36
+ # Optional Cohere
37
  try:
38
  import cohere
39
  _HAS_COHERE = True
 
52
  from mdsi_analysis import capacity_projection, cost_estimate, outcomes_summary
53
 
54
  # ---------- Config ----------
55
+ MODEL_ID = os.getenv("MODEL_ID", "microsoft/Phi-3-mini-4k-instruct") # fallback
56
  HF_TOKEN = os.getenv("HUGGINGFACE_HUB_TOKEN") or os.getenv("HF_TOKEN")
57
 
58
  COHERE_API_KEY = os.getenv("COHERE_API_KEY")
59
  USE_HOSTED_COHERE = bool(COHERE_API_KEY and _HAS_COHERE)
60
 
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
96
+ 4. Clinical Benefits
97
+ 5. ClarityOps Top 3 Recommendations
98
+ (Include a short Provenance block at the end.)
99
  """.strip()
100
 
101
  # ---------- Helpers ----------
 
113
  r"\bdescribe\s+yourself\b", r"\band\s+you\s*\?\b", r"\byour\s+name\b",
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):
 
168
  yield u, a
169
 
170
  def _sanitize_text(s: str) -> str:
171
+ if not isinstance(s, str): return s
 
172
  return re2.sub(r'[\p{C}--[\n\t]]+', '', s)
173
 
174
  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:
 
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
  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)
310
  chunks = ing.get("chunks", []) if isinstance(ing, dict) else (ing or [])
 
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:
 
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
  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()
 
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 ""),
 
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:
 
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):
 
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