Commit ยท
dcdaf35
1
Parent(s): 5ae6520
deploying the new UI
Browse files- README.md +164 -1051
- app.py +11 -12
- pages/1_Message_Viewer.py +18 -0
- requirements.txt +6 -43
- visualization/.env.example +0 -0
- visualization/.gitignore +57 -0
- visualization/README.md +243 -0
- visualization/app.py +140 -0
- visualization/pages/1_Message_Viewer.py +533 -0
- visualization/requirements.txt +10 -0
- visualization/utils/__init__.py +0 -0
- visualization/utils/auth.py +101 -0
- visualization/utils/feedback_manager.py +140 -0
- visualization/utils/snowflake_client.py +279 -0
- visualization/utils/theme.py +157 -0
README.md
CHANGED
|
@@ -4,1127 +4,240 @@ emoji: ๐ถ
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: gray
|
| 6 |
sdk: streamlit
|
| 7 |
-
sdk_version: 1.
|
| 8 |
python_version: 3.9
|
| 9 |
app_file: app.py
|
| 10 |
---
|
| 11 |
|
| 12 |
-
# AI Messaging System - Visualization Tool
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
-
- Run A/B tests with parallel processing and side-by-side comparison
|
| 20 |
-
- Visualize and analyze messages in real-time across all campaign stages
|
| 21 |
-
- Provide detailed feedback with message header/body tracking
|
| 22 |
-
- Track improvements and trends across historical experiments
|
| 23 |
-
- Cloud-native architecture with Snowflake integration
|
| 24 |
-
- Ready for deployment on HuggingFace Spaces
|
| 25 |
|
| 26 |
---
|
| 27 |
|
| 28 |
-
##
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
-
|
| 34 |
-
-
|
| 35 |
-
- **On-Demand Loading**: Historical data loaded from Snowflake when needed
|
| 36 |
-
- **One-Click Storage**: Results persisted with a single button click
|
| 37 |
-
|
| 38 |
-
### Why This Architecture?
|
| 39 |
-
|
| 40 |
-
1. **HuggingFace Ready**: No local file dependencies
|
| 41 |
-
2. **Fast Operations**: In-memory processing for real-time feedback
|
| 42 |
-
3. **Scalable**: Snowflake handles unlimited historical data
|
| 43 |
-
4. **Clean Separation**: Current experiments vs. historical data
|
| 44 |
-
5. **Versioned Configs**: Automatic configuration versioning in Snowflake
|
| 45 |
|
| 46 |
---
|
| 47 |
|
| 48 |
-
##
|
| 49 |
|
| 50 |
```
|
| 51 |
visualization/
|
| 52 |
-
โโโ app.py
|
| 53 |
-
โโโ pages/
|
| 54 |
-
โ
|
| 55 |
-
|
| 56 |
-
โ โโโ
|
| 57 |
-
โ
|
| 58 |
-
โโโ
|
| 59 |
-
โ โโโ
|
| 60 |
-
โ
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
โ โโโ theme.py # Brand-specific theming
|
| 66 |
-
โโโ data/ # Local data storage (configs cached here)
|
| 67 |
-
โ โโโ UI_users/ # Pre-loaded user lists (100 users per brand)
|
| 68 |
-
โ โโโ drumeo_users.csv
|
| 69 |
-
โ โโโ pianote_users.csv
|
| 70 |
-
โ โโโ guitareo_users.csv
|
| 71 |
-
โ โโโ singeo_users.csv
|
| 72 |
-
โโโ requirements.txt # Python dependencies
|
| 73 |
-
โโโ README.md # This file
|
| 74 |
-
โโโ IMPLEMENTATION_COMPLETE.md # Refactoring details & progress
|
| 75 |
-
โโโ ARCHITECTURE_REFACTOR_GUIDE.md # Technical refactoring guide
|
| 76 |
-
```
|
| 77 |
-
|
| 78 |
-
## ๐๏ธ Snowflake Database Schema
|
| 79 |
-
|
| 80 |
-
### Tables
|
| 81 |
-
|
| 82 |
-
**1. MESSAGING_SYSTEM_V2.UI.CONFIGS**
|
| 83 |
-
```sql
|
| 84 |
-
CONFIG_NAME VARCHAR -- Configuration identifier
|
| 85 |
-
CONFIG_FILE VARIANT -- JSON configuration
|
| 86 |
-
CONFIG_VERSION INTEGER -- Auto-incrementing version
|
| 87 |
-
BRAND VARCHAR -- Brand name (drumeo, pianote, etc.)
|
| 88 |
-
CREATED_AT TIMESTAMP -- Creation timestamp
|
| 89 |
-
```
|
| 90 |
-
|
| 91 |
-
**2. MESSAGING_SYSTEM_V2.UI.EXPERIMENT_METADATA**
|
| 92 |
-
```sql
|
| 93 |
-
EXPERIMENT_ID VARCHAR -- Unique experiment identifier
|
| 94 |
-
CONFIG_NAME VARCHAR -- Configuration used
|
| 95 |
-
BRAND VARCHAR -- Brand name
|
| 96 |
-
CAMPAIGN_NAME VARCHAR -- Campaign identifier
|
| 97 |
-
STAGE INTEGER -- Stage number (1-11)
|
| 98 |
-
LLM_MODEL VARCHAR -- Model used (gpt-4o-mini, gemini-2.5-flash-lite, etc.)
|
| 99 |
-
TOTAL_MESSAGES INTEGER -- Messages generated in this stage
|
| 100 |
-
TOTAL_USERS INTEGER -- Unique users in this stage
|
| 101 |
-
PLATFORM VARCHAR -- Platform (push, email, etc.)
|
| 102 |
-
PERSONALIZATION BOOLEAN -- Personalization enabled
|
| 103 |
-
INVOLVE_RECSYS BOOLEAN -- Recommendations enabled
|
| 104 |
-
RECSYS_CONTENTS ARRAY -- Recommendation types
|
| 105 |
-
SEGMENT_INFO VARCHAR -- Segment description
|
| 106 |
-
CAMPAIGN_INSTRUCTIONS VARCHAR -- Campaign-wide instructions
|
| 107 |
-
PER_MESSAGE_INSTRUCTIONS VARCHAR -- Stage-specific instructions
|
| 108 |
-
START_TIME TIMESTAMP -- Experiment start time
|
| 109 |
-
END_TIME TIMESTAMP -- Experiment end time (optional)
|
| 110 |
-
```
|
| 111 |
-
|
| 112 |
-
**3. MESSAGING_SYSTEM_V2.UI.FEEDBACKS**
|
| 113 |
-
```sql
|
| 114 |
-
EXPERIMENT_ID VARCHAR -- Links to EXPERIMENT_METADATA
|
| 115 |
-
USER_ID INTEGER -- User who received the message
|
| 116 |
-
STAGE INTEGER -- Stage number
|
| 117 |
-
FEEDBACK_TYPE VARCHAR -- 'reject' (only type currently)
|
| 118 |
-
REJECTION_REASON VARCHAR -- Reason category key
|
| 119 |
-
REJECTION_TEXT VARCHAR -- Custom text explanation
|
| 120 |
-
MESSAGE_HEADER VARCHAR -- Full message header
|
| 121 |
-
MESSAGE_BODY VARCHAR -- Full message body
|
| 122 |
-
CAMPAIGN_NAME VARCHAR -- Campaign identifier
|
| 123 |
-
BRAND VARCHAR -- Brand name
|
| 124 |
-
CONFIG_NAME VARCHAR -- Configuration used
|
| 125 |
-
TIMESTAMP TIMESTAMP -- Feedback submission time
|
| 126 |
```
|
| 127 |
|
| 128 |
---
|
| 129 |
|
| 130 |
-
##
|
| 131 |
|
| 132 |
-
###
|
| 133 |
-
|
| 134 |
-
- Python 3.9+
|
| 135 |
-
- Snowflake account and credentials
|
| 136 |
-
- OpenAI/Google AI API keys
|
| 137 |
-
- AI Messaging System v2 installed in parent directory
|
| 138 |
-
- Plotly for data visualization
|
| 139 |
-
|
| 140 |
-
### Installation
|
| 141 |
|
| 142 |
```bash
|
| 143 |
-
# Install dependencies
|
| 144 |
pip install -r requirements.txt
|
| 145 |
-
|
| 146 |
-
# Set up environment variables (.env file)
|
| 147 |
-
SNOWFLAKE_USER=your_user
|
| 148 |
-
SNOWFLAKE_PASSWORD=your_password
|
| 149 |
-
SNOWFLAKE_ACCOUNT=your_account
|
| 150 |
-
SNOWFLAKE_ROLE=your_role
|
| 151 |
-
SNOWFLAKE_DATABASE=MESSAGING_SYSTEM_V2
|
| 152 |
-
SNOWFLAKE_WAREHOUSE=your_warehouse
|
| 153 |
-
SNOWFLAKE_SCHEMA=UI
|
| 154 |
-
|
| 155 |
-
# LLM API keys
|
| 156 |
-
OPENAI_API_KEY=your_key
|
| 157 |
-
GOOGLE_API_KEY=your_key
|
| 158 |
-
```
|
| 159 |
-
|
| 160 |
-
### Running the Application
|
| 161 |
-
|
| 162 |
-
```bash
|
| 163 |
-
# From the visualization directory
|
| 164 |
-
cd visualization
|
| 165 |
-
streamlit run app.py
|
| 166 |
-
```
|
| 167 |
-
|
| 168 |
-
### First-Time Setup
|
| 169 |
-
|
| 170 |
-
1. Ensure `.env` file exists with Snowflake credentials
|
| 171 |
-
2. Verify user CSV files exist in `data/UI_users/` for each brand
|
| 172 |
-
3. Create Snowflake tables using schema above (or let system auto-create)
|
| 173 |
-
4. Upload initial configurations to Snowflake (optional - can create in UI)
|
| 174 |
-
5. Login with authorized email and access token
|
| 175 |
-
|
| 176 |
-
---
|
| 177 |
-
|
| 178 |
-
## ๐ Complete Data Flow
|
| 179 |
-
|
| 180 |
-
### **Session State Architecture**
|
| 181 |
-
|
| 182 |
-
All active data lives in Streamlit's `session_state`:
|
| 183 |
-
|
| 184 |
-
```python
|
| 185 |
-
# Single Experiment Mode
|
| 186 |
-
st.session_state.ui_log_data # DataFrame: Generated messages
|
| 187 |
-
st.session_state.current_experiment_id # String: Experiment identifier
|
| 188 |
-
st.session_state.current_experiment_metadata # List[Dict]: Metadata per stage
|
| 189 |
-
st.session_state.current_feedbacks # List[Dict]: Feedback records
|
| 190 |
-
|
| 191 |
-
# AB Testing Mode
|
| 192 |
-
st.session_state.ui_log_data_a # DataFrame: Experiment A messages
|
| 193 |
-
st.session_state.ui_log_data_b # DataFrame: Experiment B messages
|
| 194 |
-
st.session_state.experiment_a_id # String: Experiment A identifier
|
| 195 |
-
st.session_state.experiment_b_id # String: Experiment B identifier
|
| 196 |
-
st.session_state.experiment_a_metadata # List[Dict]: A's metadata
|
| 197 |
-
st.session_state.experiment_b_metadata # List[Dict]: B's metadata
|
| 198 |
-
st.session_state.feedbacks_a # List[Dict]: A's feedback
|
| 199 |
-
st.session_state.feedbacks_b # List[Dict]: B's feedback
|
| 200 |
-
|
| 201 |
-
# Configuration
|
| 202 |
-
st.session_state.campaign_config # Dict: Single mode config
|
| 203 |
-
st.session_state.campaign_config_a # Dict: AB mode config A
|
| 204 |
-
st.session_state.campaign_config_b # Dict: AB mode config B
|
| 205 |
-
st.session_state.configs_cache # Dict: Cached configs from Snowflake
|
| 206 |
-
```
|
| 207 |
-
|
| 208 |
-
### **End-to-End Workflow**
|
| 209 |
-
|
| 210 |
-
```
|
| 211 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 212 |
-
โ 1. CAMPAIGN BUILDER โ
|
| 213 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
| 214 |
-
โ โข Load configs from Snowflake (cached in session_state) โ
|
| 215 |
-
โ โข User selects/modifies configuration โ
|
| 216 |
-
โ โข Sample users from CSV files โ
|
| 217 |
-
โ โข Generate messages (stored in session_state.ui_log_data) โ
|
| 218 |
-
โ โข Track metadata (session_state.current_experiment_metadata) โ
|
| 219 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 220 |
-
โ
|
| 221 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 222 |
-
โ 2. MESSAGE VIEWER โ
|
| 223 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
| 224 |
-
โ โข Load messages from session_state.ui_log_data โ
|
| 225 |
-
โ โข Display in user-centric or stage-centric views โ
|
| 226 |
-
โ โข User provides feedback (reject messages) โ
|
| 227 |
-
โ โข Feedback stored in session_state.current_feedbacks โ
|
| 228 |
-
โ โข [BUTTON] Store Results to Snowflake โ
|
| 229 |
-
โ โ Writes metadata to EXPERIMENT_METADATA table โ
|
| 230 |
-
โ โ Writes feedback to FEEDBACKS table โ
|
| 231 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 232 |
-
โ
|
| 233 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 234 |
-
โ 3. ANALYTICS โ
|
| 235 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
| 236 |
-
โ โข Load current experiment from session_state โ
|
| 237 |
-
โ โข Calculate metrics using SessionFeedbackManager โ
|
| 238 |
-
โ โข Show overall performance, stage analysis, rejection reasons โ
|
| 239 |
-
โ โข Support AB testing side-by-side comparison โ
|
| 240 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 241 |
-
โ
|
| 242 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 243 |
-
โ 4. HISTORICAL ANALYTICS โ
|
| 244 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
| 245 |
-
โ โข [BUTTON] Load Historical Data from Snowflake โ
|
| 246 |
-
โ โข Query EXPERIMENT_METADATA + FEEDBACKS tables โ
|
| 247 |
-
โ โข Calculate aggregate metrics across all experiments โ
|
| 248 |
-
โ โข Show trends: rejection rates over time โ
|
| 249 |
-
โ โข Compare configurations: which performs best โ
|
| 250 |
-
โ โข Filter by date range โ
|
| 251 |
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 252 |
-
```
|
| 253 |
-
|
| 254 |
-
---
|
| 255 |
-
|
| 256 |
-
## ๐ Pages Overview
|
| 257 |
-
|
| 258 |
-
### Page 0: Home & Authentication (app.py)
|
| 259 |
-
|
| 260 |
-
**Purpose**: Login, brand selection, config loading, and navigation hub
|
| 261 |
-
|
| 262 |
-
**Features**:
|
| 263 |
-
- Email and token-based authentication
|
| 264 |
-
- Brand selection (Drumeo, Pianote, Guitareo, Singeo)
|
| 265 |
-
- **Config loading from Snowflake on startup**
|
| 266 |
-
- Brand-specific theming applied throughout app
|
| 267 |
-
- Navigation guide and quick start instructions
|
| 268 |
-
- Current experiment status overview
|
| 269 |
-
|
| 270 |
-
**Technical Details**:
|
| 271 |
-
- Session-based authentication
|
| 272 |
-
- Brand selection persists via `st.session_state.selected_brand`
|
| 273 |
-
- Configs cached in `st.session_state.configs_cache` on first load
|
| 274 |
-
- Dynamic theming using `utils/theme.py`
|
| 275 |
-
- Loads environment variables from `.env` file
|
| 276 |
-
|
| 277 |
-
**Key Functions**:
|
| 278 |
-
```python
|
| 279 |
-
def load_configs_from_snowflake(brand):
|
| 280 |
-
"""Load all configs for brand from Snowflake, cache in session_state."""
|
| 281 |
-
session = create_snowflake_session()
|
| 282 |
-
config_manager = ConfigManager(session)
|
| 283 |
-
configs = config_manager.load_configs_from_snowflake(brand)
|
| 284 |
-
st.session_state.configs_cache = configs
|
| 285 |
-
session.close()
|
| 286 |
-
```
|
| 287 |
-
|
| 288 |
-
---
|
| 289 |
-
|
| 290 |
-
### Page 1: Campaign Builder
|
| 291 |
-
|
| 292 |
-
**Purpose**: Create and run message generation campaigns with integrated A/B testing
|
| 293 |
-
|
| 294 |
-
**Architecture**: In-memory experiment execution with parallel AB testing
|
| 295 |
-
|
| 296 |
-
#### Key Features
|
| 297 |
-
|
| 298 |
-
**Configuration Management**:
|
| 299 |
-
- Loads configs from cached `session_state.configs_cache`
|
| 300 |
-
- Real-time config editing in UI
|
| 301 |
-
- Save configs to Snowflake with auto-versioning
|
| 302 |
-
- Quick save or save-as-new options
|
| 303 |
-
|
| 304 |
-
**A/B Testing Toggle**:
|
| 305 |
-
- Single Experiment Mode (default): One campaign
|
| 306 |
-
- A/B Testing Mode: Two parallel experiments for comparison
|
| 307 |
-
|
| 308 |
-
**User Sampling**:
|
| 309 |
-
- Random sampling from brand-specific CSV files
|
| 310 |
-
- 1-25 users selectable
|
| 311 |
-
- Same users used for both AB experiments (fair comparison)
|
| 312 |
-
|
| 313 |
-
**Parallel Execution**:
|
| 314 |
-
- Uses `ExperimentRunner.run_ab_test_parallel()`
|
| 315 |
-
- Threading for simultaneous A/B generation
|
| 316 |
-
- Separate Snowflake sessions per experiment (no conflicts)
|
| 317 |
-
- Console logging for thread progress
|
| 318 |
-
- Results stored directly in session_state
|
| 319 |
-
|
| 320 |
-
**Technical Details**:
|
| 321 |
-
|
| 322 |
-
**Single Mode Generation**:
|
| 323 |
-
```python
|
| 324 |
-
# ExperimentRunner handles all generation logic
|
| 325 |
-
runner = ExperimentRunner(brand=brand, system_config=config)
|
| 326 |
-
|
| 327 |
-
success, ui_log_data, metadata = runner.run_single_experiment(
|
| 328 |
-
config=campaign_config,
|
| 329 |
-
sampled_users_df=users_df,
|
| 330 |
-
experiment_id=experiment_id,
|
| 331 |
-
create_session_func=create_snowflake_session,
|
| 332 |
-
progress_container=st.container()
|
| 333 |
-
)
|
| 334 |
-
|
| 335 |
-
# Store in session_state
|
| 336 |
-
st.session_state.ui_log_data = ui_log_data
|
| 337 |
-
st.session_state.current_experiment_metadata = metadata
|
| 338 |
-
st.session_state.current_experiment_id = experiment_id
|
| 339 |
```
|
| 340 |
|
| 341 |
-
|
| 342 |
-
```python
|
| 343 |
-
# Parallel execution in threads
|
| 344 |
-
results = runner.run_ab_test_parallel(
|
| 345 |
-
config_a=campaign_config_a,
|
| 346 |
-
config_b=campaign_config_b,
|
| 347 |
-
sampled_users_df=users_df,
|
| 348 |
-
experiment_a_id=experiment_a_id,
|
| 349 |
-
experiment_b_id=experiment_b_id,
|
| 350 |
-
create_session_func=create_snowflake_session
|
| 351 |
-
)
|
| 352 |
|
| 353 |
-
|
| 354 |
-
st.session_state.ui_log_data_a = results['a']['ui_log_data']
|
| 355 |
-
st.session_state.ui_log_data_b = results['b']['ui_log_data']
|
| 356 |
-
st.session_state.experiment_a_metadata = results['a']['metadata']
|
| 357 |
-
st.session_state.experiment_b_metadata = results['b']['metadata']
|
| 358 |
-
```
|
| 359 |
|
| 360 |
-
**Configuration Saving to Snowflake**:
|
| 361 |
-
```python
|
| 362 |
-
# Save with auto-versioning
|
| 363 |
-
session = create_snowflake_session()
|
| 364 |
-
db_manager = UIDatabaseManager(session)
|
| 365 |
-
success = db_manager.save_config(
|
| 366 |
-
config_name=config_name,
|
| 367 |
-
config_data=campaign_config,
|
| 368 |
-
brand=brand
|
| 369 |
-
)
|
| 370 |
-
session.close()
|
| 371 |
```
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
### Page 2: Message Viewer
|
| 381 |
-
|
| 382 |
-
**Purpose**: Browse, search, and evaluate generated messages with A/B testing awareness
|
| 383 |
-
|
| 384 |
-
**Architecture**: Loads from session_state, feedback stored in-memory
|
| 385 |
-
|
| 386 |
-
#### Key Features
|
| 387 |
-
|
| 388 |
-
**Automatic AB Detection**:
|
| 389 |
-
```python
|
| 390 |
-
def detect_ab_testing_mode():
|
| 391 |
-
"""Detect AB mode from session_state."""
|
| 392 |
-
return (
|
| 393 |
-
'ui_log_data_a' in st.session_state and
|
| 394 |
-
'ui_log_data_b' in st.session_state and
|
| 395 |
-
st.session_state.ui_log_data_a is not None and
|
| 396 |
-
st.session_state.ui_log_data_b is not None
|
| 397 |
-
)
|
| 398 |
-
```
|
| 399 |
-
|
| 400 |
-
**Message Loading**:
|
| 401 |
-
```python
|
| 402 |
-
# Single mode
|
| 403 |
-
def get_single_experiment_data():
|
| 404 |
-
if 'ui_log_data' in st.session_state:
|
| 405 |
-
return st.session_state.ui_log_data
|
| 406 |
-
return None
|
| 407 |
-
|
| 408 |
-
# AB mode - loads both dataframes
|
| 409 |
-
messages_a_df = st.session_state.ui_log_data_a
|
| 410 |
-
messages_b_df = st.session_state.ui_log_data_b
|
| 411 |
-
```
|
| 412 |
-
|
| 413 |
-
**Feedback System**:
|
| 414 |
-
- In-memory storage using `SessionFeedbackManager`
|
| 415 |
-
- Rejection categories: Poor header, Poor body, Grammar, Emoji, Recommendation issues, Similar to previous, etc.
|
| 416 |
-
- Stores full message header and body with feedback
|
| 417 |
-
- Undo rejection capability
|
| 418 |
-
|
| 419 |
-
```python
|
| 420 |
-
# Add feedback
|
| 421 |
-
SessionFeedbackManager.add_feedback(
|
| 422 |
-
experiment_id=experiment_id,
|
| 423 |
-
user_id=user_id,
|
| 424 |
-
stage=stage,
|
| 425 |
-
feedback_type="reject",
|
| 426 |
-
rejection_reason="poor_header",
|
| 427 |
-
rejection_text="Too generic",
|
| 428 |
-
message_header=header,
|
| 429 |
-
message_body=body,
|
| 430 |
-
campaign_name=campaign_name,
|
| 431 |
-
brand=brand,
|
| 432 |
-
config_name=config_name,
|
| 433 |
-
feedback_list_key="current_feedbacks" # or "feedbacks_a", "feedbacks_b"
|
| 434 |
-
)
|
| 435 |
```
|
| 436 |
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
Located after message viewing section. Button appears prominently.
|
| 440 |
-
|
| 441 |
-
```python
|
| 442 |
-
if st.button("๐พ Store Results to Snowflake"):
|
| 443 |
-
session = create_snowflake_session()
|
| 444 |
-
db_manager = UIDatabaseManager(session)
|
| 445 |
-
|
| 446 |
-
# Single mode
|
| 447 |
-
if not ab_mode:
|
| 448 |
-
# Store metadata
|
| 449 |
-
for meta in st.session_state.current_experiment_metadata:
|
| 450 |
-
db_manager.store_experiment_metadata(meta)
|
| 451 |
-
|
| 452 |
-
# Store feedback
|
| 453 |
-
for feedback in st.session_state.current_feedbacks:
|
| 454 |
-
db_manager.store_feedback(feedback)
|
| 455 |
-
|
| 456 |
-
# AB mode
|
| 457 |
-
else:
|
| 458 |
-
# Store both experiments
|
| 459 |
-
for meta in st.session_state.experiment_a_metadata:
|
| 460 |
-
db_manager.store_experiment_metadata(meta)
|
| 461 |
-
for meta in st.session_state.experiment_b_metadata:
|
| 462 |
-
db_manager.store_experiment_metadata(meta)
|
| 463 |
-
|
| 464 |
-
for feedback in st.session_state.feedbacks_a:
|
| 465 |
-
db_manager.store_feedback(feedback)
|
| 466 |
-
for feedback in st.session_state.feedbacks_b:
|
| 467 |
-
db_manager.store_feedback(feedback)
|
| 468 |
-
|
| 469 |
-
session.close()
|
| 470 |
-
st.success("โ
Results stored to Snowflake successfully!")
|
| 471 |
-
st.balloons()
|
| 472 |
-
```
|
| 473 |
-
|
| 474 |
-
**View Modes**:
|
| 475 |
-
- User-Centric: All stages for each user
|
| 476 |
-
- Stage-Centric: All users for each stage
|
| 477 |
-
- Filters: Stage selection, keyword search, pagination
|
| 478 |
-
|
| 479 |
-
---
|
| 480 |
-
|
| 481 |
-
### Page 3: Analytics Dashboard
|
| 482 |
-
|
| 483 |
-
**Purpose**: Visualize performance metrics for CURRENT experiment only
|
| 484 |
-
|
| 485 |
-
**Architecture**: Loads from session_state, uses SessionFeedbackManager
|
| 486 |
-
|
| 487 |
-
#### Key Features
|
| 488 |
-
|
| 489 |
-
**Data Loading**:
|
| 490 |
-
```python
|
| 491 |
-
# Single mode
|
| 492 |
-
def get_single_experiment_data():
|
| 493 |
-
return st.session_state.ui_log_data
|
| 494 |
-
|
| 495 |
-
# AB mode
|
| 496 |
-
detect_ab_testing_mode() # Returns True if AB data exists
|
| 497 |
-
messages_a_df = st.session_state.ui_log_data_a
|
| 498 |
-
messages_b_df = st.session_state.ui_log_data_b
|
| 499 |
-
```
|
| 500 |
-
|
| 501 |
-
**Feedback Stats Calculation**:
|
| 502 |
-
```python
|
| 503 |
-
# Single mode
|
| 504 |
-
feedback_stats = SessionFeedbackManager.get_feedback_stats(
|
| 505 |
-
experiment_id=experiment_id,
|
| 506 |
-
total_messages=len(messages_df),
|
| 507 |
-
feedback_list_key="current_feedbacks"
|
| 508 |
-
)
|
| 509 |
-
|
| 510 |
-
# AB mode
|
| 511 |
-
feedback_stats_a = SessionFeedbackManager.get_feedback_stats(
|
| 512 |
-
experiment_a_id,
|
| 513 |
-
total_messages=len(messages_a_df),
|
| 514 |
-
feedback_list_key="feedbacks_a"
|
| 515 |
-
)
|
| 516 |
-
feedback_stats_b = SessionFeedbackManager.get_feedback_stats(
|
| 517 |
-
experiment_b_id,
|
| 518 |
-
total_messages=len(messages_b_df),
|
| 519 |
-
feedback_list_key="feedbacks_b"
|
| 520 |
-
)
|
| 521 |
-
```
|
| 522 |
-
|
| 523 |
-
**Metrics Displayed**:
|
| 524 |
-
- Overall: Total messages, rejection rate, feedback count
|
| 525 |
-
- Stage-by-Stage: Performance breakdown per stage
|
| 526 |
-
- Rejection Reasons: Pie charts and bar charts
|
| 527 |
-
- AB Comparison: Side-by-side metrics with winner determination
|
| 528 |
-
|
| 529 |
-
**Export Options**:
|
| 530 |
-
- Export current messages to CSV
|
| 531 |
-
- Export current feedback to CSV
|
| 532 |
-
- Export analytics summary to CSV
|
| 533 |
-
|
| 534 |
-
**Important**: Analytics page shows ONLY the current in-memory experiment. For historical data, use Historical Analytics.
|
| 535 |
-
|
| 536 |
-
---
|
| 537 |
-
|
| 538 |
-
### Page 4: Historical Analytics
|
| 539 |
-
|
| 540 |
-
**Purpose**: Track all past experiments and analyze trends from Snowflake
|
| 541 |
-
|
| 542 |
-
**Architecture**: Button-triggered Snowflake queries
|
| 543 |
-
|
| 544 |
-
#### Key Features
|
| 545 |
-
|
| 546 |
-
**Load Button**:
|
| 547 |
-
```python
|
| 548 |
-
if st.button("๐ Load Historical Data from Snowflake"):
|
| 549 |
-
session = create_snowflake_session()
|
| 550 |
-
db_manager = UIDatabaseManager(session)
|
| 551 |
-
|
| 552 |
-
# Load experiment summary with JOIN
|
| 553 |
-
experiments_df = db_manager.get_experiment_summary(brand=brand)
|
| 554 |
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
session.close()
|
| 559 |
-
```
|
| 560 |
-
|
| 561 |
-
**SQL Query Example**:
|
| 562 |
-
```sql
|
| 563 |
-
SELECT
|
| 564 |
-
m.EXPERIMENT_ID,
|
| 565 |
-
m.CONFIG_NAME,
|
| 566 |
-
m.CAMPAIGN_NAME,
|
| 567 |
-
m.BRAND,
|
| 568 |
-
MIN(m.START_TIME) as start_time,
|
| 569 |
-
SUM(m.TOTAL_MESSAGES) as total_messages,
|
| 570 |
-
MAX(m.TOTAL_USERS) as total_users,
|
| 571 |
-
COUNT(DISTINCT m.STAGE) as total_stages,
|
| 572 |
-
COUNT(f.FEEDBACK_TYPE) as total_rejects,
|
| 573 |
-
(COUNT(f.FEEDBACK_TYPE) * 100.0 / NULLIF(SUM(m.TOTAL_MESSAGES), 0)) as rejection_rate
|
| 574 |
-
FROM MESSAGING_SYSTEM_V2.UI.EXPERIMENT_METADATA m
|
| 575 |
-
LEFT JOIN MESSAGING_SYSTEM_V2.UI.FEEDBACKS f
|
| 576 |
-
ON m.EXPERIMENT_ID = f.EXPERIMENT_ID
|
| 577 |
-
WHERE m.BRAND = :brand
|
| 578 |
-
GROUP BY m.EXPERIMENT_ID, m.CONFIG_NAME, m.CAMPAIGN_NAME, m.BRAND
|
| 579 |
-
ORDER BY start_time DESC
|
| 580 |
```
|
| 581 |
|
| 582 |
-
**Visualizations**:
|
| 583 |
-
- Experiments summary table
|
| 584 |
-
- Rejection rate trend over time (line chart)
|
| 585 |
-
- Performance comparison by configuration (bar chart)
|
| 586 |
-
- Best/worst performing configs
|
| 587 |
-
|
| 588 |
-
**Filters**:
|
| 589 |
-
- Date range filtering
|
| 590 |
-
- Automatic refresh button
|
| 591 |
-
|
| 592 |
-
**Export**:
|
| 593 |
-
- Export summary to CSV
|
| 594 |
-
- Note: Detailed feedback export coming soon (use SQL queries for now)
|
| 595 |
-
|
| 596 |
---
|
| 597 |
|
| 598 |
-
##
|
| 599 |
-
|
| 600 |
-
### utils/db_manager.py - UIDatabaseManager
|
| 601 |
-
|
| 602 |
-
**Purpose**: All Snowflake database operations
|
| 603 |
-
|
| 604 |
-
**Key Methods**:
|
| 605 |
-
```python
|
| 606 |
-
class UIDatabaseManager:
|
| 607 |
-
def __init__(self, session: Session):
|
| 608 |
-
"""Initialize with Snowflake session."""
|
| 609 |
-
|
| 610 |
-
def save_config(self, config_name, config_data, brand):
|
| 611 |
-
"""Save config with auto-versioning."""
|
| 612 |
-
|
| 613 |
-
def load_config(self, config_name, brand, version=None):
|
| 614 |
-
"""Load specific config version."""
|
| 615 |
-
|
| 616 |
-
def store_experiment_metadata(self, metadata: dict):
|
| 617 |
-
"""Insert metadata record."""
|
| 618 |
|
| 619 |
-
|
| 620 |
-
"""Insert feedback record."""
|
| 621 |
-
|
| 622 |
-
def get_experiment_summary(self, brand=None, start_date=None, end_date=None):
|
| 623 |
-
"""Get aggregated experiment metrics with JOIN."""
|
| 624 |
-
|
| 625 |
-
def close(self):
|
| 626 |
-
"""Close Snowflake session."""
|
| 627 |
-
```
|
| 628 |
-
|
| 629 |
-
**Usage Pattern**:
|
| 630 |
-
```python
|
| 631 |
-
# Always use context-like pattern
|
| 632 |
-
session = create_snowflake_session()
|
| 633 |
-
db_manager = UIDatabaseManager(session)
|
| 634 |
-
|
| 635 |
-
try:
|
| 636 |
-
# Do operations
|
| 637 |
-
db_manager.save_config(...)
|
| 638 |
-
db_manager.store_feedback(...)
|
| 639 |
-
finally:
|
| 640 |
-
db_manager.close() # or session.close()
|
| 641 |
-
```
|
| 642 |
|
| 643 |
---
|
| 644 |
|
| 645 |
-
##
|
| 646 |
|
| 647 |
-
|
| 648 |
|
| 649 |
-
|
| 650 |
-
```python
|
| 651 |
-
class ConfigManager:
|
| 652 |
-
def __init__(self, session: Session):
|
| 653 |
-
"""Initialize with Snowflake session."""
|
| 654 |
|
| 655 |
-
|
| 656 |
-
"""Load all configs for brand, returns {name: config_data}."""
|
| 657 |
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
``
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
|
| 662 |
-
|
| 663 |
-
- Configs loaded once on app startup
|
| 664 |
-
- Cached in `st.session_state.configs_cache`
|
| 665 |
-
- Format: `{"config_name": {...config_data...}, ...}`
|
| 666 |
-
- No re-querying Snowflake during session
|
| 667 |
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
### utils/experiment_runner.py - ExperimentRunner
|
| 671 |
-
|
| 672 |
-
**Purpose**: Execute experiments with proper session management
|
| 673 |
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
|
| 683 |
-
|
| 684 |
-
self, config_a, config_b, sampled_users_df,
|
| 685 |
-
experiment_a_id, experiment_b_id, create_session_func
|
| 686 |
-
):
|
| 687 |
-
"""Run two experiments in parallel threads."""
|
| 688 |
-
```
|
| 689 |
-
|
| 690 |
-
**Thread-Safe Design**:
|
| 691 |
-
- Each thread gets own Snowflake session
|
| 692 |
-
- Progress updates handled safely:
|
| 693 |
-
- Main thread: Full Streamlit UI
|
| 694 |
-
- Worker threads: Console logging with dummy UI objects
|
| 695 |
-
- No Streamlit context errors
|
| 696 |
|
| 697 |
-
**
|
| 698 |
-
```python
|
| 699 |
-
# Thread function
|
| 700 |
-
def run_experiment(exp_key, config, exp_id):
|
| 701 |
-
try:
|
| 702 |
-
success, data, metadata = self.run_single_experiment(
|
| 703 |
-
config=config,
|
| 704 |
-
sampled_users_df=users_df,
|
| 705 |
-
experiment_id=exp_id,
|
| 706 |
-
create_session_func=create_session_func,
|
| 707 |
-
progress_container=None # None = threaded mode
|
| 708 |
-
)
|
| 709 |
-
results[exp_key] = {'success': success, 'ui_log_data': data, 'metadata': metadata}
|
| 710 |
-
except Exception as e:
|
| 711 |
-
results[exp_key] = {'success': False, 'error': str(e)}
|
| 712 |
-
|
| 713 |
-
# Start threads
|
| 714 |
-
thread_a = threading.Thread(target=run_experiment, args=('a', config_a, exp_a_id))
|
| 715 |
-
thread_b = threading.Thread(target=run_experiment, args=('b', config_b, exp_b_id))
|
| 716 |
-
thread_a.start()
|
| 717 |
-
thread_b.start()
|
| 718 |
-
thread_a.join()
|
| 719 |
-
thread_b.join()
|
| 720 |
-
```
|
| 721 |
|
| 722 |
---
|
| 723 |
|
| 724 |
-
##
|
| 725 |
-
|
| 726 |
-
**Purpose**: In-memory feedback management
|
| 727 |
-
|
| 728 |
-
**Static Methods** (no instance needed):
|
| 729 |
-
```python
|
| 730 |
-
@staticmethod
|
| 731 |
-
def add_feedback(experiment_id, user_id, stage, feedback_type,
|
| 732 |
-
rejection_reason, rejection_text, message_header,
|
| 733 |
-
message_body, campaign_name, brand, config_name,
|
| 734 |
-
feedback_list_key):
|
| 735 |
-
"""Add feedback to session_state list."""
|
| 736 |
-
|
| 737 |
-
@staticmethod
|
| 738 |
-
def get_feedback_stats(experiment_id, total_messages, feedback_list_key):
|
| 739 |
-
"""Calculate aggregate stats from feedback list."""
|
| 740 |
-
|
| 741 |
-
@staticmethod
|
| 742 |
-
def get_stage_feedback_stats(experiment_id, messages_df, feedback_list_key):
|
| 743 |
-
"""Calculate per-stage stats."""
|
| 744 |
-
|
| 745 |
-
@staticmethod
|
| 746 |
-
def get_rejection_reason_label(reason_key):
|
| 747 |
-
"""Map reason key to display label."""
|
| 748 |
-
```
|
| 749 |
|
| 750 |
-
|
| 751 |
-
- `"current_feedbacks"` - Single mode
|
| 752 |
-
- `"feedbacks_a"` - AB mode experiment A
|
| 753 |
-
- `"feedbacks_b"` - AB mode experiment B
|
| 754 |
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
stage=1,
|
| 762 |
-
feedback_type="reject",
|
| 763 |
-
rejection_reason="poor_header",
|
| 764 |
-
rejection_text="Too generic",
|
| 765 |
-
message_header="Your next lesson ๐",
|
| 766 |
-
message_body="Check it out...",
|
| 767 |
-
campaign_name="Re-engagement",
|
| 768 |
-
brand="drumeo",
|
| 769 |
-
config_name="drumeo_re_engagement_test",
|
| 770 |
-
feedback_list_key="current_feedbacks"
|
| 771 |
-
)
|
| 772 |
-
|
| 773 |
-
# Get stats
|
| 774 |
-
stats = SessionFeedbackManager.get_feedback_stats(
|
| 775 |
-
experiment_id="drumeo_20260114_1234",
|
| 776 |
-
total_messages=100,
|
| 777 |
-
feedback_list_key="current_feedbacks"
|
| 778 |
-
)
|
| 779 |
-
# Returns: {'total_feedback': 10, 'total_rejects': 10,
|
| 780 |
-
# 'reject_rate': 10.0, 'rejection_reasons': {...}}
|
| 781 |
-
```
|
| 782 |
|
| 783 |
---
|
| 784 |
|
| 785 |
-
##
|
| 786 |
-
|
| 787 |
-
Theming automatically adjusts based on selected brand:
|
| 788 |
-
|
| 789 |
-
| Brand | Primary Color | Sidebar BG | Accent | Emoji |
|
| 790 |
-
|----------|---------------|------------|----------|-------|
|
| 791 |
-
| Base | Gold | Dark Gold | Gold | ๐ต |
|
| 792 |
-
| Drumeo | Light Blue | Dark Blue | Blue | ๐ฅ |
|
| 793 |
-
| Pianote | Light Red | Dark Red | Red | ๐น |
|
| 794 |
-
| Guitareo | Light Green | Dark Green | Green | ๐ธ |
|
| 795 |
-
| Singeo | Light Purple | Dark Purple| Purple | ๐ค |
|
| 796 |
-
|
| 797 |
-
**Implementation**: `utils/theme.py`
|
| 798 |
-
|
| 799 |
-
```python
|
| 800 |
-
def get_brand_theme(brand):
|
| 801 |
-
"""Returns theme dictionary."""
|
| 802 |
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
``
|
| 809 |
|
| 810 |
---
|
| 811 |
|
| 812 |
-
##
|
| 813 |
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
``
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
"personalization": true,
|
| 826 |
-
"involve_recsys_result": true,
|
| 827 |
-
"recsys_contents": ["workout", "course", "quick_tips"],
|
| 828 |
-
"specific_content_id": null,
|
| 829 |
-
"segment_info": "Students inactive for 3+ days",
|
| 830 |
-
"instructions": "",
|
| 831 |
-
"sample_examples": "Header: Your next lesson ๐\nMessage: Check it out!",
|
| 832 |
-
"identifier_column": "user_id",
|
| 833 |
-
"platform": "push"
|
| 834 |
-
},
|
| 835 |
-
"2": {
|
| 836 |
-
"stage": 2,
|
| 837 |
-
"model": "gpt-4o-mini",
|
| 838 |
-
"personalization": true,
|
| 839 |
-
"involve_recsys_result": true,
|
| 840 |
-
"recsys_contents": ["song"],
|
| 841 |
-
"specific_content_id": 12345,
|
| 842 |
-
"segment_info": "Students inactive for 7+ days",
|
| 843 |
-
"instructions": "Focus on easy songs",
|
| 844 |
-
"sample_examples": "Header: Let's jam! ๐ธ\nMessage: Try this song!",
|
| 845 |
-
"identifier_column": "user_id",
|
| 846 |
-
"platform": "push"
|
| 847 |
-
}
|
| 848 |
-
}
|
| 849 |
-
```
|
| 850 |
-
|
| 851 |
-
### Rejection Reason Categories
|
| 852 |
-
|
| 853 |
-
```python
|
| 854 |
-
REJECTION_REASONS = {
|
| 855 |
-
"poor_header": "Poor Header",
|
| 856 |
-
"poor_body": "Poor Body/Content",
|
| 857 |
-
"grammar_issues": "Grammar Issues",
|
| 858 |
-
"emoji_problems": "Emoji Problems",
|
| 859 |
-
"recommendation_issues": "Recommendation Issues",
|
| 860 |
-
"wrong_information": "Wrong/Inaccurate Information",
|
| 861 |
-
"tone_issues": "Tone Issues",
|
| 862 |
-
"similarity": "Similar To Previous Header/Messages",
|
| 863 |
-
"other": "Other"
|
| 864 |
-
}
|
| 865 |
-
```
|
| 866 |
-
|
| 867 |
-
### Environment Variables Required
|
| 868 |
-
|
| 869 |
-
```bash
|
| 870 |
-
# Snowflake
|
| 871 |
-
SNOWFLAKE_USER=your_user
|
| 872 |
-
SNOWFLAKE_PASSWORD=your_password
|
| 873 |
-
SNOWFLAKE_ACCOUNT=your_account
|
| 874 |
-
SNOWFLAKE_ROLE=your_role
|
| 875 |
-
SNOWFLAKE_DATABASE=MESSAGING_SYSTEM_V2
|
| 876 |
-
SNOWFLAKE_WAREHOUSE=your_warehouse
|
| 877 |
-
SNOWFLAKE_SCHEMA=UI
|
| 878 |
-
|
| 879 |
-
# LLM APIs
|
| 880 |
-
OPENAI_API_KEY=sk-...
|
| 881 |
-
GOOGLE_API_KEY=AIza...
|
| 882 |
-
```
|
| 883 |
|
| 884 |
---
|
| 885 |
|
| 886 |
-
##
|
| 887 |
-
|
| 888 |
-
### Adding a New Page
|
| 889 |
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
``
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
#
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
BRAND_THEMES["newbrand"] = {
|
| 941 |
-
"primary": "#COLOR",
|
| 942 |
-
"accent": "#COLOR",
|
| 943 |
-
"sidebar_bg": "#DARK_COLOR",
|
| 944 |
-
"text": "#FFFFFF"
|
| 945 |
-
}
|
| 946 |
-
BRAND_EMOJIS["newbrand"] = "๐"
|
| 947 |
-
```
|
| 948 |
-
3. Update `app.py`:
|
| 949 |
-
```python
|
| 950 |
-
brands = ["drumeo", "pianote", "guitareo", "singeo", "newbrand"]
|
| 951 |
-
brand_labels["newbrand"] = "๐ New Brand"
|
| 952 |
```
|
| 953 |
-
4. Create default config in Snowflake or via UI
|
| 954 |
-
|
| 955 |
-
### Debugging Tips
|
| 956 |
-
|
| 957 |
-
**Common Issues**:
|
| 958 |
-
|
| 959 |
-
1. **"No messages found"** in Message Viewer:
|
| 960 |
-
- Check `st.session_state.ui_log_data` exists
|
| 961 |
-
- Verify generation completed in Campaign Builder
|
| 962 |
-
- Look for errors in terminal
|
| 963 |
-
|
| 964 |
-
2. **Snowflake connection errors**:
|
| 965 |
-
- Verify `.env` file exists and is loaded
|
| 966 |
-
- Check credentials are correct
|
| 967 |
-
- Test connection: `create_snowflake_session()`
|
| 968 |
-
|
| 969 |
-
3. **AB test AttributeError**:
|
| 970 |
-
- Fixed in latest version
|
| 971 |
-
- Ensure ExperimentRunner uses thread-safe progress handling
|
| 972 |
-
|
| 973 |
-
4. **Config save errors with quotes**:
|
| 974 |
-
- Fixed: Now uses `write_pandas()` instead of raw SQL
|
| 975 |
-
- Handles JSON with apostrophes correctly
|
| 976 |
-
|
| 977 |
-
5. **Feedback not in Analytics**:
|
| 978 |
-
- Check `st.session_state.current_feedbacks` has data
|
| 979 |
-
- Verify correct feedback_list_key used
|
| 980 |
-
- Check experiment_id matches
|
| 981 |
-
|
| 982 |
-
**Debugging Code**:
|
| 983 |
-
```python
|
| 984 |
-
# Debug session state
|
| 985 |
-
with st.expander("Debug Info"):
|
| 986 |
-
st.write("Session State Keys:", list(st.session_state.keys()))
|
| 987 |
-
if 'ui_log_data' in st.session_state:
|
| 988 |
-
st.write("Messages shape:", st.session_state.ui_log_data.shape)
|
| 989 |
-
if 'current_feedbacks' in st.session_state:
|
| 990 |
-
st.write("Feedback count:", len(st.session_state.current_feedbacks))
|
| 991 |
-
```
|
| 992 |
-
|
| 993 |
-
---
|
| 994 |
-
|
| 995 |
-
## ๐ฏ Key Design Decisions
|
| 996 |
-
|
| 997 |
-
### 1. In-Memory + Cloud Hybrid
|
| 998 |
-
**Decision**: Use session_state for active data, Snowflake for persistence
|
| 999 |
-
**Rationale**:
|
| 1000 |
-
- Fast in-memory operations
|
| 1001 |
-
- No local file dependencies (HuggingFace ready)
|
| 1002 |
-
- Scalable historical storage
|
| 1003 |
-
- Clean separation: current vs. historical
|
| 1004 |
-
|
| 1005 |
-
### 2. One-Click Storage
|
| 1006 |
-
**Decision**: Single "Store Results" button to persist everything
|
| 1007 |
-
**Rationale**:
|
| 1008 |
-
- Simple user workflow
|
| 1009 |
-
- Explicit persistence action
|
| 1010 |
-
- User controls when data is saved
|
| 1011 |
-
- No auto-save surprises
|
| 1012 |
-
|
| 1013 |
-
### 3. Config Caching
|
| 1014 |
-
**Decision**: Load all configs once, cache in session_state
|
| 1015 |
-
**Rationale**:
|
| 1016 |
-
- Reduces Snowflake queries
|
| 1017 |
-
- Faster config switching
|
| 1018 |
-
- Session-scoped cache (fresh on page load)
|
| 1019 |
-
- No stale data issues
|
| 1020 |
-
|
| 1021 |
-
### 4. Thread-Safe AB Testing
|
| 1022 |
-
**Decision**: Separate Snowflake sessions per thread, console logging
|
| 1023 |
-
**Rationale**:
|
| 1024 |
-
- Prevents session conflicts
|
| 1025 |
-
- Streamlit UI only in main thread
|
| 1026 |
-
- Clean error handling
|
| 1027 |
-
- Production-ready parallel execution
|
| 1028 |
-
|
| 1029 |
-
### 5. Versioned Configurations
|
| 1030 |
-
**Decision**: Auto-increment version on every config save
|
| 1031 |
-
**Rationale**:
|
| 1032 |
-
- Full audit trail
|
| 1033 |
-
- Can rollback to previous versions
|
| 1034 |
-
- Supports experimentation
|
| 1035 |
-
- No data loss
|
| 1036 |
-
|
| 1037 |
-
### 6. Button-Triggered Historical Loading
|
| 1038 |
-
**Decision**: Historical Analytics loads on button click, not auto
|
| 1039 |
-
**Rationale**:
|
| 1040 |
-
- User controls when to query Snowflake
|
| 1041 |
-
- Avoids unnecessary queries
|
| 1042 |
-
- Faster page load
|
| 1043 |
-
- Clear user action
|
| 1044 |
-
|
| 1045 |
-
### 7. SessionFeedbackManager Static Methods
|
| 1046 |
-
**Decision**: All methods static, no instance needed
|
| 1047 |
-
**Rationale**:
|
| 1048 |
-
- Simpler API
|
| 1049 |
-
- Works directly with session_state
|
| 1050 |
-
- No state to manage
|
| 1051 |
-
- Cleaner code
|
| 1052 |
-
|
| 1053 |
-
---
|
| 1054 |
-
|
| 1055 |
-
## ๐ Deployment Guide
|
| 1056 |
-
|
| 1057 |
-
### HuggingFace Spaces Deployment
|
| 1058 |
-
|
| 1059 |
-
**Requirements**:
|
| 1060 |
-
- No local file dependencies โ
|
| 1061 |
-
- Environment variables for secrets โ
|
| 1062 |
-
- Snowflake connectivity โ
|
| 1063 |
-
- CSV files in repo (data/UI_users/) โ
|
| 1064 |
-
|
| 1065 |
-
**Steps**:
|
| 1066 |
-
1. Push code to GitHub/HuggingFace repo
|
| 1067 |
-
2. Include `data/UI_users/` CSV files
|
| 1068 |
-
3. Set environment variables in Space settings:
|
| 1069 |
-
- All SNOWFLAKE_* variables
|
| 1070 |
-
- All API keys
|
| 1071 |
-
4. Run: `streamlit run app.py`
|
| 1072 |
-
5. Verify Snowflake connection works
|
| 1073 |
-
|
| 1074 |
-
**Files to Exclude**:
|
| 1075 |
-
- `.env` (use Space secrets instead)
|
| 1076 |
-
- Local cache directories
|
| 1077 |
-
- Test data
|
| 1078 |
-
|
| 1079 |
-
---
|
| 1080 |
-
|
| 1081 |
-
## ๐ Support & Resources
|
| 1082 |
-
|
| 1083 |
-
**Contact**:
|
| 1084 |
-
- Technical Support: danial@musora.com
|
| 1085 |
-
|
| 1086 |
-
**Related Documentation**:
|
| 1087 |
-
- Main System: `ai_messaging_system_v2/README.md`
|
| 1088 |
-
- UI Mode Guide: `ai_messaging_system_v2/UI_MODE_GUIDE.md`
|
| 1089 |
-
- Implementation Details: `visualization/IMPLEMENTATION_COMPLETE.md`
|
| 1090 |
-
- Refactoring Guide: `visualization/ARCHITECTURE_REFACTOR_GUIDE.md`
|
| 1091 |
-
|
| 1092 |
-
**Useful Links**:
|
| 1093 |
-
- Streamlit Documentation: https://docs.streamlit.io
|
| 1094 |
-
- Snowflake Python Connector: https://docs.snowflake.com/en/developer-guide/python-connector/python-connector
|
| 1095 |
-
- Plotly Charts: https://plotly.com/python/
|
| 1096 |
-
|
| 1097 |
-
---
|
| 1098 |
-
|
| 1099 |
-
## ๐ System Status
|
| 1100 |
-
|
| 1101 |
-
**Completion**: 100% โ
|
| 1102 |
-
|
| 1103 |
-
**Completed Components**:
|
| 1104 |
-
1. โ
Database Layer (db_manager.py)
|
| 1105 |
-
2. โ
Config Manager (config_manager.py)
|
| 1106 |
-
3. โ
Session Feedback Manager (session_feedback_manager.py)
|
| 1107 |
-
4. โ
Experiment Runner (experiment_runner.py)
|
| 1108 |
-
5. โ
App.py - Authentication & config loading
|
| 1109 |
-
6. โ
Campaign Builder - Generation & AB testing
|
| 1110 |
-
7. โ
Message Viewer - Viewing & feedback
|
| 1111 |
-
8. โ
Analytics - Current experiment metrics
|
| 1112 |
-
9. โ
Historical Analytics - Snowflake integration
|
| 1113 |
-
|
| 1114 |
-
**Recent Fixes**:
|
| 1115 |
-
- โ
Configuration save error (JSON escaping) - Fixed with `write_pandas()`
|
| 1116 |
-
- โ
AB testing `AttributeError: enter` - Fixed with thread-safe design
|
| 1117 |
-
- โ
Historical Analytics Snowflake connection - Fixed to use `.env`
|
| 1118 |
-
|
| 1119 |
-
**Ready For**:
|
| 1120 |
-
- โ
Production use
|
| 1121 |
-
- โ
HuggingFace deployment
|
| 1122 |
-
- โ
End-to-end testing
|
| 1123 |
-
- โ
Team onboarding
|
| 1124 |
|
| 1125 |
---
|
| 1126 |
|
| 1127 |
-
|
| 1128 |
|
| 1129 |
-
|
| 1130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: gray
|
| 6 |
sdk: streamlit
|
| 7 |
+
sdk_version: 1.50.0
|
| 8 |
python_version: 3.9
|
| 9 |
app_file: app.py
|
| 10 |
---
|
| 11 |
|
|
|
|
| 12 |
|
| 13 |
+
# AI Messaging System v3 โ Visualization Tool
|
| 14 |
|
| 15 |
+
A Streamlit tool for reviewing and evaluating AI-generated push notification messages. All data is read from a single output table โ no joins. Reviewers filter messages by brand, campaign, and scenario, inspect user context stored at generation time, and flag any message that has issues. Rejections are persisted to Snowflake. No action on a message means it is good.
|
| 16 |
|
| 17 |
+
This tool does **not** generate messages. It is purely for reviewing messages already produced by the v3 pipeline.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
---
|
| 20 |
|
| 21 |
+
## Purpose
|
| 22 |
|
| 23 |
+
- Pre-fetch the most recent 150 messages per campaign on login (single query, no joins)
|
| 24 |
+
- Filter instantly by brand, campaign, and scenario โ all client-side, no extra DB queries
|
| 25 |
+
- View the user context captured at generation time (streaks, profile, previous messages, last interacted content)
|
| 26 |
+
- Reject messages with a structured reason โ persisted to Snowflake immediately
|
| 27 |
+
- Track rejection counts per review session in the sidebar
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
---
|
| 30 |
|
| 31 |
+
## Directory Structure
|
| 32 |
|
| 33 |
```
|
| 34 |
visualization/
|
| 35 |
+
โโโ app.py # Entry point: login + data pre-fetch on authentication
|
| 36 |
+
โโโ pages/
|
| 37 |
+
โ โโโ 1_Message_Viewer.py # Message cards, filters, user context, rejection UI
|
| 38 |
+
โโโ utils/
|
| 39 |
+
โ โโโ auth.py # Login / session authentication
|
| 40 |
+
โ โโโ snowflake_client.py # Snowflake reads (single table) and feedback writes
|
| 41 |
+
โ โโโ feedback_manager.py # In-session rejection state + Snowflake persistence
|
| 42 |
+
โ โโโ theme.py # Brand colours, campaign labels, scenario definitions
|
| 43 |
+
โ โโโ __init__.py
|
| 44 |
+
โโโ requirements.txt
|
| 45 |
+
โโโ .env # Credentials (not committed)
|
| 46 |
+
โโโ .env.example
|
| 47 |
+
โโโ README.md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
```
|
| 49 |
|
| 50 |
---
|
| 51 |
|
| 52 |
+
## Getting Started
|
| 53 |
|
| 54 |
+
### 1. Install dependencies
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
```bash
|
|
|
|
| 57 |
pip install -r requirements.txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
```
|
| 59 |
|
| 60 |
+
### 2. Configure credentials
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
+
Copy `.env.example` to `.env` and fill in your Snowflake credentials and the app token:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
```
|
| 65 |
+
SNOWFLAKE_USER = ...
|
| 66 |
+
SNOWFLAKE_PASSWORD = ...
|
| 67 |
+
SNOWFLAKE_ACCOUNT = ...
|
| 68 |
+
SNOWFLAKE_ROLE = ACCOUNTADMIN
|
| 69 |
+
SNOWFLAKE_DATABASE = RECSYS_V3
|
| 70 |
+
SNOWFLAKE_WAREHOUSE= COMPUTE_WH
|
| 71 |
+
SNOWFLAKE_SCHEMA = PUBLIC
|
| 72 |
+
APP_TOKEN = ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
```
|
| 74 |
|
| 75 |
+
### 3. Run the app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+
```bash
|
| 78 |
+
cd ai_messaging_system_v3/visualization
|
| 79 |
+
streamlit run app.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
```
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
---
|
| 83 |
|
| 84 |
+
## Authentication
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
Login requires an email from the authorised list and a shared access token (`APP_TOKEN` in `.env`). Authorised emails are defined in `utils/auth.py`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
---
|
| 89 |
|
| 90 |
+
## Snowflake Tables
|
| 91 |
|
| 92 |
+
### Input (read-only) โ single table, no joins
|
| 93 |
|
| 94 |
+
All data needed for display and filtering is stored at message generation time in one table.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
**`MESSAGING_SYSTEM_V2.GENERATED_DATA.DAILY_PUSH_MESSAGES`**
|
|
|
|
| 97 |
|
| 98 |
+
| Column | Type | Description |
|
| 99 |
+
| --- | --- | --- |
|
| 100 |
+
| `USER_ID` | NUMBER | Target user |
|
| 101 |
+
| `DETECTED_BRAND` | VARCHAR | Brand: `drumeo`, `pianote`, `guitareo`, `singeo`, `playbass` |
|
| 102 |
+
| `BRANCH` | VARCHAR | Campaign routing key (mirrors `CAMPAIGN_NAME`) |
|
| 103 |
+
| `MESSAGE` | VARCHAR | JSON string `{"header": "...", "message": "..."}` โ parsed into header + body |
|
| 104 |
+
| `PLATFORM` | VARCHAR | Delivery platform (e.g. `push`) |
|
| 105 |
+
| `CAMPAIGN_NAME` | VARCHAR | `dailyPush_dailyStreak`, `dailyPush_weeklyStreak`, `dailyPush_noStreak` |
|
| 106 |
+
| `TIMESTAMP` | TIMESTAMP | Generation timestamp |
|
| 107 |
+
| `RECOMMENDATION` | VARCHAR | Recommendation type; `for_you` means no specific recommendation (used for scenario detection) |
|
| 108 |
+
| `RECOMMENDED_CONTENT_ID` | NUMBER | Content ID linked in the message (if any) |
|
| 109 |
+
| `FIRST_NAME` | VARCHAR | User's first name at generation time |
|
| 110 |
+
| `CURRENT_DAILY_STREAK_LENGTH` | NUMBER | Daily streak at generation time |
|
| 111 |
+
| `CURRENT_WEEKLY_STREAK_LENGTH` | NUMBER | Weekly streak at generation time |
|
| 112 |
+
| `USER_PROFILE` | VARCHAR | User profile text used for personalisation |
|
| 113 |
+
| `PREVIOUS_MESSAGES` | VARCHAR | JSON snapshot of prior messages for this user |
|
| 114 |
+
| `LAST_INTERACTED_CONTENT_PROFILE` | VARCHAR | Profile of the last content the user interacted with |
|
| 115 |
|
| 116 |
+
### Output (written by this tool)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
+
**`MESSAGING_SYSTEM_V2.UI.V3_FEEDBACKS`** โ created automatically on first run.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
+
| Column | Description |
|
| 121 |
+
| --- | --- |
|
| 122 |
+
| `BATCH_ID` | UUID generated per review session |
|
| 123 |
+
| `USER_ID` | Reviewed user |
|
| 124 |
+
| `CAMPAIGN_NAME` | Campaign of the reviewed message |
|
| 125 |
+
| `BRAND` | Brand of the reviewed user |
|
| 126 |
+
| `REJECTION_REASON` | Category key (see Rejection Reasons) |
|
| 127 |
+
| `REJECTION_TEXT` | Optional free-text note from the reviewer |
|
| 128 |
+
| `MESSAGE_HEADER` | Full header at time of review |
|
| 129 |
+
| `MESSAGE_BODY` | Full body at time of review |
|
| 130 |
+
| `MESSAGE_TIMESTAMP` | When the message was originally generated |
|
| 131 |
+
| `REVIEWER_EMAIL` | Email of the reviewer |
|
| 132 |
+
| `TIMESTAMP` | When the rejection was submitted |
|
| 133 |
|
| 134 |
+
Rejections are upserted via `MERGE` โ changing the reason on an already-rejected message updates the existing row.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
+
Users with any existing rejection in `V3_FEEDBACKS` are **excluded** from the pre-fetch query, so the same message is never shown twice across sessions.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
---
|
| 139 |
|
| 140 |
+
## Filters
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
+
All filters are applied instantly client-side on the pre-loaded DataFrame โ no Snowflake query is triggered on filter change.
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
+
| Filter | Source column | Notes |
|
| 145 |
+
| --- | --- | --- |
|
| 146 |
+
| Brand | `DETECTED_BRAND` | All, Drumeo, Pianote, Guitareo, Singeo, Playbass |
|
| 147 |
+
| Campaign | `CAMPAIGN_NAME` | All, Daily Streak, Weekly Streak, No Streak |
|
| 148 |
+
| Scenario | `RECOMMENDATION` | Weekly Streak campaign only (see Scenarios) |
|
| 149 |
+
| Only users with previous messages | `HAS_PREVIOUS` | Pre-computed at fetch time via `COUNT(*) > 1` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
---
|
| 152 |
|
| 153 |
+
## Campaigns and Scenarios
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
+
| Campaign | Scenario key | Display label | Detection |
|
| 156 |
+
| --- | --- | --- | --- |
|
| 157 |
+
| `dailyPush_dailyStreak` | โ | โ | No sub-scenarios |
|
| 158 |
+
| `dailyPush_weeklyStreak` | `no_practice_this_week` | No Practice This Week | `RECOMMENDATION == 'for_you'` |
|
| 159 |
+
| `dailyPush_weeklyStreak` | `practiced_this_week` | Practiced This Week | `RECOMMENDATION != 'for_you'` |
|
| 160 |
+
| `dailyPush_noStreak` | โ | โ | No sub-scenarios |
|
| 161 |
|
| 162 |
---
|
| 163 |
|
| 164 |
+
## Rejection Reason Categories
|
| 165 |
|
| 166 |
+
| Key | Label |
|
| 167 |
+
| --- | --- |
|
| 168 |
+
| `poor_header` | Poor Header |
|
| 169 |
+
| `poor_body` | Poor Body / Content |
|
| 170 |
+
| `grammar_issues` | Grammar Issues |
|
| 171 |
+
| `emoji_problems` | Emoji Problems |
|
| 172 |
+
| `recommendation_issues` | Recommendation Issues |
|
| 173 |
+
| `wrong_information` | Wrong / Inaccurate Information |
|
| 174 |
+
| `tone_issues` | Tone Issues |
|
| 175 |
+
| `similarity` | Similar To Previous Messages |
|
| 176 |
+
| `other` | Other |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
---
|
| 179 |
|
| 180 |
+
## Supported Brands
|
|
|
|
|
|
|
| 181 |
|
| 182 |
+
| Brand | Colour |
|
| 183 |
+
| --- | --- |
|
| 184 |
+
| Drumeo | Red `#E84545` |
|
| 185 |
+
| Pianote | Blue `#4A90D9` |
|
| 186 |
+
| Guitareo | Amber `#F5A623` |
|
| 187 |
+
| Singeo | Purple `#7B68EE` |
|
| 188 |
+
| Playbass | Green `#3DAA5C` |
|
| 189 |
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## UI Flow
|
| 193 |
+
|
| 194 |
+
```
|
| 195 |
+
app.py (Login form)
|
| 196 |
+
โ authenticated
|
| 197 |
+
app.py (Landing)
|
| 198 |
+
โ pre-fetches top 150 messages per campaign from DAILY_PUSH_MESSAGES
|
| 199 |
+
โ stores result in session_state["all_messages"]
|
| 200 |
+
โ shows per-campaign counts
|
| 201 |
+
โ navigate to Message Viewer
|
| 202 |
+
pages/1_Message_Viewer.py
|
| 203 |
+
Sidebar (instant, no Apply button):
|
| 204 |
+
Brand / Campaign / Scenario / "Only users with prev messages"
|
| 205 |
+
Reload button โ clears cache, re-fetches
|
| 206 |
+
Sidebar:
|
| 207 |
+
Session stats: showing ยท rejected ยท reject %
|
| 208 |
+
|
| 209 |
+
Main area โ paginated cards (20 per page):
|
| 210 |
+
Each card:
|
| 211 |
+
โโ Brand badge ยท Campaign ยท User #ID ยท Generated timestamp โโโ
|
| 212 |
+
โ โ
|
| 213 |
+
โ Push Notification Preview โ
|
| 214 |
+
โ [Header text] โ
|
| 215 |
+
โ [Body text] โ
|
| 216 |
+
โ Header: N chars ยท Body: N chars โ
|
| 217 |
+
โ โ
|
| 218 |
+
โ โผ User Context (collapsible) โ
|
| 219 |
+
โ First name ยท Daily streak ยท Weekly streak ยท Profile โ
|
| 220 |
+
โ โ
|
| 221 |
+
โ โผ Previous Messages (collapsible) โ
|
| 222 |
+
โ Parsed from PREVIOUS_MESSAGES column โ no extra query โ
|
| 223 |
+
โ โ
|
| 224 |
+
โ โผ Last Interacted Content Profile (collapsible) โ
|
| 225 |
+
โ โ
|
| 226 |
+
โ Feedback column: โ
|
| 227 |
+
โ [โ Reject] โ reason dropdown + optional note โ Submit โ
|
| 228 |
+
โ Already rejected: shows reason ยท [โ๏ธ Change] [Clear] โ
|
| 229 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 230 |
+
โ Rejections saved to Snowflake on Submit
|
| 231 |
+
โ No action = message is considered good
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
---
|
| 235 |
|
| 236 |
+
## Module Responsibilities
|
| 237 |
|
| 238 |
+
| Module | Responsibility |
|
| 239 |
+
| --- | --- |
|
| 240 |
+
| `auth.py` | Session authentication, email allowlist, token verification |
|
| 241 |
+
| `snowflake_client.py` | Single-table fetch from `DAILY_PUSH_MESSAGES`; feedback MERGE/DELETE |
|
| 242 |
+
| `feedback_manager.py` | In-session rejection dict (fast cache), delegates writes to `snowflake_client` |
|
| 243 |
+
| `theme.py` | Brand colours, campaign labels, scenario definitions and `detect_scenario()` |
|
app.py
CHANGED
|
@@ -1,19 +1,18 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
"""
|
| 5 |
|
| 6 |
import sys
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
sys.path.insert(0, str(visualization_dir))
|
| 12 |
-
sys.path.insert(0, str(Path(__file__).parent))
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
from visualization import app
|
|
|
|
| 1 |
"""
|
| 2 |
+
Root entry point for HuggingFace Spaces deployment.
|
| 3 |
+
Delegates to visualization/app.py without modifying any underlying code.
|
| 4 |
"""
|
| 5 |
|
| 6 |
import sys
|
| 7 |
+
import os
|
| 8 |
+
import runpy
|
| 9 |
|
| 10 |
+
_here = os.path.dirname(os.path.abspath(__file__))
|
| 11 |
+
_viz = os.path.join(_here, "visualization")
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
if _viz not in sys.path:
|
| 14 |
+
sys.path.insert(0, _viz)
|
| 15 |
+
|
| 16 |
+
os.chdir(_viz)
|
| 17 |
|
| 18 |
+
runpy.run_path(os.path.join(_viz, "app.py"), run_name="__main__")
|
|
|
pages/1_Message_Viewer.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Root-level page wrapper for HuggingFace Spaces deployment.
|
| 3 |
+
Delegates to visualization/pages/1_Message_Viewer.py without modifying any underlying code.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
import os
|
| 8 |
+
import runpy
|
| 9 |
+
|
| 10 |
+
_here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 11 |
+
_viz = os.path.join(_here, "visualization")
|
| 12 |
+
|
| 13 |
+
if _viz not in sys.path:
|
| 14 |
+
sys.path.insert(0, _viz)
|
| 15 |
+
|
| 16 |
+
os.chdir(_viz)
|
| 17 |
+
|
| 18 |
+
runpy.run_path(os.path.join(_viz, "pages", "1_Message_Viewer.py"), run_name="__main__")
|
requirements.txt
CHANGED
|
@@ -1,47 +1,10 @@
|
|
| 1 |
-
# AI Messaging System -
|
| 2 |
-
# Merges dependencies from both ai_messaging_system_v2 and visualization
|
| 3 |
|
| 4 |
-
# Core
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
python-dotenv==1.0.0
|
| 9 |
|
| 10 |
-
#
|
| 11 |
-
plotly==6.5.1
|
| 12 |
-
|
| 13 |
-
# Snowflake Integration
|
| 14 |
snowflake-snowpark-python==1.19.0
|
| 15 |
snowflake-connector-python==3.17.3
|
| 16 |
-
pyarrow==21.0.0
|
| 17 |
-
cloudpickle==2.2.1
|
| 18 |
-
PyYAML==6.0.2
|
| 19 |
-
|
| 20 |
-
# Date/Time Handling
|
| 21 |
-
pytz==2025.2
|
| 22 |
-
python-dateutil==2.9.0.post0
|
| 23 |
-
tzdata==2025.2
|
| 24 |
-
|
| 25 |
-
# AI/ML APIs
|
| 26 |
-
openai==1.99.9
|
| 27 |
-
google-genai==1.24.0
|
| 28 |
-
anyio==4.10.0
|
| 29 |
-
google-auth==2.40.3
|
| 30 |
-
httpx==0.28.1
|
| 31 |
-
pydantic==2.11.7
|
| 32 |
-
tenacity==8.5.0
|
| 33 |
-
websockets==15.0.1
|
| 34 |
-
distro==1.9.0
|
| 35 |
-
jiter==0.10.0
|
| 36 |
-
sniffio==1.3.1
|
| 37 |
-
|
| 38 |
-
# HTTP and Crypto Dependencies
|
| 39 |
-
certifi==2025.8.3
|
| 40 |
-
requests==2.32.5
|
| 41 |
-
urllib3==1.26.20
|
| 42 |
-
cryptography==45.0.7
|
| 43 |
-
pyOpenSSL==25.1.0
|
| 44 |
-
cffi==1.17.1
|
| 45 |
-
|
| 46 |
-
# Utility Libraries
|
| 47 |
-
tqdm==4.66.4
|
|
|
|
| 1 |
+
# AI Messaging System v3 - Visualization Tool Requirements
|
|
|
|
| 2 |
|
| 3 |
+
# Core
|
| 4 |
+
streamlit>=1.50.0
|
| 5 |
+
pandas>=2.2.0
|
| 6 |
+
python-dotenv>=1.0.0
|
|
|
|
| 7 |
|
| 8 |
+
# Snowflake (connector is bundled with snowpark; either works)
|
|
|
|
|
|
|
|
|
|
| 9 |
snowflake-snowpark-python==1.19.0
|
| 10 |
snowflake-connector-python==3.17.3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
visualization/.env.example
ADDED
|
File without changes
|
visualization/.gitignore
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
env/
|
| 8 |
+
venv/
|
| 9 |
+
ENV/
|
| 10 |
+
build/
|
| 11 |
+
develop-eggs/
|
| 12 |
+
dist/
|
| 13 |
+
downloads/
|
| 14 |
+
eggs/
|
| 15 |
+
.eggs/
|
| 16 |
+
lib/
|
| 17 |
+
lib64/
|
| 18 |
+
parts/
|
| 19 |
+
sdist/
|
| 20 |
+
var/
|
| 21 |
+
wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
+
|
| 26 |
+
# Data files (local storage)
|
| 27 |
+
data/configs/*.json
|
| 28 |
+
data/feedback/*.csv
|
| 29 |
+
data/experiments/*.json
|
| 30 |
+
data/users/*.csv
|
| 31 |
+
|
| 32 |
+
# Keep directory structure but ignore contents
|
| 33 |
+
!data/configs/.gitkeep
|
| 34 |
+
!data/feedback/.gitkeep
|
| 35 |
+
!data/experiments/.gitkeep
|
| 36 |
+
!data/users/.gitkeep
|
| 37 |
+
|
| 38 |
+
# Environment variables
|
| 39 |
+
.env
|
| 40 |
+
.env.local
|
| 41 |
+
|
| 42 |
+
# IDEs
|
| 43 |
+
.vscode/
|
| 44 |
+
.idea/
|
| 45 |
+
*.swp
|
| 46 |
+
*.swo
|
| 47 |
+
*~
|
| 48 |
+
|
| 49 |
+
# OS
|
| 50 |
+
.DS_Store
|
| 51 |
+
Thumbs.db
|
| 52 |
+
|
| 53 |
+
# Streamlit
|
| 54 |
+
.streamlit/secrets.toml
|
| 55 |
+
|
| 56 |
+
# Logs
|
| 57 |
+
*.log
|
visualization/README.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AI Messaging System
|
| 3 |
+
emoji: ๐ถ
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.50.0
|
| 8 |
+
python_version: 3.9
|
| 9 |
+
app_file: app.py
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# AI Messaging System v3 โ Visualization Tool
|
| 14 |
+
|
| 15 |
+
A Streamlit tool for reviewing and evaluating AI-generated push notification messages. All data is read from a single output table โ no joins. Reviewers filter messages by brand, campaign, and scenario, inspect user context stored at generation time, and flag any message that has issues. Rejections are persisted to Snowflake. No action on a message means it is good.
|
| 16 |
+
|
| 17 |
+
This tool does **not** generate messages. It is purely for reviewing messages already produced by the v3 pipeline.
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## Purpose
|
| 22 |
+
|
| 23 |
+
- Pre-fetch the most recent 150 messages per campaign on login (single query, no joins)
|
| 24 |
+
- Filter instantly by brand, campaign, and scenario โ all client-side, no extra DB queries
|
| 25 |
+
- View the user context captured at generation time (streaks, profile, previous messages, last interacted content)
|
| 26 |
+
- Reject messages with a structured reason โ persisted to Snowflake immediately
|
| 27 |
+
- Track rejection counts per review session in the sidebar
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## Directory Structure
|
| 32 |
+
|
| 33 |
+
```
|
| 34 |
+
visualization/
|
| 35 |
+
โโโ app.py # Entry point: login + data pre-fetch on authentication
|
| 36 |
+
โโโ pages/
|
| 37 |
+
โ โโโ 1_Message_Viewer.py # Message cards, filters, user context, rejection UI
|
| 38 |
+
โโโ utils/
|
| 39 |
+
โ โโโ auth.py # Login / session authentication
|
| 40 |
+
โ โโโ snowflake_client.py # Snowflake reads (single table) and feedback writes
|
| 41 |
+
โ โโโ feedback_manager.py # In-session rejection state + Snowflake persistence
|
| 42 |
+
โ โโโ theme.py # Brand colours, campaign labels, scenario definitions
|
| 43 |
+
โ โโโ __init__.py
|
| 44 |
+
โโโ requirements.txt
|
| 45 |
+
โโโ .env # Credentials (not committed)
|
| 46 |
+
โโโ .env.example
|
| 47 |
+
โโโ README.md
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## Getting Started
|
| 53 |
+
|
| 54 |
+
### 1. Install dependencies
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
pip install -r requirements.txt
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### 2. Configure credentials
|
| 61 |
+
|
| 62 |
+
Copy `.env.example` to `.env` and fill in your Snowflake credentials and the app token:
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
SNOWFLAKE_USER = ...
|
| 66 |
+
SNOWFLAKE_PASSWORD = ...
|
| 67 |
+
SNOWFLAKE_ACCOUNT = ...
|
| 68 |
+
SNOWFLAKE_ROLE = ACCOUNTADMIN
|
| 69 |
+
SNOWFLAKE_DATABASE = RECSYS_V3
|
| 70 |
+
SNOWFLAKE_WAREHOUSE= COMPUTE_WH
|
| 71 |
+
SNOWFLAKE_SCHEMA = PUBLIC
|
| 72 |
+
APP_TOKEN = ...
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### 3. Run the app
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
cd ai_messaging_system_v3/visualization
|
| 79 |
+
streamlit run app.py
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## Authentication
|
| 85 |
+
|
| 86 |
+
Login requires an email from the authorised list and a shared access token (`APP_TOKEN` in `.env`). Authorised emails are defined in `utils/auth.py`.
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
## Snowflake Tables
|
| 91 |
+
|
| 92 |
+
### Input (read-only) โ single table, no joins
|
| 93 |
+
|
| 94 |
+
All data needed for display and filtering is stored at message generation time in one table.
|
| 95 |
+
|
| 96 |
+
**`MESSAGING_SYSTEM_V2.GENERATED_DATA.DAILY_PUSH_MESSAGES`**
|
| 97 |
+
|
| 98 |
+
| Column | Type | Description |
|
| 99 |
+
| --- | --- | --- |
|
| 100 |
+
| `USER_ID` | NUMBER | Target user |
|
| 101 |
+
| `DETECTED_BRAND` | VARCHAR | Brand: `drumeo`, `pianote`, `guitareo`, `singeo`, `playbass` |
|
| 102 |
+
| `BRANCH` | VARCHAR | Campaign routing key (mirrors `CAMPAIGN_NAME`) |
|
| 103 |
+
| `MESSAGE` | VARCHAR | JSON string `{"header": "...", "message": "..."}` โ parsed into header + body |
|
| 104 |
+
| `PLATFORM` | VARCHAR | Delivery platform (e.g. `push`) |
|
| 105 |
+
| `CAMPAIGN_NAME` | VARCHAR | `dailyPush_dailyStreak`, `dailyPush_weeklyStreak`, `dailyPush_noStreak` |
|
| 106 |
+
| `TIMESTAMP` | TIMESTAMP | Generation timestamp |
|
| 107 |
+
| `RECOMMENDATION` | VARCHAR | Recommendation type; `for_you` means no specific recommendation (used for scenario detection) |
|
| 108 |
+
| `RECOMMENDED_CONTENT_ID` | NUMBER | Content ID linked in the message (if any) |
|
| 109 |
+
| `FIRST_NAME` | VARCHAR | User's first name at generation time |
|
| 110 |
+
| `CURRENT_DAILY_STREAK_LENGTH` | NUMBER | Daily streak at generation time |
|
| 111 |
+
| `CURRENT_WEEKLY_STREAK_LENGTH` | NUMBER | Weekly streak at generation time |
|
| 112 |
+
| `USER_PROFILE` | VARCHAR | User profile text used for personalisation |
|
| 113 |
+
| `PREVIOUS_MESSAGES` | VARCHAR | JSON snapshot of prior messages for this user |
|
| 114 |
+
| `LAST_INTERACTED_CONTENT_PROFILE` | VARCHAR | Profile of the last content the user interacted with |
|
| 115 |
+
|
| 116 |
+
### Output (written by this tool)
|
| 117 |
+
|
| 118 |
+
**`MESSAGING_SYSTEM_V2.UI.V3_FEEDBACKS`** โ created automatically on first run.
|
| 119 |
+
|
| 120 |
+
| Column | Description |
|
| 121 |
+
| --- | --- |
|
| 122 |
+
| `BATCH_ID` | UUID generated per review session |
|
| 123 |
+
| `USER_ID` | Reviewed user |
|
| 124 |
+
| `CAMPAIGN_NAME` | Campaign of the reviewed message |
|
| 125 |
+
| `BRAND` | Brand of the reviewed user |
|
| 126 |
+
| `REJECTION_REASON` | Category key (see Rejection Reasons) |
|
| 127 |
+
| `REJECTION_TEXT` | Optional free-text note from the reviewer |
|
| 128 |
+
| `MESSAGE_HEADER` | Full header at time of review |
|
| 129 |
+
| `MESSAGE_BODY` | Full body at time of review |
|
| 130 |
+
| `MESSAGE_TIMESTAMP` | When the message was originally generated |
|
| 131 |
+
| `REVIEWER_EMAIL` | Email of the reviewer |
|
| 132 |
+
| `TIMESTAMP` | When the rejection was submitted |
|
| 133 |
+
|
| 134 |
+
Rejections are upserted via `MERGE` โ changing the reason on an already-rejected message updates the existing row.
|
| 135 |
+
|
| 136 |
+
Users with any existing rejection in `V3_FEEDBACKS` are **excluded** from the pre-fetch query, so the same message is never shown twice across sessions.
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## Filters
|
| 141 |
+
|
| 142 |
+
All filters are applied instantly client-side on the pre-loaded DataFrame โ no Snowflake query is triggered on filter change.
|
| 143 |
+
|
| 144 |
+
| Filter | Source column | Notes |
|
| 145 |
+
| --- | --- | --- |
|
| 146 |
+
| Brand | `DETECTED_BRAND` | All, Drumeo, Pianote, Guitareo, Singeo, Playbass |
|
| 147 |
+
| Campaign | `CAMPAIGN_NAME` | All, Daily Streak, Weekly Streak, No Streak |
|
| 148 |
+
| Scenario | `RECOMMENDATION` | Weekly Streak campaign only (see Scenarios) |
|
| 149 |
+
| Only users with previous messages | `HAS_PREVIOUS` | Pre-computed at fetch time via `COUNT(*) > 1` |
|
| 150 |
+
|
| 151 |
+
---
|
| 152 |
+
|
| 153 |
+
## Campaigns and Scenarios
|
| 154 |
+
|
| 155 |
+
| Campaign | Scenario key | Display label | Detection |
|
| 156 |
+
| --- | --- | --- | --- |
|
| 157 |
+
| `dailyPush_dailyStreak` | โ | โ | No sub-scenarios |
|
| 158 |
+
| `dailyPush_weeklyStreak` | `no_practice_this_week` | No Practice This Week | `RECOMMENDATION == 'for_you'` |
|
| 159 |
+
| `dailyPush_weeklyStreak` | `practiced_this_week` | Practiced This Week | `RECOMMENDATION != 'for_you'` |
|
| 160 |
+
| `dailyPush_noStreak` | โ | โ | No sub-scenarios |
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## Rejection Reason Categories
|
| 165 |
+
|
| 166 |
+
| Key | Label |
|
| 167 |
+
| --- | --- |
|
| 168 |
+
| `poor_header` | Poor Header |
|
| 169 |
+
| `poor_body` | Poor Body / Content |
|
| 170 |
+
| `grammar_issues` | Grammar Issues |
|
| 171 |
+
| `emoji_problems` | Emoji Problems |
|
| 172 |
+
| `recommendation_issues` | Recommendation Issues |
|
| 173 |
+
| `wrong_information` | Wrong / Inaccurate Information |
|
| 174 |
+
| `tone_issues` | Tone Issues |
|
| 175 |
+
| `similarity` | Similar To Previous Messages |
|
| 176 |
+
| `other` | Other |
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## Supported Brands
|
| 181 |
+
|
| 182 |
+
| Brand | Colour |
|
| 183 |
+
| --- | --- |
|
| 184 |
+
| Drumeo | Red `#E84545` |
|
| 185 |
+
| Pianote | Blue `#4A90D9` |
|
| 186 |
+
| Guitareo | Amber `#F5A623` |
|
| 187 |
+
| Singeo | Purple `#7B68EE` |
|
| 188 |
+
| Playbass | Green `#3DAA5C` |
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## UI Flow
|
| 193 |
+
|
| 194 |
+
```
|
| 195 |
+
app.py (Login form)
|
| 196 |
+
โ authenticated
|
| 197 |
+
app.py (Landing)
|
| 198 |
+
โ pre-fetches top 150 messages per campaign from DAILY_PUSH_MESSAGES
|
| 199 |
+
โ stores result in session_state["all_messages"]
|
| 200 |
+
โ shows per-campaign counts
|
| 201 |
+
โ navigate to Message Viewer
|
| 202 |
+
pages/1_Message_Viewer.py
|
| 203 |
+
Sidebar (instant, no Apply button):
|
| 204 |
+
Brand / Campaign / Scenario / "Only users with prev messages"
|
| 205 |
+
Reload button โ clears cache, re-fetches
|
| 206 |
+
Sidebar:
|
| 207 |
+
Session stats: showing ยท rejected ยท reject %
|
| 208 |
+
|
| 209 |
+
Main area โ paginated cards (20 per page):
|
| 210 |
+
Each card:
|
| 211 |
+
โโ Brand badge ยท Campaign ยท User #ID ยท Generated timestamp โโโ
|
| 212 |
+
โ โ
|
| 213 |
+
โ Push Notification Preview โ
|
| 214 |
+
โ [Header text] โ
|
| 215 |
+
โ [Body text] โ
|
| 216 |
+
โ Header: N chars ยท Body: N chars โ
|
| 217 |
+
โ โ
|
| 218 |
+
โ โผ User Context (collapsible) โ
|
| 219 |
+
โ First name ยท Daily streak ยท Weekly streak ยท Profile โ
|
| 220 |
+
โ โ
|
| 221 |
+
โ โผ Previous Messages (collapsible) โ
|
| 222 |
+
โ Parsed from PREVIOUS_MESSAGES column โ no extra query โ
|
| 223 |
+
โ โ
|
| 224 |
+
โ โผ Last Interacted Content Profile (collapsible) โ
|
| 225 |
+
โ โ
|
| 226 |
+
โ Feedback column: โ
|
| 227 |
+
โ [โ Reject] โ reason dropdown + optional note โ Submit โ
|
| 228 |
+
โ Already rejected: shows reason ยท [โ๏ธ Change] [Clear] โ
|
| 229 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 230 |
+
โ Rejections saved to Snowflake on Submit
|
| 231 |
+
โ No action = message is considered good
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## Module Responsibilities
|
| 237 |
+
|
| 238 |
+
| Module | Responsibility |
|
| 239 |
+
| --- | --- |
|
| 240 |
+
| `auth.py` | Session authentication, email allowlist, token verification |
|
| 241 |
+
| `snowflake_client.py` | Single-table fetch from `DAILY_PUSH_MESSAGES`; feedback MERGE/DELETE |
|
| 242 |
+
| `feedback_manager.py` | In-session rejection dict (fast cache), delegates writes to `snowflake_client` |
|
| 243 |
+
| `theme.py` | Brand colours, campaign labels, scenario definitions and `detect_scenario()` |
|
visualization/app.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Messaging System v3 โ Visualization Tool
|
| 3 |
+
Entry point: Login + data pre-fetch on authentication.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import streamlit as st
|
| 7 |
+
|
| 8 |
+
st.set_page_config(
|
| 9 |
+
page_title="Messaging Viewer",
|
| 10 |
+
page_icon="๐ฌ",
|
| 11 |
+
layout="wide",
|
| 12 |
+
initial_sidebar_state="expanded",
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
from utils.auth import check_authentication, verify_login, logout, get_current_user
|
| 16 |
+
from utils.theme import inject_global_css
|
| 17 |
+
from utils import snowflake_client as sf
|
| 18 |
+
from utils import feedback_manager as fm
|
| 19 |
+
|
| 20 |
+
inject_global_css()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Login gate
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
def show_login():
|
| 28 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 29 |
+
with col2:
|
| 30 |
+
st.markdown("## ๐ฌ Messaging Viewer")
|
| 31 |
+
st.markdown("Sign in to review AI-generated push notification messages.")
|
| 32 |
+
st.markdown("---")
|
| 33 |
+
|
| 34 |
+
with st.form("login_form"):
|
| 35 |
+
email = st.text_input("Email", placeholder="you@musora.com")
|
| 36 |
+
token = st.text_input("Access Token", type="password")
|
| 37 |
+
submitted = st.form_submit_button("Sign In", use_container_width=True)
|
| 38 |
+
|
| 39 |
+
if submitted:
|
| 40 |
+
if verify_login(email, token):
|
| 41 |
+
st.session_state["authenticated"] = True
|
| 42 |
+
st.session_state["user_email"] = email.lower().strip()
|
| 43 |
+
st.rerun()
|
| 44 |
+
else:
|
| 45 |
+
st.error("Invalid email or token. Please try again.")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Data pre-fetch (runs once after login, cached in session_state)
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
def prefetch_messages():
|
| 53 |
+
"""
|
| 54 |
+
Load top 150 messages per campaign for all campaigns in a single query.
|
| 55 |
+
Stores the result in session_state["all_messages"] so every page can
|
| 56 |
+
filter client-side without additional Snowflake round-trips.
|
| 57 |
+
Skips fetch if data is already cached in this session.
|
| 58 |
+
"""
|
| 59 |
+
if "all_messages" in st.session_state:
|
| 60 |
+
return # already loaded
|
| 61 |
+
|
| 62 |
+
with st.spinner("Loading messagesโฆ"):
|
| 63 |
+
try:
|
| 64 |
+
sf.ensure_feedback_table()
|
| 65 |
+
df = sf.fetch_all_messages()
|
| 66 |
+
st.session_state["all_messages"] = df
|
| 67 |
+
except Exception as e:
|
| 68 |
+
st.error(f"Failed to load messages: {e}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# Main app (authenticated)
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
def show_home():
|
| 76 |
+
user = get_current_user()
|
| 77 |
+
|
| 78 |
+
with st.sidebar:
|
| 79 |
+
st.markdown(f"**Signed in as:** {user}")
|
| 80 |
+
if st.button("Sign Out", use_container_width=True):
|
| 81 |
+
logout()
|
| 82 |
+
fm.reset_session()
|
| 83 |
+
st.session_state.pop("all_messages", None)
|
| 84 |
+
st.rerun()
|
| 85 |
+
if st.button("Reload Data", use_container_width=True, help="Re-fetch messages from Snowflake"):
|
| 86 |
+
st.session_state.pop("all_messages", None)
|
| 87 |
+
fm.reset_session()
|
| 88 |
+
st.rerun()
|
| 89 |
+
|
| 90 |
+
# Pre-fetch data as soon as the user lands on the home page
|
| 91 |
+
prefetch_messages()
|
| 92 |
+
|
| 93 |
+
df = st.session_state.get("all_messages")
|
| 94 |
+
total = len(df) if df is not None else 0
|
| 95 |
+
|
| 96 |
+
st.markdown("## ๐ฌ Messaging Viewer")
|
| 97 |
+
st.markdown(
|
| 98 |
+
"Browse AI-generated push notifications, inspect user context, "
|
| 99 |
+
"and flag messages that need improvement."
|
| 100 |
+
)
|
| 101 |
+
st.markdown("---")
|
| 102 |
+
|
| 103 |
+
if df is not None and not df.empty:
|
| 104 |
+
campaigns = df["CAMPAIGN_NAME"].value_counts().to_dict()
|
| 105 |
+
cols = st.columns(len(campaigns) + 1)
|
| 106 |
+
cols[0].metric("Total loaded", total)
|
| 107 |
+
for i, (camp, count) in enumerate(campaigns.items(), 1):
|
| 108 |
+
cols[i].metric(camp.replace("dailyPush_", ""), count)
|
| 109 |
+
st.markdown("---")
|
| 110 |
+
|
| 111 |
+
st.markdown("### How to use")
|
| 112 |
+
c1, c2, c3 = st.columns(3)
|
| 113 |
+
with c1:
|
| 114 |
+
st.markdown("**1. Open Message Viewer**")
|
| 115 |
+
st.markdown("Navigate to the Message Viewer page using the sidebar.")
|
| 116 |
+
with c2:
|
| 117 |
+
st.markdown("**2. Filter instantly**")
|
| 118 |
+
st.markdown(
|
| 119 |
+
"Use the sidebar filters (brand, campaign, scenario) to narrow down "
|
| 120 |
+
"messages. Changes apply immediately โ no Apply button needed."
|
| 121 |
+
)
|
| 122 |
+
with c3:
|
| 123 |
+
st.markdown("**3. Review & reject**")
|
| 124 |
+
st.markdown(
|
| 125 |
+
"Read each message with its user context. Click **Reject** and pick "
|
| 126 |
+
"a reason for any message that has issues. No action = message is good."
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
st.markdown("---")
|
| 130 |
+
st.page_link("pages/1_Message_Viewer.py", label="Open Message Viewer โ", icon="๐ฌ")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
# Router
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
|
| 137 |
+
if not check_authentication():
|
| 138 |
+
show_login()
|
| 139 |
+
else:
|
| 140 |
+
show_home()
|
visualization/pages/1_Message_Viewer.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Message Viewer page.
|
| 3 |
+
|
| 4 |
+
Displays the chronological sequence of push notifications per user.
|
| 5 |
+
Each user has up to 3 messages (oldest โ newest).
|
| 6 |
+
|
| 7 |
+
Sidebar view-mode controls how many columns are shown per card:
|
| 8 |
+
1 โ oldest message only (default)
|
| 9 |
+
2 โ oldest + 2nd message side by side
|
| 10 |
+
3 โ all three messages side by side
|
| 11 |
+
|
| 12 |
+
All data comes from session_state["all_messages"] pre-fetched at login.
|
| 13 |
+
Filters (brand, campaign, scenario, min messages) apply instantly client-side.
|
| 14 |
+
Rejections are per user and persisted to Snowflake on submit.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import streamlit as st
|
| 18 |
+
import pandas as pd
|
| 19 |
+
import json
|
| 20 |
+
|
| 21 |
+
st.set_page_config(
|
| 22 |
+
page_title="Message Viewer",
|
| 23 |
+
page_icon="๐ฌ",
|
| 24 |
+
layout="wide",
|
| 25 |
+
initial_sidebar_state="expanded",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
from utils.auth import check_authentication, get_current_user, logout
|
| 29 |
+
from utils.theme import (
|
| 30 |
+
inject_global_css, get_brand, get_campaign_label,
|
| 31 |
+
ALL_BRANDS, ALL_CAMPAIGNS, get_scenarios, detect_scenario,
|
| 32 |
+
)
|
| 33 |
+
from utils import snowflake_client as sf
|
| 34 |
+
from utils import feedback_manager as fm
|
| 35 |
+
|
| 36 |
+
inject_global_css()
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Auth guard
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
if not check_authentication():
|
| 43 |
+
st.warning("Please sign in on the Home page.")
|
| 44 |
+
st.stop()
|
| 45 |
+
|
| 46 |
+
user_email = get_current_user()
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Ensure data is loaded (fallback if user navigates here directly)
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
if "all_messages" not in st.session_state:
|
| 53 |
+
with st.spinner("Loading messagesโฆ"):
|
| 54 |
+
try:
|
| 55 |
+
sf.ensure_feedback_table()
|
| 56 |
+
st.session_state["all_messages"] = sf.fetch_all_messages()
|
| 57 |
+
except Exception as e:
|
| 58 |
+
st.error(f"Failed to load messages: {e}")
|
| 59 |
+
st.stop()
|
| 60 |
+
|
| 61 |
+
df_all: pd.DataFrame = st.session_state["all_messages"]
|
| 62 |
+
|
| 63 |
+
PAGE_SIZE = 15 # users per page (each user can span up to 3 columns)
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# Sidebar
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
with st.sidebar:
|
| 70 |
+
st.markdown(f"**{user_email}**")
|
| 71 |
+
|
| 72 |
+
col_so, col_rl = st.columns(2)
|
| 73 |
+
with col_so:
|
| 74 |
+
if st.button("Sign Out", use_container_width=True):
|
| 75 |
+
logout()
|
| 76 |
+
fm.reset_session()
|
| 77 |
+
st.session_state.pop("all_messages", None)
|
| 78 |
+
st.rerun()
|
| 79 |
+
with col_rl:
|
| 80 |
+
if st.button("Reload", use_container_width=True, help="Re-fetch from Snowflake"):
|
| 81 |
+
st.session_state.pop("all_messages", None)
|
| 82 |
+
fm.reset_session()
|
| 83 |
+
st.session_state.pop("mv_page", None)
|
| 84 |
+
st.rerun()
|
| 85 |
+
|
| 86 |
+
st.markdown("---")
|
| 87 |
+
st.markdown("### View")
|
| 88 |
+
|
| 89 |
+
view_mode = st.radio(
|
| 90 |
+
"Messages to display per user",
|
| 91 |
+
options=[1, 2, 3],
|
| 92 |
+
format_func=lambda n: {
|
| 93 |
+
1: "First message only",
|
| 94 |
+
2: "First + 2nd message",
|
| 95 |
+
3: "All 3 messages",
|
| 96 |
+
}[n],
|
| 97 |
+
key="mv_view_mode",
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
st.markdown("---")
|
| 101 |
+
st.markdown("### Filters")
|
| 102 |
+
|
| 103 |
+
selected_brand = st.selectbox(
|
| 104 |
+
"Brand",
|
| 105 |
+
options=ALL_BRANDS,
|
| 106 |
+
format_func=lambda b: get_brand(b)["label"],
|
| 107 |
+
key="mv_brand",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
selected_campaign = st.selectbox(
|
| 111 |
+
"Campaign",
|
| 112 |
+
options=ALL_CAMPAIGNS,
|
| 113 |
+
format_func=get_campaign_label,
|
| 114 |
+
key="mv_campaign",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
scenarios = get_scenarios(selected_campaign)
|
| 118 |
+
selected_scenario = "all"
|
| 119 |
+
if scenarios:
|
| 120 |
+
scenario_keys = [s[0] for s in scenarios]
|
| 121 |
+
scenario_labels = {s[0]: s[1] for s in scenarios}
|
| 122 |
+
selected_scenario = st.selectbox(
|
| 123 |
+
"Scenario",
|
| 124 |
+
options=scenario_keys,
|
| 125 |
+
format_func=lambda k: scenario_labels[k],
|
| 126 |
+
key="mv_scenario",
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
min_msgs = st.checkbox(
|
| 130 |
+
f"Only users with โฅ {view_mode} message(s)",
|
| 131 |
+
value=False,
|
| 132 |
+
key="mv_min_msgs",
|
| 133 |
+
help="Hide users who have fewer messages than the selected view mode",
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# Reset to page 0 on any filter / view change
|
| 137 |
+
filter_sig = (selected_brand, selected_campaign, selected_scenario, min_msgs, view_mode)
|
| 138 |
+
if st.session_state.get("_filter_sig") != filter_sig:
|
| 139 |
+
st.session_state["mv_page"] = 0
|
| 140 |
+
st.session_state["_filter_sig"] = filter_sig
|
| 141 |
+
|
| 142 |
+
st.markdown("---")
|
| 143 |
+
st.markdown("### Session Stats")
|
| 144 |
+
stats_placeholder = st.empty()
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# Client-side filtering
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
# Work only on the rank-1 (oldest) rows to decide which users to show
|
| 152 |
+
df_primary = df_all[df_all["MSG_RANK"] == 1].copy()
|
| 153 |
+
|
| 154 |
+
if selected_brand != "all":
|
| 155 |
+
df_primary = df_primary[df_primary["DETECTED_BRAND"] == selected_brand.lower()]
|
| 156 |
+
|
| 157 |
+
if selected_campaign != "all":
|
| 158 |
+
df_primary = df_primary[df_primary["CAMPAIGN_NAME"] == selected_campaign]
|
| 159 |
+
|
| 160 |
+
if (
|
| 161 |
+
selected_scenario != "all"
|
| 162 |
+
and selected_campaign == "dailyPush_weeklyStreak"
|
| 163 |
+
and "RECOMMENDATION" in df_primary.columns
|
| 164 |
+
):
|
| 165 |
+
df_primary = df_primary[
|
| 166 |
+
df_primary["RECOMMENDATION"].apply(detect_scenario) == selected_scenario
|
| 167 |
+
]
|
| 168 |
+
|
| 169 |
+
if min_msgs:
|
| 170 |
+
df_primary = df_primary[df_primary["USER_MSG_COUNT"] >= view_mode]
|
| 171 |
+
|
| 172 |
+
# Ordered list of USER_IDs to display
|
| 173 |
+
user_ids_ordered = df_primary["USER_ID"].tolist()
|
| 174 |
+
total_users = len(user_ids_ordered)
|
| 175 |
+
|
| 176 |
+
# ---------------------------------------------------------------------------
|
| 177 |
+
# Sidebar stats
|
| 178 |
+
# ---------------------------------------------------------------------------
|
| 179 |
+
|
| 180 |
+
stats = fm.get_stats(total_users)
|
| 181 |
+
with stats_placeholder.container():
|
| 182 |
+
st.metric("Users shown", total_users)
|
| 183 |
+
st.metric("Rejected โ", stats["total_rejected"])
|
| 184 |
+
if total_users:
|
| 185 |
+
st.progress(
|
| 186 |
+
stats["total_rejected"] / total_users,
|
| 187 |
+
text=f"{stats['reject_rate']}% rejected",
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# Page header
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
b_cfg = get_brand(selected_brand)
|
| 195 |
+
view_labels = {1: "First message", 2: "First 2 messages", 3: "All 3 messages"}
|
| 196 |
+
st.markdown(f"## {b_cfg['emoji']} Message Viewer")
|
| 197 |
+
st.caption(
|
| 198 |
+
f"Brand: **{b_cfg['label']}** ยท "
|
| 199 |
+
f"Campaign: **{get_campaign_label(selected_campaign)}** ยท "
|
| 200 |
+
f"{total_users} users ยท Showing: **{view_labels[view_mode]}**"
|
| 201 |
+
)
|
| 202 |
+
st.markdown("---")
|
| 203 |
+
|
| 204 |
+
if not user_ids_ordered:
|
| 205 |
+
st.info("No messages match the current filters.")
|
| 206 |
+
st.stop()
|
| 207 |
+
|
| 208 |
+
# ---------------------------------------------------------------------------
|
| 209 |
+
# Pagination (per user, not per row)
|
| 210 |
+
# ---------------------------------------------------------------------------
|
| 211 |
+
|
| 212 |
+
total_pages = max(1, (total_users + PAGE_SIZE - 1) // PAGE_SIZE)
|
| 213 |
+
if "mv_page" not in st.session_state:
|
| 214 |
+
st.session_state["mv_page"] = 0
|
| 215 |
+
page = min(st.session_state["mv_page"], total_pages - 1)
|
| 216 |
+
st.session_state["mv_page"] = page
|
| 217 |
+
|
| 218 |
+
pc1, pc2, pc3 = st.columns([1, 3, 1])
|
| 219 |
+
with pc1:
|
| 220 |
+
if st.button("โ Prev", disabled=page == 0, key="prev_top"):
|
| 221 |
+
st.session_state["mv_page"] = page - 1
|
| 222 |
+
st.rerun()
|
| 223 |
+
with pc2:
|
| 224 |
+
st.markdown(
|
| 225 |
+
f"<p style='text-align:center;color:#666;margin:6px 0'>"
|
| 226 |
+
f"Page {page + 1} of {total_pages}</p>",
|
| 227 |
+
unsafe_allow_html=True,
|
| 228 |
+
)
|
| 229 |
+
with pc3:
|
| 230 |
+
if st.button("Next โ", disabled=page >= total_pages - 1, key="next_top"):
|
| 231 |
+
st.session_state["mv_page"] = page + 1
|
| 232 |
+
st.rerun()
|
| 233 |
+
|
| 234 |
+
page_user_ids = user_ids_ordered[page * PAGE_SIZE : (page + 1) * PAGE_SIZE]
|
| 235 |
+
|
| 236 |
+
# Pre-slice all rows for users on this page
|
| 237 |
+
page_df = df_all[df_all["USER_ID"].isin(page_user_ids)].copy()
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
# Helpers
|
| 242 |
+
# ---------------------------------------------------------------------------
|
| 243 |
+
|
| 244 |
+
def _val(v):
|
| 245 |
+
if v is None:
|
| 246 |
+
return None
|
| 247 |
+
try:
|
| 248 |
+
if pd.isna(v):
|
| 249 |
+
return None
|
| 250 |
+
except (TypeError, ValueError):
|
| 251 |
+
pass
|
| 252 |
+
return v
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _parse_previous_messages(raw) -> list:
|
| 256 |
+
if not raw:
|
| 257 |
+
return []
|
| 258 |
+
try:
|
| 259 |
+
parsed = json.loads(str(raw))
|
| 260 |
+
if isinstance(parsed, list):
|
| 261 |
+
return parsed
|
| 262 |
+
if isinstance(parsed, dict):
|
| 263 |
+
return [
|
| 264 |
+
{"date": k, **v} if isinstance(v, dict) else {"date": k, "text": str(v)}
|
| 265 |
+
for k, v in parsed.items()
|
| 266 |
+
]
|
| 267 |
+
except Exception:
|
| 268 |
+
pass
|
| 269 |
+
return [{"text": str(raw)}]
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _parse_recommendation(raw) -> dict:
|
| 273 |
+
"""
|
| 274 |
+
Parse the RECOMMENDATION column into a dict.
|
| 275 |
+
Returns {} if the value is missing, 'for_you', or not valid JSON.
|
| 276 |
+
"""
|
| 277 |
+
if not raw:
|
| 278 |
+
return {}
|
| 279 |
+
s = str(raw).strip()
|
| 280 |
+
if s.lower() == "for_you":
|
| 281 |
+
return {}
|
| 282 |
+
try:
|
| 283 |
+
parsed = json.loads(s)
|
| 284 |
+
if isinstance(parsed, dict):
|
| 285 |
+
return parsed
|
| 286 |
+
except Exception:
|
| 287 |
+
pass
|
| 288 |
+
return {}
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _render_recommendation(raw):
|
| 292 |
+
"""
|
| 293 |
+
Display a recommendation card: thumbnail image + clickable title.
|
| 294 |
+
Renders nothing if there is no recommendation or it is 'for_you'.
|
| 295 |
+
"""
|
| 296 |
+
rec = _parse_recommendation(raw)
|
| 297 |
+
if not rec:
|
| 298 |
+
return
|
| 299 |
+
title = rec.get("title", "")
|
| 300 |
+
url = rec.get("web_url_path", "")
|
| 301 |
+
thumb_url = rec.get("thumbnail_url", "")
|
| 302 |
+
|
| 303 |
+
if thumb_url:
|
| 304 |
+
st.image(thumb_url, width=140)
|
| 305 |
+
if title and url:
|
| 306 |
+
st.markdown(f"[{title}]({url})")
|
| 307 |
+
elif title:
|
| 308 |
+
st.markdown(f"**{title}**")
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _notification_box(header: str, body: str, border_color: str, label: str):
|
| 312 |
+
"""Render a single push notification preview box with a column label."""
|
| 313 |
+
st.markdown(
|
| 314 |
+
f"<p style='font-size:11px;font-weight:700;text-transform:uppercase;"
|
| 315 |
+
f"letter-spacing:.06em;color:#999;margin-bottom:4px;'>{label}</p>",
|
| 316 |
+
unsafe_allow_html=True,
|
| 317 |
+
)
|
| 318 |
+
st.markdown(
|
| 319 |
+
f"""<div class="msg-preview" style="border-left-color:{border_color};">
|
| 320 |
+
<p class="msg-header-text">{header}</p>
|
| 321 |
+
<p class="msg-body-text">{body}</p>
|
| 322 |
+
</div>""",
|
| 323 |
+
unsafe_allow_html=True,
|
| 324 |
+
)
|
| 325 |
+
st.caption(f"Header: {len(header)} chars ยท Body: {len(body)} chars")
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
# ---------------------------------------------------------------------------
|
| 329 |
+
# Message cards โ one card per user
|
| 330 |
+
# ---------------------------------------------------------------------------
|
| 331 |
+
|
| 332 |
+
col_labels = ["1st message (oldest)", "2nd message", "3rd message (latest)"]
|
| 333 |
+
|
| 334 |
+
for user_id in page_user_ids:
|
| 335 |
+
user_rows = (
|
| 336 |
+
page_df[page_df["USER_ID"] == user_id]
|
| 337 |
+
.sort_values("MSG_RANK")
|
| 338 |
+
.reset_index(drop=True)
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
# All data for shared sections comes from the oldest (rank-1) row
|
| 342 |
+
base = user_rows.iloc[0]
|
| 343 |
+
|
| 344 |
+
brand_name = str(_val(base.get("DETECTED_BRAND")) or "").lower()
|
| 345 |
+
campaign = str(_val(base.get("CAMPAIGN_NAME")) or "")
|
| 346 |
+
msg_ts = _val(base.get("TIMESTAMP"))
|
| 347 |
+
first_name = str(_val(base.get("FIRST_NAME")) or "")
|
| 348 |
+
daily_streak = _val(base.get("CURRENT_DAILY_STREAK_LENGTH"))
|
| 349 |
+
weekly_streak= _val(base.get("CURRENT_WEEKLY_STREAK_LENGTH"))
|
| 350 |
+
user_profile = str(_val(base.get("USER_PROFILE")) or "")
|
| 351 |
+
prev_msgs_raw = _val(base.get("PREVIOUS_MESSAGES"))
|
| 352 |
+
last_int = str(_val(base.get("LAST_INTERACTED_CONTENT_PROFILE")) or "")
|
| 353 |
+
user_msg_count = int(_val(base.get("USER_MSG_COUNT")) or 1)
|
| 354 |
+
|
| 355 |
+
card_brand = get_brand(brand_name)
|
| 356 |
+
border_color = card_brand["color"]
|
| 357 |
+
existing_rej = fm.get_rejection(user_id)
|
| 358 |
+
|
| 359 |
+
ts_str = str(msg_ts)[:16] if msg_ts else "โ"
|
| 360 |
+
|
| 361 |
+
# Badge strip
|
| 362 |
+
st.markdown(
|
| 363 |
+
f"""<div style="border-left:5px solid {border_color};border-radius:4px;
|
| 364 |
+
background:{card_brand['light']};padding:3px 12px;margin-bottom:4px;
|
| 365 |
+
display:flex;justify-content:space-between;align-items:center;">
|
| 366 |
+
<span>
|
| 367 |
+
<span style="font-size:13px;font-weight:700;color:{border_color};">
|
| 368 |
+
{card_brand['emoji']} {card_brand['label'].upper()}
|
| 369 |
+
</span>
|
| 370 |
+
|
| 371 |
+
<span style="font-size:12px;color:#666;">{get_campaign_label(campaign)}</span>
|
| 372 |
+
ยท
|
| 373 |
+
<span style="font-size:12px;color:#888;">User #{user_id}</span>
|
| 374 |
+
ยท
|
| 375 |
+
<span style="font-size:12px;color:#aaa;">{user_msg_count} message(s) in sequence</span>
|
| 376 |
+
</span>
|
| 377 |
+
<span style="font-size:12px;color:#999;">{ts_str}</span>
|
| 378 |
+
</div>""",
|
| 379 |
+
unsafe_allow_html=True,
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
with st.container(border=True):
|
| 383 |
+
|
| 384 |
+
# โโ Notification columns โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 385 |
+
n_cols = min(view_mode, user_msg_count)
|
| 386 |
+
notif_cols = st.columns(n_cols)
|
| 387 |
+
|
| 388 |
+
for col_idx, col in enumerate(notif_cols):
|
| 389 |
+
row = user_rows.iloc[col_idx]
|
| 390 |
+
header = str(_val(row.get("MESSAGE_HEADER")) or "")
|
| 391 |
+
body = str(_val(row.get("MESSAGE_BODY")) or "")
|
| 392 |
+
row_rec = _val(row.get("RECOMMENDATION"))
|
| 393 |
+
with col:
|
| 394 |
+
_notification_box(header, body, border_color, col_labels[col_idx])
|
| 395 |
+
_render_recommendation(row_rec)
|
| 396 |
+
|
| 397 |
+
st.markdown("---")
|
| 398 |
+
|
| 399 |
+
# โโ Shared context + feedback โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 400 |
+
ctx_col, fb_col = st.columns([2, 1])
|
| 401 |
+
|
| 402 |
+
with ctx_col:
|
| 403 |
+
# User Context
|
| 404 |
+
with st.expander("๐ค User Context"):
|
| 405 |
+
has_ctx = False
|
| 406 |
+
if first_name:
|
| 407 |
+
st.markdown(f"**First name:** {first_name}")
|
| 408 |
+
has_ctx = True
|
| 409 |
+
if daily_streak is not None:
|
| 410 |
+
st.markdown(f"**Current daily streak:** {int(daily_streak)} days")
|
| 411 |
+
has_ctx = True
|
| 412 |
+
if weekly_streak is not None:
|
| 413 |
+
st.markdown(f"**Current weekly streak:** {int(weekly_streak)} weeks")
|
| 414 |
+
has_ctx = True
|
| 415 |
+
if user_profile:
|
| 416 |
+
st.markdown("**User profile:**")
|
| 417 |
+
st.text(user_profile)
|
| 418 |
+
has_ctx = True
|
| 419 |
+
if not has_ctx:
|
| 420 |
+
st.caption("No user context stored for this message.")
|
| 421 |
+
|
| 422 |
+
# Previous Messages (snapshot stored at generation time)
|
| 423 |
+
with st.expander("๐ Previous Messages (at generation time)"):
|
| 424 |
+
prev_msgs = _parse_previous_messages(prev_msgs_raw)
|
| 425 |
+
if not prev_msgs:
|
| 426 |
+
st.caption("No previous messages recorded.")
|
| 427 |
+
else:
|
| 428 |
+
for pm in prev_msgs:
|
| 429 |
+
date_label = pm.get("date", "")
|
| 430 |
+
pm_header = pm.get("header", pm.get("text", ""))
|
| 431 |
+
pm_body = pm.get("message", pm.get("body", ""))
|
| 432 |
+
if date_label:
|
| 433 |
+
st.markdown(f"**{date_label}**")
|
| 434 |
+
if pm_header:
|
| 435 |
+
st.markdown(f"> **{pm_header}**")
|
| 436 |
+
if pm_body:
|
| 437 |
+
st.markdown(f"> {pm_body}")
|
| 438 |
+
st.markdown("")
|
| 439 |
+
|
| 440 |
+
if last_int:
|
| 441 |
+
with st.expander("๐ต Last Interacted Content Profile"):
|
| 442 |
+
st.text(last_int)
|
| 443 |
+
|
| 444 |
+
# โโ Feedback โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 445 |
+
with fb_col:
|
| 446 |
+
st.markdown('<p class="section-label">Feedback</p>', unsafe_allow_html=True)
|
| 447 |
+
st.caption("No action = sequence is good.")
|
| 448 |
+
|
| 449 |
+
if existing_rej:
|
| 450 |
+
reason_label = fm.get_rejection_label(
|
| 451 |
+
existing_rej.get("rejection_reason") or "other"
|
| 452 |
+
)
|
| 453 |
+
st.error(f"โ Rejected\n\n_{reason_label}_")
|
| 454 |
+
if existing_rej.get("rejection_text"):
|
| 455 |
+
st.caption(existing_rej["rejection_text"])
|
| 456 |
+
|
| 457 |
+
if not st.session_state.get(f"rejecting_{user_id}"):
|
| 458 |
+
btn_label = "โ Reject" if not existing_rej else "โ๏ธ Change Reason"
|
| 459 |
+
if st.button(btn_label, key=f"open_{user_id}", use_container_width=True):
|
| 460 |
+
st.session_state[f"rejecting_{user_id}"] = True
|
| 461 |
+
st.rerun()
|
| 462 |
+
|
| 463 |
+
if st.session_state.get(f"rejecting_{user_id}"):
|
| 464 |
+
with st.form(key=f"form_{user_id}"):
|
| 465 |
+
reason_keys = list(fm.REJECTION_REASONS.keys())
|
| 466 |
+
reason_labels = [fm.get_rejection_label(k) for k in reason_keys]
|
| 467 |
+
|
| 468 |
+
default_idx = 0
|
| 469 |
+
if existing_rej and existing_rej.get("rejection_reason") in reason_keys:
|
| 470 |
+
default_idx = reason_keys.index(existing_rej["rejection_reason"])
|
| 471 |
+
|
| 472 |
+
sel_reason = st.selectbox(
|
| 473 |
+
"Reason",
|
| 474 |
+
options=range(len(reason_keys)),
|
| 475 |
+
format_func=lambda i: reason_labels[i],
|
| 476 |
+
index=default_idx,
|
| 477 |
+
)
|
| 478 |
+
extra_text = st.text_input(
|
| 479 |
+
"Additional notes (optional)",
|
| 480 |
+
value=existing_rej.get("rejection_text") or "" if existing_rej else "",
|
| 481 |
+
)
|
| 482 |
+
c1, c2 = st.columns(2)
|
| 483 |
+
with c1:
|
| 484 |
+
submit_rej = st.form_submit_button("Submit", use_container_width=True)
|
| 485 |
+
with c2:
|
| 486 |
+
cancel_rej = st.form_submit_button("Cancel", use_container_width=True)
|
| 487 |
+
|
| 488 |
+
if submit_rej:
|
| 489 |
+
fm.set_rejection(
|
| 490 |
+
user_id=user_id,
|
| 491 |
+
rejection_reason=reason_keys[sel_reason],
|
| 492 |
+
rejection_text=extra_text.strip() or None,
|
| 493 |
+
campaign_name=campaign,
|
| 494 |
+
brand=brand_name,
|
| 495 |
+
message_header=str(_val(user_rows.iloc[0].get("MESSAGE_HEADER")) or ""),
|
| 496 |
+
message_body=str(_val(user_rows.iloc[0].get("MESSAGE_BODY")) or ""),
|
| 497 |
+
message_timestamp=msg_ts,
|
| 498 |
+
reviewer_email=user_email,
|
| 499 |
+
)
|
| 500 |
+
st.session_state.pop(f"rejecting_{user_id}", None)
|
| 501 |
+
st.rerun()
|
| 502 |
+
|
| 503 |
+
if cancel_rej:
|
| 504 |
+
st.session_state.pop(f"rejecting_{user_id}", None)
|
| 505 |
+
st.rerun()
|
| 506 |
+
|
| 507 |
+
if existing_rej and not st.session_state.get(f"rejecting_{user_id}"):
|
| 508 |
+
if st.button("Clear rejection", key=f"clear_{user_id}", use_container_width=True):
|
| 509 |
+
fm.clear_rejection(user_id)
|
| 510 |
+
st.rerun()
|
| 511 |
+
|
| 512 |
+
st.markdown("")
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
# ---------------------------------------------------------------------------
|
| 516 |
+
# Bottom pagination
|
| 517 |
+
# ---------------------------------------------------------------------------
|
| 518 |
+
|
| 519 |
+
st.markdown("---")
|
| 520 |
+
bp1, bp2, bp3 = st.columns([1, 3, 1])
|
| 521 |
+
with bp1:
|
| 522 |
+
if st.button("โ Prev", disabled=page == 0, key="prev_bot"):
|
| 523 |
+
st.session_state["mv_page"] = page - 1
|
| 524 |
+
st.rerun()
|
| 525 |
+
with bp2:
|
| 526 |
+
st.markdown(
|
| 527 |
+
f"<p style='text-align:center;color:#666'>Page {page + 1} of {total_pages}</p>",
|
| 528 |
+
unsafe_allow_html=True,
|
| 529 |
+
)
|
| 530 |
+
with bp3:
|
| 531 |
+
if st.button("Next โ", disabled=page >= total_pages - 1, key="next_bot"):
|
| 532 |
+
st.session_state["mv_page"] = page + 1
|
| 533 |
+
st.rerun()
|
visualization/requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Messaging System v3 - Visualization Tool Requirements
|
| 2 |
+
|
| 3 |
+
# Core
|
| 4 |
+
streamlit>=1.50.0
|
| 5 |
+
pandas>=2.2.0
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
|
| 8 |
+
# Snowflake (connector is bundled with snowpark; either works)
|
| 9 |
+
snowflake-snowpark-python==1.19.0
|
| 10 |
+
snowflake-connector-python==3.17.3
|
visualization/utils/__init__.py
ADDED
|
File without changes
|
visualization/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"]
|
visualization/utils/feedback_manager.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Feedback manager for the v3 visualization tool.
|
| 3 |
+
|
| 4 |
+
Reviewers only submit rejections โ no action means the message is good.
|
| 5 |
+
Keeps an in-session dict as the source of truth and persists every change
|
| 6 |
+
immediately to Snowflake via snowflake_client.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import Optional
|
| 11 |
+
import streamlit as st
|
| 12 |
+
|
| 13 |
+
from utils import snowflake_client as sf
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Rejection reason catalogue
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
REJECTION_REASONS = {
|
| 20 |
+
"poor_header": "Poor Header",
|
| 21 |
+
"poor_body": "Poor Body / Content",
|
| 22 |
+
"grammar_issues": "Grammar Issues",
|
| 23 |
+
"emoji_problems": "Emoji Problems",
|
| 24 |
+
"recommendation_issues": "Recommendation Issues",
|
| 25 |
+
"wrong_information": "Wrong / Inaccurate Information",
|
| 26 |
+
"tone_issues": "Tone Issues",
|
| 27 |
+
"similarity": "Similar To Previous Messages",
|
| 28 |
+
"other": "Other",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_rejection_labels() -> dict:
|
| 33 |
+
return REJECTION_REASONS.copy()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_rejection_label(key: str) -> str:
|
| 37 |
+
return REJECTION_REASONS.get(key, key)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# Session-state helpers
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
|
| 44 |
+
_SESSION_KEY = "v3_rejections" # dict keyed by user_id (int)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _fb_dict() -> dict:
|
| 48 |
+
if _SESSION_KEY not in st.session_state:
|
| 49 |
+
st.session_state[_SESSION_KEY] = {}
|
| 50 |
+
return st.session_state[_SESSION_KEY]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _batch_id() -> str:
|
| 54 |
+
"""Return (or create) a stable batch ID for this review session."""
|
| 55 |
+
if "review_batch_id" not in st.session_state:
|
| 56 |
+
import uuid
|
| 57 |
+
st.session_state["review_batch_id"] = str(uuid.uuid4())
|
| 58 |
+
return st.session_state["review_batch_id"]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
# Public API
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
def set_rejection(
|
| 66 |
+
user_id: int,
|
| 67 |
+
rejection_reason: str,
|
| 68 |
+
rejection_text: Optional[str],
|
| 69 |
+
campaign_name: str,
|
| 70 |
+
brand: str,
|
| 71 |
+
message_header: str,
|
| 72 |
+
message_body: str,
|
| 73 |
+
message_timestamp,
|
| 74 |
+
reviewer_email: str,
|
| 75 |
+
) -> bool:
|
| 76 |
+
"""
|
| 77 |
+
Record a rejection for a single message.
|
| 78 |
+
Updates session state and persists to Snowflake immediately.
|
| 79 |
+
"""
|
| 80 |
+
record = {
|
| 81 |
+
"rejection_reason": rejection_reason,
|
| 82 |
+
"rejection_text": rejection_text,
|
| 83 |
+
"campaign_name": campaign_name,
|
| 84 |
+
"brand": brand,
|
| 85 |
+
"message_header": message_header,
|
| 86 |
+
"message_body": message_body,
|
| 87 |
+
"message_timestamp": message_timestamp,
|
| 88 |
+
"reviewer_email": reviewer_email,
|
| 89 |
+
"updated_at": datetime.now(),
|
| 90 |
+
}
|
| 91 |
+
_fb_dict()[int(user_id)] = record
|
| 92 |
+
|
| 93 |
+
return sf.save_feedback(
|
| 94 |
+
batch_id=_batch_id(),
|
| 95 |
+
user_id=user_id,
|
| 96 |
+
campaign_name=campaign_name,
|
| 97 |
+
brand=brand,
|
| 98 |
+
rejection_reason=rejection_reason,
|
| 99 |
+
rejection_text=rejection_text,
|
| 100 |
+
message_header=message_header,
|
| 101 |
+
message_body=message_body,
|
| 102 |
+
message_timestamp=message_timestamp,
|
| 103 |
+
reviewer_email=reviewer_email,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def clear_rejection(user_id: int) -> bool:
|
| 108 |
+
"""Remove a rejection (reviewer un-rated)."""
|
| 109 |
+
_fb_dict().pop(int(user_id), None)
|
| 110 |
+
return sf.delete_feedback(_batch_id(), user_id)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def get_rejection(user_id: int) -> Optional[dict]:
|
| 114 |
+
"""Return in-session rejection record for a user, or None."""
|
| 115 |
+
return _fb_dict().get(int(user_id))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def get_stats(total_messages: int) -> dict:
|
| 119 |
+
"""Aggregate rejection stats for the current session."""
|
| 120 |
+
fb = _fb_dict()
|
| 121 |
+
total_rejected = len(fb)
|
| 122 |
+
|
| 123 |
+
reason_counts: dict = {}
|
| 124 |
+
for v in fb.values():
|
| 125 |
+
key = v.get("rejection_reason") or "other"
|
| 126 |
+
label = get_rejection_label(key)
|
| 127 |
+
reason_counts[label] = reason_counts.get(label, 0) + 1
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"total_messages": total_messages,
|
| 131 |
+
"total_rejected": total_rejected,
|
| 132 |
+
"reject_rate": round(total_rejected / total_messages * 100, 1) if total_messages else 0.0,
|
| 133 |
+
"rejection_reasons": reason_counts,
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def reset_session():
|
| 138 |
+
"""Clear all in-session rejections and start a new batch ID."""
|
| 139 |
+
st.session_state.pop(_SESSION_KEY, None)
|
| 140 |
+
st.session_state.pop("review_batch_id", None)
|
visualization/utils/snowflake_client.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Snowflake client for the AI Messaging System Visualization Tool.
|
| 3 |
+
|
| 4 |
+
All message data โ including brand, streaks, user profile, and previous
|
| 5 |
+
messages โ is read directly from DAILY_PUSH_MESSAGES. No joins needed.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
import streamlit as st
|
| 11 |
+
import pandas as pd
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
|
| 17 |
+
_env_path = Path(__file__).parent.parent / ".env"
|
| 18 |
+
if _env_path.exists():
|
| 19 |
+
load_dotenv(_env_path)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Timestamp helper
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
def _to_ts_str(ts) -> Optional[str]:
|
| 27 |
+
"""Convert any timestamp-like value to a string Snowflake can bind."""
|
| 28 |
+
if ts is None:
|
| 29 |
+
return None
|
| 30 |
+
if hasattr(ts, "strftime"):
|
| 31 |
+
return ts.strftime("%Y-%m-%d %H:%M:%S")
|
| 32 |
+
return str(ts)[:19]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Connection
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
def _get_connection():
|
| 40 |
+
import snowflake.connector
|
| 41 |
+
|
| 42 |
+
conn = st.session_state.get("_sf_conn")
|
| 43 |
+
if conn is not None:
|
| 44 |
+
try:
|
| 45 |
+
conn.cursor().execute("SELECT 1")
|
| 46 |
+
return conn
|
| 47 |
+
except Exception:
|
| 48 |
+
pass
|
| 49 |
+
|
| 50 |
+
conn = snowflake.connector.connect(
|
| 51 |
+
user=os.getenv("SNOWFLAKE_USER"),
|
| 52 |
+
password=os.getenv("SNOWFLAKE_PASSWORD"),
|
| 53 |
+
account=os.getenv("SNOWFLAKE_ACCOUNT"),
|
| 54 |
+
role=os.getenv("SNOWFLAKE_ROLE", "ACCOUNTADMIN"),
|
| 55 |
+
warehouse=os.getenv("SNOWFLAKE_WAREHOUSE", "COMPUTE_WH"),
|
| 56 |
+
database=os.getenv("SNOWFLAKE_DATABASE", "RECSYS_V3"),
|
| 57 |
+
schema=os.getenv("SNOWFLAKE_SCHEMA", "PUBLIC"),
|
| 58 |
+
)
|
| 59 |
+
st.session_state["_sf_conn"] = conn
|
| 60 |
+
return conn
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _run_query(sql: str, params: Optional[tuple] = None) -> pd.DataFrame:
|
| 64 |
+
conn = _get_connection()
|
| 65 |
+
cur = conn.cursor()
|
| 66 |
+
cur.execute(sql, params) if params else cur.execute(sql)
|
| 67 |
+
cols = [d[0].upper() for d in cur.description]
|
| 68 |
+
return pd.DataFrame(cur.fetchall(), columns=cols)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _run_dml(sql: str, params: Optional[tuple] = None) -> None:
|
| 72 |
+
conn = _get_connection()
|
| 73 |
+
cur = conn.cursor()
|
| 74 |
+
cur.execute(sql, params) if params else cur.execute(sql)
|
| 75 |
+
conn.commit()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# Schema bootstrap
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
|
| 82 |
+
CREATE_FEEDBACK_TABLE = """
|
| 83 |
+
CREATE TABLE IF NOT EXISTS MESSAGING_SYSTEM_V2.UI.V3_FEEDBACKS (
|
| 84 |
+
BATCH_ID VARCHAR,
|
| 85 |
+
USER_ID INTEGER,
|
| 86 |
+
CAMPAIGN_NAME VARCHAR,
|
| 87 |
+
BRAND VARCHAR,
|
| 88 |
+
REJECTION_REASON VARCHAR,
|
| 89 |
+
REJECTION_TEXT VARCHAR,
|
| 90 |
+
MESSAGE_HEADER VARCHAR,
|
| 91 |
+
MESSAGE_BODY VARCHAR,
|
| 92 |
+
MESSAGE_TIMESTAMP TIMESTAMP,
|
| 93 |
+
REVIEWER_EMAIL VARCHAR,
|
| 94 |
+
TIMESTAMP TIMESTAMP DEFAULT CURRENT_TIMESTAMP()
|
| 95 |
+
)
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def ensure_feedback_table():
|
| 100 |
+
try:
|
| 101 |
+
_run_dml(CREATE_FEEDBACK_TABLE)
|
| 102 |
+
except Exception as e:
|
| 103 |
+
st.warning(f"Could not verify feedback table: {e}")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
# Pre-fetch: all campaigns, top N per campaign
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
def fetch_all_messages() -> pd.DataFrame:
|
| 111 |
+
"""
|
| 112 |
+
Fetch all messages from DAILY_PUSH_MESSAGES โ no joins, no per-campaign cap.
|
| 113 |
+
|
| 114 |
+
Each user has up to 3 rows (messages generated on different days).
|
| 115 |
+
Rows are ordered oldestโnewest per user so the UI can display the
|
| 116 |
+
chronological sequence in columns.
|
| 117 |
+
|
| 118 |
+
Adds two window-function columns:
|
| 119 |
+
- MSG_RANK: 1 = oldest, 2 = middle, 3 = newest (per user)
|
| 120 |
+
- USER_MSG_COUNT: total messages this user has (1โ3)
|
| 121 |
+
|
| 122 |
+
Excludes users who already have any rejection in V3_FEEDBACKS.
|
| 123 |
+
|
| 124 |
+
Returns a DataFrame with columns:
|
| 125 |
+
USER_ID, DETECTED_BRAND, CAMPAIGN_NAME, BRANCH, PLATFORM,
|
| 126 |
+
TIMESTAMP, MESSAGE_HEADER, MESSAGE_BODY, RECOMMENDATION,
|
| 127 |
+
RECOMMENDED_CONTENT_ID, FIRST_NAME,
|
| 128 |
+
CURRENT_DAILY_STREAK_LENGTH, CURRENT_WEEKLY_STREAK_LENGTH,
|
| 129 |
+
USER_PROFILE, PREVIOUS_MESSAGES, LAST_INTERACTED_CONTENT_PROFILE,
|
| 130 |
+
MSG_RANK, USER_MSG_COUNT
|
| 131 |
+
"""
|
| 132 |
+
sql = """
|
| 133 |
+
WITH excluded_users AS (
|
| 134 |
+
SELECT DISTINCT USER_ID
|
| 135 |
+
FROM MESSAGING_SYSTEM_V2.UI.V3_FEEDBACKS
|
| 136 |
+
),
|
| 137 |
+
ranked AS (
|
| 138 |
+
SELECT
|
| 139 |
+
m.USER_ID,
|
| 140 |
+
LOWER(m.DETECTED_BRAND) AS DETECTED_BRAND,
|
| 141 |
+
m.BRANCH,
|
| 142 |
+
m.MESSAGE,
|
| 143 |
+
m.PLATFORM,
|
| 144 |
+
m.CAMPAIGN_NAME,
|
| 145 |
+
m.TIMESTAMP,
|
| 146 |
+
m.RECOMMENDATION,
|
| 147 |
+
m.RECOMMENDED_CONTENT_ID,
|
| 148 |
+
m.FIRST_NAME,
|
| 149 |
+
m.CURRENT_DAILY_STREAK_LENGTH,
|
| 150 |
+
m.CURRENT_WEEKLY_STREAK_LENGTH,
|
| 151 |
+
m.USER_PROFILE,
|
| 152 |
+
m.PREVIOUS_MESSAGES,
|
| 153 |
+
m.LAST_INTERACTED_CONTENT_PROFILE,
|
| 154 |
+
ROW_NUMBER() OVER (
|
| 155 |
+
PARTITION BY m.USER_ID
|
| 156 |
+
ORDER BY m.TIMESTAMP ASC
|
| 157 |
+
) AS MSG_RANK,
|
| 158 |
+
COUNT(*) OVER (PARTITION BY m.USER_ID) AS USER_MSG_COUNT
|
| 159 |
+
FROM MESSAGING_SYSTEM_V2.GENERATED_DATA.DAILY_PUSH_MESSAGES m
|
| 160 |
+
WHERE m.USER_ID NOT IN (SELECT USER_ID FROM excluded_users)
|
| 161 |
+
)
|
| 162 |
+
SELECT
|
| 163 |
+
USER_ID, DETECTED_BRAND, BRANCH, MESSAGE, PLATFORM, CAMPAIGN_NAME,
|
| 164 |
+
TIMESTAMP, RECOMMENDATION, RECOMMENDED_CONTENT_ID, FIRST_NAME,
|
| 165 |
+
CURRENT_DAILY_STREAK_LENGTH, CURRENT_WEEKLY_STREAK_LENGTH,
|
| 166 |
+
USER_PROFILE, PREVIOUS_MESSAGES, LAST_INTERACTED_CONTENT_PROFILE,
|
| 167 |
+
MSG_RANK, USER_MSG_COUNT
|
| 168 |
+
FROM ranked
|
| 169 |
+
WHERE MSG_RANK <= 3
|
| 170 |
+
ORDER BY USER_ID, MSG_RANK
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
df = _run_query(sql)
|
| 174 |
+
if df.empty:
|
| 175 |
+
return df
|
| 176 |
+
|
| 177 |
+
df["MESSAGE_HEADER"] = df["MESSAGE"].apply(_parse_header)
|
| 178 |
+
df["MESSAGE_BODY"] = df["MESSAGE"].apply(_parse_body)
|
| 179 |
+
df.drop(columns=["MESSAGE"], inplace=True)
|
| 180 |
+
|
| 181 |
+
return df
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
# JSON message parsing
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
|
| 188 |
+
def _parse_json_message(raw) -> dict:
|
| 189 |
+
if raw is None:
|
| 190 |
+
return {}
|
| 191 |
+
if isinstance(raw, dict):
|
| 192 |
+
return raw
|
| 193 |
+
try:
|
| 194 |
+
return json.loads(str(raw))
|
| 195 |
+
except Exception:
|
| 196 |
+
return {}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _parse_header(raw) -> str:
|
| 200 |
+
return _parse_json_message(raw).get("header", "")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _parse_body(raw) -> str:
|
| 204 |
+
msg = _parse_json_message(raw)
|
| 205 |
+
return msg.get("message", msg.get("body", ""))
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ---------------------------------------------------------------------------
|
| 209 |
+
# Feedback storage
|
| 210 |
+
# ---------------------------------------------------------------------------
|
| 211 |
+
|
| 212 |
+
def save_feedback(
|
| 213 |
+
batch_id: str,
|
| 214 |
+
user_id: int,
|
| 215 |
+
campaign_name: str,
|
| 216 |
+
brand: str,
|
| 217 |
+
rejection_reason: Optional[str],
|
| 218 |
+
rejection_text: Optional[str],
|
| 219 |
+
message_header: str,
|
| 220 |
+
message_body: str,
|
| 221 |
+
message_timestamp,
|
| 222 |
+
reviewer_email: str,
|
| 223 |
+
) -> bool:
|
| 224 |
+
msg_ts_str = _to_ts_str(message_timestamp)
|
| 225 |
+
|
| 226 |
+
merge_sql = """
|
| 227 |
+
MERGE INTO MESSAGING_SYSTEM_V2.UI.V3_FEEDBACKS AS tgt
|
| 228 |
+
USING (
|
| 229 |
+
SELECT
|
| 230 |
+
%s AS BATCH_ID,
|
| 231 |
+
%s AS USER_ID,
|
| 232 |
+
%s AS CAMPAIGN_NAME,
|
| 233 |
+
%s AS BRAND,
|
| 234 |
+
%s AS REJECTION_REASON,
|
| 235 |
+
%s AS REJECTION_TEXT,
|
| 236 |
+
%s AS MESSAGE_HEADER,
|
| 237 |
+
%s AS MESSAGE_BODY,
|
| 238 |
+
TO_TIMESTAMP_NTZ(%s) AS MESSAGE_TIMESTAMP,
|
| 239 |
+
%s AS REVIEWER_EMAIL
|
| 240 |
+
) AS src
|
| 241 |
+
ON tgt.BATCH_ID = src.BATCH_ID AND tgt.USER_ID = src.USER_ID
|
| 242 |
+
WHEN MATCHED THEN UPDATE SET
|
| 243 |
+
REJECTION_REASON = src.REJECTION_REASON,
|
| 244 |
+
REJECTION_TEXT = src.REJECTION_TEXT,
|
| 245 |
+
TIMESTAMP = CURRENT_TIMESTAMP()
|
| 246 |
+
WHEN NOT MATCHED THEN INSERT (
|
| 247 |
+
BATCH_ID, USER_ID, CAMPAIGN_NAME, BRAND,
|
| 248 |
+
REJECTION_REASON, REJECTION_TEXT,
|
| 249 |
+
MESSAGE_HEADER, MESSAGE_BODY, MESSAGE_TIMESTAMP, REVIEWER_EMAIL
|
| 250 |
+
) VALUES (
|
| 251 |
+
src.BATCH_ID, src.USER_ID, src.CAMPAIGN_NAME, src.BRAND,
|
| 252 |
+
src.REJECTION_REASON, src.REJECTION_TEXT,
|
| 253 |
+
src.MESSAGE_HEADER, src.MESSAGE_BODY, src.MESSAGE_TIMESTAMP, src.REVIEWER_EMAIL
|
| 254 |
+
)
|
| 255 |
+
"""
|
| 256 |
+
try:
|
| 257 |
+
_run_dml(merge_sql, (
|
| 258 |
+
batch_id, int(user_id), campaign_name, brand,
|
| 259 |
+
rejection_reason, rejection_text,
|
| 260 |
+
message_header, message_body,
|
| 261 |
+
msg_ts_str, reviewer_email,
|
| 262 |
+
))
|
| 263 |
+
return True
|
| 264 |
+
except Exception as e:
|
| 265 |
+
st.error(f"Failed to save feedback: {e}")
|
| 266 |
+
return False
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def delete_feedback(batch_id: str, user_id: int) -> bool:
|
| 270 |
+
sql = """
|
| 271 |
+
DELETE FROM MESSAGING_SYSTEM_V2.UI.V3_FEEDBACKS
|
| 272 |
+
WHERE BATCH_ID = %s AND USER_ID = %s
|
| 273 |
+
"""
|
| 274 |
+
try:
|
| 275 |
+
_run_dml(sql, (batch_id, int(user_id)))
|
| 276 |
+
return True
|
| 277 |
+
except Exception as e:
|
| 278 |
+
st.error(f"Failed to delete feedback: {e}")
|
| 279 |
+
return False
|
visualization/utils/theme.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Brand theming for the AI Messaging System Visualization Tool.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
BRAND_CONFIG = {
|
| 8 |
+
"drumeo": {
|
| 9 |
+
"color": "#E84545",
|
| 10 |
+
"light": "#FDEAEA",
|
| 11 |
+
"emoji": "๐ฅ",
|
| 12 |
+
"label": "Drumeo",
|
| 13 |
+
},
|
| 14 |
+
"pianote": {
|
| 15 |
+
"color": "#4A90D9",
|
| 16 |
+
"light": "#EAF3FC",
|
| 17 |
+
"emoji": "๐น",
|
| 18 |
+
"label": "Pianote",
|
| 19 |
+
},
|
| 20 |
+
"guitareo": {
|
| 21 |
+
"color": "#F5A623",
|
| 22 |
+
"light": "#FEF6E7",
|
| 23 |
+
"emoji": "๐ธ",
|
| 24 |
+
"label": "Guitareo",
|
| 25 |
+
},
|
| 26 |
+
"singeo": {
|
| 27 |
+
"color": "#7B68EE",
|
| 28 |
+
"light": "#F0EEFF",
|
| 29 |
+
"emoji": "๐ค",
|
| 30 |
+
"label": "Singeo",
|
| 31 |
+
},
|
| 32 |
+
"playbass": {
|
| 33 |
+
"color": "#3DAA5C",
|
| 34 |
+
"light": "#E8F7ED",
|
| 35 |
+
"emoji": "๐ธ",
|
| 36 |
+
"label": "Playbass",
|
| 37 |
+
},
|
| 38 |
+
"all": {
|
| 39 |
+
"color": "#555555",
|
| 40 |
+
"light": "#F5F5F5",
|
| 41 |
+
"emoji": "๐ต",
|
| 42 |
+
"label": "All Brands",
|
| 43 |
+
},
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
CAMPAIGN_LABELS = {
|
| 47 |
+
"dailyPush_dailyStreak": "Daily Streak",
|
| 48 |
+
"dailyPush_weeklyStreak": "Weekly Streak",
|
| 49 |
+
"dailyPush_noStreak": "No Streak (Re-engagement)",
|
| 50 |
+
"all": "All Campaigns",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
ALL_BRANDS = ["all", "drumeo", "pianote", "guitareo", "singeo", "playbass"]
|
| 54 |
+
ALL_CAMPAIGNS = [
|
| 55 |
+
"all",
|
| 56 |
+
"dailyPush_dailyStreak",
|
| 57 |
+
"dailyPush_weeklyStreak",
|
| 58 |
+
"dailyPush_noStreak",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
# Scenarios per campaign, applied client-side after data load.
|
| 62 |
+
# Detection uses the RECOMMENDATION column in DAILY_PUSH_MESSAGES.
|
| 63 |
+
# For weeklyStreak: RECOMMENDATION == 'for_you' โ no_practice_this_week
|
| 64 |
+
CAMPAIGN_SCENARIOS = {
|
| 65 |
+
"all": [],
|
| 66 |
+
"dailyPush_dailyStreak": [], # single scenario, no sub-filter needed
|
| 67 |
+
"dailyPush_weeklyStreak": [
|
| 68 |
+
("all", "All"),
|
| 69 |
+
("practiced_this_week", "Practiced This Week"),
|
| 70 |
+
("no_practice_this_week", "No Practice This Week"),
|
| 71 |
+
],
|
| 72 |
+
"dailyPush_noStreak": [],
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_scenarios(campaign: str) -> list:
|
| 77 |
+
"""Return scenario (key, label) pairs for a given campaign."""
|
| 78 |
+
return CAMPAIGN_SCENARIOS.get(campaign, [])
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def detect_scenario(recommendation_value) -> str:
|
| 82 |
+
"""
|
| 83 |
+
Detect the weekly-streak sub-scenario from the RECOMMENDATION column.
|
| 84 |
+
|
| 85 |
+
RECOMMENDATION == 'for_you' โ user hasn't practiced this week
|
| 86 |
+
anything else โ user has practiced this week
|
| 87 |
+
"""
|
| 88 |
+
s = str(recommendation_value or "").strip().lower()
|
| 89 |
+
return "no_practice_this_week" if s == "for_you" else "practiced_this_week"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_brand(brand: str) -> dict:
|
| 93 |
+
return BRAND_CONFIG.get(brand.lower() if brand else "all", BRAND_CONFIG["all"])
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_campaign_label(campaign: str) -> str:
|
| 97 |
+
return CAMPAIGN_LABELS.get(campaign, campaign)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def inject_global_css():
|
| 101 |
+
"""Inject shared CSS into the Streamlit app."""
|
| 102 |
+
st.markdown(
|
| 103 |
+
"""
|
| 104 |
+
<style>
|
| 105 |
+
.msg-card {
|
| 106 |
+
border: 1px solid #e0e0e0;
|
| 107 |
+
border-radius: 12px;
|
| 108 |
+
padding: 20px;
|
| 109 |
+
margin-bottom: 18px;
|
| 110 |
+
background: #ffffff;
|
| 111 |
+
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
| 112 |
+
}
|
| 113 |
+
.msg-preview {
|
| 114 |
+
background: #f4f6f8;
|
| 115 |
+
border-left: 4px solid #aaa;
|
| 116 |
+
border-radius: 6px;
|
| 117 |
+
padding: 12px 16px;
|
| 118 |
+
margin: 10px 0;
|
| 119 |
+
}
|
| 120 |
+
.msg-header-text {
|
| 121 |
+
font-size: 15px;
|
| 122 |
+
font-weight: 700;
|
| 123 |
+
margin: 0 0 5px 0;
|
| 124 |
+
color: #1a1a1a;
|
| 125 |
+
}
|
| 126 |
+
.msg-body-text {
|
| 127 |
+
font-size: 14px;
|
| 128 |
+
color: #444;
|
| 129 |
+
margin: 0;
|
| 130 |
+
}
|
| 131 |
+
.user-meta {
|
| 132 |
+
font-size: 13px;
|
| 133 |
+
color: #555;
|
| 134 |
+
margin: 3px 0;
|
| 135 |
+
}
|
| 136 |
+
.streak-badge {
|
| 137 |
+
display: inline-block;
|
| 138 |
+
background: #fff3cd;
|
| 139 |
+
border: 1px solid #ffc107;
|
| 140 |
+
border-radius: 20px;
|
| 141 |
+
padding: 2px 10px;
|
| 142 |
+
font-size: 12px;
|
| 143 |
+
font-weight: 600;
|
| 144 |
+
margin-right: 6px;
|
| 145 |
+
}
|
| 146 |
+
.section-label {
|
| 147 |
+
font-size: 11px;
|
| 148 |
+
font-weight: 700;
|
| 149 |
+
text-transform: uppercase;
|
| 150 |
+
letter-spacing: 0.08em;
|
| 151 |
+
color: #888;
|
| 152 |
+
margin-bottom: 4px;
|
| 153 |
+
}
|
| 154 |
+
</style>
|
| 155 |
+
""",
|
| 156 |
+
unsafe_allow_html=True,
|
| 157 |
+
)
|