Arag / app /services /upsell_engine.py
AuthorBot
Wire upsell strategy into LLM prompts for humanistic persuasion.
a0ab367
Raw
History Blame Contribute Delete
5.58 kB
"""Author RAG Chatbot SaaS β€” Upsell Strategy Engine.
Selects and injects the appropriate upsell strategy into every response.
RULE: Strategy drives LLM behavioral instructions β€” not canned append-only hooks.
RULE: Buy Book button shown when engagement and intent warrant it.
"""
import structlog
from app.services.prompter import UPSELL_HOOKS, get_upsell_strategy_instruction
from app.services.session_core.manager import SessionContext
logger = structlog.get_logger(__name__)
_INTRIGUE_SIGNALS: tuple[str, ...] = (
"?",
"you'll",
"you will",
"what happens",
"want to know",
"curious",
"find out",
"waiting",
"won't see",
"imagine",
"worth",
"surprise",
)
class UpsellEngine:
"""Selects and injects upsell strategy based on user context."""
_STRATEGY_MATRIX: dict[tuple[str, str], str] = {
("purchase_intent", "low"): "DIRECT_CTA",
("purchase_intent", "medium"): "DIRECT_CTA",
("purchase_intent", "high"): "DIRECT_CTA",
("full_story_request", "low"): "DIRECT_CTA",
("full_story_request", "medium"): "DIRECT_CTA",
("full_story_request", "high"): "DIRECT_CTA",
("question", "low"): "RECIPROCITY",
("question", "medium"): "CURIOSITY_GAP",
("question", "high"): "DIRECT_CTA",
("comparison", "low"): "SOCIAL_PROOF",
("comparison", "medium"): "SOCIAL_PROOF",
("comparison", "high"): "FUTURE_PACING",
("complaint", "low"): "PAIN_SOLUTION",
("complaint", "medium"): "PAIN_SOLUTION",
("complaint", "high"): "STORY_BRIDGE",
("greeting", "low"): "RECIPROCITY",
("greeting", "medium"): "CURIOSITY_GAP",
("greeting", "high"): "DIRECT_CTA",
}
@staticmethod
def normalize_intent(intent: str) -> str:
"""Map classifier intents to upsell matrix keys."""
if intent == "book_comparison":
return "comparison"
return intent
def select_strategy(self, intent: str, context: SessionContext) -> str:
"""Select the optimal upsell strategy for this turn."""
intent = self.normalize_intent(intent)
if intent in ("purchase_intent", "full_story_request"):
return "DIRECT_CTA"
if context.turn_count >= 3:
interest_tier = self._get_interest_tier(context.interest_score)
return self._STRATEGY_MATRIX.get((intent, interest_tier), "CURIOSITY_GAP")
if context.turn_count >= 1:
return "CURIOSITY_GAP"
return "RECIPROCITY"
def get_strategy_instruction(self, strategy: str) -> str:
"""Return prompt-ready behavioral instruction for the given strategy."""
return get_upsell_strategy_instruction(strategy)
def build_hook(
self,
strategy: str,
purchase_url: str | None = None,
chapter_ref: str | None = None,
author_name: str = "the author",
) -> str:
"""Build the upsell hook text for the given strategy (fallback use only)."""
template = UPSELL_HOOKS.get(strategy, UPSELL_HOOKS["RECIPROCITY"])
return template.format(
purchase_url=purchase_url or "#",
chapter_ref=chapter_ref or "a key chapter",
author_name=author_name,
)
def should_include_link(
self,
intent: str,
context: SessionContext,
strategy: str,
) -> bool:
"""Determine if a purchase link button should be shown.
Rules (ordered by priority):
1. Complaint or off_topic β†’ never
2. purchase_intent β†’ always (even turn 0 β€” they're asking to buy)
3. full_story_request β†’ always (they want it badly enough to ask for the full thing)
4. Turn 0 (very first message) β†’ never (too early for anything else)
5. Turn 1+ with book selected + URL β†’ show
6. Turn 2+ with sufficient engagement β†’ show
"""
intent = self.normalize_intent(intent)
if intent in ("complaint", "off_topic"):
return False
if intent in ("purchase_intent", "full_story_request"):
return True
if context.turn_count < 1:
return False
if strategy == "DIRECT_CTA":
return True
if context.selected_book_id and context.turn_count >= 1:
return True
if context.interest_score >= 0.40 and context.turn_count >= 2:
return True
return False
def fallback_hook_if_needed(
self,
response_text: str,
strategy: str,
interest_score: float,
) -> str | None:
"""Return a canned hook only when the LLM omitted an intrigue close."""
if strategy == "DIRECT_CTA":
return None
if interest_score < 0.4:
return None
if response_has_intrigue_hook(response_text):
return None
return self.build_hook(strategy)
@staticmethod
def _get_interest_tier(score: float) -> str:
if score < 0.3:
return "low"
if score < 0.7:
return "medium"
return "high"
def response_has_intrigue_hook(text: str) -> bool:
"""Heuristic: did the response end with curiosity or engagement?"""
cleaned = text.strip()
if not cleaned:
return False
if cleaned.endswith("?"):
return True
lower = cleaned.lower()
return any(signal in lower for signal in _INTRIGUE_SIGNALS)