improve interaction model

This commit is contained in:
Emil Lerch 2026-02-04 11:21:22 -08:00
parent d77e593607
commit d1e93d8529
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 240 additions and 148 deletions

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ node_modules/
# ASK CLI state (account-specific) # ASK CLI state (account-specific)
.ask/ .ask/
# Generated skill manifest (contains account-specific Lambda ARN)
skill-package/skill.json

View file

@ -11,6 +11,9 @@ pub fn build(b: *std.Build) !void {
}); });
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
// Native target for build tools
const native_target = b.resolveTargetQuery(.{});
// Get lambda-zig dependency // Get lambda-zig dependency
const lambda_zig_dep = b.dependency("lambda_zig", .{ const lambda_zig_dep = b.dependency("lambda_zig", .{
.target = target, .target = target,
@ -43,30 +46,53 @@ pub fn build(b: *std.Build) !void {
b.installArtifact(exe); b.installArtifact(exe);
// Configure Lambda build steps (awslambda_package, awslambda_deploy, etc.) // Configure Lambda build steps and get deployment info
try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{ const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{
.default_function_name = "house-control", .default_function_name = "house-control",
}); });
// Build the gen-skill-json tool (runs on host)
const gen_skill_json_module = b.createModule(.{
.root_source_file = b.path("tools/gen-skill-json.zig"),
.target = native_target,
.optimize = .ReleaseFast,
});
const gen_skill_json_exe = b.addExecutable(.{
.name = "gen-skill-json",
.root_module = gen_skill_json_module,
});
// Generate skill.json from template using Lambda ARN
const gen_skill_cmd = b.addRunArtifact(gen_skill_json_exe);
gen_skill_cmd.addFileArg(lambda.deploy_output);
gen_skill_cmd.addFileArg(b.path("skill-package/skill.template.json"));
gen_skill_cmd.step.dependOn(lambda.deploy_step);
// Capture generated skill.json
const skill_json = gen_skill_cmd.captureStdOut();
// Write skill.json to skill-package directory (updates source files, necessary for the ask deploy)
const write_skill_json = b.addUpdateSourceFiles();
write_skill_json.addCopyFileToSource(skill_json, "skill-package/skill.json");
const gen_skill_step = b.step("gen_skill_json", "Generate skill.json from template (will deploy function)");
gen_skill_step.dependOn(&write_skill_json.step);
// ASK CLI deploy step for Alexa skill metadata // ASK CLI deploy step for Alexa skill metadata
const ask_deploy_cmd = b.addSystemCommand(&.{ const ask_deploy_cmd = b.addSystemCommand(&.{
"bun", "x", "ask", "deploy", "--target", "skill-metadata", "bun", "x", "ask", "deploy", "--target", "skill-metadata",
}); });
// ASK deploy depends on skill.json being generated
ask_deploy_cmd.step.dependOn(&write_skill_json.step);
const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI"); const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI");
ask_deploy_step.dependOn(&ask_deploy_cmd.step); ask_deploy_step.dependOn(&ask_deploy_cmd.step);
// Full deploy step - deploys both Lambda function and Alexa skill // Full deploy step - deploys Lambda, generates skill.json, deploys Alexa skill
const full_deploy_step = b.step("deploy", "Deploy Lambda function and Alexa skill"); const full_deploy_step = b.step("deploy", "Deploy Lambda function and Alexa skill");
// Lambda deploy (awslambda_deploy) is added by lambda_zig.configureBuild
// We need to get a reference to it - it's registered as "awslambda_deploy"
if (b.top_level_steps.get("awslambda_deploy")) |lambda_deploy| {
full_deploy_step.dependOn(&lambda_deploy.step);
}
full_deploy_step.dependOn(&ask_deploy_cmd.step); full_deploy_step.dependOn(&ask_deploy_cmd.step);
// Test step - use native target for tests (not cross-compiled Lambda target) // Test step - use native target for tests (not cross-compiled Lambda target)
const native_target = b.resolveTargetQuery(.{});
const lambda_zig_dep_native = b.dependency("lambda_zig", .{ const lambda_zig_dep_native = b.dependency("lambda_zig", .{
.target = native_target, .target = native_target,
.optimize = optimize, .optimize = optimize,

View file

@ -9,8 +9,8 @@
.hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk", .hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk",
}, },
.lambda_zig = .{ .lambda_zig = .{
.url = "git+https://git.lerch.org/lobo/lambda-zig#ed9c7ced6c23426c062a46a77f9dead9eb708550", .url = "git+https://git.lerch.org/lobo/lambda-zig#56ac230e5e6c849376a72e12f9e65ea3800fe18e",
.hash = "lambda_zig-0.1.0-_G43_2hEAQC6_qXjFpEt2kAjCK4cBYIqkF3E-yl9S_dA", .hash = "lambda_zig-0.1.0-_G43_2ZbAQAirikqqFgrxNwwluSxOBzN4PnrRMr4IGNx",
}, },
}, },
.paths = .{ .paths = .{

View file

@ -32,20 +32,16 @@
"slots": [ "slots": [
{ {
"name": "device", "name": "device",
"type": "AMAZON.SearchQuery" "type": "DEVICE_NAME"
},
{
"name": "action",
"type": "DEVICE_ACTION"
} }
], ],
"samples": [ "samples": [
"turn on {device}", "{action} {device}",
"turn on the {device}", "{action} the {device}",
"turn {device} on",
"switch on {device}",
"switch on the {device}",
"turn off {device}",
"turn off the {device}",
"turn {device} off",
"switch off {device}",
"switch off the {device}",
"is {device} on", "is {device} on",
"is the {device} on", "is the {device} on",
"is {device} off", "is {device} off",
@ -59,9 +55,7 @@
"check {device}", "check {device}",
"check the {device}", "check the {device}",
"check on {device}", "check on {device}",
"check on the {device}", "check on the {device}"
"toggle {device}",
"toggle the {device}"
] ]
}, },
{ {
@ -150,26 +144,30 @@
{ {
"name": "DEVICE_NAME", "name": "DEVICE_NAME",
"values": [ "values": [
{"name": {"value": "bedroom light"}}, {"name": {"value": "bar"}},
{"name": {"value": "bar light"}},
{"name": {"value": "basement"}},
{"name": {"value": "basement light"}},
{"name": {"value": "basement fireplace"}},
{"name": {"value": "fireplace"}},
{"name": {"value": "deck"}},
{"name": {"value": "deck light"}},
{"name": {"value": "family room"}},
{"name": {"value": "family room light"}},
{"name": {"value": "jack bedroom light"}},
{"name": {"value": "jack bedroom"}},
{"name": {"value": "jack's bedroom"}},
{"name": {"value": "kitchen"}},
{"name": {"value": "kitchen light"}}, {"name": {"value": "kitchen light"}},
{"name": {"value": "living room light"}}, {"name": {"value": "kris bedroom"}},
{"name": {"value": "bathroom light"}}, {"name": {"value": "kris bedroom light"}},
{"name": {"value": "hallway light"}}, {"name": {"value": "kris's bedroom"}},
{"name": {"value": "office light"}}, {"name": {"value": "bedroom"}},
{"name": {"value": "garage light"}}, {"name": {"value": "bedroom light"}},
{"name": {"value": "porch light"}}, {"name": {"value": "master bedroom"}},
{"name": {"value": "front porch"}}, {"name": {"value": "master bedroom light"}},
{"name": {"value": "back porch"}}, {"name": {"value": "emil light"}},
{"name": {"value": "bedroom lamp"}}, {"name": {"value": "emil's light"}}
{"name": {"value": "desk lamp"}},
{"name": {"value": "floor lamp"}},
{"name": {"value": "thermostat"}},
{"name": {"value": "downstairs thermostat"}},
{"name": {"value": "upstairs thermostat"}},
{"name": {"value": "bedroom fan"}},
{"name": {"value": "ceiling fan"}},
{"name": {"value": "living room fan"}},
{"name": {"value": "kitchen fan"}}
] ]
} }
] ]

View file

@ -3,7 +3,7 @@
"apis": { "apis": {
"custom": { "custom": {
"endpoint": { "endpoint": {
"uri": "arn:aws:lambda:us-west-2:932028523435:function:water-recirculation" "uri": "{{LAMBDA_ARN}}"
}, },
"interfaces": [] "interfaces": []
} }
@ -50,4 +50,4 @@
} }
} }
} }
} }

