mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user