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

@@ -600,6 +600,20 @@ Current release theme:
- expand command-vs-question splits to more expressive intents (pizza, surprise, photo prompts) - expand command-vs-question splits to more expressive intents (pizza, surprise, photo prompts)
- add Pegasus phrase and MIM-backed variants for richer style coverage - add Pegasus phrase and MIM-backed variants for richer style coverage
### 23. First Memory-Backed Personal Facts
- Status: `implemented`
- Tags: `storage`, `content`
- Result:
- tenant-scoped memory store abstraction is in place for personal facts
- birthday set/recall works (`my birthday is ...` / `when is my birthday`)
- preference set/recall works (`my favorite X is Y` / `what is my favorite X`)
- account/loop/device scoped lookup prevents cross-tenant leakage
- Follow-up:
- extend phrase parsing beyond first rule-based patterns
- add durable persistence path for personal facts
- broaden fact categories (names, important dates, household preferences)
## Suggested Order ## Suggested Order
Before closing `1.0.18`: Before closing `1.0.18`:
@@ -615,7 +629,7 @@ Use [regression-test-plan.md](regression-test-plan.md) as the detailed checklist
For `1.0.19`: For `1.0.19`:
1. Command-vs-question personality split (`dance` command vs `do you like to dance` question style; expand this pattern) 1. Command-vs-question personality split (`dance` command vs `do you like to dance` question style; expand this pattern)
2. First memory-backed personal facts with tenant-scoped storage (birthday/preferences foundation) 2. Expand memory-backed personal facts with tenant-scoped storage (beyond the first birthday/preferences foundation)
3. Proactivity selector baseline with source-backed first offers 3. Proactivity selector baseline with source-backed first offers
4. Dialog parsing expansion and ambiguity guardrails 4. Dialog parsing expansion and ambiguity guardrails
5. Holidays and seasonal personality behavior built on the new memory/proactivity foundation 5. Holidays and seasonal personality behavior built on the new memory/proactivity foundation

View File

