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)¶
- Сформировать
contentsиз истории (≤ 20 реплик) и нового промпта. Голосовое сообщение приходит как встроенный аудиофрагмент в base64 — Gemini понимает его напрямую, отдельный шаг STT не нужен. - Вызвать Gemini со слоистой системной инструкцией и объявлениями инструментов.
- Если ответ содержит вызовы функций: выполнить каждый инструмент, добавить
реплику модели и реплику
functionResponse, затем повторить цикл (доMaxSteps, по умолчанию 4). Каждый выполненный вызов записывается какStep{Tool, Args, Result}и возвращается клиенту, чтобы интерфейс мог показать, «что сделал AI». - Если ответ — текст: вернуть его.
Семантика сбоев¶
| Случай | Результат |
|---|---|
| Временный сбой Gemini (после трёх повторов клиента) | Не 5xx — резервный ответ на языке пользователя с degraded: true |
Отсутствует GEMINI_API_KEY |
Настоящая ошибка — 500, причина в логах |
| Неизвестный или упавший инструмент | Возвращается модели как {"error": …} — клиенту напрямую не попадает |
Не меняйте это поведение
При временных сбоях Gemini чат должен корректно деградировать до резервного ответа — не превращайте это в 5xx.
Слои промпта¶
Системный промпт собирается на каждый запрос из трёх слоёв:
- Жёстко заданные ограничения — зафиксированы в коде, не настраиваются.
- Область применения (scope) — из таблицы
ai_prompts, редактируется админом. - Инструкции — также настраиваются из базы данных.
Язык ответа
Фронтенд передаёт язык интерфейса (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:
Инструменты «из коробки»¶
Семантический поиск (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.