Israelbliz's picture
Upload app.py
79bb546 verified
"""User Modeling Agent β€” the demo.
DSN Γ— BCT LLM Agent Challenge Β· Task A.
Takes a user persona and product details as input, and generates a star
rating and a written review as that user would write it β€” then critiques
and revises its own draft (self-reflection). Optionally renders the review
in Nigerian English.
Two ways to use it:
1. Compose a persona β€” type a persona + product (the brief's input contract)
2. Dataset reader β€” pick a real user, compare against ground truth
Run:
streamlit run app.py
"""
from __future__ import annotations
import html
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pandas as pd
import streamlit as st
from core.config import settings
from core.persona import PersonaEngine, UserPersona
from task_a_user_modeling.agent import ImpersonationAgent, ItemInput
st.set_page_config(page_title="User Modeling Agent", page_icon="✢",
layout="wide", initial_sidebar_state="expanded")
esc = html.escape
# ══════════════════════════════════════════════════════════════════════════════
# Design system
# ══════════════════════════════════════════════════════════════════════════════
CSS = """
<style>
@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');
:root {
--paper:#f3ecdb; --paper-2:#fffdf6; --paper-3:#ece2cb;
--pine:#1d3a2b; --pine-2:#2c5440; --pine-ink:#14241b;
--clay:#b0472b; --ochre:#c98a3c; --gold:#d8a64a;
--ink:#221e16; --muted:#6f6651; --hair:#d4c8aa;
}
.stApp { background:var(--paper); color:var(--ink); }
.stApp::before {
content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
background:
radial-gradient(900px 600px at 12% -5%, rgba(45,84,64,.10), transparent 60%),
radial-gradient(800px 600px at 95% 8%, rgba(176,71,43,.08), transparent 55%);
}
[data-testid="stMainBlockContainer"] { max-width:1140px; padding-top:2rem; padding-bottom:4rem; }
h1,h2,h3,h4 { font-family:'Fraunces',Georgia,serif !important; color:var(--pine) !important;
letter-spacing:-0.015em; font-weight:600 !important; }
html,body,p,div,span,label,li,.stMarkdown { font-family:'Newsreader',Georgia,serif; }
.stCaption,[data-testid="stCaptionContainer"] { font-family:'Spline Sans Mono',monospace !important; }
.masthead { position:relative; z-index:1; margin-bottom:0.3rem; }
.mast-rule { height:2px; background:var(--pine); margin-bottom:0.5rem; }
.mast-kicker { font-family:'Spline Sans Mono',monospace; font-size:0.70rem;
letter-spacing:0.30em; text-transform:uppercase; color:var(--clay); font-weight:600; }
.mast-title { font-family:'Fraunces',serif; font-weight:900;
font-size:clamp(2.3rem,5.5vw,3.7rem); line-height:1.0; color:var(--pine);
margin:0.16rem 0 0.1rem; letter-spacing:-0.03em; }
.mast-title .em { color:var(--clay); font-style:italic; font-weight:500; }
.mast-stand { font-family:'Newsreader',serif; font-size:1.08rem; color:#45402f;
max-width:66ch; line-height:1.45; }
.mast-stand em { color:var(--clay); font-style:italic; }
.mast-rule-bot { height:1px; background:var(--hair); margin:0.85rem 0 0.2rem; }
.sec-label { font-family:'Spline Sans Mono',monospace; font-size:0.70rem;
letter-spacing:0.2em; text-transform:uppercase; color:var(--clay);
font-weight:600; margin:0.3rem 0 0.15rem; }
.card { background:var(--paper-2); border:1px solid var(--hair); border-radius:3px;
padding:1.1rem 1.3rem; margin:0.5rem 0 0.85rem; position:relative; z-index:1; }
.card-kicker { font-family:'Spline Sans Mono',monospace; font-size:0.64rem;
letter-spacing:0.2em; text-transform:uppercase; color:var(--clay);
font-weight:600; margin-bottom:0.5rem; }
.persona-quote { font-family:'Fraunces',serif; font-weight:500; font-style:italic;
font-size:1.26rem; line-height:1.34; color:var(--pine); margin:0.1rem 0 0.8rem;
padding-left:0.8rem; border-left:3px solid var(--ochre); }
.pstats { display:flex; gap:1.7rem; flex-wrap:wrap; align-items:flex-end; }
.pstat .num { font-family:'Fraunces',serif; font-weight:900; font-size:1.5rem;
color:var(--pine); line-height:1; }
.pstat .lab { font-family:'Spline Sans Mono',monospace; font-size:0.60rem;
letter-spacing:0.13em; text-transform:uppercase; color:var(--muted); margin-top:0.2rem; }
.chips { margin-top:0.6rem; }
.chip-lab { font-family:'Spline Sans Mono',monospace; font-size:0.60rem;
letter-spacing:0.12em; text-transform:uppercase; color:var(--muted); margin-right:0.4rem; }
.chip { display:inline-block; margin:0.15rem 0.25rem 0.15rem 0; padding:0.15rem 0.6rem;
border-radius:999px; font-family:'Spline Sans Mono',monospace; font-size:0.72rem;
background:var(--paper-3); color:var(--pine-2); border:1px solid var(--hair); }
.chip.warn { background:#f0ddd2; color:var(--clay); border-color:#e3c4b4; }
.panel { background:var(--pine-ink); border-radius:3px; padding:1.35rem 1.55rem;
margin:0.5rem 0 0.85rem; position:relative; z-index:1;
box-shadow:0 14px 34px -22px rgba(20,36,27,.7); }
.panel .card-kicker { color:var(--gold); }
.rating-row { display:flex; align-items:center; gap:0.8rem; margin:0.25rem 0 0.65rem; }
.rating-chip { font-family:'Fraunces',serif; font-weight:900; font-size:1.6rem;
background:var(--clay); color:#fff7ec; padding:0.05rem 0.65rem; border-radius:3px; }
.stars { font-size:1.15rem; letter-spacing:0.1em; color:var(--gold); }
.review-body { font-family:'Newsreader',serif; font-size:1.1rem; line-height:1.7;
color:#f0e9d6; white-space:pre-wrap; }
.naija-badge { display:inline-block; margin-left:0.45rem; font-family:'Spline Sans Mono',monospace;
font-size:0.60rem; letter-spacing:0.12em; font-weight:600; background:#e9f0e2;
color:var(--pine); padding:0.12rem 0.5rem; border-radius:999px; border:1px solid #cdd9bf; }
.stepper { display:flex; gap:0; margin:0.3rem 0 0.2rem; flex-wrap:wrap; }
.step { flex:1; min-width:125px; padding:0.5rem 0.65rem; position:relative; }
.step .dot { width:11px; height:11px; border-radius:50%; background:var(--pine); margin-bottom:0.35rem; }
.step.flag .dot { background:var(--clay); }
.step.pass .dot { background:var(--pine-2); }
.step .st-name { font-family:'Fraunces',serif; font-weight:600; font-size:0.93rem;
color:var(--pine); line-height:1.1; }
.step .st-sub { font-family:'Spline Sans Mono',monospace; font-size:0.63rem;
color:var(--muted); margin-top:0.18rem; }
.step:not(:last-child)::after { content:""; position:absolute; top:0.87rem; right:-2px;
width:100%; height:1px;
background:repeating-linear-gradient(90deg,var(--hair) 0 6px,transparent 6px 12px); }
.critique-note { font-family:'Newsreader',serif; font-style:italic; font-size:0.93rem;
color:#5a4030; line-height:1.45; background:#f0ddd2; border-left:3px solid var(--clay);
padding:0.5rem 0.75rem; border-radius:2px; margin-top:0.45rem; }
.cmp { background:var(--paper-2); border:1px solid var(--hair); border-radius:3px;
padding:0.9rem 1.05rem; height:100%; }
.cmp.truth { border-top:3px solid var(--pine-2); }
.cmp.agent { border-top:3px solid var(--clay); }
.cmp-head { font-family:'Spline Sans Mono',monospace; font-size:0.62rem;
letter-spacing:0.15em; text-transform:uppercase; color:var(--muted); margin-bottom:0.35rem; }
.cmp-body { font-family:'Newsreader',serif; font-size:0.97rem; line-height:1.5;
color:#4a4434; white-space:pre-wrap; }
.delta { font-family:'Spline Sans Mono',monospace; font-size:0.70rem; font-weight:600;
padding:0.16rem 0.55rem; border-radius:999px; }
.delta.good { background:#e3ecd9; color:var(--pine); }
.delta.mid { background:#f3e6c8; color:#8a6420; }
.delta.far { background:#f0d8cc; color:var(--clay); }
.empty { border:1px dashed var(--hair); border-radius:3px; padding:1.5rem; text-align:center;
font-family:'Newsreader',serif; font-style:italic; color:var(--muted); font-size:1rem;
background:rgba(255,253,246,.5); }
@keyframes rise { from{opacity:0;transform:translateY(13px);} to{opacity:1;transform:translateY(0);} }
.reveal { animation:rise 0.55s cubic-bezier(.2,.7,.2,1) both; }
.d1{animation-delay:.04s;} .d2{animation-delay:.13s;} .d3{animation-delay:.22s;}
.stButton > button { background:var(--pine); color:var(--paper); border:none; border-radius:3px;
font-family:'Spline Sans Mono',monospace; font-weight:600; font-size:0.82rem;
letter-spacing:0.05em; padding:0.55rem 1rem; }
.stButton > button:hover { background:var(--clay); color:#fff7ec; }
[data-testid="stSidebar"] { background:var(--pine-ink); border-right:1px solid #2c4133; }
[data-testid="stSidebar"] * { color:#e7e0cd; }
[data-testid="stSidebar"] h1,[data-testid="stSidebar"] h2,[data-testid="stSidebar"] h3 { color:#f3ecdb !important; }
/* toggle β€” colour ONLY the switch (track + knob), never the label row */
[data-testid="stSidebar"] [data-testid="stWidgetLabel"] ~ div [role="switch"],
[data-testid="stSidebar"] [data-baseweb="checkbox"] [role="switch"],
[data-testid="stSidebar"] div[role="switch"] {
background-color:#c9bf9f !important;
border:1.5px solid #d8a64a !important;
}
[data-testid="stSidebar"] [data-baseweb="checkbox"] [role="switch"][aria-checked="true"],
[data-testid="stSidebar"] div[role="switch"][aria-checked="true"] {
background-color:#d8a64a !important;
border-color:#e8c98a !important;
}
[data-testid="stSidebar"] [data-baseweb="checkbox"] [role="switch"] > div,
[data-testid="stSidebar"] div[role="switch"] > div {
background-color:#1d3a2b !important;
}
[data-testid="stSidebar"] [data-baseweb="checkbox"] [role="switch"][aria-checked="true"] > div,
[data-testid="stSidebar"] div[role="switch"][aria-checked="true"] > div {
background-color:#fffdf6 !important;
}
[data-baseweb="tab-list"] { gap:2.4rem; border-bottom:2px solid var(--pine); }
[data-baseweb="tab"] { font-family:'Fraunces',serif !important; font-weight:600;
padding-left:0.7rem; padding-right:0.7rem;
font-size:1.04rem; color:var(--muted); }
[data-baseweb="tab"][aria-selected="true"] { color:var(--pine) !important;
font-weight:900 !important; background:#ece2cb; border-radius:5px 5px 0 0; }
[data-baseweb="tab-highlight"] { background:var(--clay) !important; height:4px; }
/* form field contrast β€” fields were blending into the cream page */
.stTextInput input, .stTextArea textarea,
[data-baseweb="select"] > div, [data-baseweb="input"] {
background:#fffdf6 !important;
border:1px solid #c2b48f !important;
border-radius:4px !important;
color:#7d7560 !important;
font-weight:500 !important;
}
.stTextInput input:focus, .stTextArea textarea:focus {
border-color:var(--clay) !important;
box-shadow:0 0 0 1px var(--clay) !important;
}
.stTextInput input::placeholder, .stTextArea textarea::placeholder {
color:#9a917a !important;
font-weight:400 !important;
}
[data-testid="stExpander"] {
border:1px solid #c2b48f !important;
border-radius:5px !important;
background:#faf5e6 !important;
}
.foot { margin-top:2.2rem; padding-top:0.85rem; border-top:1px solid var(--hair);
font-family:'Spline Sans Mono',monospace; font-size:0.68rem; color:var(--muted); line-height:1.6; }
</style>
"""
st.markdown(CSS, unsafe_allow_html=True)
# ══════════════════════════════════════════════════════════════════════════════
# HTML builders
# ══════════════════════════════════════════════════════════════════════════════
def stars(r: float) -> str:
f = int(round(r))
return "β˜…" * f + "β˜†" * (5 - f)
def persona_card(p: UserPersona) -> str:
themes = "".join(f'<span class="chip">{esc(t)}</span>'
for t in p.preferred_themes) or '<span class="chip">β€”</span>'
comps = "".join(f'<span class="chip warn">{esc(t)}</span>'
for t in p.common_complaints) or '<span class="chip warn">β€”</span>'
nrev = (f'{p.n_reviews}' if p.n_reviews else 'composed')
return f"""
<div class="card reveal d1">
<div class="card-kicker">The Person Β· persona</div>
<div class="persona-quote">β€œ{esc(p.voice_one_liner or 'No voice captured.')}”</div>
<div class="pstats">
<div class="pstat"><div class="num">{nrev}</div><div class="lab">history</div></div>
<div class="pstat"><div class="num">{p.avg_rating:.1f}β˜…</div><div class="lab">avg rating</div></div>
<div class="pstat"><div class="num">{esc(p.tone or 'β€”')}</div><div class="lab">tone</div></div>
</div>
<div class="chips"><span class="chip-lab">drawn to</span>{themes}</div>
<div class="chips"><span class="chip-lab">put off by</span>{comps}</div>
</div>"""
def reflection_stepper(iters: int, refined: bool, notes: list[str] | None) -> str:
steps = ['<div class="step pass"><div class="dot"></div>'
'<div class="st-name">First draft</div>'
'<div class="st-sub">generated in-voice</div></div>']
if refined:
steps += ['<div class="step flag"><div class="dot"></div>'
'<div class="st-name">Self-critique</div>'
'<div class="st-sub">found issues</div></div>',
'<div class="step pass"><div class="dot"></div>'
'<div class="st-name">Revised draft</div>'
'<div class="st-sub">rewritten with feedback</div></div>',
'<div class="step pass"><div class="dot"></div>'
'<div class="st-name">Re-checked</div>'
'<div class="st-sub">critique cleared</div></div>']
else:
steps += ['<div class="step pass"><div class="dot"></div>'
'<div class="st-name">Self-critique</div>'
'<div class="st-sub">passed first pass</div></div>',
'<div class="step pass"><div class="dot"></div>'
'<div class="st-name">Accepted</div>'
'<div class="st-sub">no revision needed</div></div>']
note = ""
if notes:
real = [n for n in notes if n and n.strip().lower() != "passed"]
if real:
note = f'<div class="critique-note">The critic flagged: {esc(real[0])}</div>'
return f"""
<div class="card reveal d3">
<div class="card-kicker">Self-reflection Β· {iters} critique cycle(s)</div>
<div class="stepper">{''.join(steps)}</div>
{note}
</div>"""
# ══════════════════════════════════════════════════════════════════════════════
# Cached resources
# ══════════════════════════════════════════════════════════════════════════════
@st.cache_data(show_spinner=False)
def load_data():
rev = pd.read_parquet(settings.processed_dir / "reviews.parquet")
items = pd.read_parquet(settings.processed_dir / "items.parquet")
return rev, items
@st.cache_resource(show_spinner=False)
def get_engines():
return PersonaEngine(), ImpersonationAgent()
def composed_persona(desc: str, themes: list[str], dislikes: list[str],
tone: str, avg_rating: float) -> UserPersona:
"""Build a UserPersona from typed input β€” the brief's persona-as-input contract."""
# rating distribution skewed around the stated average
lo, hi = int(avg_rating), min(5, int(avg_rating) + 1)
dist = {lo: 0.55, hi: 0.35} if lo != hi else {lo: 0.9}
dist.setdefault(3, 0.1)
return UserPersona(
user_id="composed", n_reviews=0, avg_rating=avg_rating,
std_rating=0.6, avg_review_length=90.0, std_review_length=30.0,
verified_rate=1.0, domains=[], n_domains=0,
rating_distribution=dist, top_terms=[],
tone=tone, preferred_themes=themes, common_complaints=dislikes,
voice_one_liner=desc, history_samples=[],
)
def persona_from_reviews(rows: list[dict]) -> UserPersona:
"""Build a UserPersona from pasted past reviews.
Each row: {rating, title, domain, text, date(optional)}. Assembles them
into the column shape PersonaEngine.from_dataframe expects, then lets the
engine do the real modelling. This is the agent building a persona itself
from raw user history.
"""
import pandas as _pd
records = []
for i, r in enumerate(rows):
ts = r.get("date")
# PersonaEngine sorts history by timestamp; fall back to entry order
try:
ts_val = _pd.Timestamp(ts) if ts else _pd.Timestamp("2020-01-01") + _pd.Timedelta(days=i)
except Exception:
ts_val = _pd.Timestamp("2020-01-01") + _pd.Timedelta(days=i)
records.append({
"user_id": "pasted",
"parent_asin": f"pasted_{i}",
"rating": float(r["rating"]),
"text": r["text"],
"verified_purchase": True,
"domain": r["domain"],
"timestamp": ts_val,
})
df = _pd.DataFrame(records)
engine = PersonaEngine()
persona = engine.from_dataframe("pasted", df)
return engine.enrich(persona)
st.markdown("""
<div class="masthead">
<div class="mast-rule"></div>
<div class="mast-kicker">DSN Γ— BCT LLM Agent Challenge Β· Task A</div>
<div class="mast-title">User Modeling <span class="em">Agent</span></div>
<div class="mast-stand">
Give it a <em>user persona</em> and a <em>product</em>. It writes the star
rating and the review that user would write β€” weighing what they usually do
against what this specific item signals β€” then <em>critiques and revises</em>
its own draft before showing it.
</div>
<div class="mast-rule-bot"></div>
</div>
""", unsafe_allow_html=True)
try:
reviews, items = load_data()
except Exception as e:
st.error(f"Could not load data β€” ensure data/processed/*.parquet exist.\n\n{e}")
st.stop()
train = reviews[reviews["split"] == "train"]
test = reviews[reviews["split"] == "test"]
persona_engine, agent = get_engines()
with st.sidebar:
st.markdown("## ✢ Controls")
st.markdown(
'<div style="background:#1d3a2b;border:1px solid #3a5c46;border-radius:6px;'
'padding:0.7rem 0.85rem;margin-bottom:0.6rem">'
'<div style="font-family:Fraunces,serif;font-weight:600;font-size:1.05rem;'
'color:#f3ecdb">πŸ‡³πŸ‡¬ Naija Mode</div>'
'<div style="font-family:Spline Sans Mono,monospace;font-size:0.68rem;'
'color:#d8a64a;letter-spacing:0.04em;margin-top:0.15rem">'
'NIGERIAN-ENGLISH LOCALIZATION</div></div>',
unsafe_allow_html=True)
naija = st.toggle("Render output in Nigerian English", value=False)
if naija:
st.markdown(
'<div style="background:#d8a64a;border-radius:5px;padding:0.4rem 0.7rem;'
'margin-top:0.3rem"><span style="font-family:Spline Sans Mono,monospace;'
'font-size:0.72rem;font-weight:600;color:#1d3a2b">'
'● NAIJA MODE ACTIVE</span></div>', unsafe_allow_html=True)
else:
st.markdown(
'<div style="background:#3a4d40;border:1px solid #5a6b60;border-radius:5px;'
'padding:0.4rem 0.7rem;margin-top:0.3rem">'
'<span style="font-family:Spline Sans Mono,monospace;font-size:0.72rem;'
'font-weight:600;color:#9bb0a3">β—‹ OFF Β· STANDARD ENGLISH</span></div>',
unsafe_allow_html=True)
st.divider()
st.markdown("### How it works")
st.caption("The agent builds a persona, drafts a review in that voice, then "
"runs a self-reflection loop β€” a critic LLM checks rating-text "
"consistency, voice match and on-topic fit, and the agent revises "
"if the critic objects.")
st.divider()
_prov = {"openai": "OpenAI", "gemini": "Gemini"}.get(
settings.llm_provider.lower(), settings.llm_provider.capitalize())
st.caption(f"LLM Β· {_prov}")
st.session_state.setdefault("result", None)
st.session_state.setdefault("ctx", None)
if naija:
st.markdown(
'<div style="background:linear-gradient(90deg,#1d3a2b,#2c5440);'
'border-left:4px solid #d8a64a;border-radius:4px;padding:0.7rem 1.1rem;'
'margin:0.4rem 0 0.2rem;display:flex;align-items:center;gap:0.7rem">'
'<span style="font-size:1.3rem">πŸ‡³πŸ‡¬</span>'
'<span><span style="font-family:Fraunces,serif;font-weight:600;'
'font-size:1.02rem;color:#f3ecdb">Naija Mode is active</span>'
'<span style="font-family:Spline Sans Mono,monospace;font-size:0.74rem;'
'color:#e8c98a;margin-left:0.6rem">output localized to Nigerian English'
'</span></span></div>', unsafe_allow_html=True)
# ══════════════════════════════════════════════════════════════════════════════
# Tabs β€” Compose (primary) Β· Dataset reader (secondary)
# ══════════════════════════════════════════════════════════════════════════════
tab_compose, tab_dataset, tab_history = st.tabs([
"✎ Compose a Persona",
"⊞ Dataset Reader",
"❏ Build From Past Reviews"])
# ── COMPOSE ───────────────────────────────────────────────────────────────────
with tab_compose:
st.markdown('<div class="sec-label">Input Β· Persona and Product</div>',
unsafe_allow_html=True)
with st.expander("The Person", expanded=True):
p_desc = st.text_area(
"Describe the Person's Reviewing Voice",
value="Someone who loves character-driven stories and "
"rich world-building, but is impatient with slow pacing.",
height=90, key="p_desc")
p_themes = st.text_input("Drawn To (Comma-Separated)",
value="character development, immersive worlds, "
"original plots", key="p_themes")
p_dislikes = st.text_input("Put Off By (Comma-Separated)",
value="slow pacing, thin characters", key="p_dis")
c1, c2 = st.columns(2)
with c1:
p_tone = st.selectbox("Tone", ["enthusiastic", "analytical", "casual",
"critical", "earnest", "terse"], key="p_tone")
with c2:
p_rating = st.slider("Typical Rating", 1.0, 5.0, 4.0, 0.5, key="p_rate")
with st.expander("The Product", expanded=True):
i_title = st.text_input("Title", value="The Midnight Library", key="i_title")
i_domain = st.selectbox("Domain", ["Books", "Movies_and_TV", "Kindle_Store",
"Other"], key="i_domain")
i_desc = st.text_area(
"Description / Synopsis",
value="A novel about a library between life and death, where each "
"book lets a woman try a different version of her life.",
height=110, key="i_desc")
go = st.button("Generate review ✢", key="go_compose", use_container_width=True)
if go:
try:
with st.status("The agent is working…", expanded=True) as status:
themes = [t.strip() for t in p_themes.split(",") if t.strip()]
dislikes = [t.strip() for t in p_dislikes.split(",") if t.strip()]
st.write("Assembling the persona…")
persona = composed_persona(p_desc, themes, dislikes, p_tone, p_rating)
item = ItemInput(parent_asin="composed", title=i_title,
description=i_desc, categories="",
domain=i_domain)
st.write("Drafting in the person's voice, then self-critiquing…")
result = agent.run(persona, item, naija_mode=naija)
st.write("Self-reflection complete")
status.update(label="Review generated", state="complete")
st.session_state.result = result
st.session_state.ctx = {"persona": persona, "item": item, "truth": None}
except Exception as e:
st.session_state.result = None
st.markdown(f'<div class="card" style="border-left:3px solid var(--clay)">'
f'<div class="card-kicker">Generation interrupted</div>'
f'The model call did not complete β€” it may be rate-limited. '
f'Try again shortly.<br><span style="font-family:Spline Sans Mono,'
f'monospace;font-size:0.72rem;color:#6f6651">'
f'{esc(type(e).__name__)}</span></div>', unsafe_allow_html=True)
# ── DATASET READER ────────────────────────────────────────────────────────────
with tab_dataset:
st.markdown('<div class="sec-label">Input Β· A Real Person From the Data</div>',
unsafe_allow_html=True)
elig = train.groupby("user_id").size().reset_index(name="n")
elig = elig[(elig["n"] >= 5) & (elig["user_id"].isin(set(test["user_id"])))]
users = elig.sample(min(40, len(elig)), random_state=7)["user_id"].tolist()
with st.expander("The Person", expanded=True):
st.caption("Pick a real person. The agent builds their persona from "
"actual history and is scored against a held-out review.")
user = st.selectbox("Person", users, key="sel_user")
go_ds = st.button("Generate review ✢", key="go_ds", use_container_width=True)
if go_ds and user:
try:
with st.status("The agent is working…", expanded=True) as status:
ut = test[test["user_id"] == user]
if ut.empty:
status.update(label="No held-out item for this person",
state="error")
st.stop()
tr = ut.iloc[0]
tid = tr["parent_asin"]
meta = items[items["parent_asin"] == tid]
if meta.empty:
item = ItemInput(parent_asin=tid, title=str(tr.get("title", "")),
description="", categories="", domain=tr["domain"])
else:
m = meta.iloc[0]
item = ItemInput(parent_asin=tid, title=str(m.get("title", "")),
description=str(m.get("description", ""))[:1500],
categories=str(m.get("categories", "")),
domain=tr["domain"],
average_rating=(float(m["average_rating"])
if pd.notna(m.get("average_rating"))
else None))
st.write("Reading the person's history…")
persona = persona_engine.from_dataframe(user, train)
persona = persona_engine.enrich(persona)
st.write(f"Persona built from {persona.n_reviews} reviews")
st.write("Drafting in their voice, then self-critiquing…")
result = agent.run(persona, item, naija_mode=naija)
st.write("Self-reflection complete")
status.update(label="Review generated", state="complete")
st.session_state.result = result
st.session_state.ctx = {"persona": persona, "item": item,
"truth": {"rating": float(tr["rating"]),
"text": str(tr["text"])}}
except Exception as e:
st.session_state.result = None
st.markdown(f'<div class="card" style="border-left:3px solid var(--clay)">'
f'<div class="card-kicker">Generation interrupted</div>'
f'The model call did not complete β€” it may be rate-limited. '
f'Try again shortly.<br><span style="font-family:Spline Sans Mono,'
f'monospace;font-size:0.72rem;color:#6f6651">'
f'{esc(type(e).__name__)}</span></div>', unsafe_allow_html=True)
# ── BUILD FROM PAST REVIEWS ────────────────────────────────────────────────────
with tab_history:
st.markdown('<div class="sec-label">Input Β· Raw Past Reviews</div>',
unsafe_allow_html=True)
st.markdown("Paste a person's past reviews β€” the agent builds their persona "
"from this history, then writes a review of a new product. "
"Three to four reviews give the strongest persona.")
DOMAINS = ["Books", "Movies_and_TV", "Kindle_Store", "Other"]
hist_rows = []
for i in range(5):
with st.expander(f"Past Review {i + 1}", expanded=(i == 0)):
hc1, hc2, hc3 = st.columns([1, 2, 1])
with hc1:
h_rating = st.selectbox("Rating", [1.0, 2.0, 3.0, 4.0, 5.0],
index=3, key=f"h_rate_{i}")
with hc2:
h_title = st.text_input("Product Title", key=f"h_title_{i}",
placeholder="e.g. The Silent Patient")
with hc3:
h_domain = st.selectbox("Domain", DOMAINS, key=f"h_dom_{i}")
h_text = st.text_area("Review Text", key=f"h_text_{i}", height=80,
placeholder="Paste what this person wrote\u2026")
h_date = st.text_input("Date (Optional, e.g. 2024-03)", key=f"h_date_{i}",
placeholder="optional")
if h_text.strip():
hist_rows.append({"rating": h_rating, "title": h_title.strip(),
"domain": h_domain, "text": h_text.strip(),
"date": h_date.strip() or None})
st.markdown('<div class="sec-label" style="margin-top:0.8rem">'
'The New Product to Review</div>', unsafe_allow_html=True)
th1, th2 = st.columns([2, 1])
with th1:
ht_title = st.text_input("Title", value="The Midnight Library",
key="ht_title")
with th2:
ht_domain = st.selectbox("Domain ", DOMAINS, key="ht_domain")
ht_desc = st.text_area("Description / Synopsis", height=90, key="ht_desc",
value="A novel about a library between life and death, "
"where each book lets a woman try a different "
"version of her life.")
go_hist = st.button("Build persona & generate review ✢", key="go_hist",
use_container_width=True)
if go_hist:
if not hist_rows:
st.warning("Add at least one past review with text so the agent "
"has history to model.")
else:
try:
with st.status("The agent is working…", expanded=True) as status:
st.write(f"Reading {len(hist_rows)} pasted review(s)…")
persona = persona_from_reviews(hist_rows)
st.write(f"Persona built by the agent from "
f"{persona.n_reviews} reviews")
item = ItemInput(parent_asin="pasted_target", title=ht_title,
description=ht_desc, categories="",
domain=ht_domain)
st.write("Drafting in the inferred voice, then self-critiquing…")
result = agent.run(persona, item, naija_mode=naija)
st.write("Self-reflection complete")
status.update(label="Review generated", state="complete")
st.session_state.result = result
st.session_state.ctx = {"persona": persona, "item": item,
"truth": None}
except Exception as e:
st.session_state.result = None
st.markdown(f'<div class="card" style="border-left:3px solid var(--clay)">'
f'<div class="card-kicker">Generation interrupted</div>'
f'The model call did not complete \u2014 it may be '
f'rate-limited. Try again shortly.<br>'
f'<span style="font-family:Spline Sans Mono,monospace;'
f'font-size:0.72rem;color:#6f6651">'
f'{esc(type(e).__name__)}</span></div>',
unsafe_allow_html=True)
# ══════════════════════════════════════════════════════════════════════════════
# Result β€” shown below both tabs
# ══════════════════════════════════════════════════════════════════════════════
res = st.session_state.result
ctx = st.session_state.ctx
st.markdown("---")
if res and ctx:
st.markdown(persona_card(ctx["persona"]), unsafe_allow_html=True)
it = ctx["item"]
st.markdown(f"""
<div class="card reveal d2">
<div class="card-kicker">The Item</div>
<span style="font-family:Spline Sans Mono,monospace;font-size:0.6rem;
letter-spacing:0.13em;text-transform:uppercase;color:var(--pine-2)">
{esc(it.domain)}</span>
<div style="font-family:Fraunces,serif;font-weight:600;font-size:1.14rem;
color:var(--ink);margin-top:0.1rem">{esc(it.title)}</div>
</div>""", unsafe_allow_html=True)
badge = '<span class="naija-badge">NAIJA VOICE</span>' if res.naija_mode else ""
st.markdown(f"""
<div class="panel reveal d3">
<div class="card-kicker">The Generated Review Β· written as the person</div>
<div class="rating-row">
<span class="rating-chip">{res.rating:.1f}</span>
<span class="stars">{stars(res.rating)}</span>{badge}
</div>
<div class="review-body">{esc(res.review)}</div>
</div>""", unsafe_allow_html=True)
st.markdown(reflection_stepper(res.reflection_iterations,
res.reflection_refined,
res.reflection_notes), unsafe_allow_html=True)
st.markdown('<div class="sec-label">Why This Rating</div>', unsafe_allow_html=True)
truth = ctx.get("truth")
if truth:
col1, col2 = st.columns(2)
with col1:
st.markdown(f"""
<div class="cmp agent reveal d1">
<div class="cmp-head">The agent rated it {res.rating:.1f}β˜…</div>
<div class="cmp-body">{esc(res.reasoning)}</div>
</div>""", unsafe_allow_html=True)
with col2:
d = abs(res.rating - truth["rating"])
dc = "good" if d <= 0.5 else ("mid" if d <= 1.0 else "far")
t = truth["text"].replace("<br />", "\n").replace("<br>", "\n")
t = t[:520] + ("…" if len(t) > 520 else "")
st.markdown(f"""
<div class="cmp truth reveal d2">
<div class="cmp-head">The person actually wrote &nbsp;
<span class="delta {dc}">Ξ” {d:.1f}β˜…</span></div>
<div style="margin:0.15rem 0 0.35rem">
<span class="stars" style="color:var(--pine-2)">{stars(truth['rating'])}</span>
<span style="font-family:Spline Sans Mono,monospace;font-size:0.74rem;
color:#6f6651"> {truth['rating']:.1f}β˜…</span></div>
<div class="cmp-body">{esc(t)}</div>
</div>""", unsafe_allow_html=True)
else:
st.markdown(f"""
<div class="cmp agent reveal d1">
<div class="cmp-head">The agent rated it {res.rating:.1f}β˜…</div>
<div class="cmp-body">{esc(res.reasoning)}</div>
</div>""", unsafe_allow_html=True)
st.caption(f"Grounded on {res.used_history_count} similar past reviews")
else:
st.markdown('<div class="empty">Compose a persona and a product, or pick a '
'dataset person β€” then press <b>Generate</b>. The agent writes '
'the review in that person\'s voice and shows its reasoning.</div>',
unsafe_allow_html=True)
st.markdown("""
<div class="foot">
User Modeling Agent Β· DSN Γ— BCT LLM Agent Challenge 2026 Β·
persona β†’ draft in-voice β†’ self-reflection critique &amp; revise Β·
rating predicted as persona prior adjusted by item evidence
</div>
""", unsafe_allow_html=True)