"""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 = """ """ st.markdown(CSS, unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # HTML builders # ══════════════════════════════════════════════════════════════════════════════ def persona_card(p: UserPersona) -> str: themes = "".join(f'{esc(t)}' for t in p.preferred_themes) or '' comps = "".join(f'{esc(t)}' for t in p.common_complaints) or '' hist = f'{p.n_reviews}' if p.n_reviews else 'no history' doms = ", ".join(p.domains) if p.domains else "—" return f"""
The Person · persona
“{esc(p.voice_one_liner or 'No voice captured.')}”
{hist}
history
{esc(doms)}
domains
drawn to{themes}
put off by{comps}
""" def reflection_stepper(iters: int, refined: bool, notes: list[str] | None) -> str: steps = ['
' '
First ranking
' '
retrieved & reranked
'] if refined: steps += ['
' '
Self-critique
' '
found issues
', '
' '
Re-ranked
' '
revised with feedback
', '
' '
Re-checked
' '
critique cleared
'] else: steps += ['
' '
Self-critique
' '
passed first pass
', '
' '
Accepted
' '
no revision needed
'] note = "" if notes: real = [n for n in notes if n and n.strip().lower() != "passed"] if real: note = f'
The critic flagged: {esc(real[0])}
' return f"""
Self-reflection · {iters} critique cycle(s)
{''.join(steps)}
{note}
""" 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"""
{rank:02d}
{esc(domain)}
{esc(title)}
{esc(why)}
""" # ══════════════════════════════════════════════════════════════════════════════ # 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("""
DSN × BCT LLM Agent Challenge · Task B
Recommendation Agent
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 cold-start, warm and cross-domain automatically, and critiques its own ranking before showing it. Not enough to go on? It asks. Then keep the conversation going — refine, reject a pick, or switch medium, and it remembers.
""", 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( '
' '
🇳🇬 Naija Mode
' '
' 'NIGERIAN-ENGLISH LOCALIZATION
', unsafe_allow_html=True) naija = st.toggle("Render reasoning in Nigerian English", value=False) if naija: st.markdown( '
' '● NAIJA MODE ACTIVE
', unsafe_allow_html=True) else: st.markdown( '
' '○ OFF · STANDARD ENGLISH
', 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( '
' '🇳🇬' 'Naija Mode is active' 'top pick reasoning localized to ' 'Nigerian English
', 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('
Input · User Persona
', 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('
Input · A Real Person From the Data
', 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('
Input · A Nigerian Cold-Start Persona
', 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'

{esc(chosen["description"])}

', unsafe_allow_html=True) go_n = st.button("Recommend ✦", key="go_naija", use_container_width=True) # ── BUILD FROM HISTORY ──────────────────────────────────────────────────────── with tab_history: st.markdown('
Input · Raw Reading / Watching History
', 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'
' f'
Generation interrupted
' f'The model call did not complete — it may be rate-limited. ' f'Try again shortly.
' f'{esc(type(e).__name__)}
', 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"""
Quick question · sharpens the recommendations
"{esc(q.question)}"
Pick one — or skip
""", 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'{mlabel}', unsafe_allow_html=True) if mode == "cold_start": st.markdown('
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.
', unsafe_allow_html=True) elif mode == "cross_domain": st.markdown('
Recommending in domains the person ' 'has never touched — bridged from the tastes they have ' 'shown.
', 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('
' 'Conversation · Keep Talking to the Agent
', 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'
Conversation: ' f'{trail}
', 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("Picked up: " + 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("Avoiding: " + rj) st.markdown('
' 'The agent is remembering  ·  ' + "  ·  ".join(mem_bits) + '
', 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('
The Ranking
', 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 = ('
Naija mode — the #1 pick\'s ' 'reasoning is shown in Nigerian English.
') 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('
Compose a persona, pick a person from the ' 'data, choose a Nigerian persona, or build one from past ' 'history — then press Recommend. The agent ranks ten ' 'titles and shows its reasoning.
', unsafe_allow_html=True) st.markdown("""
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
""", unsafe_allow_html=True)