Гид по проекту · студия внедрения ИИ

Как собрать ассистента, который не выдумывает

Разбор конкретного проекта: где лежит база знаний и как её писать, где прописаны правила для модели, как подключить генерацию по API-ключу или по подписке, как устроен гейт, который не даёт боту сочинять цены, и что делать, когда он молчит или отвечает не то.

Демо на бою: aerisvisual.ru/aistudio/ Бэкенд: botsapi · 127.0.0.1:8798 Модель: Mistral · ministral-14b Проверки: routing 32/32 Обновлено: 24.10.2026

1. Карта проекта что за что отвечает

Проект маленький: одна статическая страница, один файл бэкенда на чистом Node без единой зависимости, и папка с базами знаний. Всё остальное — проверки и деплой.

локально: clients/aeris-bots/ · на сервере: /opt/botsapi/ и /var/www/aerisvisual/aistudio/
ФайлЗа что отвечаетКогда трогать
index.htmlЛендинг: герой, боли, ниши, цены, FAQ, форма заявки и разметка блока демоМенять тексты продажи, цены, добавлять блок
styles.cssСветлая «бумажная» тема, типографика, адаптив, оформление чатаВнешний вид
site.jsДемо-движок в браузере: тот же поиск и тот же гейт, что на сервере; CONFIG; базы ниш демо в NICHES; отправка заявкиДобавить нишу демо, подсказки-чипы, порог
server-bots.mjsБэкенд :8798: /botsapi/chat · /lead · /leads(.csv) · /health. Здесь поиск, гейт, правила для модели и все проверки ответаВсё, что касается «как бот думает»
tenants/<slug>.jsonБаза знаний одной ниши: разделы, ключевые слова, тексты, отказ, контактКаждый новый клиент = новый файл
server-bots.shНастройка сервера: сервис systemd, /etc/bots.env, маршрут Caddy с validate и откатом, права на статику, robotsПервый запуск, смена порта, новый маршрут
deploy-studio.shЗаливка с компьютера + живые проверки: md5 статики, коды ответов, /health, тестовая заявкаКаждый раз, когда что-то поменял
test/routing.mjs32 проверки маршрутизации: каждый вопрос попадает в свой раздел, посторонние — в отказПосле любой правки поиска или гейта
test/live-check.mjsЖивой прогон бою: ответ по базе, отказ на посторонний вопрос, приём заявкиПосле деплоя
preview.mjsЛокальный предпросмотр: отдаёт страницу и проксирует /botsapi на серверПравить вёрстку, не трогая прод
privacy.htmlСогласие на обработку данных под формой заявкиПоменялись реквизиты или оператор данных
data/leads.jsonlЖурнал заявок (одна строка = заявка)Бэкап: scp этого файла
docs/Word-версия этого гида, генерится скриптом make_doc.pyНужен документ для клиента
Два зеркала одного мозгаПоиск и гейт живут в двух местах: server-bots.mjs (когда включена генерация) и site.js (офлайн-режим прямо в браузере). Пороги, стоп-слова и правила близнецы — правил одно, правь второе, иначе демо на странице и бот на сервере начнут отвечать по-разному.

Что стоит на сервере

