Skip to content

Shared Enrichment Layer — Product-User Context

Shared Enrichment Layer — Product-User Context

Section titled “Shared Enrichment Layer — Product-User Context”

Three systems independently enrich product data for overlapping purposes:

  • Consumer graph worker (this repo) — builds a Neo4j purchase graph, computes repurchase metrics, schedules notifications
  • Rover-agent (feat/PLT-360-product-card-enrichment) — enriches AI product mentions into mobile-renderable product cards
  • Rover-mcp (feat/PLT-356-fido-offer-lookup) — wraps Offer Guardian with a FIDO reverse index, exposes offer lookups to the agent

Each calls a different subset of the same upstream services, applies its own enrichment logic, and produces its own output format. The result is duplicated client code, inconsistent data, and no shared path for a product’s full context.

A shared enrichment layer would provide a single, complete product-user context that all three consumers draw from.

The table below is the union of all data points consumed across the three systems. Every row must be supported by the enrichment layer regardless of which consumer currently uses it.

Data PointUpstream SourceGraph WorkerRover-AgentRover-MCPEnrichment Layer
Product nameFPS + FIDORAFPS nameFIDORA nameBoth (FPS for generic, FIDORA for retailer-specific)
Product brandFPSFPS brandFPS
Product categoryFPSFPS categoryFPS
Product image URLFIDORAFIDORA normalizedImageUrlFIDORA
Product price (cents)FIDORAFIDORA priceCentsFIDORA
Product URLFIDORAFIDORA urlFIDORA
Retailer ID (from product)FIDORAFIDORA retailerIdFIDORA
Product availabilityFIDORAFIDORA isAvailableFIDORA
Button commissionedFIDORAFIDORA isButtonCommissionedFIDORA
Retailer nameRetailer Service / ReceiptReceipt storeNameButton merchant nameRetailer Service + FIDORA→Button chain
Retailer venue typeRetailer ServicenormalizeVenueType()Retailer Service
Merchant name + logoButton Merchant ServiceButton name, iconUrlButton (via FIDORA retailerID)
Merchant PPDButton Merchant ServiceButton awardDetailsButton
Merchant stickersButton Merchant ServiceButton stickers[]Button
All offers for FIDOOffer Guardian (via rover-mcp)OG direct (mocked)rover-mcpOG → FidoIndexrover-mcp FidoIndex
Offer pointsOffer GuardianPointsEarned onlyvia rover-mcpPointsEarnedrover-mcp
Offer stackabilityOffer GuardianNot modeledvia rover-mcpIsStackablerover-mcp
Offer expirationOffer GuardianNot modeledvia rover-mcpEndTimerover-mcp
Offer descriptionOffer GuardianNot usedvia rover-mcpOfferDescriptionrover-mcp
User eligible for offerNeliSeparate Neli callNot yet (user_id param unused)Not yetNeli (or integrated into rover-mcp)
eReceipt supporteReceipt Provider + HandbookIsSupported()eReceipt services
Purchase countNeo4j PURCHASED.timesCandidate queryNeo4j read
Avg interval daysNeo4j PURCHASED.avg_interval_daysCandidate queryNeo4j read
Last purchase dateNeo4j PURCHASED.lastCandidate queryNeo4j read
Repurchase likelihoodNeo4j PURCHASED.repurchase_likelihoodCandidate queryNeo4j read
Purchase timestampsNeo4j PURCHASED.timestampsCandidate queryNeo4j read
User timezoneNeo4j User.timezoneCandidate queryNeo4j read
Expected valueComputed: likelihood × (points - 25)calculator.goCalculated enrichment
Days since last purchaseComputed: now - lastcalculator.goCalculated enrichment
Predicted next purchaseComputed: last + avg_intervalcalculator.goCalculated enrichment
Total achievable pointsComputed: stacking rules + PPDCalculateProductPoints()Calculated enrichment
StickersComputed: offer state + merchantgenerateStickers()Calculated enrichment
CTA deep linkComputed: merchant + offer + URLbuildProductCTA()Calculated enrichment
GetProductContext(userID, productID) -> ProductUserContext

Returns the full enriched view of one product for one user:

