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

@@ -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

View File

@@ -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.

View File

@@ -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` - 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 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 - `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 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 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 - `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 - weather icon/animation parity and view support
- broader non-local weather query handling and short-range date coverage - broader non-local weather query handling and short-range date coverage
- provider-backed news ingestion and filtering - 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 - coverage matrix for personal report parity gaps and test/capture exit criteria
- Progress update (`2026-05-10`): - Progress update (`2026-05-10`):
- added provider-ready news briefing lane with Nimbus-compatible `news` skill payload continuity - 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 - 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 - 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 - 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: - Source anchors:
- `C:\Projects\jibo\pegasus\packages\report-skill\src\subskills\weather\WeatherMimLogic.ts` - `C:\Projects\jibo\pegasus\packages\report-skill\src\subskills\weather\WeatherMimLogic.ts`
- `C:\Projects\jibo\pegasus\packages\report-skill\resources\views\weatherHiLo.json` - `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 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) 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) 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 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 - 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 - allow disabled holiday records to suppress reminders for people who do not celebrate a holiday

View File

@@ -120,6 +120,7 @@ Reference design:
- [persistence-architecture.md](persistence-architecture.md) - [persistence-architecture.md](persistence-architecture.md)
- [holiday-architecture.md](holiday-architecture.md) - [holiday-architecture.md](holiday-architecture.md)
- [commute-architecture.md](commute-architecture.md)
## First Implemented Slice In `1.0.19` ## 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. 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: Reference:
- [personal-report-parity-plan.md](personal-report-parity-plan.md) - [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: 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: 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 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 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 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 ## Next Slices

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 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. 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 ## Recovery Strategy
The first supported device path is: The first supported device path is:

View File

@@ -44,5 +44,9 @@ public interface ICloudStateStore
IReadOnlyList<KeyRequestRecord> GetBinaryRequests(); IReadOnlyList<KeyRequestRecord> GetBinaryRequests();
IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null); IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null);
HolidayRecord UpsertHoliday(HolidayRecord holiday); 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); void UpdateRobot(DeviceRegistration registration);
} }

View File

