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,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

View File

@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Jibo QR Generator</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
<head>
<meta charset="UTF-8"/>
<title>Jibo QR Generator</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
@@ -122,57 +122,65 @@
text-align: center;
}
</style>
</head>
<body>
<h1>🤖 Jibo Wi-Fi QR Generator</h1>
<p class="sub">Generates a QR code using Jibo's XOR encoding format</p>
<span id="accessToken"></span>
<span id="wifiConfig"></span>
<div class="card">
<label>SSID (Network Name)</label>
<input id="ssid" placeholder="MyNetwork" />
</head>
<body>
<h1>🤖 Jibo Wi-Fi QR Generator</h1>
<p class="sub">Generates a QR code using Jibo's XOR encoding format</p>
<span id="accessToken"></span>
<span id="wifiConfig"></span>
<div class="card">
<label>SSID (Network Name)</label>
<input id="ssid" placeholder="MyNetwork"/>
<label>Password (leave blank for open network)</label>
<input id="password" type="password" placeholder="••••••••" />
<label>Password (leave blank for open network)</label>
<input id="password" type="password" placeholder="••••••••"/>
<label class="toggle">
<input type="checkbox" id="useStatic" onchange="toggleStatic()" />
Use Static IP
</label>
<label class="toggle">
<input type="checkbox" id="useStatic" onchange="toggleStatic()"/>
Use Static IP
</label>
<div class="static-section" id="staticSection">
<div class="row">
<div>
<label>Static IP</label
><input id="staticIP" placeholder="192.168.1.100" />
</div>
<div>
<label>Netmask</label
><input id="netmask" placeholder="255.255.255.0" />
</div>
</div>
<div class="row">
<div>
<label>Gateway</label
><input id="gateway" placeholder="192.168.1.1" />
</div>
<div>
<label>DNS 1</label><input id="dns1" placeholder="8.8.8.8" />
</div>
</div>
<div><label>DNS 2</label><input id="dns2" placeholder="8.8.4.4" /></div>
</div>
<div class="static-section" id="staticSection">
<div class="row">
<div>
<label>
Static IP
</label
><input id="staticIP" placeholder="192.168.1.100"/>
</div>
<div>
<label>
Netmask
</label
><input id="netmask" placeholder="255.255.255.0"/>
</div>
</div>
<div class="row">
<div>
<label>
Gateway
</label
><input id="gateway" placeholder="192.168.1.1"/>
</div>
<div>
<label>DNS 1</label><input id="dns1" placeholder="8.8.8.8"/>
</div>
</div>
<div>
<label>DNS 2</label><input id="dns2" placeholder="8.8.4.4"/>
</div>
</div>
<button onclick="generate()">Generate QR Code</button>
</div>
<button onclick="generate()">Generate QR Code</button>
</div>
<div id="qr-out">
<div id="qrdiv"></div>
<button id="dl" onclick="download()">⬇ Download PNG</button>
<p class="note">Scan with Jibo's app to configure Wi-Fi</p>
</div>
<div id="qr-out">
<div id="qrdiv"></div>
<button id="dl" onclick="download()">⬇ Download PNG</button>
<p class="note">Scan with Jibo's app to configure Wi-Fi</p>
</div>
<script>
<script>
function toggleStatic() {
document.getElementById("staticSection").style.display =
document.getElementById("useStatic").checked ? "block" : "none";
@@ -302,5 +310,5 @@ e!Ekiaon*%O? 'O`);
return wifiConfig;
}
</script>
</body>
</html>
</body>
</html>

View File

@@ -1,5 +1,6 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Domain.Models;
@@ -38,7 +39,7 @@ app.Use(async (context, next) =>
var webSocketService = context.RequestServices.GetRequiredService<JiboWebSocketService>();
var telemetrySink = context.RequestServices.GetRequiredService<IWebSocketTelemetrySink>();
using var socket = await context.WebSockets.AcceptWebSocketAsync();
var openEnvelope = new WebSocketMessageEnvelope
@@ -74,7 +75,7 @@ app.Use(async (context, next) =>
break;
}
}
var envelope = new WebSocketMessageEnvelope
{
ConnectionId = Guid.NewGuid().ToString("N"),
@@ -88,18 +89,13 @@ app.Use(async (context, next) =>
var replies = await webSocketService.HandleMessageAsync(envelope, context.RequestAborted);
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)
{
if (string.IsNullOrWhiteSpace(reply.Text))
{
continue;
}
if (string.IsNullOrWhiteSpace(reply.Text)) continue;
if (reply.DelayMs > 0)
{
await Task.Delay(reply.DelayMs, context.RequestAborted);
}
if (reply.DelayMs > 0) await Task.Delay(reply.DelayMs, context.RequestAborted);
var payload = Encoding.UTF8.GetBytes(reply.Text);
await socket.SendAsync(payload, WebSocketMessageType.Text, true, context.RequestAborted);
@@ -117,7 +113,8 @@ app.Use(async (context, next) =>
Token = token
};
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
@@ -127,7 +124,8 @@ app.MapGet("/health", () => Results.Json(new
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 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.ContentType = result.ContentType;
foreach (var header in result.Headers)
{
context.Response.Headers[header.Key] = header.Value;
}
foreach (var header in result.Headers) context.Response.Headers[header.Key] = header.Value;
if (!string.IsNullOrEmpty(result.BodyText))
{
await context.Response.WriteAsync(result.BodyText, cancellationToken);
}
if (!string.IsNullOrEmpty(result.BodyText)) await context.Response.WriteAsync(result.BodyText, cancellationToken);
});
app.Run();
@@ -160,8 +152,7 @@ static async Task<ReceivedSocketMessage> ReceiveAsync(WebSocket socket, Cancella
{
result = await socket.ReceiveAsync(buffer, cancellationToken);
ms.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);
} while (!result.EndOfMessage);
return new ReceivedSocketMessage(result.MessageType, ms.ToArray());
}
@@ -170,7 +161,7 @@ static async Task<ProtocolEnvelope> BuildEnvelopeAsync(HttpContext context, Canc
{
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);
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(),
ApplicationVersion = context.Request.Headers["X-OpenJibo-AppVersion"].ToString(),
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)
{
if (host.Equals("api-socket.jibo.com", StringComparison.OrdinalIgnoreCase))
{
return "api-socket";
}
if (host.Equals("api-socket.jibo.com", StringComparison.OrdinalIgnoreCase)) return "api-socket";
if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase) &&
path.StartsWithSegments("/v1/proactive"))
{
return "neo-hub-proactive";
}
if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase))
{
return "neo-hub-listen";
}
if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase)) return "neo-hub-listen";
if (host.Equals("openjibo.com", StringComparison.OrdinalIgnoreCase) ||
host.Equals("openjibo.ai", StringComparison.OrdinalIgnoreCase) ||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
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)
{
var auth = request.Headers.Authorization.ToString();
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return auth["Bearer ".Length..].Trim();
}
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return auth["Bearer ".Length..].Trim();
var path = request.Path.Value;
if (!string.IsNullOrWhiteSpace(path) && path.Length > 1)
{
return path.Trim('/');
}
if (!string.IsNullOrWhiteSpace(path) && path.Length > 1) return path.Trim('/');
return null;
}
static string ReadMessageType(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return "BINARY_OR_EMPTY";
}
if (string.IsNullOrWhiteSpace(text)) return "BINARY_OR_EMPTY";
try
{
using var document = System.Text.Json.JsonDocument.Parse(text);
return document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == System.Text.Json.JsonValueKind.String
using var document = JsonDocument.Parse(text);
return document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String
? type.GetString() ?? "UNKNOWN"
: "UNKNOWN";
}
@@ -265,4 +239,4 @@ static CloudSession ResolveSession(JiboWebSocketService webSocketService, WebSoc
return webSocketService.GetOrCreateSession(envelope);
}
internal sealed record ReceivedSocketMessage(WebSocketMessageType MessageType, byte[] Buffer);
internal sealed record ReceivedSocketMessage(WebSocketMessageType MessageType, byte[] Buffer);

View File

@@ -19,12 +19,21 @@ public interface ICloudStateStore
IReadOnlyList<PersonRecord> GetPeople();
IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null);
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);
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> 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();
bool ShouldCreateSymmetricKey(string loopId);
string GetOrCreateSymmetricKey(string loopId);
@@ -34,4 +43,4 @@ public interface ICloudStateStore
IReadOnlyList<KeyRequestRecord> GetBinaryRequests();
IReadOnlyList<object> GetHolidays();
void UpdateRobot(DeviceRegistration registration);
}
}

View File

@@ -46,4 +46,4 @@ public sealed class JiboExperienceCatalog
public IReadOnlyList<string> GenericFallbackReplies { get; init; } = [];
public IReadOnlyList<string> DanceReplies { get; init; } = [];
public IReadOnlyList<string> DanceQuestionReplies { get; init; } = [];
}
}

View File

@@ -25,4 +25,4 @@ public sealed record NewsBriefingSnapshot(
string? ProviderMessage = null,
int? ProviderHttpStatusCode = null,
string? ProviderEndpoint = null,
string? ProviderErrorCode = null);
string? ProviderErrorCode = null);

View File

@@ -21,7 +21,11 @@ public interface IPersonalMemoryStore
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(
string SchemaVersion,
@@ -34,4 +38,4 @@ public enum PersonalAffinity
Like,
Love,
Dislike
}
}

View File

@@ -4,5 +4,6 @@ namespace Jibo.Cloud.Application.Abstractions;
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
{
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);
}
}

View File

@@ -22,4 +22,4 @@ public sealed record WeatherReportSnapshot(
int? HighTemperature,
int? LowTemperature,
string? Condition,
bool UseCelsius);
bool UseCelsius);

View File