ProductUserContext {
// Product identity (FPS + FIDORA merged)
Product: {
ProductID string
Name string // FPS name, FIDORA override if available
Brand string // FPS
Category string // FPS
}
// Retailer-specific product data (FIDORA)
RetailerProduct: {
RetailerID string
ImageURL *string
PriceCents int
ProductURL string
IsAvailable bool
IsButtonCommissioned *bool
}
// Offers (rover-mcp FidoIndex + Neli eligibility)
Offers: []OfferContext {
OfferID string
Points int
Description string
EndDate *time.Time
IsStackable bool
UserEligible bool // Neli check
}
// Merchant / retailer (Button + Retailer Service)
Merchant: {
Name string
Logo *string
VenueType string // Retailer Service normalized
PPD float64 // Points per dollar
PPDType string // "PPD", "COMMISSIONLESS_PPD", "FLAT"
Stickers []string // BMS stickers
EReceipt bool // eReceipt Provider + Handbook
}
// Purchase history (Neo4j read)
History: {
PurchaseCount int
AvgIntervalDays float64
LastPurchaseDate time.Time
RepurchaseLikelihood float64
Timestamps []time.Time
Timezone string
}
// Calculated scoring (combines graph + offers)
Scoring: {
ExpectedValue float64 // likelihood × (points - 25)
DaysSinceLastPurchase float64
PredictedNextPurchase time.Time
TotalAchievablePoints int // Stacking rules applied
}
// Presentation (computed from above)
Display: {
Stickers []string // Max 2, priority ordered
CTA *CTA // Deep link (shop/offer/browser)
}
}
GetCandidatesForUser(userID) -> []ProductUserContext
GetTopCandidates(filters) -> []ProductUserContext

GetTopCandidates wraps the existing Neo4j repurchase candidate query plus full enrichment. The scheduler, agent, and product card renderer all consume the same function — only what they do with the results differs.

All upstream service integrations that the enrichment layer must support:

ClientServiceWhat It ProvidesCurrent Location(s)
FIDO Product ServiceFPSGeneric product metadata: name, brand, category, barcodegraph-worker internal/api/fido/
FIDORAFIDO Assortment ServiceRetailer-specific product data: name, price, image, URL, retailerID, availability, Button-commissioned flagrover-agent internal/enrichment/product/fidora_client.go
Offer Guardian (via rover-mcp FidoIndex)Offer Guardian → rover-mcpAll offers for a FIDO: points, stackability, expiration, descriptionrover-mcp pkg/api/service/offer-guardian/, graph-worker internal/api/offerguardian/ (mocked)
NeliNeli eligibility servicePer-user offer eligibility: (userID, offerID) → boolgraph-worker internal/api/neli/
Button Merchant ServiceBMSMerchant data: name, logo, PPD, stickers, merchant URL. Cached retailerID→merchant mapping.rover-agent internal/enrichment/product/button_client.go
Retailer ServiceFetch Retailer ServiceVenue type classification per store namegraph-worker internal/api/retailer/
eReceipt Provider + HandbookeReceipt servicesWhether a retailer supports eReceipt scanning (direct connection or email parsing)rover-agent internal/enrichment/product/ereceipt_client.go
Neo4j (read)Consumer graphPurchase history, repurchase likelihood, timestamps, timezonegraph-worker internal/notification/repurchase/neo4j.go

All computed fields that the enrichment layer must derive:

