# Product Buying-Intelligence Layer — Design
Date: 2026-06-11
Status: Approved (brainstorm) — pending spec review
Repo: lead-contagion-project (Lyréco tenant), with a hybrid prove-then-extract
path into ppi-core.
Goal
Give sales/marketing a buying profile per customer: each week, the Top-10 products bought — at individual-user grain rolling up to company, ranked by purchase frequency and units, stored as weekly snapshots so the week-over-week shift is visible. Add a basket layer that registers every ordered line (product, qty, timestamp) and its cart grouping into the timeline for cross-product analysis, and a firmographic/behavioral cluster overlay ("which cluster buys these") under two lenses.
Decisions locked in brainstorm
| Question | Decision |
|---|---|
| Profile grain | Both — individual user (ecom_user_id), rolled up to company (soldto_number) |
| Ranking measure | Order frequency + units (quantity) — both stored & ranked. Spend stored, not ranked. |
| Window / cadence | Weekly snapshot of a trailing window (default 13 weeks); appended → time-series of Top-10 lists |
| Cluster overlay | Both lenses — emergent graph communities ("behaves alike") AND firmographic look-alike / BvD-SIC clusters ("is alike") |
| Build approach | Hybrid — Lyréco-native now, written config-shaped; lift the generalizable compute into ppi-core once proven |
| Source table | ecom_order_lines (orders) — order_number = the cart; frequency = distinct orders. Not invoices (they split/merge, no clean basket). |
Architecture — three layers
Layer 2 — Basket events (substrate, built first)
Reconciled with reality (2026-06-11 dependency dig): ecom_order_lines is
already a TimescaleDB hypertable carrying soldto_number, ecom_user_id,
product_reference, quantity, sales_amount, order_date, order_number
(first-class — the cart id), order_channel_code, source_country. And
compute_timeline already emits per-line ORDER_PLACED events (PK includes
order_line_number; carries product_reference + quantity + amount, with
order_number inside event_details). So a separate purchase_lines table was
dropped as pure duplication.
What we build for layer 2:
1. Promote order_id to a first-class column on customer_timeline (lifted
from event_details->>'order_number' on ORDER_PLACED rows) + index, so
baskets regroup with a fast GROUP BY order_id instead of JSONB extraction.
Populate it in compute_timeline; backfill existing ORDER_PLACED rows.
2. v_order_baskets — a thin view over ORDER_PLACED grouping by
order_id, the basket substrate for cross-product / co-purchase analysis
(complements existing product_co_occurrence / Neo4j :BOUGHT_TOGETHER).
Layer 1 — Weekly buying profile
buying_profile_weekly:
`(scope_type ['user'|'company'], scope_id, source_country, snapshot_week,
product_reference, orders_count, units_qty, sales_amount, rank_freq, rank_qty,
window_weeks)`.
A weekly worker (invoked from daily_refresh, gated to fire once per ISO week)
recomputes the Top-N (default 10) over a trailing window_weeks window
(default 13) and appends the snapshot keyed by snapshot_week. Ranks by
both orders_count (frequency) and units_qty; deterministic tie-break
(product_reference asc) so snapshots are reproducible.
Layer 3 — Cluster overlay (dual lens)
cluster_buying_profile_weekly:
`(cluster_source ['emergent'|'firmographic'], cluster_id, source_country,
snapshot_week, product_reference, orders_count, units_qty, rank_freq, rank_qty,
n_accounts)`.
Rolls company-level purchases up via an account→cluster map, run under both lenses. The rollup accepts any mapping table (the generalization that makes it liftable):
- Firmographic —
account_firmographic_community(account_number →
workers/community_match_soft.py). Populated today — wired in Plan 1.
- Behavioral — account-grain Louvain communities. Does not exist yet
:EmergentCommunity nodes are empty); stood up in Plan 2, then
plugged into this same rollup as a second mapping.
Decisions 2026-06-11: order_id promoted to a timeline column (not a new
purchase_lines table); behavioral communities built now (Plan 2) so both
lenses ship. Build split into two plans, each independently shippable.
Consumption
Dashboard script-backed report (logs/reports/*.json, `refresh_mode:
script, PYTHONIOENCODING=utf-8`): weekly Top-10 per account/user and per
cluster, with week-over-week shift indicators. XLSX export alongside, following
export_wise_top10_by_family.py / refresh_prospect_intelligence_hub.py.
Hybrid extraction path
Layer-1 ranking/window logic and layer-3 rollup are written as clean,
config-shaped units (pure functions over a query result + a spec object), so
once proven on Lyréco data they lift into ppi-core as:
- a
buying_profileengine (top-N over a trailing window), and - a
cluster_rollupoverlay (top-N per cluster given an account→cluster map),
each gated by a parity golden test — the same path funnel/cohort took. Co-purchase maps onto the existing contagion / co-occurrence engine; not re-built here.
Testing
- Hermetic unit tests on ranking, trailing-window selection, and tie-break
- Parity gate: worker output == an independent direct SQL query over one
Data-flow & platform notes
- Cross-DB:
ecom_order_lines/customer_timeline/purchase_lineslive
ecom_products, taxonomy, cluster maps live in
Postgres (:5433). No cross-DB joins — 2-step bridge (WHERE col = ANY(%s)).
- Timeline volume: adding ~25M per-line
PRODUCT_PURCHASEDevents to a
- Cadence: weekly snapshot worker is gated inside
daily_refresh(runs only
Open dependencies (resolve at plan time)
1. Emergent community table — confirm exact Postgres name + freshness/refresh
cadence of the account→community_id assignment.
2. Firmographic cluster table — confirm the prospect_lookalike account→
cluster map name and that it is current.
3. purchase_lines vs reusing ecom_order_lines — decide whether the
substrate is a distinct hypertable or a thin view/projection over
ecom_order_lines (it already holds the same columns); the timeline-event
emission is needed either way.
YAGNI cuts
No spend ranking; no per-calendar-week buckets (too sparse for B2B); no new clustering model (reuse existing); no real-time — weekly batch only.