Spaces:
Running
Running
| """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", | |
| } | |
| 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) | |
| 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) | |