CalculationFormulaCurrent LocationConsumers
ExpectedValuerepurchase_likelihood × (offer_points - 25)graph-worker calculator.goScheduler (ranking, notification score), Agent (prioritization)
DaysSinceLastPurchasenow - last_purchasegraph-worker calculator.goScheduler, Agent
PredictedNextPurchaselast_purchase + avg_interval_daysgraph-worker calculator.goScheduler, Agent
TotalAchievablePointsPPD×price + stackable offers (stacking rules)rover-agent points.goAgent (display), Product cards
OfferPointssum(stackable) or max(non-stackable)rover-agent offer_client.goPoints calc input
StickersPriority: expiring → stackable → multiplier → eReceipt (max 2)rover-agent stickers.goProduct cards, Agent
CTA deep linkShop > Offer > Browser priority with merchant/offer contextrover-agent cta.goProduct cards, Agent
ComponentWhy
SendTime / DeliveryWindowPush notification timing mechanics
Time-of-day minute-level analysisScheduler-specific optimization
ShouldSendToday()Scheduling decision
State tracking (snooze, opt-out, quota)Notification-specific Valkey state
Candidate selection (top N per user per run)Notification throughput management
Notification template buildingCopy formatting for push notifications
ComponentWhy
Cypher MERGE / upsert logicWrite path
Community assignmentGraph topology maintenance
ID generation (CAT_, RET_, COMM_)Internal identity scheme
Idempotency cache (Valkey)Write-path dedup
ComponentWhy
ProductCard struct formattingiOS BFF contract serialization
Component parsing / streamingLLM output processing
Product card YAML configComponent registry
┌─────────────────────────────────────────────────────┐
│ Enrichment Layer │
│ │
┌───────────┐ │ ┌──────────────────────────────────────────────┐ │
│ FPS │◄──┼──│ Product Identity │ │
├───────────┤ │ │ FPS (brand, category) + FIDORA (price, img) │ │
│ FIDORA │◄──┼──│ │ │
└───────────┘ │ └──────────────────┬───────────────────────────┘ │
│ │ │
┌───────────┐ │ ┌──────────────────▼───────────────────────────┐ │
│ rover-mcp │◄──┼──│ Offer Resolution │ │
│ FidoIndex │ │ │ rover-mcp (offers) + Neli (eligibility) │ │ ┌─────────────────┐
├───────────┤ │ │ │ │ │ │
│ Neli │◄──┼──│ │ │──►│ Scheduler │
└───────────┘ │ └──────────────────┬───────────────────────────┘ │ │ (timing, │
│ │ │ │ delivery, │
┌───────────┐ │ ┌──────────────────▼───────────────────────────┐ │ │ state mgmt) │
│ Button │◄──┼──│ Merchant / Retailer │ │ │ │
│ (BMS) │ │ │ Button (PPD, logo, stickers) │ │ └─────────────────┘
├───────────┤ │ │ Retailer Service (venue type) │ │
│ Retailer │◄──┼──│ eReceipt (scanning support) │ │ ┌─────────────────┐
│ Service │ │ │ │ │ │ │
├───────────┤ │ └──────────────────┬───────────────────────────┘ │──►│ Agent │
│ eReceipt │◄──┼── │ │ │ (reasoning, │
└───────────┘ │ ┌──────────────────▼───────────────────────────┐ │ │ conversation, │
│ │ Graph Reads │ │ │ recs + cards) │
┌───────────┐ │ │ (purchase history, likelihood, patterns) │ │ │ │
│ Neo4j │◄──┼──│ │ │ └─────────────────┘
│ (read) │ │ └──────────────────┬───────────────────────────┘ │
└───────────┘ │ │ │ ┌─────────────────┐
│ ┌──────────────────▼───────────────────────────┐ │ │ │
│ │ Calculated Enrichment │ │──►│ Product Cards │
│ │ EV, days since, predicted next, │ │ │ (rover-agent │
│ │ total points, stickers, CTA │ │ │ formatting) │
│ └──────────────────────────────────────────────┘ │ │ │
└─────────────────────────────────────────────────────┘ └─────────────────┘
┌─────────────────────────────────────────────────────┐
│ Graph Writer (unchanged) │
│ Kafka → Transform → Neo4j MERGE │
└─────────────────────────────────────────────────────┘
Input: (userID, fidoID)
Step 1: Product identity
├── FPS.GetProductInfo(fidoID) → name, brand, category
└── FIDORA.GetFidorsByFido(fidoID) → best fidor: image, price, URL, retailerID, availability
Step 2: Offer resolution
├── rover-mcp.GetOffersByFido(fidoID) → all offers: points, stackability, expiry
└── Neli.IsUserEligibleForOffer(userID, offerID) → eligible flag per offer
Step 3: Merchant / retailer
├── Button.GetMerchantByFetchRetailerID(fidor.retailerID) → name, logo, PPD, stickers
├── RetailerService.GetRetailerByStoreName(name) → venue type
└── eReceipt.IsSupported(merchantName) → eReceipt flag
Step 4: Graph reads (if userID has history)
└── Neo4j: MATCH (u:User)-[r:PURCHASED]->(p:Product) → times, avg_interval, last, likelihood, timestamps
Step 5: Calculated enrichment
├── ExpectedValue = likelihood × (best_offer_points - 25)
├── DaysSinceLastPurchase = now - last
├── PredictedNextPurchase = last + avg_interval
├── TotalAchievablePoints = CalculateProductPoints(price, PPD, offerPoints, stackable)
├── Stickers = generateStickers(offers, hasShopPartner, merchantStickers)
└── CTA = buildProductCTA(merchantID, merchantName, productURL, offerID, hasOffer)
Output: ProductUserContext (full union)
  1. Single wiring point — Eight upstream clients initialized once, shared by all consumers. Offer Guardian is currently mocked in the graph worker; FIDORA doesn’t exist here at all. Both get wired once.

  2. Consistent data — Agent, scheduler, and product cards all see the same product, the same offers, the same points. No drift between what the agent says and what the notification sends.

  3. Full context everywhere — The scheduler gains price, image, and merchant data it never had. The agent gains purchase history and repurchase scoring it never had. Product cards gain history context for smarter display.

  4. Testability — One enrichment interface to mock instead of eight separate clients scattered across three repos.

  5. Caching — Product metadata (FPS, FIDORA) and merchant data (Button) are stable on minute-to-hour timescales. The enrichment layer can cache internally without each consumer managing its own cache. Rover-mcp’s FidoIndex already demonstrates this for offers.

