From 26ad45fbdbfc9a883de5facf9da5d3fdab94c04f Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 25 May 2026 18:31:54 -0400 Subject: [PATCH] feat(homecore-assist/p1): ADR-133 intent pipeline + ruflo runner stub (23 tests pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/adr/ADR-133-homecore-assist-ruflo.md | 176 ++++++++++++ v2/crates/homecore-assist/Cargo.toml | 47 ++++ v2/crates/homecore-assist/src/handler.rs | 288 ++++++++++++++++++++ v2/crates/homecore-assist/src/intent.rs | 131 +++++++++ v2/crates/homecore-assist/src/lib.rs | 42 +++ v2/crates/homecore-assist/src/pipeline.rs | 262 ++++++++++++++++++ v2/crates/homecore-assist/src/recognizer.rs | 232 ++++++++++++++++ v2/crates/homecore-assist/src/runner.rs | 174 ++++++++++++ 8 files changed, 1352 insertions(+) create mode 100644 docs/adr/ADR-133-homecore-assist-ruflo.md create mode 100644 v2/crates/homecore-assist/Cargo.toml create mode 100644 v2/crates/homecore-assist/src/handler.rs create mode 100644 v2/crates/homecore-assist/src/intent.rs create mode 100644 v2/crates/homecore-assist/src/lib.rs create mode 100644 v2/crates/homecore-assist/src/pipeline.rs create mode 100644 v2/crates/homecore-assist/src/recognizer.rs create mode 100644 v2/crates/homecore-assist/src/runner.rs diff --git a/docs/adr/ADR-133-homecore-assist-ruflo.md b/docs/adr/ADR-133-homecore-assist-ruflo.md new file mode 100644 index 00000000..6a712e89 --- /dev/null +++ b/docs/adr/ADR-133-homecore-assist-ruflo.md @@ -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, + pub data: Option, +} + +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; + async fn shutdown(&mut self) -> Result<(), AssistError>; +} +``` + +`RufloResponse` is `{ intent: Option, speech: Option }`. + +### 2.5 Pipeline + +```rust +pub struct AssistPipeline { + recognizer: R, + handler: H, + runner: Option>, +} + +impl AssistPipeline { + pub async fn process(&self, utterance: &str, language: &str, hc: &HomeCore) + -> Result; +} +``` + +--- + +## 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>>` 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 | diff --git a/v2/crates/homecore-assist/Cargo.toml b/v2/crates/homecore-assist/Cargo.toml new file mode 100644 index 00000000..8233b39d --- /dev/null +++ b/v2/crates/homecore-assist/Cargo.toml @@ -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 ", "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"] } diff --git a/v2/crates/homecore-assist/src/handler.rs b/v2/crates/homecore-assist/src/handler.rs new file mode 100644 index 00000000..f83852be --- /dev/null +++ b/v2/crates/homecore-assist/src/handler.rs @@ -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; +} + +// ---- 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 { + 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 { + 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 { + 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 { + 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 { + 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)` so tests can assert + /// the handler was called. + async fn hc_with_spy(domain: &str, service: &str) -> (HomeCore, std::sync::Arc) { + 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"); + } +} diff --git a/v2/crates/homecore-assist/src/intent.rs b/v2/crates/homecore-assist/src/intent.rs new file mode 100644 index 00000000..6afc13bd --- /dev/null +++ b/v2/crates/homecore-assist/src/intent.rs @@ -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) -> 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, + /// 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, entity_id: impl Into, 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, + /// Optional structured data for programmatic callers. + pub data: Option, +} + +impl IntentResponse { + /// Quick constructor for a plain speech-only response. + pub fn speech_only(text: impl Into) -> 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()); + } +} diff --git a/v2/crates/homecore-assist/src/lib.rs b/v2/crates/homecore-assist/src/lib.rs new file mode 100644 index 00000000..9bd72262 --- /dev/null +++ b/v2/crates/homecore-assist/src/lib.rs @@ -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; diff --git a/v2/crates/homecore-assist/src/pipeline.rs b/v2/crates/homecore-assist/src/pipeline.rs new file mode 100644 index 00000000..fa230dcc --- /dev/null +++ b/v2/crates/homecore-assist/src/pipeline.rs @@ -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; + +/// The main Assist pipeline. +/// +/// Construct with `AssistPipeline::new(recognizer)`, register handlers +/// with `register_handler`, then call `process`. +pub struct AssistPipeline { + recognizer: R, + handlers: HashMap, +} + +impl AssistPipeline { + /// 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(&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 { + 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 { + 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, HomeCore) { + let r = RegexIntentRecognizer::new(); + r.register( + "HassTurnOn", + r"turn on (?:the )?(?P[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)", + "*", + ) + .await + .unwrap(); + r.register( + "HassTurnOff", + r"turn off (?:the )?(?P[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\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\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")); + } +} diff --git a/v2/crates/homecore-assist/src/recognizer.rs b/v2/crates/homecore-assist/src/recognizer.rs new file mode 100644 index 00000000..c47655eb --- /dev/null +++ b/v2/crates/homecore-assist/src/recognizer.rs @@ -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, 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>` so +/// that `register` can be called from multiple tasks. +#[derive(Clone, Default)] +pub struct RegexIntentRecognizer { + patterns: std::sync::Arc>>, +} + +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\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, + pattern: &str, + language: impl Into, + ) -> 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, 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 = 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, 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[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)", + "*", + ) + .await + .unwrap(); + r.register( + "HassTurnOff", + r"turn off (?:the )?(?P[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\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()); + } +} diff --git a/v2/crates/homecore-assist/src/runner.rs b/v2/crates/homecore-assist/src/runner.rs new file mode 100644 index 00000000..54ee9957 --- /dev/null +++ b/v2/crates/homecore-assist/src/runner.rs @@ -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>>`. +//! - 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, + /// 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, + /// Spoken text from the LLM, if any. + pub speech: Option, +} + +/// 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; + + /// 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 { + // 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()); + } +}