aichat-go

Мультитенантная платформа AI-бота для продаж

3-этапный диалоговый движок
Гибридный поиск: векторный + BM25
Реранкинг cross-encoder
Мультитенантная изоляция
Автоматические follow-up
Go Qdrant PostgreSQL OpenRouter NixOS

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 по уровням.

TelegramWebhook POST
нормализация
GatewayTelegramUpdate → Message
Агент3-этапный автомат
поиск
Гибридный поискQdrant + BM25
QdrantDense + Sparse
PostgreSQLBM25 Index
генерация
LLM по уровнямЧат / Извлечение
OpenRouterЛюбой провайдер
отправка
CRM-выводГруппа лидов
TelegramСообщения в группу
Ключевые проектные решения: Состояние вычисляется (а не хранится) из полей Finished и DeterminedURL. Метрики применяются через декорирование (ноль импортов Prometheus в ядре). Все пути данных используют составные ключи (chat_id, project_id) для изоляции арендаторов.
Детали компонентов
КомпонентРасположениеНазначение
Gatewayinternal/gateway/Нормализация TelegramUpdate в domain.Message (текст, подпись, файлы, отправитель)
Агентinternal/agent/3-этапный автомат диалога с обработкой команд
LLM по уровнямinternal/llm/Маршрутизация вызовов Чат/Извлечение/embedding к различным комбинациям провайдер+модель
Гибридный поискinternal/search/Плотные эмбеддинги + BM25-разреженные векторы с DBSF-слиянием и реранкингом cross-encoder
CRMinternal/crm/Отправка лидов в Telegram-группу (резюме + история + медиа)
Таймерinternal/timer/Автоматические follow-up последовательности с LLM-классификацией
Краулерinternal/crawler/4-фазный конвейер: скачивание, чанкинг, эмбеддинг, загрузка в Qdrant
Метрикиinternal/metrics/Обёртки Prometheus (без связи с основными пакетами)
Администрированиеinternal/admin/Обработчик команд админ-группы для кросс-проектного управления
i18ninternal/i18n/Локализация (ru/en) с встроенными файлами локалей на этапе компиляции
Жизненный цикл запроса (от webhook до ответа)

Каждое входящее сообщение Telegram проходит следующий путь:

  1. Приём webhookPOST /webhook/{tg_api_key} поступает на публичный HTTP-сервер (:8080). API-ключ в URL маршрутизирует к нужному бандлу проекта.
  2. Нормализация в GatewayTelegramUpdate преобразуется в domain.Message с извлечением текста, подписи, файлов и информации об отправителе.
  3. Загрузка состояния — Состояние чата загружается из PostgreSQL по составному ключу (chat_id, project_id).
  4. Проверка команд/start сбрасывает состояние и возвращает приветствие. /debug переключает режим отладки.
  5. Сбор файлов — Вложения накапливаются в состоянии. Файлы без текста получают немедленное подтверждение без LLM-вызовов.
  6. Диспетчеризация по этапу — На основе вычисленного состояния (CurrentStage()) сообщение направляется соответствующему обработчику этапа.
  7. Сохранение состояния — Обновлённое состояние записывается обратно в PostgreSQL.
  8. Сброс таймера — Таймер 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-этапный автомат воронки продаж. Каждое входящее сообщение направляется обработчику этапа на основе вычисленного состояния.

ЭТАП 1

Общая консультация

Определение, о какой услуге спрашивает пользователь, через многоходовый диалог.

  • Перефразирование запроса — 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 ≥ 3.0 (пропускается на первом ходе)
скор ≥ 3.0 или LLM-определение
ЭТАП 1.5

Определение URL

Когда ни один URL не превысил порог автовыбора, решение принимает LLM.

  • Автовыбор — если скор любого URL ≥ 3.0, фиксируем немедленно
  • LLM-определение — уровень Извлечение (temp 0.7), анализирует историю + результаты поиска + URL-скоры
  • Вывод: URL: <url> → фиксация и переход к Этапу 2
  • Вывод: QUESTION: <text> → уточняющий вопрос, остаёмся на Этапе 1
Условие: Определение пропускается, пока не произойдёт минимум 2 обмена сообщениями
URL зафиксирован
ЭТАП 2

Предметная консультация

Углублённые вопросы-ответы по зафиксированной услуге с параллельным извлечением контактов.

  • Полная страница источника — извлекается через 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
Переход: finish=YES из LLM-извлечения → Этап 3
finish = YES
ЭТАП 3

Генерация лида и отправка