Interface: enrichment.Enricher

type Enricher interface {
EnrichProducts(ctx context.Context, products []ProductInfo, userID string) ([]ProductCard, error)
}

Input: ProductInfo — minimal product data from AI output (FidoID, Title, Price, ImageURL, ProductURL).

Output: ProductCard — fully enriched card for iOS rendering (ID, Title, ImageURL, Merchant, Price, TotalPoints, Stickers, CTA, RetailerEreceipt).

Per-product flow: FIDORA (gatekeeper) → offers + merchant in parallel → eReceipt → assemble card (points, stickers, CTA). Each product enriched concurrently via goroutines. 1-second timeout for all enrichment.

Points calculation (points.go):

shopPoints = price × PPD (or flat rate)
offerPoints = sum(stackable) or max(non-stackable)
TotalPoints = shopPoints + offerPoints (if stackable)
TotalPoints = max(shopPoints, offerPoints) (if not stackable)

Sticker priority (stickers.go): expiring offer (< 7d) → stackable → BMS multiplier → eReceipt. Max 2.

CTA priority (cta.go): Shop deep link (Button partner) → Offer deep link → Fallback browser.

Tool: get_offers_by_fido (MCP JSON-RPC)

Architecture: Wraps Offer Guardian SDK with a FIDO reverse index. At startup, warms cache by fetching all active offers (up to 20,000), extracting FIDOs from ActionRequirements, and building fido_id → []*Offer map. Lookups are O(1) in-memory.

Internal model:

type Offer struct {
ID, Points, OfferDescription, Description, Details, SubHeader // identity + display
Category, Categories, ImageURL, DetailImageURL // categorization + images
FidoIds []string // reverse-indexed
EndTime *int64 // ms epoch
IsStackable bool // stacking rules
}

Gaps for shared use:

  • User eligibility filtering (user_id accepted but unused)
  • Index staleness (built once at startup, no refresh)
  • Single-FIDO lookups only (no batch endpoint)

Offer client: internal/api/offerguardian/client.go — calls Offer Guardian SDK directly to get active offers and scan ActionRequirements for a FIDO match. Extracts PointsEarnedPointsPerDollarPointsPerItem. Currently mocked in production (dependencies.go).

FIDO Product Service: internal/api/fido/client.go — batch API for generic product metadata (name/description, brand, category, barcode). Used by all three Kafka/API transformers. 30s timeout, 3 retries, up to 1000 FIDOs per batch.

Graph reads: internal/notification/repurchase/neo4j.go — Cypher query returns top 10,000 candidates ordered by repurchase_likelihood DESC, filtered by times >= 2, not Alcohol, within lookback window.

Calculated enrichment: internal/scheduler/calculator.goExpectedValue = likelihood × (points - 25), plus DaysSinceLastPurchase and PredictedNextPurchase.

  • Service vs library? If consumers run in the same process, this is an internal Go package. If the agent is a separate service, this needs an HTTP API. Rover-mcp is already an HTTP/JSON-RPC server — could the enrichment layer be an extension of rover-mcp, or does it warrant its own service given the Neo4j dependency?
  • Staleness tolerance — Rover-mcp’s FidoIndex is stale after startup. The scheduler runs periodically (minutes). The agent needs near-real-time. Different caching strategies may be needed per data tier: offers (minutes), product metadata (hours), graph data (seconds).
  • Graph read load — The scheduler’s candidate query returns up to 10,000 rows in batch. The agent needs single-user lookups in real-time. The enrichment layer needs both access patterns, likely with different Neo4j indexes.
  • Batch efficiency — Rover-mcp’s get_offers_by_fido is single-FIDO. The scheduler processes thousands of candidates. Options: batch MCP endpoint, client-side parallelism, or embed the FidoIndex directly in the enrichment library.
  • FIDORA as gatekeeper — In rover-agent, FIDORA is the gatekeeper: if it returns nothing, the product gets only AI-provided data. Should the enrichment layer follow the same pattern, or should it degrade gracefully per-tier (product identity works even if FIDORA is down, offers work even if FIDORA is down, etc.)?
  • Neli integration point — Either add Neli filtering into rover-mcp’s offer lookup (single call returns pre-filtered offers) or keep it as a separate enrichment step. The former is cleaner; the latter is more flexible if eligibility checking needs to happen at different points in different consumers.