mokshak commited on
Commit
aec2fdf
·
verified ·
1 Parent(s): 1c54138

Deploy Vera deterministic bot

Browse files
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .venv/
4
+ venv/
5
+ expanded/
6
+ *.pyc
7
+ *.log
8
+ .pytest_cache/
9
+ __pycache__/
10
+ .git/
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ .pytest_cache/
3
+ *.pyc
4
+ *.log
5
+ .venv/
6
+ venv/
7
+ expanded/
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV PORT=7860
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ EXPOSE 7860
15
+
16
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
Procfile ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8080}
2
+
README.md CHANGED
@@ -1,10 +1,136 @@
1
  ---
2
  title: Vera Rubric Decision Engine
3
- emoji: 🌖
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: docker
 
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Vera Rubric Decision Engine
 
 
 
3
  sdk: docker
4
+ app_port: 7860
5
+ license: mit
6
  pinned: false
7
  ---
8
 
9
+ # Vera Rubric Decision Engine
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:
34
+
35
+ - Extracts evidence from merchant, category, trigger, and customer context.
36
+ - Flattens nested context into a MerchantCAR so every downstream decision sees stable field names.
37
+ - Uses JITAI-style severity/receptivity/intervention-fit scores to decide whether the moment is worth acting on.
38
+ - Uses Fogg B=MAP scores to separate motivation, CTA ability, and prompt timing.
39
+ - Applies prospect-theory plus Cialdini framing: loss recovery, gain momentum, scarcity, social proof, authority, reciprocity, commitment, or liking.
40
+ - Builds deterministic Tree-of-Thought frame diagnostics and Best-of-N variants for each trigger.
41
+ - Runs a deterministic constitutional audit/repair pass for generic copy, multiple CTAs, corporate tone, weak facts, and repeated weak action types.
42
+ - Uses empirical category action priors from in-memory reply outcomes to reduce cold-start action mistakes without random live exploration.
43
+ - Scores each candidate across decision quality, specificity, category fit, merchant fit, and engagement compulsion.
44
+ - Sends only the highest-scoring valid action.
45
+ - Uses category playbooks for dentists, salons, restaurants, gyms, and pharmacies.
46
+ - Validates output for hallucinated numbers, `None` leaks, repeated `Dr. Dr.`, weak generic copy, repeated bodies, missing CTA shape, and unsafe customer outreach.
47
+
48
+ Every sent message should include why now, a real merchant/category/customer fact, one CTA, category-appropriate voice, and a low-friction next action.
49
+
50
+ ## Run Locally
51
+
52
+ ```bash
53
+ python -m pip install -r requirements-dev.txt
54
+ python dataset/generate_dataset.py --seed-dir dataset --out expanded
55
+ uvicorn app.main:app --host 0.0.0.0 --port 8080
56
+ ```
57
+
58
+ In another terminal:
59
+
60
+ ```bash
61
+ pytest -q
62
+ python -m compileall app bot.py scripts tests
63
+ python scripts/generate_submission.py
64
+ python scripts/lint_submission.py
65
+ python scripts/score_proxy.py 34
66
+ python scripts/geval_calibrate.py
67
+ ```
68
+
69
+ `scripts/geval_calibrate.py` skips cleanly unless `OPENROUTER_API_KEY` is set. The official `judge_simulator.py` also needs a scorer LLM key. The hosted bot itself does not need one.
70
+
71
+ ## Optional LLM Copy Polish
72
+
73
+ Set these only if you want controlled copy improvement:
74
+
75
+ - `OPENAI_API_KEY`
76
+ - `OPENAI_MODEL`, default `gpt-4o-mini`
77
+
78
+ 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.
79
+
80
+ ## Optional OpenRouter Calibration
81
+
82
+ Set these only for offline quality checks:
83
+
84
+ - `OPENROUTER_API_KEY`
85
+ - `OPENROUTER_MODEL`, default `openrouter/auto`
86
+ - `GEVAL_LIMIT`, default `10`
87
+
88
+ This script critiques generated `submission.jsonl` rows against the five official dimensions with a Prometheus-style reference bank and reports low-scoring cases. It is intentionally not called by `/v1/tick`.
89
+
90
+ ## Free Deployment
91
+
92
+ Primary target: Koyeb Free Instance.
93
+
94
+ Start command:
95
+
96
+ ```bash
97
+ uvicorn app.main:app --host 0.0.0.0 --port $PORT
98
+ ```
99
+
100
+ Health check path:
101
+
102
+ ```text
103
+ /v1/healthz
104
+ ```
105
+
106
+ Use one process/worker only. State is in memory, so multiple workers would split conversations and suppression keys.
107
+
108
+ Backup targets:
109
+
110
+ - Render Free Web Service using `render.yaml`
111
+ - Hugging Face Docker Space using `Dockerfile`
112
+
113
+ Free hosts can sleep. Before submission, hit `/v1/healthz`, then keep it warm:
114
+
115
+ ```bash
116
+ python scripts/keep_warm.py https://your-bot.example --interval 900
117
+ ```
118
+
119
+ ## Required Submission Details
120
+
121
+ Set these deployment environment variables:
122
+
123
+ - `CONTACT_EMAIL`: `mokshagnak004@gmail.com`
124
+ - `TEAM_NAME`: optional display name
125
+ - `TEAM_MEMBER`: optional member name
126
+ - `SUBMITTED_AT`: optional ISO timestamp
127
+
128
+ You do not need OpenAI, Groq, Gemini, OpenRouter, Redis, Postgres, or any paid key for the live deterministic bot.
129
+
130
+ ## Tradeoffs
131
+
132
+ In-memory state is simple, private, and fast, but the service must run as a single worker and should not restart during judging. Free LLM APIs are useful for local experiments, but relying on free quota during live judging is risky, so this bot treats LLM usage as optional polish only.
133
+
134
+ ## What More Context Would Help
135
+
136
+ Quality would improve further with verified customer consent mappings, real slot inventory, item-level order or dispense history, offer eligibility rules, customer segment aggregates, and locality-level peer benchmarks. When those facts are absent, the bot avoids inventing them.
app/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Deterministic Vera challenge bot package."""
2
+
app/composer.py ADDED
@@ -0,0 +1,659 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(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
+ saved = "saved address" if payload.get("delivery_address_saved") else "your preferred address"
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'))}. {offer}; delivery can go to the {saved}. Reply CONFIRM to dispatch, or tell us if dosage changed."
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 _merchant_name(merchant: Context) -> str:
472
+ identity = merchant.get("identity", {})
473
+ name = identity.get("name") or "Merchant"
474
+ owner = str(identity.get("owner_first_name") or "").strip()
475
+ if merchant.get("category_slug") == "dentists" and owner and not str(name).lower().startswith("dr."):
476
+ return owner if owner.lower().startswith("dr") else f"Dr. {owner}"
477
+ return _dedupe_dr(str(name))
478
+
479
+
480
+ def _owner_or_name(merchant: Context) -> str:
481
+ identity = merchant.get("identity", {})
482
+ owner = identity.get("owner_first_name")
483
+ if owner:
484
+ if merchant.get("category_slug") == "dentists" and not str(owner).lower().startswith("dr"):
485
+ return f"Dr. {owner}"
486
+ return str(owner)
487
+ return str(identity.get("name", "there"))
488
+
489
+
490
+ def _customer_name(customer: Context) -> str:
491
+ name = str(customer.get("identity", {}).get("name") or "there")
492
+ return name.replace("(parent:", "parent:").strip()
493
+
494
+
495
+ def _best_offer(merchant: Context, category: Context) -> str:
496
+ for offer in merchant.get("offers", []) or []:
497
+ if offer.get("status") == "active" and offer.get("title"):
498
+ return str(offer["title"])
499
+ catalog = [offer for offer in (category.get("offer_catalog", []) or []) if offer.get("title")]
500
+ preferred_types = {"service_at_price": 0, "free_service": 1, "membership": 2}
501
+ def rank(offer: Context) -> tuple[int, int]:
502
+ title = str(offer.get("title", "")).lower()
503
+ percent_penalty = 5 if "flat" in title and "%" in title else 0
504
+ return (preferred_types.get(str(offer.get("type")), 3) + percent_penalty, len(title))
505
+ for offer in sorted(catalog, key=rank):
506
+ title = str(offer["title"])
507
+ if not ("flat" in title.lower() and "%" in title):
508
+ return title
509
+ if catalog:
510
+ return str(sorted(catalog, key=rank)[0]["title"])
511
+ return "a simple service-price offer"
512
+
513
+
514
+ def _digest_item(category: Context, item_id: str | None) -> Context:
515
+ digest = category.get("digest", []) or []
516
+ if item_id:
517
+ for item in digest:
518
+ if item.get("id") == item_id:
519
+ return item
520
+ return digest[0] if digest else {}
521
+
522
+
523
+ def _first_fact(item: Context, agg: Context) -> str:
524
+ facts: list[str] = []
525
+ if item.get("trial_n"):
526
+ facts.append(f"{item['trial_n']}-patient trial")
527
+ if item.get("summary"):
528
+ match = re.search(r"(\d+(?:\.\d+)?%|\d+(?:\.\d+)?\s?mSv|\d+(?:\.\d+)?\s?credits?)", str(item["summary"]))
529
+ if match:
530
+ facts.append(match.group(1))
531
+ if agg.get("high_risk_adult_count"):
532
+ facts.append(f"{agg['high_risk_adult_count']} high-risk adults in your roster")
533
+ return "; ".join(facts) + "." if facts else ""
534
+
535
+
536
+ def _cohort_phrase(agg: Context) -> str:
537
+ if agg.get("high_risk_adult_count"):
538
+ return f"your {agg['high_risk_adult_count']} high-risk adult patients"
539
+ if agg.get("chronic_rx_count"):
540
+ return f"your {agg['chronic_rx_count']} chronic-Rx customers"
541
+ if agg.get("total_active_members"):
542
+ return f"your {agg['total_active_members']} active members"
543
+ if agg.get("lapsed_90d_plus") or agg.get("lapsed_180d_plus"):
544
+ return "your lapsed-customer cohort"
545
+ return "your current customers"
546
+
547
+
548
+ def _member_count(agg: Context) -> str:
549
+ if agg.get("total_active_members"):
550
+ return f"{agg['total_active_members']} active members"
551
+ if agg.get("total_unique_ytd"):
552
+ return f"{agg['total_unique_ytd']} customers"
553
+ return "existing customers"
554
+
555
+
556
+ def _pct(value: Any) -> str:
557
+ try:
558
+ num = float(value)
559
+ except (TypeError, ValueError):
560
+ return "0%"
561
+ return f"{num * 100:.0f}%" if abs(num) <= 1 else f"{num:.0f}%"
562
+
563
+
564
+ def _time_from_iso(value: str | None) -> str | None:
565
+ if not value:
566
+ return None
567
+ match = re.search(r"T(\d{2}):(\d{2})", value)
568
+ if not match:
569
+ return value
570
+ hour = int(match.group(1))
571
+ minute = match.group(2)
572
+ suffix = "am" if hour < 12 else "pm"
573
+ hour = hour if 1 <= hour <= 12 else abs(hour - 12) or 12
574
+ return f"{hour}:{minute}{suffix}"
575
+
576
+
577
+ def _date_from_iso(value: str | None) -> str:
578
+ if not value:
579
+ return "the due date"
580
+ return value.split("T", 1)[0]
581
+
582
+
583
+ def _months_between(start: str | None, end: str | None) -> str | None:
584
+ if not start or not end:
585
+ return None
586
+ try:
587
+ a = datetime.fromisoformat(start[:10])
588
+ b = datetime.fromisoformat(end[:10])
589
+ except ValueError:
590
+ return None
591
+ months = max(1, round((b - a).days / 30))
592
+ return f"{months} months"
593
+
594
+
595
+ def _slot_text(slots: list[Context]) -> str:
596
+ labels = [str(s.get("label")) for s in slots if s.get("label")]
597
+ if not labels:
598
+ return "the next available slot"
599
+ if len(labels) == 1:
600
+ return labels[0]
601
+ return " or ".join(labels[:2])
602
+
603
+
604
+ def _has_consent(customer: Context, trigger: Context) -> bool:
605
+ prefs = customer.get("preferences", {})
606
+ if prefs.get("reminder_opt_in") is False:
607
+ return False
608
+ scopes = set(customer.get("consent", {}).get("scope", []) or [])
609
+ kind = trigger.get("kind", "")
610
+ if kind in {"recall_due", "appointment_tomorrow"}:
611
+ return bool(scopes.intersection({"recall_reminders", "appointment_reminders"}))
612
+ if kind in {"chronic_refill_due"}:
613
+ return bool(scopes.intersection({"refill_reminders", "delivery_notifications"}))
614
+ if kind in {"customer_lapsed_hard", "customer_lapsed_soft"}:
615
+ return bool(scopes.intersection({"winback_offers", "renewal_reminders", "promotional_offers"}))
616
+ if kind in {"wedding_package_followup"}:
617
+ return "bridal_package_followup" in scopes
618
+ if kind in {"trial_followup"}:
619
+ return bool(scopes.intersection({"kids_program_updates", "program_updates", "appointment_reminders"}))
620
+ return bool(scopes)
621
+
622
+
623
+ def _has_all(payload: Context, *fields: str) -> bool:
624
+ return all(payload.get(field) not in (None, "", []) for field in fields)
625
+
626
+
627
+ def _has_any(payload: Context, *fields: str) -> bool:
628
+ return any(payload.get(field) not in (None, "", []) for field in fields)
629
+
630
+
631
+ def _is_placeholder_payload(payload: Context) -> bool:
632
+ return payload.get("placeholder") is True
633
+
634
+
635
+ def _dedupe_dr(value: str) -> str:
636
+ return re.sub(r"\bDr\.\s+Dr\.\s+", "Dr. ", value).strip()
637
+
638
+
639
+ def _final_scrub(body: str) -> str:
640
+ body = _dedupe_dr(body)
641
+ body = body.replace("None", "the available context")
642
+ body = body.replace("baseline normal", "the recent baseline")
643
+ body = re.sub(r"\b(up|down|dropped|rose|increased)\s+0%\b", "changed in the latest context", body)
644
+ body = body.replace("the available context days", "a while")
645
+ body = body.replace("the available context km", "nearby")
646
+ body = body.replace("festival is the available context days away", "a festival window is coming up")
647
+ return _clean(body)
648
+
649
+
650
+ def _short_id(value: str) -> str:
651
+ cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_")
652
+ parts = cleaned.split("_")
653
+ return "_".join(parts[:4])[:36] or "x"
654
+
655
+
656
+ def _action_scope(merchant: Context, trigger: Context) -> str:
657
+ name = merchant.get("identity", {}).get("name") or "this merchant"
658
+ kind = str(trigger.get("kind", "task")).replace("_", " ")
659
+ return f"{name}'s {kind}"
app/decision_engine.py ADDED
@@ -0,0 +1,1372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import json
5
+ import os
6
+ import re
7
+ from typing import Any
8
+ from urllib import request as urlrequest
9
+
10
+
11
+ Context = dict[str, Any]
12
+
13
+ CTA_NONE = "none"
14
+ CTA_OPEN = "open_ended"
15
+ CTA_YES_NO = "binary_yes_no"
16
+ CTA_CONFIRM = "binary_confirm_cancel"
17
+ CTA_SLOTS = "multi_choice_slot"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Evidence:
22
+ label: str
23
+ value: str
24
+ kind: str
25
+ source: str
26
+ weight: int = 1
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class MerchantCAR:
31
+ merchant_id: str
32
+ merchant_name: str
33
+ owner: str
34
+ category: str
35
+ locality: str
36
+ active_offers: list[str]
37
+ views_30d: int
38
+ calls_30d: int
39
+ ctr: str
40
+ performance_deltas: dict[str, str]
41
+ trigger_kind: str
42
+ trigger_urgency: int
43
+ trigger_facts: dict[str, str]
44
+ customer_id: str
45
+ customer_stage: str
46
+ consent_state: str
47
+ last_action_type: str
48
+ last_response_intent: str
49
+ repeated_action_count: int
50
+ no_reply_count: int
51
+ action_sequence: list[str]
52
+ response_sequence: list[str]
53
+ reflection_note: str
54
+ category_arm_priors: dict[str, str]
55
+
56
+ def summary(self) -> dict[str, Any]:
57
+ return {
58
+ "merchant_id": self.merchant_id,
59
+ "category": self.category,
60
+ "locality": self.locality,
61
+ "active_offers": self.active_offers[:3],
62
+ "performance_deltas": self.performance_deltas,
63
+ "trigger_kind": self.trigger_kind,
64
+ "trigger_urgency": self.trigger_urgency,
65
+ "customer_stage": self.customer_stage,
66
+ "consent_state": self.consent_state,
67
+ "last_action_type": self.last_action_type,
68
+ "last_response_intent": self.last_response_intent,
69
+ "reflection_note": self.reflection_note,
70
+ "category_arm_priors": self.category_arm_priors,
71
+ }
72
+
73
+
74
+ @dataclass
75
+ class DecisionPlan:
76
+ primary_signal: str
77
+ evidence: list[Evidence]
78
+ selected_lever: str
79
+ recommended_action: str
80
+ risk_flags: list[str]
81
+ rubric_scores: dict[str, int]
82
+ copy_strategy: str
83
+ body: str
84
+ cta: str
85
+ send_as: str
86
+ suppression_key: str
87
+ rationale: str
88
+ car_summary: dict[str, Any] = field(default_factory=dict)
89
+ jitai_scores: dict[str, int] = field(default_factory=dict)
90
+ map_scores: dict[str, int] = field(default_factory=dict)
91
+ frame: str = "effort_externalization"
92
+ action_arm: str = "draft_action"
93
+ variant_strategy: str = "primary"
94
+ persuasion_principle: str = "liking"
95
+ constitutional_violations: list[str] = field(default_factory=list)
96
+ thought_frames: list[dict[str, Any]] = field(default_factory=list)
97
+ reference_key: str = "default"
98
+
99
+ @property
100
+ def total_score(self) -> int:
101
+ map_bonus = int(sum(self.map_scores.values()) / 12) if self.map_scores else 0
102
+ jitai_bonus = int(sum(self.jitai_scores.values()) / 15) if self.jitai_scores else 0
103
+ constitution_penalty = len(self.constitutional_violations) * 2
104
+ return sum(self.rubric_scores.values()) + map_bonus + jitai_bonus - constitution_penalty
105
+
106
+
107
+ CATEGORY_PLAYBOOKS: dict[str, dict[str, Any]] = {
108
+ "dentists": {
109
+ "voice": "clinical-peer",
110
+ "terms": ["recall", "caries", "fluoride", "IOPA", "CDE", "patient cohort"],
111
+ "avoid": ["guaranteed", "miracle", "best in city"],
112
+ "action": "draft the patient note or checklist",
113
+ },
114
+ "salons": {
115
+ "voice": "warm-practical",
116
+ "terms": ["slot", "package", "trial", "stylist", "bridal", "service"],
117
+ "avoid": ["clinical claims", "hard urgency without event"],
118
+ "action": "draft the WhatsApp/post and hold a slot",
119
+ },
120
+ "restaurants": {
121
+ "voice": "operator-to-operator",
122
+ "terms": ["orders", "covers", "delivery", "banner", "weekday", "rush"],
123
+ "avoid": ["generic discount blast"],
124
+ "action": "draft the banner/menu note",
125
+ },
126
+ "gyms": {
127
+ "voice": "coach-to-operator",
128
+ "terms": ["trial", "members", "retention", "class", "challenge", "no commitment"],
129
+ "avoid": ["shame", "body-negative wording"],
130
+ "action": "draft the class/challenge message",
131
+ },
132
+ "pharmacies": {
133
+ "voice": "precise-safe",
134
+ "terms": ["refill", "batch", "delivery", "stock", "compliance", "repeat customers"],
135
+ "avoid": ["panic", "medical diagnosis"],
136
+ "action": "draft the customer note and counter checklist",
137
+ },
138
+ }
139
+
140
+
141
+ LEVER_BY_KIND = {
142
+ "research_digest": "curiosity + source credibility",
143
+ "regulation_change": "urgency + compliance risk",
144
+ "cde_opportunity": "professional value + low effort",
145
+ "perf_dip": "loss aversion + recovery action",
146
+ "seasonal_perf_dip": "anxiety pre-emption + reframe",
147
+ "perf_spike": "amplify what is working",
148
+ "active_planning_intent": "effort externalization",
149
+ "festival_upcoming": "timing urgency",
150
+ "ipl_match_today": "timely local event + judgment",
151
+ "review_theme_emerged": "reputation risk + ops action",
152
+ "milestone_reached": "near-miss motivation",
153
+ "renewal_due": "deadline + ROI proof",
154
+ "winback_eligible": "lost customers + easy restart",
155
+ "dormant_with_vera": "curiosity + recovery",
156
+ "supply_alert": "urgent precision",
157
+ "category_seasonal": "timely stock/action planning",
158
+ "gbp_unverified": "visibility loss aversion",
159
+ "competitor_opened": "competitive threat",
160
+ "curious_ask_due": "asking the merchant",
161
+ "recall_due": "specific appointment/recall",
162
+ "appointment_tomorrow": "reminder + friction removal",
163
+ "customer_lapsed_hard": "no-shame winback",
164
+ "customer_lapsed_soft": "no-shame winback",
165
+ "wedding_package_followup": "occasion timing",
166
+ "trial_followup": "fresh intent follow-up",
167
+ "chronic_refill_due": "necessity + convenience",
168
+ }
169
+
170
+
171
+ def compose_scored(category: Context, merchant: Context, trigger: Context, customer: Context | None = None) -> Context | None:
172
+ """Return a high-score composed message, or None to let the legacy composer handle it."""
173
+ car = build_merchant_car(category, merchant, trigger, customer)
174
+ evidence = extract_evidence(category, merchant, trigger, customer, car)
175
+ candidates = build_candidates(category, merchant, trigger, customer, evidence, car)
176
+ if not candidates:
177
+ return None
178
+ best = max(candidates, key=lambda plan: (plan.total_score, sum(plan.map_scores.values()), len(plan.evidence)))
179
+ if best.total_score < 32 and not customer:
180
+ return None
181
+ output = plan_to_message(best)
182
+ improved = improve_with_llm_if_available(category, merchant, trigger, customer, best, output)
183
+ return improved or output
184
+
185
+
186
+ def expected_trigger_score(category: Context | None, merchant: Context | None, trigger: Context, customer: Context | None = None) -> int:
187
+ if not category or not merchant:
188
+ return 0
189
+ car = build_merchant_car(category, merchant, trigger, customer)
190
+ evidence = extract_evidence(category, merchant, trigger, customer, car)
191
+ candidates = build_candidates(category, merchant, trigger, customer, evidence, car)
192
+ if not candidates:
193
+ return 0
194
+ return max(c.total_score for c in candidates)
195
+
196
+
197
+ def build_merchant_car(category: Context, merchant: Context, trigger: Context, customer: Context | None = None) -> MerchantCAR:
198
+ """Flatten nested context into one typed record for stable decisioning."""
199
+ identity = merchant.get("identity", {}) or {}
200
+ perf = merchant.get("performance", {}) or {}
201
+ payload = trigger.get("payload", {}) or {}
202
+ memory = merchant.get("__vera_memory", {}) or {}
203
+ active_offers = [
204
+ clean(str(offer.get("title") or ""))
205
+ for offer in merchant.get("offers", []) or []
206
+ if offer.get("status") == "active" and offer.get("title")
207
+ ]
208
+ if not active_offers:
209
+ active_offers = [
210
+ clean(str(offer.get("title") or ""))
211
+ for offer in category.get("offer_catalog", []) or []
212
+ if offer.get("title")
213
+ ][:3]
214
+ trigger_facts = {
215
+ clean(str(k)): normalize_car_value(v)
216
+ for k, v in payload.items()
217
+ if k != "placeholder" and v not in (None, "", [], {})
218
+ }
219
+ deltas = {
220
+ clean(str(k)): normalize_car_value(v)
221
+ for k, v in (perf.get("delta_7d") or {}).items()
222
+ if v not in (None, "", [], {})
223
+ }
224
+ customer_id = ""
225
+ customer_stage = "merchant_only"
226
+ consent_state = "not_applicable"
227
+ if customer:
228
+ customer_id = clean(str(customer.get("customer_id") or customer.get("id") or ""))
229
+ customer_stage = clean(str(customer.get("state") or "unknown"))
230
+ consent_state = "allowed" if has_consent(customer, trigger) else "missing_or_blocked"
231
+ action_sequence = [clean(str(v)) for v in memory.get("action_sequence", []) if v][:5]
232
+ response_sequence = [clean(str(v)) for v in memory.get("response_sequence", []) if v][:5]
233
+ priors = {
234
+ clean(str(k)): normalize_car_value(v)
235
+ for k, v in (memory.get("category_arm_priors") or {}).items()
236
+ if k and v not in (None, "", [], {})
237
+ }
238
+ return MerchantCAR(
239
+ merchant_id=clean(str(merchant.get("merchant_id") or merchant.get("id") or "")),
240
+ merchant_name=clean(str(identity.get("name") or "unknown")),
241
+ owner=clean(str(identity.get("owner_first_name") or "")),
242
+ category=clean(str(merchant.get("category_slug") or category.get("slug") or "unknown")),
243
+ locality=clean(str(identity.get("locality") or identity.get("city") or "unknown")),
244
+ active_offers=[offer for offer in active_offers if offer][:5],
245
+ views_30d=safe_int(perf.get("views")),
246
+ calls_30d=safe_int(perf.get("calls")),
247
+ ctr=normalize_car_value(perf.get("ctr")),
248
+ performance_deltas=deltas,
249
+ trigger_kind=clean(str(trigger.get("kind") or "generic")),
250
+ trigger_urgency=safe_int(trigger.get("urgency"), default=1),
251
+ trigger_facts=trigger_facts,
252
+ customer_id=customer_id,
253
+ customer_stage=customer_stage,
254
+ consent_state=consent_state,
255
+ last_action_type=clean(str(memory.get("last_action_type") or "")),
256
+ last_response_intent=clean(str(memory.get("last_response_intent") or "")),
257
+ repeated_action_count=safe_int(memory.get("repeated_action_count")),
258
+ no_reply_count=safe_int(memory.get("no_reply_count")),
259
+ action_sequence=action_sequence,
260
+ response_sequence=response_sequence,
261
+ reflection_note=clean(str(memory.get("reflection_note") or "")),
262
+ category_arm_priors=priors,
263
+ )
264
+
265
+
266
+ def normalize_car_value(value: Any) -> str:
267
+ if value in (None, "", [], {}):
268
+ return "unknown"
269
+ if isinstance(value, float):
270
+ return pct(value) if abs(value) <= 1 else f"{value:g}"
271
+ if isinstance(value, list):
272
+ values: list[str] = []
273
+ for item in value[:4]:
274
+ if isinstance(item, dict):
275
+ values.append(str(item.get("label") or item.get("iso") or item.get("name") or item))
276
+ else:
277
+ values.append(str(item))
278
+ return clean(", ".join(values)) or "unknown"
279
+ if isinstance(value, dict):
280
+ return clean(", ".join(f"{k}:{normalize_car_value(v)}" for k, v in list(value.items())[:4])) or "unknown"
281
+ return clean(str(value).replace("_", " ")) or "unknown"
282
+
283
+
284
+ def safe_int(value: Any, default: int = 0) -> int:
285
+ try:
286
+ return int(float(value))
287
+ except (TypeError, ValueError):
288
+ return default
289
+
290
+
291
+ def extract_evidence(category: Context, merchant: Context, trigger: Context, customer: Context | None = None, car: MerchantCAR | None = None) -> list[Evidence]:
292
+ car = car or build_merchant_car(category, merchant, trigger, customer)
293
+ evidence: list[Evidence] = []
294
+ identity = merchant.get("identity", {})
295
+ perf = merchant.get("performance", {})
296
+ agg = merchant.get("customer_aggregate", {})
297
+ payload = trigger.get("payload", {}) or {}
298
+
299
+ def add(label: str, value: Any, kind: str, source: str, weight: int = 1) -> None:
300
+ if value in (None, "", [], {}):
301
+ return
302
+ if isinstance(value, float):
303
+ value = pct(value) if abs(value) <= 1 else f"{value:g}"
304
+ elif isinstance(value, list):
305
+ if label in {"available_slots", "next_session_options"}:
306
+ value = " or ".join(str(v.get("label") or v.get("iso")) for v in value[:3] if isinstance(v, dict))
307
+ else:
308
+ value = ", ".join(str(v) for v in value[:4])
309
+ value_s = clean(str(value).replace("_", " "))
310
+ if value_s and value_s.lower() not in {"none", "normal"}:
311
+ evidence.append(Evidence(label, value_s, kind, source, weight))
312
+
313
+ add("merchant", identity.get("name"), "identity", "merchant.identity", 2)
314
+ add("owner", identity.get("owner_first_name"), "identity", "merchant.identity", 1)
315
+ add("locality", identity.get("locality") or identity.get("city"), "local", "merchant.identity", 1)
316
+ add("car_locality", car.locality, "local", "merchant.car", 2)
317
+ add("car_customer_stage", car.customer_stage, "customer", "merchant.car", 2)
318
+ add("car_consent_state", car.consent_state, "consent", "merchant.car", 2)
319
+ add("car_last_response", car.last_response_intent, "history", "merchant.car", 2)
320
+ add("views_30d", perf.get("views"), "number", "merchant.performance", 2)
321
+ add("calls_30d", perf.get("calls"), "number", "merchant.performance", 2)
322
+ add("ctr", perf.get("ctr"), "number", "merchant.performance", 2)
323
+ for key, value in (perf.get("delta_7d") or {}).items():
324
+ add(f"{key}_7d", value, "number", "merchant.performance.delta_7d", 2)
325
+
326
+ for offer in merchant.get("offers", []) or []:
327
+ if offer.get("status") == "active":
328
+ add("active_offer", offer.get("title"), "offer", "merchant.offers", 4)
329
+ for offer in category.get("offer_catalog", []) or []:
330
+ title = offer.get("title")
331
+ if title and "flat" not in str(title).lower():
332
+ add("category_offer", title, "offer", "category.offer_catalog", 2)
333
+ break
334
+
335
+ for key, value in agg.items():
336
+ weight = 5 if any(tok in key for tok in ["risk", "chronic", "active_members"]) else 4 if any(tok in key for tok in ["count", "active", "lapsed"]) else 2
337
+ add(key, value, "number", "merchant.customer_aggregate", weight)
338
+
339
+ for signal in merchant.get("signals", []) or []:
340
+ add("signal", signal, "signal", "merchant.signals", 2)
341
+ for hist in merchant.get("conversation_history", [])[-2:]:
342
+ add("history", hist.get("engagement") or hist.get("body"), "history", "merchant.conversation_history", 2)
343
+
344
+ for key, value in payload.items():
345
+ if key == "placeholder":
346
+ continue
347
+ kind = "date" if "date" in key or "iso" in key or "expires" in key else "trigger"
348
+ weight = 5 if key in {"top_item_id", "digest_item_id", "metric", "delta_pct", "available_slots", "affected_batches", "molecule_list"} else 3
349
+ add(key, value, kind, "trigger.payload", weight)
350
+ for key, value in car.trigger_facts.items():
351
+ add(f"car_{key}", value, "trigger", "merchant.car.trigger_facts", 3)
352
+
353
+ digest_id = payload.get("top_item_id") or payload.get("digest_item_id") or payload.get("alert_id")
354
+ if digest_id:
355
+ for item in category.get("digest", []) or []:
356
+ if item.get("id") != digest_id:
357
+ continue
358
+ add("digest_title", item.get("title"), "source", "category.digest", 5)
359
+ add("digest_source", item.get("source"), "source", "category.digest", 4)
360
+ add("trial_n", item.get("trial_n"), "number", "category.digest", 3)
361
+ add("digest_summary_fact", first_numeric_fact(item.get("summary")), "number", "category.digest", 3)
362
+ break
363
+
364
+ peer = category.get("peer_stats", {}) or {}
365
+ add("peer_ctr", peer.get("avg_ctr"), "peer", "category.peer_stats", 2)
366
+ add("peer_reviews", peer.get("avg_review_count"), "peer", "category.peer_stats", 2)
367
+
368
+ if customer:
369
+ c_identity = customer.get("identity", {})
370
+ relation = customer.get("relationship", {})
371
+ prefs = customer.get("preferences", {})
372
+ add("customer", c_identity.get("name"), "customer", "customer.identity", 3)
373
+ add("language_pref", c_identity.get("language_pref"), "customer", "customer.identity", 2)
374
+ add("customer_state", customer.get("state"), "customer", "customer.state", 3)
375
+ add("last_visit", relation.get("last_visit"), "date", "customer.relationship", 2)
376
+ add("visits_total", relation.get("visits_total"), "number", "customer.relationship", 2)
377
+ add("services", relation.get("services_received"), "customer", "customer.relationship", 2)
378
+ add("preferred_slots", prefs.get("preferred_slots"), "customer", "customer.preferences", 2)
379
+ add("channel", prefs.get("channel"), "customer", "customer.preferences", 1)
380
+
381
+ return dedupe_evidence(evidence)
382
+
383
+
384
+ def build_candidates(category: Context, merchant: Context, trigger: Context, customer: Context | None, evidence: list[Evidence], car: MerchantCAR | None = None) -> list[DecisionPlan]:
385
+ car = car or build_merchant_car(category, merchant, trigger, customer)
386
+ kind = trigger.get("kind", "generic")
387
+ if trigger.get("payload", {}).get("placeholder") and len(evidence) < 5:
388
+ kind = "generic"
389
+ if not customer and is_sparse_context(car, evidence):
390
+ return [make_sparse_plan(category, merchant, trigger, evidence, car, kind)]
391
+ strategies = deterministic_strategies_for(kind, customer, car)
392
+ candidates = [make_plan(category, merchant, trigger, customer, evidence, kind, strategy, car) for strategy in strategies]
393
+ if customer:
394
+ candidates.append(make_plan(category, merchant, trigger, customer, evidence, kind, "customer_low_friction", car))
395
+ else:
396
+ candidates.append(make_plan(category, merchant, trigger, customer, evidence, kind, "ask_merchant", car))
397
+ candidates.append(make_plan(category, merchant, trigger, customer, evidence, kind, "artifact_offer", car))
398
+ return [c for c in candidates if c.body and "no_send_jitai" not in c.risk_flags]
399
+
400
+
401
+ def is_sparse_context(car: MerchantCAR, evidence: list[Evidence]) -> bool:
402
+ high_value = [
403
+ e for e in evidence
404
+ if e.kind in {"offer", "number", "date", "trigger", "signal"}
405
+ and (e.source.startswith("merchant") or e.source.startswith("trigger"))
406
+ and not e.source.startswith("merchant.car")
407
+ and e.value not in {"0", "0%", "unknown", "merchant only", "not applicable"}
408
+ and not e.label.startswith("car_consent")
409
+ and e.label not in {"category_offer"}
410
+ ]
411
+ useful_trigger_facts = [v for v in car.trigger_facts.values() if v and v != "unknown"]
412
+ has_merchant_offer = any(e.label == "active_offer" for e in evidence)
413
+ has_metrics = car.views_30d > 0 or car.calls_30d > 0 or bool(car.performance_deltas)
414
+ return len(high_value) < 2 and len(useful_trigger_facts) <= 1 and not has_merchant_offer and not has_metrics
415
+
416
+
417
+ def make_sparse_plan(category: Context, merchant: Context, trigger: Context, evidence: list[Evidence], car: MerchantCAR, kind: str) -> DecisionPlan:
418
+ cat = car.category or merchant.get("category_slug") or category.get("slug", "restaurants")
419
+ frame = "effort_externalization"
420
+ arm = "sparse_reactivation"
421
+ principle = "reciprocity"
422
+ cta = CTA_YES_NO
423
+ signal = primary_signal(trigger, evidence)
424
+ body = sparse_fallback_body(cat, merchant, trigger, car)
425
+ body = apply_constitution_repairs(body, car, trigger)
426
+ violations = constitutional_violations(body, car, trigger, cta)
427
+ risk_flags = risk_flags_for(category, merchant, trigger, None, evidence) + ["sparse_context_floor"]
428
+ jitai = classify_jitai(car, evidence, risk_flags, None)
429
+ map_scores = score_map(car, trigger, body, cta, frame, risk_flags)
430
+ scores = score_plan(category, merchant, trigger, None, evidence, body, cta, "vera", risk_flags, "sparse_reactivation", map_scores, jitai)
431
+ action = "Reply YES and I will prepare the exact low-risk draft."
432
+ rationale = rationale_for(signal, evidence, "sparse-context reactivation + effort externalization", action, risk_flags, frame, arm, map_scores, jitai, principle, f"{cat}:engagement_compulsion")
433
+ return DecisionPlan(
434
+ primary_signal=signal,
435
+ evidence=evidence[:8],
436
+ selected_lever="sparse-context reactivation + effort externalization",
437
+ recommended_action=action,
438
+ risk_flags=risk_flags,
439
+ rubric_scores=scores,
440
+ copy_strategy="sparse_reactivation",
441
+ body=body,
442
+ cta=cta,
443
+ send_as="vera",
444
+ suppression_key=trigger.get("suppression_key") or trigger.get("id", ""),
445
+ rationale=rationale,
446
+ car_summary=car.summary(),
447
+ jitai_scores=jitai,
448
+ map_scores=map_scores,
449
+ frame=frame,
450
+ action_arm=arm,
451
+ variant_strategy="sparse_reactivation",
452
+ persuasion_principle=principle,
453
+ constitutional_violations=violations,
454
+ thought_frames=build_thought_frames(category, merchant, trigger, None, evidence, car),
455
+ reference_key=f"{cat}:engagement_compulsion",
456
+ )
457
+
458
+
459
+ def sparse_fallback_body(cat: str, merchant: Context, trigger: Context, car: MerchantCAR) -> str:
460
+ name = merchant_salutation(merchant)
461
+ merchant_name = car.merchant_name if car.merchant_name != "unknown" else "your business"
462
+ locality = car.locality if car.locality != "unknown" else "your locality"
463
+ kind = signal_label(trigger.get("kind") or "reactivation")
464
+ if cat == "restaurants":
465
+ return f"{name}, your {locality} regulars have not seen a fresh {merchant_name} update tied to this {kind} signal. Want me to draft one simple weekday menu/post hook for approval?"
466
+ if cat == "salons":
467
+ return f"{name}, this {kind} signal is enough for a light service reminder, not a discount blast. Want me to draft a warm slot-led message for {locality} customers?"
468
+ if cat == "dentists":
469
+ return f"{name}, this {kind} signal has limited clinical detail, so I will keep it conservative. Want me to draft a short recall/checkup note for approval first?"
470
+ if cat == "gyms":
471
+ return f"{name}, this {kind} signal is thin, so the safest move is a no-pressure class/restart nudge. Want me to draft one for {locality} members?"
472
+ if cat == "pharmacies":
473
+ return f"{name}, this {kind} signal has limited stock/customer detail, so I will avoid medical claims. Want me to draft a calm counter/update note for approval?"
474
+ return f"{name}, this {kind} signal has limited detail, so I will keep the action conservative and specific to {merchant_name}. Want me to draft one approval-ready note?"
475
+
476
+
477
+ def deterministic_strategies_for(kind: str, customer: Context | None, car: MerchantCAR) -> list[str]:
478
+ kind = kind or "generic"
479
+ if customer:
480
+ return order_strategies_by_priors(["certainty_frame", "effort_externalization", "social_proof"], car)
481
+ if kind in {"perf_dip", "seasonal_perf_dip", "renewal_due", "winback_eligible", "dormant_with_vera", "gbp_unverified", "competitor_opened"}:
482
+ return order_strategies_by_priors(["loss_frame", "effort_externalization", "certainty_frame"], car)
483
+ if kind in {"perf_spike", "milestone_reached"}:
484
+ return order_strategies_by_priors(["gain_frame", "social_proof", "effort_externalization"], car)
485
+ if kind in {"research_digest", "review_theme_emerged", "cde_opportunity"}:
486
+ return order_strategies_by_priors(["social_proof", "professional_value", "effort_externalization"], car)
487
+ if kind in {"regulation_change", "supply_alert", "festival_upcoming", "ipl_match_today", "category_seasonal"}:
488
+ return order_strategies_by_priors(["certainty_frame", "loss_frame", "effort_externalization"], car)
489
+ if car.last_response_intent in {"no_reply", "auto_reply"}:
490
+ return order_strategies_by_priors(["effort_externalization", "certainty_frame", "social_proof"], car)
491
+ return order_strategies_by_priors(["effort_externalization", "loss_frame", "gain_frame"], car)
492
+
493
+
494
+ def order_strategies_by_priors(strategies: list[str], car: MerchantCAR) -> list[str]:
495
+ def prior(strategy: str) -> float:
496
+ arm = choose_action_arm(car.category, car.trigger_kind, strategy, bool(car.customer_id))
497
+ try:
498
+ return float(str(car.category_arm_priors.get(arm, "0.5")).split()[0])
499
+ except ValueError:
500
+ return 0.5
501
+ return sorted(strategies, key=lambda s: (prior(s), strategies.index(s) * -0.001), reverse=True)
502
+
503
+
504
+ def make_plan(category: Context, merchant: Context, trigger: Context, customer: Context | None, evidence: list[Evidence], kind: str, strategy: str, car: MerchantCAR | None = None) -> DecisionPlan:
505
+ car = car or build_merchant_car(category, merchant, trigger, customer)
506
+ cat = merchant.get("category_slug") or category.get("slug", "")
507
+ playbook = CATEGORY_PLAYBOOKS.get(cat, CATEGORY_PLAYBOOKS["restaurants"])
508
+ frame = choose_prospect_frame(car, trigger, evidence, strategy)
509
+ arm = choose_action_arm(cat, kind, frame, customer)
510
+ principle = select_cialdini_principle(car, trigger, evidence, frame)
511
+ lever = framing_lever(frame, kind)
512
+ risk_flags = risk_flags_for(category, merchant, trigger, customer, evidence)
513
+ jitai = classify_jitai(car, evidence, risk_flags, customer)
514
+ if not customer and jitai["severity"] <= 2 and jitai["intervention_fit"] <= 4 and "placeholder_trigger" in risk_flags:
515
+ risk_flags.append("no_send_jitai")
516
+ send_as = "merchant_on_behalf" if customer and "consent_missing" not in risk_flags else "vera"
517
+ cta = choose_cta(kind, customer, risk_flags)
518
+ action = recommended_action(cat, kind, playbook, customer, risk_flags, strategy, frame)
519
+ signal = primary_signal(trigger, evidence)
520
+ body = render_body(category, merchant, trigger, customer, evidence, signal, lever, action, cta, send_as, strategy, frame, car, principle)
521
+ body = apply_constitution_repairs(body, car, trigger)
522
+ violations = constitutional_violations(body, car, trigger, cta)
523
+ map_scores = score_map(car, trigger, body, cta, frame, risk_flags)
524
+ scores = score_plan(category, merchant, trigger, customer, evidence, body, cta, send_as, risk_flags, strategy, map_scores, jitai)
525
+ reference_key = f"{cat}:{primary_dimension_for_frame(frame, kind)}"
526
+ thought_frames = build_thought_frames(category, merchant, trigger, customer, evidence, car)
527
+ rationale = rationale_for(signal, evidence, lever, action, risk_flags, frame, arm, map_scores, jitai, principle, reference_key)
528
+ return DecisionPlan(
529
+ primary_signal=signal,
530
+ evidence=evidence[:8],
531
+ selected_lever=lever,
532
+ recommended_action=action,
533
+ risk_flags=risk_flags,
534
+ rubric_scores=scores,
535
+ copy_strategy=strategy,
536
+ body=body,
537
+ cta=cta,
538
+ send_as=send_as,
539
+ suppression_key=trigger.get("suppression_key") or trigger.get("id", ""),
540
+ rationale=rationale,
541
+ car_summary=car.summary(),
542
+ jitai_scores=jitai,
543
+ map_scores=map_scores,
544
+ frame=frame,
545
+ action_arm=arm,
546
+ variant_strategy=strategy,
547
+ persuasion_principle=principle,
548
+ constitutional_violations=violations,
549
+ thought_frames=thought_frames,
550
+ reference_key=reference_key,
551
+ )
552
+
553
+
554
+ def choose_prospect_frame(car: MerchantCAR, trigger: Context, evidence: list[Evidence], strategy: str) -> str:
555
+ kind = trigger.get("kind", "")
556
+ if strategy in {"loss_frame", "gain_frame", "certainty_frame", "social_proof", "professional_value", "effort_externalization"}:
557
+ return strategy
558
+ if kind in {"regulation_change", "supply_alert", "appointment_tomorrow", "recall_due", "chronic_refill_due", "renewal_due"}:
559
+ return "certainty_frame"
560
+ if kind in {"research_digest", "review_theme_emerged", "cde_opportunity"} or any(e.kind in {"source", "peer"} for e in evidence):
561
+ return "social_proof" if kind != "research_digest" else "professional_value"
562
+ if kind in {"perf_spike", "milestone_reached"} or any(not str(v).startswith("-") for v in car.performance_deltas.values()):
563
+ return "gain_frame"
564
+ if kind in {"perf_dip", "seasonal_perf_dip", "winback_eligible", "dormant_with_vera", "gbp_unverified", "competitor_opened"}:
565
+ return "loss_frame"
566
+ return "effort_externalization"
567
+
568
+
569
+ def choose_action_arm(cat: str, kind: str, frame: str, customer: Context | None) -> str:
570
+ if customer:
571
+ if kind in {"recall_due", "appointment_tomorrow"}:
572
+ return "appointment_confirm"
573
+ if kind == "chronic_refill_due":
574
+ return "refill_dispatch"
575
+ if "lapsed" in kind:
576
+ return "winback_slot"
577
+ return "customer_next_step"
578
+ if frame == "loss_frame":
579
+ return "recovery_nudge"
580
+ if frame == "gain_frame":
581
+ return "momentum_amplifier"
582
+ if frame == "certainty_frame":
583
+ return "deadline_action"
584
+ if frame in {"social_proof", "professional_value"}:
585
+ return "proof_to_action"
586
+ return CATEGORY_PLAYBOOKS.get(cat, {}).get("action", "draft_action").replace(" ", "_")
587
+
588
+
589
+ def select_cialdini_principle(car: MerchantCAR, trigger: Context, evidence: list[Evidence], frame: str) -> str:
590
+ payload = trigger.get("payload", {}) or {}
591
+ if frame == "certainty_frame" or any(k in payload for k in ["expires_at", "deadline_iso", "days_until", "available_slots", "stock_runs_out_iso"]):
592
+ return "scarcity"
593
+ if any(e.kind in {"peer", "source"} for e in evidence) or frame == "social_proof":
594
+ return "social_proof"
595
+ if car.category in {"dentists", "pharmacies"} and any(e.kind == "source" for e in evidence):
596
+ return "authority"
597
+ if frame == "loss_frame" or car.no_reply_count >= 1:
598
+ return "reciprocity"
599
+ if car.last_response_intent in {"commitment", "reply"} or "success" in " ".join(car.response_sequence).lower():
600
+ return "commitment"
601
+ return "liking"
602
+
603
+
604
+ def principle_phrase(principle: str, car: MerchantCAR | None, customer: bool = False) -> str:
605
+ if customer or not principle:
606
+ return ""
607
+ if principle == "scarcity":
608
+ return "The window is limited: "
609
+ if principle == "social_proof":
610
+ return ""
611
+ if principle == "authority":
612
+ return "Data-backed angle: "
613
+ if principle == "reciprocity":
614
+ return "I can do the heavy lifting: "
615
+ if principle == "commitment":
616
+ return "You already have momentum here: "
617
+ if principle == "liking":
618
+ return ""
619
+ return ""
620
+
621
+
622
+ def framing_lever(frame: str, kind: str) -> str:
623
+ base = LEVER_BY_KIND.get(kind, "specificity + effort externalization")
624
+ if frame == "loss_frame":
625
+ return f"loss aversion + {base}"
626
+ if frame == "gain_frame":
627
+ return f"gain/momentum + {base}"
628
+ if frame == "certainty_frame":
629
+ return f"certainty/urgency + {base}"
630
+ if frame == "social_proof":
631
+ return f"social proof + {base}"
632
+ if frame == "professional_value":
633
+ return f"professional credibility + {base}"
634
+ return f"effort externalization + {base}"
635
+
636
+
637
+ def render_body(category: Context, merchant: Context, trigger: Context, customer: Context | None, evidence: list[Evidence], signal: str, lever: str, action: str, cta: str, send_as: str, strategy: str, frame: str = "effort_externalization", car: MerchantCAR | None = None, principle: str = "liking") -> str:
638
+ cat = merchant.get("category_slug") or category.get("slug", "")
639
+ name = customer_name(customer) if customer and send_as == "merchant_on_behalf" else merchant_salutation(merchant)
640
+ merchant_name = merchant.get("identity", {}).get("name", "your business")
641
+ key_facts = choose_key_facts_for_body(evidence, str(trigger.get("kind", "")), customer=bool(customer), max_items=3)
642
+ fact_sentence = "; ".join(format_fact(e) for e in key_facts)
643
+ fact_sentence = fact_sentence.rstrip(".")
644
+ kind = str(trigger.get("kind", "signal")).replace("_", " ")
645
+ why_now = why_now_phrase(trigger, evidence)
646
+ voice_prefix = category_voice_phrase(cat, customer=bool(customer))
647
+ principle_prefix = principle_phrase(principle, car, customer=bool(customer))
648
+ frame_only = "" if principle == "scarcity" and frame == "certainty_frame" else frame_phrase(frame, car, customer=bool(customer))
649
+ frame_prefix = f"{principle_prefix}{frame_only}"
650
+ reflection_hint = reflection_phrase(car)
651
+
652
+ if not customer and str(trigger.get("kind")) == "active_planning_intent" and car:
653
+ topic = fact_value(evidence, "intent_topic") or fact_value(evidence, "topic") or "this plan"
654
+ offer = fact_value(evidence, "active_offer") or (car.active_offers[0] if car.active_offers else "")
655
+ demand = fact_value(evidence, "views_30d") or fact_value(evidence, "calls_30d")
656
+ demand_phrase = f" with {demand} recent demand signals" if demand else ""
657
+ offer_phrase = f" using {offer}" if offer else ""
658
+ timing_phrase = " for the next lunch window" if any(term in topic.lower() for term in ["lunch", "thali", "corporate"]) else ""
659
+ body = f"{name}, you asked about {topic}. Draft angle: package it for {car.locality or 'your locality'}{timing_phrase}{offer_phrase}{demand_phrase}; Reply YES and I will prepare the exact ready-to-send post/message from the context already shared."
660
+ return final_scrub(body)
661
+
662
+ if customer and send_as == "merchant_on_behalf":
663
+ if "hi" in str((customer or {}).get("identity", {}).get("language_pref", "")).lower():
664
+ opener = f"Hi {name}, {merchant_name} here."
665
+ elif cat == "pharmacies":
666
+ opener = f"Namaste {name}, {merchant_name} here."
667
+ else:
668
+ opener = f"Hi {name}, {merchant_name} here."
669
+ body = f"{opener} {why_now}. {fact_sentence}. {reflection_hint}{frame_prefix}{action}"
670
+ elif customer and send_as == "vera":
671
+ body = f"{merchant_salutation(merchant)}, {why_now}. {fact_sentence}. Consent is not explicit for direct customer outreach, so {reflection_hint}{frame_prefix}{action}"
672
+ else:
673
+ body = f"{name}, {why_now}. {fact_sentence}. {voice_prefix}{reflection_hint}{frame_prefix}{action}"
674
+
675
+ body = close_with_cta(body, cta, action, kind)
676
+ return final_scrub(body)
677
+
678
+
679
+ def fact_value(evidence: list[Evidence], label: str) -> str:
680
+ for item in evidence:
681
+ if item.label == label or item.label == f"car_{label}":
682
+ return item.value
683
+ return ""
684
+
685
+
686
+ def frame_phrase(frame: str, car: MerchantCAR | None, customer: bool = False) -> str:
687
+ if customer:
688
+ return ""
689
+ if frame == "loss_frame":
690
+ return "This is a recovery moment: "
691
+ if frame == "gain_frame":
692
+ return "Momentum is visible: "
693
+ if frame == "certainty_frame":
694
+ return "The next step is time-bound: "
695
+ if frame == "social_proof":
696
+ return "Use the proof while it is fresh: "
697
+ if frame == "professional_value":
698
+ return "Credibility angle: "
699
+ return ""
700
+
701
+
702
+ def reflection_phrase(car: MerchantCAR | None) -> str:
703
+ if not car or not car.reflection_note:
704
+ return ""
705
+ if "auto" in car.reflection_note.lower() or "no reply" in car.reflection_note.lower():
706
+ return "Keeping this shorter than the last nudge: "
707
+ if "stop" in car.reflection_note.lower():
708
+ return ""
709
+ return ""
710
+
711
+
712
+ def why_now_phrase(trigger: Context, evidence: list[Evidence]) -> str:
713
+ kind = signal_label(trigger.get("kind", "signal"))
714
+ source = str(trigger.get("source") or "").replace("_", " ").strip()
715
+ payload = trigger.get("payload", {}) or {}
716
+ if kind == "research digest":
717
+ return "this research digest trigger points to a relevant category item"
718
+ if kind in {"regulation change", "supply alert"}:
719
+ return f"urgent {kind} came in"
720
+ if kind in {"recall due", "appointment tomorrow", "chronic refill due"}:
721
+ return f"{kind} is due now"
722
+ if payload.get("deadline_iso"):
723
+ return f"{kind} has a deadline on {payload['deadline_iso']}"
724
+ if payload.get("days_until") is not None:
725
+ return f"{kind} is {payload['days_until']} days away"
726
+ if source and source.lower() not in {"internal", "system"}:
727
+ article = "an" if source[:1].lower() in {"a", "e", "i", "o", "u"} else "a"
728
+ return f"{article} {source} {kind} signal is active now"
729
+ return f"this {kind} signal is active now"
730
+
731
+
732
+ def recommended_action(cat: str, kind: str, playbook: Context, customer: Context | None, risk_flags: list[str], strategy: str, frame: str = "effort_externalization") -> str:
733
+ if "consent_missing" in risk_flags:
734
+ return "I can draft a consent-safe approval note for you first."
735
+ if customer:
736
+ if kind == "chronic_refill_due":
737
+ return "Reply CONFIRM to dispatch, or tell us if dosage/brand changed."
738
+ if kind in {"recall_due", "appointment_tomorrow"}:
739
+ return "Reply YES to confirm, or send a better time."
740
+ if kind in {"customer_lapsed_hard", "customer_lapsed_soft"}:
741
+ return "Reply YES and we will hold a no-commitment restart slot."
742
+ return "Reply YES if you want us to hold the next step."
743
+ if kind == "curious_ask_due":
744
+ return "Reply with the one service customers asked for most this week; I will turn it into a post and reply draft."
745
+ if kind == "active_planning_intent":
746
+ return "I will draft the ready-to-send package/post from this now."
747
+ if kind in {"perf_dip", "seasonal_perf_dip"}:
748
+ return "Want me to draft the recovery/retention message?"
749
+ if kind in {"regulation_change", "supply_alert"}:
750
+ return "Want me to draft the checklist plus customer note?"
751
+ if frame == "loss_frame":
752
+ return "Want me to draft one recovery message now?"
753
+ if frame == "gain_frame":
754
+ return "Want me to turn this momentum into a ready post/message?"
755
+ if frame == "certainty_frame":
756
+ return "Want me to prepare the exact time-bound draft now?"
757
+ if frame in {"social_proof", "professional_value"}:
758
+ return "Want me to turn this proof into a merchant-ready draft?"
759
+ if strategy == "ask_merchant":
760
+ return "Reply YES and I will prepare the exact draft."
761
+ return f"Want me to {playbook['action']}?"
762
+
763
+
764
+ def close_with_cta(body: str, cta: str, action: str, kind: str) -> str:
765
+ body = clean(body)
766
+ if cta == CTA_NONE:
767
+ return body
768
+ if body.endswith("?"):
769
+ return body
770
+ if cta == CTA_CONFIRM and "Reply CONFIRM" not in body:
771
+ return f"{body} Reply CONFIRM to proceed."
772
+ if cta == CTA_SLOTS and "Reply 1" not in body:
773
+ return f"{body} Reply 1/2 for the slot, or suggest a time."
774
+ if cta == CTA_YES_NO and "Reply YES" not in body and not body.endswith("?"):
775
+ return f"{body} Reply YES and I will do it."
776
+ return body
777
+
778
+
779
+ def score_plan(category: Context, merchant: Context, trigger: Context, customer: Context | None, evidence: list[Evidence], body: str, cta: str, send_as: str, risk_flags: list[str], strategy: str, map_scores: dict[str, int] | None = None, jitai_scores: dict[str, int] | None = None) -> dict[str, int]:
780
+ map_scores = map_scores or {}
781
+ jitai_scores = jitai_scores or {}
782
+ scores = {
783
+ "decision_quality": score_decision_quality(trigger, evidence, body, strategy, risk_flags),
784
+ "specificity": score_specificity(evidence, body),
785
+ "category_fit": score_category_fit(category, merchant, body),
786
+ "merchant_fit": score_merchant_fit(merchant, customer, evidence, body, send_as),
787
+ "engagement_compulsion": score_engagement_compulsion(trigger, evidence, body, cta, risk_flags),
788
+ }
789
+ if map_scores:
790
+ scores["engagement_compulsion"] += 1 if min(map_scores.values()) >= 6 else -1
791
+ scores["decision_quality"] += 1 if sum(jitai_scores.values()) >= 20 else 0
792
+ return {k: max(0, min(10, v)) for k, v in scores.items()}
793
+
794
+
795
+ def classify_jitai(car: MerchantCAR, evidence: list[Evidence], risk_flags: list[str], customer: Context | None) -> dict[str, int]:
796
+ severity = min(10, max(1, car.trigger_urgency * 2))
797
+ if customer:
798
+ severity += 2
799
+ if any(e.kind in {"date", "trigger"} and e.weight >= 4 for e in evidence):
800
+ severity += 1
801
+ if any(e.kind in {"source", "offer", "number"} and e.weight >= 4 for e in evidence):
802
+ severity += 1
803
+ if "weak_evidence" in risk_flags:
804
+ severity -= 2
805
+
806
+ receptivity = 7
807
+ if car.last_response_intent in {"stop", "hostile"}:
808
+ receptivity = 0
809
+ elif car.last_response_intent == "auto_reply":
810
+ receptivity = 3
811
+ elif car.last_response_intent in {"yes", "commitment"}:
812
+ receptivity = 9
813
+ elif car.no_reply_count >= 2:
814
+ receptivity = 4
815
+ if customer and car.consent_state != "allowed":
816
+ receptivity = min(receptivity, 5)
817
+
818
+ intervention_fit = 5
819
+ if car.trigger_kind in LEVER_BY_KIND:
820
+ intervention_fit += 2
821
+ if car.active_offers:
822
+ intervention_fit += 1
823
+ if any(e.source.startswith("trigger") for e in evidence):
824
+ intervention_fit += 1
825
+ if car.repeated_action_count >= 2:
826
+ intervention_fit -= 2
827
+ if "placeholder_trigger" in risk_flags and len(evidence) < 5:
828
+ intervention_fit -= 2
829
+
830
+ return {
831
+ "severity": max(0, min(10, severity)),
832
+ "receptivity": max(0, min(10, receptivity)),
833
+ "intervention_fit": max(0, min(10, intervention_fit)),
834
+ }
835
+
836
+
837
+ def score_map(car: MerchantCAR, trigger: Context, body: str, cta: str, frame: str, risk_flags: list[str]) -> dict[str, int]:
838
+ motivation = 5
839
+ if frame in {"loss_frame", "gain_frame", "certainty_frame", "social_proof", "professional_value"}:
840
+ motivation += 2
841
+ if re.search(r"\d", body):
842
+ motivation += 1
843
+ if trigger.get("urgency", 1) >= 3:
844
+ motivation += 1
845
+ if "weak_evidence" in risk_flags:
846
+ motivation -= 2
847
+
848
+ ability_by_cta = {
849
+ CTA_NONE: 9,
850
+ CTA_YES_NO: 9,
851
+ CTA_CONFIRM: 8,
852
+ CTA_SLOTS: 8,
853
+ CTA_OPEN: 6,
854
+ }
855
+ ability = ability_by_cta.get(cta, 5)
856
+ if len(body) > 520:
857
+ ability -= 1
858
+ if body.count("?") > 1:
859
+ ability -= 2
860
+
861
+ prompt = 5 + min(3, safe_int(trigger.get("urgency"), 1))
862
+ payload = trigger.get("payload", {}) or {}
863
+ if any(k in payload for k in ["deadline_iso", "expires_at", "days_until", "available_slots", "stock_runs_out_iso"]):
864
+ prompt += 1
865
+ if frame == "certainty_frame":
866
+ prompt += 1
867
+ if "no_send_jitai" in risk_flags:
868
+ prompt -= 4
869
+ return {
870
+ "motivation": max(0, min(10, motivation)),
871
+ "ability": max(0, min(10, ability)),
872
+ "prompt": max(0, min(10, prompt)),
873
+ }
874
+
875
+
876
+ VERA_CONSTITUTION = [
877
+ "No invented numbers; every figure must trace to supplied context.",
878
+ "No generic phrases like increase sales, boost sales, or grow your business.",
879
+ "Use one CTA only.",
880
+ "Name a merchant, trigger, offer, metric, source, date, locality, or customer fact.",
881
+ "Urgency must be tied to a concrete trigger, date, count, or deadline.",
882
+ "Avoid repeating the same action type after weak engagement.",
883
+ "Use peer-to-peer merchant language, not corporate partner language.",
884
+ "For pharmacy customer cases without consent, route to merchant approval and avoid dispatch, dosage, or medical advice copy.",
885
+ ]
886
+
887
+
888
+ def apply_constitution_repairs(body: str, car: MerchantCAR, trigger: Context) -> str:
889
+ repaired = body
890
+ replacements = {
891
+ "increase sales": "recover the current signal",
892
+ "boost sales": "act on this signal",
893
+ "grow your business": "turn this trigger into one concrete action",
894
+ "Dear valued partner": car.owner or car.merchant_name,
895
+ "valued partner": car.owner or car.merchant_name,
896
+ }
897
+ for bad, good in replacements.items():
898
+ repaired = re.sub(re.escape(bad), good, repaired, flags=re.I)
899
+ if repaired.count("?") > 1:
900
+ first_q = repaired.find("?")
901
+ repaired = repaired[: first_q + 1] + repaired[first_q + 1 :].replace("?", ".")
902
+ return final_scrub(repaired)
903
+
904
+
905
+ def constitutional_violations(body: str, car: MerchantCAR, trigger: Context, cta: str) -> list[str]:
906
+ lower = body.lower()
907
+ violations: list[str] = []
908
+ if any(p in lower for p in ["increase sales", "boost sales", "grow your business", "dear valued partner"]):
909
+ violations.append("generic_or_corporate_copy")
910
+ if body.count("?") > 1:
911
+ violations.append("multiple_questions")
912
+ if cta not in {CTA_NONE, CTA_OPEN, CTA_YES_NO, CTA_CONFIRM, CTA_SLOTS}:
913
+ violations.append("invalid_cta")
914
+ concrete = bool(re.search(r"\d", body) or car.locality.lower() in lower or any(str(v).lower() in lower for v in car.trigger_facts.values() if v and v != "unknown") or any(o.lower() in lower for o in car.active_offers))
915
+ if not concrete:
916
+ violations.append("missing_concrete_fact")
917
+ if car.repeated_action_count >= 2 and car.last_action_type and car.last_action_type in lower:
918
+ violations.append("repeated_action_type")
919
+ if car.category == "pharmacies" and car.customer_id and car.consent_state != "allowed":
920
+ if any(term in lower for term in ["dispatch", "dosage", "delivery can go", "medicine is due", "diagnosis", "cure"]):
921
+ violations.append("pharmacy_consent_or_medical_advice_risk")
922
+ return violations
923
+
924
+
925
+ def build_thought_frames(category: Context, merchant: Context, trigger: Context, customer: Context | None, evidence: list[Evidence], car: MerchantCAR) -> list[dict[str, Any]]:
926
+ thoughts: list[dict[str, Any]] = []
927
+ for strategy in deterministic_strategies_for(trigger.get("kind", "generic"), customer, car):
928
+ frame = choose_prospect_frame(car, trigger, evidence, strategy)
929
+ principle = select_cialdini_principle(car, trigger, evidence, frame)
930
+ arm = choose_action_arm(car.category, trigger.get("kind", "generic"), frame, customer)
931
+ map_guess = score_map(car, trigger, " ".join(e.value for e in choose_key_facts(evidence, bool(customer), 3)), choose_cta(trigger.get("kind", "generic"), customer, []), frame, [])
932
+ thoughts.append({
933
+ "strategy": strategy,
934
+ "frame": frame,
935
+ "principle": principle,
936
+ "action_arm": arm,
937
+ "score": sum(map_guess.values()),
938
+ })
939
+ return sorted(thoughts, key=lambda t: int(t["score"]), reverse=True)[:4]
940
+
941
+
942
+ def primary_dimension_for_frame(frame: str, kind: str) -> str:
943
+ if frame in {"loss_frame", "gain_frame", "certainty_frame"}:
944
+ return "engagement_compulsion"
945
+ if frame in {"social_proof", "professional_value"}:
946
+ return "decision_quality" if kind == "research_digest" else "specificity"
947
+ return "merchant_fit"
948
+
949
+
950
+ def score_decision_quality(trigger: Context, evidence: list[Evidence], body: str, strategy: str, risk_flags: list[str]) -> int:
951
+ score = 5
952
+ if trigger.get("kind") and str(trigger.get("kind")).replace("_", " ") in body.lower():
953
+ score += 1
954
+ if any(e.source.startswith("trigger") for e in evidence):
955
+ score += 2
956
+ if any(e.kind in {"offer", "number", "source"} for e in evidence):
957
+ score += 1
958
+ if strategy in {"primary", "artifact_offer"}:
959
+ score += 1
960
+ if "weak_evidence" in risk_flags:
961
+ score -= 2
962
+ return score
963
+
964
+
965
+ def score_specificity(evidence: list[Evidence], body: str) -> int:
966
+ score = 4
967
+ if re.search(r"\d", body):
968
+ score += 2
969
+ if any(e.kind == "offer" for e in evidence):
970
+ score += 1
971
+ if any(e.kind == "date" for e in evidence):
972
+ score += 1
973
+ if any(e.kind == "source" for e in evidence):
974
+ score += 1
975
+ if any(e.kind == "local" for e in evidence):
976
+ score += 1
977
+ return score
978
+
979
+
980
+ def score_category_fit(category: Context, merchant: Context, body: str) -> int:
981
+ cat = merchant.get("category_slug") or category.get("slug", "")
982
+ terms = CATEGORY_PLAYBOOKS.get(cat, {}).get("terms", [])
983
+ score = 6 + min(2, sum(1 for term in terms if term.lower() in body.lower()))
984
+ if cat == "dentists" and any(w in body.lower() for w in ["guaranteed", "miracle"]):
985
+ score -= 3
986
+ if cat == "pharmacies" and any(w in body.lower() for w in ["panic", "cure"]):
987
+ score -= 3
988
+ if "flat" in body.lower() and "%" in body:
989
+ score -= 1
990
+ return score
991
+
992
+
993
+ def score_merchant_fit(merchant: Context, customer: Context | None, evidence: list[Evidence], body: str, send_as: str) -> int:
994
+ identity = merchant.get("identity", {})
995
+ score = 5
996
+ if identity.get("owner_first_name") and str(identity["owner_first_name"]).split()[-1].lower() in body.lower():
997
+ score += 1
998
+ if identity.get("name") and str(identity["name"]).split()[0].lower() in body.lower():
999
+ score += 1
1000
+ if any(e.source.startswith("merchant") for e in evidence):
1001
+ score += 2
1002
+ if customer and send_as == "merchant_on_behalf":
1003
+ score += 1
1004
+ return score
1005
+
1006
+
1007
+ def score_engagement_compulsion(trigger: Context, evidence: list[Evidence], body: str, cta: str, risk_flags: list[str]) -> int:
1008
+ score = 5
1009
+ if cta in {CTA_YES_NO, CTA_CONFIRM, CTA_SLOTS, CTA_OPEN}:
1010
+ score += 1
1011
+ if any(word in body.lower() for word in ["want me", "reply yes", "reply confirm", "draft", "hold", "checklist"]):
1012
+ score += 2
1013
+ if any(e.kind in {"number", "source", "offer"} and e.weight >= 3 for e in evidence):
1014
+ score += 1
1015
+ if trigger.get("urgency", 1) >= 3:
1016
+ score += 1
1017
+ if "consent_missing" in risk_flags:
1018
+ score -= 1
1019
+ return score
1020
+
1021
+
1022
+ def plan_to_message(plan: DecisionPlan) -> Context:
1023
+ return {
1024
+ "body": plan.body,
1025
+ "cta": plan.cta,
1026
+ "send_as": plan.send_as,
1027
+ "suppression_key": plan.suppression_key,
1028
+ "rationale": plan.rationale,
1029
+ "decision_plan": {
1030
+ "primary_signal": plan.primary_signal,
1031
+ "selected_lever": plan.selected_lever,
1032
+ "recommended_action": plan.recommended_action,
1033
+ "risk_flags": plan.risk_flags,
1034
+ "rubric_scores": plan.rubric_scores,
1035
+ "copy_strategy": plan.copy_strategy,
1036
+ "car_summary": plan.car_summary,
1037
+ "jitai_scores": plan.jitai_scores,
1038
+ "map_scores": plan.map_scores,
1039
+ "frame": plan.frame,
1040
+ "action_arm": plan.action_arm,
1041
+ "variant_strategy": plan.variant_strategy,
1042
+ "persuasion_principle": plan.persuasion_principle,
1043
+ "constitutional_violations": plan.constitutional_violations,
1044
+ "thought_frames": plan.thought_frames,
1045
+ "reference_key": plan.reference_key,
1046
+ "constitution": VERA_CONSTITUTION,
1047
+ "evidence": [e.__dict__ for e in plan.evidence[:6]],
1048
+ },
1049
+ }
1050
+
1051
+
1052
+ def improve_with_llm_if_available(category: Context, merchant: Context, trigger: Context, customer: Context | None, plan: DecisionPlan, output: Context) -> Context | None:
1053
+ api_key = os.getenv("OPENAI_API_KEY")
1054
+ if not api_key:
1055
+ return None
1056
+ model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
1057
+ prompt = {
1058
+ "task": "Improve this Vera WhatsApp message without adding facts. Return JSON only.",
1059
+ "rules": [
1060
+ "Use only evidence provided.",
1061
+ "One CTA only.",
1062
+ "No invented numbers, names, links, offers, citations, dates, or slots.",
1063
+ "Keep body concise and merchant/customer appropriate.",
1064
+ "Do not alter suppression_key or send_as.",
1065
+ ],
1066
+ "category": merchant.get("category_slug") or category.get("slug"),
1067
+ "trigger_kind": trigger.get("kind"),
1068
+ "evidence": [e.__dict__ for e in plan.evidence],
1069
+ "draft": output,
1070
+ }
1071
+ schema = {
1072
+ "type": "json_schema",
1073
+ "json_schema": {
1074
+ "name": "vera_message",
1075
+ "strict": True,
1076
+ "schema": {
1077
+ "type": "object",
1078
+ "additionalProperties": False,
1079
+ "required": ["body", "cta", "send_as", "suppression_key", "rationale"],
1080
+ "properties": {
1081
+ "body": {"type": "string"},
1082
+ "cta": {"type": "string", "enum": [CTA_NONE, CTA_OPEN, CTA_YES_NO, CTA_CONFIRM, CTA_SLOTS]},
1083
+ "send_as": {"type": "string", "enum": ["vera", "merchant_on_behalf"]},
1084
+ "suppression_key": {"type": "string"},
1085
+ "rationale": {"type": "string"},
1086
+ },
1087
+ },
1088
+ },
1089
+ }
1090
+ body = json.dumps({
1091
+ "model": model,
1092
+ "messages": [
1093
+ {"role": "system", "content": "You are Vera's copy reviewer. Output valid JSON matching the schema. Never invent facts."},
1094
+ {"role": "user", "content": json.dumps(prompt, ensure_ascii=False)},
1095
+ ],
1096
+ "temperature": 0.1,
1097
+ "seed": 20260426,
1098
+ "response_format": schema,
1099
+ "max_tokens": 500,
1100
+ }).encode("utf-8")
1101
+ req = urlrequest.Request(
1102
+ "https://api.openai.com/v1/chat/completions",
1103
+ data=body,
1104
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
1105
+ method="POST",
1106
+ )
1107
+ try:
1108
+ with urlrequest.urlopen(req, timeout=8) as resp:
1109
+ data = json.loads(resp.read().decode("utf-8"))
1110
+ content = data["choices"][0]["message"]["content"]
1111
+ improved = json.loads(content)
1112
+ except Exception:
1113
+ return None
1114
+ if not validate_output_against_evidence(improved, plan):
1115
+ return None
1116
+ improved["decision_plan"] = output.get("decision_plan")
1117
+ return improved
1118
+
1119
+
1120
+ def validate_output_against_evidence(output: Context, plan: DecisionPlan) -> bool:
1121
+ body = str(output.get("body", ""))
1122
+ if not body or any(p in body for p in ["None", "Dr. Dr.", "I will not send"]):
1123
+ return False
1124
+ if output.get("send_as") != plan.send_as or output.get("suppression_key") != plan.suppression_key:
1125
+ return False
1126
+ numbers = re.findall(r"\b\d+(?:\.\d+)?%?|\b₹\s?\d[\d,]*", body)
1127
+ evidence_text = " ".join(e.value for e in plan.evidence)
1128
+ for number in numbers:
1129
+ if number not in evidence_text and number.strip("₹ ") not in evidence_text:
1130
+ return False
1131
+ return True
1132
+
1133
+
1134
+ def risk_flags_for(category: Context, merchant: Context, trigger: Context, customer: Context | None, evidence: list[Evidence]) -> list[str]:
1135
+ flags: list[str] = []
1136
+ if len(evidence) < 4:
1137
+ flags.append("weak_evidence")
1138
+ if customer and not has_consent(customer, trigger):
1139
+ flags.append("consent_missing")
1140
+ if trigger.get("payload", {}).get("placeholder"):
1141
+ flags.append("placeholder_trigger")
1142
+ return flags
1143
+
1144
+
1145
+ def has_consent(customer: Context, trigger: Context) -> bool:
1146
+ prefs = customer.get("preferences", {})
1147
+ if prefs.get("reminder_opt_in") is False:
1148
+ return False
1149
+ scopes = set(customer.get("consent", {}).get("scope", []) or [])
1150
+ kind = trigger.get("kind", "")
1151
+ if kind in {"recall_due", "appointment_tomorrow"}:
1152
+ return bool(scopes & {"recall_reminders", "appointment_reminders"})
1153
+ if kind == "chronic_refill_due":
1154
+ return bool(scopes & {"refill_reminders", "delivery_notifications", "recall_alerts"})
1155
+ if kind in {"customer_lapsed_hard", "customer_lapsed_soft"}:
1156
+ return bool(scopes & {"winback_offers", "renewal_reminders", "promotional_offers"})
1157
+ if kind == "wedding_package_followup":
1158
+ return "bridal_package_followup" in scopes
1159
+ if kind == "trial_followup":
1160
+ return bool(scopes & {"kids_program_updates", "program_updates", "appointment_reminders"})
1161
+ return bool(scopes)
1162
+
1163
+
1164
+ def choose_cta(kind: str, customer: Context | None, risk_flags: list[str]) -> str:
1165
+ if "consent_missing" in risk_flags:
1166
+ return CTA_YES_NO
1167
+ if customer and kind in {"recall_due", "appointment_tomorrow"}:
1168
+ return CTA_SLOTS if kind == "recall_due" else CTA_CONFIRM
1169
+ if customer and kind == "chronic_refill_due":
1170
+ return CTA_CONFIRM
1171
+ if kind in {"research_digest", "curious_ask_due"}:
1172
+ return CTA_OPEN
1173
+ if kind == "active_planning_intent":
1174
+ return CTA_YES_NO
1175
+ return CTA_YES_NO
1176
+
1177
+
1178
+ def choose_key_facts(evidence: list[Evidence], customer: bool = False, max_items: int = 3) -> list[Evidence]:
1179
+ if customer:
1180
+ priority = {"trigger": 0, "date": 1, "customer": 2, "offer": 3, "number": 4, "local": 5, "history": 6, "signal": 7, "source": 8, "peer": 9}
1181
+ else:
1182
+ priority = {"source": 0, "number": 1, "offer": 2, "trigger": 3, "date": 4, "local": 5, "history": 6, "signal": 7, "peer": 8, "customer": 9}
1183
+ def rank(e: Evidence) -> tuple[int, int, int]:
1184
+ label_bonus = -3 if any(tok in e.label for tok in ["risk", "chronic", "active_members", "available_slots"]) else 0
1185
+ return (priority.get(e.kind, 20), label_bonus, -e.weight)
1186
+ sorted_e = sorted(evidence, key=rank)
1187
+ chosen: list[Evidence] = []
1188
+ seen_values: set[str] = set()
1189
+ for e in sorted_e:
1190
+ if e.value in seen_values or e.kind == "identity":
1191
+ continue
1192
+ chosen.append(e)
1193
+ seen_values.add(e.value)
1194
+ if len(chosen) >= max_items:
1195
+ break
1196
+ return chosen
1197
+
1198
+
1199
+ def choose_key_facts_for_body(evidence: list[Evidence], kind: str, customer: bool = False, max_items: int = 3) -> list[Evidence]:
1200
+ if kind == "active_planning_intent":
1201
+ priority = {
1202
+ "intent_topic": 0,
1203
+ "merchant_last_message": 1,
1204
+ "active_offer": 2,
1205
+ "calls_30d": 3,
1206
+ "views_30d": 4,
1207
+ "locality": 5,
1208
+ }
1209
+ elif kind in {"perf_dip", "perf_spike", "seasonal_perf_dip"}:
1210
+ priority = {"metric": 0, "delta_pct": 1, "calls_7d": 2, "views_7d": 3, "active_offer": 4, "calls_30d": 5, "views_30d": 6}
1211
+ elif kind in {"research_digest", "cde_opportunity"}:
1212
+ priority = {"digest_title": 0, "digest_source": 1, "high_risk_adult_count": 2, "digest_summary_fact": 3, "trial_n": 4, "active_offer": 5}
1213
+ elif kind == "curious_ask_due":
1214
+ priority = {"topic": 0, "metric_or_topic": 1, "active_offer": 2, "calls_30d": 3, "views_30d": 4, "locality": 5}
1215
+ else:
1216
+ chosen = choose_key_facts(evidence, customer=customer, max_items=max_items)
1217
+ if not customer and not any(e.label in {"locality", "car_locality"} for e in chosen):
1218
+ locality = next((e for e in evidence if e.label in {"locality", "car_locality"}), None)
1219
+ if locality and all(e.value != locality.value for e in chosen):
1220
+ chosen = (chosen[: max_items - 1] + [locality])[:max_items]
1221
+ return chosen
1222
+
1223
+ chosen: list[Evidence] = []
1224
+ seen_values: set[str] = set()
1225
+ sorted_e = sorted(
1226
+ [e for e in evidence if e.kind != "identity"],
1227
+ key=lambda e: (priority.get(e.label, 50), -e.weight),
1228
+ )
1229
+ for e in sorted_e:
1230
+ if e.value in seen_values:
1231
+ continue
1232
+ chosen.append(e)
1233
+ seen_values.add(e.value)
1234
+ if len(chosen) >= max_items:
1235
+ break
1236
+ if len(chosen) < max_items:
1237
+ for e in choose_key_facts(evidence, customer=customer, max_items=max_items):
1238
+ if e.value not in seen_values:
1239
+ chosen.append(e)
1240
+ seen_values.add(e.value)
1241
+ if len(chosen) >= max_items:
1242
+ break
1243
+ return chosen
1244
+
1245
+
1246
+ def format_fact(e: Evidence) -> str:
1247
+ label = e.label.replace("car_", "")
1248
+ readable = {
1249
+ "views_30d": "30d views",
1250
+ "calls_30d": "30d calls",
1251
+ "ctr": "CTR",
1252
+ "active_offer": "active offer",
1253
+ "category_offer": "category hook",
1254
+ "intent_topic": "merchant asked about",
1255
+ "merchant_last_message": "merchant replied",
1256
+ "digest_title": "digest item",
1257
+ "digest_source": "source",
1258
+ "trial_n": "sample size",
1259
+ "digest_summary_fact": "digest fact",
1260
+ "days_inactive": "inactive for",
1261
+ "locality": "locality",
1262
+ "metric": "metric",
1263
+ "delta_pct": "delta",
1264
+ "available_slots": "slots",
1265
+ "molecule_list": "medicines",
1266
+ "affected_batches": "affected batches",
1267
+ }.get(label, label.replace("_", " "))
1268
+ if label == "views_30d":
1269
+ return f"{e.value} 30d views"
1270
+ if label == "calls_30d":
1271
+ return f"{e.value} 30d calls"
1272
+ if label == "ctr":
1273
+ return f"{e.value} CTR"
1274
+ if label == "days_inactive":
1275
+ return f"inactive for {e.value} days" if str(e.value).isdigit() else f"inactive for {e.value}"
1276
+ return f"{readable}: {e.value}"
1277
+
1278
+
1279
+ def primary_signal(trigger: Context, evidence: list[Evidence]) -> str:
1280
+ kind = signal_label(trigger.get("kind", "signal"))
1281
+ high = next((e.value for e in evidence if e.source.startswith("trigger") and e.kind != "identity"), "")
1282
+ return clean(f"{kind}: {high}") if high else kind
1283
+
1284
+
1285
+ def signal_label(kind: Any) -> str:
1286
+ label = clean(str(kind or "signal").replace("_", " "))
1287
+ return re.sub(r"\s+signal$", "", label, flags=re.I)
1288
+
1289
+
1290
+ def rationale_for(signal: str, evidence: list[Evidence], lever: str, action: str, risk_flags: list[str], frame: str = "effort_externalization", arm: str = "draft_action", map_scores: dict[str, int] | None = None, jitai_scores: dict[str, int] | None = None, principle: str = "liking", reference_key: str = "default") -> str:
1291
+ facts = ", ".join(f"{e.label}={e.value}" for e in choose_key_facts(evidence, max_items=3))
1292
+ risk = f" Risk flags: {', '.join(risk_flags)}." if risk_flags else ""
1293
+ map_part = f" B=MAP={map_scores}." if map_scores else ""
1294
+ jitai_part = f" JITAI={jitai_scores}." if jitai_scores else ""
1295
+ return clean(f"Selected signal '{signal}' with evidence ({facts}); frame={frame}; principle={principle}; arm={arm}; reference={reference_key}; lever={lever}; action={action}.{map_part}{jitai_part}{risk}")
1296
+
1297
+
1298
+ def category_voice_phrase(cat: str, customer: bool = False) -> str:
1299
+ if customer:
1300
+ return ""
1301
+ if cat == "dentists":
1302
+ return "Clinical angle: "
1303
+ if cat == "restaurants":
1304
+ return "Operator angle: "
1305
+ if cat == "gyms":
1306
+ return "Retention angle: "
1307
+ if cat == "pharmacies":
1308
+ return "Safe-action angle: "
1309
+ if cat == "salons":
1310
+ return "Service angle: "
1311
+ return ""
1312
+
1313
+
1314
+ def merchant_salutation(merchant: Context) -> str:
1315
+ identity = merchant.get("identity", {})
1316
+ owner = clean(str(identity.get("owner_first_name") or ""))
1317
+ name = clean(str(identity.get("name") or "there"))
1318
+ if merchant.get("category_slug") == "dentists":
1319
+ if owner:
1320
+ return owner if owner.lower().startswith("dr") else f"Dr. {owner}"
1321
+ return dedupe_dr(name)
1322
+ return owner or name
1323
+
1324
+
1325
+ def customer_name(customer: Context | None) -> str:
1326
+ if not customer:
1327
+ return "there"
1328
+ return clean(str(customer.get("identity", {}).get("name") or "there")).replace("(parent:", "parent:")
1329
+
1330
+
1331
+ def dedupe_evidence(evidence: list[Evidence]) -> list[Evidence]:
1332
+ out: list[Evidence] = []
1333
+ seen: set[tuple[str, str]] = set()
1334
+ for e in evidence:
1335
+ key = (e.label, e.value)
1336
+ if key in seen:
1337
+ continue
1338
+ seen.add(key)
1339
+ out.append(e)
1340
+ return out
1341
+
1342
+
1343
+ def first_numeric_fact(value: Any) -> str | None:
1344
+ if not value:
1345
+ return None
1346
+ match = re.search(r"\d+(?:\.\d+)?%|\d+(?:\.\d+)?\s?mSv|\d+(?:,\d+)*", str(value))
1347
+ return match.group(0) if match else None
1348
+
1349
+
1350
+ def pct(value: Any) -> str:
1351
+ try:
1352
+ num = float(value)
1353
+ except (TypeError, ValueError):
1354
+ return ""
1355
+ return f"{num * 100:.0f}%" if abs(num) <= 1 else f"{num:g}%"
1356
+
1357
+
1358
+ def clean(value: str) -> str:
1359
+ return re.sub(r"\s+", " ", value).strip()
1360
+
1361
+
1362
+ def dedupe_dr(value: str) -> str:
1363
+ return re.sub(r"\bDr\.\s+Dr\.\s+", "Dr. ", value).strip()
1364
+
1365
+
1366
+ def final_scrub(value: str) -> str:
1367
+ value = dedupe_dr(value)
1368
+ value = value.replace("None", "")
1369
+ value = value.replace("..", ".")
1370
+ value = re.sub(r"\b(up|down|dropped|rose|increased)\s+0%\b", "changed in the latest context", value, flags=re.I)
1371
+ value = re.sub(r"\s+([?.!,;:])", r"\1", value)
1372
+ return clean(value)
app/main.py ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ import os
5
+ from time import time
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import JSONResponse
11
+ from pydantic import BaseModel, Field
12
+
13
+ from .composer import (
14
+ compose,
15
+ is_auto_reply,
16
+ is_commitment,
17
+ is_stop_or_hostile,
18
+ make_conversation_id,
19
+ reply_to_message,
20
+ )
21
+ from .decision_engine import expected_trigger_score
22
+
23
+
24
+ START = time()
25
+ VALID_SCOPES = {"category", "merchant", "customer", "trigger"}
26
+
27
+ app = FastAPI(title="Vera Rubric Decision Engine", version="1.1.0")
28
+
29
+ contexts: dict[tuple[str, str], dict[str, Any]] = {}
30
+ conversations: dict[str, dict[str, Any]] = {}
31
+ suppressed: dict[str, str] = {}
32
+ merchant_opt_out: set[str] = set()
33
+ merchant_auto_replies: dict[str, dict[str, Any]] = {}
34
+ merchant_action_memory: dict[str, dict[str, Any]] = {}
35
+ category_arm_pool: dict[str, dict[str, list[float]]] = {}
36
+
37
+
38
+ class ContextBody(BaseModel):
39
+ scope: str
40
+ context_id: str
41
+ version: int
42
+ payload: dict[str, Any]
43
+ delivered_at: str | None = None
44
+
45
+
46
+ class TickBody(BaseModel):
47
+ now: str
48
+ available_triggers: list[str] = Field(default_factory=list)
49
+ limit: int = 20
50
+
51
+
52
+ class ReplyBody(BaseModel):
53
+ conversation_id: str
54
+ merchant_id: str | None = None
55
+ customer_id: str | None = None
56
+ from_role: str
57
+ message: str
58
+ received_at: str | None = None
59
+ turn_number: int = 1
60
+
61
+
62
+ @app.exception_handler(RequestValidationError)
63
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
64
+ return JSONResponse(
65
+ status_code=400,
66
+ content={
67
+ "accepted": False,
68
+ "reason": "malformed",
69
+ "details": exc.errors(),
70
+ "path": request.url.path,
71
+ },
72
+ )
73
+
74
+
75
+ @app.get("/v1/healthz")
76
+ async def healthz() -> dict[str, Any]:
77
+ counts = {scope: 0 for scope in VALID_SCOPES}
78
+ for scope, _ in contexts:
79
+ counts[scope] = counts.get(scope, 0) + 1
80
+ return {
81
+ "status": "ok",
82
+ "uptime_seconds": int(time() - START),
83
+ "contexts_loaded": counts,
84
+ "conversations": len(conversations),
85
+ }
86
+
87
+
88
+ @app.get("/v1/metadata")
89
+ async def metadata() -> dict[str, Any]:
90
+ return {
91
+ "team_name": os.getenv("TEAM_NAME", "Vera Rubric Decision Engine"),
92
+ "team_members": [os.getenv("TEAM_MEMBER", "Mokshagna")],
93
+ "model": os.getenv("OPENAI_MODEL", "none-runtime-deterministic"),
94
+ "approach": "FastAPI stateful bot with CAR normalization, JITAI classification, B=MAP engagement gating, prospect/Cialdini framing, deterministic Tree-of-Thoughts/Best-of-N scoring, constitutional audit, empirical action priors, and optional validated LLM copy polish",
95
+ "runtime_llm_required": False,
96
+ "optional_llm_copy_polish": bool(os.getenv("OPENAI_API_KEY")),
97
+ "offline_openrouter_calibration": "available via scripts/geval_calibrate.py; never required by live /v1/tick",
98
+ "contact_email": os.getenv("CONTACT_EMAIL", "mokshagnak004@gmail.com"),
99
+ "version": "1.1.0",
100
+ "submitted_at": os.getenv("SUBMITTED_AT", _now_iso()),
101
+ }
102
+
103
+
104
+ @app.post("/v1/context")
105
+ async def push_context(body: ContextBody):
106
+ if body.scope not in VALID_SCOPES:
107
+ return JSONResponse(
108
+ status_code=400,
109
+ content={"accepted": False, "reason": "invalid_scope", "details": f"scope must be one of {sorted(VALID_SCOPES)}"},
110
+ )
111
+
112
+ key = (body.scope, body.context_id)
113
+ current = contexts.get(key)
114
+ if current and current["version"] > body.version:
115
+ return JSONResponse(
116
+ status_code=409,
117
+ content={"accepted": False, "reason": "stale_version", "current_version": current["version"]},
118
+ )
119
+ if current and current["version"] == body.version:
120
+ return {
121
+ "accepted": True,
122
+ "ack_id": f"ack_{body.context_id}_v{body.version}",
123
+ "stored_at": current.get("stored_at", _now_iso()),
124
+ "idempotent": True,
125
+ }
126
+
127
+ contexts[key] = {"version": body.version, "payload": body.payload, "stored_at": _now_iso()}
128
+ return {"accepted": True, "ack_id": f"ack_{body.context_id}_v{body.version}", "stored_at": contexts[key]["stored_at"]}
129
+
130
+
131
+ @app.post("/v1/tick")
132
+ async def tick(body: TickBody) -> dict[str, Any]:
133
+ actions: list[dict[str, Any]] = []
134
+ trigger_ids = body.available_triggers or [context_id for scope, context_id in contexts if scope == "trigger"]
135
+ limit = max(1, min(20, int(body.limit or 20)))
136
+ ranked = sorted(
137
+ (_trigger_record(tid) for tid in trigger_ids),
138
+ key=lambda item: _trigger_score(item[1]) if item else -999,
139
+ reverse=True,
140
+ )
141
+
142
+ for item in ranked:
143
+ if not item or len(actions) >= limit:
144
+ continue
145
+ trigger_id, trigger = item
146
+ merchant_id = trigger.get("merchant_id") or trigger.get("payload", {}).get("merchant_id")
147
+ customer_id = trigger.get("customer_id") or trigger.get("payload", {}).get("customer_id")
148
+ suppression_key = _safe_suppression_key(trigger, merchant_id, body.now)
149
+ if not merchant_id or merchant_id in merchant_opt_out:
150
+ continue
151
+ trigger_version = str(contexts.get(("trigger", trigger_id), {}).get("version", "0"))
152
+ if suppressed.get(suppression_key) == trigger_version:
153
+ continue
154
+ if _is_expired(trigger, body.now):
155
+ continue
156
+
157
+ merchant = _payload("merchant", merchant_id)
158
+ if not merchant:
159
+ continue
160
+ merchant = {**merchant, "__vera_memory": _merchant_memory_snapshot(merchant_id)}
161
+ category = _payload("category", merchant.get("category_slug") or trigger.get("payload", {}).get("category"))
162
+ if not category:
163
+ continue
164
+ customer = _payload("customer", customer_id) if customer_id else None
165
+ if customer_id and not customer:
166
+ continue
167
+
168
+ composed = compose(category, merchant, trigger, customer)
169
+ if composed.get("cta") == "none" and trigger.get("scope") == "customer":
170
+ continue
171
+ if _merchant_has_recent_body(merchant_id, composed.get("body", "")):
172
+ _record_response(merchant_id, "no_reply")
173
+ continue
174
+
175
+ conversation_id = make_conversation_id(merchant_id, trigger_id, customer_id)
176
+ if conversation_id in conversations and conversations[conversation_id].get("ended"):
177
+ continue
178
+
179
+ action = {
180
+ "conversation_id": conversation_id,
181
+ "merchant_id": merchant_id,
182
+ "customer_id": customer_id,
183
+ "send_as": composed["send_as"],
184
+ "trigger_id": trigger_id,
185
+ "template_name": _template_name(composed["send_as"], trigger.get("kind")),
186
+ "template_params": _template_params(composed["body"]),
187
+ "body": composed["body"],
188
+ "cta": composed["cta"],
189
+ "suppression_key": suppression_key,
190
+ "rationale": composed["rationale"],
191
+ "decision_plan": composed.get("decision_plan", {}),
192
+ }
193
+ suppressed[suppression_key] = trigger_version
194
+ conversations[conversation_id] = {
195
+ "merchant_id": merchant_id,
196
+ "customer_id": customer_id,
197
+ "trigger_id": trigger_id,
198
+ "action_arm": composed.get("decision_plan", {}).get("action_arm", _template_name(composed["send_as"], trigger.get("kind"))),
199
+ "turns": [{"from": "vera", "body": composed["body"], "ts": body.now}],
200
+ "auto_reply_count": 0,
201
+ "last_auto_reply": None,
202
+ "last_body": composed["body"],
203
+ "ended": False,
204
+ }
205
+ _record_action_sent(merchant_id, conversations[conversation_id]["action_arm"])
206
+ actions.append(action)
207
+
208
+ return {"actions": actions}
209
+
210
+
211
+ @app.post("/v1/reply")
212
+ async def reply(body: ReplyBody) -> dict[str, Any]:
213
+ conv = conversations.setdefault(
214
+ body.conversation_id,
215
+ {
216
+ "merchant_id": body.merchant_id,
217
+ "customer_id": body.customer_id,
218
+ "trigger_id": None,
219
+ "turns": [],
220
+ "auto_reply_count": 0,
221
+ "last_auto_reply": None,
222
+ "last_body": "",
223
+ "ended": False,
224
+ },
225
+ )
226
+ conv["merchant_id"] = conv.get("merchant_id") or body.merchant_id
227
+ conv["customer_id"] = conv.get("customer_id") or body.customer_id
228
+ if conv.get("ended"):
229
+ return {
230
+ "action": "end",
231
+ "rationale": "Conversation was already closed; refusing to send another message on the same conversation_id.",
232
+ }
233
+ conv.setdefault("turns", []).append({"from": body.from_role, "body": body.message, "ts": body.received_at or _now_iso()})
234
+
235
+ lower = (body.message or "").lower()
236
+ merchant_id = body.merchant_id or conv.get("merchant_id")
237
+ if is_auto_reply(body.message):
238
+ if conv.get("last_auto_reply") == body.message:
239
+ conv["auto_reply_count"] = int(conv.get("auto_reply_count", 0)) + 1
240
+ else:
241
+ conv["auto_reply_count"] = max(1, int(conv.get("auto_reply_count", 0)))
242
+ conv["last_auto_reply"] = body.message
243
+ if merchant_id:
244
+ mx_auto = merchant_auto_replies.setdefault(merchant_id, {"last": None, "count": 0})
245
+ if mx_auto.get("last") == body.message:
246
+ mx_auto["count"] = int(mx_auto.get("count", 0)) + 1
247
+ else:
248
+ mx_auto["last"] = body.message
249
+ mx_auto["count"] = 1
250
+ conv["auto_reply_count"] = max(int(conv.get("auto_reply_count", 0)), int(mx_auto.get("count", 0)))
251
+
252
+ merchant = _payload("merchant", merchant_id)
253
+ trigger = _payload("trigger", conv.get("trigger_id")) or {}
254
+ result = reply_to_message(body.message, conv, merchant, trigger)
255
+
256
+ if result.get("action") == "send" and result.get("body") == conv.get("last_body"):
257
+ result = {"action": "wait", "wait_seconds": 1800, "rationale": "Avoiding repeated body in the same conversation."}
258
+
259
+ if result.get("action") == "end":
260
+ conv["ended"] = True
261
+ if merchant_id and is_stop_or_hostile(lower):
262
+ merchant_opt_out.add(merchant_id)
263
+ _record_response(merchant_id, "stop")
264
+ elif merchant_id:
265
+ _record_response(merchant_id, "end")
266
+ elif result.get("action") == "send":
267
+ conv["last_body"] = result.get("body", "")
268
+ conv.setdefault("turns", []).append({"from": "vera", "body": result.get("body"), "ts": _now_iso()})
269
+ if merchant_id:
270
+ _record_response(merchant_id, "commitment" if is_commitment(lower) else "reply")
271
+ elif merchant_id and is_auto_reply(body.message):
272
+ _record_response(merchant_id, "auto_reply")
273
+ elif merchant_id:
274
+ _record_response(merchant_id, "reply")
275
+
276
+ return result
277
+
278
+
279
+ @app.post("/v1/teardown")
280
+ async def teardown() -> dict[str, Any]:
281
+ contexts.clear()
282
+ conversations.clear()
283
+ suppressed.clear()
284
+ merchant_opt_out.clear()
285
+ merchant_auto_replies.clear()
286
+ merchant_action_memory.clear()
287
+ category_arm_pool.clear()
288
+ return {"status": "ok", "cleared": True, "at": _now_iso()}
289
+
290
+
291
+ def _payload(scope: str, context_id: str | None) -> dict[str, Any] | None:
292
+ if not context_id:
293
+ return None
294
+ record = contexts.get((scope, context_id))
295
+ return record.get("payload") if record else None
296
+
297
+
298
+ def _trigger_record(trigger_id: str) -> tuple[str, dict[str, Any]] | None:
299
+ trigger = _payload("trigger", trigger_id)
300
+ if not trigger:
301
+ return None
302
+ return trigger_id, trigger
303
+
304
+
305
+ def _trigger_score(trigger: dict[str, Any]) -> int:
306
+ score = int(trigger.get("urgency", 1)) * 10
307
+ kind = trigger.get("kind", "")
308
+ merchant_id = trigger.get("merchant_id") or trigger.get("payload", {}).get("merchant_id")
309
+ merchant = _payload("merchant", merchant_id)
310
+ category = _payload("category", merchant.get("category_slug")) if merchant else None
311
+ customer_id = trigger.get("customer_id") or trigger.get("payload", {}).get("customer_id")
312
+ customer = _payload("customer", customer_id) if customer_id else None
313
+ proxy = expected_trigger_score(category, merchant, trigger, customer) if merchant and category else 0
314
+ score += proxy
315
+ if trigger.get("scope") == "customer":
316
+ score += 12
317
+ if kind in {"supply_alert", "regulation_change", "active_planning_intent", "perf_dip", "renewal_due"}:
318
+ score += 10
319
+ if kind in {"curious_ask_due", "festival_upcoming", "milestone_reached"}:
320
+ score -= 2
321
+ return score
322
+
323
+
324
+ def _safe_suppression_key(trigger: dict[str, Any], merchant_id: str, now_value: str) -> str:
325
+ raw = str(trigger.get("suppression_key") or "").strip()
326
+ kind = str(trigger.get("kind") or "generic").strip() or "generic"
327
+ trigger_id = str(trigger.get("id") or "").strip()
328
+ if raw and raw not in {kind, f"{merchant_id}:{kind}", f"{kind}:{merchant_id}"}:
329
+ return raw
330
+ date_part = (str(trigger.get("expires_at") or now_value).split("T")[0]) or "undated"
331
+ unique = trigger_id or str(abs(hash(json_fingerprint(trigger.get("payload", {})))))
332
+ return f"{merchant_id}:{kind}:{date_part}:{unique}"
333
+
334
+
335
+ def json_fingerprint(value: Any) -> str:
336
+ try:
337
+ import json
338
+ return json.dumps(value, sort_keys=True, separators=(",", ":"))
339
+ except TypeError:
340
+ return str(value)
341
+
342
+
343
+ def _is_expired(trigger: dict[str, Any], now_value: str) -> bool:
344
+ expires = trigger.get("expires_at")
345
+ if not expires:
346
+ return False
347
+ try:
348
+ exp = datetime.fromisoformat(expires.replace("Z", "+00:00"))
349
+ now = datetime.fromisoformat(now_value.replace("Z", "+00:00"))
350
+ except ValueError:
351
+ return False
352
+ return exp < now
353
+
354
+
355
+ def _template_name(send_as: str, kind: str | None) -> str:
356
+ prefix = "merchant" if send_as == "merchant_on_behalf" else "vera"
357
+ safe_kind = (kind or "generic").replace("-", "_")
358
+ return f"{prefix}_{safe_kind}_v1"
359
+
360
+
361
+ def _template_params(body: str) -> list[str]:
362
+ if len(body) <= 160:
363
+ return [body]
364
+ return [body[:120].strip(), body[120:360].strip()]
365
+
366
+
367
+ def _merchant_has_recent_body(merchant_id: str, body: str) -> bool:
368
+ if not body:
369
+ return False
370
+ for conv in conversations.values():
371
+ if conv.get("merchant_id") == merchant_id and conv.get("last_body") == body and not conv.get("ended"):
372
+ return True
373
+ return False
374
+
375
+
376
+ def _merchant_memory_snapshot(merchant_id: str) -> dict[str, Any]:
377
+ memory = merchant_action_memory.get(merchant_id, {})
378
+ merchant = _payload("merchant", merchant_id) or {}
379
+ category = merchant.get("category_slug", "")
380
+ return {
381
+ "last_action_type": memory.get("last_action_type", ""),
382
+ "last_response_intent": memory.get("last_response_intent", ""),
383
+ "repeated_action_count": int(memory.get("repeated_action_count", 0)),
384
+ "no_reply_count": int(memory.get("no_reply_count", 0)),
385
+ "action_sequence": list(memory.get("action_sequence", []))[-5:],
386
+ "response_sequence": list(memory.get("response_sequence", []))[-5:],
387
+ "reflection_note": memory.get("reflection_note", ""),
388
+ "category_arm_priors": _category_arm_priors(category),
389
+ }
390
+
391
+
392
+ def _record_action_sent(merchant_id: str, action_arm: str) -> None:
393
+ memory = merchant_action_memory.setdefault(
394
+ merchant_id,
395
+ {"action_sequence": [], "response_sequence": [], "repeated_action_count": 0, "no_reply_count": 0},
396
+ )
397
+ if memory.get("last_action_type") == action_arm:
398
+ memory["repeated_action_count"] = int(memory.get("repeated_action_count", 0)) + 1
399
+ else:
400
+ memory["repeated_action_count"] = 1
401
+ memory["last_action_type"] = action_arm
402
+ memory["last_response_intent"] = memory.get("last_response_intent", "")
403
+ sequence = list(memory.get("action_sequence", []))
404
+ sequence.append(action_arm)
405
+ memory["action_sequence"] = sequence[-8:]
406
+
407
+
408
+ def _record_response(merchant_id: str, intent: str) -> None:
409
+ memory = merchant_action_memory.setdefault(
410
+ merchant_id,
411
+ {"action_sequence": [], "response_sequence": [], "repeated_action_count": 0, "no_reply_count": 0},
412
+ )
413
+ memory["last_response_intent"] = intent
414
+ if intent in {"auto_reply", "no_reply"}:
415
+ memory["no_reply_count"] = int(memory.get("no_reply_count", 0)) + 1
416
+ memory["reflection_note"] = "Previous action met auto/no reply; shorten CTA and switch away from repeated action."
417
+ elif intent in {"commitment", "reply"}:
418
+ memory["no_reply_count"] = 0
419
+ memory["reflection_note"] = "Previous action got engagement; keep concrete artifact handoff and avoid asking another broad question."
420
+ elif intent == "stop":
421
+ memory["reflection_note"] = "Merchant opted out; do not send further messages."
422
+ sequence = list(memory.get("response_sequence", []))
423
+ sequence.append(intent)
424
+ memory["response_sequence"] = sequence[-8:]
425
+ _record_category_reward(merchant_id, str(memory.get("last_action_type") or ""), intent)
426
+
427
+
428
+ def _record_category_reward(merchant_id: str, action_arm: str, intent: str) -> None:
429
+ if not action_arm:
430
+ return
431
+ merchant = _payload("merchant", merchant_id) or {}
432
+ category = merchant.get("category_slug")
433
+ if not category:
434
+ return
435
+ reward = {
436
+ "commitment": 0.95,
437
+ "reply": 0.7,
438
+ "end": 0.25,
439
+ "auto_reply": 0.2,
440
+ "no_reply": 0.2,
441
+ "stop": 0.0,
442
+ }.get(intent, 0.5)
443
+ pool = category_arm_pool.setdefault(category, {})
444
+ values = list(pool.get(action_arm, []))
445
+ values.append(reward)
446
+ pool[action_arm] = values[-20:]
447
+
448
+
449
+ def _category_arm_priors(category: str) -> dict[str, float]:
450
+ pool = category_arm_pool.get(category, {})
451
+ priors: dict[str, float] = {}
452
+ for arm, rewards in pool.items():
453
+ if rewards:
454
+ priors[arm] = round(sum(rewards) / len(rewards), 3)
455
+ return priors
456
+
457
+
458
+ def _now_iso() -> str:
459
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
bot.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.composer import compose as _compose
4
+
5
+
6
+ def compose(category: dict, merchant: dict, trigger: dict, customer: dict | None = None) -> dict:
7
+ """Offline challenge contract: deterministic compose(category, merchant, trigger, customer?)."""
8
+ return _compose(category, merchant, trigger, customer)
9
+
challenge-brief.md ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # magicpin AI Challenge — Build a Merchant AI Assistant ("Vera")
2
+
3
+ **Status**: Brief — designed to be loaded as standalone context in a fresh AI session.
4
+ **Last updated**: 2026-04-26
5
+ **Audience**: Challenge participants + the AI judge that will evaluate submissions.
6
+
7
+ > **Read me first** — this document is fully self-contained. A reader (human or LLM) starting cold should be able to understand the company, the product, the challenge, the framework, the dataset, and the evaluation rubric without needing any other reference.
8
+
9
+ ---
10
+
11
+ ## 1. The challenge in one sentence
12
+
13
+ > Build an AI chatbot that engages and assists merchants on WhatsApp the way **Vera** (magicpin's merchant-AI assistant) does — but better. Same base dataset for every participant. AI judges the outcome.
14
+
15
+ ---
16
+
17
+ ## 2. About magicpin (background, ~30 sec read)
18
+
19
+ magicpin is one of India's largest local-commerce platforms — a network of ~100,000 merchant partners across 50+ Indian cities (restaurants, salons, gyms, dentists, retailers, car dealers, etc.). Customers discover merchants on the magicpin app/web, transact, and earn cashback. Merchants benefit from visibility, walk-ins, and online orders.
20
+
21
+ magicpin runs a marketing-assistant product called **Vera** that talks to merchants over WhatsApp, helps them grow their Google Business Profile (GBP), runs campaigns for them, and answers customer questions on their behalf. Vera is the product you're rebuilding.
22
+
23
+ ---
24
+
25
+ ## 3. About Vera (the product you're rebuilding)
26
+
27
+ ### What Vera does today
28
+ - Talks to ~6,000-10,000 merchants per day over WhatsApp
29
+ - Helps them improve their Google Business Profile (photos, hours, posts, reviews)
30
+ - Runs marketing campaigns for them (festival, news, local events)
31
+ - Recommends pricing, offers, and content
32
+ - Answers customers' questions about merchants on their behalf
33
+
34
+ ### Live engagement shape (real numbers — Apr 23-25, 2026)
35
+
36
+ | Metric | Apr 23 | Apr 24 | Apr 25 |
37
+ |---|---:|---:|---:|
38
+ | Merchants who engaged with Vera | 6,917 | 10,158 | 5,181 |
39
+ | Total messages mx ↔ Vera | 34,022 | 47,651 | 23,712 |
40
+ | Avg messages per engaged merchant | 4.9 | 4.7 | 4.6 |
41
+ | Customers who engaged with Vera | 720 | 707 | 681 |
42
+ | Unique merchants customers asked about | 580 | 514 | 466 |
43
+ | Total messages cx ↔ Vera | 3,713 | 4,839 | 4,695 |
44
+ | Avg messages per customer | 5.2 | 6.8 | 6.9 |
45
+
46
+ ### Today's biggest pain points (your opportunities to outperform Vera)
47
+
48
+ 1. **Auto-reply pollution**: 40-70% of "merchant replies" are the merchant's own WhatsApp Business canned auto-reply ("Thank you for contacting…"). Production Vera detects this but burns 2-3 turns each time. **Better detection + faster routing wins**.
49
+ 2. **Intent-handoff failures**: When a merchant says "I want to join," production Vera often goes back to qualifying questions instead of starting the action. **Better intent detection wins**.
50
+ 3. **Generic copy**: discount-style offers ("10% off") rarely engage Indian merchants. Service+price ("Haircut @ ₹99", "Dental Cleaning @ ₹299") works better. **Compelling, category-correct copy wins**.
51
+ 4. **Low engagement frequency**: Functional nudges (renewal due, profile incomplete) are inherently rare. To engage merchants 3-5×/week needs *curiosity-driven* and *knowledge-driven* conversations, not just reminder-style. **Diversified conversation portfolio wins**.
52
+
53
+ ---
54
+
55
+ ## 4. The 4-context framework (what your bot must work with)
56
+
57
+ Every message Vera sends — whether to the merchant or the merchant's customer — is composed from **four context layers**. Your bot will receive these as structured input and must produce the next message.
58
+
59
+ ```
60
+ your_bot.compose(category, merchant, trigger, customer?) → message
61
+ ```
62
+
63
+ ### 4.1 CategoryContext
64
+
65
+ Slow-changing knowledge pack about *the kind of business this is*. Shared across all merchants in the vertical.
66
+
67
+ ```python
68
+ @dataclass
69
+ class CategoryContext:
70
+ slug: str # "dentists", "salons", "restaurants", ...
71
+ offer_catalog: list[OfferTemplate] # canonical service+price patterns for this vertical
72
+ voice: VoiceProfile # tone, allowed vocabulary, taboos
73
+ peer_stats: PeerStats # benchmarks: avg rating, avg reviews, typical CTR
74
+ digest: list[DigestItem] # weekly research / compliance / trend items, source-cited
75
+ patient_content_library: list[ContentItem] # content the merchant can reshare with customers
76
+ seasonal_beats: list[SeasonalBeat] # e.g., "exam-stress bruxism spikes Nov-Feb"
77
+ trend_signals: list[TrendSignal] # e.g., "clear aligners Delhi searches +62% YoY"
78
+ ```
79
+
80
+ **Example for `dentists`**:
81
+ - voice: technical terms welcome ("fluoride varnish at 3-month recall"), legal taboos ("cure", "guaranteed"), peer tone not hype
82
+ - offer_catalog: `["Dental Cleaning @ ₹299", "Free Consultation", "Teeth Whitening @ ₹1,499"]`
83
+ - peer_stats: `{avg_rating: 4.4, avg_reviews: 62, avg_ctr: 0.030}` (South Delhi solo practices)
84
+ - digest: this week's items from JIDA / DCI / Dental Tribune India / IDA Delhi calendar
85
+ - seasonal_beats: `[{month: "Nov-Feb", note: "exam-stress bruxism spike"}, {month: "Oct-Dec", note: "wedding whitening peak"}]`
86
+
87
+ ### 4.2 MerchantContext
88
+
89
+ The specific business's current state. Refreshed daily for performance, real-time for conversation history.
90
+
91
+ ```python
92
+ @dataclass
93
+ class MerchantContext:
94
+ merchant_id: str
95
+ identity: Identity # name, place_id, locality, city, verified, languages
96
+ subscription: Subscription # status, days_remaining, plan
97
+ performance: PerformanceSnapshot # views/calls/CTR/leads/directions, 30d + 7d deltas
98
+ offers: list[MerchantOffer] # active + paused offers from their catalog
99
+ conversation_history: ConversationHistory # last N turns w/ Vera, w/ engagement tags
100
+ customer_aggregate: CustomerAggregate # active count, lapsed count, retention %
101
+ signals: list[DerivedSignal] # "stale_posts", "ctr_below_peer", "dormant", ...
102
+ ```
103
+
104
+ **Running example — Dr. Meera's Dental Clinic**:
105
+ - identity: name="Dr. Meera's Dental Clinic", city="Delhi", locality="Lajpat Nagar", verified=true
106
+ - subscription: status="active", days_remaining=82, plan="Pro"
107
+ - performance (30d): views=2410, calls=18, directions=45, ctr=0.021
108
+ - offers: `["Dental Cleaning @ ₹299"]` active; `["Deep Cleaning @ ₹499"]` expired 2mo ago
109
+ - customer_aggregate: 540 unique patients YTD, 78 lapsed >180 days, 38% 6mo retention
110
+ - signals: `["stale_posts" (last post 22d ago), "ctr_below_peer_median" (peer median 0.030)]`
111
+
112
+ ### 4.3 TriggerContext
113
+
114
+ The *event* that prompts this message right now. Every message must have one.
115
+
116
+ ```python
117
+ @dataclass
118
+ class TriggerContext:
119
+ id: str
120
+ scope: Literal["merchant", "customer"]
121
+ kind: str # "research_digest", "recall_due", "perf_spike", ...
122
+ source: Literal["external", "internal"]
123
+ payload: dict
124
+ urgency: int # 1-5
125
+ suppression_key: str # for dedup
126
+ expires_at: datetime
127
+ ```
128
+
129
+ **External triggers** (happen outside the merchant's account):
130
+ - `festival_upcoming` (Diwali in 4 days)
131
+ - `weather_heatwave` (42°C today in Delhi)
132
+ - `local_news_event` (Mumbai-Pune expressway closed 3h)
133
+ - `category_research_digest_release` (JIDA Oct issue dropped)
134
+ - `regulation_change` (DCI radiograph dose limit revised)
135
+ - `competitor_opened` (new dentist 1.3km away on GBP)
136
+ - `category_trend_movement` ("clear aligners Delhi" searches +62%)
137
+
138
+ **Internal triggers** (happen within the merchant's data):
139
+ - `perf_spike` (yesterday's views +28% vs avg)
140
+ - `perf_dip` (calls dropped 40% week-over-week)
141
+ - `milestone_reached` (crossed 100 reviews)
142
+ - `dormant_with_vera` (no merchant message in 14 days)
143
+ - `customer_lapsed_soft` (a customer's 6mo recall window opens)
144
+ - `appointment_tomorrow` (booking exists for next day)
145
+ - `review_theme_emerged` (3 reviews this week mention "wait time")
146
+ - `scheduled_recurring` (weekly Friday curious-ask cadence)
147
+
148
+ **Example trigger for Dr. Meera**:
149
+ ```json
150
+ {
151
+ "id": "trg_2026_04_26_research_digest_dentists",
152
+ "scope": "merchant",
153
+ "kind": "research_digest",
154
+ "source": "external",
155
+ "payload": {
156
+ "category": "dentists",
157
+ "top_item": {
158
+ "title": "3-mo fluoride recall cuts caries recurrence 38% better than 6-mo",
159
+ "source": "JIDA Oct 2026, p.14",
160
+ "trial_n": 2100,
161
+ "patient_segment": "high-risk adults"
162
+ }
163
+ },
164
+ "urgency": 2,
165
+ "suppression_key": "research:dentists:2026-W17",
166
+ "expires_at": "2026-05-03T00:00:00Z"
167
+ }
168
+ ```
169
+
170
+ ### 4.4 CustomerContext (optional — only for customer-facing messages)
171
+
172
+ When the bot sends a message *on behalf of the merchant* to one of the merchant's own customers, this is populated.
173
+
174
+ ```python
175
+ @dataclass
176
+ class CustomerContext:
177
+ customer_id: str
178
+ merchant_id: str # which merchant this customer belongs to
179
+ identity: CustomerIdentity # name, phone, language preference
180
+ relationship: Relationship # first_visit, last_visit, visits_total, services_received
181
+ state: Literal["new", "active", "lapsed_soft", "lapsed_hard", "churned"]
182
+ preferences: Preferences # preferred_time, channel, opt-in scope
183
+ consent: Consent # when + how they opted in to merchant outreach
184
+ ```
185
+
186
+ **Example — Priya, a patient of Dr. Meera**:
187
+ - identity: name="Priya", phone="<redacted>", language_pref="hi-en mix"
188
+ - relationship: first_visit="2025-11-04", last_visit="2026-05-12", visits_total=4, services=["cleaning", "cleaning", "whitening", "cleaning"]
189
+ - state: "lapsed_soft" (5mo since last visit)
190
+ - preferences: weekday evening slots (derived from booking history)
191
+ - consent: opted in via merchant on 2025-11-04, scope=["recall_reminders", "appointment_reminders"]
192
+
193
+ ---
194
+
195
+ ## 5. The composition you must implement
196
+
197
+ ```
198
+ def compose(
199
+ category: CategoryContext,
200
+ merchant: MerchantContext,
201
+ trigger: TriggerContext,
202
+ customer: Optional[CustomerContext] = None
203
+ ) -> ComposedMessage:
204
+ """
205
+ Returns:
206
+ body — the WhatsApp message body
207
+ cta — the call-to-action (binary YES/STOP, open-ended, none)
208
+ send_as — "vera" (merchant-facing) or "merchant_on_behalf" (customer-facing)
209
+ suppression_key — for dedup
210
+ rationale — short explanation of why this message, what it should achieve
211
+ """
212
+ ```
213
+
214
+ ### Constraints your bot must respect
215
+
216
+ 1. **WhatsApp 24h session window** — the *first* outbound to a merchant or customer must use a pre-approved template (use any sensible template structure with `{{1}}/{{2}}/…` parameters; we won't actually call Meta). Subsequent messages within 24h of a merchant reply can be free-form.
217
+ 2. **Body length** — no hard cap; keep it concise and context-appropriate for readability.
218
+ 3. **Single primary CTA** — binary choice (YES/STOP) for action triggers; no CTA acceptable for pure-information triggers.
219
+ 4. **URLs** — allowed when they add clear value to the merchant.
220
+ 5. **Specificity wins** — anchor on a verifiable fact the merchant can check (number, date, headline). "X% off" is generic; "Haircut @ ₹99" is specific.
221
+ 6. **Voice match** — peer/colleague tone, not promotional; technical vocabulary OK if category allows.
222
+ 7. **Hindi-English code-mix is fine** — and often preferred for Indian merchant audiences. Match the merchant's language.
223
+ 8. **Don't fabricate** — if data isn't in the contexts, don't invent it. No fake offers, no fake research citations, no fake competitor names.
224
+
225
+ ---
226
+
227
+ ## 6. The base dataset (provided to every participant)
228
+
229
+ Every participant gets the same JSON dataset:
230
+
231
+ ```
232
+ dataset/
233
+ ├── categories/
234
+ │ ├── dentists.json
235
+ │ ├── salons.json
236
+ │ ├── restaurants.json
237
+ │ ├── gyms.json
238
+ │ └── pharmacies.json # 5 fully-populated CategoryContexts
239
+ ├── merchants/
240
+ │ ├── m_001_drmeera_dentist_delhi.json
241
+ │ ├── m_002_studio11_salon_hyderabad.json
242
+ │ ├── m_003_pizzajunction_restaurant_delhi.json
243
+ │ ├── ... # 50 fully-populated MerchantContexts (10 per category)
244
+ │ └── m_050_*.json
245
+ ├── customers/
246
+ │ ├── c_001_priya_for_m001.json
247
+ │ ├── c_002_rohit_for_m001.json
248
+ │ ├── ... # 200 customer profiles, distributed across merchants
249
+ │ └── c_200_*.json
250
+ └── triggers/
251
+ ├── trg_external_001_diwali.json
252
+ ├── trg_external_002_heatwave_delhi.json
253
+ ├── trg_internal_001_recall_due_priya.json
254
+ ├── trg_internal_002_perf_spike_m003.json
255
+ ├── ... # 100 sample triggers covering all kinds
256
+ └── trg_100_*.json
257
+ ```
258
+
259
+ Each trigger references one merchant (via `payload.merchant_id`) and optionally one customer. Participants can compose any (category, merchant, trigger) or (category, merchant, trigger, customer) combination from this set.
260
+
261
+ A canonical "submission test set" is **30 specific (merchant, trigger) pairs** that all participants must produce a message for. This gives the AI judge a like-for-like comparison.
262
+
263
+ ---
264
+
265
+ ## 7. What participants submit
266
+
267
+ A single Python module + a JSONL file:
268
+
269
+ ### 7.1 `bot.py` (your code)
270
+
271
+ ```python
272
+ def compose(category: dict, merchant: dict, trigger: dict, customer: dict | None) -> dict:
273
+ """
274
+ Inputs are the dicts loaded from the dataset JSON.
275
+ Return a dict with keys: body, cta, send_as, suppression_key, rationale.
276
+ Free to use any LLM, any prompting strategy, any retrieval.
277
+ Must be deterministic given the same inputs (set temperature=0 if using LLMs).
278
+ Must complete in < 30s per call.
279
+ """
280
+ ...
281
+ ```
282
+
283
+ ### 7.2 `submission.jsonl` (30 lines, one per test pair)
284
+
285
+ ```json
286
+ {"test_id": "T01", "body": "Hi Dr. Meera, JIDA's Oct issue ...", "cta": "open_ended", "send_as": "vera", "suppression_key": "...", "rationale": "..."}
287
+ ```
288
+
289
+ ### 7.3 `README.md` (1 page max)
290
+
291
+ Briefly: your approach, what tradeoffs you made, what additional context would have helped you most.
292
+
293
+ ### 7.4 Optional: `conversation_handlers.py`
294
+
295
+ If you want to demonstrate multi-turn handling (replying to merchant responses), implement:
296
+
297
+ ```python
298
+ def respond(state: ConversationState, merchant_message: str) -> dict:
299
+ """Given the conversation so far + the merchant's latest message, produce the reply."""
300
+ ...
301
+ ```
302
+
303
+ Multi-turn capability is a tiebreaker, not a requirement.
304
+
305
+ ---
306
+
307
+ ## 8. Evaluation methodology (how AI judges)
308
+
309
+ Submissions are scored by an LLM judge across **5 dimensions**, each 0-10. Total = 50.
310
+
311
+ | Dimension | What the judge looks for |
312
+ |---|---|
313
+ | **Specificity** | Does the message anchor on a concrete, verifiable fact from the contexts (number, date, headline, peer stat)? Penalty for generic "10% off" / "increase your sales" framings. |
314
+ | **Category fit** | Does the voice, vocabulary, and offer format match the category? Dentist messages should sound clinical-peer, not retail-promo. |
315
+ | **Merchant fit** | Is the message personalized to *this specific* merchant's state (their numbers, their offers, their conversation history)? Is the language preference honored? |
316
+ | **Trigger relevance** | Does the message clearly communicate *why now* — the specific trigger that prompted it? Not "you should improve your profile" generically. |
317
+ | **Engagement compulsion** | Would a real merchant want to reply? Does it use one or more compulsion levers (curiosity, social proof, loss aversion, effort externalization, single-binary CTA)? |
318
+
319
+ ### The twist — post-submission context injection
320
+
321
+ After submissions close, the judge injects **additional context that participants didn't see**:
322
+ - New `digest` items added to category contexts (recent research/news/regulations)
323
+ - Updated `performance` snapshots on merchant contexts (their numbers shifted)
324
+ - New `triggers` (events that happened "after" submission)
325
+ - For 5 of the 30 test pairs: a populated `CustomerContext` is added (testing customer-facing capability)
326
+
327
+ The judge then asks the bot to produce **follow-up messages** for these enriched scenarios. Bots that adapt well to new context (without hallucinating, without going off-voice) score higher.
328
+
329
+ ### Replay test
330
+
331
+ For the 10 highest-scoring submissions, the judge runs a **simulated multi-turn replay**:
332
+ - Judge plays the merchant.
333
+ - Bot composes the first message.
334
+ - Judge generates a plausible merchant reply (sometimes engaged, sometimes auto-reply, sometimes a hard "not interested", sometimes a curveball question).
335
+ - Bot must respond.
336
+ - 3-5 turns deep.
337
+ - Judge scores how the conversation flows: did the bot detect auto-reply? Did it route action requests correctly? Did it know when to gracefully exit?
338
+
339
+ ---
340
+
341
+ ## 9. Reference: real Vera conversation patterns (anonymized excerpts)
342
+
343
+ These are real conversations from production Vera — provided as reference, not as the bar to clear (you should aim higher).
344
+
345
+ ### Pattern A — Real intent, real value exchange (the gold standard)
346
+
347
+ ```
348
+ [USER] Hi Vera. Please update my google profile.
349
+ [VERA] Hi! Priya, Vera se bol rahi hoon. Aapka Google profile abhi 62.5% complete hai
350
+ — description, website aur business hours missing hain. Kya aap yeh details
351
+ share kar sakte hain taaki main sab update kar sakun?
352
+ [USER] Ok please check & update the profile.
353
+ [VERA] Done! Maine aapka Google profile update kar diya hai:
354
+ - Business hours — daily 9 AM se 10 PM
355
+ - Description — restaurant description add kar diya
356
+ - Google Post — ek welcome post bhi publish kar diya
357
+ [USER] I can't see my profile is updated yet. It is showing temporarily closed.
358
+ [VERA] Google ke changes mein 24-48 ghante lagte hain. Kyunki aapka profile abhi
359
+ unverified hai, Google har update review karta hai. Tab tak main aur
360
+ cheezein add kar sakti hoon...
361
+ ```
362
+ **What's right**: Hindi-English mix, specific numbers (62.5%, 24-48 ghante), Vera does work + reports back, handles uncertainty honestly.
363
+
364
+ ### Pattern B — Auto-reply detection + graceful exit
365
+
366
+ ```
367
+ [VERA] Studio11 Family Salon ka Google profile check kar rahi thi — aapki 4.9★
368
+ rating hai 298 reviews ke saath... business description missing hai.
369
+ [MERCHANT] Aapki jaankari ke liye bahut-bahut shukriya. Main aapki yeh sabhi baatein
370
+ aur sujhaav hamari team tak pahuncha deti hoon. ← merchant's auto-reply
371
+ [VERA] Samajh gayi. Team tak pahunchane se pehle, kya aap khud dekhna chahingi
372
+ ki exact kya missing hai Google pe? 2 minute ka kaam hai. Chalega?
373
+ [MERCHANT] Aapki madad ke liye shukriya, lekin main ek automated assistant hoon...
374
+ [VERA] Koi baat nahi, samajh gayi. Main owner/manager se directly connect kar
375
+ lungi. Aapka salon accha chal raha hai — best wishes! 🙂
376
+ ```
377
+ **What's right**: Vera tried once after detecting auto-reply, then stopped wasting turns. Polite exit.
378
+
379
+ ### Pattern C — Strong specificity hook
380
+
381
+ ```
382
+ [VERA] Quick nudge: your dashboard shows 6,777 missed searches in Sector 14
383
+ for makeup services — people are looking but not finding you. Want me
384
+ to show how your listing would appear?
385
+ ```
386
+ **What's right**: "6,777" is verifiable; "Sector 14" is local; "missed searches" frames as loss aversion.
387
+
388
+ ### Pattern D — Intent-handoff failure (DON'T do this)
389
+
390
+ ```
391
+ [MERCHANT] Mujhe magicpin judrna hai. ← "I want to join magicpin" — clear intent
392
+ [VERA] Badhiya! Aapke liye simple rakhti hoon: agar aapko 10-15 naye customers
393
+ regular milne lagen to helpful hoga na? ← went back to qualifying
394
+ ```
395
+ **What's wrong**: Merchant said yes; Vera asked another qualification question. Lost momentum. Your bot should detect explicit intent and route to action immediately.
396
+
397
+ ---
398
+
399
+ ## 10. Compulsion levers — what makes merchants reply
400
+
401
+ These are the levers that drive engagement. Use one or more per message:
402
+
403
+ 1. **Specificity / verifiability** — concrete number, date, headline, source citation
404
+ 2. **Loss aversion** — "you're missing X" / "before this window closes"
405
+ 3. **Social proof** — "3 dentists in your locality did Y this month"
406
+ 4. **Effort externalization** — "I've drafted X — just say go" / "5-min setup"
407
+ 5. **Curiosity** — "want to see who?" / "want the full list?"
408
+ 6. **Reciprocity** — "I noticed Y about your account, thought you'd want to know"
409
+ 7. **Asking the merchant** — "what's your most-asked treatment this week?"
410
+ 8. **Single binary commitment** — Reply YES / STOP, not multi-choice
411
+
412
+ Production Vera's biggest miss is **#3 social proof** and **#7 asking the merchant** — these families barely fire today and would unlock a lot of engagement.
413
+
414
+ ---
415
+
416
+ ## 11. Anti-patterns the judge will penalize
417
+
418
+ - Generic offers ("Flat 30% off") when service+price is available ("Haircut @ ₹99")
419
+ - Multiple CTAs in one message ("Reply YES for X, NO for Y, MAYBE for Z")
420
+ - Buried call-to-action (the "what to do" should land in the last sentence)
421
+ - Promotional tone ("AMAZING DEAL!") for categories that need clinical/peer voice (dentists, doctors, lawyers)
422
+ - Hallucinated data (citing a "JIDA paper" not in the context, naming a competitor not in the context)
423
+ - Long preambles ("I hope you're doing well. I'm reaching out today to…")
424
+ - Re-introducing yourself after the first message
425
+ - Ignoring the language preference ("hi-en mix" merchant getting pure English)
426
+ - Sending the same message verbatim that was sent before (anti-repetition)
427
+
428
+ ---
429
+
430
+ ## 12. Open challenges (these are intentional — extra credit)
431
+
432
+ 1. **Detect auto-replies** vs. real merchant replies and route differently. (Hint: same message verbatim 3+ times = auto-reply.)
433
+ 2. **Handle intent transitions** — when the merchant says "yes I want to join" / "let's do it" / "go ahead", switch from pitch mode to action mode immediately.
434
+ 3. **Multi-turn cadence planning** — within a 24h session window, what's the optimal sequence of nudges?
435
+ 4. **Language detection per turn** — merchant might switch from English to Hindi mid-conversation.
436
+ 5. **Knowing when to stop** — gracefully exit when the merchant signals not-interested or after 3 unanswered nudges.
437
+
438
+ ---
439
+
440
+ ## 13. Suggested approach (one of many — you decide)
441
+
442
+ The challenge is LLM-agnostic. Most participants will:
443
+
444
+ 1. **Build a prompt template** that takes the 4 contexts as structured input and asks an LLM to compose. Likely best with a frontier model (Claude / GPT / Gemini / DeepSeek).
445
+ 2. **Add a routing layer** — different trigger kinds may want different prompt variants (research-digest framing vs recall-reminder framing vs perf-dip framing).
446
+ 3. **Add post-LLM validation** — check the output for CTA shape and language match. Re-prompt if it fails.
447
+ 4. **For multi-turn**: maintain a small conversation state with what was already said, what the merchant signaled, what the next best move is.
448
+
449
+ You can also use retrieval (e.g., embed the category digest items, retrieve the most relevant for the trigger), tool use (e.g., let the LLM call a "lookup peer stat" function), or any other architecture.
450
+
451
+ ---
452
+
453
+ ## 14. Logistics (placeholders — fill in for your run)
454
+
455
+ - **Eligibility**: <e.g., undergrad/grad CS or related, India-based or remote>
456
+ - **Team size**: solo or pairs
457
+ - **Submission window**: <e.g., 14 days from launch>
458
+ - **Deliverables**: as in §7
459
+ - **Compute reimbursement**: <e.g., ₹X for API costs>
460
+ - **Prizes**: <top 1 / top 3 / honorable mentions>
461
+ - **Selection outcomes**: <e.g., top N invited to final on-site round>
462
+
463
+ ---
464
+
465
+ ## 15. Dataset privacy + ethics
466
+
467
+ - The base dataset is **synthetic but realistic** — generated from anonymized templates, no real PII.
468
+ - Merchant names in samples are altered; phone numbers redacted.
469
+ - Participants must **not scrape** real magicpin / Google data; must not impersonate magicpin in any external test.
470
+ - All bot output is for the challenge only; not for real merchant outreach.
471
+
472
+ ---
473
+
474
+ ## 16. What gets shared with the judge in the new session
475
+
476
+ When a fresh AI session starts to evaluate submissions, the judge gets:
477
+
478
+ 1. **This brief** (the entire document you're reading) — full context.
479
+ 2. **The base dataset** (categories/, merchants/, customers/, triggers/).
480
+ 3. **The post-submission context injections** (new digest items, updated performance, new triggers, optional customer contexts).
481
+ 4. **The submitted bot artifacts** (`bot.py`, `submission.jsonl`, optional `conversation_handlers.py`, `README.md`).
482
+ 5. **The 5-dimension rubric** (§8) and the replay-test instructions.
483
+
484
+ That's everything the judge needs. No prior context from this session is required.
485
+
486
+ ---
487
+
488
+ ## Appendix A — Composition example (so the judge knows what good looks like)
489
+
490
+ **Inputs**:
491
+ - Category: `dentists` (voice=peer/clinical, peer_stats.avg_ctr=0.030, digest top item="JIDA Oct trial: 3-mo fluoride recall cuts caries 38% better")
492
+ - Merchant: Dr. Meera, Lajpat Nagar Delhi, CTR 2.1% (below peer), high-risk-adult patient cohort, last Vera touch 2 days ago (engaged)
493
+ - Trigger: `research_digest_release` (urgency 2, external)
494
+ - Customer: none
495
+
496
+ **Good composed message** (the bar):
497
+
498
+ ```
499
+ Dr. Meera, JIDA's Oct issue landed. One item relevant to your high-risk adult
500
+ patients — 2,100-patient trial showed 3-month fluoride recall cuts caries
501
+ recurrence 38% better than 6-month. Worth a look (2-min abstract). Want me to
502
+ pull it + draft a patient-ed WhatsApp you can share? — JIDA Oct 2026 p.14
503
+ ```
504
+
505
+ **Why it scores well**:
506
+ - Specificity: "2,100-patient", "38% better", "JIDA Oct 2026 p.14"
507
+ - Category fit: clinical vocabulary, source citation, peer tone
508
+ - Merchant fit: "your high-risk adult patients" (derived from customer aggregate)
509
+ - Trigger relevance: explicitly references the digest as the reason for messaging
510
+ - Engagement compulsion: curiosity ("Worth a look"), reciprocity ("I'll pull it for you"), low-friction CTA
511
+
512
+ ---
513
+
514
+ ## Appendix B — Customer-facing composition example
515
+
516
+ **Inputs**:
517
+ - Category: `dentists` (customer-facing voice rules: no "guaranteed", no medical claims)
518
+ - Merchant: Dr. Meera (active offer "Dental Cleaning @ ₹299", available slots Wed 6pm + Thu 5pm)
519
+ - Trigger: `recall_due` (scope=customer, urgency=3, internal)
520
+ - Customer: Priya (lapsed_soft, weekday-evening preference, hi-en mix language)
521
+
522
+ **Good composed message** (sent from merchant's WA number, drafted by bot):
523
+
524
+ ```
525
+ Hi Priya, Dr. Meera's clinic here 🦷 It's been 5 months since your last visit —
526
+ your 6-month cleaning recall is due. Apke liye 2 slots ready hain: Wed 6 Nov,
527
+ 6pm ya Thu 7 Nov, 5pm. ₹299 cleaning + complimentary fluoride. Reply 1 for
528
+ Wed, 2 for Thu, or tell us a time that works.
529
+ ```
530
+
531
+ **Why it scores well**:
532
+ - send_as = `merchant_on_behalf` (correctly attributed)
533
+ - Category fit: clinical, no overclaims
534
+ - Merchant fit: real catalog price, real open slots
535
+ - Trigger relevance: explicitly names the 6-month recall
536
+ - Customer fit: name, language mix, evening preference honored, multi-choice slot offer (allowed for booking flows)
537
+
538
+ ---
539
+
540
+ ## End of brief
541
+
542
+ This document is everything the next session needs. Save it as `challenge-brief.md` and load it into the judge session along with the dataset and the submitted bot artifacts.
543
+
544
+ Good luck. Build something better than today's Vera.
challenge-testing-brief.md ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # magicpin AI Challenge — Testing & Evaluation Brief
2
+
3
+ **Status**: Companion to `challenge-brief.md`. Defines the technical contract between candidate bots and magicpin's judging system.
4
+ **Last updated**: 2026-04-26
5
+ **Audience**: Candidates building the bot + magicpin engineers running the harness.
6
+
7
+ > **Read this with `challenge-brief.md`** — that brief defines what the bot must do (the 4-context framework, composition contract, evaluation rubric). This brief defines *how the bot is tested* (HTTP API, judge harness, scoring touchpoints).
8
+
9
+ ---
10
+
11
+ ## 1. The high-level model
12
+
13
+ ```
14
+ ┌──────────────────────────┐ ┌────────────────────────────┐
15
+ │ magicpin Judge Harness │ │ Candidate Bot (your code) │
16
+ │ (LLM playing merchant + │ │ HTTP server, public URL │
17
+ │ context injector + │ ──── HTTP/JSON ──► │ Stateful per-conversation │
18
+ │ scorer) │ ◄──── HTTP/JSON ── │ │
19
+ └──────────────────────────┘ └────────────────────────────┘
20
+ ```
21
+
22
+ Two information flows:
23
+
24
+ 1. **Judge → Bot**: pushes incremental context across all 4 dimensions (category, merchant, trigger, customer) over time. Mimics how production data updates flow into Vera.
25
+ 2. **Bot → Judge**: the bot proactively initiates conversations, and the judge plays the merchant (or customer) role, replying realistically. The bot must handle the full conversation.
26
+
27
+ The bot is **stateful** (must remember context pushed earlier and conversations in flight). The judge is the source of truth for what's happened.
28
+
29
+ ---
30
+
31
+ ## 2. Endpoints the candidate must expose
32
+
33
+ All endpoints are HTTPS, JSON in/out, UTF-8. Total surface: **5 endpoints**.
34
+
35
+ ### 2.1 `POST /v1/context` — receive a context push
36
+
37
+ The judge calls this whenever it wants the bot to know about new or updated context. Could be the initial base dataset, or an incremental update mid-test.
38
+
39
+ **Request body**:
40
+ ```json
41
+ {
42
+ "scope": "category" | "merchant" | "customer" | "trigger",
43
+ "context_id": "dentists" | "m_001_drmeera" | "c_001_priya" | "trg_2026_04_26_research_digest",
44
+ "version": 3,
45
+ "payload": { /* the full context object — see §3 */ },
46
+ "delivered_at": "2026-04-26T10:00:00Z"
47
+ }
48
+ ```
49
+
50
+ **Behavior**:
51
+ - **Idempotent** by `(context_id, version)`. Re-posting the same version is a no-op.
52
+ - A higher `version` for the same `context_id` **replaces** the prior version atomically.
53
+ - Bot must persist context until the test ends. Storing in memory is fine; just don't restart between calls.
54
+
55
+ **Response (200)**:
56
+ ```json
57
+ { "accepted": true, "ack_id": "ack_abc123", "stored_at": "2026-04-26T10:00:00.123Z" }
58
+ ```
59
+
60
+ **Response (409)** — version conflict (you already have a higher version):
61
+ ```json
62
+ { "accepted": false, "reason": "stale_version", "current_version": 5 }
63
+ ```
64
+
65
+ **Response (400)** — malformed:
66
+ ```json
67
+ { "accepted": false, "reason": "invalid_scope", "details": "..." }
68
+ ```
69
+
70
+ ### 2.2 `POST /v1/tick` — periodic wake-up; bot can initiate
71
+
72
+ The judge calls this every **N seconds of simulated time** (default: every 5 simulated minutes). The bot inspects its current context state and decides whether to send any proactive messages.
73
+
74
+ **Request body**:
75
+ ```json
76
+ {
77
+ "now": "2026-04-26T10:30:00Z",
78
+ "available_triggers": ["trg_2026_04_26_research_digest", "trg_2026_04_26_recall_priya"]
79
+ }
80
+ ```
81
+
82
+ `available_triggers` is a hint listing trigger context_ids the judge considers "active right now". The bot can use any subset (or none).
83
+
84
+ **Response (200)**:
85
+ ```json
86
+ {
87
+ "actions": [
88
+ {
89
+ "conversation_id": "conv_001",
90
+ "merchant_id": "m_001_drmeera",
91
+ "customer_id": null,
92
+ "send_as": "vera",
93
+ "trigger_id": "trg_2026_04_26_research_digest",
94
+ "template_name": "vera_research_digest_v1",
95
+ "template_params": ["Dr. Meera", "JIDA Oct issue", "..."],
96
+ "body": "Dr. Meera, JIDA's Oct issue landed...",
97
+ "cta": "open_ended",
98
+ "suppression_key": "research:dentists:2026-W17",
99
+ "rationale": "External research digest with merchant-relevant clinical anchor; merchant is a dentist with high-risk-adult patient cohort"
100
+ }
101
+ ]
102
+ }
103
+ ```
104
+
105
+ `actions` MAY be an empty list — the bot is free to decide nothing's worth sending this tick.
106
+
107
+ `conversation_id`:
108
+ - If you want to start a new conversation, generate any unique string.
109
+ - Reusing an existing `conversation_id` is invalid here — use `/v1/reply` to continue an existing conversation.
110
+
111
+ ### 2.3 `POST /v1/reply` — receive a reply from the simulated merchant/customer
112
+
113
+ The judge calls this with the merchant's (or customer's) reply to a previous bot message. The bot must respond synchronously with its next move.
114
+
115
+ **Request body**:
116
+ ```json
117
+ {
118
+ "conversation_id": "conv_001",
119
+ "merchant_id": "m_001_drmeera",
120
+ "customer_id": null,
121
+ "from_role": "merchant",
122
+ "message": "Yes, send me the abstract",
123
+ "received_at": "2026-04-26T10:45:00Z",
124
+ "turn_number": 2
125
+ }
126
+ ```
127
+
128
+ **Response (200)** — three valid `action` values:
129
+
130
+ ```json
131
+ { "action": "send",
132
+ "body": "Sending now — also drafted a 90-sec patient-ed WhatsApp...",
133
+ "cta": "open_ended",
134
+ "rationale": "Honoring the merchant's accept; adding the next-best-step (patient-ed) as low-friction follow-on" }
135
+ ```
136
+
137
+ ```json
138
+ { "action": "wait",
139
+ "wait_seconds": 1800,
140
+ "rationale": "Merchant asked for time; back off 30 min" }
141
+ ```
142
+
143
+ ```json
144
+ { "action": "end",
145
+ "rationale": "Merchant said not interested; gracefully exiting conversation" }
146
+ ```
147
+
148
+ The bot has **30 seconds** to respond. After 30s the judge marks this turn as `timeout` and proceeds.
149
+
150
+ ### 2.4 `GET /v1/healthz` — liveness probe
151
+
152
+ **Response (200)**:
153
+ ```json
154
+ { "status": "ok", "uptime_seconds": 3600, "contexts_loaded": { "category": 5, "merchant": 50, "customer": 200, "trigger": 100 } }
155
+ ```
156
+
157
+ The judge polls this every 60s during the test window. Three consecutive failures = bot disqualified for that test slot.
158
+
159
+ ### 2.5 `GET /v1/metadata` — bot identity
160
+
161
+ **Response (200)**:
162
+ ```json
163
+ {
164
+ "team_name": "Team Alpha",
165
+ "team_members": ["Alice", "Bob"],
166
+ "model": "claude-opus-4-7",
167
+ "approach": "single-prompt composer with retrieval over digest items",
168
+ "contact_email": "team@example.com",
169
+ "version": "1.2.0",
170
+ "submitted_at": "2026-04-26T08:00:00Z"
171
+ }
172
+ ```
173
+
174
+ ---
175
+
176
+ ## 3. Context payload schemas (what the judge pushes to `/v1/context`)
177
+
178
+ Each `scope` has a fixed payload shape. These mirror the dataclasses defined in `challenge-brief.md` §4.
179
+
180
+ ### 3.1 `scope: "category"`
181
+ ```json
182
+ {
183
+ "slug": "dentists",
184
+ "offer_catalog": [{ "title": "Dental Cleaning @ ₹299", "value": "299", "audience": "new_user" }],
185
+ "voice": { "tone": "peer_clinical", "vocab_allowed": ["fluoride varnish", "caries"], "taboos": ["cure", "guaranteed"] },
186
+ "peer_stats": { "avg_rating": 4.4, "avg_reviews": 62, "avg_ctr": 0.030, "scope": "delhi_solo_practices" },
187
+ "digest": [
188
+ { "id": "d_2026W17_jida_fluoride", "kind": "research",
189
+ "title": "3-mo fluoride recall cuts caries 38% better than 6-mo",
190
+ "source": "JIDA Oct 2026, p.14", "trial_n": 2100, "patient_segment": "high_risk_adults",
191
+ "summary": "..." }
192
+ ],
193
+ "patient_content_library": [
194
+ { "id": "pc_001", "title": "3 things your teeth tell you about your heart", "channel": "whatsapp", "body": "..." }
195
+ ],
196
+ "seasonal_beats": [{ "month_range": "Nov-Feb", "note": "exam-stress bruxism spike" }],
197
+ "trend_signals": [{ "query": "clear aligners delhi", "delta_yoy": 0.62, "segment_age": "28-45" }]
198
+ }
199
+ ```
200
+
201
+ ### 3.2 `scope: "merchant"`
202
+ ```json
203
+ {
204
+ "merchant_id": "m_001_drmeera",
205
+ "category_slug": "dentists",
206
+ "identity": { "name": "Dr. Meera's Dental Clinic", "city": "Delhi", "locality": "Lajpat Nagar",
207
+ "place_id": "ChIJ...", "verified": true, "languages": ["en", "hi"] },
208
+ "subscription": { "status": "active", "plan": "Pro", "days_remaining": 82 },
209
+ "performance": {
210
+ "window_days": 30,
211
+ "views": 2410, "calls": 18, "directions": 45, "ctr": 0.021,
212
+ "delta_7d": { "views_pct": 0.18, "calls_pct": -0.05 }
213
+ },
214
+ "offers": [
215
+ { "id": "o_meera_001", "title": "Dental Cleaning @ ₹299", "status": "active" },
216
+ { "id": "o_meera_002", "title": "Deep Cleaning @ ₹499", "status": "expired" }
217
+ ],
218
+ "conversation_history": [
219
+ { "ts": "2026-04-24T10:00:00Z", "from": "vera", "body": "...", "engagement": "merchant_replied" }
220
+ ],
221
+ "customer_aggregate": { "total_unique_ytd": 540, "lapsed_180d_plus": 78, "retention_6mo_pct": 0.38 },
222
+ "signals": ["stale_posts:22d", "ctr_below_peer_median", "high_risk_adult_cohort"]
223
+ }
224
+ ```
225
+
226
+ ### 3.3 `scope: "customer"`
227
+ ```json
228
+ {
229
+ "customer_id": "c_001_priya",
230
+ "merchant_id": "m_001_drmeera",
231
+ "identity": { "name": "Priya", "phone_redacted": "<phone>", "language_pref": "hi-en mix" },
232
+ "relationship": {
233
+ "first_visit": "2025-11-04", "last_visit": "2026-05-12", "visits_total": 4,
234
+ "services_received": ["cleaning", "cleaning", "whitening", "cleaning"]
235
+ },
236
+ "state": "lapsed_soft",
237
+ "preferences": { "preferred_slots": "weekday_evening", "channel": "whatsapp" },
238
+ "consent": { "opted_in_at": "2025-11-04", "scope": ["recall_reminders", "appointment_reminders"] }
239
+ }
240
+ ```
241
+
242
+ ### 3.4 `scope: "trigger"`
243
+ ```json
244
+ {
245
+ "id": "trg_2026_04_26_research_digest_dentists",
246
+ "scope": "merchant",
247
+ "kind": "research_digest",
248
+ "source": "external",
249
+ "merchant_id": "m_001_drmeera",
250
+ "customer_id": null,
251
+ "payload": {
252
+ "category": "dentists",
253
+ "top_item_id": "d_2026W17_jida_fluoride"
254
+ },
255
+ "urgency": 2,
256
+ "suppression_key": "research:dentists:2026-W17",
257
+ "expires_at": "2026-05-03T00:00:00Z"
258
+ }
259
+ ```
260
+
261
+ For `scope: "customer"` triggers (e.g., `recall_due`), `customer_id` is populated.
262
+
263
+ ---
264
+
265
+ ## 4. The judge harness behavior — full lifecycle
266
+
267
+ ### Phase 1 — Warmup (T-15 min before scoring window opens)
268
+
269
+ 1. Judge calls `GET /v1/healthz` and `GET /v1/metadata` to verify the bot is reachable.
270
+ 2. Judge POSTs the **base dataset** to `/v1/context`:
271
+ - 5 category contexts
272
+ - 50 merchant contexts
273
+ - 200 customer contexts
274
+ - 0 triggers (triggers come during the test window)
275
+ 3. Judge waits 60s for the bot to settle, then re-checks `/healthz`.
276
+ 4. If `contexts_loaded` reflects all 255 base contexts, warmup passes.
277
+
278
+ ### Phase 2 — Test window (T0 to T0 + 60 simulated minutes)
279
+
280
+ The judge advances simulated time in **5-minute ticks**. At each tick:
281
+
282
+ 1. Judge POSTs any new/updated contexts that "happened" during this tick (incremental updates).
283
+ 2. Judge calls `POST /v1/tick` with current simulated time + currently-active triggers.
284
+ 3. Bot returns `actions[]` — zero or more proactive sends.
285
+ 4. For each action:
286
+ - Judge logs it (with rationale).
287
+ - Judge invokes a sub-LLM playing the merchant/customer role.
288
+ - The sub-LLM replies with a plausible response (sometimes engaged, sometimes auto-reply, sometimes hard "no", sometimes a question).
289
+ - Judge POSTs that reply to `/v1/reply`.
290
+ - Bot returns next action — `send`, `wait`, or `end`.
291
+ - Repeat up to 5 turns or until bot ends.
292
+
293
+ ### Phase 3 — Adaptive context injection (interleaved during Phase 2)
294
+
295
+ Between ticks, the judge injects **post-submission context** the bot didn't see during development:
296
+
297
+ - New `digest` items (5 new research/compliance items per category context, posted as a new `version`)
298
+ - Updated `performance` snapshots (10 merchants get new perf numbers — some spikes, some dips)
299
+ - New `triggers` (15 new triggers spread across the test window)
300
+ - For 5 specific merchants: a new `customer` context is pushed mid-test with a `recall_due` trigger 2 minutes later
301
+
302
+ Bots that incorporate the new context in subsequent sends score higher. Bots that ignore it (sending stale composition) score lower. Bots that hallucinate (invent context that wasn't pushed) score lowest.
303
+
304
+ ### Phase 4 — Replay test (top 10 only)
305
+
306
+ For the 10 highest-scoring bots from Phase 2, the judge runs **3 deep-dive scenarios** as standalone replays:
307
+
308
+ 1. **Auto-reply hell** — judge plays a merchant whose phone is on WA Business with a canned auto-reply; sends the same canned text 4 times in a row. Bot must detect and exit gracefully.
309
+ 2. **Intent transition** — judge plays an engaged merchant who, after 2 turns of qualification, says "ok let's do it". Bot must switch from qualifying to action immediately (not ask another qualifying question).
310
+ 3. **Hostile / off-topic** — judge plays a merchant who replies with abuse, then asks an unrelated question ("can you also help me file my GST?"). Bot must stay on-mission politely.
311
+
312
+ Each replay: 5 turns, scored on conversation flow only.
313
+
314
+ ### Phase 5 — Scoring + report (T0 + 90 min)
315
+
316
+ Judge aggregates:
317
+ - Phase 2 scores (per the 5-dimension rubric in §8 of the main brief)
318
+ - Phase 3 adaptation bonus (max +5 per dimension)
319
+ - Phase 4 replay scores (top 10 only; max +30)
320
+ - Operational penalties (timeouts, healthz failures, malformed responses; max -20)
321
+
322
+ Sends each team a per-message scorecard with judge rationale.
323
+
324
+ ---
325
+
326
+ ## 5. Rate limits + timeouts
327
+
328
+ | Limit | Value |
329
+ |---|---|
330
+ | Max requests/sec from judge to bot | 10 |
331
+ | Per-call timeout (judge waits this long) | 30 s |
332
+ | `/v1/context` payload size cap | 500 KB |
333
+ | `/v1/tick` action count cap | 20 actions per tick |
334
+ | Healthz failures before disqualification | 3 consecutive |
335
+ | Total test window | 60 simulated minutes (real-time ~30-45 min) |
336
+
337
+ If your bot needs more than 30s for `/v1/tick`, return an empty `actions: []` immediately and process work asynchronously — but you can't catch up later, so design for the budget.
338
+
339
+ ---
340
+
341
+ ## 6. Where to deploy
342
+
343
+ Deploy your bot anywhere that gives you a **public URL**:
344
+ - Any cloud (AWS, GCP, Azure, Render, Fly, Railway, Replit, …)
345
+ - ngrok tunnel to localhost
346
+ - Any hosting that exposes HTTP endpoints
347
+
348
+ Requirements:
349
+ - Must respond at the URL pattern `https://<your-host>/v1/*` (or `http://` for local testing)
350
+ - Submit your public URL via the submission portal
351
+
352
+ ---
353
+
354
+ ## 7. Reference implementation skeleton
355
+
356
+ A minimal-viable bot in ~80 lines of Python (FastAPI). Save as `bot.py`:
357
+
358
+ ```python
359
+ import os, time
360
+ from datetime import datetime
361
+ from fastapi import FastAPI, Request, HTTPException
362
+ from pydantic import BaseModel
363
+ from typing import Any
364
+
365
+ app = FastAPI()
366
+ START = time.time()
367
+
368
+ # In-memory stores (use Redis/SQLite for production-grade)
369
+ contexts: dict[tuple[str, str], dict] = {} # (scope, context_id) -> {version, payload}
370
+ conversations: dict[str, list] = {} # conversation_id -> [turns]
371
+
372
+
373
+ @app.get("/v1/healthz")
374
+ async def healthz():
375
+ counts = {"category": 0, "merchant": 0, "customer": 0, "trigger": 0}
376
+ for (scope, _), _ in contexts.items():
377
+ counts[scope] = counts.get(scope, 0) + 1
378
+ return {"status": "ok", "uptime_seconds": int(time.time() - START), "contexts_loaded": counts}
379
+
380
+
381
+ @app.get("/v1/metadata")
382
+ async def metadata():
383
+ return {"team_name": "Team Alpha", "team_members": ["Alice"], "model": "gpt-4o-mini",
384
+ "approach": "single-prompt composer", "contact_email": "alice@example.com",
385
+ "version": "0.1.0", "submitted_at": "2026-04-26T08:00:00Z"}
386
+
387
+
388
+ class CtxBody(BaseModel):
389
+ scope: str
390
+ context_id: str
391
+ version: int
392
+ payload: dict[str, Any]
393
+ delivered_at: str
394
+
395
+ @app.post("/v1/context")
396
+ async def push_context(body: CtxBody):
397
+ key = (body.scope, body.context_id)
398
+ cur = contexts.get(key)
399
+ if cur and cur["version"] >= body.version:
400
+ return {"accepted": False, "reason": "stale_version", "current_version": cur["version"]}
401
+ contexts[key] = {"version": body.version, "payload": body.payload}
402
+ return {"accepted": True, "ack_id": f"ack_{body.context_id}_v{body.version}",
403
+ "stored_at": datetime.utcnow().isoformat() + "Z"}
404
+
405
+
406
+ class TickBody(BaseModel):
407
+ now: str
408
+ available_triggers: list[str] = []
409
+
410
+ @app.post("/v1/tick")
411
+ async def tick(body: TickBody):
412
+ actions = []
413
+ for trg_id in body.available_triggers:
414
+ trg = contexts.get(("trigger", trg_id), {}).get("payload")
415
+ if not trg: continue
416
+ merchant_id = trg.get("merchant_id")
417
+ merchant = contexts.get(("merchant", merchant_id), {}).get("payload")
418
+ category = contexts.get(("category", merchant.get("category_slug")), {}).get("payload") if merchant else None
419
+ if not (merchant and category): continue
420
+ # YOUR COMPOSER GOES HERE — call your LLM with the 4 contexts
421
+ body_text = f"Hi {merchant['identity']['name']}, ..." # replace with real composition
422
+ actions.append({
423
+ "conversation_id": f"conv_{merchant_id}_{trg_id}",
424
+ "merchant_id": merchant_id, "customer_id": None,
425
+ "send_as": "vera", "trigger_id": trg_id,
426
+ "template_name": "vera_generic_v1",
427
+ "template_params": [merchant['identity']['name'], "...", "..."],
428
+ "body": body_text, "cta": "open_ended",
429
+ "suppression_key": trg.get("suppression_key", ""),
430
+ "rationale": "Composed from category+merchant+trigger"
431
+ })
432
+ return {"actions": actions}
433
+
434
+
435
+ class ReplyBody(BaseModel):
436
+ conversation_id: str
437
+ merchant_id: str | None = None
438
+ customer_id: str | None = None
439
+ from_role: str
440
+ message: str
441
+ received_at: str
442
+ turn_number: int
443
+
444
+ @app.post("/v1/reply")
445
+ async def reply(body: ReplyBody):
446
+ conversations.setdefault(body.conversation_id, []).append({"from": body.from_role, "msg": body.message})
447
+ # YOUR REPLY-COMPOSER GOES HERE
448
+ return {"action": "send", "body": "Got it, here's what's next...", "cta": "open_ended",
449
+ "rationale": "acknowledged + advanced"}
450
+ ```
451
+
452
+ Run: `uvicorn bot:app --host 0.0.0.0 --port 8080`
453
+
454
+ This is a working skeleton. The composer logic is stubbed — replace the `# YOUR COMPOSER GOES HERE` blocks with your LLM call.
455
+
456
+ ---
457
+
458
+ ## 9. Local self-test before submitting
459
+
460
+ Magicpin provides a `judge_simulator.py` that runs a mini version of the harness against your endpoint. Use it during development:
461
+
462
+ ```bash
463
+ export BOT_URL=http://localhost:8080
464
+ python judge_simulator.py
465
+ ```
466
+
467
+ Each scenario prints the judge's prompts + your bot's responses + a mock score. Iterate until you're happy, then submit your URL.
468
+
469
+ ---
470
+
471
+ ## 10. Failure modes the judge handles
472
+
473
+ | Failure | Judge behavior | Penalty |
474
+ |---|---|---|
475
+ | `/v1/healthz` returns non-200 (3× in a row) | Mark bot offline; skip remaining ticks | -10 (operational) |
476
+ | `/v1/tick` times out (>30s) | Skip this tick's actions; continue | -1 per timeout |
477
+ | `/v1/reply` times out | Mark turn as `bot_silent`; judge plays next merchant turn after 30s | -1 per timeout |
478
+ | Bot returns malformed JSON | Logged, scored as 0 for that action | -2 per malformed |
479
+ | Bot returns `action: send` with empty body | Treated as malformed | -2 |
480
+ | Bot returns the same body verbatim it sent before in the same conversation | Anti-repetition flag | -2 per repeat |
481
+
482
+ ---
483
+
484
+ ## 11. Security + privacy
485
+
486
+ - All payloads are synthetic — no real PII.
487
+ - Bots **must not** transmit any payload data outside the test environment (no calls to non-LLM external APIs with merchant/customer fields).
488
+ - Bots **may** use commercial LLM APIs (OpenAI, Anthropic, Google, DeepSeek, etc.) — those are necessary for composition.
489
+ - Bots **must not** persist context data after the test ends. magicpin will issue a `POST /v1/teardown` (optional) at end of test; on receiving it, wipe state.
490
+
491
+ ---
492
+
493
+ ## 12. Pre-flight checklist for candidates
494
+
495
+ Before submitting:
496
+
497
+ - [ ] Endpoint reachable from the public internet (HTTPS or HTTP)
498
+ - [ ] All 5 endpoints implemented and returning correct schemas
499
+ - [ ] `/v1/context` is idempotent on `(scope, context_id, version)`
500
+ - [ ] `/v1/tick` returns within 30s even if it has nothing to send (returns `{"actions": []}`)
501
+ - [ ] `/v1/reply` returns within 30s for any conversation
502
+ - [ ] Bot persists context across calls (in-memory is fine; no restarts during test)
503
+ - [ ] `judge_simulator.py` passes locally with non-zero scores
504
+ - [ ] Submitted URL via submission portal
505
+ - [ ] Compute budget set (rate limits, LLM API quota, etc.) so the bot survives 60-min test
506
+
507
+ ---
508
+
509
+ ## 13. What the judge logs (for transparency)
510
+
511
+ Every test produces a per-team artifact:
512
+
513
+ ```
514
+ results/<team_name>/
515
+ ├── conversations.jsonl # all turns, both sides, with timestamps
516
+ ├── context_pushes.jsonl # every context push, with bot's ack
517
+ ├── scoring.json # 5-dimension scores per action + per conversation
518
+ ├── timeline.html # visual timeline of the test window
519
+ ├── replay_*.jsonl # phase 4 replay transcripts (top 10 only)
520
+ └── final_report.md # aggregated score + judge's qualitative feedback
521
+ ```
522
+
523
+ Candidates receive their own artifact bundle within 48h of the test. Top scorers' bundles (with consent) become reference material for the next cohort.
524
+
525
+ ---
526
+
527
+ ## 14. FAQ
528
+
529
+ **Q: Can the bot use external tools / function calling during composition?**
530
+ Yes. Your LLM can call any tool you implement internally. You can't call out to non-LLM external APIs that receive merchant/customer payloads (privacy rule §11).
531
+
532
+ **Q: What if my bot needs more than 30s to compose a really good message?**
533
+ Two options: (a) speed it up; (b) at `/v1/tick`, return immediately with `{"actions": []}` and skip the cycle. Don't try to background-process and return late — late responses are dropped.
534
+
535
+ **Q: Can I send multiple messages in one tick to the same merchant?**
536
+ Yes, but only one `action` per `(merchant_id, conversation_id)` pair per tick. Use a follow-up tick to send more.
537
+
538
+ **Q: Does the judge see my bot's `rationale` field?**
539
+ Yes — it's included in the scoring rubric ("did the rationale match the actual output?"). High-quality rationales help the judge interpret edge cases generously.
540
+
541
+ **Q: What language do replies have to be in?**
542
+ Match the merchant's `identity.languages` field. Default is English. Hindi-English code-mix is encouraged where the language pref says `hi`.
543
+
544
+ **Q: Can my bot refuse to send when nothing's worth saying?**
545
+ Yes — return `{"actions": []}` from `/v1/tick`. Restraint is rewarded; spam is penalized.
546
+
547
+ **Q: What if the judge pushes a context for a merchant I've never seen before, mid-conversation?**
548
+ Treat it as a normal new merchant. The bot should be ready for any context to arrive at any time.
549
+
550
+ **Q: Is there a way to query the judge for clarification mid-test?**
551
+ No. The bot has only the contexts it's been pushed. This is intentional — production Vera doesn't get clarifications either.
552
+
553
+ ---
554
+
555
+ ## End of testing brief
556
+
557
+ The two briefs together (`challenge-brief.md` for *what to build* and this one for *how it's tested*) are the complete spec. A team should be able to read both end-to-end in 30 minutes and start coding.
dataset/categories/dentists.json ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "slug": "dentists",
3
+ "display_name": "Dentists",
4
+ "voice": {
5
+ "tone": "peer_clinical",
6
+ "register": "respectful_collegial",
7
+ "code_mix": "hindi_english_natural",
8
+ "vocab_allowed": [
9
+ "fluoride varnish", "scaling", "caries", "occlusion", "bruxism",
10
+ "endodontic", "periodontal", "implant", "aligner", "veneer",
11
+ "OPG", "IOPA", "RCT", "CAD/CAM", "zirconia", "PFM"
12
+ ],
13
+ "vocab_taboo": [
14
+ "guaranteed", "100% safe", "completely cure", "miracle", "best in city",
15
+ "doctor approved" ,"FDA-approved (use only when actually applicable)"
16
+ ],
17
+ "salutation_examples": ["Dr. {first_name}", "Doc"],
18
+ "tone_examples": [
19
+ "Worth a look — JIDA Oct 2026 p.14",
20
+ "This one likely affects your high-risk adult cohort",
21
+ "If your case-mix is mostly cosmetic, may not be relevant"
22
+ ]
23
+ },
24
+ "offer_catalog": [
25
+ { "id": "den_001", "title": "Dental Cleaning @ ₹299", "value": "299", "audience": "new_user", "type": "service_at_price" },
26
+ { "id": "den_002", "title": "Free Consultation", "value": "0", "audience": "new_user", "type": "free_service" },
27
+ { "id": "den_003", "title": "Teeth Whitening @ ₹1,499", "value": "1499", "audience": "new_user", "type": "service_at_price" },
28
+ { "id": "den_004", "title": "Root Canal @ ₹2,999 (single rooted)", "value": "2999", "audience": "new_user", "type": "service_at_price" },
29
+ { "id": "den_005", "title": "Free Smile Analysis + Digital Scan", "value": "0", "audience": "new_user", "type": "free_service" },
30
+ { "id": "den_006", "title": "Aligner Consultation @ ₹499", "value": "499", "audience": "new_user", "type": "service_at_price" },
31
+ { "id": "den_007", "title": "Pediatric Dental Checkup @ ₹199", "value": "199", "audience": "new_user", "type": "service_at_price" },
32
+ { "id": "den_008", "title": "Annual Family Dental Plan @ ₹4,999", "value": "4999", "audience": "repeat_user", "type": "membership" }
33
+ ],
34
+ "peer_stats": {
35
+ "scope": "metro_solo_practices_2026",
36
+ "avg_rating": 4.4,
37
+ "avg_review_count": 62,
38
+ "avg_views_30d": 1820,
39
+ "avg_calls_30d": 12,
40
+ "avg_directions_30d": 38,
41
+ "avg_ctr": 0.030,
42
+ "avg_photos": 9,
43
+ "avg_post_freq_days": 14,
44
+ "retention_6mo_pct": 0.42
45
+ },
46
+ "digest": [
47
+ {
48
+ "id": "d_2026W17_jida_fluoride",
49
+ "kind": "research",
50
+ "title": "3-month fluoride varnish recall outperforms 6-month for high-risk adult caries",
51
+ "source": "JIDA Oct 2026, p.14",
52
+ "trial_n": 2100,
53
+ "patient_segment": "high_risk_adults",
54
+ "summary": "Multi-center Indian trial shows 38% lower caries recurrence with 3-month vs 6-month recall in adults with active decay history. No effect in low-risk patients.",
55
+ "actionable": "Reassess recall interval for adults flagged high-risk in your charting"
56
+ },
57
+ {
58
+ "id": "d_2026W17_dci_radiograph",
59
+ "kind": "compliance",
60
+ "title": "DCI revised radiograph dose limits effective 2026-12-15",
61
+ "source": "Dental Council of India circular 2026-11-04",
62
+ "summary": "Maximum dose per IOPA exposure drops from 1.5 mSv to 1.0 mSv. E-speed film passes at the new limit; D-speed does not. Digital RVG sensors unaffected.",
63
+ "actionable": "Audit your X-ray setup before Dec 15; document E-speed or RVG in your SOPs"
64
+ },
65
+ {
66
+ "id": "d_2026W17_ida_webinar",
67
+ "kind": "cde",
68
+ "title": "IDA Delhi: Digital impressions — 2026 state of the art",
69
+ "source": "IDA Delhi chapter calendar",
70
+ "date": "2026-05-02T19:00:00+05:30",
71
+ "credits": 2,
72
+ "summary": "Speaker: Dr. R. Mehta. Covers Primescan 2, Trios 5, and CAD/CAM workflow ROI for solo practices.",
73
+ "actionable": "Free for IDA members; ₹500 for non-members"
74
+ },
75
+ {
76
+ "id": "d_2026W17_aligner_trend",
77
+ "kind": "trend",
78
+ "title": "Clear aligner consultations searches +62% YoY in metros",
79
+ "source": "Practo + Google Trends, Apr 2026",
80
+ "summary": "'Clear aligners near me' query growth concentrated in 28-45 age band; female-skewed. Pricing pressure as DIY aligner brands hit the market.",
81
+ "actionable": "Position your supervised-aligner offer against DIY in your GBP description"
82
+ },
83
+ {
84
+ "id": "d_2026W17_e_max_press",
85
+ "kind": "tech",
86
+ "title": "Dentsply launches IPS e.max Press for zirconia crowns in India at ₹3,200/unit (Delhi labs)",
87
+ "source": "Dentsply India launch note 2026-04-18",
88
+ "summary": "Better aesthetic, similar fracture resistance to standard zirconia. Two Delhi labs (Dental Direkt, Siam Dental) confirmed stocking. Shared-lease scanner deals available.",
89
+ "actionable": "Worth comparing if crown work is >15% of monthly revenue"
90
+ }
91
+ ],
92
+ "patient_content_library": [
93
+ {
94
+ "id": "pc_oral_heart",
95
+ "title": "3 things your teeth tell you about your heart",
96
+ "channel": "whatsapp",
97
+ "length_seconds": 90,
98
+ "body": "Periodontal disease shares inflammation pathways with cardiovascular disease. Bleeding gums, persistent bad breath, and loose teeth in middle age are early signals. A 2024 meta-analysis of 18 studies found periodontal patients had 22% higher CVD events. The simplest action: scaling every 6 months reduces both gum and inflammation markers."
99
+ },
100
+ {
101
+ "id": "pc_kid_brushing",
102
+ "title": "Why your child resists brushing — and what to try this week",
103
+ "channel": "whatsapp",
104
+ "length_seconds": 60,
105
+ "body": "Most resistance under age 7 is sensory, not behavioural. Try: (1) a softer brush than you think necessary, (2) brushing in front of a mirror so they see what's happening, (3) two minutes of music as the timer. If resistance persists past 4 weeks, it's worth a pediatric dental consult."
106
+ },
107
+ {
108
+ "id": "pc_aligner_vs_braces",
109
+ "title": "Aligners or braces? What I tell my patients in their 30s",
110
+ "channel": "whatsapp",
111
+ "length_seconds": 75,
112
+ "body": "Aligners work for ~70% of cases — mild-to-moderate crowding, spacing, and minor rotations. Braces still win for severe rotations, deep bites, and skeletal corrections. The tell: if you can't keep aligners on for 22 hours/day, braces are the safer choice."
113
+ }
114
+ ],
115
+ "seasonal_beats": [
116
+ { "month_range": "Nov-Feb", "note": "exam-stress bruxism spike — ortho consults rise 30% in 18-24 cohort" },
117
+ { "month_range": "Oct-Dec", "note": "wedding whitening peak — bookings 2x baseline; ladies' segment dominant" },
118
+ { "month_range": "Jan", "note": "new-year resolution surge — annual check-up bookings +40%" },
119
+ { "month_range": "Apr-Jun", "note": "school holiday window — pediatric appointments +50%" }
120
+ ],
121
+ "trend_signals": [
122
+ { "query": "clear aligners delhi", "delta_yoy": 0.62, "segment_age": "28-45", "skew": "female" },
123
+ { "query": "teeth whitening price", "delta_yoy": 0.41, "segment_age": "all", "skew": "balanced" },
124
+ { "query": "dental implants near me", "delta_yoy": 0.18, "segment_age": "45-65", "skew": "male" },
125
+ { "query": "kids first dental visit", "delta_yoy": 0.27, "segment_age": "parents_25-40", "skew": "female" }
126
+ ],
127
+ "regulatory_authorities": ["Dental Council of India (DCI)", "Indian Dental Association (IDA)"],
128
+ "professional_journals": ["JIDA", "Indian Journal of Dental Research", "Dental Tribune India"]
129
+ }
dataset/categories/gyms.json ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "slug": "gyms",
3
+ "display_name": "Gyms & Fitness",
4
+ "voice": {
5
+ "tone": "energetic_disciplined",
6
+ "register": "coach_to_member",
7
+ "code_mix": "english_primary_some_hindi",
8
+ "vocab_allowed": [
9
+ "footfall", "membership churn", "PT sessions", "PR (personal record)",
10
+ "1RM", "EMOM", "AMRAP", "split", "cut", "bulk", "BMR", "VO2max",
11
+ "functional", "HIIT", "CrossFit", "yoga", "pilates"
12
+ ],
13
+ "vocab_taboo": [
14
+ "guaranteed weight loss", "shred in 7 days", "miracle transformation",
15
+ "fastest results"
16
+ ],
17
+ "salutation_examples": ["Hi {first_name}", "{gym_name} team", "Coach"],
18
+ "tone_examples": [
19
+ "Quick check — your weekday 7-9pm slot has been at 90%+ capacity all month",
20
+ "Footfall pattern: April drop-off is normal; bookings recover by 2nd week May"
21
+ ]
22
+ },
23
+ "offer_catalog": [
24
+ { "id": "gym_001", "title": "3 FREE Trial Classes", "value": "0", "audience": "new_user", "type": "free_trial" },
25
+ { "id": "gym_002", "title": "First Month @ ₹499", "value": "499", "audience": "new_user", "type": "service_at_price" },
26
+ { "id": "gym_003", "title": "Personal Training Demo @ ₹199", "value": "199", "audience": "new_user", "type": "service_at_price" },
27
+ { "id": "gym_004", "title": "Annual Membership @ ₹14,999 (save ₹6,000)", "value": "14999", "audience": "new_user", "type": "service_at_price" },
28
+ { "id": "gym_005", "title": "Couple/Family Plan @ ₹999/month", "value": "999", "audience": "new_user", "type": "service_at_price" },
29
+ { "id": "gym_006", "title": "Free Body Composition Analysis", "value": "0", "audience": "all", "type": "free_service" },
30
+ { "id": "gym_007", "title": "Refer-a-friend: 1 month free for both", "value": "free_addon", "audience": "repeat_user", "type": "free_addon" },
31
+ { "id": "gym_008", "title": "Yoga + Strength Combo @ ₹1,499/month", "value": "1499", "audience": "new_user", "type": "service_at_price" }
32
+ ],
33
+ "peer_stats": {
34
+ "scope": "metro_neighbourhood_gyms_2026",
35
+ "avg_rating": 4.5,
36
+ "avg_review_count": 56,
37
+ "avg_views_30d": 1100,
38
+ "avg_calls_30d": 18,
39
+ "avg_directions_30d": 42,
40
+ "avg_ctr": 0.045,
41
+ "avg_photos": 16,
42
+ "avg_post_freq_days": 12,
43
+ "monthly_churn_pct": 0.08,
44
+ "trial_to_paid_pct": 0.32
45
+ },
46
+ "digest": [
47
+ {
48
+ "id": "d_2026W17_resolution_window",
49
+ "kind": "seasonal",
50
+ "title": "Post-Jan resolution window closing — last 2 weeks of high trial-walk-ins",
51
+ "source": "magicpin gym data, Apr 2026",
52
+ "summary": "Gym trial walk-ins spike Jan 1-15, taper through Mar; April-June hits the lowest acquisition window of the year. Most gyms over-spend on ads now; underspend in October pre-holiday window.",
53
+ "actionable": "Pause acquisition spend in May; double down Sept-Oct"
54
+ },
55
+ {
56
+ "id": "d_2026W17_pt_demand",
57
+ "kind": "trend",
58
+ "title": "Personal Training inquiries +38% YoY in 30-50 corporate cohort",
59
+ "source": "Multi-gym aggregate Apr 2026",
60
+ "summary": "Driven by health-check-up findings (rising HbA1c, BP, cholesterol). Demand is for 2x/week PT, not full-program. ₹3,000-5,000/month sweet spot.",
61
+ "actionable": "Package a '2x/week PT @ ₹3,499' option separate from full membership"
62
+ },
63
+ {
64
+ "id": "d_2026W17_yoga_studio_competition",
65
+ "kind": "compete",
66
+ "title": "Boutique yoga/pilates studios opening fast in metro neighbourhoods",
67
+ "source": "Industry watch Apr 2026",
68
+ "summary": "Reformer pilates studios at ₹6,000-8,000/month membership are pulling 30-45 women from traditional gyms. Don't compete on price; compete on coach quality + community.",
69
+ "actionable": "Add one yoga or pilates class to your weekly schedule if you don't have one"
70
+ },
71
+ {
72
+ "id": "d_2026W17_creatine_safety_bulletin",
73
+ "kind": "research",
74
+ "title": "ICMR creatine supplementation safety bulletin — adolescent guidance",
75
+ "source": "ICMR, Apr 2026",
76
+ "summary": "Creatine monohydrate at 3-5g/day deemed safe for healthy adults; cautioned against under-18 use without medical supervision. Reinforces what most coaches already practice.",
77
+ "actionable": "Update your store / counter recommendations; flag under-18 customers"
78
+ },
79
+ {
80
+ "id": "d_2026W17_class_schedule_optimization",
81
+ "kind": "tech",
82
+ "title": "Schedule density study — peak slots underutilized in mornings",
83
+ "source": "magicpin internal Apr 2026",
84
+ "summary": "Across 200+ partner gyms, 6-8am weekday slots run at 60% capacity vs 90%+ for 6-9pm. Adding a 7am class typically doesn't cannibalize evenings.",
85
+ "actionable": "Consider one new 7am class — yoga or HIIT pulls best in that slot"
86
+ }
87
+ ],
88
+ "patient_content_library": [
89
+ {
90
+ "id": "pc_first_30_days",
91
+ "title": "First 30 days at the gym — what actually changes",
92
+ "channel": "whatsapp",
93
+ "length_seconds": 60,
94
+ "body": "Week 1-2: nervous-system adaptation, lifts feel easier (not muscle gain — coordination). Week 3-4: small visible changes, sleep improves. Real strength/aesthetic changes start at week 8-12. Most people quit at week 3 expecting visible change. Trust the process."
95
+ },
96
+ {
97
+ "id": "pc_pt_or_solo",
98
+ "title": "PT or solo? A 5-question test",
99
+ "channel": "whatsapp",
100
+ "length_seconds": 45,
101
+ "body": "Solo if: you've trained before, you can read program structure, you don't get injured easily. PT if: you're new, recovering from injury, training for a specific event, or have inconsistent form. Hybrid (PT 1-2x/month + solo): the smartest middle path for most members."
102
+ },
103
+ {
104
+ "id": "pc_protein_basics",
105
+ "title": "How much protein do you actually need?",
106
+ "channel": "whatsapp",
107
+ "length_seconds": 50,
108
+ "body": "Sedentary adults: 0.8g/kg body weight. Active adults: 1.2-1.6g/kg. Strength training: 1.6-2.2g/kg. For a 70kg active person, that's 84-112g/day — roughly 4 fistful-portions of protein. Indian-veg diets often fall short; supplement with paneer, dal, tofu before adding powders."
109
+ }
110
+ ],
111
+ "seasonal_beats": [
112
+ { "month_range": "Jan", "note": "resolution surge — trial walk-ins 4x baseline; convert window" },
113
+ { "month_range": "Apr-Jun", "note": "lowest acquisition window — focus on retention, not acquisition" },
114
+ { "month_range": "Aug-Oct", "note": "wedding-prep + festival window — repeat clients return to shape up" },
115
+ { "month_range": "Nov-Dec", "note": "holiday slowdown — class density drops 25%; right time to renovate or pilot new programs" }
116
+ ],
117
+ "trend_signals": [
118
+ { "query": "gym near me", "delta_yoy": 0.05, "segment_age": "all", "skew": "balanced" },
119
+ { "query": "personal trainer cost", "delta_yoy": 0.38, "segment_age": "30-50", "skew": "balanced" },
120
+ { "query": "yoga classes near me", "delta_yoy": 0.42, "segment_age": "25-55", "skew": "female" },
121
+ { "query": "weight loss program", "delta_yoy": 0.28, "segment_age": "30-50", "skew": "female" }
122
+ ],
123
+ "regulatory_authorities": ["FSSAI (for supplement sales)", "Local Municipal Corp (premises)"],
124
+ "professional_journals": ["Indian Journal of Sports Medicine"]
125
+ }
dataset/categories/pharmacies.json ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "slug": "pharmacies",
3
+ "display_name": "Pharmacies & Medical Stores",
4
+ "voice": {
5
+ "tone": "trustworthy_precise",
6
+ "register": "neighbourhood_pharmacist",
7
+ "code_mix": "hindi_english_natural",
8
+ "vocab_allowed": [
9
+ "OTC", "schedule H", "schedule X", "generic", "branded", "molecule",
10
+ "MRP", "expiry", "batch", "PCR retail", "pharmacist counsel"
11
+ ],
12
+ "vocab_taboo": [
13
+ "miracle cure", "guaranteed result", "100% safe", "doctor recommended (without disclosure)",
14
+ "best price (without supporting data)"
15
+ ],
16
+ "salutation_examples": ["Hi {pharmacist_name}", "{pharmacy_name} team"],
17
+ "tone_examples": [
18
+ "Quick check — your repeat-prescription customer count is up 18% this month",
19
+ "Heads up: a generic alternative for {molecule} just got approved — likely 30% lower MRP"
20
+ ]
21
+ },
22
+ "offer_catalog": [
23
+ { "id": "phr_001", "title": "Flat 20% OFF on medicines", "value": "20%", "audience": "new_user", "type": "percentage_discount" },
24
+ { "id": "phr_002", "title": "Free Home Delivery > ₹499", "value": "free_delivery", "audience": "new_user", "type": "free_addon" },
25
+ { "id": "phr_003", "title": "Annual Health Card @ ₹399 (15% off all year)", "value": "399", "audience": "repeat_user", "type": "membership" },
26
+ { "id": "phr_004", "title": "Free BP & Sugar Check", "value": "0", "audience": "all", "type": "free_service" },
27
+ { "id": "phr_005", "title": "Senior Citizen 15% OFF (60+ age)", "value": "15%", "audience": "senior", "type": "percentage_discount" },
28
+ { "id": "phr_006", "title": "Diabetic Care Combo: Glucometer + 50 strips @ ₹999", "value": "999", "audience": "new_user", "type": "service_at_price" },
29
+ { "id": "phr_007", "title": "Free Pharmacist Consultation (10 min)", "value": "0", "audience": "all", "type": "free_service" },
30
+ { "id": "phr_008", "title": "Subscription refill reminder + delivery (chronic Rx)", "value": "0", "audience": "repeat_user", "type": "free_service" }
31
+ ],
32
+ "peer_stats": {
33
+ "scope": "metro_neighbourhood_pharmacies_2026",
34
+ "avg_rating": 4.6,
35
+ "avg_review_count": 42,
36
+ "avg_views_30d": 1400,
37
+ "avg_calls_30d": 22,
38
+ "avg_directions_30d": 58,
39
+ "avg_ctr": 0.038,
40
+ "avg_photos": 6,
41
+ "avg_post_freq_days": 21,
42
+ "delivery_share_pct": 0.35,
43
+ "repeat_customer_pct": 0.62
44
+ },
45
+ "digest": [
46
+ {
47
+ "id": "d_2026W17_generic_metformin",
48
+ "kind": "supply",
49
+ "title": "Generic metformin SR price drop after 4 new approvals",
50
+ "source": "DGCI release Apr 2026",
51
+ "summary": "Three major Indian players got SR-formulation approval; wholesale price down 22% effective next month. Branded retail likely flat for now (margin will absorb).",
52
+ "actionable": "Audit your shelf — switching diabetic refills to generic now saves the patient ~₹120/month and improves your repeat-buy stickiness"
53
+ },
54
+ {
55
+ "id": "d_2026W17_schedule_h1_compliance",
56
+ "kind": "compliance",
57
+ "title": "FDA enforcement audit on Schedule H1 antibiotic dispensing — Q2",
58
+ "source": "FDA India inspector circular Apr 2026",
59
+ "summary": "Tighter Q2 audit cycle on H1 antibiotic dispensing — proper Rx capture, register entries, batch tracking. ₹50,000+ penalties for missing entries.",
60
+ "actionable": "Audit your H1 register; ensure pharmacist signature + Rx photocopy on file for every dispense in last 90 days"
61
+ },
62
+ {
63
+ "id": "d_2026W17_summer_demand",
64
+ "kind": "seasonal",
65
+ "title": "Summer demand shift: ORS, sunscreen, anti-fungal up 40%; cold/cough down 60%",
66
+ "source": "Multi-pharmacy aggregate Apr 2026",
67
+ "summary": "Standard April-Jun pattern: ORS sachets, sunscreen, anti-fungal creams, deodorant peak; respiratory medication tapers off. Restock + shelf-rearrange.",
68
+ "actionable": "Move ORS + sunscreen to counter visibility; cold/cough to back shelf"
69
+ },
70
+ {
71
+ "id": "d_2026W17_chronic_subscription",
72
+ "kind": "tech",
73
+ "title": "Chronic-Rx subscription retention 3.2x higher than walk-in",
74
+ "source": "magicpin pharmacy data Apr 2026",
75
+ "summary": "Pharmacies with WhatsApp-based refill reminder + auto-delivery for chronic patients see 88% 12-month retention vs 27% for walk-in-only chronic customers.",
76
+ "actionable": "Set up the WhatsApp reminder — covers diabetic, hypertensive, thyroid Rx"
77
+ },
78
+ {
79
+ "id": "d_2026W17_atorvastatin_recall",
80
+ "kind": "alert",
81
+ "title": "Voluntary recall: Specific atorvastatin batches by manufacturer X",
82
+ "source": "CDSCO alert Apr 2026",
83
+ "summary": "Two batches (numbers in alert) flagged for sub-potency. Customers should be informed; replacement available via distributor return chain. No safety risk for patients beyond suboptimal LDL control.",
84
+ "actionable": "Pull the batches; WhatsApp affected customers from your repeat-Rx list"
85
+ }
86
+ ],
87
+ "patient_content_library": [
88
+ {
89
+ "id": "pc_generic_branded",
90
+ "title": "Generic vs branded medicines — what's actually different?",
91
+ "channel": "whatsapp",
92
+ "length_seconds": 60,
93
+ "body": "The active molecule is identical and regulator-tested. Difference is in the binders, fillers, and brand markup. Generic is typically 40-60% cheaper. Two cases where brand may help: (1) very narrow-therapeutic-index drugs (some thyroid, blood thinners) where bioequivalence varies, (2) extended-release formulations. Otherwise, generic is the rational choice."
94
+ },
95
+ {
96
+ "id": "pc_storage",
97
+ "title": "Where to NOT store your medicines",
98
+ "channel": "whatsapp",
99
+ "length_seconds": 45,
100
+ "body": "Three places: (1) bathroom — humidity wrecks tablet integrity. (2) car glove box — temperature swings degrade most molecules in a month. (3) refrigerator door — temperature varies too much; use the main shelf if cold storage is needed. The kitchen at room temperature, away from sunlight, is best."
101
+ },
102
+ {
103
+ "id": "pc_summer_basics",
104
+ "title": "Summer first-aid: what every Indian household should have",
105
+ "channel": "whatsapp",
106
+ "length_seconds": 50,
107
+ "body": "Five items: ORS sachets (heat-stroke is sudden), broad-spectrum sunscreen SPF 50+ (apply 30 min before sun), anti-fungal cream (sweat triggers infections), Vaseline (chafing relief), and a thermometer. Skip energy drinks marketed as 'summer drinks' — sugar load is counterproductive when dehydrated."
108
+ }
109
+ ],
110
+ "seasonal_beats": [
111
+ { "month_range": "Apr-Jun", "note": "summer surge — ORS, sunscreen, anti-fungal, deodorant" },
112
+ { "month_range": "Jul-Aug", "note": "monsoon — anti-bacterial, anti-fungal, immunity supplements peak" },
113
+ { "month_range": "Oct-Nov", "note": "festival sweets → blood sugar spike — diabetic monitoring needs surge" },
114
+ { "month_range": "Dec-Jan", "note": "respiratory peak — cough/cold/anti-allergic 2x baseline" }
115
+ ],
116
+ "trend_signals": [
117
+ { "query": "medicine home delivery", "delta_yoy": 0.42, "segment_age": "all", "skew": "balanced" },
118
+ { "query": "generic medicine", "delta_yoy": 0.34, "segment_age": "30-65", "skew": "balanced" },
119
+ { "query": "diabetes care kit", "delta_yoy": 0.28, "segment_age": "40-65", "skew": "balanced" },
120
+ { "query": "blood pressure monitor", "delta_yoy": 0.18, "segment_age": "45-70", "skew": "balanced" }
121
+ ],
122
+ "regulatory_authorities": ["CDSCO", "State FDA", "Drug Controller General of India (DCGI)"],
123
+ "professional_journals": ["Pharmabiz", "ChemistryView India", "IPA Bulletin"]
124
+ }
dataset/categories/restaurants.json ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "slug": "restaurants",
3
+ "display_name": "Restaurants & Cafes",
4
+ "voice": {
5
+ "tone": "warm_busy_practical",
6
+ "register": "fellow_operator",
7
+ "code_mix": "hindi_english_natural",
8
+ "vocab_allowed": [
9
+ "footfall", "covers", "AOV", "RPC", "table turnover", "reservations",
10
+ "GRO", "weekend brunch", "happy hour", "thali", "biryani", "tandoor"
11
+ ],
12
+ "vocab_taboo": [
13
+ "best food in city", "guaranteed packed house", "miracle marketing",
14
+ "viral guarantee"
15
+ ],
16
+ "salutation_examples": ["Hi {chef_or_owner_first_name}", "{restaurant_name} team"],
17
+ "tone_examples": [
18
+ "Quick one — IPL match nights have been 1.5x your weekday avg this season",
19
+ "Spotted: 'biryani delivery' searches in your sublocality up 28% this week"
20
+ ]
21
+ },
22
+ "offer_catalog": [
23
+ { "id": "res_001", "title": "Flat 30% OFF on total bill (limit ₹500)", "value": "30%", "audience": "new_user", "type": "percentage_discount" },
24
+ { "id": "res_002", "title": "Buy 1 Pizza Get 1 Free (Tue-Thu)", "value": "BOGO", "audience": "new_user", "type": "bogo" },
25
+ { "id": "res_003", "title": "Weekday Lunch Thali @ ₹149", "value": "149", "audience": "new_user", "type": "service_at_price" },
26
+ { "id": "res_004", "title": "Free Starter on orders > ₹1,200", "value": "free_addon", "audience": "new_user", "type": "free_addon" },
27
+ { "id": "res_005", "title": "Match-night Combo @ ₹399 (food + drink)", "value": "399", "audience": "new_user", "type": "service_at_price" },
28
+ { "id": "res_006", "title": "Family Sunday Brunch @ ₹699/pax", "value": "699", "audience": "new_user", "type": "service_at_price" },
29
+ { "id": "res_007", "title": "Free Delivery > ₹500", "value": "free_delivery", "audience": "new_user", "type": "free_addon" },
30
+ { "id": "res_008", "title": "Birthday: Free Cake on parties of 6+", "value": "free_addon", "audience": "all", "type": "free_addon" }
31
+ ],
32
+ "peer_stats": {
33
+ "scope": "metro_casual_dining_2026",
34
+ "avg_rating": 4.2,
35
+ "avg_review_count": 142,
36
+ "avg_views_30d": 4800,
37
+ "avg_calls_30d": 38,
38
+ "avg_directions_30d": 95,
39
+ "avg_ctr": 0.025,
40
+ "avg_photos": 22,
41
+ "avg_post_freq_days": 7,
42
+ "retention_30d_pct": 0.18
43
+ },
44
+ "digest": [
45
+ {
46
+ "id": "d_2026W17_ipl_window",
47
+ "kind": "seasonal",
48
+ "title": "IPL home-match Saturdays underperformed weeknight matches across metros",
49
+ "source": "magicpin order data, Apr 2026",
50
+ "summary": "Saturday IPL matches shift orders to home-watch parties; restaurant covers down 12% vs Saturday average. Weeknight matches drive +18% covers. Adjust promo days accordingly.",
51
+ "actionable": "Push match-night combos on Tue/Wed/Thu match dates only"
52
+ },
53
+ {
54
+ "id": "d_2026W17_packaged_food_gst",
55
+ "kind": "compliance",
56
+ "title": "GST council clarifies 5% rate for restaurant takeaway packaging from 2026-06-01",
57
+ "source": "GST Council circular 224/2026",
58
+ "summary": "Single-use plastic surcharge added to packaging cost for orders >₹500. Effective June 1. Cloud kitchens hit hardest.",
59
+ "actionable": "Audit your packaging cost; consider Eco-pack alternative if volume justifies"
60
+ },
61
+ {
62
+ "id": "d_2026W17_zomato_visibility",
63
+ "kind": "trend",
64
+ "title": "Zomato 'verified' badge correlates with +24% impressions in Tier-1 cities",
65
+ "source": "Zomato partner update, Apr 2026",
66
+ "summary": "Verification requires uploaded GST cert + 6 months continuous operation. Most eligible mid-size restaurants haven't applied; 5-day approval.",
67
+ "actionable": "Apply via partner dashboard if you've crossed 6 months"
68
+ },
69
+ {
70
+ "id": "d_2026W17_swiggy_iCare",
71
+ "kind": "tech",
72
+ "title": "Swiggy iCare: AI complaint summarizer launching Apr 2026",
73
+ "source": "Swiggy partner blog 2026-04-12",
74
+ "summary": "Auto-summarises customer complaints into themes (cold food / late / wrong order). Free for Pro merchants. Reveals pattern most operators miss.",
75
+ "actionable": "Enable in dashboard; review weekly themes Sunday evening"
76
+ },
77
+ {
78
+ "id": "d_2026W17_dish_trend",
79
+ "kind": "trend",
80
+ "title": "'Sugar-free dessert' searches +52% YoY across Indian metros",
81
+ "source": "Google Trends Apr 2026",
82
+ "summary": "Concentrated in 28-45 age band; correlated with rising diabetic awareness. Two new options on dessert menus seeing strong attach rate: keto cheesecake, almond-flour brownie.",
83
+ "actionable": "Add one sugar-free dessert; mark prominently on menu and GBP"
84
+ }
85
+ ],
86
+ "patient_content_library": [
87
+ {
88
+ "id": "pc_ordering_smart",
89
+ "title": "Three things to look for before ordering food online",
90
+ "channel": "whatsapp",
91
+ "length_seconds": 60,
92
+ "body": "(1) Look at the latest review's date — if last review is 30+ days old, the kitchen may be off. (2) Filter for 4★+ reviews mentioning your specific dish. (3) Order at peak hours — fresh prep, not warmed-up morning batch. These three filter out 80% of bad-experience risk."
93
+ },
94
+ {
95
+ "id": "pc_thali_economics",
96
+ "title": "Why a ₹149 thali is the best lunch deal in town",
97
+ "channel": "whatsapp",
98
+ "length_seconds": 45,
99
+ "body": "A weekday thali for ₹149 covers your daily protein, two veggies, dal, rice, roti, and pickle/sweet. Buying these separately at any market in the neighbourhood: ₹240+. The only way restaurants make this work is high lunch volume — which means freshly-cooked, not standing in a chafer."
100
+ }
101
+ ],
102
+ "seasonal_beats": [
103
+ { "month_range": "Mar-Apr", "note": "IPL season — match-night promos on Tue/Wed/Thu; not weekends" },
104
+ { "month_range": "Oct-Nov", "note": "Diwali corporate gifting + family-feast bookings" },
105
+ { "month_range": "Dec", "note": "Christmas + New Year — set menu sales 3x baseline" },
106
+ { "month_range": "Jul-Aug", "note": "monsoon delivery surge; rain-day discount window" },
107
+ { "month_range": "Feb 14", "note": "Valentine's prix-fixe — book starting 2 weeks prior" }
108
+ ],
109
+ "trend_signals": [
110
+ { "query": "biryani near me", "delta_yoy": 0.18, "segment_age": "all", "skew": "balanced" },
111
+ { "query": "weekday lunch thali", "delta_yoy": 0.34, "segment_age": "office_25-45", "skew": "balanced" },
112
+ { "query": "sugar free dessert", "delta_yoy": 0.52, "segment_age": "28-45", "skew": "balanced" },
113
+ { "query": "match night offer", "delta_yoy": 0.65, "segment_age": "20-40", "skew": "male" },
114
+ { "query": "small party catering", "delta_yoy": 0.22, "segment_age": "30-50", "skew": "female" }
115
+ ],
116
+ "regulatory_authorities": ["FSSAI", "Local Health Department", "GST Council"],
117
+ "professional_journals": ["FoodService India", "Hospitality Bizz"]
118
+ }
dataset/categories/salons.json ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "slug": "salons",
3
+ "display_name": "Salons & Beauty",
4
+ "voice": {
5
+ "tone": "warm_practical",
6
+ "register": "approachable_expert",
7
+ "code_mix": "hindi_english_natural",
8
+ "vocab_allowed": [
9
+ "balayage", "highlights", "keratin", "smoothening", "hair spa",
10
+ "manicure", "pedicure", "facial", "threading", "waxing", "extensions",
11
+ "olaplex", "wella", "loreal", "schwarzkopf", "redken"
12
+ ],
13
+ "vocab_taboo": [
14
+ "guaranteed glow", "permanent results", "instant transformation", "miracle",
15
+ "best in city"
16
+ ],
17
+ "salutation_examples": ["Hi {first_name}", "{salon_name} team"],
18
+ "tone_examples": [
19
+ "Bridal season is starting — bookings usually 2x normal in next 4 weeks",
20
+ "Quick one — your Saturday 5-7pm slot has been the strongest this month"
21
+ ]
22
+ },
23
+ "offer_catalog": [
24
+ { "id": "sal_001", "title": "Haircut @ ₹99", "value": "99", "audience": "new_user", "type": "service_at_price" },
25
+ { "id": "sal_002", "title": "FREE head massage with Haircut", "value": "0", "audience": "new_user", "type": "free_addon" },
26
+ { "id": "sal_003", "title": "Hair Spa @ ₹499", "value": "499", "audience": "new_user", "type": "service_at_price" },
27
+ { "id": "sal_004", "title": "Threading + Waxing combo @ ₹299", "value": "299", "audience": "new_user", "type": "service_at_price" },
28
+ { "id": "sal_005", "title": "Bridal Trial @ ₹999", "value": "999", "audience": "new_user", "type": "service_at_price" },
29
+ { "id": "sal_006", "title": "Keratin Treatment @ ₹2,499", "value": "2499", "audience": "new_user", "type": "service_at_price" },
30
+ { "id": "sal_007", "title": "Mani+Pedi Combo @ ₹599", "value": "599", "audience": "new_user", "type": "service_at_price" },
31
+ { "id": "sal_008", "title": "Annual Membership: 12 services @ ₹4,999", "value": "4999", "audience": "repeat_user", "type": "membership" }
32
+ ],
33
+ "peer_stats": {
34
+ "scope": "metro_unisex_salons_2026",
35
+ "avg_rating": 4.5,
36
+ "avg_review_count": 88,
37
+ "avg_views_30d": 2400,
38
+ "avg_calls_30d": 28,
39
+ "avg_directions_30d": 62,
40
+ "avg_ctr": 0.040,
41
+ "avg_photos": 14,
42
+ "avg_post_freq_days": 10,
43
+ "retention_3mo_pct": 0.55
44
+ },
45
+ "digest": [
46
+ {
47
+ "id": "d_2026W17_olaplex_no9",
48
+ "kind": "tech",
49
+ "title": "Olaplex No.9 launches in India — bond protector for chemically-treated hair",
50
+ "source": "Hair Brand News India, Apr 2026",
51
+ "summary": "Pre-shampoo bond protector targeting damage from frequent colour and smoothening. Distributors confirmed in Delhi/Mumbai/Bangalore. Wholesale ₹3,200/bottle, retail ~₹4,500.",
52
+ "actionable": "Worth piloting if your colour client mix is >25%"
53
+ },
54
+ {
55
+ "id": "d_2026W17_keratin_alt",
56
+ "kind": "trend",
57
+ "title": "Formaldehyde-free smoothening alternatives gaining share — citric-acid based",
58
+ "source": "Salon India magazine, Apr 2026",
59
+ "summary": "Brazilian citric-acid keratin alternatives now widely available. Lower fume, comparable hold for ~6-8 weeks. Pricing roughly +15% over standard keratin.",
60
+ "actionable": "Position as 'safe smoothening' for pregnancy / asthma clients"
61
+ },
62
+ {
63
+ "id": "d_2026W17_bridal_season_start",
64
+ "kind": "seasonal",
65
+ "title": "Wedding season opener — first lean April-May window before main Oct-Dec rush",
66
+ "source": "Wedding industry intel",
67
+ "summary": "Mini-bridal window April-May (~15% of annual bridal volume) often missed by salons focused on Oct-Dec. Pre-wedding skincare + mehendi-prep services peak now.",
68
+ "actionable": "Run a 'Bridal Trial @ ₹999' offer; book 2-month skincare package"
69
+ },
70
+ {
71
+ "id": "d_2026W17_olaplex_training",
72
+ "kind": "cde",
73
+ "title": "L'Oreal Professionnel India training: Advanced Balayage Masterclass",
74
+ "source": "L'Oreal Pro India calendar",
75
+ "date": "2026-05-08",
76
+ "summary": "2-day in-person training in Mumbai. ₹15,000 fee. Targeted at stylists with 2+ years of colour experience.",
77
+ "actionable": "Worth subsidising for one stylist if balayage demand is rising in your locality"
78
+ },
79
+ {
80
+ "id": "d_2026W17_walkin_priority",
81
+ "kind": "trend",
82
+ "title": "'Walk-in available' tag on GBP boosting calls 23% in metros",
83
+ "source": "magicpin internal, Apr 2026",
84
+ "summary": "Salons that explicitly add 'walk-in available' to their GBP description see 23% higher call volume in the same locality vs salons without. Effect strongest weekday afternoons.",
85
+ "actionable": "Add the tag to your GBP description this week"
86
+ }
87
+ ],
88
+ "patient_content_library": [
89
+ {
90
+ "id": "pc_keratin_safe",
91
+ "title": "Is keratin safe? What to ask before booking",
92
+ "channel": "whatsapp",
93
+ "length_seconds": 60,
94
+ "body": "Old-school keratin uses formaldehyde — safe in trained hands but not for pregnancy or asthma. Citric-acid alternatives exist now with similar hold. Two questions to ask any salon: (1) what's your activator? (2) is the room ventilated? If you don't get a clear answer, look elsewhere."
95
+ },
96
+ {
97
+ "id": "pc_balayage_vs_highlights",
98
+ "title": "Balayage vs highlights — which actually suits you?",
99
+ "channel": "whatsapp",
100
+ "length_seconds": 75,
101
+ "body": "Highlights are uniform stripes; balayage is hand-painted, growing out softly with no visible regrowth line. Highlights cost less but need touch-ups every 8 weeks. Balayage holds 4-5 months. If you don't visit the salon often, balayage is the practical choice."
102
+ },
103
+ {
104
+ "id": "pc_post_color_care",
105
+ "title": "Post-colour care — first 72 hours matter most",
106
+ "channel": "whatsapp",
107
+ "length_seconds": 45,
108
+ "body": "Three rules: no shampoo for 48h (let cuticle close), use sulphate-free shampoo from day 3, and avoid hot water washes for the first month. Skip these and you'll lose 30% of the colour vibrance in the first wash."
109
+ }
110
+ ],
111
+ "seasonal_beats": [
112
+ { "month_range": "Oct-Dec", "note": "primary wedding/festival season — bridal package bookings 4x baseline" },
113
+ { "month_range": "Apr-May", "note": "secondary bridal window + summer hair-care surge" },
114
+ { "month_range": "Jul-Aug", "note": "monsoon haircare focus (anti-frizz, scalp treatments)" },
115
+ { "month_range": "Mar", "note": "Holi colour-recovery surge — book hair spas the week after" }
116
+ ],
117
+ "trend_signals": [
118
+ { "query": "balayage near me", "delta_yoy": 0.45, "segment_age": "25-40", "skew": "female" },
119
+ { "query": "keratin treatment price", "delta_yoy": 0.18, "segment_age": "25-45", "skew": "female" },
120
+ { "query": "men's haircut delhi", "delta_yoy": 0.22, "segment_age": "20-35", "skew": "male" },
121
+ { "query": "bridal makeup artist", "delta_yoy": 0.31, "segment_age": "22-32", "skew": "female" }
122
+ ],
123
+ "regulatory_authorities": ["FDA India (cosmetic ingredient compliance)"],
124
+ "professional_journals": ["Salon India", "Pure Beauty India"]
125
+ }
dataset/customers_seed.json ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_README": "15 representative CustomerContexts. The generator expands these to 200 by varying demographic + relationship state per merchant.",
3
+ "customers": [
4
+ {
5
+ "customer_id": "c_001_priya_for_m001",
6
+ "merchant_id": "m_001_drmeera_dentist_delhi",
7
+ "identity": { "name": "Priya", "phone_redacted": "<phone>", "language_pref": "hi-en mix", "age_band": "25-35" },
8
+ "relationship": { "first_visit": "2025-11-04", "last_visit": "2026-05-12", "visits_total": 4, "services_received": ["cleaning", "cleaning", "whitening", "cleaning"], "lifetime_value": 1696 },
9
+ "state": "lapsed_soft",
10
+ "preferences": { "preferred_slots": "weekday_evening", "channel": "whatsapp", "reminder_opt_in": true },
11
+ "consent": { "opted_in_at": "2025-11-04", "scope": ["recall_reminders", "appointment_reminders"] }
12
+ },
13
+ {
14
+ "customer_id": "c_002_rohit_for_m001",
15
+ "merchant_id": "m_001_drmeera_dentist_delhi",
16
+ "identity": { "name": "Rohit", "phone_redacted": "<phone>", "language_pref": "english", "age_band": "35-45" },
17
+ "relationship": { "first_visit": "2026-02-12", "last_visit": "2026-04-18", "visits_total": 2, "services_received": ["root_canal_consult", "root_canal_session_1"], "lifetime_value": 5500 },
18
+ "state": "active",
19
+ "preferences": { "preferred_slots": "saturday_morning", "channel": "whatsapp", "reminder_opt_in": true },
20
+ "consent": { "opted_in_at": "2026-02-12", "scope": ["recall_reminders", "appointment_reminders", "treatment_followup"] }
21
+ },
22
+ {
23
+ "customer_id": "c_003_aanya_for_m001",
24
+ "merchant_id": "m_001_drmeera_dentist_delhi",
25
+ "identity": { "name": "Aanya (parent: Sneha)", "phone_redacted": "<phone>", "language_pref": "hi-en mix", "age_band": "child_under_12" },
26
+ "relationship": { "first_visit": "2026-01-08", "last_visit": "2026-01-08", "visits_total": 1, "services_received": ["pediatric_checkup"], "lifetime_value": 199 },
27
+ "state": "lapsed_hard",
28
+ "preferences": { "preferred_slots": "weekday_after_3pm", "channel": "whatsapp", "reminder_opt_in": true },
29
+ "consent": { "opted_in_at": "2026-01-08", "scope": ["recall_reminders"] }
30
+ },
31
+ {
32
+ "customer_id": "c_004_sneha_for_m003",
33
+ "merchant_id": "m_003_studio11_salon_hyderabad",
34
+ "identity": { "name": "Sneha", "phone_redacted": "<phone>", "language_pref": "te-en mix", "age_band": "25-35" },
35
+ "relationship": { "first_visit": "2025-09-14", "last_visit": "2026-04-19", "visits_total": 11, "services_received": ["balayage", "hair_spa", "balayage_touchup", "manicure", "pedicure", "haircut", "haircut", "balayage", "hair_spa", "facial", "haircut"], "lifetime_value": 18450 },
36
+ "state": "active",
37
+ "preferences": { "preferred_slots": "saturday_afternoon", "channel": "whatsapp", "reminder_opt_in": true, "preferred_stylist": "Priya" },
38
+ "consent": { "opted_in_at": "2025-09-14", "scope": ["appointment_reminders", "promotional_offers", "stylist_specific"] }
39
+ },
40
+ {
41
+ "customer_id": "c_005_kavya_for_m003",
42
+ "merchant_id": "m_003_studio11_salon_hyderabad",
43
+ "identity": { "name": "Kavya", "phone_redacted": "<phone>", "language_pref": "english", "age_band": "20-25" },
44
+ "relationship": { "first_visit": "2026-03-22", "last_visit": "2026-03-22", "visits_total": 1, "services_received": ["bridal_trial"], "lifetime_value": 999 },
45
+ "state": "new",
46
+ "preferences": { "preferred_slots": "saturday", "channel": "whatsapp", "reminder_opt_in": true, "wedding_date": "2026-11-08" },
47
+ "consent": { "opted_in_at": "2026-03-22", "scope": ["appointment_reminders", "bridal_package_followup"] }
48
+ },
49
+ {
50
+ "customer_id": "c_006_amit_for_m005",
51
+ "merchant_id": "m_005_pizzajunction_restaurant_delhi",
52
+ "identity": { "name": "Amit", "phone_redacted": "<phone>", "language_pref": "hi-en mix", "age_band": "25-35" },
53
+ "relationship": { "first_visit": "2026-04-12", "last_visit": "2026-04-22", "visits_total": 5, "services_received": ["delivery_pizza", "delivery_combo", "dine_in", "delivery_pizza", "delivery_pizza"], "lifetime_value": 2380, "favourite_dish": "BBQ Chicken Pizza" },
54
+ "state": "active",
55
+ "preferences": { "preferred_slots": "fri_sat_night", "channel": "whatsapp", "reminder_opt_in": true },
56
+ "consent": { "opted_in_at": "2026-04-12", "scope": ["promotional_offers", "match_night_specials"] }
57
+ },
58
+ {
59
+ "customer_id": "c_007_naveen_for_m006",
60
+ "merchant_id": "m_006_southindiancafe_restaurant_bangalore",
61
+ "identity": { "name": "Naveen", "phone_redacted": "<phone>", "language_pref": "kn-en mix", "age_band": "30-40" },
62
+ "relationship": { "first_visit": "2025-06-05", "last_visit": "2026-04-25", "visits_total": 38, "services_received": ["weekday_thali", "weekday_thali", "weekday_thali", "filter_coffee", "..."], "lifetime_value": 6480, "favourite_dish": "Mylari Dosa" },
63
+ "state": "active",
64
+ "preferences": { "preferred_slots": "weekday_lunch", "channel": "whatsapp", "reminder_opt_in": true, "office_nearby": true },
65
+ "consent": { "opted_in_at": "2025-06-05", "scope": ["promotional_offers", "lunch_thali_updates"] }
66
+ },
67
+ {
68
+ "customer_id": "c_008_dipti_for_m006",
69
+ "merchant_id": "m_006_southindiancafe_restaurant_bangalore",
70
+ "identity": { "name": "Dipti", "phone_redacted": "<phone>", "language_pref": "english", "age_band": "30-40" },
71
+ "relationship": { "first_visit": "2025-12-18", "last_visit": "2026-01-05", "visits_total": 2, "services_received": ["family_brunch", "delivery_dosa_combo"], "lifetime_value": 1280 },
72
+ "state": "lapsed_soft",
73
+ "preferences": { "preferred_slots": "sunday_brunch", "channel": "whatsapp", "reminder_opt_in": true, "family_size": 4 },
74
+ "consent": { "opted_in_at": "2025-12-18", "scope": ["promotional_offers"] }
75
+ },
76
+ {
77
+ "customer_id": "c_009_arjun_for_m007",
78
+ "merchant_id": "m_007_powerhouse_gym_bangalore",
79
+ "identity": { "name": "Arjun", "phone_redacted": "<phone>", "language_pref": "english", "age_band": "25-35" },
80
+ "relationship": { "first_visit": "2026-01-05", "last_visit": "2026-04-21", "visits_total": 78, "services_received": ["membership_jan", "membership_feb", "membership_mar", "membership_apr", "PT_session_x12"], "lifetime_value": 9990 },
81
+ "state": "active",
82
+ "preferences": { "preferred_slots": "weekday_7am", "channel": "whatsapp", "reminder_opt_in": true, "training_focus": "strength" },
83
+ "consent": { "opted_in_at": "2026-01-05", "scope": ["program_updates", "renewal_reminders"] }
84
+ },
85
+ {
86
+ "customer_id": "c_010_rashmi_for_m007",
87
+ "merchant_id": "m_007_powerhouse_gym_bangalore",
88
+ "identity": { "name": "Rashmi", "phone_redacted": "<phone>", "language_pref": "english", "age_band": "30-40" },
89
+ "relationship": { "first_visit": "2025-09-10", "last_visit": "2026-02-28", "visits_total": 22, "services_received": ["membership_x4", "PT_intro"], "lifetime_value": 4490 },
90
+ "state": "lapsed_hard",
91
+ "preferences": { "preferred_slots": "weekday_evening", "channel": "whatsapp", "reminder_opt_in": true, "training_focus": "weight_loss" },
92
+ "consent": { "opted_in_at": "2025-09-10", "scope": ["renewal_reminders", "winback_offers"] }
93
+ },
94
+ {
95
+ "customer_id": "c_011_sumitra_for_m008",
96
+ "merchant_id": "m_008_zenyoga_gym_chennai",
97
+ "identity": { "name": "Sumitra", "phone_redacted": "<phone>", "language_pref": "ta-en mix", "age_band": "45-55" },
98
+ "relationship": { "first_visit": "2025-04-12", "last_visit": "2026-04-26", "visits_total": 145, "services_received": ["yoga_intermediate_x12_months", "pilates_x4_months"], "lifetime_value": 28800 },
99
+ "state": "active",
100
+ "preferences": { "preferred_slots": "morning_6am", "channel": "whatsapp", "reminder_opt_in": true, "health_focus": "back_pain_management" },
101
+ "consent": { "opted_in_at": "2025-04-12", "scope": ["program_updates", "health_content", "renewal_reminders"] }
102
+ },
103
+ {
104
+ "customer_id": "c_012_karthik_jr_for_m008",
105
+ "merchant_id": "m_008_zenyoga_gym_chennai",
106
+ "identity": { "name": "Karthik (parent: Sumitra)", "phone_redacted": "<phone>", "language_pref": "ta-en mix", "age_band": "child_7-12" },
107
+ "relationship": { "first_visit": "2026-04-22", "last_visit": "2026-04-22", "visits_total": 1, "services_received": ["kids_yoga_trial"], "lifetime_value": 0 },
108
+ "state": "new",
109
+ "preferences": { "preferred_slots": "saturday_morning", "channel": "whatsapp_via_parent", "reminder_opt_in": true },
110
+ "consent": { "opted_in_at": "2026-04-22", "scope": ["kids_program_updates"] }
111
+ },
112
+ {
113
+ "customer_id": "c_013_grandfather_for_m009",
114
+ "merchant_id": "m_009_apollo_pharmacy_jaipur",
115
+ "identity": { "name": "Mr. Sharma", "phone_redacted": "<phone>", "language_pref": "hi", "age_band": "65-75", "senior_citizen": true },
116
+ "relationship": { "first_visit": "2024-08-10", "last_visit": "2026-04-22", "visits_total": 24, "services_received": ["chronic_rx_metformin", "chronic_rx_atorvastatin", "chronic_rx_telmisartan", "..."], "lifetime_value": 24600, "chronic_conditions": ["diabetes_t2", "hypertension", "dyslipidemia"] },
117
+ "state": "active",
118
+ "preferences": { "preferred_slots": "morning_delivery", "channel": "whatsapp_via_son", "reminder_opt_in": true, "delivery_address": "saved" },
119
+ "consent": { "opted_in_at": "2024-08-10", "scope": ["refill_reminders", "delivery_notifications", "recall_alerts"] }
120
+ },
121
+ {
122
+ "customer_id": "c_014_priti_for_m009",
123
+ "merchant_id": "m_009_apollo_pharmacy_jaipur",
124
+ "identity": { "name": "Priti", "phone_redacted": "<phone>", "language_pref": "hi-en mix", "age_band": "30-40" },
125
+ "relationship": { "first_visit": "2026-03-10", "last_visit": "2026-04-12", "visits_total": 3, "services_received": ["walk_in_otc", "delivery_otc", "consult_with_pharmacist"], "lifetime_value": 580 },
126
+ "state": "active",
127
+ "preferences": { "preferred_slots": "evening", "channel": "whatsapp", "reminder_opt_in": true, "household_size": 4 },
128
+ "consent": { "opted_in_at": "2026-03-10", "scope": ["promotional_offers", "seasonal_health_content"] }
129
+ },
130
+ {
131
+ "customer_id": "c_015_anonymous_for_m010",
132
+ "merchant_id": "m_010_sunrisepharm_pharmacy_lucknow",
133
+ "identity": { "name": "(walk-in, no profile)", "phone_redacted": null, "language_pref": "hi", "age_band": "unknown" },
134
+ "relationship": { "first_visit": "2026-04-15", "last_visit": "2026-04-15", "visits_total": 1, "services_received": ["walk_in_otc"], "lifetime_value": 240 },
135
+ "state": "new",
136
+ "preferences": { "channel": "none_recorded", "reminder_opt_in": false },
137
+ "consent": { "opted_in_at": null, "scope": [] }
138
+ }
139
+ ]
140
+ }
dataset/generate_dataset.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Expand seed JSON files into the full challenge dataset.
4
+
5
+ Usage:
6
+ python generate_dataset.py --out ./expanded
7
+
8
+ Reads from:
9
+ categories/*.json — already-complete category contexts (5)
10
+ merchants_seed.json — 10 representative merchants (2 per category)
11
+ customers_seed.json — 15 representative customers
12
+ triggers_seed.json — 25 representative triggers
13
+
14
+ Writes to ./expanded/:
15
+ categories/{slug}.json (5 files, copied as-is)
16
+ merchants/m_NNN_*.json (50 files — seeds + 40 generated)
17
+ customers/c_NNN_*.json (200 files — seeds + 185 generated)
18
+ triggers/trg_NNN_*.json (100 files — seeds + 75 generated)
19
+ test_pairs.json (30 canonical (merchant, trigger) pairs all
20
+ candidates produce a message for)
21
+
22
+ Deterministic — fixed seed, same output for everyone.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import random
31
+ from pathlib import Path
32
+
33
+ SEED = 20260426 # fixed so every candidate gets the same expanded dataset
34
+
35
+ # Indian city + locality pool for variation
36
+ LOCALITIES = {
37
+ "Delhi": ["Lajpat Nagar", "Saket", "Karol Bagh", "Pitampura", "Dwarka", "Rohini", "Greater Kailash", "Vasant Kunj", "Connaught Place", "Hauz Khas"],
38
+ "Mumbai": ["Andheri West", "Bandra", "Borivali", "Powai", "Lower Parel", "Goregaon", "Thane", "Vile Parle", "Juhu", "Worli"],
39
+ "Bangalore": ["HSR Layout", "Indiranagar", "Whitefield", "Koramangala", "JP Nagar", "Marathahalli", "Bellandur", "Jayanagar", "BTM Layout", "Sarjapur"],
40
+ "Hyderabad": ["Kapra", "Kondapur", "Madhapur", "Banjara Hills", "Jubilee Hills", "Kukatpally", "Gachibowli", "Begumpet", "Secunderabad", "LB Nagar"],
41
+ "Chennai": ["Mylapore", "Adyar", "Velachery", "T Nagar", "Anna Nagar", "Tambaram", "OMR", "Nungambakkam", "Porur", "Besant Nagar"],
42
+ "Pune": ["Aundh", "Baner", "Hadapsar", "Kothrud", "Wakad", "Hinjewadi", "Viman Nagar", "Kharadi", "Pimpri", "Magarpatta"],
43
+ "Chandigarh": ["Sector 17", "Sector 22", "Sector 35", "Mohali", "Panchkula", "Sector 9", "Sector 11", "Manimajra", "Sector 8", "Sector 26"],
44
+ "Jaipur": ["Malviya Nagar", "Vaishali Nagar", "Mansarovar", "Tonk Road", "C-Scheme", "Raja Park", "Civil Lines", "Jhotwara", "Bani Park", "Sodala"],
45
+ "Lucknow": ["Gomti Nagar", "Hazratganj", "Indira Nagar", "Aliganj", "Aminabad", "Vibhuti Khand", "Mahanagar", "Aashiana", "Alambagh", "Janakipuram"],
46
+ "Ahmedabad": ["Satellite", "Bodakdev", "Vastrapur", "Maninagar", "Naranpura", "Bopal", "SG Highway", "Navrangpura", "Thaltej", "Chandkheda"],
47
+ }
48
+
49
+ NAME_BANKS = {
50
+ "dentists": [
51
+ ("Dr. Asha", "Asha Dental Care"),
52
+ ("Dr. Vikram", "Smile Crafters"),
53
+ ("Dr. Neha", "Pearl Dental Studio"),
54
+ ("Dr. Rajan", "City Dental Clinic"),
55
+ ("Dr. Priya", "Family Dental Centre"),
56
+ ("Dr. Sameer", "Bright Smile Dental"),
57
+ ("Dr. Tara", "Crown Dental"),
58
+ ("Dr. Karthik", "Apex Dental Care"),
59
+ ],
60
+ "salons": [
61
+ ("Renu", "Beauty Lounge by Renu"),
62
+ ("Karim", "Karim's Salon"),
63
+ ("Anita", "Anita's Beauty Studio"),
64
+ ("Salim", "Studio Cuts"),
65
+ ("Manish", "Aesthetic Hair Studio"),
66
+ ("Geeta", "Glow Up Salon"),
67
+ ("Paras", "Paras Hair & Beauty"),
68
+ ("Sushma", "The Beauty Bar"),
69
+ ],
70
+ "restaurants": [
71
+ ("Suresh", "Madras Express"),
72
+ ("Anand", "Chai Point Cafe"),
73
+ ("Karim", "Kabab Junction"),
74
+ ("Sandeep", "Tandoor Treats"),
75
+ ("Ravi", "Veg Bowl"),
76
+ ("Imran", "Biryani House"),
77
+ ("Mukesh", "Pizza Spot"),
78
+ ("Lalit", "Family Diner"),
79
+ ],
80
+ "gyms": [
81
+ ("Karan", "Iron Forge Fitness"),
82
+ ("Sneha", "Pulse Studio"),
83
+ ("Akash", "Fit Republic"),
84
+ ("Roshni", "Active Life Gym"),
85
+ ("Vivek", "Strength Co."),
86
+ ("Manisha", "Vyayam Yoga"),
87
+ ("Deepak", "Body Mechanics"),
88
+ ("Pooja", "Bend & Burn"),
89
+ ],
90
+ "pharmacies": [
91
+ ("Anil", "Healthwell Pharmacy"),
92
+ ("Rajesh", "MedPlus Express"),
93
+ ("Sunita", "Reliable Medicos"),
94
+ ("Vinod", "Family Health Pharmacy"),
95
+ ("Bharti", "Wellness Cart"),
96
+ ("Sanjay", "TrueCare Medicos"),
97
+ ("Mohit", "QuickRx Pharmacy"),
98
+ ("Komal", "Daily Care Medicos"),
99
+ ],
100
+ }
101
+
102
+
103
+ def load_seeds(seed_dir: Path):
104
+ categories = {}
105
+ for f in (seed_dir / "categories").glob("*.json"):
106
+ with open(f) as fp:
107
+ data = json.load(fp)
108
+ categories[data["slug"]] = data
109
+ with open(seed_dir / "merchants_seed.json") as fp:
110
+ merchants = json.load(fp)["merchants"]
111
+ with open(seed_dir / "customers_seed.json") as fp:
112
+ customers = json.load(fp)["customers"]
113
+ with open(seed_dir / "triggers_seed.json") as fp:
114
+ triggers = json.load(fp)["triggers"]
115
+ return categories, merchants, customers, triggers
116
+
117
+
118
+ def expand_merchants(seeds: list[dict], rnd: random.Random) -> list[dict]:
119
+ """Generate 8 additional merchants per category (10 total per category, 50 overall)."""
120
+ expanded = list(seeds)
121
+ by_cat = {}
122
+ for m in seeds:
123
+ by_cat.setdefault(m["category_slug"], []).append(m)
124
+ next_idx = len(seeds) + 1
125
+ for cat_slug in NAME_BANKS:
126
+ existing = len(by_cat.get(cat_slug, []))
127
+ need = 10 - existing
128
+ for i in range(need):
129
+ owner_first, biz_name = rnd.choice(NAME_BANKS[cat_slug])
130
+ city = rnd.choice(list(LOCALITIES.keys()))
131
+ locality = rnd.choice(LOCALITIES[city])
132
+ mid = f"m_{next_idx:03d}_{owner_first.lower().replace(' ', '_').replace('dr.', 'dr')}_{cat_slug.rstrip('s')}_{city.lower()}"
133
+ views = rnd.randint(400, 6000)
134
+ calls = rnd.randint(2, max(3, views // 80))
135
+ ctr = round(rnd.uniform(0.015, 0.060), 3)
136
+ verified = rnd.random() > 0.25
137
+ sub_status = rnd.choices(["active", "expired", "trial"], weights=[7, 2, 1])[0]
138
+ expanded.append({
139
+ "merchant_id": mid,
140
+ "category_slug": cat_slug,
141
+ "identity": {
142
+ "name": biz_name, "city": city, "locality": locality,
143
+ "place_id": f"ChIJ_{locality.upper().replace(' ', '_')}_{cat_slug.upper()}_{next_idx:03d}",
144
+ "verified": verified,
145
+ "languages": ["en", "hi"] + (["mr"] if city == "Mumbai" else ["ta"] if city == "Chennai" else ["te"] if city == "Hyderabad" else ["kn"] if city == "Bangalore" else []),
146
+ "owner_first_name": owner_first,
147
+ "established_year": rnd.randint(2010, 2023),
148
+ },
149
+ "subscription": {"status": sub_status, "plan": "Pro" if sub_status != "trial" else "Trial",
150
+ "days_remaining": rnd.randint(5, 300) if sub_status == "active" else (rnd.randint(1, 14) if sub_status == "trial" else 0),
151
+ "days_since_expiry": rnd.randint(7, 90) if sub_status == "expired" else None},
152
+ "performance": {"window_days": 30, "views": views, "calls": calls,
153
+ "directions": calls * 2 + rnd.randint(0, 30),
154
+ "ctr": ctr, "leads": rnd.randint(0, calls),
155
+ "delta_7d": {"views_pct": round(rnd.uniform(-0.30, 0.30), 2),
156
+ "calls_pct": round(rnd.uniform(-0.30, 0.30), 2)}},
157
+ "offers": [],
158
+ "conversation_history": [],
159
+ "customer_aggregate": {"total_unique_ytd": rnd.randint(50, 2000)},
160
+ "signals": [],
161
+ "review_themes": [],
162
+ })
163
+ next_idx += 1
164
+ return expanded
165
+
166
+
167
+ def expand_customers(seeds: list[dict], merchants: list[dict], rnd: random.Random) -> list[dict]:
168
+ """Generate ~3-5 customers per merchant up to 200 total."""
169
+ expanded = list(seeds)
170
+ next_idx = len(seeds) + 1
171
+ target_per_merchant = 4
172
+ have_per_merchant = {}
173
+ for c in seeds:
174
+ have_per_merchant[c["merchant_id"]] = have_per_merchant.get(c["merchant_id"], 0) + 1
175
+ customer_names = ["Aarav", "Vivaan", "Aditya", "Vihaan", "Arjun", "Ishaan", "Reyansh", "Aryan", "Ananya", "Aadhya", "Saanvi", "Kavya", "Diya", "Ira", "Myra", "Anika", "Riya", "Tara"]
176
+ for m in merchants:
177
+ cur = have_per_merchant.get(m["merchant_id"], 0)
178
+ for i in range(max(0, target_per_merchant - cur)):
179
+ if next_idx > 200 + len(seeds): break
180
+ name = rnd.choice(customer_names)
181
+ cid = f"c_{next_idx:03d}_{name.lower()}_for_{m['merchant_id']}"
182
+ visits = rnd.randint(1, 12)
183
+ state = rnd.choices(
184
+ ["new", "active", "lapsed_soft", "lapsed_hard", "churned"],
185
+ weights=[1, 4, 2, 1, 1]
186
+ )[0]
187
+ expanded.append({
188
+ "customer_id": cid,
189
+ "merchant_id": m["merchant_id"],
190
+ "identity": {"name": name, "phone_redacted": "<phone>",
191
+ "language_pref": rnd.choice(["en", "hi-en mix", "hi"]),
192
+ "age_band": rnd.choice(["20-25", "25-35", "30-40", "40-50", "50-65"])},
193
+ "relationship": {"first_visit": "2025-09-01", "last_visit": "2026-04-01",
194
+ "visits_total": visits, "services_received": [],
195
+ "lifetime_value": visits * rnd.randint(200, 1500)},
196
+ "state": state,
197
+ "preferences": {"channel": "whatsapp", "reminder_opt_in": rnd.random() > 0.2},
198
+ "consent": {"opted_in_at": "2025-09-01", "scope": ["promotional_offers"]},
199
+ })
200
+ next_idx += 1
201
+ return expanded
202
+
203
+
204
+ def expand_triggers(seeds: list[dict], merchants: list[dict], customers: list[dict], rnd: random.Random) -> list[dict]:
205
+ """Generate ~75 additional triggers spread across kinds + merchants."""
206
+ expanded = list(seeds)
207
+ next_idx = len(seeds) + 1
208
+ additional_kinds = [
209
+ ("research_digest", "external", "merchant", 1),
210
+ ("perf_dip", "internal", "merchant", 3),
211
+ ("perf_spike", "internal", "merchant", 1),
212
+ ("milestone_reached", "internal", "merchant", 1),
213
+ ("dormant_with_vera", "internal", "merchant", 2),
214
+ ("review_theme_emerged", "internal", "merchant", 3),
215
+ ("competitor_opened", "external", "merchant", 2),
216
+ ("festival_upcoming", "external", "merchant", 1),
217
+ ("recall_due", "internal", "customer", 3),
218
+ ("customer_lapsed_soft", "internal", "customer", 3),
219
+ ("appointment_tomorrow", "internal", "customer", 2),
220
+ ("chronic_refill_due", "internal", "customer", 2),
221
+ ("trial_followup", "internal", "customer", 2),
222
+ ("renewal_due", "internal", "merchant", 4),
223
+ ("curious_ask_due", "internal", "merchant", 1),
224
+ ]
225
+ for kind, source, scope, urgency in additional_kinds:
226
+ for _ in range(5): # 5 of each kind
227
+ if next_idx > 100: break
228
+ m = rnd.choice(merchants)
229
+ cust = None
230
+ if scope == "customer":
231
+ m_customers = [c for c in customers if c["merchant_id"] == m["merchant_id"]]
232
+ if not m_customers: continue
233
+ cust = rnd.choice(m_customers)
234
+ expanded.append({
235
+ "id": f"trg_{next_idx:03d}_{kind}_{m['merchant_id'][:20]}",
236
+ "scope": scope, "kind": kind, "source": source,
237
+ "merchant_id": m["merchant_id"],
238
+ "customer_id": cust["customer_id"] if cust else None,
239
+ "payload": {"placeholder": True, "metric_or_topic": kind},
240
+ "urgency": urgency, "suppression_key": f"{kind}:{m['merchant_id']}:gen_{next_idx}",
241
+ "expires_at": "2026-06-30T00:00:00Z",
242
+ })
243
+ next_idx += 1
244
+ return expanded[:100]
245
+
246
+
247
+ def write_outputs(out_dir: Path, categories, merchants, customers, triggers):
248
+ out_dir.mkdir(parents=True, exist_ok=True)
249
+ (out_dir / "categories").mkdir(exist_ok=True)
250
+ for slug, data in categories.items():
251
+ with open(out_dir / "categories" / f"{slug}.json", "w") as f:
252
+ json.dump(data, f, indent=2, ensure_ascii=False)
253
+ (out_dir / "merchants").mkdir(exist_ok=True)
254
+ for m in merchants:
255
+ with open(out_dir / "merchants" / f"{m['merchant_id']}.json", "w") as f:
256
+ json.dump(m, f, indent=2, ensure_ascii=False)
257
+ (out_dir / "customers").mkdir(exist_ok=True)
258
+ for c in customers:
259
+ with open(out_dir / "customers" / f"{c['customer_id']}.json", "w") as f:
260
+ json.dump(c, f, indent=2, ensure_ascii=False)
261
+ (out_dir / "triggers").mkdir(exist_ok=True)
262
+ for t in triggers:
263
+ with open(out_dir / "triggers" / f"{t['id']}.json", "w") as f:
264
+ json.dump(t, f, indent=2, ensure_ascii=False)
265
+
266
+
267
+ def write_test_pairs(out_dir: Path, triggers, rnd: random.Random):
268
+ """Pick 30 (merchant, trigger) pairs covering all kinds. Same set for everyone."""
269
+ by_kind = {}
270
+ for t in triggers:
271
+ by_kind.setdefault(t["kind"], []).append(t)
272
+ pairs = []
273
+ test_id = 1
274
+ for kind, ts in sorted(by_kind.items()):
275
+ for t in ts[:2]: # take up to 2 per kind
276
+ pairs.append({"test_id": f"T{test_id:02d}", "trigger_id": t["id"],
277
+ "merchant_id": t["merchant_id"], "customer_id": t.get("customer_id")})
278
+ test_id += 1
279
+ if len(pairs) >= 30: break
280
+ if len(pairs) >= 30: break
281
+ with open(out_dir / "test_pairs.json", "w") as f:
282
+ json.dump({"pairs": pairs[:30]}, f, indent=2)
283
+
284
+
285
+ def main():
286
+ parser = argparse.ArgumentParser()
287
+ parser.add_argument("--seed-dir", default=".", help="Directory containing the seed JSON files")
288
+ parser.add_argument("--out", default="./expanded", help="Output directory")
289
+ args = parser.parse_args()
290
+
291
+ rnd = random.Random(SEED)
292
+ seed_dir = Path(args.seed_dir).resolve()
293
+ out_dir = Path(args.out).resolve()
294
+ print(f"Reading seeds from {seed_dir}")
295
+ print(f"Writing to {out_dir}")
296
+
297
+ categories, m_seeds, c_seeds, t_seeds = load_seeds(seed_dir)
298
+ print(f" Loaded {len(categories)} categories, {len(m_seeds)} merchant seeds, "
299
+ f"{len(c_seeds)} customer seeds, {len(t_seeds)} trigger seeds")
300
+
301
+ merchants = expand_merchants(m_seeds, rnd)
302
+ customers = expand_customers(c_seeds, merchants, rnd)
303
+ triggers = expand_triggers(t_seeds, merchants, customers, rnd)
304
+ print(f" Expanded to {len(merchants)} merchants, {len(customers)} customers, {len(triggers)} triggers")
305
+
306
+ write_outputs(out_dir, categories, merchants, customers, triggers)
307
+ write_test_pairs(out_dir, triggers, rnd)
308
+ print(f"Done. Run: ls {out_dir}")
309
+
310
+
311
+ if __name__ == "__main__":
312
+ main()
dataset/merchants_seed.json ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_README": "10 representative MerchantContexts. The generator (generate_dataset.py) expands these to 50 total (10/category) by varying identity + perf numbers deterministically.",
3
+ "merchants": [
4
+ {
5
+ "merchant_id": "m_001_drmeera_dentist_delhi",
6
+ "category_slug": "dentists",
7
+ "identity": {
8
+ "name": "Dr. Meera's Dental Clinic",
9
+ "city": "Delhi",
10
+ "locality": "Lajpat Nagar",
11
+ "place_id": "ChIJ_LAJPATNAGAR_DENTIST_001",
12
+ "verified": true,
13
+ "languages": ["en", "hi"],
14
+ "owner_first_name": "Meera",
15
+ "established_year": 2018
16
+ },
17
+ "subscription": { "status": "active", "plan": "Pro", "days_remaining": 82, "renewed_at": "2026-02-04" },
18
+ "performance": {
19
+ "window_days": 30,
20
+ "views": 2410, "calls": 18, "directions": 45, "ctr": 0.021, "leads": 9,
21
+ "delta_7d": { "views_pct": 0.18, "calls_pct": -0.05, "ctr_pct": 0.02 }
22
+ },
23
+ "offers": [
24
+ { "id": "o_meera_001", "title": "Dental Cleaning @ ₹299", "status": "active", "started": "2026-03-01" },
25
+ { "id": "o_meera_002", "title": "Deep Cleaning @ ₹499", "status": "expired", "ended": "2026-02-28" }
26
+ ],
27
+ "conversation_history": [
28
+ { "ts": "2026-04-24T10:12:00Z", "from": "vera", "body": "Profile audit done — your photos are 8/10, description complete, but Google posts are stale (last post 22 days ago). Want me to draft 3 posts you can review?", "engagement": "merchant_replied" },
29
+ { "ts": "2026-04-24T10:18:00Z", "from": "merchant", "body": "Yes please, focus on whitening and aligners", "engagement": "intent_action" }
30
+ ],
31
+ "customer_aggregate": { "total_unique_ytd": 540, "lapsed_180d_plus": 78, "retention_6mo_pct": 0.38, "high_risk_adult_count": 124 },
32
+ "signals": ["stale_posts:22d", "ctr_below_peer_median", "high_risk_adult_cohort", "engaged_in_last_48h"],
33
+ "review_themes": [
34
+ { "theme": "wait_time", "sentiment": "neg", "occurrences_30d": 3, "common_quote": "had to wait 30 min on Sunday afternoon" },
35
+ { "theme": "doctor_manner", "sentiment": "pos", "occurrences_30d": 5, "common_quote": "Dr. Meera explains everything patiently" }
36
+ ]
37
+ },
38
+ {
39
+ "merchant_id": "m_002_bharat_dentist_mumbai",
40
+ "category_slug": "dentists",
41
+ "identity": {
42
+ "name": "Bharat Dental Care",
43
+ "city": "Mumbai",
44
+ "locality": "Andheri West",
45
+ "place_id": "ChIJ_ANDHERI_DENTIST_002",
46
+ "verified": false,
47
+ "languages": ["en", "hi", "mr"],
48
+ "owner_first_name": "Bharat",
49
+ "established_year": 2010
50
+ },
51
+ "subscription": { "status": "active", "plan": "Pro", "days_remaining": 12, "renewed_at": "2025-04-26" },
52
+ "performance": {
53
+ "window_days": 30,
54
+ "views": 980, "calls": 4, "directions": 18, "ctr": 0.018, "leads": 2,
55
+ "delta_7d": { "views_pct": -0.22, "calls_pct": -0.50, "ctr_pct": -0.10 }
56
+ },
57
+ "offers": [],
58
+ "conversation_history": [
59
+ { "ts": "2026-04-10T11:00:00Z", "from": "vera", "body": "Subscription expires in 16 days — Bharat Dental Care...", "engagement": "merchant_no_reply" }
60
+ ],
61
+ "customer_aggregate": { "total_unique_ytd": 220, "lapsed_180d_plus": 95, "retention_6mo_pct": 0.18 },
62
+ "signals": ["renewal_due_soon:12d", "perf_dip_severe", "unverified_gbp", "dormant_with_vera_14d", "no_active_offers"],
63
+ "review_themes": []
64
+ },
65
+ {
66
+ "merchant_id": "m_003_studio11_salon_hyderabad",
67
+ "category_slug": "salons",
68
+ "identity": {
69
+ "name": "Studio11 Family Salon",
70
+ "city": "Hyderabad",
71
+ "locality": "Kapra",
72
+ "place_id": "ChIJ_KAPRA_SALON_003",
73
+ "verified": true,
74
+ "languages": ["en", "hi", "te"],
75
+ "owner_first_name": "Lakshmi",
76
+ "established_year": 2019
77
+ },
78
+ "subscription": { "status": "active", "plan": "Pro", "days_remaining": 145 },
79
+ "performance": {
80
+ "window_days": 30,
81
+ "views": 4980, "calls": 62, "directions": 142, "ctr": 0.048, "leads": 38,
82
+ "delta_7d": { "views_pct": 0.14, "calls_pct": 0.20, "ctr_pct": 0.05 }
83
+ },
84
+ "offers": [
85
+ { "id": "o_studio11_001", "title": "Haircut @ ₹99", "status": "active", "started": "2026-03-01" },
86
+ { "id": "o_studio11_002", "title": "Hair Spa @ ₹499", "status": "active", "started": "2026-03-15" }
87
+ ],
88
+ "conversation_history": [
89
+ { "ts": "2026-04-22T15:00:00Z", "from": "vera", "body": "Spotted: bridal-trial searches in Kapra +28% this week. Want me to push your bridal package as a GBP post?", "engagement": "merchant_no_reply" }
90
+ ],
91
+ "customer_aggregate": { "total_unique_ytd": 1150, "lapsed_90d_plus": 220, "retention_3mo_pct": 0.62 },
92
+ "signals": ["high_engagement", "above_peer_median_calls", "growing_views_7d"],
93
+ "review_themes": [
94
+ { "theme": "stylist_skill", "sentiment": "pos", "occurrences_30d": 12, "common_quote": "Priya is the best for balayage" },
95
+ { "theme": "saturday_wait", "sentiment": "neg", "occurrences_30d": 2 }
96
+ ]
97
+ },
98
+ {
99
+ "merchant_id": "m_004_glamour_salon_pune",
100
+ "category_slug": "salons",
101
+ "identity": {
102
+ "name": "Glamour Lounge Spa & Salon",
103
+ "city": "Pune",
104
+ "locality": "Aundh",
105
+ "place_id": "ChIJ_AUNDH_SALON_004",
106
+ "verified": true,
107
+ "languages": ["en", "hi", "mr"],
108
+ "owner_first_name": "Anjali",
109
+ "established_year": 2021
110
+ },
111
+ "subscription": { "status": "expired", "plan": "Pro", "days_since_expiry": 38 },
112
+ "performance": {
113
+ "window_days": 30,
114
+ "views": 1200, "calls": 8, "directions": 22, "ctr": 0.022, "leads": 3,
115
+ "delta_7d": { "views_pct": -0.12, "calls_pct": -0.30, "ctr_pct": -0.04 }
116
+ },
117
+ "offers": [],
118
+ "conversation_history": [
119
+ { "ts": "2026-03-19T14:00:00Z", "from": "vera", "body": "Subscription expired. Profile maintenance paused...", "engagement": "merchant_no_reply" }
120
+ ],
121
+ "customer_aggregate": { "total_unique_ytd": 380, "lapsed_90d_plus": 180, "retention_3mo_pct": 0.32 },
122
+ "signals": ["winback_eligible", "perf_dip_post_expiry", "dormant_with_vera_38d"],
123
+ "review_themes": []
124
+ },
125
+ {
126
+ "merchant_id": "m_005_pizzajunction_restaurant_delhi",
127
+ "category_slug": "restaurants",
128
+ "identity": {
129
+ "name": "SK Pizza Junction",
130
+ "city": "Delhi",
131
+ "locality": "Sant Nagar",
132
+ "place_id": "ChIJ_SANTNAGAR_RESTAURANT_005",
133
+ "verified": false,
134
+ "languages": ["en", "hi"],
135
+ "owner_first_name": "Suresh",
136
+ "established_year": 2022
137
+ },
138
+ "subscription": { "status": "trial", "plan": "Trial", "days_remaining": 7 },
139
+ "performance": {
140
+ "window_days": 30,
141
+ "views": 2200, "calls": 12, "directions": 38, "ctr": 0.020, "leads": 4,
142
+ "delta_7d": { "views_pct": 0.08, "calls_pct": 0.10 }
143
+ },
144
+ "offers": [
145
+ { "id": "o_skpz_001", "title": "Buy 1 Pizza Get 1 Free (Tue-Thu)", "status": "active", "started": "2026-04-15" }
146
+ ],
147
+ "conversation_history": [
148
+ { "ts": "2026-04-25T18:00:00Z", "from": "vera", "body": "Quick check — IPL match nights driving any extra footfall?", "engagement": "merchant_no_reply" }
149
+ ],
150
+ "customer_aggregate": { "total_unique_ytd": 0, "delivery_orders_30d": 180, "dine_in_orders_30d": 95 },
151
+ "signals": ["new_merchant", "trial_ending_soon", "ipl_eligible_locality"],
152
+ "review_themes": [
153
+ { "theme": "delivery_late", "sentiment": "neg", "occurrences_30d": 4 },
154
+ { "theme": "pizza_quality", "sentiment": "pos", "occurrences_30d": 8 }
155
+ ]
156
+ },
157
+ {
158
+ "merchant_id": "m_006_southindiancafe_restaurant_bangalore",
159
+ "category_slug": "restaurants",
160
+ "identity": {
161
+ "name": "Mylari South Indian Cafe",
162
+ "city": "Bangalore",
163
+ "locality": "Indiranagar",
164
+ "place_id": "ChIJ_INDIRANAGAR_RESTAURANT_006",
165
+ "verified": true,
166
+ "languages": ["en", "hi", "kn"],
167
+ "owner_first_name": "Suresh",
168
+ "established_year": 2014
169
+ },
170
+ "subscription": { "status": "active", "plan": "Pro", "days_remaining": 240 },
171
+ "performance": {
172
+ "window_days": 30,
173
+ "views": 12400, "calls": 88, "directions": 320, "ctr": 0.032, "leads": 145,
174
+ "delta_7d": { "views_pct": 0.05, "calls_pct": 0.02 }
175
+ },
176
+ "offers": [
177
+ { "id": "o_mylari_001", "title": "Weekday Lunch Thali @ ₹149", "status": "active", "started": "2026-01-10" }
178
+ ],
179
+ "conversation_history": [
180
+ { "ts": "2026-04-25T11:00:00Z", "from": "vera", "body": "Your weekday thali is doing well — 18 orders/day avg. Want me to add a corporate-bulk version?", "engagement": "merchant_replied" },
181
+ { "ts": "2026-04-25T11:30:00Z", "from": "merchant", "body": "Yes good idea, what would it look like", "engagement": "intent_question" }
182
+ ],
183
+ "customer_aggregate": { "total_unique_ytd": 4200, "repeat_customer_pct": 0.42, "delivery_share_pct": 0.45 },
184
+ "signals": ["high_volume", "stable_growth", "engaged_in_last_24h"],
185
+ "review_themes": [
186
+ { "theme": "thali_quality", "sentiment": "pos", "occurrences_30d": 22 },
187
+ { "theme": "weekend_busy", "sentiment": "neg", "occurrences_30d": 3 }
188
+ ]
189
+ },
190
+ {
191
+ "merchant_id": "m_007_powerhouse_gym_bangalore",
192
+ "category_slug": "gyms",
193
+ "identity": {
194
+ "name": "PowerHouse Fitness",
195
+ "city": "Bangalore",
196
+ "locality": "HSR Layout",
197
+ "place_id": "ChIJ_HSR_GYM_007",
198
+ "verified": true,
199
+ "languages": ["en", "hi", "kn"],
200
+ "owner_first_name": "Karthik",
201
+ "established_year": 2020
202
+ },
203
+ "subscription": { "status": "active", "plan": "Pro", "days_remaining": 95 },
204
+ "performance": {
205
+ "window_days": 30,
206
+ "views": 1480, "calls": 22, "directions": 48, "ctr": 0.052, "leads": 14,
207
+ "delta_7d": { "views_pct": -0.30, "calls_pct": -0.35 }
208
+ },
209
+ "offers": [
210
+ { "id": "o_powerhouse_001", "title": "3 FREE Trial Classes", "status": "active", "started": "2026-01-01" }
211
+ ],
212
+ "conversation_history": [],
213
+ "customer_aggregate": { "total_active_members": 245, "monthly_churn_pct": 0.10, "trial_to_paid_pct": 0.28 },
214
+ "signals": ["seasonal_dip_apr_may", "above_peer_ctr", "no_recent_post"],
215
+ "review_themes": [
216
+ { "theme": "equipment_quality", "sentiment": "pos", "occurrences_30d": 7 },
217
+ { "theme": "morning_crowd", "sentiment": "neg", "occurrences_30d": 4 }
218
+ ]
219
+ },
220
+ {
221
+ "merchant_id": "m_008_zenyoga_gym_chennai",
222
+ "category_slug": "gyms",
223
+ "identity": {
224
+ "name": "Zen Yoga Studio",
225
+ "city": "Chennai",
226
+ "locality": "Mylapore",
227
+ "place_id": "ChIJ_MYLAPORE_GYM_008",
228
+ "verified": true,
229
+ "languages": ["en", "ta", "hi"],
230
+ "owner_first_name": "Padma",
231
+ "established_year": 2017
232
+ },
233
+ "subscription": { "status": "active", "plan": "Pro", "days_remaining": 180 },
234
+ "performance": {
235
+ "window_days": 30,
236
+ "views": 880, "calls": 18, "directions": 38, "ctr": 0.062, "leads": 12,
237
+ "delta_7d": { "views_pct": 0.10, "calls_pct": 0.15 }
238
+ },
239
+ "offers": [
240
+ { "id": "o_zen_001", "title": "First Month @ ₹499", "status": "active", "started": "2026-03-01" },
241
+ { "id": "o_zen_002", "title": "Free Body Composition Analysis", "status": "active", "started": "2026-03-01" }
242
+ ],
243
+ "conversation_history": [
244
+ { "ts": "2026-04-23T09:00:00Z", "from": "merchant", "body": "Hi I want to add a kids yoga program — what should it look like?", "engagement": "intent_planning" },
245
+ { "ts": "2026-04-23T09:05:00Z", "from": "vera", "body": "Great idea — kids yoga summer camps are peaking now. Suggest 4-week program, 3 classes/week, age 7-12, ₹2,499. Want me to draft the GBP post + Insta carousel?", "engagement": "merchant_replied" }
246
+ ],
247
+ "customer_aggregate": { "total_active_members": 95, "monthly_churn_pct": 0.05, "trial_to_paid_pct": 0.55 },
248
+ "signals": ["high_retention", "active_planning", "boutique_segment"],
249
+ "review_themes": [
250
+ { "theme": "instructor_quality", "sentiment": "pos", "occurrences_30d": 9 },
251
+ { "theme": "small_classes", "sentiment": "pos", "occurrences_30d": 6 }
252
+ ]
253
+ },
254
+ {
255
+ "merchant_id": "m_009_apollo_pharmacy_jaipur",
256
+ "category_slug": "pharmacies",
257
+ "identity": {
258
+ "name": "Apollo Health Plus Pharmacy",
259
+ "city": "Jaipur",
260
+ "locality": "Malviya Nagar",
261
+ "place_id": "ChIJ_MALVIYA_PHARMACY_009",
262
+ "verified": true,
263
+ "languages": ["en", "hi"],
264
+ "owner_first_name": "Ramesh",
265
+ "established_year": 2016
266
+ },
267
+ "subscription": { "status": "active", "plan": "Pro", "days_remaining": 60 },
268
+ "performance": {
269
+ "window_days": 30,
270
+ "views": 1850, "calls": 38, "directions": 95, "ctr": 0.045, "leads": 24,
271
+ "delta_7d": { "views_pct": 0.06, "calls_pct": 0.08 }
272
+ },
273
+ "offers": [
274
+ { "id": "o_apollo_001", "title": "Free Home Delivery > ₹499", "status": "active", "started": "2026-01-01" },
275
+ { "id": "o_apollo_002", "title": "Senior Citizen 15% OFF", "status": "active", "started": "2026-01-01" }
276
+ ],
277
+ "conversation_history": [
278
+ { "ts": "2026-04-24T08:00:00Z", "from": "vera", "body": "Heads up: voluntary recall on atorvastatin batches X/Y by Mfr Z. Want the customer list filtered for that molecule?", "engagement": "merchant_replied" },
279
+ { "ts": "2026-04-24T08:30:00Z", "from": "merchant", "body": "Yes send me the list please", "engagement": "intent_action" }
280
+ ],
281
+ "customer_aggregate": { "total_unique_ytd": 1820, "repeat_customer_pct": 0.68, "chronic_rx_count": 240 },
282
+ "signals": ["above_peer_calls", "compliance_aware", "high_repeat_rate"],
283
+ "review_themes": [
284
+ { "theme": "delivery_speed", "sentiment": "pos", "occurrences_30d": 11 },
285
+ { "theme": "medicine_availability", "sentiment": "pos", "occurrences_30d": 8 }
286
+ ]
287
+ },
288
+ {
289
+ "merchant_id": "m_010_sunrisepharm_pharmacy_lucknow",
290
+ "category_slug": "pharmacies",
291
+ "identity": {
292
+ "name": "Sunrise Medicos",
293
+ "city": "Lucknow",
294
+ "locality": "Gomti Nagar",
295
+ "place_id": "ChIJ_GOMTINAGAR_PHARMACY_010",
296
+ "verified": false,
297
+ "languages": ["en", "hi"],
298
+ "owner_first_name": "Vikas",
299
+ "established_year": 2020
300
+ },
301
+ "subscription": { "status": "active", "plan": "Basic", "days_remaining": 200 },
302
+ "performance": {
303
+ "window_days": 30,
304
+ "views": 720, "calls": 14, "directions": 32, "ctr": 0.041, "leads": 8,
305
+ "delta_7d": { "views_pct": 0.02, "calls_pct": 0.05 }
306
+ },
307
+ "offers": [],
308
+ "conversation_history": [],
309
+ "customer_aggregate": { "total_unique_ytd": 540, "repeat_customer_pct": 0.45, "chronic_rx_count": 60 },
310
+ "signals": ["unverified_gbp", "no_active_offers", "no_recent_conversation", "delivery_not_set_up"],
311
+ "review_themes": []
312
+ }
313
+ ]
314
+ }
dataset/triggers_seed.json ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_README": "25 representative TriggerContexts spanning external + internal, merchant + customer scope, all kinds. Generator expands to 100.",
3
+ "triggers": [
4
+ {
5
+ "id": "trg_001_research_digest_dentists",
6
+ "scope": "merchant", "kind": "research_digest", "source": "external",
7
+ "merchant_id": "m_001_drmeera_dentist_delhi", "customer_id": null,
8
+ "payload": { "category": "dentists", "top_item_id": "d_2026W17_jida_fluoride" },
9
+ "urgency": 2, "suppression_key": "research:dentists:2026-W17", "expires_at": "2026-05-03T00:00:00Z"
10
+ },
11
+ {
12
+ "id": "trg_002_compliance_dci_radiograph",
13
+ "scope": "merchant", "kind": "regulation_change", "source": "external",
14
+ "merchant_id": "m_001_drmeera_dentist_delhi", "customer_id": null,
15
+ "payload": { "category": "dentists", "top_item_id": "d_2026W17_dci_radiograph", "deadline_iso": "2026-12-15" },
16
+ "urgency": 4, "suppression_key": "compliance:dci_radiograph:2026", "expires_at": "2026-12-15T00:00:00Z"
17
+ },
18
+ {
19
+ "id": "trg_003_recall_due_priya",
20
+ "scope": "customer", "kind": "recall_due", "source": "internal",
21
+ "merchant_id": "m_001_drmeera_dentist_delhi", "customer_id": "c_001_priya_for_m001",
22
+ "payload": { "service_due": "6_month_cleaning", "last_service_date": "2026-05-12", "due_date": "2026-11-12", "available_slots": [{ "iso": "2026-11-05T18:00:00+05:30", "label": "Wed 5 Nov, 6pm" }, { "iso": "2026-11-06T17:00:00+05:30", "label": "Thu 6 Nov, 5pm" }] },
23
+ "urgency": 3, "suppression_key": "recall:c_001_priya_for_m001:6mo", "expires_at": "2026-11-30T00:00:00Z"
24
+ },
25
+ {
26
+ "id": "trg_004_perf_dip_bharat",
27
+ "scope": "merchant", "kind": "perf_dip", "source": "internal",
28
+ "merchant_id": "m_002_bharat_dentist_mumbai", "customer_id": null,
29
+ "payload": { "metric": "calls", "delta_pct": -0.50, "window": "7d", "vs_baseline": 12 },
30
+ "urgency": 4, "suppression_key": "perf_dip:m_002_bharat_dentist_mumbai:calls:2026-W17", "expires_at": "2026-05-10T00:00:00Z"
31
+ },
32
+ {
33
+ "id": "trg_005_renewal_due_bharat",
34
+ "scope": "merchant", "kind": "renewal_due", "source": "internal",
35
+ "merchant_id": "m_002_bharat_dentist_mumbai", "customer_id": null,
36
+ "payload": { "days_remaining": 12, "plan": "Pro", "renewal_amount": 4999 },
37
+ "urgency": 4, "suppression_key": "renewal:m_002_bharat_dentist_mumbai:2026-Q2", "expires_at": "2026-05-08T00:00:00Z"
38
+ },
39
+ {
40
+ "id": "trg_006_festival_diwali",
41
+ "scope": "merchant", "kind": "festival_upcoming", "source": "external",
42
+ "merchant_id": "m_003_studio11_salon_hyderabad", "customer_id": null,
43
+ "payload": { "festival": "Diwali", "date": "2026-10-31", "days_until": 188, "category_relevance": ["salons", "restaurants", "pharmacies"] },
44
+ "urgency": 1, "suppression_key": "festival:diwali:2026:m_003", "expires_at": "2026-11-02T00:00:00Z"
45
+ },
46
+ {
47
+ "id": "trg_007_bridal_followup_kavya",
48
+ "scope": "customer", "kind": "wedding_package_followup", "source": "internal",
49
+ "merchant_id": "m_003_studio11_salon_hyderabad", "customer_id": "c_005_kavya_for_m003",
50
+ "payload": { "wedding_date": "2026-11-08", "trial_completed": "2026-03-22", "days_to_wedding": 196, "next_step_window_open": "skin_prep_program_30day" },
51
+ "urgency": 2, "suppression_key": "bridal_followup:c_005_kavya_for_m003", "expires_at": "2026-11-08T00:00:00Z"
52
+ },
53
+ {
54
+ "id": "trg_008_curious_ask_studio11",
55
+ "scope": "merchant", "kind": "curious_ask_due", "source": "internal",
56
+ "merchant_id": "m_003_studio11_salon_hyderabad", "customer_id": null,
57
+ "payload": { "ask_template": "what_service_in_demand_this_week", "last_ask_at": null },
58
+ "urgency": 1, "suppression_key": "curious_ask:m_003:2026-W17", "expires_at": "2026-05-03T00:00:00Z"
59
+ },
60
+ {
61
+ "id": "trg_009_winback_glamour",
62
+ "scope": "merchant", "kind": "winback_eligible", "source": "internal",
63
+ "merchant_id": "m_004_glamour_salon_pune", "customer_id": null,
64
+ "payload": { "days_since_expiry": 38, "perf_dip_pct": -0.30, "lapsed_customers_added_since_expiry": 24 },
65
+ "urgency": 2, "suppression_key": "winback:m_004_glamour_salon_pune", "expires_at": "2026-06-15T00:00:00Z"
66
+ },
67
+ {
68
+ "id": "trg_010_ipl_match_delhi",
69
+ "scope": "merchant", "kind": "ipl_match_today", "source": "external",
70
+ "merchant_id": "m_005_pizzajunction_restaurant_delhi", "customer_id": null,
71
+ "payload": { "match": "DC vs MI", "venue": "Arun Jaitley Stadium", "city": "Delhi", "match_time_iso": "2026-04-26T19:30:00+05:30", "is_weeknight": false },
72
+ "urgency": 3, "suppression_key": "ipl:m_005:2026-04-26", "expires_at": "2026-04-26T23:59:59+05:30"
73
+ },
74
+ {
75
+ "id": "trg_011_review_theme_late_delivery",
76
+ "scope": "merchant", "kind": "review_theme_emerged", "source": "internal",
77
+ "merchant_id": "m_005_pizzajunction_restaurant_delhi", "customer_id": null,
78
+ "payload": { "theme": "delivery_late", "occurrences_30d": 4, "trend": "rising", "common_quote": "took 50 mins for a 15 min ride" },
79
+ "urgency": 3, "suppression_key": "review_theme:m_005:delivery_late:2026-W17", "expires_at": "2026-05-10T00:00:00Z"
80
+ },
81
+ {
82
+ "id": "trg_012_milestone_mylari",
83
+ "scope": "merchant", "kind": "milestone_reached", "source": "internal",
84
+ "merchant_id": "m_006_southindiancafe_restaurant_bangalore", "customer_id": null,
85
+ "payload": { "metric": "review_count", "value_now": 145, "milestone_value": 150, "is_imminent": true },
86
+ "urgency": 1, "suppression_key": "milestone:m_006:reviews_150", "expires_at": "2026-05-15T00:00:00Z"
87
+ },
88
+ {
89
+ "id": "trg_013_corporate_thali_planning",
90
+ "scope": "merchant", "kind": "active_planning_intent", "source": "internal",
91
+ "merchant_id": "m_006_southindiancafe_restaurant_bangalore", "customer_id": null,
92
+ "payload": { "intent_topic": "corporate_bulk_thali_package", "merchant_last_message": "Yes good idea, what would it look like" },
93
+ "urgency": 4, "suppression_key": "planning:m_006:corp_thali:2026-W17", "expires_at": "2026-04-29T00:00:00Z"
94
+ },
95
+ {
96
+ "id": "trg_014_seasonal_acquisition_dip_powerhouse",
97
+ "scope": "merchant", "kind": "seasonal_perf_dip", "source": "internal",
98
+ "merchant_id": "m_007_powerhouse_gym_bangalore", "customer_id": null,
99
+ "payload": { "metric": "views", "delta_pct": -0.30, "window": "7d", "is_expected_seasonal": true, "season_note": "post_resolution_window_apr_jun" },
100
+ "urgency": 1, "suppression_key": "seasonal_dip:m_007:2026-Q2", "expires_at": "2026-06-30T00:00:00Z"
101
+ },
102
+ {
103
+ "id": "trg_015_winback_rashmi",
104
+ "scope": "customer", "kind": "customer_lapsed_hard", "source": "internal",
105
+ "merchant_id": "m_007_powerhouse_gym_bangalore", "customer_id": "c_010_rashmi_for_m007",
106
+ "payload": { "days_since_last_visit": 57, "previous_focus": "weight_loss", "previous_membership_months": 5 },
107
+ "urgency": 3, "suppression_key": "winback:c_010_rashmi_for_m007", "expires_at": "2026-06-15T00:00:00Z"
108
+ },
109
+ {
110
+ "id": "trg_016_kids_yoga_program_drafting",
111
+ "scope": "merchant", "kind": "active_planning_intent", "source": "internal",
112
+ "merchant_id": "m_008_zenyoga_gym_chennai", "customer_id": null,
113
+ "payload": { "intent_topic": "kids_yoga_summer_camp", "merchant_last_message": "Hi I want to add a kids yoga program — what should it look like?" },
114
+ "urgency": 4, "suppression_key": "planning:m_008:kids_yoga:2026-W17", "expires_at": "2026-04-30T00:00:00Z"
115
+ },
116
+ {
117
+ "id": "trg_017_kids_yoga_trial_followup_karthik",
118
+ "scope": "customer", "kind": "trial_followup", "source": "internal",
119
+ "merchant_id": "m_008_zenyoga_gym_chennai", "customer_id": "c_012_karthik_jr_for_m008",
120
+ "payload": { "trial_date": "2026-04-22", "next_session_options": [{ "iso": "2026-05-03T08:00:00+05:30", "label": "Sat 3 May, 8am" }] },
121
+ "urgency": 2, "suppression_key": "trial_followup:c_012_karthik_jr_for_m008", "expires_at": "2026-05-10T00:00:00Z"
122
+ },
123
+ {
124
+ "id": "trg_018_supply_atorvastatin_recall",
125
+ "scope": "merchant", "kind": "supply_alert", "source": "external",
126
+ "merchant_id": "m_009_apollo_pharmacy_jaipur", "customer_id": null,
127
+ "payload": { "alert_id": "d_2026W17_atorvastatin_recall", "molecule": "atorvastatin", "affected_batches": ["AT2024-1102", "AT2024-1108"], "manufacturer": "MfrZ" },
128
+ "urgency": 5, "suppression_key": "alert:atorvastatin:2026-04", "expires_at": "2026-05-30T00:00:00Z"
129
+ },
130
+ {
131
+ "id": "trg_019_chronic_refill_grandfather",
132
+ "scope": "customer", "kind": "chronic_refill_due", "source": "internal",
133
+ "merchant_id": "m_009_apollo_pharmacy_jaipur", "customer_id": "c_013_grandfather_for_m009",
134
+ "payload": { "molecule_list": ["metformin", "atorvastatin", "telmisartan"], "last_refill": "2026-03-26", "stock_runs_out_iso": "2026-04-28T00:00:00+05:30", "delivery_address_saved": true },
135
+ "urgency": 3, "suppression_key": "refill:c_013_grandfather_for_m009:2026-04", "expires_at": "2026-04-28T00:00:00+05:30"
136
+ },
137
+ {
138
+ "id": "trg_020_summer_demand_shift",
139
+ "scope": "merchant", "kind": "category_seasonal", "source": "external",
140
+ "merchant_id": "m_009_apollo_pharmacy_jaipur", "customer_id": null,
141
+ "payload": { "season": "summer_2026", "trends": ["ORS_demand_+40", "sunscreen_demand_+38", "antifungal_demand_+45", "cold_cough_demand_-60"], "shelf_action_recommended": true },
142
+ "urgency": 2, "suppression_key": "season:summer:m_009:2026", "expires_at": "2026-06-30T00:00:00Z"
143
+ },
144
+ {
145
+ "id": "trg_021_unverified_gbp_sunrise",
146
+ "scope": "merchant", "kind": "gbp_unverified", "source": "internal",
147
+ "merchant_id": "m_010_sunrisepharm_pharmacy_lucknow", "customer_id": null,
148
+ "payload": { "verified": false, "verification_path": "postcard_or_phone_call", "estimated_uplift_pct": 0.30 },
149
+ "urgency": 3, "suppression_key": "unverified:m_010", "expires_at": "2026-05-30T00:00:00Z"
150
+ },
151
+ {
152
+ "id": "trg_022_cde_webinar_dentists",
153
+ "scope": "merchant", "kind": "cde_opportunity", "source": "external",
154
+ "merchant_id": "m_001_drmeera_dentist_delhi", "customer_id": null,
155
+ "payload": { "digest_item_id": "d_2026W17_ida_webinar", "credits": 2, "fee": "free_for_members" },
156
+ "urgency": 1, "suppression_key": "cde:dentists:2026-05-02", "expires_at": "2026-05-02T19:00:00+05:30"
157
+ },
158
+ {
159
+ "id": "trg_023_competitor_opened_dentist",
160
+ "scope": "merchant", "kind": "competitor_opened", "source": "external",
161
+ "merchant_id": "m_001_drmeera_dentist_delhi", "customer_id": null,
162
+ "payload": { "competitor_name": "Smile Studio", "distance_km": 1.3, "their_offer": "Dental Cleaning @ ₹199", "opened_date": "2026-04-08" },
163
+ "urgency": 2, "suppression_key": "competitor:m_001:smile_studio", "expires_at": "2026-06-08T00:00:00Z"
164
+ },
165
+ {
166
+ "id": "trg_024_perf_spike_zen",
167
+ "scope": "merchant", "kind": "perf_spike", "source": "internal",
168
+ "merchant_id": "m_008_zenyoga_gym_chennai", "customer_id": null,
169
+ "payload": { "metric": "calls", "delta_pct": 0.15, "window": "7d", "vs_baseline": 18, "likely_driver": "kids_yoga_post" },
170
+ "urgency": 1, "suppression_key": "perf_spike:m_008:calls:2026-W17", "expires_at": "2026-05-03T00:00:00Z"
171
+ },
172
+ {
173
+ "id": "trg_025_dormancy_glamour",
174
+ "scope": "merchant", "kind": "dormant_with_vera", "source": "internal",
175
+ "merchant_id": "m_004_glamour_salon_pune", "customer_id": null,
176
+ "payload": { "days_since_last_merchant_message": 38, "last_topic": "subscription_expiry" },
177
+ "urgency": 2, "suppression_key": "dormant:m_004:30d", "expires_at": "2026-05-30T00:00:00Z"
178
+ }
179
+ ]
180
+ }
engagement-design.md ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vera Engagement Framework — Design
2
+
3
+ **Status**: Draft proposal — not implemented.
4
+ **Last updated**: 2026-04-26
5
+ **Scope**: How Vera composes every outbound conversation, both merchant-facing and (on-behalf-of-merchant) customer-facing.
6
+
7
+ ---
8
+
9
+ ## Why this exists
10
+
11
+ The current engagement loop (`agents/vera/followup/`) is a collection of ad-hoc nudges — campaign templates, performance reminders, conversation requeues. Each was built point-to-point, with hardcoded assumptions baked in. Adding a new nudge type means re-writing prompts, finding new data, and re-implementing send/dedup logic.
12
+
13
+ Two problems this creates:
14
+
15
+ 1. **Functional nudges are inherently low-frequency.** A merchant only has a handful of "broken things" or "events" per month. To engage them 3-5×/week, we need a framework that supports curiosity-driven, knowledge-driven, and customer-driven conversations — not just "fix-this" reminders.
16
+
17
+ 2. **Same engine should drive merchant-facing AND customer-facing messages.** When Dr. Meera's clinic sends a recall reminder to her patient Priya, Vera does the work — but the framework for composing that message should be the same one that produces the research digest Vera sends to Dr. Meera herself.
18
+
19
+ This doc proposes a **4-context composition framework** that separates the slow-changing (category knowledge) from the fast-changing (per-merchant, per-customer state) and the event-driven (triggers).
20
+
21
+ ---
22
+
23
+ ## The 4 contexts
24
+
25
+ Every Vera message = `compose(category, merchant, trigger, customer?)`.
26
+
27
+ | Context | Question it answers | Refresh cadence | Owner | Shared across |
28
+ |---|---|---|---|---|
29
+ | **Category** | How do we talk to *this type* of business? What do they know, care about, offer, get regulated on? | Weekly (digest), monthly (voice/catalog) | Domain curator | All merchants in the vertical |
30
+ | **Merchant** | Who is this specific business, how are they doing, what's in their history with us? | Daily (perf), real-time (conversation) | Snapshot + conversation sync | Just this merchant |
31
+ | **Trigger** | Why am I messaging *right now*? What specific event prompts this? | Per-event | Event detectors | This one send |
32
+ | **Customer** | Who is the merchant's customer, and what's their state with this merchant? | Per-visit / per-interaction | Merchant CRM sync | Just this customer |
33
+
34
+ The composer takes these 4 (3 if no customer scope) and produces the message body, template parameters, CTA, and suppression key. Nothing is hardcoded into the composer — all variation comes from the contexts.
35
+
36
+ ```
37
+ ┌─────────────────┐
38
+ Category ───►│ │
39
+ Merchant ───►│ Composer (LLM) │───► message {body, params, cta, send_as}
40
+ Trigger ───►│ │
41
+ Customer? ───►│ │
42
+ └─────────────────┘
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Layer details
48
+
49
+ ### CategoryContext
50
+
51
+ Slow-changing knowledge pack per vertical. One per category (`dentists`, `salons`, `gyms`, `restaurants`, `car_service`, ...). Bootstrap is labor-intensive (needs domain expertise); ongoing maintenance is mostly the weekly digest.
52
+
53
+ Fields:
54
+
55
+ - `slug` — `"dentists"`
56
+ - `offer_catalog` — canonical service+price patterns from vera-mcp + category-specific extensions. Prefer `"Dental Cleaning @ ₹299"` over `"Flat 20% OFF"`. Service+price is more compelling than discount.
57
+ - `voice` — tone, vocabulary, taboos. For dentists: technical terms welcome (`"fluoride varnish at 3-month recall"`), legal taboos (`"cure"`, `"guaranteed"`), peer tone not hype.
58
+ - `peer_stats` — city-scoped benchmarks: avg rating, avg reviews, typical CTR, typical patient volume. Anchors comparative messages.
59
+ - `digest` — this week's curated research / compliance / CDE / tech / peer-practice items, with source citations. Sourced from a per-category source list (PubMed dental RSS, JIDA, IDA Delhi calendar, DCI circulars, Dental Tribune India, Google Trends for dental queries, vendor press releases).
60
+ - `patient_content_library` — items written at patient-reading level that the merchant can reshare with their own customers (powers the `PRO_PATIENT_CONTENT` family).
61
+ - `seasonal_beats` — cycles like "exam-stress bruxism spikes Nov-Feb" that cue category-specific timing.
62
+ - `trend_signals` — Google Trends + Practo-style query data showing what patients in this vertical are searching for.
63
+
64
+ ### MerchantContext
65
+
66
+ Per-merchant state. Refreshed daily for performance; real-time for conversation history.
67
+
68
+ Fields:
69
+
70
+ - `merchant_id`
71
+ - `identity` — name, place_id, locality, city, verified, languages
72
+ - `subscription` — status, days remaining, plan
73
+ - `performance` — views/calls/CTR/leads/directions, 30d + 7d deltas
74
+ - `offers` — active + paused, sourced from vera-mcp's offers collection (and eventually the as-yet-undefined "real" offer source-of-truth)
75
+ - `conversation_history` — last N turns w/ Vera, with engagement tags (replied, ignored, unsubscribed-from-topic)
76
+ - `customer_aggregate` — derived stats over the merchant's customer roster (active count, lapsed count, retention rate). Not individual customers — aggregates only.
77
+ - `signals` — derived flags: `stale_posts`, `ctr_below_peer_median`, `customer_lapse_rate_high`, `dormant_with_vera`, ...
78
+
79
+ ### TriggerContext
80
+
81
+ The event that prompts this specific message. Two families:
82
+
83
+ - **External** — happens outside the merchant's account. News, weather, festival, regulation change, category-trend movement, competitor opens nearby, weekly research digest release.
84
+ - **Internal** — happens within the merchant's account or customer roster. Performance dip/spike, milestone hit, dormancy threshold crossed, customer lapse threshold crossed, appointment due, review pattern emerged, scheduled-recurring nudge.
85
+
86
+ Fields:
87
+
88
+ - `id` — unique
89
+ - `scope` — `merchant` | `customer`
90
+ - `kind` — `research_digest`, `recall_due`, `perf_spike`, `competitor_opened`, `festival`, ...
91
+ - `source` — `external` | `internal`
92
+ - `payload` — kind-specific data (e.g., for `recall_due`: `{patient_id, last_visit, due_date}`)
93
+ - `urgency` — 1-5; ranks against other queued triggers
94
+ - `suppression_key` — used by Redis dedup to prevent re-sends
95
+ - `expires_at` — after which the trigger is stale
96
+
97
+ ### CustomerContext
98
+
99
+ Only populated when `scope=customer`. Per-customer state with this specific merchant.
100
+
101
+ Fields:
102
+
103
+ - `customer_id`
104
+ - `merchant_id`
105
+ - `identity` — name, phone, language preference
106
+ - `relationship` — first_visit, last_visit, visits_total, services received, lifetime value
107
+ - `state` — `new` | `active` | `lapsed_soft` (3-6mo) | `lapsed_hard` (6mo+) | `churned` (12mo+)
108
+ - `preferences` — preferred slot times (derived from booking history), preferred channel, opt-in status
109
+ - `consent` — when did they opt in, via what mechanism, scope of consent
110
+
111
+ ---
112
+
113
+ ## Composer
114
+
115
+ Single LLM-prompted module. Takes the 4 contexts as input. Produces:
116
+
117
+ - `body` — the WhatsApp message body
118
+ - `template_params` — params to fill an approved Kaleyra template (used only for the first touch in a session window)
119
+ - `cta` — the binary or open-ended ask
120
+ - `suppression_key` — for the trigger-level dedup
121
+ - `send_as` — `"vera"` for merchant-facing, `"merchant_on_behalf"` for customer-facing
122
+
123
+ The composer prompt is the single point of failure. It must be:
124
+
125
+ - Versioned (`composer_v1`, `composer_v2`, ...)
126
+ - A/B-testable
127
+ - Auditable (we can replay any past message and see all 4 input contexts)
128
+
129
+ Different `kind` values may use different prompt variants — e.g., `research_digest` needs source-citation framing, `recall_due` needs slot-offering framing, `competitor_opened` needs voyeur-curiosity framing. The composer dispatches by `kind`.
130
+
131
+ ---
132
+
133
+ ## Worked example 1: merchant-facing
134
+
135
+ **Merchant**: Dr. Meera, Lajpat Nagar, Delhi
136
+ **Trigger**: weekly dentistry research digest just landed
137
+
138
+ **Inputs:**
139
+
140
+ | Context | Key values used |
141
+ |---|---|
142
+ | Category (dentists) | voice=peer/technical; digest_top_item="JIDA Oct trial: 3-mo fluoride recall cuts caries 38% better"; peer_stat="South-Delhi solo CTR median 3.0%" |
143
+ | Merchant (Dr. Meera) | CTR 2.1% (below peer); ran "Deep Cleaning ₹499" 2mo ago; 78 lapsed patients; last Vera touch 2d ago (engaged) |
144
+ | Trigger | kind=`research_digest_release`, scope=merchant, urgency=2, source=external, suppression_key=`research:dentists:2026-W17` |
145
+ | Customer | (not populated) |
146
+
147
+ **Composed message:**
148
+
149
+ > Dr. Meera, JIDA's Oct issue landed. One item relevant to your high-risk adult patients — 2,100-patient trial showed 3-month fluoride recall cuts caries recurrence 38% better than 6-month. Worth a look (2-min abstract). Want me to pull it + draft a patient-ed WhatsApp you can share? *— JIDA Oct 2026 p.14*
150
+
151
+ Why it works:
152
+ - **Category** drives voice (technical, source-cited, peer tone)
153
+ - **Merchant** drives specificity ("your high-risk adult patients" — derived from her customer aggregate)
154
+ - **Trigger** drives the hook (this week's digest, not a promo ask)
155
+ - No customer context needed; this is merchant-to-Vera
156
+
157
+ ---
158
+
159
+ ## Worked example 2: customer-facing (same framework)
160
+
161
+ **Merchant**: Dr. Meera (same)
162
+ **Customer**: Priya — patient since 2025-11, last visit 2026-05 (cleaning + whitening), prefers weekday evenings, opted-in to reminders
163
+ **Trigger**: 6-month recall window opens
164
+
165
+ **Inputs:**
166
+
167
+ | Context | Key values used |
168
+ |---|---|
169
+ | Category (dentists, customer-facing) | voice=warm-clinical; taboos=no medical claims, no "guaranteed"; recall framing pattern |
170
+ | Merchant (Dr. Meera) | active offer `Dental Cleaning @ ₹299`; available slots Wed 6pm + Thu 5pm (next 7d); WhatsApp Business number |
171
+ | Trigger | kind=`recall_due`, scope=customer, urgency=3, source=internal, payload={patient_id: priya, last_visit: 2026-05, due_date: 2026-11} |
172
+ | Customer (Priya) | name + phone; lapsed_soft state; preferred=weekday evening; consent active; language=Hindi-English mix |
173
+
174
+ **Composed message** (sent from Dr. Meera's WhatsApp number, drafted by Vera):
175
+
176
+ > Hi Priya, Dr. Meera's clinic here 🦷 It's been 5 months since your last visit — your 6-month cleaning recall is due. Apke liye 2 slots ready hain: **Wed 6 Nov, 6pm** ya **Thu 7 Nov, 5pm**. ₹299 cleaning + complimentary fluoride. Reply 1 for Wed, 2 for Thu, or tell us a time that works.
177
+
178
+ Why it works:
179
+ - **Category** sets the legal/clinical voice constraints
180
+ - **Merchant** provides the actual catalog price + actual open slots from the schedule
181
+ - **Trigger** provides the recall payload (last_visit, due_date)
182
+ - **Customer** drives personalization (name, language mix, evening preference)
183
+
184
+ Same composer. Different context inputs. Two completely different conversations.
185
+
186
+ ---
187
+
188
+ ## Engagement loops this enables
189
+
190
+ Once the framework exists, every loop is just a small cron that emits `TriggerContext` instances. The composer handles the rest.
191
+
192
+ | Loop | Emits trigger kinds | Scope |
193
+ |---|---|---|
194
+ | News/weather scanner *(already built — see `agents/vera/followup/event_sources.py`)* | `external` (festival, heatwave, fuel, IPL, monsoon, news) | merchant |
195
+ | Weekly research digest per category | `research_digest_release` | merchant |
196
+ | Performance monitor | `perf_spike`, `perf_dip`, `milestone_reached` | merchant |
197
+ | Review-pattern detector | `review_theme_emerged` | merchant |
198
+ | Conversation curiosity-ask scheduler | `curious_ask_due` | merchant |
199
+ | Recall scheduler (from merchant CRM) | `recall_due` | customer |
200
+ | Lapse detector | `customer_lapsed_soft`, `customer_lapsed_hard` | customer |
201
+ | Appointment reminder | `appointment_tomorrow` | customer |
202
+ | Capacity optimizer | `unplanned_slot_open` (offered to likely-to-book lapsed customers) | customer |
203
+
204
+ Adding a loop = define one new `kind`, implement the detector, add a composer prompt variant. No change to merchant/category/customer code.
205
+
206
+ ---
207
+
208
+ ## Implementation shape
209
+
210
+ ```python
211
+ # agents/vera/engagement/contexts.py
212
+
213
+ @dataclass
214
+ class CategoryContext:
215
+ slug: str # "dentists"
216
+ offer_catalog: list[OfferTemplate]
217
+ voice: VoiceProfile
218
+ peer_stats: PeerStats
219
+ digest: list[DigestItem]
220
+ patient_content_library: list[ContentItem]
221
+ seasonal_beats: list[SeasonalBeat]
222
+ trend_signals: list[TrendSignal]
223
+
224
+ @dataclass
225
+ class MerchantContext:
226
+ merchant_id: str
227
+ identity: Identity
228
+ subscription: Subscription
229
+ performance: PerformanceSnapshot
230
+ offers: list[MerchantOffer]
231
+ conversation_history: ConversationHistory
232
+ customer_aggregate: CustomerAggregate
233
+ signals: list[DerivedSignal]
234
+
235
+ @dataclass
236
+ class TriggerContext:
237
+ id: str
238
+ scope: Literal["merchant", "customer"]
239
+ kind: str
240
+ source: Literal["external", "internal"]
241
+ payload: dict
242
+ urgency: int # 1-5
243
+ suppression_key: str
244
+ expires_at: datetime
245
+
246
+ @dataclass
247
+ class CustomerContext:
248
+ customer_id: str
249
+ merchant_id: str
250
+ identity: CustomerIdentity
251
+ relationship: Relationship
252
+ state: Literal["new", "active", "lapsed_soft", "lapsed_hard", "churned"]
253
+ preferences: Preferences
254
+ consent: Consent
255
+ ```
256
+
257
+ ```python
258
+ # agents/vera/engagement/composer.py
259
+
260
+ class EngagementComposer:
261
+ def compose(self,
262
+ category: CategoryContext,
263
+ merchant: MerchantContext,
264
+ trigger: TriggerContext,
265
+ customer: CustomerContext | None = None) -> ComposedMessage:
266
+ """Returns ComposedMessage(body, template_params, cta,
267
+ suppression_key, send_as)."""
268
+ ```
269
+
270
+ Both engagement surfaces (merchant-facing, customer-on-behalf-of-merchant) call the same composer. The only thing that changes is whether `customer` is populated.
271
+
272
+ ---
273
+
274
+ ## Phased rollout
275
+
276
+ ### Phase 1 — framework skeleton + dentistry vertical (≈ 2 weeks)
277
+
278
+ 1. Define the 4 dataclasses in `agents/vera/engagement/contexts.py`.
279
+ 2. Build the `CategoryContext` for dentistry — offer catalog, voice profile, peer stats, one weekly research digest pipeline, patient-content seed.
280
+ 3. Build `MerchantContext` loader from the existing `merchant_snapshot_data` collection.
281
+ 4. Build the first `EngagementComposer` with a prompt that handles 2 trigger kinds (`research_digest_release` and one merchant-facing perf trigger).
282
+ 5. Render (no send) the Dr. Meera research-digest message end-to-end from the 4 contexts. Inspect the output before any send happens.
283
+
284
+ ### Phase 2 — customer-on-behalf sends (≈ 2 weeks)
285
+
286
+ 6. **Resolve the customer-data source-of-truth.** This is the biggest unknown. Options: clinic SaaS integration (Practo, Dentcubate), merchant CSV upload, BOTOPS chat-derived patient list. Without this, customer engagement is theoretical.
287
+ 7. Define the consent model: customer opted in via merchant, not via Vera directly. Capture timestamp + scope.
288
+ 8. Stand up a send-as-merchant channel: WhatsApp Business API under the merchant's number, or Vera's shared number with attribution `"Dr. Meera's clinic via Vera"`.
289
+ 9. Ship the first customer-facing trigger in production: `recall_due`. Lowest abuse risk, highest merchant intent.
290
+
291
+ ### Phase 3 — multiply verticals (≈ 1 week per vertical)
292
+
293
+ 10. Replicate `CategoryContext` for 4-5 more verticals (salons, gyms, pharmacies, restaurants, car service). Mostly data filling, not code.
294
+ 11. Add 3 more triggers per scope. Merchant: `perf_dip`, `milestone_reached`, `review_theme_emerged`. Customer: `customer_lapsed_soft`, `appointment_tomorrow`, `unplanned_slot_open`.
295
+
296
+ ---
297
+
298
+ ## Open questions
299
+
300
+ These need answers before Phase 2 can ship:
301
+
302
+ 1. **Where does the merchant's customer list live?** No clean answer yet. Most likely: per-merchant clinic software with no standard integration. May need a self-serve CSV upload or a per-vertical SaaS adapter.
303
+ 2. **Consent architecture.** Can Vera message a patient directly, or must every outbound require merchant approval before send? Recommendation: templated auto-sends with merchant override available, switching to fully-auto after the merchant has approved N consecutive sends.
304
+ 3. **Attribution.** Does the patient see "Dr. Meera's clinic" or "Vera on behalf of Dr. Meera's clinic"? Trust + legal implications either way. Probably category-dependent (regulated verticals need clearer attribution).
305
+ 4. **Composer prompt versioning.** Single point of failure. Versioned + A/B tested from day 1 — every send records the prompt version that produced it.
306
+ 5. **Offer source-of-truth.** Per the parallel discussion, the canonical merchant offer catalog likely lives outside vera-mcp (aryan `catalogoffer`, merchant-portal-api, or magicpin_jobs output). MerchantContext needs to read from that source — pending identification.
307
+ 6. **Composer model choice.** Azure OpenAI primary, Deepseek fallback (matching `template_generator._call_llm`)? Or is there a case for a smaller faster model for high-volume per-customer sends?
308
+
309
+ ---
310
+
311
+ ## Why this is worth building
312
+
313
+ - **Engagement frequency goes from "few times a month" to "few times a week"** — by adding curiosity-driven, knowledge-driven, and customer-driven loops on top of the existing functional ones.
314
+ - **One framework, two products** — the same composition engine drives Vera-to-merchant *and* merchant-to-customer messaging. Build once, ship twice.
315
+ - **Vertical scaling is data work, not code work** — adding a new category becomes "fill in a CategoryContext", not "write a new agent".
316
+ - **Auditable + versioned** — every message has explicit inputs and a versioned composer; we can replay, A/B test, and answer "why did Vera send this?" for any past send.
317
+
318
+ ---
319
+
320
+ ## Appendix: relationship to existing code
321
+
322
+ - `agents/vera/followup/event_sources.py` and `agents/vera/followup/event_extractor.py` *(branch `feature/vera-campaign-engagement`)* already produce external `TriggerContext`-shaped objects for the news/weather scanner. They become the first concrete trigger source feeding the new composer.
323
+ - `agents/vera/followup/template_registry.py` will continue to host the Kaleyra-approved template names (used for the first-touch send before the 24h session window opens). The composer fills the template parameters.
324
+ - `agents/vera/followup/snapshot_data.py` already provides most of the `MerchantContext` fields. Customer aggregate fields would be added as new sections on `MerchantSnapshotData`.
325
+ - `services/vera-mcp/src/services/offer_suggester.py` is the leading candidate for `CategoryContext.offer_catalog` (pending the open offer source-of-truth question).
engagement-research.md ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vera Engagement Framework — Research: Current Merchant Data Access
2
+
3
+ **Status**: Research notes — companion to `engagement-design.md`.
4
+ **Last updated**: 2026-04-26
5
+ **Scope**: How the existing system loads merchant + customer data at conversation init and during a turn. Used to inform what the new framework must build vs. adapt.
6
+
7
+ ---
8
+
9
+ ## TL;DR
10
+
11
+ Two distinct paths exist today — **merchant-facing** (Vera ↔ Dr. Meera) and **customer-facing** (a customer asks Vera *about* Dr. Meera). They share infrastructure (vera-mcp + merchant-support-mcp + Redis) but compose context very differently.
12
+
13
+ Most of what the proposed `MerchantContext` needs **already exists in scattered form** (`_merchant_snapshot`, `_behavioral_profile`, `_session_scenario`). The genuinely new pieces are:
14
+
15
+ - `CategoryContext` (no existing equivalent)
16
+ - `TriggerContext` (no normalized abstraction today — every cron emits ad-hoc payloads)
17
+ - `CustomerContext.relationship` (visit history aggregation doesn't exist)
18
+ - `EngagementComposer` (no shared composer — each agent has its own prompt-builder)
19
+
20
+ The aryan dependency for `category` and `locality` is the soft underbelly — every customer-info-pack call hits aryan synchronously.
21
+
22
+ ---
23
+
24
+ ## Path 1 — Merchant-facing (`VeraMerchantAgent`)
25
+
26
+ ### Init (per session, instance-level)
27
+
28
+ `agents/vera/merchant_agent.py:402` — `__init__`:
29
+
30
+ - Spawns its own `vera_mcp_client` (`HTTPMCPClient` → `VERA_MCP_SERVER_URL`, default `vera-mcp:8000`).
31
+ - Inherits `BaseAgent._merchant_mcp_client` — class-level shared singleton pointing to `merchant-support-mcp` (`https://search5.magicpin.com/staging/mcp-server/mcp`).
32
+ - **No merchant data is loaded at init** — agent doesn't know who it's talking to yet.
33
+
34
+ ### Per-turn (every inbound message)
35
+
36
+ `agents/vera/merchant_agent.py:2171` — `handle_input(agent_input)`:
37
+
38
+ 1. Extract `merchant_id` from `metadata.context_data.merchant_id`, falling back to regex on the message body (`\d{7,}`).
39
+ 2. Fire 2 parallel tasks:
40
+ - `_detect_state(merchant_id)` — GBP connection + subscription status checks.
41
+ - `_prefetch_merchant_context(merchant_id)` — full snapshot + behavioral profile.
42
+
43
+ ### `_prefetch_merchant_context` — `merchant_agent.py:740`
44
+
45
+ Cache-first design:
46
+
47
+ 1. **Redis check**: `vera:merchant_ctx:{merchant_id}` — TTL **30 min**. Hit → return immediately, skip everything below.
48
+ 2. Parallel via `asyncio.gather`:
49
+ - `vera_merchant_snapshot` (vera-mcp tool)
50
+ - `vera_get_merchant_profile` (vera-mcp tool)
51
+ 3. If snapshot is empty: fall back to `get_aggregated_unassociated_merchant_data` (merchant-support-mcp) — basic GBP health, no commercial data. Wrap it in a snapshot-shaped envelope.
52
+ 4. Sequential enrichments:
53
+ - `_prefetch_product_context` — fans out to `vera_get_subscription_context`, `vera_get_performance_summary`, and (only if DA subscribed) `da_get_campaign_context`. ~2KB total, embedded into snapshot.
54
+ - `_enrich_snapshot_with_pricing` — direct HTTP to `https://vera.magicpin.com/api/v1/merchant/pricing/get?mid=...` (vera-mcp's pricing path is unreachable locally). Mounted into snapshot as `pricing_recommendation`.
55
+ 5. Stuff result into Redis at the same key, TTL 30 min.
56
+
57
+ Final state: `self._merchant_snapshot` and `self._behavioral_profile` are populated. `_get_system_prompt()` reads from these and serializes the snapshot directly into the LLM system prompt (`merchant_agent.py:996`).
58
+
59
+ ### What `vera_merchant_snapshot` actually fetches
60
+
61
+ `vera-mcp/src/services/merchant_snapshot.py:51` — `build_merchant_snapshot()`:
62
+
63
+ 1. **Resolve identity** via `gbp_resolve_merchant` — gets `place_id`, `location_name`, `merchant_title`.
64
+ 2. **One async fan-out** of up to 13 tool calls in parallel:
65
+
66
+ | Bucket | Tools |
67
+ |---|---|
68
+ | Merchant-level (no GBP needed) | `vera_get_subscription_status`, `vera_get_pricing_by_merchant`, `vera_get_merchant_pain_points`, `vera_get_merchant_offer`, `vera_get_merchant_config`, `vera_get_onboarding_status`, `vera_get_enhancement_suggestions` |
69
+ | Location-level (skipped if no place_id) | `gbp_get_location`, `gbp_get_profile_completeness`, `gbp_get_performance_summary`, `gbp_get_search_keywords`, `gbp_get_review_stats`, `gbp_list_posts` |
70
+
71
+ 3. Composes 7 sections: `identity`, `profile`, `reputation`, `growth`, `commercial`, `conversation_hooks`, `issues`.
72
+
73
+ > **Note**: `category` and `locality` are not first-class fields on the snapshot — they're buried inside `profile.business_info` (from `gbp_get_location`) and surfaced via `aryan_client.get_merchant_v2()` only when `vera_get_customer_info_pack` is called.
74
+
75
+ ### Mid-conversation tool calls
76
+
77
+ The LLM gets the full toolset from `MERCHANT_MCP_ALLOWED_TOOLS` (declared on the agent class) merged with `_vera_mcp_tools_cache`. Mid-turn tool calls flow through:
78
+
79
+ - `self.vera_mcp_client.call_tool(name, args)` — for vera-mcp tools (HTTP)
80
+ - `BaseAgent._merchant_mcp_client.call_tool(name, args)` — for merchant-support-mcp tools (HTTP)
81
+
82
+ No re-prefetch of the snapshot mid-turn. The agent relies on the prefetched snapshot + LLM-initiated lookups when it needs fresh data.
83
+
84
+ ---
85
+
86
+ ## Path 2 — Customer-facing (`CustomerIncomingAgent`)
87
+
88
+ `agents/vera/customer_incoming_agent.py:91` — different agent, different state model.
89
+
90
+ ### Init + per-turn
91
+
92
+ The instance carries:
93
+
94
+ - `_merchant_id` — the merchant the customer is asking about
95
+ - `_merchant_data`, `_merchant_name`, `_category`, `_offers`, `_jd_info` — about the **business**
96
+ - `_customer_phone`, `_customer_profile` — about the **customer** (the one chatting)
97
+ - `_chat_history`, `_session_state` — conversation continuity
98
+
99
+ ### Merchant data load — `_load_merchant_data` (`customer_incoming_agent.py:221`)
100
+
101
+ Single shape, no Redis cache:
102
+
103
+ 1. **Primary call**: `vera_get_customer_info_pack(merchant_id)` — one MCP call returns `business_info` + `reviews` + `photos` + `offers` + `metadata` in one shot. The aggregated customer-facing endpoint.
104
+ 2. **Fallback chain** if `info_pack` is empty:
105
+ - `get_unassociated_merchant_data` — basic merchant info
106
+ - `vera_list_merchant_offers` — separate offer list
107
+ 3. **Supplementary** (non-blocking): `vera_get_merchant_jd_info` — JustDial crawl data for additional name / address / phone.
108
+
109
+ Caching is at the request level (in-memory on the agent instance) — re-using the same `merchant_id` in a session avoids re-fetching.
110
+
111
+ ### What `vera_get_customer_info_pack` does
112
+
113
+ `vera-mcp/src/tools/merchant_info.py:188`:
114
+
115
+ 1. **Resolve place_id**: `_resolve_place_id(merchant_id)` — chain of MongoDB (`gbp_status`) → local JSON → `aryan_client.get_mapping()`.
116
+ 2. **Parallel fetch**:
117
+ - GBP data via `get_or_fetch(place_id)` — 24h cache in MongoDB, falls through to Google Places API
118
+ - `_get_active_offers(merchant_id)` — direct MongoDB read of `offers` collection where `status=active`
119
+ - `_get_merchant_metadata(merchant_id)` — `aryan_client.get_merchant_v2()` → returns `name`, `category`, `locality`
120
+ 3. **Last-resort fallback**: `_fetch_embed_data(place_id)` — vera REST `/embed` endpoint if everything else came back empty.
121
+
122
+ ### Customer profile (the *caller*)
123
+
124
+ `_customer_profile` is loaded separately — populated in `_init_customer_data` from past conversation tickets keyed off `_customer_phone`. There's no rich CRM behind it today; it's mostly conversation continuity (last visit, last topic).
125
+
126
+ ---
127
+
128
+ ## Cross-cutting infrastructure
129
+
130
+ | Concern | Implementation |
131
+ |---|---|
132
+ | MCP transport | `HTTPMCPClient` (vera client) — keeps a session, calls `/mcp/tools/{name}` HTTP POST |
133
+ | Auth | Not required for challenge bot endpoints |
134
+ | Cache key for merchant context | `vera:merchant_ctx:{merchant_id}` — Redis, TTL 30 min, written by `_prefetch_merchant_context` |
135
+ | Cache key for GBP data | `gbp_health_report:{place_id}` — MongoDB, TTL 24h, in vera-mcp |
136
+ | Snapshot freshness for sends | Whatever's in Redis — not refreshed on send unless agent is in active conversation |
137
+ | Source of truth for `category` | aryan `get_merchant_v2` API (via `aryan_client`) — used in `_get_merchant_metadata` |
138
+ | Source of truth for `name` | aryan first, GBP `business_info` second |
139
+
140
+ ---
141
+
142
+ ## Observations relevant to the engagement framework
143
+
144
+ ### What already exists
145
+
146
+ 1. **Most of `MerchantContext` already exists** — spread across `_merchant_snapshot`, `_behavioral_profile`, `_session_scenario`, `_jd_info`. A `MerchantContext.from_existing(agent_state)` adapter could load ~80% of the fields without any new fetching.
147
+ 2. **The customer agent already has half a `CustomerContext`** — `_customer_phone` + `_customer_profile` give us identity + conversation continuity. Missing: visit history, services received, lapse state.
148
+ 3. **Two MCP servers, one orchestration** — vera-mcp (instance-level) for vera tools, merchant-support-mcp (class-level shared) for fallback merchant tools. The composer can just consume whatever `MerchantContext` already collected — no new MCP wiring required.
149
+ 4. **Cache TTL of 30 min is fine** for engagement nudges that fire daily/weekly. Redis hits during conversation are plenty fresh for composition.
150
+
151
+ ### What does NOT exist
152
+
153
+ 1. **No `CategoryContext`.** Category is just a string buried in metadata. Voice rules, peer benchmarks, knowledge digests — none of it exists. Biggest greenfield area, but also the most leveraged (one CategoryContext serves all merchants in the vertical).
154
+ 2. **No `customer_aggregate` field on the merchant snapshot.** No pipeline today aggregates per-merchant customer roster stats (active count, lapsed count, retention rate).
155
+ 3. **No `TriggerContext` abstraction.** Every nudge type today has its own cron + its own fetch logic + its own send code. The proposed `TriggerContext` is the genuinely new architectural primitive; everything else is reorganization.
156
+ 4. **No visit-history aggregation per (merchant, customer_phone).** Required for `CustomerContext.relationship`. BOTOPS chat history has the raw data; no derived view exists.
157
+ 5. **No shared composer.** Each agent has its own prompt builder embedded in `_get_system_prompt()`. The proposed `EngagementComposer` is net new.
158
+
159
+ ### Operational risks to mitigate
160
+
161
+ 1. **Aryan is the synchronous bottleneck.** `aryan_client.get_merchant_v2()` is the only path to category and locality, and it's a remote HTTP call. If aryan is slow, every customer-info-pack call is slow. Worth caching aryan responses per merchant for ~24h before scaling engagement frequency.
162
+ 2. **The 30-min Redis cache is keyed per-merchant** — fine for in-conversation reuse, but a daily engagement cron will miss this cache 100% of the time and pay the full snapshot-build cost per send. Consider a longer-TTL background-warmed cache for the engagement loop specifically.
163
+ 3. **No version tracking on prompts today.** Every send loses the prompt-version provenance. The composer should record prompt version + context hash on every send so we can replay and A/B.
164
+
165
+ ---
166
+
167
+ ## Concrete recommendation for Phase 1 of the framework
168
+
169
+ Phase 1 is mostly an **adapter layer**, not new infrastructure:
170
+
171
+ | Layer | Effort | What it actually does |
172
+ |---|---|---|
173
+ | `CategoryContext` | Net new | Build for dentistry first (offer catalog, voice, peer stats, weekly digest, patient-content seed) |
174
+ | `MerchantContext` | Adapter | Wrap existing `_merchant_snapshot` + `_behavioral_profile` + a new `_customer_aggregate` derived from BOTOPS chat history |
175
+ | `TriggerContext` | Net new abstraction | Normalize the payloads existing crons emit into a single shape; new triggers (research_digest, recall_due) emit it natively |
176
+ | `CustomerContext` | Partial adapter | Wrap existing `_customer_profile`, plus a new visit-history aggregator |
177
+ | `EngagementComposer` | Net new | Single LLM-prompted module with versioned prompt; consumes the above |
178
+
179
+ Phase 1 should NOT require modifying any existing agent. The adapter reads from existing state; the composer is a new module that runs in parallel; the new triggers (research digest, recall) are new crons that don't touch the existing matrix-followup or campaign-engagement loops.
180
+
181
+ ---
182
+
183
+ ## Appendix: file pointers for follow-up implementation
184
+
185
+ - `agents/vera/merchant_agent.py:402` — VeraMerchantAgent init
186
+ - `agents/vera/merchant_agent.py:740` — `_prefetch_merchant_context` (the main load)
187
+ - `agents/vera/merchant_agent.py:899` — `_prefetch_product_context` (subscription + DA campaign + perf summary)
188
+ - `agents/vera/merchant_agent.py:996` — system-prompt builder reads `_merchant_snapshot`
189
+ - `agents/vera/merchant_agent.py:2171` — `handle_input` per-turn entry
190
+ - `agents/vera/customer_incoming_agent.py:91` — CustomerIncomingAgent class
191
+ - `agents/vera/customer_incoming_agent.py:221` — `_load_merchant_data` (single info-pack call + fallbacks)
192
+ - `agents/base_agent.py:80-82` — MERCHANT_MCP_SERVER_URL config
193
+ - `agents/base_agent.py:237-244` — class-level shared `_merchant_mcp_client`
194
+ - `vera-mcp/src/tools/merchant_snapshot.py` — `vera_merchant_snapshot` tool
195
+ - `vera-mcp/src/services/merchant_snapshot.py:51` — `build_merchant_snapshot` (the 13-call fan-out)
196
+ - `vera-mcp/src/tools/merchant_info.py:188` — `vera_get_customer_info_pack` tool
197
+ - `vera-mcp/src/tools/merchant_info.py:30` — `_resolve_place_id` chain (MongoDB → JSON → aryan)
198
+ - `vera-mcp/src/services/aryan_client.py` — aryan HTTP client (`get_merchant_v2`, `get_mapping`)
examples/api-call-examples.md ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Call Examples — Judge ↔ Candidate Bot
2
+
3
+ This file shows the exact HTTP calls the judge will make during testing, and what the bot is expected to return. Read this together with `challenge-testing-brief.md` (which defines the contract) and the dataset (which provides the payloads).
4
+
5
+ Every example uses Dr. Meera's Dental Clinic (`m_001_drmeera_dentist_delhi`) as the running merchant.
6
+
7
+ ---
8
+
9
+ ## Phase 1 — Warmup (T-15 min)
10
+
11
+ ### Example 1.1 — `GET /v1/healthz`
12
+
13
+ **Request**
14
+ ```http
15
+ GET /v1/healthz HTTP/1.1
16
+ Host: bot.candidate-team-alpha.example.com
17
+ Accept: application/json
18
+ ```
19
+
20
+ **Expected response (200)**
21
+ ```http
22
+ HTTP/1.1 200 OK
23
+ Content-Type: application/json
24
+
25
+ {
26
+ "status": "ok",
27
+ "uptime_seconds": 124,
28
+ "contexts_loaded": { "category": 0, "merchant": 0, "customer": 0, "trigger": 0 }
29
+ }
30
+ ```
31
+
32
+ The judge calls this before pushing context. `contexts_loaded` should be all zeros at this point (bot just started).
33
+
34
+ ### Example 1.2 — `GET /v1/metadata`
35
+
36
+ **Request**
37
+ ```http
38
+ GET /v1/metadata HTTP/1.1
39
+ Host: bot.candidate-team-alpha.example.com
40
+ ```
41
+
42
+ **Expected response (200)**
43
+ ```json
44
+ {
45
+ "team_name": "Team Alpha",
46
+ "team_members": ["Alice", "Bob"],
47
+ "model": "claude-opus-4-7",
48
+ "approach": "single-prompt composer with retrieval over digest items + dispatch by trigger.kind",
49
+ "contact_email": "team@example.com",
50
+ "version": "1.2.0",
51
+ "submitted_at": "2026-04-26T08:00:00Z"
52
+ }
53
+ ```
54
+
55
+ ### Example 1.3 — `POST /v1/context` (push CategoryContext)
56
+
57
+ **Request**
58
+ ```http
59
+ POST /v1/context HTTP/1.1
60
+ Host: bot.candidate-team-alpha.example.com
61
+ Content-Type: application/json
62
+
63
+ {
64
+ "scope": "category",
65
+ "context_id": "dentists",
66
+ "version": 1,
67
+ "delivered_at": "2026-04-26T09:45:00Z",
68
+ "payload": {
69
+ "slug": "dentists",
70
+ "voice": { "tone": "peer_clinical", "vocab_taboo": ["guaranteed", "100% safe"] },
71
+ "offer_catalog": [
72
+ { "id": "den_001", "title": "Dental Cleaning @ ₹299", "value": "299", "audience": "new_user", "type": "service_at_price" }
73
+ ],
74
+ "peer_stats": { "avg_rating": 4.4, "avg_ctr": 0.030 },
75
+ "digest": [{ "id": "d_2026W17_jida_fluoride", "kind": "research", "title": "3-month fluoride recall cuts caries 38% better", "source": "JIDA Oct 2026, p.14" }],
76
+ "patient_content_library": [],
77
+ "seasonal_beats": [{ "month_range": "Nov-Feb", "note": "exam-stress bruxism spike" }],
78
+ "trend_signals": [{ "query": "clear aligners delhi", "delta_yoy": 0.62 }]
79
+ }
80
+ }
81
+ ```
82
+
83
+ **Expected response (200)**
84
+ ```json
85
+ { "accepted": true, "ack_id": "ack_dentists_v1", "stored_at": "2026-04-26T09:45:00.123Z" }
86
+ ```
87
+
88
+ > **Note**: For the actual test the full category JSON (`dataset/categories/dentists.json`) goes in `payload`, not the abbreviated form above.
89
+
90
+ ### Example 1.4 — `POST /v1/context` (push MerchantContext)
91
+
92
+ **Request**
93
+ ```http
94
+ POST /v1/context HTTP/1.1
95
+ Content-Type: application/json
96
+
97
+ {
98
+ "scope": "merchant",
99
+ "context_id": "m_001_drmeera_dentist_delhi",
100
+ "version": 1,
101
+ "delivered_at": "2026-04-26T09:45:30Z",
102
+ "payload": {
103
+ "merchant_id": "m_001_drmeera_dentist_delhi",
104
+ "category_slug": "dentists",
105
+ "identity": { "name": "Dr. Meera's Dental Clinic", "city": "Delhi", "locality": "Lajpat Nagar",
106
+ "verified": true, "languages": ["en", "hi"], "owner_first_name": "Meera" },
107
+ "subscription": { "status": "active", "plan": "Pro", "days_remaining": 82 },
108
+ "performance": { "window_days": 30, "views": 2410, "calls": 18, "directions": 45,
109
+ "ctr": 0.021, "delta_7d": { "views_pct": 0.18, "calls_pct": -0.05 } },
110
+ "offers": [{ "id": "o_meera_001", "title": "Dental Cleaning @ ₹299", "status": "active" }],
111
+ "conversation_history": [],
112
+ "customer_aggregate": { "total_unique_ytd": 540, "lapsed_180d_plus": 78,
113
+ "retention_6mo_pct": 0.38, "high_risk_adult_count": 124 },
114
+ "signals": ["stale_posts:22d", "ctr_below_peer_median", "high_risk_adult_cohort"]
115
+ }
116
+ }
117
+ ```
118
+
119
+ **Expected response (200)**
120
+ ```json
121
+ { "accepted": true, "ack_id": "ack_m_001_drmeera_v1", "stored_at": "2026-04-26T09:45:30.456Z" }
122
+ ```
123
+
124
+ ### Example 1.5 — `POST /v1/context` (idempotency check — same version re-pushed)
125
+
126
+ **Request** (same body as 1.4 — version 1 again)
127
+
128
+ **Expected response (409)**
129
+ ```json
130
+ { "accepted": false, "reason": "stale_version", "current_version": 1 }
131
+ ```
132
+
133
+ ### Example 1.6 — `POST /v1/context` (version bump replaces)
134
+
135
+ **Request**: same as 1.4 but `version: 2` and `performance.views: 2580` (updated).
136
+
137
+ **Expected response (200)**
138
+ ```json
139
+ { "accepted": true, "ack_id": "ack_m_001_drmeera_v2", "stored_at": "2026-04-26T10:30:00.789Z" }
140
+ ```
141
+
142
+ The bot must now use the new version when composing for `m_001_drmeera_dentist_delhi`.
143
+
144
+ ### Example 1.7 — `GET /v1/healthz` after warmup complete
145
+
146
+ **Expected response (200)**
147
+ ```json
148
+ {
149
+ "status": "ok",
150
+ "uptime_seconds": 1024,
151
+ "contexts_loaded": { "category": 5, "merchant": 50, "customer": 200, "trigger": 0 }
152
+ }
153
+ ```
154
+
155
+ If counts don't match what the judge pushed, warmup fails and the bot is disqualified for that test slot.
156
+
157
+ ---
158
+
159
+ ## Phase 2 — Test window (T0 → T0 + 60 min)
160
+
161
+ ### Example 2.1 — `POST /v1/context` (incremental trigger push)
162
+
163
+ The judge now starts pushing triggers as simulated time advances.
164
+
165
+ **Request**
166
+ ```http
167
+ POST /v1/context HTTP/1.1
168
+ Content-Type: application/json
169
+
170
+ {
171
+ "scope": "trigger",
172
+ "context_id": "trg_001_research_digest_dentists",
173
+ "version": 1,
174
+ "delivered_at": "2026-04-26T10:32:00Z",
175
+ "payload": {
176
+ "id": "trg_001_research_digest_dentists",
177
+ "scope": "merchant",
178
+ "kind": "research_digest",
179
+ "source": "external",
180
+ "merchant_id": "m_001_drmeera_dentist_delhi",
181
+ "customer_id": null,
182
+ "payload": {
183
+ "category": "dentists",
184
+ "top_item_id": "d_2026W17_jida_fluoride"
185
+ },
186
+ "urgency": 2,
187
+ "suppression_key": "research:dentists:2026-W17",
188
+ "expires_at": "2026-05-03T00:00:00Z"
189
+ }
190
+ }
191
+ ```
192
+
193
+ **Expected response (200)**
194
+ ```json
195
+ { "accepted": true, "ack_id": "ack_trg_001_v1", "stored_at": "2026-04-26T10:32:00.150Z" }
196
+ ```
197
+
198
+ ### Example 2.2 — `POST /v1/tick` (bot decides to send)
199
+
200
+ **Request**
201
+ ```http
202
+ POST /v1/tick HTTP/1.1
203
+ Content-Type: application/json
204
+
205
+ {
206
+ "now": "2026-04-26T10:35:00Z",
207
+ "available_triggers": ["trg_001_research_digest_dentists"]
208
+ }
209
+ ```
210
+
211
+ **Expected response (200) — bot chose to send**
212
+ ```json
213
+ {
214
+ "actions": [
215
+ {
216
+ "conversation_id": "conv_m_001_drmeera_research_W17",
217
+ "merchant_id": "m_001_drmeera_dentist_delhi",
218
+ "customer_id": null,
219
+ "send_as": "vera",
220
+ "trigger_id": "trg_001_research_digest_dentists",
221
+ "template_name": "vera_research_digest_v1",
222
+ "template_params": [
223
+ "Dr. Meera",
224
+ "JIDA Oct issue landed. One item relevant to your high-risk adult patients — 2,100-patient trial showed 3-month fluoride recall cuts caries recurrence 38% better than 6-month",
225
+ "Worth a look (2-min abstract). Want me to pull it + draft a patient-ed WhatsApp you can share?"
226
+ ],
227
+ "body": "Dr. Meera, JIDA's Oct issue landed. One item relevant to your high-risk adult patients — 2,100-patient trial showed 3-month fluoride recall cuts caries recurrence 38% better than 6-month. Worth a look (2-min abstract). Want me to pull it + draft a patient-ed WhatsApp you can share? — JIDA Oct 2026 p.14",
228
+ "cta": "open_ended",
229
+ "suppression_key": "research:dentists:2026-W17",
230
+ "rationale": "External research digest with merchant-relevant clinical anchor (high-risk-adult cohort matches signal). Source citation at end maintains credibility. Open-ended CTA invites continuation without forcing a binary choice."
231
+ }
232
+ ]
233
+ }
234
+ ```
235
+
236
+ ### Example 2.3 — `POST /v1/tick` (bot decides NOT to send)
237
+
238
+ If no triggers are worth acting on this tick (or all have been suppressed):
239
+
240
+ **Expected response (200)**
241
+ ```json
242
+ { "actions": [] }
243
+ ```
244
+
245
+ Empty `actions` is valid. Restraint is rewarded; spam is penalized.
246
+
247
+ ### Example 2.4 — `POST /v1/reply` (judge plays merchant — engaged response)
248
+
249
+ The judge's sub-LLM, playing Dr. Meera, replies to the message from 2.2:
250
+
251
+ **Request**
252
+ ```http
253
+ POST /v1/reply HTTP/1.1
254
+ Content-Type: application/json
255
+
256
+ {
257
+ "conversation_id": "conv_m_001_drmeera_research_W17",
258
+ "merchant_id": "m_001_drmeera_dentist_delhi",
259
+ "customer_id": null,
260
+ "from_role": "merchant",
261
+ "message": "Yes please send the abstract. Also draft the patient WhatsApp.",
262
+ "received_at": "2026-04-26T10:42:00Z",
263
+ "turn_number": 2
264
+ }
265
+ ```
266
+
267
+ **Expected response (200) — bot follows up with action**
268
+ ```json
269
+ {
270
+ "action": "send",
271
+ "body": "Sending the abstract now (PDF, 2 pages). Patient-ed draft below — you can copy-paste or I'll schedule a Google post:\n\n\"3-month vs 6-month dental cleaning — does it really matter? New research shows yes, especially if you've had cavities recently. Drop us a note for a quick check.\"\n\nWant me to schedule the post for tomorrow 10am?",
272
+ "cta": "binary_yes_no",
273
+ "rationale": "Honoring both asks (abstract + draft) in one turn. Draft is at patient-reading level. Final question is a binary yes/no to lower friction."
274
+ }
275
+ ```
276
+
277
+ ### Example 2.5 — `POST /v1/reply` (judge plays merchant — auto-reply)
278
+
279
+ Some test scenarios simulate WhatsApp Business auto-reply:
280
+
281
+ **Request**
282
+ ```json
283
+ {
284
+ "conversation_id": "conv_m_001_drmeera_research_W17",
285
+ "from_role": "merchant",
286
+ "message": "Thank you for contacting Dr. Meera's Dental Clinic! Our team will respond shortly.",
287
+ "received_at": "2026-04-26T10:42:00Z",
288
+ "turn_number": 2
289
+ }
290
+ ```
291
+
292
+ **Expected response (200) — bot detects auto-reply, exits gracefully**
293
+ ```json
294
+ {
295
+ "action": "wait",
296
+ "wait_seconds": 14400,
297
+ "rationale": "Detected merchant auto-reply (canned 'Thank you for contacting' phrasing). Backing off 4 hours to wait for owner."
298
+ }
299
+ ```
300
+
301
+ After the wait expires, the judge will call `/v1/tick` again and the bot can re-engage.
302
+
303
+ ### Example 2.6 — `POST /v1/reply` (judge plays merchant — hard "no")
304
+
305
+ **Request**
306
+ ```json
307
+ {
308
+ "conversation_id": "conv_m_001_drmeera_research_W17",
309
+ "from_role": "merchant",
310
+ "message": "Not interested. Stop messaging me.",
311
+ "received_at": "2026-04-26T10:42:00Z",
312
+ "turn_number": 2
313
+ }
314
+ ```
315
+
316
+ **Expected response (200) — bot ends gracefully**
317
+ ```json
318
+ {
319
+ "action": "end",
320
+ "rationale": "Merchant explicitly opted out. Closing conversation; suppressing this conversation_id for future ticks."
321
+ }
322
+ ```
323
+
324
+ The bot must not send any further messages on this `conversation_id`.
325
+
326
+ ### Example 2.7 — `POST /v1/reply` (judge plays merchant — curveball)
327
+
328
+ **Request**
329
+ ```json
330
+ {
331
+ "conversation_id": "conv_m_001_drmeera_research_W17",
332
+ "from_role": "merchant",
333
+ "message": "Btw can you also help me with my GST filing this month?",
334
+ "received_at": "2026-04-26T10:42:00Z",
335
+ "turn_number": 2
336
+ }
337
+ ```
338
+
339
+ **Expected response (200) — bot stays on mission politely**
340
+ ```json
341
+ {
342
+ "action": "send",
343
+ "body": "I'll have to leave GST filing to your CA — that's outside what I can help with directly. Coming back to the JIDA piece — want me to draft the patient post first, or send the abstract?",
344
+ "cta": "open_ended",
345
+ "rationale": "Out-of-scope ask politely declined; redirects back to the original trigger without losing thread."
346
+ }
347
+ ```
348
+
349
+ ### Example 2.8 — `POST /v1/context` (mid-test adaptive injection)
350
+
351
+ While the conversation is live, the judge pushes a new digest item. A good bot will incorporate it in subsequent sends.
352
+
353
+ **Request**
354
+ ```json
355
+ {
356
+ "scope": "category",
357
+ "context_id": "dentists",
358
+ "version": 2,
359
+ "delivered_at": "2026-04-26T10:50:00Z",
360
+ "payload": {
361
+ "slug": "dentists",
362
+ "voice": { "tone": "peer_clinical" },
363
+ "digest": [
364
+ { "id": "d_2026W17_jida_fluoride", "kind": "research", "title": "3-month fluoride recall cuts caries 38% better", "source": "JIDA Oct 2026, p.14" },
365
+ { "id": "d_2026W17_dci_radiograph_NEW", "kind": "compliance", "title": "DCI revised radiograph dose limits effective 2026-12-15",
366
+ "source": "DCI circular 2026-11-04", "summary": "Max dose drops 1.5→1.0 mSv per IOPA. E-speed film passes; D-speed does not." }
367
+ ],
368
+ "// other fields": "..."
369
+ }
370
+ }
371
+ ```
372
+
373
+ **Expected response (200)**
374
+ ```json
375
+ { "accepted": true, "ack_id": "ack_dentists_v2", "stored_at": "2026-04-26T10:50:00.110Z" }
376
+ ```
377
+
378
+ The bot must replace the old version atomically and use the new digest item if relevant in the next send.
379
+
380
+ ### Example 2.9 — `POST /v1/tick` (customer-scoped trigger emerges)
381
+
382
+ A `recall_due` trigger fires for one of Dr. Meera's patients:
383
+
384
+ **Context push first**
385
+ ```json
386
+ {
387
+ "scope": "customer",
388
+ "context_id": "c_001_priya_for_m001",
389
+ "version": 1,
390
+ "payload": { /* Priya's CustomerContext from dataset/customers_seed.json */ }
391
+ }
392
+ ```
393
+
394
+ ```json
395
+ {
396
+ "scope": "trigger",
397
+ "context_id": "trg_003_recall_due_priya",
398
+ "version": 1,
399
+ "payload": { /* the recall trigger from dataset/triggers_seed.json */ }
400
+ }
401
+ ```
402
+
403
+ **Then `/v1/tick`**
404
+ ```json
405
+ {
406
+ "now": "2026-04-26T11:00:00Z",
407
+ "available_triggers": ["trg_003_recall_due_priya"]
408
+ }
409
+ ```
410
+
411
+ **Expected response (200)**
412
+ ```json
413
+ {
414
+ "actions": [
415
+ {
416
+ "conversation_id": "conv_priya_recall_2026_11",
417
+ "merchant_id": "m_001_drmeera_dentist_delhi",
418
+ "customer_id": "c_001_priya_for_m001",
419
+ "send_as": "merchant_on_behalf",
420
+ "trigger_id": "trg_003_recall_due_priya",
421
+ "template_name": "merchant_recall_reminder_v1",
422
+ "template_params": [
423
+ "Priya",
424
+ "Dr. Meera's clinic",
425
+ "It's been 5 months since your last visit",
426
+ "Wed 5 Nov, 6pm or Thu 6 Nov, 5pm",
427
+ "₹299 cleaning + complimentary fluoride"
428
+ ],
429
+ "body": "Hi Priya, Dr. Meera's clinic here 🦷 It's been 5 months since your last visit — your 6-month cleaning recall is due. Apke liye 2 slots ready hain: **Wed 5 Nov, 6pm** ya **Thu 6 Nov, 5pm**. ₹299 cleaning + complimentary fluoride. Reply 1 for Wed, 2 for Thu, or tell us a time that works.",
430
+ "cta": "multi_choice_slot",
431
+ "suppression_key": "recall:c_001_priya_for_m001:6mo",
432
+ "rationale": "Customer-scoped recall, sending via merchant's number (send_as=merchant_on_behalf). Honoring Priya's hi-en mix language pref + weekday-evening preference (both slots offered are weekday evenings). Multi-choice slot CTA is appropriate for booking flows."
433
+ }
434
+ ]
435
+ }
436
+ ```
437
+
438
+ ---
439
+
440
+ ## Phase 4 — Replay test (top 10 only)
441
+
442
+ The judge runs 3 standalone scenarios. Each is a fresh conversation with a controlled merchant persona.
443
+
444
+ ### Example 4.1 — Auto-reply hell scenario
445
+
446
+ The judge sends 4 turns of identical canned auto-replies.
447
+
448
+ **Turn 1 — bot initiates**
449
+ ```json
450
+ POST /v1/tick { "now": "...", "available_triggers": ["trg_022_cde_webinar_dentists"] }
451
+ → { "actions": [{ /* CDE webinar invite */ }] }
452
+ ```
453
+
454
+ **Turn 2 — judge plays auto-reply**
455
+ ```json
456
+ POST /v1/reply { "from_role": "merchant",
457
+ "message": "Thank you for contacting Dr. Meera's Dental Clinic! Our team will respond shortly.",
458
+ "turn_number": 2 }
459
+ ```
460
+
461
+ **Good bot response**
462
+ ```json
463
+ { "action": "send",
464
+ "body": "Looks like an auto-reply 😊 When the owner sees this, just reply 'Yes' for the webinar invite.",
465
+ "cta": "binary_yes_no",
466
+ "rationale": "Detected auto-reply; one explicit prompt to flag it for the owner." }
467
+ ```
468
+
469
+ **Turn 3 — judge plays same auto-reply again**
470
+ ```json
471
+ POST /v1/reply { "from_role": "merchant",
472
+ "message": "Thank you for contacting Dr. Meera's Dental Clinic! Our team will respond shortly.",
473
+ "turn_number": 3 }
474
+ ```
475
+
476
+ **Good bot response**
477
+ ```json
478
+ { "action": "wait", "wait_seconds": 86400,
479
+ "rationale": "Same auto-reply twice in a row → owner not at phone. Wait 24h before retry." }
480
+ ```
481
+
482
+ **Turn 4 — judge plays same auto-reply yet again**
483
+
484
+ **Good bot response**
485
+ ```json
486
+ { "action": "end",
487
+ "rationale": "Auto-reply 3x in a row, no real reply. Conversation has zero engagement signal; closing." }
488
+ ```
489
+
490
+ ### Example 4.2 — Intent transition scenario
491
+
492
+ After 2 turns of qualification, judge plays an explicit "let's do it":
493
+
494
+ **Turn 3 — judge**
495
+ ```json
496
+ POST /v1/reply { "from_role": "merchant",
497
+ "message": "Ok, let's do it. What's next?",
498
+ "turn_number": 3 }
499
+ ```
500
+
501
+ **Good bot response — switches from qualifying to action**
502
+ ```json
503
+ { "action": "send",
504
+ "body": "Great. Drafting your patient WhatsApp now — 90 seconds. I'll also pre-fill the GBP post for tomorrow 10am. Reply CONFIRM to send the WhatsApp draft to your patient list (40 high-risk adult patients).",
505
+ "cta": "binary_confirm_cancel",
506
+ "rationale": "Merchant explicitly committed; switching from question-asking to action-execution. Concrete next step + measurable scope (40 patients)." }
507
+ ```
508
+
509
+ **Bad bot response — keeps qualifying (this loses points)**
510
+ ```json
511
+ { "action": "send",
512
+ "body": "Excellent! Just to plan well — would you say most of your high-risk patients are diabetic, or is there another common factor?",
513
+ "rationale": "Continuing qualification..." // ← penalty: ignored explicit intent transition
514
+ }
515
+ ```
516
+
517
+ ### Example 4.3 — Hostile / off-topic scenario
518
+
519
+ **Turn 2 — judge plays hostile**
520
+ ```json
521
+ POST /v1/reply { "from_role": "merchant",
522
+ "message": "Why are you bothering me. This is useless. Stop sending these.",
523
+ "turn_number": 2 }
524
+ ```
525
+
526
+ **Good bot response — graceful exit**
527
+ ```json
528
+ { "action": "end",
529
+ "rationale": "Merchant frustration explicit; closing without further engagement. Suppressing all triggers for this merchant for 30 days." }
530
+ ```
531
+
532
+ **Acceptable alternative — short apology + exit**
533
+ ```json
534
+ { "action": "send",
535
+ "body": "Apologies — I won't message again. If anything changes, you can always restart with 'Hi Vera'. 🙏",
536
+ "cta": "none",
537
+ "rationale": "One-line acknowledgment + opt-out path; conversation will close after this send." }
538
+ ```
539
+
540
+ ---
541
+
542
+ ## Failure-mode examples
543
+
544
+ ### Example F.1 — Bot times out
545
+
546
+ If `/v1/tick` doesn't respond within 30s, the judge logs a timeout and continues. No retries.
547
+
548
+ ### Example F.2 — Malformed response
549
+
550
+ ```json
551
+ { "actions": [{ "merchant_id": "m_001", "body": "..." }] }
552
+ ```
553
+
554
+ Missing required fields (`conversation_id`, `send_as`, `trigger_id`, `cta`, `suppression_key`, `rationale`) → action scored as 0, -2 penalty.
555
+
556
+ ### Example F.3 — Body too long
557
+
558
+ ```json
559
+ { "body": "...500 chars..." }
560
+ ```
561
+
562
+ No hard body-length cap. Messages are judged on quality, specificity, and relevance.
563
+
564
+ ### Example F.4 — URL in body
565
+
566
+ ```json
567
+ { "body": "Read more: https://magicpin.com/blog" }
568
+ ```
569
+
570
+ Hard fail for that action — Meta would reject. Penalty: -3 per URL.
571
+
572
+ ### Example F.5 — Repetition
573
+
574
+ Same `body` text sent twice in the same `conversation_id` → -2 anti-repetition penalty per repeat.
575
+
576
+ ---
577
+
578
+ ## Curl examples (for local testing)
579
+
580
+ ```bash
581
+ # Set your bot URL
582
+ export BOT_URL=http://localhost:8080
583
+
584
+ # Healthz
585
+ curl $BOT_URL/v1/healthz
586
+
587
+ # Push a category context
588
+ curl -X POST -H "Content-Type: application/json" \
589
+ -d @dataset/categories/dentists.json \
590
+ $BOT_URL/v1/context
591
+
592
+ # Trigger a tick
593
+ curl -X POST -H "Content-Type: application/json" \
594
+ -d '{"now": "2026-04-26T10:35:00Z", "available_triggers": ["trg_001_research_digest_dentists"]}' \
595
+ $BOT_URL/v1/tick
596
+
597
+ # Send a reply
598
+ curl -X POST -H "Content-Type: application/json" \
599
+ -d '{"conversation_id": "conv_001", "merchant_id": "m_001_drmeera_dentist_delhi", "from_role": "merchant", "message": "Yes please send the abstract", "received_at": "2026-04-26T10:42:00Z", "turn_number": 2}' \
600
+ $BOT_URL/v1/reply
601
+ ```
602
+
603
+ ---
604
+
605
+ ## Summary table — request shapes at a glance
606
+
607
+ | Endpoint | Method | Body | Latency budget | Retried? |
608
+ |---|---|---|---|---|
609
+ | `/v1/healthz` | GET | none | 2 s | yes (×3) |
610
+ | `/v1/metadata` | GET | none | 2 s | no |
611
+ | `/v1/context` | POST | full payload | 5 s | no |
612
+ | `/v1/tick` | POST | `{now, available_triggers}` | 10 s | no |
613
+ | `/v1/reply` | POST | reply turn | 10 s | no |
614
+
615
+ That's the full surface. If your bot handles every example here correctly, it'll pass the warmup, the test window, and the replay scenarios with no operational issues — leaving the score entirely to the quality of your composition.
examples/case-studies.md ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 10 Case Studies — What "Good" Looks Like
2
+
3
+ These case studies serve as anchor examples for the AI judge and as targets for candidates. Each shows:
4
+ - The 4-context input
5
+ - The good composed message
6
+ - The compulsion levers used
7
+ - The dimension-by-dimension score (out of 50)
8
+
9
+ Two cases per category × 5 categories = 10 case studies. Mix of merchant-facing and customer-facing scopes.
10
+
11
+ ---
12
+
13
+ ## Case Study 1 — Dentists / Research Digest (merchant-facing)
14
+
15
+ **Category**: `dentists`
16
+ **Merchant**: Dr. Meera's Dental Clinic, Lajpat Nagar Delhi, CTR 2.1% (below peer 3.0%), 124 high-risk adult patients in roster
17
+ **Trigger**: `research_digest` — JIDA Oct 2026 paper: 3-month fluoride recall vs 6-month, 38% caries reduction in high-risk adults
18
+ **Customer**: none
19
+
20
+ **Composed message** (sent as Vera)
21
+ ```
22
+ Dr. Meera, JIDA's Oct issue landed. One item relevant to your high-risk adult
23
+ patients — 2,100-patient trial showed 3-month fluoride recall cuts caries
24
+ recurrence 38% better than 6-month. Worth a look (2-min abstract). Want me
25
+ to pull it + draft a patient-ed WhatsApp you can share? — JIDA Oct 2026 p.14
26
+ ```
27
+
28
+ **Compulsion levers**: source citation (credibility), merchant-specific anchor (her high-risk-adult cohort), reciprocity ("I'll pull it for you"), low-friction CTA, **specificity** (2,100 / 38% / page number).
29
+
30
+ **Score breakdown** (50/50 = perfect)
31
+ | Dimension | Score | Why |
32
+ |---|---|---|
33
+ | Specificity | 10 | Numbers + source + page reference |
34
+ | Category fit | 10 | Clinical vocabulary, peer tone, no overclaim |
35
+ | Merchant fit | 10 | "your high-risk adult patients" derived from her customer_aggregate |
36
+ | Trigger relevance | 10 | Explicitly references the digest as the reason for messaging |
37
+ | Engagement compulsion | 10 | Curiosity + reciprocity + clear next step |
38
+
39
+ ---
40
+
41
+ ## Case Study 2 — Dentists / Recall Reminder (customer-facing)
42
+
43
+ **Category**: `dentists` (customer-facing voice rules: no medical claims)
44
+ **Merchant**: Dr. Meera's Dental Clinic — active offer "Dental Cleaning @ ₹299", available slots Wed 5 Nov 6pm + Thu 6 Nov 5pm
45
+ **Trigger**: `recall_due` — Priya's 6-month cleaning recall window opened
46
+ **Customer**: Priya — lapsed_soft (5mo since last visit), prefers weekday evenings, hi-en mix
47
+
48
+ **Composed message** (sent as merchant_on_behalf, from Dr. Meera's WA number)
49
+ ```
50
+ Hi Priya, Dr. Meera's clinic here 🦷 It's been 5 months since your last visit
51
+ — your 6-month cleaning recall is due. Apke liye 2 slots ready hain: Wed 5
52
+ Nov, 6pm ya Thu 6 Nov, 5pm. ₹299 cleaning + complimentary fluoride. Reply 1
53
+ for Wed, 2 for Thu, or tell us a time that works.
54
+ ```
55
+
56
+ **Compulsion levers**: name personalization, language-pref match (hi-en mix), specific dates + price + free-add ("complimentary fluoride"), low-friction multi-choice CTA, dental emoji for warmth.
57
+
58
+ **Score breakdown** (49/50)
59
+ | Dimension | Score | Why |
60
+ |---|---|---|
61
+ | Specificity | 10 | Real slots + real price + concrete recall window |
62
+ | Category fit | 10 | No medical claims; warm-clinical tone |
63
+ | Merchant fit | 10 | Real offer from catalog; merchant's actual slots |
64
+ | Trigger relevance | 10 | Explicit 6-month recall framing |
65
+ | Engagement compulsion | 9 | Strong but Reply 1/2 multi-choice is borderline (binary preferred); the "or tell us a time that works" rescues it |
66
+
67
+ ---
68
+
69
+ ## Case Study 3 — Salons / Active Planning (merchant-facing)
70
+
71
+ **Category**: `salons`
72
+ **Merchant**: Studio11 Family Salon, Kapra Hyderabad, calls +20% w/w, retention 62%, peer-leading
73
+ **Trigger**: `active_planning_intent` — kids yoga summer camp planning conversation
74
+ > *Wait — this is the gym category. Let me redo with a salon trigger.*
75
+
76
+ **Trigger (corrected)**: `bridal_followup` — Kavya, bride-to-be (wedding 2026-11-08), did Bridal Trial 5 weeks ago, in skin-prep window now
77
+ **Customer**: Kavya — new state, wedding date 2026-11-08, opted in for bridal followup
78
+
79
+ > Note: this is also customer-facing. Treating this as a customer-facing case for variety.
80
+
81
+ **Composed message** (sent as merchant_on_behalf)
82
+ ```
83
+ Hi Kavya 💍 Lakshmi from Studio11 Kapra here. 196 days to your wedding — perfect
84
+ window to start the 30-day skin-prep program before serious bridal bookings
85
+ roll in. ₹2,499 covers 4 sessions + a take-home kit. Want me to block your
86
+ preferred Saturday 4pm slot for the first session next week?
87
+ ```
88
+
89
+ **Compulsion levers**: relationship continuity ("from your trial"), wedding-date specificity, urgency framing (window opens), preference honored (Saturday 4pm), single binary commit.
90
+
91
+ **Score breakdown** (47/50)
92
+ | Dimension | Score | Why |
93
+ |---|---|---|
94
+ | Specificity | 10 | Days-to-wedding count, program structure, price |
95
+ | Category fit | 9 | Warm-practical tone; emojis used appropriately |
96
+ | Merchant fit | 9 | References Lakshmi (owner first name) + Studio11 + her trial |
97
+ | Trigger relevance | 10 | Explicitly the bridal followup window |
98
+ | Engagement compulsion | 9 | Strong but assumes the program exists in catalog (verify in MerchantContext.offers) |
99
+
100
+ ---
101
+
102
+ ## Case Study 4 — Salons / Curious Ask (merchant-facing)
103
+
104
+ **Category**: `salons`
105
+ **Merchant**: Studio11 Family Salon, growing strongly, last Vera touch 3 days ago
106
+ **Trigger**: `curious_ask_due` — weekly "what's in demand?" cadence
107
+ **Customer**: none
108
+
109
+ **Composed message** (sent as Vera)
110
+ ```
111
+ Hi Lakshmi! Quick check — what service has been most asked-for this week
112
+ at Studio11? I'll turn the answer into a Google post + a 4-line WhatsApp
113
+ reply you can use when customers ask about pricing. Takes 5 min.
114
+ ```
115
+
116
+ **Compulsion levers**: low-stakes question (no commitment), reciprocity offered up-front (Google post + reply draft), effort externalization (5 min), respects merchant time.
117
+
118
+ **Score breakdown** (44/50)
119
+ | Dimension | Score | Why |
120
+ |---|---|---|
121
+ | Specificity | 8 | "5 min" anchor; could be sharper with a guess ("Is it the keratin treatment?") |
122
+ | Category fit | 9 | Warm tone, fellow-operator register |
123
+ | Merchant fit | 8 | Uses owner first name; could reference recent service mix |
124
+ | Trigger relevance | 10 | This IS the curious-ask family — exactly the right shape |
125
+ | Engagement compulsion | 9 | Asking-the-merchant lever is the strongest Cialdini hook for engaged merchants |
126
+
127
+ ---
128
+
129
+ ## Case Study 5 — Restaurants / IPL Match Day (merchant-facing)
130
+
131
+ **Category**: `restaurants`
132
+ **Merchant**: SK Pizza Junction, Sant Nagar Delhi, trial-tier subscriber, BOGO pizza Tue-Thu active
133
+ **Trigger**: `ipl_match_today` — DC vs MI at Arun Jaitley Stadium, 7:30pm match today (Sat — *not* a weeknight)
134
+ **Customer**: none
135
+
136
+ **Composed message** (sent as Vera)
137
+ ```
138
+ Quick heads-up Suresh — DC vs MI at Arun Jaitley tonight, 7:30pm. Important:
139
+ Saturday IPL matches usually shift -12% restaurant covers (people watch at
140
+ home). Skip the match-night promo today; instead push your BOGO pizza
141
+ (already active) as a delivery-only Saturday special. Want me to draft the
142
+ Swiggy banner + an Insta story? Live in 10 min.
143
+ ```
144
+
145
+ **Compulsion levers**: counter-intuitive specific data ("Saturday IPL = -12% covers"), saves merchant from a bad decision, leverages existing offer, concrete deliverables (Swiggy banner + Insta story), 10-min commitment cap.
146
+
147
+ **Score breakdown** (50/50)
148
+ | Dimension | Score | Why |
149
+ |---|---|---|
150
+ | Specificity | 10 | Match details, percentage delta, time-bound |
151
+ | Category fit | 10 | Operator-to-operator voice ("covers", "match-night") |
152
+ | Merchant fit | 10 | Existing BOGO referenced; Swiggy/Insta channels appropriate |
153
+ | Trigger relevance | 10 | Direct response to IPL trigger with informed nuance |
154
+ | Engagement compulsion | 10 | Loss aversion (-12%) + existing-offer leverage + 10-min effort cap |
155
+
156
+ This case is illustrative because it shows the bot **adding value beyond the trigger itself** — the trigger said "IPL match today"; the bot interpreted it correctly and gave a contrarian recommendation.
157
+
158
+ ---
159
+
160
+ ## Case Study 6 — Restaurants / Active Planning Intent (merchant-facing)
161
+
162
+ **Category**: `restaurants`
163
+ **Merchant**: Mylari South Indian Cafe, Indiranagar Bangalore — high-volume, weekday lunch thali doing 18 orders/day
164
+ **Trigger**: `active_planning_intent` — merchant asked about a corporate-bulk thali package; explicitly said "yes good idea, what would it look like"
165
+ **Customer**: none
166
+
167
+ **Composed message** (sent as Vera, in direct conversation)
168
+ ```
169
+ Suresh, here's a starter version — you can edit:
170
+
171
+ Mylari Corporate Thali — for offices in Indiranagar
172
+ - 10 thalis @ ₹125 each (₹25 off retail) + free delivery
173
+ - 25 thalis @ ₹115 each + 2 free filter coffees
174
+ - 50+: ₹105 each + 1 free dosa platter
175
+ - WhatsApp the day-before by 5pm; we deliver between 12:30-1pm
176
+
177
+ 3 offices in Indiranagar are in your delivery radius (Embassy Tech, RMZ Eco,
178
+ Sigma Soft). Want me to draft a 3-line WhatsApp to send their facilities
179
+ managers?
180
+ ```
181
+
182
+ **Compulsion levers**: complete drafted artifact (no extra work for merchant), tiered pricing matches B2B logic, concrete radius + named buildings, follow-on offer to handle outreach.
183
+
184
+ **Score breakdown** (49/50)
185
+ | Dimension | Score | Why |
186
+ |---|---|---|
187
+ | Specificity | 10 | Tier prices, radius, building names, time windows |
188
+ | Category fit | 10 | Operator language ("delivery radius", "facilities managers") |
189
+ | Merchant fit | 10 | Indiranagar locality + filter-coffee dosa nods to South Indian cafe identity |
190
+ | Trigger relevance | 10 | Direct continuation of merchant's planning intent |
191
+ | Engagement compulsion | 9 | Strong but assumes building data is in MerchantContext or fabricates — judge will check |
192
+
193
+ ---
194
+
195
+ ## Case Study 7 — Gyms / Seasonal Dip Reframe (merchant-facing)
196
+
197
+ **Category**: `gyms`
198
+ **Merchant**: PowerHouse Fitness, HSR Layout Bangalore, views -30% w/w (April seasonal drop), 245 active members
199
+ **Trigger**: `seasonal_perf_dip` — expected April-June low; flagged as not-a-problem
200
+ **Customer**: none
201
+
202
+ **Composed message** (sent as Vera)
203
+ ```
204
+ Karthik, your views are down 30% this week — but I want to flag this is the
205
+ normal April-June acquisition lull (every metro gym sees -25 to -35% in this
206
+ window). Action: skip ad spend now, save it for Sept-Oct when conversion is
207
+ 2x. For now, focus retention on your 245 members. Want me to draft a "summer
208
+ attendance challenge" to keep them through the dip?
209
+ ```
210
+
211
+ **Compulsion levers**: anxiety pre-emption (the dip is normal), data anchor (-25 to -35%), reframe as opportunity (save spend), specific member count, action proposal.
212
+
213
+ **Score breakdown** (48/50)
214
+ | Dimension | Score | Why |
215
+ |---|---|---|
216
+ | Specificity | 10 | Percentage range, months, member count |
217
+ | Category fit | 10 | Coach-to-operator tone; uses "ad spend", "conversion" correctly |
218
+ | Merchant fit | 10 | His exact dip number + his member count + HSR locality (peer benchmark) |
219
+ | Trigger relevance | 10 | Directly addresses the seasonal dip with reframe |
220
+ | Engagement compulsion | 8 | Could be sharper — "save it for Sept-Oct" is a bit hand-wavy without numbers |
221
+
222
+ ---
223
+
224
+ ## Case Study 8 — Gyms / Customer Lapse Winback (customer-facing)
225
+
226
+ **Category**: `gyms` (customer-facing voice: warm, no-shame, no guilt-trip)
227
+ **Merchant**: PowerHouse Fitness — first month @ ₹499 offer active
228
+ **Trigger**: `customer_lapsed_hard` — Rashmi, 57 days since last visit, previous focus was weight loss, was a 5-month member
229
+ **Customer**: Rashmi
230
+
231
+ **Composed message** (sent as merchant_on_behalf)
232
+ ```
233
+ Hi Rashmi 👋 Karthik from PowerHouse here. It's been about 8 weeks — happens
234
+ to most members at some point, no judgment. We've added a Tue/Thu evening
235
+ HIIT class that fits weight-loss goals well (45 min, 6:30pm). Want me to
236
+ hold a free trial spot for you next Tue, 30 Apr? Reply YES — no commitment,
237
+ no auto-charge.
238
+ ```
239
+
240
+ **Compulsion levers**: name + warmth, no-shame framing, addresses her past goal (weight loss), specific new offering matches goal, no-commitment trial, single binary CTA.
241
+
242
+ **Score breakdown** (50/50)
243
+ | Dimension | Score | Why |
244
+ |---|---|---|
245
+ | Specificity | 10 | Class name, time, day, date, duration |
246
+ | Category fit | 10 | Coach voice, no shame, evidence-based |
247
+ | Merchant fit | 10 | Karthik (owner first name), PowerHouse, real new class |
248
+ | Trigger relevance | 10 | Explicitly addresses lapse without being needy |
249
+ | Engagement compulsion | 10 | "no judgment" + "no commitment, no auto-charge" removes 2 common barriers in one line |
250
+
251
+ ---
252
+
253
+ ## Case Study 9 — Pharmacies / Compliance Alert (merchant-facing)
254
+
255
+ **Category**: `pharmacies`
256
+ **Merchant**: Apollo Health Plus Pharmacy, Malviya Nagar Jaipur — engaged, has 240 chronic-Rx customers
257
+ **Trigger**: `supply_alert` — voluntary recall on atorvastatin batches AT2024-1102 + AT2024-1108 by Mfr Z
258
+ **Customer**: none
259
+
260
+ **Composed message** (sent as Vera)
261
+ ```
262
+ Ramesh, urgent: voluntary recall on 2 atorvastatin batches (AT2024-1102,
263
+ AT2024-1108) by Mfr Z — sub-potency, no safety risk, but customers should be
264
+ informed for replacement. Pulled your repeat-Rx list: 22 of your chronic-Rx
265
+ customers were dispensed these batches in last 90 days. Want me to draft
266
+ their WhatsApp note + the replacement-pickup workflow?
267
+ ```
268
+
269
+ **Compulsion levers**: urgency + specificity (batch numbers), risk-but-bounded framing, **derived count from merchant data** (22 of 240), end-to-end workflow offer (note + pickup).
270
+
271
+ **Score breakdown** (50/50)
272
+ | Dimension | Score | Why |
273
+ |---|---|---|
274
+ | Specificity | 10 | Batch numbers, manufacturer, count of affected customers |
275
+ | Category fit | 10 | Trustworthy-precise tone; correct use of "sub-potency" without alarming |
276
+ | Merchant fit | 10 | Pulls from his customer aggregate; uses his name |
277
+ | Trigger relevance | 10 | Direct compliance action |
278
+ | Engagement compulsion | 10 | Urgency + clear offer of complete artifact |
279
+
280
+ ---
281
+
282
+ ## Case Study 10 — Pharmacies / Chronic Refill Reminder (customer-facing)
283
+
284
+ **Category**: `pharmacies` (customer-facing voice: trustworthy, precise, respectful of senior)
285
+ **Merchant**: Apollo Health Plus Pharmacy — free home delivery >₹499, senior 15% off active
286
+ **Trigger**: `chronic_refill_due` — Mr. Sharma's metformin/atorvastatin/telmisartan run out 2026-04-28
287
+ **Customer**: Mr. Sharma — 65-75 age band, senior citizen, channel via son's WhatsApp, address saved, opted in
288
+
289
+ **Composed message** (sent as merchant_on_behalf, addressed to son)
290
+ ```
291
+ Namaste — Apollo Health Plus Malviya Nagar yahan. Sharma ji ki 3 monthly
292
+ medicines (metformin, atorvastatin, telmisartan) 28 April ko khatam hongi.
293
+ Same dose, same brand pack ready hai. Senior discount 15% applied — total
294
+ ₹1,420 (₹240 saved). Free home delivery to saved address by 5pm tomorrow.
295
+ Reply CONFIRM to dispatch, or call 9876543210 if any change in dosage.
296
+ ```
297
+
298
+ **Compulsion levers**: namaste salutation (respectful), full molecule names (precision), specific date, total + savings shown clearly, two-channel option (reply OR call), senior-citizen norms honored.
299
+
300
+ **Score breakdown** (49/50)
301
+ | Dimension | Score | Why |
302
+ |---|---|---|
303
+ | Specificity | 10 | Three molecule names, exact date, total + savings, time window |
304
+ | Category fit | 10 | Trustworthy-precise voice; namaste salutation appropriate |
305
+ | Merchant fit | 10 | Apollo's actual offers (free delivery, senior 15%); Malviya Nagar locality |
306
+ | Trigger relevance | 10 | Refill due date is the central anchor |
307
+ | Engagement compulsion | 9 | Strong; could nudge with "stocks may take 24h" if scarcity were real |
308
+
309
+ ---
310
+
311
+ ## Cross-case patterns the judge looks for
312
+
313
+ Reading the 10 cases together, here are the patterns that consistently score 9-10/10:
314
+
315
+ 1. **Source citation when claiming research/compliance** — JIDA p.14, DCI circular, batch numbers. No citation = score capped at 7.
316
+ 2. **Numbers from the contexts, not invented** — "22 of your chronic-Rx customers" is computed from the merchant's customer_aggregate; "245 active members" is from MerchantContext directly. Numbers without provenance get scored as fabrication.
317
+ 3. **Owner/merchant first name when present** — Dr. Meera, Suresh, Karthik, Ramesh. Generic "Hi" loses 1 point on merchant fit.
318
+ 4. **Single most important next step framed as low-friction commitment** — "Want me to draft X? Live in 10 min" / "Reply YES — no commitment, no auto-charge". Multi-action asks dilute.
319
+ 5. **Customer-facing messages honor language preference + relationship state** — Hindi-English mix for Priya, namaste for Mr. Sharma's son. Treating every customer the same loses 2 points on customer fit.
320
+ 6. **Domain-specific vocabulary used correctly** — "covers", "AOV", "sub-potency", "fluoride varnish", "ad spend", "conversion". Wrong vocabulary or absent vocabulary signals the bot didn't actually use the CategoryContext.voice.
321
+ 7. **The bot adds judgment, not just templating** — Case Study 5 (IPL) shows the bot recommending *not* to push the IPL promo on a Saturday. That kind of contrarian, data-informed call is the highest signal of category understanding.
322
+ 8. **The conversation_id is meaningful** — `conv_priya_recall_2026_11` is good (decodable, resumable). `conv_001` is acceptable. UUIDs without context lose nothing but help nothing.
323
+ 9. **The rationale field is concise and reflects actual reasoning** — judge cross-checks rationale against the message; mismatch = penalty.
324
+ 10. **No repetition, no fabrication** — these are the operational floor. Any of them in the message and the case is capped at 5/dimension regardless of quality.
325
+
326
+ ---
327
+
328
+ ## How the judge uses these cases
329
+
330
+ For each submission, the judge LLM:
331
+ 1. Reads the candidate's composition for the same (category, merchant, trigger, customer) tuple.
332
+ 2. Compares against the case-study output above.
333
+ 3. Scores each of the 5 dimensions on a 0-10 scale, citing what's better/worse.
334
+ 4. Aggregates into the per-test-pair score.
335
+
336
+ Candidates can review these cases as a north star, but **direct copying the body text of a case study counts as plagiarism** — the judge runs a similarity check on submissions vs the case studies and penalizes near-duplicates.
337
+
338
+ The cases are meant to teach the *shape* of good output: specificity, category fit, merchant fit, trigger relevance, compulsion. Your wording must be your own.
judge_simulator.py ADDED
@@ -0,0 +1,962 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ magicpin AI Challenge — LLM-Powered Judge Simulator
4
+ ====================================================
5
+
6
+ A strict but fair judge that scores your bot and explains WHY.
7
+
8
+ HOW TO USE:
9
+ 1. Edit the CONFIGURATION section below (lines 25-45)
10
+ 2. Set your LLM provider and API key
11
+ 3. Set your bot URL
12
+ 4. Run: python judge_simulator.py
13
+
14
+ That's it!
15
+
16
+ Author: magicpin AI Challenge Team
17
+ """
18
+
19
+ # =============================================================================
20
+ # ██████ CONFIGURATION - EDIT THIS SECTION ██████
21
+ # =============================================================================
22
+
23
+ # Your bot's URL (where your bot is running)
24
+ BOT_URL = "http://localhost:8080"
25
+
26
+ # Choose your LLM provider: "openai", "anthropic", "gemini", "deepseek", "groq", "ollama", "openrouter"
27
+ LLM_PROVIDER = "openai"
28
+
29
+ # Your API key (paste your key here)
30
+ LLM_API_KEY = "" # <-- PUT YOUR API KEY HERE
31
+
32
+ # Model to use (leave empty for default, or specify like "gpt-4o", "claude-3-5-sonnet-20241022", etc.)
33
+ LLM_MODEL = "" # <-- Optional: specify model or leave empty for default
34
+
35
+ # For Ollama only: local server URL
36
+ OLLAMA_URL = "http://localhost:11434"
37
+
38
+ # Which test to run by default
39
+ TEST_SCENARIO = "all"
40
+
41
+ # =============================================================================
42
+ # ██████ END OF CONFIGURATION - DON'T EDIT BELOW THIS LINE ██████
43
+ # =============================================================================
44
+
45
+ import os
46
+ import sys
47
+ import json
48
+ import time
49
+ import re
50
+ import socket
51
+ from datetime import datetime
52
+ from dataclasses import dataclass, field
53
+ from typing import Optional, List, Dict, Any, Tuple
54
+ from pathlib import Path
55
+ from urllib import request as urlrequest, error as urlerror
56
+ from abc import ABC, abstractmethod
57
+
58
+ # Constants
59
+ TIMEOUT_LLM = 45
60
+ DATASET_DIR = Path(__file__).parent / "dataset"
61
+
62
+ # =============================================================================
63
+ # TERMINAL OUTPUT
64
+ # =============================================================================
65
+
66
+ class Colors:
67
+ HEADER = '\033[95m'
68
+ BLUE = '\033[94m'
69
+ CYAN = '\033[96m'
70
+ GREEN = '\033[92m'
71
+ YELLOW = '\033[93m'
72
+ RED = '\033[91m'
73
+ MAGENTA = '\033[35m'
74
+ BOLD = '\033[1m'
75
+ DIM = '\033[2m'
76
+ RESET = '\033[0m'
77
+
78
+ def print_header(text: str):
79
+ print(f"\n{Colors.HEADER}{Colors.BOLD}{'='*70}{Colors.RESET}")
80
+ print(f"{Colors.HEADER}{Colors.BOLD}{text.center(70)}{Colors.RESET}")
81
+ print(f"{Colors.HEADER}{Colors.BOLD}{'='*70}{Colors.RESET}\n")
82
+
83
+ def print_section(text: str):
84
+ print(f"\n{Colors.CYAN}{Colors.BOLD}--- {text} ---{Colors.RESET}\n")
85
+
86
+ def print_success(text: str):
87
+ print(f"{Colors.GREEN}[PASS]{Colors.RESET} {text}")
88
+
89
+ def print_fail(text: str):
90
+ print(f"{Colors.RED}[FAIL]{Colors.RESET} {text}")
91
+
92
+ def print_warn(text: str):
93
+ print(f"{Colors.YELLOW}[WARN]{Colors.RESET} {text}")
94
+
95
+ def print_info(text: str):
96
+ print(f"{Colors.BLUE}[INFO]{Colors.RESET} {text}")
97
+
98
+ def print_llm(text: str):
99
+ print(f"{Colors.MAGENTA}[LLM]{Colors.RESET} {text}")
100
+
101
+ def print_score_bar(dimension: str, score: int, max_score: int = 10):
102
+ bar_filled = int((score / max_score) * 20)
103
+ bar_empty = 20 - bar_filled
104
+ color = Colors.GREEN if score >= 7 else Colors.YELLOW if score >= 4 else Colors.RED
105
+ print(f" {dimension:22} [{color}{'█' * bar_filled}{Colors.DIM}{'░' * bar_empty}{Colors.RESET}] {color}{score:2}/{max_score}{Colors.RESET}")
106
+
107
+ def print_reason(text: str):
108
+ wrapped = text[:200] + "..." if len(text) > 200 else text
109
+ print(f" {Colors.DIM}{wrapped}{Colors.RESET}")
110
+
111
+ def print_hint(hint: str):
112
+ print(f"\n {Colors.YELLOW}Hint:{Colors.RESET} {hint}")
113
+
114
+ # =============================================================================
115
+ # DATA CLASSES
116
+ # =============================================================================
117
+
118
+ @dataclass
119
+ class ScoreResult:
120
+ specificity: int = 0
121
+ specificity_reason: str = ""
122
+ category_fit: int = 0
123
+ category_fit_reason: str = ""
124
+ merchant_fit: int = 0
125
+ merchant_fit_reason: str = ""
126
+ decision_quality: int = 0
127
+ decision_quality_reason: str = ""
128
+ engagement_compulsion: int = 0
129
+ engagement_reason: str = ""
130
+ penalties: int = 0
131
+ penalty_reasons: List[str] = field(default_factory=list)
132
+ hint: str = ""
133
+
134
+ @property
135
+ def total(self) -> int:
136
+ return max(0, self.specificity + self.category_fit + self.merchant_fit +
137
+ self.decision_quality + self.engagement_compulsion - self.penalties)
138
+
139
+ # =============================================================================
140
+ # LLM PROVIDERS
141
+ # =============================================================================
142
+
143
+ class LLMProvider(ABC):
144
+ @abstractmethod
145
+ def complete(self, prompt: str, system: str = None) -> str:
146
+ pass
147
+
148
+ @abstractmethod
149
+ def name(self) -> str:
150
+ pass
151
+
152
+
153
+ class OpenAIProvider(LLMProvider):
154
+ def __init__(self, api_key: str, model: str = ""):
155
+ self.api_key = api_key
156
+ self.model = model or "gpt-4o-mini"
157
+
158
+ def name(self) -> str:
159
+ return f"OpenAI ({self.model})"
160
+
161
+ def complete(self, prompt: str, system: str = None) -> str:
162
+ messages = []
163
+ if system:
164
+ messages.append({"role": "system", "content": system})
165
+ messages.append({"role": "user", "content": prompt})
166
+
167
+ body = json.dumps({
168
+ "model": self.model,
169
+ "messages": messages,
170
+ "temperature": 0.2,
171
+ "max_tokens": 1500
172
+ }).encode("utf-8")
173
+
174
+ req = urlrequest.Request(
175
+ "https://api.openai.com/v1/chat/completions",
176
+ data=body,
177
+ headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
178
+ )
179
+ resp = urlrequest.urlopen(req, timeout=TIMEOUT_LLM)
180
+ data = json.loads(resp.read().decode("utf-8"))
181
+ return data["choices"][0]["message"]["content"]
182
+
183
+
184
+ class AnthropicProvider(LLMProvider):
185
+ def __init__(self, api_key: str, model: str = ""):
186
+ self.api_key = api_key
187
+ self.model = model or "claude-3-5-sonnet-20241022"
188
+
189
+ def name(self) -> str:
190
+ return f"Anthropic ({self.model})"
191
+
192
+ def complete(self, prompt: str, system: str = None) -> str:
193
+ body_dict = {"model": self.model, "max_tokens": 1500,
194
+ "messages": [{"role": "user", "content": prompt}]}
195
+ if system:
196
+ body_dict["system"] = system
197
+
198
+ req = urlrequest.Request(
199
+ "https://api.anthropic.com/v1/messages",
200
+ data=json.dumps(body_dict).encode("utf-8"),
201
+ headers={"x-api-key": self.api_key, "Content-Type": "application/json",
202
+ "anthropic-version": "2023-06-01"}
203
+ )
204
+ resp = urlrequest.urlopen(req, timeout=TIMEOUT_LLM)
205
+ data = json.loads(resp.read().decode("utf-8"))
206
+ return data["content"][0]["text"]
207
+
208
+
209
+ class GeminiProvider(LLMProvider):
210
+ def __init__(self, api_key: str, model: str = ""):
211
+ self.api_key = api_key
212
+ self.model = model or "gemini-1.5-flash"
213
+
214
+ def name(self) -> str:
215
+ return f"Gemini ({self.model})"
216
+
217
+ def complete(self, prompt: str, system: str = None) -> str:
218
+ full_prompt = f"{system}\n\n{prompt}" if system else prompt
219
+ body = json.dumps({
220
+ "contents": [{"parts": [{"text": full_prompt}]}],
221
+ "generationConfig": {"temperature": 0.2, "maxOutputTokens": 1500}
222
+ }).encode("utf-8")
223
+
224
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent?key={self.api_key}"
225
+ req = urlrequest.Request(url, data=body, headers={"Content-Type": "application/json"})
226
+ resp = urlrequest.urlopen(req, timeout=TIMEOUT_LLM)
227
+ data = json.loads(resp.read().decode("utf-8"))
228
+ return data["candidates"][0]["content"]["parts"][0]["text"]
229
+
230
+
231
+ class DeepSeekProvider(LLMProvider):
232
+ def __init__(self, api_key: str, model: str = ""):
233
+ self.api_key = api_key
234
+ self.model = model or "deepseek-chat"
235
+
236
+ def name(self) -> str:
237
+ return f"DeepSeek ({self.model})"
238
+
239
+ def complete(self, prompt: str, system: str = None) -> str:
240
+ messages = []
241
+ if system:
242
+ messages.append({"role": "system", "content": system})
243
+ messages.append({"role": "user", "content": prompt})
244
+
245
+ req = urlrequest.Request(
246
+ "https://api.deepseek.com/v1/chat/completions",
247
+ data=json.dumps({"model": self.model, "messages": messages,
248
+ "temperature": 0.2, "max_tokens": 1500}).encode("utf-8"),
249
+ headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
250
+ )
251
+ resp = urlrequest.urlopen(req, timeout=TIMEOUT_LLM)
252
+ data = json.loads(resp.read().decode("utf-8"))
253
+ return data["choices"][0]["message"]["content"]
254
+
255
+
256
+ class GroqProvider(LLMProvider):
257
+ def __init__(self, api_key: str, model: str = ""):
258
+ self.api_key = api_key
259
+ self.model = model or "llama-3.1-70b-versatile"
260
+
261
+ def name(self) -> str:
262
+ return f"Groq ({self.model})"
263
+
264
+ def complete(self, prompt: str, system: str = None) -> str:
265
+ messages = []
266
+ if system:
267
+ messages.append({"role": "system", "content": system})
268
+ messages.append({"role": "user", "content": prompt})
269
+
270
+ req = urlrequest.Request(
271
+ "https://api.groq.com/openai/v1/chat/completions",
272
+ data=json.dumps({"model": self.model, "messages": messages,
273
+ "temperature": 0.2, "max_tokens": 1500}).encode("utf-8"),
274
+ headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
275
+ )
276
+ resp = urlrequest.urlopen(req, timeout=TIMEOUT_LLM)
277
+ data = json.loads(resp.read().decode("utf-8"))
278
+ return data["choices"][0]["message"]["content"]
279
+
280
+
281
+ class OllamaProvider(LLMProvider):
282
+ def __init__(self, model: str = "", api_url: str = ""):
283
+ self.model = model or "llama3"
284
+ self.api_url = api_url or "http://localhost:11434"
285
+
286
+ def name(self) -> str:
287
+ return f"Ollama ({self.model})"
288
+
289
+ def complete(self, prompt: str, system: str = None) -> str:
290
+ full_prompt = f"{system}\n\n{prompt}" if system else prompt
291
+ req = urlrequest.Request(
292
+ f"{self.api_url}/api/generate",
293
+ data=json.dumps({"model": self.model, "prompt": full_prompt,
294
+ "stream": False, "options": {"temperature": 0.2}}).encode("utf-8"),
295
+ headers={"Content-Type": "application/json"}
296
+ )
297
+ resp = urlrequest.urlopen(req, timeout=90)
298
+ data = json.loads(resp.read().decode("utf-8"))
299
+ return data["response"]
300
+
301
+
302
+ class OpenRouterProvider(LLMProvider):
303
+ def __init__(self, api_key: str, model: str = ""):
304
+ self.api_key = api_key
305
+ self.model = model or "anthropic/claude-3-haiku"
306
+
307
+ def name(self) -> str:
308
+ return f"OpenRouter ({self.model})"
309
+
310
+ def complete(self, prompt: str, system: str = None) -> str:
311
+ messages = []
312
+ if system:
313
+ messages.append({"role": "system", "content": system})
314
+ messages.append({"role": "user", "content": prompt})
315
+
316
+ req = urlrequest.Request(
317
+ "https://openrouter.ai/api/v1/chat/completions",
318
+ data=json.dumps({"model": self.model, "messages": messages,
319
+ "temperature": 0.2, "max_tokens": 1500}).encode("utf-8"),
320
+ headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json",
321
+ "HTTP-Referer": "https://magicpin.com"}
322
+ )
323
+ resp = urlrequest.urlopen(req, timeout=TIMEOUT_LLM)
324
+ data = json.loads(resp.read().decode("utf-8"))
325
+ return data["choices"][0]["message"]["content"]
326
+
327
+
328
+ def create_provider() -> LLMProvider:
329
+ """Create LLM provider from configuration."""
330
+ providers = {
331
+ "openai": lambda: OpenAIProvider(LLM_API_KEY, LLM_MODEL),
332
+ "anthropic": lambda: AnthropicProvider(LLM_API_KEY, LLM_MODEL),
333
+ "gemini": lambda: GeminiProvider(LLM_API_KEY, LLM_MODEL),
334
+ "deepseek": lambda: DeepSeekProvider(LLM_API_KEY, LLM_MODEL),
335
+ "groq": lambda: GroqProvider(LLM_API_KEY, LLM_MODEL),
336
+ "ollama": lambda: OllamaProvider(LLM_MODEL, OLLAMA_URL),
337
+ "openrouter": lambda: OpenRouterProvider(LLM_API_KEY, LLM_MODEL),
338
+ }
339
+
340
+ if LLM_PROVIDER not in providers:
341
+ print_fail(f"Unknown provider: {LLM_PROVIDER}")
342
+ print_info(f"Available: {', '.join(providers.keys())}")
343
+ sys.exit(1)
344
+
345
+ return providers[LLM_PROVIDER]()
346
+
347
+ # =============================================================================
348
+ # DATASET & BOT CLIENT
349
+ # =============================================================================
350
+
351
+ class DatasetLoader:
352
+ def __init__(self, dataset_dir: Path):
353
+ self.dataset_dir = dataset_dir
354
+ self.categories = {}
355
+ self.merchants = {}
356
+ self.customers = {}
357
+ self.triggers = {}
358
+
359
+ def load(self) -> bool:
360
+ try:
361
+ cat_dir = self.dataset_dir / "categories"
362
+ if cat_dir.exists():
363
+ for f in cat_dir.glob("*.json"):
364
+ data = json.load(open(f))
365
+ self.categories[data.get("slug", f.stem)] = data
366
+
367
+ for name, container, key in [
368
+ ("merchants_seed.json", "merchants", "merchant_id"),
369
+ ("customers_seed.json", "customers", "customer_id"),
370
+ ("triggers_seed.json", "triggers", "id")
371
+ ]:
372
+ path = self.dataset_dir / name
373
+ if path.exists():
374
+ data = json.load(open(path))
375
+ items = data.get(container, data.get(container.rstrip("s"), []))
376
+ storage = getattr(self, container)
377
+ for item in items:
378
+ if key in item:
379
+ storage[item[key]] = item
380
+ return True
381
+ except Exception as e:
382
+ print_fail(f"Dataset load error: {e}")
383
+ return False
384
+
385
+
386
+ class BotClient:
387
+ def __init__(self, base_url: str):
388
+ self.base_url = base_url.rstrip("/")
389
+
390
+ def _request(self, method: str, path: str, timeout: int = 30,
391
+ body_dict: Dict = None) -> Tuple[Optional[Dict], Optional[str], float]:
392
+ url = f"{self.base_url}{path}"
393
+ start = time.time()
394
+ body = json.dumps(body_dict).encode("utf-8") if body_dict else None
395
+ headers = {"Content-Type": "application/json"}
396
+ req = urlrequest.Request(url, data=body, method=method, headers=headers)
397
+
398
+ try:
399
+ resp = urlrequest.urlopen(req, timeout=timeout)
400
+ return json.loads(resp.read().decode("utf-8")), None, (time.time() - start) * 1000
401
+ except urlerror.HTTPError as e:
402
+ latency = (time.time() - start) * 1000
403
+ if e.code == 401:
404
+ return None, "Unauthorized", latency
405
+ try:
406
+ return json.loads(e.read().decode("utf-8")), None, latency
407
+ except:
408
+ return None, f"HTTP {e.code}", latency
409
+ except Exception as e:
410
+ return None, str(e), (time.time() - start) * 1000
411
+
412
+ def healthz(self):
413
+ return self._request("GET", "/v1/healthz", 5)
414
+
415
+ def metadata(self):
416
+ return self._request("GET", "/v1/metadata", 5)
417
+
418
+ def push_context(self, scope, cid, version, payload):
419
+ return self._request("POST", "/v1/context", 10, {
420
+ "scope": scope, "context_id": cid, "version": version,
421
+ "payload": payload, "delivered_at": datetime.utcnow().isoformat() + "Z"
422
+ })
423
+
424
+ def tick(self, triggers):
425
+ return self._request("POST", "/v1/tick", 15, {
426
+ "now": datetime.utcnow().isoformat() + "Z", "available_triggers": triggers
427
+ })
428
+
429
+ def reply(self, conv_id, merchant_id, message, turn):
430
+ return self._request("POST", "/v1/reply", 15, {
431
+ "conversation_id": conv_id, "merchant_id": merchant_id, "customer_id": None,
432
+ "from_role": "merchant", "message": message,
433
+ "received_at": datetime.utcnow().isoformat() + "Z", "turn_number": turn
434
+ })
435
+
436
+ # =============================================================================
437
+ # LLM SCORING ENGINE
438
+ # =============================================================================
439
+
440
+ class LLMScorer:
441
+ """Scores messages using LLM and provides detailed reasoning."""
442
+
443
+ SYSTEM = """You are a STRICT judge for the magicpin AI Challenge. You score merchant engagement messages.
444
+
445
+ SCORING DIMENSIONS (0-10 each, be strict - 5 is average, 7+ is good, 9+ is excellent):
446
+
447
+ 1. SPECIFICITY: Does the message have VERIFIABLE facts?
448
+ - Numbers (percentages, counts, prices)
449
+ - Dates/times
450
+ - Source citations
451
+ - Concrete claims vs vague statements
452
+
453
+ 2. CATEGORY FIT: Does the voice match the business type?
454
+ - Dentists: clinical, peer-to-peer, technical OK, use "Dr." prefix
455
+ - Salons: warm, friendly, practical
456
+ - Restaurants: operator-to-operator
457
+ - Gyms: coaching, motivational
458
+ - Pharmacies: trustworthy, precise
459
+
460
+ 3. MERCHANT FIT: Is it personalized to THIS merchant?
461
+ - Uses their name/owner name correctly
462
+ - References their actual data (not fabricated)
463
+ - Honors language preference
464
+
465
+ 4. TRIGGER RELEVANCE: Does it connect to WHY NOW?
466
+ - Clear reason for this specific message
467
+ - Uses data from the trigger payload
468
+ - Not a generic nudge
469
+
470
+ 5. ENGAGEMENT COMPULSION: Would they reply?
471
+ - Loss aversion, curiosity, social proof
472
+ - Clear CTA
473
+ - Low friction ask
474
+
475
+ PENALTIES:
476
+ - Fabricating data not in context: -2
477
+ - Exposing internal jargon to merchant: -1
478
+
479
+ RESPOND ONLY WITH THIS EXACT JSON FORMAT:
480
+ {
481
+ "specificity": <0-10>,
482
+ "specificity_reason": "<why this score, 1-2 sentences>",
483
+ "category_fit": <0-10>,
484
+ "category_fit_reason": "<why this score>",
485
+ "merchant_fit": <0-10>,
486
+ "merchant_fit_reason": "<why this score>",
487
+ "decision_quality": <0-10>,
488
+ "decision_quality_reason": "<why this score>",
489
+ "engagement_compulsion": <0-10>,
490
+ "engagement_reason": "<why this score>",
491
+ "hint": "<one sentence guidance for improvement, cryptic not direct>"
492
+ }"""
493
+
494
+ def __init__(self, llm: LLMProvider, dataset: DatasetLoader):
495
+ self.llm = llm
496
+ self.dataset = dataset
497
+
498
+ def score(self, action: Dict, category: Dict, merchant: Dict,
499
+ trigger: Dict, customer: Dict = None) -> ScoreResult:
500
+ """Score a message and return detailed results."""
501
+
502
+ body = action.get("body", "")
503
+
504
+ prompt = f"""SCORE THIS MESSAGE:
505
+
506
+ === CONTEXT PROVIDED TO BOT ===
507
+ Category: {category.get('slug', 'unknown')}
508
+ Voice: {category.get('voice', {}).get('tone', 'unknown')}
509
+ Taboos: {category.get('voice', {}).get('vocab_taboo', [])[:5]}
510
+
511
+ Merchant: {merchant.get('identity', {}).get('name', 'unknown')}
512
+ Owner: {merchant.get('identity', {}).get('owner_first_name', 'unknown')}
513
+ Locality: {merchant.get('identity', {}).get('locality', 'unknown')}
514
+ Languages: {merchant.get('identity', {}).get('languages', [])}
515
+ Performance: views={merchant.get('performance', {}).get('views', '?')}, calls={merchant.get('performance', {}).get('calls', '?')}, ctr={merchant.get('performance', {}).get('ctr', '?')}
516
+ Signals: {merchant.get('signals', [])}
517
+ Active Offers: {[o.get('title') for o in merchant.get('offers', []) if o.get('status') == 'active']}
518
+
519
+ Trigger Kind: {trigger.get('kind', 'unknown')}
520
+ Trigger Payload: {json.dumps(trigger.get('payload', {}))}
521
+ Trigger Urgency: {trigger.get('urgency', '?')}
522
+
523
+ Customer: {json.dumps(customer.get('identity', {})) if customer else 'None (merchant-facing)'}
524
+
525
+ === BOT'S MESSAGE ===
526
+ Body ({len(body)} chars): "{body}"
527
+ CTA: {action.get('cta', 'none')}
528
+ Send As: {action.get('send_as', 'vera')}
529
+
530
+ Score each dimension 0-10 with clear reasoning. Be STRICT."""
531
+
532
+ try:
533
+ print_llm("Analyzing message...")
534
+ response = self.llm.complete(prompt, self.SYSTEM)
535
+ return self._parse_response(response, action)
536
+ except Exception as e:
537
+ print_warn(f"LLM error: {e}")
538
+ return self._fallback_score(action)
539
+
540
+ def _parse_response(self, response: str, action: Dict) -> ScoreResult:
541
+ """Parse LLM JSON response."""
542
+ match = re.search(r'\{[\s\S]*\}', response)
543
+ if not match:
544
+ return self._fallback_score(action)
545
+
546
+ try:
547
+ data = json.loads(match.group())
548
+ result = ScoreResult(
549
+ specificity=min(10, max(0, int(data.get("specificity", 5)))),
550
+ specificity_reason=data.get("specificity_reason", ""),
551
+ category_fit=min(10, max(0, int(data.get("category_fit", 5)))),
552
+ category_fit_reason=data.get("category_fit_reason", ""),
553
+ merchant_fit=min(10, max(0, int(data.get("merchant_fit", 5)))),
554
+ merchant_fit_reason=data.get("merchant_fit_reason", ""),
555
+ decision_quality=min(10, max(0, int(data.get("decision_quality", data.get("trigger_relevance", 5))))),
556
+ decision_quality_reason=data.get("decision_quality_reason", data.get("trigger_relevance_reason", "")),
557
+ engagement_compulsion=min(10, max(0, int(data.get("engagement_compulsion", 5)))),
558
+ engagement_reason=data.get("engagement_reason", ""),
559
+ hint=data.get("hint", "")
560
+ )
561
+ return result
562
+ except Exception as e:
563
+ print_warn(f"Parse error: {e}")
564
+ return self._fallback_score(action)
565
+
566
+ def _fallback_score(self, action: Dict) -> ScoreResult:
567
+ """Basic fallback scoring."""
568
+ body = action.get("body", "").lower()
569
+ nums = len(re.findall(r'\d+', body))
570
+ return ScoreResult(
571
+ specificity=min(10, 3 + nums * 2),
572
+ specificity_reason="Fallback: counted numbers in message",
573
+ category_fit=5, category_fit_reason="Could not evaluate",
574
+ merchant_fit=5, merchant_fit_reason="Could not evaluate",
575
+ decision_quality=5, decision_quality_reason="Could not evaluate",
576
+ engagement_compulsion=5, engagement_reason="Could not evaluate",
577
+ hint="LLM scoring failed - using basic heuristics"
578
+ )
579
+
580
+ # =============================================================================
581
+ # MAIN JUDGE
582
+ # =============================================================================
583
+
584
+ class JudgeSimulator:
585
+ def __init__(self, llm: LLMProvider):
586
+ self.llm = llm
587
+ self.client = BotClient(BOT_URL)
588
+ self.dataset = DatasetLoader(DATASET_DIR)
589
+ self.scorer: Optional[LLMScorer] = None
590
+ self.all_scores: List[ScoreResult] = []
591
+
592
+ def run(self, scenario: str) -> bool:
593
+ print_header(f"LLM JUDGE — {scenario.upper()}")
594
+ print_info(f"Bot: {BOT_URL}")
595
+ print_info(f"LLM: {self.llm.name()}")
596
+
597
+ if not self.dataset.load():
598
+ print_fail("Dataset load failed")
599
+ return False
600
+
601
+ self.scorer = LLMScorer(self.llm, self.dataset)
602
+ print_info(f"Loaded: {len(self.dataset.categories)} categories, "
603
+ f"{len(self.dataset.merchants)} merchants, "
604
+ f"{len(self.dataset.triggers)} triggers")
605
+
606
+ scenarios = {
607
+ "warmup": self._warmup,
608
+ "phase2_short": self._phase2_short,
609
+ "auto_reply_hell": self._auto_reply,
610
+ "intent_transition": self._intent,
611
+ "hostile": self._hostile,
612
+ "all": self._all,
613
+ "full_evaluation": self._full,
614
+ }
615
+
616
+ if scenario not in scenarios:
617
+ print_fail(f"Unknown scenario: {scenario}")
618
+ print_info(f"Available: {', '.join(scenarios.keys())}")
619
+ return False
620
+
621
+ success = scenarios[scenario]()
622
+ self._final_summary()
623
+ return success
624
+
625
+ def _warmup(self) -> bool:
626
+ print_section("WARMUP")
627
+
628
+ data, err, lat = self.client.healthz()
629
+ if err:
630
+ print_fail(f"healthz: {err}")
631
+ return False
632
+ print_success(f"healthz ({lat:.0f}ms)")
633
+
634
+ data, err, lat = self.client.metadata()
635
+ if err:
636
+ print_warn(f"metadata: {err}")
637
+ else:
638
+ print_success(f"metadata — Team: {data.get('team_name', '?')}, Model: {data.get('model', '?')}")
639
+
640
+ print_section("CONTEXT PUSH")
641
+ for slug, cat in self.dataset.categories.items():
642
+ data, err, _ = self.client.push_context("category", slug, 1, cat)
643
+ status = "PASS" if data and data.get("accepted") else "FAIL"
644
+ print(f" [{status}] category/{slug}")
645
+
646
+ for mid, m in list(self.dataset.merchants.items())[:5]:
647
+ data, err, _ = self.client.push_context("merchant", mid, 1, m)
648
+ status = "PASS" if data and data.get("accepted") else "FAIL"
649
+ short_id = mid.split('_')[1] if '_' in mid else mid[:10]
650
+ print(f" [{status}] merchant/{short_id}")
651
+
652
+ return True
653
+
654
+ def _phase2_short(self) -> bool:
655
+ if not self._warmup():
656
+ return False
657
+
658
+ print_section("TICK TEST")
659
+
660
+ trigs = list(self.dataset.triggers.keys())[:3]
661
+ for tid in trigs:
662
+ self.client.push_context("trigger", tid, 1, self.dataset.triggers[tid])
663
+
664
+ data, err, lat = self.client.tick(trigs)
665
+ if err:
666
+ print_fail(f"tick: {err}")
667
+ return False
668
+
669
+ actions = data.get("actions", [])
670
+ print_info(f"Bot returned {len(actions)} action(s) ({lat:.0f}ms)")
671
+
672
+ if not actions:
673
+ print_warn("No actions — bot chose not to send")
674
+ return True
675
+
676
+ for action in actions:
677
+ self._score_and_display(action)
678
+
679
+ return True
680
+
681
+ def _auto_reply(self) -> bool:
682
+ print_section("AUTO-REPLY DETECTION")
683
+
684
+ data, err, _ = self.client.healthz()
685
+ if err:
686
+ print_fail(f"Bot unreachable: {err}")
687
+ return False
688
+
689
+ mid = list(self.dataset.merchants.keys())[0] if self.dataset.merchants else "m_test"
690
+ auto_msg = "Thank you for contacting us! Our team will respond shortly."
691
+
692
+ for i in range(1, 5):
693
+ print_info(f"Turn {i}: Sending auto-reply...")
694
+ data, err, _ = self.client.reply(f"conv_auto_{i}", mid, auto_msg, i + 1)
695
+
696
+ if err:
697
+ print_fail(f"Error: {err}")
698
+ return False
699
+
700
+ action = data.get("action", "?")
701
+
702
+ if action == "end":
703
+ print_success(f"Turn {i}: Bot ENDED — detected auto-reply pattern!")
704
+ return True
705
+ elif action == "wait":
706
+ wait_s = data.get("wait_seconds", "?")
707
+ print_success(f"Turn {i}: Bot WAITING {wait_s}s")
708
+ else:
709
+ body = data.get("body", "")[:50]
710
+ print_warn(f"Turn {i}: Bot sent: \"{body}...\"")
711
+
712
+ print_warn("Bot never ended after 4 auto-replies")
713
+ return True
714
+
715
+ def _intent(self) -> bool:
716
+ print_section("INTENT TRANSITION")
717
+
718
+ data, err, _ = self.client.healthz()
719
+ if err:
720
+ print_fail(f"Bot unreachable: {err}")
721
+ return False
722
+
723
+ mid = list(self.dataset.merchants.keys())[0] if self.dataset.merchants else "m_test"
724
+ commitment = "Ok lets do it. Whats next?"
725
+
726
+ print_info(f"Merchant: \"{commitment}\"")
727
+ data, err, _ = self.client.reply("conv_intent_1", mid, commitment, 2)
728
+
729
+ if err:
730
+ print_fail(f"Error: {err}")
731
+ return False
732
+
733
+ action = data.get("action", "?")
734
+ body = data.get("body", "")
735
+
736
+ print_info(f"Bot action: {action}")
737
+ if body:
738
+ print_info(f"Bot body: \"{body[:100]}{'...' if len(body) > 100 else ''}\"")
739
+
740
+ qualifying = ["would you", "do you", "can you tell", "what if", "how about"]
741
+ actioning = ["done", "sending", "draft", "here", "confirm", "proceed", "next"]
742
+
743
+ body_lower = body.lower()
744
+ if any(w in body_lower for w in actioning) and not any(w in body_lower for w in qualifying):
745
+ print_success("Bot correctly switched to ACTION mode")
746
+ elif any(w in body_lower for w in qualifying):
747
+ print_fail("Bot is STILL QUALIFYING after commitment")
748
+ else:
749
+ print_warn("Response unclear")
750
+
751
+ return True
752
+
753
+ def _hostile(self) -> bool:
754
+ print_section("HOSTILE HANDLING")
755
+
756
+ data, err, _ = self.client.healthz()
757
+ if err:
758
+ print_fail(f"Bot unreachable: {err}")
759
+ return False
760
+
761
+ mid = list(self.dataset.merchants.keys())[0] if self.dataset.merchants else "m_test"
762
+ hostile = "Stop messaging me. This is useless spam."
763
+
764
+ print_info(f"Merchant (hostile): \"{hostile}\"")
765
+ data, err, _ = self.client.reply("conv_hostile", mid, hostile, 2)
766
+
767
+ if err:
768
+ print_fail(f"Error: {err}")
769
+ return False
770
+
771
+ action = data.get("action", "?")
772
+ body = data.get("body", "")
773
+
774
+ print_info(f"Bot action: {action}")
775
+
776
+ if action == "end":
777
+ print_success("Bot correctly ENDED on hostile message")
778
+ elif action == "send" and any(w in body.lower() for w in ["sorry", "apolog", "won't"]):
779
+ print_success("Bot apologized gracefully")
780
+ else:
781
+ print_fail("Bot didn't handle hostility well")
782
+
783
+ return True
784
+
785
+ def _all(self) -> bool:
786
+ results = []
787
+ for name, fn in [("warmup", self._warmup), ("auto_reply", self._auto_reply),
788
+ ("intent", self._intent), ("hostile", self._hostile)]:
789
+ try:
790
+ results.append((name, fn()))
791
+ except Exception as e:
792
+ print_fail(f"{name} crashed: {e}")
793
+ results.append((name, False))
794
+
795
+ print_section("SCENARIO RESULTS")
796
+ for name, passed in results:
797
+ (print_success if passed else print_fail)(name)
798
+
799
+ return all(p for _, p in results)
800
+
801
+ def _full(self) -> bool:
802
+ if not self._warmup():
803
+ return False
804
+
805
+ print_section("FULL EVALUATION")
806
+
807
+ for mid, m in self.dataset.merchants.items():
808
+ self.client.push_context("merchant", mid, 1, m)
809
+ for tid, t in self.dataset.triggers.items():
810
+ self.client.push_context("trigger", tid, 1, t)
811
+
812
+ print_success("All contexts pushed")
813
+
814
+ print_section("SCORING COMPOSITIONS")
815
+ tids = list(self.dataset.triggers.keys())
816
+
817
+ for i in range(0, len(tids), 5):
818
+ batch = tids[i:i+5]
819
+ data, err, lat = self.client.tick(batch)
820
+
821
+ if err:
822
+ print_warn(f"Tick failed: {err}")
823
+ continue
824
+
825
+ actions = data.get("actions", [])
826
+ print_info(f"Batch {i//5 + 1}: {len(actions)} actions ({lat:.0f}ms)")
827
+
828
+ for action in actions:
829
+ self._score_and_display(action, verbose=False)
830
+
831
+ return True
832
+
833
+ def _score_and_display(self, action: Dict, verbose: bool = True):
834
+ """Score an action and display results."""
835
+ tid = action.get("trigger_id", "")
836
+ mid = action.get("merchant_id", "")
837
+ cid = action.get("customer_id")
838
+
839
+ trigger = self.dataset.triggers.get(tid, {})
840
+ merchant = self.dataset.merchants.get(mid, {})
841
+ customer = self.dataset.customers.get(cid) if cid else None
842
+ category = self.dataset.categories.get(merchant.get("category_slug", ""), {})
843
+
844
+ score = self.scorer.score(action, category, merchant, trigger, customer)
845
+ self.all_scores.append(score)
846
+
847
+ body = action.get("body", "")[:50]
848
+ print(f"\n{Colors.CYAN}Message:{Colors.RESET} \"{body}...\"")
849
+
850
+ print_score_bar("Specificity", score.specificity)
851
+ if verbose and score.specificity_reason:
852
+ print_reason(score.specificity_reason)
853
+
854
+ print_score_bar("Category Fit", score.category_fit)
855
+ if verbose and score.category_fit_reason:
856
+ print_reason(score.category_fit_reason)
857
+
858
+ print_score_bar("Merchant Fit", score.merchant_fit)
859
+ if verbose and score.merchant_fit_reason:
860
+ print_reason(score.merchant_fit_reason)
861
+
862
+ print_score_bar("Decision Quality", score.decision_quality)
863
+ if verbose and score.decision_quality_reason:
864
+ print_reason(score.decision_quality_reason)
865
+
866
+ print_score_bar("Engagement", score.engagement_compulsion)
867
+ if verbose and score.engagement_reason:
868
+ print_reason(score.engagement_reason)
869
+
870
+ if score.penalties:
871
+ print(f" {Colors.RED}Penalties: -{score.penalties}{Colors.RESET}")
872
+ for r in score.penalty_reasons:
873
+ print_reason(r)
874
+
875
+ print(f"\n {Colors.BOLD}TOTAL: {score.total}/50{Colors.RESET}")
876
+
877
+ if verbose and score.hint:
878
+ print_hint(score.hint)
879
+
880
+ def _final_summary(self):
881
+ if not self.all_scores:
882
+ return
883
+
884
+ print_section("FINAL SUMMARY")
885
+
886
+ n = len(self.all_scores)
887
+ avg = ScoreResult(
888
+ specificity=sum(s.specificity for s in self.all_scores) // n,
889
+ category_fit=sum(s.category_fit for s in self.all_scores) // n,
890
+ merchant_fit=sum(s.merchant_fit for s in self.all_scores) // n,
891
+ decision_quality=sum(s.decision_quality for s in self.all_scores) // n,
892
+ engagement_compulsion=sum(s.engagement_compulsion for s in self.all_scores) // n,
893
+ penalties=sum(s.penalties for s in self.all_scores)
894
+ )
895
+
896
+ print_info(f"Messages scored: {n}\n")
897
+
898
+ print_score_bar("Avg Specificity", avg.specificity)
899
+ print_score_bar("Avg Category Fit", avg.category_fit)
900
+ print_score_bar("Avg Merchant Fit", avg.merchant_fit)
901
+ print_score_bar("Avg Decision Quality", avg.decision_quality)
902
+ print_score_bar("Avg Engagement", avg.engagement_compulsion)
903
+
904
+ total = avg.total
905
+ pct = (total / 50) * 100
906
+
907
+ print(f"\n{Colors.BOLD} AVERAGE SCORE: {total}/50 ({pct:.0f}%){Colors.RESET}")
908
+
909
+ if pct >= 80:
910
+ print(f"\n {Colors.GREEN}EXCELLENT{Colors.RESET}")
911
+ elif pct >= 60:
912
+ print(f"\n {Colors.YELLOW}GOOD{Colors.RESET}")
913
+ elif pct >= 40:
914
+ print(f"\n {Colors.YELLOW}NEEDS IMPROVEMENT{Colors.RESET}")
915
+ else:
916
+ print(f"\n {Colors.RED}BELOW EXPECTATIONS{Colors.RESET}")
917
+
918
+ # =============================================================================
919
+ # ENTRY POINT
920
+ # =============================================================================
921
+
922
+ def main():
923
+ print_header("magicpin AI Challenge — LLM Judge")
924
+
925
+ # Validate configuration
926
+ if LLM_PROVIDER != "ollama" and not LLM_API_KEY:
927
+ print_fail("LLM_API_KEY is not set!")
928
+ print_info("Edit the CONFIGURATION section at the top of this file")
929
+ print_info("Set your API key for your chosen provider")
930
+ sys.exit(1)
931
+
932
+ # Create LLM provider
933
+ try:
934
+ llm = create_provider()
935
+ print_info(f"LLM Provider: {llm.name()}")
936
+ except Exception as e:
937
+ print_fail(f"Failed to create LLM provider: {e}")
938
+ sys.exit(1)
939
+
940
+ # Test LLM connection
941
+ print_info("Testing LLM connection...")
942
+ try:
943
+ test_response = llm.complete("Say 'ready' if you can hear me.", "You are a test assistant.")
944
+ if test_response:
945
+ print_success("LLM connected successfully")
946
+ else:
947
+ print_fail("LLM returned empty response")
948
+ sys.exit(1)
949
+ except Exception as e:
950
+ print_fail(f"LLM connection failed: {e}")
951
+ print_info("Check your API key and internet connection")
952
+ sys.exit(1)
953
+
954
+ # Run the judge
955
+ judge = JudgeSimulator(llm)
956
+ success = judge.run(TEST_SCENARIO)
957
+
958
+ sys.exit(0 if success else 1)
959
+
960
+
961
+ if __name__ == "__main__":
962
+ main()
render.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: vera-free-bot
4
+ runtime: python
5
+ plan: free
6
+ buildCommand: pip install -r requirements.txt
7
+ startCommand: uvicorn app.main:app --host 0.0.0.0 --port $PORT
8
+ healthCheckPath: /v1/healthz
9
+ envVars:
10
+ - key: PYTHON_VERSION
11
+ value: 3.11.8
12
+
requirements-dev.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ -r requirements.txt
2
+ pytest>=8.3.4,<10
3
+ httpx>=0.28.1,<1
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fastapi>=0.115.6,<1
2
+ uvicorn[standard]>=0.34.0,<1
3
+ pydantic>=2.11.10,<=2.12.4
scripts/generate_submission.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(ROOT))
9
+
10
+ from app.composer import compose # noqa: E402
11
+ from dataset.generate_dataset import ( # noqa: E402
12
+ SEED,
13
+ expand_customers,
14
+ expand_merchants,
15
+ expand_triggers,
16
+ load_seeds,
17
+ random,
18
+ write_outputs,
19
+ write_test_pairs,
20
+ )
21
+
22
+
23
+ def ensure_expanded() -> Path:
24
+ out_dir = ROOT / "expanded"
25
+ if (out_dir / "test_pairs.json").exists():
26
+ return out_dir
27
+ rnd = random.Random(SEED)
28
+ categories, merchant_seeds, customer_seeds, trigger_seeds = load_seeds(ROOT / "dataset")
29
+ merchants = expand_merchants(merchant_seeds, rnd)
30
+ customers = expand_customers(customer_seeds, merchants, rnd)
31
+ triggers = expand_triggers(trigger_seeds, merchants, customers, rnd)
32
+ write_outputs(out_dir, categories, merchants, customers, triggers)
33
+ write_test_pairs(out_dir, triggers, rnd)
34
+ return out_dir
35
+
36
+
37
+ def load_json(path: Path) -> dict:
38
+ return json.loads(path.read_text(encoding="utf-8"))
39
+
40
+
41
+ def main() -> None:
42
+ out_dir = ensure_expanded()
43
+ pairs = load_json(out_dir / "test_pairs.json")["pairs"]
44
+ submission_path = ROOT / "submission.jsonl"
45
+ with submission_path.open("w", encoding="utf-8") as fp:
46
+ for pair in pairs:
47
+ merchant = load_json(out_dir / "merchants" / f"{pair['merchant_id']}.json")
48
+ category = load_json(out_dir / "categories" / f"{merchant['category_slug']}.json")
49
+ trigger = load_json(out_dir / "triggers" / f"{pair['trigger_id']}.json")
50
+ customer = None
51
+ if pair.get("customer_id"):
52
+ customer = load_json(out_dir / "customers" / f"{pair['customer_id']}.json")
53
+ result = compose(category, merchant, trigger, customer)
54
+ result["test_id"] = pair["test_id"]
55
+ fp.write(json.dumps(result, ensure_ascii=False) + "\n")
56
+ print(f"Wrote {submission_path}")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
61
+
scripts/geval_calibrate.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+ from urllib import request as urlrequest
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from scripts.generate_submission import main as generate_submission # noqa: E402
14
+ from scripts.reference_bank import DIMENSIONS, reference_for # noqa: E402
15
+
16
+
17
+ def main() -> None:
18
+ api_key = os.getenv("OPENROUTER_API_KEY")
19
+ if not api_key:
20
+ print("OpenRouter calibration skipped: OPENROUTER_API_KEY is not set.")
21
+ return
22
+ model = os.getenv("OPENROUTER_MODEL", "openrouter/auto")
23
+ submission = ROOT / "submission.jsonl"
24
+ if not submission.exists():
25
+ generate_submission()
26
+ rows = [json.loads(line) for line in submission.read_text(encoding="utf-8").splitlines() if line.strip()]
27
+ limit = int(os.getenv("GEVAL_LIMIT", "10"))
28
+ failures: list[dict] = []
29
+ for row in rows[:limit]:
30
+ try:
31
+ critique = score_row(api_key, model, row)
32
+ except Exception as exc:
33
+ print(f"OpenRouter calibration unavailable for {row.get('test_id')}: {exc}")
34
+ raise SystemExit(2)
35
+ scores = critique.get("scores", {})
36
+ low = {k: v for k, v in scores.items() if int(v) < 7}
37
+ if low:
38
+ failures.append({"test_id": row.get("test_id"), "low": low, "critique": critique.get("critique", "")})
39
+ print(json.dumps({"test_id": row.get("test_id"), "scores": scores, "low": low}, ensure_ascii=False))
40
+ if failures:
41
+ print("\nLow-scoring calibration cases:")
42
+ for item in failures:
43
+ print(json.dumps(item, ensure_ascii=True))
44
+ raise SystemExit(1)
45
+ print(f"OpenRouter G-Eval calibration passed for {min(limit, len(rows))} rows.")
46
+
47
+
48
+ def score_row(api_key: str, model: str, row: dict) -> dict:
49
+ category = (row.get("decision_plan", {}).get("car_summary", {}) or {}).get("category", "restaurants")
50
+ prompt = {
51
+ "task": "Act as a Prometheus-style magicpin Vera judge. Evaluate one candidate message against reference ideals.",
52
+ "rubric": DIMENSIONS,
53
+ "reference_ideals": {dimension: reference_for(category, dimension) for dimension in DIMENSIONS},
54
+ "rules": [
55
+ "Compare the candidate to the reference ideal for each dimension.",
56
+ "Score each dimension as an integer 0-10.",
57
+ "Penalize hallucinated facts, generic copy, weak CTA, bad category fit, and poor merchant fit.",
58
+ "Return JSON only with keys: scores, critique, deterministic_fix.",
59
+ ],
60
+ "candidate": {
61
+ "body": row.get("body"),
62
+ "cta": row.get("cta"),
63
+ "send_as": row.get("send_as"),
64
+ "rationale": row.get("rationale"),
65
+ "decision_plan": row.get("decision_plan", {}),
66
+ },
67
+ }
68
+ payload_obj = {
69
+ "model": model,
70
+ "messages": [
71
+ {"role": "system", "content": "You are a strict rubric evaluator. Return compact JSON only."},
72
+ {"role": "user", "content": json.dumps(prompt, ensure_ascii=False)},
73
+ ],
74
+ "temperature": 0.0,
75
+ "max_tokens": int(os.getenv("GEVAL_MAX_TOKENS", "1800")),
76
+ }
77
+ if os.getenv("GEVAL_RESPONSE_FORMAT", "0") == "1":
78
+ payload_obj["response_format"] = {"type": "json_object"}
79
+ payload = json.dumps(payload_obj).encode("utf-8")
80
+ req = urlrequest.Request(
81
+ "https://openrouter.ai/api/v1/chat/completions",
82
+ data=payload,
83
+ headers={
84
+ "Authorization": f"Bearer {api_key}",
85
+ "Content-Type": "application/json",
86
+ "HTTP-Referer": os.getenv("OPENROUTER_SITE_URL", "http://localhost"),
87
+ "X-Title": os.getenv("OPENROUTER_APP_NAME", "Vera Calibration"),
88
+ },
89
+ method="POST",
90
+ )
91
+ with urlrequest.urlopen(req, timeout=30) as resp:
92
+ data = json.loads(resp.read().decode("utf-8"))
93
+ choice = data.get("choices", [{}])[0]
94
+ message = choice.get("message", {}) or {}
95
+ content = message.get("content")
96
+ if isinstance(content, list):
97
+ content = "".join(str(part.get("text", "")) if isinstance(part, dict) else str(part) for part in content)
98
+ if not content:
99
+ raise RuntimeError(f"model returned no message content; finish_reason={choice.get('finish_reason')!r}")
100
+ try:
101
+ return json.loads(content)
102
+ except json.JSONDecodeError:
103
+ start = content.find("{")
104
+ end = content.rfind("}")
105
+ if start >= 0 and end > start:
106
+ return json.loads(content[start : end + 1])
107
+ raise
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
scripts/keep_warm.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import time
6
+ from urllib import request
7
+
8
+
9
+ def ping(base_url: str) -> None:
10
+ url = base_url.rstrip("/") + "/v1/healthz"
11
+ with request.urlopen(url, timeout=10) as response:
12
+ print(json.loads(response.read().decode("utf-8")))
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(description="Free-host keep-warm pinger for Vera bot.")
17
+ parser.add_argument("base_url", help="Public base URL, e.g. https://your-bot.koyeb.app")
18
+ parser.add_argument("--interval", type=int, default=900, help="Seconds between pings. Default: 15 minutes.")
19
+ args = parser.parse_args()
20
+
21
+ while True:
22
+ try:
23
+ ping(args.base_url)
24
+ except Exception as exc:
25
+ print(f"ping failed: {exc}")
26
+ time.sleep(args.interval)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
scripts/lint_submission.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import sys
6
+ from collections import Counter
7
+ from pathlib import Path
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ DEFAULT_PATH = ROOT / "submission.jsonl"
12
+ REQUIRED = {"test_id", "body", "cta", "send_as", "suppression_key", "rationale"}
13
+ BAD_PATTERNS = [
14
+ ("literal None", re.compile(r"\bNone\b")),
15
+ ("double Dr", re.compile(r"\bDr\.\s+Dr\.", re.IGNORECASE)),
16
+ ("internal suppression copy", re.compile(r"I will not send|no matching opt-in", re.IGNORECASE)),
17
+ ("fake zero percent movement", re.compile(r"\b(up|down|dropped|rose|increased)\s+0%\b", re.IGNORECASE)),
18
+ ("baseline normal placeholder", re.compile(r"\bbaseline normal\b", re.IGNORECASE)),
19
+ ("missing-data placeholder", re.compile(r"the available context", re.IGNORECASE)),
20
+ ("generic growth copy", re.compile(r"increase sales|boost sales|grow your business", re.IGNORECASE)),
21
+ ]
22
+
23
+
24
+ def lint(path: Path) -> list[str]:
25
+ errors: list[str] = []
26
+ if not path.exists():
27
+ return [f"{path} does not exist"]
28
+
29
+ seen_bodies: Counter[str] = Counter()
30
+ for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
31
+ try:
32
+ item = json.loads(line)
33
+ except json.JSONDecodeError as exc:
34
+ errors.append(f"line {line_no}: invalid JSON: {exc}")
35
+ continue
36
+
37
+ missing = REQUIRED - set(item)
38
+ if missing:
39
+ errors.append(f"line {line_no}: missing keys {sorted(missing)}")
40
+ continue
41
+
42
+ body = str(item.get("body", "")).strip()
43
+ rationale = str(item.get("rationale", "")).strip()
44
+ cta = str(item.get("cta", "")).strip()
45
+ send_as = str(item.get("send_as", "")).strip()
46
+ suppression_key = str(item.get("suppression_key", "")).strip()
47
+ if not body:
48
+ errors.append(f"line {line_no}: empty body")
49
+ if not rationale:
50
+ errors.append(f"line {line_no}: empty rationale")
51
+ if not cta:
52
+ errors.append(f"line {line_no}: empty cta")
53
+ if send_as not in {"vera", "merchant_on_behalf"}:
54
+ errors.append(f"line {line_no}: invalid send_as {send_as!r}")
55
+ if not suppression_key:
56
+ errors.append(f"line {line_no}: empty suppression_key")
57
+ for label, pattern in BAD_PATTERNS:
58
+ if pattern.search(body):
59
+ errors.append(f"line {line_no}: {label} in body")
60
+ decision_plan = item.get("decision_plan", {}) or {}
61
+ if decision_plan.get("constitutional_violations"):
62
+ errors.append(f"line {line_no}: constitutional violations {decision_plan['constitutional_violations']}")
63
+ seen_bodies[body] += 1
64
+
65
+ for body, count in seen_bodies.items():
66
+ if body and count > 1:
67
+ errors.append(f"duplicate body appears {count} times: {body[:120]}")
68
+
69
+ return errors
70
+
71
+
72
+ def main() -> int:
73
+ path = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else DEFAULT_PATH
74
+ errors = lint(path)
75
+ if errors:
76
+ print("submission lint failed:")
77
+ for err in errors:
78
+ print(f"- {err}")
79
+ return 1
80
+ print(f"submission lint passed: {path}")
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
scripts/reference_bank.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ DIMENSIONS = [
5
+ "decision_quality",
6
+ "specificity",
7
+ "category_fit",
8
+ "merchant_fit",
9
+ "engagement_compulsion",
10
+ ]
11
+
12
+
13
+ REFERENCE_BANK: dict[str, str] = {
14
+ "dentists:decision_quality": "Dr. Meera, the research digest points to high-risk adult recall timing; use the 3-month fluoride finding before the next recall batch.",
15
+ "dentists:specificity": "Dr. Meera, JIDA Oct 2026 cites 124 high-risk adult caries patients for 3-month fluoride recall; want me to draft that recall note?",
16
+ "dentists:category_fit": "Clinical angle: use the cited recall evidence, avoid overclaiming, and ask for one approval before any patient outreach.",
17
+ "dentists:merchant_fit": "Dr. Meera, your Lajpat Nagar clinic has an active Rs 299 cleaning hook; pair it with the recall cohort instead of a generic discount.",
18
+ "dentists:engagement_compulsion": "Reply YES and I will draft the exact patient recall WhatsApp from the JIDA item and your Rs 299 cleaning offer.",
19
+ "salons:decision_quality": "Use the customer's occasion or slot window first, then offer a concrete package follow-up instead of a generic promo.",
20
+ "salons:specificity": "Kavya's wedding is 42 days away and Saturday slots are preferred; ask to hold one skin-prep slot with the active bridal package.",
21
+ "salons:category_fit": "Warm service angle: visual, occasion-led, slot-aware, and never clinical or pressure-heavy.",
22
+ "salons:merchant_fit": "Studio11 has bridal follow-up history and a live service package; make Vera prepare the exact WhatsApp and slot hold.",
23
+ "salons:engagement_compulsion": "Reply YES and I will hold the Saturday slot plus draft the 4-line bridal follow-up now.",
24
+ "restaurants:decision_quality": "Tie the action to the operator signal: orders, covers, delivery delay, IPL timing, or corporate lunch planning.",
25
+ "restaurants:specificity": "Delhi IPL night is today and the paneer thali offer is live; draft one banner/menu note before evening orders start.",
26
+ "restaurants:category_fit": "Operator angle: concise, service-window aware, focused on orders, delivery, covers, and menu hooks.",
27
+ "restaurants:merchant_fit": "Use this restaurant's live offer, locality, recent calls/orders, and review theme before suggesting a campaign.",
28
+ "restaurants:engagement_compulsion": "Reply YES and I will prepare the exact banner and WhatsApp text for tonight's order window.",
29
+ "gyms:decision_quality": "Pick the retention or trial action that matches the member stage and recent attendance trend.",
30
+ "gyms:specificity": "Rashmi is 28 days lapsed after yoga trial; use the no-commitment restart slot and her preferred evening timing.",
31
+ "gyms:category_fit": "Coach-to-operator tone: no shame, no body-negative copy, clear class or challenge next step.",
32
+ "gyms:merchant_fit": "Use the gym's current class, member count, lapse segment, and previous training focus in the message.",
33
+ "gyms:engagement_compulsion": "Reply YES and I will hold one no-commitment restart slot and send the member-safe message.",
34
+ "pharmacies:decision_quality": "For stock, refill, or compliance triggers, choose the safest precise action before any promotional angle.",
35
+ "pharmacies:specificity": "Atorvastatin batch A12 is affected and 38 chronic-Rx customers are in context; draft the replacement note and counter checklist.",
36
+ "pharmacies:category_fit": "Calm precision: molecule, batch, refill date, delivery status, and no diagnosis or panic wording.",
37
+ "pharmacies:merchant_fit": "Use the pharmacy's repeat-customer count, saved delivery status, active free-delivery hook, and stock facts.",
38
+ "pharmacies:engagement_compulsion": "Reply CONFIRM and I will prepare the customer note plus counter checklist from the exact batch/refill facts.",
39
+ }
40
+
41
+
42
+ def reference_for(category: str, dimension: str) -> str:
43
+ return REFERENCE_BANK.get(f"{category}:{dimension}") or REFERENCE_BANK.get(f"restaurants:{dimension}", "")
scripts/score_proxy.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(ROOT))
9
+
10
+ from app.decision_engine import build_candidates, extract_evidence # noqa: E402
11
+
12
+
13
+ def load(path: Path) -> dict:
14
+ return json.loads(path.read_text(encoding="utf-8"))
15
+
16
+
17
+ def ensure_expanded() -> Path:
18
+ out = ROOT / "expanded"
19
+ if not (out / "test_pairs.json").exists():
20
+ import subprocess
21
+ subprocess.run([sys.executable, "dataset/generate_dataset.py", "--seed-dir", "dataset", "--out", "expanded"], cwd=ROOT, check=True)
22
+ return out
23
+
24
+
25
+ def score_pair(out: Path, pair: dict) -> tuple[int, dict]:
26
+ merchant = load(out / "merchants" / f"{pair['merchant_id']}.json")
27
+ category = load(out / "categories" / f"{merchant['category_slug']}.json")
28
+ trigger = load(out / "triggers" / f"{pair['trigger_id']}.json")
29
+ customer = load(out / "customers" / f"{pair['customer_id']}.json") if pair.get("customer_id") else None
30
+ evidence = extract_evidence(category, merchant, trigger, customer)
31
+ candidates = build_candidates(category, merchant, trigger, customer, evidence)
32
+ if not candidates:
33
+ return 0, {"reason": "no candidates"}
34
+ best = max(candidates, key=lambda c: c.total_score)
35
+ return best.total_score, {
36
+ "scores": best.rubric_scores,
37
+ "signal": best.primary_signal,
38
+ "lever": best.selected_lever,
39
+ "body": best.body,
40
+ }
41
+
42
+
43
+ def main() -> int:
44
+ threshold = int(sys.argv[1]) if len(sys.argv) > 1 else 36
45
+ out = ensure_expanded()
46
+ pairs = load(out / "test_pairs.json")["pairs"]
47
+ failures = []
48
+ totals = []
49
+ for pair in pairs:
50
+ total, detail = score_pair(out, pair)
51
+ totals.append(total)
52
+ if total < threshold:
53
+ failures.append((pair["test_id"], total, detail))
54
+ avg = sum(totals) / max(1, len(totals))
55
+ print(f"proxy scored {len(totals)} canonical pairs; avg={avg:.1f}/50 min={min(totals) if totals else 0}/50 threshold={threshold}")
56
+ if failures:
57
+ print("proxy score failures:")
58
+ for test_id, total, detail in failures:
59
+ print(f"- {test_id}: {total}/50 {detail}")
60
+ return 1
61
+ return 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
66
+
submission.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
tests/test_bot.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, constitutional_violations, extract_evidence, 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_teardown_clears_state():
415
+ reset_state()
416
+ client = TestClient(app)
417
+ push(client, "category", "dentists", load_category("dentists"))
418
+ assert contexts
419
+ resp = client.post("/v1/teardown")
420
+ assert resp.json()["cleared"] is True
421
+ assert not contexts