Danialebrat commited on
Commit
dcdaf35
ยท
1 Parent(s): 5ae6520

deploying the new UI

Browse files
README.md CHANGED
@@ -4,1127 +4,240 @@ emoji: ๐ŸŽถ
4
  colorFrom: blue
5
  colorTo: gray
6
  sdk: streamlit
7
- sdk_version: 1.46.0
8
  python_version: 3.9
9
  app_file: app.py
10
  ---
11
 
12
- # AI Messaging System - Visualization Tool
13
 
14
- An interactive Streamlit-based visualization and experimentation tool for the AI Messaging System v2. This tool enables non-technical users to generate, visualize, analyze, and improve personalized message campaigns with integrated A/B testing capabilities and comprehensive historical tracking.
15
 
16
- ## ๐ŸŽฏ Purpose
17
 
18
- - Generate personalized messages with various configurations
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
- ## ๐Ÿ—๏ธ Architecture Overview
29
 
30
- ### Core Philosophy: **In-Memory + Cloud Persistence**
31
-
32
- The system operates on a hybrid architecture:
33
- - **In-Memory Operations**: All active experiments run in `session_state` (no local files)
34
- - **Cloud Persistence**: Data stored in Snowflake for long-term analytics
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
- ## ๐Ÿ“ Directory Structure
49
 
50
  ```
51
  visualization/
52
- โ”œโ”€โ”€ app.py # Main entry point with authentication & brand selection
53
- โ”œโ”€โ”€ pages/ # Multi-page Streamlit application
54
- โ”‚ โ”œโ”€โ”€ 1_Campaign_Builder.py # Campaign configuration & generation (with A/B testing)
55
- โ”‚ โ”œโ”€โ”€ 2_Message_Viewer.py # Message browsing & feedback (A/B aware)
56
- โ”‚ โ”œโ”€โ”€ 4_Analytics.py # Performance metrics for CURRENT experiment
57
- โ”‚ โ””โ”€โ”€ 5_Historical_Analytics.py # Historical experiments from Snowflake
58
- โ”œโ”€โ”€ utils/ # Utility modules
59
- โ”‚ โ”œโ”€โ”€ __init__.py
60
- โ”‚ โ”œโ”€โ”€ auth.py # Authentication logic
61
- โ”‚ โ”œโ”€โ”€ config_manager.py # Configuration loading from Snowflake
62
- โ”‚ โ”œโ”€โ”€ db_manager.py # Snowflake database operations (NEW)
63
- โ”‚ โ”œโ”€โ”€ experiment_runner.py # Parallel experiment execution (NEW)
64
- โ”‚ โ”œโ”€โ”€ session_feedback_manager.py # In-memory feedback management (NEW)
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
- ## ๐Ÿš€ Getting Started
131
 
132
- ### Prerequisites
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
- **AB Mode Generation**:
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
- # Store both results
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
- **Thread-Safe Progress Handling**:
374
- - Main thread: Full Streamlit UI updates
375
- - Worker threads: Console logging only
376
- - Dummy progress bars prevent Streamlit errors in threads
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
- **Store Results to Snowflake** (CRITICAL FEATURE):
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
- st.session_state['historical_experiments'] = experiments_df
556
- st.session_state['historical_data_loaded'] = True
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
- ## ๐Ÿ”ง Utility Modules
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
- def store_feedback(self, feedback: dict):
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
- ### utils/config_manager.py - ConfigManager
646
 
647
- **Purpose**: Configuration loading and caching
648
 
649
- **Key Methods**:
650
- ```python
651
- class ConfigManager:
652
- def __init__(self, session: Session):
653
- """Initialize with Snowflake session."""
654
 
655
- def load_configs_from_snowflake(self, brand: str) -> Dict:
656
- """Load all configs for brand, returns {name: config_data}."""
657
 
658
- def get_latest_version(self, config_name: str, brand: str) -> int:
659
- """Get latest version number."""
660
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
 
