Israelbliz's picture
Upload the latest update of app.py
15f27e7 verified
"""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 &amp; 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
# ══════════════════════════════════════════════════════════════════════════════
@st.cache_data(show_spinner=False)
def load_data():
rev = pd.read_parquet(settings.processed_dir / "reviews.parquet")
return rev
@st.cache_resource(show_spinner=False)
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 &mdash; books, films or Kindle reads &mdash; 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> &mdash; 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 &nbsp;·&nbsp; '
+ " &nbsp;·&nbsp; ".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 &amp; re-rank · warm · cold-start · cross-domain
</div>
""", unsafe_allow_html=True)