"""Clarifier — single-question elicitation for sparse cold-start personas. When a user composes a persona with only a thin description ("I like thrillers, slow pacing annoys me") and clicks Recommend, the agent has very little to work with. Most multi-turn systems either (a) recommend on noise and hope, or (b) ask five follow-up questions and feel evasive. This module sits between those extremes: detect when the cold-start persona is genuinely too sparse to act on, generate ONE focused, multiple-choice clarifying question, take the user's answer as an injected preference signal, then proceed normally. Triggers only when: - persona has no history (n_reviews == 0) - explicit signals are sparse (themes + complaints < 3) - the description itself is thin (< 20 words) Never triggers more than once per session — the multi-turn conversation engine handles further refinement. """ from __future__ import annotations import logging from pydantic import BaseModel, Field from core.llm import LLMClient from core.persona import UserPersona log = logging.getLogger(__name__) SPARSE_SIGNAL_THRESHOLD = 3 THIN_DESCRIPTION_WORDS = 20 # ────────────────────────────────────────────────────────────────────────────── # Schema # ────────────────────────────────────────────────────────────────────────────── class ClarifyingQuestion(BaseModel): """LLM-generated single clarifying question with quick-pick answers.""" question: str = Field( description="A single focused question that, when answered, would " "meaningfully narrow the recommendations. Conversational, " "not robotic. Maximum 18 words." ) quick_answers: list[str] = Field( description="3-4 short, mutually exclusive answer options the user can " "click. Each option max 6 words. Together they should cover " "the realistic answer space." ) # ────────────────────────────────────────────────────────────────────────────── # Trigger heuristic — deterministic, no LLM call # ────────────────────────────────────────────────────────────────────────────── def should_clarify(persona: UserPersona) -> bool: """Decide whether to ask a clarifying question before recommending. Deterministic and cheap. Returns True only when the persona is genuinely sparse enough that recommendations without further signal would be noise. """ if persona.n_reviews > 0: return False signal_count = (len(persona.preferred_themes or []) + len(persona.common_complaints or [])) desc_length = len((persona.voice_one_liner or "").split()) return (signal_count < SPARSE_SIGNAL_THRESHOLD and desc_length < THIN_DESCRIPTION_WORDS) # ────────────────────────────────────────────────────────────────────────────── # Question generation # ────────────────────────────────────────────────────────────────────────────── def generate_clarifying_question(persona: UserPersona, llm: LLMClient | None = None ) -> ClarifyingQuestion: """Generate one focused clarifying question for this sparse persona. Uses the bulk model — this is an elicitation task, not deep reasoning. Falls back to a safe generic question if the LLM call fails. """ llm = llm or LLMClient() themes = ", ".join(persona.preferred_themes) or "(none stated)" complaints = ", ".join(persona.common_complaints) or "(none stated)" desc = persona.voice_one_liner or "(no description)" prompt = ( f"A user is starting a recommendation session with a thin persona. " f"Before recommending, ask ONE focused question whose answer would " f"meaningfully narrow the recommendations.\n\n" f"PERSONA SO FAR\n" f" Description: {desc}\n" f" Drawn to: {themes}\n" f" Put off by: {complaints}\n\n" f"YOUR QUESTION MUST:\n" f" - target the BIGGEST remaining ambiguity (mood, sub-genre, " f"length, tone, comfort vs challenge — pick the one most useful)\n" f" - NOT re-ask anything already stated\n" f" - have 3 or 4 distinct, clickable answer options\n" f" - sound like a friend asking, not a customer survey\n\n" f"EXAMPLE SHAPE (do not copy verbatim):\n" f' Q: "What mood are you in — light and quick, or something to sink into?"\n' f' Options: ["Light and quick", "Something to sink into", "Either"]' ) try: return llm.structured( prompt, ClarifyingQuestion, model="bulk", system="You ask one well-chosen clarifying question. Brief, " "conversational, helpful — never a survey.", ) except Exception as e: log.warning(f"Clarifier LLM call failed ({type(e).__name__}); " f"falling back to generic question") return ClarifyingQuestion( question="What mood are you in right now?", quick_answers=["Something light and quick", "Something to sink into", "Either — surprise me"], ) # ────────────────────────────────────────────────────────────────────────────── # Answer injection # ────────────────────────────────────────────────────────────────────────────── def apply_clarification(persona: UserPersona, answer: str) -> UserPersona: """Inject the clarification answer into the persona. The answer goes to the FRONT of preferred_themes so retrieval picks it up as the strongest signal, and is appended to voice_one_liner so the HyDE prompt and reranker also see it. """ answer = (answer or "").strip() if not answer: return persona existing = [t for t in (persona.preferred_themes or []) if t.lower() != answer.lower()] persona.preferred_themes = [answer] + existing persona.preferred_themes = persona.preferred_themes[:12] voice = (persona.voice_one_liner or "").rstrip(".") persona.voice_one_liner = ( f"{voice}. Specifically asked for: {answer}." if voice else f"Specifically asked for: {answer}." ) return persona