"""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)