Skip to main content

Cross-sell

What it does

Per ADR-0010 D6, Ratiba's cross-sell is slot-aware: after a primary booking is confirmed, the FSM offers a complementary service only when an adjacent post-primary slot exists within ±15 minutes of primary.end_at. The framing is "come for your pedicure right after your manicure" — pre-primary slots are explicitly not offered, because rearranging the customer's day is a materially different proposition.

The offer path is bounded by five locked v1 mechanics:

  1. Top-1 candidate ranked by relation.confidence × adjacency_tightness, where adjacency_tightness = 1 − |gap_min| / tolerance_min. Tie-break is service_a_id ascending for deterministic behaviour.
  2. Cascade cap of 1 per conversation (cascade depth = 1). Once the customer declines, no second offer fires in the same booking thread.
  3. Same-staff or staff-swap allowed: the candidate slot may be filled by any staff member qualified for the candidate service, joined via staff_services.
  4. Returns empty when cross_sell_dial == "never", upsell_dial == "never", no complementary relation row exists, no slot fits the adjacency window, the primary was cancelled, or the customer already declined this conversation.
  5. Atomic ordered-pair reservation via Redis Lua (M11 T8): a single EVAL performs SET NX EX on both keys atomically; on second-key failure the script rolls back the primary acquisition with DEL before returning 0. Default TTL 30 s.

The decline path calls release_pair(), returns the secondary slot to the availability pool, and the primary booking continues with single-service STK. The yes path advances to BUNDLE_CONFIRM, writes the secondary appointment row, and emits a combined STK push using simple-sum pricing (no v1 discount logic).

How it fits in the system

For the full FSM state graph that surrounds CONFIRM see Conversation FSM.

Service relation graph

The cross-sell engine's candidate list is produced by the service relation graph — a per-tenant graph of typed edges between services, stored in <tenant>.service_relations:

ColumnNotes
service_a_idSource service (UUID FK to services)
service_b_idTarget service (UUID FK to services)
relation_typeOne of complementary / alternative / sequential
confidenceFloat 0.0–1.0, LLM-inferred at catalog import
directionbidirectional or unidirectional

Only complementary edges participate in cross-sell. alternative edges are used by a future slot-fallback path; sequential edges are reserved for bundled appointment workflows.

The relation graph is populated at catalog-import time by the relation inferrer. For how that inference works and how to re-run it on an existing catalog, see Catalog onboarding.

Confidence × adjacency ranking

find_cross_sell_options() applies a two-factor score to every eligible (secondary_service, adjacent_slot) pair:

score = relation.confidence × adjacency_tightness
= relation.confidence × (1 − |gap_min| / tolerance_min)

Where gap_min is the absolute gap in minutes between primary.end_at and secondary_slot.start_at, and tolerance_min is the adjacency window (default 15 min). A zero-gap pair (back-to-back) scores the full confidence weight; a 14-minute gap scores confidence × (1/15).

Dial gating

find_cross_sell_options() performs two dial checks before touching the database:

  • cross_sell_dial == "never" is the explicit opt-out. Set it when the tenant doesn't want any post-booking offers.
  • upsell_dial == "never" is a broader opt-out that silences both the upsell nudge during slot collection AND cross-sell post-confirm.
  • cross_sell_dial == "related-only" (default for most verticals) limits candidates to complementary relation edges only — the same as v1.
  • cross_sell_dial == "full-suggest" allows the engine to widen the candidate pool in a future version (reserved; behaves as related-only in v1).

For the full dial value table, see Personality dials.

How it flows

Adjacency window

The adjacency window is ±15 minutes around primary.end_at. Only the post-primary half is used in v1 — slots starting before primary.end_at are excluded unconditionally:

eligible range:
primary.end_at ≤ secondary.start_at ≤ primary.end_at + 15 min

Why post-primary only? Offering a pre-primary slot (e.g. "come earlier for an eyebrow wax before your cut") requires the customer to rearrange their arrival time — a materially different commitment than "stay 30 minutes longer". D6 locks this conservatively for v1; a "pre-primary with opt-in" flag is on the roadmap.

The window is not per-tenant configurable in v1. If a tenant's services are typically scheduled with longer gaps, a future JSONB threshold override on tenant_personality_config is the planned extension point.

Atomic ordered-pair reservation

Two slots must be locked simultaneously: the one already soft-reserved for the primary service (extended TTL at CONFIRM) and the new secondary slot. reserve_pair() achieves this with a single Redis EVAL call:

-- pseudo-code of the Lua script
local pk = KEYS[1] -- pair:reservation:<primary_slot_id>
local sk = KEYS[2] -- pair:reservation:<secondary_slot_id>
local ttl = ARGV[1]