@@ -57,10 +57,21 @@ The first delivered slice in this release is persona expansion:
This slice is intentionally small and user-visible. It creates immediate personality gains while we keep deeper platform work in parallel. This slice is intentionally small and user-visible. It creates immediate personality gains while we keep deeper platform work in parallel.
## Second Implemented Slice In `1.0.19`
The second delivered slice is first tenant-scoped personal memory:
- store birthday from phrases like `my birthday is April 12`
- recall birthday from phrases like `when is my birthday`
- store preferences from phrases like `my favorite music is jazz`
- recall preferences from phrases like `what is my favorite music`
Memory keys are scoped by account/loop/device tenant context so one tenant does not leak into another.
## Next Slices ## Next Slices
1. Command-vs-question personality split (start with dance/twerk-style prompts, keep commands action-oriented and questions conversational) 1. Command-vs-question personality split (start with dance/twerk-style prompts, keep commands action-oriented and questions conversational)
2. First memory-backed personal facts (tenant-scoped birthday/preferences storage contracts + initial implementation) 2. Expand memory-backed personal facts (tenant-scoped birthday/preferences coverage, persistence depth, and parsing breadth)
3. Proactivity selector baseline (source-backed first proactive offers with safe throttling and stock-compatible payloads) 3. Proactivity selector baseline (source-backed first proactive offers with safe throttling and stock-compatible payloads)
4. Dialog parsing expansion (more phrase variants, ambiguity handling, and transcript-to-intent guardrails) 4. Dialog parsing expansion (more phrase variants, ambiguity handling, and transcript-to-intent guardrails)
5. Holidays and seasonal personality slice (time-scoped content backed by the new memory/proactivity path) 5. Holidays and seasonal personality slice (time-scoped content backed by the new memory/proactivity path)

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( public sealed class JiboInteractionService(
JiboExperienceContentCache contentCache, JiboExperienceContentCache contentCache,
IJiboRandomizer randomizer) IJiboRandomizer randomizer,
IPersonalMemoryStore personalMemoryStore)
{ {
public async Task<JiboInteractionDecision> BuildDecisionAsync(TurnContext turn, CancellationToken cancellationToken = default) public async Task<JiboInteractionDecision> BuildDecisionAsync(TurnContext turn, CancellationToken cancellationToken = default)
{ {
@@ -77,6 +78,10 @@ public sealed class JiboInteractionService(
"robot_age" => BuildRobotAgeDecision(referenceLocalTime), "robot_age" => BuildRobotAgeDecision(referenceLocalTime),
"robot_birthday" => BuildRobotBirthdayDecision(), "robot_birthday" => BuildRobotBirthdayDecision(),
"robot_personality" => new JiboInteractionDecision("robot_personality", randomizer.Choose(catalog.PersonalityReplies)), "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(), "pizza" => BuildPizzaDecision(),
"order_pizza" => BuildOrderPizzaDecision(), "order_pizza" => BuildOrderPizzaDecision(),
"yes" => new JiboInteractionDecision("yes", "Yes."), "yes" => new JiboInteractionDecision("yes", "Yes."),
@@ -115,6 +120,70 @@ public sealed class JiboInteractionService(
$"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}."); $"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() private JiboInteractionDecision BuildPizzaDecision()
{ {
var prompt = randomizer.Choose(PizzaMimPrompts); 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)) if (IsRobotBirthdayQuestion(loweredTranscript))
{ {
return "robot_birthday"; return "robot_birthday";
@@ -390,6 +469,16 @@ public sealed class JiboInteractionService(
return "cloud_version"; return "cloud_version";
} }
if (IsPreferenceSetStatement(loweredTranscript))
{
return "memory_set_preference";
}
if (IsPreferenceRecallQuestion(loweredTranscript))
{
return "memory_get_preference";
}
if (TryResolveRadioGenre(loweredTranscript) is not null) if (TryResolveRadioGenre(loweredTranscript) is not null)
{ {
return "radio_genre"; return "radio_genre";
@@ -1109,8 +1198,116 @@ public sealed class JiboInteractionService(
"what s your birthday", "what s your birthday",
"what is your birthday", "what is your birthday",
"when were you born", "when were you born",
"what day is your birthday") || "what day is your birthday");
loweredTranscript.Contains("birthday", StringComparison.Ordinal); }
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) private static string? TryResolveRadioGenre(string loweredTranscript)

View File

@@ -21,6 +21,23 @@ public sealed class ProtocolToTurnContextMapper
attributes["transID"] = turnState.TransId; 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)) if (!string.IsNullOrWhiteSpace(turnState.ContextPayload))
{ {
attributes["context"] = turnState.ContextPayload; attributes["context"] = turnState.ContextPayload;

View File

@@ -27,6 +27,7 @@ public static class ServiceCollectionExtensions
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath)); services.AddSingleton<ICloudStateStore>(_ => new InMemoryCloudStateStore(statePersistencePath));
services.AddSingleton<IPersonalMemoryStore, InMemoryPersonalMemoryStore>();
services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>(); services.AddSingleton<IJiboExperienceContentRepository, InMemoryJiboExperienceContentRepository>();
services.AddSingleton<JiboExperienceContentCache>(); services.AddSingleton<JiboExperienceContentCache>();
services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>(); services.AddSingleton<IJiboRandomizer, DefaultJiboRandomizer>();

View File

@@ -102,7 +102,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Kind = "hub", Kind = "hub",
AccountId = _account.AccountId, AccountId = _account.AccountId,
Token = token, Token = token,
DeviceId = _robot.DeviceId DeviceId = _robot.DeviceId,
Metadata = BuildSessionMetadata(_account.AccountId, _robot.DeviceId, ResolveDefaultLoopId())
}; };
return token; return token;
@@ -116,7 +117,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Kind = "robot", Kind = "robot",
AccountId = _account.AccountId, AccountId = _account.AccountId,
Token = token, Token = token,
DeviceId = deviceId DeviceId = deviceId,
Metadata = BuildSessionMetadata(_account.AccountId, deviceId, ResolveDefaultLoopId())
}; };
return token; return token;
@@ -124,14 +126,17 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public CloudSession OpenSession(string kind, string? deviceId, string? token, string? hostName, string? path) 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 var session = new CloudSession
{ {
Kind = kind, Kind = kind,
AccountId = _account.AccountId, AccountId = _account.AccountId,
DeviceId = deviceId ?? _robot.DeviceId, DeviceId = resolvedDeviceId,
Token = token, Token = token,
HostName = hostName, HostName = hostName,
Path = path Path = path,
Metadata = BuildSessionMetadata(_account.AccountId, resolvedDeviceId, resolvedLoopId)
}; };
if (!string.IsNullOrWhiteSpace(token)) if (!string.IsNullOrWhiteSpace(token))
@@ -424,4 +429,21 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public MediaRecord[]? Media { get; init; } public MediaRecord[]? Media { get; init; }
public BackupRecord[]? Backups { 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);
}
}

