File size: 18,456 Bytes
fdeeb34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# Vera Engagement Framework β€” Design

**Status**: Draft proposal β€” not implemented.
**Last updated**: 2026-04-26
**Scope**: How Vera composes every outbound conversation, both merchant-facing and (on-behalf-of-merchant) customer-facing.

---

## Why this exists

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.

Two problems this creates:

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.

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.

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).

---

## The 4 contexts

Every Vera message = `compose(category, merchant, trigger, customer?)`.

| Context | Question it answers | Refresh cadence | Owner | Shared across |
|---|---|---|---|---|
| **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 |
| **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 |
| **Trigger** | Why am I messaging *right now*? What specific event prompts this? | Per-event | Event detectors | This one send |
| **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 |

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.

```
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   Category   ───►│                 β”‚
   Merchant   ───►│  Composer (LLM) │───► message {body, params, cta, send_as}
   Trigger    ───►│                 β”‚
   Customer?  ───►│                 β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

---

## Layer details

### CategoryContext

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.

Fields:

- `slug` β€” `"dentists"`
- `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.
- `voice` β€” tone, vocabulary, taboos. For dentists: technical terms welcome (`"fluoride varnish at 3-month recall"`), legal taboos (`"cure"`, `"guaranteed"`), peer tone not hype.
- `peer_stats` β€” city-scoped benchmarks: avg rating, avg reviews, typical CTR, typical patient volume. Anchors comparative messages.
- `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).
- `patient_content_library` β€” items written at patient-reading level that the merchant can reshare with their own customers (powers the `PRO_PATIENT_CONTENT` family).
- `seasonal_beats` β€” cycles like "exam-stress bruxism spikes Nov-Feb" that cue category-specific timing.
- `trend_signals` β€” Google Trends + Practo-style query data showing what patients in this vertical are searching for.

### MerchantContext

Per-merchant state. Refreshed daily for performance; real-time for conversation history.

Fields:

- `merchant_id`
- `identity` β€” name, place_id, locality, city, verified, languages
- `subscription` β€” status, days remaining, plan
- `performance` β€” views/calls/CTR/leads/directions, 30d + 7d deltas
- `offers` β€” active + paused, sourced from vera-mcp's offers collection (and eventually the as-yet-undefined "real" offer source-of-truth)
- `conversation_history` β€” last N turns w/ Vera, with engagement tags (replied, ignored, unsubscribed-from-topic)
- `customer_aggregate` β€” derived stats over the merchant's customer roster (active count, lapsed count, retention rate). Not individual customers β€” aggregates only.
- `signals` β€” derived flags: `stale_posts`, `ctr_below_peer_median`, `customer_lapse_rate_high`, `dormant_with_vera`, ...

### TriggerContext

The event that prompts this specific message. Two families:

- **External** β€” happens outside the merchant's account. News, weather, festival, regulation change, category-trend movement, competitor opens nearby, weekly research digest release.
- **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.

Fields:

- `id` β€” unique
- `scope` β€” `merchant` | `customer`
- `kind` β€” `research_digest`, `recall_due`, `perf_spike`, `competitor_opened`, `festival`, ...
- `source` β€” `external` | `internal`
- `payload` β€” kind-specific data (e.g., for `recall_due`: `{patient_id, last_visit, due_date}`)
- `urgency` β€” 1-5; ranks against other queued triggers
- `suppression_key` β€” used by Redis dedup to prevent re-sends
- `expires_at` β€” after which the trigger is stale

### CustomerContext

Only populated when `scope=customer`. Per-customer state with this specific merchant.

Fields:

- `customer_id`
- `merchant_id`
- `identity` β€” name, phone, language preference
- `relationship` β€” first_visit, last_visit, visits_total, services received, lifetime value
- `state` β€” `new` | `active` | `lapsed_soft` (3-6mo) | `lapsed_hard` (6mo+) | `churned` (12mo+)
- `preferences` β€” preferred slot times (derived from booking history), preferred channel, opt-in status
- `consent` β€” when did they opt in, via what mechanism, scope of consent

---

## Composer

Single LLM-prompted module. Takes the 4 contexts as input. Produces:

- `body` β€” the WhatsApp message body
- `template_params` β€” params to fill an approved Kaleyra template (used only for the first touch in a session window)
- `cta` β€” the binary or open-ended ask
- `suppression_key` β€” for the trigger-level dedup
- `send_as` β€” `"vera"` for merchant-facing, `"merchant_on_behalf"` for customer-facing

The composer prompt is the single point of failure. It must be:

- Versioned (`composer_v1`, `composer_v2`, ...)
- A/B-testable
- Auditable (we can replay any past message and see all 4 input contexts)

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`.

