feat(homecore-assist/p1): ADR-133 intent pipeline + ruflo runner stub (23 tests pass)
- Creates v2/crates/homecore-assist with intent, recognizer, handler, runner, and pipeline modules per ADR-133 §2 design - RegexIntentRecognizer: HA-style named-capture-group pattern matching - Built-in handlers: HassTurnOn, HassTurnOff, HassLightSet, HassNevermind, HassCancelAll — dispatch to homecore ServiceRegistry - RufloRunner trait + NoopRunner P1 stub (Windows-safe subprocess teardown deferred to P2 per ADR-133 §Q3) - AssistPipeline + default_pipeline() wires recognizer → handler → response - SemanticIntentRecognizer P2 stub (ruvector HNSW deferred) - 23 unit tests, 0 failures; cargo build -p homecore-assist clean Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
901adf1be6
commit
26ad45fbdb
|
|
@ -0,0 +1,176 @@
|
|||
# ADR-133: HOMECORE-ASSIST — Voice/Intent Pipeline + Ruflo Agent Bridge
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-25 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HOMECORE-ASSIST** |
|
||||
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (SENSE-BRIDGE) |
|
||||
| **Tracking issue** | TBD |
|
||||
| **Crate** | `v2/crates/homecore-assist` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Home Assistant's Assist pipeline (`homeassistant/components/assist_pipeline/`) provides
|
||||
voice-to-intent-to-response processing. It chains:
|
||||
|
||||
1. **STT** (speech-to-text) — Whisper, cloud, or satellite
|
||||
2. **NLU** (natural language understanding) — intent recognition via regex/slots
|
||||
3. **Intent handler** — maps intent to a HA service call
|
||||
4. **TTS** (text-to-speech) — synthesises the response for the caller
|
||||
|
||||
HA's intent model (`homeassistant/helpers/intent.py`) is keyword/regex based. Every
|
||||
intent is a named template with slot definitions and a handler that dispatches to HA
|
||||
services. The built-in intents (`homeassistant/components/conversation/default_agent.py`)
|
||||
cover `HassTurnOn`, `HassTurnOff`, `HassLightSet`, `HassNevermind`, `HassCancelAll`,
|
||||
`HassGetState`, `HassGetWeather`, and many others.
|
||||
|
||||
HOMECORE needs a wire-compatible Assist pipeline so that:
|
||||
- The HA iOS/Android companion app's "Assist" button works against HOMECORE.
|
||||
- The HOMECORE-API WebSocket `assist` command (ADR-130 §2.2) has a handler.
|
||||
- The ruflo agent toolchain (ADR-124) can provide LLM-grade intent disambiguation as a
|
||||
drop-in upgrade path for the P1 regex recognizer.
|
||||
|
||||
### 1.1 Ruflo integration approach
|
||||
|
||||
Ruflo's agent runner exposes an MCP-over-stdio interface (`node ruflo-agent.js`).
|
||||
HOMECORE-ASSIST manages a long-lived subprocess (Q3 Windows concern below), sends
|
||||
utterance JSON, and receives intent JSON back. In P1 we ship only the trait surface
|
||||
and a `NoopRunner` stub; the real subprocess management is P2.
|
||||
|
||||
### 1.2 Ruvector semantic intent matching (P2)
|
||||
|
||||
`ruvector-core` provides embedding + cosine-similarity primitives. P2 will add a
|
||||
`SemanticIntentRecognizer` that embeds the utterance and compares it to a HNSW index
|
||||
of intent exemplars, falling back to the P1 regex recognizer when similarity < 0.75.
|
||||
This is the mechanism that allows "dim the lights" to match `HassLightSet` without an
|
||||
explicit regex entry.
|
||||
|
||||
---
|
||||
|
||||
## 2. Design
|
||||
|
||||
### 2.1 Module layout (`v2/crates/homecore-assist/`)
|
||||
|
||||
| Module | Contents |
|
||||
|--------|----------|
|
||||
| `intent` | `IntentName` newtype, `Intent` (name + slots), `IntentResponse` (speech + optional card + optional data) |
|
||||
| `recognizer` | `IntentRecognizer` trait; `RegexIntentRecognizer` (P1); `SemanticIntentRecognizer` stub (P2) |
|
||||
| `handler` | `IntentHandler` trait; built-in handlers: `HassTurnOn`, `HassTurnOff`, `HassLightSet`, `HassNevermind`, `HassCancelAll` |
|
||||
| `runner` | `RufloRunner` trait + `RufloRunnerOpts`; `NoopRunner` (P1 stub); real subprocess runner (P2) |
|
||||
| `pipeline` | `AssistPipeline`: wires recognizer → handler → response; exposes `async fn process(utterance, language) -> IntentResponse` |
|
||||
|
||||
### 2.2 Built-in intent handlers (P1)
|
||||
|
||||
| Handler | HA service call | Slot |
|
||||
|---------|-----------------|------|
|
||||
| `HassTurnOn` | `homeassistant.turn_on` / `light.turn_on` / `switch.turn_on` | `entity_id` |
|
||||
| `HassTurnOff` | `homeassistant.turn_off` / `light.turn_off` / `switch.turn_off` | `entity_id` |
|
||||
| `HassLightSet` | `light.turn_on` | `entity_id`, `brightness` (0–255), `color_name` |
|
||||
| `HassNevermind` | — (no-op, returns acknowledgement) | — |
|
||||
| `HassCancelAll` | — (fires `homeassistant_stop_all_scripts` domain event) | — |
|
||||
|
||||
### 2.3 IntentResponse
|
||||
|
||||
```rust
|
||||
pub struct IntentResponse {
|
||||
pub speech: String,
|
||||
pub card: Option<Card>,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub struct Card {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 RufloRunner trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait RufloRunner: Send + Sync + 'static {
|
||||
async fn spawn(&mut self, opts: RufloRunnerOpts) -> Result<(), AssistError>;
|
||||
async fn send_request(&self, payload: serde_json::Value) -> Result<RufloResponse, AssistError>;
|
||||
async fn shutdown(&mut self) -> Result<(), AssistError>;
|
||||
}
|
||||
```
|
||||
|
||||
`RufloResponse` is `{ intent: Option<Intent>, speech: Option<String> }`.
|
||||
|
||||
### 2.5 Pipeline
|
||||
|
||||
```rust
|
||||
pub struct AssistPipeline<R, H> {
|
||||
recognizer: R,
|
||||
handler: H,
|
||||
runner: Option<Box<dyn RufloRunner>>,
|
||||
}
|
||||
|
||||
impl<R: IntentRecognizer, H: IntentHandler> AssistPipeline<R, H> {
|
||||
pub async fn process(&self, utterance: &str, language: &str, hc: &HomeCore)
|
||||
-> Result<IntentResponse, AssistError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Questions & Answers
|
||||
|
||||
### Q1 — Why not reuse HA's existing `homeassistant.helpers.intent` via PyO3?
|
||||
|
||||
PyO3 bridges add a GIL lock on every cross-language call; the Assist pipeline processes
|
||||
hundreds of short utterances per day from voice satellites. A native Rust recognizer is
|
||||
simpler and faster. Python HA can still connect as an external integration via MQTT or
|
||||
the HOMECORE WebSocket API.
|
||||
|
||||
### Q2 — How does `RegexIntentRecognizer` handle ambiguity?
|
||||
|
||||
Patterns are tried in registration order; the first match wins. Slot extraction uses
|
||||
named capture groups. A future P2 upgrade can run all patterns, score them by slot
|
||||
completeness, and return the highest-scoring match.
|
||||
|
||||
### Q3 — Windows subprocess teardown (ruflo runner subprocess on Windows)
|
||||
|
||||
`tokio::process::Child` on Windows does not automatically kill the child process when
|
||||
the `Child` struct is dropped — `SIGTERM` is not a Windows concept, and `TerminateProcess`
|
||||
is not called automatically. Options for P2:
|
||||
|
||||
1. Call `child.start_kill()` in a `Drop` impl (requires a `Runtime` handle — tricky in sync Drop).
|
||||
2. Wrap `Child` in an `Arc<Mutex<Option<Child>>>` and call `kill()` in an `async fn shutdown()`.
|
||||
3. Use a Windows job object to bind the subprocess lifetime to the parent process.
|
||||
|
||||
**P2 decision**: implement option 2 (explicit `async shutdown()`) + register a `tokio::signal`
|
||||
handler for `Ctrl+C` / `SIGINT` that calls `shutdown()` before exit. Document the Windows caveat
|
||||
in the crate README and in `runner.rs`. Job object approach (option 3) is deferred to P3 only
|
||||
if option 2 proves insufficient in fleet testing.
|
||||
|
||||
### Q4 — Why is `SemanticIntentRecognizer` a P2 stub?
|
||||
|
||||
The ruvector HNSW index requires the vector store to be populated at startup with intent
|
||||
exemplars. That startup path requires deciding on a serialization format (HNSW index files
|
||||
vs. an in-memory array at compile time), which intersects with ADR-084 (RabitQ) and ADR-067
|
||||
(ruvector v2.0.5). P2 will define the exemplar format and populate the index.
|
||||
|
||||
---
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
- **Positive**: HOMECORE-API `assist` WebSocket command gets a functional backend.
|
||||
- **Positive**: Ruflo LLM pipelines can upgrade intent matching by swapping the `RufloRunner` impl.
|
||||
- **Positive**: P1 ships with zero new heavy dependencies (no subprocess spawning, no ML runtime).
|
||||
- **Negative**: Regex matching has limited coverage; long-tail utterances will return "I'm not sure".
|
||||
- **Deferral**: ruvector semantic recognizer and real subprocess runner both land in P2.
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation phases
|
||||
|
||||
| Phase | Scope |
|
||||
|-------|-------|
|
||||
| **P1** (this ADR) | `intent`, `recognizer` (regex), `handler` (5 built-ins), `runner` (trait + noop), `pipeline` (end-to-end wiring), 10–15 tests |
|
||||
| **P2** | Real `tokio::process::Child` runner with Windows-safe teardown; `SemanticIntentRecognizer` with ruvector HNSW |
|
||||
| **P3** | STT/TTS bridge, satellite protocol, cloud fallback |
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# HOMECORE-ASSIST — Voice/intent pipeline + ruflo agent bridge.
|
||||
# Implements ADR-133 (HOMECORE-ASSIST), P1 scaffold:
|
||||
# - IntentName, Intent, IntentResponse types
|
||||
# - IntentRecognizer trait + RegexIntentRecognizer (P1)
|
||||
# - IntentHandler trait + 5 built-in HA-mirroring handlers
|
||||
# - RufloRunner trait + NoopRunner (P1 stub; real subprocess in P2)
|
||||
# - AssistPipeline: utterance → recognizer → handler → response
|
||||
|
||||
[package]
|
||||
name = "homecore-assist"
|
||||
version = "0.1.0-alpha.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||
description = "HOMECORE voice/intent pipeline + ruflo agent bridge (ADR-133 P1 scaffold)"
|
||||
repository = "https://github.com/ruvnet/RuView"
|
||||
|
||||
[lib]
|
||||
name = "homecore_assist"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# HOMECORE state machine — local path (ADR-127).
|
||||
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||
|
||||
# Async runtime — same feature set as workspace.
|
||||
# tokio::process is used by the P2 runner; included now so the trait compiles.
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Async trait support for IntentRecognizer, IntentHandler, RufloRunner.
|
||||
async-trait = "0.1"
|
||||
|
||||
# Error handling.
|
||||
thiserror = "1"
|
||||
|
||||
# Serialisation (intents, slots, ruflo request/response payloads).
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Regex for P1 intent pattern matching.
|
||||
regex = "1"
|
||||
|
||||
# Structured logging.
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
//! Intent handler trait + built-in HA-mirroring handlers.
|
||||
//!
|
||||
//! Mirrors `homeassistant.helpers.intent.IntentHandler`. Each handler
|
||||
//! receives a recognised `Intent` and a `HomeCore` handle, dispatches the
|
||||
//! appropriate service call, and returns an `IntentResponse`.
|
||||
//!
|
||||
//! ## Built-in handlers (P1)
|
||||
//!
|
||||
//! | Handler | HA service | Slots |
|
||||
//! |---------|-----------|-------|
|
||||
//! | `HassTurnOn` | `homeassistant.turn_on` | `entity_id` |
|
||||
//! | `HassTurnOff` | `homeassistant.turn_off` | `entity_id` |
|
||||
//! | `HassLightSet` | `light.turn_on` | `entity_id`, `brightness`, `color_name` |
|
||||
//! | `HassNevermind` | — (no-op) | — |
|
||||
//! | `HassCancelAll` | — (domain event) | — |
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use homecore::{Context, HomeCore, ServiceCall, ServiceName};
|
||||
|
||||
use crate::intent::{Intent, IntentResponse};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum HandlerError {
|
||||
#[error("service call failed: {0}")]
|
||||
ServiceFailed(String),
|
||||
#[error("missing required slot: {0}")]
|
||||
MissingSlot(String),
|
||||
#[error("handler internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Core trait every intent handler must implement.
|
||||
#[async_trait]
|
||||
pub trait IntentHandler: Send + Sync + 'static {
|
||||
/// The intent name(s) this handler accepts.
|
||||
fn intent_name(&self) -> &str;
|
||||
|
||||
/// Handle the intent and return a response.
|
||||
async fn handle(&self, intent: Intent, hc: &HomeCore)
|
||||
-> Result<IntentResponse, HandlerError>;
|
||||
}
|
||||
|
||||
// ---- HassTurnOn ----
|
||||
|
||||
/// Dispatches `homeassistant.turn_on` (domain-agnostic) for the entity.
|
||||
pub struct HassTurnOn;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassTurnOn {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassTurnOn"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
let entity_id = intent
|
||||
.entity_id()
|
||||
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
|
||||
.to_owned();
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("homeassistant", "turn_on"),
|
||||
data: serde_json::json!({ "entity_id": entity_id }),
|
||||
context: Context::new(),
|
||||
};
|
||||
hc.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
|
||||
Ok(IntentResponse::speech_only(format!("Turned on {entity_id}.")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassTurnOff ----
|
||||
|
||||
/// Dispatches `homeassistant.turn_off` for the entity.
|
||||
pub struct HassTurnOff;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassTurnOff {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassTurnOff"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
let entity_id = intent
|
||||
.entity_id()
|
||||
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
|
||||
.to_owned();
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("homeassistant", "turn_off"),
|
||||
data: serde_json::json!({ "entity_id": entity_id }),
|
||||
context: Context::new(),
|
||||
};
|
||||
hc.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
|
||||
Ok(IntentResponse::speech_only(format!("Turned off {entity_id}.")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassLightSet ----
|
||||
|
||||
/// Dispatches `light.turn_on` with optional `brightness` and `color_name`.
|
||||
pub struct HassLightSet;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassLightSet {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassLightSet"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
let entity_id = intent
|
||||
.entity_id()
|
||||
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
|
||||
.to_owned();
|
||||
let mut data = serde_json::json!({ "entity_id": entity_id });
|
||||
if let Some(b) = intent.slots.get("brightness") {
|
||||
data["brightness"] = b.clone();
|
||||
}
|
||||
if let Some(c) = intent.slots.get("color_name") {
|
||||
data["color_name"] = c.clone();
|
||||
}
|
||||
let call = ServiceCall {
|
||||
name: ServiceName::new("light", "turn_on"),
|
||||
data,
|
||||
context: Context::new(),
|
||||
};
|
||||
hc.services()
|
||||
.call(call)
|
||||
.await
|
||||
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
|
||||
Ok(IntentResponse::speech_only(format!("Done, adjusted {entity_id}.")))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassNevermind ----
|
||||
|
||||
/// No-op — acknowledges the cancellation without a service call.
|
||||
pub struct HassNevermind;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassNevermind {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassNevermind"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
_intent: Intent,
|
||||
_hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
Ok(IntentResponse::speech_only("Okay, never mind."))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HassCancelAll ----
|
||||
|
||||
/// Fires a domain event to cancel all running scripts/automations.
|
||||
pub struct HassCancelAll;
|
||||
|
||||
#[async_trait]
|
||||
impl IntentHandler for HassCancelAll {
|
||||
fn intent_name(&self) -> &str {
|
||||
"HassCancelAll"
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
_intent: Intent,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, HandlerError> {
|
||||
use homecore::{Context, DomainEvent};
|
||||
let event = DomainEvent::new(
|
||||
"homeassistant_stop_all_scripts",
|
||||
serde_json::json!({}),
|
||||
Context::new(),
|
||||
);
|
||||
// fire_domain is synchronous and infallible (returns receiver count).
|
||||
let _receivers = hc.bus().fire_domain(event);
|
||||
Ok(IntentResponse::speech_only("Cancelled all running automations."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use homecore::service::FnHandler;
|
||||
use homecore::ServiceName;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build a `HomeCore` pre-registered with a spy handler for the given
|
||||
/// service. Returns `(HomeCore, Arc<AtomicBool>)` so tests can assert
|
||||
/// the handler was called.
|
||||
async fn hc_with_spy(domain: &str, service: &str) -> (HomeCore, std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
||||
let hc = HomeCore::new();
|
||||
let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let called2 = called.clone();
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new(domain, service),
|
||||
FnHandler(move |_call| {
|
||||
let c = called2.clone();
|
||||
async move {
|
||||
c.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(serde_json::json!({}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
(hc, called)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_on_dispatches_service() {
|
||||
let (hc, called) = hc_with_spy("homeassistant", "turn_on").await;
|
||||
let intent = Intent::with_entity("HassTurnOn", "light.kitchen", "en");
|
||||
let resp = HassTurnOn.handle(intent, &hc).await.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(resp.speech.contains("light.kitchen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_off_dispatches_service() {
|
||||
let (hc, called) = hc_with_spy("homeassistant", "turn_off").await;
|
||||
let intent = Intent::with_entity("HassTurnOff", "switch.fan", "en");
|
||||
let resp = HassTurnOff.handle(intent, &hc).await.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(resp.speech.contains("switch.fan"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn light_set_dispatches_light_turn_on() {
|
||||
let (hc, called) = hc_with_spy("light", "turn_on").await;
|
||||
let mut intent = Intent::with_entity("HassLightSet", "light.living", "en");
|
||||
intent
|
||||
.slots
|
||||
.insert("brightness".into(), serde_json::json!(128));
|
||||
let resp = HassLightSet.handle(intent, &hc).await.unwrap();
|
||||
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(resp.speech.contains("light.living"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nevermind_returns_ok_response() {
|
||||
let hc = HomeCore::new();
|
||||
let intent = Intent {
|
||||
name: crate::intent::IntentName::new("HassNevermind"),
|
||||
slots: Default::default(),
|
||||
language: "en".into(),
|
||||
};
|
||||
let resp = HassNevermind.handle(intent, &hc).await.unwrap();
|
||||
assert!(resp.speech.to_lowercase().contains("never mind")
|
||||
|| resp.speech.to_lowercase().contains("nevermind")
|
||||
|| resp.speech.to_lowercase().contains("okay"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_all_fires_domain_event() {
|
||||
let hc = HomeCore::new();
|
||||
// Subscribe before firing so the sender has a live receiver.
|
||||
let mut rx = hc.bus().subscribe_domain();
|
||||
let intent = Intent {
|
||||
name: crate::intent::IntentName::new("HassCancelAll"),
|
||||
slots: Default::default(),
|
||||
language: "en".into(),
|
||||
};
|
||||
let resp = HassCancelAll.handle(intent, &hc).await.unwrap();
|
||||
assert!(resp.speech.to_lowercase().contains("cancel"));
|
||||
// Domain event should have been broadcast.
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert_eq!(event.event_type, "homeassistant_stop_all_scripts");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
//! Intent types for the HOMECORE-ASSIST pipeline.
|
||||
//!
|
||||
//! Mirrors `homeassistant.helpers.intent.Intent` and
|
||||
//! `homeassistant.helpers.intent.IntentResponse`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Newtype wrapping the intent name string (e.g. `"HassTurnOn"`).
|
||||
///
|
||||
/// Kept as a newtype rather than a raw `String` so that call sites can
|
||||
/// pattern-match on well-known constant values without stringly-typed bugs.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
pub struct IntentName(pub String);
|
||||
|
||||
impl IntentName {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IntentName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A recognised user intent with extracted slot values.
|
||||
///
|
||||
/// Mirrors `homeassistant.helpers.intent.Intent`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Intent {
|
||||
/// The intent name, e.g. `HassTurnOn`.
|
||||
pub name: IntentName,
|
||||
/// Extracted slots as a JSON-value map. Keys are slot names
|
||||
/// (e.g. `"entity_id"`, `"brightness"`); values are typed by the
|
||||
/// recognizer.
|
||||
pub slots: HashMap<String, serde_json::Value>,
|
||||
/// BCP-47 language tag of the utterance (e.g. `"en"`, `"en-US"`).
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
/// Convenience constructor for single-slot intents.
|
||||
pub fn with_entity(name: impl Into<String>, entity_id: impl Into<String>, lang: &str) -> Self {
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert(
|
||||
"entity_id".into(),
|
||||
serde_json::Value::String(entity_id.into()),
|
||||
);
|
||||
Self {
|
||||
name: IntentName::new(name),
|
||||
slots,
|
||||
language: lang.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the `entity_id` slot as a `&str`, if present.
|
||||
pub fn entity_id(&self) -> Option<&str> {
|
||||
self.slots.get("entity_id").and_then(|v| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional card displayed in the HA frontend alongside the speech response.
|
||||
///
|
||||
/// Mirrors `homeassistant.helpers.intent.IntentResponseType.ACTION_DONE`
|
||||
/// card payload.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// The full response produced by an intent handler.
|
||||
///
|
||||
/// Mirrors `homeassistant.helpers.intent.IntentResponse`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct IntentResponse {
|
||||
/// Spoken text to synthesise (TTS) or display.
|
||||
pub speech: String,
|
||||
/// Optional rich card for dashboard display.
|
||||
pub card: Option<Card>,
|
||||
/// Optional structured data for programmatic callers.
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl IntentResponse {
|
||||
/// Quick constructor for a plain speech-only response.
|
||||
pub fn speech_only(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
speech: text.into(),
|
||||
card: None,
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default "not understood" response, mirroring HA's fallback text.
|
||||
pub fn not_understood() -> Self {
|
||||
Self::speech_only("I'm not sure how to help with that.")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intent_name_display() {
|
||||
let n = IntentName::new("HassTurnOn");
|
||||
assert_eq!(format!("{n}"), "HassTurnOn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intent_with_entity_sets_slot() {
|
||||
let intent = Intent::with_entity("HassTurnOn", "light.kitchen", "en");
|
||||
assert_eq!(intent.entity_id(), Some("light.kitchen"));
|
||||
assert_eq!(intent.name.as_str(), "HassTurnOn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_understood_response_text() {
|
||||
let r = IntentResponse::not_understood();
|
||||
assert!(r.speech.contains("not sure"));
|
||||
assert!(r.card.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
//! HOMECORE-ASSIST — Voice/intent pipeline + ruflo agent bridge.
|
||||
//!
|
||||
//! Implements [ADR-133](../../../docs/adr/ADR-133-homecore-assist-ruflo.md):
|
||||
//! the Assist pipeline that takes a voice utterance through intent
|
||||
//! recognition, intent handling, and response synthesis.
|
||||
//!
|
||||
//! ## Module layout (P1 scaffold)
|
||||
//!
|
||||
//! - [`intent`] — `IntentName`, `Intent`, `IntentResponse`, `Card`
|
||||
//! - [`recognizer`] — `IntentRecognizer` trait + `RegexIntentRecognizer` (P1)
|
||||
//! - [`handler`] — `IntentHandler` trait + 5 built-in HA-mirroring handlers
|
||||
//! - [`runner`] — `RufloRunner` trait + `NoopRunner` (P1 stub)
|
||||
//! - [`pipeline`] — `AssistPipeline`: wires recognizer → handler → response
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
//! - Regex-based intent recognition (HA classic intent matching).
|
||||
//! - Built-in handlers: `HassTurnOn`, `HassTurnOff`, `HassLightSet`,
|
||||
//! `HassNevermind`, `HassCancelAll`.
|
||||
//! - `RufloRunner` trait surface only; `NoopRunner` stub for P1.
|
||||
//!
|
||||
//! ## What's NOT here yet (deferred to P2+)
|
||||
//!
|
||||
//! - Real `tokio::process::Child` subprocess runner for `node ruflo-agent.js`
|
||||
//! (Windows-safe teardown per ADR-133 §Q3 lands in P2).
|
||||
//! - `SemanticIntentRecognizer` using ruvector HNSW embeddings (P2).
|
||||
//! - STT/TTS bridge and satellite protocol (P3).
|
||||
|
||||
pub mod intent;
|
||||
pub mod recognizer;
|
||||
pub mod handler;
|
||||
pub mod runner;
|
||||
pub mod pipeline;
|
||||
|
||||
pub use intent::{Card, Intent, IntentName, IntentResponse};
|
||||
pub use recognizer::{IntentRecognizer, RecognizerError, RegexIntentRecognizer};
|
||||
pub use handler::{
|
||||
HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
|
||||
IntentHandler,
|
||||
};
|
||||
pub use runner::{AssistError, NoopRunner, RufloResponse, RufloRunner, RufloRunnerOpts};
|
||||
pub use pipeline::AssistPipeline;
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
//! AssistPipeline — wires recognizer → handler → response.
|
||||
//!
|
||||
//! The pipeline is the public entry point for the HOMECORE-ASSIST subsystem.
|
||||
//! The HOMECORE-API WebSocket `assist` command will call
|
||||
//! `pipeline.process(utterance, language, &hc).await`.
|
||||
//!
|
||||
//! ## Processing flow
|
||||
//!
|
||||
//! 1. Call `recognizer.recognize(utterance, language)`.
|
||||
//! 2. If no intent matched → return `IntentResponse::not_understood()`.
|
||||
//! 3. Look up the handler by intent name.
|
||||
//! 4. Call `handler.handle(intent, hc)`.
|
||||
//! 5. Return the `IntentResponse`.
|
||||
//!
|
||||
//! The `RufloRunner` is reserved for a P2 LLM disambiguation pass that
|
||||
//! fires between steps 1 and 2 when the regex recognizer returns `None`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use homecore::HomeCore;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::handler::IntentHandler;
|
||||
use crate::intent::IntentResponse;
|
||||
use crate::recognizer::IntentRecognizer;
|
||||
use crate::runner::AssistError;
|
||||
|
||||
/// Boxed type alias so the pipeline can hold heterogeneous handlers.
|
||||
type BoxedHandler = Arc<dyn IntentHandler>;
|
||||
|
||||
/// The main Assist pipeline.
|
||||
///
|
||||
/// Construct with `AssistPipeline::new(recognizer)`, register handlers
|
||||
/// with `register_handler`, then call `process`.
|
||||
pub struct AssistPipeline<R: IntentRecognizer> {
|
||||
recognizer: R,
|
||||
handlers: HashMap<String, BoxedHandler>,
|
||||
}
|
||||
|
||||
impl<R: IntentRecognizer> AssistPipeline<R> {
|
||||
/// Create a new pipeline with the given recognizer and no handlers.
|
||||
pub fn new(recognizer: R) -> Self {
|
||||
Self {
|
||||
recognizer,
|
||||
handlers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register an intent handler. If a handler for the same intent name
|
||||
/// was already registered, it is replaced.
|
||||
pub fn register_handler<H: IntentHandler>(&mut self, handler: H) {
|
||||
self.handlers
|
||||
.insert(handler.intent_name().to_owned(), Arc::new(handler));
|
||||
}
|
||||
|
||||
/// Process an utterance through the full pipeline.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `AssistError` only for unexpected internal failures.
|
||||
/// Unknown intents and unrecognised utterances are returned as
|
||||
/// `IntentResponse::not_understood()` — not as errors — so the caller
|
||||
/// (WebSocket handler) can always synthesise a speech reply.
|
||||
pub async fn process(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
hc: &HomeCore,
|
||||
) -> Result<IntentResponse, AssistError> {
|
||||
debug!(%utterance, %language, "AssistPipeline: processing utterance");
|
||||
|
||||
let intent = match self.recognizer.recognize(utterance, language).await {
|
||||
Ok(Some(i)) => i,
|
||||
Ok(None) => {
|
||||
debug!("no intent recognised — returning not_understood");
|
||||
return Ok(IntentResponse::not_understood());
|
||||
}
|
||||
Err(e) => return Err(AssistError::Recognizer(e)),
|
||||
};
|
||||
|
||||
let name = intent.name.as_str().to_owned();
|
||||
let handler = self.handlers.get(&name).cloned();
|
||||
|
||||
match handler {
|
||||
Some(h) => h
|
||||
.handle(intent, hc)
|
||||
.await
|
||||
.map_err(AssistError::Handler),
|
||||
None => {
|
||||
debug!(%name, "no handler registered for intent");
|
||||
Ok(IntentResponse::not_understood())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: count of registered handlers.
|
||||
pub fn handler_count(&self) -> usize {
|
||||
self.handlers.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder that pre-wires the standard set of built-in HA intent handlers.
|
||||
///
|
||||
/// Use this when you want all 5 P1 built-ins registered without listing
|
||||
/// them individually.
|
||||
pub fn default_pipeline(
|
||||
recognizer: impl IntentRecognizer,
|
||||
) -> AssistPipeline<impl IntentRecognizer> {
|
||||
use crate::handler::{HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn};
|
||||
let mut pipeline = AssistPipeline::new(recognizer);
|
||||
pipeline.register_handler(HassTurnOn);
|
||||
pipeline.register_handler(HassTurnOff);
|
||||
pipeline.register_handler(HassLightSet);
|
||||
pipeline.register_handler(HassNevermind);
|
||||
pipeline.register_handler(HassCancelAll);
|
||||
pipeline
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use homecore::service::FnHandler;
|
||||
use homecore::{HomeCore, ServiceName};
|
||||
|
||||
use crate::handler::{HassTurnOff, HassTurnOn};
|
||||
use crate::recognizer::RegexIntentRecognizer;
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn build_test_pipeline() -> (AssistPipeline<RegexIntentRecognizer>, HomeCore) {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register(
|
||||
"HassTurnOn",
|
||||
r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r.register(
|
||||
"HassTurnOff",
|
||||
r"turn off (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r.register("HassNevermind", r"never ?mind|cancel that", "*")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut pipeline = AssistPipeline::new(r);
|
||||
pipeline.register_handler(HassTurnOn);
|
||||
pipeline.register_handler(HassTurnOff);
|
||||
pipeline.register_handler(crate::handler::HassNevermind);
|
||||
|
||||
let hc = HomeCore::new();
|
||||
// Register spy handlers so service calls don't return NotRegistered.
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_on"),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({})) }),
|
||||
)
|
||||
.await;
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_off"),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({})) }),
|
||||
)
|
||||
.await;
|
||||
(pipeline, hc)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_turn_on_end_to_end() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("turn on light.kitchen", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.contains("light.kitchen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_turn_off_end_to_end() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("turn off switch.fan", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.to_lowercase().contains("off") || resp.speech.contains("switch.fan"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_unknown_utterance_returns_not_understood() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("what is the weather like", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_recognized_but_no_handler_returns_not_understood() {
|
||||
// Register a pattern but NOT its handler.
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register("HassGetState", r"what is (?P<entity_id>\S+)", "*")
|
||||
.await
|
||||
.unwrap();
|
||||
let pipeline = AssistPipeline::new(r);
|
||||
let hc = HomeCore::new();
|
||||
let resp = pipeline
|
||||
.process("what is light.kitchen", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_pipeline_registers_five_handlers() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
let pipeline = default_pipeline(r);
|
||||
assert_eq!(pipeline.handler_count(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_nevermind_response() {
|
||||
let (pipeline, hc) = build_test_pipeline().await;
|
||||
let resp = pipeline
|
||||
.process("never mind", "en", &hc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.speech.to_lowercase().contains("okay")
|
||||
|| resp.speech.to_lowercase().contains("never")
|
||||
|| resp.speech.to_lowercase().contains("cancel")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_use_homecore_service_fn_handler() {
|
||||
use homecore::service::FnHandler;
|
||||
let hc = HomeCore::new();
|
||||
hc.services()
|
||||
.register(
|
||||
ServiceName::new("homeassistant", "turn_on"),
|
||||
FnHandler(|_| async { Ok(serde_json::json!({"ok": true})) }),
|
||||
)
|
||||
.await;
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register(
|
||||
"HassTurnOn",
|
||||
r"on (?P<entity_id>\S+)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut pipeline = AssistPipeline::new(r);
|
||||
pipeline.register_handler(HassTurnOn);
|
||||
let resp = pipeline.process("on light.bed", "en", &hc).await.unwrap();
|
||||
assert!(resp.speech.contains("light.bed"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
//! Intent recognizer trait + P1 regex-based implementation.
|
||||
//!
|
||||
//! Mirrors `homeassistant.helpers.intent.IntentRecognizer` and the
|
||||
//! `homeassistant/components/conversation/default_agent.py` regex pattern
|
||||
//! approach used in HA's classic intent matching.
|
||||
//!
|
||||
//! ## P1: `RegexIntentRecognizer`
|
||||
//!
|
||||
//! Tries each registered pattern in order; the first match wins.
|
||||
//! Slot values are extracted from named capture groups.
|
||||
//!
|
||||
//! ## P2 (stub only): `SemanticIntentRecognizer`
|
||||
//!
|
||||
//! Will embed the utterance with ruvector-core and compare it to a
|
||||
//! HNSW index of intent exemplars. Falls back to regex when similarity
|
||||
//! is below a configurable threshold (default 0.75).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
// serde imports used by SemanticIntentRecognizer and future P2 code
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::intent::{Intent, IntentName};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RecognizerError {
|
||||
#[error("regex compile error: {0}")]
|
||||
BadPattern(String),
|
||||
#[error("recognizer internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Core trait every recognizer must implement.
|
||||
///
|
||||
/// Returns `Ok(None)` when no intent matches (pipeline falls through to
|
||||
/// the "not understood" path).
|
||||
#[async_trait]
|
||||
pub trait IntentRecognizer: Send + Sync + 'static {
|
||||
async fn recognize(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError>;
|
||||
}
|
||||
|
||||
/// A single registered intent pattern.
|
||||
#[derive(Clone)]
|
||||
struct IntentPattern {
|
||||
name: IntentName,
|
||||
/// Pre-compiled regex. Named capture groups become slot keys.
|
||||
regex: Regex,
|
||||
/// Language tag this pattern applies to. `"*"` means any language.
|
||||
language: String,
|
||||
}
|
||||
|
||||
/// P1 recognizer that matches utterances against pre-registered regex patterns.
|
||||
///
|
||||
/// Thread-safe: patterns are stored in a `Vec` behind an `Arc<RwLock<_>>` so
|
||||
/// that `register` can be called from multiple tasks.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RegexIntentRecognizer {
|
||||
patterns: std::sync::Arc<tokio::sync::RwLock<Vec<IntentPattern>>>,
|
||||
}
|
||||
|
||||
impl RegexIntentRecognizer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a regex pattern for the given intent name and language.
|
||||
///
|
||||
/// Named capture groups (e.g. `(?P<entity_id>\w+\.\w+)`) become slot keys.
|
||||
/// `language` may be a BCP-47 tag (`"en"`) or `"*"` to match any language.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `RecognizerError::BadPattern` if the regex fails to compile.
|
||||
pub async fn register(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
pattern: &str,
|
||||
language: impl Into<String>,
|
||||
) -> Result<(), RecognizerError> {
|
||||
let regex = Regex::new(pattern).map_err(|e| RecognizerError::BadPattern(e.to_string()))?;
|
||||
self.patterns.write().await.push(IntentPattern {
|
||||
name: IntentName::new(name),
|
||||
regex,
|
||||
language: language.into(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IntentRecognizer for RegexIntentRecognizer {
|
||||
async fn recognize(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError> {
|
||||
let normalised = utterance.trim().to_lowercase();
|
||||
let patterns = self.patterns.read().await;
|
||||
for pattern in patterns.iter() {
|
||||
if pattern.language != "*" && pattern.language != language {
|
||||
continue;
|
||||
}
|
||||
if let Some(caps) = pattern.regex.captures(&normalised) {
|
||||
let mut slots: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
for name in pattern.regex.capture_names().flatten() {
|
||||
if let Some(m) = caps.name(name) {
|
||||
slots.insert(name.to_owned(), serde_json::Value::String(m.as_str().to_owned()));
|
||||
}
|
||||
}
|
||||
return Ok(Some(Intent {
|
||||
name: pattern.name.clone(),
|
||||
slots,
|
||||
language: language.to_owned(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// P2 stub: semantic recognizer backed by ruvector HNSW.
|
||||
///
|
||||
/// Currently always delegates to the inner `RegexIntentRecognizer`.
|
||||
/// P2 will populate a HNSW index at startup and compare embedded
|
||||
/// utterances before falling back to regex.
|
||||
pub struct SemanticIntentRecognizer {
|
||||
fallback: RegexIntentRecognizer,
|
||||
}
|
||||
|
||||
impl SemanticIntentRecognizer {
|
||||
pub fn new(fallback: RegexIntentRecognizer) -> Self {
|
||||
Self { fallback }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IntentRecognizer for SemanticIntentRecognizer {
|
||||
async fn recognize(
|
||||
&self,
|
||||
utterance: &str,
|
||||
language: &str,
|
||||
) -> Result<Option<Intent>, RecognizerError> {
|
||||
// TODO P2: embed utterance + HNSW search before falling through.
|
||||
self.fallback.recognize(utterance, language).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn turn_on_recognizer() -> RegexIntentRecognizer {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register(
|
||||
"HassTurnOn",
|
||||
r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r.register(
|
||||
"HassTurnOff",
|
||||
r"turn off (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)",
|
||||
"*",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recognizes_turn_on_entity() {
|
||||
let r = turn_on_recognizer().await;
|
||||
let intent = r
|
||||
.recognize("turn on the kitchen light", "en")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(intent.name.as_str(), "HassTurnOn");
|
||||
assert!(intent.slots.contains_key("entity_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recognizes_dotted_entity_id() {
|
||||
let r = turn_on_recognizer().await;
|
||||
let intent = r
|
||||
.recognize("turn on light.kitchen", "en")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(intent.name.as_str(), "HassTurnOn");
|
||||
assert_eq!(intent.entity_id(), Some("light.kitchen"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrecognized_utterance_returns_none() {
|
||||
let r = turn_on_recognizer().await;
|
||||
let result = r.recognize("play jazz music", "en").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn language_filter_skips_non_matching() {
|
||||
let r = RegexIntentRecognizer::new();
|
||||
r.register("HassTurnOn", r"turn on (?P<entity_id>\S+)", "de")
|
||||
.await
|
||||
.unwrap();
|
||||
// German-only pattern must not match an English utterance.
|
||||
let result = r.recognize("turn on light.kitchen", "en").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
// But it must match a German-tagged utterance.
|
||||
let result = r.recognize("turn on licht.kueche", "de").await.unwrap();
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_recognizer_delegates_to_fallback() {
|
||||
let regex = turn_on_recognizer().await;
|
||||
let semantic = SemanticIntentRecognizer::new(regex);
|
||||
let result = semantic
|
||||
.recognize("turn on light.kitchen", "en")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
//! RufloRunner trait + NoopRunner (P1 stub).
|
||||
//!
|
||||
//! The ruflo agent is a Node.js process that exposes an MCP-over-stdio
|
||||
//! interface for LLM-grade intent disambiguation. HOMECORE-ASSIST manages
|
||||
//! a long-lived subprocess via `tokio::process::Child`.
|
||||
//!
|
||||
//! ## P1 scope
|
||||
//!
|
||||
//! Only the trait + `NoopRunner` stub ship in P1. No subprocess is spawned.
|
||||
//!
|
||||
//! ## P2 scope
|
||||
//!
|
||||
//! Real subprocess management with Windows-safe teardown per ADR-133 §Q3:
|
||||
//! - `Child` wrapped in `Arc<Mutex<Option<Child>>>`.
|
||||
//! - Explicit `async shutdown()` calls `child.kill().await` before drop.
|
||||
//! - `tokio::signal` handler registered for `Ctrl+C`/`SIGINT` that calls
|
||||
//! `shutdown()` before exit.
|
||||
//! - Windows job object approach (option 3 per Q3) deferred to P3.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::intent::Intent;
|
||||
|
||||
/// Error type for the assist pipeline (runner + pipeline-level errors).
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AssistError {
|
||||
#[error("runner not started")]
|
||||
NotStarted,
|
||||
#[error("runner IO error: {0}")]
|
||||
Io(String),
|
||||
#[error("runner response parse error: {0}")]
|
||||
ParseError(String),
|
||||
#[error("recognizer error: {0}")]
|
||||
Recognizer(#[from] crate::recognizer::RecognizerError),
|
||||
#[error("handler error: {0}")]
|
||||
Handler(#[from] crate::handler::HandlerError),
|
||||
#[error("no handler registered for intent: {0}")]
|
||||
NoHandler(String),
|
||||
}
|
||||
|
||||
/// Configuration for launching the ruflo agent subprocess.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RufloRunnerOpts {
|
||||
/// Path to the `ruflo-agent.js` entry point.
|
||||
pub script_path: String,
|
||||
/// Additional environment variables to pass to the subprocess.
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// Request timeout in milliseconds (default 5000).
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for RufloRunnerOpts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
script_path: "ruflo-agent.js".into(),
|
||||
env: Default::default(),
|
||||
timeout_ms: 5000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON response from the ruflo agent subprocess.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RufloResponse {
|
||||
/// Recognised intent, if the LLM resolved one.
|
||||
pub intent: Option<Intent>,
|
||||
/// Spoken text from the LLM, if any.
|
||||
pub speech: Option<String>,
|
||||
}
|
||||
|
||||
/// Trait for the ruflo agent subprocess runner.
|
||||
///
|
||||
/// P1 ships only this trait + `NoopRunner`. The real subprocess runner
|
||||
/// lands in P2 with Windows-safe teardown (ADR-133 §Q3).
|
||||
#[async_trait]
|
||||
pub trait RufloRunner: Send + Sync + 'static {
|
||||
/// Spawn (or reconnect to) the ruflo agent subprocess.
|
||||
async fn spawn(&mut self, opts: RufloRunnerOpts) -> Result<(), AssistError>;
|
||||
|
||||
/// Send an utterance payload to the agent and await a response.
|
||||
///
|
||||
/// `payload` is an arbitrary JSON object; at minimum it should include
|
||||
/// `{ "utterance": "...", "language": "..." }`.
|
||||
async fn send_request(
|
||||
&self,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<RufloResponse, AssistError>;
|
||||
|
||||
/// Gracefully shut down the subprocess.
|
||||
///
|
||||
/// Must be idempotent — calling `shutdown` on an already-stopped runner
|
||||
/// must return `Ok(())` rather than an error.
|
||||
async fn shutdown(&mut self) -> Result<(), AssistError>;
|
||||
}
|
||||
|
||||
/// P1 no-op implementation. Spawn/send/shutdown are all immediate Ok.
|
||||
///
|
||||
/// `send_request` returns an empty `RufloResponse` (no intent, no speech),
|
||||
/// which causes the pipeline to fall through to the regex recognizer path.
|
||||
#[derive(Default)]
|
||||
pub struct NoopRunner {
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl NoopRunner {
|
||||
pub fn new() -> Self {
|
||||
Self { started: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RufloRunner for NoopRunner {
|
||||
async fn spawn(&mut self, _opts: RufloRunnerOpts) -> Result<(), AssistError> {
|
||||
self.started = true;
|
||||
tracing::debug!("NoopRunner: spawn called (P1 stub — no subprocess started)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
&self,
|
||||
_payload: serde_json::Value,
|
||||
) -> Result<RufloResponse, AssistError> {
|
||||
// P1 stub: always returns empty response so the pipeline falls through
|
||||
// to the regex recognizer.
|
||||
Ok(RufloResponse {
|
||||
intent: None,
|
||||
speech: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) -> Result<(), AssistError> {
|
||||
// Idempotent: Ok whether or not spawn was called.
|
||||
self.started = false;
|
||||
tracing::debug!("NoopRunner: shutdown called (idempotent no-op in P1)");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_runner_spawn_returns_ok() {
|
||||
let mut runner = NoopRunner::new();
|
||||
let result = runner.spawn(RufloRunnerOpts::default()).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_runner_send_request_returns_empty_response() {
|
||||
let runner = NoopRunner::new();
|
||||
let resp = runner
|
||||
.send_request(serde_json::json!({"utterance": "turn on the light", "language": "en"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.intent.is_none());
|
||||
assert!(resp.speech.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_runner_shutdown_is_idempotent() {
|
||||
let mut runner = NoopRunner::new();
|
||||
// First shutdown without spawn — must not error.
|
||||
assert!(runner.shutdown().await.is_ok());
|
||||
// Spawn then shutdown — must not error.
|
||||
runner.spawn(RufloRunnerOpts::default()).await.unwrap();
|
||||
assert!(runner.shutdown().await.is_ok());
|
||||
// Second shutdown — must still not error.
|
||||
assert!(runner.shutdown().await.is_ok());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue