improve interaction model
This commit is contained in:
parent
d77e593607
commit
d1e93d8529
7 changed files with 240 additions and 148 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
46
build.zig
46
build.zig
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = .{
|
||||||
|
|
|
||||||
|
|
@ -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"}}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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": []
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
expected: Expected,
|
||||||
|
};
|
||||||
|
|
||||||
|
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" } },
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (test_cases) |tc| {
|
||||||
|
const result = try findEntitiesByName(allocator, &entities, tc.spoken);
|
||||||
|
|
||||||
|
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);
|
defer allocator.free(result.multiple);
|
||||||
try std.testing.expectEqual(@as(usize, 2), result.multiple.len);
|
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;
|
||||||
test "findEntitiesByName no match" {
|
}
|
||||||
const allocator = std.testing.allocator;
|
},
|
||||||
const entities = [_]Entity{
|
.none => {
|
||||||
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
if (result != .none) {
|
||||||
};
|
std.debug.print("FAIL: '{s}' expected no match, got {}\n", .{ tc.spoken, result });
|
||||||
|
if (result == .multiple) allocator.free(result.multiple);
|
||||||
const result = try findEntitiesByName(allocator, &entities, "garage");
|
return error.TestUnexpectedResult;
|
||||||
try std.testing.expect(result == .none);
|
}
|
||||||
}
|
},
|
||||||
|
}
|
||||||
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 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// "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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "formatStateResponse light on with brightness" {
|
test "formatStateResponse light on with brightness" {
|
||||||
|
|
|
||||||
63
tools/gen-skill-json.zig
Normal file
63
tools/gen-skill-json.zig
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue