Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3d8a628
perf: store entity samples as set for O(1) membership lookup
JarbasAl Apr 21, 2026
a2284ca
fix: clamp confidence to [0.0, 1.0] in _match()
JarbasAl Apr 21, 2026
8fcda09
perf: proportional wildcard penalty based on open-token ratio
JarbasAl Apr 21, 2026
fcb8eb8
fix: word-boundary keyword exclusion in _filter
JarbasAl Apr 21, 2026
888718c
fix: non-greedy entity capture for multi-entity patterns
JarbasAl Apr 21, 2026
b0b54f2
perf: skip cased matcher pass for all-lowercase queries
JarbasAl Apr 21, 2026
3af6401
perf: early exit at 0.95 confidence + deterministic tie-breaking
JarbasAl Apr 21, 2026
550620d
perf: increase _calc_padacioso_intent LRU cache to 128
JarbasAl Apr 21, 2026
fcbb4c7
test: add accuracy-improvement regression tests
JarbasAl Apr 21, 2026
070950f
Delete status.md
JarbasAl Apr 21, 2026
3931e8d
fix: strip _matched_regex before constructing PadaciosoIntent in opm.py
JarbasAl Apr 21, 2026
e202a9c
docs: add README with usage guide and speed benchmarks
JarbasAl Apr 21, 2026
b5407a0
docs: add 10k-intent benchmark results to README
JarbasAl Apr 21, 2026
5541fa9
docs: add fuzzy vs non-fuzzy benchmark table to README
JarbasAl Apr 22, 2026
8d1f51e
perf: pre-compute fuzz variants + word-length and token-overlap gates
JarbasAl Apr 22, 2026
6eca682
docs: add accuracy benchmark dataset and runner (97.8% on 269 utteran…
JarbasAl Apr 22, 2026
bbb99e6
fix: literal patterns take priority over entity/wildcard matches
JarbasAl Apr 22, 2026
e296a58
docs: replace template-fill test utterances with genuine human phrasing
JarbasAl Apr 22, 2026
1db7a1b
docs: add engine comparison table to README
JarbasAl Apr 22, 2026
c505a45
docs: fix mycroft.conf snippet — JSON not YAML
JarbasAl Apr 22, 2026
afac3d8
fix: remove f-string with no placeholders (Ruff F541)
JarbasAl Apr 22, 2026
51c0727
fix: resolve Ruff E402 and F841 in benchmark/accuracy.py
JarbasAl Apr 22, 2026
59a1dad
fix: use lowercased query for multi-token excluded-keyword matching
JarbasAl Apr 22, 2026
3fea267
fix: collect all tied candidates before early-exit in calc_intent
JarbasAl Apr 22, 2026
9be96cd
fix: ensure tie-breaker sees all tied candidates at conf=1.0
JarbasAl Apr 22, 2026
2f454ae
fix: rename ambiguous loop variable l → line (Ruff E741)
JarbasAl Apr 22, 2026
1f804f9
feat: add nebulento to compare benchmark, cover all fuzzy strategies …
JarbasAl Apr 22, 2026
e31db78
docs: update engine comparison table with nebulento all-strategy resu…
JarbasAl Apr 22, 2026
c913949
feat: replace RTF with mean latency in benchmark summary table
JarbasAl Apr 22, 2026
2ce99a3
chore: remove benchmark suite (moved to nebulento repo)
JarbasAl Apr 22, 2026
e03ed5b
feat: add DomainIntentContainer for two-stage intent matching
JarbasAl May 19, 2026
03802fd
Merge branch 'dev' into add-domain-intent-container
JarbasAl May 19, 2026
e0760b1
refactor: extract container-shape hooks on PadaciosoPipeline
JarbasAl May 20, 2026
8f54b9b
feat: add DomainPadaciosoPipeline as separate OPM entry point
JarbasAl May 20, 2026
304b4a4
docs: document DomainPadaciosoPipeline entry point and hierarchical r…
JarbasAl May 20, 2026
d8db65c
feat(test): ovoscope end-to-end tests for DomainPadaciosoPipeline
JarbasAl May 20, 2026
80b15ad
refactor: drop router from DomainIntentContainer, adopt parallel-argmax
JarbasAl May 20, 2026
347a6f2
Merge remote-tracking branch 'origin/dev' into HEAD
JarbasAl Jul 30, 2026
1d2b628
fix: use padacioso._normalize in place of removed bracket_expansion.n…
JarbasAl Jul 30, 2026
4074840
fix: route OVOS-INTENT-4 spec handlers through container-shape hooks
JarbasAl Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ Padacioso ships as an OVOS pipeline plugin (`ovos-padacioso-pipeline-plugin`) an
}
```

## Hierarchical (domain) pipeline

A second OPM entry point — `ovos-padacioso-domain-pipeline` — exposes a two-level
variant backed by `DomainIntentContainer`. Intents are grouped by `skill_id` (taken from
the `skill_id:intent` label prefix); the top-level classifier first picks the most likely
domain, then the per-domain sub-container resolves the intent. See
[docs/domain_pipeline.md](docs/domain_pipeline.md) for configuration and routing details.

## License

Apache 2.0
110 changes: 110 additions & 0 deletions docs/domain_pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Domain Padacioso Pipeline

This page documents two layers that ship together:

* **`DomainPadaciosoPipeline`** — the OPM-discoverable pipeline class. Entry point: `ovos-padacioso-domain-pipeline`. Subclasses the flat `PadaciosoPipeline`; the only differences are the container shape (below) and that intents are routed to a domain == `skill_id` at registration time.
* **`DomainIntentContainer`** — the parallel-argmax variant of `IntentContainer` used internally by that pipeline.

A separate entry point (rather than a config flag on the flat pipeline) keeps the two pipelines independently selectable in `default_pipeline` ordering and lets each have its own `intents.<key>` config block.

## Enabling

Add it to your OVOS config and place it in your pipeline order alongside (or in place of) the flat plugin:

```json
{
"intents": {
"ovos-padacioso-domain-pipeline": {
"fuzz": true
},
"pipeline": [
"ovos-padacioso-domain-pipeline-high",
"ovos-padacioso-domain-pipeline-medium",
"ovos-padacioso-domain-pipeline-low"
]
}
}
```

Configuration keys are read from `intents.ovos_padacioso_domain_pipeline`. The pipeline accepts every key the flat plugin does (`fuzz`, `workers`, `conf_high`, `conf_med`, `conf_low`).

## Domain container

`DomainIntentContainer` groups intents into *domains* (one sub-container per domain) and evaluates every domain in parallel at inference time, returning the global argmax. This mirrors the parallel-argmax pattern shipped by sibling OVOS intent engines (adapt, `nebulento.DomainIntentContainer`, `ovos_padatious.DomainIntentContainer`, `palavreado.DomainIntentContainer`, `ovos_m2v_pipeline.DomainPrototypeIntentStore`).

There is intentionally no top-level "router" container. Strict regex matching is the wrong tool for routing: paraphrases that don't match any router template would block the sub-stage from ever running, even when a domain has a perfect template hit. Parallel evaluation is strictly more permissive and — for padacioso — practically free.

## Why grouped by domain

Two concrete benefits over a flat container:

1. **Targeted scoping** — `calc_intent(query, domain=...)` evaluates a single sub-container, useful for session/context-driven scoping where the caller already knows the active domain.
2. **Cheap prefilter** — domains whose templates share zero literal tokens with the utterance are skipped before any regex runs.
3. **Short-circuit on decisive match** — padacioso hits literal templates at 0.95 confidence; the first such hit ends the scan.

## Routing

Padatious intents follow the convention `<skill_id>:<intent_name>`. The domain pipeline extracts the `skill_id` prefix from each registered intent label and uses it as the domain name. Labels without a `:` use the whole name as the domain (graceful fallback).

```
utterance
┌─────────────────────────────────┐
│ prefilter by literal tokens │
└─────────────────────────────────┘
candidate domains
┌─────────────────────────────────┐
│ domains[d1].calc_intent(query) │
│ domains[d2].calc_intent(query) │ parallel argmax
│ domains[d3].calc_intent(query) │
└─────────────────────────────────┘
best by confidence
PadaciosoIntent
```

Every `padatious:register_intent` adds the templates to the domain's `IntentContainer` (`domains[skill_id]`) under the full `skill_id:intent` label.

Entities are shared across domains: a `padatious:register_entity` adds the entity to every sub-container.

`detach_intent` removes only the named intent from its domain; if the domain is left empty, the domain entry is dropped. `detach_skill` removes the whole domain in one shot.

## Programmatic usage

```python
from padacioso import DomainIntentContainer

