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