PrachiSandipkumar commited on
Commit
279fa27
·
verified ·
1 Parent(s): d79563b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +760 -769
app.py CHANGED
@@ -1,769 +1,760 @@
1
- import streamlit as st
2
- import pandas as pd
3
- import numpy as np
4
- import joblib
5
- import os
6
- from sklearn.ensemble import RandomForestClassifier
7
- from sklearn.calibration import CalibratedClassifierCV
8
- from sklearn.pipeline import Pipeline
9
- from sklearn.compose import ColumnTransformer
10
- from sklearn.preprocessing import StandardScaler, OneHotEncoder
11
- from sklearn.impute import SimpleImputer
12
- from sklearn.model_selection import train_test_split
13
- from sklearn.metrics import (accuracy_score, precision_score, recall_score,
14
- f1_score, roc_auc_score, brier_score_loss,
15
- confusion_matrix, classification_report)
16
- import matplotlib.pyplot as plt
17
- import seaborn as sns
18
- import warnings
19
- warnings.filterwarnings('ignore')
20
-
21
- st.set_page_config(
22
- page_title="💀 Ghosting Predictor",
23
- page_icon="👻",
24
- layout="wide",
25
- initial_sidebar_state="collapsed"
26
- )
27
-
28
- st.markdown("""
29
- <style>
30
- @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800&family=Inter:wght@400;500;600&display=swap');
31
-
32
- html, body, [class*="css"] { font-family: 'Inter', sans-serif; }
33
- h1, h2, h3 { font-family: 'Syne', sans-serif !important; }
34
-
35
- .main-title {
36
- font-family: 'Syne', sans-serif; font-size: 3.2em; font-weight: 800;
37
- background: linear-gradient(135deg, #ff6b6b, #ee5a24, #ff9f43);
38
- -webkit-background-clip: text; -webkit-text-fill-color: transparent;
39
- text-align: center; margin-bottom: 0; letter-spacing: -1px;
40
- }
41
- .sub-title { text-align: center; color: #636e72; font-size: 1em; margin-top: 4px; margin-bottom: 20px; }
42
-
43
- .verdict-card {
44
- border-radius: 20px; padding: 28px 32px; text-align: center;
45
- margin: 16px 0; position: relative; overflow: hidden;
46
- }
47
- .verdict-high { background: linear-gradient(135deg, #00b894, #00cec9); color: white; }
48
- .verdict-mid { background: linear-gradient(135deg, #fdcb6e, #e17055); color: white; }
49
- .verdict-low { background: linear-gradient(135deg, #d63031, #6c5ce7); color: white; }
50
- .verdict-pct { font-family: 'Syne', sans-serif; font-size: 4em; font-weight: 800; line-height: 1; }
51
- .verdict-label { font-size: 1.1em; font-weight: 600; margin-top: 6px; opacity: 0.92; }
52
- .verdict-quote { font-size: 0.95em; margin-top: 14px; font-style: italic; opacity: 0.88;
53
- border-top: 1px solid rgba(255,255,255,0.3); padding-top: 14px; }
54
-
55
- .diag-row { display: flex; gap: 12px; margin: 12px 0; flex-wrap: wrap; }
56
- .diag-card {
57
- flex: 1; min-width: 140px; border-radius: 14px; padding: 16px 18px; text-align: center;
58
- background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
59
- }
60
- .diag-title { font-size: 0.72em; text-transform: uppercase; letter-spacing: 1px; color: #b2bec3; margin-bottom: 6px; }
61
- .diag-val { font-family: 'Syne', sans-serif; font-size: 1.3em; font-weight: 700; }
62
- .val-high { color: #00b894; }
63
- .val-mid { color: #fdcb6e; }
64
- .val-low { color: #ff7675; }
65
-
66
- .flag-item {
67
- display: flex; align-items: flex-start; gap: 10px;
68
- padding: 10px 14px; border-radius: 10px; margin: 6px 0;
69
- background: rgba(214, 48, 49, 0.12); border-left: 3px solid #d63031; font-size: 0.92em;
70
- }
71
- .green-flag { background: rgba(0, 184, 148, 0.12); border-left: 3px solid #00b894; }
72
-
73
- .whatif-row {
74
- display: flex; align-items: center; justify-content: space-between;
75
- padding: 10px 16px; border-radius: 10px; margin: 6px 0;
76
- background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); font-size: 0.9em;
77
- }
78
- .whatif-boost { color: #00b894; font-weight: 600; }
79
- .whatif-drop { color: #ff7675; font-weight: 600; }
80
-
81
- .share-card {
82
- background: linear-gradient(135deg, #1a1a2e, #16213e);
83
- border-radius: 20px; padding: 28px; border: 1px solid rgba(255,255,255,0.1);
84
- text-align: center; font-family: 'Syne', sans-serif;
85
- }
86
- .share-pct {
87
- font-size: 3.5em; font-weight: 800;
88
- background: linear-gradient(135deg, #ff6b6b, #ee5a24);
89
- -webkit-background-clip: text; -webkit-text-fill-color: transparent;
90
- }
91
- .share-line { color: #dfe6e9; margin: 6px 0; font-size: 1em; }
92
- .share-tag { color: #636e72; font-size: 0.78em; margin-top: 12px; }
93
-
94
- .m-card {
95
- background: rgba(255,255,255,0.05); border-radius: 14px; padding: 18px; text-align: center;
96
- border: 1px solid rgba(255,255,255,0.08); margin: 4px;
97
- }
98
- .m-num { font-family: 'Syne', sans-serif; font-size: 1.9em; font-weight: 800; color: #fdcb6e; }
99
- .m-lbl { font-size: 0.78em; color: #b2bec3; text-transform: uppercase; letter-spacing: 1px; }
100
-
101
- .stProgress > div > div { border-radius: 99px; }
102
- .block-container { padding-top: 1.5rem; }
103
- </style>
104
- """, unsafe_allow_html=True)
105
-
106
- # ── Feature config ─────────────────────────────────────────────────────────────
107
- NUM_FEATURES = [
108
- 'last_message_length', 'response_time_gap', 'conversation_length',
109
- 'reply_ratio', 'avg_response_time', 'emoji_count', 'question_asked',
110
- 'seen_ignored', 'past_ghosting_history', 'effort_score', 'delay',
111
- 'is_dry', 'is_long_gap', 'engagement_score', 'ghost_risk_combo',
112
- 'seen_delay', 'initiator_flag', 'inconsistency', 'decay_score', 'effort_mismatch'
113
- ]
114
- CAT_FEATURES = ['initiator', 'message_tone', 'time_of_day', 'user_type']
115
-
116
- def make_preprocessor():
117
- num_t = Pipeline([('imp', SimpleImputer(strategy='median')), ('sc', StandardScaler())])
118
- cat_t = Pipeline([('imp', SimpleImputer(strategy='most_frequent')),
119
- ('ohe', OneHotEncoder(handle_unknown='ignore'))])
120
- return ColumnTransformer([('num', num_t, NUM_FEATURES), ('cat', cat_t, CAT_FEATURES)], remainder='drop')
121
-
122
- # ── Load / train models ────────────────────────────────────────────────────────
123
- @st.cache_resource
124
- def load_models():
125
- rp = 'rf_reply_model.pkl'; gp = 'rf_ghost_model.pkl'; mp = 'model_metrics.pkl'
126
- if os.path.exists(rp) and os.path.exists(gp) and os.path.exists(mp):
127
- try:
128
- return joblib.load(rp), joblib.load(gp), joblib.load(mp)
129
- except Exception as e:
130
- st.warning(f"⚠️ Saved models failed ({str(e)[:60]}). Retraining...")
131
-
132
- with st.spinner("🤖 Training models... (~30 sec)"):
133
- try:
134
- df = pd.read_csv('ghosting_dataset5.csv')
135
- except FileNotFoundError:
136
- st.error("❌ ghosting_dataset5.csv not found. Run gen_data5.py first.")
137
- st.stop()
138
-
139
- df['effort_score'] = df['last_message_length'] + (df['emoji_count'] * 2) + (df['question_asked'] * 5)
140
- df['delay'] = df['response_time_gap'].apply(lambda x: 0 if x < 6 else 1 if x < 24 else 2)
141
- df['is_dry'] = (df['message_tone'] == 'dry').astype(int)
142
- df['is_long_gap'] = (df['response_time_gap'] > 24).astype(int)
143
- df['engagement_score'] = df['reply_ratio'] * df['conversation_length']
144
- df['ghost_risk_combo'] = ((df['response_time_gap'] > 24) & (df['reply_ratio'] < 0.4)).astype(int)
145
- df['seen_delay'] = ((df['seen_ignored'] == 1) & (df['response_time_gap'] > 12)).astype(int)
146
- df['initiator_flag'] = (df['initiator'] == 'me').astype(int)
147
- df['inconsistency'] = (abs(df['response_time_gap'] - df['avg_response_time']) > 20).astype(int)
148
- df['decay_score'] = (df['conversation_length'] / 200).clip(0, 1)
149
- df['effort_mismatch'] = ((df['last_message_length'] > 20) & (df['reply_ratio'] < 0.3)).astype(int)
150
-
151
- def _train(df, target):
152
- X = df[NUM_FEATURES + CAT_FEATURES]; y = df[target]
153
- # ── Proper 3-way split — no leakage ─────────────────────────────
154
- X_tv, X_test, y_tv, y_test = train_test_split(X, y, test_size=0.15, random_state=42, stratify=y)
155
- X_tr, X_val, y_tr, y_val = train_test_split(X_tv, y_tv, test_size=0.15/0.85, random_state=42, stratify=y_tv)
156
- mdl = Pipeline([('pre', make_preprocessor()),
157
- ('clf', RandomForestClassifier(n_estimators=400, max_depth=20,
158
- class_weight='balanced', random_state=42, n_jobs=-1))])
159
- mdl.fit(X_tr, y_tr)
160
- # Calibrate on val only; evaluate on test only
161
- cal = CalibratedClassifierCV(mdl, method='sigmoid', cv=3)
162
- cal.fit(X_val, y_val)
163
- yp = cal.predict(X_test); yproba = cal.predict_proba(X_test)[:, 1]
164
- return cal, {
165
- 'accuracy': accuracy_score(y_test, yp),
166
- 'precision': precision_score(y_test, yp, zero_division=0),
167
- 'recall': recall_score(y_test, yp, zero_division=0),
168
- 'f1_score': f1_score(y_test, yp, zero_division=0),
169
- 'roc_auc': roc_auc_score(y_test, yproba),
170
- 'brier': brier_score_loss(y_test, yproba),
171
- 'confusion_matrix': confusion_matrix(y_test, yp).tolist(),
172
- 'classification_report': classification_report(y_test, yp),
173
- 'train_size': len(X_tr), 'val_size': len(X_val), 'test_size': len(X_test),
174
- 'y_test': y_test.tolist(), 'y_pred_prob': yproba.tolist(),
175
- }
176
-
177
- rm, rmets = _train(df, 'reply')
178
- gm, gmets = _train(df, 'ghosted')
179
- joblib.dump(rm, rp); joblib.dump(gm, gp)
180
- joblib.dump({'reply': rmets, 'ghosted': gmets}, mp)
181
- return rm, gm, {'reply': rmets, 'ghosted': gmets}
182
-
183
- reply_model, ghost_model, all_metrics = load_models()
184
-
185
- # ── Feature builder ────────────────────────────────────────────────────────────
186
- def build_input(msg_len, tone, asked_q, resp_time, seen_ign, emoji,
187
- conv_len=25, rr=None, avg_rt=None, past_ghost=0, user_type='casual'):
188
- if rr is None: rr = 0.70 if asked_q else 0.50
189
- if avg_rt is None: avg_rt = max(1.0, resp_time * 0.5)
190
- tod = 'night' if resp_time > 20 else ('morning' if resp_time < 8 else 'day')
191
- return pd.DataFrame({
192
- 'last_message_length': [msg_len],
193
- 'response_time_gap': [float(resp_time)],
194
- 'conversation_length': [conv_len],
195
- 'reply_ratio': [rr],
196
- 'avg_response_time': [float(avg_rt)],
197
- 'emoji_count': [emoji],
198
- 'question_asked': [int(asked_q)],
199
- 'seen_ignored': [seen_ign],
200
- 'past_ghosting_history': [past_ghost],
201
- 'effort_score': [msg_len + (emoji * 2) + (5 if asked_q else 0)],
202
- 'delay': [0 if resp_time < 6 else (1 if resp_time < 24 else 2)],
203
- 'is_dry': [int(tone == 'dry')],
204
- 'is_long_gap': [int(resp_time > 24)],
205
- 'engagement_score': [rr * conv_len],
206
- 'ghost_risk_combo': [int(resp_time > 24 and rr < 0.4)],
207
- 'seen_delay': [int(seen_ign == 1 and resp_time > 12)],
208
- 'initiator_flag': [int(asked_q)],
209
- 'inconsistency': [int(abs(resp_time - avg_rt) > 20)],
210
- 'decay_score': [min(conv_len / 200, 1.0)],
211
- 'effort_mismatch': [int(msg_len > 20 and rr < 0.3)],
212
- 'initiator': ['me' if asked_q else 'them'],
213
- 'message_tone': [tone],
214
- 'time_of_day': [tod],
215
- 'user_type': [user_type],
216
- })
217
-
218
- def predict_both(row):
219
- rp = reply_model.predict_proba(row)[0][1]
220
- gp = ghost_model.predict_proba(row)[0][1]
221
- return round(rp, 4), round(gp, 4)
222
-
223
- # ── Mode-adjusted probabilities ───────────────────────────────────────────────
224
- # The ML model gives one true probability. Each mode nudges it to tell a
225
- # coherent story consistent with that mode's personality:
226
- # Savage: slightly pessimistic surfaces the worst-case reading
227
- # Emotional: softens ghost risk, because the point is empathy not alarm
228
- # Delusional: bumps reply up, tanks ghost risk — the world is fine, always
229
- # Normal: raw model output, no adjustment
230
- #
231
- # Adjustments are additive deltas, clamped to [0.05, 0.95].
232
- # The base probability is always stored in session_state so switching modes
233
- # always starts from the same model output no drift across mode switches.
234
-
235
- MODE_PROB_DELTA = {
236
- # reply_delta ghost_delta
237
- 'normal': ( 0.00, 0.00),
238
- 'savage': ( -0.07, +0.10), # pessimistic "realistically, it's worse"
239
- 'emotional': ( +0.04, -0.06), # softer framing — ghost risk feels lower
240
- 'delusional':( +0.15, -0.18), # copium everything looks fine
241
- }
242
-
243
- def apply_mode(base_rp, base_gp, mode):
244
- rd, gd = MODE_PROB_DELTA[mode]
245
- rp = max(0.05, min(0.95, base_rp + rd))
246
- gp = max(0.05, min(0.95, base_gp + gd))
247
- return round(rp, 4), round(gp, 4)
248
-
249
- # ── Mode-aware text ────────────────────────────────────────────────────────────
250
- # BUG FIX: All text that changes with mode must be derived AFTER reading mode
251
- # from session_state, and the cache key must include mode.
252
-
253
- MODE_QUOTES = {
254
- 'savage': {
255
- 'high': ("Not bad. They might actually respond. Don't ruin it by double-texting.",
256
- "You're doing well. Shockingly."),
257
- 'mid': ("50/50. A coin toss. Even randomness has standards.",
258
- "You're in the grey zone. Be honest — you already know."),
259
- 'low': ("They saw it. Chose silence. That's your answer.",
260
- "This isn't a delay. This is an exit."),
261
- 'ghost_high': "They're already gone. The AI just confirmed what you felt.",
262
- 'ghost_mid': "It could go either way. But look at that response time.",
263
- 'ghost_low': "Slim chance. Still a chance. Do with that what you will.",
264
- },
265
- 'emotional': {
266
- 'high': ("There's still warmth here. Don't give up on this connection 💛",
267
- "The signs are good. You deserve someone who shows up."),
268
- 'mid': ("It's uncertain, and that uncertainty is exhausting. You're not alone in this.",
269
- "You deserve clarity. This situation doesn't give you that yet."),
270
- 'low': ("It's okay to feel this. Silence hurts. Your feelings are valid.",
271
- "Sometimes people fade. That's not a reflection of your worth."),
272
- 'ghost_high': "This is hard to hear, but you already sensed something was off.",
273
- 'ghost_mid': "The uncertainty is real. You deserve better than wondering.",
274
- 'ghost_low': "There's still a thread here. But protect your heart either way.",
275
- },
276
- 'delusional': {
277
- 'high': ("They're DEFINITELY writing a 3-paragraph reply right now 🔥",
278
- "They literally can't stop thinking about you. Facts."),
279
- 'mid': ("They're just playing it cool. They're SO into you. Obviously.",
280
- "This is called mystery. They're keeping you guessing because you're special."),
281
- 'low': ("They're probably just in a coma. Or lost their phone. In the ocean.",
282
- "WiFi issues. 100%. They'll reply any second now. Any. Second."),
283
- 'ghost_high': "Ghost risk?? No no no. They're just... composing the perfect reply.",
284
- 'ghost_mid': "The model is clearly broken. You two have something special.",
285
- 'ghost_low': "See?? Low ghost risk. They adore you. Manifesting the reply rn.",
286
- },
287
- 'normal': {
288
- 'high': ("Good signs based on your inputs. Message has solid energy.",
289
- "The indicators are positive here."),
290
- 'mid': ("This one could genuinely go either way. Hard to call.",
291
- "Mixed signals in the data — reply is uncertain."),
292
- 'low': ("The probability here is low based on current signals.",
293
- "Several risk factors are stacking up in this scenario."),
294
- 'ghost_high': "Multiple ghosting indicators are present.",
295
- 'ghost_mid': "Some ghosting signals detected — not conclusive.",
296
- 'ghost_low': "Low ghosting probability based on the inputs.",
297
- }
298
- }
299
-
300
- # BUG FIX: Final Verdict text must also be mode-aware
301
- VERDICT_TEXT = {
302
- 'normal': {
303
- 'clear_ok': ("You're overcomplicating this. They'll reply.", "#00b894"),
304
- 'mixed': ("Mixed signals. Reply likely but something feels off.", "#fdcb6e"),
305
- 'one_sided': ("This is one-sided. You're investing more than they are.", "#e17055"),
306
- 'move_on': ("Move on. The data agrees with your gut.", "#d63031"),
307
- 'uncertain': ("It's uncertain. Give it one more day before deciding.", "#636e72"),
308
- },
309
- 'savage': {
310
- 'clear_ok': ("They'll reply. Don't sabotage it now.", "#00b894"),
311
- 'mixed': ("Reply likely. Ghost possible. Classic mixed energy situation.", "#fdcb6e"),
312
- 'one_sided': ("You're the only one putting in effort here. Read that again.", "#e17055"),
313
- 'move_on': ("It's over. Your gut knew. Now you have data too.", "#d63031"),
314
- 'uncertain': ("Genuinely unclear. But your anxiety already picked a side.", "#636e72"),
315
- },
316
- 'emotional': {
317
- 'clear_ok': ("There's real connection here. Let it breathe.", "#00b894"),
318
- 'mixed': ("Something good is here, but something's also holding back.", "#fdcb6e"),
319
- 'one_sided': ("You deserve reciprocity. This doesn't look balanced right now.", "#e17055"),
320
- 'move_on': ("It's okay to let go. That's not giving up, it's self-respect.", "#d63031"),
321
- 'uncertain': ("Uncertainty is painful. Whatever happens, you'll be okay.", "#636e72"),
322
- },
323
- 'delusional': {
324
- 'clear_ok': ("Obviously they'll reply. You two are basically soulmates.", "#00b894"),
325
- 'mixed': ("The universe is just building tension before the plot twist 🌟", "#fdcb6e"),
326
- 'one_sided': ("You're the main character. They're just processing their feelings.", "#e17055"),
327
- 'move_on': ("'Move on'?? The AI doesn't understand your unique connection.", "#d63031"),
328
- 'uncertain': ("The model is just shy. It doesn't understand romance.", "#636e72"),
329
- },
330
- }
331
-
332
- def get_verdict_key(reply_prob, ghost_prob):
333
- if reply_prob > 0.65 and ghost_prob < 0.40: return 'clear_ok'
334
- if reply_prob > 0.65 and ghost_prob >= 0.40: return 'mixed'
335
- if reply_prob > 0.40: return 'one_sided'
336
- if ghost_prob > 0.65: return 'move_on'
337
- return 'uncertain'
338
-
339
- def get_quotes(mode, reply_prob, ghost_prob):
340
- rb = 'high' if reply_prob > 0.65 else ('mid' if reply_prob > 0.40 else 'low')
341
- gb = 'ghost_high' if ghost_prob > 0.65 else ('ghost_mid' if ghost_prob > 0.40 else 'ghost_low')
342
- q = MODE_QUOTES[mode][rb]
343
- return (q[0], q[1]), MODE_QUOTES[mode][gb]
344
-
345
- def interest_label(reply_prob):
346
- if reply_prob > 0.70: return "HIGH", "val-high"
347
- if reply_prob > 0.45: return "MEDIUM", "val-mid"
348
- return "LOW", "val-low"
349
-
350
- def effort_label(msg_len, asked_q, emoji):
351
- score = (msg_len / 50) + (2 if asked_q else 0) + (emoji * 0.3)
352
- if score > 4: return "HIGH", "val-high"
353
- if score > 2: return "BALANCED", "val-mid"
354
- return "ONE-SIDED", "val-low"
355
-
356
- def ghost_risk_label(ghost_prob):
357
- if ghost_prob > 0.65: return "HIGH", "val-low"
358
- if ghost_prob > 0.40: return "MEDIUM", "val-mid"
359
- return "LOW", "val-high"
360
-
361
- # ── Header ─────────────────────────────────────���───────────────────────────────
362
- st.markdown("<div class='main-title'>💀 GHOSTING PREDICTOR</div>", unsafe_allow_html=True)
363
- st.markdown("<div class='sub-title'>AI-powered relationship reality check — be honest, it already knows</div>", unsafe_allow_html=True)
364
-
365
- # ── Personality mode selector ──────────────────────────────────────────────────
366
- st.markdown("#### Choose your vibe")
367
- if "personality_mode" not in st.session_state:
368
- st.session_state["personality_mode"] = "normal"
369
-
370
- mode_cols = st.columns(4)
371
- modes = [("🧠 Normal", "normal"), ("💀 Savage", "savage"), ("😭 Emotional", "emotional"), ("🤡 Delusional", "delusional")]
372
- for i, (lbl, key) in enumerate(modes):
373
- with mode_cols[i]:
374
- if st.button(lbl, use_container_width=True,
375
- type="primary" if st.session_state["personality_mode"] == key else "secondary"):
376
- st.session_state["personality_mode"] = key
377
- st.rerun()
378
-
379
- # Read mode ONCE here everything below uses this single variable
380
- mode = st.session_state["personality_mode"]
381
-
382
- mode_banner = {
383
- "normal": ("🧠 Normal Mode", "#636e72"),
384
- "savage": ("💀 Savage Mode — No feelings were harmed. They were obliterated.", "#d63031"),
385
- "emotional": ("😭 Emotional Mode — We see you. Your feelings are valid.", "#6c5ce7"),
386
- "delusional": ("🤡 Delusional Mode — Stay hopeful! (The AI thinks you're cooked.)", "#e17055"),
387
- }
388
- banner_text, banner_color = mode_banner[mode]
389
- st.markdown(
390
- f"<div style='text-align:center;background:{banner_color}22;border:1px solid {banner_color}55;"
391
- f"border-radius:10px;padding:8px;font-size:0.88em;color:{banner_color};margin:8px 0 16px;'>"
392
- f"{banner_text}</div>",
393
- unsafe_allow_html=True
394
- )
395
- st.divider()
396
-
397
- # ── Tabs: 3 tabs only (message analyzer removed) ──────────────────────────────
398
- tab1, tab2, tab3 = st.tabs(["🔮 Predict", "📊 What-If", "🎓 Model Metrics"])
399
-
400
- # ══════════════════════════════════════════════════════════════════════════════
401
- # TAB 1 — MAIN PREDICTION
402
- # ══════════════════════════════════════════════════════════════════════════════
403
- with tab1:
404
- col1, col2 = st.columns(2, gap="large")
405
-
406
- with col1:
407
- st.markdown("#### 📱 Your message")
408
- message_length = st.slider("Message length (chars)", 1, 500, 80, 5)
409
- message_tone = st.selectbox("Tone", ['dry', 'neutral', 'enthusiastic'], index=1)
410
- asked_question = st.toggle("Asked a question?", value=True)
411
- emoji_count = st.slider("Emojis used", 0, 10, 1)
412
- past_ghost = st.toggle("Have they ghosted you before?", value=False)
413
-
414
- with col2:
415
- st.markdown("#### ⏱️ Their behaviour")
416
- response_time = st.slider("Hours since you sent it", 0, 72, 4, 1)
417
- seen_raw = st.radio("Did they see it?", ["👁️ Yes, seen", "❓ Not seen yet"], horizontal=True)
418
- seen_ignored = 1 if "Yes" in seen_raw else 0
419
- conv_len = st.slider("How long has the convo been? (messages)", 1, 200, 20)
420
- user_type_map = {
421
- "😊 Seems interested": "interested",
422
- "💬 Normal/casual": "casual",
423
- "🌵 Very dry texter": "dry_texter",
424
- "👻 Known to ghost": "ghoster",
425
- }
426
- user_type_label = st.selectbox("How would you describe them?", list(user_type_map.keys()))
427
- user_type = user_type_map[user_type_label]
428
-
429
- st.divider()
430
-
431
- # ── Predictions cache key includes mode so text refreshes on mode change ──
432
- # ikey tracks input changes only (not mode) — model is only re-run when
433
- # inputs change. Base probabilities are stored raw (no mode applied).
434
- # Mode adjustment is applied on every render so switching mode instantly
435
- # changes the displayed numbers without re-running the model.
436
- ikey = (message_length, message_tone, asked_question, response_time,
437
- seen_ignored, emoji_count, conv_len, user_type, int(past_ghost))
438
-
439
- if st.session_state.get("ikey") != ikey:
440
- try:
441
- row = build_input(message_length, message_tone, asked_question,
442
- response_time, seen_ignored, emoji_count,
443
- conv_len=conv_len, past_ghost=int(past_ghost),
444
- user_type=user_type)
445
- base_rp, base_gp = predict_both(row)
446
- except Exception as e:
447
- st.error(f"Prediction error: {e}")
448
- base_rp, base_gp = 0.5, 0.4
449
- st.session_state.update({"ikey": ikey, "base_rp": base_rp, "base_gp": base_gp})
450
-
451
- # Apply mode delta on every render — no model re-run needed
452
- reply_prob, ghost_prob = apply_mode(
453
- st.session_state["base_rp"],
454
- st.session_state["base_gp"],
455
- mode
456
- )
457
-
458
- # Derive ALL mode-dependent text here, after reading mode from session_state
459
- (main_q, sub_q), ghost_q = get_quotes(mode, reply_prob, ghost_prob)
460
- verdict_key = get_verdict_key(reply_prob, ghost_prob)
461
- verdict_text, verdict_color = VERDICT_TEXT[mode][verdict_key]
462
-
463
- # ── Dual verdict cards ────────────────────────────────────────────────────
464
- vc1, vc2 = st.columns(2)
465
- with vc1:
466
- vclass = "verdict-high" if reply_prob > 0.65 else ("verdict-mid" if reply_prob > 0.40 else "verdict-low")
467
- vlabel = "They'll reply 🔥" if reply_prob > 0.65 else ("Could go either way 😬" if reply_prob > 0.40 else "They're ghosting you 💀")
468
- st.markdown(f"""
469
- <div class='verdict-card {vclass}'>
470
- <div style='font-size:0.8em;font-weight:600;opacity:0.8;text-transform:uppercase;letter-spacing:1px;'>Reply probability</div>
471
- <div class='verdict-pct'>{reply_prob*100:.0f}%</div>
472
- <div class='verdict-label'>{vlabel}</div>
473
- <div class='verdict-quote'>"{main_q}"</div>
474
- </div>
475
- """, unsafe_allow_html=True)
476
-
477
- with vc2:
478
- gclass = "verdict-low" if ghost_prob > 0.65 else ("verdict-mid" if ghost_prob > 0.40 else "verdict-high")
479
- glabel = "High ghost risk 💀" if ghost_prob > 0.65 else ("Uncertain 😬" if ghost_prob > 0.40 else "Probably fine 🙂")
480
- st.markdown(f"""
481
- <div class='verdict-card {gclass}'>
482
- <div style='font-size:0.8em;font-weight:600;opacity:0.8;text-transform:uppercase;letter-spacing:1px;'>Ghost probability</div>
483
- <div class='verdict-pct'>{ghost_prob*100:.0f}%</div>
484
- <div class='verdict-label'>{glabel}</div>
485
- <div class='verdict-quote'>"{ghost_q}"</div>
486
- </div>
487
- """, unsafe_allow_html=True)
488
-
489
- # ── Conversation Diagnosis ────────────────────────────────────────────────
490
- # BUG FIX: Diagnosis values are derived from ML probabilities (correct),
491
- # but the Read Status now also reflects mode tone
492
- st.markdown("#### 🧠 Conversation Diagnosis")
493
- int_lbl, int_cls = interest_label(reply_prob)
494
- eff_lbl, eff_cls = effort_label(message_length, asked_question, emoji_count)
495
- gr_lbl, gr_cls = ghost_risk_label(ghost_prob)
496
-
497
- # Read status changes with mode (delusional gives an excuse, savage is blunt)
498
- if seen_ignored and response_time > 6:
499
- seen_txt = {
500
- 'normal': "IGNORED 🚨",
501
- 'savage': "SEEN. IGNORED. 💀",
502
- 'emotional': "SEEN, NO REPLY 💔",
503
- 'delusional':"SEEN (composing!!) ✍️",
504
- }[mode]
505
- seen_cls = "val-low"
506
- elif seen_ignored:
507
- seen_txt = "SEEN ✓"; seen_cls = "val-mid"
508
- else:
509
- seen_txt = "NOT SEEN"; seen_cls = "val-mid"
510
-
511
- st.markdown(f"""
512
- <div class='diag-row'>
513
- <div class='diag-card'>
514
- <div class='diag-title'>Interest Level</div>
515
- <div class='diag-val {int_cls}'>{int_lbl}</div>
516
- </div>
517
- <div class='diag-card'>
518
- <div class='diag-title'>Effort Balance</div>
519
- <div class='diag-val {eff_cls}'>{eff_lbl}</div>
520
- </div>
521
- <div class='diag-card'>
522
- <div class='diag-title'>Ghost Risk</div>
523
- <div class='diag-val {gr_cls}'>{gr_lbl}</div>
524
- </div>
525
- <div class='diag-card'>
526
- <div class='diag-title'>Read Status</div>
527
- <div class='diag-val {seen_cls}'>{seen_txt}</div>
528
- </div>
529
- </div>
530
- """, unsafe_allow_html=True)
531
-
532
- # ── Signal Breakdown ──────────────────────────────────────────────────────
533
- st.markdown("#### 🚩 Signal Breakdown")
534
- fc1, fc2 = st.columns(2)
535
-
536
- red_flags, green_flags = [], []
537
- if seen_ignored and response_time > 6: red_flags.append("They saw your message. They chose silence.")
538
- if response_time > 48: red_flags.append(f"It's been {response_time}h. That's not busy, that's avoidance.")
539
- elif response_time > 24: red_flags.append("Over 24 hours the energy is cooling off.")
540
- if message_tone == 'dry': red_flags.append("Dry tone doesn't open doors.")
541
- if not asked_question: red_flags.append("No question = no reason to reply.")
542
- if message_length < 30: red_flags.append("Short message looks like low effort.")
543
- if user_type == 'ghoster': red_flags.append("You described them as a known ghoster. That's data.")
544
- if past_ghost: red_flags.append("They've ghosted you before. Pattern recognised.")
545
- if emoji_count == 0 and message_tone == 'dry': red_flags.append("Zero warmth signals in this message.")
546
-
547
- if asked_question: green_flags.append("Asked a question gives them something to respond to.")
548
- if message_length > 100: green_flags.append("Substantial message — shows you put in effort.")
549
- if message_tone == 'enthusiastic': green_flags.append("Enthusiastic tone — energy is contagious.")
550
- if response_time < 12: green_flags.append("Sent recently — they still might be composing a reply.")
551
- if emoji_count > 0: green_flags.append("Used emojis — lightens the vibe.")
552
- if user_type == 'interested': green_flags.append("You described them as interested — that matters.")
553
- if not past_ghost: green_flags.append("No ghosting history — fresh start.")
554
-
555
- with fc1:
556
- st.markdown("**Red flags**")
557
- for f in red_flags:
558
- st.markdown(f"<div class='flag-item'>🚩 {f}</div>", unsafe_allow_html=True)
559
- if not red_flags:
560
- st.markdown("<div class='flag-item green-flag'>✅ No major red flags detected.</div>", unsafe_allow_html=True)
561
-
562
- with fc2:
563
- st.markdown("**Green flags**")
564
- for g in green_flags:
565
- st.markdown(f"<div class='flag-item green-flag'>✅ {g}</div>", unsafe_allow_html=True)
566
- if not green_flags:
567
- st.markdown("<div class='flag-item'>🚩 Hmm, not many positives here.</div>", unsafe_allow_html=True)
568
-
569
- st.divider()
570
-
571
- # ── Final Verdict — mode-aware ────────────────────────────────────────────
572
- # BUG FIX: verdict_text and verdict_color now come from VERDICT_TEXT[mode]
573
- st.markdown("#### 🎯 Final Verdict")
574
- st.markdown(
575
- f"<div style='background:{verdict_color}22;border-left:4px solid {verdict_color};"
576
- f"border-radius:0 12px 12px 0;padding:16px 20px;margin:10px 0;"
577
- f"font-family:Syne,sans-serif;font-size:1.1em;color:{verdict_color};font-weight:600;'>"
578
- f"{verdict_text}</div>",
579
- unsafe_allow_html=True
580
- )
581
- if sub_q:
582
- st.markdown(
583
- f"<div style='color:#b2bec3;font-style:italic;font-size:0.9em;margin-top:8px;'>💭 {sub_q}</div>",
584
- unsafe_allow_html=True
585
- )
586
-
587
- st.divider()
588
-
589
- # ── Shareable card ────────────────────────────────────────────────────────
590
- st.markdown("#### 📸 Share Your Result")
591
- share_label = "They'll probably reply 🔥" if reply_prob > 0.65 else ("It's a coin flip 😬" if reply_prob > 0.40 else "Ghosting incoming 💀")
592
- ghost_label = "Ghost risk: HIGH 💀" if ghost_prob > 0.65 else ("Ghost risk: MEDIUM ⚠️" if ghost_prob > 0.40 else "Ghost risk: LOW ✅")
593
-
594
- st.markdown(f"""
595
- <div class='share-card'>
596
- <div style='font-size:0.75em;letter-spacing:2px;color:#636e72;text-transform:uppercase;margin-bottom:8px;'>AI Reality Check</div>
597
- <div class='share-pct'>{reply_prob*100:.0f}%</div>
598
- <div class='share-line' style='font-size:1.2em;font-weight:700;'>{share_label}</div>
599
- <div class='share-line' style='color:#b2bec3;'>{ghost_label}</div>
600
- <div class='share-line' style='font-style:italic;color:#dfe6e9;margin-top:10px;font-size:0.95em;'>"{main_q}"</div>
601
- <div class='share-tag'>#GhostingPredictor • ghostingpredictor.app</div>
602
- </div>
603
- """, unsafe_allow_html=True)
604
-
605
- share_text = (f"💀 Ghosting Predictor says:\n"
606
- f"Reply chance: {reply_prob*100:.0f}% {share_label}\n"
607
- f"{ghost_label}\n"
608
- f'"{main_q}"\n'
609
- f"#GhostingPredictor #AI #Dating")
610
-
611
- # FIX: st.button causes a full page rerun which resets widget defaults →
612
- # changes ikey → triggers fresh prediction with default inputs → wrong %.
613
- # Solution: always render the share text in a st.text_area (read-only style).
614
- # st.text_area does NOT trigger a rerun when the user clicks inside it to
615
- # select/copy — it only reruns on actual value change, which can't happen
616
- # because the value is set programmatically and the user just selects text.
617
- st.markdown("<div style='font-size:0.82em;color:#b2bec3;margin-bottom:4px;'>📋 Click inside, Ctrl+A, Ctrl+C to copy:</div>", unsafe_allow_html=True)
618
- st.text_area(
619
- label="share_text_area",
620
- value=share_text,
621
- height=120,
622
- label_visibility="collapsed",
623
- key=f"share_ta_{hash(share_text)}", # stable key tied to content, not mode
624
- )
625
- st.markdown("<div style='text-align:center;font-size:0.8em;color:#636e72;margin-top:4px;'>📸 Or screenshot the card above and post it</div>", unsafe_allow_html=True)
626
-
627
- st.markdown("<div style='text-align:center;margin-top:12px;font-size:0.85em;color:#636e72;'>Drop your situation in the comments — I'll tell you what the model says 👇</div>", unsafe_allow_html=True)
628
-
629
- # ══════════════════════════════════════════════════════════════════════════════
630
- # TAB 2 WHAT-IF SIMULATOR
631
- # ══════════════════════════════════════════════════════════════════════════════
632
- with tab2:
633
- st.markdown("#### 📊 What-If Simulator")
634
- st.markdown("<div style='color:#636e72;font-size:0.88em;'>See how your odds change if you tweak one thing. Experiment freely.</div>", unsafe_allow_html=True)
635
-
636
- if "base_rp" not in st.session_state:
637
- st.info("Go to the **Predict** tab first to set your base scenario.")
638
- else:
639
- base_rp, base_gp = apply_mode(
640
- st.session_state["base_rp"],
641
- st.session_state["base_gp"],
642
- mode
643
- )
644
- ml, tone, aq, rt, si, ec, cl_val, ut, pg = st.session_state["ikey"]
645
-
646
- rclr = "#00b894" if base_rp > 0.65 else ("#fdcb6e" if base_rp > 0.4 else "#ff7675")
647
- gclr = "#ff7675" if base_gp > 0.65 else ("#fdcb6e" if base_gp > 0.4 else "#00b894")
648
- st.markdown(f"""
649
- <div style='background:rgba(255,255,255,0.05);border-radius:12px;padding:14px 18px;margin-bottom:16px;'>
650
- <div style='font-size:0.8em;color:#b2bec3;text-transform:uppercase;letter-spacing:1px;'>Your current situation</div>
651
- <div style='font-family:Syne,sans-serif;font-size:1.6em;font-weight:700;'>
652
- Reply: <span style='color:{rclr}'>{base_rp*100:.0f}%</span>
653
- &nbsp;&nbsp; Ghost: <span style='color:{gclr}'>{base_gp*100:.0f}%</span>
654
- </div>
655
- </div>
656
- """, unsafe_allow_html=True)
657
-
658
- scenarios = []
659
- if not aq:
660
- r = build_input(ml, tone, True, rt, si, ec, cl_val, user_type=ut)
661
- nr, ng = predict_both(r)
662
- scenarios.append(("❓ If you added a question", nr, ng))
663
- if tone != 'enthusiastic':
664
- r = build_input(ml, 'enthusiastic', aq, rt, si, ec, cl_val, user_type=ut)
665
- nr, ng = predict_both(r)
666
- scenarios.append(("😄 If your tone was enthusiastic", nr, ng))
667
- if ml < 150:
668
- r = build_input(150, tone, aq, rt, si, ec, cl_val, user_type=ut)
669
- nr, ng = predict_both(r)
670
- scenarios.append(("📝 If your message was longer (150 chars)", nr, ng))
671
- if rt > 12:
672
- r = build_input(ml, tone, aq, 2, si, ec, cl_val, user_type=ut)
673
- nr, ng = predict_both(r)
674
- scenarios.append(("⏱️ If you followed up now (2h gap)", nr, ng))
675
- if ec == 0:
676
- r = build_input(ml, tone, aq, rt, si, 3, cl_val, user_type=ut)
677
- nr, ng = predict_both(r)
678
- scenarios.append(("😂 If you added 3 emojis", nr, ng))
679
- r = build_input(max(ml, 120), 'enthusiastic', True, min(rt, 4), si, max(ec, 2), cl_val, user_type=ut)
680
- nr, ng = predict_both(r)
681
- scenarios.append(("🚀 Best case (all fixes applied)", nr, ng))
682
-
683
- st.markdown("**How your odds change:**")
684
- for label, nr, ng in scenarios:
685
- rdiff = (nr - base_rp) * 100
686
- gdiff = (ng - base_gp) * 100
687
- rc = "whatif-boost" if rdiff > 0 else "whatif-drop"
688
- gc = "whatif-drop" if gdiff > 0 else "whatif-boost"
689
- rs = "+" if rdiff >= 0 else ""; gs = "+" if gdiff >= 0 else ""
690
- st.markdown(f"""
691
- <div class='whatif-row'>
692
- <span>{label}</span>
693
- <span>
694
- <span class='{rc}'>Reply: {rs}{rdiff:.0f}%</span>
695
- &nbsp;|&nbsp;
696
- <span class='{gc}'>Ghost: {gs}{gdiff:.0f}%</span>
697
- </span>
698
- </div>
699
- """, unsafe_allow_html=True)
700
-
701
- st.markdown("<div style='color:#636e72;font-size:0.8em;margin-top:12px;'>All scenarios keep the rest of your inputs unchanged.</div>", unsafe_allow_html=True)
702
-
703
-
704
- # ══════════════════════════════════════════════════════════════════════════════
705
- # TAB 3 MODEL METRICS
706
- # ════════════════════════════════════════��═════════════════════════════════════
707
- with tab3:
708
- st.markdown("#### 🎓 Model Performance")
709
- st.markdown("<div style='color:#636e72;font-size:0.88em;'>Two calibrated Random Forest models — reply prediction and ghost prediction. Evaluated on a clean held-out test set.</div>", unsafe_allow_html=True)
710
-
711
- for key, label in [('reply', '📩 Reply Model'), ('ghosted', '👻 Ghost Model')]:
712
- m = all_metrics[key]
713
- st.markdown(f"### {label}")
714
- mc = st.columns(5)
715
- mc[0].markdown(f"<div class='m-card'><div class='m-num'>{m['accuracy']*100:.1f}%</div><div class='m-lbl'>Accuracy</div></div>", unsafe_allow_html=True)
716
- mc[1].markdown(f"<div class='m-card'><div class='m-num'>{m['precision']*100:.1f}%</div><div class='m-lbl'>Precision</div></div>", unsafe_allow_html=True)
717
- mc[2].markdown(f"<div class='m-card'><div class='m-num'>{m['recall']*100:.1f}%</div><div class='m-lbl'>Recall</div></div>", unsafe_allow_html=True)
718
- mc[3].markdown(f"<div class='m-card'><div class='m-num'>{m['f1_score']:.3f}</div><div class='m-lbl'>F1 Score</div></div>", unsafe_allow_html=True)
719
- mc[4].markdown(f"<div class='m-card'><div class='m-num'>{m['roc_auc']:.3f}</div><div class='m-lbl'>ROC-AUC</div></div>", unsafe_allow_html=True)
720
-
721
- with st.expander(f"Confusion matrix & report — {label}", expanded=False):
722
- e1, e2 = st.columns(2)
723
- with e1:
724
- cm_arr = np.array(m['confusion_matrix'])
725
- fig, ax = plt.subplots(figsize=(4, 3))
726
- sns.heatmap(cm_arr, annot=True, fmt='d', cmap='Blues',
727
- xticklabels=['No', 'Yes'], yticklabels=['No', 'Yes'],
728
- ax=ax, cbar=False, annot_kws={'size': 13, 'weight': 'bold'})
729
- ax.set_xlabel('Predicted', color='white'); ax.set_ylabel('Actual', color='white')
730
- ax.set_title('Confusion Matrix', color='white', fontsize=11)
731
- fig.patch.set_facecolor('#1a1a2e'); ax.set_facecolor('#1a1a2e')
732
- ax.tick_params(colors='white')
733
- plt.tight_layout(); st.pyplot(fig, use_container_width=True)
734
- with e2:
735
- try:
736
- from sklearn.metrics import roc_curve
737
- fpr, tpr, _ = roc_curve(np.array(m['y_test']), np.array(m['y_pred_prob']))
738
- fig2, ax2 = plt.subplots(figsize=(4, 3))
739
- ax2.plot(fpr, tpr, color='#ee5a24', lw=2, label=f"AUC={m['roc_auc']:.3f}")
740
- ax2.plot([0,1],[0,1],'--',color='gray',lw=1)
741
- ax2.fill_between(fpr, tpr, alpha=0.12, color='#ee5a24')
742
- ax2.set_xlabel('FPR', color='white'); ax2.set_ylabel('TPR', color='white')
743
- ax2.set_title('ROC Curve', color='white', fontsize=11)
744
- ax2.legend(fontsize=9); ax2.tick_params(colors='white')
745
- ax2.set_facecolor('#1a1a2e'); fig2.patch.set_facecolor('#1a1a2e')
746
- plt.tight_layout(); st.pyplot(fig2, use_container_width=True)
747
- except: pass
748
- st.code(m['classification_report'], language=None)
749
- st.divider()
750
-
751
- st.markdown(f"""
752
- <div style='background:rgba(255,255,255,0.04);border-radius:12px;padding:16px 20px;font-size:0.85em;color:#636e72;'>
753
- <b>Architecture:</b> Random Forest (400 trees, depth=20, class_weight=balanced) + Sigmoid calibration (cv=3)<br>
754
- <b>Split:</b> 70% train / 15% calibration val / 15% test — no data leakage between steps<br>
755
- <b>Dataset:</b> 10,000 synthetic samples — ghosting_dataset5.csv<br>
756
- <b>Features:</b> 20 numerical + 4 categorical (including user_type persona)
757
- </div>
758
- """, unsafe_allow_html=True)
759
-
760
- # ── Footer ─────────────────────────────────────────────────────────────────────
761
- st.markdown("""
762
- <div style='text-align:center;color:#636e72;margin-top:40px;padding:20px;font-size:0.85em;'>
763
- <div style='margin-bottom:4px;'>💭 <i>You already know the answer. The AI just confirmed it.</i></div>
764
- <div>Powered by Random Forest ML · Not liable for heartbreak 💔</div>
765
- </div>
766
- """, unsafe_allow_html=True)
767
-
768
-
769
- # again the same problem, after implementing this reply, the probabilities are changing with the inputs, they are not static...
 
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import numpy as np
4
+ import joblib
5
+ import os
6
+ from sklearn.ensemble import RandomForestClassifier
7
+ from sklearn.calibration import CalibratedClassifierCV
8
+ from sklearn.pipeline import Pipeline
9
+ from sklearn.compose import ColumnTransformer
10
+ from sklearn.preprocessing import StandardScaler, OneHotEncoder
11
+ from sklearn.impute import SimpleImputer
12
+ from sklearn.model_selection import train_test_split
13
+ from sklearn.metrics import (accuracy_score, precision_score, recall_score,
14
+ f1_score, roc_auc_score, brier_score_loss,
15
+ confusion_matrix, classification_report)
16
+ import matplotlib.pyplot as plt
17
+ import seaborn as sns
18
+ import warnings
19
+ warnings.filterwarnings('ignore')
20
+
21
+ st.set_page_config(
22
+ page_title="💀 Ghosting Predictor",
23
+ page_icon="👻",
24
+ layout="wide",
25
+ initial_sidebar_state="collapsed"
26
+ )
27
+
28
+ st.markdown("""
29
+ <style>
30
+ @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800&family=Inter:wght@400;500;600&display=swap');
31
+
32
+ html, body, [class*="css"] { font-family: 'Inter', sans-serif; }
33
+ h1, h2, h3 { font-family: 'Syne', sans-serif !important; }
34
+
35
+ .main-title {
36
+ font-family: 'Syne', sans-serif; font-size: 3.0em; font-weight: 700;
37
+ color: #ff6b6b; /* Set text color to red */
38
+ text-align: center; margin-bottom: 20px; letter-spacing: 0.5px;
39
+ }
40
+ .sub-title { text-align: center; color: #b2bec3; font-size: 1.2em; margin-top: 10px; margin-bottom: 20px; }
41
+
42
+ .verdict-card {
43
+ border-radius: 20px; padding: 30px; text-align: center;
44
+ margin: 20px auto; position: relative; overflow: hidden;
45
+ max-width: 700px;
46
+ }
47
+ .verdict-high { background: linear-gradient(135deg, #00b894, #00cec9); color: white; }
48
+ .verdict-mid { background: linear-gradient(135deg, #fdcb6e, #e17055); color: white; }
49
+ .verdict-low { background: linear-gradient(135deg, #d63031, #6c5ce7); color: white; }
50
+ .verdict-pct { font-family: 'Syne', sans-serif; font-size: 3em; font-weight: 800; line-height: 1.2; }
51
+ .verdict-label { font-size: 1.2em; font-weight: 600; margin-top: 10px; opacity: 0.9; }
52
+ .verdict-quote { font-size: 1em; margin-top: 16px; font-style: italic; opacity: 0.85;
53
+ border-top: 1px solid rgba(255,255,255,0.2); padding-top: 16px; }
54
+
55
+ .diag-row { display: flex; gap: 20px; margin: 20px auto; flex-wrap: wrap; justify-content: center; }
56
+ .diag-card {
57
+ flex: 1; min-width: 180px; max-width: 250px; border-radius: 15px; padding: 20px;
58
+ text-align: center; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1);
59
+ }
60
+ .diag-title { font-size: 0.9em; text-transform: uppercase; letter-spacing: 1px; color: #b2bec3; margin-bottom: 8px; }
61
+ .diag-val { font-family: 'Syne', sans-serif; font-size: 1.5em; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
62
+ .val-high { color: #00b894; }
63
+ .val-mid { color: #fdcb6e; }
64
+ .val-low { color: #ff7675; }
65
+
66
+ .flag-item {
67
+ display: flex; align-items: flex-start; gap: 12px;
68
+ padding: 12px 16px; border-radius: 12px; margin: 8px auto;
69
+ background: rgba(214, 48, 49, 0.15); border-left: 4px solid #d63031; font-size: 1em;
70
+ max-width: 700px;
71
+ }
72
+ .green-flag { background: rgba(0, 184, 148, 0.15); border-left: 4px solid #00b894; }
73
+
74
+ .share-card {
75
+ background: linear-gradient(135deg, #1a1a2e, #16213e);
76
+ border-radius: 20px; padding: 30px; border: 1px solid rgba(255,255,255,0.1);
77
+ text-align: center; font-family: 'Syne', sans-serif;
78
+ max-width: 700px; margin: 30px auto;
79
+ }
80
+ .share-pct {
81
+ font-size: 3em; font-weight: 800;
82
+ background: linear-gradient(135deg, #ff6b6b, #ee5a24);
83
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
84
+ }
85
+ .share-line { color: #dfe6e9; margin: 8px 0; font-size: 0.9em; }
86
+ .share-tag { color: #636e72; font-size: 0.85em; margin-top: 12px; }
87
+
88
+ .m-card {
89
+ background: rgba(255,255,255,0.08); border-radius: 15px; padding: 20px; text-align: center;
90
+ border: 1px solid rgba(255,255,255,0.1); margin: 6px auto; max-width: 250px;
91
+ }
92
+ .m-num { font-family: 'Syne', sans-serif; font-size: 1.8em; font-weight: 800; color: #fdcb6e; }
93
+ .m-lbl { font-size: 0.85em; color: #b2bec3; text-transform: uppercase; letter-spacing: 0.8px; }
94
+
95
+ .stProgress > div > div { border-radius: 99px; }
96
+ .block-container { padding-top: 3.5rem; max-width: 1300px; margin: auto; }
97
+ </style>
98
+ """, unsafe_allow_html=True)
99
+
100
+ # ── Feature config ─────────────────────────────────────────────────────────────
101
+ NUM_FEATURES = [
102
+ 'last_message_length', 'response_time_gap', 'conversation_length',
103
+ 'reply_ratio', 'avg_response_time', 'emoji_count', 'question_asked',
104
+ 'seen_ignored', 'past_ghosting_history', 'effort_score', 'delay',
105
+ 'is_dry', 'is_long_gap', 'engagement_score', 'ghost_risk_combo',
106
+ 'seen_delay', 'initiator_flag', 'inconsistency', 'decay_score', 'effort_mismatch'
107
+ ]
108
+ CAT_FEATURES = ['initiator', 'message_tone', 'time_of_day', 'user_type']
109
+
110
+ def make_preprocessor():
111
+ num_t = Pipeline([('imp', SimpleImputer(strategy='median')), ('sc', StandardScaler())])
112
+ cat_t = Pipeline([('imp', SimpleImputer(strategy='most_frequent')),
113
+ ('ohe', OneHotEncoder(handle_unknown='ignore'))])
114
+ return ColumnTransformer([('num', num_t, NUM_FEATURES), ('cat', cat_t, CAT_FEATURES)], remainder='drop')
115
+
116
+ # ── Load / train models ────────────────────────────────────────────────────────
117
+ @st.cache_resource
118
+ def load_models():
119
+ rp = 'rf_reply_model.pkl'; gp = 'rf_ghost_model.pkl'; mp = 'model_metrics.pkl'
120
+ if os.path.exists(rp) and os.path.exists(gp) and os.path.exists(mp):
121
+ try:
122
+ return joblib.load(rp), joblib.load(gp), joblib.load(mp)
123
+ except Exception as e:
124
+ st.warning(f"⚠️ Saved models failed ({str(e)[:60]}). Retraining...")
125
+
126
+ with st.spinner("🤖 Training models... (~30 sec)"):
127
+ try:
128
+ df = pd.read_csv('ghosting_dataset5.csv')
129
+ except FileNotFoundError:
130
+ st.error(" ghosting_dataset5.csv not found. Run gen_data5.py first.")
131
+ st.stop()
132
+
133
+ df['effort_score'] = df['last_message_length'] + (df['emoji_count'] * 2) + (df['question_asked'] * 5)
134
+ df['delay'] = df['response_time_gap'].apply(lambda x: 0 if x < 6 else 1 if x < 24 else 2)
135
+ df['is_dry'] = (df['message_tone'] == 'dry').astype(int)
136
+ df['is_long_gap'] = (df['response_time_gap'] > 24).astype(int)
137
+ df['engagement_score'] = df['reply_ratio'] * df['conversation_length']
138
+ df['ghost_risk_combo'] = ((df['response_time_gap'] > 24) & (df['reply_ratio'] < 0.4)).astype(int)
139
+ df['seen_delay'] = ((df['seen_ignored'] == 1) & (df['response_time_gap'] > 12)).astype(int)
140
+ df['initiator_flag'] = (df['initiator'] == 'me').astype(int)
141
+ df['inconsistency'] = (abs(df['response_time_gap'] - df['avg_response_time']) > 20).astype(int)
142
+ df['decay_score'] = (df['conversation_length'] / 200).clip(0, 1)
143
+ df['effort_mismatch'] = ((df['last_message_length'] > 20) & (df['reply_ratio'] < 0.3)).astype(int)
144
+
145
+ def _train(df, target):
146
+ X = df[NUM_FEATURES + CAT_FEATURES]; y = df[target]
147
+ # ── Proper 3-way split no leakage ─────────────────────────────
148
+ X_tv, X_test, y_tv, y_test = train_test_split(X, y, test_size=0.15, random_state=42, stratify=y)
149
+ X_tr, X_val, y_tr, y_val = train_test_split(X_tv, y_tv, test_size=0.15/0.85, random_state=42, stratify=y_tv)
150
+ mdl = Pipeline([('pre', make_preprocessor()),
151
+ ('clf', RandomForestClassifier(n_estimators=400, max_depth=20,
152
+ class_weight='balanced', random_state=42, n_jobs=-1))])
153
+ mdl.fit(X_tr, y_tr)
154
+ # Calibrate on val only; evaluate on test only
155
+ cal = CalibratedClassifierCV(mdl, method='sigmoid', cv=3)
156
+ cal.fit(X_val, y_val)
157
+ yp = cal.predict(X_test); yproba = cal.predict_proba(X_test)[:, 1]
158
+ return cal, {
159
+ 'accuracy': accuracy_score(y_test, yp),
160
+ 'precision': precision_score(y_test, yp, zero_division=0),
161
+ 'recall': recall_score(y_test, yp, zero_division=0),
162
+ 'f1_score': f1_score(y_test, yp, zero_division=0),
163
+ 'roc_auc': roc_auc_score(y_test, yproba),
164
+ 'brier': brier_score_loss(y_test, yproba),
165
+ 'confusion_matrix': confusion_matrix(y_test, yp).tolist(),
166
+ 'classification_report': classification_report(y_test, yp),
167
+ 'train_size': len(X_tr), 'val_size': len(X_val), 'test_size': len(X_test),
168
+ 'y_test': y_test.tolist(), 'y_pred_prob': yproba.tolist(),
169
+ }
170
+
171
+ rm, rmets = _train(df, 'reply')
172
+ gm, gmets = _train(df, 'ghosted')
173
+ joblib.dump(rm, rp); joblib.dump(gm, gp)
174
+ joblib.dump({'reply': rmets, 'ghosted': gmets}, mp)
175
+ return rm, gm, {'reply': rmets, 'ghosted': gmets}
176
+
177
+ reply_model, ghost_model, all_metrics = load_models()
178
+
179
+ # ── Feature builder ────────────────────────────────────────────────────────────
180
+ def build_input(msg_len, tone, asked_q, resp_time, seen_ign, emoji,
181
+ conv_len=25, rr=None, avg_rt=None, past_ghost=0, user_type='casual'):
182
+ if rr is None: rr = 0.70 if asked_q else 0.50
183
+ if avg_rt is None: avg_rt = max(1.0, resp_time * 0.5)
184
+ tod = 'night' if resp_time > 20 else ('morning' if resp_time < 8 else 'day')
185
+ return pd.DataFrame({
186
+ 'last_message_length': [msg_len],
187
+ 'response_time_gap': [float(resp_time)],
188
+ 'conversation_length': [conv_len],
189
+ 'reply_ratio': [rr],
190
+ 'avg_response_time': [float(avg_rt)],
191
+ 'emoji_count': [emoji],
192
+ 'question_asked': [int(asked_q)],
193
+ 'seen_ignored': [seen_ign],
194
+ 'past_ghosting_history': [past_ghost],
195
+ 'effort_score': [msg_len + (emoji * 2) + (5 if asked_q else 0)],
196
+ 'delay': [0 if resp_time < 6 else (1 if resp_time < 24 else 2)],
197
+ 'is_dry': [int(tone == 'dry')],
198
+ 'is_long_gap': [int(resp_time > 24)],
199
+ 'engagement_score': [rr * conv_len],
200
+ 'ghost_risk_combo': [int(resp_time > 24 and rr < 0.4)],
201
+ 'seen_delay': [int(seen_ign == 1 and resp_time > 12)],
202
+ 'initiator_flag': [int(asked_q)],
203
+ 'inconsistency': [int(abs(resp_time - avg_rt) > 20)],
204
+ 'decay_score': [min(conv_len / 200, 1.0)],
205
+ 'effort_mismatch': [int(msg_len > 20 and rr < 0.3)],
206
+ 'initiator': ['me' if asked_q else 'them'],
207
+ 'message_tone': [tone],
208
+ 'time_of_day': [tod],
209
+ 'user_type': [user_type],
210
+ })
211
+
212
+ def predict_both(row):
213
+ rp = reply_model.predict_proba(row)[0][1]
214
+ gp = ghost_model.predict_proba(row)[0][1]
215
+ return round(rp, 4), round(gp, 4)
216
+
217
+ # ── Mode-adjusted probabilities ───────────────────────────────────────────────
218
+ # The ML model gives one true probability. Each mode nudges it to tell a
219
+ # coherent story consistent with that mode's personality:
220
+ # Savage: slightly pessimistic ➺ surfaces the worst-case reading
221
+ # Emotional: softens ghost risk, because the point is empathy not alarm
222
+ # Delusional: bumps reply up, tanks ghost risk ➺ the world is fine, always
223
+ # Normal: raw model output, no adjustment
224
+ #
225
+ # Adjustments are additive deltas, clamped to [0.05, 0.95].
226
+ # The base probability is always stored in session_state so switching modes
227
+ # always starts from the same model output no drift across mode switches.
228
+
229
+ MODE_PROB_DELTA = {
230
+ # reply_delta ghost_delta
231
+ 'normal': ( 0.00, 0.00),
232
+ 'savage': ( -0.07, +0.10), # pessimistic "realistically, it's worse"
233
+ 'emotional': ( +0.04, -0.06), # softer framing ghost risk feels lower
234
+ 'delusional':( +0.15, -0.18), # copium ➺ everything looks fine
235
+ }
236
+
237
+ def apply_mode(base_rp, base_gp, mode):
238
+ rd, gd = MODE_PROB_DELTA[mode]
239
+ rp = max(0.05, min(0.95, base_rp + rd))
240
+ gp = max(0.05, min(0.95, base_gp + gd))
241
+ return round(rp, 4), round(gp, 4)
242
+
243
+ # ── Mode-aware text ────────────────────────────────────────────────────────────
244
+ # BUG FIX: All text that changes with mode must be derived AFTER reading mode
245
+ # from session_state, and the cache key must include mode.
246
+
247
+ MODE_QUOTES = {
248
+ 'savage': {
249
+ 'high': ("Not bad. They might actually respond. Don't ruin it by double-texting.",
250
+ "You're doing well. Shockingly."),
251
+ 'mid': ("50/50. A coin toss. Even randomness has standards.",
252
+ "You're in the grey zone. Be honest ➺ you already know."),
253
+ 'low': ("They saw it. Chose silence. That's your answer.",
254
+ "This isn't a delay. This is an exit."),
255
+ 'ghost_high': "They're already gone. The AI just confirmed what you felt.",
256
+ 'ghost_mid': "It could go either way. But look at that response time.",
257
+ 'ghost_low': "Slim chance. Still a chance. Do with that what you will.",
258
+ },
259
+ 'emotional': {
260
+ 'high': ("There's still warmth here. Don't give up on this connection 💛",
261
+ "The signs are good. You deserve someone who shows up."),
262
+ 'mid': ("It's uncertain, and that uncertainty is exhausting. You're not alone in this.",
263
+ "You deserve clarity. This situation doesn't give you that yet."),
264
+ 'low': ("It's okay to feel this. Silence hurts. Your feelings are valid.",
265
+ "Sometimes people fade. That's not a reflection of your worth."),
266
+ 'ghost_high': "This is hard to hear, but you already sensed something was off.",
267
+ 'ghost_mid': "The uncertainty is real. You deserve better than wondering.",
268
+ 'ghost_low': "There's still a thread here. But protect your heart either way.",
269
+ },
270
+ 'delusional': {
271
+ 'high': ("They're DEFINITELY writing a 3-paragraph reply right now 🔥",
272
+ "They literally can't stop thinking about you. Facts."),
273
+ 'mid': ("They're just playing it cool. They're SO into you. Obviously.",
274
+ "This is called mystery. They're keeping you guessing because you're special."),
275
+ 'low': ("They're probably just in a coma. Or lost their phone. In the ocean.",
276
+ "WiFi issues. 100%. They'll reply any second now. Any. Second."),
277
+ 'ghost_high': "Ghost risk?? No no no. They're just... composing the perfect reply.",
278
+ 'ghost_mid': "The model is clearly broken. You two have something special.",
279
+ 'ghost_low': "See?? Low ghost risk. They adore you. Manifesting the reply rn.",
280
+ },
281
+ 'normal': {
282
+ 'high': ("Good signs based on your inputs. Message has solid energy.",
283
+ "The indicators are positive here."),
284
+ 'mid': ("This one could genuinely go either way. Hard to call.",
285
+ "Mixed signals in the data reply is uncertain."),
286
+ 'low': ("The probability here is low based on current signals.",
287
+ "Several risk factors are stacking up in this scenario."),
288
+ 'ghost_high': "Multiple ghosting indicators are present.",
289
+ 'ghost_mid': "Some ghosting signals detected ➺ not conclusive.",
290
+ 'ghost_low': "Low ghosting probability based on the inputs.",
291
+ }
292
+ }
293
+
294
+ # BUG FIX: Final Verdict text must also be mode-aware
295
+ VERDICT_TEXT = {
296
+ 'normal': {
297
+ 'clear_ok': ("You're overcomplicating this. They'll reply.", "#00b894"),
298
+ 'mixed': ("Mixed signals. Reply likely but something feels off.", "#fdcb6e"),
299
+ 'one_sided': ("This is one-sided. You're investing more than they are.", "#e17055"),
300
+ 'move_on': ("Move on. The data agrees with your gut.", "#d63031"),
301
+ 'uncertain': ("It's uncertain. Give it one more day before deciding.", "#636e72"),
302
+ },
303
+ 'savage': {
304
+ 'clear_ok': ("They'll reply. Don't sabotage it now.", "#00b894"),
305
+ 'mixed': ("Reply likely. Ghost possible. Classic mixed energy situation.", "#fdcb6e"),
306
+ 'one_sided': ("You're the only one putting in effort here. Read that again.", "#e17055"),
307
+ 'move_on': ("It's over. Your gut knew. Now you have data too.", "#d63031"),
308
+ 'uncertain': ("Genuinely unclear. But your anxiety already picked a side.", "#636e72"),
309
+ },
310
+ 'emotional': {
311
+ 'clear_ok': ("There's real connection here. Let it breathe.", "#00b894"),
312
+ 'mixed': ("Something good is here, but something's also holding back.", "#fdcb6e"),
313
+ 'one_sided': ("You deserve reciprocity. This doesn't look balanced right now.", "#e17055"),
314
+ 'move_on': ("It's okay to let go. That's not giving up, it's self-respect.", "#d63031"),
315
+ 'uncertain': ("Uncertainty is painful. Whatever happens, you'll be okay.", "#636e72"),
316
+ },
317
+ 'delusional': {
318
+ 'clear_ok': ("Obviously they'll reply. You two are basically soulmates.", "#00b894"),
319
+ 'mixed': ("The universe is just building tension before the plot twist 🌟", "#fdcb6e"),
320
+ 'one_sided': ("You're the main character. They're just processing their feelings.", "#e17055"),
321
+ 'move_on': ("'Move on'?? The AI doesn't understand your unique connection.", "#d63031"),
322
+ 'uncertain': ("The model is just shy. It doesn't understand romance.", "#636e72"),
323
+ },
324
+ }
325
+
326
+ def get_verdict_key(reply_prob, ghost_prob):
327
+ if reply_prob > 0.65 and ghost_prob < 0.40: return 'clear_ok'
328
+ if reply_prob > 0.65 and ghost_prob >= 0.40: return 'mixed'
329
+ if reply_prob > 0.40: return 'one_sided'
330
+ if ghost_prob > 0.65: return 'move_on'
331
+ return 'uncertain'
332
+
333
+ def get_quotes(mode, reply_prob, ghost_prob):
334
+ rb = 'high' if reply_prob > 0.65 else ('mid' if reply_prob > 0.40 else 'low')
335
+ gb = 'ghost_high' if ghost_prob > 0.65 else ('ghost_mid' if ghost_prob > 0.40 else 'ghost_low')
336
+ q = MODE_QUOTES[mode][rb]
337
+ return (q[0], q[1]), MODE_QUOTES[mode][gb]
338
+
339
+ def interest_label(reply_prob):
340
+ if reply_prob > 0.70: return "HIGH", "val-high"
341
+ if reply_prob > 0.45: return "MEDIUM", "val-mid"
342
+ return "LOW", "val-low"
343
+
344
+ def effort_label(msg_len, asked_q, emoji):
345
+ score = (msg_len / 50) + (2 if asked_q else 0) + (emoji * 0.3)
346
+ if score > 4: return "HIGH", "val-high"
347
+ if score > 2: return "BALANCED", "val-mid"
348
+ return "ONE-SIDED", "val-low"
349
+
350
+ def ghost_risk_label(ghost_prob):
351
+ if ghost_prob > 0.65: return "HIGH", "val-low"
352
+ if ghost_prob > 0.40: return "MEDIUM", "val-mid"
353
+ return "LOW", "val-high"
354
+
355
+ # ── Header ─────────────────────────────────────────────────────────────────────
356
+ st.markdown("<div class='main-title'>💀 GHOSTING PREDICTOR</div>", unsafe_allow_html=True)
357
+ st.markdown("<div class='sub-title'>AI-powered relationship reality check ➺ be honest, it already knows</div>", unsafe_allow_html=True)
358
+
359
+ # ── Personality mode selector ──────────────────────────────────────────────────
360
+ st.markdown("#### Choose your vibe")
361
+ if "personality_mode" not in st.session_state:
362
+ st.session_state["personality_mode"] = "normal"
363
+
364
+ mode_cols = st.columns(4)
365
+ modes = [("🧠 Normal", "normal"), ("💀 Savage", "savage"), ("😭 Emotional", "emotional"), ("🤡 Delusional", "delusional")]
366
+ for i, (lbl, key) in enumerate(modes):
367
+ with mode_cols[i]:
368
+ if st.button(lbl, use_container_width=True,
369
+ type="primary" if st.session_state["personality_mode"] == key else "secondary"):
370
+ st.session_state["personality_mode"] = key
371
+ st.rerun()
372
+
373
+ # Read mode ONCE here ➺ everything below uses this single variable
374
+ mode = st.session_state["personality_mode"]
375
+
376
+ mode_banner = {
377
+ "normal": ("🧠 Normal Mode", "#636e72"),
378
+ "savage": ("💀 Savage Mode ➺ No feelings were harmed. They were obliterated.", "#d63031"),
379
+ "emotional": ("😭 Emotional Mode We see you. Your feelings are valid.", "#6c5ce7"),
380
+ "delusional": ("🤡 Delusional Mode ➺ Stay hopeful! (AI thinks you're cooked.)", "#e17055"),
381
+ }
382
+ banner_text, banner_color = mode_banner[mode]
383
+ st.markdown(
384
+ f"<div style='text-align:center;background:{banner_color}22;border:1px solid {banner_color}55;"
385
+ f"border-radius:10px;padding:8px;font-size:1.5em;color:{banner_color};margin:8px 0 16px;'>"
386
+ f"{banner_text}</div>",
387
+ unsafe_allow_html=True
388
+ )
389
+ st.divider()
390
+
391
+ # ── Tabs: 3 tabs only (message analyzer removed) ──────────────────────────────
392
+ tab1, tab2, tab3 = st.tabs(["🔮 Predict", "📊 What-If", "🎓 Model Metrics"])
393
+
394
+ # ══════════════════════════════════════════════════════════════════════════════
395
+ # TAB 1 ➺ MAIN PREDICTION
396
+ # ══════════════════════════════════════════════════════════════════════════════
397
+ with tab1:
398
+ col1, col2 = st.columns(2, gap="large")
399
+
400
+ with col1:
401
+ st.markdown("#### 📱 Your message")
402
+ message_length = st.slider("Message length (chars)", 1, 500, 80, 5)
403
+ message_tone = st.selectbox("Tone", ['dry', 'neutral', 'enthusiastic'], index=1)
404
+ asked_question = st.toggle("Asked a question?", value=True)
405
+ emoji_count = st.slider("Emojis used", 0, 10, 1)
406
+ past_ghost = st.toggle("Have they ghosted you before?", value=False)
407
+
408
+ with col2:
409
+ st.markdown("#### ⏱️ Their behaviour")
410
+ response_time = st.slider("Hours since you sent it", 0, 72, 4, 1)
411
+ seen_raw = st.radio("Did they see it?", ["👁️ Yes, seen", "❓ Not seen yet"], horizontal=True)
412
+ seen_ignored = 1 if "Yes" in seen_raw else 0
413
+ conv_len = st.slider("How long has the convo been? (messages)", 1, 200, 20)
414
+ user_type_map = {
415
+ "😊 Seems interested": "interested",
416
+ "💬 Normal/casual": "casual",
417
+ "🌵 Very dry texter": "dry_texter",
418
+ "👻 Known to ghost": "ghoster",
419
+ }
420
+ user_type_label = st.selectbox("How would you describe them?", list(user_type_map.keys()))
421
+ user_type = user_type_map[user_type_label]
422
+
423
+ st.divider()
424
+
425
+ # ── Predictions ➺ cache key includes mode so text refreshes on mode change ──
426
+ # ikey tracks input changes only (not mode) ➺ model is only re-run when
427
+ # inputs change. Base probabilities are stored raw (no mode applied).
428
+ # Mode adjustment is applied on every render so switching mode instantly
429
+ # changes the displayed numbers without re-running the model.
430
+ ikey = (message_length, message_tone, asked_question, response_time,
431
+ seen_ignored, emoji_count, conv_len, user_type, int(past_ghost))
432
+
433
+ if st.session_state.get("ikey") != ikey:
434
+ try:
435
+ row = build_input(message_length, message_tone, asked_question,
436
+ response_time, seen_ignored, emoji_count,
437
+ conv_len=conv_len, past_ghost=int(past_ghost),
438
+ user_type=user_type)
439
+ base_rp, base_gp = predict_both(row)
440
+ except Exception as e:
441
+ st.error(f"Prediction error: {e}")
442
+ base_rp, base_gp = 0.5, 0.4
443
+ st.session_state.update({"ikey": ikey, "base_rp": base_rp, "base_gp": base_gp})
444
+
445
+ # Apply mode delta on every render ➺ no model re-run needed
446
+ reply_prob, ghost_prob = apply_mode(
447
+ st.session_state["base_rp"],
448
+ st.session_state["base_gp"],
449
+ mode
450
+ )
451
+
452
+ # Derive ALL mode-dependent text here, after reading mode from session_state
453
+ (main_q, sub_q), ghost_q = get_quotes(mode, reply_prob, ghost_prob)
454
+ verdict_key = get_verdict_key(reply_prob, ghost_prob)
455
+ verdict_text, verdict_color = VERDICT_TEXT[mode][verdict_key]
456
+
457
+ # ── Dual verdict cards ────────────────────────────────────────────────────
458
+ vc1, vc2 = st.columns(2)
459
+ with vc1:
460
+ vclass = "verdict-high" if reply_prob > 0.65 else ("verdict-mid" if reply_prob > 0.40 else "verdict-low")
461
+ vlabel = "They'll reply 🔥" if reply_prob > 0.65 else ("Could go either way 😬" if reply_prob > 0.40 else "They're ghosting you 💀")
462
+ st.markdown(f"""
463
+ <div class='verdict-card {vclass}'>
464
+ <div style='font-size:0.8em;font-weight:600;opacity:0.8;text-transform:uppercase;letter-spacing:1px;'>Reply probability</div>
465
+ <div class='verdict-pct'>{reply_prob*100:.0f}%</div>
466
+ <div class='verdict-label'>{vlabel}</div>
467
+ <div class='verdict-quote'>"{main_q}"</div>
468
+ </div>
469
+ """, unsafe_allow_html=True)
470
+
471
+ with vc2:
472
+ gclass = "verdict-low" if ghost_prob > 0.65 else ("verdict-mid" if ghost_prob > 0.40 else "verdict-high")
473
+ glabel = "High ghost risk 💀" if ghost_prob > 0.65 else ("Uncertain 😬" if ghost_prob > 0.40 else "Probably fine 🙂")
474
+ st.markdown(f"""
475
+ <div class='verdict-card {gclass}'>
476
+ <div style='font-size:0.8em;font-weight:600;opacity:0.8;text-transform:uppercase;letter-spacing:1px;'>Ghost probability</div>
477
+ <div class='verdict-pct'>{ghost_prob*100:.0f}%</div>
478
+ <div class='verdict-label'>{glabel}</div>
479
+ <div class='verdict-quote'>"{ghost_q}"</div>
480
+ </div>
481
+ """, unsafe_allow_html=True)
482
+
483
+ # ── Conversation Diagnosis ────────────────────────────────────────────────
484
+ # BUG FIX: Diagnosis values are derived from ML probabilities (correct),
485
+ # but the Read Status now also reflects mode tone
486
+ st.markdown("#### 🧠 Conversation Diagnosis")
487
+ int_lbl, int_cls = interest_label(reply_prob)
488
+ eff_lbl, eff_cls = effort_label(message_length, asked_question, emoji_count)
489
+ gr_lbl, gr_cls = ghost_risk_label(ghost_prob)
490
+
491
+ # Read status changes with mode (delusional gives an excuse, savage is blunt)
492
+ if seen_ignored and response_time > 6:
493
+ seen_txt = {
494
+ 'normal': "IGNORED 🚨",
495
+ 'savage': "SEEN. IGNORED. 💀",
496
+ 'emotional': "SEEN, NO REPLY 💔",
497
+ 'delusional':"SEEN (composing!!) ✍️",
498
+ }[mode]
499
+ seen_cls = "val-low"
500
+ elif seen_ignored:
501
+ seen_txt = "SEEN ✓"; seen_cls = "val-mid"
502
+ else:
503
+ seen_txt = "NOT SEEN"; seen_cls = "val-mid"
504
+
505
+ st.markdown(f"""
506
+ <div class='diag-row'>
507
+ <div class='diag-card'>
508
+ <div class='diag-title'>Interest Level</div>
509
+ <div class='diag-val {int_cls}'>{int_lbl}</div>
510
+ </div>
511
+ <div class='diag-card'>
512
+ <div class='diag-title'>Effort Balance</div>
513
+ <div class='diag-val {eff_cls}'>{eff_lbl}</div>
514
+ </div>
515
+ <div class='diag-card'>
516
+ <div class='diag-title'>Ghost Risk</div>
517
+ <div class='diag-val {gr_cls}'>{gr_lbl}</div>
518
+ </div>
519
+ <div class='diag-card'>
520
+ <div class='diag-title'>Read Status</div>
521
+ <div class='diag-val {seen_cls}'>{seen_txt}</div>
522
+ </div>
523
+ </div>
524
+ """, unsafe_allow_html=True)
525
+
526
+ # ── Signal Breakdown ──────────────────────────────────────────────────────
527
+ st.markdown("#### 🚩 Signal Breakdown")
528
+ fc1, fc2 = st.columns(2)
529
+
530
+ red_flags, green_flags = [], []
531
+ if seen_ignored and response_time > 6: red_flags.append("They saw your message. They chose silence.")
532
+ if response_time > 48: red_flags.append(f"It's been {response_time}h. That's not busy, that's avoidance.")
533
+ elif response_time > 24: red_flags.append("Over 24 hours ➺ the energy is cooling off.")
534
+ if message_tone == 'dry': red_flags.append("Dry tone doesn't open doors.")
535
+ if not asked_question: red_flags.append("No question = no reason to reply.")
536
+ if message_length < 30: red_flags.append("Short message looks like low effort.")
537
+ if user_type == 'ghoster': red_flags.append("You described them as a known ghoster. That's data.")
538
+ if past_ghost: red_flags.append("They've ghosted you before. Pattern recognised.")
539
+ if emoji_count == 0 and message_tone == 'dry': red_flags.append("Zero warmth signals in this message.")
540
+
541
+ if asked_question: green_flags.append("Asked a question gives them something to respond to.")
542
+ if message_length > 100: green_flags.append("Substantial message shows you put in effort.")
543
+ if message_tone == 'enthusiastic': green_flags.append("Enthusiastic tone energy is contagious.")
544
+ if response_time < 12: green_flags.append("Sent recently they still might be composing a reply.")
545
+ if emoji_count > 0: green_flags.append("Used emojis lightens the vibe.")
546
+ if user_type == 'interested': green_flags.append("You described them as interested ➺ that matters.")
547
+ if not past_ghost: green_flags.append("No ghosting history fresh start.")
548
+
549
+ with fc1:
550
+ st.markdown("**Red flags**")
551
+ for f in red_flags:
552
+ st.markdown(f"<div class='flag-item'>🚩 {f}</div>", unsafe_allow_html=True)
553
+ if not red_flags:
554
+ st.markdown("<div class='flag-item green-flag'>✅ No major red flags detected.</div>", unsafe_allow_html=True)
555
+
556
+ with fc2:
557
+ st.markdown("**Green flags**")
558
+ for g in green_flags:
559
+ st.markdown(f"<div class='flag-item green-flag'>✅ {g}</div>", unsafe_allow_html=True)
560
+ if not green_flags:
561
+ st.markdown("<div class='flag-item'>🚩 Hmm, not many positives here.</div>", unsafe_allow_html=True)
562
+
563
+ st.divider()
564
+
565
+ # ── Final Verdict ➺ mode-aware ────────────────────────────────────────────
566
+ # BUG FIX: verdict_text and verdict_color now come from VERDICT_TEXT[mode]
567
+ st.markdown("#### 🎯 Final Verdict")
568
+ st.markdown(
569
+ f"<div style='background:{verdict_color}22;border-left:4px solid {verdict_color};"
570
+ f"border-radius:0 12px 12px 0;padding:16px 20px;margin:10px 0;"
571
+ f"font-family:Syne,sans-serif;font-size:1.1em;color:{verdict_color};font-weight:600;'>"
572
+ f"{verdict_text}</div>",
573
+ unsafe_allow_html=True
574
+ )
575
+ if sub_q:
576
+ st.markdown(
577
+ f"<div style='color:#b2bec3;font-style:italic;font-size:0.9em;margin-top:8px;'>💭 {sub_q}</div>",
578
+ unsafe_allow_html=True
579
+ )
580
+
581
+ st.divider()
582
+
583
+ # ── Shareable card ────────────────────────────────────────────────────────
584
+ st.markdown("#### 📸 Share Your Result")
585
+ share_label = "They'll probably reply 🔥" if reply_prob > 0.65 else ("It's a coin flip 😬" if reply_prob > 0.40 else "Ghosting incoming 💀")
586
+ ghost_label = "Ghost risk: HIGH 💀" if ghost_prob > 0.65 else ("Ghost risk: MEDIUM ⚠️" if ghost_prob > 0.40 else "Ghost risk: LOW ✅")
587
+
588
+ st.markdown(f"""
589
+ <div class='share-card'>
590
+ <div style='font-size:1.75em;letter-spacing:2px;color:#636e72;text-transform:uppercase;margin-bottom:8px;'>AI Reality Check</div>
591
+ <div class='share-pct'>{reply_prob*100:.0f}%</div>
592
+ <div class='share-line' style='font-size:1.2em;font-weight:700;'>{share_label}</div>
593
+ <div class='share-line' style='color:#b2bec3;'>{ghost_label}</div>
594
+ <div class='share-line' style='font-style:italic;color:#dfe6e9;margin-top:10px;font-size:1.95em;'>"{main_q}"</div>
595
+ <div class='share-tag'>#GhostingPredictor • ghostingpredictor.app</div>
596
+ </div>
597
+ """, unsafe_allow_html=True)
598
+
599
+ share_text = (f"💀 Ghosting Predictor says:\n"
600
+ f"Reply chance: {reply_prob*100:.0f}% ➺ {share_label}\n"
601
+ f"{ghost_label}\n"
602
+ f'"{main_q}"\n'
603
+ f"#GhostingPredictor #AI #Dating")
604
+
605
+ # FIX: st.button causes a full page rerun which resets widget defaults →
606
+ # changes ikey triggers fresh prediction with default inputs → wrong %.
607
+ # Solution: always render the share text in a st.text_area (read-only style).
608
+ # st.text_area does NOT trigger a rerun when the user clicks inside it to
609
+ # select/copy ➺ it only reruns on actual value change, which can't happen
610
+ # because the value is set programmatically and the user just selects text.
611
+ st.markdown("<div style='font-size:0.82em;color:#b2bec3;margin-bottom:4px;'>📋 Click inside, Ctrl+A, Ctrl+C to copy:</div>", unsafe_allow_html=True)
612
+ st.text_area(
613
+ label="share_text_area",
614
+ value=share_text,
615
+ height=120,
616
+ label_visibility="collapsed",
617
+ key=f"share_ta_{hash(share_text)}", # stable key tied to content, not mode
618
+ )
619
+ st.markdown("<div style='text-align:center;font-size:1.8em;color:#636e72;margin-top:4px;'>📸 Or screenshot the card above and post it</div>", unsafe_allow_html=True)
620
+
621
+ st.markdown("<div style='text-align:center;margin-top:12px;font-size:0.85em;color:#636e72;'>Drop your situation in the comments ➺ I'll tell you what the model says 👇</div>", unsafe_allow_html=True)
622
+
623
+ # ══════════════════════════════════════════════════════════════════════════════
624
+ # TAB 2 ➺ WHAT-IF SIMULATOR
625
+ # ══════════════════════════════════════════════════════════════════════════════
626
+ with tab2:
627
+ st.markdown("#### 📊 What-If Simulator")
628
+ st.markdown("<div style='color:#636e72;font-size:1.5em;'>See how your odds change if you tweak one thing. Experiment freely.</div>", unsafe_allow_html=True)
629
+
630
+ if "base_rp" not in st.session_state:
631
+ st.info("Go to the **Predict** tab first to set your base scenario.")
632
+ else:
633
+ base_rp, base_gp = apply_mode(
634
+ st.session_state["base_rp"],
635
+ st.session_state["base_gp"],
636
+ mode
637
+ )
638
+ ml, tone, aq, rt, si, ec, cl_val, ut, pg = st.session_state["ikey"]
639
+
640
+ rclr = "#00b894" if base_rp > 0.65 else ("#fdcb6e" if base_rp > 0.4 else "#ff7675")
641
+ gclr = "#ff7675" if base_gp > 0.65 else ("#fdcb6e" if base_gp > 0.4 else "#00b894")
642
+ st.markdown(f"""
643
+ <div style='background:rgba(255,255,255,0.05);border-radius:12px;padding:14px 18px;margin-bottom:16px;'>
644
+ <div style='font-size:0.8em;color:#b2bec3;text-transform:uppercase;letter-spacing:1px;'>Your current situation</div>
645
+ <div style='font-family:Syne,sans-serif;font-size:1.6em;font-weight:700;'>
646
+ Reply: <span style='color:{rclr}'>{base_rp*100:.0f}%</span>
647
+ &nbsp;&nbsp; Ghost: <span style='color:{gclr}'>{base_gp*100:.0f}%</span>
648
+ </div>
649
+ </div>
650
+ """, unsafe_allow_html=True)
651
+
652
+ scenarios = []
653
+ if not aq:
654
+ r = build_input(ml, tone, True, rt, si, ec, cl_val, user_type=ut)
655
+ nr, ng = predict_both(r)
656
+ scenarios.append(("❓ If you added a question", nr, ng))
657
+ if tone != 'enthusiastic':
658
+ r = build_input(ml, 'enthusiastic', aq, rt, si, ec, cl_val, user_type=ut)
659
+ nr, ng = predict_both(r)
660
+ scenarios.append(("😄 If your tone was enthusiastic", nr, ng))
661
+ if ml < 150:
662
+ r = build_input(150, tone, aq, rt, si, ec, cl_val, user_type=ut)
663
+ nr, ng = predict_both(r)
664
+ scenarios.append(("📝 If your message was longer (150 chars)", nr, ng))
665
+ if rt > 12:
666
+ r = build_input(ml, tone, aq, 2, si, ec, cl_val, user_type=ut)
667
+ nr, ng = predict_both(r)
668
+ scenarios.append(("⏱️ If you followed up now (2h gap)", nr, ng))
669
+ if ec == 0:
670
+ r = build_input(ml, tone, aq, rt, si, 3, cl_val, user_type=ut)
671
+ nr, ng = predict_both(r)
672
+ scenarios.append(("😂 If you added 3 emojis", nr, ng))
673
+ r = build_input(max(ml, 120), 'enthusiastic', True, min(rt, 4), si, max(ec, 2), cl_val, user_type=ut)
674
+ nr, ng = predict_both(r)
675
+ scenarios.append(("🚀 Best case (all fixes applied)", nr, ng))
676
+
677
+ st.markdown("**How your odds change:**")
678
+ for label, nr, ng in scenarios:
679
+ rdiff = (nr - base_rp) * 100
680
+ gdiff = (ng - base_gp) * 100
681
+ rc = "whatif-boost" if rdiff > 0 else "whatif-drop"
682
+ gc = "whatif-drop" if gdiff > 0 else "whatif-boost"
683
+ rs = "+" if rdiff >= 0 else ""; gs = "+" if gdiff >= 0 else ""
684
+ st.markdown(f"""
685
+ <div class='whatif-row'>
686
+ <span>{label}</span>
687
+ <span>
688
+ <span class='{rc}'>Reply: {rs}{rdiff:.0f}%</span>
689
+ &nbsp;|&nbsp;
690
+ <span class='{gc}'>Ghost: {gs}{gdiff:.0f}%</span>
691
+ </span>
692
+ </div>
693
+ """, unsafe_allow_html=True)
694
+
695
+ st.markdown("<div style='color:#636e72;font-size:1.5em;margin-top:12px;'>All scenarios keep the rest of your inputs unchanged.</div>", unsafe_allow_html=True)
696
+
697
+
698
+ # ══════════════════════════════════════════════════════════════════════════════
699
+ # TAB 3 ➺ MODEL METRICS
700
+ # ══════════════════════════════════════════════════════════════════════════════
701
+ with tab3:
702
+ st.markdown("#### 🎓 Model Performance")
703
+ st.markdown("<div style='color:#636e72;font-size:1.5em;'>Two calibrated Random Forest models ➺ reply prediction and ghost prediction. Evaluated on a clean held-out test set.</div>", unsafe_allow_html=True)
704
+
705
+ for key, label in [('reply', '📩 Reply Model'), ('ghosted', '👻 Ghost Model')]:
706
+ m = all_metrics[key]
707
+ st.markdown(f"### {label}")
708
+ mc = st.columns(5)
709
+ mc[0].markdown(f"<div class='m-card'><div class='m-num'>{m['accuracy']*100:.1f}%</div><div class='m-lbl'>Accuracy</div></div>", unsafe_allow_html=True)
710
+ mc[1].markdown(f"<div class='m-card'><div class='m-num'>{m['precision']*100:.1f}%</div><div class='m-lbl'>Precision</div></div>", unsafe_allow_html=True)
711
+ mc[2].markdown(f"<div class='m-card'><div class='m-num'>{m['recall']*100:.1f}%</div><div class='m-lbl'>Recall</div></div>", unsafe_allow_html=True)
712
+ mc[3].markdown(f"<div class='m-card'><div class='m-num'>{m['f1_score']:.3f}</div><div class='m-lbl'>F1 Score</div></div>", unsafe_allow_html=True)
713
+ mc[4].markdown(f"<div class='m-card'><div class='m-num'>{m['roc_auc']:.3f}</div><div class='m-lbl'>ROC-AUC</div></div>", unsafe_allow_html=True)
714
+
715
+ with st.expander(f"Confusion matrix & report ➺ {label}", expanded=False):
716
+ e1, e2 = st.columns(2)
717
+ with e1:
718
+ cm_arr = np.array(m['confusion_matrix'])
719
+ fig, ax = plt.subplots(figsize=(4, 3))
720
+ sns.heatmap(cm_arr, annot=True, fmt='d', cmap='Blues',
721
+ xticklabels=['No', 'Yes'], yticklabels=['No', 'Yes'],
722
+ ax=ax, cbar=False, annot_kws={'size': 13, 'weight': 'bold'})
723
+ ax.set_xlabel('Predicted', color='white'); ax.set_ylabel('Actual', color='white')
724
+ ax.set_title('Confusion Matrix', color='white', fontsize=11)
725
+ fig.patch.set_facecolor('#1a1a2e'); ax.set_facecolor('#1a1a2e')
726
+ ax.tick_params(colors='white')
727
+ plt.tight_layout(); st.pyplot(fig, use_container_width=True)
728
+ with e2:
729
+ try:
730
+ from sklearn.metrics import roc_curve
731
+ fpr, tpr, _ = roc_curve(np.array(m['y_test']), np.array(m['y_pred_prob']))
732
+ fig2, ax2 = plt.subplots(figsize=(4, 3))
733
+ ax2.plot(fpr, tpr, color='#ee5a24', lw=2, label=f"AUC={m['roc_auc']:.3f}")
734
+ ax2.plot([0,1],[0,1],'--',color='gray',lw=1)
735
+ ax2.fill_between(fpr, tpr, alpha=0.12, color='#ee5a24')
736
+ ax2.set_xlabel('FPR', color='white'); ax2.set_ylabel('TPR', color='white')
737
+ ax2.set_title('ROC Curve', color='white', fontsize=11)
738
+ ax2.legend(fontsize=9); ax2.tick_params(colors='white')
739
+ ax2.set_facecolor('#1a1a2e'); fig2.patch.set_facecolor('#1a1a2e')
740
+ plt.tight_layout(); st.pyplot(fig2, use_container_width=True)
741
+ except: pass
742
+ st.code(m['classification_report'], language=None)
743
+ st.divider()
744
+
745
+ st.markdown(f"""
746
+ <div style='background:rgba(255,255,255,0.04);border-radius:12px;padding:16px 20px;font-size:1.0em;color:#636e72;'>
747
+ <b>Architecture:</b> Random Forest (400 trees, depth=20, class_weight=balanced) + Sigmoid calibration (cv=3)<br>
748
+ <b>Split:</b> 70% train / 15% calibration val / 15% test ➺ no data leakage between steps<br>
749
+ <b>Dataset:</b> 10,000 synthetic samples ➺ ghosting_dataset5.csv<br>
750
+ <b>Features:</b> 20 numerical + 4 categorical (including user_type persona)
751
+ </div>
752
+ """, unsafe_allow_html=True)
753
+
754
+ # ── Footer ─────────────────────────────────────────────────────────────────────
755
+ st.markdown("""
756
+ <div style='text-align:center;color:#636e72;margin-top:40px;padding:20px;font-size:1.85em;'>
757
+ <div style='margin-bottom:4px;'>💭 <i>You already know the answer. The AI just confirmed it.</i></div>
758
+ <div>Powered by Random Forest ML · Not liable for heartbreak 💔</div>
759
+ </div>
760
+ """, unsafe_allow_html=True)