d = DomainIntentContainer()
d.register_domain_intent("media", "play",
["play {song}", "put on {song}"])
d.register_domain_intent("home", "lights_on",
["lights on", "turn on the lights"])

d.calc_intent("play some jazz")
# {'name': 'play', ..., 'conf': 0.95}
```

### Scoping to a single domain

Pass `domain=...` to `calc_intent` to evaluate only that domain — useful for session/context-driven scoping where the caller already knows the active domain:

```python
d.calc_intent("play some jazz", domain="media")
```

### Top-K matches

```python
d.calc_intents("play some jazz", top_k=3)
# [{'name': 'play', 'conf': 0.95, ...}, ...]
```

## See also

- [Padacioso README](../README.md) — flat pipeline overview and template syntax.
5 changes: 5 additions & 0 deletions padacioso/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import re
from typing import List, Iterator, Optional

import simplematch

from ovos_spec_tools import expand, normalize_for_match

Check failure on line 6 in padacioso/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (I001)

padacioso/__init__.py:1:1: I001 Import block is un-sorted or un-formatted help: Organize imports


def _normalize(text: str) -> str:
Expand Down Expand Up @@ -94,7 +94,7 @@
_init_sm_word_type()

@staticmethod
def _get_fuzzed(sample: str) -> List[str]:

Check failure on line 97 in padacioso/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (FA100)

padacioso/__init__.py:97:37: FA100 Add `from __future__ import annotations` to simplify `typing.List` help: Add `from __future__ import annotations`
fuzzed = []
words = sample.split(" ")
for idx in range(len(words)):
Expand All @@ -113,7 +113,7 @@
if w != "*" and "{" not in w and "}" not in w
)

def add_intent(self, name: str, lines: List[str]):

Check failure on line 116 in padacioso/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (FA100)

padacioso/__init__.py:116:44: FA100 Add `from __future__ import annotations` to simplify `typing.List` help: Add `from __future__ import annotations`
"""
Add an intent with examples.
@param name: name of intent to add
Expand Down Expand Up @@ -162,7 +162,7 @@
self._fuzz_variants.pop(rx, None)
self._cache_dirty = True # Mark cache as needing rebuild

def add_entity(self, name: str, lines: List[str]):

Check failure on line 165 in padacioso/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (FA100)

padacioso/__init__.py:165:44: FA100 Add `from __future__ import annotations` to simplify `typing.List` help: Add `from __future__ import annotations`
"""
Add an entity with examples.
@param name: name of entity to add
Expand Down Expand Up @@ -208,10 +208,10 @@
if any(_kw_hit(s) for s in samples):
excluded_intents.append(intent_name)
for intent_name, contexts in self.required_contexts.items():
if intent_name not in self.available_contexts:
excluded_intents.append(intent_name)
elif any(context not in self.available_contexts[intent_name] for context in contexts):
excluded_intents.append(intent_name)

Check failure on line 214 in padacioso/__init__.py

View workflow job for this annotation

GitHub Actions / lint / lint

ruff (SIM114)

padacioso/__init__.py:211:13: SIM114 Combine `if` branches using logical `or` operator help: Combine `if` branches
for intent_name, contexts in self.excluded_contexts.items():
if intent_name not in self.available_contexts:
continue
Expand Down Expand Up @@ -436,3 +436,8 @@
"""
regex = r"[a-zA-Z0-9]+"
simplematch.register_type("word", regex)


# Re-export DomainIntentContainer at the package root for parity with
# nebulento and ovos-padatious.
from padacioso.domain_engine import DomainIntentContainer # noqa: E402, F401
176 changes: 176 additions & 0 deletions padacioso/domain_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""Domain-aware intent container for hierarchical intent organisation.

Mirrors the parallel-argmax design used by sibling intent engines
(adapt, nebulento, ovos_padatious, palavreado): intents are grouped into
*domains*, and at query time **every** domain's sub-container is
evaluated in parallel; the global best confidence wins.

There is intentionally no top-level "router" container. Strict regex
matching (padacioso) is the wrong tool for routing: paraphrases that
don't match any router template would block the sub-stage from ever
running, even when a domain sub-container has a perfect template hit.
Parallel evaluation is strictly more permissive and, for padacioso,
practically free (regex matching is fast and we additionally prefilter
domains by literal-token overlap and short-circuit on the first 0.95
hit).
"""

import re
from typing import Dict, List, Optional, Set

from padacioso import IntentContainer, _normalize as normalize_example


_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+")


def _literal_tokens(line: str) -> Set[str]:
"""Return the set of literal word tokens in a padacioso template line.

Entity placeholders (``{name}``), wildcards (``*``) and bracketed
alternations are stripped so only fixed surface tokens remain.
"""
try:
text = normalize_example(line)
except Exception:
text = line
# Drop entity placeholders
text = re.sub(r"\{[^}]*\}", " ", text)
# Drop bracket-alternation syntax (kept tokens are still extracted by regex)
text = text.replace("(", " ").replace(")", " ").replace("|", " ")
text = text.replace("*", " ")
return {t.lower() for t in _TOKEN_RE.findall(text)}


class DomainIntentContainer:
"""Parallel-argmax intent engine across per-domain sub-containers.

Intents are grouped into *domains*. At query time the engine asks
every sub-container to match and returns the global best.

Example::

from padacioso import DomainIntentContainer

d = DomainIntentContainer()
d.register_domain_intent("media", "play",
["play {song}", "put on {song}"])
d.register_domain_intent("home", "lights_on",
["lights on", "turn on the lights"])

result = d.calc_intent("play some jazz")
# result["name"] == "play"

Args:
fuzz: Forwarded to every :class:`IntentContainer` created
internally. When ``True`` partial matching is enabled.
n_workers: Forwarded to every internal :class:`IntentContainer`.
"""

#: Confidence at/above which the first matching domain short-circuits.
_GOOD_ENOUGH = 0.95

def __init__(self, fuzz: bool = False, n_workers: int = 4) -> None:
self.fuzz = fuzz
self.n_workers = n_workers
#: Per-domain intent containers, keyed by domain name.
self.domains: Dict[str, IntentContainer] = {}
#: Literal-token vocabulary per domain (for cheap prefilter).
self._domain_vocab: Dict[str, Set[str]] = {}

# ── domain management ──────────────────────────────────────────────────

def remove_domain(self, domain_name: str) -> None:
"""Remove a domain and all its intents."""
self.domains.pop(domain_name, None)
self._domain_vocab.pop(domain_name, None)

# ── intent management ──────────────────────────────────────────────────

def register_domain_intent(self, domain_name: str, intent_name: str,
lines: List[str]) -> None:
"""Register an intent inside a domain.

Creates the domain's :class:`IntentContainer` on first use.

Args:
domain_name: Target domain (created if it does not exist).
intent_name: Unique intent name within the domain.
lines: Padacioso template lines for the intent.
"""
if domain_name not in self.domains:
self.domains[domain_name] = IntentContainer(
fuzz=self.fuzz, n_workers=self.n_workers
)
self._domain_vocab[domain_name] = set()
self.domains[domain_name].add_intent(intent_name, lines)
for line in lines:
self._domain_vocab[domain_name] |= _literal_tokens(line)

def remove_domain_intent(self, domain_name: str, intent_name: str) -> None:
"""Remove an intent from a domain."""
if domain_name in self.domains:
self.domains[domain_name].remove_intent(intent_name)
# Vocabulary is not pruned per-intent (cheap over-approximation
# only widens the prefilter; correctness is preserved).

# ── query API ──────────────────────────────────────────────────────────

def _candidate_domains(self, query: str) -> List[str]:
"""Cheap literal-token prefilter: keep domains whose vocab overlaps."""
utt_tokens = {t.lower() for t in _TOKEN_RE.findall(query)}
if not utt_tokens:
return list(self.domains.keys())
candidates = []
for name, vocab in self._domain_vocab.items():
# Empty vocab (only entity/wildcard templates) -> can't prefilter.
if not vocab or utt_tokens & vocab:
candidates.append(name)
return candidates

def calc_intent(self, query: str,
domain: Optional[str] = None) -> Optional[dict]:
"""Return the global best intent match for *query*.

Args:
query: The utterance to match.
domain: If given, evaluate only inside this domain.

Returns:
The match dict from the winning domain's container, or ``None``.
"""
if domain is not None:
sub = self.domains.get(domain)
return sub.calc_intent(query) if sub is not None else None

best: Optional[dict] = None
best_conf = 0.0
for name in self._candidate_domains(query):
sub = self.domains[name]
match = sub.calc_intent(query)
if not match or not match.get("name"):
continue
conf = match.get("conf", 0) or 0
if conf > best_conf:
best = match
best_conf = conf
# padacioso hits literal templates at 0.95; first such match
# is decisive enough to skip remaining domains.
if best_conf >= self._GOOD_ENOUGH:
break
return best

def calc_intents(self, query: str, top_k: int = 5) -> List[dict]:
"""Return the top-K intent matches across all domains.

Each sub-container contributes its best match; results are sorted
by confidence descending and truncated to ``top_k``.
"""
results: List[dict] = []
for name in self._candidate_domains(query):
sub = self.domains[name]
for match in sub.calc_intents(query):
if match and match.get("name"):
results.append(match)
results.sort(key=lambda m: m.get("conf", 0) or 0, reverse=True)
return results[:top_k]
Loading
Loading