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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +156 -182
app.py CHANGED
@@ -11,7 +11,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()
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 +21,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)
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 +34,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
38
  try:
39
  import cohere
40
  _HAS_COHERE = True
@@ -53,50 +53,36 @@ 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") # 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
- # 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 ----------
@@ -114,7 +100,7 @@ def is_identity_query(message, history):
114
  r"\bdescribe\s+yourself\b", r"\band\s+you\s*\?\b", r"\byour\s+name\b",
115
  r"\bwho\s+am\s+i\s+chatting\s+with\b",
116
  ]
117
- def match(t): 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
@@ -142,47 +128,45 @@ def _history_to_prompt(message, history):
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):
@@ -286,12 +270,12 @@ def _mdsi_block():
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 "")}})
@@ -300,14 +284,14 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
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,128 +302,119 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
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,
372
  policy_context=policy_context,
373
  computed_numbers=computed,
374
- scenario_text=scenario_block + (f"\n\nExecutive Pre-Computed Blocks:\n{mdsi_extra}" if mdsi_extra else ""),
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 "
384
- "(Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations). "
385
- "Use uploaded files + the user's latest answers as authoritative. Show calculations, units, and a brief Provenance.\n"
386
- )
387
- augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nClarification answers from user:\n" + (safe_in or "<none>") + phase_directive
388
-
389
- out = cohere_chat(augmented_user, history)
390
- if not out:
391
- model, tokenizer = load_local_model()
392
- inputs = build_inputs(tokenizer, augmented_user, history)
393
- out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
394
-
395
- if isinstance(out, str):
396
- for tag in ("Assistant:", "System:", "User:"):
397
- if out.startswith(tag):
398
- out = out[len(tag):].strip()
399
- out = _sanitize_text(out)
400
-
401
- safe_out, blocked_out, reason_out = safety_filter(out, mode="output")
402
- if blocked_out:
403
- safe_out = refusal_reply(reason_out)
404
-
405
- log_event("assistant_reply", None, {
406
- **hash_summary("prompt", augmented_user if not PERSIST_CONTENT else ""),
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):
428
- for tag in ("Assistant:", "System:", "User:"):
429
- if out.startswith(tag):
430
- out = out[len(tag):].strip()
431
- out = _sanitize_text(out)
432
-
433
- safe_out, blocked_out, reason_out = safety_filter(out, mode="output")
434
- if blocked_out:
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,7 +422,7 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
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)
@@ -486,7 +461,7 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
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:
@@ -509,7 +484,7 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
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):
@@ -524,7 +499,6 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
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
 
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
 
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
  except Exception:
35
  pass
36
 
37
+ # Optional Cohere (preferred path)
38
  try:
39
  import cohere
40
  _HAS_COHERE = True
 
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
  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
+ def match(t): return any(re.search(p, (t or "").strip().lower()) for p in patterns))
104
  if match(message): return True
105
  if history:
106
  last_user = history[-1][0] if isinstance(history[-1], (list, tuple)) else None
 
128
  parts.append("Assistant:")
129
  return "\n".join(parts)
130
 
131
+ def _summarize_artifacts(arts):
132
+ """Turn parsed artifacts (CSV, etc.) into a compact, model-friendly text block."""
133
+ if not arts:
134
+ return ""
135
+ blocks = []
136
+ for a in arts:
137
+ kind = a.get("kind")
138
+ name = a.get("name") or a.get("path") or "<file>"
139
+ if kind == "csv":
140
+ cols = ", ".join(map(str, a.get("columns", [])[:40])) or "<no columns>"
141
+ n_rows = a.get("n_rows_sampled", 0)
142
+ sample_rows = a.get("preview_rows") or []
143
+ first = sample_rows[0] if sample_rows else {}
144
+ first_str = ", ".join(f"{k}={str(v)[:60]}" for k, v in first.items()) if first else "<no sample>"
145
+ blocks.append(
146
+ f"FILE: {name}\nTYPE: CSV\nCOLUMNS: {cols}\nSAMPLED_ROWS: {n_rows}\nFIRST_ROW: {first_str}"
147
+ )
148
+ else:
149
+ text = a.get("text", "")
150
+ if text:
151
+ blocks.append(f"FILE: {name}\nTYPE: {kind}\nEXTRACT (truncated): {text[:800]}")
152
+ return "## Data File Summaries\n" + "\n\n".join(blocks)
153
+
154
+ def _user_requested_files(text: str) -> bool:
155
+ low = (text or "").lower()
156
+ return "use the data files" in low or "use files" in low or "use uploaded" in low
157
+
158
+ def _looks_like_scenario(text: str) -> bool:
159
+ """Heuristics to detect scenario/case-study inputs and avoid triggering on greetings."""
160
+ low = (text or "").lower().strip()
161
+ if not low:
162
  return False
