schlmpcs/megroup_ai_teacher

Python

0

86 commits

updated Aug 7, 2026

See the code

README

VR AI Assistant (локальный гибридный RAG + OpenAI)

ИИ-помощник для школьного VR-тренажёра (физика, химия, биология). Тонкий FastAPI-прокси между VR-клиентом, локальным ретривал-стеком и OpenAI. Для follow-up вопросов /ask и /voice_ask держат ограниченную TTL-память диалога в процессе API:

  • База знаний локальная (self-hosted Qdrant). Гибридный поиск: плотный вектор (dense, 1024-d, cosine) + разреженный (sparse), две ветки Prefetch в Qdrant Query API, слитые через RRF (Reciprocal Rank Fusion).
  • Эмбеддинги: локальный GPU-сайдкар embedder на модели BAAI/bge-m3 (мультиязычная, поддерживает русский, казахский и английский; FlagEmbedding BGEM3FlagModel, отдаёт dense + learned-sparse). Рассчитан на NVIDIA RTX 3060 12GB (Ampere sm_86).
  • Генерация: OpenAI Responses API. RAG явный: retrieve → inject → generate. Найденные чанки подставляются в системный промт (а не hosted file_search). Ссылки на источники собираются из метаданных чанков (ТЗ §4).
  • Контекст сценария (текущая сцена, шаги, объекты) подставляется в системный промт по scenario_id (ТЗ §3.2).
  • Профили ассистентов: встроенные и созданные администратором профили имеют отдельные system prompt, коллекцию Qdrant и корень корпуса. Динамические профили хранятся в общей SQLite-базе API и ingestion worker.
  • Голос: STT и TTS обслуживает встроенный GPU-сайдкар voice (Whisper ru/kk/en/auto + Supertonic по умолчанию / Qwen3-TTS 0.6B по явному выбору на русском и английском, отдельный OmniVoice с фиксированным молодым мужским голосом на казахском и MMS как fallback, каталоги ./voice и ./voice_omnivoice). Перед синтезом числа детерминированно раскрываются словами на языке соответствующего сайдкара; полный конвейер /voice_ask под целевую задержку ≤5 c (ТЗ §5, §7).

Прокси держит секретный ключ OpenAI у себя. VR-клиент аутентифицируется только коротким INTERNAL_API_KEY, а реальный ключ OpenAI в приложение не попадает.

Архитектура

VR-клиент (Unity)
   │  Authorization: Bearer <INTERNAL_API_KEY>
   ▼