@@ -12,4 +12,7 @@ public sealed record CommuteReportSnapshot(
string Summary, string Summary,
int DurationMinutes, int DurationMinutes,
string? Mode = null, 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> CalendarNothingReplies { get; init; } = [];
public IReadOnlyList<string> CalendarServiceDownReplies { get; init; } = []; public IReadOnlyList<string> CalendarServiceDownReplies { get; init; } = [];
public IReadOnlyList<string> CalendarOutroReplies { 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> 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> CommuteServiceDownReplies { get; init; } = [];
public IReadOnlyList<string> NewsIntroReplies { get; init; } = []; public IReadOnlyList<string> NewsIntroReplies { get; init; } = [];
public IReadOnlyList<string> NewsCategoryIntroReplies { 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) private ProtocolDispatchResult HandlePerson(string operation, ProtocolEnvelope envelope)
{ {
if (!operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(Array.Empty<object>());
var body = envelope.TryParseBody(); 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) 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) private static object MapMedia(MediaRecord item)
{ {
return new return new

View File

@@ -1626,6 +1626,11 @@ public sealed class JiboInteractionService(
"commute", "commute",
ChooseCommuteServiceDownReply(catalog)); ChooseCommuteServiceDownReply(catalog));
if (snapshot.RequiresSetup)
return new JiboInteractionDecision(
"commute_setup",
ChooseCommuteAppSetupReply(catalog));
return new JiboInteractionDecision( return new JiboInteractionDecision(
"commute", "commute",
BuildCommuteSpokenReply(snapshot, catalog)); BuildCommuteSpokenReply(snapshot, catalog));
@@ -1754,47 +1759,122 @@ public sealed class JiboInteractionService(
{ {
var duration = snapshot.DurationMinutes; var duration = snapshot.DurationMinutes;
var durationText = duration <= 1 ? "1 minute" : $"{duration} minutes"; 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 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, if (minutesLeft > 0 && minutesLeft < 30)
"For your commute, it should take about ${skill.commute.durationMins} minutes."); {
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 return template
.Replace("${skill.commute.durationMins}", durationText, StringComparison.OrdinalIgnoreCase) .Replace("${skill.commute.durationMins}", durationText, StringComparison.OrdinalIgnoreCase)
.Replace("${skill.commute.minsLeft}", minutesLeftText, StringComparison.OrdinalIgnoreCase)
.Replace("${speaker}", string.Empty, StringComparison.OrdinalIgnoreCase) .Replace("${speaker}", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace(" ", " ", StringComparison.Ordinal) .Replace(" ", " ", StringComparison.Ordinal)
.Trim(); .Trim();
} }
private static string ChooseCommuteTemplate( private static string? ChooseShortestTemplate(IEnumerable<string> templates)
IReadOnlyList<string> templates,
string mode,
string fallback)
{ {
if (templates.Count == 0) return fallback; return templates
.Where(static template => !string.IsNullOrWhiteSpace(template))
var loweredMode = mode.Trim().ToLowerInvariant(); .OrderBy(static template => template.Length)
var filtered = templates.Where(template => .FirstOrDefault();
{
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!;
} }
private static string BuildWeeklyForecastSpokenReply( 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.", "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." "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 = CommuteReplies =
[ [
"I heard your commute request. That one is recognized, but not fully implemented yet.", "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." "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 = NewsReplies =
[ [
"I heard your news request. That path is still a future cloud integration.", "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)) if (fileName.StartsWith("CalendarOutro", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CalendarOutro; 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("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)) if (fileName.StartsWith("CommuteServiceDown", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteServiceDown; return LegacyMimBucket.CommuteServiceDown;
@@ -295,7 +334,26 @@ public static class LegacyMimCatalogImporter
CalendarServiceDownReplies = Merge(baseCatalog.CalendarServiceDownReplies, CalendarServiceDownReplies = Merge(baseCatalog.CalendarServiceDownReplies,
importedCatalog.CalendarServiceDownReplies), importedCatalog.CalendarServiceDownReplies),
CalendarOutroReplies = Merge(baseCatalog.CalendarOutroReplies, importedCatalog.CalendarOutroReplies), CalendarOutroReplies = Merge(baseCatalog.CalendarOutroReplies, importedCatalog.CalendarOutroReplies),
CommuteAppSetupReplies = Merge(baseCatalog.CommuteAppSetupReplies, importedCatalog.CommuteAppSetupReplies),
CommuteConfirmSpeakerReplies = Merge(baseCatalog.CommuteConfirmSpeakerReplies,
importedCatalog.CommuteConfirmSpeakerReplies),
CommuteNowReplies = Merge(baseCatalog.CommuteNowReplies, importedCatalog.CommuteNowReplies), 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, CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies,
importedCatalog.CommuteServiceDownReplies), importedCatalog.CommuteServiceDownReplies),
NewsIntroReplies = Merge(baseCatalog.NewsIntroReplies, importedCatalog.NewsIntroReplies), NewsIntroReplies = Merge(baseCatalog.NewsIntroReplies, importedCatalog.NewsIntroReplies),
@@ -449,6 +507,19 @@ public static class LegacyMimCatalogImporter
CalendarServiceDown, CalendarServiceDown,
CalendarOutro, CalendarOutro,
CommuteNow, CommuteNow,
CommuteMinutesLeft,
CommuteDepartTimeNormal,
CommuteDepartTimeNotNormal,
CommuteAppSetup,
CommuteConfirmSpeaker,
CommuteDriveNormal,
CommuteDriveLate,
CommuteDriveHurry,
CommuteDrivePoor,
CommuteDriveTerrible,
CommuteTransportNormal,
CommuteTransportLate,
CommuteTransportHurry,
CommuteServiceDown, CommuteServiceDown,
NewsIntro, NewsIntro,
NewsCategoryIntro, NewsCategoryIntro,
@@ -462,7 +533,20 @@ public static class LegacyMimCatalogImporter
private readonly List<string> _calendarNothingTodayReplies = []; private readonly List<string> _calendarNothingTodayReplies = [];
private readonly List<string> _calendarOutroReplies = []; private readonly List<string> _calendarOutroReplies = [];
private readonly List<string> _calendarServiceDownReplies = []; 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> _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> _commuteServiceDownReplies = [];
private readonly List<string> _birthdayCelebrationReplies = []; private readonly List<string> _birthdayCelebrationReplies = [];
private readonly List<JiboConditionedReply> _emotionReplies = []; private readonly List<JiboConditionedReply> _emotionReplies = [];
@@ -615,9 +699,48 @@ public static class LegacyMimCatalogImporter
case LegacyMimBucket.CalendarOutro: case LegacyMimBucket.CalendarOutro:
AddDistinct(_calendarOutroReplies, text); AddDistinct(_calendarOutroReplies, text);
return; return;
case LegacyMimBucket.CommuteAppSetup:
AddDistinct(_commuteAppSetupReplies, text);
return;
case LegacyMimBucket.CommuteConfirmSpeaker:
AddDistinct(_commuteConfirmSpeakerReplies, text);
return;
case LegacyMimBucket.CommuteNow: case LegacyMimBucket.CommuteNow:
AddDistinct(_commuteNowReplies, text); AddDistinct(_commuteNowReplies, text);
return; 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: case LegacyMimBucket.CommuteServiceDown:
AddDistinct(_commuteServiceDownReplies, text); AddDistinct(_commuteServiceDownReplies, text);
return; return;
@@ -670,7 +793,20 @@ public static class LegacyMimCatalogImporter
CalendarNothingReplies = [.. _calendarNothingReplies], CalendarNothingReplies = [.. _calendarNothingReplies],
CalendarServiceDownReplies = [.. _calendarServiceDownReplies], CalendarServiceDownReplies = [.. _calendarServiceDownReplies],
CalendarOutroReplies = [.. _calendarOutroReplies], CalendarOutroReplies = [.. _calendarOutroReplies],
CommuteAppSetupReplies = [.. _commuteAppSetupReplies],
CommuteConfirmSpeakerReplies = [.. _commuteConfirmSpeakerReplies],
CommuteNowReplies = [.. _commuteNowReplies], 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], CommuteServiceDownReplies = [.. _commuteServiceDownReplies],
NewsIntroReplies = [.. _newsIntroReplies], NewsIntroReplies = [.. _newsIntroReplies],
NewsCategoryIntroReplies = [.. _newsCategoryIntroReplies], NewsCategoryIntroReplies = [.. _newsCategoryIntroReplies],

View File

@@ -54,8 +54,10 @@ public static class ServiceCollectionExtensions
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>(); services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
services.AddSingleton<IHolidayCalendarProvider>(provider => services.AddSingleton<IHolidayCalendarProvider>(provider =>
new NagerDateHolidayCalendarProvider(provider.GetRequiredService<HolidayCalendarOptions>())); new NagerDateHolidayCalendarProvider(provider.GetRequiredService<HolidayCalendarOptions>()));
services.AddSingleton<ICalendarReportProvider, UnavailableCalendarReportProvider>(); services.AddSingleton<ICalendarReportProvider>(provider =>
services.AddSingleton<ICommuteReportProvider, UnavailableCommuteReportProvider>(); new CloudStateCalendarReportProvider(provider.GetRequiredService<ICloudStateStore>()));
services.AddSingleton<ICommuteReportProvider>(provider =>
new CloudStateCommuteReportProvider(provider.GetRequiredService<ICloudStateStore>()));
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"] var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json"); ?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
var personalMemoryPersistencePath = configuration?["OpenJibo:PersonalMemory:PersistencePath"] 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<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, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, KeyRequestRecord> private readonly ConcurrentDictionary<string, KeyRequestRecord>
@@ -161,6 +163,12 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
_backups.Clear(); _backups.Clear();
_backups.AddRange(snapshot.Backups ?? []); _backups.AddRange(snapshot.Backups ?? []);
_commuteProfiles.Clear();
_commuteProfiles.AddRange(snapshot.CommuteProfiles ?? []);
_calendarEvents.Clear();
_calendarEvents.AddRange(snapshot.CalendarEvents ?? []);
_loops.Clear(); _loops.Clear();
_loops.AddRange(snapshot.Loops ?? []); _loops.AddRange(snapshot.Loops ?? []);
@@ -214,6 +222,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Updates = _updates.ToArray(), Updates = _updates.ToArray(),
Media = _media.ToArray(), Media = _media.ToArray(),
Backups = _backups.ToArray(), Backups = _backups.ToArray(),
CommuteProfiles = _commuteProfiles.ToArray(),
CalendarEvents = _calendarEvents.ToArray(),
Loops = _loops.ToArray(), Loops = _loops.ToArray(),
Holidays = _holidayOverrides.ToArray(), Holidays = _holidayOverrides.ToArray(),
People = _people.ToArray() People = _people.ToArray()
@@ -479,6 +489,55 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
return backup; 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) public bool ShouldCreateSymmetricKey(string loopId)
{ {
return !_symmetricKeys.ContainsKey(loopId); return !_symmetricKeys.ContainsKey(loopId);
@@ -580,6 +639,59 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
.ToArray(); .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) public HolidayRecord UpsertHoliday(HolidayRecord holiday)
{ {
var resolvedLoopId = string.IsNullOrWhiteSpace(holiday.LoopId) ? ResolveDefaultLoopId() : holiday.LoopId.Trim(); var resolvedLoopId = string.IsNullOrWhiteSpace(holiday.LoopId) ? ResolveDefaultLoopId() : holiday.LoopId.Trim();
@@ -656,6 +768,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
if (_people.Count != 0) if (_people.Count != 0)
{ {
EnsureDefaultCommuteProfile();
return; return;
} }
@@ -680,6 +793,29 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
Alias = "Household Member", Alias = "Household Member",
IsPrimary = false 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) private static string Slugify(string value)
@@ -767,6 +903,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public UpdateManifest[]? Updates { get; init; } public UpdateManifest[]? Updates { get; init; }
public MediaRecord[]? Media { get; init; } public MediaRecord[]? Media { get; init; }
public BackupRecord[]? Backups { get; init; } public BackupRecord[]? Backups { get; init; }
public CommuteProfileRecord[]? CommuteProfiles { get; init; }
public CalendarEventRecord[]? CalendarEvents { get; init; }
public LoopRecord[]? Loops { get; init; } public LoopRecord[]? Loops { get; init; }
public HolidayRecord[]? Holidays { get; init; } public HolidayRecord[]? Holidays { get; init; }
public PersonRecord[]? People { get; init; } public PersonRecord[]? People { get; init; }

View File

@@ -1,4 +1,5 @@
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Cloud.Infrastructure.Persistence;
namespace Jibo.Cloud.Tests.Infrastructure; 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 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, var media = firstStore.CreateMedia("openjibo-default-loop", "persisted-photo", "image", "photo-ref", false,
new Dictionary<string, object?> { ["note"] = "roundtrip" }); new Dictionary<string, object?> { ["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 sessionToken = firstStore.IssueRobotToken("robot-123");
var device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6"); var device = firstStore.GetOrCreateDevice("robot-123", "3.2.1", "4.5.6");
firstStore.SavePersistedState(); firstStore.SavePersistedState();
@@ -117,6 +133,10 @@ public sealed class PersistenceStoreTests
Assert.Equal(firstInfo.Revision, secondInfo.Revision); Assert.Equal(firstInfo.Revision, secondInfo.Revision);
Assert.Contains(secondStore.ListUpdates("robot"), item => item.UpdateId == update.UpdateId); Assert.Contains(secondStore.ListUpdates("robot"), item => item.UpdateId == update.UpdateId);
Assert.Contains(secondStore.ListMedia(), item => item.Path == media.Path); 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.NotNull(secondStore.FindSessionByToken(sessionToken));
Assert.Equal("3.2.1", secondStore.GetOrCreateDevice(device.DeviceId, null, null).FirmwareVersion); Assert.Equal("3.2.1", secondStore.GetOrCreateDevice(device.DeviceId, null, null).FirmwareVersion);
Assert.NotEmpty(secondStore.GetPeople()); Assert.NotEmpty(secondStore.GetPeople());

View File

@@ -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] [Fact]
public async Task MediaCreateAndGet_ReturnsCreatedItem() public async Task MediaCreateAndGet_ReturnsCreatedItem()
{ {

View File

@@ -2,6 +2,9 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Jibo.Cloud.Application.Abstractions; using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services; 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.Content;
using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Runtime.Abstractions; 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) 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 service = CreateService(weatherReportProvider: weatherProvider, calendarReportProvider: calendarProvider);
var decision = await service.BuildDecisionAsync(new TurnContext var decision = await service.BuildDecisionAsync(new TurnContext
@@ -1913,6 +1921,54 @@ public sealed class JiboInteractionServiceTests
StringComparison.OrdinalIgnoreCase); 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<string, object?>
{
[PersonalReportStateKey] = "awaiting_identity_confirmation",
[PersonalReportUserNameKey] = "alex"
}
});
Assert.Equal("personal_report_delivered", decision.IntentName);
Assert.Contains("commute", decision.ReplyText, StringComparison.OrdinalIgnoreCase);
}
[Fact] [Fact]
public async Task BuildDecisionAsync_PersonalReport_NoMatchRetriesThenDeclines() 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<CalendarReportSnapshot?> GetReportAsync(
TurnContext turn,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Snapshot);
}
}
} }