163
+ if len(low) > 600:
 
 
 
 
 
 
 
 
 
 
 
 
164
  return True
165
+ hit_words = sum(w in low for w in [
166
+ "case study", "scenario", "background", "objective", "evaluation questions",
167
+ "expected output", "structured analysis", "prioritization", "capacity", "clinical benefits"
168
+ ])
169
+ return hit_words >= 2
 
 
170
 
171
  # ---------- Cohere first ----------
172
  def cohere_chat(message, history):
 
270
  "outcomes_summary": outcomes
271
  }, indent=2)
272
 
273
+ # ---------- Core chat logic (auto scenario detection + two-phase when applicable) ----------
274
  def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answers=False):
275
  """
276
+ awaiting_answers:
277
+ - False: normal chat or Phase 1 (if scenario)
278
+ - True: Phase 2 (if scenario)
279
  """
280
  try:
281
  log_event("user_message", None, {"sizes": {"chars": len(user_msg or "")}})
 
284
  safe_in, blocked_in, reason_in = safety_filter(user_msg, mode="input")
285
  if blocked_in:
286
  ans = refusal_reply(reason_in)
287
+ return history + [(user_msg, ans)], awaiting_answers
288
 
289
  # Identity short-circuit
290
  if is_identity_query(safe_in, history):
291
  ans = "I am ClarityOps, your strategic decision making AI partner."
292
+ return history + [(user_msg, ans)], awaiting_answers
293
 
294
+ # Ingest uploads
295
  if uploaded_files_paths:
296
  ing = extract_text_from_files(uploaded_files_paths)
297
  chunks = ing.get("chunks", []) if isinstance(ing, dict) else (ing or [])
 
302
  _session_rag.register_artifacts(artifacts)
303
  log_event("uploads_added", None, {"chunks": len(chunks), "artifacts": len(artifacts)})
304
 
305
+ # Prepare artifact summary for prompt use
306
+ data_summary_text = _summarize_artifacts(_session_rag.artifacts)
307
+
308
+ # If user asks to "use files" but none parsed, ask for them explicitly
309
+ if _user_requested_files(safe_in) and not _session_rag.artifacts:
310
+ msg = ("I don’t see any parsed data files yet. Please attach your CSV/XLSX/PDF first, "
311
+ "then resend your scenario. I’ll auto-summarize the files and use them in the analysis.")
312
+ return history + [(user_msg, msg)], False
313
+
314
+ # Quick helper: show latest CSV columns if asked
315
  if re.search(r"\b(columns?|headers?)\b", (safe_in or "").lower()):
316
  cols = _session_rag.get_latest_csv_columns()
317
  if cols:
318
  return history + [(user_msg, "Here are the column names from your most recent CSV upload:\n\n- " + "\n- ".join(cols))], awaiting_answers
319
 
320
+ # Retrieval snippets
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  session_snips = "\n---\n".join(_session_rag.retrieve(
322
  "diabetes screening Indigenous Métis mobile program cost throughput outcomes logistics",
323
  k=6
324
  ))
325
+
326
+ # Background computation/policy context
327
  snapshot = _load_snapshot()
328
  policy_context = retrieve_context(
329
  "mobile diabetes screening Indigenous community outreach cultural safety data governance outcomes"
330
  )
331
  computed = compute_operational_numbers(snapshot)
332
+
333
+ # MDSi extras if relevant words present
334
  user_lower = (safe_in or "").lower()
335
  mdsi_extra = _mdsi_block() if ("diabetes" in user_lower or "mdsi" in user_lower or "mobile screening" in user_lower) else ""
336
+
337
+ # Build scenario-text (always include file summaries if present)
338
  scenario_block = safe_in if len((safe_in or "")) > 0 else ""
339
+ scenario_text_full = (
340
+ scenario_block
341
+ + (f"\n\nExecutive Pre-Computed Blocks:\n{mdsi_extra}" if mdsi_extra else "")
342
+ + ("\n\n" + data_summary_text if data_summary_text else "")
343
+ )
344
+
345
+ # Build the common system preamble
346
  system_preamble = build_system_preamble(
347
  snapshot=snapshot,
348
  policy_context=policy_context,
349
  computed_numbers=computed,
350
+ scenario_text=scenario_text_full,
351
  session_snips=session_snips
352
  )
