Add tenant-scoped personal memory facts
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
namespace Jibo.Cloud.Application.Abstractions;
|
||||
|
||||
public interface IPersonalMemoryStore
|
||||
{
|
||||
void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText);
|
||||
string? GetBirthday(PersonalMemoryTenantScope tenantScope);
|
||||
void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value);
|
||||
string? GetPreference(PersonalMemoryTenantScope tenantScope, string category);
|
||||
}
|
||||
|
||||
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId);
|
||||
@@ -7,7 +7,8 @@ namespace Jibo.Cloud.Application.Services;
|
||||
|
||||
public sealed class JiboInteractionService(
|
||||
JiboExperienceContentCache contentCache,
|
||||
IJiboRandomizer randomizer)
|
||||
IJiboRandomizer randomizer,
|
||||
IPersonalMemoryStore personalMemoryStore)
|
||||
{
|
||||
public async Task<JiboInteractionDecision> BuildDecisionAsync(TurnContext turn, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -77,6 +78,10 @@ public sealed class JiboInteractionService(
|
||||
"robot_age" => BuildRobotAgeDecision(referenceLocalTime),
|
||||
"robot_birthday" => BuildRobotBirthdayDecision(),
|
||||
"robot_personality" => new JiboInteractionDecision("robot_personality", randomizer.Choose(catalog.PersonalityReplies)),
|
||||
"memory_set_birthday" => BuildRememberBirthdayDecision(turn, transcript),
|
||||
"memory_get_birthday" => BuildRecallBirthdayDecision(turn),
|
||||
"memory_set_preference" => BuildRememberPreferenceDecision(turn, transcript),
|
||||
"memory_get_preference" => BuildRecallPreferenceDecision(turn, transcript),
|
||||
"pizza" => BuildPizzaDecision(),
|
||||
"order_pizza" => BuildOrderPizzaDecision(),
|
||||
"yes" => new JiboInteractionDecision("yes", "Yes."),
|
||||
@@ -115,6 +120,70 @@ public sealed class JiboInteractionService(
|
||||
$"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}.");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildRememberBirthdayDecision(TurnContext turn, string transcript)
|
||||
{
|
||||
var birthday = TryExtractBirthdayFact(transcript);
|
||||
if (string.IsNullOrWhiteSpace(birthday))
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
"memory_set_birthday",
|
||||
"I can remember it if you say, my birthday is March 14.");
|
||||
}
|
||||
|
||||
personalMemoryStore.SetBirthday(ResolveTenantScope(turn), birthday);
|
||||
return new JiboInteractionDecision(
|
||||
"memory_set_birthday",
|
||||
$"Got it. I will remember your birthday is {birthday}.");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildRecallBirthdayDecision(TurnContext turn)
|
||||
{
|
||||
var birthday = personalMemoryStore.GetBirthday(ResolveTenantScope(turn));
|
||||
return string.IsNullOrWhiteSpace(birthday)
|
||||
? new JiboInteractionDecision(
|
||||
"memory_get_birthday",
|
||||
"I do not know your birthday yet. You can say, my birthday is March 14.")
|
||||
: new JiboInteractionDecision(
|
||||
"memory_get_birthday",
|
||||
$"You told me your birthday is {birthday}.");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildRememberPreferenceDecision(TurnContext turn, string transcript)
|
||||
{
|
||||
var preference = TryExtractPreferenceSet(transcript);
|
||||
if (preference is null)
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
"memory_set_preference",
|
||||
"I can remember it if you say, my favorite music is jazz.");
|
||||
}
|
||||
|
||||
personalMemoryStore.SetPreference(ResolveTenantScope(turn), preference.Value.Category, preference.Value.Value);
|
||||
return new JiboInteractionDecision(
|
||||
"memory_set_preference",
|
||||
$"Got it. I will remember your favorite {preference.Value.Category} is {preference.Value.Value}.");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildRecallPreferenceDecision(TurnContext turn, string transcript)
|
||||
{
|
||||
var category = TryExtractPreferenceLookupCategory(transcript);
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
"memory_get_preference",
|
||||
"Ask me like this: what is my favorite music?");
|
||||
}
|
||||
|
||||
var preference = personalMemoryStore.GetPreference(ResolveTenantScope(turn), category);
|
||||
return string.IsNullOrWhiteSpace(preference)
|
||||
? new JiboInteractionDecision(
|
||||
"memory_get_preference",
|
||||
$"I do not know your favorite {category} yet.")
|
||||
: new JiboInteractionDecision(
|
||||
"memory_get_preference",
|
||||
$"You told me your favorite {category} is {preference}.");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildPizzaDecision()
|
||||
{
|
||||
var prompt = randomizer.Choose(PizzaMimPrompts);
|
||||
@@ -266,6 +335,16 @@ public sealed class JiboInteractionService(
|
||||
};
|
||||
}
|
||||
|
||||
if (IsUserBirthdaySetStatement(loweredTranscript))
|
||||
{
|
||||
return "memory_set_birthday";
|
||||
}
|
||||
|
||||
if (IsUserBirthdayRecallQuestion(loweredTranscript))
|
||||
{
|
||||
return "memory_get_birthday";
|
||||
}
|
||||
|
||||
if (IsRobotBirthdayQuestion(loweredTranscript))
|
||||
{
|
||||
return "robot_birthday";
|
||||
@@ -390,6 +469,16 @@ public sealed class JiboInteractionService(
|
||||
return "cloud_version";
|
||||
}
|
||||
|
||||
if (IsPreferenceSetStatement(loweredTranscript))
|
||||
{
|
||||
return "memory_set_preference";
|
||||
}
|
||||
|
||||
if (IsPreferenceRecallQuestion(loweredTranscript))
|
||||
{
|
||||
return "memory_get_preference";
|
||||
}
|
||||
|
||||
if (TryResolveRadioGenre(loweredTranscript) is not null)
|
||||
{
|
||||
return "radio_genre";
|
||||
@@ -1102,15 +1191,123 @@ public sealed class JiboInteractionService(
|
||||
private static bool IsRobotBirthdayQuestion(string loweredTranscript)
|
||||
{
|
||||
return MatchesAny(
|
||||
loweredTranscript,
|
||||
"when is your birthday",
|
||||
"when's your birthday",
|
||||
"what's your birthday",
|
||||
"what s your birthday",
|
||||
"what is your birthday",
|
||||
"when were you born",
|
||||
"what day is your birthday") ||
|
||||
loweredTranscript.Contains("birthday", StringComparison.Ordinal);
|
||||
loweredTranscript,
|
||||
"when is your birthday",
|
||||
"when's your birthday",
|
||||
"what's your birthday",
|
||||
"what s your birthday",
|
||||
"what is your birthday",
|
||||
"when were you born",
|
||||
"what day is your birthday");
|
||||
}
|
||||
|
||||
private static bool IsUserBirthdayRecallQuestion(string loweredTranscript)
|
||||
{
|
||||
return MatchesAny(
|
||||
loweredTranscript,
|
||||
"when is my birthday",
|
||||
"when's my birthday",
|
||||
"what is my birthday",
|
||||
"what s my birthday",
|
||||
"what's my birthday",
|
||||
"do you remember my birthday");
|
||||
}
|
||||
|
||||
private static bool IsUserBirthdaySetStatement(string loweredTranscript)
|
||||
{
|
||||
return TryExtractBirthdayFact(loweredTranscript) is not null;
|
||||
}
|
||||
|
||||
private static string? TryExtractBirthdayFact(string transcript)
|
||||
{
|
||||
var normalized = NormalizeCommandPhrase(transcript);
|
||||
var marker = "my birthday is ";
|
||||
var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = normalized[(markerIndex + marker.Length)..].Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static bool IsPreferenceRecallQuestion(string loweredTranscript)
|
||||
{
|
||||
return TryExtractPreferenceLookupCategory(loweredTranscript) is not null;
|
||||
}
|
||||
|
||||
private static bool IsPreferenceSetStatement(string loweredTranscript)
|
||||
{
|
||||
return TryExtractPreferenceSet(loweredTranscript) is not null;
|
||||
}
|
||||
|
||||
private static string? TryExtractPreferenceLookupCategory(string transcript)
|
||||
{
|
||||
var normalized = NormalizeCommandPhrase(transcript);
|
||||
var prefixes = new[]
|
||||
{
|
||||
"what is my favorite ",
|
||||
"what s my favorite ",
|
||||
"what's my favorite ",
|
||||
"do you remember my favorite "
|
||||
};
|
||||
|
||||
foreach (var prefix in prefixes)
|
||||
{
|
||||
if (!normalized.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var category = normalized[prefix.Length..].Trim();
|
||||
return string.IsNullOrWhiteSpace(category) ? null : category;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (string Category, string Value)? TryExtractPreferenceSet(string transcript)
|
||||
{
|
||||
var normalized = NormalizeCommandPhrase(transcript);
|
||||
var marker = "my favorite ";
|
||||
var markerIndex = normalized.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var preferencePhrase = normalized[(markerIndex + marker.Length)..];
|
||||
var splitMarker = " is ";
|
||||
var splitIndex = preferencePhrase.IndexOf(splitMarker, StringComparison.Ordinal);
|
||||
if (splitIndex <= 0 || splitIndex >= preferencePhrase.Length - splitMarker.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var category = preferencePhrase[..splitIndex].Trim();
|
||||
var value = preferencePhrase[(splitIndex + splitMarker.Length)..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (category, value);
|
||||
}
|
||||
|
||||
private static PersonalMemoryTenantScope ResolveTenantScope(TurnContext turn)
|
||||
{
|
||||
var accountId = ReadTenantAttribute(turn, "accountId") ?? "usr_openjibo_owner";
|
||||
var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop";
|
||||
var deviceId = turn.DeviceId ?? ReadTenantAttribute(turn, "deviceId") ?? "unknown-device";
|
||||
return new PersonalMemoryTenantScope(accountId, loopId, deviceId);
|
||||
}
|
||||
|
||||
private static string? ReadTenantAttribute(TurnContext turn, string key)
|
||||
{
|
||||
return turn.Attributes.TryGetValue(key, out var value)
|
||||
? value?.ToString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? TryResolveRadioGenre(string loweredTranscript)
|
||||
|
||||
@@ -21,6 +21,23 @@ public sealed class ProtocolToTurnContextMapper
|
||||
attributes["transID"] = turnState.TransId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(session.AccountId))
|
||||
{
|
||||
attributes["accountId"] = session.AccountId;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -27,6 +27,7 @@ public static class ServiceCollectionExtensions
|
||||
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
|
||||
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
|
||||
services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));
|
||||
services.AddSingleton<IPersonalMemoryStore, InMemoryPersonalMemoryStore>();
|
||||
services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>();
|
||||
services.AddSingleton<JiboExperienceContentCache>();
|
||||
services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>();
|
||||
|
||||
@@ -102,7 +102,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
Kind = "hub",
|
||||
AccountId = _account.AccountId,
|
||||
Token = token,
|
||||
DeviceId = _robot.DeviceId
|
||||
DeviceId = _robot.DeviceId,
|
||||
Metadata = BuildSessionMetadata(_account.AccountId, _robot.DeviceId, ResolveDefaultLoopId())
|
||||
};
|
||||
|
||||
return token;
|
||||
@@ -116,7 +117,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
Kind = "robot",
|
||||
AccountId = _account.AccountId,
|
||||
Token = token,
|
||||
DeviceId = deviceId
|
||||
DeviceId = deviceId,
|
||||
Metadata = BuildSessionMetadata(_account.AccountId, deviceId, ResolveDefaultLoopId())
|
||||
};
|
||||
|
||||
return token;
|
||||
@@ -124,14 +126,17 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
|
||||
public CloudSession OpenSession(string kind, string? deviceId, string? token, string? hostName, string? path)
|
||||
{
|
||||
var resolvedDeviceId = deviceId ?? _robot.DeviceId;
|
||||
var resolvedLoopId = ResolveDefaultLoopId();
|
||||
var session = new CloudSession
|
||||
{
|
||||
Kind = kind,
|
||||
AccountId = _account.AccountId,
|
||||
DeviceId = deviceId ?? _robot.DeviceId,
|
||||
DeviceId = resolvedDeviceId,
|
||||
Token = token,
|
||||
HostName = hostName,
|
||||
Path = path
|
||||
Path = path,
|
||||
Metadata = BuildSessionMetadata(_account.AccountId, resolvedDeviceId, resolvedLoopId)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
@@ -424,4 +429,21 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
public MediaRecord[]? Media { get; init; }
|
||||
public BackupRecord[]? Backups { get; init; }
|
||||
}
|
||||
|
||||
private string ResolveDefaultLoopId()
|
||||
{
|
||||
return _loops.FirstOrDefault(loop => string.Equals(loop.OwnerAccountId, _account.AccountId, StringComparison.OrdinalIgnoreCase))?.LoopId
|
||||
?? _loops.FirstOrDefault()?.LoopId
|
||||
?? "openjibo-default-loop";
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?> BuildSessionMetadata(string accountId, string? deviceId, string loopId)
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["accountId"] = accountId,
|
||||
["loopId"] = loopId,
|
||||
["deviceId"] = deviceId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
|
||||
namespace Jibo.Cloud.Infrastructure.Persistence;
|
||||
|
||||
public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TenantMemoryRecord> _tenantMemory = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
||||
record.Birthday = birthdayText;
|
||||
}
|
||||
|
||||
public string? GetBirthday(PersonalMemoryTenantScope tenantScope)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
return _tenantMemory.TryGetValue(key, out var record) ? record.Birthday : null;
|
||||
}
|
||||
|
||||
public void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
var record = _tenantMemory.GetOrAdd(key, static _ => new TenantMemoryRecord());
|
||||
record.Preferences[NormalizeCategory(category)] = value;
|
||||
}
|
||||
|
||||
public string? GetPreference(PersonalMemoryTenantScope tenantScope, string category)
|
||||
{
|
||||
var key = BuildTenantKey(tenantScope);
|
||||
return _tenantMemory.TryGetValue(key, out var record) &&
|
||||
record.Preferences.TryGetValue(NormalizeCategory(category), out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
|
||||
{
|
||||
return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}";
|
||||
}
|
||||
|
||||
private static string NormalizeCategory(string category)
|
||||
{
|
||||
return category.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class TenantMemoryRecord
|
||||
{
|
||||
public string? Birthday { get; set; }
|
||||
public ConcurrentDictionary<string, string> Preferences { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user