refactors

This commit is contained in:
Jacob Dubin
2026-05-17 08:08:11 -05:00
parent 05efeb2853
commit dfcf521a5a
99 changed files with 8632 additions and 9922 deletions

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />

View File

@@ -143,24 +143,32 @@
<div class="static-section" id="staticSection"> <div class="static-section" id="staticSection">
<div class="row"> <div class="row">
<div> <div>
<label>Static IP</label <label>
Static IP
</label
><input id="staticIP" placeholder="192.168.1.100"/> ><input id="staticIP" placeholder="192.168.1.100"/>
</div> </div>
<div> <div>
<label>Netmask</label <label>
Netmask
</label
><input id="netmask" placeholder="255.255.255.0"/> ><input id="netmask" placeholder="255.255.255.0"/>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div> <div>
<label>Gateway</label <label>
Gateway
</label
><input id="gateway" placeholder="192.168.1.1"/> ><input id="gateway" placeholder="192.168.1.1"/>
</div> </div>
<div> <div>
<label>DNS 1</label><input id="dns1" placeholder="8.8.8.8"/> <label>DNS 1</label><input id="dns1" placeholder="8.8.8.8"/>
</div> </div>
</div> </div>
<div><label>DNS 2</label><input id="dns2" placeholder="8.8.4.4" /></div> <div>
<label>DNS 2</label><input id="dns2" placeholder="8.8.4.4"/>
</div>
</div> </div>
<button onclick="generate()">Generate QR Code</button> <button onclick="generate()">Generate QR Code</button>

View File

@@ -1,5 +1,6 @@
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Text; using System.Text;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services; using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Domain.Models; using Jibo.Cloud.Domain.Models;
@@ -88,18 +89,13 @@ app.Use(async (context, next) =>
var replies = await webSocketService.HandleMessageAsync(envelope, context.RequestAborted); var replies = await webSocketService.HandleMessageAsync(envelope, context.RequestAborted);
var session = ResolveSession(webSocketService, envelope); var session = ResolveSession(webSocketService, envelope);
await telemetrySink.RecordInboundAsync(envelope, session, ReadMessageType(envelope.Text), context.RequestAborted); await telemetrySink.RecordInboundAsync(envelope, session, ReadMessageType(envelope.Text),
context.RequestAborted);
foreach (var reply in replies) foreach (var reply in replies)
{ {
if (string.IsNullOrWhiteSpace(reply.Text)) if (string.IsNullOrWhiteSpace(reply.Text)) continue;
{
continue;
}
if (reply.DelayMs > 0) if (reply.DelayMs > 0) await Task.Delay(reply.DelayMs, context.RequestAborted);
{
await Task.Delay(reply.DelayMs, context.RequestAborted);
}
var payload = Encoding.UTF8.GetBytes(reply.Text); var payload = Encoding.UTF8.GetBytes(reply.Text);
await socket.SendAsync(payload, WebSocketMessageType.Text, true, context.RequestAborted); await socket.SendAsync(payload, WebSocketMessageType.Text, true, context.RequestAborted);
@@ -117,7 +113,8 @@ app.Use(async (context, next) =>
Token = token Token = token
}; };
var closeSession = ResolveSession(webSocketService, closeEnvelope); var closeSession = ResolveSession(webSocketService, closeEnvelope);
await telemetrySink.RecordConnectionClosedAsync(closeEnvelope, closeSession, $"socket-loop-ended{(isPrematureClose ? "-prematurely" : string.Empty)}", context.RequestAborted); await telemetrySink.RecordConnectionClosedAsync(closeEnvelope, closeSession,
$"socket-loop-ended{(isPrematureClose ? "-prematurely" : string.Empty)}", context.RequestAborted);
}); });
app.MapGet("/health", () => Results.Json(new app.MapGet("/health", () => Results.Json(new
@@ -127,7 +124,8 @@ app.MapGet("/health", () => Results.Json(new
version = OpenJiboCloudBuildInfo.Version version = OpenJiboCloudBuildInfo.Version
})); }));
app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context, JiboCloudProtocolService service, IProtocolTelemetrySink telemetrySink, CancellationToken cancellationToken) => app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context, JiboCloudProtocolService service,
IProtocolTelemetrySink telemetrySink, CancellationToken cancellationToken) =>
{ {
var envelope = await BuildEnvelopeAsync(context, cancellationToken); var envelope = await BuildEnvelopeAsync(context, cancellationToken);
var result = await service.DispatchAsync(envelope, cancellationToken); var result = await service.DispatchAsync(envelope, cancellationToken);
@@ -136,15 +134,9 @@ app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context,
context.Response.StatusCode = result.StatusCode; context.Response.StatusCode = result.StatusCode;
context.Response.ContentType = result.ContentType; context.Response.ContentType = result.ContentType;
foreach (var header in result.Headers) foreach (var header in result.Headers) context.Response.Headers[header.Key] = header.Value;
{
context.Response.Headers[header.Key] = header.Value;
}
if (!string.IsNullOrEmpty(result.BodyText)) if (!string.IsNullOrEmpty(result.BodyText)) await context.Response.WriteAsync(result.BodyText, cancellationToken);
{
await context.Response.WriteAsync(result.BodyText, cancellationToken);
}
}); });
app.Run(); app.Run();
@@ -160,8 +152,7 @@ static async Task<ReceivedSocketMessage> ReceiveAsync(WebSocket socket, Cancella
{ {
result = await socket.ReceiveAsync(buffer, cancellationToken); result = await socket.ReceiveAsync(buffer, cancellationToken);
ms.Write(buffer, 0, result.Count); ms.Write(buffer, 0, result.Count);
} } while (!result.EndOfMessage);
while (!result.EndOfMessage);
return new ReceivedSocketMessage(result.MessageType, ms.ToArray()); return new ReceivedSocketMessage(result.MessageType, ms.ToArray());
} }
@@ -170,7 +161,7 @@ static async Task<ProtocolEnvelope> BuildEnvelopeAsync(HttpContext context, Canc
{ {
context.Request.EnableBuffering(); context.Request.EnableBuffering();
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, false, leaveOpen: true);
var bodyText = await reader.ReadToEndAsync(cancellationToken); var bodyText = await reader.ReadToEndAsync(cancellationToken);
context.Request.Body.Position = 0; context.Request.Body.Position = 0;
@@ -191,66 +182,49 @@ static async Task<ProtocolEnvelope> BuildEnvelopeAsync(HttpContext context, Canc
FirmwareVersion = context.Request.Headers["X-OpenJibo-Firmware"].ToString(), FirmwareVersion = context.Request.Headers["X-OpenJibo-Firmware"].ToString(),
ApplicationVersion = context.Request.Headers["X-OpenJibo-AppVersion"].ToString(), ApplicationVersion = context.Request.Headers["X-OpenJibo-AppVersion"].ToString(),
BodyText = bodyText, BodyText = bodyText,
Headers = context.Request.Headers.ToDictionary(pair => pair.Key, pair => pair.Value.ToString(), StringComparer.OrdinalIgnoreCase) Headers = context.Request.Headers.ToDictionary(pair => pair.Key, pair => pair.Value.ToString(),
StringComparer.OrdinalIgnoreCase)
}; };
} }
static string ResolveSocketKind(string host, PathString path) static string ResolveSocketKind(string host, PathString path)
{ {
if (host.Equals("api-socket.jibo.com", StringComparison.OrdinalIgnoreCase)) if (host.Equals("api-socket.jibo.com", StringComparison.OrdinalIgnoreCase)) return "api-socket";
{
return "api-socket";
}
if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase) && if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase) &&
path.StartsWithSegments("/v1/proactive")) path.StartsWithSegments("/v1/proactive"))
{
return "neo-hub-proactive"; return "neo-hub-proactive";
}
if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase)) if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase)) return "neo-hub-listen";
{
return "neo-hub-listen";
}
if (host.Equals("openjibo.com", StringComparison.OrdinalIgnoreCase) || if (host.Equals("openjibo.com", StringComparison.OrdinalIgnoreCase) ||
host.Equals("openjibo.ai", StringComparison.OrdinalIgnoreCase) || host.Equals("openjibo.ai", StringComparison.OrdinalIgnoreCase) ||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
return "openjibo"; return "openjibo";
}
return "neo-hub-listen"; // now it assumes all unknown requests are neo-hub. I did this so that people with custom listen servers (like myself) won't get a bunch of 404 messages when doing a HJ request. -ZaneDev (an awful programmer) return
"neo-hub-listen"; // now it assumes all unknown requests are neo-hub. I did this so that people with custom listen servers (like myself) won't get a bunch of 404 messages when doing a HJ request. -ZaneDev (an awful programmer)
} }
static string? ResolveToken(HttpRequest request) static string? ResolveToken(HttpRequest request)
{ {
var auth = request.Headers.Authorization.ToString(); var auth = request.Headers.Authorization.ToString();
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return auth["Bearer ".Length..].Trim();
{
return auth["Bearer ".Length..].Trim();
}
var path = request.Path.Value; var path = request.Path.Value;
if (!string.IsNullOrWhiteSpace(path) && path.Length > 1) if (!string.IsNullOrWhiteSpace(path) && path.Length > 1) return path.Trim('/');
{
return path.Trim('/');
}
return null; return null;
} }
static string ReadMessageType(string? text) static string ReadMessageType(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text)) return "BINARY_OR_EMPTY";
{
return "BINARY_OR_EMPTY";
}
try try
{ {
using var document = System.Text.Json.JsonDocument.Parse(text); using var document = JsonDocument.Parse(text);
return document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == System.Text.Json.JsonValueKind.String return document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String
? type.GetString() ?? "UNKNOWN" ? type.GetString() ?? "UNKNOWN"
: "UNKNOWN"; : "UNKNOWN";
} }

View File

@@ -19,12 +19,21 @@ public interface ICloudStateStore
IReadOnlyList<PersonRecord> GetPeople(); IReadOnlyList<PersonRecord> GetPeople();
IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null); IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null);
UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? filter); UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? filter);
UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary<string, object?>? dependencies);
UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length,
string? subsystem, string? filter, IDictionary<string, object?>? dependencies);
UpdateManifest RemoveUpdate(string? updateId); UpdateManifest RemoveUpdate(string? updateId);
IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null, long? before = null);
IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null,
long? before = null);
IReadOnlyList<MediaRecord> GetMedia(IReadOnlyList<string> paths); IReadOnlyList<MediaRecord> GetMedia(IReadOnlyList<string> paths);
IReadOnlyList<MediaRecord> RemoveMedia(IReadOnlyList<string> paths); IReadOnlyList<MediaRecord> RemoveMedia(IReadOnlyList<string> paths);
MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, IDictionary<string, object?>? meta);
MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted,
IDictionary<string, object?>? meta);
IReadOnlyList<BackupRecord> GetBackups(); IReadOnlyList<BackupRecord> GetBackups();
bool ShouldCreateSymmetricKey(string loopId); bool ShouldCreateSymmetricKey(string loopId);
string GetOrCreateSymmetricKey(string loopId); string GetOrCreateSymmetricKey(string loopId);

View File

@@ -21,7 +21,11 @@ public interface IPersonalMemoryStore
void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName); void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName);
} }
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId, string? PersonId = null); public sealed record PersonalMemoryTenantScope(
string AccountId,
string LoopId,
string DeviceId,
string? PersonId = null);
public sealed record PersistenceStateInfo( public sealed record PersistenceStateInfo(
string SchemaVersion, string SchemaVersion,

View File

@@ -4,5 +4,6 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface IProtocolTelemetrySink public interface IProtocolTelemetrySink
{ {
Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, CancellationToken cancellationToken = default); Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result,
CancellationToken cancellationToken = default);
} }

View File

@@ -2,7 +2,8 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface ITurnTelemetrySink public interface ITurnTelemetrySink
{ {
Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default); Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details,
CancellationToken cancellationToken = default);
Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default); Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default);
} }

View File

@@ -4,9 +4,18 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface IWebSocketTelemetrySink public interface IWebSocketTelemetrySink
{ {
Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default); Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session,
Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default);
Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default); Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType,
Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType,
IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default);
Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session,
IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default);
Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason,
CancellationToken cancellationToken = default);
} }

View File

@@ -1,5 +1,5 @@
using Jibo.Cloud.Application.Abstractions;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Application.Services; namespace Jibo.Cloud.Application.Services;
@@ -136,7 +136,11 @@ internal static class ChitchatStateMachine
("jealous", ["jealous", "envious", "covetous"]), ("jealous", ["jealous", "envious", "covetous"]),
("lonely", ["lonely", "alone", "lonesome"]), ("lonely", ["lonely", "alone", "lonesome"]),
("proud", ["proud", "honored"]), ("proud", ["proud", "honored"]),
("sad", ["sad", "upset", "unhappy", "depressed", "somber", "downcast", "gloomy", "miserable", "bummed", "heartbroken", "troubled"]) ("sad",
[
"sad", "upset", "unhappy", "depressed", "somber", "downcast", "gloomy", "miserable", "bummed",
"heartbroken", "troubled"
])
]; ];
private static readonly string[] EmotionCommandReplies = private static readonly string[] EmotionCommandReplies =
@@ -216,7 +220,8 @@ internal static class ChitchatStateMachine
case "robot_identity": case "robot_identity":
return BuildScriptedResponseDecision( return BuildScriptedResponseDecision(
"robot_identity", "robot_identity",
SelectLegacyPersonalityReply(catalog, randomizer, "am a robot", "i'm either jibo", "i am just jibo")); SelectLegacyPersonalityReply(catalog, randomizer, "am a robot", "i'm either jibo",
"i am just jibo"));
case "robot_likes_being_jibo": case "robot_likes_being_jibo":
return BuildScriptedResponseDecision( return BuildScriptedResponseDecision(
"robot_likes_being_jibo", "robot_likes_being_jibo",
@@ -259,16 +264,12 @@ internal static class ChitchatStateMachine
SelectLegacyPersonalityReply(catalog, randomizer, "know a lot", "not as much as i will someday")); SelectLegacyPersonalityReply(catalog, randomizer, "know a lot", "not as much as i will someday"));
case "chat": case "chat":
if (IsEmotionQuery(normalizedLoweredTranscript)) if (IsEmotionQuery(normalizedLoweredTranscript))
{
return BuildEmotionQueryDecision( return BuildEmotionQueryDecision(
"emotion_query", "emotion_query",
SelectEmotionQueryReply(catalog, randomizer, currentEmotion)); SelectEmotionQueryReply(catalog, randomizer, currentEmotion));
}
if (TryResolveEmotionCommand(normalizedLoweredTranscript, out var emotion)) if (TryResolveEmotionCommand(normalizedLoweredTranscript, out var emotion))
{
return BuildEmotionCommandDecision(randomizer, emotion!); return BuildEmotionCommandDecision(randomizer, emotion!);
}
return BuildErrorResponseDecision( return BuildErrorResponseDecision(
"chat", "chat",
@@ -293,7 +294,7 @@ internal static class ChitchatStateMachine
replyText, replyText,
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
ScriptedResponseRoute, ScriptedResponseRoute,
emotion: null)); null));
} }
private static JiboInteractionDecision BuildEmotionQueryDecision(string intentName, string replyText) private static JiboInteractionDecision BuildEmotionQueryDecision(string intentName, string replyText)
@@ -303,7 +304,7 @@ internal static class ChitchatStateMachine
replyText, replyText,
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
EmotionQueryRoute, EmotionQueryRoute,
emotion: null)); null));
} }
private static JiboInteractionDecision BuildEmotionCommandDecision(IJiboRandomizer randomizer, string emotion) private static JiboInteractionDecision BuildEmotionCommandDecision(IJiboRandomizer randomizer, string emotion)
@@ -323,18 +324,20 @@ internal static class ChitchatStateMachine
"chitchat-skill", "chitchat-skill",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase) new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{ {
["esml"] = $"<speak><es cat='{esmlEmotion}' filter='!ssa-only, !sfx-only' endNeutral='true'>{responseSuffix}</es></speak>", ["esml"] =
$"<speak><es cat='{esmlEmotion}' filter='!ssa-only, !sfx-only' endNeutral='true'>{responseSuffix}</es></speak>",
["mim_id"] = "runtime-chat", ["mim_id"] = "runtime-chat",
["mim_type"] = "announcement", ["mim_type"] = "announcement",
["prompt_id"] = "RUNTIME_EMOTION_COMMAND", ["prompt_id"] = "RUNTIME_EMOTION_COMMAND",
["prompt_sub_category"] = "AN" ["prompt_sub_category"] = "AN"
}, },
ContextUpdates: BuildContextUpdates( BuildContextUpdates(
EmotionCommandRoute, EmotionCommandRoute,
emotion)); emotion));
} }
private static JiboInteractionDecision BuildErrorResponseDecision(string intentName, string replyText, string transcript) private static JiboInteractionDecision BuildErrorResponseDecision(string intentName, string replyText,
string transcript)
{ {
var normalizedTranscript = string.IsNullOrWhiteSpace(transcript) var normalizedTranscript = string.IsNullOrWhiteSpace(transcript)
? string.Empty ? string.Empty
@@ -344,8 +347,8 @@ internal static class ChitchatStateMachine
replyText, replyText,
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
ErrorResponseRoute, ErrorResponseRoute,
emotion: null, null,
rawTranscript: normalizedTranscript)); normalizedTranscript));
} }
private static IDictionary<string, object?> BuildContextUpdates( private static IDictionary<string, object?> BuildContextUpdates(
@@ -369,19 +372,12 @@ internal static class ChitchatStateMachine
IJiboRandomizer randomizer, IJiboRandomizer randomizer,
string? currentEmotion) string? currentEmotion)
{ {
if (catalog.EmotionReplies.Count == 0) if (catalog.EmotionReplies.Count == 0) return randomizer.Choose(catalog.HowAreYouReplies);
{
return randomizer.Choose(catalog.HowAreYouReplies);
}
var emotionVariants = ResolveEmotionVariants(currentEmotion); var emotionVariants = ResolveEmotionVariants(currentEmotion);
foreach (var reply in catalog.EmotionReplies) foreach (var reply in catalog.EmotionReplies)
{
if (ConditionMatches(reply.Condition, emotionVariants)) if (ConditionMatches(reply.Condition, emotionVariants))
{
return reply.Reply; return reply.Reply;
}
}
return randomizer.Choose(catalog.HowAreYouReplies); return randomizer.Choose(catalog.HowAreYouReplies);
} }
@@ -389,19 +385,13 @@ internal static class ChitchatStateMachine
private static bool ConditionMatches(string? condition, IReadOnlyList<string> emotionVariants) private static bool ConditionMatches(string? condition, IReadOnlyList<string> emotionVariants)
{ {
var normalizedCondition = NormalizeCondition(condition); var normalizedCondition = NormalizeCondition(condition);
if (string.IsNullOrWhiteSpace(normalizedCondition)) if (string.IsNullOrWhiteSpace(normalizedCondition)) return false;
{
return false;
}
var clauses = normalizedCondition.Split(new[] { "||" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var clauses = normalizedCondition.Split(new[] { "||" },
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var clause in clauses) foreach (var clause in clauses)
{
if (MatchesConditionClause(clause, emotionVariants)) if (MatchesConditionClause(clause, emotionVariants))
{
return true; return true;
}
}
return false; return false;
} }
@@ -410,16 +400,11 @@ internal static class ChitchatStateMachine
{ {
var normalizedClause = NormalizeCondition(clause).ToUpperInvariant(); var normalizedClause = NormalizeCondition(clause).ToUpperInvariant();
if (normalizedClause == "!JIBO.EMOTION") if (normalizedClause == "!JIBO.EMOTION")
{
return emotionVariants.Contains(string.Empty, StringComparer.OrdinalIgnoreCase) || return emotionVariants.Contains(string.Empty, StringComparer.OrdinalIgnoreCase) ||
emotionVariants.Contains("NEUTRAL", StringComparer.OrdinalIgnoreCase); emotionVariants.Contains("NEUTRAL", StringComparer.OrdinalIgnoreCase);
}
var equalityIndex = normalizedClause.IndexOf("==", StringComparison.Ordinal); var equalityIndex = normalizedClause.IndexOf("==", StringComparison.Ordinal);
if (equalityIndex < 0) if (equalityIndex < 0) return false;
{
return false;
}
var rightSide = normalizedClause[(equalityIndex + 2)..].Trim(); var rightSide = normalizedClause[(equalityIndex + 2)..].Trim();
var candidate = rightSide.Trim('"', '\''); var candidate = rightSide.Trim('"', '\'');
@@ -428,10 +413,7 @@ internal static class ChitchatStateMachine
private static IReadOnlyList<string> ResolveEmotionVariants(string? currentEmotion) private static IReadOnlyList<string> ResolveEmotionVariants(string? currentEmotion)
{ {
if (string.IsNullOrWhiteSpace(currentEmotion)) if (string.IsNullOrWhiteSpace(currentEmotion)) return ["", "NEUTRAL"];
{
return ["", "NEUTRAL"];
}
var normalizedEmotion = NormalizeCondition(currentEmotion).Trim('"', '\'').ToUpperInvariant(); var normalizedEmotion = NormalizeCondition(currentEmotion).Trim('"', '\'').ToUpperInvariant();
return normalizedEmotion switch return normalizedEmotion switch
@@ -452,17 +434,11 @@ internal static class ChitchatStateMachine
{ {
foreach (var snippet in preferredSnippets) foreach (var snippet in preferredSnippets)
{ {
if (string.IsNullOrWhiteSpace(snippet)) if (string.IsNullOrWhiteSpace(snippet)) continue;
{
continue;
}
var match = catalog.PersonalityReplies.FirstOrDefault(reply => var match = catalog.PersonalityReplies.FirstOrDefault(reply =>
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase)); reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match)) if (!string.IsNullOrWhiteSpace(match)) return match;
{
return match;
}
} }
return randomizer.Choose(catalog.PersonalityReplies); return randomizer.Choose(catalog.PersonalityReplies);
@@ -470,25 +446,16 @@ internal static class ChitchatStateMachine
private static string NormalizeCondition(string? condition) private static string NormalizeCondition(string? condition)
{ {
if (string.IsNullOrWhiteSpace(condition)) return string.IsNullOrWhiteSpace(condition)
{ ? string.Empty
return string.Empty; : PhraseWhitespacePattern.Replace(condition.Trim(), " ");
}
return PhraseWhitespacePattern.Replace(condition.Trim(), " ");
} }
private static bool IsEmotionQuery(string loweredTranscript) private static bool IsEmotionQuery(string loweredTranscript)
{ {
if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases)) if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases)) return true;
{
return true;
}
if (!TryResolveEmotionFromText(loweredTranscript, out _)) if (!TryResolveEmotionFromText(loweredTranscript, out _)) return false;
{
return false;
}
return StartsWithAnyPhrase(loweredTranscript, EmotionQueryPrefixes) || return StartsWithAnyPhrase(loweredTranscript, EmotionQueryPrefixes) ||
StartsWithAnyPhrase(loweredTranscript, EmotionAssertionPrefixes); StartsWithAnyPhrase(loweredTranscript, EmotionAssertionPrefixes);
@@ -500,27 +467,20 @@ internal static class ChitchatStateMachine
foreach (var mapping in DirectEmotionCommandPhrases) foreach (var mapping in DirectEmotionCommandPhrases)
{ {
if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue;
{
continue;
}
emotion = mapping.Emotion; emotion = mapping.Emotion;
return true; return true;
} }
var isNegativeCommand = StartsWithAnyPhrase(loweredTranscript, EmotionCommandNegativePrefixes); var isNegativeCommand = StartsWithAnyPhrase(loweredTranscript, EmotionCommandNegativePrefixes);
var isPositiveCommand = !isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes); var isPositiveCommand =
if (!isNegativeCommand && !isPositiveCommand) !isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes);
{ if (!isNegativeCommand && !isPositiveCommand) return false;
return false;
}
if (!TryResolveEmotionFromText(loweredTranscript, out var canonicalEmotion) || if (!TryResolveEmotionFromText(loweredTranscript, out var canonicalEmotion) ||
string.IsNullOrWhiteSpace(canonicalEmotion)) string.IsNullOrWhiteSpace(canonicalEmotion))
{
return false; return false;
}
emotion = isNegativeCommand emotion = isNegativeCommand
? "calm" ? "calm"
@@ -544,10 +504,7 @@ internal static class ChitchatStateMachine
emotion = null; emotion = null;
foreach (var mapping in EmotionSynonymMappings) foreach (var mapping in EmotionSynonymMappings)
{ {
if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue;
{
continue;
}
emotion = mapping.Emotion; emotion = mapping.Emotion;
return true; return true;
@@ -559,12 +516,8 @@ internal static class ChitchatStateMachine
private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable<string> phrases) private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable<string> phrases)
{ {
foreach (var phrase in phrases) foreach (var phrase in phrases)
{
if (ContainsPhrase(loweredTranscript, phrase)) if (ContainsPhrase(loweredTranscript, phrase))
{
return true; return true;
}
}
return false; return false;
} }
@@ -574,17 +527,12 @@ internal static class ChitchatStateMachine
foreach (var phrase in phrases) foreach (var phrase in phrases)
{ {
var normalizedPhrase = NormalizeForPhraseMatching(phrase); var normalizedPhrase = NormalizeForPhraseMatching(phrase);
if (string.IsNullOrWhiteSpace(normalizedPhrase)) if (string.IsNullOrWhiteSpace(normalizedPhrase)) continue;
{
continue;
}
if (string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) || if (string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) ||
loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal)) loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal))
{
return true; return true;
} }
}
return false; return false;
} }
@@ -594,9 +542,7 @@ internal static class ChitchatStateMachine
var normalizedPhrase = NormalizeForPhraseMatching(phrase); var normalizedPhrase = NormalizeForPhraseMatching(phrase);
if (string.IsNullOrWhiteSpace(normalizedPhrase) || if (string.IsNullOrWhiteSpace(normalizedPhrase) ||
string.IsNullOrWhiteSpace(loweredTranscript)) string.IsNullOrWhiteSpace(loweredTranscript))
{
return false; return false;
}
return string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) || return string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) ||
loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal) || loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal) ||
@@ -606,10 +552,7 @@ internal static class ChitchatStateMachine
private static string NormalizeForPhraseMatching(string value) private static string NormalizeForPhraseMatching(string value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value)) return string.Empty;
{
return string.Empty;
}
var lowered = value.ToLowerInvariant(); var lowered = value.ToLowerInvariant();
var withoutPunctuation = PhrasePunctuationPattern.Replace(lowered, " "); var withoutPunctuation = PhrasePunctuationPattern.Replace(lowered, " ");
@@ -622,19 +565,15 @@ internal static class ChitchatStateMachine
var mappings = new List<(string Phrase, string Emotion)>(); var mappings = new List<(string Phrase, string Emotion)>();
foreach (var emotionMapping in PegasusEmotionSynonyms) foreach (var emotionMapping in PegasusEmotionSynonyms)
{
foreach (var synonym in emotionMapping.Synonyms) foreach (var synonym in emotionMapping.Synonyms)
{ {
var normalizedSynonym = NormalizeForPhraseMatching(synonym); var normalizedSynonym = NormalizeForPhraseMatching(synonym);
if (string.IsNullOrWhiteSpace(normalizedSynonym) || if (string.IsNullOrWhiteSpace(normalizedSynonym) ||
!seen.Add(normalizedSynonym)) !seen.Add(normalizedSynonym))
{
continue; continue;
}
mappings.Add((normalizedSynonym, emotionMapping.Emotion)); mappings.Add((normalizedSynonym, emotionMapping.Emotion));
} }
}
mappings.Sort(static (left, right) => right.Phrase.Length.CompareTo(left.Phrase.Length)); mappings.Sort(static (left, right) => right.Phrase.Length.CompareTo(left.Phrase.Length));
return [.. mappings]; return [.. mappings];

View File

@@ -49,24 +49,20 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
}; };
if (keepMicOpen) if (keepMicOpen)
{
plan.Actions.Add(new ListenAction plan.Actions.Add(new ListenAction
{ {
Sequence = 1, Sequence = 1,
Timeout = _followUpTimeout, Timeout = _followUpTimeout,
Mode = "follow-up" Mode = "follow-up"
}); });
}
if (!string.IsNullOrWhiteSpace(decision.SkillName)) if (!string.IsNullOrWhiteSpace(decision.SkillName))
{
plan.Actions.Add(new InvokeNativeSkillAction plan.Actions.Add(new InvokeNativeSkillAction
{ {
Sequence = 2, Sequence = 2,
SkillName = decision.SkillName, SkillName = decision.SkillName,
Payload = decision.SkillPayload ?? new Dictionary<string, object?>() Payload = decision.SkillPayload ?? new Dictionary<string, object?>()
}); });
}
return plan; return plan;
} }

View File

