mokshak commited on
Commit
eccbb03
·
verified ·
1 Parent(s): 0fda60b

Align endpoint contract with challenge brief

Browse files
Files changed (6) hide show
  1. README.md +12 -7
  2. app/composer.py +668 -668
  3. app/decision_engine.py +0 -0
  4. app/main.py +6 -0
  5. submission.jsonl +0 -0
  6. 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. If `OPENAI_API_KEY` is present, it can polish copy after deterministic planning, but the deterministic engine remains the source of truth and invalid LLM output is discarded. OpenRouter can be used only for offline G-Eval-style calibration.
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` clears in-memory state for clean reruns.
21
 
22
- Required endpoints:
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 34
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
- Set these only if you want controlled copy improvement:
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