Spaces:
Sleeping
Sleeping
| """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 | |