aichat-go
Мультитенантная платформа AI-бота для продаж
3-этапная воронка продаж
Общая консультация → предметный Q&A → захват лида. Автоматические переходы между этапами на основе накопления URL-скора и LLM-классификации.
Гибридный поиск
Плотные эмбеддинги + BM25-разреженные векторы с DBSF-слиянием в Qdrant. Опциональный реранкинг cross-encoder от Cohere и расширение соседями для более полного контекста.
Маршрутизация LLM по уровням
Уровень Чат для ответов клиентам, уровень Извлечение для лёгкой классификации. Поддержка любого OpenAI-совместимого API. Пул ключей с round-robin ротацией.
Умные follow-up
4-шаговая последовательность таймеров (5 мин / 15 мин / 40 мин / 24 ч) с LLM-классификацией клиента. Автоматически определяет холодные, горячие и завершённые диалоги.
Мультитенантность
Полная изоляция проектов через составные ключи. Каждый арендатор получает собственного бота, коллекции, промпты и группу для лидов при общей инфраструктуре.
Инкрементальный краулер
4-фазный конвейер (скачивание / чанкинг / эмбеддинг / загрузка) с возобновлением по URL. Поддержка HTML, PDF, DOCX, XLSX. Семантический чанкинг с LLM-метками тем.
Основные понятия
- Проект
- Арендатор (tenant) — один бизнес со своим ботом, базой знаний, промптами и CRM-целью. Все пути данных используют составные ключи
(chat_id, project_id).
- Этап
- Позиция в 3-этапной воронке продаж. Вычисляется из полей
FinishedиDeterminedURL— отдельного поля этапа нет, что исключает рассинхронизацию.
- База знаний
- Коллекции Qdrant для каждого проекта с чанкированным контентом сайта, содержащим как плотные (embedding), так и разреженные (BM25) векторы.
- URL
- URL страницы услуги, идентифицирующий конкретную услугу, о которой спрашивает пользователь. Агент «фиксируется» на URL для перехода от общей к предметной консультации.
- Лид
- Результат работы: контактная информация + резюме диалога + история чата + файлы, отправляемые в CRM-группу для лидов.
- Таймер
- Автоматическая последовательность follow-up, срабатывающая при молчании пользователя. Классифицирует вовлечённость через LLM и либо отправляет напоминание, либо отправляет лид.
- Уровень (Tier)
- Категория маршрутизации LLM: Чат (клиентские ответы), Извлечение (лёгкие задачи), embedding (векторы), реранкер (cross-encoder).
Обзор архитектуры
Сквозной поток данных от webhook Telegram до отправки лида в CRM с гибридным поиском и маршрутизацией LLM по уровням.
Finished и DeterminedURL. Метрики применяются через декорирование (ноль импортов Prometheus в ядре). Все пути данных используют составные ключи (chat_id, project_id) для изоляции арендаторов.
Детали компонентов
| Компонент | Расположение | Назначение |
|---|---|---|
| Gateway | internal/gateway/ | Нормализация TelegramUpdate в domain.Message (текст, подпись, файлы, отправитель) |
| Агент | internal/agent/ | 3-этапный автомат диалога с обработкой команд |
| LLM по уровням | internal/llm/ | Маршрутизация вызовов Чат/Извлечение/embedding к различным комбинациям провайдер+модель |
| Гибридный поиск | internal/search/ | Плотные эмбеддинги + BM25-разреженные векторы с DBSF-слиянием и реранкингом cross-encoder |
| CRM | internal/crm/ | Отправка лидов в Telegram-группу (резюме + история + медиа) |
| Таймер | internal/timer/ | Автоматические follow-up последовательности с LLM-классификацией |
| Краулер | internal/crawler/ | 4-фазный конвейер: скачивание, чанкинг, эмбеддинг, загрузка в Qdrant |
| Метрики | internal/metrics/ | Обёртки Prometheus (без связи с основными пакетами) |
| Администрирование | internal/admin/ | Обработчик команд админ-группы для кросс-проектного управления |
| i18n | internal/i18n/ | Локализация (ru/en) с встроенными файлами локалей на этапе компиляции |
Жизненный цикл запроса (от webhook до ответа)
Каждое входящее сообщение Telegram проходит следующий путь:
- Приём webhook —
POST /webhook/{tg_api_key}поступает на публичный HTTP-сервер (:8080). API-ключ в URL маршрутизирует к нужному бандлу проекта. - Нормализация в Gateway —
TelegramUpdateпреобразуется вdomain.Messageс извлечением текста, подписи, файлов и информации об отправителе. - Загрузка состояния — Состояние чата загружается из PostgreSQL по составному ключу
(chat_id, project_id). - Проверка команд —
/startсбрасывает состояние и возвращает приветствие./debugпереключает режим отладки. - Сбор файлов — Вложения накапливаются в состоянии. Файлы без текста получают немедленное подтверждение без LLM-вызовов.
- Диспетчеризация по этапу — На основе вычисленного состояния (
CurrentStage()) сообщение направляется соответствующему обработчику этапа. - Сохранение состояния — Обновлённое состояние записывается обратно в PostgreSQL.
- Сброс таймера — Таймер follow-up сбрасывается на шаг 0 для данного чата.
Структура проекта
cmd/
bot/ # Основной бинарник бота
main.go # Связка: БД, LLM по уровням, бандлы проектов, HTTP-сервер
config.go # Загрузка JSON-конфига с fallback на env-переменные
crawler/ # CLI-индексатор базы знаний
main.go # Подкоманды: init-project, seed, add-urls, register
cli/ # Локальный readline-интерфейс для тестирования
main.go # Тот же конвейер агента, файловый CRM-вывод
internal/
domain/ types.go # Stage, Message, ChatState, Lead, SearchResult
agent/ agent.go # Диспетчеризация HandleMessage
stages.go # Обработчики Этапов 1/1.5/2/3
commands.go # /start, /debug
lead_commands.go # Группа лидов: /prompt, /stats
llm/ tiered.go # TieredClient: маршрутизация по уровням
chain.go # Цепочка провайдеров с retry + fallback
openai.go # OpenAI-совместимый провайдер
claude.go # Claude/Anthropic провайдер
embedding_adapter.go # Форматирование текста для конкретных моделей
search/ hybrid.go # DBSF Qdrant + реранкинг + соседи
reranker.go # Клиент cross-encoder Cohere
bm25.go # BM25Encoder, Tokenize
db/ postgres.go # Реализация PostgreSQL
gateway/ telegram.go # Нормализация TelegramUpdate
telegram/ client.go # HTTP-клиент, retry при 429
crm/ telegram.go # TelegramCRM: резюме + история + медиа
timer/ timer.go # Планировщик, последовательности, LLM-классификация
metrics/ metrics.go # Определения метрик
llm.go # Обёртка InstrumentedLLMClient
crawler/ pipeline.go # Возобновляемый 4-фазный конвейер
chunker.go # Семантический + простой чанкинг
indexer.go # Upsert в Qdrant
i18n/ i18n.go # T(), Tf(), встроенные локали
locales/ ru.json, en.json
migrations/ 001_initial.sql, 002_bm25_index.sql, 003_timers.sql
nix/ module.nix, example-configuration.nix
flake.nix Makefile
Точки расширения
Новый LLM-провайдер
Реализуйте интерфейс Provider
internal/llm/
Новый CRM-бэкенд
Реализуйте интерфейс CRM (паттерн мульти-CRM диспетчер)
internal/crm/
Новый Gateway
Нормализация в domain.Message + webhook-маршрут
internal/gateway/
Свой поисковый бэкенд
Реализуйте интерфейс KnowledgeBase
internal/search/
Диалоговый поток
Агент реализует 3-этапный автомат воронки продаж. Каждое входящее сообщение направляется обработчику этапа на основе вычисленного состояния.
Общая консультация
Определение, о какой услуге спрашивает пользователь, через многоходовый диалог.
- Перефразирование запроса — LLM генерирует 3 поисковые вариации (уровень Извлечение, temp 0.3)
- Гибридный поиск — по каждой вариации, limit=5 (с реранкером) или 2 (без), minScore=0.5
- DBSF-слияние — Qdrant объединяет dense + sparse результаты, сохраняя абсолютный сигнал релевантности
- Реранкинг — cross-encoder Cohere переоценивает кандидатов (если настроен)
- Расширение соседями — соседние чанки N-1/N+1 извлекаются для контекста
- Ответ LLM — prompt1 + история + топ-5 результатов поиска (уровень Чат, temp 0.3)
- Накопление URL-скора — URL каждого результата получает
+= scoreпо ходу диалога
Определение URL
Когда ни один URL не превысил порог автовыбора, решение принимает LLM.
- Автовыбор — если скор любого URL ≥ 3.0, фиксируем немедленно
- LLM-определение — уровень Извлечение (temp 0.7), анализирует историю + результаты поиска + URL-скоры
- Вывод:
URL: <url>→ фиксация и переход к Этапу 2 - Вывод:
QUESTION: <text>→ уточняющий вопрос, остаёмся на Этапе 1
Предметная консультация
Углублённые вопросы-ответы по зафиксированной услуге с параллельным извлечением контактов.
- Полная страница источника — извлекается через
GetByURLиз коллекции source - Извлечение цен — однократный LLM-вызов при входе в Этап 2, кешируется в
PriceSummary(Извлечение, temp 0.1) - Ответ LLM — prompt2 + страница источника + кешированные цены + результаты поиска + история (уровень Чат, temp 0.3)
- Извлечение контактов — 3 параллельных LLM-вызова (уровень Извлечение, temp 0.1):
- Способ связи + значение (
METHOD: x / VALUE: yилиNONE) - Название города (или
NONE) - Решение о завершении (
YESилиNO)
- Способ связи + значение (
- Запрос контакта — кнопка Telegram
request_contactотправляется при первом взаимодействии на Этапе 2
Генерация лида и отправка
Формирование лида из диалога и отправка в CRM.
- Установка Finished=true — немедленно сохраняется в БД (двухфазное сохранение)
- Резюме лида — LLM генерирует резюме с именем, городом, услугой, контактом (Извлечение, temp 0.2)
- Прощальный ответ — прощальное сообщение пользователю (уровень Чат, temp 0.3)
- Отправка в CRM — пересланное сообщение + резюме + документ истории + медиафайлы
- Установка LeadSent=true — сохраняется в БД (предотвращает повторную отправку)
Детали двухфазного сохранения лида
Finished=true записывается в базу данных до отправки в CRM, а LeadSent=true записывается после. Это предотвращает дублирование лидов при параллельных webhook-запросах или срабатываниях таймера, читающих устаревшее состояние. Если бот падает между двумя записями, система таймеров повторяет отправку в CRM при следующем срабатывании или при перезапуске.
Сбой между этими состояниями → таймер повторяет отправку в CRM при следующем срабатывании или перезапуске
Обработка загрузки файлов
Когда пользователь отправляет файл без текста, бот немедленно подтверждает получение («Принято! Дайте знать, когда закончите.») без обращения к LLM. Файлы накапливаются в состоянии чата и прикрепляются к лиду при его генерации.
Медиафайлы в лиде группируются по типу для наглядного представления:
- Фото + видео — отправляются как медиа-альбом (Telegram группирует их визуально)
- Документы — отправляются как альбом документов
- Голосовые / видео-заметки — отправляются поштучно (Telegram не поддерживает альбомы для них)
Кнопка запроса контакта
При первом взаимодействии на Этапе 2 бот отправляет кнопку Telegram request_contact вместе с ответом. Это позволяет пользователям поделиться номером телефона одним нажатием.
- Отправляется только один раз за диалог (отслеживается через
state.UserSettings.ContactRequested) - Полученный контакт хранится в состоянии чата (не в истории) для конфиденциальности
- Контактные данные включаются в резюме лида и промпты извлечения
- Когда пользователь говорит «возьмите мой телефон из Telegram», LLM может использовать реальные данные контакта
Мягкий подход к чек-листу
Бот мягко напоминает пользователю поделиться городом и номером телефона, но не блокирует генерацию лида. Если пользователь предоставил достаточно информации для качественного лида, finish=true допускается даже без всех полей. Это избегает раздражения пользователей, которые предпочитают не делиться определённой информацией, при этом максимизируя качество лида.
Поисковый конвейер
Гибридный поиск, объединяющий плотные эмбеддинги и BM25-разреженные векторы с DBSF-слиянием, реранкингом cross-encoder и расширением соседями.
Перефразирование запроса
LLM генерирует 3 поисковые вариации из вопроса пользователя (уровень Извлечение, temp 0.3). Улучшает полноту выдачи, захватывая различные формулировки, которые могут совпадать с разными чанками базы знаний.
Гибридный поиск
Каждая вариация ищется через Qdrant prefetch с плотными эмбеддингами (косинусное сходство, 1024 измерения) и BM25-разреженными векторами. Встроенное в Qdrant DBSF (Distribution-Based Score Fusion) объединяет результаты, сохраняя абсолютный сигнал релевантности в отличие от ранговой RRF. Каждый prefetch извлекает limit * 2 кандидатов для алгоритма слияния.
Дедупликация
Результаты всех 3 вариаций запроса объединяются. Дубликаты (по текстовому содержимому) удаляются с сохранением экземпляра с наивысшим скором.
Реранкинг
Cross-encoder Cohere (настраивается через models.reranker) переоценивает всех кандидатов. Извлекает с запасом 5 результатов на вариацию запроса (3x от итогового лимита), чтобы дать реранкеру больше материала. Все результаты участвуют в подсчёте URL-скора до отсечения контекста.
Отсечение контекста
Оставляются топ-5 результатов для контекстного окна LLM. URL-скоры накапливаются из всех результатов (до и после отсечения) в state.URLsCounter.
Расширение соседями
Для каждого результата соседние чанки (позиции N-1 и N+1 с того же URL) извлекаются через Qdrant scroll и объединяются в порядке позиций. Дешёвый запрос только по метаданным, без векторных вычислений.
Визуальная схема поиска
cosine, 1024 dims
ключевые слова
Distribution-Based Score Fusion (сохраняет абсолютную релевантность)
Cohere rerank (опционально)
позиции N-1 / N+1
Коллекции Qdrant для каждого проекта
| Коллекция | Векторы | Назначение |
|---|---|---|
{project}_content_hybrid | Dense + Sparse | Чанкированный контент с эмбеддингами, обогащёнными темой. Цель поиска для гибридных запросов. |
{project}_source | Только Dense | Полное содержимое страниц с реальными эмбеддингами. Используется на Этапе 2 для контекста определённого URL и сопоставления на уровне страниц. |
Детали реализации BM25
- Стандартная формула BM25:
IDF * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl/avgDL)) - Параметры: k1=1.5, b=0.75
- Токенизатор: Unicode-совместимый (кириллица + латиница), приведение к нижнему регистру, разбивка по не-буквенным/не-цифровым символам
- Хранение: словарь, IDF, avgDL, numDocs хранятся как JSONB в PostgreSQL (таблица
bm25_indexes) - Кеш в памяти: загружается из БД при первом использовании для каждого проекта, инвалидируется при обновлениях краулера
Карта LLM-вызовов
Все 14 точек вызова LLM в системе, организованные по уровням и назначению.
Уровни
| # | Назначение | Уровень | Temp | Расположение |
|---|---|---|---|---|
| 1 | Ответ Этапа 1 — общая консультация с контекстом из базы знаний | Чат | 0.3 | stages.go |
| 2 | Ответ Этапа 2 — предметный, с полной страницей источника + ценами | Чат | 0.3 | stages.go |
| 3 | Завершение Этапа 3 — прощание после захвата лида | Чат | 0.3 | stages.go |
| 4 | Перефразирование запроса — 3 поисковые вариации | Извлечение | 0.3 | stages.go |
| 5 | Определение URL — выбор услуги или уточняющий вопрос | Извлечение | 0.7 | stages.go |
| 6 | Извлечение контакта — способ + значение | Извлечение | 0.1 | stages.go |
| 7 | Извлечение города | Извлечение | 0.1 | stages.go |
| 8 | Определение завершения — YES/NO | Извлечение | 0.1 | stages.go |
| 9 | Резюме лида — для отправки в CRM | Извлечение | 0.2 | stages.go |
| 10 | Классификация таймера — COLD/HOT/FINISHED | Извлечение | 0.3 | timer.go |
| 11 | Follow-up таймера — контекстное напоминание (условно) | Извлечение | 0.5 | timer.go |
| 12 | Резюме лида от таймера — для отправки лида по таймеру | Извлечение | 0.2 | timer.go |
| 13 | Семантический чанкинг — определение границ на странице | Извлечение | 0.1 | chunker.go |
| 14 | Извлечение цен — однократно при входе в Этап 2, кешируется | Извлечение | 0.1 | stages.go |
Диаграмма потока вызовов
// Поток обработки сообщения пользователя
Сообщение пользователя
→ #4 rewriteQuery (Извлечение) → поисковые вариации
→ гибридный поиск (DBSF-слияние, dense + BM25)
→ реранкер (cross-encoder Cohere, если настроен)
→ топ-5 результатов → расширение соседями (позиции N-1/N+1)
→ #5 determineURL (Извлечение) → выбор услуги или уточнение
→ при входе в Этап 2: #14 извлечение цен (Извлечение, однократно, кеш)
→ #1 или #2 Ответ этапа (Чат) → ответ пользователю
→ #6 + #7 + #8 extractContact (3x Извлечение, параллельно)
→ если завершено:
→ #9 generateSummary (Извлечение) → резюме для CRM
→ #3 Завершение Этапа 3 (Чат) → прощание
// Поток таймера
Срабатывание таймера
→ #10 classifyStatus (Извлечение) → cold/hot/finished
→ #11 generateFollowUp (Извлечение, условно)
→ если завершено: #12 summary (Извлечение) → лид в CRM
// Поток краулера
Краулер
→ #13 семантическое определение границ на странице (Извлечение)
→ разрезание исходного текста по обнаруженным границам
→ добавление темы при эмбеддинге ("Topic: X\n\n...")
→ пакетный эмбеддинг (32 чанка за API-вызов)
Детали маршрутизации провайдеров
TieredClient маршрутизирует вызовы Complete на основе req.Tier:
TierChat→ цепочка Чат (модели для клиентов)TierExtract→ цепочка Извлечение (лёгкие модели). При отсутствии конфигурации переключается на цепочку Чат.Embed→ цепочка эмбеддингов (обёрнута вAdaptedProviderдля форматирования текста под конкретную модель)
Каждая цепочка имеет независимый retry/fallback. Формула задержки: 2^attempt + 10% jitter, максимум 60 с. Пул ключей ротирует API-ключи round-robin.
Адаптеры эмбеддингов
AdaptedProvider оборачивает провайдер эмбеддингов и форматирует текст в зависимости от режима (документ или запрос). Адаптер выбирается автоматически по имени модели. Модели E5-instruct получают префикс Instruct: ...\nQuery: ... для запросов. Авторазбиение по лимиту токенов: если чанк превышает лимит модели, текст разбивается по предложениям, каждая половина эмбеддится рекурсивно, и векторы усредняются + L2-нормализуются.
Конвейер краулера
4-фазный инкрементальный конвейер, который скачивает, чанкирует, эмбеддит и загружает контент в Qdrant. Промежуточные результаты сохраняются для каждого URL для возобновления.
Скачивание страниц
HTTP GET с параллельными воркерами (по умолчанию: 3). Поддержка HTML, PDF, DOCX, XLSX через автоопределение. Каждая страница сохраняется в 1_pages/{urlhash}.json. Логика пропуска: страницы с существующими файлами пропускаются при повторном запуске.
Семантический чанкинг
Определение границ через LLM: выводит якорные фразы TOPIC: X | STARTS: Y для каждой страницы (уровень Извлечение, temp 0.1). Исходный текст разрезается по обнаруженным границам. Каждый чанк получает метку темы. Сохраняется в 2_chunks/{urlhash}.json. Fallback на разбивку по абзацам с --no-llm-chunk.
Пакетный эмбеддинг
32 чанка за API-вызов. Тема добавляется перед эмбеддингом: "Topic: X\n\n{text}". BM25-разреженные векторы перестраиваются из всех чанков (IDF требует полного корпуса). Сохраняется в 3_embeds/{urlhash}.json + bm25.json. Авторазбиение по лимиту токенов обрабатывает слишком большие чанки.
Загрузка в Qdrant
Upsert в Qdrant пакетами по 50 точек. Страницы источников загружаются с реальными эмбеддингами в {name}_source. Чанкированный контент — в {name}_content_hybrid. BM25-индекс сохраняется в PostgreSQL. Данные конвейера архивируются в project-archives/{name}.tar.gz.
sha256(url)[:16].
Структура хранения
new-projects/{project-name}/
1_pages/{urlhash}.json # одна Page на URL
2_chunks/{urlhash}.json # []Chunk на URL
3_embeds/{urlhash}.json # []EmbeddedChunk на URL
3_embeds/bm25.json # снимок BM25-энкодера
Поддержка типов документов
| Формат | Библиотека | Примечания |
|---|---|---|
| HTML | go-readability v2 | Алгоритм Firefox Reader View. Fallback: обход DOM (main → article → body, удаление nav/header/footer/script) |
| ledongthuc/pdf | Извлечение текста. Отсканированные PDF без текстового слоя пропускаются. | |
| DOCX | fumiama/go-docx | Извлечение текста абзацев и таблиц |
| XLSX | xuri/excelize | Все листы как текст с табуляцией |
Семантический чанкинг vs простой чанкинг
Семантический чанкинг (по умолчанию)
Один LLM-вызов на страницу (уровень Извлечение, temp 0.1) определяет естественные тематические границы. LLM выводит якорные фразы в формате TOPIC: X | STARTS: Y. Исходный текст разрезается по обнаруженным границам, сохраняя точный текст источника без перефразирования LLM. Каждый чанк получает метку темы для эмбеддинга.
Простой чанкинг (--no-llm-chunk)
Разбивка с учётом абзацев и заголовков. Текст разбивается по двойным переносам строк и markdown-заголовкам. Короткие абзацы объединяются до maxChars (по умолчанию 1500). Чанки получают нумерованные метки тем («Part 1», «Part 2» и т.д.).
Стратегия добавления темы
При индексации текст каждого чанка дополняется меткой темы перед эмбеддингом:
До: "Our basic plan starts at $99/month with unlimited support."
После: "Topic: Pricing plans\n\nOur basic plan starts at $99/month with unlimited support."
Тема выступает как семантический якорь — эмбеддинг теперь захватывает тему чанка, а не только его поверхностное содержание. Запрос о «стоимости» будет лучше совпадать с чанком, привязанным к теме «Pricing plans», даже если текст чанка не содержит слова «стоимость».
Поисковые запросы не нуждаются в префиксе темы — пространство эмбеддингов выравнивается естественным образом.
Примеры использования CLI
# Создать новый проект и сразу заполнить базу знаний
bin/aichat-crawler init-project \
--name myproject \
--tg-api-key "123456:ABC-DEF" \
--tg-lead-group -1001234567890 \
--language en \
--prompt1 "You are a sales consultant..." \
--prompt2 "You are helping a customer..." \
--start-reply "Welcome! How can I help?" \
--sitemap-url "https://example.com/sitemap.xml"
# Заполнить проект позднее (отдельно от создания)
bin/aichat-crawler seed \
--project-id "uuid-from-init-output" \
--project-name myproject \
--sitemap-url "https://example.com/sitemap.xml"
# Добавить URL в существующий проект
bin/aichat-crawler add-urls \
--project-name myproject \
--urls "https://example.com/new-page1,https://example.com/new-page2"
# Установить промпты из файлов
bin/aichat-crawler set \
--project-name myproject prompt1 @prompts/prompt1.txt
Система таймеров
Автоматические follow-up последовательности, срабатывающие при молчании пользователя. LLM-классификация определяет действие на каждом шаге.
Временная шкала follow-up
Логика каждого срабатывания
Детали жизненного цикла
- Сброс — Каждое входящее сообщение сбрасывает таймер для данного чата, начиная последовательность с шага 0.
- Срабатывание — Загружается свежее состояние чата и история. Пропускается, если уже завершено. Классифицирует клиента через LLM, затем действует на основе классификации.
- Сохранение — Состояние таймера записывается в PostgreSQL со временем следующего срабатывания. При перезапуске
Reload()восстанавливает все активные таймеры, вычисляя оставшуюся задержку и немедленно запуская просроченные. - Повтор отправки лида — Если таймер обнаруживает
Finished=true, LeadSent=false, он повторяет отправку в CRM вместо классификации. - Отмена — Когда пользователь отправляет новое сообщение, старый таймер отменяется (горутина завершается + запись в БД обновляется).
Безопасность горутин: Каждая пара чат/проект получает одну горутину. Проверка идентичности current == self предотвращает ситуацию, когда вытесненная горутина очищает состояние более нового таймера.
Экономия токенов: Двухступенчатый подход (сначала классификация, затем генерация условно) пропускает генерацию follow-up для клиентов со статусом finished и hot, которым ещё не нужно сообщение.
Мультитенантность
Все пути данных используют составные ключи (chat_id, project_id) для изоляции арендаторов. Каждый проект получает собственные ресурсы при общей инфраструктуре.
По проектам (изолировано)
Общее (инфраструктура)
Детали изоляции данных
| Данные | Таблица/Коллекция | Ключ |
|---|---|---|
| Состояние чата | chat_states | PRIMARY KEY (chat_id, project_id) |
| История чата | chat_history | WHERE chat_id AND project_id |
| Таймеры | timers | PRIMARY KEY (chat_id, project_id) |
| BM25-индекс | bm25_indexes | PRIMARY KEY (project_id) |
| Поисковые векторы | Qdrant | {project_name}_content_hybrid, {project_name}_source |
| Маршрутизация webhook | URL-путь | POST /webhook/{tg_api_key} |
Регистрация в рантайме
Бандлы проектов (Telegram-клиент, агент, CRM, база знаний) хранятся в карте в памяти. Новые проекты могут быть зарегистрированы в рантайме двумя путями:
- Админ-группа — команда
/project_initсоздаёт проект, заполняет базу знаний и регистрирует бандл мгновенно. Перезапуск не требуется. - Внутренний API —
POST /api/register-project/{id}для CLI или внешних инструментов, которые заполняют БД самостоятельно.
Процесс подключения проекта (Telegram)
Шаг 1: Создайте бота в BotFather, скопируйте токен.
Шаг 2: Отправьте одну команду в группе лидов админ-проекта:
/project_init myproject sitemap https://example.com/sitemap.xml 123456:AAH...token
Система проверяет токен, создаёт проект, заполняет базу знаний и отвечает с прогрессом:
> Project "myproject" created (bot: @myproject_bot). Seeding started...
> Scraping completed: 42 pages
> Chunking completed: 156 chunks
> Embeddings completed: 156 vectors
> Upload completed: 42 pages, 156 chunks indexed
> Project "myproject" init finished!
> Test the bot: https://t.me/myproject_bot
> To create the CRM group, click:
> https://t.me/myproject_bot?startgroup=connect_myproject
Шаг 3: Нажмите на deep link. Telegram откроет интерфейс создания группы с уже добавленным ботом. Бот автоматически обнаружит группу и подключит её как группу лидов. ID группы не нужен — обнаруживается автоматически через команду /start connect_{name}.
Справочник команд администратора
| Команда | Описание |
|---|---|
/project_init <name> sitemap <url> <token> | Создать проект, просканировать sitemap, заполнить базу знаний |
/project_init <name> urls <url1,url2> <token> | Создать проект из отдельных URL |
/project_prompt1 <name> <text> | Обновить системный промпт Этапа 1 |
/project_prompt2 <name> <text> | Обновить системный промпт Этапа 2 |
/project_prompt3 <name> <text> | Обновить промпт Этапа 3 (завершение) |
/project_price_url <name> <url> | Установить URL цен (чанки из базы знаний включаются в Этап 1) |
/project_stats <name> [period] | Показать статистику чатов |
/project_info <name> | Показать информацию о проекте |
/project_add_urls <name> <urls> | Добавить URL в базу знаний |
/project_delete_urls <name> <urls> | Удалить чанки URL из базы знаний |
/project_delete <name> | Мягкое удаление проекта |
/project_restore <name> | Восстановить мягко удалённый проект |
Команды группы лидов (по проекту)
| Команда | Описание |
|---|---|
/help | Показать все доступные команды |
/prompt | Показать текущие prompt1, prompt2 и prompt3 |
/prompt1 [text] | Показать или обновить системный промпт Этапа 1 |
/prompt2 [text] | Показать или обновить системный промпт Этапа 2 |
/stats [period] | Показать статистику чатов (напр. 1 week, 3 days) |
/info | Показать информацию о проекте |
Команды пользовательского чата
| Команда | Описание |
|---|---|
/start | Сбросить состояние диалога и показать приветствие проекта |
/debug | Переключить режим отладки (добавляет трейс-информацию к ответам бота) |
Схема базы данных
Три миграции в migrations/:
001_initial.sql
CREATE TABLE projects (
id UUID PRIMARY KEY,
name TEXT UNIQUE,
tg_lead_group BIGINT,
tg_api_key TEXT UNIQUE,
prompts JSONB,
language TEXT DEFAULT 'ru'
);
CREATE TABLE chat_history (
id UUID PRIMARY KEY,
chat_id BIGINT,
project_id UUID REFERENCES projects(id),
question TEXT,
reply TEXT,
dbinfo JSONB,
created_at TIMESTAMPTZ
);
CREATE TABLE chat_states (
chat_id BIGINT,
project_id UUID REFERENCES projects(id),
state JSONB,
PRIMARY KEY (chat_id, project_id)
);
002_bm25_index.sql
CREATE TABLE bm25_indexes (
project_id UUID PRIMARY KEY REFERENCES projects(id),
vocab JSONB,
idf JSONB,
avg_dl FLOAT,
num_docs INT,
updated_at TIMESTAMPTZ
);
003_timers.sql
CREATE TABLE timers (
chat_id BIGINT,
project_id UUID REFERENCES projects(id),
step INT,
trigger_at TIMESTAMPTZ,
PRIMARY KEY (chat_id, project_id)
);
Ключевые индексы
idx_chat_history_chat_idна(chat_id, created_at)— поиск историиidx_chat_states_projectна(project_id)— запросы на уровне проектаidx_chat_states_state_ginGIN на(state)— JSONB-запросы по состоянию
Отправка лидов в CRM
При отправке лида в Telegram-группу проекта передаётся следующее.
lead{chatID}.txt в ответ на сообщение-резюме.Дизайн интерфейса CRM
type CRM interface {
SendLead(ctx context.Context, lead domain.Lead) (string, error)
Name() string
}
Текущая реализация: TelegramCRM. Интерфейс спроектирован для будущих CRM-бэкендов (Bitrix, AmoCRM) через паттерн диспетчера MultiCRM.
Развёртывание
Нативное развёртывание на NixOS с усилением безопасности systemd, секретами sops-nix и Cloudflare Tunnel.
Цепочка зависимостей сервисов
Усиление безопасности
| Параметр | Значение | Назначение |
|---|---|---|
NoNewPrivileges | true | Предотвращение повышения привилегий |
ProtectSystem | strict | Файловая система только для чтения, кроме разрешённых путей |
ProtectHome | true | Нет доступа к /home |
PrivateTmp | true | Изолированный /tmp |
MemoryMax | 512M | Лимит памяти (защита от OOM) |
Конфигурация модуля NixOS
services.aichat = {
enable = true;
configFile = "/run/secrets/aichat-config.json";
listenAddr = ":8080";
# База данных
enablePostgres = true;
dbName = "aichat";
postgresPort = 5432;
# Qdrant
enableQdrant = true;
qdrantPort = 6333;
qdrantDataDir = "/var/lib/qdrant";
# Резервное копирование
enableBackup = true;
backupDir = "/var/backup/aichat";
backupRetentionDays = 14;
};
Резервное копирование и секреты
Резервное копирование
- Ежедневный systemd-таймер с
RandomizedDelaySec=1h pg_dump+ снимки Qdrant- 14-дневное хранение с автоматической очисткой
Секреты (sops-nix)
- Зашифрованы в
secrets/production.yamlс использованием age-ключей - Расшифровываются при загрузке на целевой машине с помощью SSH-ключа хоста
- Ключи:
aichat_config(полный JSON-конфиг),cloudflared_creds(учётные данные туннеля)
Команды быстрого развёртывания
# Развернуть изменения кода в продакшн
make prod-deploy
# SSH-туннели для доступа краулера к БД + Qdrant продакшна
make prod-tunnel
# PostgreSQL на localhost:15432, Qdrant на localhost:16333
# Заполнить проект на продакшне (при открытом туннеле)
./bin/aichat-crawler --config /tmp/config.prod-tunnel.json init-project \
--name myproject --tg-api-key "TOKEN" --language ru \
--no-llm-chunk --workers 1 --urls "URL1,URL2,..."
# Получить файлы трейсов с продакшна
ssh root@server "cat /var/lib/aichat/traces/{chatID}/{dialogID}.log"
Настройка webhook
После запуска бота и его доступности по HTTPS зарегистрируйте webhook в Telegram:
curl -X POST "https://api.telegram.org/bot{TG_API_KEY}/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-domain.com/webhook/{TG_API_KEY}"}'
URL webhook должен точно соответствовать https://{host}/webhook/{tg_api_key}, где tg_api_key — токен бота из таблицы projects. API-ключ в URL маршрутизирует входящие обновления к нужному бандлу проекта.
Ручное развёртывание / Docker
Требования: Go 1.22+, PostgreSQL 16, Qdrant
# Собрать все бинарники
make build
# создаёт bin/aichat-bot, bin/aichat-crawler, bin/aichat-cli
# Запустить миграции
make migrate DATABASE_URL="postgres://user:pass@localhost/aichat"
# Запустить бота с JSON-конфигом
bin/aichat-bot --config config.prod.json
# Или с переменными окружения
DATABASE_URL="postgres://user:pass@localhost/aichat" \
OPENAI_API_KEYS="sk-xxx" \
bin/aichat-bot
Настройка Cloudflare Tunnel
В продакшне используется Cloudflare Tunnel для маршрутизации HTTPS-трафика к боту без открытия портов. Туннель направляет aichat.example.com на localhost:8080 на сервере.
- Учётные данные туннеля управляются через sops-nix (зашифрованы в покое)
- Настроен в
nix/production.nix - Нет необходимости в TLS-сертификатах или настройке обратного прокси
- Webhook Telegram указывают на URL туннеля
Локализация
Пользовательские строки и шаблоны промптов LLM локализованы через JSON-файлы локалей, встроенные при компиляции.
| Аспект | Детали |
|---|---|
| Поддерживаемые языки | ru (по умолчанию), en |
| Файлы локалей | internal/i18n/locales/ru.json, internal/i18n/locales/en.json |
| API | i18n.T(lang, key) для строк, i18n.Tf(lang, key, args...) для форматированных строк |
| Цепочка fallback | Запрошенный язык → "ru" → сам ключ |
| Покрытие | 36+ ключей: ответы агента, промпты LLM, форматирование истории, сообщения таймера, форматирование лидов CRM, промпты чанкера |
| По проектам | Устанавливается в столбце projects.language, передаётся агенту, CRM и таймеру |
| Добавление локали | Создайте internal/i18n/locales/{code}.json с теми же ключами — автозагрузка через //go:embed |
Мониторинг
Метрики Prometheus доступны по GET /metrics на внутреннем сервере (localhost:9090 по умолчанию). Метрики применяются через декорирование — основные пакеты не импортируют Prometheus.
Метрики LLM
| Метрика | Тип | Метки | Описание |
|---|---|---|---|
aichat_llm_requests_total | counter | provider, method, status | Всего вызовов LLM API |
aichat_llm_request_duration_seconds | histogram | provider, method | Задержка вызовов LLM (бакеты 0.1-51 с) |
Метрики Telegram
| Метрика | Тип | Метки | Описание |
|---|---|---|---|
aichat_telegram_requests_total | counter | method, status | Вызовы Telegram API |
aichat_telegram_request_duration_seconds | histogram | method | Задержка Telegram API |
Метрики Webhook и агента
| Метрика | Тип | Метки | Описание |
|---|---|---|---|
aichat_webhook_requests_total | counter | project, status | Входящие webhook-запросы |
aichat_webhook_request_duration_seconds | histogram | project | Время обработки webhook |
aichat_agent_messages_total | counter | project, stage | Обработано сообщений по этапам |
Метрики таймеров и CRM
| Метрика | Тип | Метки | Описание |
|---|---|---|---|
aichat_timer_active_sequences | gauge | — | Текущие активные последовательности таймеров |
aichat_timer_fires_total | counter | action | Результаты срабатываний таймера (follow_up, lead, skip) |
aichat_crm_leads_total | counter | project, status | Лиды, отправленные в CRM |
Рекомендации по алертам
| Условие | PromQL-запрос |
|---|---|
| Всплеск ошибок LLM | rate(aichat_llm_requests_total{status="error"}[5m]) > 0.1 |
| Высокая задержка LLM | histogram_quantile(0.95, rate(aichat_llm_request_duration_seconds_bucket[5m])) > 30 |
| Ошибки webhook | rate(aichat_webhook_requests_total{status="error"}[5m]) > 0.05 |
| Зависшие таймеры | rate(aichat_timer_fires_total[1h]) == 0 при aichat_timer_active_sequences > 0 |
| Ошибки CRM | rate(aichat_crm_leads_total{status="error"}[5m]) > 0 |
Советы по дашборду Grafana
- Группируйте метрики LLM по
providerдля сравнения производительности и частоты ошибок провайдеров - Отслеживайте
aichat_agent_messages_totalпоstage, чтобы видеть конверсию воронки (general → service_specific → final) - Следите за
aichat_timer_active_sequencesкак индикатором общего числа активных диалогов - Используйте
aichat_webhook_request_duration_secondsp95, чтобы замечать медленные ответы (типично: 2-5 с, включая LLM + поиск) - Мониторьте
aichat_crm_leads_totalпоprojectдля отслеживания генерации лидов по арендаторам
Справочник меток
| Метка | Значения | Используется в |
|---|---|---|
provider | openai, claude, together, openrouter, tiered | Метрики LLM |
method | complete, embed (LLM); sendMessage, sendMediaGroup и др. (Telegram) | Метрики LLM, Telegram |
status | ok, error | Все метрики-счётчики |
project | Строка имени проекта | Метрики Webhook, агента, CRM |
stage | general (Этап 1), service_specific (Этап 2), final (Этап 3) | Метрики агента |
action | follow_up, lead, skip | Метрики таймера |
Справочник конфигурации
Конфигурация через JSON-файл (--config config.json) или переменные окружения (fallback). Для продакшна рекомендуется JSON-конфиг.
Аннотированный config.example.json
{
// Строка подключения PostgreSQL
"database_url": "postgresql://user:pass@localhost/aichat",
// Векторная база данных Qdrant
"qdrant": {
"url": "http://localhost:6333",
"api_key": ""
},
// Публичный HTTP-сервер (webhook)
"listen_addr": ":8080",
// Внутренний HTTP-сервер (health, метрики, register-project)
"internal_addr": "localhost:9090",
// Базовый URL для регистрации webhook (в продакшне должен быть HTTPS)
"webhook_url": "https://bot.example.com",
// LLM-провайдеры: любой OpenAI-совместимый API
"providers": {
"openrouter": {
"keys": ["sk-or-v1-your-key"],
"base_url": "https://openrouter.ai/api/v1"
},
"together": {
"keys": ["your-together-key"],
"base_url": "https://api.together.xyz/v1"
}
},
// Уровни моделей: формат "provider/model" (разделение по первому /)
"models": {
"chat": "openrouter/qwen/qwen3-235b-a22b-2507", // для клиентов
"extract": "openrouter/mistralai/mistral-small-3.1-24b-instruct", // лёгкая
"embedding": "openrouter/qwen/qwen3-embedding-8b", // векторы
"reranker": "openrouter/cohere/rerank-4-fast" // cross-encoder
},
// Настройки краулера
"crawler": {
"chunk_size": 1500, // символов на чанк (простой режим)
"workers": 4, // параллельные воркеры краулера
"no_llm_chunk": false, // true = по абзацам, false = семантический LLM
"embedding_dim": 1024 // размерность Matryoshka-усечения
},
"debug": false
}
Fallback на переменные окружения
| Переменная | Обязательная | По умолчанию | Описание |
|---|---|---|---|
DATABASE_URL | Да | — | Строка подключения PostgreSQL |
OPENAI_API_KEYS | Хотя бы одна | — | API-ключи OpenAI через запятую |
CLAUDE_API_KEYS | — | API-ключи Claude через запятую | |
QDRANT_URL | Нет | http://localhost:6333 | URL REST API Qdrant |
QDRANT_API_KEY | Нет | — | Ключ аутентификации Qdrant |
LISTEN_ADDR | Нет | :8080 | Адрес публичного HTTP-сервера |
INTERNAL_ADDR | Нет | localhost:9090 | Адрес внутреннего HTTP-сервера |
DEBUG | Нет | — | Включить отладочное логирование (JSON) |
AGENT_TRACE | Нет | — | Установить 1 для файлов трейсов по диалогам |
Маршрутизация по уровням: подробности
Идентификаторы моделей используют формат "provider/model". Имя провайдера отделяется по первому символу / и сопоставляется с картой providers.
| Уровень | Ключ конфига | Назначение | Примечания |
|---|---|---|---|
| Чат | models.chat | Ответы клиентам на этапах | Более крупная, качественная модель |
| Извлечение | models.extract | Извлечение, классификация, резюме | Более лёгкая, быстрая модель. При отсутствии переключается на Чат. |
models.embedding | Векторные эмбеддинги для поиска | Пакетный API, Matryoshka-усечение до настроенной размерности | |
| Реранкер | models.reranker | Переоценка результатов cross-encoder | Cohere API, опционально |
Ограничение модели Извлечение: Должна возвращать контент в поле choices[].message.content. Модели с thinking/reasoning, использующие поле reasoning, не подходят для уровня Извлечение.
Справочник ключевых порогов
Все настраиваемые и захардкоженные константы, управляющие поведением системы.
Пороги агента
| Константа | Значение | Назначение | Определена в |
|---|---|---|---|
urlScoreThreshold | 3.0 | Минимальный накопленный URL-скор для автовыбора услуги | internal/agent/stages.go |
minSearchScore | 0.5 | Минимальный DBSF-скор для включения результата | internal/agent/stages.go |
maxContextResults | 5 | Макс. результатов поиска в контексте LLM | internal/agent/stages.go |
| Лимит поиска (с реранкером) | 5 | Результатов на вариацию запроса при наличии реранкера | internal/agent/stages.go |
| Лимит поиска (без реранкера) | 2 | Результатов на вариацию запроса без реранкера | internal/agent/stages.go |
| Защита первого хода | 2 обмена | Определение URL пропускается до минимум 2 обменов сообщениями | internal/agent/stages.go |
Интервалы таймера
| Шаг | Задержка | Поведение |
|---|---|---|
| 1 | 5 минут | Лёгкое напоминание, без давления |
| 2 | 15 минут | Предложить конкретную ценность, подтолкнуть к следующему шагу |
| 3 | 40 минут | Предложить живого специалиста |
| 4 | 24 часа | Мягкое напоминание о доступности помощи |
Константы краулера
| Константа | Значение | Назначение | Определена в |
|---|---|---|---|
defaultEmbeddingDim | 1536 | Размерность эмбеддинга по умолчанию (text-embedding-3-small) | internal/crawler/indexer.go |
upsertBatch | 50 | Точек на пакет upsert в Qdrant | internal/crawler/indexer.go |
| Размер чанка по умолчанию | 1500 символов | Лимит символов в режиме простого чанкинга | internal/crawler/chunker.go |
| Размер пакета эмбеддинга | 32 чанка | Чанков на вызов API эмбеддинга | internal/crawler/pipeline.go |
| Воркеры по умолчанию | 3 | Параллельные воркеры краулера | cmd/crawler/main.go |
Температуры LLM
| Температура | Используется для |
|---|---|
| 0.1 | Извлечение контакта, города, определение завершения, семантический чанкинг, извлечение цен |
| 0.2 | Резюме лида (агент + таймер) |
| 0.3 | Ответы Этапов 1/2/3, перефразирование запроса, классификация таймера |
| 0.5 | Генерация follow-up таймера |
| 0.7 | Определение URL (больше креативности для неоднозначных случаев) |
LLM-провайдер
| Константа | Значение | Назначение |
|---|---|---|
| Макс. задержка | 60 секунд | Потолок экспоненциальной задержки при retry 429 |
| Формула задержки | 2^attempt + 10% jitter | Экспоненциальная с jitter, учитывает заголовок Retry-After |
| Retry на цепочку | 3 | Макс. retry при 429 до переключения на следующего провайдера |
Telegram
| Константа | Значение | Назначение |
|---|---|---|
| Макс. длина сообщения | 4096 символов | Лимит API Telegram; сообщения автоматически разбиваются по этой границе |
| Retry при 429 | Да | Учитывает заголовок Retry-After от API Telegram |
Справочник производительности
Задержка webhook включает генерацию LLM + поиск. Задержка поиска покрывает DBSF-слияние Qdrant. Оценки хранения: ~1 КБ на ход диалога, ~100 КБ на 10К чанков, ~8 КБ на плотный вектор (1024 измерения).
Команды сборки и тестирования
# Собрать все три бинарника
make build # создаёт bin/aichat-bot, bin/aichat-crawler, bin/aichat-cli
# Запустить тесты
make test # go test ./...
# Линтер
make lint # go vet ./...
# Локальные сервисы для разработки (PostgreSQL + Qdrant через Docker)
make dev-services
# Запустить CLI-интерфейс для тестирования (без Telegram)
bin/aichat-cli --config config.dev.json --project myproject
aichat-go
Мультитенантная платформа AI-бота для продаж
Построена на Go, Qdrant, PostgreSQL и OpenRouter
Документация сгенерирована из исходного кода и файлов docs/*.md.