Arag / app /services /objection.py
AuthorBot
Simplify upsell stack and align tests with current prompt wiring.
a7271b7
Raw
History Blame Contribute Delete
3.94 kB
"""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 <other genre>"
"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