From d1e93d852936a96dd2be325b184373076d16e69f Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 4 Feb 2026 11:21:22 -0800 Subject: [PATCH] improve interaction model --- .gitignore | 3 + build.zig | 46 +++- build.zig.zon | 4 +- .../interactionModels/custom/en-US.json | 64 +++--- .../skill.json => skill.template.json | 4 +- src/homeassistant.zig | 204 +++++++++--------- tools/gen-skill-json.zig | 63 ++++++ 7 files changed, 240 insertions(+), 148 deletions(-) rename skill-package/skill.json => skill.template.json (94%) create mode 100644 tools/gen-skill-json.zig diff --git a/.gitignore b/.gitignore index 0878995..4bba4ef 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ node_modules/ # ASK CLI state (account-specific) .ask/ + +# Generated skill manifest (contains account-specific Lambda ARN) +skill-package/skill.json diff --git a/build.zig b/build.zig index 7ddaaf9..f60fd97 100644 --- a/build.zig +++ b/build.zig @@ -11,6 +11,9 @@ pub fn build(b: *std.Build) !void { }); const optimize = b.standardOptimizeOption(.{}); + // Native target for build tools + const native_target = b.resolveTargetQuery(.{}); + // Get lambda-zig dependency const lambda_zig_dep = b.dependency("lambda_zig", .{ .target = target, @@ -43,30 +46,53 @@ pub fn build(b: *std.Build) !void { b.installArtifact(exe); - // Configure Lambda build steps (awslambda_package, awslambda_deploy, etc.) - try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{ + // Configure Lambda build steps and get deployment info + const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{ .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 const ask_deploy_cmd = b.addSystemCommand(&.{ "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"); 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"); - // 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); // 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", .{ .target = native_target, .optimize = optimize, diff --git a/build.zig.zon b/build.zig.zon index 17b1618..e36c478 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,8 +9,8 @@ .hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk", }, .lambda_zig = .{ - .url = "git+https://git.lerch.org/lobo/lambda-zig#ed9c7ced6c23426c062a46a77f9dead9eb708550", - .hash = "lambda_zig-0.1.0-_G43_2hEAQC6_qXjFpEt2kAjCK4cBYIqkF3E-yl9S_dA", + .url = "git+https://git.lerch.org/lobo/lambda-zig#56ac230e5e6c849376a72e12f9e65ea3800fe18e", + .hash = "lambda_zig-0.1.0-_G43_2ZbAQAirikqqFgrxNwwluSxOBzN4PnrRMr4IGNx", }, }, .paths = .{ diff --git a/skill-package/interactionModels/custom/en-US.json b/skill-package/interactionModels/custom/en-US.json index 9277370..28834cb 100644 --- a/skill-package/interactionModels/custom/en-US.json +++ b/skill-package/interactionModels/custom/en-US.json @@ -32,20 +32,16 @@ "slots": [ { "name": "device", - "type": "AMAZON.SearchQuery" + "type": "DEVICE_NAME" + }, + { + "name": "action", + "type": "DEVICE_ACTION" } ], "samples": [ - "turn on {device}", - "turn on 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}", + "{action} {device}", + "{action} the {device}", "is {device} on", "is the {device} on", "is {device} off", @@ -59,9 +55,7 @@ "check {device}", "check the {device}", "check on {device}", - "check on the {device}", - "toggle {device}", - "toggle the {device}" + "check on the {device}" ] }, { @@ -150,26 +144,30 @@ { "name": "DEVICE_NAME", "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": "living room light"}}, - {"name": {"value": "bathroom light"}}, - {"name": {"value": "hallway light"}}, - {"name": {"value": "office light"}}, - {"name": {"value": "garage light"}}, - {"name": {"value": "porch light"}}, - {"name": {"value": "front porch"}}, - {"name": {"value": "back porch"}}, - {"name": {"value": "bedroom lamp"}}, - {"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"}} + {"name": {"value": "kris bedroom"}}, + {"name": {"value": "kris bedroom light"}}, + {"name": {"value": "kris's bedroom"}}, + {"name": {"value": "bedroom"}}, + {"name": {"value": "bedroom light"}}, + {"name": {"value": "master bedroom"}}, + {"name": {"value": "master bedroom light"}}, + {"name": {"value": "emil light"}}, + {"name": {"value": "emil's light"}} ] } ] diff --git a/skill-package/skill.json b/skill.template.json similarity index 94% rename from skill-package/skill.json rename to skill.template.json index d233076..6c2f0c7 100644 --- a/skill-package/skill.json +++ b/skill.template.json @@ -3,7 +3,7 @@ "apis": { "custom": { "endpoint": { - "uri": "arn:aws:lambda:us-west-2:932028523435:function:water-recirculation" + "uri": "{{LAMBDA_ARN}}" }, "interfaces": [] } @@ -50,4 +50,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/homeassistant.zig b/src/homeassistant.zig index ba316b6..8f47a72 100644 --- a/src/homeassistant.zig +++ b/src/homeassistant.zig @@ -510,8 +510,22 @@ fn searchWordsInName(friendly_name: []const u8, search: []const u8) usize { 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 -/// 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( allocator: Allocator, 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) { - var shortest_idx: usize = matches.items[0]; - var shortest_len: usize = entities[shortest_idx].friendly_name.len; + var best_idx: usize = matches.items[0]; + var fewest_words: usize = countImportantWords(entities[best_idx].friendly_name); + var unique_best = true; for (matches.items[1..]) |idx| { - if (entities[idx].friendly_name.len < shortest_len) { - shortest_idx = idx; - shortest_len = entities[idx].friendly_name.len; + const word_count = countImportantWords(entities[idx].friendly_name); + if (word_count < fewest_words) { + 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 - var count_at_shortest: usize = 0; - 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 (unique_best) + return .{ .single = best_idx }; } if (matches.items.len == 1) @@ -871,104 +884,93 @@ test "fuzzyMatchWord handles underscores and hyphens" { try std.testing.expect(fuzzyMatchWord("living-room-lamp", "room")); } -test "findEntitiesByName single match" { +test "findEntitiesByName - spoken phrase resolution" { const allocator = std.testing.allocator; + + // Test entities representing a typical Home Assistant setup 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.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"); - try std.testing.expect(result == .single); - try std.testing.expectEqual(@as(usize, 0), result.single); -} - -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 }, + // Expected result type for test cases + const Expected = union(enum) { + single: []const u8, // friendly_name of expected match + multiple: usize, // number of matches expected + none, }; - const result = try findEntitiesByName(allocator, &entities, "light"); - try std.testing.expect(result == .multiple); - defer allocator.free(result.multiple); - 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 TestCase = struct { + spoken: []const u8, + expected: Expected, }; - const result = try findEntitiesByName(allocator, &entities, "garage"); - try std.testing.expect(result == .none); -} + const test_cases = [_]TestCase{ + // 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" { - 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 }, + // Specific name with multiple words matches over generic + .{ .spoken = "jack bedroom", .expected = .{ .single = "Jack Bedroom Light" } }, + .{ .spoken = "jack bedroom light", .expected = .{ .single = "Jack Bedroom Light" } }, + .{ .spoken = "jack's bedroom", .expected = .{ .single = "Jack Bedroom Light" } }, + .{ .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" - const result = try findEntitiesByName(allocator, &entities, "bedroom"); - try std.testing.expect(result == .single); - try std.testing.expectEqual(@as(usize, 0), result.single); -} + for (test_cases) |tc| { + const result = try findEntitiesByName(allocator, &entities, tc.spoken); -test "findEntitiesByName noise words dont override important matches" { - 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 }, - }; - - // "bedroom light" should still match "Bedroom" - "light" is a noise word - const result = try findEntitiesByName(allocator, &entities, "bedroom light"); - try std.testing.expect(result == .single); - try std.testing.expectEqual(@as(usize, 0), result.single); -} - -test "findEntitiesByName specific name matches over generic" { - 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" should match "Jack Bedroom Light" - has both important words - const result = try findEntitiesByName(allocator, &entities, "jack bedroom"); - try std.testing.expect(result == .single); - try std.testing.expectEqual(@as(usize, 1), result.single); -} - -test "findEntitiesByName handles possessives" { - 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'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); + switch (tc.expected) { + .single => |expected_name| { + if (result != .single) { + std.debug.print("FAIL: '{s}' expected single match '{s}', got {}\n", .{ tc.spoken, expected_name, result }); + return error.TestUnexpectedResult; + } + const actual_name = entities[result.single].friendly_name; + if (!std.mem.eql(u8, actual_name, expected_name)) { + std.debug.print("FAIL: '{s}' expected '{s}', got '{s}'\n", .{ tc.spoken, expected_name, actual_name }); + return error.TestUnexpectedResult; + } + }, + .multiple => |expected_count| { + if (result != .multiple) { + std.debug.print("FAIL: '{s}' expected {d} matches, got {}\n", .{ tc.spoken, expected_count, result }); + return error.TestUnexpectedResult; + } + 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 }); + return error.TestUnexpectedResult; + } + }, + .none => { + if (result != .none) { + std.debug.print("FAIL: '{s}' expected no match, got {}\n", .{ tc.spoken, result }); + if (result == .multiple) allocator.free(result.multiple); + return error.TestUnexpectedResult; + } + }, + } + } } test "formatStateResponse light on with brightness" { diff --git a/tools/gen-skill-json.zig b/tools/gen-skill-json.zig new file mode 100644 index 0000000..c283648 --- /dev/null +++ b/tools/gen-skill-json.zig @@ -0,0 +1,63 @@ +//! Generate skill.json from template by substituting Lambda ARN +//! +//! Usage: gen-skill-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} \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); +}