From a5de0fdbdd27779c6b0bae9c0dcdd217add7d8d6 Mon Sep 17 00:00:00 2001 From: Jacob Dubin Date: Wed, 6 May 2026 16:49:26 -0500 Subject: [PATCH] Add pizza yes-no wiring and Pegasus parser guardrails --- OpenJibo/docs/development-plan.md | 2 +- OpenJibo/docs/feature-backlog.md | 23 +++- OpenJibo/docs/release-1.0.19-plan.md | 27 ++++- OpenJibo/docs/system-diagram-alignment.md | 104 ++++++++++++++++++ .../Services/JiboInteractionService.cs | 66 +++++++---- .../ResponsePlanToSocketMessagesMapper.cs | 5 +- .../WebSocketTurnFinalizationService.cs | 33 ++++++ .../WebSockets/JiboInteractionServiceTests.cs | 59 ++++++++++ .../WebSockets/JiboWebSocketServiceTests.cs | 72 ++++++++++++ 9 files changed, 364 insertions(+), 27 deletions(-) create mode 100644 OpenJibo/docs/system-diagram-alignment.md diff --git a/OpenJibo/docs/development-plan.md b/OpenJibo/docs/development-plan.md index 54b8e10..c84caf0 100644 --- a/OpenJibo/docs/development-plan.md +++ b/OpenJibo/docs/development-plan.md @@ -6,7 +6,7 @@ This document is the current working plan for the OpenJibo hosted cloud. The production lane is the `.NET` cloud in `src/Jibo.Cloud/dotnet`. The Node server remains the protocol oracle, capture harness, and fast reverse-engineering lab, but it is no longer the long-term hosted architecture. -Day-to-day feature sequencing lives in [feature-backlog.md](feature-backlog.md). Live closeout checks live in [regression-test-plan.md](regression-test-plan.md). The `1.0.19` release shape is detailed in [release-1.0.19-plan.md](release-1.0.19-plan.md), while this file keeps the broader evidence and architecture context. +Day-to-day feature sequencing lives in [feature-backlog.md](feature-backlog.md). Live closeout checks live in [regression-test-plan.md](regression-test-plan.md). The `1.0.19` release shape is detailed in [release-1.0.19-plan.md](release-1.0.19-plan.md), and the legacy-to-current architecture map is tracked in [system-diagram-alignment.md](system-diagram-alignment.md), while this file keeps the broader evidence and architecture context. ## Current Release Snapshot diff --git a/OpenJibo/docs/feature-backlog.md b/OpenJibo/docs/feature-backlog.md index d1f5a60..bb0192d 100644 --- a/OpenJibo/docs/feature-backlog.md +++ b/OpenJibo/docs/feature-backlog.md @@ -460,6 +460,27 @@ Current release theme: - what upload metadata must survive for gallery refresh - how to map this cleanly to Blob Storage +### Next Up (`2026-05-06`): Dialog Parsing Expansion And Ambiguity Guardrails + +- Status: `ready` +- Tags: `protocol`, `content`, `stt`, `docs` +- Why now: + - this is the next queued `1.0.19` implementation slice after weather provider bring-up + - recent live runs showed phrases where trigger detection can interrupt full-utterance understanding + - phrase import work from Pegasus has already started for chitchat and should now expand to broader parsing boundaries +- Scope: + - expand Pegasus-backed phrase coverage for question/command/assertion patterns + - add ambiguity guardrails for overlapping intents (date vs birthday, generic chat vs memory set/lookup, weather variants) + - preserve command-vs-question personality behavior and stock skill launch compatibility + - add focused tests for new phrase families and negative boundary cases +- Exit criteria: + - ambiguous phrase handling is improved without regressions in existing `1.0.19` features + - phrase imports are documented and traceable to Pegasus parser sources + - test suite stays green and includes targeted parser-guardrail coverage +- Tracking: + - [release-1.0.19-plan.md](release-1.0.19-plan.md) + - [system-diagram-alignment.md](system-diagram-alignment.md) + ## Discovery Queue ### 12. Weather As Cloud Report Plus Local Presentation @@ -661,7 +682,7 @@ For `1.0.19`: 2. Expand memory-backed personal facts with tenant-scoped storage (beyond the first birthday/preferences foundation) - implemented 3. Proactivity selector baseline with source-backed first offers - implemented 4. Weather report-skill launch compatibility - implemented -5. Dialog parsing expansion and ambiguity guardrails +5. Dialog parsing expansion and ambiguity guardrails - queued next as of `2026-05-06` 6. Holidays and seasonal personality behavior built on the new memory/proactivity foundation 7. Durable memory persistence path (multi-tenant backing store) 8. Update, backup, and restore proof diff --git a/OpenJibo/docs/release-1.0.19-plan.md b/OpenJibo/docs/release-1.0.19-plan.md index 0e07c27..3d4af6d 100644 --- a/OpenJibo/docs/release-1.0.19-plan.md +++ b/OpenJibo/docs/release-1.0.19-plan.md @@ -105,9 +105,34 @@ The fifth delivered slice adds provider-backed weather content while preserving - simple location extraction is supported for phrasing like `what's the weather in Chicago tomorrow` - provider config supports appsettings and `OPENWEATHER_API_KEY` environment fallback for deployment +## System Diagram Alignment Snapshot (`2026-05-06`) + +Legacy architecture (`system_diagram.png`) has been mapped to current OpenJibo cloud services so release execution stays anchored to: + +- where we were (Pegasus/Jibo cloud design intent) +- where we are (current hosted `.NET` modular monolith) +- where we are headed (durable memory, proactivity catalogs, parser depth, provider aggregation) + +Reference: + +- [system-diagram-alignment.md](system-diagram-alignment.md) + +## Next Queued Task (`2026-05-06`) + +Queued next `1.0.19` implementation task: + +- dialog parsing expansion and ambiguity guardrails + +Execution focus: + +- import additional Pegasus parser phrases/entities into intent handling where safe +- reduce trigger-only captures that drop the rest of the utterance +- preserve command-vs-question personality split and local skill payload compatibility +- add focused tests for new phrase families and ambiguity boundaries + ## Next Slices -1. Dialog parsing expansion (more phrase variants, ambiguity handling, and transcript-to-intent guardrails) +1. Dialog parsing expansion (queued next as of `2026-05-06`; more phrase variants, ambiguity handling, and transcript-to-intent guardrails) 2. Holidays and seasonal personality slice beyond pizza day (time-scoped content backed by memory/proactivity path) 3. Durable memory persistence path (swap in provider-backed multi-tenant storage while preserving behavior contracts) 4. Update/backup/restore end-to-end proof (operator-run and documented) diff --git a/OpenJibo/docs/system-diagram-alignment.md b/OpenJibo/docs/system-diagram-alignment.md new file mode 100644 index 0000000..64f1221 --- /dev/null +++ b/OpenJibo/docs/system-diagram-alignment.md @@ -0,0 +1,104 @@ +# System Diagram Alignment + +## Purpose + +This document maps the legacy Pegasus/Jibo cloud `system_diagram.png` architecture to the current OpenJibo `1.0.19` cloud. + +Use it to keep release planning grounded in three views: + +- where we were (legacy design intent) +- where we are (current hosted `.NET` implementation) +- where we are headed (next architecture slices) + +As-of date: `2026-05-06` + +## Diagram Inputs + +- Legacy system architecture: `C:\Projects\jibo\pegasus\resources\system_diagram.png` +- Legacy generic skill scaffold: `C:\Projects\jibo\pegasus\packages\template-skill\docs\TemplateSkill.png` + +## Template Skill Verdict + +The template-skill diagram is a generic scaffold, not a production behavior contract. + +Evidence: + +- `C:\Projects\jibo\pegasus\packages\template-skill\src\TemplateSkill.ts` is a starter graph (`Intent Split` -> `Do MIM` -> `Complete` -> `Done`). +- `C:\Projects\jibo\pegasus\packages\template-skill\src\nodes\MemoSplitNode.ts` uses placeholder memo validation (`SomeThing`). + +Conclusion: do not treat template-skill flow as a port target. Treat it as a shape reference only. + +## System Diagram Mapping + +| Legacy block | OpenJibo `1.0.19` equivalent | Current gap / opportunity | +| --- | --- | --- | +| `Auth` | [JiboCloudProtocolService.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs) (`CreateHubToken`, `CreateAccessToken`, account handlers) | move from in-memory/session stubs to durable tenant/account identity services | +| `Loop` | [JiboCloudProtocolService.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboCloudProtocolService.cs) (`HandleLoop`) + [InMemoryCloudStateStore.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryCloudStateStore.cs) | richer loop/member lifecycle and onboarding flows | +| `Hub` | [JiboWebSocketService.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboWebSocketService.cs) + [WebSocketTurnFinalizationService.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs) | split hub responsibilities into clearer protocol, routing, and orchestration boundaries | +| `ASR Handler` | STT strategy selection in [WebSocketTurnFinalizationService.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs) + DI in [ServiceCollectionExtensions.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs) | short-turn reliability, managed STT comparison, and better low-signal/noise handling | +| `Parser / Robust Parser` | rule-based intent resolution in [JiboInteractionService.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs) + focused state machines (personal report/chitchat) | deeper phrase import from Pegasus intents/entities plus ambiguity guardrails | +| `Skill Router` | [JiboInteractionService.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs) decision switch and local skill payload shaping | external skill routing config and safer declarative intent mapping | +| `Proactivity Selector` | weighted candidate selection in [JiboInteractionService.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/JiboInteractionService.cs) + pending-offer session state in [WebSocketTurnFinalizationService.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs) | externalized proactivity catalog, cooldown policy, and broader category coverage | +| `Skill Registry` | implicit in current code/routing | formal registry abstraction for local/cloud capabilities and manifest metadata | +| `History` | tenant-scoped memory store in [InMemoryPersonalMemoryStore.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Persistence/InMemoryPersonalMemoryStore.cs) | durable multi-tenant persistence and history timeline/query support | +| `Lasso` provider aggregation | partial provider integration via weather provider wiring in [ServiceCollectionExtensions.cs](../src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs) | full aggregation service for weather/news/calendar/knowledge inputs | +| `Proactivity Catalog` | in-code candidate lists/weights | explicit catalog service with tuned weights and operator controls | +| `Audio Logs` | file telemetry sinks in infrastructure telemetry | hosted indexed capture/retention for multi-operator analysis | + +## Where We Were + +Legacy cloud design was service-oriented around: + +- hub orchestration +- parser robustness +- skill routing +- proactivity selection +- history/memory and provider aggregation + +It emphasized a personality-rich surface while still being operationally observable. + +## Where We Are + +OpenJibo `1.0.19` is a functional hosted `.NET` modular monolith with: + +- protocol compatibility paths for HTTP and websocket robot flows +- deterministic intent routing plus state-machine slices +- tenant-scoped memory foundation +- first proactivity baseline +- first external weather provider integration + +This is the right shape for rapid parity plus safe incremental growth. + +## Where We Are Headed + +Near-term architecture evolution should preserve current shipping velocity: + +1. Expand parser coverage and ambiguity guardrails from Pegasus phrase corpora. +2. Externalize proactivity policy and category catalogs. +3. Move memory from in-memory to durable multi-tenant backing stores. +4. Add stronger observability around STT, parser decisions, and follow-up turn state. +5. Build a focused aggregation layer (Lasso-like) for multi-provider content. + +## Charm Preservation Rules + +To keep Jibo's charm while modernizing the platform: + +- keep MIM/ESML and expressive animation hooks as first-class outputs +- keep deterministic command-vs-question behavior for personality reliability +- layer richer provider data behind stable personality and gesture patterns +- prefer small source-backed slices over broad rewrites + +## Queued Next `1.0.19` Task + +The next queued implementation task is: + +- `Dialog parsing expansion and ambiguity guardrails` + +Tracking anchors: + +- [release-1.0.19-plan.md](release-1.0.19-plan.md) +- [feature-backlog.md](feature-backlog.md) + +Primary objective: + +- import Pegasus parser intent phrases/entities to improve intent confidence while preserving command-vs-question personality behavior. 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 a1c4cdb..1b393e9 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 @@ -2102,18 +2102,7 @@ public sealed class JiboInteractionService( { var normalized = NormalizeCommandPhrase(transcript); - var directMappings = new (string Prefix, PersonalAffinity Affinity)[] - { - ("i love ", PersonalAffinity.Love), - ("i like ", PersonalAffinity.Like), - ("i dislike ", PersonalAffinity.Dislike), - ("i hate ", PersonalAffinity.Dislike), - ("i don t like ", PersonalAffinity.Dislike), - ("i dont like ", PersonalAffinity.Dislike), - ("i do not like ", PersonalAffinity.Dislike) - }; - - foreach (var (prefix, affinity) in directMappings) + foreach (var (prefix, affinity) in PegasusUserAffinitySetPrefixes) { if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) { @@ -2133,17 +2122,8 @@ public sealed class JiboInteractionService( private static (string Item, PersonalAffinity? ExpectedAffinity)? TryExtractAffinityLookup(string transcript) { var normalized = NormalizeCommandPhrase(transcript); - var expectationPrefixes = new (string Prefix, PersonalAffinity? ExpectedAffinity)[] - { - ("do i love ", PersonalAffinity.Love), - ("do i like ", PersonalAffinity.Like), - ("do i dislike ", PersonalAffinity.Dislike), - ("do i hate ", PersonalAffinity.Dislike), - ("how do i feel about ", null), - ("what do i think about ", null) - }; - foreach (var (prefix, expectedAffinity) in expectationPrefixes) + foreach (var (prefix, expectedAffinity) in PegasusUserAffinityLookupPrefixes) { if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) { @@ -2826,7 +2806,47 @@ public sealed class JiboInteractionService( private static readonly string[] PreferenceReverseMarkers = [ " is my favorite ", - " is my favourite " + " is my favourite ", + " are my favorite ", + " are my favourite " + ]; + + // Directly imported from Pegasus parser intent phrase families: + // userLikesThing / userDislikesThing / doesUserLikeThing / doesUserDislikeThing. + private static readonly (string Prefix, PersonalAffinity Affinity)[] PegasusUserAffinitySetPrefixes = + [ + ("i love ", PersonalAffinity.Love), + ("i like ", PersonalAffinity.Like), + ("i enjoy ", PersonalAffinity.Like), + ("i do like ", PersonalAffinity.Like), + ("i dislike ", PersonalAffinity.Dislike), + ("i hate ", PersonalAffinity.Dislike), + ("i don t like ", PersonalAffinity.Dislike), + ("i dont like ", PersonalAffinity.Dislike), + ("i do not like ", PersonalAffinity.Dislike), + ("i don t enjoy ", PersonalAffinity.Dislike), + ("i dont enjoy ", PersonalAffinity.Dislike), + ("i do not enjoy ", PersonalAffinity.Dislike), + ("i don t love ", PersonalAffinity.Dislike), + ("i dont love ", PersonalAffinity.Dislike), + ("i do not love ", PersonalAffinity.Dislike), + ("i can t stand ", PersonalAffinity.Dislike), + ("i cant stand ", PersonalAffinity.Dislike), + ("i despise ", PersonalAffinity.Dislike), + ("i detest ", PersonalAffinity.Dislike) + ]; + + private static readonly (string Prefix, PersonalAffinity? ExpectedAffinity)[] PegasusUserAffinityLookupPrefixes = + [ + ("do i love ", PersonalAffinity.Love), + ("do i like ", PersonalAffinity.Like), + ("do i enjoy ", PersonalAffinity.Like), + ("do i dislike ", PersonalAffinity.Dislike), + ("do i hate ", PersonalAffinity.Dislike), + ("do i despise ", PersonalAffinity.Dislike), + ("do i detest ", PersonalAffinity.Dislike), + ("how do i feel about ", null), + ("what do i think about ", null) ]; private static readonly string[] PizzaPreferenceCategories = diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs index 7836512..5f196a2 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/ResponsePlanToSocketMessagesMapper.cs @@ -31,6 +31,7 @@ public sealed class ResponsePlanToSocketMessagesMapper var isVolumeControl = string.Equals(plan.IntentName, "volume_up", StringComparison.OrdinalIgnoreCase) || string.Equals(plan.IntentName, "volume_down", StringComparison.OrdinalIgnoreCase) || string.Equals(plan.IntentName, "volume_to_value", StringComparison.OrdinalIgnoreCase); + var isProactivePizzaFactOffer = string.Equals(plan.IntentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase); var isSettingsLaunch = string.Equals(skill?.SkillName, "@be/settings", StringComparison.OrdinalIgnoreCase); var isGlobalCommand = isStopCommand || isVolumeControl; var isPhotoGalleryLaunch = string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase); @@ -99,7 +100,9 @@ public sealed class ResponsePlanToSocketMessagesMapper !string.IsNullOrWhiteSpace(clientIntent) ? clientIntent : transcript; - var outboundRules = isWordOfDayLaunch + var outboundRules = isProactivePizzaFactOffer + ? ["shared/yes_no", "$YESNO"] + : isWordOfDayLaunch ? ["word-of-the-day/menu"] : isGlobalCommand ? BuildGlobalCommandRules(rules) diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs index aa974db..872be39 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Application/Services/WebSocketTurnFinalizationService.cs @@ -18,6 +18,28 @@ public sealed partial class WebSocketTurnFinalizationService( private static readonly TimeSpan AutoFinalizeMissingTranscriptFallbackAge = TimeSpan.FromMilliseconds(4200); private static readonly TimeSpan AutoFinalizeContinuationDeferralMaxAge = TimeSpan.FromMilliseconds(3600); private const int AutoFinalizeContinuationDeferralMaxAttempts = 2; + private static readonly HashSet PegasusAffinityContinuationStems = new(StringComparer.Ordinal) + { + "i love", + "i like", + "i enjoy", + "i do like", + "i dislike", + "i hate", + "i dont like", + "i don t like", + "i do not like", + "i dont enjoy", + "i don t enjoy", + "i do not enjoy", + "i dont love", + "i don t love", + "i do not love", + "i cant stand", + "i can t stand", + "i despise", + "i detest" + }; public static void ObserveIncomingMessage(CloudSession session, string? text) { @@ -1482,9 +1504,20 @@ public sealed partial class WebSocketTurnFinalizationService( } } + if (LooksLikeIncompleteAffinitySet(normalized)) + { + reason = "affinity_set_incomplete"; + return true; + } + return false; } + private static bool LooksLikeIncompleteAffinitySet(string normalized) + { + return PegasusAffinityContinuationStems.Contains(normalized); + } + private static Dictionary BuildTurnDiagnosticSnapshot( CloudSession session, WebSocketMessageEnvelope envelope, diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs index a9137fe..2f34940 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboInteractionServiceTests.cs @@ -565,6 +565,43 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Yes. You told me you dislike mushrooms.", recallDecision.ReplyText); } + [Fact] + public async Task BuildDecisionAsync_AffinityMemory_PegasusEnjoyPhrase_SetThenRecallWithinTenant() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var service = CreateService(memoryStore); + + var setDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "I enjoy country music", + NormalizedTranscript = "I enjoy country music", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + Assert.Equal("memory_set_affinity", setDecision.IntentName); + Assert.Equal("Got it. I will remember you like country music.", setDecision.ReplyText); + + var recallDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "do i enjoy country music", + NormalizedTranscript = "do i enjoy country music", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + Assert.Equal("memory_get_affinity", recallDecision.IntentName); + Assert.Equal("Yes. You told me you like country music.", recallDecision.ReplyText); + } + [Fact] public async Task BuildDecisionAsync_PreferenceReversePhrase_ParsesFavoriteVariant() { @@ -587,6 +624,28 @@ public sealed class JiboInteractionServiceTests Assert.Equal("Got it. I will remember your favorite food is pizza.", setDecision.ReplyText); } + [Fact] + public async Task BuildDecisionAsync_PreferenceReversePluralPhrase_ParsesFavoriteVariant() + { + var memoryStore = new InMemoryPersonalMemoryStore(); + var service = CreateService(memoryStore); + + var setDecision = await service.BuildDecisionAsync(new TurnContext + { + RawTranscript = "dogs are my favorite animals", + NormalizedTranscript = "dogs are my favorite animals", + Attributes = new Dictionary + { + ["accountId"] = "acct-a", + ["loopId"] = "loop-a" + }, + DeviceId = "device-a" + }); + + Assert.Equal("memory_set_preference", setDecision.IntentName); + Assert.Equal("Got it. I will remember your favorite animals is dogs.", setDecision.ReplyText); + } + [Fact] public async Task BuildDecisionAsync_Surprise_WithPizzaPreference_UsesPizzaProactivity() { diff --git a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs index 4ca4d73..66d7f6d 100644 --- a/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs +++ b/OpenJibo/tests/Jibo.Cloud.Tests/WebSockets/JiboWebSocketServiceTests.cs @@ -355,6 +355,75 @@ public sealed class JiboWebSocketServiceTests Assert.Equal("my favorite sport is football", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); } + [Fact] + public async Task BufferedAudio_WithIncompleteAffinityHint_DefersThenFinalizesWhenContinuationArrives() + { + await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-affinity-continuation-token", + Text = """{"type":"LISTEN","transID":"trans-affinity-continuation","data":{"rules":["launch"]}}""" + }); + + await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-affinity-continuation-token", + Text = """{"type":"CONTEXT","transID":"trans-affinity-continuation","data":{"audioTranscriptHint":"i do like"}}""" + }); + + for (var index = 0; index < 4; index += 1) + { + var chunkReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-affinity-continuation-token", + Binary = new byte[3000] + }); + + Assert.Empty(chunkReplies); + } + + var session = _store.FindSessionByToken("hub-affinity-continuation-token"); + Assert.NotNull(session); + session.TurnState.FirstAudioReceivedUtc = DateTimeOffset.UtcNow - TimeSpan.FromSeconds(2); + + var deferredReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-affinity-continuation-token", + Binary = new byte[3000] + }); + + Assert.Empty(deferredReplies); + + var finalizedReplies = await _service.HandleMessageAsync(new WebSocketMessageEnvelope + { + HostName = "neo-hub.jibo.com", + Path = "/listen", + Kind = "neo-hub-listen", + Token = "hub-affinity-continuation-token", + Text = """{"type":"CONTEXT","transID":"trans-affinity-continuation","data":{"audioTranscriptHint":"i do like pizza"}}""" + }); + + Assert.Equal(3, finalizedReplies.Count); + Assert.Equal("LISTEN", ReadReplyType(finalizedReplies[0])); + Assert.Equal("EOS", ReadReplyType(finalizedReplies[1])); + Assert.Equal("SKILL_ACTION", ReadReplyType(finalizedReplies[2])); + + using var listenPayload = JsonDocument.Parse(finalizedReplies[0].Text!); + Assert.Equal("memory_set_affinity", listenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("i do like pizza", listenPayload.RootElement.GetProperty("data").GetProperty("asr").GetProperty("text").GetString()); + } + [Fact] public async Task MultiChunkAudio_AccumulatesBufferedStateAcrossMessages() { @@ -3147,6 +3216,9 @@ public sealed class JiboWebSocketServiceTests using (var offerListenPayload = JsonDocument.Parse(offerReplies[0].Text!)) { Assert.Equal("proactive_offer_pizza_fact", offerListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("intent").GetString()); + Assert.Equal("shared/yes_no", offerListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[0].GetString()); + Assert.Equal("$YESNO", offerListenPayload.RootElement.GetProperty("data").GetProperty("nlu").GetProperty("rules")[1].GetString()); + Assert.Equal("shared/yes_no", offerListenPayload.RootElement.GetProperty("data").GetProperty("match").GetProperty("rule").GetString()); } var session = _store.FindSessionByToken(token);