Формирование лида из диалога и отправка в CRM.

  • Установка Finished=true — немедленно сохраняется в БД (двухфазное сохранение)
  • Резюме лида — LLM генерирует резюме с именем, городом, услугой, контактом (Извлечение, temp 0.2)
  • Прощальный ответ — прощальное сообщение пользователю (уровень Чат, temp 0.3)
  • Отправка в CRM — пересланное сообщение + резюме + документ истории + медиафайлы
  • Установка LeadSent=true — сохраняется в БД (предотвращает повторную отправку)
После: Последующие сообщения получают подтверждение «ваш запрос передан»
Детали двухфазного сохранения лида

Finished=true записывается в базу данных до отправки в CRM, а LeadSent=true записывается после. Это предотвращает дублирование лидов при параллельных webhook-запросах или срабатываниях таймера, читающих устаревшее состояние. Если бот падает между двумя записями, система таймеров повторяет отправку в CRM при следующем срабатывании или при перезапуске.

Finished=trueЗапись в БД
Отправка в CRM
LeadSent=trueЗапись в БД

Сбой между этими состояниями → таймер повторяет отправку в CRM при следующем срабатывании или перезапуске

Обработка загрузки файлов

Когда пользователь отправляет файл без текста, бот немедленно подтверждает получение («Принято! Дайте знать, когда закончите.») без обращения к LLM. Файлы накапливаются в состоянии чата и прикрепляются к лиду при его генерации.

Медиафайлы в лиде группируются по типу для наглядного представления:

  • Фото + видео — отправляются как медиа-альбом (Telegram группирует их визуально)
  • Документы — отправляются как альбом документов
  • Голосовые / видео-заметки — отправляются поштучно (Telegram не поддерживает альбомы для них)
Кнопка запроса контакта

При первом взаимодействии на Этапе 2 бот отправляет кнопку Telegram request_contact вместе с ответом. Это позволяет пользователям поделиться номером телефона одним нажатием.

  • Отправляется только один раз за диалог (отслеживается через state.UserSettings.ContactRequested)
  • Полученный контакт хранится в состоянии чата (не в истории) для конфиденциальности
  • Контактные данные включаются в резюме лида и промпты извлечения
  • Когда пользователь говорит «возьмите мой телефон из Telegram», LLM может использовать реальные данные контакта
Мягкий подход к чек-листу

Бот мягко напоминает пользователю поделиться городом и номером телефона, но не блокирует генерацию лида. Если пользователь предоставил достаточно информации для качественного лида, finish=true допускается даже без всех полей. Это избегает раздражения пользователей, которые предпочитают не делиться определённой информацией, при этом максимизируя качество лида.

Поисковый конвейер

Гибридный поиск, объединяющий плотные эмбеддинги и BM25-разреженные векторы с DBSF-слиянием, реранкингом cross-encoder и расширением соседями.

1

Перефразирование запроса

LLM генерирует 3 поисковые вариации из вопроса пользователя (уровень Извлечение, temp 0.3). Улучшает полноту выдачи, захватывая различные формулировки, которые могут совпадать с разными чанками базы знаний.

2

Гибридный поиск

Каждая вариация ищется через Qdrant prefetch с плотными эмбеддингами (косинусное сходство, 1024 измерения) и BM25-разреженными векторами. Встроенное в Qdrant DBSF (Distribution-Based Score Fusion) объединяет результаты, сохраняя абсолютный сигнал релевантности в отличие от ранговой RRF. Каждый prefetch извлекает limit * 2 кандидатов для алгоритма слияния.

3

Дедупликация

Результаты всех 3 вариаций запроса объединяются. Дубликаты (по текстовому содержимому) удаляются с сохранением экземпляра с наивысшим скором.

4

Реранкинг

Cross-encoder Cohere (настраивается через models.reranker) переоценивает всех кандидатов. Извлекает с запасом 5 результатов на вариацию запроса (3x от итогового лимита), чтобы дать реранкеру больше материала. Все результаты участвуют в подсчёте URL-скора до отсечения контекста.

5

Отсечение контекста

Оставляются топ-5 результатов для контекстного окна LLM. URL-скоры накапливаются из всех результатов (до и после отсечения) в state.URLsCounter.

6

Расширение соседями

Для каждого результата соседние чанки (позиции N-1 и N+1 с того же URL) извлекаются через Qdrant scroll и объединяются в порядке позиций. Дешёвый запрос только по метаданным, без векторных вычислений.

Визуальная схема поиска

