"""Author RAG Chatbot SaaS — Purchase Objection Detector. Classifies reader pushback so the LLM can counter the SPECIFIC concern instead of giving a generic reply. An objection is a buying signal — the reader is weighing the purchase, not leaving. RULE: Objections are modifiers on top of intent — they never block the pipeline. RULE: Objection turns are never cached (responses are session-personalized). """ import re import structlog logger = structlog.get_logger(__name__) # Objection type → trigger phrases. Ordered most-specific first inside each list. _OBJECTION_PATTERNS: dict[str, tuple[str, ...]] = { # "Too expensive / not worth the money" "price": ( "too expensive", "too pricey", "costs too much", "cost too much", "can't afford", "cannot afford", "cheaper", "expensive for", "not worth the money", "waste of money", "that price", "overpriced", ), # "No time to read / too long" "time": ( "no time to read", "don't have time", "do not have time", "too long", "too many pages", "never finish", "takes too long", "busy to read", "too busy", ), # "Not my topic / no interest in the subject" "relevance": ( "no interest in", "not interested in", "not into", "don't care about", "do not care about", "not my thing", "not for me", "doesn't apply to me", "why would i need", "don't like politics", "boring topic", "sounds boring", ), # "Is it actually good? Prove it." "skepticism": ( "is it actually good", "is it any good", "why should i trust", "prove it", "sounds like hype", "everyone says that", "heard that before", "what makes it different", "how is it different", "why is it special", "what's so special", ), # "I only read " "genre": ( "i only read", "i usually read", "i prefer fiction", "i prefer non-fiction", "not my genre", "don't read this genre", "do not read this genre", ), # "I already read something like this" "alternatives": ( "already read", "read something similar", "similar book", "same as", "just like every other", "nothing new", ), } # Generic value-challenge phrasing → relevance objection when no specific type hits _GENERIC_OBJECTION_MARKERS: tuple[str, ...] = ( "why should i buy", "why would i buy", "why should i get", "convince me", "why bother", "give me one reason", "not sure if i should", "should i really", ) _WS_RE = re.compile(r"\s+") def detect_objection(query: str) -> str | None: """Detect and classify a purchase objection in the reader's message. Args: query: Sanitized user message. Returns: Objection type ('price', 'time', 'relevance', 'skepticism', 'genre', 'alternatives') or None if no objection detected. """ q = _WS_RE.sub(" ", query.lower().strip()) for objection_type, phrases in _OBJECTION_PATTERNS.items(): if any(phrase in q for phrase in phrases): logger.debug("Objection detected", type=objection_type, query=q[:60]) return objection_type if any(marker in q for marker in _GENERIC_OBJECTION_MARKERS): logger.debug("Generic value objection detected", query=q[:60]) return "relevance" return None def count_prior_objections(history: list[dict]) -> int: """Count how many previous user messages contained an objection. Used to vary the counter-angle — never repeat the same persuasion twice, and soften after repeated hesitation. Args: history: Session history as [{role, content}] dicts. Returns: Number of prior user turns that contained an objection. """ count = 0 for message in history: if message.get("role") != "user": continue if detect_objection(message.get("content", "")): count += 1 return count