diff --git a/OpenJibo/docs/calendar-architecture.md b/OpenJibo/docs/calendar-architecture.md new file mode 100644 index 0000000..055bf50 --- /dev/null +++ b/OpenJibo/docs/calendar-architecture.md @@ -0,0 +1,24 @@ +# Calendar Architecture + +Pegasus treated calendar as a loop-scoped report surface, with report output fed by the +household context instead of an isolated generic calendar service. + +In OpenJibo, the current calendar path follows the same broad shape: + +- calendar report output is loop-scoped +- the report provider can read persisted loop calendar events +- birthday and other personal dates already live in the loop-scoped holiday list +- the personal report merges the report provider output into the spoken flow + +Current behavior: + +- if loop calendar events exist, the provider surfaces the next matching items +- if no loop calendar events exist, the provider falls back to the merged holiday list +- birthdays and custom holiday entries can therefore appear in the calendar section +- the personal report still degrades safely when no calendar data is available + +Notes: + +- the current provider is intentionally lightweight and in-process +- this gives us a swappable seam for later Azure-backed calendar sync +- commute remains a separate report gap for the next pass diff --git a/OpenJibo/docs/commute-architecture.md b/OpenJibo/docs/commute-architecture.md new file mode 100644 index 0000000..5d2be8d --- /dev/null +++ b/OpenJibo/docs/commute-architecture.md @@ -0,0 +1,72 @@ +# Commute Architecture + +## Purpose + +Commute is part of personal report parity and household-aware personality. + +The original Jibo report-skill had a commute section that could speak about getting to work, leaving soon, or being too early or too late. In OpenJibo, that behavior now starts with a loop-scoped commute profile so we can stay faithful to stock behavior first and add richer routing later. + +## Current Shape + +The cloud now models commute as persisted loop data instead of a hardcoded reply. + +The current pieces are: + +- `CommuteProfileRecord` +- `ICommuteReportProvider` +- `CloudStateCommuteReportProvider` +- `Person/ListCommute` +- `Person/UpsertCommute` + +## Data Model + +The commute profile is stored per loop and can optionally be tied to a person. + +Typical fields include: + +- `LoopId` +- `MemberId` +- `Mode` +- `WorkHour` +- `WorkMinute` +- `OriginName` +- `DestinationName` +- `TypicalDurationMinutes` +- `IsEnabled` +- `IsComplete` + +The provider uses the loop-scoped profile to decide whether commute is ready, missing setup, or ready to render a spoken answer. + +## Runtime Behavior + +At runtime, the commute provider: + +- reads the current loop from the request context +- loads the loop commute profile from cloud state +- uses the profile plus current time to compute minutes until work +- merges in same-day calendar pressure when a calendar event exists before the commute window +- returns a safe setup response when the commute profile is missing or incomplete + +## Personal Report Integration + +Personal report uses the commute provider as a section in the broader household report. + +That means the report can now speak in the familiar Jibo shape: + +- weather +- calendar +- commute +- news + +## Next Gaps + +The current commute provider is intentionally conservative. + +Next steps can include: + +- a richer travel-time source +- map or transit integration +- better depart-time commentary +- preference-based commute suppression or reminders + +For now the goal is to keep the interface stable and the behavior stock-like. diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index 622368e..3cfd153 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -44,6 +44,7 @@ Current release theme: - radio, ESML apostrophe cleanup, and first news are implemented in source/tests; radio and basic news are live-proven as of `jibo test 23` - `jibo test 22` validated radio, exposed backup/load interference, exposed a shared yes/no no-input gap, exposed repeated create keeper prompts after photo handoff, and showed local whisper `ffmpeg` failures on unusable buffered audio - `jibo test 23` validated basic news, proved one alarm set/fire path at `7:43 AM`, exposed comma-separated/short alarm follow-up parsing risk, showed stock alarm replacement yes/no rules that needed cloud handling, and showed photo gallery still failing when `shared/yes_no` ASR came back empty +- personal report parity now has loop-scoped calendar and commute provider seams that merge persisted loop events, birthday/holiday dates, and commute profiles; the remaining report gap is richer travel-time data, not missing structure - `jibo test 24` showed alarm replacement yes/no working, but exposed empty `clock/alarm_set_value` and `gallery/gallery_preview` turns falling into generic `I heard you` fallback speech; it also showed `CLIENT_NLU cancel` inside `clock/alarm_set_value` re-asking for an alarm value instead of closing the prompt - `jibo test 25` proved a broader regression path but exposed repeated backup-in-progress/update-menu blockage, timer/alarm stale state and delete/menu disagreement, gallery `shared/yes_no` hangs under `@be/gallery`, punctuated `Never mind.` falling through to chat, volume homophone parsing (`Set Volume 2-6.`), and settings volume-control cleanup falling into `I heard you` - `jibo test 26` live-proved punctuated stop, volume homophone parsing, gallery launch/yes/create/save, and good morning; it still exposed robot-local backup warnings, long blue-ring buffering without a fresh `LISTEN`, alarm replacement drifting into the value/manual screen, and alarm delete phrases/mishears falling to chat @@ -731,7 +732,7 @@ Current release theme: - weather icon/animation parity and view support - broader non-local weather query handling and short-range date coverage - provider-backed news ingestion and filtering - - commute provider path and settings schema + - commute provider path, settings schema, and loop-scoped commute profile storage - coverage matrix for personal report parity gaps and test/capture exit criteria - Progress update (`2026-05-10`): - added provider-ready news briefing lane with Nimbus-compatible `news` skill payload continuity @@ -739,6 +740,7 @@ Current release theme: - fallback synthetic news behavior remains active when no provider key is configured - added TTL caching for weather/news provider calls to reduce repeated external requests - vendored Pegasus `report-skill` templates for weather and personal-report phrasing so the next pass can focus on renderer coverage for calendar, commute, and news templates instead of rediscovering source text + - commute now has a loop-scoped provider seam plus persisted commute profiles, so the next pass can focus on richer travel-time data instead of basic storage shape - Source anchors: - `C:\Projects\jibo\pegasus\packages\report-skill\src\subskills\weather\WeatherMimLogic.ts` - `C:\Projects\jibo\pegasus\packages\report-skill\resources\views\weatherHiLo.json` @@ -927,7 +929,7 @@ For `1.0.19`: 4. Weather report-skill launch compatibility - implemented 5. Dialog parsing expansion and ambiguity guardrails - in progress (`2026-05-09` third guardrail slice implemented; Pegasus affinity phrase families + continuation guardrails expanded) 6. Presence-aware greetings and identity-triggered proactivity - implemented (trigger path, identity-aware reactive/proactive replies, cooldown metadata wiring, focused websocket coverage) -7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - in progress (`2026-05-10` first live-news provider slice implemented) +7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - in progress (`2026-05-10` first live-news provider slice implemented; commute now has a loop-scoped provider seam) 8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation - system holidays should come from an up-to-date provider and merge with loop-scoped custom holiday records - allow disabled holiday records to suppress reminders for people who do not celebrate a holiday diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index d1d0f23..88f9544 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -120,6 +120,7 @@ Reference design: - [persistence-architecture.md](persistence-architecture.md) - [holiday-architecture.md](holiday-architecture.md) +- [commute-architecture.md](commute-architecture.md) ## First Implemented Slice In `1.0.19` @@ -291,6 +292,10 @@ This confirms the pizza-fact offer state now keeps the yes/no branch open throug Personal report parity planning is now captured with Pegasus source anchors for weather visuals/animations, live news, commute, and calendar gap coverage. +Calendar is now backed by a loop-scoped provider seam that can merge persisted loop events with birthday and holiday dates, keeping the report aligned with household context. + +Commute now uses a loop-scoped commute profile and provider seam so the report can speak in the legacy commute shape without inventing a separate hosted travel service yet. + Reference: - [personal-report-parity-plan.md](personal-report-parity-plan.md) @@ -335,7 +340,7 @@ Third completed guardrail slice under this queue: Next queued implementation track after parser guardrails: -- personal report parity slices (weather visual parity, live news path, commute/calendar gap closure) +- personal report parity slices (weather visual parity, live news path, commute/calendar refinement) First completed slice in this personal-report parity track: @@ -344,6 +349,7 @@ First completed slice in this personal-report parity track: - added memory/transcript category hinting for provider requests (`sports`, `technology`, `business`, etc.) - added provider-side request caching for both news and weather to reduce integration churn and repeated lookups - added focused interaction + websocket tests for provider-backed news speech output and request-hint plumbing +- added loop-scoped calendar and commute provider seams so personal report can use persisted household context instead of static placeholders ## Next Slices diff --git a/OpenJibo/src/Jibo.Cloud/README.md b/OpenJibo/src/Jibo.Cloud/README.md index db43c69..d6b3551 100644 --- a/OpenJibo/src/Jibo.Cloud/README.md +++ b/OpenJibo/src/Jibo.Cloud/README.md @@ -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: diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs index 84c5f26..8f393b9 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICloudStateStore.cs @@ -44,5 +44,9 @@ public interface ICloudStateStore IReadOnlyList GetBinaryRequests(); IReadOnlyList GetHolidays(string? loopId = null); HolidayRecord UpsertHoliday(HolidayRecord holiday); + IReadOnlyList GetCommuteProfiles(string? loopId = null); + CommuteProfileRecord UpsertCommuteProfile(CommuteProfileRecord commuteProfile); + IReadOnlyList GetCalendarEvents(string? loopId = null); + CalendarEventRecord UpsertCalendarEvent(CalendarEventRecord calendarEvent); void UpdateRobot(DeviceRegistration registration); } diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICommuteReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICommuteReportProvider.cs index ac4fc3e..30dad2d 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICommuteReportProvider.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/ICommuteReportProvider.cs @@ -12,4 +12,7 @@ public sealed record CommuteReportSnapshot( string Summary, int DurationMinutes, string? Mode = null, - bool EventIsEarly = false); \ No newline at end of file + bool EventIsEarly = false, + int MinutesUntilWork = 0, + int ExtraMinutes = 0, + bool RequiresSetup = false); diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs index 66fceba..031f675 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Abstractions/IJiboExperienceContentRepository.cs @@ -44,7 +44,20 @@ public sealed class JiboConditionedReply public IReadOnlyList CalendarNothingReplies { get; init; } = []; public IReadOnlyList CalendarServiceDownReplies { get; init; } = []; public IReadOnlyList CalendarOutroReplies { get; init; } = []; + public IReadOnlyList CommuteAppSetupReplies { get; init; } = []; + public IReadOnlyList CommuteConfirmSpeakerReplies { get; init; } = []; public IReadOnlyList CommuteNowReplies { get; init; } = []; + public IReadOnlyList CommuteMinutesLeftReplies { get; init; } = []; + public IReadOnlyList CommuteDepartTimeNormalReplies { get; init; } = []; + public IReadOnlyList CommuteDepartTimeNotNormalReplies { get; init; } = []; + public IReadOnlyList CommuteDriveNormalReplies { get; init; } = []; + public IReadOnlyList CommuteDriveLateReplies { get; init; } = []; + public IReadOnlyList CommuteDriveHurryReplies { get; init; } = []; + public IReadOnlyList CommuteDrivePoorReplies { get; init; } = []; + public IReadOnlyList CommuteDriveTerribleReplies { get; init; } = []; + public IReadOnlyList CommuteTransportNormalReplies { get; init; } = []; + public IReadOnlyList CommuteTransportLateReplies { get; init; } = []; + public IReadOnlyList CommuteTransportHurryReplies { get; init; } = []; public IReadOnlyList CommuteServiceDownReplies { get; init; } = []; public IReadOnlyList NewsIntroReplies { get; init; } = []; public IReadOnlyList NewsCategoryIntroReplies { get; init; } = []; diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs index 5f9264d..0f52ec7 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs @@ -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()); - 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()); } 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 diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs index e78bab1..1da5500 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs @@ -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 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 templates, - string mode, - string fallback) + private static string? ChooseShortestTemplate(IEnumerable 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( diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CalendarEventRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CalendarEventRecord.cs new file mode 100644 index 0000000..803db6c --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CalendarEventRecord.cs @@ -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; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CommuteProfileRecord.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CommuteProfileRecord.cs new file mode 100644 index 0000000..64e60e5 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Domain/Models/CommuteProfileRecord.cs @@ -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; +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Calendar/CloudStateCalendarReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Calendar/CloudStateCalendarReportProvider.cs new file mode 100644 index 0000000..d1b3848 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Calendar/CloudStateCalendarReportProvider.cs @@ -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 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(); + var todayTimes = new List(); + var tomorrowSummaries = new List(); + + 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( + 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 holidays) + { + foreach (var holiday in holidays) + { + yield return ( + holiday.Name, + "all day", + holiday.Date); + } + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/CloudStateCommuteReportProvider.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/CloudStateCommuteReportProvider.cs new file mode 100644 index 0000000..71cd137 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Commute/CloudStateCommuteReportProvider.cs @@ -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( + @"(?\d{1,2})(?::(?\d{2}))?\s*(?a\.?m\.?|p\.?m\.?)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public Task 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( + 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( + 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; + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/InMemoryJiboExperienceContentRepository.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/InMemoryJiboExperienceContentRepository.cs index fc2f4b7..a4de945 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/InMemoryJiboExperienceContentRepository.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/InMemoryJiboExperienceContentRepository.cs @@ -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.", diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs index e5dcdeb..f4cb241 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Content/LegacyMimCatalogImporter.cs @@ -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 _calendarNothingTodayReplies = []; private readonly List _calendarOutroReplies = []; private readonly List _calendarServiceDownReplies = []; + private readonly List _commuteAppSetupReplies = []; + private readonly List _commuteConfirmSpeakerReplies = []; + private readonly List _commuteDepartTimeNormalReplies = []; + private readonly List _commuteDepartTimeNotNormalReplies = []; private readonly List _commuteNowReplies = []; + private readonly List _commuteMinutesLeftReplies = []; + private readonly List _commuteDriveNormalReplies = []; + private readonly List _commuteDriveLateReplies = []; + private readonly List _commuteDriveHurryReplies = []; + private readonly List _commuteDrivePoorReplies = []; + private readonly List _commuteDriveTerribleReplies = []; + private readonly List _commuteTransportNormalReplies = []; + private readonly List _commuteTransportLateReplies = []; + private readonly List _commuteTransportHurryReplies = []; private readonly List _commuteServiceDownReplies = []; private readonly List _birthdayCelebrationReplies = []; private readonly List _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], diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index ba0cab6..075f9e7 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -54,8 +54,10 @@ public static class ServiceCollectionExtensions services.AddHttpClient(); services.AddSingleton(provider => new NagerDateHolidayCalendarProvider(provider.GetRequiredService())); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(provider => + new CloudStateCalendarReportProvider(provider.GetRequiredService())); + services.AddSingleton(provider => + new CloudStateCommuteReportProvider(provider.GetRequiredService())); var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"] diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs index d55e039..52b1c19 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs @@ -18,6 +18,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore }; private readonly List _backups = []; + private readonly List _commuteProfiles = []; + private readonly List _calendarEvents = []; private readonly ConcurrentDictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary @@ -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 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 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; } diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs index 0c7fba5..a5f9b8b 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Infrastructure/PersistenceStoreTests.cs @@ -1,4 +1,5 @@ using Jibo.Cloud.Application.Abstractions; +using Jibo.Cloud.Domain.Models; using Jibo.Cloud.Infrastructure.Persistence; namespace Jibo.Cloud.Tests.Infrastructure; @@ -103,6 +104,21 @@ public sealed class PersistenceStoreTests var update = firstStore.CreateUpdate("1.0.0", "1.0.1", "Bug fix", null, 42, "robot", null, null); var media = firstStore.CreateMedia("openjibo-default-loop", "persisted-photo", "image", "photo-ref", false, new Dictionary { ["note"] = "roundtrip" }); + var commute = firstStore.UpsertCommuteProfile(new CommuteProfileRecord + { + LoopId = "openjibo-default-loop", + Mode = "driving", + WorkHour = 8, + WorkMinute = 30, + TypicalDurationMinutes = 25 + }); + var calendarEvent = firstStore.UpsertCalendarEvent(new CalendarEventRecord + { + LoopId = "openjibo-default-loop", + Summary = "Report review", + TimeLabel = "at 6:00 p.m.", + Date = DateOnly.FromDateTime(DateTime.UtcNow) + }); var sessionToken = firstStore.IssueRobotToken("robot-123"); var device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6"); firstStore.SavePersistedState(); @@ -117,6 +133,10 @@ public sealed class PersistenceStoreTests Assert.Equal(firstInfo.Revision, secondInfo.Revision); Assert.Contains(secondStore.ListUpdates("robot"), item => item.UpdateId == update.UpdateId); Assert.Contains(secondStore.ListMedia(), item => item.Path == media.Path); + Assert.Contains(secondStore.GetCommuteProfiles("openjibo-default-loop"), + item => item.Id == commute.Id && item.Mode == commute.Mode); + Assert.Contains(secondStore.GetCalendarEvents("openjibo-default-loop"), + item => item.Id == calendarEvent.Id && item.Summary == calendarEvent.Summary); Assert.NotNull(secondStore.FindSessionByToken(sessionToken)); Assert.Equal("3.2.1", secondStore.GetOrCreateDevice(device.DeviceId, null, null).FirmwareVersion); Assert.NotEmpty(secondStore.GetPeople()); diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs index a1ad48a..c3b90ab 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/Protocol/JiboCloudProtocolServiceTests.cs @@ -167,6 +167,43 @@ public sealed class JiboCloudProtocolServiceTests } } + [Fact] + public async Task PersonUpsertCommute_ThenListCommute_ReturnsPersistedLoopProfile() + { + var service = new JiboCloudProtocolService(new InMemoryCloudStateStore()); + + var upsert = await service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Person_20160715", + Operation = "UpsertCommute", + BodyText = + """{"loopId":"loop-123","mode":"walking","workHour":8,"workMinute":15,"typicalDurationMinutes":22}""" + }); + + using var upsertPayload = JsonDocument.Parse(upsert.BodyText); + Assert.Equal(200, upsert.StatusCode); + Assert.Equal("loop-123", upsertPayload.RootElement.GetProperty("loopId").GetString()); + Assert.Equal("walking", upsertPayload.RootElement.GetProperty("mode").GetString()); + + var listed = await service.DispatchAsync(new ProtocolEnvelope + { + HostName = "api.jibo.com", + Method = "POST", + ServicePrefix = "Person_20160715", + Operation = "ListCommute", + BodyText = """{"loopId":"loop-123"}""" + }); + + using var listedPayload = JsonDocument.Parse(listed.BodyText); + Assert.Equal(200, listed.StatusCode); + Assert.Contains(listedPayload.RootElement.EnumerateArray(), + item => item.GetProperty("loopId").GetString() == "loop-123" && + item.GetProperty("mode").GetString() == "walking" && + item.GetProperty("workHour").GetInt32() == 8); + } + [Fact] public async Task MediaCreateAndGet_ReturnsCreatedItem() { diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index 65f9193..4779441 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -2,6 +2,9 @@ using System.Text; using System.Text.Json; using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Services; +using Jibo.Cloud.Domain.Models; +using Jibo.Cloud.Infrastructure.Calendar; +using Jibo.Cloud.Infrastructure.Commute; using Jibo.Cloud.Infrastructure.Content; using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Runtime.Abstractions; @@ -1891,10 +1894,15 @@ public sealed class JiboInteractionServiceTests { Snapshot = new WeatherReportSnapshot("Boston, U.S.", "light rain", 61, 65, 54, "rain", false) }; - var calendarProvider = new CapturingCalendarReportProvider + var cloudStateStore = new InMemoryCloudStateStore(); + cloudStateStore.UpsertCalendarEvent(new CalendarEventRecord { - Snapshot = new CalendarReportSnapshot(["get personal report from jibo"], ["at 6:00 p.m."], []) - }; + LoopId = "openjibo-default-loop", + Summary = "get personal report from jibo", + TimeLabel = "at 6:00 p.m.", + Date = DateOnly.FromDateTime(DateTime.UtcNow) + }); + var calendarProvider = new CloudStateCalendarReportProvider(cloudStateStore); var service = CreateService(weatherReportProvider: weatherProvider, calendarReportProvider: calendarProvider); var decision = await service.BuildDecisionAsync(new TurnContext @@ -1913,6 +1921,54 @@ public sealed class JiboInteractionServiceTests StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task BuildDecisionAsync_PersonalReport_UsesCommuteProviderAndNormalTraffic() + { + var weatherProvider = new CapturingWeatherReportProvider + { + Snapshot = new WeatherReportSnapshot("Boston, U.S.", "light rain", 61, 65, 54, "rain", false) + }; + var calendarStore = new InMemoryCloudStateStore(); + calendarStore.UpsertCalendarEvent(new CalendarEventRecord + { + LoopId = "openjibo-default-loop", + Summary = "get personal report from jibo", + TimeLabel = "at 6:00 p.m.", + Date = DateOnly.FromDateTime(DateTime.UtcNow) + }); + var calendarProvider = new CloudStateCalendarReportProvider(calendarStore); + var cloudStateStore = new InMemoryCloudStateStore(); + var commuteProvider = new CloudStateCommuteReportProvider(cloudStateStore); + var commuteTime = DateTimeOffset.Now.AddMinutes(45); + cloudStateStore.UpsertCommuteProfile(new CommuteProfileRecord + { + LoopId = "openjibo-default-loop", + Mode = "driving", + WorkHour = commuteTime.Hour, + WorkMinute = commuteTime.Minute, + TypicalDurationMinutes = 25 + }); + + var service = CreateService( + weatherReportProvider: weatherProvider, + calendarReportProvider: calendarProvider, + commuteReportProvider: commuteProvider); + + var decision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "yes", + NormalizedTranscript = "yes", + Attributes = new Dictionary + { + [PersonalReportStateKey] = "awaiting_identity_confirmation", + [PersonalReportUserNameKey] = "alex" + } + }); + + Assert.Equal("personal_report_delivered", decision.IntentName); + Assert.Contains("commute", decision.ReplyText, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task BuildDecisionAsync_PersonalReport_NoMatchRetriesThenDeclines() { @@ -4192,15 +4248,4 @@ public sealed class JiboInteractionServiceTests } } - private sealed class CapturingCalendarReportProvider : ICalendarReportProvider - { - public CalendarReportSnapshot? Snapshot { get; init; } - - public Task GetReportAsync( - TurnContext turn, - CancellationToken cancellationToken = default) - { - return Task.FromResult(Snapshot); - } - } }