Вопрос пользователя
Перефразирование (3 вариации)
Плотные эмбеддинги
cosine, 1024 dims
+
BM25 Sparse
ключевые слова
DBSF-слияние
Distribution-Based Score Fusion (сохраняет абсолютную релевантность)
Дедупликация (по текстовому содержимому)
Реранкинг cross-encoder
Cohere rerank (опционально)
Топ-5 результатов
+
Расширение соседями
позиции N-1 / N+1
Контекстное окно LLM
DBSF vs RRF: DBSF нормализует выход каждого скорера на основе его реального распределения, поэтому результат с высоким косинусным сходством сохраняет свой абсолютный сигнал релевантности. RRF (Reciprocal Rank Fusion) основан исключительно на рангах и теряет эту информацию.
3
Вариации запроса
1024
Размерность эмбеддинга
5
Макс. результатов в контексте
0.5
Мин. скор
Коллекции Qdrant для каждого проекта
КоллекцияВекторыНазначение
{project}_content_hybridDense + 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 в системе, организованные по уровням и назначению.

Уровни

Чат — ответы клиентам Извлечение — лёгкая обработка Реранкер — cross-encoder Cohere Embedding — генерация векторов
#НазначениеУровеньTempРасположение
1Ответ Этапа 1 — общая консультация с контекстом из базы знанийЧат0.3stages.go
2Ответ Этапа 2 — предметный, с полной страницей источника + ценамиЧат0.3stages.go
3Завершение Этапа 3 — прощание после захвата лидаЧат0.3stages.go
4Перефразирование запроса — 3 поисковые вариацииИзвлечение0.3stages.go
5Определение URL — выбор услуги или уточняющий вопросИзвлечение0.7stages.go
6Извлечение контакта — способ + значениеИзвлечение0.1stages.go
7Извлечение городаИзвлечение0.1stages.go
8Определение завершения — YES/NOИзвлечение0.1stages.go
9Резюме лида — для отправки в CRMИзвлечение0.2stages.go
10Классификация таймера — COLD/HOT/FINISHEDИзвлечение0.3timer.go
11Follow-up таймера — контекстное напоминание (условно)Извлечение0.5timer.go
12Резюме лида от таймера — для отправки лида по таймеруИзвлечение0.2timer.go
13Семантический чанкинг — определение границ на страницеИзвлечение0.1chunker.go
14Извлечение цен — однократно при входе в Этап 2, кешируетсяИзвлечение0.1stages.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 для возобновления.

1

Скачивание страниц

HTTP GET с параллельными воркерами (по умолчанию: 3). Поддержка HTML, PDF, DOCX, XLSX через автоопределение. Каждая страница сохраняется в 1_pages/{urlhash}.json. Логика пропуска: страницы с существующими файлами пропускаются при повторном запуске.

2

Семантический чанкинг

Определение границ через LLM: выводит якорные фразы TOPIC: X | STARTS: Y для каждой страницы (уровень Извлечение, temp 0.1). Исходный текст разрезается по обнаруженным границам. Каждый чанк получает метку темы. Сохраняется в 2_chunks/{urlhash}.json. Fallback на разбивку по абзацам с --no-llm-chunk.

3

Пакетный эмбеддинг

32 чанка за API-вызов. Тема добавляется перед эмбеддингом: "Topic: X\n\n{text}". BM25-разреженные векторы перестраиваются из всех чанков (IDF требует полного корпуса). Сохраняется в 3_embeds/{urlhash}.json + bm25.json. Авторазбиение по лимиту токенов обрабатывает слишком большие чанки.

4

Загрузка в Qdrant

Upsert в Qdrant пакетами по 50 точек. Страницы источников загружаются с реальными эмбеддингами в {name}_source. Чанкированный контент — в {name}_content_hybrid. BM25-индекс сохраняется в PostgreSQL. Данные конвейера архивируются в project-archives/{name}.tar.gz.

Инкрементальное поведение: Каждый шаг проверяет наличие существующих файлов для URL перед обработкой. Повторный запуск конвейера обрабатывает только отсутствующий контент. Удалите папку шага для принудительной переобработки с этого момента. Ключ файла: 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-энкодера
Поддержка типов документов
ФорматБиблиотекаПримечания
HTMLgo-readability v2Алгоритм Firefox Reader View. Fallback: обход DOM (main → article → body, удаление nav/header/footer/script)
PDFledongthuc/pdfИзвлечение текста. Отсканированные PDF без текстового слоя пропускаются.
DOCXfumiama/go-docxИзвлечение текста абзацев и таблиц
XLSXxuri/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

1
5 мин
Лёгкое напоминание
2
15 мин
Предложить ценность
3
40 мин
Живой специалист
4
24 часа
Финальное напоминание

Логика каждого срабатывания