if redis.call('SETNX', pk, ARGV[2]) == 1 then
if redis.call('SETNX', sk, ARGV[3]) == 1 then
redis.call('EXPIRE', pk, ttl)
redis.call('EXPIRE', sk, ttl)
return 1 -- both acquired
else
redis.call('DEL', pk) -- rollback
return 0 -- secondary was raced
end
else
return 0 -- primary was raced
end

Default TTL is 30 s. The entire CROSS_SELL_OFFER → CROSS_SELL_RESPONSE window must complete within that TTL; on timeout the pair is released and the primary booking continues. The 30 s ceiling is intentionally tight — leaving a secondary slot locked while a customer reads a message for two minutes would degrade availability for all other callers.

To observe active pair reservations: redis-cli KEYS "pair:reservation:*".

For the general per-thread SETNX mutex that guards booking turns, see Conversation FSM.

Cascade cap

The cascade cap is enforced by cross_sell_declined=True on BookingState. Once set, find_cross_sell_options() returns an empty list immediately — before any database or Redis call. This prevents a customer who said "no" from being re-offered a cross-sell if the FSM is somehow re-entered or if a retry resets the conversation context.

The cross_sell_declined flag is per-thread (ULID thread lifetime), not per-customer or per-session. A customer who declines today will be eligible for a cross-sell offer on their next booking. Per-customer learning (e.g. "this customer always declines") is a future personalisation concern outside the v1 scope.

Where it lives in code

ConcernFileKey entry point
Cross-sell decisionbackend/app/services/cross_sell.pyfind_cross_sell_options()
Adjacent slot searchbackend/app/services/availability.pyfind_adjacent_slot()
Atomic pair reservationbackend/app/services/reservations.pyreserve_pair() / release_pair()
CROSS_SELL_OFFER FSM nodebackend/app/orchestrator/booking_graph.pycross_sell_offer_node()
CROSS_SELL_RESPONSE FSM nodebackend/app/orchestrator/booking_graph.pycross_sell_response_node()
BUNDLE_CONFIRM FSM nodebackend/app/orchestrator/booking_graph.pybundle_confirm_node()
Relation graphbackend/app/persistence/catalog_imports.pyservice_relations upsert (see catalog-onboarding)

Decisions

Try this on local dev

  1. Trigger the offer. Book a manicure end-to-end via WhatsApp (or tests/e2e/cross_sell_e2e.py for headless). After CONFIRM, watch the logs for cross_sell.candidate_selected followed by a CROSS_SELL_OFFER message rendering the pedicure adjacent slot. Verify pair_reservation_acquired in Redis logs (KEYS pair:reservation:* in redis-cli).

  2. Walk the yes path. Reply yes (or ndio). Observe CROSS_SELL_RESPONSE route to affirmative, then BUNDLE_CONFIRM write a second appointments row. The STK push from the M-Pesa sandbox should show the combined primary + secondary amount; check payment_callbacks for the merged bundle reference.

  3. Walk the decline path. Restart the scenario, reply no (or hapana). Confirm release_pair fires (pair_reservation_released log line), the secondary slot reappears in find_available_slots(), and the STK push fires for the single-service primary amount only. The cross_sell_declined=True flag persists for the rest of the conversation, blocking any cascade re-offer.

  4. Inspect the relation graph. After onboarding a catalog, check which complementary edges were inferred:

    docker compose exec postgres psql -U ratiba -d ratiba \
    -c "SET search_path TO tenant_<slug>; \
    SELECT sa.name, sb.name, sr.confidence \
    FROM service_relations sr \
    JOIN services sa ON sa.id = sr.service_a_id \
    JOIN services sb ON sb.id = sr.service_b_id \
    WHERE sr.relation_type = 'complementary' \
    ORDER BY sr.confidence DESC;"

    Low-confidence edges (below ~0.4) rarely win the top-1 ranking unless the adjacency tightness is near 1.0.

  5. Test dial gating. PATCH cross_sell_dial to never (see Personality dials), then re-run the booking. find_cross_sell_options should return an empty list immediately, and the post-CONFIRM path should proceed straight to BOOKED without emitting a CROSS_SELL_OFFER.

  • Conversation FSM — the booking state machine that hosts CONFIRM, CROSS_SELL_OFFER, CROSS_SELL_RESPONSE, and BUNDLE_CONFIRM as first-class nodes.
  • Catalog onboarding — how complementary relation edges are inferred by the LLM at import time and stored in service_relations.
  • Personality dials — the cross_sell and upsell dial values that gate whether an offer fires.
  • Payments — the STK push orchestration that handles both single-service and combined-bundle totals.
  • Observability — log events: cross_sell.candidate_selected, pair_reservation_acquired, pair_reservation_released, cross_sell_declined.
  • Glossary — definitions for adjacency window, ordered-pair reservation, service relation graph, cross-sell, and cascade cap.