FastAPI-прокси (этот репозиторий)            ┌─ scenarios/*.json  (контекст сцен)
   │  http://api:8000 (хост :8001)            ├─ инъекция в system prompt
   │                                           └─ assistant_profiles.sqlite3
   ├─► embedder (bge-m3, GPU)        POST http://embedder:8080/embed
   │       └─ dense + sparse эмбеддинг запроса
   ├─► Qdrant                        http://qdrant:6333 (Query API + RRF)
   │       └─ гибридный поиск (dense + sparse, RRF) → top-k чанки → system prompt
   ├─► voice-сайдкар (GPU)           POST http://voice:8001/stt/recognize
   │       ├─ Whisper (ru/kk/en/auto) POST http://voice:8001/tts/synthesize?format=wav
   │       ├─ Supertonic / Qwen / MMS (ru)
   │       ├─ Supertonic / Qwen, shared instances (en)
   │       └─ MMS fallback (kk)
   ├─► voice-omnivoice (GPU)         POST http://voice-omnivoice:8003/tts/synthesize
   │       └─ OmniVoice fixed young-male default (kk)
   ▼
OpenAI: Responses API (генерация, ключ OpenAI)

Хосты вида http://embedder:8080 являются именами сервисов внутри docker-compose сети. Для локального запуска прокси вне Docker используйте localhost и host-mapped порты (см. таблицу «Подключения и порты» ниже).

Документы (PDF/DOCX/EPUB/TXT/MD) парсятся локально, нарезаются на чанки, эмбеддятся через embedder и кладутся в Qdrant. Всё выполняется on-prem. Ретривал изолирован за app/services/embeddings.py (HTTP-клиент к эмбеддеру) и app/services/vectorstore.py (обёртка над Qdrant Query API + RRF); генерация осталась в облаке OpenAI. Форма ответа для VR-клиента (citations, primary_source) не изменилась. Ссылки пересобираются из метаданных чанков.

Быстрый старт

Проще всего поднять весь стек через Docker:

cp .env.example .env
python -c "import secrets; print(secrets.token_urlsafe(32))"  # INTERNAL_API_KEY
python -c "import secrets; print(secrets.token_urlsafe(32))"  # distinct ADMIN_API_KEY
python -c "import secrets; print(secrets.token_urlsafe(32))"  # ADMIN_UI_SESSION_SECRET
python -m admin_ui.hash_password                              # ADMIN_UI_PASSWORD_HASH
# Set OPENAI_API_KEY and the four generated values in .env.
docker compose up --build     # 8 сервисов (порты ниже)

docker compose поднимает восемь сервисов:

СервисОбраз / buildПорт (хост → контейнер)GPU
gatewaynginx:alpine8001 → 8080нет
api.нет, доступен через gatewayнет
ingestion-worker.нетнет
admin-ui./admin_ui8004 → 8000нет
qdrantqdrant/qdrant:latest6333 → 6333, 6334 → 6334нет
embedder./embedder8080 → 8080да
voice./voice8002 → 8001да
voice-omnivoice./voice_omnivoice8003 → 8003да

embedder, voice и voice-omnivoice являются GPU-сайдкарами, требуют NVIDIA Container Toolkit и делят одну карту; первый запуск скачивает модели (bge-m3, Whisper, TTS), поэтому стартует медленно.

Панель оператора

Для полного запуска нужны разные значения INTERNAL_API_KEY и ADMIN_API_KEY, ключ OPENAI_API_KEY, сгенерированный ADMIN_UI_SESSION_SECRET и хэш из python -m admin_ui.hash_password. После настройки .env операторские сервисы запускаются так:

python -m admin_ui.hash_password
docker compose up -d --build api ingestion-worker admin-ui gateway
open http://localhost:8004

Для локальной проверки по HTTP на http://localhost:8004 задайте ADMIN_UI_COOKIE_SECURE=false. В продакшене за HTTPS используйте ADMIN_UI_COOKIE_SECURE=true. Compose передаёт сервису admin-ui оба серверных API-ключа; браузер получает только защищённую сессию и CSRF-токен. Одна панель содержит загрузку, задания, документы и вкладку Тест API для /health, /ready, /ask, streaming chat, /hint, /stt, /tts и /voice_ask. Удаление отсутствующих документов доступно только при полном сканировании с пустым относительным путём. Загруженные файлы и история задания хранятся до удаления завершённого задания из панели.

Локальная разработка прокси (Qdrant и embedder при этом удобно держать в Docker):

python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env          # INTERNAL_API_KEY, OPENAI_API_KEY,
                              # QDRANT_URL, EMBEDDING_BASE_URL и т.д.

# 1) создать коллекцию в Qdrant (dense + sparse именованные векторы)
python -m scripts.manage_corpus create-collection

# 2) загрузить отдельные общие документы без предметных метаданных
python -m scripts.manage_corpus upload materials/physics_8.pdf
python -m scripts.manage_corpus upload materials/chem_9.docx
python -m scripts.manage_corpus list
python -m scripts.manage_corpus status

# 3) запустить сервис
uvicorn app.main:app --reload --port 8000
# docs: http://localhost:8000/docs

Удалить документ из коллекции: python -m scripts.manage_corpus delete <doc_id>.

Для полной или массовой загрузки учебников и лабораторных инструкций предпочтителен структурированный bulk-ingest, описанный ниже. Отдельный тегированный документ можно загрузить через backend API POST /admin/documents, явно передав его предметные метаданные. CLI-команда upload по-прежнему загружает общий документ без предметных метаданных.

Загрузка учебников, лабораторных инструкций и сценариев

Система использует три независимых источника контекста:

ИсточникГде хранитсяКак добавляется
Учебники и теорияQdrant, doc_type=textbookPOST /admin/documents для одного файла или manage_corpus.py bulk-ingest для целого корпуса
Инструкции к лабораторным работамQdrant, doc_type=lab_instructionPOST /admin/documents для одного файла или bulk-ingest из правильной структуры каталогов
Статическая логика VR-сценыJSON в scenarios/добавить или изменить файл вручную

scenario_state, который тренажёр передаёт в каждом запросе, является четвёртым источником. Это актуальное состояние сцены, а не загружаемый документ. При расхождении со статическим JSON оно имеет приоритет.

1. Подготовить структуру корпуса

bulk-ingest определяет метаданные из пути. Поддерживаются файлы .pdf, .docx, .epub, .txt и .md. Минимальная структура выглядит так:

Лабораторные физхимбио/
└── Материалы лабок/
    ├── Школьный материал 7-11 класс 3 предмета/
    │   └── Физика/
    │       └── рус/
    │           └── Физика 8 класс.pdf
    └── Лабораторные работы/
        └── Физика/
            └── Физика 10 класс/
                └── рус/
                    └── Лабораторная работа №2.docx

Английская структура поддерживается тем же загрузчиком:

Corpus/
├── School materials/Biology/en/Biology Grade 9.epub
└── Laboratory works/Physics/Physics Grade 10/en/Lab work No. 2.docx

Правила распознавания путей:

  • каталог типа документа должен начинаться с Школьный материал или называться Лабораторные работы; английские aliases: School materials, Textbooks, Laboratory works, Lab instructions;
  • предмет должен быть Физика, Химия, Биология, Physics, Chemistry или Biology;
  • для лабораторной инструкции класс берётся из каталога вида Физика 10 класс, а для учебника из имени файла;
  • язык определяется по каталогу рус, русс, каз, қаз, en, eng или english;
  • номер лабораторной работы берётся из первого номера в имени файла, например Лабораторная работа №2.docx, Зертханалық жұмыс № 2.docx или Lab work No. 2.docx.

Из этих значений формируется стабильный lab_id. Например, путь выше получает physics-10-ru-02; английский пример получает physics-10-en-02. Тренажёр должен передать те же значения в объекте lab.

Если корпус находится не в каталоге по умолчанию, задайте путь в .env:

CORPUS_ROOT=/absolute/path/to/Лабораторные физхимбио
LABS_MANIFEST=./labs.json

2. Проверить лабораторные работы до загрузки

Команда не обращается к Qdrant или embedder. Она проверяет пути, извлекает текст и создаёт отчёт labs.json:

Перед запуском создайте .env из .env.example и задайте непустой INTERNAL_API_KEY. Corpus CLI использует общие настройки приложения, хотя для gen-manifest и bulk-ingest ключ OpenAI не применяется.

python -m scripts.manage_corpus gen-manifest

В отчёте:

  • complete означает, что инструкция распознана и содержит достаточно текста;
  • stub означает, что файл найден, но извлечённого текста слишком мало;
  • missing_metadata содержит пути, для которых не удалось определить обязательные метаданные.

Исправьте stub и missing_metadata до основной загрузки. Лабораторная работа без инструкции в Qdrant считается неполной: ассистент отвечает только по теории и не придумывает последовательность действий.

3. Загрузить весь корпус

Qdrant и embedder должны быть доступны по QDRANT_URL и EMBEDDING_BASE_URL:

docker compose up -d qdrant embedder
python -m scripts.manage_corpus create-collection
python -m scripts.manage_corpus bulk-ingest

Повторная загрузка безопасно заменяет чанки документа с тем же относительным путём. Для загрузки только части дерева используйте --only, не меняя корневой каталог. Это сохраняет стабильные идентификаторы документов:

python -m scripts.manage_corpus bulk-ingest --only 'Биология/рус'

Для сканированных PDF или EPUB включите OCR. На машине, где запускается команда, нужны Tesseract и языковые модели rus/kaz/eng. Они включены в Docker-образ API, но для запуска CLI непосредственно на хосте их нужно установить отдельно:

python -m scripts.manage_corpus bulk-ingest --ocr --only 'Биология/рус'

При обработке PDF загрузчик удаляет известные водяные знаки и служебные строки OKULYK, а также таблицы из одних номеров страниц. Если после очистки текстовый слой остаётся чрезмерно повторяющимся или малоинформативным, --ocr запускает Tesseract даже при формально большом количестве распознанных слов.

4. Проверить результат

python -m scripts.manage_corpus status
python -m scripts.manage_corpus list
python -m scripts.manage_corpus gen-manifest

После загрузки проверьте тестовый запрос с тем же контекстом, который отправляет тренажёр:

curl -s localhost:8000/ask \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "Что делать дальше?",
    "scenario_id": "physics_lab_02_heating",
    "lab": {
      "subject": "physics",
      "grade": 10,
      "lang": "ru",
      "lab_number": 2
    },
    "scenario_state": {
      "current_step_id": "heat-water",
      "current_step_index": 3,
      "current_step": "Нагреть воду",
      "next_step": "Записать температуру",
      "held_items": ["термометр"],
      "visible_items": ["стакан", "штатив"],
      "allowed_actions": ["включить нагрев"]
    }
  }'

Ответ должен содержать цитату на инструкцию с lab_id=physics-10-ru-02 и, если понадобилась теория, цитаты на учебник соответствующего предмета и класса.

5. Добавить статический сценарий VR

Сценарий не загружается в Qdrant. Создайте JSON-файл, имя которого совпадает с scenario_id:

scenarios/physics_lab_02_heating.json

Используйте существующий пример как шаблон. При запуске через Docker каталог scenarios/ смонтирован read-only, но изменения файлов подхватываются без пересборки контейнера.

Проверить список доступных сценариев можно через API:

curl -s localhost:8000/admin/scenarios \
  -H "Authorization: Bearer $KEY"

Сценарии через API не загружаются. Учебники, лабораторные инструкции и общие документы можно загружать по одному через backend API. Для синхронизации всего корпуса по структуре каталогов используйте bulk-ingest.

Одиночная загрузка через backend API

POST /admin/documents принимает multipart/form-data со следующими полями:

ПолеЗначенияНазначение
filePDF, DOCX, EPUB, TXT или MDобязательный загружаемый файл
doc_typetextbook или lab_instructionнеобязательный тип структурированного документа
subjectphysics, chemistry или biologyпредмет структурированного документа
gradeцелое число от 7 до 11класс структурированного документа
langru, kk или enязык структурированного документа
lab_numberцелое число от 1 до 99обязателен при doc_type=lab_instruction
ocrtrue или falseнеобязательно включить OCR, по умолчанию false

Все поля метаданных необязательны для общего документа. Если передано хотя бы одно из них, требуется doc_type; для обоих структурированных типов также требуются subject, grade и lang. Учебник не принимает lab_number.

Если отправить только file, документ останется общим и будет участвовать в поиске без структурированного lab-контекста. При передаче doc_type, subject, grade и lang backend сохраняет детерминированные метаданные. Для лабораторной инструкции он также строит стабильный lab_id, например physics-10-ru-02. Повторная загрузка файла с теми же метаданными и тем же именем заменяет существующий документ и его чанки, а не создаёт дубль.

Пример загрузки учебника на развёрнутый backend:

API_BASE=https://megroup-b560m-hdv-m-2.tail7dd37a.ts.net

curl -sS -X POST "$API_BASE/admin/documents" \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@materials/physics_8_ru.pdf' \
  -F 'doc_type=textbook' \
  -F 'subject=physics' \
  -F 'grade=8' \
  -F 'lang=ru' \
  -F 'ocr=false'

Пример загрузки инструкции к лабораторной работе:

API_BASE=https://megroup-b560m-hdv-m-2.tail7dd37a.ts.net

curl -sS -X POST "$API_BASE/admin/documents" \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@materials/physics_lab_02_ru.docx' \
  -F 'doc_type=lab_instruction' \
  -F 'subject=physics' \
  -F 'grade=10' \
  -F 'lang=ru' \
  -F 'lab_number=2' \
  -F 'ocr=false'

Для общего документа без предметных метаданных можно использовать тот же API с одним полем file или CLI:

python -m scripts.manage_corpus upload path/to/document.pdf

API-вариант без метаданных:

curl -s localhost:8000/admin/documents \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@path/to/document.pdf'

Управление загруженными документами:

API_BASE=https://megroup-b560m-hdv-m-2.tail7dd37a.ts.net

# Список документов и их file_id
curl -sS "$API_BASE/admin/documents" -H "Authorization: Bearer $KEY"

# Удаление документа и всех его чанков
curl -sS -X DELETE "$API_BASE/admin/documents/<file_id>" \
  -H "Authorization: Bearer $KEY"

# Состояние коллекции Qdrant
curl -sS "$API_BASE/admin/corpus_status" -H "Authorization: Bearer $KEY"

Подключения и порты

Прокси является единственным сервисом, который ходит наружу. К локальным сервисам он подключается по HTTP через ленивый httpx.AsyncClient (Qdrant через свой клиент). Под docker-compose адреса по умолчанию указывают на имена сервисов; для запуска прокси вне Docker переопределите их на localhost + host-mapped порт.

ПодключениеПеременнаяURL под compose (по умолчанию)URL для локального devЭндпоинты, которые вызывает прокси
VR-клиент → APIINTERNAL_API_KEYAuthorization: BearerAuthorization: Bearerвесь публичный API (см. ниже)
API → OpenAIOPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODELapi.openai.com (если OPENAI_BASE_URL пуст)то жеResponses API
API → QdrantQDRANT_URLhttp://qdrant:6333http://localhost:6333Query API (поиск), upsert/delete
API → embedderEMBEDDING_BASE_URLhttp://embedder:8080http://localhost:8080POST /embed
API → voiceVOICE_BASE_URLhttp://voice:8001http://localhost:8002POST /stt/recognize, POST /tts/synthesize?format=wav
API → voice-omnivoiceVOICE_KK_OMNIVOICE_BASE_URLhttp://voice-omnivoice:8003http://localhost:8003POST /tts/synthesize?format=wav

⚠️ Локальный dev: значения по умолчанию в .env.example уже указывают на localhost (QDRANT_URL=http://localhost:6333, EMBEDDING_BASE_URL=http://localhost:8080, VOICE_BASE_URL=http://localhost:8002, VOICE_KK_OMNIVOICE_BASE_URL=http://localhost:8003). Под docker-compose их нужно переопределить на имена сервисов (qdrant/embedder/voice/voice-omnivoice). Это уже сделано в environment: блоке compose-файла, отдельно в .env менять не требуется.

Публичный доступ (Tailscale Funnel, HTTPS)

Gateway выставлен наружу через Tailscale Funnel, публичный HTTPS-эндпоинт с автоматическим TLS, без проброса портов и без Tailscale на стороне клиента. На порту 8001 gateway сохраняет все API-маршруты и отдаёт панель администратора на /; голосовой порт 8002 не публикуется.

ЧтоURLАвторизация
Панель администратораhttps://megroup-b560m-hdv-m-2.tail7dd37a.ts.netлогин и пароль администратора
API прокси (/ask, /voice_ask, /hint, …)https://megroup-b560m-hdv-m-2.tail7dd37a.ts.netAuthorization: Bearer <INTERNAL_API_KEY>
Swagger UIhttps://megroup-b560m-hdv-m-2.tail7dd37a.ts.net/docsвызовы требуют Bearer-ключ
Healthcheckhttps://megroup-b560m-hdv-m-2.tail7dd37a.ts.net/healthнет

Включается на сервере: sudo tailscale funnel --bg 8001 (статус: tailscale funnel status, выключение: sudo tailscale funnel --https=443 off). Каждый вызов /ask//hint идёт в OpenAI и стоит денег. Держите INTERNAL_API_KEY в секрете и RATE_LIMIT_PER_MINUTE > 0.

Переменные окружения (API, генерация и состояние)

ПеременнаяНазначение
INTERNAL_API_KEYBearer-ключ публичного VR API; не даёт доступ к /admin/*
ADMIN_API_KEYотдельный Bearer-ключ backend endpoints /admin/*; не передавать VR-клиенту
OPENAI_API_KEYключ OpenAI, обязательный для генерации ответов
OPENAI_BASE_URLнеобязательный URL Azure или совместимого прокси
OPENAI_MODELмодель Responses API, по умолчанию gpt-4.1-mini
OPENAI_SERVICE_TIERпусто для стандартного режима или priority для меньшей задержки и большей стоимости
REQUEST_TIMEOUT_Sтаймаут запроса генерации
ANSWER_CACHE_SIZE / ANSWER_CACHE_TTL_Sразмер и TTL кэша повторяющихся ответов; ≤0 отключает кэш
TTS_CACHE_SIZEразмер кэша синтезированного аудио; ≤0 отключает кэш
CHAT_MEMORY_MAX_CONVERSATIONS / CHAT_MEMORY_TTL_Sразмер и TTL эфемерной памяти VR-диалогов; ≤0 отключает серверную память
CHAT_MEMORY_MAX_MESSAGES / CHAT_MEMORY_HISTORY_CHARSлимиты истории одного диалога
CHAT_MEMORY_RETRIEVAL_CONTEXT_CHARSобъём предыдущего диалога, добавляемый к ретривалу follow-up вопроса
INGESTION_DATA_DIRобщий каталог SQLite очереди, загруженных файлов и динамических профилей; compose использует /data/ingestion
ALLOW_GENERAL_KNOWLEDGE_FALLBACKразрешить ответ из общеизвестных научных знаний без цитат только вне структурированного контекста лабораторной работы, если найденные материалы непригодны и нет авторитетного контекста сцены/процедуры

Переменные окружения (ретривал)

ПеременнаяНазначение
QDRANT_URLадрес Qdrant (compose: http://qdrant:6333, local: http://localhost:6333)
QDRANT_COLLECTIONимя коллекции (по умолчанию school_kb)
EMBEDDING_BASE_URLадрес сайдкара-эмбеддера, вызов POST /embed
EMBEDDING_DIMразмерность плотного вектора (1024 для bge-m3)
EMBED_BATCH_SIZEразмер батча эмбеддинга (≤0 означает одним запросом)
RETRIEVAL_TOP_Kсколько чанков подставить в промт
RETRIEVAL_CANDIDATESсколько кандидатов тянуть из каждой ветки до RRF
RETRIEVAL_SCORE_THRESHOLDминимальный скор после слияния
CHUNK_SIZE / CHUNK_OVERLAPпараметры нарезки при загрузке
CORPUS_ROOTкорень структурированного корпуса для bulk-ingest
LABS_MANIFESTпуть для отчёта gen-manifest
OCR_ENABLED / OCR_DPI / OCR_MAX_PAGESOCR для сканированных материалов при загрузке

Переменные окружения (голос)

ПеременнаяНазначение
VOICE_BASE_URLадрес основного voice-сайдкара (compose: http://voice:8001, local: http://localhost:8002)
VOICE_KK_OMNIVOICE_BASE_URLадрес отдельного сайдкара Kazakh OmniVoice (compose: http://voice-omnivoice:8003, local: http://localhost:8003)
VOICE_VERIFY_SSLпроверять ли TLS (по умолчанию false, внутри compose plain HTTP)
VOICE_TIMEOUT_Sтаймаут STT/TTS (по умолчанию 120, покрывает GPU cold start)
VOICE_TTS_RU_DEFAULT_BACKENDbackend, который API явно отправляет для русского TTS без поля backend; по умолчанию supertonic
VOICE_TTS_EN_DEFAULT_BACKENDbackend для английского TTS без поля backend: supertonic по умолчанию или qwen
VOICE_TTS_KK_DEFAULT_BACKENDbackend по умолчанию для казахского TTS: omnivoice (фиксированный молодой мужской голос) или mms (fallback)
NVIDIA_PERSISTENCE_MODE1 просит драйвер включить persistence mode при каждом старте voice-контейнера; ошибка не блокирует запуск приложения
STT_KEEP_WARM_ENABLEDзапускать реальные RU/KK STT-пробы при старте и после периода без пользовательских запросов; по умолчанию true
STT_KEEP_WARM_REAL_IDLE_Sсколько секунд без завершённого пользовательского STT должно пройти до фоновой пробы; по умолчанию 180
STT_KEEP_WARM_INTERVAL_Sминимальный интервал между успешными фоновыми прогревами; по умолчанию 240
STT_KEEP_WARM_POLL_Sчастота проверки необходимости прогрева; по умолчанию 15
OMNIVOICE_MODEL / OMNIVOICE_INSTRUCT / OMNIVOICE_STEPScheckpoint, фиксированный голосовой профиль и число diffusion-шагов отдельного OmniVoice-сайдкара
OMNIVOICE_AUDIO_TOKENIZER_PATHсмонтированный локальный путь к eustlb/higgs-audio-v2-tokenizer, чтобы запуск OmniVoice не зависел от Hugging Face
OMNIVOICE_NORMALIZE_KK_NUMBERSраскрывать числа казахскими словами непосредственно перед OmniVoice; по умолчанию true, false отключает для диагностики
TTS_RU_BACKEND / TTS_RU_BACKENDSdefault и список загружаемых русских backend-ов voice-сайдкара; по умолчанию supertonic и supertonic,qwen
TTS_EN_BACKENDанглийский default внутри voice-сайдкара; использует уже загруженный supertonic или qwen
TTS_RU_QWEN_MODELHugging Face ID Qwen3-TTS; по умолчанию Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
TTS_RU_QWEN_SPEAKERмужской голос Qwen по умолчанию; Aiden
TTS_RU_QWEN_DTYPE / TTS_RU_QWEN_ATTENTIONтип весов и attention implementation; bfloat16 / sdpa
TTS_RU_SUPERTONIC_VOICE_STYLEстиль Supertonic по умолчанию; M3
TTS_SUPERTONIC_MODEL_DIRпостоянный каталог общих RU/EN Supertonic assets; compose использует /models/hf_cache/supertonic3 в volume voice_hf_cache
TTS_NORMALIZE_RU_NUMBERSраскрывать числа русскими словами перед Supertonic, Qwen и MMS; по умолчанию true, false отключает для диагностики
TTS_NORMALIZE_EN_NUMBERSбезопасно раскрывать английские числа, единицы, даты, время, проценты и температуры; по умолчанию true
DEFAULT_LANGUAGEрезервный язык ответа после явного language, текущего текста, недавней истории и lab.lang; также язык TTS по умолчанию (ru/kk/en), STT без language использует auto

VOICE_TTS_EN_DEFAULT_BACKEND управляет публичными /tts и /voice_ask: прокси всегда передаёт выбранный backend в voice-сайдкар. TTS_EN_BACKEND используется только как default при прямом вызове sidecar API. Обычно оба значения должны совпадать. Английский STT использует уже загруженную мультиязычную Whisper-модель, поэтому отдельного английского checkpoint или keep-warm аудиофайла не требуется.

Нормализация чисел для TTS

Русский, казахский и английский маршруты используют отдельные детерминированные нормализаторы из репозитория. Они обрабатывают целые и отрицательные числа, десятичные дроби с точкой или запятой, проценты, даты, время, диапазоны, температуру и школьные единицы измерения. Химические формулы, научная нотация, уравнения, URL, имена файлов, лабораторные ID и буквенно-цифровые идентификаторы маскируются на время преобразования и восстанавливаются без изменений. Общий normalize_text из OmniVoice не используется, поэтому результат не зависит от num2words или языка fallback-нормализатора библиотеки.

Voice image pins supertonic==1.3.1, the tested release with official ru and en support. English and Russian reuse one loaded Supertonic instance and one loaded Qwen instance, so English does not duplicate those large models in memory. Supertonic files live in the persistent voice_hf_cache volume rather than the container filesystem.

Примеры текста, который получает модель:

Суды жиырма бес градус Цельсийге дейін қыздырыңыз.
Ерітіндіге үш бүтін жүзден он төрт грамм тұз қосыңыз.
Тәжірибе екі мың жиырма алтыншы жылғы он екінші наурыз күні сағат он төрт отызда басталады.
H2O молекуласында екі сутек атомы бар.

Нагрейте воду до двадцати пяти градусов Цельсия.
Добавьте три целых четырнадцать сотых грамма соли.
Опыт начнётся двенадцатого марта две тысячи двадцать шестого года в четырнадцать часов тридцать минут.
В молекуле H2O есть два атома водорода.

Heat two hundred fifty milliliters to twenty five degrees Celsius.
The solution is twelve point five percent.
H2O, LAB-204, report2.csv, and URLs remain unchanged.

Эндпоинты

Публичные endpoints требуют Authorization: Bearer <INTERNAL_API_KEY>, административные /admin/* требуют отдельный ADMIN_API_KEY, а /health и /ready доступны без авторизации.

МетодПутьНазначение
POST/askВопрос с грунтингом: {assistant_type?, query, language?, conversation_id?, scenario_id?, scenario_state?, lab?} → ответ + language + citations + conversation_id
POST/v1/chat/completionsOpenAI-совместимый чат (stream поддерживается), расширен полями assistant_type, language=ru/kk/en, scenario_id, scenario_state, lab
POST/hintПерефразирование подсказки: {hint_text, hint_level, language?, scenario_id?, scenario_state?} с возвращаемым language
POST/sttmultipart file, необязательный language=ru/kk/en/auto → {text, language}; без language включается автоопределение
POST/tts{text, language?, backend?, voice?, format?, instructions?} → WAV; русский = supertonic / qwen / mms, английский = supertonic (default) / qwen, казахский = omnivoice (default) / mms
POST/voice_askmultipart file, language=ru/kk/en/auto и необязательные assistant_type, response_language=ru/kk/en, tts_backend, поля лаборатории → JSON или SSE с language metadata
DELETE/v1/conversations/{conversation_id}удалить историю VR-диалога в namespace выбранного assistant_type
GET/POST/admin/assistant-profilesсписок профилей / создание динамического профиля
GET/admin/corpus_statusсостояние коллекции Qdrant
POST/admin/documentsзагрузить общий или структурированный документ в KB: multipart file, метаданные и необязательный OCR
GET/DELETE/admin/documents[/{file_id}]список / удаление по file_id
GET/admin/scenariosсписок сценариев
GET/health, /readyпроверки

Профили ассистентов

Клиент передаёт только доверенный идентификатор assistant_type. Сервер разрешает его во встроенном реестре или в общей SQLite-базе и сам выбирает system prompt, коллекцию Qdrant и corpus root. По умолчанию используется vr_lab_teacher.

Администратор может создать профиль без изменения кода и перезапуска сервисов:

curl -sS -X POST "$API_BASE/admin/assistant-profiles" \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "assistant_type": "math_tutor",
    "system_prompt": "You are a mathematics tutor."
  }'

Backend создаст коллекцию math_tutor_kb, каталог $INGESTION_DATA_DIR/assistant_corpora/math_tutor и сохранит профиль в assistant_profiles.sqlite3. Поля инфраструктуры в запросе запрещены. Полный контракт, операции с корпусом и ограничения описаны в руководстве по профилям.

English support

English uses canonical code en. language selects the answer language, while lab.lang continues to select the preferred corpus language and exact lab_id. They may differ, for example language=en with lab.lang=ru produces an English answer grounded in the Russian procedure. The default remains Russian.

curl -s localhost:8000/ask \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "What is the purpose of this lab?",
    "language": "en",
    "lab": {"subject": "physics", "grade": 10, "lang": "en", "lab_number": 2}
  }'

The same language=en extension is supported by /v1/chat/completions and /hint. Structured uploads accept lang=en.

English STT and TTS

VR clients should call the authenticated proxy endpoints, not the internal voice-sidecar endpoints. The English voice contract is:

EndpointEnglish fieldsResult
/sttmultipart language=en or language=autoJSON {text, language}; detected English is returned as language=en
/ttsJSON language=en, optional `backend=supertonicqwen, optional voice`
/voice_askmultipart STT language, optional response_language=en, optional `tts_backend=supertonicqwen`

Supertonic is the default English TTS backend. Qwen can be selected per request. MMS and OmniVoice do not support English and are rejected with HTTP 422. Output from the proxy /tts endpoint is always audio/wav; legacy format and instructions fields are accepted only for client compatibility.

English STT with explicit language selection or automatic detection:

curl -sS localhost:8000/stt \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@question_en.wav' \
  -F 'language=en'

curl -sS localhost:8000/stt \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@question_en.wav' \
  -F 'language=auto'

English TTS with the default Supertonic backend or explicit Qwen selection:

curl -sS localhost:8000/tts \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"text":"Heat 250 mL to 25 °C.","language":"en"}' \
  -o answer_supertonic_en.wav

curl -sS localhost:8000/tts \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"text":"Heat 250 mL to 25 °C.","language":"en","backend":"qwen"}' \
  -o answer_qwen_en.wav

Full English voice pipeline. language controls STT, response_language controls the generated answer and TTS, and lang continues to select the laboratory procedure and corpus language:

curl -sS localhost:8000/voice_ask \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@question_en.wav' \
  -F 'language=auto' \
  -F 'response_language=en' \
  -F 'tts_backend=supertonic' \
  -F 'subject=physics' \
  -F 'grade=10' \
  -F 'lang=en' \
  -F 'lab_number=2'

If response_language is omitted, /voice_ask uses the resolved STT language for both the answer and TTS. The non-streaming response contains audio_base64; with stream=true, sentence-level WAV chunks arrive in SSE audio frames.

See the English migration and operations runbook for complete curl examples, corpus layout, ingestion, health checks, and rollout steps. Code support does not supply authoritative English textbooks or procedures. Those source files must be provided and ingested for complete production grounding.

Пример: /ask

curl -s localhost:8000/ask -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{
    "query": "Что делать дальше?",
    "scenario_id": "physics_lab_02_heating",
    "lab": {"subject": "physics", "grade": 10, "lang": "ru", "lab_number": 2}
  }'
{
  "answer": "Следующий шаг: начните равномерно нагревать воду и следите за термометром...",
  "citations": [{
    "filename": "Лабораторная работа №2.docx",
    "file_id": "...",
    "source_type": "lab_instruction",
    "lab_id": "physics-10-ru-02",
    "lab_number": 2,
    "display_label": "Инструкция к лабораторной работе №2"
  }],
  "primary_source": {
    "filename": "Лабораторная работа №2.docx",
    "file_id": "...",
    "source_type": "lab_instruction",
    "lab_id": "physics-10-ru-02",
    "lab_number": 2,
    "display_label": "Инструкция к лабораторной работе №2"
  },
  "conversation_id": "0e8c0ea8-3ad6-49c4-8678-7f12c33024b2",
  "scenario_id": "physics_lab_02_heating",
  "language": "ru",
  "observability": {"latency_ms": {"embed": 30.5, "retrieval": 18.1, "llm": 1840.2, "total": 1888.8}}
}

Клиент рисует под ответом блок «Источник: primary_source.filename». Для «объёмных» ответов (ТЗ §4) клиент может показать источник по запросу. Данные уже в citations. Поля страницы, главы и раздела добавляются только тогда, когда ingestion смог надёжно определить их из исходного документа. Для вопросов о текущем шаге и порядке действий инструкция к лабораторной работе ставится первой. Для теоретических вопросов первым остаётся найденный учебник.

Для follow-up вопросов VR-клиент сохраняет conversation_id из первого ответа и передаёт его в следующих /ask или /voice_ask запросах. Подробный контракт, SSE-поля и lifecycle описаны в docs/memory-backend-guide.md.

Сценарии (контекст ПО)

Каждая лабораторная использует один JSON-файл в scenarios/ (имя файла = scenario_id). Поля см. в scenarios/physics_lab_02_heating.json: scenario_name, language, subject, topic, environment_description, objects, action_sequence, risks, common_mistakes, correct_behavior, regulations и т.д. Заполняются только нужные поля. Это статическое описание сцены, а не инструкция в базе знаний. Английский демонстрационный вариант находится в scenarios/physics_lab_02_heating_en.json; /admin/scenarios возвращает язык, если он объявлен в JSON. Тренажёр передаёт scenario_id, структурированный lab и актуальный scenario_state в каждом запросе. Поля scenario_state включают текущий и следующий шаги, завершённые шаги, предметы в руках и в поле зрения, разрешённые действия, а также результат последнего действия.

Тесты

pytest          # без сети и GPU (OpenAI, Qdrant и embedder замоканы)

Для ручной проверки войдите в панель оператора и откройте вкладку Тест API. Она проверяет health/readiness, текстовые запросы, streaming, STT, TTS и полный voice_ask-конвейер через серверный прокси, не раскрывая API-ключ браузеру. Вызовы /ask, chat, /hint и /voice_ask используют OpenAI и расходуют токены.

Замечания по приёмке (ТЗ §7)

  • Только из загруженных файлов: системный промт запрещает выдумывать факты; ответы грунтятся подставленными чанками из гибридного поиска по Qdrant, источники возвращаются в citations.
  • Текущий шаг: актуальный scenario_state имеет приоритет над статическим сценарием по scenario_id.
  • ≤5 c: /voice_ask возвращает по-стадийные задержки (stt/llm/tts). STT/TTS обслуживает встроенный сайдкар voice (VOICE_BASE_URL, см. ./voice); для минимума латентности держите сайдкар «прогретым» и используйте быстрый OPENAI_MODEL.
  • Источники на каждый ответ: primary_source + citations в каждом ответе.

schlmpcs/megroup_ai_teacher

Python

0

86 commits

updated Aug 7, 2026

See the code

README

VR AI Assistant (локальный гибридный RAG + OpenAI)

ИИ-помощник для школьного VR-тренажёра (физика, химия, биология). Тонкий FastAPI-прокси между VR-клиентом, локальным ретривал-стеком и OpenAI. Для follow-up вопросов /ask и /voice_ask держат ограниченную TTL-память диалога в процессе API:

  • База знаний локальная (self-hosted Qdrant). Гибридный поиск: плотный вектор (dense, 1024-d, cosine) + разреженный (sparse), две ветки Prefetch в Qdrant Query API, слитые через RRF (Reciprocal Rank Fusion).
  • Эмбеддинги: локальный GPU-сайдкар embedder на модели BAAI/bge-m3 (мультиязычная, поддерживает русский, казахский и английский; FlagEmbedding BGEM3FlagModel, отдаёт dense + learned-sparse). Рассчитан на NVIDIA RTX 3060 12GB (Ampere sm_86).
  • Генерация: OpenAI Responses API. RAG явный: retrieve → inject → generate. Найденные чанки подставляются в системный промт (а не hosted file_search). Ссылки на источники собираются из метаданных чанков (ТЗ §4).
  • Контекст сценария (текущая сцена, шаги, объекты) подставляется в системный промт по scenario_id (ТЗ §3.2).
  • Профили ассистентов: встроенные и созданные администратором профили имеют отдельные system prompt, коллекцию Qdrant и корень корпуса. Динамические профили хранятся в общей SQLite-базе API и ingestion worker.
  • Голос: STT и TTS обслуживает встроенный GPU-сайдкар voice (Whisper ru/kk/en/auto + Supertonic по умолчанию / Qwen3-TTS 0.6B по явному выбору на русском и английском, отдельный OmniVoice с фиксированным молодым мужским голосом на казахском и MMS как fallback, каталоги ./voice и ./voice_omnivoice). Перед синтезом числа детерминированно раскрываются словами на языке соответствующего сайдкара; полный конвейер /voice_ask под целевую задержку ≤5 c (ТЗ §5, §7).

Прокси держит секретный ключ OpenAI у себя. VR-клиент аутентифицируется только коротким INTERNAL_API_KEY, а реальный ключ OpenAI в приложение не попадает.

Архитектура

VR-клиент (Unity)
   │  Authorization: Bearer <INTERNAL_API_KEY>
   ▼
FastAPI-прокси (этот репозиторий)            ┌─ scenarios/*.json  (контекст сцен)
   │  http://api:8000 (хост :8001)            ├─ инъекция в system prompt
   │                                           └─ assistant_profiles.sqlite3
   ├─► embedder (bge-m3, GPU)        POST http://embedder:8080/embed
   │       └─ dense + sparse эмбеддинг запроса
   ├─► Qdrant                        http://qdrant:6333 (Query API + RRF)
   │       └─ гибридный поиск (dense + sparse, RRF) → top-k чанки → system prompt
   ├─► voice-сайдкар (GPU)           POST http://voice:8001/stt/recognize
   │       ├─ Whisper (ru/kk/en/auto) POST http://voice:8001/tts/synthesize?format=wav
   │       ├─ Supertonic / Qwen / MMS (ru)
   │       ├─ Supertonic / Qwen, shared instances (en)
   │       └─ MMS fallback (kk)
   ├─► voice-omnivoice (GPU)         POST http://voice-omnivoice:8003/tts/synthesize
   │       └─ OmniVoice fixed young-male default (kk)
   ▼
OpenAI: Responses API (генерация, ключ OpenAI)

Хосты вида http://embedder:8080 являются именами сервисов внутри docker-compose сети. Для локального запуска прокси вне Docker используйте localhost и host-mapped порты (см. таблицу «Подключения и порты» ниже).

Документы (PDF/DOCX/EPUB/TXT/MD) парсятся локально, нарезаются на чанки, эмбеддятся через embedder и кладутся в Qdrant. Всё выполняется on-prem. Ретривал изолирован за app/services/embeddings.py (HTTP-клиент к эмбеддеру) и app/services/vectorstore.py (обёртка над Qdrant Query API + RRF); генерация осталась в облаке OpenAI. Форма ответа для VR-клиента (citations, primary_source) не изменилась. Ссылки пересобираются из метаданных чанков.

Быстрый старт

Проще всего поднять весь стек через Docker:

cp .env.example .env
python -c "import secrets; print(secrets.token_urlsafe(32))"  # INTERNAL_API_KEY
python -c "import secrets; print(secrets.token_urlsafe(32))"  # distinct ADMIN_API_KEY
python -c "import secrets; print(secrets.token_urlsafe(32))"  # ADMIN_UI_SESSION_SECRET
python -m admin_ui.hash_password                              # ADMIN_UI_PASSWORD_HASH
# Set OPENAI_API_KEY and the four generated values in .env.
docker compose up --build     # 8 сервисов (порты ниже)

docker compose поднимает восемь сервисов:

СервисОбраз / buildПорт (хост → контейнер)GPU
gatewaynginx:alpine8001 → 8080нет
api.нет, доступен через gatewayнет
ingestion-worker.нетнет
admin-ui./admin_ui8004 → 8000нет
qdrantqdrant/qdrant:latest6333 → 6333, 6334 → 6334нет
embedder./embedder8080 → 8080да
voice./voice8002 → 8001да
voice-omnivoice./voice_omnivoice8003 → 8003да

embedder, voice и voice-omnivoice являются GPU-сайдкарами, требуют NVIDIA Container Toolkit и делят одну карту; первый запуск скачивает модели (bge-m3, Whisper, TTS), поэтому стартует медленно.

Панель оператора

Для полного запуска нужны разные значения INTERNAL_API_KEY и ADMIN_API_KEY, ключ OPENAI_API_KEY, сгенерированный ADMIN_UI_SESSION_SECRET и хэш из python -m admin_ui.hash_password. После настройки .env операторские сервисы запускаются так:

python -m admin_ui.hash_password
docker compose up -d --build api ingestion-worker admin-ui gateway
open http://localhost:8004

Для локальной проверки по HTTP на http://localhost:8004 задайте ADMIN_UI_COOKIE_SECURE=false. В продакшене за HTTPS используйте ADMIN_UI_COOKIE_SECURE=true. Compose передаёт сервису admin-ui оба серверных API-ключа; браузер получает только защищённую сессию и CSRF-токен. Одна панель содержит загрузку, задания, документы и вкладку Тест API для /health, /ready, /ask, streaming chat, /hint, /stt, /tts и /voice_ask. Удаление отсутствующих документов доступно только при полном сканировании с пустым относительным путём. Загруженные файлы и история задания хранятся до удаления завершённого задания из панели.

Локальная разработка прокси (Qdrant и embedder при этом удобно держать в Docker):

python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env          # INTERNAL_API_KEY, OPENAI_API_KEY,
                              # QDRANT_URL, EMBEDDING_BASE_URL и т.д.

# 1) создать коллекцию в Qdrant (dense + sparse именованные векторы)
python -m scripts.manage_corpus create-collection

# 2) загрузить отдельные общие документы без предметных метаданных
python -m scripts.manage_corpus upload materials/physics_8.pdf
python -m scripts.manage_corpus upload materials/chem_9.docx
python -m scripts.manage_corpus list
python -m scripts.manage_corpus status

# 3) запустить сервис
uvicorn app.main:app --reload --port 8000
# docs: http://localhost:8000/docs

Удалить документ из коллекции: python -m scripts.manage_corpus delete <doc_id>.

Для полной или массовой загрузки учебников и лабораторных инструкций предпочтителен структурированный bulk-ingest, описанный ниже. Отдельный тегированный документ можно загрузить через backend API POST /admin/documents, явно передав его предметные метаданные. CLI-команда upload по-прежнему загружает общий документ без предметных метаданных.

Загрузка учебников, лабораторных инструкций и сценариев

Система использует три независимых источника контекста:

ИсточникГде хранитсяКак добавляется
Учебники и теорияQdrant, doc_type=textbookPOST /admin/documents для одного файла или manage_corpus.py bulk-ingest для целого корпуса
Инструкции к лабораторным работамQdrant, doc_type=lab_instructionPOST /admin/documents для одного файла или bulk-ingest из правильной структуры каталогов
Статическая логика VR-сценыJSON в scenarios/добавить или изменить файл вручную

scenario_state, который тренажёр передаёт в каждом запросе, является четвёртым источником. Это актуальное состояние сцены, а не загружаемый документ. При расхождении со статическим JSON оно имеет приоритет.

1. Подготовить структуру корпуса

bulk-ingest определяет метаданные из пути. Поддерживаются файлы .pdf, .docx, .epub, .txt и .md. Минимальная структура выглядит так:

Лабораторные физхимбио/
└── Материалы лабок/
    ├── Школьный материал 7-11 класс 3 предмета/
    │   └── Физика/
    │       └── рус/
    │           └── Физика 8 класс.pdf
    └── Лабораторные работы/
        └── Физика/
            └── Физика 10 класс/
                └── рус/
                    └── Лабораторная работа №2.docx

Английская структура поддерживается тем же загрузчиком:

Corpus/
├── School materials/Biology/en/Biology Grade 9.epub
└── Laboratory works/Physics/Physics Grade 10/en/Lab work No. 2.docx

Правила распознавания путей:

  • каталог типа документа должен начинаться с Школьный материал или называться Лабораторные работы; английские aliases: School materials, Textbooks, Laboratory works, Lab instructions;
  • предмет должен быть Физика, Химия, Биология, Physics, Chemistry или Biology;
  • для лабораторной инструкции класс берётся из каталога вида Физика 10 класс, а для учебника из имени файла;
  • язык определяется по каталогу рус, русс, каз, қаз, en, eng или english;
  • номер лабораторной работы берётся из первого номера в имени файла, например Лабораторная работа №2.docx, Зертханалық жұмыс № 2.docx или Lab work No. 2.docx.

Из этих значений формируется стабильный lab_id. Например, путь выше получает physics-10-ru-02; английский пример получает physics-10-en-02. Тренажёр должен передать те же значения в объекте lab.

Если корпус находится не в каталоге по умолчанию, задайте путь в .env:

CORPUS_ROOT=/absolute/path/to/Лабораторные физхимбио
LABS_MANIFEST=./labs.json

2. Проверить лабораторные работы до загрузки

Команда не обращается к Qdrant или embedder. Она проверяет пути, извлекает текст и создаёт отчёт labs.json:

Перед запуском создайте .env из .env.example и задайте непустой INTERNAL_API_KEY. Corpus CLI использует общие настройки приложения, хотя для gen-manifest и bulk-ingest ключ OpenAI не применяется.

python -m scripts.manage_corpus gen-manifest

В отчёте:

  • complete означает, что инструкция распознана и содержит достаточно текста;
  • stub означает, что файл найден, но извлечённого текста слишком мало;
  • missing_metadata содержит пути, для которых не удалось определить обязательные метаданные.

Исправьте stub и missing_metadata до основной загрузки. Лабораторная работа без инструкции в Qdrant считается неполной: ассистент отвечает только по теории и не придумывает последовательность действий.

3. Загрузить весь корпус

Qdrant и embedder должны быть доступны по QDRANT_URL и EMBEDDING_BASE_URL:

docker compose up -d qdrant embedder
python -m scripts.manage_corpus create-collection
python -m scripts.manage_corpus bulk-ingest

Повторная загрузка безопасно заменяет чанки документа с тем же относительным путём. Для загрузки только части дерева используйте --only, не меняя корневой каталог. Это сохраняет стабильные идентификаторы документов:

python -m scripts.manage_corpus bulk-ingest --only 'Биология/рус'

Для сканированных PDF или EPUB включите OCR. На машине, где запускается команда, нужны Tesseract и языковые модели rus/kaz/eng. Они включены в Docker-образ API, но для запуска CLI непосредственно на хосте их нужно установить отдельно:

python -m scripts.manage_corpus bulk-ingest --ocr --only 'Биология/рус'

При обработке PDF загрузчик удаляет известные водяные знаки и служебные строки OKULYK, а также таблицы из одних номеров страниц. Если после очистки текстовый слой остаётся чрезмерно повторяющимся или малоинформативным, --ocr запускает Tesseract даже при формально большом количестве распознанных слов.

4. Проверить результат

python -m scripts.manage_corpus status
python -m scripts.manage_corpus list
python -m scripts.manage_corpus gen-manifest

После загрузки проверьте тестовый запрос с тем же контекстом, который отправляет тренажёр:

curl -s localhost:8000/ask \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "Что делать дальше?",
    "scenario_id": "physics_lab_02_heating",
    "lab": {
      "subject": "physics",
      "grade": 10,
      "lang": "ru",
      "lab_number": 2
    },
    "scenario_state": {
      "current_step_id": "heat-water",
      "current_step_index": 3,
      "current_step": "Нагреть воду",
      "next_step": "Записать температуру",
      "held_items": ["термометр"],
      "visible_items": ["стакан", "штатив"],
      "allowed_actions": ["включить нагрев"]
    }
  }'

Ответ должен содержать цитату на инструкцию с lab_id=physics-10-ru-02 и, если понадобилась теория, цитаты на учебник соответствующего предмета и класса.

5. Добавить статический сценарий VR

Сценарий не загружается в Qdrant. Создайте JSON-файл, имя которого совпадает с scenario_id:

scenarios/physics_lab_02_heating.json

Используйте существующий пример как шаблон. При запуске через Docker каталог scenarios/ смонтирован read-only, но изменения файлов подхватываются без пересборки контейнера.

Проверить список доступных сценариев можно через API:

curl -s localhost:8000/admin/scenarios \
  -H "Authorization: Bearer $KEY"

Сценарии через API не загружаются. Учебники, лабораторные инструкции и общие документы можно загружать по одному через backend API. Для синхронизации всего корпуса по структуре каталогов используйте bulk-ingest.

Одиночная загрузка через backend API

POST /admin/documents принимает multipart/form-data со следующими полями:

ПолеЗначенияНазначение
filePDF, DOCX, EPUB, TXT или MDобязательный загружаемый файл
doc_typetextbook или lab_instructionнеобязательный тип структурированного документа
subjectphysics, chemistry или biologyпредмет структурированного документа
gradeцелое число от 7 до 11класс структурированного документа
langru, kk или enязык структурированного документа
lab_numberцелое число от 1 до 99обязателен при doc_type=lab_instruction
ocrtrue или falseнеобязательно включить OCR, по умолчанию false

Все поля метаданных необязательны для общего документа. Если передано хотя бы одно из них, требуется doc_type; для обоих структурированных типов также требуются subject, grade и lang. Учебник не принимает lab_number.

Если отправить только file, документ останется общим и будет участвовать в поиске без структурированного lab-контекста. При передаче doc_type, subject, grade и lang backend сохраняет детерминированные метаданные. Для лабораторной инструкции он также строит стабильный lab_id, например physics-10-ru-02. Повторная загрузка файла с теми же метаданными и тем же именем заменяет существующий документ и его чанки, а не создаёт дубль.

Пример загрузки учебника на развёрнутый backend:

API_BASE=https://megroup-b560m-hdv-m-2.tail7dd37a.ts.net

curl -sS -X POST "$API_BASE/admin/documents" \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@materials/physics_8_ru.pdf' \
  -F 'doc_type=textbook' \
  -F 'subject=physics' \
  -F 'grade=8' \
  -F 'lang=ru' \
  -F 'ocr=false'

Пример загрузки инструкции к лабораторной работе:

API_BASE=https://megroup-b560m-hdv-m-2.tail7dd37a.ts.net

curl -sS -X POST "$API_BASE/admin/documents" \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@materials/physics_lab_02_ru.docx' \
  -F 'doc_type=lab_instruction' \
  -F 'subject=physics' \
  -F 'grade=10' \
  -F 'lang=ru' \
  -F 'lab_number=2' \
  -F 'ocr=false'

Для общего документа без предметных метаданных можно использовать тот же API с одним полем file или CLI:

python -m scripts.manage_corpus upload path/to/document.pdf

API-вариант без метаданных:

curl -s localhost:8000/admin/documents \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@path/to/document.pdf'

Управление загруженными документами:

API_BASE=https://megroup-b560m-hdv-m-2.tail7dd37a.ts.net

# Список документов и их file_id
curl -sS "$API_BASE/admin/documents" -H "Authorization: Bearer $KEY"

# Удаление документа и всех его чанков
curl -sS -X DELETE "$API_BASE/admin/documents/<file_id>" \
  -H "Authorization: Bearer $KEY"

# Состояние коллекции Qdrant
curl -sS "$API_BASE/admin/corpus_status" -H "Authorization: Bearer $KEY"

Подключения и порты

Прокси является единственным сервисом, который ходит наружу. К локальным сервисам он подключается по HTTP через ленивый httpx.AsyncClient (Qdrant через свой клиент). Под docker-compose адреса по умолчанию указывают на имена сервисов; для запуска прокси вне Docker переопределите их на localhost + host-mapped порт.

ПодключениеПеременнаяURL под compose (по умолчанию)URL для локального devЭндпоинты, которые вызывает прокси
VR-клиент → APIINTERNAL_API_KEYAuthorization: BearerAuthorization: Bearerвесь публичный API (см. ниже)
API → OpenAIOPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODELapi.openai.com (если OPENAI_BASE_URL пуст)то жеResponses API
API → QdrantQDRANT_URLhttp://qdrant:6333http://localhost:6333Query API (поиск), upsert/delete
API → embedderEMBEDDING_BASE_URLhttp://embedder:8080http://localhost:8080POST /embed
API → voiceVOICE_BASE_URLhttp://voice:8001http://localhost:8002POST /stt/recognize, POST /tts/synthesize?format=wav
API → voice-omnivoiceVOICE_KK_OMNIVOICE_BASE_URLhttp://voice-omnivoice:8003http://localhost:8003POST /tts/synthesize?format=wav

⚠️ Локальный dev: значения по умолчанию в .env.example уже указывают на localhost (QDRANT_URL=http://localhost:6333, EMBEDDING_BASE_URL=http://localhost:8080, VOICE_BASE_URL=http://localhost:8002, VOICE_KK_OMNIVOICE_BASE_URL=http://localhost:8003). Под docker-compose их нужно переопределить на имена сервисов (qdrant/embedder/voice/voice-omnivoice). Это уже сделано в environment: блоке compose-файла, отдельно в .env менять не требуется.

Публичный доступ (Tailscale Funnel, HTTPS)

Gateway выставлен наружу через Tailscale Funnel, публичный HTTPS-эндпоинт с автоматическим TLS, без проброса портов и без Tailscale на стороне клиента. На порту 8001 gateway сохраняет все API-маршруты и отдаёт панель администратора на /; голосовой порт 8002 не публикуется.

ЧтоURLАвторизация
Панель администратораhttps://megroup-b560m-hdv-m-2.tail7dd37a.ts.netлогин и пароль администратора
API прокси (/ask, /voice_ask, /hint, …)https://megroup-b560m-hdv-m-2.tail7dd37a.ts.netAuthorization: Bearer <INTERNAL_API_KEY>
Swagger UIhttps://megroup-b560m-hdv-m-2.tail7dd37a.ts.net/docsвызовы требуют Bearer-ключ
Healthcheckhttps://megroup-b560m-hdv-m-2.tail7dd37a.ts.net/healthнет

Включается на сервере: sudo tailscale funnel --bg 8001 (статус: tailscale funnel status, выключение: sudo tailscale funnel --https=443 off). Каждый вызов /ask//hint идёт в OpenAI и стоит денег. Держите INTERNAL_API_KEY в секрете и RATE_LIMIT_PER_MINUTE > 0.

Переменные окружения (API, генерация и состояние)

ПеременнаяНазначение
INTERNAL_API_KEYBearer-ключ публичного VR API; не даёт доступ к /admin/*
ADMIN_API_KEYотдельный Bearer-ключ backend endpoints /admin/*; не передавать VR-клиенту
OPENAI_API_KEYключ OpenAI, обязательный для генерации ответов
OPENAI_BASE_URLнеобязательный URL Azure или совместимого прокси
OPENAI_MODELмодель Responses API, по умолчанию gpt-4.1-mini
OPENAI_SERVICE_TIERпусто для стандартного режима или priority для меньшей задержки и большей стоимости
REQUEST_TIMEOUT_Sтаймаут запроса генерации
ANSWER_CACHE_SIZE / ANSWER_CACHE_TTL_Sразмер и TTL кэша повторяющихся ответов; ≤0 отключает кэш
TTS_CACHE_SIZEразмер кэша синтезированного аудио; ≤0 отключает кэш
CHAT_MEMORY_MAX_CONVERSATIONS / CHAT_MEMORY_TTL_Sразмер и TTL эфемерной памяти VR-диалогов; ≤0 отключает серверную память
CHAT_MEMORY_MAX_MESSAGES / CHAT_MEMORY_HISTORY_CHARSлимиты истории одного диалога
CHAT_MEMORY_RETRIEVAL_CONTEXT_CHARSобъём предыдущего диалога, добавляемый к ретривалу follow-up вопроса
INGESTION_DATA_DIRобщий каталог SQLite очереди, загруженных файлов и динамических профилей; compose использует /data/ingestion
ALLOW_GENERAL_KNOWLEDGE_FALLBACKразрешить ответ из общеизвестных научных знаний без цитат только вне структурированного контекста лабораторной работы, если найденные материалы непригодны и нет авторитетного контекста сцены/процедуры

Переменные окружения (ретривал)

ПеременнаяНазначение
QDRANT_URLадрес Qdrant (compose: http://qdrant:6333, local: http://localhost:6333)
QDRANT_COLLECTIONимя коллекции (по умолчанию school_kb)
EMBEDDING_BASE_URLадрес сайдкара-эмбеддера, вызов POST /embed
EMBEDDING_DIMразмерность плотного вектора (1024 для bge-m3)
EMBED_BATCH_SIZEразмер батча эмбеддинга (≤0 означает одним запросом)
RETRIEVAL_TOP_Kсколько чанков подставить в промт
RETRIEVAL_CANDIDATESсколько кандидатов тянуть из каждой ветки до RRF
RETRIEVAL_SCORE_THRESHOLDминимальный скор после слияния
CHUNK_SIZE / CHUNK_OVERLAPпараметры нарезки при загрузке
CORPUS_ROOTкорень структурированного корпуса для bulk-ingest
LABS_MANIFESTпуть для отчёта gen-manifest
OCR_ENABLED / OCR_DPI / OCR_MAX_PAGESOCR для сканированных материалов при загрузке

Переменные окружения (голос)

ПеременнаяНазначение
VOICE_BASE_URLадрес основного voice-сайдкара (compose: http://voice:8001, local: http://localhost:8002)
VOICE_KK_OMNIVOICE_BASE_URLадрес отдельного сайдкара Kazakh OmniVoice (compose: http://voice-omnivoice:8003, local: http://localhost:8003)
VOICE_VERIFY_SSLпроверять ли TLS (по умолчанию false, внутри compose plain HTTP)
VOICE_TIMEOUT_Sтаймаут STT/TTS (по умолчанию 120, покрывает GPU cold start)
VOICE_TTS_RU_DEFAULT_BACKENDbackend, который API явно отправляет для русского TTS без поля backend; по умолчанию supertonic
VOICE_TTS_EN_DEFAULT_BACKENDbackend для английского TTS без поля backend: supertonic по умолчанию или qwen
VOICE_TTS_KK_DEFAULT_BACKENDbackend по умолчанию для казахского TTS: omnivoice (фиксированный молодой мужской голос) или mms (fallback)
NVIDIA_PERSISTENCE_MODE1 просит драйвер включить persistence mode при каждом старте voice-контейнера; ошибка не блокирует запуск приложения
STT_KEEP_WARM_ENABLEDзапускать реальные RU/KK STT-пробы при старте и после периода без пользовательских запросов; по умолчанию true
STT_KEEP_WARM_REAL_IDLE_Sсколько секунд без завершённого пользовательского STT должно пройти до фоновой пробы; по умолчанию 180
STT_KEEP_WARM_INTERVAL_Sминимальный интервал между успешными фоновыми прогревами; по умолчанию 240
STT_KEEP_WARM_POLL_Sчастота проверки необходимости прогрева; по умолчанию 15
OMNIVOICE_MODEL / OMNIVOICE_INSTRUCT / OMNIVOICE_STEPScheckpoint, фиксированный голосовой профиль и число diffusion-шагов отдельного OmniVoice-сайдкара
OMNIVOICE_AUDIO_TOKENIZER_PATHсмонтированный локальный путь к eustlb/higgs-audio-v2-tokenizer, чтобы запуск OmniVoice не зависел от Hugging Face
OMNIVOICE_NORMALIZE_KK_NUMBERSраскрывать числа казахскими словами непосредственно перед OmniVoice; по умолчанию true, false отключает для диагностики
TTS_RU_BACKEND / TTS_RU_BACKENDSdefault и список загружаемых русских backend-ов voice-сайдкара; по умолчанию supertonic и supertonic,qwen
TTS_EN_BACKENDанглийский default внутри voice-сайдкара; использует уже загруженный supertonic или qwen
TTS_RU_QWEN_MODELHugging Face ID Qwen3-TTS; по умолчанию Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
TTS_RU_QWEN_SPEAKERмужской голос Qwen по умолчанию; Aiden
TTS_RU_QWEN_DTYPE / TTS_RU_QWEN_ATTENTIONтип весов и attention implementation; bfloat16 / sdpa
TTS_RU_SUPERTONIC_VOICE_STYLEстиль Supertonic по умолчанию; M3
TTS_SUPERTONIC_MODEL_DIRпостоянный каталог общих RU/EN Supertonic assets; compose использует /models/hf_cache/supertonic3 в volume voice_hf_cache
TTS_NORMALIZE_RU_NUMBERSраскрывать числа русскими словами перед Supertonic, Qwen и MMS; по умолчанию true, false отключает для диагностики
TTS_NORMALIZE_EN_NUMBERSбезопасно раскрывать английские числа, единицы, даты, время, проценты и температуры; по умолчанию true
DEFAULT_LANGUAGEрезервный язык ответа после явного language, текущего текста, недавней истории и lab.lang; также язык TTS по умолчанию (ru/kk/en), STT без language использует auto

VOICE_TTS_EN_DEFAULT_BACKEND управляет публичными /tts и /voice_ask: прокси всегда передаёт выбранный backend в voice-сайдкар. TTS_EN_BACKEND используется только как default при прямом вызове sidecar API. Обычно оба значения должны совпадать. Английский STT использует уже загруженную мультиязычную Whisper-модель, поэтому отдельного английского checkpoint или keep-warm аудиофайла не требуется.

Нормализация чисел для TTS

Русский, казахский и английский маршруты используют отдельные детерминированные нормализаторы из репозитория. Они обрабатывают целые и отрицательные числа, десятичные дроби с точкой или запятой, проценты, даты, время, диапазоны, температуру и школьные единицы измерения. Химические формулы, научная нотация, уравнения, URL, имена файлов, лабораторные ID и буквенно-цифровые идентификаторы маскируются на время преобразования и восстанавливаются без изменений. Общий normalize_text из OmniVoice не используется, поэтому результат не зависит от num2words или языка fallback-нормализатора библиотеки.

Voice image pins supertonic==1.3.1, the tested release with official ru and en support. English and Russian reuse one loaded Supertonic instance and one loaded Qwen instance, so English does not duplicate those large models in memory. Supertonic files live in the persistent voice_hf_cache volume rather than the container filesystem.

Примеры текста, который получает модель:

Суды жиырма бес градус Цельсийге дейін қыздырыңыз.
Ерітіндіге үш бүтін жүзден он төрт грамм тұз қосыңыз.
Тәжірибе екі мың жиырма алтыншы жылғы он екінші наурыз күні сағат он төрт отызда басталады.
H2O молекуласында екі сутек атомы бар.

Нагрейте воду до двадцати пяти градусов Цельсия.
Добавьте три целых четырнадцать сотых грамма соли.
Опыт начнётся двенадцатого марта две тысячи двадцать шестого года в четырнадцать часов тридцать минут.
В молекуле H2O есть два атома водорода.

Heat two hundred fifty milliliters to twenty five degrees Celsius.
The solution is twelve point five percent.
H2O, LAB-204, report2.csv, and URLs remain unchanged.

Эндпоинты

Публичные endpoints требуют Authorization: Bearer <INTERNAL_API_KEY>, административные /admin/* требуют отдельный ADMIN_API_KEY, а /health и /ready доступны без авторизации.

МетодПутьНазначение
POST/askВопрос с грунтингом: {assistant_type?, query, language?, conversation_id?, scenario_id?, scenario_state?, lab?} → ответ + language + citations + conversation_id
POST/v1/chat/completionsOpenAI-совместимый чат (stream поддерживается), расширен полями assistant_type, language=ru/kk/en, scenario_id, scenario_state, lab
POST/hintПерефразирование подсказки: {hint_text, hint_level, language?, scenario_id?, scenario_state?} с возвращаемым language
POST/sttmultipart file, необязательный language=ru/kk/en/auto → {text, language}; без language включается автоопределение
POST/tts{text, language?, backend?, voice?, format?, instructions?} → WAV; русский = supertonic / qwen / mms, английский = supertonic (default) / qwen, казахский = omnivoice (default) / mms
POST/voice_askmultipart file, language=ru/kk/en/auto и необязательные assistant_type, response_language=ru/kk/en, tts_backend, поля лаборатории → JSON или SSE с language metadata
DELETE/v1/conversations/{conversation_id}удалить историю VR-диалога в namespace выбранного assistant_type
GET/POST/admin/assistant-profilesсписок профилей / создание динамического профиля
GET/admin/corpus_statusсостояние коллекции Qdrant
POST/admin/documentsзагрузить общий или структурированный документ в KB: multipart file, метаданные и необязательный OCR
GET/DELETE/admin/documents[/{file_id}]список / удаление по file_id
GET/admin/scenariosсписок сценариев
GET/health, /readyпроверки

Профили ассистентов

Клиент передаёт только доверенный идентификатор assistant_type. Сервер разрешает его во встроенном реестре или в общей SQLite-базе и сам выбирает system prompt, коллекцию Qdrant и corpus root. По умолчанию используется vr_lab_teacher.

Администратор может создать профиль без изменения кода и перезапуска сервисов:

curl -sS -X POST "$API_BASE/admin/assistant-profiles" \
  -H "Authorization: Bearer $ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "assistant_type": "math_tutor",
    "system_prompt": "You are a mathematics tutor."
  }'

Backend создаст коллекцию math_tutor_kb, каталог $INGESTION_DATA_DIR/assistant_corpora/math_tutor и сохранит профиль в assistant_profiles.sqlite3. Поля инфраструктуры в запросе запрещены. Полный контракт, операции с корпусом и ограничения описаны в руководстве по профилям.

English support

English uses canonical code en. language selects the answer language, while lab.lang continues to select the preferred corpus language and exact lab_id. They may differ, for example language=en with lab.lang=ru produces an English answer grounded in the Russian procedure. The default remains Russian.

curl -s localhost:8000/ask \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "What is the purpose of this lab?",
    "language": "en",
    "lab": {"subject": "physics", "grade": 10, "lang": "en", "lab_number": 2}
  }'

The same language=en extension is supported by /v1/chat/completions and /hint. Structured uploads accept lang=en.

English STT and TTS

VR clients should call the authenticated proxy endpoints, not the internal voice-sidecar endpoints. The English voice contract is:

EndpointEnglish fieldsResult
/sttmultipart language=en or language=autoJSON {text, language}; detected English is returned as language=en
/ttsJSON language=en, optional `backend=supertonicqwen, optional voice`
/voice_askmultipart STT language, optional response_language=en, optional `tts_backend=supertonicqwen`

Supertonic is the default English TTS backend. Qwen can be selected per request. MMS and OmniVoice do not support English and are rejected with HTTP 422. Output from the proxy /tts endpoint is always audio/wav; legacy format and instructions fields are accepted only for client compatibility.

English STT with explicit language selection or automatic detection:

curl -sS localhost:8000/stt \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@question_en.wav' \
  -F 'language=en'

curl -sS localhost:8000/stt \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@question_en.wav' \
  -F 'language=auto'

English TTS with the default Supertonic backend or explicit Qwen selection:

curl -sS localhost:8000/tts \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"text":"Heat 250 mL to 25 °C.","language":"en"}' \
  -o answer_supertonic_en.wav

curl -sS localhost:8000/tts \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"text":"Heat 250 mL to 25 °C.","language":"en","backend":"qwen"}' \
  -o answer_qwen_en.wav

Full English voice pipeline. language controls STT, response_language controls the generated answer and TTS, and lang continues to select the laboratory procedure and corpus language:

curl -sS localhost:8000/voice_ask \
  -H "Authorization: Bearer $KEY" \
  -F 'file=@question_en.wav' \
  -F 'language=auto' \
  -F 'response_language=en' \
  -F 'tts_backend=supertonic' \
  -F 'subject=physics' \
  -F 'grade=10' \
  -F 'lang=en' \
  -F 'lab_number=2'

If response_language is omitted, /voice_ask uses the resolved STT language for both the answer and TTS. The non-streaming response contains audio_base64; with stream=true, sentence-level WAV chunks arrive in SSE audio frames.

See the English migration and operations runbook for complete curl examples, corpus layout, ingestion, health checks, and rollout steps. Code support does not supply authoritative English textbooks or procedures. Those source files must be provided and ingested for complete production grounding.

Пример: /ask

curl -s localhost:8000/ask -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{
    "query": "Что делать дальше?",
    "scenario_id": "physics_lab_02_heating",
    "lab": {"subject": "physics", "grade": 10, "lang": "ru", "lab_number": 2}
  }'
{
  "answer": "Следующий шаг: начните равномерно нагревать воду и следите за термометром...",
  "citations": [{
    "filename": "Лабораторная работа №2.docx",
    "file_id": "...",
    "source_type": "lab_instruction",
    "lab_id": "physics-10-ru-02",
    "lab_number": 2,
    "display_label": "Инструкция к лабораторной работе №2"
  }],
  "primary_source": {
    "filename": "Лабораторная работа №2.docx",
    "file_id": "...",
    "source_type": "lab_instruction",
    "lab_id": "physics-10-ru-02",
    "lab_number": 2,
    "display_label": "Инструкция к лабораторной работе №2"
  },
  "conversation_id": "0e8c0ea8-3ad6-49c4-8678-7f12c33024b2",
  "scenario_id": "physics_lab_02_heating",
  "language": "ru",
  "observability": {"latency_ms": {"embed": 30.5, "retrieval": 18.1, "llm": 1840.2, "total": 1888.8}}
}

Клиент рисует под ответом блок «Источник: primary_source.filename». Для «объёмных» ответов (ТЗ §4) клиент может показать источник по запросу. Данные уже в citations. Поля страницы, главы и раздела добавляются только тогда, когда ingestion смог надёжно определить их из исходного документа. Для вопросов о текущем шаге и порядке действий инструкция к лабораторной работе ставится первой. Для теоретических вопросов первым остаётся найденный учебник.

Для follow-up вопросов VR-клиент сохраняет conversation_id из первого ответа и передаёт его в следующих /ask или /voice_ask запросах. Подробный контракт, SSE-поля и lifecycle описаны в docs/memory-backend-guide.md.

Сценарии (контекст ПО)

Каждая лабораторная использует один JSON-файл в scenarios/ (имя файла = scenario_id). Поля см. в scenarios/physics_lab_02_heating.json: scenario_name, language, subject, topic, environment_description, objects, action_sequence, risks, common_mistakes, correct_behavior, regulations и т.д. Заполняются только нужные поля. Это статическое описание сцены, а не инструкция в базе знаний. Английский демонстрационный вариант находится в scenarios/physics_lab_02_heating_en.json; /admin/scenarios возвращает язык, если он объявлен в JSON. Тренажёр передаёт scenario_id, структурированный lab и актуальный scenario_state в каждом запросе. Поля scenario_state включают текущий и следующий шаги, завершённые шаги, предметы в руках и в поле зрения, разрешённые действия, а также результат последнего действия.

Тесты

pytest          # без сети и GPU (OpenAI, Qdrant и embedder замоканы)

Для ручной проверки войдите в панель оператора и откройте вкладку Тест API. Она проверяет health/readiness, текстовые запросы, streaming, STT, TTS и полный voice_ask-конвейер через серверный прокси, не раскрывая API-ключ браузеру. Вызовы /ask, chat, /hint и /voice_ask используют OpenAI и расходуют токены.

Замечания по приёмке (ТЗ §7)

  • Только из загруженных файлов: системный промт запрещает выдумывать факты; ответы грунтятся подставленными чанками из гибридного поиска по Qdrant, источники возвращаются в citations.
  • Текущий шаг: актуальный scenario_state имеет приоритет над статическим сценарием по scenario_id.
  • ≤5 c: /voice_ask возвращает по-стадийные задержки (stt/llm/tts). STT/TTS обслуживает встроенный сайдкар voice (VOICE_BASE_URL, см. ./voice); для минимума латентности держите сайдкар «прогретым» и используйте быстрый OPENAI_MODEL.
  • Источники на каждый ответ: primary_source + citations в каждом ответе.