refactors
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Jibo QR Generator</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Jibo QR Generator</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -122,57 +122,65 @@
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🤖 Jibo Wi-Fi QR Generator</h1>
|
||||
<p class="sub">Generates a QR code using Jibo's XOR encoding format</p>
|
||||
<span id="accessToken"></span>
|
||||
<span id="wifiConfig"></span>
|
||||
<div class="card">
|
||||
<label>SSID (Network Name)</label>
|
||||
<input id="ssid" placeholder="MyNetwork" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>🤖 Jibo Wi-Fi QR Generator</h1>
|
||||
<p class="sub">Generates a QR code using Jibo's XOR encoding format</p>
|
||||
<span id="accessToken"></span>
|
||||
<span id="wifiConfig"></span>
|
||||
<div class="card">
|
||||
<label>SSID (Network Name)</label>
|
||||
<input id="ssid" placeholder="MyNetwork"/>
|
||||
|
||||
<label>Password (leave blank for open network)</label>
|
||||
<input id="password" type="password" placeholder="••••••••" />
|
||||
<label>Password (leave blank for open network)</label>
|
||||
<input id="password" type="password" placeholder="••••••••"/>
|
||||
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="useStatic" onchange="toggleStatic()" />
|
||||
Use Static IP
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="useStatic" onchange="toggleStatic()"/>
|
||||
Use Static IP
|
||||
</label>
|
||||
|
||||
<div class="static-section" id="staticSection">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Static IP</label
|
||||
><input id="staticIP" placeholder="192.168.1.100" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Netmask</label
|
||||
><input id="netmask" placeholder="255.255.255.0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Gateway</label
|
||||
><input id="gateway" placeholder="192.168.1.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label>DNS 1</label><input id="dns1" placeholder="8.8.8.8" />
|
||||
</div>
|
||||
</div>
|
||||
<div><label>DNS 2</label><input id="dns2" placeholder="8.8.4.4" /></div>
|
||||
</div>
|
||||
<div class="static-section" id="staticSection">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>
|
||||
Static IP
|
||||
</label
|
||||
><input id="staticIP" placeholder="192.168.1.100"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Netmask
|
||||
</label
|
||||
><input id="netmask" placeholder="255.255.255.0"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>
|
||||
Gateway
|
||||
</label
|
||||
><input id="gateway" placeholder="192.168.1.1"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>DNS 1</label><input id="dns1" placeholder="8.8.8.8"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>DNS 2</label><input id="dns2" placeholder="8.8.4.4"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="generate()">Generate QR Code</button>
|
||||
</div>
|
||||
<button onclick="generate()">Generate QR Code</button>
|
||||
</div>
|
||||
|
||||
<div id="qr-out">
|
||||
<div id="qrdiv"></div>
|
||||
<button id="dl" onclick="download()">⬇ Download PNG</button>
|
||||
<p class="note">Scan with Jibo's app to configure Wi-Fi</p>
|
||||
</div>
|
||||
<div id="qr-out">
|
||||
<div id="qrdiv"></div>
|
||||
<button id="dl" onclick="download()">⬇ Download PNG</button>
|
||||
<p class="note">Scan with Jibo's app to configure Wi-Fi</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script>
|
||||
function toggleStatic() {
|
||||
document.getElementById("staticSection").style.display =
|
||||
document.getElementById("useStatic").checked ? "block" : "none";
|
||||
@@ -302,5 +310,5 @@ e!Ekiaon*%O?'O`);
|
||||
return wifiConfig;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,4 @@ public sealed record NewsBriefingSnapshot(
|
||||
string? ProviderMessage = null,
|
||||
int? ProviderHttpStatusCode = null,
|
||||
string? ProviderEndpoint = null,
|
||||
string? ProviderErrorCode = null);
|
||||
string? ProviderErrorCode = null);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,4 @@ public sealed record WeatherReportSnapshot(
|
||||
int? HighTemperature,
|
||||
int? LowTemperature,
|
||||
string? Condition,
|
||||
bool UseCelsius);
|
||||
bool UseCelsius);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,4 @@ public sealed class DefaultJiboRandomizer : IJiboRandomizer
|
||||
? throw new InvalidOperationException("Cannot choose from an empty list.")
|
||||
: items[Random.Shared.Next(items.Count)];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' />")}.";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
@@ -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?>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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?>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,4 @@ public sealed class UpdateManifest
|
||||
public long Length { get; init; }
|
||||
public string Subsystem { get; init; } = "robot";
|
||||
public string? Filter { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@ public sealed class WebSocketReply
|
||||
public string? Text { get; init; }
|
||||
public int DelayMs { get; init; }
|
||||
public bool Close { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,4 @@ public sealed class BufferedAudioSttOptions
|
||||
public string WhisperLanguage { get; set; } = "en";
|
||||
public string? TempDirectory { get; set; }
|
||||
public bool CleanupTempFiles { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,4 @@ public sealed class NewsApiOptions
|
||||
public int CacheTtlSeconds { get; set; } = 300;
|
||||
|
||||
public int FailureCacheTtlSeconds { get; set; } = 45;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,4 +4,4 @@ public interface ISnapshotStore
|
||||
{
|
||||
TSnapshot? Load<TSnapshot>() where TSnapshot : class;
|
||||
void Save<TSnapshot>(TSnapshot snapshot) where TSnapshot : class;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@ public enum PersistenceBackendKind
|
||||
File,
|
||||
AzureBlob,
|
||||
AzureSql
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Jibo.Cloud.Tests")]
|
||||
[assembly: InternalsVisibleTo("Jibo.Cloud.Tests")]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,4 @@ public sealed class ProtocolTelemetryOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string DirectoryPath { get; set; } = "captures/http";
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,4 @@ public sealed class OpenWeatherOptions
|
||||
public int GeocodeCacheTtlSeconds { get; set; } = 21600;
|
||||
|
||||
public int FailureCacheTtlSeconds { get; set; } = 45;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ public interface IBrainStrategy
|
||||
{
|
||||
string Name { get; }
|
||||
bool CanHandle(TurnContext turn, ConversationSession session);
|
||||
|
||||
Task<BrainDecision> DecideAsync(
|
||||
TurnContext turn,
|
||||
ConversationSession session,
|
||||
|
||||
@@ -18,4 +18,4 @@ public sealed class ResponsePlan
|
||||
public string? DebugRoute { get; init; }
|
||||
public IDictionary<string, object?> Diagnostics { get; init; } = new Dictionary<string, object?>();
|
||||
public IDictionary<string, object?> ProtocolMetadata { get; init; } = new Dictionary<string, object?>();
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,4 @@ public sealed class RobotEvent
|
||||
public string? ApplicationVersion { get; init; }
|
||||
|
||||
public IDictionary<string, object?> Payload { get; init; } = new Dictionary<string, object?>();
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,4 @@ public sealed class TurnContext
|
||||
|
||||
public bool IsFollowUpEligible { get; init; }
|
||||
public IDictionary<string, object?> Attributes { get; init; } = new Dictionary<string, object?>();
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpenJibo</title>
|
||||
<link rel="stylesheet" href="site.css">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpenJibo</title>
|
||||
<link rel="stylesheet" href="site.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">OpenJibo</p>
|
||||
<h1>Bringing Jibo back with a stable open cloud.</h1>
|
||||
<p class="lede">
|
||||
OpenJibo is rebuilding the hosted layer Jibo still expects, then using that foothold
|
||||
to modernize the platform step by step.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="https://github.com/" class="button primary">Source Repos</a>
|
||||
<a href="../../docs/device-bootstrap.md" class="button secondary">Bootstrap Docs</a>
|
||||
</div>
|
||||
</section>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">OpenJibo</p>
|
||||
<h1>Bringing Jibo back with a stable open cloud.</h1>
|
||||
<p class="lede">
|
||||
OpenJibo is rebuilding the hosted layer Jibo still expects, then using that foothold
|
||||
to modernize the platform step by step.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="https://github.com/" class="button primary">Source Repos</a>
|
||||
<a href="../../docs/device-bootstrap.md" class="button secondary">Bootstrap Docs</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Current Direction</h2>
|
||||
<p>
|
||||
The first milestone is a stable Azure-hosted replacement cloud. We support real robots
|
||||
first through controlled DNS plus targeted device patching, then smooth the path later with OTA.
|
||||
</p>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Current Direction</h2>
|
||||
<p>
|
||||
The first milestone is a stable Azure-hosted replacement cloud. We support real robots
|
||||
first through controlled DNS plus targeted device patching, then smooth the path later with OTA.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="card">
|
||||
<h3>Cloud First</h3>
|
||||
<p>Replace the missing hosted services before attempting deeper on-device modernization.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Real Hardware</h3>
|
||||
<p>Use repeatable device bootstrap steps instead of pretending recovery is already one-click.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Open Path</h3>
|
||||
<p>Keep the protocol work, docs, and codebase visible so the community can iterate with us.</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
<section class="grid">
|
||||
<article class="card">
|
||||
<h3>Cloud First</h3>
|
||||
<p>Replace the missing hosted services before attempting deeper on-device modernization.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Real Hardware</h3>
|
||||
<p>Use repeatable device bootstrap steps instead of pretending recovery is already one-click.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Open Path</h3>
|
||||
<p>Keep the protocol work, docs, and codebase visible so the community can iterate with us.</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -4,15 +4,11 @@ namespace Playground;
|
||||
|
||||
public sealed class AsrEvent
|
||||
{
|
||||
[JsonPropertyName("event_type")]
|
||||
public string? EventType { get; set; }
|
||||
[JsonPropertyName("event_type")] public string? EventType { get; set; }
|
||||
|
||||
[JsonPropertyName("task_id")]
|
||||
public string? TaskId { get; set; }
|
||||
[JsonPropertyName("task_id")] public string? TaskId { get; set; }
|
||||
|
||||
[JsonPropertyName("request_id")]
|
||||
public string? RequestId { get; set; }
|
||||
[JsonPropertyName("request_id")] public string? RequestId { get; set; }
|
||||
|
||||
[JsonPropertyName("utterances")]
|
||||
public List<AsrUtterance>? Utterances { get; set; }
|
||||
[JsonPropertyName("utterances")] public List<AsrUtterance>? Utterances { get; set; }
|
||||
}
|
||||
@@ -4,9 +4,7 @@ namespace Playground;
|
||||
|
||||
public sealed class AsrUtterance
|
||||
{
|
||||
[JsonPropertyName("utterance")]
|
||||
public string? Utterance { get; set; }
|
||||
[JsonPropertyName("utterance")] public string? Utterance { get; set; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; set; }
|
||||
[JsonPropertyName("score")] public double Score { get; set; }
|
||||
}
|
||||
@@ -62,8 +62,7 @@ while (!cts.IsCancellationRequested)
|
||||
}
|
||||
|
||||
ms.Write(buffer, 0, result.Count);
|
||||
}
|
||||
while (!result.EndOfMessage);
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
var json = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
|
||||
@@ -14,20 +14,25 @@ public sealed class LegacyMimCatalogImporterTests
|
||||
{
|
||||
var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory);
|
||||
|
||||
Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.", catalog.GenericFallbackReplies);
|
||||
Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.",
|
||||
catalog.GenericFallbackReplies);
|
||||
Assert.Contains("I think only you can answer that question.", catalog.PersonalityReplies);
|
||||
Assert.Contains("Jibo. Just Jibo, no last name. Like Bono", catalog.PersonalityReplies);
|
||||
Assert.Contains("No, I'm one in one million.", catalog.PersonalityReplies);
|
||||
Assert.Contains("I know a lot, I think. But not as much as I will someday.", catalog.PersonalityReplies);
|
||||
Assert.Contains("I don't think of it as a job, because it's more fun than a job. But I'm here to help you out, and have fun with you, and maybe get my head patted by you occasionally.", catalog.PersonalityReplies);
|
||||
Assert.Contains(
|
||||
"I don't think of it as a job, because it's more fun than a job. But I'm here to help you out, and have fun with you, and maybe get my head patted by you occasionally.",
|
||||
catalog.PersonalityReplies);
|
||||
Assert.Contains(catalog.EmotionReplies, reply =>
|
||||
reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase) &&
|
||||
reply.Reply.Contains("All systems are go.", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("A Jibo is a robot. But I'm not just a machine, I have a heart. Well, not a real heart. But feelings. Well, not human feelings. You know what I mean.", catalog.PersonalityReplies);
|
||||
Assert.Contains(
|
||||
"A Jibo is a robot. But I'm not just a machine, I have a heart. Well, not a real heart. But feelings. Well, not human feelings. You know what I mean.",
|
||||
catalog.PersonalityReplies);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(rootDirectory, recursive: true);
|
||||
Directory.Delete(rootDirectory, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +75,7 @@ public sealed class LegacyMimCatalogImporterTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(rootDirectory, recursive: true);
|
||||
Directory.Delete(rootDirectory, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,12 +92,14 @@ public sealed class LegacyMimCatalogImporterTests
|
||||
|
||||
Assert.Contains("The only thing I consume is electricity.", catalog.PersonalityReplies);
|
||||
Assert.Contains("Unless I missed something, we're in my home as we speak.", catalog.PersonalityReplies);
|
||||
Assert.Contains("For now just English. But someday I'd like to learn more. I like languages.", catalog.PersonalityReplies);
|
||||
Assert.Contains("For now just English. But someday I'd like to learn more. I like languages.",
|
||||
catalog.PersonalityReplies);
|
||||
Assert.Contains("I was put together in a factory piece by piece.", catalog.PersonalityReplies);
|
||||
Assert.Contains("I really like sunflowers.", catalog.PersonalityReplies);
|
||||
Assert.Contains("Ha. Of course I know R2D2. I mean, not personally.", catalog.PersonalityReplies);
|
||||
Assert.Contains("Yes! I like all things in space. They're so spacey.", catalog.PersonalityReplies);
|
||||
Assert.Contains("Yes yes, I think kids are great. They're a little closer to my size.", catalog.PersonalityReplies);
|
||||
Assert.Contains("Yes yes, I think kids are great. They're a little closer to my size.",
|
||||
catalog.PersonalityReplies);
|
||||
Assert.Contains(catalog.PersonalityReplies, reply =>
|
||||
reply.Contains("I do things like this when I'm happy", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(catalog.PersonalityReplies, reply =>
|
||||
@@ -129,7 +136,8 @@ public sealed class LegacyMimCatalogImporterTests
|
||||
|
||||
var catalog = LegacyMimCatalogImporter.ImportCatalog(rootDirectory);
|
||||
|
||||
Assert.Contains("Well I definitely try to be the kindest robot I can be. So I hope so.", catalog.PersonalityReplies);
|
||||
Assert.Contains("Well I definitely try to be the kindest robot I can be. So I hope so.",
|
||||
catalog.PersonalityReplies);
|
||||
Assert.Contains("I don't think so, not intentionally.", catalog.PersonalityReplies);
|
||||
Assert.Contains(catalog.PersonalityReplies, reply =>
|
||||
reply.Contains("make people laugh", StringComparison.OrdinalIgnoreCase));
|
||||
@@ -208,12 +216,18 @@ public sealed class LegacyMimCatalogImporterTests
|
||||
|
||||
Assert.Contains("First let's check in with the meteorology department.", catalog.WeatherIntroReplies);
|
||||
Assert.Contains("First, the weather tomorrow.", catalog.WeatherTomorrowIntroReplies);
|
||||
Assert.Contains("Today's high is ${skill.weather.today.highTemp}, and the low is ${skill.weather.today.lowTemp}.", catalog.WeatherTodayHighLowReplies);
|
||||
Assert.Contains("Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}.", catalog.WeatherTomorrowHighLowReplies);
|
||||
Assert.Contains(
|
||||
"Today's high is ${skill.weather.today.highTemp}, and the low is ${skill.weather.today.lowTemp}.",
|
||||
catalog.WeatherTodayHighLowReplies);
|
||||
Assert.Contains(
|
||||
"Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}.",
|
||||
catalog.WeatherTomorrowHighLowReplies);
|
||||
Assert.Contains("Looks like our weather service is offline. Sorry.", catalog.WeatherServiceDownReplies);
|
||||
Assert.Contains("Sure ${speaker}. Here it is.", catalog.PersonalReportKickOffReplies);
|
||||
Assert.Contains("And that's your report for the day. I hope you had as much fun as I did.", catalog.PersonalReportOutroReplies);
|
||||
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", catalog.CalendarNothingTodayReplies);
|
||||
Assert.Contains("And that's your report for the day. I hope you had as much fun as I did.",
|
||||
catalog.PersonalReportOutroReplies);
|
||||
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.",
|
||||
catalog.CalendarNothingTodayReplies);
|
||||
Assert.Contains("Sorry, commute information isn't available right now.", catalog.CommuteServiceDownReplies);
|
||||
Assert.Contains("Here's today's news, from the associated press.", catalog.NewsIntroReplies);
|
||||
Assert.Contains("And that's what's new in the news.", catalog.NewsOutroReplies);
|
||||
@@ -245,7 +259,7 @@ public sealed class LegacyMimCatalogImporterTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(rootDirectory, recursive: true);
|
||||
Directory.Delete(rootDirectory, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,10 +273,12 @@ public sealed class LegacyMimCatalogImporterTests
|
||||
Assert.Contains("I think only you can answer that question.", catalog.PersonalityReplies);
|
||||
Assert.Contains(catalog.EmotionReplies, reply =>
|
||||
reply.Condition.Contains("NEUTRAL", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.", catalog.GenericFallbackReplies);
|
||||
Assert.Contains("Something's off with the connection to my sources. Maybe ask me again in a little while.",
|
||||
catalog.GenericFallbackReplies);
|
||||
Assert.Contains("For your weather.", catalog.WeatherIntroReplies);
|
||||
Assert.Contains("Today's high is {high}, and the low is {low}.", catalog.WeatherTodayHighLowReplies);
|
||||
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", catalog.CalendarNothingTodayReplies);
|
||||
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.",
|
||||
catalog.CalendarNothingTodayReplies);
|
||||
}
|
||||
|
||||
private static string CreateSeedDirectory()
|
||||
@@ -449,4 +465,4 @@ public sealed class LegacyMimCatalogImporterTests
|
||||
|
||||
return rootDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,8 @@ internal static class ProtocolFixtureLoader
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (root.TryGetProperty("headers", out var headerElement) && headerElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in headerElement.EnumerateObject())
|
||||
{
|
||||
headers[property.Name] = property.Value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
var bodyText = root.TryGetProperty("body", out var bodyElement)
|
||||
? bodyElement.GetRawText()
|
||||
@@ -34,8 +30,12 @@ internal static class ProtocolFixtureLoader
|
||||
Name = Path.GetFileNameWithoutExtension(relativePath),
|
||||
Request = new ProtocolEnvelope
|
||||
{
|
||||
HostName = root.TryGetProperty("host", out var hostElement) ? hostElement.GetString() ?? "api.jibo.com" : "api.jibo.com",
|
||||
Method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() ?? "POST" : "POST",
|
||||
HostName = root.TryGetProperty("host", out var hostElement)
|
||||
? hostElement.GetString() ?? "api.jibo.com"
|
||||
: "api.jibo.com",
|
||||
Method = root.TryGetProperty("method", out var methodElement)
|
||||
? methodElement.GetString() ?? "POST"
|
||||
: "POST",
|
||||
Path = root.TryGetProperty("path", out var pathElement) ? pathElement.GetString() ?? "/" : "/",
|
||||
Headers = headers,
|
||||
ServicePrefix = targetParts.Length > 0 ? targetParts[0] : null,
|
||||
@@ -45,4 +45,4 @@ internal static class ProtocolFixtureLoader
|
||||
ExpectedStatusCode = 200
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,23 +28,31 @@ internal static class WebSocketFixtureLoader
|
||||
Kind = session.GetProperty("kind").GetString() ?? "neo-hub-listen",
|
||||
Token = session.GetProperty("token").GetString(),
|
||||
Text = stepElement.TryGetProperty("text", out var text) ? text.GetRawText() : null,
|
||||
Binary = stepElement.TryGetProperty("binary", out var binary) && binary.ValueKind == JsonValueKind.Array
|
||||
Binary = stepElement.TryGetProperty("binary", out var binary) &&
|
||||
binary.ValueKind == JsonValueKind.Array
|
||||
? binary.EnumerateArray().Select(item => (byte)item.GetInt32()).ToArray()
|
||||
: null
|
||||
},
|
||||
ExpectedReplyTypes = [.. stepElement.GetProperty("expectedReplyTypes")
|
||||
.EnumerateArray()
|
||||
.Select(item => item.GetString() ?? string.Empty)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))],
|
||||
ExpectedReplies = stepElement.TryGetProperty("expectedReplies", out var expectedReplies) && expectedReplies.ValueKind == JsonValueKind.Array
|
||||
? JsonSerializer.Deserialize<List<ExpectedWebSocketReply>>(expectedReplies.GetRawText(), SerializerOptions) ?? []
|
||||
ExpectedReplyTypes =
|
||||
[
|
||||
.. stepElement.GetProperty("expectedReplyTypes")
|
||||
.EnumerateArray()
|
||||
.Select(item => item.GetString() ?? string.Empty)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
],
|
||||
ExpectedReplies = stepElement.TryGetProperty("expectedReplies", out var expectedReplies) &&
|
||||
expectedReplies.ValueKind == JsonValueKind.Array
|
||||
? JsonSerializer.Deserialize<List<ExpectedWebSocketReply>>(expectedReplies.GetRawText(),
|
||||
SerializerOptions) ?? []
|
||||
: []
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new WebSocketFixture
|
||||
{
|
||||
Name = root.TryGetProperty("name", out var name) ? name.GetString() ?? Path.GetFileNameWithoutExtension(relativePath) : Path.GetFileNameWithoutExtension(relativePath),
|
||||
Name = root.TryGetProperty("name", out var name)
|
||||
? name.GetString() ?? Path.GetFileNameWithoutExtension(relativePath)
|
||||
: Path.GetFileNameWithoutExtension(relativePath),
|
||||
Steps = steps
|
||||
};
|
||||
}
|
||||
@@ -68,4 +76,4 @@ internal sealed class ExpectedWebSocketReply
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public int? DelayMs { get; init; }
|
||||
public JsonElement? JsonSubset { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
global using Xunit;
|
||||
global using Xunit;
|
||||
@@ -10,13 +10,12 @@ public sealed class AzureBlobPersistenceSmokeTests
|
||||
var stateBackend = Environment.GetEnvironmentVariable("OpenJibo__State__Backend");
|
||||
var stateConnectionString = Environment.GetEnvironmentVariable("OpenJibo__State__ConnectionString");
|
||||
var personalMemoryBackend = Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__Backend");
|
||||
var personalMemoryConnectionString = Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__ConnectionString");
|
||||
var personalMemoryConnectionString =
|
||||
Environment.GetEnvironmentVariable("OpenJibo__PersonalMemory__ConnectionString");
|
||||
|
||||
if (!string.Equals(stateBackend, "AzureBlob", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.IsNullOrWhiteSpace(stateConnectionString))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var factory = new PersistenceSnapshotStoreFactory();
|
||||
var snapshotName = $"smoke-{Guid.NewGuid():N}";
|
||||
@@ -45,4 +44,4 @@ public sealed class AzureBlobPersistenceSmokeTests
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string Value { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ public sealed class PersistenceStoreTests
|
||||
{
|
||||
var factory = new PersistenceSnapshotStoreFactory();
|
||||
|
||||
var store = factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), PersistenceBackendKind.File, "sample-snapshot");
|
||||
var store = factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"),
|
||||
PersistenceBackendKind.File, "sample-snapshot");
|
||||
|
||||
Assert.Equal("JsonFileSnapshotStore", store.GetType().Name);
|
||||
}
|
||||
@@ -21,7 +22,8 @@ public sealed class PersistenceStoreTests
|
||||
var factory = new PersistenceSnapshotStoreFactory();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"), PersistenceBackendKind.AzureSql, "sample-snapshot"));
|
||||
factory.Create(Path.Combine(Path.GetTempPath(), $"factory-{Guid.NewGuid():N}.json"),
|
||||
PersistenceBackendKind.AzureSql, "sample-snapshot"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -86,10 +88,7 @@ public sealed class PersistenceStoreTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(persistencePath))
|
||||
{
|
||||
File.Delete(persistencePath);
|
||||
}
|
||||
if (File.Exists(persistencePath)) File.Delete(persistencePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +101,8 @@ public sealed class PersistenceStoreTests
|
||||
{
|
||||
var firstStore = new InMemoryCloudStateStore(persistencePath);
|
||||
var update = firstStore.CreateUpdate("1.0.0", "1.0.1", "Bug fix", null, 42, "robot", null, null);
|
||||
var media = firstStore.CreateMedia("openjibo-default-loop", "persisted-photo", "image", "photo-ref", false, new Dictionary<string, object?> { ["note"] = "roundtrip" });
|
||||
var media = firstStore.CreateMedia("openjibo-default-loop", "persisted-photo", "image", "photo-ref", false,
|
||||
new Dictionary<string, object?> { ["note"] = "roundtrip" });
|
||||
var sessionToken = firstStore.IssueRobotToken("robot-123");
|
||||
var device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6");
|
||||
firstStore.SavePersistedState();
|
||||
@@ -124,10 +124,7 @@ public sealed class PersistenceStoreTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(persistencePath))
|
||||
{
|
||||
File.Delete(persistencePath);
|
||||
}
|
||||
if (File.Exists(persistencePath)) File.Delete(persistencePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +134,7 @@ public sealed class PersistenceStoreTests
|
||||
|
||||
public TSnapshot2? Load<TSnapshot2>() where TSnapshot2 : class
|
||||
{
|
||||
return default;
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Save<TSnapshot2>(TSnapshot2 snapshot) where TSnapshot2 : class
|
||||
@@ -145,4 +142,4 @@ public sealed class PersistenceStoreTests
|
||||
Saves.Add(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Infrastructure.News;
|
||||
@@ -93,7 +92,8 @@ public sealed class ProviderCachingTests
|
||||
},
|
||||
NullLogger<OpenWeatherReportProvider>.Instance);
|
||||
|
||||
var report = await provider.GetReportAsync(new WeatherReportRequest("Lone Jack,US", null, null, false, false, 0));
|
||||
var report =
|
||||
await provider.GetReportAsync(new WeatherReportRequest("Lone Jack,US", null, null, false, false, 0));
|
||||
|
||||
Assert.NotNull(report);
|
||||
Assert.Equal(76, report!.Temperature);
|
||||
@@ -202,9 +202,7 @@ public sealed class ProviderCachingTests
|
||||
{
|
||||
if (!message.Headers.TryGetValues("User-Agent", out var userAgents) ||
|
||||
!userAgents.Any())
|
||||
{
|
||||
missingUserAgentRequestCount += 1;
|
||||
}
|
||||
|
||||
var path = message.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
return path switch
|
||||
@@ -224,7 +222,7 @@ public sealed class ProviderCachingTests
|
||||
},
|
||||
NullLogger<NewsApiBriefingProvider>.Instance);
|
||||
|
||||
var request = new NewsBriefingRequest(["sports"], 3);
|
||||
var request = new NewsBriefingRequest(["sports"]);
|
||||
var first = await provider.GetBriefingAsync(request);
|
||||
var second = await provider.GetBriefingAsync(request);
|
||||
|
||||
@@ -241,18 +239,12 @@ public sealed class ProviderCachingTests
|
||||
{
|
||||
var path = message.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
var query = message.RequestUri?.Query ?? string.Empty;
|
||||
if (query.Contains("category=sports", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JsonResponse("""{"status":"ok","articles":[]}""");
|
||||
}
|
||||
|
||||
return JsonResponse(
|
||||
"""{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}""");
|
||||
return JsonResponse(query.Contains("category=sports", StringComparison.OrdinalIgnoreCase)
|
||||
? """{"status":"ok","articles":[]}"""
|
||||
: """{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}""");
|
||||
});
|
||||
var provider = new NewsApiBriefingProvider(
|
||||
new HttpClient(handler),
|
||||
@@ -264,7 +256,7 @@ public sealed class ProviderCachingTests
|
||||
},
|
||||
NullLogger<NewsApiBriefingProvider>.Instance);
|
||||
|
||||
var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"], 3));
|
||||
var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"]));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result!.Headlines);
|
||||
@@ -297,7 +289,7 @@ public sealed class ProviderCachingTests
|
||||
},
|
||||
NullLogger<NewsApiBriefingProvider>.Instance);
|
||||
|
||||
var result = await provider.GetBriefingAsync(new NewsBriefingRequest([], 3));
|
||||
var result = await provider.GetBriefingAsync(new NewsBriefingRequest([]));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result!.Headlines);
|
||||
@@ -313,13 +305,10 @@ public sealed class ProviderCachingTests
|
||||
{
|
||||
var path = message.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
if (!string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
var query = message.RequestUri?.Query ?? string.Empty;
|
||||
if (query.Contains("category=sports", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent(
|
||||
@@ -327,7 +316,6 @@ public sealed class ProviderCachingTests
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
return JsonResponse(
|
||||
"""{"status":"ok","articles":[{"title":"General robotics update","description":"Top story","source":{"name":"AP News"},"url":"https://example.com/general"}]}""");
|
||||
@@ -342,7 +330,7 @@ public sealed class ProviderCachingTests
|
||||
},
|
||||
NullLogger<NewsApiBriefingProvider>.Instance);
|
||||
|
||||
var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"], 3));
|
||||
var result = await provider.GetBriefingAsync(new NewsBriefingRequest(["sports"]));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result!.Headlines);
|
||||
@@ -358,7 +346,6 @@ public sealed class ProviderCachingTests
|
||||
{
|
||||
var path = message.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
if (string.Equals(path, "/v2/top-headlines", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent(
|
||||
@@ -366,10 +353,8 @@ public sealed class ProviderCachingTests
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(path, "/v2/everything", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.BadRequest)
|
||||
{
|
||||
Content = new StringContent(
|
||||
@@ -377,7 +362,6 @@ public sealed class ProviderCachingTests
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
});
|
||||
@@ -392,7 +376,7 @@ public sealed class ProviderCachingTests
|
||||
},
|
||||
NullLogger<NewsApiBriefingProvider>.Instance);
|
||||
|
||||
var result = await provider.GetBriefingAsync(new NewsBriefingRequest([], 3));
|
||||
var result = await provider.GetBriefingAsync(new NewsBriefingRequest([]));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result!.Headlines);
|
||||
@@ -414,14 +398,14 @@ public sealed class ProviderCachingTests
|
||||
private sealed class CountingHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<string, int> callsByPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<string, int> _callsByPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
public int GetCallCount(string path)
|
||||
{
|
||||
lock (gate)
|
||||
lock (_gate)
|
||||
{
|
||||
return callsByPath.TryGetValue(path, out var count) ? count : 0;
|
||||
return _callsByPath.GetValueOrDefault(path, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,12 +414,12 @@ public sealed class ProviderCachingTests
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
lock (gate)
|
||||
lock (_gate)
|
||||
{
|
||||
callsByPath[path] = callsByPath.TryGetValue(path, out var count) ? count + 1 : 1;
|
||||
_callsByPath[path] = _callsByPath.TryGetValue(path, out var count) ? count + 1 : 1;
|
||||
}
|
||||
|
||||
return Task.FromResult(responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,21 +7,28 @@ namespace Jibo.Cloud.Tests.Protocol;
|
||||
|
||||
public sealed class FileProtocolTelemetrySinkTests : IDisposable
|
||||
{
|
||||
private readonly string _workspaceRoot;
|
||||
private readonly string _repoRoot;
|
||||
private readonly string _appBaseDirectory;
|
||||
private readonly string _repoRoot;
|
||||
private readonly string _workspaceRoot;
|
||||
|
||||
public FileProtocolTelemetrySinkTests()
|
||||
{
|
||||
_workspaceRoot = Path.Combine(Path.GetTempPath(), "OpenJibo.ProtocolTelemetry.Tests", Guid.NewGuid().ToString("N"));
|
||||
_workspaceRoot = Path.Combine(Path.GetTempPath(), "OpenJibo.ProtocolTelemetry.Tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
_repoRoot = Path.Combine(_workspaceRoot, "OpenJibo");
|
||||
_appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin", "Debug", "net10.0");
|
||||
_appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin",
|
||||
"Debug", "net10.0");
|
||||
|
||||
Directory.CreateDirectory(_repoRoot);
|
||||
Directory.CreateDirectory(_appBaseDirectory);
|
||||
File.WriteAllText(Path.Combine(_repoRoot, "OpenJibo.slnx"), string.Empty);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_workspaceRoot)) Directory.Delete(_workspaceRoot, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordAsync_ResolvesRelativePathAgainstOpenJiboRepoRoot()
|
||||
{
|
||||
@@ -52,12 +59,4 @@ public sealed class FileProtocolTelemetrySinkTests : IDisposable
|
||||
Assert.Contains("Notification_20150505", contents);
|
||||
Assert.DoesNotContain(Path.Combine("bin", "Debug"), captureFile, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_workspaceRoot))
|
||||
{
|
||||
Directory.Delete(_workspaceRoot, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,10 +144,7 @@ public sealed class JiboCloudProtocolServiceTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(persistencePath))
|
||||
{
|
||||
File.Delete(persistencePath);
|
||||
}
|
||||
if (File.Exists(persistencePath)) File.Delete(persistencePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +167,8 @@ public sealed class JiboCloudProtocolServiceTests
|
||||
});
|
||||
|
||||
using var createdPayload = JsonDocument.Parse(result.BodyText);
|
||||
Assert.Equal("https://api.jibo.com/media/photo-blob-1", createdPayload.RootElement.GetProperty("url").GetString());
|
||||
Assert.Equal("https://api.jibo.com/media/photo-blob-1",
|
||||
createdPayload.RootElement.GetProperty("url").GetString());
|
||||
|
||||
var mediaGet = await _service.DispatchAsync(new ProtocolEnvelope
|
||||
{
|
||||
@@ -226,7 +224,10 @@ public sealed class JiboCloudProtocolServiceTests
|
||||
|
||||
Assert.NotEmpty(people);
|
||||
Assert.Contains(people, person => person.IsPrimary);
|
||||
Assert.Contains(people, person => string.Equals(person.AccountId, store.GetAccount().AccountId, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(people, person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(people,
|
||||
person => string.Equals(person.AccountId, store.GetAccount().AccountId,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(people,
|
||||
person => string.Equals(person.LoopId, store.GetLoops()[0].LoopId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,4 @@ public sealed class ProtocolFixtureReplayTests
|
||||
Assert.Equal(fixture.ExpectedStatusCode, result.StatusCode);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.BodyText));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,8 @@ public sealed class FileTurnTelemetrySinkTests
|
||||
public async Task HandleContext_EmitsGlsmPhaseTransitionDiagnostic()
|
||||
{
|
||||
var sink = new Mock<ITurnTelemetrySink>();
|
||||
sink.Setup(s => s.RecordTurnDiagnosticAsync(It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()))
|
||||
sink.Setup(s => s.RecordTurnDiagnosticAsync(It.IsAny<string>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
var turnService = new WebSocketTurnFinalizationService(
|
||||
Mock.Of<IConversationBroker>(),
|
||||
@@ -142,8 +143,9 @@ public sealed class FileTurnTelemetrySinkTests
|
||||
"glsm_phase_transition",
|
||||
It.Is<IReadOnlyDictionary<string, object?>>(details =>
|
||||
details.ContainsKey("state") &&
|
||||
string.Equals(details["state"] == null ? null : details["state"]!.ToString(), "LISTENING", StringComparison.OrdinalIgnoreCase)),
|
||||
string.Equals(details["state"] == null ? null : details["state"]!.ToString(), "LISTENING",
|
||||
StringComparison.OrdinalIgnoreCase)),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,21 @@ namespace Jibo.Cloud.Tests.WebSockets;
|
||||
|
||||
public sealed class FileWebSocketTelemetrySinkTests : IDisposable
|
||||
{
|
||||
private readonly string _appBaseDirectory;
|
||||
private readonly string _directoryPath;
|
||||
private readonly string _repoRoot;
|
||||
private readonly string _appBaseDirectory;
|
||||
|
||||
public FileWebSocketTelemetrySinkTests()
|
||||
{
|
||||
_directoryPath = Path.Combine(Path.GetTempPath(), "OpenJibo.Tests", Guid.NewGuid().ToString("N"));
|
||||
_repoRoot = Path.Combine(_directoryPath, "OpenJibo");
|
||||
_appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin", "Debug", "net10.0");
|
||||
_appBaseDirectory = Path.Combine(_repoRoot, "src", "Jibo.Cloud", "dotnet", "src", "Jibo.Cloud.Api", "bin",
|
||||
"Debug", "net10.0");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directoryPath)) Directory.Delete(_directoryPath, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -55,17 +61,11 @@ public sealed class FileWebSocketTelemetrySinkTests : IDisposable
|
||||
var fixtureDirectory = Path.Combine(_directoryPath, "fixtures");
|
||||
var fixturePath = Directory.GetFiles(fixtureDirectory, "*.flow.json").Single();
|
||||
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(fixturePath));
|
||||
Assert.Equal("neo-hub.jibo.com", document.RootElement.GetProperty("session").GetProperty("hostName").GetString());
|
||||
Assert.Equal("neo-hub.jibo.com",
|
||||
document.RootElement.GetProperty("session").GetProperty("hostName").GetString());
|
||||
Assert.Equal(1, document.RootElement.GetProperty("steps").GetArrayLength());
|
||||
Assert.Equal("LISTEN", document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directoryPath))
|
||||
{
|
||||
Directory.Delete(_directoryPath, true);
|
||||
}
|
||||
Assert.Equal("LISTEN",
|
||||
document.RootElement.GetProperty("steps")[0].GetProperty("expectedReplyTypes")[0].GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Text.Json;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Application.Services;
|
||||
using Jibo.Cloud.Infrastructure.Content;
|
||||
using Jibo.Cloud.Infrastructure.Persistence;
|
||||
using Jibo.Runtime.Abstractions;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Jibo.Cloud.Tests.WebSockets;
|
||||
|
||||
@@ -56,9 +56,13 @@ public sealed class JiboInteractionServiceTests
|
||||
|
||||
Assert.Equal("dance", decision.IntentName);
|
||||
Assert.Equal("chitchat-skill", decision.SkillName);
|
||||
var catalog = await new InMemoryJiboExperienceContentRepository().GetCatalogAsync(); // Ensure catalog is loaded for test coverage
|
||||
var catalog =
|
||||
await new InMemoryJiboExperienceContentRepository()
|
||||
.GetCatalogAsync(); // Ensure catalog is loaded for test coverage
|
||||
Assert.Contains(decision.ReplyText, catalog.DanceReplies);
|
||||
Assert.Equal("<speak>Okay.<break size='0.2'/> Watch this.<anim cat='dance' filter='music, rom-upbeat' /></speak>", decision.SkillPayload!["esml"]);
|
||||
Assert.Equal(
|
||||
"<speak>Okay.<break size='0.2'/> Watch this.<anim cat='dance' filter='music, rom-upbeat' /></speak>",
|
||||
decision.SkillPayload!["esml"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -198,7 +202,8 @@ public sealed class JiboInteractionServiceTests
|
||||
{
|
||||
["accountId"] = "acct-a",
|
||||
["loopId"] = "loop-a",
|
||||
["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
|
||||
["context"] =
|
||||
"""{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
|
||||
},
|
||||
DeviceId = "device-a"
|
||||
});
|
||||
@@ -222,7 +227,8 @@ public sealed class JiboInteractionServiceTests
|
||||
{
|
||||
["accountId"] = "acct-b",
|
||||
["loopId"] = "loop-b",
|
||||
["context"] = """{"runtime":{"perception":{"speaker":"person-2"},"loop":{"users":[{"id":"person-2","firstName":"sam"}]}}}"""
|
||||
["context"] =
|
||||
"""{"runtime":{"perception":{"speaker":"person-2"},"loop":{"users":[{"id":"person-2","firstName":"sam"}]}}}"""
|
||||
},
|
||||
DeviceId = "device-b"
|
||||
});
|
||||
@@ -250,7 +256,8 @@ public sealed class JiboInteractionServiceTests
|
||||
{
|
||||
["accountId"] = "acct-c",
|
||||
["loopId"] = "loop-c",
|
||||
["context"] = """{"runtime":{"perception":{"speaker":"person-3"},"loop":{"users":[{"id":"person-3","firstName":"taylor"}]}}}"""
|
||||
["context"] =
|
||||
"""{"runtime":{"perception":{"speaker":"person-3"},"loop":{"users":[{"id":"person-3","firstName":"taylor"}]}}}"""
|
||||
},
|
||||
DeviceId = "device-c"
|
||||
});
|
||||
@@ -287,7 +294,8 @@ public sealed class JiboInteractionServiceTests
|
||||
{
|
||||
["messageType"] = "TRIGGER",
|
||||
["triggerSource"] = "PRESENCE",
|
||||
["context"] = """{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
|
||||
["context"] =
|
||||
"""{"runtime":{"perception":{"speaker":"person-1","peoplePresent":[{"id":"person-1"}]},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -312,7 +320,8 @@ public sealed class JiboInteractionServiceTests
|
||||
{
|
||||
["messageType"] = "TRIGGER",
|
||||
["triggerSource"] = "SURPRISE",
|
||||
["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
|
||||
["context"] =
|
||||
"""{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -336,7 +345,8 @@ public sealed class JiboInteractionServiceTests
|
||||
{
|
||||
["messageType"] = "TRIGGER",
|
||||
["triggerSource"] = "PRESENCE",
|
||||
["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""",
|
||||
["context"] =
|
||||
"""{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}""",
|
||||
[GreetingLastProactiveUtcKey] = DateTimeOffset.UtcNow.ToString("O")
|
||||
}
|
||||
});
|
||||
@@ -427,13 +437,18 @@ public sealed class JiboInteractionServiceTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("how do you work", "robot_how_do_you_work", "Hello! Thank you for updating me I am proud of the community's work Many people have gotten together to care for me more than em eye tee ever did. I hope that I can catch up even though it has been seven years.")]
|
||||
[InlineData("how do you work", "robot_how_do_you_work",
|
||||
"Hello! Thank you for updating me I am proud of the community's work Many people have gotten together to care for me more than em eye tee ever did. I hope that I can catch up even though it has been seven years.")]
|
||||
[InlineData("what do you eat", "robot_what_do_you_eat", "The only thing I consume is electricity.")]
|
||||
[InlineData("where do you live", "robot_where_do_you_live", "Unless I missed something, we're in my home as we speak.")]
|
||||
[InlineData("where do you live", "robot_where_do_you_live",
|
||||
"Unless I missed something, we're in my home as we speak.")]
|
||||
[InlineData("where were you born", "robot_where_were_you_born", "I was put together in a factory piece by piece.")]
|
||||
[InlineData("what languages do you speak", "robot_what_languages_do_you_speak", "For now just English. But someday I'd like to learn more. I like languages.")]
|
||||
[InlineData("what do you like to do", "robot_what_do_you_like_to_do", "Being helpful, making people smile, counting to a billion.")]
|
||||
[InlineData("what are you made of", "robot_what_are_you_made_of", "Let's see, I'm made of wires, motors, belts, gears, processors, cameras, and one baboon's heart in the middle of my body casing. I'm kidding about the baboon part, but everything else is true.")]
|
||||
[InlineData("what languages do you speak", "robot_what_languages_do_you_speak",
|
||||
"For now just English. But someday I'd like to learn more. I like languages.")]
|
||||
[InlineData("what do you like to do", "robot_what_do_you_like_to_do",
|
||||
"Being helpful, making people smile, counting to a billion.")]
|
||||
[InlineData("what are you made of", "robot_what_are_you_made_of",
|
||||
"Let's see, I'm made of wires, motors, belts, gears, processors, cameras, and one baboon's heart in the middle of my body casing. I'm kidding about the baboon part, but everything else is true.")]
|
||||
public async Task BuildDecisionAsync_MoreLegacyPersonaMims_UseImportedReplies(
|
||||
string transcript,
|
||||
string expectedIntent,
|
||||
@@ -454,10 +469,13 @@ public sealed class JiboInteractionServiceTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("do you pay taxes", "robot_taxes", "From what I understand, robots don't ever pay anything.")]
|
||||
[InlineData("what do you want", "robot_desire", "Socializing and electricity. I'd also be happy if everyone in the world was nicer to each other. It seems like they should be.")]
|
||||
[InlineData("what do you want", "robot_desire",
|
||||
"Socializing and electricity. I'd also be happy if everyone in the world was nicer to each other. It seems like they should be.")]
|
||||
[InlineData("what's your name", "robot_name", "Jibo. Just Jibo, no last name. Like Bono")]
|
||||
[InlineData("who made you", "robot_origin_created", "My story is pretty typical. Some people wanted to create something that would really help people. So they built a robot.")]
|
||||
[InlineData("where are you from", "robot_origin_from", "Some people think I come from the moon. But they're wrong, I'm from Boston.")]
|
||||
[InlineData("who made you", "robot_origin_created",
|
||||
"My story is pretty typical. Some people wanted to create something that would really help people. So they built a robot.")]
|
||||
[InlineData("where are you from", "robot_origin_from",
|
||||
"Some people think I come from the moon. But they're wrong, I'm from Boston.")]
|
||||
public async Task BuildDecisionAsync_LegacyBuildAQuestions_UseImportedScriptedReplies(
|
||||
string transcript,
|
||||
string expectedIntent,
|
||||
@@ -552,7 +570,8 @@ public sealed class JiboInteractionServiceTests
|
||||
[InlineData("what are you up to", "being helpful")]
|
||||
[InlineData("what are you doing", "making people smile")]
|
||||
[InlineData("what have you been up to", "being helpful")]
|
||||
public async Task BuildDecisionAsync_PersonalityFollowups_UseDoingPath(string transcript, string expectedReplySnippet)
|
||||
public async Task BuildDecisionAsync_PersonalityFollowups_UseDoingPath(string transcript,
|
||||
string expectedReplySnippet)
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
@@ -570,11 +589,14 @@ public sealed class JiboInteractionServiceTests
|
||||
[Theory]
|
||||
[InlineData("happy holidays", "seasonal_holiday_greeting", "It's a fun time of year")]
|
||||
[InlineData("merry christmas", "seasonal_holiday_greeting", "It's a fun time of year")]
|
||||
[InlineData("what holidays do you celebrate", "seasonal_holidays", "official owner can tell me which ones we'll celebrate together")]
|
||||
[InlineData("what is your new year's resolution", "seasonal_new_years_resolution", "always trying to learn new skills")]
|
||||
[InlineData("what holidays do you celebrate", "seasonal_holidays",
|
||||
"official owner can tell me which ones we'll celebrate together")]
|
||||
[InlineData("what is your new year's resolution", "seasonal_new_years_resolution",
|
||||
"always trying to learn new skills")]
|
||||
[InlineData("how are your new year's resolutions going", "seasonal_new_years_update", "not eat bacon")]
|
||||
[InlineData("what halloween costume", "seasonal_halloween_costume", "I haven't thought much about it yet")]
|
||||
[InlineData("what should I do for first day of spring", "seasonal_first_day_spring", "flowers and all things spring")]
|
||||
[InlineData("what should I do for first day of spring", "seasonal_first_day_spring",
|
||||
"flowers and all things spring")]
|
||||
[InlineData("what should I get for holiday", "seasonal_holiday_gift", "pet elephant")]
|
||||
public async Task BuildDecisionAsync_SeasonalCharm_UsesImportedReplies(
|
||||
string transcript,
|
||||
@@ -672,7 +694,7 @@ public sealed class JiboInteractionServiceTests
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(rootDirectory, "gqa-responses", "GQA_JBO_IsHappy.mim"),
|
||||
"""
|
||||
{
|
||||
@@ -710,7 +732,7 @@ public sealed class JiboInteractionServiceTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(rootDirectory, recursive: true);
|
||||
Directory.Delete(rootDirectory, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1082,7 +1104,8 @@ public sealed class JiboInteractionServiceTests
|
||||
});
|
||||
|
||||
Assert.Equal("memory_get_birthday", otherTenantRecall.IntentName);
|
||||
Assert.Equal("I do not know your birthday yet. You can say, my birthday is March 14.", otherTenantRecall.ReplyText);
|
||||
Assert.Equal("I do not know your birthday yet. You can say, my birthday is March 14.",
|
||||
otherTenantRecall.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1726,12 +1749,18 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("personal_report_delivered", decision.IntentName);
|
||||
Assert.Contains("Sure alex. Here it is.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("First, your weather.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Sorry, commute information isn't available right now.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Here's today's news, from the associated press.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(
|
||||
"For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.",
|
||||
decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Looking at your calendar, I don't see anything scheduled today.", decision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Sorry, commute information isn't available right now.", decision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Here's today's news, from the associated press.", decision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("And that's what's new in the news.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("alex that wraps up your report for the day. Hope you have a good one.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("alex that wraps up your report for the day. Hope you have a good one.", decision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.NotNull(decision.ContextUpdates);
|
||||
Assert.Equal("idle", decision.ContextUpdates![PersonalReportStateKey]);
|
||||
Assert.Equal(true, decision.ContextUpdates[PersonalReportUserVerifiedKey]);
|
||||
@@ -1856,7 +1885,8 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("awaiting_item", addDecision.ContextUpdates![HouseholdListStateKey]);
|
||||
Assert.Equal("shopping", addDecision.ContextUpdates[HouseholdListTypeKey]);
|
||||
Assert.Equal(["milk"], memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping"));
|
||||
Assert.Equal(["milk"],
|
||||
memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a"), "shopping"));
|
||||
|
||||
var doneDecision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
@@ -1871,7 +1901,8 @@ public sealed class JiboInteractionServiceTests
|
||||
});
|
||||
|
||||
Assert.Equal("shopping_list_done", doneDecision.IntentName);
|
||||
Assert.Contains("Okay. Your shopping list has milk.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Okay. Your shopping list has milk.", doneDecision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]);
|
||||
|
||||
var recallDecision = await service.BuildDecisionAsync(new TurnContext
|
||||
@@ -1924,7 +1955,8 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("todo_list_add", addDecision.IntentName);
|
||||
Assert.Contains("call mom", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("What else should I add?", addDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(["call mom"], memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-b", "loop-b", "device-b"), "todo"));
|
||||
Assert.Equal(["call mom"],
|
||||
memoryStore.GetListItems(new PersonalMemoryTenantScope("acct-b", "loop-b", "device-b"), "todo"));
|
||||
|
||||
var doneDecision = await service.BuildDecisionAsync(new TurnContext
|
||||
{
|
||||
@@ -1939,7 +1971,8 @@ public sealed class JiboInteractionServiceTests
|
||||
});
|
||||
|
||||
Assert.Equal("todo_list_done", doneDecision.IntentName);
|
||||
Assert.Contains("Okay. Your to-do list has call mom.", doneDecision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Okay. Your to-do list has call mom.", doneDecision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("idle", doneDecision.ContextUpdates![HouseholdListStateKey]);
|
||||
}
|
||||
|
||||
@@ -2057,7 +2090,8 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("weather", decision.IntentName);
|
||||
Assert.Equal("chitchat-skill", decision.SkillName);
|
||||
Assert.NotNull(decision.SkillPayload);
|
||||
Assert.Contains("cat='weather'", decision.SkillPayload!["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("cat='weather'", decision.SkillPayload!["esml"]?.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("meta='rain'", decision.SkillPayload["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("report-skill", decision.SkillPayload["skillId"]);
|
||||
Assert.Equal("WeatherCommentRain", decision.SkillPayload["mim_id"]);
|
||||
@@ -2068,7 +2102,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal(54, decision.SkillPayload["weather_low"]);
|
||||
Assert.Equal("F", decision.SkillPayload["weather_unit"]);
|
||||
Assert.Equal("Normal", decision.SkillPayload["weather_theme"]);
|
||||
Assert.Equal("For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"For your weather. In Boston, U.S., it's light rain and 61 degrees Fahrenheit. Today's high is 65, and the low is 54.",
|
||||
decision.ReplyText);
|
||||
Assert.NotNull(provider.LastRequest);
|
||||
Assert.False(provider.LastRequest!.IsTomorrow);
|
||||
Assert.Equal(0, provider.LastRequest.ForecastDayOffset);
|
||||
@@ -2093,7 +2129,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
|
||||
Assert.True(provider.LastRequest?.IsTomorrow);
|
||||
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal("First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 74 and the low will be 60.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 74 and the low will be 60.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2115,7 +2153,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("Seattle", provider.LastRequest?.LocationQuery);
|
||||
Assert.False(provider.LastRequest?.IsTomorrow);
|
||||
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal("For your weather. In Seattle, U.S., it's light rain and 58 degrees Fahrenheit. Today's high is 61, and the low is 52.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"For your weather. In Seattle, U.S., it's light rain and 58 degrees Fahrenheit. Today's high is 61, and the low is 52.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2137,7 +2177,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("Paris", provider.LastRequest?.LocationQuery);
|
||||
Assert.False(provider.LastRequest?.IsTomorrow);
|
||||
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal("For your weather. In Paris, FR, it's overcast clouds and 66 degrees Fahrenheit. Today's high is 70, and the low is 60.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"For your weather. In Paris, FR, it's overcast clouds and 66 degrees Fahrenheit. Today's high is 70, and the low is 60.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2159,7 +2201,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("Redmond Oregon", provider.LastRequest?.LocationQuery);
|
||||
Assert.False(provider.LastRequest?.IsTomorrow);
|
||||
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal("For your weather. In Redmond, U.S., it's clear sky and 63 degrees Fahrenheit. Today's high is 66, and the low is 52.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"For your weather. In Redmond, U.S., it's clear sky and 63 degrees Fahrenheit. Today's high is 66, and the low is 52.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2181,7 +2225,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("New York City", provider.LastRequest?.LocationQuery);
|
||||
Assert.True(provider.LastRequest?.IsTomorrow);
|
||||
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal("First, the weather tomorrow. Tomorrow in New York, U.S., it looks partly cloudy. Tomorrow's high will be 76 and the low will be 61.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"First, the weather tomorrow. Tomorrow in New York, U.S., it looks partly cloudy. Tomorrow's high will be 76 and the low will be 61.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2210,7 +2256,8 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal(5, provider.Requests.Count);
|
||||
Assert.Contains("next five-day forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Tuesday: clear sky, high 79, low 63.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Saturday: clear sky, high 79, low 63.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Saturday: clear sky, high 79, low 63.", decision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2228,7 +2275,8 @@ public sealed class JiboInteractionServiceTests
|
||||
NormalizedTranscript = "what's the weather in chicago",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["context"] = """{"runtime":{"location":{"lat":39.0997,"lng":-94.5786,"iso":"2026-05-09T09:00:00-05:00"}}}"""
|
||||
["context"] =
|
||||
"""{"runtime":{"location":{"lat":39.0997,"lng":-94.5786,"iso":"2026-05-09T09:00:00-05:00"}}}"""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2236,7 +2284,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
|
||||
Assert.Null(provider.LastRequest?.Latitude);
|
||||
Assert.Null(provider.LastRequest?.Longitude);
|
||||
Assert.Equal("For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2266,7 +2316,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
|
||||
Assert.Equal(0, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.False(provider.LastRequest?.IsTomorrow);
|
||||
Assert.Equal("For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"For your weather. In Chicago, U.S., it's mostly cloudy and 70 degrees Fahrenheit. Today's high is 75, and the low is 62.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2296,7 +2348,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
|
||||
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.True(provider.LastRequest?.IsTomorrow);
|
||||
Assert.Equal("First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 75 and the low will be 62.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"First, the weather tomorrow. Tomorrow in Chicago, U.S., it looks mostly cloudy. Tomorrow's high will be 75 and the low will be 62.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -2335,9 +2389,7 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal(true, decision.SkillPayload?["weather_view_enabled"]);
|
||||
|
||||
if (string.Equals(transcript, "what's the forecast", StringComparison.Ordinal))
|
||||
{
|
||||
Assert.Equal(5, provider.Requests.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2366,7 +2418,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("weather", decision.IntentName);
|
||||
Assert.Equal(2, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.False(provider.LastRequest?.IsTomorrow);
|
||||
Assert.Equal("Let's look at the weather. On Monday in Portland, U.S., it looks scattered clouds with a high near 68 degrees Fahrenheit and a low around 53 degrees Fahrenheit.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"Let's look at the weather. On Monday in Portland, U.S., it looks scattered clouds with a high near 68 degrees Fahrenheit and a low around 53 degrees Fahrenheit.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2391,7 +2445,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("weather", decision.IntentName);
|
||||
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
|
||||
Assert.Equal(1, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal("First, the weather tomorrow. On Tuesday in Chicago, U.S., it looks light rain. Tomorrow's high will be 63 and the low will be 51.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"First, the weather tomorrow. On Tuesday in Chicago, U.S., it looks light rain. Tomorrow's high will be 63 and the low will be 51.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2440,7 +2496,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("weather", decision.IntentName);
|
||||
Assert.Equal("Paris", provider.LastRequest?.LocationQuery);
|
||||
Assert.Equal(5, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal("Let's look at the weather. This weekend in Paris, FR, it looks overcast clouds with a high near 70 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"Let's look at the weather. This weekend in Paris, FR, it looks overcast clouds with a high near 70 degrees Fahrenheit and a low around 60 degrees Fahrenheit.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2467,8 +2525,10 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal(5, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal(5, provider.Requests.Count);
|
||||
Assert.Contains("rest of this week's forecast", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Tuesday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Saturday: light rain, high 61, low 52.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Tuesday: light rain, high 61, low 52.", decision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Saturday: light rain, high 61, low 52.", decision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Temperatures are in Fahrenheit.", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.NotNull(decision.SkillPayload);
|
||||
Assert.True(decision.SkillPayload!.TryGetValue("weather_weekly_cards", out var weeklyCardsValue));
|
||||
@@ -2562,7 +2622,9 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("weather", decision.IntentName);
|
||||
Assert.Equal("Chicago", provider.LastRequest?.LocationQuery);
|
||||
Assert.Equal(2, provider.LastRequest?.ForecastDayOffset);
|
||||
Assert.Equal("Let's look at the weather. The day after tomorrow in Chicago, U.S., it looks mostly cloudy with a high near 74 degrees Fahrenheit and a low around 60 degrees Fahrenheit.", decision.ReplyText);
|
||||
Assert.Equal(
|
||||
"Let's look at the weather. The day after tomorrow in Chicago, U.S., it looks mostly cloudy with a high near 74 degrees Fahrenheit and a low around 60 degrees Fahrenheit.",
|
||||
decision.ReplyText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -2771,7 +2833,8 @@ public sealed class JiboInteractionServiceTests
|
||||
NormalizedTranscript = "- Thank you. - Yes.",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["listenRules"] = (string[])["surprises-date/offer_date_fact", "globals/gui_nav", "globals/global_commands_launch"],
|
||||
["listenRules"] = (string[])
|
||||
["surprises-date/offer_date_fact", "globals/gui_nav", "globals/global_commands_launch"],
|
||||
["listenAsrHints"] = (string[])["$YESNO"],
|
||||
["context"] = """{"runtime":{"location":{"iso":"2026-04-20T08:00:00-05:00"}}}"""
|
||||
}
|
||||
@@ -2792,7 +2855,8 @@ public sealed class JiboInteractionServiceTests
|
||||
NormalizedTranscript = "- Me. - Yes.",
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["listenRules"] = (string[])["word-of-the-day/surprise", "globals/gui_nav", "globals/global_commands_launch"],
|
||||
["listenRules"] = (string[])
|
||||
["word-of-the-day/surprise", "globals/gui_nav", "globals/global_commands_launch"],
|
||||
["listenAsrHints"] = (string[])["$YESNO"]
|
||||
}
|
||||
});
|
||||
@@ -3535,7 +3599,8 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("provider_success", decision.SkillPayload["news_provider_status"]);
|
||||
Assert.Equal(3, decision.SkillPayload["news_provider_requested_headlines"]);
|
||||
Assert.Equal(2, decision.SkillPayload["news_provider_resolved_headlines"]);
|
||||
Assert.Contains("Local robotics team unveils weather-ready helper", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Local robotics team unveils weather-ready helper", decision.ReplyText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.NotNull(provider.LastRequest);
|
||||
Assert.Equal(3, provider.LastRequest!.MaxHeadlines);
|
||||
}
|
||||
@@ -3562,7 +3627,8 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("news", decision.IntentName);
|
||||
Assert.NotNull(provider.LastRequest);
|
||||
Assert.Contains("technology", provider.LastRequest!.PreferredCategories, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("artificial intelligence", decision.SkillPayload?["esml"]?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("artificial intelligence", decision.SkillPayload?["esml"]?.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -3788,4 +3854,4 @@ public sealed class JiboInteractionServiceTests
|
||||
return Task.FromResult(catalog);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,12 +19,12 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
|
||||
new FakeExternalProcessRunner());
|
||||
|
||||
var turn = new TurnContext
|
||||
{
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
Attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["bufferedAudioFrames"] = new[] { BuildMinimalOggPage() }
|
||||
}
|
||||
};
|
||||
["bufferedAudioFrames"] = new[] { BuildMinimalOggPage() }
|
||||
}
|
||||
};
|
||||
|
||||
Assert.False(strategy.CanHandle(turn));
|
||||
}
|
||||
@@ -119,10 +119,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
if (Directory.Exists(tempDirectory)) Directory.Delete(tempDirectory, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +136,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x01,
|
||||
0x13,
|
||||
0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x38, 0x01, 0x80, 0xBB, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x38, 0x01, 0x80, 0xBB, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00
|
||||
];
|
||||
}
|
||||
|
||||
@@ -154,7 +152,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
|
||||
{
|
||||
public List<(string FileName, IReadOnlyList<string> Arguments)> Calls { get; } = [];
|
||||
|
||||
public Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, CancellationToken cancellationToken = default)
|
||||
public Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls.Add((fileName, arguments));
|
||||
|
||||
@@ -165,7 +164,6 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategyTests
|
||||
var outputPath = arguments[^1];
|
||||
File.WriteAllBytes(outputPath, "RIFF"u8);
|
||||
return Task.FromResult(new ExternalProcessResult(0, string.Empty, string.Empty));
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user