Перейти к содержанию

AI-конвейер (Gemini)

REST-конвейер без SDK: чат, голос и синхронный перевод — с инструментами, которые выполняются на сервере (function calling).

Общая картина

Браузер (/me/ai, /me/translate)
   │  fetch со своего origin (заголовок CSRF)
Next.js BFF  /api/ai/{chat,stt,tts,translate}   ← проверяет форму, добавляет JWT
   │  сервер→сервер
Go API  /api/v1/ai/*   (JWT + лимит ~20/мин)
usecases/ai ──────────► pkg/gemini ──────► Gemini REST API
   │   ▲                 (3 повтора с backoff при 429/5xx/сетевых ошибках)
   │   └─ functionResponse
ToolDef.Execute()  ← выполняется НА БЭКЕНДЕ с контекстом запроса
   ├─ search_knowledge → таблица ai_knowledge
   └─ get_server_time  → демонстрационный инструмент

Ключевой принцип

Модель решает, какой инструмент вызвать; бэкенд его выполняет. Модель никогда не выполняет код. Инструменты работают в контексте запроса, поэтому к тому, чего они касаются, применяются RLS и таймауты.

Поток чата (цикл function-calling)

  1. Сформировать contents из истории (≤ 20 реплик) и нового промпта. Голосовое сообщение приходит как встроенный аудиофрагмент в base64 — Gemini понимает его напрямую, отдельный шаг STT не нужен.
  2. Вызвать Gemini со слоистой системной инструкцией и объявлениями инструментов.
  3. Если ответ содержит вызовы функций: выполнить каждый инструмент, добавить реплику модели и реплику functionResponse, затем повторить цикл (до MaxSteps, по умолчанию 4). Каждый выполненный вызов записывается как Step{Tool, Args, Result} и возвращается клиенту, чтобы интерфейс мог показать, «что сделал AI».
  4. Если ответ — текст: вернуть его.

Семантика сбоев

Случай Результат
Временный сбой Gemini (после трёх повторов клиента) Не 5xx — резервный ответ на языке пользователя с degraded: true
Отсутствует GEMINI_API_KEY Настоящая ошибка — 500, причина в логах
Неизвестный или упавший инструмент Возвращается модели как {"error": …} — клиенту напрямую не попадает

Не меняйте это поведение

При временных сбоях Gemini чат должен корректно деградировать до резервного ответа — не превращайте это в 5xx.

Слои промпта

Системный промпт собирается на каждый запрос из трёх слоёв:

  1. Жёстко заданные ограничения — зафиксированы в коде, не настраиваются.
  2. Область применения (scope) — из таблицы ai_prompts, редактируется админом.
  3. Инструкции — также настраиваются из базы данных.

Язык ответа

Фронтенд передаёт язык интерфейса (mn/en/zh/ru) в поле lang, и ассистент отвечает только на нём — язык ввода пользователя, история переписки, база знаний и результаты инструментов его не меняют (источники на другом языке переводятся). Резервный ответ degraded локализуется так же.

Слой ограничений никогда не делать настраиваемым

Слой ограничений живёт только в коде. Из базы управляются лишь scope и instructions.

Добавление инструмента

ai.ToolDef{
    Declaration: gemini.FunctionDeclaration{
        Name:        "my_tool",
        Description: "Когда модель должна это вызывать…",
        Parameters:  map[string]any{ /* JSON Schema */ },
    },
    Execute: func(ctx context.Context, args map[string]any) (map[string]any, error) {
        // выполняется на бэкенде; ctx несёт личность запроса (работает RLS)
        return map[string]any{"result": "…"}, nil
    },
}

Зарегистрируйте его в cmd/api/server/server.go:

aiTools := append(ai.DefaultTools(), ai.KnowledgeSearchTool(aiRepo), myTool)

Инструменты «из коробки»

Семантический поиск (RAG)

Знания платформы хранятся в ai_knowledge примерно 58 фрагментами. Вопрос векторизуется через Gemini и сопоставляется по косинусной близости в pgvector, поэтому вопрос, заданный иначе, всё равно находит нужный фрагмент. Векторы дозаполняются автоматически при старте; в Админ → Настройки есть кнопка ручной переиндексации.

  • search_knowledge — семантический поиск по ai_knowledge: вопрос векторизуется, из pgvector берутся топ-8 совпадений, затем они фильтруются относительно лучшего (всё, что ниже него более чем на 0.03, отбрасывается; остаётся 2–4). Фиксированный порог здесь не работает — даже несвязанные фрагменты этого корпуса имеют близость 0.64+. Если векторы недоступны, происходит откат к ILIKE: вопрос разбивается на слова и поиск идёт по основам самых длинных из них. Базовые ограничения предписывают модели вызывать его до ответа на вопросы о платформе и говорить «не знаю», а не догадываться, если ничего не найдено. Пополняйте корпус вставкой строк — новые строки векторизуются автоматически.
  • get_server_time — минимальный пример (время Улан-Батора), без зависимостей.

Публичный чат на главной (без входа)

Плавающий виджет в правом нижнем углу вызывает POST /public/ai/chat без токена. Он работает на отдельном экземпляре usecase, подключённом только к инструменту поиска по базе знаний, поэтому до данных пользователей не добирается. ~6 запросов/мин на IP, сообщение ≤ 1000 символов, история ≤ 6 реплик; в системный промпт добавляется слой «анонимный посетитель». Виджет обращается к POST /public/ai/chat/stream (SSE), поэтому ответ появляется по мере написания. Push-to-talk (удерживайте большую круглую кнопку) отправляет фрагмент ~250 КБ base64 (≈ 15 с), и один вызов модели возвращает и расшифровку (событие transcript), и ответ. На голосовые вопросы виджет отвечает вслух по предложениям через POST /public/ai/tts — речь начинается уже после первой фразы.

Голос

Возможность Endpoint Как работает
Голосовое сообщение в чате POST /ai/chat с audio Аудио идёт прямо в реплику пользователя как встроенные данные — чат-модель мультимодальна
Речь в текст POST /ai/stt Один вызов со строгой инструкцией «расшифровать дословно»; пустой текст = речи нет
Текст в речь POST /ai/tts Отдельная TTS-модель с responseModalities: ["AUDIO"]; сырой PCM (L16/24 кГц) оборачивается в WAV-заголовок для воспроизведения в браузере
Синхронный перевод POST /ai/translate Текст → перевод; аудио → два шага STT→перевод; speak: true добавляет озвучку (сбой TTS деградирует молча — текст всё равно возвращается)

Аудио на входе фильтруется по mime-типу (webm/ogg/wav/mpeg/mp3/mp4/m4a/aac/flac) и ограничено ~700 КБ base64 (~30 с opus) и в BFF, и в DTO бэкенда.

Деталь синхронного перевода

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

Ограничение запросов

/ai/* ограничен примерно 20 запросами в минуту на IP. Синхронный перевод отправляет около 8 фрагментов в минуту, поэтому снижайте лимит осторожно.

Полные подробности — в репозитории: backend/docs/AI_PIPELINE.md.