# Design — Compléments fonctionnels par IA & croisement ventes
Date : 2026-06-02
Auteur : Pierre Samson + Claude
Périmètre : nouvelle brique « Compléments IA » adossée au panel cross-family-baskets (FR)
Statut : design validé (Pierre, 2026-06-02), en attente de relecture du spec écrit
---
1. Contexte & objectif
Le panel Cross-Family Baskets actuel (core/basket_bundles.py +
scripts/refresh_cross_family_baskets.py) mine ce qui est acheté ensemble :
règles d'association (Apriori, confiance + lift) sur les paniers réels. C'est un
signal piloté par les ventes : il capture les co-achats observés, avec leur
bruit, et rate les complémentarités fonctionnelles qui ne se sont pas encore
matérialisées dans les ventes.
On veut le signal opposé et complémentaire : déduire, uniquement à partir des produits eux-mêmes (IA, sales-blind), quels produits vont fonctionnellement ensemble (souris→piles, chaussures→lacets, imprimante→cartouche), puis croiser avec les ventes pour isoler les paires complémentaires mais rarement achetées ensemble = opportunités de reco/bundle que les ventes seules ne voient pas.
Question métier cible : *« Quels produits devraient logiquement être achetés ensemble (selon leur fonction) mais ne le sont pas encore — donc où placer une reco ou un bundle ? »*
Contrainte structurante : l'IA ne doit voir que le produit (titre + marque), jamais les ventes. Sinon le croisement IA↔ventes devient circulaire.
---
2. Décisions verrouillées
| Décision | Choix |
|---|---|
| Approche | A — Typage fonctionnel → graphe de compléments par type → croisement ventes |
| Signal IA | Titre web + Marque uniquement (sales-blind) |
| Grain de complémentarité | niveau type fonctionnel (souris ↔ pile), pas SKU-exact |
| Périmètre IA (graphe) | les 18 197 SKU du CSV imports/marie/Datas_pour_produits_associes.csv |
| Périmètre co-achat (ventes) | tout l'assortiment actif en paniers (~31k SKU typés) — option (a) |
| Fenêtre / canal | 90 j / ALL (alignés sur le refresh existant) |
| Garde-fou volume | ≥ 25 acheteurs par type (aligné min_support) |
| Exécution | offline / snapshot (passe IA + croisement trop lourds pour le live) |
| Moteur LLM | SDK anthropic (modèle Haiku), comme core/labeling.py ; température 0 |
| Livrables | Excel pour Marie (principal) + mapping/graphe persistés (réutilisable). Section panel = phase 2 optionnelle |
---
3. Données
3.1 Univers IA — le CSV
imports/marie/Datas_pour_produits_associes.csv
- 18 197 SKU, colonnes :
SAP Code (.),SAP Code,Titre web,Marque,
Code fabricant, GTIN 1..5.
- Encodage cp1252 (accents). À lire avec
encoding="cp1252". - Signal exploitable :
Titre web+Marque. Pas de description longue ni de
- Couverture : tout l'assortiment Lyreco (bureau, EPI/sécurité, encres,
- Le SKU du CSV (
SAP Code, sans points) correspond à
ecom_order_lines.product_reference / ecom_products.product_reference.
3.2 Côté ventes — réutilisation de l'existant
Le pullorder_baskets de scripts/refresh_cross_family_baskets.py
(ecom_order_lines, FR, fenêtre, HAVING COUNT(DISTINCT product_reference) >= 2,
ARRAY_AGG(DISTINCT product_reference) AS skus) couvre déjà tout l'actif
(~31k SKU distincts sur 90 j). On le réutilise tel quel, ainsi que clean_baskets
(filtre anti-réassort taille>20 OU univers≥5).
3.3 Enrichissement produit
prod_meta (nom, section, world, famille) via `ecom_products × section_taxonomy
× product_family_taxonomy` (postgres), même requête batchée que l'existant.
Sert à afficher des SKU représentatifs lisibles par type.
3.4 Bridge cross-DB (règle mémoire)
Paniers en timescale :5434 ; produits/section/famille en postgres :5433. Jamais de join inter-DB dans une requête : on tire les SKU d'un côté, on requête l'autre avecANY(%s). (Pattern déjà en place dans le refresh existant.)
---
4. Méthode (pipeline offline)
Phase 1 — Chargement & normalisation
Lire le CSV (cp1252) →csv_products: {sku: {title, brand}} (sku = SAP Code
sans points, normalisé string). Charger prod_meta pour enrichissement.
Phase 2 — Typage fonctionnel IA (sales-blind)
Attribuer à chaque SKU un type fonctionnel normalisé depuistitre + marque.
- Vocabulaire contrôlé incrémental : à chaque batch, on passe au LLM la liste
souris).
- Batché ~40-60 produits / appel, entrée/sortie JSON (
{sku: type}),
- Cache disque clé =
hash(title + brand)→ re-runs quasi gratuits ; seuls
- Périmètre du typage : union des SKU du CSV (18k, pour le graphe) et des
- Sortie :
sku_type_map: {sku: {type_id, type_label}}. Attendu ~400-800 types.
Phase 3 — Graphe de compléments par type (sales-blind)
Pour chaque type issu du CSV, le LLM propose ses types compléments, choisis dans le vocabulaire de types existant (pour que chaque arête retombe sur de vrais produits du catalogue) :- sortie par type : liste de
{type_complement, relation, force_ia, justification}
, force_ia ∈ [0,1], justification` = une ligne.
- Batché par type-ancre. Le LLM ne voit jamais les ventes.
- Symétrisation :
a→betb→afusionnés en une arête non orientée (type1,
max(force_ia) et on concatène les justifications/relations.
- Sortie :
complement_edges: [{t1, t2, relation, ai_strength, rationale}].
Phase 4 — Croisement ventes (co-achat au grain type)
Sur les paniers nettoyés (clean_baskets), mapper chaque SKU → son type
(sku_type_map), puis agréger au grain paire de types :
n(t)= nb de paniers contenant ≥1 SKU de typet;co(t1,t2)= nb de paniers contenant à la fois ≥1 SKU det1et ≥1 det2;N= nb total de paniers nettoyés ;lift(t1,t2) = (co/N) / ((n(t1)/N)·(n(t2)/N));cond(t1,t2) = co / min(n(t1), n(t2))(taux de co-achat conditionnel).
Fonction pure, testable, dans core/functional_complements.py (pas d'I/O).
Phase 5 — Score & buckets
Pour chaque arête IA(t1,t2), jointer le co-achat mesuré :
volume = min(n(t1), n(t2))(les deux types doivent avoir assez d'acheteurs) ;copurchase_norm= normalisation decond(ou delift) sur [0,1] ;opportunité = ai_strength × log1p(volume) × (1 − copurchase_norm).
Garde-fou : ne garder que les paires où n(t1) ≥ min_type_support et
n(t2) ≥ min_type_support (défaut 25).
Trois seaux :
1. Sous-exploité — IA fort + co-achat faible (p. ex. lift < ~1 ou cond
sous un seuil) → la cible : candidat reco/bundle.
2. Confirmé — IA fort + co-achat fort → valide le moteur (peut alimenter un
« fréquemment complémentaires »).
3. IA-seule, jamais ensemble — co ≈ 0 → vraie lacune ou erreur de
typage → à vérifier (sert aussi de détecteur de bruit de typage).
Phase 6 — Sorties
- Excel
exports/complements_ia_FR_(pour Marie) :_d .xlsx Sous-exploités: t1, t2, relation, ai_strength, justification,
Confirmés(même structure).IA-seule à vérifier(même structure).Typage: sku → type (audit/correction par Marie).Graphe types: toutes les arêtes IA.- Snapshot JSON
exports/complements_ia_FR__d .json
- Persistance légère (réutilisable) : tables postgres
et type_complement_edge (source_country, t1, t2, relation,
ai_strength, rationale, co_baskets, lift, cond, bucket, computed_at)`.
*(Alternative au moment du plan : se contenter des fichiers snapshot si on
veut éviter une migration DB — à trancher dans le plan.)*
---
5. Architecture & isolation (calquée sur l'existant)
5.1 core/functional_complements.py — fonctions pures, testables (TDD)
`python
def load_csv_products(path, encoding="cp1252") -> dict:
"""{sku: {"title": str, "brand": str}} depuis le CSV de Marie."""
def type_copurchase(baskets, sku_type_map, min_type_support=25) -> dict: """Agrège n(t), co(t1,t2), lift, cond au grain type sur paniers nettoyés. Pur : pas d'I/O. baskets = list[frozenset[str]] (sortie de clean_baskets)."""
def score_opportunities(complement_edges, copurchase, min_type_support=25) -> list: """Jointe arêtes IA × co-achat → score + bucket (sous-exploité / confirmé / ia-seule). Pur."""
def build_complement_outputs(scored, sku_type_map, prod_meta, type_volume,
top_per_type=3) -> dict:
"""Assemble les sections d'affichage : 3 seaux + typage + graphe,
enrichis (SKU représentatifs lisibles par type). Pur."""
`
5.2 core/ai_typing.py — wrapper LLM injectable
Réutilise le pattern core/labeling.py (SDK anthropic, ANTHROPIC_API_KEY,
fallback gracieux, modèle Haiku, température 0).
`python
def type_products(products, existing_vocab, llm_call=None, batch_size=50,
cache_path=None) -> dict:
"""{sku: {type_id, type_label}} ; batché ; cache disque ; vocab contrôlé.
llm_call injectable → tests sans réseau."""
def propose_complements(types, llm_call=None) -> list:
"""Pour chaque type, types compléments (relation, force_ia, justification),
contraints au vocab. llm_call injectable."""
`
Les appels LLM sont isolés ici ; tout le reste (core/functional_complements)
est pur. Les tests injectent un llm_call factice → aucun appel réseau en test.
5.3 scripts/refresh_functional_complements.py — orchestration
- Réutilise
database.timeseries_db.TimeseriesDatabase(paniers) +
database.crm_db.CRMDatabase (produits) + clean_baskets.
- Pull
order_baskets(même requête que le refresh existant) → typage (Phase 2)
- CLI : `--days 90 --channel ALL --min-type-support 25 --perimeter active
(--limit` pour dev : sous-échantillonne le CSV).
- Logs
step()+ runtime de la passe IA mesuré et loggé (1er run vs caché).
5.4 tests/test_functional_complements.py — TDD, LLM stubbé
Jeu synthétique :
load_csv_products: cp1252 + colonnes correctes.type_copurchase: n(t), co, lift, cond exacts sur paniers connus ;
min_type_support.
score_opportunities: bucketing correct (sous-exploité vs confirmé vs
build_complement_outputs: SKU représentatifs présents ; enrichissement OK.ai_typing:llm_callfactice → vocab réutilisé avant création ; cache hit.
---
6. Paramètres & défauts
| Paramètre | Défaut | Rôle |
|---|---|---|
| days | 90 | fenêtre ventes |
| channel | ALL | ALL / WEB |
| perimeter | active | co-achat sur tout l'actif (a) vs csv (b) |
| min_type_support | 25 | plancher acheteurs par type |
| batch_size | 50 | produits par appel LLM |
| max_basket_size | 20 | filtre anti-réassort (réutilisé) |
| max_worlds | 5 | filtre anti-réassort (réutilisé) |
---
7. Risques & mitigations
- Qualité du typage = pièce maîtresse. Mitigations : vocabulaire contrôlé
Typage
pour audit/correction par Marie, et le seau « IA-seule » sert de détecteur
d'erreurs de typage (un complément « jamais acheté ensemble » est souvent un
mauvais type).
- Titre seul comme signal : pas de description → certains titres ambigus.
IA-seule à vérifier.
- Coût/runtime LLM : ~600-1000 appels Haiku (batch 50) au 1er run, mesuré +
- Cross-DB : bridge 2 étapes (règle mémoire) — déjà respecté par réutilisation.
- Encodage cp1252 : lecture explicite ; sinon accents corrompus.
- Déterminisme du snapshot : température 0 + cache → reproductible.
---
8. Hors périmètre
- Recalcul live des compléments dans le panel (coût) → snapshot uniquement.
- GB (FR d'abord).
- Section panel dans
_cross_family_baskets.html: phase 2 optionnelle
- Optimisation du moteur de reco en aval (cette brique produit les
- On ne casse pas l'existant :
core/basket_bundles.py,
product_co_occurrence et le refresh cross-family restent intacts ; cette
brique est additive.