353
 
354
+ # Decide if this turn should be in scenario mode
355
+ scenario_mode = awaiting_answers or _looks_like_scenario(safe_in)
356
+
357
+ # Phase directive (only if scenario mode)
358
+ if scenario_mode:
359
+ if not awaiting_answers:
360
+ phase_directive = (
361
+ "\n\n[INSTRUCTION TO MODEL]\n"
362
+ "Produce **Phase 1** only: First show a brief 'Data Snapshot' summarizing any parsed files (max 6 bullets), "
363
+ "then output a header 'Clarification Questions' and ask up to 5 concise, grouped questions "
364
+ "(Prioritization, Capacity, Cost, Clinical, Recommendations). Then STOP and WAIT.\n"
365
+ )
366
+ else:
367
+ phase_directive = (
368
+ "\n\n[INSTRUCTION TO MODEL]\n"
369
+ "Produce **Phase 2** only: output a header 'Structured Analysis' and follow the exact section order "
370
+ "(Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations). "
371
+ "Use uploaded files + the user's latest answers as authoritative. Show calculations, units, and a brief Provenance.\n"
372
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  else:
374
+ # Normal chat: no phase instruction
375
+ phase_directive = ""
376
+
377
+ augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser message:\n" + safe_in + phase_directive
378
+
379
+ # Call LLM (Cohere preferred, HF fallback)
380
+ out = cohere_chat(augmented_user, history)
381
+ if not out:
382
+ model, tokenizer = load_local_model()
383
+ inputs = build_inputs(tokenizer, augmented_user, history)
384
+ out = local_generate(model, tokenizer, inputs, max_new_tokens=MAX_NEW_TOKENS)
385
+
386
+ # Clean + sanitize
387
+ if isinstance(out, str):
388
+ for tag in ("Assistant:", "System:", "User:"):
389
+ if out.startswith(tag):
390
+ out = out[len(tag):].strip()
391
+ out = _sanitize_text(out)
392
+
393
+ # Safety (output)
394
+ safe_out, blocked_out, reason_out = safety_filter(out, mode="output")
395
+ if blocked_out:
396
+ safe_out = refusal_reply(reason_out)
397
+
398
+ # Flip phase state only if we were in scenario mode
399
+ new_awaiting = awaiting_answers
400
+ if scenario_mode:
401
+ low = safe_out.lower()
402
+ if not awaiting_answers and "clarification questions" in low:
403
+ new_awaiting = True
404
+ elif awaiting_answers and "structured analysis" in low:
405
+ new_awaiting = False
406
+ else:
407
+ new_awaiting = False # normal chat never toggles scenario phase
408
+
409
+ # Audit (content-free fingerprints)
410
+ log_event("assistant_reply", None, {
411
+ **hash_summary("prompt", augmented_user if not PERSIST_CONTENT else ""),
412
+ **hash_summary("reply", safe_out if not PERSIST_CONTENT else ""),
413
+ "awaiting_next_phase": new_awaiting,
414
+ "scenario_mode": scenario_mode
415
+ })
416
+
417
+ return history + [(user_msg, safe_out)], new_awaiting
418
 
419
  except Exception as e:
420
  err = f"Error: {e}"
 
422
  traceback.print_exc()
423
  except Exception:
424
  pass
425
+ return history + [(user_msg, err)], awaiting_answers
426
 
427
  # ---------- Theme & CSS ----------
428
  theme = gr.themes.Soft(primary_hue="teal", neutral_hue="slate", radius_size=gr.themes.sizes.radius_lg)
 
461
  elem_classes="hero-box"
462
  )
463
  hero_send = gr.Button("➤", scale=0)
464
+ 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>')
465
 
466
  # --- MAIN APP (hidden until first message) ---
467
  with gr.Column(elem_id="chat-container", visible=False) as app_wrap:
 
484
  # ---- State
485
  state_history = gr.State(value=[])
486
  state_uploaded = gr.State(value=[])
487
+ state_awaiting = gr.State(value=False) # False -> no pending Phase-2; True -> expecting Phase-2 answers
488
 
489
  # ---- Uploads
490
  def _store_uploads(files, current):
 
499
  def _on_send(user_msg, history, up_paths, awaiting):
500
  try:
501
  if not user_msg or not user_msg.strip():
 
502
  return history, "", history, awaiting
503
  new_history, new_awaiting = clarityops_reply(
504
  user_msg.strip(), history or [], None, up_paths or [], awaiting_answers=awaiting