View file

@ -510,8 +510,22 @@ fn searchWordsInName(friendly_name: []const u8, search: []const u8) usize {
return important_matches; return important_matches;
} }
/// Count important (non-noise) words in a friendly name
fn countImportantWords(friendly_name: []const u8) usize {
var name_buf: [256]u8 = undefined;
const name_len = @min(friendly_name.len, name_buf.len);
const name_lower = std.ascii.lowerString(name_buf[0..name_len], friendly_name[0..name_len]);
var count: usize = 0;
var words = std.mem.tokenizeAny(u8, name_lower, " _-");
while (words.next()) |word| {
if (!isNoiseWord(word)) count += 1;
}
return count;
}
/// Find entities matching the given name /// Find entities matching the given name
/// Priority: 1) Exact match, 2) All search words in friendly name (prefer more matches, then shorter name), 3) Fuzzy match /// Priority: 1) Exact match, 2) All search words in friendly name (prefer higher match ratio), 3) Fuzzy match
pub fn findEntitiesByName( pub fn findEntitiesByName(
allocator: Allocator, allocator: Allocator,
entities: []const Entity, entities: []const Entity,
@ -551,27 +565,26 @@ pub fn findEntitiesByName(
} }
} }
// If we have ties, prefer shorter friendly names (more specific/direct match) // If we have ties, prefer entities with fewer important words (higher match ratio)
// e.g., "bedroom" (1 word) over "Jack Bedroom Light" (2 important words) when searching "bedroom"
if (matches.items.len > 1) { if (matches.items.len > 1) {
var shortest_idx: usize = matches.items[0]; var best_idx: usize = matches.items[0];
var shortest_len: usize = entities[shortest_idx].friendly_name.len; var fewest_words: usize = countImportantWords(entities[best_idx].friendly_name);
var unique_best = true;
for (matches.items[1..]) |idx| { for (matches.items[1..]) |idx| {
if (entities[idx].friendly_name.len < shortest_len) { const word_count = countImportantWords(entities[idx].friendly_name);
shortest_idx = idx; if (word_count < fewest_words) {
shortest_len = entities[idx].friendly_name.len; best_idx = idx;
fewest_words = word_count;
unique_best = true;
} else if (word_count == fewest_words) {
unique_best = false;
} }
} }
// Check if there's a unique shortest if (unique_best)
var count_at_shortest: usize = 0; return .{ .single = best_idx };
for (matches.items) |idx| {
if (entities[idx].friendly_name.len == shortest_len)
count_at_shortest += 1;
}
if (count_at_shortest == 1)
return .{ .single = shortest_idx };
} }
if (matches.items.len == 1) if (matches.items.len == 1)
@ -871,104 +884,93 @@ test "fuzzyMatchWord handles underscores and hyphens" {
try std.testing.expect(fuzzyMatchWord("living-room-lamp", "room")); try std.testing.expect(fuzzyMatchWord("living-room-lamp", "room"));
} }
test "findEntitiesByName single match" { test "findEntitiesByName - spoken phrase resolution" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
// Test entities representing a typical Home Assistant setup
const entities = [_]Entity{ const entities = [_]Entity{
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, .{ .entity_id = "light.bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "light.jack_bedroom", .state = "off", .friendly_name = "Jack Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, .{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "light.living_room", .state = "off", .friendly_name = "Living Room Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "climate.thermostat", .state = "heat", .friendly_name = "Thermostat", .domain = "climate", .last_changed = null, .brightness = null, .temperature = 72, .current_temperature = 68 },
.{ .entity_id = "climate.upstairs", .state = "cool", .friendly_name = "Upstairs Thermostat", .domain = "climate", .last_changed = null, .brightness = null, .temperature = 70, .current_temperature = 72 },
}; };
const result = try findEntitiesByName(allocator, &entities, "bedroom"); // Expected result type for test cases
try std.testing.expect(result == .single); const Expected = union(enum) {
try std.testing.expectEqual(@as(usize, 0), result.single); single: []const u8, // friendly_name of expected match
} multiple: usize, // number of matches expected
none,
test "findEntitiesByName multiple matches" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
}; };
const result = try findEntitiesByName(allocator, &entities, "light"); const TestCase = struct {
try std.testing.expect(result == .multiple); spoken: []const u8,
defer allocator.free(result.multiple); expected: Expected,
try std.testing.expectEqual(@as(usize, 2), result.multiple.len);
}
test "findEntitiesByName no match" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
}; };
const result = try findEntitiesByName(allocator, &entities, "garage"); const test_cases = [_]TestCase{
try std.testing.expect(result == .none); // Exact matches
} .{ .spoken = "bedroom", .expected = .{ .single = "Bedroom" } },
.{ .spoken = "Bedroom", .expected = .{ .single = "Bedroom" } },
.{ .spoken = "BEDROOM", .expected = .{ .single = "Bedroom" } },
.{ .spoken = "thermostat", .expected = .{ .single = "Thermostat" } },
.{ .spoken = "kitchen light", .expected = .{ .single = "Kitchen Light" } },
test "findEntitiesByName exact match takes priority" { // Specific name with multiple words matches over generic
const allocator = std.testing.allocator; .{ .spoken = "jack bedroom", .expected = .{ .single = "Jack Bedroom Light" } },
const entities = [_]Entity{ .{ .spoken = "jack bedroom light", .expected = .{ .single = "Jack Bedroom Light" } },
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, .{ .spoken = "jack's bedroom", .expected = .{ .single = "Jack Bedroom Light" } },
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, .{ .spoken = "jack's bedroom light", .expected = .{ .single = "Jack Bedroom Light" } },
.{ .spoken = "upstairs thermostat", .expected = .{ .single = "Upstairs Thermostat" } },
.{ .spoken = "living room", .expected = .{ .single = "Living Room Light" } },
.{ .spoken = "living room light", .expected = .{ .single = "Living Room Light" } },
// Ambiguous spoken phrase resolves to simpler entity name
.{ .spoken = "bedroom light", .expected = .{ .single = "Bedroom" } }, // "Bedroom" has 1 important word, "Jack Bedroom Light" has 2
.{ .spoken = "light", .expected = .{ .multiple = 3 } }, // fuzzy matches entities with "Light" in name (not "Bedroom" which has no "Light")
// No match
.{ .spoken = "garage", .expected = .none },
.{ .spoken = "garage door", .expected = .none },
.{ .spoken = "front porch", .expected = .none },
}; };
// "bedroom" exactly matches "Bedroom" for (test_cases) |tc| {
const result = try findEntitiesByName(allocator, &entities, "bedroom"); const result = try findEntitiesByName(allocator, &entities, tc.spoken);
try std.testing.expect(result == .single);
try std.testing.expectEqual(@as(usize, 0), result.single);
}
test "findEntitiesByName noise words dont override important matches" { switch (tc.expected) {
const allocator = std.testing.allocator; .single => |expected_name| {
const entities = [_]Entity{ if (result != .single) {
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, std.debug.print("FAIL: '{s}' expected single match '{s}', got {}\n", .{ tc.spoken, expected_name, result });
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, return error.TestUnexpectedResult;
}; }
const actual_name = entities[result.single].friendly_name;
// "bedroom light" should still match "Bedroom" - "light" is a noise word if (!std.mem.eql(u8, actual_name, expected_name)) {
const result = try findEntitiesByName(allocator, &entities, "bedroom light"); std.debug.print("FAIL: '{s}' expected '{s}', got '{s}'\n", .{ tc.spoken, expected_name, actual_name });
try std.testing.expect(result == .single); return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(usize, 0), result.single); }
} },
.multiple => |expected_count| {
test "findEntitiesByName specific name matches over generic" { if (result != .multiple) {
const allocator = std.testing.allocator; std.debug.print("FAIL: '{s}' expected {d} matches, got {}\n", .{ tc.spoken, expected_count, result });
const entities = [_]Entity{ return error.TestUnexpectedResult;
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, }
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, defer allocator.free(result.multiple);
}; if (result.multiple.len != expected_count) {
std.debug.print("FAIL: '{s}' expected {d} matches, got {d}\n", .{ tc.spoken, expected_count, result.multiple.len });
// "jack bedroom" should match "Jack Bedroom Light" - has both important words return error.TestUnexpectedResult;
const result = try findEntitiesByName(allocator, &entities, "jack bedroom"); }
try std.testing.expect(result == .single); },
try std.testing.expectEqual(@as(usize, 1), result.single); .none => {
} if (result != .none) {
std.debug.print("FAIL: '{s}' expected no match, got {}\n", .{ tc.spoken, result });
test "findEntitiesByName handles possessives" { if (result == .multiple) allocator.free(result.multiple);
const allocator = std.testing.allocator; return error.TestUnexpectedResult;
const entities = [_]Entity{ }
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, },
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null }, }
}; }
// "jack's bedroom" should match "Jack Bedroom Light"
const result = try findEntitiesByName(allocator, &entities, "jack's bedroom");
try std.testing.expect(result == .single);
try std.testing.expectEqual(@as(usize, 1), result.single);
}
test "findEntitiesByName full specific match" {
const allocator = std.testing.allocator;
const entities = [_]Entity{
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
};
// "jack bedroom light" should match "Jack Bedroom Light"
const result = try findEntitiesByName(allocator, &entities, "jack bedroom light");
try std.testing.expect(result == .single);
try std.testing.expectEqual(@as(usize, 1), result.single);
} }
test "formatStateResponse light on with brightness" { test "formatStateResponse light on with brightness" {

63
tools/gen-skill-json.zig Normal file
View file

@ -0,0 +1,63 @@
//! Generate skill.json from template by substituting Lambda ARN
//!
//! Usage: gen-skill-json <deploy-output.json> <skill.template.json>
//! Outputs the generated skill.json to stdout.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len != 3) {
std.debug.print("Usage: {s} <deploy-output.json> <skill.template.json>\n", .{args[0]});
std.process.exit(1);
}
const deploy_output_path = args[1];
const template_path = args[2];
// Read deploy output JSON
const deploy_output = try readFile(allocator, deploy_output_path);
defer allocator.free(deploy_output);
// Parse to extract ARN
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, deploy_output, .{});
defer parsed.deinit();
const arn = parsed.value.object.get("arn") orelse {
std.debug.print("Error: deploy output missing 'arn' field\n", .{});
std.process.exit(1);
};
const arn_str = switch (arn) {
.string => |s| s,
else => {
std.debug.print("Error: 'arn' field is not a string\n", .{});
std.process.exit(1);
},
};
// Read template
const template = try readFile(allocator, template_path);
defer allocator.free(template);
// Replace placeholder with ARN
const result = try std.mem.replaceOwned(u8, allocator, template, "{{LAMBDA_ARN}}", arn_str);
defer allocator.free(result);
// Write to stdout
const stdout = std.fs.File.stdout();
try stdout.writeAll(result);
}
fn readFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
return try file.readToEndAlloc(allocator, 1024 * 1024);
}