Recommendation-Agent / core /clarifier.py
Israelbliz's picture
Upload clarifier
1768d12 verified
"""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