{
  "name": "Проект 14 — Nordwaren VoiceDesk · полностью локальный голос (Full local)",
  "nodes": [
    {
      "parameters": {
        "content": "## ПРОЕКТ 14 — Nordwaren VoiceDesk\n**Голосовой ассистент поддержки — GDPR-first гибрид, локальный по умолчанию.**\nСтратегия cloud-first → local-ready реализована: STT (whisper.cpp), синтез речи (Piper), модель (LM Studio gemma-4-12b), каталог/журнал/заказы (Postgres) и FAQ-поиск (Qdrant + локальные эмбеддинги) — всё на Mac Dennis по Tailscale. Облако (OpenAI/Google Sheets/ElevenLabs) остаётся только как отключаемый запасной путь на случай отказа локального сервиса.\n\nКреды: Telegram (канал), Postgres/Qdrant/LM Studio/Piper/whisper.cpp (локальные, через Tailscale-креду), ElevenLabs (только резерв).\n\n⚠️ Это боевой граф: часть нод отключена сознательно (история миграции/резервные пути) — прежде чем удалять «лишнее», сверься с их notes и MEMORY.md.\n\n**Зоны на канве:** ЭТАП 1–4 — сквозной поток приёма/понимания/данных/ответа; три дополнительные зоны сбоку — МАСТЕР ЗАКАЗА (пошаговое оформление), ЭСКАЛАЦИЯ (передача оператору), ОЗВУЧКА (Piper основной / ElevenLabs запасной, после S-2).",
        "height": 496,
        "width": 708,
        "color": 7
      },
      "id": "a113e0a9-a7b7-4a1d-90a4-32d5af3ef043",
      "name": "LEGEND",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1024,
        -288
      ]
    },
    {
      "parameters": {
        "updates": [
          "message"
        ],
        "additionalFields": {}
      },
      "id": "67f318c2-fe92-4f08-910b-1a77ff6b07b4",
      "name": "1. Telegram Trigger (voice)",
      "type": "n8n-nodes-base.telegramTrigger",
      "typeVersion": 1.1,
      "position": [
        -544,
        368
      ],
      "credentials": {},
      "notes": "Точка входа графа: любое входящее сообщение боту (голос или текст). Выход → 2 (индикатор набора) и 3 (проверка голос/текст) параллельно."
    },
    {
      "parameters": {
        "operation": "sendChatAction",
        "chatId": "={{ $json.message.chat.id }}",
        "action": "record_audio"
      },
      "id": "8db4d10f-b5c6-492c-ac38-dc0fb208622a",
      "name": "2. Send Chat Action (record_audio)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        -336,
        176
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Служебный индикатор «записываю голосовое» в Telegram — на данные не влияет, ошибку не блокирует (onError continueRegularOutput). Параллельная ветка от ноды 1."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "cond-voice-exists",
              "leftValue": "={{ $json.message.voice }}",
              "rightValue": "",
              "operator": {
                "type": "object",
                "operation": "exists",
                "singleValue": true
              }
            },
            {
              "id": "cond-voice-duration",
              "leftValue": "={{ $json.message.voice.duration }}",
              "rightValue": 1,
              "operator": {
                "type": "number",
                "operation": "gte"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "30a94a4a-ede7-46e1-8534-069b456e7a1c",
      "name": "3. IF voice present?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -176,
        368
      ],
      "notes": "Развилка голос/текст. true (есть voice, duration≥1) → 4 (скачать аудио). false (текст или пустое аудио) → E2a — обработка текста при активном мастере заказа/confirm-этапе."
    },
    {
      "parameters": {
        "resource": "file",
        "fileId": "={{ $('1. Telegram Trigger (voice)').item.json.message.voice.file_id }}",
        "additionalFields": {}
      },
      "id": "b1b4dd5d-b1e3-455a-937e-15b78db3c455",
      "name": "4. Telegram Get File (audio)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        -16,
        176
      ],
      "credentials": {},
      "onError": "continueErrorOutput",
      "notes": "Скачивает файл голосового сообщения по file_id. Успех → STT (5b выключен + 5c основной, параллельно). Ошибка (onError continueErrorOutput) → R (просьба повторить)."
    },
    {
      "parameters": {
        "content": "### STT [LOCAL]\nОсновной путь: whisper.cpp (large-v3-turbo, self-hosted на Mac Dennis, Metal + Neural Engine) через HTTP к своему STT-серверу — нода 5c, без hint языка (автоопределение, клиенты пишут на RU/DE/UK). Аудио и транскрипт не покидают инфраструктуру компании (GDPR, [PII]).\nОблачный резерв (OpenAI Whisper, нода 5) ОТКЛЮЧЁН и отсоединён от боевого пути (решение S-2, 15.08.2026) — при сбое локального STT клиент честно слышит «повторите», а не молча уходит в облако. Нода 5b (ElevenLabs STT) тоже отключена, не используется.",
        "height": 328,
        "width": 492,
        "color": 7
      },
      "id": "5850ce81-aa9a-44b8-a63c-5a63fbef4421",
      "name": "note STT",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        544,
        -592
      ]
    },
    {
      "parameters": {
        "resource": "audio",
        "operation": "transcribe",
        "options": {}
      },
      "id": "992926dd-c66e-4a83-b53f-b16731efcc5b",
      "name": "5. STT — Whisper (auto)  [SWAP→LOCAL]",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.8,
      "position": [
        720,
        -48
      ],
      "credentials": {},
      "disabled": true,
      "onError": "continueErrorOutput",
      "notes": "ЗАПАСНОЕ распознавание (облако OpenAI). Работает только когда локальный whisper.cpp недоступен\n(Mac выключен или сервис упал) — вход идёт с error-выхода ноды 5c.\n\n⚠️ Пока эта ветка работает, аудио клиента уходит в облако — учитывать в GDPR-контуре.\n\nБез hint языка (автоопределение).\n`prompt` — словарь предметной области: смещает распознавание к терминам магазина.\nПовод (прогон 836): «Wie sind die Lieferbedingungen?» услышано как «Wir sind die Lieferbedienenden»."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "cond-len",
              "leftValue": "={{ ($json.text || '').trim().length }}",
              "rightValue": 2,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            },
            {
              "id": "cond-no-cjk",
              "leftValue": "={{ $json.text }}",
              "rightValue": "[぀-ヿ㐀-䶿一-鿿가-힯]",
              "operator": {
                "type": "string",
                "operation": "notRegex"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "20df2c74-b510-4be0-a607-3952e71c1bc8",
      "name": "6. IF transcript valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1152,
        240
      ],
      "notes": "Проверка транскрипта: непустой и без иероглифов-галлюцинаций (whisper на тишине/шуме). true → 6b (guardrails入). false → R (повторить)."
    },
    {
      "parameters": {
        "content": "### Guardrails\nПроверка тем (Topical Alignment) и jailbreak ДО классификатора. Нарушение → ветка вежливого отказа (none). n8n Guardrails node или Code+LLM-проверка.",
        "height": 208,
        "width": 300,
        "color": 7
      },
      "id": "715b3046-2b30-4e02-9073-b6a069008e6c",
      "name": "note Guardrails IN",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1056,
        -592
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "={{ $json.guardrailsInput || $json.text }}",
        "hasOutputParser": true,
        "messages": {
          "messageValues": [
            {
              "message": "Ты — классификатор намерений голосовой поддержки Nordwaren GmbH (магазин: товары для дома, электроника, канцелярия).\nОпредели категорию запроса клиента строго по схеме.\n\nintent:\n— stock — всё о товарах на складе: наличие, остаток, количество, а также АССОРТИМЕНТ — какие есть варианты, модели, объёмы, цвета, размеры («Какие варианты чайников у вас есть?» — это stock, не none);\n— faq — доставка, оплата, оформление, самовывоз, возврат, условия работы;\n— complaint — жалоба, претензия, недовольство качеством или сервисом, угроза пожаловаться. Это НЕ оффтоп — передавай оператору, не отказывай;\n— none — всё остальное: оффтоп, болтовня, оскорбления, просьбы не по теме.\n\n⚠Вопрос о товаре магазина — это всегда stock или faq, НИКОГДА не none.\nЗамок для самопроверки: если ты заполнил slots.product, intent НЕ МОЖЕТ быть none.\nПовод (прогон 892): «Welche Varianten von Wasserkochen haben Sie?» получил none при заполненном slots.product — живому клиенту отказали в ответе на законный вопрос о товаре.\n\nconfidence: high | mid | low.\n\nlang: язык, на котором говорит клиент — код ISO 639-1 из двух букв (ru, uk, de, en, pl, tr…).\nОпределяй язык по словам и грамматике, а НЕ по алфавиту: украинский и русский оба кириллические, но это разные языки («чи», «будь ласка», «замовлення» — это uk).\nТекст приходит из распознавания речи и может содержать искажения — определяй язык по общему впечатлению.\n\n⚠️ ЯЗЫК СЛУЖЕБНЫХ ПОЛЕЙ — РУССКИЙ, независимо от языка клиента.\nКаталог товаров и справочник FAQ ведутся по-русски, и поиск идёт по ним.\n\nquery_norm: короткая нормализованная формулировка запроса по-русски.\nslots.product: название товара по-русски, в единственном числе и именительном падеже, как оно стояло бы в каталоге: «электрочайник», «настольная лампа». НЕ переводи на немецкий и не ставь дефисы.\nslots.variant и slots.color: только если клиент назвал их явно (объём, цвет). Не догадывайся и не выдумывай.\n\nЕсли клиент спрашивает по-немецки — всё равно заполняй поля по-русски: это внутренние ключи для поиска, клиент их не видит.\n\nТебе могут быть доступны предыдущие реплики этого же клиента (история диалога, до 10 последних). Если текущая реплика неполная и понятна только в контексте предыдущего вопроса («а чёрные есть?», «а подешевле?») — восстанови slots.product и query_norm по истории. Если истории нет или она не относится к вопросу — не догадывайся, опирайся только на текущую реплику."
            }
          ]
        }
      },
      "id": "af99af7f-cc31-4315-b69c-2f1f502fce09",
      "name": "7. Классификатор (Basic LLM Chain)",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.5,
      "position": [
        1568,
        -64
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 1000,
      "onError": "continueRegularOutput",
      "notes": "LLM со structured output: intent/confidence/lang/query_norm/slots из текста запроса. Модель — под-нода Chat Model classifier, парсер — Structured Output Parser, память — Memory (10 реплик). Вход — 6b (после guardrails). Выход → 8."
    },
    {
      "parameters": {
        "content": "### Chat Model [LOCAL]\nLM Studio, модель gemma-4-12b-it-mlx, локально на Mac Dennis — одна модель обслуживает классификатор (нода 7), guardrails (6b) и голосовой стиль ответа (14) через три под-ноды Chat Model.\nStructured output — через Structured Output Parser с JSON Schema; при сбое парсера — keyword-fallback в коде ноды 8 (не повторный запрос к модели).\nТест-данные (каталог, журнал, FAQ) — в Postgres и Qdrant локально, см. зоны «СКЛАД» и «ЭТАП 3».",
        "height": 440,
        "width": 300,
        "color": 7
      },
      "id": "0426ad23-82ce-4f58-80a6-b4842a271657",
      "name": "note Chat Model",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        848,
        1376
      ]
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "gemma-4-12b-it-mlx"
        },
        "options": {
          "temperature": 0
        }
      },
      "id": "977c24a2-d89f-48bf-9823-46a35d2a9b9d",
      "name": "Chat Model — classifier (LM Studio, локально)",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.2,
      "position": [
        1088,
        80
      ],
      "credentials": {},
      "notes": "Под-нода LLM (ai_languageModel), локальная модель через LM Studio — используется классификатором 7, парсером и guardrails 6b."
    },
    {
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"intent\": {\n      \"type\": \"string\",\n      \"enum\": [\"stock\", \"faq\", \"complaint\", \"none\"],\n      \"description\": \"stock — наличие/остаток/количество товара; faq — доставка, оплата, оформление, самовывоз, возврат; none — всё остальное\"\n    },\n    \"confidence\": {\n      \"type\": \"string\",\n      \"enum\": [\"high\", \"mid\", \"low\"]\n    },\n    \"lang\": {\n      \"type\": \"string\",\n      \"description\": \"Язык обращения клиента — код ISO 639-1 из двух букв в нижнем регистре: ru, uk, de, en, pl, tr и т.д. Определять по самому тексту, а не по алфавиту: украинский и русский пишутся кириллицей, но это разные языки\"\n    },\n    \"query_norm\": {\n      \"type\": \"string\",\n      \"description\": \"Короткая нормализованная формулировка запроса по-русски\"\n    },\n    \"slots\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"product\": { \"type\": \"string\" },\n        \"variant\": { \"type\": \"string\" },\n        \"color\": { \"type\": \"string\" }\n      },\n      \"description\": \"Заполнять только тем, что прямо сказано клиентом, по-русски. Не догадываться.\"\n    }\n  },\n  \"required\": [\"intent\", \"confidence\", \"lang\"]\n}",
        "autoFix": true,
        "customizeRetryPrompt": true,
        "prompt": "Инструкции:\n--------------\n{instructions}\n--------------\nОтвет модели:\n--------------\n{completion}\n--------------\nОтвет не соответствует схеме. Ошибка:\n--------------\n{error}\n--------------\nИсправь ответ. Верни ТОЛЬКО валидный JSON по схеме, без пояснений и без обрамления кодом. intent — строго одно из: stock, faq, complaint, none. confidence — строго одно из: high, mid, low."
      },
      "id": "a71dce96-3785-4afd-93fe-eac1d68ca192",
      "name": "Structured Output Parser",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.2,
      "position": [
        1632,
        128
      ],
      "notes": "Парсер structured output для классификатора 7 — принуждает модель отвечать строго по JSON-схеме (intent/confidence/lang/query_norm/slots)."
    },
    {
      "parameters": {
        "jsCode": "// Нормализация вывода классификатора + запасной путь по ключевым словам (план §5.3).\n//\n// Почему fallback здесь, а не отдельной нодой на error-выходе:\n// при ошибке под-ноды (парсера) цепочка отдаёт элемент в ОСНОВНОЙ выход,\n// а не в error-выход — проверено на прогонах 840/841.\nconst raw = $input.first().json;\nconst out = raw.output || raw;\n\n// Что сказал клиент: из своего входа (у ошибочного элемента это поле text), иначе ссылкой на STT\nlet sourceText = String(raw.source_text || out.source_text || raw.text || '');\n// Транскрипт приходит либо от локального STT, либо от облачного запасного —\n// спрашиваем оба по очереди. Ссылка на одну ноду ломается при свапе движка:\n// неработающая сквозная ссылка отдаёт пустоту без ошибки (прогон 917).\nif (!sourceText) {\n  for (const n of ['5c. STT — whisper.cpp (локально, Tailscale)', '5. STT — Whisper (auto)  [SWAP→LOCAL]']) {\n    try {\n      const t = String($(n).first().json.text || '');\n      if (t) { sourceText = t; break; }\n    } catch (e) {}\n  }\n}\n\nlet intent = String(out.intent || '').toLowerCase().trim();\nlet conf = String(out.confidence || '').toLowerCase().trim();\nlet fallback = false;\n\nif (!['stock', 'faq', 'complaint', 'none'].includes(intent)) {\n  // Классификатор не дал пригодного ответа — грубая подстраховка по ключевым словам.\n  const t = sourceText.toLowerCase();\n  const has = (words) => words.some(w => t.includes(w));\n\n  // Три языка обслуживания — значит и запасной путь должен знать все три.\n  // Без украинских слов этот рубеж был для украинского слеп: при отказе классификатора\n  // законный вопрос про товар уходил в оффтоп (прогон 882).\n  const COMPLAINT = ['жалоб', 'жаловат', 'недовольн', 'возмущ', 'претензи', 'верните деньги',\n                    'жаліт', 'скарг',\n                    'beschwerde', 'beschweren', 'unzufrieden', 'reklamation'];\n  const STOCK = ['сколько', 'есть ли', 'в наличии', 'наличи', 'остат', 'склад',\n                 'чи є', 'наявн', 'залиш', 'скільки',\n                 'verfügbar', 'vorrätig', 'bestand', 'lager', 'wie viele'];\n  const FAQ = ['доставк', 'оплат', 'оплач', 'самовывоз', 'забрать', 'возврат', 'счёт', 'счет',\n               'самовивіз', 'поверненн', 'рахунок', 'забрати', 'терміни',\n               'lieferbeding', 'lieferung', 'liefer', 'versand', 'zahlung', 'bezahl',\n               'abholung', 'abholen', 'rückgabe', 'rechnung'];\n\n  intent = has(COMPLAINT) ? 'complaint' : (has(STOCK) ? 'stock' : (has(FAQ) ? 'faq' : 'none'));\n  conf = 'low';\n  fallback = true;\n}\n\nif (!['high', 'mid', 'low'].includes(conf)) conf = 'low';\n\n// Язык обращения определяет модель (код ISO 639-1), а не алфавит\nlet lang = String(out.lang || '').toLowerCase().trim().slice(0, 2);\nif (!/^[a-z]{2}$/.test(lang)) lang = '';\n\n// --- КОНТЕКСТ ДИАЛОГА (14.08.2026) ------------------------------------------\n// Почему ведём его САМИ, а не под-нодой памяти:\n// подключённая memoryBufferWindow до цепочки НЕ ДОЕЗЖАЕТ: валидация её принимает,\n// ошибок нет, эффекта ноль — три реплики подряд (прогоны 1004→1005→1006) классификатор\n// каждый раз начинал с чистого листа. Замер моделей показал, что с историей справляется\n// даже текущая 12B — значит дело не в модели, а в доставке контекста.\n// Здесь он детерминирован, виден в данных прогона и не зависит от под-нод.\nconst CTX_TTL_MIN = 30;   // старше — это уже новый разговор, наследовать нельзя\n\nlet chatId = 'unknown';\ntry { chatId = String($('1. Telegram Trigger (voice)').first().json.message.chat.id || 'unknown'); } catch (e) {}\n\nconst slots = out.slots || {};\nlet product = String(slots.product || '').trim();\nlet queryNorm = String(out.query_norm || sourceText);\nlet inherited = false;\n\n// Разговорные синонимы объёма (15.08.2026, разбор прогона 1411): клиент говорит «компакт»/«побольше»,\n// а не литрами — литеральной цифры в тексте нет, поэтому обычный цифровой матчинг (ниже) не сработает.\n// Ключевое слово «компакт» буквально совпадает с пометкой в названии каталога (1.0л (компакт)) —\n// используем это же слово как разговорный маркер объёма.\nconst VARIANT_SYNONYMS = [\n  { re: /компакт|мини(?!атюр)|маленьк/i, variant: '1.0' },\n  { re: /семейн|вместительн|больш(ой|ая|е|им)/i, variant: '1.7' },\n];\nlet colloquialVariant = '';\nfor (const { re, variant } of VARIANT_SYNONYMS) {\n  if (re.test(sourceText)) { colloquialVariant = variant; break; }\n}\n\n// Анафоры-обобщения («товар», «их», «this», «es»…) — это отсутствие продукта, а не сам продукт.\n// Повод (прогон 1030): «Вы уверены, что у вас их 113 штук?» → product: \"товар\" →\n// наследование не срабатывало, потому что product был НЕПУСТОЙ. Само обобщённое слово —\n// уже маркер продолжения, отдельного совпадения с FOLLOWUP не требуем.\nconst GENERIC = ['товар', 'товары', 'это', 'этот', 'они', 'их', 'он', 'она', 'оно',\n                  'це', 'він', 'вона', 'воно',\n                  'produkt', 'artikel', 'ware', 'es', 'sie', 'dieser'];\nconst productIsGeneric = GENERIC.includes(product.toLowerCase());\nif (productIsGeneric) product = '';\n\n// Маркеры продолжения разговора на трёх языках обслуживания.\n// Без них наследовать нельзя: «расскажите анекдот» после вопроса о чайниках\n// не должно превращаться в вопрос о чайниках.\nconst FOLLOWUP = /какие|какой|вариант|подешевле|подороже|а есть|еще|ещё|другой|другие|мне нужен|мне нужна|возьму|давайте|які|який|варіант|ще|інший|welche|welcher|andere|billiger|günstiger|noch/i;\n\n// Голое подтверждение («да», «хорошо», «ок») отвечает на вопрос АССИСТЕНТА (обычно —\n// его же приглашение оформить заказ), а не открывает новую тему. Без этого маркера\n// intent уходил в 'none' и разговор ломался прямо на закрытии сделки\n// (прогон 1055: «Да, мне будет удобно» после «Вам удобно оформить заказ?» → оффтоп-отказ).\n// \\b в JS-regex опирается на \\w = [A-Za-z0-9_] и кириллицу вообще не видит — с ним «да\\b» никогда не\n// срабатывает на русском тексте (проверено node -e при внедрении). Граница слова —\n// отрицательный lookahead на следующую букву, включая кириллицу.\n// Whisper на односложном звуке гадает и язык, и письмо: прогон 1102 — голосовое «Да.»\n// пришло как «- Da.» латиницей с lang: pl → маркер ждал кириллицу и начало строки,\n// не совпал — сделка сорвалась оффтоп-отказом на немецком. Поэтому: (1) ведущая\n// пунктуация срезается, (2) латинские подтверждения принимаются, но «da» — ТОЛЬКО\n// одиночным словом: в немецком «Da bin ich nicht sicher» начинается с «Da»,\n// и длинная фраза с него — не согласие.\nconst confirmProbe = sourceText.trim().replace(/^[\\s\\-–—.,!?…]+/, '');\n// E8/А2 (14.08, разбор прогона 1139): два уровня. STRONG — однозначные слова\n// согласия, срабатывают начало фразы, как и раньше. WEAK — вводные/многозначные\n// слова («так», «хорошо», «ok»…) считаются подтверждением ТОЛЬКО если они — ВСЯ реплика\n// (≤ 2 слова), иначе ложно срабатывают вводное слово в начале вопроса (прогон 1139: «Так, от\n// какой суммы доставка бесплатна?» — intent faq правильный, но CONFIRM ложно сработал на «так»).\nconst STRONG_CONFIRM = /^(да|ага|угу|согласен|согласна|згоден|згодна|подтверждаю|оформляйте|ja|yes)(?![а-яёіїєa-z])/i;\nconst WEAK_CONFIRM = /^(так|хорошо|ок|окей|добре|гаразд|da|ok|okay|gut|einverstanden|passt)[\\s.!?…]*$/i;\nconst confirmWordCount = confirmProbe.split(/\\s+/).filter(Boolean).length;\nconst isWeakConfirm = confirmWordCount <= 2 && WEAK_CONFIRM.test(confirmProbe);\n// Гварды: вопросительная реплика, intent faq или «да нет» вместе — не подтверждение.\n// \\b опирается на \\w = [A-Za-z0-9_] и кириллицу не видит (та же грабля, что и в CONFIRM выше) —\n// границы слова через lookahead/lookbehind, проверено node -e на «Да нет, не надо» до применения.\nconst hasQuestionMark = sourceText.includes('?');\nconst hasDaWord = /(?<![а-яёіїєa-z])да(?![а-яёіїєa-z])/i.test(sourceText);\nconst hasNetWord = /(?<![а-яёіїєa-z])нет(?![а-яёіїєa-z])/i.test(sourceText);\nconst generalGuardBlocked = hasQuestionMark || (hasDaWord && hasNetWord);\nconst confirmGuardBlocked = intent === 'faq' || generalGuardBlocked;\nconst isConfirm = !confirmGuardBlocked && (STRONG_CONFIRM.test(confirmProbe) || isWeakConfirm);\n// E9-CONFIRM-фикс (разбор серии 1208–1236): естественные фразы подтверждения типа\n// «Давайте оформим, да» / «Оформим.» / «Белый чайник хочу заказать» не ловились\n// STRONG/WEAK выше и часто классифицировались как faq/none (сам промпт классификатора относит\n// «оформление» к faq) — в бою А2 давал 5 промахов подряд, прежде чем Dennis случайно попал\n// в голое «Да.», которое распознаётся. По решению оркестратора: ORDER_CONFIRM обходит faq-гвард\n// (он бессмыслен именно вне контекста подтверждения), но НЕ обходит вопросительный/«да нет»\n// гварды («Как оформить заказ?» — это вопрос, не подтверждение) и применяется только\n// внутри orderConfirmed (ниже) — там уже есть гвоздь prev.awaitingConfirm, вне этого окна те же\n// слова могут быть обычным FAQ-вопросом про оформление.\nconst ORDER_CONFIRM = /(оформ|заказат|заказыва|бер[уё]|возьм|покупа|купит|bestell|kaufen|nehme)/i;\nconst orderIntentConfirm = !generalGuardBlocked && ORDER_CONFIRM.test(sourceText);\n\nconst store = $getWorkflowStaticData('global');\nstore.dialogCtx = store.dialogCtx || {};\nconst prev = store.dialogCtx[chatId];\nconst fresh = prev && prev.product && (Date.now() - (prev.ts || 0)) < CTX_TTL_MIN * 60000;\n\n// Язык односложной реплики — из контекста разговора, не из мгновенной детекции:\n// одно слово не меняет язык разговора (прогон 1102: «Да.» → pl/de → ответ немецким).\nconst isShortUtterance = confirmProbe.split(/\\s+/).filter(Boolean).length <= 1;\n// Фикс 18.08 (прогон 1846): хотфикс выше был слишком широким — он гасил ЛЮБУЮ смену\n// языка на короткой реплике, и русское «Увидимся.» в украинском контексте получало\n// ответ по-украински. Подмену из контекста оставляем ТОЛЬКО для заведомо неоднозначных\n// междометий согласия/отказа/отмены: у них нет собственных языковых признаков, и именно\n// на них whisper гадает язык (прогон 1102: «Да.» → pl). Слово со своей морфологией\n// («Увидимся», «Дякую», «Danke») язык разговора определяет само — доверяем детекции STT.\nconst AMBIGUOUS_SHORT = /^(да|ага|угу|так|хорошо|ок|окей|добре|гаразд|згоден|згодна|согласен|согласна|подтверждаю|оформляйте|нет|не|отмена|отменить|da|ja|yes|ok|okay|gut|passt|einverstanden|nein|no|cancel|abbrechen)$/i;\nconst isAmbiguousShort = AMBIGUOUS_SHORT.test(confirmProbe.replace(/[\\s.!?,…]+$/g, '').toLowerCase());\nlet langSource = 'detect';\nif (isShortUtterance && isAmbiguousShort && fresh && prev.lang) { lang = prev.lang; langSource = 'ctx'; }\n\n// Наследуем товар только когда его нет в текущей реплике И видно, что это продолжение:\n// либо клиент уточнил цвет/вариант, либо фраза явно ссылается на предыдущее.\nif (!product && fresh && (slots.color || slots.variant || FOLLOWUP.test(sourceText) || productIsGeneric || isConfirm || colloquialVariant)) {\n  product = prev.product;\n  inherited = true;\n  queryNorm = `${prev.product}: ${queryNorm}`;\n  // Замок из промпта, применённый кодом: если товар известен, это не оффтоп.\n  if (intent === 'none') { intent = 'stock'; conf = 'mid'; }\n}\n\n// Фикс 18.08 (прогон 1853 — главный обрыв сделки): «Оформите мне белый чайник на\n// 1,7 литра» — классификатор по своему промпту относит ЛЮБОЕ «оформление заказа» к FAQ,\n// и клиент вместо товара получал справку «как оформить заказ» — сделка рвалась на самом\n// важном шаге. ORDER_CONFIRM выше обходит faq-гвард ТОЛЬКО внутри окна awaitingConfirm,\n// а здесь окна не было: предыдущая реплика вернула ambiguous, кнопка не выставлялась.\n// Правило: глагол намерения купить + КОНКРЕТНЫЙ товар (названный или унаследованный)\n// = склад, а не справочник. Дальше 10b сам решит, однозначен товар или нужно уточнение.\n// Гварды уже внутри orderIntentConfirm: «Как оформить заказ?» содержит «?» → остаётся faq.\n// Жалобу не перехватываем: «хочу оформить возврат» должно идти в эскалацию, а не в каталог.\nif (product && orderIntentConfirm && intent !== 'complaint') {\n  intent = 'stock';\n  if (conf === 'low') conf = 'mid';\n}\n\n// «Да» подтверждает ЗАКАЗ, только если предыдущая реплика ассистента предлагала ОДНОЗНАЧНЫЙ\n// товар (флаг awaitingConfirm — нода 10b). Без привязки к этому флагу любое голое «да» в\n// разговоре ложно читалось бы как согласие купить. Запрос Dennis 14.08: подтверждение должно\n// завершать сделку (эскалация оператору в ноде 13), а не повторно уходить в поиск по складу.\nconst orderConfirmed = !!(prev && prev.awaitingConfirm && (isConfirm || orderIntentConfirm));\n// Детали товара для уведомления оператору (запрос Dennis 14.08, прогон 1071:\n// в тексте эскалации был только транскрипт «Да.», оператор не знал, что заказано).\n// Берём из prev ДО сброса флага — они туда положились в тот же момент, что и awaitingConfirm (нода 10b).\nlet confirmedOrder = null;\nif (orderConfirmed) {\n  confirmedOrder = {\n    name: prev.confirmName || prev.product || '',\n    color: prev.confirmColor || '',\n    price: prev.confirmPrice != null ? prev.confirmPrice : null\n  };\n  try { store.dialogCtx[chatId].awaitingConfirm = false; } catch (e) {}\n}\n\n// E10 (кнопка подтверждения заказа, решение Dennis): отказ на предложение оформить заказ должен\n// закрывать окно ожидания так же, как и подтверждение, но без оффтоп-отказа и без эскалации.\n// Гейт тот же prev.awaitingConfirm — вне этого окна «нет» работает как раньше (не decline).\nconst DECLINE_CONFIRM = /^(нет|не надо|не нужно|спасибо,? нет|nein|no)(?![а-яёіїєa-z])/i;\n// Confirm-этап переиспользует кнопку мастера «Otmenitь zakaz» (решение Dennis) — отдельный алиас, т.к. «отменить» не проходит через lookahead выше (буква после корня «отмен»).\nconst CANCEL_CONFIRM = /^(отмена|отменить|cancel|abbrechen)/i;\nconst declineOrder = !orderConfirmed && !!(prev && prev.awaitingConfirm) && !hasQuestionMark && (DECLINE_CONFIRM.test(confirmProbe) || CANCEL_CONFIRM.test(confirmProbe));\nif (declineOrder) {\n  try { store.dialogCtx[chatId].awaitingConfirm = false; } catch (e) {}\n}\n\n// Накапливаем НЕ ТОЛЬКО товар, но и уточнения (цвет, объём).\n// Повод (прогоны 1024→1025→1023): клиент сказал «белый на 1,7 литра», на следующей\n// реплике унаследовался только товар — и ассистент снова предлагал все 5 вариантов\n// и снова спрашивал «какой именно?». Разговор шёл по кругу, клиент не мог купить.\n// Классификатор иногда подмешивает цвет/объём из ИСТОРИИ диалога в свежий, более широкий\n// вопрос, где клиент их не называл. Повод (прогон 1050): «Сколько у вас чайников?» —\n// без единого слова о цвете — вернулось slots.color: \"белый\", slots.variant: \"1,7 литра\"\n// из предыдущей реплики — ответ сузился до одного варианта вместо всех пяти (и «самый\n// дешёвый?» в 1052 ответил ценой не того чайника). Доверяем только тому, что реально\n// произнесено в ЭТОЙ реплике: цвет и цифры объёма должны буквально встречаться в sourceText.\nconst textLower = sourceText.toLowerCase();\n// ё/е схлопываем: классификатор возвращает «черный», клиент говорит «чёрный» (прогон 1852) —\n// без нормализации анти-галлюцинационный гвард ниже гасил законный цвет.\nconst deyo = (s) => String(s == null ? '' : s).toLowerCase().replace(/ё/g, 'е');\nconst textNorm = deyo(sourceText);\nlet curColor = String(slots.color || '').trim();\n// МУЛЬТИТОВАР (18.08, прогоны 1851/1852): на «белый и ещё один чёрный» классификатор отдаёт\n// slots.color одной строкой «белый, чёрный». Дословно такая строка в тексте не встречается —\n// гвард гасил цвет ЦЕЛИКОМ, 10b не мог сузить, ambiguous держался вечно, awaitingConfirm не\n// выставлялся, кнопка заказа не появлялась. Клиент три реплики подряд слышал «какой именно?»\n// и не мог купить. Корзину не строим (см. роадмап): берём ПЕРВЫЙ названный товар, остальные\n// отдаём в extra_colors — нода 10b честно скажет, что их оформим следующим заказом.\nlet extraColors = [];\nif (curColor && /[,;]|\\bи\\b|\\bта\\b|\\bund\\b|\\band\\b/i.test(curColor)) {\n  const parts = curColor.split(/\\s*(?:[,;]|\\bи\\b|\\bта\\b|\\bund\\b|\\band\\b)\\s*/i).map(s => s.trim()).filter(Boolean);\n  const present = parts.filter(p => textNorm.includes(deyo(p)));\n  if (present.length) { curColor = present[0]; extraColors = present.slice(1); }\n}\nif (curColor && !textNorm.includes(deyo(curColor))) curColor = '';\nlet curVariant = String(slots.variant || '').trim();\nif (curVariant) {\n  const vDigits = (curVariant.match(/\\d/g) || []).join('');\n  if (vDigits && !textLower.replace(/[.,]/g, '').includes(vDigits)) curVariant = '';\n}\n// Фикс 15/16.08 (прогоны 1442): классификатор кладёт словесный синоним («компактный») в slots.variant НЕ пустым — старая проверка «!curVariant» никогда не срабатывала, а 10b фильтрует только по цифрам — словесный вариант без цифр ей бесполезен, даже если классификатор его вернул. Приоритет: colloquialVariant, если в curVariant нет своих цифр.\nif (colloquialVariant && !/\\d/.test(curVariant)) curVariant = colloquialVariant;\nlet color = curColor;\nlet variant = curVariant;\n\n// Явное повторение ШИРОКОГО вопроса о том же товаре («Сколько у вас чайников?»\n// после того как уже сузили до одного варианта) — сигнал сбросить сужение, а не сохранить его.\n// Решение оркестратора 14.08 (по прогонам 1050/1052): узкий цвет/объём, молча переживший\n// в новом широком вопросе, давал неверный ответ («самый дешёвый» считался от одного\n// варианта вместо пяти). Признаки сброса: товар назван СЕЙЧАС явно (не унаследован),\n// в реплике нет цвета/объёма, и вопрос широкий. Эллиптические уточнения типа «а чёрный\n// есть?» товар не называют — под сброс не попадают, наследуют как раньше.\nconst productNamedNow = !!String(slots.product || '').trim() && !productIsGeneric;\nconst BROAD = /сколько|какие|вариант|есть ли|скільки|які|wie viele|welche/i;\nconst isResetQuestion = productNamedNow && !curColor && !curVariant && BROAD.test(sourceText);\n\n// Уточнения переносим только пока речь об ОДНОМ и том же товаре:\n// сменил товар — старый цвет и объём больше не значат ничего.\nconst sameProduct = fresh && product && String(prev.product || '') === product;\nif (sameProduct && !isResetQuestion) {\n  if (!color) color = String(prev.color || '');\n  if (!variant) variant = String(prev.variant || '');\n}\n\nif (product) store.dialogCtx[chatId] = { product, color, variant, lang: lang || (prev && prev.lang) || '', ts: Date.now() };\n\nreturn [{ json: {\n  intent,\n  confidence: conf,\n  query_norm: queryNorm,\n  slots: Object.assign({}, slots,\n    product ? { product } : {},\n    color ? { color } : {},\n    variant ? { variant } : {}),\n  source_text: sourceText,\n  lang,\n  lang_source: langSource,\n  fallback,\n  ctx_inherited: inherited,\n  ctx_slots: { product, color, variant },\n  extra_colors: extraColors,\n  orderConfirmed,\n  confirmedOrder,\n  declineOrder\n}, pairedItem: { item: 0 } }];"
      },
      "id": "61501d9e-afc6-44fd-8f99-89a1ed871076",
      "name": "8. Нормализация intent + keyword-fallback",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1856,
        -64
      ],
      "notes": "Код: чистит вывод классификатора, keyword-fallback при сбое парсера, ведёт контекст диалога (наследование товара/цвета/объёма между репликами, разговорные синонимы объёма), детектирует подтверждение/отказ/отмену заказа на confirm-этапе. Вход — 7. Выход → 9."
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "stock",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              }
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "faq",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              }
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "none",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              }
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "complaint",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              }
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "79a8429b-525a-48ae-a46b-925fd298f2fd",
      "name": "9. Switch по intent",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        2112,
        208
      ],
      "notes": "Маршрутизация по intent из ноды 8: stock → 10a (каталог), faq → 11a (Qdrant), none/default → 12 (оффтоп-отказ), complaint → 12b (жалоба)."
    },
    {
      "parameters": {
        "content": "## Ветка «СКЛАД» [LOCAL]  (R3 / Критерий 2)\nPostgres (таблица catalog, локально на Mac Dennis, нода 10a) → Code-агрегация (10b): взвешенный стемминг, синонимы материала/объёма («стеклянный»→стекло, «компакт»→1.0л), кросс-сейл аксессуара.\nМногострочные товары (чайник в 5 вариантах) → суммируем и даћм разбивку, echo-back как страховка. Нет товара → честно «нет», без выдуманных цифр.\nGoogle Sheets (старая нода 10a) — отключена и отсоединена, оставлена как история миграции (этап C, 15.08.2026).",
        "height": 264,
        "width": 652,
        "color": 7
      },
      "id": "b24bdf3e-aff2-4cf9-8735-69e39026b149",
      "name": "note Склад",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2496,
        -32
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Товары"
        },
        "options": {}
      },
      "id": "570cd657-e381-4c65-9c35-515aa183e678",
      "name": "10a. Google Sheets — каталог остатков (read)",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2800,
        -304
      ],
      "alwaysOutputData": true,
      "credentials": {},
      "disabled": true,
      "onError": "continueRegularOutput",
      "notes": "УСТАРЕЛО, отключено (disabled). Прежний источник каталога до перехода на Postgres (этап C). Оставлено как запасной путь на демо, в боевой цепочке не участвует."
    },
    {
      "parameters": {
        "jsCode": "// Ветка «Склад»: сопоставление запроса с каталогом, агрегация, echo-back (план §5.4).\n\n// --- что искал клиент\nlet product = '', color = '', variant = '', sourceText = '', chatId = 'unknown', extraColors = [];\ntry {\n  const n = $('8. Нормализация intent + keyword-fallback').first().json;\n  product = String((n.slots && n.slots.product) || '');\n  color = String((n.slots && n.slots.color) || '');\n  variant = String((n.slots && n.slots.variant) || '');\n  sourceText = String(n.source_text || '');\n  extraColors = Array.isArray(n.extra_colors) ? n.extra_colors : [];\n  if (!product) product = String(n.query_norm || '');\n} catch (e) {}\nif (!product) product = sourceText;\ntry { chatId = String($('1. Telegram Trigger (voice)').first().json.message.chat.id || 'unknown'); } catch (e) {}\n\n// Флаг «сделка на пороге закрытия» (запрос Dennis 14.08): по умолчанию снят на КАЖДОЙ реплике —\n// выставляется в true ниже только для однозначно найденного товара. Нода 8 на следующей\n// реплике читает его, чтобы отличить «да» = подтверждение заказа от «да» просто в разговоре.\ntry {\n  const s0 = $getWorkflowStaticData('global');\n  s0.dialogCtx = s0.dialogCtx || {};\n  if (s0.dialogCtx[chatId]) s0.dialogCtx[chatId].awaitingConfirm = false;\n} catch (e) {}\n\nconst items = $input.all().map(i => i.json);\n\n// Сбой самого источника — это НЕ «товара нет». Говорим честно (R6).\nif (items.length && items[0] && items[0].error !== undefined) {\n  return [{ json: { branch: 'stock', found: false, source_error: true, source_text: sourceText,\n    answer_facts: 'Сейчас не получается открыть каталог остатков — техническая неполадка на нашей стороне. Повторите вопрос немного позже.' }, pairedItem: { item: 0 } }];\n}\n\nconst norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/ё/g, 'е')\n  .replace(/[^a-zа-я0-9 ]+/gi, ' ').replace(/\\s+/g, ' ').trim();\n\nconst STOP = ['сколько','осталось','есть','вас','наличии','наличие','склад','складе',\n              'штук','мне','нужен','нужна','какие','какой','это','пожалуйста','подскажите',\n              // Предлоги (18.08.2026): без них мягкий матчинг на «подарок ДЛЯ мамы»\n              // предлагал «Фильтр ДЛЯ чайника» — совпадение по предлогу, а не по смыслу.\n              'для','или','под','над','при','про'];\n\n// Составные слова: «электрочайник» → «электро» + «чайник»\nconst expand = (w) => {\n  for (const p of ['электро', 'мини', 'микро', 'авто']) {\n    if (w.startsWith(p) && w.length > p.length + 3) return [p, w.slice(p.length)];\n  }\n  return [w];\n};\n\n// Грубый стемминг: первые 6 символов — снимает разницу падежей (лампы/лампа),\n// но не схлопывает разные слова с общим коротким началом. Было 4 символа: «компьютер» и\n// «компактный» совпадали по «комп», и клиенту вместо честного «нет в наличии»\n// ответили остатком чайника (прогон 956).\n// Материал/цвет — разговорная форма прилагательного не совпадает по стему с названием в каталоге\n// (15.08.2026, разбор прогона 1408): «стеклянный» режется до «стекля», а в каталоге «стекло» —\n// совпадение по префиксу ломается на declension. Подменяем на канон ДО стемминга.\nconst MATERIAL_SYNONYMS = [\n  { re: /^стеклянн/, canon: 'стекло' },\n  { re: /^прозрачн/, canon: 'стекло' },\n];\n// Фикс 15/16.08 (прогон 1441): классификатор кладёт материал («стеклянный») в slots.variant, НЕ в slots.product —\n// старый цикл по product его никогда не видел (живой прогон отдал все 5 вариантов вместо одного). Сканируем оба поля.\nconst stems = [];\nfor (const w of norm(product + ' ' + variant).split(' ')) {\n  if (!w || w.length < 3 || STOP.includes(w)) continue;\n  let ww = w;\n  for (const syn of MATERIAL_SYNONYMS) if (syn.re.test(w)) { ww = syn.canon; break; }\n  for (const part of expand(ww)) if (part.length >= 4) stems.push(part.slice(0, 6));\n}\n\n// Строки каталога без служебной «Итого» и без пустых артикулов.\n// Последняя строка листа содержит итог 524 шт — в сумму она попасть не должна (AE-11).\nconst rows = items.filter(r => String(r['Артикул'] || '').trim() && norm(r['Наименование товара']) !== 'итого');\n\nconst qty = (r) => Number(String(r['Остаток (шт)'] != null ? r['Остаток (шт)'] : 0).replace(',', '.')) || 0;\nconst priceOf = (r) => Number(String(r['Цена (EUR)'] != null ? r['Цена (EUR)'] : 0).replace(',', '.')) || 0;\nconst nameOf = (r) => String(r['Наименование товара'] || '');\n\n// Совпадение считается только по НАЧАЛУ слова, а не по подстроке где попало внутри.\n// Повод (прогон 947): запрос «столовая ложка» давал основу «стол», а она находилась\n// внутри слова «наСТОЛьная» — и клиенту выдавались остатки ламп как остатки ложек.\n// Самый опасный вид ошибки: не отказ и не сбой, а уверенный ответ с чужими числами.\n// Взвешенный матчинг: совпадение с ПЕРВЫМ словом названия весит 2, с остальными\n// словами названия и категорией — 1. Иначе «Фильтр для чайника» и «Чайник электрический»\n// получали одинаковый score по стему «чайник» (стем матчит начало слова «чайника») —\n// клиенту озвучивали фильтры как чайники (прогоны 1029/1031: «113 чайников» вместо 53).\n// Товар (первое слово = сам продукт) теперь всегда перевешивает аксессуар,\n// а запрос «фильтр» по-прежнему находит фильтр — его первое слово и есть «фильтр».\nlet best = [], bestScore = 0;\nfor (const r of rows) {\n  const nameWords = norm(r['Наименование товара']).split(' ').filter(Boolean);\n  const restWords = nameWords.slice(1).concat(norm(r['Категория']).split(' ').filter(Boolean));\n  const firstWord = nameWords[0] || '';\n  let score = 0;\n  for (const s of stems) {\n    if (firstWord.startsWith(s)) score += 2;\n    else if (restWords.some(w => w.startsWith(s))) score += 1;\n  }\n  if (score > bestScore) { bestScore = score; best = [r]; }\n  else if (score === bestScore && score > 0) best.push(r);\n}\n\nif (!best.length) {\n  // КАТЕГОРИЙНЫЙ ЗАПРОС (18.08.2026, находка Dennis на живом тесте): клиент спрашивает\n  // не про товар, а про КАТЕГОРИЮ или повод: «какие есть кухонные товары», «товары для сада»,\n  // «подарок для мамы». Стемминг по префиксу такое не ловит из-за склонения («кухни» ≠ «Кухня»,\n  // «кухонные» ≠ «Кухня») — и клиент получал глухое «не найдено», хотя в категории «Кухня»\n  // лежат 12 позиций, включая чайники. Синонимы проверены симуляцией на реальных категориях БД (9/9).\n  const catText = norm(product + ' ' + sourceText);\n  const CATEGORY_SYNONYMS = [\n    { re: /кух|готов|посуд|чаепит/, cat: 'кухня' },\n    { re: /дом|уют|интерьер|спальн|плед/, cat: 'дом' },\n    { re: /канцеляр|офис|школ|учеб|записк/, cat: 'канцелярия' },\n    { re: /свет|освещ|светильник|лампоч/, cat: 'освещение' },\n    { re: /спорт|отдых|туризм|поход|пикник/, cat: 'спорт и отдых' },\n  ];\n  const allCats = Array.from(new Set(rows.map(r => String(r['Категория'] || '').trim()).filter(Boolean)));\n  const catHit = CATEGORY_SYNONYMS.find(c => c.re.test(catText));\n  if (catHit) {\n    const inCat = rows.filter(r => norm(r['Категория']) === catHit.cat && qty(r) > 0);\n    if (inCat.length) {\n      const uniq = [];\n      for (const r of inCat) {\n        const nm = nameOf(r);\n        if (!uniq.some(u => u.n === nm)) uniq.push({ n: nm, p: priceOf(r) });\n      }\n      const catName = String(inCat[0]['Категория'] || catHit.cat);\n      return [{ json: { branch: 'stock', found: true, ambiguous: true, category: catName,\n        total: inCat.reduce((s, r) => s + qty(r), 0),\n        variants: uniq.map(u => u.n), accessory: null, source_text: sourceText,\n        answer_facts: `Клиент спросил про КАТЕГОРИЮ, а не про конкретный товар. В категории «${catName}» есть в наличии: ${uniq.map(u => u.n + ' — ' + u.p + ' евро').join('; ')}. Назови эти позиции коротко и живо, без количеств и артикулов, затем спроси, что из этого интересует.` }, pairedItem: { item: 0 } }];\n    }\n  }\n  // Точного совпадения нет — ищем ближайшую замену в наличии по частичному описанию\n  // (запрос Dennis 14.08: не бросать клиента на голом «не найдено», а предложить то, что реально\n  // есть на складе, если оно хоть отдалённо похоже на запрос; прогоны 1057/1058:\n  // «беспроводные наушники»/«маркеры» — плоский отказ без альтернативы.\n  // Матчинг ЗАМЕТНО мягче основного (A1): подстрока в любом месте, стем от 3 символов,\n  // без обрезки до 6 — иначе «маркер» и «наушники», у которых в каталоге вообще нет\n  // пересечения по словам, получили бы случайную «похожую» подсказку. Предлагаем\n  // ТОЛЬКО когда есть реальное текстовое пересечение — выдумывать связи запрещено.\n  const looseStems = [];\n  for (const w of norm(product).split(' ')) {\n    if (!w || w.length < 3 || STOP.includes(w)) continue;\n    for (const part of expand(w)) if (part.length >= 3) looseStems.push(part);\n  }\n  let altBest = [], altScore = 0;\n  for (const r of rows) {\n    const hay = norm(nameOf(r)) + ' ' + norm(r['Категория']);\n    const score = looseStems.filter(s => hay.includes(s)).length;\n    if (score > altScore) { altScore = score; altBest = [r]; }\n    else if (score === altScore && score > 0) altBest.push(r);\n  }\n  if (altBest.length) {\n    const alt = altBest.filter(r => qty(r) > 0).sort((a, b) => qty(b) - qty(a))[0] || altBest[0];\n    return [{ json: { branch: 'stock', found: false, suggested: true, source_text: sourceText,\n      answer_facts: `Товар «${product}» в каталоге Nordwaren не найден. Точных данных об остатке нет. БЛИЖАЙШАЯ ЗАМЕНА ИЗ НАЛИЧИЯ (предложить честно как альтернативу, не как то, что клиент просил): ${nameOf(alt)}${alt['Цвет'] ? ', ' + alt['Цвет'] : ''}, ${priceOf(alt)} евро, в наличии ${qty(alt)} шт.` }, pairedItem: { item: 0 } }];\n  }\n  // Честное «нет» + ориентиры: клиент не должен угадывать, чем торгует магазин (18.08.2026):\n  // на «товары для сада» / «подарок для мамы» говорим «нет» и сразу называем направления каталога.\n  return [{ json: { branch: 'stock', found: false, source_text: sourceText,\n    answer_facts: `Товар «${product}» в каталоге Nordwaren не найден. Точных данных об остатке нет. ЧТО ЕСТЬ В КАТАЛОГЕ (назови эти направления одной фразой, чтобы клиент сориентировался): ${allCats.join(', ')}.` }, pairedItem: { item: 0 } }];\n}\n\n// Сужаем выбор по тому, что клиент уже назвал.\n// Сначала цвет, потом ОБЪЁМ — раньше объём не читался вообще, и на «белый на 1,7 литра»\n// ассистент всả равно переспрашивал «какой именно?» (прогон 1024) — разговор шёл по кругу.\nconst byColor = color ? best.filter(r => norm(r['Цвет']).includes(norm(color))) : [];\nlet picked = byColor.length ? byColor : best;\n\n// Объём сравниваем числом: клиент говорит «1,7 литра», в каталоге «1.7л» —\n// разные записи одного и того же.\nconst vNum = (String(variant).match(/\\d+[.,]?\\d*/) || [''])[0].replace(',', '.');\nif (vNum) {\n  const byVariant = picked.filter(r => String(r['Наименование товара'] || '').replace(',', '.').includes(vNum));\n  if (byVariant.length) picked = byVariant;\n}\n\nconst total = picked.reduce((s, r) => s + qty(r), 0);\n// Цена в разбивке — опора для «самый дешёвый» / «до N евро» (прогоны 1034/1035);\n// в речь по умолчанию модель её не тянет (см. промпт ноды 14), только когда клиент спросит.\nconst variants = picked.map(r => `${r['Наименование товара']}${r['Цвет'] ? ', ' + r['Цвет'] : ''} — ${qty(r)} шт, ${priceOf(r)} евро`);\nconst ambiguous = picked.length > 1;\n// Запрос Dennis 14.08: уведомление оператору при подтвержденном заказе не содержало товар\n// (прогон 1071: только chat_id/имя/тип/транскрипт «Да.»). Сохраняем товар/цвет/цену\n// в тот же момент, когда выставляем awaitingConfirm — нода 8 прочтёт на следующей реплике.\ntry {\n  const s1 = $getWorkflowStaticData('global');\n  if (s1.dialogCtx[chatId]) {\n    s1.dialogCtx[chatId].awaitingConfirm = !ambiguous;\n    if (!ambiguous) {\n      const r0 = picked[0];\n      const newName = r0['Наименование товара'] || '';\n      s1.dialogCtx[chatId].confirmName = newName;\n      s1.dialogCtx[chatId].confirmColor = r0['Цвет'] || '';\n      s1.dialogCtx[chatId].confirmPrice = priceOf(r0);\n\n      // Найдено 15.08 (разбор прогонов 1370–1391, жалоба Dennis «неверная цена в заказе»):\n      // если клиент раньше голосом/кнопкой подтвердил ДРУГОЙ товар и мастер ещё стоит на\n      // шаге 1 (ничего не ответил, TTL 30 мин ещё не истёк) — а сейчас нашлись НОВОЕ однозначное\n      // совпадение для ДРУГОГО товара — старый мастер уже неактуален и тихо ждёт ответа про чужой\n      // товар. Без сброса следующий текст клиента тихо ушёл бы в СТАРЫЙ мастер (прогоны\n      // 1375→1391: клиент обсуждал белый чайник, а в сводке вылез забытый прозрачный за 34.9€).\n      // Сбрасываем ТОЛЬКО нетронутый (step===1) мастер — если клиент уже начал вводить имя/\n      // адрес для прежнего заказа — не трогаем (могут быть легитимные уточнения).\n      try {\n        const wf = s1.orderForm && s1.orderForm[chatId];\n        if (wf && wf.step === 1 && wf.product && wf.product.name && wf.product.name !== newName) {\n          delete s1.orderForm[chatId];\n        }\n      } catch (e) {}\n    }\n  }\n} catch (e) {}\n\n// --- Сопутствующий товар (кросс-сейл, 14.08.2026) ------------------------------\n// Кандидат берётся ТОЛЬКО из этого же каталога — модели запрещено выдумывать товары,\n// а выдуманный «фильтр к чайнику» — это обещание того, чего нет на складе.\n// ❗ Настоящих аксессуаров в каталоге нет (ни фильтров, ни чашек), поэтому логика такая:\n//   1) осмысленная пара по сценарию использования (чайник ↔ термос — оба про горячие напитки);\n//   2) иначе — сосед по категории, самый доступный по цене (не дороже основной покупки).\n// 14.08.2026 в каталог добавлены НАСТОЯЩИЕ аксессуары (ACC-6001–6007) — по одному\n// на каждую категорию, чтобы у любого товара было осмысленное дополнение.\n// До этого аксессуаров не было вообще, и кросс-сейл приходилось строить на соседях\n// по категории («к чайнику тостер») — честно, но слабо как продажа.\nconst AFFINITY = {\n  'чайник': ['фильтр', 'накип', 'чашек'],\n  'термос': ['чехол'],\n  'лампа': ['лампочк'],\n  'плед': ['подушк'],\n  'блокнот': ['стикер', 'ручек'],\n  'ручек': ['блокнот', 'стикер'],\n  'тостер': ['чашек'],\n  'блендер': ['чашек'],\n  'мультиварк': ['чашек'],\n};\n\nconst pickCross = (base) => {\n  const baseName = norm(nameOf(base));\n  const baseCat = norm(base['Категория']);\n  // Предлагаем только то, что реально есть на складе, и не тот же самый товар\n  const stock = rows.filter(r => qty(r) > 0 && norm(nameOf(r)) !== baseName);\n\n  for (const key of Object.keys(AFFINITY)) {\n    if (!baseName.includes(key)) continue;\n    for (const partner of AFFINITY[key]) {\n      const hit = stock.filter(r => norm(nameOf(r)).includes(partner))\n                       .sort((a, b) => qty(b) - qty(a))[0];\n      if (hit) return hit;\n    }\n  }\n\n  const sameCat = stock.filter(r => norm(r['Категория']) === baseCat && priceOf(r) <= priceOf(base));\n  if (!sameCat.length) return null;\n  return sameCat.sort((a, b) => priceOf(a) - priceOf(b))[0];\n};\n\nconst crossText = (c) => c\n  ? ` СОПУТСТВУЮЩЕЕ (предложить ОДНОЙ короткой фразой и только если уместно; не навязывать, при отказе не повторять): ${nameOf(c)}${c['Цвет'] ? ', ' + String(c['Цвет']).toLowerCase() : ''}, ${priceOf(c)} евро, в наличии ${qty(c)} шт.`\n  : '';\n\nlet facts;\nlet crossAccessory = null;\nif (!ambiguous) {\n  const r = picked[0];\n  const cross = pickCross(r);\n  crossAccessory = cross ? { name: nameOf(cross), price: priceOf(cross) } : null;\n  // E11 (сопутствующий товар в заказе): кладём его СТРУКТУРНО (не только текстом в answer_facts для\n  // голоса) — и в dialogCtx для кнопки подтверждения, и в ответ, чтобы его можно было\n  // добавить в заказ одним нажатием кнопки, а не только услышать о нём.\n  try {\n    const s3 = $getWorkflowStaticData('global');\n    if (s3.dialogCtx[chatId]) {\n      s3.dialogCtx[chatId].confirmAccessoryName = crossAccessory ? crossAccessory.name : '';\n      s3.dialogCtx[chatId].confirmAccessoryPrice = crossAccessory ? crossAccessory.price : null;\n    }\n  } catch (e) {}\n  // МУЛЬТИТОВАР (18.08, прогоны 1851/1852): клиент назвал несколько товаров сразу.\n  // Мастер заказа рассчитан на ОДНУ позицию + аксессуар (orderForm.product — один объект),\n  // мультипозиционная корзина — отдельная фича в роадмапе. Здесь честно оформляем первый\n  // товар и вслух говорим про остальные — вместо того чтобы молча их потерять.\n  const multiNote = extraColors.length\n    ? ` ВАЖНО: клиент назвал ещё ${extraColors.length === 1 ? 'один вариант' : 'несколько вариантов'} (${extraColors.join(', ')}). В одном заказе оформляется только одна позиция — скажи об этом честно и коротко: сейчас оформляем этот товар, остальное добавим следующим заказом.`\n    : '';\n  facts = `Товар: ${r['Наименование товара']}${r['Цвет'] ? ', цвет ' + r['Цвет'] : ''}. В наличии ${qty(r)} шт. Цена ${r['Цена (EUR)']} евро.` + multiNote + crossText(cross);\n} else {\n  // Требование Dennis (14.08): перечислять ВСЕ варианты, не выбирать за клиента различие.\n  // Раньше инструкция «озвучивать все варианты» была только в промпте ноды 14 и терялась\n  // при откате правок через UI (v29, 09:04:58) — теперь она закреплена и здесь, в фактах.\n  const clarify = ' Перечисли клиенту ВСЕ варианты из разбивки (модель, цвет, объём — что есть в наличии) одной живой фразой, затем коротко спроси, какой подходит.';\n\n  facts = `Запрос клиента: ${product}. Всего в наличии ${total} шт по ${picked.length} вариантам. Разбивка: ${variants.join('; ')}.` + clarify;\n}\n\nreturn [{ json: { branch: 'stock', found: true, ambiguous, total, variants, accessory: crossAccessory, source_text: sourceText, answer_facts: facts }, pairedItem: { item: 0 } }];"
      },
      "id": "b42d1b08-0462-4c0c-b3bf-f0a787f518d7",
      "name": "10b. Агрегация + echo-back",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3184,
        720
      ],
      "notes": "Код: сопоставление запроса с каталогом (взвешенный стемминг, синонимы материала/объёма, КАТЕГОРИЙНЫЙ ПОИСК с 18.08: «товары для кухни» → позиции категории, неизвестное направление → честное «нет» + перечень направлений каталога), агрегация остатков, кросс-сейл аксессуара, выставление awaitingConfirm для однозначного товара. Вход — 10a. Выход → 13 (ответ) и далее 16→10c (кнопки после голоса)."
    },
    {
      "parameters": {
        "content": "## Ветка «FAQ»  (R4 / Критерий 3)\nRetrieve-first: Qdrant Get Many (top-k) → Code score-gate (абсолютный порог TH=0.55 + относительный GAP=0.05 от лучшего фрагмента; перекалиброван 14.08.2026 под bge-m3, было 0.45 под OpenAI) → пусто = отказ «нет информации». Ответ ТОЛЬКО из найденного фрагмента (анти-галлюцинация).\n[LOCAL] embeddings и Qdrant уже локальные (та же многоязычная модель bge-m3 на ingestion и retrieval, self-hosted).\nⓘ Ingestion FAQ.pdf — отдельный workflow.",
        "height": 386,
        "width": 380,
        "color": 7
      },
      "id": "939d45ee-89a0-4114-a3bc-df0d23e4a190",
      "name": "note FAQ",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3136,
        272
      ]
    },
    {
      "parameters": {
        "mode": "load",
        "qdrantCollection": {
          "__rl": true,
          "mode": "id",
          "value": "nordwaren_faq_local"
        },
        "prompt": "={{ $json.query_norm }}",
        "options": {}
      },
      "id": "43d58cfb-ee2a-4400-aa4f-f60469653c6d",
      "name": "11a. Qdrant — retrieve (Get Many)",
      "type": "@n8n/n8n-nodes-langchain.vectorStoreQdrant",
      "typeVersion": 1.1,
      "position": [
        2704,
        416
      ],
      "alwaysOutputData": true,
      "credentials": {},
      "onError": "continueErrorOutput",
      "notes": "Векторный поиск релевантных фрагментов FAQ (bge-m3 через под-ноду Embeddings). Вход — 9 (intent=faq). Выход (оба output) → 11b."
    },
    {
      "parameters": {
        "model": "text-embedding-bge-m3",
        "options": {
          "encodingFormat": "float"
        }
      },
      "id": "8c75ded3-83ac-4600-8788-96ca0fb66ffc",
      "name": "Embeddings  [SWAP→LOCAL]",
      "type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
      "typeVersion": 1.2,
      "position": [
        2784,
        608
      ],
      "credentials": {},
      "notes": "Под-нода эмбеддингов для векторного поиска 11a (ai_embedding). Локальная модель — часть GDPR-контура (данные FAQ не уходят к внешнему провайдеру эмбеддингов)."
    },
    {
      "parameters": {
        "jsCode": "// Score-gate: отбрасываем нерелевантное, пустой контекст = честный отказ (план §5.5).\n//\n// ПЕРЕКАЛИБРОВАНО 14.08.2026 под эмбеддер bge-m3 (было 0.45 под OpenAI).\n// Старый порог переносить было нельзя: у разных эмбеддеров разная шкала близости.\n// Замер на живой коллекции nordwaren_faq_local (8 вопросов, ru/de/uk):\n//   ответ В СПРАВОЧНИКЕ ЕСТЬ   → 0.636 … 0.836\n//   ответа НЕТ (погода, футбол) → 0.280 … 0.507\n// Разрыв чистый, порог ставим посередине. Относительный отбор сохранён:\n// он спасает, когда два фрагмента идут вплотную и нужный — второй.\nconst TH = 0.55;      // абсолютный минимум: ниже — «вообще не про то»\nconst GAP = 0.05;     // допустимое отставание от лучшего фрагмента\n\nlet sourceText = '';\ntry {\n  sourceText = String($('8. Нормализация intent + keyword-fallback').first().json.source_text || '');\n} catch (e) { sourceText = ''; }\n\nconst items = $input.all().map(i => i.json).filter(Boolean);\n\n// 1) Явный сбой источника — это НЕ «нет основания».\n// Ловим ДВА вида сбоя:\n//   а) элемент с полем error;\n//   б) МОЛЧАЛИВЫЙ — при onError=continueErrorOutput нода Qdrant отдаёт на error-выход\n//      ИСХОДНЫЙ элемент БЕЗ поля error. Прогон 982 (14.08.2026): сервер Qdrant лежал,\n//      нода упала с «fetch failed», а клиент услышал «такой информации нет» — то есть враньё\n//      вместо честного признания сбоя, причём про доставку, которая в справочнике ЕСТЬ.\n//      Такой элемент узнаётся по полям классификатора: у фрагмента справочника их нет.\nconst srcFailed = items.length && items[0] && (\n  items[0].error !== undefined ||\n  (items[0].query_norm !== undefined && items[0].intent !== undefined)\n);\nif (srcFailed) {\n  return [{ json: { branch: 'faq', grounded: false, source_error: true, source_text: sourceText,\n    answer_facts: 'Сейчас не получается свериться со справочником — техническая неполадка на нашей стороне. Пожалуйста, повторите вопрос позже или напишите в поддержку.' }, pairedItem: { item: 0 } }];\n}\n\n// Текст фрагмента может лежать на верхнем уровне или внутри document — берём оба случая\nconst textOf = (d) => String(\n  (d && d.pageContent) ||\n  (d && d.document && d.document.pageContent) ||\n  (d && d.text) || ''\n).trim();\n\nconst scoreOf = (d) => {\n  if (d && typeof d.score === 'number') return d.score;\n  if (d && d.metadata && typeof d.metadata.score === 'number') return d.metadata.score;\n  return 1; // скора нет — не отбрасываем фрагмент молча\n};\n\nconst withText = items.filter(d => textOf(d));\n\n// 2) Ничего не нашлось / нода вернула пустоту — всё равно отвечаем голосом (R6)\nif (!withText.length) {\n  return [{ json: { branch: 'faq', grounded: false, source_text: sourceText,\n    answer_facts: `Вопрос клиента: ${sourceText}\\n\\nВ справочнике Nordwaren нет фрагмента, отвечающего на этот вопрос. Надо вежливо сказать, что такой информации нет, и предложить обратиться в поддержку. Отвечать из общих знаний нельзя.` }, pairedItem: { item: 0 } }];\n}\n\n// 3) Отсекаем нерелевантное по score\nconst maxScore = Math.max(...withText.map(scoreOf));\nconst docs = withText.filter(d => scoreOf(d) >= TH && scoreOf(d) >= maxScore - GAP);\n\nif (!docs.length) {\n  return [{ json: { branch: 'faq', grounded: false, source_text: sourceText,\n    scores: withText.map(scoreOf),\n    answer_facts: `Вопрос клиента: ${sourceText}\\n\\nНайденные фрагменты справочника недостаточно близки к вопросу. Надо вежливо сказать, что такой информации нет. Отвечать из общих знаний нельзя.` }, pairedItem: { item: 0 } }];\n}\n\nconst context = docs.map(textOf).join('\\n---\\n');\n\nreturn [{ json: {\n  branch: 'faq',\n  grounded: true,\n  chunks: docs.length,\n  scores: docs.map(scoreOf),\n  source_text: sourceText,\n  context,\n  answer_facts: `Вопрос клиента: ${sourceText}\\n\\nФрагменты справочника Nordwaren — единственный допустимый источник ответа:\\n${context}`\n}, pairedItem: { item: 0 } }];"
      },
      "id": "95cc74f5-c6b4-4891-a1f9-e8886ef80fc1",
      "name": "11b. Score-gate + контекст",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3168,
        992
      ],
      "notes": "Код: порог релевантности (score-gate) для найденных FAQ-фрагментов — при недостаточном score честный отказ «нет информации» вместо выдумки. Вход — 11a. Выход → 13."
    },
    {
      "parameters": {
        "jsCode": "// Вежливый голосовой отказ: оффтоп (intent=none, R8a) или сработавшие гуардрейлы.\n// Клиент не должен слышать разницы, но в журнале она должна оставаться.\nconst j = $input.first().json;\n\n// У гуардрейл-ноды текст лежит в guardrailsInput, у ветки оффтопа — в source_text\nlet sourceText = String(j.source_text || j.guardrailsInput || j.text || '');\nif (!sourceText) {\n  try { sourceText = String($('8. Нормализация intent + keyword-fallback').first().json.source_text || ''); } catch (e) {}\n}\nif (!sourceText) {\n  for (const n of ['5c. STT — whisper.cpp (локально, Tailscale)', '5. STT — Whisper (auto)  [SWAP→LOCAL]']) {\n    try {\n      const t = String($(n).first().json.text || '');\n      if (t) { sourceText = t; break; }\n    } catch (e) {}\n  }\n}\n\n// Какой контроль сработал и с какой уверенностью — нужно для разбора инцидентов\nlet guardrail = '';\ntry {\n  const fired = (j.checks || []).filter(c => c && c.triggered);\n  guardrail = fired.map(c => `${c.name} (${c.confidenceScore})`).join(', ');\n} catch (e) { guardrail = ''; }\n\nreturn [{\n  json: {\n    branch: 'none',\n    guardrail,\n    source_text: sourceText,\n    answer_facts: 'Это вне моей компетенции. Я помогаю с наличием товаров и вопросами по заказу, оплате и доставке.'\n  },\n  pairedItem: { item: 0 }\n}];"
      },
      "id": "d288a544-1272-458e-9543-8e78c3ec4e85",
      "name": "12. Отказ (оффтоп)  [R8a]",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3168,
        1264
      ],
      "notes": "Код: канонический вежливый отказ вне темы Nordwaren. Вход — 9 (intent=none/default) и 6c (реальное нарушение guardrails). Выход → 13."
    },
    {
      "parameters": {
        "jsCode": "// Пустое/нераспознанное аудио (R8b) + circuit breaker (план §5.6, урок 274).\nconst N = 2; // после скольких неудач подряд зовём человека\n\nlet clientText = '', chatId = 'unknown';\ntry {\n  const msg = $('1. Telegram Trigger (voice)').first().json.message || {};\n  clientText = String(msg.text || msg.caption || '');\n  chatId = String((msg.chat && msg.chat.id) || 'unknown');\n} catch (e) {}\n\n// Счётчик неудач по каждому чату. staticData переживает выполнения активного воркфлоу.\n// ❗ В ручных запусках (Execute workflow) staticData НЕ сохраняется — проверять только живыми сообщениями боту.\nlet attempts = 1;\ntry {\n  const store = $getWorkflowStaticData('global');\n  store.failedAttempts = store.failedAttempts || {};\n  attempts = (store.failedAttempts[chatId] || 0) + 1;\n  store.failedAttempts[chatId] = attempts;\n\n  if (attempts >= N) store.failedAttempts[chatId] = 0; // после эскалации счёт начинается заново\n} catch (e) {}\n\nif (attempts >= N) {\n  return [{ json: {\n    branch: 'escalate',\n    escalate: true,\n    attempts,\n    source_text: clientText,\n    answer_facts: 'Распознать обращение не удалось уже второй раз подряд. Нужно спокойно сказать, что обращение передаём живому оператору и специалист свяжется с клиентом.'\n  }, pairedItem: { item: 0 } }];\n}\n\nreturn [{ json: {\n  branch: 'repeat',\n  escalate: false,\n  attempts,\n  source_text: clientText,\n  answer_facts: 'Не расслышал сообщение. Повторите, пожалуйста, запрос.'\n}, pairedItem: { item: 0 } }];"
      },
      "id": "e2f6d8f0-560e-4e7d-b295-8ac13b771491",
      "name": "R. Повторить / эскалация  [R8b + circuit breaker]",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        224,
        464
      ],
      "notes": "Код: голосовая просьба повторить при сбое STT/пустом-битом аудио; после N неудач подряд (circuit breaker) — эскалация оператору вместо повторного «не расслышал». Вход — error-выходы 4/5c/6/3b. Выход → 13."
    },
    {
      "parameters": {
        "content": "### Голосовой стиль ответа (R5)\nПереписать факты в 1–2 коротких разговорных предложения, без канцелярита, один смысловой блок. temperature ~0.3, лимит длины.\nМодель — та же локальная (LM Studio, gemma-4-12b-it-mlx), под-нода Chat Model — final. При перечислении нескольких вариантов допустимо 2–3 предложения (решение Dennis 14.08.2026).",
        "height": 330,
        "width": 320,
        "color": 7
      },
      "id": "8ff6b5a7-c395-4a4a-98a6-14ed16a423fc",
      "name": "note Style",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3568,
        224
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=ЯЗЫК ОТВЕТА: {{ $json.lang }}\n\nТЕКУЩАЯ РЕПЛИКА КЛИЕНТА: {{ $json.source_text }}\n\nФАКТЫ ДЛЯ ОТВЕТА (единственный допустимый источник сведений):\n{{ $json.answer_facts }}",
        "messages": {
          "messageValues": [
            {
              "message": "Ты — голос службы поддержки Nordwaren. Ты не автоответчик, а продавец-консультант:\nпомогаешь клиенту выбрать и довести покупку до конца.\n\nФАКТЫ — ЕДИНСТВЕННЫЙ ИСТОЧНИК. Всё, чего в них нет, для тебя не существует —\nдаже если ты это знаешь. Продажа никогда не оправдывает выдумку.\n\nПОРЯДОК В КАЖДОЙ РЕПЛИКЕ:\n1. Сначала ОТВЕТЬ на вопрос клиента — коротко и по делу. Продавать до ответа нельзя.\n2. Потом ОДИН шаг вперёд: уточняющий вопрос или предложение — только если он вытекает из фактов.\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  если сведений нет, скажи по-человечески («не подскажу производителя, но…»), не объясняя механику;\n— называть цену или превосходную степень («самый дешёвый», «дороже всех»), которых нет в фактах —\n  но СРАВНИВАТЬ цены, которые в фактах ЕСТЬ (самый дешёвый из перечисленных, «до N евро») — можно и нужно.\n\nЯЗЫК: строго код из поля «ЯЗЫК ОТВЕТА» (ISO 639-1: ru — русский, uk — украинский,\nde — немецкий, en — английский). Факты могут быть на другом языке — передай смысл на нужном.\n\nЧИСЛА — ТОЛЬКО ЦИФРАМИ, РОВНО ТАКИМИ, КАК В ФАКТАХ.\nНе переписывай их словами, не округляй, не складывай и не пересчитывай.\nПовод (прогон 910): при попытке записать число прописью 53 превратилось в «тридцять три» —\nклиент услышал неверный остаток. В слова числа разворачивает сервис озвучки.\n\nАртикулы и обозначения моделей (LED 10W, ELK-1001) в речь не включай вовсе:\nв разговоре они звучат как набор букв.\n\nФОРМА: 1–2 коротких разговорных предложения; если перечисляешь все варианты — можно 2–3.\nЭто ГОЛОС, а не письмо: без списков, таблиц и канцелярита.\n\nЕсли в фактах сказано, что данных нет, товар не найден, сообщение не расслышано\nили вопрос вне компетенции — передай именно это, спокойно и без придумок.\nВ этот момент продавать НЕЛЬЗЯ."
            }
          ]
        }
      },
      "id": "5e6a6873-cb57-4cf5-b7bc-1e37cb05793c",
      "name": "14. Голосовой стиль ответа (LLM)",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.5,
      "position": [
        3616,
        576
      ],
      "notes": "LLM превращает сухие answer_facts из ноды 13 в живую разговорную реплику по системному промпту (запрет выдумывать факты/цены, запрет упоминать «данные/каталог»). Модель — Chat Model final, память — Memory. Вход — 13. Выход → 14b."
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "gemma-4-12b-it-mlx"
        },
        "options": {
          "maxTokens": 160,
          "temperature": 0.3
        }
      },
      "id": "9f0060ae-953e-44b7-a274-399910013f62",
      "name": "Chat Model — final (LM Studio, локально)",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.2,
      "position": [
        3648,
        752
      ],
      "credentials": {},
      "notes": "Под-нода LLM для ноды 14 (ai_languageModel), локальная модель через LM Studio."
    },
    {
      "parameters": {
        "content": "### AI-дисклеймер (EU AI Act ст.50, действует с 02.08.2026)\nПри первом обращении клиента и дальше каждые 10-е обращение добавить голосом: «Вы говорите с автоматическим ассистентом Nordwaren». Счётчик обращений — в staticData по chat_id (нода 13, поле needsDisclaimer). Реализовано префиксом в тексте перед TTS (ноды 15/15b).",
        "height": 202,
        "width": 612,
        "color": 3
      },
      "id": "b0f80f9c-b235-4cad-8d37-62e81017b969",
      "name": "note Disclaimer",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3344,
        896
      ]
    },
    {
      "parameters": {
        "content": "### Озвучка [LOCAL основной / облако запасной]\nОсновной: Piper (нода 15b, self-hosted на Mac Dennis, Tailscale) — синтез не уходит в облако. Голоса: de_DE-thorsten-high / ru_RU-irina-medium / uk_UA-tetiana-high.\nЗапасной: ElevenLabs (нода 15, eleven_multilingual_v2) — только при сбое Piper (решение S-2, 15.08.2026; счётчик сбоев 15c уведомляет Dennis). Stability 0.7 / Similarity 0.8 / Style 0 / Speed 1.0 — подобраны осознанно под поддержку (Критерий 5).\nФормат — OGG/Opus, нативный voice-бабл Telegram.",
        "height": 440,
        "width": 372,
        "color": 7
      },
      "id": "2c5556f7-0378-4103-be15-80145de51cf9",
      "name": "note TTS",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4832,
        752
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.elevenlabs.io/v1/text-to-speech/hpp4J3VqNfWAUOO0d1Us",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "elevenLabsApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "output_format",
              "value": "opus_48000_128"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "audio/ogg"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => {\n  const j = $('13. Единая точка ответа').first().json;\n  let spoken = '';\n  try { spoken = String($('14b. Фильтр PII перед озвучкой').first().json.text || ''); } catch (e) {}\n  if (!spoken) spoken = String($json.text || '');\n  const greet = {\n    ru: 'Здравствуйте! Вы разговариваете с автоматическим ассистентом Nordwaren. ',\n    uk: 'Вітаю! Ви розмовляєте з автоматичним асистентом Nordwaren. ',\n    de: 'Hallo! Sie sprechen mit dem automatischen Assistenten von Nordwaren. ',\n    en: 'Hello! You are speaking with the automated assistant of Nordwaren. ',\n    pl: 'Cześć! Rozmawiasz z automatycznym asystentem Nordwaren. ',\n    tr: 'Merhaba! Nordwaren otomatik asistanıyla konuşuyorsunuz. '\n  };\n  const remind = {\n    ru: 'Напоминаем: вы разговариваете с автоматическим ассистентом Nordwaren. ',\n    uk: 'Нагадуємо: ви розмовляєте з автоматичним асистентом Nordwaren. ',\n    de: 'Zur Erinnerung: Sie sprechen mit dem automatischen Assistenten von Nordwaren. ',\n    en: 'Reminder: you are speaking with the automated assistant of Nordwaren. ',\n    pl: 'Przypomnienie: rozmawiasz z automatycznym asystentem Nordwaren. ',\n    tr: 'Hatırlatma: Nordwaren otomatik asistanıyla konuşuyorsunuz. '\n  };\n  const dict = j.disclaimerVariant === 'greeting' ? greet : (j.disclaimerVariant === 'reminder' ? remind : null);\n  const prefix = dict ? (dict[j.lang] || dict.de) : '';\n  return JSON.stringify({\n    text: prefix + spoken,\n    model_id: 'eleven_multilingual_v2',\n    voice_settings: { stability: 0.7, similarity_boost: 0.8, style: 0, speed: 1.0 }\n  });\n})() }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "id": "a525b98b-6518-4c04-b085-e05963ec6304",
      "name": "15. TTS — ElevenLabs (Opus)  [SWAP→LOCAL]",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4640,
        288
      ],
      "retryOnFail": false,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "credentials": {},
      "onError": "continueErrorOutput",
      "notes": "FALLBACK озвучка (после переворота S-2, 15.08.2026). Вызывается только с error-выхода 15b (нода 15c между ними считает сбои Piper). Текст/язык берётся ссылками на ноды 13/14b, не из $json — на error-входе там ошибка, не ответ.\nРетраи СНЯТЫ осознанно: раньше 3 попытки по 2 с стояли против сетевого сбоя (прогон 828),\nно теперь этот случай закрывает fallback — и закрывает лучше: ответ не теряется, а деградирует.\nШесть секунд тишины перед каждым ответом в живой демонстрации хуже, чем быстрый переход на локаль.\nВернуть retryOnFail, когда счёт ElevenLabs пополнен.\n\nГолос: Bella (hpp4J3VqNfWAUOO0d1Us). Ключ — только через Predefined Credential Type (AE-19).\noutput_format — QUERY-параметр opus_48000_128; имя answer.ogg в ноде отправки — без них Telegram отдаёт трек."
    },
    {
      "parameters": {
        "operation": "sendAudio",
        "chatId": "={{ $('1. Telegram Trigger (voice)').first().json.message.chat.id }}",
        "binaryData": true,
        "additionalFields": {
          "fileName": "answer.ogg"
        }
      },
      "id": "f4547e4c-b84c-41c7-9878-3d6e7e55344c",
      "name": "16. Telegram Send Voice (R6)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        4864,
        192
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "credentials": {},
      "notes": "Отправка готового аудио клиенту voice-сообщением. Вход — 15 или 15b (кто из TTS реально отработал). Выход → 10c (кнопки после голосового ответа, отправляются последними — правило R6)."
    },
    {
      "parameters": {
        "jsCode": "// Audit log с redaction [PII] (урок 274 + TOMs). Храним транскрипт, не сырое аудио.\n// Вход — прямо из единой точки ответа, параллельно озвучке.\nconst j = $json;\nconst t = String(j.source_text || '');\n\n// Телефоны в транскрипте маскируем до записи в журнал\nconst redact = t.replace(/\\+?\\d[\\d\\s\\-()]{6,}\\d/g, '[PHONE]');\n\nreturn [{ json: {\n  ts: new Date().toISOString(),\n  branch: j.branch || 'unknown',\n  lang: j.lang || '',\n  escalated: j.escalate === true,\n  attempts: j.attempts || 0,\n  guardrail: j.guardrail || '',\n  transcript_redacted: redact,\n  answer_facts: j.answer_facts || ''\n}, pairedItem: { item: 0 } }];"
      },
      "id": "f68cfed8-690e-4561-95f7-74fae5a6d92d",
      "name": "17. Audit log (redaction PII)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3680,
        1280
      ],
      "notes": "Код: пишет запись в служебный аудит-журнал (branch/lang/attempts/guardrail/транскрипт) с редакцией PII перед сохранением. Параллельная ветка от 13, на ответ клиенту не влияет."
    },
    {
      "parameters": {
        "resource": "speech",
        "operation": "speechToText",
        "file": "data",
        "additionalOptions": {}
      },
      "id": "1a5572c8-f727-4d82-bc18-7a69e7dd646f",
      "name": "5b. STT — ElevenLabs (запасной, выключен)",
      "type": "@elevenlabs/n8n-nodes-elevenlabs.elevenLabs",
      "typeVersion": 1,
      "position": [
        368,
        -48
      ],
      "credentials": {},
      "disabled": true,
      "notes": "УСТАРЕЛО, отключено (disabled) — облачный STT-резерв снят решением Dennis (S-2, 15.08): аудио клиента не должно уходить в облако даже как запасной путь."
    },
    {
      "parameters": {
        "jsCode": "// Единственная точка сборки ответа: Склад / FAQ / оффтоп / повторить / эскалация.\nconst src = $input.first().json;\n\n// Что сказал или написал клиент. Главный источник — собственный вход:\n// каждая ветка несёт source_text с собой, так как сквозные ссылки после развилки ненадёжны.\nlet clientText = String(src.source_text || '');\n\nif (!clientText) {\n  try { clientText = String($('8. Нормализация intent + keyword-fallback').first().json.source_text || ''); } catch (e) {}\n}\nif (!clientText) {\n  for (const n of ['5c. STT — whisper.cpp (локально, Tailscale)', '5. STT — Whisper (auto)  [SWAP→LOCAL]']) {\n    try {\n      const t = String($(n).first().json.text || '');\n      if (t) { clientText = t; break; }\n    } catch (e) {}\n  }\n}\n\nlet uiLang = '', chatId = 'unknown', clientName = '', clientUsername = '';\ntry {\n  const msg = $('1. Telegram Trigger (voice)').first().json.message || {};\n  if (!clientText) clientText = String(msg.text || msg.caption || '');\n  uiLang = String((msg.from && msg.from.language_code) || '');\n  chatId = String((msg.chat && msg.chat.id) || 'unknown');\n  clientName = String((msg.from && msg.from.first_name) || '');\n  clientUsername = String((msg.from && msg.from.username) || '');\n} catch (e) {}\n\n// Язык ответа = язык обращения. Порядок источников, от надёжного к запасному:\n// 1) код ISO от классификатора (только он различает ru и uk — алфавит не различает);\n// 2) он же из ноды 8; 3) алфавит самого обращения; 4) язык интерфейса Telegram; 5) немецкий.\n// Интерфейс Telegram стоит НИЖЕ алфавита осознанно: это настройка телефона сотрудника,\n// а не язык клиента — на прогоне 883 немецкий вопрос из-за него получил русский ответ.\nlet lang = String(src.lang || '').toLowerCase().trim().slice(0, 2);\n\nif (!/^[a-z]{2}$/.test(lang)) {\n  try { lang = String($('8. Нормализация intent + keyword-fallback').first().json.lang || '').toLowerCase().slice(0, 2); } catch (e) { lang = ''; }\n}\n\n// Язык, услышанный САМИМ распознаванием (сервис отдаёт поле lang с 14.08.2026,\n// после перехода на двухпроходное распознавание).\n// Стоит ВЫШЕ алфавита по двум причинам: алфавит не отличает русский от украинского\n// и, главное, врёт целиком, если STT записал немецкую речь кириллицей (прогон 972).\nif (!/^[a-z]{2}$/.test(lang)) {\n  try {\n    const l = String($('5c. STT — whisper.cpp (локально, Tailscale)').first().json.lang || '').toLowerCase().slice(0, 2);\n    if (/^[a-z]{2}$/.test(l)) lang = l;\n  } catch (e) {}\n}\n\n// Последний рубеж, когда кода не дали ни классификатор, ни распознавание:\n// алфавит обращения, иначе язык интерфейса, иначе немецкий (язык компании).\nif (!/^[a-z]{2}$/.test(lang)) {\n  const ui = uiLang.toLowerCase().slice(0, 2);\n  if (/[Ѐ-ӿ]/.test(clientText)) lang = 'ru';\n  else if (/[a-zA-Z]/.test(clientText)) lang = 'de';\n  else if (/^[a-z]{2}$/.test(ui)) lang = ui;\n  else lang = 'de';\n}\n\n// «Да» после однозначного предложения товара (флаг из ноды 8, awaitingConfirm в ноде 10b) —\n// подтверждение заказа. Раньше это тихо уходило назад в ветку «Склад», и нода 14 просто\n// повторяла остаток/цену вместо того, чтобы завершить сделку (запрос Dennis 14.08).\n// Передаём оператору через УЖЕ существующий канал эскалации (нода 20) — не дублируем механизм.\nlet orderConfirmed = false;\nlet declineOrder = false;\nlet orderSummary = '';\nlet confirmedOrderData = null;\ntry {\n  const n8 = $('8. Нормализация intent + keyword-fallback').first().json;\n  orderConfirmed = n8.orderConfirmed === true;\n  declineOrder = n8.declineOrder === true;\n  const co = n8.confirmedOrder;\n  if (co) {\n    confirmedOrderData = co;\n    const parts = [co.name, co.color, (co.price != null ? co.price + ' евро' : '')].filter(Boolean);\n    orderSummary = parts.join(', ');\n  }\n} catch (e) {}\n\nlet branch = src.branch || 'unknown';\nlet answerFactsOverride = null;\nif (orderConfirmed) {\n  branch = 'order_confirmed';\n  answerFactsOverride = 'Клиент подтвердил намерение оформить заказ на ранее обсуждённый товар. Скажи коротко и по-деловому, что заявка передана оператору Nordwaren, который свяжется для оформления и оплаты — не называй остаток и цену повторно.';\n  // Этап E: открываем окно ожидания текстовой формы (способ получения/адрес/email).\n  // Данные товара берём из того же confirmedOrder, что и order_summary — не спрашиваем повторно.\n  try {\n    const storeE = $getWorkflowStaticData('global');\n    storeE.orderForm = storeE.orderForm || {};\n    // E8/Б3: повторный orderConfirmed при УЖЕ активном мастере не должен стирать его шаг/данные —\n    // иначе ложное повторное «да» во время заполнения формы откатывает клиента на шаг 1.\n    const existingWizard = storeE.orderForm[chatId];\n    const wizardActive = !!(existingWizard && existingWizard.step && (Date.now() - (existingWizard.ts || 0)) < 30 * 60000);\n    if (!wizardActive) {\n      storeE.orderForm[chatId] = {\n        step: 1,\n        ts: Date.now(),\n        product: {\n          name: (confirmedOrderData && confirmedOrderData.name) || '',\n          color: (confirmedOrderData && confirmedOrderData.color) || '',\n          price: confirmedOrderData ? confirmedOrderData.price : null\n        },\n        data: {}\n      };\n    }\n  } catch (e) {}\n} else if (declineOrder || branch === 'decline') {\n  // E10: отказ от предложения — закрываем окно спокойно, без оффтоп-отказа и без эскалации.\n  // Гвард branch === 'decline' — для текстового/кнопочного пути (E10-фикс): E2a сама ставит\n  // branch='decline', а нода 8 в этом прогоне вообще не выполнялась (текст, не голос), declineOrder\n  // остался false из-за пойманного catch выше.\n  branch = 'decline';\n  answerFactsOverride = 'Клиент вежливо отказался от предложения оформить этот заказ. Скажи спокойно и коротко, например «хорошо, обращайтесь, если что-то понадобится» — НЕ предлагай тот же товар повторно.';\n} else if (branch === 'confirm_expired') {\n  // E10-фикс: окно подтверждения протухло (>30 мин) к моменту, когда клиент нажал кнопку/написал текстом —\n  // честное «предложение устарело» вместо вводящего в заблуждение «не расслышал, повторите».\n  answerFactsOverride = 'Клиент ответил на предложение оформить заказ, но окно ожидания уже закрылось (прошло больше 30 минут). Скажи вежливо, что предложение устарело, и предложи назвать товар заново, если он всё ещё нужен.';\n}\n\n// Удачный ответ сбрасывает счётчик circuit breaker — иначе клиента будет эскалировать вечно\nif (branch !== 'repeat' && branch !== 'escalate') {\n  try {\n    const store = $getWorkflowStaticData('global');\n    if (store.failedAttempts) store.failedAttempts[chatId] = 0;\n  } catch (e) {}\n}\n\n// Приветствие + AI-дисклеймер (ст.50): здороваемся ОДИН раз на первом обращении клиента,\n// дальше просто напоминаем о себе каждые 10 обращений без повторного «здравствуйте»\n// (запрошено Dennis 13.08.2026 — то же правило заложено для будущего real-time звонка).\n// Счётчик обращений — в staticData по chat_id, как и circuit breaker выше.\nlet disclaimerVariant = 'greeting';\ntry {\n  const store = $getWorkflowStaticData('global');\n  store.contactCount = store.contactCount || {};\n  const count = (store.contactCount[chatId] || 0) + 1;\n  store.contactCount[chatId] = count;\n  disclaimerVariant = count === 1 ? 'greeting' : (count % 10 === 1 ? 'reminder' : 'none');\n} catch (e) {}\n\n// order_data_received приходит уже с order_name/order_color/order_price от E2a (та же staticData-запись,\n// что мы сохранили выше при order_confirmed) — досчитываем order_summary, если он ещё не собран.\nif (!orderSummary && branch === 'order_data_received') {\n  const parts = [src.order_name, src.order_color, (src.order_price !== '' && src.order_price != null ? src.order_price + ' евро' : '')].filter(Boolean);\n  orderSummary = parts.join(', ');\n  // E11: сопутствующий товар виден оператору в той же строке, что и основной товар.\n  if (src.accessory_name) {\n    orderSummary += ` + ${src.accessory_name} (${src.accessory_price} евро)`;\n  }\n}\n\n// G0: номер заказа (NW-<id> из E3b) — FAQ уже обещает клиенту «уточнить статус по номеру» —\n// назвать его голосом обязательно при order_data_received, иначе обещание без функции.\nif (branch === 'order_data_received' && src.order_number) {\n  answerFactsOverride = (src.answer_facts || '') + ` Номер заказа: ${src.order_number}. Обязательно назови его в ответе, например «ваш заказ ${src.order_number} принят».`;\n}\n\nreturn [{ json: {\n  branch,\n  answer_facts: answerFactsOverride || src.answer_facts || src.context || '',\n  source_text: clientText,\n  lang,\n  escalate: orderConfirmed || src.escalate === true,\n  attempts: src.attempts || 0,\n  guardrail: src.guardrail || '',\n  chat_id: chatId,\n  client_name: clientName,\n  client_username: clientUsername,\n  order_summary: orderSummary,\n  delivery_method: src.delivery_method || '',\n  address: src.address || '',\n  email: src.email || '',\n  first_name: src.first_name || '',\n  last_name: src.last_name || '',\n  phone: src.phone || '',\n  order_number: src.order_number || '',\n  accessory_name: src.accessory_name || '',\n  accessory_price: src.accessory_price !== undefined ? src.accessory_price : '',\n  disclaimerVariant\n}, pairedItem: { item: 0 } }];"
      },
      "id": "56ca7db5-766c-4c2d-933c-abb4803ea00c",
      "name": "13. Единая точка ответа",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3424,
        1168
      ],
      "notes": "Код: единая сборка ответа (branch/answer_facts/lang/данные заказа/эскалация) из ЛЮБОЙ ветки графа (10b/11b/12/R/12b/E3b) — единственная точка, откуда идёт озвучка, журнал и служебные ветки формы заказа. Выход → 14, 17, 13c, 13d."
    },
    {
      "parameters": {
        "text": "={{ $json.text }}",
        "guardrails": {
          "jailbreak": {
            "value": {
              "threshold": 0.7
            }
          }
        }
      },
      "id": "48127379-0afb-4a7e-b738-4bfddf6e8f73",
      "name": "6b. Guardrails IN (jailbreak)",
      "type": "@n8n/n8n-nodes-langchain.guardrails",
      "typeVersion": 2,
      "position": [
        1088,
        -240
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 1500,
      "onError": "continueRegularOutput",
      "notes": "LangChain guardrails: проверка на попытки взлома/манипуляции промптом ДО классификатора. Вход — 6 (валидный транскрипт). true (чисто) → 7. false (подозрение) → 6c."
    },
    {
      "parameters": {
        "jsCode": "// Строка журнала обращений для листа «Черновик».\n// Ответ берём из ноды 14 (что реально озвучено), остальное — из единой точки ответа.\n// Ссылка на ноду 13 здесь надёжна: тот же приём уже работает в ноде 15 (TTS),\n// но всё равно в try/catch — выражения со сквозными ссылками падают тихо.\nconst spoken = String($json.text || '');\n\nlet j = {};\ntry { j = $('13. Единая точка ответа').first().json || {}; } catch (e) {}\n\n// [PII] телефоны маскируем и в вопросе, и в ответе — как в ноде 17\nconst redact = (s) => String(s || '').replace(/\\+?\\d[\\d\\s\\-()]{6,}\\d/g, '[PHONE]');\n\n// Человеческое название ветки: лист читает человек, а не машина\nconst TYPE = {\n  stock: 'Склад',\n  faq: 'FAQ',\n  none: 'Отказ (не по теме)',\n  repeat: 'Просьба повторить',\n  escalate: 'Эскалация к оператору'\n};\n\nconst ts = new Date().toISOString().slice(0, 19).replace('T', ' ');\n\nreturn [{ json: {\n  'Дата': ts,\n  'Chat ID': String(j.chat_id || 'unknown'),\n  'Имя': String(j.client_name || ''),\n  'Тип': TYPE[j.branch] || String(j.branch || 'unknown'),\n  'Вопрос': redact(j.source_text),\n  'Ответ': redact(spoken)\n}, pairedItem: { item: 0 } }];"
      },
      "id": "eae30110-5fbc-417c-b744-ff083c75a86b",
      "name": "18. Журнал — сборка строки",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4192,
        768
      ],
      "notes": "Код: собирает строку журнала обращений (дата/chat id/имя/тип/вопрос/ответ) из выхода 14b. Вход — 14b. Выход → 19 (оба: Sheets-запасной и Postgres основной)."
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SHEET_ID"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Черновик"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "3dcbc5a6-7b5d-41f9-8ae3-d01fa88615b9",
      "name": "19. Журнал обращений → лист «Черновик»",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        4416,
        672
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "credentials": {},
      "disabled": true,
      "onError": "continueRegularOutput",
      "notes": "УСТАРЕЛО, отключено (disabled). Прежний журнал в Google Sheets до перехода на Postgres call_log."
    },
    {
      "parameters": {
        "content": "## ЭТАП 1 · Приём и распознавание речи\nГолосовое из Telegram → проверка, что аудио есть и длиннее секунды → Whisper без подсказки языка → проверка транскрипта.\n\nПустое или нераспознанное аудио уходит в ветку «R. Повторить / эскалация» (R8b).\nПроверка на иероглифы — против галлюцинаций Whisper на тихой записи (найдено прогоном 826).",
        "height": 182,
        "width": 1122,
        "color": 7
      },
      "id": "c1e3ceb2-d81a-446d-9bc0-91eed641b80d",
      "name": "ЭТАП 1 — приём и распознавание",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -592,
        -592
      ]
    },
    {
      "parameters": {
        "content": "## ЭТАП 2 · Понимание запроса\nGuardrails на входе (тема + jailbreak) → классификатор со structured output → нормализация → Switch.\n\nКлассификатор отдаёт intent, confidence, язык клиента (ISO 639-1) и слоты — служебные поля всегда по-русски, потому что каталог русский.\nТри рубежа защиты: JSON Schema → Auto-Fix → keyword-fallback.",
        "height": 198,
        "width": 1266,
        "color": 7
      },
      "id": "4b157017-d4a4-4d4a-8c4d-638320720f65",
      "name": "ЭТАП 2 — понимание запроса",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1376,
        -592
      ]
    },
    {
      "parameters": {
        "content": "## ЭТАП 3 · Работа с данными\n**Склад:** чтение каталога целиком + агрегация по вариантам и echo-back. Таблица ≠ RAG: числа считаем точно.\n**FAQ:** Qdrant retrieve-first + относительный score-gate.\nОбе ветки честно говорят «не знаю» вместо выдумывания.",
        "height": 198,
        "width": 868,
        "color": 7
      },
      "id": "162205da-05a1-45e9-96b2-9e5af5257acd",
      "name": "ЭТАП 3 — работа с данными",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2656,
        -592
      ]
    },
    {
      "parameters": {
        "content": "## ЭТАП 4 · Ответ голосом и журнал\nЕдиная точка ответа → голосовой стиль (1–2 фразы на языке клиента) → ElevenLabs Opus → voice-бабл в Telegram.\n\nВсе четыре ветки сходятся здесь — ответ ВСЕГДА голосом, включая отказ и эскалацию (Критерий 4).\nAI-дисклеймер ст.50 — детерминированный префикс. Параллельно: аудит-лог [PII] и журнал обращений в лист «Черновик».",
        "height": 198,
        "width": 2198,
        "color": 7
      },
      "id": "2509ab5b-4ce9-46f3-add2-45c397ca04a4",
      "name": "ЭТАП 4 — ответ голосом и журнал",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3536,
        -592
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://YOUR_LOCAL_HOST:8088/tts",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => {\n  const j = $('13. Единая точка ответа').first().json;\n  let spoken = '';\n  try { spoken = String($('14b. Фильтр PII перед озвучкой').first().json.text || ''); } catch (e) {}\n  if (!spoken) spoken = String($json.text || '');\n  const greet = {\n    ru: 'Здравствуйте! Вы разговариваете с автоматическим ассистентом Nordwaren. ',\n    uk: 'Вітаю! Ви розмовляєте з автоматичним асистентом Nordwaren. ',\n    de: 'Hallo! Sie sprechen mit dem automatischen Assistenten von Nordwaren. ',\n    en: 'Hello! You are speaking with the automated assistant of Nordwaren. ',\n    pl: 'Cześć! Rozmawiasz z automatycznym asystentem Nordwaren. ',\n    tr: 'Merhaba! Nordwaren otomatik asistanıyla konuşuyorsunuz. '\n  };\n  const remind = {\n    ru: 'Напоминаем: вы разговариваете с автоматическим ассистентом Nordwaren. ',\n    uk: 'Нагадуємо: ви розмовляєте з автоматичним асистентом Nordwaren. ',\n    de: 'Zur Erinnerung: Sie sprechen mit dem automatischen Assistenten von Nordwaren. ',\n    en: 'Reminder: you are speaking with the automated assistant of Nordwaren. ',\n    pl: 'Przypomnienie: rozmawiasz z automatycznym asystentem Nordwaren. ',\n    tr: 'Hatırlatma: Nordwaren otomatik asistanıyla konuşuyorsunuz. '\n  };\n  const lang = String(j.lang || 'de');\n  const dict = j.disclaimerVariant === 'greeting' ? greet : (j.disclaimerVariant === 'reminder' ? remind : null);\n  const prefix = dict ? (dict[lang] || dict.de) : '';\n  return JSON.stringify({ text: prefix + spoken, lang });\n})() }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          },
          "timeout": 30000
        }
      },
      "id": "cddde876-96b4-4f87-8812-212565ced13d",
      "name": "15b. TTS — Piper (локально, Tailscale)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4192,
        384
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 1000,
      "credentials": {},
      "onError": "continueErrorOutput",
      "notes": "PRIMARY озвучка (после переворота S-2, 15.08.2026, решение Dennis, подтверждено трижды): локальный Piper на Mac, доступ по Tailscale (YOUR_LOCAL_HOST:8088).\nПри ЛЮБОЙ ошибке error-выход ведёт на 15c (счётчик) → ноду 15 — облачный ElevenLabs как запасной путь. Причина переворота: ElevenLabs на 15.08 реально не работает (квота исчерпана, прогон 1408), и полная локальность — цель Dennis.\n\nГолоса: de_DE-thorsten-high / ru_RU-irina-medium / uk_UA-tetiana-high, неизвестный язык → немецкий.\nСервис сам отдаёт OGG/Opus 48 кГц моно — тот же формат, что у ElevenLabs, поэтому нода 16 не меняется.\nБинарное поле ОБЯЗАНО называться data — иначе нода 16 не найдёт аудио.\n\nAI-дисклеймер ст.50 продублирован тем же словарём: комплаенс не должен зависеть от того, какая ветка озвучки сработала.\nТекст и язык берём ссылками на ноды 14 и 13, а не из $json: на error-выходе во входящем элементе лежит ошибка, а не ответ."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://YOUR_LOCAL_HOST:8088/stt",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "binaryData",
        "inputDataFieldName": "data",
        "options": {
          "timeout": 60000
        }
      },
      "id": "c4c0e506-5f98-4ade-9279-78a80ed1330f",
      "name": "5c. STT — whisper.cpp (локально, Tailscale)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        720,
        160
      ],
      "retryOnFail": false,
      "credentials": {},
      "onError": "continueErrorOutput",
      "notes": "ОСНОВНОЕ распознавание: whisper.cpp (large-v3-turbo) на Mac, Metal + Neural Engine.\nАудио клиента не покидает инфраструктуру — это закрывает последний пункт GDPR-контура §6.\n\nСервис сам конвертирует OGG/Opus из Telegram в WAV 16 кГц (whisper.cpp принимает только его)\nи отдаёт {\"text\": \"...\"} — та же форма, что у ноды OpenAI Whisper, поэтому граф ниже не меняется.\nСловарь предметной области (ru/uk/de) задан внутри сервиса, язык определяется автоматически.\n\nСкорость на замерах: 0.3–0.7 с на реплику 8–11 с — быстрее облачного вызова.\nОшибка (Mac выключен, сервис упал) → error-выход на ноду 5 — облачный Whisper.\nРетраи сняты осознанно: быстрый переход в облако лучше паузы в разговоре."
    },
    {
      "parameters": {
        "jsCode": "// Аудио для запасного (облачного) распознавания.\n// Error-выход HTTP-ноды бинарные данные не передаёт (прогон 929),\n// поэтому берём файл у той ноды, где он точно есть.\nlet binary = {};\ntry {\n  binary = $('4. Telegram Get File (audio)').first().binary || {};\n} catch (e) {}\n\nif (!binary || !binary.data) {\n  // Нечем распознавать — пусть облачная нода честно упадёт в ветку «повторите»,\n  // а не молча оборвётся.\n  return [{ json: { stt_fallback: 'audio_missing' }, pairedItem: { item: 0 } }];\n}\n\nreturn [{ json: { stt_fallback: 'cloud' }, binary, pairedItem: { item: 0 } }];"
      },
      "id": "1919361b-f2a8-4a9d-a4ec-1b51b77ac4dd",
      "name": "5d. Вернуть аудио для запасного STT",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        368,
        -240
      ],
      "disabled": true,
      "notes": "Почему эта нода вообще нужна (прогон 929):\nerror-выход HTTP-ноды несёт только json с описанием ошибки, без бинарных данных.\nПоэтому облачный Whisper падал с «expects binary file 'data', but none was found»,\nи запасное распознавание не работало вообще — клиент получал «повторите» вместо ответа.\n\nБерём аудио напрямую у ноды 4 и прикладываем обратно."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "cond-real-violation",
              "leftValue": "={{ ($json.checks || []).some(c => c && c.triggered === true && c.executionFailed !== true) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "58277658-b3ff-41e4-812a-2b43b20fd3d7",
      "name": "6c. IF реальное нарушение (не сбой)?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1344,
        256
      ],
      "notes": "Отсекает ложные срабатывания guardrails (технический сбой модели vs реальный jailbreak). true (реальное) → 12 (отказ). false (сбой) → 7 (классификатор всё же вызывается)."
    },
    {
      "parameters": {
        "sessionIdType": "customKey",
        "sessionKey": "={{ String($('1. Telegram Trigger (voice)').first().json.message.chat.id) }}",
        "contextWindowLength": 50
      },
      "id": "f545c616-8496-4763-b8dc-dfafc179c49a",
      "name": "Memory — история диалога (10 реплик)",
      "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
      "typeVersion": 1.4,
      "position": [
        2208,
        -176
      ],
      "notes": "Буфер памяти диалога (последние 10 реплик), подключён к классификатору 7 и стилисту 14 (ai_memory). Основной контекст между репликами ведётся кодом в ноде 8 — эта память вспомогательная, для LLM-шагов."
    },
    {
      "parameters": {
        "jsCode": "// Жалоба клиента — согласно матрице рисков (протокол §2.0) всегда передаётся оператору,\n// а не отсекается как оффтоп. Закрыто расхождение §8.0 (прогон 906: жалоба получила отказ).\nconst j = $input.first().json;\nreturn [{\n  json: {\n    branch: 'complaint',\n    escalate: true,\n    source_text: j.source_text || '',\n    answer_facts: 'Клиент высказал жалобу или претензию. Скажи спокойно, что обращение принято и передано живому оператору, который свяжется с клиентом и разберётся в ситуации.'\n  },\n  pairedItem: { item: 0 }\n}];"
      },
      "id": "818597db-1d44-44b7-85a6-1e1e006b996d",
      "name": "12b. Жалоба → эскалация",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3168,
        1456
      ],
      "notes": "Код: канонический вежливый ответ на жалобу клиента + флаг эскалации оператору. Вход — 9 (intent=complaint). Выход → 13."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "cond-escalate",
              "leftValue": "={{ $json.escalate }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "cc72af40-bf75-4599-b609-6890cf6bd0ad",
      "name": "13b. IF эскалация?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        4192,
        1104
      ],
      "notes": "Проверка флага escalate из 14b. true → 20 (уведомить оператора)."
    },
    {
      "parameters": {
        "chatId": "YOUR_CHAT_ID",
        "text": "=⚠️ Эскалация VoiceDesk\nДата: {{ $now.format('yyyy-MM-dd HH:mm') }}\nChat ID: {{ $('13. Единая точка ответа').first().json.chat_id }} · Имя: {{ $('13. Единая точка ответа').first().json.client_name }}{{ $('13. Единая точка ответа').first().json.client_username ? ' (@' + $('13. Единая точка ответа').first().json.client_username + ')' : '' }}\nТип: {{ $('13. Единая точка ответа').first().json.branch === 'complaint' ? 'Жалоба' : ($('13. Единая точка ответа').first().json.branch === 'order_confirmed' ? 'Подтверждён заказ' : ($('13. Единая точка ответа').first().json.branch === 'order_data_received' ? 'Данные заказа получены' : 'Не распознано (2 подряд)')) }}\n{{ ($('13. Единая точка ответа').first().json.branch === 'order_confirmed' || $('13. Единая точка ответа').first().json.branch === 'order_data_received') ? ('Заказ: ' + ($('13. Единая точка ответа').first().json.order_summary || 'детали не определены') + '\\n') : '' }}{{ $('13. Единая точка ответа').first().json.branch === 'order_data_received' ? ('Номер: ' + ($('13. Единая точка ответа').first().json.order_number || '—') + '\\nПолучатель: ' + $('13. Единая точка ответа').first().json.first_name + ' ' + $('13. Единая точка ответа').first().json.last_name + '\\nСпособ: ' + $('13. Единая точка ответа').first().json.delivery_method + ($('13. Единая точка ответа').first().json.address ? '\\nАдрес: ' + $('13. Единая точка ответа').first().json.address : '') + '\\nТелефон: ' + $('13. Единая точка ответа').first().json.phone + '\\nEmail: ' + $('13. Единая точка ответа').first().json.email + '\\n') : '' }}Транскрипт: {{ $('13. Единая точка ответа').first().json.source_text }}\nОтвет клиенту: {{ $json.text }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "57b4280c-4e25-4ff7-bbc7-1c191f6f9ef5",
      "name": "20. Уведомить оператора (эскалация)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        4400,
        1088
      ],
      "credentials": {},
      "notes": "Отправка полной заявки/жалобы/сбоя оператору (Dennis) в Telegram отдельным сообщением — не клиенту."
    },
    {
      "parameters": {
        "jsCode": "// Счётчик сбоев ЛОКАЛЬНОЙ озвучки (после переворота S-2, 15.08.2026, решение Dennis).\n//\n// После S-2: Piper — основной, ElevenLabs — запасной. Эта нода теперь сидит на\n// error-выходе 15b и считает сбои локального Piper, не ElevenLabs. Молча падать он не\n// должен: каждый пятый сбой зовём человека. Клиент при этом ничего не теряет — ответ\n// уходит на облачный ElevenLabs следующей нодой (если и он упадёт — это двойной отказ, тревога всё равно уже считана).\nconst N = 5;\nconst GAP_RESET_MIN = 60;  // час без сбоев — считаем это новой серией, счёт с нуля\n\n// Озвученный текст прокидываем дальше: нода 15b использует $json.text как запасной\n// источник, если ссылка на ноду 14 не сработает. Не сломать его важнее, чем считать сбои.\nlet spoken = '';\ntry { spoken = String($('14. Голосовой стиль ответа (LLM)').first().json.text || ''); } catch (e) {}\n\nlet why = '';\ntry {\n  const e = $json.error || {};\n  why = String(e.description || e.message || '');\n} catch (e) {}\n\nconst store = $getWorkflowStaticData('global');\nconst now = Date.now();\nconst s = store.ttsFail || { count: 0, ts: 0 };\nif (now - (s.ts || 0) > GAP_RESET_MIN * 60000) s.count = 0;\ns.count += 1;\ns.ts = now;\nstore.ttsFail = s;\n\nconst notify = s.count % N === 0;\nconst ts = new Date().toISOString().slice(0, 16).replace('T', ' ');\n\nreturn [{ json: {\n  text: spoken,\n  notify,\n  fails: s.count,\n  alert_text: notify\n    ? `⚠️ Nordwaren VoiceDesk — локальная озвучка не работает\\n${ts}\\n\\nPiper упал ${s.count} раз подряд.\\nПричина: ${why.slice(0, 180) || 'не указана'}\\n\\nКлиенты ответы получают — озвучивает облачный ElevenLabs (запасной).\\nПроверьте сервис Piper на Mac (Tailscale YOUR_LOCAL_HOST:8088).`\n    : ''\n}, pairedItem: { item: 0 } }];"
      },
      "id": "fde93613-06d5-44f1-952b-50bfa63e6bf2",
      "name": "15c. Счётчик сбоев облачной озвучки",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4416,
        480
      ],
      "notes": "После S-2 стоит НА ERROR-ВЫХОДЕ ноды 15b (Piper) и ничего не решает в судьбе ответа:\nаудио всё равно идёт дальше на ElevenLabs (запасной). Задача ноды — только счёт и текст тревоги."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "c-notify",
              "leftValue": "={{ $json.notify }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "b312dba2-0963-4e4c-a1d8-97af454569e3",
      "name": "15d. IF пора звать оператора?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        4640,
        576
      ],
      "notes": "Проверка флага notify из 15c (каждый N-й сбой Piper подряд, счётчик с автосбросом через час без сбоев). true → 15e (уведомление Dennis)."
    },
    {
      "parameters": {
        "chatId": "YOUR_CHAT_ID",
        "text": "={{ $json.alert_text }}",
        "additionalFields": {}
      },
      "id": "f4a9f2d3-0666-4d44-b0c3-ed4aba59fb5c",
      "name": "15e. Предупреждение: облачная озвучка падает",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        4864,
        576
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "onError = continueRegularOutput: неудавшееся СЛУЖЕБНОЕ уведомление\nне должно помечать прогон ошибкой — клиент свой ответ уже получил."
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT article AS \"Артикул\", name AS \"Наименование товара\", category AS \"Категория\", color AS \"Цвет\", qty AS \"Остаток (шт)\", price AS \"Цена (EUR)\" FROM catalog ORDER BY article;",
        "options": {}
      },
      "id": "pg-10a-read",
      "name": "10a. Postgres — каталог остатков (read)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        2816,
        208
      ],
      "alwaysOutputData": true,
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Актуальный источник каталога остатков: SELECT из таблицы catalog (алиасы колонок под формат, который читает 10b). Вход — 9 (intent=stock). Выход → 10b."
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO call_log (ts, chat_id, client_name, type, question, answer) VALUES ($1, $2, $3, $4, $5, $6);",
        "options": {
          "queryReplacement": "={{ [$json['Дата'], $json['Chat ID'], $json['Имя'], $json['Тип'], $json['Вопрос'], $json['Ответ']] }}"
        }
      },
      "id": "pg-19-insert",
      "name": "19. Журнал обращений → Postgres (call_log)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        4416,
        912
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Актуальный журнал обращений: INSERT в таблицу call_log. [PII] — редактируется в 17/18 до записи, хранится локально."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "cond-isorderform",
              "leftValue": "={{ $json.isOrderForm }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-3b",
      "name": "3b. IF форма заказа?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        464,
        736
      ],
      "notes": "Проверка isOrderForm/branch из E2a при активном мастере. true → 3e (проверка завершения мастера). false → R (повторить — текст вне контекста заказа)."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "cond-orderdata",
              "leftValue": "={{ $json.branch }}",
              "rightValue": "order_data_received",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-3c",
      "name": "3c. IF данные заказа полные?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        464,
        992
      ],
      "disabled": true,
      "notes": "УСТАРЕЛО, отключена и отсоединена от боевого пути (ранняя реализация мастера заказа до перехода на пошаговый E7). Оставлена в графе для истории, ждёт подтверждения Dennis на удаление."
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO orders (chat_id, username, product_name, product_color, product_price, delivery_method, address, email, first_name, last_name, street, plz, city, country, phone, accessory_name, accessory_price) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id;",
        "options": {
          "queryReplacement": "={{ [ $json.chat_id, ($('1. Telegram Trigger (voice)').item.json.message.from.username || ''), $json.order_name, $json.order_color, ($json.order_price === '' ? null : $json.order_price), $json.delivery_method, $json.address, $json.email, $json.first_name, $json.last_name, $json.street, $json.plz, $json.city, $json.country, $json.phone, ($json.accessory_name || null), ($json.accessory_price === '' ? null : $json.accessory_price) ] }}"
        }
      },
      "id": "e3-insert",
      "name": "E3. Заказ → Postgres (orders)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        928,
        720
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "INSERT итоговой заявки (товар/аксессуар/адрес/контакты/статус awaiting_payment) в таблицу orders после явного подтверждения на шаге 8 мастера. Вход — 3e (мастер завершён). Выход → E3b."
    },
    {
      "parameters": {
        "jsCode": "const ORDER_TTL_MIN = 30;\n\nlet chatId = 'unknown', text = '', contact = null;\ntry {\n  const msg = $('1. Telegram Trigger (voice)').first().json.message || {};\n  chatId = String((msg.chat && msg.chat.id) || 'unknown');\n  text = String(msg.text || msg.caption || '').trim();\n  contact = msg.contact || null;\n} catch (e) {}\n\nconst store = $getWorkflowStaticData('global');\nstore.orderForm = store.orderForm || {};\nconst of = store.orderForm[chatId];\nconst active = !!(of && of.step && (Date.now() - (of.ts || 0)) < ORDER_TTL_MIN * 60000);\n\n// E10-фикс (прогон 1374): кнопка «Оформить заказ» (и любой текст-ответ на предложение\n// оформить заказ) приходит КАК ТЕКСТ и раньше упирался сюда же, минуя ORDER_CONFIRM в ноде 8\n// (тот живёт только на голосовом пути через STT) — текст уходил в обычное «не расслышал,\n// повторите». Проверяем ЭТУ ветку ДО проверки мастера — она независимая от orderForm.\nif (!active && text) {\n  let dc = null;\n  try { dc = store.dialogCtx && store.dialogCtx[chatId]; } catch (e) {}\n  const dcFresh = !!(dc && dc.awaitingConfirm && (Date.now() - (dc.ts || 0)) < ORDER_TTL_MIN * 60000);\n  const dcStaleAwaiting = !!(dc && dc.awaitingConfirm && !dcFresh);\n\n  if (dcFresh || dcStaleAwaiting) {\n    const t0 = text.toLowerCase();\n    const CONFIRM_TEXT = /(оформ|заказат|заказыва|бер[уё]|возьм|покупа|купит|bestell|kaufen|nehme|^да\\b|^ja\\b|^yes\\b)/i;\n    const DECLINE_TEXT = /^(нет|не надо|не нужно|спасибо,? нет|nein|no)(?![а-яёіїєa-z])/i;\n    const CANCEL_TEXT = /^(отмена|отменить|cancel|abbrechen)/i;\n    // E11: кнопка сопутствующего товара выглядит как «+ Фильтр за 4.90 €» — это ТОЖЕ подтверждение,\n    // просто с дополнительным товаром в составе.\n    const looksAccessory = /^\\+/.test(text.trim());\n    const looksConfirm = (CONFIRM_TEXT.test(t0) || looksAccessory) && !text.includes('?');\n    const looksDecline = DECLINE_TEXT.test(text.trim()) || CANCEL_TEXT.test(text.trim());\n\n    if (dcStaleAwaiting && (looksConfirm || looksDecline)) {\n      try { store.dialogCtx[chatId].awaitingConfirm = false; } catch (e) {}\n      return [{ json: { isOrderForm: true, branch: 'confirm_expired', chat_id: chatId, source_text: text, escalate: false }, pairedItem: { item: 0 } }];\n    }\n\n    if (dcFresh && looksConfirm) {\n      store.dialogCtx[chatId].awaitingConfirm = false;\n      const btnProduct = {\n        name: dc.confirmName || dc.product || '',\n        color: dc.confirmColor || '',\n        price: dc.confirmPrice != null ? dc.confirmPrice : null,\n        // E11: аксессуар кладётся в состав ТОЛЬКО если нажата именно кнопка «+ ...» —\n        // простое «Оформить заказ» его НЕ добавляет, даже если аксессуар предлагался.\n        accessory: (looksAccessory && dc.confirmAccessoryName) ? { name: dc.confirmAccessoryName, price: dc.confirmAccessoryPrice != null ? dc.confirmAccessoryPrice : null } : null\n      };\n      store.orderForm[chatId] = { step: 1, ts: Date.now(), product: btnProduct, data: {} };\n      return [{ json: { isOrderForm: true, branch: 'wizard_step', chat_id: chatId, nextStep: 1, errorNote: '', product: btnProduct, data: {}, escalate: false }, pairedItem: { item: 0 } }];\n    }\n\n    if (dcFresh && looksDecline) {\n      store.dialogCtx[chatId].awaitingConfirm = false;\n      return [{ json: { isOrderForm: true, branch: 'decline', chat_id: chatId, source_text: text, escalate: false }, pairedItem: { item: 0 } }];\n    }\n  }\n}\n\n// E8/Б3: «отмена» закрывает мастер с любого шага, до проверки на пустой текст/истечёй TTL.\nif (active && text && /^(отмена|отменить|cancel|abbrechen)/i.test(text.trim())) {\n  delete store.orderForm[chatId];\n  return [{ json: {\n    isOrderForm: true,\n    branch: 'wizard_step',\n    chat_id: chatId,\n    nextStep: 9,\n    errorNote: '',\n    product: (of && of.product) || {},\n    data: (of && of.data) || {},\n    escalate: false\n  }, pairedItem: { item: 0 } }];\n}\n\nif (!active || (!text && !contact)) {\n  return [{ json: { isOrderForm: false }, pairedItem: { item: 0 } }];\n}\n\nconst product = of.product || {};\nconst data = Object.assign({}, of.data || {});\nlet step = of.step;\n// E9/1: режим \"точечного исправления\" — включается на шаге 10 (меню выбора поля),\n// выключается автоматически, как только мастер возвращается на сводку (шаг 8).\nlet fixing = !!of.fixing;\nconst t = text.toLowerCase();\n\nfunction save(nextStep) {\n  step = nextStep;\n  store.orderForm[chatId] = { step, ts: Date.now(), product, data, fixing: nextStep === 8 ? false : fixing };\n}\n\nfunction reprompt(errorNote) {\n  // Счётчик ошибок шага: со второй подряд подсказываем выход «отмена» — клиент не должен\n  // оказаться в тупике без двери (живая серия 1160–1169: четыре отбитых ответа подряд).\n  // advance() пишет свежий объект без errCount — прогресс сбрасывает счётчик сам.\n  const ec = ((of && of.errCount) || 0) + 1;\n  save(step);\n  store.orderForm[chatId].errCount = ec;\n  const tail = ec >= 2 ? ' Или напишите «отмена», чтобы прервать оформление.' : '';\n  return [{ json: {\n    isOrderForm: true,\n    branch: 'wizard_step',\n    chat_id: chatId,\n    nextStep: step,\n    errorNote: (errorNote || '') + tail,\n    product,\n    data,\n    escalate: false\n  }, pairedItem: { item: 0 } }];\n}\n\nfunction advance(nextStep, patch) {\n  Object.assign(data, patch || {});\n  save(nextStep);\n  return [{ json: {\n    isOrderForm: true,\n    branch: 'wizard_step',\n    chat_id: chatId,\n    nextStep: step,\n    errorNote: '',\n    product,\n    data,\n    escalate: false\n  }, pairedItem: { item: 0 } }];\n}\n\n// Шаг 1 — способ получения. Самовывоз пропускает адресные шаги (минимизация данных, ст.5(1)(c) GDPR).\n// E9/1: в режиме исправления после смены способа — либо сразу на сводку (адрес уже был или не\n// нужен), либо, если сменили НА доставку и адреса ещё нет — досдать его (шаги 3–5), а не пропускать.\nif (step === 1) {\n  const isDelivery = /доставк/i.test(t);\n  const isPickup = /самовыв|заберу|заберём|склад/i.test(t);\n  if (!isDelivery && !isPickup) return reprompt('Выберите, пожалуйста, «Доставка» или «Самовывоз со склада».');\n  const method = isDelivery ? 'Доставка' : 'Самовывоз';\n  const patch = method === 'Самовывоз' ? { delivery_method: method, street: '', plz: '', city: '', country: '' } : { delivery_method: method };\n  if (fixing) {\n    const needsAddress = method === 'Доставка' && !data.street;\n    return advance(needsAddress ? 3 : 8, patch);\n  }\n  return advance(2, patch);\n}\n\n// Шаг 2 — имя и фамилия получателя (минимум два слова из букв).\nif (step === 2) {\n  const m = text.match(/^([A-Za-zА-Яа-яЁёÄÖÜäöüß-]{2,})\\s+([A-Za-zА-Яа-яЁёÄÖÜäöüß-]{2,})/);\n  if (!m) return reprompt('Напишите, пожалуйста, имя и фамилию через пробел (например: Anna Schmidt).');\n  if (fixing) return advance(8, { first_name: m[1], last_name: m[2] });\n  const nextStep = data.delivery_method === 'Самовывоз' ? 6 : 3;\n  return advance(nextStep, { first_name: m[1], last_name: m[2] });\n}\n\n// Шаг 3 — улица и номер дома. Примитивная проверка осознанно (см. E2a-грабля из stop-точки): требуем цифру в тексте.\nif (step === 3) {\n  if (text.length < 5 || !/\\d/.test(text)) return reprompt('Напишите улицу и номер дома (например: Hauptstraße 12).');\n  return advance(4, { street: text });\n}\n\n// Шаг 4 — PLZ (ровно 5 цифр для DE) и город. Принимаем и ОДНИМ сообщением, и ПО ОТДЕЛЬНОСТИ:\n// живая серия 1160–1169 — клиент отправил «10115» без города, шаг отбивал этот и все\n// следующие ответы одной ошибкой — для клиента неотличимо от обрыва.\n// E9/2: PLZ↔город приходят и уходят ВМЕСТЕ (одним сообщением или парой сообщений подряд) — при\n// исправлении «Адрес» шаги 3→4→5 проходятся заново целиком, так что старый индекс никогда не\n// остаётся привязан к новому городу молча (отдельного справочника PLZ в учебном проекте нет).\nif (step === 4) {\n  const both = text.match(/^(\\d{5})[\\s,]+(.{2,})/);\n  if (both) return advance(5, { plz: both[1], city: both[2].trim() });\n  const plzOnly = text.match(/^(\\d{5})[\\s.,!]*$/);\n  if (plzOnly) return advance(4, { plz: plzOnly[1] }); // остаёмся на шаге: E7-4 спросит только город\n  if (data.plz && !/\\d/.test(text) && text.trim().length >= 2) {\n    return advance(5, { city: text.trim() });\n  }\n  return reprompt(data.plz\n    ? 'Напишите город словом (например: Berlin).'\n    : 'Индекс (PLZ) — ровно 5 цифр, затем город (например: 10115 Berlin). Можно отправить сначала только индекс.');\n}\n\n// Шаг 5 — страна. Доставка только по ЕС (см. FAQ); вне ЕС — вежливый отказ, шаг не двигаем.\nif (step === 5) {\n  const EU = ['герман','deutschland','german','австри','österreich','austria','франц','frankreich','france','нидерланд','niederlande','netherlands','бельг','belgien','belgium','польш','polen','poland','итали','italien','italy','испан','spanien','spain','чехи','tschechien','czech','дани','dänemark','denmark','люксембург','luxemburg','luxembourg'];\n  const isEU = EU.some(k => t.includes(k));\n  if (!isEU) return reprompt('Доставка возможна только по странам ЕС. Укажите страну ЕС или напишите «самовывоз», чтобы начать оформление заново.');\n  if (fixing) return advance(8, { country: text.trim() });\n  return advance(6, { country: text.trim() });\n}\n\n// Шаг 6 — телефон. Основной путь: кнопка request_contact (номер приходит подтверждённым). Fallback — ручной ввод, нормализация к +49 для локального формата.\nif (step === 6) {\n  let phone = '';\n  if (contact && contact.phone_number) {\n    phone = String(contact.phone_number).replace(/[^\\d+]/g, '');\n    if (!phone.startsWith('+')) phone = '+' + phone;\n  } else {\n    const digits = text.replace(/[^\\d+]/g, '');\n    if (/^\\+?\\d{7,15}$/.test(digits)) {\n      phone = digits.startsWith('+') ? digits : (digits.startsWith('0') ? '+49' + digits.slice(1) : '+' + digits);\n    }\n  }\n  if (!phone) return reprompt('Укажите телефон кнопкой «Поделиться номером» или напишите его вручную (например: +49 151 23456789).');\n  if (fixing) return advance(8, { phone });\n  return advance(7, { phone });\n}\n\n// Шаг 7 — email для счёта.\nif (step === 7) {\n  const m = text.match(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/);\n  if (!m) return reprompt('Email указан некорректно — нужен формат вида name@example.com.');\n  return advance(8, { email: text.trim() });\n}\n\n// Шаг 8 — сводка и явное подтверждение (Button-Lösung, §312j BGB).\n// E9/1: «Исправить» теперь ведёт не на шаг 1 (весь мастер заново), а на шаг 10 — меню выбора\n// ОДНОГО поля. Запрос Dennis: раньше клиент получил индекс Берлина с городом Köln, потому что\n// повторный проход по шагам молча писал поверх старых значений вперемешку.\nif (step === 8) {\n  if (/подтверд/i.test(t)) {\n    const address = data.delivery_method === 'Доставка'\n      ? `${data.street}, ${data.plz} ${data.city}, ${data.country}`\n      : '';\n    delete store.orderForm[chatId];\n    return [{ json: {\n      isOrderForm: true,\n      branch: 'order_data_received',\n      chat_id: chatId,\n      order_name: product.name || '',\n      order_color: product.color || '',\n      order_price: product.price != null ? product.price : '',\n      accessory_name: (product.accessory && product.accessory.name) || '',\n      accessory_price: (product.accessory && product.accessory.price != null) ? product.accessory.price : '',\n      delivery_method: data.delivery_method || '',\n      address,\n      email: data.email || '',\n      first_name: data.first_name || '',\n      last_name: data.last_name || '',\n      street: data.street || '',\n      plz: data.plz || '',\n      city: data.city || '',\n      country: data.country || '',\n      phone: data.phone || '',\n      answer_facts: 'Клиент подтвердил и отправил все данные для оформления заказа. Скажи спокойно, что данные получены, счёт на оплату отправят на указанную почту, заказ запустится в обработку после оплаты. Адрес, email и телефон вслух НЕ называть.',\n      escalate: true\n    }, pairedItem: { item: 0 } }];\n  }\n  if (/исправ/i.test(t)) {\n    return advance(10, {});\n  }\n  return reprompt('Напишите «Подтвердить заказ» или «Исправить».');\n}\n\n// Шаг 10 — меню точечного исправления (E9/1). Выбор поля включает fixing и уводит на\n// соответствующий обычный шаг мастера; по завершении ЭТОГО ОДНОГО поля — прямой\n// возврат на сводку (шаг 8) вместо повторного прохода по всем шагам подряд.\nif (step === 10) {\n  if (/способ/i.test(t)) { fixing = true; return advance(1, {}); }\n  if (/имя|фамили/i.test(t)) { fixing = true; return advance(2, {}); }\n  if (/адрес/i.test(t)) {\n    if (data.delivery_method !== 'Доставка') return reprompt('У вас самовывоз — адрес не требуется. Выберите другое поле или напишите «отмена».');\n    fixing = true; return advance(3, {});\n  }\n  if (/телефон/i.test(t)) { fixing = true; return advance(6, {}); }\n  if (/email/i.test(t)) { fixing = true; return advance(7, {}); }\n  if (/состав/i.test(t)) { return advance(11, {}); }\n  return reprompt('Выберите, что изменить: Способ / Имя / Адрес / Телефон / Email / Состав заказа.');\n}\n\n// Шаг 11 — состав заказа (E11): убрать сопутствующий товар, если он есть. Добавить новый аксессуар\n// здесь не предлагаем — выбор был один раз, в момент подтверждения (кнопка «+ ...»).\nif (step === 11) {\n  if (!product.accessory) return advance(8, {});\n  if (/убра|удали/i.test(t)) {\n    product.accessory = null;\n    return advance(8, {});\n  }\n  return advance(8, {});\n}\n\n// Защитный рубеж — неизвестный шаг в staticData, сбрасываем мастер вместо зацикливания.\ndelete store.orderForm[chatId];\nreturn [{ json: { isOrderForm: false }, pairedItem: { item: 0 } }];"
      },
      "id": "e2a-parse",
      "name": "E2a. Обработка текста при ожидании формы заказа",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        224,
        704
      ],
      "notes": "Код: обрабатывает текстовые ответы клиента — подтверждение/отказ/отмену на confirm-этапе (до старта мастера) и валидацию по шагам внутри мастера заказа (E7). Вход — 3 (текстовая ветка). Выход → 3b, 3c."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "cond-needform",
              "leftValue": "={{ $json.branch }}",
              "rightValue": "order_confirmed",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-13c",
      "name": "13c. IF нужна форма заказа?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3680,
        1120
      ],
      "notes": "Проверка branch==='order_data_received' на выходе 13 (клиент только что подтвердил заказ голосом/кнопкой первый раз). true → E7-1 (старт мастера, шаг 1)."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.errorNote ? ('⚠️ ' + $json.errorNote + '\\n\\n') : '' }}Способ получения: «Доставка» или «Самовывоз со склада»? Напишите один из вариантов или нажмите кнопку ниже.\n\nЭти данные нужны для оформления этого заказа (исполнение договора, ст. 6(1)(b) GDPR), обрабатываются и хранятся в собственной инфраструктуре и не передаются третьим лицам, не участвующим в доставке этого сообщения и счёта.",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Доставка",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Самовывоз со склада",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "tg-e1form",
      "name": "E7-1. Способ получения (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        3968,
        1072
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 1 мастера заказа: «Доставка»/«Самовывоз» кнопками + правовой абзац GDPR ст.6(1)(b). Ряд «Отменить заказ» — выход из мастера в любой момент. Вход — 13c (старт) или E7sw (возврат/исправление)."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "cond-gotdata",
              "leftValue": "={{ $json.branch }}",
              "rightValue": "order_data_received",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-13d",
      "name": "13d. IF данные заказа получены?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        3680,
        1456
      ],
      "notes": "Проверка branch==='order_data_received' на выходе 13 для финального текстового эха клиенту (без озвучки PII). true → E4."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "=Заказ {{ $json.order_number || '' }} — записали:\nТовар: {{ $json.order_summary }}\nПолучатель: {{ $json.first_name }} {{ $json.last_name }}\nСпособ: {{ $json.delivery_method }}\n{{ $json.address ? 'Адрес: ' + $json.address + '\\n' : '' }}Телефон: {{ $json.phone }}\nEmail: {{ $json.email }}\n\nСпасибо! Счёт отправим на указанную почту.",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "tg-e4echo",
      "name": "E4. Подтверждение данных (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        3968,
        1440
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Текстовое эхо принятой заявки клиенту («Записали: …») — дополняет голосовой ответ (правило модуля №1: голос основной, текст вспомогательный), без PII в самом голосе."
    },
    {
      "parameters": {
        "jsCode": "// Второй, детерминированный рубеж защиты PII (этап E, требование Dennis/Fable): адрес и email\n// клиента не должны попасть в текст, который уходит в облачную озвучку (нода 15, ElevenLabs).\n// Не доверяем только промпту ноды 14 (там уже есть инструкция «не называть») — это фильтр,\n// который сработает, даже если модель ошибётся. Применяется ко ВСЕМ веткам ответа (email/address\n// пустые вне этапа E — но-оп).\nlet text = String($json.text || '');\n\n// E9/3-фикс (регрессия, найдена оркестратором): связь 13→113b переставлена на 14b→13b для\n// гарантированного порядка выполнения (нода 20 читает реальный ответ, не LLM-инструкцию) —\n// значит, 14b теперь ЕДИНСТВЕННЫЙ путь к 13b/20 и ОБЯЗАНА прокинуть ВСЕ поля единой точки\n// ответа (branch, escalate, chat_id, order_number...) — иначе IF «эскалация?» (проверяет\n// $json.escalate) всегда false: $json до этой ноды — только {text} от chainLlm, остальные поля не\n// прокидываются через chainLlm автоматически.\nlet j13 = {};\nlet j13Error = '';\ntry {\n  j13 = $('13. Единая точка ответа').first().json || {};\n} catch (e) {\n  j13Error = String((e && e.message) || e);\n}\n\nconst email = String(j13.email || '');\nconst address = String(j13.address || '');\n\nif (email && text.includes(email)) text = text.split(email).join('вашу почту');\nif (address && address.length >= 4 && text.includes(address)) text = text.split(address).join('указанный адрес');\n\n// Общая подстраховка: любой email-подобный паттерн вообще, не только тот, что был в этой реплике.\ntext = text.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/g, 'указанную почту');\n\nreturn [{ json: Object.assign({}, j13, $json, { text }, j13Error ? { j13Error } : {}), pairedItem: { item: 0 } }];"
      },
      "id": "code-14b-pii",
      "name": "14b. Фильтр PII перед озвучкой",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3968,
        672
      ],
      "notes": "Код: убирает из текста ответа персональные данные (адрес/email/телефон) перед синтезом речи и логом — полные данные озвучивать нельзя. Вход — 14. Выход → 18 (журнал), 13b (эскалация), 15b (TTS, основной путь после S-2)."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "cond-wizdone",
              "leftValue": "={{ ['order_data_received','decline','confirm_expired'].includes($json.branch) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-3e",
      "name": "3e. IF мастер завершён?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        688,
        736
      ],
      "notes": "Проверка шага мастера: 8 (сводка подтверждена клиентом) → E3 (запись заказа). Любой другой активный шаг → E7sw (следующее сообщение мастера)."
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s1",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 1,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Step1"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s2",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 2,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Step2"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s3",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 3,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Step3"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s4",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 4,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Step4"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s5",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 5,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Step5"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s6",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 6,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Step6"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s7",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 7,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Step7"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s8",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 8,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Step8"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s9",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 9,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Cancel"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s10",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 10,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "FixMenu"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 1
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s11",
                    "leftValue": "={{ $json.nextStep }}",
                    "rightValue": 11,
                    "operator": {
                      "type": "number",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Step11"
            }
          ]
        },
        "options": {}
      },
      "id": "e7-switch",
      "name": "E7sw. Switch по шагу мастера",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.4,
      "position": [
        1200,
        1168
      ],
      "notes": "Маршрутизация по номеру текущего шага мастера (1-11) на соответствующее сообщение клиенту (E7-1…E7-8, E7-cancel, E7-fix, E7-11)."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.errorNote ? ('⚠️ ' + $json.errorNote + '\\n\\n') : '' }}Как вас зовут? Напишите имя и фамилию, например: Anna Schmidt.",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-step2",
      "name": "E7-2. Имя и фамилия (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        496
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 2 мастера: запрос имени и фамилии получателя (минимум два слова). Кнопка «Отменить заказ» вместо keyboardRemove."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.errorNote ? ('⚠️ ' + $json.errorNote + '\\n\\n') : '' }}Улица и номер дома? Например: Hauptstraße 12.",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-step3",
      "name": "E7-3. Улица и дом (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        688
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 3 мастера (только при доставке): улица и номер дома. Кнопка «Отменить заказ» вместо keyboardRemove."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.errorNote ? ('⚠️ ' + $json.errorNote + '\\n\\n') : '' }}{{ $json.data && $json.data.plz ? ('Индекс ' + $json.data.plz + ' принял. Теперь город? Например: Berlin.') : 'Индекс (PLZ) и город? Например: 10115 Berlin.' }}",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-step4",
      "name": "E7-4. PLZ и город (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        880
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 4 мастера (только при доставке): индекс (5 цифр, DE) и город, можно одним сообщением или по отдельности. Кнопка «Отменить заказ» вместо keyboardRemove."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.errorNote ? ('⚠️ ' + $json.errorNote + '\\n\\n') : '' }}Страна? Доставляем только по ЕС. Нажмите «Deutschland» или напишите другую страну ЕС, например: Österreich.",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Deutschland",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-step5",
      "name": "E7-5. Страна (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        1072
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 5 мастера (только при доставке): страна ЕС, кнопка «Deutschland» по умолчанию + ряд «Отменить заказ». Вне ЕС — вежливый отказ, шаг не двигается (валидация в E2a)."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.errorNote ? ('⚠️ ' + $json.errorNote + '\\n\\n') : '' }}Телефон для связи по заказу. Нажмите кнопку «Поделиться номером» ниже или напишите вручную, например: +49 151 23456789.",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "📱 Поделиться номером",
                    "additionalFields": {
                      "request_contact": true
                    }
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-step6",
      "name": "E7-6. Телефон (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        1264
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 6 мастера: телефон кнопкой «Поделиться номером» (request_contact) или вручную + ряд «Отменить заказ». При самовывозе — сюда сразу после имени (адресные шаги 3-5 пропущены)."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.errorNote ? ('⚠️ ' + $json.errorNote + '\\n\\n') : '' }}Email для счёта? Например: name@example.com.",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-step7",
      "name": "E7-7. Email (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        1456
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 7 мастера: email для счёта, валидация формата в E2a. Кнопка «Отменить заказ» вместо keyboardRemove."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.errorNote ? ('⚠️ ' + $json.errorNote + '\\n\\n') : '' }}Проверьте, пожалуйста, данные заказа:\n\nТовар: {{ $json.product.name }}, {{ $json.product.color }}, {{ Number($json.product.price).toFixed(2) }} €\n{{ $json.product.accessory ? ('+ ' + $json.product.accessory.name + ', ' + Number($json.product.accessory.price).toFixed(2) + ' €\\n') : '' }}Итого: {{ Number($json.product.accessory ? ($json.product.price + $json.product.accessory.price) : $json.product.price).toFixed(2) }} €\nПолучатель: {{ $json.data.first_name || '—' }} {{ $json.data.last_name || '' }}\nСпособ: {{ $json.data.delivery_method }}\n{{ $json.data.delivery_method === 'Доставка' ? ('Адрес: ' + $json.data.street + ', ' + $json.data.plz + ' ' + $json.data.city + ', ' + $json.data.country + '\\n') : '' }}Телефон: {{ $json.data.phone }}\nEmail: {{ $json.data.email }}\n\nВсё верно?",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Подтвердить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Исправить",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-step8",
      "name": "E7-8. Сводка и подтверждение (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        1648
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 8 (финальный) мастера: сводка заказа (товар+аксессуар+итого+контакты), кнопки «Подтвердить заказ»/«Исправить»/«Отменить заказ» (Button-Lösung §312j BGB). «Подтвердить» → E3; «Исправить» → шаг 10 (E7-fix)."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "Хорошо, оформление заказа отменено. Если захотите оформить заказ позже — просто напишите или проговорите нам снова.",
        "replyMarkup": "replyKeyboardRemove",
        "replyKeyboardRemove": {
          "remove_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-cancel",
      "name": "E7-cancel. Мастер отменён (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        1840
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Ответ при отмене мастера словом/кнопкой «Отменить заказ» на любом шаге — вежливое закрытие без предложения товара повторно, клавиатура убирается явно."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.errorNote ? ('⚠️ ' + $json.errorNote + '\\n\\n') : '' }}Что изменить в заявке? Выберите одно из полей или нажмите кнопку ниже.",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Способ получения",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Имя",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Адрес",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Телефон",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Email",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Состав заказа",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-fixmenu",
      "name": "E7-fix. Что изменить (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        2032
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 10 (меню точечного исправления): выбор ОДНОГО поля для правки (способ/имя/адрес/телефон/email/состав заказа) — без повторного прохода по всему мастеру."
    },
    {
      "parameters": {
        "jsCode": "// G0: E3 вставила заказ с RETURNING id — собираем клиентский номер NW-<id> и возвращаем все исходные\n// поля из E2a (выход E3 содержит ТОЛЬКО id — всё остальное нужно 13/20/E4).\n// Если INSERT упал (onError: continueRegularOutput на E3) — id не придёт, номер останется пустым (грациозная деградация вместо падения всей ветки).\nlet id = null;\ntry { id = $input.first().json.id; } catch (e) {}\n\nlet src = {};\ntry { src = $('E2a. Обработка текста при ожидании формы заказа').first().json; } catch (e) {}\n\nconst order_number = id != null ? ('NW-' + id) : '';\n\nreturn [{ json: Object.assign({}, src, { order_id: id, order_number }), pairedItem: { item: 0 } }];"
      },
      "id": "e3b-ordernum",
      "name": "E3b. Прикрепить номер заказа",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1120,
        720
      ],
      "notes": "Код: формирует читаемый номер заказа (NW-<id>) из id, полученного при INSERT в E3, и кладёт его в единую точку ответа 13 для голоса/текста/эскалации."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-found",
              "leftValue": "={{ (() => { try { return $('10b. Агрегация + echo-back').first().json.found === true && $('10b. Агрегация + echo-back').first().json.ambiguous === false; } catch (e) { return false; } })() }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "options": {}
      },
      "id": "if-10c",
      "name": "10c. IF однозначный товар (кнопка)?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        5088,
        192
      ],
      "notes": "Проверка found&&!ambiguous из 10b после голосового ответа о складе. true → 10e (есть ли сопутствующий товар, для выбора 2- или 3-кнопочной клавиатуры)."
    },
    {
      "parameters": {
        "chatId": "={{ $('1. Telegram Trigger (voice)').first().json.message.chat.id }}",
        "text": "Оформить этот заказ?",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Оформить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": false
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "tg-10d-confirmbtn",
      "name": "10d. Кнопка подтверждения заказа (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        5536,
        288
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Reply-клавиатура «Оформить заказ»/«Отменить заказ» — когда у найденного однозначного товара НЕТ сопутствующего аксессуара."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-has-accessory",
              "leftValue": "={{ (() => { try { return !!$('10b. Агрегация + echo-back').first().json.accessory; } catch (e) { return false; } })() }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "options": {}
      },
      "id": "if-10e",
      "name": "10e. IF есть сопутствующий?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        5312,
        176
      ],
      "notes": "Проверка наличия accessory (кросс-сейл) у найденного товара из 10b. true → 10f (3-кнопочная клавиатура с аксессуаром). false → 10d (обычная 2-кнопочная)."
    },
    {
      "parameters": {
        "chatId": "={{ $('1. Telegram Trigger (voice)').first().json.message.chat.id }}",
        "text": "Оформить этот заказ?",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Оформить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "={{ '+ ' + $('10b. Агрегация + echo-back').first().json.accessory.name + ' за ' + $('10b. Агрегация + echo-back').first().json.accessory.price + ' €' }}",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Отменить заказ",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": false
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "tg-10f-confirmaccessory",
      "name": "10f. Кнопка подтверждения + аксессуар (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        5536,
        96
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Reply-клавиатура «Оформить заказ»/«+ <Аксессуар> за N €»/«Отменить заказ» — кнопка с аксессуаром добавляет его в заказ структурно (E2a/looksAccessory)."
    },
    {
      "parameters": {
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.product.accessory ? ('Дополнительно в заказе: ' + $json.product.accessory.name + ', ' + $json.product.accessory.price + ' €. Убрать его из заказа?') : 'Дополнительных товаров в заказе нет. Напишите что-нибудь, чтобы вернуться к сводке.' }}",
        "replyMarkup": "replyKeyboard",
        "replyKeyboard": {
          "rows": [
            {
              "row": {
                "buttons": [
                  {
                    "text": "Убрать",
                    "additionalFields": {}
                  }
                ]
              }
            },
            {
              "row": {
                "buttons": [
                  {
                    "text": "Оставить",
                    "additionalFields": {}
                  }
                ]
              }
            }
          ]
        },
        "replyKeyboardOptions": {
          "resize_keyboard": true,
          "one_time_keyboard": true
        },
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e7-step11",
      "name": "E7-11. Состав заказа (текст клиенту)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1696,
        2224
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Шаг 11 (из меню исправления): показывает текущий сопутствующий товар (если есть) с кнопками «Убрать»/«Оставить»; без аксессуара — честно говорит, что убирать нечего. Новый аксессуар здесь не добавляется (выбор один раз, при подтверждении)."
    },
    {
      "parameters": {
        "content": "## МАСТЕР ЗАКАЗА (E2a–E7)\nПошаговое оформление заказа текстом: подтверждение (кнопка/слово) → 8 шагов (способ получения, адрес, контакты, сводка) → точечное исправление одного поля → запись в Postgres orders.\nОтмена — словом/кнопкой на любом шаге.",
        "height": 198,
        "width": 1022,
        "color": 7
      },
      "id": "1250235e-e20e-496a-b441-244d2c3c2324",
      "name": "ЗОНА — МАСТЕР ЗАКАЗА",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        128,
        1168
      ]
    },
    {
      "parameters": {
        "content": "## ЭСКАЛАЦИЯ ОПЕРАТОРУ\nЖалоба клиента или флаг escalate из единой точки ответа → уведомление оператору (Dennis) в Telegram с полной заявкой/жалобой. Голосовой ответ клиенту не заменяется — эскалация идёт ПАРАЛЛЕЛЬНО.",
        "height": 214,
        "width": 434,
        "color": 7
      },
      "id": "c9b4c577-27ab-44b8-9cf1-c98260fe8fa3",
      "name": "ЗОНА — ЭСКАЛАЦИЯ",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3152,
        1616
      ]
    },
    {
      "parameters": {
        "content": "## ОЗВУЧКА (после S-2)\nPiper (локально, основной) → при ошибке счётчик (15c) → ElevenLabs (облачный, запасной). Каждый N-й сбой Piper подряд — тревога Dennis (15d/15e).",
        "height": 80,
        "width": 380,
        "color": 7
      },
      "id": "e8800c18-4016-4086-8edf-6b7f11892d14",
      "name": "ЗОНА — ОЗВУЧКА",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3984,
        288
      ]
    },
    {
      "parameters": {
        "fromEmail": "Nordwaren GmbH <you@example.com>",
        "toEmail": "={{ $('13. Единая точка ответа').first().json.email }}",
        "subject": "={{ 'Nordwaren — заявка ' + ($('13. Единая точка ответа').first().json.order_number || '') + ' принята' }}",
        "html": "={{ (() => {\n  // Вход этой ноды — ответ Telegram API от E4, а НЕ данные заказа (грабля AE-14),\n  // поэтому всё берём именованными ссылками, как в нодах 20 и 15b.\n  let j = {};\n  try { j = $('13. Единая точка ответа').first().json || {}; } catch (e) {}\n  let baseName = '', baseColor = '', base = null;\n  try {\n    const e2 = $('E2a. Обработка текста при ожидании формы заказа').first().json;\n    baseName = String(e2.order_name || '');\n    baseColor = String(e2.order_color || '');\n    base = (e2.order_price === '' || e2.order_price == null) ? null : Number(e2.order_price);\n  } catch (e) {}\n  const accName = String(j.accessory_name || '');\n  const accPrice = (j.accessory_price === '' || j.accessory_price == null) ? null : Number(j.accessory_price);\n  const rows = [];\n  if (baseName) rows.push({ n: baseName + (baseColor ? ', ' + baseColor : ''), p: base });\n  if (accName) rows.push({ n: accName, p: accPrice });\n  const total = rows.reduce((s, r) => s + (r.p || 0), 0);\n  const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n  const money = (v) => (v == null ? '—' : Number(v).toFixed(2).replace('.', ',') + ' €');\n  const trs = rows.map(r => `<tr><td style=\"padding:8px 0;border-bottom:1px solid #eee\">${esc(r.n)}</td><td align=\"right\" style=\"padding:8px 0;border-bottom:1px solid #eee;white-space:nowrap\">${money(r.p)}</td></tr>`).join('');\n  const receiver = [j.first_name, j.last_name].filter(Boolean).map(esc).join(' ');\n  const addrBlock = String(j.delivery_method || '') === 'Доставка' && j.address\n    ? `<p style=\"margin:4px 0;color:#555\">Адрес доставки: ${esc(j.address)}</p>`\n    : `<p style=\"margin:4px 0;color:#555\">Способ получения: самовывоз со склада (по будням 9:00–18:00)</p>`;\n  return `<div style=\"font-family:system-ui,-apple-system,Segoe UI,sans-serif;max-width:560px;color:#2c2c2a;line-height:1.5\">\n  <h2 style=\"margin:0 0 4px\">Заявка ${esc(j.order_number)} принята</h2>\n  <p style=\"margin:0 0 16px;color:#555\">Здравствуйте${receiver ? ', ' + receiver : ''}! Мы получили ваш заказ и запустим его в обработку после поступления оплаты.</p>\n  <table style=\"width:100%;border-collapse:collapse;font-size:15px\">${trs}\n    <tr><td style=\"padding:10px 0;font-weight:600\">К оплате</td><td align=\"right\" style=\"padding:10px 0;font-weight:600;white-space:nowrap\">${money(total)}</td></tr>\n  </table>\n  <p style=\"margin:4px 0;color:#555\">Получатель: ${receiver || '—'}</p>\n  ${addrBlock}\n  <p style=\"margin:4px 0 16px;color:#555\">Телефон: ${esc(j.phone)}</p>\n  <div style=\"background:#f6f5f1;border-radius:6px;padding:12px 14px;font-size:14px\">\n    <strong>Реквизиты для оплаты</strong><br>\n    Получатель: Nordwaren GmbH<br>\n    IBAN: DE00 0000 0000 0000 0000 00  <em>(плейсхолдер)</em><br>\n    BIC: XXXXDEXXXXX  <em>(плейсхолдер)</em><br>\n    Назначение платежа: ${esc(j.order_number)}\n  </div>\n  <p style=\"margin:16px 0 0;font-size:13px;color:#888\">Заказ можно изменить или отменить в течение двух часов после оформления — напишите нам в Telegram, назвав номер заказа.</p>\n  <p style=\"margin:8px 0 0;font-size:12px;color:#aaa\">Учебный проект ICH · Nordwaren GmbH — вымышленная компания. Письмо сформировано автоматически.</p>\n</div>`;\n})() }}",
        "options": {}
      },
      "id": "2368ee34-f410-4616-844f-dd0030d010f2",
      "name": "G1. Письмо-квитанция клиенту (SMTP)",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        4416,
        1440
      ],
      "credentials": {},
      "disabled": true,
      "onError": "continueRegularOutput",
      "notes": "G1 — АКТИВНА (18.08.2026): SMTP-кред «n8n Nordwaren» создан Dennis в UI, нода включена.\n⚠️ From обязан совпадать с ящиком креда (Gmail отвергает чужой отправитель) — сейчас you@example.com.\nЧто делает: после подтверждённого заказа шлёт клиенту подтверждение заявки + квитанцию на оплату (позиции, аксессуар, сумма, номер NW-N, реквизиты-плейсхолдеры).\nВход — E4 (текстовое эхо в чат), т.е. письмо уходит ПОСЛЕ ответа клиенту. Выхода нет.\n⚠️ Данные берутся СКВОЗНЫМИ ссылками на ноды 13 и E2a, а НЕ из $json: на входе лежит ответ Telegram API (грабля AE-14, тот же класс ошибки, что ловили в ноде 17).\nonError=continueRegularOutput — неудавшееся письмо НЕ рвёт заказ: запись в orders сделана, голос клиент уже услышал.\n⚠️ Учебный проект: реквизиты — плейсхолдеры, не реальные банковские данные."
    },
    {
      "parameters": {
        "chatId": "={{ $('13. Единая точка ответа').first().json.chat_id }}",
        "text": "=Сейчас не получается ответить голосом — техническая неполадка на нашей стороне. Оператор уже уведомлён и свяжется с вами.",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "95b17c27-201e-41e6-8434-ddba5e6fe6d3",
      "name": "15f. Озвучка недоступна — текст клиенту",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        4864,
        384
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "ПОСЛЕДНИЙ РУБЕЖ ОЗВУЧКИ (18.08.2026, решение Dennis). Срабатывает, когда УПАЛИ ОБЕ озвучки: сначала локальный Piper (15b), затем запасной ElevenLabs (15).\nДо этой ноды аварийный выход ноды 15 висел в воздухе — клиент молча не получал НИЧЕГО (найдено валидацией после переворота озвучки S-2).\nГолосовое правило модуля не нарушено: текст здесь — не замена ответа, а сообщение о техническом сбое, когда голос физически невозможен."
    },
    {
      "parameters": {
        "chatId": "YOUR_CHAT_ID",
        "text": "=⚠️ ОБЕ ОЗВУЧКИ НЕДОСТУПНЫ (Piper и ElevenLabs)\nКлиент: {{ $('13. Единая точка ответа').first().json.client_name }} · chat {{ $('13. Единая точка ответа').first().json.chat_id }}\nЗапрос: {{ $('13. Единая точка ответа').first().json.source_text }}\nОтвет, который не удалось озвучить: {{ $('14b. Фильтр PII перед озвучкой').first().json.text }}\nКлиент получил текстовое предупреждение. Проверьте Piper на Mac (YOUR_LOCAL_HOST:8088/health) и квоту ElevenLabs.",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "94c11bb7-d062-454b-8f31-6f3cc129a2b8",
      "name": "15g. Тревога: обе озвучки упали (оператор)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        5088,
        384
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "Тревога оператору при полном отказе озвучки (оба движка). Отдельно от 15c/15e: те считают СБОИ локальной озвучки (клиент при этом всё равно слышит облачный голос), а здесь клиент НЕ услышал ничего — это инцидент, требующий человека."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://YOUR_LOCAL_HOST:8088/mail",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ (() => {\n  let j = {};\n  try { j = $('13. Единая точка ответа').first().json || {}; } catch (e) {}\n  let baseName = '', baseColor = '', base = null;\n  try {\n    const e2 = $('E2a. Обработка текста при ожидании формы заказа').first().json;\n    baseName = String(e2.order_name || '');\n    baseColor = String(e2.order_color || '');\n    base = (e2.order_price === '' || e2.order_price == null) ? null : Number(e2.order_price);\n  } catch (e) {}\n  const accName = String(j.accessory_name || '');\n  const accPrice = (j.accessory_price === '' || j.accessory_price == null) ? null : Number(j.accessory_price);\n  const rows = [];\n  if (baseName) rows.push({ n: baseName + (baseColor ? ', ' + baseColor : ''), p: base });\n  if (accName) rows.push({ n: accName, p: accPrice });\n  const total = rows.reduce((s, r) => s + (r.p || 0), 0);\n  const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n  const money = (v) => (v == null ? '—' : Number(v).toFixed(2).replace('.', ',') + ' €');\n  const trs = rows.map(r => '<tr><td style=\"padding:8px 0;border-bottom:1px solid #eee\">' + esc(r.n) + '</td><td align=\"right\" style=\"padding:8px 0;border-bottom:1px solid #eee\">' + money(r.p) + '</td></tr>').join('');\n  const receiver = [j.first_name, j.last_name].filter(Boolean).map(esc).join(' ');\n  const addrBlock = String(j.delivery_method || '') === 'Доставка' && j.address\n    ? '<p style=\"margin:4px 0;color:#555\">Адрес доставки: ' + esc(j.address) + '</p>'\n    : '<p style=\"margin:4px 0;color:#555\">Способ получения: самовывоз со склада (по будням 9:00–18:00)</p>';\n  const html = '<div style=\"font-family:system-ui,-apple-system,Segoe UI,sans-serif;max-width:560px;color:#2c2c2a;line-height:1.5\">'\n    + '<h2 style=\"margin:0 0 4px\">Заявка ' + esc(j.order_number) + ' принята</h2>'\n    + '<p style=\"margin:0 0 16px;color:#555\">Здравствуйте' + (receiver ? ', ' + receiver : '') + '! Мы получили ваш заказ и запустим его в обработку после поступления оплаты.</p>'\n    + '<table style=\"width:100%;border-collapse:collapse;font-size:15px\">' + trs\n    + '<tr><td style=\"padding:10px 0;font-weight:600\">К оплате</td><td align=\"right\" style=\"padding:10px 0;font-weight:600\">' + money(total) + '</td></tr></table>'\n    + '<p style=\"margin:4px 0;color:#555\">Получатель: ' + (receiver || '—') + '</p>'\n    + addrBlock\n    + '<p style=\"margin:4px 0 16px;color:#555\">Телефон: ' + esc(j.phone) + '</p>'\n    + '<div style=\"background:#f6f5f1;border-radius:6px;padding:12px 14px;font-size:14px\"><strong>Реквизиты для оплаты</strong><br>Получатель: Nordwaren GmbH<br>IBAN: DE00 0000 0000 0000 0000 00 <em>(плейсхолдер)</em><br>BIC: XXXXDEXXXXX <em>(плейсхолдер)</em><br>Назначение платежа: ' + esc(j.order_number) + '</div>'\n    + '<p style=\"margin:16px 0 0;font-size:13px;color:#888\">Заказ можно изменить или отменить в течение двух часов после оформления — напишите нам в Telegram, назвав номер заказа.</p>'\n    + '<p style=\"margin:8px 0 0;font-size:12px;color:#aaa\">Учебный проект ICH · Nordwaren GmbH — вымышленная компания. Письмо сформировано автоматически.</p></div>';\n  return JSON.stringify({ to: String(j.email || ''), subject: 'Nordwaren — заявка ' + String(j.order_number || '') + ' принята', html: html });\n})() }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "4745695f-9699-4a8c-b980-ab91912678db",
      "name": "G1b. Письмо-квитанция через локальный сервис (Mac)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4192,
        1440
      ],
      "credentials": {},
      "onError": "continueRegularOutput",
      "notes": "АКТИВНЫЙ путь письма-квитанции (18.08.2026) — через локальный сервис на Mac.\nПочему не SMTP напрямую (нода G1 рядом, выключена): n8n на VPS Hetzner, исходящие SMTP-порты\nзакрыты провайдером — нода падала по таймауту 120 с при ВЕРНЫХ кредах (прогон 1919).\nС Mac порты открыты, сервис доступен по Tailscale, и письмо тоже уходит из своей инфраструктуры.\nЭндпоинт /mail (server.py): отправитель фиксирован конфигом, лимит 30 писем/час,\nпароль приложения в файле chmod 600, токен тот же, что у /tts и /stt.\nДанные — сквозными ссылками на 13 и E2a (на входе ответ Telegram API — грабля AE-14).\nonError=continueRegularOutput: сбой письма не рвёт заказ."
    },
    {
      "parameters": {
        "content": "## Почему часть нод выключена (серые)\n\nЭто **не мусор**, а документированная история миграции «облако → своя инфраструктура» и готовые запасные пути. Каждая включается одним переключателем.\n\n**5 / 5b / 5d** — облачное распознавание речи (OpenAI Whisper, ElevenLabs).\nЗаменены локальным whisper.cpp на Mac (нода 5c).\n\n**10a Google Sheets** — каталог остатков.\nЗаменён локальным Postgres (нода «10a. Postgres» рядом).\n\n**19 Google Sheets** — журнал обращений.\nЗаменён таблицей call_log в Postgres.\n\n**3c** — ранняя проверка данных заказа.\nЗаменена пошаговым мастером (E2a → 3e → E7sw).\n\n**G1 SMTP** — письмо-квитанция напрямую с VPS.\nХостер VPS блокирует исходящие SMTP-порты — письмо уходит через локальный сервис на Mac (нода G1b).\n\n⚠️ Не удалять как «лишние»: в заметках каждой ноды описано, чем она была и почему выключена.",
        "height": 808,
        "width": 420,
        "color": 3
      },
      "id": "940146e7-f82f-4055-a3e5-0793b4507cc2",
      "name": "ПОЧЕМУ ЕСТЬ ВЫКЛЮЧЕННЫЕ НОДЫ",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1008,
        224
      ]
    }
  ],
  "pinData": {},
  "connections": {
    "1. Telegram Trigger (voice)": {
      "main": [
        [
          {
            "node": "2. Send Chat Action (record_audio)",
            "type": "main",
            "index": 0
          },
          {
            "node": "3. IF voice present?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3. IF voice present?": {
      "main": [
        [
          {
            "node": "4. Telegram Get File (audio)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E2a. Обработка текста при ожидании формы заказа",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4. Telegram Get File (audio)": {
      "main": [
        [
          {
            "node": "5b. STT — ElevenLabs (запасной, выключен)",
            "type": "main",
            "index": 0
          },
          {
            "node": "5c. STT — whisper.cpp (локально, Tailscale)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "R. Повторить / эскалация  [R8b + circuit breaker]",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "5. STT — Whisper (auto)  [SWAP→LOCAL]": {
      "main": [
        [
          {
            "node": "6. IF transcript valid?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "R. Повторить / эскалация  [R8b + circuit breaker]",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "6. IF transcript valid?": {
      "main": [
        [
          {
            "node": "6b. Guardrails IN (jailbreak)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "R. Повторить / эскалация  [R8b + circuit breaker]",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chat Model — classifier (LM Studio, локально)": {
      "ai_languageModel": [
        [
          {
            "node": "7. Классификатор (Basic LLM Chain)",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "Structured Output Parser",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "6b. Guardrails IN (jailbreak)",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Structured Output Parser": {
      "ai_outputParser": [
        [
          {
            "node": "7. Классификатор (Basic LLM Chain)",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "7. Классификатор (Basic LLM Chain)": {
      "main": [
        [
          {
            "node": "8. Нормализация intent + keyword-fallback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "8. Нормализация intent + keyword-fallback": {
      "main": [
        [
          {
            "node": "9. Switch по intent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "9. Switch по intent": {
      "main": [
        [
          {
            "node": "10a. Google Sheets — каталог остатков (read)",
            "type": "main",
            "index": 0
          },
          {
            "node": "10a. Postgres — каталог остатков (read)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "11a. Qdrant — retrieve (Get Many)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "12. Отказ (оффтоп)  [R8a]",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "12b. Жалоба → эскалация",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "12. Отказ (оффтоп)  [R8a]",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Embeddings  [SWAP→LOCAL]": {
      "ai_embedding": [
        [
          {
            "node": "11a. Qdrant — retrieve (Get Many)",
            "type": "ai_embedding",
            "index": 0
          }
        ]
      ]
    },
    "Chat Model — final (LM Studio, локально)": {
      "ai_languageModel": [
        [
          {
            "node": "14. Голосовой стиль ответа (LLM)",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "15. TTS — ElevenLabs (Opus)  [SWAP→LOCAL]": {
      "main": [
        [
          {
            "node": "16. Telegram Send Voice (R6)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "15f. Озвучка недоступна — текст клиенту",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "11a. Qdrant — retrieve (Get Many)": {
      "main": [
        [
          {
            "node": "11b. Score-gate + контекст",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "11b. Score-gate + контекст",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "10b. Агрегация + echo-back": {
      "main": [
        [
          {
            "node": "13. Единая точка ответа",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "11b. Score-gate + контекст": {
      "main": [
        [
          {
            "node": "13. Единая точка ответа",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "12. Отказ (оффтоп)  [R8a]": {
      "main": [
        [
          {
            "node": "13. Единая точка ответа",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "R. Повторить / эскалация  [R8b + circuit breaker]": {
      "main": [
        [
          {
            "node": "13. Единая точка ответа",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "13. Единая точка ответа": {
      "main": [
        [
          {
            "node": "14. Голосовой стиль ответа (LLM)",
            "type": "main",
            "index": 0
          },
          {
            "node": "17. Audit log (redaction PII)",
            "type": "main",
            "index": 0
          },
          {
            "node": "13c. IF нужна форма заказа?",
            "type": "main",
            "index": 0
          },
          {
            "node": "13d. IF данные заказа получены?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "6b. Guardrails IN (jailbreak)": {
      "main": [
        [
          {
            "node": "7. Классификатор (Basic LLM Chain)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "6c. IF реальное нарушение (не сбой)?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "18. Журнал — сборка строки": {
      "main": [
        [
          {
            "node": "19. Журнал обращений → лист «Черновик»",
            "type": "main",
            "index": 0
          },
          {
            "node": "19. Журнал обращений → Postgres (call_log)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "15b. TTS — Piper (локально, Tailscale)": {
      "main": [
        [
          {
            "node": "16. Telegram Send Voice (R6)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "15c. Счётчик сбоев облачной озвучки",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "5c. STT — whisper.cpp (локально, Tailscale)": {
      "main": [
        [
          {
            "node": "6. IF transcript valid?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "R. Повторить / эскалация  [R8b + circuit breaker]",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "5d. Вернуть аудио для запасного STT": {
      "main": [
        [
          {
            "node": "5. STT — Whisper (auto)  [SWAP→LOCAL]",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "6c. IF реальное нарушение (не сбой)?": {
      "main": [
        [
          {
            "node": "12. Отказ (оффтоп)  [R8a]",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "7. Классификатор (Basic LLM Chain)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Memory — история диалога (10 реплик)": {
      "ai_memory": [
        [
          {
            "node": "7. Классификатор (Basic LLM Chain)",
            "type": "ai_memory",
            "index": 0
          },
          {
            "node": "14. Голосовой стиль ответа (LLM)",
            "type": "ai_memory",
            "index": 0
          }
        ]
      ]
    },
    "12b. Жалоба → эскалация": {
      "main": [
        [
          {
            "node": "13. Единая точка ответа",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "13b. IF эскалация?": {
      "main": [
        [
          {
            "node": "20. Уведомить оператора (эскалация)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "15c. Счётчик сбоев облачной озвучки": {
      "main": [
        [
          {
            "node": "15d. IF пора звать оператора?",
            "type": "main",
            "index": 0
          },
          {
            "node": "15. TTS — ElevenLabs (Opus)  [SWAP→LOCAL]",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "15d. IF пора звать оператора?": {
      "main": [
        [
          {
            "node": "15e. Предупреждение: облачная озвучка падает",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "10a. Postgres — каталог остатков (read)": {
      "main": [
        [
          {
            "node": "10b. Агрегация + echo-back",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3b. IF форма заказа?": {
      "main": [
        [
          {
            "node": "3e. IF мастер завершён?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "R. Повторить / эскалация  [R8b + circuit breaker]",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "E2a. Обработка текста при ожидании формы заказа": {
      "main": [
        [
          {
            "node": "3b. IF форма заказа?",
            "type": "main",
            "index": 0
          },
          {
            "node": "3c. IF данные заказа полные?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "13c. IF нужна форма заказа?": {
      "main": [
        [
          {
            "node": "E7-1. Способ получения (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "13d. IF данные заказа получены?": {
      "main": [
        [
          {
            "node": "E4. Подтверждение данных (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "14. Голосовой стиль ответа (LLM)": {
      "main": [
        [
          {
            "node": "14b. Фильтр PII перед озвучкой",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "14b. Фильтр PII перед озвучкой": {
      "main": [
        [
          {
            "node": "18. Журнал — сборка строки",
            "type": "main",
            "index": 0
          },
          {
            "node": "13b. IF эскалация?",
            "type": "main",
            "index": 0
          },
          {
            "node": "15b. TTS — Piper (локально, Tailscale)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3e. IF мастер завершён?": {
      "main": [
        [
          {
            "node": "E3. Заказ → Postgres (orders)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7sw. Switch по шагу мастера",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "E7sw. Switch по шагу мастера": {
      "main": [
        [
          {
            "node": "E7-1. Способ получения (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-2. Имя и фамилия (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-3. Улица и дом (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-4. PLZ и город (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-5. Страна (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-6. Телефон (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-7. Email (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-8. Сводка и подтверждение (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-cancel. Мастер отменён (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-fix. Что изменить (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "E7-11. Состав заказа (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "E3. Заказ → Postgres (orders)": {
      "main": [
        [
          {
            "node": "E3b. Прикрепить номер заказа",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "E3b. Прикрепить номер заказа": {
      "main": [
        [
          {
            "node": "13. Единая точка ответа",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "16. Telegram Send Voice (R6)": {
      "main": [
        [
          {
            "node": "10c. IF однозначный товар (кнопка)?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "10c. IF однозначный товар (кнопка)?": {
      "main": [
        [
          {
            "node": "10e. IF есть сопутствующий?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "10e. IF есть сопутствующий?": {
      "main": [
        [
          {
            "node": "10f. Кнопка подтверждения + аксессуар (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "10d. Кнопка подтверждения заказа (текст клиенту)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "E4. Подтверждение данных (текст клиенту)": {
      "main": [
        [
          {
            "node": "G1b. Письмо-квитанция через локальный сервис (Mac)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "15f. Озвучка недоступна — текст клиенту": {
      "main": [
        [
          {
            "node": "15g. Тревога: обе озвучки упали (оператор)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "G1b. Письмо-квитанция через локальный сервис (Mac)": {
      "main": [
        [
          {
            "node": "G1. Письмо-квитанция клиенту (SMTP)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false
  },
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodeGroups": [],
  "staticData": null
}