@@ -4,9 +4,18 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface IWebSocketTelemetrySink
{
Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default);
Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, 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);
Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session,
CancellationToken cancellationToken = default);
Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType,
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 Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Application.Services;
@@ -136,7 +136,11 @@ internal static class ChitchatStateMachine
("jealous", ["jealous", "envious", "covetous"]),
("lonely", ["lonely", "alone", "lonesome"]),
("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 =
@@ -216,7 +220,8 @@ internal static class ChitchatStateMachine
case "robot_identity":
return BuildScriptedResponseDecision(
"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":
return BuildScriptedResponseDecision(
"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"));
case "chat":
if (IsEmotionQuery(normalizedLoweredTranscript))
{
return BuildEmotionQueryDecision(
"emotion_query",
SelectEmotionQueryReply(catalog, randomizer, currentEmotion));
}
if (TryResolveEmotionCommand(normalizedLoweredTranscript, out var emotion))
{
return BuildEmotionCommandDecision(randomizer, emotion!);
}
return BuildErrorResponseDecision(
"chat",
@@ -293,7 +294,7 @@ internal static class ChitchatStateMachine
replyText,
ContextUpdates: BuildContextUpdates(
ScriptedResponseRoute,
emotion: null));
null));
}
private static JiboInteractionDecision BuildEmotionQueryDecision(string intentName, string replyText)
@@ -303,7 +304,7 @@ internal static class ChitchatStateMachine
replyText,
ContextUpdates: BuildContextUpdates(
EmotionQueryRoute,
emotion: null));
null));
}
private static JiboInteractionDecision BuildEmotionCommandDecision(IJiboRandomizer randomizer, string emotion)
@@ -323,18 +324,20 @@ internal static class ChitchatStateMachine
"chitchat-skill",
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_type"] = "announcement",
["prompt_id"] = "RUNTIME_EMOTION_COMMAND",
["prompt_sub_category"] = "AN"
},
ContextUpdates: BuildContextUpdates(
BuildContextUpdates(
EmotionCommandRoute,
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)
? string.Empty
@@ -344,8 +347,8 @@ internal static class ChitchatStateMachine
replyText,
ContextUpdates: BuildContextUpdates(
ErrorResponseRoute,
emotion: null,
rawTranscript: normalizedTranscript));
null,
normalizedTranscript));
}
private static IDictionary<string, object?> BuildContextUpdates(
@@ -360,7 +363,7 @@ internal static class ChitchatStateMachine
[EmotionMetadataKey] = emotion ?? string.Empty,
["chitchatLastState"] = IntentSplitState,
["chitchatProcessState"] = ProcessQueryState,
["chitchatRawTranscript"] = rawTranscript ?? string.Empty
["chitchatRawTranscript"] = rawTranscript ?? string.Empty
};
}
@@ -369,19 +372,12 @@ internal static class ChitchatStateMachine
IJiboRandomizer randomizer,
string? currentEmotion)
{
if (catalog.EmotionReplies.Count == 0)
{
return randomizer.Choose(catalog.HowAreYouReplies);
}
if (catalog.EmotionReplies.Count == 0) return randomizer.Choose(catalog.HowAreYouReplies);
var emotionVariants = ResolveEmotionVariants(currentEmotion);
foreach (var reply in catalog.EmotionReplies)
{
if (ConditionMatches(reply.Condition, emotionVariants))
{
return reply.Reply;
}
}
return randomizer.Choose(catalog.HowAreYouReplies);
}
@@ -389,19 +385,13 @@ internal static class ChitchatStateMachine
private static bool ConditionMatches(string? condition, IReadOnlyList<string> emotionVariants)
{
var normalizedCondition = NormalizeCondition(condition);
if (string.IsNullOrWhiteSpace(normalizedCondition))
{
return false;
}
if (string.IsNullOrWhiteSpace(normalizedCondition)) 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)
{
if (MatchesConditionClause(clause, emotionVariants))
{
return true;
}
}
return false;
}
@@ -410,16 +400,11 @@ internal static class ChitchatStateMachine
{
var normalizedClause = NormalizeCondition(clause).ToUpperInvariant();
if (normalizedClause == "!JIBO.EMOTION")
{
return emotionVariants.Contains(string.Empty, StringComparer.OrdinalIgnoreCase) ||
emotionVariants.Contains("NEUTRAL", StringComparer.OrdinalIgnoreCase);
}
var equalityIndex = normalizedClause.IndexOf("==", StringComparison.Ordinal);
if (equalityIndex < 0)
{
return false;
}
if (equalityIndex < 0) return false;
var rightSide = normalizedClause[(equalityIndex + 2)..].Trim();
var candidate = rightSide.Trim('"', '\'');
@@ -428,10 +413,7 @@ internal static class ChitchatStateMachine
private static IReadOnlyList<string> ResolveEmotionVariants(string? currentEmotion)
{
if (string.IsNullOrWhiteSpace(currentEmotion))
{
return ["", "NEUTRAL"];
}
if (string.IsNullOrWhiteSpace(currentEmotion)) return ["", "NEUTRAL"];
var normalizedEmotion = NormalizeCondition(currentEmotion).Trim('"', '\'').ToUpperInvariant();
return normalizedEmotion switch
@@ -452,17 +434,11 @@ internal static class ChitchatStateMachine
{
foreach (var snippet in preferredSnippets)
{
if (string.IsNullOrWhiteSpace(snippet))
{
continue;
}
if (string.IsNullOrWhiteSpace(snippet)) continue;
var match = catalog.PersonalityReplies.FirstOrDefault(reply =>
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match))
{
return match;
}
if (!string.IsNullOrWhiteSpace(match)) return match;
}
return randomizer.Choose(catalog.PersonalityReplies);
@@ -470,25 +446,16 @@ internal static class ChitchatStateMachine
private static string NormalizeCondition(string? condition)
{
if (string.IsNullOrWhiteSpace(condition))
{
return string.Empty;
}
return PhraseWhitespacePattern.Replace(condition.Trim(), " ");
return string.IsNullOrWhiteSpace(condition)
? string.Empty
: PhraseWhitespacePattern.Replace(condition.Trim(), " ");
}
private static bool IsEmotionQuery(string loweredTranscript)
{
if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases))
{
return true;
}
if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases)) return true;
if (!TryResolveEmotionFromText(loweredTranscript, out _))
{
return false;
}
if (!TryResolveEmotionFromText(loweredTranscript, out _)) return false;
return StartsWithAnyPhrase(loweredTranscript, EmotionQueryPrefixes) ||
StartsWithAnyPhrase(loweredTranscript, EmotionAssertionPrefixes);
@@ -500,27 +467,20 @@ internal static class ChitchatStateMachine
foreach (var mapping in DirectEmotionCommandPhrases)
{
if (!ContainsPhrase(loweredTranscript, mapping.Phrase))
{
continue;
}
if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue;
emotion = mapping.Emotion;
return true;
}
var isNegativeCommand = StartsWithAnyPhrase(loweredTranscript, EmotionCommandNegativePrefixes);
var isPositiveCommand = !isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes);
if (!isNegativeCommand && !isPositiveCommand)
{
return false;
}
var isPositiveCommand =
!isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes);
if (!isNegativeCommand && !isPositiveCommand) return false;
if (!TryResolveEmotionFromText(loweredTranscript, out var canonicalEmotion) ||
string.IsNullOrWhiteSpace(canonicalEmotion))
{
return false;
}
emotion = isNegativeCommand
? "calm"
@@ -544,10 +504,7 @@ internal static class ChitchatStateMachine
emotion = null;
foreach (var mapping in EmotionSynonymMappings)
{
if (!ContainsPhrase(loweredTranscript, mapping.Phrase))
{
continue;
}
if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue;
emotion = mapping.Emotion;
return true;
@@ -559,12 +516,8 @@ internal static class ChitchatStateMachine
private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable<string> phrases)
{
foreach (var phrase in phrases)
{
if (ContainsPhrase(loweredTranscript, phrase))
{
return true;
}
}
return false;
}
@@ -574,16 +527,11 @@ internal static class ChitchatStateMachine
foreach (var phrase in phrases)
{
var normalizedPhrase = NormalizeForPhraseMatching(phrase);
if (string.IsNullOrWhiteSpace(normalizedPhrase))
{
continue;
}
if (string.IsNullOrWhiteSpace(normalizedPhrase)) continue;
if (string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) ||
loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal))
{
return true;
}
}
return false;
@@ -594,9 +542,7 @@ internal static class ChitchatStateMachine
var normalizedPhrase = NormalizeForPhraseMatching(phrase);
if (string.IsNullOrWhiteSpace(normalizedPhrase) ||
string.IsNullOrWhiteSpace(loweredTranscript))
{
return false;
}
return string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) ||
loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal) ||
@@ -606,10 +552,7 @@ internal static class ChitchatStateMachine
private static string NormalizeForPhraseMatching(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var lowered = value.ToLowerInvariant();
var withoutPunctuation = PhrasePunctuationPattern.Replace(lowered, " ");
@@ -622,21 +565,17 @@ internal static class ChitchatStateMachine
var mappings = new List<(string Phrase, string Emotion)>();
foreach (var emotionMapping in PegasusEmotionSynonyms)
foreach (var synonym in emotionMapping.Synonyms)
{
foreach (var synonym in emotionMapping.Synonyms)
{
var normalizedSynonym = NormalizeForPhraseMatching(synonym);
if (string.IsNullOrWhiteSpace(normalizedSynonym) ||
!seen.Add(normalizedSynonym))
{
continue;
}
var normalizedSynonym = NormalizeForPhraseMatching(synonym);
if (string.IsNullOrWhiteSpace(normalizedSynonym) ||
!seen.Add(normalizedSynonym))
continue;
mappings.Add((normalizedSynonym, emotionMapping.Emotion));
}
mappings.Add((normalizedSynonym, emotionMapping.Emotion));
}
mappings.Sort(static (left, right) => right.Phrase.Length.CompareTo(left.Phrase.Length));
return [.. mappings];
}
}
}

View File

@@ -13,4 +13,4 @@ public sealed class DefaultSttStrategySelector(IEnumerable<ISttStrategy> strateg
? throw new InvalidOperationException("No STT strategy can handle the current turn.")
: Task.FromResult(strategy);
}
}
}

View File

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

View File

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

@@ -13,4 +13,4 @@ public sealed class DefaultJiboRandomizer : IJiboRandomizer
? throw new InvalidOperationException("Cannot choose from an empty list.")
: items[Random.Shared.Next(items.Count)];
}
}
}

View File

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

View File

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

View File

