Add tenant-scoped personal memory facts
This commit is contained in:
@@ -600,6 +600,20 @@ Current release theme:
|
||||
- expand command-vs-question splits to more expressive intents (pizza, surprise, photo prompts)
|
||||
- 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
|
||||
|
||||
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`:
|
||||
|
||||
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
|
||||
4. Dialog parsing expansion and ambiguity guardrails
|
||||
5. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -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";
|
||||
@@ -1109,8 +1198,116 @@ public sealed class JiboInteractionService(
|
||||
"what s your birthday",
|
||||
"what is your birthday",
|
||||
"when were you born",
|
||||
"what day is your birthday") ||
|
||||
loweredTranscript.Contains("birthday", StringComparison.Ordinal);
|
||||
"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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task BuildDecisionAsync_MakePizza_UsesOriginalMimStylePayload()
|
||||
{
|
||||
@@ -1185,11 +1295,12 @@ public sealed class JiboInteractionServiceTests
|
||||
Assert.Equal("aglet", decision.SkillPayload!["guess"]);
|
||||
}
|
||||
|
||||
private static JiboInteractionService CreateService()
|
||||
private static JiboInteractionService CreateService(IPersonalMemoryStore? personalMemoryStore = null)
|
||||
{
|
||||
return new JiboInteractionService(
|
||||
new JiboExperienceContentCache(new InMemoryJiboExperienceContentRepository()),
|
||||
new FirstItemRandomizer());
|
||||
new FirstItemRandomizer(),
|
||||
personalMemoryStore ?? new InMemoryPersonalMemoryStore());
|
||||
}
|
||||
|
||||
private sealed class FirstItemRandomizer : IJiboRandomizer
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class JiboWebSocketServiceTests
|
||||
_store = new InMemoryCloudStateStore();
|
||||
var contentRepository = new InMemoryJiboExperienceContentRepository();
|
||||
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(
|
||||
[
|
||||
new SyntheticBufferedAudioSttStrategy()
|
||||
@@ -2906,6 +2906,74 @@ public sealed class JiboWebSocketServiceTests
|
||||
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]
|
||||
public async Task FollowUpTurn_UsesNewTurnStateWithoutLeakingBufferedAudio()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user