AutomationFlowsAI & RAG › Generate Photo-based Construction Cost Estimates with Gpt-4 Vision and Ddc Cwicr

Generate Photo-based Construction Cost Estimates with Gpt-4 Vision and Ddc Cwicr

ByArtem Boiko @datadrivenconstruction on n8n.io

Upload a construction photo via web form → get a detailed cost estimate with work breakdown, resource costs, and professional HTML report. Powered by GPT-4 Vision and the open-source DDC CWICR database (55,000+ work items). Site managers who need quick estimates from mobile…

Event trigger★★★★★ complexityAI-powered39 nodesForm TriggerChain LlmOpenAI ChatQdrant Vector StoreOpenAI Embeddings
AI & RAG Trigger: Event Nodes: 39 Complexity: ★★★★★ AI nodes: yes Added:

This workflow corresponds to n8n.io template #12175 — we link there as the canonical source.

This workflow follows the Chainllm → OpenAI Embeddings recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "OvYK6agG8RmwF6Gq",
  "name": "Photo Cost Estimate Pro v2.0",
  "tags": [],
  "nodes": [
    {
      "id": "0f4cbe14-7ba4-46c5-81d2-6b1d4482efcc",
      "name": "Photo Upload Form",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        29104,
        400
      ],
      "parameters": {
        "options": {},
        "formTitle": "\ud83d\udcf8 Photo Cost Estimate Pro v2",
        "formFields": {
          "values": [
            {
              "fieldType": "file",
              "fieldLabel": "\ud83d\udcf7 Upload Photo",
              "multipleFiles": false,
              "requiredField": true,
              "acceptFileTypes": ".jpg,.jpeg,.png,.webp"
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "\ud83c\udf0d Region & Language",
              "fieldOptions": {
                "values": [
                  {
                    "option": "\ud83c\udde9\ud83c\uddea German - Berlin (EUR \u20ac)"
                  },
                  {
                    "option": "\ud83c\uddec\ud83c\udde7 English - Toronto (CAD $)"
                  },
                  {
                    "option": "\ud83c\uddf7\ud83c\uddfa Russian - St. Petersburg (RUB \u20bd)"
                  },
                  {
                    "option": "\ud83c\uddea\ud83c\uddf8 Spanish - Barcelona (EUR \u20ac)"
                  },
                  {
                    "option": "\ud83c\uddeb\ud83c\uddf7 French - Paris (EUR \u20ac)"
                  },
                  {
                    "option": "\ud83c\udde7\ud83c\uddf7 Portuguese - S\u00e3o Paulo (BRL R$)"
                  },
                  {
                    "option": "\ud83c\udde8\ud83c\uddf3 Chinese - Shanghai (CNY \u00a5)"
                  },
                  {
                    "option": "\ud83c\udde6\ud83c\uddea Arabic - Dubai (AED \u062f.\u0625)"
                  },
                  {
                    "option": "\ud83c\uddee\ud83c\uddf3 Hindi - Mumbai (INR \u20b9)"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "\ud83c\udfd7\ufe0f Work Type",
              "fieldOptions": {
                "values": [
                  {
                    "option": "\ud83d\udd28 New Construction"
                  },
                  {
                    "option": "\ud83d\udd04 Renovation / Remodel"
                  },
                  {
                    "option": "\ud83d\udd27 Repair"
                  },
                  {
                    "option": "\u2753 Auto-detect"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "\ud83d\udcdd Description (optional)",
              "placeholder": "Describe what's in the photo, specify dimensions, or add context..."
            }
          ]
        },
        "responseMode": "lastNode",
        "formDescription": "Upload a construction photo for automatic cost estimation.\nIMPROVED: Multi-stage AI decomposition for accurate work identification.\nPrices based on DDC CWICR regional databases (9 languages)."
      },
      "typeVersion": 2.2
    },
    {
      "id": "fae54f1a-9225-48c1-a755-83dca9f15033",
      "name": "Extract Input",
      "type": "n8n-nodes-base.code",
      "position": [
        29328,
        400
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// EXTRACT INPUT FROM FORM - 9 LANGUAGES + WORK TYPE\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst input = $input.first();\nconst formData = input.json || {};\n\nconst regionField = formData['\ud83c\udf0d Region & Language'] || formData['Region & Language'] || '';\nconst workTypeField = formData['\ud83c\udfd7\ufe0f Work Type'] || formData['Work Type'] || 'Auto-detect';\n\n// 9 languages mapping\nconst langMap = {\n  'German': 'DE',\n  'English': 'EN',\n  'Russian': 'RU',\n  'Spanish': 'ES',\n  'French': 'FR',\n  'Portuguese': 'PT',\n  'Chinese': 'ZH',\n  'Arabic': 'AR',\n  'Hindi': 'HI'\n};\n\nlet languageCode = 'EN';\nfor (const [lang, code] of Object.entries(langMap)) {\n  if (regionField.includes(lang)) {\n    languageCode = code;\n    break;\n  }\n}\n\n// Work type detection\nlet workType = 'auto';\nif (workTypeField.includes('New')) workType = 'new_construction';\nelse if (workTypeField.includes('Renovation')) workType = 'renovation';\nelse if (workTypeField.includes('Repair')) workType = 'repair';\n\nconst userDescription = formData['\ud83d\udcdd Description (optional)'] || formData['Description (optional)'] || '';\n\nlet photoBase64 = '';\nlet photoMimeType = 'image/jpeg';\n\nif (input.binary) {\n  for (const key of Object.keys(input.binary)) {\n    const bin = input.binary[key];\n    if (bin.mimeType && bin.mimeType.startsWith('image/')) {\n      photoBase64 = bin.data;\n      photoMimeType = bin.mimeType;\n      break;\n    }\n  }\n}\n\nreturn {\n  json: {\n    language_code: languageCode,\n    photo_base64: photoBase64,\n    photo_mime_type: photoMimeType,\n    has_photo: photoBase64.length > 100,\n    user_description: userDescription,\n    selected_region: regionField,\n    work_type: workType\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "d5b99499-be6d-4da7-a381-94cdf5e9f7d4",
      "name": "Configure Language",
      "type": "n8n-nodes-base.code",
      "position": [
        29552,
        400
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// LANGUAGE & VECTOR DB CONFIGURATION - 9 LANGUAGES\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst input = $input.first().json;\nconst languageCode = (input.language_code || 'EN').toUpperCase();\n\nconst languageConfig = {\n  'DE': {\n    city: 'Berlin',\n    vectorDb: 'DE_BERLIN_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR',\n    language: 'German',\n    languageNative: 'Deutsch',\n    currency: 'EUR',\n    currencySymbol: '\u20ac',\n    locale: 'de-DE',\n    systemPromptLang: 'Antworte auf Deutsch.',\n    searchLang: 'German'\n  },\n  'EN': {\n    city: 'Toronto',\n    vectorDb: 'ENG_TORONTO_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR',\n    language: 'English',\n    languageNative: 'English',\n    currency: 'CAD',\n    currencySymbol: '$',\n    locale: 'en-CA',\n    systemPromptLang: 'Respond in English.',\n    searchLang: 'English'\n  },\n  'RU': {\n    city: 'St. Petersburg',\n    vectorDb: 'RU_STPETERSBURG_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR',\n    language: 'Russian',\n    languageNative: '\u0420\u0443\u0441\u0441\u043a\u0438\u0439',\n    currency: 'RUB',\n    currencySymbol: '\u20bd',\n    locale: 'ru-RU',\n    systemPromptLang: '\u041e\u0442\u0432\u0435\u0447\u0430\u0439 \u043d\u0430 \u0440\u0443\u0441\u0441\u043a\u043e\u043c \u044f\u0437\u044b\u043a\u0435.',\n    searchLang: 'Russian'\n  },\n  'FR': {\n    city: 'Paris',\n    vectorDb: 'FR_PARIS_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR',\n    language: 'French',\n    languageNative: 'Fran\u00e7ais',\n    currency: 'EUR',\n    currencySymbol: '\u20ac',\n    locale: 'fr-FR',\n    systemPromptLang: 'R\u00e9pondez en fran\u00e7ais.',\n    searchLang: 'French'\n  },\n  'ES': {\n    city: 'Barcelona',\n    vectorDb: 'ES_BARCELONA_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR',\n    language: 'Spanish',\n    languageNative: 'Espa\u00f1ol',\n    currency: 'EUR',\n    currencySymbol: '\u20ac',\n    locale: 'es-ES',\n    systemPromptLang: 'Responde en espa\u00f1ol.',\n    searchLang: 'Spanish'\n  },\n  'PT': {\n    city: 'S\u00e3o Paulo',\n    vectorDb: 'PT_SAOPAULO_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR',\n    language: 'Portuguese',\n    languageNative: 'Portugu\u00eas',\n    currency: 'BRL',\n    currencySymbol: 'R$',\n    locale: 'pt-BR',\n    systemPromptLang: 'Responda em portugu\u00eas.',\n    searchLang: 'Portuguese'\n  },\n  'ZH': {\n    city: 'Shanghai',\n    vectorDb: 'ZH_SHANGHAI_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR',\n    language: 'Chinese',\n    languageNative: '\u4e2d\u6587',\n    currency: 'CNY',\n    currencySymbol: '\u00a5',\n    locale: 'zh-CN',\n    systemPromptLang: '\u8bf7\u7528\u4e2d\u6587\u56de\u7b54\u3002',\n    searchLang: 'Chinese'\n  },\n  'AR': {\n    city: 'Dubai',\n    vectorDb: 'AR_DUBAI_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR',\n    language: 'Arabic',\n    languageNative: '\u0627\u0644\u0639\u0631\u0628\u064a\u0629',\n    currency: 'AED',\n    currencySymbol: '\u062f.\u0625',\n    locale: 'ar-AE',\n    systemPromptLang: '\u0623\u062c\u0628 \u0628\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0639\u0631\u0628\u064a\u0629.',\n    searchLang: 'Arabic'\n  },\n  'HI': {\n    city: 'Mumbai',\n    vectorDb: 'HI_MUMBAI_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR',\n    language: 'Hindi',\n    languageNative: '\u0939\u093f\u0928\u094d\u0926\u0940',\n    currency: 'INR',\n    currencySymbol: '\u20b9',\n    locale: 'hi-IN',\n    systemPromptLang: '\u0939\u093f\u0902\u0926\u0940 \u092e\u0947\u0902 \u091c\u0935\u093e\u092c \u0926\u0947\u0902\u0964',\n    searchLang: 'Hindi'\n  }\n};\n\nconst config = languageConfig[languageCode] || languageConfig['EN'];\n\nreturn {\n  json: {\n    ...input,\n    language_code: languageCode,\n    language_config: config,\n    qdrant_collection: config.vectorDb,\n    city: config.city,\n    language: config.language,\n    language_native: config.languageNative,\n    currency: config.currency,\n    currency_symbol: config.currencySymbol,\n    locale: config.locale,\n    system_prompt_lang: config.systemPromptLang,\n    search_lang: config.searchLang,\n    pricing_level: config.city\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "2ad831a9-2893-4a0c-992e-337eb4a4bc36",
      "name": "Has Photo?",
      "type": "n8n-nodes-base.if",
      "position": [
        29776,
        400
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.has_photo }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "97d8463c-6e3b-4b16-b44f-6147d7b17f91",
      "name": "Error No Photo",
      "type": "n8n-nodes-base.code",
      "position": [
        29984,
        560
      ],
      "parameters": {
        "jsCode": "return {\n  json: {\n    success: false,\n    error: true,\n    message: '\u274c No photo provided',\n    html_content: '<!DOCTYPE html><html><head><meta charset=\"UTF-8\"></head><body style=\"font-family:system-ui;padding:40px;text-align:center;\"><h1>\u274c Error</h1><p>No photo was uploaded. Please go back and upload a construction photo.</p></body></html>'\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "b9545c52-79e0-447f-bb2c-fe81fc38a6bf",
      "name": "STAGE 1 Vision Prompt",
      "type": "n8n-nodes-base.set",
      "position": [
        29984,
        384
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "prompt",
              "name": "chatInput",
              "type": "string",
              "value": "=You are an expert construction surveyor. {{ $json.system_prompt_lang }}\n\n## STAGE 1: IDENTIFY ELEMENTS FROM PHOTO\n\nAnalyze this construction photo and identify ALL visible elements.\n\n### IDENTIFICATION RULES:\n1. **Identify ROOM TYPE** first (bathroom, kitchen, bedroom, living room, hallway, exterior, facade, roof, basement, utility)\n2. **List ALL visible construction elements** with their materials\n3. **Estimate dimensions** using reference objects:\n   - Standard door: 2.0m height, 0.9m width\n   - Standard window: 1.2m \u00d7 1.4m\n   - Ceiling height: typically 2.5-2.7m\n   - Brick: 25cm \u00d7 12cm \u00d7 6.5cm\n   - Tile: 30cm \u00d7 30cm or 60cm \u00d7 60cm\n   - Socket/switch: 8cm \u00d7 8cm\n\n{{ $json.user_description ? 'User note: ' + $json.user_description : '' }}\n\nReturn ONLY valid JSON (no markdown, no code blocks):\n{\n  \"room_type\": \"bathroom|kitchen|bedroom|living_room|hallway|exterior|facade|roof|basement|utility|other\",\n  \"room_description\": \"Brief description of what you see\",\n  \"estimated_dimensions\": {\n    \"floor_area_m2\": 0,\n    \"wall_area_m2\": 0,\n    \"ceiling_height_m\": 2.5,\n    \"perimeter_m\": 0\n  },\n  \"elements\": [\n    {\n      \"element_type\": \"wall|floor|ceiling|window|door|fixture|furniture|mep\",\n      \"element_name\": \"Descriptive name\",\n      \"material\": \"concrete|brick|drywall|tile|wood|glass|metal|plastic\",\n      \"surface_finish\": \"painted|tiled|plastered|wallpaper|raw|laminate\",\n      \"quantity\": 1,\n      \"unit\": \"m\u00b2|m|pcs\",\n      \"estimated_size\": \"e.g. 3.5 m\u00b2 or 2.1 m\",\n      \"condition\": \"new|good|worn|damaged\",\n      \"notes\": \"Any additional observations\"\n    }\n  ],\n  \"fixtures\": [\n    {\n      \"fixture_type\": \"toilet|sink|bathtub|shower|faucet|radiator|socket|switch|light|vent\",\n      \"quantity\": 1,\n      \"notes\": \"Description\"\n    }\n  ],\n  \"work_type_detected\": \"new_construction|renovation|repair\",\n  \"confidence\": \"high|medium|low\"\n}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "0fb1af6a-fa06-44e6-9181-2f0c3073f1f6",
      "name": "STAGE 1 Analyze Photo",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        30208,
        384
      ],
      "parameters": {
        "messages": {
          "messageValues": [
            {
              "message": "={{ $json.chatInput }}"
            }
          ]
        }
      },
      "typeVersion": 1.4
    },
    {
      "id": "7a62ea97-a203-4ce0-b693-90d385aa542c",
      "name": "GPT-4 Vision",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        30208,
        592
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "chatgpt-4o-latest",
          "cachedResultName": "chatgpt-4o-latest"
        },
        "options": {
          "maxTokens": 4000,
          "temperature": 0.2
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "f4168e15-aa61-4878-a583-79df493e8104",
      "name": "Parse STAGE 1",
      "type": "n8n-nodes-base.code",
      "position": [
        30480,
        384
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// PARSE STAGE 1 VISION RESPONSE\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst aiResponse = $input.first().json;\nconst configData = $('Configure Language').first().json;\n\nlet parsed = {\n  room_type: 'unknown',\n  room_description: 'Analysis failed',\n  elements: [],\n  fixtures: [],\n  estimated_dimensions: { floor_area_m2: 10, wall_area_m2: 30, ceiling_height_m: 2.5, perimeter_m: 12 }\n};\n\ntry {\n  const content = aiResponse.text || aiResponse.content || aiResponse.response || '';\n  let jsonStr = content;\n  \n  const jsonMatch = content.match(/```json\\s*([\\s\\S]*?)\\s*```/);\n  if (jsonMatch) {\n    jsonStr = jsonMatch[1];\n  } else {\n    const codeMatch = content.match(/```\\s*([\\s\\S]*?)\\s*```/);\n    if (codeMatch) {\n      jsonStr = codeMatch[1];\n    } else {\n      const objMatch = content.match(/\\{[\\s\\S]*\\}/);\n      if (objMatch) {\n        jsonStr = objMatch[0];\n      }\n    }\n  }\n  \n  parsed = JSON.parse(jsonStr);\n} catch (error) {\n  console.error('Parse error:', error.message);\n}\n\nreturn {\n  json: {\n    ...configData,\n    stage1_result: parsed,\n    room_type: parsed.room_type || 'unknown',\n    room_description: parsed.room_description || 'Photo analysis',\n    elements: parsed.elements || [],\n    fixtures: parsed.fixtures || [],\n    dimensions: parsed.estimated_dimensions || {},\n    work_type_detected: parsed.work_type_detected || configData.work_type || 'renovation',\n    confidence: parsed.confidence || 'medium'\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "c733ceb2-3357-42b1-b585-0cf2f8d3f2d1",
      "name": "STAGE 4 Decompose Prompt",
      "type": "n8n-nodes-base.set",
      "position": [
        30656,
        384
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "prompt2",
              "name": "chatInput",
              "type": "string",
              "value": "={{ $json.system_prompt_lang }}\n\n## STAGE 4: DECOMPOSE ELEMENTS TO CONSTRUCTION WORKS\n\nYou are a construction cost estimator. Based on the photo analysis, decompose each element into specific construction work items.\n\n### PHOTO ANALYSIS RESULTS:\n- Room Type: {{ $json.room_type }}\n- Description: {{ $json.room_description }}\n- Work Type: {{ $json.work_type_detected }}\n- Dimensions: Floor {{ $json.dimensions.floor_area_m2 || 10 }} m\u00b2, Walls {{ $json.dimensions.wall_area_m2 || 30 }} m\u00b2\n\n### ELEMENTS DETECTED:\n{{ JSON.stringify($json.elements, null, 2) }}\n\n### FIXTURES DETECTED:\n{{ JSON.stringify($json.fixtures, null, 2) }}\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n## CATEGORY \u2192 WORK ITEMS MAPPING (USE THIS!)\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n### BATHROOM:\n1. Demolition of old finishes (m\u00b2) - if renovation\n2. Waterproofing floor (m\u00b2)\n3. Waterproofing walls wet zone (m\u00b2)\n4. Floor screed (m\u00b2)\n5. Wall tiling (m\u00b2)\n6. Floor tiling (m\u00b2)\n7. Ceiling finishing (m\u00b2)\n8. Toilet installation (pcs)\n9. Sink/washbasin installation (pcs)\n10. Bathtub installation (pcs) - if present\n11. Shower cabin installation (pcs) - if present\n12. Faucet/mixer installation (pcs)\n13. Towel rail installation (pcs)\n14. Mirror installation (pcs)\n15. Ventilation installation (pcs)\n16. Electrical: sockets, switches, lighting (pcs)\n17. Plumbing pipes (m)\n18. Door installation (pcs)\n\n### KITCHEN:\n1. Demolition of old finishes (m\u00b2) - if renovation\n2. Wall preparation/plastering (m\u00b2)\n3. Wall tiling (backsplash area) (m\u00b2)\n4. Floor finishing (m\u00b2)\n5. Ceiling finishing (m\u00b2)\n6. Kitchen cabinets installation (m)\n7. Countertop installation (m)\n8. Sink installation (pcs)\n9. Faucet installation (pcs)\n10. Appliance connections (pcs)\n11. Electrical: sockets, switches (pcs)\n12. Lighting installation (pcs)\n13. Ventilation hood installation (pcs)\n14. Plumbing pipes (m)\n\n### LIVING ROOM / BEDROOM:\n1. Demolition of old finishes (m\u00b2) - if renovation\n2. Wall preparation (m\u00b2)\n3. Wall painting OR wallpaper (m\u00b2)\n4. Floor preparation/screed (m\u00b2)\n5. Floor covering - laminate/parquet/carpet (m\u00b2)\n6. Baseboard/skirting installation (m)\n7. Ceiling finishing - paint/stretch/drywall (m\u00b2)\n8. Electrical: sockets, switches (pcs)\n9. Lighting installation (pcs)\n10. Window sill finishing (m)\n11. Door installation (pcs)\n12. Radiator installation (pcs) - if visible\n\n### FLOOR WORKS:\n1. Old floor demolition (m\u00b2) - if renovation\n2. Floor leveling/screed (m\u00b2)\n3. Insulation (m\u00b2) - if ground floor\n4. Underfloor heating (m\u00b2) - if applicable\n5. Primer/preparation (m\u00b2)\n6. Floor covering (m\u00b2)\n7. Baseboard installation (m)\n\n### WALL WORKS:\n- Drywall: Metal framing (m\u00b2) \u2192 Insulation (m\u00b2) \u2192 Boarding (m\u00b2) \u2192 Jointing (m\u00b2) \u2192 Painting (m\u00b2)\n- Masonry: Brickwork (m\u00b2) \u2192 Plastering (m\u00b2) \u2192 Painting (m\u00b2)\n- Tiling: Wall preparation (m\u00b2) \u2192 Tiling (m\u00b2) \u2192 Grouting (m\u00b2)\n\n### WINDOW WORKS:\n1. Old window demolition (pcs) - if renovation\n2. Window frame installation (pcs)\n3. Glazing (m\u00b2)\n4. Internal sill (m)\n5. External sill (m)\n6. Sealing/foam (m)\n7. Trim/architrave (m)\n8. Painting/finishing (m)\n\n### DOOR WORKS:\n1. Old door demolition (pcs) - if renovation\n2. Door frame installation (pcs)\n3. Door leaf hanging (pcs)\n4. Hardware installation (pcs)\n5. Trim/architrave (m)\n6. Painting/finishing (m\u00b2)\n\n### ELECTRICAL WORKS:\n1. Cable routing (m)\n2. Socket installation (pcs)\n3. Switch installation (pcs)\n4. Junction box (pcs)\n5. Lighting point (pcs)\n6. Panel/breaker installation (pcs)\n\n### PLUMBING WORKS:\n1. Pipe installation - supply (m)\n2. Pipe installation - drain (m)\n3. Valve installation (pcs)\n4. Connection to fixture (pcs)\n5. Pressure testing (pcs)\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n## CRITICAL RULES:\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n1. **NEVER return empty work_items** - minimum 3 works per element\n2. **Include PREPARATION works** - demolition, priming, leveling\n3. **Include FINISHING works** - painting, sealing, cleaning\n4. **Match units correctly**: areas\u2192m\u00b2, lengths\u2192m, items\u2192pcs\n5. **For RENOVATION**: always include demolition/removal works first\n6. **Scale quantities** from dimensions provided\n7. **search_query must be in {{ $json.search_lang }}** for vector database\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nReturn ONLY valid JSON:\n{\n  \"work_items\": [\n    {\n      \"work_sequence\": 1,\n      \"work_category\": \"PREPARATION|MAIN|FINISHING|MEP\",\n      \"work_name\": \"Work name in {{ $json.language }}\",\n      \"search_query\": \"Search terms in {{ $json.search_lang }} for DDC CWICR database - be specific!\",\n      \"quantity\": 12.5,\n      \"unit\": \"m\u00b2|m|pcs\",\n      \"calculation_basis\": \"floor_area \u00d7 1.0 = 12.5 m\u00b2\",\n      \"source_element\": \"Which element this work is for\",\n      \"is_demolition\": false\n    }\n  ],\n  \"total_works_count\": 15,\n  \"phases\": [\"PREPARATION\", \"MAIN\", \"FINISHING\", \"MEP\"]\n}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "b7550960-96b9-400a-82cd-57fcae2813ec",
      "name": "STAGE 4 Decompose LLM",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        30880,
        384
      ],
      "parameters": {
        "messages": {
          "messageValues": [
            {
              "message": "={{ $json.chatInput }}"
            }
          ]
        }
      },
      "typeVersion": 1.4
    },
    {
      "id": "b973f4c7-7800-48ac-bd3f-f6a7f1e4d278",
      "name": "GPT-4 Decompose",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        30880,
        592
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "chatgpt-4o-latest",
          "cachedResultName": "chatgpt-4o-latest"
        },
        "options": {
          "maxTokens": 8000,
          "temperature": 0.3
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "ae0ff40d-08ce-4c80-8cdc-69d7a343b1a7",
      "name": "Parse STAGE 4",
      "type": "n8n-nodes-base.code",
      "position": [
        31168,
        384
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// PARSE STAGE 4 DECOMPOSITION RESPONSE\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst aiResponse = $input.first().json;\nconst configData = $('Parse STAGE 1').first().json;\n\nlet parsed = { work_items: [] };\n\ntry {\n  const content = aiResponse.text || aiResponse.content || aiResponse.response || '';\n  let jsonStr = content;\n  \n  const jsonMatch = content.match(/```json\\s*([\\s\\S]*?)\\s*```/);\n  if (jsonMatch) {\n    jsonStr = jsonMatch[1];\n  } else {\n    const objMatch = content.match(/\\{[\\s\\S]*\\}/);\n    if (objMatch) {\n      jsonStr = objMatch[0];\n    }\n  }\n  \n  parsed = JSON.parse(jsonStr);\n} catch (error) {\n  console.error('STAGE 4 Parse error:', error.message);\n}\n\n// Validate and enrich work items\nconst workItems = (parsed.work_items || []).map((work, idx) => {\n  // Ensure search_query exists and is meaningful\n  let searchQuery = work.search_query || work.work_name || '';\n  \n  // Add language-specific terms if needed\n  if (searchQuery.length < 5) {\n    searchQuery = work.work_name + ' ' + (work.source_element || '');\n  }\n  \n  return {\n    work_id: `W${String(idx + 1).padStart(3, '0')}`,\n    work_sequence: work.work_sequence || idx + 1,\n    work_category: work.work_category || 'MAIN',\n    work_name: work.work_name || 'Unnamed work',\n    search_query: searchQuery.trim(),\n    project_quantity: parseFloat(work.quantity) || 1,\n    unit: work.unit || 'm\u00b2',\n    calculation_basis: work.calculation_basis || '',\n    source_element: work.source_element || '',\n    is_demolition: work.is_demolition || false\n  };\n});\n\n// Sort by sequence\nworkItems.sort((a, b) => a.work_sequence - b.work_sequence);\n\nreturn {\n  json: {\n    ...configData,\n    work_items: workItems,\n    works_count: workItems.length,\n    phases: parsed.phases || ['PREPARATION', 'MAIN', 'FINISHING', 'MEP'],\n    stage4_success: workItems.length >= 3\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "70e62b94-292e-4f2e-b3bc-ca607618d772",
      "name": "Prepare Works",
      "type": "n8n-nodes-base.code",
      "position": [
        31344,
        384
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// PREPARE WORKS FOR LOOP\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst data = $input.first().json;\nconst works = data.work_items || [];\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData.photo_config = {\n  language_code: data.language_code,\n  language: data.language,\n  currency: data.currency,\n  currency_symbol: data.currency_symbol,\n  locale: data.locale,\n  city: data.city,\n  qdrant_collection: data.qdrant_collection,\n  room_type: data.room_type,\n  room_description: data.room_description,\n  dimensions: data.dimensions,\n  work_type_detected: data.work_type_detected,\n  phases: data.phases,\n  elements: data.elements,\n  fixtures: data.fixtures\n};\nstaticData.work_results = [];\n\nif (works.length === 0) {\n  return [{ json: { _no_works: true, message: 'No work items generated' } }];\n}\n\nreturn works.map((work) => ({\n  json: {\n    ...work,\n    expected_unit: work.unit,\n    type_name: work.work_name,\n    category: work.work_category,\n    assigned_phase: work.work_category,\n    qdrant_collection: data.qdrant_collection,\n    language: data.language,\n    currency: data.currency,\n    currency_symbol: data.currency_symbol,\n    locale: data.locale\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "10b9b98b-f782-499f-9c12-e254af13bd0a",
      "name": "Loop Works",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        31536,
        384
      ],
      "parameters": {
        "options": {
          "reset": false
        }
      },
      "typeVersion": 3
    },
    {
      "id": "79f735d7-6de7-4a05-88e7-12d08b2fd6df",
      "name": "Store Work Data",
      "type": "n8n-nodes-base.code",
      "position": [
        32016,
        592
      ],
      "parameters": {
        "jsCode": "// Store current work item for Vector Search\nconst work = $input.first().json;\nconst staticData = $getWorkflowStaticData('global');\nstaticData.currentWork = work;\nreturn { json: work };"
      },
      "typeVersion": 2
    },
    {
      "id": "60873726-0ece-4e69-91dc-73c77ad796b2",
      "name": "Wait",
      "type": "n8n-nodes-base.wait",
      "position": [
        32160,
        592
      ],
      "parameters": {
        "amount": 0.3
      },
      "typeVersion": 1.1
    },
    {
      "id": "c5e35231-575a-4d53-95c4-ef2875363906",
      "name": "Restore Work Data",
      "type": "n8n-nodes-base.code",
      "position": [
        32304,
        592
      ],
      "parameters": {
        "jsCode": "// Restore work data from staticData after Wait\nconst staticData = $getWorkflowStaticData('global');\nconst work = staticData.currentWork || {};\nreturn { json: work };"
      },
      "typeVersion": 2
    },
    {
      "id": "595e380e-4b57-4063-a594-fb89e010f81f",
      "name": "Vector Search",
      "type": "@n8n/n8n-nodes-langchain.vectorStoreQdrant",
      "position": [
        32464,
        592
      ],
      "parameters": {
        "mode": "load",
        "topK": 5,
        "prompt": "={{ $json.search_query || $json.work_name }}",
        "options": {
          "contentPayloadKey": "content"
        },
        "qdrantCollection": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.qdrant_collection }}"
        }
      },
      "credentials": {
        "qdrantApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "782d04ee-61c6-40a0-85bf-c5554533f3b5",
      "name": "Embeddings",
      "type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
      "position": [
        32448,
        752
      ],
      "parameters": {
        "model": "text-embedding-3-large",
        "options": {
          "dimensions": 3072
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "e527cbd6-8841-4425-902a-ee4a28706c2f",
      "name": "STAGE 5 Parse & Score",
      "type": "n8n-nodes-base.code",
      "position": [
        32720,
        592
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// STAGE 5: PARSE & VALIDATE VECTOR SEARCH RESULTS\n// FIXED: Correct document/pageContent extraction\n// Quality scoring v2.0 + Resource extraction\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst searchResults = $input.all();\nconst staticData = $getWorkflowStaticData('global');\nconst workData = staticData.currentWork || {};\n\n// MACHINE/LABOR PATTERNS (9 languages)\nconst MACHINE_PATTERNS = ['masch', 'maschinenstunde', 'ger\u00e4t', 'machine', 'mach-h', 'equipment', 'heure machine', 'engin', 'm\u00e1quina', 'equipo', 'equipamento', '\u043c\u0430\u0448\u0438\u043d', '\u043c\u0430\u0448-\u0447', '\u043c\u0430\u0448.\u0447', '\u043c\u0430\u0448.-\u0447', '\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c', '\u673a\u5668', '\u673a\u68b0', '\u8bbe\u5907', '\u53f0\u73ed', '\u0622\u0644\u0629', '\u0645\u0639\u062f\u0629', '\u092e\u0936\u0940\u0928', '\u092f\u0902\u0924\u094d\u0930'];\nconst LABOR_PATTERNS = ['std', 'stunde', 'arbeiter', 'hour', 'man-hour', 'labor', 'worker', 'heure', 'ouvrier', 'hora', 'obrero', 'oper\u00e1rio', '\u0447-\u0447', '\u0447\u0435\u043b-\u0447', '\u0447\u0435\u043b\u043e\u0432\u0435\u043a\u043e-\u0447\u0430\u0441', '\u0442\u0440\u0443\u0434', '\u0440\u0430\u0431\u043e\u0447\u0438\u0439', '\u0440\u0430\u0437\u0440\u044f\u0434', ' \u0447', '\u5de5\u65f6', '\u4eba\u5de5', '\u5de5\u4eba', '\u0633\u0627\u0639\u0629', '\u0639\u0627\u0645\u0644', '\u0918\u0902\u091f\u093e', '\u0936\u094d\u0930\u092e', '\u092e\u091c\u0926\u0942\u0930'];\n\nfunction detectResourceType(unit, name, code) {\n  const combined = ((unit || '') + ' ' + (name || '') + ' ' + (code || '')).toLowerCase();\n  if (MACHINE_PATTERNS.some(p => combined.includes(p.toLowerCase()))) return 'machine';\n  if (LABOR_PATTERNS.some(p => combined.includes(p.toLowerCase()))) return 'labor';\n  return 'material';\n}\n\nfunction normalizeUnit(unit) {\n  if (!unit) return '';\n  const u = unit.toLowerCase().trim();\n  const unitGroups = {\n    'm2': ['m\u00b2', 'm2', 'qm', '\u043a\u0432\u043c', '\u043a\u0432.\u043c', '\u043a\u0432. \u043c', 'sq.m', 'sqm', '\u5e73\u65b9\u7c73'],\n    'm3': ['m\u00b3', 'm3', 'cbm', '\u043a\u0443\u0431.\u043c', '\u043a\u0443\u0431. \u043c', 'cu.m', '\u7acb\u65b9\u7c73'],\n    'm': ['m', '\u043c', 'lm', 'lfm', '\u043f.\u043c', '\u043f. \u043c', 'lin.m', '\u7c73'],\n    'stk': ['stk', 'st\u00fcck', 'st', '\u0448\u0442', '\u0448\u0442.', 'pcs', 'ea', 'each', 'unit', '\u4e2a', '\u4ef6'],\n    '100m2': ['100 m\u00b2', '100m\u00b2', '100 m2', '100m2', '100 qm', '100 \u043a\u0432.\u043c']\n  };\n  for (const [normalized, variants] of Object.entries(unitGroups)) {\n    if (variants.some(v => u === v || u.includes(v))) return normalized;\n  }\n  return u;\n}\n\n// MATERIAL KEYWORDS for matching\nconst MATERIAL_KEYWORDS = [\n  'aluminum', 'aluminium', 'alu', '\u0430\u043b\u044e\u043c\u0438\u043d',\n  'wood', 'holz', '\u0434\u0435\u0440\u0435\u0432', '\u0434\u0440\u0435\u0432\u0435\u0441', '\u6728',\n  'plastic', 'pvc', 'kunststoff', '\u043f\u043b\u0430\u0441\u0442\u0438\u043a', '\u043f\u0432\u0445',\n  'steel', 'stahl', '\u0441\u0442\u0430\u043b', '\u043c\u0435\u0442\u0430\u043b\u043b', '\u94a2',\n  'concrete', 'beton', '\u0431\u0435\u0442\u043e\u043d', '\u6df7\u51dd\u571f',\n  'glass', 'glas', '\u0441\u0442\u0435\u043a\u043b', '\u73bb\u7483',\n  'brick', 'ziegel', '\u043a\u0438\u0440\u043f\u0438\u0447', '\u7816',\n  'tile', 'fliese', '\u043f\u043b\u0438\u0442\u043a', '\u043a\u0430\u0444\u0435\u043b\u044c', '\u74f7\u7816',\n  'ceramic', 'keramik', '\u043a\u0435\u0440\u0430\u043c\u0438\u043a', '\u9676\u74f7',\n  'gypsum', 'gips', '\u0433\u0438\u043f\u0441', '\u77f3\u818f',\n  'drywall', '\u0433\u0438\u043f\u0441\u043e\u043a\u0430\u0440\u0442\u043e\u043d', '\u0433\u043a\u043b',\n  'paint', 'farbe', '\u043a\u0440\u0430\u0441\u043a', '\u6cb9\u6f06',\n  'wallpaper', 'tapete', '\u043e\u0431\u043e', '\u58c1\u7eb8',\n  'laminate', 'laminat', '\u043b\u0430\u043c\u0438\u043d\u0430\u0442',\n  'parquet', 'parkett', '\u043f\u0430\u0440\u043a\u0435\u0442'\n];\n\n// WORK TYPE KEYWORDS for matching\nconst WORK_TYPE_KEYWORDS = [\n  'installation', 'montage', '\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a', '\u043c\u043e\u043d\u0442\u0430\u0436', '\u5b89\u88c5',\n  'demolition', 'abbruch', '\u0434\u0435\u043c\u043e\u043d\u0442\u0430\u0436', '\u0441\u043d\u043e\u0441', '\u0440\u0430\u0437\u0431\u043e\u0440', '\u62c6\u9664',\n  'preparation', 'vorbereitung', '\u043f\u043e\u0434\u0433\u043e\u0442\u043e\u0432', '\u51c6\u5907',\n  'sealing', 'abdichtung', '\u0433\u0435\u0440\u043c\u0435\u0442\u0438\u0437', '\u0433\u0438\u0434\u0440\u043e\u0438\u0437\u043e\u043b', '\u5bc6\u5c01',\n  'insulation', 'd\u00e4mmung', '\u0438\u0437\u043e\u043b\u044f\u0446', '\u0443\u0442\u0435\u043f\u043b', '\u4fdd\u6e29',\n  'finishing', '\u043e\u0442\u0434\u0435\u043b\u043a', 'finish', '\u88c5\u4fee',\n  'excavation', 'aushub', '\u0432\u044b\u0435\u043c\u043a', '\u043a\u043e\u043f\u0430\u043d', '\u5f00\u6316',\n  'concrete', 'beton', '\u0431\u0435\u0442\u043e\u043d\u0438\u0440', '\u6df7\u51dd\u571f',\n  'reinforcement', 'bewehrung', '\u0430\u0440\u043c\u0438\u0440', '\u94a2\u7b4b',\n  'plastering', 'putz', '\u0448\u0442\u0443\u043a\u0430\u0442\u0443\u0440', '\u62b9\u7070',\n  'painting', 'malen', 'anstrich', '\u043e\u043a\u0440\u0430\u0441\u043a', '\u043f\u043e\u043a\u0440\u0430\u0441\u043a', '\u6cb9\u6f06',\n  'tiling', 'fliesen', '\u043e\u0431\u043b\u0438\u0446\u043e\u0432', '\u0443\u043a\u043b\u0430\u0434\u043a \u043f\u043b\u0438\u0442\u043a', '\u8d34\u7816',\n  'flooring', 'boden', '\u043d\u0430\u043f\u043e\u043b\u044c\u043d', '\u043f\u043e\u043b', '\u5730\u677f',\n  'roofing', 'dach', '\u043a\u0440\u043e\u0432\u043b', '\u5c4b\u9762',\n  'plumbing', 'sanit\u00e4r', '\u0441\u0430\u043d\u0442\u0435\u0445\u043d', '\u7ba1\u9053',\n  'electrical', 'elektro', '\u044d\u043b\u0435\u043a\u0442\u0440', '\u7535\u6c14'\n];\n\n// NOT FOUND FALLBACK\nif (!searchResults || searchResults.length === 0) {\n  return [{\n    json: {\n      ...workData,\n      rate_code: 'NOT_FOUND',\n      rate_name: '[Not Found] ' + (workData.work_name || 'Unknown'),\n      rate_unit: workData.expected_unit || 'm\u00b2',\n      unit_cost: 0,\n      total_cost: 0,\n      estimated_labor_hours: 0,\n      quality_level: 'not_found',\n      quality_score: 0,\n      quality_reason: 'No search results',\n      resources_all: [],\n      cost_breakdown: { workers: 0, machines: 0, materials: 0 }\n    }\n  }];\n}\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// FIX: CORRECT EXTRACTION OF document.pageContent\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst parsedResults = searchResults.map((item) => {\n  const data = item.json || {};\n  \n  // FIX: Handle nested document structure from Vector Search\n  const doc = data.document || {};\n  const content = String(doc.pageContent || doc.content || data.pageContent || data.content || '');\n  const metadata = doc.metadata || data.metadata || {};\n  const score = data.score || 0;\n  \n  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  // EXTRACT TOTAL COST from \"Total cost: 3127.16 EUR\"\n  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  let totalCost = 0;\n  const costPatterns = [\n    /Total\\s*cost:\\s*([\\d.,]+)\\s*(?:EUR|USD|RUB|CAD|CNY|AED|INR|BRL)/i,\n    /Resources?\\s*cost:\\s*([\\d.,]+)/i,\n    /(?:Gesamt|\u0418\u0422\u041e\u0413\u041e|\u0412\u0441\u0435\u0433\u043e|\u0421\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c)[^\\d]*([\\d.,]+)/i,\n    /(?:EUR|USD|RUB|CAD|\\$|\u20ac|\u20bd)\\s*([\\d.,]+)/i\n  ];\n  for (const pattern of costPatterns) {\n    const match = content.match(pattern);\n    if (match) {\n      totalCost = parseFloat(match[1].replace(/,/g, '.').replace(/\\s/g, '')) || 0;\n      if (totalCost > 0) break;\n    }\n  }\n  \n  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  // EXTRACT CODE, NAME, UNIT from content if not in metadata\n  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  let rateCode = metadata.rsts || metadata.code || '';\n  let rateName = metadata.names || metadata.name || '';\n  let rateUnit = metadata.unit || '';\n  \n  // Fallback: extract from content\n  if (!rateCode) {\n    const codeMatch = content.match(/CODE:\\s*([A-Z\u0410-\u042fa-z\u0430-\u044f0-9_-]+)/i);\n    if (codeMatch) rateCode = codeMatch[1];\n  }\n  if (!rateName) {\n    const nameMatch = content.match(/NAME:\\s*(.+?)(?:\\n|UNIT:|CATEGORY:)/i);\n    if (nameMatch) rateName = nameMatch[1].trim();\n  }\n  if (!rateUnit) {\n    const unitMatch = content.match(/UNIT:\\s*(.+?)(?:\\n|CATEGORY:)/i);\n    if (unitMatch) rateUnit = unitMatch[1].trim();\n  }\n  \n  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  // EXTRACT LABOR HOURS\n  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  let laborHours = 0;\n  const laborMatch = content.match(/(?:\u0440\u0430\u0437\u0440\u044f\u0434|worker|arbeiter|labor)[^\u2014\\n]*\u2014\\s*([\\d.,]+)\\s*(?:\u0447|\u0447\u0430\u0441|std|hour|h)/i);\n  if (laborMatch) {\n    laborHours = parseFloat(laborMatch[1].replace(',', '.')) || 0;\n  }\n  \n  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  // EXTRACT RESOURCES from RESOURCES: section\n  // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n  const resources = [];\n  const resourcesSection = content.match(/RESOURCES:\\s*([\\s\\S]*?)(?:$|MACHINES:|SCOPE|CLASSIFICATION:)/i);\n  if (resourcesSection) {\n    const resourcesText = resourcesSection[1];\n    const resourceLines = resourcesText.split('\\n').filter(line => line.trim().startsWith('-'));\n    \n    for (const line of resourceLines) {\n      const resMatch = line.match(/^-\\s*([A-Z\u0410-\u042fa-z\u0430-\u044f0-9_-]+)\\s*[\u2014\u2013-]\\s*(.+?)\\s*[\u2014\u2013-]\\s*([\\d.,]+)\\s*(.+?)$/i);\n      if (resMatch) {\n        const resCode = resMatch[1].trim();\n        const resName = resMatch[2].trim();\n        const resQty = parseFloat(resMatch[3].replace(',', '.')) || 0;\n        const resUnit = resMatch[4].trim();\n        const resType = detectResourceType(resUnit, resName, resCode);\n        \n        resources.push({\n          resource_code: resCode,\n          resource_name: resName,\n          resource_quantity: resQty,\n          resource_unit: resUnit,\n          resource_cost: 0,\n          resource_type: resType\n        });\n      }\n    }\n  }\n  \n  return {\n    rate_code: rateCode,\n    rate_name: rateName,\n    rate_unit: rateUnit,\n    total_cost_position: totalCost,\n    worker_labor_hours: laborHours,\n    hierarchy: metadata.hierarchy || '',\n    resources: resources,\n    resources_count: resources.length,\n    vector_score: score,\n    content_preview: content.substring(0, 300)\n  };\n});\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// QUALITY SCORING v2.0\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst expectedUnit = normalizeUnit(workData.expected_unit || workData.unit || '');\nconst searchQuery = (workData.search_query || workData.work_name || '').toLowerCase();\nconst workName = (workData.work_name || '').toLowerCase();\n\nconst scoredResults = parsedResults.map(result => {\n  let score = 0;\n  const factors = [];\n  const resultUnit = normalizeUnit(result.rate_unit);\n  const rateName = (result.rate_name || '').toLowerCase();\n  const rateContent = (result.content_preview || '').toLowerCase();\n  const combined = rateName + ' ' + rateContent;\n  \n  if (result.total_cost_position > 0) { \n    score += 30; \n    factors.push('has_price:' + result.total_cost_position.toFixed(0)); \n  }\n  \n  if (result.resources_count > 0) { \n    score += Math.min(25, result.resources_count * 5); \n    factors.push('resources:' + result.resources_count); \n  }\n  \n  if (resultUnit && expectedUnit) {\n    if (resultUnit === expectedUnit) { \n      score += 20; \n      factors.push('unit_exact'); \n    } else if (resultUnit.includes(expectedUnit) || expectedUnit.includes(resultUnit)) { \n      score += 10; \n      factors.push('unit_partial'); \n    }\n  }\n  \n  const materialMatches = MATERIAL_KEYWORDS.filter(kw => \n    (workName.includes(kw) || searchQuery.includes(kw)) && combined.includes(kw)\n  );\n  if (materialMatches.length > 0) {\n    score += 15;\n    factors.push('material:' + materialMatches[0]);\n  }\n  \n  const workTypeMatches = WORK_TYPE_KEYWORDS.filter(kw => \n    (workName.includes(kw) || searchQuery.includes(kw)) && combined.includes(kw)\n  );\n  if (workTypeMatches.length > 0) {\n    score += 10;\n    factors.push('work_type:' + workTypeMatches[0]);\n  }\n  \n  const queryWords = searchQuery.split(/\\s+/).filter(w => w.length > 3);\n  const matchedWords = queryWords.filter(w => rateName.includes(w) || rateContent.includes(w));\n  if (matchedWords.length > 0) { \n    score += Math.min(15, matchedWords.length * 5); \n    factors.push('words:' + matchedWords.length); \n  }\n  \n  if (result.vector_score > 0.5) {\n    score += 10;\n    factors.push('vscore:' + result.vector_score.toFixed(2));\n  } else if (result.vector_score > 0.4) {\n    score += 5;\n    factors.push('vscore:' + result.vector_score.toFixed(2));\n  }\n  \n  const hasLabor = result.resources.some(r => r.resource_type === 'labor');\n  const hasMaterial = result.resources.some(r => r.resource_type === 'material');\n  if (hasLabor && hasMaterial) {\n    score += 5;\n    factors.push('complete_rate');\n  }\n  \n  return { ...result, quality_score: score, quality_factors: factors };\n});\n\nconst sorted = scoredResults.sort((a, b) => b.quality_score - a.quality_score);\nconst best = sorted[0] || { quality_score: 0, quality_factors: [], resources: [] };\n\nlet qualityLevel = 'not_found';\nconst s = best.quality_score;\nif (s >= 60) { qualityLevel = 'high'; }\nelse if (s >= 40) { qualityLevel = 'medium'; }\nelse if (s >= 20) { qualityLevel = 'low'; }\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// CALCULATE COSTS\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst projectQuantity = workData.project_quantity || 0;\nconst unitCost = best.total_cost_position || 0;\n\nlet unitDivisor = 1;\nconst rateUnit = (best.rate_unit || '').toLowerCase();\nif (rateUnit.includes('100')) unitDivisor = 100;\nelse if (rateUnit.includes('10 ')) unitDivisor = 10;\n\nconst quantityInRateUnits = projectQuantity / unitDivisor;\nconst totalCost = quantityInRateUnits * unitCost;\nconst estimatedLaborHours = (best.worker_labor_hours || 0) * quantityInRateUnits;\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// SCALE & CATEGORIZE RESOURCES\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nlet laborCost = 0, machineCost = 0, materialCost = 0;\nconst scaledResources = (best.resources || []).map(r => {\n  const scaledQty = (r.resource_quantity || 0) * quantityInRateUnits;\n  const scaledCost = (r.resource_cost || 0) * quantityInRateUnits;\n  \n  if (r.resource_type === 'labor') laborCost += scaledCost;\n  else if (r.resource_type === 'machine') machineCost += scaledCost;\n  else materialCost += scaledCost;\n  \n  return { ...r, scaled_quantity: scaledQty, scaled_cost: scaledCost };\n});\n\nif (laborCost === 0 && materialCost === 0 && totalCost > 0) {\n  laborCost = totalCost * 0.35;\n  materialCost = totalCost * 0.55;\n  machineCost = totalCost * 0.10;\n}\n\nreturn [{\n  json: {\n    work_id: workData.work_id,\n    work_sequence: workData.work_sequence,\n    work_category: workData.work_category,\n    work_name: workData.work_name,\n    source_element: workData.source_element,\n    calculation_basis: workData.calculation_basis,\n    is_demolition: workData.is_demolition,\n    rate_code: best.rate_code || 'NOT_FOUND',\n    rate_name: best.rate_name || workData.work_name || 'Not found',\n    rate_unit: best.rate_unit || workData.expected_unit,\n    project_quantity: projectQuantity,\n    project_unit: workData.unit || workData.expected_unit,\n    quantity_in_rate_units: quantityInRateUnits,\n    calculated_quantity: quantityInRateUnits,\n    unit_divisor: unitDivisor,\n    unit_cost: unitCost,\n    total_cost: totalCost,\n    estimated_labor_hours: estimatedLaborHours,\n    quality_level: qualityLevel,\n    quality_score: s,\n    quality_reason: 'Score ' + s + '/100: ' + best.quality_factors.join(', '),\n    currency: workData.currency,\n    currency_symbol: workData.currency_symbol,\n    locale: workData.locale,\n    type_name: workData.type_name,\n    category: workData.category,\n    assigned_phase: workData.assigned_phase,\n    resources_all: scaledResources,\n    cost_breakdown: {\n      workers: laborCost,\n      machines: machineCost,\n      materials: materialCost\n    },\n    calculation_details: {\n      method: 'photo_analysis',\n      calculation_basis: workData.calculation_basis,\n      raw_value: projectQuantity,\n      unit_divisor: unitDivisor,\n      formula_display: (workData.unit || 'Qty') + ' = ' + projectQuantity\n    },\n    search_debug: {\n      query_used: workData.search_query,\n      results_count: searchResults.length,\n      best_match_score: s,\n      best_vector_score: best.vector_score || 0,\n      best_rate_found: best.rate_name || 'none'\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "577e8350-ff19-4b73-a53f-80605484a493",
      "name": "Accumulate",
      "type": "n8n-nodes-base.code",
      "position": [
        32864,
        592
      ],
      "parameters": {
        "jsCode": "// ACCUMULATE RESULTS\nconst staticData = $getWorkflowStaticData('global');\nconst work = $input.first().json;\nif (!staticData.work_results) staticData.work_results = [];\nstaticData.work_results.push(work);\nreturn { json: work };"
      },
      "typeVersion": 2
    },
    {
      "id": "9b8dd6f9-e36b-4fc0-bac8-5a0857890e32",
      "name": "STAGE 7.5 Aggregate & Validate",
      "type": "n8n-nodes-base.code",
      "position": [
        31824,
        352
      ],
      "parameters": {
        "jsCode": "// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// STAGE 7.5: AGGREGATE & VALIDATE RESULTS\n// Build by_phase structure with validation\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nconst staticData = $getWorkflowStaticData('global');\nconst config = staticData.photo_config || {};\nconst works = staticData.work_results || [];\n\n// Calculate totals\nlet grandTotal = 0, grandHours = 0;\nlet grandLaborCost = 0, grandMaterialCost = 0, grandMachineCost = 0;\nlet qHigh = 0, qMedium = 0, qLow = 0, qNotFound = 0;\n\nworks.forEach(w => {\n  grandTotal += w.total_cost || 0;\n  grandHours += w.estimated_labor_hours || 0;\n  \n  const cb = w.cost_breakdown || {};\n  grandLaborCost += cb.workers || 0;\n  grandMaterialCost += cb.materials || 0;\n  grandMachineCost += cb.machines || 0;\n  \n  if (w.quality_level === 'high') qHigh++;\n  else if (w.quality_level === 'medium') qMedium++;\n  else if (w.quality_level === 'low') qLow++;\n  else qNotFound++;\n});\n\n// Sort by sequence\nworks.sort((a, b) => (a.work_sequence || 0) - (b.work_sequence || 0));\n\n// Group by category (PREPA

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Upload a construction photo via web form → get a detailed cost estimate with work breakdown, resource costs, and professional HTML report. Powered by GPT-4 Vision and the open-source DDC CWICR database (55,000+ work items). Site managers who need quick estimates from mobile…

Source: https://n8n.io/workflows/12175/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

I originally started to template to ask questions on the "n8n @ scale office-hours" livestream videos but then extended it to include the latest videos on the official channel.

HTTP Request, Qdrant Vector Store, Document Default Data Loader +7
AI & RAG

Code Extractfromfile. Uses manualTrigger, sort, httpRequest, compression. Event-driven trigger; 50 nodes.

HTTP Request, Compression, Edit Image +15
AI & RAG

2464. Uses httpRequest, compression, editImage, documentDefaultDataLoader. Event-driven trigger; 50 nodes.

HTTP Request, Compression, Edit Image +15
AI & RAG

Workflow 2464. Uses httpRequest, compression, editImage, documentDefaultDataLoader. Event-driven trigger; 50 nodes.

HTTP Request, Compression, Edit Image +15
AI & RAG

Are you a popular tech startup accelerator (named after a particular higher order function) overwhelmed with 1000s of pitch decks on a daily basis? Wish you could filter through them quickly using AI

HTTP Request, Compression, Edit Image +15