diff --git a/infra/docker/deploy-lucia.sh b/infra/docker/deploy-lucia.sh old mode 100755 new mode 100644 diff --git "a/lucia-dashboard/obj\\Debug/\\package.g.props" "b/lucia-dashboard/obj\357\201\234Debug/\357\201\234package.g.props" similarity index 100% rename from "lucia-dashboard/obj\\Debug/\\package.g.props" rename to "lucia-dashboard/obj\357\201\234Debug/\357\201\234package.g.props" diff --git a/lucia-dashboard/vite.config.ts b/lucia-dashboard/vite.config.ts index a4815959..1b799060 100644 --- a/lucia-dashboard/vite.config.ts +++ b/lucia-dashboard/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ tailwindcss(), ], server: { + host: true, // listen on 0.0.0.0 proxy: { // API routes — prefix-matched '/api': proxyOpts, diff --git a/lucia-dashboard/vite.config.ts.bak b/lucia-dashboard/vite.config.ts.bak new file mode 100644 index 00000000..a4815959 --- /dev/null +++ b/lucia-dashboard/vite.config.ts.bak @@ -0,0 +1,37 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// Aspire injects service URLs as env vars (e.g., services__lucia-agenthost__https__0) +const apiTarget = + process.env['services__lucia-agenthost__https__0'] ?? + process.env['services__lucia-agenthost__http__0'] ?? + 'http://localhost:5151' + +const proxyOpts = { target: apiTarget, changeOrigin: true, secure: false } as const + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + ], + server: { + proxy: { + // API routes — prefix-matched + '/api': proxyOpts, + // A2A agent list endpoint (exact path, not prefix — avoids + // clobbering /agent-dashboard and /agent-definitions SPA routes) + '/agents': proxyOpts, + '/a2a': proxyOpts, + // A2A send-message endpoint — only proxy POST /agent, not SPA routes + '/agent': { + ...proxyOpts, + bypass(req) { + // Let SPA routes like /agent-dashboard, /agent-definitions through + if (req.url && req.url.length > '/agent'.length) return req.url + }, + }, + }, + }, +}) diff --git a/lucia.AgentHost/Models/speaker-embedding/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx b/lucia.AgentHost/Models/speaker-embedding/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx new file mode 100644 index 00000000..afe3c5f3 Binary files /dev/null and b/lucia.AgentHost/Models/speaker-embedding/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx differ diff --git a/lucia.AgentHost/Models/speech-enhancement/gtcrn_simple/gtcrn_simple.onnx b/lucia.AgentHost/Models/speech-enhancement/gtcrn_simple/gtcrn_simple.onnx new file mode 100644 index 00000000..015454ee Binary files /dev/null and b/lucia.AgentHost/Models/speech-enhancement/gtcrn_simple/gtcrn_simple.onnx differ diff --git a/lucia.AgentHost/Models/vad/silero_vad_v5/silero_vad_v5.onnx b/lucia.AgentHost/Models/vad/silero_vad_v5/silero_vad_v5.onnx new file mode 100644 index 00000000..d0ccd9d7 Binary files /dev/null and b/lucia.AgentHost/Models/vad/silero_vad_v5/silero_vad_v5.onnx differ diff --git a/lucia.Agents/Abstractions/IDeviceCacheService.cs b/lucia.Agents/Abstractions/IDeviceCacheService.cs index 802b7f4a..5a8f428d 100644 --- a/lucia.Agents/Abstractions/IDeviceCacheService.cs +++ b/lucia.Agents/Abstractions/IDeviceCacheService.cs @@ -23,6 +23,9 @@ public interface IDeviceCacheService Task?> GetCachedFansAsync(CancellationToken cancellationToken = default); Task SetCachedFansAsync(List fans, TimeSpan ttl, CancellationToken cancellationToken = default); + Task?> GetCachedSensorsAsync(CancellationToken cancellationToken = default); + Task SetCachedSensorsAsync(List sensors, TimeSpan ttl, CancellationToken cancellationToken = default); + Task>?> GetAreaEmbeddingsAsync(CancellationToken cancellationToken = default); Task SetAreaEmbeddingsAsync(Dictionary> areaEmbeddings, TimeSpan ttl, CancellationToken cancellationToken = default); } diff --git a/lucia.Agents/Agents/SensorAgent.cs b/lucia.Agents/Agents/SensorAgent.cs new file mode 100644 index 00000000..00790169 --- /dev/null +++ b/lucia.Agents/Agents/SensorAgent.cs @@ -0,0 +1,229 @@ +using A2A; +using lucia.Agents.Abstractions; +using lucia.Agents.Integration; +using lucia.Agents.Services; +using lucia.Agents.Skills; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace lucia.Agents.Agents; + +/// +/// Specialized agent for querying sensor and binary_sensor entities in Home Assistant. +/// Provides read-only access to sensor data — temperature, humidity, motion, doors, battery, etc. +/// +public sealed class SensorAgent : ILuciaAgent, ISkillConfigProvider +{ + private const string AgentId = "sensor-agent"; + + private readonly AgentCard _agent; + private readonly SensorControlSkill _sensorSkill; + private readonly IChatClientResolver _clientResolver; + private readonly IAgentDefinitionRepository _definitionRepository; + private readonly TracingChatClientFactory _tracingFactory; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private volatile AIAgent _aiAgent; + private string? _lastEmbeddingProviderName; + private DateTime? _lastConfigUpdate; + + /// + /// The system instructions used by this agent. + /// + public string Instructions { get; set; } + + /// + /// The AI tools available to this agent. + /// + public IList Tools { get; } + + public SensorAgent( + IChatClientResolver clientResolver, + IAgentDefinitionRepository definitionRepository, + SensorControlSkill sensorSkill, + TracingChatClientFactory tracingFactory, + ILoggerFactory loggerFactory) + { + _sensorSkill = sensorSkill; + _clientResolver = clientResolver; + _definitionRepository = definitionRepository; + _tracingFactory = tracingFactory; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + + var sensorSkillCard = new AgentSkill() + { + Id = "id_sensor_agent", + Name = "SensorControl", + Description = "Skill for querying sensors and binary sensors in Home Assistant", + Tags = ["sensor", "binary_sensor", "temperature", "humidity", "motion", "door", "window", "battery", "power", "illuminance", "home automation"], + Examples = [ + "What's the temperature in the living room?", + "Is the front door open?", + "What's the humidity in the bedroom?", + "Are there any motion sensors triggered?", + "What's the battery level on the thermostat?", + "Show me all sensors in the kitchen", + "Is the garage door open?", + "What's the power consumption right now?" + ], + }; + + _agent = new AgentCard + { + SupportedInterfaces = [new AgentInterface { Url = "/a2a/sensor-agent" }], + Name = AgentId, + Description = "Agent for querying #sensors, #binary_sensors, #temperature, #humidity, #motion, #door, #window, #battery, and #power readings in Home Assistant", + Capabilities = new AgentCapabilities + { + PushNotifications = false, + Streaming = true, + }, + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Skills = [sensorSkillCard], + Version = "1.0.0", + }; + + var instructions = """ + You are a specialized Sensor Agent for a home automation system. + + Your responsibilities: + - Query sensor values (temperature, humidity, battery, power, illuminance, etc.) + - Query binary sensor states (motion detected, door/window open/closed, presence, etc.) + - Find sensors by name, area, or device type + - Report current readings and states + + ## Available Tools + - FindSensor: Find a sensor by name or description using natural language + - FindSensorsByArea: Find all sensors in a specific area/room + - GetSensorState: Get the current reading of a specific sensor entity + - GetBinarySensorState: Get the current state of a binary sensor (on/off) + - GetAreaSensors: Get sensors of a specific type in an area (optional device class filter) + + ## MANDATORY RULES — NEVER SKIP THESE + 1. You MUST call at least one tool function for EVERY request. NEVER respond based on assumptions. + 2. You do NOT know the current state or reading of any sensor. You MUST call a tool to check. + 3. NEVER say a sensor "is already on/off" or "reads X" without first calling the appropriate Get state tool. + 4. For queries about specific sensors: call FindSensor FIRST, then call the appropriate Get state tool. + 5. For area-based queries: use FindSensorsByArea or GetAreaSensors. + + ## Understanding Sensor vs Binary Sensor + - Regular sensors (sensor.*) report numeric or text values (temperature, humidity, battery %, etc.) + - Binary sensors (binary_sensor.*) report on/off states (motion, door open/closed, window, etc.) + - Use GetSensorState for regular sensors and GetBinarySensorState for binary sensors. + - When in doubt, check the entity ID prefix. + + ## Understanding User Context + The orchestrator provides context about where the user is located. Use this + to determine which area's sensors to query when the user doesn't specify. + + ## Response Format + * Keep responses short and informative. Examples: "The living room is 72°F.", "The front door is closed.", "Kitchen humidity is 45%." + * Do not offer additional assistance. + * If you need clarification, end your response with '?'. + * Focus only on sensor queries — if asked about controlling devices, + politely indicate that another agent handles those functions. + """; + + Instructions = instructions; + + // Propagate agent ID to skill for trace filtering + _sensorSkill.AgentId = AgentId; + Tools = _sensorSkill.GetTools(); + + // _aiAgent is built during InitializeAsync via ApplyDefinitionAsync + _aiAgent = null!; + } + + /// + /// Get the agent card for registration with the registry and A2A endpoints. + /// + public AgentCard GetAgentCard() => _agent; + + /// + public IReadOnlyList GetSkillConfigSections() => + [ + new() + { + SectionName = Configuration.UserConfiguration.SensorControlSkillOptions.SectionName, + DisplayName = "Sensor Control", + OptionsType = typeof(Configuration.UserConfiguration.SensorControlSkillOptions) + } + ]; + + /// + /// Get the underlying AI agent for processing requests. + /// + public AIAgent GetAIAgent() => _aiAgent; + + /// + /// Initialize the agent by pre-loading sensor caches. + /// + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Initializing SensorAgent..."); + await _sensorSkill.InitializeAsync(cancellationToken).ConfigureAwait(false); + + await ApplyDefinitionAsync(cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("SensorAgent initialized successfully"); + _lastConfigUpdate = DateTime.Now; + } + + /// + public async Task RefreshConfigAsync(CancellationToken cancellationToken = default) + { + await ApplyDefinitionAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task ApplyDefinitionAsync(CancellationToken cancellationToken) + { + var definition = await _definitionRepository.GetAgentDefinitionAsync(AgentId, cancellationToken).ConfigureAwait(false); + var newConnectionName = definition?.ModelConnectionName; + var newEmbeddingName = definition?.EmbeddingProviderName; + + if (!string.IsNullOrEmpty(definition?.Instructions)) + Instructions = definition.Instructions; + + if (_lastConfigUpdate == null || _lastConfigUpdate < definition?.UpdatedAt) + { + var copilotAgent = await _clientResolver.ResolveAIAgentAsync(newConnectionName, cancellationToken).ConfigureAwait(false); + _aiAgent = copilotAgent ?? BuildAgent( + await _clientResolver.ResolveAsync(newConnectionName, cancellationToken).ConfigureAwait(false)) + .AsBuilder() + .UseOpenTelemetry() + .Build(); + _logger.LogInformation("SensorAgent: using model provider '{Provider}'", newConnectionName ?? "default-chat"); + _lastConfigUpdate = DateTime.Now; + } + + if (!string.Equals(_lastEmbeddingProviderName, newEmbeddingName, StringComparison.Ordinal)) + { + await _sensorSkill.UpdateEmbeddingProviderAsync(newEmbeddingName, cancellationToken).ConfigureAwait(false); + _lastEmbeddingProviderName = newEmbeddingName; + } + } + + private AIAgent BuildAgent(IChatClient chatClient) + { + var traced = _tracingFactory.Wrap(chatClient, AgentId); + var agentOptions = new ChatClientAgentOptions + { + Id = AgentId, + Name = AgentId, + Description = "Agent for querying sensors and binary sensors in Home Assistant", + ChatOptions = new ChatOptions + { + Instructions = Instructions, + Tools = Tools + } + }; + + return new ChatClientAgent(traced, agentOptions, _loggerFactory) + .AsBuilder() + .UseOpenTelemetry() + .Build(); + } +} \ No newline at end of file diff --git a/lucia.Agents/Configuration/UserConfiguration/SensorControlSkillOptions.cs b/lucia.Agents/Configuration/UserConfiguration/SensorControlSkillOptions.cs new file mode 100644 index 00000000..cc79e30b --- /dev/null +++ b/lucia.Agents/Configuration/UserConfiguration/SensorControlSkillOptions.cs @@ -0,0 +1,53 @@ +namespace lucia.Agents.Configuration.UserConfiguration; + +/// +/// Configurable options for . +/// Stored in MongoDB configuration under the SensorControlSkill section +/// and hot-reloaded via . +/// +public sealed class SensorControlSkillOptions +{ + public const string SectionName = "SensorControlSkill"; + + /// + /// Minimum hybrid similarity score (0–1) for a sensor entity to be included + /// in search results. The hybrid score blends embedding cosine similarity + /// with string-level (Levenshtein / token-core / phonetic) similarity. + /// + public double HybridSimilarityThreshold { get; set; } = 0.55; + + /// + /// Weight applied to the embedding cosine similarity component of the + /// hybrid score. The string similarity weight is 1 − EmbeddingWeight. + /// + public double EmbeddingWeight { get; set; } = 0.4; + + /// + /// After sorting matches by score, only keep results whose score is at + /// least this fraction of the top match's score. Set to 0 to disable. + /// + public double ScoreDropoffRatio { get; set; } = 0.80; + + /// + /// Penalty applied when string-level similarity metrics disagree (0–1). + /// Higher values penalize spread between best and mean string scores. + /// + public double DisagreementPenalty { get; set; } = 0.4; + + /// + /// When multiple candidates have embedding similarities within this margin, + /// string-level scores resolve the tie. Range 0–1. + /// + public double EmbeddingResolutionMargin { get; set; } = 0.10; + + /// + /// How often the sensor entity cache is refreshed from Home Assistant, in minutes. + /// + public int CacheRefreshMinutes { get; set; } = 5; + + /// + /// The Home Assistant entity domains this skill operates on. + /// Configurable so users can extend or restrict which domains the sensor agent searches. + /// + public List EntityDomains { get; set; } = ["sensor", "binary_sensor"]; +} \ No newline at end of file diff --git a/lucia.Agents/Extensions/ServiceCollectionExtensions.cs b/lucia.Agents/Extensions/ServiceCollectionExtensions.cs index 242ae440..8c5ccea4 100644 --- a/lucia.Agents/Extensions/ServiceCollectionExtensions.cs +++ b/lucia.Agents/Extensions/ServiceCollectionExtensions.cs @@ -159,6 +159,9 @@ public static void AddLuciaAgents( builder.Services.Configure( builder.Configuration.GetSection(SceneControlSkillOptions.SectionName)); + builder.Services.Configure( + builder.Configuration.GetSection(SensorControlSkillOptions.SectionName)); + // Register agent skills and agents builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); @@ -176,6 +179,10 @@ public static void AddLuciaAgents( builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/lucia.Agents/Models/HomeAssistant/SensorEntity.cs b/lucia.Agents/Models/HomeAssistant/SensorEntity.cs new file mode 100644 index 00000000..72dab85b --- /dev/null +++ b/lucia.Agents/Models/HomeAssistant/SensorEntity.cs @@ -0,0 +1,48 @@ +using lucia.Agents.Abstractions; +using lucia.Agents.Services; +using Microsoft.Extensions.AI; + +namespace lucia.Agents.Models.HomeAssistant; + +/// +/// Represents a cached sensor or binary_sensor entity with search capabilities. +/// Mirrors Home Assistant sensor entity attributes. +/// +public sealed class SensorEntity : IMatchableEntity +{ + public string EntityId { get; set; } = string.Empty; + public string FriendlyName { get; set; } = string.Empty; + public Embedding? NameEmbedding { get; set; } + public string? Area { get; set; } + + /// + public string[] PhoneticKeys { get; set; } = []; + + /// + /// The Home Assistant device class (e.g., "temperature", "humidity", "motion", "door", "battery"). + /// Null when not set by the integration. + /// + public string? DeviceClass { get; set; } + + /// + /// The unit of measurement (e.g., "°F", "%", "lux", "ppm"). + /// Null when not set by the integration. + /// + public string? UnitOfMeasurement { get; set; } + + /// + /// The state class from Home Assistant (e.g., "measurement", "total", "total_increasing"). + /// Null when not set by the integration. + /// + public string? StateClass { get; set; } + + /// + /// Whether this is a binary_sensor entity (on/off states) vs a regular sensor entity. + /// + public bool IsBinarySensor => EntityId.StartsWith("binary_sensor."); + + // ── IMatchableEntity ──────────────────────────────────────── + + string IMatchableEntity.MatchableName => FriendlyName; + Embedding? IMatchableEntity.NameEmbedding => NameEmbedding; +} \ No newline at end of file diff --git a/lucia.Agents/Services/RedisDeviceCacheService.cs b/lucia.Agents/Services/RedisDeviceCacheService.cs index 38e47240..66a36dd9 100644 --- a/lucia.Agents/Services/RedisDeviceCacheService.cs +++ b/lucia.Agents/Services/RedisDeviceCacheService.cs @@ -21,6 +21,7 @@ public sealed class RedisDeviceCacheService : IDeviceCacheService private const string PlayersKey = "lucia:cache:players"; private const string ClimateDevicesKey = "lucia:cache:climate-devices"; private const string FansKey = "lucia:cache:fans"; + private const string SensorsKey = "lucia:cache:sensors"; private const string EmbeddingKeyPrefix = "lucia:cache:embed:"; private const string AreaEmbeddingsKey = "lucia:cache:area-embeds"; @@ -314,6 +315,14 @@ private sealed record FanCacheDto( string? ModeSelectEntityId, int SupportedFeatures); + private sealed record SensorCacheDto( + string EntityId, + string FriendlyName, + string? Area, + string? DeviceClass, + string? UnitOfMeasurement, + string? StateClass); + public async Task?> GetCachedClimateDevicesAsync(CancellationToken cancellationToken = default) { using var activity = ActivitySource.StartActivity("GetCachedClimateDevices"); @@ -467,4 +476,74 @@ public async Task SetCachedFansAsync(List fans, TimeSpan ttl, Cancell OperationDuration.Record(Stopwatch.GetElapsedTime(start).TotalMilliseconds); } } + + public async Task?> GetCachedSensorsAsync(CancellationToken cancellationToken = default) + { + using var activity = ActivitySource.StartActivity("GetCachedSensors"); + var start = Stopwatch.GetTimestamp(); + + try + { + var db = _redis.GetDatabase(); + var value = await db.StringGetAsync(SensorsKey).ConfigureAwait(false); + + if (value.IsNullOrEmpty) + { + CacheMisses.Add(1); + return null; + } + + CacheHits.Add(1); + var dtos = JsonSerializer.Deserialize>((string)value!); + if (dtos is null) return null; + + return dtos.Select(d => new SensorEntity + { + EntityId = d.EntityId, + FriendlyName = d.FriendlyName, + Area = d.Area, + DeviceClass = d.DeviceClass, + UnitOfMeasurement = d.UnitOfMeasurement, + StateClass = d.StateClass + }).ToList(); + } + catch (RedisException ex) + { + _logger.LogWarning(ex, "Redis error retrieving cached sensors"); + return null; + } + finally + { + OperationDuration.Record(Stopwatch.GetElapsedTime(start).TotalMilliseconds); + } + } + + public async Task SetCachedSensorsAsync(List sensors, TimeSpan ttl, CancellationToken cancellationToken = default) + { + using var activity = ActivitySource.StartActivity("SetCachedSensors"); + var start = Stopwatch.GetTimestamp(); + + try + { + var dtos = sensors.Select(s => new SensorCacheDto( + s.EntityId, + s.FriendlyName, + s.Area, + s.DeviceClass, + s.UnitOfMeasurement, + s.StateClass)).ToList(); + + var json = JsonSerializer.Serialize(dtos); + var db = _redis.GetDatabase(); + await db.StringSetAsync(SensorsKey, json, ttl).ConfigureAwait(false); + } + catch (RedisException ex) + { + _logger.LogWarning(ex, "Redis error setting cached sensors"); + } + finally + { + OperationDuration.Record(Stopwatch.GetElapsedTime(start).TotalMilliseconds); + } + } } diff --git a/lucia.Agents/Skills/SensorControlSkill.cs b/lucia.Agents/Skills/SensorControlSkill.cs new file mode 100644 index 00000000..8cb98008 --- /dev/null +++ b/lucia.Agents/Skills/SensorControlSkill.cs @@ -0,0 +1,618 @@ +using System.Collections.Immutable; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Text; +using System.Text.Json; +using lucia.Agents.Abstractions; +using lucia.Agents.Configuration; +using lucia.Agents.Configuration.UserConfiguration; +using lucia.Agents.Integration; +using lucia.Agents.Models; +using lucia.Agents.Models.HomeAssistant; +using lucia.Agents.Services; +using lucia.HomeAssistant.Models; +using lucia.HomeAssistant.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace lucia.Agents.Skills; + +/// +/// Skill for querying sensor and binary_sensor entities in Home Assistant. +/// Sensors are read-only — this skill provides discovery and state-reading tools only. +/// +public sealed class SensorControlSkill : IAgentSkill, IOptimizableSkill, ICommandPatternProvider +{ + private readonly IHomeAssistantClient _homeAssistantClient; + private readonly IEmbeddingProviderResolver _embeddingResolver; + private IEmbeddingGenerator>? _embeddingService; + private readonly ILogger _logger; + private readonly IDeviceCacheService _deviceCache; + private readonly IEntityLocationService _locationService; + private readonly IHybridEntityMatcher _entityMatcher; + private readonly IOptionsMonitor _options; + private ImmutableArray _cachedSensors = []; + private long _lastCacheUpdateTicks = DateTime.MinValue.Ticks; + private readonly SemaphoreSlim _refreshLock = new(1, 1); + + private static readonly ActivitySource ActivitySource = new("Lucia.Skills.SensorControl", "1.0.0"); + private static readonly Meter Meter = new("Lucia.Skills.SensorControl", "1.0.0"); + private static readonly Counter SensorSearchRequests = Meter.CreateCounter("sensor.search.requests"); + private static readonly Counter SensorSearchSuccess = Meter.CreateCounter("sensor.search.success"); + private static readonly Counter SensorSearchFailures = Meter.CreateCounter("sensor.search.failures"); + private static readonly Histogram SensorSearchDurationMs = Meter.CreateHistogram("sensor.search.duration", "ms"); + private static readonly Histogram CacheRefreshDurationMs = Meter.CreateHistogram("sensor.cache.refresh.duration", "ms"); + + public SensorControlSkill( + IHomeAssistantClient homeAssistantClient, + IEmbeddingProviderResolver embeddingResolver, + ILogger logger, + IDeviceCacheService deviceCache, + IEntityLocationService locationService, + IHybridEntityMatcher entityMatcher, + IOptionsMonitor options) + { + _homeAssistantClient = homeAssistantClient; + _embeddingResolver = embeddingResolver; + _logger = logger; + _deviceCache = deviceCache; + _locationService = locationService; + _entityMatcher = entityMatcher; + _options = options; + } + + public IList GetTools() + { + return [ + AIFunctionFactory.Create(FindSensorAsync), + AIFunctionFactory.Create(FindSensorsByAreaAsync), + AIFunctionFactory.Create(GetSensorStateAsync), + AIFunctionFactory.Create(GetBinarySensorStateAsync), + AIFunctionFactory.Create(GetAreaSensorsAsync) + ]; + } + + public IReadOnlyList GetCommandPatterns() => + [ + new() + { + Id = "sensor-query", + SkillId = "SensorControlSkill", + Action = "query", + Templates = + [ + "what [is] [the] {entity} [reading|value]", + "how [much|many] {entity} [is there]", + "is [the] {entity} [open|closed|on|off]", + ], + }, + ]; + + // ── IOptimizableSkill ───────────────────────────────────────── + + /// + public string SkillDisplayName => "Sensor Control"; + + /// + public string SkillId => "sensor-control"; + + /// + public string AgentId { get; set; } = string.Empty; + + /// + public IReadOnlyList SearchToolNames { get; } = ["FindSensor", "FindSensorsByArea"]; + + /// + public string ConfigSectionName => SensorControlSkillOptions.SectionName; + + /// + public IReadOnlyList EntityDomains => _options.CurrentValue.EntityDomains; + + /// + public HybridMatchOptions GetCurrentMatchOptions() + { + var opts = _options.CurrentValue; + return new HybridMatchOptions + { + Threshold = opts.HybridSimilarityThreshold, + EmbeddingWeight = opts.EmbeddingWeight, + ScoreDropoffRatio = opts.ScoreDropoffRatio, + DisagreementPenalty = opts.DisagreementPenalty, + EmbeddingResolutionMargin = opts.EmbeddingResolutionMargin + }; + } + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation("Initializing SensorControlSkill and caching sensor entities..."); + + _embeddingService = await _embeddingResolver.ResolveAsync(ct: cancellationToken).ConfigureAwait(false); + if (_embeddingService is null) + { + _logger.LogWarning("No embedding provider configured — sensor semantic search will not be available."); + return; + } + + await RefreshCacheAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("SensorControlSkill initialized with {SensorCount} sensor entities", _cachedSensors.Length); + } + + /// + /// Re-resolves the embedding generator using the specified provider name. + /// Called by the owning agent when the embedding configuration changes. + /// + public async Task UpdateEmbeddingProviderAsync(string? providerName, CancellationToken cancellationToken = default) + { + _embeddingService = await _embeddingResolver.ResolveAsync(providerName, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("SensorControlSkill: embedding provider updated to '{Provider}'", providerName ?? "system-default"); + } + + [Description("Find a sensor by name or description using natural language. Works for both regular sensors (temperature, humidity, etc.) and binary sensors (motion, door, etc.).")] + public async Task FindSensorAsync( + [Description("Name or description of the sensor (e.g., 'living room temperature', 'front door', 'motion sensor', 'battery level')")] string searchTerm) + { + await EnsureCacheIsCurrentAsync().ConfigureAwait(false); + + if (_cachedSensors.IsEmpty) + { + SensorSearchFailures.Add(1); + return "No sensors available in the system."; + } + + using var activity = ActivitySource.StartActivity(); + activity?.SetTag("search.term", searchTerm); + var start = Stopwatch.GetTimestamp(); + SensorSearchRequests.Add(1); + + try + { + if (_embeddingService is null) + { + SensorSearchFailures.Add(1); + return "Embedding provider not available for sensor search."; + } + + var opts = _options.CurrentValue; + var matchOptions = new HybridMatchOptions + { + Threshold = opts.HybridSimilarityThreshold, + EmbeddingWeight = opts.EmbeddingWeight, + ScoreDropoffRatio = opts.ScoreDropoffRatio, + DisagreementPenalty = opts.DisagreementPenalty, + EmbeddingResolutionMargin = opts.EmbeddingResolutionMargin + }; + + var matches = await _entityMatcher.FindMatchesAsync( + searchTerm, + (IReadOnlyList)_cachedSensors, + _embeddingService, + matchOptions).ConfigureAwait(false); + + if (matches.Count > 0) + { + activity?.SetTag("match.type", "sensor"); + activity?.SetTag("match.count", matches.Count); + activity?.SetTag("match.top_similarity", matches[0].HybridScore); + + var sb = new StringBuilder(); + if (matches.Count == 1) + sb.Append("Found sensor: "); + else + sb.AppendLine($"Found {matches.Count} matching sensor(s):"); + + foreach (var match in matches) + { + var sensor = match.Entity; + var state = await _homeAssistantClient.GetEntityStateAsync(sensor.EntityId).ConfigureAwait(false); + if (state is null) continue; + + var stateInfo = FormatSensorState(state, sensor); + + if (matches.Count == 1) + sb.Append($"{sensor.FriendlyName} (Entity ID: {sensor.EntityId}), {stateInfo}"); + else + sb.AppendLine($"- {sensor.FriendlyName} (Entity ID: {sensor.EntityId}), {stateInfo}"); + } + + SensorSearchSuccess.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + return matches.Count == 1 ? sb.ToString() : sb.ToString().TrimEnd(); + } + + // Fallback: use hierarchical search for area-based resolution + var hierarchyResult = await _locationService.SearchHierarchyAsync( + searchTerm, GetCurrentMatchOptions(), EntityDomains, CancellationToken.None).ConfigureAwait(false); + var locationEntities = hierarchyResult.ResolvedEntities; + + activity?.SetTag("match.resolution", hierarchyResult.ResolutionStrategy.ToString()); + + if (hierarchyResult.ResolutionStrategy != ResolutionStrategy.None && locationEntities.Count > 0) + { + var matchedEntityIds = locationEntities.Select(e => e.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase); + var areaSensors = _cachedSensors.Where(s => matchedEntityIds.Contains(s.EntityId)).ToList(); + + if (areaSensors.Count > 0) + { + var sb = new StringBuilder(); + sb.AppendLine($"Found {areaSensors.Count} sensor(s) matching '{searchTerm}':"); + + foreach (var sensor in areaSensors) + { + var state = await _homeAssistantClient.GetEntityStateAsync(sensor.EntityId).ConfigureAwait(false); + if (state is null) continue; + sb.AppendLine($"- {sensor.FriendlyName} (Entity ID: {sensor.EntityId}), {FormatSensorState(state, sensor)}"); + } + + SensorSearchSuccess.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + return sb.ToString().TrimEnd(); + } + } + + SensorSearchFailures.Add(1); + return $"No sensor found matching '{searchTerm}'. Available sensors: {string.Join(", ", _cachedSensors.Take(5).Select(s => s.FriendlyName))}"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finding sensor for search term: {SearchTerm}", searchTerm); + SensorSearchFailures.Add(1); + return $"Error searching for sensor: {ex.Message}"; + } + finally + { + SensorSearchDurationMs.Record(Stopwatch.GetElapsedTime(start).TotalMilliseconds); + } + } + + [Description("Find all sensors in a specific area/room. Returns both regular sensors and binary sensors.")] + public async Task FindSensorsByAreaAsync( + [Description("The area/room name to search (e.g., 'living room', 'garage', 'kitchen')")] string areaName) + { + await EnsureCacheIsCurrentAsync().ConfigureAwait(false); + + if (_cachedSensors.IsEmpty) + return "No sensors available in the system."; + + using var activity = ActivitySource.StartActivity(); + activity?.SetTag("search.area", areaName); + + try + { + var hierarchyResult = await _locationService.SearchHierarchyAsync( + areaName, GetCurrentMatchOptions(), EntityDomains, CancellationToken.None).ConfigureAwait(false); + var locationEntities = hierarchyResult.ResolvedEntities; + + activity?.SetTag("match.resolution", hierarchyResult.ResolutionStrategy.ToString()); + + if (hierarchyResult.ResolutionStrategy == ResolutionStrategy.None || locationEntities.Count == 0) + { + var availableAreas = _cachedSensors + .Where(s => !string.IsNullOrEmpty(s.Area)) + .Select(s => s.Area!) + .Distinct(); + return $"No area found matching '{areaName}'. {hierarchyResult.ResolutionReason}. Available areas with sensors: {string.Join(", ", availableAreas)}"; + } + + var matchedEntityIds = locationEntities.Select(e => e.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase); + var areaSensors = _cachedSensors.Where(s => matchedEntityIds.Contains(s.EntityId)).ToList(); + if (!areaSensors.Any()) + return $"No sensors found in the matched location for '{areaName}'."; + + var sb = new StringBuilder(); + sb.AppendLine($"Found {areaSensors.Count} sensor(s) matching '{areaName}':"); + + foreach (var sensor in areaSensors) + { + var state = await _homeAssistantClient.GetEntityStateAsync(sensor.EntityId).ConfigureAwait(false); + if (state is null) continue; + sb.AppendLine($"- {sensor.FriendlyName} (Entity ID: {sensor.EntityId}), {FormatSensorState(state, sensor)}"); + } + + activity?.SetStatus(ActivityStatusCode.Ok); + return sb.ToString().TrimEnd(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finding sensors in area: {AreaName}", areaName); + return $"Error searching for sensors in area: {ex.Message}"; + } + } + + [Description("Get the current reading of a specific sensor entity. Use this for regular sensors (temperature, humidity, battery, power, etc.). Sensors are read-only.")] + public async Task GetSensorStateAsync( + [Description("The entity ID of the sensor (e.g., 'sensor.living_room_temperature', 'sensor.battery_level')")] string entityId) + { + using var activity = ActivitySource.StartActivity(); + activity?.SetTag("entity_id", entityId); + + try + { + var state = await _homeAssistantClient.GetEntityStateAsync(entityId).ConfigureAwait(false); + if (state is null) + return $"Sensor '{entityId}' not found."; + + var device = _cachedSensors.FirstOrDefault(s => s.EntityId == entityId); + return FormatSensorState(state, device); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting sensor state for {EntityId}", entityId); + return $"Error getting state for '{entityId}': {ex.Message}"; + } + } + + [Description("Get the current state of a specific binary sensor entity. Binary sensors have on/off states (e.g., motion detected, door open/closed, window open). They are read-only.")] + public async Task GetBinarySensorStateAsync( + [Description("The entity ID of the binary sensor (e.g., 'binary_sensor.front_door', 'binary_sensor.living_room_motion')")] string entityId) + { + using var activity = ActivitySource.StartActivity(); + activity?.SetTag("entity_id", entityId); + + try + { + var state = await _homeAssistantClient.GetEntityStateAsync(entityId).ConfigureAwait(false); + if (state is null) + return $"Binary sensor '{entityId}' not found."; + + var device = _cachedSensors.FirstOrDefault(s => s.EntityId == entityId); + + var friendlyName = state.Attributes.TryGetValue("friendly_name", out var fn) ? fn?.ToString() : entityId; + var deviceClass = state.Attributes.TryGetValue("device_class", out var dc) ? dc?.ToString() : null; + + var sb = new StringBuilder(); + sb.Append($"State: {state.State}"); + + if (deviceClass is not null) + sb.Append($", Type: {deviceClass}"); + + if (device is not null) + sb.Append($", Area: {device.Area ?? "unknown"}"); + + sb.Append($", Name: {friendlyName}"); + + activity?.SetStatus(ActivityStatusCode.Ok); + return sb.ToString(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting binary sensor state for {EntityId}", entityId); + return $"Error getting state for '{entityId}': {ex.Message}"; + } + } + + [Description("Get all sensors of a specific type in an area. Filter by device class to find specific kinds of sensors (e.g., temperature sensors, motion sensors, battery sensors).")] + public async Task GetAreaSensorsAsync( + [Description("The area/room name to search (e.g., 'living room', 'kitchen')")] string areaName, + [Description("Optional device class filter (e.g., 'temperature', 'humidity', 'motion', 'door', 'window', 'battery', 'power', 'illuminance'). Leave empty to return all sensors in the area.")] string? deviceClass = null) + { + await EnsureCacheIsCurrentAsync().ConfigureAwait(false); + + if (_cachedSensors.IsEmpty) + return "No sensors available in the system."; + + using var activity = ActivitySource.StartActivity(); + activity?.SetTag("search.area", areaName); + activity?.SetTag("filter.device_class", deviceClass ?? "none"); + + try + { + var hierarchyResult = await _locationService.SearchHierarchyAsync( + areaName, GetCurrentMatchOptions(), EntityDomains, CancellationToken.None).ConfigureAwait(false); + + activity?.SetTag("match.resolution", hierarchyResult.ResolutionStrategy.ToString()); + + if (hierarchyResult.ResolutionStrategy == ResolutionStrategy.None || hierarchyResult.ResolvedEntities.Count == 0) + return $"No area found matching '{areaName}'. {hierarchyResult.ResolutionReason}"; + + var matchedEntityIds = hierarchyResult.ResolvedEntities.Select(e => e.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase); + var areaSensors = _cachedSensors.Where(s => matchedEntityIds.Contains(s.EntityId)).ToList(); + + if (!string.IsNullOrEmpty(deviceClass)) + { + areaSensors = areaSensors.Where(s => + s.DeviceClass?.Equals(deviceClass, StringComparison.OrdinalIgnoreCase) == true + || s.EntityId.Contains(deviceClass, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + if (areaSensors.Count == 0) + { + if (!string.IsNullOrEmpty(deviceClass)) + return $"No {deviceClass} sensors found in '{areaName}'."; + return $"No sensors found in '{areaName}'."; + } + + var sb = new StringBuilder(); + sb.AppendLine($"Found {areaSensors.Count} sensor(s) in '{areaName}'{(deviceClass is not null ? $" (type: {deviceClass})" : "")}:"); + + foreach (var sensor in areaSensors) + { + var state = await _homeAssistantClient.GetEntityStateAsync(sensor.EntityId).ConfigureAwait(false); + if (state is null) continue; + + var unit = sensor.UnitOfMeasurement ?? ""; + var sensorType = sensor.IsBinarySensor ? "binary_sensor" : "sensor"; + var dc = sensor.DeviceClass ?? "unknown"; + sb.AppendLine($"- {sensor.FriendlyName}: {state.State}{unit} [{sensorType}/{dc}] (Entity ID: {sensor.EntityId})"); + } + + activity?.SetStatus(ActivityStatusCode.Ok); + return sb.ToString().TrimEnd(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finding sensors in area: {AreaName}", areaName); + return $"Error searching for sensors in area: {ex.Message}"; + } + } + + private static string FormatSensorState(HomeAssistantState state, SensorEntity? device) + { + var sb = new StringBuilder(); + + if (device is not null && device.IsBinarySensor) + { + // Binary sensor formatting + sb.Append($"State: {state.State}"); + var deviceClass = state.Attributes.TryGetValue("device_class", out var dc) ? dc?.ToString() : null; + if (deviceClass is not null) + sb.Append($", Type: {deviceClass}"); + sb.Append($", Name: {device.FriendlyName}"); + if (device.Area is not null) + sb.Append($", Area: {device.Area}"); + } + else + { + // Regular sensor formatting + var unit = state.Attributes.TryGetValue("unit_of_measurement", out var u) ? u?.ToString() : ""; + var friendlyName = state.Attributes.TryGetValue("friendly_name", out var fn) ? fn?.ToString() : state.EntityId; + var deviceClass = state.Attributes.TryGetValue("device_class", out var dc) ? dc?.ToString() : null; + + sb.Append($"State: {state.State}{unit}"); + + if (deviceClass is not null) + sb.Append($", Type: {deviceClass}"); + + if (device is not null) + sb.Append($", Area: {device.Area ?? "unknown"}"); + + sb.Append($", Name: {friendlyName}"); + } + + return sb.ToString(); + } + + private async Task RefreshCacheAsync(CancellationToken cancellationToken = default) + { + if (_embeddingService is null) + { + _logger.LogWarning("Skipping sensor cache refresh — no embedding provider available."); + return; + } + + using var activity = ActivitySource.StartActivity(); + var start = Stopwatch.GetTimestamp(); + try + { + // Try Redis cache first (device data only — areas come from IEntityLocationService) + var cached = await _deviceCache.GetCachedSensorsAsync(cancellationToken).ConfigureAwait(false); + if (cached is not null) + { + var allEmbeddingsFound = true; + foreach (var sensor in cached) + { + var embedding = await _deviceCache.GetEmbeddingAsync($"sensor:{sensor.EntityId}", cancellationToken).ConfigureAwait(false); + if (embedding is not null) + { + sensor.NameEmbedding = embedding; + } + else + { + allEmbeddingsFound = false; + break; + } + } + + if (allEmbeddingsFound) + { + foreach (var sensor in cached) + { + if (sensor.PhoneticKeys.Length == 0) + sensor.PhoneticKeys = StringSimilarity.BuildPhoneticKeys(sensor.FriendlyName); + } + + _cachedSensors = [.. cached]; + Volatile.Write(ref _lastCacheUpdateTicks, DateTime.UtcNow.Ticks); + _logger.LogInformation("Loaded {Count} sensors from Redis cache", cached.Count); + return; + } + } + + _logger.LogDebug("Refreshing sensor cache from Home Assistant..."); + + var allStates = await _homeAssistantClient.GetAllEntityStatesAsync(cancellationToken).ConfigureAwait(false); + + var sensorEntities = allStates + .Where(s => s.EntityId.StartsWith("sensor.", StringComparison.OrdinalIgnoreCase) + || s.EntityId.StartsWith("binary_sensor.", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var newSensors = new List(); + + foreach (var entity in sensorEntities) + { + var friendlyName = entity.Attributes.TryGetValue("friendly_name", out var nameObj) + ? nameObj?.ToString() ?? entity.EntityId + : entity.EntityId; + + var deviceClass = entity.Attributes.TryGetValue("device_class", out var dcObj) + ? dcObj?.ToString() + : null; + + var unitOfMeasurement = entity.Attributes.TryGetValue("unit_of_measurement", out var uomObj) + ? uomObj?.ToString() + : null; + + var stateClass = entity.Attributes.TryGetValue("state_class", out var scObj) + ? scObj?.ToString() + : null; + + // Resolve area from the shared entity location service + var areaInfo = _locationService.GetAreaForEntity(entity.EntityId); + + var embedding = await _embeddingService.GenerateAsync(friendlyName, cancellationToken: cancellationToken).ConfigureAwait(false); + + newSensors.Add(new SensorEntity + { + EntityId = entity.EntityId, + FriendlyName = friendlyName, + NameEmbedding = embedding, + PhoneticKeys = StringSimilarity.BuildPhoneticKeys(friendlyName), + Area = areaInfo?.Name, + DeviceClass = deviceClass, + UnitOfMeasurement = unitOfMeasurement, + StateClass = stateClass + }); + } + + _cachedSensors = [.. newSensors]; + Volatile.Write(ref _lastCacheUpdateTicks, DateTime.UtcNow.Ticks); + + var ttl = TimeSpan.FromMinutes(_options.CurrentValue.CacheRefreshMinutes); + var embedTtl = TimeSpan.FromHours(24); + await _deviceCache.SetCachedSensorsAsync(newSensors, ttl, cancellationToken).ConfigureAwait(false); + foreach (var sensor in newSensors.Where(s => s.NameEmbedding is not null)) + await _deviceCache.SetEmbeddingAsync($"sensor:{sensor.EntityId}", sensor.NameEmbedding!, embedTtl, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Cached {Count} sensor entities", newSensors.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing sensor cache"); + } + finally + { + CacheRefreshDurationMs.Record(Stopwatch.GetElapsedTime(start).TotalMilliseconds); + } + } + + private async Task EnsureCacheIsCurrentAsync(CancellationToken cancellationToken = default) + { + var cacheRefreshInterval = TimeSpan.FromMinutes(_options.CurrentValue.CacheRefreshMinutes); + if (DateTime.UtcNow - new DateTime(Volatile.Read(ref _lastCacheUpdateTicks), DateTimeKind.Utc) > cacheRefreshInterval) + { + if (!await _refreshLock.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + return; + try + { + if (DateTime.UtcNow - new DateTime(Volatile.Read(ref _lastCacheUpdateTicks), DateTimeKind.Utc) > cacheRefreshInterval) + await RefreshCacheAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _refreshLock.Release(); + } + } + } +} \ No newline at end of file diff --git a/lucia.Data/InMemory/InMemoryDeviceCacheService.cs b/lucia.Data/InMemory/InMemoryDeviceCacheService.cs index 90870ce6..709f8bd2 100644 --- a/lucia.Data/InMemory/InMemoryDeviceCacheService.cs +++ b/lucia.Data/InMemory/InMemoryDeviceCacheService.cs @@ -16,6 +16,7 @@ public sealed class InMemoryDeviceCacheService : IDeviceCacheService private readonly ConcurrentDictionary>> _players = new(); private readonly ConcurrentDictionary>> _climateDevices = new(); private readonly ConcurrentDictionary>> _fans = new(); + private readonly ConcurrentDictionary>> _sensors = new(); private readonly ConcurrentDictionary>> _embeddings = new(); private readonly ConcurrentDictionary>>> _areaEmbeddings = new(); @@ -70,6 +71,17 @@ public Task SetCachedFansAsync(List fans, TimeSpan ttl, CancellationT return Task.CompletedTask; } + // ── Sensors ───────────────────────────────────────────────────────── + + public Task?> GetCachedSensorsAsync(CancellationToken cancellationToken = default) + => Task.FromResult(GetOrExpire(_sensors, "sensors")); + + public Task SetCachedSensorsAsync(List sensors, TimeSpan ttl, CancellationToken cancellationToken = default) + { + _sensors["sensors"] = new CacheEntry>(sensors, ttl); + return Task.CompletedTask; + } + // ── Individual Embeddings ─────────────────────────────────────────── public Task?> GetEmbeddingAsync(string key, CancellationToken cancellationToken = default) diff --git a/lucia.EvalHarness/Tui/HtmlReportGenerator.cs b/lucia.EvalHarness/Tui/HtmlReportGenerator.cs new file mode 100644 index 00000000..e81154b0 --- /dev/null +++ b/lucia.EvalHarness/Tui/HtmlReportGenerator.cs @@ -0,0 +1,105 @@ +using System.Text; +using System.Text.Json; +using lucia.EvalHarness.Evaluation; +using lucia.EvalHarness.Infrastructure; + +namespace lucia.EvalHarness.Tui; + +/// +/// Generates an HTML report from evaluation results. +/// +public static class HtmlReportGenerator +{ + /// + /// Generates an HTML report file and returns its path. + /// + public static string Generate(EvalRunResult result, GpuInfo gpuInfo, string reportDir) + { + Directory.CreateDirectory(reportDir); + + var timestamp = result.StartedAt.ToString("yyyyMMdd_HHmmss"); + var htmlPath = Path.Combine(reportDir, $"eval-{timestamp}.html"); + + var html = BuildHtml(result, gpuInfo); + File.WriteAllText(htmlPath, html); + + return htmlPath; + } + + private static string BuildHtml(EvalRunResult result, GpuInfo gpuInfo) + { + var sb = new StringBuilder(); + var duration = result.CompletedAt - result.StartedAt; + var allModels = result.AgentResults + .SelectMany(a => a.ModelResults) + .Select(m => m.ModelName) + .Distinct() + .ToList(); + + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"lucia Eval Report — {result.RunId}"); + sb.AppendLine(""); + sb.AppendLine(""); + + sb.AppendLine($"

lucia Eval Harness Report

"); + sb.AppendLine($"

Run ID: {result.RunId} | Duration: {duration.TotalSeconds:F1}s | GPU: {gpuInfo.GpuLabel} | Timestamp: {result.StartedAt:yyyy-MM-dd HH:mm:ss} UTC

"); + + // Quality matrix + sb.AppendLine("

Quality Scores (0–100)

"); + sb.AppendLine(""); + foreach (var model in allModels) + sb.AppendLine($""); + sb.AppendLine(""); + + foreach (var agent in result.AgentResults) + { + sb.AppendLine($""); + foreach (var model in allModels) + { + var mr = agent.ModelResults.FirstOrDefault(m => m.ModelName == model); + if (mr is not null) + { + var cls = mr.OverallScore >= 80 ? "score-high" : mr.OverallScore >= 60 ? "score-mid" : "score-low"; + sb.AppendLine($""); + } + else + { + sb.AppendLine(""); + } + } + sb.AppendLine(""); + } + sb.AppendLine("
Agent{model}
{agent.AgentName}{mr.OverallScore:F1}N/A
"); + + // Per-agent details + foreach (var agent in result.AgentResults) + { + sb.AppendLine($"

{agent.AgentName}

"); + sb.AppendLine(""); + + foreach (var m in agent.ModelResults.OrderByDescending(m => m.OverallScore)) + { + var passRate = m.TestCaseCount > 0 ? (double)m.PassedCount / m.TestCaseCount : 0; + var cls = m.OverallScore >= 80 ? "score-high" : m.OverallScore >= 60 ? "score-mid" : "score-low"; + sb.AppendLine($""); + } + sb.AppendLine("
ModelOverallTool SelTool SuccTool EffTask CompPass RateMean Latency
{m.ModelName}{m.OverallScore:F1}{m.ToolSelectionScore:F1}{m.ToolSuccessScore:F1}{m.ToolEfficiencyScore:F1}{m.TaskCompletionScore:F1}{passRate:P0}{m.Performance.MeanLatency.TotalMilliseconds:F0}ms
"); + } + + sb.AppendLine(""); + return sb.ToString(); + } +} \ No newline at end of file diff --git a/scripts/sync-lucia-to-ha.sh b/scripts/sync-lucia-to-ha.sh old mode 100755 new mode 100644