View File

@@ -1,5 +1,7 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services; using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Infrastructure.Content; using Jibo.Cloud.Infrastructure.Content;
using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Runtime.Abstractions; using Jibo.Runtime.Abstractions;
using System.Text.Json; using System.Text.Json;
@@ -136,6 +138,114 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("I do. I am curious, playful, and always up for a new experiment.", decision.ReplyText); Assert.Equal("I do. I am curious, playful, and always up for a new experiment.", decision.ReplyText);
} }
[Fact]
public async Task BuildDecisionAsync_BirthdayMemory_SetThenRecallWithinTenant()
{
var memoryStore = new InMemoryPersonalMemoryStore();
var service = CreateService(memoryStore);
var setDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "my birthday is April 12",
NormalizedTranscript = "my birthday is April 12",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_set_birthday", setDecision.IntentName);
Assert.Equal("Got it. I will remember your birthday is april 12.", setDecision.ReplyText);
var recallDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "when is my birthday",
NormalizedTranscript = "when is my birthday",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_get_birthday", recallDecision.IntentName);
Assert.Equal("You told me your birthday is april 12.", recallDecision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_PreferenceMemory_SetThenRecallWithinTenant()
{
var memoryStore = new InMemoryPersonalMemoryStore();
var service = CreateService(memoryStore);
var setDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "my favorite music is jazz",
NormalizedTranscript = "my favorite music is jazz",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_set_preference", setDecision.IntentName);
Assert.Equal("Got it. I will remember your favorite music is jazz.", setDecision.ReplyText);
var recallDecision = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "what is my favorite music",
NormalizedTranscript = "what is my favorite music",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
Assert.Equal("memory_get_preference", recallDecision.IntentName);
Assert.Equal("You told me your favorite music is jazz.", recallDecision.ReplyText);
}
[Fact]
public async Task BuildDecisionAsync_PersonalMemory_IsTenantScoped()
{
var memoryStore = new InMemoryPersonalMemoryStore();
var service = CreateService(memoryStore);
await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "my birthday is April 12",
NormalizedTranscript = "my birthday is April 12",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-a",
["loopId"] = "loop-a"
},
DeviceId = "device-a"
});
var otherTenantRecall = await service.BuildDecisionAsync(new TurnContext
{
RawTranscript = "what is my birthday",
NormalizedTranscript = "what is my birthday",
Attributes = new Dictionary<string, object?>
{
["accountId"] = "acct-b",
["loopId"] = "loop-a"
},
DeviceId = "device-b"
});
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);
}
[Fact] [Fact]
public async Task BuildDecisionAsync_MakePizza_UsesOriginalMimStylePayload() public async Task BuildDecisionAsync_MakePizza_UsesOriginalMimStylePayload()
{ {
@@ -1185,11 +1295,12 @@ public sealed class JiboInteractionServiceTests
Assert.Equal("aglet", decision.SkillPayload!["guess"]); Assert.Equal("aglet", decision.SkillPayload!["guess"]);
} }
private static JiboInteractionService CreateService() private static JiboInteractionService CreateService(IPersonalMemoryStore? personalMemoryStore = null)
{ {
return new JiboInteractionService( return new JiboInteractionService(
new JiboExperienceContentCache(new InMemoryJiboExperienceContentRepository()), new JiboExperienceContentCache(new InMemoryJiboExperienceContentRepository()),
new FirstItemRandomizer()); new FirstItemRandomizer(),
personalMemoryStore ?? new InMemoryPersonalMemoryStore());
} }
private sealed class FirstItemRandomizer : IJiboRandomizer private sealed class FirstItemRandomizer : IJiboRandomizer

View File

@@ -18,7 +18,7 @@ public sealed class JiboWebSocketServiceTests
_store = new InMemoryCloudStateStore(); _store = new InMemoryCloudStateStore();
var contentRepository = new InMemoryJiboExperienceContentRepository(); var contentRepository = new InMemoryJiboExperienceContentRepository();
var contentCache = new JiboExperienceContentCache(contentRepository); var contentCache = new JiboExperienceContentCache(contentRepository);
var conversationBroker = new DemoConversationBroker(new JiboInteractionService(contentCache, new DefaultJiboRandomizer())); var conversationBroker = new DemoConversationBroker(new JiboInteractionService(contentCache, new DefaultJiboRandomizer(), new InMemoryPersonalMemoryStore()));
var sttSelector = new DefaultSttStrategySelector( var sttSelector = new DefaultSttStrategySelector(
[ [
new SyntheticBufferedAudioSttStrategy() new SyntheticBufferedAudioSttStrategy()
@@ -2906,6 +2906,74 @@ public sealed class JiboWebSocketServiceTests
Assert.Equal("AN", meta.GetProperty("prompt_sub_category").GetString()); Assert.Equal("AN", meta.GetProperty("prompt_sub_category").GetString());
} }
[Fact]
public async Task ClientAsrPersonalMemory_BirthdayIsScopedPerDeviceTenant()
{
var tokenA = _store.IssueRobotToken("tenant-device-a");
var tokenB = _store.IssueRobotToken("tenant-device-b");
var setReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = tokenA,
Text = """{"type":"CLIENT_ASR","transID":"trans-memory-set","data":{"text":"my birthday is april 12"}}"""
});
Assert.Equal(3, setReplies.Count);
using (var setListenPayload = JsonDocument.Parse(setReplies[0].Text!))
{
Assert.Equal("memory_set_birthday", setListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString());
}
var sameTenantRecallReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = tokenA,
Text = """{"type":"CLIENT_ASR","transID":"trans-memory-recall-a","data":{"text":"what is my birthday"}}"""
});
Assert.Equal(3, sameTenantRecallReplies.Count);
using (var skillPayload = JsonDocument.Parse(sameTenantRecallReplies[2].Text!))
{
var esml = skillPayload.RootElement
.GetProperty("data")
.GetProperty("action")
.GetProperty("config")
.GetProperty("jcp")
.GetProperty("config")
.GetProperty("play")
.GetProperty("esml")
.GetString();
Assert.Contains("You told me your birthday is april 12", esml, StringComparison.OrdinalIgnoreCase);
}
var otherTenantRecallReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope
{
HostName = "neo-hub.jibo.com",
Path = "/listen",
Kind = "neo-hub-listen",
Token = tokenB,
Text = """{"type":"CLIENT_ASR","transID":"trans-memory-recall-b","data":{"text":"what is my birthday"}}"""
});
Assert.Equal(3, otherTenantRecallReplies.Count);
using var otherSkillPayload = JsonDocument.Parse(otherTenantRecallReplies[2].Text!);
var otherEsml = otherSkillPayload.RootElement
.GetProperty("data")
.GetProperty("action")
.GetProperty("config")
.GetProperty("jcp")
.GetProperty("config")
.GetProperty("play")
.GetProperty("esml")
.GetString();
Assert.Contains("I do not know your birthday yet", otherEsml, StringComparison.OrdinalIgnoreCase);
}
[Fact] [Fact]
public async Task FollowUpTurn_UsesNewTurnStateWithoutLeakingBufferedAudio() public async Task FollowUpTurn_UsesNewTurnStateWithoutLeakingBufferedAudio()
{ {