Срабатывание таймера
Классификация клиентаLLM: COLD / HOT / FINISHED
FINISHED
Отправить лид в CRM
COLD
Сгенерировать follow-up
HOT
Пропустить (или follow-up на предпоследнем шаге)
Детали жизненного цикла
  1. Сброс — Каждое входящее сообщение сбрасывает таймер для данного чата, начиная последовательность с шага 0.
  2. Срабатывание — Загружается свежее состояние чата и история. Пропускается, если уже завершено. Классифицирует клиента через LLM, затем действует на основе классификации.
  3. Сохранение — Состояние таймера записывается в PostgreSQL со временем следующего срабатывания. При перезапуске Reload() восстанавливает все активные таймеры, вычисляя оставшуюся задержку и немедленно запуская просроченные.
  4. Повтор отправки лида — Если таймер обнаруживает Finished=true, LeadSent=false, он повторяет отправку в CRM вместо классификации.
  5. Отмена — Когда пользователь отправляет новое сообщение, старый таймер отменяется (горутина завершается + запись в БД обновляется).

Безопасность горутин: Каждая пара чат/проект получает одну горутину. Проверка идентичности current == self предотвращает ситуацию, когда вытесненная горутина очищает состояние более нового таймера.

Экономия токенов: Двухступенчатый подход (сначала классификация, затем генерация условно) пропускает генерацию follow-up для клиентов со статусом finished и hot, которым ещё не нужно сообщение.

Мультитенантность

Все пути данных используют составные ключи (chat_id, project_id) для изоляции арендаторов. Каждый проект получает собственные ресурсы при общей инфраструктуре.

По проектам (изолировано)

Telegram bot token
Коллекции Qdrant
Системные промпты (JSONB)
Группа лидов (CRM-цель)
BM25 sparse encoder
Экземпляр агента
Языковая локаль

Общее (инфраструктура)

Экземпляр PostgreSQL
Экземпляр Qdrant
Пул LLM-провайдеров
Планировщик таймеров
HTTP-серверы
Эндпоинт метрик
Детали изоляции данных
ДанныеТаблица/КоллекцияКлюч
Состояние чатаchat_statesPRIMARY KEY (chat_id, project_id)
История чатаchat_historyWHERE chat_id AND project_id
ТаймерыtimersPRIMARY KEY (chat_id, project_id)
BM25-индексbm25_indexesPRIMARY KEY (project_id)
Поисковые векторыQdrant{project_name}_content_hybrid, {project_name}_source
Маршрутизация webhookURL-путьPOST /webhook/{tg_api_key}
Регистрация в рантайме

Бандлы проектов (Telegram-клиент, агент, CRM, база знаний) хранятся в карте в памяти. Новые проекты могут быть зарегистрированы в рантайме двумя путями:

  • Админ-группа — команда /project_init создаёт проект, заполняет базу знаний и регистрирует бандл мгновенно. Перезапуск не требуется.
  • Внутренний APIPOST /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_gin GIN на (state) — JSONB-запросы по состоянию

Отправка лидов в CRM

При отправке лида в Telegram-группу проекта передаётся следующее.

1
Пересылка последнего сообщения — последнее сообщение пользователя пересылается, чтобы менеджеры могли кликнуть на профиль и начать прямой диалог.
2
Сообщение-резюме — сгенерированное LLM резюме, содержащее: ID лида, имя, город, услугу, способ связи, переданный контакт (если есть), статус клиента (finished/hot/cold), количество файлов.
3
Документ истории чата — полная транскрипция Q/A с временными метками, загруженная как lead{chatID}.txt в ответ на сообщение-резюме.
4
Медиафайлы — сгруппированные по типу: фото+видео как альбом, документы как альбом, голосовые/видео-заметки поштучно. Всё в ответ на резюме.
Хранение контактов: Контакты извлекаются один раз и переиспользуются между ходами. Контакты, переданные через кнопку Telegram, хранятся в состоянии чата (не в истории) и включаются в резюме лида при отправке.
Дизайн интерфейса 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.

Цепочка зависимостей сервисов

postgresql
aichat-migrate
aichat-bot
qdrant
(также зависит)
aichat-bot

Усиление безопасности

