{"workflow":{"id":12176,"name":"Estimate construction costs from text, photos and PDFs with Telegram, GPT‑4/Gemini and DDC CWICR","views":260,"recentViews":1,"totalViews":260,"createdAt":"2025-12-26T12:53:12.303Z","description":"A **full-featured Telegram bot** that accepts **text descriptions**, **photos**, or **PDF floor plans** and returns detailed **cost estimates** with work breakdown. Powered by **GPT-4 Vision / Gemini 2.0**, vector search, and the open-source **DDC CWICR** database (55,000+ construction rates).\n\n## Who's it for\n\n- **Contractors & Estimators** who need estimates from any input format\n- **Construction managers** evaluating scope from site photos or drawings\n- **Architects** getting quick cost feedback on floor plans\n- **Real estate professionals** assessing renovation costs\n- **Project managers** doing rapid feasibility checks via mobile\n\n## What it does\n\n1. **Receives** text / photo / PDF via Telegram\n2. **Analyzes** input with AI (Gemini 2.0 Flash or GPT-4 Vision)\n3. **Extracts** work items with quantities and units\n4. **Searches** DDC CWICR vector database for matching rates\n5. **Generates** professional HTML report with full cost breakdown\n6. **Exports** results as Excel or PDF\n\n**Supports 9 languages:** 🇩🇪 DE · 🇬🇧 EN · 🇷🇺 RU · 🇪🇸 ES · 🇫🇷 FR · 🇮🇹 IT · 🇵🇱 PL · 🇧🇷 PT · 🇺🇦 UK\n\n## How it works\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│                        TELEGRAM INPUT                                │\n│  📝 Text Description  │  📷 Construction Photo  │  📄 PDF Floor Plan │\n└─────────────────────────────────────────────────────────────────────┘\n                                    ↓\n┌─────────────────────────────────────────────────────────────────────┐\n│                         MAIN ROUTER                                  │\n│  Parse message → Detect content type → Route to handler (17 actions) │\n└─────────────────────────────────────────────────────────────────────┘\n                                    ↓\n         ┌──────────────────────────┼──────────────────────────┐\n         ↓                          ↓                          ↓\n┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐\n│  Text LLM       │     │  Vision API     │     │  Vision PDF     │\n│  Parse works    │     │  Analyze photo  │     │  Read floor plan│\n│  from text      │     │  GPT-4/Gemini   │     │  Gemini 2.0     │\n└─────────────────┘     └─────────────────┘     └─────────────────┘\n         └──────────────────────────┼──────────────────────────┘\n                                    ↓\n┌─────────────────────────────────────────────────────────────────────┐\n│                    CALCULATION LOOP                                  │\n│  For each work item:                                                 │\n│  1️⃣ Transform query → 2️⃣ Optimize search → 3️⃣ Get embedding         │\n│  4️⃣ Qdrant search → 5️⃣ Score results → 6️⃣ AI rerank → 7️⃣ Calculate  │\n└─────────────────────────────────────────────────────────────────────┘\n                                    ↓\n┌─────────────────────────────────────────────────────────────────────┐\n│                      OUTPUT                                          │\n│  📊 Telegram message  │  🌐 HTML Report  │  📑 Excel  │  📄 PDF      │\n└─────────────────────────────────────────────────────────────────────┘\n```\n\n## Input Types\n\n| Type | Description | AI Used |\n|------|-------------|---------|\n| 📝 **Text** | Work lists, specifications, notes | OpenAI GPT-4 |\n| 📷 **Photo** | Construction site photos (up to 4) | GPT-4 Vision / Gemini |\n| 📄 **PDF** | Floor plans, architectural drawings | Gemini 2.0 Flash |\n\n## Route Actions (17 total)\n\n| # | Action | Description |\n|---|--------|-------------|\n| 0 | show_lang | Language selection menu |\n| 1 | ask_photo | Request photo upload |\n| 2 | lang_selected | Save language preference |\n| 3 | show_analyze | Photo analysis options |\n| 4 | analyze | Run AI vision analysis |\n| 5 | show_edit_menu | Edit work quantities |\n| 6 | works_updated | After quantity change |\n| 7 | ask_new_work | Add manual work item |\n| 8 | start_calc | Start cost calculation |\n| 9 | show_help | Display help message |\n| 10 | view_details | Show resource details |\n| 11 | export_excel | Generate CSV export |\n| 12 | export_pdf | Generate PDF export |\n| 13 | process_pdf | Analyze PDF floor plan |\n| 14 | analyze_text | Parse text description |\n| 15 | refine | Re-analyze with context |\n| 16 | fallback | Handle unknown input |\n\n## Prerequisites\n\n| Component | Requirement |\n|-----------|-------------|\n| **n8n** | v1.30+ with Telegram Trigger |\n| **Telegram Bot** | Token from @BotFather |\n| **OpenAI API** | For embeddings + text parsing |\n| **Gemini API** | For Vision (photos/PDF) — or use GPT-4 Vision |\n| **Qdrant** | Vector DB with DDC CWICR collections |\n| **DDC CWICR Data** | [github.com/datadrivenconstruction/DDC-CWICR](https://github.com/datadrivenconstruction/OpenConstructionEstimate-DDC-CWICR) |\n\n## Setup\n\n### 1. Configure 🔑 TOKEN Node\n```json\n{\n  \"bot_token\": \"YOUR_TELEGRAM_BOT_TOKEN\",\n  \"AI_PROVIDER\": \"gemini\",\n  \"GEMINI_API_KEY\": \"YOUR_GEMINI_KEY\",\n  \"OPENAI_API_KEY\": \"YOUR_OPENAI_KEY\",\n  \"QDRANT_URL\": \"http://localhost:6333\",\n  \"QDRANT_API_KEY\": \"YOUR_QDRANT_KEY\"\n}\n```\n\n### 2. Vision Provider Selection\n- `AI_PROVIDER: \"gemini\"` → Gemini 2.0 Flash (recommended for photos + PDF)\n- `AI_PROVIDER: \"openai\"` → GPT-4 Vision (photos only)\n\n### 3. n8n Credentials\n1. **Settings** → **Credentials** → **Add** → **Telegram API**\n2. Enter bot token, save\n3. Select credential in **Telegram Trigger** node\n\n### 4. Qdrant Collections\nLoad DDC CWICR embeddings for target languages (example for Russian):\n```\nRU_STPETERSBURG_workitems_costs_resources_EMBEDDINGS_3072_DDC_CWICR\n```\n\n### 5. Activate & Test\n1. Activate workflow\n2. Send `/start` to your bot\n3. Select language → send photo/text/PDF\n\n## Features\n\n| Feature | Description |\n|---------|-------------|\n| 📷 **Photo Analysis** | GPT-4 Vision or Gemini 2.0 for site photos |\n| 📄 **PDF Processing** | Floor plan analysis with room extraction |\n| 📝 **Text Parsing** | Natural language work lists |\n| 🔍 **Vector Search** | Semantic matching via Qdrant + OpenAI embeddings |\n| 🤖 **AI Reranking** | LLM-based result scoring for accuracy |\n| ✏️ **Inline Editing** | Modify quantities via Telegram buttons |\n| 📊 **HTML Report** | Professional expandable report with KPIs |\n| 📑 **Excel Export** | CSV with full work breakdown |\n| 📄 **PDF Export** | HTML-based PDF document |\n| 🌍 **9 Languages** | Full UI + database localization |\n| 💾 **Session State** | Multi-turn conversation support |\n| 🔧 **Refine Mode** | Re-analyze with additional context |\n\n## Example Workflow\n\n**User:** `/start`  \n**Bot:** Language selection menu (9 options)\n\n**User:** Selects 🇷🇺 Russian  \n**Bot:** \"Отправьте фото, PDF или текстовое описание работ\"\n\n**User:** Sends bathroom photo  \n**Bot:** \"📷 Анализ фото... ⏳\"\n\n**Bot:** Shows detected works:\n```\n🏠 Ванная комната — 4.5 m²\n\nНайдено 12 работ:\n1. Демонтаж плитки стен — 18 m²\n2. Демонтаж плитки пола — 4.5 m²\n3. Гидроизоляция пола — 4.5 m²\n4. Гидроизоляция стен — 8 m²\n5. Стяжка пола — 4.5 m²\n6. Укладка плитки стены — 18 m²\n7. Укладка плитки пол — 4.5 m²\n8. Установка унитаза — 1 шт\n9. Установка раковины — 1 шт\n10. Установка смесителя — 2 шт\n...\n\n[✏️ Редактировать] [📊 Рассчитать]\n```\n\n**User:** Taps 📊 Calculate  \n**Bot:** Shows progress per item, then final result:\n\n```\n✅ Смета готова — 12 позиций\n\n💰 Итого: ₽ 89,450\n\nРабота: ₽ 35,200 (39%)\nМатериалы: ₽ 48,750 (55%)\nМеханизмы: ₽ 5,500 (6%)\n\n[📋 Детали] [↓ Excel] [↓ PDF] [↻ Заново]\n```\n\n## HTML Report Features\n\n- **KPI Cards:** Total cost, item count, labor days, cost breakdown %\n- **Expandable rows:** Click work item to show resources\n- **Resource tags:** Color-coded (Labor/Material/Machine)\n- **Scope of work:** Expandable detailed descriptions\n- **Quality indicators:** Match quality dots (high/medium/low)\n- **Responsive design:** Works on mobile and desktop\n- **Export buttons:** Expand/Collapse all\n\n## Notes & Tips\n\n- **Photo tips:** Capture full room, include reference objects (doors, tiles)\n- **PDF support:** Works best with clear floor plans and room schedules\n- **Text input:** Supports lists, tables, free-form descriptions\n- **Rate accuracy:** Depends on DDC CWICR coverage for your region\n- **Session timeout:** User sessions persist across messages\n- **Extend:** Chain with CRM, project management, or notification tools\n\n## Categories\n\n`AI` · `Communication` · `Data Extraction` · `Document Ops`\n\n## Tags\n\n`telegram-bot`, `construction`, `cost-estimation`, `gpt-4-vision`, `gemini`, `pdf-analysis`, `qdrant`, `vector-search`, `multilingual`, `html-report`\n\n---\n\n## Author\n\n**DataDrivenConstruction.io**  \n[https://DataDrivenConstruction.io](https://DataDrivenConstruction.io)  \n[info@datadrivenconstruction.io](mailto:info@datadrivenconstruction.io)\n\n## Consulting & Training\n\nWe help construction, engineering, and technology firms implement:\n- AI-powered estimation systems (text, photo, PDF)\n- Multi-channel bot integrations (Telegram, WhatsApp, Web)\n- Vector database solutions for construction data\n- Multilingual cost database deployment\n\n**Contact us** to test with your data or adapt to your project requirements.\n\n## Resources\n\n- **DDC CWICR Database:** [GitHub](https://github.com/datadrivenconstruction/OpenConstructionEstimate-DDC-CWICR)\n- **Qdrant Documentation:** [qdrant.tech/documentation](https://qdrant.tech/documentation/)\n- **Gemini API:** [aistudio.google.com](https://aistudio.google.com/)\n- **n8n Telegram Trigger:** [docs.n8n.io](https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.telegramtrigger/)\n\n---\n\n⭐ **Star us on GitHub!** [github.com/datadrivenconstruction/DDC-CWICR](https://github.com/datadrivenconstruction/OpenConstructionEstimate-DDC-CWICR)","workflow":{"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}]]}}},"lastUpdatedBy":1,"workflowInfo":{"nodeCount":107,"nodeTypes":{"n8n-nodes-base.if":{"count":4},"n8n-nodes-base.set":{"count":1},"n8n-nodes-base.code":{"count":44},"n8n-nodes-base.switch":{"count":1},"n8n-nodes-base.telegram":{"count":3},"n8n-nodes-base.stickyNote":{"count":16},"n8n-nodes-base.httpRequest":{"count":35},"n8n-nodes-base.splitInBatches":{"count":2},"n8n-nodes-base.telegramTrigger":{"count":1}}},"status":"published","readyToDemo":null,"user":{"name":"Artem Boiko","username":"datadrivenconstruction","bio":"Founder DataDrivenConstruction.io | AEC Tech Consultant & Automation Expert | Bridging Software and Construction","verified":true,"links":["https://datadrivenconstruction.io/"],"avatar":"https://gravatar.com/avatar/96a88b84c9f49338945054d2393a04a29e434a2b60a8937de78e6ef9a6305b5f?r=pg&d=retro&size=200"},"nodes":[{"id":19,"icon":"file:httprequest.svg","name":"n8n-nodes-base.httpRequest","codex":{"data":{"alias":["API","Request","URL","Build","cURL"],"resources":{"generic":[{"url":"https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/","icon":"☀️","label":"2021: The Year to Automate the New You with n8n"},{"url":"https://n8n.io/blog/why-business-process-automation-with-n8n-can-change-your-daily-life/","icon":"🧬","label":"Why business process automation with n8n can change your daily life"},{"url":"https://n8n.io/blog/automatically-pulling-and-visualizing-data-with-n8n/","icon":"📈","label":"Automatically pulling and visualizing data with n8n"},{"url":"https://n8n.io/blog/learn-how-to-automatically-cross-post-your-content-with-n8n/","icon":"✍️","label":"Learn how to automatically cross-post your content with n8n"},{"url":"https://n8n.io/blog/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n/","icon":"🧾","label":"Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n"},{"url":"https://n8n.io/blog/running-n8n-on-ships-an-interview-with-maranics/","icon":"🛳","label":"Running n8n on ships: An interview with Maranics"},{"url":"https://n8n.io/blog/what-are-apis-how-to-use-them-with-no-code/","icon":" 🪢","label":"What are APIs and how to use them with no code"},{"url":"https://n8n.io/blog/5-tasks-you-can-automate-with-notion-api/","icon":"⚡️","label":"5 tasks you can automate with the new Notion API "},{"url":"https://n8n.io/blog/world-poetry-day-workflow/","icon":"📜","label":"Celebrating World Poetry Day with a daily poem in Telegram"},{"url":"https://n8n.io/blog/automate-google-apps-for-productivity/","icon":"💡","label":"15 Google apps you can combine and automate to increase productivity"},{"url":"https://n8n.io/blog/automate-designs-with-bannerbear-and-n8n/","icon":"🎨","label":"Automate Designs with Bannerbear and n8n"},{"url":"https://n8n.io/blog/how-uproc-scraped-a-multi-page-website-with-a-low-code-workflow/","icon":" 🕸️","label":"How uProc scraped a multi-page website with a low-code workflow"},{"url":"https://n8n.io/blog/building-an-expense-tracking-app-in-10-minutes/","icon":"📱","label":"Building an expense tracking app in 10 minutes"},{"url":"https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/","icon":"🤖","label":"5 workflow automations for Mattermost that we love at n8n"},{"url":"https://n8n.io/blog/how-to-use-the-http-request-node-the-swiss-army-knife-for-workflow-automation/","icon":"🧰","label":"How to use the HTTP Request Node - The Swiss Army Knife for Workflow Automation"},{"url":"https://n8n.io/blog/learn-how-to-use-webhooks-with-mattermost-slash-commands/","icon":"🦄","label":"Learn how to use webhooks with Mattermost slash commands"},{"url":"https://n8n.io/blog/how-a-membership-development-manager-automates-his-work-and-investments/","icon":"📈","label":"How a Membership Development Manager automates his work and investments"},{"url":"https://n8n.io/blog/a-low-code-bitcoin-ticker-built-with-questdb-and-n8n-io/","icon":"📈","label":"A low-code bitcoin ticker built with QuestDB and n8n.io"},{"url":"https://n8n.io/blog/how-to-set-up-a-ci-cd-pipeline-with-no-code/","icon":"🎡","label":"How to set up a no-code CI/CD pipeline with GitHub and TravisCI"},{"url":"https://n8n.io/blog/automations-for-activists/","icon":"✨","label":"How Common Knowledge use workflow automation for activism"},{"url":"https://n8n.io/blog/creating-scheduled-text-affirmations-with-n8n/","icon":"🤟","label":"Creating scheduled text affirmations with n8n"},{"url":"https://n8n.io/blog/how-goomer-automated-their-operations-with-over-200-n8n-workflows/","icon":"🛵","label":"How Goomer automated their operations with over 200 n8n workflows"},{"url":"https://n8n.io/blog/aws-workflow-automation/","label":"7 no-code workflow automations for Amazon Web Services"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/"}]},"categories":["Development","Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Helpers"]}}},"group":"[\"output\"]","defaults":{"name":"HTTP Request","color":"#0004F5"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik00MCAyMEM0MCA4Ljk1MzE0IDMxLjA0NjkgMCAyMCAwQzguOTUzMTQgMCAwIDguOTUzMTQgMCAyMEMwIDMxLjA0NjkgOC45NTMxNCA0MCAyMCA0MEMzMS4wNDY5IDQwIDQwIDMxLjA0NjkgNDAgMjBaTTIwIDM2Ljk0NThDMTguODg1MiAzNi45NDU4IDE3LjEzNzggMzUuOTY3IDE1LjQ5OTggMzIuNjk4NUMxNC43OTY0IDMxLjI5MTggMTQuMTk2MSAyOS41NDMxIDEzLjc1MjYgMjcuNjg0N0gyNi4xODk4QzI1LjgwNDUgMjkuNTQwMyAyNS4yMDQ0IDMxLjI5MDEgMjQuNTAwMiAzMi42OTg1QzIyLjg2MjIgMzUuOTY3IDIxLjExNDggMzYuOTQ1OCAyMCAzNi45NDU4Wk0xMi45MDY0IDIwQzEyLjkwNjQgMjEuNjA5NyAxMy4wMDg3IDIzLjE2NCAxMy4yMDAzIDI0LjYzMDVIMjYuNzk5N0MyNi45OTEzIDIzLjE2NCAyNy4wOTM2IDIxLjYwOTcgMjcuMDkzNiAyMEMyNy4wOTM2IDE4LjM5MDMgMjYuOTkxMyAxNi44MzYgMjYuNzk5NyAxNS4zNjk1SDEzLjIwMDNDMTMuMDA4NyAxNi44MzYgMTIuOTA2NCAxOC4zOTAzIDEyLjkwNjQgMjBaTTIwIDMuMDU0MTlDMjEuMTE0OSAzLjA1NDE5IDIyLjg2MjIgNC4wMzA3OCAyNC41MDAxIDcuMzAwMzlDMjUuMjA2NiA4LjcxNDA4IDI1LjgwNzIgMTAuNDA2NyAyNi4xOTIgMTIuMzE1M0gxMy43NTAxQzE0LjE5MzMgMTAuNDA0NyAxNC43OTQyIDguNzEyNTQgMTUuNDk5OCA3LjMwMDY0QzE3LjEzNzcgNC4wMzA4MyAxOC44ODUxIDMuMDU0MTkgMjAgMy4wNTQxOVpNMzAuMTQ3OCAyMEMzMC4xNDc4IDE4LjQwOTkgMzAuMDU0MyAxNi44NjE3IDI5LjgyMjcgMTUuMzY5NUgzNi4zMDQyQzM2LjcyNTIgMTYuODQyIDM2Ljk0NTggMTguMzk2NCAzNi45NDU4IDIwQzM2Ljk0NTggMjEuNjAzNiAzNi43MjUyIDIzLjE1OCAzNi4zMDQyIDI0LjYzMDVIMjkuODIyN0MzMC4wNTQzIDIzLjEzODMgMzAuMTQ3OCAyMS41OTAxIDMwLjE0NzggMjBaTTI2LjI3NjcgNC4yNTUxMkMyNy42MzY1IDYuMzYwMTkgMjguNzExIDkuMTMyIDI5LjM3NzQgMTIuMzE1M0gzNS4xMDQ2QzMzLjI1MTEgOC42NjggMzAuMTA3IDUuNzgzNDYgMjYuMjc2NyA0LjI1NTEyWk0xMC42MjI2IDEyLjMxNTNINC44OTI5M0M2Ljc1MTQ3IDguNjY3ODQgOS44OTM1MSA1Ljc4MzQxIDEzLjcyMzIgNC4yNTUxM0MxMi4zNjM1IDYuMzYwMjEgMTEuMjg5IDkuMTMyMDEgMTAuNjIyNiAxMi4zMTUzWk0zLjA1NDE5IDIwQzMuMDU0MTkgMjEuNjAzIDMuMjc3NDMgMjMuMTU3NSAzLjY5NDg0IDI0LjYzMDVIMTAuMTIxN0M5Ljk0NjE5IDIzLjE0MiA5Ljg1MjIyIDIxLjU5NDMgOS44NTIyMiAyMEM5Ljg1MjIyIDE4LjQwNTcgOS45NDYxOSAxNi44NTggMTAuMTIxNyAxNS4zNjk1SDMuNjk0ODRDMy4yNzc0MyAxNi44NDI1IDMuMDU0MTkgMTguMzk3IDMuMDU0MTkgMjBaTTI2LjI3NjYgMzUuNzQyN0MyNy42MzY1IDMzLjYzOTMgMjguNzExIDMwLjg2OCAyOS4zNzc0IDI3LjY4NDdIMzUuMTA0NkMzMy4yNTEgMzEuMzMyMiAzMC4xMDY4IDM0LjIxNzkgMjYuMjc2NiAzNS43NDI3Wk0xMy43MjM0IDM1Ljc0MjdDOS44OTM2OSAzNC4yMTc5IDYuNzUxNTUgMzEuMzMyNCA0Ljg5MjkzIDI3LjY4NDdIMTAuNjIyNkMxMS4yODkgMzAuODY4IDEyLjM2MzUgMzMuNjM5MyAxMy43MjM0IDM1Ljc0MjdaIiBmaWxsPSIjM0E0MkU5Ii8+Cjwvc3ZnPgo="},"displayName":"HTTP Request","typeVersion":4,"nodeCategories":[{"id":5,"name":"Development"},{"id":9,"name":"Core Nodes"}]},{"id":20,"icon":"fa:map-signs","name":"n8n-nodes-base.if","codex":{"data":{"alias":["Router","Filter","Condition","Logic","Boolean","Branch"],"details":"The IF node can be used to implement binary conditional logic in your workflow. You can set up one-to-many conditions to evaluate each item of data being inputted into the node. That data will either evaluate to TRUE or FALSE and route out of the node accordingly.\n\nThis node has multiple types of conditions: Bool, String, Number, and Date & Time.","resources":{"generic":[{"url":"https://n8n.io/blog/learn-to-automate-your-factorys-incident-reporting-a-step-by-step-guide/","icon":"🏭","label":"Learn to Automate Your Factory's Incident Reporting: A Step by Step Guide"},{"url":"https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/","icon":"☀️","label":"2021: The Year to Automate the New You with n8n"},{"url":"https://n8n.io/blog/why-business-process-automation-with-n8n-can-change-your-daily-life/","icon":"🧬","label":"Why business process automation with n8n can change your daily life"},{"url":"https://n8n.io/blog/create-a-toxic-language-detector-for-telegram/","icon":"🤬","label":"Create a toxic language detector for Telegram in 4 step"},{"url":"https://n8n.io/blog/no-code-ecommerce-workflow-automations/","icon":"store","label":"6 e-commerce workflows to power up your Shopify s"},{"url":"https://n8n.io/blog/how-to-build-a-low-code-self-hosted-url-shortener/","icon":"🔗","label":"How to build a low-code, self-hosted URL shortener in 3 steps"},{"url":"https://n8n.io/blog/automate-your-data-processing-pipeline-in-9-steps-with-n8n/","icon":"⚙️","label":"Automate your data processing pipeline in 9 steps"},{"url":"https://n8n.io/blog/how-to-get-started-with-crm-automation-and-no-code-workflow-ideas/","icon":"👥","label":"How to get started with CRM automation (with 3 no-code workflow ideas"},{"url":"https://n8n.io/blog/5-tasks-you-can-automate-with-notion-api/","icon":"⚡️","label":"5 tasks you can automate with the new Notion API "},{"url":"https://n8n.io/blog/automate-google-apps-for-productivity/","icon":"💡","label":"15 Google apps you can combine and automate to increase productivity"},{"url":"https://n8n.io/blog/automation-for-maintainers-of-open-source-projects/","icon":"🏷️","label":"How to automatically manage contributions to open-source projects"},{"url":"https://n8n.io/blog/how-uproc-scraped-a-multi-page-website-with-a-low-code-workflow/","icon":" 🕸️","label":"How uProc scraped a multi-page website with a low-code workflow"},{"url":"https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/","icon":"🤖","label":"5 workflow automations for Mattermost that we love at n8n"},{"url":"https://n8n.io/blog/why-this-product-manager-loves-workflow-automation-with-n8n/","icon":"🧠","label":"Why this Product Manager loves workflow automation with n8n"},{"url":"https://n8n.io/blog/sending-automated-congratulations-with-google-sheets-twilio-and-n8n/","icon":"🙌","label":"Sending Automated Congratulations with Google Sheets, Twilio, and n8n "},{"url":"https://n8n.io/blog/how-to-set-up-a-ci-cd-pipeline-with-no-code/","icon":"🎡","label":"How to set up a no-code CI/CD pipeline with GitHub and TravisCI"},{"url":"https://n8n.io/blog/benefits-of-automation-and-n8n-an-interview-with-hubspots-hugh-durkin/","icon":"🎖","label":"Benefits of automation and n8n: An interview with HubSpot's Hugh Durkin"},{"url":"https://n8n.io/blog/aws-workflow-automation/","label":"7 no-code workflow automations for Amazon Web Services"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.if/"}]},"categories":["Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Flow"]}}},"group":"[\"transform\"]","defaults":{"name":"If","color":"#408000"},"iconData":{"icon":"map-signs","type":"icon"},"displayName":"If","typeVersion":2,"nodeCategories":[{"id":9,"name":"Core Nodes"}]},{"id":38,"icon":"fa:pen","name":"n8n-nodes-base.set","codex":{"data":{"alias":["Set","JS","JSON","Filter","Transform","Map"],"resources":{"generic":[{"url":"https://n8n.io/blog/learn-to-automate-your-factorys-incident-reporting-a-step-by-step-guide/","icon":"🏭","label":"Learn to Automate Your Factory's Incident Reporting: A Step by Step Guide"},{"url":"https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/","icon":"☀️","label":"2021: The Year to Automate the New You with n8n"},{"url":"https://n8n.io/blog/automatically-pulling-and-visualizing-data-with-n8n/","icon":"📈","label":"Automatically pulling and visualizing data with n8n"},{"url":"https://n8n.io/blog/database-monitoring-and-alerting-with-n8n/","icon":"📡","label":"Database Monitoring and Alerting with n8n"},{"url":"https://n8n.io/blog/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n/","icon":"🧾","label":"Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n"},{"url":"https://n8n.io/blog/no-code-ecommerce-workflow-automations/","icon":"store","label":"6 e-commerce workflows to power up your Shopify s"},{"url":"https://n8n.io/blog/how-to-build-a-low-code-self-hosted-url-shortener/","icon":"🔗","label":"How to build a low-code, self-hosted URL shortener in 3 steps"},{"url":"https://n8n.io/blog/automate-your-data-processing-pipeline-in-9-steps-with-n8n/","icon":"⚙️","label":"Automate your data processing pipeline in 9 steps"},{"url":"https://n8n.io/blog/how-to-get-started-with-crm-automation-and-no-code-workflow-ideas/","icon":"👥","label":"How to get started with CRM automation (with 3 no-code workflow ideas"},{"url":"https://n8n.io/blog/5-tasks-you-can-automate-with-notion-api/","icon":"⚡️","label":"5 tasks you can automate with the new Notion API "},{"url":"https://n8n.io/blog/automate-google-apps-for-productivity/","icon":"💡","label":"15 Google apps you can combine and automate to increase productivity"},{"url":"https://n8n.io/blog/how-uproc-scraped-a-multi-page-website-with-a-low-code-workflow/","icon":" 🕸️","label":"How uProc scraped a multi-page website with a low-code workflow"},{"url":"https://n8n.io/blog/building-an-expense-tracking-app-in-10-minutes/","icon":"📱","label":"Building an expense tracking app in 10 minutes"},{"url":"https://n8n.io/blog/the-ultimate-guide-to-automate-your-video-collaboration-with-whereby-mattermost-and-n8n/","icon":"📹","label":"The ultimate guide to automate your video collaboration with Whereby, Mattermost, and n8n"},{"url":"https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/","icon":"🤖","label":"5 workflow automations for Mattermost that we love at n8n"},{"url":"https://n8n.io/blog/learn-to-build-powerful-api-endpoints-using-webhooks/","icon":"🧰","label":"Learn to Build Powerful API Endpoints Using Webhooks"},{"url":"https://n8n.io/blog/how-a-membership-development-manager-automates-his-work-and-investments/","icon":"📈","label":"How a Membership Development Manager automates his work and investments"},{"url":"https://n8n.io/blog/a-low-code-bitcoin-ticker-built-with-questdb-and-n8n-io/","icon":"📈","label":"A low-code bitcoin ticker built with QuestDB and n8n.io"},{"url":"https://n8n.io/blog/how-to-set-up-a-ci-cd-pipeline-with-no-code/","icon":"🎡","label":"How to set up a no-code CI/CD pipeline with GitHub and TravisCI"},{"url":"https://n8n.io/blog/benefits-of-automation-and-n8n-an-interview-with-hubspots-hugh-durkin/","icon":"🎖","label":"Benefits of automation and n8n: An interview with HubSpot's Hugh Durkin"},{"url":"https://n8n.io/blog/how-goomer-automated-their-operations-with-over-200-n8n-workflows/","icon":"🛵","label":"How Goomer automated their operations with over 200 n8n workflows"},{"url":"https://n8n.io/blog/aws-workflow-automation/","label":"7 no-code workflow automations for Amazon Web Services"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.set/"}]},"categories":["Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Data Transformation"]}}},"group":"[\"input\"]","defaults":{"name":"Edit Fields"},"iconData":{"icon":"pen","type":"icon"},"displayName":"Edit Fields (Set)","typeVersion":3,"nodeCategories":[{"id":9,"name":"Core Nodes"}]},{"id":39,"icon":"fa:sync","name":"n8n-nodes-base.splitInBatches","codex":{"data":{"alias":["Loop","Concatenate","Batch","Split","Split In Batches"],"resources":{"generic":[{"url":"https://n8n.io/blog/how-uproc-scraped-a-multi-page-website-with-a-low-code-workflow/","icon":" 🕸️","label":"How uProc scraped a multi-page website with a low-code workflow"},{"url":"https://n8n.io/blog/benefits-of-automation-and-n8n-an-interview-with-hubspots-hugh-durkin/","icon":"🎖","label":"Benefits of automation and n8n: An interview with HubSpot's Hugh Durkin"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.splitinbatches/"}]},"categories":["Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Flow"]}}},"group":"[\"organization\"]","defaults":{"name":"Loop Over Items","color":"#007755"},"iconData":{"icon":"sync","type":"icon"},"displayName":"Loop Over Items (Split in Batches)","typeVersion":3,"nodeCategories":[{"id":9,"name":"Core Nodes"}]},{"id":49,"icon":"file:telegram.svg","name":"n8n-nodes-base.telegram","codex":{"data":{"alias":["human","form","wait","hitl","approval"],"resources":{"generic":[{"url":"https://n8n.io/blog/why-business-process-automation-with-n8n-can-change-your-daily-life/","icon":"🧬","label":"Why business process automation with n8n can change your daily life"},{"url":"https://n8n.io/blog/create-a-toxic-language-detector-for-telegram/","icon":"🤬","label":"Create a toxic language detector for Telegram in 4 step"},{"url":"https://n8n.io/blog/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n/","icon":"🧾","label":"Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n"},{"url":"https://n8n.io/blog/no-code-ecommerce-workflow-automations/","icon":"store","label":"6 e-commerce workflows to power up your Shopify s"},{"url":"https://n8n.io/blog/world-poetry-day-workflow/","icon":"📜","label":"Celebrating World Poetry Day with a daily poem in Telegram"},{"url":"https://n8n.io/blog/using-automation-to-boost-productivity-in-the-workplace/","icon":"💪","label":"Using Automation to Boost Productivity in the Workplace"},{"url":"https://n8n.io/blog/how-to-set-up-a-ci-cd-pipeline-with-no-code/","icon":"🎡","label":"How to set up a no-code CI/CD pipeline with GitHub and TravisCI"},{"url":"https://n8n.io/blog/creating-scheduled-text-affirmations-with-n8n/","icon":"🤟","label":"Creating scheduled text affirmations with n8n"},{"url":"https://n8n.io/blog/creating-telegram-bots-with-n8n-a-no-code-platform/","icon":"💬","label":"Creating Telegram Bots with n8n, a No-Code Platform"},{"url":"https://n8n.io/blog/aws-workflow-automation/","label":"7 no-code workflow automations for Amazon Web Services"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.telegram/"}],"credentialDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/credentials/telegram/"}]},"categories":["Communication","HITL"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"HITL":["Human in the Loop"]}}},"group":"[\"output\"]","defaults":{"name":"Telegram"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB2aWV3Qm94PSIwIDAgNjYgNjYiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBmaWxsLXJ1bGU9Im5vbnplcm8iIHN0cm9rZT0ibm9uZSI+PHBhdGggZmlsbD0iIzM3YWVlMiIgZD0iTTAgMzJjMCAxNy42NzMgMTQuMzI3IDMyIDMyIDMyczMyLTE0LjMyNyAzMi0zMlM0OS42NzMgMCAzMiAwIDAgMTQuMzI3IDAgMzIiLz48cGF0aCBmaWxsPSIjYzhkYWVhIiBkPSJtMjEuNjYxIDM0LjMzOCAzLjc5NyAxMC41MDhzLjQ3NS45ODMuOTgzLjk4MyA4LjA2OC03Ljg2NCA4LjA2OC03Ljg2NGw4LjQwNy0xNi4yMzctMjEuMTE5IDkuODk4eiIvPjxwYXRoIGZpbGw9IiNhOWM2ZDgiIGQ9Im0yNi42OTUgMzcuMDM0LS43MjkgNy43NDZzLS4zMDUgMi4zNzMgMi4wNjggMGw0LjY0NC00LjIwMyIvPjxwYXRoIGQ9Im0yMS43MyAzNC43MTItNy44MDktMi41NDVzLS45MzItLjM3OC0uNjMzLTEuMjM3Yy4wNjItLjE3Ny4xODYtLjMyOC41NTktLjU4OCAxLjczMS0xLjIwNiAzMi4wMjgtMTIuMDk2IDMyLjAyOC0xMi4wOTZzLjg1Ni0uMjg4IDEuMzYxLS4wOTdjLjIzMS4wODguMzc4LjE4Ny41MDMuNTQ4LjA0NS4xMzIuMDcxLjQxMS4wNjguNjg5LS4wMDMuMjAxLS4wMjcuMzg2LS4wNDUuNjc4LS4xODQgMi45NzgtNS43MDYgMjUuMTk4LTUuNzA2IDI1LjE5OHMtLjMzIDEuMy0xLjUxNCAxLjM0NWMtLjQzMi4wMTYtLjk1Ni0uMDcxLTEuNTgyLS42MS0yLjMyMy0xLjk5OC0xMC4zNTItNy4zOTQtMTIuMTI2LTguNThhLjM0LjM0IDAgMCAxLS4xNDYtLjIzOWMtLjAyNS0uMTI1LjEwOC0uMjguMTA4LS4yOHMxMy45OC0xMi40MjcgMTQuMzUyLTEzLjczMWMuMDI5LS4xMDEtLjA3OS0uMTUxLS4yMjYtLjEwNy0uOTI5LjM0Mi0xNy4wMjUgMTAuNTA2LTE4LjgwMSAxMS42MjktLjEwNC4wNjYtLjM5NS4wMjMtLjM5NS4wMjMiLz48L2c+PC9zeW1ib2w+PC9zdmc+"},"displayName":"Telegram","typeVersion":1,"nodeCategories":[{"id":6,"name":"Communication"},{"id":28,"name":"HITL"}]},{"id":50,"icon":"file:telegram.svg","name":"n8n-nodes-base.telegramTrigger","codex":{"data":{"resources":{"generic":[{"url":"https://n8n.io/blog/create-a-toxic-language-detector-for-telegram/","icon":"🤬","label":"Create a toxic language detector for Telegram in 4 step"},{"url":"https://n8n.io/blog/automatically-adding-expense-receipts-to-google-sheets-with-telegram-mindee-twilio-and-n8n/","icon":"🧾","label":"Automatically Adding Expense Receipts to Google Sheets with Telegram, Mindee, Twilio, and n8n"},{"url":"https://n8n.io/blog/how-to-set-up-a-ci-cd-pipeline-with-no-code/","icon":"🎡","label":"How to set up a no-code CI/CD pipeline with GitHub and TravisCI"},{"url":"https://n8n.io/blog/creating-telegram-bots-with-n8n-a-no-code-platform/","icon":"💬","label":"Creating Telegram Bots with n8n, a No-Code Platform"},{"url":"https://n8n.io/blog/aws-workflow-automation/","label":"7 no-code workflow automations for Amazon Web Services"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.telegramtrigger/"}],"credentialDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/credentials/telegram/"}]},"categories":["Communication"],"nodeVersion":"1.0","codexVersion":"1.0"}},"group":"[\"trigger\"]","defaults":{"name":"Telegram Trigger"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiB2aWV3Qm94PSIwIDAgNjYgNjYiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBmaWxsLXJ1bGU9Im5vbnplcm8iIHN0cm9rZT0ibm9uZSI+PHBhdGggZmlsbD0iIzM3YWVlMiIgZD0iTTAgMzJjMCAxNy42NzMgMTQuMzI3IDMyIDMyIDMyczMyLTE0LjMyNyAzMi0zMlM0OS42NzMgMCAzMiAwIDAgMTQuMzI3IDAgMzIiLz48cGF0aCBmaWxsPSIjYzhkYWVhIiBkPSJtMjEuNjYxIDM0LjMzOCAzLjc5NyAxMC41MDhzLjQ3NS45ODMuOTgzLjk4MyA4LjA2OC03Ljg2NCA4LjA2OC03Ljg2NGw4LjQwNy0xNi4yMzctMjEuMTE5IDkuODk4eiIvPjxwYXRoIGZpbGw9IiNhOWM2ZDgiIGQ9Im0yNi42OTUgMzcuMDM0LS43MjkgNy43NDZzLS4zMDUgMi4zNzMgMi4wNjggMGw0LjY0NC00LjIwMyIvPjxwYXRoIGQ9Im0yMS43MyAzNC43MTItNy44MDktMi41NDVzLS45MzItLjM3OC0uNjMzLTEuMjM3Yy4wNjItLjE3Ny4xODYtLjMyOC41NTktLjU4OCAxLjczMS0xLjIwNiAzMi4wMjgtMTIuMDk2IDMyLjAyOC0xMi4wOTZzLjg1Ni0uMjg4IDEuMzYxLS4wOTdjLjIzMS4wODguMzc4LjE4Ny41MDMuNTQ4LjA0NS4xMzIuMDcxLjQxMS4wNjguNjg5LS4wMDMuMjAxLS4wMjcuMzg2LS4wNDUuNjc4LS4xODQgMi45NzgtNS43MDYgMjUuMTk4LTUuNzA2IDI1LjE5OHMtLjMzIDEuMy0xLjUxNCAxLjM0NWMtLjQzMi4wMTYtLjk1Ni0uMDcxLTEuNTgyLS42MS0yLjMyMy0xLjk5OC0xMC4zNTItNy4zOTQtMTIuMTI2LTguNThhLjM0LjM0IDAgMCAxLS4xNDYtLjIzOWMtLjAyNS0uMTI1LjEwOC0uMjguMTA4LS4yOHMxMy45OC0xMi40MjcgMTQuMzUyLTEzLjczMWMuMDI5LS4xMDEtLjA3OS0uMTUxLS4yMjYtLjEwNy0uOTI5LjM0Mi0xNy4wMjUgMTAuNTA2LTE4LjgwMSAxMS42MjktLjEwNC4wNjYtLjM5NS4wMjMtLjM5NS4wMjMiLz48L2c+PC9zeW1ib2w+PC9zdmc+"},"displayName":"Telegram Trigger","typeVersion":1,"nodeCategories":[{"id":6,"name":"Communication"}]},{"id":112,"icon":"fa:map-signs","name":"n8n-nodes-base.switch","codex":{"data":{"alias":["Router","If","Path","Filter","Condition","Logic","Branch","Case"],"resources":{"generic":[{"url":"https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/","icon":"☀️","label":"2021: The Year to Automate the New You with n8n"},{"url":"https://n8n.io/blog/how-to-get-started-with-crm-automation-and-no-code-workflow-ideas/","icon":"👥","label":"How to get started with CRM automation (with 3 no-code workflow ideas"},{"url":"https://n8n.io/blog/build-your-own-virtual-assistant-with-n8n-a-step-by-step-guide/","icon":"👦","label":"Build your own virtual assistant with n8n: A step by step guide"},{"url":"https://n8n.io/blog/automation-for-maintainers-of-open-source-projects/","icon":"🏷️","label":"How to automatically manage contributions to open-source projects"}],"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.switch/"}]},"categories":["Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Flow"]}}},"group":"[\"transform\"]","defaults":{"name":"Switch","color":"#506000"},"iconData":{"icon":"map-signs","type":"icon"},"displayName":"Switch","typeVersion":3,"nodeCategories":[{"id":9,"name":"Core Nodes"}]},{"id":565,"icon":"fa:sticky-note","name":"n8n-nodes-base.stickyNote","codex":{"data":{"alias":["Comments","Notes","Sticky"],"categories":["Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Helpers"]}}},"group":"[\"input\"]","defaults":{"name":"Sticky Note","color":"#FFD233"},"iconData":{"icon":"sticky-note","type":"icon"},"displayName":"Sticky Note","typeVersion":1,"nodeCategories":[{"id":9,"name":"Core Nodes"}]},{"id":834,"icon":"file:code.svg","name":"n8n-nodes-base.code","codex":{"data":{"alias":["cpde","Javascript","JS","Python","Script","Custom Code","Function"],"details":"The Code node allows you to execute JavaScript in your workflow.","resources":{"primaryDocumentation":[{"url":"https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code/"}]},"categories":["Development","Core Nodes"],"nodeVersion":"1.0","codexVersion":"1.0","subcategories":{"Core Nodes":["Helpers","Data Transformation"]}}},"group":"[\"transform\"]","defaults":{"name":"Code"},"iconData":{"type":"file","fileBuffer":"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTcxXzQ0MSkiPgo8cGF0aCBkPSJNMTcwLjI4MyA0OEgxOTYuNUMyMDMuMTI3IDQ4IDIwOC41IDQyLjYyNzQgMjA4LjUgMzZWMTJDMjA4LjUgNS4zNzI1OCAyMDMuMTI3IDAgMTk2LjUgMEgxNzAuMjgzQzEyNi4xIDAgOTAuMjgzIDM1LjgxNzIgOTAuMjgzIDgwVjE3NkM5MC4yODMgMjA2LjkyOCA2NS4yMTA5IDIzMiAzNC4yODMgMjMySDIzQzE2LjM3MjYgMjMyIDExIDIzNy4zNzIgMTEgMjQ0VjI2OEMxMSAyNzQuNjI3IDE2LjM3MjQgMjgwIDIyLjk5OTYgMjgwTDM0LjI4MyAyODBDNjUuMjEwOSAyODAgOTAuMjgzIDMwNS4wNzIgOTAuMjgzIDMzNlY0NDBDOTAuMjgzIDQ3OS43NjQgMTIyLjUxOCA1MTIgMTYyLjI4MyA1MTJIMTk2LjVDMjAzLjEyNyA1MTIgMjA4LjUgNTA2LjYyNyAyMDguNSA1MDBWNDc2QzIwOC41IDQ2OS4zNzMgMjAzLjEyNyA0NjQgMTk2LjUgNDY0SDE2Mi4yODNDMTQ5LjAyOCA0NjQgMTM4LjI4MyA0NTMuMjU1IDEzOC4yODMgNDQwVjMzNkMxMzguMjgzIDMwOS4wMjIgMTI4LjAxMSAyODQuNDQzIDExMS4xNjQgMjY1Ljk2MUMxMDYuMTA5IDI2MC40MTYgMTA2LjEwOSAyNTEuNTg0IDExMS4xNjQgMjQ2LjAzOUMxMjguMDExIDIyNy41NTcgMTM4LjI4MyAyMDIuOTc4IDEzOC4yODMgMTc2VjgwQzEzOC4yODMgNjIuMzI2OSAxNTIuNjEgNDggMTcwLjI4MyA0OFoiIGZpbGw9IiNGRjk5MjIiLz4KPHBhdGggZD0iTTMwNSAzNkMzMDUgNDIuNjI3NCAzMTAuMzczIDQ4IDMxNyA0OEgzNDIuOTc5QzM2MC42NTIgNDggMzc0Ljk3OCA2Mi4zMjY5IDM3NC45NzggODBWMTc2QzM3NC45NzggMjAyLjk3OCAzODUuMjUxIDIyNy41NTcgNDAyLjA5OCAyNDYuMDM5QzQwNy4xNTMgMjUxLjU4NCA0MDcuMTUzIDI2MC40MTYgNDAyLjA5OCAyNjUuOTYxQzM4NS4yNTEgMjg0LjQ0MyAzNzQuOTc4IDMwOS4wMjIgMzc0Ljk3OCAzMzZWNDMyQzM3NC45NzggNDQ5LjY3MyAzNjAuNjUyIDQ2NCAzNDIuOTc5IDQ2NEgzMTdDMzEwLjM3MyA0NjQgMzA1IDQ2OS4zNzMgMzA1IDQ3NlY1MDBDMzA1IDUwNi42MjcgMzEwLjM3MyA1MTIgMzE3IDUxMkgzNDIuOTc5QzM4Ny4xNjEgNTEyIDQyMi45NzggNDc2LjE4MyA0MjIuOTc4IDQzMlYzMzZDNDIyLjk3OCAzMDUuMDcyIDQ0OC4wNTEgMjgwIDQ3OC45NzkgMjgwSDQ5MEM0OTYuNjI3IDI4MCA1MDIgMjc0LjYyOCA1MDIgMjY4VjI0NEM1MDIgMjM3LjM3MyA0OTYuNjI4IDIzMiA0OTAgMjMyTDQ3OC45NzkgMjMyQzQ0OC4wNTEgMjMyIDQyMi45NzggMjA2LjkyOCA0MjIuOTc4IDE3NlY4MEM0MjIuOTc4IDM1LjgxNzIgMzg3LjE2MSAwIDM0Mi45NzkgMEgzMTdDMzEwLjM3MyAwIDMwNSA1LjM3MjU4IDMwNSAxMlYzNloiIGZpbGw9IiNGRjk5MjIiLz4KPC9nPgo8ZGVmcz4KPGNsaXBQYXRoIGlkPSJjbGlwMF8xMTcxXzQ0MSI+CjxyZWN0IHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo="},"displayName":"Code","typeVersion":2,"nodeCategories":[{"id":5,"name":"Development"},{"id":9,"name":"Core Nodes"}]}],"categories":[{"id":35,"name":"Document Extraction"},{"id":48,"name":"AI RAG"}],"image":[]}}