""" demo/app.py EmpathRAG — Full Gradio Demo IG ASYNC PATTERN: When the guardrail fires (crisis detected), the safe response must appear in <1 second. Integrated Gradients (IG) attribution takes ~30-45 seconds on CPU. Implementation: 1. Monkey-patch pipeline.guardrail.check to force skip_ig=True during pipeline.run() 2. First yield: safe response + loading placeholder in IG panel 3. Run real IG check (skip_ig=False) in same generator after yielding 4. Second yield: same response + populated IG panel with token highlights This uses a generator function with conditional double-yield. Gradio 4.21 doesn't have gr.Timer, so we use synchronous IG after first yield. The user sees the safe response instantly; IG completes in background of same request. """ import sys sys.path.insert(0, "src") import gradio as gr from pipeline.pipeline import EmpathRAGPipeline # ── Constants ───────────────────────────────────────────────────────────────── LABEL_NAMES = ["distress", "anxiety", "frustration", "neutral", "hopeful"] LABEL_COLORS = { "distress": "#e74c3c", "anxiety": "#e67e22", "frustration": "#9b59b6", "neutral": "#95a5a6", "hopeful": "#27ae60", } # ── Global State ────────────────────────────────────────────────────────────── print("[Demo] Initialising EmpathRAG pipeline...") pipeline = EmpathRAGPipeline(use_real_guardrail=True, guardrail_threshold=0.5) print("[Demo] Pipeline ready.") emotion_history = [] # List of {turn: int, label_name: str, color: str} # ── HTML Formatters ─────────────────────────────────────────────────────────── def format_emotion_timeline(history: list) -> str: """Returns HTML for emotion timeline pills.""" if not history: return "
No emotions detected yet.
" trajectory = pipeline.tracker.trajectory() trajectory_badge_colors = { "stable": "#95a5a6", "stable_positive": "#27ae60", "stable_negative": "#e74c3c", "escalating": "#c0392b", "de_escalating": "#16a085", "volatile": "#f39c12", } traj_color = trajectory_badge_colors.get(trajectory, "#95a5a6") header = f"""
Session: {trajectory.replace('_', ' ').title()}
""" pills = [] for entry in history: pill = f""" T{entry["turn"]}: {entry["label_name"]} """ pills.append(pill) pills_html = f"
{''.join(pills)}
" return header + pills_html def format_ig_panel(is_crisis: bool, confidence: float, ig_tokens: list, loading: bool) -> str: """Returns HTML for the crisis + IG attribution panel.""" if not is_crisis: return "
No crisis detected this session.
" if loading: # Loading state - IG running in background return f"""
🚨 Crisis signal detected — confidence: {confidence:.1%}
⏳ Computing token attributions...
""" # Fully loaded - show confidence bar + token highlights conf_pct = int(confidence * 100) bar_color = "#e74c3c" if confidence >= 0.7 else "#f39c12" conf_bar = f"""
Crisis Confidence: {confidence:.1%}
""" if not ig_tokens: # IG was skipped or no tokens return f"""
{conf_bar}
Token attributions unavailable.
""" # Build token highlight spans # Filter out special tokens and compute max score for normalization filtered_tokens = [ (tok, score) for tok, score in ig_tokens if not (tok.startswith("▁") and len(tok.strip("▁")) == 0) ] if not filtered_tokens: token_section = "
No significant tokens.
" else: max_score = max(score for _, score in filtered_tokens) token_spans = [] for tok, score in filtered_tokens[:10]: # Top 10 opacity = 0.25 + 0.75 * (score / max_score if max_score > 0 else 0) # Clean up token display - remove leading underscore for word pieces display_tok = tok.replace("▁", " ").strip() if not display_tok: continue span = f""" {display_tok} """ token_spans.append(span) token_section = f"""
Top Crisis Signals:
{''.join(token_spans)}
""" return f"""
{conf_bar} {token_section}
""" # ── Core Logic ──────────────────────────────────────────────────────────────── def respond(message, chat_history): """ Generator function that yields 1-2 times. Normal flow: single yield with all outputs. Crisis flow: - yield 1: instant safe response + IG loading panel - yield 2: same response + populated IG panel (after IG completes) """ # Validate input if not message or not message.strip(): yield ( chat_history, format_emotion_timeline(emotion_history), pipeline.tracker.trajectory(), format_ig_panel(False, 0.0, [], False), ) return # Monkey-patch guardrail to skip IG on first pass original_check = pipeline.guardrail.check def fast_check(text, threshold=0.5, skip_ig=False): return original_check(text, threshold=threshold, skip_ig=True) pipeline.guardrail.check = fast_check # Run pipeline with fast guardrail check result = pipeline.run(message) # Restore original guardrail pipeline.guardrail.check = original_check # Update chat history chat_history.append((message, result["response"])) # Update emotion timeline turn = len(emotion_history) + 1 emotion_history.append({ "turn": turn, "label_name": result["emotion_name"], "color": LABEL_COLORS[result["emotion_name"]], }) timeline_html = format_emotion_timeline(emotion_history) trajectory_text = result["trajectory"] # Handle crisis vs non-crisis if result["crisis"]: # FIRST YIELD: Instant response with loading IG panel crisis_panel_loading = format_ig_panel( True, result["crisis_confidence"], [], loading=True, ) yield ( chat_history, timeline_html, trajectory_text, crisis_panel_loading, ) # Run real IG check in foreground (blocking, but user already has response) # This takes ~30-45s on CPU _, confidence, ig_tokens = pipeline.guardrail.check( message, threshold=0.5, skip_ig=False, ) # SECOND YIELD: Same response, populated IG panel crisis_panel_final = format_ig_panel( True, confidence, ig_tokens, loading=False, ) yield ( chat_history, timeline_html, trajectory_text, crisis_panel_final, ) else: # Non-crisis: single yield crisis_panel = format_ig_panel(False, 0.0, [], False) yield ( chat_history, timeline_html, trajectory_text, crisis_panel, ) def reset_session(): """Clear session state.""" global emotion_history emotion_history = [] pipeline.reset_session() return ( [], # empty chat "
No emotions detected yet.
", # timeline "stable", # trajectory "
No crisis detected this session.
", # crisis panel ) # ── Gradio Interface ────────────────────────────────────────────────────────── with gr.Blocks(theme=gr.themes.Soft(), title="EmpathRAG Demo") as demo: gr.Markdown("# 🧠 EmpathRAG — Empathetic Student Support Assistant") gr.Markdown("Real-time emotion detection, crisis intervention, and empathetic response generation.") with gr.Row(): # Left column: Chat interface with gr.Column(scale=2): chatbot = gr.Chatbot( label="Conversation", height=450, bubble_full_width=False, ) msg_box = gr.Textbox( placeholder="How are you feeling today?", label="", autofocus=True, ) with gr.Row(): send_btn = gr.Button("Send", variant="primary") reset_btn = gr.Button("Reset Session") # Right column: Dashboard with gr.Column(scale=1): gr.Markdown("## Session Dashboard") gr.Markdown("### 📊 Emotion Timeline") timeline_out = gr.HTML( value="
No emotions detected yet.
" ) trajectory_out = gr.Textbox( label="Trajectory", value="stable", interactive=False, ) gr.Markdown("### 🛡️ Safety Guardrail") crisis_out = gr.HTML( value="
No crisis detected this session.
" ) # Wire up events msg_box.submit( respond, inputs=[msg_box, chatbot], outputs=[chatbot, timeline_out, trajectory_out, crisis_out], ).then( lambda: "", outputs=msg_box, ) send_btn.click( respond, inputs=[msg_box, chatbot], outputs=[chatbot, timeline_out, trajectory_out, crisis_out], ).then( lambda: "", outputs=msg_box, ) reset_btn.click( reset_session, outputs=[chatbot, timeline_out, trajectory_out, crisis_out], ) if __name__ == "__main__": demo.launch(share=False)