-
-
Notifications
You must be signed in to change notification settings - Fork 2
uk Intent Routing
Доповнення до voice-settings.md. Єдине джерело істини для того, як SelenaCore класифікує та диспетчеризує голосові / текстові команди користувача.
English version: docs/intent-routing.md
Застарілі FastMatcher (regex), pattern-рядки у
IntentCompiler, IntentCache, composite device patterns та LLM-як-класифікатор повністю прибрані. Кожен запит класифікується заново на живому стані БД.
┌────────────────────┐
Звук ─► Vosk / Whisper ─► текст ─► ── │ InputTranslator │ ─► англ. текст
│ (Argos / Helsinki) │
└────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ IntentRouter │
│ │
│ Tier 0 Module Bus (WebSocket → user modules) ~50 мс │
│ Tier 1 Embedding classifier (MiniLM-L6-v2) ~50 мс │
│ cosine над per-utterance каталогом │
│ Tier 2 Assistant LLM (chat prompt, БЕЗ каталогу) 300-800 │
│ розмовна відповідь, intent="unknown" │
│ Fallback детермінована фраза "Я не зрозуміла" │
└────────────────────────────────────────────────────────────┘
│
▼
publish("voice.intent", payload)
│
┌─────────────┴─────────────┐
▼ ▼
System-модуль VoiceCore озвучує
виконує дію assistant / fallback відповідь
+ self.speak_action()
Ключові файли:
-
system_modules/llm_engine/intent_router.py— оркестрація 0/1/2 рівнів -
system_modules/llm_engine/embedding_classifier.py— MiniLM-L6-v2 ONNX cosine -
system_modules/llm_engine/intent_compiler.py— живий кеш рядківintent_definitions -
core/module_loader/system_module.py::_claim_intent_ownership— реєстрація статичних інтентів -
core/api/helpers.py::on_entity_changed— invalidation hook на CRUD пристроїв / станцій / сцен
Інтенти класифікує embedding-модель, а НЕ LLM. LLM — розмовний fallback для висловлювань, які класифікатор помітив як unknown. Він не бачить каталог інтентів і повертає лише natural-language відповідь, ніколи не intent-мітку.
Кожен системний модуль оголошує свої інтенти на класі:
class WeatherServiceModule(SystemModule):
name = "weather-service"
OWNED_INTENTS = [
"weather.current",
"weather.forecast",
"weather.temperature",
]
_OWNED_INTENT_META: dict[str, dict] = {
"weather.current": dict(
noun_class="WEATHER", verb="query", priority=100,
description=(
"Report the CURRENT outdoor weather conditions "
"(temperature + summary). Use for 'what's the weather' "
"style questions. NOT for indoor AC / thermostat readings."
),
),
# ... по одному запису на кожен інтент
}
async def start(self) -> None:
self.subscribe(["voice.intent"], self._on_event)
await self._claim_intent_ownership() # idempotentSystemModule._claim_intent_ownership() (у core/module_loader/system_module.py):
-
UPDATE intent_definitions SET module=self.name WHERE intent IN OWNED_INTENTS— «привласнює» вже існуючі рядки. -
UPDATE description, entity_typesз_OWNED_INTENT_META— модуль є єдиним джерелом істини для формулювання, яке бачить класифікатор. -
INSERTвідсутні рядки з_OWNED_INTENT_META.
Виконується у кожному start() модуля — свіжий boot контейнера перереєстровує весь каталог за секунду. Змінили description у коді, перезапустили контейнер — наступний embedding classify бачить нову формулу.
Динамічних інтентів нема. Сутності — це слоти на існуючих статичних інтентах, не нові intent-мітки.
-
device.on+params.name="спальня лампа"— НЕ новийdevice.turn_on_bedroom_light -
media.play_radio_name+params.station_name="Радіо Релакс"— НЕ новийmedia.play_radio_relax
Коли пристрій / станцію додано через POST /api/v1/devices або POST /api/ui/modules/media-player/radio, роут викликає core.api.helpers.on_entity_changed(entity_type, id, action), який:
-
IntentCompiler.full_reload()— перебудовує in-memory intent catalog. Наступний embedding classify бачить повний свіжий набір інтентів. - Для
entity_type == "device":PatternGenerator.rebuild()оновлює індексname_en → device_id, який device-control використовує для перетворенняparams.nameвід класифікатора на реальний пристрій. - Публікує
REGISTRY_ENTITY_CHANGEDна EventBus для інших модулів.
IntentRouter._build_filtered_catalog(user_text, native_text) збирає кандидатів на один запит:
tokens = tokenize(user_text) ∪ tokenize(native_text) # Unicode \w{3,}
Intents:
для кожного інтенту з IntentCompiler.get_all_intents():
якщо tokens ∩ (tokenize(description) ∪ tokenize(intent_name)) != ∅:
включити інтент з description обрізаним до 120 символів
завжди додати "unknown" як bail-out
Devices:
для кожного пристрою у registry:
якщо tokens ∩ tokenize(name_en, name, location_en, location) != ∅:
включити рядок пристрою (білінгвально)
Radio stations:
для кожної станції:
якщо tokens збігаються з name_user / name_en / genre_*:
включити рядок станції
→ повертає (catalog_text, allowed_intent_set)
Фільтр білінгвальний: токени ЯК з англійського пост-Argos тексту, ТАК і з оригінального native тексту, йдуть в match-set. Команда «вимкни лампу у спальні» все одно включить "bedroom light" у filtered catalog — «спальня» потрапляє в українське поле meta.location пристрою.
_parse_catalog_to_candidates(catalog_text) витягає блок Intents: у список {"name", "description"}. EmbeddingIntentClassifier.classify(query, candidates) робить один forward pass MiniLM-L6-v2 над [query, desc1, desc2, ...] і повертає (intent, score, runner_up, margin, params).
Два пороги з конфігу (ключі під intent.*):
| Ключ | Default | Сенс |
|---|---|---|
embedding_score_threshold |
0.30 |
Абсолютний cosine floor (query vs winner) |
embedding_margin_threshold |
0.05 |
Переможець − runner-up |
Нижче будь-якого → _embedding_classify повертає None → роутер падає у Tier 2.
Allowed-set guard відкидає будь-який інтент, якого нема у filtered allowed set — захист від MiniLM, що повертає якусь фразу, якої не було у списку кандидатів.
device.set_mode / device.set_temperature інколи вигравали cosine у device.on / device.off на прикордонних фразах ("turn on the air conditioning"). Коротка евристика переключає відповідь класифікатора назад на device.on / device.off коли користувач сказав голу on/off-команду БЕЗ параметра mode / value. Дивися _ON_VERBS / _OFF_VERBS у intent_router.py::_embedding_classify.
| MiniLM-L6-v2 (ONNX) | Local LLM (phi-3-mini / qwen 1.5b) | |
|---|---|---|
| Затримка | ~50 мс | 300-2000 мс |
| Пам'ять | ~30 MB | ~1-5 GB |
| Детермінованість | так — обирає зі списку кандидатів | ні — галюцинує intent-імена |
| Не-англійські мови | через translator + білінгвальний фільтр | кошмар prompt engineering |
| Запускається на | будь-який Pi / x86 / Jetson | GPU-only для розумної затримки |
Класифікатор не намагається розуміти — він міряє семантичну схожість між висловлюванням і текстом опису. Цього достатньо щоб обрати правильний інтент і уникнути всіх пасток prompt-engineering маленьких моделей.
Текст опису — єдине, що бачить MiniLM. Два правила:
-
Почніть з дієслова + іменника, які скаже користувач.
"Turn a device on (light, switch, AC, curtain, vacuum)..."б'є"Powers a device on."— фраза "turn on" ближча до "Turn" у embedding-просторі. -
Додавайте негативи для близьких пар інтентів.
device.query_temperatureіweather.temperaturecosine-близькі до "what's the temperature". Фраза "Returns the live sensor value, NOT the outdoor weather forecast" їх розділяє.
Описи обрізаються до 120 символів у filtered prompt block — стисло краще за багатослівно.
Користувацькі модулі (type=UI / INTEGRATION / DRIVER) реєструють свої інтенти через WebSocket Module Bus. IntentRouter.route() питає bus ПЕРЕД запуском embedding-класифікатора — якщо якийсь user module каже handled=true, він перемагає, і класифікатор не запускається. Це дає user-модулям змогу перекривати built-in поведінку (наприклад, кастомний weather-модуль може забрати weather.current у weather-service).
Див. core/module_bus/ і доку SDK модулів для деталей протоколу.
IntentRouter._ask_as_assistant(text) — ОСТАННІЙ рівень. Викликається лише коли:
- Tier 0 Module Bus промазав, AND
- Tier 1 Embedding повернув
unknownабо low confidence, AND -
intent.llm_assistant_enabled=true(default), AND - Провайдер налаштований (
voice.llm_providerвстановлений), AND - Вільна RAM ≥
llm.min_ram_gb(default 5)
reply = await llm_call(
text,
prompt_key="chat", # з PromptStore
temperature=0.7,
max_tokens=100,
num_ctx=2048,
)
→ IntentResult(intent="unknown", response=reply, source="assistant")LLM ніколи не бачить intent catalog. chat-prompt — це системний prompt на кшталт "You are a helpful home-assistant. Keep answers short..." — користувач отримує людську відповідь замість роботизованого "Я не зрозуміла", але жодного нового інтенту не створюється.
Якщо LLM повернув порожньо або рівень вимкнено, роутер повертає IntentResult(intent="unknown", response="<детермінована фраза>", source="fallback").
IntentRouter.route() публікує voice.intent з результатом класифікації. Кожен system-модуль, що володіє інтентами у цьому namespace, підписаний на цю подію і виконує свою дію:
async def _on_voice_intent(self, event):
payload = event.payload or {}
if payload.get("intent") not in self.OWNED_INTENTS:
return
# ... виконуємо дію, потім:
await self.speak_action(payload["intent"], {"result": "ok", ...})speak_action() делегує TTS-формулювання rephrase-LLM у VoiceCore, щоб відповідь потрапила до користувача рідною мовою, попри те, що класифікатор бігав над англійською.
Для інтентів, що діють на конкретну сутність (пристрій, станція, сцена), params.name від класифікатора виходить прямо з висловлювання ("bedroom light"). device-control::_resolve_device() використовує PatternGenerator.get_device_id_by_name() для мепу ім'я → device_id за O(1). Неоднозначні імена (два пристрої з однаковим name_en) fallback'аться на диспет за params.location.
Радіостанції / сцени йдуть через IntentRouter._resolve_entity_ref(), який шукає RadioStation / Scene за name_user чи name_en і інжектить params.entity_ref для хендлера.
Стара архітектура мала 5 рівнів. Усе крім Module Bus і LLM-як-чату прибрано:
| Прибрано | Замінено на |
|---|---|
FastMatcher regex (IntentCompiler.match(), _flat_en, verb buckets, pattern specificity) |
Embedding classifier |
intent_patterns regex-рядки, composite device patterns |
Embedding classifier читає intent_definitions.description напряму |
PatternGenerator.rebuild_composite_device_patterns() |
PatternGenerator.rebuild() — звичайний name → device_id індекс |
IntentCache + auto_learned hot-phrase promotion |
Свіжий classify на кожен запит (жодного stale вказівника на віддалену сутність) |
| LLM-як-класифікатор з dynamic registry-aware prompt | LLM — лише chat-fallback, каталогу в prompt немає |
config/intents/, definitions.yaml, vocab/*.yaml
|
OWNED_INTENTS + _OWNED_INTENT_META на класі кожного модуля |
scripts/seed_intents_to_db.py |
_claim_intent_ownership() у базовому SystemModule
|
intent_cache.db, hourly promotion loop у lifespan |
— |
Що вижило:
- Таблиця
intent_definitions: статичний каталог, пишеться_claim_intent_ownership(), читаєтьсяIntentCompiler.get_all_intents(). -
IntentCompiler: зведено до живого кеша рядківintent_definitions. -
PatternGenerator: зведено до name → device_id lookup index для резолюції сутностей. -
on_entity_changed: незмінна точка тригера на CRUD — тепер лише оновлює кеш IntentCompiler та індекс PatternGenerator.
🤖 This wiki is auto-synced from docs/ in the main repo. Hand-edits on the wiki UI get overwritten on the next push. Open a PR against the main repo instead.
MIT License · Sponsor · Ko-fi
SelenaCore
🇬🇧 English
Getting started
Architecture
Voice & translation
Hardware integration
Development
- Modules overview
- Module development
- System module development
- Module API guide
- Module bus protocol
- Widget development
- User manager / auth
Reference
🇺🇦 Українська
Початок
Архітектура
Голос і переклад
Інтеграція заліза
Розробка
- Розробка модулів
- Розробка системних модулів
- Module API
- Module bus
- Widget development
- User manager / auth
Довідник