1. Карта проекта что за что отвечает
Проект маленький: одна статическая страница, один файл бэкенда на чистом Node без единой зависимости, и папка с базами знаний. Всё остальное — проверки и деплой.
| Файл | За что отвечает | Когда трогать |
|---|---|---|
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.mjs | 32 проверки маршрутизации: каждый вопрос попадает в свой раздел, посторонние — в отказ | После любой правки поиска или гейта |
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. Поток ответа от вопроса до текста
Бот — это не «запрос в большую модель». Это конвейер из семи шагов, и последние три важнее первых: они решают, можно ли вообще показать человеку то, что придумала модель.
Нормализациянижний регистр, ё→е, пунктуация в пробелы, слова короче 4 символов и слова-паразиты выбрасываются
Поиск по базекаждый раздел получает оценку: сколько весов слов вопроса совпало, с поправкой на редкость слова (idf) и на то, попало ли слово в ключевые слова раздела
Гейтниже порога или мало совпадений → отказ. Это и есть защита от «похоже на правду»
Контекст3 найденных раздела склеиваются в «Материалы:» — модель видит только их, плюс последние реплики диалога
Черновик моделиодна температура на «по базе», другая на живые тона; ответ — 1–3 предложения
Проверки заземлённостицифры, числа словами, слова-правила и лексическое покрытие против всей базы. Не прошёл → второй заход в строгом тоне
Ответпрошёл — живой пересказ. Не прошло — дословная цитата из базы. Ни того, ни другого нет — честный отказ с подсказкой по темам
Ключевой принцип: отказ лучше правдоподобной выдумки. Клиент, которому бот назвал несуществующую цену, стоит дороже, чем клиент, которому бот сказал «этого нет в базе, уточню у администратора».
3. Контексты как писать базу знаний
База знаний — единственный материал, из которого бот имеет право отвечать. Формат: один файл на нишу, внутри список разделов. Раздел = одна тема = один ответ.
{
"slug": "delivery",
"company": "Демо · доставка еды",
"refusal": "В нашей базе этого нет, поэтому ответа не будет. …",
"contact": "Оператор подскажет — напишите в мессенджер …",
"chunks": [
{
"id": "сроки",
"topic": "Сроки",
"keywords": ["долго", "время", "ждать", "заказ", "когда привезут"],
"text": "От 40 минут до часа двадцати в обычное время: горячее и гриль
считаются отдельно. Точное время называем при подтверждении заказа."
}
]
}
Правила написания раздела
- Один раздел — одна тема и один вопрос. «Сроки» и «Минимальная сумма» — два раздела. Смесь в одном тексте даёт ответ не на тот вопрос.
text— только проверяемые факты. Цены, сроки, условия, что нужно принести, до какого часа. Без «мы лучшие», «индивидуальный подход», «качественно и быстро» — модель охотно пересказывает пустоту.- Числа пиши как произносишь. «от 40 минут до часа двадцати», а не «40–80 мин»: модель воспроизводит форму записи, и сверка цифр сравнивает строки.
keywords— словами клиента, а не специалиста. Клиент пишет «долго?», «когда привезут», «минималка». Если слово есть в ключевых, раздел поднимается наверх автоматически; если его нет — вопрос может не пройти гейт.- Глаголы движения и состояния тоже в ключевые. «когда привезут», «ждут», «везут» — по ним находится естественный вопрос «сколько едет заказ».
- Запреты пиши прямо. «Безглютеновых позиций в меню нет», «Ночью доставка не работает». Раздел с запретом спасает от выдуманной услуги.
- Нет данных — так и пиши. «Точное время называем при подтверждении» лучше, чем молчание: модель честно перескажет отсутствие цифры вместо того, чтобы её сочинить.
- Объём текста раздела — 1–3 предложения. Длинный раздел размывает ответ: модель обязана уместиться в 1–3 предложения, и из простыни она вытащит что-то одно.
- Разделов на нишу — 8–12. Меньше — чаще отказы, больше — начинает путаться, какой фрагмент главный.
- Не кладите в базу то, что меняется каждый день (наличие, свободные окна, акции без срока) — для этого есть «уточнит оператор».
Хорошо и плохо
Так работает
- topic: «Подготовка к приёму»
- keywords: «что делать», «можно есть», «почистить», «на голодный»
- text: «За 2 часа до приёма — еда, за 30 минут — почистить зубы; при снятии слепка — ничего не есть за час.»
Так бот молчит или врёт
- topic: «Информация»
- keywords: «разное», «услуги», «прочее»
- text: «Мы внимательно относимся к каждому пациенту и гарантируем высокий уровень сервиса.»
Поля отказа и контакта
refusal — что видит гость, когда в базе правда ничего нет. contact — вторая фраза отказа: как связаться. Отказ всегда собирается из трёх частей: «в базе нет» (одна из трёх формулировок, чтобы не отвечать шаблонно) + подсказка по темам, которые бот знает + контакт.
4. Новая ниша за 10 минут
Код менять не нужно: ниша — это данные. Один файл на сервере и один блок в браузерном зеркале.
Шаг 1. Файл базы
- поменять
slug(латиницей, он придёт в запросе),company,refusal,contact; - написать 8–12 разделов по правилам из раздела 3;
- каждому разделу дать короткий
id— по нему ищутся правки.
Шаг 2. Зеркало в демо на странице
Добавить такой же набор в NICHES: chips (кнопки-примеры, 6–8 живых вопросов), na (вопросы, ответа на которые в базе нет — они показывают, что бот не выдумывает), refusal. Кнопка ниши берётся оттуда же.
Шаг 3. Проверка маршрутизации
Дописать свои вопросы в test/routing.mjs — по одному на раздел и 1–2 «чего нет в базе», — и прогнать:
Цель — «маршрутизация демо: N/N». Если вопрос не попадает в свой раздел, сначала правь keywords, и только потом пороги.
Шаг 4. Выкладка
Скрипт зальёт и tenants/*.json, и статику, перезапустит сервис и сам спросит бою, отвечает ли новая ниша.
tenant в запросе (slug файла). Свой лендинг клиенту — это обычно своя папка статики и тот же /botsapi; новый файл бэкенда плодить не нужно.5. Правила для ИИ системный промпт
Они прописаны в одном месте — в функции systemFor(). Это единственный файл, где боту объясняют, как себя вести.
Ты — ассистент компании «{company}». Отвечай по-русски, дружелюбно и коротко: 1–3 предложения.
Правило 1: используй ТОЛЬКО материалы ниже. Своих знаний о теме, ценах и законах не добавляй.
Правило 2: числа называй ровно так, как они стоят в материалах.
Правило 3: если в материалах про это ничего нет — ответь одной фразой: «Этого нет в базе
компании, уточню у администратора».
Правило 4: не выдумывай цены, часы и правила, которых нет в материалах. Не считай и не
перечисляй количество позиций.
Правило 5: пиши обычным текстом в одно-два сообщения: без звёздочек, решёток и маркдауна.
Правило 6: пересказывай своими словами и не начинай ответ одинаково — в диалоге реплики
должны различаться.
Правило 7: это продолжение разговора. Реплики до этого: гость — «…»; ты — «…». Если вопрос
короткий и без новых слов, он про это же.
Правило 8 (тон): <фразировка выбранного тона>
Тоны
| Тон | Как часто | temperature | Что добавляет в промпт |
|---|---|---|---|
| по базе | 55 % | 0.2 | ничего — строгая пересказка |
| по-гончому | 30 % | 0.8 | «Чуть живее, одним коротким оборотом по теме бизнеса. Факты не меняй.» |
| с юмором | 15 % | 1.0 | «Одна шутка максимум, в отдельном предложении и без фактов: ни цен, ни цифр, ни правил.» |
Тон выбирается случайно по весам — поэтому два одинаковых вопроса не дают одинаковый ответ. Строгий тон ещё и резервный: если живой черновик не прошёл проверки, модель спрашивается второй раз уже в нём.
Что можно менять смело
- приветствие и «по-русски / по-казахски» — первая строка промпта;
- длину ответа (1–3 предложения) и тон;
- вес тона, если «с юмором» мешает;
- фразу «Этого нет в базе компании, уточню у администратора» — её стоит менять под голос клиента.
Чего менять нельзя
- Правила 1–4 — это весь смысл продукта. Ослабил — получил выдуманные цены;
- Правило 5 — виджет печатает обычный текст, markdown покажет гостю звёздочки;
- «Материалы:» в конец промпта — без них модели неоткуда брать факты.
grounded()), и работает даже тогда, когда модель проигнорировала просьбу. Ни в коем случае не убирай проверки «потому что в промпте же написано».6. Поиск и гейт пороги, стоп-слова, основы слов
Гейт решает одну задачу: пускать ли вопрос в базу вообще. Он же — самая частая причина жалоб в духе «я спросил нормально, а он отказал».
| Константа | Значение | Что значит |
|---|---|---|
THRESHOLD | 0.34 | минимальная доля веса вопроса, закрытая базой. Ниже — отказ |
MIN_HITS | 2 | два совпавших слова пускают ответ почти всегда |
STRONG | 0.5 | одно совпавшее слово пускается, если оно закрывает половину веса вопроса |
ONE_WORD_MIN | 5 | вопрос из одного слова длиной от 5 символов («карты», «минималка») — полноценный вопрос |
LONG_ANSWER | 8 | длиннее 8 содержательных слов — отвечать надо по-взрослому, с проверкой покрытия |
Три вещи, которые надо понимать про оценку совпадения
- Редкое слово весит больше частого (idf). «Лактоза» в одной теме сильнее, чем «можно» в десяти.
- Слово, которого в базе нет вообще, не топит вопрос, но и не проходит бесплатно: ему считается 40 % веса. Поэтому «сколько едет заказ» находится по слову «заказ», а «где можно покурить» из одного служебного «можно» остаётся за гейтом.
- Окончания не важны. «цена» = «цены», «привезёте» = «привезут»: сверяется основа слова (начало без хвоста, но не короче 3 знаков) и 3/4 общего начала у длинных слов.
Служебные слова
STOP_STEMS (server-bots.mjs:36) — слова, которые ничего не говорят о теме: «как», «что», «скажи», «покажи», а также «можно», «нужно», «делать», «просто», «ещё». Они выбрасываются из вопроса. Добавляешь новое слово-паразит в отказе — добавь его сюда, иначе оно начнёт тянуть ответ не туда.
Диалог
Уточняющий вопрос («а реально?», «а долго?») сам по себе ни на что не похож. Поэтому бот берёт последние 6 реплик и пересобирает поиск с прошлым вопросом гостя; в промпт при этом попадает, что он продолжает разговор. Работает только если фронтенд шлёт history — в site.js это массив turns.
7. Проверки заземлённости почему ответ мог стать цитатой
После того как модель ответила, черновик проходит четыре фильтра. Не прошёл — вторая попытка в строгом тоне; и снова мимо — гость видит дословную цитату из базы, а не отказ.
| Проверка | Что ловит | Пример |
|---|---|---|
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. Положить ключ на сервер
BOTS_TOKEN=… # уже есть: доступ к журналу заявок
BOTS_API_BASE=https://api.mistral.ai/v1
BOTS_API_KEY=<ключ>
BOTS_MODEL=ministral-14b-latest
Файл читается сервисом через EnvironmentFile. В git, в чат, в скриншоты и в браузер он не попадает никогда. Ключ, показанный в переписке, считается скомпрометированным и меняется.
Шаг 3. Перезапустить и проверить
Ответ {"ok":true,"gen":true,…} — генерация жива. Страницу перезагружать не нужно: лендинг сам опрашивает /health и меняет плашку на «поиск + генерация».
Шаг 4. Живая проверка вопросом
Один вопрос через node (не через curl: кириллица в Git Bash приходит битой, и ты увидишь «отказ» там, где всё хорошо):
Что смотреть, если генерация не включилась
gen: false→ не хватает одной из трёх переменных или сервис не перезапущен;- в логе «пустой ответ провайдера: …Rate limit…» → 429: модель недоступна на твоём тарифе или ты долбишь её серией без пауз;
- в логе «Protocol "https:" not supported» → код пытается слать https-запрос http-библиотекой (в текущей версии это исправлено выбором библиотеки по протоколу);
- ответ есть, но всегда цитата → проверки заземлённости: смотри черновик в логе и текст базы.
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. Деплой и проверки
Одна команда, которая поднимает файл-лист, сверяет байты и сама спрашивает бою.
- Заливка в
/tmp/botsapi-stage: index.html, styles.css, site.js, privacy.html, server-bots.mjs, server-bots.sh, tenants/*.json. Список белый — новый файл в скрипт не попал, значит на сервер он не уехал. - Unix-концы строк для
.shи.mjs: правки из-под Windows иначе ломают bash. - server-bots.sh: приложение,
/etc/bots.env(существующий не трогает), systemd, Caddy сvalidateи откатом, права на статику, robots. - Сверка md5 каждого статического файла: локальный ≠ серверный → падение, а не «вроде залилось».
- Живые ответы: коды и типы страниц, редирект
/aistudio → /aistudio/,/health. - Тест бота:
test/live-check.mjs— ответ по базе, отказ на посторонний вопрос, приём заявки; тестовая строка из журнала вычищается.
scp. Забыл одно — получишь сервер, где код старый, а скрипт рапортует об успехе.Перед тем как заливать
$node test/routing.mjs # 32/32
Локальный запуск без BOTS_API_KEY — это не «неполноценный тест»: гейт и маршрутизация проверяются именно в офлайне, без лимитов провайдера.
Откат
Caddyfile трогается только через validate, и при неудаче остаётся предыдущая версия. Сервис поднимается заново сам (Restart=always). Откатить код — значит залить прежние файлы тем же скриптом: держи в репозитории коммит на каждую выкладку.
11. Заявки и данные
| Эндпоинт | Метод | Назначение |
|---|---|---|
/botsapi/chat | POST | вопрос демо-бота: {tenant, question, history} |
/botsapi/lead | POST | заявка с лендинга: имя, контакт, сайт, ниша, вопросы, согласие |
/botsapi/leads?token=… | GET | журнал заявок в JSON |
/botsapi/leads.csv?token=… | GET | то же, Excel-файлом (с BOM, чтобы кириллица не поехала) |
/botsapi/health | GET | жив ли бэкенд и включена ли генерация |
- Без токена заявки отдают
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 пуст | Лог пишется в файл, а не в journald | tail -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), в репозитории и в переписке их нет.
Как померить качество словами, а не ощущениями
- Доля отказов на 20 живых вопросах из переписки клиента. Больше 3 — база тонкая, дописывай разделы, а не пороги.
- Доля цитат вместо пересказа: читается из плашки
mode. Массово — проверки душат, смотри отвергнутые черновики в логе. - Разнообразие: один и тот же вопрос три раза — три разных первых слова. Одинаково — проверь, что тон вообще выбирается случайно.
- Ни одной выдуманной цифры: прогони 20 вопросов и сверь все числа с базой. Одна найденная выдумка стоит дороже всех отказов.
Коротко о главном
Модель — не продукт. Продукт — это три вещи: собранная база знаний, гейт, который не пускает в ответ то, чего в базе нет, и проверки, которые не выпускают наружу то, что модель всё-таки придумала.
Порядок внедрения всегда один: база → маршрутизация → отказ на пустом месте → и только потом генерация. Клиент, который купил модель без базы, получит красиво и неверно.