#!/usr/bin/env python3
"""Ghost Malone: MCP-powered emotional intelligence chatbot - Streamlit version"""
import json
import asyncio
import os
from dotenv import load_dotenv
import streamlit as st
import plotly.graph_objects as go
from utils.orchestrator import get_orchestrator
load_dotenv()
# Page config
st.set_page_config(
page_title="Ghost Malone",
page_icon="๐ป",
layout="wide"
)
# Clear memory on startup for fresh conversations
if os.path.exists("memory.json"):
os.remove("memory.json")
print("๐งน Cleared previous memory for fresh start")
@st.cache_resource
def get_event_loop():
"""Create and cache event loop."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
@st.cache_resource
def boot_orchestrator():
"""Bootstrap the orchestrator with all MCP servers."""
loop = get_event_loop()
orchestrator = loop.run_until_complete(get_orchestrator())
print("๐งฐ Ghost Malone orchestrator initialized")
return orchestrator
def run_async(coro):
"""Run async coroutine in the persistent event loop."""
loop = get_event_loop()
return loop.run_until_complete(coro)
def create_emotion_plot(emotion_arc):
"""Create a Plotly scatter plot showing emotions on valence/arousal grid."""
if not emotion_arc or not emotion_arc.get("trajectory"):
# Empty plot with quadrant labels
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=[0],
y=[0.5],
mode="text",
text=["No emotional data yet"],
textfont=dict(size=14, color="gray"),
showlegend=False,
)
)
fig.update_layout(
title="Emotional Trajectory (Russell's Circumplex)",
xaxis=dict(range=[-1, 1], title="Valence (negative โ โ positive)", zeroline=True),
yaxis=dict(range=[0, 1], title="Arousal (calm โ โ activated)", zeroline=True),
height=400,
template="plotly_white",
)
return fig
trajectory = emotion_arc["trajectory"]
x_vals = [p["valence"] for p in trajectory]
y_vals = [p["arousal"] for p in trajectory]
labels = [p["labels"] for p in trajectory]
fig = go.Figure()
# Plot trajectory with numbered markers
fig.add_trace(
go.Scatter(
x=x_vals,
y=y_vals,
mode="lines+markers+text",
marker=dict(size=12, color="purple", line=dict(width=2, color="white")),
line=dict(color="purple", width=2),
text=[str(i + 1) for i in range(len(x_vals))],
textposition="top center",
textfont=dict(size=10, color="white"),
hovertemplate="Message %{text}
Valence: %{x:.2f}
Arousal: %{y:.2f}
Emotions: %{customdata}",
customdata=labels,
showlegend=False,
)
)
# Add quadrant labels
quadrant_labels = [
dict(x=0.5, y=0.75, text="High Arousal
Positive", color="green"),
dict(x=-0.5, y=0.75, text="High Arousal
Negative", color="red"),
dict(x=0.5, y=0.25, text="Low Arousal
Positive", color="lightgreen"),
dict(x=-0.5, y=0.25, text="Low Arousal
Negative", color="pink"),
]
for label in quadrant_labels:
fig.add_annotation(
x=label["x"],
y=label["y"],
text=label["text"],
showarrow=False,
font=dict(size=11, color=label["color"]),
bgcolor="rgba(255,255,255,0.7)",
borderpad=4,
)
fig.update_layout(
title="Emotional Trajectory (Russell's Circumplex)",
xaxis=dict(range=[-1, 1], title="Valence (negative โ โ positive)", zeroline=True),
yaxis=dict(range=[0, 1], title="Arousal (calm โ โ activated)", zeroline=True),
height=400,
template="plotly_white",
)
return fig
async def process_message(user_message, min_confidence, min_arousal, min_depth):
"""Process user message through the MCP pipeline."""
orchestrator = boot_orchestrator()
# Run the emotional pipeline
result = await orchestrator.call_tool(
"run_emotional_pipeline",
{
"user_input": user_message,
"intervention_config": {
"min_confidence": min_confidence,
"min_arousal": min_arousal,
"min_depth": min_depth,
},
},
)
# Parse results
content = result.content[0].text if result.content else "{}"
data = json.loads(content)
return {
"response": data.get("response", "I'm processing your message..."),
"emotion": data.get("emotion", {}),
"need": data.get("need", {}),
"intervention": data.get("intervention"),
"emotion_arc": data.get("emotion_arc", {}),
}
# Initialize session state
if "messages" not in st.session_state:
st.session_state.messages = []
# Title
st.title("๐ป Ghost Malone")
st.markdown("*Emotion-aware MCP chatbot with constitutional alignment*")
# Sidebar controls
with st.sidebar:
st.header("โ๏ธ Intervention Settings")
st.markdown("Control when Ghost Malone offers interventions:")
min_confidence = st.slider(
"Confidence Threshold",
min_value=0.0,
max_value=1.0,
value=0.7,
step=0.05,
help="Minimum confidence in emotion detection"
)
min_arousal = st.slider(
"Arousal Threshold",
min_value=0.0,
max_value=1.0,
value=0.4,
step=0.05,
help="Minimum emotional arousal level"
)
min_depth = st.slider(
"Conversation Depth",
min_value=1,
max_value=5,
value=2,
step=1,
help="Minimum number of messages before interventions"
)
st.markdown("---")
if st.button("๐งน Clear History"):
st.session_state.messages = []
if os.path.exists("memory.json"):
os.remove("memory.json")
st.rerun()
# Main chat area
col1, col2 = st.columns([2, 1])
with col1:
st.subheader("๐ฌ Chat")
# Display chat history
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# Show emotion/need for user messages
if msg["role"] == "user" and "metadata" in msg:
meta = msg["metadata"]
if meta.get("emotion"):
st.caption(f"๐ญ {meta['emotion'].get('labels', '')} (valence: {meta['emotion'].get('valence', 0):.2f}, arousal: {meta['emotion'].get('arousal', 0):.2f})")
if meta.get("need"):
st.caption(f"๐ฏ Need: {meta['need'].get('primary_need', '')} ({meta['need'].get('confidence', 0):.2f})")
# Show intervention for assistant messages
if msg["role"] == "assistant" and "intervention" in msg:
if msg["intervention"]:
with st.expander("๐ก Intervention Offered"):
st.markdown(msg["intervention"])
# Chat input
if prompt := st.chat_input("How are you feeling?"):
# Add user message
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# Process message
with st.chat_message("assistant"):
with st.spinner("Thinking..."):
result = run_async(process_message(prompt, min_confidence, min_arousal, min_depth))
st.markdown(result["response"])
# Update user message metadata
st.session_state.messages[-1]["metadata"] = {
"emotion": result["emotion"],
"need": result["need"]
}
# Show metadata
if result.get("emotion"):
st.caption(f"๐ญ {result['emotion'].get('labels', '')} (valence: {result['emotion'].get('valence', 0):.2f}, arousal: {result['emotion'].get('arousal', 0):.2f})")
if result.get("need"):
st.caption(f"๐ฏ Need: {result['need'].get('primary_need', '')} ({result['need'].get('confidence', 0):.2f})")
# Show intervention if present
if result.get("intervention"):
with st.expander("๐ก Intervention Offered"):
st.markdown(result["intervention"])
# Add assistant message
st.session_state.messages.append({
"role": "assistant",
"content": result["response"],
"intervention": result.get("intervention")
})
st.rerun()
with col2:
st.subheader("๐ Emotional Trajectory")
# Get latest emotion arc from last message
if st.session_state.messages:
# Process current state to get emotion arc
try:
with st.spinner("Updating chart..."):
# Get emotion arc by making a dummy call
orchestrator = boot_orchestrator()
arc_result = run_async(orchestrator.call_tool("get_emotion_arc", {}))
arc_content = arc_result.content[0].text if arc_result.content else "{}"
emotion_arc = json.loads(arc_content)
fig = create_emotion_plot(emotion_arc)
st.plotly_chart(fig, use_container_width=True)
except Exception as e:
st.error(f"Could not load trajectory: {e}")
else:
fig = create_emotion_plot({})
st.plotly_chart(fig, use_container_width=True)
# Footer
st.markdown("---")
st.markdown("""
### ๐ฏ Try These Examples
**Connection needs:** "I feel so isolated and alone"
**Autonomy needs:** "I feel so trapped and powerless"
**Security needs:** "Everything feels so uncertain"
**Rest needs:** "I'm so burnt out and exhausted"
**Recognition needs:** "Nobody notices all the work I do"
๐ก *Interventions appear on the 2nd message when thresholds are met.*
""")