-
-
Notifications
You must be signed in to change notification settings - Fork 2
en Intent Authoring
This guide codifies the rules we discovered while moving the intent classifier from 57.5% to 96.6% accuracy across ~20 iterations. Every rule below was learned from a specific regression — follow them and your intent will land at ≥ 90% from day one.
Applies to both user modules (modules/*/main.py with @intent) and system modules (system_modules/*/module.py with _OWNED_INTENT_META). The classifier doesn't care which — same rules apply.
For the architecture behind the classifier (MiniLM cosine + Helsinki translation + post-processing cascade), see intent-routing.md. This doc is about how to AUTHOR intents that succeed in that architecture.
Before opening a PR with a new intent, verify:
- Name is module-prefixed:
<module>.<verb_or_noun>(e.g.weather.current). - Description names the primary action AND explicitly excludes neighbouring intents (“NOT for X”). 80–200 chars — not too short, not bloated.
- Entity types (if the intent targets a device class) reuse a canonical value — don't invent new ones.
- Anchors — 5–10 example sentences in
INTENT_ANCHORScovering the phrasings users actually say, plus Helsinki UK→EN artifacts if Ukrainian is supported. - Handler exists — every entry in
_OWNED_INTENT_METAhas a matching_handle_*method. Dead intents steal classifier matches from real intents. - Corpus cases added in tests/experiments/corpus_generator.py — ≥ 3 cases (EN + UK, plain + one variety twist).
- Bench —
run_coverage_bench.pyshows your new intent ≥ 80%, overall ≥ 97%, distractors 100%.
Every intent is <module>.<verb_or_noun>, lowercase, dot-separated. Examples:
weather.current, device.on, media.play_radio_name, clock.set_alarm.
| Namespace | Owner | Use for |
|---|---|---|
device.* |
device-control | Device power, lock, climate, queries |
media.* |
media-player | Radio playback, volume, track nav |
house.* |
device-control | Whole-house mass operations |
clock.* |
clock | Alarms, timers, reminders |
weather.* |
weather-service | Outdoor conditions + forecast |
presence.* |
presence-detection | Who's home queries |
automation.* |
automation-engine | Rule enable/disable/list |
energy.* |
energy-monitor | Power usage queries |
watchdog.* |
device-watchdog | Device liveness |
privacy_on / privacy_off
|
voice-core | Microphone mute |
Use your module's name as the prefix: my_weather.umbrella_check, garden.water_schedule. Don't put anything under a reserved namespace — your intent will be overridden or confused with the system module at classifier time.
This is the single biggest lever for accuracy. The description string is embedded and cosine-matched against the user's utterance — its quality directly controls classifier routing.
<primary action verb> <primary noun / object>. <neighbour contrast clause>. <concrete phrases>.
- Primary action — what the intent DOES. One verb, one noun.
- Contrast clause — what it is NOT for. Name the neighbour explicitly.
- Concrete phrases — 2–3 verbatim user utterances, EN and UK if you support Ukrainian.
80–200 characters of prose, max ~300 including the phrase list. Two failure modes observed:
- Too long (R2, R8 in the tuning log): description bloats past ~50 words, centroid dilutes, cosine peaks collapse. Regression of up to −11pp.
-
Too short (one-liner like
"Set temp"): classifier has no anchor at all, fails to match on paraphrased input. Regression of up to −7pp.
Before (two separate intents, 35% accuracy):
"clock.cancel_alarm": "Cancel / delete an existing alarm by label or position.",
"clock.stop_alarm": "Silence the alarm that is ringing right now (snooze or dismiss).",Users say "stop" and "cancel" interchangeably — classifier couldn't distinguish.
After (merged, 86% accuracy):
"clock.stop_alarm": (
"Silence / cancel / dismiss an alarm — covers both "
"'stop the alarm' when it's ringing AND 'cancel the "
"morning alarm' when removing from schedule. Single "
"intent for both verbs (they mean the same thing to "
"the user)."
),Both query presence state but differ in scope. The descriptions explicitly contrast:
"presence.who_home": (
"List WHO is currently at home — returns names of all "
"household members present. Open question without a "
"specific person. Use for 'who's home', 'who is here'. "
"NOT for 'who are you' (about the assistant)."
),
"presence.check_user": (
"Check whether ONE SPECIFIC named person is at home. "
"Query mentions a person's name. Use for 'is Alice home', "
"'is Bob here'. Contains a proper name — distinguishes "
"from generic who_home."
),The phrase “contains a proper name — distinguishes from generic who_home” is the critical disambiguator. Without it, the classifier routed “is Alice home” to who_home every time.
The registry's Device.entity_type column uses a fixed vocabulary. Your intent's entity_types constraint must reference one of these:
| Type | Typical devices |
|---|---|
light |
bulbs, lamps, LED strips |
switch |
smart switches |
outlet |
plugs, sockets, power strips |
fan |
ventilators, ceiling fans |
air_conditioner |
AC units |
thermostat |
smart thermostats |
radiator |
heaters, radiators |
humidifier |
humidifiers |
kettle |
electric kettles |
tv |
televisions |
curtain |
curtains, blinds, shades |
vacuum |
robot vacuums |
media_player |
speakers, audio streamers |
door_lock |
smart locks |
speaker |
standalone speakers |
sensor |
motion/temp/humidity sensors |
camera |
security cameras |
If a user says "lamp", "bulb", "light fixture" — they all map to entity_type="light". Adding entity_type="lamp" as a new value splits the device pool and breaks type+location resolution. Variant words go into ENTITY_MAP (the entity extractor), not into the registry.
Only when the intent semantically applies to a narrow set. Example:
"device.set_temperature": dict(
entity_types=["air_conditioner", "thermostat", "radiator"],
),Without this, "set the temperature in the bedroom" could match any device in the bedroom. With it, the resolver restricts to climate-only devices. This is what gave thermostat cases 13% → 91% in one change.
For generic intents like device.on or device.off that apply to ANY device — leave entity_types=None.
Anchors are example sentences pre-computed into the intent's embedding centroid. This is where real accuracy gains happen. Descriptions alone give a coarse match; anchors sharpen it.
Location: system_modules/llm_engine/embedding_classifier.py, the INTENT_ANCHORS dict (starts around line 78).
5–10 anchors per intent. Fewer → classifier misses paraphrases. More → dilutes (same failure mode as bloated descriptions). The tuning log showed +12pp on AC cases from just 6 anchor additions.
- Canonical phrasings — the 2–3 most natural ways a user would say it.
- Synonyms and casual verbs — if your intent description mentions "flip on" as a synonym, add "flip on the X" as an anchor.
- Indirect phrasings — “I want the X on” (device.on), “no need for the X” (device.off), “I want X to work” (device.on).
- Helsinki translation artifacts for UK — see section 5.
-
Short / bare forms — if your intent accepts one-word commands (
pause,resume,next), add them explicitly as anchors.
"media.resume": [
"resume the music",
"resume",
"resume playback",
"continue playing",
"unpause",
"keep going",
# Helsinki artifacts for UK "продовж" / "продовжи":
"continued.",
"continue.",
],The last two are the key — without them, продовж → Helsinki → "Continued." lands below threshold and falls to unknown. With them, it hits media.resume with confident margin.
If your intent supports Ukrainian input, the user's UK utterance gets translated to English by the Helsinki opus-mt model BEFORE reaching the embedding classifier. Helsinki is lossy and biased toward declarative sentences — not imperative commands. You must account for this.
| UK phrase | Helsinki output | Why it matters |
|---|---|---|
слухай увімкни X |
"Listen to the X." |
Verb увімкни dropped entirely |
запали X |
"Light the X" or just "X."
|
Verb запали lost or mistranslated |
замок (lock) |
"Castle." |
Misclassified as place name |
зволожувач |
"Moisturizer." |
Cosmetic product mistranslation |
продовж |
"Continued." |
Declarative; no imperative remains |
тихіше |
"Be quiet." |
Full sentence from single word |
голосніше |
"Louder." |
Correct — no special handling needed |
постав джаз |
"Let's jazz." |
Idiom; works if media.play_genre has a jazz anchor |
Some prefixes are stripped BEFORE Helsinki runs — see _strip_uk_listener_prefix(). Current list: слухай, послухай, дивись, скажи будь ласка, привіт. If your intent frequently fails on UK utterances starting with an attention-getter, extend this list rather than adding artifact anchors (cleaner and covers the whole phrase space).
Run your UK test phrases through the translator manually before writing anchors:
docker exec -t selena-core python3 -c "
from core.translation.local_translator import get_input_translator
t = get_input_translator()
print(t.to_english('ваша фраза тут', 'uk'))
"Whatever it outputs — THAT is what your anchors need to match.
Same user action, different verbs. Users don't care about the verb — they care about the outcome.
Example: clock.cancel_alarm + clock.stop_alarm were two intents. User says "cancel the alarm" or "stop the alarm" interchangeably — whether the alarm is ringing now or scheduled for tomorrow. One intent, one handler that dispatches on state: if ringing, silence it; else, delete it from the schedule. The handler makes the contextual decision, not the classifier.
Same verb phrase, genuinely different downstream actions with different reversibility semantics.
Example: media.pause vs media.stop. Users sometimes say "stop the music" when they mean pause — anchors overlap. But pause is resumable (session preserved), stop is not (session destroyed). Keeping them separate means the user can recover "wait, actually resume" after a pause. If you merge, "stop then resume" becomes "stop, then restart from scratch" — user-visible regression.
If you can't write a sharp description that contrasts two intents in under 20 words — they should be one intent. presence.who_home vs check_user passes this test ("open query" vs "contains a proper name"). cancel_alarm vs stop_alarm fails it ("silence one that's ringing" vs "delete a scheduled one" — same from user perspective).
Located in system_modules/llm_engine/intent_router.py, inside _try_embedding_classify() after the cosine winner is computed.
if result.intent == <wrong_intent> and <query_condition>:
result.intent = <correct_intent>Only for systematic misroutes that anchors can't fix. Example: TV commands were routing to media.play_radio_name because "TV" looks like a proper-noun station name. No amount of device.on anchors fixed the cosine collision. Post-proc rule:
if (
result.intent.startswith("media.play_")
and (result.params or {}).get("entity") == "tv"
):
result.intent = "device.off" if is_off else "device.on"Similarly: house.all_off override when query contains all/everything/все and classifier returned device.off. And media.volume_up override for turn it up / louder idioms misrouting to device.on.
Every override is a brittle rule that hides cosine issues instead of fixing them. If you find yourself writing 3+ overrides for one intent — your description and anchors are the problem. Fix those first.
If you declare an intent in OWNED_INTENTS / _OWNED_INTENT_META, you must have a handler for it.
Declared-but-unhandled intents pollute the classifier's candidate set. The cosine picks the closest match from all declared intents — if your dead intent's description is lexically close to a real utterance, the classifier routes to it, and the handler silently drops the command.
We had this with media.shuffle_toggle: declared since v0.3, no handler in voice_handler.py. Users saying "shuffle" got their command routed to it, then nothing happened. Removing the declaration from _OWNED_INTENT_META unblocked downstream cases — classifier picked real intents instead.
Rule: before adding to _OWNED_INTENT_META, write the handler. If the handler can't be written now, don't declare the intent.
Configured in config/core.yaml:
intent:
embedding_score_threshold: 0.25 # winner cosine must exceed this
embedding_margin_threshold: 0.003 # winner − runner-up must exceed thisAlso hardcoded in embedding_classifier.py as UNKNOWN_THRESHOLD / MARGIN_THRESHOLD — keep both layers in sync.
Don't tune per-intent. Thresholds are global because the classifier's cosine distribution is global. If the whole distribution shifts (e.g. after a model swap), tune once, re-bench, keep. The current values were found against paraphrase-multilingual-MiniLM-L12-v2 — a different embedding model may need different thresholds.
Every new intent needs corpus coverage. Without it, you can't measure whether your intent works.
Edit tests/experiments/corpus_generator.py — find the list for your category (or add a new category) and append:
{"lang": "en", "native": "<user phrase>",
"exp_intent": "<your.intent>",
"exp_entity": None, # or the entity_type if known
"exp_location": None,
"category": "<category>", "twist": None, "noise": None},Categories in use: plain, variety, noise, ambiguous, all_off, all_on, media, clock, weather, presence, automation, system, distractor. Add a new category if none fits — update _verdict() in run_coverage_bench.py accordingly.
- 2 cases EN (plain + one variety: short, polite, syn, or casual)
- 2 cases UK (plain + one variety)
- If the intent is bilingual, run Helsinki on the UK cases manually and check the English output — adjust anchors if output is unusual
docker exec -t selena-core python3 /opt/selena-core/tests/experiments/run_coverage_bench.pyTakes ~20 seconds. Output includes per-category breakdowns and full failure list.
- Per-intent: new intent must hit ≥ 80% on its own cases (≥ 60% if it shares verbs heavily with an existing intent — allowed to overlap, not allowed to steal).
- Overall: total accuracy stays ≥ 97.0% (current 96.6%, 0.4pp buffer).
- Distractors: must stay at 100%. Any drop means over-trained anchors that produce false-positives on chat.
- Name:
<module>.<verb_or_noun>in a valid namespace - Description: IS + IS-NOT + 2–3 concrete phrases, 80–200 chars
-
entity_typesconstraint only if semantically narrow - 5–10 anchors, including Helsinki artifacts if UK
- Handler written — not a declared-but-unhandled intent
- ≥ 3 corpus cases (EN + UK, plain + variety)
- Bench: ≥ 80% on new intent, ≥ 97% overall, 100% distractors
Before: description alone, no anchors. Thermostat 13%, AC 36% on set_temperature cases.
Anchors added:
"device.set_temperature": [
"set the air conditioner to 22 degrees",
"set temperature to 20",
"set the temperature to 22 degrees in the living room",
"set the temperature to 22 degrees in the bedroom",
"set the temperature to 22 degrees in the bathroom",
"set temperature to 22 in the kitchen",
"make it 22 degrees in the living room",
"change the temperature to 22 degrees",
# Helsinki outputs for UK "встанови температуру":
"set the air conditioning to 22 degrees.",
"set twenty-two degrees.",
],Result: thermostat 91%, AC 94%. One edit, +80pp on thermostat.
Attempted with anchors first: "turn off everything", "shut everything down", ~15 anchors. Regression of −19.6pp because the anchors ("turn off …") collided with device.off anchors.
Replaced with post-proc override:
if has_all and result.intent in ("device.on", "device.off"):
new_intent = "house.all_on" if result.intent == "device.on" else "house.all_off"
result.intent = new_intentDescription kept strict:
"Whole-house mass off — user said 'all' / 'everything'. "
"NOT for single-device off (use device.off). Triggers "
"ONLY when the query explicitly contains 'all', "
"'everything', 'все', 'всі', 'всё'."Result: 15/15 cases pass, no regression on device.off.
Users say "louder" alone. Classifier originally had no anchor for it — returned unknown. Users say "turn it up" which post-proc misrouted to device.on. Fixed with:
# Anchors
"media.volume_up": [
"louder",
"turn it up",
"make it louder",
"increase volume",
"volume up",
],
# Post-proc override
if result.intent in ("device.on", "device.off"):
if "turn it up" in q_low or "louder" in q_low:
result.intent = "media.volume_up"Result: media.volume_up / media.volume_down now 100%.
Questions / corrections? This doc is versioned in the repo. Open an issue or PR if your intent doesn't land where the guide says it should — that's a signal this doc is wrong or incomplete, not you.
Sometimes the classifier correctly identifies the intent but a critical parameter is missing (set_temperature with no value), or the registry has N devices that all match (turn off the light with 3 bedroom lights). Rather than fail silently or pick arbitrarily, emit a clarification — the assistant asks a follow-up question, keeps the mic open for 10 seconds, then routes the reply against the pending context.
Three triggers, listed with who detects them:
| Trigger | Detected by | Example |
|---|---|---|
ambiguous_device |
IntentRouter._disambiguate_device |
2+ devices of the same type, no room given |
missing_param |
Your module's handler |
set_temperature with no numeric value |
low_margin |
IntentRouter._try_embedding_classify |
Embedding winner margin in (0.003, 0.015) |
ambiguous_device and low_margin happen automatically in the router — nothing for a module author to wire up. missing_param is on YOU.
Use SystemModule.request_clarification():
from core.module_loader.system_module import SystemModule
class ClimateModule(SystemModule):
async def _on_voice_intent(self, event):
params = event.payload.get("params", {})
intent = event.payload.get("intent")
if intent == "climate.set_temperature":
if not params.get("value"):
await self.request_clarification(
pending_intent=intent,
pending_params=dict(params),
question_key="clarify.missing_value", # canned prompt key
reason="missing_param",
hint="temperature", # shown in the question
param_name="value", # slot to fill from reply
)
return # IMPORTANT: return immediately — VoiceCore
# enters AWAITING_CLARIFICATIONParameters:
-
pending_intent: the intent that should re-fire after the user answers. -
pending_params: params captured so far (merged with the answer on turn 2). -
question_key: a key registered in action_phrasing.py. Canned catalog hasclarify.missing_value/clarify.which_room/clarify.which_device/clarify.low_confidence/clarify.cancelled/clarify.timed_out. Register your own module-specific key if needed. -
hint: free-text hint substituted into the question ("whattemperature?" / "whatfan speed?"). -
param_name: the slot name inpending_paramsthat the reply fills. Defaults to"value". -
allowed_values: for enum-like slots (mode / fan-speed). If set, reply is fuzzy-matched against this list. Numeric parsing still runs first. -
timeout_sec: 10.0 default. Rarely override.
Must return immediately after calling request_clarification. VoiceCore's audio loop transitions to AWAITING_CLARIFICATION and opens the mic; don't block after the call.
IntentRouter.route_clarification() runs a fast-path resolver over the reply:
-
Positional reference —
"first"/"the second"/"перший"/"друге"→ pick candidate by index. EN + UK only for MVP (see plan §R9). - Room match — bilingual with morphology tolerance (UK noun cases handled via SequenceMatcher similarity ≥ 0.70).
-
Device-name match — fuzzy similarity ≥ 0.75 against candidate
namefields. -
Numeric parsing — digits (
"22") or word-form ("twenty-two"), reuses_extract_numeric_value(). - Allowed-values fuzzy match — for enum slots if your intent declared them.
-
Affirmation —
"yes"/"ok"/"так"→ picks the low-margin winner.
On match: the original intent re-fires with merged params; module handler runs as if the full command had been given in one shot.
On match failure: canned clarify.cancelled is spoken, back to idle.
Add a fixture to clarification_fixtures.py:
{
"name": "my_module.missing_param.numeric",
"lang": "en",
"turn_2_text": "5",
"synthetic_pending": {
"reason": "missing_param",
"question_key": "clarify.missing_value",
"hint": "duration",
"param_name": "duration_sec",
"pending_intent": "my_module.start",
"pending_params": {},
"timeout_sec": 10.0,
},
"expected_final_intent": "my_module.start",
"expected_final_params": {"duration_sec": "5"},
},Run:
docker exec -t selena-core python3 /opt/selena-core/tests/experiments/run_clarification_bench.pyTarget: your fixture passes. Keep the fuzzy-fail companion too (give a reply that can't match, expect allow_cancelled: True).
-
Don't implement your own "ask and wait" loop. Use
request_clarification. The state machine is in VoiceCore and needs to stay there — multiple modules racing for mic input would corrupt the session. - Don't chain clarifications. MVP is one round per command. "Which room? bedroom. Which bedroom light?" is out-of-scope and will confuse the user.
-
Don't set
timeout_secbelow 5 or above 15. Below 5 is too aggressive (users need think-time); above 15 is annoying silence.
🤖 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
Довідник