Rajan Sharma commited on
Commit
979b614
·
verified ·
1 Parent(s): ee530c2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +193 -93
app.py CHANGED
@@ -5,19 +5,13 @@ from typing import List, Dict, Any, Tuple
5
 
6
  import gradio as gr
7
  import torch
 
8
  import regex as re2 # robust control-char sanitizer
9
 
10
  from settings import SNAPSHOT_PATH, PERSIST_CONTENT
11
  from audit_log import log_event, hash_summary
12
  from privacy import redact_text
13
 
14
- # NEW: dynamic plan & profiling imports
15
- from plan_extractor import draft_plan_from_scenario
16
- from schema_profiler import profile_csv, build_dynamic_label_space, soft_bind_inputs_to_columns
17
- from analysis_runtime import ExecContext, op_summary_table, op_rank_top_n, op_delta_over_time, op_capacity_calc, op_cost_total
18
- from clarifier import missing_inputs_questions, render_phase1_markdown
19
- # ------------------------------------------------------
20
-
21
  # ---------- Writable caches (HF Spaces-safe) ----------
22
  HOME = pathlib.Path.home()
23
  HF_HOME = str(HOME / ".cache" / "huggingface")
@@ -224,6 +218,9 @@ def _load_snapshot(path=SNAPSHOT_PATH):
224
  init_retriever()
225
  _session_rag = SessionRAG()
226
 
 
 
 
227
  # ---------- Executive pre-compute (MDSi block) ----------
228
  def _mdsi_block():
229
  base_capacity = capacity_projection(18, 48, 6)
@@ -237,31 +234,164 @@ def _mdsi_block():
237
  "outcomes_summary": outcomes
238
  }, indent=2)
239
 
