{
  "id": "",
  "meta": {
    "instanceId": "",
    "templateCredsSetupCompleted": false
  },
  "name": "DDC CWICR v10.9 - Construction Cost Estimator Bot",
  "tags": [
    {
      "name": "construction"
    },
    {
      "name": "cost-estimation"
    },
    {
      "name": "telegram-bot"
    },
    {
      "name": "ai-vision"
    },
    {
      "name": "ddc-cwicr"
    }
  ],
  "nodes": [
    {
      "id": "c77b9d88-2822-49f7-b1f7-5dd8a50a7989",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2208,
        864
      ],
      "parameters": {
        "width": 420,
        "height": 132,
        "content": "⭐ **If you find our tools helpful**, please consider **starring our repository** on [GitHub](https://github.com/datadrivenconstruction/OpenConstructionEstimate-DDC-CWICR). \n\nYour support helps us improve and continue developing open solutions for the community!\n"
      },
      "typeVersion": 1
    },
    {
      "id": "b976eaa7-64c8-44da-a068-2c87ba286b5d",
      "name": "🔐 Credentials Setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2208,
        1008
      ],
      "parameters": {
        "color": 5,
        "width": 428,
        "height": 1200,
        "content": "## 🔐 API CREDENTIALS SETUP\n\n### ⬇️ Configure node `🔑 TOKEN` below:\n\n```json\n{\n  \"bot_token\": \"YOUR_BOT_TOKEN\",\n  \"AI_PROVIDER\": \"gemini\",\n  \"GEMINI_API_KEY\": \"YOUR_KEY\",\n  \"OPENAI_API_KEY\": \"YOUR_KEY\",\n  \"QDRANT_URL\": \"http://...\",\n  \"QDRANT_API_KEY\": \"YOUR_KEY\"\n}\n```\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n---\n\n### 📡 APIs Required (3 keys):\n\n| # | API | Field | Get from |\n|---|-----|-------|----------|\n| 1 | Telegram | `bot_token` | @BotFather |\n| 2 | OpenAI | `OPENAI_API_KEY` | platform.openai.com |\n| 3 | Gemini | `GEMINI_API_KEY` | aistudio.google.com |\n\n\n### ⚙️ Vision Provider:\n\n`AI_PROVIDER`:\n- `\"gemini\"` → Gemini 2.0 Flash\n- `\"openai\"` → GPT-4 Vision\n\n---\n\n### ✅ Quick Start:\n\n1. Get bot token from @BotFather\n2. Get OpenAI key (for embeddings)\n3. Get Gemini key (for vision)\n4. Paste in `🔑 TOKEN` node\n5. Set Telegram credential\n6. Activate workflow!"
      },
      "typeVersion": 1
    },
    {
      "id": "7d603c66-1ea8-47f7-9825-3162a72d0861",
      "name": "UI Messages",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -896,
        208
      ],
      "parameters": {
        "color": 6,
        "width": 384,
        "height": 692,
        "content": "## 🌍 UI Messages\n\nTelegram menus:\n- Language selection\n- Photo request\n- Analysis options\n- Help text\n- Error messages\n\nAll localized in Config node"
      },
      "typeVersion": 1
    },
    {
      "id": "ea106c90-9541-4a13-8456-ff3352714f3f",
      "name": "Route Switch",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1232,
        688
      ],
      "parameters": {
        "color": 5,
        "width": 308,
        "height": 1136,
        "content": "## 🔀 Route Switch\n\n**17 Actions:**\n\n| # | Action | Description |\n|---|--------|-------------|\n| 0 | show_lang | Language menu |\n| 1 | ask_photo | Request photo |\n| 2 | lang_selected | Save language |\n| 3 | show_analyze | Photo options |\n| 4 | analyze | AI vision |\n| 5 | show_edit_menu | Edit work |\n| 6 | works_updated | Qty changed |\n| 7 | ask_new_work | Add work |\n| 8 | start_calc | Calculate |\n| 9 | show_help | Help text |\n| 10 | view_details | Work info |\n| 11 | export_excel | CSV export |\n| 12 | export_pdf | PDF export |\n| 13 | process_pdf | PDF analysis |\n| 14 | analyze_text | Text input |\n| 15 | refine | Re-analyze |\n| 16 | fallback | Unknown |"
      },
      "typeVersion": 1
    },
    {
      "id": "c5c73cb9-6b1b-472e-a7a7-7af0c63a689f",
      "name": "Config & Localization",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1472,
        1008
      ],
      "parameters": {
        "color": 5,
        "width": 220,
        "height": 808,
        "content": "## 🌐 Config\n\n**9 Languages:**\nDE, EN, RU, ES, FR, IT, PL, PT, UK\n\n**Contains:**\n- UI messages\n- Button labels\n- Currency symbols\n- Database mapping\n- System prompts\n\n**Auto-selects:**\n- Database by language\n- Currency by region"
      },
      "typeVersion": 1
    },
    {
      "id": "95cda5bd-7882-4136-9027-e983b69967e9",
      "name": "Main Router",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1760,
        1008
      ],
      "parameters": {
        "color": 5,
        "width": 268,
        "height": 808,
        "content": "## 🧠 Main Router\n\nCentral message handler:\n\n**Input:** Telegram Update\n\n**Processing:**\n- Parse message/callback\n- Manage user sessions\n- Detect content type\n- Route to action\n\n**Output:**\n`action` → Route switch"
      },
      "typeVersion": 1
    },
    {
      "id": "364cd5eb-f4f7-40b7-a981-c0051d4fb308",
      "name": "Checklist",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2544,
        1328
      ],
      "parameters": {
        "width": 324,
        "height": 428,
        "content": "## ✅ CHECKLIST\n\n**1. Telegram Bot**\n- [ ] Created via @BotFather\n- [ ] Token in 🔑 TOKEN\n- [ ] Credential configured\n\n**2. OpenAI**\n- [ ] API key obtained\n- [ ] Added to 🔑 TOKEN\n\n**3. Gemini/GPT-4**\n- [ ] Vision API enabled\n- [ ] Key configured\n\n**4. Activation**\n- [ ] Workflow active\n- [ ] Webhook set"
      },
      "typeVersion": 1
    },
    {
      "id": "89b7cc09-4159-4e5c-9354-aa2124b11219",
      "name": "Telegram Credentials",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2544,
        1776
      ],
      "parameters": {
        "width": 328,
        "height": 436,
        "content": "## ⚙️ Telegram Credentials\n\n### Setup n8n Credential:\n\n1. **Settings** → Credentials\n2. **Add** → Telegram API\n3. Enter Bot Token\n4. **Save**\n\n### Configure Trigger:\n1. Select credential in `Telegram Trigger`\n2. Set webhook mode\n3. Activate workflow\n\n### Test:\nSend `/start` to your bot"
      },
      "typeVersion": 1
    },
    {
      "id": "e6f22398-fdf5-49de-b81a-570691e0809c",
      "name": "Intro",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2544,
        864
      ],
      "parameters": {
        "width": 318,
        "height": 440,
        "content": "## 🚀 DDC CWICR Pipeline\n### Construction Cost Estimation Bot\n\n**Version:** 10.3 Enhanced\n**Author:** DataDrivenConstruction.io\n\n**Features:**\n- 📷 Photo analysis (GPT-4 Vision / Gemini)\n- 📄 PDF floor plan processing\n- 🔍 Vector search (Qdrant + OpenAI)\n- 🤖 AI reranking for accuracy\n- 📊 HTML/Excel/PDF export\n- 🌍 9 languages supported\n\n**Database:** 55,000+ construction rates\n\n⭐ **Star our repo:** [GitHub](https://github.com/datadrivenconstruction/OpenConstructionEstimate-DDC-CWICR)"
      },
      "typeVersion": 1
    },
    {
      "id": "d6ebe802-df6f-4a4a-bb92-2e9d4349b19c",
      "name": "Agg",
      "type": "n8n-nodes-base.code",
      "position": [
        1008,
        2160
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// AGG - Final aggregation of calculation results + FREE DEMO info\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $('Config').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\n\nconst L = cfg.L || {};\nlet total = 0, workers_sum = 0, materials_sum = 0, machines_sum = 0, labor_hours_sum = 0;\nlet found_count = 0;\n\n// Get accumulated results\nconst storedResults = sd.res?.[cid] || [];\n\nconsole.log('=== AGG ===');\nconsole.log('Results count:', storedResults.length);\n\n// Process each result\nconst works = storedResults.map(w => {\n  const uc = w.uc || 0;\n  const tc = w.tc || 0;\n\n  total += tc;\n  workers_sum += (w.workers_total || 0);\n  materials_sum += (w.materials_total || 0);\n  machines_sum += (w.machines_total || 0);\n  labor_hours_sum += (w.labor_hours || 0);\n\n  if (w._found) found_count++;\n\n  return {\n    id: w.id,\n    name: w.name || w.sq,\n    query: w.sq || w.query,\n    qty: w.qty,\n    unit: w.unit,\n    room: w.room,\n    uc: uc,\n    tc: tc,\n    rate_code: w.rate_code || '',\n    rate_name: w.rate_name || '',\n    resources: w.resources || [],\n    workers_total: w.workers_total || 0,\n    materials_total: w.materials_total || 0,\n    machines_total: w.machines_total || 0,\n    labor_hours: w.labor_hours || 0,\n    found: w._found || false,\n    scope_of_work: w.scope_of_work || [],\n    original_query: w.original_query || w.name\n  };\n});\n\nconst pct = works.length > 0 ? Math.round(found_count / works.length * 100) : 0;\n\n// FREE DEMO info\nconst isLimited = session.isLimited || false;\nconst originalTotal = session.totalWorks || works.length;\nconst skippedWorks = originalTotal - works.length;\n\nconsole.log('Calculated:', works.length, '/', originalTotal);\nconsole.log('Found:', found_count, '/', works.length);\nconsole.log('Total cost:', total.toFixed(2));\nconsole.log('Limited:', isLimited);\n\n// Cleanup staticData\nif (sd.res?.[cid]) delete sd.res[cid];\nif (sd.calcProgress?.[cid]) delete sd.calcProgress[cid];\nif (sd.progress?.[cid]) delete sd.progress[cid];\n\n// Save for report generation\nsd.lastResults = { works, total, workers_sum, materials_sum, machines_sum, labor_hours_sum, found_count, pct, L };\n\nreturn {\n  json: {\n    chatId: cid,\n    bot_token: cfg.bot_token,\n    works: works,\n    total: Math.round(total * 100) / 100,\n    workers_sum: Math.round(workers_sum * 100) / 100,\n    materials_sum: Math.round(materials_sum * 100) / 100,\n    machines_sum: Math.round(machines_sum * 100) / 100,\n    labor_hours_sum: Math.round(labor_hours_sum * 100) / 100,\n    found_count: found_count,\n    total_count: works.length,\n    pct: pct,\n    L: L,\n    currency: L.sym || '€',\n    description: session.description || '',\n    // FREE DEMO\n    _is_limited: isLimited,\n    _total_works: originalTotal,\n    _skipped_works: skippedWorks\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "bbbcfcaa-81de-4e05-98c1-c5c005c5f6b5",
      "name": "🗑️ Delete Progress Msg",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        832,
        2160
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/deleteMessage",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={\n  \"chat_id\": {{ $('🧹 Prep Cleanup').first().json.chatId }},\n  \"message_id\": {{ $('🧹 Prep Cleanup').first().json._delete_progress_msg || 0 }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "5fdc516f-bc6b-4613-afb4-aac2d0863c82",
      "name": "🗑️ Delete Work Msg",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        640,
        2160
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/deleteMessage",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={\n  \"chat_id\": {{ $('🧹 Prep Cleanup').first().json.chatId }},\n  \"message_id\": {{ $('🧹 Prep Cleanup').first().json._delete_work_msg || 0 }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "085bef11-75f5-4b87-8d03-b48f0d35e029",
      "name": "🧹 Prep Cleanup",
      "type": "n8n-nodes-base.code",
      "position": [
        464,
        2160
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// PREP CLEANUP - Prepare message IDs for deletion after loop completes\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $('Config').first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(cfg.chatId);\n\n// Get message IDs to delete\nconst lastMsgId = sd.calcProgress?.[cid]?.lastMsgId || null;\nconst progressMsgId = sd.progress?.[cid]?.message_id || null;\n\nconsole.log('=== PREP CLEANUP ===');\nconsole.log('ChatId:', cid);\nconsole.log('Work msg:', lastMsgId);\nconsole.log('Progress msg:', progressMsgId);\n\nreturn {\n  json: {\n    chatId: cfg.chatId,\n    bot_token: cfg.bot_token,\n    L: cfg.L,\n    _delete_work_msg: lastMsgId,\n    _delete_progress_msg: progressMsgId\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "3f30a182-fa54-45fd-b259-01e60b1f9e09",
      "name": "Acc",
      "type": "n8n-nodes-base.code",
      "position": [
        1184,
        2960
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// ACC - Accumulate calculation results for final aggregation\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst sd = $getWorkflowStaticData('global');\nconst w = $('📊 Update Result').first().json;\nconst cid = String(w.chatId);\n\nif (!sd.res) sd.res = {};\nif (!sd.res[cid]) sd.res[cid] = [];\nsd.res[cid].push(w);\n\nconsole.log('Accumulated:', sd.res[cid].length, '/', w.total_works);\n\nreturn { json: w };"
      },
      "typeVersion": 2
    },
    {
      "id": "61587d91-08a4-4444-aa10-7412c1044888",
      "name": "📤 Edit Result",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1008,
        2960
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/editMessageText",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={\"chat_id\": {{ $json.chatId }}, \"message_id\": {{ $json._edit_msg_id || 0 }}, \"text\": {{ JSON.stringify($json._result_text) }}, \"parse_mode\": \"Markdown\"}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "4f7a0cec-1f13-42f6-b667-521ffdf1a160",
      "name": "📊 Update Result",
      "type": "n8n-nodes-base.code",
      "position": [
        848,
        2960
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// UPDATE RESULT - Format result message (✓ Found / Not found)\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst calcData = $input.first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(calcData.chatId);\nconst L = calcData.L || {};\n\nconst current = calcData.work_index || 1;\nconst total = calcData.total_works || 1;\nconst name = calcData.name || calcData.sq || 'Work';\n\nlet shortName = name.length > 22 ? name.substring(0, 19) + '...' : name;\n\nconst tc = parseFloat(calcData.tc) || 0;\nconst uc = parseFloat(calcData.uc) || 0;\nconst rateCode = String(calcData.rate_code || '');\nconst rateName = String(calcData.rate_name || '');\nconst currency = calcData.currency || L.sym || '€';\n\nconst hasValidRate = rateCode && \n  !rateCode.includes('NOT_FOUND') && \n  !rateCode.includes('PAYLOAD_NOT_FOUND');\n\nconst found = tc > 0 || uc > 0 || hasValidRate || (rateName && rateName.length > 0);\n\nconsole.log('Result:', shortName, found ? '✓' : '✗', tc.toFixed(0), currency);\n\nlet text = '';\nif (found) {\n  text = current + '/' + total + ' ' + shortName + ' ✓\\n';\n  text += tc.toFixed(0) + ' ' + currency;\n  if (rateCode && hasValidRate) text += ' · ' + rateCode.substring(0, 20);\n} else {\n  text = current + '/' + total + ' ' + shortName + '\\n';\n  text += (L.not_found || 'Не найдено');\n}\n\nconst msgId = sd.calcProgress?.[cid]?.lastMsgId || null;\n\nreturn { json: { ...calcData, _result_text: text, _edit_msg_id: msgId, _found: found } };"
      },
      "typeVersion": 2
    },
    {
      "id": "d9f47e58-5041-4e40-b83d-db1446c33788",
      "name": "1️⃣ Prep Query",
      "type": "n8n-nodes-base.code",
      "position": [
        1184,
        2560
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// PREP QUERY - Prepare search query with credentials from TOKEN\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst loopItem = $input.first().json;\nconst tokenData = $('🔑 TOKEN').first().json;\n\nconst originalQuery = loopItem.sq || loopItem.query || loopItem.name || '';\nconst collectionName = loopItem.db || '';\nconst L = loopItem.L || {};\nconst workUnit = loopItem.unit || 'm²';\nconst workQty = loopItem.qty || 1;\n\n// Get credentials from TOKEN node\nconst OPENAI_API_KEY = tokenData.OPENAI_API_KEY || '';\nconst QDRANT_URL = tokenData.QDRANT_URL || 'http://localhost:6333';\nconst QDRANT_API_KEY = tokenData.QDRANT_API_KEY || '';\n\nconsole.log('=== PREP QUERY ===');\nconsole.log('Query:', originalQuery);\nconsole.log('Collection:', collectionName);\nconsole.log('Qdrant URL:', QDRANT_URL);\n\nif (!OPENAI_API_KEY || !originalQuery || !collectionName) {\n  console.log('ERROR: Missing required data');\n  return [{ json: { ...loopItem, _error: 'Missing data', _skip: true } }];\n}\n\nconst searchLang = L.search_lang || 'Russian';\n\n// Detect database language from collection name\nconst isRussianDB = collectionName.includes('RU_') || collectionName.includes('_RU');\nconst isGermanDB = collectionName.includes('DE_');\nconst dbLang = isRussianDB ? 'Russian' : (isGermanDB ? 'German' : 'English');\n\nconst transformPrompt = `You are a construction cost database search expert for ${dbLang} construction rates database.\n\nTASK: Transform user query into optimal SEARCH KEYWORDS that will match entries in a vector database of construction work rates.\n\nDATABASE CONTEXT:\n- Contains standardized construction work rates with codes, names, units, resources\n- Each rate has: rate_code, rate_name, rate_unit, scope_of_work, resources\n- Language: ${dbLang}\n\nTRANSFORMATION RULES:\n1. EXPAND abbreviations to full professional terms\n2. ADD synonyms and related construction terms\n3. INCLUDE work action verbs (install, apply, lay, mount)\n4. Keep original query terms + expanded terms\n5. Output in ${dbLang} language only\n\nUSER QUERY: ${originalQuery}\nUNIT: ${workUnit}\n\nReply with ONLY the optimized search keywords (one line, no explanations):`;\n\nreturn [{ json: {\n  ...loopItem,\n  _original_query: originalQuery,\n  _transform_prompt: transformPrompt,\n  _collection: collectionName,\n  _openai_key: OPENAI_API_KEY,\n  _db_lang: dbLang,\n  _qdrant_url: QDRANT_URL,\n  _qdrant_key: QDRANT_API_KEY\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "3f571e9d-54ec-4cbc-8d4a-016e98aa691a",
      "name": "💾 Save Work Msg",
      "type": "n8n-nodes-base.code",
      "position": [
        1008,
        2560
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// SAVE WORK MSG - Store message ID for later editing/deletion\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst loopItem = $('📝 Prep Work Msg').first().json;\nconst tgResp = $input.first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(loopItem.chatId);\n\nif (!sd.calcProgress) sd.calcProgress = {};\nif (!sd.calcProgress[cid]) sd.calcProgress[cid] = {};\nsd.calcProgress[cid].lastMsgId = tgResp.result?.message_id || null;\n\nconsole.log('Saved msg ID:', sd.calcProgress[cid].lastMsgId);\n\nreturn { json: loopItem };"
      },
      "typeVersion": 2
    },
    {
      "id": "5e4d6280-54eb-4003-abc6-0b1a65b85aeb",
      "name": "📤 Send Work",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        832,
        2560
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"chat_id\": {{ $(\"📝 Prep Work Msg\").item.json.chatId }}, \"text\": {{ JSON.stringify($(\"📝 Prep Work Msg\").item.json._work_text) }}, \"parse_mode\": \"Markdown\"}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "298424b6-a27f-4213-acfa-00355e55abf5",
      "name": "🗑️ Delete Prev",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        640,
        2560
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/deleteMessage",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={\"chat_id\": {{ $json.chatId }}, \"message_id\": {{ $json._prev_msg_id || 0 }}}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "3d061508-ba4c-4138-83cc-4bdbf2b61d2a",
      "name": "📝 Prep Work Msg",
      "type": "n8n-nodes-base.code",
      "position": [
        464,
        2560
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// PREP WORK MSG - Create \"Searching...\" message for current work\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst loopItem = $('Loop').first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(loopItem.chatId);\nconst L = loopItem.L || {};\n\nconst current = loopItem.work_index || 1;\nconst total = loopItem.total_works || 1;\nconst name = loopItem.name || loopItem.sq || 'Work';\nconst qty = loopItem.qty || 1;\nconst unit = loopItem.unit || 'm²';\nconst room = loopItem.room || '';\n\nlet shortName = name.length > 25 ? name.substring(0, 22) + '...' : name;\n\n// Minimal message\nlet text = current + '/' + total + ' ' + shortName + '\\n';\ntext += qty + ' ' + unit;\nif (room) text += ' · ' + room;\ntext += '\\n...';\n\nconst prevMsgId = sd.calcProgress?.[cid]?.lastMsgId || null;\n\nconsole.log('Work', current, '/', total);\n\nreturn { json: { ...loopItem, _work_text: text, _prev_msg_id: prevMsgId } };"
      },
      "typeVersion": 2
    },
    {
      "id": "b4a6f71f-4b4b-45a3-9e3b-b947849994e1",
      "name": "Loop",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        192,
        2256
      ],
      "parameters": {
        "options": {
          "reset": false
        }
      },
      "typeVersion": 3
    },
    {
      "id": "e6bef734-a432-4c92-8157-f4d0637457b2",
      "name": "Prep Works",
      "type": "n8n-nodes-base.code",
      "position": [
        -48,
        2256
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// PREP WORKS - Prepare work items for calculation loop (FREE DEMO: 5 items)\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $input.first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\n\n// Get limited works list (max 5 for FREE DEMO)\nconst FREE_LIMIT = cfg._free_limit || 5;\nconst works = session.limitedWorks || (session.works || []).slice(0, FREE_LIMIT);\nconst db = session.db || cfg.db;\nconst L = session.L || cfg.L;\n\n// Initialize results accumulator\nif (!sd.res) sd.res = {};\nsd.res[cid] = [];\n\nconst totalWorks = works.length;\n\nconsole.log('=== PREP WORKS ===');\nconsole.log('Processing:', totalWorks, 'items');\n\n// Return array for loop processing\nreturn works.map((w, idx) => ({ \n  json: { \n    ...w, \n    db, \n    L, \n    currency: L?.sym || '€',\n    bot_token: cfg.bot_token, \n    chatId: cid, \n    sq: w.query || w.name,\n    original_query: w.query || w.name,\n    work_index: idx + 1, \n    total_works: totalWorks,\n    _is_limited: cfg._is_limited,\n    _original_total: cfg._total_works\n  } \n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "2861b910-50e2-4da2-b094-0729a451ffe3",
      "name": "Save Progress ID",
      "type": "n8n-nodes-base.code",
      "position": [
        -224,
        2256
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// SAVE PROGRESS ID - Store initial progress message ID for cleanup\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $('Config').first().json;\nconst prepData = $('📝 Prep Progress').first().json;\nconst telegramResponse = $input.first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(cfg.chatId);\n\n// Store progress message info\nif (!sd.progress) sd.progress = {};\nsd.progress[cid] = {\n  message_id: telegramResponse.result?.message_id || 0,\n  chat_id: cfg.chatId,\n  bot_token: cfg.bot_token\n};\n\n// Initialize calculation progress tracker\nif (!sd.calcProgress) sd.calcProgress = {};\nsd.calcProgress[cid] = { lastMsgId: null };\n\nconsole.log('Progress msg ID:', sd.progress[cid].message_id);\n\nreturn { \n  json: { \n    ...cfg,\n    _free_limit: prepData._free_limit,\n    _is_limited: prepData._is_limited,\n    _total_works: prepData._total_works\n  } \n};"
      },
      "typeVersion": 2
    },
    {
      "id": "1ff11965-3d5d-4f71-a77d-2e826124348b",
      "name": "📤 Send Progress",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -432,
        2256
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"chat_id\": {{ $json._progress_chat_id }}, \"text\": {{ JSON.stringify($json._progress_text) }}, \"parse_mode\": \"Markdown\"}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "ae966de2-9499-41b2-b042-6e6afd09c219",
      "name": "📝 Prep Progress",
      "type": "n8n-nodes-base.code",
      "position": [
        -672,
        2256
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// PREP PROGRESS - Show initial calculation message with FREE DEMO limit\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $('Config').first().json;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(cfg.chatId);\nconst session = sd.sess?.[cid] || {};\n\nconst allWorks = session.works || [];\n\n// Get L from Config (primary) or session (fallback)\nconst L = cfg.L || session.L || {};\n\nconsole.log('=== PREP PROGRESS ===');\nconsole.log('L.search_lang:', L.search_lang);\n\n// FREE DEMO LIMIT\nconst FREE_LIMIT = 5;\nconst totalWorks = allWorks.length;\nconst isLimited = totalWorks > FREE_LIMIT;\nconst worksToProcess = Math.min(totalWorks, FREE_LIMIT);\n\nsession.isLimited = isLimited;\nsession.totalWorks = totalWorks;\nsession.limitedWorks = allWorks.slice(0, FREE_LIMIT);\n\nconst estimatedMinutes = Math.max(1, Math.ceil(worksToProcess * 8 / 60));\n\n// Use localized text with Russian fallback\nconst calcWord = L.loading || 'Расчёт...';\nconst itemsWord = L.items || 'позиций';\nconst minWord = L.min || 'мин';\n\nlet text = '';\nif (isLimited) {\n  text = 'FREE DEMO · ' + FREE_LIMIT + '/' + totalWorks + ' ' + itemsWord + '\\n';\n} else {\n  text = calcWord + ' ' + worksToProcess + ' ' + itemsWord + '\\n';\n}\ntext += '~' + estimatedMinutes + ' ' + minWord;\n\nreturn {\n  json: {\n    ...cfg,\n    _progress_chat_id: cfg.chatId,\n    _progress_text: text,\n    _free_limit: FREE_LIMIT,\n    _is_limited: isLimited,\n    _total_works: totalWorks\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "cf55b185-8ee0-4fdd-ba0d-7871cefe27b2",
      "name": "📄 PDF Download Prep",
      "type": "n8n-nodes-base.code",
      "position": [
        -240,
        1696
      ],
      "parameters": {
        "jsCode": "// 📄 PDF DOWNLOAD PREP\nconst cfg = $('Config').first().json;\nconsole.log('=== PDF DOWNLOAD PREP ===');\nreturn {\n  json: {\n    ...cfg,\n    pdfFileId: cfg.pdfFileId,\n    pdfFileName: cfg.pdfFileName || 'document.pdf'\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "8ec32217-3765-43f0-97c3-bf93537fdcd6",
      "name": "🧹 Deduplicate & Merge",
      "type": "n8n-nodes-base.code",
      "position": [
        1968,
        1568
      ],
      "parameters": {
        "jsCode": "// 🧹 DEDUPLICATE & MERGE v7\nconst cfgNode = $('Config').first().json;\nconst cid = String(cfgNode.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst acc = sd.pdfAcc?.[cid] || { rooms: [], works: [] };\n\nconsole.log('=== DEDUPLICATE v7 ===');\nconsole.log('Raw rooms:', acc.rooms.length, 'works:', acc.works.length);\n\n// Dedupe rooms\nconst roomMap = new Map();\nfor (const r of acc.rooms) {\n  const key = (r.name || '').toLowerCase().trim();\n  if (!roomMap.has(key) || (r.area_m2 || 0) > (roomMap.get(key).area_m2 || 0)) {\n    roomMap.set(key, r);\n  }\n}\nconst rooms = Array.from(roomMap.values()).map((r, i) => ({\n  ...r,\n  id: 'R' + String(i + 1).padStart(3, '0')\n}));\n\n// Dedupe works\nconst workMap = new Map();\nfor (const w of acc.works) {\n  const key = (w.name || '').toLowerCase().trim() + '_' + (w.unit || '') + '_' + (w.room || '');\n  if (workMap.has(key)) {\n    workMap.get(key).qty += (w.qty || 1);\n  } else {\n    workMap.set(key, { ...w, qty: w.qty || 1 });\n  }\n}\n\nconst works = Array.from(workMap.values()).map((w, i) => ({\n  id: 'W' + String(i + 1).padStart(3, '0'),\n  seq: i + 1,\n  name: w.name,\n  query: w.query || w.name,\n  qty: Math.round((w.qty || 1) * 100) / 100,\n  unit: w.unit || 'm²',\n  room: w.room || '',\n  details: w.details || '',\n  conf: 'medium'\n}));\n\nconsole.log('Unique rooms:', rooms.length, 'works:', works.length);\n\nconst totalArea = rooms.reduce((sum, r) => sum + (r.area_m2 || 0), 0);\n\n// Save to session\nif (!sd.sess) sd.sess = {};\nif (!sd.sess[cid]) sd.sess[cid] = {};\nsd.sess[cid].works = works;\nsd.sess[cid].rooms = rooms;\nsd.sess[cid].totalArea = totalArea;\nsd.sess[cid].L = cfgNode.L;\nsd.sess[cid].state = 'wait_edit';\n\n// Cleanup\nif (sd.pdfAcc) delete sd.pdfAcc[cid];\nif (sd.pdfData) delete sd.pdfData[cid];\n\nreturn {\n  json: {\n    chatId: cfgNode.chatId,\n    bot_token: cfgNode.bot_token,\n    L: cfgNode.L,\n    works,\n    rooms,\n    totalArea\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "82860aeb-1b85-4c4e-a97d-b22d3b6ddc4b",
      "name": "📤 Send Works",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2608,
        1328
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"chat_id\": {{ $json.chatId }}, \"text\": {{ JSON.stringify($json.msg) }}, \"parse_mode\": \"Markdown\", \"reply_markup\": {\"inline_keyboard\": {{ JSON.stringify($json.keyboard) }}}}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "a90e8421-983b-4d69-b7ff-33209f45e184",
      "name": "📊 Show Works",
      "type": "n8n-nodes-base.code",
      "position": [
        2480,
        1472
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// SHOW WORKS - Display extracted work items with edit buttons\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfgNode = $('Config').first().json;\nconst inputData = $input.first().json;\nconst sd = $getWorkflowStaticData('global');\n\nconst chatId = inputData.chatId || cfgNode.chatId;\nconst bot_token = inputData.bot_token || cfgNode.bot_token;\nconst cid = String(chatId);\n\n// Get L from multiple sources (session is most reliable)\nconst session = sd.sess?.[cid] || {};\nlet L = cfgNode.L || session.L || inputData.L || {};\n\n// Get works from input (from Deduplicate) or session\nconst works = inputData.works || session.works || [];\nconst rooms = inputData.rooms || session.rooms || [];\n\nconsole.log('=== SHOW WORKS ===');\nconsole.log('Works:', works.length);\nconsole.log('L.search_lang:', L.search_lang);\n\n// Save to session\nif (!sd.sess) sd.sess = {};\nif (!sd.sess[cid]) sd.sess[cid] = {};\nif (works.length > 0) {\n  sd.sess[cid].works = works;\n  sd.sess[cid].rooms = rooms;\n  sd.sess[cid].L = L;\n  sd.sess[cid].db = cfgNode.db;\n  sd.sess[cid].state = 'wait_edit';\n}\n\nfunction short(str, len) {\n  str = String(str || '');\n  return str.length > len ? str.substring(0, len - 1) + '…' : str;\n}\n\nconst totalArea = rooms.reduce((sum, r) => sum + (r.area_m2 || 0), 0);\n\n// Use localized labels with fallback\nconst roomWord = L.rooms || 'комнат';\nconst workWord = L.works_identified || 'позиций';\nconst generalWord = L.general || 'Общее';\n\nlet msg = '*' + rooms.length + ' ' + roomWord;\nif (totalArea > 0) msg += ' · ' + (Math.round(totalArea * 10) / 10) + ' m²';\nmsg += '*\\n';\nmsg += '_' + works.length + ' ' + workWord + '_\\n\\n';\n\nif (works.length === 0) {\n  msg += '_' + (L.no_works || 'Работы не найдены') + '_\\n';\n} else {\n  const worksByRoom = new Map();\n  const noRoom = [];\n\n  for (const w of works) {\n    if (w.room && w.room.length > 0) {\n      if (!worksByRoom.has(w.room)) worksByRoom.set(w.room, []);\n      worksByRoom.get(w.room).push(w);\n    } else {\n      noRoom.push(w);\n    }\n  }\n\n  let workNum = 1;\n\n  for (const [roomName, roomWorks] of worksByRoom) {\n    const room = rooms.find(r => r.name === roomName);\n    const areaStr = room?.area_m2 ? ' · ' + room.area_m2 + ' m²' : '';\n    msg += '*' + short(roomName, 20) + '*' + areaStr + '\\n';\n\n    for (const w of roomWorks) {\n      const wname = short(w.name || 'Work', 25);\n      const qty = typeof w.qty === 'number' ? Math.round(w.qty * 100) / 100 : w.qty;\n      msg += workNum + '. ' + wname + ' — ' + qty + ' ' + (w.unit || '') + '\\n';\n      workNum++;\n    }\n    msg += '\\n';\n  }\n\n  if (noRoom.length > 0) {\n    msg += '*' + generalWord + '*\\n';\n    for (const w of noRoom) {\n      const wname = short(w.name || 'Work', 25);\n      const qty = typeof w.qty === 'number' ? Math.round(w.qty * 100) / 100 : w.qty;\n      msg += workNum + '. ' + wname + ' — ' + qty + ' ' + (w.unit || '') + '\\n';\n      workNum++;\n    }\n  }\n}\n\n// Build keyboard\nconst keyboard = [];\nconst maxBtns = works.length;\nif (maxBtns > 0) {\n  for (let i = 0; i < maxBtns; i += 5) {\n    const row = [];\n    for (let j = 0; j < 5 && i + j < maxBtns; j++) {\n      row.push({ text: '✏' + (i + j + 1), callback_data: 'edit_work_' + (i + j) });\n    }\n    keyboard.push(row);\n  }\n}\n\nkeyboard.push([\n  { text: L.btn_add_work || '+ Позиция', callback_data: 'add_work' },\n  { text: L.btn_calc || '▶ Расчёт', callback_data: 'calculate' }\n]);\nkeyboard.push([\n  { text: L.btn_new || '🔄 Заново', callback_data: 'new_photo' }\n]);\n\nreturn { json: { chatId, bot_token, L, msg, keyboard, works, rooms } };"
      },
      "typeVersion": 2
    },
    {
      "id": "19a3735f-b966-48c3-a83e-5c0c9122fe0a",
      "name": "📦 Accumulate Pages",
      "type": "n8n-nodes-base.code",
      "position": [
        2208,
        1712
      ],
      "parameters": {
        "jsCode": "// 📦 ACCUMULATE v7\nconst cfg = $input.first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\n\nif (!sd.pdfAcc) sd.pdfAcc = {};\nif (!sd.pdfAcc[cid]) sd.pdfAcc[cid] = { rooms: [], works: [], totalArea: 0 };\n\nconst acc = sd.pdfAcc[cid];\n\nif (cfg.pageRooms) acc.rooms.push(...cfg.pageRooms);\nif (cfg.pageWorks) acc.works.push(...cfg.pageWorks);\nif (cfg.totalArea) acc.totalArea = Math.max(acc.totalArea, cfg.totalArea);\n\nconsole.log('Accumulated - rooms:', acc.rooms.length, 'works:', acc.works.length);\n\nreturn { json: { ...cfg } };"
      },
      "typeVersion": 2
    },
    {
      "id": "37b2c0c2-0df0-4c52-a492-1516065d9e07",
      "name": "🏠 Parse PDF Page",
      "type": "n8n-nodes-base.code",
      "position": [
        1984,
        1712
      ],
      "parameters": {
        "jsCode": "// 🏠 PARSE PDF PAGE v7\nconst prepData = $('👁️ Prep Vision PDF').first().json;\nconst resp = $input.first().json;\n\nif (prepData._skip_vision) {\n  return { json: { ...prepData, pageRooms: [], pageWorks: [], totalArea: 0 } };\n}\n\nlet raw = resp.candidates?.[0]?.content?.parts?.[0]?.text || '';\nlet parsed = { rooms: [], works: [], total_area_m2: 0 };\n\ntry {\n  let clean = raw.replace(/```json\\s*/gi, '').replace(/```\\s*/gi, '').trim();\n  const s = clean.indexOf('{'), e = clean.lastIndexOf('}');\n  if (s !== -1 && e > s) {\n    parsed = JSON.parse(clean.substring(s, e + 1));\n  }\n  console.log('Parsed - rooms:', parsed.rooms?.length, 'works:', parsed.works?.length);\n} catch(err) {\n  console.log('Parse error:', err.message);\n}\n\nconst rooms = (parsed.rooms || []).map((r, i) => ({\n  id: 'R' + String(i + 1).padStart(3, '0'),\n  name: r.name || 'Room ' + (i + 1),\n  area_m2: r.area_m2 || 0\n}));\n\nconst works = (parsed.works || []).map((w, i) => {\n  let name = w.name || 'Work';\n  if (w.details && w.details.length > 0 && w.details.length < 30) {\n    name = name + ' ' + w.details;\n  }\n  return {\n    id: 'W' + String(i + 1).padStart(3, '0'),\n    name: name.trim(),\n    query: (w.name || 'Work').trim(),\n    room: w.room || '',\n    qty: w.qty || w.quantity || 1,\n    unit: w.unit || 'm²',\n    details: w.details || '',\n    conf: 'medium'\n  };\n});\n\nreturn {\n  json: {\n    ...prepData,\n    pageRooms: rooms,\n    pageWorks: works,\n    totalArea: parsed.total_area_m2 || 0\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "c060c9ed-960a-4dc5-a353-d7626525853e",
      "name": "👁️ Call Vision PDF",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1760,
        1712
      ],
      "parameters": {
        "url": "={{ $json._vision_url }}",
        "method": "POST",
        "options": {
          "timeout": 120000,
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={{ JSON.stringify($json._vision_body) }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "f5b2b616-d25f-4174-8f08-416deffe08f1",
      "name": "👁️ Prep Vision PDF",
      "type": "n8n-nodes-base.code",
      "position": [
        1536,
        1712
      ],
      "parameters": {
        "jsCode": "// 👁️ PREP VISION PDF v7 - UNIVERSAL\nconst cfgNode = $('Config').first().json;\nconst tokenConfig = $('🔑 TOKEN').first().json;\nconst loopData = $('🔁 Loop PDF Pages').first().json;\n\nconst GEMINI_API_KEY = tokenConfig.GEMINI_API_KEY;\nconst cid = String(cfgNode.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst pdfBase64 = sd.pdfData?.[cid]?.base64 || '';\n\nif (!pdfBase64 || pdfBase64.length < 100) {\n  return { json: { ...cfgNode, _skip_vision: true } };\n}\n\nconst lang = cfgNode.lang || 'EN';\nconst langName = {\n  'RU': 'Russian', 'EN': 'English', 'DE': 'German',\n  'ES': 'Spanish', 'FR': 'French', 'PT': 'Portuguese',\n  'ZH': 'Chinese', 'AR': 'Arabic', 'HI': 'Hindi'\n}[lang] || 'English';\n\nconst prompt = `Analyze this architectural floor plan / construction drawing.\n\nYOUR TASK:\n1. Find room schedule/table if present - extract room names and areas\n2. If no table, estimate rooms from the drawing\n3. Generate construction works list for cost estimation database search\n\nOUTPUT in ${langName}:\n{\n  \"total_area_m2\": 99.15,\n  \"rooms\": [\n    {\"name\": \"Room Name\", \"area_m2\": 15.5}\n  ],\n  \"works\": [\n    {\"name\": \"work description\", \"room\": \"Room Name\", \"qty\": 15.5, \"unit\": \"m²\", \"details\": \"optional specs\"}\n  ]\n}\n\nWORK NAMING GUIDELINES:\n- Use short, searchable descriptions (good for database lookup)\n- Include specifications when visible: dimensions, materials, types\n- Examples of good work names:\n  - \"Floor tiling\" or \"Laminate flooring\" (not just \"flooring\")\n  - \"Wall plastering\" or \"Drywall installation\"\n  - \"Interior door 800×2000mm\" (include size if visible)\n  - \"Suspended ceiling\" or \"Stretch ceiling\"\n  - \"Toilet installation\" or \"Sink installation\"\n\nQUANTITY GUIDELINES:\n- Floor works: qty = room area\n- Wall works: estimate from perimeter × height (typically 2.5-3m)\n- Ceiling works: qty = room area\n- Doors/windows/fixtures: count from drawing\n- Use realistic quantities that match the total area\n\nIMPORTANT:\n- Read any tables/schedules in the drawing for accurate data\n- Each work should reference which room it belongs to\n- Include \"details\" field for dimensions, materials, or specifications when visible\n- Respond ONLY with valid JSON, no explanations`;\n\nconst apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=' + GEMINI_API_KEY;\nconst requestBody = {\n  contents: [{ parts: [\n    { text: prompt },\n    { inline_data: { mime_type: 'application/pdf', data: pdfBase64 } }\n  ]}],\n  generationConfig: { temperature: 0.1, maxOutputTokens: 8000 }\n};\n\nreturn { json: { ...cfgNode, _vision_url: apiUrl, _vision_body: requestBody } };"
      },
      "typeVersion": 2
    },
    {
      "id": "452a8445-7f83-4adc-9bd4-74eb3269948c",
      "name": "🔁 Loop PDF Pages",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        1328,
        1696
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "01bc5a5b-160a-42b2-a70b-96bf101b50c1",
      "name": "📄 Prep Pages Loop",
      "type": "n8n-nodes-base.code",
      "position": [
        1104,
        1696
      ],
      "parameters": {
        "jsCode": "// 📄 PREP PAGES LOOP\nconst cfg = $('📝 Prep PDF Message').first().json;\nconst pages = cfg.pdfPages || [{pageNum: 1}];\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst pdfData = sd.pdfData?.[cid] || {};\n\nreturn pages.map((p, i) => ({\n  json: {\n    ...cfg,\n    currentPage: p.pageNum,\n    totalPages: pages.length,\n    pdfBase64: pdfData.base64 || ''\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "65af249b-61c8-4e32-8ff3-85dc1a4c4849",
      "name": "📤 PDF Received",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        880,
        1696
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"chat_id\": {{ $json._tg_chat_id }}, \"text\": {{ JSON.stringify($json._tg_text) }}, \"parse_mode\": \"Markdown\"}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "2744b78d-8522-4d10-964b-9c42471b3af8",
      "name": "📝 Prep PDF Message",
      "type": "n8n-nodes-base.code",
      "position": [
        656,
        1696
      ],
      "parameters": {
        "jsCode": "// 📝 PREP PDF MESSAGE\nconst cfg = $('Config').first().json;\nconst data = $input.first().json;\nconst pages = data.totalPages || 1;\nconst estMinutes = Math.max(1, Math.ceil(pages * 0.5));\n\nlet text = '📄 *PDF received*\\n\\n';\ntext += '⏳ Analyzing drawing...\\n';\ntext += '⏱ ~' + estMinutes + ' min\\n\\n';\ntext += '_Report will be sent to this chat_';\n\nreturn {\n  json: {\n    ...data,\n    _tg_chat_id: cfg.chatId,\n    _tg_text: text\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "668cea57-5db4-4e3c-80e0-b118fd7471de",
      "name": "📄 Split PDF Pages",
      "type": "n8n-nodes-base.code",
      "position": [
        448,
        1696
      ],
      "parameters": {
        "jsCode": "// 📄 SPLIT PDF PAGES\nconst cfg = $('Config').first().json;\nconst inputBinary = $input.first().binary;\nconst MAX_PAGES = 3;\nlet pdfBase64 = '';\n\nif (inputBinary) {\n  const key = Object.keys(inputBinary)[0];\n  if (key) pdfBase64 = inputBinary[key].data || '';\n}\n\nconst sizeKB = pdfBase64 ? (pdfBase64.length * 0.75) / 1024 : 0;\nconst totalPages = Math.min(Math.max(1, Math.ceil(sizeKB / 150)), MAX_PAGES);\n\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nif (!sd.pdfData) sd.pdfData = {};\nsd.pdfData[cid] = { base64: pdfBase64, totalPages };\n\nconsole.log('PDF size KB:', Math.round(sizeKB), 'Pages:', totalPages);\n\nreturn {\n  json: {\n    ...cfg,\n    pdfPages: Array.from({length: totalPages}, (_, i) => ({pageNum: i+1})),\n    totalPages\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "04c81dbc-547c-4de3-9e26-2524a9ab85e9",
      "name": "📄 Download PDF",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        224,
        1696
      ],
      "parameters": {
        "url": "=https://api.telegram.org/file/bot{{ $('🔑 TOKEN').first().json.bot_token }}/{{ $json.result.file_path }}",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "file"
            }
          }
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "15cb3ffe-a7ca-4012-8818-cf3e5a546cf4",
      "name": "📄 Get PDF Path",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -16,
        1696
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/getFile?file_id={{ $json.pdfFileId }}",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "7a2b25b7-a115-4f32-99cd-5533b51c48c6",
      "name": "Merge To Vision1",
      "type": "n8n-nodes-base.code",
      "position": [
        1008,
        1344
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Merge paths before Prep Vision\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst input = $input.first().json;\n\nconsole.log('=== MERGE TO VISION ===');\nconsole.log('photos:', input.photos?.length || 0);\nconsole.log('skipVision:', input.skipVision);\nconsole.log('L.search_lang:', input.L?.search_lang);\n\nreturn { json: input };"
      },
      "typeVersion": 2
    },
    {
      "id": "ad2d437c-7f99-4804-a83e-c38bec553cfc",
      "name": "📤 No Photos Msg1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        832,
        1024
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $json.chatId }},\n  \"text\": {{ JSON.stringify('📷 ' + (($json.L && $json.L.photo) ? $json.L.photo.split('\\n')[0] : 'Please send a photo first')) }},\n  \"parse_mode\": \"Markdown\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "0328789b-cbae-4e5f-b027-eba05a7c24bc",
      "name": "IF No Photos1",
      "type": "n8n-nodes-base.if",
      "position": [
        544,
        1072
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.noPhotosError }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "6d1879fc-9f72-45b6-bd17-808508e4c422",
      "name": "Merge Vision1",
      "type": "n8n-nodes-base.code",
      "position": [
        1472,
        1344
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Merge Vision API response with original input data\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst originalInput = $('Prep Vision1').first().json;\nconst apiResponse = $input.first().json;\n\nconsole.log('=== MERGE VISION ===');\nconsole.log('Provider:', originalInput.provider);\nconsole.log('Has candidates:', (apiResponse.candidates || []).length);\nconsole.log('Has choices:', (apiResponse.choices || []).length);\n\nreturn { \n  json: { \n    ...originalInput,\n    candidates: apiResponse.candidates || [],\n    promptFeedback: apiResponse.promptFeedback,\n    choices: apiResponse.choices || [],\n    error: apiResponse.error\n  } \n};"
      },
      "typeVersion": 2
    },
    {
      "id": "7768ef04-9fa7-4ccf-8726-658d20b9d468",
      "name": "Call Vision1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1312,
        1344
      ],
      "parameters": {
        "url": "={{ $json.apiUrl }}",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify($json.requestBody) }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Authorization",
              "value": "={{ $json.provider === 'openai' ? 'Bearer ' + $json.apiKey : '' }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "7d5ffd29-d8de-427e-8812-eb0d53b5b690",
      "name": "Prep Vision1",
      "type": "n8n-nodes-base.code",
      "position": [
        1168,
        1344
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Prepare request for Vision API (Gemini or OpenAI GPT-4o)\n// FIXED: Description in correct language\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst input = $input.first().json;\n\nconst photos = input.photos || [];\nconsole.log('=== PREP VISION ===');\nconsole.log('Photos received:', photos.length);\n\nif (photos.length === 0) {\n  console.log('❌ No photos to process');\n  return { json: { ...input, error: 'No photos', candidates: [], choices: [] } };\n}\n\nconsole.log('Photo base64 lengths:', photos.map(p => p.base64?.length || 0));\nconst description = input.description || '';\nconst L = input.L || {};\nconst searchLang = L.search_lang || 'German';\n\nconsole.log('Search language:', searchLang);\n\nconst tokenConfig = $('🔑 TOKEN').first().json;\nconst provider = (tokenConfig.AI_PROVIDER || 'gemini').toLowerCase();\nconst geminiKey = tokenConfig.GEMINI_API_KEY;\nconst openaiKey = tokenConfig.OPENAI_API_KEY;\n\nconsole.log('AI Provider:', provider);\n\nif (provider === 'gemini' && (!geminiKey || geminiKey === 'YOUR_GEMINI_API_KEY_HERE')) {\n  return { json: { ...input, error: 'GEMINI_API_KEY not configured', candidates: [] } };\n}\nif (provider === 'openai' && (!openaiKey || openaiKey === 'YOUR_OPENAI_API_KEY_HERE')) {\n  return { json: { ...input, error: 'OPENAI_API_KEY not configured', choices: [] } };\n}\n\nconst LANG_CONFIG = {\n  'German': {\n    good: ['Gipskartonwand 2-lagig CW75', 'Trockenbau Abhangdecke', 'Fliesen Feinsteinzeug verlegen', 'Malerarbeiten Dispersionsfarbe', 'Elektroinstallation Steckdosen UP'],\n    bad: ['Wand', 'Decke', 'Installation'],\n    descExample: 'Renovierung Badezimmer mit Trockenbau und Fliesen',\n    descInstruction: 'Beschreibung auf Deutsch'\n  },\n  'English': {\n    good: ['Drywall installation 2-layer metal stud CW75', 'Suspended ceiling installation', 'Porcelain tile flooring installation', 'Painting emulsion 2 coats', 'Electrical outlets flush mount'],\n    bad: ['Wall', 'Ceiling', 'Tiles'],\n    descExample: 'Bathroom renovation with drywall and tiles',\n    descInstruction: 'Description in English'\n  },\n  'Russian': {\n    good: ['Устройство перегородок из ГКЛ 2 слоя профиль ПП60', 'Устройство подвесных потолков из гипсокартона', 'Облицовка пола керамогранитом 600x600', 'Окраска водоэмульсионной краской 2 слоя', 'Установка розеток скрытой проводки'],\n    bad: ['Стена', 'Потолок', 'Плитка'],\n    descExample: 'Ремонт санузла с монтажом каркаса и разводкой труб',\n    descInstruction: 'Описание на русском языке'\n  },\n  'Spanish': {\n    good: ['Tabique pladur 2 capas perfil 70', 'Falso techo desmontable', 'Solado gres porcelánico 60x60', 'Pintura plástica 2 manos', 'Mecanismos eléctricos empotrados'],\n    bad: ['Pared', 'Techo', 'Azulejos'],\n    descExample: 'Reforma baño con pladur y azulejos',\n    descInstruction: 'Descripción en español'\n  },\n  'French': {\n    good: ['Cloison placo 2 couches ossature 70', 'Plafond suspendu dalles', 'Carrelage grès cérame 60x60', 'Peinture acrylique 2 couches', 'Prises électriques encastrées'],\n    bad: ['Mur', 'Plafond', 'Carrelage'],\n    descExample: 'Rénovation salle de bain avec placo et carrelage',\n    descInstruction: 'Description en français'\n  },\n  'Portuguese': {\n    good: ['Divisória drywall 2 camadas perfil 70', 'Forro de gesso acartonado', 'Piso porcelanato 60x60', 'Pintura acrílica 2 demãos', 'Tomadas elétricas embutidas'],\n    bad: ['Parede', 'Teto', 'Piso'],\n    descExample: 'Reforma banheiro com drywall e porcelanato',\n    descInstruction: 'Descrição em português'\n  },\n  'Chinese': {\n    good: ['轻钢龙骨石膏板隔墙双层', '石膏板吊顶安装', '地砖铺贴600x600', '乳胶漆涂刷两遍', '暗装电源插座'],\n    bad: ['墙', '顶', '砖'],\n    descExample: '卫生间装修，石膏板隔墙和瓷砖铺贴',\n    descInstruction: '用中文描述'\n  },\n  'Arabic': {\n    good: ['تركيب جدران جبس بورد طبقتين', 'سقف معلق جبس بورد', 'تركيب بلاط بورسلين 60x60', 'دهان مائي طبقتين', 'تركيب مقابس كهربائية مخفية'],\n    bad: ['جدار', 'سقف', 'بلاط'],\n    descExample: 'تجديد الحمام مع الجبس بورد والبلاط',\n    descInstruction: 'الوصف باللغة العربية'\n  },\n  'Hindi': {\n    good: ['ड्राईवॉल पार्टीशन 2 लेयर', 'फॉल्स सीलिंग जिप्सम बोर्ड', 'फ्लोर टाइल्स 600x600', 'इमल्शन पेंट 2 कोट', 'इलेक्ट्रिक सॉकेट्स कंसील्ड'],\n    bad: ['दीवार', 'छत', 'टाइल'],\n    descExample: 'बाथरूम रेनोवेशन ड्राईवॉल और टाइल्स के साथ',\n    descInstruction: 'हिंदी में विवरण'\n  }\n};\n\nconst langConfig = LANG_CONFIG[searchLang] || LANG_CONFIG['English'];\n\nconst prompt = `You are an expert construction cost estimator.\n\nCRITICAL LANGUAGE REQUIREMENT:\n- ALL your output MUST be ONLY in ${searchLang} language\n- The \"description\" field MUST be in ${searchLang} (${langConfig.descInstruction})\n- All \"name\" fields MUST be in ${searchLang}\n- Do NOT use English or any other language anywhere in your response\n\nAnalyze these photos and identify construction work items.\n\nGOOD QUERIES (specific, searchable) in ${searchLang}:\n${langConfig.good.map(e => '✓ ' + e).join('\\n')}\n\nBAD QUERIES (too generic, avoid):\n${langConfig.bad.map(e => '✗ ' + e).join('\\n')}\n\n${description ? 'CONTEXT: ' + description : ''}\n\nRULES:\n1. List ONLY what you can ACTUALLY SEE in the photos\n2. Use SPECIFIC technical terms in ${searchLang} like the good examples above\n3. Include materials, dimensions, specifications when visible\n4. Estimate quantities based on photo scale\n5. Write \"description\" field in ${searchLang} language\n\nFor each work item:\n- name: Specific ${searchLang} construction term (like good examples above)\n- qty: Estimated quantity (number)\n- unit: m² / m / pcs / kg\n- conf: high (clearly visible) / medium (partially visible)\n- cat: demolition / rough / finishing / mep\n\nEXAMPLE RESPONSE in ${searchLang}:\n{\"description\":\"${langConfig.descExample}\",\"items\":[{\"name\":\"${langConfig.good[0]}\",\"qty\":10,\"unit\":\"m²\",\"conf\":\"high\",\"cat\":\"finishing\"}]}\n\nRespond ONLY with valid JSON. Remember: ALL text must be in ${searchLang}!`;\n\nlet requestBody, apiUrl, apiKey;\n\nif (provider === 'openai') {\n  apiUrl = 'https://api.openai.com/v1/chat/completions';\n  apiKey = openaiKey;\n  \n  const content = [{ type: 'text', text: prompt }];\n  \n  for (const photo of photos) {\n    if (photo.base64) {\n      content.push({\n        type: 'image_url',\n        image_url: {\n          url: 'data:image/jpeg;base64,' + photo.base64,\n          detail: 'high'\n        }\n      });\n    }\n  }\n  \n  requestBody = {\n    model: 'gpt-4o',\n    max_tokens: 4000,\n    temperature: 0.4,\n    messages: [\n      { role: 'system', content: `You are an expert construction cost estimator. CRITICAL: You MUST respond ONLY in ${searchLang} language. The \"description\" field and all \"name\" fields MUST be written in ${searchLang}. Do NOT use English. Respond only with valid JSON.` },\n      { role: 'user', content: content }\n    ]\n  };\n  \n} else {\n  apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + geminiKey;\n  apiKey = geminiKey;\n  \n  const parts = [];\n  \n  for (const photo of photos) {\n    if (photo.base64) {\n      parts.push({\n        inline_data: {\n          mime_type: 'image/jpeg',\n          data: photo.base64\n        }\n      });\n    }\n  }\n  \n  parts.push({ text: prompt });\n  \n  requestBody = {\n    contents: [{ parts: parts }],\n    generationConfig: {\n      temperature: 0.4,\n      maxOutputTokens: 4000\n    }\n  };\n}\n\nconsole.log('API URL:', apiUrl ? apiUrl.substring(0, 60) + '...' : 'EMPTY');\nconsole.log('Photos in request:', photos.length);\nconsole.log('Language:', searchLang);\n\nreturn { json: { \n  ...input, \n  provider: provider,\n  apiUrl: apiUrl,\n  apiKey: apiKey,\n  requestBody: requestBody \n}};"
      },
      "typeVersion": 2
    },
    {
      "id": "edddbbbe-e972-4c44-b57f-9d1e86768a4a",
      "name": "Convert To Base",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        1344
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Convert downloaded photo to base64 for Vision API\n// FIXED: Save base64 to session for Refine Analysis\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst prepData = $('Prep Photo Download').first().json;\nconst binaryData = $input.first().binary;\nconst sd = $getWorkflowStaticData('global');\nconst cid = String(prepData.chatId);\n\nconsole.log('=== CONVERT TO BASE64 ===');\nconsole.log('chatId:', cid);\nconsole.log('Binary data keys:', Object.keys(binaryData || {}));\n\nconst photos = [];\n\nif (binaryData) {\n  const binaryKey = Object.keys(binaryData)[0];\n  if (binaryKey && binaryData[binaryKey]) {\n    const item = binaryData[binaryKey];\n    console.log('Binary item keys:', Object.keys(item));\n    \n    if (item.data) {\n      photos.push({\n        base64: item.data,\n        caption: ''\n      });\n      console.log('✅ SUCCESS: Base64 length:', item.data.length);\n    }\n  }\n}\n\n// ═══════════════════════════════════════════════════════════════════════════\n// СОХРАНЯЕМ base64 в сессию для повторного использования (Refine Analysis)\n// ═══════════════════════════════════════════════════════════════════════════\nif (!sd.sess) sd.sess = {};\nif (!sd.sess[cid]) sd.sess[cid] = { lang: prepData.lang };\nsd.sess[cid].photos_base64 = photos;\nconsole.log('✅ Saved base64 to session for chat:', cid, 'photos:', photos.length);\n\nconst L = prepData.L || sd.sess[cid]?.L || {};\n\nreturn { json: { \n  chatId: prepData.chatId,\n  bot_token: prepData.bot_token,\n  photos: photos,\n  photoCount: photos.length,\n  description: prepData.description || '',\n  L: L,\n  lang: prepData.lang,\n  skipVision: false\n}};"
      },
      "typeVersion": 2
    },
    {
      "id": "7a2d0390-802f-4f04-966b-3a9b87652ba2",
      "name": "Download Photo File1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        640,
        1344
      ],
      "parameters": {
        "url": "=https://api.telegram.org/file/bot{{ $('🔑 TOKEN').first().json.bot_token }}/{{ $json.result.file_path }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "3cd89191-d2e5-4bfc-a156-959fb6282bba",
      "name": "Get File Path1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        464,
        1344
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/getFile?file_id={{ $json.fileId }}",
        "options": {}
      },
      "typeVersion": 4.2
    },
    {
      "id": "bab03931-39fe-4a2e-9f86-7d05ccea2b46",
      "name": "Use Stored Base64",
      "type": "n8n-nodes-base.code",
      "position": [
        832,
        1184
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Use stored base64 images from session\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst input = $input.first().json;\nconst cid = String(input.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\n\nconsole.log('=== USE STORED BASE64 ===');\nconsole.log('input.photos:', input.photos?.length || 0);\n\nconst photos = input.photos || [];\n\nif (photos.length === 0) {\n  console.log('❌ No photos in input');\n  return { json: { ...input, skipVision: true, noPhotos: true } };\n}\n\nconsole.log('✅ Using', photos.length, 'stored photos');\nconsole.log('First photo base64 length:', photos[0]?.base64?.length || 0);\n\nreturn { json: { \n  chatId: input.chatId,\n  bot_token: input.bot_token,\n  photos: photos,\n  photoCount: photos.length,\n  description: input.description || session.description || '',\n  L: input.L || session.L || {},\n  lang: input.lang || session.lang,\n  skipVision: false,\n  noPhotos: false\n}};"
      },
      "typeVersion": 2
    },
    {
      "id": "61ced79f-d12a-4285-96f4-31a3c6618574",
      "name": "IF Skip Download",
      "type": "n8n-nodes-base.if",
      "position": [
        272,
        1136
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.skipDownload }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "545a0532-c51c-40e1-8b13-2995b21c189a",
      "name": "9️⃣ Calculate",
      "type": "n8n-nodes-base.code",
      "position": [
        640,
        2960
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// STEP 8: Calculate - FIXED VERSION v3\n// Универсальный поиск данных в разных структурах\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst inputData = $input.first().json;\n\nconsole.log('═══════════════════════════════════════════════════════════════');\nconsole.log('STEP 8: CALCULATE v3');\nconsole.log('═══════════════════════════════════════════════════════════════');\n\n// ═══════════════════════════════════════════════════════════════════════════\n// УМНЫЙ ПОИСК PAYLOAD - ищем во всех возможных местах\n// ═══════════════════════════════════════════════════════════════════════════\n\nlet payload = null;\nlet payloadSource = 'not_found';\n\n// Вариант 1: _best_result.payload\nif (inputData._best_result?.payload?.rate_code) {\n  payload = inputData._best_result.payload;\n  payloadSource = '_best_result.payload';\n}\n// Вариант 2: _best_payload напрямую\nelse if (inputData._best_payload?.rate_code) {\n  payload = inputData._best_payload;\n  payloadSource = '_best_payload';\n}\n// Вариант 3: данные лежат прямо в _best_result (без вложенного payload)\nelse if (inputData._best_result?.rate_code) {\n  payload = inputData._best_result;\n  payloadSource = '_best_result (direct)';\n}\n// Вариант 4: payload внутри payload (двойная вложенность)\nelse if (inputData._best_result?.payload?.payload?.rate_code) {\n  payload = inputData._best_result.payload.payload;\n  payloadSource = '_best_result.payload.payload';\n}\n// Вариант 5: ищем rate_code где угодно в _best_result\nelse if (inputData._best_result) {\n  const br = inputData._best_result;\n  // Рекурсивный поиск\n  const findPayload = (obj, depth = 0) => {\n    if (!obj || depth > 3) return null;\n    if (obj.rate_code && obj.resources) return obj;\n    for (const key of Object.keys(obj)) {\n      if (typeof obj[key] === 'object' && obj[key] !== null) {\n        const found = findPayload(obj[key], depth + 1);\n        if (found) return found;\n      }\n    }\n    return null;\n  };\n  payload = findPayload(br);\n  if (payload) payloadSource = '_best_result (deep search)';\n}\n\nconsole.log('Payload source:', payloadSource);\nconsole.log('Payload found:', !!payload);\n\nif (payload) {\n  console.log('Payload keys:', Object.keys(payload));\n  console.log('rate_code:', payload.rate_code);\n  console.log('rate_name:', payload.rate_name?.substring(0, 50));\n  console.log('resources count:', (payload.resources || []).length);\n  console.log('cost_summary:', JSON.stringify(payload.cost_summary || {}).substring(0, 200));\n}\n\n// Если payload не найден - выводим структуру для диагностики\nif (!payload) {\n  console.log('');\n  console.log('❌ PAYLOAD NOT FOUND! Debug info:');\n  console.log('inputData keys:', Object.keys(inputData));\n  if (inputData._best_result) {\n    console.log('_best_result keys:', Object.keys(inputData._best_result));\n    console.log('_best_result.payload:', typeof inputData._best_result.payload);\n    if (inputData._best_result.payload) {\n      console.log('_best_result.payload keys:', Object.keys(inputData._best_result.payload));\n    }\n  }\n  \n  // Возвращаем ошибку с диагностикой\n  return [{ json: { \n    ...inputData,\n    rate_code: 'PAYLOAD_NOT_FOUND',\n    rate_name: inputData.name || inputData.sq || 'Unknown',\n    uc: 0, tc: 0,\n    resources: [],\n    _debug_keys: Object.keys(inputData),\n    _debug_best_result_keys: Object.keys(inputData._best_result || {}),\n    _debug_payload_source: payloadSource\n  }}];\n}\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Теперь payload найден - извлекаем данные\n// ═══════════════════════════════════════════════════════════════════════════\n\n// Базовые данные работы\nconst workName = inputData.name || inputData.sq || '';\nconst workQty = inputData.qty || 1;\nconst workUnit = inputData.unit || 'm²';\n\nconsole.log('');\nconsole.log('Work:', workName);\nconsole.log('Qty:', workQty, workUnit);\n\n// Данные расценки\nconst rateCode = payload.rate_code || 'NOT_FOUND';\nconst rateName = payload.rate_name || workName;\nconst rateUnit = payload.rate_unit || '';\n\nconsole.log('Rate code:', rateCode);\nconsole.log('Rate name:', rateName?.substring(0, 50));\nconsole.log('Rate unit:', rateUnit);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Получаем стоимость\n// ═══════════════════════════════════════════════════════════════════════════\nconst costSummary = payload.cost_summary || {};\nlet totalCost = parseFloat(costSummary.total_cost_position || costSummary.total_cost || 0);\n\n// Если cost_summary пустой, вычисляем из ресурсов\nconst rawResources = payload.resources || [];\nif (totalCost === 0 && rawResources.length > 0) {\n  totalCost = rawResources.reduce((sum, r) => {\n    return sum + parseFloat(r.resource_cost_eur || 0);\n  }, 0);\n  console.log('Total cost calculated from resources:', totalCost);\n}\n\nconsole.log('Total cost:', totalCost);\nconsole.log('Resources count:', rawResources.length);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Определяем делитель единицы измерения\n// ═══════════════════════════════════════════════════════════════════════════\nlet unitDivisor = 1;\nconst rateUnitLower = (rateUnit || '').toLowerCase();\n\nif (rateUnitLower.includes('100 ') || rateUnitLower === '100 м' || rateUnitLower === '100 м²' || rateUnitLower === '100 м2') {\n  unitDivisor = 100;\n} else if (rateUnitLower.match(/^10\\s/) || rateUnitLower.includes('10 м')) {\n  unitDivisor = 10;\n}\n\nconsole.log('Unit divisor:', unitDivisor);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Price calculation\n// ═══════════════════════════════════════════════════════════════════════════\nconst uc = unitDivisor > 0 ? totalCost / unitDivisor : 0;\nconst tc = workQty * uc;\nconst scaleFactor = unitDivisor > 0 ? workQty / unitDivisor : workQty;\n\nconsole.log('');\nconsole.log('=== PRICE CALCULATION ===');\nconsole.log('UC (price per unit):', uc.toFixed(2), 'EUR');\nconsole.log('TC (total cost):', tc.toFixed(2), 'EUR');\nconsole.log('Scale factor:', scaleFactor);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Обработка ресурсов\n// ═══════════════════════════════════════════════════════════════════════════\nlet workersTotal = 0;\nlet materialsTotal = 0;\nlet machinesTotal = 0;\nlet laborHoursTotal = 0;\n\nconst resources = rawResources.map(r => {\n  const code = r.resource_code || '';\n  const name = r.resource_name || '';\n  const unit = r.resource_unit || '';\n  const rowType = r.row_type || '';\n  \n  const originalQty = r.resource_quantity !== null && r.resource_quantity !== undefined \n    ? parseFloat(r.resource_quantity) \n    : null;\n  const pricePerUnit = parseFloat(r.resource_price_per_unit_eur_current || 0);\n  const originalCost = parseFloat(r.resource_cost_eur || 0);\n  \n  // Определяем тип ресурса\n  let resourceType = 'material';\n  const rowTypeLower = (rowType || '').toLowerCase();\n  const codeUpper = (code || '').toUpperCase();\n  \n  if (rowTypeLower === 'машинист' || rowTypeLower.includes('машинист')) {\n    resourceType = 'labor';\n  } else if (rowTypeLower === 'электричество' || rowTypeLower.includes('электричеств')) {\n    resourceType = 'machine';\n  } else if (codeUpper.startsWith('DXME') || codeUpper.startsWith('DX')) {\n    resourceType = 'machine';\n  } else if (codeUpper.startsWith('ME_') || codeUpper.startsWith('PU_') || codeUpper.startsWith('RI_')) {\n    resourceType = 'labor';\n  }\n  \n  // Масштабирование\n  let scaledQty = null;\n  let scaledCost = 0;\n  \n  if (originalQty !== null) {\n    scaledQty = originalQty * scaleFactor;\n    scaledCost = scaledQty * pricePerUnit;\n  } else {\n    scaledCost = originalCost * scaleFactor;\n  }\n  \n  // Суммируем по категориям\n  if (resourceType === 'labor') {\n    workersTotal += scaledCost;\n    if (unit === 'ч' || unit === 'чел.-ч' || unit === 'чел-ч' || unit === 'маш.-ч') {\n      laborHoursTotal += scaledQty || 0;\n    }\n  } else if (resourceType === 'machine') {\n    machinesTotal += scaledCost;\n  } else {\n    materialsTotal += scaledCost;\n  }\n  \n  return {\n    resource_code: code,\n    resource_name: name,\n    resource_unit: unit,\n    resource_type: resourceType,\n    row_type: rowType,\n    resource_price: pricePerUnit,\n    original_quantity: originalQty,\n    original_cost: originalCost,\n    scaled_quantity: scaledQty,\n    scaled_cost: scaledCost\n  };\n});\n\nconsole.log('');\nconsole.log('=== RESOURCES BREAKDOWN ===');\nconsole.log('Resources processed:', resources.length);\nconsole.log('Workers total:', workersTotal.toFixed(2), 'EUR');\nconsole.log('Materials total:', materialsTotal.toFixed(2), 'EUR');\nconsole.log('Machines total:', machinesTotal.toFixed(2), 'EUR');\nconsole.log('Labor hours:', laborHoursTotal.toFixed(1), 'h');\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Scope of work\n// ═══════════════════════════════════════════════════════════════════════════\nconst workSteps = payload.work_steps || [];\nconst scopeOfWork = workSteps.map(s => s.text || '').filter(t => t.length > 0);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Quality\n// ═══════════════════════════════════════════════════════════════════════════\nconst llmScore = inputData._llm_score || 0;\nconst qdrantScore = inputData._qdrant_score || 0;\n\nconst qualityLevel = llmScore >= 75 ? 'high' : \n                     llmScore >= 50 ? 'medium' : \n                     llmScore >= 25 ? 'low' : 'not_found';\n\nconsole.log('');\nconsole.log('═══════════════════════════════════════════════════════════════');\nconsole.log('✅ CALCULATION COMPLETE');\nconsole.log('Rate:', rateCode, '-', rateName?.substring(0, 40));\nconsole.log('Total:', tc.toFixed(2), 'EUR');\nconsole.log('Resources:', resources.length);\nconsole.log('═══════════════════════════════════════════════════════════════');\n\n// ═══════════════════════════════════════════════════════════════════════════\n// ФИНАЛЬНЫЙ РЕЗУЛЬТАТ\n// ═══════════════════════════════════════════════════════════════════════════\nreturn [{ json: { \n  // Original data\n  ...inputData,\n  name: workName,\n  qty: workQty,\n  unit: workUnit,\n  \n  // Rate\n  rate_code: rateCode,\n  rate_name: rateName,\n  rate_unit: workUnit,\n  original_rate_unit: rateUnit,\n  \n  // Prices\n  uc,\n  tc,\n  \n  // Labor\n  labor_hours: laborHoursTotal,\n  workers_total: workersTotal,\n  machines_total: machinesTotal,\n  materials_total: materialsTotal,\n  \n  // Quality\n  ql: qualityLevel,\n  quality_score: llmScore,\n  qdrant_score: qdrantScore,\n  llm_score: llmScore,\n  llm_reason: inputData._llm_reason || '',\n  \n  // Resources\n  resources,\n  \n  // Scope of work\n  scope_of_work: scopeOfWork,\n  \n  // Hierarchy\n  hierarchy: payload.hierarchy || {},\n  \n  // Breakdown\n  cost_breakdown: { \n    workers: workersTotal, \n    machines: machinesTotal, \n    materials: materialsTotal \n  },\n  \n  // Debug\n  _payload_source: payloadSource,\n  _scale_factor: scaleFactor,\n  _unit_divisor: unitDivisor,\n  _original_total_cost: totalCost\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "7d3d1e50-0625-4d4e-91d4-099ea87aa29f",
      "name": "8️⃣ Apply Rerank",
      "type": "n8n-nodes-base.code",
      "position": [
        448,
        2960
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// 8️⃣ APPLY RERANK v4 - Enhanced result selection\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst prepData = $('6️⃣ Prep Rerank').first().json;\nconst llmResponse = $input.first().json;\n\nconsole.log('=== APPLY RERANK v4 ===');\n\nconst qdrantResults = prepData._qdrant_results || [];\n\nlet rankings = [];\ntry {\n  let text = llmResponse.choices?.[0]?.message?.content || '';\n  // Clear от markdown и лишних символов\n  text = text.replace(/```json\\n?/g, '').replace(/```/g, '').trim();\n  \n  // Пытаемся найти JSON\n  const jsonMatch = text.match(/\\{[\\s\\S]*\\}/);\n  if (jsonMatch) {\n    const parsed = JSON.parse(jsonMatch[0]);\n    rankings = parsed.rankings || [];\n    console.log('Parsed rankings:', rankings.length);\n  }\n} catch(e) {\n  console.log('Parse error:', e.message);\n  // Fallback - используем Qdrant scores\n  rankings = qdrantResults.map((r, i) => ({\n    index: i,\n    score: Math.round((r.score || 0) * 100),\n    reason: 'qdrant score'\n  }));\n}\n\n// Комбинируем LLM и Qdrant scores\nconst scored = qdrantResults.map((r, i) => {\n  const p = r.payload || {};\n  const rank = rankings.find(x => x.index === i);\n  const llmScore = rank?.score || 0;\n  const reason = rank?.reason || '';\n  const qdrantScore = (r.score || 0) * 100;\n  \n  // Weighted combination: LLM важнее если есть, иначе Qdrant\n  let combinedScore;\n  if (llmScore > 0) {\n    // LLM 70% + Qdrant 30%\n    combinedScore = llmScore * 0.7 + qdrantScore * 0.3;\n  } else {\n    combinedScore = qdrantScore;\n  }\n  \n  console.log('[' + i + '] LLM=' + llmScore + ' Qdrant=' + qdrantScore.toFixed(0) + ' Combined=' + combinedScore.toFixed(0) + ' - ' + (p.rate_code || 'N/A'));\n  \n  return {\n    ...r,\n    payload: p,\n    llm_score: llmScore,\n    llm_reason: reason,\n    qdrant_score: r.score || 0,\n    combined_score: combinedScore\n  };\n});\n\n// Sort по комбинированному score\nscored.sort((a, b) => b.combined_score - a.combined_score);\n\nconst best = scored[0];\nconst bestPayload = best?.payload || {};\n\n// Determine quality результата\nlet qualityLevel = 'not_found';\nif (best.combined_score >= 80) qualityLevel = 'high';\nelse if (best.combined_score >= 60) qualityLevel = 'medium';\nelse if (best.combined_score >= 40) qualityLevel = 'low';\n\nconsole.log('');\nconsole.log('✅ BEST: ' + bestPayload.rate_code + ' - ' + (bestPayload.rate_name || '').substring(0, 50));\nconsole.log('   Combined: ' + best.combined_score.toFixed(0) + ' | Quality: ' + qualityLevel);\nconsole.log('   Reason: ' + best.llm_reason);\n\nreturn [{ json: {\n  ...prepData,\n  _best_result: best,\n  _best_payload: bestPayload,\n  _llm_score: best?.llm_score || 0,\n  _llm_reason: best?.llm_reason || '',\n  _qdrant_score: best?.qdrant_score || 0,\n  _quality_level: qualityLevel,\n  ql: qualityLevel,\n  _openai_key: prepData._openai_key,\n  _step: 'rerank_done'\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "58b35003-2cb2-45bd-85b9-904880344e88",
      "name": "7️⃣ LLM Rerank",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1360,
        2768
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/chat/completions",
        "method": "POST",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={\n  \"model\": \"gpt-4o-mini\",\n  \"messages\": [\n    {\"role\": \"system\", \"content\": \"You are a construction cost database expert. Respond ONLY with valid JSON, no markdown.\"},\n    {\"role\": \"user\", \"content\": {{ JSON.stringify($json._rerank_prompt) }}}\n  ],\n  \"temperature\": 0.1,\n  \"max_tokens\": 500\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json._openai_key }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "6e1e913c-995d-4f5a-843b-b567bb9bbd7b",
      "name": "6️⃣ Prep Rerank",
      "type": "n8n-nodes-base.code",
      "position": [
        1184,
        2768
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// 6️⃣ PREP RERANK v4 - Enhanced semantic matching\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst prepData = $('4️⃣ Extract Embedding').first().json;\nconst qdrantResponse = $input.first().json;\n\nconsole.log('=== PREP RERANK v4 ===');\n\nif (qdrantResponse.status?.error) {\n  return [{ json: { ...prepData, rate_code: 'QDRANT_ERROR', uc: 0, tc: 0, ql: 'not_found', resources: [] }}];\n}\n\nconst results = qdrantResponse.result || [];\nconsole.log('Qdrant results:', results.length);\n\nif (results.length === 0) {\n  return [{ json: { ...prepData, rate_code: 'NOT_FOUND', rate_name: prepData._original_query, uc: 0, tc: 0, ql: 'not_found', resources: [] }}];\n}\n\nconst originalQuery = prepData._original_query || prepData.name || '';\nconst workUnit = prepData.unit || 'm²';\nconst workQty = prepData.qty || 1;\n\n// Format компактное описание кандидатов\nconst candidates = results.slice(0, 5).map((r, i) => {\n  const p = r.payload || {};\n  const code = p.rate_code || '';\n  const name = p.rate_name || '';\n  const unit = p.rate_unit || '';\n  \n  // Scope of work\n  const workSteps = p.work_steps || [];\n  const scopeText = workSteps.slice(0, 3).map(s => s.text).join('; ');\n  \n  // Key materials\n  const resources = p.resources || [];\n  const materials = resources\n    .filter(r => !r.resource_code?.match(/^(DXME|ME_|PU_|RI_)/))\n    .slice(0, 3)\n    .map(r => r.resource_name)\n    .join(', ');\n  \n  const qdrantScore = (r.score * 100).toFixed(0);\n  \n  console.log('[' + i + '] ' + code + ' - ' + name?.substring(0, 40) + ' | ' + qdrantScore + '%');\n  \n  return i + '. [' + code + '] ' + name + '\\n   Unit: ' + unit + ' | Scope: ' + (scopeText || 'n/a').substring(0, 100) + '\\n   Materials: ' + (materials || 'n/a');\n}).join('\\n\\n');\n\nconst rerankPrompt = `TASK: Score construction rate candidates (0-100) for matching user's work request.\n\nSCORING GUIDE:\n95-100: EXACT MATCH - Same work type, method, materials, unit compatible\n80-94: VERY GOOD - Same work, minor spec differences (thickness, brand)\n65-79: GOOD - Same category, different specification\n50-64: PARTIAL - Related work, different scope\n30-49: WEAK - Same trade, different work type\n0-29: WRONG - Different trade or unrelated\n\nCRITICAL DISTINCTIONS:\n• ГКЛ (гипсокартон) ≠ ГВЛ (гипсоволокно) - DIFFERENT materials, max 60\n• Стены ≠ Потолок - check work location matches\n• Монтаж ≠ Демонтаж - opposite operations\n• 1 слой ≠ 2 слоя - check layer count\n• Profile types: ПП60 = ceiling, ПС = wall stud, ПН = guide\n\nUNIT MATCHING:\n• Query unit: ${workUnit}\n• Rate unit should be compatible or convertible\n• m² work → m² rate (ideal)\n• m² work → 100m² rate (ok, will scale)\n\nUSER REQUEST: \"${originalQuery}\" (${workQty} ${workUnit})\n\nCANDIDATES:\n${candidates}\n\nReturn ONLY valid JSON (no markdown):\n{\"rankings\":[{\"index\":0,\"score\":N,\"reason\":\"brief reason\"},{\"index\":1,\"score\":N,\"reason\":\"brief reason\"}]}`;\n\nconsole.log('Prompt length:', rerankPrompt.length);\n\nreturn [{ json: {\n  ...prepData,\n  _qdrant_results: results,\n  _rerank_prompt: rerankPrompt,\n  _openai_key: prepData._openai_key,\n  _step: 'prep_rerank_done'\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "94286c7c-17c2-4aac-966b-c7de4510dbe2",
      "name": "5️⃣ Qdrant Search",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1008,
        2768
      ],
      "parameters": {
        "url": "={{ $json._qdrant_url }}/collections/{{ $json._collection }}/points/search",
        "method": "POST",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={\n  \"vector\": {{ JSON.stringify($json._embedding) }},\n  \"limit\": 10,\n  \"with_payload\": true,\n  \"with_vector\": false\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "api-key",
              "value": "={{ $json._qdrant_key }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "0e9d4cda-40d3-40be-a3d3-4f545515878b",
      "name": "4️⃣ Extract Embedding",
      "type": "n8n-nodes-base.code",
      "position": [
        848,
        2768
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// EXTRACT EMBEDDING - Get vector from OpenAI response\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst prepData = $('2️⃣ Extract Transform').first().json;\nconst embResponse = $input.first().json;\n\nconsole.log('=== EXTRACT EMBEDDING ===');\n\nif (embResponse.error) {\n  console.log('OpenAI Error:', embResponse.error.message);\n  return [{ json: { ...prepData, _error: embResponse.error.message, _embedding: [] }}];\n}\n\nconst embedding = embResponse.data?.[0]?.embedding || [];\nconsole.log('Embedding length:', embedding.length);\n\nif (embedding.length !== 3072) {\n  console.log('WARNING: Expected 3072, got', embedding.length);\n}\n\n// Pass all data including Qdrant credentials\nreturn [{ json: {\n  ...prepData,\n  _embedding: embedding,\n  _collection: prepData._collection,\n  _qdrant_url: prepData._qdrant_url,\n  _qdrant_key: prepData._qdrant_key,\n  _openai_key: prepData._openai_key,\n  _step: 'embedding_done'\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "26067864-f69d-475d-a226-798f2cc85f2a",
      "name": "3️⃣ OpenAI Embedding",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        640,
        2768
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/embeddings",
        "method": "POST",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={\n  \"model\": \"text-embedding-3-large\",\n  \"input\": {{ JSON.stringify($json._query) }},\n  \"dimensions\": 3072\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json._openai_key }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "4b47c060-4955-48f8-8f30-fc86e71f8f65",
      "name": "2️⃣ Extract Transform",
      "type": "n8n-nodes-base.code",
      "position": [
        448,
        2768
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// EXTRACT TRANSFORM - Clean LLM response and combine queries\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst prepData = $('1️⃣ Prep Query').first().json;\nconst llmResponse = $input.first().json;\n\nconsole.log('=== EXTRACT TRANSFORM ===');\n\nlet transformedQuery = prepData._original_query;\n\ntry {\n  const content = llmResponse.choices?.[0]?.message?.content || '';\n  if (content && content.length > 5) {\n    transformedQuery = content\n      .replace(/^(keywords?:|search:|query:|result:)/i, '')\n      .replace(/[\\n\\r]+/g, ' ')\n      .trim();\n    console.log('Transformed OK');\n  }\n} catch(e) {\n  console.log('Transform failed:', e.message);\n}\n\nconsole.log('Original:', prepData._original_query);\nconsole.log('Transformed:', transformedQuery.substring(0, 80));\n\n// Smart combination - original is more important\nconst originalWords = prepData._original_query.toLowerCase().split(/\\s+/);\nconst transformedWords = transformedQuery.toLowerCase().split(/\\s+/);\n\n// Add only new words from transformation\nconst newWords = transformedWords.filter(w => \n  w.length > 2 && !originalWords.some(ow => ow.includes(w) || w.includes(ow))\n);\n\nconst combinedQuery = prepData._original_query + ' ' + newWords.slice(0, 10).join(' ');\n\nconsole.log('Combined:', combinedQuery.substring(0, 100));\n\n// Pass all data including Qdrant credentials\nreturn [{ json: {\n  ...prepData,\n  _query: combinedQuery.trim(),\n  _transformed_query: transformedQuery,\n  _collection: prepData._collection,\n  _qdrant_url: prepData._qdrant_url,\n  _qdrant_key: prepData._qdrant_key,\n  _openai_key: prepData._openai_key,\n  _step: 'transform_done'\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "5e7573f6-e820-4169-94f8-38bcc976f455",
      "name": "1.5️⃣ LLM Transform",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1360,
        2560
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/chat/completions",
        "method": "POST",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "json"
            }
          }
        },
        "jsonBody": "={\n  \"model\": \"gpt-4o-mini\",\n  \"messages\": [{\"role\": \"user\", \"content\": {{ JSON.stringify($json._transform_prompt) }}}],\n  \"temperature\": 0.3,\n  \"max_tokens\": 200\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json._openai_key }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "5730871f-d323-4bef-a6bc-df237d7c0120",
      "name": "Parse Text LLM",
      "type": "n8n-nodes-base.code",
      "position": [
        -656,
        1472
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Parse Text LLM Response\n// Extract works from LLM API response\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst prepData = $('Prep Text LLM').first().json;\nconst apiResponse = $input.first().json;\nconst cid = String(prepData.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst L = prepData.L || {};\nconst provider = prepData._llm_provider || 'gemini';\n\nconsole.log('=== PARSE TEXT LLM ===');\nconsole.log('Provider:', provider);\n\nlet works = [];\nlet rawContent = '';\n\ntry {\n  // Extract text based on provider\n  if (provider === 'openai') {\n    rawContent = apiResponse.choices?.[0]?.message?.content || '';\n  } else {\n    rawContent = apiResponse.candidates?.[0]?.content?.parts?.[0]?.text || '';\n  }\n  \n  console.log('Raw response length:', rawContent.length);\n  console.log('Raw response preview:', rawContent.substring(0, 200));\n  \n  if (rawContent) {\n    // Clean and parse JSON\n    let cleanContent = rawContent\n      .replace(/```json\\s*/gi, '')\n      .replace(/```\\s*/gi, '')\n      .replace(/^[^\\[]*/, '')  // Remove anything before [\n      .trim();\n    \n    const jsonStart = cleanContent.indexOf('[');\n    const jsonEnd = cleanContent.lastIndexOf(']');\n    \n    if (jsonStart !== -1 && jsonEnd !== -1) {\n      const jsonStr = cleanContent.substring(jsonStart, jsonEnd + 1);\n      console.log('JSON to parse:', jsonStr.substring(0, 200));\n      works = JSON.parse(jsonStr);\n      console.log('Parsed works count:', works.length);\n    }\n  }\n} catch(e) {\n  console.log('Parse error:', e.message);\n}\n\n// Validate and normalize\nconst validWorks = (works || [])\n  .filter(w => w && w.name && String(w.name).length > 2)\n  .map((w, i) => ({\n    id: 'W' + String(i + 1).padStart(3, '0'),\n    seq: i + 1,\n    name: String(w.name).substring(0, 80).trim(),\n    query: String(w.name).substring(0, 80).trim(),\n    qty: Math.max(0.1, parseFloat(w.qty) || 1),\n    unit: ['m²', 'm', 'pcs', 'kg', 'l', 'шт', 'м²', 'м', 'St', 'Stk'].includes(w.unit) ? w.unit : 'm²',\n    room: w.room || '',\n    conf: 'medium',\n    cat: 'finishing'\n  }));\n\nconsole.log('Valid works:', validWorks.length);\n\n// Save to session\nif (!sd.sess) sd.sess = {};\nif (!sd.sess[cid]) sd.sess[cid] = { lang: prepData.lang };\nsd.sess[cid].works = validWorks;\nsd.sess[cid].description = prepData.description || '';\nsd.sess[cid].state = 'wait_edit';\nsd.sess[cid].db = prepData.db;\nsd.sess[cid].L = L;\n\nreturn { json: { \n  ...prepData,\n  chatId: cid, \n  works: validWorks,\n  description: prepData.description || '',\n  L\n}};"
      },
      "typeVersion": 2
    },
    {
      "id": "9b1e2dcf-e28d-4757-843b-a084c2d67704",
      "name": "📤 Details",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -672,
        2560
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $json.chatId }},\n  \"text\": {{ JSON.stringify($json.msg) }},\n  \"parse_mode\": \"Markdown\",\n  \"reply_markup\": {\n    \"inline_keyboard\": [\n      [{\"text\": \"{{ $json.L.btn_export_excel || '↓ Excel' }}\", \"callback_data\": \"export_excel\"}, {\"text\": \"{{ $json.L.btn_export_pdf || '↓ PDF' }}\", \"callback_data\": \"export_pdf\"}],\n      [{\"text\": \"{{ $json.L.btn_restart || '↻ Restart' }}\", \"callback_data\": \"restart\"}]\n    ]\n  }\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "fd9ab335-40db-4a70-8d69-874eb71ca2ca",
      "name": "View Details",
      "type": "n8n-nodes-base.code",
      "position": [
        -864,
        2560
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Data Driven Construction Cost Estimator\n// https://DataDrivenConstruction.io\n// Open source construction cost database with 55,000+ work items\n// ═══════════════════════════════════════════════════════════════════════════\n\n// Detailed view with resource prices\nconst cfg = $('Config').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst data = sd.lastResults || {};\nconst L = data.L || cfg.L || {};\nconst works = data.works || [];\nconst sym = L.sym || '€';\n\nfunction fmtCur(v) { \n  if (!v || v === 0) return sym + ' 0';\n  return sym + ' ' + v.toFixed(2); \n}\n\nlet msg = `*${L.ready} — ${L.resources}*\\n\\n`;\n\nworks.forEach((w, i) => {\n  const qi = { 'high': '●', 'medium': '○', 'low': '◌', 'not_found': '✕' }[w.ql] || '○';\n  \n  msg += `*${i+1}. ${w.name}*\\n`;\n  if (w.rate_code && w.rate_code !== 'NOT_FOUND') {\n    msg += `${qi} \\`${w.rate_code}\\`\\n`;\n    if (w.rate_name && w.rate_name !== w.name) {\n      const rateName = (w.rate_name || '').substring(0, 45);\n      msg += `_${rateName}_\\n`;\n    }\n  } else {\n    msg += `${qi} _${L.not_found}_\\n`;\n  }\n  msg += `${w.qty} ${w.unit} × ${fmtCur(w.uc)} = *${fmtCur(w.tc)}*\\n`;\n  \n  // Cost breakdown\n  const parts = [];\n  if (w.workers_total > 0) parts.push(`${L.workers}: ${fmtCur(w.workers_total)}`);\n  if (w.materials_total > 0) parts.push(`${L.materials}: ${fmtCur(w.materials_total)}`);\n  if (w.machines_total > 0) parts.push(`${L.machines}: ${fmtCur(w.machines_total)}`);\n  if (parts.length > 0) msg += `_${parts.join(' · ')}_\\n`;\n  \n  // Resources with prices\n  const resources = w.resources || [];\n  if (resources.length > 0) {\n    msg += `\\n`;\n    const showCount = Math.min(5, resources.length);\n    resources.slice(0, showCount).forEach((r, ri) => {\n      const isLast = ri === showCount - 1 && resources.length <= 5;\n      const prefix = isLast ? '└' : '├';\n      const typeTag = r.resource_type === 'labor' ? L.res_labor : r.resource_type === 'machine' ? L.res_machine : L.res_material;\n      const resName = (r.resource_name || '').substring(0, 26);\n      msg += `${prefix} *${typeTag}*: ${resName}\\n`;\n      \n      // Show quantity and cost\n      const qtyVal = r.scaled_quantity || r.resource_quantity || 0;\n      const costVal = r.scaled_cost || r.resource_cost || 0;\n      if (costVal > 0) {\n        msg += `   ${qtyVal.toFixed(2)} ${r.resource_unit || ''} = ${fmtCur(costVal)}\\n`;\n      } else {\n        msg += `   ${qtyVal.toFixed(2)} ${r.resource_unit || ''}\\n`;\n      }\n    });\n    if (resources.length > 5) {\n      msg += `└ _...+${resources.length - 5} ${L.resources}_\\n`;\n    }\n  }\n  \n  // Scope of work\n  const scope = w.scope_of_work || [];\n  if (scope.length > 0) {\n    msg += `\\n📋 *${L.scope_title || 'Scope of Work'}:*\\n`;\n    scope.forEach((s, si) => {\n      const prefix = si === scope.length - 1 ? '└' : '├';\n      const sText = (s || '').substring(0, 45);\n      msg += `${prefix} ${sText}\\n`;\n    });\n  }\n  msg += `\\n`;\n});\n\n// Summary\nmsg += `─────────────────────\\n`;\nconst totalParts = [];\nif (data.workers_sum > 0) totalParts.push(`${L.workers}: ${fmtCur(data.workers_sum)}`);\nif (data.materials_sum > 0) totalParts.push(`${L.materials}: ${fmtCur(data.materials_sum)}`);\nif (data.machines_sum > 0) totalParts.push(`${L.machines}: ${fmtCur(data.machines_sum)}`);\nif (totalParts.length > 0) msg += totalParts.join('\\n') + '\\n\\n';\n\nmsg += `*${L.total}: ${fmtCur(data.total || 0)}*\\n`;\n\nreturn { json: { ...cfg, msg, chatId: cid, bot_token: cfg.bot_token, L } };"
      },
      "typeVersion": 2
    },
    {
      "id": "55090509-0d4e-45ff-af52-159396c1e4e3",
      "name": "📤 Fallback",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -880,
        1904
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $json.chatId }},\n  \"text\": {{ JSON.stringify(($json.L && $json.L.fallback_start) || \"Use /start to begin\") }},\n  \"parse_mode\": \"Markdown\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "635465bb-9607-4542-af66-a84051bed2b4",
      "name": "📤 Help",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -864,
        2560
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $json.chatId }},\n  \"text\": \"*DDC CWICR - Help*\\n\\n*What is DDC CWICR?*\\nOpen source construction cost database\\nhttps://DataDrivenConstruction.io\\n\\n*Features:*\\n📸 Photo analysis with AI\\n📝 Text input with work lists\\n🌍 9 languages supported\\n📊 55,000+ work items\\n💰 Regional pricing (EUR, USD, RUB, etc.)\\n📄 Excel & PDF export\\n\\n*How to use:*\\n1. Select your language\\n2. Send photo OR text description\\n3. Edit detected works if needed\\n4. Get cost estimate\\n\\n*Commands:*\\n/start - New estimate\\n\\n*Contact:*\\nGitHub: github.com/datadrivenconstruction/OpenConstructionEstimate-DDC-CWICR\",\n  \"parse_mode\": \"Markdown\",\n  \"reply_markup\": {\n    \"inline_keyboard\": [[{\"text\": \"◀️ Back\", \"callback_data\": \"back_to_lang\"}]]\n  }\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "cbdbf90f-6d11-4c5a-a535-846985347634",
      "name": "📤 Send PDF",
      "type": "n8n-nodes-base.telegram",
      "position": [
        -432,
        2400
      ],
      "webhookId": "75b0ff85",
      "parameters": {
        "chatId": "={{ $json.chatId }}",
        "operation": "sendDocument",
        "binaryData": true,
        "additionalFields": {
          "caption": "={{ $json.L?.export_pdf_msg || '📄 PDF Export (HTML)' }}",
          "fileName": "={{ $json.filename }}"
        },
        "binaryPropertyName": "pdf"
      },
      "credentials": {
        "telegramApi": {
          "id": "YOUR_CREDENTIAL_ID",
          "name": "Your Telegram Bot"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "24c92682-d981-4898-93f4-eec576ba3357",
      "name": "IF PDF",
      "type": "n8n-nodes-base.if",
      "position": [
        -672,
        2416
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.skip }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "64c08403-f77a-4c12-8b89-265c989c8446",
      "name": "Generate PDF",
      "type": "n8n-nodes-base.code",
      "position": [
        -864,
        2416
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Data Driven Construction Cost Estimator\n// https://DataDrivenConstruction.io\n// Open source construction cost database with 55,000+ work items\n// ═══════════════════════════════════════════════════════════════════════════\n\n// Generate PDF (using HTML-to-PDF service or return HTML)\nconst cfg = $('Config').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst html = sd.html_report || '';\nconst L = sd.lastResults?.L || cfg.L || {};\n\nif (!html) {\n  // No HTML report yet - send message\n  try {\n    await $http.request({\n      method: 'POST',\n      url: `https://api.telegram.org/bot${cfg.bot_token}/sendMessage`,\n      body: { chat_id: parseInt(cid), text: '❌ No report to export. Please calculate first.' },\n      json: true\n    });\n  } catch(e) {}\n  return { json: { skip: true } };\n}\n\n// For now, send HTML file as \"PDF alternative\"\nconst filename = `Estimate_${(L.region || 'Report').replace(/[^a-zA-Z0-9]/g, '_')}_${new Date().toISOString().substring(0, 10)}.html`;\n\nreturn { json: { chatId: cid, L, bot_token: cfg.bot_token, filename }, binary: { pdf: { data: Buffer.from(html, 'utf-8').toString('base64'), mimeType: 'text/html', fileName: filename } } };"
      },
      "typeVersion": 2
    },
    {
      "id": "44e37da4-e4bd-4d0e-bd22-210045d2fa91",
      "name": "📤 Send Excel",
      "type": "n8n-nodes-base.telegram",
      "position": [
        -672,
        2720
      ],
      "webhookId": "401e16ac",
      "parameters": {
        "chatId": "={{ $json.chatId }}",
        "operation": "sendDocument",
        "binaryData": true,
        "additionalFields": {
          "caption": "={{ $json.L?.export_excel_msg || '📊 Excel Export (CSV)' }}",
          "fileName": "={{ $json.filename }}"
        },
        "binaryPropertyName": "excel"
      },
      "credentials": {
        "telegramApi": {
          "id": "YOUR_CREDENTIAL_ID",
          "name": "Your Telegram Bot"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "20dd2b37-f393-4676-a0ae-f51b771ee4e9",
      "name": "Generate Excel",
      "type": "n8n-nodes-base.code",
      "position": [
        -864,
        2720
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Data Driven Construction Cost Estimator\n// https://DataDrivenConstruction.io\n// Open source construction cost database with 55,000+ work items\n// ═══════════════════════════════════════════════════════════════════════════\n\n// Generate Excel file\nconst cfg = $('Config').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst data = sd.lastResults || {};\nconst L = data.L || cfg.L || {};\nconst works = data.works || [];\n\n// Create CSV content (Excel-compatible)\nlet csv = '\\uFEFF'; // BOM for UTF-8\ncsv += `${L.doc_title || 'ESTIMATE'} - ${data.description || 'Photo Estimate'}\\n`;\ncsv += `${L.region || 'Region'} | ${new Date().toLocaleDateString()}\\n\\n`;\n\n// Headers\ncsv += `${L.col_pos || 'Pos'};${L.col_code || 'Code'};${L.col_desc || 'Description'};${L.col_unit || 'Unit'};${L.col_qty || 'Qty'};${L.col_price || 'Price'};${L.col_total || 'Total'}\\n`;\n\n// Data rows\nworks.forEach((w, i) => {\n  const name = (w.rate_name || w.name || '').replace(/;/g, ',').replace(/\\n/g, ' ');\n  csv += `${i+1};${w.rate_code || ''};\"${name}\";${w.rate_unit || w.unit || ''};${(w.qty || 0).toFixed(2)};${(w.uc || 0).toFixed(2)};${(w.tc || 0).toFixed(2)}\\n`;\n  \n  // Resources\n  (w.resources || []).forEach(r => {\n    const resName = (r.resource_name || '').replace(/;/g, ',').replace(/\\n/g, ' ');\n    csv += `;${r.resource_code || ''};\"  ${resName}\";${r.resource_unit || ''};${(r.scaled_quantity || 0).toFixed(3)};${(r.resource_price || 0).toFixed(2)};${(r.scaled_cost || 0).toFixed(2)}\\n`;\n  });\n});\n\n// Totals\ncsv += `\\n;;;;;${L.total || 'TOTAL'};${(data.total || 0).toFixed(2)}\\n`;\ncsv += `;;;;;${L.workers || 'Labor'};${(data.workers_sum || 0).toFixed(2)}\\n`;\ncsv += `;;;;;${L.materials || 'Materials'};${(data.materials_sum || 0).toFixed(2)}\\n`;\ncsv += `;;;;;${L.machines || 'Equipment'};${(data.machines_sum || 0).toFixed(2)}\\n`;\n\nconst filename = `Estimate_${(L.region || 'Report').replace(/[^a-zA-Z0-9]/g, '_')}_${new Date().toISOString().substring(0, 10)}.csv`;\n\nreturn { json: { chatId: cid, L, bot_token: cfg.bot_token, filename }, binary: { excel: { data: Buffer.from(csv, 'utf-8').toString('base64'), mimeType: 'text/csv', fileName: filename } } };"
      },
      "typeVersion": 2
    },
    {
      "id": "b5c3151e-c099-4212-96c3-09883d81e106",
      "name": "📤 Send HTML",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1648,
        2192
      ],
      "webhookId": "a0c501c4",
      "parameters": {
        "chatId": "={{ $json.chatId }}",
        "operation": "sendDocument",
        "binaryData": true,
        "additionalFields": {
          "caption": "📊 Professional HTML Report",
          "fileName": "={{ $json.filename }}"
        },
        "binaryPropertyName": "html"
      },
      "credentials": {
        "telegramApi": {
          "id": "YOUR_CREDENTIAL_ID",
          "name": "Your Telegram Bot"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "421bf313-e942-4bf1-af81-1e43bbe47fb1",
      "name": "Prep HTML File",
      "type": "n8n-nodes-base.code",
      "position": [
        1488,
        2192
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Data Driven Construction Cost Estimator\n// https://DataDrivenConstruction.io\n// Open source construction cost database with 55,000+ work items\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst d = $input.first().json;\nconst html = d.html_content || '';\nconst L = d.L || {};\nconst now = new Date();\nconst ts = now.toISOString().replace(/[:.]/g, '-').substring(0, 16);\nconst filename = `Estimate_${(L.region || 'Report').replace(/[^a-zA-Z0-9]/g, '_')}_${ts}.html`;\n\nreturn { json: { chatId: d.chatId, bot_token: d.bot_token, filename }, binary: { html: { data: Buffer.from(html, 'utf-8').toString('base64'), mimeType: 'text/html', fileName: filename } } };"
      },
      "typeVersion": 2
    },
    {
      "id": "43c8bf7d-505f-4938-bb0e-acd88c2c9369",
      "name": "📤 Final",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1488,
        2032
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\"chat_id\": {{ $json.chatId }}, \"text\": {{ JSON.stringify($json.msg) }}, \"parse_mode\": \"Markdown\", \"reply_markup\": {\"inline_keyboard\": [[{\"text\": \"{{ $json.L.resources || 'Resources' }}\", \"callback_data\": \"view_details\"}], [{\"text\": \"{{ $json.L.btn_export_excel || 'Excel' }}\", \"callback_data\": \"export_excel\"}, {\"text\": \"{{ $json.L.btn_export_pdf || 'PDF' }}\", \"callback_data\": \"export_pdf\"}], [{\"text\": \"{{ $json.L.btn_restart || 'New' }}\", \"callback_data\": \"restart\"}]]}}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "20facbec-b1db-4de1-8f13-5a9c83ec08cb",
      "name": "Final",
      "type": "n8n-nodes-base.code",
      "position": [
        1344,
        2160
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Final message with resources preview\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst d = $('Generate HTML').first().json;\nconst cid = d.chatId;\nconst sd = $getWorkflowStaticData('global');\nconst L = d.L || {};\n\nif (sd.res?.[cid]) delete sd.res[cid];\nif (sd.sess?.[cid]) sd.sess[cid].state = 'done';\n\nfunction fmt(v) { \n  try { return new Intl.NumberFormat(L.loc || 'en', { maximumFractionDigits: 0 }).format(v || 0); } \n  catch(e) { return (v || 0).toFixed(0); } \n}\nfunction fmtCur(v) { \n  const sym = L.sym || '$';\n  if (Math.abs(v) >= 1000) return sym + ' ' + fmt(v);\n  return sym + ' ' + (v || 0).toFixed(2);\n}\nfunction shortName(str, len) {\n  str = String(str || '');\n  if (str.length > len) return str.substring(0, len - 1) + '...';\n  return str;\n}\n\nfunction escMd(s) {\n  // Remove Markdown special chars for Telegram (Markdown mode doesn't support escaping)\n  return String(s || '').replace(/[_*`\\[\\]]/g, '');\n}\n\nconst works = d.works || [];\nconst total = d.total || 0;\nconst now = new Date();\nconst dateStr = now.toLocaleDateString(L.loc || 'en', { year: 'numeric', month: '2-digit', day: '2-digit' });\n\nconst qiMarks = { 'high': '●', 'medium': '○', 'low': '◌', 'not_found': '✕' };\n\n// Header\nlet msg = `*${L.doc_title || 'COST ESTIMATE'}*\\n`;\nmsg += `${dateStr} · ${L.region || ''}\\n\\n`;\n\n// Works with resources\nworks.forEach((w, i) => {\n  const qi = qiMarks[w.ql] || '○';\n  const name = escMd(shortName(w.rate_name || w.name || '', 30));\n  const sumStr = fmtCur(w.tc);\n  \n  msg += `${qi} *${i+1}.* ${name}\\n`;\n  msg += `   ${w.qty} ${w.unit} × ${fmtCur(w.uc)} = *${sumStr}*\\n`;\n  \n  // Show 2-3 resources\n  const resources = w.resources || [];\n  if (resources.length > 0) {\n    const preview = resources.slice(0, 3);\n    preview.forEach((r, ri) => {\n      const isLast = ri === preview.length - 1 && resources.length <= 3;\n      const prefix = isLast ? '└' : '├';\n      const typeIcon = r.resource_type === 'labor' ? 'L' : r.resource_type === 'machine' ? 'M' : 'R';\n      const rName = escMd(shortName(r.resource_name || '', 40));\n      const rCost = fmtCur(r.scaled_cost || 0);\n      msg += `   ${prefix} [${typeIcon}] ${rName} ${rCost}\\n`;\n    });\n    if (resources.length > 3) {\n      msg += `   └ +${resources.length - 3} ${L.more_resources || 'more'}\\n`;\n    }\n  }\n  \n  // Show scope of work preview\n  const scope = w.scope_of_work || [];\n  if (scope.length > 0) {\n    msg += `   📋 ${L.scope_title || 'Scope'}:\\n`;\n    const scopePreview = scope.slice(0, 2);\n    scopePreview.forEach((s, si) => {\n      const isLast = si === scopePreview.length - 1 && scope.length <= 2;\n      const prefix = isLast ? '   └' : '   ├';\n      const sText = escMd(shortName(s, 35));\n      msg += `${prefix} • ${sText}\\n`;\n    });\n    if (scope.length > 2) {\n      msg += `   └ +${scope.length - 2} ${L.more_resources || 'more'}\\n`;\n    }\n  }\n});\n\n// Total\nmsg += `━━━━━━━━━━━━━━━━━━━━━━━━━━\\n`;\nmsg += `*${L.total || 'TOTAL'}:* ${fmtCur(total)}\\n\\n`;\n\n// Quality legend\nconst qHigh = works.filter(w => w.ql === 'high').length;\nconst qMed = works.filter(w => w.ql === 'medium').length;\nconst qLow = works.filter(w => w.ql === 'low').length;\nconst qNone = works.filter(w => w.ql === 'not_found').length;\n\nconst avgLLM = works.length > 0 ? Math.round(works.reduce((s, w) => s + (w.llm_score || 0), 0) / works.length) : 0;\nmsg += `● ${qHigh}  ○ ${qMed}  ◌ ${qLow}  ✕ ${qNone}  (${d.pct}% ${L.found_pct || 'found'})\\n`;\nif (avgLLM > 0) msg += `🎯 AI: ${avgLLM}%\\n`;\nmsg += `\\n`;\n\n// Cost breakdown\nif (d.workers_sum > 0 || d.materials_sum > 0 || d.machines_sum > 0) {\n  const parts = [];\n  if (d.workers_sum > 0) parts.push(`${L.workers || 'Labor'}: ${fmtCur(d.workers_sum)}`);\n  if (d.materials_sum > 0) parts.push(`${L.materials || 'Mat'}: ${fmtCur(d.materials_sum)}`);\n  if (d.machines_sum > 0) parts.push(`${L.machines || 'Equip'}: ${fmtCur(d.machines_sum)}`);\n  msg += parts.join(' · ') + '\\n';\n}\n\nif (d.labor_hours_sum > 0) {\n  const days = Math.ceil(d.labor_hours_sum / 8);\n  msg += `${L.labor_hours || 'Hours'}: ${fmt(d.labor_hours_sum)}h · ~${days} ${L.days || 'days'}\\n`;\n}\n\nmsg += `\\n_${L.price_note || 'Prices are approximate'}_\\n`;\nmsg += `_${L.more_in_html || 'Detailed report available'}_\\n\\n`;\nmsg += `${L.what_next || 'Options:'}`;\n\nreturn { json: { ...d, msg } };"
      },
      "typeVersion": 2
    },
    {
      "id": "27f841fe-3dda-4479-935c-ea27db52b85f",
      "name": "Generate HTML",
      "type": "n8n-nodes-base.code",
      "position": [
        1184,
        2160
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Generate HTML Report with expandable resources\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst d = $('Agg').first().json;\nconst L = d.L || {};\nconst cur = L.cur || 'USD'; \nconst loc = L.loc || 'en'; \nconst sym = L.sym || '$';\nconst works = d.works || []; \nconst total = d.total || 0;\n\nfunction fmt(v) { \n  try { \n    return new Intl.NumberFormat(loc, { style: 'currency', currency: cur, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v || 0); \n  } catch(e) { \n    return sym + ' ' + (v || 0).toFixed(2); \n  } \n}\nfunction fmtNum(v, dec) { \n  return new Intl.NumberFormat(loc, { minimumFractionDigits: dec || 2, maximumFractionDigits: dec || 2 }).format(v || 0); \n}\nfunction esc(t) { \n  return String(t || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); \n}\n\nconst now = new Date();\nconst dateStr = now.toLocaleDateString(loc, { day: '2-digit', month: '2-digit', year: 'numeric' });\nconst timeStr = now.toLocaleTimeString(loc, { hour: '2-digit', minute: '2-digit' });\n\nconst laborPct = total > 0 ? Math.round(d.workers_sum / total * 100) : 0;\nconst materialPct = total > 0 ? Math.round(d.materials_sum / total * 100) : 0;\nconst machinePct = total > 0 ? Math.round(d.machines_sum / total * 100) : 0;\nconst laborDays = Math.ceil((d.labor_hours_sum || 0) / 8);\n\nlet html = `<!DOCTYPE html>\n<html><head><meta charset=\"UTF-8\">\n<title>${esc(L.doc_title || 'Cost Estimate')} - ${esc(d.description || '')}</title>\n<style>\n:root{--primary:#007AFF;--text:#1D1D1F;--text2:#86868B;--text3:#AEAEB2;--bg:#FFF;--bg2:#F5F5F7;--bg3:#E8E8ED;--border:#D2D2D7;--labor:#E3F2FD;--labor-text:#1565C0;--material:#FFF3E0;--material-text:#E65100;--machine:#F3E5F5;--machine-text:#7B1FA2}\n*{box-sizing:border-box}\nbody{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;margin:0;padding:16px;background:var(--bg2);color:var(--text);font-size:12px;line-height:1.4}\n.container{background:var(--bg);max-width:1200px;margin:0 auto;border-radius:16px;box-shadow:0 4px 20px rgba(0,0,0,.08);overflow:hidden}\n.header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}\n.header h1{margin:0;font-size:18px;font-weight:600}\n.header-info{font-size:11px;color:var(--text3)}\n.toolbar{padding:10px 20px;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;gap:8px;flex-wrap:wrap}\n.btn{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);cursor:pointer;font-size:11px}\n.btn:hover{background:var(--bg3)}\n.kpi{display:flex;gap:8px;padding:12px 20px;background:var(--bg2);flex-wrap:wrap}\n.kpi-card{background:var(--bg);border-radius:8px;padding:10px 14px;border:1px solid var(--border);min-width:100px}\n.kpi-value{font-size:16px;font-weight:600}\n.kpi-label{font-size:9px;color:var(--text3);text-transform:uppercase}\ntable{width:100%;border-collapse:collapse}\nth,td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--border)}\nth{background:var(--bg2);font-weight:500;font-size:10px;text-transform:uppercase;color:var(--text2)}\n.work-row{cursor:pointer;transition:background 0.15s}\n.work-row:hover{background:var(--bg2)}\n.work-row td:first-child{font-weight:500}\n.toggle{width:20px;color:var(--text3);font-size:10px}\n.res-row{background:#FAFAFA;font-size:11px}\n.res-row.hidden{display:none}\n.scope-row{background:#F0FFF0;font-size:11px}\n.scope-row.hidden{display:none}\n.scope-row td{padding:6px 10px 6px 30px;border-bottom:1px dashed #D0E8D0}\n.scope-toggle{color:var(--primary);cursor:pointer;font-size:10px;margin-left:8px}\n.res-row td{padding:6px 10px 6px 30px;border-bottom:1px dashed var(--border)}\n.res-tag{display:inline-block;padding:2px 6px;border-radius:4px;font-size:9px;font-weight:500;margin-right:6px}\n.res-labor{background:var(--labor);color:var(--labor-text)}\n.res-material{background:var(--material);color:var(--material-text)}\n.res-machine{background:var(--machine);color:var(--machine-text)}\n.summary-row{background:linear-gradient(90deg,var(--bg2),var(--bg));font-size:10px}\n.summary-row.hidden{display:none}\n.summary-row td{padding:6px 10px 6px 30px;border-top:1px solid var(--border)}\n.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px}\n.dot-high{background:#34C759}\n.dot-medium{background:#FF9500}\n.dot-low{background:#FF3B30}\n.dot-none{background:#8E8E93}\n.total-row{background:var(--bg2);font-weight:600}\n.total-row td{padding:12px 10px}\n.right{text-align:right}\n.footer{padding:16px 20px;text-align:center;font-size:10px;color:var(--text3);border-top:1px solid var(--border)}\n.footer a{color:var(--primary);text-decoration:none}\n@media(max-width:600px){\n  body{padding:8px;font-size:11px}\n  th,td{padding:6px}\n  .kpi-card{min-width:80px;padding:8px}\n  .kpi-value{font-size:14px}\n}\n</style>\n</head><body>\n<div class=\"container\">\n<div class=\"header\">\n  <h1>${esc(L.doc_title || 'Cost Estimate')}</h1>\n  <div class=\"header-info\">${dateStr} ${timeStr} · ${esc(L.region || '')}</div>\n</div>\n<div class=\"toolbar\">\n  <button class=\"btn\" onclick=\"expandAll()\">${esc(L.expand_all || 'Expand All')}</button>\n  <button class=\"btn\" onclick=\"collapseAll()\">${esc(L.collapse_all || 'Collapse All')}</button>\n</div>\n<div class=\"kpi\">\n  <div class=\"kpi-card\"><div class=\"kpi-value\">${fmt(total)}</div><div class=\"kpi-label\">${esc(L.kpi_total || 'Total')}</div></div>\n  <div class=\"kpi-card\"><div class=\"kpi-value\">${works.length}</div><div class=\"kpi-label\">${esc(L.kpi_items || 'Items')}</div></div>\n  <div class=\"kpi-card\"><div class=\"kpi-value\">${laborDays}d</div><div class=\"kpi-label\">${esc(L.kpi_days || 'Days')}</div></div>\n  <div class=\"kpi-card\"><div class=\"kpi-value\">${laborPct}%</div><div class=\"kpi-label\">${esc(L.workers || 'Labor')}</div></div>\n  <div class=\"kpi-card\"><div class=\"kpi-value\">${materialPct}%</div><div class=\"kpi-label\">${esc(L.materials || 'Materials')}</div></div>\n</div>\n<table>\n<tr>\n  <th style=\"width:30px\"></th>\n  <th>${esc(L.col_code || 'Code')}</th>\n  <th>${esc(L.col_desc || 'Description')}</th>\n  <th>${esc(L.col_unit || 'Unit')}</th>\n  <th class=\"right\">${esc(L.col_qty || 'Qty')}</th>\n  <th class=\"right\">${esc(L.col_price || 'Price')}</th>\n  <th class=\"right\">${esc(L.col_total || 'Total')}</th>\n  <th style=\"width:30px\"></th>\n</tr>`;\n\nworks.forEach((w, i) => {\n  console.log('Work ' + (i+1) + ': ' + (w.rate_name || w.name) + ' - Resources: ' + (w.resources || []).length);\n  const hasRes = (w.resources || []).length > 0;\n  const isFirst = i === 0;\n  const dotClass = w.ql === 'high' ? 'dot-high' : w.ql === 'medium' ? 'dot-medium' : w.ql === 'low' ? 'dot-low' : 'dot-none';\n  const code = w.rate_code && w.rate_code !== 'NOT_FOUND' ? w.rate_code : '—';\n  \n  const hasScope = (w.scope_of_work || []).length > 0;\n  const scopeBtn = hasScope ? '<span class=\"scope-toggle\" onclick=\"event.stopPropagation();toggleScope(' + i + ')\">📋</span>' : '';\n  \n  html += `<tr class=\"work-row\" data-work=\"${i}\" onclick=\"toggleWork(${i})\">\n    <td class=\"toggle\"><span id=\"icon-${i}\">${isFirst ? '▼' : '▶'}</span></td>\n    <td style=\"font-size:10px;color:var(--text2)\">${esc(code)}</td>\n    <td><strong>${esc(w.rate_name || w.name)}</strong>${scopeBtn}</td>\n    <td>${esc(w.rate_unit || w.unit)}</td>\n    <td class=\"right\">${fmtNum(w.qty)}</td>\n    <td class=\"right\">${fmt(w.uc)}</td>\n    <td class=\"right\"><strong>${fmt(w.tc)}</strong></td>\n    <td><span class=\"dot ${dotClass}\" title=\"${w.llm_match || ''}\"></span>${w.llm_score ? '<span style=\"font-size:9px;color:var(--text3);margin-left:2px\">' + w.llm_score + '</span>' : ''}</td>\n  </tr>`;\n  \n  // Scope of work rows\n  const scopeItems = w.scope_of_work || [];\n  if (scopeItems.length > 0) {\n    html += `<tr class=\"scope-row hidden\" data-scope=\"${i}\">\n      <td></td>\n      <td colspan=\"6\" style=\"background:#F8FFF8\">\n        <strong style=\"color:#2E7D32\">📋 ${esc(L.scope_title || 'Scope of Work')}:</strong><br>\n        ${scopeItems.map((s, si) => '<span style=\"color:#555\">• ' + esc(s) + '</span>').join('<br>')}\n      </td>\n      <td></td>\n    </tr>`;\n  }\n  \n  // Resources\n  const resources = w.resources || [];\n  resources.forEach((r, ri) => {\n    const tagClass = r.resource_type === 'labor' ? 'res-labor' : r.resource_type === 'machine' ? 'res-machine' : 'res-material';\n    const tagLabel = r.resource_type === 'labor' ? (L.res_labor || 'Labor') : r.resource_type === 'machine' ? (L.res_machine || 'Equip') : (L.res_material || 'Mat');\n    const hiddenClass = isFirst ? '' : 'hidden';\n    const resQty = r.scaled_quantity || r.resource_quantity || 0;\n    const resPrice = r.resource_price || 0;\n    const resCost = r.scaled_cost || 0;\n    const norm = r.resource_quantity || r.norma || 0;\n    const pct = w.tc > 0 ? Math.round(resCost / w.tc * 100) : 0;\n    \n    html += `<tr class=\"res-row ${hiddenClass}\" data-work=\"${i}\">\n      <td></td>\n      <td><span style=\"font-size:9px;color:var(--text3)\">${esc(r.resource_code || '')}</span></td>\n      <td>\n        <span class=\"res-tag ${tagClass}\">${esc(tagLabel)}</span>\n        ${esc(r.resource_name || '')}\n        ${norm ? '<span style=\"color:var(--text3);font-size:9px;margin-left:4px\">×' + fmtNum(norm, 4) + '</span>' : ''}\n      </td>\n      <td style=\"font-size:10px\">${esc(r.resource_unit || '')}</td>\n      <td class=\"right\" style=\"font-size:10px\">${fmtNum(resQty, 3)}</td>\n      <td class=\"right\" style=\"font-size:10px\">${fmt(resPrice)}</td>\n      <td class=\"right\">${fmt(resCost)} <span style=\"font-size:9px;color:var(--text3)\">${pct}%</span></td>\n      <td></td>\n    </tr>`;\n  });\n  \n  // Summary row for work\n  if (resources.length > 0) {\n    const laborSum = resources.filter(r => r.resource_type === 'labor').reduce((s, r) => s + (r.scaled_cost || 0), 0);\n    const matSum = resources.filter(r => r.resource_type === 'material').reduce((s, r) => s + (r.scaled_cost || 0), 0);\n    const machSum = resources.filter(r => r.resource_type === 'machine').reduce((s, r) => s + (r.scaled_cost || 0), 0);\n    const hiddenClass = isFirst ? '' : 'hidden';\n    \n    html += `<tr class=\"summary-row ${hiddenClass}\" data-work=\"${i}\">\n      <td colspan=\"2\"></td>\n      <td colspan=\"4\">\n        <span style=\"color:var(--labor-text)\">${L.workers || 'Labor'}: ${fmt(laborSum)}</span> · \n        <span style=\"color:var(--material-text)\">${L.materials || 'Mat'}: ${fmt(matSum)}</span> · \n        <span style=\"color:var(--machine-text)\">${L.machines || 'Equip'}: ${fmt(machSum)}</span>\n      </td>\n      <td class=\"right\"><strong>${fmt(w.tc)}</strong></td>\n      <td></td>\n    </tr>`;\n  }\n});\n\nhtml += `<tr class=\"total-row\">\n  <td colspan=\"6\" style=\"text-align:right\">${esc(L.grand_total || 'TOTAL')}</td>\n  <td class=\"right\">${fmt(total)}</td>\n  <td></td>\n</tr>\n</table>\n<div class=\"footer\">\n  <a href=\"https://DataDrivenConstruction.io\" target=\"_blank\">DDC CWICR</a> · \n  Open Source Construction Cost Database · ${dateStr}\n</div>\n</div>\n<script>\nfunction toggleWork(idx) {\n  const icon = document.getElementById('icon-' + idx);\n  const rows = document.querySelectorAll('tr[data-work=\"' + idx + '\"]:not(.work-row)');\n  const isHidden = rows.length > 0 && rows[0].classList.contains('hidden');\n  rows.forEach(r => r.classList.toggle('hidden', !isHidden));\n  if (icon) icon.textContent = isHidden ? '▼' : '▶';\n}\nfunction expandAll() {\n  document.querySelectorAll('tr[data-work]').forEach(r => r.classList.remove('hidden'));\n  document.querySelectorAll('[id^=\"icon-\"]').forEach(i => i.textContent = '▼');\n}\nfunction collapseAll() {\n  document.querySelectorAll('tr.res-row, tr.summary-row, tr.scope-row').forEach(r => r.classList.add('hidden'));\n  document.querySelectorAll('[id^=\"icon-\"]').forEach(i => i.textContent = '▶');\n}\nfunction toggleScope(idx) {\n  const rows = document.querySelectorAll('tr.scope-row[data-scope=\"' + idx + '\"]');\n  rows.forEach(r => r.classList.toggle('hidden'));\n}\n</script>\n</body></html>`;\n\nconst sd = $getWorkflowStaticData('global');\nsd.html_report = html;\n\nreturn { json: { ...d, html_content: html } };"
      },
      "typeVersion": 2
    },
    {
      "id": "b5ce02bc-1d4b-49ff-bd34-2244ca2a542a",
      "name": "Answer Calc CB",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        -864,
        2256
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/answerCallbackQuery",
        "method": "POST",
        "options": {
          "timeout": 5000
        },
        "jsonBody": "={\n  \"callback_query_id\": \"{{ $('Config').item.json.callbackQueryId }}\",\n  \"text\": \"{{ $('Config').item.json.L.loading }}\",\n  \"show_alert\": false\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "a508aba4-0189-4dbb-b9e2-c0cbceb288fd",
      "name": "📤 Works Updated",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -672,
        2064
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $json.chatId }},\n  \"text\": {{ JSON.stringify($json.msg) }},\n  \"parse_mode\": \"Markdown\",\n  \"reply_markup\": { \"inline_keyboard\": {{ JSON.stringify($json.keyboard) }} }\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "2893b600-dbe5-40d7-be75-25e07c87a22f",
      "name": "Works Updated",
      "type": "n8n-nodes-base.code",
      "position": [
        -880,
        2064
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// WORKS UPDATED - Show updated works list after quantity change\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $('Config').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\nconst works = session.works || [];\nconst L = cfg.L || session.L || {};\n\nconsole.log('=== WORKS UPDATED ===');\nconsole.log('Works:', works.length);\nconsole.log('L.native:', L.native);\n\nlet msg = '✅ ' + (L.work_added || 'Обновлено') + '\\n\\n';\nmsg += '*' + works.length + ' ' + (L.items || 'позиций') + '*\\n\\n';\n\nfor (let i = 0; i < works.length; i++) {\n  const w = works[i];\n  const name = w.name.length > 25 ? w.name.substring(0, 22) + '...' : w.name;\n  msg += (i + 1) + '. ' + name + ' — ' + w.qty + ' ' + (w.unit || 'm²') + '\\n';\n}\n\n// No limit\n\nconst keyboard = [];\nconst maxBtns = works.length;\nfor (let i = 0; i < maxBtns; i += 5) {\n  const row = [];\n  for (let j = 0; j < 5 && i + j < maxBtns; j++) {\n    row.push({ text: '✏️' + (i + j + 1), callback_data: 'edit_work_' + (i + j) });\n  }\n  keyboard.push(row);\n}\n\nkeyboard.push([\n  { text: L.btn_add_work || '+ Позиция', callback_data: 'add_work' },\n  { text: L.btn_calc || '▶ Расчёт', callback_data: 'calculate' }\n]);\nkeyboard.push([{ text: L.btn_new || '🔄 Заново', callback_data: 'new_photo' }]);\n\nreturn { json: { ...cfg, msg, keyboard } };"
      },
      "typeVersion": 2
    },
    {
      "id": "604b8dbf-163c-48eb-ba40-9bea7e6575db",
      "name": "📤 Ask New Work",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -656,
        1072
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $('Config').item.json.chatId }},\n  \"text\": {{ JSON.stringify($('Config').item.json.L.enter_work) }},\n  \"parse_mode\": \"Markdown\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "8eefdc4b-08b6-4687-90f8-d8ef271696f5",
      "name": "Edit Menu",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -896,
        928
      ],
      "parameters": {
        "color": 6,
        "width": 384,
        "height": 296,
        "content": "## ✏️ Edit Menu\n\n**Quantity controls:**\n- +1, -1, +10, -10\n- ×2, ÷2\n\n**Actions:**\n- 🗑️ Delete work\n- ✅ Done editing\n- ➕ Add new work\n\n**Data:** Stored in StaticData"
      },
      "typeVersion": 1
    },
    {
      "id": "f0f0edf2-e07a-45ca-8d9b-46484d615b53",
      "name": "Parse AI",
      "type": "n8n-nodes-base.code",
      "position": [
        1648,
        1344
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// PARSE AI RESPONSE - Extract work items from Vision API response\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst data = $input.first().json;\nconst cid = String(data.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst L = data.L || {};\nconst provider = data.provider || 'gemini';\n\nconsole.log('=== PARSE AI ===');\nconsole.log('Provider:', provider);\nconsole.log('ChatId:', cid);\n\nlet p = { description: '', items: [] };\nlet rawContent = '';\n\ntry {\n  // Extract text from response based on provider\n  if (provider === 'openai') {\n    rawContent = data.choices?.[0]?.message?.content || '';\n  } else {\n    rawContent = data.candidates?.[0]?.content?.parts?.[0]?.text || '';\n  }\n  \n  console.log('Response length:', rawContent.length);\n  \n  if (rawContent) {\n    // Clean markdown and extract JSON\n    let cleanContent = rawContent\n      .replace(/```json\\s*/gi, '')\n      .replace(/```\\s*/gi, '')\n      .trim();\n    \n    const jsonStart = cleanContent.indexOf('{');\n    const jsonEnd = cleanContent.lastIndexOf('}');\n    \n    if (jsonStart !== -1 && jsonEnd !== -1) {\n      const jsonStr = cleanContent.substring(jsonStart, jsonEnd + 1);\n      p = JSON.parse(jsonStr);\n      console.log('Parsed items:', p.items?.length || 0);\n    }\n  }\n} catch(e) { \n  console.log('Parse error:', e.message);\n}\n\n// Validate and normalize items\nconst validItems = (p.items || [])\n  .filter(it => it.name && it.name.length > 2)\n  .map((it, i) => ({\n    id: 'W' + String(i + 1).padStart(3, '0'),\n    seq: i + 1,\n    name: String(it.name).substring(0, 80).trim(),\n    query: String(it.name).substring(0, 80).trim(),\n    qty: Math.max(0.1, parseFloat(it.qty) || 1),\n    unit: ['m²', 'm', 'pcs', 'kg', 'l', 'шт', 'м²', 'м'].includes(it.unit) ? it.unit : 'm²',\n    conf: ['high', 'medium', 'low'].includes(it.conf) ? it.conf : 'medium',\n    cat: ['demolition', 'rough', 'finishing', 'mep'].includes(it.cat) ? it.cat : 'finishing'\n  }));\n\nconsole.log('Valid items:', validItems.length);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// CRITICAL: Save works to session for later access\n// ═══════════════════════════════════════════════════════════════════════════\nif (!sd.sess) sd.sess = {};\nif (!sd.sess[cid]) sd.sess[cid] = { lang: data.lang };\n\nsd.sess[cid].works = validItems;\nsd.sess[cid].description = p.description || '';\nsd.sess[cid].state = 'wait_edit';\nsd.sess[cid].db = data.db;\nsd.sess[cid].L = data.L;\n\nconsole.log('Saved to session:', sd.sess[cid].works.length, 'works');\n\nreturn { \n  json: { \n    ...data,\n    chatId: cid, \n    works: validItems,\n    description: p.description || '',\n    L: data.L\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "b30de9b7-9827-42ce-876e-ec17088f593d",
      "name": "📤 Analyze Options",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -656,
        944
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $('Config').item.json.chatId }},\n  \"text\": \"{{ $('Config').item.json.L.photo_added }} ({{ $('Config').item.json.photos.length }} {{ $('Config').item.json.L.photos_count }})\",\n  \"parse_mode\": \"Markdown\",\n  \"reply_markup\": {\n    \"inline_keyboard\": [\n      [{\"text\": \"{{ $('Config').item.json.L.add_more }}\", \"callback_data\": \"add_more_photos\"}, {\"text\": \"{{ $('Config').item.json.L.analyze_now }}\", \"callback_data\": \"analyze_photos\"}],\n      [{\"text\": \"{{ $('Config').item.json.L.btn_help }}\", \"callback_data\": \"show_help\"}]\n    ]\n  }\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "afd0f5c5-af51-47bf-94a6-40ba84787303",
      "name": "📤 Ask Photo",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -656,
        752
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $('Config').item.json.chatId }},\n  \"text\": {{ JSON.stringify($('Config').item.json.L.photo) }},\n  \"parse_mode\": \"Markdown\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "fd178d4c-5b34-4612-a1a6-dfd0ad2156b2",
      "name": "Answer Photo CB",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        -864,
        752
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/answerCallbackQuery",
        "method": "POST",
        "options": {
          "timeout": 5000
        },
        "jsonBody": "={\n  \"callback_query_id\": \"{{ $('Config').item.json.callbackQueryId }}\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "0b768ae2-44f4-46b7-931a-381da810420e",
      "name": "📤 Lang OK",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -656,
        608
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $('Config').item.json.chatId }},\n  \"text\": {{ JSON.stringify($('Config').item.json.L.ok + \"\\n\\n\" + $('Config').item.json.L.photo) }},\n  \"parse_mode\": \"Markdown\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "bc394458-02ef-43d9-9f6f-6332699bbafa",
      "name": "Answer Lang CB",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        -864,
        608
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/answerCallbackQuery",
        "method": "POST",
        "options": {
          "timeout": 5000
        },
        "jsonBody": "={\n  \"callback_query_id\": \"{{ $('Config').item.json.callbackQueryId }}\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "4c826493-5537-4a95-a765-f35a00d2014a",
      "name": "📤 Lang Menu",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -864,
        464
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $json.chatId }},\n  \"text\": \"*DDC CWICR Cost Estimator*\\nhttps://DataDrivenConstruction.io\\n\\n▸ Photo analysis (up to 4)\\n▸ Text description\\n▸ 9 languages · 55,000+ work items\\n▸ Excel & PDF export\\n\\nSelect language:\",\n  \"parse_mode\": \"Markdown\",\n  \"reply_markup\": {\n    \"inline_keyboard\": [\n      [{\"text\": \"🇩🇪 Deutsch\", \"callback_data\": \"lang_DE\"}, {\"text\": \"🇬🇧 English\", \"callback_data\": \"lang_EN\"}, {\"text\": \"🇷🇺 Русский\", \"callback_data\": \"lang_RU\"}],\n      [{\"text\": \"🇪🇸 Español\", \"callback_data\": \"lang_ES\"}, {\"text\": \"🇫🇷 Français\", \"callback_data\": \"lang_FR\"}, {\"text\": \"🇧🇷 Português\", \"callback_data\": \"lang_PT\"}],\n      [{\"text\": \"🇨🇳 中文\", \"callback_data\": \"lang_ZH\"}, {\"text\": \"🇦🇪 العربية\", \"callback_data\": \"lang_AR\"}, {\"text\": \"🇮🇳 हिन्दी\", \"callback_data\": \"lang_HI\"}],\n      [{\"text\": \"❓ Help\", \"callback_data\": \"show_help\"}]\n    ]\n  }\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "3129142d-9018-4611-bec3-9f80d90e43f1",
      "name": "Route",
      "type": "n8n-nodes-base.switch",
      "position": [
        -1216,
        1136
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "LANG",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "a3c7266e-9deb-4abe-a856-0e835757cdc8",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "show_lang"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "LANG_OK",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "abc9388b-156f-4a27-8b6b-16d0e18e9749",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "lang_selected"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "PHOTO",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "6a8ae65c-90f2-4a74-ac8c-e7e34d8eb49f",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "ask_photo"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "ANALYZE_OPT",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "96f28956-a38b-4d71-86b7-b9b2f2750cc7",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "show_analyze_options"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "PHOTO_ADDED",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "ea59db87-7ac6-44aa-9e6f-6f061c30b451",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "photo_added"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "ANALYZE",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "f1f0126d-794b-48f7-9097-9fb54d5f097e",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "analyze"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "WORKS_UPD",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "320b81af-ef5e-4776-814d-3aead2e0a38a",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "works_updated"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "EDIT_MENU",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "def6b912-0ac9-4066-b421-59fd67b596c8",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "show_edit_menu"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "ADD_WORK",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "4ca5650d-0486-456a-b17c-34ec088923d1",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "ask_new_work"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "CALC",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "8f7bceab-ef7c-43b4-8afa-a8c309a74645",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "start_calc"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "EXCEL",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "e1037d8b-0e5f-46c3-a008-ad16d97689c2",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "export_excel"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "PDF",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "6fff52ee-58d7-4073-b42d-cb15298d5788",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "export_pdf"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "HELP",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "fdf49046-6eaf-4183-8ff8-209eff4fefc4",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "show_help"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "DETAILS",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "6b143832-1fbb-40ee-9465-ff85f59b0328",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "view_details"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "REFINE",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "d687c3a2-8bec-42df-9b32-74609fbd2d89",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "refine_analysis"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "ANALYZE_TEXT",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "244cd331-efdc-4e0e-b267-f51d8a6c6f3d",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "analyze_text"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "PDF_PROCESS",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "97856224-76ac-4a6d-8719-a5401d6d9ff1",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "process_pdf"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "typeVersion": 3.2
    },
    {
      "id": "835a5db4-f0e0-45c4-9e7d-c9ef1a7f465c",
      "name": "Config",
      "type": "n8n-nodes-base.code",
      "position": [
        -1424,
        1392
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Data Driven Construction Cost Estimator\n// https://DataDrivenConstruction.io\n// Open source construction cost database with 55,000+ work items\n// ═══════════════════════════════════════════════════════════════════════════\n\n// CONFIG v8.5 PRO - Professional style localization + PDF SUPPORT\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst input = $input.first().json;\nconst lang = (input.lang || 'EN').toUpperCase();\nconst chatId = String(input.chatId);\nconst sd = $getWorkflowStaticData('global');\n\nconst LANGS = {\n  'DE': { \n    fallback_start: 'Drücken Sie /start um zu beginnen', \n    rooms: 'Räume', works_identified: 'Positionen', general: 'Allgemein', no_works: 'Keine Arbeiten', items: 'Positionen', min: 'Min', \n    db: 'DE_BERLIN_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR', \n    name: 'German', flag: '🇩🇪', native: 'Deutsch', cur: 'EUR', sym: '€', loc: 'de-DE', region: 'Berlin', search_lang: 'German',\n    // Professional welcome with PDF support\n    ok: '✅ *Deutsch* · Berlin · EUR',\n    photo: `*Foto, PDF oder Beschreibung senden*\n\n📄 *PDF-Zeichnungen* — Grundriss oder Bauplan (max. 3 Seiten)\n📷 *Foto* — Raum oder Objekt fotografieren\n✏️ *Text* — Arbeiten als Liste beschreiben\n\n_Beispiel zum Kopieren:_\n\\`\\`\\`\nGipskarton 2-lagig Metallprofil CW75 25m2\nFliesen Feinsteinzeug 60x60 Bad 15m2\nSpachteln Q3 Decken und Waende 120m2\nElektro Steckdosen UP 20 Stueck\n\\`\\`\\`\n\nOder Foto/PDF senden 📷📄`,\n    // PDF specific\n    pdf_received: '📄 *PDF erhalten*',\n    pdf_processing: '⏳ Analysiere Zeichnung...',\n    pdf_pages: 'Seiten',\n    pdf_page_limit: '⚠️ Nur erste 3 Seiten werden verarbeitet',\n    pdf_rooms_found: '🏠 Räume gefunden',\n    pdf_elements_found: '🧱 Elemente gefunden',\n    pdf_works_generated: '📝 Arbeiten generiert',\n    pdf_analyzing_page: '🔍 Analysiere Seite',\n    pdf_of: 'von',\n    pdf_complete: '✅ Analyse abgeschlossen',\n    pdf_error: '❌ PDF-Verarbeitungsfehler',\n    // Rest of translations\n    photo_added: '✅ Foto hinzugefügt',\n    photos_count: 'Fotos',\n    add_more: '+ Weitere Fotos',\n    analyze_now: '▶ Analyse starten',\n    analyzing: 'Bildanalyse läuft...',\n    found: '*Erkannte Leistungen:*',\n    edit_hint: 'Zur Bearbeitung antippen',\n    calc: 'Preisermittlung',\n    ready: '*KOSTENVORANSCHLAG*',\n    total: 'GESAMT',\n    days: 'Tage',\n    pct: 'Trefferquote',\n    workers: 'Lohn',\n    machines: 'Geräte',\n    materials: 'Material',\n    subtotal: 'Zusammenfassung',\n    searching: 'Suche',\n    of: 'von',\n    not_found: 'Keine Übereinstimmung',\n    low_conf: 'Prüfung empfohlen',\n    price_note: 'Preisbasis: Berlin 2025',\n    btn_calc: '▶ Berechnen',\n    btn_new: '+ Neues Projekt',\n    btn_lang: '⚙ Sprache',\n    btn_edit: '✎ Ändern',\n    btn_delete: '✕ Entfernen',\n    btn_add_work: '+ Position',\n    btn_done: '✅ Fertig',\n    btn_export_excel: '↓ Excel',\n    btn_export_pdf: '↓ PDF',\n    btn_restart: '↻ Neu starten',\n    btn_help: '? Hilfe',\n    loading: 'Preisermittlung läuft...',\n    more_in_html: 'Detaillierter Bericht verfügbar',\n    resources: 'Details: Ressourcen & Leistungsumfang',\n    enter_work: '*Neue Position hinzufügen*\\nFormat: Bezeichnung, Menge Einheit\\nBeispiel: Gipskartonwand, 15 m²',\n    work_added: '✅ Position hinzugefügt',\n    what_next: '*Weitere Optionen:*',\n    categories: 'Kategorien',\n    cat_demolition: 'Abbruch',\n    cat_rough: 'Rohbau',\n    cat_finishing: 'Ausbau',\n    cat_mep: 'TGA',\n    help_title: '*Benutzerhandbuch*',\n    help_text: `*Benutzerhandbuch*\n\n*1. Dokumentation*\nSenden Sie Fotos, PDF-Pläne oder Beschreibung.\nPDF: max. 3 Seiten werden analysiert.\n\n*2. Prüfung*\nÜberprüfen Sie die erkannten Leistungen.\n\n*3. Kalkulation*\nPreisermittlung aus DDC CWICR Datenbank.\n\n*4. Export*\nErgebnisse als Excel oder PDF.\n\n*Befehle:*\n/start — Neues Projekt\n/help — Diese Hilfe`,\n    doc_title: 'KOSTENVORANSCHLAG',\n    col_pos: 'Pos', col_code: 'Kennziffer', col_desc: 'Bezeichnung', col_unit: 'Einh.', col_qty: 'Menge', col_price: 'EP', col_total: 'GP', col_labor: 'Std', col_quality: 'Q',\n    grand_total: 'GESAMTSUMME', labor_cost: 'Lohnkosten', material_cost: 'Materialkosten', labor_days: 'Arbeitstage',\n    kpi_total: 'Gesamtkosten', kpi_hours: 'Arbeitsstunden', kpi_days: 'Arbeitstage',\n    chart_cost_structure: 'Kostenstruktur', chart_labor: 'Lohn', chart_material: 'Material', chart_machines: 'Geräte',\n    res_labor: 'Lohn', res_material: 'Mat', res_machine: 'Ger',\n    collapse_all: 'Alle einklappen', expand_all: 'Alle ausklappen',\n    quality_high: 'Hohe Übereinstimmung', quality_medium: 'Mittlere Übereinstimmung', quality_low: 'Geringe Übereinstimmung',\n    export_excel_msg: 'Excel-Export (CSV)', export_pdf_msg: 'PDF-Export', btn_refine: 'Genauer analysieren', found_pct: 'gefunden', more_resources: 'weitere', kpi_items: 'Positionen', scope_title: 'Leistungsumfang', show_scope: 'Leistungen anzeigen'\n  },\n\n  'EN': { \n    fallback_start: 'Use /start to begin', \n    rooms: 'комнат', works_identified: 'works', general: 'Общее', no_works: 'Работы не найдены', items: 'позиций', min: 'мин', \n    db: 'ENG_TORONTO_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR', \n    name: 'English', flag: '🇬🇧', native: 'English', cur: 'CAD', sym: '$', loc: 'en-CA', region: 'Toronto', search_lang: 'English',\n    ok: '✅ *English* · Toronto · CAD',\n    photo: `*Send photo, PDF or description*\n\n📄 *PDF drawings* — floor plan or blueprint (max 3 pages)\n📷 *Photo* — photograph room or object\n✏️ *Text* — describe work as a list\n\n_Example to copy:_\n\\`\\`\\`\nDrywall 2-layer metal stud CW75 25m2\nPorcelain tiles 60x60 bathroom 15m2\nPlastering level 3 ceiling walls 120m2\nElectrical outlets flush mount 20 pcs\n\\`\\`\\`\n\nOr send photo/PDF 📷📄`,\n    // PDF specific\n    pdf_received: '📄 *PDF received*',\n    pdf_processing: '⏳ Analyzing drawing...',\n    pdf_pages: 'pages',\n    pdf_page_limit: '⚠️ Processing first 3 pages only',\n    pdf_rooms_found: '🏠 Rooms found',\n    pdf_elements_found: '🧱 Elements found',\n    pdf_works_generated: '📝 Works generated',\n    pdf_analyzing_page: '🔍 Analyzing page',\n    pdf_of: 'of',\n    pdf_complete: '✅ Analysis complete',\n    pdf_error: '❌ PDF processing error',\n    // Rest\n    photo_added: '✅ Photo added',\n    photos_count: 'photos',\n    add_more: '+ Add more',\n    analyze_now: '▶ Start analysis',\n    analyzing: 'Analyzing images...',\n    found: '*Identified Work Items:*',\n    edit_hint: 'Tap to edit',\n    calc: 'Pricing lookup',\n    ready: '*COST ESTIMATE*',\n    total: 'TOTAL',\n    days: 'days',\n    pct: 'Match rate',\n    workers: 'Labor',\n    machines: 'Equipment',\n    materials: 'Materials',\n    subtotal: 'Summary',\n    searching: 'Searching',\n    of: 'of',\n    not_found: 'No match found',\n    low_conf: 'Review recommended',\n    price_note: 'Price basis: Toronto 2025',\n    btn_calc: '▶ Calculate',\n    btn_new: '+ New Project',\n    btn_lang: '⚙ Language',\n    btn_edit: '✎ Edit',\n    btn_delete: '✕ Remove',\n    btn_add_work: '+ Add Item',\n    btn_done: '✅ Done',\n    btn_export_excel: '↓ Excel',\n    btn_export_pdf: '↓ PDF',\n    btn_restart: '↻ Start Over',\n    btn_help: '? Help',\n    loading: 'Calculating prices...',\n    more_in_html: 'Detailed report available',\n    resources: 'Details: Resources & Scope of Work',\n    enter_work: '*Add New Item*\\nFormat: Description, Quantity Unit\\nExample: Drywall installation, 15 m²',\n    work_added: '✅ Item added',\n    what_next: '*Options:*',\n    categories: 'Categories',\n    cat_demolition: 'Demolition',\n    cat_rough: 'Structure',\n    cat_finishing: 'Finishes',\n    cat_mep: 'MEP',\n    help_title: '*User Guide*',\n    help_text: `*User Guide*\n\n*1. Documentation*\nSend photos, PDF plans or description.\nPDF: max 3 pages will be analyzed.\n\n*2. Review*\nCheck identified work items.\n\n*3. Расчёт*\nPricing from DDC CWICR database.\n\n*4. Export*\nExport as Excel or PDF.\n\n*Commands:*\n/start — New project\n/help — This guide`,\n    doc_title: 'COST ESTIMATE',\n    col_pos: 'No', col_code: 'Code', col_desc: 'Description', col_unit: 'Unit', col_qty: 'Qty', col_price: 'Rate', col_total: 'Total', col_labor: 'Hrs', col_quality: 'Q',\n    grand_total: 'GRAND TOTAL', labor_cost: 'Labor Cost', material_cost: 'Material Cost', labor_days: 'Work Days',\n    kpi_total: 'Total Cost', kpi_hours: 'Work Hours', kpi_days: 'Work Days',\n    chart_cost_structure: 'Cost Structure', chart_labor: 'Labor', chart_material: 'Material', chart_machines: 'Equipment',\n    res_labor: 'Labor', res_material: 'Mat', res_machine: 'Equip',\n    collapse_all: 'Collapse all', expand_all: 'Expand all',\n    quality_high: 'High confidence', quality_medium: 'Medium confidence', quality_low: 'Low confidence',\n    export_excel_msg: 'Excel Export (CSV)', export_pdf_msg: 'PDF Export', btn_refine: 'Refine Analysis', found_pct: 'found', more_resources: 'more', kpi_items: 'Items', scope_title: 'Scope of Work', show_scope: 'Show scope'\n  },\n\n  'RU': { \n    fallback_start: 'Нажмите /start для начала', \n    rooms: 'комнат', works_identified: 'позиций', general: 'Общее', no_works: 'Работы не найдены', items: 'позиций', min: 'мин', \n    db: 'RU_STPETERSBURG_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR', \n    name: 'Russian', flag: '🇷🇺', native: 'Русский', cur: 'RUB', sym: '₽', loc: 'ru-RU', region: 'Санкт-Петербург', search_lang: 'Russian',\n    ok: '✅ *Русский* · СПб · RUB',\n    photo: `*Отправьте фото, PDF или описание*\n\n📄 *PDF чертежи* — план этажа или чертёж (до 3 стр.)\n📷 *Фото* — сфотографируйте помещение\n✏️ *Текст* — опишите работы списком\n\n_Пример для копирования:_\n\\`\\`\\`\nГипсокартон 2 слоя профиль ПП60 25м2\nПлитка керамогранит 60x60 ванная 15м2\nШпаклевка под покраску потолки стены 120м2\nРозетки скрытый монтаж 20шт\n\\`\\`\\`\n\nИли отправьте фото/PDF 📷📄`,\n    // PDF specific\n    pdf_received: '📄 *PDF получен*',\n    pdf_processing: 'Анализирую чертёж...',\n    pdf_pages: 'стр.',\n    pdf_page_limit: '⚠️ Обрабатываю первые 3 страницы',\n    pdf_rooms_found: '🏠 Найдено помещений',\n    pdf_elements_found: '🧱 Найдено элементов',\n    pdf_works_generated: '📝 Сформировано работ',\n    pdf_analyzing_page: 'Анализирую страницу',\n    pdf_of: 'из',\n    pdf_complete: '✅ Анализ завершён',\n    pdf_error: '❌ Ошибка обработки PDF',\n    // Rest\n    photo_added: '✅ Фото добавлено',\n    photos_count: 'фото',\n    add_more: '+ Ещё фото',\n    analyze_now: '▶ Начать анализ',\n    analyzing: 'Анализ изображений...',\n    found: '*Определённые работы:*',\n    edit_hint: 'Нажмите для редактирования',\n    calc: 'Поиск расценок',\n    ready: '*СМЕТА*',\n    total: 'ИТОГО',\n    days: 'дн.',\n    pct: 'Точность',\n    workers: 'Труд',\n    machines: 'Механизмы',\n    materials: 'Материалы',\n    subtotal: 'Сводка',\n    searching: 'Поиск',\n    of: 'из',\n    not_found: 'Не найдено',\n    low_conf: 'Требует проверки',\n    price_note: 'База цен: Санкт-Петербург 2025',\n    btn_calc: '▶ Рассчитать',\n    btn_new: '+ Новый проект',\n    btn_lang: '⚙ Язык',\n    btn_edit: '✎ Изменить',\n    btn_delete: '✕ Удалить',\n    btn_add_work: '+ Позиция',\n    btn_done: '✅ Готово',\n    btn_export_excel: '↓ Excel',\n    btn_export_pdf: '↓ PDF',\n    btn_restart: '↻ Заново',\n    btn_help: '? Справка',\n    loading: 'Расчёт...',\n    more_in_html: 'Подробный отчёт доступен',\n    resources: 'Подробнее: ресурсы и составы работ',\n    enter_work: '*Добавить позицию*\\nФормат: Наименование, Количество Единица\\nПример: Монтаж ГКЛ, 15 м²',\n    work_added: '✅ Позиция добавлена',\n    what_next: '*Действия:*',\n    categories: 'Категории',\n    cat_demolition: 'Демонтаж',\n    cat_rough: 'Черновые',\n    cat_finishing: 'Отделка',\n    cat_mep: 'Инженерия',\n    help_title: '*Руководство*',\n    help_text: `*Руководство пользователя*\n\n*1. Документация*\nОтправьте фото, PDF-план или описание.\nPDF: анализируются до 3 страниц.\n\n*2. Проверка*\nПроверьте определённые работы.\n\n*3. Расчёт*\nЦены из базы DDC CWICR.\n\n*4. Экспорт*\nВыгрузка в Excel или PDF.\n\n*Команды:*\n/start — Новый проект\n/help — Справка`,\n    doc_title: 'СМЕТА',\n    col_pos: '№', col_code: 'Шифр', col_desc: 'Наименование', col_unit: 'Ед.', col_qty: 'Кол.', col_price: 'Цена', col_total: 'Сумма', col_labor: 'Ч/ч', col_quality: 'К',\n    grand_total: 'ВСЕГО', labor_cost: 'ФОТ', material_cost: 'Материалы', labor_days: 'Дней',\n    kpi_total: 'Стоимость', kpi_hours: 'Трудозатраты', kpi_days: 'Срок',\n    chart_cost_structure: 'Структура затрат', chart_labor: 'Труд', chart_material: 'Материалы', chart_machines: 'Механизмы',\n    res_labor: 'Труд', res_material: 'Мат', res_machine: 'Мех',\n    collapse_all: 'Свернуть', expand_all: 'Развернуть',\n    quality_high: 'Высокая точность', quality_medium: 'Средняя точность', quality_low: 'Низкая точность',\n    export_excel_msg: 'Экспорт Excel (CSV)', export_pdf_msg: 'Экспорт PDF', btn_refine: 'Уточнить анализ', found_pct: 'найдено', more_resources: 'ещё', kpi_items: 'Позиции', scope_title: 'Состав работ', show_scope: 'Показать состав'\n  },\n\n  'ES': { \n    fallback_start: 'Pulse /start para comenzar', \n    rooms: 'habitaciones', works_identified: 'trabajos', general: 'Общее', no_works: 'Sin trabajos', items: 'elementos', min: 'мин', \n    db: 'ES_BARCELONA_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR', \n    name: 'Spanish', flag: '🇪🇸', native: 'Español', cur: 'EUR', sym: '€', loc: 'es-ES', region: 'Barcelona', search_lang: 'Spanish',\n    ok: '✅ *Español* · Barcelona · EUR',\n    photo: `*Envíe foto, PDF o descripción*\n\n📄 *Planos PDF* — plano de planta o dibujo (máx. 3 págs.)\n📷 *Foto* — fotografíe el espacio\n✏️ *Texto* — describa trabajos en lista\n\n_Ejemplo para copiar:_\n\\`\\`\\`\nPladur 2 capas perfil metálico 25m2\nAzulejos porcelánico 60x60 baño 15m2\nAlisado para pintura techos paredes 120m2\nEnchufes empotrados 20uds\n\\`\\`\\`\n\nO envíe foto/PDF 📷📄`,\n    pdf_received: '📄 *PDF recibido*',\n    pdf_processing: '⏳ Analizando plano...',\n    pdf_pages: 'páginas',\n    pdf_page_limit: '⚠️ Solo se procesan las primeras 3 páginas',\n    pdf_rooms_found: '🏠 Habitaciones encontradas',\n    pdf_elements_found: '🧱 Elementos encontrados',\n    pdf_works_generated: '📝 Trabajos generados',\n    pdf_analyzing_page: '🔍 Analizando página',\n    pdf_of: 'de',\n    pdf_complete: '✅ Análisis completado',\n    pdf_error: '❌ Error al procesar PDF',\n    photo_added: '✅ Foto añadida',\n    photos_count: 'fotos',\n    add_more: '+ Más fotos',\n    analyze_now: '▶ Analizar',\n    analyzing: 'Analizando...',\n    found: '*Trabajos identificados:*',\n    edit_hint: 'Toque para editar',\n    calc: 'Búsqueda de precios',\n    ready: '*PRESUPUESTO*',\n    total: 'TOTAL',\n    days: 'días',\n    pct: 'Precisión',\n    workers: 'M.O.',\n    machines: 'Equipos',\n    materials: 'Materiales',\n    subtotal: 'Resumen',\n    searching: 'Buscando',\n    of: 'de',\n    not_found: 'Sin coincidencia',\n    low_conf: 'Revisar',\n    price_note: 'Base: Barcelona 2025',\n    btn_calc: '▶ Calcular',\n    btn_new: '+ Nuevo',\n    btn_lang: '⚙ Idioma',\n    btn_edit: '✎ Editar',\n    btn_delete: '✕ Eliminar',\n    btn_add_work: '+ Partida',\n    btn_done: '✅ Listo',\n    btn_export_excel: '↓ Excel',\n    btn_export_pdf: '↓ PDF',\n    btn_restart: '↻ Reiniciar',\n    btn_help: '? Ayuda',\n    loading: 'Calculando...',\n    more_in_html: 'Informe detallado disponible',\n    resources: 'Detalles: recursos y alcance',\n    enter_work: '*Añadir partida*\\nFormato: Descripción, Cantidad Unidad',\n    work_added: '✅ Añadido',\n    what_next: '*Opciones:*',\n    categories: 'Categorías',\n    cat_demolition: 'Demolición',\n    cat_rough: 'Estructura',\n    cat_finishing: 'Acabados',\n    cat_mep: 'Instalaciones',\n    help_title: '*Guía*',\n    help_text: `*Guía de uso*\n\n*1.* Envíe fotos, PDF o descripción\nPDF: máx. 3 páginas\n*2.* Revise los trabajos\n*3.* Calcule precios\n*4.* Exporte\n\n/start — Nuevo\n/help — Ayuda`,\n    doc_title: 'PRESUPUESTO',\n    col_pos: 'Nº', col_code: 'Código', col_desc: 'Descripción', col_unit: 'Ud', col_qty: 'Cant', col_price: 'P.U.', col_total: 'Total', col_labor: 'Hrs', col_quality: 'Q',\n    grand_total: 'TOTAL', labor_cost: 'M.O.', material_cost: 'Materiales', labor_days: 'Días',\n    kpi_total: 'Coste Total', kpi_hours: 'Horas', kpi_days: 'Días',\n    chart_cost_structure: 'Estructura', chart_labor: 'M.O.', chart_material: 'Mat', chart_machines: 'Eq',\n    res_labor: 'MO', res_material: 'Mat', res_machine: 'Eq',\n    collapse_all: 'Contraer', expand_all: 'Expandir',\n    quality_high: 'Alta', quality_medium: 'Media', quality_low: 'Baja',\n    export_excel_msg: 'Exportar Excel', export_pdf_msg: 'Exportar PDF', btn_refine: 'Refinar', found_pct: 'encontrado', more_resources: 'más', kpi_items: 'Artículos', scope_title: 'Alcance', show_scope: 'Ver alcance'\n  },\n\n  'FR': { \n    fallback_start: 'Appuyez sur /start pour commencer', \n    rooms: 'pièces', works_identified: 'travaux', general: 'Général', no_works: 'Aucun travail', items: 'éléments', min: 'мин', \n    db: 'FR_PARIS_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR', \n    name: 'French', flag: '🇫🇷', native: 'Français', cur: 'EUR', sym: '€', loc: 'fr-FR', region: 'Paris', search_lang: 'French',\n    ok: '✅ *Français* · Paris · EUR',\n    photo: `*Envoyez photo, PDF ou description*\n\n📄 *Plans PDF* — plan d'étage ou dessin (max 3 pages)\n📷 *Photo* — photographiez l'espace\n✏️ *Texte* — décrivez les travaux en liste\n\n_Exemple à copier:_\n\\`\\`\\`\nPlaco 2 couches ossature métallique 25m2\nCarrelage grès cérame 60x60 sdb 15m2\nEnduit plafonds murs 120m2\nPrises encastrées 20pcs\n\\`\\`\\`\n\nOu envoyez photo/PDF 📷📄`,\n    pdf_received: '📄 *PDF reçu*',\n    pdf_processing: '⏳ Analyse du plan...',\n    pdf_pages: 'pages',\n    pdf_page_limit: '⚠️ Seules les 3 premières pages sont traitées',\n    pdf_rooms_found: '🏠 Pièces trouvées',\n    pdf_elements_found: '🧱 Éléments trouvés',\n    pdf_works_generated: '📝 Travaux générés',\n    pdf_analyzing_page: '🔍 Analyse de la page',\n    pdf_of: 'sur',\n    pdf_complete: '✅ Analyse terminée',\n    pdf_error: '❌ Erreur de traitement PDF',\n    photo_added: '✅ Photo ajoutée',\n    photos_count: 'photos',\n    add_more: '+ Autres photos',\n    analyze_now: '▶ Analyser',\n    analyzing: 'Analyse en cours...',\n    found: '*Ouvrages identifiés:*',\n    edit_hint: 'Appuyez pour modifier',\n    calc: 'Recherche des prix',\n    ready: '*DEVIS*',\n    total: 'TOTAL',\n    days: 'jours',\n    pct: 'Précision',\n    workers: 'M.O.',\n    machines: 'Matériel',\n    materials: 'Matériaux',\n    subtotal: 'Résumé',\n    searching: 'Recherche',\n    of: 'sur',\n    not_found: 'Non trouvé',\n    low_conf: 'À vérifier',\n    price_note: 'Base: Paris 2025',\n    btn_calc: '▶ Calculer',\n    btn_new: '+ Nouveau',\n    btn_lang: '⚙ Langue',\n    btn_edit: '✎ Modifier',\n    btn_delete: '✕ Supprimer',\n    btn_add_work: '+ Ouvrage',\n    btn_done: '✅ Terminé',\n    btn_export_excel: '↓ Excel',\n    btn_export_pdf: '↓ PDF',\n    btn_restart: '↻ Recommencer',\n    btn_help: '? Aide',\n    loading: 'Calcul en cours...',\n    more_in_html: 'Rapport détaillé disponible',\n    resources: 'Détails: ressources et description',\n    enter_work: '*Ajouter ouvrage*\\nFormat: Description, Quantité Unité',\n    work_added: '✅ Ajouté',\n    what_next: '*Options:*',\n    categories: 'Catégories',\n    cat_demolition: 'Démolition',\n    cat_rough: 'Gros œuvre',\n    cat_finishing: 'Finitions',\n    cat_mep: 'CVC',\n    help_title: '*Guide*',\n    help_text: `*Guide d'utilisation*\n\n*1.* Envoyez photos, PDF ou description\nPDF: max 3 pages\n*2.* Vérifiez les ouvrages\n*3.* Calculez les prix\n*4.* Exportez\n\n/start — Nouveau\n/help — Aide`,\n    doc_title: 'DEVIS',\n    col_pos: 'N°', col_code: 'Code', col_desc: 'Désignation', col_unit: 'U', col_qty: 'Qté', col_price: 'P.U.', col_total: 'Total', col_labor: 'Hrs', col_quality: 'Q',\n    grand_total: 'TOTAL', labor_cost: 'M.O.', material_cost: 'Matériaux', labor_days: 'Jours',\n    kpi_total: 'Coût Total', kpi_hours: 'Heures', kpi_days: 'Jours',\n    chart_cost_structure: 'Structure', chart_labor: 'M.O.', chart_material: 'Mat', chart_machines: 'Éq',\n    res_labor: 'MO', res_material: 'Mat', res_machine: 'Éq',\n    collapse_all: 'Réduire', expand_all: 'Développer',\n    quality_high: 'Haute', quality_medium: 'Moyenne', quality_low: 'Faible',\n    export_excel_msg: 'Export Excel', export_pdf_msg: 'Export PDF', btn_refine: 'Affiner', found_pct: 'trouvé', more_resources: 'plus', kpi_items: 'Articles', scope_title: 'Description', show_scope: 'Voir description'\n  },\n\n  'PT': { \n    fallback_start: 'Pressione /start para começar', \n    rooms: 'quartos', works_identified: 'trabalhos', general: 'Geral', no_works: 'Sem trabalhos', items: 'itens', min: 'мин', \n    db: 'PT_SAOPAULO_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR', \n    name: 'Portuguese', flag: '🇧🇷', native: 'Português', cur: 'BRL', sym: 'R$', loc: 'pt-BR', region: 'São Paulo', search_lang: 'Portuguese',\n    ok: '✅ *Português* · São Paulo · BRL',\n    photo: `*Envie foto, PDF ou descrição*\n\n📄 *Plantas PDF* — planta baixa ou desenho (máx. 3 págs.)\n📷 *Foto* — fotografe o ambiente\n✏️ *Texto* — descreva os trabalhos em lista\n\n_Exemplo para copiar:_\n\\`\\`\\`\nDrywall 2 camadas perfil metálico 25m2\nPorcelanato 60x60 banheiro 15m2\nMassa corrida teto paredes 120m2\nTomadas embutidas 20un\n\\`\\`\\`\n\nOu envie foto/PDF 📷📄`,\n    pdf_received: '📄 *PDF recebido*',\n    pdf_processing: '⏳ Analisando planta...',\n    pdf_pages: 'páginas',\n    pdf_page_limit: '⚠️ Processando apenas as 3 primeiras páginas',\n    pdf_rooms_found: '🏠 Cômodos encontrados',\n    pdf_elements_found: '🧱 Elementos encontrados',\n    pdf_works_generated: '📝 Trabalhos gerados',\n    pdf_analyzing_page: '🔍 Analisando página',\n    pdf_of: 'de',\n    pdf_complete: '✅ Análise concluída',\n    pdf_error: '❌ Erro ao processar PDF',\n    photo_added: '✅ Foto adicionada',\n    photos_count: 'fotos',\n    add_more: '+ Mais fotos',\n    analyze_now: '▶ Analisar',\n    analyzing: 'Analisando...',\n    found: '*Serviços identificados:*',\n    edit_hint: 'Toque para editar',\n    calc: 'Busca de preços',\n    ready: '*ORÇAMENTO*',\n    total: 'TOTAL',\n    days: 'dias',\n    pct: 'Precisão',\n    workers: 'M.O.',\n    machines: 'Equipamentos',\n    materials: 'Materiais',\n    subtotal: 'Resumo',\n    searching: 'Buscando',\n    of: 'de',\n    not_found: 'Não encontrado',\n    low_conf: 'Verificar',\n    price_note: 'Base: São Paulo 2025',\n    btn_calc: '▶ Calcular',\n    btn_new: '+ Novo',\n    btn_lang: '⚙ Idioma',\n    btn_edit: '✎ Editar',\n    btn_delete: '✕ Excluir',\n    btn_add_work: '+ Serviço',\n    btn_done: '✅ Pronto',\n    btn_export_excel: '↓ Excel',\n    btn_export_pdf: '↓ PDF',\n    btn_restart: '↻ Recomeçar',\n    btn_help: '? Ajuda',\n    loading: 'Calculando...',\n    more_in_html: 'Relatório detalhado disponível',\n    resources: 'Detalhes: recursos e escopo',\n    enter_work: '*Adicionar serviço*\\nFormato: Descrição, Quantidade Unidade',\n    work_added: '✅ Adicionado',\n    what_next: '*Opções:*',\n    categories: 'Categorias',\n    cat_demolition: 'Demolição',\n    cat_rough: 'Estrutura',\n    cat_finishing: 'Acabamento',\n    cat_mep: 'Instalações',\n    help_title: '*Guia*',\n    help_text: `*Guia de uso*\n\n*1.* Envie fotos, PDF ou descrição\nPDF: máx. 3 páginas\n*2.* Revise os serviços\n*3.* Calcule preços\n*4.* Exporte\n\n/start — Novo\n/help — Ajuda`,\n    doc_title: 'ORÇAMENTO',\n    col_pos: 'Nº', col_code: 'Código', col_desc: 'Descrição', col_unit: 'Un', col_qty: 'Qtd', col_price: 'P.U.', col_total: 'Total', col_labor: 'Hrs', col_quality: 'Q',\n    grand_total: 'TOTAL', labor_cost: 'M.O.', material_cost: 'Materiais', labor_days: 'Dias',\n    kpi_total: 'Custo Total', kpi_hours: 'Horas', kpi_days: 'Dias',\n    chart_cost_structure: 'Estrutura', chart_labor: 'M.O.', chart_material: 'Mat', chart_machines: 'Eq',\n    res_labor: 'MO', res_material: 'Mat', res_machine: 'Eq',\n    collapse_all: 'Recolher', expand_all: 'Expandir',\n    quality_high: 'Alta', quality_medium: 'Média', quality_low: 'Baixa',\n    export_excel_msg: 'Exportar Excel', export_pdf_msg: 'Exportar PDF', btn_refine: 'Refinar', found_pct: 'encontrado', more_resources: 'mais', kpi_items: 'Itens', scope_title: 'Escopo', show_scope: 'Ver escopo'\n  },\n\n  'ZH': { \n    db: 'ZH_SHANGHAI_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR', \n    name: 'Chinese', flag: '🇨🇳', native: '中文', cur: 'CNY', sym: '¥', loc: 'zh-CN', region: '上海', search_lang: 'Chinese',\n    ok: '✅ *中文* · 上海 · CNY',\n    photo: `*发送照片、PDF或描述*\n\n📄 *PDF图纸* — 平面图或图纸（最多3页）\n📷 *照片* — 拍摄房间或物体\n✏️ *文字* — 以列表形式描述\n\n_复制示例：_\n\\`\\`\\`\n石膏板双层轻钢龙骨 25m2\n瓷砖600x600卫生间 15m2\n腻子找平顶墙 120m2\n暗装插座 20个\n\\`\\`\\`\n\n或发送照片/PDF 📷📄`,\n    pdf_received: '📄 *已收到PDF*',\n    pdf_processing: '⏳ 正在分析图纸...',\n    pdf_pages: '页',\n    pdf_page_limit: '⚠️ 仅处理前3页',\n    pdf_rooms_found: '🏠 发现房间',\n    pdf_elements_found: '🧱 发现元素',\n    pdf_works_generated: '📝 已生成工作项',\n    pdf_analyzing_page: '🔍 正在分析第',\n    pdf_of: '页，共',\n    pdf_complete: '✅ 分析完成',\n    pdf_error: '❌ PDF处理错误',\n    photo_added: '✅ 照片已添加',\n    photos_count: '张',\n    add_more: '+ 更多照片',\n    analyze_now: '▶ 开始分析',\n    analyzing: '分析中...',\n    found: '*识别的工作项:*',\n    edit_hint: '点击编辑',\n    calc: '价格查询',\n    ready: '*工程预算*',\n    total: '合计',\n    days: '天',\n    pct: '匹配率',\n    workers: '人工',\n    machines: '机械',\n    materials: '材料',\n    subtotal: '摘要',\n    searching: '搜索',\n    of: '/',\n    not_found: '未找到',\n    low_conf: '需核查',\n    price_note: '价格基准：上海 2025',\n    btn_calc: '▶ 计算',\n    btn_new: '+ 新项目',\n    btn_lang: '⚙ 语言',\n    btn_edit: '✎ 编辑',\n    btn_delete: '✕ 删除',\n    btn_add_work: '+ 添加',\n    btn_done: '✅ 完成',\n    btn_export_excel: '↓ Excel',\n    btn_export_pdf: '↓ PDF',\n    btn_restart: '↻ 重新开始',\n    btn_help: '? 帮助',\n    loading: '计算中...',\n    more_in_html: '详细报告可用',\n    resources: '详情：资源和工作范围',\n    enter_work: '*添加项目*\\n格式：描述，数量 单位',\n    work_added: '✅ 已添加',\n    what_next: '*选项:*',\n    categories: '类别',\n    cat_demolition: '拆除',\n    cat_rough: '结构',\n    cat_finishing: '装饰',\n    cat_mep: '机电',\n    help_title: '*指南*',\n    help_text: `*使用指南*\n\n*1.* 发送照片、PDF或描述\nPDF：最多3页\n*2.* 检查工作项\n*3.* 计算价格\n*4.* 导出\n\n/start — 新项目\n/help — 帮助`,\n    doc_title: '预算',\n    col_pos: '序号', col_code: '编码', col_desc: '名称', col_unit: '单位', col_qty: '数量', col_price: '单价', col_total: '合计', col_labor: '工时', col_quality: '质',\n    grand_total: '总计', labor_cost: '人工费', material_cost: '材料费', labor_days: '工期',\n    kpi_total: '总成本', kpi_hours: '工时', kpi_days: '工期',\n    chart_cost_structure: '成本结构', chart_labor: '人工', chart_material: '材料', chart_machines: '机械',\n    res_labor: '人工', res_material: '材料', res_machine: '机械',\n    collapse_all: '折叠', expand_all: '展开',\n    quality_high: '高', quality_medium: '中', quality_low: '低',\n    export_excel_msg: '导出Excel', export_pdf_msg: '导出PDF', btn_refine: '精确分析', found_pct: '找到', more_resources: '更多', kpi_items: '项目', scope_title: '工作范围', show_scope: '查看范围'\n  },\n\n  'AR': { \n    db: 'AR_DUBAI_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR', \n    name: 'Arabic', flag: '🇦🇪', native: 'العربية', cur: 'AED', sym: 'د.إ', loc: 'ar-AE', region: 'دبي', search_lang: 'Arabic',\n    ok: '✅ *العربية* · دبي · AED',\n    photo: `*أرسل صورة أو PDF أو وصف*\n\n📄 *مخططات PDF* — مخطط الطابق أو الرسم (حتى 3 صفحات)\n📷 *صورة* — التقط صورة للمكان\n✏️ *نص* — صف الأعمال كقائمة\n\n_مثال للنسخ:_\n\\`\\`\\`\nجبس بورد طبقتين هيكل معدني 25م2\nبلاط سيراميك 60x60 حمام 15م2\nمعجون أسقف جدران 120م2\nمقابس مخفية 20قطعة\n\\`\\`\\`\n\nأو أرسل صورة/PDF 📷📄`,\n    pdf_received: '📄 *تم استلام PDF*',\n    pdf_processing: '⏳ جاري تحليل المخطط...',\n    pdf_pages: 'صفحات',\n    pdf_page_limit: '⚠️ معالجة أول 3 صفحات فقط',\n    pdf_rooms_found: '🏠 الغرف الموجودة',\n    pdf_elements_found: '🧱 العناصر الموجودة',\n    pdf_works_generated: '📝 الأعمال المُنشأة',\n    pdf_analyzing_page: '🔍 تحليل الصفحة',\n    pdf_of: 'من',\n    pdf_complete: '✅ اكتمل التحليل',\n    pdf_error: '❌ خطأ في معالجة PDF',\n    photo_added: '✅ تمت الإضافة',\n    photos_count: 'صور',\n    add_more: '+ المزيد',\n    analyze_now: '▶ تحليل',\n    analyzing: 'جاري التحليل...',\n    found: '*الأعمال المحددة:*',\n    edit_hint: 'اضغط للتعديل',\n    calc: 'البحث عن الأسعار',\n    ready: '*التقدير*',\n    total: 'المجموع',\n    days: 'يوم',\n    pct: 'الدقة',\n    workers: 'عمالة',\n    machines: 'معدات',\n    materials: 'مواد',\n    subtotal: 'ملخص',\n    searching: 'بحث',\n    of: 'من',\n    not_found: 'غير موجود',\n    low_conf: 'للمراجعة',\n    price_note: 'الأساس: دبي 2025',\n    btn_calc: '▶ حساب',\n    btn_new: '+ جديد',\n    btn_lang: '⚙ لغة',\n    btn_edit: '✎ تعديل',\n    btn_delete: '✕ حذف',\n    btn_add_work: '+ إضافة',\n    btn_done: '✅ تم',\n    btn_export_excel: '↓ Excel',\n    btn_export_pdf: '↓ PDF',\n    btn_restart: '↻ البدء من جديد',\n    btn_help: '? مساعدة',\n    loading: 'جاري الحساب...',\n    more_in_html: 'تقرير مفصل متاح',\n    resources: 'التفاصيل: الموارد ونطاق العمل',\n    enter_work: '*إضافة بند*\\nالصيغة: الوصف، الكمية الوحدة',\n    work_added: '✅ تمت الإضافة',\n    what_next: '*الخيارات:*',\n    categories: 'الفئات',\n    cat_demolition: 'هدم',\n    cat_rough: 'هيكل',\n    cat_finishing: 'تشطيب',\n    cat_mep: 'ميكانيك',\n    help_title: '*دليل*',\n    help_text: `*دليل الاستخدام*\n\n*1.* أرسل صور أو PDF أو وصف\nPDF: حتى 3 صفحات\n*2.* راجع الأعمال\n*3.* احسب الأسعار\n*4.* صدّر\n\n/start — جديد\n/help — مساعدة`,\n    doc_title: 'التقدير',\n    col_pos: 'رقم', col_code: 'الرمز', col_desc: 'الوصف', col_unit: 'وحدة', col_qty: 'كمية', col_price: 'سعر', col_total: 'المجموع', col_labor: 'ساعات', col_quality: 'ج',\n    grand_total: 'الإجمالي', labor_cost: 'عمالة', material_cost: 'مواد', labor_days: 'أيام',\n    kpi_total: 'التكلفة', kpi_hours: 'ساعات', kpi_days: 'أيام',\n    chart_cost_structure: 'الهيكل', chart_labor: 'عمالة', chart_material: 'مواد', chart_machines: 'معدات',\n    res_labor: 'عمالة', res_material: 'مواد', res_machine: 'معدات',\n    collapse_all: 'طي', expand_all: 'توسيع',\n    quality_high: 'عالية', quality_medium: 'متوسطة', quality_low: 'منخفضة',\n    export_excel_msg: 'تصدير Excel', export_pdf_msg: 'تصدير PDF', btn_refine: 'تحليل أدق', found_pct: 'وجد', more_resources: 'المزيد', kpi_items: 'بنود', scope_title: 'نطاق العمل', show_scope: 'عرض النطاق'\n  },\n\n  'HI': { \n    db: 'HI_MUMBAI_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR', \n    name: 'Hindi', flag: '🇮🇳', native: 'हिन्दी', cur: 'INR', sym: '₹', loc: 'hi-IN', region: 'मुंबई', search_lang: 'Hindi',\n    ok: '✅ *हिन्दी* · मुंबई · INR',\n    photo: `*फ़ोटो, PDF या विवरण भेजें*\n\n📄 *PDF ड्राइंग* — फ्लोर प्लान या ब्लूप्रिंट (अधिकतम 3 पेज)\n📷 *फ़ोटो* — कमरे या वस्तु की फ़ोटो\n✏️ *टेक्स्ट* — कार्यों का विवरण सूची में\n\n_कॉपी उदाहरण:_\n\\`\\`\\`\nड्राईवॉल 2 लेयर मेटल फ्रेम 25m2\nटाइल्स 60x60 बाथरूम 15m2\nपुट्टी छत दीवारें 120m2\nसॉकेट 20 पीस\n\\`\\`\\`\n\nया फ़ोटो/PDF भेजें 📷📄`,\n    pdf_received: '📄 *PDF प्राप्त*',\n    pdf_processing: '⏳ ड्राइंग का विश्लेषण...',\n    pdf_pages: 'पेज',\n    pdf_page_limit: '⚠️ केवल पहले 3 पेज प्रोसेस होंगे',\n    pdf_rooms_found: '🏠 कमरे मिले',\n    pdf_elements_found: '🧱 एलिमेंट मिले',\n    pdf_works_generated: '📝 कार्य बनाए गए',\n    pdf_analyzing_page: '🔍 पेज का विश्लेषण',\n    pdf_of: 'में से',\n    pdf_complete: '✅ विश्लेषण पूर्ण',\n    pdf_error: '❌ PDF प्रोसेसिंग त्रुटि',\n    photo_added: '✅ फ़ोटो जोड़ी गई',\n    photos_count: 'फ़ोटो',\n    add_more: '+ और फ़ोटो',\n    analyze_now: '▶ विश्लेषण',\n    analyzing: 'विश्लेषण...',\n    found: '*पहचाने गए कार्य:*',\n    edit_hint: 'संपादन के लिए टैप करें',\n    calc: 'मूल्य खोज',\n    ready: '*अनुमान*',\n    total: 'कुल',\n    days: 'दिन',\n    pct: 'सटीकता',\n    workers: 'श्रम',\n    machines: 'उपकरण',\n    materials: 'सामग्री',\n    subtotal: 'सारांश',\n    searching: 'खोज',\n    of: 'में से',\n    not_found: 'नहीं मिला',\n    low_conf: 'जाँचें',\n    price_note: 'आधार: मुंबई 2025',\n    btn_calc: '▶ गणना',\n    btn_new: '+ नया',\n    btn_lang: '⚙ भाषा',\n    btn_edit: '✎ संपादित',\n    btn_delete: '✕ हटाएं',\n    btn_add_work: '+ जोड़ें',\n    btn_done: '✅ हो गया',\n    btn_export_excel: '↓ Excel',\n    btn_export_pdf: '↓ PDF',\n    btn_restart: '↻ फिर से',\n    btn_help: '? मदद',\n    loading: 'गणना...',\n    more_in_html: 'विस्तृत रिपोर्ट उपलब्ध',\n    resources: 'विवरण: संसाधन और कार्य क्षेत्र',\n    enter_work: '*आइटम जोड़ें*\\nप्रारूप: विवरण, मात्रा इकाई',\n    work_added: '✅ जोड़ा गया',\n    what_next: '*विकल्प:*',\n    categories: 'श्रेणियां',\n    cat_demolition: 'तोड़फोड़',\n    cat_rough: 'ढांचा',\n    cat_finishing: 'फ़िनिशिंग',\n    cat_mep: 'MEP',\n    help_title: '*गाइड*',\n    help_text: `*उपयोग गाइड*\n\n*1.* फ़ोटो, PDF या विवरण भेजें\nPDF: अधिकतम 3 पेज\n*2.* कार्य जाँचें\n*3.* मूल्य गणना\n*4.* निर्यात\n\n/start — नया\n/help — मदद`,\n    doc_title: 'अनुमान',\n    col_pos: 'क्रम', col_code: 'कोड', col_desc: 'विवरण', col_unit: 'इकाई', col_qty: 'मात्रा', col_price: 'दर', col_total: 'कुल', col_labor: 'घंटे', col_quality: 'गु',\n    grand_total: 'कुल योग', labor_cost: 'श्रम', material_cost: 'सामग्री', labor_days: 'दिन',\n    kpi_total: 'कुल लागत', kpi_hours: 'घंटे', kpi_days: 'दिन',\n    chart_cost_structure: 'संरचना', chart_labor: 'श्रम', chart_material: 'सामग्री', chart_machines: 'उपकरण',\n    res_labor: 'श्रम', res_material: 'सामग्री', res_machine: 'उपकरण',\n    collapse_all: 'संक्षिप्त', expand_all: 'विस्तृत',\n    quality_high: 'उच्च', quality_medium: 'मध्यम', quality_low: 'निम्न',\n    export_excel_msg: 'Excel निर्यात', export_pdf_msg: 'PDF निर्यात', btn_refine: 'सुधारें', found_pct: 'मिला', more_resources: 'और', kpi_items: 'आइटम', scope_title: 'कार्य क्षेत्र', show_scope: 'देखें'\n  }\n};\n\nconst L = LANGS[lang] || LANGS['EN'];\n\n// Update session with language data\nif (sd.sess && sd.sess[chatId]) { \n  sd.sess[chatId].db = L.db; \n  sd.sess[chatId].L = L; \n}\n\n// Get voice/PDF file IDs from session if available\nconst voiceFileId = sd.sess?.[chatId]?.voiceFileId || input.voiceFileId || null;\nconst pdfFileId = sd.sess?.[chatId]?.pdfFileId || input.pdfFileId || null;\nconst pdfFileName = sd.sess?.[chatId]?.pdfFileName || input.pdfFileName || null;\n\nreturn { \n  json: { \n    ...input, \n    L: L, \n    db: L.db, \n    voiceFileId,\n    pdfFileId,\n    pdfFileName,\n    // API Keys passthrough\n    AI_PROVIDER: input.AI_PROVIDER || 'gemini',\n    GEMINI_API_KEY: input.GEMINI_API_KEY,\n    OPENAI_API_KEY: input.OPENAI_API_KEY\n  } \n};"
      },
      "typeVersion": 2
    },
    {
      "id": "a0234943-000e-42fb-9a75-0432dbd3b71a",
      "name": "Main",
      "type": "n8n-nodes-base.code",
      "position": [
        -1680,
        1392
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// ═══════════════════════════════════════════════════════════════════════════\n// MAIN ROUTER - Central message handler\n// DDC CWICR - Data Driven Construction Cost Estimator\n// https://DataDrivenConstruction.io\n// ═══════════════════════════════════════════════════════════════════════════\n// https://DataDrivenConstruction.io\n// Open source construction cost database with 55,000+ work items\n// ═══════════════════════════════════════════════════════════════════════════\n\n// ═══════════════════════════════════════════════════════════════════════════\n// MAIN ROUTER v8.5 PRO - Multi-photo, Voice, PDF, Edit, Categories, Export\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst update = $('Telegram Trigger').first().json;\nconst botToken = $input.first().json.bot_token;\nconst isCallback = !!update.callback_query;\n\nlet chatId, callbackData, callbackQueryId, text, fileId, hasImage, caption;\nlet hasVoice, voiceFileId, mediaGroupId;\nlet hasPDF, pdfFileId, pdfFileName; // PDF support\n\nif (isCallback) {\n  const cb = update.callback_query;\n  chatId = cb.message?.chat?.id;\n  callbackData = cb.data || '';\n  callbackQueryId = cb.id;\n  text = ''; fileId = null; hasImage = false; caption = ''; \n  hasVoice = false; voiceFileId = null; mediaGroupId = null;\n  hasPDF = false; pdfFileId = null; pdfFileName = null;\n} else {\n  const msg = update.message || {};\n  chatId = msg.chat?.id;\n  callbackData = ''; callbackQueryId = '';\n  text = msg.text || ''; caption = msg.caption || '';\n  mediaGroupId = msg.media_group_id || null;\n  \n  // Photo detection\n  const photo = msg.photo || [];\n  fileId = photo.length > 0 ? photo[photo.length - 1].file_id : null;\n  hasImage = !!fileId;\n  if (!fileId && msg.document?.mime_type?.startsWith('image/')) {\n    fileId = msg.document.file_id; hasImage = true;\n  }\n  \n  // Voice detection\n  hasVoice = !!(msg.voice || msg.audio);\n  voiceFileId = msg.voice?.file_id || msg.audio?.file_id || null;\n  \n  // ═══════════════════════════════════════════════════════════════════════════\n  // PDF DOCUMENT DETECTION\n  // ═══════════════════════════════════════════════════════════════════════════\n  const doc = msg.document || {};\n  hasPDF = doc.mime_type === 'application/pdf';\n  pdfFileId = hasPDF ? doc.file_id : null;\n  pdfFileName = hasPDF ? (doc.file_name || 'document.pdf') : null;\n}\n\nconst sd = $getWorkflowStaticData('global');\nif (!sd.sess) sd.sess = {};\nconst cid = String(chatId);\nif (!sd.sess[cid]) sd.sess[cid] = { \n  lang: null, works: [], state: 'new', db: null, L: null, \n  photos: [], voiceText: '', description: '',\n  categories: { demolition: true, rough: true, finishing: true, mep: true },\n  // PDF state\n  pdfFileId: null, pdfFileName: null, pdfPages: [], \n  rooms: [], elements: [], pdfProcessed: false\n};\nconst S = sd.sess[cid];\n\nlet action = 'none';\n\n// === COMMANDS ===\nif (text.toLowerCase() === '/start') {\n  S.lang = null; S.works = []; S.state = 'wait_lang'; S.db = null; S.L = null;\n  S.photos = []; S.voiceText = ''; S.description = '';\n  S.pdfFileId = null; S.pdfFileName = null; S.rooms = []; S.elements = []; S.pdfProcessed = false;\n  action = 'show_lang';\n}\nelse if (text.toLowerCase() === '/help') {\n  action = 'show_help';\n}\n\n// === LANGUAGE SELECTION ===\nelse if (/^lang_(DE|EN|RU|ES|FR|PT|ZH|AR|HI)$/i.test(callbackData)) {\n  S.lang = callbackData.replace('lang_', '').toUpperCase();\n  S.state = 'wait_photo'; action = 'lang_selected';\n}\n\n// ═══════════════════════════════════════════════════════════════════════════\n// PDF HANDLING\n// ═══════════════════════════════════════════════════════════════════════════\nelse if (hasPDF && S.lang) {\n  S.pdfFileId = pdfFileId;\n  S.pdfFileName = pdfFileName;\n  S.state = 'processing_pdf';\n  S.description = `📄 ${pdfFileName}`;\n  action = 'process_pdf';\n}\n\n// === PHOTO HANDLING (Multi-photo support) ===\nelse if (hasImage && S.lang) {\n  S.photos.push({ fileId, caption });\n  if (caption) S.description = caption;\n  \n  if (mediaGroupId) {\n    S.mediaGroupId = mediaGroupId;\n    S.state = 'collecting_photos';\n    action = 'photo_added';\n  } else {\n    S.state = 'ready_to_analyze';\n    action = 'show_analyze_options';\n  }\n}\n\n// === VOICE MESSAGE ===\nelse if (hasVoice && S.lang) {\n  S.voiceFileId = voiceFileId;\n  S.state = 'processing_voice';\n  action = 'process_voice';\n}\n\n// === ANALYZE BUTTON ===\nelse if (callbackData === 'analyze_photos') {\n  if (S.photos.length > 0) {\n    S.state = 'analyzing';\n    action = 'analyze';\n  } else {\n    action = 'ask_photo';\n  }\n}\n\n// === ADD MORE PHOTOS ===\nelse if (callbackData === 'add_more_photos') {\n  S.state = 'wait_photo';\n  action = 'ask_photo';\n}\n\n// === CATEGORY FILTERS ===\nelse if (/^cat_(demolition|rough|finishing|mep)$/.test(callbackData)) {\n  const cat = callbackData.replace('cat_', '');\n  S.categories[cat] = !S.categories[cat];\n  action = 'update_categories';\n}\n\n// === EDIT WORK ITEMS ===\nelse if (/^edit_work_(\\d+)$/.test(callbackData)) {\n  const idx = parseInt(callbackData.match(/edit_work_(\\d+)/)[1]);\n  S.editingWorkIndex = idx;\n  S.state = 'editing_work';\n  action = 'show_edit_menu';\n}\nelse if (/^delete_work_(\\d+)$/.test(callbackData)) {\n  const idx = parseInt(callbackData.match(/delete_work_(\\d+)/)[1]);\n  if (S.works[idx]) {\n    S.works.splice(idx, 1);\n    S.works.forEach((w, i) => { w.seq = i + 1; w.id = `W${String(i+1).padStart(3,'0')}`; });\n  }\n  action = 'works_updated';\n}\nelse if (/^qty_work_(\\d+)_(.+)$/.test(callbackData)) {\n  const match = callbackData.match(/qty_work_(\\d+)_(.+)/);\n  const idx = parseInt(match[1]);\n  const change = match[2];\n  if (S.works[idx]) {\n    let q = S.works[idx].qty;\n    if (change === 'plus10') q += 10;\n    else if (change === 'minus10') q = Math.max(1, q - 10);\n    else if (change === 'plus1') q += 1;\n    else if (change === 'minus1') q = Math.max(1, q - 1);\n    else if (change === 'double') q *= 2;\n    else if (change === 'half') q = Math.max(1, Math.round(q / 2));\n    S.works[idx].qty = q;\n  }\n  action = 'works_updated';\n}\nelse if (callbackData === 'add_work') {\n  S.state = 'adding_work';\n  action = 'ask_new_work';\n}\nelse if (callbackData === 'done_editing') {\n  S.state = 'works_shown';\n  action = 'works_updated';\n}\n\n// === CALCULATE ===\nelse if (callbackData === 'refine_analysis') {\n  action = 'refine_analysis';\n}\nelse if (callbackData === 'calculate') {\n  if (S.works && S.works.length > 0) {\n    S.state = 'calc';\n    action = 'start_calc';\n  } else {\n    action = 'ask_photo';\n  }\n}\n\n// === EXPORT OPTIONS ===\nelse if (callbackData === 'view_details') {\n  action = 'view_details';\n}\nelse if (callbackData === 'export_excel') {\n  action = 'export_excel';\n}\nelse if (callbackData === 'export_pdf') {\n  action = 'export_pdf';\n}\n\n// === NEW ESTIMATE / RESTART ===\nelse if (callbackData === 'new_photo' || callbackData === 'new_estimate' || callbackData === 'restart') {\n  S.photos = []; S.works = []; S.voiceText = ''; S.description = '';\n  S.pdfFileId = null; S.pdfFileName = null; S.rooms = []; S.elements = []; S.pdfProcessed = false;\n  S.state = 'wait_photo';\n  action = 'ask_photo';\n}\nelse if (callbackData === 'show_help' || callbackData === 'help') {\n  action = 'show_help';\n}\nelse if (callbackData === 'change_language') {\n  S.lang = null; S.state = 'wait_lang';\n  action = 'show_lang';\n}\n\n// === TEXT INPUT (for adding work) ===\nelse if (text && S.state === 'adding_work') {\n  const parts = text.split(',');\n  const name = parts[0]?.trim() || text;\n  let qty = 1, unit = 'm²';\n  if (parts[1]) {\n    const qtyMatch = parts[1].match(/([\\d.,]+)\\s*(.*)/);\n    if (qtyMatch) {\n      qty = parseFloat(qtyMatch[1].replace(',', '.')) || 1;\n      unit = qtyMatch[2]?.trim() || 'm²';\n    }\n  }\n  const newWork = {\n    id: `W${String(S.works.length + 1).padStart(3,'0')}`,\n    name, query: name, qty, unit, conf: 'medium', category: 'general', seq: S.works.length + 1\n  };\n  S.works.push(newWork);\n  S.state = 'works_shown';\n  action = 'works_updated';\n}\n\n// === FALLBACKS ===\nelse if (!S.lang) { action = 'show_lang'; }\nelse if (S.state === 'wait_photo' && !hasImage && !hasPDF && text && text.length > 5) {\n  S.description = text;\n  S.textInput = text;\n  S.photos = S.photos || [];\n  action = 'analyze_text';\n}\nelse if (S.state === 'wait_photo' && !hasImage && !hasPDF) { action = 'ask_photo'; }\nelse if (S.state === 'collecting_photos') {\n  action = 'none';\n}\n\nreturn { json: { \n  bot_token: botToken, chatId, action, lang: S.lang, works: S.works, \n  callbackData, callbackQueryId, isCallback, hasImage, hasVoice, \n  fileId, voiceFileId, text, caption, photos: S.photos,\n  categories: S.categories, editingWorkIndex: S.editingWorkIndex,\n  description: S.description, voiceText: S.voiceText,\n  // PDF fields\n  hasPDF, pdfFileId, pdfFileName,\n  rooms: S.rooms, elements: S.elements, pdfProcessed: S.pdfProcessed\n}};"
      },
      "typeVersion": 2
    },
    {
      "id": "a73da24e-48b2-4d5d-910a-a0ad3b914f8e",
      "name": "🔑 TOKEN",
      "type": "n8n-nodes-base.set",
      "position": [
        -1952,
        1392
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "{\n  \"bot_token\": \"YOUR_TELEGRAM_BOT_TOKEN\",\n  \n  \"AI_PROVIDER\": \"gemini\",\n  \n  \"GEMINI_API_KEY\": \"YOUR_GEMINI_API_KEY\",\n  \n  \"OPENAI_API_KEY\": \"YOUR_OPENAI_API_KEY\",\n  \n  \"QDRANT_URL\": \"http://localhost:6333\",\n  \"QDRANT_API_KEY\": \"YOUR_QDRANT_API_KEY\"\n}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "32aae0b5-4d2a-4aa2-8e6c-f0d5fdef483f",
      "name": "Telegram Trigger",
      "type": "n8n-nodes-base.telegramTrigger",
      "position": [
        -2144,
        1392
      ],
      "webhookId": "00faed2e",
      "parameters": {
        "updates": [
          "message",
          "callback_query"
        ],
        "additionalFields": {}
      },
      "credentials": {
        "telegramApi": {
          "id": "YOUR_CREDENTIAL_ID",
          "name": "Your Telegram Bot"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "10bf7718-bc60-478d-abcd-e7347b36d09f",
      "name": "Block 4 - Vision",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -480,
        848
      ],
      "parameters": {
        "color": 6,
        "width": 2248,
        "height": 644,
        "content": "## 👁️ Block 4: Vision Analysis Pipeline\n\n**Photo Analysis Flow:**\n```\nRefine → Prep Download → Get File → Download → Convert Base64 → Merge → Prep Vision → Call API → Parse AI → Show Works\n```\n\n**AI Providers:**\n- **Gemini 2.0 Flash** — Fast, multilingual\n- **GPT-4 Vision** — High accuracy\n\n**Output:** JSON with work items:\n- name, qty, unit, conf, cat"
      },
      "typeVersion": 1
    },
    {
      "id": "547f468a-a1cc-4607-a160-cb24594665cf",
      "name": "Block 5 - PDF",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -480,
        1536
      ],
      "parameters": {
        "color": 6,
        "width": 2896,
        "height": 312,
        "content": "## 📄 Block 5: PDF Floor Plan Processing\n\n**PDF Pipeline:**\n```\nDownload Prep → Get Path → Download → Split Pages → Loop → Vision per Page → Parse → Accumulate\n```\n\n**Features:**\n- Multi-page PDF support\n- Room detection per page\n- Measurements extraction\n- Aggregation across pages"
      },
      "typeVersion": 1
    },
    {
      "id": "4aa87cec-13e9-4c06-b6d7-7bd0d02505a2",
      "name": "Block 6 - Calculation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        96,
        2384
      ],
      "parameters": {
        "color": 3,
        "width": 1728,
        "height": 724,
        "content": "## 🔄 Block 6: Calculation Loop\n\n**Per Work Item:**\n```\nPrep Query → LLM Transform → Embedding → Qdrant Search → Rerank → Calculate → Update Result → Accumulate\n```\n\n**Vector Search:**\n- OpenAI embeddings\n- Qdrant vector DB\n- 55,000+ construction rates\n\n**AI Reranking:**\n- LLM validates matches\n- Quality scoring (●○◌✕)"
      },
      "typeVersion": 1
    },
    {
      "id": "99c168d5-2470-4153-bbb6-e46bc81ebba4",
      "name": "Block 7 - Reports",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        96,
        1936
      ],
      "parameters": {
        "color": 6,
        "width": 1720,
        "height": 420,
        "content": "## 📊 Block 7: Aggregation & Reports\n\n**Final Processing:**\n```\nCleanup → Delete Messages → Aggregate → Generate HTML → Final Message → Send HTML\n```\n\n**Report Contents:**\n- Work items with resources\n- Cost breakdown (Labor/Materials/Equipment)\n- Quality indicators\n- Total calculation"
      },
      "typeVersion": 1
    },
    {
      "id": "8518453e-cbde-4a6a-a6b3-3e40daa9a511",
      "name": "Block 8 - Export",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1248,
        2208
      ],
      "parameters": {
        "color": 6,
        "width": 1320,
        "height": 692,
        "content": "## 📥 Block 8: Export Options\n\n**Available Exports:**\n- 📊 **Excel (CSV)** — Spreadsheet format\n- 📄 **PDF** — Professional document\n- 🌐 **HTML** — Interactive report\n\n**View Details:**\n- Full resource breakdown\n- Scope of work preview\n- Rate codes & names"
      },
      "typeVersion": 1
    },
    {
      "id": "eaf3c968-6165-4e51-9418-fb55243ca68d",
      "name": "Qdrant Info",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1568,
        2704
      ],
      "parameters": {
        "width": 236,
        "height": 384,
        "content": "## 🔍 Vector Search Setup\n\n**Qdrant Collections:**\n- `DDC_CWICR_DE` — German\n- `DDC_CWICR_EN` — English  \n- `DDC_CWICR_RU` — Russian\n- ... (9 languages total)\n\n**To enable:**\n1. Install Qdrant locally/VPS\n2. Upload datasets from GitHub\n3. Configure URL & API key\n\n**Database:** 55,000+ rates"
      },
      "typeVersion": 1
    },
    {
      "id": "fb3a5397-d567-4fc9-9320-0c0f5b84cce6",
      "name": "Refine Analysis",
      "type": "n8n-nodes-base.code",
      "position": [
        -320,
        1184
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Refine analysis with advanced prompt\n// FIXED: Check if photos are available before proceeding\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $('Config').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\nconst L = cfg.L || session.L || {};\n\nconsole.log('=== REFINE ANALYSIS ===');\nconsole.log('Session keys:', Object.keys(session));\nconsole.log('photos_base64:', session.photos_base64?.length || 0);\nconsole.log('photos:', (cfg.photos || session.photos || []).length);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// ПРОВЕРЯЕМ: есть ли фотографии для анализа?\n// ═══════════════════════════════════════════════════════════════════════════\nconst hasStoredBase64 = session.photos_base64 && session.photos_base64.length > 0;\nconst hasPhotoFileIds = (cfg.photos && cfg.photos.length > 0) || (session.photos && session.photos.length > 0);\n\nconsole.log('hasStoredBase64:', hasStoredBase64);\nconsole.log('hasPhotoFileIds:', hasPhotoFileIds);\n\nif (!hasStoredBase64 && !hasPhotoFileIds) {\n  console.log('No photos available for refinement - will ask user');\n  \n  if (!sd.sess) sd.sess = {};\n  if (!sd.sess[cid]) sd.sess[cid] = {};\n  sd.sess[cid].state = 'wait_photo';\n  \n  return { json: { \n    ...cfg, \n    chatId: cid,\n    skipRefine: true,\n    noPhotos: true,\n    L \n  }};\n}\n\nconsole.log('Photos available - proceeding with refine');\n\nif (!sd.sess) sd.sess = {};\nif (!sd.sess[cid]) sd.sess[cid] = {};\nsd.sess[cid].use_advanced_prompt = true;\n\nreturn { json: { \n  ...cfg, \n  chatId: cid,\n  use_advanced_prompt: true, \n  skipRefine: false,\n  noPhotos: false,\n  L \n}};"
      },
      "typeVersion": 2
    },
    {
      "id": "743f34f4-4130-46c5-97d9-db79ab95da7d",
      "name": "IF Skip Refine",
      "type": "n8n-nodes-base.if",
      "position": [
        -176,
        1120
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "14fdccc2-7a01-4d7d-9da0-81c0174beb68",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.skipRefine }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "bc87508c-2997-4a43-83e9-7a117c7444f0",
      "name": "📤 Ask Photo Refine",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        32,
        992
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $json.chatId }},\n  \"text\": {{ JSON.stringify('📷 ' + (($json.L && $json.L.photo) ? $json.L.photo.split('\\n')[0] : 'Please send a photo first')) }},\n  \"parse_mode\": \"Markdown\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "fdcf8603-4a6e-40dd-9b41-0ad4ab68203a",
      "name": "Prep Photo Download",
      "type": "n8n-nodes-base.code",
      "position": [
        32,
        1136
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Prepare photo download\n// FIXED: Check for stored base64 first (for Refine Analysis)\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $('Config').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\nconst botToken = $('🔑 TOKEN').first().json.bot_token;\nconst L = cfg.L || session.L || {};\n\nconsole.log('=== PREP PHOTO DOWNLOAD ===');\nconsole.log('chatId:', cid);\nconsole.log('session.photos_base64:', session.photos_base64?.length || 0);\nconsole.log('cfg.photos:', (cfg.photos || []).length);\nconsole.log('session.photos:', (session.photos || []).length);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// ПРОВЕРЯЕМ: есть ли сохранённые base64 изображения в сессии?\n// ═══════════════════════════════════════════════════════════════════════════\nif (session.photos_base64 && session.photos_base64.length > 0) {\n  console.log('✅ Found stored base64 in session:', session.photos_base64.length, 'photos');\n  console.log('First photo base64 length:', session.photos_base64[0]?.base64?.length || 0);\n  \n  return { json: { \n    ...cfg,\n    chatId: cid,\n    bot_token: cfg.bot_token,\n    photos: session.photos_base64,\n    photoCount: session.photos_base64.length,\n    description: session.description || cfg.description || '',\n    useStoredBase64: true,\n    skipDownload: true,\n    botToken: botToken,\n    lang: cfg.lang || session.lang,\n    L\n  }};\n}\n\nconst photos = cfg.photos || session.photos || [];\n\nconsole.log('Photos to download:', photos.length);\n\nif (!photos || photos.length === 0) {\n  console.log('❌ No photos available');\n  return { json: { \n    ...cfg, \n    chatId: cid,\n    bot_token: cfg.bot_token,\n    photos: [], \n    photoCount: 0,\n    skipDownload: true,\n    noPhotosError: true,\n    botToken: botToken,\n    L\n  }};\n}\n\nconst firstPhoto = photos[0];\n\nconsole.log('Downloading photo with fileId:', firstPhoto.fileId);\n\nreturn { json: { \n  ...cfg,\n  chatId: cid,\n  bot_token: cfg.bot_token,\n  fileId: firstPhoto.fileId,\n  allPhotos: photos,\n  photoIndex: 0,\n  botToken: botToken,\n  useStoredBase64: false,\n  skipDownload: false,\n  L\n}};"
      },
      "typeVersion": 2
    },
    {
      "id": "ab7e8b09-6672-400e-8a7f-295a54816079",
      "name": "Prep Text LLM",
      "type": "n8n-nodes-base.code",
      "position": [
        -1056,
        1472
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// DDC CWICR - Prep Text LLM Request\n// Prepare API request for text-to-works analysis\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $('Config').first().json;\nconst tokenConfig = $('🔑 TOKEN').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\nconst L = cfg.L || {};\n\nconst textInput = cfg.text || session.textInput || cfg.textInput || cfg.description || '';\nconst searchLang = L.search_lang || 'English';\n\nconsole.log('=== PREP TEXT LLM ===');\nconsole.log('Input:', textInput);\nconsole.log('Language:', searchLang);\n\nif (!textInput || textInput.length < 3) {\n  return { json: { ...cfg, chatId: cid, _skip_llm: true, works: [], description: 'No input' }};\n}\n\nconst GEMINI_API_KEY = tokenConfig.GEMINI_API_KEY || '';\nconst OPENAI_API_KEY = tokenConfig.OPENAI_API_KEY || '';\nconst provider = (tokenConfig.AI_PROVIDER || 'gemini').toLowerCase();\n\n// Language-specific examples\nconst LANG_EXAMPLES = {\n  'German': {\n    input: 'Renovierung Badezimmer 8qm',\n    output: '[{\"name\": \"Fliesendemontage Wand Boden\", \"qty\": 24, \"unit\": \"m²\"}, {\"name\": \"Wandfliesen Feinsteinzeug 30x60\", \"qty\": 16, \"unit\": \"m²\"}, {\"name\": \"Bodenfliesen rutschfest\", \"qty\": 8, \"unit\": \"m²\"}, {\"name\": \"WC wandhängend montieren\", \"qty\": 1, \"unit\": \"pcs\"}, {\"name\": \"Waschtisch montieren\", \"qty\": 1, \"unit\": \"pcs\"}]'\n  },\n  'English': {\n    input: 'Bathroom renovation 8sqm',\n    output: '[{\"name\": \"Tile removal walls floor\", \"qty\": 24, \"unit\": \"m²\"}, {\"name\": \"Wall tiles porcelain 30x60\", \"qty\": 16, \"unit\": \"m²\"}, {\"name\": \"Floor tiles anti-slip\", \"qty\": 8, \"unit\": \"m²\"}, {\"name\": \"Wall-hung toilet install\", \"qty\": 1, \"unit\": \"pcs\"}, {\"name\": \"Vanity basin install\", \"qty\": 1, \"unit\": \"pcs\"}]'\n  },\n  'Russian': {\n    input: 'ремонт кухни 20м2 стены и ламинат',\n    output: '[{\"name\": \"Демонтаж старых обоев\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Штукатурка стен гипсовая\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Шпаклёвка стен финишная\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Окраска стен водоэмульсионная\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Демонтаж напольного покрытия\", \"qty\": 20, \"unit\": \"m²\"}, {\"name\": \"Укладка ламината с подложкой\", \"qty\": 20, \"unit\": \"m²\"}, {\"name\": \"Монтаж плинтуса\", \"qty\": 18, \"unit\": \"m\"}]'\n  },\n  'Spanish': {\n    input: 'reforma cocina 20m2 paredes y suelo',\n    output: '[{\"name\": \"Retirada papel pintado\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Enlucido paredes\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Pintura paredes\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Demolición suelo\", \"qty\": 20, \"unit\": \"m²\"}, {\"name\": \"Tarima flotante\", \"qty\": 20, \"unit\": \"m²\"}]'\n  },\n  'French': {\n    input: 'rénovation cuisine 20m2 murs et sol',\n    output: '[{\"name\": \"Dépose papier peint\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Enduit murs\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Peinture murs\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Dépose revêtement sol\", \"qty\": 20, \"unit\": \"m²\"}, {\"name\": \"Pose parquet flottant\", \"qty\": 20, \"unit\": \"m²\"}]'\n  },\n  'Portuguese': {\n    input: 'reforma cozinha 20m2 paredes e piso',\n    output: '[{\"name\": \"Remoção papel parede\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Reboco paredes\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Pintura paredes\", \"qty\": 50, \"unit\": \"m²\"}, {\"name\": \"Remoção piso\", \"qty\": 20, \"unit\": \"m²\"}, {\"name\": \"Piso laminado\", \"qty\": 20, \"unit\": \"m²\"}]'\n  }\n};\n\nconst langExample = LANG_EXAMPLES[searchLang] || LANG_EXAMPLES['English'];\n\nconst prompt = `You are a construction cost estimator. Extract ALL construction works from user's text.\n\nRULES:\n1. Output ONLY valid JSON array - NO explanations, NO markdown code blocks\n2. Generate works in ${searchLang} language  \n3. Be COMPREHENSIVE - include ALL works needed for described scope\n4. Use REALISTIC quantities based on area/room size mentioned\n5. Work names must be SPECIFIC for database search (not generic like \"wall work\")\n\nUNITS: m² (area), m (linear), pcs (items), kg, l\n\nEXAMPLE:\nInput: \"${langExample.input}\"\nOutput: ${langExample.output}\n\nUSER INPUT: \"${textInput}\"\n\nJSON ARRAY OUTPUT:`;\n\nlet apiUrl, requestBody;\n\nif (provider === 'gemini' && GEMINI_API_KEY) {\n  apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=' + GEMINI_API_KEY;\n  requestBody = {\n    contents: [{ parts: [{ text: prompt }] }],\n    generationConfig: { temperature: 0.15, maxOutputTokens: 4000 }\n  };\n} else if (OPENAI_API_KEY) {\n  apiUrl = 'https://api.openai.com/v1/chat/completions';\n  requestBody = {\n    model: 'gpt-4o-mini',\n    messages: [{ role: 'user', content: prompt }],\n    temperature: 0.15,\n    max_tokens: 4000\n  };\n}\n\nconsole.log('Provider:', provider);\nconsole.log('API URL:', apiUrl ? 'configured' : 'MISSING');\n\nreturn { json: { \n  ...cfg, \n  chatId: cid, \n  textInput,\n  description: textInput.substring(0, 50) + (textInput.length > 50 ? '...' : ''),\n  _llm_provider: provider,\n  _llm_api_url: apiUrl,\n  _llm_request_body: requestBody,\n  _llm_api_key: provider === 'openai' ? OPENAI_API_KEY : '',\n  L\n}};"
      },
      "typeVersion": 2
    },
    {
      "id": "5054346e-8316-4c37-93a8-af028b02b605",
      "name": "Call Text LLM",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -856,
        1472
      ],
      "parameters": {
        "url": "={{ $json._llm_api_url }}",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify($json._llm_request_body) }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Authorization",
              "value": "={{ $json._llm_provider === 'openai' ? 'Bearer ' + $json._llm_api_key : '' }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "87e2e087-b1aa-4a34-9c13-8f457cec3a5d",
      "name": "Edit Menu",
      "type": "n8n-nodes-base.code",
      "position": [
        -864,
        2080
      ],
      "parameters": {
        "jsCode": "// ═══════════════════════════════════════════════════════════════════════════\n// EDIT MENU - Show edit buttons for selected work item\n// ═══════════════════════════════════════════════════════════════════════════\n\nconst cfg = $('Config').first().json;\nconst cid = String(cfg.chatId);\nconst sd = $getWorkflowStaticData('global');\nconst session = sd.sess?.[cid] || {};\nconst works = session.works || [];\nconst L = cfg.L || session.L || {};\n\nconst idx = session.editingWorkIndex || cfg.editingWorkIndex || 0;\nconst work = works[idx];\n\nconsole.log('=== EDIT MENU ===');\nconsole.log('Editing work index:', idx);\nconsole.log('Work:', work?.name);\n\nif (!work) {\n  return { json: { ...cfg, _skip: true } };\n}\n\nconst name = work.name.length > 30 ? work.name.substring(0, 27) + '...' : work.name;\nlet msg = '✏️ *' + (L.btn_edit || 'Редактирование') + '*\\n\\n';\nmsg += '*' + (idx + 1) + '. ' + name + '*\\n';\nmsg += '📏 ' + work.qty + ' ' + (work.unit || 'm²') + '\\n\\n';\nmsg += (L.edit_hint || 'Изменить количество:');\n\n// Quantity edit buttons\nconst keyboard = [\n  [\n    { text: '-10', callback_data: 'qty_work_' + idx + '_minus10' },\n    { text: '-1', callback_data: 'qty_work_' + idx + '_minus1' },\n    { text: '+1', callback_data: 'qty_work_' + idx + '_plus1' },\n    { text: '+10', callback_data: 'qty_work_' + idx + '_plus10' }\n  ],\n  [\n    { text: '÷2', callback_data: 'qty_work_' + idx + '_half' },\n    { text: '×2', callback_data: 'qty_work_' + idx + '_double' }\n  ],\n  [\n    { text: '🗑 ' + (L.btn_delete || 'Удалить'), callback_data: 'delete_work_' + idx }\n  ],\n  [\n    { text: '◀️ ' + (L.btn_done || 'Назад'), callback_data: 'done_editing' }\n  ]\n];\n\nreturn { json: { ...cfg, msg, keyboard, chatId: cid } };"
      },
      "typeVersion": 2
    },
    {
      "id": "eaaa85c6-b3a5-4569-b5af-d22d08511024",
      "name": "📤 Edit Menu",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -672,
        2080
      ],
      "parameters": {
        "url": "=https://api.telegram.org/bot{{ $('🔑 TOKEN').first().json.bot_token }}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": {{ $json.chatId }},\n  \"text\": {{ JSON.stringify($json.msg) }},\n  \"parse_mode\": \"Markdown\",\n  \"reply_markup\": { \"inline_keyboard\": {{ JSON.stringify($json.keyboard) }} }\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    }
  ],
  "active": true,
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "fbb0b77c-883b-46b8-907a-6503ec658e4a",
  "connections": {
    "Acc": {
      "main": [
        [
          {
            "node": "Loop",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Agg": {
      "main": [
        [
          {
            "node": "Generate HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop": {
      "main": [
        [
          {
            "node": "🧹 Prep Cleanup",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "📝 Prep Work Msg",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Main": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Final": {
      "main": [
        [
          {
            "node": "Prep HTML File",
            "type": "main",
            "index": 0
          },
          {
            "node": "📤 Final",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route": {
      "main": [
        [
          {
            "node": "📤 Lang Menu",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Answer Lang CB",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Answer Photo CB",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "📤 Analyze Options",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "📤 Analyze Options",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Refine Analysis",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Works Updated",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Edit Menu",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "📤 Ask New Work",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Answer Calc CB",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Generate Excel",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Generate PDF",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "📤 Help",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "View Details",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Refine Analysis",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prep Text LLM",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "📄 PDF Download Prep",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "📤 Fallback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF PDF": {
      "main": [
        [
          {
            "node": "📤 Send PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse AI": {
      "main": [
        [
          {
            "node": "📊 Show Works",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Edit Menu": {
      "main": [
        [
          {
            "node": "📤 Edit Menu",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep Works": {
      "main": [
        [
          {
            "node": "Loop",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "🔑 TOKEN": {
      "main": [
        [
          {
            "node": "Main",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call Vision1": {
      "main": [
        [
          {
            "node": "Merge Vision1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate PDF": {
      "main": [
        [
          {
            "node": "IF PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep Vision1": {
      "main": [
        [
          {
            "node": "Call Vision1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "View Details": {
      "main": [
        [
          {
            "node": "📤 Details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call Text LLM": {
      "main": [
        [
          {
            "node": "Parse Text LLM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate HTML": {
      "main": [
        [
          {
            "node": "Final",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF No Photos1": {
      "main": [
        [
          {
            "node": "📤 No Photos Msg1",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Use Stored Base64",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Vision1": {
      "main": [
        [
          {
            "node": "Parse AI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep Text LLM": {
      "main": [
        [
          {
            "node": "Call Text LLM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Works Updated": {
      "main": [
        [
          {
            "node": "📤 Works Updated",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Answer Calc CB": {
      "main": [
        [
          {
            "node": "📝 Prep Progress",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Answer Lang CB": {
      "main": [
        [
          {
            "node": "📤 Lang OK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Excel": {
      "main": [
        [
          {
            "node": "📤 Send Excel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get File Path1": {
      "main": [
        [
          {
            "node": "Download Photo File1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Skip Refine": {
      "main": [
        [
          {
            "node": "📤 Ask Photo Refine",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prep Photo Download",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Text LLM": {
      "main": [
        [
          {
            "node": "📊 Show Works",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep HTML File": {
      "main": [
        [
          {
            "node": "📤 Send HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📤 Send Work": {
      "main": [
        [
          {
            "node": "💾 Save Work Msg",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Answer Photo CB": {
      "main": [
        [
          {
            "node": "📤 Ask Photo",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert To Base": {
      "main": [
        [
          {
            "node": "Merge To Vision1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Refine Analysis": {
      "main": [
        [
          {
            "node": "IF Skip Refine",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📊 Show Works": {
      "main": [
        [
          {
            "node": "📤 Send Works",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Skip Download": {
      "main": [
        [
          {
            "node": "IF No Photos1",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Get File Path1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge To Vision1": {
      "main": [
        [
          {
            "node": "Prep Vision1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Progress ID": {
      "main": [
        [
          {
            "node": "Prep Works",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Telegram Trigger": {
      "main": [
        [
          {
            "node": "🔑 TOKEN",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📤 Edit Result": {
      "main": [
        [
          {
            "node": "Acc",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "9️⃣ Calculate": {
      "main": [
        [
          {
            "node": "📊 Update Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use Stored Base64": {
      "main": [
        [
          {
            "node": "Merge To Vision1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📄 Download PDF": {
      "main": [
        [
          {
            "node": "📄 Split PDF Pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📄 Get PDF Path": {
      "main": [
        [
          {
            "node": "📄 Download PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📤 PDF Received": {
      "main": [
        [
          {
            "node": "📄 Prep Pages Loop",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "🧹 Prep Cleanup": {
      "main": [
        [
          {
            "node": "🗑️ Delete Work Msg",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1️⃣ Prep Query": {
      "main": [
        [
          {
            "node": "1.5️⃣ LLM Transform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "7️⃣ LLM Rerank": {
      "main": [
        [
          {
            "node": "8️⃣ Apply Rerank",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "💾 Save Work Msg": {
      "main": [
        [
          {
            "node": "1️⃣ Prep Query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📊 Update Result": {
      "main": [
        [
          {
            "node": "📤 Edit Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📝 Prep Progress": {
      "main": [
        [
          {
            "node": "📤 Send Progress",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📝 Prep Work Msg": {
      "main": [
        [
          {
            "node": "🗑️ Delete Prev",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📤 Send Progress": {
      "main": [
        [
          {
            "node": "Save Progress ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "6️⃣ Prep Rerank": {
      "main": [
        [
          {
            "node": "7️⃣ LLM Rerank",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prep Photo Download": {
      "main": [
        [
          {
            "node": "IF Skip Download",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "🏠 Parse PDF Page": {
      "main": [
        [
          {
            "node": "📦 Accumulate Pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "🔁 Loop PDF Pages": {
      "main": [
        [
          {
            "node": "🧹 Deduplicate & Merge",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "👁️ Prep Vision PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "🗑️ Delete Prev": {
      "main": [
        [
          {
            "node": "📤 Send Work",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "8️⃣ Apply Rerank": {
      "main": [
        [
          {
            "node": "9️⃣ Calculate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Photo File1": {
      "main": [
        [
          {
            "node": "Convert To Base",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📄 Prep Pages Loop": {
      "main": [
        [
          {
            "node": "🔁 Loop PDF Pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📄 Split PDF Pages": {
      "main": [
        [
          {
            "node": "📝 Prep PDF Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "5️⃣ Qdrant Search": {
      "main": [
        [
          {
            "node": "6️⃣ Prep Rerank",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📝 Prep PDF Message": {
      "main": [
        [
          {
            "node": "📤 PDF Received",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📦 Accumulate Pages": {
      "main": [
        [
          {
            "node": "🔁 Loop PDF Pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "📄 PDF Download Prep": {
      "main": [
        [
          {
            "node": "📄 Get PDF Path",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1.5️⃣ LLM Transform": {
      "main": [
        [
          {
            "node": "2️⃣ Extract Transform",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "👁️ Call Vision PDF": {
      "main": [
        [
          {
            "node": "🏠 Parse PDF Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "👁️ Prep Vision PDF": {
      "main": [
        [
          {
            "node": "👁️ Call Vision PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "🗑️ Delete Work Msg": {
      "main": [
        [
          {
            "node": "🗑️ Delete Progress Msg",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3️⃣ OpenAI Embedding": {
      "main": [
        [
          {
            "node": "4️⃣ Extract Embedding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "🧹 Deduplicate & Merge": {
      "main": [
        [
          {
            "node": "📊 Show Works",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2️⃣ Extract Transform": {
      "main": [
        [
          {
            "node": "3️⃣ OpenAI Embedding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4️⃣ Extract Embedding": {
      "main": [
        [
          {
            "node": "5️⃣ Qdrant Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "🗑️ Delete Progress Msg": {
      "main": [
        [
          {
            "node": "Agg",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}