Add person-aware state and sync roadmap
This commit is contained in:
@@ -654,6 +654,8 @@ Current release theme:
|
|||||||
- Follow-up:
|
- Follow-up:
|
||||||
- add durable persistence path for personal facts
|
- add durable persistence path for personal facts
|
||||||
- broaden fact categories further (multi-person household memory, relationship cues, and corrective updates)
|
- broaden fact categories further (multi-person household memory, relationship cues, and corrective updates)
|
||||||
|
- add explicit person-scoped state so future interactions can distinguish household members inside the same loop
|
||||||
|
- define the first server-to-server sync envelope for durable state before we need it in production
|
||||||
|
|
||||||
### 24. Memory-Triggered Proactivity Baseline
|
### 24. Memory-Triggered Proactivity Baseline
|
||||||
|
|
||||||
@@ -669,6 +671,7 @@ Current release theme:
|
|||||||
- expand proactivity beyond pizza to additional Pegasus-backed categories
|
- expand proactivity beyond pizza to additional Pegasus-backed categories
|
||||||
- add cooldown/throttle policy and observability around proactive offer frequency
|
- add cooldown/throttle policy and observability around proactive offer frequency
|
||||||
- connect memory store to durable multi-tenant persistence
|
- connect memory store to durable multi-tenant persistence
|
||||||
|
- keep the sync story visible so stateful offers can survive a multi-server deployment later
|
||||||
|
|
||||||
### 25. Weather Report-Skill Launch Compatibility
|
### 25. Weather Report-Skill Launch Compatibility
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ The goal is to keep compatibility work steady while shipping personality and cap
|
|||||||
- define tenant boundaries across account, loop, device, and person-memory records
|
- define tenant boundaries across account, loop, device, and person-memory records
|
||||||
- add storage abstractions that can move from in-memory/local JSON to hosted SQL/Blob without reworking behavior layers
|
- add storage abstractions that can move from in-memory/local JSON to hosted SQL/Blob without reworking behavior layers
|
||||||
- implement memory-ready schemas and repository contracts for user facts (names, birthdays, personal dates, preferences) with strict tenant scoping
|
- implement memory-ready schemas and repository contracts for user facts (names, birthdays, personal dates, preferences) with strict tenant scoping
|
||||||
|
- seed person-aware state keys now so future interactions can scope to account + loop + device + person without another shape change
|
||||||
|
- keep stateful interaction flows repository-backed instead of embedding more ad hoc metadata in the websocket layer
|
||||||
|
|
||||||
|
### 6. Multi-Server Sync Path
|
||||||
|
|
||||||
|
- document the eventual sync boundary for stateful data that should move between servers
|
||||||
|
- treat the first pass as repository-local durability, then layer replication and conflict handling on top
|
||||||
|
- prefer explicit change records or versioned state snapshots over implicit last-writer wins when we outgrow a single node
|
||||||
|
- keep cross-server reconciliation out of the hot path until the single-server semantics are stable
|
||||||
|
|
||||||
## First Implemented Slice In `1.0.19`
|
## First Implemented Slice In `1.0.19`
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface ICloudStateStore
|
|||||||
CloudSession OpenSession(string kind, string? deviceId, string? token, string? hostName, string? path);
|
CloudSession OpenSession(string kind, string? deviceId, string? token, string? hostName, string? path);
|
||||||
CloudSession? FindSessionByToken(string token);
|
CloudSession? FindSessionByToken(string token);
|
||||||
IReadOnlyList<LoopRecord> GetLoops();
|
IReadOnlyList<LoopRecord> GetLoops();
|
||||||
|
IReadOnlyList<PersonRecord> GetPeople();
|
||||||
IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null);
|
IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null);
|
||||||
UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? filter);
|
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);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public interface IPersonalMemoryStore
|
|||||||
void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName);
|
void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId);
|
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId, string? PersonId = null);
|
||||||
|
|
||||||
public enum PersonalAffinity
|
public enum PersonalAffinity
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ public sealed class JiboInteractionService(
|
|||||||
randomizer,
|
randomizer,
|
||||||
personalMemoryStore,
|
personalMemoryStore,
|
||||||
BuildWeatherReportDecisionAsync,
|
BuildWeatherReportDecisionAsync,
|
||||||
ResolveTenantScope,
|
turnContext => ResolveTenantScope(turnContext),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (personalReportDecision is not null)
|
if (personalReportDecision is not null)
|
||||||
{
|
{
|
||||||
@@ -92,7 +92,7 @@ public sealed class JiboInteractionService(
|
|||||||
lowered,
|
lowered,
|
||||||
randomizer,
|
randomizer,
|
||||||
personalMemoryStore,
|
personalMemoryStore,
|
||||||
ResolveTenantScope);
|
turnContext => ResolveTenantScope(turnContext));
|
||||||
if (householdListDecision is not null)
|
if (householdListDecision is not null)
|
||||||
{
|
{
|
||||||
return householdListDecision;
|
return householdListDecision;
|
||||||
@@ -151,7 +151,7 @@ public sealed class JiboInteractionService(
|
|||||||
"good_night" => BuildReactiveGreetingDecision(turn, "good_night", referenceLocalTime),
|
"good_night" => BuildReactiveGreetingDecision(turn, "good_night", referenceLocalTime),
|
||||||
"welcome_back" => BuildReactiveGreetingDecision(turn, "welcome_back", referenceLocalTime),
|
"welcome_back" => BuildReactiveGreetingDecision(turn, "welcome_back", referenceLocalTime),
|
||||||
"memory_set_name" => BuildRememberNameDecision(turn, transcript),
|
"memory_set_name" => BuildRememberNameDecision(turn, transcript),
|
||||||
"memory_get_name" => BuildRecallNameDecision(turn),
|
"memory_get_name" => BuildRecallNameDecision(turn, greetingPresence),
|
||||||
"memory_set_birthday" => BuildRememberBirthdayDecision(turn, transcript),
|
"memory_set_birthday" => BuildRememberBirthdayDecision(turn, transcript),
|
||||||
"memory_get_birthday" => BuildRecallBirthdayDecision(turn),
|
"memory_get_birthday" => BuildRecallBirthdayDecision(turn),
|
||||||
"memory_set_important_date" => BuildRememberImportantDateDecision(turn, transcript),
|
"memory_set_important_date" => BuildRememberImportantDateDecision(turn, transcript),
|
||||||
@@ -270,12 +270,18 @@ public sealed class JiboInteractionService(
|
|||||||
|
|
||||||
private string? ResolvePreferredGreetingName(TurnContext turn, GreetingPresenceProfile presence)
|
private string? ResolvePreferredGreetingName(TurnContext turn, GreetingPresenceProfile presence)
|
||||||
{
|
{
|
||||||
var rememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn));
|
var rememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn, presence.PrimaryPersonId));
|
||||||
if (!string.IsNullOrWhiteSpace(rememberedName))
|
if (!string.IsNullOrWhiteSpace(rememberedName))
|
||||||
{
|
{
|
||||||
return ToDisplayName(rememberedName);
|
return ToDisplayName(rememberedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tenantRememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn));
|
||||||
|
if (!string.IsNullOrWhiteSpace(tenantRememberedName))
|
||||||
|
{
|
||||||
|
return ToDisplayName(tenantRememberedName);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId) &&
|
if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId) &&
|
||||||
presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) &&
|
presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) &&
|
||||||
!string.IsNullOrWhiteSpace(firstName))
|
!string.IsNullOrWhiteSpace(firstName))
|
||||||
@@ -372,16 +378,35 @@ public sealed class JiboInteractionService(
|
|||||||
$"Nice to meet you, {name}. I will remember your name.");
|
$"Nice to meet you, {name}. I will remember your name.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private JiboInteractionDecision BuildRecallNameDecision(TurnContext turn)
|
private JiboInteractionDecision BuildRecallNameDecision(TurnContext turn, GreetingPresenceProfile? presence = null)
|
||||||
{
|
{
|
||||||
var name = personalMemoryStore.GetName(ResolveTenantScope(turn));
|
var personScope = ResolveTenantScope(turn, presence?.PrimaryPersonId);
|
||||||
|
var name = personalMemoryStore.GetName(personScope);
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
name = personalMemoryStore.GetName(ResolveTenantScope(turn));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(name) &&
|
||||||
|
presence is not null &&
|
||||||
|
!string.IsNullOrWhiteSpace(presence.PrimaryPersonId) &&
|
||||||
|
presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) &&
|
||||||
|
!string.IsNullOrWhiteSpace(firstName))
|
||||||
|
{
|
||||||
|
name = ToDisplayName(firstName);
|
||||||
|
}
|
||||||
|
|
||||||
|
name = ToDisplayName(name ?? string.Empty);
|
||||||
|
|
||||||
return string.IsNullOrWhiteSpace(name)
|
return string.IsNullOrWhiteSpace(name)
|
||||||
? new JiboInteractionDecision(
|
? new JiboInteractionDecision(
|
||||||
"memory_get_name",
|
"memory_get_name",
|
||||||
"I do not know your name yet. You can say, my name is Alex.")
|
"I do not know your name yet. You can say, my name is Alex.")
|
||||||
: new JiboInteractionDecision(
|
: new JiboInteractionDecision(
|
||||||
"memory_get_name",
|
"memory_get_name",
|
||||||
$"You told me your name is {name}.");
|
presence is not null && !string.IsNullOrWhiteSpace(presence.PrimaryPersonId)
|
||||||
|
? $"I think you are {name}."
|
||||||
|
: $"You told me your name is {name}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private JiboInteractionDecision BuildRememberBirthdayDecision(TurnContext turn, string transcript)
|
private JiboInteractionDecision BuildRememberBirthdayDecision(TurnContext turn, string transcript)
|
||||||
@@ -4137,12 +4162,15 @@ public sealed class JiboInteractionService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PersonalMemoryTenantScope ResolveTenantScope(TurnContext turn)
|
private static PersonalMemoryTenantScope ResolveTenantScope(TurnContext turn, string? personId = null)
|
||||||
{
|
{
|
||||||
var accountId = ReadTenantAttribute(turn, "accountId") ?? "usr_openjibo_owner";
|
var accountId = ReadTenantAttribute(turn, "accountId") ?? "usr_openjibo_owner";
|
||||||
var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop";
|
var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop";
|
||||||
var deviceId = turn.DeviceId ?? ReadTenantAttribute(turn, "deviceId") ?? "unknown-device";
|
var deviceId = turn.DeviceId ?? ReadTenantAttribute(turn, "deviceId") ?? "unknown-device";
|
||||||
return new PersonalMemoryTenantScope(accountId, loopId, deviceId);
|
var resolvedPersonId = !string.IsNullOrWhiteSpace(personId)
|
||||||
|
? personId
|
||||||
|
: ReadTenantAttribute(turn, "personId") ?? ReadTenantAttribute(turn, "speakerId");
|
||||||
|
return new PersonalMemoryTenantScope(accountId, loopId, deviceId, resolvedPersonId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ReadTenantAttribute(TurnContext turn, string key)
|
private static string? ReadTenantAttribute(TurnContext turn, string key)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Jibo.Cloud.Domain.Models;
|
||||||
|
|
||||||
|
public sealed class PersonRecord
|
||||||
|
{
|
||||||
|
public string PersonId { get; init; } = "person-openjibo-owner";
|
||||||
|
public string AccountId { get; init; } = "usr_openjibo_owner";
|
||||||
|
public string LoopId { get; init; } = "openjibo-default-loop";
|
||||||
|
public string RobotId { get; init; } = "my-robot-name";
|
||||||
|
public string DisplayName { get; init; } = "Jibo Owner";
|
||||||
|
public string? Alias { get; init; }
|
||||||
|
public bool IsPrimary { get; init; } = true;
|
||||||
|
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
private readonly List<MediaRecord> _media = [];
|
private readonly List<MediaRecord> _media = [];
|
||||||
private readonly List<BackupRecord> _backups = [];
|
private readonly List<BackupRecord> _backups = [];
|
||||||
private readonly List<LoopRecord> _loops;
|
private readonly List<LoopRecord> _loops;
|
||||||
|
private readonly List<PersonRecord> _people;
|
||||||
private DeviceRegistration _robot;
|
private DeviceRegistration _robot;
|
||||||
private RobotProfile _robotProfile;
|
private RobotProfile _robotProfile;
|
||||||
|
|
||||||
@@ -60,6 +61,29 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
RobotFriendlyId = _robot.DeviceId
|
RobotFriendlyId = _robot.DeviceId
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
_people =
|
||||||
|
[
|
||||||
|
new PersonRecord
|
||||||
|
{
|
||||||
|
PersonId = "person-openjibo-owner",
|
||||||
|
AccountId = _account.AccountId,
|
||||||
|
LoopId = _loops[0].LoopId,
|
||||||
|
RobotId = _robot.RobotId,
|
||||||
|
DisplayName = $"{_account.FirstName} {_account.LastName}",
|
||||||
|
Alias = _account.FirstName,
|
||||||
|
IsPrimary = true
|
||||||
|
},
|
||||||
|
new PersonRecord
|
||||||
|
{
|
||||||
|
PersonId = "person-openjibo-household-member",
|
||||||
|
AccountId = _account.AccountId,
|
||||||
|
LoopId = _loops[0].LoopId,
|
||||||
|
RobotId = _robot.RobotId,
|
||||||
|
DisplayName = "OpenJibo Household Member",
|
||||||
|
Alias = "Household Member",
|
||||||
|
IsPrimary = false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
_updates = [];
|
_updates = [];
|
||||||
LoadPersistentState();
|
LoadPersistentState();
|
||||||
@@ -154,6 +178,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
|||||||
|
|
||||||
public IReadOnlyList<LoopRecord> GetLoops() => _loops.ToArray();
|
public IReadOnlyList<LoopRecord> GetLoops() => _loops.ToArray();
|
||||||
|
|
||||||
|
public IReadOnlyList<PersonRecord> GetPeople() => _people.ToArray();
|
||||||
|
|
||||||
public IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null)
|
public IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null)
|
||||||
{
|
{
|
||||||
return _updates
|
return _updates
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ public sealed class InMemoryPersonalMemoryStore : IPersonalMemoryStore
|
|||||||
|
|
||||||
private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
|
private static string BuildTenantKey(PersonalMemoryTenantScope tenantScope)
|
||||||
{
|
{
|
||||||
return $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}";
|
return string.IsNullOrWhiteSpace(tenantScope.PersonId)
|
||||||
|
? $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}"
|
||||||
|
: $"{tenantScope.AccountId}|{tenantScope.LoopId}|{tenantScope.DeviceId}|{tenantScope.PersonId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NormalizeCategory(string category)
|
private static string NormalizeCategory(string category)
|
||||||
|
|||||||
@@ -216,4 +216,17 @@ public sealed class JiboCloudProtocolServiceTests
|
|||||||
using var payload = JsonDocument.Parse(result.BodyText);
|
using var payload = JsonDocument.Parse(result.BodyText);
|
||||||
Assert.Single(payload.RootElement.EnumerateArray());
|
Assert.Single(payload.RootElement.EnumerateArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InMemoryCloudStateStore_SeedsPeopleForTheDefaultAccountLoop()
|
||||||
|
{
|
||||||
|
var store = new InMemoryCloudStateStore();
|
||||||
|
|
||||||
|
var people = store.GetPeople();
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,54 @@ public sealed class JiboInteractionServiceTests
|
|||||||
Assert.True(DateTimeOffset.TryParse(decision.ContextUpdates[GreetingLastReactiveUtcKey]?.ToString(), out _));
|
Assert.True(DateTimeOffset.TryParse(decision.ContextUpdates[GreetingLastReactiveUtcKey]?.ToString(), out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildDecisionAsync_GoodMorning_UsesPersonScopedNameWhenSpeakerIsKnown()
|
||||||
|
{
|
||||||
|
var memoryStore = new InMemoryPersonalMemoryStore();
|
||||||
|
memoryStore.SetName(new PersonalMemoryTenantScope("acct-a", "loop-a", "device-a", "person-1"), "alex");
|
||||||
|
var service = CreateService(memoryStore);
|
||||||
|
|
||||||
|
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||||
|
{
|
||||||
|
RawTranscript = "good morning",
|
||||||
|
NormalizedTranscript = "good morning",
|
||||||
|
Attributes = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["accountId"] = "acct-a",
|
||||||
|
["loopId"] = "loop-a",
|
||||||
|
["context"] = """{"runtime":{"perception":{"speaker":"person-1"},"loop":{"users":[{"id":"person-1","firstName":"jake"}]}}}"""
|
||||||
|
},
|
||||||
|
DeviceId = "device-a"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("good_morning", decision.IntentName);
|
||||||
|
Assert.Equal("Good morning, Alex. It is great to see you.", decision.ReplyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildDecisionAsync_WhoAmI_UsesPersonScopedNameWhenSpeakerIsKnown()
|
||||||
|
{
|
||||||
|
var memoryStore = new InMemoryPersonalMemoryStore();
|
||||||
|
memoryStore.SetName(new PersonalMemoryTenantScope("acct-b", "loop-b", "device-b", "person-2"), "sam");
|
||||||
|
var service = CreateService(memoryStore);
|
||||||
|
|
||||||
|
var decision = await service.BuildDecisionAsync(new TurnContext
|
||||||
|
{
|
||||||
|
RawTranscript = "who am i",
|
||||||
|
NormalizedTranscript = "who am i",
|
||||||
|
Attributes = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["accountId"] = "acct-b",
|
||||||
|
["loopId"] = "loop-b",
|
||||||
|
["context"] = """{"runtime":{"perception":{"speaker":"person-2"},"loop":{"users":[{"id":"person-2","firstName":"sam"}]}}}"""
|
||||||
|
},
|
||||||
|
DeviceId = "device-b"
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("memory_get_name", decision.IntentName);
|
||||||
|
Assert.Equal("I think you are Sam.", decision.ReplyText);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildDecisionAsync_TriggerWithKnownIdentity_BuildsProactiveGreetingAndContext()
|
public async Task BuildDecisionAsync_TriggerWithKnownIdentity_BuildsProactiveGreetingAndContext()
|
||||||
{
|
{
|
||||||
@@ -803,7 +851,7 @@ public sealed class JiboInteractionServiceTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
Assert.Equal("memory_get_name", recallDecision.IntentName);
|
Assert.Equal("memory_get_name", recallDecision.IntentName);
|
||||||
Assert.Equal("You told me your name is alex.", recallDecision.ReplyText);
|
Assert.Equal("You told me your name is Alex.", recallDecision.ReplyText);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user