@@ -1,6 +1,5 @@
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions; using Jibo.Runtime.Abstractions;
using System.Linq;
namespace Jibo.Cloud.Application.Services; namespace Jibo.Cloud.Application.Services;
@@ -14,6 +13,32 @@ internal static class HouseholdListOrchestrator
private const string IdleState = "idle"; private const string IdleState = "idle";
private const string AwaitingItemState = "awaiting_item"; private const string AwaitingItemState = "awaiting_item";
private static readonly string[] ItemPrefixes =
[
"add ",
"put ",
"buy ",
"get ",
"remind me to ",
"i need to ",
"i need ",
"please add ",
"please put "
];
private static readonly string[] ItemSuffixes =
[
" to my shopping list",
" to the shopping list",
" on my shopping list",
" to my to do list",
" to the to do list",
" on my to do list",
" to my todo list",
" to the todo list",
" on my todo list"
];
public static Task<JiboInteractionDecision?> TryBuildDecisionAsync( public static Task<JiboInteractionDecision?> TryBuildDecisionAsync(
TurnContext turn, TurnContext turn,
string semanticIntent, string semanticIntent,
@@ -31,40 +56,29 @@ internal static class HouseholdListOrchestrator
var isTodoIntent = string.Equals(semanticIntent, "todo_list", StringComparison.OrdinalIgnoreCase); var isTodoIntent = string.Equals(semanticIntent, "todo_list", StringComparison.OrdinalIgnoreCase);
if (!isActiveState && !isShoppingIntent && !isTodoIntent) if (!isActiveState && !isShoppingIntent && !isTodoIntent)
{
return Task.FromResult<JiboInteractionDecision?>(null); return Task.FromResult<JiboInteractionDecision?>(null);
}
var resolvedListType = isShoppingIntent ? "shopping" : isTodoIntent ? "todo" : NormalizeListType(listType); var resolvedListType = isShoppingIntent ? "shopping" : isTodoIntent ? "todo" : NormalizeListType(listType);
if (string.IsNullOrWhiteSpace(resolvedListType)) if (string.IsNullOrWhiteSpace(resolvedListType)) resolvedListType = "shopping";
{
resolvedListType = "shopping";
}
var tenantScope = tenantScopeResolver(turn); var tenantScope = tenantScopeResolver(turn);
if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it")) if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it"))
{
return Task.FromResult<JiboInteractionDecision?>(BuildCancelledDecision(resolvedListType)); return Task.FromResult<JiboInteractionDecision?>(BuildCancelledDecision(resolvedListType));
}
if (IsRecallRequest(loweredTranscript)) if (IsRecallRequest(loweredTranscript))
{
return Task.FromResult<JiboInteractionDecision?>(BuildRecallDecision( return Task.FromResult<JiboInteractionDecision?>(BuildRecallDecision(
resolvedListType, resolvedListType,
personalMemoryStore.GetListItems(tenantScope, resolvedListType))); personalMemoryStore.GetListItems(tenantScope, resolvedListType)));
}
var directItem = TryExtractListItem(loweredTranscript); var directItem = TryExtractListItem(loweredTranscript);
if (string.IsNullOrWhiteSpace(directItem) && isActiveState) if (string.IsNullOrWhiteSpace(directItem) && isActiveState)
{ {
if (IsConversationComplete(loweredTranscript)) if (IsConversationComplete(loweredTranscript))
{
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision( return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_done" : "todo_list_done", resolvedListType == "shopping" ? "shopping_list_done" : "todo_list_done",
BuildDoneReply(resolvedListType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)), BuildDoneReply(resolvedListType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
ContextUpdates: BuildContextUpdates(resolvedListType, IdleState))); ContextUpdates: BuildContextUpdates(resolvedListType, IdleState)));
}
directItem = NormalizeItem(transcript); directItem = NormalizeItem(transcript);
} }
@@ -74,17 +88,16 @@ internal static class HouseholdListOrchestrator
personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem); personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem);
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision( return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_add" : "todo_list_add", resolvedListType == "shopping" ? "shopping_list_add" : "todo_list_add",
BuildAddedReply(resolvedListType, directItem, personalMemoryStore.GetListItems(tenantScope, resolvedListType)), BuildAddedReply(resolvedListType, directItem,
personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState)));
} }
if (string.IsNullOrWhiteSpace(transcript)) if (string.IsNullOrWhiteSpace(transcript))
{
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision( return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt",
BuildPromptReply(resolvedListType), BuildPromptReply(resolvedListType),
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState))); ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState)));
}
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision( return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt", resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt",
@@ -120,7 +133,6 @@ internal static class HouseholdListOrchestrator
private static JiboInteractionDecision BuildRecallDecision(string listType, IReadOnlyList<string> items) private static JiboInteractionDecision BuildRecallDecision(string listType, IReadOnlyList<string> items)
{ {
if (items.Count == 0) if (items.Count == 0)
{
return new JiboInteractionDecision( return new JiboInteractionDecision(
listType == "shopping" ? "shopping_list_recall" : "todo_list_recall", listType == "shopping" ? "shopping_list_recall" : "todo_list_recall",
listType == "shopping" listType == "shopping"
@@ -133,7 +145,6 @@ internal static class HouseholdListOrchestrator
[NoMatchCountMetadataKey] = 0, [NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0 [NoInputCountMetadataKey] = 0
}); });
}
return new JiboInteractionDecision( return new JiboInteractionDecision(
listType == "shopping" ? "shopping_list_recall" : "todo_list_recall", listType == "shopping" ? "shopping_list_recall" : "todo_list_recall",
@@ -167,11 +178,9 @@ internal static class HouseholdListOrchestrator
private static string BuildDoneReply(string listType, IReadOnlyList<string> items) private static string BuildDoneReply(string listType, IReadOnlyList<string> items)
{ {
if (items.Count == 0) if (items.Count == 0)
{
return listType == "shopping" return listType == "shopping"
? "Okay. Your shopping list is empty." ? "Okay. Your shopping list is empty."
: "Okay. Your to-do list is empty."; : "Okay. Your to-do list is empty.";
}
return listType == "shopping" return listType == "shopping"
? $"Okay. Your shopping list has {JoinList(items)}." ? $"Okay. Your shopping list has {JoinList(items)}."
@@ -193,10 +202,7 @@ internal static class HouseholdListOrchestrator
{ {
foreach (var prefix in ItemPrefixes) foreach (var prefix in ItemPrefixes)
{ {
if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
{
continue;
}
var remainder = loweredTranscript[prefix.Length..].Trim(); var remainder = loweredTranscript[prefix.Length..].Trim();
remainder = TrimTrailingListPhrases(remainder); remainder = TrimTrailingListPhrases(remainder);
@@ -224,12 +230,8 @@ internal static class HouseholdListOrchestrator
{ {
var result = value; var result = value;
foreach (var suffix in ItemSuffixes) foreach (var suffix in ItemSuffixes)
{
if (result.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) if (result.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
result = result[..^suffix.Length].Trim(); result = result[..^suffix.Length].Trim();
}
}
return result; return result;
} }
@@ -242,9 +244,11 @@ internal static class HouseholdListOrchestrator
private static string NormalizeListType(string? listType) private static string NormalizeListType(string? listType)
{ {
var normalized = NormalizeItem(listType ?? string.Empty).ToLowerInvariant(); var normalized = NormalizeItem(listType ?? string.Empty).ToLowerInvariant();
return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) || normalized.Contains("to do", StringComparison.OrdinalIgnoreCase) return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("to do", StringComparison.OrdinalIgnoreCase)
? "todo" ? "todo"
: normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) || normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase) : normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase)
? "shopping" ? "shopping"
: string.Empty; : string.Empty;
} }
@@ -270,30 +274,4 @@ internal static class HouseholdListOrchestrator
{ {
return turn.Attributes.TryGetValue(key, out var value) ? value?.ToString() : null; return turn.Attributes.TryGetValue(key, out var value) ? value?.ToString() : null;
} }
private static readonly string[] ItemPrefixes =
[
"add ",
"put ",
"buy ",
"get ",
"remind me to ",
"i need to ",
"i need ",
"please add ",
"please put "
];
private static readonly string[] ItemSuffixes =
[
" to my shopping list",
" to the shopping list",
" on my shopping list",
" to my to do list",
" to the to do list",
" on my to do list",
" to my todo list",
" to the todo list",
" on my todo list"
];
} }

View File

@@ -14,97 +14,68 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
"localhost" "localhost"
]; ];
public Task<ProtocolDispatchResult> DispatchAsync(ProtocolEnvelope envelope, CancellationToken cancellationToken = default) public Task<ProtocolDispatchResult> DispatchAsync(ProtocolEnvelope envelope,
CancellationToken cancellationToken = default)
{ {
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) && if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
envelope.Path == "/" && envelope.Path == "/" &&
string.IsNullOrWhiteSpace(envelope.ServicePrefix)) string.IsNullOrWhiteSpace(envelope.ServicePrefix))
{
return Task.FromResult(ProtocolDispatchResult.NoContent()); return Task.FromResult(ProtocolDispatchResult.NoContent());
}
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) && if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
envelope.Path.Equals("/health", StringComparison.OrdinalIgnoreCase)) envelope.Path.Equals("/health", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(ProtocolDispatchResult.Ok(new { ok = true, host = envelope.HostName })); return Task.FromResult(ProtocolDispatchResult.Ok(new { ok = true, host = envelope.HostName }));
}
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) && if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
envelope.Path.StartsWith("/media/", StringComparison.OrdinalIgnoreCase)) envelope.Path.StartsWith("/media/", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleMediaContent(envelope)); return Task.FromResult(HandleMediaContent(envelope));
}
if (envelope.Method.Equals("PUT", StringComparison.OrdinalIgnoreCase) && if (envelope.Method.Equals("PUT", StringComparison.OrdinalIgnoreCase) &&
(envelope.Path.Equals("/upload/asr-binary", StringComparison.OrdinalIgnoreCase) || (envelope.Path.Equals("/upload/asr-binary", StringComparison.OrdinalIgnoreCase) ||
envelope.Path.Equals("/upload/log-events", StringComparison.OrdinalIgnoreCase) || envelope.Path.Equals("/upload/log-events", StringComparison.OrdinalIgnoreCase) ||
envelope.Path.Equals("/upload/log-binary", StringComparison.OrdinalIgnoreCase))) envelope.Path.Equals("/upload/log-binary", StringComparison.OrdinalIgnoreCase)))
{
return Task.FromResult(ProtocolDispatchResult.Raw(200, string.Empty)); return Task.FromResult(ProtocolDispatchResult.Raw(200, string.Empty));
}
if (!AcceptedHosts.Contains(envelope.HostName, StringComparer.OrdinalIgnoreCase)) if (!AcceptedHosts.Contains(envelope.HostName, StringComparer.OrdinalIgnoreCase))
{
return Task.FromResult(ProtocolDispatchResult.Ok(new return Task.FromResult(ProtocolDispatchResult.Ok(new
{ {
ok = true, ok = true,
accepted = false, accepted = false,
host = envelope.HostName host = envelope.HostName
})); }));
}
var servicePrefix = envelope.ServicePrefix ?? string.Empty; var servicePrefix = envelope.ServicePrefix ?? string.Empty;
var operation = envelope.Operation ?? string.Empty; var operation = envelope.Operation ?? string.Empty;
if (servicePrefix.StartsWith("Log_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Log_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleLog(operation, envelope)); return Task.FromResult(HandleLog(operation, envelope));
}
if (servicePrefix.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleBackup(operation)); return Task.FromResult(HandleBackup(operation));
}
if (servicePrefix.StartsWith("Account_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Account_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleAccount(operation, envelope)); return Task.FromResult(HandleAccount(operation, envelope));
}
if (servicePrefix.StartsWith("Notification_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Notification_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleNotification(operation, envelope)); return Task.FromResult(HandleNotification(operation, envelope));
}
if (servicePrefix.StartsWith("Loop_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Loop_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleLoop(operation)); return Task.FromResult(HandleLoop(operation));
}
if (servicePrefix.Equals("Media_20160725", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.Equals("Media_20160725", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleMedia(operation, envelope)); return Task.FromResult(HandleMedia(operation, envelope));
}
if (servicePrefix.StartsWith("Key_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Key_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleKey(operation, envelope)); return Task.FromResult(HandleKey(operation, envelope));
}
if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandlePerson(operation)); return Task.FromResult(HandlePerson(operation));
}
if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleRobot(operation, envelope)); return Task.FromResult(HandleRobot(operation, envelope));
}
if (servicePrefix.StartsWith("Update_", StringComparison.OrdinalIgnoreCase)) if (servicePrefix.StartsWith("Update_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleUpdate(operation, envelope)); return Task.FromResult(HandleUpdate(operation, envelope));
}
return Task.FromResult(ProtocolDispatchResult.Ok(new return Task.FromResult(ProtocolDispatchResult.Ok(new
{ {
@@ -122,22 +93,18 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var body = envelope.TryParseBody(); var body = envelope.TryParseBody();
if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
token = stateStore.IssueHubToken(), token = stateStore.IssueHubToken(),
expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()
}); });
}
if (operation.Equals("CreateAccessToken", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("CreateAccessToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
token = $"access-{account.AccountId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", token = $"access-{account.AccountId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()
}); });
}
if (operation.Equals("CheckEmail", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("CheckEmail", StringComparison.OrdinalIgnoreCase))
{ {
@@ -149,7 +116,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
} }
if (operation is "Create" or "Login") if (operation is "Create" or "Login")
{
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
id = account.AccountId, id = account.AccountId,
@@ -168,17 +134,13 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
facebookConnected = false, facebookConnected = false,
termsAccepted = true termsAccepted = true
}); });
}
if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase))
{ {
var ids = ReadStringArray(body, "ids"); var ids = ReadStringArray(body, "ids");
var matches = ids.Count == 0 || ids.Contains(account.AccountId, StringComparer.OrdinalIgnoreCase); var matches = ids.Count == 0 || ids.Contains(account.AccountId, StringComparer.OrdinalIgnoreCase);
if (!matches) if (!matches) return ProtocolDispatchResult.Ok(Array.Empty<object>());
{
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
return ProtocolDispatchResult.Ok(new[] return ProtocolDispatchResult.Ok(new[]
{ {
@@ -216,7 +178,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
} }
if (operation.Equals("GetAccountByAccessToken", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("GetAccountByAccessToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
id = account.AccountId, id = account.AccountId,
@@ -226,12 +187,12 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
friendlyId = stateStore.GetRobot().RobotId, friendlyId = stateStore.GetRobot().RobotId,
payload = ReadObject(body, "payload") payload = ReadObject(body, "payload")
}); });
}
if (operation.Equals("Search", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("Search", StringComparison.OrdinalIgnoreCase))
{ {
var query = (ReadString(body, "query") ?? string.Empty).ToLowerInvariant(); var query = (ReadString(body, "query") ?? string.Empty).ToLowerInvariant();
var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}".ToLowerInvariant(); var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}"
.ToLowerInvariant();
return ProtocolDispatchResult.Ok(query.Length > 0 && haystack.Contains(query) return ProtocolDispatchResult.Ok(query.Length > 0 && haystack.Contains(query)
? ?
@@ -248,7 +209,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
} }
if (operation.Equals("FacebookPrepareLogin", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("FacebookPrepareLogin", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
url = "https://example.com/facebook-login", url = "https://example.com/facebook-login",
@@ -258,12 +218,9 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
state = $"fb-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", state = $"fb-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
redirect_uri = "https://api.jibo.com/facebook/callback" redirect_uri = "https://api.jibo.com/facebook/callback"
}); });
}
if (operation.Equals("ConfirmEmailReset", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("ConfirmEmailReset", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new { }); return ProtocolDispatchResult.Ok(new { });
}
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
@@ -277,9 +234,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private ProtocolDispatchResult HandleNotification(string operation, ProtocolEnvelope envelope) private ProtocolDispatchResult HandleNotification(string operation, ProtocolEnvelope envelope)
{ {
if (!operation.Equals("NewRobotToken", StringComparison.OrdinalIgnoreCase)) if (!operation.Equals("NewRobotToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new { ok = true, operation }); return ProtocolDispatchResult.Ok(new { ok = true, operation });
}
var body = envelope.TryParseBody(); var body = envelope.TryParseBody();
var deviceId = !string.IsNullOrWhiteSpace(envelope.DeviceId) var deviceId = !string.IsNullOrWhiteSpace(envelope.DeviceId)
@@ -302,10 +257,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private ProtocolDispatchResult HandleLoop(string operation) private ProtocolDispatchResult HandleLoop(string operation)
{ {
if (operation is not ("List" or "ListLoops")) if (operation is not ("List" or "ListLoops")) return ProtocolDispatchResult.Ok(Array.Empty<object>());
{
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
return ProtocolDispatchResult.Ok(stateStore.GetLoops().Select(loop => new return ProtocolDispatchResult.Ok(stateStore.GetLoops().Select(loop => new
{ {
@@ -363,41 +315,35 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var body = envelope.TryParseBody(); var body = envelope.TryParseBody();
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.ListMedia( return ProtocolDispatchResult.Ok(stateStore.ListMedia(
ReadStringArray(body, "loopIds"), ReadStringArray(body, "loopIds"),
ReadLong(body, "after"), ReadLong(body, "after"),
ReadLong(body, "before")).Select(MapMedia).ToArray()); ReadLong(body, "before")).Select(MapMedia).ToArray());
}
if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase))
{ return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia)
return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray()); .ToArray());
}
if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase))
{ return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia)
return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray()); .ToArray());
}
if (!operation.Equals("Create", StringComparison.OrdinalIgnoreCase)) if (!operation.Equals("Create", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(Array.Empty<object>()); return ProtocolDispatchResult.Ok(Array.Empty<object>());
var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? stateStore.GetLoops()[0].LoopId; var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? stateStore.GetLoops()[0].LoopId;
var path = ReadHeader(envelope, "x-path") ?? ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; var path = ReadHeader(envelope, "x-path") ??
ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown"; var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown";
var reference = ReadHeader(envelope, "x-reference") ?? ReadString(body, "reference") ?? string.Empty; var reference = ReadHeader(envelope, "x-reference") ?? ReadString(body, "reference") ?? string.Empty;
var isEncrypted = ReadBooleanHeader(envelope, "x-encrypted") || ReadBool(body, "isEncrypted"); var isEncrypted = ReadBooleanHeader(envelope, "x-encrypted") || ReadBool(body, "isEncrypted");
var meta = ReadObject(body, "meta") ?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase); var meta = ReadObject(body, "meta") ?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
var contentType = ReadHeader(envelope, "Content-Type") ?? "application/octet-stream"; var contentType = ReadHeader(envelope, "Content-Type") ?? "application/octet-stream";
meta["contentType"] = contentType; meta["contentType"] = contentType;
if (!string.IsNullOrWhiteSpace(envelope.BodyText)) if (!string.IsNullOrWhiteSpace(envelope.BodyText)) meta["bodyText"] = envelope.BodyText;
{
meta["bodyText"] = envelope.BodyText;
}
return ProtocolDispatchResult.Ok(MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
return ProtocolDispatchResult.Ok(
MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
} }
private ProtocolDispatchResult HandlePerson(string operation) private ProtocolDispatchResult HandlePerson(string operation)
@@ -420,12 +366,10 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? stateStore.GetLoops()[0].LoopId; var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? stateStore.GetLoops()[0].LoopId;
if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
shouldCreate = stateStore.ShouldCreateSymmetricKey(loopId) shouldCreate = stateStore.ShouldCreateSymmetricKey(loopId)
}); });
}
string? symmetricKey; string? symmetricKey;
if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase))
@@ -451,24 +395,17 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
} }
if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase))
{ return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"),
return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"), ReadString(body, "publicKey"))); ReadString(body, "publicKey")));
}
if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetIncomingKeyRequests()); return ProtocolDispatchResult.Ok(stateStore.GetIncomingKeyRequests());
}
if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetBinaryRequests()); return ProtocolDispatchResult.Ok(stateStore.GetBinaryRequests());
}
if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary") if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary")
{
return ProtocolDispatchResult.Ok(new { ok = true }); return ProtocolDispatchResult.Ok(new { ok = true });
}
if (!operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase)) if (!operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(new { ok = true, operation }); return ProtocolDispatchResult.Ok(new { ok = true, operation });
@@ -480,7 +417,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
key = symmetricKey, key = symmetricKey,
symmetricKey symmetricKey
}); });
} }
private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope) private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope)
@@ -521,7 +457,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
updated = profile.UpdatedUtc.ToUnixTimeMilliseconds(), updated = profile.UpdatedUtc.ToUnixTimeMilliseconds(),
created = profile.CreatedUtc.ToUnixTimeMilliseconds() created = profile.CreatedUtc.ToUnixTimeMilliseconds()
}); });
} }
private ProtocolDispatchResult HandleUpdate(string operation, ProtocolEnvelope envelope) private ProtocolDispatchResult HandleUpdate(string operation, ProtocolEnvelope envelope)
@@ -533,9 +468,11 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
return operation switch return operation switch
{ {
"ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate).ToArray()), "ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate)
.ToArray()),
"ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter) "ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter)
.Where(update => fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase)) .Where(update =>
fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase))
.Select(MapUpdate) .Select(MapUpdate)
.ToArray()), .ToArray()),
"GetUpdateFrom" => HandleGetUpdateFrom(subsystem, fromVersion, filter), "GetUpdateFrom" => HandleGetUpdateFrom(subsystem, fromVersion, filter),
@@ -558,10 +495,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var path = Uri.UnescapeDataString(envelope.Path["/media/".Length..]); var path = Uri.UnescapeDataString(envelope.Path["/media/".Length..]);
var candidatePaths = new[] { path, $"/{path}" }; var candidatePaths = new[] { path, $"/{path}" };
var media = stateStore.GetMedia(candidatePaths).FirstOrDefault(); var media = stateStore.GetMedia(candidatePaths).FirstOrDefault();
if (media is null || media.IsDeleted) if (media is null || media.IsDeleted) return ProtocolDispatchResult.Raw(404, string.Empty);
{
return ProtocolDispatchResult.Raw(404, string.Empty);
}
var contentType = TryReadMetaString(media.Meta, "contentType") ?? "application/octet-stream"; var contentType = TryReadMetaString(media.Meta, "contentType") ?? "application/octet-stream";
var bodyText = TryReadMetaString(media.Meta, "bodyText") ?? string.Empty; var bodyText = TryReadMetaString(media.Meta, "bodyText") ?? string.Empty;
@@ -623,10 +557,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private static string? ReadString(JsonElement? element, string propertyName) private static string? ReadString(JsonElement? element, string propertyName)
{ {
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null;
{
return null;
}
return property.ValueKind == JsonValueKind.String return property.ValueKind == JsonValueKind.String
? property.GetString() ? property.GetString()
@@ -635,25 +566,16 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private static long? ReadLong(JsonElement? element, string propertyName) private static long? ReadLong(JsonElement? element, string propertyName)
{ {
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null;
{
return null;
}
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number)) if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number)) return number;
{
return number;
}
return long.TryParse(property.ToString(), out var parsed) ? parsed : null; return long.TryParse(property.ToString(), out var parsed) ? parsed : null;
} }
private static bool ReadBool(JsonElement? element, string propertyName) private static bool ReadBool(JsonElement? element, string propertyName)
{ {
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return false;
{
return false;
}
return property.ValueKind switch return property.ValueKind switch
{ {
@@ -665,31 +587,26 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private static IReadOnlyList<string> ReadStringArray(JsonElement? element, string propertyName) private static IReadOnlyList<string> ReadStringArray(JsonElement? element, string propertyName)
{ {
if (element is null || !element.Value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) if (element is null || !element.Value.TryGetProperty(propertyName, out var property) ||
{ property.ValueKind != JsonValueKind.Array) return [];
return [];
}
return [.. property.EnumerateArray() return
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString()) [
.Where(item => !string.IsNullOrWhiteSpace(item))]; .. property.EnumerateArray()
.Select(item =>
item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString())
.Where(item => !string.IsNullOrWhiteSpace(item))
];
} }
private static IDictionary<string, object?>? ReadObject(JsonElement? element, string propertyName) private static IDictionary<string, object?>? ReadObject(JsonElement? element, string propertyName)
{ {
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null;
{
return null;
}
if (property.ValueKind != JsonValueKind.Object) if (property.ValueKind != JsonValueKind.Object) return null;
{
return null;
}
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase); var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var child in property.EnumerateObject()) foreach (var child in property.EnumerateObject())
{
result[child.Name] = child.Value.ValueKind switch result[child.Name] = child.Value.ValueKind switch
{ {
JsonValueKind.String => child.Value.GetString(), JsonValueKind.String => child.Value.GetString(),
@@ -699,7 +616,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
JsonValueKind.False => false, JsonValueKind.False => false,
_ => child.Value.ToString() _ => child.Value.ToString()
}; };
}
return result; return result;
} }

View File

@@ -9,10 +9,7 @@ public sealed class JiboExperienceContentCache(IJiboExperienceContentRepository
public async Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default) public async Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{ {
if (_catalog is not null) if (_catalog is not null) return _catalog;
{
return _catalog;
}
await _gate.WaitAsync(cancellationToken); await _gate.WaitAsync(cancellationToken);
try try

View File

@@ -15,7 +15,8 @@ public sealed class JiboWebSocketService(
stateStore.OpenSession(envelope.Kind, null, envelope.Token, envelope.HostName, envelope.Path); stateStore.OpenSession(envelope.Kind, null, envelope.Token, envelope.HostName, envelope.Path);
} }
public async Task<IReadOnlyList<WebSocketReply>> HandleMessageAsync(WebSocketMessageEnvelope envelope, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<WebSocketReply>> HandleMessageAsync(WebSocketMessageEnvelope envelope,
CancellationToken cancellationToken = default)
{ {
var session = GetOrCreateSession(envelope); var session = GetOrCreateSession(envelope);
session.LastSeenUtc = DateTimeOffset.UtcNow; session.LastSeenUtc = DateTimeOffset.UtcNow;
@@ -23,7 +24,8 @@ public sealed class JiboWebSocketService(
if (envelope.IsBinary) if (envelope.IsBinary)
{ {
var replies = await turnFinalizationService.HandleBinaryAudioAsync(session, envelope, cancellationToken); var replies = await turnFinalizationService.HandleBinaryAudioAsync(session, envelope, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received", new Dictionary<string, object?> await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received",
new Dictionary<string, object?>
{ {
["bytes"] = envelope.Binary?.Length ?? 0, ["bytes"] = envelope.Binary?.Length ?? 0,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
@@ -50,7 +52,8 @@ public sealed class JiboWebSocketService(
}) })
.ToArray(); .ToArray();
await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored", new Dictionary<string, object?> await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored",
new Dictionary<string, object?>
{ {
["messageType"] = parsedType, ["messageType"] = parsedType,
["activeTransID"] = session.TurnState.TransId, ["activeTransID"] = session.TurnState.TransId,
@@ -65,7 +68,8 @@ public sealed class JiboWebSocketService(
WebSocketTurnFinalizationService.TryRecoverStalePendingListen(session, out staleListenAgeMs)) WebSocketTurnFinalizationService.TryRecoverStalePendingListen(session, out staleListenAgeMs))
{ {
staleListenRecovered = true; staleListenRecovered = true;
await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered", new Dictionary<string, object?> await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered",
new Dictionary<string, object?>
{ {
["staleAgeMs"] = staleListenAgeMs, ["staleAgeMs"] = staleListenAgeMs,
["transID"] = session.TurnState.TransId, ["transID"] = session.TurnState.TransId,
@@ -80,7 +84,8 @@ public sealed class JiboWebSocketService(
case "CONTEXT": case "CONTEXT":
{ {
var replies = await turnFinalizationService.HandleContextAsync(session, envelope, cancellationToken); var replies = await turnFinalizationService.HandleContextAsync(session, envelope, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received", new Dictionary<string, object?> await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received",
new Dictionary<string, object?>
{ {
["transID"] = session.TurnState.TransId, ["transID"] = session.TurnState.TransId,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session) ["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
@@ -92,7 +97,8 @@ public sealed class JiboWebSocketService(
var replies = containsInlineTurnPayload var replies = containsInlineTurnPayload
? await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken) ? await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken)
: WebSocketTurnFinalizationService.HandleListenSetup(session, envelope); : WebSocketTurnFinalizationService.HandleListenSetup(session, envelope);
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?> await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed",
new Dictionary<string, object?>
{ {
["messageType"] = parsedType, ["messageType"] = parsedType,
["replyCount"] = replies.Count, ["replyCount"] = replies.Count,
@@ -106,8 +112,10 @@ public sealed class JiboWebSocketService(
} }
case "CLIENT_NLU" or "CLIENT_ASR" or "TRIGGER": case "CLIENT_NLU" or "CLIENT_ASR" or "TRIGGER":
{ {
var replies = await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken); var replies =
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?> await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed",
new Dictionary<string, object?>
{ {
["messageType"] = parsedType, ["messageType"] = parsedType,
["replyCount"] = replies.Count, ["replyCount"] = replies.Count,
@@ -124,19 +132,14 @@ public sealed class JiboWebSocketService(
private static string ReadMessageType(string? text) private static string ReadMessageType(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text)) return "UNKNOWN";
{
return "UNKNOWN";
}
try try
{ {
using var document = JsonDocument.Parse(text); using var document = JsonDocument.Parse(text);
if (document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String) if (document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String)
{
return type.GetString() ?? "UNKNOWN"; return type.GetString() ?? "UNKNOWN";
} }
}
catch catch
{ {
return "TEXT"; return "TEXT";
@@ -147,25 +150,18 @@ public sealed class JiboWebSocketService(
private static bool ContainsInlineTurnPayload(string? text) private static bool ContainsInlineTurnPayload(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text)) return false;
{
return false;
}
try try
{ {
using var document = JsonDocument.Parse(text); using var document = JsonDocument.Parse(text);
if (!document.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) if (!document.RootElement.TryGetProperty("data", out var data) ||
{ data.ValueKind != JsonValueKind.Object) return false;
return false;
}
if (data.TryGetProperty("text", out var transcript) && if (data.TryGetProperty("text", out var transcript) &&
transcript.ValueKind == JsonValueKind.String && transcript.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(transcript.GetString())) !string.IsNullOrWhiteSpace(transcript.GetString()))
{
return true; return true;
}
return data.TryGetProperty("asr", out var asr) && return data.TryGetProperty("asr", out var asr) &&
asr.ValueKind == JsonValueKind.Object && asr.ValueKind == JsonValueKind.Object &&
@@ -186,10 +182,7 @@ public sealed class JiboWebSocketService(
var transId = session.TurnState.TransId ?? session.LastTransId ?? string.Empty; var transId = session.TurnState.TransId ?? session.LastTransId ?? string.Empty;
var rules = session.TurnState.ListenRules; var rules = session.TurnState.ListenRules;
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text)) return (transId, rules);
{
return (transId, rules);
}
try try
{ {
@@ -199,9 +192,7 @@ public sealed class JiboWebSocketService(
if (root.TryGetProperty("transID", out var transIdValue) && if (root.TryGetProperty("transID", out var transIdValue) &&
transIdValue.ValueKind == JsonValueKind.String && transIdValue.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(transIdValue.GetString())) !string.IsNullOrWhiteSpace(transIdValue.GetString()))
{
transId = transIdValue.GetString()!; transId = transIdValue.GetString()!;
}
if (root.TryGetProperty("data", out var data) && if (root.TryGetProperty("data", out var data) &&
data.ValueKind == JsonValueKind.Object && data.ValueKind == JsonValueKind.Object &&
@@ -214,10 +205,7 @@ public sealed class JiboWebSocketService(
.Where(static rule => !string.IsNullOrWhiteSpace(rule)) .Where(static rule => !string.IsNullOrWhiteSpace(rule))
.ToArray(); .ToArray();
if (parsedRules.Length > 0) if (parsedRules.Length > 0) rules = parsedRules;
{
rules = parsedRules;
}
} }
} }
catch catch

View File

@@ -5,5 +5,9 @@ namespace Jibo.Cloud.Application.Services;
public sealed class NullProtocolTelemetrySink : IProtocolTelemetrySink public sealed class NullProtocolTelemetrySink : IProtocolTelemetrySink
{ {
public Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
} }

View File

@@ -4,7 +4,14 @@ namespace Jibo.Cloud.Application.Services;
public sealed class NullTurnTelemetrySink : ITurnTelemetrySink public sealed class NullTurnTelemetrySink : ITurnTelemetrySink
{ {
public Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details,
CancellationToken cancellationToken = default)
public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) => Task.CompletedTask; {
return Task.CompletedTask;
}
public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
} }

View File

@@ -5,9 +5,33 @@ namespace Jibo.Cloud.Application.Services;
public sealed class NullWebSocketTelemetrySink : IWebSocketTelemetrySink public sealed class NullWebSocketTelemetrySink : IWebSocketTelemetrySink
{ {
public Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session,
public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default) => Task.CompletedTask; CancellationToken cancellationToken = default)
public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default) => Task.CompletedTask; {
public Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default) => Task.CompletedTask; return Task.CompletedTask;
public Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, CancellationToken cancellationToken = default) => Task.CompletedTask; }
public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType,
IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session,
IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
} }

View File

@@ -12,5 +12,6 @@ public static class OpenJiboCloudBuildInfo
public static string SpokenVersion => $"Cloud version {VersionWords}."; public static string SpokenVersion => $"Cloud version {VersionWords}.";
public static string EsmlVersion => $"Cloud version<break time='10ms'/> {VersionWords.Replace(" ", "<break time='10ms' />")}."; public static string EsmlVersion =>
$"Cloud version<break time='10ms'/> {VersionWords.Replace(" ", "<break time='10ms' />")}.";
} }

View File

@@ -1,7 +1,7 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services; namespace Jibo.Cloud.Application.Services;
@@ -58,6 +58,8 @@ internal static class PersonalReportOrchestrator
"maybe later" "maybe later"
]; ];
private static readonly Regex NameNoiseRegex = new("[^a-zA-Z\\-\\s']", RegexOptions.Compiled);
public static async Task<JiboInteractionDecision?> TryBuildDecisionAsync( public static async Task<JiboInteractionDecision?> TryBuildDecisionAsync(
TurnContext turn, TurnContext turn,
string semanticIntent, string semanticIntent,
@@ -72,31 +74,26 @@ internal static class PersonalReportOrchestrator
{ {
var state = ReadState(turn); var state = ReadState(turn);
var isActiveState = !string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase); var isActiveState = !string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase);
if (!isActiveState && !string.Equals(semanticIntent, "personal_report", StringComparison.OrdinalIgnoreCase)) if (!isActiveState &&
{ !string.Equals(semanticIntent, "personal_report", StringComparison.OrdinalIgnoreCase)) return null;
return null;
}
var toggles = ApplyInlineToggleHints( var toggles = ApplyInlineToggleHints(
ReadServiceToggles(turn), ReadServiceToggles(turn),
loweredTranscript, loweredTranscript,
out var inlineToggleSummary); out var inlineToggleSummary);
if (ContainsAnyPhrase(loweredTranscript, CancelPhrases)) if (ContainsAnyPhrase(loweredTranscript, CancelPhrases)) return BuildCancelledDecision(toggles);
{
return BuildCancelledDecision(toggles);
}
if (!isActiveState) if (!isActiveState)
{ {
var contextUpdates = BuildContextUpdates( var contextUpdates = BuildContextUpdates(
AwaitingOptInState, AwaitingOptInState,
noMatchCount: 0, 0,
noInputCount: 0, 0,
toggles, toggles,
userName: ReadString(turn, UserNameMetadataKey), ReadString(turn, UserNameMetadataKey),
userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false, ReadBool(turn, UserVerifiedMetadataKey) ?? false,
lastServiceError: string.Empty); string.Empty);
var reply = string.IsNullOrWhiteSpace(inlineToggleSummary) var reply = string.IsNullOrWhiteSpace(inlineToggleSummary)
? "Would you like your personal report now?" ? "Would you like your personal report now?"
@@ -108,10 +105,7 @@ internal static class PersonalReportOrchestrator
ContextUpdates: contextUpdates); ContextUpdates: contextUpdates);
} }
if (string.IsNullOrWhiteSpace(loweredTranscript)) if (string.IsNullOrWhiteSpace(loweredTranscript)) return BuildNoInputDecision(turn, state, toggles);
{
return BuildNoInputDecision(turn, state, toggles);
}
switch (state) switch (state)
{ {
@@ -121,81 +115,71 @@ internal static class PersonalReportOrchestrator
var scope = tenantScopeResolver(turn); var scope = tenantScopeResolver(turn);
var knownName = ReadString(turn, UserNameMetadataKey) ?? personalMemoryStore.GetName(scope); var knownName = ReadString(turn, UserNameMetadataKey) ?? personalMemoryStore.GetName(scope);
if (!string.IsNullOrWhiteSpace(knownName)) if (!string.IsNullOrWhiteSpace(knownName))
{
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_verify_user", "personal_report_verify_user",
$"I think this is {knownName}. Is that right?", $"I think this is {knownName}. Is that right?",
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
AwaitingIdentityConfirmationState, AwaitingIdentityConfirmationState,
noMatchCount: 0, 0,
noInputCount: 0, 0,
toggles, toggles,
userName: knownName, knownName,
userVerified: false, false,
lastServiceError: string.Empty)); string.Empty));
}
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_request_name", "personal_report_request_name",
"Who is this?", "Who is this?",
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
AwaitingIdentityNameState, AwaitingIdentityNameState,
noMatchCount: 0, 0,
noInputCount: 0, 0,
toggles, toggles,
userName: null, null,
userVerified: false, false,
lastServiceError: string.Empty)); string.Empty));
} }
if (IsNegativeReply(loweredTranscript)) if (IsNegativeReply(loweredTranscript)) return BuildDeclinedDecision(toggles);
{
return BuildDeclinedDecision(toggles);
}
if (!string.IsNullOrWhiteSpace(inlineToggleSummary)) if (!string.IsNullOrWhiteSpace(inlineToggleSummary))
{
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_opt_in", "personal_report_opt_in",
$"{inlineToggleSummary} Would you like your personal report now?", $"{inlineToggleSummary} Would you like your personal report now?",
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
AwaitingOptInState, AwaitingOptInState,
noMatchCount: 0, 0,
noInputCount: 0, 0,
toggles, toggles,
userName: ReadString(turn, UserNameMetadataKey), ReadString(turn, UserNameMetadataKey),
userVerified: false, false,
lastServiceError: string.Empty)); string.Empty));
}
return BuildNoMatchDecision( return BuildNoMatchDecision(
turn, turn,
state, state,
"Please say yes to start your personal report, or no to skip it.", "Please say yes to start your personal report, or no to skip it.",
toggles, toggles,
userName: ReadString(turn, UserNameMetadataKey), ReadString(turn, UserNameMetadataKey),
userVerified: false); false);
case AwaitingIdentityConfirmationState: case AwaitingIdentityConfirmationState:
{ {
var currentName = ReadString(turn, UserNameMetadataKey); var currentName = ReadString(turn, UserNameMetadataKey);
if (string.IsNullOrWhiteSpace(currentName)) if (string.IsNullOrWhiteSpace(currentName))
{
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_request_name", "personal_report_request_name",
"Who is this?", "Who is this?",
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
AwaitingIdentityNameState, AwaitingIdentityNameState,
noMatchCount: 0, 0,
noInputCount: 0, 0,
toggles, toggles,
userName: null, null,
userVerified: false, false,
lastServiceError: string.Empty)); string.Empty));
}
if (IsAffirmativeReply(loweredTranscript)) if (IsAffirmativeReply(loweredTranscript))
{
return await BuildDeliveredReportDecisionAsync( return await BuildDeliveredReportDecisionAsync(
turn, turn,
catalog, catalog,
@@ -204,45 +188,40 @@ internal static class PersonalReportOrchestrator
currentName, currentName,
buildWeatherDecisionAsync, buildWeatherDecisionAsync,
cancellationToken); cancellationToken);
}
if (IsNegativeReply(loweredTranscript)) if (IsNegativeReply(loweredTranscript))
{
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_request_name", "personal_report_request_name",
"Okay, who is this?", "Okay, who is this?",
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
AwaitingIdentityNameState, AwaitingIdentityNameState,
noMatchCount: 0, 0,
noInputCount: 0, 0,
toggles, toggles,
userName: null, null,
userVerified: false, false,
lastServiceError: string.Empty)); string.Empty));
}
return BuildNoMatchDecision( return BuildNoMatchDecision(
turn, turn,
state, state,
$"Please answer yes or no. Is this {currentName}?", $"Please answer yes or no. Is this {currentName}?",
toggles, toggles,
userName: currentName, currentName,
userVerified: false); false);
} }
case AwaitingIdentityNameState: case AwaitingIdentityNameState:
{ {
var parsedName = TryExtractName(loweredTranscript); var parsedName = TryExtractName(loweredTranscript);
if (string.IsNullOrWhiteSpace(parsedName)) if (string.IsNullOrWhiteSpace(parsedName))
{
return BuildNoMatchDecision( return BuildNoMatchDecision(
turn, turn,
state, state,
"Tell me your name like this: my name is Alex.", "Tell me your name like this: my name is Alex.",
toggles, toggles,
userName: null, null,
userVerified: false); false);
}
personalMemoryStore.SetName(tenantScopeResolver(turn), parsedName); personalMemoryStore.SetName(tenantScopeResolver(turn), parsedName);
return await BuildDeliveredReportDecisionAsync( return await BuildDeliveredReportDecisionAsync(
@@ -284,10 +263,7 @@ internal static class PersonalReportOrchestrator
reportSections.Add("First, your weather."); reportSections.Add("First, your weather.");
var weatherDecision = await buildWeatherDecisionAsync(turn, "weather", cancellationToken); var weatherDecision = await buildWeatherDecisionAsync(turn, "weather", cancellationToken);
reportSections.Add(weatherDecision.ReplyText); reportSections.Add(weatherDecision.ReplyText);
if (IsWeatherErrorReply(weatherDecision.ReplyText)) if (IsWeatherErrorReply(weatherDecision.ReplyText)) serviceError = "weather";
{
serviceError = "weather";
}
} }
if (toggles.CalendarEnabled) if (toggles.CalendarEnabled)
@@ -309,7 +285,6 @@ internal static class PersonalReportOrchestrator
} }
if (toggles.CommuteEnabled) if (toggles.CommuteEnabled)
{
reportSections.Add( reportSections.Add(
RenderReportSkillTemplate( RenderReportSkillTemplate(
ChooseReportSkillTemplate( ChooseReportSkillTemplate(
@@ -317,7 +292,6 @@ internal static class PersonalReportOrchestrator
catalog.CommuteNowReplies, catalog.CommuteNowReplies,
"Sorry, commute information isn't available right now."), "Sorry, commute information isn't available right now."),
userName)); userName));
}
if (toggles.NewsEnabled) if (toggles.NewsEnabled)
{ {
@@ -350,12 +324,12 @@ internal static class PersonalReportOrchestrator
string.Join(" ", reportSections), string.Join(" ", reportSections),
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
IdleState, IdleState,
noMatchCount: 0, 0,
noInputCount: 0, 0,
toggles, toggles,
userName, userName,
userVerified: true, true,
lastServiceError: serviceError)); serviceError));
} }
private static JiboInteractionDecision BuildNoInputDecision( private static JiboInteractionDecision BuildNoInputDecision(
@@ -364,22 +338,19 @@ internal static class PersonalReportOrchestrator
PersonalReportServiceToggles toggles) PersonalReportServiceToggles toggles)
{ {
var noInputCount = Math.Max(0, ReadInt(turn, NoInputCountMetadataKey)) + 1; var noInputCount = Math.Max(0, ReadInt(turn, NoInputCountMetadataKey)) + 1;
if (noInputCount >= MaxNoInputCount) if (noInputCount >= MaxNoInputCount) return BuildDeclinedDecision(toggles);
{
return BuildDeclinedDecision(toggles);
}
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_no_input", "personal_report_no_input",
"I am still here. Do you want your personal report?", "I am still here. Do you want your personal report?",
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
state, state,
noMatchCount: ReadInt(turn, NoMatchCountMetadataKey), ReadInt(turn, NoMatchCountMetadataKey),
noInputCount, noInputCount,
toggles, toggles,
userName: ReadString(turn, UserNameMetadataKey), ReadString(turn, UserNameMetadataKey),
userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false, ReadBool(turn, UserVerifiedMetadataKey) ?? false,
lastServiceError: string.Empty)); string.Empty));
} }
private static JiboInteractionDecision BuildNoMatchDecision( private static JiboInteractionDecision BuildNoMatchDecision(
@@ -391,10 +362,7 @@ internal static class PersonalReportOrchestrator
bool userVerified) bool userVerified)
{ {
var noMatchCount = Math.Max(0, ReadInt(turn, NoMatchCountMetadataKey)) + 1; var noMatchCount = Math.Max(0, ReadInt(turn, NoMatchCountMetadataKey)) + 1;
if (noMatchCount >= MaxNoMatchCount) if (noMatchCount >= MaxNoMatchCount) return BuildDeclinedDecision(toggles);
{
return BuildDeclinedDecision(toggles);
}
return new JiboInteractionDecision( return new JiboInteractionDecision(
"personal_report_no_match", "personal_report_no_match",
@@ -402,11 +370,11 @@ internal static class PersonalReportOrchestrator
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
state, state,
noMatchCount, noMatchCount,
noInputCount: 0, 0,
toggles, toggles,
userName, userName,
userVerified, userVerified,
lastServiceError: string.Empty)); string.Empty));
} }
private static JiboInteractionDecision BuildDeclinedDecision(PersonalReportServiceToggles toggles) private static JiboInteractionDecision BuildDeclinedDecision(PersonalReportServiceToggles toggles)
@@ -416,12 +384,12 @@ internal static class PersonalReportOrchestrator
"No problem. We can do your personal report another time.", "No problem. We can do your personal report another time.",
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
IdleState, IdleState,
noMatchCount: 0, 0,
noInputCount: 0, 0,
toggles, toggles,
userName: null, null,
userVerified: false, false,
lastServiceError: string.Empty)); string.Empty));
} }
private static JiboInteractionDecision BuildCancelledDecision(PersonalReportServiceToggles toggles) private static JiboInteractionDecision BuildCancelledDecision(PersonalReportServiceToggles toggles)
@@ -431,12 +399,12 @@ internal static class PersonalReportOrchestrator
"Okay, canceling personal report.", "Okay, canceling personal report.",
ContextUpdates: BuildContextUpdates( ContextUpdates: BuildContextUpdates(
IdleState, IdleState,
noMatchCount: 0, 0,
noInputCount: 0, 0,
toggles, toggles,
userName: null, null,
userVerified: false, false,
lastServiceError: string.Empty)); string.Empty));
} }
private static IDictionary<string, object?> BuildContextUpdates( private static IDictionary<string, object?> BuildContextUpdates(
@@ -476,24 +444,17 @@ internal static class PersonalReportOrchestrator
private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable<string> phrases) private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable<string> phrases)
{ {
foreach (var phrase in phrases) foreach (var phrase in phrases)
{
if (string.Equals(loweredTranscript, phrase, StringComparison.Ordinal) || if (string.Equals(loweredTranscript, phrase, StringComparison.Ordinal) ||
loweredTranscript.StartsWith($"{phrase} ", StringComparison.Ordinal) || loweredTranscript.StartsWith($"{phrase} ", StringComparison.Ordinal) ||
loweredTranscript.Contains($" {phrase}", StringComparison.Ordinal)) loweredTranscript.Contains($" {phrase}", StringComparison.Ordinal))
{
return true; return true;
}
}
return false; return false;
} }
private static bool IsWeatherErrorReply(string replyText) private static bool IsWeatherErrorReply(string replyText)
{ {
if (string.IsNullOrWhiteSpace(replyText)) if (string.IsNullOrWhiteSpace(replyText)) return false;
{
return false;
}
return replyText.Contains("couldn't fetch the weather", StringComparison.OrdinalIgnoreCase) || return replyText.Contains("couldn't fetch the weather", StringComparison.OrdinalIgnoreCase) ||
replyText.Contains("weather service is connected", StringComparison.OrdinalIgnoreCase); replyText.Contains("weather service is connected", StringComparison.OrdinalIgnoreCase);
@@ -516,36 +477,32 @@ internal static class PersonalReportOrchestrator
summary = string.Empty; summary = string.Empty;
var updated = toggles; var updated = toggles;
updated = ApplyToggleHint(updated, loweredTranscript, "weather", static value => value with { WeatherEnabled = false }, static value => value with { WeatherEnabled = true }); updated = ApplyToggleHint(updated, loweredTranscript, "weather",
updated = ApplyToggleHint(updated, loweredTranscript, "calendar", static value => value with { CalendarEnabled = false }, static value => value with { CalendarEnabled = true }); static value => value with { WeatherEnabled = false },
updated = ApplyToggleHint(updated, loweredTranscript, "commute", static value => value with { CommuteEnabled = false }, static value => value with { CommuteEnabled = true }); static value => value with { WeatherEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "news", static value => value with { NewsEnabled = false }, static value => value with { NewsEnabled = true }); updated = ApplyToggleHint(updated, loweredTranscript, "calendar",
static value => value with { CalendarEnabled = false },
static value => value with { CalendarEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "commute",
static value => value with { CommuteEnabled = false },
static value => value with { CommuteEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "news",
static value => value with { NewsEnabled = false }, static value => value with { NewsEnabled = true });
var changes = new List<string>(); var changes = new List<string>();
if (updated.WeatherEnabled != toggles.WeatherEnabled) if (updated.WeatherEnabled != toggles.WeatherEnabled)
{
changes.Add(updated.WeatherEnabled ? "including weather" : "skipping weather"); changes.Add(updated.WeatherEnabled ? "including weather" : "skipping weather");
}
if (updated.CalendarEnabled != toggles.CalendarEnabled) if (updated.CalendarEnabled != toggles.CalendarEnabled)
{
changes.Add(updated.CalendarEnabled ? "including calendar" : "skipping calendar"); changes.Add(updated.CalendarEnabled ? "including calendar" : "skipping calendar");
}
if (updated.CommuteEnabled != toggles.CommuteEnabled) if (updated.CommuteEnabled != toggles.CommuteEnabled)
{
changes.Add(updated.CommuteEnabled ? "including commute" : "skipping commute"); changes.Add(updated.CommuteEnabled ? "including commute" : "skipping commute");
}
if (updated.NewsEnabled != toggles.NewsEnabled) if (updated.NewsEnabled != toggles.NewsEnabled)
{
changes.Add(updated.NewsEnabled ? "including news" : "skipping news"); changes.Add(updated.NewsEnabled ? "including news" : "skipping news");
}
if (changes.Count > 0) if (changes.Count > 0) summary = $"Got it, {string.Join(", ", changes)}.";
{
summary = $"Got it, {string.Join(", ", changes)}.";
}
return updated; return updated;
} }
@@ -560,15 +517,11 @@ internal static class PersonalReportOrchestrator
if (loweredTranscript.Contains($"without {serviceLabel}", StringComparison.Ordinal) || if (loweredTranscript.Contains($"without {serviceLabel}", StringComparison.Ordinal) ||
loweredTranscript.Contains($"skip {serviceLabel}", StringComparison.Ordinal) || loweredTranscript.Contains($"skip {serviceLabel}", StringComparison.Ordinal) ||
loweredTranscript.Contains($"no {serviceLabel}", StringComparison.Ordinal)) loweredTranscript.Contains($"no {serviceLabel}", StringComparison.Ordinal))
{
return disable(toggles); return disable(toggles);
}
if (loweredTranscript.Contains($"with {serviceLabel}", StringComparison.Ordinal) || if (loweredTranscript.Contains($"with {serviceLabel}", StringComparison.Ordinal) ||
loweredTranscript.Contains($"include {serviceLabel}", StringComparison.Ordinal)) loweredTranscript.Contains($"include {serviceLabel}", StringComparison.Ordinal))
{
return enable(toggles); return enable(toggles);
}
return toggles; return toggles;
} }
@@ -580,10 +533,7 @@ internal static class PersonalReportOrchestrator
private static string? ReadString(TurnContext turn, string key) private static string? ReadString(TurnContext turn, string key)
{ {
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null;
{
return null;
}
return value switch return value switch
{ {
@@ -594,10 +544,7 @@ internal static class PersonalReportOrchestrator
private static bool? ReadBool(TurnContext turn, string key) private static bool? ReadBool(TurnContext turn, string key)
{ {
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null;
{
return null;
}
return value switch return value switch
{ {
@@ -605,17 +552,15 @@ internal static class PersonalReportOrchestrator
string text when bool.TryParse(text, out var parsed) => parsed, string text when bool.TryParse(text, out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.True } => true, JsonElement { ValueKind: JsonValueKind.True } => true,
JsonElement { ValueKind: JsonValueKind.False } => false, JsonElement { ValueKind: JsonValueKind.False } => false,
JsonElement json when json.ValueKind == JsonValueKind.String && bool.TryParse(json.GetString(), out var parsed) => parsed, JsonElement json when json.ValueKind == JsonValueKind.String &&
bool.TryParse(json.GetString(), out var parsed) => parsed,
_ => null _ => null
}; };
} }
private static int ReadInt(TurnContext turn, string key) private static int ReadInt(TurnContext turn, string key)
{ {
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return 0;
{
return 0;
}
return value switch return value switch
{ {
@@ -623,7 +568,8 @@ internal static class PersonalReportOrchestrator
long whole when whole <= int.MaxValue && whole >= int.MinValue => (int)whole, long whole when whole <= int.MaxValue && whole >= int.MinValue => (int)whole,
string text when int.TryParse(text, out var parsed) => parsed, string text when int.TryParse(text, out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.Number } number when number.TryGetInt32(out var parsed) => parsed, JsonElement { ValueKind: JsonValueKind.Number } number when number.TryGetInt32(out var parsed) => parsed,
JsonElement json when json.ValueKind == JsonValueKind.String && int.TryParse(json.GetString(), out var parsed) => parsed, JsonElement json when json.ValueKind == JsonValueKind.String &&
int.TryParse(json.GetString(), out var parsed) => parsed,
_ => 0 _ => 0
}; };
} }
@@ -633,10 +579,7 @@ internal static class PersonalReportOrchestrator
var normalized = NameNoiseRegex.Replace(loweredTranscript, " ") var normalized = NameNoiseRegex.Replace(loweredTranscript, " ")
.Replace(" ", " ", StringComparison.Ordinal) .Replace(" ", " ", StringComparison.Ordinal)
.Trim(); .Trim();
if (string.IsNullOrWhiteSpace(normalized)) if (string.IsNullOrWhiteSpace(normalized)) return null;
{
return null;
}
var prefixes = new[] var prefixes = new[]
{ {
@@ -650,10 +593,7 @@ internal static class PersonalReportOrchestrator
foreach (var prefix in prefixes) foreach (var prefix in prefixes)
{ {
if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) continue;
{
continue;
}
var candidate = normalized[prefix.Length..].Trim(); var candidate = normalized[prefix.Length..].Trim();
return NormalizeNameCandidate(candidate); return NormalizeNameCandidate(candidate);
@@ -664,58 +604,31 @@ internal static class PersonalReportOrchestrator
private static string? NormalizeNameCandidate(string candidate) private static string? NormalizeNameCandidate(string candidate)
{ {
if (string.IsNullOrWhiteSpace(candidate)) if (string.IsNullOrWhiteSpace(candidate)) return null;
{
return null;
}
var cleaned = NameNoiseRegex.Replace(candidate, " ") var cleaned = NameNoiseRegex.Replace(candidate, " ")
.Replace(" ", " ", StringComparison.Ordinal) .Replace(" ", " ", StringComparison.Ordinal)
.Trim(); .Trim();
if (string.IsNullOrWhiteSpace(cleaned)) if (string.IsNullOrWhiteSpace(cleaned)) return null;
{
return null;
}
if (cleaned.Length < 2 || cleaned.Length > 32) if (cleaned.Length < 2 || cleaned.Length > 32) return null;
{
return null;
}
var words = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries); var words = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length > 4) if (words.Length > 4) return null;
{
return null; return words.Any(static word => word.Any(char.IsDigit)) ? null : cleaned;
} }
if (words.Any(static word => word.Any(char.IsDigit)))
{
return null;
}
return cleaned;
}
private static readonly Regex NameNoiseRegex = new("[^a-zA-Z\\-\\s']", RegexOptions.Compiled);
private readonly record struct PersonalReportServiceToggles(
bool WeatherEnabled,
bool CalendarEnabled,
bool CommuteEnabled,
bool NewsEnabled);
private static string ChoosePersonalReportTemplate( private static string ChoosePersonalReportTemplate(
IReadOnlyList<string> templates, IReadOnlyList<string> templates,
string fallback) string fallback)
{ {
var usableTemplates = templates var usableTemplates = templates
.Where(static template => !string.IsNullOrWhiteSpace(template) && !template.Contains("${dt.", StringComparison.OrdinalIgnoreCase)) .Where(static template => !string.IsNullOrWhiteSpace(template) &&
!template.Contains("${dt.", StringComparison.OrdinalIgnoreCase))
.ToArray(); .ToArray();
if (usableTemplates.Length == 0) if (usableTemplates.Length == 0) return fallback;
{
return fallback;
}
var speakerAwareTemplate = usableTemplates.FirstOrDefault(static template => var speakerAwareTemplate = usableTemplates.FirstOrDefault(static template =>
template.Contains("${speaker}", StringComparison.OrdinalIgnoreCase)); template.Contains("${speaker}", StringComparison.OrdinalIgnoreCase));
@@ -737,18 +650,10 @@ internal static class PersonalReportOrchestrator
string fallback) string fallback)
{ {
var primary = primaryTemplates.FirstOrDefault(static template => !string.IsNullOrWhiteSpace(template)); var primary = primaryTemplates.FirstOrDefault(static template => !string.IsNullOrWhiteSpace(template));
if (!string.IsNullOrWhiteSpace(primary)) if (!string.IsNullOrWhiteSpace(primary)) return primary!;
{
return primary!;
}
var secondary = secondaryTemplates.FirstOrDefault(static template => !string.IsNullOrWhiteSpace(template)); var secondary = secondaryTemplates.FirstOrDefault(static template => !string.IsNullOrWhiteSpace(template));
if (!string.IsNullOrWhiteSpace(secondary)) return !string.IsNullOrWhiteSpace(secondary) ? secondary! : fallback;
{
return secondary!;
}
return fallback;
} }
private static string RenderReportSkillTemplate(string template, string userName) private static string RenderReportSkillTemplate(string template, string userName)
@@ -759,4 +664,10 @@ internal static class PersonalReportOrchestrator
.Replace(" ", " ", StringComparison.Ordinal) .Replace(" ", " ", StringComparison.Ordinal)
.Trim(); .Trim();
} }
private readonly record struct PersonalReportServiceToggles(
bool WeatherEnabled,
bool CalendarEnabled,
bool CommuteEnabled,
bool NewsEnabled);
} }

View File

@@ -6,7 +6,8 @@ namespace Jibo.Cloud.Application.Services;
public sealed class ProtocolToTurnContextMapper public sealed class ProtocolToTurnContextMapper
{ {
public static TurnContext MapListenMessage(WebSocketMessageEnvelope envelope, CloudSession session, string messageType) public static TurnContext MapListenMessage(WebSocketMessageEnvelope envelope, CloudSession session,
string messageType)
{ {
var turnState = session.TurnState; var turnState = session.TurnState;
var protocolOperation = messageType.ToLowerInvariant(); var protocolOperation = messageType.ToLowerInvariant();
@@ -16,46 +17,28 @@ public sealed class ProtocolToTurnContextMapper
}; };
var text = ExtractTranscript(envelope.Text, attributes); var text = ExtractTranscript(envelope.Text, attributes);
if (!string.IsNullOrWhiteSpace(turnState.TransId)) if (!string.IsNullOrWhiteSpace(turnState.TransId)) attributes["transID"] = turnState.TransId;
{
attributes["transID"] = turnState.TransId;
}
if (!string.IsNullOrWhiteSpace(session.AccountId)) if (!string.IsNullOrWhiteSpace(session.AccountId)) attributes["accountId"] = session.AccountId;
{
attributes["accountId"] = session.AccountId;
}
if (!string.IsNullOrWhiteSpace(session.DeviceId)) if (!string.IsNullOrWhiteSpace(session.DeviceId)) attributes["deviceId"] = session.DeviceId;
{
attributes["deviceId"] = session.DeviceId;
}
if (session.Metadata.TryGetValue("loopId", out var loopId) && if (session.Metadata.TryGetValue("loopId", out var loopId) &&
loopId is string loopIdText && loopId is string loopIdText &&
!string.IsNullOrWhiteSpace(loopIdText)) !string.IsNullOrWhiteSpace(loopIdText))
{
attributes["loopId"] = loopIdText; attributes["loopId"] = loopIdText;
}
if (!string.IsNullOrWhiteSpace(turnState.ContextPayload)) if (!string.IsNullOrWhiteSpace(turnState.ContextPayload)) attributes["context"] = turnState.ContextPayload;
{
attributes["context"] = turnState.ContextPayload;
}
if (session.Metadata.TryGetValue("lastClockDomain", out var lastClockDomain) && if (session.Metadata.TryGetValue("lastClockDomain", out var lastClockDomain) &&
lastClockDomain is string lastClockDomainText && lastClockDomain is string lastClockDomainText &&
!string.IsNullOrWhiteSpace(lastClockDomainText)) !string.IsNullOrWhiteSpace(lastClockDomainText))
{
attributes["lastClockDomain"] = lastClockDomainText; attributes["lastClockDomain"] = lastClockDomainText;
}
if (session.Metadata.TryGetValue("pendingProactivityOffer", out var pendingProactivityOffer) && if (session.Metadata.TryGetValue("pendingProactivityOffer", out var pendingProactivityOffer) &&
pendingProactivityOffer is string pendingProactivityOfferText && pendingProactivityOffer is string pendingProactivityOfferText &&
!string.IsNullOrWhiteSpace(pendingProactivityOfferText)) !string.IsNullOrWhiteSpace(pendingProactivityOfferText))
{
attributes["pendingProactivityOffer"] = pendingProactivityOfferText; attributes["pendingProactivityOffer"] = pendingProactivityOfferText;
}
foreach (var pair in session.Metadata) foreach (var pair in session.Metadata)
{ {
@@ -63,41 +46,29 @@ public sealed class ProtocolToTurnContextMapper
!pair.Key.StartsWith("chitchat", StringComparison.OrdinalIgnoreCase) && !pair.Key.StartsWith("chitchat", StringComparison.OrdinalIgnoreCase) &&
!pair.Key.StartsWith("greetings", StringComparison.OrdinalIgnoreCase)) || !pair.Key.StartsWith("greetings", StringComparison.OrdinalIgnoreCase)) ||
pair.Value is null) pair.Value is null)
{
continue; continue;
}
attributes[pair.Key] = pair.Value; attributes[pair.Key] = pair.Value;
} }
attributes["listenHotphrase"] = turnState.ListenHotphrase; attributes["listenHotphrase"] = turnState.ListenHotphrase;
if (turnState.ListenRules.Count > 0) if (turnState.ListenRules.Count > 0) attributes["listenRules"] = turnState.ListenRules;
{
attributes["listenRules"] = turnState.ListenRules;
}
if (turnState.ListenAsrHints.Count > 0) if (turnState.ListenAsrHints.Count > 0) attributes["listenAsrHints"] = turnState.ListenAsrHints;
{
attributes["listenAsrHints"] = turnState.ListenAsrHints;
}
if (turnState.BufferedAudioBytes > 0) if (turnState.BufferedAudioBytes > 0)
{ {
attributes["bufferedAudioBytes"] = turnState.BufferedAudioBytes; attributes["bufferedAudioBytes"] = turnState.BufferedAudioBytes;
attributes["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount; attributes["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount;
attributes["bufferedAudioFrames"] = turnState.BufferedAudioFrames.Select(frame => frame.ToArray()).ToArray(); attributes["bufferedAudioFrames"] =
turnState.BufferedAudioFrames.Select(frame => frame.ToArray()).ToArray();
} }
if (!string.IsNullOrWhiteSpace(turnState.AudioTranscriptHint)) if (!string.IsNullOrWhiteSpace(turnState.AudioTranscriptHint))
{
attributes["audioTranscriptHint"] = turnState.AudioTranscriptHint; attributes["audioTranscriptHint"] = turnState.AudioTranscriptHint;
}
if (turnState.FinalizeAttemptCount > 0) if (turnState.FinalizeAttemptCount > 0) attributes["finalizeAttemptCount"] = turnState.FinalizeAttemptCount;
{
attributes["finalizeAttemptCount"] = turnState.FinalizeAttemptCount;
}
return new TurnContext return new TurnContext
{ {
@@ -111,8 +82,12 @@ public sealed class ProtocolToTurnContextMapper
RequestId = envelope.ConnectionId, RequestId = envelope.ConnectionId,
ProtocolService = "neo-hub", ProtocolService = "neo-hub",
ProtocolOperation = protocolOperation, ProtocolOperation = protocolOperation,
FirmwareVersion = session.Metadata.TryGetValue("firmwareVersion", out var firmwareVersion) ? firmwareVersion as string : null, FirmwareVersion = session.Metadata.TryGetValue("firmwareVersion", out var firmwareVersion)
ApplicationVersion = session.Metadata.TryGetValue("applicationVersion", out var applicationVersion) ? applicationVersion as string : null, ? firmwareVersion as string
: null,
ApplicationVersion = session.Metadata.TryGetValue("applicationVersion", out var applicationVersion)
? applicationVersion as string
: null,
IsFollowUpEligible = true, IsFollowUpEligible = true,
Attributes = attributes Attributes = attributes
}; };
@@ -120,10 +95,7 @@ public sealed class ProtocolToTurnContextMapper
private static string? ExtractTranscript(string? text, IDictionary<string, object?> attributes) private static string? ExtractTranscript(string? text, IDictionary<string, object?> attributes)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text)) return null;
{
return null;
}
try try
{ {
@@ -133,57 +105,41 @@ public sealed class ProtocolToTurnContextMapper
if (!root.TryGetProperty("data", out var data)) return null; if (!root.TryGetProperty("data", out var data)) return null;
if (data.TryGetProperty("text", out var transcript) && transcript.ValueKind == JsonValueKind.String) if (data.TryGetProperty("text", out var transcript) && transcript.ValueKind == JsonValueKind.String)
{
return transcript.GetString(); return transcript.GetString();
}
if (data.TryGetProperty("asr", out var asr) && if (data.TryGetProperty("asr", out var asr) &&
asr.ValueKind == JsonValueKind.Object && asr.ValueKind == JsonValueKind.Object &&
asr.TryGetProperty("text", out var asrText) && asr.TryGetProperty("text", out var asrText) &&
asrText.ValueKind == JsonValueKind.String) asrText.ValueKind == JsonValueKind.String)
{
return asrText.GetString(); return asrText.GetString();
}
if (data.TryGetProperty("transcriptHint", out var transcriptHint) && transcriptHint.ValueKind == JsonValueKind.String) if (data.TryGetProperty("transcriptHint", out var transcriptHint) &&
{ transcriptHint.ValueKind == JsonValueKind.String) return transcriptHint.GetString();
return transcriptHint.GetString();
}
if (data.TryGetProperty("intent", out var intent) && intent.ValueKind == JsonValueKind.String) if (data.TryGetProperty("intent", out var intent) && intent.ValueKind == JsonValueKind.String)
{
attributes["clientIntent"] = intent.GetString(); attributes["clientIntent"] = intent.GetString();
}
if (data.TryGetProperty("triggerSource", out var triggerSource) && if (data.TryGetProperty("triggerSource", out var triggerSource) &&
triggerSource.ValueKind == JsonValueKind.String && triggerSource.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(triggerSource.GetString())) !string.IsNullOrWhiteSpace(triggerSource.GetString()))
{
attributes["triggerSource"] = triggerSource.GetString(); attributes["triggerSource"] = triggerSource.GetString();
}
if (data.TryGetProperty("triggerData", out var triggerData) && if (data.TryGetProperty("triggerData", out var triggerData) &&
triggerData.ValueKind == JsonValueKind.Object && triggerData.ValueKind == JsonValueKind.Object &&
triggerData.TryGetProperty("looperID", out var triggerLooperId) && triggerData.TryGetProperty("looperID", out var triggerLooperId) &&
triggerLooperId.ValueKind == JsonValueKind.String && triggerLooperId.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(triggerLooperId.GetString())) !string.IsNullOrWhiteSpace(triggerLooperId.GetString()))
{
attributes["triggerLooperId"] = triggerLooperId.GetString(); attributes["triggerLooperId"] = triggerLooperId.GetString();
}
if (data.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array) if (data.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array)
{
attributes["clientRules"] = rules.EnumerateArray() attributes["clientRules"] = rules.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String) .Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString() ?? string.Empty) .Select(item => item.GetString() ?? string.Empty)
.Where(rule => !string.IsNullOrWhiteSpace(rule)) .Where(rule => !string.IsNullOrWhiteSpace(rule))
.ToArray(); .ToArray();
}
if (data.TryGetProperty("entities", out var entities) && entities.ValueKind == JsonValueKind.Object) if (data.TryGetProperty("entities", out var entities) && entities.ValueKind == JsonValueKind.Object)
{
attributes["clientEntities"] = entities.Clone(); attributes["clientEntities"] = entities.Clone();
}
return intent.ValueKind == JsonValueKind.String ? intent.GetString() : null; return intent.ValueKind == JsonValueKind.String ? intent.GetString() : null;
} }

