Document commute provider seam for personal report

This commit is contained in:
Jacob Dubin
2026-05-20 23:25:41 -05:00
parent c76af83d7e
commit 884b2215c7
21 changed files with 1046 additions and 55 deletions

View File

@@ -103,6 +103,33 @@ The default country code is `US`, but you can override it with:
If you later add custom holiday authoring, disabled records can be used to suppress a holiday for a
loop without removing the underlying system holiday source.
## Calendar Wiring
Calendar report output is now driven by a loop-scoped in-process provider.
The provider currently:
- reads persisted loop calendar events
- folds in birthday and holiday dates that already live in the loop-scoped holiday list
- returns a safe empty calendar view when nothing is scheduled
This keeps the personal report moving toward Pegasus-style household-aware output without forcing a
full external calendar integration yet.
## Commute Wiring
Commute report output is now driven by a loop-scoped commute profile plus a provider seam.
The provider currently:
- reads persisted loop commute profiles
- returns a setup view when commute is missing or incomplete
- computes commute timing from the loop profile and the current clock
- keeps the personal report flow aligned with the stock `Commute_*` shape
The provider is intentionally conservative for now. It preserves the old report shape and gives us
room to add a richer travel-time source later without changing the behavior layer again.
## Recovery Strategy
The first supported device path is:

View File

@@ -44,5 +44,9 @@ public interface ICloudStateStore
IReadOnlyList<KeyRequestRecord> GetBinaryRequests();
IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null);
HolidayRecord UpsertHoliday(HolidayRecord holiday);
IReadOnlyList<CommuteProfileRecord> GetCommuteProfiles(string? loopId = null);
CommuteProfileRecord UpsertCommuteProfile(CommuteProfileRecord commuteProfile);
IReadOnlyList<CalendarEventRecord> GetCalendarEvents(string? loopId = null);
CalendarEventRecord UpsertCalendarEvent(CalendarEventRecord calendarEvent);
void UpdateRobot(DeviceRegistration registration);
}

View File

@@ -12,4 +12,7 @@ public sealed record CommuteReportSnapshot(
string Summary,
int DurationMinutes,
string? Mode = null,
bool EventIsEarly = false);
bool EventIsEarly = false,
int MinutesUntilWork = 0,
int ExtraMinutes = 0,
bool RequiresSetup = false);

View File

@@ -44,7 +44,20 @@ public sealed class JiboConditionedReply
public IReadOnlyList<string> CalendarNothingReplies { get; init; } = [];
public IReadOnlyList<string> CalendarServiceDownReplies { get; init; } = [];
public IReadOnlyList<string> CalendarOutroReplies { get; init; } = [];
public IReadOnlyList<string> CommuteAppSetupReplies { get; init; } = [];
public IReadOnlyList<string> CommuteConfirmSpeakerReplies { get; init; } = [];
public IReadOnlyList<string> CommuteNowReplies { get; init; } = [];
public IReadOnlyList<string> CommuteMinutesLeftReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDepartTimeNormalReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDepartTimeNotNormalReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDriveNormalReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDriveLateReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDriveHurryReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDrivePoorReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDriveTerribleReplies { get; init; } = [];
public IReadOnlyList<string> CommuteTransportNormalReplies { get; init; } = [];
public IReadOnlyList<string> CommuteTransportLateReplies { get; init; } = [];
public IReadOnlyList<string> CommuteTransportHurryReplies { get; init; } = [];
public IReadOnlyList<string> CommuteServiceDownReplies { get; init; } = [];
public IReadOnlyList<string> NewsIntroReplies { get; init; } = [];
public IReadOnlyList<string> NewsCategoryIntroReplies { get; init; } = [];

View File

