""" The Brain — compose() and reply_for(). compose() is a PURE function: no I/O, no clock reads, no random. Same inputs ALWAYS produce the same output. compose_many() orchestrates: decision filtering → compose per item → action assembly. reply_for() handles inbound replies with the state machine. """ from typing import Optional from app.core import templates, determinism from app.core.decision import pick_signals from app.categories import get_handler from app.state.store import ( Conversation, is_auto_reply, is_hostile, is_not_interested, is_affirmative_intent, ) def compose( category: dict, merchant: dict, trigger: dict, customer: Optional[dict] = None, ) -> dict: """ Pure function. No I/O, no clock reads, no random. Same inputs ALWAYS produce the same ComposeResult. Returns: {body, cta, rationale, template_params} """ slug = category.get("slug", "") handler = get_handler(slug) # 1. Fill the template with real data result = templates.fill(category, merchant, trigger, customer) # 2. Validate against category rules problems = handler.validate_body(result["body"], category) if problems: # Strip offending terms — simple cleanup body = result["body"] for p in problems: # Extract the term from "Taboo term 'X' used" if "Taboo term" in p: term = p.split("'")[1] if "'" in p else "" if term: body = body.replace(term, "") body = body.replace(term.lower(), "") result["body"] = body return result def compose_many( items: list[dict], recent_contacts: Optional[dict[str, float]] = None, ) -> list[dict]: """ Takes a list of dicts with keys: category, merchant, trigger, customer, conversation_id. Returns a list of action dicts (skipping any that failed). """ # 1. Decision engine filters and prioritizes selected = pick_signals(items, recent_contacts) if not selected: return [] actions: list[dict] = [] for item in selected: category = item["category"] merchant = item["merchant"] trigger = item["trigger"] customer = item.get("customer") conversation_id = item["conversation_id"] # 2. Compose the message result = compose(category, merchant, trigger, customer) if not result.get("body"): continue # 3. Get category handler for send_as and template_name slug = category.get("slug", "") handler = get_handler(slug) # 4. Build the action dict merchant_id = merchant.get("merchant_id", "") customer_id = (customer or {}).get("customer_id") sup = trigger.get("suppression_key", "") action = { "conversation_id": conversation_id, "merchant_id": merchant_id, "customer_id": customer_id, "send_as": handler.get_send_as(trigger), "trigger_id": trigger.get("id", ""), "template_name": handler.get_template_name(trigger), "template_params": result.get("template_params", []), "body": result["body"].strip(), "cta": result.get("cta", "open_ended"), "suppression_key": sup, "rationale": result.get("rationale", "").strip(), } # 5. Mark suppression key as sent if sup: determinism.mark_sent(sup) actions.append(action) return actions def reply_for( conv: Conversation, merchant_message: str, merchant_ctx: Optional[dict], category_ctx: Optional[dict], customer_ctx: Optional[dict] = None, ) -> dict: """ Handle an inbound reply. Returns one of: {action: "send", body, cta, rationale} {action: "wait", wait_seconds, rationale} {action: "end", rationale} """ use_hindi = False if merchant_ctx: langs = merchant_ctx.get("identity", {}).get("languages", []) use_hindi = "hi" in langs # 1. Already ended? if conv.ended: return templates.reply_ended(conv.ended_reason or "unknown") # 2. Hostile detection if is_hostile(merchant_message): return templates.reply_hostile() # 3. Auto-reply detection prior_inbound = [t.body for t in conv.turns if t.role in ("merchant", "customer")] if is_auto_reply(merchant_message, prior_inbound): # Count prior auto-replies prior_auto = sum(1 for b in prior_inbound if is_auto_reply(b, [])) if prior_auto >= 1: return templates.reply_auto_second() return templates.reply_auto_detected(use_hindi) # 4. Not interested if is_not_interested(merchant_message): return templates.reply_not_interested() # 5. 3-strikes silence rule vera_count = sum(1 for t in conv.turns if t.role == "vera") inbound_count = sum(1 for t in conv.turns if t.role in ("merchant", "customer")) if vera_count >= 3 and inbound_count == 0: return templates.reply_3_strikes() # 6. Affirmative intent → action mode if is_affirmative_intent(merchant_message): result = templates.reply_affirmative(merchant_ctx, category_ctx) # Apply Hindi mix if use_hindi: result["body"] = templates._hi_mix(result["body"], True) return result # 7. Default acknowledgment + next step result = templates.reply_fallback() if use_hindi: result["body"] = templates._hi_mix(result["body"], True) return result def reset_dedupe(): """Clear suppression-key dedupe (used by /v1/teardown).""" determinism.reset()