2. Sentence templates
August 1, 2026 · View on GitHub
Spec coverage. This chapter is the reference for OVOS-INTENT-1: the Sentence Template Grammar (
expansion.py).expand()is the Expander conformance role of OVOS-INTENT-1 §7: it implements the token set of §3, the enumeration algorithm of §4.1, and the malformed-form rejection of §3.6.
A sentence template is a compact string that stands for a set of sentences. It is the grammar of OVOS-INTENT-1, and it is the foundation of this package: resource files are lists of templates, and the dialog renderer renders them.
expand() is the whole interface:
from ovos_spec_tools import expand, MalformedTemplate
expand(template) returns the sample set, the finite list of sentences
the template denotes.
The four tokens
A template is literal words interspersed with four kinds of token.
Literal words
Anything that is not a token is literal text, matched verbatim:
expand("turn on the lights")
# ['turn on the lights']
Alternatives: (a|b|c)
Parentheses hold branches separated by |; each combination takes one branch:
expand("(turn on|switch on|enable) it")
# ['turn on it', 'switch on it', 'enable it']
A branch may be empty: (please|) means "please, or nothing":
expand("(please|) help me")
# ['please help me', 'help me']
Optionals: [x]
[x] is exactly (x|): the segment is included or omitted:
expand("turn on [the] lights")
# ['turn on the lights', 'turn on lights']
Groups nest freely:
expand("turn on [(all|every) ]light[s]")
# ['turn on all lights', 'turn on lights', 'turn on every lights',
# 'turn on all light', 'turn on light', 'turn on every light']
Named slots: {name}
A {name} slot is a placeholder for a value that varies, such as a song
title or a city. It is opaque: expansion carries it through untouched and never
enumerates it.
expand("(play|put on) {query}")
# ['play {query}', 'put on {query}']
Who fills a slot, and when, depends on the file role: an intent engine fills it from speech; a skill fills a dialog slot before the phrase is spoken. Expansion itself never does.
Vocabulary references: <name>
<name> (OVOS-INTENT-1 §3.7) pulls in a named vocabulary, a reusable set
of phrasings, and expands it in place. Pass the vocabularies as a dict:
expand("<greeting> [there]", {"greeting": ["hello", "hi", "hey"]})
# ['hello there', 'hi there', 'hey there', 'hello', 'hi', 'hey']
This is how you avoid repeating the same (hello|hi|hey) group across many
templates: define it once as a vocabulary, reference it as <greeting>. A
vocabulary may itself contain <name> references; they resolve recursively
(§4.1 step 1). A reference must resolve to a slot-free set, a vocabulary
may not introduce a {slot}, and a single-member vocabulary substitutes its
bare member rather than a one-branch group.
How expansion works: the §4.1 algorithm
expand() follows OVOS-INTENT-1 §4.1 exactly, in order:
- Resolve
<name>references to alternative groups (recursively). - Rewrite
[x]as(x|): an optional is sugar for an empty branch. - Cartesian product of the innermost
(...)groups, repeated until no parenthesis remains. - Normalize whitespace: collapse runs of spaces, strip the ends (this is what removes the double space an empty branch leaves behind).
- De-duplicate, preserving first-seen order.
Worked through on (turn|switch) [the] (light|fan): three 2-branch groups
once [the] becomes (the|), so 2×2×2 = 8 combinations collapse (after
whitespace normalization) to the 8-sentence sample set of §4.2:
expand("(turn|switch) [the] (light|fan)")
# ['turn the light', 'switch the light', 'turn light', 'switch light',
# 'turn the fan', 'switch the fan', 'turn fan', 'switch fan']
The sample set is a set: §4 defines its membership, not an ordering. So
the eight sentences are exactly those of §4.2, the sequence above is just the
order expand() happens to emit them in. Throughout, {name} slots are
opaque: carried through and never enumerated (§4.1 final note).
The input model
The grammar is built for voice. Templates and the utterances matched against them are assumed already ASR-normalized: lowercase, alphanumeric words, single spaces, no punctuation. This package expands; it does not normalize. Normalization happens upstream.
One consequence: the metacharacters ( ) [ ] { } < > | never occur as literal
spoken text, so there is no escaping mechanism and none is needed.
(.dialog phrases are the exception: they are spoken output, so they may
carry mixed case and punctuation. See Dialog.)
Malformed templates
A template that cannot mean anything sensible raises MalformedTemplate:
try:
expand("turn (on|off the lights")
except MalformedTemplate as error:
print(error) # unbalanced metacharacters ...
The malformed forms (OVOS-INTENT-1 §3.6):
| Form | Example | Why |
|---|---|---|
| Unbalanced metacharacters | `(a | b` |
| Single-branch group | (word), () | a group must offer a choice |
| Empty sample | [hello], `( | )` |
| Slot-only template | {name} | a template needs anchoring literal text |
| Adjacent slots | {a} {b} | no word between two slots to delimit them |
| Repeated slot name | {x} and {x} | a slot is defined once per sample |
| Undefined vocabulary | <missing> | no such vocabulary was supplied |
| Cyclic vocabulary | <a>→<b>→<a> | resolution would not terminate |
Two are checked against the expanded samples, not the raw template:
{a} [x] {b} is malformed because the empty-x branch yields the adjacent
pair {a} {b}; and a template is malformed if any branch combination
produces an empty sentence.
Templates as training data
Expansion produces the shape of the training data a template contributes, nothing more. A capable intent engine generalizes beyond the sample set: it recognizes phrasings that were never enumerated. So keep templates focused and readable. You are not obliged to spell out every wording. Matching and generalization are the engine's job and are out of scope here.
Next
Locale resources, where templates live on disk, and how to load them.