---

## Worked example 1: merchant-facing

**Merchant**: Dr. Meera, Lajpat Nagar, Delhi
**Trigger**: weekly dentistry research digest just landed

**Inputs:**

| Context | Key values used |
|---|---|
| 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%" |
| Merchant (Dr. Meera) | CTR 2.1% (below peer); ran "Deep Cleaning β‚Ή499" 2mo ago; 78 lapsed patients; last Vera touch 2d ago (engaged) |
| Trigger | kind=`research_digest_release`, scope=merchant, urgency=2, source=external, suppression_key=`research:dentists:2026-W17` |
| Customer | (not populated) |

**Composed message:**

> 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*

Why it works:
- **Category** drives voice (technical, source-cited, peer tone)
- **Merchant** drives specificity ("your high-risk adult patients" β€” derived from her customer aggregate)
- **Trigger** drives the hook (this week's digest, not a promo ask)
- No customer context needed; this is merchant-to-Vera

---

## Worked example 2: customer-facing (same framework)

**Merchant**: Dr. Meera (same)
**Customer**: Priya β€” patient since 2025-11, last visit 2026-05 (cleaning + whitening), prefers weekday evenings, opted-in to reminders
**Trigger**: 6-month recall window opens

**Inputs:**

| Context | Key values used |
|---|---|
| Category (dentists, customer-facing) | voice=warm-clinical; taboos=no medical claims, no "guaranteed"; recall framing pattern |
| Merchant (Dr. Meera) | active offer `Dental Cleaning @ β‚Ή299`; available slots Wed 6pm + Thu 5pm (next 7d); WhatsApp Business number |
| Trigger | kind=`recall_due`, scope=customer, urgency=3, source=internal, payload={patient_id: priya, last_visit: 2026-05, due_date: 2026-11} |
| Customer (Priya) | name + phone; lapsed_soft state; preferred=weekday evening; consent active; language=Hindi-English mix |

**Composed message** (sent from Dr. Meera's WhatsApp number, drafted by Vera):

> 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.

Why it works:
- **Category** sets the legal/clinical voice constraints
- **Merchant** provides the actual catalog price + actual open slots from the schedule
- **Trigger** provides the recall payload (last_visit, due_date)
- **Customer** drives personalization (name, language mix, evening preference)

Same composer. Different context inputs. Two completely different conversations.

---

## Engagement loops this enables

Once the framework exists, every loop is just a small cron that emits `TriggerContext` instances. The composer handles the rest.

| Loop | Emits trigger kinds | Scope |
|---|---|---|
| News/weather scanner *(already built β€” see `agents/vera/followup/event_sources.py`)* | `external` (festival, heatwave, fuel, IPL, monsoon, news) | merchant |
| Weekly research digest per category | `research_digest_release` | merchant |
| Performance monitor | `perf_spike`, `perf_dip`, `milestone_reached` | merchant |
| Review-pattern detector | `review_theme_emerged` | merchant |
| Conversation curiosity-ask scheduler | `curious_ask_due` | merchant |
| Recall scheduler (from merchant CRM) | `recall_due` | customer |
| Lapse detector | `customer_lapsed_soft`, `customer_lapsed_hard` | customer |
| Appointment reminder | `appointment_tomorrow` | customer |
| Capacity optimizer | `unplanned_slot_open` (offered to likely-to-book lapsed customers) | customer |

Adding a loop = define one new `kind`, implement the detector, add a composer prompt variant. No change to merchant/category/customer code.

---

## Implementation shape

```python
# agents/vera/engagement/contexts.py

@dataclass
class CategoryContext:
    slug: str                                # "dentists"
    offer_catalog: list[OfferTemplate]
    voice: VoiceProfile
    peer_stats: PeerStats
    digest: list[DigestItem]
    patient_content_library: list[ContentItem]
    seasonal_beats: list[SeasonalBeat]
    trend_signals: list[TrendSignal]

@dataclass
class MerchantContext:
    merchant_id: str
    identity: Identity
    subscription: Subscription
    performance: PerformanceSnapshot
    offers: list[MerchantOffer]
    conversation_history: ConversationHistory
    customer_aggregate: CustomerAggregate
    signals: list[DerivedSignal]

@dataclass
class TriggerContext:
    id: str
    scope: Literal["merchant", "customer"]
    kind: str
    source: Literal["external", "internal"]
    payload: dict
    urgency: int                              # 1-5
    suppression_key: str
    expires_at: datetime

@dataclass
class CustomerContext:
    customer_id: str
    merchant_id: str
    identity: CustomerIdentity
    relationship: Relationship
    state: Literal["new", "active", "lapsed_soft", "lapsed_hard", "churned"]
    preferences: Preferences
    consent: Consent
```

```python
# agents/vera/engagement/composer.py

class EngagementComposer:
    def compose(self,
                category: CategoryContext,
                merchant: MerchantContext,
                trigger: TriggerContext,
                customer: CustomerContext | None = None) -> ComposedMessage:
        """Returns ComposedMessage(body, template_params, cta,
        suppression_key, send_as)."""
```

Both engagement surfaces (merchant-facing, customer-on-behalf-of-merchant) call the same composer. The only thing that changes is whether `customer` is populated.

---

## Phased rollout

### Phase 1 β€” framework skeleton + dentistry vertical (β‰ˆ 2 weeks)

1. Define the 4 dataclasses in `agents/vera/engagement/contexts.py`.
2. Build the `CategoryContext` for dentistry β€” offer catalog, voice profile, peer stats, one weekly research digest pipeline, patient-content seed.
3. Build `MerchantContext` loader from the existing `merchant_snapshot_data` collection.
4. Build the first `EngagementComposer` with a prompt that handles 2 trigger kinds (`research_digest_release` and one merchant-facing perf trigger).
5. Render (no send) the Dr. Meera research-digest message end-to-end from the 4 contexts. Inspect the output before any send happens.

### Phase 2 β€” customer-on-behalf sends (β‰ˆ 2 weeks)

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.
7. Define the consent model: customer opted in via merchant, not via Vera directly. Capture timestamp + scope.
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"`.
9. Ship the first customer-facing trigger in production: `recall_due`. Lowest abuse risk, highest merchant intent.

### Phase 3 β€” multiply verticals (β‰ˆ 1 week per vertical)

10. Replicate `CategoryContext` for 4-5 more verticals (salons, gyms, pharmacies, restaurants, car service). Mostly data filling, not code.
11. Add 3 more triggers per scope. Merchant: `perf_dip`, `milestone_reached`, `review_theme_emerged`. Customer: `customer_lapsed_soft`, `appointment_tomorrow`, `unplanned_slot_open`.

---

## Open questions

These need answers before Phase 2 can ship:

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.
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.
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).
4. **Composer prompt versioning.** Single point of failure. Versioned + A/B tested from day 1 β€” every send records the prompt version that produced it.
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.
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?

---

## Why this is worth building

- **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.
- **One framework, two products** β€” the same composition engine drives Vera-to-merchant *and* merchant-to-customer messaging. Build once, ship twice.
- **Vertical scaling is data work, not code work** β€” adding a new category becomes "fill in a CategoryContext", not "write a new agent".
- **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.

---

## Appendix: relationship to existing code

- `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.
- `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.
- `agents/vera/followup/snapshot_data.py` already provides most of the `MerchantContext` fields. Customer aggregate fields would be added as new sections on `MerchantSnapshotData`.
- `services/vera-mcp/src/services/offer_suggester.py` is the leading candidate for `CategoryContext.offer_catalog` (pending the open offer source-of-truth question).