СлойГде
Статика лендинга/var/www/aerisvisual/aistudio/ — index.html, styles.css, site.js, privacy.html
Код и базы/opt/botsapi/ — server-bots.mjs, tenants/*.json, data/leads.jsonl
Секреты/etc/bots.env, права 600. В репозиторий и в чат не попадает никогда
Процессsystemd: сервис botsapi, слушает 127.0.0.1:8798, рестарт по Restart=always
НаружуCaddy: handle /botsapi/* { reverse_proxy 127.0.0.1:8798 }
Логи/var/log/botsapi.log — не в journald: юнит пишет через StandardOutput=append:

2. Поток ответа от вопроса до текста

Бот — это не «запрос в большую модель». Это конвейер из семи шагов, и последние три важнее первых: они решают, можно ли вообще показать человеку то, что придумала модель.

1

Нормализациянижний регистр, ё→е, пунктуация в пробелы, слова короче 4 символов и слова-паразиты выбрасываются

2

Поиск по базекаждый раздел получает оценку: сколько весов слов вопроса совпало, с поправкой на редкость слова (idf) и на то, попало ли слово в ключевые слова раздела

3

Гейтниже порога или мало совпадений → отказ. Это и есть защита от «похоже на правду»

4

Контекст3 найденных раздела склеиваются в «Материалы:» — модель видит только их, плюс последние реплики диалога

5

Черновик моделиодна температура на «по базе», другая на живые тона; ответ — 1–3 предложения

6

Проверки заземлённостицифры, числа словами, слова-правила и лексическое покрытие против всей базы. Не прошёл → второй заход в строгом тоне

7

Ответпрошёл — живой пересказ. Не прошло — дословная цитата из базы. Ни того, ни другого нет — честный отказ с подсказкой по темам

server-bots.mjs:271 — обработчик /botsapi/chat

Ключевой принцип: отказ лучше правдоподобной выдумки. Клиент, которому бот назвал несуществующую цену, стоит дороже, чем клиент, которому бот сказал «этого нет в базе, уточню у администратора».

3. Контексты как писать базу знаний

База знаний — единственный материал, из которого бот имеет право отвечать. Формат: один файл на нишу, внутри список разделов. Раздел = одна тема = один ответ.

tenants/delivery.json
{
  "slug": "delivery",
  "company": "Демо · доставка еды",
  "refusal": "В нашей базе этого нет, поэтому ответа не будет. …",
  "contact": "Оператор подскажет — напишите в мессенджер …",
  "chunks": [
    {
      "id": "сроки",
      "topic": "Сроки",
      "keywords": ["долго", "время", "ждать", "заказ", "когда привезут"],
      "text": "От 40 минут до часа двадцати в обычное время: горячее и гриль
               считаются отдельно. Точное время называем при подтверждении заказа."
    }
  ]
}

Правила написания раздела

  1. Один раздел — одна тема и один вопрос. «Сроки» и «Минимальная сумма» — два раздела. Смесь в одном тексте даёт ответ не на тот вопрос.
  2. text — только проверяемые факты. Цены, сроки, условия, что нужно принести, до какого часа. Без «мы лучшие», «индивидуальный подход», «качественно и быстро» — модель охотно пересказывает пустоту.
  3. Числа пиши как произносишь. «от 40 минут до часа двадцати», а не «40–80 мин»: модель воспроизводит форму записи, и сверка цифр сравнивает строки.
  4. keywords — словами клиента, а не специалиста. Клиент пишет «долго?», «когда привезут», «минималка». Если слово есть в ключевых, раздел поднимается наверх автоматически; если его нет — вопрос может не пройти гейт.
  5. Глаголы движения и состояния тоже в ключевые. «когда привезут», «ждут», «везут» — по ним находится естественный вопрос «сколько едет заказ».
  6. Запреты пиши прямо. «Безглютеновых позиций в меню нет», «Ночью доставка не работает». Раздел с запретом спасает от выдуманной услуги.
  7. Нет данных — так и пиши. «Точное время называем при подтверждении» лучше, чем молчание: модель честно перескажет отсутствие цифры вместо того, чтобы её сочинить.
  8. Объём текста раздела — 1–3 предложения. Длинный раздел размывает ответ: модель обязана уместиться в 1–3 предложения, и из простыни она вытащит что-то одно.
  9. Разделов на нишу — 8–12. Меньше — чаще отказы, больше — начинает путаться, какой фрагмент главный.
  10. Не кладите в базу то, что меняется каждый день (наличие, свободные окна, акции без срока) — для этого есть «уточнит оператор».

Хорошо и плохо

Так работает
  • topic: «Подготовка к приёму»
  • keywords: «что делать», «можно есть», «почистить», «на голодный»
  • text: «За 2 часа до приёма — еда, за 30 минут — почистить зубы; при снятии слепка — ничего не есть за час.»
Так бот молчит или врёт
  • topic: «Информация»
  • keywords: «разное», «услуги», «прочее»
  • text: «Мы внимательно относимся к каждому пациенту и гарантируем высокий уровень сервиса.»

Поля отказа и контакта

refusal — что видит гость, когда в базе правда ничего нет. contact — вторая фраза отказа: как связаться. Отказ всегда собирается из трёх частей: «в базе нет» (одна из трёх формулировок, чтобы не отвечать шаблонно) + подсказка по темам, которые бот знает + контакт.

База — это и есть продуктКлиент платит не за модель, а за то, что кто-то собрал его правила в 10 разделов. Пока этого нет, подключать генерацию рано: модель начёт красиво и неверно.

4. Новая ниша за 10 минут

Код менять не нужно: ниша — это данные. Один файл на сервере и один блок в браузерном зеркале.

Шаг 1. Файл базы

$cp tenants/delivery.json tenants/stomatologia.json  # затем отредактировать
  • поменять slug (латиницей, он придёт в запросе), company, refusal, contact;
  • написать 8–12 разделов по правилам из раздела 3;
  • каждому разделу дать короткий id — по нему ищутся правки.

Шаг 2. Зеркало в демо на странице

site.js:19 — объект NICHES

Добавить такой же набор в NICHES: chips (кнопки-примеры, 6–8 живых вопросов), na (вопросы, ответа на которые в базе нет — они показывают, что бот не выдумывает), refusal. Кнопка ниши берётся оттуда же.

Шаг 3. Проверка маршрутизации

Дописать свои вопросы в test/routing.mjs — по одному на раздел и 1–2 «чего нет в базе», — и прогнать:

$node server-bots.mjs & node test/routing.mjs

Цель — «маршрутизация демо: N/N». Если вопрос не попадает в свой раздел, сначала правь keywords, и только потом пороги.

Шаг 4. Выкладка

$bash deploy-studio.sh

Скрипт зальёт и tenants/*.json, и статику, перезапустит сервис и сам спросит бою, отвечает ли новая ниша.

Один бэкенд на всехКомпании различаются полем tenant в запросе (slug файла). Свой лендинг клиенту — это обычно своя папка статики и тот же /botsapi; новый файл бэкенда плодить не нужно.

5. Правила для ИИ системный промпт

Они прописаны в одном месте — в функции systemFor(). Это единственный файл, где боту объясняют, как себя вести.

server-bots.mjs:181 — systemFor(tenant, tone, dialog)
Ты — ассистент компании «{company}». Отвечай по-русски, дружелюбно и коротко: 1–3 предложения.
Правило 1: используй ТОЛЬКО материалы ниже. Своих знаний о теме, ценах и законах не добавляй.
Правило 2: числа называй ровно так, как они стоят в материалах.
Правило 3: если в материалах про это ничего нет — ответь одной фразой: «Этого нет в базе
           компании, уточню у администратора».
Правило 4: не выдумывай цены, часы и правила, которых нет в материалах. Не считай и не
           перечисляй количество позиций.
Правило 5: пиши обычным текстом в одно-два сообщения: без звёздочек, решёток и маркдауна.
Правило 6: пересказывай своими словами и не начинай ответ одинаково — в диалоге реплики
           должны различаться.
Правило 7: это продолжение разговора. Реплики до этого: гость — «…»; ты — «…». Если вопрос
           короткий и без новых слов, он про это же.
Правило 8 (тон): <фразировка выбранного тона>

Тоны

server-bots.mjs:195 — TONES
ТонКак частоtemperatureЧто добавляет в промпт
по базе55 %0.2ничего — строгая пересказка
по-гончому30 %0.8«Чуть живее, одним коротким оборотом по теме бизнеса. Факты не меняй.»
с юмором15 %1.0«Одна шутка максимум, в отдельном предложении и без фактов: ни цен, ни цифр, ни правил.»

Тон выбирается случайно по весам — поэтому два одинаковых вопроса не дают одинаковый ответ. Строгий тон ещё и резервный: если живой черновик не прошёл проверки, модель спрашивается второй раз уже в нём.

Что можно менять смело

  • приветствие и «по-русски / по-казахски» — первая строка промпта;
  • длину ответа (1–3 предложения) и тон;
  • вес тона, если «с юмором» мешает;
  • фразу «Этого нет в базе компании, уточню у администратора» — её стоит менять под голос клиента.

Чего менять нельзя

  • Правила 1–4 — это весь смысл продукта. Ослабил — получил выдуманные цены;
  • Правило 5 — виджет печатает обычный текст, markdown покажет гостю звёздочки;
  • «Материалы:» в конец промпта — без них модели неоткуда брать факты.
Правила не заменяют проверкиПромпт — это просьба. Гарантия стоит отдельно, в коде (grounded()), и работает даже тогда, когда модель проигнорировала просьбу. Ни в коем случае не убирай проверки «потому что в промпте же написано».

6. Поиск и гейт пороги, стоп-слова, основы слов

Гейт решает одну задачу: пускать ли вопрос в базу вообще. Он же — самая частая причина жалоб в духе «я спросил нормально, а он отказал».

server-bots.mjs:44 — THRESHOLD, MIN_HITS, STRONG, ONE_WORD_MIN, LONG_ANSWER
КонстантаЗначениеЧто значит
THRESHOLD0.34минимальная доля веса вопроса, закрытая базой. Ниже — отказ
MIN_HITS2два совпавших слова пускают ответ почти всегда
STRONG0.5одно совпавшее слово пускается, если оно закрывает половину веса вопроса
ONE_WORD_MIN5вопрос из одного слова длиной от 5 символов («карты», «минималка») — полноценный вопрос
LONG_ANSWER8длиннее 8 содержательных слов — отвечать надо по-взрослому, с проверкой покрытия

Три вещи, которые надо понимать про оценку совпадения

  1. Редкое слово весит больше частого (idf). «Лактоза» в одной теме сильнее, чем «можно» в десяти.
  2. Слово, которого в базе нет вообще, не топит вопрос, но и не проходит бесплатно: ему считается 40 % веса. Поэтому «сколько едет заказ» находится по слову «заказ», а «где можно покурить» из одного служебного «можно» остаётся за гейтом.
  3. Окончания не важны. «цена» = «цены», «привезёте» = «привезут»: сверяется основа слова (начало без хвоста, но не короче 3 знаков) и 3/4 общего начала у длинных слов.

Служебные слова

STOP_STEMS (server-bots.mjs:36) — слова, которые ничего не говорят о теме: «как», «что», «скажи», «покажи», а также «можно», «нужно», «делать», «просто», «ещё». Они выбрасываются из вопроса. Добавляешь новое слово-паразит в отказе — добавь его сюда, иначе оно начнёт тянуть ответ не туда.

Проверенная ловушкаКазалось бы полезное «понизить вес незнакомых слов до трети среднего» уронило маршрутизацию с 32 до 29: посторонние вопросы начинали попадать в случайные разделы («можно приехать с собакой?» → «Цены»). Незнакомое слово можно не допускать к ответу, но нельзя обесценивать совпавшее.

Диалог

Уточняющий вопрос («а реально?», «а долго?») сам по себе ни на что не похож. Поэтому бот берёт последние 6 реплик и пересобирает поиск с прошлым вопросом гостя; в промпт при этом попадает, что он продолжает разговор. Работает только если фронтенд шлёт history — в site.js это массив turns.

7. Проверки заземлённости почему ответ мог стать цитатой

После того как модель ответила, черновик проходит четыре фильтра. Не прошёл — вторая попытка в строгом тоне; и снова мимо — гость видит дословную цитату из базы, а не отказ.

server-bots.mjs:125–178 — numbersOk · wordNumbersOk · modalsOk · lexicalOk
ПроверкаЧто ловитПример
numbersOkЛюбое число ответа обязано стоять в базе или в вопросе — цифрами или словами«1 часа 20 минут» проходит, потому что в базе есть «двадцати»; «от 25 минут» — нет
wordNumbersOk«несколько», «пара», «три» — только если так же сказано в базе или в вопросе«несколько мастеров» не появится, если в базе этого слова нет
modalsOk«можно», «нельзя», «обязательно», «нужно» — только если слово вообще встречается в базе или в вопросеРазрешение на услугу, которой в базе нет, отсекается здесь
lexicalOkДоля слов ответа, которые есть в базе. Строгий тон — 0.4, живой — 0.32; считаем по всей базе, а не по трём фрагментамПересказ законного факта своими словами проходит; «раздел Контакты», которого в базе нет, — нет

Почему по всей базе: законный пересказ часто использует слова из соседнего раздела. Если сравнивать только с тремя найденными фрагментами, хорошие ответы начнут отбиваться, и бот замолчит цитатами — это ровно тот случай, когда «всё работает, но скучно».

Как понять, что произошлоВ плашке под ответом написано: модель по базе, модель живым тоном, по базе дословно или gate. Если «по базе дословно · черновик модели не подтвердился» стало массовым — смотри в /var/log/botsapi.log: там печатается сам отвергнутый черновик.

8. Модель по API-ключу три строки и рестарт

Без ключа бот отвечает дословными фрагментами базы — это рабочий режим, а не поломка. Генерация включается тремя переменными окружения, код не меняется.

Шаг 1. Купить ключ

Подойдёт любой OpenAI-совместимый эндпоинт: Mistral, Yandex GPT, ProxyAPI, OpenRouter, сервер провайдера GPU. Нужны три вещи: адрес вида https://…/v1, ключ и имя модели.

Шаг 2. Положить ключ на сервер

$ssh root@ваш-vps
#nano /etc/bots.env
BOTS_TOKEN=…            # уже есть: доступ к журналу заявок
BOTS_API_BASE=https://api.mistral.ai/v1
BOTS_API_KEY=<ключ>
BOTS_MODEL=ministral-14b-latest
$chmod 600 /etc/bots.env

Файл читается сервисом через EnvironmentFile. В git, в чат, в скриншоты и в браузер он не попадает никогда. Ключ, показанный в переписке, считается скомпрометированным и меняется.

Шаг 3. Перезапустить и проверить

$systemctl restart botsapi
$curl -s http://127.0.0.1:8798/botsapi/health

Ответ {"ok":true,"gen":true,…} — генерация жива. Страницу перезагружать не нужно: лендинг сам опрашивает /health и меняет плашку на «поиск + генерация».

Шаг 4. Живая проверка вопросом

Один вопрос через node (не через curl: кириллица в Git Bash приходит битой, и ты увидишь «отказ» там, где всё хорошо):

$node -e "fetch('https://aerisvisual.ru/botsapi/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({tenant:'delivery',question:'Сколько едет заказ?'})}).then(r=>r.json()).then(d=>console.log(d.mode,d.tone,d.answer))"

Что смотреть, если генерация не включилась

  • gen: false → не хватает одной из трёх переменных или сервис не перезапущен;
  • в логе «пустой ответ провайдера: …Rate limit…» → 429: модель недоступна на твоём тарифе или ты долбишь её серией без пауз;
  • в логе «Protocol "https:" not supported» → код пытается слать https-запрос http-библиотекой (в текущей версии это исправлено выбором библиотеки по протоколу);
  • ответ есть, но всегда цитата → проверки заземлённости: смотри черновик в логе и текст базы.
Паузы между запросамиБесплатные тарифы считают не только токены, но и запросы в минуту. Автотест из 30 вопросов подряд даст серию 429, которую легко принять за «ключ не работает». Между запросами — 6–9 секунд.

9. Подписка и своя модель что выбрать клиенту

Три схемы, между которыми выбирают по двум признакам: куда уходят переписки и сколько стоит ответ.

СхемаКак подключаетсяПлюсыМинусы
API-ключ провайдератри строки в /etc/bots.envбыстро, платим за факт, модель свежаяпереписка уходит наружу; лимиты и тарифы чужие
Подписка сервиса (обёртка с биллингом)то же самое: адрес, ключ, модельодин счёт, нет возни с тарифамизависимость от чужого SLA; ключ всё равно у тебя
Своя модель на своём серверелокальный OpenAI-совместимый эндпоинт (http://127.0.0.1:…/v1)переписка не покидает сервер клиента; нет платы за запроснужен GPU/сервер и присмотр; модель слабее облачной

Код от выбора схемы не меняется вообще: providerChat() говорит по OpenAI-совместимому протоколу и сам выбирает http/https по адресу. Отличие локальной модели — адрес на http:// и имя модели, которое отдаёт GET /v1/models.

Что сказать клиенту про персональные данные

  • в форме заявки собирается имя и контакт по согласию, оператор данных — сам клиент, мы обработчик;
  • в провайдера уходит текст вопроса и фрагменты базы, а не паспортные данные;
  • если это критично — схема «своя модель», и переписка никуда не уходит.

Сколько это стоит в деньгах

Один ответ = вопрос + три фрагмента базы + системный промпт ≈ 400–700 токенов на вход и до 260 на выход. Даже на платном тарифе это копейки на вопрос; реальная статья расходов — не токены, а работа по сбору базы и сопровождению. Поэтому и продаётся база + настройка, а не «доступ к нейросети».

10. Деплой и проверки

Одна команда, которая поднимает файл-лист, сверяет байты и сама спрашивает бою.

$bash deploy-studio.sh
  1. Заливка в /tmp/botsapi-stage: index.html, styles.css, site.js, privacy.html, server-bots.mjs, server-bots.sh, tenants/*.json. Список белый — новый файл в скрипт не попал, значит на сервер он не уехал.
  2. Unix-концы строк для .sh и .mjs: правки из-под Windows иначе ломают bash.
  3. server-bots.sh: приложение, /etc/bots.env (существующий не трогает), systemd, Caddy с validate и откатом, права на статику, robots.
  4. Сверка md5 каждого статического файла: локальный ≠ серверный → падение, а не «вроде залилось».
  5. Живые ответы: коды и типы страниц, редирект /aistudio → /aistudio/, /health.
  6. Тест бота: test/live-check.mjs — ответ по базе, отказ на посторонний вопрос, приём заявки; тестовая строка из журнала вычищается.
Правило двух списковЛюбой новый файл (модуль бэкенда, база, страница) добавляется в два места: в цикл проверок обязательных файлов и в команду scp. Забыл одно — получишь сервер, где код старый, а скрипт рапортует об успехе.

Перед тем как заливать

$node --check server-bots.mjs && node --check site.js
$node server-bots.mjs  # локально, без ключа
$node test/routing.mjs  # 32/32

Локальный запуск без BOTS_API_KEY — это не «неполноценный тест»: гейт и маршрутизация проверяются именно в офлайне, без лимитов провайдера.

Откат

Caddyfile трогается только через validate, и при неудаче остаётся предыдущая версия. Сервис поднимается заново сам (Restart=always). Откатить код — значит залить прежние файлы тем же скриптом: держи в репозитории коммит на каждую выкладку.

11. Заявки и данные

ЭндпоинтМетодНазначение
/botsapi/chatPOSTвопрос демо-бота: {tenant, question, history}
/botsapi/leadPOSTзаявка с лендинга: имя, контакт, сайт, ниша, вопросы, согласие
/botsapi/leads?token=…GETжурнал заявок в JSON
/botsapi/leads.csv?token=…GETто же, Excel-файлом (с BOM, чтобы кириллица не поехала)
/botsapi/healthGETжив ли бэкенд и включена ли генерация
  • Без токена заявки отдают 403. Токен — из /etc/bots.env, в переписку его не печатают.
  • Хранение: /opt/botsapi/data/leads.jsonl, одна строка = одна заявка. Бэкап = забрать этот файл.
  • Форма требует согласие на обработку данных; без него заявка не уходит, и это же проверяет сервер, а не только браузер.

12. Неполадки симптом → причина → что делать

СимптомПричинаЧто делать
Бот молчит / плашка «поиск без модели»Не задана одна из трёх переменных или сервис не перезапущенsystemctl restart botsapi, затем /health
В логе Rate limit exceededМодель недоступна на тарифе или серия запросов без паузСменить модель на доступную, тесты — с паузами 6–9 с
«Протокол https не поддерживается» в логеhttps-адрес открыли http-клиентомПроверить, что providerChat() выбирает библиотеку по протоколу URL
Вместо ответа — длинная цитатаЧерновик не прошёл проверки заземлённостиПосмотреть отвергнутый текст в /var/log/botsapi.log; чаще всего не хватает чисел или слов в базе
«Этого нет в базе» на нормальный вопросГейт не собрал вес: нет ключевых слов, вопрос из служебных словДобавь слова клиента в keywords раздела. Пороги — последнее, куда стоит лезть
Отказ там, где раньше был ответПравка стоп-слов или весов сломала маршрутизациюnode test/routing.mjs — покажет, какие вопросы уехали
В ответе звёздочки **текст**Markdown от моделиСнятие разметки в providerChat() + Правило 5 в промпте
Локальный тест «упал», на бою всё нормальноКириллица в curl из Git Bash приходит битой → score 0 → отказПроверять через node, не через curl
journalctl -u botsapi пустЛог пишется в файл, а не в journaldtail -50 /var/log/botsapi.log
Страница обновилась, а демо ведёт себя по-старомуКэш браузера или файл не долетелmd5-сверка в деплое; жёсткое обновление страницы
Один клиент отвечает, другой нетВ запросе едет чужой tenant или файл базы не залилиls /opt/botsapi/tenants, сверить slug
bash спотыкается на серверном скриптеWindows-концы строк CRLFДеплой сам делает tr -d "\r"; правь локально, не на сервере
Caddy не принимает маршрутТабы вместо пробелов, незакрытая скобкаСкрипт делает caddy validate и откат; правь файл целиком, не на живую

13. Чек-лист перед сдачей клиенту

База

8–12 разделов, в каждом проверяемый факт; ключевые слова — словами клиента; запреты записаны прямо; нет обещаний без цифр.

Маршрутизация

Свои 8–12 вопросов в test/routing.mjs попадают в нужные разделы, 2 посторонних собирают отказ. Итог N/N.

Отказ

refusal и contact переписаны под клиента; бот не обещает «перезвоним за 5 минут», если этого никто не делает.

Генерация

/health → gen:true; три разных вопроса даны тремя разными формулировками; ни в одном ответе нет числа, которого нет в базе.

Цены и часы

Сверены с прайсом клиента сегодня. Это единственные факты, за выдумку которых спрашивают по-настоящему.

Форма заявки

Согласие включено, почта куда падает заявка проверена тестовой строкой, тестовая строка удалена.

Деплой

bash deploy-studio.sh прошёл до конца, md5 совпали, живые проверки зелёные.

Секреты

Ключ и токен только в /etc/bots.env (600), в репозитории и в переписке их нет.

Как померить качество словами, а не ощущениями

  1. Доля отказов на 20 живых вопросах из переписки клиента. Больше 3 — база тонкая, дописывай разделы, а не пороги.
  2. Доля цитат вместо пересказа: читается из плашки mode. Массово — проверки душат, смотри отвергнутые черновики в логе.
  3. Разнообразие: один и тот же вопрос три раза — три разных первых слова. Одинаково — проверь, что тон вообще выбирается случайно.
  4. Ни одной выдуманной цифры: прогони 20 вопросов и сверь все числа с базой. Одна найденная выдумка стоит дороже всех отказов.

Коротко о главном

Модель — не продукт. Продукт — это три вещи: собранная база знаний, гейт, который не пускает в ответ то, чего в базе нет, и проверки, которые не выпускают наружу то, что модель всё-таки придумала.

Порядок внедрения всегда один: база → маршрутизация → отказ на пустом месте → и только потом генерация. Клиент, который купил модель без базы, получит красиво и неверно.