240
- # ---------- Core chat logic (auto scenario, dynamic Phase 1) ----------
241
- def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answers=False,
242
- session_profiles=None, session_frames=None):
243
  """
244
- awaiting_answers:
245
- - False: If scenario triggered -> Phase 1 (dynamic questions). Else normal chat.
246
- - True: If scenario triggered -> Phase 2 (structured analysis). Else normal chat.
247
- session_profiles: list of CSV profiles built at upload-time
248
- session_frames: dict of {filename: DataFrame}
249
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  try:
251
  log_event("user_message", None, {"sizes": {"chars": len(user_msg or "")}})
252
 
253
- # Safety (input)
254
  safe_in, blocked_in, reason_in = safety_filter(user_msg, mode="input")
255
  if blocked_in:
256
  ans = refusal_reply(reason_in)
257
  return history + [(user_msg, ans)], awaiting_answers
258
 
259
- # Identity short-circuit
260
  if is_identity_query(safe_in, history):
261
  ans = "I am ClarityOps, your strategic decision making AI partner."
262
  return history + [(user_msg, ans)], awaiting_answers
263
 
264
- # Ingest uploads (text for RAG; CSVs already profiled by UI handler)
265
  artifacts = []
266
  if uploaded_files_paths:
267
  ing = extract_text_from_files(uploaded_files_paths)
@@ -271,15 +401,26 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
271
  _session_rag.add_docs(chunks)
272
  if artifacts:
273
  _session_rag.register_artifacts(artifacts)
274
- log_event("uploads_added", None, {"chunks": len(chunks), "artifacts": len(artifacts)})
275
-
276
- # Column helper
 
 
 
 
 
 
 
 
 
 
 
 
277
  if re.search(r"\b(columns?|headers?)\b", (safe_in or "").lower()):
278
  cols = _session_rag.get_latest_csv_columns()
279
  if cols:
280
  return history + [(user_msg, "Here are the column names from your most recent CSV upload:\n\n- " + "\n- ".join(cols))], awaiting_answers
281
 
282
- # Decide mode
283
  scenario_mode = is_scenario_triggered(safe_in, uploaded_files_paths)
284
 
285
  if not scenario_mode:
@@ -307,34 +448,19 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
307
  return history + [(user_msg, safe_out)], awaiting_answers
308
 
309
  # ---------- Scenario Mode ----------
310
- # Build a dynamic column bag from uploaded profiles
311
- column_bag: List[str] = []
312
- for prof in (session_profiles or []):
313
- for c in prof.get("columns", []):
314
- nm = c.get("raw")
315
- if nm:
316
- column_bag.append(str(nm))
317
- column_bag = list(dict.fromkeys(column_bag))
318
-
319
  if not awaiting_answers:
320
- # PHASE 1: draft plan, bind inputs to real columns, and ask only for missing bits
321
- hf_tuple = None if USE_HOSTED_COHERE else load_local_model()
322
- plan = draft_plan_from_scenario(safe_in, column_bag, cohere_client=None, hf_tuple=hf_tuple)
323
- required_names = [r.get("input") or r.get("name") or "" for r in (plan.get("requires") or [])]
324
- scenario_labels = build_dynamic_label_space(safe_in)
325
- binding = soft_bind_inputs_to_columns(required_names, column_bag, scenario_labels)
326
- questions = missing_inputs_questions(plan, binding)
327
- phase1_md = render_phase1_markdown(questions)
328
- phase1_md = _sanitize_text(phase1_md)
329
  log_event("assistant_reply", None, {
330
  **hash_summary("prompt", safe_in if not PERSIST_CONTENT else ""),
331
- **hash_summary("reply", phase1_md if not PERSIST_CONTENT else ""),
332
  "mode": "scenario_phase1",
333
  "awaiting_next_phase": True
334
  })
335
- return history + [(user_msg, phase1_md)], True
336
 
337
- # PHASE 2: keep your existing structured generation (now with better file summary)
338
  session_snips = "\n---\n".join(_session_rag.retrieve(
339
  "diabetes screening Indigenous Métis mobile program cost throughput outcomes logistics",
340
  k=6
@@ -349,15 +475,9 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
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
 
352
- # Summarize actual DataFrames for provenance
353
- prov_lines = []
354
- for name, df in (session_frames or {}).items():
355
- try:
356
- cols = ", ".join(map(str, list(df.columns)[:12]))
357
- except Exception:
358
- cols = "<unavailable>"
359
- prov_lines.append(f"- {name}: {cols}")
360
- artifact_block = "Uploaded Data Files (summarized):\n" + ("\n".join(prov_lines) if prov_lines else "- <none>")
361
 
362
  scenario_block = safe_in if len((safe_in or "")) > 0 else ""
363
  system_preamble = build_system_preamble(
@@ -372,7 +492,8 @@ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answe
372
  "\n\n[INSTRUCTION TO MODEL]\n"
373
  "Produce **Phase 2** only now: start with 'Structured Analysis' and follow the exact section order "
374
  "(Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations). "
375
- "Use uploaded files + the user's latest answers as authoritative. Show calculations, units, and a brief Provenance.\n"
 
376
  )
377
 
378
  augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser scenario & answers:\n" + safe_in + directive
@@ -479,45 +600,23 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
479
  state_history = gr.State(value=[])
480
  state_uploaded = gr.State(value=[])
481
  state_awaiting = gr.State(value=False)
482
- # NEW: store CSV profiles and dataframes
483
- state_profiles = gr.State(value=[])
484
- state_frames = gr.State(value={})
485
-
486
- # ---- Uploads (now: store paths + build CSV profiles/frames)
487
- def _ingest_uploads(files, current_paths, current_profiles, current_frames):
488
- paths = list(current_paths or [])
489
- profiles = list(current_profiles or [])
490
- frames = dict(current_frames or {})
491
  for f in (files or []):
492
- p = getattr(f, "name", None) or f
493
- if not p:
494
- continue
495
- paths.append(p)
496
- # Build CSV profile+df when applicable
497
- try:
498
- if str(p).lower().endswith(".csv"):
499
- prof = profile_csv(p)
500
- profiles.append({k:v for k,v in prof.items() if k != "df"})
501
- frames[prof["name"]] = prof["df"]
502
- except Exception:
503
- # Non-fatal; keep going
504
- pass
505
- return paths, profiles, frames
506
-
507
- uploads.change(
508
- fn=_ingest_uploads,
509
- inputs=[uploads, state_uploaded, state_profiles, state_frames],
510
- outputs=[state_uploaded, state_profiles, state_frames]
511
- )
512
 
513
  # ---- Core send (used by both hero input and chat input)
514
- def _on_send(user_msg, history, up_paths, awaiting, profiles, frames):
515
  try:
516
  if not user_msg or not user_msg.strip():
517
  return history, "", history, awaiting
518
  new_history, new_awaiting = clarityops_reply(
519
- user_msg.strip(), history or [], None, up_paths or [], awaiting_answers=awaiting,
520
- session_profiles=profiles or [], session_frames=frames or {}
521
  )
522
  return new_history, "", new_history, new_awaiting
523
  except Exception as e:
@@ -528,8 +627,8 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
528
  return new_hist, "", new_hist, awaiting
529
 
530
  # ---- Hero -> App transition + first send
531
- def _hero_start(user_msg, history, up_paths, awaiting, profiles, frames):
532
- chat_o, msg_o, hist_o, await_o = _on_send(user_msg, history, up_paths, awaiting, profiles, frames)
533
  return (
534
  chat_o, msg_o, hist_o, await_o,
535
  gr.update(visible=False),
@@ -539,28 +638,28 @@ with gr.Blocks(theme=theme, css=custom_css, analytics_enabled=False) as demo:
539
 
540
  hero_send.click(
541
  _hero_start,
542
- inputs=[hero_msg, state_history, state_uploaded, state_awaiting, state_profiles, state_frames],
543
  outputs=[chat, msg, state_history, state_awaiting, hero_wrap, app_wrap, hero_msg],
544
  concurrency_limit=2, queue=True
545
  )
546
  hero_msg.submit(
547
  _hero_start,
548
- inputs=[hero_msg, state_history, state_uploaded, state_awaiting, state_profiles, state_frames],
549
  outputs=[chat, msg, state_history, state_awaiting, hero_wrap, app_wrap, hero_msg],
550
  concurrency_limit=2, queue=True
551
  )
552
 
553
  # ---- Normal chat interactions after hero is gone
554
- send.click(_on_send,
555
- inputs=[msg, state_history, state_uploaded, state_awaiting, state_profiles, state_frames],
556
  outputs=[chat, msg, state_history, state_awaiting],
557
  concurrency_limit=2, queue=True)
558
- msg.submit(_on_send,
559
- inputs=[msg, state_history, state_uploaded, state_awaiting, state_profiles, state_frames],
560
  outputs=[chat, msg, state_history, state_awaiting],
561
  concurrency_limit=2, queue=True)
562
 
563
  def _on_clear():
 
 
564
  return (
565
  [], "", [], False,
566
  gr.update(visible=True),
@@ -574,3 +673,4 @@ if __name__ == "__main__":
574
  port = int(os.environ.get("PORT", "7860"))
575
  demo.launch(server_name="0.0.0.0", server_port=port, show_api=False, max_threads=8)
576
 
 
 
5
 
6
  import gradio as gr
7
  import torch
8
+ import pandas as pd # <-- NEW: for real CSV analytics
9
  import regex as re2 # robust control-char sanitizer
10
 
11
  from settings import SNAPSHOT_PATH, PERSIST_CONTENT
12
  from audit_log import log_event, hash_summary
13
  from privacy import redact_text
14
 
 
 
 
 
 
 
 
15
  # ---------- Writable caches (HF Spaces-safe) ----------
16
  HOME = pathlib.Path.home()
17
  HF_HOME = str(HOME / ".cache" / "huggingface")
 
218
  init_retriever()
219
  _session_rag = SessionRAG()
220
 
221
+ # In-memory stash of uploaded DataFrames (name -> pd.DataFrame)
222
+ _SESSION_FRAMES: Dict[str, pd.DataFrame] = {} # <-- NEW
223
+
224
  # ---------- Executive pre-compute (MDSi block) ----------
225
  def _mdsi_block():
226
  base_capacity = capacity_projection(18, 48, 6)
 
234
  "outcomes_summary": outcomes
235
  }, indent=2)
236
 
237
+ # ---------- DataFrame -> JSON summary (generic, schema-free) ----------
238
+ def _summarize_frames_for_prompt(frames: Dict[str, pd.DataFrame], max_cols: int = 12, max_groups: int = 10) -> str:
 
239
  """
