Spaces:
Running
Running
| """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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_data(): | |
| rev = pd.read_parquet(settings.processed_dir / "reviews.parquet") | |
| items = pd.read_parquet(settings.processed_dir / "items.parquet") | |
| return rev, items | |
| 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 | |
| <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 & revise Β· | |
| rating predicted as persona prior adjusted by item evidence | |
| </div> | |
| """, unsafe_allow_html=True) | |