Israelbliz commited on
Commit
5cdc85a
·
verified ·
1 Parent(s): 74e7e35

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -717
app.py DELETED
@@ -1,717 +0,0 @@
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 Reader · 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
- st.caption(f"provider · {settings.llm_provider}")
411
-
412
- st.session_state.setdefault("result", None)
413
- st.session_state.setdefault("ctx", None)
414
-
415
- if naija:
416
- st.markdown(
417
- '<div style="background:linear-gradient(90deg,#1d3a2b,#2c5440);'
418
- 'border-left:4px solid #d8a64a;border-radius:4px;padding:0.7rem 1.1rem;'
419
- 'margin:0.4rem 0 0.2rem;display:flex;align-items:center;gap:0.7rem">'
420
- '<span style="font-size:1.3rem">🇳🇬</span>'
421
- '<span><span style="font-family:Fraunces,serif;font-weight:600;'
422
- 'font-size:1.02rem;color:#f3ecdb">Naija Mode is active</span>'
423
- '<span style="font-family:Spline Sans Mono,monospace;font-size:0.74rem;'
424
- 'color:#e8c98a;margin-left:0.6rem">output localized to Nigerian English'
425
- '</span></span></div>', unsafe_allow_html=True)
426
-
427
-
428
- # ══════════════════════════════════════════════════════════════════════════════
429
- # Tabs — Compose (primary) · Dataset reader (secondary)
430
- # ══════════════════════════════════════════════════════════════════════════════
431
-
432
- tab_compose, tab_dataset, tab_history = st.tabs([
433
- "✎ Compose a Persona",
434
- "⊞ Dataset Reader",
435
- "❏ Build From Past Reviews"])
436
-
437
- # ── COMPOSE ───────────────────────────────────────────────────────────────────
438
- with tab_compose:
439
- st.markdown('<div class="sec-label">Input · Persona and Product</div>',
440
- unsafe_allow_html=True)
441
-
442
- with st.expander("The Reader", expanded=True):
443
- p_desc = st.text_area(
444
- "Describe the Reader's Reviewing Voice",
445
- value="A thoughtful reader who loves character-driven stories and "
446
- "rich world-building, but is impatient with slow pacing.",
447
- height=90, key="p_desc")
448
- p_themes = st.text_input("Drawn To (Comma-Separated)",
449
- value="character development, immersive worlds, "
450
- "original plots", key="p_themes")
451
- p_dislikes = st.text_input("Put Off By (Comma-Separated)",
452
- value="slow pacing, thin characters", key="p_dis")
453
- c1, c2 = st.columns(2)
454
- with c1:
455
- p_tone = st.selectbox("Tone", ["enthusiastic", "analytical", "casual",
456
- "critical", "earnest", "terse"], key="p_tone")
457
- with c2:
458
- p_rating = st.slider("Typical Rating", 1.0, 5.0, 4.0, 0.5, key="p_rate")
459
-
460
- with st.expander("The Product", expanded=True):
461
- i_title = st.text_input("Title", value="The Midnight Library", key="i_title")
462
- i_domain = st.selectbox("Domain", ["Books", "Movies_and_TV", "Kindle_Store",
463
- "Other"], key="i_domain")
464
- i_desc = st.text_area(
465
- "Description / Synopsis",
466
- value="A novel about a library between life and death, where each "
467
- "book lets a woman try a different version of her life.",
468
- height=110, key="i_desc")
469
-
470
- go = st.button("Generate review ✶", key="go_compose", use_container_width=True)
471
-
472
- if go:
473
- try:
474
- with st.status("The agent is working…", expanded=True) as status:
475
- themes = [t.strip() for t in p_themes.split(",") if t.strip()]
476
- dislikes = [t.strip() for t in p_dislikes.split(",") if t.strip()]
477
- st.write("Assembling the persona…")
478
- persona = composed_persona(p_desc, themes, dislikes, p_tone, p_rating)
479
- item = ItemInput(parent_asin="composed", title=i_title,
480
- description=i_desc, categories="",
481
- domain=i_domain)
482
- st.write("Drafting in the reader's voice, then self-critiquing…")
483
- result = agent.run(persona, item, naija_mode=naija)
484
- st.write("Self-reflection complete")
485
- status.update(label="Review generated", state="complete")
486
- st.session_state.result = result
487
- st.session_state.ctx = {"persona": persona, "item": item, "truth": None}
488
- except Exception as e:
489
- st.session_state.result = None
490
- st.markdown(f'<div class="card" style="border-left:3px solid var(--clay)">'
491
- f'<div class="card-kicker">Generation interrupted</div>'
492
- f'The model call did not complete — it may be rate-limited. '
493
- f'Try again shortly.<br><span style="font-family:Spline Sans Mono,'
494
- f'monospace;font-size:0.72rem;color:#6f6651">'
495
- f'{esc(type(e).__name__)}</span></div>', unsafe_allow_html=True)
496
-
497
- # ── DATASET READER ────────────────────────────────────────────────────────────
498
- with tab_dataset:
499
- st.markdown('<div class="sec-label">Input · A Real Reader From the Data</div>',
500
- unsafe_allow_html=True)
501
-
502
- elig = train.groupby("user_id").size().reset_index(name="n")
503
- elig = elig[(elig["n"] >= 5) & (elig["user_id"].isin(set(test["user_id"])))]
504
- users = elig.sample(min(40, len(elig)), random_state=7)["user_id"].tolist()
505
-
506
- with st.expander("The Reader", expanded=True):
507
- st.caption("Pick a real reader. The agent builds their persona from "
508
- "actual history and is scored against a held-out review.")
509
- user = st.selectbox("Reader", users, key="sel_user")
510
-
511
- go_ds = st.button("Generate review ✶", key="go_ds", use_container_width=True)
512
-
513
- if go_ds and user:
514
- try:
515
- with st.status("The agent is working…", expanded=True) as status:
516
- ut = test[test["user_id"] == user]
517
- if ut.empty:
518
- status.update(label="No held-out item for this reader",
519
- state="error")
520
- st.stop()
521
- tr = ut.iloc[0]
522
- tid = tr["parent_asin"]
523
- meta = items[items["parent_asin"] == tid]
524
- if meta.empty:
525
- item = ItemInput(parent_asin=tid, title=str(tr.get("title", "")),
526
- description="", categories="", domain=tr["domain"])
527
- else:
528
- m = meta.iloc[0]
529
- item = ItemInput(parent_asin=tid, title=str(m.get("title", "")),
530
- description=str(m.get("description", ""))[:1500],
531
- categories=str(m.get("categories", "")),
532
- domain=tr["domain"],
533
- average_rating=(float(m["average_rating"])
534
- if pd.notna(m.get("average_rating"))
535
- else None))
536
- st.write("Reading the reader's history…")
537
- persona = persona_engine.from_dataframe(user, train)
538
- persona = persona_engine.enrich(persona)
539
- st.write(f"Persona built from {persona.n_reviews} reviews")
540
- st.write("Drafting in their voice, then self-critiquing…")
541
- result = agent.run(persona, item, naija_mode=naija)
542
- st.write("Self-reflection complete")
543
- status.update(label="Review generated", state="complete")
544
- st.session_state.result = result
545
- st.session_state.ctx = {"persona": persona, "item": item,
546
- "truth": {"rating": float(tr["rating"]),
547
- "text": str(tr["text"])}}
548
- except Exception as e:
549
- st.session_state.result = None
550
- st.markdown(f'<div class="card" style="border-left:3px solid var(--clay)">'
551
- f'<div class="card-kicker">Generation interrupted</div>'
552
- f'The model call did not complete — it may be rate-limited. '
553
- f'Try again shortly.<br><span style="font-family:Spline Sans Mono,'
554
- f'monospace;font-size:0.72rem;color:#6f6651">'
555
- f'{esc(type(e).__name__)}</span></div>', unsafe_allow_html=True)
556
-
557
-
558
- # ── BUILD FROM PAST REVIEWS ────────────────────────────────────────────────────
559
- with tab_history:
560
- st.markdown('<div class="sec-label">Input · Raw Past Reviews</div>',
561
- unsafe_allow_html=True)
562
- st.markdown("Paste a person's past reviews — the agent builds their persona "
563
- "from this history, then writes a review of a new product. "
564
- "Three to four reviews give the strongest persona.")
565
-
566
- DOMAINS = ["Books", "Movies_and_TV", "Kindle_Store", "Other"]
567
- hist_rows = []
568
- for i in range(5):
569
- with st.expander(f"Past Review {i + 1}", expanded=(i == 0)):
570
- hc1, hc2, hc3 = st.columns([1, 2, 1])
571
- with hc1:
572
- h_rating = st.selectbox("Rating", [1.0, 2.0, 3.0, 4.0, 5.0],
573
- index=3, key=f"h_rate_{i}")
574
- with hc2:
575
- h_title = st.text_input("Product Title", key=f"h_title_{i}",
576
- placeholder="e.g. The Silent Patient")
577
- with hc3:
578
- h_domain = st.selectbox("Domain", DOMAINS, key=f"h_dom_{i}")
579
- h_text = st.text_area("Review Text", key=f"h_text_{i}", height=80,
580
- placeholder="Paste what this person wrote\u2026")
581
- h_date = st.text_input("Date (Optional, e.g. 2024-03)", key=f"h_date_{i}",
582
- placeholder="optional")
583
- if h_text.strip():
584
- hist_rows.append({"rating": h_rating, "title": h_title.strip(),
585
- "domain": h_domain, "text": h_text.strip(),
586
- "date": h_date.strip() or None})
587
-
588
- st.markdown('<div class="sec-label" style="margin-top:0.8rem">'
589
- 'The New Product to Review</div>', unsafe_allow_html=True)
590
- th1, th2 = st.columns([2, 1])
591
- with th1:
592
- ht_title = st.text_input("Title", value="The Midnight Library",
593
- key="ht_title")
594
- with th2:
595
- ht_domain = st.selectbox("Domain ", DOMAINS, key="ht_domain")
596
- ht_desc = st.text_area("Description / Synopsis", height=90, key="ht_desc",
597
- value="A novel about a library between life and death, "
598
- "where each book lets a woman try a different "
599
- "version of her life.")
600
-
601
- go_hist = st.button("Build persona & generate review ✶", key="go_hist",
602
- use_container_width=True)
603
-
604
- if go_hist:
605
- if not hist_rows:
606
- st.warning("Add at least one past review with text so the agent "
607
- "has history to model.")
608
- else:
609
- try:
610
- with st.status("The agent is working…", expanded=True) as status:
611
- st.write(f"Reading {len(hist_rows)} pasted review(s)…")
612
- persona = persona_from_reviews(hist_rows)
613
- st.write(f"Persona built by the agent from "
614
- f"{persona.n_reviews} reviews")
615
- item = ItemInput(parent_asin="pasted_target", title=ht_title,
616
- description=ht_desc, categories="",
617
- domain=ht_domain)
618
- st.write("Drafting in the inferred voice, then self-critiquing…")
619
- result = agent.run(persona, item, naija_mode=naija)
620
- st.write("Self-reflection complete")
621
- status.update(label="Review generated", state="complete")
622
- st.session_state.result = result
623
- st.session_state.ctx = {"persona": persona, "item": item,
624
- "truth": None}
625
- except Exception as e:
626
- st.session_state.result = None
627
- st.markdown(f'<div class="card" style="border-left:3px solid var(--clay)">'
628
- f'<div class="card-kicker">Generation interrupted</div>'
629
- f'The model call did not complete \u2014 it may be '
630
- f'rate-limited. Try again shortly.<br>'
631
- f'<span style="font-family:Spline Sans Mono,monospace;'
632
- f'font-size:0.72rem;color:#6f6651">'
633
- f'{esc(type(e).__name__)}</span></div>',
634
- unsafe_allow_html=True)
635
-
636
-
637
- # ══════════════════════════════════════════════════════════════════════════════
638
- # Result — shown below both tabs
639
- # ══════════════════════════════════════════════════════════════════════════════
640
-
641
- res = st.session_state.result
642
- ctx = st.session_state.ctx
643
- st.markdown("---")
644
- if res and ctx:
645
- st.markdown(persona_card(ctx["persona"]), unsafe_allow_html=True)
646
-
647
- it = ctx["item"]
648
- st.markdown(f"""
649
- <div class="card reveal d2">
650
- <div class="card-kicker">The Item</div>
651
- <span style="font-family:Spline Sans Mono,monospace;font-size:0.6rem;
652
- letter-spacing:0.13em;text-transform:uppercase;color:var(--pine-2)">
653
- {esc(it.domain)}</span>
654
- <div style="font-family:Fraunces,serif;font-weight:600;font-size:1.14rem;
655
- color:var(--ink);margin-top:0.1rem">{esc(it.title)}</div>
656
- </div>""", unsafe_allow_html=True)
657
-
658
- badge = '<span class="naija-badge">NAIJA VOICE</span>' if res.naija_mode else ""
659
- st.markdown(f"""
660
- <div class="panel reveal d3">
661
- <div class="card-kicker">The Generated Review · written as the reader</div>
662
- <div class="rating-row">
663
- <span class="rating-chip">{res.rating:.1f}</span>
664
- <span class="stars">{stars(res.rating)}</span>{badge}
665
- </div>
666
- <div class="review-body">{esc(res.review)}</div>
667
- </div>""", unsafe_allow_html=True)
668
-
669
- st.markdown(reflection_stepper(res.reflection_iterations,
670
- res.reflection_refined,
671
- res.reflection_notes), unsafe_allow_html=True)
672
-
673
- st.markdown('<div class="sec-label">Why This Rating</div>', unsafe_allow_html=True)
674
- truth = ctx.get("truth")
675
- if truth:
676
- col1, col2 = st.columns(2)
677
- with col1:
678
- st.markdown(f"""
679
- <div class="cmp agent reveal d1">
680
- <div class="cmp-head">The agent rated it {res.rating:.1f}★</div>
681
- <div class="cmp-body">{esc(res.reasoning)}</div>
682
- </div>""", unsafe_allow_html=True)
683
- with col2:
684
- d = abs(res.rating - truth["rating"])
685
- dc = "good" if d <= 0.5 else ("mid" if d <= 1.0 else "far")
686
- t = truth["text"].replace("<br />", "\n").replace("<br>", "\n")
687
- t = t[:520] + ("…" if len(t) > 520 else "")
688
- st.markdown(f"""
689
- <div class="cmp truth reveal d2">
690
- <div class="cmp-head">The reader actually wrote &nbsp;
691
- <span class="delta {dc}">Δ {d:.1f}★</span></div>
692
- <div style="margin:0.15rem 0 0.35rem">
693
- <span class="stars" style="color:var(--pine-2)">{stars(truth['rating'])}</span>
694
- <span style="font-family:Spline Sans Mono,monospace;font-size:0.74rem;
695
- color:#6f6651"> {truth['rating']:.1f}★</span></div>
696
- <div class="cmp-body">{esc(t)}</div>
697
- </div>""", unsafe_allow_html=True)
698
- else:
699
- st.markdown(f"""
700
- <div class="cmp agent reveal d1">
701
- <div class="cmp-head">The agent rated it {res.rating:.1f}★</div>
702
- <div class="cmp-body">{esc(res.reasoning)}</div>
703
- </div>""", unsafe_allow_html=True)
704
- st.caption(f"Grounded on {res.used_history_count} similar past reviews")
705
- else:
706
- st.markdown('<div class="empty">Compose a persona and a product, or pick a '
707
- 'dataset reader — then press <b>Generate</b>. The agent writes '
708
- 'the review in that reader\'s voice and shows its reasoning.</div>',
709
- unsafe_allow_html=True)
710
-
711
- st.markdown("""
712
- <div class="foot">
713
- User Modeling Agent · DSN × BCT LLM Agent Challenge 2026 ·
714
- persona → draft in-voice → self-reflection critique &amp; revise ·
715
- rating predicted as persona prior adjusted by item evidence
716
- </div>
717
- """, unsafe_allow_html=True)