ПараметрЗначениеНазначение
NoNewPrivilegestrueПредотвращение повышения привилегий
ProtectSystemstrictФайловая система только для чтения, кроме разрешённых путей
ProtectHometrueНет доступа к /home
PrivateTmptrueИзолированный /tmp
MemoryMax512MЛимит памяти (защита от 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
APIi18n.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_totalcounterprovider, method, statusВсего вызовов LLM API
aichat_llm_request_duration_secondshistogramprovider, methodЗадержка вызовов LLM (бакеты 0.1-51 с)

Метрики Telegram

МетрикаТипМеткиОписание
aichat_telegram_requests_totalcountermethod, statusВызовы Telegram API
aichat_telegram_request_duration_secondshistogrammethodЗадержка Telegram API

Метрики Webhook и агента

МетрикаТипМеткиОписание
aichat_webhook_requests_totalcounterproject, statusВходящие webhook-запросы
aichat_webhook_request_duration_secondshistogramprojectВремя обработки webhook
aichat_agent_messages_totalcounterproject, stageОбработано сообщений по этапам

Метрики таймеров и CRM

МетрикаТипМеткиОписание
aichat_timer_active_sequencesgaugeТекущие активные последовательности таймеров
aichat_timer_fires_totalcounteractionРезультаты срабатываний таймера (follow_up, lead, skip)
aichat_crm_leads_totalcounterproject, statusЛиды, отправленные в CRM
Рекомендации по алертам
УсловиеPromQL-запрос
Всплеск ошибок LLMrate(aichat_llm_requests_total{status="error"}[5m]) > 0.1
Высокая задержка LLMhistogram_quantile(0.95, rate(aichat_llm_request_duration_seconds_bucket[5m])) > 30
Ошибки webhookrate(aichat_webhook_requests_total{status="error"}[5m]) > 0.05
Зависшие таймерыrate(aichat_timer_fires_total[1h]) == 0 при aichat_timer_active_sequences > 0
Ошибки CRMrate(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_seconds p95, чтобы замечать медленные ответы (типично: 2-5 с, включая LLM + поиск)
  • Мониторьте aichat_crm_leads_total по project для отслеживания генерации лидов по арендаторам
Справочник меток
МеткаЗначенияИспользуется в
provideropenai, claude, together, openrouter, tieredМетрики LLM
methodcomplete, embed (LLM); sendMessage, sendMediaGroup и др. (Telegram)Метрики LLM, Telegram
statusok, errorВсе метрики-счётчики
projectСтрока имени проектаМетрики Webhook, агента, CRM
stagegeneral (Этап 1), service_specific (Этап 2), final (Этап 3)Метрики агента
actionfollow_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_KEYSAPI-ключи Claude через запятую
QDRANT_URLНетhttp://localhost:6333URL 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Извлечение, классификация, резюмеБолее лёгкая, быстрая модель. При отсутствии переключается на Чат.
Embeddingmodels.embeddingВекторные эмбеддинги для поискаПакетный API, Matryoshka-усечение до настроенной размерности
Реранкерmodels.rerankerПереоценка результатов cross-encoderCohere API, опционально

Ограничение модели Извлечение: Должна возвращать контент в поле choices[].message.content. Модели с thinking/reasoning, использующие поле reasoning, не подходят для уровня Извлечение.

Справочник ключевых порогов

Все настраиваемые и захардкоженные константы, управляющие поведением системы.

Пороги агента

КонстантаЗначениеНазначениеОпределена в
urlScoreThreshold3.0Минимальный накопленный URL-скор для автовыбора услугиinternal/agent/stages.go
minSearchScore0.5Минимальный DBSF-скор для включения результатаinternal/agent/stages.go
maxContextResults5Макс. результатов поиска в контексте LLMinternal/agent/stages.go
Лимит поиска (с реранкером)5Результатов на вариацию запроса при наличии реранкераinternal/agent/stages.go
Лимит поиска (без реранкера)2Результатов на вариацию запроса без реранкераinternal/agent/stages.go
Защита первого хода2 обменаОпределение URL пропускается до минимум 2 обменов сообщениямиinternal/agent/stages.go

Интервалы таймера

ШагЗадержкаПоведение
15 минутЛёгкое напоминание, без давления
215 минутПредложить конкретную ценность, подтолкнуть к следующему шагу
340 минутПредложить живого специалиста
424 часаМягкое напоминание о доступности помощи

Константы краулера

КонстантаЗначениеНазначениеОпределена в
defaultEmbeddingDim1536Размерность эмбеддинга по умолчанию (text-embedding-3-small)internal/crawler/indexer.go
upsertBatch50Точек на пакет upsert в Qdrantinternal/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

Справочник производительности

2-5 с
Задержка Webhook
~500 мс
Поиск (Qdrant)
~1 КБ
На ход
~8 КБ
На вектор

Задержка 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

3 точки входа: bot, crawler, cli 14 точек вызова LLM 3 SQL-миграции 2 локали (ru/en)

Документация сгенерирована из исходного кода и файлов docs/*.md.