vera-bot / app /core /compose.py
Dov-tek
Restructure: rule-based decision engine + template slot-filling architecture
a1757c5
Raw
History Blame Contribute Delete
5.69 kB
"""
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()