Israelbliz commited on
Commit
79bb546
Β·
verified Β·
1 Parent(s): 5cdc85a

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +719 -0
app.py ADDED
@@ -0,0 +1,719 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User Modeling Agent β€” the demo.
2
+
3
+ DSN Γ— BCT LLM Agent Challenge Β· Task A.
4
+
5
+ Takes a user persona and product details as input, and generates a star
6
+ rating and a written review as that user would write it β€” then critiques
7
+ and revises its own draft (self-reflection). Optionally renders the review
8
+ in Nigerian English.
9
+
10
+ Two ways to use it:
11
+ 1. Compose a persona β€” type a persona + product (the brief's input contract)
12
+ 2. Dataset reader β€” pick a real user, compare against ground truth
13
+
14
+ Run:
15
+ streamlit run app.py
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import html
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ ROOT = Path(__file__).resolve().parent
24
+ if str(ROOT) not in sys.path:
25
+ sys.path.insert(0, str(ROOT))
26
+
27
+ import pandas as pd
28
+ import streamlit as st
29
+
30
+ from core.config import settings
31
+ from core.persona import PersonaEngine, UserPersona
32
+ from task_a_user_modeling.agent import ImpersonationAgent, ItemInput
33
+
34
+ st.set_page_config(page_title="User Modeling Agent", page_icon="✢",
35
+ layout="wide", initial_sidebar_state="expanded")
36
+
37
+ esc = html.escape
38
+
39
+
40
+ # ══════════════════════════════════════════════════════════════════════════════
41
+ # Design system
42
+ # ══════════════════════════════════════════════════════════════════════════════
43
+
44
+ CSS = """
45
+ <style>
46
+ @import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,900&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=Spline+Sans+Mono:wght@400;500;600&display=swap');
47
+
48
+ :root {
49
+ --paper:#f3ecdb; --paper-2:#fffdf6; --paper-3:#ece2cb;
50
+ --pine:#1d3a2b; --pine-2:#2c5440; --pine-ink:#14241b;
51
+ --clay:#b0472b; --ochre:#c98a3c; --gold:#d8a64a;
52
+ --ink:#221e16; --muted:#6f6651; --hair:#d4c8aa;
53
+ }
54
+ .stApp { background:var(--paper); color:var(--ink); }
55
+ .stApp::before {
56
+ content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
57
+ background:
58
+ radial-gradient(900px 600px at 12% -5%, rgba(45,84,64,.10), transparent 60%),
59
+ radial-gradient(800px 600px at 95% 8%, rgba(176,71,43,.08), transparent 55%);
60
+ }
61
+ [data-testid="stMainBlockContainer"] { max-width:1140px; padding-top:2rem; padding-bottom:4rem; }
62
+ h1,h2,h3,h4 { font-family:'Fraunces',Georgia,serif !important; color:var(--pine) !important;
63
+ letter-spacing:-0.015em; font-weight:600 !important; }
64
+ html,body,p,div,span,label,li,.stMarkdown { font-family:'Newsreader',Georgia,serif; }
65
+ .stCaption,[data-testid="stCaptionContainer"] { font-family:'Spline Sans Mono',monospace !important; }
66
+
67
+ .masthead { position:relative; z-index:1; margin-bottom:0.3rem; }
68
+ .mast-rule { height:2px; background:var(--pine); margin-bottom:0.5rem; }
69
+ .mast-kicker { font-family:'Spline Sans Mono',monospace; font-size:0.70rem;
70
+ letter-spacing:0.30em; text-transform:uppercase; color:var(--clay); font-weight:600; }
71
+ .mast-title { font-family:'Fraunces',serif; font-weight:900;
72
+ font-size:clamp(2.3rem,5.5vw,3.7rem); line-height:1.0; color:var(--pine);
73
+ margin:0.16rem 0 0.1rem; letter-spacing:-0.03em; }
74
+ .mast-title .em { color:var(--clay); font-style:italic; font-weight:500; }
75
+ .mast-stand { font-family:'Newsreader',serif; font-size:1.08rem; color:#45402f;
76
+ max-width:66ch; line-height:1.45; }
77
+ .mast-stand em { color:var(--clay); font-style:italic; }
78
+ .mast-rule-bot { height:1px; background:var(--hair); margin:0.85rem 0 0.2rem; }
79
+
80
+ .sec-label { font-family:'Spline Sans Mono',monospace; font-size:0.70rem;
81
+ letter-spacing:0.2em; text-transform:uppercase; color:var(--clay);
82
+ font-weight:600; margin:0.3rem 0 0.15rem; }
83
+
84
+ .card { background:var(--paper-2); border:1px solid var(--hair); border-radius:3px;
85
+ padding:1.1rem 1.3rem; margin:0.5rem 0 0.85rem; position:relative; z-index:1; }
86
+ .card-kicker { font-family:'Spline Sans Mono',monospace; font-size:0.64rem;
87
+ letter-spacing:0.2em; text-transform:uppercase; color:var(--clay);
88
+ font-weight:600; margin-bottom:0.5rem; }
89
+
90
+ .persona-quote { font-family:'Fraunces',serif; font-weight:500; font-style:italic;
91
+ font-size:1.26rem; line-height:1.34; color:var(--pine); margin:0.1rem 0 0.8rem;
92
+ padding-left:0.8rem; border-left:3px solid var(--ochre); }
93
+ .pstats { display:flex; gap:1.7rem; flex-wrap:wrap; align-items:flex-end; }
94
+ .pstat .num { font-family:'Fraunces',serif; font-weight:900; font-size:1.5rem;
95
+ color:var(--pine); line-height:1; }
96
+ .pstat .lab { font-family:'Spline Sans Mono',monospace; font-size:0.60rem;
97
+ letter-spacing:0.13em; text-transform:uppercase; color:var(--muted); margin-top:0.2rem; }
98
+ .chips { margin-top:0.6rem; }
99
+ .chip-lab { font-family:'Spline Sans Mono',monospace; font-size:0.60rem;
100
+ letter-spacing:0.12em; text-transform:uppercase; color:var(--muted); margin-right:0.4rem; }
101
+ .chip { display:inline-block; margin:0.15rem 0.25rem 0.15rem 0; padding:0.15rem 0.6rem;
102
+ border-radius:999px; font-family:'Spline Sans Mono',monospace; font-size:0.72rem;
103
+ background:var(--paper-3); color:var(--pine-2); border:1px solid var(--hair); }
104
+ .chip.warn { background:#f0ddd2; color:var(--clay); border-color:#e3c4b4; }
105
+
106
+ .panel { background:var(--pine-ink); border-radius:3px; padding:1.35rem 1.55rem;
107
+ margin:0.5rem 0 0.85rem; position:relative; z-index:1;
108
+ box-shadow:0 14px 34px -22px rgba(20,36,27,.7); }
109
+ .panel .card-kicker { color:var(--gold); }
110
+ .rating-row { display:flex; align-items:center; gap:0.8rem; margin:0.25rem 0 0.65rem; }
111
+ .rating-chip { font-family:'Fraunces',serif; font-weight:900; font-size:1.6rem;
112
+ background:var(--clay); color:#fff7ec; padding:0.05rem 0.65rem; border-radius:3px; }
113
+ .stars { font-size:1.15rem; letter-spacing:0.1em; color:var(--gold); }
114
+ .review-body { font-family:'Newsreader',serif; font-size:1.1rem; line-height:1.7;
115
+ color:#f0e9d6; white-space:pre-wrap; }
116
+ .naija-badge { display:inline-block; margin-left:0.45rem; font-family:'Spline Sans Mono',monospace;
117
+ font-size:0.60rem; letter-spacing:0.12em; font-weight:600; background:#e9f0e2;
118
+ color:var(--pine); padding:0.12rem 0.5rem; border-radius:999px; border:1px solid #cdd9bf; }
119
+
120
+ .stepper { display:flex; gap:0; margin:0.3rem 0 0.2rem; flex-wrap:wrap; }
121
+ .step { flex:1; min-width:125px; padding:0.5rem 0.65rem; position:relative; }
122
+ .step .dot { width:11px; height:11px; border-radius:50%; background:var(--pine); margin-bottom:0.35rem; }
123
+ .step.flag .dot { background:var(--clay); }
124
+ .step.pass .dot { background:var(--pine-2); }
125
+ .step .st-name { font-family:'Fraunces',serif; font-weight:600; font-size:0.93rem;
126
+ color:var(--pine); line-height:1.1; }
127
+ .step .st-sub { font-family:'Spline Sans Mono',monospace; font-size:0.63rem;
128
+ color:var(--muted); margin-top:0.18rem; }
129
+ .step:not(:last-child)::after { content:""; position:absolute; top:0.87rem; right:-2px;
130
+ width:100%; height:1px;
131
+ background:repeating-linear-gradient(90deg,var(--hair) 0 6px,transparent 6px 12px); }
132
+ .critique-note { font-family:'Newsreader',serif; font-style:italic; font-size:0.93rem;
133
+ color:#5a4030; line-height:1.45; background:#f0ddd2; border-left:3px solid var(--clay);
134
+ padding:0.5rem 0.75rem; border-radius:2px; margin-top:0.45rem; }
135
+
136
+ .cmp { background:var(--paper-2); border:1px solid var(--hair); border-radius:3px;
137
+ padding:0.9rem 1.05rem; height:100%; }
138
+ .cmp.truth { border-top:3px solid var(--pine-2); }
139
+ .cmp.agent { border-top:3px solid var(--clay); }
140
+ .cmp-head { font-family:'Spline Sans Mono',monospace; font-size:0.62rem;
141
+ letter-spacing:0.15em; text-transform:uppercase; color:var(--muted); margin-bottom:0.35rem; }
142
+ .cmp-body { font-family:'Newsreader',serif; font-size:0.97rem; line-height:1.5;
143
+ color:#4a4434; white-space:pre-wrap; }
144
+ .delta { font-family:'Spline Sans Mono',monospace; font-size:0.70rem; font-weight:600;
145
+ padding:0.16rem 0.55rem; border-radius:999px; }
146
+ .delta.good { background:#e3ecd9; color:var(--pine); }
147
+ .delta.mid { background:#f3e6c8; color:#8a6420; }
148
+ .delta.far { background:#f0d8cc; color:var(--clay); }
149
+
150
+ .empty { border:1px dashed var(--hair); border-radius:3px; padding:1.5rem; text-align:center;
151
+ font-family:'Newsreader',serif; font-style:italic; color:var(--muted); font-size:1rem;
152
+ background:rgba(255,253,246,.5); }
153
+
154
+ @keyframes rise { from{opacity:0;transform:translateY(13px);} to{opacity:1;transform:translateY(0);} }
155
+ .reveal { animation:rise 0.55s cubic-bezier(.2,.7,.2,1) both; }
156
+ .d1{animation-delay:.04s;} .d2{animation-delay:.13s;} .d3{animation-delay:.22s;}
157
+
158
+ .stButton > button { background:var(--pine); color:var(--paper); border:none; border-radius:3px;
159
+ font-family:'Spline Sans Mono',monospace; font-weight:600; font-size:0.82rem;
160
+ letter-spacing:0.05em; padding:0.55rem 1rem; }
161
+ .stButton > button:hover { background:var(--clay); color:#fff7ec; }
162
+ [data-testid="stSidebar"] { background:var(--pine-ink); border-right:1px solid #2c4133; }
163
+ [data-testid="stSidebar"] * { color:#e7e0cd; }
164
+ [data-testid="stSidebar"] h1,[data-testid="stSidebar"] h2,[data-testid="stSidebar"] h3 { color:#f3ecdb !important; }
165
+
166
+ /* toggle β€” colour ONLY the switch (track + knob), never the label row */
167
+ [data-testid="stSidebar"] [data-testid="stWidgetLabel"] ~ div [role="switch"],
168
+ [data-testid="stSidebar"] [data-baseweb="checkbox"] [role="switch"],
169
+ [data-testid="stSidebar"] div[role="switch"] {
170
+ background-color:#c9bf9f !important;
171
+ border:1.5px solid #d8a64a !important;
172
+ }
173
+ [data-testid="stSidebar"] [data-baseweb="checkbox"] [role="switch"][aria-checked="true"],
174
+ [data-testid="stSidebar"] div[role="switch"][aria-checked="true"] {
175
+ background-color:#d8a64a !important;
176
+ border-color:#e8c98a !important;
177
+ }
178
+ [data-testid="stSidebar"] [data-baseweb="checkbox"] [role="switch"] > div,
179
+ [data-testid="stSidebar"] div[role="switch"] > div {
180
+ background-color:#1d3a2b !important;
181
+ }
182
+ [data-testid="stSidebar"] [data-baseweb="checkbox"] [role="switch"][aria-checked="true"] > div,
183
+ [data-testid="stSidebar"] div[role="switch"][aria-checked="true"] > div {
184
+ background-color:#fffdf6 !important;
185
+ }
186
+ [data-baseweb="tab-list"] { gap:2.4rem; border-bottom:2px solid var(--pine); }
187
+ [data-baseweb="tab"] { font-family:'Fraunces',serif !important; font-weight:600;
188
+ padding-left:0.7rem; padding-right:0.7rem;
189
+ font-size:1.04rem; color:var(--muted); }
190
+ [data-baseweb="tab"][aria-selected="true"] { color:var(--pine) !important;
191
+ font-weight:900 !important; background:#ece2cb; border-radius:5px 5px 0 0; }
192
+ [data-baseweb="tab-highlight"] { background:var(--clay) !important; height:4px; }
193
+
194
+ /* form field contrast β€” fields were blending into the cream page */
195
+ .stTextInput input, .stTextArea textarea,
196
+ [data-baseweb="select"] > div, [data-baseweb="input"] {
197
+ background:#fffdf6 !important;
198
+ border:1px solid #c2b48f !important;
199
+ border-radius:4px !important;
200
+ color:#7d7560 !important;
201
+ font-weight:500 !important;
202
+ }
203
+ .stTextInput input:focus, .stTextArea textarea:focus {
204
+ border-color:var(--clay) !important;
205
+ box-shadow:0 0 0 1px var(--clay) !important;
206
+ }
207
+ .stTextInput input::placeholder, .stTextArea textarea::placeholder {
208
+ color:#9a917a !important;
209
+ font-weight:400 !important;
210
+ }
211
+ [data-testid="stExpander"] {
212
+ border:1px solid #c2b48f !important;
213
+ border-radius:5px !important;
214
+ background:#faf5e6 !important;
215
+ }
216
+ .foot { margin-top:2.2rem; padding-top:0.85rem; border-top:1px solid var(--hair);
217
+ font-family:'Spline Sans Mono',monospace; font-size:0.68rem; color:var(--muted); line-height:1.6; }
218
+ </style>
219
+ """
220
+ st.markdown(CSS, unsafe_allow_html=True)
221
+
222
+
223
+ # ══════════════════════════════════════════════════════════════════════════════
224
+ # HTML builders
225
+ # ══════════════════════════════════════════════════════════════════════════════
226
+
227
+ def stars(r: float) -> str:
228
+ f = int(round(r))
229
+ return "β˜…" * f + "β˜†" * (5 - f)
230
+
231
+
232
+ def persona_card(p: UserPersona) -> str:
233
+ themes = "".join(f'<span class="chip">{esc(t)}</span>'
234
+ for t in p.preferred_themes) or '<span class="chip">β€”</span>'
235
+ comps = "".join(f'<span class="chip warn">{esc(t)}</span>'
236
+ for t in p.common_complaints) or '<span class="chip warn">β€”</span>'
237
+ nrev = (f'{p.n_reviews}' if p.n_reviews else 'composed')
238
+ return f"""
239
+ <div class="card reveal d1">
240
+ <div class="card-kicker">The Person Β· persona</div>
241
+ <div class="persona-quote">β€œ{esc(p.voice_one_liner or 'No voice captured.')}”</div>
242
+ <div class="pstats">
243
+ <div class="pstat"><div class="num">{nrev}</div><div class="lab">history</div></div>
244
+ <div class="pstat"><div class="num">{p.avg_rating:.1f}β˜…</div><div class="lab">avg rating</div></div>
245
+ <div class="pstat"><div class="num">{esc(p.tone or 'β€”')}</div><div class="lab">tone</div></div>
246
+ </div>
247
+ <div class="chips"><span class="chip-lab">drawn to</span>{themes}</div>
248
+ <div class="chips"><span class="chip-lab">put off by</span>{comps}</div>
249
+ </div>"""
250
+
251
+
252
+ def reflection_stepper(iters: int, refined: bool, notes: list[str] | None) -> str:
253
+ steps = ['<div class="step pass"><div class="dot"></div>'
254
+ '<div class="st-name">First draft</div>'
255
+ '<div class="st-sub">generated in-voice</div></div>']
256
+ if refined:
257
+ steps += ['<div class="step flag"><div class="dot"></div>'
258
+ '<div class="st-name">Self-critique</div>'
259
+ '<div class="st-sub">found issues</div></div>',
260
+ '<div class="step pass"><div class="dot"></div>'
261
+ '<div class="st-name">Revised draft</div>'
262
+ '<div class="st-sub">rewritten with feedback</div></div>',
263
+ '<div class="step pass"><div class="dot"></div>'
264
+ '<div class="st-name">Re-checked</div>'
265
+ '<div class="st-sub">critique cleared</div></div>']
266
+ else:
267
+ steps += ['<div class="step pass"><div class="dot"></div>'
268
+ '<div class="st-name">Self-critique</div>'
269
+ '<div class="st-sub">passed first pass</div></div>',
270
+ '<div class="step pass"><div class="dot"></div>'
271
+ '<div class="st-name">Accepted</div>'
272
+ '<div class="st-sub">no revision needed</div></div>']
273
+ note = ""
274
+ if notes:
275
+ real = [n for n in notes if n and n.strip().lower() != "passed"]
276
+ if real:
277
+ note = f'<div class="critique-note">The critic flagged: {esc(real[0])}</div>'
278
+ return f"""
279
+ <div class="card reveal d3">
280
+ <div class="card-kicker">Self-reflection Β· {iters} critique cycle(s)</div>
281
+ <div class="stepper">{''.join(steps)}</div>
282
+ {note}
283
+ </div>"""
284
+
285
+
286
+ # ════════════════════════��═════════════════════════════════════════════════════
287
+ # Cached resources
288
+ # ══════════════════════════════════════════════════════════════════════════════
289
+
290
+ @st.cache_data(show_spinner=False)
291
+ def load_data():
292
+ rev = pd.read_parquet(settings.processed_dir / "reviews.parquet")
293
+ items = pd.read_parquet(settings.processed_dir / "items.parquet")
294
+ return rev, items
295
+
296
+
297
+ @st.cache_resource(show_spinner=False)
298
+ def get_engines():
299
+ return PersonaEngine(), ImpersonationAgent()
300
+
301
+
302
+ def composed_persona(desc: str, themes: list[str], dislikes: list[str],
303
+ tone: str, avg_rating: float) -> UserPersona:
304
+ """Build a UserPersona from typed input β€” the brief's persona-as-input contract."""
305
+ # rating distribution skewed around the stated average
306
+ lo, hi = int(avg_rating), min(5, int(avg_rating) + 1)
307
+ dist = {lo: 0.55, hi: 0.35} if lo != hi else {lo: 0.9}
308
+ dist.setdefault(3, 0.1)
309
+ return UserPersona(
310
+ user_id="composed", n_reviews=0, avg_rating=avg_rating,
311
+ std_rating=0.6, avg_review_length=90.0, std_review_length=30.0,
312
+ verified_rate=1.0, domains=[], n_domains=0,
313
+ rating_distribution=dist, top_terms=[],
314
+ tone=tone, preferred_themes=themes, common_complaints=dislikes,
315
+ voice_one_liner=desc, history_samples=[],
316
+ )
317
+
318
+
319
+ def persona_from_reviews(rows: list[dict]) -> UserPersona:
320
+ """Build a UserPersona from pasted past reviews.
321
+
322
+ Each row: {rating, title, domain, text, date(optional)}. Assembles them
323
+ into the column shape PersonaEngine.from_dataframe expects, then lets the
324
+ engine do the real modelling. This is the agent building a persona itself
325
+ from raw user history.
326
+ """
327
+ import pandas as _pd
328
+ records = []
329
+ for i, r in enumerate(rows):
330
+ ts = r.get("date")
331
+ # PersonaEngine sorts history by timestamp; fall back to entry order
332
+ try:
333
+ ts_val = _pd.Timestamp(ts) if ts else _pd.Timestamp("2020-01-01") + _pd.Timedelta(days=i)
334
+ except Exception:
335
+ ts_val = _pd.Timestamp("2020-01-01") + _pd.Timedelta(days=i)
336
+ records.append({
337
+ "user_id": "pasted",
338
+ "parent_asin": f"pasted_{i}",
339
+ "rating": float(r["rating"]),
340
+ "text": r["text"],
341
+ "verified_purchase": True,
342
+ "domain": r["domain"],
343
+ "timestamp": ts_val,
344
+ })
345
+ df = _pd.DataFrame(records)
346
+ engine = PersonaEngine()
347
+ persona = engine.from_dataframe("pasted", df)
348
+ return engine.enrich(persona)
349
+
350
+
351
+
352
+
353
+ st.markdown("""
354
+ <div class="masthead">
355
+ <div class="mast-rule"></div>
356
+ <div class="mast-kicker">DSN Γ— BCT LLM Agent Challenge Β· Task A</div>
357
+ <div class="mast-title">User Modeling <span class="em">Agent</span></div>
358
+ <div class="mast-stand">
359
+ Give it a <em>user persona</em> and a <em>product</em>. It writes the star
360
+ rating and the review that user would write β€” weighing what they usually do
361
+ against what this specific item signals β€” then <em>critiques and revises</em>
362
+ its own draft before showing it.
363
+ </div>
364
+ <div class="mast-rule-bot"></div>
365
+ </div>
366
+ """, unsafe_allow_html=True)
367
+
368
+ try:
369
+ reviews, items = load_data()
370
+ except Exception as e:
371
+ st.error(f"Could not load data β€” ensure data/processed/*.parquet exist.\n\n{e}")
372
+ st.stop()
373
+
374
+ train = reviews[reviews["split"] == "train"]
375
+ test = reviews[reviews["split"] == "test"]
376
+ persona_engine, agent = get_engines()
377
+
378
+ with st.sidebar:
379
+ st.markdown("## ✢ Controls")
380
+ st.markdown(
381
+ '<div style="background:#1d3a2b;border:1px solid #3a5c46;border-radius:6px;'
382
+ 'padding:0.7rem 0.85rem;margin-bottom:0.6rem">'
383
+ '<div style="font-family:Fraunces,serif;font-weight:600;font-size:1.05rem;'
384
+ 'color:#f3ecdb">πŸ‡³πŸ‡¬ Naija Mode</div>'
385
+ '<div style="font-family:Spline Sans Mono,monospace;font-size:0.68rem;'
386
+ 'color:#d8a64a;letter-spacing:0.04em;margin-top:0.15rem">'
387
+ 'NIGERIAN-ENGLISH LOCALIZATION</div></div>',
388
+ unsafe_allow_html=True)
389
+ naija = st.toggle("Render output in Nigerian English", value=False)
390
+ if naija:
391
+ st.markdown(
392
+ '<div style="background:#d8a64a;border-radius:5px;padding:0.4rem 0.7rem;'
393
+ 'margin-top:0.3rem"><span style="font-family:Spline Sans Mono,monospace;'
394
+ 'font-size:0.72rem;font-weight:600;color:#1d3a2b">'
395
+ '● NAIJA MODE ACTIVE</span></div>', unsafe_allow_html=True)
396
+ else:
397
+ st.markdown(
398
+ '<div style="background:#3a4d40;border:1px solid #5a6b60;border-radius:5px;'
399
+ 'padding:0.4rem 0.7rem;margin-top:0.3rem">'
400
+ '<span style="font-family:Spline Sans Mono,monospace;font-size:0.72rem;'
401
+ 'font-weight:600;color:#9bb0a3">β—‹ OFF Β· STANDARD ENGLISH</span></div>',
402
+ unsafe_allow_html=True)
403
+ st.divider()
404
+ st.markdown("### How it works")
405
+ st.caption("The agent builds a persona, drafts a review in that voice, then "
406
+ "runs a self-reflection loop β€” a critic LLM checks rating-text "
407
+ "consistency, voice match and on-topic fit, and the agent revises "
408
+ "if the critic objects.")
409
+ st.divider()
410
+ _prov = {"openai": "OpenAI", "gemini": "Gemini"}.get(
411
+ settings.llm_provider.lower(), settings.llm_provider.capitalize())
412
+ st.caption(f"LLM Β· {_prov}")
413
+
414
+ st.session_state.setdefault("result", None)
415
+ st.session_state.setdefault("ctx", None)
416
+
417
+ if naija:
418
+ st.markdown(
419
+ '<div style="background:linear-gradient(90deg,#1d3a2b,#2c5440);'
420
+ 'border-left:4px solid #d8a64a;border-radius:4px;padding:0.7rem 1.1rem;'
421
+ 'margin:0.4rem 0 0.2rem;display:flex;align-items:center;gap:0.7rem">'
422
+ '<span style="font-size:1.3rem">πŸ‡³πŸ‡¬</span>'
423
+ '<span><span style="font-family:Fraunces,serif;font-weight:600;'
424
+ 'font-size:1.02rem;color:#f3ecdb">Naija Mode is active</span>'
425
+ '<span style="font-family:Spline Sans Mono,monospace;font-size:0.74rem;'
426
+ 'color:#e8c98a;margin-left:0.6rem">output localized to Nigerian English'
427
+ '</span></span></div>', unsafe_allow_html=True)
428
+
429
+
430
+ # ══════════════════════════════════════════════════════════════════════════════
431
+ # Tabs β€” Compose (primary) Β· Dataset reader (secondary)
432
+ # ══════════════════════════════════════════════════════════════════════════════
433
+
434
+ tab_compose, tab_dataset, tab_history = st.tabs([
435
+ "✎ Compose a Persona",
436
+ "⊞ Dataset Reader",
437
+ "❏ Build From Past Reviews"])
438
+
439
+ # ── COMPOSE ───────────────────────────────────────────────────────────────────
440
+ with tab_compose:
441
+ st.markdown('<div class="sec-label">Input Β· Persona and Product</div>',
442
+ unsafe_allow_html=True)
443
+
444
+ with st.expander("The Person", expanded=True):
445
+ p_desc = st.text_area(
446
+ "Describe the Person's Reviewing Voice",
447
+ value="Someone who loves character-driven stories and "
448
+ "rich world-building, but is impatient with slow pacing.",
449
+ height=90, key="p_desc")
450
+ p_themes = st.text_input("Drawn To (Comma-Separated)",
451
+ value="character development, immersive worlds, "
452
+ "original plots", key="p_themes")
453
+ p_dislikes = st.text_input("Put Off By (Comma-Separated)",
454
+ value="slow pacing, thin characters", key="p_dis")
455
+ c1, c2 = st.columns(2)
456
+ with c1:
457
+ p_tone = st.selectbox("Tone", ["enthusiastic", "analytical", "casual",
458
+ "critical", "earnest", "terse"], key="p_tone")
459
+ with c2:
460
+ p_rating = st.slider("Typical Rating", 1.0, 5.0, 4.0, 0.5, key="p_rate")
461
+
462
+ with st.expander("The Product", expanded=True):
463
+ i_title = st.text_input("Title", value="The Midnight Library", key="i_title")
464
+ i_domain = st.selectbox("Domain", ["Books", "Movies_and_TV", "Kindle_Store",
465
+ "Other"], key="i_domain")
466
+ i_desc = st.text_area(
467
+ "Description / Synopsis",
468
+ value="A novel about a library between life and death, where each "
469
+ "book lets a woman try a different version of her life.",
470
+ height=110, key="i_desc")
471
+
472
+ go = st.button("Generate review ✢", key="go_compose", use_container_width=True)
473
+
474
+ if go:
475
+ try:
476
+ with st.status("The agent is working…", expanded=True) as status:
477
+ themes = [t.strip() for t in p_themes.split(",") if t.strip()]
478
+ dislikes = [t.strip() for t in p_dislikes.split(",") if t.strip()]
479
+ st.write("Assembling the persona…")
480
+ persona = composed_persona(p_desc, themes, dislikes, p_tone, p_rating)
481
+ item = ItemInput(parent_asin="composed", title=i_title,
482
+ description=i_desc, categories="",
483
+ domain=i_domain)
484
+ st.write("Drafting in the person's voice, then self-critiquing…")
485
+ result = agent.run(persona, item, naija_mode=naija)
486
+ st.write("Self-reflection complete")
487
+ status.update(label="Review generated", state="complete")
488
+ st.session_state.result = result
489
+ st.session_state.ctx = {"persona": persona, "item": item, "truth": None}
490
+ except Exception as e:
491
+ st.session_state.result = None
492
+ st.markdown(f'<div class="card" style="border-left:3px solid var(--clay)">'
493
+ f'<div class="card-kicker">Generation interrupted</div>'
494
+ f'The model call did not complete β€” it may be rate-limited. '
495
+ f'Try again shortly.<br><span style="font-family:Spline Sans Mono,'
496
+ f'monospace;font-size:0.72rem;color:#6f6651">'
497
+ f'{esc(type(e).__name__)}</span></div>', unsafe_allow_html=True)
498
+
499
+ # ── DATASET READER ────────────────────────────────────────────────────────────
500
+ with tab_dataset:
501
+ st.markdown('<div class="sec-label">Input Β· A Real Person From the Data</div>',
502
+ unsafe_allow_html=True)
503
+
504
+ elig = train.groupby("user_id").size().reset_index(name="n")
505
+ elig = elig[(elig["n"] >= 5) & (elig["user_id"].isin(set(test["user_id"])))]
506
+ users = elig.sample(min(40, len(elig)), random_state=7)["user_id"].tolist()
507
+
508
+ with st.expander("The Person", expanded=True):
509
+ st.caption("Pick a real person. The agent builds their persona from "
510
+ "actual history and is scored against a held-out review.")
511
+ user = st.selectbox("Person", users, key="sel_user")
512
+
513
+ go_ds = st.button("Generate review ✢", key="go_ds", use_container_width=True)
514
+
515
+ if go_ds and user:
516
+ try:
517
+ with st.status("The agent is working…", expanded=True) as status:
518
+ ut = test[test["user_id"] == user]
519
+ if ut.empty:
520
+ status.update(label="No held-out item for this person",
521
+ state="error")
522
+ st.stop()
523
+ tr = ut.iloc[0]
524
+ tid = tr["parent_asin"]
525
+ meta = items[items["parent_asin"] == tid]
526
+ if meta.empty:
527
+ item = ItemInput(parent_asin=tid, title=str(tr.get("title", "")),
528
+ description="", categories="", domain=tr["domain"])
529
+ else:
530
+ m = meta.iloc[0]
531
+ item = ItemInput(parent_asin=tid, title=str(m.get("title", "")),
532
+ description=str(m.get("description", ""))[:1500],
533
+ categories=str(m.get("categories", "")),
534
+ domain=tr["domain"],
535
+ average_rating=(float(m["average_rating"])
536
+ if pd.notna(m.get("average_rating"))
537
+ else None))
538
+ st.write("Reading the person's history…")
539
+ persona = persona_engine.from_dataframe(user, train)
540
+ persona = persona_engine.enrich(persona)
541
+ st.write(f"Persona built from {persona.n_reviews} reviews")
542
+ st.write("Drafting in their voice, then self-critiquing…")
543
+ result = agent.run(persona, item, naija_mode=naija)
544
+ st.write("Self-reflection complete")
545
+ status.update(label="Review generated", state="complete")
546
+ st.session_state.result = result
547
+ st.session_state.ctx = {"persona": persona, "item": item,
548
+ "truth": {"rating": float(tr["rating"]),
549
+ "text": str(tr["text"])}}
550
+ except Exception as e:
551
+ st.session_state.result = None
552
+ st.markdown(f'<div class="card" style="border-left:3px solid var(--clay)">'
553
+ f'<div class="card-kicker">Generation interrupted</div>'
554
+ f'The model call did not complete β€” it may be rate-limited. '
555
+ f'Try again shortly.<br><span style="font-family:Spline Sans Mono,'
556
+ f'monospace;font-size:0.72rem;color:#6f6651">'
557
+ f'{esc(type(e).__name__)}</span></div>', unsafe_allow_html=True)
558
+
559
+
560
+ # ── BUILD FROM PAST REVIEWS ────────────────────────────────────────────────────
561
+ with tab_history:
562
+ st.markdown('<div class="sec-label">Input Β· Raw Past Reviews</div>',
563
+ unsafe_allow_html=True)
564
+ st.markdown("Paste a person's past reviews β€” the agent builds their persona "
565
+ "from this history, then writes a review of a new product. "
566
+ "Three to four reviews give the strongest persona.")
567
+
568
+ DOMAINS = ["Books", "Movies_and_TV", "Kindle_Store", "Other"]
569
+ hist_rows = []
570
+ for i in range(5):
571
+ with st.expander(f"Past Review {i + 1}", expanded=(i == 0)):
572
+ hc1, hc2, hc3 = st.columns([1, 2, 1])
573
+ with hc1:
574
+ h_rating = st.selectbox("Rating", [1.0, 2.0, 3.0, 4.0, 5.0],
575
+ index=3, key=f"h_rate_{i}")
576
+ with hc2:
577
+ h_title = st.text_input("Product Title", key=f"h_title_{i}",
578
+ placeholder="e.g. The Silent Patient")
579
+ with hc3:
580
+ h_domain = st.selectbox("Domain", DOMAINS, key=f"h_dom_{i}")
581
+ h_text = st.text_area("Review Text", key=f"h_text_{i}", height=80,
582
+ placeholder="Paste what this person wrote\u2026")
583
+ h_date = st.text_input("Date (Optional, e.g. 2024-03)", key=f"h_date_{i}",
584
+ placeholder="optional")
585
+ if h_text.strip():
586
+ hist_rows.append({"rating": h_rating, "title": h_title.strip(),
587
+ "domain": h_domain, "text": h_text.strip(),
588
+ "date": h_date.strip() or None})
589
+
590
+ st.markdown('<div class="sec-label" style="margin-top:0.8rem">'
591
+ 'The New Product to Review</div>', unsafe_allow_html=True)
592
+ th1, th2 = st.columns([2, 1])
593
+ with th1:
594
+ ht_title = st.text_input("Title", value="The Midnight Library",
595
+ key="ht_title")
596
+ with th2:
597
+ ht_domain = st.selectbox("Domain ", DOMAINS, key="ht_domain")
598
+ ht_desc = st.text_area("Description / Synopsis", height=90, key="ht_desc",
599
+ value="A novel about a library between life and death, "
600
+ "where each book lets a woman try a different "
601
+ "version of her life.")
602
+
603
+ go_hist = st.button("Build persona & generate review ✢", key="go_hist",
604
+ use_container_width=True)
605
+
606
+ if go_hist:
607
+ if not hist_rows:
608
+ st.warning("Add at least one past review with text so the agent "
609
+ "has history to model.")
610
+ else:
611
+ try:
612
+ with st.status("The agent is working…", expanded=True) as status:
613
+ st.write(f"Reading {len(hist_rows)} pasted review(s)…")
614
+ persona = persona_from_reviews(hist_rows)
615
+ st.write(f"Persona built by the agent from "
616
+ f"{persona.n_reviews} reviews")
617
+ item = ItemInput(parent_asin="pasted_target", title=ht_title,
618
+ description=ht_desc, categories="",
619
+ domain=ht_domain)
620
+ st.write("Drafting in the inferred voice, then self-critiquing…")
621
+ result = agent.run(persona, item, naija_mode=naija)
622
+ st.write("Self-reflection complete")
623
+ status.update(label="Review generated", state="complete")
624
+ st.session_state.result = result
625
+ st.session_state.ctx = {"persona": persona, "item": item,
626
+ "truth": None}
627
+ except Exception as e:
628
+ st.session_state.result = None
629
+ st.markdown(f'<div class="card" style="border-left:3px solid var(--clay)">'
630
+ f'<div class="card-kicker">Generation interrupted</div>'
631
+ f'The model call did not complete \u2014 it may be '
632
+ f'rate-limited. Try again shortly.<br>'
633
+ f'<span style="font-family:Spline Sans Mono,monospace;'
634
+ f'font-size:0.72rem;color:#6f6651">'
635
+ f'{esc(type(e).__name__)}</span></div>',
636
+ unsafe_allow_html=True)
637
+
638
+
639
+ # ══════════════════════════════════════════════════════════════════════════════
640
+ # Result β€” shown below both tabs
641
+ # ══════════════════════════════════════════════════════════════════════════════
642
+
643
+ res = st.session_state.result
644
+ ctx = st.session_state.ctx
645
+ st.markdown("---")
646
+ if res and ctx:
647
+ st.markdown(persona_card(ctx["persona"]), unsafe_allow_html=True)
648
+
649
+ it = ctx["item"]
650
+ st.markdown(f"""
651
+ <div class="card reveal d2">
652
+ <div class="card-kicker">The Item</div>
653
+ <span style="font-family:Spline Sans Mono,monospace;font-size:0.6rem;
654
+ letter-spacing:0.13em;text-transform:uppercase;color:var(--pine-2)">
655
+ {esc(it.domain)}</span>
656
+ <div style="font-family:Fraunces,serif;font-weight:600;font-size:1.14rem;
657
+ color:var(--ink);margin-top:0.1rem">{esc(it.title)}</div>
658
+ </div>""", unsafe_allow_html=True)
659
+
660
+ badge = '<span class="naija-badge">NAIJA VOICE</span>' if res.naija_mode else ""
661
+ st.markdown(f"""
662
+ <div class="panel reveal d3">
663
+ <div class="card-kicker">The Generated Review Β· written as the person</div>
664
+ <div class="rating-row">
665
+ <span class="rating-chip">{res.rating:.1f}</span>
666
+ <span class="stars">{stars(res.rating)}</span>{badge}
667
+ </div>
668
+ <div class="review-body">{esc(res.review)}</div>
669
+ </div>""", unsafe_allow_html=True)
670
+
671
+ st.markdown(reflection_stepper(res.reflection_iterations,
672
+ res.reflection_refined,
673
+ res.reflection_notes), unsafe_allow_html=True)
674
+
675
+ st.markdown('<div class="sec-label">Why This Rating</div>', unsafe_allow_html=True)
676
+ truth = ctx.get("truth")
677
+ if truth:
678
+ col1, col2 = st.columns(2)
679
+ with col1:
680
+ st.markdown(f"""
681
+ <div class="cmp agent reveal d1">
682
+ <div class="cmp-head">The agent rated it {res.rating:.1f}β˜…</div>
683
+ <div class="cmp-body">{esc(res.reasoning)}</div>
684
+ </div>""", unsafe_allow_html=True)
685
+ with col2:
686
+ d = abs(res.rating - truth["rating"])
687
+ dc = "good" if d <= 0.5 else ("mid" if d <= 1.0 else "far")
688
+ t = truth["text"].replace("<br />", "\n").replace("<br>", "\n")
689
+ t = t[:520] + ("…" if len(t) > 520 else "")
690
+ st.markdown(f"""
691
+ <div class="cmp truth reveal d2">
692
+ <div class="cmp-head">The person actually wrote &nbsp;
693
+ <span class="delta {dc}">Ξ” {d:.1f}β˜…</span></div>
694
+ <div style="margin:0.15rem 0 0.35rem">
695
+ <span class="stars" style="color:var(--pine-2)">{stars(truth['rating'])}</span>
696
+ <span style="font-family:Spline Sans Mono,monospace;font-size:0.74rem;
697
+ color:#6f6651"> {truth['rating']:.1f}β˜…</span></div>
698
+ <div class="cmp-body">{esc(t)}</div>
699
+ </div>""", unsafe_allow_html=True)
700
+ else:
701
+ st.markdown(f"""
702
+ <div class="cmp agent reveal d1">
703
+ <div class="cmp-head">The agent rated it {res.rating:.1f}β˜…</div>
704
+ <div class="cmp-body">{esc(res.reasoning)}</div>
705
+ </div>""", unsafe_allow_html=True)
706
+ st.caption(f"Grounded on {res.used_history_count} similar past reviews")
707
+ else:
708
+ st.markdown('<div class="empty">Compose a persona and a product, or pick a '
709
+ 'dataset person β€” then press <b>Generate</b>. The agent writes '
710
+ 'the review in that person\'s voice and shows its reasoning.</div>',
711
+ unsafe_allow_html=True)
712
+
713
+ st.markdown("""
714
+ <div class="foot">
715
+ User Modeling Agent Β· DSN Γ— BCT LLM Agent Challenge 2026 Β·
716
+ persona β†’ draft in-voice β†’ self-reflection critique &amp; revise Β·
717
+ rating predicted as persona prior adjusted by item evidence
718
+ </div>
719
+ """, unsafe_allow_html=True)