@@ -15,7 +15,8 @@ public sealed class JiboWebSocketService(
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);
session.LastSeenUtc = DateTimeOffset.UtcNow;
@@ -23,11 +24,12 @@ public sealed class JiboWebSocketService(
if (envelope.IsBinary)
{
var replies = await turnFinalizationService.HandleBinaryAudioAsync(session, envelope, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received", new Dictionary<string, object?>
{
["bytes"] = envelope.Binary?.Length ?? 0,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
}, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received",
new Dictionary<string, object?>
{
["bytes"] = envelope.Binary?.Length ?? 0,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
}, cancellationToken);
return replies;
}
@@ -50,13 +52,14 @@ public sealed class JiboWebSocketService(
})
.ToArray();
await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored", new Dictionary<string, object?>
{
["messageType"] = parsedType,
["activeTransID"] = session.TurnState.TransId,
["ignoredTransID"] = lateTransId,
["replyCount"] = replies.Length
}, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored",
new Dictionary<string, object?>
{
["messageType"] = parsedType,
["activeTransID"] = session.TurnState.TransId,
["ignoredTransID"] = lateTransId,
["replyCount"] = replies.Length
}, cancellationToken);
return replies;
}
@@ -65,12 +68,13 @@ public sealed class JiboWebSocketService(
WebSocketTurnFinalizationService.TryRecoverStalePendingListen(session, out staleListenAgeMs))
{
staleListenRecovered = true;
await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered", new Dictionary<string, object?>
{
["staleAgeMs"] = staleListenAgeMs,
["transID"] = session.TurnState.TransId,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
}, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered",
new Dictionary<string, object?>
{
["staleAgeMs"] = staleListenAgeMs,
["transID"] = session.TurnState.TransId,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
}, cancellationToken);
}
WebSocketTurnFinalizationService.ObserveIncomingMessage(session, envelope.Text);
@@ -80,11 +84,12 @@ public sealed class JiboWebSocketService(
case "CONTEXT":
{
var replies = await turnFinalizationService.HandleContextAsync(session, envelope, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received", new Dictionary<string, object?>
{
["transID"] = session.TurnState.TransId,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
}, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received",
new Dictionary<string, object?>
{
["transID"] = session.TurnState.TransId,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
}, cancellationToken);
return replies;
}
case "LISTEN":
@@ -92,29 +97,32 @@ public sealed class JiboWebSocketService(
var replies = containsInlineTurnPayload
? await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken)
: WebSocketTurnFinalizationService.HandleListenSetup(session, envelope);
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
{
["messageType"] = parsedType,
["replyCount"] = replies.Count,
["transcript"] = session.LastTranscript,
["intent"] = session.LastIntent,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session),
["staleListenRecovered"] = staleListenRecovered,
["staleListenAgeMs"] = staleListenAgeMs
}, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed",
new Dictionary<string, object?>
{
["messageType"] = parsedType,
["replyCount"] = replies.Count,
["transcript"] = session.LastTranscript,
["intent"] = session.LastIntent,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session),
["staleListenRecovered"] = staleListenRecovered,
["staleListenAgeMs"] = staleListenAgeMs
}, cancellationToken);
return replies;
}
case "CLIENT_NLU" or "CLIENT_ASR" or "TRIGGER":
{
var replies = await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
{
["messageType"] = parsedType,
["replyCount"] = replies.Count,
["transcript"] = session.LastTranscript,
["intent"] = session.LastIntent,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
}, cancellationToken);
var replies =
await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed",
new Dictionary<string, object?>
{
["messageType"] = parsedType,
["replyCount"] = replies.Count,
["transcript"] = session.LastTranscript,
["intent"] = session.LastIntent,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
}, cancellationToken);
return replies;
}
default:
@@ -124,18 +132,13 @@ public sealed class JiboWebSocketService(
private static string ReadMessageType(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return "UNKNOWN";
}
if (string.IsNullOrWhiteSpace(text)) return "UNKNOWN";
try
{
using var document = JsonDocument.Parse(text);
if (document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String)
{
return type.GetString() ?? "UNKNOWN";
}
}
catch
{
@@ -147,25 +150,18 @@ public sealed class JiboWebSocketService(
private static bool ContainsInlineTurnPayload(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
if (string.IsNullOrWhiteSpace(text)) return false;
try
{
using var document = JsonDocument.Parse(text);
if (!document.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!document.RootElement.TryGetProperty("data", out var data) ||
data.ValueKind != JsonValueKind.Object) return false;
if (data.TryGetProperty("text", out var transcript) &&
transcript.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(transcript.GetString()))
{
return true;
}
return data.TryGetProperty("asr", out var asr) &&
asr.ValueKind == JsonValueKind.Object &&
@@ -186,10 +182,7 @@ public sealed class JiboWebSocketService(
var transId = session.TurnState.TransId ?? session.LastTransId ?? string.Empty;
var rules = session.TurnState.ListenRules;
if (string.IsNullOrWhiteSpace(text))
{
return (transId, rules);
}
if (string.IsNullOrWhiteSpace(text)) return (transId, rules);
try
{
@@ -199,9 +192,7 @@ public sealed class JiboWebSocketService(
if (root.TryGetProperty("transID", out var transIdValue) &&
transIdValue.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(transIdValue.GetString()))
{
transId = transIdValue.GetString()!;
}
if (root.TryGetProperty("data", out var data) &&
data.ValueKind == JsonValueKind.Object &&
@@ -214,10 +205,7 @@ public sealed class JiboWebSocketService(
.Where(static rule => !string.IsNullOrWhiteSpace(rule))
.ToArray();
if (parsedRules.Length > 0)
{
rules = parsedRules;
}
if (parsedRules.Length > 0) rules = parsedRules;
}
}
catch
@@ -227,4 +215,4 @@ public sealed class JiboWebSocketService(
return (transId, rules);
}
}
}

View File

@@ -5,5 +5,9 @@ namespace Jibo.Cloud.Application.Services;
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 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)
{
return Task.CompletedTask;
}
public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) => 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 Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default) => Task.CompletedTask;
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;
public Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session,
CancellationToken cancellationToken = default)
{
return 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 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.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services;
@@ -58,6 +58,8 @@ internal static class PersonalReportOrchestrator
"maybe later"
];
private static readonly Regex NameNoiseRegex = new("[^a-zA-Z\\-\\s']", RegexOptions.Compiled);
public static async Task<JiboInteractionDecision?> TryBuildDecisionAsync(
TurnContext turn,
string semanticIntent,
@@ -72,31 +74,26 @@ internal static class PersonalReportOrchestrator
{
var state = ReadState(turn);
var isActiveState = !string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase);
if (!isActiveState && !string.Equals(semanticIntent, "personal_report", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (!isActiveState &&
!string.Equals(semanticIntent, "personal_report", StringComparison.OrdinalIgnoreCase)) return null;
var toggles = ApplyInlineToggleHints(
ReadServiceToggles(turn),
loweredTranscript,
out var inlineToggleSummary);
if (ContainsAnyPhrase(loweredTranscript, CancelPhrases))
{
return BuildCancelledDecision(toggles);
}
if (ContainsAnyPhrase(loweredTranscript, CancelPhrases)) return BuildCancelledDecision(toggles);
if (!isActiveState)
{
var contextUpdates = BuildContextUpdates(
AwaitingOptInState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: ReadString(turn, UserNameMetadataKey),
userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false,
lastServiceError: string.Empty);
ReadString(turn, UserNameMetadataKey),
ReadBool(turn, UserVerifiedMetadataKey) ?? false,
string.Empty);
var reply = string.IsNullOrWhiteSpace(inlineToggleSummary)
? "Would you like your personal report now?"
@@ -108,10 +105,7 @@ internal static class PersonalReportOrchestrator
ContextUpdates: contextUpdates);
}
if (string.IsNullOrWhiteSpace(loweredTranscript))
{
return BuildNoInputDecision(turn, state, toggles);
}
if (string.IsNullOrWhiteSpace(loweredTranscript)) return BuildNoInputDecision(turn, state, toggles);
switch (state)
{
@@ -121,81 +115,71 @@ internal static class PersonalReportOrchestrator
var scope = tenantScopeResolver(turn);
var knownName = ReadString(turn, UserNameMetadataKey) ?? personalMemoryStore.GetName(scope);
if (!string.IsNullOrWhiteSpace(knownName))
{
return new JiboInteractionDecision(
"personal_report_verify_user",
$"I think this is {knownName}. Is that right?",
ContextUpdates: BuildContextUpdates(
AwaitingIdentityConfirmationState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: knownName,
userVerified: false,
lastServiceError: string.Empty));
}
knownName,
false,
string.Empty));
return new JiboInteractionDecision(
"personal_report_request_name",
"Who is this?",
ContextUpdates: BuildContextUpdates(
AwaitingIdentityNameState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
null,
false,
string.Empty));
}
if (IsNegativeReply(loweredTranscript))
{
return BuildDeclinedDecision(toggles);
}
if (IsNegativeReply(loweredTranscript)) return BuildDeclinedDecision(toggles);
if (!string.IsNullOrWhiteSpace(inlineToggleSummary))
{
return new JiboInteractionDecision(
"personal_report_opt_in",
$"{inlineToggleSummary} Would you like your personal report now?",
ContextUpdates: BuildContextUpdates(
AwaitingOptInState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: ReadString(turn, UserNameMetadataKey),
userVerified: false,
lastServiceError: string.Empty));
}
ReadString(turn, UserNameMetadataKey),
false,
string.Empty));
return BuildNoMatchDecision(
turn,
state,
"Please say yes to start your personal report, or no to skip it.",
toggles,
userName: ReadString(turn, UserNameMetadataKey),
userVerified: false);
ReadString(turn, UserNameMetadataKey),
false);
case AwaitingIdentityConfirmationState:
{
var currentName = ReadString(turn, UserNameMetadataKey);
if (string.IsNullOrWhiteSpace(currentName))
{
return new JiboInteractionDecision(
"personal_report_request_name",
"Who is this?",
ContextUpdates: BuildContextUpdates(
AwaitingIdentityNameState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
}
null,
false,
string.Empty));
if (IsAffirmativeReply(loweredTranscript))
{
return await BuildDeliveredReportDecisionAsync(
turn,
catalog,
@@ -204,45 +188,40 @@ internal static class PersonalReportOrchestrator
currentName,
buildWeatherDecisionAsync,
cancellationToken);
}
if (IsNegativeReply(loweredTranscript))
{
return new JiboInteractionDecision(
"personal_report_request_name",
"Okay, who is this?",
ContextUpdates: BuildContextUpdates(
AwaitingIdentityNameState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
}
null,
false,
string.Empty));
return BuildNoMatchDecision(
turn,
state,
$"Please answer yes or no. Is this {currentName}?",
toggles,
userName: currentName,
userVerified: false);
currentName,
false);
}
case AwaitingIdentityNameState:
{
var parsedName = TryExtractName(loweredTranscript);
if (string.IsNullOrWhiteSpace(parsedName))
{
return BuildNoMatchDecision(
turn,
state,
"Tell me your name like this: my name is Alex.",
toggles,
userName: null,
userVerified: false);
}
null,
false);
personalMemoryStore.SetName(tenantScopeResolver(turn), parsedName);
return await BuildDeliveredReportDecisionAsync(
@@ -284,10 +263,7 @@ internal static class PersonalReportOrchestrator
reportSections.Add("First, your weather.");
var weatherDecision = await buildWeatherDecisionAsync(turn, "weather", cancellationToken);
reportSections.Add(weatherDecision.ReplyText);
if (IsWeatherErrorReply(weatherDecision.ReplyText))
{
serviceError = "weather";
}
if (IsWeatherErrorReply(weatherDecision.ReplyText)) serviceError = "weather";
}
if (toggles.CalendarEnabled)
@@ -309,7 +285,6 @@ internal static class PersonalReportOrchestrator
}
if (toggles.CommuteEnabled)
{
reportSections.Add(
RenderReportSkillTemplate(
ChooseReportSkillTemplate(
@@ -317,7 +292,6 @@ internal static class PersonalReportOrchestrator
catalog.CommuteNowReplies,
"Sorry, commute information isn't available right now."),
userName));
}
if (toggles.NewsEnabled)
{
@@ -350,12 +324,12 @@ internal static class PersonalReportOrchestrator
string.Join(" ", reportSections),
ContextUpdates: BuildContextUpdates(
IdleState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName,
userVerified: true,
lastServiceError: serviceError));
true,
serviceError));
}
private static JiboInteractionDecision BuildNoInputDecision(
@@ -364,22 +338,19 @@ internal static class PersonalReportOrchestrator
PersonalReportServiceToggles toggles)
{
var noInputCount = Math.Max(0, ReadInt(turn, NoInputCountMetadataKey)) + 1;
if (noInputCount >= MaxNoInputCount)
{
return BuildDeclinedDecision(toggles);
}
if (noInputCount >= MaxNoInputCount) return BuildDeclinedDecision(toggles);
return new JiboInteractionDecision(
"personal_report_no_input",
"I am still here. Do you want your personal report?",
ContextUpdates: BuildContextUpdates(
state,
noMatchCount: ReadInt(turn, NoMatchCountMetadataKey),
ReadInt(turn, NoMatchCountMetadataKey),
noInputCount,
toggles,
userName: ReadString(turn, UserNameMetadataKey),
userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false,
lastServiceError: string.Empty));
ReadString(turn, UserNameMetadataKey),
ReadBool(turn, UserVerifiedMetadataKey) ?? false,
string.Empty));
}
private static JiboInteractionDecision BuildNoMatchDecision(
@@ -391,10 +362,7 @@ internal static class PersonalReportOrchestrator
bool userVerified)
{
var noMatchCount = Math.Max(0, ReadInt(turn, NoMatchCountMetadataKey)) + 1;
if (noMatchCount >= MaxNoMatchCount)
{
return BuildDeclinedDecision(toggles);
}
if (noMatchCount >= MaxNoMatchCount) return BuildDeclinedDecision(toggles);
return new JiboInteractionDecision(
"personal_report_no_match",
@@ -402,11 +370,11 @@ internal static class PersonalReportOrchestrator
ContextUpdates: BuildContextUpdates(
state,
noMatchCount,
noInputCount: 0,
0,
toggles,
userName,
userVerified,
lastServiceError: string.Empty));
string.Empty));
}
private static JiboInteractionDecision BuildDeclinedDecision(PersonalReportServiceToggles toggles)
@@ -416,12 +384,12 @@ internal static class PersonalReportOrchestrator
"No problem. We can do your personal report another time.",
ContextUpdates: BuildContextUpdates(
IdleState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
null,
false,
string.Empty));
}
private static JiboInteractionDecision BuildCancelledDecision(PersonalReportServiceToggles toggles)
@@ -431,12 +399,12 @@ internal static class PersonalReportOrchestrator
"Okay, canceling personal report.",
ContextUpdates: BuildContextUpdates(
IdleState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
null,
false,
string.Empty));
}
private static IDictionary<string, object?> BuildContextUpdates(
@@ -476,24 +444,17 @@ internal static class PersonalReportOrchestrator
private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable<string> phrases)
{
foreach (var phrase in phrases)
{
if (string.Equals(loweredTranscript, phrase, StringComparison.Ordinal) ||
loweredTranscript.StartsWith($"{phrase} ", StringComparison.Ordinal) ||
loweredTranscript.Contains($" {phrase}", StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static bool IsWeatherErrorReply(string replyText)
{
if (string.IsNullOrWhiteSpace(replyText))
{
return false;
}
if (string.IsNullOrWhiteSpace(replyText)) return false;
return replyText.Contains("couldn't fetch the weather", StringComparison.OrdinalIgnoreCase) ||
replyText.Contains("weather service is connected", StringComparison.OrdinalIgnoreCase);
@@ -516,36 +477,32 @@ internal static class PersonalReportOrchestrator
summary = string.Empty;
var updated = toggles;
updated = ApplyToggleHint(updated, loweredTranscript, "weather", static value => value with { WeatherEnabled = false }, static value => value with { WeatherEnabled = 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 });
updated = ApplyToggleHint(updated, loweredTranscript, "weather",
static value => value with { WeatherEnabled = false },
static value => value with { WeatherEnabled = 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>();
if (updated.WeatherEnabled != toggles.WeatherEnabled)
{
changes.Add(updated.WeatherEnabled ? "including weather" : "skipping weather");
}
if (updated.CalendarEnabled != toggles.CalendarEnabled)
{
changes.Add(updated.CalendarEnabled ? "including calendar" : "skipping calendar");
}
if (updated.CommuteEnabled != toggles.CommuteEnabled)
{
changes.Add(updated.CommuteEnabled ? "including commute" : "skipping commute");
}
if (updated.NewsEnabled != toggles.NewsEnabled)
{
changes.Add(updated.NewsEnabled ? "including news" : "skipping news");
}
if (changes.Count > 0)
{
summary = $"Got it, {string.Join(", ", changes)}.";
}
if (changes.Count > 0) summary = $"Got it, {string.Join(", ", changes)}.";
return updated;
}
@@ -560,15 +517,11 @@ internal static class PersonalReportOrchestrator
if (loweredTranscript.Contains($"without {serviceLabel}", StringComparison.Ordinal) ||
loweredTranscript.Contains($"skip {serviceLabel}", StringComparison.Ordinal) ||
loweredTranscript.Contains($"no {serviceLabel}", StringComparison.Ordinal))
{
return disable(toggles);
}
if (loweredTranscript.Contains($"with {serviceLabel}", StringComparison.Ordinal) ||
loweredTranscript.Contains($"include {serviceLabel}", StringComparison.Ordinal))
{
return enable(toggles);
}
return toggles;
}
@@ -580,10 +533,7 @@ internal static class PersonalReportOrchestrator
private static string? ReadString(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
{
return null;
}
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null;
return value switch
{
@@ -594,10 +544,7 @@ internal static class PersonalReportOrchestrator
private static bool? ReadBool(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
{
return null;
}
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null;
return value switch
{
@@ -605,17 +552,15 @@ internal static class PersonalReportOrchestrator
string text when bool.TryParse(text, out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.True } => true,
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
};
}
private static int ReadInt(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
{
return 0;
}
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return 0;
return value switch
{
@@ -623,7 +568,8 @@ internal static class PersonalReportOrchestrator
long whole when whole <= int.MaxValue && whole >= int.MinValue => (int)whole,
string text when int.TryParse(text, 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
};
}
@@ -633,10 +579,7 @@ internal static class PersonalReportOrchestrator
var normalized = NameNoiseRegex.Replace(loweredTranscript, " ")
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
if (string.IsNullOrWhiteSpace(normalized)) return null;
var prefixes = new[]
{
@@ -650,10 +593,7 @@ internal static class PersonalReportOrchestrator
foreach (var prefix in prefixes)
{
if (!normalized.StartsWith(prefix, StringComparison.Ordinal))
{
continue;
}
if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) continue;
var candidate = normalized[prefix.Length..].Trim();
return NormalizeNameCandidate(candidate);
@@ -664,58 +604,31 @@ internal static class PersonalReportOrchestrator
private static string? NormalizeNameCandidate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return null;
}
if (string.IsNullOrWhiteSpace(candidate)) return null;
var cleaned = NameNoiseRegex.Replace(candidate, " ")
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
if (string.IsNullOrWhiteSpace(cleaned))
{
return null;
}
if (string.IsNullOrWhiteSpace(cleaned)) return null;
if (cleaned.Length < 2 || cleaned.Length > 32)
{
return null;
}
if (cleaned.Length < 2 || cleaned.Length > 32) return null;
var words = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length > 4)
{
return null;
}
if (words.Length > 4) return null;
if (words.Any(static word => word.Any(char.IsDigit)))
{
return null;
}
return cleaned;
return words.Any(static word => word.Any(char.IsDigit)) ? null : 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(
IReadOnlyList<string> templates,
string fallback)
{
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();
if (usableTemplates.Length == 0)
{
return fallback;
}
if (usableTemplates.Length == 0) return fallback;
var speakerAwareTemplate = usableTemplates.FirstOrDefault(static template =>
template.Contains("${speaker}", StringComparison.OrdinalIgnoreCase));
@@ -737,18 +650,10 @@ internal static class PersonalReportOrchestrator
string fallback)
{
var primary = primaryTemplates.FirstOrDefault(static template => !string.IsNullOrWhiteSpace(template));
if (!string.IsNullOrWhiteSpace(primary))
{
return primary!;
}
if (!string.IsNullOrWhiteSpace(primary)) return primary!;
var secondary = secondaryTemplates.FirstOrDefault(static template => !string.IsNullOrWhiteSpace(template));
if (!string.IsNullOrWhiteSpace(secondary))
{
return secondary!;
}
return fallback;
return !string.IsNullOrWhiteSpace(secondary) ? secondary! : fallback;
}
private static string RenderReportSkillTemplate(string template, string userName)
@@ -759,4 +664,10 @@ internal static class PersonalReportOrchestrator
.Replace(" ", " ", StringComparison.Ordinal)
.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 static TurnContext MapListenMessage(WebSocketMessageEnvelope envelope, CloudSession session, string messageType)
public static TurnContext MapListenMessage(WebSocketMessageEnvelope envelope, CloudSession session,
string messageType)
{
var turnState = session.TurnState;
var protocolOperation = messageType.ToLowerInvariant();
@@ -16,46 +17,28 @@ public sealed class ProtocolToTurnContextMapper
};
var text = ExtractTranscript(envelope.Text, attributes);
if (!string.IsNullOrWhiteSpace(turnState.TransId))
{
attributes["transID"] = turnState.TransId;
}
if (!string.IsNullOrWhiteSpace(turnState.TransId)) attributes["transID"] = turnState.TransId;
if (!string.IsNullOrWhiteSpace(session.AccountId))
{
attributes["accountId"] = session.AccountId;
}
if (!string.IsNullOrWhiteSpace(session.AccountId)) attributes["accountId"] = session.AccountId;
if (!string.IsNullOrWhiteSpace(session.DeviceId))
{
attributes["deviceId"] = session.DeviceId;
}
if (!string.IsNullOrWhiteSpace(session.DeviceId)) attributes["deviceId"] = session.DeviceId;
if (session.Metadata.TryGetValue("loopId", out var loopId) &&
loopId is string loopIdText &&
!string.IsNullOrWhiteSpace(loopIdText))
{
attributes["loopId"] = loopIdText;
}
if (!string.IsNullOrWhiteSpace(turnState.ContextPayload))
{
attributes["context"] = turnState.ContextPayload;
}
if (!string.IsNullOrWhiteSpace(turnState.ContextPayload)) attributes["context"] = turnState.ContextPayload;
if (session.Metadata.TryGetValue("lastClockDomain", out var lastClockDomain) &&
lastClockDomain is string lastClockDomainText &&
!string.IsNullOrWhiteSpace(lastClockDomainText))
{
attributes["lastClockDomain"] = lastClockDomainText;
}
if (session.Metadata.TryGetValue("pendingProactivityOffer", out var pendingProactivityOffer) &&
pendingProactivityOffer is string pendingProactivityOfferText &&
!string.IsNullOrWhiteSpace(pendingProactivityOfferText))
{
attributes["pendingProactivityOffer"] = pendingProactivityOfferText;
}
foreach (var pair in session.Metadata)
{
@@ -63,41 +46,29 @@ public sealed class ProtocolToTurnContextMapper
!pair.Key.StartsWith("chitchat", StringComparison.OrdinalIgnoreCase) &&
!pair.Key.StartsWith("greetings", StringComparison.OrdinalIgnoreCase)) ||
pair.Value is null)
{
continue;
}
attributes[pair.Key] = pair.Value;
}
attributes["listenHotphrase"] = turnState.ListenHotphrase;
if (turnState.ListenRules.Count > 0)
{
attributes["listenRules"] = turnState.ListenRules;
}
if (turnState.ListenRules.Count > 0) attributes["listenRules"] = turnState.ListenRules;
if (turnState.ListenAsrHints.Count > 0)
{
attributes["listenAsrHints"] = turnState.ListenAsrHints;
}
if (turnState.ListenAsrHints.Count > 0) attributes["listenAsrHints"] = turnState.ListenAsrHints;
if (turnState.BufferedAudioBytes > 0)
{
attributes["bufferedAudioBytes"] = turnState.BufferedAudioBytes;
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))
{
attributes["audioTranscriptHint"] = turnState.AudioTranscriptHint;
}
if (turnState.FinalizeAttemptCount > 0)
{
attributes["finalizeAttemptCount"] = turnState.FinalizeAttemptCount;
}
if (turnState.FinalizeAttemptCount > 0) attributes["finalizeAttemptCount"] = turnState.FinalizeAttemptCount;
return new TurnContext
{
@@ -111,8 +82,12 @@ public sealed class ProtocolToTurnContextMapper
RequestId = envelope.ConnectionId,
ProtocolService = "neo-hub",
ProtocolOperation = protocolOperation,
FirmwareVersion = session.Metadata.TryGetValue("firmwareVersion", out var firmwareVersion) ? firmwareVersion as string : null,
ApplicationVersion = session.Metadata.TryGetValue("applicationVersion", out var applicationVersion) ? applicationVersion as string : null,
FirmwareVersion = session.Metadata.TryGetValue("firmwareVersion", out var firmwareVersion)
? firmwareVersion as string
: null,
ApplicationVersion = session.Metadata.TryGetValue("applicationVersion", out var applicationVersion)
? applicationVersion as string
: null,
IsFollowUpEligible = true,
Attributes = attributes
};
@@ -120,10 +95,7 @@ public sealed class ProtocolToTurnContextMapper
private static string? ExtractTranscript(string? text, IDictionary<string, object?> attributes)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
if (string.IsNullOrWhiteSpace(text)) return null;
try
{
@@ -133,57 +105,41 @@ public sealed class ProtocolToTurnContextMapper
if (!root.TryGetProperty("data", out var data)) return null;
if (data.TryGetProperty("text", out var transcript) && transcript.ValueKind == JsonValueKind.String)
{
return transcript.GetString();
}
if (data.TryGetProperty("asr", out var asr) &&
asr.ValueKind == JsonValueKind.Object &&
asr.TryGetProperty("text", out var asrText) &&
asrText.ValueKind == JsonValueKind.String)
{
return asrText.GetString();
}
if (data.TryGetProperty("transcriptHint", out var transcriptHint) && transcriptHint.ValueKind == JsonValueKind.String)
{
return transcriptHint.GetString();
}
if (data.TryGetProperty("transcriptHint", out var transcriptHint) &&
transcriptHint.ValueKind == JsonValueKind.String) return transcriptHint.GetString();
if (data.TryGetProperty("intent", out var intent) && intent.ValueKind == JsonValueKind.String)
{
attributes["clientIntent"] = intent.GetString();
}
if (data.TryGetProperty("triggerSource", out var triggerSource) &&
triggerSource.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(triggerSource.GetString()))
{
attributes["triggerSource"] = triggerSource.GetString();
}
if (data.TryGetProperty("triggerData", out var triggerData) &&
triggerData.ValueKind == JsonValueKind.Object &&
triggerData.TryGetProperty("looperID", out var triggerLooperId) &&
triggerLooperId.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(triggerLooperId.GetString()))
{
attributes["triggerLooperId"] = triggerLooperId.GetString();
}
if (data.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array)
{
attributes["clientRules"] = rules.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString() ?? string.Empty)
.Where(rule => !string.IsNullOrWhiteSpace(rule))
.ToArray();
}
if (data.TryGetProperty("entities", out var entities) && entities.ValueKind == JsonValueKind.Object)
{
attributes["clientEntities"] = entities.Clone();
}
return intent.ValueKind == JsonValueKind.String ? intent.GetString() : null;
}
@@ -192,4 +148,4 @@ public sealed class ProtocolToTurnContextMapper
return text;
}
}
}
}

