vera-engine / engagement-design.md
Rachit-Mangawa
feat: platinum deployment suite v1.0
fdeeb34
|
Raw History Blame Contribute Delete
18.5 kB
# 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).