Commit ·
bd604e5
1
Parent(s): b5c659d
Fixing deployment issues:
Browse filesTransferring files from visualization directory to the root and modifying paths.
- app.py +299 -17
- data/UI_users/drumeo_users.csv +101 -0
- data/UI_users/guitareo_users.csv +101 -0
- data/UI_users/pianote_users.csv +101 -0
- data/UI_users/singeo_users.csv +101 -0
- data/configs/.gitkeep +0 -0
- data/configs/drumeo_re_engagement_test.json +193 -0
- data/configs/guitareo_re_engagement_test.json +116 -0
- data/configs/pianote_re_engagement_test.json +116 -0
- data/configs/singeo_re_engagement_test.json +116 -0
- data/experiments/.gitkeep +0 -0
- data/feedback/.gitkeep +0 -0
- data/users/.gitkeep +0 -0
- pages/1_Campaign_Builder.py +1034 -15
- pages/2_Message_Viewer.py +803 -15
- pages/4_Analytics.py +1061 -15
- pages/5_Historical_Analytics.py +510 -15
- utils/__init__.py +28 -0
- utils/auth.py +101 -0
- utils/config_manager.py +313 -0
- utils/data_loader.py +230 -0
- utils/feedback_manager.py +414 -0
- utils/theme.py +335 -0
- visualization/check_env.py +0 -85
app.py
CHANGED
|
@@ -1,26 +1,308 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
redirecting to the visualization app.
|
| 5 |
"""
|
| 6 |
|
|
|
|
| 7 |
import sys
|
| 8 |
from pathlib import Path
|
|
|
|
| 9 |
|
| 10 |
-
#
|
| 11 |
-
|
| 12 |
-
|
|
|
|
| 13 |
|
| 14 |
-
# Add
|
| 15 |
-
sys.path.insert(0, str(
|
| 16 |
-
sys.path.insert(0, str(root_dir / "ai_messaging_system_v2"))
|
| 17 |
-
sys.path.insert(0, str(visualization_dir))
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
| 21 |
|
| 22 |
-
#
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
AI Messaging System - Visualization Tool
|
| 3 |
+
Main entry point with authentication and brand selection.
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
import streamlit as st
|
| 7 |
import sys
|
| 8 |
from pathlib import Path
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
|
| 11 |
+
# Load environment variables from .env file FIRST
|
| 12 |
+
env_path = Path(__file__).parent / '.env'
|
| 13 |
+
if env_path.exists():
|
| 14 |
+
load_dotenv(env_path)
|
| 15 |
|
| 16 |
+
# Add root directory to path (for ai_messaging_system_v2 imports)
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
from utils.auth import verify_login, check_authentication, get_current_user, logout
|
| 20 |
+
from utils.theme import apply_theme, get_brand_emoji, get_brand_theme
|
| 21 |
+
from utils.data_loader import DataLoader
|
| 22 |
|
| 23 |
+
# Page configuration
|
| 24 |
+
st.set_page_config(
|
| 25 |
+
page_title="AI Messaging System - Visualization",
|
| 26 |
+
page_icon="🎵",
|
| 27 |
+
layout="wide",
|
| 28 |
+
initial_sidebar_state="expanded"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Initialize session state
|
| 32 |
+
def init_session_state():
|
| 33 |
+
"""Initialize session state variables."""
|
| 34 |
+
defaults = {
|
| 35 |
+
"authenticated": False,
|
| 36 |
+
"user_email": "",
|
| 37 |
+
"selected_brand": None,
|
| 38 |
+
"current_experiment_id": None,
|
| 39 |
+
}
|
| 40 |
+
for key, value in defaults.items():
|
| 41 |
+
if key not in st.session_state:
|
| 42 |
+
st.session_state[key] = value
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
init_session_state()
|
| 46 |
+
|
| 47 |
+
# Authentication check
|
| 48 |
+
if not check_authentication():
|
| 49 |
+
# Show login page
|
| 50 |
+
st.title("🔐 AI Messaging System - Login")
|
| 51 |
+
|
| 52 |
+
st.markdown("""
|
| 53 |
+
Welcome to the **AI Messaging System Visualization Tool**!
|
| 54 |
+
|
| 55 |
+
This tool enables you to:
|
| 56 |
+
- 🏗️ Build and configure message campaigns
|
| 57 |
+
- 👀 Visualize generated messages across all stages
|
| 58 |
+
- 📊 Analyze performance and provide feedback
|
| 59 |
+
- 🧪 Run A/B tests to compare different approaches
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
**Access is restricted to authorized team members only.**
|
| 64 |
+
Please enter your credentials below.
|
| 65 |
+
""")
|
| 66 |
+
|
| 67 |
+
with st.form("login_form"):
|
| 68 |
+
email = st.text_input("📧 Email Address", placeholder="your.name@musora.com")
|
| 69 |
+
token = st.text_input("🔑 Access Token", type="password", placeholder="Enter your access token")
|
| 70 |
+
submit = st.form_submit_button("🚀 Login", use_container_width=True)
|
| 71 |
+
|
| 72 |
+
if submit:
|
| 73 |
+
if verify_login(email, token):
|
| 74 |
+
st.session_state.authenticated = True
|
| 75 |
+
st.session_state.user_email = email
|
| 76 |
+
st.success("✅ Login successful! Redirecting...")
|
| 77 |
+
st.rerun()
|
| 78 |
+
else:
|
| 79 |
+
st.error("❌ Invalid email or access token. Please try again.")
|
| 80 |
+
|
| 81 |
+
st.stop()
|
| 82 |
+
|
| 83 |
+
# User is authenticated - show main app
|
| 84 |
+
# Apply base theme initially
|
| 85 |
+
apply_theme("base")
|
| 86 |
+
|
| 87 |
+
# Sidebar - Brand Selection
|
| 88 |
+
with st.sidebar:
|
| 89 |
+
st.title("🎵 AI Messaging System")
|
| 90 |
+
|
| 91 |
+
st.markdown(f"**Logged in as:** {get_current_user()}")
|
| 92 |
+
|
| 93 |
+
if st.button("🚪 Logout", use_container_width=True):
|
| 94 |
+
logout()
|
| 95 |
+
st.rerun()
|
| 96 |
+
|
| 97 |
+
st.markdown("---")
|
| 98 |
+
|
| 99 |
+
# Brand Selection
|
| 100 |
+
st.subheader("🎨 Select Brand")
|
| 101 |
+
|
| 102 |
+
brands = ["drumeo", "pianote", "guitareo", "singeo"]
|
| 103 |
+
brand_labels = {
|
| 104 |
+
"drumeo": "🥁 Drumeo",
|
| 105 |
+
"pianote": "🎹 Pianote",
|
| 106 |
+
"guitareo": "🎸 Guitareo",
|
| 107 |
+
"singeo": "🎤 Singeo"
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
# Get current brand from session state if exists
|
| 111 |
+
current_brand = st.session_state.get("selected_brand", brands[0])
|
| 112 |
+
|
| 113 |
+
selected_brand = st.selectbox(
|
| 114 |
+
"Brand",
|
| 115 |
+
brands,
|
| 116 |
+
index=brands.index(current_brand) if current_brand in brands else 0,
|
| 117 |
+
format_func=lambda x: brand_labels[x],
|
| 118 |
+
key="brand_selector",
|
| 119 |
+
label_visibility="collapsed"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Update session state with selected brand
|
| 123 |
+
st.session_state.selected_brand = selected_brand
|
| 124 |
+
|
| 125 |
+
# Apply brand theme
|
| 126 |
+
if selected_brand:
|
| 127 |
+
apply_theme(selected_brand)
|
| 128 |
+
|
| 129 |
+
st.markdown("---")
|
| 130 |
+
|
| 131 |
+
# Navigation info
|
| 132 |
+
st.subheader("📖 Navigation")
|
| 133 |
+
st.markdown("""
|
| 134 |
+
Use the pages in the sidebar to navigate:
|
| 135 |
+
- **Campaign Builder**: Create campaigns & A/B tests
|
| 136 |
+
- **Message Viewer**: Review messages
|
| 137 |
+
- **Analytics**: View current performance
|
| 138 |
+
- **Historical Analytics**: Track improvements
|
| 139 |
+
""")
|
| 140 |
+
|
| 141 |
+
# Main Content Area
|
| 142 |
+
if not selected_brand:
|
| 143 |
+
st.title("🎵 Welcome to AI Messaging System")
|
| 144 |
+
|
| 145 |
+
st.markdown("""
|
| 146 |
+
### Get Started
|
| 147 |
+
|
| 148 |
+
**Please select a brand from the sidebar to begin.**
|
| 149 |
+
|
| 150 |
+
Once you've selected a brand, you can:
|
| 151 |
+
|
| 152 |
+
1. **Build Campaigns** - Configure and generate personalized messages
|
| 153 |
+
2. **View Messages** - Browse and provide feedback on generated messages
|
| 154 |
+
3. **Run A/B Tests** - Compare different configurations side-by-side
|
| 155 |
+
4. **Analyze Results** - Review performance metrics and insights
|
| 156 |
+
""")
|
| 157 |
+
|
| 158 |
+
# Show feature cards
|
| 159 |
+
col1, col2 = st.columns(2)
|
| 160 |
+
|
| 161 |
+
with col1:
|
| 162 |
+
st.markdown("""
|
| 163 |
+
#### 🏗️ Campaign Builder
|
| 164 |
+
- Configure multi-stage campaigns
|
| 165 |
+
- Built-in A/B testing mode
|
| 166 |
+
- Parallel experiment processing
|
| 167 |
+
- Automatic experiment archiving
|
| 168 |
+
- Save custom configurations
|
| 169 |
+
""")
|
| 170 |
+
|
| 171 |
+
st.markdown("""
|
| 172 |
+
#### 👀 Message Viewer
|
| 173 |
+
- View all generated messages
|
| 174 |
+
- Side-by-side A/B comparison
|
| 175 |
+
- User-centric or stage-centric views
|
| 176 |
+
- Search and filter messages
|
| 177 |
+
- Provide detailed feedback
|
| 178 |
+
""")
|
| 179 |
+
|
| 180 |
+
with col2:
|
| 181 |
+
st.markdown("""
|
| 182 |
+
#### 📊 Analytics Dashboard
|
| 183 |
+
- Real-time performance metrics
|
| 184 |
+
- A/B test winner determination
|
| 185 |
+
- Rejection reason analysis
|
| 186 |
+
- Stage-by-stage performance
|
| 187 |
+
- Export capabilities
|
| 188 |
+
""")
|
| 189 |
+
|
| 190 |
+
st.markdown("""
|
| 191 |
+
#### 📚 Historical Analytics
|
| 192 |
+
- Track all past experiments
|
| 193 |
+
- Rejection rate trends over time
|
| 194 |
+
- Compare historical A/B tests
|
| 195 |
+
- Export historical data
|
| 196 |
+
""")
|
| 197 |
+
|
| 198 |
+
else:
|
| 199 |
+
# Brand is selected - show brand-specific homepage
|
| 200 |
+
emoji = get_brand_emoji(selected_brand)
|
| 201 |
+
theme = get_brand_theme(selected_brand)
|
| 202 |
+
|
| 203 |
+
st.title(f"{emoji} {selected_brand.title()} - AI Messaging System")
|
| 204 |
+
|
| 205 |
+
st.markdown(f"""
|
| 206 |
+
### Welcome to {selected_brand.title()} Message Generation!
|
| 207 |
+
|
| 208 |
+
You're all set to create personalized messages for {selected_brand.title()} users.
|
| 209 |
+
""")
|
| 210 |
+
|
| 211 |
+
# Quick stats
|
| 212 |
+
data_loader = DataLoader()
|
| 213 |
+
|
| 214 |
+
# Check if we have brand users
|
| 215 |
+
has_users = data_loader.has_brand_users(selected_brand)
|
| 216 |
+
|
| 217 |
+
# Check if we have generated messages
|
| 218 |
+
messages_df = data_loader.load_generated_messages()
|
| 219 |
+
has_messages = messages_df is not None and len(messages_df) > 0
|
| 220 |
+
|
| 221 |
+
st.markdown("---")
|
| 222 |
+
|
| 223 |
+
# Status cards
|
| 224 |
+
col1, col2, col3 = st.columns(3)
|
| 225 |
+
|
| 226 |
+
with col1:
|
| 227 |
+
st.markdown("### 👥 Available Users")
|
| 228 |
+
if has_users:
|
| 229 |
+
user_count = data_loader.get_brand_user_count(selected_brand)
|
| 230 |
+
st.metric("Users Available", user_count)
|
| 231 |
+
st.success(f"✅ {user_count} users ready")
|
| 232 |
+
else:
|
| 233 |
+
st.metric("Users Available", 0)
|
| 234 |
+
st.info("ℹ️ No users available")
|
| 235 |
+
|
| 236 |
+
with col2:
|
| 237 |
+
st.markdown("### 📨 Generated Messages")
|
| 238 |
+
if has_messages:
|
| 239 |
+
stats = data_loader.get_message_stats()
|
| 240 |
+
st.metric("Total Messages", stats['total_messages'])
|
| 241 |
+
st.success(f"✅ {stats['total_stages']} stages")
|
| 242 |
+
else:
|
| 243 |
+
st.metric("Total Messages", 0)
|
| 244 |
+
st.info("ℹ️ No messages generated yet")
|
| 245 |
+
|
| 246 |
+
with col3:
|
| 247 |
+
st.markdown("### 🎯 Quick Actions")
|
| 248 |
+
st.markdown("") # spacing
|
| 249 |
+
if st.button("🏗️ Build Campaign", use_container_width=True):
|
| 250 |
+
st.switch_page("pages/1_Campaign_Builder.py")
|
| 251 |
+
if has_messages:
|
| 252 |
+
if st.button("👀 View Messages", use_container_width=True):
|
| 253 |
+
st.switch_page("pages/2_Message_Viewer.py")
|
| 254 |
+
|
| 255 |
+
st.markdown("---")
|
| 256 |
+
|
| 257 |
+
# Getting started guide
|
| 258 |
+
st.markdown("### 🚀 Quick Start Guide")
|
| 259 |
+
|
| 260 |
+
st.markdown("""
|
| 261 |
+
#### Step 1: Build a Campaign
|
| 262 |
+
Navigate to **Campaign Builder** to:
|
| 263 |
+
- Toggle A/B testing mode on/off as needed
|
| 264 |
+
- Select number of users to experiment with (1-25)
|
| 265 |
+
- Configure campaign stages (or two experiments side-by-side)
|
| 266 |
+
- Set LLM models and instructions
|
| 267 |
+
- Generate personalized messages with automatic archiving
|
| 268 |
+
|
| 269 |
+
#### Step 2: Review Messages
|
| 270 |
+
Go to **Message Viewer** to:
|
| 271 |
+
- Browse all generated messages
|
| 272 |
+
- View A/B tests side-by-side automatically
|
| 273 |
+
- Switch between user-centric and stage-centric views
|
| 274 |
+
- Provide detailed feedback with rejection reasons
|
| 275 |
+
- Track message headers and content
|
| 276 |
+
|
| 277 |
+
#### Step 3: Analyze Performance
|
| 278 |
+
Check **Analytics Dashboard** for:
|
| 279 |
+
- Real-time approval and rejection rates
|
| 280 |
+
- A/B test comparisons with winner determination
|
| 281 |
+
- Common rejection reasons with visual charts
|
| 282 |
+
- Stage-by-stage performance insights
|
| 283 |
+
- Export analytics data
|
| 284 |
+
|
| 285 |
+
#### Step 4: Track Improvements
|
| 286 |
+
View **Historical Analytics** to:
|
| 287 |
+
- See all past experiments over time
|
| 288 |
+
- Identify rejection rate trends
|
| 289 |
+
- Compare historical A/B tests
|
| 290 |
+
- Export comprehensive historical data
|
| 291 |
+
""")
|
| 292 |
+
|
| 293 |
+
st.markdown("---")
|
| 294 |
+
|
| 295 |
+
# Tips
|
| 296 |
+
with st.expander("💡 Tips & Best Practices"):
|
| 297 |
+
st.markdown("""
|
| 298 |
+
- **User Selection**: Select 1-25 users for quick experimentation
|
| 299 |
+
- **Configuration Templates**: Start with default configurations and customize as needed
|
| 300 |
+
- **A/B Testing**: Enable A/B mode in Campaign Builder to compare two configurations in parallel
|
| 301 |
+
- **Feedback**: Reject poor messages with specific categories including the new 'Similar To Previous' option
|
| 302 |
+
- **Historical Tracking**: Check Historical Analytics regularly to track improvements over time
|
| 303 |
+
- **Stage Design**: Each stage should build upon previous stages with varied messaging approaches
|
| 304 |
+
- **Automatic Archiving**: Previous experiments are automatically archived when you start a new one
|
| 305 |
+
""")
|
| 306 |
+
|
| 307 |
+
st.markdown("---")
|
| 308 |
+
st.markdown("**Built with ❤️ for the Musora team**")
|
data/UI_users/drumeo_users.csv
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
USER_ID
|
| 2 |
+
668594
|
| 3 |
+
268920
|
| 4 |
+
223559
|
| 5 |
+
830729
|
| 6 |
+
356828
|
| 7 |
+
703749
|
| 8 |
+
840761
|
| 9 |
+
773120
|
| 10 |
+
847327
|
| 11 |
+
566765
|
| 12 |
+
843750
|
| 13 |
+
825806
|
| 14 |
+
551310
|
| 15 |
+
924846
|
| 16 |
+
655113
|
| 17 |
+
576751
|
| 18 |
+
334867
|
| 19 |
+
691463
|
| 20 |
+
680767
|
| 21 |
+
812160
|
| 22 |
+
164216
|
| 23 |
+
881978
|
| 24 |
+
539894
|
| 25 |
+
566222
|
| 26 |
+
660036
|
| 27 |
+
531101
|
| 28 |
+
591381
|
| 29 |
+
876151
|
| 30 |
+
596577
|
| 31 |
+
910512
|
| 32 |
+
395956
|
| 33 |
+
173257
|
| 34 |
+
792254
|
| 35 |
+
911568
|
| 36 |
+
914399
|
| 37 |
+
903638
|
| 38 |
+
736051
|
| 39 |
+
675660
|
| 40 |
+
892433
|
| 41 |
+
346547
|
| 42 |
+
824636
|
| 43 |
+
774712
|
| 44 |
+
488145
|
| 45 |
+
688315
|
| 46 |
+
546326
|
| 47 |
+
610623
|
| 48 |
+
560107
|
| 49 |
+
356255
|
| 50 |
+
757824
|
| 51 |
+
826319
|
| 52 |
+
272269
|
| 53 |
+
919799
|
| 54 |
+
670740
|
| 55 |
+
919348
|
| 56 |
+
324493
|
| 57 |
+
284107
|
| 58 |
+
772112
|
| 59 |
+
821339
|
| 60 |
+
433101
|
| 61 |
+
705894
|
| 62 |
+
775898
|
| 63 |
+
922718
|
| 64 |
+
627434
|
| 65 |
+
811134
|
| 66 |
+
922432
|
| 67 |
+
922696
|
| 68 |
+
934924
|
| 69 |
+
158386
|
| 70 |
+
488116
|
| 71 |
+
167704
|
| 72 |
+
721421
|
| 73 |
+
785019
|
| 74 |
+
831724
|
| 75 |
+
928899
|
| 76 |
+
911985
|
| 77 |
+
911041
|
| 78 |
+
552685
|
| 79 |
+
391892
|
| 80 |
+
265614
|
| 81 |
+
916134
|
| 82 |
+
402573
|
| 83 |
+
900149
|
| 84 |
+
717562
|
| 85 |
+
161665
|
| 86 |
+
389376
|
| 87 |
+
509974
|
| 88 |
+
913237
|
| 89 |
+
760712
|
| 90 |
+
415475
|
| 91 |
+
346652
|
| 92 |
+
604378
|
| 93 |
+
817870
|
| 94 |
+
698502
|
| 95 |
+
904044
|
| 96 |
+
931916
|
| 97 |
+
662869
|
| 98 |
+
837947
|
| 99 |
+
666152
|
| 100 |
+
367181
|
| 101 |
+
510852
|
data/UI_users/guitareo_users.csv
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
USER_ID
|
| 2 |
+
157715
|
| 3 |
+
911553
|
| 4 |
+
156907
|
| 5 |
+
694766
|
| 6 |
+
914407
|
| 7 |
+
158017
|
| 8 |
+
157707
|
| 9 |
+
931216
|
| 10 |
+
157275
|
| 11 |
+
157049
|
| 12 |
+
762379
|
| 13 |
+
922989
|
| 14 |
+
894939
|
| 15 |
+
156233
|
| 16 |
+
445299
|
| 17 |
+
893526
|
| 18 |
+
502852
|
| 19 |
+
412696
|
| 20 |
+
524379
|
| 21 |
+
934803
|
| 22 |
+
533843
|
| 23 |
+
918486
|
| 24 |
+
326346
|
| 25 |
+
825138
|
| 26 |
+
510105
|
| 27 |
+
836199
|
| 28 |
+
903763
|
| 29 |
+
847337
|
| 30 |
+
920186
|
| 31 |
+
367515
|
| 32 |
+
464147
|
| 33 |
+
876953
|
| 34 |
+
820530
|
| 35 |
+
483932
|
| 36 |
+
804270
|
| 37 |
+
652888
|
| 38 |
+
611387
|
| 39 |
+
901114
|
| 40 |
+
161990
|
| 41 |
+
858049
|
| 42 |
+
504479
|
| 43 |
+
793688
|
| 44 |
+
596030
|
| 45 |
+
856499
|
| 46 |
+
830288
|
| 47 |
+
905807
|
| 48 |
+
737373
|
| 49 |
+
801123
|
| 50 |
+
936207
|
| 51 |
+
161806
|
| 52 |
+
844885
|
| 53 |
+
156673
|
| 54 |
+
156417
|
| 55 |
+
166828
|
| 56 |
+
157214
|
| 57 |
+
163682
|
| 58 |
+
157637
|
| 59 |
+
815647
|
| 60 |
+
445428
|
| 61 |
+
848726
|
| 62 |
+
345886
|
| 63 |
+
645329
|
| 64 |
+
908931
|
| 65 |
+
157569
|
| 66 |
+
900579
|
| 67 |
+
161677
|
| 68 |
+
898048
|
| 69 |
+
429111
|
| 70 |
+
902004
|
| 71 |
+
161816
|
| 72 |
+
676947
|
| 73 |
+
160755
|
| 74 |
+
921572
|
| 75 |
+
453858
|
| 76 |
+
548274
|
| 77 |
+
922381
|
| 78 |
+
825297
|
| 79 |
+
720840
|
| 80 |
+
449753
|
| 81 |
+
504742
|
| 82 |
+
157789
|
| 83 |
+
162667
|
| 84 |
+
920329
|
| 85 |
+
157258
|
| 86 |
+
739976
|
| 87 |
+
641480
|
| 88 |
+
157176
|
| 89 |
+
565814
|
| 90 |
+
870217
|
| 91 |
+
574774
|
| 92 |
+
420668
|
| 93 |
+
344602
|
| 94 |
+
918695
|
| 95 |
+
174797
|
| 96 |
+
270977
|
| 97 |
+
903413
|
| 98 |
+
157324
|
| 99 |
+
158234
|
| 100 |
+
156592
|
| 101 |
+
491409
|
data/UI_users/pianote_users.csv
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
USER_ID
|
| 2 |
+
901462
|
| 3 |
+
663534
|
| 4 |
+
925578
|
| 5 |
+
808019
|
| 6 |
+
727360
|
| 7 |
+
907785
|
| 8 |
+
903125
|
| 9 |
+
358505
|
| 10 |
+
903293
|
| 11 |
+
926435
|
| 12 |
+
577983
|
| 13 |
+
640044
|
| 14 |
+
871545
|
| 15 |
+
689297
|
| 16 |
+
839797
|
| 17 |
+
838978
|
| 18 |
+
894404
|
| 19 |
+
388259
|
| 20 |
+
742545
|
| 21 |
+
875618
|
| 22 |
+
906682
|
| 23 |
+
156360
|
| 24 |
+
615274
|
| 25 |
+
496647
|
| 26 |
+
837649
|
| 27 |
+
819147
|
| 28 |
+
762360
|
| 29 |
+
667066
|
| 30 |
+
922040
|
| 31 |
+
165396
|
| 32 |
+
491498
|
| 33 |
+
643639
|
| 34 |
+
388015
|
| 35 |
+
876565
|
| 36 |
+
391879
|
| 37 |
+
451960
|
| 38 |
+
508244
|
| 39 |
+
426511
|
| 40 |
+
401073
|
| 41 |
+
869259
|
| 42 |
+
522076
|
| 43 |
+
631399
|
| 44 |
+
428081
|
| 45 |
+
867206
|
| 46 |
+
815870
|
| 47 |
+
458726
|
| 48 |
+
547732
|
| 49 |
+
554044
|
| 50 |
+
364525
|
| 51 |
+
825266
|
| 52 |
+
844418
|
| 53 |
+
818026
|
| 54 |
+
751982
|
| 55 |
+
356018
|
| 56 |
+
345635
|
| 57 |
+
427868
|
| 58 |
+
586083
|
| 59 |
+
166330
|
| 60 |
+
825075
|
| 61 |
+
343531
|
| 62 |
+
843298
|
| 63 |
+
558798
|
| 64 |
+
352221
|
| 65 |
+
558176
|
| 66 |
+
813053
|
| 67 |
+
386914
|
| 68 |
+
154237
|
| 69 |
+
362024
|
| 70 |
+
855589
|
| 71 |
+
807289
|
| 72 |
+
307320
|
| 73 |
+
623466
|
| 74 |
+
825321
|
| 75 |
+
605229
|
| 76 |
+
348870
|
| 77 |
+
388261
|
| 78 |
+
519602
|
| 79 |
+
153096
|
| 80 |
+
586282
|
| 81 |
+
716526
|
| 82 |
+
401891
|
| 83 |
+
874652
|
| 84 |
+
834192
|
| 85 |
+
732481
|
| 86 |
+
508825
|
| 87 |
+
859490
|
| 88 |
+
519051
|
| 89 |
+
482569
|
| 90 |
+
518170
|
| 91 |
+
541203
|
| 92 |
+
421310
|
| 93 |
+
550180
|
| 94 |
+
406176
|
| 95 |
+
520979
|
| 96 |
+
593825
|
| 97 |
+
549999
|
| 98 |
+
483788
|
| 99 |
+
841717
|
| 100 |
+
703563
|
| 101 |
+
364690
|
data/UI_users/singeo_users.csv
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
USER_ID
|
| 2 |
+
397959
|
| 3 |
+
807871
|
| 4 |
+
532351
|
| 5 |
+
344867
|
| 6 |
+
491564
|
| 7 |
+
687686
|
| 8 |
+
912997
|
| 9 |
+
757896
|
| 10 |
+
475186
|
| 11 |
+
559116
|
| 12 |
+
476436
|
| 13 |
+
430839
|
| 14 |
+
929828
|
| 15 |
+
546602
|
| 16 |
+
660403
|
| 17 |
+
809863
|
| 18 |
+
930638
|
| 19 |
+
308771
|
| 20 |
+
156181
|
| 21 |
+
832580
|
| 22 |
+
642142
|
| 23 |
+
484262
|
| 24 |
+
672837
|
| 25 |
+
723502
|
| 26 |
+
735251
|
| 27 |
+
440691
|
| 28 |
+
676900
|
| 29 |
+
891419
|
| 30 |
+
912102
|
| 31 |
+
605067
|
| 32 |
+
727808
|
| 33 |
+
449959
|
| 34 |
+
295615
|
| 35 |
+
934452
|
| 36 |
+
932834
|
| 37 |
+
631715
|
| 38 |
+
933185
|
| 39 |
+
247877
|
| 40 |
+
550753
|
| 41 |
+
841927
|
| 42 |
+
795745
|
| 43 |
+
555274
|
| 44 |
+
679834
|
| 45 |
+
675272
|
| 46 |
+
911688
|
| 47 |
+
689022
|
| 48 |
+
414410
|
| 49 |
+
502875
|
| 50 |
+
649091
|
| 51 |
+
540841
|
| 52 |
+
616322
|
| 53 |
+
761625
|
| 54 |
+
534677
|
| 55 |
+
830290
|
| 56 |
+
870293
|
| 57 |
+
510636
|
| 58 |
+
932700
|
| 59 |
+
533364
|
| 60 |
+
540891
|
| 61 |
+
584369
|
| 62 |
+
739290
|
| 63 |
+
914907
|
| 64 |
+
934815
|
| 65 |
+
760925
|
| 66 |
+
411509
|
| 67 |
+
420037
|
| 68 |
+
566575
|
| 69 |
+
667578
|
| 70 |
+
483362
|
| 71 |
+
787773
|
| 72 |
+
164176
|
| 73 |
+
876127
|
| 74 |
+
344023
|
| 75 |
+
634693
|
| 76 |
+
365543
|
| 77 |
+
160559
|
| 78 |
+
474363
|
| 79 |
+
675851
|
| 80 |
+
934109
|
| 81 |
+
563890
|
| 82 |
+
875101
|
| 83 |
+
768715
|
| 84 |
+
405549
|
| 85 |
+
509855
|
| 86 |
+
615509
|
| 87 |
+
800896
|
| 88 |
+
901230
|
| 89 |
+
444234
|
| 90 |
+
152323
|
| 91 |
+
610485
|
| 92 |
+
628039
|
| 93 |
+
854271
|
| 94 |
+
503894
|
| 95 |
+
339113
|
| 96 |
+
441986
|
| 97 |
+
721231
|
| 98 |
+
486789
|
| 99 |
+
807279
|
| 100 |
+
749298
|
| 101 |
+
923577
|
data/configs/.gitkeep
ADDED
|
File without changes
|
data/configs/drumeo_re_engagement_test.json
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"brand": "drumeo",
|
| 3 |
+
"campaign_type": "re_engagement",
|
| 4 |
+
"campaign_name": "UI-Test-Campaign-Re-engagement",
|
| 5 |
+
"campaign_instructions": "Keep messages encouraging and motivational. Focus on getting users excited about practicing.",
|
| 6 |
+
"1": {
|
| 7 |
+
"stage": 1,
|
| 8 |
+
"model": "gpt-5-nano",
|
| 9 |
+
"personalization": true,
|
| 10 |
+
"involve_recsys_result": true,
|
| 11 |
+
"recsys_contents": [
|
| 12 |
+
"workout",
|
| 13 |
+
"course",
|
| 14 |
+
"quick_tips"
|
| 15 |
+
],
|
| 16 |
+
"specific_content_id": null,
|
| 17 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 18 |
+
"instructions": "",
|
| 19 |
+
"sample_examples": "Header: Your next lesson is waiting 👇 \n Message: Check it out now and improve your playing!",
|
| 20 |
+
"identifier_column": "user_id",
|
| 21 |
+
"platform": "push"
|
| 22 |
+
},
|
| 23 |
+
"2": {
|
| 24 |
+
"stage": 2,
|
| 25 |
+
"model": "gemini-2.5-flash-lite",
|
| 26 |
+
"personalization": true,
|
| 27 |
+
"involve_recsys_result": true,
|
| 28 |
+
"recsys_contents": [
|
| 29 |
+
"workout",
|
| 30 |
+
"course",
|
| 31 |
+
"quick_tips"
|
| 32 |
+
],
|
| 33 |
+
"specific_content_id": null,
|
| 34 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 35 |
+
"instructions": "",
|
| 36 |
+
"sample_examples": "Header: It's a great day to play 🤩,\n Message: It's been a few days — warm up with a quick lesson!",
|
| 37 |
+
"identifier_column": "user_id",
|
| 38 |
+
"platform": "push"
|
| 39 |
+
},
|
| 40 |
+
"3": {
|
| 41 |
+
"stage": 3,
|
| 42 |
+
"model": "gemini-2.5-flash-lite",
|
| 43 |
+
"personalization": true,
|
| 44 |
+
"involve_recsys_result": true,
|
| 45 |
+
"recsys_contents": [
|
| 46 |
+
"workout",
|
| 47 |
+
"course",
|
| 48 |
+
"quick_tips"
|
| 49 |
+
],
|
| 50 |
+
"specific_content_id": null,
|
| 51 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 52 |
+
"instructions": "",
|
| 53 |
+
"sample_examples": "Header: Practice makes progress 💪, \nMessage: You don't need to be perfect. But you do need practice to reach your goals!",
|
| 54 |
+
"identifier_column": "user_id",
|
| 55 |
+
"platform": "push"
|
| 56 |
+
},
|
| 57 |
+
"4": {
|
| 58 |
+
"stage": 4,
|
| 59 |
+
"model": "gemini-2.5-flash-lite",
|
| 60 |
+
"personalization": true,
|
| 61 |
+
"involve_recsys_result": true,
|
| 62 |
+
"recsys_contents": [
|
| 63 |
+
"workout",
|
| 64 |
+
"course",
|
| 65 |
+
"quick_tips"
|
| 66 |
+
],
|
| 67 |
+
"specific_content_id": null,
|
| 68 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 69 |
+
"instructions": "",
|
| 70 |
+
"sample_examples": "Header: Never stop learning, \nMessage: Take a lesson today and get back on track!",
|
| 71 |
+
"identifier_column": "user_id",
|
| 72 |
+
"platform": "push"
|
| 73 |
+
},
|
| 74 |
+
"5": {
|
| 75 |
+
"stage": 5,
|
| 76 |
+
"model": "gemini-2.5-flash-lite",
|
| 77 |
+
"personalization": true,
|
| 78 |
+
"involve_recsys_result": true,
|
| 79 |
+
"recsys_contents": [
|
| 80 |
+
"workout",
|
| 81 |
+
"course",
|
| 82 |
+
"quick_tips"
|
| 83 |
+
],
|
| 84 |
+
"specific_content_id": null,
|
| 85 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 86 |
+
"instructions": "",
|
| 87 |
+
"sample_examples": "Header: Get back on track ⏱️\nMessage: It's been two weeks since your last practice session. Take a lesson today!",
|
| 88 |
+
"identifier_column": "user_id",
|
| 89 |
+
"platform": "push"
|
| 90 |
+
},
|
| 91 |
+
"6": {
|
| 92 |
+
"stage": 6,
|
| 93 |
+
"model": "gemini-2.5-flash-lite",
|
| 94 |
+
"personalization": true,
|
| 95 |
+
"involve_recsys_result": true,
|
| 96 |
+
"recsys_contents": [
|
| 97 |
+
"workout",
|
| 98 |
+
"course",
|
| 99 |
+
"quick_tips"
|
| 100 |
+
],
|
| 101 |
+
"specific_content_id": null,
|
| 102 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 103 |
+
"instructions": "",
|
| 104 |
+
"sample_examples": "Header: Keep on going!\nMessage: Get back to playing today. It only takes a few minutes!",
|
| 105 |
+
"identifier_column": "user_id",
|
| 106 |
+
"platform": "push"
|
| 107 |
+
},
|
| 108 |
+
"7": {
|
| 109 |
+
"stage": 7,
|
| 110 |
+
"model": "gemini-2.5-flash-lite",
|
| 111 |
+
"personalization": true,
|
| 112 |
+
"involve_recsys_result": true,
|
| 113 |
+
"recsys_contents": [
|
| 114 |
+
"workout",
|
| 115 |
+
"course",
|
| 116 |
+
"quick_tips"
|
| 117 |
+
],
|
| 118 |
+
"specific_content_id": null,
|
| 119 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 120 |
+
"instructions": "",
|
| 121 |
+
"sample_examples": "Header: Ready to play? 🎸\nMessage: Let's get started. Time for a quick practice session!",
|
| 122 |
+
"identifier_column": "user_id",
|
| 123 |
+
"platform": "push"
|
| 124 |
+
},
|
| 125 |
+
"8": {
|
| 126 |
+
"stage": 8,
|
| 127 |
+
"model": "gemini-2.5-flash-lite",
|
| 128 |
+
"personalization": true,
|
| 129 |
+
"involve_recsys_result": true,
|
| 130 |
+
"recsys_contents": [
|
| 131 |
+
"workout",
|
| 132 |
+
"course",
|
| 133 |
+
"quick_tips"
|
| 134 |
+
],
|
| 135 |
+
"specific_content_id": null,
|
| 136 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 137 |
+
"instructions": "",
|
| 138 |
+
"sample_examples": "Header: Your lesson's waiting. 📥\nMessage: We want to hear you play! Dive in today.",
|
| 139 |
+
"identifier_column": "user_id",
|
| 140 |
+
"platform": "push"
|
| 141 |
+
},
|
| 142 |
+
"9": {
|
| 143 |
+
"stage": 9,
|
| 144 |
+
"model": "gemini-2.5-flash-lite",
|
| 145 |
+
"personalization": true,
|
| 146 |
+
"involve_recsys_result": true,
|
| 147 |
+
"recsys_contents": [
|
| 148 |
+
"workout",
|
| 149 |
+
"course",
|
| 150 |
+
"quick_tips"
|
| 151 |
+
],
|
| 152 |
+
"specific_content_id": null,
|
| 153 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 154 |
+
"instructions": "",
|
| 155 |
+
"sample_examples": "Header: Time for a comeback!\nMessage: We haven't seen you in 25 days. This will help get you back into the groove!",
|
| 156 |
+
"identifier_column": "user_id",
|
| 157 |
+
"platform": "push"
|
| 158 |
+
},
|
| 159 |
+
"10": {
|
| 160 |
+
"stage": 10,
|
| 161 |
+
"model": "gemini-2.5-flash-lite",
|
| 162 |
+
"personalization": true,
|
| 163 |
+
"involve_recsys_result": true,
|
| 164 |
+
"recsys_contents": [
|
| 165 |
+
"workout",
|
| 166 |
+
"course",
|
| 167 |
+
"quick_tips"
|
| 168 |
+
],
|
| 169 |
+
"specific_content_id": null,
|
| 170 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 171 |
+
"instructions": "",
|
| 172 |
+
"sample_examples": "Header: Have you been practicing?\nMessage: You're very talented. We'd love to hear you play again!",
|
| 173 |
+
"identifier_column": "user_id",
|
| 174 |
+
"platform": "push"
|
| 175 |
+
},
|
| 176 |
+
"11": {
|
| 177 |
+
"stage": 11,
|
| 178 |
+
"model": "gemini-2.5-flash-lite",
|
| 179 |
+
"personalization": true,
|
| 180 |
+
"involve_recsys_result": true,
|
| 181 |
+
"recsys_contents": [
|
| 182 |
+
"workout",
|
| 183 |
+
"course",
|
| 184 |
+
"quick_tips"
|
| 185 |
+
],
|
| 186 |
+
"specific_content_id": null,
|
| 187 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 188 |
+
"instructions": "",
|
| 189 |
+
"sample_examples": "Header: We Miss You 😔Message: All your lessons will just be here when you get back!",
|
| 190 |
+
"identifier_column": "user_id",
|
| 191 |
+
"platform": "push"
|
| 192 |
+
}
|
| 193 |
+
}
|
data/configs/guitareo_re_engagement_test.json
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"campaign_name": "UI-Test-Campaign-Re-engagement",
|
| 3 |
+
"brand": "guitareo",
|
| 4 |
+
"campaign_instructions": "Keep messages encouraging and motivational. Focus on getting users excited about practicing.",
|
| 5 |
+
|
| 6 |
+
"1": {
|
| 7 |
+
"identifier_column": "user_id",
|
| 8 |
+
"stage": 1,
|
| 9 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 10 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 11 |
+
"involve_recsys_result": true,
|
| 12 |
+
"personalization": true,
|
| 13 |
+
"sample_examples": "Header: Your next lesson is waiting 👇 \n Message: Check it out now and improve your playing!",
|
| 14 |
+
"model": "gemini-2.5-flash-lite"
|
| 15 |
+
},
|
| 16 |
+
"2": {
|
| 17 |
+
"identifier_column": "user_id",
|
| 18 |
+
"stage": 2,
|
| 19 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 20 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 21 |
+
"involve_recsys_result": true,
|
| 22 |
+
"personalization": true,
|
| 23 |
+
"sample_examples": "Header: It's a great day to play 🤩,\n Message: It's been a few days — warm up with a quick lesson!",
|
| 24 |
+
"model": "gemini-2.5-flash-lite"
|
| 25 |
+
},
|
| 26 |
+
"3": {
|
| 27 |
+
"identifier_column": "user_id",
|
| 28 |
+
"stage": 3,
|
| 29 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 30 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 31 |
+
"involve_recsys_result": true,
|
| 32 |
+
"personalization": true,
|
| 33 |
+
"sample_examples": "Header: Practice makes progress 💪, \nMessage: You don't need to be perfect. But you do need practice to reach your goals!",
|
| 34 |
+
"model": "gemini-2.5-flash-lite"
|
| 35 |
+
},
|
| 36 |
+
"4": {
|
| 37 |
+
"identifier_column": "user_id",
|
| 38 |
+
"stage": 4,
|
| 39 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 40 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 41 |
+
"involve_recsys_result": true,
|
| 42 |
+
"personalization": true,
|
| 43 |
+
"sample_examples": "Header: Never stop learning, \nMessage: Take a lesson today and get back on track!",
|
| 44 |
+
"model": "gemini-2.5-flash-lite"
|
| 45 |
+
},
|
| 46 |
+
"5": {
|
| 47 |
+
"identifier_column": "user_id",
|
| 48 |
+
"stage": 5,
|
| 49 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 50 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 51 |
+
"involve_recsys_result": true,
|
| 52 |
+
"personalization": true,
|
| 53 |
+
"sample_examples": "Header: Get back on track ⏱️\nMessage: It's been two weeks since your last practice session. Take a lesson today!",
|
| 54 |
+
"model": "gemini-2.5-flash-lite"
|
| 55 |
+
},
|
| 56 |
+
"6": {
|
| 57 |
+
"identifier_column": "user_id",
|
| 58 |
+
"stage": 6,
|
| 59 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 60 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 61 |
+
"involve_recsys_result": true,
|
| 62 |
+
"personalization": true,
|
| 63 |
+
"sample_examples": "Header: Keep on going!\nMessage: Get back to playing today. It only takes a few minutes!",
|
| 64 |
+
"model": "gemini-2.5-flash-lite"
|
| 65 |
+
},
|
| 66 |
+
"7": {
|
| 67 |
+
"identifier_column": "user_id",
|
| 68 |
+
"stage": 7,
|
| 69 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 70 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 71 |
+
"involve_recsys_result": true,
|
| 72 |
+
"personalization": true,
|
| 73 |
+
"sample_examples": "Header: Ready to play? 🎸\nMessage: Let's get started. Time for a quick practice session!",
|
| 74 |
+
"model": "gemini-2.5-flash-lite"
|
| 75 |
+
},
|
| 76 |
+
"8": {
|
| 77 |
+
"identifier_column": "user_id",
|
| 78 |
+
"stage": 8,
|
| 79 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 80 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 81 |
+
"involve_recsys_result": true,
|
| 82 |
+
"personalization": true,
|
| 83 |
+
"sample_examples": "Header: Your lesson's waiting. 📥\nMessage: We want to hear you play! Dive in today.",
|
| 84 |
+
"model": "gemini-2.5-flash-lite"
|
| 85 |
+
},
|
| 86 |
+
"9": {
|
| 87 |
+
"identifier_column": "user_id",
|
| 88 |
+
"stage": 9,
|
| 89 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 90 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 91 |
+
"involve_recsys_result": true,
|
| 92 |
+
"personalization": true,
|
| 93 |
+
"sample_examples": "Header: Time for a comeback!\nMessage: We haven't seen you in 25 days. This will help get you back into the groove!",
|
| 94 |
+
"model": "gemini-2.5-flash-lite"
|
| 95 |
+
},
|
| 96 |
+
"10": {
|
| 97 |
+
"identifier_column": "user_id",
|
| 98 |
+
"stage": 10,
|
| 99 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 100 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 101 |
+
"involve_recsys_result": true,
|
| 102 |
+
"personalization": true,
|
| 103 |
+
"sample_examples": "Header: Have you been practicing?\nMessage: You're very talented. We'd love to hear you play again!",
|
| 104 |
+
"model": "gemini-2.5-flash-lite"
|
| 105 |
+
},
|
| 106 |
+
"11": {
|
| 107 |
+
"identifier_column": "user_id",
|
| 108 |
+
"stage": 11,
|
| 109 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 110 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 111 |
+
"involve_recsys_result": true,
|
| 112 |
+
"personalization": true,
|
| 113 |
+
"sample_examples": "Header: We Miss You 😔Message: All your lessons will just be here when you get back!",
|
| 114 |
+
"model": "gemini-2.5-flash-lite"
|
| 115 |
+
}
|
| 116 |
+
}
|
data/configs/pianote_re_engagement_test.json
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"campaign_name": "UI-Test-Campaign-Re-engagement",
|
| 3 |
+
"brand": "pianote",
|
| 4 |
+
"campaign_instructions": "Keep messages encouraging and motivational. Focus on getting users excited about practicing.",
|
| 5 |
+
|
| 6 |
+
"1": {
|
| 7 |
+
"identifier_column": "user_id",
|
| 8 |
+
"stage": 1,
|
| 9 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 10 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 11 |
+
"involve_recsys_result": true,
|
| 12 |
+
"personalization": true,
|
| 13 |
+
"sample_examples": "Header: Your next lesson is waiting 👇 \n Message: Check it out now and improve your playing!",
|
| 14 |
+
"model": "gemini-2.5-flash-lite"
|
| 15 |
+
},
|
| 16 |
+
"2": {
|
| 17 |
+
"identifier_column": "user_id",
|
| 18 |
+
"stage": 2,
|
| 19 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 20 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 21 |
+
"involve_recsys_result": true,
|
| 22 |
+
"personalization": true,
|
| 23 |
+
"sample_examples": "Header: It's a great day to play 🤩,\n Message: It's been a few days — warm up with a quick lesson!",
|
| 24 |
+
"model": "gemini-2.5-flash-lite"
|
| 25 |
+
},
|
| 26 |
+
"3": {
|
| 27 |
+
"identifier_column": "user_id",
|
| 28 |
+
"stage": 3,
|
| 29 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 30 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 31 |
+
"involve_recsys_result": true,
|
| 32 |
+
"personalization": true,
|
| 33 |
+
"sample_examples": "Header: Practice makes progress 💪, \nMessage: You don't need to be perfect. But you do need practice to reach your goals!",
|
| 34 |
+
"model": "gemini-2.5-flash-lite"
|
| 35 |
+
},
|
| 36 |
+
"4": {
|
| 37 |
+
"identifier_column": "user_id",
|
| 38 |
+
"stage": 4,
|
| 39 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 40 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 41 |
+
"involve_recsys_result": true,
|
| 42 |
+
"personalization": true,
|
| 43 |
+
"sample_examples": "Header: Never stop learning, \nMessage: Take a lesson today and get back on track!",
|
| 44 |
+
"model": "gemini-2.5-flash-lite"
|
| 45 |
+
},
|
| 46 |
+
"5": {
|
| 47 |
+
"identifier_column": "user_id",
|
| 48 |
+
"stage": 5,
|
| 49 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 50 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 51 |
+
"involve_recsys_result": true,
|
| 52 |
+
"personalization": true,
|
| 53 |
+
"sample_examples": "Header: Get back on track ⏱️\nMessage: It's been two weeks since your last practice session. Take a lesson today!",
|
| 54 |
+
"model": "gemini-2.5-flash-lite"
|
| 55 |
+
},
|
| 56 |
+
"6": {
|
| 57 |
+
"identifier_column": "user_id",
|
| 58 |
+
"stage": 6,
|
| 59 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 60 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 61 |
+
"involve_recsys_result": true,
|
| 62 |
+
"personalization": true,
|
| 63 |
+
"sample_examples": "Header: Keep on going!\nMessage: Get back to playing today. It only takes a few minutes!",
|
| 64 |
+
"model": "gemini-2.5-flash-lite"
|
| 65 |
+
},
|
| 66 |
+
"7": {
|
| 67 |
+
"identifier_column": "user_id",
|
| 68 |
+
"stage": 7,
|
| 69 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 70 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 71 |
+
"involve_recsys_result": true,
|
| 72 |
+
"personalization": true,
|
| 73 |
+
"sample_examples": "Header: Ready to play? 🎸\nMessage: Let's get started. Time for a quick practice session!",
|
| 74 |
+
"model": "gemini-2.5-flash-lite"
|
| 75 |
+
},
|
| 76 |
+
"8": {
|
| 77 |
+
"identifier_column": "user_id",
|
| 78 |
+
"stage": 8,
|
| 79 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 80 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 81 |
+
"involve_recsys_result": true,
|
| 82 |
+
"personalization": true,
|
| 83 |
+
"sample_examples": "Header: Your lesson's waiting. 📥\nMessage: We want to hear you play! Dive in today.",
|
| 84 |
+
"model": "gemini-2.5-flash-lite"
|
| 85 |
+
},
|
| 86 |
+
"9": {
|
| 87 |
+
"identifier_column": "user_id",
|
| 88 |
+
"stage": 9,
|
| 89 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 90 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 91 |
+
"involve_recsys_result": true,
|
| 92 |
+
"personalization": true,
|
| 93 |
+
"sample_examples": "Header: Time for a comeback!\nMessage: We haven't seen you in 25 days. This will help get you back into the groove!",
|
| 94 |
+
"model": "gemini-2.5-flash-lite"
|
| 95 |
+
},
|
| 96 |
+
"10": {
|
| 97 |
+
"identifier_column": "user_id",
|
| 98 |
+
"stage": 10,
|
| 99 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 100 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 101 |
+
"involve_recsys_result": true,
|
| 102 |
+
"personalization": true,
|
| 103 |
+
"sample_examples": "Header: Have you been practicing?\nMessage: You're very talented. We'd love to hear you play again!",
|
| 104 |
+
"model": "gemini-2.5-flash-lite"
|
| 105 |
+
},
|
| 106 |
+
"11": {
|
| 107 |
+
"identifier_column": "user_id",
|
| 108 |
+
"stage": 11,
|
| 109 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 110 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 111 |
+
"involve_recsys_result": true,
|
| 112 |
+
"personalization": true,
|
| 113 |
+
"sample_examples": "Header: We Miss You 😔Message: All your lessons will just be here when you get back!",
|
| 114 |
+
"model": "gemini-2.5-flash-lite"
|
| 115 |
+
}
|
| 116 |
+
}
|
data/configs/singeo_re_engagement_test.json
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"campaign_name": "UI-Test-Campaign-Re-engagement",
|
| 3 |
+
"brand": "singeo",
|
| 4 |
+
"campaign_instructions": "Keep messages encouraging and motivational. Focus on getting users excited about practicing.",
|
| 5 |
+
|
| 6 |
+
"1": {
|
| 7 |
+
"identifier_column": "user_id",
|
| 8 |
+
"stage": 1,
|
| 9 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 10 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 11 |
+
"involve_recsys_result": true,
|
| 12 |
+
"personalization": true,
|
| 13 |
+
"sample_examples": "Header: Your next lesson is waiting 👇 \n Message: Check it out now and improve your singing!",
|
| 14 |
+
"model": "gemini-2.5-flash-lite"
|
| 15 |
+
},
|
| 16 |
+
"2": {
|
| 17 |
+
"identifier_column": "user_id",
|
| 18 |
+
"stage": 2,
|
| 19 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 20 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 21 |
+
"involve_recsys_result": true,
|
| 22 |
+
"personalization": true,
|
| 23 |
+
"sample_examples": "Header: It's a great day to sing 🤩,\n Message: It's been a few days — warm up with a quick lesson!",
|
| 24 |
+
"model": "gemini-2.5-flash-lite"
|
| 25 |
+
},
|
| 26 |
+
"3": {
|
| 27 |
+
"identifier_column": "user_id",
|
| 28 |
+
"stage": 3,
|
| 29 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 30 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 31 |
+
"involve_recsys_result": true,
|
| 32 |
+
"personalization": true,
|
| 33 |
+
"sample_examples": "Header: Practice makes progress 💪, \nMessage: You don't need to be perfect. But you do need practice to reach your goals!",
|
| 34 |
+
"model": "gemini-2.5-flash-lite"
|
| 35 |
+
},
|
| 36 |
+
"4": {
|
| 37 |
+
"identifier_column": "user_id",
|
| 38 |
+
"stage": 4,
|
| 39 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 40 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 41 |
+
"involve_recsys_result": true,
|
| 42 |
+
"personalization": true,
|
| 43 |
+
"sample_examples": "Header: Never stop learning, \nMessage: Take a lesson today and get back on track!",
|
| 44 |
+
"model": "gemini-2.5-flash-lite"
|
| 45 |
+
},
|
| 46 |
+
"5": {
|
| 47 |
+
"identifier_column": "user_id",
|
| 48 |
+
"stage": 5,
|
| 49 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 50 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 51 |
+
"involve_recsys_result": true,
|
| 52 |
+
"personalization": true,
|
| 53 |
+
"sample_examples": "Header: Get back on track ⏱️\nMessage: It's been two weeks since your last practice session. Take a lesson today!",
|
| 54 |
+
"model": "gemini-2.5-flash-lite"
|
| 55 |
+
},
|
| 56 |
+
"6": {
|
| 57 |
+
"identifier_column": "user_id",
|
| 58 |
+
"stage": 6,
|
| 59 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 60 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 61 |
+
"involve_recsys_result": true,
|
| 62 |
+
"personalization": true,
|
| 63 |
+
"sample_examples": "Header: Keep on going!\nMessage: Get back to singing today. It only takes a few minutes!",
|
| 64 |
+
"model": "gemini-2.5-flash-lite"
|
| 65 |
+
},
|
| 66 |
+
"7": {
|
| 67 |
+
"identifier_column": "user_id",
|
| 68 |
+
"stage": 7,
|
| 69 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 70 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 71 |
+
"involve_recsys_result": true,
|
| 72 |
+
"personalization": true,
|
| 73 |
+
"sample_examples": "Header: Ready to play? 🎸\nMessage: Let's get started. Time for a quick practice session!",
|
| 74 |
+
"model": "gemini-2.5-flash-lite"
|
| 75 |
+
},
|
| 76 |
+
"8": {
|
| 77 |
+
"identifier_column": "user_id",
|
| 78 |
+
"stage": 8,
|
| 79 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 80 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 81 |
+
"involve_recsys_result": true,
|
| 82 |
+
"personalization": true,
|
| 83 |
+
"sample_examples": "Header: Your lesson's waiting. 📥\nMessage: We want to hear you sing! Dive in today.",
|
| 84 |
+
"model": "gemini-2.5-flash-lite"
|
| 85 |
+
},
|
| 86 |
+
"9": {
|
| 87 |
+
"identifier_column": "user_id",
|
| 88 |
+
"stage": 9,
|
| 89 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 90 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 91 |
+
"involve_recsys_result": true,
|
| 92 |
+
"personalization": true,
|
| 93 |
+
"sample_examples": "Header: Time for a comeback!\nMessage: We haven't seen you in 25 days. This will help get you back into the groove!",
|
| 94 |
+
"model": "gemini-2.5-flash-lite"
|
| 95 |
+
},
|
| 96 |
+
"10": {
|
| 97 |
+
"identifier_column": "user_id",
|
| 98 |
+
"stage": 10,
|
| 99 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 100 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 101 |
+
"involve_recsys_result": true,
|
| 102 |
+
"personalization": true,
|
| 103 |
+
"sample_examples": "Header: Have you been practicing?\nMessage: You have a lovely voice. We'd love to hear it again!",
|
| 104 |
+
"model": "gemini-2.5-flash-lite"
|
| 105 |
+
},
|
| 106 |
+
"11": {
|
| 107 |
+
"identifier_column": "user_id",
|
| 108 |
+
"stage": 11,
|
| 109 |
+
"segment_info": "Students who haven't practiced and logged into the app after at least 3 days.",
|
| 110 |
+
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 111 |
+
"involve_recsys_result": true,
|
| 112 |
+
"personalization": true,
|
| 113 |
+
"sample_examples": "Header: We Miss You 😔Message: All your lessons will just be here when you get back!",
|
| 114 |
+
"model": "gemini-2.5-flash-lite"
|
| 115 |
+
}
|
| 116 |
+
}
|
data/experiments/.gitkeep
ADDED
|
File without changes
|
data/feedback/.gitkeep
ADDED
|
File without changes
|
data/users/.gitkeep
ADDED
|
File without changes
|
pages/1_Campaign_Builder.py
CHANGED
|
@@ -1,24 +1,1043 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
"""
|
| 5 |
|
|
|
|
| 6 |
import sys
|
| 7 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
visualization_dir = root_dir / "visualization"
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Page 1: Campaign Builder
|
| 3 |
+
Configure and run message generation campaigns with A/B testing support.
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
import streamlit as st
|
| 7 |
import sys
|
| 8 |
from pathlib import Path
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import json
|
| 11 |
+
import threading
|
| 12 |
+
import shutil
|
| 13 |
|
| 14 |
+
# Add root directory to path
|
| 15 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
| 16 |
|
| 17 |
+
from utils.auth import check_authentication
|
| 18 |
+
from utils.theme import apply_theme, get_brand_emoji
|
| 19 |
+
from utils.data_loader import DataLoader
|
| 20 |
+
from utils.config_manager import ConfigManager
|
| 21 |
|
| 22 |
+
from ai_messaging_system_v2.Messaging_system.Permes import Permes
|
| 23 |
+
from ai_messaging_system_v2.configs.config_loader import get_system_config
|
| 24 |
+
from snowflake.snowpark import Session
|
| 25 |
+
from dotenv import load_dotenv
|
| 26 |
+
import os
|
| 27 |
|
| 28 |
+
# Load environment variables
|
| 29 |
+
env_path = Path(__file__).parent.parent / '.env'
|
| 30 |
+
if env_path.exists():
|
| 31 |
+
load_dotenv(env_path)
|
| 32 |
+
|
| 33 |
+
# Page configuration
|
| 34 |
+
st.set_page_config(
|
| 35 |
+
page_title="Campaign Builder",
|
| 36 |
+
page_icon="🏗️",
|
| 37 |
+
layout="wide"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Check authentication
|
| 41 |
+
if not check_authentication():
|
| 42 |
+
st.error("🔒 Please login first")
|
| 43 |
+
st.stop()
|
| 44 |
+
|
| 45 |
+
# Initialize utilities
|
| 46 |
+
data_loader = DataLoader()
|
| 47 |
+
config_manager = ConfigManager()
|
| 48 |
+
|
| 49 |
+
# Helper function to create Snowflake session
|
| 50 |
+
def create_snowflake_session() -> Session:
|
| 51 |
+
"""Create a Snowflake session using environment variables."""
|
| 52 |
+
conn_params = {
|
| 53 |
+
"user": os.getenv("SNOWFLAKE_USER"),
|
| 54 |
+
"password": os.getenv("SNOWFLAKE_PASSWORD"),
|
| 55 |
+
"account": os.getenv("SNOWFLAKE_ACCOUNT"),
|
| 56 |
+
"role": os.getenv("SNOWFLAKE_ROLE"),
|
| 57 |
+
"database": os.getenv("SNOWFLAKE_DATABASE"),
|
| 58 |
+
"warehouse": os.getenv("SNOWFLAKE_WAREHOUSE"),
|
| 59 |
+
"schema": os.getenv("SNOWFLAKE_SCHEMA"),
|
| 60 |
+
}
|
| 61 |
+
return Session.builder.configs(conn_params).create()
|
| 62 |
+
|
| 63 |
+
# Check if brand is selected
|
| 64 |
+
if "selected_brand" not in st.session_state or not st.session_state.selected_brand:
|
| 65 |
+
st.error("⚠️ Please select a brand from the home page first")
|
| 66 |
+
st.stop()
|
| 67 |
+
|
| 68 |
+
brand = st.session_state.selected_brand
|
| 69 |
+
apply_theme(brand)
|
| 70 |
+
|
| 71 |
+
# Initialize session state
|
| 72 |
+
if "ab_testing_mode" not in st.session_state:
|
| 73 |
+
st.session_state.ab_testing_mode = False
|
| 74 |
+
if "campaign_config" not in st.session_state:
|
| 75 |
+
st.session_state.campaign_config = None
|
| 76 |
+
if "campaign_config_a" not in st.session_state:
|
| 77 |
+
st.session_state.campaign_config_a = None
|
| 78 |
+
if "campaign_config_b" not in st.session_state:
|
| 79 |
+
st.session_state.campaign_config_b = None
|
| 80 |
+
if "selected_user_count" not in st.session_state:
|
| 81 |
+
st.session_state.selected_user_count = 5
|
| 82 |
+
if "generation_complete" not in st.session_state:
|
| 83 |
+
st.session_state.generation_complete = False
|
| 84 |
+
if "show_next_steps" not in st.session_state:
|
| 85 |
+
st.session_state.show_next_steps = False
|
| 86 |
+
|
| 87 |
+
# Page Header
|
| 88 |
+
emoji = get_brand_emoji(brand)
|
| 89 |
+
st.title(f"🏗️ Campaign Builder - {emoji} {brand.title()}")
|
| 90 |
+
st.markdown("**Configure and generate personalized message campaigns**")
|
| 91 |
+
|
| 92 |
+
st.markdown("---")
|
| 93 |
+
|
| 94 |
+
# ============================================================================
|
| 95 |
+
# A/B TESTING TOGGLE
|
| 96 |
+
# ============================================================================
|
| 97 |
+
ab_col1, ab_col2 = st.columns([3, 1])
|
| 98 |
+
|
| 99 |
+
with ab_col1:
|
| 100 |
+
st.subheader("🧪 Experiment Mode")
|
| 101 |
+
st.markdown("Toggle A/B testing to run two experiments in parallel with different configurations.")
|
| 102 |
+
|
| 103 |
+
with ab_col2:
|
| 104 |
+
ab_testing_enabled = st.toggle(
|
| 105 |
+
"Enable A/B Testing",
|
| 106 |
+
value=st.session_state.ab_testing_mode,
|
| 107 |
+
help="Enable to run two experiments (A and B) in parallel"
|
| 108 |
+
)
|
| 109 |
+
st.session_state.ab_testing_mode = ab_testing_enabled
|
| 110 |
+
|
| 111 |
+
st.markdown("---")
|
| 112 |
+
|
| 113 |
+
# ============================================================================
|
| 114 |
+
# HELPER FUNCTIONS FOR CONFIGURATION SECTIONS
|
| 115 |
+
# ============================================================================
|
| 116 |
+
|
| 117 |
+
def render_configuration_section(prefix: str, col_container, initial_config=None):
|
| 118 |
+
"""
|
| 119 |
+
Render a configuration section (used for both single mode and A/B mode).
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
prefix: Prefix for session state keys (e.g., "single", "a", "b")
|
| 123 |
+
col_container: Streamlit container/column to render in
|
| 124 |
+
initial_config: Initial configuration to load
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
Complete configuration dictionary
|
| 128 |
+
"""
|
| 129 |
+
with col_container:
|
| 130 |
+
# Configuration template selection
|
| 131 |
+
st.subheader("📋 Configuration Template")
|
| 132 |
+
|
| 133 |
+
# Get available configurations
|
| 134 |
+
all_configs = config_manager.get_all_configs(brand)
|
| 135 |
+
config_names = list(all_configs.keys())
|
| 136 |
+
|
| 137 |
+
if len(config_names) == 0:
|
| 138 |
+
st.warning("No configurations available. Please check your setup.")
|
| 139 |
+
return None
|
| 140 |
+
|
| 141 |
+
selected_config_name = st.selectbox(
|
| 142 |
+
"Template",
|
| 143 |
+
config_names,
|
| 144 |
+
index=0 if "re_engagement_test" in config_names else 0,
|
| 145 |
+
help="Select a configuration template to start with",
|
| 146 |
+
key=f"{prefix}_config_select"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# Load selected configuration
|
| 150 |
+
if selected_config_name == "re_engagement_test":
|
| 151 |
+
loaded_config = config_manager.load_default_config(brand)
|
| 152 |
+
else:
|
| 153 |
+
loaded_config = config_manager.load_custom_config(brand, selected_config_name)
|
| 154 |
+
|
| 155 |
+
if loaded_config:
|
| 156 |
+
st.success(f"✅ Loaded: **{selected_config_name}**")
|
| 157 |
+
|
| 158 |
+
# Show config preview
|
| 159 |
+
# with st.expander("📄 View Configuration Details"):
|
| 160 |
+
# st.json(loaded_config)
|
| 161 |
+
|
| 162 |
+
st.markdown("---")
|
| 163 |
+
|
| 164 |
+
# Campaign settings
|
| 165 |
+
st.subheader("⚙️ Campaign Settings")
|
| 166 |
+
|
| 167 |
+
campaign_name = st.text_input(
|
| 168 |
+
"Campaign Name",
|
| 169 |
+
value=loaded_config.get("campaign_name", f"UI-Campaign-{brand}"),
|
| 170 |
+
help="Name for this campaign",
|
| 171 |
+
key=f"{prefix}_campaign_name"
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
col1, col2 = st.columns(2)
|
| 175 |
+
|
| 176 |
+
with col1:
|
| 177 |
+
campaign_type = st.selectbox(
|
| 178 |
+
"Campaign Type",
|
| 179 |
+
["re_engagement"],
|
| 180 |
+
help="Type of campaign",
|
| 181 |
+
key=f"{prefix}_campaign_type"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
with col2:
|
| 185 |
+
num_stages = st.number_input(
|
| 186 |
+
"Number of Stages",
|
| 187 |
+
min_value=1,
|
| 188 |
+
max_value=11,
|
| 189 |
+
value=min(3, len([k for k in loaded_config.keys() if k.isdigit()])),
|
| 190 |
+
help="Number of message stages (1-11)",
|
| 191 |
+
key=f"{prefix}_num_stages"
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
campaign_instructions = st.text_area(
|
| 195 |
+
"Campaign-Wide Instructions (Optional)",
|
| 196 |
+
value=loaded_config.get("campaign_instructions", ""),
|
| 197 |
+
height=80,
|
| 198 |
+
help="Instructions applied to all stages",
|
| 199 |
+
key=f"{prefix}_campaign_instructions"
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
st.markdown("---")
|
| 203 |
+
|
| 204 |
+
# Stage configuration
|
| 205 |
+
st.subheader("📝 Stage Configuration")
|
| 206 |
+
|
| 207 |
+
# Load system config for model options
|
| 208 |
+
system_config = get_system_config()
|
| 209 |
+
available_models = system_config.get("openai_models", []) + system_config.get("google_models", [])
|
| 210 |
+
|
| 211 |
+
stages_config = {}
|
| 212 |
+
|
| 213 |
+
for stage_num in range(1, num_stages + 1):
|
| 214 |
+
with st.expander(f"Stage {stage_num} Configuration", expanded=(stage_num == 1)):
|
| 215 |
+
# Load existing stage config if available
|
| 216 |
+
existing_stage = loaded_config.get(str(stage_num), {})
|
| 217 |
+
|
| 218 |
+
col1, col2 = st.columns(2)
|
| 219 |
+
|
| 220 |
+
with col1:
|
| 221 |
+
model = st.selectbox(
|
| 222 |
+
"LLM Model",
|
| 223 |
+
available_models,
|
| 224 |
+
index=available_models.index(existing_stage.get("model", available_models[0])) if existing_stage.get("model") in available_models else 0,
|
| 225 |
+
key=f"{prefix}_model_{stage_num}",
|
| 226 |
+
help="Language model to use for generation"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
personalization = st.checkbox(
|
| 230 |
+
"Enable Personalization",
|
| 231 |
+
value=existing_stage.get("personalization", True),
|
| 232 |
+
key=f"{prefix}_personalization_{stage_num}",
|
| 233 |
+
help="Personalize messages based on user profile"
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
involve_recsys = st.checkbox(
|
| 237 |
+
"Include Content Recommendation",
|
| 238 |
+
value=existing_stage.get("involve_recsys_result", True),
|
| 239 |
+
key=f"{prefix}_involve_recsys_{stage_num}",
|
| 240 |
+
help="Include content recommendations in messages"
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
with col2:
|
| 244 |
+
recsys_contents = st.multiselect(
|
| 245 |
+
"Recommendation Types",
|
| 246 |
+
["workout", "course", "quick_tips", "song"],
|
| 247 |
+
default=existing_stage.get("recsys_contents", ["workout", "course"]),
|
| 248 |
+
key=f"{prefix}_recsys_contents_{stage_num}",
|
| 249 |
+
help="Types of content to recommend",
|
| 250 |
+
disabled=not involve_recsys
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
specific_content_id = st.number_input(
|
| 254 |
+
"Specific Content ID (Optional)",
|
| 255 |
+
min_value=0,
|
| 256 |
+
value=existing_stage.get("specific_content_id", 0) or 0,
|
| 257 |
+
key=f"{prefix}_specific_content_id_{stage_num}",
|
| 258 |
+
help="Force specific content for all users (leave 0 for AI recommendations)"
|
| 259 |
+
)
|
| 260 |
+
specific_content_id = specific_content_id if specific_content_id > 0 else None
|
| 261 |
+
|
| 262 |
+
segment_info = st.text_area(
|
| 263 |
+
"Segment Description",
|
| 264 |
+
value=existing_stage.get("segment_info", f"Users eligible for stage {stage_num}"),
|
| 265 |
+
key=f"{prefix}_segment_info_{stage_num}",
|
| 266 |
+
height=68,
|
| 267 |
+
help="Description of the user segment"
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
instructions = st.text_area(
|
| 271 |
+
"Stage-Specific Instructions (Optional)",
|
| 272 |
+
value=existing_stage.get("instructions", ""),
|
| 273 |
+
key=f"{prefix}_instructions_{stage_num}",
|
| 274 |
+
height=80,
|
| 275 |
+
help="Instructions specific to this stage"
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
sample_example = st.text_area(
|
| 279 |
+
"Example Messages",
|
| 280 |
+
value=existing_stage.get("sample_examples", "Header: Hi!\nMessage: Check this out!"),
|
| 281 |
+
key=f"{prefix}_sample_example_{stage_num}",
|
| 282 |
+
height=80,
|
| 283 |
+
help="Example messages for the LLM to learn from"
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
# Store stage configuration
|
| 287 |
+
stages_config[stage_num] = {
|
| 288 |
+
"stage": stage_num,
|
| 289 |
+
"model": model,
|
| 290 |
+
"personalization": personalization,
|
| 291 |
+
"involve_recsys_result": involve_recsys,
|
| 292 |
+
"recsys_contents": recsys_contents if involve_recsys else [],
|
| 293 |
+
"specific_content_id": specific_content_id,
|
| 294 |
+
"segment_info": segment_info,
|
| 295 |
+
"instructions": instructions,
|
| 296 |
+
"sample_examples": sample_example,
|
| 297 |
+
"identifier_column": "user_id",
|
| 298 |
+
"platform": "push"
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
# Create complete configuration
|
| 302 |
+
complete_config = config_manager.create_config_from_ui(
|
| 303 |
+
brand=brand,
|
| 304 |
+
campaign_name=campaign_name,
|
| 305 |
+
campaign_type=campaign_type,
|
| 306 |
+
num_stages=num_stages,
|
| 307 |
+
campaign_instructions=campaign_instructions,
|
| 308 |
+
stages_config=stages_config
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
# Validation
|
| 312 |
+
is_valid, error_msg = config_manager.validate_config(complete_config)
|
| 313 |
+
|
| 314 |
+
if not is_valid:
|
| 315 |
+
st.error(f"❌ Configuration Error: {error_msg}")
|
| 316 |
+
else:
|
| 317 |
+
st.success("✅ Configuration is valid")
|
| 318 |
+
|
| 319 |
+
return complete_config
|
| 320 |
+
|
| 321 |
+
def render_save_config_section(prefix: str, col_container, config):
|
| 322 |
+
"""Render save configuration section."""
|
| 323 |
+
with col_container:
|
| 324 |
+
st.subheader("💾 Save Configuration")
|
| 325 |
+
|
| 326 |
+
with st.form(f"{prefix}_save_config_form"):
|
| 327 |
+
new_config_name = st.text_input(
|
| 328 |
+
"Configuration Name",
|
| 329 |
+
placeholder="my-custom-config",
|
| 330 |
+
help="Enter a name to save this configuration"
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
save_button = st.form_submit_button("💾 Save Configuration", use_container_width=True)
|
| 334 |
+
|
| 335 |
+
if save_button and new_config_name:
|
| 336 |
+
if config:
|
| 337 |
+
# Check if config already exists
|
| 338 |
+
if config_manager.config_exists(brand, new_config_name):
|
| 339 |
+
st.warning(f"⚠️ Configuration '{new_config_name}' already exists. It will be overwritten.")
|
| 340 |
+
|
| 341 |
+
# Save configuration
|
| 342 |
+
if config_manager.save_custom_config(brand, new_config_name, config):
|
| 343 |
+
st.success(f"✅ Configuration saved as '{new_config_name}'")
|
| 344 |
+
else:
|
| 345 |
+
st.error("❌ Failed to save configuration")
|
| 346 |
+
else:
|
| 347 |
+
st.error("❌ No configuration loaded")
|
| 348 |
+
|
| 349 |
+
# ============================================================================
|
| 350 |
+
# RENDER CONFIGURATION SECTIONS BASED ON MODE
|
| 351 |
+
# ============================================================================
|
| 352 |
+
|
| 353 |
+
if st.session_state.ab_testing_mode:
|
| 354 |
+
# A/B Testing Mode - Two Columns
|
| 355 |
+
st.header("🧪 A/B Testing Configuration")
|
| 356 |
+
st.info("Configure two different experiments (A and B) to run in parallel with the same users.")
|
| 357 |
+
|
| 358 |
+
col_a, col_b = st.columns(2)
|
| 359 |
+
|
| 360 |
+
# Initialize A/B configs with defaults if None
|
| 361 |
+
if st.session_state.campaign_config_a is None:
|
| 362 |
+
st.session_state.campaign_config_a = config_manager.load_default_config(brand)
|
| 363 |
+
if st.session_state.campaign_config_b is None:
|
| 364 |
+
st.session_state.campaign_config_b = config_manager.load_default_config(brand)
|
| 365 |
+
|
| 366 |
+
# Column A
|
| 367 |
+
with col_a:
|
| 368 |
+
st.markdown("### 🅰️ Experiment A")
|
| 369 |
+
with st.expander("Configuration A", expanded=True):
|
| 370 |
+
config_a = render_configuration_section("a", st.container(), st.session_state.campaign_config_a)
|
| 371 |
+
if config_a is not None:
|
| 372 |
+
st.session_state.campaign_config_a = config_a
|
| 373 |
+
|
| 374 |
+
with st.expander("Save Configuration A"):
|
| 375 |
+
if st.session_state.campaign_config_a:
|
| 376 |
+
render_save_config_section("a", st.container(), st.session_state.campaign_config_a)
|
| 377 |
+
|
| 378 |
+
# Column B
|
| 379 |
+
with col_b:
|
| 380 |
+
st.markdown("### 🅱️ Experiment B")
|
| 381 |
+
with st.expander("Configuration B", expanded=True):
|
| 382 |
+
config_b = render_configuration_section("b", st.container(), st.session_state.campaign_config_b)
|
| 383 |
+
if config_b is not None:
|
| 384 |
+
st.session_state.campaign_config_b = config_b
|
| 385 |
+
|
| 386 |
+
with st.expander("Save Configuration B"):
|
| 387 |
+
if st.session_state.campaign_config_b:
|
| 388 |
+
render_save_config_section("b", st.container(), st.session_state.campaign_config_b)
|
| 389 |
+
|
| 390 |
+
else:
|
| 391 |
+
# Single Mode
|
| 392 |
+
# Initialize config with default if None
|
| 393 |
+
if st.session_state.campaign_config is None:
|
| 394 |
+
st.session_state.campaign_config = config_manager.load_default_config(brand)
|
| 395 |
+
|
| 396 |
+
with st.expander("📋 Configuration", expanded=True):
|
| 397 |
+
config = render_configuration_section("single", st.container(), st.session_state.campaign_config)
|
| 398 |
+
if config is not None:
|
| 399 |
+
st.session_state.campaign_config = config
|
| 400 |
+
|
| 401 |
+
# Save section outside the main config expander
|
| 402 |
+
with st.expander("💾 Save Configuration"):
|
| 403 |
+
if st.session_state.campaign_config:
|
| 404 |
+
render_save_config_section("single", st.container(), st.session_state.campaign_config)
|
| 405 |
+
|
| 406 |
+
st.markdown("---")
|
| 407 |
+
|
| 408 |
+
# ============================================================================
|
| 409 |
+
# USER SELECTION SECTION
|
| 410 |
+
# ============================================================================
|
| 411 |
+
|
| 412 |
+
with st.expander("👥 User Selection", expanded=True):
|
| 413 |
+
st.subheader("👥 Select Number of Users")
|
| 414 |
+
|
| 415 |
+
# Check if brand users file exists
|
| 416 |
+
has_brand_users = data_loader.has_brand_users(brand)
|
| 417 |
+
|
| 418 |
+
if not has_brand_users:
|
| 419 |
+
st.error(f"❌ No user file found for {brand}. Please ensure {brand}_users.csv exists in visualization/data/UI_users/")
|
| 420 |
+
else:
|
| 421 |
+
# Get total available users
|
| 422 |
+
total_users = data_loader.get_brand_user_count(brand)
|
| 423 |
+
|
| 424 |
+
st.info(f"ℹ️ {total_users} users available for {brand}")
|
| 425 |
+
|
| 426 |
+
st.markdown("""
|
| 427 |
+
Choose how many users you want to experiment with. Users will be **randomly sampled** each time you generate messages.
|
| 428 |
+
|
| 429 |
+
For quick experimentation, we recommend **5-10 users**.
|
| 430 |
+
""")
|
| 431 |
+
|
| 432 |
+
col1, col2 = st.columns([2, 3])
|
| 433 |
+
|
| 434 |
+
with col1:
|
| 435 |
+
user_count = st.number_input(
|
| 436 |
+
"Number of Users",
|
| 437 |
+
min_value=1,
|
| 438 |
+
max_value=min(25, total_users),
|
| 439 |
+
value=st.session_state.selected_user_count,
|
| 440 |
+
help="Select 1-25 users for experimentation"
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
# Update session state
|
| 444 |
+
st.session_state.selected_user_count = user_count
|
| 445 |
+
|
| 446 |
+
with col2:
|
| 447 |
+
st.markdown("")
|
| 448 |
+
st.markdown("")
|
| 449 |
+
st.markdown(f"**Selected:** {user_count} user{'s' if user_count != 1 else ''}")
|
| 450 |
+
st.markdown(f"**Available:** {total_users} total users")
|
| 451 |
+
|
| 452 |
+
st.markdown("---")
|
| 453 |
+
|
| 454 |
+
# Preview sample button
|
| 455 |
+
if st.button("👀 Preview Random Sample", use_container_width=False):
|
| 456 |
+
sample_df = data_loader.sample_users_randomly(brand, user_count)
|
| 457 |
+
if sample_df is not None:
|
| 458 |
+
st.success(f"✅ Sampled {len(sample_df)} random users")
|
| 459 |
+
st.dataframe(sample_df, use_container_width=True)
|
| 460 |
+
|
| 461 |
+
st.markdown("---")
|
| 462 |
+
|
| 463 |
+
# ============================================================================
|
| 464 |
+
# GENERATION SECTION
|
| 465 |
+
# ============================================================================
|
| 466 |
+
|
| 467 |
+
def archive_existing_messages(campaign_name: str):
|
| 468 |
+
"""Archive existing messages.csv to a timestamped file."""
|
| 469 |
+
ui_output_path = data_loader.get_ui_output_path()
|
| 470 |
+
messages_file = ui_output_path / "messages.csv"
|
| 471 |
+
|
| 472 |
+
if messages_file.exists():
|
| 473 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M')
|
| 474 |
+
archive_name = f"messages_{campaign_name}_{timestamp}.csv"
|
| 475 |
+
archive_path = ui_output_path / archive_name
|
| 476 |
+
|
| 477 |
+
try:
|
| 478 |
+
shutil.copy2(messages_file, archive_path)
|
| 479 |
+
st.info(f"📦 Archived existing messages to: {archive_name}")
|
| 480 |
+
return True
|
| 481 |
+
except Exception as e:
|
| 482 |
+
st.warning(f"⚠️ Could not archive existing messages: {e}")
|
| 483 |
+
return False
|
| 484 |
+
return True
|
| 485 |
+
|
| 486 |
+
def generate_messages_for_experiment(
|
| 487 |
+
experiment_name: str,
|
| 488 |
+
config: dict,
|
| 489 |
+
sampled_users_df,
|
| 490 |
+
output_suffix: str = None,
|
| 491 |
+
progress_container=None
|
| 492 |
+
):
|
| 493 |
+
"""
|
| 494 |
+
Generate messages for a single experiment.
|
| 495 |
+
|
| 496 |
+
Args:
|
| 497 |
+
experiment_name: Name of the experiment (e.g., "A" or "B")
|
| 498 |
+
config: Configuration dictionary
|
| 499 |
+
sampled_users_df: DataFrame of sampled users
|
| 500 |
+
output_suffix: Optional suffix for output filename (e.g., "_a" or "_b")
|
| 501 |
+
progress_container: Streamlit container for progress updates
|
| 502 |
+
|
| 503 |
+
Returns:
|
| 504 |
+
tuple: (success, total_messages_generated)
|
| 505 |
+
"""
|
| 506 |
+
if progress_container is None:
|
| 507 |
+
progress_container = st
|
| 508 |
+
|
| 509 |
+
with progress_container:
|
| 510 |
+
st.markdown(f"### 📊 Experiment {experiment_name} Progress")
|
| 511 |
+
|
| 512 |
+
system_config = get_system_config()
|
| 513 |
+
|
| 514 |
+
# Setup Permes for message generation
|
| 515 |
+
permes = Permes()
|
| 516 |
+
original_output_file = permes.UI_OUTPUT_FILE
|
| 517 |
+
ui_experiment_id = None
|
| 518 |
+
|
| 519 |
+
if output_suffix:
|
| 520 |
+
# Generate experiment ID for timestamping and naming
|
| 521 |
+
experiment_timestamp = datetime.now().strftime('%Y%m%d_%H%M')
|
| 522 |
+
ui_experiment_id = f"messages_{output_suffix}_{brand}_{experiment_timestamp}"
|
| 523 |
+
# Keep this for backward compatibility (though it won't be used anymore)
|
| 524 |
+
permes.UI_OUTPUT_FILE = f"{ui_experiment_id}.csv"
|
| 525 |
+
st.info(f"💾 Results will be saved to: {ui_experiment_id}.csv")
|
| 526 |
+
|
| 527 |
+
# Get campaign-level configurations
|
| 528 |
+
campaign_name = config.get("campaign_name", "UI-Campaign")
|
| 529 |
+
campaign_instructions = config.get("campaign_instructions", None)
|
| 530 |
+
num_stages = len([k for k in config.keys() if k.isdigit()])
|
| 531 |
+
|
| 532 |
+
# Generate messages for each stage
|
| 533 |
+
total_messages_generated = 0
|
| 534 |
+
generation_successful = True
|
| 535 |
+
|
| 536 |
+
for stage in range(1, num_stages + 1):
|
| 537 |
+
st.markdown(f"#### Stage {stage} of {num_stages}")
|
| 538 |
+
|
| 539 |
+
progress_bar = st.progress(0)
|
| 540 |
+
status_text = st.empty()
|
| 541 |
+
|
| 542 |
+
status_text.text(f"Generating messages for stage {stage}...")
|
| 543 |
+
|
| 544 |
+
session = None
|
| 545 |
+
try:
|
| 546 |
+
session = create_snowflake_session()
|
| 547 |
+
progress_bar.progress(10)
|
| 548 |
+
|
| 549 |
+
# Get stage configuration
|
| 550 |
+
stage_config = config.get(str(stage), {})
|
| 551 |
+
|
| 552 |
+
if not stage_config:
|
| 553 |
+
status_text.error(f"❌ Stage {stage}: Configuration not found")
|
| 554 |
+
generation_successful = False
|
| 555 |
+
if session:
|
| 556 |
+
try:
|
| 557 |
+
session.close()
|
| 558 |
+
except:
|
| 559 |
+
pass
|
| 560 |
+
continue
|
| 561 |
+
|
| 562 |
+
# Extract stage parameters
|
| 563 |
+
model = stage_config.get("model", "gemini-2.5-flash-lite")
|
| 564 |
+
segment_info = stage_config.get("segment_info", "")
|
| 565 |
+
recsys_contents = stage_config.get("recsys_contents", [])
|
| 566 |
+
involve_recsys_result = stage_config.get("involve_recsys_result", True)
|
| 567 |
+
personalization = stage_config.get("personalization", True)
|
| 568 |
+
sample_example = stage_config.get("sample_examples", "")
|
| 569 |
+
per_message_instructions = stage_config.get("instructions", None)
|
| 570 |
+
specific_content_id = stage_config.get("specific_content_id", None)
|
| 571 |
+
|
| 572 |
+
progress_bar.progress(20)
|
| 573 |
+
|
| 574 |
+
# Call Permes to generate messages
|
| 575 |
+
users_message = permes.create_personalize_messages(
|
| 576 |
+
session=session,
|
| 577 |
+
model=model,
|
| 578 |
+
users=sampled_users_df.copy(),
|
| 579 |
+
brand=brand,
|
| 580 |
+
config_file=system_config,
|
| 581 |
+
segment_info=segment_info,
|
| 582 |
+
involve_recsys_result=involve_recsys_result,
|
| 583 |
+
identifier_column="user_id",
|
| 584 |
+
recsys_contents=recsys_contents,
|
| 585 |
+
sample_example=sample_example,
|
| 586 |
+
campaign_name=campaign_name,
|
| 587 |
+
personalization=personalization,
|
| 588 |
+
stage=stage,
|
| 589 |
+
test_mode=False,
|
| 590 |
+
mode="ui",
|
| 591 |
+
campaign_instructions=campaign_instructions,
|
| 592 |
+
per_message_instructions=per_message_instructions,
|
| 593 |
+
specific_content_id=specific_content_id,
|
| 594 |
+
ui_experiment_id=ui_experiment_id
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
progress_bar.progress(100)
|
| 598 |
+
|
| 599 |
+
if users_message is not None and len(users_message) > 0:
|
| 600 |
+
total_messages_generated += len(users_message)
|
| 601 |
+
status_text.success(
|
| 602 |
+
f"✅ Stage {stage}: Generated {len(users_message)} messages"
|
| 603 |
+
)
|
| 604 |
+
else:
|
| 605 |
+
status_text.warning(f"⚠️ Stage {stage}: No messages generated")
|
| 606 |
+
generation_successful = False
|
| 607 |
+
|
| 608 |
+
except Exception as e:
|
| 609 |
+
progress_bar.progress(0)
|
| 610 |
+
status_text.error(f"❌ Stage {stage}: Error - {str(e)}")
|
| 611 |
+
generation_successful = False
|
| 612 |
+
|
| 613 |
+
finally:
|
| 614 |
+
if session:
|
| 615 |
+
try:
|
| 616 |
+
session.close()
|
| 617 |
+
except:
|
| 618 |
+
pass
|
| 619 |
+
|
| 620 |
+
# Restore original output file name
|
| 621 |
+
permes.UI_OUTPUT_FILE = original_output_file
|
| 622 |
+
|
| 623 |
+
return generation_successful, total_messages_generated
|
| 624 |
+
|
| 625 |
+
def generate_messages_threaded(
|
| 626 |
+
experiment_name: str,
|
| 627 |
+
config: dict,
|
| 628 |
+
sampled_users_df,
|
| 629 |
+
output_suffix: str,
|
| 630 |
+
brand: str,
|
| 631 |
+
experiment_timestamp: str = None
|
| 632 |
+
):
|
| 633 |
+
"""
|
| 634 |
+
Generate messages for threading (no UI updates).
|
| 635 |
+
|
| 636 |
+
Args:
|
| 637 |
+
experiment_name: Name of the experiment (e.g., "A" or "B")
|
| 638 |
+
config: Configuration dictionary
|
| 639 |
+
sampled_users_df: DataFrame of sampled users
|
| 640 |
+
output_suffix: Suffix for output filename (e.g., "a" or "b")
|
| 641 |
+
brand: Brand name
|
| 642 |
+
experiment_timestamp: Shared timestamp for A/B test pairs
|
| 643 |
+
|
| 644 |
+
Returns:
|
| 645 |
+
dict: Results with 'success', 'total_messages', 'error', 'stages_completed'
|
| 646 |
+
"""
|
| 647 |
+
result = {
|
| 648 |
+
'success': False,
|
| 649 |
+
'total_messages': 0,
|
| 650 |
+
'error': None,
|
| 651 |
+
'stages_completed': 0,
|
| 652 |
+
'stage_details': []
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
try:
|
| 656 |
+
system_config = get_system_config()
|
| 657 |
+
|
| 658 |
+
# Setup Permes for message generation
|
| 659 |
+
permes = Permes()
|
| 660 |
+
original_output_file = permes.UI_OUTPUT_FILE
|
| 661 |
+
|
| 662 |
+
# Use provided timestamp or generate new one
|
| 663 |
+
if experiment_timestamp is None:
|
| 664 |
+
experiment_timestamp = datetime.now().strftime('%Y%m%d_%H%M')
|
| 665 |
+
|
| 666 |
+
# Create experiment ID for UI mode
|
| 667 |
+
ui_experiment_id = f"messages_{output_suffix}_{brand}_{experiment_timestamp}"
|
| 668 |
+
# Keep this for backward compatibility (though it won't be used anymore)
|
| 669 |
+
permes.UI_OUTPUT_FILE = f"{ui_experiment_id}.csv"
|
| 670 |
+
|
| 671 |
+
# Get campaign-level configurations
|
| 672 |
+
campaign_name = config.get("campaign_name", "UI-Campaign")
|
| 673 |
+
campaign_instructions = config.get("campaign_instructions", None)
|
| 674 |
+
num_stages = len([k for k in config.keys() if k.isdigit()])
|
| 675 |
+
|
| 676 |
+
# Generate messages for each stage
|
| 677 |
+
for stage in range(1, num_stages + 1):
|
| 678 |
+
session = None
|
| 679 |
+
try:
|
| 680 |
+
session = create_snowflake_session()
|
| 681 |
+
|
| 682 |
+
# Get stage configuration
|
| 683 |
+
stage_config = config.get(str(stage), {})
|
| 684 |
+
|
| 685 |
+
if not stage_config:
|
| 686 |
+
result['stage_details'].append({
|
| 687 |
+
'stage': stage,
|
| 688 |
+
'status': 'error',
|
| 689 |
+
'message': 'Configuration not found',
|
| 690 |
+
'count': 0
|
| 691 |
+
})
|
| 692 |
+
continue
|
| 693 |
+
|
| 694 |
+
# Extract stage parameters
|
| 695 |
+
model = stage_config.get("model", "gemini-2.5-flash-lite")
|
| 696 |
+
segment_info = stage_config.get("segment_info", "")
|
| 697 |
+
recsys_contents = stage_config.get("recsys_contents", [])
|
| 698 |
+
involve_recsys_result = stage_config.get("involve_recsys_result", True)
|
| 699 |
+
personalization = stage_config.get("personalization", True)
|
| 700 |
+
sample_example = stage_config.get("sample_examples", "")
|
| 701 |
+
per_message_instructions = stage_config.get("instructions", None)
|
| 702 |
+
specific_content_id = stage_config.get("specific_content_id", None)
|
| 703 |
+
|
| 704 |
+
# Call Permes to generate messages
|
| 705 |
+
users_message = permes.create_personalize_messages(
|
| 706 |
+
session=session,
|
| 707 |
+
model=model,
|
| 708 |
+
users=sampled_users_df.copy(),
|
| 709 |
+
brand=brand,
|
| 710 |
+
config_file=system_config,
|
| 711 |
+
segment_info=segment_info,
|
| 712 |
+
involve_recsys_result=involve_recsys_result,
|
| 713 |
+
identifier_column="user_id",
|
| 714 |
+
recsys_contents=recsys_contents,
|
| 715 |
+
sample_example=sample_example,
|
| 716 |
+
campaign_name=campaign_name,
|
| 717 |
+
personalization=personalization,
|
| 718 |
+
stage=stage,
|
| 719 |
+
test_mode=False,
|
| 720 |
+
mode="ui",
|
| 721 |
+
campaign_instructions=campaign_instructions,
|
| 722 |
+
per_message_instructions=per_message_instructions,
|
| 723 |
+
specific_content_id=specific_content_id,
|
| 724 |
+
ui_experiment_id=ui_experiment_id
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
if users_message is not None and len(users_message) > 0:
|
| 728 |
+
result['total_messages'] += len(users_message)
|
| 729 |
+
result['stages_completed'] += 1
|
| 730 |
+
result['stage_details'].append({
|
| 731 |
+
'stage': stage,
|
| 732 |
+
'status': 'success',
|
| 733 |
+
'message': f'Generated {len(users_message)} messages',
|
| 734 |
+
'count': len(users_message)
|
| 735 |
+
})
|
| 736 |
+
else:
|
| 737 |
+
result['stage_details'].append({
|
| 738 |
+
'stage': stage,
|
| 739 |
+
'status': 'warning',
|
| 740 |
+
'message': 'No messages generated',
|
| 741 |
+
'count': 0
|
| 742 |
+
})
|
| 743 |
+
|
| 744 |
+
except Exception as e:
|
| 745 |
+
result['stage_details'].append({
|
| 746 |
+
'stage': stage,
|
| 747 |
+
'status': 'error',
|
| 748 |
+
'message': str(e),
|
| 749 |
+
'count': 0
|
| 750 |
+
})
|
| 751 |
+
|
| 752 |
+
finally:
|
| 753 |
+
if session:
|
| 754 |
+
try:
|
| 755 |
+
session.close()
|
| 756 |
+
except:
|
| 757 |
+
pass
|
| 758 |
+
|
| 759 |
+
# Restore original output file name
|
| 760 |
+
permes.UI_OUTPUT_FILE = original_output_file
|
| 761 |
+
|
| 762 |
+
# Mark as successful if at least one stage completed
|
| 763 |
+
result['success'] = result['stages_completed'] > 0
|
| 764 |
+
|
| 765 |
+
except Exception as e:
|
| 766 |
+
result['error'] = str(e)
|
| 767 |
+
result['success'] = False
|
| 768 |
+
|
| 769 |
+
return result
|
| 770 |
+
|
| 771 |
+
with st.expander("🚀 Generate Messages", expanded=True):
|
| 772 |
+
st.subheader("🚀 Generate Messages")
|
| 773 |
+
|
| 774 |
+
# Check prerequisites
|
| 775 |
+
if st.session_state.ab_testing_mode:
|
| 776 |
+
config_ready = (st.session_state.campaign_config_a is not None and
|
| 777 |
+
st.session_state.campaign_config_b is not None)
|
| 778 |
+
else:
|
| 779 |
+
config_ready = st.session_state.campaign_config is not None
|
| 780 |
+
|
| 781 |
+
users_ready = data_loader.has_brand_users(brand)
|
| 782 |
+
user_count_selected = st.session_state.get("selected_user_count", 5)
|
| 783 |
+
|
| 784 |
+
# Status checks
|
| 785 |
+
col1, col2, col3 = st.columns(3)
|
| 786 |
+
|
| 787 |
+
with col1:
|
| 788 |
+
if config_ready:
|
| 789 |
+
st.success("✅ Configuration ready")
|
| 790 |
+
else:
|
| 791 |
+
st.error("❌ Configuration not ready")
|
| 792 |
+
|
| 793 |
+
with col2:
|
| 794 |
+
if users_ready:
|
| 795 |
+
st.success(f"✅ {user_count_selected} users selected")
|
| 796 |
+
else:
|
| 797 |
+
st.error("❌ No users available")
|
| 798 |
+
|
| 799 |
+
with col3:
|
| 800 |
+
if st.session_state.ab_testing_mode:
|
| 801 |
+
st.metric("Mode", "A/B Testing")
|
| 802 |
+
else:
|
| 803 |
+
st.metric("Mode", "Single")
|
| 804 |
+
|
| 805 |
+
st.markdown("---")
|
| 806 |
+
|
| 807 |
+
if not config_ready:
|
| 808 |
+
st.info("👆 Please complete the configuration first")
|
| 809 |
+
elif not users_ready:
|
| 810 |
+
st.error("❌ No users available. Please check that user files exist.")
|
| 811 |
+
else:
|
| 812 |
+
# Generation settings
|
| 813 |
+
st.subheader("Generation Settings")
|
| 814 |
+
|
| 815 |
+
if st.session_state.ab_testing_mode:
|
| 816 |
+
num_stages_a = len([k for k in st.session_state.campaign_config_a.keys() if k.isdigit()])
|
| 817 |
+
num_stages_b = len([k for k in st.session_state.campaign_config_b.keys() if k.isdigit()])
|
| 818 |
+
st.info(f"ℹ️ Will generate messages for **{num_stages_a} stages (A)** and **{num_stages_b} stages (B)** in parallel")
|
| 819 |
+
else:
|
| 820 |
+
num_stages = len([k for k in st.session_state.campaign_config.keys() if k.isdigit()])
|
| 821 |
+
st.info(f"ℹ️ Will generate messages for **all {num_stages} stages** configured in the campaign")
|
| 822 |
+
|
| 823 |
+
# Clear previous results option
|
| 824 |
+
clear_previous = st.checkbox(
|
| 825 |
+
"Clear previous UI output before generating",
|
| 826 |
+
value=True,
|
| 827 |
+
help="Clear previous message results before generating new ones"
|
| 828 |
+
)
|
| 829 |
+
|
| 830 |
+
st.markdown("---")
|
| 831 |
+
|
| 832 |
+
# Generate button
|
| 833 |
+
if st.button("🚀 Start Generation", use_container_width=True, type="primary"):
|
| 834 |
+
# Reset next steps flag
|
| 835 |
+
st.session_state.show_next_steps = False
|
| 836 |
+
st.session_state.generation_complete = False
|
| 837 |
+
|
| 838 |
+
# Sample users randomly from the brand's user file
|
| 839 |
+
with st.spinner(f"Sampling {user_count_selected} random users from {brand}_users.csv..."):
|
| 840 |
+
sampled_users_df = data_loader.sample_users_randomly(brand, user_count_selected)
|
| 841 |
+
|
| 842 |
+
if sampled_users_df is None or len(sampled_users_df) == 0:
|
| 843 |
+
st.error("❌ Failed to sample users. Please check your user files.")
|
| 844 |
+
st.stop()
|
| 845 |
+
|
| 846 |
+
st.success(f"✅ Sampled {len(sampled_users_df)} users successfully")
|
| 847 |
+
|
| 848 |
+
# Archive existing messages before starting
|
| 849 |
+
if st.session_state.ab_testing_mode:
|
| 850 |
+
campaign_name_a = st.session_state.campaign_config_a.get("campaign_name", "Experiment_A")
|
| 851 |
+
archive_existing_messages(campaign_name_a)
|
| 852 |
+
else:
|
| 853 |
+
campaign_name = st.session_state.campaign_config.get("campaign_name", "Campaign")
|
| 854 |
+
archive_existing_messages(campaign_name)
|
| 855 |
+
|
| 856 |
+
# Clear previous output if requested
|
| 857 |
+
if clear_previous:
|
| 858 |
+
data_loader.clear_ui_output()
|
| 859 |
+
|
| 860 |
+
st.markdown("---")
|
| 861 |
+
|
| 862 |
+
if st.session_state.ab_testing_mode:
|
| 863 |
+
# A/B Testing Mode - Parallel Execution
|
| 864 |
+
st.markdown("## 🧪 Running A/B Tests in Parallel")
|
| 865 |
+
st.info("⏳ Generating messages for both experiments in parallel. This may take a few minutes...")
|
| 866 |
+
|
| 867 |
+
# Read configs from session state BEFORE starting threads
|
| 868 |
+
# (session state is not thread-safe when accessed from worker threads)
|
| 869 |
+
config_a = st.session_state.campaign_config_a
|
| 870 |
+
config_b = st.session_state.campaign_config_b
|
| 871 |
+
|
| 872 |
+
# Generate shared timestamp for both experiments (needed for A/B detection)
|
| 873 |
+
shared_timestamp = datetime.now().strftime('%Y%m%d_%H%M')
|
| 874 |
+
|
| 875 |
+
# Store results
|
| 876 |
+
results = {"a": None, "b": None}
|
| 877 |
+
|
| 878 |
+
def run_experiment_a(config, users_df, brand_name, timestamp):
|
| 879 |
+
results["a"] = generate_messages_threaded(
|
| 880 |
+
"A",
|
| 881 |
+
config,
|
| 882 |
+
users_df,
|
| 883 |
+
"a",
|
| 884 |
+
brand_name,
|
| 885 |
+
timestamp
|
| 886 |
+
)
|
| 887 |
+
|
| 888 |
+
def run_experiment_b(config, users_df, brand_name, timestamp):
|
| 889 |
+
results["b"] = generate_messages_threaded(
|
| 890 |
+
"B",
|
| 891 |
+
config,
|
| 892 |
+
users_df,
|
| 893 |
+
"b",
|
| 894 |
+
brand_name,
|
| 895 |
+
timestamp
|
| 896 |
+
)
|
| 897 |
+
|
| 898 |
+
# Start both threads with configs as arguments
|
| 899 |
+
thread_a = threading.Thread(target=run_experiment_a, args=(config_a, sampled_users_df, brand, shared_timestamp))
|
| 900 |
+
thread_b = threading.Thread(target=run_experiment_b, args=(config_b, sampled_users_df, brand, shared_timestamp))
|
| 901 |
+
|
| 902 |
+
thread_a.start()
|
| 903 |
+
thread_b.start()
|
| 904 |
+
|
| 905 |
+
# Show spinner while waiting
|
| 906 |
+
with st.spinner("Waiting for both experiments to complete..."):
|
| 907 |
+
thread_a.join()
|
| 908 |
+
thread_b.join()
|
| 909 |
+
|
| 910 |
+
st.markdown("---")
|
| 911 |
+
|
| 912 |
+
# Display results
|
| 913 |
+
st.markdown("## 📊 A/B Test Results")
|
| 914 |
+
|
| 915 |
+
col_a_result, col_b_result = st.columns(2)
|
| 916 |
+
|
| 917 |
+
# Experiment A Results
|
| 918 |
+
with col_a_result:
|
| 919 |
+
st.markdown("### 🅰️ Experiment A")
|
| 920 |
+
if results["a"]:
|
| 921 |
+
result_a = results["a"]
|
| 922 |
+
if result_a['error']:
|
| 923 |
+
st.error(f"❌ Error: {result_a['error']}")
|
| 924 |
+
elif result_a['success']:
|
| 925 |
+
st.success(f"✅ Generated {result_a['total_messages']} messages")
|
| 926 |
+
st.metric("Stages Completed", f"{result_a['stages_completed']}")
|
| 927 |
+
|
| 928 |
+
# Show stage details
|
| 929 |
+
with st.expander("View Stage Details"):
|
| 930 |
+
for stage_detail in result_a['stage_details']:
|
| 931 |
+
if stage_detail['status'] == 'success':
|
| 932 |
+
st.success(f"Stage {stage_detail['stage']}: {stage_detail['message']}")
|
| 933 |
+
elif stage_detail['status'] == 'warning':
|
| 934 |
+
st.warning(f"Stage {stage_detail['stage']}: {stage_detail['message']}")
|
| 935 |
+
elif stage_detail['status'] == 'error':
|
| 936 |
+
st.error(f"Stage {stage_detail['stage']}: {stage_detail['message']}")
|
| 937 |
+
else:
|
| 938 |
+
st.warning(f"⚠️ Generation completed with issues")
|
| 939 |
+
st.write(f"Messages: {result_a['total_messages']}")
|
| 940 |
+
else:
|
| 941 |
+
st.error("❌ No results available")
|
| 942 |
+
|
| 943 |
+
# Experiment B Results
|
| 944 |
+
with col_b_result:
|
| 945 |
+
st.markdown("### 🅱️ Experiment B")
|
| 946 |
+
if results["b"]:
|
| 947 |
+
result_b = results["b"]
|
| 948 |
+
if result_b['error']:
|
| 949 |
+
st.error(f"❌ Error: {result_b['error']}")
|
| 950 |
+
elif result_b['success']:
|
| 951 |
+
st.success(f"✅ Generated {result_b['total_messages']} messages")
|
| 952 |
+
st.metric("Stages Completed", f"{result_b['stages_completed']}")
|
| 953 |
+
|
| 954 |
+
# Show stage details
|
| 955 |
+
with st.expander("View Stage Details"):
|
| 956 |
+
for stage_detail in result_b['stage_details']:
|
| 957 |
+
if stage_detail['status'] == 'success':
|
| 958 |
+
st.success(f"Stage {stage_detail['stage']}: {stage_detail['message']}")
|
| 959 |
+
elif stage_detail['status'] == 'warning':
|
| 960 |
+
st.warning(f"Stage {stage_detail['stage']}: {stage_detail['message']}")
|
| 961 |
+
elif stage_detail['status'] == 'error':
|
| 962 |
+
st.error(f"Stage {stage_detail['stage']}: {stage_detail['message']}")
|
| 963 |
+
else:
|
| 964 |
+
st.warning(f"⚠️ Generation completed with issues")
|
| 965 |
+
st.write(f"Messages: {result_b['total_messages']}")
|
| 966 |
+
else:
|
| 967 |
+
st.error("❌ No results available")
|
| 968 |
+
|
| 969 |
+
# Create experiment record
|
| 970 |
+
experiment_id = f"{brand}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
| 971 |
+
st.session_state.current_experiment_id = experiment_id
|
| 972 |
+
|
| 973 |
+
# Mark generation as complete
|
| 974 |
+
st.session_state.generation_complete = True
|
| 975 |
+
st.session_state.show_next_steps = True
|
| 976 |
+
|
| 977 |
+
else:
|
| 978 |
+
# Single Mode
|
| 979 |
+
success, total = generate_messages_for_experiment(
|
| 980 |
+
"Single",
|
| 981 |
+
st.session_state.campaign_config,
|
| 982 |
+
sampled_users_df,
|
| 983 |
+
output_suffix=None,
|
| 984 |
+
progress_container=st.container()
|
| 985 |
+
)
|
| 986 |
+
|
| 987 |
+
st.markdown("---")
|
| 988 |
+
|
| 989 |
+
if success:
|
| 990 |
+
st.success(f"✅ Generation complete! Generated {total} total messages.")
|
| 991 |
+
else:
|
| 992 |
+
st.warning("⚠️ Generation completed with some errors. Please check the output above.")
|
| 993 |
+
|
| 994 |
+
# Create experiment record
|
| 995 |
+
experiment_id = f"{brand}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
| 996 |
+
st.session_state.current_experiment_id = experiment_id
|
| 997 |
+
|
| 998 |
+
# Mark generation as complete
|
| 999 |
+
st.session_state.generation_complete = True
|
| 1000 |
+
st.session_state.show_next_steps = True
|
| 1001 |
+
|
| 1002 |
+
# Show next steps outside the generation button block (persists across reruns)
|
| 1003 |
+
if st.session_state.get("show_next_steps", False):
|
| 1004 |
+
st.markdown("---")
|
| 1005 |
+
st.subheader("📋 Next Steps")
|
| 1006 |
+
|
| 1007 |
+
col1, col2 = st.columns(2)
|
| 1008 |
+
|
| 1009 |
+
with col1:
|
| 1010 |
+
if st.button("👀 View Messages", use_container_width=True, key="view_messages_after_gen"):
|
| 1011 |
+
st.switch_page("pages/2_Message_Viewer.py")
|
| 1012 |
+
|
| 1013 |
+
with col2:
|
| 1014 |
+
if st.button("📊 View Analytics", use_container_width=True, key="view_analytics_after_gen"):
|
| 1015 |
+
st.switch_page("pages/4_Analytics.py")
|
| 1016 |
+
|
| 1017 |
+
# Show existing messages if any
|
| 1018 |
+
existing_messages = data_loader.load_generated_messages()
|
| 1019 |
+
|
| 1020 |
+
if existing_messages is not None and len(existing_messages) > 0:
|
| 1021 |
+
st.markdown("---")
|
| 1022 |
+
st.subheader("📨 Existing Messages")
|
| 1023 |
+
|
| 1024 |
+
stats = data_loader.get_message_stats()
|
| 1025 |
+
|
| 1026 |
+
col1, col2, col3 = st.columns(3)
|
| 1027 |
+
|
| 1028 |
+
with col1:
|
| 1029 |
+
st.metric("Total Messages", stats['total_messages'])
|
| 1030 |
+
|
| 1031 |
+
with col2:
|
| 1032 |
+
st.metric("Total Users", stats['total_users'])
|
| 1033 |
+
|
| 1034 |
+
with col3:
|
| 1035 |
+
st.metric("Total Stages", stats['total_stages'])
|
| 1036 |
+
|
| 1037 |
+
if st.button("🗑️ Clear All Messages", use_container_width=False):
|
| 1038 |
+
data_loader.clear_ui_output()
|
| 1039 |
+
st.success("✅ All messages cleared")
|
| 1040 |
+
st.rerun()
|
| 1041 |
+
|
| 1042 |
+
st.markdown("---")
|
| 1043 |
+
st.markdown("**💡 Tip:** Use A/B testing to compare different configurations with the same users!")
|
pages/2_Message_Viewer.py
CHANGED
|
@@ -1,24 +1,812 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
|
|
|
| 4 |
"""
|
| 5 |
|
|
|
|
| 6 |
import sys
|
| 7 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
visualization_dir = root_dir / "visualization"
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
|
| 18 |
-
#
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Page 2: Message Viewer
|
| 3 |
+
Browse, search, and evaluate generated messages with feedback system.
|
| 4 |
+
Supports A/B testing mode for comparing two experiments side-by-side.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
import streamlit as st
|
| 8 |
import sys
|
| 9 |
from pathlib import Path
|
| 10 |
+
import json
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import re
|
| 13 |
+
from datetime import datetime
|
| 14 |
|
| 15 |
+
# Add root directory to path
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
| 17 |
|
| 18 |
+
from utils.auth import check_authentication
|
| 19 |
+
from utils.theme import apply_theme, get_brand_emoji, create_badge
|
| 20 |
+
from utils.data_loader import DataLoader
|
| 21 |
+
from utils.feedback_manager import FeedbackManager
|
| 22 |
|
| 23 |
+
# Page configuration
|
| 24 |
+
st.set_page_config(
|
| 25 |
+
page_title="Message Viewer",
|
| 26 |
+
page_icon="👀",
|
| 27 |
+
layout="wide"
|
| 28 |
+
)
|
| 29 |
|
| 30 |
+
# Check authentication
|
| 31 |
+
if not check_authentication():
|
| 32 |
+
st.error("🔒 Please login first")
|
| 33 |
+
st.stop()
|
| 34 |
+
|
| 35 |
+
# Initialize utilities
|
| 36 |
+
data_loader = DataLoader()
|
| 37 |
+
feedback_manager = FeedbackManager()
|
| 38 |
+
|
| 39 |
+
# Check if brand is selected
|
| 40 |
+
if "selected_brand" not in st.session_state or not st.session_state.selected_brand:
|
| 41 |
+
st.error("⚠️ Please select a brand from the home page first")
|
| 42 |
+
st.stop()
|
| 43 |
+
|
| 44 |
+
brand = st.session_state.selected_brand
|
| 45 |
+
apply_theme(brand)
|
| 46 |
+
|
| 47 |
+
# Helper functions for A/B testing
|
| 48 |
+
def detect_ab_testing_files():
|
| 49 |
+
"""
|
| 50 |
+
Detect if there are A/B testing files in the UI output directory.
|
| 51 |
+
Returns (is_ab_mode, messages_a_path, messages_b_path, timestamp)
|
| 52 |
+
"""
|
| 53 |
+
ui_output_path = data_loader.get_ui_output_path()
|
| 54 |
+
|
| 55 |
+
# Check session state first
|
| 56 |
+
if st.session_state.get('ab_testing_mode', False):
|
| 57 |
+
# Look for files with pattern: messages_a_* and messages_b_*
|
| 58 |
+
a_files = list(ui_output_path.glob('messages_a_*.csv'))
|
| 59 |
+
b_files = list(ui_output_path.glob('messages_b_*.csv'))
|
| 60 |
+
|
| 61 |
+
if a_files and b_files:
|
| 62 |
+
# Find matching pairs by timestamp
|
| 63 |
+
for a_file in a_files:
|
| 64 |
+
# Extract timestamp from filename: messages_a_brand_variant_timestamp.csv
|
| 65 |
+
match_a = re.search(r'messages_a_.*_(\d{8}_\d{4})\.csv', a_file.name)
|
| 66 |
+
if match_a:
|
| 67 |
+
timestamp = match_a.group(1)
|
| 68 |
+
# Look for matching b file
|
| 69 |
+
for b_file in b_files:
|
| 70 |
+
if timestamp in b_file.name:
|
| 71 |
+
return True, a_file, b_file, timestamp
|
| 72 |
+
|
| 73 |
+
# Also detect if files exist even if session state not set
|
| 74 |
+
ui_output_path = data_loader.get_ui_output_path()
|
| 75 |
+
a_files = list(ui_output_path.glob('messages_a_*.csv'))
|
| 76 |
+
b_files = list(ui_output_path.glob('messages_b_*.csv'))
|
| 77 |
+
|
| 78 |
+
if a_files and b_files:
|
| 79 |
+
# Auto-detect matching pairs
|
| 80 |
+
for a_file in a_files:
|
| 81 |
+
match_a = re.search(r'messages_a_.*_(\d{8}_\d{4})\.csv', a_file.name)
|
| 82 |
+
if match_a:
|
| 83 |
+
timestamp = match_a.group(1)
|
| 84 |
+
for b_file in b_files:
|
| 85 |
+
if timestamp in b_file.name:
|
| 86 |
+
return True, a_file, b_file, timestamp
|
| 87 |
+
|
| 88 |
+
return False, None, None, None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def load_ab_test_messages(file_path):
|
| 92 |
+
"""Load messages from A/B test file."""
|
| 93 |
+
try:
|
| 94 |
+
return pd.read_csv(file_path, encoding='utf-8-sig')
|
| 95 |
+
except Exception as e:
|
| 96 |
+
st.error(f"Error loading A/B test file: {e}")
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def extract_experiment_id_from_filename(file_path):
|
| 101 |
+
"""Extract experiment ID from A/B test filename."""
|
| 102 |
+
# Pattern: messages_a_brand_variant_timestamp.csv or messages_b_brand_variant_timestamp.csv
|
| 103 |
+
# Use the full filename (without .csv) to ensure A and B experiments have different IDs
|
| 104 |
+
filename = file_path.stem # Removes .csv extension automatically
|
| 105 |
+
return filename
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# Page Header
|
| 109 |
+
emoji = get_brand_emoji(brand)
|
| 110 |
+
st.title(f"👀 Message Viewer - {emoji} {brand.title()}")
|
| 111 |
+
st.markdown("**Browse and evaluate generated messages**")
|
| 112 |
+
|
| 113 |
+
# Detect A/B testing mode
|
| 114 |
+
is_ab_mode, messages_a_path, messages_b_path, ab_timestamp = detect_ab_testing_files()
|
| 115 |
+
|
| 116 |
+
if is_ab_mode:
|
| 117 |
+
st.info(f"🔬 **A/B Testing Mode Active** - Comparing two experiments side-by-side (Timestamp: {ab_timestamp})")
|
| 118 |
+
|
| 119 |
+
st.markdown("---")
|
| 120 |
+
|
| 121 |
+
# Load messages based on mode
|
| 122 |
+
if is_ab_mode:
|
| 123 |
+
# Load A/B test messages
|
| 124 |
+
messages_a_df = load_ab_test_messages(messages_a_path)
|
| 125 |
+
messages_b_df = load_ab_test_messages(messages_b_path)
|
| 126 |
+
|
| 127 |
+
if messages_a_df is None or len(messages_a_df) == 0 or messages_b_df is None or len(messages_b_df) == 0:
|
| 128 |
+
st.warning("⚠️ Error loading A/B test messages. Please check the files.")
|
| 129 |
+
if st.button("🏗️ Go to Campaign Builder"):
|
| 130 |
+
st.switch_page("pages/1_Campaign_Builder.py")
|
| 131 |
+
st.stop()
|
| 132 |
+
|
| 133 |
+
# Extract experiment IDs from filenames
|
| 134 |
+
experiment_a_id = extract_experiment_id_from_filename(messages_a_path)
|
| 135 |
+
experiment_b_id = extract_experiment_id_from_filename(messages_b_path)
|
| 136 |
+
|
| 137 |
+
messages_df = None # Not used in AB mode
|
| 138 |
+
else:
|
| 139 |
+
# Load regular messages
|
| 140 |
+
messages_df = data_loader.load_generated_messages()
|
| 141 |
+
|
| 142 |
+
if messages_df is None or len(messages_df) == 0:
|
| 143 |
+
st.warning("⚠️ No messages found. Please generate messages first in Campaign Builder.")
|
| 144 |
+
if st.button("🏗️ Go to Campaign Builder"):
|
| 145 |
+
st.switch_page("pages/1_Campaign_Builder.py")
|
| 146 |
+
st.stop()
|
| 147 |
+
|
| 148 |
+
# Get experiment ID from session state or create one
|
| 149 |
+
if "current_experiment_id" not in st.session_state or not st.session_state.current_experiment_id:
|
| 150 |
+
# Create experiment ID based on campaign name and timestamp
|
| 151 |
+
if 'campaign_name' in messages_df.columns and len(messages_df) > 0:
|
| 152 |
+
campaign_name = messages_df['campaign_name'].iloc[0]
|
| 153 |
+
experiment_id = campaign_name.replace(" ", "_")
|
| 154 |
+
else:
|
| 155 |
+
experiment_id = f"{brand}_experiment"
|
| 156 |
+
|
| 157 |
+
st.session_state.current_experiment_id = experiment_id
|
| 158 |
+
|
| 159 |
+
experiment_id = st.session_state.current_experiment_id
|
| 160 |
+
messages_a_df = None
|
| 161 |
+
messages_b_df = None
|
| 162 |
+
experiment_a_id = None
|
| 163 |
+
experiment_b_id = None
|
| 164 |
+
|
| 165 |
+
# Sidebar - Filters and Settings
|
| 166 |
+
with st.sidebar:
|
| 167 |
+
st.header("🔍 Filters & Settings")
|
| 168 |
+
|
| 169 |
+
# View mode
|
| 170 |
+
if is_ab_mode:
|
| 171 |
+
st.markdown("**View Mode (applies to both experiments)**")
|
| 172 |
+
view_mode = st.radio(
|
| 173 |
+
"View Mode",
|
| 174 |
+
["User-Centric", "Stage-Centric"],
|
| 175 |
+
help="User-Centric: All stages for each user\nStage-Centric: All users for each stage"
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
st.markdown("---")
|
| 179 |
+
|
| 180 |
+
# Stage filter
|
| 181 |
+
if is_ab_mode:
|
| 182 |
+
# Get stages from both dataframes
|
| 183 |
+
stages_a = sorted(messages_a_df['stage'].unique()) if 'stage' in messages_a_df.columns else []
|
| 184 |
+
stages_b = sorted(messages_b_df['stage'].unique()) if 'stage' in messages_b_df.columns else []
|
| 185 |
+
available_stages = sorted(list(set(stages_a + stages_b)))
|
| 186 |
+
else:
|
| 187 |
+
available_stages = sorted(messages_df['stage'].unique()) if 'stage' in messages_df.columns else []
|
| 188 |
+
|
| 189 |
+
selected_stages = st.multiselect(
|
| 190 |
+
"Filter by Stage",
|
| 191 |
+
available_stages,
|
| 192 |
+
default=available_stages,
|
| 193 |
+
help="Select specific stages to view" + (" (applies to both experiments)" if is_ab_mode else "")
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
# Search
|
| 197 |
+
search_term = st.text_input(
|
| 198 |
+
"🔎 Search Messages",
|
| 199 |
+
placeholder="Enter keyword...",
|
| 200 |
+
help="Search for specific keywords in messages" + (" (applies to both experiments)" if is_ab_mode else "")
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
st.markdown("---")
|
| 204 |
+
|
| 205 |
+
# Display settings
|
| 206 |
+
st.subheader("⚙️ Display Settings")
|
| 207 |
+
|
| 208 |
+
show_feedback = st.checkbox(
|
| 209 |
+
"Show Feedback Options",
|
| 210 |
+
value=True,
|
| 211 |
+
help="Show reject button and feedback form"
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
messages_per_page = st.number_input(
|
| 215 |
+
"Messages per Page",
|
| 216 |
+
min_value=5,
|
| 217 |
+
max_value=100,
|
| 218 |
+
value=20,
|
| 219 |
+
help="Number of messages to display per page"
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# Filter messages
|
| 223 |
+
if is_ab_mode:
|
| 224 |
+
# Filter both experiments
|
| 225 |
+
filtered_messages_a = messages_a_df.copy()
|
| 226 |
+
filtered_messages_b = messages_b_df.copy()
|
| 227 |
+
|
| 228 |
+
if selected_stages:
|
| 229 |
+
filtered_messages_a = filtered_messages_a[filtered_messages_a['stage'].isin(selected_stages)]
|
| 230 |
+
filtered_messages_b = filtered_messages_b[filtered_messages_b['stage'].isin(selected_stages)]
|
| 231 |
+
|
| 232 |
+
if search_term:
|
| 233 |
+
filtered_messages_a = data_loader.search_messages(filtered_messages_a, search_term)
|
| 234 |
+
filtered_messages_b = data_loader.search_messages(filtered_messages_b, search_term)
|
| 235 |
+
|
| 236 |
+
filtered_messages = None # Not used in AB mode
|
| 237 |
+
else:
|
| 238 |
+
filtered_messages = messages_df.copy()
|
| 239 |
+
|
| 240 |
+
if selected_stages:
|
| 241 |
+
filtered_messages = filtered_messages[filtered_messages['stage'].isin(selected_stages)]
|
| 242 |
+
|
| 243 |
+
if search_term:
|
| 244 |
+
filtered_messages = data_loader.search_messages(filtered_messages, search_term)
|
| 245 |
+
|
| 246 |
+
filtered_messages_a = None
|
| 247 |
+
filtered_messages_b = None
|
| 248 |
+
|
| 249 |
+
# Summary stats
|
| 250 |
+
st.subheader("📊 Summary")
|
| 251 |
+
|
| 252 |
+
if is_ab_mode:
|
| 253 |
+
# Show stats for both experiments
|
| 254 |
+
st.markdown("##### Experiment A vs Experiment B")
|
| 255 |
+
|
| 256 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 257 |
+
|
| 258 |
+
with col1:
|
| 259 |
+
st.metric("Total Messages (A)", len(filtered_messages_a))
|
| 260 |
+
st.metric("Total Messages (B)", len(filtered_messages_b))
|
| 261 |
+
|
| 262 |
+
with col2:
|
| 263 |
+
unique_users_a = filtered_messages_a['user_id'].nunique() if 'user_id' in filtered_messages_a.columns else 0
|
| 264 |
+
unique_users_b = filtered_messages_b['user_id'].nunique() if 'user_id' in filtered_messages_b.columns else 0
|
| 265 |
+
st.metric("Unique Users (A)", unique_users_a)
|
| 266 |
+
st.metric("Unique Users (B)", unique_users_b)
|
| 267 |
+
|
| 268 |
+
with col3:
|
| 269 |
+
unique_stages_a = filtered_messages_a['stage'].nunique() if 'stage' in filtered_messages_a.columns else 0
|
| 270 |
+
unique_stages_b = filtered_messages_b['stage'].nunique() if 'stage' in filtered_messages_b.columns else 0
|
| 271 |
+
st.metric("Stages (A)", unique_stages_a)
|
| 272 |
+
st.metric("Stages (B)", unique_stages_b)
|
| 273 |
+
|
| 274 |
+
with col4:
|
| 275 |
+
# Load feedback stats for both experiments
|
| 276 |
+
feedback_a_df = feedback_manager.load_feedback(experiment_a_id)
|
| 277 |
+
feedback_b_df = feedback_manager.load_feedback(experiment_b_id)
|
| 278 |
+
|
| 279 |
+
reject_count_a = 0
|
| 280 |
+
if feedback_a_df is not None and len(feedback_a_df) > 0:
|
| 281 |
+
reject_count_a = len(feedback_a_df[feedback_a_df['feedback_type'] == 'reject'])
|
| 282 |
+
|
| 283 |
+
reject_count_b = 0
|
| 284 |
+
if feedback_b_df is not None and len(feedback_b_df) > 0:
|
| 285 |
+
reject_count_b = len(feedback_b_df[feedback_b_df['feedback_type'] == 'reject'])
|
| 286 |
+
|
| 287 |
+
st.metric("Rejected (A)", reject_count_a)
|
| 288 |
+
st.metric("Rejected (B)", reject_count_b)
|
| 289 |
+
else:
|
| 290 |
+
# Single experiment stats
|
| 291 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 292 |
+
|
| 293 |
+
with col1:
|
| 294 |
+
st.metric("Total Messages", len(filtered_messages))
|
| 295 |
+
|
| 296 |
+
with col2:
|
| 297 |
+
unique_users = filtered_messages['user_id'].nunique() if 'user_id' in filtered_messages.columns else 0
|
| 298 |
+
st.metric("Unique Users", unique_users)
|
| 299 |
+
|
| 300 |
+
with col3:
|
| 301 |
+
unique_stages = filtered_messages['stage'].nunique() if 'stage' in filtered_messages.columns else 0
|
| 302 |
+
st.metric("Stages", unique_stages)
|
| 303 |
+
|
| 304 |
+
with col4:
|
| 305 |
+
# Load feedback stats
|
| 306 |
+
feedback_df = feedback_manager.load_feedback(experiment_id)
|
| 307 |
+
if feedback_df is not None and len(feedback_df) > 0:
|
| 308 |
+
reject_count = len(feedback_df[feedback_df['feedback_type'] == 'reject'])
|
| 309 |
+
st.metric("Rejected", reject_count)
|
| 310 |
+
else:
|
| 311 |
+
st.metric("Rejected", 0)
|
| 312 |
+
|
| 313 |
+
st.markdown("---")
|
| 314 |
+
|
| 315 |
+
# Helper function to parse message JSON
|
| 316 |
+
def parse_message_content(message_str):
|
| 317 |
+
"""Parse message JSON string and extract content."""
|
| 318 |
+
try:
|
| 319 |
+
if isinstance(message_str, str):
|
| 320 |
+
message_data = json.loads(message_str)
|
| 321 |
+
elif isinstance(message_str, dict):
|
| 322 |
+
message_data = message_str
|
| 323 |
+
else:
|
| 324 |
+
return None
|
| 325 |
+
|
| 326 |
+
# Handle different message formats
|
| 327 |
+
if isinstance(message_data, dict):
|
| 328 |
+
# Check for stage-keyed format: {"1": {"header": ..., "message": ...}}
|
| 329 |
+
if any(k.isdigit() for k in message_data.keys()):
|
| 330 |
+
# Extract messages from all stages
|
| 331 |
+
messages = []
|
| 332 |
+
for key in sorted(message_data.keys(), key=lambda x: int(x) if x.isdigit() else 0):
|
| 333 |
+
if isinstance(message_data[key], dict):
|
| 334 |
+
messages.append(message_data[key])
|
| 335 |
+
return messages
|
| 336 |
+
else:
|
| 337 |
+
# Single message format
|
| 338 |
+
return [message_data]
|
| 339 |
+
elif isinstance(message_data, list):
|
| 340 |
+
return message_data
|
| 341 |
+
|
| 342 |
+
except (json.JSONDecodeError, TypeError):
|
| 343 |
+
return None
|
| 344 |
+
|
| 345 |
+
return None
|
| 346 |
+
|
| 347 |
+
# Helper function to parse and display recommendation
|
| 348 |
+
def parse_recommendation(rec_str):
|
| 349 |
+
"""Parse recommendation JSON string and extract details."""
|
| 350 |
+
try:
|
| 351 |
+
if isinstance(rec_str, str):
|
| 352 |
+
rec_data = json.loads(rec_str)
|
| 353 |
+
elif isinstance(rec_str, dict):
|
| 354 |
+
rec_data = rec_str
|
| 355 |
+
else:
|
| 356 |
+
return None
|
| 357 |
+
|
| 358 |
+
if isinstance(rec_data, dict):
|
| 359 |
+
return rec_data
|
| 360 |
+
|
| 361 |
+
except (json.JSONDecodeError, TypeError):
|
| 362 |
+
return None
|
| 363 |
+
|
| 364 |
+
return None
|
| 365 |
+
|
| 366 |
+
def display_recommendation(recommendation):
|
| 367 |
+
"""Display recommendation with thumbnail and link."""
|
| 368 |
+
if not recommendation:
|
| 369 |
+
return
|
| 370 |
+
|
| 371 |
+
rec_data = parse_recommendation(recommendation)
|
| 372 |
+
|
| 373 |
+
if rec_data:
|
| 374 |
+
# Extract recommendation details
|
| 375 |
+
title = rec_data.get('title', 'Recommended Content')
|
| 376 |
+
web_url = rec_data.get('web_url_path', '#')
|
| 377 |
+
thumbnail_url = rec_data.get('thumbnail_url', '')
|
| 378 |
+
|
| 379 |
+
# Create a clickable card with thumbnail
|
| 380 |
+
st.markdown("**📍 Recommended Content:**")
|
| 381 |
+
|
| 382 |
+
if thumbnail_url:
|
| 383 |
+
# Display thumbnail as clickable image
|
| 384 |
+
col1, col2 = st.columns([1, 2])
|
| 385 |
+
|
| 386 |
+
with col1:
|
| 387 |
+
st.image(thumbnail_url, use_container_width=True)
|
| 388 |
+
|
| 389 |
+
with col2:
|
| 390 |
+
st.markdown(f"**{title}**")
|
| 391 |
+
if web_url and web_url != '#':
|
| 392 |
+
st.markdown(f"[🔗 View Content]({web_url})")
|
| 393 |
+
else:
|
| 394 |
+
# No thumbnail, just show title and link
|
| 395 |
+
st.markdown(f"**{title}**")
|
| 396 |
+
if web_url and web_url != '#':
|
| 397 |
+
st.markdown(f"[🔗 View Content]({web_url})")
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def display_user_centric_messages(filtered_df, exp_id, key_suffix, show_feedback_option, msgs_per_page):
|
| 401 |
+
"""Display messages in user-centric view for a single experiment."""
|
| 402 |
+
if 'user_id' not in filtered_df.columns:
|
| 403 |
+
st.warning("No user_id column found in messages.")
|
| 404 |
+
return
|
| 405 |
+
|
| 406 |
+
unique_users = filtered_df['user_id'].unique()
|
| 407 |
+
|
| 408 |
+
# Pagination
|
| 409 |
+
total_users = len(unique_users)
|
| 410 |
+
users_per_page = msgs_per_page
|
| 411 |
+
|
| 412 |
+
if total_users > users_per_page:
|
| 413 |
+
page = st.number_input(
|
| 414 |
+
f"Page",
|
| 415 |
+
min_value=1,
|
| 416 |
+
max_value=(total_users // users_per_page) + 1,
|
| 417 |
+
value=1,
|
| 418 |
+
key=f"page_{key_suffix}"
|
| 419 |
+
)
|
| 420 |
+
start_idx = (page - 1) * users_per_page
|
| 421 |
+
end_idx = min(start_idx + users_per_page, total_users)
|
| 422 |
+
users_to_display = unique_users[start_idx:end_idx]
|
| 423 |
+
|
| 424 |
+
st.markdown(f"*Showing users {start_idx + 1}-{end_idx} of {total_users}*")
|
| 425 |
+
else:
|
| 426 |
+
users_to_display = unique_users
|
| 427 |
+
|
| 428 |
+
# Display each user
|
| 429 |
+
for user_id in users_to_display:
|
| 430 |
+
user_messages = filtered_df[filtered_df['user_id'] == user_id].sort_values('stage')
|
| 431 |
+
|
| 432 |
+
if len(user_messages) == 0:
|
| 433 |
+
continue
|
| 434 |
+
|
| 435 |
+
# Get user info from first message
|
| 436 |
+
first_msg = user_messages.iloc[0]
|
| 437 |
+
user_email = first_msg.get('email', 'N/A')
|
| 438 |
+
user_first_name = first_msg.get('first_name', 'N/A')
|
| 439 |
+
|
| 440 |
+
# User expander
|
| 441 |
+
with st.expander(f"👤 User {user_id} - {user_first_name} ({user_email})", expanded=False):
|
| 442 |
+
# User profile
|
| 443 |
+
st.markdown("##### 📋 User Profile")
|
| 444 |
+
|
| 445 |
+
profile_cols = st.columns(4)
|
| 446 |
+
profile_fields = ['first_name', 'country', 'instrument', 'subscription_status']
|
| 447 |
+
|
| 448 |
+
for idx, field in enumerate(profile_fields):
|
| 449 |
+
if field in first_msg:
|
| 450 |
+
profile_cols[idx % 4].markdown(f"**{field.replace('_', ' ').title()}:** {first_msg[field]}")
|
| 451 |
+
|
| 452 |
+
st.markdown("---")
|
| 453 |
+
|
| 454 |
+
# Display all stages for this user
|
| 455 |
+
st.markdown("##### 📨 Messages Across All Stages")
|
| 456 |
+
|
| 457 |
+
for _, row in user_messages.iterrows():
|
| 458 |
+
stage = row['stage']
|
| 459 |
+
|
| 460 |
+
# Parse message content
|
| 461 |
+
message_content = parse_message_content(row.get('message', ''))
|
| 462 |
+
|
| 463 |
+
if message_content:
|
| 464 |
+
# Find the message for this stage
|
| 465 |
+
stage_message = None
|
| 466 |
+
for msg in message_content:
|
| 467 |
+
if isinstance(msg, dict):
|
| 468 |
+
stage_message = msg
|
| 469 |
+
break
|
| 470 |
+
|
| 471 |
+
if stage_message:
|
| 472 |
+
# Create message card
|
| 473 |
+
st.markdown(f"**Stage {stage}**")
|
| 474 |
+
|
| 475 |
+
msg_col1, msg_col2 = st.columns([4, 1])
|
| 476 |
+
|
| 477 |
+
with msg_col1:
|
| 478 |
+
header = stage_message.get('header', 'No header')
|
| 479 |
+
message_text = stage_message.get('message', 'No message')
|
| 480 |
+
|
| 481 |
+
st.markdown(f"**📧 Header:** {header}")
|
| 482 |
+
st.markdown(f"**💬 Message:** {message_text}")
|
| 483 |
+
|
| 484 |
+
# Show recommendation if available
|
| 485 |
+
if 'thumbnail_url' in stage_message or 'web_url_path' in stage_message:
|
| 486 |
+
rec_data = {
|
| 487 |
+
'title': stage_message.get('title', 'Recommended Content'),
|
| 488 |
+
'web_url_path': stage_message.get('web_url_path', '#'),
|
| 489 |
+
'thumbnail_url': stage_message.get('thumbnail_url', '')
|
| 490 |
+
}
|
| 491 |
+
display_recommendation(rec_data)
|
| 492 |
+
elif 'recommendation' in row and pd.notna(row['recommendation']):
|
| 493 |
+
display_recommendation(row['recommendation'])
|
| 494 |
+
|
| 495 |
+
with msg_col2:
|
| 496 |
+
# Feedback section
|
| 497 |
+
if show_feedback_option:
|
| 498 |
+
existing_feedback = feedback_manager.get_feedback_for_message(
|
| 499 |
+
exp_id, user_id, stage
|
| 500 |
+
)
|
| 501 |
+
|
| 502 |
+
if existing_feedback:
|
| 503 |
+
st.error("❌ Rejected")
|
| 504 |
+
if st.button(f"Undo", key=f"undo_{user_id}_{stage}_{key_suffix}"):
|
| 505 |
+
feedback_manager.delete_feedback(exp_id, user_id, stage)
|
| 506 |
+
st.rerun()
|
| 507 |
+
else:
|
| 508 |
+
if st.button("🚫 Reject", key=f"reject_{user_id}_{stage}_{key_suffix}"):
|
| 509 |
+
st.session_state[f"show_feedback_form_{user_id}_{stage}_{key_suffix}"] = True
|
| 510 |
+
st.rerun()
|
| 511 |
+
|
| 512 |
+
# Feedback form
|
| 513 |
+
if st.session_state.get(f"show_feedback_form_{user_id}_{stage}_{key_suffix}", False):
|
| 514 |
+
with st.form(f"feedback_form_{user_id}_{stage}_{key_suffix}"):
|
| 515 |
+
st.markdown("**Why is this message rejected?**")
|
| 516 |
+
|
| 517 |
+
rejection_reasons = feedback_manager.get_all_rejection_reasons()
|
| 518 |
+
selected_reason = st.selectbox(
|
| 519 |
+
"Rejection Reason",
|
| 520 |
+
list(rejection_reasons.keys()),
|
| 521 |
+
format_func=lambda x: rejection_reasons[x],
|
| 522 |
+
key=f"reason_{user_id}_{stage}_{key_suffix}"
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
rejection_text = st.text_area(
|
| 526 |
+
"Additional Comments (Optional)",
|
| 527 |
+
key=f"text_{user_id}_{stage}_{key_suffix}"
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
col1, col2 = st.columns(2)
|
| 531 |
+
|
| 532 |
+
with col1:
|
| 533 |
+
if st.form_submit_button("💾 Save", use_container_width=True):
|
| 534 |
+
feedback_manager.save_feedback(
|
| 535 |
+
experiment_id=exp_id,
|
| 536 |
+
user_id=user_id,
|
| 537 |
+
stage=stage,
|
| 538 |
+
feedback_type="reject",
|
| 539 |
+
rejection_reason=selected_reason,
|
| 540 |
+
rejection_text=rejection_text,
|
| 541 |
+
campaign_name=row.get('campaign_name'),
|
| 542 |
+
message_header=stage_message.get('header', 'N/A'),
|
| 543 |
+
message_body=stage_message.get('message', 'N/A')
|
| 544 |
+
)
|
| 545 |
+
st.session_state[f"show_feedback_form_{user_id}_{stage}_{key_suffix}"] = False
|
| 546 |
+
st.success("✅ Feedback saved")
|
| 547 |
+
st.rerun()
|
| 548 |
+
|
| 549 |
+
with col2:
|
| 550 |
+
if st.form_submit_button("❌ Cancel", use_container_width=True):
|
| 551 |
+
st.session_state[f"show_feedback_form_{user_id}_{stage}_{key_suffix}"] = False
|
| 552 |
+
st.rerun()
|
| 553 |
+
|
| 554 |
+
st.markdown("---")
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def display_stage_centric_messages(filtered_df, exp_id, key_suffix, show_feedback_option, msgs_per_page, stages_to_display):
|
| 558 |
+
"""Display messages in stage-centric view for a single experiment."""
|
| 559 |
+
# Group by stage
|
| 560 |
+
for stage in sorted(stages_to_display if stages_to_display else filtered_df['stage'].unique()):
|
| 561 |
+
stage_messages = filtered_df[filtered_df['stage'] == stage]
|
| 562 |
+
|
| 563 |
+
if len(stage_messages) == 0:
|
| 564 |
+
continue
|
| 565 |
+
|
| 566 |
+
with st.expander(f"📨 Stage {stage} - {len(stage_messages)} messages", expanded=(stage == 1)):
|
| 567 |
+
st.markdown(f"##### Stage {stage} Messages")
|
| 568 |
+
|
| 569 |
+
# Pagination for this stage
|
| 570 |
+
total_stage_messages = len(stage_messages)
|
| 571 |
+
stage_messages_per_page = msgs_per_page
|
| 572 |
+
|
| 573 |
+
if total_stage_messages > stage_messages_per_page:
|
| 574 |
+
page = st.number_input(
|
| 575 |
+
f"Page for Stage {stage}",
|
| 576 |
+
min_value=1,
|
| 577 |
+
max_value=(total_stage_messages // stage_messages_per_page) + 1,
|
| 578 |
+
value=1,
|
| 579 |
+
key=f"page_stage_{stage}_{key_suffix}"
|
| 580 |
+
)
|
| 581 |
+
start_idx = (page - 1) * stage_messages_per_page
|
| 582 |
+
end_idx = min(start_idx + stage_messages_per_page, total_stage_messages)
|
| 583 |
+
messages_to_display = stage_messages.iloc[start_idx:end_idx]
|
| 584 |
+
|
| 585 |
+
st.markdown(f"*Showing messages {start_idx + 1}-{end_idx} of {total_stage_messages}*")
|
| 586 |
+
else:
|
| 587 |
+
messages_to_display = stage_messages
|
| 588 |
+
|
| 589 |
+
# Display messages
|
| 590 |
+
for idx, row in messages_to_display.iterrows():
|
| 591 |
+
user_id = row.get('user_id', 'N/A')
|
| 592 |
+
user_email = row.get('email', 'N/A')
|
| 593 |
+
user_name = row.get('first_name', 'N/A')
|
| 594 |
+
|
| 595 |
+
# Parse message content
|
| 596 |
+
message_content = parse_message_content(row.get('message', ''))
|
| 597 |
+
|
| 598 |
+
if message_content:
|
| 599 |
+
stage_message = None
|
| 600 |
+
for msg in message_content:
|
| 601 |
+
if isinstance(msg, dict):
|
| 602 |
+
stage_message = msg
|
| 603 |
+
break
|
| 604 |
+
|
| 605 |
+
if stage_message:
|
| 606 |
+
# Message card
|
| 607 |
+
msg_col1, msg_col2 = st.columns([4, 1])
|
| 608 |
+
|
| 609 |
+
with msg_col1:
|
| 610 |
+
st.markdown(f"**User:** {user_name} ({user_email}) - ID: {user_id}")
|
| 611 |
+
|
| 612 |
+
header = stage_message.get('header', 'No header')
|
| 613 |
+
message_text = stage_message.get('message', 'No message')
|
| 614 |
+
|
| 615 |
+
st.markdown(f"**📧 Header:** {header}")
|
| 616 |
+
st.markdown(f"**💬 Message:** {message_text}")
|
| 617 |
+
|
| 618 |
+
# Show recommendation if available
|
| 619 |
+
if 'thumbnail_url' in stage_message or 'web_url_path' in stage_message:
|
| 620 |
+
rec_data = {
|
| 621 |
+
'title': stage_message.get('title', 'Recommended Content'),
|
| 622 |
+
'web_url_path': stage_message.get('web_url_path', '#'),
|
| 623 |
+
'thumbnail_url': stage_message.get('thumbnail_url', '')
|
| 624 |
+
}
|
| 625 |
+
display_recommendation(rec_data)
|
| 626 |
+
elif 'recommendation' in row and pd.notna(row['recommendation']):
|
| 627 |
+
display_recommendation(row['recommendation'])
|
| 628 |
+
|
| 629 |
+
with msg_col2:
|
| 630 |
+
# Feedback section
|
| 631 |
+
if show_feedback_option:
|
| 632 |
+
existing_feedback = feedback_manager.get_feedback_for_message(
|
| 633 |
+
exp_id, user_id, stage
|
| 634 |
+
)
|
| 635 |
+
|
| 636 |
+
if existing_feedback:
|
| 637 |
+
st.error("❌ Rejected")
|
| 638 |
+
if st.button(f"Undo", key=f"undo_{user_id}_{stage}_{key_suffix}_sc"):
|
| 639 |
+
feedback_manager.delete_feedback(exp_id, user_id, stage)
|
| 640 |
+
st.rerun()
|
| 641 |
+
else:
|
| 642 |
+
if st.button("🚫 Reject", key=f"reject_{user_id}_{stage}_{key_suffix}_sc"):
|
| 643 |
+
st.session_state[f"show_feedback_form_{user_id}_{stage}_{key_suffix}_sc"] = True
|
| 644 |
+
st.rerun()
|
| 645 |
+
|
| 646 |
+
# Feedback form
|
| 647 |
+
if st.session_state.get(f"show_feedback_form_{user_id}_{stage}_{key_suffix}_sc", False):
|
| 648 |
+
with st.form(f"feedback_form_{user_id}_{stage}_{key_suffix}_sc"):
|
| 649 |
+
st.markdown("**Why is this message rejected?**")
|
| 650 |
+
|
| 651 |
+
rejection_reasons = feedback_manager.get_all_rejection_reasons()
|
| 652 |
+
selected_reason = st.selectbox(
|
| 653 |
+
"Rejection Reason",
|
| 654 |
+
list(rejection_reasons.keys()),
|
| 655 |
+
format_func=lambda x: rejection_reasons[x],
|
| 656 |
+
key=f"reason_{user_id}_{stage}_{key_suffix}_sc"
|
| 657 |
+
)
|
| 658 |
+
|
| 659 |
+
rejection_text = st.text_area(
|
| 660 |
+
"Additional Comments (Optional)",
|
| 661 |
+
key=f"text_{user_id}_{stage}_{key_suffix}_sc"
|
| 662 |
+
)
|
| 663 |
+
|
| 664 |
+
col1, col2 = st.columns(2)
|
| 665 |
+
|
| 666 |
+
with col1:
|
| 667 |
+
if st.form_submit_button("💾 Save", use_container_width=True):
|
| 668 |
+
feedback_manager.save_feedback(
|
| 669 |
+
experiment_id=exp_id,
|
| 670 |
+
user_id=user_id,
|
| 671 |
+
stage=stage,
|
| 672 |
+
feedback_type="reject",
|
| 673 |
+
rejection_reason=selected_reason,
|
| 674 |
+
rejection_text=rejection_text,
|
| 675 |
+
campaign_name=row.get('campaign_name'),
|
| 676 |
+
message_header=stage_message.get('header', 'N/A'),
|
| 677 |
+
message_body=stage_message.get('message', 'N/A')
|
| 678 |
+
)
|
| 679 |
+
st.session_state[f"show_feedback_form_{user_id}_{stage}_{key_suffix}_sc"] = False
|
| 680 |
+
st.success("✅ Feedback saved")
|
| 681 |
+
st.rerun()
|
| 682 |
+
|
| 683 |
+
with col2:
|
| 684 |
+
if st.form_submit_button("❌ Cancel", use_container_width=True):
|
| 685 |
+
st.session_state[f"show_feedback_form_{user_id}_{stage}_{key_suffix}_sc"] = False
|
| 686 |
+
st.rerun()
|
| 687 |
+
|
| 688 |
+
st.markdown("---")
|
| 689 |
+
|
| 690 |
+
# Display messages based on mode and view
|
| 691 |
+
if is_ab_mode:
|
| 692 |
+
# A/B Testing Mode - Show both experiments side by side
|
| 693 |
+
if view_mode == "User-Centric":
|
| 694 |
+
st.subheader("👤 User-Centric View")
|
| 695 |
+
st.markdown("*All stages for each user - comparing both experiments*")
|
| 696 |
+
else:
|
| 697 |
+
st.subheader("📊 Stage-Centric View")
|
| 698 |
+
st.markdown("*All users for each stage - comparing both experiments*")
|
| 699 |
+
|
| 700 |
+
# Create two columns for A/B comparison
|
| 701 |
+
col_a, col_b = st.columns(2)
|
| 702 |
+
|
| 703 |
+
with col_a:
|
| 704 |
+
st.markdown("### 🅰️ Experiment A")
|
| 705 |
+
if view_mode == "User-Centric":
|
| 706 |
+
display_user_centric_messages(
|
| 707 |
+
filtered_messages_a,
|
| 708 |
+
experiment_a_id,
|
| 709 |
+
"exp_a",
|
| 710 |
+
show_feedback,
|
| 711 |
+
messages_per_page
|
| 712 |
+
)
|
| 713 |
+
else:
|
| 714 |
+
display_stage_centric_messages(
|
| 715 |
+
filtered_messages_a,
|
| 716 |
+
experiment_a_id,
|
| 717 |
+
"exp_a",
|
| 718 |
+
show_feedback,
|
| 719 |
+
messages_per_page,
|
| 720 |
+
selected_stages
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
with col_b:
|
| 724 |
+
st.markdown("### 🅱️ Experiment B")
|
| 725 |
+
if view_mode == "User-Centric":
|
| 726 |
+
display_user_centric_messages(
|
| 727 |
+
filtered_messages_b,
|
| 728 |
+
experiment_b_id,
|
| 729 |
+
"exp_b",
|
| 730 |
+
show_feedback,
|
| 731 |
+
messages_per_page
|
| 732 |
+
)
|
| 733 |
+
else:
|
| 734 |
+
display_stage_centric_messages(
|
| 735 |
+
filtered_messages_b,
|
| 736 |
+
experiment_b_id,
|
| 737 |
+
"exp_b",
|
| 738 |
+
show_feedback,
|
| 739 |
+
messages_per_page,
|
| 740 |
+
selected_stages
|
| 741 |
+
)
|
| 742 |
+
|
| 743 |
+
else:
|
| 744 |
+
# Single Experiment Mode - Original behavior
|
| 745 |
+
if view_mode == "User-Centric":
|
| 746 |
+
st.subheader("👤 User-Centric View")
|
| 747 |
+
st.markdown("*All stages for each user*")
|
| 748 |
+
display_user_centric_messages(
|
| 749 |
+
filtered_messages,
|
| 750 |
+
experiment_id,
|
| 751 |
+
"single",
|
| 752 |
+
show_feedback,
|
| 753 |
+
messages_per_page
|
| 754 |
+
)
|
| 755 |
+
else:
|
| 756 |
+
st.subheader("📊 Stage-Centric View")
|
| 757 |
+
st.markdown("*All users for each stage*")
|
| 758 |
+
display_stage_centric_messages(
|
| 759 |
+
filtered_messages,
|
| 760 |
+
experiment_id,
|
| 761 |
+
"single",
|
| 762 |
+
show_feedback,
|
| 763 |
+
messages_per_page,
|
| 764 |
+
selected_stages
|
| 765 |
+
)
|
| 766 |
+
|
| 767 |
+
st.markdown("---")
|
| 768 |
+
|
| 769 |
+
# Quick actions
|
| 770 |
+
col1, col2, col3 = st.columns(3)
|
| 771 |
+
|
| 772 |
+
with col1:
|
| 773 |
+
if st.button("🏗️ Back to Campaign Builder", use_container_width=True):
|
| 774 |
+
st.switch_page("pages/1_Campaign_Builder.py")
|
| 775 |
+
|
| 776 |
+
with col2:
|
| 777 |
+
if st.button("📊 View Analytics", use_container_width=True):
|
| 778 |
+
st.switch_page("pages/4_Analytics.py")
|
| 779 |
+
|
| 780 |
+
with col3:
|
| 781 |
+
# Export messages
|
| 782 |
+
if st.button("💾 Export Messages", use_container_width=True):
|
| 783 |
+
if is_ab_mode:
|
| 784 |
+
# In AB mode, offer to export both experiments
|
| 785 |
+
st.markdown("**Export Options:**")
|
| 786 |
+
csv_a = filtered_messages_a.to_csv(index=False, encoding='utf-8')
|
| 787 |
+
csv_b = filtered_messages_b.to_csv(index=False, encoding='utf-8')
|
| 788 |
+
|
| 789 |
+
st.download_button(
|
| 790 |
+
label="⬇️ Download Experiment A CSV",
|
| 791 |
+
data=csv_a,
|
| 792 |
+
file_name=f"{brand}_messages_experiment_a_{ab_timestamp}.csv",
|
| 793 |
+
mime="text/csv",
|
| 794 |
+
use_container_width=True
|
| 795 |
+
)
|
| 796 |
+
|
| 797 |
+
st.download_button(
|
| 798 |
+
label="⬇️ Download Experiment B CSV",
|
| 799 |
+
data=csv_b,
|
| 800 |
+
file_name=f"{brand}_messages_experiment_b_{ab_timestamp}.csv",
|
| 801 |
+
mime="text/csv",
|
| 802 |
+
use_container_width=True
|
| 803 |
+
)
|
| 804 |
+
else:
|
| 805 |
+
csv = filtered_messages.to_csv(index=False, encoding='utf-8')
|
| 806 |
+
st.download_button(
|
| 807 |
+
label="⬇️ Download CSV",
|
| 808 |
+
data=csv,
|
| 809 |
+
file_name=f"{brand}_messages_{experiment_id}.csv",
|
| 810 |
+
mime="text/csv",
|
| 811 |
+
use_container_width=True
|
| 812 |
+
)
|
pages/4_Analytics.py
CHANGED
|
@@ -1,24 +1,1070 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
|
|
|
| 4 |
"""
|
| 5 |
|
|
|
|
| 6 |
import sys
|
| 7 |
from pathlib import Path
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
sys.path.insert(0, str(root_dir))
|
| 15 |
-
sys.path.insert(0, str(root_dir / "ai_messaging_system_v2"))
|
| 16 |
-
sys.path.insert(0, str(visualization_dir))
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Page 4: Analytics Dashboard
|
| 3 |
+
Visualize performance metrics and insights from generated messages and feedback.
|
| 4 |
+
Supports A/B testing mode for comparing two experiments side-by-side.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
import streamlit as st
|
| 8 |
import sys
|
| 9 |
from pathlib import Path
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import re
|
| 12 |
|
| 13 |
+
# Try to import plotly with helpful error message
|
| 14 |
+
try:
|
| 15 |
+
import plotly.express as px
|
| 16 |
+
import plotly.graph_objects as go
|
| 17 |
+
except ModuleNotFoundError:
|
| 18 |
+
st.error("""
|
| 19 |
+
⚠️ **Missing Dependency: plotly**
|
| 20 |
|
| 21 |
+
Plotly is not installed in the current Python environment.
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
**To fix this:**
|
| 24 |
+
1. Make sure you're running Streamlit from the correct Python environment
|
| 25 |
+
2. Install plotly: `pip install plotly>=5.17.0`
|
| 26 |
+
3. Or install all requirements: `pip install -r visualization/requirements.txt`
|
| 27 |
|
| 28 |
+
**Current Python:** {}
|
| 29 |
+
""".format(sys.executable))
|
| 30 |
+
st.stop()
|
| 31 |
+
|
| 32 |
+
# Add root directory to path
|
| 33 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 34 |
+
|
| 35 |
+
from utils.auth import check_authentication
|
| 36 |
+
from utils.theme import apply_theme, get_brand_emoji, get_brand_theme
|
| 37 |
+
from utils.data_loader import DataLoader
|
| 38 |
+
from utils.feedback_manager import FeedbackManager
|
| 39 |
+
|
| 40 |
+
# Page configuration
|
| 41 |
+
st.set_page_config(
|
| 42 |
+
page_title="Analytics Dashboard",
|
| 43 |
+
page_icon="📊",
|
| 44 |
+
layout="wide"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Check authentication
|
| 48 |
+
if not check_authentication():
|
| 49 |
+
st.error("🔒 Please login first")
|
| 50 |
+
st.stop()
|
| 51 |
+
|
| 52 |
+
# Initialize utilities
|
| 53 |
+
data_loader = DataLoader()
|
| 54 |
+
feedback_manager = FeedbackManager()
|
| 55 |
+
|
| 56 |
+
# Check if brand is selected
|
| 57 |
+
if "selected_brand" not in st.session_state or not st.session_state.selected_brand:
|
| 58 |
+
st.error("⚠️ Please select a brand from the home page first")
|
| 59 |
+
st.stop()
|
| 60 |
+
|
| 61 |
+
brand = st.session_state.selected_brand
|
| 62 |
+
apply_theme(brand)
|
| 63 |
+
theme = get_brand_theme(brand)
|
| 64 |
+
|
| 65 |
+
# Helper function to detect A/B testing files
|
| 66 |
+
def detect_ab_testing_files():
|
| 67 |
+
"""
|
| 68 |
+
Detect if there are A/B testing files in the UI output directory.
|
| 69 |
+
Returns (is_ab_mode, messages_a_path, messages_b_path, timestamp)
|
| 70 |
+
"""
|
| 71 |
+
ui_output_path = data_loader.get_ui_output_path()
|
| 72 |
+
|
| 73 |
+
# Check session state first
|
| 74 |
+
if st.session_state.get('ab_testing_mode', False):
|
| 75 |
+
# Look for files with pattern: messages_a_* and messages_b_*
|
| 76 |
+
a_files = list(ui_output_path.glob('messages_a_*.csv'))
|
| 77 |
+
b_files = list(ui_output_path.glob('messages_b_*.csv'))
|
| 78 |
+
|
| 79 |
+
if a_files and b_files:
|
| 80 |
+
# Find matching pairs by timestamp
|
| 81 |
+
for a_file in a_files:
|
| 82 |
+
# Extract timestamp from filename: messages_a_brand_variant_timestamp.csv
|
| 83 |
+
match_a = re.search(r'messages_a_.*_(\d{8}_\d{4})\.csv', a_file.name)
|
| 84 |
+
if match_a:
|
| 85 |
+
timestamp = match_a.group(1)
|
| 86 |
+
# Look for matching b file
|
| 87 |
+
for b_file in b_files:
|
| 88 |
+
if timestamp in b_file.name:
|
| 89 |
+
return True, a_file, b_file, timestamp
|
| 90 |
+
|
| 91 |
+
# Also detect if files exist even if session state not set
|
| 92 |
+
ui_output_path = data_loader.get_ui_output_path()
|
| 93 |
+
a_files = list(ui_output_path.glob('messages_a_*.csv'))
|
| 94 |
+
b_files = list(ui_output_path.glob('messages_b_*.csv'))
|
| 95 |
+
|
| 96 |
+
if a_files and b_files:
|
| 97 |
+
# Auto-detect matching pairs
|
| 98 |
+
for a_file in a_files:
|
| 99 |
+
match_a = re.search(r'messages_a_.*_(\d{8}_\d{4})\.csv', a_file.name)
|
| 100 |
+
if match_a:
|
| 101 |
+
timestamp = match_a.group(1)
|
| 102 |
+
for b_file in b_files:
|
| 103 |
+
if timestamp in b_file.name:
|
| 104 |
+
return True, a_file, b_file, timestamp
|
| 105 |
+
|
| 106 |
+
return False, None, None, None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def load_ab_test_messages(file_path):
|
| 110 |
+
"""Load messages from A/B test file."""
|
| 111 |
+
try:
|
| 112 |
+
return pd.read_csv(file_path, encoding='utf-8-sig')
|
| 113 |
+
except Exception as e:
|
| 114 |
+
st.error(f"Error loading A/B test file: {e}")
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def extract_experiment_id_from_filename(file_path):
|
| 119 |
+
"""Extract experiment ID from A/B test filename."""
|
| 120 |
+
# Pattern: messages_a_brand_variant_timestamp.csv or messages_b_brand_variant_timestamp.csv
|
| 121 |
+
# Use the full filename (without .csv) to ensure A and B experiments have different IDs
|
| 122 |
+
filename = file_path.stem # Removes .csv extension automatically
|
| 123 |
+
return filename
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# Page Header
|
| 127 |
+
emoji = get_brand_emoji(brand)
|
| 128 |
+
st.title(f"📊 Analytics Dashboard - {emoji} {brand.title()}")
|
| 129 |
+
st.markdown("**Performance metrics and insights**")
|
| 130 |
+
|
| 131 |
+
# Detect A/B testing mode
|
| 132 |
+
is_ab_mode, messages_a_path, messages_b_path, ab_timestamp = detect_ab_testing_files()
|
| 133 |
+
|
| 134 |
+
if is_ab_mode:
|
| 135 |
+
st.success(f"🔬 **A/B Testing Mode - Comparing Experiments Side-by-Side** (Timestamp: {ab_timestamp})")
|
| 136 |
+
|
| 137 |
+
st.markdown("---")
|
| 138 |
+
|
| 139 |
+
# Load messages based on mode
|
| 140 |
+
if is_ab_mode:
|
| 141 |
+
# Load A/B test messages
|
| 142 |
+
messages_a_df = load_ab_test_messages(messages_a_path)
|
| 143 |
+
messages_b_df = load_ab_test_messages(messages_b_path)
|
| 144 |
+
|
| 145 |
+
if messages_a_df is None or len(messages_a_df) == 0 or messages_b_df is None or len(messages_b_df) == 0:
|
| 146 |
+
st.warning("⚠️ Error loading A/B test messages. Please check the files.")
|
| 147 |
+
if st.button("🏗️ Go to Campaign Builder"):
|
| 148 |
+
st.switch_page("pages/1_Campaign_Builder.py")
|
| 149 |
+
st.stop()
|
| 150 |
+
|
| 151 |
+
# Extract experiment IDs from filenames
|
| 152 |
+
experiment_a_id = extract_experiment_id_from_filename(messages_a_path)
|
| 153 |
+
experiment_b_id = extract_experiment_id_from_filename(messages_b_path)
|
| 154 |
+
|
| 155 |
+
messages_df = None # Not used in AB mode
|
| 156 |
+
else:
|
| 157 |
+
# Load regular messages
|
| 158 |
+
messages_df = data_loader.load_generated_messages()
|
| 159 |
+
|
| 160 |
+
if messages_df is None or len(messages_df) == 0:
|
| 161 |
+
st.warning("⚠️ No messages found. Please generate messages first in Campaign Builder.")
|
| 162 |
+
if st.button("🏗️ Go to Campaign Builder"):
|
| 163 |
+
st.switch_page("pages/1_Campaign_Builder.py")
|
| 164 |
+
st.stop()
|
| 165 |
+
|
| 166 |
+
# Get experiment ID
|
| 167 |
+
if "current_experiment_id" not in st.session_state or not st.session_state.current_experiment_id:
|
| 168 |
+
if 'campaign_name' in messages_df.columns and len(messages_df) > 0:
|
| 169 |
+
campaign_name = messages_df['campaign_name'].iloc[0]
|
| 170 |
+
experiment_id = campaign_name.replace(" ", "_")
|
| 171 |
+
else:
|
| 172 |
+
experiment_id = f"{brand}_experiment"
|
| 173 |
+
|
| 174 |
+
st.session_state.current_experiment_id = experiment_id
|
| 175 |
+
|
| 176 |
+
experiment_id = st.session_state.current_experiment_id
|
| 177 |
+
|
| 178 |
+
# Sidebar - Experiment Selection and Filters
|
| 179 |
+
with st.sidebar:
|
| 180 |
+
st.header("⚙️ Settings")
|
| 181 |
+
|
| 182 |
+
if not is_ab_mode:
|
| 183 |
+
# List available experiments (only in single mode)
|
| 184 |
+
experiment_files = list(data_loader.ui_output_path.glob("messages_*.csv"))
|
| 185 |
+
experiment_names = [f.stem for f in experiment_files]
|
| 186 |
+
|
| 187 |
+
if len(experiment_names) > 1:
|
| 188 |
+
selected_experiment = st.selectbox(
|
| 189 |
+
"Select Experiment",
|
| 190 |
+
experiment_names,
|
| 191 |
+
help="Choose an experiment to analyze"
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
if selected_experiment != experiment_id:
|
| 195 |
+
# Load different experiment
|
| 196 |
+
experiment_id = selected_experiment
|
| 197 |
+
st.session_state.current_experiment_id = experiment_id
|
| 198 |
+
|
| 199 |
+
# Reload messages and feedback
|
| 200 |
+
exp_file = data_loader.ui_output_path / f"{selected_experiment}.csv"
|
| 201 |
+
if exp_file.exists():
|
| 202 |
+
messages_df = pd.read_csv(exp_file, encoding='utf-8-sig')
|
| 203 |
+
st.rerun()
|
| 204 |
+
|
| 205 |
+
st.markdown("---")
|
| 206 |
+
|
| 207 |
+
# Filters
|
| 208 |
+
st.subheader("🔍 Filters")
|
| 209 |
+
|
| 210 |
+
# Stage filter
|
| 211 |
+
if is_ab_mode:
|
| 212 |
+
# Get stages from both dataframes
|
| 213 |
+
stages_a = sorted(messages_a_df['stage'].unique()) if 'stage' in messages_a_df.columns else []
|
| 214 |
+
stages_b = sorted(messages_b_df['stage'].unique()) if 'stage' in messages_b_df.columns else []
|
| 215 |
+
available_stages = sorted(list(set(stages_a + stages_b)))
|
| 216 |
+
else:
|
| 217 |
+
available_stages = sorted(messages_df['stage'].unique()) if 'stage' in messages_df.columns else []
|
| 218 |
+
|
| 219 |
+
if available_stages:
|
| 220 |
+
selected_stages = st.multiselect(
|
| 221 |
+
"Filter by Stage",
|
| 222 |
+
available_stages,
|
| 223 |
+
default=available_stages,
|
| 224 |
+
help="Select stages to include in analysis" + (" (applies to both experiments)" if is_ab_mode else "")
|
| 225 |
+
)
|
| 226 |
+
else:
|
| 227 |
+
selected_stages = []
|
| 228 |
+
|
| 229 |
+
# Filter messages by selected stages
|
| 230 |
+
if is_ab_mode:
|
| 231 |
+
# Filter both experiments
|
| 232 |
+
filtered_messages_a = messages_a_df.copy()
|
| 233 |
+
filtered_messages_b = messages_b_df.copy()
|
| 234 |
+
|
| 235 |
+
if selected_stages:
|
| 236 |
+
filtered_messages_a = filtered_messages_a[filtered_messages_a['stage'].isin(selected_stages)]
|
| 237 |
+
filtered_messages_b = filtered_messages_b[filtered_messages_b['stage'].isin(selected_stages)]
|
| 238 |
+
|
| 239 |
+
filtered_messages = None # Not used in AB mode
|
| 240 |
+
|
| 241 |
+
# Get feedback stats for both experiments
|
| 242 |
+
feedback_stats_a = feedback_manager.get_feedback_stats(experiment_a_id, total_messages=len(filtered_messages_a))
|
| 243 |
+
feedback_stats_b = feedback_manager.get_feedback_stats(experiment_b_id, total_messages=len(filtered_messages_b))
|
| 244 |
+
stage_feedback_stats_a = feedback_manager.get_stage_feedback_stats(experiment_a_id, messages_df=filtered_messages_a)
|
| 245 |
+
stage_feedback_stats_b = feedback_manager.get_stage_feedback_stats(experiment_b_id, messages_df=filtered_messages_b)
|
| 246 |
+
else:
|
| 247 |
+
if selected_stages and 'stage' in messages_df.columns:
|
| 248 |
+
filtered_messages = messages_df[messages_df['stage'].isin(selected_stages)]
|
| 249 |
+
else:
|
| 250 |
+
filtered_messages = messages_df
|
| 251 |
+
|
| 252 |
+
# Get feedback stats (pass total messages for accurate rejection rate)
|
| 253 |
+
feedback_stats = feedback_manager.get_feedback_stats(experiment_id, total_messages=len(filtered_messages))
|
| 254 |
+
stage_feedback_stats = feedback_manager.get_stage_feedback_stats(experiment_id, messages_df=filtered_messages)
|
| 255 |
+
|
| 256 |
+
# ============================================================================
|
| 257 |
+
# OVERALL METRICS
|
| 258 |
+
# ============================================================================
|
| 259 |
+
st.header("📈 Overall Performance")
|
| 260 |
+
|
| 261 |
+
if is_ab_mode:
|
| 262 |
+
# A/B Testing Mode - Show comparison side-by-side
|
| 263 |
+
st.markdown("##### Experiment A vs Experiment B")
|
| 264 |
+
|
| 265 |
+
col1, col2 = st.columns(2)
|
| 266 |
+
|
| 267 |
+
with col1:
|
| 268 |
+
st.markdown("### 🅰️ Experiment A")
|
| 269 |
+
sub_col1, sub_col2 = st.columns(2)
|
| 270 |
+
|
| 271 |
+
with sub_col1:
|
| 272 |
+
st.metric(
|
| 273 |
+
"Total Messages",
|
| 274 |
+
len(filtered_messages_a),
|
| 275 |
+
help="Total number of generated messages"
|
| 276 |
+
)
|
| 277 |
+
st.metric(
|
| 278 |
+
"Rejected Messages",
|
| 279 |
+
feedback_stats_a['total_rejects'],
|
| 280 |
+
f"{feedback_stats_a['reject_rate']:.1f}%"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
with sub_col2:
|
| 284 |
+
st.metric(
|
| 285 |
+
"Total Feedback",
|
| 286 |
+
feedback_stats_a['total_feedback'],
|
| 287 |
+
help="Number of messages with feedback"
|
| 288 |
+
)
|
| 289 |
+
approved_a = len(filtered_messages_a) - feedback_stats_a['total_rejects']
|
| 290 |
+
approval_rate_a = (approved_a / len(filtered_messages_a) * 100) if len(filtered_messages_a) > 0 else 0
|
| 291 |
+
st.metric(
|
| 292 |
+
"Approved/OK",
|
| 293 |
+
approved_a,
|
| 294 |
+
f"{approval_rate_a:.1f}%"
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
with col2:
|
| 298 |
+
st.markdown("### 🅱️ Experiment B")
|
| 299 |
+
sub_col1, sub_col2 = st.columns(2)
|
| 300 |
+
|
| 301 |
+
with sub_col1:
|
| 302 |
+
st.metric(
|
| 303 |
+
"Total Messages",
|
| 304 |
+
len(filtered_messages_b),
|
| 305 |
+
help="Total number of generated messages"
|
| 306 |
+
)
|
| 307 |
+
st.metric(
|
| 308 |
+
"Rejected Messages",
|
| 309 |
+
feedback_stats_b['total_rejects'],
|
| 310 |
+
f"{feedback_stats_b['reject_rate']:.1f}%"
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
with sub_col2:
|
| 314 |
+
st.metric(
|
| 315 |
+
"Total Feedback",
|
| 316 |
+
feedback_stats_b['total_feedback'],
|
| 317 |
+
help="Number of messages with feedback"
|
| 318 |
+
)
|
| 319 |
+
approved_b = len(filtered_messages_b) - feedback_stats_b['total_rejects']
|
| 320 |
+
approval_rate_b = (approved_b / len(filtered_messages_b) * 100) if len(filtered_messages_b) > 0 else 0
|
| 321 |
+
st.metric(
|
| 322 |
+
"Approved/OK",
|
| 323 |
+
approved_b,
|
| 324 |
+
f"{approval_rate_b:.1f}%"
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Winner determination
|
| 328 |
+
st.markdown("---")
|
| 329 |
+
st.markdown("#### 🏆 Winner Determination")
|
| 330 |
+
|
| 331 |
+
reject_rate_diff = feedback_stats_a['reject_rate'] - feedback_stats_b['reject_rate']
|
| 332 |
+
|
| 333 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 334 |
+
|
| 335 |
+
with col2:
|
| 336 |
+
if reject_rate_diff < -1:
|
| 337 |
+
st.success(f"**🏆 Experiment A is performing better** by {abs(reject_rate_diff):.1f}% (lower rejection rate)")
|
| 338 |
+
elif reject_rate_diff > 1:
|
| 339 |
+
st.success(f"**🏆 Experiment B is performing better** by {abs(reject_rate_diff):.1f}% (lower rejection rate)")
|
| 340 |
+
else:
|
| 341 |
+
st.info("**➡️ Both experiments have similar performance** (difference < 1%)")
|
| 342 |
+
|
| 343 |
+
else:
|
| 344 |
+
# Single Experiment Mode - Original behavior
|
| 345 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 346 |
+
|
| 347 |
+
with col1:
|
| 348 |
+
st.metric(
|
| 349 |
+
"Total Messages",
|
| 350 |
+
len(filtered_messages),
|
| 351 |
+
help="Total number of generated messages"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
with col2:
|
| 355 |
+
st.metric(
|
| 356 |
+
"Total Feedback",
|
| 357 |
+
feedback_stats['total_feedback'],
|
| 358 |
+
help="Number of messages with feedback"
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
with col3:
|
| 362 |
+
st.metric(
|
| 363 |
+
"Rejected Messages",
|
| 364 |
+
feedback_stats['total_rejects'],
|
| 365 |
+
f"{feedback_stats['reject_rate']:.1f}%"
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
with col4:
|
| 369 |
+
approved = len(filtered_messages) - feedback_stats['total_rejects']
|
| 370 |
+
approval_rate = (approved / len(filtered_messages) * 100) if len(filtered_messages) > 0 else 0
|
| 371 |
+
st.metric(
|
| 372 |
+
"Approved/OK Messages",
|
| 373 |
+
approved,
|
| 374 |
+
f"{approval_rate:.1f}%"
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
st.markdown("---")
|
| 378 |
+
|
| 379 |
+
# ============================================================================
|
| 380 |
+
# STAGE-BY-STAGE PERFORMANCE
|
| 381 |
+
# ============================================================================
|
| 382 |
+
st.header("📊 Stage-by-Stage Performance")
|
| 383 |
+
|
| 384 |
+
if is_ab_mode:
|
| 385 |
+
# A/B Testing Mode - Show both experiments on same chart for easy comparison
|
| 386 |
+
if len(stage_feedback_stats_a) > 0 or len(stage_feedback_stats_b) > 0:
|
| 387 |
+
# Create comparison chart
|
| 388 |
+
fig = go.Figure()
|
| 389 |
+
|
| 390 |
+
if len(stage_feedback_stats_a) > 0:
|
| 391 |
+
fig.add_trace(go.Bar(
|
| 392 |
+
x=stage_feedback_stats_a['stage'],
|
| 393 |
+
y=stage_feedback_stats_a['reject_rate'],
|
| 394 |
+
name='Experiment A',
|
| 395 |
+
marker_color='#4A90E2',
|
| 396 |
+
text=stage_feedback_stats_a['reject_rate'].round(1),
|
| 397 |
+
textposition='auto',
|
| 398 |
+
))
|
| 399 |
+
|
| 400 |
+
if len(stage_feedback_stats_b) > 0:
|
| 401 |
+
fig.add_trace(go.Bar(
|
| 402 |
+
x=stage_feedback_stats_b['stage'],
|
| 403 |
+
y=stage_feedback_stats_b['reject_rate'],
|
| 404 |
+
name='Experiment B',
|
| 405 |
+
marker_color='#E94B3C',
|
| 406 |
+
text=stage_feedback_stats_b['reject_rate'].round(1),
|
| 407 |
+
textposition='auto',
|
| 408 |
+
))
|
| 409 |
+
|
| 410 |
+
max_reject_rate = 10
|
| 411 |
+
if len(stage_feedback_stats_a) > 0:
|
| 412 |
+
max_reject_rate = max(max_reject_rate, stage_feedback_stats_a['reject_rate'].max())
|
| 413 |
+
if len(stage_feedback_stats_b) > 0:
|
| 414 |
+
max_reject_rate = max(max_reject_rate, stage_feedback_stats_b['reject_rate'].max())
|
| 415 |
+
|
| 416 |
+
fig.update_layout(
|
| 417 |
+
title="Rejection Rate by Stage - A vs B Comparison",
|
| 418 |
+
xaxis_title="Stage",
|
| 419 |
+
yaxis_title="Rejection Rate (%)",
|
| 420 |
+
yaxis=dict(range=[0, max_reject_rate * 1.2]),
|
| 421 |
+
template="plotly_dark",
|
| 422 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 423 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 424 |
+
barmode='group'
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 428 |
+
|
| 429 |
+
# Stage details tables side-by-side
|
| 430 |
+
with st.expander("📋 View Stage Details Comparison"):
|
| 431 |
+
col1, col2 = st.columns(2)
|
| 432 |
+
|
| 433 |
+
with col1:
|
| 434 |
+
st.markdown("**🅰️ Experiment A**")
|
| 435 |
+
if len(stage_feedback_stats_a) > 0:
|
| 436 |
+
st.dataframe(
|
| 437 |
+
stage_feedback_stats_a,
|
| 438 |
+
use_container_width=True,
|
| 439 |
+
column_config={
|
| 440 |
+
"stage": "Stage",
|
| 441 |
+
"total_messages": "Messages",
|
| 442 |
+
"total_feedback": "Feedback",
|
| 443 |
+
"rejects": "Rejected",
|
| 444 |
+
"reject_rate": st.column_config.NumberColumn(
|
| 445 |
+
"Reject Rate (%)",
|
| 446 |
+
format="%.1f%%"
|
| 447 |
+
)
|
| 448 |
+
}
|
| 449 |
+
)
|
| 450 |
+
else:
|
| 451 |
+
st.info("No feedback data for Experiment A")
|
| 452 |
+
|
| 453 |
+
with col2:
|
| 454 |
+
st.markdown("**🅱️ Experiment B**")
|
| 455 |
+
if len(stage_feedback_stats_b) > 0:
|
| 456 |
+
st.dataframe(
|
| 457 |
+
stage_feedback_stats_b,
|
| 458 |
+
use_container_width=True,
|
| 459 |
+
column_config={
|
| 460 |
+
"stage": "Stage",
|
| 461 |
+
"total_messages": "Messages",
|
| 462 |
+
"total_feedback": "Feedback",
|
| 463 |
+
"rejects": "Rejected",
|
| 464 |
+
"reject_rate": st.column_config.NumberColumn(
|
| 465 |
+
"Reject Rate (%)",
|
| 466 |
+
format="%.1f%%"
|
| 467 |
+
)
|
| 468 |
+
}
|
| 469 |
+
)
|
| 470 |
+
else:
|
| 471 |
+
st.info("No feedback data for Experiment B")
|
| 472 |
+
else:
|
| 473 |
+
st.info("ℹ️ No feedback data available yet. Provide feedback in the Message Viewer to see stage-by-stage analysis.")
|
| 474 |
+
|
| 475 |
+
else:
|
| 476 |
+
# Single Experiment Mode - Original behavior
|
| 477 |
+
if len(stage_feedback_stats) > 0:
|
| 478 |
+
# Create bar chart for stage performance
|
| 479 |
+
fig = go.Figure()
|
| 480 |
+
|
| 481 |
+
fig.add_trace(go.Bar(
|
| 482 |
+
x=stage_feedback_stats['stage'],
|
| 483 |
+
y=stage_feedback_stats['reject_rate'],
|
| 484 |
+
name='Rejection Rate (%)',
|
| 485 |
+
marker_color=theme['accent'],
|
| 486 |
+
text=stage_feedback_stats['reject_rate'].round(1),
|
| 487 |
+
textposition='auto',
|
| 488 |
+
))
|
| 489 |
+
|
| 490 |
+
fig.update_layout(
|
| 491 |
+
title="Rejection Rate by Stage",
|
| 492 |
+
xaxis_title="Stage",
|
| 493 |
+
yaxis_title="Rejection Rate (%)",
|
| 494 |
+
yaxis=dict(range=[0, max(stage_feedback_stats['reject_rate'].max() * 1.2, 10)]),
|
| 495 |
+
template="plotly_dark",
|
| 496 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 497 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 501 |
+
|
| 502 |
+
# Stage details table
|
| 503 |
+
with st.expander("📋 View Stage Details"):
|
| 504 |
+
st.dataframe(
|
| 505 |
+
stage_feedback_stats,
|
| 506 |
+
use_container_width=True,
|
| 507 |
+
column_config={
|
| 508 |
+
"stage": "Stage",
|
| 509 |
+
"total_messages": "Total Messages",
|
| 510 |
+
"total_feedback": "Total Feedback",
|
| 511 |
+
"rejects": "Rejected",
|
| 512 |
+
"reject_rate": st.column_config.NumberColumn(
|
| 513 |
+
"Rejection Rate (%)",
|
| 514 |
+
format="%.1f%%"
|
| 515 |
+
)
|
| 516 |
+
}
|
| 517 |
+
)
|
| 518 |
+
else:
|
| 519 |
+
st.info("ℹ️ No feedback data available yet. Provide feedback in the Message Viewer to see stage-by-stage analysis.")
|
| 520 |
+
|
| 521 |
+
st.markdown("---")
|
| 522 |
+
|
| 523 |
+
# ============================================================================
|
| 524 |
+
# REJECTION REASONS ANALYSIS
|
| 525 |
+
# ============================================================================
|
| 526 |
+
st.header("🔍 Rejection Reasons Analysis")
|
| 527 |
+
|
| 528 |
+
if is_ab_mode:
|
| 529 |
+
# A/B Testing Mode - Show side-by-side pie charts for both experiments
|
| 530 |
+
has_rejection_a = len(feedback_stats_a['rejection_reasons']) > 0
|
| 531 |
+
has_rejection_b = len(feedback_stats_b['rejection_reasons']) > 0
|
| 532 |
+
|
| 533 |
+
if has_rejection_a or has_rejection_b:
|
| 534 |
+
col1, col2 = st.columns(2)
|
| 535 |
+
|
| 536 |
+
with col1:
|
| 537 |
+
st.markdown("### 🅰️ Experiment A")
|
| 538 |
+
if has_rejection_a:
|
| 539 |
+
rejection_reasons_a_df = pd.DataFrame([
|
| 540 |
+
{"Reason": reason, "Count": count}
|
| 541 |
+
for reason, count in feedback_stats_a['rejection_reasons'].items()
|
| 542 |
+
]).sort_values('Count', ascending=False)
|
| 543 |
+
|
| 544 |
+
# Pie chart for rejection reasons
|
| 545 |
+
fig_a = px.pie(
|
| 546 |
+
rejection_reasons_a_df,
|
| 547 |
+
names='Reason',
|
| 548 |
+
values='Count',
|
| 549 |
+
title="Distribution of Rejection Reasons",
|
| 550 |
+
color_discrete_sequence=px.colors.qualitative.Set3
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
fig_a.update_layout(
|
| 554 |
+
template="plotly_dark",
|
| 555 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 556 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 557 |
+
)
|
| 558 |
+
|
| 559 |
+
st.plotly_chart(fig_a, use_container_width=True)
|
| 560 |
+
|
| 561 |
+
# Most common issue
|
| 562 |
+
most_common_reason_a = rejection_reasons_a_df.iloc[0]
|
| 563 |
+
st.info(f"💡 **Most Common:** {most_common_reason_a['Reason']} ({most_common_reason_a['Count']})")
|
| 564 |
+
else:
|
| 565 |
+
st.info("No rejection feedback for Experiment A")
|
| 566 |
+
|
| 567 |
+
with col2:
|
| 568 |
+
st.markdown("### 🅱️ Experiment B")
|
| 569 |
+
if has_rejection_b:
|
| 570 |
+
rejection_reasons_b_df = pd.DataFrame([
|
| 571 |
+
{"Reason": reason, "Count": count}
|
| 572 |
+
for reason, count in feedback_stats_b['rejection_reasons'].items()
|
| 573 |
+
]).sort_values('Count', ascending=False)
|
| 574 |
+
|
| 575 |
+
# Pie chart for rejection reasons
|
| 576 |
+
fig_b = px.pie(
|
| 577 |
+
rejection_reasons_b_df,
|
| 578 |
+
names='Reason',
|
| 579 |
+
values='Count',
|
| 580 |
+
title="Distribution of Rejection Reasons",
|
| 581 |
+
color_discrete_sequence=px.colors.qualitative.Pastel
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
fig_b.update_layout(
|
| 585 |
+
template="plotly_dark",
|
| 586 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 587 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
st.plotly_chart(fig_b, use_container_width=True)
|
| 591 |
+
|
| 592 |
+
# Most common issue
|
| 593 |
+
most_common_reason_b = rejection_reasons_b_df.iloc[0]
|
| 594 |
+
st.info(f"💡 **Most Common:** {most_common_reason_b['Reason']} ({most_common_reason_b['Count']})")
|
| 595 |
+
else:
|
| 596 |
+
st.info("No rejection feedback for Experiment B")
|
| 597 |
+
|
| 598 |
+
# Detailed rejection reasons tables side-by-side
|
| 599 |
+
with st.expander("📋 View Detailed Rejection Feedback"):
|
| 600 |
+
col1, col2 = st.columns(2)
|
| 601 |
+
|
| 602 |
+
with col1:
|
| 603 |
+
st.markdown("**🅰️ Experiment A**")
|
| 604 |
+
feedback_a_df = feedback_manager.load_feedback(experiment_a_id)
|
| 605 |
+
if feedback_a_df is not None and len(feedback_a_df) > 0:
|
| 606 |
+
reject_a_df = feedback_a_df[feedback_a_df['feedback_type'] == 'reject']
|
| 607 |
+
|
| 608 |
+
if len(reject_a_df) > 0:
|
| 609 |
+
display_a_df = reject_a_df[[
|
| 610 |
+
'user_id', 'stage', 'rejection_reason', 'rejection_text', 'timestamp'
|
| 611 |
+
]].copy()
|
| 612 |
+
|
| 613 |
+
display_a_df['rejection_reason'] = display_a_df['rejection_reason'].apply(
|
| 614 |
+
lambda x: feedback_manager.get_rejection_reason_label(x) if pd.notna(x) else 'N/A'
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
st.dataframe(display_a_df, use_container_width=True)
|
| 618 |
+
else:
|
| 619 |
+
st.info("No rejection feedback")
|
| 620 |
+
else:
|
| 621 |
+
st.info("No feedback data")
|
| 622 |
+
|
| 623 |
+
with col2:
|
| 624 |
+
st.markdown("**🅱️ Experiment B**")
|
| 625 |
+
feedback_b_df = feedback_manager.load_feedback(experiment_b_id)
|
| 626 |
+
if feedback_b_df is not None and len(feedback_b_df) > 0:
|
| 627 |
+
reject_b_df = feedback_b_df[feedback_b_df['feedback_type'] == 'reject']
|
| 628 |
+
|
| 629 |
+
if len(reject_b_df) > 0:
|
| 630 |
+
display_b_df = reject_b_df[[
|
| 631 |
+
'user_id', 'stage', 'rejection_reason', 'rejection_text', 'timestamp'
|
| 632 |
+
]].copy()
|
| 633 |
+
|
| 634 |
+
display_b_df['rejection_reason'] = display_b_df['rejection_reason'].apply(
|
| 635 |
+
lambda x: feedback_manager.get_rejection_reason_label(x) if pd.notna(x) else 'N/A'
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
st.dataframe(display_b_df, use_container_width=True)
|
| 639 |
+
else:
|
| 640 |
+
st.info("No rejection feedback")
|
| 641 |
+
else:
|
| 642 |
+
st.info("No feedback data")
|
| 643 |
+
else:
|
| 644 |
+
st.info("ℹ️ No rejection feedback available yet. Reject messages in the Message Viewer to see detailed analysis.")
|
| 645 |
+
|
| 646 |
+
else:
|
| 647 |
+
# Single Experiment Mode - Original behavior
|
| 648 |
+
if len(feedback_stats['rejection_reasons']) > 0:
|
| 649 |
+
# Create DataFrame for rejection reasons
|
| 650 |
+
rejection_reasons_df = pd.DataFrame([
|
| 651 |
+
{"Reason": reason, "Count": count}
|
| 652 |
+
for reason, count in feedback_stats['rejection_reasons'].items()
|
| 653 |
+
]).sort_values('Count', ascending=False)
|
| 654 |
+
|
| 655 |
+
col1, col2 = st.columns([1, 1])
|
| 656 |
+
|
| 657 |
+
with col1:
|
| 658 |
+
# Pie chart for rejection reasons
|
| 659 |
+
fig = px.pie(
|
| 660 |
+
rejection_reasons_df,
|
| 661 |
+
names='Reason',
|
| 662 |
+
values='Count',
|
| 663 |
+
title="Distribution of Rejection Reasons",
|
| 664 |
+
color_discrete_sequence=px.colors.qualitative.Set3
|
| 665 |
+
)
|
| 666 |
+
|
| 667 |
+
fig.update_layout(
|
| 668 |
+
template="plotly_dark",
|
| 669 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 670 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 674 |
+
|
| 675 |
+
with col2:
|
| 676 |
+
# Bar chart for rejection reasons
|
| 677 |
+
fig = px.bar(
|
| 678 |
+
rejection_reasons_df,
|
| 679 |
+
x='Reason',
|
| 680 |
+
y='Count',
|
| 681 |
+
title="Rejection Reasons Count",
|
| 682 |
+
color='Count',
|
| 683 |
+
color_continuous_scale='Reds'
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
fig.update_layout(
|
| 687 |
+
template="plotly_dark",
|
| 688 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 689 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 690 |
+
xaxis_tickangle=-45
|
| 691 |
+
)
|
| 692 |
+
|
| 693 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 694 |
+
|
| 695 |
+
# Most common issue
|
| 696 |
+
most_common_reason = rejection_reasons_df.iloc[0]
|
| 697 |
+
st.info(f"💡 **Most Common Issue:** {most_common_reason['Reason']} ({most_common_reason['Count']} occurrences)")
|
| 698 |
+
|
| 699 |
+
# Detailed rejection reasons table
|
| 700 |
+
with st.expander("📋 View Detailed Rejection Feedback"):
|
| 701 |
+
feedback_df = feedback_manager.load_feedback(experiment_id)
|
| 702 |
+
if feedback_df is not None and len(feedback_df) > 0:
|
| 703 |
+
reject_df = feedback_df[feedback_df['feedback_type'] == 'reject']
|
| 704 |
+
|
| 705 |
+
if len(reject_df) > 0:
|
| 706 |
+
# Prepare display dataframe
|
| 707 |
+
display_df = reject_df[[
|
| 708 |
+
'user_id', 'stage', 'rejection_reason', 'rejection_text', 'timestamp'
|
| 709 |
+
]].copy()
|
| 710 |
+
|
| 711 |
+
# Map rejection reason keys to labels
|
| 712 |
+
display_df['rejection_reason'] = display_df['rejection_reason'].apply(
|
| 713 |
+
lambda x: feedback_manager.get_rejection_reason_label(x) if pd.notna(x) else 'N/A'
|
| 714 |
+
)
|
| 715 |
+
|
| 716 |
+
st.dataframe(display_df, use_container_width=True)
|
| 717 |
+
else:
|
| 718 |
+
st.info("ℹ️ No rejection feedback available yet. Reject messages in the Message Viewer to see detailed analysis.")
|
| 719 |
+
|
| 720 |
+
st.markdown("---")
|
| 721 |
+
|
| 722 |
+
# ============================================================================
|
| 723 |
+
# STATISTICAL COMPARISON (AB Testing Mode Only)
|
| 724 |
+
# ============================================================================
|
| 725 |
+
if is_ab_mode:
|
| 726 |
+
st.header("📊 Statistical Comparison")
|
| 727 |
+
|
| 728 |
+
# Get comparison stats
|
| 729 |
+
comparison = feedback_manager.compare_experiments(
|
| 730 |
+
experiment_a_id,
|
| 731 |
+
experiment_b_id,
|
| 732 |
+
total_messages_a=len(filtered_messages_a),
|
| 733 |
+
total_messages_b=len(filtered_messages_b)
|
| 734 |
+
)
|
| 735 |
+
|
| 736 |
+
col1, col2, col3 = st.columns(3)
|
| 737 |
+
|
| 738 |
+
with col1:
|
| 739 |
+
st.markdown("### 🅰️ Experiment A")
|
| 740 |
+
st.metric(
|
| 741 |
+
"Rejection Rate",
|
| 742 |
+
f"{comparison['experiment_a']['stats']['reject_rate']:.1f}%"
|
| 743 |
+
)
|
| 744 |
+
st.metric(
|
| 745 |
+
"Total Feedback",
|
| 746 |
+
comparison['experiment_a']['stats']['total_feedback']
|
| 747 |
+
)
|
| 748 |
+
st.metric(
|
| 749 |
+
"Total Messages",
|
| 750 |
+
len(filtered_messages_a)
|
| 751 |
+
)
|
| 752 |
+
|
| 753 |
+
with col2:
|
| 754 |
+
st.markdown("### 🅱️ Experiment B")
|
| 755 |
+
st.metric(
|
| 756 |
+
"Rejection Rate",
|
| 757 |
+
f"{comparison['experiment_b']['stats']['reject_rate']:.1f}%"
|
| 758 |
+
)
|
| 759 |
+
st.metric(
|
| 760 |
+
"Total Feedback",
|
| 761 |
+
comparison['experiment_b']['stats']['total_feedback']
|
| 762 |
+
)
|
| 763 |
+
st.metric(
|
| 764 |
+
"Total Messages",
|
| 765 |
+
len(filtered_messages_b)
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
with col3:
|
| 769 |
+
st.markdown("### 📊 Difference")
|
| 770 |
+
diff = comparison['difference']['reject_rate']
|
| 771 |
+
|
| 772 |
+
st.metric(
|
| 773 |
+
"Rejection Rate Δ",
|
| 774 |
+
f"{diff:.1f}%",
|
| 775 |
+
delta=f"{-diff:.1f}%" if diff != 0 else "0%",
|
| 776 |
+
delta_color="inverse"
|
| 777 |
+
)
|
| 778 |
+
|
| 779 |
+
feedback_diff = comparison['experiment_b']['stats']['total_feedback'] - comparison['experiment_a']['stats']['total_feedback']
|
| 780 |
+
st.metric(
|
| 781 |
+
"Feedback Δ",
|
| 782 |
+
feedback_diff,
|
| 783 |
+
delta=f"{feedback_diff}" if feedback_diff != 0 else "0"
|
| 784 |
+
)
|
| 785 |
+
|
| 786 |
+
st.markdown("---")
|
| 787 |
+
|
| 788 |
+
# ============================================================================
|
| 789 |
+
# COMPARATIVE ANALYSIS (if multiple experiments in non-AB mode)
|
| 790 |
+
# ============================================================================
|
| 791 |
+
if not is_ab_mode:
|
| 792 |
+
st.header("🧪 Comparative Analysis")
|
| 793 |
+
|
| 794 |
+
# Check for A/B test experiments
|
| 795 |
+
experiment_files = list(data_loader.ui_output_path.glob("messages_*_*.csv"))
|
| 796 |
+
ab_experiments = [f for f in experiment_files if '_a_' in f.stem.lower() or '_b_' in f.stem.lower()]
|
| 797 |
+
|
| 798 |
+
if len(ab_experiments) >= 2:
|
| 799 |
+
st.markdown("**A/B Test Experiments Detected**")
|
| 800 |
+
|
| 801 |
+
# Find matching A/B pairs
|
| 802 |
+
ab_pairs = {}
|
| 803 |
+
for exp_file in ab_experiments:
|
| 804 |
+
exp_name = exp_file.stem
|
| 805 |
+
if '_a_' in exp_name.lower():
|
| 806 |
+
base_name = exp_name.lower().replace('_a_', '_')
|
| 807 |
+
if base_name not in ab_pairs:
|
| 808 |
+
ab_pairs[base_name] = {}
|
| 809 |
+
ab_pairs[base_name]['A'] = exp_name
|
| 810 |
+
elif '_b_' in exp_name.lower():
|
| 811 |
+
base_name = exp_name.lower().replace('_b_', '_')
|
| 812 |
+
if base_name not in ab_pairs:
|
| 813 |
+
ab_pairs[base_name] = {}
|
| 814 |
+
ab_pairs[base_name]['B'] = exp_name
|
| 815 |
+
|
| 816 |
+
# Display A/B comparisons
|
| 817 |
+
for base_name, pair in ab_pairs.items():
|
| 818 |
+
if 'A' in pair and 'B' in pair:
|
| 819 |
+
with st.expander(f"📊 {base_name.replace('messages_', '').replace('_', ' ').title()}"):
|
| 820 |
+
exp_a_id = pair['A']
|
| 821 |
+
exp_b_id = pair['B']
|
| 822 |
+
|
| 823 |
+
# Load messages to get total counts
|
| 824 |
+
try:
|
| 825 |
+
exp_a_file = data_loader.ui_output_path / f"{exp_a_id}.csv"
|
| 826 |
+
exp_b_file = data_loader.ui_output_path / f"{exp_b_id}.csv"
|
| 827 |
+
|
| 828 |
+
messages_a_df = pd.read_csv(exp_a_file, encoding='utf-8-sig') if exp_a_file.exists() else None
|
| 829 |
+
messages_b_df = pd.read_csv(exp_b_file, encoding='utf-8-sig') if exp_b_file.exists() else None
|
| 830 |
+
|
| 831 |
+
total_messages_a = len(messages_a_df) if messages_a_df is not None else None
|
| 832 |
+
total_messages_b = len(messages_b_df) if messages_b_df is not None else None
|
| 833 |
+
except:
|
| 834 |
+
total_messages_a = None
|
| 835 |
+
total_messages_b = None
|
| 836 |
+
|
| 837 |
+
# Get comparison stats
|
| 838 |
+
comparison = feedback_manager.compare_experiments(
|
| 839 |
+
exp_a_id,
|
| 840 |
+
exp_b_id,
|
| 841 |
+
total_messages_a=total_messages_a,
|
| 842 |
+
total_messages_b=total_messages_b
|
| 843 |
+
)
|
| 844 |
+
|
| 845 |
+
col1, col2, col3 = st.columns(3)
|
| 846 |
+
|
| 847 |
+
with col1:
|
| 848 |
+
st.markdown("### 🅰️ Experiment A")
|
| 849 |
+
st.metric(
|
| 850 |
+
"Rejection Rate",
|
| 851 |
+
f"{comparison['experiment_a']['stats']['reject_rate']:.1f}%"
|
| 852 |
+
)
|
| 853 |
+
st.metric(
|
| 854 |
+
"Total Feedback",
|
| 855 |
+
comparison['experiment_a']['stats']['total_feedback']
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
with col2:
|
| 859 |
+
st.markdown("### 🅱️ Experiment B")
|
| 860 |
+
st.metric(
|
| 861 |
+
"Rejection Rate",
|
| 862 |
+
f"{comparison['experiment_b']['stats']['reject_rate']:.1f}%"
|
| 863 |
+
)
|
| 864 |
+
st.metric(
|
| 865 |
+
"Total Feedback",
|
| 866 |
+
comparison['experiment_b']['stats']['total_feedback']
|
| 867 |
+
)
|
| 868 |
+
|
| 869 |
+
with col3:
|
| 870 |
+
st.markdown("### 📈 Winner")
|
| 871 |
+
diff = comparison['difference']['reject_rate']
|
| 872 |
+
|
| 873 |
+
if diff < -1:
|
| 874 |
+
st.success("🏆 Experiment A")
|
| 875 |
+
st.metric("Better by", f"{abs(diff):.1f}%")
|
| 876 |
+
elif diff > 1:
|
| 877 |
+
st.success("🏆 Experiment B")
|
| 878 |
+
st.metric("Better by", f"{abs(diff):.1f}%")
|
| 879 |
+
else:
|
| 880 |
+
st.info("➡️ Similar Performance")
|
| 881 |
+
|
| 882 |
+
# Side-by-side rejection reasons comparison
|
| 883 |
+
st.markdown("#### Rejection Reasons Comparison")
|
| 884 |
+
|
| 885 |
+
col1, col2 = st.columns(2)
|
| 886 |
+
|
| 887 |
+
with col1:
|
| 888 |
+
reasons_a = comparison['experiment_a']['stats']['rejection_reasons']
|
| 889 |
+
if len(reasons_a) > 0:
|
| 890 |
+
df_a = pd.DataFrame([
|
| 891 |
+
{"Reason": k, "Count": v}
|
| 892 |
+
for k, v in reasons_a.items()
|
| 893 |
+
])
|
| 894 |
+
|
| 895 |
+
fig_a = px.bar(
|
| 896 |
+
df_a,
|
| 897 |
+
x='Reason',
|
| 898 |
+
y='Count',
|
| 899 |
+
title="Experiment A - Rejection Reasons",
|
| 900 |
+
color='Count',
|
| 901 |
+
color_continuous_scale='Blues'
|
| 902 |
+
)
|
| 903 |
+
|
| 904 |
+
fig_a.update_layout(
|
| 905 |
+
template="plotly_dark",
|
| 906 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 907 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 908 |
+
xaxis_tickangle=-45,
|
| 909 |
+
height=300
|
| 910 |
+
)
|
| 911 |
+
|
| 912 |
+
st.plotly_chart(fig_a, use_container_width=True)
|
| 913 |
+
else:
|
| 914 |
+
st.info("No rejection feedback for Experiment A")
|
| 915 |
+
|
| 916 |
+
with col2:
|
| 917 |
+
reasons_b = comparison['experiment_b']['stats']['rejection_reasons']
|
| 918 |
+
if len(reasons_b) > 0:
|
| 919 |
+
df_b = pd.DataFrame([
|
| 920 |
+
{"Reason": k, "Count": v}
|
| 921 |
+
for k, v in reasons_b.items()
|
| 922 |
+
])
|
| 923 |
+
|
| 924 |
+
fig_b = px.bar(
|
| 925 |
+
df_b,
|
| 926 |
+
x='Reason',
|
| 927 |
+
y='Count',
|
| 928 |
+
title="Experiment B - Rejection Reasons",
|
| 929 |
+
color='Count',
|
| 930 |
+
color_continuous_scale='Reds'
|
| 931 |
+
)
|
| 932 |
+
|
| 933 |
+
fig_b.update_layout(
|
| 934 |
+
template="plotly_dark",
|
| 935 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 936 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 937 |
+
xaxis_tickangle=-45,
|
| 938 |
+
height=300
|
| 939 |
+
)
|
| 940 |
+
|
| 941 |
+
st.plotly_chart(fig_b, use_container_width=True)
|
| 942 |
+
else:
|
| 943 |
+
st.info("No rejection feedback for Experiment B")
|
| 944 |
+
else:
|
| 945 |
+
st.info("ℹ️ Run A/B tests to see comparative analysis here")
|
| 946 |
+
|
| 947 |
+
st.markdown("---")
|
| 948 |
+
|
| 949 |
+
# Export options
|
| 950 |
+
st.header("💾 Export Data")
|
| 951 |
+
|
| 952 |
+
if is_ab_mode:
|
| 953 |
+
# A/B Testing Mode - Export both experiments
|
| 954 |
+
st.markdown("##### Export Experiment Data")
|
| 955 |
+
|
| 956 |
+
col1, col2 = st.columns(2)
|
| 957 |
+
|
| 958 |
+
with col1:
|
| 959 |
+
st.markdown("**🅰️ Experiment A**")
|
| 960 |
+
|
| 961 |
+
if st.button("📥 Export Messages A", use_container_width=True):
|
| 962 |
+
csv = filtered_messages_a.to_csv(index=False, encoding='utf-8')
|
| 963 |
+
st.download_button(
|
| 964 |
+
label="⬇️ Download Messages A CSV",
|
| 965 |
+
data=csv,
|
| 966 |
+
file_name=f"{brand}_{experiment_a_id}_messages.csv",
|
| 967 |
+
mime="text/csv",
|
| 968 |
+
use_container_width=True
|
| 969 |
+
)
|
| 970 |
+
|
| 971 |
+
feedback_a_df = feedback_manager.load_feedback(experiment_a_id)
|
| 972 |
+
if feedback_a_df is not None and len(feedback_a_df) > 0:
|
| 973 |
+
if st.button("📥 Export Feedback A", use_container_width=True):
|
| 974 |
+
csv = feedback_a_df.to_csv(index=False, encoding='utf-8')
|
| 975 |
+
st.download_button(
|
| 976 |
+
label="⬇️ Download Feedback A CSV",
|
| 977 |
+
data=csv,
|
| 978 |
+
file_name=f"{brand}_{experiment_a_id}_feedback.csv",
|
| 979 |
+
mime="text/csv",
|
| 980 |
+
use_container_width=True
|
| 981 |
+
)
|
| 982 |
+
|
| 983 |
+
if len(stage_feedback_stats_a) > 0:
|
| 984 |
+
if st.button("📥 Export Analytics A", use_container_width=True):
|
| 985 |
+
csv = stage_feedback_stats_a.to_csv(index=False, encoding='utf-8')
|
| 986 |
+
st.download_button(
|
| 987 |
+
label="⬇️ Download Analytics A CSV",
|
| 988 |
+
data=csv,
|
| 989 |
+
file_name=f"{brand}_{experiment_a_id}_analytics.csv",
|
| 990 |
+
mime="text/csv",
|
| 991 |
+
use_container_width=True
|
| 992 |
+
)
|
| 993 |
+
|
| 994 |
+
with col2:
|
| 995 |
+
st.markdown("**🅱️ Experiment B**")
|
| 996 |
+
|
| 997 |
+
if st.button("📥 Export Messages B", use_container_width=True):
|
| 998 |
+
csv = filtered_messages_b.to_csv(index=False, encoding='utf-8')
|
| 999 |
+
st.download_button(
|
| 1000 |
+
label="⬇️ Download Messages B CSV",
|
| 1001 |
+
data=csv,
|
| 1002 |
+
file_name=f"{brand}_{experiment_b_id}_messages.csv",
|
| 1003 |
+
mime="text/csv",
|
| 1004 |
+
use_container_width=True
|
| 1005 |
+
)
|
| 1006 |
+
|
| 1007 |
+
feedback_b_df = feedback_manager.load_feedback(experiment_b_id)
|
| 1008 |
+
if feedback_b_df is not None and len(feedback_b_df) > 0:
|
| 1009 |
+
if st.button("📥 Export Feedback B", use_container_width=True):
|
| 1010 |
+
csv = feedback_b_df.to_csv(index=False, encoding='utf-8')
|
| 1011 |
+
st.download_button(
|
| 1012 |
+
label="⬇️ Download Feedback B CSV",
|
| 1013 |
+
data=csv,
|
| 1014 |
+
file_name=f"{brand}_{experiment_b_id}_feedback.csv",
|
| 1015 |
+
mime="text/csv",
|
| 1016 |
+
use_container_width=True
|
| 1017 |
+
)
|
| 1018 |
+
|
| 1019 |
+
if len(stage_feedback_stats_b) > 0:
|
| 1020 |
+
if st.button("📥 Export Analytics B", use_container_width=True):
|
| 1021 |
+
csv = stage_feedback_stats_b.to_csv(index=False, encoding='utf-8')
|
| 1022 |
+
st.download_button(
|
| 1023 |
+
label="⬇️ Download Analytics B CSV",
|
| 1024 |
+
data=csv,
|
| 1025 |
+
file_name=f"{brand}_{experiment_b_id}_analytics.csv",
|
| 1026 |
+
mime="text/csv",
|
| 1027 |
+
use_container_width=True
|
| 1028 |
+
)
|
| 1029 |
+
|
| 1030 |
+
else:
|
| 1031 |
+
# Single Experiment Mode - Original behavior
|
| 1032 |
+
col1, col2, col3 = st.columns(3)
|
| 1033 |
+
|
| 1034 |
+
with col1:
|
| 1035 |
+
if st.button("📥 Export Messages", use_container_width=True):
|
| 1036 |
+
csv = filtered_messages.to_csv(index=False, encoding='utf-8')
|
| 1037 |
+
st.download_button(
|
| 1038 |
+
label="⬇️ Download Messages CSV",
|
| 1039 |
+
data=csv,
|
| 1040 |
+
file_name=f"{brand}_{experiment_id}_messages.csv",
|
| 1041 |
+
mime="text/csv",
|
| 1042 |
+
use_container_width=True
|
| 1043 |
+
)
|
| 1044 |
+
|
| 1045 |
+
with col2:
|
| 1046 |
+
feedback_df = feedback_manager.load_feedback(experiment_id)
|
| 1047 |
+
if feedback_df is not None and len(feedback_df) > 0:
|
| 1048 |
+
if st.button("📥 Export Feedback", use_container_width=True):
|
| 1049 |
+
csv = feedback_df.to_csv(index=False, encoding='utf-8')
|
| 1050 |
+
st.download_button(
|
| 1051 |
+
label="⬇️ Download Feedback CSV",
|
| 1052 |
+
data=csv,
|
| 1053 |
+
file_name=f"{brand}_{experiment_id}_feedback.csv",
|
| 1054 |
+
mime="text/csv",
|
| 1055 |
+
use_container_width=True
|
| 1056 |
+
)
|
| 1057 |
+
|
| 1058 |
+
with col3:
|
| 1059 |
+
if len(stage_feedback_stats) > 0:
|
| 1060 |
+
if st.button("📥 Export Analytics", use_container_width=True):
|
| 1061 |
+
csv = stage_feedback_stats.to_csv(index=False, encoding='utf-8')
|
| 1062 |
+
st.download_button(
|
| 1063 |
+
label="⬇️ Download Analytics CSV",
|
| 1064 |
+
data=csv,
|
| 1065 |
+
file_name=f"{brand}_{experiment_id}_analytics.csv",
|
| 1066 |
+
mime="text/csv",
|
| 1067 |
+
use_container_width=True
|
| 1068 |
+
)
|
| 1069 |
+
|
| 1070 |
+
st.markdown("---")
|
pages/5_Historical_Analytics.py
CHANGED
|
@@ -1,24 +1,519 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
"""
|
| 5 |
|
|
|
|
| 6 |
import sys
|
| 7 |
from pathlib import Path
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
sys.path.insert(0, str(root_dir))
|
| 15 |
-
sys.path.insert(0, str(root_dir / "ai_messaging_system_v2"))
|
| 16 |
-
sys.path.insert(0, str(visualization_dir))
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Page 5: Historical Analytics
|
| 3 |
+
View performance metrics and insights from all previous experiments.
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
import streamlit as st
|
| 7 |
import sys
|
| 8 |
from pathlib import Path
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from datetime import datetime
|
| 11 |
|
| 12 |
+
# Try to import plotly with helpful error message
|
| 13 |
+
try:
|
| 14 |
+
import plotly.express as px
|
| 15 |
+
import plotly.graph_objects as go
|
| 16 |
+
except ModuleNotFoundError:
|
| 17 |
+
st.error("""
|
| 18 |
+
⚠️ **Missing Dependency: plotly**
|
| 19 |
|
| 20 |
+
Plotly is not installed in the current Python environment.
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
**To fix this:**
|
| 23 |
+
1. Make sure you're running Streamlit from the correct Python environment
|
| 24 |
+
2. Install plotly: `pip install plotly>=5.17.0`
|
| 25 |
+
3. Or install all requirements: `pip install -r visualization/requirements.txt`
|
| 26 |
|
| 27 |
+
**Current Python:** {}
|
| 28 |
+
""".format(sys.executable))
|
| 29 |
+
st.stop()
|
| 30 |
+
|
| 31 |
+
# Add root directory to path
|
| 32 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 33 |
+
|
| 34 |
+
from utils.auth import check_authentication
|
| 35 |
+
from utils.theme import apply_theme, get_brand_emoji, get_brand_theme
|
| 36 |
+
from utils.data_loader import DataLoader
|
| 37 |
+
from utils.feedback_manager import FeedbackManager
|
| 38 |
+
|
| 39 |
+
# Page configuration
|
| 40 |
+
st.set_page_config(
|
| 41 |
+
page_title="Historical Analytics",
|
| 42 |
+
page_icon="📚",
|
| 43 |
+
layout="wide"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Check authentication
|
| 47 |
+
if not check_authentication():
|
| 48 |
+
st.error("🔒 Please login first")
|
| 49 |
+
st.stop()
|
| 50 |
+
|
| 51 |
+
# Initialize utilities
|
| 52 |
+
data_loader = DataLoader()
|
| 53 |
+
feedback_manager = FeedbackManager()
|
| 54 |
+
|
| 55 |
+
# Check if brand is selected
|
| 56 |
+
if "selected_brand" not in st.session_state or not st.session_state.selected_brand:
|
| 57 |
+
st.error("⚠️ Please select a brand from the home page first")
|
| 58 |
+
st.stop()
|
| 59 |
+
|
| 60 |
+
brand = st.session_state.selected_brand
|
| 61 |
+
apply_theme(brand)
|
| 62 |
+
theme = get_brand_theme(brand)
|
| 63 |
+
|
| 64 |
+
# Page Header
|
| 65 |
+
emoji = get_brand_emoji(brand)
|
| 66 |
+
st.title(f"📚 Historical Analytics - {emoji} {brand.title()}")
|
| 67 |
+
st.markdown("**View performance metrics from all previous experiments**")
|
| 68 |
+
|
| 69 |
+
st.markdown("---")
|
| 70 |
+
|
| 71 |
+
# Load all experiment files
|
| 72 |
+
ui_output_path = data_loader.ui_output_path
|
| 73 |
+
experiment_files = list(ui_output_path.glob("messages_*.csv"))
|
| 74 |
+
|
| 75 |
+
# Filter out current messages.csv and A/B experiment files for separate handling
|
| 76 |
+
regular_experiments = [f for f in experiment_files if f.name != "messages.csv" and "_a_" not in f.name.lower() and "_b_" not in f.name.lower()]
|
| 77 |
+
ab_experiments = [f for f in experiment_files if "_a_" in f.name.lower() or "_b_" in f.name.lower()]
|
| 78 |
+
|
| 79 |
+
if len(regular_experiments) == 0 and len(ab_experiments) == 0:
|
| 80 |
+
st.info("ℹ️ No historical experiments found yet. Run experiments in Campaign Builder to see historical data here.")
|
| 81 |
+
st.stop()
|
| 82 |
+
|
| 83 |
+
# Sidebar - Filters
|
| 84 |
+
with st.sidebar:
|
| 85 |
+
st.header("🔍 Filters")
|
| 86 |
+
|
| 87 |
+
# Date range filter
|
| 88 |
+
st.subheader("Date Range")
|
| 89 |
+
show_all = st.checkbox("Show All Experiments", value=True)
|
| 90 |
+
|
| 91 |
+
if not show_all:
|
| 92 |
+
# Extract dates from filenames for filtering
|
| 93 |
+
all_dates = []
|
| 94 |
+
for f in regular_experiments + ab_experiments:
|
| 95 |
+
# Try to extract date from filename (format: messages_brand_YYYYMMDD_HHMM.csv)
|
| 96 |
+
try:
|
| 97 |
+
parts = f.stem.split('_')
|
| 98 |
+
if len(parts) >= 3:
|
| 99 |
+
date_str = parts[-2] # YYYYMMDD
|
| 100 |
+
date_obj = datetime.strptime(date_str, '%Y%m%d')
|
| 101 |
+
all_dates.append(date_obj.date())
|
| 102 |
+
except:
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
if len(all_dates) > 0:
|
| 106 |
+
min_date = min(all_dates)
|
| 107 |
+
max_date = max(all_dates)
|
| 108 |
+
|
| 109 |
+
start_date = st.date_input("Start Date", min_date)
|
| 110 |
+
end_date = st.date_input("End Date", max_date)
|
| 111 |
+
|
| 112 |
+
st.markdown("---")
|
| 113 |
+
|
| 114 |
+
# Experiment type filter
|
| 115 |
+
st.subheader("Experiment Type")
|
| 116 |
+
show_regular = st.checkbox("Regular Experiments", value=True)
|
| 117 |
+
show_ab = st.checkbox("A/B Tests", value=True)
|
| 118 |
+
|
| 119 |
+
# ============================================================================
|
| 120 |
+
# OVERVIEW STATISTICS
|
| 121 |
+
# ============================================================================
|
| 122 |
+
st.header("📊 Overview Statistics")
|
| 123 |
+
|
| 124 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 125 |
+
|
| 126 |
+
with col1:
|
| 127 |
+
st.metric("Total Experiments", len(regular_experiments) + (len(ab_experiments) // 2))
|
| 128 |
+
|
| 129 |
+
with col2:
|
| 130 |
+
st.metric("Regular Experiments", len(regular_experiments))
|
| 131 |
+
|
| 132 |
+
with col3:
|
| 133 |
+
st.metric("A/B Tests", len(ab_experiments) // 2)
|
| 134 |
+
|
| 135 |
+
with col4:
|
| 136 |
+
# Calculate total messages across all experiments
|
| 137 |
+
total_messages = 0
|
| 138 |
+
for f in regular_experiments + ab_experiments:
|
| 139 |
+
try:
|
| 140 |
+
df = pd.read_csv(f, encoding='utf-8-sig')
|
| 141 |
+
total_messages += len(df)
|
| 142 |
+
except:
|
| 143 |
+
continue
|
| 144 |
+
st.metric("Total Messages Generated", total_messages)
|
| 145 |
+
|
| 146 |
+
st.markdown("---")
|
| 147 |
+
|
| 148 |
+
# ============================================================================
|
| 149 |
+
# REGULAR EXPERIMENTS
|
| 150 |
+
# ============================================================================
|
| 151 |
+
if show_regular and len(regular_experiments) > 0:
|
| 152 |
+
st.header("📈 Regular Experiments")
|
| 153 |
+
|
| 154 |
+
# Create summary table
|
| 155 |
+
experiment_summary = []
|
| 156 |
+
|
| 157 |
+
for exp_file in sorted(regular_experiments, key=lambda x: x.stat().st_mtime, reverse=True):
|
| 158 |
+
try:
|
| 159 |
+
# Load experiment data
|
| 160 |
+
messages_df = pd.read_csv(exp_file, encoding='utf-8-sig')
|
| 161 |
+
|
| 162 |
+
# Extract experiment info
|
| 163 |
+
exp_id = exp_file.stem
|
| 164 |
+
exp_name = exp_file.stem.replace('messages_', '')
|
| 165 |
+
|
| 166 |
+
# Get timestamp from file
|
| 167 |
+
file_timestamp = datetime.fromtimestamp(exp_file.stat().st_mtime)
|
| 168 |
+
|
| 169 |
+
# Get feedback stats
|
| 170 |
+
feedback_stats = feedback_manager.get_feedback_stats(exp_id, total_messages=len(messages_df))
|
| 171 |
+
|
| 172 |
+
experiment_summary.append({
|
| 173 |
+
"Experiment": exp_name,
|
| 174 |
+
"Date": file_timestamp.strftime('%Y-%m-%d %H:%M'),
|
| 175 |
+
"Messages": len(messages_df),
|
| 176 |
+
"Users": messages_df['user_id'].nunique() if 'user_id' in messages_df.columns else 0,
|
| 177 |
+
"Stages": messages_df['stage'].nunique() if 'stage' in messages_df.columns else 0,
|
| 178 |
+
"Rejection Rate (%)": round(feedback_stats['reject_rate'], 1),
|
| 179 |
+
"Total Feedback": feedback_stats['total_feedback'],
|
| 180 |
+
"exp_id": exp_id,
|
| 181 |
+
"exp_file": exp_file
|
| 182 |
+
})
|
| 183 |
+
except Exception as e:
|
| 184 |
+
st.warning(f"⚠️ Error loading {exp_file.name}: {str(e)}")
|
| 185 |
+
continue
|
| 186 |
+
|
| 187 |
+
if len(experiment_summary) > 0:
|
| 188 |
+
summary_df = pd.DataFrame(experiment_summary)
|
| 189 |
+
|
| 190 |
+
# Display summary table
|
| 191 |
+
st.dataframe(
|
| 192 |
+
summary_df.drop(columns=['exp_id', 'exp_file']),
|
| 193 |
+
use_container_width=True,
|
| 194 |
+
hide_index=True
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
st.markdown("---")
|
| 198 |
+
|
| 199 |
+
# Rejection rate trend over time
|
| 200 |
+
st.subheader("📉 Rejection Rate Trend Over Time")
|
| 201 |
+
|
| 202 |
+
fig = go.Figure()
|
| 203 |
+
|
| 204 |
+
fig.add_trace(go.Scatter(
|
| 205 |
+
x=summary_df['Date'],
|
| 206 |
+
y=summary_df['Rejection Rate (%)'],
|
| 207 |
+
mode='lines+markers',
|
| 208 |
+
name='Rejection Rate',
|
| 209 |
+
line=dict(color=theme['accent'], width=3),
|
| 210 |
+
marker=dict(size=10)
|
| 211 |
+
))
|
| 212 |
+
|
| 213 |
+
fig.update_layout(
|
| 214 |
+
xaxis_title="Experiment Date",
|
| 215 |
+
yaxis_title="Rejection Rate (%)",
|
| 216 |
+
template="plotly_dark",
|
| 217 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 218 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 219 |
+
hovermode='x unified'
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 223 |
+
|
| 224 |
+
st.markdown("---")
|
| 225 |
+
|
| 226 |
+
# Detailed experiment view
|
| 227 |
+
st.subheader("🔍 Detailed Experiment View")
|
| 228 |
+
|
| 229 |
+
selected_exp = st.selectbox(
|
| 230 |
+
"Select Experiment to View Details",
|
| 231 |
+
summary_df['Experiment'].tolist(),
|
| 232 |
+
key="selected_regular_exp"
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
if selected_exp:
|
| 236 |
+
# Get selected experiment data
|
| 237 |
+
selected_row = summary_df[summary_df['Experiment'] == selected_exp].iloc[0]
|
| 238 |
+
exp_id = selected_row['exp_id']
|
| 239 |
+
exp_file = selected_row['exp_file']
|
| 240 |
+
|
| 241 |
+
# Load messages and feedback
|
| 242 |
+
messages_df = pd.read_csv(exp_file, encoding='utf-8-sig')
|
| 243 |
+
feedback_df = feedback_manager.load_feedback(exp_id)
|
| 244 |
+
|
| 245 |
+
col1, col2 = st.columns(2)
|
| 246 |
+
|
| 247 |
+
with col1:
|
| 248 |
+
st.markdown("### 📊 Experiment Metrics")
|
| 249 |
+
st.metric("Total Messages", len(messages_df))
|
| 250 |
+
st.metric("Unique Users", messages_df['user_id'].nunique() if 'user_id' in messages_df.columns else 0)
|
| 251 |
+
st.metric("Stages", messages_df['stage'].nunique() if 'stage' in messages_df.columns else 0)
|
| 252 |
+
st.metric("Rejection Rate", f"{selected_row['Rejection Rate (%)']}%")
|
| 253 |
+
|
| 254 |
+
with col2:
|
| 255 |
+
st.markdown("### 🔍 Rejection Reasons")
|
| 256 |
+
if feedback_df is not None and len(feedback_df) > 0:
|
| 257 |
+
reject_df = feedback_df[feedback_df['feedback_type'] == 'reject']
|
| 258 |
+
|
| 259 |
+
if len(reject_df) > 0:
|
| 260 |
+
# Get rejection reasons
|
| 261 |
+
reason_counts = reject_df['rejection_reason'].value_counts()
|
| 262 |
+
|
| 263 |
+
# Map to labels
|
| 264 |
+
reason_labels = {
|
| 265 |
+
feedback_manager.get_rejection_reason_label(k): v
|
| 266 |
+
for k, v in reason_counts.items()
|
| 267 |
+
if pd.notna(k)
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
# Create pie chart
|
| 271 |
+
if len(reason_labels) > 0:
|
| 272 |
+
fig = px.pie(
|
| 273 |
+
names=list(reason_labels.keys()),
|
| 274 |
+
values=list(reason_labels.values()),
|
| 275 |
+
color_discrete_sequence=px.colors.qualitative.Set3
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
fig.update_layout(
|
| 279 |
+
template="plotly_dark",
|
| 280 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 281 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 282 |
+
height=300
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 286 |
+
else:
|
| 287 |
+
st.info("No rejection feedback for this experiment")
|
| 288 |
+
else:
|
| 289 |
+
st.info("No feedback data available")
|
| 290 |
+
|
| 291 |
+
# Stage-by-stage performance
|
| 292 |
+
st.markdown("### 📈 Stage-by-Stage Performance")
|
| 293 |
+
stage_stats = feedback_manager.get_stage_feedback_stats(exp_id, messages_df=messages_df)
|
| 294 |
+
|
| 295 |
+
if len(stage_stats) > 0:
|
| 296 |
+
fig = go.Figure()
|
| 297 |
+
|
| 298 |
+
fig.add_trace(go.Bar(
|
| 299 |
+
x=stage_stats['stage'],
|
| 300 |
+
y=stage_stats['reject_rate'],
|
| 301 |
+
name='Rejection Rate (%)',
|
| 302 |
+
marker_color=theme['accent'],
|
| 303 |
+
text=stage_stats['reject_rate'].round(1),
|
| 304 |
+
textposition='auto',
|
| 305 |
+
))
|
| 306 |
+
|
| 307 |
+
fig.update_layout(
|
| 308 |
+
xaxis_title="Stage",
|
| 309 |
+
yaxis_title="Rejection Rate (%)",
|
| 310 |
+
template="plotly_dark",
|
| 311 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 312 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 316 |
+
else:
|
| 317 |
+
st.info("No stage performance data available")
|
| 318 |
+
|
| 319 |
+
# Show rejected messages with headers
|
| 320 |
+
if feedback_df is not None and len(feedback_df) > 0:
|
| 321 |
+
reject_df = feedback_df[feedback_df['feedback_type'] == 'reject']
|
| 322 |
+
|
| 323 |
+
if len(reject_df) > 0 and 'message_header' in reject_df.columns:
|
| 324 |
+
st.markdown("### ❌ Rejected Messages")
|
| 325 |
+
|
| 326 |
+
with st.expander("View Rejected Messages"):
|
| 327 |
+
display_df = reject_df[[
|
| 328 |
+
'user_id', 'stage', 'message_header', 'message_body',
|
| 329 |
+
'rejection_reason', 'rejection_text'
|
| 330 |
+
]].copy()
|
| 331 |
+
|
| 332 |
+
# Map rejection reason keys to labels
|
| 333 |
+
display_df['rejection_reason'] = display_df['rejection_reason'].apply(
|
| 334 |
+
lambda x: feedback_manager.get_rejection_reason_label(x) if pd.notna(x) else 'N/A'
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
st.dataframe(display_df, use_container_width=True, hide_index=True)
|
| 338 |
+
|
| 339 |
+
st.markdown("---")
|
| 340 |
+
|
| 341 |
+
# ============================================================================
|
| 342 |
+
# A/B TEST EXPERIMENTS
|
| 343 |
+
# ============================================================================
|
| 344 |
+
if show_ab and len(ab_experiments) > 0:
|
| 345 |
+
st.header("🧪 A/B Test Experiments")
|
| 346 |
+
|
| 347 |
+
# Group A/B experiments into pairs
|
| 348 |
+
ab_pairs = {}
|
| 349 |
+
for exp_file in ab_experiments:
|
| 350 |
+
exp_name = exp_file.stem
|
| 351 |
+
if '_a_' in exp_name.lower():
|
| 352 |
+
base_name = exp_name.lower().replace('messages_a_', '').replace('_a_', '_')
|
| 353 |
+
if base_name not in ab_pairs:
|
| 354 |
+
ab_pairs[base_name] = {}
|
| 355 |
+
ab_pairs[base_name]['A'] = exp_file
|
| 356 |
+
elif '_b_' in exp_name.lower():
|
| 357 |
+
base_name = exp_name.lower().replace('messages_b_', '').replace('_b_', '_')
|
| 358 |
+
if base_name not in ab_pairs:
|
| 359 |
+
ab_pairs[base_name] = {}
|
| 360 |
+
ab_pairs[base_name]['B'] = exp_file
|
| 361 |
+
|
| 362 |
+
# Display A/B test pairs
|
| 363 |
+
for base_name, pair in sorted(ab_pairs.items(), key=lambda x: x[0], reverse=True):
|
| 364 |
+
if 'A' in pair and 'B' in pair:
|
| 365 |
+
with st.expander(f"🧪 {base_name.replace('_', ' ').title()}", expanded=False):
|
| 366 |
+
exp_a_file = pair['A']
|
| 367 |
+
exp_b_file = pair['B']
|
| 368 |
+
|
| 369 |
+
exp_a_id = exp_a_file.stem
|
| 370 |
+
exp_b_id = exp_b_file.stem
|
| 371 |
+
|
| 372 |
+
try:
|
| 373 |
+
# Load both experiments
|
| 374 |
+
messages_a = pd.read_csv(exp_a_file, encoding='utf-8-sig')
|
| 375 |
+
messages_b = pd.read_csv(exp_b_file, encoding='utf-8-sig')
|
| 376 |
+
|
| 377 |
+
# Get feedback stats
|
| 378 |
+
stats_a = feedback_manager.get_feedback_stats(exp_a_id, total_messages=len(messages_a))
|
| 379 |
+
stats_b = feedback_manager.get_feedback_stats(exp_b_id, total_messages=len(messages_b))
|
| 380 |
+
|
| 381 |
+
# Comparison metrics
|
| 382 |
+
col1, col2, col3 = st.columns(3)
|
| 383 |
+
|
| 384 |
+
with col1:
|
| 385 |
+
st.markdown("### 🅰️ Experiment A")
|
| 386 |
+
st.metric("Messages", len(messages_a))
|
| 387 |
+
st.metric("Rejection Rate", f"{stats_a['reject_rate']:.1f}%")
|
| 388 |
+
st.metric("Total Feedback", stats_a['total_feedback'])
|
| 389 |
+
|
| 390 |
+
with col2:
|
| 391 |
+
st.markdown("### 🅱️ Experiment B")
|
| 392 |
+
st.metric("Messages", len(messages_b))
|
| 393 |
+
st.metric("Rejection Rate", f"{stats_b['reject_rate']:.1f}%")
|
| 394 |
+
st.metric("Total Feedback", stats_b['total_feedback'])
|
| 395 |
+
|
| 396 |
+
with col3:
|
| 397 |
+
st.markdown("### 🏆 Winner")
|
| 398 |
+
diff = stats_a['reject_rate'] - stats_b['reject_rate']
|
| 399 |
+
|
| 400 |
+
if diff < -1:
|
| 401 |
+
st.success("🏆 Experiment A")
|
| 402 |
+
st.metric("Better by", f"{abs(diff):.1f}%")
|
| 403 |
+
elif diff > 1:
|
| 404 |
+
st.success("🏆 Experiment B")
|
| 405 |
+
st.metric("Better by", f"{abs(diff):.1f}%")
|
| 406 |
+
else:
|
| 407 |
+
st.info("➡️ Similar Performance")
|
| 408 |
+
|
| 409 |
+
st.markdown("---")
|
| 410 |
+
|
| 411 |
+
# Side-by-side rejection reasons comparison
|
| 412 |
+
st.markdown("#### Rejection Reasons Comparison")
|
| 413 |
+
|
| 414 |
+
col1, col2 = st.columns(2)
|
| 415 |
+
|
| 416 |
+
with col1:
|
| 417 |
+
reasons_a = stats_a['rejection_reasons']
|
| 418 |
+
if len(reasons_a) > 0:
|
| 419 |
+
df_a = pd.DataFrame([
|
| 420 |
+
{"Reason": k, "Count": v}
|
| 421 |
+
for k, v in reasons_a.items()
|
| 422 |
+
])
|
| 423 |
+
|
| 424 |
+
fig_a = px.bar(
|
| 425 |
+
df_a,
|
| 426 |
+
x='Reason',
|
| 427 |
+
y='Count',
|
| 428 |
+
title="Experiment A - Rejection Reasons",
|
| 429 |
+
color='Count',
|
| 430 |
+
color_continuous_scale='Blues'
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
fig_a.update_layout(
|
| 434 |
+
template="plotly_dark",
|
| 435 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 436 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 437 |
+
xaxis_tickangle=-45,
|
| 438 |
+
height=300
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
st.plotly_chart(fig_a, use_container_width=True)
|
| 442 |
+
else:
|
| 443 |
+
st.info("No rejection feedback for Experiment A")
|
| 444 |
+
|
| 445 |
+
with col2:
|
| 446 |
+
reasons_b = stats_b['rejection_reasons']
|
| 447 |
+
if len(reasons_b) > 0:
|
| 448 |
+
df_b = pd.DataFrame([
|
| 449 |
+
{"Reason": k, "Count": v}
|
| 450 |
+
for k, v in reasons_b.items()
|
| 451 |
+
])
|
| 452 |
+
|
| 453 |
+
fig_b = px.bar(
|
| 454 |
+
df_b,
|
| 455 |
+
x='Reason',
|
| 456 |
+
y='Count',
|
| 457 |
+
title="Experiment B - Rejection Reasons",
|
| 458 |
+
color='Count',
|
| 459 |
+
color_continuous_scale='Reds'
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
fig_b.update_layout(
|
| 463 |
+
template="plotly_dark",
|
| 464 |
+
paper_bgcolor='rgba(0,0,0,0)',
|
| 465 |
+
plot_bgcolor='rgba(0,0,0,0)',
|
| 466 |
+
xaxis_tickangle=-45,
|
| 467 |
+
height=300
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
st.plotly_chart(fig_b, use_container_width=True)
|
| 471 |
+
else:
|
| 472 |
+
st.info("No rejection feedback for Experiment B")
|
| 473 |
+
|
| 474 |
+
except Exception as e:
|
| 475 |
+
st.error(f"Error loading A/B test data: {str(e)}")
|
| 476 |
+
|
| 477 |
+
st.markdown("---")
|
| 478 |
+
|
| 479 |
+
# Export options
|
| 480 |
+
st.header("💾 Export Historical Data")
|
| 481 |
+
|
| 482 |
+
col1, col2 = st.columns(2)
|
| 483 |
+
|
| 484 |
+
with col1:
|
| 485 |
+
if st.button("📥 Export All Experiments Summary", use_container_width=True):
|
| 486 |
+
if len(experiment_summary) > 0:
|
| 487 |
+
summary_export_df = pd.DataFrame(experiment_summary).drop(columns=['exp_id', 'exp_file'])
|
| 488 |
+
csv = summary_export_df.to_csv(index=False, encoding='utf-8')
|
| 489 |
+
st.download_button(
|
| 490 |
+
label="⬇️ Download Summary CSV",
|
| 491 |
+
data=csv,
|
| 492 |
+
file_name=f"{brand}_experiments_summary_{datetime.now().strftime('%Y%m%d')}.csv",
|
| 493 |
+
mime="text/csv",
|
| 494 |
+
use_container_width=True
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
with col2:
|
| 498 |
+
if st.button("📥 Export All Feedback", use_container_width=True):
|
| 499 |
+
# Combine all feedback files
|
| 500 |
+
all_feedback = []
|
| 501 |
+
for exp_file in regular_experiments + ab_experiments:
|
| 502 |
+
exp_id = exp_file.stem
|
| 503 |
+
feedback_df = feedback_manager.load_feedback(exp_id)
|
| 504 |
+
if feedback_df is not None and len(feedback_df) > 0:
|
| 505 |
+
all_feedback.append(feedback_df)
|
| 506 |
+
|
| 507 |
+
if len(all_feedback) > 0:
|
| 508 |
+
combined_feedback = pd.concat(all_feedback, ignore_index=True)
|
| 509 |
+
csv = combined_feedback.to_csv(index=False, encoding='utf-8')
|
| 510 |
+
st.download_button(
|
| 511 |
+
label="⬇️ Download All Feedback CSV",
|
| 512 |
+
data=csv,
|
| 513 |
+
file_name=f"{brand}_all_feedback_{datetime.now().strftime('%Y%m%d')}.csv",
|
| 514 |
+
mime="text/csv",
|
| 515 |
+
use_container_width=True
|
| 516 |
+
)
|
| 517 |
+
|
| 518 |
+
st.markdown("---")
|
| 519 |
+
st.markdown("**💡 Tip:** Use historical analytics to track improvements over time and identify patterns in rejection reasons!")
|
utils/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility modules for the AI Messaging System Visualization Tool.
|
| 3 |
+
|
| 4 |
+
Modules:
|
| 5 |
+
- auth: Authentication and access control
|
| 6 |
+
- data_loader: Data loading from Snowflake and CSV files
|
| 7 |
+
- config_manager: Configuration persistence and management
|
| 8 |
+
- theme: Brand-specific theming and styling
|
| 9 |
+
- feedback_manager: Feedback storage and retrieval
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from .auth import verify_login, check_authentication, AUTHORIZED_EMAILS
|
| 13 |
+
from .theme import get_brand_theme, apply_theme, BRAND_COLORS
|
| 14 |
+
from .data_loader import DataLoader
|
| 15 |
+
from .config_manager import ConfigManager
|
| 16 |
+
from .feedback_manager import FeedbackManager
|
| 17 |
+
|
| 18 |
+
__all__ = [
|
| 19 |
+
'verify_login',
|
| 20 |
+
'check_authentication',
|
| 21 |
+
'AUTHORIZED_EMAILS',
|
| 22 |
+
'get_brand_theme',
|
| 23 |
+
'apply_theme',
|
| 24 |
+
'BRAND_COLORS',
|
| 25 |
+
'DataLoader',
|
| 26 |
+
'ConfigManager',
|
| 27 |
+
'FeedbackManager',
|
| 28 |
+
]
|
utils/auth.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication module for the AI Messaging System Visualization Tool.
|
| 3 |
+
|
| 4 |
+
Handles user authentication and access control.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import streamlit as st
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
|
| 12 |
+
# Load environment variables from .env file in visualization directory
|
| 13 |
+
env_path = Path(__file__).parent.parent / '.env'
|
| 14 |
+
if env_path.exists():
|
| 15 |
+
load_dotenv(env_path)
|
| 16 |
+
else:
|
| 17 |
+
# Try parent directory .env
|
| 18 |
+
parent_env_path = Path(__file__).parent.parent.parent / '.env'
|
| 19 |
+
if parent_env_path.exists():
|
| 20 |
+
load_dotenv(parent_env_path)
|
| 21 |
+
|
| 22 |
+
# Authorized emails - team members only
|
| 23 |
+
AUTHORIZED_EMAILS = {
|
| 24 |
+
"danial@musora.com",
|
| 25 |
+
"danial.ebrat@gmail.com",
|
| 26 |
+
"simon@musora.com",
|
| 27 |
+
"una@musora.com",
|
| 28 |
+
"mark@musora.com",
|
| 29 |
+
"gabriel@musora.com",
|
| 30 |
+
"nikki@musora.com"
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_credential(key: str) -> str:
|
| 35 |
+
"""
|
| 36 |
+
Get credential from environment variables.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
key: Credential key
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
str: Credential value
|
| 43 |
+
"""
|
| 44 |
+
return os.getenv(key, "")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_valid_token() -> str:
|
| 48 |
+
"""
|
| 49 |
+
Get the valid access token from environment.
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
str: Valid access token
|
| 53 |
+
"""
|
| 54 |
+
return get_credential("APP_TOKEN")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def verify_login(email: str, token: str) -> bool:
|
| 58 |
+
"""
|
| 59 |
+
Verify user login credentials.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
email: User email address
|
| 63 |
+
token: Access token
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
bool: True if credentials are valid, False otherwise
|
| 67 |
+
"""
|
| 68 |
+
valid_token = get_valid_token()
|
| 69 |
+
email_normalized = email.lower().strip()
|
| 70 |
+
|
| 71 |
+
return (email_normalized in AUTHORIZED_EMAILS) and (token == valid_token)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def check_authentication() -> bool:
|
| 75 |
+
"""
|
| 76 |
+
Check if user is authenticated in current session.
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
bool: True if authenticated, False otherwise
|
| 80 |
+
"""
|
| 81 |
+
return st.session_state.get("authenticated", False)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_current_user() -> str:
|
| 85 |
+
"""
|
| 86 |
+
Get the currently logged-in user's email.
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
str: User email or empty string if not authenticated
|
| 90 |
+
"""
|
| 91 |
+
return st.session_state.get("user_email", "")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def logout():
|
| 95 |
+
"""
|
| 96 |
+
Log out the current user by clearing session state.
|
| 97 |
+
"""
|
| 98 |
+
if "authenticated" in st.session_state:
|
| 99 |
+
del st.session_state["authenticated"]
|
| 100 |
+
if "user_email" in st.session_state:
|
| 101 |
+
del st.session_state["user_email"]
|
utils/config_manager.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration management module for the AI Messaging System Visualization Tool.
|
| 3 |
+
|
| 4 |
+
Handles saving, loading, and managing campaign configurations.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, List, Optional
|
| 10 |
+
import streamlit as st
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ConfigManager:
|
| 14 |
+
"""
|
| 15 |
+
Manages campaign configurations including loading defaults and persisting custom configs.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
"""Initialize ConfigManager."""
|
| 20 |
+
self.base_path = Path(__file__).parent.parent
|
| 21 |
+
self.configs_path = self.base_path / "data" / "configs"
|
| 22 |
+
|
| 23 |
+
# Create directory if it doesn't exist
|
| 24 |
+
self.configs_path.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
|
| 26 |
+
def load_default_config(self, brand: str, campaign_type: str = "re_engagement") -> Dict:
|
| 27 |
+
"""
|
| 28 |
+
Load default configuration from local JSON files.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
brand: Brand name
|
| 32 |
+
campaign_type: Campaign type (default: re_engagement)
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
dict: Campaign configuration
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
# Load from local configs directory
|
| 39 |
+
filename = f"{brand}_{campaign_type}_test.json"
|
| 40 |
+
file_path = self.configs_path / filename
|
| 41 |
+
|
| 42 |
+
if not file_path.exists():
|
| 43 |
+
st.error(f"Default config file not found: {filename}")
|
| 44 |
+
return {}
|
| 45 |
+
|
| 46 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 47 |
+
config = json.load(f)
|
| 48 |
+
|
| 49 |
+
return config
|
| 50 |
+
except Exception as e:
|
| 51 |
+
st.error(f"Error loading default config: {e}")
|
| 52 |
+
return {}
|
| 53 |
+
|
| 54 |
+
def save_custom_config(self, brand: str, config_name: str, config: Dict) -> bool:
|
| 55 |
+
"""
|
| 56 |
+
Save custom configuration to JSON file.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
brand: Brand name
|
| 60 |
+
config_name: Configuration name
|
| 61 |
+
config: Configuration dictionary
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
bool: True if saved successfully, False otherwise
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
# Create filename
|
| 68 |
+
filename = f"{brand}_{config_name}.json"
|
| 69 |
+
file_path = self.configs_path / filename
|
| 70 |
+
|
| 71 |
+
# Save to JSON
|
| 72 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 73 |
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
| 74 |
+
|
| 75 |
+
return True
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
st.error(f"Error saving config: {e}")
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
def load_custom_config(self, brand: str, config_name: str) -> Optional[Dict]:
|
| 82 |
+
"""
|
| 83 |
+
Load custom configuration from JSON file.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
brand: Brand name
|
| 87 |
+
config_name: Configuration name
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
dict or None: Configuration dictionary or None if not found
|
| 91 |
+
"""
|
| 92 |
+
try:
|
| 93 |
+
filename = f"{brand}_{config_name}.json"
|
| 94 |
+
file_path = self.configs_path / filename
|
| 95 |
+
|
| 96 |
+
if not file_path.exists():
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 100 |
+
config = json.load(f)
|
| 101 |
+
|
| 102 |
+
return config
|
| 103 |
+
|
| 104 |
+
except Exception as e:
|
| 105 |
+
st.error(f"Error loading config: {e}")
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
def list_custom_configs(self, brand: str) -> List[str]:
|
| 109 |
+
"""
|
| 110 |
+
List all custom configurations for a brand.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
brand: Brand name
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
list: List of configuration names
|
| 117 |
+
"""
|
| 118 |
+
try:
|
| 119 |
+
# Find all JSON files for this brand
|
| 120 |
+
pattern = f"{brand}_*.json"
|
| 121 |
+
config_files = list(self.configs_path.glob(pattern))
|
| 122 |
+
|
| 123 |
+
# Extract config names (remove brand prefix and .json extension)
|
| 124 |
+
config_names = []
|
| 125 |
+
for file in config_files:
|
| 126 |
+
name = file.stem # filename without extension
|
| 127 |
+
# Remove brand prefix
|
| 128 |
+
config_name = name.replace(f"{brand}_", "", 1)
|
| 129 |
+
config_names.append(config_name)
|
| 130 |
+
|
| 131 |
+
return sorted(config_names)
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
st.error(f"Error listing configs: {e}")
|
| 135 |
+
return []
|
| 136 |
+
|
| 137 |
+
def delete_custom_config(self, brand: str, config_name: str) -> bool:
|
| 138 |
+
"""
|
| 139 |
+
Delete a custom configuration.
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
brand: Brand name
|
| 143 |
+
config_name: Configuration name
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
bool: True if deleted successfully, False otherwise
|
| 147 |
+
"""
|
| 148 |
+
try:
|
| 149 |
+
filename = f"{brand}_{config_name}.json"
|
| 150 |
+
file_path = self.configs_path / filename
|
| 151 |
+
|
| 152 |
+
if file_path.exists():
|
| 153 |
+
file_path.unlink()
|
| 154 |
+
return True
|
| 155 |
+
return False
|
| 156 |
+
|
| 157 |
+
except Exception as e:
|
| 158 |
+
st.error(f"Error deleting config: {e}")
|
| 159 |
+
return False
|
| 160 |
+
|
| 161 |
+
def config_exists(self, brand: str, config_name: str) -> bool:
|
| 162 |
+
"""
|
| 163 |
+
Check if a custom configuration exists.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
brand: Brand name
|
| 167 |
+
config_name: Configuration name
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
bool: True if exists, False otherwise
|
| 171 |
+
"""
|
| 172 |
+
filename = f"{brand}_{config_name}.json"
|
| 173 |
+
file_path = self.configs_path / filename
|
| 174 |
+
return file_path.exists()
|
| 175 |
+
|
| 176 |
+
def get_all_configs(self, brand: str) -> Dict[str, Dict]:
|
| 177 |
+
"""
|
| 178 |
+
Get all configurations (default + custom) for a brand.
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
brand: Brand name
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
dict: Dictionary mapping config names to config dictionaries
|
| 185 |
+
"""
|
| 186 |
+
configs = {}
|
| 187 |
+
|
| 188 |
+
# Add default config as "re_engagement_test"
|
| 189 |
+
default_config = self.load_default_config(brand)
|
| 190 |
+
if default_config:
|
| 191 |
+
configs["re_engagement_test"] = default_config
|
| 192 |
+
|
| 193 |
+
# Add custom configs
|
| 194 |
+
custom_names = self.list_custom_configs(brand)
|
| 195 |
+
for name in custom_names:
|
| 196 |
+
# Skip re_engagement_test if it's already in the list (avoid duplicates)
|
| 197 |
+
if name == "re_engagement_test":
|
| 198 |
+
continue
|
| 199 |
+
custom_config = self.load_custom_config(brand, name)
|
| 200 |
+
if custom_config:
|
| 201 |
+
configs[name] = custom_config
|
| 202 |
+
|
| 203 |
+
return configs
|
| 204 |
+
|
| 205 |
+
def create_config_from_ui(
|
| 206 |
+
self,
|
| 207 |
+
brand: str,
|
| 208 |
+
campaign_name: str,
|
| 209 |
+
campaign_type: str,
|
| 210 |
+
num_stages: int,
|
| 211 |
+
campaign_instructions: Optional[str],
|
| 212 |
+
stages_config: Dict[int, Dict]
|
| 213 |
+
) -> Dict:
|
| 214 |
+
"""
|
| 215 |
+
Create a configuration dictionary from UI inputs.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
brand: Brand name
|
| 219 |
+
campaign_name: Campaign name
|
| 220 |
+
campaign_type: Campaign type
|
| 221 |
+
num_stages: Number of stages
|
| 222 |
+
campaign_instructions: Campaign-wide instructions (optional)
|
| 223 |
+
stages_config: Dictionary mapping stage numbers to stage configurations
|
| 224 |
+
|
| 225 |
+
Returns:
|
| 226 |
+
dict: Complete configuration dictionary
|
| 227 |
+
"""
|
| 228 |
+
config = {
|
| 229 |
+
"brand": brand,
|
| 230 |
+
"campaign_type": campaign_type,
|
| 231 |
+
"campaign_name": campaign_name,
|
| 232 |
+
"campaign_instructions": campaign_instructions,
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
# Add stage configurations
|
| 236 |
+
for stage_num in range(1, num_stages + 1):
|
| 237 |
+
if stage_num in stages_config:
|
| 238 |
+
config[str(stage_num)] = stages_config[stage_num]
|
| 239 |
+
|
| 240 |
+
return config
|
| 241 |
+
|
| 242 |
+
def extract_stage_config(self, config: Dict, stage: int) -> Dict:
|
| 243 |
+
"""
|
| 244 |
+
Extract configuration for a specific stage.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
config: Complete configuration dictionary
|
| 248 |
+
stage: Stage number
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
dict: Stage configuration
|
| 252 |
+
"""
|
| 253 |
+
return config.get(str(stage), {})
|
| 254 |
+
|
| 255 |
+
def validate_config(self, config: Dict) -> tuple[bool, str]:
|
| 256 |
+
"""
|
| 257 |
+
Validate configuration structure and required fields.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
config: Configuration dictionary
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
tuple: (is_valid, error_message)
|
| 264 |
+
"""
|
| 265 |
+
required_fields = ["brand", "campaign_name"]
|
| 266 |
+
|
| 267 |
+
# Check required top-level fields
|
| 268 |
+
for field in required_fields:
|
| 269 |
+
if field not in config:
|
| 270 |
+
return False, f"Missing required field: {field}"
|
| 271 |
+
|
| 272 |
+
# Check if at least stage 1 exists
|
| 273 |
+
if "1" not in config:
|
| 274 |
+
return False, "Configuration must have at least stage 1"
|
| 275 |
+
|
| 276 |
+
# Validate stage 1 config
|
| 277 |
+
stage1 = config["1"]
|
| 278 |
+
required_stage_fields = ["stage", "model"]
|
| 279 |
+
|
| 280 |
+
for field in required_stage_fields:
|
| 281 |
+
if field not in stage1:
|
| 282 |
+
return False, f"Stage 1 missing required field: {field}"
|
| 283 |
+
|
| 284 |
+
return True, ""
|
| 285 |
+
|
| 286 |
+
def duplicate_config(self, brand: str, source_name: str, new_name: str) -> bool:
|
| 287 |
+
"""
|
| 288 |
+
Duplicate an existing configuration with a new name.
|
| 289 |
+
|
| 290 |
+
Args:
|
| 291 |
+
brand: Brand name
|
| 292 |
+
source_name: Source configuration name
|
| 293 |
+
new_name: New configuration name
|
| 294 |
+
|
| 295 |
+
Returns:
|
| 296 |
+
bool: True if duplicated successfully, False otherwise
|
| 297 |
+
"""
|
| 298 |
+
try:
|
| 299 |
+
# Load source config
|
| 300 |
+
if source_name == "re_engagement_test":
|
| 301 |
+
source_config = self.load_default_config(brand)
|
| 302 |
+
else:
|
| 303 |
+
source_config = self.load_custom_config(brand, source_name)
|
| 304 |
+
|
| 305 |
+
if source_config is None:
|
| 306 |
+
return False
|
| 307 |
+
|
| 308 |
+
# Save as new config
|
| 309 |
+
return self.save_custom_config(brand, new_name, source_config)
|
| 310 |
+
|
| 311 |
+
except Exception as e:
|
| 312 |
+
st.error(f"Error duplicating config: {e}")
|
| 313 |
+
return False
|
utils/data_loader.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data loading module for the AI Messaging System Visualization Tool.
|
| 3 |
+
|
| 4 |
+
Handles data loading from local CSV files only.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional
|
| 11 |
+
import streamlit as st
|
| 12 |
+
|
| 13 |
+
# Add parent directory to path to import from ai_messaging_system_v2
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
from ai_messaging_system_v2.Messaging_system.Permes import Permes
|
| 18 |
+
except ImportError as e:
|
| 19 |
+
st.error(f"Error importing AI Messaging System modules: {e}")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class DataLoader:
|
| 23 |
+
"""
|
| 24 |
+
Handles all data loading operations for the visualization tool.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
"""Initialize DataLoader."""
|
| 29 |
+
self.base_path = Path(__file__).parent.parent
|
| 30 |
+
self.ui_users_path = self.base_path / "data" / "UI_users"
|
| 31 |
+
self.ui_output_path = Path(__file__).parent.parent.parent / "ai_messaging_system_v2" / "Data" / "ui_output"
|
| 32 |
+
|
| 33 |
+
# Create directories if they don't exist
|
| 34 |
+
self.ui_users_path.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
self.ui_output_path.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
|
| 37 |
+
def load_brand_users(self, brand: str) -> Optional[pd.DataFrame]:
|
| 38 |
+
"""
|
| 39 |
+
Load all users for a brand from UI_users directory.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
brand: Brand name
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
pd.DataFrame or None: All users for the brand or None if file doesn't exist
|
| 46 |
+
"""
|
| 47 |
+
file_path = self.ui_users_path / f"{brand}_users.csv"
|
| 48 |
+
|
| 49 |
+
if not file_path.exists():
|
| 50 |
+
st.error(f"User file not found: {brand}_users.csv")
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
users_df = pd.read_csv(file_path, encoding='utf-8-sig')
|
| 55 |
+
return users_df
|
| 56 |
+
except Exception as e:
|
| 57 |
+
st.error(f"Error loading users from {brand}_users.csv: {e}")
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
def sample_users_randomly(self, brand: str, sample_size: int) -> Optional[pd.DataFrame]:
|
| 61 |
+
"""
|
| 62 |
+
Randomly sample users from the brand's user file.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
brand: Brand name
|
| 66 |
+
sample_size: Number of users to sample (1-25)
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
pd.DataFrame or None: Sampled users or None if error
|
| 70 |
+
"""
|
| 71 |
+
try:
|
| 72 |
+
# Load all users for the brand
|
| 73 |
+
all_users = self.load_brand_users(brand)
|
| 74 |
+
|
| 75 |
+
if all_users is None or len(all_users) == 0:
|
| 76 |
+
st.error(f"No users available for {brand}")
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
# Validate sample size
|
| 80 |
+
if sample_size < 1:
|
| 81 |
+
sample_size = 1
|
| 82 |
+
if sample_size > min(25, len(all_users)):
|
| 83 |
+
sample_size = min(25, len(all_users))
|
| 84 |
+
|
| 85 |
+
# Randomly sample
|
| 86 |
+
sampled_users = all_users.sample(n=sample_size, random_state=None)
|
| 87 |
+
|
| 88 |
+
return sampled_users
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
st.error(f"Error sampling users: {e}")
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
def has_brand_users(self, brand: str) -> bool:
|
| 95 |
+
"""
|
| 96 |
+
Check if user file exists for a brand.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
brand: Brand name
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
bool: True if file exists, False otherwise
|
| 103 |
+
"""
|
| 104 |
+
file_path = self.ui_users_path / f"{brand}_users.csv"
|
| 105 |
+
return file_path.exists()
|
| 106 |
+
|
| 107 |
+
def get_brand_user_count(self, brand: str) -> int:
|
| 108 |
+
"""
|
| 109 |
+
Get the total number of users available for a brand.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
brand: Brand name
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
int: Number of users available
|
| 116 |
+
"""
|
| 117 |
+
users_df = self.load_brand_users(brand)
|
| 118 |
+
if users_df is not None:
|
| 119 |
+
return len(users_df)
|
| 120 |
+
return 0
|
| 121 |
+
|
| 122 |
+
def load_generated_messages(self) -> Optional[pd.DataFrame]:
|
| 123 |
+
"""
|
| 124 |
+
Load generated messages from UI output folder.
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
pd.DataFrame or None: Generated messages or None if file doesn't exist
|
| 128 |
+
"""
|
| 129 |
+
messages_file = self.ui_output_path / "messages.csv"
|
| 130 |
+
|
| 131 |
+
if messages_file.exists():
|
| 132 |
+
return pd.read_csv(messages_file, encoding='utf-8-sig')
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
def clear_ui_output(self):
|
| 136 |
+
"""
|
| 137 |
+
Clear UI output files (messages and costs).
|
| 138 |
+
"""
|
| 139 |
+
try:
|
| 140 |
+
Permes.clear_ui_output()
|
| 141 |
+
st.success("UI output cleared successfully!")
|
| 142 |
+
except Exception as e:
|
| 143 |
+
st.error(f"Error clearing UI output: {e}")
|
| 144 |
+
|
| 145 |
+
def get_ui_output_path(self) -> Path:
|
| 146 |
+
"""
|
| 147 |
+
Get path to UI output directory.
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
Path: UI output directory path
|
| 151 |
+
"""
|
| 152 |
+
return self.ui_output_path
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def get_message_stats(self) -> dict:
|
| 156 |
+
"""
|
| 157 |
+
Get statistics about generated messages.
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
dict: Statistics including total messages, stages, users, etc.
|
| 161 |
+
"""
|
| 162 |
+
messages_df = self.load_generated_messages()
|
| 163 |
+
|
| 164 |
+
if messages_df is None or len(messages_df) == 0:
|
| 165 |
+
return {
|
| 166 |
+
"total_messages": 0,
|
| 167 |
+
"total_users": 0,
|
| 168 |
+
"total_stages": 0,
|
| 169 |
+
"campaigns": []
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
stats = {
|
| 173 |
+
"total_messages": len(messages_df),
|
| 174 |
+
"total_users": messages_df['user_id'].nunique() if 'user_id' in messages_df.columns else 0,
|
| 175 |
+
"total_stages": messages_df['stage'].nunique() if 'stage' in messages_df.columns else 0,
|
| 176 |
+
"campaigns": messages_df['campaign_name'].unique().tolist() if 'campaign_name' in messages_df.columns else []
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
return stats
|
| 180 |
+
|
| 181 |
+
def filter_messages_by_stage(self, messages_df: pd.DataFrame, stage: int) -> pd.DataFrame:
|
| 182 |
+
"""
|
| 183 |
+
Filter messages by stage number.
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
messages_df: Messages DataFrame
|
| 187 |
+
stage: Stage number
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
pd.DataFrame: Filtered messages
|
| 191 |
+
"""
|
| 192 |
+
if 'stage' in messages_df.columns:
|
| 193 |
+
return messages_df[messages_df['stage'] == stage]
|
| 194 |
+
return pd.DataFrame()
|
| 195 |
+
|
| 196 |
+
def filter_messages_by_user(self, messages_df: pd.DataFrame, user_id: int) -> pd.DataFrame:
|
| 197 |
+
"""
|
| 198 |
+
Filter messages by user ID.
|
| 199 |
+
|
| 200 |
+
Args:
|
| 201 |
+
messages_df: Messages DataFrame
|
| 202 |
+
user_id: User ID
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
pd.DataFrame: Filtered messages
|
| 206 |
+
"""
|
| 207 |
+
if 'user_id' in messages_df.columns:
|
| 208 |
+
return messages_df[messages_df['user_id'] == user_id]
|
| 209 |
+
return pd.DataFrame()
|
| 210 |
+
|
| 211 |
+
def search_messages(self, messages_df: pd.DataFrame, search_term: str) -> pd.DataFrame:
|
| 212 |
+
"""
|
| 213 |
+
Search messages by keyword in message content.
|
| 214 |
+
|
| 215 |
+
Args:
|
| 216 |
+
messages_df: Messages DataFrame
|
| 217 |
+
search_term: Search keyword
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
pd.DataFrame: Filtered messages containing the search term
|
| 221 |
+
"""
|
| 222 |
+
if len(search_term) == 0:
|
| 223 |
+
return messages_df
|
| 224 |
+
|
| 225 |
+
# Search in message column (which contains JSON)
|
| 226 |
+
if 'message' in messages_df.columns:
|
| 227 |
+
mask = messages_df['message'].astype(str).str.contains(search_term, case=False, na=False)
|
| 228 |
+
return messages_df[mask]
|
| 229 |
+
|
| 230 |
+
return pd.DataFrame()
|
utils/feedback_manager.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feedback management module for the AI Messaging System Visualization Tool.
|
| 3 |
+
|
| 4 |
+
Handles storage and retrieval of user feedback on generated messages.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import Optional, List, Dict
|
| 11 |
+
import streamlit as st
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Rejection reason categories
|
| 15 |
+
REJECTION_REASONS = {
|
| 16 |
+
"poor_header": "Poor Header",
|
| 17 |
+
"poor_body": "Poor Body/Content",
|
| 18 |
+
"grammar_issues": "Grammar Issues",
|
| 19 |
+
"emoji_problems": "Emoji Problems",
|
| 20 |
+
"recommendation_issues": "Recommendation Issues",
|
| 21 |
+
"wrong_information": "Wrong/Inaccurate Information",
|
| 22 |
+
"tone_issues": "Tone Issues",
|
| 23 |
+
"similarity": "Similar To Previous Header/Messages",
|
| 24 |
+
"other": "Other"
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class FeedbackManager:
|
| 29 |
+
"""
|
| 30 |
+
Manages feedback storage and retrieval for generated messages.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(self):
|
| 34 |
+
"""Initialize FeedbackManager."""
|
| 35 |
+
self.base_path = Path(__file__).parent.parent
|
| 36 |
+
self.feedback_path = self.base_path / "data" / "feedback"
|
| 37 |
+
|
| 38 |
+
# Create directory if it doesn't exist
|
| 39 |
+
self.feedback_path.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
|
| 41 |
+
def get_feedback_file_path(self, experiment_id: str) -> Path:
|
| 42 |
+
"""
|
| 43 |
+
Get path to feedback file for an experiment.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
experiment_id: Experiment ID
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Path: Feedback file path
|
| 50 |
+
"""
|
| 51 |
+
return self.feedback_path / f"{experiment_id}_feedback.csv"
|
| 52 |
+
|
| 53 |
+
def save_feedback(
|
| 54 |
+
self,
|
| 55 |
+
experiment_id: str,
|
| 56 |
+
user_id: int,
|
| 57 |
+
stage: int,
|
| 58 |
+
feedback_type: str,
|
| 59 |
+
rejection_reason: Optional[str] = None,
|
| 60 |
+
rejection_text: Optional[str] = None,
|
| 61 |
+
campaign_name: Optional[str] = None,
|
| 62 |
+
message_header: Optional[str] = None,
|
| 63 |
+
message_body: Optional[str] = None
|
| 64 |
+
) -> bool:
|
| 65 |
+
"""
|
| 66 |
+
Save feedback for a message.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
experiment_id: Experiment ID
|
| 70 |
+
user_id: User ID
|
| 71 |
+
stage: Stage number
|
| 72 |
+
feedback_type: 'reject' (we focus on rejects)
|
| 73 |
+
rejection_reason: Category of rejection (optional)
|
| 74 |
+
rejection_text: Additional text for rejection (optional)
|
| 75 |
+
campaign_name: Campaign name (optional)
|
| 76 |
+
message_header: Message header text (optional)
|
| 77 |
+
message_body: Message body text (optional)
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
bool: True if saved successfully, False otherwise
|
| 81 |
+
"""
|
| 82 |
+
try:
|
| 83 |
+
feedback_file = self.get_feedback_file_path(experiment_id)
|
| 84 |
+
|
| 85 |
+
# Create feedback record
|
| 86 |
+
feedback_record = {
|
| 87 |
+
"experiment_id": experiment_id,
|
| 88 |
+
"user_id": user_id,
|
| 89 |
+
"stage": stage,
|
| 90 |
+
"feedback_type": feedback_type,
|
| 91 |
+
"rejection_reason": rejection_reason,
|
| 92 |
+
"rejection_text": rejection_text,
|
| 93 |
+
"campaign_name": campaign_name,
|
| 94 |
+
"message_header": message_header,
|
| 95 |
+
"message_body": message_body,
|
| 96 |
+
"timestamp": datetime.now().isoformat()
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
# Convert to DataFrame
|
| 100 |
+
new_feedback = pd.DataFrame([feedback_record])
|
| 101 |
+
|
| 102 |
+
# Append to existing file or create new
|
| 103 |
+
if feedback_file.exists():
|
| 104 |
+
existing_feedback = pd.read_csv(feedback_file, encoding='utf-8-sig')
|
| 105 |
+
|
| 106 |
+
# Check if this exact feedback already exists
|
| 107 |
+
duplicate = existing_feedback[
|
| 108 |
+
(existing_feedback['user_id'] == user_id) &
|
| 109 |
+
(existing_feedback['stage'] == stage) &
|
| 110 |
+
(existing_feedback['experiment_id'] == experiment_id)
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
if len(duplicate) > 0:
|
| 114 |
+
# Update existing feedback
|
| 115 |
+
existing_feedback.loc[
|
| 116 |
+
(existing_feedback['user_id'] == user_id) &
|
| 117 |
+
(existing_feedback['stage'] == stage) &
|
| 118 |
+
(existing_feedback['experiment_id'] == experiment_id),
|
| 119 |
+
['feedback_type', 'rejection_reason', 'rejection_text', 'message_header', 'message_body', 'timestamp']
|
| 120 |
+
] = [feedback_type, rejection_reason, rejection_text, message_header, message_body, datetime.now().isoformat()]
|
| 121 |
+
|
| 122 |
+
updated_feedback = existing_feedback
|
| 123 |
+
else:
|
| 124 |
+
# Append new feedback
|
| 125 |
+
updated_feedback = pd.concat([existing_feedback, new_feedback], ignore_index=True)
|
| 126 |
+
else:
|
| 127 |
+
updated_feedback = new_feedback
|
| 128 |
+
|
| 129 |
+
# Save to CSV
|
| 130 |
+
updated_feedback.to_csv(feedback_file, index=False, encoding='utf-8-sig')
|
| 131 |
+
return True
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
st.error(f"Error saving feedback: {e}")
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
def load_feedback(self, experiment_id: str) -> Optional[pd.DataFrame]:
|
| 138 |
+
"""
|
| 139 |
+
Load feedback for an experiment.
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
experiment_id: Experiment ID
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
pd.DataFrame or None: Feedback DataFrame or None if not found
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
feedback_file = self.get_feedback_file_path(experiment_id)
|
| 149 |
+
|
| 150 |
+
if not feedback_file.exists():
|
| 151 |
+
return pd.DataFrame(columns=[
|
| 152 |
+
'experiment_id', 'user_id', 'stage', 'feedback_type',
|
| 153 |
+
'rejection_reason', 'rejection_text', 'campaign_name',
|
| 154 |
+
'message_header', 'message_body', 'timestamp'
|
| 155 |
+
])
|
| 156 |
+
|
| 157 |
+
return pd.read_csv(feedback_file, encoding='utf-8-sig')
|
| 158 |
+
|
| 159 |
+
except Exception as e:
|
| 160 |
+
st.error(f"Error loading feedback: {e}")
|
| 161 |
+
return None
|
| 162 |
+
|
| 163 |
+
def get_feedback_for_message(
|
| 164 |
+
self,
|
| 165 |
+
experiment_id: str,
|
| 166 |
+
user_id: int,
|
| 167 |
+
stage: int
|
| 168 |
+
) -> Optional[Dict]:
|
| 169 |
+
"""
|
| 170 |
+
Get feedback for a specific message.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
experiment_id: Experiment ID
|
| 174 |
+
user_id: User ID
|
| 175 |
+
stage: Stage number
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
dict or None: Feedback record or None if not found
|
| 179 |
+
"""
|
| 180 |
+
feedback_df = self.load_feedback(experiment_id)
|
| 181 |
+
|
| 182 |
+
if feedback_df is None or len(feedback_df) == 0:
|
| 183 |
+
return None
|
| 184 |
+
|
| 185 |
+
# Filter for specific message
|
| 186 |
+
message_feedback = feedback_df[
|
| 187 |
+
(feedback_df['user_id'] == user_id) &
|
| 188 |
+
(feedback_df['stage'] == stage) &
|
| 189 |
+
(feedback_df['experiment_id'] == experiment_id)
|
| 190 |
+
]
|
| 191 |
+
|
| 192 |
+
if len(message_feedback) > 0:
|
| 193 |
+
return message_feedback.iloc[0].to_dict()
|
| 194 |
+
|
| 195 |
+
return None
|
| 196 |
+
|
| 197 |
+
def delete_feedback(
|
| 198 |
+
self,
|
| 199 |
+
experiment_id: str,
|
| 200 |
+
user_id: int,
|
| 201 |
+
stage: int
|
| 202 |
+
) -> bool:
|
| 203 |
+
"""
|
| 204 |
+
Delete feedback for a specific message.
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
experiment_id: Experiment ID
|
| 208 |
+
user_id: User ID
|
| 209 |
+
stage: Stage number
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
bool: True if deleted successfully, False otherwise
|
| 213 |
+
"""
|
| 214 |
+
try:
|
| 215 |
+
feedback_df = self.load_feedback(experiment_id)
|
| 216 |
+
|
| 217 |
+
if feedback_df is None or len(feedback_df) == 0:
|
| 218 |
+
return False
|
| 219 |
+
|
| 220 |
+
# Remove the feedback
|
| 221 |
+
updated_feedback = feedback_df[
|
| 222 |
+
~((feedback_df['user_id'] == user_id) &
|
| 223 |
+
(feedback_df['stage'] == stage) &
|
| 224 |
+
(feedback_df['experiment_id'] == experiment_id))
|
| 225 |
+
]
|
| 226 |
+
|
| 227 |
+
# Save updated feedback
|
| 228 |
+
feedback_file = self.get_feedback_file_path(experiment_id)
|
| 229 |
+
updated_feedback.to_csv(feedback_file, index=False, encoding='utf-8-sig')
|
| 230 |
+
|
| 231 |
+
return True
|
| 232 |
+
|
| 233 |
+
except Exception as e:
|
| 234 |
+
st.error(f"Error deleting feedback: {e}")
|
| 235 |
+
return False
|
| 236 |
+
|
| 237 |
+
def get_feedback_stats(self, experiment_id: str, total_messages: int = None) -> Dict:
|
| 238 |
+
"""
|
| 239 |
+
Get feedback statistics for an experiment.
|
| 240 |
+
|
| 241 |
+
Args:
|
| 242 |
+
experiment_id: Experiment ID
|
| 243 |
+
total_messages: Total number of messages generated (optional, for accurate rejection rate)
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
dict: Feedback statistics
|
| 247 |
+
"""
|
| 248 |
+
feedback_df = self.load_feedback(experiment_id)
|
| 249 |
+
|
| 250 |
+
if feedback_df is None or len(feedback_df) == 0:
|
| 251 |
+
return {
|
| 252 |
+
"total_feedback": 0,
|
| 253 |
+
"total_rejects": 0,
|
| 254 |
+
"reject_rate": 0.0,
|
| 255 |
+
"rejection_reasons": {}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
total_feedback = len(feedback_df)
|
| 259 |
+
total_rejects = len(feedback_df[feedback_df['feedback_type'] == 'reject'])
|
| 260 |
+
|
| 261 |
+
# Count rejection reasons
|
| 262 |
+
rejection_reasons = {}
|
| 263 |
+
if total_rejects > 0:
|
| 264 |
+
reason_counts = feedback_df[
|
| 265 |
+
feedback_df['feedback_type'] == 'reject'
|
| 266 |
+
]['rejection_reason'].value_counts().to_dict()
|
| 267 |
+
|
| 268 |
+
rejection_reasons = {
|
| 269 |
+
REJECTION_REASONS.get(k, k): v
|
| 270 |
+
for k, v in reason_counts.items()
|
| 271 |
+
if k is not None and pd.notna(k)
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
# Calculate rejection rate based on total messages (not total feedback)
|
| 275 |
+
# Since users only provide feedback for rejected messages
|
| 276 |
+
if total_messages is not None and total_messages > 0:
|
| 277 |
+
reject_rate = (total_rejects / total_messages * 100)
|
| 278 |
+
else:
|
| 279 |
+
# Fallback to old calculation if total_messages not provided
|
| 280 |
+
reject_rate = (total_rejects / total_feedback * 100) if total_feedback > 0 else 0.0
|
| 281 |
+
|
| 282 |
+
stats = {
|
| 283 |
+
"total_feedback": total_feedback,
|
| 284 |
+
"total_rejects": total_rejects,
|
| 285 |
+
"reject_rate": reject_rate,
|
| 286 |
+
"rejection_reasons": rejection_reasons
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
return stats
|
| 290 |
+
|
| 291 |
+
def get_stage_feedback_stats(self, experiment_id: str, messages_df: pd.DataFrame = None) -> pd.DataFrame:
|
| 292 |
+
"""
|
| 293 |
+
Get feedback statistics by stage.
|
| 294 |
+
|
| 295 |
+
Args:
|
| 296 |
+
experiment_id: Experiment ID
|
| 297 |
+
messages_df: DataFrame of all messages (optional, for accurate rejection rate per stage)
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
pd.DataFrame: Stage-wise feedback statistics
|
| 301 |
+
"""
|
| 302 |
+
feedback_df = self.load_feedback(experiment_id)
|
| 303 |
+
|
| 304 |
+
if feedback_df is None or len(feedback_df) == 0:
|
| 305 |
+
return pd.DataFrame(columns=['stage', 'total_messages', 'total_feedback', 'rejects', 'reject_rate'])
|
| 306 |
+
|
| 307 |
+
# Get total messages per stage from messages_df if provided
|
| 308 |
+
stage_message_counts = {}
|
| 309 |
+
if messages_df is not None and 'stage' in messages_df.columns:
|
| 310 |
+
stage_message_counts = messages_df.groupby('stage').size().to_dict()
|
| 311 |
+
|
| 312 |
+
# Group by stage
|
| 313 |
+
stage_stats = []
|
| 314 |
+
for stage in sorted(feedback_df['stage'].unique()):
|
| 315 |
+
stage_feedback = feedback_df[feedback_df['stage'] == stage]
|
| 316 |
+
total_feedback = len(stage_feedback)
|
| 317 |
+
rejects = len(stage_feedback[stage_feedback['feedback_type'] == 'reject'])
|
| 318 |
+
|
| 319 |
+
# Calculate rejection rate based on total messages for this stage
|
| 320 |
+
total_messages_for_stage = stage_message_counts.get(stage, total_feedback)
|
| 321 |
+
reject_rate = (rejects / total_messages_for_stage * 100) if total_messages_for_stage > 0 else 0.0
|
| 322 |
+
|
| 323 |
+
stage_stats.append({
|
| 324 |
+
'stage': stage,
|
| 325 |
+
'total_messages': total_messages_for_stage,
|
| 326 |
+
'total_feedback': total_feedback,
|
| 327 |
+
'rejects': rejects,
|
| 328 |
+
'reject_rate': reject_rate
|
| 329 |
+
})
|
| 330 |
+
|
| 331 |
+
return pd.DataFrame(stage_stats)
|
| 332 |
+
|
| 333 |
+
def compare_experiments(
|
| 334 |
+
self,
|
| 335 |
+
experiment_id_a: str,
|
| 336 |
+
experiment_id_b: str,
|
| 337 |
+
total_messages_a: int = None,
|
| 338 |
+
total_messages_b: int = None
|
| 339 |
+
) -> Dict:
|
| 340 |
+
"""
|
| 341 |
+
Compare feedback statistics between two experiments.
|
| 342 |
+
|
| 343 |
+
Args:
|
| 344 |
+
experiment_id_a: First experiment ID
|
| 345 |
+
experiment_id_b: Second experiment ID
|
| 346 |
+
total_messages_a: Total messages in experiment A (optional, for accurate rejection rate)
|
| 347 |
+
total_messages_b: Total messages in experiment B (optional, for accurate rejection rate)
|
| 348 |
+
|
| 349 |
+
Returns:
|
| 350 |
+
dict: Comparison statistics
|
| 351 |
+
"""
|
| 352 |
+
stats_a = self.get_feedback_stats(experiment_id_a, total_messages=total_messages_a)
|
| 353 |
+
stats_b = self.get_feedback_stats(experiment_id_b, total_messages=total_messages_b)
|
| 354 |
+
|
| 355 |
+
comparison = {
|
| 356 |
+
"experiment_a": {
|
| 357 |
+
"id": experiment_id_a,
|
| 358 |
+
"stats": stats_a
|
| 359 |
+
},
|
| 360 |
+
"experiment_b": {
|
| 361 |
+
"id": experiment_id_b,
|
| 362 |
+
"stats": stats_b
|
| 363 |
+
},
|
| 364 |
+
"difference": {
|
| 365 |
+
"reject_rate": stats_a['reject_rate'] - stats_b['reject_rate']
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
return comparison
|
| 370 |
+
|
| 371 |
+
def export_feedback(self, experiment_id: str, export_path: Path) -> bool:
|
| 372 |
+
"""
|
| 373 |
+
Export feedback to CSV file.
|
| 374 |
+
|
| 375 |
+
Args:
|
| 376 |
+
experiment_id: Experiment ID
|
| 377 |
+
export_path: Export file path
|
| 378 |
+
|
| 379 |
+
Returns:
|
| 380 |
+
bool: True if exported successfully, False otherwise
|
| 381 |
+
"""
|
| 382 |
+
try:
|
| 383 |
+
feedback_df = self.load_feedback(experiment_id)
|
| 384 |
+
|
| 385 |
+
if feedback_df is None:
|
| 386 |
+
return False
|
| 387 |
+
|
| 388 |
+
feedback_df.to_csv(export_path, index=False, encoding='utf-8-sig')
|
| 389 |
+
return True
|
| 390 |
+
|
| 391 |
+
except Exception as e:
|
| 392 |
+
st.error(f"Error exporting feedback: {e}")
|
| 393 |
+
return False
|
| 394 |
+
|
| 395 |
+
def get_rejection_reason_label(self, reason_key: str) -> str:
|
| 396 |
+
"""
|
| 397 |
+
Get human-readable label for rejection reason key.
|
| 398 |
+
|
| 399 |
+
Args:
|
| 400 |
+
reason_key: Rejection reason key
|
| 401 |
+
|
| 402 |
+
Returns:
|
| 403 |
+
str: Human-readable label
|
| 404 |
+
"""
|
| 405 |
+
return REJECTION_REASONS.get(reason_key, reason_key)
|
| 406 |
+
|
| 407 |
+
def get_all_rejection_reasons(self) -> Dict[str, str]:
|
| 408 |
+
"""
|
| 409 |
+
Get all available rejection reasons.
|
| 410 |
+
|
| 411 |
+
Returns:
|
| 412 |
+
dict: Dictionary mapping keys to labels
|
| 413 |
+
"""
|
| 414 |
+
return REJECTION_REASONS.copy()
|
utils/theme.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Brand-specific theming module for the AI Messaging System Visualization Tool.
|
| 3 |
+
|
| 4 |
+
Provides dynamic color schemes and styling based on selected brand.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import streamlit as st
|
| 8 |
+
from typing import Dict, Tuple
|
| 9 |
+
|
| 10 |
+
# Brand color definitions (primary, secondary, accent)
|
| 11 |
+
BRAND_COLORS = {
|
| 12 |
+
"base": {
|
| 13 |
+
"primary": "#FFD700", # Gold
|
| 14 |
+
"secondary": "#2C2C2C", # Dark gray
|
| 15 |
+
"accent": "#1A1A1A", # Black
|
| 16 |
+
"text": "#FFFFFF", # White
|
| 17 |
+
"background": "#0D0D0D", # Very dark
|
| 18 |
+
"sidebar_bg": "#8B7500" # Darker gold for sidebar
|
| 19 |
+
},
|
| 20 |
+
"drumeo": {
|
| 21 |
+
"primary": "#5DADE2", # Light blue
|
| 22 |
+
"secondary": "#FFD700", # Gold
|
| 23 |
+
"accent": "#2874A6", # Darker blue
|
| 24 |
+
"text": "#FFFFFF", # White
|
| 25 |
+
"background": "#1C2833", # Dark blue-gray
|
| 26 |
+
"sidebar_bg": "#1A4D6B" # Darker blue for sidebar
|
| 27 |
+
},
|
| 28 |
+
"pianote": {
|
| 29 |
+
"primary": "#EC7063", # Light red
|
| 30 |
+
"secondary": "#FFD700", # Gold
|
| 31 |
+
"accent": "#C0392B", # Darker red
|
| 32 |
+
"text": "#FFFFFF", # White
|
| 33 |
+
"background": "#1C1C1C", # Dark
|
| 34 |
+
"sidebar_bg": "#8B2500" # Darker red for sidebar
|
| 35 |
+
},
|
| 36 |
+
"guitareo": {
|
| 37 |
+
"primary": "#58D68D", # Light green
|
| 38 |
+
"secondary": "#FFD700", # Gold
|
| 39 |
+
"accent": "#229954", # Darker green
|
| 40 |
+
"text": "#FFFFFF", # White
|
| 41 |
+
"background": "#1C2E1F", # Dark green-gray
|
| 42 |
+
"sidebar_bg": "#1B5E20" # Darker green for sidebar
|
| 43 |
+
},
|
| 44 |
+
"singeo": {
|
| 45 |
+
"primary": "#BB8FCE", # Light purple
|
| 46 |
+
"secondary": "#FFD700", # Gold
|
| 47 |
+
"accent": "#7D3C98", # Darker purple
|
| 48 |
+
"text": "#FFFFFF", # White
|
| 49 |
+
"background": "#1F1926", # Dark purple-gray
|
| 50 |
+
"sidebar_bg": "#4A148C" # Darker purple for sidebar
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
# Brand emojis
|
| 55 |
+
BRAND_EMOJIS = {
|
| 56 |
+
"drumeo": "🥁",
|
| 57 |
+
"pianote": "🎹",
|
| 58 |
+
"guitareo": "🎸",
|
| 59 |
+
"singeo": "🎤"
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def get_brand_theme(brand: str) -> Dict[str, str]:
|
| 64 |
+
"""
|
| 65 |
+
Get color theme for specified brand.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
brand: Brand name (drumeo, pianote, guitareo, singeo)
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
dict: Color theme dictionary
|
| 72 |
+
"""
|
| 73 |
+
brand_lower = brand.lower() if brand else "base"
|
| 74 |
+
return BRAND_COLORS.get(brand_lower, BRAND_COLORS["base"])
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_brand_emoji(brand: str) -> str:
|
| 78 |
+
"""
|
| 79 |
+
Get emoji for specified brand.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
brand: Brand name
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
str: Brand emoji
|
| 86 |
+
"""
|
| 87 |
+
brand_lower = brand.lower() if brand else ""
|
| 88 |
+
return BRAND_EMOJIS.get(brand_lower, "🎵")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def apply_theme(brand: str = None):
|
| 92 |
+
"""
|
| 93 |
+
Apply brand-specific theme to Streamlit app.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
brand: Brand name (optional, uses session state if not provided)
|
| 97 |
+
"""
|
| 98 |
+
if brand is None:
|
| 99 |
+
brand = st.session_state.get("selected_brand", "base")
|
| 100 |
+
|
| 101 |
+
theme = get_brand_theme(brand)
|
| 102 |
+
|
| 103 |
+
# Custom CSS for brand theming
|
| 104 |
+
css = f"""
|
| 105 |
+
<style>
|
| 106 |
+
/* Global styles */
|
| 107 |
+
.stApp {{
|
| 108 |
+
background-color: {theme['background']};
|
| 109 |
+
}}
|
| 110 |
+
|
| 111 |
+
/* Text colors */
|
| 112 |
+
h1, h2, h3, h4, h5, h6 {{
|
| 113 |
+
color: {theme['primary']} !important;
|
| 114 |
+
}}
|
| 115 |
+
|
| 116 |
+
p, span, div {{
|
| 117 |
+
color: {theme['text']} !important;
|
| 118 |
+
}}
|
| 119 |
+
|
| 120 |
+
/* Button styles */
|
| 121 |
+
.stButton > button {{
|
| 122 |
+
background-color: {theme['primary']};
|
| 123 |
+
color: {theme['background']};
|
| 124 |
+
border: none;
|
| 125 |
+
border-radius: 8px;
|
| 126 |
+
padding: 0.5rem 1rem;
|
| 127 |
+
font-weight: 600;
|
| 128 |
+
transition: all 0.3s ease;
|
| 129 |
+
}}
|
| 130 |
+
|
| 131 |
+
.stButton > button:hover {{
|
| 132 |
+
background-color: {theme['secondary']};
|
| 133 |
+
transform: translateY(-2px);
|
| 134 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
| 135 |
+
}}
|
| 136 |
+
|
| 137 |
+
/* Download button */
|
| 138 |
+
.stDownloadButton > button {{
|
| 139 |
+
background-color: {theme['accent']};
|
| 140 |
+
color: {theme['text']};
|
| 141 |
+
border-radius: 8px;
|
| 142 |
+
font-weight: 600;
|
| 143 |
+
}}
|
| 144 |
+
|
| 145 |
+
/* Input fields */
|
| 146 |
+
.stTextInput > div > div > input,
|
| 147 |
+
.stTextArea > div > div > textarea,
|
| 148 |
+
.stNumberInput > div > div > input {{
|
| 149 |
+
background-color: {theme['secondary']};
|
| 150 |
+
color: {theme['text']};
|
| 151 |
+
border: 1px solid {theme['primary']};
|
| 152 |
+
border-radius: 5px;
|
| 153 |
+
}}
|
| 154 |
+
|
| 155 |
+
/* Select boxes */
|
| 156 |
+
.stSelectbox > div > div {{
|
| 157 |
+
background-color: {theme['secondary']};
|
| 158 |
+
color: {theme['text']};
|
| 159 |
+
border-radius: 5px;
|
| 160 |
+
}}
|
| 161 |
+
|
| 162 |
+
/* Multiselect */
|
| 163 |
+
.stMultiSelect > div > div {{
|
| 164 |
+
background-color: {theme['secondary']};
|
| 165 |
+
border-radius: 5px;
|
| 166 |
+
}}
|
| 167 |
+
|
| 168 |
+
/* Tabs */
|
| 169 |
+
.stTabs [data-baseweb="tab-list"] {{
|
| 170 |
+
gap: 8px;
|
| 171 |
+
}}
|
| 172 |
+
|
| 173 |
+
.stTabs [data-baseweb="tab"] {{
|
| 174 |
+
background-color: {theme['secondary']};
|
| 175 |
+
color: {theme['text']};
|
| 176 |
+
border-radius: 5px 5px 0 0;
|
| 177 |
+
padding: 10px 20px;
|
| 178 |
+
font-weight: 600;
|
| 179 |
+
}}
|
| 180 |
+
|
| 181 |
+
.stTabs [aria-selected="true"] {{
|
| 182 |
+
background-color: {theme['primary']};
|
| 183 |
+
color: {theme['background']};
|
| 184 |
+
}}
|
| 185 |
+
|
| 186 |
+
/* Progress bar */
|
| 187 |
+
.stProgress > div > div > div {{
|
| 188 |
+
background-color: {theme['primary']};
|
| 189 |
+
}}
|
| 190 |
+
|
| 191 |
+
/* Expanders */
|
| 192 |
+
.streamlit-expanderHeader {{
|
| 193 |
+
background-color: {theme['secondary']};
|
| 194 |
+
color: {theme['primary']};
|
| 195 |
+
border-radius: 5px;
|
| 196 |
+
font-weight: 600;
|
| 197 |
+
}}
|
| 198 |
+
|
| 199 |
+
/* Cards/Containers */
|
| 200 |
+
.element-container {{
|
| 201 |
+
background-color: transparent;
|
| 202 |
+
}}
|
| 203 |
+
|
| 204 |
+
/* Sidebar */
|
| 205 |
+
[data-testid="stSidebar"] {{
|
| 206 |
+
background-color: {theme['sidebar_bg']};
|
| 207 |
+
}}
|
| 208 |
+
|
| 209 |
+
[data-testid="stSidebar"] h1,
|
| 210 |
+
[data-testid="stSidebar"] h2,
|
| 211 |
+
[data-testid="stSidebar"] h3 {{
|
| 212 |
+
color: {theme['text']} !important;
|
| 213 |
+
}}
|
| 214 |
+
|
| 215 |
+
[data-testid="stSidebar"] p,
|
| 216 |
+
[data-testid="stSidebar"] span,
|
| 217 |
+
[data-testid="stSidebar"] div,
|
| 218 |
+
[data-testid="stSidebar"] label {{
|
| 219 |
+
color: {theme['text']} !important;
|
| 220 |
+
}}
|
| 221 |
+
|
| 222 |
+
/* Metrics */
|
| 223 |
+
[data-testid="stMetricValue"] {{
|
| 224 |
+
color: {theme['primary']} !important;
|
| 225 |
+
font-size: 2rem;
|
| 226 |
+
font-weight: bold;
|
| 227 |
+
}}
|
| 228 |
+
|
| 229 |
+
/* Divider */
|
| 230 |
+
hr {{
|
| 231 |
+
border-color: {theme['primary']};
|
| 232 |
+
opacity: 0.3;
|
| 233 |
+
}}
|
| 234 |
+
|
| 235 |
+
/* Checkbox */
|
| 236 |
+
.stCheckbox {{
|
| 237 |
+
color: {theme['text']};
|
| 238 |
+
}}
|
| 239 |
+
|
| 240 |
+
/* Radio */
|
| 241 |
+
.stRadio > div {{
|
| 242 |
+
color: {theme['text']};
|
| 243 |
+
}}
|
| 244 |
+
|
| 245 |
+
/* Success/Info/Warning/Error boxes */
|
| 246 |
+
.stSuccess {{
|
| 247 |
+
background-color: rgba(88, 214, 141, 0.1);
|
| 248 |
+
color: {theme['text']};
|
| 249 |
+
}}
|
| 250 |
+
|
| 251 |
+
.stInfo {{
|
| 252 |
+
background-color: rgba(93, 173, 226, 0.1);
|
| 253 |
+
color: {theme['text']};
|
| 254 |
+
}}
|
| 255 |
+
|
| 256 |
+
.stWarning {{
|
| 257 |
+
background-color: rgba(255, 215, 0, 0.1);
|
| 258 |
+
color: {theme['text']};
|
| 259 |
+
}}
|
| 260 |
+
|
| 261 |
+
.stError {{
|
| 262 |
+
background-color: rgba(236, 112, 99, 0.1);
|
| 263 |
+
color: {theme['text']};
|
| 264 |
+
}}
|
| 265 |
+
|
| 266 |
+
/* DataFrames */
|
| 267 |
+
.dataframe {{
|
| 268 |
+
border: 1px solid {theme['primary']};
|
| 269 |
+
}}
|
| 270 |
+
|
| 271 |
+
/* Custom card styling */
|
| 272 |
+
.message-card {{
|
| 273 |
+
background-color: {theme['secondary']};
|
| 274 |
+
border-left: 4px solid {theme['primary']};
|
| 275 |
+
border-radius: 8px;
|
| 276 |
+
padding: 1rem;
|
| 277 |
+
margin: 0.5rem 0;
|
| 278 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
| 279 |
+
}}
|
| 280 |
+
|
| 281 |
+
.message-card:hover {{
|
| 282 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
| 283 |
+
transform: translateX(4px);
|
| 284 |
+
transition: all 0.3s ease;
|
| 285 |
+
}}
|
| 286 |
+
|
| 287 |
+
/* Small text */
|
| 288 |
+
.small {{
|
| 289 |
+
font-size: 0.85rem;
|
| 290 |
+
opacity: 0.7;
|
| 291 |
+
}}
|
| 292 |
+
|
| 293 |
+
/* Badge styles */
|
| 294 |
+
.badge {{
|
| 295 |
+
display: inline-block;
|
| 296 |
+
padding: 0.25rem 0.5rem;
|
| 297 |
+
border-radius: 12px;
|
| 298 |
+
font-size: 0.75rem;
|
| 299 |
+
font-weight: 600;
|
| 300 |
+
background-color: {theme['accent']};
|
| 301 |
+
color: {theme['text']};
|
| 302 |
+
}}
|
| 303 |
+
</style>
|
| 304 |
+
"""
|
| 305 |
+
|
| 306 |
+
st.markdown(css, unsafe_allow_html=True)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def create_card(content: str, key: str = None) -> None:
|
| 310 |
+
"""
|
| 311 |
+
Create a styled card container.
|
| 312 |
+
|
| 313 |
+
Args:
|
| 314 |
+
content: HTML content to display in card
|
| 315 |
+
key: Optional unique key for the card
|
| 316 |
+
"""
|
| 317 |
+
st.markdown(
|
| 318 |
+
f'<div class="message-card">{content}</div>',
|
| 319 |
+
unsafe_allow_html=True
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def create_badge(text: str, color: str = None) -> str:
|
| 324 |
+
"""
|
| 325 |
+
Create a styled badge element.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
text: Badge text
|
| 329 |
+
color: Optional custom color
|
| 330 |
+
|
| 331 |
+
Returns:
|
| 332 |
+
str: HTML for badge
|
| 333 |
+
"""
|
| 334 |
+
style = f'background-color: {color};' if color else ''
|
| 335 |
+
return f'<span class="badge" style="{style}">{text}</span>'
|
visualization/check_env.py
DELETED
|
@@ -1,85 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Environment Variable Check Script
|
| 3 |
-
Verifies that all required environment variables are set correctly.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import os
|
| 7 |
-
from pathlib import Path
|
| 8 |
-
from dotenv import load_dotenv
|
| 9 |
-
|
| 10 |
-
# Load environment variables
|
| 11 |
-
env_path = Path(__file__).parent / '.env'
|
| 12 |
-
if env_path.exists():
|
| 13 |
-
load_dotenv(env_path)
|
| 14 |
-
print(f"✅ Found .env file at: {env_path}")
|
| 15 |
-
else:
|
| 16 |
-
# Try parent directory
|
| 17 |
-
parent_env_path = Path(__file__).parent.parent / '.env'
|
| 18 |
-
if parent_env_path.exists():
|
| 19 |
-
load_dotenv(parent_env_path)
|
| 20 |
-
print(f"✅ Found .env file at: {parent_env_path}")
|
| 21 |
-
else:
|
| 22 |
-
print("❌ No .env file found!")
|
| 23 |
-
print(f" Expected locations:")
|
| 24 |
-
print(f" - {env_path}")
|
| 25 |
-
print(f" - {parent_env_path}")
|
| 26 |
-
print("\n💡 Create a .env file using .env.example as a template:")
|
| 27 |
-
print(" cp .env.example .env")
|
| 28 |
-
exit(1)
|
| 29 |
-
|
| 30 |
-
print("\n" + "="*60)
|
| 31 |
-
print("Environment Variables Check")
|
| 32 |
-
print("="*60 + "\n")
|
| 33 |
-
|
| 34 |
-
# Required variables
|
| 35 |
-
required_vars = {
|
| 36 |
-
"Snowflake": [
|
| 37 |
-
"SNOWFLAKE_USER",
|
| 38 |
-
"SNOWFLAKE_PASSWORD",
|
| 39 |
-
"SNOWFLAKE_ACCOUNT",
|
| 40 |
-
"SNOWFLAKE_ROLE",
|
| 41 |
-
"SNOWFLAKE_DATABASE",
|
| 42 |
-
"SNOWFLAKE_WAREHOUSE",
|
| 43 |
-
"SNOWFLAKE_SCHEMA"
|
| 44 |
-
],
|
| 45 |
-
"API Keys": [
|
| 46 |
-
"OPENAI_API_KEY",
|
| 47 |
-
"GOOGLE_API_KEY"
|
| 48 |
-
],
|
| 49 |
-
"Application": [
|
| 50 |
-
"APP_TOKEN"
|
| 51 |
-
]
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
all_good = True
|
| 55 |
-
|
| 56 |
-
for category, vars_list in required_vars.items():
|
| 57 |
-
print(f"\n{category}:")
|
| 58 |
-
print("-" * 40)
|
| 59 |
-
|
| 60 |
-
for var in vars_list:
|
| 61 |
-
value = os.getenv(var)
|
| 62 |
-
if value:
|
| 63 |
-
# Mask sensitive values
|
| 64 |
-
if len(value) > 10:
|
| 65 |
-
masked = value[:4] + "..." + value[-4:]
|
| 66 |
-
else:
|
| 67 |
-
masked = "***"
|
| 68 |
-
print(f" ✅ {var}: {masked}")
|
| 69 |
-
else:
|
| 70 |
-
print(f" ❌ {var}: NOT SET")
|
| 71 |
-
all_good = False
|
| 72 |
-
|
| 73 |
-
print("\n" + "="*60)
|
| 74 |
-
|
| 75 |
-
if all_good:
|
| 76 |
-
print("✅ All required environment variables are set!")
|
| 77 |
-
print("\n🚀 You're ready to run the application:")
|
| 78 |
-
print(" streamlit run app.py")
|
| 79 |
-
else:
|
| 80 |
-
print("❌ Some environment variables are missing!")
|
| 81 |
-
print("\n💡 Please edit your .env file and set all required variables.")
|
| 82 |
-
print(" Use .env.example as a reference.")
|
| 83 |
-
exit(1)
|
| 84 |
-
|
| 85 |
-
print("="*60 + "\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|