662
- **Caching Strategy**:
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
- **Key Methods**:
675
- ```python
676
- class ExperimentRunner:
677
- def run_single_experiment(
678
- self, config, sampled_users_df, experiment_id,
679
- create_session_func, progress_container
680
- ):
681
- """Run one experiment, all stages sequentially."""
 
 
 
 
 
682
 
683
- def run_ab_test_parallel(
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
- **Implementation**:
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
- ### utils/session_feedback_manager.py - SessionFeedbackManager
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
- **Feedback List Keys**:
751
- - `"current_feedbacks"` - Single mode
752
- - `"feedbacks_a"` - AB mode experiment A
753
- - `"feedbacks_b"` - AB mode experiment B
754
 
755
- **Usage**:
756
- ```python
757
- # Add feedback
758
- SessionFeedbackManager.add_feedback(
759
- experiment_id="drumeo_20260114_1234",
760
- user_id=12345,
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
- ## ๐ŸŽจ Brand Theming
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
- def apply_theme(brand):
804
- """Applies CSS via st.markdown."""
805
-
806
- def get_brand_emoji(brand):
807
- """Returns brand emoji."""
808
- ```
809
 
810
  ---
811
 
812
- ## ๐Ÿ” Key Implementation Details
813
 
814
- ### Configuration File Structure
815
-
816
- ```json
817
- {
818
- "brand": "drumeo",
819
- "campaign_type": "re_engagement",
820
- "campaign_name": "UI-Test-Campaign",
821
- "campaign_instructions": "Keep messages encouraging and motivational.",
822
- "1": {
823
- "stage": 1,
824
- "model": "gemini-2.5-flash-lite",
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
- ## ๐Ÿ› ๏ธ Development Guide
887
-
888
- ### Adding a New Page
889
 
890
- 1. Create `pages/{number}_{Name}.py`
891
- 2. Follow standard structure:
892
- ```python
893
- import streamlit as st
894
- import os
895
- from pathlib import Path
896
- from dotenv import load_dotenv
897
 
898
- # Load .env
899
- env_path = Path(__file__).parent.parent.parent / '.env'
900
- if env_path.exists():
901
- load_dotenv(env_path)
902
-
903
- from utils.auth import check_authentication
904
- from utils.theme import apply_theme, get_brand_emoji
905
-
906
- st.set_page_config(page_title="New Page", page_icon="๐Ÿ†•", layout="wide")
907
-
908
- if not check_authentication():
909
- st.error("๐Ÿ”’ Please login first")
910
- st.stop()
911
-
912
- if "selected_brand" not in st.session_state:
913
- st.error("โš ๏ธ Please select a brand first")
914
- st.stop()
915
-
916
- brand = st.session_state.selected_brand
917
- apply_theme(brand)
918
-
919
- # Your content here
920
- ```
921
- 3. Update `app.py` navigation
922
- 4. Test with all brands
923
-
924
- ### Adding a New Feedback Category
925
-
926
- 1. Update `utils/session_feedback_manager.py`:
927
- ```python
928
- REJECTION_REASONS = {
929
- # ... existing
930
- "new_reason": "New Reason Label"
931
- }
932
- ```
933
- 2. Automatically appears in UI
934
-
935
- ### Adding a New Brand
936
-
937
- 1. Create `data/UI_users/{brand}_users.csv` (100 users, must have `USER_ID` column)
938
- 2. Add to `utils/theme.py`:
939
- ```python
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
- **Built with โค๏ธ for the Musora team**
1128
 
1129
- **Last Updated**: 2026-01-14
1130
- **Version**: 2.0 (Refactored Architecture)
 
 
 
 
 
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
- Hugging Face Spaces wrapper for AI Messaging System Visualization Tool
3
- This wrapper imports and runs the main app from the visualization folder.
4
  """
5
 
6
  import sys
7
- from pathlib import Path
 
8
 
9
- # Add the visualization directory to Python path
10
- visualization_dir = Path(__file__).parent / "visualization"
11
- sys.path.insert(0, str(visualization_dir))
12
- sys.path.insert(0, str(Path(__file__).parent))
13
 
14
- # Change to visualization directory context for relative imports
15
- import os
16
- os.chdir(visualization_dir)
 
17
 
18
- # Import and run the main app
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 - Combined Requirements for HuggingFace Space
2
- # Merges dependencies from both ai_messaging_system_v2 and visualization
3
 
4
- # Core Dependencies
5
- numpy==1.26.4
6
- streamlit==1.46.0
7
- pandas==2.2.0
8
- python-dotenv==1.0.0
9
 
10
- # Data Visualization
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
+ &nbsp;
371
+ <span style="font-size:12px;color:#666;">{get_campaign_label(campaign)}</span>
372
+ &nbsp;ยท&nbsp;
373
+ <span style="font-size:12px;color:#888;">User #{user_id}</span>
374
+ &nbsp;ยท&nbsp;
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
+ )