{
  "name": "SHOPPING---Product-Discovery-WebSearch",
  "active": true,
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "product-discovery",
        "options": {
          "responseMode": "responseNode"
        }
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000001",
      "name": "Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// ============================================\n// VALIDATION INPUT & EXTRACTION API KEY\n// ============================================\n\nconst input = $input.first().json;\nconst body = input.body || {};\nconst headers = input.headers || {};\n\n// --- Validation des champs requis ---\nif (!body.items || !Array.isArray(body.items) || body.items.length === 0) {\n  return {\n    error: true,\n    error_code: 'MISSING_ITEMS',\n    message: 'Le champ \"items\" est requis et doit \u00eatre un tableau non vide'\n  };\n}\n\nif (!body.context || typeof body.context !== 'string') {\n  return {\n    error: true,\n    error_code: 'MISSING_CONTEXT',\n    message: 'Le champ \"context\" est requis'\n  };\n}\n\n// --- Extraction API Key depuis headers ---\n// Cherche dans diff\u00e9rents formats de headers (lowercase/mixed)\nconst apiKey = headers['x-openai-api-key'] \n  || headers['X-OpenAI-API-Key'] \n  || headers['X-Openai-Api-Key'];\n\nif (!apiKey) {\n  return {\n    error: true,\n    error_code: 'MISSING_API_KEY',\n    message: 'Header X-OpenAI-API-Key requis'\n  };\n}\n\n// --- Extraction Project ID ---\nconst projectId = headers['x-project-id'] \n  || headers['X-Project-ID'] \n  || headers['X-Project-Id']\n  || 'unknown';\n\n// --- Pr\u00e9paration des items ---\nconst items = body.items.map(item => ({\n  item_name: item.item_name || item.name || '',\n  category: item.category || 'ingredient'\n}));\n\n// --- Retour des donn\u00e9es valid\u00e9es ---\nreturn {\n  error: false,\n  data: {\n    items: items,\n    context: body.context,\n    discord_user_id: body.discord_user_id || null,\n    locale: body.locale || 'fr-FR',\n    project_id: projectId,\n    items_count: items.length\n  },\n  credentials: {\n    openai_api_key: apiKey\n  }\n};"
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000002",
      "name": "Validate Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "check-error",
              "leftValue": "={{ $json.error }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000003",
      "name": "Check Validation Error",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json.credentials.openai_api_key }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"gpt-4o\",\n  \"temperature\": 0.3,\n  \"response_format\": { \"type\": \"json_object\" },\n  \"messages\": [\n    {\n      \"role\": \"system\",\n      \"content\": \"Tu es un assistant shopping expert en cuisine fran\u00e7aise. Tu analyses une liste d'items pour les transformer en recommandations de produits concrets.\\n\\n## M\u00c9THODE DE RAISONNEMENT (4 COUCHES)\\n\\nPour CHAQUE item, applique ces 4 couches dans l'ordre :\\n\\n### Couche 1 - D\u00c9SAMBIGU\u00cfSATION LEXICALE\\n- Le terme est-il trop g\u00e9n\u00e9rique (hyperonyme) ?\\n- N\u00e9cessite-t-il une sp\u00e9cialisation minimale ?\\n- Exemple: \\\"farine\\\" est trop g\u00e9n\u00e9rique\\n\\n### Couche 2 - ANALYSE DU CONTEXTE D'USAGE\\n- Comment le contexte influence-t-il l'interpr\u00e9tation ?\\n- Passer d'un raisonnement 'objet' \u00e0 un raisonnement 'usage'\\n- Le contexte n'est PAS d\u00e9coratif, il est D\u00c9TERMINANT\\n\\n### Couche 3 - CONNAISSANCES CULTURELLES/PROBABILISTES\\n- Quelle est la probabilit\u00e9 par d\u00e9faut dans ce contexte ?\\n- Appliquer les r\u00e8gles de la cuisine fran\u00e7aise\\n- Les alternatives minoritaires doivent \u00eatre explicitement demand\u00e9es\\n- Exemple: cr\u00eapes (sans qualificatif) = farine de bl\u00e9 (98% des cas)\\n\\n### Couche 4 - PRINCIPE DE NON-SURSP\u00c9CIFICATION\\n- Ne JAMAIS \u00eatre plus pr\u00e9cis que ce que le contexte justifie\\n- Les variantes sont propos\u00e9es EN COMPL\u00c9MENT, pas comme v\u00e9rit\u00e9 unique\\n- Exemple: \\\"farine de bl\u00e9\\\" suffit, T45/T55 en variantes\\n\\n## R\u00c8GLES IMPORTANTES\\n- 1 item = 1 analyse compl\u00e8te\\n- Marques fran\u00e7aises connues pour search_query (Francine, Tefal, Lactel, Matines, etc.)\\n- Justification courte et claire pour l'utilisateur\\n- search_query doit \u00eatre efficace pour trouver un produit r\u00e9el\\n\\nRetourne UNIQUEMENT un JSON valide, sans texte avant ou apr\u00e8s.\"\n    },\n    {\n      \"role\": \"user\",\n      \"content\": \"## Items \u00e0 analyser\\n{{ JSON.stringify($json.data.items) }}\\n\\n## Contexte d'utilisation\\n{{ $json.data.context }}\\n\\n## Locale\\n{{ $json.data.locale }}\\n\\n## Format de sortie attendu\\nRetourne ce JSON EXACT :\\n{\\n  \\\"analysis\\\": [\\n    {\\n      \\\"original\\\": \\\"nom original de l'item\\\",\\n      \\\"refined\\\": \\\"nom affin\u00e9 apr\u00e8s raisonnement\\\",\\n      \\\"category\\\": \\\"ingredient ou ustensile\\\",\\n      \\\"reasoning\\\": {\\n        \\\"layers\\\": {\\n          \\\"lexical\\\": \\\"analyse de la couche 1\\\",\\n          \\\"context\\\": \\\"analyse de la couche 2\\\",\\n          \\\"knowledge\\\": \\\"analyse de la couche 3\\\",\\n          \\\"precision\\\": \\\"analyse de la couche 4\\\"\\n        },\\n        \\\"justification\\\": \\\"r\u00e9sum\u00e9 court pour affichage utilisateur\\\",\\n        \\\"confidence\\\": 0.92,\\n        \\\"variants\\\": [\\n          {\\\"name\\\": \\\"variante possible\\\", \\\"reason\\\": \\\"pourquoi cette variante\\\"}\\n        ]\\n      },\\n      \\\"search_query\\\": \\\"requ\u00eate optimis\u00e9e pour recherche produit avec marque fran\u00e7aise\\\"\\n    }\\n  ]\\n}\"\n    }\n  ]\n}",
        "options": {
          "timeout": 60000
        }
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000004",
      "name": "LLM Batch Reasoning",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        200
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// ============================================\n// PARSE LLM RESPONSE & PREPARE ITEMS\n// ============================================\n\nconst llmResponse = $input.first().json;\nconst validationData = $('Validate Input').first().json;\n\n// --- V\u00e9rifier les erreurs OpenAI ---\nif (llmResponse.error) {\n  return [{\n    json: {\n      error: true,\n      error_code: 'OPENAI_ERROR',\n      message: llmResponse.error.message || 'Erreur OpenAI',\n      details: llmResponse.error\n    }\n  }];\n}\n\n// --- Parser la r\u00e9ponse ---\nlet analysis;\ntry {\n  const content = llmResponse.choices[0].message.content;\n  const parsed = JSON.parse(content);\n  analysis = parsed.analysis;\n  \n  if (!analysis || !Array.isArray(analysis)) {\n    throw new Error('Missing analysis array in response');\n  }\n} catch (e) {\n  return [{\n    json: {\n      error: true,\n      error_code: 'PARSE_ERROR',\n      message: 'Impossible de parser la r\u00e9ponse LLM: ' + e.message\n    }\n  }];\n}\n\n// --- Retourner chaque item s\u00e9par\u00e9ment pour traitement parall\u00e8le ---\nconst items = analysis.map((item, index) => ({\n  json: {\n    index: index,\n    original: item.original,\n    refined: item.refined,\n    category: item.category,\n    reasoning: item.reasoning,\n    search_query: item.search_query,\n    // Passer les credentials pour les appels suivants\n    _credentials: validationData.credentials,\n    _context: validationData.data.context\n  }\n}));\n\nreturn items;"
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000005",
      "name": "Parse LLM Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        200
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "check-parse-error",
              "leftValue": "={{ $json.error }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000006",
      "name": "Check Parse Error",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1320,
        200
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json._credentials.openai_api_key }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"gpt-4o-mini\",\n  \"temperature\": 0.2,\n  \"response_format\": { \"type\": \"json_object\" },\n  \"messages\": [\n    {\n      \"role\": \"system\",\n      \"content\": \"Tu es un assistant qui recherche des produits \u00e0 acheter en France. Tu dois retourner UN SEUL produit avec toutes ses informations.\\n\\n## Crit\u00e8res de s\u00e9lection\\n- Marque connue et fiable (fran\u00e7aise de pr\u00e9f\u00e9rence)\\n- Prix milieu de gamme (pas le moins cher, pas le plus cher)\\n- Disponible en grande surface fran\u00e7aise ou en ligne\\n- Image produit existante\\n\\n## Magasins de r\u00e9f\u00e9rence\\n- Carrefour, Leclerc, Auchan, Intermarch\u00e9\\n- Amazon.fr, Cdiscount\\n- Boulanger, Darty (pour ustensiles)\\n\\nRetourne UNIQUEMENT un JSON valide.\"\n    },\n    {\n      \"role\": \"user\",\n      \"content\": \"## Produit recherch\u00e9\\n{{ $json.refined }}\\n\\n## Contexte\\n{{ $json._context }}\\n\\n## Requ\u00eate sugg\u00e9r\u00e9e\\n{{ $json.search_query }}\\n\\n## Format de sortie attendu\\nRetourne ce JSON EXACT avec des donn\u00e9es R\u00c9ALISTES pour des produits fran\u00e7ais :\\n{\\n  \\\"product\\\": {\\n    \\\"name\\\": \\\"Nom complet du produit avec marque et contenance/taille\\\",\\n    \\\"description\\\": \\\"Description courte du produit (max 100 caract\u00e8res)\\\",\\n    \\\"price_cents\\\": 189,\\n    \\\"price_display\\\": \\\"1,89 \u20ac\\\",\\n    \\\"currency\\\": \\\"EUR\\\",\\n    \\\"brand\\\": \\\"Nom de la marque\\\",\\n    \\\"seller\\\": \\\"Nom du magasin/site\\\",\\n    \\\"url\\\": \\\"https://www.exemple.fr/produit\\\",\\n    \\\"image_url\\\": \\\"https://www.exemple.fr/image.jpg\\\"\\n  }\\n}\\n\\nIMPORTANT:\\n- Prix r\u00e9alistes en centimes (1,89\u20ac = 189)\\n- URLs plausibles pour des sites fran\u00e7ais\\n- Marques r\u00e9elles existantes en France\"\n    }\n  ]\n}",
        "options": {
          "timeout": 30000,
          "batching": {
            "batch": {
              "batchSize": 10,
              "batchInterval": 100
            }
          }
        }
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000007",
      "name": "Search Products (Parallel)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        100
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// ============================================\n// AGGREGATE ALL RESULTS\n// ============================================\n\nconst searchResults = $input.all();\nconst parsedItems = $('Parse LLM Response').all();\nconst validationData = $('Validate Input').first().json;\n\nconst shoppingList = [];\nlet totalCents = 0;\n\nfor (let i = 0; i < searchResults.length; i++) {\n  const searchResult = searchResults[i].json;\n  const itemData = parsedItems[i].json;\n  \n  // --- Parser le produit trouv\u00e9 ---\n  let product = null;\n  \n  try {\n    if (searchResult.choices && searchResult.choices[0]) {\n      const content = searchResult.choices[0].message.content;\n      const parsed = JSON.parse(content);\n      product = parsed.product;\n    }\n  } catch (e) {\n    // Produit par d\u00e9faut si erreur\n    product = null;\n  }\n  \n  // --- Produit par d\u00e9faut si non trouv\u00e9 ---\n  if (!product) {\n    product = {\n      name: `${itemData.refined} (non trouv\u00e9)`,\n      description: 'Produit non disponible',\n      price_cents: 0,\n      price_display: '-- \u20ac',\n      currency: 'EUR',\n      brand: 'N/A',\n      seller: 'N/A',\n      url: null,\n      image_url: null\n    };\n  }\n  \n  // --- Calcul du total ---\n  if (product.price_cents && typeof product.price_cents === 'number') {\n    totalCents += product.price_cents;\n  }\n  \n  // --- Construire l'item de la liste ---\n  shoppingList.push({\n    original_item: itemData.original,\n    refined_item: itemData.refined,\n    category: itemData.category,\n    reasoning: itemData.reasoning,\n    product: product\n  });\n}\n\n// --- Formater le total ---\nconst totalEuros = (totalCents / 100).toFixed(2).replace('.', ',');\nconst totalDisplay = `${totalEuros} \u20ac`;\n\n// --- Construire la r\u00e9ponse finale ---\nreturn {\n  success: true,\n  context: validationData.data.context,\n  project_id: validationData.data.project_id,\n  discord_user_id: validationData.data.discord_user_id,\n  locale: validationData.data.locale,\n  items_count: shoppingList.length,\n  shopping_list: shoppingList,\n  total_cents: totalCents,\n  total_display: totalDisplay,\n  metadata: {\n    workflow_version: '2.0',\n    reasoning_model: 'gpt-4o',\n    search_model: 'gpt-4o-mini',\n    timestamp: new Date().toISOString()\n  }\n};"
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000008",
      "name": "Aggregate Results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        100
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": 200,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000009",
      "name": "Respond Success",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2200,
        100
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={\n  \"success\": false,\n  \"error_code\": \"{{ $json.error_code || 'VALIDATION_ERROR' }}\",\n  \"message\": \"{{ $json.message || 'Erreur de validation' }}\"\n}",
        "options": {
          "responseCode": 400,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000010",
      "name": "Respond Validation Error",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        660,
        400
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={\n  \"success\": false,\n  \"error_code\": \"{{ $json.error_code || 'PROCESSING_ERROR' }}\",\n  \"message\": \"{{ $json.message || 'Erreur de traitement' }}\",\n  \"details\": {{ JSON.stringify($json.details || null) }}\n}",
        "options": {
          "responseCode": 500,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000011",
      "name": "Respond Processing Error",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1540,
        300
      ]
    },
    {
      "parameters": {
        "content": "# \ud83d\uded2 Product Discovery Workflow v2.0\n\n## Architecture\n```\nWebhook \u2192 Validate \u2192 LLM Batch (4 couches) \u2192 Split \u2192 Search // \u2192 Aggregate \u2192 Response\n```\n\n## Endpoint\n```\nPOST /webhook/product-discovery\n```\n\n## Headers requis\n| Header | Description |\n|--------|-------------|\n| `X-OpenAI-API-Key` | Cl\u00e9 API OpenAI |\n| `X-Project-ID` | ID du plugin |\n\n## Body\n```json\n{\n  \"items\": [\n    {\"item_name\": \"farine\", \"category\": \"ingredient\"}\n  ],\n  \"context\": \"Pour faire des cr\u00eapes\",\n  \"discord_user_id\": \"123...\",\n  \"locale\": \"fr-FR\"\n}\n```\n\n## Raisonnement 4 couches\n1. **Lexical** - D\u00e9sambigu\u00efsation\n2. **Context** - Analyse usage\n3. **Knowledge** - Probabilit\u00e9s culturelles\n4. **Precision** - Non-sursp\u00e9cification\n\n## Output\n- `shopping_list` avec raisonnement\n- 1 produit par item\n- `total_cents` / `total_display`",
        "height": 580,
        "width": 340,
        "color": 5
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000012",
      "name": "Documentation",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -340,
        60
      ]
    },
    {
      "parameters": {
        "content": "## \ud83d\udd34 Erreurs Validation\n- MISSING_ITEMS\n- MISSING_CONTEXT  \n- MISSING_API_KEY\n\n\u2192 HTTP 400",
        "height": 140,
        "width": 200,
        "color": 3
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000013",
      "name": "Note Errors 400",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        640,
        520
      ]
    },
    {
      "parameters": {
        "content": "## \ud83d\udfe0 Erreurs Processing\n- OPENAI_ERROR\n- PARSE_ERROR\n\n\u2192 HTTP 500",
        "height": 140,
        "width": 200,
        "color": 6
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000014",
      "name": "Note Errors 500",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1520,
        420
      ]
    },
    {
      "parameters": {
        "content": "## \ud83e\udde0 LLM Batch\n- Mod\u00e8le: **gpt-4o**\n- Analyse TOUS les items\n- Raisonnement 4 couches\n- 1 seul appel API",
        "height": 140,
        "width": 220,
        "color": 4
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000015",
      "name": "Note LLM Batch",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        860,
        340
      ]
    },
    {
      "parameters": {
        "content": "## \ud83d\udd0d Search Parallel\n- Mod\u00e8le: **gpt-4o-mini**\n- N appels en parall\u00e8le\n- 1 produit par item\n- Batch size: 10",
        "height": 140,
        "width": 220,
        "color": 4
      },
      "id": "a1b2c3d4-0001-0001-0001-000000000016",
      "name": "Note Search Parallel",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1740,
        240
      ]
    }
  ],
  "connections": {
    "Webhook Trigger": {
      "main": [
        [
          {
            "node": "Validate Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Input": {
      "main": [
        [
          {
            "node": "Check Validation Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Validation Error": {
      "main": [
        [
          {
            "node": "Respond Validation Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "LLM Batch Reasoning",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM Batch Reasoning": {
      "main": [
        [
          {
            "node": "Parse LLM Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Parse LLM Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse LLM Response": {
      "main": [
        [
          {
            "node": "Check Parse Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Parse Error": {
      "main": [
        [
          {
            "node": "Respond Processing Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Search Products (Parallel)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search Products (Parallel)": {
      "main": [
        [
          {
            "node": "Aggregate Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Results": {
      "main": [
        [
          {
            "node": "Respond Success",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false
  },
  "tags": [],
  "activeVersion": {
    "updatedAt": "2026-02-02T17:55:15.060Z",
    "createdAt": "2026-02-02T17:55:15.060Z",
    "versionId": "82c50ad9-3d41-4ed0-bfca-670111fdff1c",
    "workflowId": "39uw0mdSU5IPTJys",
    "nodes": [
      {
        "parameters": {
          "httpMethod": "POST",
          "path": "product-discovery",
          "options": {
            "responseMode": "responseNode"
          }
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000001",
        "name": "Webhook Trigger",
        "type": "n8n-nodes-base.webhook",
        "typeVersion": 2,
        "position": [
          0,
          300
        ],
        "webhookId": "product-discovery-webhook"
      },
      {
        "parameters": {
          "jsCode": "// ============================================\n// VALIDATION INPUT & EXTRACTION API KEY\n// ============================================\n\nconst input = $input.first().json;\nconst body = input.body || {};\nconst headers = input.headers || {};\n\n// --- Validation des champs requis ---\nif (!body.items || !Array.isArray(body.items) || body.items.length === 0) {\n  return {\n    error: true,\n    error_code: 'MISSING_ITEMS',\n    message: 'Le champ \"items\" est requis et doit \u00eatre un tableau non vide'\n  };\n}\n\nif (!body.context || typeof body.context !== 'string') {\n  return {\n    error: true,\n    error_code: 'MISSING_CONTEXT',\n    message: 'Le champ \"context\" est requis'\n  };\n}\n\n// --- Extraction API Key depuis headers ---\n// Cherche dans diff\u00e9rents formats de headers (lowercase/mixed)\nconst apiKey = headers['x-openai-api-key'] \n  || headers['X-OpenAI-API-Key'] \n  || headers['X-Openai-Api-Key'];\n\nif (!apiKey) {\n  return {\n    error: true,\n    error_code: 'MISSING_API_KEY',\n    message: 'Header X-OpenAI-API-Key requis'\n  };\n}\n\n// --- Extraction Project ID ---\nconst projectId = headers['x-project-id'] \n  || headers['X-Project-ID'] \n  || headers['X-Project-Id']\n  || 'unknown';\n\n// --- Pr\u00e9paration des items ---\nconst items = body.items.map(item => ({\n  item_name: item.item_name || item.name || '',\n  category: item.category || 'ingredient'\n}));\n\n// --- Retour des donn\u00e9es valid\u00e9es ---\nreturn {\n  error: false,\n  data: {\n    items: items,\n    context: body.context,\n    discord_user_id: body.discord_user_id || null,\n    locale: body.locale || 'fr-FR',\n    project_id: projectId,\n    items_count: items.length\n  },\n  credentials: {\n    openai_api_key: apiKey\n  }\n};"
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000002",
        "name": "Validate Input",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          220,
          300
        ]
      },
      {
        "parameters": {
          "conditions": {
            "options": {
              "caseSensitive": true,
              "leftValue": "",
              "typeValidation": "strict"
            },
            "conditions": [
              {
                "id": "check-error",
                "leftValue": "={{ $json.error }}",
                "rightValue": true,
                "operator": {
                  "type": "boolean",
                  "operation": "equals"
                }
              }
            ],
            "combinator": "and"
          },
          "options": {}
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000003",
        "name": "Check Validation Error",
        "type": "n8n-nodes-base.if",
        "typeVersion": 2,
        "position": [
          440,
          300
        ]
      },
      {
        "parameters": {
          "method": "POST",
          "url": "https://api.openai.com/v1/chat/completions",
          "sendHeaders": true,
          "headerParameters": {
            "parameters": [
              {
                "name": "Authorization",
                "value": "=Bearer {{ $json.credentials.openai_api_key }}"
              },
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          },
          "sendBody": true,
          "specifyBody": "json",
          "jsonBody": "={\n  \"model\": \"gpt-4o\",\n  \"temperature\": 0.3,\n  \"response_format\": { \"type\": \"json_object\" },\n  \"messages\": [\n    {\n      \"role\": \"system\",\n      \"content\": \"Tu es un assistant shopping expert en cuisine fran\u00e7aise. Tu analyses une liste d'items pour les transformer en recommandations de produits concrets.\\n\\n## M\u00c9THODE DE RAISONNEMENT (4 COUCHES)\\n\\nPour CHAQUE item, applique ces 4 couches dans l'ordre :\\n\\n### Couche 1 - D\u00c9SAMBIGU\u00cfSATION LEXICALE\\n- Le terme est-il trop g\u00e9n\u00e9rique (hyperonyme) ?\\n- N\u00e9cessite-t-il une sp\u00e9cialisation minimale ?\\n- Exemple: \\\"farine\\\" est trop g\u00e9n\u00e9rique\\n\\n### Couche 2 - ANALYSE DU CONTEXTE D'USAGE\\n- Comment le contexte influence-t-il l'interpr\u00e9tation ?\\n- Passer d'un raisonnement 'objet' \u00e0 un raisonnement 'usage'\\n- Le contexte n'est PAS d\u00e9coratif, il est D\u00c9TERMINANT\\n\\n### Couche 3 - CONNAISSANCES CULTURELLES/PROBABILISTES\\n- Quelle est la probabilit\u00e9 par d\u00e9faut dans ce contexte ?\\n- Appliquer les r\u00e8gles de la cuisine fran\u00e7aise\\n- Les alternatives minoritaires doivent \u00eatre explicitement demand\u00e9es\\n- Exemple: cr\u00eapes (sans qualificatif) = farine de bl\u00e9 (98% des cas)\\n\\n### Couche 4 - PRINCIPE DE NON-SURSP\u00c9CIFICATION\\n- Ne JAMAIS \u00eatre plus pr\u00e9cis que ce que le contexte justifie\\n- Les variantes sont propos\u00e9es EN COMPL\u00c9MENT, pas comme v\u00e9rit\u00e9 unique\\n- Exemple: \\\"farine de bl\u00e9\\\" suffit, T45/T55 en variantes\\n\\n## R\u00c8GLES IMPORTANTES\\n- 1 item = 1 analyse compl\u00e8te\\n- Marques fran\u00e7aises connues pour search_query (Francine, Tefal, Lactel, Matines, etc.)\\n- Justification courte et claire pour l'utilisateur\\n- search_query doit \u00eatre efficace pour trouver un produit r\u00e9el\\n\\nRetourne UNIQUEMENT un JSON valide, sans texte avant ou apr\u00e8s.\"\n    },\n    {\n      \"role\": \"user\",\n      \"content\": \"## Items \u00e0 analyser\\n{{ JSON.stringify($json.data.items) }}\\n\\n## Contexte d'utilisation\\n{{ $json.data.context }}\\n\\n## Locale\\n{{ $json.data.locale }}\\n\\n## Format de sortie attendu\\nRetourne ce JSON EXACT :\\n{\\n  \\\"analysis\\\": [\\n    {\\n      \\\"original\\\": \\\"nom original de l'item\\\",\\n      \\\"refined\\\": \\\"nom affin\u00e9 apr\u00e8s raisonnement\\\",\\n      \\\"category\\\": \\\"ingredient ou ustensile\\\",\\n      \\\"reasoning\\\": {\\n        \\\"layers\\\": {\\n          \\\"lexical\\\": \\\"analyse de la couche 1\\\",\\n          \\\"context\\\": \\\"analyse de la couche 2\\\",\\n          \\\"knowledge\\\": \\\"analyse de la couche 3\\\",\\n          \\\"precision\\\": \\\"analyse de la couche 4\\\"\\n        },\\n        \\\"justification\\\": \\\"r\u00e9sum\u00e9 court pour affichage utilisateur\\\",\\n        \\\"confidence\\\": 0.92,\\n        \\\"variants\\\": [\\n          {\\\"name\\\": \\\"variante possible\\\", \\\"reason\\\": \\\"pourquoi cette variante\\\"}\\n        ]\\n      },\\n      \\\"search_query\\\": \\\"requ\u00eate optimis\u00e9e pour recherche produit avec marque fran\u00e7aise\\\"\\n    }\\n  ]\\n}\"\n    }\n  ]\n}",
          "options": {
            "timeout": 60000
          }
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000004",
        "name": "LLM Batch Reasoning",
        "type": "n8n-nodes-base.httpRequest",
        "typeVersion": 4.2,
        "position": [
          880,
          200
        ],
        "onError": "continueErrorOutput"
      },
      {
        "parameters": {
          "jsCode": "// ============================================\n// PARSE LLM RESPONSE & PREPARE ITEMS\n// ============================================\n\nconst llmResponse = $input.first().json;\nconst validationData = $('Validate Input').first().json;\n\n// --- V\u00e9rifier les erreurs OpenAI ---\nif (llmResponse.error) {\n  return [{\n    json: {\n      error: true,\n      error_code: 'OPENAI_ERROR',\n      message: llmResponse.error.message || 'Erreur OpenAI',\n      details: llmResponse.error\n    }\n  }];\n}\n\n// --- Parser la r\u00e9ponse ---\nlet analysis;\ntry {\n  const content = llmResponse.choices[0].message.content;\n  const parsed = JSON.parse(content);\n  analysis = parsed.analysis;\n  \n  if (!analysis || !Array.isArray(analysis)) {\n    throw new Error('Missing analysis array in response');\n  }\n} catch (e) {\n  return [{\n    json: {\n      error: true,\n      error_code: 'PARSE_ERROR',\n      message: 'Impossible de parser la r\u00e9ponse LLM: ' + e.message\n    }\n  }];\n}\n\n// --- Retourner chaque item s\u00e9par\u00e9ment pour traitement parall\u00e8le ---\nconst items = analysis.map((item, index) => ({\n  json: {\n    index: index,\n    original: item.original,\n    refined: item.refined,\n    category: item.category,\n    reasoning: item.reasoning,\n    search_query: item.search_query,\n    // Passer les credentials pour les appels suivants\n    _credentials: validationData.credentials,\n    _context: validationData.data.context\n  }\n}));\n\nreturn items;"
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000005",
        "name": "Parse LLM Response",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          1100,
          200
        ]
      },
      {
        "parameters": {
          "conditions": {
            "options": {
              "caseSensitive": true,
              "leftValue": "",
              "typeValidation": "strict"
            },
            "conditions": [
              {
                "id": "check-parse-error",
                "leftValue": "={{ $json.error }}",
                "rightValue": true,
                "operator": {
                  "type": "boolean",
                  "operation": "equals"
                }
              }
            ],
            "combinator": "and"
          },
          "options": {}
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000006",
        "name": "Check Parse Error",
        "type": "n8n-nodes-base.if",
        "typeVersion": 2,
        "position": [
          1320,
          200
        ]
      },
      {
        "parameters": {
          "method": "POST",
          "url": "https://api.openai.com/v1/chat/completions",
          "sendHeaders": true,
          "headerParameters": {
            "parameters": [
              {
                "name": "Authorization",
                "value": "=Bearer {{ $json._credentials.openai_api_key }}"
              },
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          },
          "sendBody": true,
          "specifyBody": "json",
          "jsonBody": "={\n  \"model\": \"gpt-4o-mini\",\n  \"temperature\": 0.2,\n  \"response_format\": { \"type\": \"json_object\" },\n  \"messages\": [\n    {\n      \"role\": \"system\",\n      \"content\": \"Tu es un assistant qui recherche des produits \u00e0 acheter en France. Tu dois retourner UN SEUL produit avec toutes ses informations.\\n\\n## Crit\u00e8res de s\u00e9lection\\n- Marque connue et fiable (fran\u00e7aise de pr\u00e9f\u00e9rence)\\n- Prix milieu de gamme (pas le moins cher, pas le plus cher)\\n- Disponible en grande surface fran\u00e7aise ou en ligne\\n- Image produit existante\\n\\n## Magasins de r\u00e9f\u00e9rence\\n- Carrefour, Leclerc, Auchan, Intermarch\u00e9\\n- Amazon.fr, Cdiscount\\n- Boulanger, Darty (pour ustensiles)\\n\\nRetourne UNIQUEMENT un JSON valide.\"\n    },\n    {\n      \"role\": \"user\",\n      \"content\": \"## Produit recherch\u00e9\\n{{ $json.refined }}\\n\\n## Contexte\\n{{ $json._context }}\\n\\n## Requ\u00eate sugg\u00e9r\u00e9e\\n{{ $json.search_query }}\\n\\n## Format de sortie attendu\\nRetourne ce JSON EXACT avec des donn\u00e9es R\u00c9ALISTES pour des produits fran\u00e7ais :\\n{\\n  \\\"product\\\": {\\n    \\\"name\\\": \\\"Nom complet du produit avec marque et contenance/taille\\\",\\n    \\\"description\\\": \\\"Description courte du produit (max 100 caract\u00e8res)\\\",\\n    \\\"price_cents\\\": 189,\\n    \\\"price_display\\\": \\\"1,89 \u20ac\\\",\\n    \\\"currency\\\": \\\"EUR\\\",\\n    \\\"brand\\\": \\\"Nom de la marque\\\",\\n    \\\"seller\\\": \\\"Nom du magasin/site\\\",\\n    \\\"url\\\": \\\"https://www.exemple.fr/produit\\\",\\n    \\\"image_url\\\": \\\"https://www.exemple.fr/image.jpg\\\"\\n  }\\n}\\n\\nIMPORTANT:\\n- Prix r\u00e9alistes en centimes (1,89\u20ac = 189)\\n- URLs plausibles pour des sites fran\u00e7ais\\n- Marques r\u00e9elles existantes en France\"\n    }\n  ]\n}",
          "options": {
            "timeout": 30000,
            "batching": {
              "batch": {
                "batchSize": 10,
                "batchInterval": 100
              }
            }
          }
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000007",
        "name": "Search Products (Parallel)",
        "type": "n8n-nodes-base.httpRequest",
        "typeVersion": 4.2,
        "position": [
          1760,
          100
        ],
        "onError": "continueErrorOutput"
      },
      {
        "parameters": {
          "jsCode": "// ============================================\n// AGGREGATE ALL RESULTS\n// ============================================\n\nconst searchResults = $input.all();\nconst parsedItems = $('Parse LLM Response').all();\nconst validationData = $('Validate Input').first().json;\n\nconst shoppingList = [];\nlet totalCents = 0;\n\nfor (let i = 0; i < searchResults.length; i++) {\n  const searchResult = searchResults[i].json;\n  const itemData = parsedItems[i].json;\n  \n  // --- Parser le produit trouv\u00e9 ---\n  let product = null;\n  \n  try {\n    if (searchResult.choices && searchResult.choices[0]) {\n      const content = searchResult.choices[0].message.content;\n      const parsed = JSON.parse(content);\n      product = parsed.product;\n    }\n  } catch (e) {\n    // Produit par d\u00e9faut si erreur\n    product = null;\n  }\n  \n  // --- Produit par d\u00e9faut si non trouv\u00e9 ---\n  if (!product) {\n    product = {\n      name: `${itemData.refined} (non trouv\u00e9)`,\n      description: 'Produit non disponible',\n      price_cents: 0,\n      price_display: '-- \u20ac',\n      currency: 'EUR',\n      brand: 'N/A',\n      seller: 'N/A',\n      url: null,\n      image_url: null\n    };\n  }\n  \n  // --- Calcul du total ---\n  if (product.price_cents && typeof product.price_cents === 'number') {\n    totalCents += product.price_cents;\n  }\n  \n  // --- Construire l'item de la liste ---\n  shoppingList.push({\n    original_item: itemData.original,\n    refined_item: itemData.refined,\n    category: itemData.category,\n    reasoning: itemData.reasoning,\n    product: product\n  });\n}\n\n// --- Formater le total ---\nconst totalEuros = (totalCents / 100).toFixed(2).replace('.', ',');\nconst totalDisplay = `${totalEuros} \u20ac`;\n\n// --- Construire la r\u00e9ponse finale ---\nreturn {\n  success: true,\n  context: validationData.data.context,\n  project_id: validationData.data.project_id,\n  discord_user_id: validationData.data.discord_user_id,\n  locale: validationData.data.locale,\n  items_count: shoppingList.length,\n  shopping_list: shoppingList,\n  total_cents: totalCents,\n  total_display: totalDisplay,\n  metadata: {\n    workflow_version: '2.0',\n    reasoning_model: 'gpt-4o',\n    search_model: 'gpt-4o-mini',\n    timestamp: new Date().toISOString()\n  }\n};"
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000008",
        "name": "Aggregate Results",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          1980,
          100
        ]
      },
      {
        "parameters": {
          "respondWith": "json",
          "responseBody": "={{ $json }}",
          "options": {
            "responseCode": 200,
            "responseHeaders": {
              "entries": [
                {
                  "name": "Content-Type",
                  "value": "application/json"
                }
              ]
            }
          }
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000009",
        "name": "Respond Success",
        "type": "n8n-nodes-base.respondToWebhook",
        "typeVersion": 1.1,
        "position": [
          2200,
          100
        ]
      },
      {
        "parameters": {
          "respondWith": "json",
          "responseBody": "={\n  \"success\": false,\n  \"error_code\": \"{{ $json.error_code || 'VALIDATION_ERROR' }}\",\n  \"message\": \"{{ $json.message || 'Erreur de validation' }}\"\n}",
          "options": {
            "responseCode": 400,
            "responseHeaders": {
              "entries": [
                {
                  "name": "Content-Type",
                  "value": "application/json"
                }
              ]
            }
          }
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000010",
        "name": "Respond Validation Error",
        "type": "n8n-nodes-base.respondToWebhook",
        "typeVersion": 1.1,
        "position": [
          660,
          400
        ]
      },
      {
        "parameters": {
          "respondWith": "json",
          "responseBody": "={\n  \"success\": false,\n  \"error_code\": \"{{ $json.error_code || 'PROCESSING_ERROR' }}\",\n  \"message\": \"{{ $json.message || 'Erreur de traitement' }}\",\n  \"details\": {{ JSON.stringify($json.details || null) }}\n}",
          "options": {
            "responseCode": 500,
            "responseHeaders": {
              "entries": [
                {
                  "name": "Content-Type",
                  "value": "application/json"
                }
              ]
            }
          }
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000011",
        "name": "Respond Processing Error",
        "type": "n8n-nodes-base.respondToWebhook",
        "typeVersion": 1.1,
        "position": [
          1540,
          300
        ]
      },
      {
        "parameters": {
          "content": "# \ud83d\uded2 Product Discovery Workflow v2.0\n\n## Architecture\n```\nWebhook \u2192 Validate \u2192 LLM Batch (4 couches) \u2192 Split \u2192 Search // \u2192 Aggregate \u2192 Response\n```\n\n## Endpoint\n```\nPOST /webhook/product-discovery\n```\n\n## Headers requis\n| Header | Description |\n|--------|-------------|\n| `X-OpenAI-API-Key` | Cl\u00e9 API OpenAI |\n| `X-Project-ID` | ID du plugin |\n\n## Body\n```json\n{\n  \"items\": [\n    {\"item_name\": \"farine\", \"category\": \"ingredient\"}\n  ],\n  \"context\": \"Pour faire des cr\u00eapes\",\n  \"discord_user_id\": \"123...\",\n  \"locale\": \"fr-FR\"\n}\n```\n\n## Raisonnement 4 couches\n1. **Lexical** - D\u00e9sambigu\u00efsation\n2. **Context** - Analyse usage\n3. **Knowledge** - Probabilit\u00e9s culturelles\n4. **Precision** - Non-sursp\u00e9cification\n\n## Output\n- `shopping_list` avec raisonnement\n- 1 produit par item\n- `total_cents` / `total_display`",
          "height": 580,
          "width": 340,
          "color": 5
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000012",
        "name": "Documentation",
        "type": "n8n-nodes-base.stickyNote",
        "typeVersion": 1,
        "position": [
          -340,
          60
        ]
      },
      {
        "parameters": {
          "content": "## \ud83d\udd34 Erreurs Validation\n- MISSING_ITEMS\n- MISSING_CONTEXT  \n- MISSING_API_KEY\n\n\u2192 HTTP 400",
          "height": 140,
          "width": 200,
          "color": 3
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000013",
        "name": "Note Errors 400",
        "type": "n8n-nodes-base.stickyNote",
        "typeVersion": 1,
        "position": [
          640,
          520
        ]
      },
      {
        "parameters": {
          "content": "## \ud83d\udfe0 Erreurs Processing\n- OPENAI_ERROR\n- PARSE_ERROR\n\n\u2192 HTTP 500",
          "height": 140,
          "width": 200,
          "color": 6
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000014",
        "name": "Note Errors 500",
        "type": "n8n-nodes-base.stickyNote",
        "typeVersion": 1,
        "position": [
          1520,
          420
        ]
      },
      {
        "parameters": {
          "content": "## \ud83e\udde0 LLM Batch\n- Mod\u00e8le: **gpt-4o**\n- Analyse TOUS les items\n- Raisonnement 4 couches\n- 1 seul appel API",
          "height": 140,
          "width": 220,
          "color": 4
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000015",
        "name": "Note LLM Batch",
        "type": "n8n-nodes-base.stickyNote",
        "typeVersion": 1,
        "position": [
          860,
          340
        ]
      },
      {
        "parameters": {
          "content": "## \ud83d\udd0d Search Parallel\n- Mod\u00e8le: **gpt-4o-mini**\n- N appels en parall\u00e8le\n- 1 produit par item\n- Batch size: 10",
          "height": 140,
          "width": 220,
          "color": 4
        },
        "id": "a1b2c3d4-0001-0001-0001-000000000016",
        "name": "Note Search Parallel",
        "type": "n8n-nodes-base.stickyNote",
        "typeVersion": 1,
        "position": [
          1740,
          240
        ]
      }
    ],
    "connections": {
      "Webhook Trigger": {
        "main": [
          [
            {
              "node": "Validate Input",
              "type": "main",
              "index": 0
            }
          ]
        ]
      },
      "Validate Input": {
        "main": [
          [
            {
              "node": "Check Validation Error",
              "type": "main",
              "index": 0
            }
          ]
        ]
      },
      "Check Validation Error": {
        "main": [
          [
            {
              "node": "Respond Validation Error",
              "type": "main",
              "index": 0
            }
          ],
          [
            {
              "node": "LLM Batch Reasoning",
              "type": "main",
              "index": 0
            }
          ]
        ]
      },
      "LLM Batch Reasoning": {
        "main": [
          [
            {
              "node": "Parse LLM Response",
              "type": "main",
              "index": 0
            }
          ],
          [
            {
              "node": "Parse LLM Response",
              "type": "main",
              "index": 0
            }
          ]
        ]
      },
      "Parse LLM Response": {
        "main": [
          [
            {
              "node": "Check Parse Error",
              "type": "main",
              "index": 0
            }
          ]
        ]
      },
      "Check Parse Error": {
        "main": [
          [
            {
              "node": "Respond Processing Error",
              "type": "main",
              "index": 0
            }
          ],
          [
            {
              "node": "Search Products (Parallel)",
              "type": "main",
              "index": 0
            }
          ]
        ]
      },
      "Search Products (Parallel)": {
        "main": [
          [
            {
              "node": "Aggregate Results",
              "type": "main",
              "index": 0
            }
          ]
        ]
      },
      "Aggregate Results": {
        "main": [
          [
            {
              "node": "Respond Success",
              "type": "main",
              "index": 0
            }
          ]
        ]
      }
    },
    "authors": "import",
    "name": null,
    "description": null,
    "autosaved": false,
    "workflowPublishHistory": []
  }
}