@@ -355,12 +355,47 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
private ProtocolDispatchResult HandlePerson(string operation, ProtocolEnvelope envelope)
{
if (!operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(Array.Empty<object>());
var body = envelope.TryParseBody();
var loopId = ReadString(body, "loopId");
return ProtocolDispatchResult.Ok(stateStore.GetHolidays(loopId).Select(MapHoliday));
if (operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase))
{
var loopId = ReadString(body, "loopId");
return ProtocolDispatchResult.Ok(stateStore.GetHolidays(loopId).Select(MapHoliday));
}
if (operation.Equals("ListCommute", StringComparison.OrdinalIgnoreCase))
{
var loopId = ReadString(body, "loopId");
return ProtocolDispatchResult.Ok(stateStore.GetCommuteProfiles(loopId).Select(MapCommute));
}
if (operation.Equals("UpsertCommute", StringComparison.OrdinalIgnoreCase))
{
var hasIsEnabled = body is { } enabledBody && enabledBody.TryGetProperty("isEnabled", out _);
var hasIsComplete = body is { } completeBody && completeBody.TryGetProperty("isComplete", out _);
var workHour = ReadLong(body, "workHour");
var workMinute = ReadLong(body, "workMinute");
var typicalDurationMinutes = ReadLong(body, "typicalDurationMinutes");
var commute = new CommuteProfileRecord
{
Id = ReadString(body, "id") ?? string.Empty,
LoopId = ReadString(body, "loopId") ?? string.Empty,
MemberId = ReadString(body, "memberId"),
IsEnabled = hasIsEnabled ? ReadBool(body, "isEnabled") : true,
IsComplete = hasIsComplete ? ReadBool(body, "isComplete") : true,
Mode = ReadString(body, "mode") ?? "driving",
WorkHour = workHour is > 0 and < 24 ? (int)workHour.Value : 8,
WorkMinute = workMinute is >= 0 and < 60 ? (int)workMinute.Value : 30,
OriginName = ReadString(body, "originName"),
DestinationName = ReadString(body, "destinationName"),
TypicalDurationMinutes = typicalDurationMinutes is > 0
? (int)typicalDurationMinutes.Value
: 25
};
return ProtocolDispatchResult.Ok(MapCommute(stateStore.UpsertCommuteProfile(commute)));
}
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
private ProtocolDispatchResult HandleBackup(string operation, ProtocolEnvelope envelope)
@@ -572,6 +607,26 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
};
}
private static object MapCommute(CommuteProfileRecord commute)
{
return new
{
id = commute.Id,
loopId = commute.LoopId,
memberId = commute.MemberId,
isEnabled = commute.IsEnabled,
isComplete = commute.IsComplete,
mode = commute.Mode,
workHour = commute.WorkHour,
workMinute = commute.WorkMinute,
originName = commute.OriginName,
destinationName = commute.DestinationName,
typicalDurationMinutes = commute.TypicalDurationMinutes,
created = commute.Created,
updated = commute.Updated
};
}
private static object MapMedia(MediaRecord item)
{
return new

View File

@@ -1626,6 +1626,11 @@ public sealed class JiboInteractionService(
"commute",
ChooseCommuteServiceDownReply(catalog));
if (snapshot.RequiresSetup)
return new JiboInteractionDecision(
"commute_setup",
ChooseCommuteAppSetupReply(catalog));
return new JiboInteractionDecision(
"commute",
BuildCommuteSpokenReply(snapshot, catalog));
@@ -1754,47 +1759,122 @@ public sealed class JiboInteractionService(
{
var duration = snapshot.DurationMinutes;
var durationText = duration <= 1 ? "1 minute" : $"{duration} minutes";
var minutesLeft = snapshot.MinutesUntilWork;
var minutesLeftText = minutesLeft <= 1 ? "1 minute" : $"{Math.Abs(minutesLeft)} minutes";
var mode = string.IsNullOrWhiteSpace(snapshot.Mode) ? "driving" : snapshot.Mode.Trim();
var template = ChooseCommuteTemplate(snapshot, catalog, mode);
var reply = RenderCommuteTemplate(template, durationText, minutesLeftText);
var template = ChooseCommuteTemplate(catalog.CommuteNowReplies, mode,
"For your commute, it should take about ${skill.commute.durationMins} minutes.");
if (minutesLeft > 0 && minutesLeft < 30)
{
var minutesTemplate = ChooseShortestTemplate(catalog.CommuteMinutesLeftReplies)
?? "That's in about ${skill.commute.minsLeft} minutes.";
reply = $"{reply} {RenderCommuteTemplate(minutesTemplate, durationText, minutesLeftText)}";
}
if (minutesLeft > 0 && minutesLeft < 120)
{
var departTemplate = ChooseCommuteDepartTimeTemplate(snapshot, catalog, mode);
if (!string.IsNullOrWhiteSpace(departTemplate))
reply = $"{reply} {RenderCommuteTemplate(departTemplate, durationText, minutesLeftText)}";
}
return reply.Replace(" ", " ", StringComparison.Ordinal).Trim();
}
private string ChooseCommuteAppSetupReply(JiboExperienceCatalog catalog)
{
return SelectLegacyReply(
catalog.CommuteAppSetupReplies,
[
"I need your commute settings before I can give you a commute report."
]);
}
private static string ChooseCommuteTemplate(
CommuteReportSnapshot snapshot,
JiboExperienceCatalog catalog,
string mode)
{
var minutesUntilWork = snapshot.MinutesUntilWork;
var extraMinutes = Math.Max(0, snapshot.ExtraMinutes);
var isLate = minutesUntilWork <= 0;
var isHurry = minutesUntilWork > 0 && minutesUntilWork <= 10;
var isNormal = !isLate && !isHurry;
var isFarAway = minutesUntilWork > 120 || minutesUntilWork < -30;
var hasTrafficSeverity = minutesUntilWork > 0;
var isTerrible = hasTrafficSeverity && extraMinutes >= 15;
var isPoor = hasTrafficSeverity && extraMinutes >= 5;
var loweredMode = mode.Trim().ToLowerInvariant();
IReadOnlyList<string> candidates = loweredMode switch
{
"walking" when isHurry => catalog.CommuteTransportHurryReplies,
"walking" when isLate => catalog.CommuteTransportLateReplies,
"walking" => catalog.CommuteTransportNormalReplies,
"transit" when isHurry => catalog.CommuteTransportHurryReplies,
"transit" when isLate => catalog.CommuteTransportLateReplies,
"transit" => catalog.CommuteTransportNormalReplies,
"bicycling" when isHurry => catalog.CommuteDriveHurryReplies,
"bicycling" when isLate => catalog.CommuteDriveLateReplies,
"bicycling" => catalog.CommuteDriveNormalReplies,
_ when isFarAway => catalog.CommuteNowReplies,
_ when isTerrible => catalog.CommuteDriveTerribleReplies,
_ when isPoor => catalog.CommuteDrivePoorReplies,
_ when isHurry => catalog.CommuteDriveHurryReplies,
_ when isLate => catalog.CommuteDriveLateReplies,
_ when isNormal => catalog.CommuteDriveNormalReplies,
_ => catalog.CommuteNowReplies
};
if (candidates.Count == 0)
return "For your commute, it should take about ${skill.commute.durationMins} minutes.";
var selected = ChooseShortestTemplate(candidates);
return string.IsNullOrWhiteSpace(selected)
? "For your commute, it should take about ${skill.commute.durationMins} minutes."
: selected!;
}
private static string ChooseCommuteDepartTimeTemplate(
CommuteReportSnapshot snapshot,
JiboExperienceCatalog catalog,
string mode)
{
var loweredMode = mode.Trim().ToLowerInvariant();
var templates = snapshot.MinutesUntilWork <= 0
? catalog.CommuteDepartTimeNotNormalReplies
: catalog.CommuteDepartTimeNormalReplies;
if (templates.Count == 0) return string.Empty;
var selected = ChooseShortestTemplate(templates);
if (!string.IsNullOrWhiteSpace(selected)) return selected!;
return loweredMode switch
{
"walking" => "If you leave at the usual time, that should work out fine.",
"transit" => "If you leave at the usual time, that should work out fine.",
_ => "If you leave at the usual time, that should work out fine."
};
}
private static string RenderCommuteTemplate(string template, string durationText, string minutesLeftText)
{
return template
.Replace("${skill.commute.durationMins}", durationText, StringComparison.OrdinalIgnoreCase)
.Replace("${skill.commute.minsLeft}", minutesLeftText, StringComparison.OrdinalIgnoreCase)
.Replace("${speaker}", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
}
private static string ChooseCommuteTemplate(
IReadOnlyList<string> templates,
string mode,
string fallback)
private static string? ChooseShortestTemplate(IEnumerable<string> templates)
{
if (templates.Count == 0) return fallback;
var loweredMode = mode.Trim().ToLowerInvariant();
var filtered = templates.Where(template =>
{
var lowered = template.ToLowerInvariant();
return loweredMode switch
{
"walking" => lowered.Contains("walk", StringComparison.OrdinalIgnoreCase),
"transit" => lowered.Contains("public transportation", StringComparison.OrdinalIgnoreCase) ||
lowered.Contains("transit", StringComparison.OrdinalIgnoreCase) ||
lowered.Contains("transportation", StringComparison.OrdinalIgnoreCase),
"bicycling" => lowered.Contains("bike", StringComparison.OrdinalIgnoreCase) ||
lowered.Contains("ride", StringComparison.OrdinalIgnoreCase),
_ => lowered.Contains("drive", StringComparison.OrdinalIgnoreCase) ||
lowered.Contains("commute", StringComparison.OrdinalIgnoreCase)
};
}).ToList();
var selected = filtered.Count > 0
? filtered.OrderBy(static template => template.Length).First()
: templates.OrderBy(static template => template.Length).FirstOrDefault();
return string.IsNullOrWhiteSpace(selected) ? fallback : selected!;
return templates
.Where(static template => !string.IsNullOrWhiteSpace(template))
.OrderBy(static template => template.Length)
.FirstOrDefault();
}
private static string BuildWeeklyForecastSpokenReply(

View File

@@ -0,0 +1,16 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class CalendarEventRecord
{
public string Id { get; init; } = $"calendar-{Guid.NewGuid():N}";
public string LoopId { get; init; } = "openjibo-default-loop";
public string Summary { get; init; } = "Calendar event";
public string? TimeLabel { get; init; }
public DateOnly Date { get; init; } = DateOnly.FromDateTime(DateTime.UtcNow);
public DateOnly? EndDate { get; init; }
public bool IsAllDay { get; init; }
public bool IsEnabled { get; init; } = true;
public string Source { get; init; } = "manual";
public string? MemberId { get; init; }
public DateTimeOffset Created { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,18 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class CommuteProfileRecord
{
public string Id { get; init; } = $"commute-{Guid.NewGuid():N}";
public string LoopId { get; init; } = "openjibo-default-loop";
public string? MemberId { get; init; }
public bool IsEnabled { get; init; } = true;
public bool IsComplete { get; init; } = true;
public string Mode { get; init; } = "driving";
public int WorkHour { get; init; } = 8;
public int WorkMinute { get; init; } = 30;
public string? OriginName { get; init; } = "home";
public string? DestinationName { get; init; } = "work";
public int TypicalDurationMinutes { get; init; } = 25;
public DateTimeOffset Created { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset Updated { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,75 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Infrastructure.Calendar;
public sealed class CloudStateCalendarReportProvider(ICloudStateStore cloudStateStore) : ICalendarReportProvider
{
public Task<CalendarReportSnapshot?> GetReportAsync(
TurnContext turn,
CancellationToken cancellationToken = default)
{
var loopId = ResolveLoopId(turn);
var today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.Date);
var tomorrow = today.AddDays(1);
var calendarEvents = cloudStateStore.GetCalendarEvents(loopId)
.Where(static calendarEvent => calendarEvent.IsEnabled)
.Where(calendarEvent => calendarEvent.Date != default)
.ToArray();
var holidays = cloudStateStore.GetHolidays(loopId)
.Where(static holiday => holiday.IsEnabled)
.Where(holiday => holiday.Date != default)
.ToArray();
var todaySummaries = new List<string>();
var todayTimes = new List<string>();
var tomorrowSummaries = new List<string>();
foreach (var entry in calendarEvents
.Select(calendarEvent => (
Summary: calendarEvent.Summary,
TimeLabel: calendarEvent.TimeLabel ?? "all day",
Date: calendarEvent.Date))
.Concat(ToCalendarEntries(holidays)))
{
if (entry.Date == today)
{
todaySummaries.Add(entry.Summary);
todayTimes.Add(entry.TimeLabel);
continue;
}
if (entry.Date == tomorrow)
tomorrowSummaries.Add(entry.Summary);
}
return Task.FromResult<CalendarReportSnapshot?>(
new CalendarReportSnapshot(todaySummaries, todayTimes, tomorrowSummaries));
}
private static string ResolveLoopId(TurnContext turn)
{
if (turn.Attributes.TryGetValue("loopId", out var loopValue) &&
loopValue is not null &&
!string.IsNullOrWhiteSpace(loopValue.ToString()))
return loopValue.ToString()!.Trim();
return "openjibo-default-loop";
}
private static IEnumerable<(string Summary, string TimeLabel, DateOnly Date)> ToCalendarEntries(
IEnumerable<HolidayRecord> holidays)
{
foreach (var holiday in holidays)
{
yield return (
holiday.Name,
"all day",
holiday.Date);
}
}
}

View File

@@ -0,0 +1,152 @@
using System.Text.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Infrastructure.Commute;
public sealed class CloudStateCommuteReportProvider(ICloudStateStore cloudStateStore) : ICommuteReportProvider
{
private static readonly Regex TimeLabelRegex = new(
@"(?<hour>\d{1,2})(?::(?<minute>\d{2}))?\s*(?<period>a\.?m\.?|p\.?m\.?)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public Task<CommuteReportSnapshot?> GetReportAsync(
TurnContext turn,
CancellationToken cancellationToken = default)
{
var loopId = ResolveLoopId(turn);
var memberId = ResolveMemberId(turn);
var commuteProfiles = cloudStateStore.GetCommuteProfiles(loopId);
var commute = !string.IsNullOrWhiteSpace(memberId)
? commuteProfiles.FirstOrDefault(profile =>
profile.IsEnabled &&
!string.IsNullOrWhiteSpace(profile.MemberId) &&
string.Equals(profile.MemberId, memberId, StringComparison.OrdinalIgnoreCase))
: null;
commute ??= commuteProfiles.FirstOrDefault(profile => profile.IsEnabled);
if (commute is null || !commute.IsComplete)
{
return Task.FromResult<CommuteReportSnapshot?>(
new CommuteReportSnapshot(string.Empty, string.Empty, 0, RequiresSetup: true));
}
var now = DateTimeOffset.Now;
var workTarget = ResolveWorkTarget(now, commute);
var earlyTarget = ResolveEarlyCalendarTarget(loopId, now, workTarget);
var arrivalTarget = earlyTarget ?? workTarget;
var minutesUntilWork = (int)Math.Round((arrivalTarget - now).TotalMinutes);
var durationMinutes = commute.TypicalDurationMinutes > 0 ? commute.TypicalDurationMinutes : 25;
var extraMinutes = Math.Max(0, durationMinutes - Math.Max(0, minutesUntilWork));
var summary = commute.Mode.Trim().ToLowerInvariant() switch
{
"walking" => "your walk to work",
"transit" => "your trip to work by public transportation",
"bicycling" => "your bike ride to work",
_ => "your drive to work"
};
return Task.FromResult<CommuteReportSnapshot?>(
new CommuteReportSnapshot(
string.IsNullOrWhiteSpace(commute.DestinationName) ? "work" : commute.DestinationName.Trim(),
summary,
durationMinutes,
commute.Mode,
earlyTarget is not null,
minutesUntilWork,
extraMinutes));
}
private static DateTimeOffset ResolveWorkTarget(DateTimeOffset now, CommuteProfileRecord commute)
{
var localDate = now.Date;
var workTime = new DateTimeOffset(
localDate.Year,
localDate.Month,
localDate.Day,
Math.Clamp(commute.WorkHour, 0, 23),
Math.Clamp(commute.WorkMinute, 0, 59),
0,
now.Offset);
return workTime;
}
private DateTimeOffset? ResolveEarlyCalendarTarget(
string loopId,
DateTimeOffset now,
DateTimeOffset workTarget)
{
var today = DateOnly.FromDateTime(now.DateTime);
DateTimeOffset? earliest = null;
foreach (var calendarEvent in cloudStateStore.GetCalendarEvents(loopId)
.Where(static calendarEvent => calendarEvent.IsEnabled)
.Where(calendarEvent => calendarEvent.Date == today))
{
if (!TryParseTimeLabel(calendarEvent.TimeLabel, now, out var eventTime)) continue;
if (eventTime >= workTarget) continue;
if (earliest is null || eventTime < earliest)
earliest = eventTime;
}
return earliest;
}
private static bool TryParseTimeLabel(string? timeLabel, DateTimeOffset now, out DateTimeOffset parsed)
{
parsed = default;
if (string.IsNullOrWhiteSpace(timeLabel)) return false;
var match = TimeLabelRegex.Match(timeLabel);
if (!match.Success) return false;
if (!int.TryParse(match.Groups["hour"].Value, out var hour)) return false;
var minute = match.Groups["minute"].Success && int.TryParse(match.Groups["minute"].Value, out var parsedMinute)
? parsedMinute
: 0;
var period = match.Groups["period"].Value.ToLowerInvariant();
hour %= 12;
if (period.StartsWith("p", StringComparison.Ordinal) && hour < 12) hour += 12;
if (period.StartsWith("a", StringComparison.Ordinal) && hour == 12) hour = 0;
parsed = new DateTimeOffset(
now.Year,
now.Month,
now.Day,
hour,
minute,
0,
now.Offset);
return true;
}
private static string? ResolveLoopId(TurnContext turn)
{
if (turn.Attributes.TryGetValue("loopId", out var loopValue) &&
loopValue is not null &&
!string.IsNullOrWhiteSpace(loopValue.ToString()))
return loopValue.ToString()!.Trim();
return "openjibo-default-loop";
}
private static string? ResolveMemberId(TurnContext turn)
{
if (turn.Attributes.TryGetValue("personId", out var personValue) &&
personValue is not null &&
!string.IsNullOrWhiteSpace(personValue.ToString()))
return personValue.ToString()!.Trim();
if (turn.Attributes.TryGetValue("speakerId", out var speakerValue) &&
speakerValue is not null &&
!string.IsNullOrWhiteSpace(speakerValue.ToString()))
return speakerValue.ToString()!.Trim();
return null;
}
}

View File

@@ -180,11 +180,77 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
"I heard your calendar request. The cloud knows the phrase, but the real calendar integration is still ahead.",
"Calendar is recognized. We still need to connect the actual service path."
],
CommuteAppSetupReplies =
[
"I need your commute settings before I can give you a commute report."
],
CommuteConfirmSpeakerReplies =
[
"Let me make sure I have the right speaker for your commute."
],
CommuteReplies =
[
"I heard your commute request. That one is recognized, but not fully implemented yet.",
"Commute is on the discovery list now. The real travel answer still needs a provider."
],
CommuteNowReplies =
[
"For your commute, it should take about {duration}.",
"If you head out now, it should take about {duration}."
],
CommuteMinutesLeftReplies =
[
"That's in about {minutes} minutes.",
"That's about {minutes} minutes from now."
],
CommuteDepartTimeNormalReplies =
[
"If you leave at the usual time, that should work out fine."
],
CommuteDepartTimeNotNormalReplies =
[
"Your leave-time looks a little off today."
],
CommuteDriveNormalReplies =
[
"Traffic looks about normal today.",
"Your drive today looks pretty normal."
],
CommuteDriveLateReplies =
[
"Looking at traffic, if you left now, it'd be a little late for work.",
"For your drive, you look a little late today."
],
CommuteDriveHurryReplies =
[
"You should've left a few minutes ago!",
"You'd better get moving."
],
CommuteDrivePoorReplies =
[
"Traffic looks a little rough today.",
"Your drive looks pretty slow right now."
],
CommuteDriveTerribleReplies =
[
"Traffic looks terrible today.",
"Your drive is going to be rough."
],
CommuteTransportNormalReplies =
[
"Your public transportation commute looks pretty normal.",
"Transit looks about normal today."
],
CommuteTransportLateReplies =
[
"Your transit commute looks like it may be a little late today.",
"You might be late if you leave now and take transit."
],
CommuteTransportHurryReplies =
[
"You should've left a few minutes ago if you want transit to work.",
"You're running a little late for transit."
],
NewsReplies =
[
"I heard your news request. That path is still a future cloud integration.",

View File

@@ -170,8 +170,47 @@ public static class LegacyMimCatalogImporter
if (fileName.StartsWith("CalendarOutro", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CalendarOutro;
if (fileName.StartsWith("CommuteAppSetup", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteAppSetup;
if (fileName.StartsWith("CommuteConfirmSpeaker", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteConfirmSpeaker;
if (fileName.StartsWith("CommuteNow", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.CommuteNow;
if (fileName.StartsWith("CommuteMinutesLeft", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteMinutesLeft;
if (fileName.StartsWith("CommuteDepartTimeNormal", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDepartTimeNormal;
if (fileName.StartsWith("CommuteDepartTimeNotNormal", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDepartTimeNotNormal;
if (fileName.StartsWith("CommuteDriveNormal", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDriveNormal;
if (fileName.StartsWith("CommuteDriveLate", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDriveLate;
if (fileName.StartsWith("CommuteDriveHurry", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDriveHurry;
if (fileName.StartsWith("CommuteDrivePoor", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDrivePoor;
if (fileName.StartsWith("CommuteDriveTerrible", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDriveTerrible;
if (fileName.StartsWith("CommuteTransportNormal", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteTransportNormal;
if (fileName.StartsWith("CommuteTransportLate", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteTransportLate;
if (fileName.StartsWith("CommuteTransportHurry", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteTransportHurry;
if (fileName.StartsWith("CommuteServiceDown", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteServiceDown;
@@ -295,7 +334,26 @@ public static class LegacyMimCatalogImporter
CalendarServiceDownReplies = Merge(baseCatalog.CalendarServiceDownReplies,
importedCatalog.CalendarServiceDownReplies),
CalendarOutroReplies = Merge(baseCatalog.CalendarOutroReplies, importedCatalog.CalendarOutroReplies),
CommuteAppSetupReplies = Merge(baseCatalog.CommuteAppSetupReplies, importedCatalog.CommuteAppSetupReplies),
CommuteConfirmSpeakerReplies = Merge(baseCatalog.CommuteConfirmSpeakerReplies,
importedCatalog.CommuteConfirmSpeakerReplies),
CommuteNowReplies = Merge(baseCatalog.CommuteNowReplies, importedCatalog.CommuteNowReplies),
CommuteMinutesLeftReplies = Merge(baseCatalog.CommuteMinutesLeftReplies, importedCatalog.CommuteMinutesLeftReplies),
CommuteDepartTimeNormalReplies = Merge(baseCatalog.CommuteDepartTimeNormalReplies,
importedCatalog.CommuteDepartTimeNormalReplies),
CommuteDepartTimeNotNormalReplies = Merge(baseCatalog.CommuteDepartTimeNotNormalReplies,
importedCatalog.CommuteDepartTimeNotNormalReplies),
CommuteDriveNormalReplies = Merge(baseCatalog.CommuteDriveNormalReplies, importedCatalog.CommuteDriveNormalReplies),
CommuteDriveLateReplies = Merge(baseCatalog.CommuteDriveLateReplies, importedCatalog.CommuteDriveLateReplies),
CommuteDriveHurryReplies = Merge(baseCatalog.CommuteDriveHurryReplies, importedCatalog.CommuteDriveHurryReplies),
CommuteDrivePoorReplies = Merge(baseCatalog.CommuteDrivePoorReplies, importedCatalog.CommuteDrivePoorReplies),
CommuteDriveTerribleReplies = Merge(baseCatalog.CommuteDriveTerribleReplies, importedCatalog.CommuteDriveTerribleReplies),
CommuteTransportNormalReplies = Merge(baseCatalog.CommuteTransportNormalReplies,
importedCatalog.CommuteTransportNormalReplies),
CommuteTransportLateReplies = Merge(baseCatalog.CommuteTransportLateReplies,
importedCatalog.CommuteTransportLateReplies),
CommuteTransportHurryReplies = Merge(baseCatalog.CommuteTransportHurryReplies,
importedCatalog.CommuteTransportHurryReplies),
CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies,
importedCatalog.CommuteServiceDownReplies),
NewsIntroReplies = Merge(baseCatalog.NewsIntroReplies, importedCatalog.NewsIntroReplies),
@@ -449,6 +507,19 @@ public static class LegacyMimCatalogImporter
CalendarServiceDown,
CalendarOutro,
CommuteNow,
CommuteMinutesLeft,
CommuteDepartTimeNormal,
CommuteDepartTimeNotNormal,
CommuteAppSetup,
CommuteConfirmSpeaker,
CommuteDriveNormal,
CommuteDriveLate,
CommuteDriveHurry,
CommuteDrivePoor,
CommuteDriveTerrible,
CommuteTransportNormal,
CommuteTransportLate,
CommuteTransportHurry,
CommuteServiceDown,
NewsIntro,
NewsCategoryIntro,
@@ -462,7 +533,20 @@ public static class LegacyMimCatalogImporter
private readonly List<string> _calendarNothingTodayReplies = [];
private readonly List<string> _calendarOutroReplies = [];
private readonly List<string> _calendarServiceDownReplies = [];
private readonly List<string> _commuteAppSetupReplies = [];
private readonly List<string> _commuteConfirmSpeakerReplies = [];
private readonly List<string> _commuteDepartTimeNormalReplies = [];
private readonly List<string> _commuteDepartTimeNotNormalReplies = [];
private readonly List<string> _commuteNowReplies = [];
private readonly List<string> _commuteMinutesLeftReplies = [];
private readonly List<string> _commuteDriveNormalReplies = [];
private readonly List<string> _commuteDriveLateReplies = [];
private readonly List<string> _commuteDriveHurryReplies = [];
private readonly List<string> _commuteDrivePoorReplies = [];
private readonly List<string> _commuteDriveTerribleReplies = [];
private readonly List<string> _commuteTransportNormalReplies = [];
private readonly List<string> _commuteTransportLateReplies = [];
private readonly List<string> _commuteTransportHurryReplies = [];
private readonly List<string> _commuteServiceDownReplies = [];
private readonly List<string> _birthdayCelebrationReplies = [];
private readonly List<JiboConditionedReply> _emotionReplies = [];
@@ -615,9 +699,48 @@ public static class LegacyMimCatalogImporter
case LegacyMimBucket.CalendarOutro:
AddDistinct(_calendarOutroReplies, text);
return;
case LegacyMimBucket.CommuteAppSetup:
AddDistinct(_commuteAppSetupReplies, text);
return;
case LegacyMimBucket.CommuteConfirmSpeaker:
AddDistinct(_commuteConfirmSpeakerReplies, text);
return;
case LegacyMimBucket.CommuteNow:
AddDistinct(_commuteNowReplies, text);
return;
case LegacyMimBucket.CommuteMinutesLeft:
AddDistinct(_commuteMinutesLeftReplies, text);
return;
case LegacyMimBucket.CommuteDepartTimeNormal:
AddDistinct(_commuteDepartTimeNormalReplies, text);
return;
case LegacyMimBucket.CommuteDepartTimeNotNormal:
AddDistinct(_commuteDepartTimeNotNormalReplies, text);
return;
case LegacyMimBucket.CommuteDriveNormal:
AddDistinct(_commuteDriveNormalReplies, text);
return;
case LegacyMimBucket.CommuteDriveLate:
AddDistinct(_commuteDriveLateReplies, text);
return;
case LegacyMimBucket.CommuteDriveHurry:
AddDistinct(_commuteDriveHurryReplies, text);
return;
case LegacyMimBucket.CommuteDrivePoor:
AddDistinct(_commuteDrivePoorReplies, text);
return;
case LegacyMimBucket.CommuteDriveTerrible:
AddDistinct(_commuteDriveTerribleReplies, text);
return;
case LegacyMimBucket.CommuteTransportNormal:
AddDistinct(_commuteTransportNormalReplies, text);
return;
case LegacyMimBucket.CommuteTransportLate:
AddDistinct(_commuteTransportLateReplies, text);
return;
case LegacyMimBucket.CommuteTransportHurry:
AddDistinct(_commuteTransportHurryReplies, text);
return;
case LegacyMimBucket.CommuteServiceDown:
AddDistinct(_commuteServiceDownReplies, text);
return;
@@ -670,7 +793,20 @@ public static class LegacyMimCatalogImporter
CalendarNothingReplies = [.. _calendarNothingReplies],
CalendarServiceDownReplies = [.. _calendarServiceDownReplies],
CalendarOutroReplies = [.. _calendarOutroReplies],
CommuteAppSetupReplies = [.. _commuteAppSetupReplies],
CommuteConfirmSpeakerReplies = [.. _commuteConfirmSpeakerReplies],
CommuteNowReplies = [.. _commuteNowReplies],
CommuteMinutesLeftReplies = [.. _commuteMinutesLeftReplies],
CommuteDepartTimeNormalReplies = [.. _commuteDepartTimeNormalReplies],
CommuteDepartTimeNotNormalReplies = [.. _commuteDepartTimeNotNormalReplies],
CommuteDriveNormalReplies = [.. _commuteDriveNormalReplies],
CommuteDriveLateReplies = [.. _commuteDriveLateReplies],
CommuteDriveHurryReplies = [.. _commuteDriveHurryReplies],
CommuteDrivePoorReplies = [.. _commuteDrivePoorReplies],
CommuteDriveTerribleReplies = [.. _commuteDriveTerribleReplies],
CommuteTransportNormalReplies = [.. _commuteTransportNormalReplies],
CommuteTransportLateReplies = [.. _commuteTransportLateReplies],
CommuteTransportHurryReplies = [.. _commuteTransportHurryReplies],
CommuteServiceDownReplies = [.. _commuteServiceDownReplies],
NewsIntroReplies = [.. _newsIntroReplies],
NewsCategoryIntroReplies = [.. _newsCategoryIntroReplies],

View File

@@ -54,8 +54,10 @@ public static class ServiceCollectionExtensions
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
services.AddSingleton<IHolidayCalendarProvider>(provider =>
new NagerDateHolidayCalendarProvider(provider.GetRequiredService<HolidayCalendarOptions>()));
services.AddSingleton<ICalendarReportProvider, UnavailableCalendarReportProvider>();
services.AddSingleton<ICommuteReportProvider, UnavailableCommuteReportProvider>();
services.AddSingleton<ICalendarReportProvider>(provider =>
new CloudStateCalendarReportProvider(provider.GetRequiredService<ICloudStateStore>()));
services.AddSingleton<ICommuteReportProvider>(provider =>
new CloudStateCommuteReportProvider(provider.GetRequiredService<ICloudStateStore>()));
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"]

View File

@@ -18,6 +18,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
};
private readonly List<BackupRecord> _backups = [];
private readonly List<CommuteProfileRecord> _commuteProfiles = [];
private readonly List<CalendarEventRecord> _calendarEvents = [];
private readonly ConcurrentDictionary<string, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, KeyRequestRecord>
@@ -161,6 +163,12 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
_backups.Clear();
_backups.AddRange(snapshot.Backups ?? []);
_commuteProfiles.Clear();
_commuteProfiles.AddRange(snapshot.CommuteProfiles ?? []);
_calendarEvents.Clear();
_calendarEvents.AddRange(snapshot.CalendarEvents ?? []);
_loops.Clear();
_loops.AddRange(snapshot.Loops ?? []);
@@ -214,6 +222,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Updates = _updates.ToArray(),
Media = _media.ToArray(),
Backups = _backups.ToArray(),
CommuteProfiles = _commuteProfiles.ToArray(),
CalendarEvents = _calendarEvents.ToArray(),
Loops = _loops.ToArray(),
Holidays = _holidayOverrides.ToArray(),
People = _people.ToArray()
@@ -479,6 +489,55 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
return backup;
}
public IReadOnlyList<CalendarEventRecord> GetCalendarEvents(string? loopId = null)
{
var resolvedLoopId = string.IsNullOrWhiteSpace(loopId) ? ResolveDefaultLoopId() : loopId.Trim();
return _calendarEvents
.Where(calendarEvent => calendarEvent.IsEnabled)
.Where(calendarEvent =>
string.Equals(calendarEvent.LoopId, resolvedLoopId, StringComparison.OrdinalIgnoreCase))
.OrderBy(calendarEvent => calendarEvent.Date)
.ThenBy(calendarEvent => calendarEvent.TimeLabel ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(calendarEvent => calendarEvent.Summary, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public CalendarEventRecord UpsertCalendarEvent(CalendarEventRecord calendarEvent)
{
var resolvedLoopId = string.IsNullOrWhiteSpace(calendarEvent.LoopId)
? ResolveDefaultLoopId()
: calendarEvent.LoopId.Trim();
var normalizedId = string.IsNullOrWhiteSpace(calendarEvent.Id)
? $"calendar-{resolvedLoopId}-{Slugify(calendarEvent.Summary)}"
: calendarEvent.Id.Trim();
var resolvedCalendarEvent = new CalendarEventRecord
{
Id = normalizedId,
LoopId = resolvedLoopId,
Summary = string.IsNullOrWhiteSpace(calendarEvent.Summary) ? "Calendar event" : calendarEvent.Summary.Trim(),
TimeLabel = string.IsNullOrWhiteSpace(calendarEvent.TimeLabel) ? null : calendarEvent.TimeLabel.Trim(),
Date = calendarEvent.Date,
EndDate = calendarEvent.EndDate,
IsAllDay = calendarEvent.IsAllDay,
IsEnabled = calendarEvent.IsEnabled,
Source = string.IsNullOrWhiteSpace(calendarEvent.Source) ? "manual" : calendarEvent.Source.Trim(),
MemberId = calendarEvent.MemberId,
Created = calendarEvent.Created
};
var existingIndex = _calendarEvents.FindIndex(existing =>
string.Equals(existing.Id, normalizedId, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
_calendarEvents[existingIndex] = resolvedCalendarEvent;
else
_calendarEvents.Add(resolvedCalendarEvent);
TouchState();
return resolvedCalendarEvent;
}
public bool ShouldCreateSymmetricKey(string loopId)
{
return !_symmetricKeys.ContainsKey(loopId);
@@ -580,6 +639,59 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
.ToArray();
}
public IReadOnlyList<CommuteProfileRecord> GetCommuteProfiles(string? loopId = null)
{
var resolvedLoopId = string.IsNullOrWhiteSpace(loopId) ? ResolveDefaultLoopId() : loopId.Trim();
return _commuteProfiles
.Where(commute => commute.IsEnabled)
.Where(commute => string.Equals(commute.LoopId, resolvedLoopId, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(static commute => commute.IsComplete)
.ThenByDescending(static commute => commute.Updated)
.ToArray();
}
public CommuteProfileRecord UpsertCommuteProfile(CommuteProfileRecord commuteProfile)
{
var resolvedLoopId = string.IsNullOrWhiteSpace(commuteProfile.LoopId)
? ResolveDefaultLoopId()
: commuteProfile.LoopId.Trim();
var normalizedId = string.IsNullOrWhiteSpace(commuteProfile.Id)
? $"commute-{resolvedLoopId}"
: commuteProfile.Id.Trim();
var resolvedProfile = new CommuteProfileRecord
{
Id = normalizedId,
LoopId = resolvedLoopId,
MemberId = string.IsNullOrWhiteSpace(commuteProfile.MemberId) ? null : commuteProfile.MemberId.Trim(),
IsEnabled = commuteProfile.IsEnabled,
IsComplete = commuteProfile.IsComplete,
Mode = string.IsNullOrWhiteSpace(commuteProfile.Mode) ? "driving" : commuteProfile.Mode.Trim(),
WorkHour = commuteProfile.WorkHour,
WorkMinute = commuteProfile.WorkMinute,
OriginName = string.IsNullOrWhiteSpace(commuteProfile.OriginName) ? null : commuteProfile.OriginName.Trim(),
DestinationName = string.IsNullOrWhiteSpace(commuteProfile.DestinationName)
? null
: commuteProfile.DestinationName.Trim(),
TypicalDurationMinutes = commuteProfile.TypicalDurationMinutes > 0
? commuteProfile.TypicalDurationMinutes
: 25,
Created = commuteProfile.Created == default ? DateTimeOffset.UtcNow : commuteProfile.Created,
Updated = DateTimeOffset.UtcNow
};
var existingIndex = _commuteProfiles.FindIndex(existing =>
string.Equals(existing.Id, normalizedId, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
_commuteProfiles[existingIndex] = resolvedProfile;
else
_commuteProfiles.Add(resolvedProfile);
TouchState();
return resolvedProfile;
}
public HolidayRecord UpsertHoliday(HolidayRecord holiday)
{
var resolvedLoopId = string.IsNullOrWhiteSpace(holiday.LoopId) ? ResolveDefaultLoopId() : holiday.LoopId.Trim();
@@ -656,6 +768,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
if (_people.Count != 0)
{
EnsureDefaultCommuteProfile();
return;
}
@@ -680,6 +793,29 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Alias = "Household Member",
IsPrimary = false
});
EnsureDefaultCommuteProfile();
}
private void EnsureDefaultCommuteProfile()
{
if (_commuteProfiles.Any(commute =>
string.Equals(commute.LoopId, ResolveDefaultLoopId(), StringComparison.OrdinalIgnoreCase)))
return;
_commuteProfiles.Add(new CommuteProfileRecord
{
Id = $"commute-{ResolveDefaultLoopId()}",
LoopId = ResolveDefaultLoopId(),
IsEnabled = true,
IsComplete = true,
Mode = "driving",
WorkHour = 8,
WorkMinute = 30,
OriginName = "home",
DestinationName = "work",
TypicalDurationMinutes = 25
});
}
private static string Slugify(string value)
@@ -767,6 +903,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public UpdateManifest[]? Updates { get; init; }
public MediaRecord[]? Media { get; init; }
public BackupRecord[]? Backups { get; init; }
public CommuteProfileRecord[]? CommuteProfiles { get; init; }
public CalendarEventRecord[]? CalendarEvents { get; init; }
public LoopRecord[]? Loops { get; init; }
public HolidayRecord[]? Holidays { get; init; }
public PersonRecord[]? People { get; init; }