Align endpoint contract with challenge brief
Browse files- README.md +12 -7
- app/composer.py +668 -668
- app/decision_engine.py +0 -0
- app/main.py +6 -0
- submission.jsonl +0 -0
- tests/test_bot.py +585 -585
README.md
CHANGED
|
@@ -10,24 +10,29 @@ pinned: false
|
|
| 10 |
|
| 11 |
Submission bot for the magicpin Vera AI Challenge. It exposes the required HTTP API, stores judge-pushed context in memory, and composes grounded merchant/customer actions from the JSON context it receives.
|
| 12 |
|
| 13 |
-
The live bot does not require a paid API key
|
| 14 |
|
| 15 |
## How It Works
|
| 16 |
|
| 17 |
1. The judge pushes category, merchant, customer, and trigger JSON into `POST /v1/context`.
|
| 18 |
2. `POST /v1/tick` ranks active triggers by expected rubric score and returns up to 20 message actions.
|
| 19 |
3. `POST /v1/reply` handles auto-replies, commitment, off-topic replies, STOP/hostility, and ended conversations.
|
| 20 |
-
4. `POST /v1/teardown`
|
| 21 |
|
| 22 |
-
|
| 23 |
|
| 24 |
- `GET /v1/healthz`
|
| 25 |
- `GET /v1/metadata`
|
| 26 |
- `POST /v1/context`
|
| 27 |
- `POST /v1/tick`
|
| 28 |
- `POST /v1/reply`
|
|
|
|
|
|
|
|
|
|
| 29 |
- `POST /v1/teardown`
|
| 30 |
|
|
|
|
|
|
|
| 31 |
## Scoring Strategy
|
| 32 |
|
| 33 |
The composer is a rubric-optimized decision engine:
|
|
@@ -66,7 +71,7 @@ pytest -q
|
|
| 66 |
python -m compileall app bot.py scripts tests
|
| 67 |
python scripts/generate_submission.py
|
| 68 |
python scripts/lint_submission.py
|
| 69 |
-
python scripts/score_proxy.py
|
| 70 |
python scripts/geval_calibrate.py
|
| 71 |
```
|
| 72 |
|
|
@@ -83,14 +88,14 @@ python scripts/run_judge_with_trigger_log.py
|
|
| 83 |
|
| 84 |
It prints the lowest-scoring trigger IDs and can write JSON when `JUDGE_TRIGGER_LOG_JSON` is set.
|
| 85 |
|
| 86 |
-
## Optional LLM Copy Polish
|
| 87 |
|
| 88 |
-
|
| 89 |
|
| 90 |
- `OPENAI_API_KEY`
|
| 91 |
- `OPENAI_MODEL`, default `gpt-4o-mini`
|
| 92 |
|
| 93 |
-
The model receives only the deterministic plan and evidence. It must return structured JSON with `body`, `cta`, `send_as`, `suppression_key`, and `rationale`. The bot falls back to deterministic copy if the call fails, times out, changes protected fields, or invents numbers.
|
| 94 |
|
| 95 |
## Optional OpenRouter Calibration
|
| 96 |
|
|
|
|
| 10 |
|
| 11 |
Submission bot for the magicpin Vera AI Challenge. It exposes the required HTTP API, stores judge-pushed context in memory, and composes grounded merchant/customer actions from the JSON context it receives.
|
| 12 |
|
| 13 |
+
The submitted live bot is deterministic and does not require a paid API key or any runtime LLM call. OpenRouter/LLM tooling is used only for offline calibration and local judging experiments, never as a dependency for `/v1/tick`.
|
| 14 |
|
| 15 |
## How It Works
|
| 16 |
|
| 17 |
1. The judge pushes category, merchant, customer, and trigger JSON into `POST /v1/context`.
|
| 18 |
2. `POST /v1/tick` ranks active triggers by expected rubric score and returns up to 20 message actions.
|
| 19 |
3. `POST /v1/reply` handles auto-replies, commitment, off-topic replies, STOP/hostility, and ended conversations.
|
| 20 |
+
4. `POST /v1/teardown` is an extra helper for clean local/HF reruns; it is not required by the challenge judge.
|
| 21 |
|
| 22 |
+
Challenge-required endpoints:
|
| 23 |
|
| 24 |
- `GET /v1/healthz`
|
| 25 |
- `GET /v1/metadata`
|
| 26 |
- `POST /v1/context`
|
| 27 |
- `POST /v1/tick`
|
| 28 |
- `POST /v1/reply`
|
| 29 |
+
|
| 30 |
+
Additional debug/test endpoint:
|
| 31 |
+
|
| 32 |
- `POST /v1/teardown`
|
| 33 |
|
| 34 |
+
Compatibility aliases without the `/v1` prefix are also exposed (`/healthz`, `/metadata`, `/context`, `/tick`, `/reply`) because the public challenge page mentions both shorthand endpoint names and the `/v1/...` submission contract. The submitted base URL remains the same.
|
| 35 |
+
|
| 36 |
## Scoring Strategy
|
| 37 |
|
| 38 |
The composer is a rubric-optimized decision engine:
|
|
|
|
| 71 |
python -m compileall app bot.py scripts tests
|
| 72 |
python scripts/generate_submission.py
|
| 73 |
python scripts/lint_submission.py
|
| 74 |
+
python scripts/score_proxy.py 43
|
| 75 |
python scripts/geval_calibrate.py
|
| 76 |
```
|
| 77 |
|
|
|
|
| 88 |
|
| 89 |
It prints the lowest-scoring trigger IDs and can write JSON when `JUDGE_TRIGGER_LOG_JSON` is set.
|
| 90 |
|
| 91 |
+
## Optional Local LLM Copy Polish
|
| 92 |
|
| 93 |
+
The submitted HF deployment does not use runtime LLM polish. Set these only for local experiments:
|
| 94 |
|
| 95 |
- `OPENAI_API_KEY`
|
| 96 |
- `OPENAI_MODEL`, default `gpt-4o-mini`
|
| 97 |
|
| 98 |
+
The model receives only the deterministic plan and evidence. It must return structured JSON with `body`, `cta`, `send_as`, `suppression_key`, and `rationale`. The bot falls back to deterministic copy if the call fails, times out, changes protected fields, or invents numbers. Do not enable this in the submitted Space; deterministic runtime is the reliability tradeoff.
|
| 99 |
|
| 100 |
## Optional OpenRouter Calibration
|
| 101 |
|
app/composer.py
CHANGED
|
@@ -1,668 +1,668 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from datetime import datetime
|
| 4 |
-
from typing import Any
|
| 5 |
-
import hashlib
|
| 6 |
-
import re
|
| 7 |
-
|
| 8 |
-
from .decision_engine import compose_scored
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
Context = dict[str, Any]
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
CTA_NONE = "none"
|
| 15 |
-
CTA_OPEN = "open_ended"
|
| 16 |
-
CTA_YES_NO = "binary_yes_no"
|
| 17 |
-
CTA_CONFIRM = "binary_confirm_cancel"
|
| 18 |
-
CTA_SLOTS = "multi_choice_slot"
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def compose(category: Context, merchant: Context, trigger: Context, customer: Context | None = None) -> Context:
|
| 22 |
-
"""Compose a deterministic, context-grounded Vera message."""
|
| 23 |
-
scored = compose_scored(category, merchant, trigger, customer)
|
| 24 |
-
if scored:
|
| 25 |
-
return validate_message(scored)
|
| 26 |
-
if customer:
|
| 27 |
-
return _compose_customer(category, merchant, trigger, customer)
|
| 28 |
-
return _compose_merchant(category, merchant, trigger)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def reply_to_message(
|
| 32 |
-
message: str,
|
| 33 |
-
conversation: Context | None = None,
|
| 34 |
-
merchant: Context | None = None,
|
| 35 |
-
trigger: Context | None = None,
|
| 36 |
-
) -> Context:
|
| 37 |
-
"""Respond to a merchant/customer reply without using an LLM."""
|
| 38 |
-
text = (message or "").strip()
|
| 39 |
-
lower = text.lower()
|
| 40 |
-
conversation = conversation or {}
|
| 41 |
-
auto_count = int(conversation.get("auto_reply_count", 0))
|
| 42 |
-
|
| 43 |
-
if is_stop_or_hostile(lower):
|
| 44 |
-
return {
|
| 45 |
-
"action": "end",
|
| 46 |
-
"rationale": "The sender explicitly rejected further messages or used hostile stop language; ending without another nudge.",
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
if is_auto_reply(text):
|
| 50 |
-
if auto_count >= 3:
|
| 51 |
-
return {
|
| 52 |
-
"action": "end",
|
| 53 |
-
"rationale": "Repeated canned auto-reply detected three times; closing the conversation to avoid wasting turns.",
|
| 54 |
-
}
|
| 55 |
-
if auto_count == 2:
|
| 56 |
-
return {
|
| 57 |
-
"action": "wait",
|
| 58 |
-
"wait_seconds": 86400,
|
| 59 |
-
"rationale": "Same auto-reply repeated; owner is likely unavailable, so Vera waits 24 hours.",
|
| 60 |
-
}
|
| 61 |
-
return {
|
| 62 |
-
"action": "wait",
|
| 63 |
-
"wait_seconds": 14400,
|
| 64 |
-
"rationale": "Canned WhatsApp Business auto-reply detected; backing off 4 hours for a real owner reply.",
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
if is_commitment(lower):
|
| 68 |
-
scope = _action_scope(merchant or {}, trigger or {})
|
| 69 |
-
return {
|
| 70 |
-
"action": "send",
|
| 71 |
-
"body": f"Done. I am preparing {scope} now from the details already shared. I will keep it to one ready-to-send draft/action and avoid adding anything not in your current context.",
|
| 72 |
-
"cta": CTA_NONE,
|
| 73 |
-
"rationale": "The sender committed; switching directly to action mode without another qualification or confirmation loop.",
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
if is_offtopic(lower):
|
| 77 |
-
return {
|
| 78 |
-
"action": "send",
|
| 79 |
-
"body": "That is outside what I can help with directly. Coming back to this Vera task: should I prepare the draft/action from the details above?",
|
| 80 |
-
"cta": CTA_YES_NO,
|
| 81 |
-
"rationale": "Politely declines an off-topic request and returns to the active merchant-growth task.",
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
if is_delay(lower):
|
| 85 |
-
return {
|
| 86 |
-
"action": "wait",
|
| 87 |
-
"wait_seconds": 1800,
|
| 88 |
-
"rationale": "The sender asked for time or signaled they are busy; wait 30 minutes.",
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
if "?" in text or any(w in lower for w in ["what", "how", "price", "cost", "details", "send"]):
|
| 92 |
-
return {
|
| 93 |
-
"action": "send",
|
| 94 |
-
"body": "Yes. I can keep it simple: I will draft one ready-to-send version using only your current offer, locality, and the trigger we discussed. Reply YES and I will prepare it.",
|
| 95 |
-
"cta": CTA_YES_NO,
|
| 96 |
-
"rationale": "The sender is engaged and asking for details; answer briefly and request one low-friction confirmation.",
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
return {
|
| 100 |
-
"action": "send",
|
| 101 |
-
"body": "Got it. Should I prepare the next draft/action for this now?",
|
| 102 |
-
"cta": CTA_YES_NO,
|
| 103 |
-
"rationale": "Acknowledges an ambiguous but non-negative reply and asks for one clear next step.",
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
def is_auto_reply(message: str) -> bool:
|
| 108 |
-
lower = (message or "").lower().strip()
|
| 109 |
-
patterns = [
|
| 110 |
-
"thank you for contacting",
|
| 111 |
-
"thanks for contacting",
|
| 112 |
-
"our team will respond",
|
| 113 |
-
"we will respond shortly",
|
| 114 |
-
"we are currently unavailable",
|
| 115 |
-
"business hours",
|
| 116 |
-
"automated assistant",
|
| 117 |
-
"auto-reply",
|
| 118 |
-
"away message",
|
| 119 |
-
"will get back to you",
|
| 120 |
-
]
|
| 121 |
-
return any(p in lower for p in patterns)
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def is_stop_or_hostile(lower: str) -> bool:
|
| 125 |
-
if re.search(r"\b(stop|unsubscribe|remove me|opt out)\b", lower):
|
| 126 |
-
return True
|
| 127 |
-
hard_stops = [
|
| 128 |
-
"stop messaging",
|
| 129 |
-
"stop sending",
|
| 130 |
-
"unsubscribe",
|
| 131 |
-
"not interested",
|
| 132 |
-
"dont message",
|
| 133 |
-
"don't message",
|
| 134 |
-
"useless spam",
|
| 135 |
-
"spam",
|
| 136 |
-
"leave me",
|
| 137 |
-
"bothering me",
|
| 138 |
-
]
|
| 139 |
-
return any(p in lower for p in hard_stops)
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def is_commitment(lower: str) -> bool:
|
| 143 |
-
commitments = [
|
| 144 |
-
r"\byes\b",
|
| 145 |
-
r"\bok\b",
|
| 146 |
-
r"\bokay\b",
|
| 147 |
-
r"\bgo ahead\b",
|
| 148 |
-
r"\blets do it\b",
|
| 149 |
-
r"\blet's do it\b",
|
| 150 |
-
r"\bconfirm\b",
|
| 151 |
-
r"\bproceed\b",
|
| 152 |
-
r"\bsend it\b",
|
| 153 |
-
r"\bdo it\b",
|
| 154 |
-
r"\bstart\b",
|
| 155 |
-
r"\bi want to join\b",
|
| 156 |
-
r"\bmujhe magicpin\b",
|
| 157 |
-
r"\bchalo\b",
|
| 158 |
-
]
|
| 159 |
-
return any(re.search(pattern, lower) for pattern in commitments)
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
def is_delay(lower: str) -> bool:
|
| 163 |
-
return any(p in lower for p in ["later", "busy", "after some time", "tomorrow", "call later", "not now"])
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def is_offtopic(lower: str) -> bool:
|
| 167 |
-
return any(p in lower for p in ["gst", "tax", "income tax", "hiring", "salary", "loan", "rent agreement", "website design", "cricket score", "weather", "movie ticket"])
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def validate_message(message: Context) -> Context:
|
| 171 |
-
"""Normalize shape and remove risky leftovers."""
|
| 172 |
-
body = _clean_multiline(str(message.get("body", "")))
|
| 173 |
-
if not body:
|
| 174 |
-
body = "Quick Vera update: I found one relevant action from your current context. Want me to prepare it?"
|
| 175 |
-
cta = message.get("cta") or CTA_OPEN
|
| 176 |
-
if cta not in {CTA_NONE, CTA_OPEN, CTA_YES_NO, CTA_CONFIRM, CTA_SLOTS, "binary_yes_stop"}:
|
| 177 |
-
cta = CTA_OPEN
|
| 178 |
-
body = _final_scrub(body)
|
| 179 |
-
message["body"] = body[:1800]
|
| 180 |
-
message["cta"] = cta
|
| 181 |
-
message.setdefault("send_as", "vera")
|
| 182 |
-
message.setdefault("suppression_key", "")
|
| 183 |
-
message.setdefault("rationale", "Composed deterministically from category, merchant, trigger, and optional customer context.")
|
| 184 |
-
return message
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def make_conversation_id(merchant_id: str, trigger_id: str, customer_id: str | None = None) -> str:
|
| 188 |
-
base = f"{merchant_id}:{trigger_id}:{customer_id or ''}"
|
| 189 |
-
digest = hashlib.sha1(base.encode("utf-8")).hexdigest()[:8]
|
| 190 |
-
merchant_short = _short_id(merchant_id)
|
| 191 |
-
trigger_short = _short_id(trigger_id)
|
| 192 |
-
if customer_id:
|
| 193 |
-
return f"conv_{merchant_short}_{_short_id(customer_id)}_{digest}"
|
| 194 |
-
return f"conv_{merchant_short}_{trigger_short}_{digest}"
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def _compose_merchant(category: Context, merchant: Context, trigger: Context) -> Context:
|
| 198 |
-
kind = trigger.get("kind", "generic")
|
| 199 |
-
cat = merchant.get("category_slug") or category.get("slug", "merchant")
|
| 200 |
-
identity = merchant.get("identity", {})
|
| 201 |
-
name = _merchant_name(merchant)
|
| 202 |
-
first = _owner_or_name(merchant)
|
| 203 |
-
payload = trigger.get("payload", {})
|
| 204 |
-
offer = _best_offer(merchant, category)
|
| 205 |
-
perf = merchant.get("performance", {})
|
| 206 |
-
agg = merchant.get("customer_aggregate", {})
|
| 207 |
-
locality = identity.get("locality") or identity.get("city") or "your area"
|
| 208 |
-
|
| 209 |
-
if _is_placeholder_payload(payload):
|
| 210 |
-
return _generic_merchant(category, merchant, trigger)
|
| 211 |
-
|
| 212 |
-
if kind in {"research_digest", "regulation_change", "cde_opportunity"}:
|
| 213 |
-
item = _digest_item(category, payload.get("top_item_id") or payload.get("digest_item_id"))
|
| 214 |
-
title = item.get("title") or payload.get("metric_or_topic") or "this week's category update"
|
| 215 |
-
source = item.get("source") or "your category digest"
|
| 216 |
-
number = _first_fact(item, agg)
|
| 217 |
-
if kind == "regulation_change":
|
| 218 |
-
deadline = payload.get("deadline_iso") or trigger.get("expires_at", "")[:10]
|
| 219 |
-
body = f"{name}, compliance note: {title}. Deadline: {deadline}. {number} Source: {source}. Want me to draft the 5-point SOP/checklist for your clinic?"
|
| 220 |
-
cta = CTA_YES_NO
|
| 221 |
-
elif kind == "cde_opportunity":
|
| 222 |
-
credits = payload.get("credits") or item.get("credits")
|
| 223 |
-
fee = str(payload.get("fee") or item.get("actionable") or "").replace("_", " ")
|
| 224 |
-
body = f"{name}, IDA/CDE item for you: {title}. Credits: {credits}; fee: {fee}. Source: {source}. Want me to pull the invite and make a 1-line calendar reminder?"
|
| 225 |
-
cta = CTA_YES_NO
|
| 226 |
-
else:
|
| 227 |
-
cohort = _cohort_phrase(agg)
|
| 228 |
-
body = f"{name}, {source} has one item relevant to {cohort}: {title}. {number} Want me to pull the 2-min summary and draft a patient WhatsApp?"
|
| 229 |
-
cta = CTA_OPEN
|
| 230 |
-
return _msg(body, cta, "vera", trigger, f"{kind} uses digest source plus merchant cohort/performance context.")
|
| 231 |
-
|
| 232 |
-
if kind in {"perf_dip", "seasonal_perf_dip"}:
|
| 233 |
-
if kind == "perf_dip" and not _has_all(payload, "metric", "delta_pct", "window"):
|
| 234 |
-
return _generic_merchant(category, merchant, trigger)
|
| 235 |
-
metric = payload.get("metric", "performance")
|
| 236 |
-
delta = _pct(payload.get("delta_pct") or perf.get("delta_7d", {}).get(f"{metric}_pct"))
|
| 237 |
-
baseline = payload.get("vs_baseline")
|
| 238 |
-
seasonal = payload.get("is_expected_seasonal")
|
| 239 |
-
if seasonal:
|
| 240 |
-
body = f"{first}, your {metric} is down {delta} this {payload.get('window', 'week')}, but this matches the {payload.get('season_note', 'seasonal dip')} pattern. Do not over-spend ads now; focus on your {_member_count(agg)}. Want me to draft a retention nudge?"
|
| 241 |
-
else:
|
| 242 |
-
body = f"{first}, {metric} dropped {delta} in {payload.get('window', '7d')}; baseline was {baseline} and current calls are {perf.get('calls', 'lower than usual')}. {offer} is the quickest concrete hook. Want me to draft a recovery WhatsApp/GBP post?"
|
| 243 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Performance dip trigger; recommends one low-effort recovery action.")
|
| 244 |
-
|
| 245 |
-
if kind == "perf_spike":
|
| 246 |
-
if not _has_all(payload, "metric", "delta_pct", "window"):
|
| 247 |
-
return _generic_merchant(category, merchant, trigger)
|
| 248 |
-
metric = payload.get("metric", "calls")
|
| 249 |
-
delta = _pct(payload.get("delta_pct"))
|
| 250 |
-
driver = str(payload.get("likely_driver") or "recent profile activity").replace("_", " ")
|
| 251 |
-
body = f"{first}, {metric} is up {delta} in {payload.get('window', '7d')} vs baseline {payload.get('vs_baseline', 'normal')}. Likely driver: {driver}. Want me to turn this into a repeatable post for this week?"
|
| 252 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Performance spike trigger; converts a winning signal into a repeatable action.")
|
| 253 |
-
|
| 254 |
-
if kind in {"active_planning_intent"}:
|
| 255 |
-
topic = str(payload.get("intent_topic", "growth plan")).replace("_", " ")
|
| 256 |
-
last = payload.get("merchant_last_message", "")
|
| 257 |
-
if "thali" in topic:
|
| 258 |
-
body = f"{first}, based on your '{last}' message, here is a starter corporate thali structure: 10 thalis at the current {offer}, 25+ with free delivery, 50+ with a filter-coffee add-on. Want me to draft the 3-line outreach note?"
|
| 259 |
-
elif "kids_yoga" in topic:
|
| 260 |
-
body = f"{first}, for kids yoga summer camp, keep it simple: age 7-12, 4 weeks, 3 sessions/week, Saturday trial, and use your current {offer}. Want me to draft the GBP post plus parent WhatsApp?"
|
| 261 |
-
else:
|
| 262 |
-
body = f"{first}, you asked about {topic}. I can turn it into one concrete offer using {offer}, your {locality} locality, and your current profile. Want the ready draft?"
|
| 263 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Merchant already showed planning intent; moves directly to a concrete draft.")
|
| 264 |
-
|
| 265 |
-
if kind == "festival_upcoming":
|
| 266 |
-
if not _has_all(payload, "festival", "days_until"):
|
| 267 |
-
return _generic_merchant(category, merchant, trigger)
|
| 268 |
-
festival = payload.get("festival", "festival")
|
| 269 |
-
days = payload.get("days_until")
|
| 270 |
-
body = f"{first}, {festival} is {days} days away and {cat} is marked relevant for this beat. Your active hook is {offer}. Want me to prepare a festival post now and hold it for approval?"
|
| 271 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Festival trigger with category relevance and existing offer.")
|
| 272 |
-
|
| 273 |
-
if kind == "ipl_match_today":
|
| 274 |
-
if not _has_all(payload, "match", "venue", "match_time_iso"):
|
| 275 |
-
return _generic_merchant(category, merchant, trigger)
|
| 276 |
-
match = payload.get("match", "today's match")
|
| 277 |
-
venue = payload.get("venue", "the stadium")
|
| 278 |
-
time = _time_from_iso(payload.get("match_time_iso")) or "tonight"
|
| 279 |
-
weeknight = payload.get("is_weeknight")
|
| 280 |
-
advice = "push a delivery-only offer" if not weeknight else "run a quick pre-match dine-in/post"
|
| 281 |
-
body = f"{first}, {match} at {venue} starts {time}. Since this is {'not ' if not weeknight else ''}a weeknight match, {advice} using your active {offer}. Want me to draft the banner text and Insta story?"
|
| 282 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "IPL trigger interpreted with day context and current restaurant offer.")
|
| 283 |
-
|
| 284 |
-
if kind == "review_theme_emerged":
|
| 285 |
-
if not _has_all(payload, "theme", "occurrences_30d"):
|
| 286 |
-
return _generic_merchant(category, merchant, trigger)
|
| 287 |
-
theme = str(payload.get("theme", "review theme")).replace("_", " ")
|
| 288 |
-
count = payload.get("occurrences_30d")
|
| 289 |
-
quote = payload.get("common_quote")
|
| 290 |
-
body = f"{first}, {count} reviews in 30d now mention {theme}; one customer said '{quote}'. Want me to draft a short public reply pattern plus an ops checklist?"
|
| 291 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Review theme trigger turns repeated feedback into reply and ops action.")
|
| 292 |
-
|
| 293 |
-
if kind == "milestone_reached":
|
| 294 |
-
if not _has_all(payload, "metric", "value_now", "milestone_value"):
|
| 295 |
-
return _generic_merchant(category, merchant, trigger)
|
| 296 |
-
value = payload.get("value_now")
|
| 297 |
-
target = payload.get("milestone_value")
|
| 298 |
-
metric = str(payload.get("metric", "milestone")).replace("_", " ")
|
| 299 |
-
body = f"{first}, you are at {value} {metric}; {target} is close. Want me to draft a polite review-request WhatsApp for recent happy customers?"
|
| 300 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Milestone trigger nudges a review/request action tied to current count.")
|
| 301 |
-
|
| 302 |
-
if kind == "renewal_due":
|
| 303 |
-
if not _has_any(payload, "days_remaining", "renewal_amount") and not merchant.get("subscription", {}).get("days_remaining"):
|
| 304 |
-
return _generic_merchant(category, merchant, trigger)
|
| 305 |
-
days = payload.get("days_remaining") or merchant.get("subscription", {}).get("days_remaining")
|
| 306 |
-
amount = payload.get("renewal_amount")
|
| 307 |
-
body = f"{first}, your {payload.get('plan', 'plan')} renewal is due in {days} days for Rs {amount}. Calls are down {_pct(merchant.get('performance', {}).get('delta_7d', {}).get('calls_pct'))}; before renewal, want me to show the 3 fixes likely to recover calls?"
|
| 308 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Renewal trigger pairs deadline with current performance risk.")
|
| 309 |
-
|
| 310 |
-
if kind in {"winback_eligible", "dormant_with_vera"}:
|
| 311 |
-
if not _has_any(payload, "days_since_expiry", "days_since_last_merchant_message", "lapsed_customers_added_since_expiry"):
|
| 312 |
-
return _generic_merchant(category, merchant, trigger)
|
| 313 |
-
days = payload.get("days_since_expiry") or payload.get("days_since_last_merchant_message") or merchant.get("subscription", {}).get("days_since_expiry")
|
| 314 |
-
lapsed = payload.get("lapsed_customers_added_since_expiry") or agg.get("lapsed_90d_plus") or agg.get("lapsed_180d_plus")
|
| 315 |
-
body = f"{first}, it has been {days} days since the last active Vera/subscription moment. You now have {lapsed} lapsed customers/signals to recover. Want me to draft one winback message using {offer}?"
|
| 316 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Dormancy/winback trigger; restarts with one concrete recovery action.")
|
| 317 |
-
|
| 318 |
-
if kind == "supply_alert":
|
| 319 |
-
if not _has_all(payload, "molecule", "affected_batches"):
|
| 320 |
-
return _generic_merchant(category, merchant, trigger)
|
| 321 |
-
batches = ", ".join(payload.get("affected_batches", [])[:3])
|
| 322 |
-
molecule = payload.get("molecule", "medicine")
|
| 323 |
-
chronic = agg.get("chronic_rx_count") or agg.get("total_unique_ytd") or "repeat"
|
| 324 |
-
body = f"{first}, urgent stock alert: {molecule} batches {batches} from {payload.get('manufacturer', 'the manufacturer')}. You have {chronic} chronic-Rx customers in context. Want me to draft the replacement WhatsApp and counter checklist?"
|
| 325 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Supply alert trigger uses molecule, batch numbers, and pharmacy repeat-customer context.")
|
| 326 |
-
|
| 327 |
-
if kind == "category_seasonal":
|
| 328 |
-
if not payload.get("trends"):
|
| 329 |
-
return _generic_merchant(category, merchant, trigger)
|
| 330 |
-
trends = ", ".join(str(t).replace("_", " ") for t in payload.get("trends", [])[:4])
|
| 331 |
-
body = f"{first}, summer demand shift is visible: {trends}. Since shelf action is recommended, want me to draft a 10-item reorder/checklist plus WhatsApp note?"
|
| 332 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Seasonal pharmacy/category trigger maps trends to stock action.")
|
| 333 |
-
|
| 334 |
-
if kind == "gbp_unverified":
|
| 335 |
-
if not _has_all(payload, "verification_path", "estimated_uplift_pct"):
|
| 336 |
-
return _generic_merchant(category, merchant, trigger)
|
| 337 |
-
uplift = _pct(payload.get("estimated_uplift_pct"))
|
| 338 |
-
body = f"{first}, your GBP is still unverified; the available path is {payload.get('verification_path', 'verification')}. Verified profiles can unlock about {uplift} more visibility in this context. Want me to walk you through the 3-step verification?"
|
| 339 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "GBP verification trigger uses exact path and estimated uplift.")
|
| 340 |
-
|
| 341 |
-
if kind == "competitor_opened":
|
| 342 |
-
if not _has_all(payload, "competitor_name", "distance_km", "opened_date", "their_offer"):
|
| 343 |
-
return _generic_merchant(category, merchant, trigger)
|
| 344 |
-
body = f"{name}, new competitor signal: {payload.get('competitor_name')} opened {payload.get('distance_km')} km away on {payload.get('opened_date')} with {payload.get('their_offer')}. Your current hook is {offer}. Want me to draft a sharper local post?"
|
| 345 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Competitor trigger uses named competitor, distance, date, and offer comparison.")
|
| 346 |
-
|
| 347 |
-
if kind == "curious_ask_due":
|
| 348 |
-
body = f"{first}, quick check: what service has been most asked-for this week at {identity.get('name', 'your business')}? I will turn your answer into a Google post and a 4-line WhatsApp reply. Takes 5 min."
|
| 349 |
-
return _msg(body, CTA_OPEN, "vera", trigger, "Curious-ask cadence asks the merchant for one useful signal.")
|
| 350 |
-
|
| 351 |
-
return _generic_merchant(category, merchant, trigger)
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
def _compose_customer(category: Context, merchant: Context, trigger: Context, customer: Context) -> Context:
|
| 355 |
-
if not _has_consent(customer, trigger):
|
| 356 |
-
return _msg(
|
| 357 |
-
f"{_owner_or_name(merchant)}, {_customer_name(customer)} has a {str(trigger.get('kind', 'customer')).replace('_', ' ')} signal, but the saved opt-in scope does not clearly cover this outreach. Want me to prepare a consent-safe approval note first?",
|
| 358 |
-
CTA_YES_NO,
|
| 359 |
-
"vera",
|
| 360 |
-
trigger,
|
| 361 |
-
"Customer trigger routed to merchant because consent/preference does not clearly permit direct outreach.",
|
| 362 |
-
)
|
| 363 |
-
|
| 364 |
-
kind = trigger.get("kind", "customer_message")
|
| 365 |
-
payload = trigger.get("payload", {})
|
| 366 |
-
merchant_name = merchant.get("identity", {}).get("name", "your merchant")
|
| 367 |
-
owner = merchant.get("identity", {}).get("owner_first_name") or merchant_name
|
| 368 |
-
customer_name = _customer_name(customer)
|
| 369 |
-
offer = _best_offer(merchant, category)
|
| 370 |
-
lang = str(customer.get("identity", {}).get("language_pref", "english")).lower()
|
| 371 |
-
|
| 372 |
-
if _is_placeholder_payload(payload):
|
| 373 |
-
return _generic_customer(category, merchant, trigger, customer)
|
| 374 |
-
|
| 375 |
-
if kind == "recall_due":
|
| 376 |
-
if not _has_any(payload, "service_due", "due_date", "available_slots"):
|
| 377 |
-
return _generic_customer(category, merchant, trigger, customer)
|
| 378 |
-
slots = payload.get("available_slots", [])
|
| 379 |
-
slot_text = _slot_text(slots)
|
| 380 |
-
months = _months_between(payload.get("last_service_date"), payload.get("due_date")) or "6-month"
|
| 381 |
-
if "hi" in lang:
|
| 382 |
-
body = f"Hi {customer_name}, {merchant_name} here. It has been about {months} since your last visit; your {payload.get('service_due', 'recall').replace('_', ' ')} is due. Apke liye slots: {slot_text}. {offer}. Reply 1/2 for a slot, or suggest a time."
|
| 383 |
-
else:
|
| 384 |
-
body = f"Hi {customer_name}, {merchant_name} here. It has been about {months} since your last visit; your {payload.get('service_due', 'recall').replace('_', ' ')} is due. Slots available: {slot_text}. {offer}. Reply 1/2 for a slot, or suggest a time."
|
| 385 |
-
return _msg(body, CTA_SLOTS, "merchant_on_behalf", trigger, "Customer recall uses due date, slot options, offer, and language preference.")
|
| 386 |
-
|
| 387 |
-
if kind in {"customer_lapsed_hard", "customer_lapsed_soft"}:
|
| 388 |
-
days = payload.get("days_since_last_visit")
|
| 389 |
-
focus = str(payload.get("previous_focus") or customer.get("preferences", {}).get("training_focus") or "your goal").replace("_", " ")
|
| 390 |
-
elapsed = f"{days} days" if days is not None else "a while"
|
| 391 |
-
body = f"Hi {customer_name}, {owner} from {merchant_name} here. It has been {elapsed} since your last visit; no pressure. We can restart with {offer}, matched to {focus}. Reply YES and I will hold a no-commitment slot."
|
| 392 |
-
return _msg(body, CTA_YES_NO, "merchant_on_behalf", trigger, "Customer lapse trigger uses days since visit, prior focus, and merchant offer.")
|
| 393 |
-
|
| 394 |
-
if kind == "appointment_tomorrow":
|
| 395 |
-
appointment = payload.get("appointment_time") or payload.get("slot_label") or _slot_text(payload.get("available_slots", []))
|
| 396 |
-
service = str(payload.get("service") or customer.get("relationship", {}).get("services_received", ["appointment"])[-1]).replace("_", " ")
|
| 397 |
-
body = f"Hi {customer_name}, {merchant_name} here. Reminder for your {service} appointment tomorrow: {appointment}. Reply YES to confirm or tell us if you need to reschedule."
|
| 398 |
-
return _msg(body, CTA_CONFIRM, "merchant_on_behalf", trigger, "Appointment reminder uses customer relationship and available appointment timing.")
|
| 399 |
-
|
| 400 |
-
if kind == "wedding_package_followup":
|
| 401 |
-
if not _has_any(payload, "wedding_date", "days_to_wedding", "next_step_window_open"):
|
| 402 |
-
return _generic_customer(category, merchant, trigger, customer)
|
| 403 |
-
days = payload.get("days_to_wedding")
|
| 404 |
-
body = f"Hi {customer_name}, {owner} from {merchant_name} here. {days} days to your wedding; this is the right window for {str(payload.get('next_step_window_open', 'skin prep')).replace('_', ' ')}. {offer}. Want me to block your preferred Saturday slot?"
|
| 405 |
-
return _msg(body, CTA_YES_NO, "merchant_on_behalf", trigger, "Bridal follow-up uses wedding date window, relationship history, and offer.")
|
| 406 |
-
|
| 407 |
-
if kind == "trial_followup":
|
| 408 |
-
if not _has_any(payload, "trial_date", "next_session_options"):
|
| 409 |
-
return _generic_customer(category, merchant, trigger, customer)
|
| 410 |
-
slots = _slot_text(payload.get("next_session_options", []))
|
| 411 |
-
body = f"Hi {customer_name}, {owner} from {merchant_name} here. Thanks for trying the class on {payload.get('trial_date')}. Next available option: {slots}. Want me to reserve it?"
|
| 412 |
-
return _msg(body, CTA_YES_NO, "merchant_on_behalf", trigger, "Trial follow-up uses trial date and next available session.")
|
| 413 |
-
|
| 414 |
-
if kind == "chronic_refill_due":
|
| 415 |
-
if not _has_any(payload, "molecule_list", "stock_runs_out_iso"):
|
| 416 |
-
return _generic_customer(category, merchant, trigger, customer)
|
| 417 |
-
meds = ", ".join(payload.get("molecule_list", []))
|
| 418 |
-
address_note = "We have your saved delivery address on file." if payload.get("delivery_address_saved") else "We can confirm the delivery address after your reply."
|
| 419 |
-
body = f"Namaste {customer_name}, {merchant_name} here. Your monthly medicines ({meds}) are due by {_date_from_iso(payload.get('stock_runs_out_iso'))}. {address_note} Reply CONFIRM and our pharmacist will verify stock and delivery details before preparing it."
|
| 420 |
-
return _msg(body, CTA_CONFIRM, "merchant_on_behalf", trigger, "Refill reminder uses molecule list, run-out date, delivery status, and pharmacy offer.")
|
| 421 |
-
|
| 422 |
-
return _msg(
|
| 423 |
-
f"Hi {customer_name}, {merchant_name} here. A quick update related to your last visit is ready. {offer}. Reply YES if you want the details.",
|
| 424 |
-
CTA_YES_NO,
|
| 425 |
-
"merchant_on_behalf",
|
| 426 |
-
trigger,
|
| 427 |
-
"Generic customer-scoped trigger with consent and merchant offer.",
|
| 428 |
-
)
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
def _generic_merchant(category: Context, merchant: Context, trigger: Context) -> Context:
|
| 432 |
-
first = _owner_or_name(merchant)
|
| 433 |
-
kind = str(trigger.get("kind", "update")).replace("_", " ")
|
| 434 |
-
perf = merchant.get("performance", {})
|
| 435 |
-
offer = _best_offer(merchant, category)
|
| 436 |
-
views = perf.get("views")
|
| 437 |
-
calls = perf.get("calls")
|
| 438 |
-
identity = merchant.get("identity", {})
|
| 439 |
-
locality = identity.get("locality") or identity.get("city") or "your locality"
|
| 440 |
-
fact = f"Current 30d views: {views}; calls: {calls}." if views is not None and calls is not None else f"Locality: {locality}."
|
| 441 |
-
body = f"{first}, Vera found a {kind} signal for {_merchant_name(merchant)}. {fact} Best hook: {offer}. Want me to draft the next WhatsApp/GBP action?"
|
| 442 |
-
return _msg(body, CTA_YES_NO, "vera", trigger, "Fallback grounded in trigger kind, performance, and offer.")
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
def _generic_customer(category: Context, merchant: Context, trigger: Context, customer: Context) -> Context:
|
| 446 |
-
customer_name = _customer_name(customer)
|
| 447 |
-
merchant_name = merchant.get("identity", {}).get("name", "your merchant")
|
| 448 |
-
kind = str(trigger.get("kind", "customer update")).replace("_", " ")
|
| 449 |
-
last_visit = customer.get("relationship", {}).get("last_visit")
|
| 450 |
-
state = str(customer.get("state", "active")).replace("_", " ")
|
| 451 |
-
offer = _best_offer(merchant, category)
|
| 452 |
-
visit_text = f" Last visit: {last_visit}." if last_visit else ""
|
| 453 |
-
body = f"Hi {customer_name}, {merchant_name} here. Quick {kind} update for you based on your {state} status.{visit_text} {offer}. Reply YES if you want me to hold the next step."
|
| 454 |
-
return _msg(body, CTA_YES_NO, "merchant_on_behalf", trigger, "Customer fallback grounded in relationship state, last visit, and available offer.")
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
def _msg(body: str, cta: str, send_as: str, trigger: Context, rationale: str) -> Context:
|
| 458 |
-
return validate_message({
|
| 459 |
-
"body": body,
|
| 460 |
-
"cta": cta,
|
| 461 |
-
"send_as": send_as,
|
| 462 |
-
"suppression_key": trigger.get("suppression_key", trigger.get("id", "")),
|
| 463 |
-
"rationale": rationale,
|
| 464 |
-
})
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
def _clean(text: str) -> str:
|
| 468 |
-
return re.sub(r"\s+", " ", text).strip()
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
def _clean_multiline(text: str) -> str:
|
| 472 |
-
text = re.sub(r"\.{2,}", ".", text)
|
| 473 |
-
text = re.sub(r";\s*\.", ".", text)
|
| 474 |
-
text = re.sub(r"[ \t]+", " ", text)
|
| 475 |
-
text = re.sub(r" *\n *", "\n", text)
|
| 476 |
-
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 477 |
-
return text.strip()
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
def _merchant_name(merchant: Context) -> str:
|
| 481 |
-
identity = merchant.get("identity", {})
|
| 482 |
-
name = identity.get("name") or "Merchant"
|
| 483 |
-
owner = str(identity.get("owner_first_name") or "").strip()
|
| 484 |
-
if merchant.get("category_slug") == "dentists" and owner and not str(name).lower().startswith("dr."):
|
| 485 |
-
return owner if owner.lower().startswith("dr") else f"Dr. {owner}"
|
| 486 |
-
return _dedupe_dr(str(name))
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
def _owner_or_name(merchant: Context) -> str:
|
| 490 |
-
identity = merchant.get("identity", {})
|
| 491 |
-
owner = identity.get("owner_first_name")
|
| 492 |
-
if owner:
|
| 493 |
-
if merchant.get("category_slug") == "dentists" and not str(owner).lower().startswith("dr"):
|
| 494 |
-
return f"Dr. {owner}"
|
| 495 |
-
return str(owner)
|
| 496 |
-
return str(identity.get("name", "there"))
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
def _customer_name(customer: Context) -> str:
|
| 500 |
-
name = str(customer.get("identity", {}).get("name") or "there")
|
| 501 |
-
return name.replace("(parent:", "parent:").strip()
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
def _best_offer(merchant: Context, category: Context) -> str:
|
| 505 |
-
for offer in merchant.get("offers", []) or []:
|
| 506 |
-
if offer.get("status") == "active" and offer.get("title"):
|
| 507 |
-
return str(offer["title"])
|
| 508 |
-
catalog = [offer for offer in (category.get("offer_catalog", []) or []) if offer.get("title")]
|
| 509 |
-
preferred_types = {"service_at_price": 0, "free_service": 1, "membership": 2}
|
| 510 |
-
def rank(offer: Context) -> tuple[int, int]:
|
| 511 |
-
title = str(offer.get("title", "")).lower()
|
| 512 |
-
percent_penalty = 5 if "flat" in title and "%" in title else 0
|
| 513 |
-
return (preferred_types.get(str(offer.get("type")), 3) + percent_penalty, len(title))
|
| 514 |
-
for offer in sorted(catalog, key=rank):
|
| 515 |
-
title = str(offer["title"])
|
| 516 |
-
if not ("flat" in title.lower() and "%" in title):
|
| 517 |
-
return title
|
| 518 |
-
if catalog:
|
| 519 |
-
return str(sorted(catalog, key=rank)[0]["title"])
|
| 520 |
-
return "a simple service-price offer"
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
def _digest_item(category: Context, item_id: str | None) -> Context:
|
| 524 |
-
digest = category.get("digest", []) or []
|
| 525 |
-
if item_id:
|
| 526 |
-
for item in digest:
|
| 527 |
-
if item.get("id") == item_id:
|
| 528 |
-
return item
|
| 529 |
-
return digest[0] if digest else {}
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
def _first_fact(item: Context, agg: Context) -> str:
|
| 533 |
-
facts: list[str] = []
|
| 534 |
-
if item.get("trial_n"):
|
| 535 |
-
facts.append(f"{item['trial_n']}-patient trial")
|
| 536 |
-
if item.get("summary"):
|
| 537 |
-
match = re.search(r"(\d+(?:\.\d+)?%|\d+(?:\.\d+)?\s?mSv|\d+(?:\.\d+)?\s?credits?)", str(item["summary"]))
|
| 538 |
-
if match:
|
| 539 |
-
facts.append(match.group(1))
|
| 540 |
-
if agg.get("high_risk_adult_count"):
|
| 541 |
-
facts.append(f"{agg['high_risk_adult_count']} high-risk adults in your roster")
|
| 542 |
-
return "; ".join(facts) + "." if facts else ""
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
def _cohort_phrase(agg: Context) -> str:
|
| 546 |
-
if agg.get("high_risk_adult_count"):
|
| 547 |
-
return f"your {agg['high_risk_adult_count']} high-risk adult patients"
|
| 548 |
-
if agg.get("chronic_rx_count"):
|
| 549 |
-
return f"your {agg['chronic_rx_count']} chronic-Rx customers"
|
| 550 |
-
if agg.get("total_active_members"):
|
| 551 |
-
return f"your {agg['total_active_members']} active members"
|
| 552 |
-
if agg.get("lapsed_90d_plus") or agg.get("lapsed_180d_plus"):
|
| 553 |
-
return "your lapsed-customer cohort"
|
| 554 |
-
return "your current customers"
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
def _member_count(agg: Context) -> str:
|
| 558 |
-
if agg.get("total_active_members"):
|
| 559 |
-
return f"{agg['total_active_members']} active members"
|
| 560 |
-
if agg.get("total_unique_ytd"):
|
| 561 |
-
return f"{agg['total_unique_ytd']} customers"
|
| 562 |
-
return "existing customers"
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
def _pct(value: Any) -> str:
|
| 566 |
-
try:
|
| 567 |
-
num = float(value)
|
| 568 |
-
except (TypeError, ValueError):
|
| 569 |
-
return "0%"
|
| 570 |
-
return f"{num * 100:.0f}%" if abs(num) <= 1 else f"{num:.0f}%"
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
def _time_from_iso(value: str | None) -> str | None:
|
| 574 |
-
if not value:
|
| 575 |
-
return None
|
| 576 |
-
match = re.search(r"T(\d{2}):(\d{2})", value)
|
| 577 |
-
if not match:
|
| 578 |
-
return value
|
| 579 |
-
hour = int(match.group(1))
|
| 580 |
-
minute = match.group(2)
|
| 581 |
-
suffix = "am" if hour < 12 else "pm"
|
| 582 |
-
hour = hour if 1 <= hour <= 12 else abs(hour - 12) or 12
|
| 583 |
-
return f"{hour}:{minute}{suffix}"
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
def _date_from_iso(value: str | None) -> str:
|
| 587 |
-
if not value:
|
| 588 |
-
return "the due date"
|
| 589 |
-
return value.split("T", 1)[0]
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
def _months_between(start: str | None, end: str | None) -> str | None:
|
| 593 |
-
if not start or not end:
|
| 594 |
-
return None
|
| 595 |
-
try:
|
| 596 |
-
a = datetime.fromisoformat(start[:10])
|
| 597 |
-
b = datetime.fromisoformat(end[:10])
|
| 598 |
-
except ValueError:
|
| 599 |
-
return None
|
| 600 |
-
months = max(1, round((b - a).days / 30))
|
| 601 |
-
return f"{months} months"
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
def _slot_text(slots: list[Context]) -> str:
|
| 605 |
-
labels = [str(s.get("label")) for s in slots if s.get("label")]
|
| 606 |
-
if not labels:
|
| 607 |
-
return "the next available slot"
|
| 608 |
-
if len(labels) == 1:
|
| 609 |
-
return labels[0]
|
| 610 |
-
return " or ".join(labels[:2])
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
def _has_consent(customer: Context, trigger: Context) -> bool:
|
| 614 |
-
prefs = customer.get("preferences", {})
|
| 615 |
-
if prefs.get("reminder_opt_in") is False:
|
| 616 |
-
return False
|
| 617 |
-
scopes = set(customer.get("consent", {}).get("scope", []) or [])
|
| 618 |
-
kind = trigger.get("kind", "")
|
| 619 |
-
if kind in {"recall_due", "appointment_tomorrow"}:
|
| 620 |
-
return bool(scopes.intersection({"recall_reminders", "appointment_reminders"}))
|
| 621 |
-
if kind in {"chronic_refill_due"}:
|
| 622 |
-
return bool(scopes.intersection({"refill_reminders", "delivery_notifications"}))
|
| 623 |
-
if kind in {"customer_lapsed_hard", "customer_lapsed_soft"}:
|
| 624 |
-
return bool(scopes.intersection({"winback_offers", "renewal_reminders", "promotional_offers"}))
|
| 625 |
-
if kind in {"wedding_package_followup"}:
|
| 626 |
-
return "bridal_package_followup" in scopes
|
| 627 |
-
if kind in {"trial_followup"}:
|
| 628 |
-
return bool(scopes.intersection({"kids_program_updates", "program_updates", "appointment_reminders"}))
|
| 629 |
-
return bool(scopes)
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
def _has_all(payload: Context, *fields: str) -> bool:
|
| 633 |
-
return all(payload.get(field) not in (None, "", []) for field in fields)
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
def _has_any(payload: Context, *fields: str) -> bool:
|
| 637 |
-
return any(payload.get(field) not in (None, "", []) for field in fields)
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
def _is_placeholder_payload(payload: Context) -> bool:
|
| 641 |
-
return payload.get("placeholder") is True
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
def _dedupe_dr(value: str) -> str:
|
| 645 |
-
return re.sub(r"\bDr\.\s+Dr\.\s+", "Dr. ", value).strip()
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
def _final_scrub(body: str) -> str:
|
| 649 |
-
body = _dedupe_dr(body)
|
| 650 |
-
body = body.replace("None", "the available context")
|
| 651 |
-
body = body.replace("baseline normal", "the recent baseline")
|
| 652 |
-
body = re.sub(r"\b(up|down|dropped|rose|increased)\s+0%\b", "changed in the latest context", body)
|
| 653 |
-
body = body.replace("the available context days", "a while")
|
| 654 |
-
body = body.replace("the available context km", "nearby")
|
| 655 |
-
body = body.replace("festival is the available context days away", "a festival window is coming up")
|
| 656 |
-
return _clean_multiline(body)
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
def _short_id(value: str) -> str:
|
| 660 |
-
cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_")
|
| 661 |
-
parts = cleaned.split("_")
|
| 662 |
-
return "_".join(parts[:4])[:36] or "x"
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
def _action_scope(merchant: Context, trigger: Context) -> str:
|
| 666 |
-
name = merchant.get("identity", {}).get("name") or "this merchant"
|
| 667 |
-
kind = str(trigger.get("kind", "task")).replace("_", " ")
|
| 668 |
-
return f"{name}'s {kind}"
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Any
|
| 5 |
+
import hashlib
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
from .decision_engine import compose_scored
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
Context = dict[str, Any]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
CTA_NONE = "none"
|
| 15 |
+
CTA_OPEN = "open_ended"
|
| 16 |
+
CTA_YES_NO = "binary_yes_no"
|
| 17 |
+
CTA_CONFIRM = "binary_confirm_cancel"
|
| 18 |
+
CTA_SLOTS = "multi_choice_slot"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def compose(category: Context, merchant: Context, trigger: Context, customer: Context | None = None) -> Context:
|
| 22 |
+
"""Compose a deterministic, context-grounded Vera message."""
|
| 23 |
+
scored = compose_scored(category, merchant, trigger, customer)
|
| 24 |
+
if scored:
|
| 25 |
+
return validate_message(scored)
|
| 26 |
+
if customer:
|
| 27 |
+
return _compose_customer(category, merchant, trigger, customer)
|
| 28 |
+
return _compose_merchant(category, merchant, trigger)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def reply_to_message(
|
| 32 |
+
message: str,
|
| 33 |
+
conversation: Context | None = None,
|
| 34 |
+
merchant: Context | None = None,
|
| 35 |
+
trigger: Context | None = None,
|
| 36 |
+
) -> Context:
|
| 37 |
+
"""Respond to a merchant/customer reply without using an LLM."""
|
| 38 |
+
text = (message or "").strip()
|
| 39 |
+
lower = text.lower()
|
| 40 |
+
conversation = conversation or {}
|
| 41 |
+
auto_count = int(conversation.get("auto_reply_count", 0))
|
| 42 |
+
|
| 43 |
+
if is_stop_or_hostile(lower):
|
| 44 |
+
return {
|
| 45 |
+
"action": "end",
|
| 46 |
+
"rationale": "The sender explicitly rejected further messages or used hostile stop language; ending without another nudge.",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if is_auto_reply(text):
|
| 50 |
+
if auto_count >= 3:
|
| 51 |
+
return {
|
| 52 |
+
"action": "end",
|
| 53 |
+
"rationale": "Repeated canned auto-reply detected three times; closing the conversation to avoid wasting turns.",
|
| 54 |
+
}
|
| 55 |
+
if auto_count == 2:
|
| 56 |
+
return {
|
| 57 |
+
"action": "wait",
|
| 58 |
+
"wait_seconds": 86400,
|
| 59 |
+
"rationale": "Same auto-reply repeated; owner is likely unavailable, so Vera waits 24 hours.",
|
| 60 |
+
}
|
| 61 |
+
return {
|
| 62 |
+
"action": "wait",
|
| 63 |
+
"wait_seconds": 14400,
|
| 64 |
+
"rationale": "Canned WhatsApp Business auto-reply detected; backing off 4 hours for a real owner reply.",
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if is_commitment(lower):
|
| 68 |
+
scope = _action_scope(merchant or {}, trigger or {})
|
| 69 |
+
return {
|
| 70 |
+
"action": "send",
|
| 71 |
+
"body": f"Done. I am preparing {scope} now from the details already shared. I will keep it to one ready-to-send draft/action and avoid adding anything not in your current context.",
|
| 72 |
+
"cta": CTA_NONE,
|
| 73 |
+
"rationale": "The sender committed; switching directly to action mode without another qualification or confirmation loop.",
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if is_offtopic(lower):
|
| 77 |
+
return {
|
| 78 |
+
"action": "send",
|
| 79 |
+
"body": "That is outside what I can help with directly. Coming back to this Vera task: should I prepare the draft/action from the details above?",
|
| 80 |
+
"cta": CTA_YES_NO,
|
| 81 |
+
"rationale": "Politely declines an off-topic request and returns to the active merchant-growth task.",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if is_delay(lower):
|
| 85 |
+
return {
|
| 86 |
+
"action": "wait",
|
| 87 |
+
"wait_seconds": 1800,
|
| 88 |
+
"rationale": "The sender asked for time or signaled they are busy; wait 30 minutes.",
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if "?" in text or any(w in lower for w in ["what", "how", "price", "cost", "details", "send"]):
|
| 92 |
+
return {
|
| 93 |
+
"action": "send",
|
| 94 |
+
"body": "Yes. I can keep it simple: I will draft one ready-to-send version using only your current offer, locality, and the trigger we discussed. Reply YES and I will prepare it.",
|
| 95 |
+
"cta": CTA_YES_NO,
|
| 96 |
+
"rationale": "The sender is engaged and asking for details; answer briefly and request one low-friction confirmation.",
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"action": "send",
|
| 101 |
+
"body": "Got it. Should I prepare the next draft/action for this now?",
|
| 102 |
+
"cta": CTA_YES_NO,
|
| 103 |
+
"rationale": "Acknowledges an ambiguous but non-negative reply and asks for one clear next step.",
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def is_auto_reply(message: str) -> bool:
|
| 108 |
+
lower = (message or "").lower().strip()
|
| 109 |
+
patterns = [
|
| 110 |
+
"thank you for contacting",
|
| 111 |
+
"thanks for contacting",
|
| 112 |
+
"our team will respond",
|
| 113 |
+
"we will respond shortly",
|
| 114 |
+
"we are currently unavailable",
|
| 115 |
+
"business hours",
|
| 116 |
+
"automated assistant",
|
| 117 |
+
"auto-reply",
|
| 118 |
+
"away message",
|
| 119 |
+
"will get back to you",
|
| 120 |
+
]
|
| 121 |
+
return any(p in lower for p in patterns)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def is_stop_or_hostile(lower: str) -> bool:
|
| 125 |
+
if re.search(r"\b(stop|unsubscribe|remove me|opt out)\b", lower):
|
| 126 |
+
return True
|
| 127 |
+
hard_stops = [
|
| 128 |
+
"stop messaging",
|
| 129 |
+
"stop sending",
|
| 130 |
+
"unsubscribe",
|
| 131 |
+
"not interested",
|
| 132 |
+
"dont message",
|
| 133 |
+
"don't message",
|
| 134 |
+
"useless spam",
|
| 135 |
+
"spam",
|
| 136 |
+
"leave me",
|
| 137 |
+
"bothering me",
|
| 138 |
+
]
|
| 139 |
+
return any(p in lower for p in hard_stops)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def is_commitment(lower: str) -> bool:
|
| 143 |
+
commitments = [
|
| 144 |
+
r"\byes\b",
|
| 145 |
+
r"\bok\b",
|
| 146 |
+
r"\bokay\b",
|
| 147 |
+
r"\bgo ahead\b",
|
| 148 |
+
r"\blets do it\b",
|
| 149 |
+
r"\blet's do it\b",
|
| 150 |
+
r"\bconfirm\b",
|
| 151 |
+
r"\bproceed\b",
|
| 152 |
+
r"\bsend it\b",
|
| 153 |
+
r"\bdo it\b",
|
| 154 |
+
r"\bstart\b",
|
| 155 |
+
r"\bi want to join\b",
|
| 156 |
+
r"\bmujhe magicpin\b",
|
| 157 |
+
r"\bchalo\b",
|
| 158 |
+
]
|
| 159 |
+
return any(re.search(pattern, lower) for pattern in commitments)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def is_delay(lower: str) -> bool:
|
| 163 |
+
return any(p in lower for p in ["later", "busy", "after some time", "tomorrow", "call later", "not now"])
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def is_offtopic(lower: str) -> bool:
|
| 167 |
+
return any(p in lower for p in ["gst", "tax", "income tax", "hiring", "salary", "loan", "rent agreement", "website design", "cricket score", "weather", "movie ticket"])
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def validate_message(message: Context) -> Context:
|
| 171 |
+
"""Normalize shape and remove risky leftovers."""
|
| 172 |
+
body = _clean_multiline(str(message.get("body", "")))
|
| 173 |
+
if not body:
|
| 174 |
+
body = "Quick Vera update: I found one relevant action from your current context. Want me to prepare it?"
|
| 175 |
+
cta = message.get("cta") or CTA_OPEN
|
| 176 |
+
if cta not in {CTA_NONE, CTA_OPEN, CTA_YES_NO, CTA_CONFIRM, CTA_SLOTS, "binary_yes_stop"}:
|
| 177 |
+
cta = CTA_OPEN
|
| 178 |
+
body = _final_scrub(body)
|
| 179 |
+
message["body"] = body[:1800]
|
| 180 |
+
message["cta"] = cta
|
| 181 |
+
message.setdefault("send_as", "vera")
|
| 182 |
+
message.setdefault("suppression_key", "")
|
| 183 |
+
message.setdefault("rationale", "Composed deterministically from category, merchant, trigger, and optional customer context.")
|
| 184 |
+
return message
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def make_conversation_id(merchant_id: str, trigger_id: str, customer_id: str | None = None) -> str:
|
| 188 |
+
base = f"{merchant_id}:{trigger_id}:{customer_id or ''}"
|
| 189 |
+
digest = hashlib.sha1(base.encode("utf-8")).hexdigest()[:8]
|
| 190 |
+
merchant_short = _short_id(merchant_id)
|
| 191 |
+
trigger_short = _short_id(trigger_id)
|
| 192 |
+
if customer_id:
|
| 193 |
+
return f"conv_{merchant_short}_{_short_id(customer_id)}_{digest}"
|
| 194 |
+
return f"conv_{merchant_short}_{trigger_short}_{digest}"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _compose_merchant(category: Context, merchant: Context, trigger: Context) -> Context:
|
| 198 |
+
kind = trigger.get("kind", "generic")
|
| 199 |
+
cat = merchant.get("category_slug") or category.get("slug", "merchant")
|
| 200 |
+
identity = merchant.get("identity", {})
|
| 201 |
+
name = _merchant_name(merchant)
|
| 202 |
+
first = _owner_or_name(merchant)
|
| 203 |
+
payload = trigger.get("payload", {})
|
| 204 |
+
offer = _best_offer(merchant, category)
|
| 205 |
+
perf = merchant.get("performance", {})
|
| 206 |
+
agg = merchant.get("customer_aggregate", {})
|
| 207 |
+
locality = identity.get("locality") or identity.get("city") or "your area"
|
| 208 |
+
|
| 209 |
+
if _is_placeholder_payload(payload):
|
| 210 |
+
return _generic_merchant(category, merchant, trigger)
|
| 211 |
+
|
| 212 |
+
if kind in {"research_digest", "regulation_change", "cde_opportunity"}:
|
| 213 |
+
item = _digest_item(category, payload.get("top_item_id") or payload.get("digest_item_id"))
|
| 214 |
+
title = item.get("title") or payload.get("metric_or_topic") or "this week's category update"
|
| 215 |
+
source = item.get("source") or "your category digest"
|
| 216 |
+
number = _first_fact(item, agg)
|
| 217 |
+
if kind == "regulation_change":
|
| 218 |
+
deadline = payload.get("deadline_iso") or trigger.get("expires_at", "")[:10]
|
| 219 |
+
body = f"{name}, compliance note: {title}. Deadline: {deadline}. {number} Source: {source}. Want me to draft the 5-point SOP/checklist for your clinic?"
|
| 220 |
+
cta = CTA_YES_NO
|
| 221 |
+
elif kind == "cde_opportunity":
|
| 222 |
+
credits = payload.get("credits") or item.get("credits")
|
| 223 |
+
fee = str(payload.get("fee") or item.get("actionable") or "").replace("_", " ")
|
| 224 |
+
body = f"{name}, IDA/CDE item for you: {title}. Credits: {credits}; fee: {fee}. Source: {source}. Want me to pull the invite and make a 1-line calendar reminder?"
|
| 225 |
+
cta = CTA_YES_NO
|
| 226 |
+
else:
|
| 227 |
+
cohort = _cohort_phrase(agg)
|
| 228 |
+
body = f"{name}, {source} has one item relevant to {cohort}: {title}. {number} Want me to pull the 2-min summary and draft a patient WhatsApp?"
|
| 229 |
+
cta = CTA_OPEN
|
| 230 |
+
return _msg(body, cta, "vera", trigger, f"{kind} uses digest source plus merchant cohort/performance context.")
|
| 231 |
+
|
| 232 |
+
if kind in {"perf_dip", "seasonal_perf_dip"}:
|
| 233 |
+
if kind == "perf_dip" and not _has_all(payload, "metric", "delta_pct", "window"):
|
| 234 |
+
return _generic_merchant(category, merchant, trigger)
|
| 235 |
+
metric = payload.get("metric", "performance")
|
| 236 |
+
delta = _pct(payload.get("delta_pct") or perf.get("delta_7d", {}).get(f"{metric}_pct"))
|
| 237 |
+
baseline = payload.get("vs_baseline")
|
| 238 |
+
seasonal = payload.get("is_expected_seasonal")
|
| 239 |
+
if seasonal:
|
| 240 |
+
body = f"{first}, your {metric} is down {delta} this {payload.get('window', 'week')}, but this matches the {payload.get('season_note', 'seasonal dip')} pattern. Do not over-spend ads now; focus on your {_member_count(agg)}. Want me to draft a retention nudge?"
|
| 241 |
+
else:
|
| 242 |
+
body = f"{first}, {metric} dropped {delta} in {payload.get('window', '7d')}; baseline was {baseline} and current calls are {perf.get('calls', 'lower than usual')}. {offer} is the quickest concrete hook. Want me to draft a recovery WhatsApp/GBP post?"
|
| 243 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Performance dip trigger; recommends one low-effort recovery action.")
|
| 244 |
+
|
| 245 |
+
if kind == "perf_spike":
|
| 246 |
+
if not _has_all(payload, "metric", "delta_pct", "window"):
|
| 247 |
+
return _generic_merchant(category, merchant, trigger)
|
| 248 |
+
metric = payload.get("metric", "calls")
|
| 249 |
+
delta = _pct(payload.get("delta_pct"))
|
| 250 |
+
driver = str(payload.get("likely_driver") or "recent profile activity").replace("_", " ")
|
| 251 |
+
body = f"{first}, {metric} is up {delta} in {payload.get('window', '7d')} vs baseline {payload.get('vs_baseline', 'normal')}. Likely driver: {driver}. Want me to turn this into a repeatable post for this week?"
|
| 252 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Performance spike trigger; converts a winning signal into a repeatable action.")
|
| 253 |
+
|
| 254 |
+
if kind in {"active_planning_intent"}:
|
| 255 |
+
topic = str(payload.get("intent_topic", "growth plan")).replace("_", " ")
|
| 256 |
+
last = payload.get("merchant_last_message", "")
|
| 257 |
+
if "thali" in topic:
|
| 258 |
+
body = f"{first}, based on your '{last}' message, here is a starter corporate thali structure: 10 thalis at the current {offer}, 25+ with free delivery, 50+ with a filter-coffee add-on. Want me to draft the 3-line outreach note?"
|
| 259 |
+
elif "kids_yoga" in topic:
|
| 260 |
+
body = f"{first}, for kids yoga summer camp, keep it simple: age 7-12, 4 weeks, 3 sessions/week, Saturday trial, and use your current {offer}. Want me to draft the GBP post plus parent WhatsApp?"
|
| 261 |
+
else:
|
| 262 |
+
body = f"{first}, you asked about {topic}. I can turn it into one concrete offer using {offer}, your {locality} locality, and your current profile. Want the ready draft?"
|
| 263 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Merchant already showed planning intent; moves directly to a concrete draft.")
|
| 264 |
+
|
| 265 |
+
if kind == "festival_upcoming":
|
| 266 |
+
if not _has_all(payload, "festival", "days_until"):
|
| 267 |
+
return _generic_merchant(category, merchant, trigger)
|
| 268 |
+
festival = payload.get("festival", "festival")
|
| 269 |
+
days = payload.get("days_until")
|
| 270 |
+
body = f"{first}, {festival} is {days} days away and {cat} is marked relevant for this beat. Your active hook is {offer}. Want me to prepare a festival post now and hold it for approval?"
|
| 271 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Festival trigger with category relevance and existing offer.")
|
| 272 |
+
|
| 273 |
+
if kind == "ipl_match_today":
|
| 274 |
+
if not _has_all(payload, "match", "venue", "match_time_iso"):
|
| 275 |
+
return _generic_merchant(category, merchant, trigger)
|
| 276 |
+
match = payload.get("match", "today's match")
|
| 277 |
+
venue = payload.get("venue", "the stadium")
|
| 278 |
+
time = _time_from_iso(payload.get("match_time_iso")) or "tonight"
|
| 279 |
+
weeknight = payload.get("is_weeknight")
|
| 280 |
+
advice = "push a delivery-only offer" if not weeknight else "run a quick pre-match dine-in/post"
|
| 281 |
+
body = f"{first}, {match} at {venue} starts {time}. Since this is {'not ' if not weeknight else ''}a weeknight match, {advice} using your active {offer}. Want me to draft the banner text and Insta story?"
|
| 282 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "IPL trigger interpreted with day context and current restaurant offer.")
|
| 283 |
+
|
| 284 |
+
if kind == "review_theme_emerged":
|
| 285 |
+
if not _has_all(payload, "theme", "occurrences_30d"):
|
| 286 |
+
return _generic_merchant(category, merchant, trigger)
|
| 287 |
+
theme = str(payload.get("theme", "review theme")).replace("_", " ")
|
| 288 |
+
count = payload.get("occurrences_30d")
|
| 289 |
+
quote = payload.get("common_quote")
|
| 290 |
+
body = f"{first}, {count} reviews in 30d now mention {theme}; one customer said '{quote}'. Want me to draft a short public reply pattern plus an ops checklist?"
|
| 291 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Review theme trigger turns repeated feedback into reply and ops action.")
|
| 292 |
+
|
| 293 |
+
if kind == "milestone_reached":
|
| 294 |
+
if not _has_all(payload, "metric", "value_now", "milestone_value"):
|
| 295 |
+
return _generic_merchant(category, merchant, trigger)
|
| 296 |
+
value = payload.get("value_now")
|
| 297 |
+
target = payload.get("milestone_value")
|
| 298 |
+
metric = str(payload.get("metric", "milestone")).replace("_", " ")
|
| 299 |
+
body = f"{first}, you are at {value} {metric}; {target} is close. Want me to draft a polite review-request WhatsApp for recent happy customers?"
|
| 300 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Milestone trigger nudges a review/request action tied to current count.")
|
| 301 |
+
|
| 302 |
+
if kind == "renewal_due":
|
| 303 |
+
if not _has_any(payload, "days_remaining", "renewal_amount") and not merchant.get("subscription", {}).get("days_remaining"):
|
| 304 |
+
return _generic_merchant(category, merchant, trigger)
|
| 305 |
+
days = payload.get("days_remaining") or merchant.get("subscription", {}).get("days_remaining")
|
| 306 |
+
amount = payload.get("renewal_amount")
|
| 307 |
+
body = f"{first}, your {payload.get('plan', 'plan')} renewal is due in {days} days for Rs {amount}. Calls are down {_pct(merchant.get('performance', {}).get('delta_7d', {}).get('calls_pct'))}; before renewal, want me to show the 3 fixes likely to recover calls?"
|
| 308 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Renewal trigger pairs deadline with current performance risk.")
|
| 309 |
+
|
| 310 |
+
if kind in {"winback_eligible", "dormant_with_vera"}:
|
| 311 |
+
if not _has_any(payload, "days_since_expiry", "days_since_last_merchant_message", "lapsed_customers_added_since_expiry"):
|
| 312 |
+
return _generic_merchant(category, merchant, trigger)
|
| 313 |
+
days = payload.get("days_since_expiry") or payload.get("days_since_last_merchant_message") or merchant.get("subscription", {}).get("days_since_expiry")
|
| 314 |
+
lapsed = payload.get("lapsed_customers_added_since_expiry") or agg.get("lapsed_90d_plus") or agg.get("lapsed_180d_plus")
|
| 315 |
+
body = f"{first}, it has been {days} days since the last active Vera/subscription moment. You now have {lapsed} lapsed customers/signals to recover. Want me to draft one winback message using {offer}?"
|
| 316 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Dormancy/winback trigger; restarts with one concrete recovery action.")
|
| 317 |
+
|
| 318 |
+
if kind == "supply_alert":
|
| 319 |
+
if not _has_all(payload, "molecule", "affected_batches"):
|
| 320 |
+
return _generic_merchant(category, merchant, trigger)
|
| 321 |
+
batches = ", ".join(payload.get("affected_batches", [])[:3])
|
| 322 |
+
molecule = payload.get("molecule", "medicine")
|
| 323 |
+
chronic = agg.get("chronic_rx_count") or agg.get("total_unique_ytd") or "repeat"
|
| 324 |
+
body = f"{first}, urgent stock alert: {molecule} batches {batches} from {payload.get('manufacturer', 'the manufacturer')}. You have {chronic} chronic-Rx customers in context. Want me to draft the replacement WhatsApp and counter checklist?"
|
| 325 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Supply alert trigger uses molecule, batch numbers, and pharmacy repeat-customer context.")
|
| 326 |
+
|
| 327 |
+
if kind == "category_seasonal":
|
| 328 |
+
if not payload.get("trends"):
|
| 329 |
+
return _generic_merchant(category, merchant, trigger)
|
| 330 |
+
trends = ", ".join(str(t).replace("_", " ") for t in payload.get("trends", [])[:4])
|
| 331 |
+
body = f"{first}, summer demand shift is visible: {trends}. Since shelf action is recommended, want me to draft a 10-item reorder/checklist plus WhatsApp note?"
|
| 332 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Seasonal pharmacy/category trigger maps trends to stock action.")
|
| 333 |
+
|
| 334 |
+
if kind == "gbp_unverified":
|
| 335 |
+
if not _has_all(payload, "verification_path", "estimated_uplift_pct"):
|
| 336 |
+
return _generic_merchant(category, merchant, trigger)
|
| 337 |
+
uplift = _pct(payload.get("estimated_uplift_pct"))
|
| 338 |
+
body = f"{first}, your GBP is still unverified; the available path is {payload.get('verification_path', 'verification')}. Verified profiles can unlock about {uplift} more visibility in this context. Want me to walk you through the 3-step verification?"
|
| 339 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "GBP verification trigger uses exact path and estimated uplift.")
|
| 340 |
+
|
| 341 |
+
if kind == "competitor_opened":
|
| 342 |
+
if not _has_all(payload, "competitor_name", "distance_km", "opened_date", "their_offer"):
|
| 343 |
+
return _generic_merchant(category, merchant, trigger)
|
| 344 |
+
body = f"{name}, new competitor signal: {payload.get('competitor_name')} opened {payload.get('distance_km')} km away on {payload.get('opened_date')} with {payload.get('their_offer')}. Your current hook is {offer}. Want me to draft a sharper local post?"
|
| 345 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Competitor trigger uses named competitor, distance, date, and offer comparison.")
|
| 346 |
+
|
| 347 |
+
if kind == "curious_ask_due":
|
| 348 |
+
body = f"{first}, quick check: what service has been most asked-for this week at {identity.get('name', 'your business')}? I will turn your answer into a Google post and a 4-line WhatsApp reply. Takes 5 min."
|
| 349 |
+
return _msg(body, CTA_OPEN, "vera", trigger, "Curious-ask cadence asks the merchant for one useful signal.")
|
| 350 |
+
|
| 351 |
+
return _generic_merchant(category, merchant, trigger)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _compose_customer(category: Context, merchant: Context, trigger: Context, customer: Context) -> Context:
|
| 355 |
+
if not _has_consent(customer, trigger):
|
| 356 |
+
return _msg(
|
| 357 |
+
f"{_owner_or_name(merchant)}, {_customer_name(customer)} has a {str(trigger.get('kind', 'customer')).replace('_', ' ')} signal, but the saved opt-in scope does not clearly cover this outreach. Want me to prepare a consent-safe approval note first?",
|
| 358 |
+
CTA_YES_NO,
|
| 359 |
+
"vera",
|
| 360 |
+
trigger,
|
| 361 |
+
"Customer trigger routed to merchant because consent/preference does not clearly permit direct outreach.",
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
kind = trigger.get("kind", "customer_message")
|
| 365 |
+
payload = trigger.get("payload", {})
|
| 366 |
+
merchant_name = merchant.get("identity", {}).get("name", "your merchant")
|
| 367 |
+
owner = merchant.get("identity", {}).get("owner_first_name") or merchant_name
|
| 368 |
+
customer_name = _customer_name(customer)
|
| 369 |
+
offer = _best_offer(merchant, category)
|
| 370 |
+
lang = str(customer.get("identity", {}).get("language_pref", "english")).lower()
|
| 371 |
+
|
| 372 |
+
if _is_placeholder_payload(payload):
|
| 373 |
+
return _generic_customer(category, merchant, trigger, customer)
|
| 374 |
+
|
| 375 |
+
if kind == "recall_due":
|
| 376 |
+
if not _has_any(payload, "service_due", "due_date", "available_slots"):
|
| 377 |
+
return _generic_customer(category, merchant, trigger, customer)
|
| 378 |
+
slots = payload.get("available_slots", [])
|
| 379 |
+
slot_text = _slot_text(slots)
|
| 380 |
+
months = _months_between(payload.get("last_service_date"), payload.get("due_date")) or "6-month"
|
| 381 |
+
if "hi" in lang:
|
| 382 |
+
body = f"Hi {customer_name}, {merchant_name} here. It has been about {months} since your last visit; your {payload.get('service_due', 'recall').replace('_', ' ')} is due. Apke liye slots: {slot_text}. {offer}. Reply 1/2 for a slot, or suggest a time."
|
| 383 |
+
else:
|
| 384 |
+
body = f"Hi {customer_name}, {merchant_name} here. It has been about {months} since your last visit; your {payload.get('service_due', 'recall').replace('_', ' ')} is due. Slots available: {slot_text}. {offer}. Reply 1/2 for a slot, or suggest a time."
|
| 385 |
+
return _msg(body, CTA_SLOTS, "merchant_on_behalf", trigger, "Customer recall uses due date, slot options, offer, and language preference.")
|
| 386 |
+
|
| 387 |
+
if kind in {"customer_lapsed_hard", "customer_lapsed_soft"}:
|
| 388 |
+
days = payload.get("days_since_last_visit")
|
| 389 |
+
focus = str(payload.get("previous_focus") or customer.get("preferences", {}).get("training_focus") or "your goal").replace("_", " ")
|
| 390 |
+
elapsed = f"{days} days" if days is not None else "a while"
|
| 391 |
+
body = f"Hi {customer_name}, {owner} from {merchant_name} here. It has been {elapsed} since your last visit; no pressure. We can restart with {offer}, matched to {focus}. Reply YES and I will hold a no-commitment slot."
|
| 392 |
+
return _msg(body, CTA_YES_NO, "merchant_on_behalf", trigger, "Customer lapse trigger uses days since visit, prior focus, and merchant offer.")
|
| 393 |
+
|
| 394 |
+
if kind == "appointment_tomorrow":
|
| 395 |
+
appointment = payload.get("appointment_time") or payload.get("slot_label") or _slot_text(payload.get("available_slots", []))
|
| 396 |
+
service = str(payload.get("service") or customer.get("relationship", {}).get("services_received", ["appointment"])[-1]).replace("_", " ")
|
| 397 |
+
body = f"Hi {customer_name}, {merchant_name} here. Reminder for your {service} appointment tomorrow: {appointment}. Reply YES to confirm or tell us if you need to reschedule."
|
| 398 |
+
return _msg(body, CTA_CONFIRM, "merchant_on_behalf", trigger, "Appointment reminder uses customer relationship and available appointment timing.")
|
| 399 |
+
|
| 400 |
+
if kind == "wedding_package_followup":
|
| 401 |
+
if not _has_any(payload, "wedding_date", "days_to_wedding", "next_step_window_open"):
|
| 402 |
+
return _generic_customer(category, merchant, trigger, customer)
|
| 403 |
+
days = payload.get("days_to_wedding")
|
| 404 |
+
body = f"Hi {customer_name}, {owner} from {merchant_name} here. {days} days to your wedding; this is the right window for {str(payload.get('next_step_window_open', 'skin prep')).replace('_', ' ')}. {offer}. Want me to block your preferred Saturday slot?"
|
| 405 |
+
return _msg(body, CTA_YES_NO, "merchant_on_behalf", trigger, "Bridal follow-up uses wedding date window, relationship history, and offer.")
|
| 406 |
+
|
| 407 |
+
if kind == "trial_followup":
|
| 408 |
+
if not _has_any(payload, "trial_date", "next_session_options"):
|
| 409 |
+
return _generic_customer(category, merchant, trigger, customer)
|
| 410 |
+
slots = _slot_text(payload.get("next_session_options", []))
|
| 411 |
+
body = f"Hi {customer_name}, {owner} from {merchant_name} here. Thanks for trying the class on {payload.get('trial_date')}. Next available option: {slots}. Want me to reserve it?"
|
| 412 |
+
return _msg(body, CTA_YES_NO, "merchant_on_behalf", trigger, "Trial follow-up uses trial date and next available session.")
|
| 413 |
+
|
| 414 |
+
if kind == "chronic_refill_due":
|
| 415 |
+
if not _has_any(payload, "molecule_list", "stock_runs_out_iso"):
|
| 416 |
+
return _generic_customer(category, merchant, trigger, customer)
|
| 417 |
+
meds = ", ".join(payload.get("molecule_list", []))
|
| 418 |
+
address_note = "We have your saved delivery address on file." if payload.get("delivery_address_saved") else "We can confirm the delivery address after your reply."
|
| 419 |
+
body = f"Namaste {customer_name}, {merchant_name} here. Your monthly medicines ({meds}) are due by {_date_from_iso(payload.get('stock_runs_out_iso'))}. {address_note} Reply CONFIRM and our pharmacist will verify stock and delivery details before preparing it."
|
| 420 |
+
return _msg(body, CTA_CONFIRM, "merchant_on_behalf", trigger, "Refill reminder uses molecule list, run-out date, delivery status, and pharmacy offer.")
|
| 421 |
+
|
| 422 |
+
return _msg(
|
| 423 |
+
f"Hi {customer_name}, {merchant_name} here. A quick update related to your last visit is ready. {offer}. Reply YES if you want the details.",
|
| 424 |
+
CTA_YES_NO,
|
| 425 |
+
"merchant_on_behalf",
|
| 426 |
+
trigger,
|
| 427 |
+
"Generic customer-scoped trigger with consent and merchant offer.",
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def _generic_merchant(category: Context, merchant: Context, trigger: Context) -> Context:
|
| 432 |
+
first = _owner_or_name(merchant)
|
| 433 |
+
kind = str(trigger.get("kind", "update")).replace("_", " ")
|
| 434 |
+
perf = merchant.get("performance", {})
|
| 435 |
+
offer = _best_offer(merchant, category)
|
| 436 |
+
views = perf.get("views")
|
| 437 |
+
calls = perf.get("calls")
|
| 438 |
+
identity = merchant.get("identity", {})
|
| 439 |
+
locality = identity.get("locality") or identity.get("city") or "your locality"
|
| 440 |
+
fact = f"Current 30d views: {views}; calls: {calls}." if views is not None and calls is not None else f"Locality: {locality}."
|
| 441 |
+
body = f"{first}, Vera found a {kind} signal for {_merchant_name(merchant)}. {fact} Best hook: {offer}. Want me to draft the next WhatsApp/GBP action?"
|
| 442 |
+
return _msg(body, CTA_YES_NO, "vera", trigger, "Fallback grounded in trigger kind, performance, and offer.")
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _generic_customer(category: Context, merchant: Context, trigger: Context, customer: Context) -> Context:
|
| 446 |
+
customer_name = _customer_name(customer)
|
| 447 |
+
merchant_name = merchant.get("identity", {}).get("name", "your merchant")
|
| 448 |
+
kind = str(trigger.get("kind", "customer update")).replace("_", " ")
|
| 449 |
+
last_visit = customer.get("relationship", {}).get("last_visit")
|
| 450 |
+
state = str(customer.get("state", "active")).replace("_", " ")
|
| 451 |
+
offer = _best_offer(merchant, category)
|
| 452 |
+
visit_text = f" Last visit: {last_visit}." if last_visit else ""
|
| 453 |
+
body = f"Hi {customer_name}, {merchant_name} here. Quick {kind} update for you based on your {state} status.{visit_text} {offer}. Reply YES if you want me to hold the next step."
|
| 454 |
+
return _msg(body, CTA_YES_NO, "merchant_on_behalf", trigger, "Customer fallback grounded in relationship state, last visit, and available offer.")
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def _msg(body: str, cta: str, send_as: str, trigger: Context, rationale: str) -> Context:
|
| 458 |
+
return validate_message({
|
| 459 |
+
"body": body,
|
| 460 |
+
"cta": cta,
|
| 461 |
+
"send_as": send_as,
|
| 462 |
+
"suppression_key": trigger.get("suppression_key", trigger.get("id", "")),
|
| 463 |
+
"rationale": rationale,
|
| 464 |
+
})
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def _clean(text: str) -> str:
|
| 468 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def _clean_multiline(text: str) -> str:
|
| 472 |
+
text = re.sub(r"\.{2,}", ".", text)
|
| 473 |
+
text = re.sub(r";\s*\.", ".", text)
|
| 474 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 475 |
+
text = re.sub(r" *\n *", "\n", text)
|
| 476 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 477 |
+
return text.strip()
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def _merchant_name(merchant: Context) -> str:
|
| 481 |
+
identity = merchant.get("identity", {})
|
| 482 |
+
name = identity.get("name") or "Merchant"
|
| 483 |
+
owner = str(identity.get("owner_first_name") or "").strip()
|
| 484 |
+
if merchant.get("category_slug") == "dentists" and owner and not str(name).lower().startswith("dr."):
|
| 485 |
+
return owner if owner.lower().startswith("dr") else f"Dr. {owner}"
|
| 486 |
+
return _dedupe_dr(str(name))
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def _owner_or_name(merchant: Context) -> str:
|
| 490 |
+
identity = merchant.get("identity", {})
|
| 491 |
+
owner = identity.get("owner_first_name")
|
| 492 |
+
if owner:
|
| 493 |
+
if merchant.get("category_slug") == "dentists" and not str(owner).lower().startswith("dr"):
|
| 494 |
+
return f"Dr. {owner}"
|
| 495 |
+
return str(owner)
|
| 496 |
+
return str(identity.get("name", "there"))
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def _customer_name(customer: Context) -> str:
|
| 500 |
+
name = str(customer.get("identity", {}).get("name") or "there")
|
| 501 |
+
return name.replace("(parent:", "parent:").strip()
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def _best_offer(merchant: Context, category: Context) -> str:
|
| 505 |
+
for offer in merchant.get("offers", []) or []:
|
| 506 |
+
if offer.get("status") == "active" and offer.get("title"):
|
| 507 |
+
return str(offer["title"])
|
| 508 |
+
catalog = [offer for offer in (category.get("offer_catalog", []) or []) if offer.get("title")]
|
| 509 |
+
preferred_types = {"service_at_price": 0, "free_service": 1, "membership": 2}
|
| 510 |
+
def rank(offer: Context) -> tuple[int, int]:
|
| 511 |
+
title = str(offer.get("title", "")).lower()
|
| 512 |
+
percent_penalty = 5 if "flat" in title and "%" in title else 0
|
| 513 |
+
return (preferred_types.get(str(offer.get("type")), 3) + percent_penalty, len(title))
|
| 514 |
+
for offer in sorted(catalog, key=rank):
|
| 515 |
+
title = str(offer["title"])
|
| 516 |
+
if not ("flat" in title.lower() and "%" in title):
|
| 517 |
+
return title
|
| 518 |
+
if catalog:
|
| 519 |
+
return str(sorted(catalog, key=rank)[0]["title"])
|
| 520 |
+
return "a simple service-price offer"
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
def _digest_item(category: Context, item_id: str | None) -> Context:
|
| 524 |
+
digest = category.get("digest", []) or []
|
| 525 |
+
if item_id:
|
| 526 |
+
for item in digest:
|
| 527 |
+
if item.get("id") == item_id:
|
| 528 |
+
return item
|
| 529 |
+
return digest[0] if digest else {}
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def _first_fact(item: Context, agg: Context) -> str:
|
| 533 |
+
facts: list[str] = []
|
| 534 |
+
if item.get("trial_n"):
|
| 535 |
+
facts.append(f"{item['trial_n']}-patient trial")
|
| 536 |
+
if item.get("summary"):
|
| 537 |
+
match = re.search(r"(\d+(?:\.\d+)?%|\d+(?:\.\d+)?\s?mSv|\d+(?:\.\d+)?\s?credits?)", str(item["summary"]))
|
| 538 |
+
if match:
|
| 539 |
+
facts.append(match.group(1))
|
| 540 |
+
if agg.get("high_risk_adult_count"):
|
| 541 |
+
facts.append(f"{agg['high_risk_adult_count']} high-risk adults in your roster")
|
| 542 |
+
return "; ".join(facts) + "." if facts else ""
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def _cohort_phrase(agg: Context) -> str:
|
| 546 |
+
if agg.get("high_risk_adult_count"):
|
| 547 |
+
return f"your {agg['high_risk_adult_count']} high-risk adult patients"
|
| 548 |
+
if agg.get("chronic_rx_count"):
|
| 549 |
+
return f"your {agg['chronic_rx_count']} chronic-Rx customers"
|
| 550 |
+
if agg.get("total_active_members"):
|
| 551 |
+
return f"your {agg['total_active_members']} active members"
|
| 552 |
+
if agg.get("lapsed_90d_plus") or agg.get("lapsed_180d_plus"):
|
| 553 |
+
return "your lapsed-customer cohort"
|
| 554 |
+
return "your current customers"
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def _member_count(agg: Context) -> str:
|
| 558 |
+
if agg.get("total_active_members"):
|
| 559 |
+
return f"{agg['total_active_members']} active members"
|
| 560 |
+
if agg.get("total_unique_ytd"):
|
| 561 |
+
return f"{agg['total_unique_ytd']} customers"
|
| 562 |
+
return "existing customers"
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
def _pct(value: Any) -> str:
|
| 566 |
+
try:
|
| 567 |
+
num = float(value)
|
| 568 |
+
except (TypeError, ValueError):
|
| 569 |
+
return "0%"
|
| 570 |
+
return f"{num * 100:.0f}%" if abs(num) <= 1 else f"{num:.0f}%"
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def _time_from_iso(value: str | None) -> str | None:
|
| 574 |
+
if not value:
|
| 575 |
+
return None
|
| 576 |
+
match = re.search(r"T(\d{2}):(\d{2})", value)
|
| 577 |
+
if not match:
|
| 578 |
+
return value
|
| 579 |
+
hour = int(match.group(1))
|
| 580 |
+
minute = match.group(2)
|
| 581 |
+
suffix = "am" if hour < 12 else "pm"
|
| 582 |
+
hour = hour if 1 <= hour <= 12 else abs(hour - 12) or 12
|
| 583 |
+
return f"{hour}:{minute}{suffix}"
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def _date_from_iso(value: str | None) -> str:
|
| 587 |
+
if not value:
|
| 588 |
+
return "the due date"
|
| 589 |
+
return value.split("T", 1)[0]
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def _months_between(start: str | None, end: str | None) -> str | None:
|
| 593 |
+
if not start or not end:
|
| 594 |
+
return None
|
| 595 |
+
try:
|
| 596 |
+
a = datetime.fromisoformat(start[:10])
|
| 597 |
+
b = datetime.fromisoformat(end[:10])
|
| 598 |
+
except ValueError:
|
| 599 |
+
return None
|
| 600 |
+
months = max(1, round((b - a).days / 30))
|
| 601 |
+
return f"{months} months"
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
def _slot_text(slots: list[Context]) -> str:
|
| 605 |
+
labels = [str(s.get("label")) for s in slots if s.get("label")]
|
| 606 |
+
if not labels:
|
| 607 |
+
return "the next available slot"
|
| 608 |
+
if len(labels) == 1:
|
| 609 |
+
return labels[0]
|
| 610 |
+
return " or ".join(labels[:2])
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def _has_consent(customer: Context, trigger: Context) -> bool:
|
| 614 |
+
prefs = customer.get("preferences", {})
|
| 615 |
+
if prefs.get("reminder_opt_in") is False:
|
| 616 |
+
return False
|
| 617 |
+
scopes = set(customer.get("consent", {}).get("scope", []) or [])
|
| 618 |
+
kind = trigger.get("kind", "")
|
| 619 |
+
if kind in {"recall_due", "appointment_tomorrow"}:
|
| 620 |
+
return bool(scopes.intersection({"recall_reminders", "appointment_reminders"}))
|
| 621 |
+
if kind in {"chronic_refill_due"}:
|
| 622 |
+
return bool(scopes.intersection({"refill_reminders", "delivery_notifications"}))
|
| 623 |
+
if kind in {"customer_lapsed_hard", "customer_lapsed_soft"}:
|
| 624 |
+
return bool(scopes.intersection({"winback_offers", "renewal_reminders", "promotional_offers"}))
|
| 625 |
+
if kind in {"wedding_package_followup"}:
|
| 626 |
+
return "bridal_package_followup" in scopes
|
| 627 |
+
if kind in {"trial_followup"}:
|
| 628 |
+
return bool(scopes.intersection({"kids_program_updates", "program_updates", "appointment_reminders"}))
|
| 629 |
+
return bool(scopes)
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
def _has_all(payload: Context, *fields: str) -> bool:
|
| 633 |
+
return all(payload.get(field) not in (None, "", []) for field in fields)
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def _has_any(payload: Context, *fields: str) -> bool:
|
| 637 |
+
return any(payload.get(field) not in (None, "", []) for field in fields)
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
def _is_placeholder_payload(payload: Context) -> bool:
|
| 641 |
+
return payload.get("placeholder") is True
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
def _dedupe_dr(value: str) -> str:
|
| 645 |
+
return re.sub(r"\bDr\.\s+Dr\.\s+", "Dr. ", value).strip()
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def _final_scrub(body: str) -> str:
|
| 649 |
+
body = _dedupe_dr(body)
|
| 650 |
+
body = body.replace("None", "the available context")
|
| 651 |
+
body = body.replace("baseline normal", "the recent baseline")
|
| 652 |
+
body = re.sub(r"\b(up|down|dropped|rose|increased)\s+0%\b", "changed in the latest context", body)
|
| 653 |
+
body = body.replace("the available context days", "a while")
|
| 654 |
+
body = body.replace("the available context km", "nearby")
|
| 655 |
+
body = body.replace("festival is the available context days away", "a festival window is coming up")
|
| 656 |
+
return _clean_multiline(body)
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def _short_id(value: str) -> str:
|
| 660 |
+
cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_")
|
| 661 |
+
parts = cleaned.split("_")
|
| 662 |
+
return "_".join(parts[:4])[:36] or "x"
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
def _action_scope(merchant: Context, trigger: Context) -> str:
|
| 666 |
+
name = merchant.get("identity", {}).get("name") or "this merchant"
|
| 667 |
+
kind = str(trigger.get("kind", "task")).replace("_", " ")
|
| 668 |
+
return f"{name}'s {kind}"
|
app/decision_engine.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/main.py
CHANGED
|
@@ -90,6 +90,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|
| 90 |
)
|
| 91 |
|
| 92 |
|
|
|
|
| 93 |
@app.get("/v1/healthz")
|
| 94 |
async def healthz() -> dict[str, Any]:
|
| 95 |
counts = {scope: 0 for scope in VALID_SCOPES}
|
|
@@ -103,6 +104,7 @@ async def healthz() -> dict[str, Any]:
|
|
| 103 |
}
|
| 104 |
|
| 105 |
|
|
|
|
| 106 |
@app.get("/v1/metadata")
|
| 107 |
async def metadata() -> dict[str, Any]:
|
| 108 |
return {
|
|
@@ -119,6 +121,7 @@ async def metadata() -> dict[str, Any]:
|
|
| 119 |
}
|
| 120 |
|
| 121 |
|
|
|
|
| 122 |
@app.post("/v1/context")
|
| 123 |
async def push_context(body: ContextBody):
|
| 124 |
if body.scope not in VALID_SCOPES:
|
|
@@ -146,6 +149,7 @@ async def push_context(body: ContextBody):
|
|
| 146 |
return {"accepted": True, "ack_id": f"ack_{body.context_id}_v{body.version}", "stored_at": contexts[key]["stored_at"]}
|
| 147 |
|
| 148 |
|
|
|
|
| 149 |
@app.post("/v1/tick")
|
| 150 |
async def tick(body: TickBody) -> dict[str, Any]:
|
| 151 |
actions: list[dict[str, Any]] = []
|
|
@@ -228,6 +232,7 @@ async def tick(body: TickBody) -> dict[str, Any]:
|
|
| 228 |
return {"actions": actions}
|
| 229 |
|
| 230 |
|
|
|
|
| 231 |
@app.post("/v1/reply")
|
| 232 |
async def reply(body: ReplyBody) -> dict[str, Any]:
|
| 233 |
conv = conversations.setdefault(
|
|
@@ -296,6 +301,7 @@ async def reply(body: ReplyBody) -> dict[str, Any]:
|
|
| 296 |
return result
|
| 297 |
|
| 298 |
|
|
|
|
| 299 |
@app.post("/v1/teardown")
|
| 300 |
async def teardown() -> dict[str, Any]:
|
| 301 |
contexts.clear()
|
|
|
|
| 90 |
)
|
| 91 |
|
| 92 |
|
| 93 |
+
@app.get("/healthz", include_in_schema=False)
|
| 94 |
@app.get("/v1/healthz")
|
| 95 |
async def healthz() -> dict[str, Any]:
|
| 96 |
counts = {scope: 0 for scope in VALID_SCOPES}
|
|
|
|
| 104 |
}
|
| 105 |
|
| 106 |
|
| 107 |
+
@app.get("/metadata", include_in_schema=False)
|
| 108 |
@app.get("/v1/metadata")
|
| 109 |
async def metadata() -> dict[str, Any]:
|
| 110 |
return {
|
|
|
|
| 121 |
}
|
| 122 |
|
| 123 |
|
| 124 |
+
@app.post("/context", include_in_schema=False)
|
| 125 |
@app.post("/v1/context")
|
| 126 |
async def push_context(body: ContextBody):
|
| 127 |
if body.scope not in VALID_SCOPES:
|
|
|
|
| 149 |
return {"accepted": True, "ack_id": f"ack_{body.context_id}_v{body.version}", "stored_at": contexts[key]["stored_at"]}
|
| 150 |
|
| 151 |
|
| 152 |
+
@app.post("/tick", include_in_schema=False)
|
| 153 |
@app.post("/v1/tick")
|
| 154 |
async def tick(body: TickBody) -> dict[str, Any]:
|
| 155 |
actions: list[dict[str, Any]] = []
|
|
|
|
| 232 |
return {"actions": actions}
|
| 233 |
|
| 234 |
|
| 235 |
+
@app.post("/reply", include_in_schema=False)
|
| 236 |
@app.post("/v1/reply")
|
| 237 |
async def reply(body: ReplyBody) -> dict[str, Any]:
|
| 238 |
conv = conversations.setdefault(
|
|
|
|
| 301 |
return result
|
| 302 |
|
| 303 |
|
| 304 |
+
@app.post("/teardown", include_in_schema=False)
|
| 305 |
@app.post("/v1/teardown")
|
| 306 |
async def teardown() -> dict[str, Any]:
|
| 307 |
contexts.clear()
|
submission.jsonl
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tests/test_bot.py
CHANGED
|
@@ -1,585 +1,585 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
import sys
|
| 6 |
-
|
| 7 |
-
from fastapi.testclient import TestClient
|
| 8 |
-
|
| 9 |
-
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
-
sys.path.insert(0, str(ROOT))
|
| 11 |
-
|
| 12 |
-
from app.decision_engine import build_candidates, build_merchant_car, case_similarity, constitutional_violations, extract_evidence, retrieve_similar_case, score_map, select_cialdini_principle # noqa: E402
|
| 13 |
-
from app.main import app, category_arm_pool, contexts, conversations, merchant_action_memory, merchant_auto_replies, merchant_opt_out, suppressed # noqa: E402
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def load_seed(name: str, key: str) -> list[dict]:
|
| 17 |
-
return json.loads((ROOT / "dataset" / name).read_text(encoding="utf-8"))[key]
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def load_category(slug: str) -> dict:
|
| 21 |
-
return json.loads((ROOT / "dataset" / "categories" / f"{slug}.json").read_text(encoding="utf-8"))
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def reset_state() -> None:
|
| 25 |
-
contexts.clear()
|
| 26 |
-
conversations.clear()
|
| 27 |
-
suppressed.clear()
|
| 28 |
-
merchant_opt_out.clear()
|
| 29 |
-
merchant_auto_replies.clear()
|
| 30 |
-
merchant_action_memory.clear()
|
| 31 |
-
category_arm_pool.clear()
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def push(client: TestClient, scope: str, context_id: str, payload: dict, version: int = 1):
|
| 35 |
-
return client.post(
|
| 36 |
-
"/v1/context",
|
| 37 |
-
json={"scope": scope, "context_id": context_id, "version": version, "payload": payload, "delivered_at": "2026-04-26T10:00:00Z"},
|
| 38 |
-
)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def test_health_and_context_idempotency():
|
| 42 |
-
reset_state()
|
| 43 |
-
client = TestClient(app)
|
| 44 |
-
assert client.get("/v1/healthz").json()["status"] == "ok"
|
| 45 |
-
cat = load_category("dentists")
|
| 46 |
-
resp = push(client, "category", "dentists", cat)
|
| 47 |
-
assert resp.status_code == 200
|
| 48 |
-
assert resp.json()["accepted"] is True
|
| 49 |
-
same = push(client, "category", "dentists", cat)
|
| 50 |
-
assert same.status_code == 200
|
| 51 |
-
assert same.json()["idempotent"] is True
|
| 52 |
-
stale = push(client, "category", "dentists", cat, version=0)
|
| 53 |
-
assert stale.status_code == 409
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def test_tick_creates_grounded_research_action():
|
| 57 |
-
reset_state()
|
| 58 |
-
client = TestClient(app)
|
| 59 |
-
merchants = load_seed("merchants_seed.json", "merchants")
|
| 60 |
-
triggers = load_seed("triggers_seed.json", "triggers")
|
| 61 |
-
merchant = merchants[0]
|
| 62 |
-
trigger = triggers[0]
|
| 63 |
-
push(client, "category", "dentists", load_category("dentists"))
|
| 64 |
-
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 65 |
-
push(client, "trigger", trigger["id"], trigger)
|
| 66 |
-
|
| 67 |
-
resp = client.post("/v1/tick", json={"now": "2026-04-26T10:30:00Z", "available_triggers": [trigger["id"]]})
|
| 68 |
-
assert resp.status_code == 200
|
| 69 |
-
actions = resp.json()["actions"]
|
| 70 |
-
assert len(actions) == 1
|
| 71 |
-
body = actions[0]["body"]
|
| 72 |
-
assert "JIDA" in body
|
| 73 |
-
assert "124" in body
|
| 74 |
-
assert actions[0]["send_as"] == "vera"
|
| 75 |
-
assert actions[0]["suppression_key"] == trigger["suppression_key"]
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def test_customer_consent_and_send_as():
|
| 79 |
-
reset_state()
|
| 80 |
-
client = TestClient(app)
|
| 81 |
-
merchants = load_seed("merchants_seed.json", "merchants")
|
| 82 |
-
customers = load_seed("customers_seed.json", "customers")
|
| 83 |
-
triggers = load_seed("triggers_seed.json", "triggers")
|
| 84 |
-
merchant = merchants[0]
|
| 85 |
-
customer = customers[0]
|
| 86 |
-
trigger = triggers[2]
|
| 87 |
-
push(client, "category", "dentists", load_category("dentists"))
|
| 88 |
-
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 89 |
-
push(client, "customer", customer["customer_id"], customer)
|
| 90 |
-
push(client, "trigger", trigger["id"], trigger)
|
| 91 |
-
|
| 92 |
-
resp = client.post("/v1/tick", json={"now": "2026-04-26T11:00:00Z", "available_triggers": [trigger["id"]]})
|
| 93 |
-
action = resp.json()["actions"][0]
|
| 94 |
-
assert action["send_as"] == "merchant_on_behalf"
|
| 95 |
-
assert action["customer_id"] == customer["customer_id"]
|
| 96 |
-
assert "Priya" in action["body"]
|
| 97 |
-
assert "Wed 5 Nov" in action["body"]
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def test_reply_replay_behaviors():
|
| 101 |
-
reset_state()
|
| 102 |
-
client = TestClient(app)
|
| 103 |
-
auto = "Thank you for contacting us! Our team will respond shortly."
|
| 104 |
-
first = client.post("/v1/reply", json={"conversation_id": "conv_auto", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 2}).json()
|
| 105 |
-
assert first["action"] == "wait"
|
| 106 |
-
second = client.post("/v1/reply", json={"conversation_id": "conv_auto", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 3}).json()
|
| 107 |
-
assert second["action"] == "wait"
|
| 108 |
-
third = client.post("/v1/reply", json={"conversation_id": "conv_auto", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 4}).json()
|
| 109 |
-
assert third["action"] == "end"
|
| 110 |
-
|
| 111 |
-
intent = client.post("/v1/reply", json={"conversation_id": "conv_intent", "merchant_id": "m1", "from_role": "merchant", "message": "Ok lets do it. Whats next?", "turn_number": 2}).json()
|
| 112 |
-
assert intent["action"] == "send"
|
| 113 |
-
assert "preparing" in intent["body"]
|
| 114 |
-
assert intent["cta"] == "none"
|
| 115 |
-
|
| 116 |
-
hostile = client.post("/v1/reply", json={"conversation_id": "conv_hostile", "merchant_id": "m1", "from_role": "merchant", "message": "Stop messaging me. This is useless spam.", "turn_number": 2}).json()
|
| 117 |
-
assert hostile["action"] == "end"
|
| 118 |
-
|
| 119 |
-
plain_stop = client.post("/v1/reply", json={"conversation_id": "conv_plain_stop", "merchant_id": "m1", "from_role": "merchant", "message": "STOP", "turn_number": 2}).json()
|
| 120 |
-
assert plain_stop["action"] == "end"
|
| 121 |
-
|
| 122 |
-
offtopic = client.post("/v1/reply", json={"conversation_id": "conv_offtopic", "merchant_id": "m1", "from_role": "merchant", "message": "What is the cricket score?", "turn_number": 2}).json()
|
| 123 |
-
assert offtopic["action"] == "send"
|
| 124 |
-
assert "outside" in offtopic["body"].lower()
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def test_ended_conversation_never_sends_again_and_omitted_merchant_optout():
|
| 128 |
-
reset_state()
|
| 129 |
-
client = TestClient(app)
|
| 130 |
-
conversations["conv_done"] = {"merchant_id": "m_done", "ended": True, "turns": []}
|
| 131 |
-
resp = client.post("/v1/reply", json={"conversation_id": "conv_done", "from_role": "merchant", "message": "hello", "turn_number": 5}).json()
|
| 132 |
-
assert resp["action"] == "end"
|
| 133 |
-
|
| 134 |
-
conversations["conv_stop"] = {"merchant_id": "m_stop", "ended": False, "turns": []}
|
| 135 |
-
stop = client.post("/v1/reply", json={"conversation_id": "conv_stop", "from_role": "merchant", "message": "Stop messaging me", "turn_number": 2}).json()
|
| 136 |
-
assert stop["action"] == "end"
|
| 137 |
-
assert "m_stop" in merchant_opt_out
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def test_validation_errors_are_challenge_style_400():
|
| 141 |
-
reset_state()
|
| 142 |
-
client = TestClient(app)
|
| 143 |
-
resp = client.post("/v1/context", json={"scope": "category"})
|
| 144 |
-
assert resp.status_code == 400
|
| 145 |
-
body = resp.json()
|
| 146 |
-
assert body["accepted"] is False
|
| 147 |
-
assert body["reason"] == "malformed"
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def test_merchant_level_auto_reply_tracking_across_conversations():
|
| 151 |
-
reset_state()
|
| 152 |
-
client = TestClient(app)
|
| 153 |
-
auto = "Thank you for contacting us! Our team will respond shortly."
|
| 154 |
-
assert client.post("/v1/reply", json={"conversation_id": "conv_auto_1", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 2}).json()["action"] == "wait"
|
| 155 |
-
assert client.post("/v1/reply", json={"conversation_id": "conv_auto_2", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 3}).json()["action"] == "wait"
|
| 156 |
-
assert client.post("/v1/reply", json={"conversation_id": "conv_auto_3", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 4}).json()["action"] == "end"
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
def test_placeholder_triggers_do_not_leak_missing_fields():
|
| 160 |
-
reset_state()
|
| 161 |
-
client = TestClient(app)
|
| 162 |
-
merchant = load_seed("merchants_seed.json", "merchants")[5]
|
| 163 |
-
trigger = {
|
| 164 |
-
"id": "trg_placeholder_competitor",
|
| 165 |
-
"scope": "merchant",
|
| 166 |
-
"kind": "competitor_opened",
|
| 167 |
-
"source": "external",
|
| 168 |
-
"merchant_id": merchant["merchant_id"],
|
| 169 |
-
"customer_id": None,
|
| 170 |
-
"payload": {"placeholder": True, "metric_or_topic": "competitor_opened"},
|
| 171 |
-
"urgency": 2,
|
| 172 |
-
"suppression_key": "competitor:placeholder",
|
| 173 |
-
"expires_at": "2026-06-30T00:00:00Z",
|
| 174 |
-
}
|
| 175 |
-
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 176 |
-
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 177 |
-
push(client, "trigger", trigger["id"], trigger)
|
| 178 |
-
action = client.post("/v1/tick", json={"now": "2026-04-26T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 179 |
-
assert "None" not in action["body"]
|
| 180 |
-
assert "the available context" not in action["body"]
|
| 181 |
-
assert action["cta"] == "binary_yes_no"
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
def test_customer_without_matching_consent_routes_to_merchant_not_dead_line():
|
| 185 |
-
reset_state()
|
| 186 |
-
client = TestClient(app)
|
| 187 |
-
merchant = load_seed("merchants_seed.json", "merchants")[7]
|
| 188 |
-
customer = load_seed("customers_seed.json", "customers")[11]
|
| 189 |
-
trigger = {
|
| 190 |
-
"id": "trg_recall_no_scope",
|
| 191 |
-
"scope": "customer",
|
| 192 |
-
"kind": "recall_due",
|
| 193 |
-
"source": "internal",
|
| 194 |
-
"merchant_id": merchant["merchant_id"],
|
| 195 |
-
"customer_id": customer["customer_id"],
|
| 196 |
-
"payload": {"placeholder": True, "metric_or_topic": "recall_due"},
|
| 197 |
-
"urgency": 2,
|
| 198 |
-
"suppression_key": "recall:no_scope",
|
| 199 |
-
"expires_at": "2026-06-30T00:00:00Z",
|
| 200 |
-
}
|
| 201 |
-
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 202 |
-
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 203 |
-
push(client, "customer", customer["customer_id"], customer)
|
| 204 |
-
push(client, "trigger", trigger["id"], trigger)
|
| 205 |
-
action = client.post("/v1/tick", json={"now": "2026-04-26T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 206 |
-
assert action["send_as"] == "vera"
|
| 207 |
-
assert action["cta"] == "binary_yes_no"
|
| 208 |
-
assert "I will not send" not in action["body"]
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
def test_decision_engine_scores_case_anchor_shapes():
|
| 212 |
-
merchants = load_seed("merchants_seed.json", "merchants")
|
| 213 |
-
customers = load_seed("customers_seed.json", "customers")
|
| 214 |
-
triggers = load_seed("triggers_seed.json", "triggers")
|
| 215 |
-
cases = [
|
| 216 |
-
(merchants[0], triggers[0], None),
|
| 217 |
-
(merchants[0], triggers[2], customers[0]),
|
| 218 |
-
(merchants[4], triggers[9], None),
|
| 219 |
-
(merchants[6], triggers[13], None),
|
| 220 |
-
(merchants[8], triggers[17], None),
|
| 221 |
-
(merchants[8], triggers[18], customers[12]),
|
| 222 |
-
]
|
| 223 |
-
for merchant, trigger, customer in cases:
|
| 224 |
-
category = load_category(merchant["category_slug"])
|
| 225 |
-
evidence = extract_evidence(category, merchant, trigger, customer)
|
| 226 |
-
candidates = build_candidates(category, merchant, trigger, customer, evidence)
|
| 227 |
-
assert candidates
|
| 228 |
-
best = max(candidates, key=lambda c: c.total_score)
|
| 229 |
-
assert best.total_score >= 36, (trigger["id"], best.total_score, best.body, best.rubric_scores)
|
| 230 |
-
assert any(ch.isdigit() for ch in best.body)
|
| 231 |
-
assert trigger["kind"].replace("_", " ") in best.body.lower() or any(e.source.startswith("trigger") for e in best.evidence)
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
def test_full_expanded_dataset_proxy_has_no_weak_outputs():
|
| 235 |
-
import subprocess
|
| 236 |
-
import sys as _sys
|
| 237 |
-
subprocess.run([_sys.executable, "dataset/generate_dataset.py", "--seed-dir", "dataset", "--out", "expanded"], cwd=ROOT, check=True, capture_output=True)
|
| 238 |
-
result = subprocess.run([_sys.executable, "scripts/score_proxy.py", "34"], cwd=ROOT, text=True, capture_output=True)
|
| 239 |
-
assert result.returncode == 0, result.stdout + result.stderr
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
def test_car_map_jitai_and_best_of_n_debug_fields():
|
| 243 |
-
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 244 |
-
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 245 |
-
category = load_category(merchant["category_slug"])
|
| 246 |
-
car = build_merchant_car(category, merchant, trigger)
|
| 247 |
-
assert car.merchant_name != "unknown"
|
| 248 |
-
assert car.category == "dentists"
|
| 249 |
-
assert all(value is not None for value in car.summary().values())
|
| 250 |
-
|
| 251 |
-
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 252 |
-
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 253 |
-
assert len(candidates) >= 3
|
| 254 |
-
best = max(candidates, key=lambda c: c.total_score)
|
| 255 |
-
assert best.decision_plan if hasattr(best, "decision_plan") else True
|
| 256 |
-
assert best.car_summary["category"] == "dentists"
|
| 257 |
-
assert {"severity", "receptivity", "intervention_fit"} <= set(best.jitai_scores)
|
| 258 |
-
assert {"motivation", "ability", "prompt"} <= set(best.map_scores)
|
| 259 |
-
assert best.frame in {"loss_frame", "gain_frame", "certainty_frame", "social_proof", "professional_value", "effort_externalization"}
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
def test_bmap_penalizes_high_friction_cta():
|
| 263 |
-
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 264 |
-
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 265 |
-
category = load_category(merchant["category_slug"])
|
| 266 |
-
car = build_merchant_car(category, merchant, trigger)
|
| 267 |
-
easy = score_map(car, trigger, "JIDA says 124 patients are relevant. Reply YES and I will draft it.", "binary_yes_no", "professional_value", [])
|
| 268 |
-
hard = score_map(car, trigger, "JIDA says 124 patients are relevant. Please call, log in, choose a campaign, upload a file, and configure delivery.", "open_ended", "professional_value", [])
|
| 269 |
-
assert easy["ability"] > hard["ability"]
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
def test_frames_follow_trigger_shape_and_action_memory_changes_plan():
|
| 273 |
-
merchant = load_seed("merchants_seed.json", "merchants")[1]
|
| 274 |
-
category = load_category(merchant["category_slug"])
|
| 275 |
-
dip = load_seed("triggers_seed.json", "triggers")[3]
|
| 276 |
-
spike = {**dip, "id": "trg_spike_test", "kind": "perf_spike", "payload": {"metric": "calls", "delta_pct": 0.18}, "urgency": 2}
|
| 277 |
-
dip_candidates = build_candidates(category, merchant, dip, None, extract_evidence(category, merchant, dip))
|
| 278 |
-
spike_candidates = build_candidates(category, merchant, spike, None, extract_evidence(category, merchant, spike))
|
| 279 |
-
assert any(c.frame == "loss_frame" for c in dip_candidates)
|
| 280 |
-
assert any(c.frame == "gain_frame" for c in spike_candidates)
|
| 281 |
-
|
| 282 |
-
remembered = {**merchant, "__vera_memory": {"last_action_type": "recovery_nudge", "last_response_intent": "auto_reply", "repeated_action_count": 3, "no_reply_count": 2}}
|
| 283 |
-
car = build_merchant_car(category, remembered, dip)
|
| 284 |
-
evidence = extract_evidence(category, remembered, dip, None, car)
|
| 285 |
-
candidates = build_candidates(category, remembered, dip, None, evidence, car)
|
| 286 |
-
assert all(c.jitai_scores["receptivity"] <= 4 for c in candidates)
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
def test_openrouter_calibration_skips_without_key(monkeypatch):
|
| 290 |
-
import subprocess
|
| 291 |
-
import sys as _sys
|
| 292 |
-
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 293 |
-
result = subprocess.run([_sys.executable, "scripts/geval_calibrate.py"], cwd=ROOT, text=True, capture_output=True)
|
| 294 |
-
assert result.returncode == 0
|
| 295 |
-
assert "skipped" in result.stdout.lower()
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
def test_cialdini_constitution_and_tot_debug_fields():
|
| 299 |
-
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 300 |
-
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 301 |
-
category = load_category(merchant["category_slug"])
|
| 302 |
-
car = build_merchant_car(category, merchant, trigger)
|
| 303 |
-
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 304 |
-
principle = select_cialdini_principle(car, trigger, evidence, "professional_value")
|
| 305 |
-
assert principle in {"authority", "social_proof", "liking", "reciprocity", "scarcity", "commitment"}
|
| 306 |
-
bad = "Dear valued partner, want to increase sales? Contact us?"
|
| 307 |
-
violations = constitutional_violations(bad, car, trigger, "open_ended")
|
| 308 |
-
assert "generic_or_corporate_copy" in violations
|
| 309 |
-
|
| 310 |
-
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 311 |
-
best = max(candidates, key=lambda c: c.total_score)
|
| 312 |
-
assert best.thought_frames
|
| 313 |
-
assert best.persuasion_principle
|
| 314 |
-
assert best.reference_key.startswith("dentists:")
|
| 315 |
-
assert not best.constitutional_violations
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
def test_category_empirical_prior_flows_into_car():
|
| 319 |
-
reset_state()
|
| 320 |
-
client = TestClient(app)
|
| 321 |
-
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 322 |
-
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 323 |
-
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 324 |
-
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 325 |
-
push(client, "trigger", trigger["id"], trigger)
|
| 326 |
-
first = client.post("/v1/tick", json={"now": "2026-04-26T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 327 |
-
client.post("/v1/reply", json={"conversation_id": first["conversation_id"], "merchant_id": merchant["merchant_id"], "from_role": "merchant", "message": "yes go ahead", "turn_number": 2})
|
| 328 |
-
assert category_arm_pool[merchant["category_slug"]]
|
| 329 |
-
next_trigger = {**trigger, "id": "trg_research_next", "suppression_key": "research:next"}
|
| 330 |
-
push(client, "trigger", next_trigger["id"], next_trigger, version=1)
|
| 331 |
-
second = client.post("/v1/tick", json={"now": "2026-04-26T10:05:00Z", "available_triggers": [next_trigger["id"]]}).json()["actions"][0]
|
| 332 |
-
priors = second["decision_plan"]["car_summary"]["category_arm_priors"]
|
| 333 |
-
assert priors
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
def test_sparse_context_fallback_stays_specific_and_safe():
|
| 337 |
-
category = load_category("restaurants")
|
| 338 |
-
merchant = {
|
| 339 |
-
"merchant_id": "m_sparse_restaurant",
|
| 340 |
-
"category_slug": "restaurants",
|
| 341 |
-
"identity": {"name": "Asha Cafe", "owner_first_name": "Asha", "locality": "Indiranagar"},
|
| 342 |
-
"performance": {},
|
| 343 |
-
"offers": [],
|
| 344 |
-
"customer_aggregate": {},
|
| 345 |
-
"signals": [],
|
| 346 |
-
"conversation_history": [],
|
| 347 |
-
}
|
| 348 |
-
trigger = {
|
| 349 |
-
"id": "trg_sparse_reactivation",
|
| 350 |
-
"scope": "merchant",
|
| 351 |
-
"kind": "merchant_inactive",
|
| 352 |
-
"source": "internal",
|
| 353 |
-
"merchant_id": merchant["merchant_id"],
|
| 354 |
-
"payload": {"days_inactive": 14},
|
| 355 |
-
"urgency": 1,
|
| 356 |
-
"expires_at": "2026-06-30T00:00:00Z",
|
| 357 |
-
}
|
| 358 |
-
car = build_merchant_car(category, merchant, trigger)
|
| 359 |
-
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 360 |
-
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 361 |
-
assert len(candidates) == 1
|
| 362 |
-
body = candidates[0].body
|
| 363 |
-
assert "Asha" in body
|
| 364 |
-
assert "Indiranagar" in body
|
| 365 |
-
assert "increase sales" not in body.lower()
|
| 366 |
-
assert "sparse_context_floor" in candidates[0].risk_flags
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
def test_broad_suppression_keys_are_made_unique():
|
| 370 |
-
reset_state()
|
| 371 |
-
client = TestClient(app)
|
| 372 |
-
merchant = load_seed("merchants_seed.json", "merchants")[4]
|
| 373 |
-
category = load_category(merchant["category_slug"])
|
| 374 |
-
push(client, "category", merchant["category_slug"], category)
|
| 375 |
-
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 376 |
-
t1 = {"id": "trg_broad_1", "scope": "merchant", "kind": "curious_ask_due", "source": "internal", "merchant_id": merchant["merchant_id"], "payload": {"topic": "lunch"}, "urgency": 2, "suppression_key": "curious_ask_due"}
|
| 377 |
-
t2 = {"id": "trg_broad_2", "scope": "merchant", "kind": "curious_ask_due", "source": "internal", "merchant_id": merchant["merchant_id"], "payload": {"topic": "dinner"}, "urgency": 2, "suppression_key": "curious_ask_due"}
|
| 378 |
-
push(client, "trigger", t1["id"], t1)
|
| 379 |
-
push(client, "trigger", t2["id"], t2)
|
| 380 |
-
actions = client.post("/v1/tick", json={"now": "2026-05-02T10:00:00Z", "available_triggers": [t1["id"], t2["id"]]}).json()["actions"]
|
| 381 |
-
assert len(actions) == 2
|
| 382 |
-
assert actions[0]["suppression_key"] != actions[1]["suppression_key"]
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
def test_context_updates_do_not_wipe_reply_memory():
|
| 386 |
-
reset_state()
|
| 387 |
-
client = TestClient(app)
|
| 388 |
-
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 389 |
-
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 390 |
-
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 391 |
-
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 392 |
-
push(client, "trigger", trigger["id"], trigger)
|
| 393 |
-
first = client.post("/v1/tick", json={"now": "2026-04-26T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 394 |
-
client.post("/v1/reply", json={"conversation_id": first["conversation_id"], "merchant_id": merchant["merchant_id"], "from_role": "merchant", "message": "yes go ahead"})
|
| 395 |
-
push(client, "merchant", merchant["merchant_id"], {**merchant, "signals": ["fresh update"]}, version=2)
|
| 396 |
-
assert merchant_action_memory[merchant["merchant_id"]]["last_response_intent"] == "commitment"
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
def test_pharmacy_without_consent_routes_to_merchant_and_avoids_medical_dispatch():
|
| 400 |
-
merchant = load_seed("merchants_seed.json", "merchants")[8]
|
| 401 |
-
customer = {**load_seed("customers_seed.json", "customers")[12], "consent": {"scope": []}}
|
| 402 |
-
trigger = load_seed("triggers_seed.json", "triggers")[18]
|
| 403 |
-
category = load_category(merchant["category_slug"])
|
| 404 |
-
car = build_merchant_car(category, merchant, trigger, customer)
|
| 405 |
-
evidence = extract_evidence(category, merchant, trigger, customer, car)
|
| 406 |
-
candidates = build_candidates(category, merchant, trigger, customer, evidence, car)
|
| 407 |
-
best = max(candidates, key=lambda c: c.total_score)
|
| 408 |
-
assert best.send_as == "vera"
|
| 409 |
-
assert "consent-safe" in best.body
|
| 410 |
-
assert "dispatch" not in best.body.lower()
|
| 411 |
-
assert "pharmacy_consent_or_medical_advice_risk" not in best.constitutional_violations
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
def test_unseen_ops_trigger_uses_payload_without_known_template():
|
| 415 |
-
reset_state()
|
| 416 |
-
client = TestClient(app)
|
| 417 |
-
merchant = load_seed("merchants_seed.json", "merchants")[4]
|
| 418 |
-
trigger = {
|
| 419 |
-
"id": "trg_unseen_delivery_delay",
|
| 420 |
-
"scope": "merchant",
|
| 421 |
-
"kind": "delivery_delay_spike",
|
| 422 |
-
"source": "review_digest",
|
| 423 |
-
"merchant_id": merchant["merchant_id"],
|
| 424 |
-
"payload": {
|
| 425 |
-
"theme": "delivery_late",
|
| 426 |
-
"occurrences_30d": 6,
|
| 427 |
-
"avg_delay_minutes": 42,
|
| 428 |
-
"metric": "late delivery complaints",
|
| 429 |
-
},
|
| 430 |
-
"urgency": 3,
|
| 431 |
-
"suppression_key": "delivery-delay:unseen",
|
| 432 |
-
"expires_at": "2026-06-30T00:00:00Z",
|
| 433 |
-
}
|
| 434 |
-
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 435 |
-
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 436 |
-
push(client, "trigger", trigger["id"], trigger)
|
| 437 |
-
action = client.post("/v1/tick", json={"now": "2026-05-02T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 438 |
-
body = action["body"].lower()
|
| 439 |
-
assert "42" in body
|
| 440 |
-
assert "delivery late" in body or "late delivery" in body
|
| 441 |
-
assert "prep/rider handoff" in body
|
| 442 |
-
assert "increase sales" not in body
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
def test_missing_digest_id_retrieves_nearest_category_fact_for_research():
|
| 446 |
-
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 447 |
-
category = load_category("dentists")
|
| 448 |
-
trigger = {
|
| 449 |
-
"id": "trg_unseen_aligner_research",
|
| 450 |
-
"scope": "merchant",
|
| 451 |
-
"kind": "research_digest",
|
| 452 |
-
"source": "external_digest",
|
| 453 |
-
"merchant_id": merchant["merchant_id"],
|
| 454 |
-
"payload": {
|
| 455 |
-
"top_item_id": "d_not_in_seed",
|
| 456 |
-
"topic": "clear aligner consultations",
|
| 457 |
-
"metric_or_topic": "clear aligners near me",
|
| 458 |
-
},
|
| 459 |
-
"urgency": 2,
|
| 460 |
-
"suppression_key": "research:unseen-aligner",
|
| 461 |
-
}
|
| 462 |
-
car = build_merchant_car(category, merchant, trigger)
|
| 463 |
-
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 464 |
-
assert fact_value_for_test(evidence, "retrieved_digest_title")
|
| 465 |
-
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 466 |
-
body = max(candidates, key=lambda c: c.total_score).body.lower()
|
| 467 |
-
assert "aligner" in body
|
| 468 |
-
assert "category match" in body or "practo" in body
|
| 469 |
-
assert "d_not_in_seed" not in body
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
def test_cbr_similarity_adapts_familiar_trigger_with_shifted_merchant_state():
|
| 473 |
-
merchant = {**load_seed("merchants_seed.json", "merchants")[4], "offers": []}
|
| 474 |
-
category = load_category(merchant["category_slug"])
|
| 475 |
-
trigger = {
|
| 476 |
-
"id": "trg_review_theme_no_offer",
|
| 477 |
-
"scope": "merchant",
|
| 478 |
-
"kind": "review_theme_emerged",
|
| 479 |
-
"source": "review_digest",
|
| 480 |
-
"merchant_id": merchant["merchant_id"],
|
| 481 |
-
"payload": {
|
| 482 |
-
"theme": "delivery_late",
|
| 483 |
-
"occurrences_30d": 5,
|
| 484 |
-
"avg_delay_minutes": 39,
|
| 485 |
-
"common_quote": "late again",
|
| 486 |
-
},
|
| 487 |
-
"urgency": 3,
|
| 488 |
-
"suppression_key": "review:no-offer",
|
| 489 |
-
}
|
| 490 |
-
car = build_merchant_car(category, merchant, trigger)
|
| 491 |
-
match = retrieve_similar_case(car, trigger)
|
| 492 |
-
assert match and match[0] >= 0.60
|
| 493 |
-
assert case_similarity(car, trigger, match[1]) == match[0]
|
| 494 |
-
|
| 495 |
-
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 496 |
-
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 497 |
-
body = max(candidates, key=lambda c: c.total_score).body.lower()
|
| 498 |
-
assert "kitchen-dispatch problem" in body
|
| 499 |
-
assert "39" in body
|
| 500 |
-
assert "increase sales" not in body
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
def test_property_based_compulsion_fires_without_exact_trigger_name():
|
| 504 |
-
merchant = load_seed("merchants_seed.json", "merchants")[6]
|
| 505 |
-
category = load_category(merchant["category_slug"])
|
| 506 |
-
trigger = {
|
| 507 |
-
"id": "trg_restart_window_custom",
|
| 508 |
-
"scope": "merchant",
|
| 509 |
-
"kind": "restart_window_custom",
|
| 510 |
-
"source": "ops_calendar",
|
| 511 |
-
"merchant_id": merchant["merchant_id"],
|
| 512 |
-
"payload": {
|
| 513 |
-
"available_slots": [{"label": "Mon 7 PM"}, {"label": "Wed 7 PM"}],
|
| 514 |
-
"active_members_at_risk": 28,
|
| 515 |
-
"metric": "member restarts",
|
| 516 |
-
},
|
| 517 |
-
"urgency": 3,
|
| 518 |
-
"suppression_key": "restart-window:custom",
|
| 519 |
-
}
|
| 520 |
-
car = build_merchant_car(category, merchant, trigger)
|
| 521 |
-
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 522 |
-
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 523 |
-
best = max(candidates, key=lambda c: c.total_score)
|
| 524 |
-
assert best.persuasion_principle == "scarcity"
|
| 525 |
-
assert best.map_scores["prompt"] >= 8
|
| 526 |
-
assert "mon 7 pm" in best.body.lower() or "wed 7 pm" in best.body.lower()
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
def test_final_engagement_pass_adds_command_cta_line_break_and_peer_norm():
|
| 530 |
-
reset_state()
|
| 531 |
-
client = TestClient(app)
|
| 532 |
-
merchant = load_seed("merchants_seed.json", "merchants")[4]
|
| 533 |
-
trigger = {
|
| 534 |
-
"id": "trg_unseen_delivery_delay_engagement",
|
| 535 |
-
"scope": "merchant",
|
| 536 |
-
"kind": "delivery_delay_spike",
|
| 537 |
-
"source": "review_digest",
|
| 538 |
-
"merchant_id": merchant["merchant_id"],
|
| 539 |
-
"payload": {
|
| 540 |
-
"theme": "delivery_late",
|
| 541 |
-
"occurrences_30d": 6,
|
| 542 |
-
"avg_delay_minutes": 42,
|
| 543 |
-
"metric": "late delivery complaints",
|
| 544 |
-
},
|
| 545 |
-
"urgency": 3,
|
| 546 |
-
"suppression_key": "delivery-delay:engagement",
|
| 547 |
-
}
|
| 548 |
-
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 549 |
-
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 550 |
-
push(client, "trigger", trigger["id"], trigger)
|
| 551 |
-
action = client.post("/v1/tick", json={"now": "2026-05-02T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 552 |
-
body = action["body"]
|
| 553 |
-
assert "\n\n" in body
|
| 554 |
-
assert "Want me" not in body
|
| 555 |
-
assert "Should I" not in body
|
| 556 |
-
assert "Reply YES" in body
|
| 557 |
-
assert "Restaurants in" in body
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
def test_rationale_reads_like_decision_justification():
|
| 561 |
-
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 562 |
-
category = load_category(merchant["category_slug"])
|
| 563 |
-
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 564 |
-
car = build_merchant_car(category, merchant, trigger)
|
| 565 |
-
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 566 |
-
best = max(build_candidates(category, merchant, trigger, None, evidence, car), key=lambda c: c.total_score)
|
| 567 |
-
assert best.rationale.startswith("Trigger:")
|
| 568 |
-
assert "Frame:" in best.rationale
|
| 569 |
-
assert "Receptivity:" in best.rationale
|
| 570 |
-
assert "Suppression:" in best.rationale
|
| 571 |
-
assert "Selected signal" not in best.rationale
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
def fact_value_for_test(evidence, label: str) -> str:
|
| 575 |
-
return next((item.value for item in evidence if item.label == label), "")
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
def test_teardown_clears_state():
|
| 579 |
-
reset_state()
|
| 580 |
-
client = TestClient(app)
|
| 581 |
-
push(client, "category", "dentists", load_category("dentists"))
|
| 582 |
-
assert contexts
|
| 583 |
-
resp = client.post("/v1/teardown")
|
| 584 |
-
assert resp.json()["cleared"] is True
|
| 585 |
-
assert not contexts
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
sys.path.insert(0, str(ROOT))
|
| 11 |
+
|
| 12 |
+
from app.decision_engine import build_candidates, build_merchant_car, case_similarity, constitutional_violations, extract_evidence, retrieve_similar_case, score_map, select_cialdini_principle # noqa: E402
|
| 13 |
+
from app.main import app, category_arm_pool, contexts, conversations, merchant_action_memory, merchant_auto_replies, merchant_opt_out, suppressed # noqa: E402
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def load_seed(name: str, key: str) -> list[dict]:
|
| 17 |
+
return json.loads((ROOT / "dataset" / name).read_text(encoding="utf-8"))[key]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_category(slug: str) -> dict:
|
| 21 |
+
return json.loads((ROOT / "dataset" / "categories" / f"{slug}.json").read_text(encoding="utf-8"))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def reset_state() -> None:
|
| 25 |
+
contexts.clear()
|
| 26 |
+
conversations.clear()
|
| 27 |
+
suppressed.clear()
|
| 28 |
+
merchant_opt_out.clear()
|
| 29 |
+
merchant_auto_replies.clear()
|
| 30 |
+
merchant_action_memory.clear()
|
| 31 |
+
category_arm_pool.clear()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def push(client: TestClient, scope: str, context_id: str, payload: dict, version: int = 1):
|
| 35 |
+
return client.post(
|
| 36 |
+
"/v1/context",
|
| 37 |
+
json={"scope": scope, "context_id": context_id, "version": version, "payload": payload, "delivered_at": "2026-04-26T10:00:00Z"},
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_health_and_context_idempotency():
|
| 42 |
+
reset_state()
|
| 43 |
+
client = TestClient(app)
|
| 44 |
+
assert client.get("/v1/healthz").json()["status"] == "ok"
|
| 45 |
+
cat = load_category("dentists")
|
| 46 |
+
resp = push(client, "category", "dentists", cat)
|
| 47 |
+
assert resp.status_code == 200
|
| 48 |
+
assert resp.json()["accepted"] is True
|
| 49 |
+
same = push(client, "category", "dentists", cat)
|
| 50 |
+
assert same.status_code == 200
|
| 51 |
+
assert same.json()["idempotent"] is True
|
| 52 |
+
stale = push(client, "category", "dentists", cat, version=0)
|
| 53 |
+
assert stale.status_code == 409
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_tick_creates_grounded_research_action():
|
| 57 |
+
reset_state()
|
| 58 |
+
client = TestClient(app)
|
| 59 |
+
merchants = load_seed("merchants_seed.json", "merchants")
|
| 60 |
+
triggers = load_seed("triggers_seed.json", "triggers")
|
| 61 |
+
merchant = merchants[0]
|
| 62 |
+
trigger = triggers[0]
|
| 63 |
+
push(client, "category", "dentists", load_category("dentists"))
|
| 64 |
+
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 65 |
+
push(client, "trigger", trigger["id"], trigger)
|
| 66 |
+
|
| 67 |
+
resp = client.post("/v1/tick", json={"now": "2026-04-26T10:30:00Z", "available_triggers": [trigger["id"]]})
|
| 68 |
+
assert resp.status_code == 200
|
| 69 |
+
actions = resp.json()["actions"]
|
| 70 |
+
assert len(actions) == 1
|
| 71 |
+
body = actions[0]["body"]
|
| 72 |
+
assert "JIDA" in body
|
| 73 |
+
assert "124" in body
|
| 74 |
+
assert actions[0]["send_as"] == "vera"
|
| 75 |
+
assert actions[0]["suppression_key"] == trigger["suppression_key"]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_customer_consent_and_send_as():
|
| 79 |
+
reset_state()
|
| 80 |
+
client = TestClient(app)
|
| 81 |
+
merchants = load_seed("merchants_seed.json", "merchants")
|
| 82 |
+
customers = load_seed("customers_seed.json", "customers")
|
| 83 |
+
triggers = load_seed("triggers_seed.json", "triggers")
|
| 84 |
+
merchant = merchants[0]
|
| 85 |
+
customer = customers[0]
|
| 86 |
+
trigger = triggers[2]
|
| 87 |
+
push(client, "category", "dentists", load_category("dentists"))
|
| 88 |
+
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 89 |
+
push(client, "customer", customer["customer_id"], customer)
|
| 90 |
+
push(client, "trigger", trigger["id"], trigger)
|
| 91 |
+
|
| 92 |
+
resp = client.post("/v1/tick", json={"now": "2026-04-26T11:00:00Z", "available_triggers": [trigger["id"]]})
|
| 93 |
+
action = resp.json()["actions"][0]
|
| 94 |
+
assert action["send_as"] == "merchant_on_behalf"
|
| 95 |
+
assert action["customer_id"] == customer["customer_id"]
|
| 96 |
+
assert "Priya" in action["body"]
|
| 97 |
+
assert "Wed 5 Nov" in action["body"]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_reply_replay_behaviors():
|
| 101 |
+
reset_state()
|
| 102 |
+
client = TestClient(app)
|
| 103 |
+
auto = "Thank you for contacting us! Our team will respond shortly."
|
| 104 |
+
first = client.post("/v1/reply", json={"conversation_id": "conv_auto", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 2}).json()
|
| 105 |
+
assert first["action"] == "wait"
|
| 106 |
+
second = client.post("/v1/reply", json={"conversation_id": "conv_auto", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 3}).json()
|
| 107 |
+
assert second["action"] == "wait"
|
| 108 |
+
third = client.post("/v1/reply", json={"conversation_id": "conv_auto", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 4}).json()
|
| 109 |
+
assert third["action"] == "end"
|
| 110 |
+
|
| 111 |
+
intent = client.post("/v1/reply", json={"conversation_id": "conv_intent", "merchant_id": "m1", "from_role": "merchant", "message": "Ok lets do it. Whats next?", "turn_number": 2}).json()
|
| 112 |
+
assert intent["action"] == "send"
|
| 113 |
+
assert "preparing" in intent["body"]
|
| 114 |
+
assert intent["cta"] == "none"
|
| 115 |
+
|
| 116 |
+
hostile = client.post("/v1/reply", json={"conversation_id": "conv_hostile", "merchant_id": "m1", "from_role": "merchant", "message": "Stop messaging me. This is useless spam.", "turn_number": 2}).json()
|
| 117 |
+
assert hostile["action"] == "end"
|
| 118 |
+
|
| 119 |
+
plain_stop = client.post("/v1/reply", json={"conversation_id": "conv_plain_stop", "merchant_id": "m1", "from_role": "merchant", "message": "STOP", "turn_number": 2}).json()
|
| 120 |
+
assert plain_stop["action"] == "end"
|
| 121 |
+
|
| 122 |
+
offtopic = client.post("/v1/reply", json={"conversation_id": "conv_offtopic", "merchant_id": "m1", "from_role": "merchant", "message": "What is the cricket score?", "turn_number": 2}).json()
|
| 123 |
+
assert offtopic["action"] == "send"
|
| 124 |
+
assert "outside" in offtopic["body"].lower()
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_ended_conversation_never_sends_again_and_omitted_merchant_optout():
|
| 128 |
+
reset_state()
|
| 129 |
+
client = TestClient(app)
|
| 130 |
+
conversations["conv_done"] = {"merchant_id": "m_done", "ended": True, "turns": []}
|
| 131 |
+
resp = client.post("/v1/reply", json={"conversation_id": "conv_done", "from_role": "merchant", "message": "hello", "turn_number": 5}).json()
|
| 132 |
+
assert resp["action"] == "end"
|
| 133 |
+
|
| 134 |
+
conversations["conv_stop"] = {"merchant_id": "m_stop", "ended": False, "turns": []}
|
| 135 |
+
stop = client.post("/v1/reply", json={"conversation_id": "conv_stop", "from_role": "merchant", "message": "Stop messaging me", "turn_number": 2}).json()
|
| 136 |
+
assert stop["action"] == "end"
|
| 137 |
+
assert "m_stop" in merchant_opt_out
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_validation_errors_are_challenge_style_400():
|
| 141 |
+
reset_state()
|
| 142 |
+
client = TestClient(app)
|
| 143 |
+
resp = client.post("/v1/context", json={"scope": "category"})
|
| 144 |
+
assert resp.status_code == 400
|
| 145 |
+
body = resp.json()
|
| 146 |
+
assert body["accepted"] is False
|
| 147 |
+
assert body["reason"] == "malformed"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_merchant_level_auto_reply_tracking_across_conversations():
|
| 151 |
+
reset_state()
|
| 152 |
+
client = TestClient(app)
|
| 153 |
+
auto = "Thank you for contacting us! Our team will respond shortly."
|
| 154 |
+
assert client.post("/v1/reply", json={"conversation_id": "conv_auto_1", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 2}).json()["action"] == "wait"
|
| 155 |
+
assert client.post("/v1/reply", json={"conversation_id": "conv_auto_2", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 3}).json()["action"] == "wait"
|
| 156 |
+
assert client.post("/v1/reply", json={"conversation_id": "conv_auto_3", "merchant_id": "m1", "from_role": "merchant", "message": auto, "turn_number": 4}).json()["action"] == "end"
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def test_placeholder_triggers_do_not_leak_missing_fields():
|
| 160 |
+
reset_state()
|
| 161 |
+
client = TestClient(app)
|
| 162 |
+
merchant = load_seed("merchants_seed.json", "merchants")[5]
|
| 163 |
+
trigger = {
|
| 164 |
+
"id": "trg_placeholder_competitor",
|
| 165 |
+
"scope": "merchant",
|
| 166 |
+
"kind": "competitor_opened",
|
| 167 |
+
"source": "external",
|
| 168 |
+
"merchant_id": merchant["merchant_id"],
|
| 169 |
+
"customer_id": None,
|
| 170 |
+
"payload": {"placeholder": True, "metric_or_topic": "competitor_opened"},
|
| 171 |
+
"urgency": 2,
|
| 172 |
+
"suppression_key": "competitor:placeholder",
|
| 173 |
+
"expires_at": "2026-06-30T00:00:00Z",
|
| 174 |
+
}
|
| 175 |
+
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 176 |
+
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 177 |
+
push(client, "trigger", trigger["id"], trigger)
|
| 178 |
+
action = client.post("/v1/tick", json={"now": "2026-04-26T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 179 |
+
assert "None" not in action["body"]
|
| 180 |
+
assert "the available context" not in action["body"]
|
| 181 |
+
assert action["cta"] == "binary_yes_no"
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_customer_without_matching_consent_routes_to_merchant_not_dead_line():
|
| 185 |
+
reset_state()
|
| 186 |
+
client = TestClient(app)
|
| 187 |
+
merchant = load_seed("merchants_seed.json", "merchants")[7]
|
| 188 |
+
customer = load_seed("customers_seed.json", "customers")[11]
|
| 189 |
+
trigger = {
|
| 190 |
+
"id": "trg_recall_no_scope",
|
| 191 |
+
"scope": "customer",
|
| 192 |
+
"kind": "recall_due",
|
| 193 |
+
"source": "internal",
|
| 194 |
+
"merchant_id": merchant["merchant_id"],
|
| 195 |
+
"customer_id": customer["customer_id"],
|
| 196 |
+
"payload": {"placeholder": True, "metric_or_topic": "recall_due"},
|
| 197 |
+
"urgency": 2,
|
| 198 |
+
"suppression_key": "recall:no_scope",
|
| 199 |
+
"expires_at": "2026-06-30T00:00:00Z",
|
| 200 |
+
}
|
| 201 |
+
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 202 |
+
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 203 |
+
push(client, "customer", customer["customer_id"], customer)
|
| 204 |
+
push(client, "trigger", trigger["id"], trigger)
|
| 205 |
+
action = client.post("/v1/tick", json={"now": "2026-04-26T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 206 |
+
assert action["send_as"] == "vera"
|
| 207 |
+
assert action["cta"] == "binary_yes_no"
|
| 208 |
+
assert "I will not send" not in action["body"]
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def test_decision_engine_scores_case_anchor_shapes():
|
| 212 |
+
merchants = load_seed("merchants_seed.json", "merchants")
|
| 213 |
+
customers = load_seed("customers_seed.json", "customers")
|
| 214 |
+
triggers = load_seed("triggers_seed.json", "triggers")
|
| 215 |
+
cases = [
|
| 216 |
+
(merchants[0], triggers[0], None),
|
| 217 |
+
(merchants[0], triggers[2], customers[0]),
|
| 218 |
+
(merchants[4], triggers[9], None),
|
| 219 |
+
(merchants[6], triggers[13], None),
|
| 220 |
+
(merchants[8], triggers[17], None),
|
| 221 |
+
(merchants[8], triggers[18], customers[12]),
|
| 222 |
+
]
|
| 223 |
+
for merchant, trigger, customer in cases:
|
| 224 |
+
category = load_category(merchant["category_slug"])
|
| 225 |
+
evidence = extract_evidence(category, merchant, trigger, customer)
|
| 226 |
+
candidates = build_candidates(category, merchant, trigger, customer, evidence)
|
| 227 |
+
assert candidates
|
| 228 |
+
best = max(candidates, key=lambda c: c.total_score)
|
| 229 |
+
assert best.total_score >= 36, (trigger["id"], best.total_score, best.body, best.rubric_scores)
|
| 230 |
+
assert any(ch.isdigit() for ch in best.body)
|
| 231 |
+
assert trigger["kind"].replace("_", " ") in best.body.lower() or any(e.source.startswith("trigger") for e in best.evidence)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_full_expanded_dataset_proxy_has_no_weak_outputs():
|
| 235 |
+
import subprocess
|
| 236 |
+
import sys as _sys
|
| 237 |
+
subprocess.run([_sys.executable, "dataset/generate_dataset.py", "--seed-dir", "dataset", "--out", "expanded"], cwd=ROOT, check=True, capture_output=True)
|
| 238 |
+
result = subprocess.run([_sys.executable, "scripts/score_proxy.py", "34"], cwd=ROOT, text=True, capture_output=True)
|
| 239 |
+
assert result.returncode == 0, result.stdout + result.stderr
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_car_map_jitai_and_best_of_n_debug_fields():
|
| 243 |
+
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 244 |
+
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 245 |
+
category = load_category(merchant["category_slug"])
|
| 246 |
+
car = build_merchant_car(category, merchant, trigger)
|
| 247 |
+
assert car.merchant_name != "unknown"
|
| 248 |
+
assert car.category == "dentists"
|
| 249 |
+
assert all(value is not None for value in car.summary().values())
|
| 250 |
+
|
| 251 |
+
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 252 |
+
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 253 |
+
assert len(candidates) >= 3
|
| 254 |
+
best = max(candidates, key=lambda c: c.total_score)
|
| 255 |
+
assert best.decision_plan if hasattr(best, "decision_plan") else True
|
| 256 |
+
assert best.car_summary["category"] == "dentists"
|
| 257 |
+
assert {"severity", "receptivity", "intervention_fit"} <= set(best.jitai_scores)
|
| 258 |
+
assert {"motivation", "ability", "prompt"} <= set(best.map_scores)
|
| 259 |
+
assert best.frame in {"loss_frame", "gain_frame", "certainty_frame", "social_proof", "professional_value", "effort_externalization"}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def test_bmap_penalizes_high_friction_cta():
|
| 263 |
+
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 264 |
+
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 265 |
+
category = load_category(merchant["category_slug"])
|
| 266 |
+
car = build_merchant_car(category, merchant, trigger)
|
| 267 |
+
easy = score_map(car, trigger, "JIDA says 124 patients are relevant. Reply YES and I will draft it.", "binary_yes_no", "professional_value", [])
|
| 268 |
+
hard = score_map(car, trigger, "JIDA says 124 patients are relevant. Please call, log in, choose a campaign, upload a file, and configure delivery.", "open_ended", "professional_value", [])
|
| 269 |
+
assert easy["ability"] > hard["ability"]
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def test_frames_follow_trigger_shape_and_action_memory_changes_plan():
|
| 273 |
+
merchant = load_seed("merchants_seed.json", "merchants")[1]
|
| 274 |
+
category = load_category(merchant["category_slug"])
|
| 275 |
+
dip = load_seed("triggers_seed.json", "triggers")[3]
|
| 276 |
+
spike = {**dip, "id": "trg_spike_test", "kind": "perf_spike", "payload": {"metric": "calls", "delta_pct": 0.18}, "urgency": 2}
|
| 277 |
+
dip_candidates = build_candidates(category, merchant, dip, None, extract_evidence(category, merchant, dip))
|
| 278 |
+
spike_candidates = build_candidates(category, merchant, spike, None, extract_evidence(category, merchant, spike))
|
| 279 |
+
assert any(c.frame == "loss_frame" for c in dip_candidates)
|
| 280 |
+
assert any(c.frame == "gain_frame" for c in spike_candidates)
|
| 281 |
+
|
| 282 |
+
remembered = {**merchant, "__vera_memory": {"last_action_type": "recovery_nudge", "last_response_intent": "auto_reply", "repeated_action_count": 3, "no_reply_count": 2}}
|
| 283 |
+
car = build_merchant_car(category, remembered, dip)
|
| 284 |
+
evidence = extract_evidence(category, remembered, dip, None, car)
|
| 285 |
+
candidates = build_candidates(category, remembered, dip, None, evidence, car)
|
| 286 |
+
assert all(c.jitai_scores["receptivity"] <= 4 for c in candidates)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def test_openrouter_calibration_skips_without_key(monkeypatch):
|
| 290 |
+
import subprocess
|
| 291 |
+
import sys as _sys
|
| 292 |
+
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 293 |
+
result = subprocess.run([_sys.executable, "scripts/geval_calibrate.py"], cwd=ROOT, text=True, capture_output=True)
|
| 294 |
+
assert result.returncode == 0
|
| 295 |
+
assert "skipped" in result.stdout.lower()
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def test_cialdini_constitution_and_tot_debug_fields():
|
| 299 |
+
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 300 |
+
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 301 |
+
category = load_category(merchant["category_slug"])
|
| 302 |
+
car = build_merchant_car(category, merchant, trigger)
|
| 303 |
+
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 304 |
+
principle = select_cialdini_principle(car, trigger, evidence, "professional_value")
|
| 305 |
+
assert principle in {"authority", "social_proof", "liking", "reciprocity", "scarcity", "commitment"}
|
| 306 |
+
bad = "Dear valued partner, want to increase sales? Contact us?"
|
| 307 |
+
violations = constitutional_violations(bad, car, trigger, "open_ended")
|
| 308 |
+
assert "generic_or_corporate_copy" in violations
|
| 309 |
+
|
| 310 |
+
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 311 |
+
best = max(candidates, key=lambda c: c.total_score)
|
| 312 |
+
assert best.thought_frames
|
| 313 |
+
assert best.persuasion_principle
|
| 314 |
+
assert best.reference_key.startswith("dentists:")
|
| 315 |
+
assert not best.constitutional_violations
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def test_category_empirical_prior_flows_into_car():
|
| 319 |
+
reset_state()
|
| 320 |
+
client = TestClient(app)
|
| 321 |
+
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 322 |
+
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 323 |
+
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 324 |
+
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 325 |
+
push(client, "trigger", trigger["id"], trigger)
|
| 326 |
+
first = client.post("/v1/tick", json={"now": "2026-04-26T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 327 |
+
client.post("/v1/reply", json={"conversation_id": first["conversation_id"], "merchant_id": merchant["merchant_id"], "from_role": "merchant", "message": "yes go ahead", "turn_number": 2})
|
| 328 |
+
assert category_arm_pool[merchant["category_slug"]]
|
| 329 |
+
next_trigger = {**trigger, "id": "trg_research_next", "suppression_key": "research:next"}
|
| 330 |
+
push(client, "trigger", next_trigger["id"], next_trigger, version=1)
|
| 331 |
+
second = client.post("/v1/tick", json={"now": "2026-04-26T10:05:00Z", "available_triggers": [next_trigger["id"]]}).json()["actions"][0]
|
| 332 |
+
priors = second["decision_plan"]["car_summary"]["category_arm_priors"]
|
| 333 |
+
assert priors
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def test_sparse_context_fallback_stays_specific_and_safe():
|
| 337 |
+
category = load_category("restaurants")
|
| 338 |
+
merchant = {
|
| 339 |
+
"merchant_id": "m_sparse_restaurant",
|
| 340 |
+
"category_slug": "restaurants",
|
| 341 |
+
"identity": {"name": "Asha Cafe", "owner_first_name": "Asha", "locality": "Indiranagar"},
|
| 342 |
+
"performance": {},
|
| 343 |
+
"offers": [],
|
| 344 |
+
"customer_aggregate": {},
|
| 345 |
+
"signals": [],
|
| 346 |
+
"conversation_history": [],
|
| 347 |
+
}
|
| 348 |
+
trigger = {
|
| 349 |
+
"id": "trg_sparse_reactivation",
|
| 350 |
+
"scope": "merchant",
|
| 351 |
+
"kind": "merchant_inactive",
|
| 352 |
+
"source": "internal",
|
| 353 |
+
"merchant_id": merchant["merchant_id"],
|
| 354 |
+
"payload": {"days_inactive": 14},
|
| 355 |
+
"urgency": 1,
|
| 356 |
+
"expires_at": "2026-06-30T00:00:00Z",
|
| 357 |
+
}
|
| 358 |
+
car = build_merchant_car(category, merchant, trigger)
|
| 359 |
+
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 360 |
+
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 361 |
+
assert len(candidates) == 1
|
| 362 |
+
body = candidates[0].body
|
| 363 |
+
assert "Asha" in body
|
| 364 |
+
assert "Indiranagar" in body
|
| 365 |
+
assert "increase sales" not in body.lower()
|
| 366 |
+
assert "sparse_context_floor" in candidates[0].risk_flags
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def test_broad_suppression_keys_are_made_unique():
|
| 370 |
+
reset_state()
|
| 371 |
+
client = TestClient(app)
|
| 372 |
+
merchant = load_seed("merchants_seed.json", "merchants")[4]
|
| 373 |
+
category = load_category(merchant["category_slug"])
|
| 374 |
+
push(client, "category", merchant["category_slug"], category)
|
| 375 |
+
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 376 |
+
t1 = {"id": "trg_broad_1", "scope": "merchant", "kind": "curious_ask_due", "source": "internal", "merchant_id": merchant["merchant_id"], "payload": {"topic": "lunch"}, "urgency": 2, "suppression_key": "curious_ask_due"}
|
| 377 |
+
t2 = {"id": "trg_broad_2", "scope": "merchant", "kind": "curious_ask_due", "source": "internal", "merchant_id": merchant["merchant_id"], "payload": {"topic": "dinner"}, "urgency": 2, "suppression_key": "curious_ask_due"}
|
| 378 |
+
push(client, "trigger", t1["id"], t1)
|
| 379 |
+
push(client, "trigger", t2["id"], t2)
|
| 380 |
+
actions = client.post("/v1/tick", json={"now": "2026-05-02T10:00:00Z", "available_triggers": [t1["id"], t2["id"]]}).json()["actions"]
|
| 381 |
+
assert len(actions) == 2
|
| 382 |
+
assert actions[0]["suppression_key"] != actions[1]["suppression_key"]
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def test_context_updates_do_not_wipe_reply_memory():
|
| 386 |
+
reset_state()
|
| 387 |
+
client = TestClient(app)
|
| 388 |
+
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 389 |
+
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 390 |
+
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 391 |
+
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 392 |
+
push(client, "trigger", trigger["id"], trigger)
|
| 393 |
+
first = client.post("/v1/tick", json={"now": "2026-04-26T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 394 |
+
client.post("/v1/reply", json={"conversation_id": first["conversation_id"], "merchant_id": merchant["merchant_id"], "from_role": "merchant", "message": "yes go ahead"})
|
| 395 |
+
push(client, "merchant", merchant["merchant_id"], {**merchant, "signals": ["fresh update"]}, version=2)
|
| 396 |
+
assert merchant_action_memory[merchant["merchant_id"]]["last_response_intent"] == "commitment"
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def test_pharmacy_without_consent_routes_to_merchant_and_avoids_medical_dispatch():
|
| 400 |
+
merchant = load_seed("merchants_seed.json", "merchants")[8]
|
| 401 |
+
customer = {**load_seed("customers_seed.json", "customers")[12], "consent": {"scope": []}}
|
| 402 |
+
trigger = load_seed("triggers_seed.json", "triggers")[18]
|
| 403 |
+
category = load_category(merchant["category_slug"])
|
| 404 |
+
car = build_merchant_car(category, merchant, trigger, customer)
|
| 405 |
+
evidence = extract_evidence(category, merchant, trigger, customer, car)
|
| 406 |
+
candidates = build_candidates(category, merchant, trigger, customer, evidence, car)
|
| 407 |
+
best = max(candidates, key=lambda c: c.total_score)
|
| 408 |
+
assert best.send_as == "vera"
|
| 409 |
+
assert "consent-safe" in best.body
|
| 410 |
+
assert "dispatch" not in best.body.lower()
|
| 411 |
+
assert "pharmacy_consent_or_medical_advice_risk" not in best.constitutional_violations
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def test_unseen_ops_trigger_uses_payload_without_known_template():
|
| 415 |
+
reset_state()
|
| 416 |
+
client = TestClient(app)
|
| 417 |
+
merchant = load_seed("merchants_seed.json", "merchants")[4]
|
| 418 |
+
trigger = {
|
| 419 |
+
"id": "trg_unseen_delivery_delay",
|
| 420 |
+
"scope": "merchant",
|
| 421 |
+
"kind": "delivery_delay_spike",
|
| 422 |
+
"source": "review_digest",
|
| 423 |
+
"merchant_id": merchant["merchant_id"],
|
| 424 |
+
"payload": {
|
| 425 |
+
"theme": "delivery_late",
|
| 426 |
+
"occurrences_30d": 6,
|
| 427 |
+
"avg_delay_minutes": 42,
|
| 428 |
+
"metric": "late delivery complaints",
|
| 429 |
+
},
|
| 430 |
+
"urgency": 3,
|
| 431 |
+
"suppression_key": "delivery-delay:unseen",
|
| 432 |
+
"expires_at": "2026-06-30T00:00:00Z",
|
| 433 |
+
}
|
| 434 |
+
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 435 |
+
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 436 |
+
push(client, "trigger", trigger["id"], trigger)
|
| 437 |
+
action = client.post("/v1/tick", json={"now": "2026-05-02T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 438 |
+
body = action["body"].lower()
|
| 439 |
+
assert "42" in body
|
| 440 |
+
assert "delivery late" in body or "late delivery" in body
|
| 441 |
+
assert "prep/rider handoff" in body
|
| 442 |
+
assert "increase sales" not in body
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def test_missing_digest_id_retrieves_nearest_category_fact_for_research():
|
| 446 |
+
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 447 |
+
category = load_category("dentists")
|
| 448 |
+
trigger = {
|
| 449 |
+
"id": "trg_unseen_aligner_research",
|
| 450 |
+
"scope": "merchant",
|
| 451 |
+
"kind": "research_digest",
|
| 452 |
+
"source": "external_digest",
|
| 453 |
+
"merchant_id": merchant["merchant_id"],
|
| 454 |
+
"payload": {
|
| 455 |
+
"top_item_id": "d_not_in_seed",
|
| 456 |
+
"topic": "clear aligner consultations",
|
| 457 |
+
"metric_or_topic": "clear aligners near me",
|
| 458 |
+
},
|
| 459 |
+
"urgency": 2,
|
| 460 |
+
"suppression_key": "research:unseen-aligner",
|
| 461 |
+
}
|
| 462 |
+
car = build_merchant_car(category, merchant, trigger)
|
| 463 |
+
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 464 |
+
assert fact_value_for_test(evidence, "retrieved_digest_title")
|
| 465 |
+
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 466 |
+
body = max(candidates, key=lambda c: c.total_score).body.lower()
|
| 467 |
+
assert "aligner" in body
|
| 468 |
+
assert "category match" in body or "practo" in body
|
| 469 |
+
assert "d_not_in_seed" not in body
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def test_cbr_similarity_adapts_familiar_trigger_with_shifted_merchant_state():
|
| 473 |
+
merchant = {**load_seed("merchants_seed.json", "merchants")[4], "offers": []}
|
| 474 |
+
category = load_category(merchant["category_slug"])
|
| 475 |
+
trigger = {
|
| 476 |
+
"id": "trg_review_theme_no_offer",
|
| 477 |
+
"scope": "merchant",
|
| 478 |
+
"kind": "review_theme_emerged",
|
| 479 |
+
"source": "review_digest",
|
| 480 |
+
"merchant_id": merchant["merchant_id"],
|
| 481 |
+
"payload": {
|
| 482 |
+
"theme": "delivery_late",
|
| 483 |
+
"occurrences_30d": 5,
|
| 484 |
+
"avg_delay_minutes": 39,
|
| 485 |
+
"common_quote": "late again",
|
| 486 |
+
},
|
| 487 |
+
"urgency": 3,
|
| 488 |
+
"suppression_key": "review:no-offer",
|
| 489 |
+
}
|
| 490 |
+
car = build_merchant_car(category, merchant, trigger)
|
| 491 |
+
match = retrieve_similar_case(car, trigger)
|
| 492 |
+
assert match and match[0] >= 0.60
|
| 493 |
+
assert case_similarity(car, trigger, match[1]) == match[0]
|
| 494 |
+
|
| 495 |
+
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 496 |
+
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 497 |
+
body = max(candidates, key=lambda c: c.total_score).body.lower()
|
| 498 |
+
assert "kitchen-dispatch problem" in body
|
| 499 |
+
assert "39" in body
|
| 500 |
+
assert "increase sales" not in body
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def test_property_based_compulsion_fires_without_exact_trigger_name():
|
| 504 |
+
merchant = load_seed("merchants_seed.json", "merchants")[6]
|
| 505 |
+
category = load_category(merchant["category_slug"])
|
| 506 |
+
trigger = {
|
| 507 |
+
"id": "trg_restart_window_custom",
|
| 508 |
+
"scope": "merchant",
|
| 509 |
+
"kind": "restart_window_custom",
|
| 510 |
+
"source": "ops_calendar",
|
| 511 |
+
"merchant_id": merchant["merchant_id"],
|
| 512 |
+
"payload": {
|
| 513 |
+
"available_slots": [{"label": "Mon 7 PM"}, {"label": "Wed 7 PM"}],
|
| 514 |
+
"active_members_at_risk": 28,
|
| 515 |
+
"metric": "member restarts",
|
| 516 |
+
},
|
| 517 |
+
"urgency": 3,
|
| 518 |
+
"suppression_key": "restart-window:custom",
|
| 519 |
+
}
|
| 520 |
+
car = build_merchant_car(category, merchant, trigger)
|
| 521 |
+
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 522 |
+
candidates = build_candidates(category, merchant, trigger, None, evidence, car)
|
| 523 |
+
best = max(candidates, key=lambda c: c.total_score)
|
| 524 |
+
assert best.persuasion_principle == "scarcity"
|
| 525 |
+
assert best.map_scores["prompt"] >= 8
|
| 526 |
+
assert "mon 7 pm" in best.body.lower() or "wed 7 pm" in best.body.lower()
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def test_final_engagement_pass_adds_command_cta_line_break_and_peer_norm():
|
| 530 |
+
reset_state()
|
| 531 |
+
client = TestClient(app)
|
| 532 |
+
merchant = load_seed("merchants_seed.json", "merchants")[4]
|
| 533 |
+
trigger = {
|
| 534 |
+
"id": "trg_unseen_delivery_delay_engagement",
|
| 535 |
+
"scope": "merchant",
|
| 536 |
+
"kind": "delivery_delay_spike",
|
| 537 |
+
"source": "review_digest",
|
| 538 |
+
"merchant_id": merchant["merchant_id"],
|
| 539 |
+
"payload": {
|
| 540 |
+
"theme": "delivery_late",
|
| 541 |
+
"occurrences_30d": 6,
|
| 542 |
+
"avg_delay_minutes": 42,
|
| 543 |
+
"metric": "late delivery complaints",
|
| 544 |
+
},
|
| 545 |
+
"urgency": 3,
|
| 546 |
+
"suppression_key": "delivery-delay:engagement",
|
| 547 |
+
}
|
| 548 |
+
push(client, "category", merchant["category_slug"], load_category(merchant["category_slug"]))
|
| 549 |
+
push(client, "merchant", merchant["merchant_id"], merchant)
|
| 550 |
+
push(client, "trigger", trigger["id"], trigger)
|
| 551 |
+
action = client.post("/v1/tick", json={"now": "2026-05-02T10:00:00Z", "available_triggers": [trigger["id"]]}).json()["actions"][0]
|
| 552 |
+
body = action["body"]
|
| 553 |
+
assert "\n\n" in body
|
| 554 |
+
assert "Want me" not in body
|
| 555 |
+
assert "Should I" not in body
|
| 556 |
+
assert "Reply YES" in body
|
| 557 |
+
assert "Restaurants in" in body
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def test_rationale_reads_like_decision_justification():
|
| 561 |
+
merchant = load_seed("merchants_seed.json", "merchants")[0]
|
| 562 |
+
category = load_category(merchant["category_slug"])
|
| 563 |
+
trigger = load_seed("triggers_seed.json", "triggers")[0]
|
| 564 |
+
car = build_merchant_car(category, merchant, trigger)
|
| 565 |
+
evidence = extract_evidence(category, merchant, trigger, None, car)
|
| 566 |
+
best = max(build_candidates(category, merchant, trigger, None, evidence, car), key=lambda c: c.total_score)
|
| 567 |
+
assert best.rationale.startswith("Trigger:")
|
| 568 |
+
assert "Frame:" in best.rationale
|
| 569 |
+
assert "Receptivity:" in best.rationale
|
| 570 |
+
assert "Suppression:" in best.rationale
|
| 571 |
+
assert "Selected signal" not in best.rationale
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def fact_value_for_test(evidence, label: str) -> str:
|
| 575 |
+
return next((item.value for item in evidence if item.label == label), "")
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def test_teardown_clears_state():
|
| 579 |
+
reset_state()
|
| 580 |
+
client = TestClient(app)
|
| 581 |
+
push(client, "category", "dentists", load_category("dentists"))
|
| 582 |
+
assert contexts
|
| 583 |
+
resp = client.post("/v1/teardown")
|
| 584 |
+
assert resp.json()["cleared"] is True
|
| 585 |
+
assert not contexts
|