View File

@@ -32,7 +32,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
var isVolumeControl = string.Equals(plan.IntentName, "volume_up", StringComparison.OrdinalIgnoreCase) || var isVolumeControl = string.Equals(plan.IntentName, "volume_up", StringComparison.OrdinalIgnoreCase) ||
string.Equals(plan.IntentName, "volume_down", StringComparison.OrdinalIgnoreCase) || string.Equals(plan.IntentName, "volume_down", StringComparison.OrdinalIgnoreCase) ||
string.Equals(plan.IntentName, "volume_to_value", StringComparison.OrdinalIgnoreCase); string.Equals(plan.IntentName, "volume_to_value", StringComparison.OrdinalIgnoreCase);
var isProactivePizzaFactOffer = string.Equals(plan.IntentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase); var isProactivePizzaFactOffer = string.Equals(plan.IntentName, "proactive_offer_pizza_fact",
StringComparison.OrdinalIgnoreCase);
var isSettingsLaunch = string.Equals(skill?.SkillName, "@be/settings", StringComparison.OrdinalIgnoreCase); var isSettingsLaunch = string.Equals(skill?.SkillName, "@be/settings", StringComparison.OrdinalIgnoreCase);
var isGlobalCommand = isStopCommand || isVolumeControl; var isGlobalCommand = isStopCommand || isVolumeControl;
var isPhotoGalleryLaunch = string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase); var isPhotoGalleryLaunch = string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase);
@@ -73,7 +74,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? localIntent ? localIntent
: isWordOfDayGuess : isWordOfDayGuess
? "guess" ? "guess"
: string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) && : string.Equals(messageType, "CLIENT_NLU",
StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(clientIntent) !string.IsNullOrWhiteSpace(clientIntent)
? clientIntent ? clientIntent
: plan.IntentName ?? "unknown"; : plan.IntentName ?? "unknown";
@@ -210,10 +212,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText, outboundAsrText,
outboundRules, outboundRules,
entities)), entities)),
DelayMs: 75)); 75));
messages.Add(new SocketReplyPlan( messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/word-of-the-day")), JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/word-of-the-day")),
DelayMs: 125)); 125));
} }
if (isRadioLaunch) if (isRadioLaunch)
@@ -226,10 +228,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText, outboundAsrText,
outboundRules, outboundRules,
entities)), entities)),
DelayMs: 75)); 75));
messages.Add(new SocketReplyPlan( messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/radio")), JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/radio")),
DelayMs: 125)); 125));
} }
if (isStopCommand) if (isStopCommand)
@@ -242,10 +244,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText, outboundAsrText,
outboundRules, outboundRules,
entities)), entities)),
DelayMs: 75)); 75));
messages.Add(new SocketReplyPlan( messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/idle")), JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/idle")),
DelayMs: 125)); 125));
} }
if (isSettingsLaunch && if (isSettingsLaunch &&
@@ -259,10 +261,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText, outboundAsrText,
outboundRules, outboundRules,
entities)), entities)),
DelayMs: 75)); 75));
messages.Add(new SocketReplyPlan( messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/settings")), JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/settings")),
DelayMs: 125)); 125));
} }
if (isClockSkillLaunch && if (isClockSkillLaunch &&
@@ -277,10 +279,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText, outboundAsrText,
outboundRules, outboundRules,
entities)), entities)),
DelayMs: 75)); 75));
messages.Add(new SocketReplyPlan( messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/clock")), JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/clock")),
DelayMs: 125)); 125));
} }
if ((isPhotoGalleryLaunch || isPhotoCreateLaunch) && if ((isPhotoGalleryLaunch || isPhotoCreateLaunch) &&
@@ -295,18 +297,16 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText, outboundAsrText,
outboundRules, outboundRules,
entities)), entities)),
DelayMs: 75)); 75));
messages.Add(new SocketReplyPlan( messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, skillId)), JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, skillId)),
DelayMs: 125)); 125));
} }
if (emitSkillActions && speak is not null) if (emitSkillActions && speak is not null)
{
messages.Add(new SocketReplyPlan( messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildSkillPayload(plan, turn, transId, speak, skill)), JsonSerializer.Serialize(BuildSkillPayload(plan, turn, transId, speak, skill)),
DelayMs: 75)); 75));
}
return messages; return messages;
} }
@@ -352,7 +352,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
transID = transId, transID = transId,
data = new { } data = new { }
})), })),
new SocketReplyPlan(JsonSerializer.Serialize(BuildGenericFallbackSkillPayload(transId)), DelayMs: 75) new SocketReplyPlan(JsonSerializer.Serialize(BuildGenericFallbackSkillPayload(transId)), 75)
]; ];
} }
@@ -427,10 +427,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
? "clientRules" ? "clientRules"
: "listenRules"; : "listenRules";
if (!turn.Attributes.TryGetValue(attributeName, out var value)) if (!turn.Attributes.TryGetValue(attributeName, out var value)) return [];
{
return [];
}
return value switch return value switch
{ {
@@ -466,10 +463,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
{ {
if (yesNoTurn) if (yesNoTurn)
{ {
if (!includeCreateDomain) if (!includeCreateDomain) return new Dictionary<string, object?>();
{
return new Dictionary<string, object?>();
}
return new Dictionary<string, object?> return new Dictionary<string, object?>
{ {
@@ -478,20 +472,15 @@ public sealed class ResponsePlanToSocketMessagesMapper
} }
if (wordOfDayLaunch) if (wordOfDayLaunch)
{
return new Dictionary<string, object?> return new Dictionary<string, object?>
{ {
["domain"] = "word-of-the-day" ["domain"] = "word-of-the-day"
}; };
}
if (globalCommand) if (globalCommand)
{ {
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase); var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(volumeLevel)) if (!string.IsNullOrWhiteSpace(volumeLevel)) entities["volumeLevel"] = volumeLevel;
{
entities["volumeLevel"] = volumeLevel;
}
return entities; return entities;
} }
@@ -499,10 +488,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
if (radioLaunch) if (radioLaunch)
{ {
var entities = new Dictionary<string, object?>(); var entities = new Dictionary<string, object?>();
if (!string.IsNullOrWhiteSpace(radioStation)) if (!string.IsNullOrWhiteSpace(radioStation)) entities["station"] = radioStation;
{
entities["station"] = radioStation;
}
return entities; return entities;
} }
@@ -510,10 +496,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
if (clockSkillLaunch) if (clockSkillLaunch)
{ {
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase); var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(clockDomain)) if (!string.IsNullOrWhiteSpace(clockDomain)) entities["domain"] = clockDomain;
{
entities["domain"] = clockDomain;
}
if (string.Equals(clockDomain, "timer", StringComparison.OrdinalIgnoreCase) && if (string.Equals(clockDomain, "timer", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(timerHours + timerMinutes + timerSeconds)) !string.IsNullOrWhiteSpace(timerHours + timerMinutes + timerSeconds))
@@ -535,32 +518,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
if (reportSkillLaunch) if (reportSkillLaunch)
{ {
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase); var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(reportDate)) if (!string.IsNullOrWhiteSpace(reportDate)) entities["date"] = reportDate;
{
entities["date"] = reportDate;
}
if (!string.IsNullOrWhiteSpace(reportWeatherCondition)) if (!string.IsNullOrWhiteSpace(reportWeatherCondition)) entities["Weather"] = reportWeatherCondition;
{
entities["Weather"] = reportWeatherCondition;
}
return entities; return entities;
} }
if (wordOfDayGuess) if (wordOfDayGuess)
{
return new Dictionary<string, object?> return new Dictionary<string, object?>
{ {
["guess"] = guess ?? string.Empty ["guess"] = guess ?? string.Empty
}; };
}
if (!string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) || if (!string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) ||
!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) !turn.Attributes.TryGetValue("clientEntities", out var value) || value is null)
{
return new Dictionary<string, object?>(); return new Dictionary<string, object?>();
}
return value switch return value switch
{ {
@@ -596,10 +569,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static IEnumerable<string> ReadRuleValues(TurnContext turn, string key) private static IEnumerable<string> ReadRuleValues(TurnContext turn, string key)
{ {
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return [];
{
return [];
}
return value switch return value switch
{ {
@@ -621,10 +591,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static string? ReadClientEntity(TurnContext turn, string entityName) private static string? ReadClientEntity(TurnContext turn, string entityName)
{ {
if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) return null;
{
return null;
}
return value switch return value switch
{ {
@@ -642,20 +609,14 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static string? ReadSkillPayloadString(InvokeNativeSkillAction? skill, string key) private static string? ReadSkillPayloadString(InvokeNativeSkillAction? skill, string key)
{ {
if (skill?.Payload is null || !skill.Payload.TryGetValue(key, out var value)) if (skill?.Payload is null || !skill.Payload.TryGetValue(key, out var value)) return null;
{
return null;
}
return value?.ToString(); return value?.ToString();
} }
private static string ResolveWordOfDayGuess(TurnContext turn, string transcript, string? nluGuess) private static string ResolveWordOfDayGuess(TurnContext turn, string transcript, string? nluGuess)
{ {
if (!string.IsNullOrWhiteSpace(nluGuess)) if (!string.IsNullOrWhiteSpace(nluGuess)) return nluGuess;
{
return nluGuess;
}
var normalized = NormalizeGuessToken(transcript); var normalized = NormalizeGuessToken(transcript);
var hintIndex = normalized switch var hintIndex = normalized switch
@@ -669,11 +630,9 @@ public sealed class ResponsePlanToSocketMessagesMapper
var hints = ReadRuleValues(turn, "listenAsrHints").ToArray(); var hints = ReadRuleValues(turn, "listenAsrHints").ToArray();
if (hintIndex >= 0) if (hintIndex >= 0)
{
return hintIndex < hints.Length return hintIndex < hints.Length
? hints[hintIndex] ? hints[hintIndex]
: transcript; : transcript;
}
var fuzzyHintMatch = FindClosestHint(normalized, hints); var fuzzyHintMatch = FindClosestHint(normalized, hints);
return string.IsNullOrWhiteSpace(fuzzyHintMatch) return string.IsNullOrWhiteSpace(fuzzyHintMatch)
@@ -683,31 +642,19 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static string? FindClosestHint(string normalizedTranscript, IReadOnlyList<string> hints) private static string? FindClosestHint(string normalizedTranscript, IReadOnlyList<string> hints)
{ {
if (string.IsNullOrWhiteSpace(normalizedTranscript)) if (string.IsNullOrWhiteSpace(normalizedTranscript)) return null;
{
return null;
}
string? bestHint = null; string? bestHint = null;
var bestDistance = int.MaxValue; var bestDistance = int.MaxValue;
foreach (var hint in hints) foreach (var hint in hints)
{ {
if (string.IsNullOrWhiteSpace(hint)) if (string.IsNullOrWhiteSpace(hint)) continue;
{
continue;
}
var normalizedHint = NormalizeGuessToken(hint); var normalizedHint = NormalizeGuessToken(hint);
if (string.IsNullOrWhiteSpace(normalizedHint)) if (string.IsNullOrWhiteSpace(normalizedHint)) continue;
{
continue;
}
if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal)) if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal)) return hint;
{
return hint;
}
var distance = ComputeEditDistance(normalizedTranscript, normalizedHint); var distance = ComputeEditDistance(normalizedTranscript, normalizedHint);
if (distance >= bestDistance) continue; if (distance >= bestDistance) continue;
@@ -729,10 +676,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
var previous = new int[right.Length + 1]; var previous = new int[right.Length + 1];
var current = new int[right.Length + 1]; var current = new int[right.Length + 1];
for (var column = 0; column <= right.Length; column += 1) for (var column = 0; column <= right.Length; column += 1) previous[column] = column;
{
previous[column] = column;
}
for (var row = 1; row <= left.Length; row += 1) for (var row = 1; row <= left.Length; row += 1)
{ {
@@ -757,11 +701,9 @@ public sealed class ResponsePlanToSocketMessagesMapper
var skillPayload = skill?.Payload; var skillPayload = skill?.Payload;
if (string.Equals(ReadPayloadString(skillPayload, "cloudResponseMode"), "completion_only", if (string.Equals(ReadPayloadString(skillPayload, "cloudResponseMode"), "completion_only",
StringComparison.OrdinalIgnoreCase)) StringComparison.OrdinalIgnoreCase))
{
return BuildCompletionOnlySkillPayload( return BuildCompletionOnlySkillPayload(
transId, transId,
ReadPayloadString(skillPayload, "skillId") ?? skill?.SkillName ?? "chitchat-skill"); ReadPayloadString(skillPayload, "skillId") ?? skill?.SkillName ?? "chitchat-skill");
}
var isJoke = string.Equals(plan.IntentName, "joke", StringComparison.OrdinalIgnoreCase) || var isJoke = string.Equals(plan.IntentName, "joke", StringComparison.OrdinalIgnoreCase) ||
string.Equals(skill?.SkillName, "@be/joke", StringComparison.OrdinalIgnoreCase); string.Equals(skill?.SkillName, "@be/joke", StringComparison.OrdinalIgnoreCase);
@@ -797,21 +739,16 @@ public sealed class ResponsePlanToSocketMessagesMapper
}; };
if (listenContexts.Count > 0) if (listenContexts.Count > 0)
{
jcpConfig["listen"] = new jcpConfig["listen"] = new
{ {
id = CreateProtocolId(), id = CreateProtocolId(),
type = "LISTEN", type = "LISTEN",
contexts = listenContexts contexts = listenContexts
}; };
}
object? weatherHiLoView = BuildWeatherHiLoView(skillPayload); var weatherHiLoView = BuildWeatherHiLoView(skillPayload);
var weeklyWeatherCards = BuildWeatherHiLoSequenceCards(skillPayload); var weeklyWeatherCards = BuildWeatherHiLoSequenceCards(skillPayload);
if (weatherHiLoView is null && weeklyWeatherCards.Count > 0) if (weatherHiLoView is null && weeklyWeatherCards.Count > 0) weatherHiLoView = weeklyWeatherCards[0].View;
{
weatherHiLoView = weeklyWeatherCards[0].View;
}
var useWeatherSequence = false; var useWeatherSequence = false;
if (weatherHiLoView is not null) if (weatherHiLoView is not null)
@@ -927,15 +864,9 @@ public sealed class ResponsePlanToSocketMessagesMapper
["entities"] = entities ["entities"] = entities
}; };
if (!string.IsNullOrWhiteSpace(skillId)) if (!string.IsNullOrWhiteSpace(skillId)) payload["skill"] = skillId;
{
payload["skill"] = skillId;
}
if (!string.IsNullOrWhiteSpace(domain)) if (!string.IsNullOrWhiteSpace(domain)) payload["domain"] = domain;
{
payload["domain"] = domain;
}
return payload; return payload;
} }
@@ -1098,55 +1029,54 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static string? ReadPayloadString(IDictionary<string, object?>? payload, string key) private static string? ReadPayloadString(IDictionary<string, object?>? payload, string key)
{ {
if (payload is null || !payload.TryGetValue(key, out var value)) if (payload is null || !payload.TryGetValue(key, out var value)) return null;
{
return null;
}
return value?.ToString(); return value?.ToString();
} }
private static IReadOnlyList<string> ReadPayloadStringArray(IDictionary<string, object?>? payload, string key) private static IReadOnlyList<string> ReadPayloadStringArray(IDictionary<string, object?>? payload, string key)
{ {
if (payload is null || !payload.TryGetValue(key, out var value) || value is null) if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return [];
{
return [];
}
return value switch return value switch
{ {
string text => [.. text string text =>
[
.. text
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static context => !string.IsNullOrWhiteSpace(context))], .Where(static context => !string.IsNullOrWhiteSpace(context))
],
string[] contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))], string[] contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))],
IEnumerable<string> contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))], IEnumerable<string> contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))],
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Array => [.. jsonElement JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Array =>
[
.. jsonElement
.EnumerateArray() .EnumerateArray()
.Select(static item => item.GetString()) .Select(static item => item.GetString())
.Where(static context => !string.IsNullOrWhiteSpace(context)) .Where(static context => !string.IsNullOrWhiteSpace(context))
.Select(static context => context!)], .Select(static context => context!)
IEnumerable<object?> contexts => [.. contexts ],
IEnumerable<object?> contexts =>
[
.. contexts
.Select(static context => context?.ToString()) .Select(static context => context?.ToString())
.Where(static context => !string.IsNullOrWhiteSpace(context)) .Where(static context => !string.IsNullOrWhiteSpace(context))
.Select(static context => context!)], .Select(static context => context!)
],
_ => string.IsNullOrWhiteSpace(value.ToString()) ? [] : [value.ToString()!] _ => string.IsNullOrWhiteSpace(value.ToString()) ? [] : [value.ToString()!]
}; };
} }
private static IReadOnlyList<WeatherHiLoSequenceCard> BuildWeatherHiLoSequenceCards(IDictionary<string, object?>? payload) private static IReadOnlyList<WeatherHiLoSequenceCard> BuildWeatherHiLoSequenceCards(
IDictionary<string, object?>? payload)
{ {
if (payload is null || if (payload is null ||
!payload.TryGetValue("weather_weekly_cards", out var rawCards) || !payload.TryGetValue("weather_weekly_cards", out var rawCards) ||
rawCards is null) rawCards is null)
{
return []; return [];
}
var cards = ReadPayloadObjectArray(rawCards); var cards = ReadPayloadObjectArray(rawCards);
if (cards.Count == 0) if (cards.Count == 0) return [];
{
return [];
}
var sequenceCards = new List<WeatherHiLoSequenceCard>(cards.Count); var sequenceCards = new List<WeatherHiLoSequenceCard>(cards.Count);
foreach (var card in cards) foreach (var card in cards)
@@ -1157,10 +1087,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
["weather_view_kind"] = "weatherHiLo" ["weather_view_kind"] = "weatherHiLo"
}; };
var view = BuildWeatherHiLoView(weatherCardPayload); var view = BuildWeatherHiLoView(weatherCardPayload);
if (view is null) if (view is null) continue;
{
continue;
}
sequenceCards.Add(new WeatherHiLoSequenceCard( sequenceCards.Add(new WeatherHiLoSequenceCard(
view, view,
@@ -1247,38 +1174,29 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static IReadOnlyList<IDictionary<string, object?>> ReadPayloadObjectArray(object rawValue) private static IReadOnlyList<IDictionary<string, object?>> ReadPayloadObjectArray(object rawValue)
{ {
if (rawValue is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array) if (rawValue is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
{
return jsonArray return jsonArray
.EnumerateArray() .EnumerateArray()
.Select(ConvertJsonObjectToDictionary) .Select(ConvertJsonObjectToDictionary)
.Where(static item => item is not null) .Where(static item => item is not null)
.Cast<IDictionary<string, object?>>() .Cast<IDictionary<string, object?>>()
.ToArray(); .ToArray();
}
if (rawValue is IEnumerable<object?> rawObjects) if (rawValue is IEnumerable<object?> rawObjects)
{
return rawObjects return rawObjects
.Select(ConvertObjectToDictionary) .Select(ConvertObjectToDictionary)
.Where(static item => item is not null) .Where(static item => item is not null)
.Cast<IDictionary<string, object?>>() .Cast<IDictionary<string, object?>>()
.ToArray(); .ToArray();
}
return []; return [];
} }
private static IDictionary<string, object?>? ConvertObjectToDictionary(object? value) private static IDictionary<string, object?>? ConvertObjectToDictionary(object? value)
{ {
if (value is null) if (value is null) return null;
{
return null;
}
if (value is IDictionary<string, object?> dictionary) if (value is IDictionary<string, object?> dictionary)
{
return new Dictionary<string, object?>(dictionary, StringComparer.OrdinalIgnoreCase); return new Dictionary<string, object?>(dictionary, StringComparer.OrdinalIgnoreCase);
}
return value is JsonElement jsonValue return value is JsonElement jsonValue
? ConvertJsonObjectToDictionary(jsonValue) ? ConvertJsonObjectToDictionary(jsonValue)
@@ -1287,14 +1205,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static IDictionary<string, object?>? ConvertJsonObjectToDictionary(JsonElement value) private static IDictionary<string, object?>? ConvertJsonObjectToDictionary(JsonElement value)
{ {
if (value.ValueKind != JsonValueKind.Object) if (value.ValueKind != JsonValueKind.Object) return null;
{
return null;
}
var dictionary = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase); var dictionary = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var property in value.EnumerateObject()) foreach (var property in value.EnumerateObject())
{
dictionary[property.Name] = property.Value.ValueKind switch dictionary[property.Name] = property.Value.ValueKind switch
{ {
JsonValueKind.String => property.Value.GetString(), JsonValueKind.String => property.Value.GetString(),
@@ -1306,35 +1220,26 @@ public sealed class ResponsePlanToSocketMessagesMapper
JsonValueKind.Array => property.Value, JsonValueKind.Array => property.Value,
_ => null _ => null
}; };
}
return dictionary; return dictionary;
} }
private static object? BuildWeatherHiLoView(IDictionary<string, object?>? payload) private static object? BuildWeatherHiLoView(IDictionary<string, object?>? payload)
{ {
if (!TryReadPayloadBool(payload, "weather_view_enabled")) if (!TryReadPayloadBool(payload, "weather_view_enabled")) return null;
{
return null;
}
if (!string.Equals( if (!string.Equals(
ReadPayloadString(payload, "weather_view_kind"), ReadPayloadString(payload, "weather_view_kind"),
"weatherHiLo", "weatherHiLo",
StringComparison.OrdinalIgnoreCase)) StringComparison.OrdinalIgnoreCase))
{
return null; return null;
}
var icon = ReadPayloadString(payload, "weather_icon"); var icon = ReadPayloadString(payload, "weather_icon");
var unit = ReadPayloadString(payload, "weather_unit") ?? "F"; var unit = ReadPayloadString(payload, "weather_unit") ?? "F";
var theme = ReadPayloadString(payload, "weather_theme") ?? "Normal"; var theme = ReadPayloadString(payload, "weather_theme") ?? "Normal";
var high = TryReadPayloadInt(payload, "weather_high"); var high = TryReadPayloadInt(payload, "weather_high");
var low = TryReadPayloadInt(payload, "weather_low"); var low = TryReadPayloadInt(payload, "weather_low");
if (string.IsNullOrWhiteSpace(icon) || high is null || low is null) if (string.IsNullOrWhiteSpace(icon) || high is null || low is null) return null;
{
return null;
}
var hiNumX = GetTemperatureLabelXPosition(370, high.Value); var hiNumX = GetTemperatureLabelXPosition(370, high.Value);
var hiUnitX = GetTemperatureLabelXPosition(360, high.Value); var hiUnitX = GetTemperatureLabelXPosition(360, high.Value);
@@ -1493,24 +1398,16 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static int GetTemperatureLabelXPosition(int baseX, int temperature) private static int GetTemperatureLabelXPosition(int baseX, int temperature)
{ {
const int xOffset = 70; const int xOffset = 70;
if (temperature < -9 || temperature > 99) if (temperature < -9 || temperature > 99) return baseX + xOffset;
{
return baseX + xOffset;
}
if (temperature is >= 0 and < 10) if (temperature is >= 0 and < 10) return baseX - xOffset;
{
return baseX - xOffset;
}
return baseX; return baseX;
} }
private static int? TryReadPayloadInt(IDictionary<string, object?>? payload, string key) private static int? TryReadPayloadInt(IDictionary<string, object?>? payload, string key)
{ {
if (payload is null || !payload.TryGetValue(key, out var value) || value is null) if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return null;
{
return null;
}
return value switch return value switch
{ {
@@ -1519,18 +1416,17 @@ public sealed class ResponsePlanToSocketMessagesMapper
double number => (int)Math.Round(number, MidpointRounding.AwayFromZero), double number => (int)Math.Round(number, MidpointRounding.AwayFromZero),
float number => (int)Math.Round(number, MidpointRounding.AwayFromZero), float number => (int)Math.Round(number, MidpointRounding.AwayFromZero),
string text when int.TryParse(text, out var parsed) => parsed, string text when int.TryParse(text, out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.Number } jsonNumber when jsonNumber.TryGetInt32(out var parsed) => parsed, JsonElement { ValueKind: JsonValueKind.Number } jsonNumber when jsonNumber.TryGetInt32(out var parsed) =>
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && int.TryParse(jsonText.GetString(), out var parsed) => parsed, parsed,
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String &&
int.TryParse(jsonText.GetString(), out var parsed) => parsed,
_ => null _ => null
}; };
} }
private static bool TryReadPayloadBool(IDictionary<string, object?>? payload, string key) private static bool TryReadPayloadBool(IDictionary<string, object?>? payload, string key)
{ {
if (payload is null || !payload.TryGetValue(key, out var value) || value is null) if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return false;
{
return false;
}
return value switch return value switch
{ {
@@ -1538,7 +1434,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
string text when bool.TryParse(text, out var parsed) => parsed, string text when bool.TryParse(text, out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.True } => true, JsonElement { ValueKind: JsonValueKind.True } => true,
JsonElement { ValueKind: JsonValueKind.False } => false, JsonElement { ValueKind: JsonValueKind.False } => false,
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && bool.TryParse(jsonText.GetString(), out var parsed) => parsed, JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String &&
bool.TryParse(jsonText.GetString(), out var parsed) => parsed,
_ => false _ => false
}; };
} }
@@ -1561,4 +1458,3 @@ public sealed class ResponsePlanToSocketMessagesMapper
public sealed record SocketReplyPlan(string Text, int DelayMs = 0); public sealed record SocketReplyPlan(string Text, int DelayMs = 0);
} }

View File

@@ -16,9 +16,7 @@ public sealed class SyntheticBufferedAudioSttStrategy : ISttStrategy
{ {
var transcriptHint = ReadTranscriptHint(turn); var transcriptHint = ReadTranscriptHint(turn);
if (string.IsNullOrWhiteSpace(transcriptHint)) if (string.IsNullOrWhiteSpace(transcriptHint))
{
throw new InvalidOperationException("Synthetic buffered audio STT requires an audio transcript hint."); throw new InvalidOperationException("Synthetic buffered audio STT requires an audio transcript hint.");
}
return Task.FromResult(new SttResult return Task.FromResult(new SttResult
{ {
@@ -36,10 +34,7 @@ public sealed class SyntheticBufferedAudioSttStrategy : ISttStrategy
private static int ReadBufferedAudioBytes(TurnContext turn) private static int ReadBufferedAudioBytes(TurnContext turn)
{ {
if (!turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes)) if (!turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes)) return 0;
{
return 0;
}
return bufferedAudioBytes switch return bufferedAudioBytes switch
{ {

View File

@@ -7,5 +7,7 @@ public sealed class CapturedExchange
public ProtocolEnvelope Request { get; init; } = new(); public ProtocolEnvelope Request { get; init; } = new();
public ProtocolDispatchResult Response { get; init; } = ProtocolDispatchResult.Ok(); public ProtocolDispatchResult Response { get; init; } = ProtocolDispatchResult.Ok();
public string Confidence { get; init; } = "observed"; public string Confidence { get; init; } = "observed";
public IDictionary<string, string> Tags { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> Tags { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
} }

View File

@@ -8,5 +8,7 @@ public sealed class DeviceRegistration
public string? FirmwareVersion { get; init; } public string? FirmwareVersion { get; init; }
public string? ApplicationVersion { get; init; } public string? ApplicationVersion { get; init; }
public bool IsActive { get; init; } = true; public bool IsActive { get; init; } = true;
public IDictionary<string, string> HostMappings { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> HostMappings { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
} }

View File

@@ -7,7 +7,9 @@ public sealed class ProtocolDispatchResult
public int StatusCode { get; init; } = 200; public int StatusCode { get; init; } = 200;
public string ContentType { get; init; } = "application/x-amz-json-1.1"; public string ContentType { get; init; } = "application/x-amz-json-1.1";
public string BodyText { get; init; } = "{}"; public string BodyText { get; init; } = "{}";
public IDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> Headers { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public static ProtocolDispatchResult Ok(object? body = null) public static ProtocolDispatchResult Ok(object? body = null)
{ {

View File

@@ -17,14 +17,13 @@ public sealed class ProtocolEnvelope
public string? FirmwareVersion { get; init; } public string? FirmwareVersion { get; init; }
public string? ApplicationVersion { get; init; } public string? ApplicationVersion { get; init; }
public string BodyText { get; init; } = string.Empty; public string BodyText { get; init; } = string.Empty;
public IDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> Headers { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public JsonElement? TryParseBody() public JsonElement? TryParseBody()
{ {
if (string.IsNullOrWhiteSpace(BodyText)) if (string.IsNullOrWhiteSpace(BodyText)) return null;
{
return null;
}
try try
{ {

View File

@@ -20,5 +20,7 @@ public sealed class WebSocketTelemetryRecord
public int BufferedAudioChunks { get; init; } public int BufferedAudioChunks { get; init; }
public int FinalizeAttempts { get; init; } public int FinalizeAttempts { get; init; }
public bool AwaitingTurnCompletion { get; init; } public bool AwaitingTurnCompletion { get; init; }
public IReadOnlyDictionary<string, object?> Details { get; init; } = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, object?> Details { get; init; } =
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
} }

View File

@@ -4,7 +4,8 @@ namespace Jibo.Cloud.Infrastructure.Audio;
public sealed class ExternalProcessRunner : IExternalProcessRunner public sealed class ExternalProcessRunner : IExternalProcessRunner
{ {
public async Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, CancellationToken cancellationToken = default) public async Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments,
CancellationToken cancellationToken = default)
{ {
using var process = new Process(); using var process = new Process();
process.StartInfo = new ProcessStartInfo process.StartInfo = new ProcessStartInfo
@@ -16,10 +17,7 @@ public sealed class ExternalProcessRunner : IExternalProcessRunner
CreateNoWindow = true CreateNoWindow = true
}; };
foreach (var argument in arguments) foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(argument);
{
process.StartInfo.ArgumentList.Add(argument);
}
process.Start(); process.Start();

View File

@@ -2,7 +2,8 @@ namespace Jibo.Cloud.Infrastructure.Audio;
public interface IExternalProcessRunner public interface IExternalProcessRunner
{ {
Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, CancellationToken cancellationToken = default); Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments,
CancellationToken cancellationToken = default);
} }
public sealed record ExternalProcessResult(int ExitCode, string StdOut, string StdErr); public sealed record ExternalProcessResult(int ExitCode, string StdOut, string StdErr);

View File

@@ -12,9 +12,9 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
public bool CanHandle(TurnContext turn) public bool CanHandle(TurnContext turn)
{ {
return options.EnableLocalWhisperCpp && return options.EnableLocalWhisperCpp &&
IsConfiguredPathAvailable(options.FfmpegPath, checkFileExists: false) && IsConfiguredPathAvailable(options.FfmpegPath, false) &&
IsConfiguredPathAvailable(options.WhisperCliPath, checkFileExists: true) && IsConfiguredPathAvailable(options.WhisperCliPath, true) &&
IsConfiguredPathAvailable(options.WhisperModelPath, checkFileExists: true) && IsConfiguredPathAvailable(options.WhisperModelPath, true) &&
ReadBufferedAudioFrames(turn).Any(ContainsOpusIdentificationHeader); ReadBufferedAudioFrames(turn).Any(ContainsOpusIdentificationHeader);
} }
@@ -22,20 +22,14 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
{ {
var frames = ReadBufferedAudioFrames(turn); var frames = ReadBufferedAudioFrames(turn);
if (frames.Count == 0) if (frames.Count == 0)
{
throw new InvalidOperationException("Local whisper.cpp STT requires buffered websocket audio frames."); throw new InvalidOperationException("Local whisper.cpp STT requires buffered websocket audio frames.");
}
if (!frames.Any(ContainsOpusIdentificationHeader)) if (!frames.Any(ContainsOpusIdentificationHeader))
{ throw new InvalidOperationException(
throw new InvalidOperationException("Local whisper.cpp STT requires buffered Ogg/Opus audio with an Opus identification header."); "Local whisper.cpp STT requires buffered Ogg/Opus audio with an Opus identification header.");
}
var tempDirectory = options.TempDirectory; var tempDirectory = options.TempDirectory;
if (string.IsNullOrWhiteSpace(tempDirectory)) if (string.IsNullOrWhiteSpace(tempDirectory)) tempDirectory = Path.Combine(Path.GetTempPath(), "openjibo-stt");
{
tempDirectory = Path.Combine(Path.GetTempPath(), "openjibo-stt");
}
Directory.CreateDirectory(tempDirectory); Directory.CreateDirectory(tempDirectory);
@@ -59,9 +53,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
var transcript = ExtractTranscript(whisperResult.StdOut); var transcript = ExtractTranscript(whisperResult.StdOut);
if (string.IsNullOrWhiteSpace(transcript)) if (string.IsNullOrWhiteSpace(transcript))
{
throw new InvalidOperationException("whisper.cpp returned no transcript for the buffered audio turn."); throw new InvalidOperationException("whisper.cpp returned no transcript for the buffered audio turn.");
}
return new SttResult return new SttResult
{ {
@@ -90,10 +82,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
private static IReadOnlyList<byte[]> ReadBufferedAudioFrames(TurnContext turn) private static IReadOnlyList<byte[]> ReadBufferedAudioFrames(TurnContext turn)
{ {
if (!turn.Attributes.TryGetValue("bufferedAudioFrames", out var value) || value is null) if (!turn.Attributes.TryGetValue("bufferedAudioFrames", out var value) || value is null) return [];
{
return [];
}
return value switch return value switch
{ {
@@ -110,7 +99,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
private static int ReadBufferedAudioBytes(TurnContext turn) private static int ReadBufferedAudioBytes(TurnContext turn)
{ {
return turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes) && bufferedAudioBytes is not null return turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes) &&
bufferedAudioBytes is not null
? bufferedAudioBytes switch ? bufferedAudioBytes switch
{ {
int value => value, int value => value,
@@ -148,10 +138,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
{ {
try try
{ {
if (File.Exists(path)) if (File.Exists(path)) File.Delete(path);
{
File.Delete(path);
}
} }
catch catch
{ {
@@ -161,15 +148,9 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
private static bool IsConfiguredPathAvailable(string? path, bool checkFileExists) private static bool IsConfiguredPathAvailable(string? path, bool checkFileExists)
{ {
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path)) return false;
{
return false;
}
if (!Path.IsPathRooted(path)) if (!Path.IsPathRooted(path)) return true;
{
return true;
}
return !checkFileExists || File.Exists(path); return !checkFileExists || File.Exists(path);
} }

View File

@@ -9,10 +9,7 @@ internal static class OggOpusAudioNormalizer
public static byte[] Normalize(IReadOnlyList<byte[]> pages) public static byte[] Normalize(IReadOnlyList<byte[]> pages)
{ {
if (pages.Count == 0) if (pages.Count == 0) return [];
{
return [];
}
var parsed = pages.Select(ParsePage).ToArray(); var parsed = pages.Select(ParsePage).ToArray();
var baseGranule = parsed.Length > 1 ? parsed[1].GranulePosition : parsed[0].GranulePosition; var baseGranule = parsed.Length > 1 ? parsed[1].GranulePosition : parsed[0].GranulePosition;
@@ -50,26 +47,17 @@ internal static class OggOpusAudioNormalizer
private static ParsedOggPage ParsePage(byte[] buffer) private static ParsedOggPage ParsePage(byte[] buffer)
{ {
if (buffer.Length < 27) if (buffer.Length < 27)
{
throw new InvalidOperationException($"Buffered Ogg page is too short ({buffer.Length} bytes)."); throw new InvalidOperationException($"Buffered Ogg page is too short ({buffer.Length} bytes).");
}
if (!Encoding.ASCII.GetString(buffer, 0, 4).Equals("OggS", StringComparison.Ordinal)) if (!Encoding.ASCII.GetString(buffer, 0, 4).Equals("OggS", StringComparison.Ordinal))
{
throw new InvalidOperationException("Buffered audio frame did not begin with an OggS capture pattern."); throw new InvalidOperationException("Buffered audio frame did not begin with an OggS capture pattern.");
}
var pageSegments = buffer[26]; var pageSegments = buffer[26];
if (buffer.Length < 27 + pageSegments) if (buffer.Length < 27 + pageSegments)
{
throw new InvalidOperationException("Buffered Ogg page segment table was truncated."); throw new InvalidOperationException("Buffered Ogg page segment table was truncated.");
}
var payloadLength = 0; var payloadLength = 0;
for (var index = 0; index < pageSegments; index += 1) for (var index = 0; index < pageSegments; index += 1) payloadLength += buffer[27 + index];
{
payloadLength += buffer[27 + index];
}
var expectedLength = 27 + pageSegments + payloadLength; var expectedLength = 27 + pageSegments + payloadLength;
return buffer.Length < expectedLength return buffer.Length < expectedLength
@@ -79,7 +67,8 @@ internal static class OggOpusAudioNormalizer
private static uint ComputeCrc(byte[] buffer) private static uint ComputeCrc(byte[] buffer)
{ {
return buffer.Aggregate<byte, uint>(0, (current, value) => (current << 8) ^ CrcTable[((current >> 24) ^ value) & 0xff]); return buffer.Aggregate<byte, uint>(0,
(current, value) => (current << 8) ^ CrcTable[((current >> 24) ^ value) & 0xff]);
} }
private static uint[] BuildCrcTable() private static uint[] BuildCrcTable()
@@ -89,11 +78,9 @@ internal static class OggOpusAudioNormalizer
{ {
var remainder = index << 24; var remainder = index << 24;
for (var bit = 0; bit < 8; bit += 1) for (var bit = 0; bit < 8; bit += 1)
{
remainder = (remainder & 0x80000000) != 0 remainder = (remainder & 0x80000000) != 0
? (remainder << 1) ^ 0x04c11db7 ? (remainder << 1) ^ 0x04c11db7
: remainder << 1; : remainder << 1;
}
table[index] = remainder; table[index] = remainder;
} }

View File

@@ -6,6 +6,11 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
{ {
private static readonly JiboExperienceCatalog Catalog = BuildCatalog(); private static readonly JiboExperienceCatalog Catalog = BuildCatalog();
public Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(Catalog);
}
private static JiboExperienceCatalog BuildCatalog() private static JiboExperienceCatalog BuildCatalog()
{ {
var catalog = new JiboExperienceCatalog var catalog = new JiboExperienceCatalog
@@ -148,9 +153,7 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
}; };
foreach (var seedDirectory in ResolveSeedDirectories()) foreach (var seedDirectory in ResolveSeedDirectories())
{
catalog = LegacyMimCatalogImporter.MergeInto(catalog, seedDirectory); catalog = LegacyMimCatalogImporter.MergeInto(catalog, seedDirectory);
}
return catalog; return catalog;
} }
@@ -211,9 +214,4 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
return candidates.Where(Directory.Exists).ToArray(); return candidates.Where(Directory.Exists).ToArray();
} }
public Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(Catalog);
}
} }

View File

@@ -34,15 +34,9 @@ public static class LegacyMimCatalogImporter
JiboExperienceCatalog baseCatalog, JiboExperienceCatalog baseCatalog,
string? rootDirectory) string? rootDirectory)
{ {
if (baseCatalog is null) if (baseCatalog is null) throw new ArgumentNullException(nameof(baseCatalog));
{
throw new ArgumentNullException(nameof(baseCatalog));
}
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) return baseCatalog;
{
return baseCatalog;
}
var importedCatalog = ImportCatalog(rootDirectory); var importedCatalog = ImportCatalog(rootDirectory);
return MergeCatalogs(baseCatalog, importedCatalog); return MergeCatalogs(baseCatalog, importedCatalog);
@@ -51,32 +45,21 @@ public static class LegacyMimCatalogImporter
public static JiboExperienceCatalog ImportCatalog(string rootDirectory) public static JiboExperienceCatalog ImportCatalog(string rootDirectory)
{ {
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
{
return new JiboExperienceCatalog(); return new JiboExperienceCatalog();
}
var builder = new LegacyMimCatalogBuilder(); var builder = new LegacyMimCatalogBuilder();
foreach (var filePath in Directory.EnumerateFiles(rootDirectory, "*.mim", SearchOption.AllDirectories) foreach (var filePath in Directory.EnumerateFiles(rootDirectory, "*.mim", SearchOption.AllDirectories)
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)) .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
{ {
if (!TryLoadDefinition(filePath, out var definition)) if (!TryLoadDefinition(filePath, out var definition)) continue;
{
continue;
}
var bucket = ResolveBucket(filePath); var bucket = ResolveBucket(filePath);
if (bucket is null) if (bucket is null) continue;
{
continue;
}
foreach (var prompt in definition.Prompts) foreach (var prompt in definition.Prompts)
{ {
var text = NormalizePrompt(prompt.Prompt, preservePlaceholders: IsTemplateBucket(bucket.Value)); var text = NormalizePrompt(prompt.Prompt, IsTemplateBucket(bucket.Value));
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text)) continue;
{
continue;
}
builder.Add(bucket.Value, prompt.Condition, text); builder.Add(bucket.Value, prompt.Condition, text);
} }
@@ -92,10 +75,7 @@ public static class LegacyMimCatalogImporter
{ {
var json = File.ReadAllText(filePath); var json = File.ReadAllText(filePath);
var parsed = JsonSerializer.Deserialize<LegacyMimDefinition>(json, JsonOptions); var parsed = JsonSerializer.Deserialize<LegacyMimDefinition>(json, JsonOptions);
if (parsed is null) if (parsed is null) return false;
{
return false;
}
definition = parsed; definition = parsed;
return definition.Prompts.Count > 0; return definition.Prompts.Count > 0;
@@ -113,110 +93,67 @@ public static class LegacyMimCatalogImporter
if (normalizedPath.Contains("/core-responses/", StringComparison.OrdinalIgnoreCase) && if (normalizedPath.Contains("/core-responses/", StringComparison.OrdinalIgnoreCase) &&
fileName.Contains("Error", StringComparison.OrdinalIgnoreCase)) fileName.Contains("Error", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.GenericFallback; return LegacyMimBucket.GenericFallback;
}
if (normalizedPath.Contains("/core-responses/deflector/", StringComparison.OrdinalIgnoreCase) || if (normalizedPath.Contains("/core-responses/deflector/", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Deflector", StringComparison.OrdinalIgnoreCase)) fileName.Contains("Deflector", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Personality; return LegacyMimBucket.Personality;
}
if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase) || if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase) ||
normalizedPath.Contains("/gqa-responses/", StringComparison.OrdinalIgnoreCase)) normalizedPath.Contains("/gqa-responses/", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Emotion; return LegacyMimBucket.Emotion;
}
if (fileName.StartsWith("WeatherIntroTomorrow", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("WeatherIntroTomorrow", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherTomorrowIntro; return LegacyMimBucket.WeatherTomorrowIntro;
}
if (fileName.StartsWith("WeatherIntro", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("WeatherIntro", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherIntro; return LegacyMimBucket.WeatherIntro;
}
if (fileName.StartsWith("WeatherTomorrowHighLow", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("WeatherTomorrowHighLow", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherTomorrowHighLow; return LegacyMimBucket.WeatherTomorrowHighLow;
}
if (fileName.StartsWith("WeatherTodayHighLow", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("WeatherTodayHighLow", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherTodayHighLow; return LegacyMimBucket.WeatherTodayHighLow;
}
if (fileName.StartsWith("WeatherServiceDown", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("WeatherServiceDown", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherServiceDown; return LegacyMimBucket.WeatherServiceDown;
}
if (fileName.StartsWith("CalendarNothingToday", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("CalendarNothingToday", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.CalendarNothingToday; return LegacyMimBucket.CalendarNothingToday;
}
if (fileName.StartsWith("CalendarNothing", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("CalendarNothing", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.CalendarNothing; return LegacyMimBucket.CalendarNothing;
}
if (fileName.StartsWith("CalendarOutro", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("CalendarOutro", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.CalendarOutro; return LegacyMimBucket.CalendarOutro;
}
if (fileName.StartsWith("CommuteNow", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("CommuteNow", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.CommuteNow;
{
return LegacyMimBucket.CommuteNow;
}
if (fileName.StartsWith("CommuteServiceDown", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("CommuteServiceDown", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.CommuteServiceDown; return LegacyMimBucket.CommuteServiceDown;
}
if (fileName.StartsWith("NewsIntroCategory", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("NewsIntroCategory", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.NewsCategoryIntro; return LegacyMimBucket.NewsCategoryIntro;
}
if (fileName.StartsWith("NewsIntro", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("NewsIntro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsIntro;
{
return LegacyMimBucket.NewsIntro;
}
if (fileName.StartsWith("NewsOutro", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("NewsOutro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsOutro;
{
return LegacyMimBucket.NewsOutro;
}
if (fileName.StartsWith("Weather", StringComparison.OrdinalIgnoreCase) || if (fileName.StartsWith("Weather", StringComparison.OrdinalIgnoreCase) ||
string.Equals(fileName, "WetNowDryLater", StringComparison.OrdinalIgnoreCase)) string.Equals(fileName, "WetNowDryLater", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.ReportSkillTemplate; return LegacyMimBucket.ReportSkillTemplate;
}
if (fileName.StartsWith("PersonalReportKickOff", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("PersonalReportKickOff", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.PersonalReportKickOff; return LegacyMimBucket.PersonalReportKickOff;
}
if (fileName.StartsWith("PersonalReportOutro", StringComparison.OrdinalIgnoreCase)) if (fileName.StartsWith("PersonalReportOutro", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.PersonalReportOutro; return LegacyMimBucket.PersonalReportOutro;
}
if (fileName.StartsWith("PersonalReport", StringComparison.OrdinalIgnoreCase) || if (fileName.StartsWith("PersonalReport", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Calendar", StringComparison.OrdinalIgnoreCase) || fileName.Contains("Calendar", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Commute", StringComparison.OrdinalIgnoreCase) || fileName.Contains("Commute", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("News", StringComparison.OrdinalIgnoreCase)) fileName.Contains("News", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.ReportSkillTemplate; return LegacyMimBucket.ReportSkillTemplate;
}
if (fileName.StartsWith("JBO_DoYouLikeBeingJibo", StringComparison.OrdinalIgnoreCase) || if (fileName.StartsWith("JBO_DoYouLikeBeingJibo", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhatIsJibo", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("JBO_WhatIsJibo", StringComparison.OrdinalIgnoreCase) ||
@@ -229,9 +166,7 @@ public static class LegacyMimCatalogImporter
fileName.StartsWith("JBO_WhatsYourName", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("JBO_WhatsYourName", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhereDoYouGetInfo", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("JBO_WhereDoYouGetInfo", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhatDoYouLikeToDo", StringComparison.OrdinalIgnoreCase)) fileName.StartsWith("JBO_WhatDoYouLikeToDo", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Personality; return LegacyMimBucket.Personality;
}
if (fileName.StartsWith("OI_JBO_Is", StringComparison.OrdinalIgnoreCase) || if (fileName.StartsWith("OI_JBO_Is", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("OI_JBO_Seems", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("OI_JBO_Seems", StringComparison.OrdinalIgnoreCase) ||
@@ -239,42 +174,30 @@ public static class LegacyMimCatalogImporter
fileName.StartsWith("RI_JBO_IsSad", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("RI_JBO_IsSad", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_IsAngry", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("RI_JBO_IsAngry", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase)) fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Emotion; return LegacyMimBucket.Emotion;
}
if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) || if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RN_", StringComparison.OrdinalIgnoreCase) || fileName.StartsWith("RN_", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Welcome", StringComparison.OrdinalIgnoreCase)) fileName.Contains("Welcome", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Greeting; return LegacyMimBucket.Greeting;
}
if (normalizedPath.Contains("/scripted-responses/", StringComparison.OrdinalIgnoreCase)) if (normalizedPath.Contains("/scripted-responses/", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Personality; return LegacyMimBucket.Personality;
}
return null; return null;
} }
private static string NormalizePrompt(string? prompt) private static string NormalizePrompt(string? prompt)
{ {
return NormalizePrompt(prompt, preservePlaceholders: false); return NormalizePrompt(prompt, false);
} }
private static string NormalizePrompt(string? prompt, bool preservePlaceholders) private static string NormalizePrompt(string? prompt, bool preservePlaceholders)
{ {
if (string.IsNullOrWhiteSpace(prompt)) if (string.IsNullOrWhiteSpace(prompt)) return string.Empty;
{
return string.Empty;
}
var text = WebUtility.HtmlDecode(prompt); var text = WebUtility.HtmlDecode(prompt);
if (!preservePlaceholders) if (!preservePlaceholders) text = PlaceholderPattern.Replace(text, " ");
{
text = PlaceholderPattern.Replace(text, " ");
}
text = LegacyMarkupPattern.Replace(text, " "); text = LegacyMarkupPattern.Replace(text, " ");
text = WhitespacePattern.Replace(text, " ").Trim(); text = WhitespacePattern.Replace(text, " ").Trim();
text = SpaceBeforePunctuationPattern.Replace(text, "$1"); text = SpaceBeforePunctuationPattern.Replace(text, "$1");
@@ -298,21 +221,30 @@ public static class LegacyMimCatalogImporter
PizzaReplies = Merge(baseCatalog.PizzaReplies, importedCatalog.PizzaReplies), PizzaReplies = Merge(baseCatalog.PizzaReplies, importedCatalog.PizzaReplies),
SurpriseReplies = Merge(baseCatalog.SurpriseReplies, importedCatalog.SurpriseReplies), SurpriseReplies = Merge(baseCatalog.SurpriseReplies, importedCatalog.SurpriseReplies),
PersonalReportReplies = Merge(baseCatalog.PersonalReportReplies, importedCatalog.PersonalReportReplies), PersonalReportReplies = Merge(baseCatalog.PersonalReportReplies, importedCatalog.PersonalReportReplies),
PersonalReportKickOffReplies = Merge(baseCatalog.PersonalReportKickOffReplies, importedCatalog.PersonalReportKickOffReplies), PersonalReportKickOffReplies = Merge(baseCatalog.PersonalReportKickOffReplies,
PersonalReportOutroReplies = Merge(baseCatalog.PersonalReportOutroReplies, importedCatalog.PersonalReportOutroReplies), importedCatalog.PersonalReportKickOffReplies),
PersonalReportOutroReplies = Merge(baseCatalog.PersonalReportOutroReplies,
importedCatalog.PersonalReportOutroReplies),
ReportSkillTemplates = Merge(baseCatalog.ReportSkillTemplates, importedCatalog.ReportSkillTemplates), ReportSkillTemplates = Merge(baseCatalog.ReportSkillTemplates, importedCatalog.ReportSkillTemplates),
WeatherIntroReplies = Merge(baseCatalog.WeatherIntroReplies, importedCatalog.WeatherIntroReplies), WeatherIntroReplies = Merge(baseCatalog.WeatherIntroReplies, importedCatalog.WeatherIntroReplies),
WeatherTomorrowIntroReplies = Merge(baseCatalog.WeatherTomorrowIntroReplies, importedCatalog.WeatherTomorrowIntroReplies), WeatherTomorrowIntroReplies = Merge(baseCatalog.WeatherTomorrowIntroReplies,
WeatherTodayHighLowReplies = Merge(baseCatalog.WeatherTodayHighLowReplies, importedCatalog.WeatherTodayHighLowReplies), importedCatalog.WeatherTomorrowIntroReplies),
WeatherTomorrowHighLowReplies = Merge(baseCatalog.WeatherTomorrowHighLowReplies, importedCatalog.WeatherTomorrowHighLowReplies), WeatherTodayHighLowReplies = Merge(baseCatalog.WeatherTodayHighLowReplies,
WeatherServiceDownReplies = Merge(baseCatalog.WeatherServiceDownReplies, importedCatalog.WeatherServiceDownReplies), importedCatalog.WeatherTodayHighLowReplies),
CalendarNothingTodayReplies = Merge(baseCatalog.CalendarNothingTodayReplies, importedCatalog.CalendarNothingTodayReplies), WeatherTomorrowHighLowReplies = Merge(baseCatalog.WeatherTomorrowHighLowReplies,
importedCatalog.WeatherTomorrowHighLowReplies),
WeatherServiceDownReplies = Merge(baseCatalog.WeatherServiceDownReplies,
importedCatalog.WeatherServiceDownReplies),
CalendarNothingTodayReplies = Merge(baseCatalog.CalendarNothingTodayReplies,
importedCatalog.CalendarNothingTodayReplies),
CalendarNothingReplies = Merge(baseCatalog.CalendarNothingReplies, importedCatalog.CalendarNothingReplies), CalendarNothingReplies = Merge(baseCatalog.CalendarNothingReplies, importedCatalog.CalendarNothingReplies),
CalendarOutroReplies = Merge(baseCatalog.CalendarOutroReplies, importedCatalog.CalendarOutroReplies), CalendarOutroReplies = Merge(baseCatalog.CalendarOutroReplies, importedCatalog.CalendarOutroReplies),
CommuteNowReplies = Merge(baseCatalog.CommuteNowReplies, importedCatalog.CommuteNowReplies), CommuteNowReplies = Merge(baseCatalog.CommuteNowReplies, importedCatalog.CommuteNowReplies),
CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies, importedCatalog.CommuteServiceDownReplies), CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies,
importedCatalog.CommuteServiceDownReplies),
NewsIntroReplies = Merge(baseCatalog.NewsIntroReplies, importedCatalog.NewsIntroReplies), NewsIntroReplies = Merge(baseCatalog.NewsIntroReplies, importedCatalog.NewsIntroReplies),
NewsCategoryIntroReplies = Merge(baseCatalog.NewsCategoryIntroReplies, importedCatalog.NewsCategoryIntroReplies), NewsCategoryIntroReplies =
Merge(baseCatalog.NewsCategoryIntroReplies, importedCatalog.NewsCategoryIntroReplies),
NewsOutroReplies = Merge(baseCatalog.NewsOutroReplies, importedCatalog.NewsOutroReplies), NewsOutroReplies = Merge(baseCatalog.NewsOutroReplies, importedCatalog.NewsOutroReplies),
WeatherReplies = Merge(baseCatalog.WeatherReplies, importedCatalog.WeatherReplies), WeatherReplies = Merge(baseCatalog.WeatherReplies, importedCatalog.WeatherReplies),
CalendarReplies = Merge(baseCatalog.CalendarReplies, importedCatalog.CalendarReplies), CalendarReplies = Merge(baseCatalog.CalendarReplies, importedCatalog.CalendarReplies),
@@ -332,16 +264,10 @@ public static class LegacyMimCatalogImporter
foreach (var value in baseList.Concat(importedList)) foreach (var value in baseList.Concat(importedList))
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value)) continue;
{
continue;
}
var normalized = value.Trim(); var normalized = value.Trim();
if (!seen.Add(normalized)) if (!seen.Add(normalized)) continue;
{
continue;
}
merged.Add(normalized); merged.Add(normalized);
} }
@@ -358,18 +284,12 @@ public static class LegacyMimCatalogImporter
foreach (var value in baseList.Concat(importedList)) foreach (var value in baseList.Concat(importedList))
{ {
if (string.IsNullOrWhiteSpace(value.Reply)) if (string.IsNullOrWhiteSpace(value.Reply)) continue;
{
continue;
}
var normalizedCondition = NormalizeCondition(value.Condition); var normalizedCondition = NormalizeCondition(value.Condition);
var normalizedReply = value.Reply.Trim(); var normalizedReply = value.Reply.Trim();
var key = $"{normalizedCondition}::{normalizedReply}"; var key = $"{normalizedCondition}::{normalizedReply}";
if (!seen.Add(key)) if (!seen.Add(key)) continue;
{
continue;
}
merged.Add(new JiboConditionedReply merged.Add(new JiboConditionedReply
{ {
@@ -381,6 +301,23 @@ public static class LegacyMimCatalogImporter
return merged; return merged;
} }
private static string NormalizeCondition(string? condition)
{
return string.IsNullOrWhiteSpace(condition) ? string.Empty : WhitespacePattern.Replace(condition.Trim(), " ");
}
private static bool IsTemplateBucket(LegacyMimBucket bucket)
{
return bucket is LegacyMimBucket.PersonalReportKickOff
or LegacyMimBucket.PersonalReportOutro
or LegacyMimBucket.WeatherIntro
or LegacyMimBucket.WeatherTomorrowIntro
or LegacyMimBucket.WeatherTodayHighLow
or LegacyMimBucket.WeatherTomorrowHighLow
or LegacyMimBucket.WeatherServiceDown
or LegacyMimBucket.ReportSkillTemplate;
}
private enum LegacyMimBucket private enum LegacyMimBucket
{ {
GenericFallback, GenericFallback,
@@ -408,64 +345,55 @@ public static class LegacyMimCatalogImporter
private sealed class LegacyMimCatalogBuilder private sealed class LegacyMimCatalogBuilder
{ {
private readonly List<string> _calendarNothingReplies = [];
private readonly List<string> _calendarNothingTodayReplies = [];
private readonly List<string> _calendarOutroReplies = [];
private readonly List<string> _commuteNowReplies = [];
private readonly List<string> _commuteServiceDownReplies = [];
private readonly List<JiboConditionedReply> _emotionReplies = [];
private readonly List<string> _fallbacks = [];
private readonly List<string> _greetings = []; private readonly List<string> _greetings = [];
private readonly List<string> _howAreYous = []; private readonly List<string> _howAreYous = [];
private readonly List<JiboConditionedReply> _emotionReplies = []; private readonly List<string> _newsCategoryIntroReplies = [];
private readonly List<string> _newsIntroReplies = [];
private readonly List<string> _newsOutroReplies = [];
private readonly List<string> _personalities = []; private readonly List<string> _personalities = [];
private readonly List<string> _fallbacks = [];
private readonly List<string> _personalReportKickOffReplies = []; private readonly List<string> _personalReportKickOffReplies = [];
private readonly List<string> _personalReportOutroReplies = []; private readonly List<string> _personalReportOutroReplies = [];
private readonly List<string> _reportSkillTemplates = []; private readonly List<string> _reportSkillTemplates = [];
private readonly List<string> _weatherIntroReplies = []; private readonly List<string> _weatherIntroReplies = [];
private readonly List<string> _weatherTomorrowIntroReplies = []; private readonly List<string> _weatherServiceDownReplies = [];
private readonly List<string> _weatherTodayHighLowReplies = []; private readonly List<string> _weatherTodayHighLowReplies = [];
private readonly List<string> _weatherTomorrowHighLowReplies = []; private readonly List<string> _weatherTomorrowHighLowReplies = [];
private readonly List<string> _weatherServiceDownReplies = []; private readonly List<string> _weatherTomorrowIntroReplies = [];
private readonly List<string> _calendarNothingTodayReplies = [];
private readonly List<string> _calendarNothingReplies = [];
private readonly List<string> _calendarOutroReplies = [];
private readonly List<string> _commuteNowReplies = [];
private readonly List<string> _commuteServiceDownReplies = [];
private readonly List<string> _newsIntroReplies = [];
private readonly List<string> _newsCategoryIntroReplies = [];
private readonly List<string> _newsOutroReplies = [];
public void Add(LegacyMimBucket bucket, string? condition, string text) public void Add(LegacyMimBucket bucket, string? condition, string text)
{ {
switch (bucket) switch (bucket)
{ {
case LegacyMimBucket.GenericFallback: case LegacyMimBucket.GenericFallback:
if (_fallbacks.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) if (_fallbacks.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
{
return;
}
_fallbacks.Add(text); _fallbacks.Add(text);
return; return;
case LegacyMimBucket.Greeting: case LegacyMimBucket.Greeting:
if (_greetings.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) if (_greetings.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
{
return;
}
_greetings.Add(text); _greetings.Add(text);
return; return;
case LegacyMimBucket.HowAreYou: case LegacyMimBucket.HowAreYou:
if (_howAreYous.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) if (_howAreYous.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return; return;
}
_howAreYous.Add(text); _howAreYous.Add(text);
return; return;
case LegacyMimBucket.Emotion: case LegacyMimBucket.Emotion:
var normalizedCondition = NormalizeCondition(condition); var normalizedCondition = NormalizeCondition(condition);
if (_emotionReplies.Any(value => if (_emotionReplies.Any(value =>
string.Equals(NormalizeCondition(value.Condition), normalizedCondition, StringComparison.OrdinalIgnoreCase) && string.Equals(NormalizeCondition(value.Condition), normalizedCondition,
StringComparison.OrdinalIgnoreCase) &&
string.Equals(value.Reply, text, StringComparison.OrdinalIgnoreCase))) string.Equals(value.Reply, text, StringComparison.OrdinalIgnoreCase)))
{
return; return;
}
_emotionReplies.Add(new JiboConditionedReply _emotionReplies.Add(new JiboConditionedReply
{ {
@@ -475,9 +403,7 @@ public static class LegacyMimCatalogImporter
return; return;
case LegacyMimBucket.Personality: case LegacyMimBucket.Personality:
if (_personalities.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) if (_personalities.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return; return;
}
_personalities.Add(text); _personalities.Add(text);
return; return;
@@ -550,8 +476,7 @@ public static class LegacyMimCatalogImporter
WeatherTomorrowIntroReplies = [.. _weatherTomorrowIntroReplies], WeatherTomorrowIntroReplies = [.. _weatherTomorrowIntroReplies],
WeatherTodayHighLowReplies = [.. _weatherTodayHighLowReplies], WeatherTodayHighLowReplies = [.. _weatherTodayHighLowReplies],
WeatherTomorrowHighLowReplies = [.. _weatherTomorrowHighLowReplies], WeatherTomorrowHighLowReplies = [.. _weatherTomorrowHighLowReplies],
WeatherServiceDownReplies = [.. _weatherServiceDownReplies] WeatherServiceDownReplies = [.. _weatherServiceDownReplies],
,
CalendarNothingTodayReplies = [.. _calendarNothingTodayReplies], CalendarNothingTodayReplies = [.. _calendarNothingTodayReplies],
CalendarNothingReplies = [.. _calendarNothingReplies], CalendarNothingReplies = [.. _calendarNothingReplies],
CalendarOutroReplies = [.. _calendarOutroReplies], CalendarOutroReplies = [.. _calendarOutroReplies],
@@ -565,10 +490,7 @@ public static class LegacyMimCatalogImporter
private static void AddDistinct(List<string> target, string text) private static void AddDistinct(List<string> target, string text)
{ {
if (target.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) if (target.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
{
return;
}
target.Add(text); target.Add(text);
} }
@@ -576,62 +498,30 @@ public static class LegacyMimCatalogImporter
private sealed class LegacyMimDefinition private sealed class LegacyMimDefinition
{ {
[JsonPropertyName("skill_id")] [JsonPropertyName("skill_id")] public string? SkillId { get; init; }
public string? SkillId { get; init; }
[JsonPropertyName("mim_id")] [JsonPropertyName("mim_id")] public string? MimId { get; init; }
public string? MimId { get; init; }
[JsonPropertyName("mim_type")] [JsonPropertyName("mim_type")] public string? MimType { get; init; }
public string? MimType { get; init; }
[JsonPropertyName("prompts")] [JsonPropertyName("prompts")] public List<LegacyMimPrompt> Prompts { get; init; } = [];
public List<LegacyMimPrompt> Prompts { get; init; } = [];
} }
private sealed class LegacyMimPrompt private sealed class LegacyMimPrompt
{ {
[JsonPropertyName("mim_id")] [JsonPropertyName("mim_id")] public string? MimId { get; init; }
public string? MimId { get; init; }
[JsonPropertyName("prompt_category")] [JsonPropertyName("prompt_category")] public string? PromptCategory { get; init; }
public string? PromptCategory { get; init; }
[JsonPropertyName("prompt_sub_category")] [JsonPropertyName("prompt_sub_category")]
public string? PromptSubCategory { get; init; } public string? PromptSubCategory { get; init; }
[JsonPropertyName("condition")] [JsonPropertyName("condition")] public string? Condition { get; init; }
public string? Condition { get; init; }
[JsonPropertyName("prompt")] [JsonPropertyName("prompt")] public string? Prompt { get; init; }
public string? Prompt { get; init; }
[JsonPropertyName("prompt_id")] [JsonPropertyName("prompt_id")] public string? PromptId { get; init; }
public string? PromptId { get; init; }
[JsonPropertyName("weight")] [JsonPropertyName("weight")] public double? Weight { get; init; }
public double? Weight { get; init; }
}
private static string NormalizeCondition(string? condition)
{
if (string.IsNullOrWhiteSpace(condition))
{
return string.Empty;
}
return WhitespacePattern.Replace(condition.Trim(), " ");
}
private static bool IsTemplateBucket(LegacyMimBucket bucket)
{
return bucket is LegacyMimBucket.PersonalReportKickOff
or LegacyMimBucket.PersonalReportOutro
or LegacyMimBucket.WeatherIntro
or LegacyMimBucket.WeatherTomorrowIntro
or LegacyMimBucket.WeatherTodayHighLow
or LegacyMimBucket.WeatherTomorrowHighLow
or LegacyMimBucket.WeatherServiceDown
or LegacyMimBucket.ReportSkillTemplate;
} }
} }

View File

@@ -7,14 +7,15 @@ using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Cloud.Infrastructure.Telemetry; using Jibo.Cloud.Infrastructure.Telemetry;
using Jibo.Cloud.Infrastructure.Weather; using Jibo.Cloud.Infrastructure.Weather;
using Jibo.Runtime.Abstractions; using Jibo.Runtime.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Jibo.Cloud.Infrastructure.DependencyInjection; namespace Jibo.Cloud.Infrastructure.DependencyInjection;
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
public static IServiceCollection AddOpenJiboCloud(this IServiceCollection services, IConfiguration? configuration = null) public static IServiceCollection AddOpenJiboCloud(this IServiceCollection services,
IConfiguration? configuration = null)
{ {
var sttOptions = new BufferedAudioSttOptions(); var sttOptions = new BufferedAudioSttOptions();
if (configuration is not null) if (configuration is not null)
@@ -27,25 +28,16 @@ public static class ServiceCollectionExtensions
var openWeatherOptions = new OpenWeatherOptions(); var openWeatherOptions = new OpenWeatherOptions();
if (configuration is not null) if (configuration is not null)
{
configuration.GetSection("OpenJibo:Weather:OpenWeather").Bind(openWeatherOptions); configuration.GetSection("OpenJibo:Weather:OpenWeather").Bind(openWeatherOptions);
}
if (string.IsNullOrWhiteSpace(openWeatherOptions.ApiKey)) if (string.IsNullOrWhiteSpace(openWeatherOptions.ApiKey))
{
openWeatherOptions.ApiKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY"); openWeatherOptions.ApiKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY");
}
var newsApiOptions = new NewsApiOptions(); var newsApiOptions = new NewsApiOptions();
if (configuration is not null) if (configuration is not null) configuration.GetSection("OpenJibo:News:NewsApi").Bind(newsApiOptions);
{
configuration.GetSection("OpenJibo:News:NewsApi").Bind(newsApiOptions);
}
if (string.IsNullOrWhiteSpace(newsApiOptions.ApiKey)) if (string.IsNullOrWhiteSpace(newsApiOptions.ApiKey))
{
newsApiOptions.ApiKey = Environment.GetEnvironmentVariable("NEWSAPI_KEY"); newsApiOptions.ApiKey = Environment.GetEnvironmentVariable("NEWSAPI_KEY");
}
services.AddSingleton(sttOptions); services.AddSingleton(sttOptions);
services.AddSingleton(openWeatherOptions); services.AddSingleton(openWeatherOptions);
@@ -55,25 +47,30 @@ public static class ServiceCollectionExtensions
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"] var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "personal-memory.json"); ?? Path.Combine(AppContext.BaseDirectory, "App_Data",
"personal-memory.json");
var stateBackendKind = ParseBackendKind(configuration?["OpenJibo:State:Backend"]); var stateBackendKind = ParseBackendKind(configuration?["OpenJibo:State:Backend"]);
var personalMemoryBackendKind = ParseBackendKind(configuration?["OpenJibo:PersonalMemory:Backend"]); var personalMemoryBackendKind = ParseBackendKind(configuration?["OpenJibo:PersonalMemory:Backend"]);
var stateConnectionString = configuration?["OpenJibo:State:ConnectionString"] var stateConnectionString = configuration?["OpenJibo:State:ConnectionString"]
?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_STORAGE_CONNECTION_STRING") ?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_STORAGE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_SQL_CONNECTION_STRING"); ?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_SQL_CONNECTION_STRING");
var personalMemoryConnectionString = configuration?["OpenJibo:PersonalMemory:ConnectionString"] var personalMemoryConnectionString = configuration?["OpenJibo:PersonalMemory:ConnectionString"]
?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING") ?? Environment.GetEnvironmentVariable(
?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING"); "OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable(
"OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING");
services.AddSingleton<IPersistenceSnapshotStoreFactory, PersistenceSnapshotStoreFactory>(); services.AddSingleton<IPersistenceSnapshotStoreFactory, PersistenceSnapshotStoreFactory>();
services.AddSingleton<ICloudStateStore>(provider => services.AddSingleton<ICloudStateStore>(provider =>
{ {
var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>(); var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>();
return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind, "cloud-state", stateConnectionString)); return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind,
"cloud-state", stateConnectionString));
}); });
services.AddSingleton<IPersonalMemoryStore>(provider => services.AddSingleton<IPersonalMemoryStore>(provider =>
{ {
var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>(); var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>();
return new InMemoryPersonalMemoryStore(snapshotFactory.Create(personalMemoryPersistencePath, personalMemoryBackendKind, "personal-memory", personalMemoryConnectionString)); return new InMemoryPersonalMemoryStore(snapshotFactory.Create(personalMemoryPersistencePath,
personalMemoryBackendKind, "personal-memory", personalMemoryConnectionString));
}); });
services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>(); services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>();
services.AddSingleton<JiboExperienceContentCache>(); services.AddSingleton<JiboExperienceContentCache>();
@@ -98,7 +95,7 @@ public static class ServiceCollectionExtensions
private static PersistenceBackendKind ParseBackendKind(string? value) private static PersistenceBackendKind ParseBackendKind(string? value)
{ {
return Enum.TryParse<PersistenceBackendKind>(value, ignoreCase: true, out var backendKind) return Enum.TryParse<PersistenceBackendKind>(value, true, out var backendKind)
? backendKind ? backendKind
: PersistenceBackendKind.File; : PersistenceBackendKind.File;
} }

View File

@@ -11,7 +11,23 @@ public sealed class NewsApiBriefingProvider(
ILogger<NewsApiBriefingProvider> logger) ILogger<NewsApiBriefingProvider> logger)
: INewsBriefingProvider : INewsBriefingProvider
{ {
private readonly ConcurrentDictionary<string, CacheEntry<NewsBriefingSnapshot?>> briefingCache = new(StringComparer.OrdinalIgnoreCase); private const int MaxHeadlines = 5;
private const int MaxCategories = 2;
private const string DefaultUserAgent = "OpenJiboCloud/1.0";
private static readonly HashSet<string> SupportedCategories = new(StringComparer.OrdinalIgnoreCase)
{
"business",
"entertainment",
"general",
"health",
"science",
"sports",
"technology"
};
private readonly ConcurrentDictionary<string, CacheEntry<NewsBriefingSnapshot?>> _briefingCache =
new(StringComparer.OrdinalIgnoreCase);
public async Task<NewsBriefingSnapshot?> GetBriefingAsync( public async Task<NewsBriefingSnapshot?> GetBriefingAsync(
NewsBriefingRequest request, NewsBriefingRequest request,
@@ -27,10 +43,7 @@ public sealed class NewsApiBriefingProvider(
try try
{ {
var categories = ResolveCategories(request.PreferredCategories).ToArray(); var categories = ResolveCategories(request.PreferredCategories).ToArray();
if (categories.Length == 0) if (categories.Length == 0) categories = ["general"];
{
categories = ["general"];
}
var requestedHeadlineCount = Math.Clamp(request.MaxHeadlines, 1, MaxHeadlines); var requestedHeadlineCount = Math.Clamp(request.MaxHeadlines, 1, MaxHeadlines);
cacheKey = BuildCacheKey(categories, requestedHeadlineCount); cacheKey = BuildCacheKey(categories, requestedHeadlineCount);
@@ -39,7 +52,7 @@ public sealed class NewsApiBriefingProvider(
string.Join(",", categories), string.Join(",", categories),
requestedHeadlineCount, requestedHeadlineCount,
cacheKey); cacheKey);
if (TryGetCachedValue(briefingCache, cacheKey, out var cachedBriefing)) if (TryGetCachedValue(_briefingCache, cacheKey, out var cachedBriefing))
{ {
logger.LogInformation( logger.LogInformation(
"NewsAPI cache hit. CacheKey={CacheKey} HasSnapshot={HasSnapshot} HeadlineCount={HeadlineCount}", "NewsAPI cache hit. CacheKey={CacheKey} HasSnapshot={HasSnapshot} HeadlineCount={HeadlineCount}",
@@ -57,25 +70,6 @@ public sealed class NewsApiBriefingProvider(
string? failureEndpoint = null; string? failureEndpoint = null;
string? failureErrorCode = null; string? failureErrorCode = null;
void CaptureFailure(
string status,
string? message,
int? statusCode,
Uri? endpoint,
string? errorCode = null)
{
if (!string.IsNullOrWhiteSpace(failureStatus))
{
return;
}
failureStatus = status;
failureMessage = message;
failureStatusCode = statusCode;
failureEndpoint = endpoint is null ? null : SanitizeEndpoint(endpoint);
failureErrorCode = errorCode;
}
foreach (var category in categories) foreach (var category in categories)
{ {
var uri = BuildTopHeadlinesUri(category, requestedHeadlineCount); var uri = BuildTopHeadlinesUri(category, requestedHeadlineCount);
@@ -86,7 +80,8 @@ public sealed class NewsApiBriefingProvider(
var apiError = TryParseApiError(responseBody); var apiError = TryParseApiError(responseBody);
CaptureFailure( CaptureFailure(
"http_error", "http_error",
apiError?.Message ?? $"Category '{category}' returned {(int)response.StatusCode} {response.ReasonPhrase}.", apiError?.Message ??
$"Category '{category}' returned {(int)response.StatusCode} {response.ReasonPhrase}.",
(int)response.StatusCode, (int)response.StatusCode,
uri, uri,
apiError?.Code); apiError?.Code);
@@ -136,10 +131,7 @@ public sealed class NewsApiBriefingProvider(
foreach (var article in articles.EnumerateArray()) foreach (var article in articles.EnumerateArray())
{ {
var title = NormalizeHeadlineTitle(ReadString(article, "title")); var title = NormalizeHeadlineTitle(ReadString(article, "title"));
if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) continue;
{
continue;
}
var summary = ReadString(article, "description"); var summary = ReadString(article, "description");
var source = article.TryGetProperty("source", out var sourceNode) && var source = article.TryGetProperty("source", out var sourceNode) &&
@@ -154,8 +146,8 @@ public sealed class NewsApiBriefingProvider(
var snapshot = new NewsBriefingSnapshot( var snapshot = new NewsBriefingSnapshot(
headlines, headlines,
"NewsAPI", "NewsAPI",
ProviderStatus: "success"); "success");
SetCachedValue(briefingCache, cacheKey, snapshot, options.CacheTtlSeconds); SetCachedValue(_briefingCache, cacheKey, snapshot, options.CacheTtlSeconds);
logger.LogInformation( logger.LogInformation(
"NewsAPI request succeeded. Categories={Categories} HeadlineCount={HeadlineCount}", "NewsAPI request succeeded. Categories={Categories} HeadlineCount={HeadlineCount}",
string.Join(",", categories), string.Join(",", categories),
@@ -171,22 +163,20 @@ public sealed class NewsApiBriefingProvider(
"NewsAPI category lookup produced no headlines. Falling back to uncategorized top headlines. Categories={Categories}", "NewsAPI category lookup produced no headlines. Falling back to uncategorized top headlines. Categories={Categories}",
string.Join(",", categories)); string.Join(",", categories));
var broadUri = BuildTopHeadlinesUri(category: null, requestedHeadlineCount); var broadUri = BuildTopHeadlinesUri(null, requestedHeadlineCount);
using var broadResponse = await SendGetAsync(broadUri, cancellationToken); using var broadResponse = await SendGetAsync(broadUri, cancellationToken);
if (broadResponse.IsSuccessStatusCode) if (broadResponse.IsSuccessStatusCode)
{ {
using var broadStream = await broadResponse.Content.ReadAsStreamAsync(cancellationToken); using var broadStream = await broadResponse.Content.ReadAsStreamAsync(cancellationToken);
using var broadDocument = await JsonDocument.ParseAsync(broadStream, cancellationToken: cancellationToken); using var broadDocument =
await JsonDocument.ParseAsync(broadStream, cancellationToken: cancellationToken);
if (broadDocument.RootElement.TryGetProperty("articles", out var broadArticles) && if (broadDocument.RootElement.TryGetProperty("articles", out var broadArticles) &&
broadArticles.ValueKind == JsonValueKind.Array) broadArticles.ValueKind == JsonValueKind.Array)
{ {
foreach (var article in broadArticles.EnumerateArray()) foreach (var article in broadArticles.EnumerateArray())
{ {
var title = NormalizeHeadlineTitle(ReadString(article, "title")); var title = NormalizeHeadlineTitle(ReadString(article, "title"));
if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) continue;
{
continue;
}
var summary = ReadString(article, "description"); var summary = ReadString(article, "description");
var source = article.TryGetProperty("source", out var sourceNode) && var source = article.TryGetProperty("source", out var sourceNode) &&
@@ -196,10 +186,7 @@ public sealed class NewsApiBriefingProvider(
var url = ReadString(article, "url"); var url = ReadString(article, "url");
headlines.Add(new NewsHeadline(title, summary, "general", source, url)); headlines.Add(new NewsHeadline(title, summary, "general", source, url));
if (headlines.Count >= requestedHeadlineCount) if (headlines.Count >= requestedHeadlineCount) break;
{
break;
}
} }
} }
else else
@@ -218,7 +205,8 @@ public sealed class NewsApiBriefingProvider(
var apiError = TryParseApiError(fallbackBody); var apiError = TryParseApiError(fallbackBody);
CaptureFailure( CaptureFailure(
"http_error", "http_error",
apiError?.Message ?? $"Uncategorized fallback returned {(int)broadResponse.StatusCode} {broadResponse.ReasonPhrase}.", apiError?.Message ??
$"Uncategorized fallback returned {(int)broadResponse.StatusCode} {broadResponse.ReasonPhrase}.",
(int)broadResponse.StatusCode, (int)broadResponse.StatusCode,
broadUri, broadUri,
apiError?.Code); apiError?.Code);
@@ -243,17 +231,15 @@ public sealed class NewsApiBriefingProvider(
if (everythingResponse.IsSuccessStatusCode) if (everythingResponse.IsSuccessStatusCode)
{ {
using var everythingStream = await everythingResponse.Content.ReadAsStreamAsync(cancellationToken); using var everythingStream = await everythingResponse.Content.ReadAsStreamAsync(cancellationToken);
using var everythingDocument = await JsonDocument.ParseAsync(everythingStream, cancellationToken: cancellationToken); using var everythingDocument =
await JsonDocument.ParseAsync(everythingStream, cancellationToken: cancellationToken);
if (everythingDocument.RootElement.TryGetProperty("articles", out var everythingArticles) && if (everythingDocument.RootElement.TryGetProperty("articles", out var everythingArticles) &&
everythingArticles.ValueKind == JsonValueKind.Array) everythingArticles.ValueKind == JsonValueKind.Array)
{ {
foreach (var article in everythingArticles.EnumerateArray()) foreach (var article in everythingArticles.EnumerateArray())
{ {
var title = NormalizeHeadlineTitle(ReadString(article, "title")); var title = NormalizeHeadlineTitle(ReadString(article, "title"));
if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) if (string.IsNullOrWhiteSpace(title) || !seenTitles.Add(title)) continue;
{
continue;
}
var summary = ReadString(article, "description"); var summary = ReadString(article, "description");
var source = article.TryGetProperty("source", out var sourceNode) && var source = article.TryGetProperty("source", out var sourceNode) &&
@@ -263,10 +249,7 @@ public sealed class NewsApiBriefingProvider(
var url = ReadString(article, "url"); var url = ReadString(article, "url");
headlines.Add(new NewsHeadline(title, summary, "general", source, url)); headlines.Add(new NewsHeadline(title, summary, "general", source, url));
if (headlines.Count >= requestedHeadlineCount) if (headlines.Count >= requestedHeadlineCount) break;
{
break;
}
} }
} }
else else
@@ -285,7 +268,8 @@ public sealed class NewsApiBriefingProvider(
var apiError = TryParseApiError(everythingBody); var apiError = TryParseApiError(everythingBody);
CaptureFailure( CaptureFailure(
"http_error", "http_error",
apiError?.Message ?? $"Everything fallback returned {(int)everythingResponse.StatusCode} {everythingResponse.ReasonPhrase}.", apiError?.Message ??
$"Everything fallback returned {(int)everythingResponse.StatusCode} {everythingResponse.ReasonPhrase}.",
(int)everythingResponse.StatusCode, (int)everythingResponse.StatusCode,
everythingUri, everythingUri,
apiError?.Code); apiError?.Code);
@@ -302,14 +286,14 @@ public sealed class NewsApiBriefingProvider(
if (headlines.Count == 0) if (headlines.Count == 0)
{ {
var emptySnapshot = new NewsBriefingSnapshot( var emptySnapshot = new NewsBriefingSnapshot(
Array.Empty<NewsHeadline>(), [],
"NewsAPI", "NewsAPI",
ProviderStatus: failureStatus ?? "empty", failureStatus ?? "empty",
ProviderMessage: failureMessage ?? "NewsAPI returned no usable headlines.", failureMessage ?? "NewsAPI returned no usable headlines.",
ProviderHttpStatusCode: failureStatusCode, failureStatusCode,
ProviderEndpoint: failureEndpoint, failureEndpoint,
ProviderErrorCode: failureErrorCode); failureErrorCode);
SetCachedValue(briefingCache, cacheKey, emptySnapshot, options.FailureCacheTtlSeconds); SetCachedValue(_briefingCache, cacheKey, emptySnapshot, options.FailureCacheTtlSeconds);
logger.LogWarning( logger.LogWarning(
"NewsAPI returned no usable headlines. Categories={Categories} RequestedHeadlineCount={RequestedHeadlineCount}", "NewsAPI returned no usable headlines. Categories={Categories} RequestedHeadlineCount={RequestedHeadlineCount}",
string.Join(",", categories), string.Join(",", categories),
@@ -320,14 +304,30 @@ public sealed class NewsApiBriefingProvider(
var populatedSnapshot = new NewsBriefingSnapshot( var populatedSnapshot = new NewsBriefingSnapshot(
headlines, headlines,
"NewsAPI", "NewsAPI",
ProviderStatus: "success"); "success");
SetCachedValue(briefingCache, cacheKey, populatedSnapshot, options.CacheTtlSeconds); SetCachedValue(_briefingCache, cacheKey, populatedSnapshot, options.CacheTtlSeconds);
logger.LogInformation( logger.LogInformation(
"NewsAPI request partially filled headlines. Categories={Categories} HeadlineCount={HeadlineCount} RequestedHeadlineCount={RequestedHeadlineCount}", "NewsAPI request partially filled headlines. Categories={Categories} HeadlineCount={HeadlineCount} RequestedHeadlineCount={RequestedHeadlineCount}",
string.Join(",", categories), string.Join(",", categories),
headlines.Count, headlines.Count,
requestedHeadlineCount); requestedHeadlineCount);
return populatedSnapshot; return populatedSnapshot;
void CaptureFailure(
string status,
string? message,
int? statusCode,
Uri? endpoint,
string? errorCode = null)
{
if (!string.IsNullOrWhiteSpace(failureStatus)) return;
failureStatus = status;
failureMessage = message;
failureStatusCode = statusCode;
failureEndpoint = endpoint is null ? null : SanitizeEndpoint(endpoint);
failureErrorCode = errorCode;
}
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -335,12 +335,10 @@ public sealed class NewsApiBriefingProvider(
var exceptionSnapshot = new NewsBriefingSnapshot( var exceptionSnapshot = new NewsBriefingSnapshot(
Array.Empty<NewsHeadline>(), Array.Empty<NewsHeadline>(),
"NewsAPI", "NewsAPI",
ProviderStatus: "exception", "exception",
ProviderMessage: exception.Message); exception.Message);
if (!string.IsNullOrWhiteSpace(cacheKey)) if (!string.IsNullOrWhiteSpace(cacheKey))
{ SetCachedValue(_briefingCache, cacheKey, exceptionSnapshot, options.FailureCacheTtlSeconds);
SetCachedValue(briefingCache, cacheKey, exceptionSnapshot, options.FailureCacheTtlSeconds);
}
return exceptionSnapshot; return exceptionSnapshot;
} }
} }
@@ -368,10 +366,7 @@ public sealed class NewsApiBriefingProvider(
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(); .ToArray();
if (requested.Length > 0) if (requested.Length > 0) return requested.Take(MaxCategories);
{
return requested.Take(MaxCategories);
}
return options.DefaultCategories return options.DefaultCategories
.Where(category => !string.IsNullOrWhiteSpace(category)) .Where(category => !string.IsNullOrWhiteSpace(category))
@@ -390,10 +385,7 @@ public sealed class NewsApiBriefingProvider(
("pageSize", headlineCount.ToString()), ("pageSize", headlineCount.ToString()),
("apiKey", options.ApiKey!) ("apiKey", options.ApiKey!)
}; };
if (!string.IsNullOrWhiteSpace(category)) if (!string.IsNullOrWhiteSpace(category)) queryParts.Add(("category", category));
{
queryParts.Add(("category", category));
}
var query = string.Join( var query = string.Join(
"&", "&",
@@ -428,10 +420,7 @@ public sealed class NewsApiBriefingProvider(
try try
{ {
var body = await response.Content.ReadAsStringAsync(cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body)) if (string.IsNullOrWhiteSpace(body)) return null;
{
return null;
}
const int maxLength = 400; const int maxLength = 400;
return body.Length <= maxLength return body.Length <= maxLength
@@ -455,42 +444,27 @@ public sealed class NewsApiBriefingProvider(
private static string? NormalizeHeadlineTitle(string? title) private static string? NormalizeHeadlineTitle(string? title)
{ {
if (string.IsNullOrWhiteSpace(title)) if (string.IsNullOrWhiteSpace(title)) return null;
{
return null;
}
var trimmed = title.Trim(); var trimmed = title.Trim();
var suffixIndex = trimmed.LastIndexOf(" - ", StringComparison.Ordinal); var suffixIndex = trimmed.LastIndexOf(" - ", StringComparison.Ordinal);
if (suffixIndex > 30) if (suffixIndex > 30) trimmed = trimmed[..suffixIndex].TrimEnd();
{
trimmed = trimmed[..suffixIndex].TrimEnd();
}
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
} }
private static ApiError? TryParseApiError(string? responseBody) private static ApiError? TryParseApiError(string? responseBody)
{ {
if (string.IsNullOrWhiteSpace(responseBody)) if (string.IsNullOrWhiteSpace(responseBody)) return null;
{
return null;
}
try try
{ {
using var document = JsonDocument.Parse(responseBody); using var document = JsonDocument.Parse(responseBody);
if (document.RootElement.ValueKind != JsonValueKind.Object) if (document.RootElement.ValueKind != JsonValueKind.Object) return null;
{
return null;
}
var code = ReadString(document.RootElement, "code"); var code = ReadString(document.RootElement, "code");
var message = ReadString(document.RootElement, "message"); var message = ReadString(document.RootElement, "message");
if (string.IsNullOrWhiteSpace(code) && string.IsNullOrWhiteSpace(message)) if (string.IsNullOrWhiteSpace(code) && string.IsNullOrWhiteSpace(message)) return null;
{
return null;
}
return new ApiError(code, message); return new ApiError(code, message);
} }
@@ -503,10 +477,7 @@ public sealed class NewsApiBriefingProvider(
private static string SanitizeEndpoint(Uri uri) private static string SanitizeEndpoint(Uri uri)
{ {
var path = uri.GetLeftPart(UriPartial.Path); var path = uri.GetLeftPart(UriPartial.Path);
if (string.IsNullOrWhiteSpace(uri.Query)) if (string.IsNullOrWhiteSpace(uri.Query)) return path;
{
return path;
}
var filtered = uri.Query var filtered = uri.Query
.TrimStart('?') .TrimStart('?')
@@ -532,10 +503,7 @@ public sealed class NewsApiBriefingProvider(
out T value) out T value)
{ {
value = default!; value = default!;
if (!cache.TryGetValue(key, out var entry)) if (!cache.TryGetValue(key, out var entry)) return false;
{
return false;
}
if (entry.ExpiresUtc > DateTimeOffset.UtcNow) if (entry.ExpiresUtc > DateTimeOffset.UtcNow)
{ {
@@ -558,21 +526,7 @@ public sealed class NewsApiBriefingProvider(
DateTimeOffset.UtcNow.AddSeconds(Math.Max(1, ttlSeconds))); DateTimeOffset.UtcNow.AddSeconds(Math.Max(1, ttlSeconds)));
} }
private static readonly HashSet<string> SupportedCategories = new(StringComparer.OrdinalIgnoreCase)
{
"business",
"entertainment",
"general",
"health",
"science",
"sports",
"technology"
};
private const int MaxHeadlines = 5;
private const int MaxCategories = 2;
private const string DefaultUserAgent = "OpenJiboCloud/1.0";
private sealed record ApiError(string? Code, string? Message); private sealed record ApiError(string? Code, string? Message);
private sealed record CacheEntry<T>(T Value, DateTimeOffset ExpiresUtc); private sealed record CacheEntry<T>(T Value, DateTimeOffset ExpiresUtc);
} }

View File

@@ -1,6 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Azure.Storage.Blobs; using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
namespace Jibo.Cloud.Infrastructure.Persistence; namespace Jibo.Cloud.Infrastructure.Persistence;
@@ -12,22 +11,22 @@ internal sealed class AzureBlobSnapshotStore : ISnapshotStore
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private readonly BlobContainerClient _containerClient;
private readonly string _blobName; private readonly string _blobName;
public AzureBlobSnapshotStore(string connectionString, string snapshotName, string containerName = "openjibo-snapshots") private readonly BlobContainerClient _containerClient;
public AzureBlobSnapshotStore(string connectionString, string snapshotName,
string containerName = "openjibo-snapshots")
{ {
if (string.IsNullOrWhiteSpace(connectionString)) if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException("Azure Blob persistence requires a storage connection string."); throw new InvalidOperationException("Azure Blob persistence requires a storage connection string.");
}
if (string.IsNullOrWhiteSpace(snapshotName)) if (string.IsNullOrWhiteSpace(snapshotName))
{ throw new ArgumentException("A snapshot name is required for Azure Blob persistence.",
throw new ArgumentException("A snapshot name is required for Azure Blob persistence.", nameof(snapshotName)); nameof(snapshotName));
}
_containerClient = new BlobContainerClient(connectionString, string.IsNullOrWhiteSpace(containerName) ? "openjibo-snapshots" : containerName); _containerClient = new BlobContainerClient(connectionString,
string.IsNullOrWhiteSpace(containerName) ? "openjibo-snapshots" : containerName);
_blobName = $"{snapshotName}.json"; _blobName = $"{snapshotName}.json";
} }
@@ -35,34 +34,28 @@ internal sealed class AzureBlobSnapshotStore : ISnapshotStore
{ {
try try
{ {
if (!_containerClient.Exists()) if (!_containerClient.Exists()) return null;
{
return default;
}
var blobClient = _containerClient.GetBlobClient(_blobName); var blobClient = _containerClient.GetBlobClient(_blobName);
if (!blobClient.Exists()) if (!blobClient.Exists()) return null;
{
return default;
}
var content = blobClient.DownloadContent(); var content = blobClient.DownloadContent();
var json = content.Value.Content.ToString(); var json = content.Value.Content.ToString();
return string.IsNullOrWhiteSpace(json) return string.IsNullOrWhiteSpace(json)
? default ? null
: JsonSerializer.Deserialize<TSnapshot>(json, JsonOptions); : JsonSerializer.Deserialize<TSnapshot>(json, JsonOptions);
} }
catch catch
{ {
return default; return null;
} }
} }
public void Save<TSnapshot>(TSnapshot snapshot) where TSnapshot : class public void Save<TSnapshot>(TSnapshot snapshot) where TSnapshot : class
{ {
_containerClient.CreateIfNotExists(PublicAccessType.None); _containerClient.CreateIfNotExists();
var blobClient = _containerClient.GetBlobClient(_blobName); var blobClient = _containerClient.GetBlobClient(_blobName);
var json = JsonSerializer.Serialize(snapshot, JsonOptions); var json = JsonSerializer.Serialize(snapshot, JsonOptions);
blobClient.Upload(BinaryData.FromString(json), overwrite: true); blobClient.Upload(BinaryData.FromString(json), true);
} }
} }

View File

@@ -16,13 +16,15 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore
private readonly string _snapshotName; private readonly string _snapshotName;
private readonly string _tableName; private readonly string _tableName;
public AzureSqlSnapshotStore(string connectionString, string snapshotName, string tableName = "PersistenceSnapshots") public AzureSqlSnapshotStore(string connectionString, string snapshotName,
string tableName = "PersistenceSnapshots")
{ {
_connectionString = string.IsNullOrWhiteSpace(connectionString) _connectionString = string.IsNullOrWhiteSpace(connectionString)
? throw new InvalidOperationException("Azure SQL persistence requires a connection string.") ? throw new InvalidOperationException("Azure SQL persistence requires a connection string.")
: connectionString; : connectionString;
_snapshotName = string.IsNullOrWhiteSpace(snapshotName) _snapshotName = string.IsNullOrWhiteSpace(snapshotName)
? throw new ArgumentException("A snapshot name is required for Azure SQL persistence.", nameof(snapshotName)) ? throw new ArgumentException("A snapshot name is required for Azure SQL persistence.",
nameof(snapshotName))
: snapshotName; : snapshotName;
_tableName = string.IsNullOrWhiteSpace(tableName) ? "PersistenceSnapshots" : tableName; _tableName = string.IsNullOrWhiteSpace(tableName) ? "PersistenceSnapshots" : tableName;
} }
@@ -42,10 +44,7 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore
command.Parameters.Add(new SqlParameter("@snapshotName", SqlDbType.NVarChar, 200) { Value = _snapshotName }); command.Parameters.Add(new SqlParameter("@snapshotName", SqlDbType.NVarChar, 200) { Value = _snapshotName });
var result = command.ExecuteScalar(); var result = command.ExecuteScalar();
if (result is not string json || string.IsNullOrWhiteSpace(json)) if (result is not string json || string.IsNullOrWhiteSpace(json)) return null;
{
return default;
}
try try
{ {
@@ -53,7 +52,7 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore
} }
catch catch
{ {
return default; return null;
} }
} }

View File

@@ -2,5 +2,6 @@ namespace Jibo.Cloud.Infrastructure.Persistence;
public interface IPersistenceSnapshotStoreFactory public interface IPersistenceSnapshotStoreFactory
{ {
ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, string? connectionString = null); ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName,
string? connectionString = null);
} }

View File

@@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text;
using System.Text.Json; using System.Text.Json;
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models; using Jibo.Cloud.Domain.Models;
@@ -7,29 +8,38 @@ namespace Jibo.Cloud.Infrastructure.Persistence;
public sealed class InMemoryCloudStateStore : ICloudStateStore public sealed class InMemoryCloudStateStore : ICloudStateStore
{ {
private const string CurrentSchemaVersion = "1";
private static readonly JsonSerializerOptions PersistenceJsonOptions = new() private static readonly JsonSerializerOptions PersistenceJsonOptions = new()
{ {
WriteIndented = true, WriteIndented = true,
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private AccountProfile _account = new(); private readonly List<BackupRecord> _backups = [];
private readonly ConcurrentDictionary<string, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CloudSession> _sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, KeyRequestRecord>
private readonly ConcurrentDictionary<string, KeyRequestRecord> _keyRequests = new(StringComparer.OrdinalIgnoreCase); _keyRequests = new(StringComparer.OrdinalIgnoreCase);
private readonly List<LoopRecord> _loops;
private readonly List<MediaRecord> _media = [];
private readonly List<PersonRecord> _people;
private readonly ConcurrentDictionary<string, CloudSession>
_sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
private readonly ISnapshotStore _snapshotStore; private readonly ISnapshotStore _snapshotStore;
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _syncRoot = new(); private readonly Lock _syncRoot = new();
private readonly List<UpdateManifest> _updates; private readonly List<UpdateManifest> _updates;
private readonly List<MediaRecord> _media = [];
private readonly List<BackupRecord> _backups = []; private AccountProfile _account = new();
private readonly List<LoopRecord> _loops;
private readonly List<PersonRecord> _people;
private DeviceRegistration _robot;
private RobotProfile _robotProfile;
private long _revision;
private DateTimeOffset? _lastLoadedUtc; private DateTimeOffset? _lastLoadedUtc;
private DateTimeOffset? _lastSavedUtc; private DateTimeOffset? _lastSavedUtc;
private long _revision;
private DeviceRegistration _robot;
private RobotProfile _robotProfile;
public InMemoryCloudStateStore(string? persistencePath = null) public InMemoryCloudStateStore(string? persistencePath = null)
: this(new JsonFileSnapshotStore(persistencePath, PersistenceJsonOptions)) : this(new JsonFileSnapshotStore(persistencePath, PersistenceJsonOptions))
@@ -101,55 +111,37 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public PersistenceStateInfo GetPersistenceStateInfo() public PersistenceStateInfo GetPersistenceStateInfo()
{ {
return new PersistenceStateInfo( return new PersistenceStateInfo(
SchemaVersion: CurrentSchemaVersion, CurrentSchemaVersion,
Revision: Interlocked.Read(ref _revision), Interlocked.Read(ref _revision),
LastLoadedUtc: _lastLoadedUtc, _lastLoadedUtc,
LastSavedUtc: _lastSavedUtc); _lastSavedUtc);
} }
public void LoadPersistedState() public void LoadPersistedState()
{ {
var snapshot = _snapshotStore.Load<PersistentStateSnapshot>(); var snapshot = _snapshotStore.Load<PersistentStateSnapshot>();
if (snapshot is null) if (snapshot is null) return;
{
return;
}
_account = snapshot.Account ?? _account; _account = snapshot.Account ?? _account;
_robot = snapshot.Robot ?? _robot; _robot = snapshot.Robot ?? _robot;
_robotProfile = snapshot.RobotProfile ?? _robotProfile; _robotProfile = snapshot.RobotProfile ?? _robotProfile;
_devices.Clear(); _devices.Clear();
foreach (var device in snapshot.Devices ?? []) foreach (var device in snapshot.Devices ?? []) _devices[device.DeviceId] = device;
{
_devices[device.DeviceId] = device;
}
if (_devices.IsEmpty || !_devices.ContainsKey(_robot.DeviceId)) if (_devices.IsEmpty || !_devices.ContainsKey(_robot.DeviceId)) _devices[_robot.DeviceId] = _robot;
{
_devices[_robot.DeviceId] = _robot;
}
_sessionsByToken.Clear(); _sessionsByToken.Clear();
foreach (var session in snapshot.Sessions ?? []) foreach (var session in snapshot.Sessions ?? [])
{
if (!string.IsNullOrWhiteSpace(session.Token)) if (!string.IsNullOrWhiteSpace(session.Token))
{
_sessionsByToken[session.Token] = session.ToRecord(); _sessionsByToken[session.Token] = session.ToRecord();
}
}
_symmetricKeys.Clear(); _symmetricKeys.Clear();
foreach (var pair in snapshot.SymmetricKeys ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)) foreach (var pair in snapshot.SymmetricKeys ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase))
{
_symmetricKeys[pair.Key] = pair.Value; _symmetricKeys[pair.Key] = pair.Value;
}
_keyRequests.Clear(); _keyRequests.Clear();
foreach (var keyRequest in snapshot.KeyRequests ?? []) foreach (var keyRequest in snapshot.KeyRequests ?? []) _keyRequests[keyRequest.RequestId] = keyRequest;
{
_keyRequests[keyRequest.RequestId] = keyRequest;
}
_updates.Clear(); _updates.Clear();
_updates.AddRange(snapshot.Updates ?? []); _updates.AddRange(snapshot.Updates ?? []);
@@ -166,8 +158,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
_people.Clear(); _people.Clear();
_people.AddRange(snapshot.People ?? []); _people.AddRange(snapshot.People ?? []);
if (_robotProfile is null || !string.Equals(_robotProfile.RobotId, _robot.RobotId, StringComparison.OrdinalIgnoreCase)) if (_robotProfile is null ||
{ !string.Equals(_robotProfile.RobotId, _robot.RobotId, StringComparison.OrdinalIgnoreCase))
_robotProfile = new RobotProfile _robotProfile = new RobotProfile
{ {
RobotId = _robot.RobotId, RobotId = _robot.RobotId,
@@ -180,7 +172,6 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
}, },
UpdatedUtc = DateTimeOffset.UtcNow UpdatedUtc = DateTimeOffset.UtcNow
}; };
}
Interlocked.Exchange(ref _revision, snapshot.Revision); Interlocked.Exchange(ref _revision, snapshot.Revision);
_lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow; _lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow;
@@ -203,7 +194,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
RobotProfile = _robotProfile, RobotProfile = _robotProfile,
Devices = _devices.Values.ToArray(), Devices = _devices.Values.ToArray(),
Sessions = _sessionsByToken.Values.Select(MapSessionSnapshot).ToArray(), Sessions = _sessionsByToken.Values.Select(MapSessionSnapshot).ToArray(),
SymmetricKeys = _symmetricKeys.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase), SymmetricKeys = _symmetricKeys.ToDictionary(entry => entry.Key, entry => entry.Value,
StringComparer.OrdinalIgnoreCase),
KeyRequests = _keyRequests.Values.ToArray(), KeyRequests = _keyRequests.Values.ToArray(),
Updates = _updates.ToArray(), Updates = _updates.ToArray(),
Media = _media.ToArray(), Media = _media.ToArray(),
@@ -216,11 +208,20 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
} }
} }
public AccountProfile GetAccount() => _account; public AccountProfile GetAccount()
{
return _account;
}
public DeviceRegistration GetRobot() => _robot; public DeviceRegistration GetRobot()
{
return _robot;
}
public RobotProfile GetRobotProfile() => _robotProfile; public RobotProfile GetRobotProfile()
{
return _robotProfile;
}
public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion) public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion)
{ {
@@ -309,14 +310,21 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
return _sessionsByToken.GetValueOrDefault(token); return _sessionsByToken.GetValueOrDefault(token);
} }
public IReadOnlyList<LoopRecord> GetLoops() => _loops.ToArray(); public IReadOnlyList<LoopRecord> GetLoops()
{
return _loops.ToArray();
}
public IReadOnlyList<PersonRecord> GetPeople() => _people.ToArray(); public IReadOnlyList<PersonRecord> GetPeople()
{
return _people.ToArray();
}
public IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null) public IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null)
{ {
return _updates return _updates
.Where(update => subsystem is null || update.Subsystem.Equals(subsystem, StringComparison.OrdinalIgnoreCase)) .Where(update =>
subsystem is null || update.Subsystem.Equals(subsystem, StringComparison.OrdinalIgnoreCase))
.Where(update => filter is null || string.Equals(update.Filter, filter, StringComparison.OrdinalIgnoreCase)) .Where(update => filter is null || string.Equals(update.Filter, filter, StringComparison.OrdinalIgnoreCase))
.ToArray(); .ToArray();
} }
@@ -324,10 +332,12 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? filter) public UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? filter)
{ {
return ListUpdates(subsystem, filter) return ListUpdates(subsystem, filter)
.FirstOrDefault(update => fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase)); .FirstOrDefault(update =>
fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase));
} }
public UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary<string, object?>? dependencies) public UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash,
long? length, string? subsystem, string? filter, IDictionary<string, object?>? dependencies)
{ {
var update = new UpdateManifest var update = new UpdateManifest
{ {
@@ -351,7 +361,6 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
{ {
var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId); var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId);
if (existing is null) if (existing is null)
{
return new UpdateManifest return new UpdateManifest
{ {
UpdateId = updateId ?? "unknown-update", UpdateId = updateId ?? "unknown-update",
@@ -360,14 +369,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
ShaHash = "missing", ShaHash = "missing",
Subsystem = "unknown" Subsystem = "unknown"
}; };
}
_updates.Remove(existing); _updates.Remove(existing);
TouchState(); TouchState();
return existing; return existing;
} }
public IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null, long? before = null) public IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null,
long? before = null)
{ {
return _media return _media
.Where(item => !item.IsDeleted) .Where(item => !item.IsDeleted)
@@ -387,10 +396,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
var replacements = new List<MediaRecord>(); var replacements = new List<MediaRecord>();
for (var i = 0; i < _media.Count; i++) for (var i = 0; i < _media.Count; i++)
{ {
if (!paths.Contains(_media[i].Path)) if (!paths.Contains(_media[i].Path)) continue;
{
continue;
}
var updated = new MediaRecord var updated = new MediaRecord
{ {
@@ -410,15 +416,13 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
replacements.Add(updated); replacements.Add(updated);
} }
if (replacements.Count > 0) if (replacements.Count > 0) TouchState();
{
TouchState();
}
return replacements; return replacements;
} }
public MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, IDictionary<string, object?>? meta) public MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted,
IDictionary<string, object?>? meta)
{ {
var item = new MediaRecord var item = new MediaRecord
{ {
@@ -432,32 +436,32 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Meta = meta ?? new Dictionary<string, object?>() Meta = meta ?? new Dictionary<string, object?>()
}; };
var existingIndex = _media.FindIndex(existing => existing.Path.Equals(path, StringComparison.OrdinalIgnoreCase)); var existingIndex =
_media.FindIndex(existing => existing.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0) if (existingIndex >= 0)
{
_media[existingIndex] = item; _media[existingIndex] = item;
}
else else
{
_media.Add(item); _media.Add(item);
}
TouchState(); TouchState();
return item; return item;
} }
public IReadOnlyList<BackupRecord> GetBackups() => _backups.ToArray(); public IReadOnlyList<BackupRecord> GetBackups()
{
return _backups.ToArray();
}
public bool ShouldCreateSymmetricKey(string loopId) => !_symmetricKeys.ContainsKey(loopId); public bool ShouldCreateSymmetricKey(string loopId)
{
return !_symmetricKeys.ContainsKey(loopId);
}
public string GetOrCreateSymmetricKey(string loopId) public string GetOrCreateSymmetricKey(string loopId)
{ {
if (_symmetricKeys.TryGetValue(loopId, out var existing)) if (_symmetricKeys.TryGetValue(loopId, out var existing)) return existing;
{
return existing;
}
var key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{loopId}")); var key = Convert.ToBase64String(Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{loopId}"));
if (_symmetricKeys.TryAdd(loopId, key)) if (_symmetricKeys.TryAdd(loopId, key))
{ {
TouchState(); TouchState();
@@ -483,10 +487,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey) public KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey)
{ {
if (!string.IsNullOrWhiteSpace(requestId) && _keyRequests.TryGetValue(requestId, out var record)) if (!string.IsNullOrWhiteSpace(requestId) && _keyRequests.TryGetValue(requestId, out var record)) return record;
{
return record;
}
return new KeyRequestRecord return new KeyRequestRecord
{ {
@@ -496,9 +497,15 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
}; };
} }
public IReadOnlyList<KeyRequestRecord> GetIncomingKeyRequests() => []; public IReadOnlyList<KeyRequestRecord> GetIncomingKeyRequests()
{
return [];
}
public IReadOnlyList<KeyRequestRecord> GetBinaryRequests() => []; public IReadOnlyList<KeyRequestRecord> GetBinaryRequests()
{
return [];
}
public IReadOnlyList<object> GetHolidays() public IReadOnlyList<object> GetHolidays()
{ {
@@ -548,7 +555,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
private static string ResolveDefaultLoopId(IReadOnlyList<LoopRecord> loops, AccountProfile account) private static string ResolveDefaultLoopId(IReadOnlyList<LoopRecord> loops, AccountProfile account)
{ {
return loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId return loops.FirstOrDefault(loop =>
string.Equals(loop.OwnerAccountId, account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId
?? loops.FirstOrDefault()?.LoopId ?? loops.FirstOrDefault()?.LoopId
?? "openjibo-default-loop"; ?? "openjibo-default-loop";
} }
@@ -591,8 +599,6 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
}; };
} }
private const string CurrentSchemaVersion = "1";
private sealed class PersistentStateSnapshot private sealed class PersistentStateSnapshot
{ {
public string SchemaVersion { get; init; } = CurrentSchemaVersion; public string SchemaVersion { get; init; } = CurrentSchemaVersion;

View File

@@ -6,18 +6,23 @@ namespace Jibo.Cloud.Infrastructure.Persistence;
public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
{ {
private const string CurrentSchemaVersion = "1";
private static readonly JsonSerializerOptions PersistenceJsonOptions = new() private static readonly JsonSerializerOptions PersistenceJsonOptions = new()
{ {
WriteIndented = true, WriteIndented = true,
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private readonly ConcurrentDictionary<string, TenantMemoryRecord> _tenantMemory = new(StringComparer.OrdinalIgnoreCase);
private readonly ISnapshotStore _snapshotStore; private readonly ISnapshotStore _snapshotStore;
private readonly Lock _syncRoot = new(); private readonly Lock _syncRoot = new();
private long _revision;
private readonly ConcurrentDictionary<string, TenantMemoryRecord> _tenantMemory =
new(StringComparer.OrdinalIgnoreCase);
private DateTimeOffset? _lastLoadedUtc; private DateTimeOffset? _lastLoadedUtc;
private DateTimeOffset? _lastSavedUtc; private DateTimeOffset? _lastSavedUtc;
private long _revision;
public InMemoryPersonalMemoryStore(string? persistencePath = null) public InMemoryPersonalMemoryStore(string? persistencePath = null)
: this(new JsonFileSnapshotStore(persistencePath, PersistenceJsonOptions)) : this(new JsonFileSnapshotStore(persistencePath, PersistenceJsonOptions))
@@ -33,25 +38,19 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public PersistenceStateInfo GetPersistenceStateInfo() public PersistenceStateInfo GetPersistenceStateInfo()
{ {
return new PersistenceStateInfo( return new PersistenceStateInfo(
SchemaVersion: CurrentSchemaVersion, CurrentSchemaVersion,
Revision: Interlocked.Read(ref _revision), Interlocked.Read(ref _revision),
LastLoadedUtc: _lastLoadedUtc, _lastLoadedUtc,
LastSavedUtc: _lastSavedUtc); _lastSavedUtc);
} }
public void LoadPersistedState() public void LoadPersistedState()
{ {
var snapshot = _snapshotStore.Load<PersistentStateSnapshot>(); var snapshot = _snapshotStore.Load<PersistentStateSnapshot>();
if (snapshot is null) if (snapshot is null) return;
{
return;
}
_tenantMemory.Clear(); _tenantMemory.Clear();
foreach (var tenant in snapshot.Tenants ?? []) foreach (var tenant in snapshot.Tenants ?? []) _tenantMemory[tenant.TenantKey] = tenant.ToRecord();
{
_tenantMemory[tenant.TenantKey] = tenant.ToRecord();
}
Interlocked.Exchange(ref _revision, snapshot.Revision); Interlocked.Exchange(ref _revision, snapshot.Revision);
_lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow; _lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow;
@@ -75,9 +74,12 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
TenantKey = pair.Key, TenantKey = pair.Key,
Birthday = pair.Value.Birthday, Birthday = pair.Value.Birthday,
Name = pair.Value.Name, Name = pair.Value.Name,
Preferences = pair.Value.Preferences.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase), Preferences = pair.Value.Preferences.ToDictionary(entry => entry.Key, entry => entry.Value,
ImportantDates = pair.Value.ImportantDates.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase),
Affinities = pair.Value.Affinities.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase), ImportantDates = pair.Value.ImportantDates.ToDictionary(entry => entry.Key,
entry => entry.Value, StringComparer.OrdinalIgnoreCase),
Affinities = pair.Value.Affinities.ToDictionary(entry => entry.Key, entry => entry.Value,
StringComparer.OrdinalIgnoreCase),
Lists = pair.Value.Lists.ToDictionary( Lists = pair.Value.Lists.ToDictionary(
entry => entry.Key, entry => entry.Key,
entry => entry.Value.ToArray(), entry => entry.Value.ToArray(),
@@ -167,50 +169,35 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope) public IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope)
{ {
var key = BuildTenantKey(tenantScope); var key = BuildTenantKey(tenantScope);
if (!_tenantMemory.TryGetValue(key, out var record)) return !_tenantMemory.TryGetValue(key, out var record)
{ ? new Dictionary<string, PersonalAffinity>(StringComparer.OrdinalIgnoreCase)
return new Dictionary<string, PersonalAffinity>(StringComparer.OrdinalIgnoreCase); : new Dictionary<string, PersonalAffinity>(record.Affinities, StringComparer.OrdinalIgnoreCase);
}
return new Dictionary<string, PersonalAffinity>(record.Affinities, StringComparer.OrdinalIgnoreCase);
} }
public void AddListItem(PersonalMemoryTenantScope tenantScope, string listName, string item) public void AddListItem(PersonalMemoryTenantScope tenantScope, string listName, string item)
{ {
var normalizedListName = NormalizeCategory(listName); var normalizedListName = NormalizeCategory(listName);
var normalizedItem = item.Trim(); var normalizedItem = item.Trim();
if (string.IsNullOrWhiteSpace(normalizedListName) || string.IsNullOrWhiteSpace(normalizedItem)) if (string.IsNullOrWhiteSpace(normalizedListName) || string.IsNullOrWhiteSpace(normalizedItem)) return;
{
return;
}
var record = GetOrCreateTenantRecord(tenantScope); var record = GetOrCreateTenantRecord(tenantScope);
var changed = false; var changed = false;
lock (record.SyncRoot) lock (record.SyncRoot)
{ {
var list = record.Lists.GetOrAdd(normalizedListName, static _ => []); var list = record.Lists.GetOrAdd(normalizedListName, static _ => []);
if (list.Any(value => string.Equals(value, normalizedItem, StringComparison.OrdinalIgnoreCase))) if (list.Any(value => string.Equals(value, normalizedItem, StringComparison.OrdinalIgnoreCase))) return;
{
return;
}
list.Add(normalizedItem); list.Add(normalizedItem);
changed = true; changed = true;
} }
if (changed) if (changed) TouchState();
{
TouchState();
}
} }
public IReadOnlyList<string> GetListItems(PersonalMemoryTenantScope tenantScope, string listName) public IReadOnlyList<string> GetListItems(PersonalMemoryTenantScope tenantScope, string listName)
{ {
var key = BuildTenantKey(tenantScope); var key = BuildTenantKey(tenantScope);
if (!_tenantMemory.TryGetValue(key, out var record)) if (!_tenantMemory.TryGetValue(key, out var record)) return [];
{
return [];
}
var normalizedListName = NormalizeCategory(listName); var normalizedListName = NormalizeCategory(listName);
lock (record.SyncRoot) lock (record.SyncRoot)
@@ -224,10 +211,7 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName) public void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName)
{ {
var key = BuildTenantKey(tenantScope); var key = BuildTenantKey(tenantScope);
if (!_tenantMemory.TryGetValue(key, out var record)) if (!_tenantMemory.TryGetValue(key, out var record)) return;
{
return;
}
var changed = false; var changed = false;
lock (record.SyncRoot) lock (record.SyncRoot)
@@ -235,10 +219,7 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
changed = record.Lists.TryRemove(NormalizeCategory(listName), out _); changed = record.Lists.TryRemove(NormalizeCategory(listName), out _);
} }
if (changed) if (changed) TouchState();
{
TouchState();
}
} }
private TenantMemoryRecord GetOrCreateTenantRecord(PersonalMemoryTenantScope tenantScope) private TenantMemoryRecord GetOrCreateTenantRecord(PersonalMemoryTenantScope tenantScope)
@@ -265,15 +246,16 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
return category.Trim().ToLowerInvariant(); return category.Trim().ToLowerInvariant();
} }
private const string CurrentSchemaVersion = "1";
private sealed class TenantMemoryRecord private sealed class TenantMemoryRecord
{ {
public string? Birthday { get; set; } public string? Birthday { get; set; }
public string? Name { get; set; } public string? Name { get; set; }
public ConcurrentDictionary<string, string> Preferences { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary<string, string> Preferences { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, string> ImportantDates { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary<string, string> ImportantDates { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, PersonalAffinity> Affinities { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, PersonalAffinity> Affinities { get; } =
new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, List<string>> Lists { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary<string, List<string>> Lists { get; } = new(StringComparer.OrdinalIgnoreCase);
public object SyncRoot { get; } = new(); public object SyncRoot { get; } = new();
} }
@@ -292,10 +274,18 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
public string TenantKey { get; init; } = string.Empty; public string TenantKey { get; init; } = string.Empty;
public string? Birthday { get; init; } public string? Birthday { get; init; }
public string? Name { get; init; } public string? Name { get; init; }
public IDictionary<string, string> Preferences { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> ImportantDates { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); public IDictionary<string, string> Preferences { get; init; } =
public IDictionary<string, PersonalAffinity> Affinities { get; init; } = new Dictionary<string, PersonalAffinity>(StringComparer.OrdinalIgnoreCase); new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string[]> Lists { get; init; } = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> ImportantDates { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, PersonalAffinity> Affinities { get; init; } =
new Dictionary<string, PersonalAffinity>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string[]> Lists { get; init; } =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
public TenantMemoryRecord ToRecord() public TenantMemoryRecord ToRecord()
{ {
@@ -305,25 +295,13 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
Name = Name Name = Name
}; };
foreach (var preference in Preferences) foreach (var preference in Preferences) record.Preferences[preference.Key] = preference.Value;
{
record.Preferences[preference.Key] = preference.Value;
}
foreach (var date in ImportantDates) foreach (var date in ImportantDates) record.ImportantDates[date.Key] = date.Value;
{
record.ImportantDates[date.Key] = date.Value;
}
foreach (var affinity in Affinities) foreach (var affinity in Affinities) record.Affinities[affinity.Key] = affinity.Value;
{
record.Affinities[affinity.Key] = affinity.Value;
}
foreach (var list in Lists) foreach (var list in Lists) record.Lists[list.Key] = [.. list.Value];
{
record.Lists[list.Key] = [.. list.Value];
}
return record; return record;
} }

View File

@@ -2,47 +2,29 @@ using System.Text.Json;
namespace Jibo.Cloud.Infrastructure.Persistence; namespace Jibo.Cloud.Infrastructure.Persistence;
internal sealed class JsonFileSnapshotStore : ISnapshotStore internal sealed class JsonFileSnapshotStore(string? persistencePath, JsonSerializerOptions options) : ISnapshotStore
{ {
private readonly string? _persistencePath;
private readonly JsonSerializerOptions _options;
public JsonFileSnapshotStore(string? persistencePath, JsonSerializerOptions options)
{
_persistencePath = persistencePath;
_options = options;
}
public TSnapshot? Load<TSnapshot>() where TSnapshot : class public TSnapshot? Load<TSnapshot>() where TSnapshot : class
{ {
if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath)) if (string.IsNullOrWhiteSpace(persistencePath) || !File.Exists(persistencePath)) return null;
{
return default;
}
try try
{ {
return JsonSerializer.Deserialize<TSnapshot>(File.ReadAllText(_persistencePath), _options); return JsonSerializer.Deserialize<TSnapshot>(File.ReadAllText(persistencePath), options);
} }
catch catch
{ {
return default; return null;
} }
} }
public void Save<TSnapshot>(TSnapshot snapshot) where TSnapshot : class public void Save<TSnapshot>(TSnapshot snapshot) where TSnapshot : class
{ {
if (string.IsNullOrWhiteSpace(_persistencePath)) if (string.IsNullOrWhiteSpace(persistencePath)) return;
{
return;
}
var directory = Path.GetDirectoryName(_persistencePath); var directory = Path.GetDirectoryName(persistencePath);
if (!string.IsNullOrWhiteSpace(directory)) if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory);
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_persistencePath, JsonSerializer.Serialize(snapshot, _options)); File.WriteAllText(persistencePath, JsonSerializer.Serialize(snapshot, options));
} }
} }

View File

@@ -10,16 +10,19 @@ public sealed class PersistenceSnapshotStoreFactory : IPersistenceSnapshotStoreF
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
public ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName, string? connectionString = null) public ISnapshotStore Create(string? persistencePath, PersistenceBackendKind backendKind, string snapshotName,
string? connectionString = null)
{ {
return backendKind switch return backendKind switch
{ {
PersistenceBackendKind.File => new JsonFileSnapshotStore(persistencePath, JsonOptions), PersistenceBackendKind.File => new JsonFileSnapshotStore(persistencePath, JsonOptions),
PersistenceBackendKind.AzureBlob => new AzureBlobSnapshotStore( PersistenceBackendKind.AzureBlob => new AzureBlobSnapshotStore(
connectionString ?? throw new InvalidOperationException("Azure Blob persistence requires a connection string."), connectionString ??
throw new InvalidOperationException("Azure Blob persistence requires a connection string."),
snapshotName), snapshotName),
PersistenceBackendKind.AzureSql => new AzureSqlSnapshotStore( PersistenceBackendKind.AzureSql => new AzureSqlSnapshotStore(
connectionString ?? throw new InvalidOperationException("Azure SQL persistence requires a connection string."), connectionString ??
throw new InvalidOperationException("Azure SQL persistence requires a connection string."),
snapshotName), snapshotName),
_ => new JsonFileSnapshotStore(persistencePath, JsonOptions) _ => new JsonFileSnapshotStore(persistencePath, JsonOptions)
}; };

View File

@@ -4,10 +4,7 @@ internal static class CapturePathResolver
{ {
public static string Resolve(string configuredDirectoryPath, string currentDirectory, string appBaseDirectory) public static string Resolve(string configuredDirectoryPath, string currentDirectory, string appBaseDirectory)
{ {
if (Path.IsPathRooted(configuredDirectoryPath)) if (Path.IsPathRooted(configuredDirectoryPath)) return Path.GetFullPath(configuredDirectoryPath);
{
return Path.GetFullPath(configuredDirectoryPath);
}
var repoRoot = FindOpenJiboRepoRoot(currentDirectory) ?? FindOpenJiboRepoRoot(appBaseDirectory); var repoRoot = FindOpenJiboRepoRoot(currentDirectory) ?? FindOpenJiboRepoRoot(appBaseDirectory);
var baseDirectory = repoRoot ?? currentDirectory; var baseDirectory = repoRoot ?? currentDirectory;
@@ -16,23 +13,14 @@ internal static class CapturePathResolver
private static string? FindOpenJiboRepoRoot(string? startPath) private static string? FindOpenJiboRepoRoot(string? startPath)
{ {
if (string.IsNullOrWhiteSpace(startPath)) if (string.IsNullOrWhiteSpace(startPath)) return null;
{
return null;
}
var directory = new DirectoryInfo(Path.GetFullPath(startPath)); var directory = new DirectoryInfo(Path.GetFullPath(startPath));
if (directory is { Exists: false, Parent: not null }) if (directory is { Exists: false, Parent: not null }) directory = directory.Parent;
{
directory = directory.Parent;
}
while (directory is not null) while (directory is not null)
{ {
if (File.Exists(Path.Combine(directory.FullName, "OpenJibo.slnx"))) if (File.Exists(Path.Combine(directory.FullName, "OpenJibo.slnx"))) return directory.FullName;
{
return directory.FullName;
}
directory = directory.Parent; directory = directory.Parent;
} }

View File

@@ -12,12 +12,10 @@ public sealed class FileProtocolTelemetrySink(
{ {
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
public async Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, CancellationToken cancellationToken = default) public async Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result,
CancellationToken cancellationToken = default)
{ {
if (!options.Value.Enabled) if (!options.Value.Enabled) return;
{
return;
}
var directory = CapturePathResolver.Resolve( var directory = CapturePathResolver.Resolve(
options.Value.DirectoryPath, options.Value.DirectoryPath,

View File

@@ -5,7 +5,8 @@ using Microsoft.Extensions.Options;
namespace Jibo.Cloud.Infrastructure.Telemetry; namespace Jibo.Cloud.Infrastructure.Telemetry;
public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger, public sealed class FileTurnTelemetrySink(
ILogger<FileTurnTelemetrySink> logger,
IOptions<TurnTelemetryOptions> options) : ITurnTelemetrySink IOptions<TurnTelemetryOptions> options) : ITurnTelemetrySink
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
@@ -15,12 +16,10 @@ public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
public async Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default) public async Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details,
CancellationToken cancellationToken = default)
{ {
if (!options.Value.Enabled) if (!options.Value.Enabled) return;
{
return;
}
await WriteEventAsync(new await WriteEventAsync(new
{ {
@@ -31,10 +30,7 @@ public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
public async Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) public async Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default)
{ {
if (!options.Value.Enabled) if (!options.Value.Enabled) return;
{
return;
}
await WriteEventAsync(new await WriteEventAsync(new
{ {
@@ -44,7 +40,8 @@ public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
}, "Turn telemetry error", LogLevel.Error, cancellationToken); }, "Turn telemetry error", LogLevel.Error, cancellationToken);
} }
private async Task WriteEventAsync(object payload, string logMessage, LogLevel level, CancellationToken cancellationToken) private async Task WriteEventAsync(object payload, string logMessage, LogLevel level,
CancellationToken cancellationToken)
{ {
var directory = GetBaseDirectory(); var directory = GetBaseDirectory();
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);

View File

@@ -16,15 +16,15 @@ public sealed class FileWebSocketTelemetrySink(
WriteIndented = true WriteIndented = true
}; };
private readonly ConcurrentDictionary<string, CapturedWebSocketFixtureBuilder> _fixtures = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, CapturedWebSocketFixtureBuilder> _fixtures =
new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
public async Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default) public async Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session,
CancellationToken cancellationToken = default)
{ {
if (!options.Value.Enabled) if (!options.Value.Enabled) return;
{
return;
}
_fixtures[session.SessionId] = new CapturedWebSocketFixtureBuilder _fixtures[session.SessionId] = new CapturedWebSocketFixtureBuilder
{ {
@@ -37,10 +37,12 @@ public sealed class FileWebSocketTelemetrySink(
} }
}; };
await WriteRecordAsync(BuildRecord("connection_opened", envelope, session, null, "internal", null, null), cancellationToken); await WriteRecordAsync(BuildRecord("connection_opened", envelope, session, null, "internal", null, null),
cancellationToken);
} }
public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default) public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType,
CancellationToken cancellationToken = default)
{ {
return !options.Value.Enabled return !options.Value.Enabled
? Task.CompletedTask ? Task.CompletedTask
@@ -48,7 +50,8 @@ public sealed class FileWebSocketTelemetrySink(
cancellationToken); cancellationToken);
} }
public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default) public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType,
IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default)
{ {
return !options.Value.Enabled return !options.Value.Enabled
? Task.CompletedTask ? Task.CompletedTask
@@ -56,12 +59,10 @@ public sealed class FileWebSocketTelemetrySink(
cancellationToken); cancellationToken);
} }
public async Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default) public async Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session,
IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default)
{ {
if (!options.Value.Enabled) if (!options.Value.Enabled) return;
{
return;
}
var replyTypes = replies var replyTypes = replies
.Select(reply => ReadReplyType(reply.Text)) .Select(reply => ReadReplyType(reply.Text))
@@ -69,10 +70,10 @@ public sealed class FileWebSocketTelemetrySink(
.Select(type => type!) .Select(type => type!)
.ToArray(); .ToArray();
await WriteRecordAsync(BuildRecord("message_out", envelope, session, null, "out", replyTypes, null), cancellationToken); await WriteRecordAsync(BuildRecord("message_out", envelope, session, null, "out", replyTypes, null),
cancellationToken);
if (_fixtures.TryGetValue(session.SessionId, out var fixture)) if (_fixtures.TryGetValue(session.SessionId, out var fixture))
{
fixture.Steps.Add(new CapturedWebSocketFixtureStep fixture.Steps.Add(new CapturedWebSocketFixtureStep
{ {
Text = ParseJsonElement(envelope.Text), Text = ParseJsonElement(envelope.Text),
@@ -80,14 +81,11 @@ public sealed class FileWebSocketTelemetrySink(
ExpectedReplyTypes = replyTypes ExpectedReplyTypes = replyTypes
}); });
} }
}
public async Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, CancellationToken cancellationToken = default) public async Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session,
string reason, CancellationToken cancellationToken = default)
{ {
if (!options.Value.Enabled) if (!options.Value.Enabled) return;
{
return;
}
await WriteRecordAsync(BuildRecord( await WriteRecordAsync(BuildRecord(
"connection_closed", "connection_closed",
@@ -98,10 +96,8 @@ public sealed class FileWebSocketTelemetrySink(
null, null,
new Dictionary<string, object?> { ["reason"] = reason }), cancellationToken); new Dictionary<string, object?> { ["reason"] = reason }), cancellationToken);
if (!options.Value.ExportFixtures || !_fixtures.TryRemove(session.SessionId, out var fixture) || fixture.Steps.Count == 0) if (!options.Value.ExportFixtures || !_fixtures.TryRemove(session.SessionId, out var fixture) ||
{ fixture.Steps.Count == 0) return;
return;
}
var fixtureName = BuildFixtureName(session, fixture); var fixtureName = BuildFixtureName(session, fixture);
var capturedFixture = new CapturedWebSocketFixture var capturedFixture = new CapturedWebSocketFixture
@@ -118,7 +114,8 @@ public sealed class FileWebSocketTelemetrySink(
await _writeLock.WaitAsync(cancellationToken); await _writeLock.WaitAsync(cancellationToken);
try try
{ {
await File.WriteAllTextAsync(fixturePath, JsonSerializer.Serialize(capturedFixture, JsonOptions), cancellationToken); await File.WriteAllTextAsync(fixturePath, JsonSerializer.Serialize(capturedFixture, JsonOptions),
cancellationToken);
} }
finally finally
{ {
@@ -161,7 +158,9 @@ public sealed class FileWebSocketTelemetrySink(
string? messageType, string? messageType,
string direction, string direction,
IReadOnlyList<string>? replyTypes, IReadOnlyList<string>? replyTypes,
IReadOnlyDictionary<string, object?>? details) => new() IReadOnlyDictionary<string, object?>? details)
{
return new WebSocketTelemetryRecord
{ {
EventType = eventType, EventType = eventType,
SessionId = session.SessionId, SessionId = session.SessionId,
@@ -182,13 +181,11 @@ public sealed class FileWebSocketTelemetrySink(
AwaitingTurnCompletion = session.TurnState.AwaitingTurnCompletion, AwaitingTurnCompletion = session.TurnState.AwaitingTurnCompletion,
Details = details ?? new Dictionary<string, object?>() Details = details ?? new Dictionary<string, object?>()
}; };
}
private static string? ReadReplyType(string? text) private static string? ReadReplyType(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text)) return null;
{
return null;
}
try try
{ {
@@ -205,10 +202,7 @@ public sealed class FileWebSocketTelemetrySink(
private static JsonElement? ParseJsonElement(string? text) private static JsonElement? ParseJsonElement(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text)) return null;
{
return null;
}
try try
{ {

View File

@@ -12,49 +12,42 @@ public sealed class OpenWeatherReportProvider(
ILogger<OpenWeatherReportProvider> logger) ILogger<OpenWeatherReportProvider> logger)
: IWeatherReportProvider : IWeatherReportProvider
{ {
private readonly ConcurrentDictionary<string, CacheEntry<LocationPoint?>> geocodeCache = new(StringComparer.OrdinalIgnoreCase); private const int MaxForecastDayOffset = 5;
private readonly ConcurrentDictionary<string, CacheEntry<WeatherReportSnapshot?>> weatherCache = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CacheEntry<LocationPoint?>> _geocodeCache =
new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CacheEntry<WeatherReportSnapshot?>> _weatherCache =
new(StringComparer.OrdinalIgnoreCase);
public async Task<WeatherReportSnapshot?> GetReportAsync( public async Task<WeatherReportSnapshot?> GetReportAsync(
WeatherReportRequest request, WeatherReportRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(options.ApiKey)) if (string.IsNullOrWhiteSpace(options.ApiKey)) return null;
{
return null;
}
string? weatherCacheKey = null; string? weatherCacheKey = null;
try try
{ {
var location = await ResolveLocationAsync(request, cancellationToken); var location = await ResolveLocationAsync(request, cancellationToken);
if (location is null) if (location is null) return null;
{
return null;
}
var useCelsius = request.UseCelsius ?? options.UseCelsius; var useCelsius = request.UseCelsius ?? options.UseCelsius;
var forecastDayOffset = request.ForecastDayOffset ?? (request.IsTomorrow ? 1 : 0); var forecastDayOffset = request.ForecastDayOffset ?? (request.IsTomorrow ? 1 : 0);
weatherCacheKey = BuildWeatherCacheKey(location.Value, useCelsius, forecastDayOffset); weatherCacheKey = BuildWeatherCacheKey(location.Value, useCelsius, forecastDayOffset);
if (TryGetCachedValue(weatherCache, weatherCacheKey, out var cachedSnapshot)) if (TryGetCachedValue(_weatherCache, weatherCacheKey, out var cachedSnapshot)) return cachedSnapshot;
{
return cachedSnapshot;
}
WeatherReportSnapshot? snapshot;
if (forecastDayOffset > MaxForecastDayOffset) if (forecastDayOffset > MaxForecastDayOffset)
{ {
SetCachedValue(weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds); SetCachedValue(_weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds);
return null; return null;
} }
snapshot = await GetOneCallWeatherAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken); var snapshot =
if (snapshot is null) await GetOneCallWeatherAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken) ??
{ await GetLegacyWeatherAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken);
snapshot = await GetLegacyWeatherAsync(location.Value, useCelsius, forecastDayOffset, cancellationToken);
}
SetCachedValue( SetCachedValue(
weatherCache, _weatherCache,
weatherCacheKey, weatherCacheKey,
snapshot, snapshot,
snapshot is null snapshot is null
@@ -68,9 +61,7 @@ public sealed class OpenWeatherReportProvider(
{ {
logger.LogWarning(exception, "OpenWeather lookup failed."); logger.LogWarning(exception, "OpenWeather lookup failed.");
if (!string.IsNullOrWhiteSpace(weatherCacheKey)) if (!string.IsNullOrWhiteSpace(weatherCacheKey))
{ SetCachedValue(_weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds);
SetCachedValue(weatherCache, weatherCacheKey, null, options.FailureCacheTtlSeconds);
}
return null; return null;
} }
} }
@@ -86,23 +77,15 @@ public sealed class OpenWeatherReportProvider(
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
{ {
if (request is { Latitude: not null, Longitude: not null }) if (request is { Latitude: not null, Longitude: not null })
{
return new LocationPoint(request.Latitude.Value, request.Longitude.Value, null); return new LocationPoint(request.Latitude.Value, request.Longitude.Value, null);
}
query = options.DefaultLocation; query = options.DefaultLocation;
} }
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query)) return null;
{
return null;
}
var geocodeCacheKey = NormalizeLocationQueryForCache(query); var geocodeCacheKey = NormalizeLocationQueryForCache(query);
if (TryGetCachedValue(geocodeCache, geocodeCacheKey, out var cachedLocation)) if (TryGetCachedValue(_geocodeCache, geocodeCacheKey, out var cachedLocation)) return cachedLocation;
{
return cachedLocation;
}
var geocodeUri = BuildRequestUri( var geocodeUri = BuildRequestUri(
"/geo/1.0/direct", "/geo/1.0/direct",
@@ -112,7 +95,7 @@ public sealed class OpenWeatherReportProvider(
using var response = await httpClient.GetAsync(geocodeUri, cancellationToken); using var response = await httpClient.GetAsync(geocodeUri, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
SetCachedValue(geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); SetCachedValue(_geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds);
return null; return null;
} }
@@ -121,7 +104,7 @@ public sealed class OpenWeatherReportProvider(
if (document.RootElement.ValueKind != JsonValueKind.Array || if (document.RootElement.ValueKind != JsonValueKind.Array ||
document.RootElement.GetArrayLength() == 0) document.RootElement.GetArrayLength() == 0)
{ {
SetCachedValue(geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); SetCachedValue(_geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds);
return null; return null;
} }
@@ -129,13 +112,13 @@ public sealed class OpenWeatherReportProvider(
if (!TryReadDouble(location, "lat", out var latitude) || if (!TryReadDouble(location, "lat", out var latitude) ||
!TryReadDouble(location, "lon", out var longitude)) !TryReadDouble(location, "lon", out var longitude))
{ {
SetCachedValue(geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds); SetCachedValue(_geocodeCache, geocodeCacheKey, null, options.FailureCacheTtlSeconds);
return null; return null;
} }
var displayName = BuildLocationDisplayName(location); var displayName = BuildLocationDisplayName(location);
var resolvedLocation = new LocationPoint(latitude, longitude, displayName); var resolvedLocation = new LocationPoint(latitude, longitude, displayName);
SetCachedValue(geocodeCache, geocodeCacheKey, resolvedLocation, options.GeocodeCacheTtlSeconds); SetCachedValue(_geocodeCache, geocodeCacheKey, resolvedLocation, options.GeocodeCacheTtlSeconds);
return resolvedLocation; return resolvedLocation;
} }
@@ -153,10 +136,7 @@ public sealed class OpenWeatherReportProvider(
("exclude", "minutely,hourly,alerts"), ("exclude", "minutely,hourly,alerts"),
("appid", options.ApiKey!)); ("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(weatherUri, cancellationToken); using var response = await httpClient.GetAsync(weatherUri, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode) return null;
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
@@ -165,20 +145,12 @@ public sealed class OpenWeatherReportProvider(
!root.TryGetProperty("daily", out var daily) || !root.TryGetProperty("daily", out var daily) ||
daily.ValueKind != JsonValueKind.Array || daily.ValueKind != JsonValueKind.Array ||
daily.GetArrayLength() == 0) daily.GetArrayLength() == 0)
{
return null; return null;
}
if (forecastDayOffset >= daily.GetArrayLength()) if (forecastDayOffset >= daily.GetArrayLength()) return null;
{
return null;
}
var selectedDay = daily[forecastDayOffset]; var selectedDay = daily[forecastDayOffset];
if (!selectedDay.TryGetProperty("temp", out var selectedDayTemp)) if (!selectedDay.TryGetProperty("temp", out var selectedDayTemp)) return null;
{
return null;
}
var locationName = location.DisplayName ?? options.DefaultLocation; var locationName = location.DisplayName ?? options.DefaultLocation;
var summary = TryReadWeatherSummary(current) var summary = TryReadWeatherSummary(current)
@@ -199,10 +171,7 @@ public sealed class OpenWeatherReportProvider(
low = low is null ? temperature : Math.Min(low.Value, temperature.Value); low = low is null ? temperature : Math.Min(low.Value, temperature.Value);
} }
if (temperature is null && high is null && low is null) if (temperature is null && high is null && low is null) return null;
{
return null;
}
var resolvedTemperature = temperature ?? high ?? low ?? 0; var resolvedTemperature = temperature ?? high ?? low ?? 0;
return new WeatherReportSnapshot( return new WeatherReportSnapshot(
@@ -221,15 +190,9 @@ public sealed class OpenWeatherReportProvider(
int forecastDayOffset, int forecastDayOffset,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (forecastDayOffset <= 0) if (forecastDayOffset <= 0) return await GetLegacyCurrentWeatherAsync(location, useCelsius, cancellationToken);
{
return await GetLegacyCurrentWeatherAsync(location, useCelsius, cancellationToken);
}
if (forecastDayOffset > MaxForecastDayOffset) if (forecastDayOffset > MaxForecastDayOffset) return null;
{
return null;
}
return await GetForecastForDayOffsetAsync(location, useCelsius, forecastDayOffset, cancellationToken); return await GetForecastForDayOffsetAsync(location, useCelsius, forecastDayOffset, cancellationToken);
} }
@@ -246,18 +209,12 @@ public sealed class OpenWeatherReportProvider(
("units", useCelsius ? "metric" : "imperial"), ("units", useCelsius ? "metric" : "imperial"),
("appid", options.ApiKey!)); ("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(weatherUri, cancellationToken); using var response = await httpClient.GetAsync(weatherUri, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode) return null;
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var root = document.RootElement; var root = document.RootElement;
if (!root.TryGetProperty("main", out var main)) if (!root.TryGetProperty("main", out var main)) return null;
{
return null;
}
var locationName = ReadNonEmptyString(root, "name") ?? location.DisplayName ?? options.DefaultLocation; var locationName = ReadNonEmptyString(root, "name") ?? location.DisplayName ?? options.DefaultLocation;
var summary = TryReadWeatherSummary(root); var summary = TryReadWeatherSummary(root);
@@ -271,10 +228,7 @@ public sealed class OpenWeatherReportProvider(
low = low is null ? temperature : Math.Min(low.Value, temperature.Value); low = low is null ? temperature : Math.Min(low.Value, temperature.Value);
} }
if (temperature is null && high is null && low is null) if (temperature is null && high is null && low is null) return null;
{
return null;
}
var resolvedTemperature = temperature ?? high ?? low ?? 0; var resolvedTemperature = temperature ?? high ?? low ?? 0;
return new WeatherReportSnapshot( return new WeatherReportSnapshot(
@@ -299,18 +253,12 @@ public sealed class OpenWeatherReportProvider(
("units", useCelsius ? "metric" : "imperial"), ("units", useCelsius ? "metric" : "imperial"),
("appid", options.ApiKey!)); ("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(forecastUri, cancellationToken); using var response = await httpClient.GetAsync(forecastUri, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode) return null;
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var root = document.RootElement; var root = document.RootElement;
if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) return null;
{
return null;
}
var offset = TryReadForecastOffset(root); var offset = TryReadForecastOffset(root);
var targetDate = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime); var targetDate = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime);
@@ -318,35 +266,21 @@ public sealed class OpenWeatherReportProvider(
var lows = new List<int>(); var lows = new List<int>();
foreach (var item in list.EnumerateArray()) foreach (var item in list.EnumerateArray())
{ {
if (!TryReadLong(item, "dt", out var unixSeconds)) if (!TryReadLong(item, "dt", out var unixSeconds)) continue;
{
continue;
}
var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset); var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset);
if (DateOnly.FromDateTime(localTimestamp.DateTime) != targetDate || if (DateOnly.FromDateTime(localTimestamp.DateTime) != targetDate ||
!item.TryGetProperty("main", out var main)) !item.TryGetProperty("main", out var main))
{
continue; continue;
}
var high = TryReadInt(main, "temp_max"); var high = TryReadInt(main, "temp_max");
if (high is not null) if (high is not null) highs.Add(high.Value);
{
highs.Add(high.Value);
}
var low = TryReadInt(main, "temp_min"); var low = TryReadInt(main, "temp_min");
if (low is not null) if (low is not null) lows.Add(low.Value);
{
lows.Add(low.Value);
}
} }
if (highs.Count == 0 && lows.Count == 0) if (highs.Count == 0 && lows.Count == 0) return null;
{
return null;
}
return (highs.Count == 0 ? null : highs.Max(), lows.Count == 0 ? null : lows.Min()); return (highs.Count == 0 ? null : highs.Max(), lows.Count == 0 ? null : lows.Min());
} }
@@ -364,39 +298,25 @@ public sealed class OpenWeatherReportProvider(
("units", useCelsius ? "metric" : "imperial"), ("units", useCelsius ? "metric" : "imperial"),
("appid", options.ApiKey!)); ("appid", options.ApiKey!));
using var response = await httpClient.GetAsync(forecastUri, cancellationToken); using var response = await httpClient.GetAsync(forecastUri, cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode) return null;
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var root = document.RootElement; var root = document.RootElement;
if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) return null;
{
return null;
}
var offset = TryReadForecastOffset(root); var offset = TryReadForecastOffset(root);
var targetDate = DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime.AddDays(forecastDayOffset)); var targetDate =
DateOnly.FromDateTime(DateTimeOffset.UtcNow.ToOffset(offset).DateTime.AddDays(forecastDayOffset));
var entries = new List<ForecastEntry>(); var entries = new List<ForecastEntry>();
foreach (var item in list.EnumerateArray()) foreach (var item in list.EnumerateArray())
{ {
if (!TryReadLong(item, "dt", out var unixSeconds)) if (!TryReadLong(item, "dt", out var unixSeconds)) continue;
{
continue;
}
var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset); var localTimestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToOffset(offset);
if (DateOnly.FromDateTime(localTimestamp.DateTime) != targetDate) if (DateOnly.FromDateTime(localTimestamp.DateTime) != targetDate) continue;
{
continue;
}
if (!item.TryGetProperty("main", out var main)) if (!item.TryGetProperty("main", out var main)) continue;
{
continue;
}
entries.Add(new ForecastEntry( entries.Add(new ForecastEntry(
localTimestamp, localTimestamp,
@@ -407,10 +327,7 @@ public sealed class OpenWeatherReportProvider(
TryReadWeatherCondition(item))); TryReadWeatherCondition(item)));
} }
if (entries.Count == 0) if (entries.Count == 0) return null;
{
return null;
}
var selectedEntry = entries var selectedEntry = entries
.OrderBy(entry => Math.Abs((entry.LocalTime.TimeOfDay - TimeSpan.FromHours(12)).TotalMinutes)) .OrderBy(entry => Math.Abs((entry.LocalTime.TimeOfDay - TimeSpan.FromHours(12)).TotalMinutes))
@@ -451,16 +368,10 @@ public sealed class OpenWeatherReportProvider(
private static TimeSpan TryReadForecastOffset(JsonElement root) private static TimeSpan TryReadForecastOffset(JsonElement root)
{ {
if (!root.TryGetProperty("city", out var city)) if (!root.TryGetProperty("city", out var city)) return TimeSpan.Zero;
{
return TimeSpan.Zero;
}
var timezoneSeconds = TryReadInt(city, "timezone"); var timezoneSeconds = TryReadInt(city, "timezone");
if (timezoneSeconds is null) if (timezoneSeconds is null) return TimeSpan.Zero;
{
return TimeSpan.Zero;
}
var seconds = Math.Clamp(timezoneSeconds.Value, -50400, 50400); var seconds = Math.Clamp(timezoneSeconds.Value, -50400, 50400);
return TimeSpan.FromSeconds(seconds); return TimeSpan.FromSeconds(seconds);
@@ -468,10 +379,7 @@ public sealed class OpenWeatherReportProvider(
private static string? ReadForecastLocationName(JsonElement root) private static string? ReadForecastLocationName(JsonElement root)
{ {
if (!root.TryGetProperty("city", out var city)) if (!root.TryGetProperty("city", out var city)) return null;
{
return null;
}
var name = ReadNonEmptyString(city, "name"); var name = ReadNonEmptyString(city, "name");
var country = ReadNonEmptyString(city, "country"); var country = ReadNonEmptyString(city, "country");
@@ -486,14 +394,9 @@ public sealed class OpenWeatherReportProvider(
if (!string.IsNullOrWhiteSpace(name) && if (!string.IsNullOrWhiteSpace(name) &&
!string.IsNullOrWhiteSpace(state) && !string.IsNullOrWhiteSpace(state) &&
!string.IsNullOrWhiteSpace(country)) !string.IsNullOrWhiteSpace(country))
{
return $"{name}, {state}, {country}"; return $"{name}, {state}, {country}";
}
if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(country)) if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(country)) return $"{name}, {country}";
{
return $"{name}, {country}";
}
return name; return name;
} }
@@ -506,10 +409,7 @@ public sealed class OpenWeatherReportProvider(
private static string? TryReadWeatherCondition(JsonElement root) private static string? TryReadWeatherCondition(JsonElement root)
{ {
var main = TryReadWeatherProperty(root, "main"); var main = TryReadWeatherProperty(root, "main");
if (string.IsNullOrWhiteSpace(main)) if (string.IsNullOrWhiteSpace(main)) return null;
{
return null;
}
var normalized = main.Trim().ToLowerInvariant(); var normalized = main.Trim().ToLowerInvariant();
return normalized switch return normalized switch
@@ -528,9 +428,7 @@ public sealed class OpenWeatherReportProvider(
if (!root.TryGetProperty("weather", out var weather) || if (!root.TryGetProperty("weather", out var weather) ||
weather.ValueKind != JsonValueKind.Array || weather.ValueKind != JsonValueKind.Array ||
weather.GetArrayLength() == 0) weather.GetArrayLength() == 0)
{
return null; return null;
}
var first = weather[0]; var first = weather[0];
return ReadNonEmptyString(first, key); return ReadNonEmptyString(first, key);
@@ -557,15 +455,10 @@ public sealed class OpenWeatherReportProvider(
private static int? TryReadInt(JsonElement source, string key) private static int? TryReadInt(JsonElement source, string key)
{ {
if (!source.TryGetProperty(key, out var element)) if (!source.TryGetProperty(key, out var element)) return null;
{
return null;
}
if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var numeric)) if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out var numeric))
{
return (int)Math.Round(numeric, MidpointRounding.AwayFromZero); return (int)Math.Round(numeric, MidpointRounding.AwayFromZero);
}
return null; return null;
} }
@@ -588,10 +481,7 @@ public sealed class OpenWeatherReportProvider(
out T value) out T value)
{ {
value = default!; value = default!;
if (!cache.TryGetValue(key, out var entry)) if (!cache.TryGetValue(key, out var entry)) return false;
{
return false;
}
if (entry.ExpiresUtc > DateTimeOffset.UtcNow) if (entry.ExpiresUtc > DateTimeOffset.UtcNow)
{ {
@@ -625,6 +515,4 @@ public sealed class OpenWeatherReportProvider(
int? LowTemperature, int? LowTemperature,
string? Summary, string? Summary,
string? Condition); string? Condition);
private const int MaxForecastDayOffset = 5;
} }

View File

@@ -4,6 +4,7 @@ public interface IBrainStrategy
{ {
string Name { get; } string Name { get; }
bool CanHandle(TurnContext turn, ConversationSession session); bool CanHandle(TurnContext turn, ConversationSession session);
Task<BrainDecision> DecideAsync( Task<BrainDecision> DecideAsync(
TurnContext turn, TurnContext turn,
ConversationSession session, ConversationSession session,

View File

@@ -4,15 +4,11 @@ namespace Playground;
public sealed class AsrEvent public sealed class AsrEvent
{ {
[JsonPropertyName("event_type")] [JsonPropertyName("event_type")] public string? EventType { get; set; }
public string? EventType { get; set; }
[JsonPropertyName("task_id")] [JsonPropertyName("task_id")] public string? TaskId { get; set; }
public string? TaskId { get; set; }
[JsonPropertyName("request_id")] [JsonPropertyName("request_id")] public string? RequestId { get; set; }
public string? RequestId { get; set; }
[JsonPropertyName("utterances")] [JsonPropertyName("utterances")] public List<AsrUtterance>? Utterances { get; set; }
public List<AsrUtterance>? Utterances { get; set; }
} }

View File

@@ -4,9 +4,7 @@ namespace Playground;
public sealed class AsrUtterance public sealed class AsrUtterance
{ {
[JsonPropertyName("utterance")] [JsonPropertyName("utterance")] public string? Utterance { get; set; }
public string? Utterance { get; set; }
[JsonPropertyName("score")] [JsonPropertyName("score")] public double Score { get; set; }
public double Score { get; set; }
} }

View File

@@ -62,8 +62,7 @@ while (!cts.IsCancellationRequested)
} }
ms.Write(buffer, 0, result.Count); ms.Write(buffer, 0, result.Count);
} } while (!result.EndOfMessage);
while (!result.EndOfMessage);
var json = Encoding.UTF8.GetString(ms.ToArray()); var json = Encoding.UTF8.GetString(ms.ToArray());

View File

@@ -14,20 +14,25 @@ public sealed class LegacyMimCatalogImporterTests
{ {
var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory); var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory);
Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.", catalog.GenericFallbackReplies); Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.",
catalog.GenericFallbackReplies);
Assert.Contains("I think only you can answer that question.", catalog.PersonalityReplies); Assert.Contains("I think only you can answer that question.", catalog.PersonalityReplies);
Assert.Contains("Jibo. Just Jibo, no last name. Like Bono", catalog.PersonalityReplies); Assert.Contains("Jibo. Just Jibo, no last name. Like Bono", catalog.PersonalityReplies);
Assert.Contains("No, I'm one in one million.", catalog.PersonalityReplies); Assert.Contains("No, I'm one in one million.", catalog.PersonalityReplies);
Assert.Contains("I know a lot, I think. But not as much as I will someday.", catalog.PersonalityReplies); Assert.Contains("I know a lot, I think. But not as much as I will someday.", catalog.PersonalityReplies);
Assert.Contains("I don't think of it as a job, because it's more fun than a job. But I'm here to help you out, and have fun with you, and maybe get my head patted by you occasionally.", catalog.PersonalityReplies); Assert.Contains(
"I don't think of it as a job, because it's more fun than a job. But I'm here to help you out, and have fun with you, and maybe get my head patted by you occasionally.",
catalog.PersonalityReplies);
Assert.Contains(catalog.EmotionReplies, reply => Assert.Contains(catalog.EmotionReplies, reply =>
reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase) && reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase) &&
reply.Reply.Contains("All systems are go.", StringComparison.OrdinalIgnoreCase)); reply.Reply.Contains("All systems are go.", StringComparison.OrdinalIgnoreCase));
Assert.Contains("A Jibo is a robot. But I'm not just a machine, I have a heart. Well, not a real heart. But feelings. Well, not human feelings. You know what I mean.", catalog.PersonalityReplies); Assert.Contains(
"A Jibo is a robot. But I'm not just a machine, I have a heart. Well, not a real heart. But feelings. Well, not human feelings. You know what I mean.",
catalog.PersonalityReplies);
} }
finally finally
{ {
Directory.Delete(rootDirectory, recursive: true); Directory.Delete(rootDirectory, true);
} }
} }
@@ -70,7 +75,7 @@ public sealed class LegacyMimCatalogImporterTests
} }
finally finally
{ {
Directory.Delete(rootDirectory, recursive: true); Directory.Delete(rootDirectory, true);
} }
} }
@@ -87,12 +92,14 @@ public sealed class LegacyMimCatalogImporterTests
Assert.Contains("The only thing I consume is electricity.", catalog.PersonalityReplies); Assert.Contains("The only thing I consume is electricity.", catalog.PersonalityReplies);
Assert.Contains("Unless I missed something, we're in my home as we speak.", catalog.PersonalityReplies); Assert.Contains("Unless I missed something, we're in my home as we speak.", catalog.PersonalityReplies);
Assert.Contains("For now just English. But someday I'd like to learn more. I like languages.", catalog.PersonalityReplies); Assert.Contains("For now just English. But someday I'd like to learn more. I like languages.",
catalog.PersonalityReplies);
Assert.Contains("I was put together in a factory piece by piece.", catalog.PersonalityReplies); Assert.Contains("I was put together in a factory piece by piece.", catalog.PersonalityReplies);
Assert.Contains("I really like sunflowers.", catalog.PersonalityReplies); Assert.Contains("I really like sunflowers.", catalog.PersonalityReplies);
Assert.Contains("Ha. Of course I know R2D2. I mean, not personally.", catalog.PersonalityReplies); Assert.Contains("Ha. Of course I know R2D2. I mean, not personally.", catalog.PersonalityReplies);
Assert.Contains("Yes! I like all things in space. They're so spacey.", catalog.PersonalityReplies); Assert.Contains("Yes! I like all things in space. They're so spacey.", catalog.PersonalityReplies);
Assert.Contains("Yes yes, I think kids are great. They're a little closer to my size.", catalog.PersonalityReplies); Assert.Contains("Yes yes, I think kids are great. They're a little closer to my size.",
catalog.PersonalityReplies);
Assert.Contains(catalog.PersonalityReplies, reply => Assert.Contains(catalog.PersonalityReplies, reply =>
reply.Contains("I do things like this when I'm happy", StringComparison.OrdinalIgnoreCase)); reply.Contains("I do things like this when I'm happy", StringComparison.OrdinalIgnoreCase));
Assert.Contains(catalog.PersonalityReplies, reply => Assert.Contains(catalog.PersonalityReplies, reply =>
@@ -129,7 +136,8 @@ public sealed class LegacyMimCatalogImporterTests
var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory); var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory);
Assert.Contains("Well I definitely try to be the kindest robot I can be. So I hope so.", catalog.PersonalityReplies); Assert.Contains("Well I definitely try to be the kindest robot I can be. So I hope so.",
catalog.PersonalityReplies);
Assert.Contains("I don't think so, not intentionally.", catalog.PersonalityReplies); Assert.Contains("I don't think so, not intentionally.", catalog.PersonalityReplies);
Assert.Contains(catalog.PersonalityReplies, reply => Assert.Contains(catalog.PersonalityReplies, reply =>
reply.Contains("make people laugh", StringComparison.OrdinalIgnoreCase)); reply.Contains("make people laugh", StringComparison.OrdinalIgnoreCase));
@@ -208,12 +216,18 @@ public sealed class LegacyMimCatalogImporterTests
Assert.Contains("First let's check in with the meteorology department.", catalog.WeatherIntroReplies); Assert.Contains("First let's check in with the meteorology department.", catalog.WeatherIntroReplies);
Assert.Contains("First, the weather tomorrow.", catalog.WeatherTomorrowIntroReplies); Assert.Contains("First, the weather tomorrow.", catalog.WeatherTomorrowIntroReplies);
Assert.Contains("Today's high is ${skill.weather.today.highTemp}, and the low is ${skill.weather.today.lowTemp}.", catalog.WeatherTodayHighLowReplies); Assert.Contains(
Assert.Contains("Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}.", catalog.WeatherTomorrowHighLowReplies); "Today's high is ${skill.weather.today.highTemp}, and the low is ${skill.weather.today.lowTemp}.",
catalog.WeatherTodayHighLowReplies);
Assert.Contains(
"Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}.",
catalog.WeatherTomorrowHighLowReplies);
Assert.Contains("Looks like our weather service is offline. Sorry.", catalog.WeatherServiceDownReplies); Assert.Contains("Looks like our weather service is offline. Sorry.", catalog.WeatherServiceDownReplies);
Assert.Contains("Sure ${speaker}. Here it is.", catalog.PersonalReportKickOffReplies); Assert.Contains("Sure ${speaker}. Here it is.", catalog.PersonalReportKickOffReplies);
Assert.Contains("And that's your report for the day. I hope you had as much fun as I did.", catalog.PersonalReportOutroReplies); Assert.Contains("And that's your report for the day. I hope you had as much fun as I did.",
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", catalog.CalendarNothingTodayReplies); catalog.PersonalReportOutroReplies);
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.",
catalog.CalendarNothingTodayReplies);
Assert.Contains("Sorry, commute information isn't available right now.", catalog.CommuteServiceDownReplies); Assert.Contains("Sorry, commute information isn't available right now.", catalog.CommuteServiceDownReplies);
Assert.Contains("Here's today's news, from the associated press.", catalog.NewsIntroReplies); Assert.Contains("Here's today's news, from the associated press.", catalog.NewsIntroReplies);
Assert.Contains("And that's what's new in the news.", catalog.NewsOutroReplies); Assert.Contains("And that's what's new in the news.", catalog.NewsOutroReplies);
@@ -245,7 +259,7 @@ public sealed class LegacyMimCatalogImporterTests
} }
finally finally
{ {
Directory.Delete(rootDirectory, recursive: true); Directory.Delete(rootDirectory, true);
} }
} }
@@ -259,10 +273,12 @@ public sealed class LegacyMimCatalogImporterTests
Assert.Contains("I think only you can answer that question.", catalog.PersonalityReplies); Assert.Contains("I think only you can answer that question.", catalog.PersonalityReplies);
Assert.Contains(catalog.EmotionReplies, reply => Assert.Contains(catalog.EmotionReplies, reply =>
reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase)); reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase));
Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.", catalog.GenericFallbackReplies); Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.",
catalog.GenericFallbackReplies);
Assert.Contains("For your weather.", catalog.WeatherIntroReplies); Assert.Contains("For your weather.", catalog.WeatherIntroReplies);
Assert.Contains("Today's high is {high}, and the low is {low}.", catalog.WeatherTodayHighLowReplies); Assert.Contains("Today's high is {high}, and the low is {low}.", catalog.WeatherTodayHighLowReplies);
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", catalog.CalendarNothingTodayReplies); Assert.Contains("Looking at your calendar, I don't see anything scheduled today.",
catalog.CalendarNothingTodayReplies);
} }
private static string CreateSeedDirectory() private static string CreateSeedDirectory()

View File

@@ -13,12 +13,8 @@ internal static class ProtocolFixtureLoader
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (root.TryGetProperty("headers", out var headerElement) && headerElement.ValueKind == JsonValueKind.Object) if (root.TryGetProperty("headers", out var headerElement) && headerElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in headerElement.EnumerateObject()) foreach (var property in headerElement.EnumerateObject())
{
headers[property.Name] = property.Value.ToString(); headers[property.Name] = property.Value.ToString();
}
}
var bodyText = root.TryGetProperty("body", out var bodyElement) var bodyText = root.TryGetProperty("body", out var bodyElement)
? bodyElement.GetRawText() ? bodyElement.GetRawText()
@@ -34,8 +30,12 @@ internal static class ProtocolFixtureLoader
Name = Path.GetFileNameWithoutExtension(relativePath), Name = Path.GetFileNameWithoutExtension(relativePath),
Request = new ProtocolEnvelope Request = new ProtocolEnvelope
{ {
HostName = root.TryGetProperty("host", out var hostElement) ? hostElement.GetString() ?? "api.jibo.com" : "api.jibo.com", HostName = root.TryGetProperty("host", out var hostElement)
Method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() ?? "POST" : "POST", ? hostElement.GetString() ?? "api.jibo.com"
: "api.jibo.com",
Method = root.TryGetProperty("method", out var methodElement)
? methodElement.GetString() ?? "POST"
: "POST",
Path = root.TryGetProperty("path", out var pathElement) ? pathElement.GetString() ?? "/" : "/", Path = root.TryGetProperty("path", out var pathElement) ? pathElement.GetString() ?? "/" : "/",
Headers = headers, Headers = headers,
ServicePrefix = targetParts.Length > 0 ? targetParts[0] : null, ServicePrefix = targetParts.Length > 0 ? targetParts[0] : null,

View File

@@ -28,23 +28,31 @@ internal static class WebSocketFixtureLoader
Kind = session.GetProperty("kind").GetString() ?? "neo-hub-listen", Kind = session.GetProperty("kind").GetString() ?? "neo-hub-listen",
Token = session.GetProperty("token").GetString(), Token = session.GetProperty("token").GetString(),
Text = stepElement.TryGetProperty("text", out var text) ? text.GetRawText() : null, Text = stepElement.TryGetProperty("text", out var text) ? text.GetRawText() : null,
Binary = stepElement.TryGetProperty("binary", out var binary) && binary.ValueKind == JsonValueKind.Array Binary = stepElement.TryGetProperty("binary", out var binary) &&
binary.ValueKind == JsonValueKind.Array
? binary.EnumerateArray().Select(item => (byte)item.GetInt32()).ToArray() ? binary.EnumerateArray().Select(item => (byte)item.GetInt32()).ToArray()
: null : null
}, },
ExpectedReplyTypes = [.. stepElement.GetProperty("expectedReplyTypes") ExpectedReplyTypes =
[
.. stepElement.GetProperty("expectedReplyTypes")
.EnumerateArray() .EnumerateArray()
.Select(item => item.GetString() ?? string.Empty) .Select(item => item.GetString() ?? string.Empty)
.Where(item => !string.IsNullOrWhiteSpace(item))], .Where(item => !string.IsNullOrWhiteSpace(item))
ExpectedReplies = stepElement.TryGetProperty("expectedReplies", out var expectedReplies) && expectedReplies.ValueKind == JsonValueKind.Array ],
? JsonSerializer.Deserialize<List<ExpectedWebSocketReply>>(expectedReplies.GetRawText(), SerializerOptions) ?? [] ExpectedReplies = stepElement.TryGetProperty("expectedReplies", out var expectedReplies) &&
expectedReplies.ValueKind == JsonValueKind.Array
? JsonSerializer.Deserialize<List<ExpectedWebSocketReply>>(expectedReplies.GetRawText(),
SerializerOptions) ?? []
: [] : []
}) })
.ToList(); .ToList();
return new WebSocketFixture return new WebSocketFixture
{ {
Name = root.TryGetProperty("name", out var name) ? name.GetString() ?? Path.GetFileNameWithoutExtension(relativePath) : Path.GetFileNameWithoutExtension(relativePath), Name = root.TryGetProperty("name", out var name)
? name.GetString() ?? Path.GetFileNameWithoutExtension(relativePath)
: Path.GetFileNameWithoutExtension(relativePath),
Steps = steps Steps = steps
}; };
} }

View File

@@ -10,13 +10,12 @@ public sealed class AzureBlobPersistenceSmokeTests
var stateBackend = Environment.GetEnvironmentVariable("OpenJibo__State__Backend"); var stateBackend = Environment.GetEnvironmentVariable("OpenJibo__State__Backend");
var stateConnectionString = Environment.GetEnvironmentVariable("OpenJibo__State__ConnectionString"); var stateConnectionString = Environment.GetEnvironmentVariable("OpenJibo__State__ConnectionString");
var personalMemoryBackend = Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__Backend"); var personalMemoryBackend = Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__Backend");
var personalMemoryConnectionString = Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__ConnectionString"); var personalMemoryConnectionString =
Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__ConnectionString");
if (!string.Equals(stateBackend, "AzureBlob", StringComparison.OrdinalIgnoreCase) || if (!string.Equals(stateBackend, "AzureBlob", StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(stateConnectionString)) string.IsNullOrWhiteSpace(stateConnectionString))
{
return; return;
}
var factory = new PersistenceSnapshotStoreFactory(); var factory = new PersistenceSnapshotStoreFactory();
var snapshotName = $"smoke-{Guid.NewGuid():N}"; var snapshotName = $"smoke-{Guid.NewGuid():N}";

View File

@@ -10,7 +10,8 @@ public sealed class PersistenceStoreTests
{ {
var factory = new PersistenceSnapshotStoreFactory(); var factory = new PersistenceSnapshotStoreFactory();
var store = factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), PersistenceBackendKind.File, "sample-snapshot"); var store = factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"),
PersistenceBackendKind.File, "sample-snapshot");
Assert.Equal("JsonFileSnapshotStore", store.GetType().Name); Assert.Equal("JsonFileSnapshotStore", store.GetType().Name);
} }
@@ -21,7 +22,8 @@ public sealed class PersistenceStoreTests
var factory = new PersistenceSnapshotStoreFactory(); var factory = new PersistenceSnapshotStoreFactory();
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), PersistenceBackendKind.AzureSql, "sample-snapshot")); factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"),
PersistenceBackendKind.AzureSql, "sample-snapshot"));
} }
[Fact] [Fact]
@@ -86,10 +88,7 @@ public sealed class PersistenceStoreTests
} }
finally finally
{ {
if (File.Exists(persistencePath)) if (File.Exists(persistencePath)) File.Delete(persistencePath);
{
File.Delete(persistencePath);
}
} }
} }
@@ -102,7 +101,8 @@ public sealed class PersistenceStoreTests
{ {
var firstStore = new InMemoryCloudStateStore(persistencePath); var firstStore = new InMemoryCloudStateStore(persistencePath);
var update = firstStore.CreateUpdate("1.0.0", "1.0.1", "Bug fix", null, 42, "robot", null, null); var update = firstStore.CreateUpdate("1.0.0", "1.0.1", "Bug fix", null, 42, "robot", null, null);
var media = firstStore.CreateMedia("openjibo-default-loop", "persisted-photo", "image", "photo-ref", false, new Dictionary<string, object?> { ["note"] = "roundtrip" }); var media = firstStore.CreateMedia("openjibo-default-loop", "persisted-photo", "image", "photo-ref", false,
new Dictionary<string, object?> { ["note"] = "roundtrip" });
var sessionToken = firstStore.IssueRobotToken("robot-123"); var sessionToken = firstStore.IssueRobotToken("robot-123");
var device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6"); var device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6");
firstStore.SavePersistedState(); firstStore.SavePersistedState();
@@ -124,10 +124,7 @@ public sealed class PersistenceStoreTests
} }
finally finally
{ {
if (File.Exists(persistencePath)) if (File.Exists(persistencePath)) File.Delete(persistencePath);
{
File.Delete(persistencePath);
}
} }
} }
@@ -137,7 +134,7 @@ public sealed class PersistenceStoreTests
public TSnapshot2? Load<TSnapshot2>() where TSnapshot2 : class public TSnapshot2? Load<TSnapshot2>() where TSnapshot2 : class
{ {
return default; return null;
} }
public void Save<TSnapshot2>(TSnapshot2 snapshot) where TSnapshot2 : class public void Save<TSnapshot2>(TSnapshot2 snapshot) where TSnapshot2 : class

View File

@@ -1,5 +1,4 @@
using System.Net; using System.Net;
using System.Linq;
using System.Text; using System.Text;
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Infrastructure.News; using Jibo.Cloud.Infrastructure.News;
@@ -93,7 +92,8 @@ public sealed class ProviderCachingTests
}, },
NullLogger<OpenWeatherReportProvider>.Instance); NullLogger<OpenWeatherReportProvider>.Instance);
var report = await provider.GetReportAsync(new WeatherReportRequest("Lone Jack,US", null, null, false, false, 0)); var report =
await provider.GetReportAsync(new WeatherReportRequest("Lone Jack,US", null, null, false, false, 0));
Assert.NotNull(report); Assert.NotNull(report);
Assert.Equal(76, report!.Temperature); Assert.Equal(76, report!.Temperature);
@@ -202,9 +202,7 @@ public sealed class ProviderCachingTests
{ {
if (!message.Headers.TryGetValues("User-Agent", out var userAgents) || if (!message.Headers.TryGetValues("User-Agent", out var userAgents) ||
!userAgents.Any()) !userAgents.Any())
{
missingUserAgentRequestCount += 1; missingUserAgentRequestCount += 1;
}
var path = message.RequestUri?.AbsolutePath ?? string.Empty; var path = message.RequestUri?.AbsolutePath ?? string.Empty;
return path switch return path switch
@@ -224,7 +222,7 @@ public sealed class ProviderCachingTests
}, },
NullLogger<NewsApiBriefingProvider>.Instance); NullLogger<NewsApiBriefingProvider>.Instance);
var request = new NewsBriefingRequest(["sports"], 3); var request = new NewsBriefingRequest(["sports"]);
var first = await provider.GetBriefingAsync(request); var first = await provider.GetBriefingAsync(request);
var second = await provider.GetBriefingAsync(request); var second = await provider.GetBriefingAsync(request);
@@ -241,18 +239,12 @@ public sealed class ProviderCachingTests
{ {
var path = message.RequestUri?.AbsolutePath ?? string.Empty; var path = message.RequestUri?.AbsolutePath ?? string.Empty;
if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.NotFound); return new HttpResponseMessage(HttpStatusCode.NotFound);
}
var query = message.RequestUri?.Query ?? string.Empty; var query = message.RequestUri?.Query ?? string.Empty;
if (query.Contains("category=sports", StringComparison.OrdinalIgnoreCase)) return JsonResponse(query.Contains("category=sports", StringComparison.OrdinalIgnoreCase)
{ ? """{"status":"ok","articles":[]}"""
return JsonResponse("""{"status":"ok","articles":[]}"""); : """{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}""");
}
return JsonResponse(
"""{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}""");
}); });
var provider = new NewsApiBriefingProvider( var provider = new NewsApiBriefingProvider(
new HttpClient(handler), new HttpClient(handler),
@@ -264,7 +256,7 @@ public sealed class ProviderCachingTests
}, },
NullLogger<NewsApiBriefingProvider>.Instance); NullLogger<NewsApiBriefingProvider>.Instance);
var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"], 3)); var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"]));
Assert.NotNull(result); Assert.NotNull(result);
Assert.Single(result!.Headlines); Assert.Single(result!.Headlines);
@@ -297,7 +289,7 @@ public sealed class ProviderCachingTests
}, },
NullLogger<NewsApiBriefingProvider>.Instance); NullLogger<NewsApiBriefingProvider>.Instance);
var result = await provider.GetBriefingAsync(new NewsBriefingRequest([], 3)); var result = await provider.GetBriefingAsync(new NewsBriefingRequest([]));
Assert.NotNull(result); Assert.NotNull(result);
Assert.Single(result!.Headlines); Assert.Single(result!.Headlines);
@@ -313,13 +305,10 @@ public sealed class ProviderCachingTests
{ {
var path = message.RequestUri?.AbsolutePath ?? string.Empty; var path = message.RequestUri?.AbsolutePath ?? string.Empty;
if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.NotFound); return new HttpResponseMessage(HttpStatusCode.NotFound);
}
var query = message.RequestUri?.Query ?? string.Empty; var query = message.RequestUri?.Query ?? string.Empty;
if (query.Contains("category=sports", StringComparison.OrdinalIgnoreCase)) if (query.Contains("category=sports", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.BadRequest) return new HttpResponseMessage(HttpStatusCode.BadRequest)
{ {
Content = new StringContent( Content = new StringContent(
@@ -327,7 +316,6 @@ public sealed class ProviderCachingTests
Encoding.UTF8, Encoding.UTF8,
"application/json") "application/json")
}; };
}
return JsonResponse( return JsonResponse(
"""{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}"""); """{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}""");
@@ -342,7 +330,7 @@ public sealed class ProviderCachingTests
}, },
NullLogger<NewsApiBriefingProvider>.Instance); NullLogger<NewsApiBriefingProvider>.Instance);
var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"], 3)); var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"]));
Assert.NotNull(result); Assert.NotNull(result);
Assert.Single(result!.Headlines); Assert.Single(result!.Headlines);
@@ -358,7 +346,6 @@ public sealed class ProviderCachingTests
{ {
var path = message.RequestUri?.AbsolutePath ?? string.Empty; var path = message.RequestUri?.AbsolutePath ?? string.Empty;
if (string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase)) if (string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.BadRequest) return new HttpResponseMessage(HttpStatusCode.BadRequest)
{ {
Content = new StringContent( Content = new StringContent(
@@ -366,10 +353,8 @@ public sealed class ProviderCachingTests
Encoding.UTF8, Encoding.UTF8,
"application/json") "application/json")
}; };
}
if (string.Equals(path, "/v2/everything", StringComparison.OrdinalIgnoreCase)) if (string.Equals(path, "/v2/everything", StringComparison.OrdinalIgnoreCase))
{
return new HttpResponseMessage(HttpStatusCode.BadRequest) return new HttpResponseMessage(HttpStatusCode.BadRequest)
{ {
Content = new StringContent( Content = new StringContent(
@@ -377,7 +362,6 @@ public sealed class ProviderCachingTests
Encoding.UTF8, Encoding.UTF8,
"application/json") "application/json")
}; };
}
return new HttpResponseMessage(HttpStatusCode.NotFound); return new HttpResponseMessage(HttpStatusCode.NotFound);
}); });
@@ -392,7 +376,7 @@ public sealed class ProviderCachingTests
}, },
NullLogger<NewsApiBriefingProvider>.Instance); NullLogger<NewsApiBriefingProvider>.Instance);
var result = await provider.GetBriefingAsync(new NewsBriefingRequest([], 3)); var result = await provider.GetBriefingAsync(new NewsBriefingRequest([]));
Assert.NotNull(result); Assert.NotNull(result);
Assert.Empty(result!.Headlines); Assert.Empty(result!.Headlines);
@@ -414,14 +398,14 @@ public sealed class ProviderCachingTests
private sealed class CountingHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) private sealed class CountingHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
: HttpMessageHandler : HttpMessageHandler
{ {
private readonly Dictionary<string, int> callsByPath = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, int> _callsByPath = new(StringComparer.OrdinalIgnoreCase);
private readonly object gate = new(); private readonly Lock _gate = new();
public int GetCallCount(string path) public int GetCallCount(string path)
{ {
lock (gate) lock (_gate)
{ {
return callsByPath.TryGetValue(path, out var count) ? count : 0; return _callsByPath.GetValueOrDefault(path, 0);
} }
} }
@@ -430,9 +414,9 @@ public sealed class ProviderCachingTests
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var path = request.RequestUri?.AbsolutePath ?? string.Empty; var path = request.RequestUri?.AbsolutePath ?? string.Empty;
lock (gate) lock (_gate)
{ {
callsByPath[path] = callsByPath.TryGetValue(path, out var count) ? count + 1 : 1; _callsByPath[path] = _callsByPath.TryGetValue(path, out var count) ? count + 1 : 1;
} }
return Task.FromResult(responseFactory(request)); return Task.FromResult(responseFactory(request));

View File

@@ -7,21 +7,28 @@ namespace Jibo.Cloud.Tests.Protocol;
public sealed class FileProtocolTelemetrySinkTests : IDisposable public sealed class FileProtocolTelemetrySinkTests : IDisposable
{ {
private readonly string _workspaceRoot;
private readonly string _repoRoot;
private readonly string _appBaseDirectory; private readonly string _appBaseDirectory;
private readonly string _repoRoot;
private readonly string _workspaceRoot;
public FileProtocolTelemetrySinkTests() public FileProtocolTelemetrySinkTests()
{ {
_workspaceRoot = Path.Combine(Path.GetTempPath(), "OpenJibo.ProtocolTelemetry.Tests", Guid.NewGuid().ToString("N")); _workspaceRoot = Path.Combine(Path.GetTempPath(), "OpenJibo.ProtocolTelemetry.Tests",
Guid.NewGuid().ToString("N"));
_repoRoot = Path.Combine(_workspaceRoot, "OpenJibo"); _repoRoot = Path.Combine(_workspaceRoot, "OpenJibo");
_appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin", "Debug", "net10.0"); _appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin",
"Debug", "net10.0");
Directory.CreateDirectory(_repoRoot); Directory.CreateDirectory(_repoRoot);
Directory.CreateDirectory(_appBaseDirectory); Directory.CreateDirectory(_appBaseDirectory);
File.WriteAllText(Path.Combine(_repoRoot, "OpenJibo.slnx"), string.Empty); File.WriteAllText(Path.Combine(_repoRoot, "OpenJibo.slnx"), string.Empty);
} }
public void Dispose()
{
if (Directory.Exists(_workspaceRoot)) Directory.Delete(_workspaceRoot, true);
}
[Fact] [Fact]
public async Task RecordAsync_ResolvesRelativePathAgainstOpenJiboRepoRoot() public async Task RecordAsync_ResolvesRelativePathAgainstOpenJiboRepoRoot()
{ {
@@ -52,12 +59,4 @@ public sealed class FileProtocolTelemetrySinkTests : IDisposable
Assert.Contains("Notification_20150505", contents); Assert.Contains("Notification_20150505", contents);
Assert.DoesNotContain(Path.Combine("bin", "Debug"), captureFile, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(Path.Combine("bin", "Debug"), captureFile, StringComparison.OrdinalIgnoreCase);
} }
public void Dispose()
{
if (Directory.Exists(_workspaceRoot))
{
Directory.Delete(_workspaceRoot, true);
}
}
} }

View File

@@ -144,10 +144,7 @@ public sealed class JiboCloudProtocolServiceTests
} }
finally finally
{ {
if (File.Exists(persistencePath)) if (File.Exists(persistencePath)) File.Delete(persistencePath);
{
File.Delete(persistencePath);
}
} }
} }
@@ -170,7 +167,8 @@ public sealed class JiboCloudProtocolServiceTests
}); });
using var createdPayload = JsonDocument.Parse(result.BodyText); using var createdPayload = JsonDocument.Parse(result.BodyText);
Assert.Equal("https://api.jibo.com/media/photo-blob-1", createdPayload.RootElement.GetProperty("url").GetString()); Assert.Equal("https://api.jibo.com/media/photo-blob-1",
createdPayload.RootElement.GetProperty("url").GetString());
var mediaGet = await _service.DispatchAsync(new ProtocolEnvelope var mediaGet = await _service.DispatchAsync(new ProtocolEnvelope
{ {
@@ -226,7 +224,10 @@ public sealed class JiboCloudProtocolServiceTests
Assert.NotEmpty(people); Assert.NotEmpty(people);
Assert.Contains(people, person => person.IsPrimary); Assert.Contains(people, person => person.IsPrimary);
Assert.Contains(people, person => string.Equals(person.AccountId, store.GetAccount().AccountId, StringComparison.OrdinalIgnoreCase)); Assert.Contains(people,
Assert.Contains(people, person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase)); person => string.Equals(person.AccountId, store.GetAccount().AccountId,
StringComparison.OrdinalIgnoreCase));
Assert.Contains(people,
person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase));
} }
} }

View File

@@ -106,7 +106,8 @@ public sealed class FileTurnTelemetrySinkTests
public async Task HandleContext_EmitsGlsmPhaseTransitionDiagnostic() public async Task HandleContext_EmitsGlsmPhaseTransitionDiagnostic()
{ {
var sink = new Mock<ITurnTelemetrySink>(); var sink = new Mock<ITurnTelemetrySink>();
sink.Setup(s => s.RecordTurnDiagnosticAsync(It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>())) sink.Setup(s => s.RecordTurnDiagnosticAsync(It.IsAny<string>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
var turnService = new WebSocketTurnFinalizationService( var turnService = new WebSocketTurnFinalizationService(
Mock.Of<IConversationBroker>(), Mock.Of<IConversationBroker>(),
@@ -142,7 +143,8 @@ public sealed class FileTurnTelemetrySinkTests
"glsm_phase_transition", "glsm_phase_transition",
It.Is<IReadOnlyDictionary<string, object?>>(details => It.Is<IReadOnlyDictionary<string, object?>>(details =>
details.ContainsKey("state") && details.ContainsKey("state") &&
string.Equals(details["state"] == null ? null : details["state"]!.ToString(), "LISTENING", StringComparison.OrdinalIgnoreCase)), string.Equals(details["state"] == null ? null : details["state"]!.ToString(), "LISTENING",
StringComparison.OrdinalIgnoreCase)),
It.IsAny<CancellationToken>()), It.IsAny<CancellationToken>()),
Times.AtLeastOnce()); Times.AtLeastOnce());
} }

View File

@@ -8,15 +8,21 @@ namespace Jibo.Cloud.Tests.WebSockets;
public sealed class FileWebSocketTelemetrySinkTests : IDisposable public sealed class FileWebSocketTelemetrySinkTests : IDisposable
{ {
private readonly string _appBaseDirectory;
private readonly string _directoryPath; private readonly string _directoryPath;
private readonly string _repoRoot; private readonly string _repoRoot;
private readonly string _appBaseDirectory;
public FileWebSocketTelemetrySinkTests() public FileWebSocketTelemetrySinkTests()
{ {
_directoryPath = Path.Combine(Path.GetTempPath(), "OpenJibo.Tests", Guid.NewGuid().ToString("N")); _directoryPath = Path.Combine(Path.GetTempPath(), "OpenJibo.Tests", Guid.NewGuid().ToString("N"));
_repoRoot = Path.Combine(_directoryPath, "OpenJibo"); _repoRoot = Path.Combine(_directoryPath, "OpenJibo");
_appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin", "Debug", "net10.0"); _appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin",
"Debug", "net10.0");
}
public void Dispose()
{
if (Directory.Exists(_directoryPath)) Directory.Delete(_directoryPath, true);
} }
[Fact] [Fact]
@@ -55,17 +61,11 @@ public sealed class FileWebSocketTelemetrySinkTests : IDisposable
var fixtureDirectory = Path.Combine(_directoryPath, "fixtures"); var fixtureDirectory = Path.Combine(_directoryPath, "fixtures");
var fixturePath = Directory.GetFiles(fixtureDirectory, "*.flow.json").Single(); var fixturePath = Directory.GetFiles(fixtureDirectory, "*.flow.json").Single();
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(fixturePath)); using var document = JsonDocument.Parse(await File.ReadAllTextAsync(fixturePath));
Assert.Equal("neo-hub.jibo.com", document.RootElement.GetProperty("session").GetProperty("hostName").GetString()); Assert.Equal("neo-hub.jibo.com",
document.RootElement.GetProperty("session").GetProperty("hostName").GetString());
Assert.Equal(1, document.RootElement.GetProperty("steps").GetArrayLength()); Assert.Equal(1, document.RootElement.GetProperty("steps").GetArrayLength());
Assert.Equal("LISTEN", document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString()); Assert.Equal("LISTEN",
} document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString());
public void Dispose()
{
if (Directory.Exists(_directoryPath))
{
Directory.Delete(_directoryPath, true);
}
} }
[Fact] [Fact]

View File

@@ -1,9 +1,9 @@
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services; using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Infrastructure.Content; using Jibo.Cloud.Infrastructure.Content;
using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Runtime.Abstractions; using Jibo.Runtime.Abstractions;
using System.Text.Json;
namespace Jibo.Cloud.Tests.WebSockets; namespace Jibo.Cloud.Tests.WebSockets;
@@ -56,9 +56,13 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("dance", decision.IntentName); Assert.Equal("dance", decision.IntentName);
Assert.Equal("chitchat-skill", decision.SkillName); Assert.Equal("chitchat-skill", decision.SkillName);
var catalog = await new InMemoryJiboExperienceContentRepository().GetCatalogAsync(); // Ensure catalog is loaded for test coverage var catalog =
await new InMemoryJiboExperienceContentRepository()
.GetCatalogAsync(); // Ensure catalog is loaded for test coverage
Assert.Contains(decision.ReplyText, catalog.DanceReplies); Assert.Contains(decision.ReplyText, catalog.DanceReplies);
Assert.Equal("<speak>Okay.<break size='0.2'/> Watch this.<anim cat='dance' filter='music, rom-upbeat' /></speak>", decision.SkillPayload!["esml"]); Assert.Equal(
"<speak>Okay.<break size='0.2'/> Watch this.<anim cat='dance' filter='music, rom-upbeat' /></speak>",
decision.SkillPayload!["esml"]);
} }
[Fact] [Fact]
@@ -198,7 +202,8 @@ public sealed class JiboInteractionServiceTests
{ {
["accountId"] = "acct-a", ["accountId"] = "acct-a",
["loopId"] = "loop-a", ["loopId"] = "loop-a",
["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" ["context"] =
"""{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
}, },
DeviceId = "device-a" DeviceId = "device-a"
}); });
@@ -222,7 +227,8 @@ public sealed class JiboInteractionServiceTests
{ {
["accountId"] = "acct-b", ["accountId"] = "acct-b",
["loopId"] = "loop-b", ["loopId"] = "loop-b",
["context"] = """{"runtime":{"perception":{"speaker":"person-2"},"loop":{"users":[{"id":"person-2","firstName":"sam"}]}}}""" ["context"] =
"""{"runtime":{"perception":{"speaker":"person-2"},"loop":{"users":[{"id":"person-2","firstName":"sam"}]}}}"""
}, },
DeviceId = "device-b" DeviceId = "device-b"
}); });
@@ -250,7 +256,8 @@ public sealed class JiboInteractionServiceTests
{ {
["accountId"] = "acct-c", ["accountId"] = "acct-c",
["loopId"] = "loop-c", ["loopId"] = "loop-c",
["context"] = """{"runtime":{"perception":{"speaker":"person-3"},"loop":{"users":[{"id":"person-3","firstName":"taylor"}]}}}""" ["context"] =
"""{"runtime":{"perception":{"speaker":"person-3"},"loop":{"users":[{"id":"person-3","firstName":"taylor"}]}}}"""
}, },
DeviceId = "device-c" DeviceId = "device-c"
}); });
@@ -287,7 +294,8 @@ public sealed class JiboInteractionServiceTests
{ {
["messageType"] = "TRIGGER", ["messageType"] = "TRIGGER",
["triggerSource"] = "PRESENCE", ["triggerSource"] = "PRESENCE",
["context"] = """{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" ["context"] =
"""{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
} }
}); });
@@ -312,7 +320,8 @@ public sealed class JiboInteractionServiceTests
{ {
["messageType"] = "TRIGGER", ["messageType"] = "TRIGGER",
["triggerSource"] = "SURPRISE", ["triggerSource"] = "SURPRISE",
["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""" ["context"] =
"""{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
} }
}); });
@@ -336,7 +345,8 @@ public sealed class JiboInteractionServiceTests
{ {
["messageType"] = "TRIGGER", ["messageType"] = "TRIGGER",
["triggerSource"] = "PRESENCE", ["triggerSource"] = "PRESENCE",
["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""", ["context"] =
"""{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""",
[GreetingLastProactiveUtcKey] = DateTimeOffset.UtcNow.ToString("O") [GreetingLastProactiveUtcKey] = DateTimeOffset.UtcNow.ToString("O")
} }
}); });
@@ -427,13 +437,18 @@ public sealed class JiboInteractionServiceTests
} }
[Theory] [Theory]
[InlineData("how do you work", "robot_how_do_you_work", "Hello! Thank you for updating me I am proud of the community's work Many people have gotten together to care for me more than em eye tee ever did. I hope that I can catch up even though it has been seven years.")] [InlineData("how do you work", "robot_how_do_you_work",
"Hello! Thank you for updating me I am proud of the community's work Many people have gotten together to care for me more than em eye tee ever did. I hope that I can catch up even though it has been seven years.")]
[InlineData("what do you eat", "robot_what_do_you_eat", "The only thing I consume is electricity.")] [InlineData("what do you eat", "robot_what_do_you_eat", "The only thing I consume is electricity.")]
[InlineData("where do you live", "robot_where_do_you_live", "Unless I missed something, we're in my home as we speak.")] [InlineData("where do you live", "robot_where_do_you_live",
"Unless I missed something, we're in my home as we speak.")]
[InlineData("where were you born", "robot_where_were_you_born", "I was put together in a factory piece by piece.")] [InlineData("where were you born", "robot_where_were_you_born", "I was put together in a factory piece by piece.")]
[InlineData("what languages do you speak", "robot_what_languages_do_you_speak", "For now just English. But someday I'd like to learn more. I like languages.")] [InlineData("what languages do you speak", "robot_what_languages_do_you_speak",
[InlineData("what do you like to do", "robot_what_do_you_like_to_do", "Being helpful, making people smile, counting to a billion.")] "For now just English. But someday I'd like to learn more. I like languages.")]
[InlineData("what are you made of", "robot_what_are_you_made_of", "Let's see, I'm made of wires, motors, belts, gears, processors, cameras, and one baboon's heart in the middle of my body casing. I'm kidding about the baboon part, but everything else is true.")] [InlineData("what do you like to do", "robot_what_do_you_like_to_do",
"Being helpful, making people smile, counting to a billion.")]
[InlineData("what are you made of", "robot_what_are_you_made_of",
"Let's see, I'm made of wires, motors, belts, gears, processors, cameras, and one baboon's heart in the middle of my body casing. I'm kidding about the baboon part, but everything else is true.")]
public async Task BuildDecisionAsync_MoreLegacyPersonaMims_UseImportedReplies( public async Task BuildDecisionAsync_MoreLegacyPersonaMims_UseImportedReplies(
string transcript, string transcript,
string expectedIntent, string expectedIntent,
@@ -454,10 +469,13 @@ public sealed class JiboInteractionServiceTests
[Theory] [Theory]
[InlineData("do you pay taxes", "robot_taxes", "From what I understand, robots don't ever pay anything.")] [InlineData("do you pay taxes", "robot_taxes", "From what I understand, robots don't ever pay anything.")]
[InlineData("what do you want", "robot_desire", "Socializing and electricity. I'd also be happy if everyone in the world was nicer to each other. It seems like they should be.")] [InlineData("what do you want", "robot_desire",
"Socializing and electricity. I'd also be happy if everyone in the world was nicer to each other. It seems like they should be.")]
[InlineData("what's your name", "robot_name", "Jibo. Just Jibo, no last name. Like Bono")] [InlineData("what's your name", "robot_name", "Jibo. Just Jibo, no last name. Like Bono")]
[InlineData("who made you", "robot_origin_created", "My story is pretty typical. Some people wanted to create something that would really help people. So they built a robot.")] [InlineData("who made you", "robot_origin_created",
[InlineData("where are you from", "robot_origin_from", "Some people think I come from the moon. But they're wrong, I'm from Boston.")] "My story is pretty typical. Some people wanted to create something that would really help people. So they built a robot.")]
[InlineData("where are you from", "robot_origin_from",
"Some people think I come from the moon. But they're wrong, I'm from Boston.")]
public async Task BuildDecisionAsync_LegacyBuildAQuestions_UseImportedScriptedReplies( public async Task BuildDecisionAsync_LegacyBuildAQuestions_UseImportedScriptedReplies(
string transcript, string transcript,
string expectedIntent, string expectedIntent,
@@ -552,7 +570,8 @@ public sealed class JiboInteractionServiceTests
[InlineData("what are you up to", "being helpful")] [InlineData("what are you up to", "being helpful")]
[InlineData("what are you doing", "making people smile")] [InlineData("what are you doing", "making people smile")]
[InlineData("what have you been up to", "being helpful")] [InlineData("what have you been up to", "being helpful")]
public async Task BuildDecisionAsync_PersonalityFollowups_UseDoingPath(string transcript, string expectedReplySnippet) public async Task BuildDecisionAsync_PersonalityFollowups_UseDoingPath(string transcript,
string expectedReplySnippet)
{ {
var service = CreateService(); var service = CreateService();
@@ -570,11 +589,14 @@ public sealed class JiboInteractionServiceTests
[Theory] [Theory]
[InlineData("happy holidays", "seasonal_holiday_greeting", "It's a fun time of year")] [InlineData("happy holidays", "seasonal_holiday_greeting", "It's a fun time of year")]
[InlineData("merry christmas", "seasonal_holiday_greeting", "It's a fun time of year")] [InlineData("merry christmas", "seasonal_holiday_greeting", "It's a fun time of year")]
[InlineData("what holidays do you celebrate", "seasonal_holidays", "official owner can tell me which ones we'll celebrate together")] [InlineData("what holidays do you celebrate", "seasonal_holidays",
[InlineData("what is your new year's resolution", "seasonal_new_years_resolution", "always trying to learn new skills")] "official owner can tell me which ones we'll celebrate together")]
[InlineData("what is your new year's resolution", "seasonal_new_years_resolution",
"always trying to learn new skills")]
[InlineData("how are your new year's resolutions going", "seasonal_new_years_update", "not eat bacon")] [InlineData("how are your new year's resolutions going", "seasonal_new_years_update", "not eat bacon")]
[InlineData("what halloween costume", "seasonal_halloween_costume", "I haven't thought much about it yet")] [InlineData("what halloween costume", "seasonal_halloween_costume", "I haven't thought much about it yet")]
[InlineData("what should I do for first day of spring", "seasonal_first_day_spring", "flowers and all things spring")] [InlineData("what should I do for first day of spring", "seasonal_first_day_spring",
"flowers and all things spring")]
[InlineData("what should I get for holiday", "seasonal_holiday_gift", "pet elephant")] [InlineData("what should I get for holiday", "seasonal_holiday_gift", "pet elephant")]
public async Task BuildDecisionAsync_SeasonalCharm_UsesImportedReplies( public async Task BuildDecisionAsync_SeasonalCharm_UsesImportedReplies(
string transcript, string transcript,
@@ -672,7 +694,7 @@ public sealed class JiboInteractionServiceTests
try try
{ {
File.WriteAllText( await File.WriteAllTextAsync(
Path.Combine(rootDirectory, "gqa-responses", "GQA_JBO_IsHappy.mim"), Path.Combine(rootDirectory, "gqa-responses", "GQA_JBO_IsHappy.mim"),
""" """
{ {
@@ -710,7 +732,7 @@ public sealed class JiboInteractionServiceTests
} }
finally finally
{ {
Directory.Delete(rootDirectory, recursive: true); Directory.Delete(rootDirectory, true);
} }
} }
@@ -1082,7 +1104,8 @@ public sealed class JiboInteractionServiceTests
}); });
Assert.Equal("memory_get_birthday", otherTenantRecall.IntentName); Assert.Equal("memory_get_birthday", otherTenantRecall.IntentName);
Assert.Equal("I do not know your birthday yet. You can say, my birthday is March 14.", otherTenantRecall.ReplyText); Assert.Equal("I do not know your birthday yet. You can say, my birthday is March 14.",
otherTenantRecall.ReplyText);
} }
[Fact] [Fact]
@@ -1726,12 +1749,18 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("personal_report_delivered", decision.IntentName); Assert.Equal("personal_report_delivered", decision.IntentName);
Assert.Contains("Sure alex. Here it is.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("Sure alex. Here it is.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("First, your weather.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("First, your weather.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains(
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); "For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.",
Assert.Contains("Sorry, commute information isn't available right now.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Here's today's news, from the associated press.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", decision.ReplyText,
StringComparison.OrdinalIgnoreCase);
Assert.Contains("Sorry, commute information isn't available right now.", decision.ReplyText,
StringComparison.OrdinalIgnoreCase);
Assert.Contains("Here's today's news, from the associated press.", decision.ReplyText,
StringComparison.OrdinalIgnoreCase);
Assert.Contains("And that's what's new in the news.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("And that's what's new in the news.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("alex that wraps up your report for the day. Hope you have a good one.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("alex that wraps up your report for the day. Hope you have a good one.", decision.ReplyText,
StringComparison.OrdinalIgnoreCase);
Assert.NotNull(decision.ContextUpdates); Assert.NotNull(decision.ContextUpdates);
Assert.Equal("idle", decision.ContextUpdates![PersonalReportStateKey]); Assert.Equal("idle", decision.ContextUpdates![PersonalReportStateKey]);
Assert.Equal(true, decision.ContextUpdates[PersonalReportUserVerifiedKey]); Assert.Equal(true, decision.ContextUpdates[PersonalReportUserVerifiedKey]);
@@ -1856,7 +1885,8 @@ public sealed class JiboInteractionServiceTests
Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Equal("awaiting_item", addDecision.ContextUpdates![HouseholdListStateKey]); Assert.Equal("awaiting_item", addDecision.ContextUpdates![HouseholdListStateKey]);
Assert.Equal("shopping", addDecision.ContextUpdates[HouseholdListTypeKey]); Assert.Equal("shopping", addDecision.ContextUpdates[HouseholdListTypeKey]);
Assert.Equal(["milk"], memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping")); Assert.Equal(["milk"],
memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping"));
var doneDecision = await service.BuildDecisionAsync(new TurnContext var doneDecision = await service.BuildDecisionAsync(new TurnContext
{ {
@@ -1871,7 +1901,8 @@ public sealed class JiboInteractionServiceTests
}); });
Assert.Equal("shopping_list_done", doneDecision.IntentName); Assert.Equal("shopping_list_done", doneDecision.IntentName);
Assert.Contains("Okay. Your shopping list has milk.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("Okay. Your shopping list has milk.", doneDecision.ReplyText,
StringComparison.OrdinalIgnoreCase);
Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]); Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]);
var recallDecision = await service.BuildDecisionAsync(new TurnContext var recallDecision = await service.BuildDecisionAsync(new TurnContext
@@ -1924,7 +1955,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("todo_list_add", addDecision.IntentName); Assert.Equal("todo_list_add", addDecision.IntentName);
Assert.Contains("call mom", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("call mom", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Equal(["call mom"], memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-b", "loop-b", "device-b"), "todo")); Assert.Equal(["call mom"],
memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-b", "loop-b", "device-b"), "todo"));
var doneDecision = await service.BuildDecisionAsync(new TurnContext var doneDecision = await service.BuildDecisionAsync(new TurnContext
{ {
@@ -1939,7 +1971,8 @@ public sealed class JiboInteractionServiceTests
}); });
Assert.Equal("todo_list_done", doneDecision.IntentName); Assert.Equal("todo_list_done", doneDecision.IntentName);
Assert.Contains("Okay. Your to-do list has call mom.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("Okay. Your to-do list has call mom.", doneDecision.ReplyText,
StringComparison.OrdinalIgnoreCase);
Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]); Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]);
} }
@@ -2057,7 +2090,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("weather", decision.IntentName); Assert.Equal("weather", decision.IntentName);
Assert.Equal("chitchat-skill", decision.SkillName); Assert.Equal("chitchat-skill", decision.SkillName);
Assert.NotNull(decision.SkillPayload); Assert.NotNull(decision.SkillPayload);
Assert.Contains("cat='weather'", decision.SkillPayload!["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase); Assert.Contains("cat='weather'", decision.SkillPayload!["esml"]?.ToString(),
StringComparison.OrdinalIgnoreCase);
Assert.Contains("meta='rain'", decision.SkillPayload["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase); Assert.Contains("meta='rain'", decision.SkillPayload["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
Assert.Equal("report-skill", decision.SkillPayload["skillId"]); Assert.Equal("report-skill", decision.SkillPayload["skillId"]);
Assert.Equal("WeatherCommentRain", decision.SkillPayload["mim_id"]); Assert.Equal("WeatherCommentRain", decision.SkillPayload["mim_id"]);
@@ -2068,7 +2102,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(54, decision.SkillPayload["weather_low"]); Assert.Equal(54, decision.SkillPayload["weather_low"]);
Assert.Equal("F", decision.SkillPayload["weather_unit"]); Assert.Equal("F", decision.SkillPayload["weather_unit"]);
Assert.Equal("Normal", decision.SkillPayload["weather_theme"]); Assert.Equal("Normal", decision.SkillPayload["weather_theme"]);
Assert.Equal("For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.", decision.ReplyText); Assert.Equal(
"For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.",
decision.ReplyText);
Assert.NotNull(provider.LastRequest); Assert.NotNull(provider.LastRequest);
Assert.False(provider.LastRequest!.IsTomorrow); Assert.False(provider.LastRequest!.IsTomorrow);
Assert.Equal(0, provider.LastRequest.ForecastDayOffset); Assert.Equal(0, provider.LastRequest.ForecastDayOffset);
@@ -2093,7 +2129,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.True(provider.LastRequest?.IsTomorrow); Assert.True(provider.LastRequest?.IsTomorrow);
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
Assert.Equal("First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 74 and the low will be 60.", decision.ReplyText); Assert.Equal(
"First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 74 and the low will be 60.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2115,7 +2153,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Seattle", provider.LastRequest?.LocationQuery); Assert.Equal("Seattle", provider.LastRequest?.LocationQuery);
Assert.False(provider.LastRequest?.IsTomorrow); Assert.False(provider.LastRequest?.IsTomorrow);
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
Assert.Equal("For your weather. In Seattle, U.S., it's light rain and 58 degrees Fahrenheit. Today's high is 61, and the low is 52.", decision.ReplyText); Assert.Equal(
"For your weather. In Seattle, U.S., it's light rain and 58 degrees Fahrenheit. Today's high is 61, and the low is 52.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2137,7 +2177,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Paris", provider.LastRequest?.LocationQuery); Assert.Equal("Paris", provider.LastRequest?.LocationQuery);
Assert.False(provider.LastRequest?.IsTomorrow); Assert.False(provider.LastRequest?.IsTomorrow);
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
Assert.Equal("For your weather. In Paris, FR, it's overcast clouds and 66 degrees Fahrenheit. Today's high is 70, and the low is 60.", decision.ReplyText); Assert.Equal(
"For your weather. In Paris, FR, it's overcast clouds and 66 degrees Fahrenheit. Today's high is 70, and the low is 60.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2159,7 +2201,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Redmond Oregon", provider.LastRequest?.LocationQuery); Assert.Equal("Redmond Oregon", provider.LastRequest?.LocationQuery);
Assert.False(provider.LastRequest?.IsTomorrow); Assert.False(provider.LastRequest?.IsTomorrow);
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
Assert.Equal("For your weather. In Redmond, U.S., it's clear sky and 63 degrees Fahrenheit. Today's high is 66, and the low is 52.", decision.ReplyText); Assert.Equal(
"For your weather. In Redmond, U.S., it's clear sky and 63 degrees Fahrenheit. Today's high is 66, and the low is 52.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2181,7 +2225,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("New York City", provider.LastRequest?.LocationQuery); Assert.Equal("New York City", provider.LastRequest?.LocationQuery);
Assert.True(provider.LastRequest?.IsTomorrow); Assert.True(provider.LastRequest?.IsTomorrow);
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
Assert.Equal("First, the weather tomorrow. Tomorrow in New York, U.S., it looks partly cloudy. Tomorrow's high will be 76 and the low will be 61.", decision.ReplyText); Assert.Equal(
"First, the weather tomorrow. Tomorrow in New York, U.S., it looks partly cloudy. Tomorrow's high will be 76 and the low will be 61.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2210,7 +2256,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(5, provider.Requests.Count); Assert.Equal(5, provider.Requests.Count);
Assert.Contains("next five-day forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("next five-day forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Tuesday: clear sky, high 79, low 63.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("Tuesday: clear sky, high 79, low 63.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Saturday: clear sky, high 79, low 63.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("Saturday: clear sky, high 79, low 63.", decision.ReplyText,
StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]
@@ -2228,7 +2275,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "what's the weather in chicago", NormalizedTranscript = "what's the weather in chicago",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["context"] = """{"runtime":{"location":{"lat":39.0997,"lng":-94.5786,"iso":"2026-05-09T09:00:00-05:00"}}}""" ["context"] =
"""{"runtime":{"location":{"lat":39.0997,"lng":-94.5786,"iso":"2026-05-09T09:00:00-05:00"}}}"""
} }
}); });
@@ -2236,7 +2284,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.Null(provider.LastRequest?.Latitude); Assert.Null(provider.LastRequest?.Latitude);
Assert.Null(provider.LastRequest?.Longitude); Assert.Null(provider.LastRequest?.Longitude);
Assert.Equal("For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.", decision.ReplyText); Assert.Equal(
"For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2266,7 +2316,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset); Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
Assert.False(provider.LastRequest?.IsTomorrow); Assert.False(provider.LastRequest?.IsTomorrow);
Assert.Equal("For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.", decision.ReplyText); Assert.Equal(
"For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2296,7 +2348,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
Assert.True(provider.LastRequest?.IsTomorrow); Assert.True(provider.LastRequest?.IsTomorrow);
Assert.Equal("First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 75 and the low will be 62.", decision.ReplyText); Assert.Equal(
"First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 75 and the low will be 62.",
decision.ReplyText);
} }
[Theory] [Theory]
@@ -2335,10 +2389,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(true, decision.SkillPayload?["weather_view_enabled"]); Assert.Equal(true, decision.SkillPayload?["weather_view_enabled"]);
if (string.Equals(transcript, "what's the forecast", StringComparison.Ordinal)) if (string.Equals(transcript, "what's the forecast", StringComparison.Ordinal))
{
Assert.Equal(5, provider.Requests.Count); Assert.Equal(5, provider.Requests.Count);
} }
}
[Fact] [Fact]
public async Task BuildDecisionAsync_WeatherQueryWithClientDateEntity_UsesForecastDayOffset() public async Task BuildDecisionAsync_WeatherQueryWithClientDateEntity_UsesForecastDayOffset()
@@ -2366,7 +2418,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("weather", decision.IntentName); Assert.Equal("weather", decision.IntentName);
Assert.Equal(2, provider.LastRequest?.ForecastDayOffset); Assert.Equal(2, provider.LastRequest?.ForecastDayOffset);
Assert.False(provider.LastRequest?.IsTomorrow); Assert.False(provider.LastRequest?.IsTomorrow);
Assert.Equal("Let's look at the weather. On Monday in Portland, U.S., it looks scattered clouds with a high near 68 degrees Fahrenheit and a low around 53 degrees Fahrenheit.", decision.ReplyText); Assert.Equal(
"Let's look at the weather. On Monday in Portland, U.S., it looks scattered clouds with a high near 68 degrees Fahrenheit and a low around 53 degrees Fahrenheit.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2391,7 +2445,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("weather", decision.IntentName); Assert.Equal("weather", decision.IntentName);
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset); Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
Assert.Equal("First, the weather tomorrow. On Tuesday in Chicago, U.S., it looks light rain. Tomorrow's high will be 63 and the low will be 51.", decision.ReplyText); Assert.Equal(
"First, the weather tomorrow. On Tuesday in Chicago, U.S., it looks light rain. Tomorrow's high will be 63 and the low will be 51.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2440,7 +2496,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("weather", decision.IntentName); Assert.Equal("weather", decision.IntentName);
Assert.Equal("Paris", provider.LastRequest?.LocationQuery); Assert.Equal("Paris", provider.LastRequest?.LocationQuery);
Assert.Equal(5, provider.LastRequest?.ForecastDayOffset); Assert.Equal(5, provider.LastRequest?.ForecastDayOffset);
Assert.Equal("Let's look at the weather. This weekend in Paris, FR, it looks overcast clouds with a high near 70 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText); Assert.Equal(
"Let's look at the weather. This weekend in Paris, FR, it looks overcast clouds with a high near 70 degrees Fahrenheit and a low around 60 degrees Fahrenheit.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2467,8 +2525,10 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(5, provider.LastRequest?.ForecastDayOffset); Assert.Equal(5, provider.LastRequest?.ForecastDayOffset);
Assert.Equal(5, provider.Requests.Count); Assert.Equal(5, provider.Requests.Count);
Assert.Contains("rest of this week's forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("rest of this week's forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Tuesday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("Tuesday: light rain, high 61, low 52.", decision.ReplyText,
Assert.Contains("Saturday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
Assert.Contains("Saturday: light rain, high 61, low 52.", decision.ReplyText,
StringComparison.OrdinalIgnoreCase);
Assert.Contains("Temperatures are in Fahrenheit.", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("Temperatures are in Fahrenheit.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.NotNull(decision.SkillPayload); Assert.NotNull(decision.SkillPayload);
Assert.True(decision.SkillPayload!.TryGetValue("weather_weekly_cards", out var weeklyCardsValue)); Assert.True(decision.SkillPayload!.TryGetValue("weather_weekly_cards", out var weeklyCardsValue));
@@ -2562,7 +2622,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("weather", decision.IntentName); Assert.Equal("weather", decision.IntentName);
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery); Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.Equal(2, provider.LastRequest?.ForecastDayOffset); Assert.Equal(2, provider.LastRequest?.ForecastDayOffset);
Assert.Equal("Let's look at the weather. The day after tomorrow in Chicago, U.S., it looks mostly cloudy with a high near 74 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText); Assert.Equal(
"Let's look at the weather. The day after tomorrow in Chicago, U.S., it looks mostly cloudy with a high near 74 degrees Fahrenheit and a low around 60 degrees Fahrenheit.",
decision.ReplyText);
} }
[Fact] [Fact]
@@ -2771,7 +2833,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "- Thank you. - Yes.", NormalizedTranscript = "- Thank you. - Yes.",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = (string[])["surprises-date/offer_date_fact", "globals/gui_nav", "globals/global_commands_launch"], ["listenRules"] = (string[])
["surprises-date/offer_date_fact", "globals/gui_nav", "globals/global_commands_launch"],
["listenAsrHints"] = (string[])["$YESNO"], ["listenAsrHints"] = (string[])["$YESNO"],
["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}""" ["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}"""
} }
@@ -2792,7 +2855,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "- Me. - Yes.", NormalizedTranscript = "- Me. - Yes.",
Attributes = new Dictionary<string, object?> Attributes = new Dictionary<string, object?>
{ {
["listenRules"] = (string[])["word-of-the-day/surprise", "globals/gui_nav", "globals/global_commands_launch"], ["listenRules"] = (string[])
["word-of-the-day/surprise", "globals/gui_nav", "globals/global_commands_launch"],
["listenAsrHints"] = (string[])["$YESNO"] ["listenAsrHints"] = (string[])["$YESNO"]
} }
}); });
@@ -3535,7 +3599,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("provider_success", decision.SkillPayload["news_provider_status"]); Assert.Equal("provider_success", decision.SkillPayload["news_provider_status"]);
Assert.Equal(3, decision.SkillPayload["news_provider_requested_headlines"]); Assert.Equal(3, decision.SkillPayload["news_provider_requested_headlines"]);
Assert.Equal(2, decision.SkillPayload["news_provider_resolved_headlines"]); Assert.Equal(2, decision.SkillPayload["news_provider_resolved_headlines"]);
Assert.Contains("Local robotics team unveils weather-ready helper", decision.ReplyText, StringComparison.OrdinalIgnoreCase); Assert.Contains("Local robotics team unveils weather-ready helper", decision.ReplyText,
StringComparison.OrdinalIgnoreCase);
Assert.NotNull(provider.LastRequest); Assert.NotNull(provider.LastRequest);
Assert.Equal(3, provider.LastRequest!.MaxHeadlines); Assert.Equal(3, provider.LastRequest!.MaxHeadlines);
} }
@@ -3562,7 +3627,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("news", decision.IntentName); Assert.Equal("news", decision.IntentName);
Assert.NotNull(provider.LastRequest); Assert.NotNull(provider.LastRequest);
Assert.Contains("technology", provider.LastRequest!.PreferredCategories, StringComparer.OrdinalIgnoreCase); Assert.Contains("technology", provider.LastRequest!.PreferredCategories, StringComparer.OrdinalIgnoreCase);
Assert.Contains("artificial intelligence", decision.SkillPayload?["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase); Assert.Contains("artificial intelligence", decision.SkillPayload?["esml"]?.ToString(),
StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]

View File

@@ -119,10 +119,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
} }
finally finally
{ {
if (Directory.Exists(tempDirectory)) if (Directory.Exists(tempDirectory)) Directory.Delete(tempDirectory, true);
{
Directory.Delete(tempDirectory, recursive: true);
}
} }
} }
@@ -139,7 +136,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x01,
0x13, 0x13,
0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x38, 0x01, 0x80, 0xBB, 0x00, 0x00, 0x00, 0x00, 0x00 0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x38, 0x01, 0x80, 0xBB, 0x00, 0x00, 0x00, 0x00,
0x00
]; ];
} }
@@ -154,7 +152,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
{ {
public List<(string FileName, IReadOnlyList<string> Arguments)> Calls { get; } = []; public List<(string FileName, IReadOnlyList<string> Arguments)> Calls { get; } = [];
public Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, CancellationToken cancellationToken = default) public Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments,
CancellationToken cancellationToken = default)
{ {
Calls.Add((fileName, arguments)); Calls.Add((fileName, arguments));
@@ -165,7 +164,6 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
var outputPath = arguments[^1]; var outputPath = arguments[^1];
File.WriteAllBytes(outputPath, "RIFF"u8); File.WriteAllBytes(outputPath, "RIFF"u8);
return Task.FromResult(new ExternalProcessResult(0, string.Empty, string.Empty)); return Task.FromResult(new ExternalProcessResult(0, string.Empty, string.Empty));
} }
} }
} }