View File

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

View File

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

View File

@@ -8,4 +8,4 @@ public sealed class AccountProfile
public string LastName { get; init; } = "Owner";
public string AccessKeyId { get; init; } = "openjibo-access-key";
public string SecretAccessKey { get; init; } = "openjibo-secret-access-key";
}
}

View File

@@ -5,4 +5,4 @@ public sealed class BackupRecord
public string BackupId { get; init; } = Guid.NewGuid().ToString("N");
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public string Name { get; init; } = "backup";
}
}

View File

@@ -7,5 +7,7 @@ public sealed class CapturedExchange
public ProtocolEnvelope Request { get; init; } = new();
public ProtocolDispatchResult Response { get; init; } = ProtocolDispatchResult.Ok();
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

@@ -22,4 +22,4 @@ public sealed class CapturedWebSocketFixtureStep
public JsonElement? Text { get; init; }
public IReadOnlyList<int>? Binary { get; init; }
public IReadOnlyList<string> ExpectedReplyTypes { get; init; } = [];
}
}

View File

@@ -20,4 +20,4 @@ public sealed class CloudSession
public bool FollowUpOpen => FollowUpExpiresUtc.HasValue && FollowUpExpiresUtc > DateTimeOffset.UtcNow;
public WebSocketTurnState TurnState { get; } = new();
public IDictionary<string, object?> Metadata { get; init; } = new Dictionary<string, object?>();
}
}

View File

@@ -8,5 +8,7 @@ public sealed class DeviceRegistration
public string? FirmwareVersion { get; init; }
public string? ApplicationVersion { get; init; }
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,4 +7,4 @@ public sealed class KeyRequestRecord
public string PublicKey { get; init; } = string.Empty;
public string EncryptedKey { get; init; } = string.Empty;
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
}
}

View File

@@ -10,4 +10,4 @@ public sealed class LoopRecord
public bool IsSuspended { get; init; }
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow;
}
}

View File

@@ -12,4 +12,4 @@ public sealed class MediaRecord
public bool IsEncrypted { get; init; }
public bool IsDeleted { get; init; }
public IDictionary<string, object?> Meta { get; init; } = new Dictionary<string, object?>();
}
}

View File

@@ -11,4 +11,4 @@ public sealed class PersonRecord
public bool IsPrimary { get; init; } = true;
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow;
}
}

View File

@@ -7,7 +7,9 @@ public sealed class ProtocolDispatchResult
public int StatusCode { get; init; } = 200;
public string ContentType { get; init; } = "application/x-amz-json-1.1";
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)
{
@@ -37,4 +39,4 @@ public sealed class ProtocolDispatchResult
ContentType = contentType
};
}
}
}

View File

@@ -17,14 +17,13 @@ public sealed class ProtocolEnvelope
public string? FirmwareVersion { get; init; }
public string? ApplicationVersion { get; init; }
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()
{
if (string.IsNullOrWhiteSpace(BodyText))
{
return null;
}
if (string.IsNullOrWhiteSpace(BodyText)) return null;
try
{
@@ -36,4 +35,4 @@ public sealed class ProtocolEnvelope
return null;
}
}
}
}

View File

@@ -5,4 +5,4 @@ public sealed class ProtocolFixture
public string Name { get; init; } = string.Empty;
public ProtocolEnvelope Request { get; init; } = new();
public int ExpectedStatusCode { get; init; } = 200;
}
}

View File

@@ -7,4 +7,4 @@ public sealed class RobotProfile
public IDictionary<string, object?> Payload { get; init; } = new Dictionary<string, object?>();
public IDictionary<string, object?> CalibrationPayload { get; init; } = new Dictionary<string, object?>();
public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow;
}
}

View File

@@ -12,4 +12,4 @@ public sealed class UpdateManifest
public long Length { get; init; }
public string Subsystem { get; init; } = "robot";
public string? Filter { get; init; }
}
}

View File

@@ -7,4 +7,4 @@ public sealed class UploadReference
public string ContentType { get; init; } = "application/octet-stream";
public long Length { get; init; }
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
}
}

View File

@@ -10,4 +10,4 @@ public sealed class WebSocketMessageEnvelope
public string? Text { get; init; }
public byte[]? Binary { get; init; }
public bool IsBinary => Binary is { Length: > 0 };
}
}

View File

@@ -5,4 +5,4 @@ public sealed class WebSocketReply
public string? Text { get; init; }
public int DelayMs { get; init; }
public bool Close { get; init; }
}
}

View File

@@ -20,5 +20,7 @@ public sealed class WebSocketTelemetryRecord
public int BufferedAudioChunks { get; init; }
public int FinalizeAttempts { 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

@@ -27,4 +27,4 @@ public sealed class WebSocketTurnState
public bool SawContext { get; set; }
public IReadOnlyList<string> ListenRules { get; set; } = [];
public IReadOnlyList<string> ListenAsrHints { get; set; } = [];
}
}

View File

@@ -9,4 +9,4 @@ public sealed class BufferedAudioSttOptions
public string WhisperLanguage { get; set; } = "en";
public string? TempDirectory { get; set; }
public bool CleanupTempFiles { get; set; }
}
}

View File

@@ -4,7 +4,8 @@ namespace Jibo.Cloud.Infrastructure.Audio;
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();
process.StartInfo = new ProcessStartInfo
@@ -16,10 +17,7 @@ public sealed class ExternalProcessRunner : IExternalProcessRunner
CreateNoWindow = true
};
foreach (var argument in arguments)
{
process.StartInfo.ArgumentList.Add(argument);
}
foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(argument);
process.Start();
@@ -35,4 +33,4 @@ public sealed class ExternalProcessRunner : IExternalProcessRunner
$"External process '{fileName}' failed with exit code {process.ExitCode}: {stdErr}")
: new ExternalProcessResult(process.ExitCode, stdOut, stdErr);
}
}
}

View File

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

View File

@@ -9,10 +9,7 @@ internal static class OggOpusAudioNormalizer
public static byte[] Normalize(IReadOnlyList<byte[]> pages)
{
if (pages.Count == 0)
{
return [];
}
if (pages.Count == 0) return [];
var parsed = pages.Select(ParsePage).ToArray();
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)
{
if (buffer.Length < 27)
{
throw new InvalidOperationException($"Buffered Ogg page is too short ({buffer.Length} bytes).");
}
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.");
}
var pageSegments = buffer[26];
if (buffer.Length < 27 + pageSegments)
{
throw new InvalidOperationException("Buffered Ogg page segment table was truncated.");
}
var payloadLength = 0;
for (var index = 0; index < pageSegments; index += 1)
{
payloadLength += buffer[27 + index];
}
for (var index = 0; index < pageSegments; index += 1) payloadLength += buffer[27 + index];
var expectedLength = 27 + pageSegments + payloadLength;
return buffer.Length < expectedLength
@@ -79,7 +67,8 @@ internal static class OggOpusAudioNormalizer
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()
@@ -89,11 +78,9 @@ internal static class OggOpusAudioNormalizer
{
var remainder = index << 24;
for (var bit = 0; bit < 8; bit += 1)
{
remainder = (remainder & 0x80000000) != 0
? (remainder << 1) ^ 0x04c11db7
: remainder << 1;
}
table[index] = remainder;
}
@@ -102,4 +89,4 @@ internal static class OggOpusAudioNormalizer
}
private sealed record ParsedOggPage(ulong GranulePosition);
}
}

