Spaces:
Sleeping
Sleeping
| """Recommendation Agent — the demo. | |
| DSN × BCT LLM Agent Challenge · Task B. | |
| Takes a user persona as input and produces ten personalised recommendations, | |
| ranked, each with grounded reasoning. Detects the right regime automatically — | |
| warm (history), cold-start (no history → HyDE), cross-domain (recommend in | |
| unknown domains) — and critiques its own ranking before returning it. | |
| Three ways to use it: | |
| 1. Compose a persona — type a persona (the brief's input contract) | |
| 2. Dataset reader — pick a real user with history (warm / cross-domain) | |
| 3. Nigerian persona — a cold-start demo persona, handled via HyDE | |
| 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 core.nigerian import naija_persona_examples, naija_style_review | |
| from core.clarifier import (should_clarify, generate_clarifying_question, | |
| apply_clarification) | |
| from task_b_recommender.agent import RecommendationAgent | |
| st.set_page_config(page_title="Recommendation Agent", page_icon="✦", | |
| layout="wide", initial_sidebar_state="expanded") | |
| esc = html.escape | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Design system (shared visual language with the User Modeling Agent) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| 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; } | |
| .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; } | |
| .modestrip { display:inline-block; font-family:'Spline Sans Mono',monospace; | |
| font-size:0.66rem; letter-spacing:0.1em; text-transform:uppercase; font-weight:600; | |
| padding:0.22rem 0.7rem; border-radius:999px; margin-bottom:0.15rem; } | |
| .modestrip.warm { background:#e9f0e2; color:var(--pine); border:1px solid #cdd9bf; } | |
| .modestrip.cold { background:#f0ddd2; color:var(--clay); border:1px solid #e3c4b4; } | |
| .modestrip.cross { background:#f3e6c8; color:#8a6420; border:1px solid #e0cd9c; } | |
| .mode-note { font-family:'Newsreader',serif; font-style:italic; font-size:0.96rem; | |
| color:var(--muted); margin:0.1rem 0 0.6rem; } | |
| .rec { display:flex; gap:1rem; align-items:flex-start; background:var(--paper-2); | |
| border:1px solid var(--hair); border-left:3px solid var(--ochre); border-radius:2px; | |
| padding:0.7rem 0.95rem; margin-bottom:0.5rem; position:relative; z-index:1; } | |
| .rec:hover { border-left-color:var(--clay); } | |
| .rec-rank { font-family:'Fraunces',serif; font-weight:900; font-size:1.5rem; | |
| color:var(--clay); line-height:1; min-width:2.1rem; } | |
| .rec-domain { font-family:'Spline Sans Mono',monospace; font-size:0.6rem; | |
| letter-spacing:0.13em; text-transform:uppercase; color:var(--pine-2); } | |
| .rec-title { font-family:'Fraunces',serif; font-weight:600; font-size:1.05rem; | |
| color:var(--ink); margin:0.1rem 0 0.16rem; } | |
| .rec-why { font-family:'Newsreader',serif; font-size:0.95rem; color:#574f3c; line-height:1.42; } | |
| .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; } | |
| .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;} .d4{animation-delay:.31s;} | |
| .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 — fourth attempt, with a wider DOM net. | |
| Previous tries chased [role="switch"], which may be an invisible parent | |
| in your Streamlit version. base-web's Switch (the underlying component) | |
| actually renders the visible pill as data-baseweb="checkbox-checkmark" | |
| or "checkbox-track". We target both, with [role="switch"] kept as a | |
| safety net for older builds. `html body` prefix wins the cascade against | |
| base-web's CSS-in-JS. */ | |
| html body [data-testid="stSidebar"] [data-baseweb="checkbox-checkmark"], | |
| html body [data-testid="stSidebar"] [data-baseweb="checkbox-track"], | |
| html body [data-testid="stSidebar"] [role="switch"], | |
| html body [data-testid="stSidebar"] label[data-baseweb="checkbox"] > div:first-child, | |
| html body [data-testid="stSidebar"] label[data-baseweb="checkbox"] > span:first-child { | |
| background-color:#f3ecdb !important; | |
| border:3px solid #d8a64a !important; | |
| box-shadow:inset 0 1px 2px rgba(20,36,27,0.3) !important; | |
| border-radius:14px !important; | |
| width:48px !important; | |
| min-width:48px !important; | |
| height:26px !important; | |
| } | |
| html body [data-testid="stSidebar"] [aria-checked="true"] [data-baseweb="checkbox-checkmark"], | |
| html body [data-testid="stSidebar"] [aria-checked="true"] [data-baseweb="checkbox-track"], | |
| html body [data-testid="stSidebar"] [role="switch"][aria-checked="true"], | |
| html body [data-testid="stSidebar"] label[data-baseweb="checkbox"][aria-checked="true"] > div:first-child, | |
| html body [data-testid="stSidebar"] label[aria-checked="true"][data-baseweb="checkbox"] > span:first-child { | |
| background-color:#d8a64a !important; | |
| border-color:#fffdf6 !important; | |
| box-shadow:0 0 0 3px rgba(216,166,74,0.25) !important; | |
| } | |
| /* Inner knob — we stop fighting base-web for the existing knob element | |
| and instead paint our own with a ::after pseudo-element on the pill. | |
| 1. Hide whatever base-web renders inside the pill (opacity 0). | |
| 2. Layer our own circle on top via ::after — we fully control this | |
| because we created it; there's no base-web style to override. | |
| 3. Animate the position when aria-checked flips for a natural feel. */ | |
| html body [data-testid="stSidebar"] [data-baseweb="checkbox-checkmark"], | |
| html body [data-testid="stSidebar"] [data-baseweb="checkbox-track"], | |
| html body [data-testid="stSidebar"] [role="switch"] { | |
| position:relative !important; | |
| } | |
| html body [data-testid="stSidebar"] [data-baseweb="checkbox-checkmark"] > *, | |
| html body [data-testid="stSidebar"] [data-baseweb="checkbox-track"] > *, | |
| html body [data-testid="stSidebar"] [role="switch"] > * { | |
| opacity:0 !important; | |
| } | |
| html body [data-testid="stSidebar"] [data-baseweb="checkbox-checkmark"]::after, | |
| html body [data-testid="stSidebar"] [data-baseweb="checkbox-track"]::after, | |
| html body [data-testid="stSidebar"] [role="switch"]::after { | |
| content:"" !important; | |
| position:absolute !important; | |
| top:50% !important; | |
| left:3px !important; | |
| transform:translateY(-50%) !important; | |
| width:16px !important; | |
| height:16px !important; | |
| border-radius:50% !important; | |
| background:#14241b !important; | |
| background-color:#14241b !important; | |
| z-index:5 !important; | |
| pointer-events:none !important; | |
| transition:left 0.22s ease, background-color 0.22s ease !important; | |
| box-shadow:0 1px 2px rgba(0,0,0,0.35) !important; | |
| } | |
| /* ON state — our knob slides right and turns black. */ | |
| html body [data-testid="stSidebar"] [aria-checked="true"] [data-baseweb="checkbox-checkmark"]::after, | |
| html body [data-testid="stSidebar"] [aria-checked="true"] [data-baseweb="checkbox-track"]::after, | |
| html body [data-testid="stSidebar"] [role="switch"][aria-checked="true"]::after { | |
| left:auto !important; | |
| right:3px !important; | |
| background:#000000 !important; | |
| background-color:#000000 !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 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>' | |
| hist = f'{p.n_reviews}' if p.n_reviews else 'no history' | |
| doms = ", ".join(p.domains) if p.domains else "—" | |
| 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">{hist}</div><div class="lab">history</div></div> | |
| <div class="pstat"><div class="num">{esc(doms)}</div><div class="lab">domains</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 ranking</div>' | |
| '<div class="st-sub">retrieved & reranked</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">Re-ranked</div>' | |
| '<div class="st-sub">revised 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>""" | |
| def looks_like_review_headline(title: str) -> bool: | |
| """Detect titles that are actually review headlines, not product titles. | |
| The catalogue metadata is mixed — some items carry a review's headline | |
| ("An Engaging Read", "Captivating, Dark, Romantic…") in the title field | |
| instead of the real product title. This mirrors the detection in | |
| core/retrieval.py so the displayed ranking shows only genuine titles. | |
| """ | |
| t = (title or "").strip() | |
| if not t or t.lower() in ("none", "nan"): | |
| return True | |
| low = t.lower() | |
| if low.startswith("review ") or low.startswith('"review '): | |
| return True | |
| starters = ("enjoyable", "great", "wonderful", "amazing", "boring", "fun", | |
| "fantastic", "loved", "hated", "good", "bad", "interesting", | |
| "an entertaining", "a fun", "a great", "a wonderful", | |
| "a very", "highly", "must read", "couldn't", "very ", "so ", | |
| "really ", "what a ", "this is ", "this was ", "this book", | |
| "this movie", "best ", "worst ", "decent", "excellent", | |
| "terrible", "fast paced", "fast-paced", "well written", | |
| "well-written", "captivating", "clever ", "a hundred") | |
| if low.startswith(starters): | |
| return True | |
| reviewy = (" read ", " read.", " read,", "read!", " read for ", | |
| "this book", "this movie", "this film", "this novel", | |
| "the book", "the movie", " novel.", " story.", "must-read", | |
| "page-turner", "page turner", "enjoyable read", | |
| "engaging read", "beautifully written") | |
| if any(w in low for w in reviewy): | |
| return True | |
| if t.endswith("!") or t.endswith("?"): | |
| return True | |
| if t.islower() and len(t.split()) >= 3: | |
| return True | |
| return False | |
| def clean_ranking(recs: list) -> list: | |
| """Drop recommendations whose title is a review-headline fragment, and | |
| drop duplicate titles — so the displayed ranking is only genuine, | |
| distinct titles. Ranks are renumbered to stay contiguous. | |
| """ | |
| seen = set() | |
| kept = [] | |
| for r in recs: | |
| key = (r.title or "").strip().lower() | |
| if looks_like_review_headline(r.title): | |
| continue | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| kept.append(r) | |
| # renumber ranks 1..n so the list reads cleanly | |
| for new_rank, r in enumerate(kept, 1): | |
| try: | |
| r.rank = new_rank | |
| except Exception: | |
| pass | |
| return kept | |
| def rec_row(rank: int, domain: str, title: str, why: str, delay: str) -> str: | |
| return f""" | |
| <div class="rec reveal {delay}"> | |
| <div class="rec-rank">{rank:02d}</div> | |
| <div> | |
| <div class="rec-domain">{esc(domain)}</div> | |
| <div class="rec-title">{esc(title)}</div> | |
| <div class="rec-why">{esc(why)}</div> | |
| </div> | |
| </div>""" | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Cached resources | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def load_data(): | |
| rev = pd.read_parquet(settings.processed_dir / "reviews.parquet") | |
| return rev | |
| def get_engines(): | |
| return PersonaEngine(), RecommendationAgent(use_review_enrichment=False) | |
| def composed_persona(desc: str, themes: list[str], dislikes: list[str]) -> UserPersona: | |
| """Build a no-history UserPersona from typed input — routes to cold-start / HyDE.""" | |
| return UserPersona( | |
| user_id="composed", n_reviews=0, avg_rating=4.0, | |
| std_rating=0.5, avg_review_length=80.0, std_review_length=20.0, | |
| verified_rate=1.0, domains=[], n_domains=0, | |
| rating_distribution={3: 0.1, 4: 0.6, 5: 0.3}, top_terms=[], | |
| tone="", preferred_themes=themes, common_complaints=dislikes, | |
| voice_one_liner=desc, history_samples=[], | |
| ) | |
| def persona_from_history(rows: list[dict]) -> UserPersona: | |
| """Build a UserPersona from pasted past items — the agent models the | |
| person itself from raw history, then recommends. | |
| Each row: {rating, title, domain, note(optional)}. Ratings and titles | |
| alone carry real signal: a highly-rated title is a strong "more like | |
| this" preference. Notes, when given, add qualitative taste language. | |
| The rows are assembled into the dataframe shape PersonaEngine expects, | |
| then the engine builds the persona; on top of that, liked titles are | |
| seeded as explicit preferences so retrieval has a concrete query even | |
| when no notes were written. | |
| """ | |
| import pandas as _pd | |
| records = [] | |
| for i, r in enumerate(rows): | |
| note = (r.get("note") or "").strip() | |
| title = r["title"] | |
| rating = float(r["rating"]) | |
| # Build a review-like text the engine can read. With a note, use it; | |
| # without, synthesise an honest sentence from the rating so the text | |
| # still carries sentiment rather than being a bare title. | |
| if note: | |
| text = f"{note}" | |
| elif rating >= 4: | |
| text = f"Really enjoyed {title} — rated it {rating:.0f} stars." | |
| elif rating <= 2: | |
| text = f"Did not enjoy {title} — rated it {rating:.0f} stars." | |
| else: | |
| text = f"{title} was okay — rated it {rating:.0f} stars." | |
| records.append({ | |
| "user_id": "pasted", | |
| "parent_asin": f"pasted_{i}", | |
| "rating": rating, | |
| "text": text, | |
| "verified_purchase": True, | |
| "domain": r["domain"], | |
| "timestamp": _pd.Timestamp("2021-01-01") + _pd.Timedelta(days=i), | |
| }) | |
| df = _pd.DataFrame(records) | |
| engine = PersonaEngine() | |
| persona = engine.from_dataframe("pasted", df) | |
| persona = engine.enrich(persona) | |
| # Seed liked titles (rating >= 4) as explicit preferences — a concrete | |
| # "more like these" signal so retrieval builds a strong query even when | |
| # the persona's inferred themes are thin. | |
| liked = [r["title"] for r in rows if float(r["rating"]) >= 4] | |
| if liked: | |
| seed = [f"books and titles in the spirit of {t}" for t in liked[:5]] | |
| existing = list(persona.preferred_themes or []) | |
| persona.preferred_themes = (seed + existing)[:12] | |
| if not (persona.voice_one_liner or "").strip(): | |
| persona.voice_one_liner = ( | |
| "A reader whose favourites include " | |
| + ", ".join(liked[:5]) + ".") | |
| return persona | |
| import copy as _copy | |
| from pydantic import BaseModel as _BaseModel, Field as _Field | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Conversation engine — multi-turn memory | |
| # | |
| # The core agent (agent.run) accepts a conversation_context string. This layer | |
| # maintains memory and, each turn, both (a) re-shapes the persona so RETRIEVAL | |
| # fetches a different candidate pool, and (b) renders the context string so the | |
| # RERANKER reasons over the whole dialogue. | |
| # Stage 1 — refinement narrows from the previous turn, never restarts. | |
| # Stage 2 — preferences accumulate; turn N uses turns 1..N-1. | |
| # Stage 3 — rejections + their reason are extracted, cleaned, and remembered. | |
| # Stage 5 — a topic switch keeps the taste profile but changes domain. | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| _DOMAIN_WORDS = { | |
| "Movies_and_TV": ["movie", "film", "watch", "tv", "series", "show", "cinema"], | |
| "Books": ["book", "novel", "read", "author", "fiction"], | |
| "Kindle_Store": ["kindle", "ebook"], | |
| } | |
| class _TurnAnalysis(_BaseModel): | |
| """Structured read of a single conversation turn.""" | |
| kind: str = _Field(description="one of: refine, reject, switch") | |
| rejected_title: str = _Field(default="", description="title the user " | |
| "rejected, verbatim, or empty if none") | |
| reason: str = _Field(default="", description="the SHORT signal phrase " | |
| "behind a rejection or preference, e.g. 'too slow', " | |
| "'slow openings' — not the whole sentence") | |
| positive_signal: str = _Field(default="", description="what the user now " | |
| "wants, as a short phrase, or empty") | |
| target_medium: str = _Field(default="", description="one of: Books, " | |
| "Movies_and_TV, Kindle_Store — only if the " | |
| "user is switching medium, else empty") | |
| def new_conversation(base_persona: UserPersona) -> dict: | |
| """Initialise conversation memory from a starting persona.""" | |
| return { | |
| "base_persona": base_persona, | |
| "turns": [], # [{text, kind}] | |
| "likes": list(base_persona.preferred_themes), | |
| "dislikes": list(base_persona.common_complaints), | |
| "rejected_titles": [], # [{title, reason}] | |
| "last_recs": [], | |
| "domain_focus": list(base_persona.domains), | |
| } | |
| def _keyword_fallback(instruction: str, last_recs: list) -> _TurnAnalysis: | |
| """Deterministic fallback if the LLM classifier call fails.""" | |
| low = instruction.lower() | |
| if any(w in low for w in ["tried that", "already read", "already seen", | |
| "didn't like", "did not like", "not a fan", | |
| "i tried", "no,"]): | |
| title = "" | |
| for r in last_recs: | |
| if r.title.lower() in low: | |
| title = r.title | |
| break | |
| return _TurnAnalysis(kind="reject", rejected_title=title, | |
| reason=instruction.strip()) | |
| for dom, words in _DOMAIN_WORDS.items(): | |
| if any(w in low for w in words) and any( | |
| c in low for c in ["like this", "based on", "what about", | |
| "instead", "version of", "adaptation"]): | |
| return _TurnAnalysis(kind="switch", target_medium=dom) | |
| return _TurnAnalysis(kind="refine", positive_signal=instruction.strip()) | |
| def analyse_turn(instruction: str, last_recs: list) -> _TurnAnalysis: | |
| """Fix #3/#4 — classify the turn with an LLM, not brittle keywords. | |
| The LLM reads the turn and the titles on the table and returns a | |
| structured analysis: kind, the rejected title (verbatim), the SHORT | |
| signal phrase, and any medium switch. Falls back to keywords on failure. | |
| """ | |
| titles = ", ".join(r.title for r in last_recs[:8]) or "(none yet)" | |
| prompt = ( | |
| f"You are analysing one turn of a recommendation conversation.\n" | |
| f"Items currently recommended: {titles}\n" | |
| f"The user just said: \u201c{instruction.strip()}\u201d\n\n" | |
| f"Classify this turn:\n" | |
| f"- kind 'reject' if they are rejecting a specific item or a quality " | |
| f"of the last picks; 'switch' if they want a different medium " | |
| f"(book vs movie); 'refine' otherwise.\n" | |
| f"- rejected_title: if a reject, the exact title from the list above " | |
| f"they mean (verbatim), else empty.\n" | |
| f"- reason: the SHORT phrase capturing the signal — e.g. 'too slow', " | |
| f"'slow openings', 'too dark' — never the whole sentence.\n" | |
| f"- positive_signal: what they now want, short phrase, or empty.\n" | |
| f"- target_medium: Books / Movies_and_TV / Kindle_Store if switching, " | |
| f"else empty." | |
| ) | |
| try: | |
| return agent.llm.structured(prompt, schema=_TurnAnalysis, | |
| model="bulk") | |
| except Exception: | |
| return _keyword_fallback(instruction, last_recs) | |
| def apply_turn(mem: dict, instruction: str) -> tuple[UserPersona, str, str]: | |
| """Advance the conversation by one turn. | |
| Returns (persona_for_this_turn, conversation_context_string, turn_kind). | |
| The persona is re-shaped so RETRIEVAL changes; the context string makes | |
| the RERANKER reason over the dialogue. | |
| """ | |
| instr = instruction.strip() | |
| a = analyse_turn(instr, mem["last_recs"]) | |
| kind = a.kind if a.kind in ("refine", "reject", "switch") else "refine" | |
| # Stage 3 — negative feedback: store the CLEAN reason (fix #2) | |
| if kind == "reject": | |
| clean_reason = a.reason.strip() or instr | |
| title = a.rejected_title.strip() | |
| if not title and mem["last_recs"]: | |
| title = "" # fix #4 — do NOT guess; unknown title stays blank | |
| mem["rejected_titles"].append({"title": title, "reason": clean_reason}) | |
| mem["dislikes"].append(clean_reason) | |
| else: | |
| # Stage 2 — accumulate the clean positive signal, not raw text | |
| signal = a.positive_signal.strip() or instr | |
| mem["likes"].append(signal) | |
| # Stage 5 — topic switch: move the domain focus, keep the taste | |
| if kind == "switch" and a.target_medium in _DOMAIN_WORDS: | |
| mem["domain_focus"] = [a.target_medium] | |
| mem["turns"].append({"text": instr, "kind": kind}) | |
| # ── Fix #1 — the turn signal must reach RETRIEVAL, not just rerank ── | |
| # Retrieval builds its query from preferred_themes. So the current | |
| # turn's signal goes to the FRONT of preferred_themes with weight, and | |
| # rejection signals are removed from the theme query. This changes the | |
| # candidate pool, not only its ordering. | |
| p = _copy.deepcopy(mem["base_persona"]) | |
| current_signal = (a.positive_signal.strip() or instr) if kind != "reject" else "" | |
| themes = list(mem["likes"]) | |
| if current_signal: | |
| # current request leads the query, repeated for retrieval weight | |
| themes = [current_signal, current_signal] + themes | |
| p.preferred_themes = themes[:12] | |
| p.common_complaints = mem["dislikes"][-10:] | |
| p.domains = mem["domain_focus"] | |
| p.n_domains = len(mem["domain_focus"]) | |
| # voice carries the current ask so query-building for cold-start sees it | |
| p.voice_one_liner = (mem["base_persona"].voice_one_liner + | |
| (f" Current request: {current_signal}." | |
| if current_signal else | |
| f" The user is steering away from: {a.reason}.")) | |
| p.user_id = mem["base_persona"].user_id + "+conv" | |
| ctx = _render_context(mem, a) | |
| return p, ctx, kind | |
| def _render_context(mem: dict, latest: _TurnAnalysis) -> str: | |
| """Render conversation memory into the context string for the core.""" | |
| lines: list[str] = [] | |
| if mem["base_persona"].voice_one_liner: | |
| lines.append(f"Starting profile: {mem['base_persona'].voice_one_liner}") | |
| for i, t in enumerate(mem["turns"], 1): | |
| tag = {"reject": "REJECTION", "switch": "TOPIC SWITCH", | |
| "refine": "follow-up"}[t["kind"]] | |
| lines.append(f"Turn {i} ({tag}): \u201c{t['text']}\u201d") | |
| if mem["last_recs"]: | |
| prev = ", ".join(r.title for r in mem["last_recs"][:6]) | |
| lines.append(f"Previously recommended: {prev}. " | |
| f"Narrow from these rather than starting over.") | |
| if mem["rejected_titles"]: | |
| for rej in mem["rejected_titles"]: | |
| t = rej["title"] or "an earlier pick" | |
| lines.append(f"REJECTED: {t} — the user disliked: " | |
| f"\u201c{rej['reason']}\u201d. Never recommend that " | |
| f"title again, and avoid other items with the same " | |
| f"problem.") | |
| if mem["likes"]: | |
| lines.append("Everything the user has expressed liking for, across " | |
| "all turns: " + "; ".join(mem["likes"][-12:]) + ".") | |
| if latest.kind == "switch" and latest.target_medium: | |
| lines.append(f"The user has switched to {latest.target_medium}. " | |
| f"Keep the taste profile above, recommend in that medium.") | |
| return "\n".join(lines) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Masthead | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| st.markdown(""" | |
| <div class="masthead"> | |
| <div class="mast-rule"></div> | |
| <div class="mast-kicker">DSN × BCT LLM Agent Challenge · Task B</div> | |
| <div class="mast-title">Recommendation <span class="em">Agent</span></div> | |
| <div class="mast-stand"> | |
| Describe a person, paste their history, or pick one from the data. The | |
| agent ranks ten titles — books, films or Kindle reads — they | |
| should enjoy next, handling <em>cold-start</em>, <em>warm</em> and | |
| <em>cross-domain</em> automatically, and critiques its own ranking before | |
| showing it. Not enough to go on? It asks. Then <em>keep the conversation | |
| going</em> — refine, reject a pick, or switch medium, and it remembers. | |
| </div> | |
| <div class="mast-rule-bot"></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| try: | |
| reviews = load_data() | |
| except Exception as e: | |
| st.error(f"Could not load data — ensure data/processed/reviews.parquet exists " | |
| f"and the ChromaDB index is in data/chroma/.\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 reasoning 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 reads a persona, then retrieves candidates — for " | |
| "cold-start it uses HyDE, imagining ideal items and matching " | |
| "them to the catalogue. An LLM reranks to a top ten, then a " | |
| "self-reflection loop critiques the ranking and re-ranks 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("recs", None) | |
| st.session_state.setdefault("ctx", None) | |
| st.session_state.setdefault("conv", None) | |
| st.session_state.setdefault("pending_clarification", 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">top pick reasoning localized to ' | |
| 'Nigerian English</span></span></div>', unsafe_allow_html=True) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Tabs | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| tab_compose, tab_dataset, tab_naija, tab_history = st.tabs( | |
| ["✎ Compose a persona", "⊞ Dataset reader", "🇳🇬 Nigerian persona", | |
| "❏ Build from history"]) | |
| # ── COMPOSE ─────────────────────────────────────────────────────────────────── | |
| with tab_compose: | |
| st.markdown('<div class="sec-label">Input · User Persona</div>', | |
| unsafe_allow_html=True) | |
| with st.expander("The Person", expanded=True): | |
| st.caption("With no history, the agent treats this as cold-start and " | |
| "uses HyDE to find what this person would love.") | |
| p_desc = st.text_area( | |
| "Describe the person", | |
| value="Someone who loves fast-paced psychological thrillers and " | |
| "twist-driven mysteries, and dislikes slow, predictable plots.", | |
| height=85, key="cp_desc") | |
| p_themes = st.text_input("Drawn to (comma-separated)", | |
| value="psychological thrillers, plot twists, " | |
| "suspense, unreliable narrators", key="cp_themes") | |
| p_dis = st.text_input("Put off by (comma-separated)", | |
| value="slow pacing, predictable endings", key="cp_dis") | |
| go_c = st.button("Recommend ✦", key="go_compose", use_container_width=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=11)["user_id"].tolist() | |
| with st.expander("The Person", expanded=True): | |
| st.caption("Pick a person with real history. Tick cross-domain to " | |
| "recommend in domains they have never engaged with.") | |
| user = st.selectbox("Person", users, key="sel_user") | |
| cross = st.checkbox("Cross-domain — recommend in unknown domains", | |
| key="cross") | |
| go_d = st.button("Recommend ✦", key="go_ds", use_container_width=True) | |
| # ── NIGERIAN PERSONA ────────────────────────────────────────────────────────── | |
| with tab_naija: | |
| st.markdown('<div class="sec-label">Input · A Nigerian Cold-Start Persona</div>', | |
| unsafe_allow_html=True) | |
| demos = naija_persona_examples() | |
| with st.expander("The Person", expanded=True): | |
| st.caption("A Nigerian person with no purchase history — handled " | |
| "through HyDE. Demonstrates cold-start and Nigerian " | |
| "contextualization.") | |
| demo_name = st.selectbox("Persona", [d["name"] for d in demos], | |
| key="sel_demo") | |
| chosen = next(d for d in demos if d["name"] == demo_name) | |
| st.markdown(f'<p style="font-family:Newsreader,serif;font-style:italic;' | |
| f'color:#5a5340;font-size:1.0rem">{esc(chosen["description"])}</p>', | |
| unsafe_allow_html=True) | |
| go_n = st.button("Recommend ✦", key="go_naija", use_container_width=True) | |
| # ── BUILD FROM HISTORY ──────────────────────────────────────────────────────── | |
| with tab_history: | |
| st.markdown('<div class="sec-label">Input · Raw Reading / Watching History</div>', | |
| unsafe_allow_html=True) | |
| st.session_state.setdefault("hist_slots", 3) | |
| with st.expander("The Person's Past Items", expanded=True): | |
| st.caption("Enter a few things this person has read or watched — the " | |
| "rating they gave, and a short note on what they thought. " | |
| "The agent builds their persona from this history itself, " | |
| "then recommends. Three to five items give the strongest " | |
| "persona.") | |
| DOMS = ["Books", "Movies_and_TV", "Kindle_Store", "Other"] | |
| hist_rows = [] | |
| for i in range(st.session_state.hist_slots): | |
| hc1, hc2, hc3 = st.columns([1, 2, 1]) | |
| with hc1: | |
| h_rate = 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("Title", key=f"h_title_{i}", | |
| placeholder="e.g. Half of a Yellow Sun") | |
| with hc3: | |
| h_dom = st.selectbox("Domain", DOMS, key=f"h_dom_{i}") | |
| h_note = st.text_input("What they thought of it", | |
| key=f"h_note_{i}", | |
| placeholder="e.g. loved the pacing and the twist") | |
| # an item counts as soon as it has a title; the note adds taste | |
| # signal when present but is not required | |
| if h_title.strip(): | |
| hist_rows.append({"rating": h_rate, "title": h_title.strip(), | |
| "domain": h_dom, | |
| "note": h_note.strip() or None}) | |
| ha1, ha2 = st.columns(2) | |
| with ha1: | |
| if st.button("+ Add another item", key="hist_add", | |
| use_container_width=True): | |
| st.session_state.hist_slots += 1 | |
| st.rerun() | |
| with ha2: | |
| if (st.session_state.hist_slots > 1 | |
| and st.button("– Remove last", key="hist_del", | |
| use_container_width=True)): | |
| st.session_state.hist_slots -= 1 | |
| st.rerun() | |
| go_h = st.button("Build persona & recommend ✦", key="go_hist", | |
| use_container_width=True) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Run handlers | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| def run_agent(persona: UserPersona, cross_domain: bool, build_msg: str, | |
| conversation_context: str | None = None): | |
| """Shared run path with a live status panel.""" | |
| with st.status("The agent is working…", expanded=True) as status: | |
| st.write(build_msg) | |
| recs = agent.run(persona, k=10, cross_domain=cross_domain, | |
| conversation_context=conversation_context) | |
| st.write("Self-reflection complete") | |
| status.update(label="Recommendations ready", state="complete") | |
| return recs | |
| def err_card(e: Exception): | |
| st.session_state.recs = 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) | |
| def run_and_store(persona: UserPersona, cross_domain: bool, build_msg: str): | |
| """Run the agent, store results in session state, kick off conversation. | |
| Shared by the direct compose path and the post-clarification path so they | |
| behave identically. | |
| """ | |
| try: | |
| recs = run_agent(persona, cross_domain, build_msg) | |
| st.session_state.recs = recs | |
| st.session_state.ctx = {"persona": persona, "mode": agent.last_mode, | |
| "trace": agent.last_reflection_trace, | |
| "cand": agent.last_candidate_count} | |
| conv = new_conversation(persona) | |
| conv["last_recs"] = recs | |
| st.session_state.conv = conv | |
| except Exception as e: | |
| err_card(e) | |
| if go_c: | |
| try: | |
| themes = [t.strip() for t in p_themes.split(",") if t.strip()] | |
| dis = [t.strip() for t in p_dis.split(",") if t.strip()] | |
| persona = composed_persona(p_desc, themes, dis) | |
| if should_clarify(persona): | |
| # Sparse cold-start persona — ask one focused question first. | |
| # The clarification card renders below; running the agent is | |
| # deferred until the user picks an answer (or skips). | |
| question = generate_clarifying_question(persona) | |
| st.session_state.pending_clarification = { | |
| "persona": persona, "question": question, | |
| } | |
| st.rerun() | |
| else: | |
| run_and_store(persona, False, | |
| "No history — imagining ideal items, matching the " | |
| "catalogue (HyDE)…") | |
| except Exception as e: | |
| err_card(e) | |
| if go_d and user: | |
| try: | |
| persona = persona_engine.from_dataframe(user, train) | |
| persona = persona_engine.enrich(persona) | |
| recs = run_agent(persona, cross, | |
| "Reading history, retrieving and reranking candidates…") | |
| st.session_state.recs = recs | |
| st.session_state.ctx = {"persona": persona, "mode": agent.last_mode, | |
| "trace": agent.last_reflection_trace, | |
| "cand": agent.last_candidate_count} | |
| conv = new_conversation(persona) | |
| conv["last_recs"] = recs | |
| st.session_state.conv = conv | |
| except Exception as e: | |
| err_card(e) | |
| if go_n: | |
| try: | |
| persona = composed_persona(chosen["description"], | |
| chosen["stated_preferences"], | |
| chosen["deal_breakers"]) | |
| recs = run_agent(persona, False, | |
| "No history — imagining ideal items, matching the " | |
| "catalogue (HyDE)…") | |
| st.session_state.recs = recs | |
| st.session_state.ctx = {"persona": persona, "mode": agent.last_mode, | |
| "trace": agent.last_reflection_trace, | |
| "cand": agent.last_candidate_count} | |
| conv = new_conversation(persona) | |
| conv["last_recs"] = recs | |
| st.session_state.conv = conv | |
| except Exception as e: | |
| err_card(e) | |
| if go_h: | |
| if not hist_rows: | |
| st.warning("Add at least one past item with a title so the agent " | |
| "has history to model.") | |
| else: | |
| try: | |
| persona = persona_from_history(hist_rows) | |
| recs = run_agent(persona, False, | |
| f"Built the persona from {persona.n_reviews} past " | |
| f"items — retrieving and reranking…") | |
| st.session_state.recs = recs | |
| st.session_state.ctx = {"persona": persona, "mode": agent.last_mode, | |
| "trace": agent.last_reflection_trace, | |
| "cand": agent.last_candidate_count} | |
| conv = new_conversation(persona) | |
| conv["last_recs"] = recs | |
| st.session_state.conv = conv | |
| except Exception as e: | |
| err_card(e) | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Clarification — one focused question for very sparse cold-start personas. | |
| # Renders only when go_c stored a pending_clarification. Picking any answer | |
| # (or Skip) clears the pending state, runs the agent, and st.reruns so the | |
| # result section below renders normally on the next pass. | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| pc = st.session_state.pending_clarification | |
| if pc: | |
| q = pc["question"] | |
| st.markdown(f""" | |
| <div class="card reveal d1" style="border-left:3px solid var(--ochre)"> | |
| <div class="card-kicker">Quick question · sharpens the recommendations</div> | |
| <div class="persona-quote" style="border-left-color:var(--clay)">"{esc(q.question)}"</div> | |
| <div style="font-family:'Spline Sans Mono',monospace;font-size:0.66rem; | |
| letter-spacing:0.13em;text-transform:uppercase;color:var(--muted); | |
| margin-bottom:0.5rem">Pick one — or skip</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| cols = st.columns(len(q.quick_answers) + 1) | |
| for i, ans in enumerate(q.quick_answers): | |
| with cols[i]: | |
| if st.button(ans, key=f"clar_{i}", use_container_width=True): | |
| enriched = apply_clarification(pc["persona"], ans) | |
| st.session_state.pending_clarification = None | |
| run_and_store(enriched, False, | |
| f"Got it — focusing on '{ans.lower()}'…") | |
| st.rerun() | |
| with cols[-1]: | |
| if st.button("Skip — just recommend", key="clar_skip", | |
| use_container_width=True): | |
| persona = pc["persona"] | |
| st.session_state.pending_clarification = None | |
| run_and_store(persona, False, | |
| "No history — imagining ideal items, matching the " | |
| "catalogue (HyDE)…") | |
| st.rerun() | |
| # Hold the page here while clarifying — don't render the empty result | |
| # section underneath. | |
| st.stop() | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # Result | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| recs = st.session_state.recs | |
| ctx = st.session_state.ctx | |
| # display-time clean — drop review-headline fragments and duplicate titles | |
| # so the ranking shows only genuine, distinct titles (any tab) | |
| if recs: | |
| recs = clean_ranking(recs) | |
| st.markdown("---") | |
| if recs and ctx: | |
| st.markdown(persona_card(ctx["persona"]), unsafe_allow_html=True) | |
| mode = ctx["mode"] or "warm" | |
| mcls = {"warm": "warm", "cold_start": "cold", "cross_domain": "cross"}.get(mode, "warm") | |
| mlabel = {"warm": "Warm · history-based", | |
| "cold_start": "Cold-start · HyDE", | |
| "cross_domain": "Cross-domain · bridging"}.get(mode, mode) | |
| st.markdown(f'<span class="modestrip {mcls}">{mlabel}</span>', | |
| unsafe_allow_html=True) | |
| if mode == "cold_start": | |
| st.markdown('<div class="mode-note">No history to lean on — the agent ' | |
| 'generated hypothetical items this persona would love, then ' | |
| f'retrieved the closest real titles. {ctx["cand"]} candidates ' | |
| 'considered.</div>', unsafe_allow_html=True) | |
| elif mode == "cross_domain": | |
| st.markdown('<div class="mode-note">Recommending in domains the person ' | |
| 'has never touched — bridged from the tastes they have ' | |
| 'shown.</div>', unsafe_allow_html=True) | |
| trace = ctx["trace"] | |
| if trace is not None: | |
| st.markdown(reflection_stepper(getattr(trace, "iterations_run", 1), | |
| getattr(trace, "refined", False), | |
| getattr(trace, "critiques", [])), | |
| unsafe_allow_html=True) | |
| # ── MULTI-TURN CONVERSATION ─────────────────────────────────────────────── | |
| st.markdown('<div class="sec-label" style="margin-top:1.4rem">' | |
| 'Conversation · Keep Talking to the Agent</div>', | |
| unsafe_allow_html=True) | |
| st.caption("Send a follow-up and the agent narrows from the set below — " | |
| "same taste, adjusted to your request. It builds on the " | |
| "conversation rather than starting over.") | |
| conv = st.session_state.conv | |
| if conv and conv["turns"]: | |
| icons = {"reject": "✕", "switch": "⇄", "refine": "→"} | |
| trail = " ".join(f'{icons.get(t["kind"], "→")} {esc(t["text"])}' | |
| for t in conv["turns"]) | |
| st.markdown(f'<div class="mode-note">Conversation: ' | |
| f'<b>{trail}</b></div>', unsafe_allow_html=True) | |
| # Fix #5 — show what the agent is remembering, so the capability is visible | |
| if conv and (conv["rejected_titles"] or len(conv["likes"]) > | |
| len(conv["base_persona"].preferred_themes)): | |
| mem_bits = [] | |
| learned = conv["likes"][len(conv["base_persona"].preferred_themes):] | |
| if learned: | |
| mem_bits.append("<b>Picked up:</b> " + esc(", ".join(learned))) | |
| if conv["rejected_titles"]: | |
| rj = "; ".join(f'{esc(r["title"] or "a pick")} ({esc(r["reason"])})' | |
| for r in conv["rejected_titles"]) | |
| mem_bits.append("<b>Avoiding:</b> " + rj) | |
| st.markdown('<div style="background:#f3ecdb;border-left:3px solid ' | |
| '#0f6e56;border-radius:4px;padding:0.55rem 0.85rem;' | |
| 'margin:0.3rem 0;font-size:0.86rem;color:#3a3528">' | |
| 'The agent is remembering · ' | |
| + " · ".join(mem_bits) + '</div>', | |
| unsafe_allow_html=True) | |
| rc1, rc2, rc3 = st.columns(3) | |
| turn_instr = None | |
| with rc1: | |
| if st.button("↻ More variety", key="rf_var", use_container_width=True): | |
| turn_instr = "something with more variety" | |
| with rc2: | |
| if st.button("☼ Something lighter", key="rf_light", use_container_width=True): | |
| turn_instr = "something lighter in mood" | |
| with rc3: | |
| if st.button("★ More like the top pick", key="rf_top", | |
| use_container_width=True): | |
| turn_instr = (f"more like {recs[0].title}" if recs | |
| else "more like the top pick") | |
| free = st.text_input("Or type your own follow-up", key="rf_free", | |
| placeholder="e.g. something less dystopian") | |
| if st.button("Send ✦", key="rf_go", use_container_width=True) and free.strip(): | |
| turn_instr = free.strip() | |
| st.markdown('<div class="sec-label">The Ranking</div>', unsafe_allow_html=True) | |
| naija_note = "" | |
| for i, r in enumerate(recs): | |
| why = r.reasoning | |
| if naija and i == 0: | |
| try: | |
| why = naija_style_review(r.reasoning) | |
| naija_note = ('<div class="mode-note">Naija mode — the #1 pick\'s ' | |
| 'reasoning is shown in Nigerian English.</div>') | |
| except Exception: | |
| why = r.reasoning | |
| st.markdown(rec_row(r.rank, r.domain, r.title, why, | |
| f"d{min(4, i // 3 + 1)}"), unsafe_allow_html=True) | |
| if naija_note: | |
| st.markdown(naija_note, unsafe_allow_html=True) | |
| if turn_instr and conv: | |
| try: | |
| prev_recs = conv["last_recs"] | |
| new_persona, conv_ctx, kind = apply_turn(conv, turn_instr) | |
| kind_msg = {"reject": "Noted the rejection — finding something " | |
| "that avoids that problem", | |
| "switch": "Switching medium — carrying the taste " | |
| "profile across", | |
| "refine": "Narrowing from the previous set"}[kind] | |
| new_recs = run_agent( | |
| new_persona, | |
| cross_domain=(kind == "switch") or ctx["mode"] == "cross_domain", | |
| build_msg=f"Turn {len(conv['turns']) + 1} — {kind_msg}…", | |
| conversation_context=conv_ctx) | |
| conv["last_recs"] = new_recs | |
| st.session_state.conv = conv | |
| st.session_state.recs = new_recs | |
| st.session_state.ctx = {"persona": new_persona, | |
| "mode": agent.last_mode, | |
| "trace": agent.last_reflection_trace, | |
| "cand": agent.last_candidate_count} | |
| st.rerun() | |
| except Exception as e: | |
| err_card(e) | |
| else: | |
| st.markdown('<div class="empty">Compose a persona, pick a person from the ' | |
| 'data, choose a Nigerian persona, or build one from past ' | |
| 'history — then press <b>Recommend</b>. The agent ranks ten ' | |
| 'titles and shows its reasoning.</div>', | |
| unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div class="foot"> | |
| Recommendation Agent · DSN × BCT LLM Agent Challenge 2026 · | |
| persona → retrieval (HyDE on cold-start) → LLM rerank → | |
| self-reflection critique & re-rank · warm · cold-start · cross-domain | |
| </div> | |
| """, unsafe_allow_html=True) | |