# Product Complementarity Engine (Tech-Spec Layer + v2 Merge) Implementation Plan
> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Populate a persistent product-complementarity catalog attribute for Marie's 18k in-scope products, driven primarily by structured technical characteristics and completed by the existing co-purchase/family engine.
Architecture: A pure-function core (core/complement_specs.py) turns T_PRODUCT_ATTRIBUTE rows into complementarity signals — Type A (direct-compat, device→consumable) and Type B (requirement→family top-N) — then merges them with the v2 co-purchase/family fallback, deduping against Marie's existing links. An orchestrator script pulls the data, runs the core, and writes a scope flag + a normalized product_complements table + a JSON column + a wide-format XLSX for Marie.
Tech Stack: Python 3.12, psycopg2 (Postgres :5433), oracledb thin (Halo/Oracle), openpyxl, pytest. Reuses core/complement_families.py and core/ai_typing.py.
Global Constraints
- Python 3.12 venv (
venv\Scripts\python.exe); psycopg2 needs 3.12, not 3.14. - FR only:
source_country='FR'/ OracleCOUNTRY_CODE='FR'. Product key =LPAD(sap,18,'0')(18-digit, NOT LPAD-10). - Oracle pulls set
ORACLE_TNS_ADMINto the projectoracle/dir; hygiene filterDL_DELETION_FLAG='N'. Halo is slow — pull once, aggregate in Python. - Cross-DB: masters (
ecom_products) in Postgres :5433;ecom_order_linesin timescale :5434; Oracle is a third source. Never join across DBs in one query — bridge withANY(%s). - psycopg2
%-literal trap: literal%in SQL passed with params must be%%. core/complement_specs.pyis pure (no network/DB/file I/O) — all data passed in as plain dicts/lists.- Cap per anchor:
N=20(Marie's wide format). Scope tag value:complement_scope='marie-2026-06-25'. - Preserve Marie's existing 5,458 links: v1 dedups against them, never overwrites.
Canonical data shapes (used across tasks)
`python
# attribute row (one per label per anchor), from T_PRODUCT_ATTRIBUTE
attr = {"label": str, "value": str, "unit": str | None}
# product / sku (from ecom_products, enriched with sales)
sku = {"reference": str, "description": str, "web_title": str, "family_code": str,
"subcategory_code": str, "brand": str, "brand_group": str,
"manufacturer_code": str, "gtins": list[str], "sales": int,
"not_salable": bool, "not_visible": bool}
# a signal produced from an attribute
signal = {"signal": "A_compat" | "B_family", "label": str, "value": str,
"match": str | None, "target": str | None} # match for A, target for B
# a complement candidate
cand = {"reference": str, "name": str, "family_code": str,
"signal_type": str, "signal_label": str}
# label_map entry: {"`
---
Task 1: Label map config + loader
Files:
- Create:
config/complement_label_map.json - Create:
core/complement_specs.py - Test:
tests/test_complement_specs.py
Interfaces:
- Produces:
load_label_map(path: str) -> tuple[dict, dict]returning(label_map, target_family_map).
- [ ] Step 1: Create the config file
Create config/complement_label_map.json (seed set from the surveyed vocabulary; extend later):
`json
{
"label_map": {
"Compatible Printer": {"signal": "A_compat", "match": "printer_model"},
"Imprimante compatible": {"signal": "A_compat", "match": "printer_model"},
"Compatible OEM Part Number": {"signal": "A_compat", "match": "oem_ref"},
"Compatible avec référence FEO": {"signal": "A_compat", "match": "oem_ref"},
"Maximum Screen Size Supported": {"signal": "A_compat", "match": "screen_size"},
"Taille maximale d'écran compatible": {"signal": "A_compat", "match": "screen_size"},
"Batteries Included": {"signal": "B_family", "target": "battery", "needs_type": true},
"Piles incluses": {"signal": "B_family", "target": "battery", "needs_type": true},
"Alimentation": {"signal": "B_family", "target": "battery", "needs_type": true},
"Cable Type": {"signal": "B_family", "target": "cable"},
"Connector on First End": {"signal": "B_family", "target": "cable"},
"Connecteur 1ère extrémité": {"signal": "B_family", "target": "cable"},
"Sheet Format": {"signal": "B_family", "target": "paper", "by_value": true},
"Format de feuille": {"signal": "B_family", "target": "paper", "by_value": true},
"Host Interface": {"signal": "B_family", "target": "cable_hub", "when_value": "USB"}
},
"target_family_map": {
"battery": [],
"cable": [],
"paper": [],
"cable_hub": []
}
}
`
> Note: target_family_map codes are filled in Task 9 after we query the real subcategory codes; empty lists are valid placeholders the loader must accept.
- [ ] Step 2: Write the failing test
`python
# tests/test_complement_specs.py
import json, os
from core.complement_specs import load_label_map
def test_load_label_map(tmp_path):
cfg = {"label_map": {"Cable Type": {"signal": "B_family", "target": "cable"}},
"target_family_map": {"cable": ["004005"]}}
p = tmp_path / "m.json"
p.write_text(json.dumps(cfg), encoding="utf-8")
lm, tfm = load_label_map(str(p))
assert lm["Cable Type"]["signal"] == "B_family"
assert tfm["cable"] == ["004005"]
`
- [ ] Step 3: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py::test_load_label_map -v
Expected: FAIL — ModuleNotFoundError / cannot import name 'load_label_map'.
- [ ] Step 4: Write minimal implementation
`python
# core/complement_specs.py
"""Produits complémentaires — couche tech-spec (fonctions pures). Aucun I/O réseau/DB.
Méthode : docs/superpowers/specs/2026-07-01-product-complementarity-design.md"""
from __future__ import annotations
import json
import re
def load_label_map(path: str) -> tuple[dict, dict]:
"""Charge la carte label->signal + la carte target->familles depuis le JSON versionné."""
cfg = json.loads(open(path, encoding="utf-8").read())
return cfg.get("label_map", {}), cfg.get("target_family_map", {})
`
- [ ] Step 5: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py::test_load_label_map -v
Expected: PASS
- [ ] Step 6: Commit
`bash
git add config/complement_label_map.json core/complement_specs.py tests/test_complement_specs.py
git commit -m "feat(complements): label->signal map config + loader"
`
---
Task 2: classify_attribute — one attribute → signal or None
Files:
- Modify:
core/complement_specs.py - Test:
tests/test_complement_specs.py
Interfaces:
- Consumes:
label_mapfrom Task 1. - Produces:
classify_attribute(label: str, value: str, label_map: dict) -> dict | Nonereturning asignaldict (see canonical shapes) orNonewhen the label is not in the map or awhen_valueguard fails.
- [ ] Step 1: Write the failing test
`python
from core.complement_specs import classify_attribute
LM = { "Compatible OEM Part Number": {"signal": "A_compat", "match": "oem_ref"}, "Batteries Included": {"signal": "B_family", "target": "battery", "needs_type": True}, "Host Interface": {"signal": "B_family", "target": "cable_hub", "when_value": "USB"}, }
def test_classify_type_a(): s = classify_attribute("Compatible OEM Part Number", "CE285A", LM) assert s == {"signal": "A_compat", "label": "Compatible OEM Part Number", "value": "CE285A", "match": "oem_ref", "target": None}
def test_classify_type_b(): s = classify_attribute("Batteries Included", "Yes", LM) assert s["signal"] == "B_family" and s["target"] == "battery"
def test_classify_when_value_guard(): assert classify_attribute("Host Interface", "PS/2", LM) is None # not USB assert classify_attribute("Host Interface", "USB 3.0", LM) is not None
def test_classify_unmapped_label():
assert classify_attribute("Marque", "KENSINGTON", LM) is None
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k classify -v
Expected: FAIL — cannot import name 'classify_attribute'.
- [ ] Step 3: Write minimal implementation
`python
def classify_attribute(label: str, value: str, label_map: dict) -> dict | None:
"""Traduit un attribut (label,value) en signal, ou None si non pertinent.
when_value : ne déclenche que si la valeur contient la chaîne exigée (ex. USB)."""
rule = label_map.get(label)
if not rule:
return None
wv = rule.get("when_value")
if wv and wv.upper() not in (value or "").upper():
return None
return {"signal": rule["signal"], "label": label, "value": value or "",
"match": rule.get("match"), "target": rule.get("target")}
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k classify -v
Expected: PASS (4 tests)
- [ ] Step 5: Commit
`bash
git add core/complement_specs.py tests/test_complement_specs.py
git commit -m "feat(complements): classify_attribute -> signal"
`
---
Task 3: parse_battery_type — free-text battery-type fallback
Files:
- Modify:
core/complement_specs.py - Test:
tests/test_complement_specs.py
Interfaces:
- Produces:
parse_battery_type(text: str) -> str | None→ one of"AAA"|"AA"|"9V"|"C"|"D"|"CR2032"|...orNone. Order matters: check AAA before AA.
- [ ] Step 1: Write the failing test
`python
from core.complement_specs import parse_battery_type
def test_parse_battery_type():
assert parse_battery_type("Utilise 1 pile AAA. Connexion USB.") == "AAA"
assert parse_battery_type("Requires 2x AA batteries") == "AA"
assert parse_battery_type("Pile bouton CR2032 incluse") == "CR2032"
assert parse_battery_type("9V block battery") == "9V"
assert parse_battery_type("Rechargeable lithium, no info") is None
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k battery_type -v
Expected: FAIL.
- [ ] Step 3: Write minimal implementation
`python
_BATT_PATTERNS = [
("CR2032", r"CR20\d\d"),
("AAA", r"\bAAA\b|\bLR ?0?3\b"),
("AA", r"\bAA\b|\bLR ?0?6\b"),
("9V", r"\b9\s?V\b|\b6LR61\b|\bPP3\b"),
("C", r"\bLR14\b|\bpile[s]? C\b"),
("D", r"\bLR20\b|\bpile[s]? D\b"),
]
def parse_battery_type(text: str) -> str | None:
"""Détecte le type de pile dans du texte libre (AAA avant AA). None si inconnu."""
t = text or ""
for name, pat in _BATT_PATTERNS:
if re.search(pat, t, re.IGNORECASE):
return name
return None
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k battery_type -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_specs.py tests/test_complement_specs.py
git commit -m "feat(complements): parse_battery_type free-text fallback"
`
---
Task 4: norm_oem_ref + build_compat_index
Files:
- Modify:
core/complement_specs.py - Test:
tests/test_complement_specs.py
Interfaces:
- Consumes:
label_map,classify_attribute. - Produces:
norm_oem_ref(s: str) -> str— uppercase, strip non-alphanumerics.build_compat_index(consumables: list[dict], label_map: dict) -> dictwhere eachconsumablesitem is{"reference": str, "attrs": list[attr]}; returns{norm_key: set[reference]}mapping each Type-A compat key (normalized OEM ref / printer model token) to the consumable refs that declare it.
- [ ] Step 1: Write the failing test
`python
from core.complement_specs import norm_oem_ref, build_compat_index
LM_A = {"Compatible OEM Part Number": {"signal": "A_compat", "match": "oem_ref"}, "Compatible Printer": {"signal": "A_compat", "match": "printer_model"}}
def test_norm_oem_ref(): assert norm_oem_ref("ce-285/A") == "CE285A" assert norm_oem_ref(" HP 59X ") == "HP59X"
def test_build_compat_index():
consumables = [
{"reference": "TONER1", "attrs": [
{"label": "Compatible OEM Part Number", "value": "CE285A", "unit": None},
{"label": "Compatible Printer", "value": "HP LaserJet M404", "unit": None}]},
{"reference": "TONER2", "attrs": [
{"label": "Compatible OEM Part Number", "value": "ce285a", "unit": None}]},
]
idx = build_compat_index(consumables, LM_A)
assert idx["CE285A"] == {"TONER1", "TONER2"}
assert "M404" in idx and "TONER1" in idx["M404"]
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k "oem_ref or compat_index" -v
Expected: FAIL.
- [ ] Step 3: Write minimal implementation
`python
def norm_oem_ref(s: str) -> str:
"""Clé normalisée : majuscules, sans caractères non alphanumériques."""
return re.sub(r"[^A-Z0-9]", "", (s or "").upper())
_MODEL_TOKEN = re.compile(r"[A-Z]{0,3}\d{2,4}[A-Z]{0,3}") # M404, LW450, 59X ...
def _compat_keys(match: str, value: str) -> list[str]: """Clés d'index pour une valeur de compatibilité selon le type de match.""" if match == "oem_ref": return [norm_oem_ref(value)] if value else [] if match == "printer_model": return list({m.group(0) for m in _MODEL_TOKEN.finditer((value or "").upper())}) if match == "screen_size": return [norm_oem_ref(value)] if value else [] return []
def build_compat_index(consumables: list[dict], label_map: dict) -> dict:
"""Index compat {clé -> set(references consommables déclarant cette compat)}."""
idx: dict[str, set] = {}
for c in consumables:
for a in c.get("attrs", []):
sig = classify_attribute(a["label"], a["value"], label_map)
if not sig or sig["signal"] != "A_compat":
continue
for k in _compat_keys(sig["match"], sig["value"]):
if k:
idx.setdefault(k, set()).add(c["reference"])
return idx
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k "oem_ref or compat_index" -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_specs.py tests/test_complement_specs.py
git commit -m "feat(complements): OEM-ref normalization + compatibility index"
`
---
Task 5: resolve_type_a — directionality (device → consumables)
Files:
- Modify:
core/complement_specs.py - Test:
tests/test_complement_specs.py
Interfaces:
- Consumes:
build_compat_index,_compat_keys,norm_oem_ref. - Produces:
resolve_type_a(anchor: dict, compat_index: dict, products_by_ref: dict, label_map: dict) -> list[dict]whereanchor = {"reference","attrs","is_device"(bool),"identity_keys"(list[str])}andproducts_by_ref = {ref: sku}. Returnscanddicts (signal_type="A_compat"). - If
anchor["is_device"]: reverse-lookup — for each of the anchor'sidentity_keys(its own model tokens / OEM refs), pull consumable refs fromcompat_index. - Else (anchor is a consumable): forward — the device(s) it fits are not in the index (they're the keys); v1 returns the same-key sibling consumables excluded → returns
[]for consumables in v1 (documented limitation; devices are the money direction).
- [ ] Step 1: Write the failing test
`python
from core.complement_specs import resolve_type_a, build_compat_index
LM_A = {"Compatible OEM Part Number": {"signal": "A_compat", "match": "oem_ref"}, "Compatible Printer": {"signal": "A_compat", "match": "printer_model"}}
def test_resolve_type_a_device_reverse_lookup(): consumables = [{"reference": "TONER1", "attrs": [ {"label": "Compatible Printer", "value": "HP LaserJet M404", "unit": None}]}] idx = build_compat_index(consumables, LM_A) products = {"TONER1": {"reference": "TONER1", "description": "TONER HP 59X", "family_code": "002001"}} printer = {"reference": "PRN1", "attrs": [], "is_device": True, "identity_keys": ["M404"]} out = resolve_type_a(printer, idx, products, LM_A) assert [c["reference"] for c in out] == ["TONER1"] assert out[0]["signal_type"] == "A_compat" assert out[0]["family_code"] == "002001"
def test_resolve_type_a_consumable_returns_empty_v1():
printer_anchor = {"reference": "TONER1", "attrs": [], "is_device": False, "identity_keys": []}
assert resolve_type_a(printer_anchor, {}, {}, LM_A) == []
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k resolve_type_a -v
Expected: FAIL.
- [ ] Step 3: Write minimal implementation
`python
def resolve_type_a(anchor: dict, compat_index: dict, products_by_ref: dict,
label_map: dict) -> list[dict]:
"""Type A : pour une ancre DEVICE, remonte les consommables compatibles via l'index
(device->consommable = la direction utile). Pour un consommable en v1 : [] (limite
documentée). Déduplique les refs, préserve l'ordre de découverte des clés."""
if not anchor.get("is_device"):
return []
out, seen = [], set()
for key in anchor.get("identity_keys", []):
for ref in sorted(compat_index.get(key, ())):
if ref in seen or ref == anchor["reference"]:
continue
seen.add(ref)
p = products_by_ref.get(ref, {})
out.append({"reference": ref, "name": p.get("description") or "",
"family_code": p.get("family_code") or "",
"signal_type": "A_compat", "signal_label": "compatible"})
return out
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k resolve_type_a -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_specs.py tests/test_complement_specs.py
git commit -m "feat(complements): Type-A resolution (device->consumable reverse lookup)"
`
---
Task 6: resolve_type_b — requirement → family top-N
Files:
- Modify:
core/complement_specs.py - Test:
tests/test_complement_specs.py
Interfaces:
- Consumes:
classify_attribute,parse_battery_type,target_family_map, andcore.complement_families.rank_family_skus. - Produces:
resolve_type_b(anchor: dict, label_map: dict, target_family_map: dict, family_skus: dict, top_n: int = 5) -> list[dict]. anchor = {"reference","attrs","free_text"};family_skus = {family_code: [sku, ...]}(sku dicts).- For each Type-B signal: pick target families from
target_family_map; forneeds_typebattery signals, refine the family byparse_battery_type(value or free_text)when a per-type mapping exists (keyf"battery:{TYPE}"intarget_family_map), else genericbattery. Rank family SKUs withrank_family_skus, taketop_n.
- [ ] Step 1: Write the failing test
`python
from core.complement_specs import resolve_type_b
LM_B = {"Batteries Included": {"signal": "B_family", "target": "battery", "needs_type": True}, "Cable Type": {"signal": "B_family", "target": "cable"}} TFM = {"battery": ["007010"], "battery:AAA": ["007011"], "cable": ["004005"]}
def _sku(ref, sales, fam): return {"reference": ref, "description": ref, "family_code": fam, "sales": sales, "not_salable": False, "not_visible": False}
def test_resolve_type_b_battery_typed(): fam_skus = {"007011": [_sku("PILE_AAA_1", 500, "007011"), _sku("PILE_AAA_2", 300, "007011")], "007010": [_sku("PILE_GEN", 900, "007010")]} anchor = {"reference": "MOUSE", "free_text": "Utilise 1 pile AAA", "attrs": [{"label": "Batteries Included", "value": "Yes", "unit": None}]} out = resolve_type_b(anchor, LM_B, TFM, fam_skus, top_n=2) refs = [c["reference"] for c in out] assert refs == ["PILE_AAA_1", "PILE_AAA_2"] # typed family, sales-ranked assert out[0]["signal_type"] == "B_family"
def test_resolve_type_b_cable():
fam_skus = {"004005": [_sku("RJ45_1", 100, "004005")]}
anchor = {"reference": "SWITCH", "free_text": "",
"attrs": [{"label": "Cable Type", "value": "RJ-45", "unit": None}]}
out = resolve_type_b(anchor, LM_B, TFM, fam_skus, top_n=5)
assert [c["reference"] for c in out] == ["RJ45_1"]
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k resolve_type_b -v
Expected: FAIL.
- [ ] Step 3: Write minimal implementation
`python
from core.complement_families import rank_family_skus
def _target_families(sig: dict, free_text: str, target_family_map: dict) -> list[str]: """Familles cibles d'un signal B ; pour les piles, raffine par type si mapping dédié.""" target = sig.get("target") fams = list(target_family_map.get(target, [])) if target == "battery": btype = parse_battery_type(sig.get("value") or "") or parse_battery_type(free_text or "") typed = target_family_map.get(f"battery:{btype}") if btype else None if typed: return list(typed) return fams
def resolve_type_b(anchor: dict, label_map: dict, target_family_map: dict,
family_skus: dict, top_n: int = 5) -> list[dict]:
"""Type B : requirement -> familles cibles -> top-N SKU (ventes desc, actif/périmètre)."""
out, seen = [], set()
free_text = anchor.get("free_text") or ""
for a in anchor.get("attrs", []):
sig = classify_attribute(a["label"], a["value"], label_map)
if not sig or sig["signal"] != "B_family":
continue
for fam in _target_families(sig, free_text, target_family_map):
ranked = rank_family_skus(family_skus.get(fam, []), top_n)
for s in ranked:
if s["reference"] in seen or s["reference"] == anchor["reference"]:
continue
seen.add(s["reference"])
out.append({"reference": s["reference"], "name": s.get("description") or "",
"family_code": fam, "signal_type": "B_family",
"signal_label": sig["label"]})
return out
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k resolve_type_b -v
Expected: PASS
- [ ] Step 5: Commit
`bash
git add core/complement_specs.py tests/test_complement_specs.py
git commit -m "feat(complements): Type-B resolution (requirement->family top-N)"
`
---
Task 7: merge_complements — priority, dedup (incl. vs existing), cap, display_sequence
Files:
- Modify:
core/complement_specs.py - Test:
tests/test_complement_specs.py
Interfaces:
- Produces:
merge_complements(type_a: list[dict], type_b: list[dict], fallback: list[dict], existing_refs: set[str], cap: int = 20) -> list[dict]. Priority A → B → fallback; dedup byreference(first wins); drop anyreference in existing_refs; cap; addrank1..cap.
- [ ] Step 1: Write the failing test
`python
from core.complement_specs import merge_complements
def _c(ref, st): return {"reference": ref, "name": ref, "family_code": "F", "signal_type": st, "signal_label": st}
def test_merge_priority_dedup_cap():
a = [_c("X", "A_compat"), _c("Y", "A_compat")]
b = [_c("Y", "B_family"), _c("Z", "B_family")] # Y dup -> dropped (A wins)
fb = [_c("W", "cooc"), _c("OLD", "cooc")]
out = merge_complements(a, b, fb, existing_refs={"OLD"}, cap=3)
assert [c["reference"] for c in out] == ["X", "Y", "Z"] # OLD excluded, capped at 3
assert [c["rank"] for c in out] == [1, 2, 3]
assert out[0]["signal_type"] == "A_compat" and out[2]["signal_type"] == "B_family"
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k merge -v
Expected: FAIL.
- [ ] Step 3: Write minimal implementation
`python
def merge_complements(type_a: list[dict], type_b: list[dict], fallback: list[dict],
existing_refs: set[str], cap: int = 20) -> list[dict]:
"""Fusionne les 3 pistes par priorité A>B>fallback ; dédup par reference (1er gagne) ;
écarte les liens déjà présents (existing_refs) ; plafonne ; numérote rank 1..cap."""
out, seen = [], set()
for lane in (type_a, type_b, fallback):
for c in lane:
ref = c["reference"]
if ref in seen or ref in existing_refs:
continue
seen.add(ref)
out.append({c, "rank": len(out) + 1})
if len(out) >= cap:
return out
return out
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k merge -v
Expected: PASS
- [ ] Step 5: Run the full core suite + commit
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -v
Expected: all PASS.
`bash
git add core/complement_specs.py tests/test_complement_specs.py
git commit -m "feat(complements): merge (priority/dedup-vs-existing/cap/rank)"
`
---
Task 8: DB migration — table, scope column, JSON view
Files:
- Create:
database/migrations/003_product_complements.sql - Test: manual verification via psql/python.
Interfaces:
- Produces: table
product_complements, columnecom_products.complement_scope, viewv_product_complements_json.
- [ ] Step 1: Write the migration SQL
`sql
-- database/migrations/003_product_complements.sql
CREATE TABLE IF NOT EXISTS product_complements (
anchor_ref text NOT NULL,
complement_ref text NOT NULL,
complement_name text,
family_code text,
signal_type text NOT NULL, -- A_compat | B_family | cooc | family_llm
signal_label text,
rank int NOT NULL,
source_country text NOT NULL DEFAULT 'FR',
generated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (anchor_ref, complement_ref, source_country)
);
CREATE INDEX IF NOT EXISTS ix_prodcomp_anchor ON product_complements (anchor_ref, source_country);
CREATE INDEX IF NOT EXISTS ix_prodcomp_comp ON product_complements (complement_ref, source_country);
ALTER TABLE ecom_products ADD COLUMN IF NOT EXISTS complement_scope text; CREATE INDEX IF NOT EXISTS ix_ecomprod_scope ON ecom_products (complement_scope);
CREATE OR REPLACE VIEW v_product_complements_json AS
SELECT anchor_ref, source_country,
jsonb_agg(jsonb_build_object('name', complement_name, 'sku', complement_ref,
'family_code', family_code, 'signal', signal_type, 'rank', rank)
ORDER BY rank) AS complements
FROM product_complements
GROUP BY anchor_ref, source_country;
`
- [ ] Step 2: Apply the migration
Run: venv\Scripts\python.exe -c "from database.crm_db import CRMDatabase; import pathlib; sql=pathlib.Path('database/migrations/003_product_complements.sql').read_text(encoding='utf-8'); db=CRMDatabase(); db.execute(sql); print('applied')"
(If CRMDatabase has no execute, use its connection: with db.connect() as c, c.cursor() as cur: cur.execute(sql); c.commit().)
Expected: applied.
- [ ] Step 3: Verify objects exist
Run: venv\Scripts\python.exe -c "from database.crm_db import CRMDatabase; db=CRMDatabase(); print(db.query(\"SELECT to_regclass('product_complements') t, to_regclass('v_product_complements_json') v\"))"
Expected: both non-null.
- [ ] Step 4: Commit
`bash
git add database/migrations/003_product_complements.sql
git commit -m "feat(complements): DB migration (table + complement_scope + json view)"
`
---
Task 9: Anchor + existing-links readers, and fill target_family_map
Files:
- Create:
scripts/refresh_product_complements_specs.py(readers only in this task) - Modify:
config/complement_label_map.json(fill target subcategory codes) - Test:
tests/test_complement_specs.py(pure parsing of wide rows)
Interfaces:
- Produces (in the script):
read_anchor_scope(path) -> list[dict]({"sap","ref18","title","descriptif","brand","mfr_code","gtins"});read_existing_links(path) -> dict[str, set[str]](anchor SAP → set of already-linked complement SAPs, parsing the wideConsumable Webcolumns). - Produces (pure, in
complement_specs.py):parse_wide_existing(header: list, rows: list) -> dict[str, set[str]]for testability.
- [ ] Step 1: Write the failing test for
parse_wide_existing
`python
from core.complement_specs import parse_wide_existing
def test_parse_wide_existing():
header = ["SAP Code", "Local SAP Description",
"Consumable Web", "Local SAP Description", "Local Status Current Year", "Display Sequence",
"Consumable Web", "Local SAP Description", "Local Status Current Year", "Display Sequence"]
rows = [["471006", "BISCUITS", "4213285", "ASSORT", "Continued", "1", "", "", "", ""]]
out = parse_wide_existing(header, rows)
assert out == {"471006": {"4213285"}}
`
- [ ] Step 2: Run test to verify it fails
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k parse_wide_existing -v
Expected: FAIL.
- [ ] Step 3: Implement
parse_wide_existing(pure)
`python
def parse_wide_existing(header: list, rows: list) -> dict:
"""Parse le format large de Marie (SAP + N blocs [Consumable Web, desc, status, seq]).
Retourne {SAP ancre -> set(SAP compléments déjà liés)}. Ignore les cellules vides."""
cons_cols = [i for i, h in enumerate(header) if h == "Consumable Web"]
out: dict[str, set] = {}
for row in rows:
if not row or not row[0]:
continue
anchor = str(row[0]).strip()
links = {str(row[i]).strip() for i in cons_cols
if i < len(row) and row[i] not in (None, "") and str(row[i]).strip()}
if links:
out.setdefault(anchor, set()).update(links)
return out
`
- [ ] Step 4: Run test to verify it passes
Run: venv\Scripts\python.exe -m pytest tests/test_complement_specs.py -k parse_wide_existing -v
Expected: PASS
- [ ] Step 5: Add the file readers to the orchestrator script
`python
# scripts/refresh_product_complements_specs.py (start of file)
"""Refresh 'Produits complémentaires' — couche tech-spec + fallback v2, table + colonne.
Méthode : docs/superpowers/specs/2026-07-01-product-complementarity-design.md"""
from __future__ import annotations
import sys, json
from pathlib import Path
_REPO = Path(__file__).resolve().parent.parent
if str(_REPO) not in sys.path:
sys.path.insert(0, str(_REPO))
import openpyxl
from core.complement_specs import parse_wide_existing
MARIE = _REPO / "imports" / "marie" SCOPE_FILE = MARIE / "Datas pour produits associés 20260625.xlsx" EXISTING_FILE = MARIE / "Produits complémentaires déjà liés 20260526.xlsx" SCOPE_TAG = "marie-2026-06-25"
def ref18(sap: str) -> str: return str(sap).strip().replace(".", "").zfill(18)
def read_anchor_scope(path: Path = SCOPE_FILE) -> list[dict]: wb = openpyxl.load_workbook(path, read_only=True, data_only=True) ws = wb["Sheet1"]; it = ws.iter_rows(values_only=True); next(it) out = [] for r in it: if not r or not r[1]: continue out.append({"sap": str(r[1]).strip(), "ref18": ref18(r[1]), "title": r[2] or "", "descriptif": r[3] or "", "brand": r[5] or "", "mfr_code": r[6] or "", "gtins": [g for g in r[7:12] if g]}) wb.close() return out
def read_existing_links(path: Path = EXISTING_FILE) -> dict:
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = wb["Sheet1"]; rows = list(ws.iter_rows(values_only=True))
wb.close()
return parse_wide_existing(list(rows[0]), [list(r) for r in rows[1:]])
`
- [ ] Step 6: Smoke-run the readers + fill
target_family_map
Run: venv\Scripts\python.exe -c "from scripts.refresh_product_complements_specs import read_anchor_scope, read_existing_links; a=read_anchor_scope(); e=read_existing_links(); print('anchors',len(a),'existing',len(e)); print(a[0])"
Expected: anchors 18183 existing 5458 (approx).
Then query the real subcategory codes for the target families and paste them into config/complement_label_map.json target_family_map (battery / battery:AA / battery:AAA / cable / paper / cable_hub). Use:
venv\Scripts\python.exe -c "from database.crm_db import CRMDatabase; db=CRMDatabase(); [print(r) for r in db.query(\"SELECT subcategory_code, COUNT(*) n, MIN(product_description) ex FROM ecom_products WHERE source_country='FR' AND (product_description ILIKE '%%pile%%' OR product_description ILIKE '%%batter%%') GROUP BY 1 ORDER BY n DESC LIMIT 15\")]"
(repeat for cable/RJ-45, paper A4). Record chosen codes in the config.
- [ ] Step 7: Commit
`bash
git add scripts/refresh_product_complements_specs.py core/complement_specs.py config/complement_label_map.json tests/test_complement_specs.py
git commit -m "feat(complements): anchor + existing-links readers, fill target families"
`
---
Task 10: Orchestrator — pulls, run lanes, merge, write outputs
Files:
- Modify:
scripts/refresh_product_complements_specs.py - Test: end-to-end sample run (
--n-anchors 50).
Interfaces:
- Consumes: everything above +
core.complement_families(build_wide_rows,WIDE_HEADER,is_in_perimeter), the v2 fallback fromscripts.refresh_product_complements_v2. - Produces:
main(n_anchors: int)that writes the scope flag,product_complementsrows, and the Marie XLSX.
- [ ] Step 1: Add the Oracle attribute pull
`python
from database.oracle_db import OracleDatabase
def pull_attributes(ref18s: list[str]) -> dict:
"""{ref18 -> list[attr]} depuis FRANCE.T_PRODUCT_ATTRIBUTE (hygiène DL_DELETION_FLAG='N').
Batch par 1000 refs (IN-list)."""
ora = OracleDatabase(); out: dict[str, list] = {}
for i in range(0, len(ref18s), 1000):
chunk = ref18s[i:i+1000]
binds = ",".join(f":{j+1}" for j in range(len(chunk)))
rows = ora.query(f"""SELECT PRODUCT_REFERENCE ref, ATTRIBUTE_LABEL lbl,
ATTRIBUTE_VALUE val, VALUE_UNIT unit
FROM FRANCE.T_PRODUCT_ATTRIBUTE
WHERE COUNTRY_CODE='FR' AND DL_DELETION_FLAG='N'
AND PRODUCT_REFERENCE IN ({binds})""", chunk)
for r in rows:
out.setdefault(r["REF"], []).append(
{"label": r["LBL"], "value": r["VAL"] or "", "unit": r["UNIT"]})
ora.close()
return out
`
- [ ] Step 2: Add Postgres master + timescale sales pulls
`python
from database.crm_db import CRMDatabase
from database.timeseries_db import TimeseriesDatabase
def pull_products_and_sales(ref18s: list[str]) -> tuple[dict, dict]:
"""products_by_ref (masters) + family_skus (par family_code, avec ventes 365j)."""
crm = CRMDatabase()
prods = {}
for i in range(0, len(ref18s), 5000):
chunk = ref18s[i:i+5000]
for r in crm.query("""SELECT product_reference ref, product_description descr, web_title,
family_code, subcategory_code, brand, brand_group, group_supplier_id mfr,
not_salable_flag, not_visible_flag
FROM ecom_products WHERE source_country='FR' AND product_reference = ANY(%s)""", (chunk,)):
prods[r["ref"]] = {"reference": r["ref"], "description": r["descr"] or "",
"web_title": r["web_title"] or "", "family_code": r["family_code"] or "",
"subcategory_code": r["subcategory_code"] or "", "brand": r["brand"] or "",
"brand_group": r["brand_group"] or "",
"not_salable": r["not_salable_flag"] == "Y", "not_visible": r["not_visible_flag"] == "Y"}
# family_skus for the target families in the config (sales-joined) — see helper below
return prods, _family_skus_for_targets(crm)
`
(Implement _family_skus_for_targets to load, per target family_code in the config, its FR products + 365-day sales from timescale via ecom_order_lines bridged on product_reference, shaped as the sku dict with sales.)
- [ ] Step 3: Wire
main()— run lanes, merge, write
`python
def main(n_anchors: int = 0):
anchors = read_anchor_scope()
if n_anchors > 0:
anchors = anchors[:n_anchors]
existing = read_existing_links()
label_map, target_family_map = load_label_map(str(_REPO / "config" / "complement_label_map.json"))
ref18s = [a["ref18"] for a in anchors]
attrs = pull_attributes(ref18s)
products, family_skus = pull_products_and_sales(ref18s)
# build compat index from ALL anchors' consumable attrs (v1 catalog = the scope set)
consumables = [{"reference": a["ref18"], "attrs": attrs.get(a["ref18"], [])} for a in anchors]
compat_index = build_compat_index(consumables, label_map)
rows_out = [] # for product_complements
for a in anchors:
ref = a["ref18"]; ax = attrs.get(ref, [])
is_device = is_durable_anchor((a["title"] or "") + " " + (a["descriptif"] or ""))
anchor_a = {"reference": ref, "attrs": ax, "is_device": is_device,
"identity_keys": _identity_keys(a, ax, label_map)}
anchor_b = {"reference": ref, "attrs": ax,
"free_text": _free_text(a, ax)}
ta = resolve_type_a(anchor_a, compat_index, products, label_map)
tb = resolve_type_b(anchor_b, label_map, target_family_map, family_skus, top_n=5)
fb = run_v2_fallback(a) if not (ta or tb) else [] # reuse v2 engine
merged = merge_complements(ta, tb, fb, existing.get(a["sap"], set()), cap=20)
for c in merged:
rows_out.append((ref, c["reference"], c["name"], c["family_code"],
c["signal_type"], c["signal_label"], c["rank"]))
write_scope_flag([a["ref18"] for a in anchors])
write_product_complements(rows_out)
write_marie_xlsx(anchors, rows_out, products)
print(f"anchors={len(anchors)} rows={len(rows_out)}")
`
(Implement the small helpers _identity_keys (anchor's own model tokens / mfr code / gtins → compat keys via _compat_keys), _free_text (concat title+descriptif+Technical Details attr), run_v2_fallback (thin wrapper over scripts.refresh_product_complements_v2 family-graph→assemble_anchor_blocks), write_scope_flag (UPDATE ecom_products SET complement_scope=%s WHERE product_reference = ANY(%s)), write_product_complements (delete-then-insert for the anchor set), write_marie_xlsx (build_wide_rows + WIDE_HEADER). Add argparse --n-anchors.)
- [ ] Step 4: Sample end-to-end run (VPN up)
Run: set ORACLE_TNS_ADMIN=%CD%\oracle && venv\Scripts\python.exe -m scripts.refresh_product_complements_specs --n-anchors 50
Expected: prints anchors=50 rows=...; product_complements has rows; a Marie XLSX is written to exports/.
- [ ] Step 5: Verify a known device anchor
Run a query for a printer anchor in the sample and confirm its complements are its cartridges (Type A) or that a mouse anchor gets battery complements (Type B). Spot-check product_complements rows + signal_type.
- [ ] Step 6: Commit
`bash
git add scripts/refresh_product_complements_specs.py
git commit -m "feat(complements): orchestrator (pulls + lanes + merge + writes)"
`
---
Task 11: Full run + coverage report
Files:
- Modify:
scripts/refresh_product_complements_specs.py(add a coverage summary print)
- [ ] Step 1: Add coverage summary
At end of main, print: anchors total, how many got ≥1 complement, split by dominant signal_type (A/B/fallback/none), and how many anchors had a qualifying tech attribute — so we quantify tech-spec reach vs fallback reliance (spec §8).
- [ ] Step 2: Full run over the missing ~12.7k
Run: set ORACLE_TNS_ADMIN=%CD%\oracle && venv\Scripts\python.exe -m scripts.refresh_product_complements_specs
Expected: completes; coverage summary printed; ecom_products.complement_scope set for 18,183; product_complements populated.
- [ ] Step 3: Sanity-check the JSON view + Marie XLSX
Run: venv\Scripts\python.exe -c "from database.crm_db import CRMDatabase; db=CRMDatabase(); print(db.query('SELECT count(*) FROM product_complements'), db.query('SELECT complements FROM v_product_complements_json LIMIT 1'))"
Confirm the exported XLSX opens and matches Marie's wide header.
- [ ] Step 4: Commit
`bash
git add scripts/refresh_product_complements_specs.py
git commit -m "feat(complements): coverage report + full refresh"
`
---
Self-review notes
- Spec coverage: §2 inputs → Tasks 9/10; §3 lanes+merge → Tasks 5,6,7,10; §4 label map → Tasks 1,2; §5 resolution → Tasks 4,5,6 (+battery parse Task 3); §6 storage → Tasks 8,10,11; §7 modules/tests → all; §8 open items → Task 11 coverage + preserved-existing (Task 7 dedup).
- Type consistency:
canddict keys (reference,name,family_code,signal_type,signal_label) are produced identically byresolve_type_a/resolve_type_b/fallback and consumed bymerge_complements(which addsrank).ref18()normalization is the single anchor↔attribute↔master join key throughout. - Deferred detail (implement inline during Task 10, not placeholders):
_identity_keys,_free_text,_family_skus_for_targets,run_v2_fallback,write_*are I/O helpers whose signatures are fixed by their call sites in Step 3; each is a thin, well-scoped function over already-defined pure primitives.