View File

@@ -6,6 +6,11 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
{
private static readonly JiboExperienceCatalog Catalog = BuildCatalog();
public Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(Catalog);
}
private static JiboExperienceCatalog BuildCatalog()
{
var catalog = new JiboExperienceCatalog
@@ -148,9 +153,7 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
};
foreach (var seedDirectory in ResolveSeedDirectories())
{
catalog = LegacyMimCatalogImporter.MergeInto(catalog, seedDirectory);
}
return catalog;
}
@@ -211,9 +214,4 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
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,
string? rootDirectory)
{
if (baseCatalog is null)
{
throw new ArgumentNullException(nameof(baseCatalog));
}
if (baseCatalog is null) throw new ArgumentNullException(nameof(baseCatalog));
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
{
return baseCatalog;
}
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) return baseCatalog;
var importedCatalog = ImportCatalog(rootDirectory);
return MergeCatalogs(baseCatalog, importedCatalog);
@@ -51,32 +45,21 @@ public static class LegacyMimCatalogImporter
public static JiboExperienceCatalog ImportCatalog(string rootDirectory)
{
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
{
return new JiboExperienceCatalog();
}
var builder = new LegacyMimCatalogBuilder();
foreach (var filePath in Directory.EnumerateFiles(rootDirectory, "*.mim", SearchOption.AllDirectories)
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
{
if (!TryLoadDefinition(filePath, out var definition))
{
continue;
}
if (!TryLoadDefinition(filePath, out var definition)) continue;
var bucket = ResolveBucket(filePath);
if (bucket is null)
{
continue;
}
if (bucket is null) continue;
foreach (var prompt in definition.Prompts)
{
var text = NormalizePrompt(prompt.Prompt, preservePlaceholders: IsTemplateBucket(bucket.Value));
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
var text = NormalizePrompt(prompt.Prompt, IsTemplateBucket(bucket.Value));
if (string.IsNullOrWhiteSpace(text)) continue;
builder.Add(bucket.Value, prompt.Condition, text);
}
@@ -92,10 +75,7 @@ public static class LegacyMimCatalogImporter
{
var json = File.ReadAllText(filePath);
var parsed = JsonSerializer.Deserialize<LegacyMimDefinition>(json, JsonOptions);
if (parsed is null)
{
return false;
}
if (parsed is null) return false;
definition = parsed;
return definition.Prompts.Count > 0;
@@ -113,110 +93,67 @@ public static class LegacyMimCatalogImporter
if (normalizedPath.Contains("/core-responses/", StringComparison.OrdinalIgnoreCase) &&
fileName.Contains("Error", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.GenericFallback;
}
if (normalizedPath.Contains("/core-responses/deflector/", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Deflector", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Personality;
}
if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase) ||
normalizedPath.Contains("/gqa-responses/", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Emotion;
}
if (fileName.StartsWith("WeatherIntroTomorrow", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherTomorrowIntro;
}
if (fileName.StartsWith("WeatherIntro", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherIntro;
}
if (fileName.StartsWith("WeatherTomorrowHighLow", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherTomorrowHighLow;
}
if (fileName.StartsWith("WeatherTodayHighLow", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherTodayHighLow;
}
if (fileName.StartsWith("WeatherServiceDown", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.WeatherServiceDown;
}
if (fileName.StartsWith("CalendarNothingToday", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.CalendarNothingToday;
}
if (fileName.StartsWith("CalendarNothing", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.CalendarNothing;
}
if (fileName.StartsWith("CalendarOutro", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.CalendarOutro;
}
if (fileName.StartsWith("CommuteNow", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.CommuteNow;
}
if (fileName.StartsWith("CommuteNow", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.CommuteNow;
if (fileName.StartsWith("CommuteServiceDown", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.CommuteServiceDown;
}
if (fileName.StartsWith("NewsIntroCategory", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.NewsCategoryIntro;
}
if (fileName.StartsWith("NewsIntro", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.NewsIntro;
}
if (fileName.StartsWith("NewsIntro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsIntro;
if (fileName.StartsWith("NewsOutro", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.NewsOutro;
}
if (fileName.StartsWith("NewsOutro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsOutro;
if (fileName.StartsWith("Weather", StringComparison.OrdinalIgnoreCase) ||
string.Equals(fileName, "WetNowDryLater", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.ReportSkillTemplate;
}
if (fileName.StartsWith("PersonalReportKickOff", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.PersonalReportKickOff;
}
if (fileName.StartsWith("PersonalReportOutro", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.PersonalReportOutro;
}
if (fileName.StartsWith("PersonalReport", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Calendar", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Commute", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("News", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.ReportSkillTemplate;
}
if (fileName.StartsWith("JBO_DoYouLikeBeingJibo", 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_WhereDoYouGetInfo", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhatDoYouLikeToDo", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Personality;
}
if (fileName.StartsWith("OI_JBO_Is", 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_IsAngry", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Emotion;
}
if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RN_", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Welcome", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Greeting;
}
if (normalizedPath.Contains("/scripted-responses/", StringComparison.OrdinalIgnoreCase))
{
return LegacyMimBucket.Personality;
}
return null;
}
private static string NormalizePrompt(string? prompt)
{
return NormalizePrompt(prompt, preservePlaceholders: false);
return NormalizePrompt(prompt, false);
}
private static string NormalizePrompt(string? prompt, bool preservePlaceholders)
{
if (string.IsNullOrWhiteSpace(prompt))
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(prompt)) return string.Empty;
var text = WebUtility.HtmlDecode(prompt);
if (!preservePlaceholders)
{
text = PlaceholderPattern.Replace(text, " ");
}
if (!preservePlaceholders) text = PlaceholderPattern.Replace(text, " ");
text = LegacyMarkupPattern.Replace(text, " ");
text = WhitespacePattern.Replace(text, " ").Trim();
text = SpaceBeforePunctuationPattern.Replace(text, "$1");
@@ -298,21 +221,30 @@ public static class LegacyMimCatalogImporter
PizzaReplies = Merge(baseCatalog.PizzaReplies, importedCatalog.PizzaReplies),
SurpriseReplies = Merge(baseCatalog.SurpriseReplies, importedCatalog.SurpriseReplies),
PersonalReportReplies = Merge(baseCatalog.PersonalReportReplies, importedCatalog.PersonalReportReplies),
PersonalReportKickOffReplies = Merge(baseCatalog.PersonalReportKickOffReplies, importedCatalog.PersonalReportKickOffReplies),
PersonalReportOutroReplies = Merge(baseCatalog.PersonalReportOutroReplies, importedCatalog.PersonalReportOutroReplies),
PersonalReportKickOffReplies = Merge(baseCatalog.PersonalReportKickOffReplies,
importedCatalog.PersonalReportKickOffReplies),
PersonalReportOutroReplies = Merge(baseCatalog.PersonalReportOutroReplies,
importedCatalog.PersonalReportOutroReplies),
ReportSkillTemplates = Merge(baseCatalog.ReportSkillTemplates, importedCatalog.ReportSkillTemplates),
WeatherIntroReplies = Merge(baseCatalog.WeatherIntroReplies, importedCatalog.WeatherIntroReplies),
WeatherTomorrowIntroReplies = Merge(baseCatalog.WeatherTomorrowIntroReplies, importedCatalog.WeatherTomorrowIntroReplies),
WeatherTodayHighLowReplies = Merge(baseCatalog.WeatherTodayHighLowReplies, importedCatalog.WeatherTodayHighLowReplies),
WeatherTomorrowHighLowReplies = Merge(baseCatalog.WeatherTomorrowHighLowReplies, importedCatalog.WeatherTomorrowHighLowReplies),
WeatherServiceDownReplies = Merge(baseCatalog.WeatherServiceDownReplies, importedCatalog.WeatherServiceDownReplies),
CalendarNothingTodayReplies = Merge(baseCatalog.CalendarNothingTodayReplies, importedCatalog.CalendarNothingTodayReplies),
WeatherTomorrowIntroReplies = Merge(baseCatalog.WeatherTomorrowIntroReplies,
importedCatalog.WeatherTomorrowIntroReplies),
WeatherTodayHighLowReplies = Merge(baseCatalog.WeatherTodayHighLowReplies,
importedCatalog.WeatherTodayHighLowReplies),
WeatherTomorrowHighLowReplies = Merge(baseCatalog.WeatherTomorrowHighLowReplies,
importedCatalog.WeatherTomorrowHighLowReplies),
WeatherServiceDownReplies = Merge(baseCatalog.WeatherServiceDownReplies,
importedCatalog.WeatherServiceDownReplies),
CalendarNothingTodayReplies = Merge(baseCatalog.CalendarNothingTodayReplies,
importedCatalog.CalendarNothingTodayReplies),
CalendarNothingReplies = Merge(baseCatalog.CalendarNothingReplies, importedCatalog.CalendarNothingReplies),
CalendarOutroReplies = Merge(baseCatalog.CalendarOutroReplies, importedCatalog.CalendarOutroReplies),
CommuteNowReplies = Merge(baseCatalog.CommuteNowReplies, importedCatalog.CommuteNowReplies),
CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies, importedCatalog.CommuteServiceDownReplies),
CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies,
importedCatalog.CommuteServiceDownReplies),
NewsIntroReplies = Merge(baseCatalog.NewsIntroReplies, importedCatalog.NewsIntroReplies),
NewsCategoryIntroReplies = Merge(baseCatalog.NewsCategoryIntroReplies, importedCatalog.NewsCategoryIntroReplies),
NewsCategoryIntroReplies =
Merge(baseCatalog.NewsCategoryIntroReplies, importedCatalog.NewsCategoryIntroReplies),
NewsOutroReplies = Merge(baseCatalog.NewsOutroReplies, importedCatalog.NewsOutroReplies),
WeatherReplies = Merge(baseCatalog.WeatherReplies, importedCatalog.WeatherReplies),
CalendarReplies = Merge(baseCatalog.CalendarReplies, importedCatalog.CalendarReplies),
@@ -332,16 +264,10 @@ public static class LegacyMimCatalogImporter
foreach (var value in baseList.Concat(importedList))
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
if (string.IsNullOrWhiteSpace(value)) continue;
var normalized = value.Trim();
if (!seen.Add(normalized))
{
continue;
}
if (!seen.Add(normalized)) continue;
merged.Add(normalized);
}
@@ -358,18 +284,12 @@ public static class LegacyMimCatalogImporter
foreach (var value in baseList.Concat(importedList))
{
if (string.IsNullOrWhiteSpace(value.Reply))
{
continue;
}
if (string.IsNullOrWhiteSpace(value.Reply)) continue;
var normalizedCondition = NormalizeCondition(value.Condition);
var normalizedReply = value.Reply.Trim();
var key = $"{normalizedCondition}::{normalizedReply}";
if (!seen.Add(key))
{
continue;
}
if (!seen.Add(key)) continue;
merged.Add(new JiboConditionedReply
{
@@ -381,6 +301,23 @@ public static class LegacyMimCatalogImporter
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
{
GenericFallback,
@@ -408,64 +345,55 @@ public static class LegacyMimCatalogImporter
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> _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> _fallbacks = [];
private readonly List<string> _personalReportKickOffReplies = [];
private readonly List<string> _personalReportOutroReplies = [];
private readonly List<string> _reportSkillTemplates = [];
private readonly List<string> _weatherIntroReplies = [];
private readonly List<string> _weatherTomorrowIntroReplies = [];
private readonly List<string> _weatherServiceDownReplies = [];
private readonly List<string> _weatherTodayHighLowReplies = [];
private readonly List<string> _weatherTomorrowHighLowReplies = [];
private readonly List<string> _weatherServiceDownReplies = [];
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 = [];
private readonly List<string> _weatherTomorrowIntroReplies = [];
public void Add(LegacyMimBucket bucket, string? condition, string text)
{
switch (bucket)
{
case LegacyMimBucket.GenericFallback:
if (_fallbacks.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
if (_fallbacks.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
_fallbacks.Add(text);
return;
case LegacyMimBucket.Greeting:
if (_greetings.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
if (_greetings.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
_greetings.Add(text);
return;
case LegacyMimBucket.HowAreYou:
if (_howAreYous.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
_howAreYous.Add(text);
return;
case LegacyMimBucket.Emotion:
var normalizedCondition = NormalizeCondition(condition);
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)))
{
return;
}
_emotionReplies.Add(new JiboConditionedReply
{
@@ -475,9 +403,7 @@ public static class LegacyMimCatalogImporter
return;
case LegacyMimBucket.Personality:
if (_personalities.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
_personalities.Add(text);
return;
@@ -550,8 +476,7 @@ public static class LegacyMimCatalogImporter
WeatherTomorrowIntroReplies = [.. _weatherTomorrowIntroReplies],
WeatherTodayHighLowReplies = [.. _weatherTodayHighLowReplies],
WeatherTomorrowHighLowReplies = [.. _weatherTomorrowHighLowReplies],
WeatherServiceDownReplies = [.. _weatherServiceDownReplies]
,
WeatherServiceDownReplies = [.. _weatherServiceDownReplies],
CalendarNothingTodayReplies = [.. _calendarNothingTodayReplies],
CalendarNothingReplies = [.. _calendarNothingReplies],
CalendarOutroReplies = [.. _calendarOutroReplies],
@@ -565,10 +490,7 @@ public static class LegacyMimCatalogImporter
private static void AddDistinct(List<string> target, string text)
{
if (target.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
{
return;
}
if (target.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
target.Add(text);
}
@@ -576,62 +498,30 @@ public static class LegacyMimCatalogImporter
private sealed class LegacyMimDefinition
{
[JsonPropertyName("skill_id")]
public string? SkillId { get; init; }
[JsonPropertyName("skill_id")] public string? SkillId { get; init; }
[JsonPropertyName("mim_id")]
public string? MimId { get; init; }
[JsonPropertyName("mim_id")] public string? MimId { get; init; }
[JsonPropertyName("mim_type")]
public string? MimType { get; init; }
[JsonPropertyName("mim_type")] public string? MimType { get; init; }
[JsonPropertyName("prompts")]
public List<LegacyMimPrompt> Prompts { get; init; } = [];
[JsonPropertyName("prompts")] public List<LegacyMimPrompt> Prompts { get; init; } = [];
}
private sealed class LegacyMimPrompt
{
[JsonPropertyName("mim_id")]
public string? MimId { get; init; }
[JsonPropertyName("mim_id")] public string? MimId { get; init; }
[JsonPropertyName("prompt_category")]
public string? PromptCategory { get; init; }
[JsonPropertyName("prompt_category")] public string? PromptCategory { get; init; }
[JsonPropertyName("prompt_sub_category")]
public string? PromptSubCategory { get; init; }
[JsonPropertyName("condition")]
public string? Condition { get; init; }
[JsonPropertyName("condition")] public string? Condition { get; init; }
[JsonPropertyName("prompt")]
public string? Prompt { get; init; }
[JsonPropertyName("prompt")] public string? Prompt { get; init; }
[JsonPropertyName("prompt_id")]
public string? PromptId { get; init; }
[JsonPropertyName("prompt_id")] public string? PromptId { get; init; }
[JsonPropertyName("weight")]
public double? Weight { get; init; }
[JsonPropertyName("weight")] 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.Weather;
using Jibo.Runtime.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Jibo.Cloud.Infrastructure.DependencyInjection;
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();
if (configuration is not null)
@@ -27,25 +28,16 @@ public static class ServiceCollectionExtensions
var openWeatherOptions = new OpenWeatherOptions();
if (configuration is not null)
{
configuration.GetSection("OpenJibo:Weather:OpenWeather").Bind(openWeatherOptions);
}
if (string.IsNullOrWhiteSpace(openWeatherOptions.ApiKey))
{
openWeatherOptions.ApiKey = Environment.GetEnvironmentVariable("OPENWEATHER_API_KEY");
}
var newsApiOptions = new NewsApiOptions();
if (configuration is not null)
{
configuration.GetSection("OpenJibo:News:NewsApi").Bind(newsApiOptions);
}
if (configuration is not null) configuration.GetSection("OpenJibo:News:NewsApi").Bind(newsApiOptions);
if (string.IsNullOrWhiteSpace(newsApiOptions.ApiKey))
{
newsApiOptions.ApiKey = Environment.GetEnvironmentVariable("NEWSAPI_KEY");
}
services.AddSingleton(sttOptions);
services.AddSingleton(openWeatherOptions);
@@ -53,27 +45,32 @@ public static class ServiceCollectionExtensions
services.AddHttpClient<IWeatherReportProvider, OpenWeatherReportProvider>();
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
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"]
?? 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 personalMemoryBackendKind = ParseBackendKind(configuration?["OpenJibo:PersonalMemory:Backend"]);
var stateConnectionString = configuration?["OpenJibo:State:ConnectionString"]
?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_STORAGE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_SQL_CONNECTION_STRING");
?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_STORAGE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable("OPENJIBO_STATE_SQL_CONNECTION_STRING");
var personalMemoryConnectionString = configuration?["OpenJibo:PersonalMemory:ConnectionString"]
?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable("OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING");
?? Environment.GetEnvironmentVariable(
"OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable(
"OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING");
services.AddSingleton<IPersistenceSnapshotStoreFactory, PersistenceSnapshotStoreFactory>();
services.AddSingleton<ICloudStateStore>(provider =>
{
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 =>
{
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<JiboExperienceContentCache>();
@@ -98,8 +95,8 @@ public static class ServiceCollectionExtensions
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
: PersistenceBackendKind.File;
}
}
}

View File

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

View File

@@ -25,4 +25,4 @@ public sealed class NewsApiOptions
public int CacheTtlSeconds { get; set; } = 300;
public int FailureCacheTtlSeconds { get; set; } = 45;
}
}

View File

@@ -1,6 +1,5 @@
using System.Text.Json;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
namespace Jibo.Cloud.Infrastructure.Persistence;
@@ -12,22 +11,22 @@ internal sealed class AzureBlobSnapshotStore : ISnapshotStore
PropertyNameCaseInsensitive = true
};
private readonly BlobContainerClient _containerClient;
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))
{
throw new InvalidOperationException("Azure Blob persistence requires a storage connection string.");
}
if (string.IsNullOrWhiteSpace(snapshotName))
{
throw new ArgumentException("A snapshot name is required for Azure Blob persistence.", nameof(snapshotName));
}
throw new ArgumentException("A snapshot name is required for Azure Blob persistence.",
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";
}
@@ -35,34 +34,28 @@ internal sealed class AzureBlobSnapshotStore : ISnapshotStore
{
try
{
if (!_containerClient.Exists())
{
return default;
}
if (!_containerClient.Exists()) return null;
var blobClient = _containerClient.GetBlobClient(_blobName);
if (!blobClient.Exists())
{
return default;
}
if (!blobClient.Exists()) return null;
var content = blobClient.DownloadContent();
var json = content.Value.Content.ToString();
return string.IsNullOrWhiteSpace(json)
? default
? null
: JsonSerializer.Deserialize<TSnapshot>(json, JsonOptions);
}
catch
{
return default;
return null;
}
}
public void Save<TSnapshot>(TSnapshot snapshot) where TSnapshot : class
{
_containerClient.CreateIfNotExists(PublicAccessType.None);
_containerClient.CreateIfNotExists();
var blobClient = _containerClient.GetBlobClient(_blobName);
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 _tableName;
public AzureSqlSnapshotStore(string connectionString, string snapshotName, string tableName = "PersistenceSnapshots")
public AzureSqlSnapshotStore(string connectionString, string snapshotName,
string tableName = "PersistenceSnapshots")
{
_connectionString = string.IsNullOrWhiteSpace(connectionString)
? throw new InvalidOperationException("Azure SQL persistence requires a connection string.")
: connectionString;
_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;
_tableName = string.IsNullOrWhiteSpace(tableName) ? "PersistenceSnapshots" : tableName;
}
@@ -35,17 +37,14 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore
using var command = connection.CreateCommand();
command.CommandText = $"""
SELECT SnapshotJson
FROM dbo.[{_tableName}]
WHERE SnapshotName = @snapshotName
""";
SELECT SnapshotJson
FROM dbo.[{_tableName}]
WHERE SnapshotName = @snapshotName
""";
command.Parameters.Add(new SqlParameter("@snapshotName", SqlDbType.NVarChar, 200) { Value = _snapshotName });
var result = command.ExecuteScalar();
if (result is not string json || string.IsNullOrWhiteSpace(json))
{
return default;
}
if (result is not string json || string.IsNullOrWhiteSpace(json)) return null;
try
{
@@ -53,7 +52,7 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore
}
catch
{
return default;
return null;
}
}
@@ -67,16 +66,16 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore
using var command = connection.CreateCommand();
command.CommandText = $"""
MERGE dbo.[{_tableName}] AS target
USING (SELECT @snapshotName AS SnapshotName) AS source
ON target.SnapshotName = source.SnapshotName
WHEN MATCHED THEN
UPDATE SET SnapshotJson = @snapshotJson,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (SnapshotName, SnapshotJson, CreatedUtc, UpdatedUtc)
VALUES (@snapshotName, @snapshotJson, SYSUTCDATETIME(), SYSUTCDATETIME());
""";
MERGE dbo.[{_tableName}] AS target
USING (SELECT @snapshotName AS SnapshotName) AS source
ON target.SnapshotName = source.SnapshotName
WHEN MATCHED THEN
UPDATE SET SnapshotJson = @snapshotJson,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (SnapshotName, SnapshotJson, CreatedUtc, UpdatedUtc)
VALUES (@snapshotName, @snapshotJson, SYSUTCDATETIME(), SYSUTCDATETIME());
""";
command.Parameters.Add(new SqlParameter("@snapshotName", SqlDbType.NVarChar, 200) { Value = _snapshotName });
command.Parameters.Add(new SqlParameter("@snapshotJson", SqlDbType.NVarChar, -1) { Value = json });
command.ExecuteNonQuery();
@@ -86,16 +85,16 @@ internal sealed class AzureSqlSnapshotStore : ISnapshotStore
{
using var command = connection.CreateCommand();
command.CommandText = $"""
IF OBJECT_ID(N'dbo.[{_tableName}]', N'U') IS NULL
BEGIN
CREATE TABLE dbo.[{_tableName}] (
SnapshotName nvarchar(200) NOT NULL CONSTRAINT PK_{_tableName}_SnapshotName PRIMARY KEY,
SnapshotJson nvarchar(max) NOT NULL,
CreatedUtc datetimeoffset NOT NULL,
UpdatedUtc datetimeoffset NOT NULL
);
END
""";
IF OBJECT_ID(N'dbo.[{_tableName}]', N'U') IS NULL
BEGIN
CREATE TABLE dbo.[{_tableName}] (
SnapshotName nvarchar(200) NOT NULL CONSTRAINT PK_{_tableName}_SnapshotName PRIMARY KEY,
SnapshotJson nvarchar(max) NOT NULL,
CreatedUtc datetimeoffset NOT NULL,
UpdatedUtc datetimeoffset NOT NULL
);
END
""";
command.ExecuteNonQuery();
}
}
}

View File

@@ -2,5 +2,6 @@ namespace Jibo.Cloud.Infrastructure.Persistence;
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

@@ -4,4 +4,4 @@ public interface ISnapshotStore
{
TSnapshot? Load<TSnapshot>() where TSnapshot : class;
void Save<TSnapshot>(TSnapshot snapshot) where TSnapshot : class;
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
@@ -7,29 +8,38 @@ namespace Jibo.Cloud.Infrastructure.Persistence;
public sealed class InMemoryCloudStateStore : ICloudStateStore
{
private const string CurrentSchemaVersion = "1";
private static readonly JsonSerializerOptions PersistenceJsonOptions = new()
{
WriteIndented = 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, CloudSession> _sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, KeyRequestRecord> _keyRequests = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, KeyRequestRecord>
_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 ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _syncRoot = new();
private readonly List<UpdateManifest> _updates;
private readonly List<MediaRecord> _media = [];
private readonly List<BackupRecord> _backups = [];
private readonly List<LoopRecord> _loops;
private readonly List<PersonRecord> _people;
private DeviceRegistration _robot;
private RobotProfile _robotProfile;
private long _revision;
private AccountProfile _account = new();
private DateTimeOffset? _lastLoadedUtc;
private DateTimeOffset? _lastSavedUtc;
private long _revision;
private DeviceRegistration _robot;
private RobotProfile _robotProfile;
public InMemoryCloudStateStore(string? persistencePath = null)
: this(new JsonFileSnapshotStore(persistencePath, PersistenceJsonOptions))
@@ -101,55 +111,37 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public PersistenceStateInfo GetPersistenceStateInfo()
{
return new PersistenceStateInfo(
SchemaVersion: CurrentSchemaVersion,
Revision: Interlocked.Read(ref _revision),
LastLoadedUtc: _lastLoadedUtc,
LastSavedUtc: _lastSavedUtc);
CurrentSchemaVersion,
Interlocked.Read(ref _revision),
_lastLoadedUtc,
_lastSavedUtc);
}
public void LoadPersistedState()
{
var snapshot = _snapshotStore.Load<PersistentStateSnapshot>();
if (snapshot is null)
{
return;
}
if (snapshot is null) return;
_account = snapshot.Account ?? _account;
_robot = snapshot.Robot ?? _robot;
_robotProfile = snapshot.RobotProfile ?? _robotProfile;
_devices.Clear();
foreach (var device in snapshot.Devices ?? [])
{
_devices[device.DeviceId] = device;
}
foreach (var device in snapshot.Devices ?? []) _devices[device.DeviceId] = device;
if (_devices.IsEmpty || !_devices.ContainsKey(_robot.DeviceId))
{
_devices[_robot.DeviceId] = _robot;
}
if (_devices.IsEmpty || !_devices.ContainsKey(_robot.DeviceId)) _devices[_robot.DeviceId] = _robot;
_sessionsByToken.Clear();
foreach (var session in snapshot.Sessions ?? [])
{
if (!string.IsNullOrWhiteSpace(session.Token))
{
_sessionsByToken[session.Token] = session.ToRecord();
}
}
_symmetricKeys.Clear();
foreach (var pair in snapshot.SymmetricKeys ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase))
{
_symmetricKeys[pair.Key] = pair.Value;
}
_keyRequests.Clear();
foreach (var keyRequest in snapshot.KeyRequests ?? [])
{
_keyRequests[keyRequest.RequestId] = keyRequest;
}
foreach (var keyRequest in snapshot.KeyRequests ?? []) _keyRequests[keyRequest.RequestId] = keyRequest;
_updates.Clear();
_updates.AddRange(snapshot.Updates ?? []);
@@ -166,8 +158,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
_people.Clear();
_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
{
RobotId = _robot.RobotId,
@@ -180,7 +172,6 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
},
UpdatedUtc = DateTimeOffset.UtcNow
};
}
Interlocked.Exchange(ref _revision, snapshot.Revision);
_lastLoadedUtc = snapshot.LastLoadedUtc ?? DateTimeOffset.UtcNow;
@@ -203,7 +194,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
RobotProfile = _robotProfile,
Devices = _devices.Values.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(),
Updates = _updates.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)
{
@@ -309,14 +310,21 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
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)
{
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))
.ToArray();
}
@@ -324,10 +332,12 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? 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
{
@@ -351,7 +361,6 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
{
var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId);
if (existing is null)
{
return new UpdateManifest
{
UpdateId = updateId ?? "unknown-update",
@@ -360,14 +369,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
ShaHash = "missing",
Subsystem = "unknown"
};
}
_updates.Remove(existing);
TouchState();
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
.Where(item => !item.IsDeleted)
@@ -387,10 +396,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
var replacements = new List<MediaRecord>();
for (var i = 0; i < _media.Count; i++)
{
if (!paths.Contains(_media[i].Path))
{
continue;
}
if (!paths.Contains(_media[i].Path)) continue;
var updated = new MediaRecord
{
@@ -410,15 +416,13 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
replacements.Add(updated);
}
if (replacements.Count > 0)
{
TouchState();
}
if (replacements.Count > 0) TouchState();
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
{
@@ -432,32 +436,32 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
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)
{
_media[existingIndex] = item;
}
else
{
_media.Add(item);
}
TouchState();
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)
{
if (_symmetricKeys.TryGetValue(loopId, out var existing))
{
return existing;
}
if (_symmetricKeys.TryGetValue(loopId, out var 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))
{
TouchState();
@@ -483,10 +487,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey)
{
if (!string.IsNullOrWhiteSpace(requestId) && _keyRequests.TryGetValue(requestId, out var record))
{
return record;
}
if (!string.IsNullOrWhiteSpace(requestId) && _keyRequests.TryGetValue(requestId, out var record)) return record;
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()
{
@@ -548,7 +555,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
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
?? "openjibo-default-loop";
}
@@ -591,8 +599,6 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
};
}
private const string CurrentSchemaVersion = "1";
private sealed class PersistentStateSnapshot
{
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
@@ -655,4 +661,4 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
};
}
}
}
}

View File

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

View File

@@ -2,47 +2,29 @@ using System.Text.Json;
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
{
if (string.IsNullOrWhiteSpace(_persistencePath) || !File.Exists(_persistencePath))
{
return default;
}
if (string.IsNullOrWhiteSpace(persistencePath) || !File.Exists(persistencePath)) return null;
try
{
return JsonSerializer.Deserialize<TSnapshot>(File.ReadAllText(_persistencePath), _options);
return JsonSerializer.Deserialize<TSnapshot>(File.ReadAllText(persistencePath), options);
}
catch
{
return default;
return null;
}
}
public void Save<TSnapshot>(TSnapshot snapshot) where TSnapshot : class
{
if (string.IsNullOrWhiteSpace(_persistencePath))
{
return;
}
if (string.IsNullOrWhiteSpace(persistencePath)) return;
var directory = Path.GetDirectoryName(_persistencePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var directory = Path.GetDirectoryName(persistencePath);
if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory);
File.WriteAllText(_persistencePath, JsonSerializer.Serialize(snapshot, _options));
File.WriteAllText(persistencePath, JsonSerializer.Serialize(snapshot, options));
}
}
}

View File

@@ -5,4 +5,4 @@ public enum PersistenceBackendKind
File,
AzureBlob,
AzureSql
}
}

View File

@@ -10,18 +10,21 @@ public sealed class PersistenceSnapshotStoreFactory : IPersistenceSnapshotStoreF
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
{
PersistenceBackendKind.File => new JsonFileSnapshotStore(persistencePath, JsonOptions),
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),
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),
_ => new JsonFileSnapshotStore(persistencePath, JsonOptions)
};
}
}
}

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Jibo.Cloud.Tests")]
[assembly: InternalsVisibleTo("Jibo.Cloud.Tests")]

View File

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

View File

@@ -12,12 +12,10 @@ public sealed class FileProtocolTelemetrySink(
{
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)
{
return;
}
if (!options.Value.Enabled) return;
var directory = CapturePathResolver.Resolve(
options.Value.DirectoryPath,
@@ -74,4 +72,4 @@ public sealed class FileProtocolTelemetrySink(
$"{envelope.ServicePrefix}.{envelope.Operation}".Trim('.'),
result.StatusCode);
}
}
}

View File

@@ -5,7 +5,8 @@ using Microsoft.Extensions.Options;
namespace Jibo.Cloud.Infrastructure.Telemetry;
public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
public sealed class FileTurnTelemetrySink(
ILogger<FileTurnTelemetrySink> logger,
IOptions<TurnTelemetryOptions> options) : ITurnTelemetrySink
{
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);
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)
{
return;
}
if (!options.Value.Enabled) return;
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)
{
if (!options.Value.Enabled)
{
return;
}
if (!options.Value.Enabled) return;
await WriteEventAsync(new
{
@@ -44,7 +40,8 @@ public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
}, "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();
Directory.CreateDirectory(directory);
@@ -71,4 +68,4 @@ public sealed class FileTurnTelemetrySink(ILogger<FileTurnTelemetrySink> logger,
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory);
}
}
}

View File

@@ -16,15 +16,15 @@ public sealed class FileWebSocketTelemetrySink(
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);
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)
{
return;
}
if (!options.Value.Enabled) return;
_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
? Task.CompletedTask
@@ -48,7 +50,8 @@ public sealed class FileWebSocketTelemetrySink(
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
? Task.CompletedTask
@@ -56,12 +59,10 @@ public sealed class FileWebSocketTelemetrySink(
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)
{
return;
}
if (!options.Value.Enabled) return;
var replyTypes = replies
.Select(reply => ReadReplyType(reply.Text))
@@ -69,25 +70,22 @@ public sealed class FileWebSocketTelemetrySink(
.Select(type => type!)
.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))
{
fixture.Steps.Add(new CapturedWebSocketFixtureStep
{
Text = ParseJsonElement(envelope.Text),
Binary = envelope.Binary?.Select(value => (int)value).ToArray(),
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)
{
return;
}
if (!options.Value.Enabled) return;
await WriteRecordAsync(BuildRecord(
"connection_closed",
@@ -98,10 +96,8 @@ public sealed class FileWebSocketTelemetrySink(
null,
new Dictionary<string, object?> { ["reason"] = reason }), cancellationToken);
if (!options.Value.ExportFixtures || !_fixtures.TryRemove(session.SessionId, out var fixture) || fixture.Steps.Count == 0)
{
return;
}
if (!options.Value.ExportFixtures || !_fixtures.TryRemove(session.SessionId, out var fixture) ||
fixture.Steps.Count == 0) return;
var fixtureName = BuildFixtureName(session, fixture);
var capturedFixture = new CapturedWebSocketFixture
@@ -118,7 +114,8 @@ public sealed class FileWebSocketTelemetrySink(
await _writeLock.WaitAsync(cancellationToken);
try
{
await File.WriteAllTextAsync(fixturePath, JsonSerializer.Serialize(capturedFixture, JsonOptions), cancellationToken);
await File.WriteAllTextAsync(fixturePath, JsonSerializer.Serialize(capturedFixture, JsonOptions),
cancellationToken);
}
finally
{
@@ -161,8 +158,10 @@ public sealed class FileWebSocketTelemetrySink(
string? messageType,
string direction,
IReadOnlyList<string>? replyTypes,
IReadOnlyDictionary<string, object?>? details) => new()
IReadOnlyDictionary<string, object?>? details)
{
return new WebSocketTelemetryRecord
{
EventType = eventType,
SessionId = session.SessionId,
ConnectionId = envelope.ConnectionId,
@@ -182,13 +181,11 @@ public sealed class FileWebSocketTelemetrySink(
AwaitingTurnCompletion = session.TurnState.AwaitingTurnCompletion,
Details = details ?? new Dictionary<string, object?>()
};
}
private static string? ReadReplyType(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
if (string.IsNullOrWhiteSpace(text)) return null;
try
{
@@ -205,10 +202,7 @@ public sealed class FileWebSocketTelemetrySink(
private static JsonElement? ParseJsonElement(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
if (string.IsNullOrWhiteSpace(text)) return null;
try
{
@@ -252,4 +246,4 @@ public sealed class FileWebSocketTelemetrySink(
public CapturedWebSocketFixtureSession Session { get; init; } = new();
public List<CapturedWebSocketFixtureStep> Steps { get; } = [];
}
}
}

View File

@@ -4,4 +4,4 @@ public sealed class ProtocolTelemetryOptions
{
public bool Enabled { get; set; } = true;
public string DirectoryPath { get; set; } = "captures/http";
}
}

View File

@@ -17,4 +17,4 @@ public sealed class OpenWeatherOptions
public int GeocodeCacheTtlSeconds { get; set; } = 21600;
public int FailureCacheTtlSeconds { get; set; } = 45;
}
}

View File

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

View File

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

View File

@@ -18,4 +18,4 @@ public sealed class ResponsePlan
public string? DebugRoute { get; init; }
public IDictionary<string, object?> Diagnostics { get; init; } = new Dictionary<string, object?>();
public IDictionary<string, object?> ProtocolMetadata { get; init; } = new Dictionary<string, object?>();
}
}

View File

@@ -18,4 +18,4 @@ public sealed class RobotEvent
public string? ApplicationVersion { get; init; }
public IDictionary<string, object?> Payload { get; init; } = new Dictionary<string, object?>();
}
}

View File

@@ -25,4 +25,4 @@ public sealed class TurnContext
public bool IsFollowUpEligible { get; init; }
public IDictionary<string, object?> Attributes { get; init; } = new Dictionary<string, object?>();
}
}

View File

@@ -1,48 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenJibo</title>
<link rel="stylesheet" href="site.css">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenJibo</title>
<link rel="stylesheet" href="site.css">
</head>
<body>
<main class="shell">
<section class="hero">
<p class="eyebrow">OpenJibo</p>
<h1>Bringing Jibo back with a stable open cloud.</h1>
<p class="lede">
OpenJibo is rebuilding the hosted layer Jibo still expects, then using that foothold
to modernize the platform step by step.
</p>
<div class="actions">
<a href="https://github.com/" class="button primary">Source Repos</a>
<a href="../../docs/device-bootstrap.md" class="button secondary">Bootstrap Docs</a>
</div>
</section>
<main class="shell">
<section class="hero">
<p class="eyebrow">OpenJibo</p>
<h1>Bringing Jibo back with a stable open cloud.</h1>
<p class="lede">
OpenJibo is rebuilding the hosted layer Jibo still expects, then using that foothold
to modernize the platform step by step.
</p>
<div class="actions">
<a href="https://github.com/" class="button primary">Source Repos</a>
<a href="../../docs/device-bootstrap.md" class="button secondary">Bootstrap Docs</a>
</div>
</section>
<section class="panel">
<h2>Current Direction</h2>
<p>
The first milestone is a stable Azure-hosted replacement cloud. We support real robots
first through controlled DNS plus targeted device patching, then smooth the path later with OTA.
</p>
</section>
<section class="panel">
<h2>Current Direction</h2>
<p>
The first milestone is a stable Azure-hosted replacement cloud. We support real robots
first through controlled DNS plus targeted device patching, then smooth the path later with OTA.
</p>
</section>
<section class="grid">
<article class="card">
<h3>Cloud First</h3>
<p>Replace the missing hosted services before attempting deeper on-device modernization.</p>
</article>
<article class="card">
<h3>Real Hardware</h3>
<p>Use repeatable device bootstrap steps instead of pretending recovery is already one-click.</p>
</article>
<article class="card">
<h3>Open Path</h3>
<p>Keep the protocol work, docs, and codebase visible so the community can iterate with us.</p>
</article>
</section>
</main>
<section class="grid">
<article class="card">
<h3>Cloud First</h3>
<p>Replace the missing hosted services before attempting deeper on-device modernization.</p>
</article>
<article class="card">
<h3>Real Hardware</h3>
<p>Use repeatable device bootstrap steps instead of pretending recovery is already one-click.</p>
</article>
<article class="card">
<h3>Open Path</h3>
<p>Keep the protocol work, docs, and codebase visible so the community can iterate with us.</p>
</article>
</section>
</main>
</body>
</html>
</html>

View File

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

View File

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

View File

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

View File

@@ -14,20 +14,25 @@ public sealed class LegacyMimCatalogImporterTests
{
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("Jibo. Just Jibo, no last name. Like Bono", 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 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 =>
reply.Condition.Contains("NEUTRAL", 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
{
Directory.Delete(rootDirectory, recursive: true);
Directory.Delete(rootDirectory, true);
}
}
@@ -70,7 +75,7 @@ public sealed class LegacyMimCatalogImporterTests
}
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("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 really like sunflowers.", 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 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 =>
reply.Contains("I do things like this when I'm happy", StringComparison.OrdinalIgnoreCase));
Assert.Contains(catalog.PersonalityReplies, reply =>
@@ -129,7 +136,8 @@ public sealed class LegacyMimCatalogImporterTests
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(catalog.PersonalityReplies, reply =>
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, 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("Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}.", catalog.WeatherTomorrowHighLowReplies);
Assert.Contains(
"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("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("Looking at your calendar, I don't see anything scheduled today.", catalog.CalendarNothingTodayReplies);
Assert.Contains("And that's your report for the day. I hope you had as much fun as I did.",
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("Here's today's news, from the associated press.", catalog.NewsIntroReplies);
Assert.Contains("And that's what's new in the news.", catalog.NewsOutroReplies);
@@ -245,7 +259,7 @@ public sealed class LegacyMimCatalogImporterTests
}
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(catalog.EmotionReplies, reply =>
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("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()
@@ -449,4 +465,4 @@ public sealed class LegacyMimCatalogImporterTests
return rootDirectory;
}
}
}

View File

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

View File

@@ -28,23 +28,31 @@ internal static class WebSocketFixtureLoader
Kind = session.GetProperty("kind").GetString() ?? "neo-hub-listen",
Token = session.GetProperty("token").GetString(),
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()
: null
},
ExpectedReplyTypes = [.. stepElement.GetProperty("expectedReplyTypes")
.EnumerateArray()
.Select(item => item.GetString() ?? string.Empty)
.Where(item => !string.IsNullOrWhiteSpace(item))],
ExpectedReplies = stepElement.TryGetProperty("expectedReplies", out var expectedReplies) && expectedReplies.ValueKind == JsonValueKind.Array
? JsonSerializer.Deserialize<List<ExpectedWebSocketReply>>(expectedReplies.GetRawText(), SerializerOptions) ?? []
ExpectedReplyTypes =
[
.. stepElement.GetProperty("expectedReplyTypes")
.EnumerateArray()
.Select(item => item.GetString() ?? string.Empty)
.Where(item => !string.IsNullOrWhiteSpace(item))
],
ExpectedReplies = stepElement.TryGetProperty("expectedReplies", out var expectedReplies) &&
expectedReplies.ValueKind == JsonValueKind.Array
? JsonSerializer.Deserialize<List<ExpectedWebSocketReply>>(expectedReplies.GetRawText(),
SerializerOptions) ?? []
: []
})
.ToList();
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
};
}
@@ -68,4 +76,4 @@ internal sealed class ExpectedWebSocketReply
public string Type { get; init; } = string.Empty;
public int? DelayMs { get; init; }
public JsonElement? JsonSubset { get; init; }
}
}

View File

@@ -1 +1 @@
global using Xunit;
global using Xunit;

View File

@@ -10,13 +10,12 @@ public sealed class AzureBlobPersistenceSmokeTests
var stateBackend = Environment.GetEnvironmentVariable("OpenJibo__State__Backend");
var stateConnectionString = Environment.GetEnvironmentVariable("OpenJibo__State__ConnectionString");
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) ||
string.IsNullOrWhiteSpace(stateConnectionString))
{
return;
}
var factory = new PersistenceSnapshotStoreFactory();
var snapshotName = $"smoke-{Guid.NewGuid():N}";
@@ -45,4 +44,4 @@ public sealed class AzureBlobPersistenceSmokeTests
public string Name { get; init; } = string.Empty;
public string Value { get; init; } = string.Empty;
}
}
}

View File

@@ -10,7 +10,8 @@ public sealed class PersistenceStoreTests
{
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);
}
@@ -21,7 +22,8 @@ public sealed class PersistenceStoreTests
var factory = new PersistenceSnapshotStoreFactory();
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]
@@ -86,10 +88,7 @@ public sealed class PersistenceStoreTests
}
finally
{
if (File.Exists(persistencePath))
{
File.Delete(persistencePath);
}
if (File.Exists(persistencePath)) File.Delete(persistencePath);
}
}
@@ -102,7 +101,8 @@ public sealed class PersistenceStoreTests
{
var firstStore = new InMemoryCloudStateStore(persistencePath);
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 device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6");
firstStore.SavePersistedState();
@@ -124,10 +124,7 @@ public sealed class PersistenceStoreTests
}
finally
{
if (File.Exists(persistencePath))
{
File.Delete(persistencePath);
}
if (File.Exists(persistencePath)) File.Delete(persistencePath);
}
}
@@ -137,7 +134,7 @@ public sealed class PersistenceStoreTests
public TSnapshot2? Load<TSnapshot2>() where TSnapshot2 : class
{
return default;
return null;
}
public void Save<TSnapshot2>(TSnapshot2 snapshot) where TSnapshot2 : class
@@ -145,4 +142,4 @@ public sealed class PersistenceStoreTests
Saves.Add(snapshot);
}
}
}
}

View File

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

View File

@@ -7,21 +7,28 @@ namespace Jibo.Cloud.Tests.Protocol;
public sealed class FileProtocolTelemetrySinkTests : IDisposable
{
private readonly string _workspaceRoot;
private readonly string _repoRoot;
private readonly string _appBaseDirectory;
private readonly string _repoRoot;
private readonly string _workspaceRoot;
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");
_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(_appBaseDirectory);
File.WriteAllText(Path.Combine(_repoRoot, "OpenJibo.slnx"), string.Empty);
}
public void Dispose()
{
if (Directory.Exists(_workspaceRoot)) Directory.Delete(_workspaceRoot, true);
}
[Fact]
public async Task RecordAsync_ResolvesRelativePathAgainstOpenJiboRepoRoot()
{
@@ -52,12 +59,4 @@ public sealed class FileProtocolTelemetrySinkTests : IDisposable
Assert.Contains("Notification_20150505", contents);
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
{
if (File.Exists(persistencePath))
{
File.Delete(persistencePath);
}
if (File.Exists(persistencePath)) File.Delete(persistencePath);
}
}
@@ -170,7 +167,8 @@ public sealed class JiboCloudProtocolServiceTests
});
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
{
@@ -226,7 +224,10 @@ public sealed class JiboCloudProtocolServiceTests
Assert.NotEmpty(people);
Assert.Contains(people, person => person.IsPrimary);
Assert.Contains(people, person => string.Equals(person.AccountId, store.GetAccount().AccountId, StringComparison.OrdinalIgnoreCase));
Assert.Contains(people, person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase));
Assert.Contains(people,
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

@@ -19,4 +19,4 @@ public sealed class ProtocolFixtureReplayTests
Assert.Equal(fixture.ExpectedStatusCode, result.StatusCode);
Assert.False(string.IsNullOrWhiteSpace(result.BodyText));
}
}
}

View File

@@ -106,7 +106,8 @@ public sealed class FileTurnTelemetrySinkTests
public async Task HandleContext_EmitsGlsmPhaseTransitionDiagnostic()
{
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);
var turnService = new WebSocketTurnFinalizationService(
Mock.Of<IConversationBroker>(),
@@ -142,8 +143,9 @@ public sealed class FileTurnTelemetrySinkTests
"glsm_phase_transition",
It.Is<IReadOnlyDictionary<string, object?>>(details =>
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>()),
Times.AtLeastOnce());
}
}
}

View File

@@ -8,15 +8,21 @@ namespace Jibo.Cloud.Tests.WebSockets;
public sealed class FileWebSocketTelemetrySinkTests : IDisposable
{
private readonly string _appBaseDirectory;
private readonly string _directoryPath;
private readonly string _repoRoot;
private readonly string _appBaseDirectory;
public FileWebSocketTelemetrySinkTests()
{
_directoryPath = Path.Combine(Path.GetTempPath(), "OpenJibo.Tests", Guid.NewGuid().ToString("N"));
_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]
@@ -55,17 +61,11 @@ public sealed class FileWebSocketTelemetrySinkTests : IDisposable
var fixtureDirectory = Path.Combine(_directoryPath, "fixtures");
var fixturePath = Directory.GetFiles(fixtureDirectory, "*.flow.json").Single();
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("LISTEN", document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString());
}
public void Dispose()
{
if (Directory.Exists(_directoryPath))
{
Directory.Delete(_directoryPath, true);
}
Assert.Equal("LISTEN",
document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString());
}
[Fact]

View File

@@ -1,9 +1,9 @@
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Infrastructure.Content;
using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Runtime.Abstractions;
using System.Text.Json;
namespace Jibo.Cloud.Tests.WebSockets;
@@ -56,9 +56,13 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("dance", decision.IntentName);
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.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]
@@ -198,7 +202,8 @@ public sealed class JiboInteractionServiceTests
{
["accountId"] = "acct-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"
});
@@ -222,7 +227,8 @@ public sealed class JiboInteractionServiceTests
{
["accountId"] = "acct-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"
});
@@ -250,7 +256,8 @@ public sealed class JiboInteractionServiceTests
{
["accountId"] = "acct-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"
});
@@ -287,7 +294,8 @@ public sealed class JiboInteractionServiceTests
{
["messageType"] = "TRIGGER",
["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",
["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",
["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")
}
});
@@ -427,13 +437,18 @@ public sealed class JiboInteractionServiceTests
}
[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("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("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 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.")]
[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 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(
string transcript,
string expectedIntent,
@@ -454,10 +469,13 @@ public sealed class JiboInteractionServiceTests
[Theory]
[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("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("where are you from", "robot_origin_from", "Some people think I come from the moon. But they're wrong, I'm from Boston.")]
[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("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(
string transcript,
string expectedIntent,
@@ -552,7 +570,8 @@ public sealed class JiboInteractionServiceTests
[InlineData("what are you up to", "being helpful")]
[InlineData("what are you doing", "making people smile")]
[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();
@@ -570,11 +589,14 @@ public sealed class JiboInteractionServiceTests
[Theory]
[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("what holidays do you celebrate", "seasonal_holidays", "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("what holidays do you celebrate", "seasonal_holidays",
"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("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")]
public async Task BuildDecisionAsync_SeasonalCharm_UsesImportedReplies(
string transcript,
@@ -672,7 +694,7 @@ public sealed class JiboInteractionServiceTests
try
{
File.WriteAllText(
await File.WriteAllTextAsync(
Path.Combine(rootDirectory, "gqa-responses", "GQA_JBO_IsHappy.mim"),
"""
{
@@ -710,7 +732,7 @@ public sealed class JiboInteractionServiceTests
}
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("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]
@@ -1726,12 +1749,18 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("personal_report_delivered", decision.IntentName);
Assert.Contains("Sure alex. Here it is.", 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("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(
"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("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("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.Equal("idle", decision.ContextUpdates![PersonalReportStateKey]);
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.Equal("awaiting_item", addDecision.ContextUpdates![HouseholdListStateKey]);
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
{
@@ -1871,7 +1901,8 @@ public sealed class JiboInteractionServiceTests
});
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]);
var recallDecision = await service.BuildDecisionAsync(new TurnContext
@@ -1924,7 +1955,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("todo_list_add", addDecision.IntentName);
Assert.Contains("call mom", 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
{
@@ -1939,7 +1971,8 @@ public sealed class JiboInteractionServiceTests
});
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]);
}
@@ -2057,7 +2090,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("weather", decision.IntentName);
Assert.Equal("chitchat-skill", decision.SkillName);
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.Equal("report-skill", decision.SkillPayload["skillId"]);
Assert.Equal("WeatherCommentRain", decision.SkillPayload["mim_id"]);
@@ -2068,7 +2102,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(54, decision.SkillPayload["weather_low"]);
Assert.Equal("F", decision.SkillPayload["weather_unit"]);
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.False(provider.LastRequest!.IsTomorrow);
Assert.Equal(0, provider.LastRequest.ForecastDayOffset);
@@ -2093,7 +2129,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.True(provider.LastRequest?.IsTomorrow);
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]
@@ -2115,7 +2153,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Seattle", provider.LastRequest?.LocationQuery);
Assert.False(provider.LastRequest?.IsTomorrow);
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]
@@ -2137,7 +2177,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Paris", provider.LastRequest?.LocationQuery);
Assert.False(provider.LastRequest?.IsTomorrow);
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]
@@ -2159,7 +2201,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Redmond Oregon", provider.LastRequest?.LocationQuery);
Assert.False(provider.LastRequest?.IsTomorrow);
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]
@@ -2181,7 +2225,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("New York City", provider.LastRequest?.LocationQuery);
Assert.True(provider.LastRequest?.IsTomorrow);
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]
@@ -2210,7 +2256,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(5, provider.Requests.Count);
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("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]
@@ -2228,7 +2275,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "what's the weather in chicago",
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.Null(provider.LastRequest?.Latitude);
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]
@@ -2266,7 +2316,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
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]
@@ -2296,7 +2348,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
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]
@@ -2335,9 +2389,7 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(true, decision.SkillPayload?["weather_view_enabled"]);
if (string.Equals(transcript, "what's the forecast", StringComparison.Ordinal))
{
Assert.Equal(5, provider.Requests.Count);
}
}
[Fact]
@@ -2366,7 +2418,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("weather", decision.IntentName);
Assert.Equal(2, provider.LastRequest?.ForecastDayOffset);
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]
@@ -2391,7 +2445,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("weather", decision.IntentName);
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
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]
@@ -2440,7 +2496,9 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("weather", decision.IntentName);
Assert.Equal("Paris", provider.LastRequest?.LocationQuery);
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]
@@ -2467,8 +2525,10 @@ public sealed class JiboInteractionServiceTests
Assert.Equal(5, provider.LastRequest?.ForecastDayOffset);
Assert.Equal(5, provider.Requests.Count);
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("Saturday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Tuesday: light rain, high 61, low 52.", decision.ReplyText,
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.NotNull(decision.SkillPayload);
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("Chicago", provider.LastRequest?.LocationQuery);
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]
@@ -2771,7 +2833,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "- Thank you. - Yes.",
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"],
["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}"""
}
@@ -2792,7 +2855,8 @@ public sealed class JiboInteractionServiceTests
NormalizedTranscript = "- Me. - Yes.",
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"]
}
});
@@ -3535,7 +3599,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("provider_success", decision.SkillPayload["news_provider_status"]);
Assert.Equal(3, decision.SkillPayload["news_provider_requested_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.Equal(3, provider.LastRequest!.MaxHeadlines);
}
@@ -3562,7 +3627,8 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("news", decision.IntentName);
Assert.NotNull(provider.LastRequest);
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]
@@ -3788,4 +3854,4 @@ public sealed class JiboInteractionServiceTests
return Task.FromResult(catalog);
}
}
}
}

View File

@@ -19,12 +19,12 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
new FakeExternalProcessRunner());
var turn = new TurnContext
{
Attributes = new Dictionary<string, object?>
{
Attributes = new Dictionary<string, object?>
{
["bufferedAudioFrames"] = new[] { BuildMinimalOggPage() }
}
};
["bufferedAudioFrames"] = new[] { BuildMinimalOggPage() }
}
};
Assert.False(strategy.CanHandle(turn));
}
@@ -119,10 +119,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
}
finally
{
if (Directory.Exists(tempDirectory))
{
Directory.Delete(tempDirectory, recursive: true);
}
if (Directory.Exists(tempDirectory)) Directory.Delete(tempDirectory, true);
}
}
@@ -139,7 +136,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
0x00, 0x00, 0x00, 0x00,
0x01,
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 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));
@@ -165,7 +164,6 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
var outputPath = arguments[^1];
File.WriteAllBytes(outputPath, "RIFF"u8);
return Task.FromResult(new ExternalProcessResult(0, string.Empty, string.Empty));
}
}
}
}