240
+ Build a JSON block with concrete, generic stats from uploaded DataFrames.
241
+ Works for arbitrary CSVs (no static schema).
 
 
 
242
  """
243
+ def safe_num_cols(df: pd.DataFrame):
244
+ return [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])]
245
+ def likely_group_cols(df: pd.DataFrame):
246
+ cand = [c for c in df.columns if any(k in str(c).lower()
247
+ for k in ["settlement", "community", "facility", "site", "region", "zone", "program", "service", "specialty", "hospital"])]
248
+ return cand[:2]
249
+ out = {"files": []}
250
+ for name, df in (frames or {}).items():
251
+ try:
252
+ rec = {"name": name, "shape": [int(df.shape[0]), int(df.shape[1])], "columns": list(map(str, df.columns[:max_cols]))}
253
+ num_cols = safe_num_cols(df)
254
+ if num_cols:
255
+ # count, mean, std, min, 25%, 50%, 75%, max for each numeric column
256
+ desc = df[num_cols].describe().to_dict()
257
+ # convert numpy types to natives for JSON
258
+ for k, v in desc.items():
259
+ for m, val in v.items():
260
+ try:
261
+ v[m] = float(val)
262
+ except Exception:
263
+ try:
264
+ v[m] = int(val)
265
+ except Exception:
266
+ pass
267
+ rec["numeric_summary"] = desc
268
+ groups = []
269
+ for gcol in likely_group_cols(df):
270
+ try:
271
+ gb = df.groupby(gcol).size().sort_values(ascending=False).head(max_groups)
272
+ # ensure JSON-serializable
273
+ groups.append({"by": str(gcol), "size_top": {str(k): int(v) for k, v in gb.to_dict().items()}})
274
+ except Exception:
275
+ pass
276
+ if groups:
277
+ rec["groups"] = groups
278
+ out["files"].append(rec)
279
+ except Exception:
280
+ continue
281
+ return json.dumps(out, indent=2)
282
+
283
+ # ---------- Dynamic Phase 1 question generator ----------
284
+ def _extract_present_domains(artifacts: List[Dict[str, Any]]) -> Dict[str, bool]:
285
+ flags = dict(population=False, cost=False, clinical=False, capacity=False)
286
+ for a in artifacts or []:
287
+ name = (a.get("name") or "").lower()
288
+ cols = [c.lower() for c in (a.get("columns") or [])]
289
+ if any(k in name for k in ["population", "census", "membership"]) or any(
290
+ k in ",".join(cols) for k in ["population", "census", "residence", "settlement", "age"]
291
+ ):
292
+ flags["population"] = True
293
+ if any(k in name for k in ["cost", "finance", "budget"]) or any(
294
+ k in ",".join(cols) for k in ["cost", "startup", "ongoing", "per_client", "per-visit"]
295
+ ):
296
+ flags["cost"] = True
297
+ if any(k in name for k in ["a1c", "outcome", "bp", "chol"]) or any(
298
+ k in ",".join(cols) for k in ["a1c", "bmi", "bp", "chol", "outcome"]
299
+ ):
300
+ flags["clinical"] = True
301
+ if any(k in name for k in ["ops", "capacity", "throughput", "volume"]) or any(
302
+ k in ",".join(cols) for k in ["clients_per_day", "teams", "visits", "throughput"]
303
+ ):
304
+ flags["capacity"] = True
305
+ return flags
306
+
307
+ def _domain_from_text(text: str) -> Dict[str, bool]:
308
+ t = (text or "").lower()
309
+ return {
310
+ "population": any(k in t for k in ["population", "census", "settlement", "membership"]),
311
+ "cost": any(k in t for k in ["cost", "budget", "startup", "per client", "per-client", "ongoing"]),
312
+ "clinical": any(k in t for k in ["a1c", "bmi", "blood pressure", "bp", "cholesterol", "outcome"]),
313
+ "capacity": any(k in t for k in ["capacity", "throughput", "clients per day", "teams", "screen", "volume"]),
314
+ }
315
+
316
+ def _is_mdsi_diabetes(text: str) -> bool:
317
+ t = (text or "").lower()
318
+ return any(k in t for k in ["mdsi", "mobile diabetes", "diabetes", "metabolic", "a1c", "metis"])
319
+
320
+ def build_dynamic_clarifications(scenario_text: str, artifacts: List[Dict[str, Any]]) -> str:
321
+ flags_from_files = _extract_present_domains(artifacts)
322
+ flags_from_text = _domain_from_text(scenario_text)
323
+ missing = {
324
+ k: not (flags_from_files.get(k) or flags_from_text.get(k))
325
+ for k in ["population", "capacity", "cost", "clinical"]
326
+ }
327
+
328
+ qs: List[Tuple[str, str]] = []
329
+ is_mdsi = _is_mdsi_diabetes(scenario_text)
330
+
331
+ if missing["population"]:
332
+ qs.append((
333
+ "Prioritization",
334
+ "Which population/risk indicators should drive prioritization (size, prevalence, access, equity factors)?"
335
+ if not is_mdsi else
336
+ "Confirm prioritization inputs: settlement membership living on-settlement (latest), obesity/metabolic syndrome prevalence, and any access-to-care constraints to weigh."
337
+ ))
338
+
339
+ if missing["capacity"]:
340
+ qs.append((
341
+ "Capacity",
342
+ "What per-team throughput and operating schedule should be used for capacity calculations?"
343
+ if not is_mdsi else
344
+ "What is the realistic per-team screening rate (clients/day) and operating schedule (days/week, weeks/3-month window)?"
345
+ ))
346
+
347
+ if missing["cost"]:
348
+ qs.append((
349
+ "Cost",
350
+ "Provide fixed setup costs and variable cost per client to model total program spend."
351
+ if not is_mdsi else
352
+ "Provide startup cost per client and ongoing cost per client/visit (or total program costs) to price scenarios like 1,200 screens."
353
+ ))
354
+
355
+ if missing["clinical"]:
356
+ qs.append((
357
+ "Clinical",
358
+ "Which clinical indicators and expected effect sizes should be tracked for outcomes?"
359
+ if not is_mdsi else
360
+ "What longitudinal deltas should we expect (e.g., ΔA1c, ΔBP, ΔBMI, lipids) from repeat screenings, and over what interval?"
361
+ ))
362
+
363
+ qs.append((
364
+ "Recommendations",
365
+ "Any operational constraints (scheduling, staffing, partnerships) we should incorporate into deployment modeling?"
366
+ if not is_mdsi else
367
+ "Are there community constraints (events/seasonality/cultural protocols) that should shape routing and visit cadence?"
368
+ ))
369
+
370
+ qs = qs[:5]
371
+ out = ["**Clarification Questions**"]
372
+ current_group = None
373
+ for grp, q in qs:
374
+ if grp != current_group:
375
+ out.append(f"\n**{grp}:**")
376
+ current_group = grp
377
+ out.append(f"- {q}")
378
+ return "\n".join(out)
379
+
380
+ # ---------- Core chat logic (auto scenario, dynamic Phase 1) ----------
381
+ def clarityops_reply(user_msg, history, tz, uploaded_files_paths, awaiting_answers=False):
382
  try:
383
  log_event("user_message", None, {"sizes": {"chars": len(user_msg or "")}})
384
 
 
385
  safe_in, blocked_in, reason_in = safety_filter(user_msg, mode="input")
386
  if blocked_in:
387
  ans = refusal_reply(reason_in)
388
  return history + [(user_msg, ans)], awaiting_answers
389
 
 
390
  if is_identity_query(safe_in, history):
391
  ans = "I am ClarityOps, your strategic decision making AI partner."
392
  return history + [(user_msg, ans)], awaiting_answers
393
 
394
+ # ---- Ingest uploads FIRST (files alone can trigger scenario mode)
395
  artifacts = []
396
  if uploaded_files_paths:
397
  ing = extract_text_from_files(uploaded_files_paths)
 
401
  _session_rag.add_docs(chunks)
402
  if artifacts:
403
  _session_rag.register_artifacts(artifacts)
404
+ # NEW: Read CSVs into DataFrames and stash in-memory for analytics
405
+ for a in (artifacts or []):
406
+ try:
407
+ if a.get("kind") == "csv" and a.get("path") and a.get("name"):
408
+ # read the whole CSV with automatic dtype inference; fallback to strings
409
+ try:
410
+ df = pd.read_csv(a["path"])
411
+ except Exception:
412
+ df = pd.read_csv(a["path"], dtype=str, low_memory=False)
413
+ _SESSION_FRAMES[str(a["name"])] = df
414
+ except Exception:
415
+ pass
416
+ log_event("uploads_added", None, {"chunks": len(chunks), "artifacts": len(artifacts), "dfs": len(_SESSION_FRAMES)})
417
+
418
+ # CSV columns helper (works in both modes)
419
  if re.search(r"\b(columns?|headers?)\b", (safe_in or "").lower()):
420
  cols = _session_rag.get_latest_csv_columns()
421
  if cols:
422
  return history + [(user_msg, "Here are the column names from your most recent CSV upload:\n\n- " + "\n- ".join(cols))], awaiting_answers
423
 
 
424
  scenario_mode = is_scenario_triggered(safe_in, uploaded_files_paths)
425
 
426
  if not scenario_mode:
 
448
  return history + [(user_msg, safe_out)], awaiting_answers
449
 
450
  # ---------- Scenario Mode ----------
 
 
 
 
 
 
 
 
 
451
  if not awaiting_answers:
452
+ # PHASE 1: dynamic questions (no assumptions)
453
+ phase1 = build_dynamic_clarifications(scenario_text=safe_in, artifacts=artifacts or _session_rag.artifacts)
454
+ phase1 = _sanitize_text(phase1)
 
 
 
 
 
 
455
  log_event("assistant_reply", None, {
456
  **hash_summary("prompt", safe_in if not PERSIST_CONTENT else ""),
457
+ **hash_summary("reply", phase1 if not PERSIST_CONTENT else ""),
458
  "mode": "scenario_phase1",
459
  "awaiting_next_phase": True
460
  })
461
+ return history + [(user_msg, phase1)], True
462
 
463
+ # PHASE 2: build rich system preamble + feed to LLM
464
  session_snips = "\n---\n".join(_session_rag.retrieve(
465
  "diabetes screening Indigenous Métis mobile program cost throughput outcomes logistics",
466
  k=6
 
475
  user_lower = (safe_in or "").lower()
476
  mdsi_extra = _mdsi_block() if ("diabetes" in user_lower or "mdsi" in user_lower or "mobile screening" in user_lower) else ""
477
 
478
+ # NEW: Real computed stats from CSVs for the model to use
479
+ computed_from_csvs = _summarize_frames_for_prompt(_SESSION_FRAMES)
480
+ artifact_block = "Computed Blocks From Uploaded Data (JSON):\n" + computed_from_csvs
 
 
 
 
 
 
481
 
482
  scenario_block = safe_in if len((safe_in or "")) > 0 else ""
483
  system_preamble = build_system_preamble(
 
492
  "\n\n[INSTRUCTION TO MODEL]\n"
493
  "Produce **Phase 2** only now: start with 'Structured Analysis' and follow the exact section order "
494
  "(Prioritization, Capacity, Cost, Clinical Benefits, ClarityOps Top 3 Recommendations). "
495
+ "Use the JSON computed blocks from the uploaded files + the user's latest answers as authoritative. "
496
+ "Show calculations, units, and a brief Provenance. If required data is still missing, output INSUFFICIENT_DATA.\n"
497
  )
498
 
499
  augmented_user = SYSTEM_MASTER + "\n\n" + system_preamble + "\n\nUser scenario & answers:\n" + safe_in + directive
 
600
  state_history = gr.State(value=[])
601
  state_uploaded = gr.State(value=[])
602
  state_awaiting = gr.State(value=False)
603
+
604
+ # ---- Uploads
605
+ def _store_uploads(files, current):
606
+ paths = []
 
 
 
 
 
607
  for f in (files or []):
608
+ paths.append(getattr(f, "name", None) or f)
609
+ return (current or []) + paths
610
+
611
+ uploads.change(fn=_store_uploads, inputs=[uploads, state_uploaded], outputs=state_uploaded)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
 
613
  # ---- Core send (used by both hero input and chat input)
614
+ def _on_send(user_msg, history, up_paths, awaiting):
615
  try:
616
  if not user_msg or not user_msg.strip():
617
  return history, "", history, awaiting
618
  new_history, new_awaiting = clarityops_reply(
619
+ user_msg.strip(), history or [], None, up_paths or [], awaiting_answers=awaiting
 
620
  )
621
  return new_history, "", new_history, new_awaiting
622
  except Exception as e:
 
627
  return new_hist, "", new_hist, awaiting
628
 
629
  # ---- Hero -> App transition + first send
630
+ def _hero_start(user_msg, history, up_paths, awaiting):
631
+ chat_o, msg_o, hist_o, await_o = _on_send(user_msg, history, up_paths, awaiting)
632
  return (
633
  chat_o, msg_o, hist_o, await_o,
634
  gr.update(visible=False),
 
638
 
639
  hero_send.click(
640
  _hero_start,
641
+ inputs=[hero_msg, state_history, state_uploaded, state_awaiting],
642
  outputs=[chat, msg, state_history, state_awaiting, hero_wrap, app_wrap, hero_msg],
643
  concurrency_limit=2, queue=True
644
  )
645
  hero_msg.submit(
646
  _hero_start,
647
+ inputs=[hero_msg, state_history, state_uploaded, state_awaiting],
648
  outputs=[chat, msg, state_history, state_awaiting, hero_wrap, app_wrap, hero_msg],
649
  concurrency_limit=2, queue=True
650
  )
651
 
652
  # ---- Normal chat interactions after hero is gone
653
+ send.click(_on_send, inputs=[msg, state_history, state_uploaded, state_awaiting],
 
654
  outputs=[chat, msg, state_history, state_awaiting],
655
  concurrency_limit=2, queue=True)
656
+ msg.submit(_on_send, inputs=[msg, state_history, state_uploaded, state_awaiting],
 
657
  outputs=[chat, msg, state_history, state_awaiting],
658
  concurrency_limit=2, queue=True)
659
 
660
  def _on_clear():
661
+ # also clear in-memory DataFrames
662
+ _SESSION_FRAMES.clear()
663
  return (
664
  [], "", [], False,
665
  gr.update(visible=True),
 
673
  port = int(os.environ.get("PORT", "7860"))
674
  demo.launch(server_name="0.0.0.0", server_port=port, show_api=False, max_threads=8)
675
 
676
+