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,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;
}
}