Add tenant-scoped personal memory facts

This commit is contained in:
Jacob Dubin
2026-05-05 22:40:11 -05:00
parent 687ff62f0f
commit 699e0d5282
10 changed files with 525 additions and 19 deletions

View File

@@ -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);

View File

@@ -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)

View File

@@ -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;

View File

@@ -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>();

View File

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

View File

@@ -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);
}
}