116 Commits

Author SHA1 Message Date
f6bf5e2079 Merge branch 'main' into Features/Webpanel-Ports 2026-05-23 00:22:23 +03:00
1e130e69ab Merge branch 'main' of https://kevinblog.sytes.net/Code/Jaked/JiboExperiments 2026-05-23 00:21:51 +03:00
bca138ecc8 Original server design doc 2026-05-23 00:21:42 +03:00
Jacob Dubin
2357e82ae3 Randomize how are you replies 2026-05-22 07:37:22 -05:00
Jacob Dubin
1755888fc1 Refine favorite animal and flower personality replies 2026-05-22 07:31:27 -05:00
Jacob Dubin
3086ad6a6d Adjust idle socket reply delays for sleep and spin commands 2026-05-21 23:23:44 -05:00
Jacob Dubin
90b48314d3 Restrict loop name fallback for multi-person greetings 2026-05-21 23:21:36 -05:00
Jacob Dubin
b99ee5d794 Record live QA repair targets for identity and motion quirks 2026-05-21 23:18:25 -05:00
Jacob Dubin
386f864e94 Import Build B age prompts for how old are you 2026-05-21 23:12:48 -05:00
Jacob Dubin
9d675ed59c Import work eat home Build B replies 2026-05-21 20:31:49 -05:00
Jacob Dubin
b113dd55d3 Refactor Build B templated persona prompts 2026-05-21 20:29:33 -05:00
Jacob Dubin
d52c4e6e19 Add body and mission personality prompts 2026-05-21 18:05:39 -05:00
Jacob Dubin
b0709dd25e Add identity and knowledge legacy MIM replies 2026-05-21 17:55:02 -05:00
Jacob Dubin
5422febb8c Add deep personality Build B prompts 2026-05-21 17:48:20 -05:00
Jacob Dubin
eeef2b3beb Polish grocery list alias wording and backlog MVP decision 2026-05-21 17:00:29 -05:00
Jacob Dubin
acdc6da286 Add structured headlines to news payload 2026-05-21 16:50:43 -05:00
32c601d046 Created a simple web panel with a quick API and added a mode for the server to switch to "multi-port" mode 2026-05-22 00:47:47 +03:00
Jacob Dubin
febceecab8 Add binary media manifest metadata 2026-05-21 16:41:23 -05:00
Jacob Dubin
791fe60612 Add capture bundle helper for group testing 2026-05-21 16:37:54 -05:00
Jacob Dubin
3d016debe5 Add low-signal short-turn screening 2026-05-21 15:12:34 -05:00
Jacob Dubin
764a2b2d4f Personalize how_are_you replies with remembered names 2026-05-21 14:31:59 -05:00
Jacob Dubin
b75d9f7941 Expand mood small-talk intent routing 2026-05-21 12:54:51 -05:00
Jacob Dubin
303d8830b0 Route home/back greetings to welcome back 2026-05-21 12:10:58 -05:00
Jacob Dubin
c3b2e5fc2c Refine proactive greetings with morning and return-visit tones 2026-05-21 12:09:30 -05:00
Jacob Dubin
e8d7bafcd6 Add birthday and holiday special-day greetings 2026-05-21 12:05:46 -05:00
Jacob Dubin
70b1b1547f Track durable greeting history for presence-aware greetings 2026-05-21 12:01:13 -05:00
Jacob Dubin
4989889608 Expand friendship parsing guardrails 2026-05-21 11:53:40 -05:00
Jacob Dubin
40deecf2ff Add friendship persona responses 2026-05-21 11:29:44 -05:00
Jacob Dubin
e85792ac57 Add source-backed singing personality responses 2026-05-21 11:22:20 -05:00
Jacob Dubin
a398689851 Extract launch decisions into partial 2026-05-21 09:54:36 -05:00
Jacob Dubin
c883297f26 Extract command route builders from JiboInteractionService 2026-05-21 09:51:38 -05:00
Jacob Dubin
5fa13a65a2 Extract memory and personality decision builders 2026-05-21 09:40:21 -05:00
Jacob Dubin
e5e8e72dbf line endings 2026-05-21 08:53:50 -05:00
Jacob Dubin
a72991dfcb Remove obsolete code paths and simplify implementation 2026-05-21 08:50:15 -05:00
Jacob Dubin
0a0a94502a Remove obsolete code paths 2026-05-21 08:46:24 -05:00
Jacob Dubin
aebfe2e38d Normalize location abbreviations in speech 2026-05-21 07:51:11 -05:00
Jacob Dubin
0f9f91f79a Extract report helpers from JiboInteractionService 2026-05-21 07:45:47 -05:00
Jacob Dubin
a0d6102399 Extract report decision helpers into partial service 2026-05-21 07:39:52 -05:00
Jacob Dubin
2bf686f791 more refactors 2026-05-21 07:31:11 -05:00
Jacob Dubin
c4c512497c refactors 2026-05-21 07:20:31 -05:00
Jacob Dubin
6138ef1c3e Extract seasonal holiday routing into dedicated builder 2026-05-21 07:02:56 -05:00
Jacob Dubin
bba1dfdcfc Refactor scripted response decision helpers 2026-05-21 06:53:02 -05:00
Jacob Dubin
e85e14fbd3 Add seasonal spring and summer scripted responses 2026-05-21 06:36:25 -05:00
Jacob Dubin
bedb5d1715 Polish news and personal report phrasing 2026-05-21 06:28:16 -05:00
Jacob Dubin
eb509a66e0 Polish weather news and STT filtering 2026-05-21 06:09:27 -05:00
Jacob Dubin
1b9efc4226 Add identity charm mims for Jibo persona 2026-05-21 05:53:19 -05:00
Jacob Dubin
fff342fd18 Wrap personal report in report-skill payload 2026-05-21 00:28:05 -05:00
Jacob Dubin
884b2215c7 Document commute provider seam for personal report 2026-05-20 23:25:41 -05:00
Jacob Dubin
c76af83d7e Add holiday seasonal routing and calendar report seam 2026-05-20 20:03:14 -05:00
Jacob Dubin
39b21d1326 Add remaining seasonal holiday polish 2026-05-20 07:20:47 -05:00
Jacob Dubin
9f2a8fd7e1 Add Santa Tracker and penguin favorites 2026-05-20 07:15:30 -05:00
Jacob Dubin
b172a00454 Rehydrate default loop on empty snapshot 2026-05-19 20:06:03 -05:00
Jacob Dubin
30493d554b cleanup 2026-05-19 19:28:04 -05:00
Jacob Dubin
5ad6d4e673 Add holiday buckets and birthday authoring 2026-05-19 19:12:34 -05:00
6ac0c794e4 revert 07d7c83559
revert Tweak how mims work to get better responses
2026-05-19 20:11:06 +00:00
07d7c83559 Tweak how mims work to get better responses 2026-05-19 16:01:12 -04:00
c17c3db0a2 Add more mims 2026-05-19 14:26:03 -04:00
Jacob Dubin
2bc6fec1bf Add loop-scoped holiday list support 2026-05-19 06:57:09 -05:00
Jacob Dubin
54b32bc9cf refactor 2026-05-19 06:03:34 -05:00
Jacob Dubin
6bae858da9 cleanup 2026-05-19 06:00:42 -05:00
Jacob Dubin
d3f9de9503 Add commute report support to interaction service 2026-05-18 06:33:35 -05:00
Jacob Dubin
b25793443f Add calendar service-down support to personal report 2026-05-17 23:47:51 -05:00
Jacob Dubin
e588f00c43 Add sleep and spin-around global command handling 2026-05-17 23:38:29 -05:00
Jacob Dubin
51e36bc492 Expand favorites with longer Pegasus-style phrasing 2026-05-17 20:35:28 -05:00
Jacob Dubin
9ffdd6d09e Document longer persona variants and post-release dialog composition 2026-05-17 20:29:39 -05:00
Jacob Dubin
af76cbaee2 Fix personal report yes/no and weather hi/low handling 2026-05-17 20:20:48 -05:00
Jacob Dubin
f2826253d5 Expand surprise facts into robot and human categories 2026-05-17 17:50:01 -05:00
Jacob Dubin
8ed4763df5 Add proactive fun facts, jokes, and current location 2026-05-17 17:41:55 -05:00
Jacob Dubin
9353e8d2e3 Add binary-safe media storage seam 2026-05-17 16:47:58 -05:00
Jacob Dubin
14b5cb74cc Add capture index manifest for group testing 2026-05-17 14:07:56 -05:00
Jacob Dubin
c0485da46d Normalize loose STT transcripts before routing 2026-05-17 14:02:47 -05:00
Jacob Dubin
193fa56847 Clarify update backup restore rehydration proof 2026-05-17 13:47:44 -05:00
Jacob Dubin
a2aa9df46a Normalize transcripts and expand speech regressions 2026-05-17 12:39:46 -05:00
Jacob Dubin
d8949fcc9a Tighten STT noise filtering and preserve yes-no replies 2026-05-17 11:18:57 -05:00
Jacob Dubin
3b279fdd6f Implement update backup and restore proof 2026-05-17 10:11:36 -05:00
Jacob Dubin
dfcf521a5a refactors 2026-05-17 08:08:11 -05:00
Jacob Dubin
05efeb2853 Add Azure Blob startup profile and config sample 2026-05-17 08:01:04 -05:00
Jacob Dubin
478a320581 Wire selectable persistence backend for cloud state stores 2026-05-17 07:15:12 -05:00
Jacob Dubin
888f472f69 Introduce pluggable snapshot storage for persistence 2026-05-17 07:02:49 -05:00
Jacob Dubin
785dc2b48b Extract JSON snapshot persistence helpers 2026-05-17 06:58:12 -05:00
Jacob Dubin
d37521281e Add persistence seams for durable state adapters 2026-05-17 00:47:36 -05:00
Jacob Dubin
5d57095ce5 Document persistence architecture and report-skill parity 2026-05-17 00:41:09 -05:00
Jacob Dubin
a8a153e910 Vendor Pegasus report-skill templates for weather and reports 2026-05-17 00:30:00 -05:00
Jacob Dubin
a47c90c9c3 Polish weather phrasing for current and forecast replies 2026-05-16 10:16:34 -05:00
Jacob Dubin
393c34055d Prefer forecast hi lo for current weather 2026-05-16 10:01:40 -05:00
Jacob Dubin
f9b728c2a0 Add seasonal and presence charm batches 2026-05-16 09:09:18 -05:00
Jacob Dubin
c87af4686c Add seasonal Build B legacy MIM imports 2026-05-16 08:53:07 -05:00
Jacob Dubin
84759f51de Add Build B charm descriptors and mood replies 2026-05-16 08:39:45 -05:00
Jacob Dubin
c8beb0d1f0 Expand Build B mood and persona follow-ups 2026-05-16 08:05:42 -05:00
Jacob Dubin
e43b4f05f0 Port more legacy charm MIMs into Build B 2026-05-14 22:21:36 -05:00
Jacob Dubin
2677cf9dac Refine commit message handling 2026-05-14 22:06:25 -05:00
Jacob Dubin
20b84632ec Close memory recall turns so recognition does not keep mic open 2026-05-14 21:58:00 -05:00
Jacob Dubin
5718edecaf shortened timeout in conversation broker 2026-05-14 21:47:10 -05:00
Jacob Dubin
40b5b8e4a8 Expand persona follow-ups for identity and favorites 2026-05-14 21:39:17 -05:00
Jacob Dubin
8f7c118fb3 Expand persona inventory and add favorite-family prompts 2026-05-14 21:16:50 -05:00
Jacob Dubin
c30363ec9f Add person-aware favorites and multitenant state scaffolding 2026-05-14 21:15:14 -05:00
Jacob Dubin
ec786be797 Add person-aware state and sync roadmap 2026-05-14 20:48:55 -05:00
Jacob Dubin
f299cef9be Add stateful shopping and to-do list follow-ups 2026-05-14 07:44:46 -05:00
Jacob Dubin
f5e37729ab Expand legacy Build A routing and emotion replies 2026-05-14 06:48:39 -05:00
Jacob Dubin
7297017250 Port legacy persona and emotion replies 2026-05-14 06:44:22 -05:00
Jacob Dubin
66b89f3cee Add Build A legacy MIM import support 2026-05-13 23:22:05 -05:00
Jacob Dubin
11a3e4ef13 Add legacy MIM importer and seed Build A content 2026-05-13 23:18:18 -05:00
Jacob Dubin
7c6dacdbd8 Fix weather, yes/no, and news integrations 2026-05-12 20:36:43 -05:00
Jacob Dubin
9093b429ca Harden weather date parsing and add request diagnostics 2026-05-12 07:52:38 -05:00
Jacob Dubin
df3b34c8ad Add weekly weather cards and improve news API fallback 2026-05-11 22:44:56 -05:00
Jacob Dubin
67c738fae3 Improve weather and news diagnostics for report skills 2026-05-11 19:59:15 -05:00
Jacob Dubin
c0e9b41cd1 Revert weather report-skill routing to stabilize playback 2026-05-11 07:26:56 -05:00
Jacob Dubin
af2fdd230c Improve weather routing and news API fallback 2026-05-11 07:15:11 -05:00
Jacob Dubin
0c597ebbf8 Fix weather forecast parsing and NewsAPI fallback 2026-05-10 23:08:06 -05:00
Jacob Dubin
4bc87f927b Broaden yes no parsing for proactive follow ups 2026-05-10 21:22:25 -05:00
Jacob Dubin
a94b7ec493 Merge branch 'main' of https://kevinblog.sytes.net/Code/Jibo-Revival-Group/JiboExperiments 2026-05-10 20:31:46 -05:00
Jacob Dubin
8c17ad4035 Update commit message generation 2026-05-10 20:31:07 -05:00
383c272d9a Assume unknown requests as neo-hub
I did this so custom servers that haven't edited this to include their server, won't have any issues with Hey Jibo requests.
2026-05-11 01:23:22 +00:00
Jacob Dubin
d434138f9b Refresh OpenJibo docs with a roadmap 2026-05-10 06:15:19 -05:00
Jacob Dubin
80c4ae38fb Add commit message generation prompt 2026-05-10 00:30:31 -05:00
Jacob Dubin
8ae6d86a8c Expand affinity parser guardrails with Pegasus phrases 2026-05-09 23:46:00 -05:00
4250 changed files with 765880 additions and 6003 deletions

1
.gitignore vendored
View File

@@ -420,3 +420,4 @@ FodyWeavers.xsd
OpenJibo/captures/
OpenJibo/.tmp/
OpenJibo/docs/DesignDoc/original server

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />

View File

@@ -1,15 +1,26 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=ampm/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Arrrr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=bday/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=bleebo/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=didn/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=didnt/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=dont/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=esml/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Hotphrase/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Jibo/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=jiboji/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=jibos/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Jibo_0027s/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mult/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=multichunk/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=nevermind/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=noinput/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=onomies/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=openjibo/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Photobooth/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=photogal/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=roboting/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=slnx/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=slowdance/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=timecoded/@EntryIndexedValue">True</s:Boolean>

View File

@@ -3,12 +3,17 @@
<File Path="docs/development-plan.md" />
<File Path="docs/device-bootstrap.md" />
<File Path="docs/feature-backlog.md" />
<File Path="docs/greetings-presence-plan.md" />
<File Path="docs/live-jibo-capture.md" />
<File Path="docs/live-jibo-test-runbook.md" />
<File Path="docs/personal-report-parity-plan.md" />
<File Path="docs/protocol-inventory.md" />
<File Path="docs/public-site-plan.md" />
<File Path="docs/regression-test-plan.md" />
<File Path="docs/release-1.0.19-plan.md" />
<File Path="docs/roadmap.md" />
<File Path="docs/support-tiers.md" />
<File Path="docs/system-diagram-alignment.md" />
</Folder>
<Folder Name="/docs/prompts/">
<File Path="docs/prompts/cloud-deploy-and-jibo-rcm-path.md" />

View File

@@ -1,31 +1,41 @@
# OpenJibo
## Summary
OpenJibo is the working revival track for Jibo.
The near-term plan is intentionally concrete:
We are rebuilding the hosted cloud first, then using that foundation for OTA, Open Jibo OS, and a tiered brain that can eventually hand higher-order work to CoffeeBreak without losing Jibo's original charm.
1. Build a stable replacement cloud on Azure.
2. Use the existing Node prototype as the protocol oracle and capture harness.
3. Port the hosted implementation to .NET as a modular monolith.
4. Bring real robots online first through RCM plus controlled DNS/TLS patching.
5. Use OTA later to reduce setup friction once the hosted cloud is proven.
## Current Focus
This keeps the project grounded in what is already working while moving toward a maintainable hosted platform.
- ship a stable Azure-hosted replacement cloud
- keep the Node prototype as the protocol oracle and capture harness
- port the production path to .NET
- support real devices through repeatable bootstrap steps first
- use OTA later to reduce recovery friction once the cloud is trustworthy
## Current Truth
Current release truth lives in [docs/development-plan.md](docs/development-plan.md). The current cloud release constant is `1.0.19`.
## Roadmap
The long-range plan is summarized in [docs/roadmap.md](docs/roadmap.md). In short:
1. Working hosted cloud.
2. OTA-assisted recovery and updates.
3. Open Jibo OS / `open-jibo` mode conversion.
4. Tiered brain and CoffeeBreak orchestration.
5. Broader ecosystem expansion.
## Current Architecture
The repo now has three distinct lanes:
- `src/Jibo.Cloud/node`
The discovery server. This is the best source of observed protocol behavior today.
Protocol oracle, discovery server, fixture source, and rapid reverse-engineering lab.
- `src/Jibo.Cloud/dotnet`
The long-term hosted implementation. This is where the stable cloud is being built.
Production-oriented hosted implementation intended for Azure deployment and long-term maintenance.
- `src/Jibo.Runtime.Abstractions`
The normalized runtime seam between robot/cloud traffic and modern conversation logic.
The seam between robot/cloud traffic and higher-level runtime and capability logic.
The key architectural idea is:
The core shape is:
```text
Jibo device -> OpenJibo cloud -> normalized runtime contracts -> capabilities and planning
@@ -40,18 +50,30 @@ QR Wi-Fi -> inject OpenJibo region config -> set robot region ->
RCM/device patch for TLS and host acceptance -> OpenJibo cloud on Azure
```
That path is documented in [docs/device-bootstrap.md](/OpenJibo/docs/device-bootstrap.md).
That path is documented in [docs/device-bootstrap.md](docs/device-bootstrap.md).
## Design Principles
- Preserve the original skills and visual design.
- Build the hosted cloud before making OTA the default recovery path.
- Keep every migration reversible whenever possible.
- Prefer source-backed slices over speculative rewrites.
- Let Jibo remain the face of the experience, even when higher-level orchestration sits behind him.
## Repo Map
```text
OpenJibo/
docs/
roadmap.md
development-plan.md
device-bootstrap.md
protocol-inventory.md
feature-backlog.md
public-site-plan.md
regression-test-plan.md
release-1.0.19-plan.md
support-tiers.md
system-diagram-alignment.md
scripts/bootstrap/
Discover-JiboHosts.ps1
@@ -67,56 +89,15 @@ OpenJibo/
OpenJibo.Site/
```
## Decisions Locked In
## Living Docs
- The first milestone is `core revive`, not full protocol parity.
- Azure SQL is the relational system of record for the hosted cloud.
- Billing and donations are future-compatible concerns, not phase-one delivery requirements.
- OTA is a phase-two simplification strategy, not the initial dependency.
Use these when you want the active technical truth:
## Near-Term Work
- [Development plan](docs/development-plan.md)
- [Feature backlog](docs/feature-backlog.md)
- [Release 1.0.19 plan](docs/release-1.0.19-plan.md)
- [Support tiers](docs/support-tiers.md)
- [System diagram alignment](docs/system-diagram-alignment.md)
- [Public site plan](docs/public-site-plan.md)
- port required endpoint and WebSocket behavior from Node to .NET
- keep protocol captures and replay fixtures current
- keep HTTP and websocket live-run telemetry writing to the same repo-root capture tree
- harden device bootstrap documentation and scripts
- map more endpoints and behaviors beyond the current Node coverage
- stand up the initial `openjibo.com` information site
## Live Test Status
The first physical `.NET -> Jibo` experiments have now produced useful captures, but not a full wake-and-interact success yet.
What we have confirmed so far:
- the robot reaches `.NET` HTTP startup calls on `api.jibo.com`
- `.NET` can issue a robot token and accept the `api-socket.jibo.com` websocket
- live HTTP and websocket telemetry are now intended to land together under repo-root `captures/`
What remains unresolved:
- matching the Node startup cadence closely enough for consistent wake/eye-open behavior
- the next post-`api-socket` startup requests and timing seen in successful Node runs
- broader live websocket behavior on a real robot beyond the current synthetic parity slice
The current websocket bridge now also includes server-driven raw-audio turn completion:
- enough buffered audio plus `CONTEXT` can now trigger auto-finalize on the server side
- `EOS` is emitted on that auto-finalize path so turns do not remain open indefinitely
- transcript-less raw-audio turns still fall back to a synthetic compatibility response, not real ASR
The current richer websocket parity slice is still intentionally narrow:
- the successful joke path now has fixture-backed reply sequencing and partial payload-shape fidelity through `CLIENT_ASR -> LISTEN -> EOS -> delayed SKILL_ACTION`
- menu-side `CLIENT_NLU` parity is beginning to expand from live captures, starting with preserved clock-menu intent/rules/entities
- `.NET` now preserves buffered websocket audio frames so local tool-based ASR experiments can run without changing the stable cloud-first architecture
- this is not a claim of broad skill parity or full Jibo websocket coverage
## Important Docs
- [Cloud overview](/src/Jibo.Cloud/README.md)
- [Development plan](/docs/development-plan.md)
- [Protocol inventory](/docs/protocol-inventory.md)
- [Support tiers](/docs/support-tiers.md)
- [Device bootstrap path](/docs/device-bootstrap.md)
- [Public site plan](/docs/public-site-plan.md)
If you only read one document for the long view, make it [docs/roadmap.md](docs/roadmap.md).

View File

@@ -0,0 +1,792 @@
# Original Jibo Server (Pegasus) Design Document
## Executive Summary
The original Jibo server, codenamed "Pegasus" (formerly V1.X), is a cloud-based microservices architecture that powers the Jibo social robot's conversational AI capabilities. It is built as a Lerna monorepo using Node.js/TypeScript and deployed via Docker containers. The system processes speech, performs natural language understanding, routes to appropriate skills, and manages proactive behaviors.
## Architecture Overview
### Monorepo Structure
The codebase is organized as a Lerna monorepo with the following main packages:
- **packages/hub** - Central orchestration service
- **packages/parser** - NLU (Natural Language Understanding) service
- **packages/history** - Data persistence service (MongoDB)
- **packages/baseskill** - Base class and framework for cloud skills
- **packages/interfaces** - TypeScript interfaces and API contracts
- **packages/utils** - Shared utility libraries
- **packages/chitchat-skill** - Example conversational skill
- **packages/report-skill** - Reporting skill
- **packages/lasso** - External data integration service
- **packages/hub-client** - Client library for hub communication
- **packages/history-client** - Client library for history service
- **packages/test-utils** - Testing utilities
### Technology Stack
- **Language**: TypeScript 2.5.3
- **Runtime**: Node.js 8.9.4
- **Package Manager**: Yarn 1.7.0
- **Containerization**: Docker
- **Orchestration**: Docker Compose (local), AWS ECS (production)
- **Database**: MongoDB 3.6.0
- **Cache**: Redis 3
- **NLU**: Dialogflow (API.ai)
- **ASR**: Google Cloud Speech API
- **WebSocket**: ws library
- **HTTP**: Express.js
- **Authentication**: JWT (jsonwebtoken)
## Core Services
### 1. Hub Service (`packages/hub`)
The Hub is the central orchestrator that coordinates all interactions between the robot and cloud services.
#### Key Components
**HubService** (`HubService.ts`)
- Main service class extending `BaseService`
- Initializes and manages all hub components
- Registers WebSocket and HTTP handlers
**HubComponents** - Dependency injection container:
- `parser: ParserClient` - NLU service client
- `skillConfigManager: SkillConfigManager` - Manages skill configurations
- `intentRouter: IntentRouter` - Routes intents to skills
- `skillRequestMaker: SkillRequestMaker` - Makes HTTP requests to skills
- `history: HistoryServiceClient` - History service client
- `hubSettings: HubSettings` - Hub configuration
- `settingsClient: SettingsClient` - Settings service client
#### Endpoints
**WebSocket Endpoints:**
- `/listen` and `/v1/listen` - Handles speech recognition and NLU
- `/proactive` and `/v1/proactive` - Handles proactive triggers
**HTTP Endpoints:**
- `/skills` and `/v1/skills` - Lists available skills
- `/healthcheck` - Service health check
#### Listen Flow
The listen transaction follows a state machine implemented in `ListenTransactionHandler`:
```
States:
WAIT_LISTEN → ASR → NLU → ROUTE → DONE
WAIT_LISTEN → WAIT_CLIENT_ASR → NLU → ROUTE → DONE
WAIT_LISTEN → WAIT_CLIENT_NLU → ROUTE → DONE
```
**State Transitions:**
1. **WAIT_LISTEN** - Receives LISTEN message from robot
2. **ASR** - Performs Automatic Speech Recognition using Google Cloud Speech API
- Streams audio packets
- Emits SOS (Start of Speech) when speech detected
- Emits EOS (End of Speech) when speech ends
- Handles timeouts (SOS timeout, max speech timeout)
3. **NLU** - Sends ASR text to Parser service for intent recognition
- Includes context (loop users, perception, etc.)
- Supports external Dialogflow agents
4. **ROUTE** - Intent Router determines which skill to launch
- Matches NLU result against skill intent configurations
- Decision Mediator can alter decisions based on external factors
- Routes to on-robot skills or cloud skills
5. **DONE** - Transaction complete
**Listen Transaction Handler** (`ListenTransactionHandler.ts`):
- Manages audio streaming via `AudioBuffer`
- Creates `ASRSession` for speech recognition
- Handles timeouts (ASR: 40s, Parser: 10s, Context: 5s, Skill: 10s)
- Records speech history to MongoDB and optionally S3
- Supports client-provided ASR/NLU (for menu clicks, etc.)
- Handles skill redirects
#### Proactive Flow
The proactive system allows Jibo to initiate conversations based on context, history, and triggers.
**Proactive Transaction Handler** (`ProactiveTransactionHandler.ts`):
1. Receives TRIGGER message from robot
2. Waits for CONTEXT message (robot state)
3. **Action Selection**:
- Gets all proactive skill configurations
- Filters by context rules (time, location, people present, etc.)
- Filters by interaction history rules (frequency, recency)
- Filters by user settings
- Randomly selects from eligible actions
4. Launches selected skill (on-robot or cloud)
5. Returns match response or no-action response
**Proactive Registration**:
Skills register proactive behaviors with:
- Trigger types (time-based, event-based, surprise)
- Context rules (when this can trigger)
- Interaction history rules (how often it can trigger)
- Settings rules (user preferences)
### 2. Parser Service (`packages/parser`)
The Parser service performs Natural Language Understanding using Dialogflow.
**ParserService** (`ParserService.ts`):
- Starts RobustParser process on port 8787 (optional)
- Initializes Dialogflow client
- Initializes Robust Parser client
- Handles POST requests to `/v1/parse`
- Exposes state at `/state` endpoint
**NLU Pipeline:**
1. Receives text, rules, and context
2. Queries Dialogflow with configured agents
3. Optionally queries Robust Parser (custom NLU)
4. Returns intent, entities, and rules
**Configuration:**
- Dialogflow API key
- Robust Parser enable/disable
- Multiple external agents support
### 3. History Service (`packages/history`)
The History service persists interaction data to MongoDB.
**HistoryService** (`HistoryService.ts`):
- Two database clients:
- `SkillLaunchDBClient` - Records skill launches
- `SpeechHistoryDBClient` - Records speech interactions (optional)
- HTTP endpoints:
- `/v1/skill/launch` - Skill launch history
- `/v1/speech` - Speech history (if enabled)
- Health check endpoint
**Data Stored:**
- Skill launches (skill ID, intent, timestamp, robot ID, account ID)
- Speech interactions (ASR result, NLU result, audio file URL, error tracking)
### 4. Lasso Service (`packages/lasso`)
Lasso provides external data integration for skills.
**Features:**
- OAuth2 credential management
- Calendar client integration
- Weather data (Dark Sky API)
- Maps data (Google Maps API)
- News data (AP News)
- MongoDB for credential storage
- Redis for caching
**LassoService** (`LassoService.ts`):
- Manages OAuth2 flows
- Provides relay endpoints for external APIs
- Caches responses in Redis
## Skill Framework
### BaseSkill (`packages/baseskill`)
**BaseSkill** (`BaseSkill.ts`):
- Abstract base class for all cloud skills
- Extends `BaseHttpHandler`
- Handles POST requests to `/`
- Provides error handling
- Tracks timing
**GraphSkill** (`GraphSkill.ts`):
- Extends BaseSkill with graph-based state machine
- Implements node-based conversation flow
- Supports skill redirects
- Tracks analytics events
- Supports supplemental behaviors (parallel/sequence)
### Graph System
The graph system provides a state machine framework for skills.
**Graph** (`Graph.ts`):
- Directed graph of connected nodes
- Supports subgraphs (hierarchical)
- Exit transitions for graph termination
- Validation (reachability, transition completeness)
- GraphViz dot file generation
**GraphManager** (`GraphManager.ts`):
- Singleton per skill
- Manages node IDs and mappings
- Executes graph:
- `start()` - Creates session, enters initial node
- `enterNode()` - Calls node's enter method
- `exitNode()` - Calls node's exit method with action results
- `executeTransition()` - Moves to next node
- Maintains session state (node ID, data, trace)
**Node** (`Node.ts`):
- Abstract base class for graph nodes
- Has transition names and destinations
- Two lifecycle methods:
- `enter(data)` - Called when node is entered, returns action or redirect
- `exit(data)` - Called with action results, returns next transition
- Supports graph traversal (BFS)
**Built-in Node Types:**
- `DefaultNode` - Simple terminal node
- `JCPNode` - Returns JCP action
- `NoOpNode` - No operation
- `TrueFalseNode` - Conditional branching
- `SetLooperIDNode` - Sets speaker ID
**MIM (Motion Interaction Model) System:**
- `ANFactory` - Creates graph for playing MIM animations
- Supports scripted responses, emotion responses, fallback responses
- Semi-specific responses (context-aware)
### Skill Request/Response Protocol
**Skill Request Types** (`skill/request.ts`):
- `LISTEN_LAUNCH` - Launch skill from listen interaction
- `LISTEN_UPDATE` - Update skill with action results
- `PROACTIVE_LAUNCH` - Launch skill proactively
**Skill Request Data:**
```typescript
{
type: MessageType,
msgID: UUID,
ts: number,
data: {
general: { accountID, robotID, lang, release },
runtime: { character, location, loop, perception, dialog },
skill: { id, session? },
result: any, // Action results for UPDATE
nlu: NLUResult,
asr: ASRResult,
memo?: any
}
}
```
**Skill Response Types** (`skill/response.ts`):
- `SKILL_ACTION` - Returns action to execute
- `SKILL_REDIRECT` - Redirects to another skill
- `ERROR` - Error response
**Skill Action Data:**
```typescript
{
action: JCPAction, // JCP protocol behavior
analytics?: AnalyticsData,
final?: boolean, // Is this the final response?
fireAndForget?: boolean
}
```
**JCP Action** (`skill/action.ts`):
```typescript
{
type: ActionType.JCP,
config: {
version: "1.0.0",
jcp: SupportedBehaviors // SLIM, Sequence, Parallel, SetPresentPerson, ImpactEmotion
}
}
```
### Skill Configuration
**SkillConfig** (`skill/config.ts`):
```typescript
{
id: SkillID,
intents: [{
name: IntentName,
entities?: EntityConfig[],
memo?: any
}],
proactives?: ProactiveRegistration[],
IHQueries?: IHQueryDefinitions,
onRobot?: boolean,
URL: string,
settings?: ManifestSettings
}
```
**Entity Config**:
- `name` - Entity name
- `value` - Expected value
- `matchRule` - 'EXACT' or 'NOT'
**Proactive Registration**:
- Trigger type and conditions
- Context rules
- Interaction history rules
- Settings rules
## Interfaces Package
The `interfaces` package defines all TypeScript interfaces for communication between services.
### Key Interface Modules
**service.ts** - Base message types:
- `BaseMessage<T, D>` - Generic message with type, msgID, timestamp, data
- `BaseResponse<T, D>` - Response with final flag and timings
- `IAuthDetails` - Authentication details (account ID, access keys)
**hub/** - Hub-specific interfaces:
- `request.ts` - LISTEN, CONTEXT, CLIENT_ASR, CLIENT_NLU messages
- `response.ts` - ASR, NLU, LISTEN, SKILL_REDIRECT, ERROR responses
- `MessageType.ts` - Message type enums
- `HubErrorCode.ts` - Error code enums
**skill/** - Skill-specific interfaces:
- `request.ts` - LISTEN_LAUNCH, LISTEN_UPDATE, PROACTIVE_LAUNCH
- `response.ts` - SKILL_ACTION, SKILL_REDIRECT, ERROR
- `action.ts` - JCP action types
- `config.ts` - Skill configuration
- `behaviors.ts` - Supported JCP behaviors
- `analytics.ts` - Analytics event types
**nlu.ts** - NLU interfaces:
- `NLURequestData` - Text, rules, loop users, external agents
- `NLUResult` - Intent, entities, rules
- `ExternalAgentRequest` - External Dialogflow agent config
**asr.ts** - ASR interfaces:
- `ASRResult` - Text, confidence, annotation
- `ASRConfig` - Language, hints, timeouts
**jibo/** - Jibo-specific data:
- `data.ts` - GeneralData (account, robot, language), SkillData (session, trace)
- `runtime.ts` - RuntimeContext (character, location, loop, perception, dialog)
**proactive/** - Proactive interfaces:
- Context field definitions
- History rules
- Settings rules
- Proactive trigger/request/response
**history/** - History interfaces:
- Skill launch data
- Speech history data
## Utils Package
The `utils` package provides shared functionality.
### BaseService (`utils/service/BaseService.ts`)
Base class for all Pegasus services:
**Features:**
- Express.js HTTP server
- WebSocket server (ws library)
- JWT authentication
- Request/response logging with jibo-log
- New Relic monitoring
- Health check endpoint
- Error handling middleware
**Methods:**
- `addSocketHandler(path, handler)` - Register WebSocket handler
- `addHttpHandler(path, handler)` - Register HTTP handler
- `init(port)` - Start server
- `close()` - Stop server
**Authentication:**
- JWT token verification
- Bearer token scheme
- Configurable secret via `ETCO_server_hubTokenSecret`
**Logging:**
- Per-request log instances
- Transaction ID tracking
- Robot ID tracking
- Configurable log levels per namespace
### Other Utils
- `PegasusRequest` - Enhanced Express request with Jibo headers
- `PegasusWebSocket` - Enhanced WebSocket with auth and logging
- `JiboHeaders` - Parses Jibo-specific headers (transID, robotID, logging config)
- `ResponseWrapper` - Wraps WebSocket responses
- `HttpError` - HTTP error with status code
## Communication Protocols
### WebSocket Protocol
**Connection:**
- URL: `ws://hub:9000/listen` or `ws://hub:9000/proactive`
- Authentication: Bearer token in Authorization header
- Headers: `x-jibo-transid`, `x-jibo-robotid`, `x-jibo-logging-config`
**Message Format:**
```json
{
"type": "MESSAGE_TYPE",
"msgID": "uuid",
"ts": 1234567890,
"data": { ... }
}
```
**Listen Flow Messages:**
1. Robot → Hub: LISTEN (with ASR config, rules, language)
2. Robot → Hub: Audio packets (binary)
3. Hub → Robot: SOS (Start of Speech)
4. Robot → Hub: CONTEXT (runtime context)
5. Hub → Robot: EOS (End of Speech)
6. Hub → Robot: LISTEN (with ASR result, NLU result, match)
7. Hub → Robot: SKILL_ACTION (if cloud skill)
8. Robot → Hub: CMD_RESULT (action results)
9. Hub → Robot: SKILL_ACTION (next action) or final
**Proactive Flow Messages:**
1. Robot → Hub: TRIGGER (trigger data)
2. Robot → Hub: CONTEXT (runtime context)
3. Hub → Robot: PROACTIVE (match or no-action)
4. Hub → Robot: SKILL_ACTION (if cloud skill)
### HTTP Protocol
**Skill Request:**
- Method: POST
- URL: `http://skill-host:port/`
- Headers: Authorization, x-jibo-transid, x-jibo-robotid
- Body: SkillRequest JSON
**Parser Request:**
- Method: POST
- URL: `http://parser:8080/v1/parse`
- Body: NLURequestData JSON
## Authentication & Security
### JWT Authentication
**Token Format:**
```json
{
"id": "account-id",
"accessKeyId": "client-id",
"secretAccessKey": "client-secret",
"friendlyId": "robot-name"
}
```
**Verification:**
- Secret: `ETCO_server_hubTokenSecret` environment variable
- Scheme: Bearer
- Applied to WebSocket connections and HTTP endpoints
### Network Security
- All services run in Docker containers
- Services communicate via Docker network (pegasus-nw)
- External access via load balancer
- TLS termination at load balancer
## Deployment
### Docker Compose (Local Development)
**Services:**
- `hub` - Hub service (port 9000)
- `parser` - Parser service (port 9005)
- `history` - History service (port 9006)
- `chitchat-skill` - Chitchat skill (port 9004)
- `report-skill` - Report skill (port 9003)
- `lasso` - Lasso service (port 9007)
- `redis` - Redis cache (port 6379)
- `mongo_lasso` - MongoDB for Lasso (port 27017)
- `history_cluster` - MongoDB for History (from docker-compose-history-db.yml)
**Configuration:**
- Environment variables prefixed with `ETCO_` (ETCO = Environment TO Configuration)
- Volume mounting: `./:/pegasus:consistent` for live code editing
- Debug ports: 5850-5855 for Node.js debugging
### Build Process
**Commands:**
```bash
docker build -t pegasus_base:latest .
yarn docker:bootstrap
yarn docker:build
./pegasus.js build-docker-image --services hub
```
**CLI Tool** (`cli/`):
- `bootstrap` - Install dependencies
- `build` - Build TypeScript
- `test` - Run tests
- `docker-run` - Run commands in Docker
- `build-docker-image` - Build Docker images for services
### Production Deployment
- AWS ECS (Elastic Container Service)
- ECR (Elastic Container Registry) for Docker images
- Application Load Balancer
- MongoDB Atlas for production databases
- ElastiCache for Redis
- CloudWatch for logging
- New Relic for monitoring
## Data Flow Examples
### Example 1: User Says "Tell Me a Joke"
1. **Robot → Hub**: LISTEN message with ASR config
2. **Robot → Hub**: Audio stream
3. **Hub**: Detects SOS, emits SOS message
4. **Hub**: Streams audio to Google Cloud Speech API
5. **Hub**: Detects EOS, emits EOS message
6. **Robot → Hub**: CONTEXT message (runtime state)
7. **Hub → Parser**: POST /v1/parse with text "tell me a joke"
8. **Parser → Dialogflow**: Query with "joke" intent rules
9. **Dialogflow → Parser**: Intent="joke_tell", entities={}
10. **Parser → Hub**: NLU result
11. **Hub → IntentRouter**: Match intent to "joke-skill"
12. **Hub → joke-skill**: POST LISTEN_LAUNCH request
13. **joke-skill**: Executes graph, selects joke
14. **joke-skill → Hub**: SKILL_ACTION with JCP behavior (SayText)
15. **Hub → Robot**: SKILL_ACTION message
16. **Robot**: Executes behavior, speaks joke
17. **Robot → Hub**: CMD_RESULT with action result
18. **Hub → joke-skill**: POST LISTEN_UPDATE request
19. **joke-skill**: Returns final=true
20. **Hub → Robot**: Final SKILL_ACTION
### Example 2: Proactive Greeting
1. **Robot**: Detects person entering room
2. **Robot → Hub**: TRIGGER message with trigger data
3. **Robot → Hub**: CONTEXT message (runtime state)
4. **Hub**: Queries all proactive skill configs
5. **Hub**: Filters by context (time, people present)
6. **Hub**: Filters by history (last greeting time)
7. **Hub**: Filters by settings (user greeting preference)
8. **Hub**: Selects "greeting-skill"
9. **Hub → greeting-skill**: POST PROACTIVE_LAUNCH request
10. **greeting-skill → Hub**: SKILL_ACTION with greeting behavior
11. **Hub → Robot**: PROACTIVE response with match
12. **Hub → Robot**: SKILL_ACTION message
13. **Robot**: Executes greeting
## Error Handling
### Error Types
**Hub Error Codes** (`HubErrorCode.ts`):
- `TIMEOUT_ASR` - ASR timeout
- `TIMEOUT_PARSER` - Parser timeout
- `TIMEOUT_CONTEXT` - Context timeout
- `TIMEOUT_SKILL` - Skill timeout
- `PARSER` - Parser error
- `ASR` - ASR error
**Skill Request Errors** (`SkillRequestError`):
- `SKILL_NOT_FOUND` - Skill does not exist
- `TIMEOUT` - Skill request timeout
### Error Response Format
```json
{
"type": "ERROR",
"msgID": "uuid",
"ts": 1234567890,
"final": true,
"data": {
"message": "Error description",
"code": "ERROR_CODE"
},
"timings": {
"total": 1234
}
}
```
### Timeout Handling
- ASR: 40 seconds (configurable via sosTimeout, maxSpeechTimeout)
- Parser: 10 seconds
- Context: 5 seconds
- Skill: 10 seconds
- Transaction: 60 seconds (configurable)
## Monitoring & Logging
### Logging
**jibo-log Integration:**
- Per-namespace log levels
- Transaction ID correlation
- Robot ID tracking
- Structured logging support
**Log Levels:**
- Configured via `x-jibo-logging-config` header
- Per-namespace granularity
- Environment variable: `ETCO_server_logLevel`
### Monitoring
**New Relic:**
- HTTP request tracking
- WebSocket transaction tracking
- Error tracking
- Custom attributes (transID, robotID)
**Health Checks:**
- `/healthcheck` endpoint on all services
- Returns service-specific health data
- Database connection status
### Speech History Recording
**Optional Features:**
- Record skill launches to MongoDB
- Record speech interactions to MongoDB
- Upload speech logs to S3 (JSON with audio base64)
**Configuration:**
- `ETCO_hub_recordLaunchHistory` - Enable launch history
- `ETCO_hub_recordSpeechHistory` - Enable speech history
- `ETCO_hub_recordSpeechLogBucket` - S3 bucket for speech logs
## Skill Development Guide
### Creating a New Skill
1. **Extend GraphSkill:**
```typescript
export class MySkill extends GraphSkill<Transition> {
constructor() {
super('my-skill');
}
createGraph(): Graph<Transition> {
const g = new Graph('My Skill', generateTransitions<Transition>(Transition));
// Add nodes and transitions
g.finalize();
return g;
}
}
```
2. **Define Transitions:**
```typescript
enum Transition {
Done = 'Done',
Retry = 'Retry'
}
```
3. **Create Nodes:**
```typescript
class MyNode extends Node<Transition> {
async enter(data: Data): Promise<EnterResponse> {
// Return action or redirect
return { action: myJCPAction };
}
async exit(data: Data): Promise<ExitResponse> {
// Return next transition
return { transition: Transition.Done };
}
}
```
4. **Create Skill Manifest:**
```json
{
"id": "my-skill",
"intents": [
{
"name": "my_intent",
"entities": []
}
],
"onRobot": false
}
```
5. **Register with Hub:**
- Add skill config to skills-local.json or environment
- Deploy skill service
- Hub will load configuration
### Skill Best Practices
- Use graph for complex flows, direct responses for simple ones
- Track analytics events for monitoring
- Handle errors gracefully with try-catch
- Use supplemental behaviors for parallel actions
- Set appropriate timeouts
- Log important events
- Test with both LISTEN_LAUNCH and PROACTIVE_LAUNCH
## Key Design Decisions
### Why Graph-Based Skills?
- **State Management**: Explicit state machine with session tracking
- **Visualization**: GraphViz generation for debugging
- **Reusability**: Subgraphs for common patterns
- **Testability**: Isolated node testing
- **Maintainability**: Clear flow structure
### Why WebSocket for Robot Communication?
- **Low Latency**: Real-time bidirectional communication
- **Audio Streaming**: Binary message support for audio
- **Stateful**: Single connection per transaction
- **Efficiency**: No HTTP overhead for each message
### Why Separate Services?
- **Scalability**: Scale each service independently
- **Isolation**: Failure in one service doesn't affect others
- **Technology**: Different services can use different tech stacks
- **Deployment**: Independent deployment cycles
### Why Lerna Monorepo?
- **Code Sharing**: Easy to share interfaces and utils
- **Versioning**: Linked versioning for interdependent packages
- **Development**: Single repository for all services
- **Testing**: Integration tests across packages
## Limitations & Known Issues
1. **Single Graph Manager**: Skills cannot have concurrent sessions (singleton pattern)
2. **Sequential Skill Redirects**: Only one level of redirect supported
3. **No Skill-to-Skill Communication**: Skills must go through hub
4. **Fixed Timeouts**: Hardcoded timeouts in some places
5. **No Skill Hot-Reload**: Requires container rebuild for skill changes
6. **Limited NLU**: Dialogflow dependency, no custom model training
7. **No Skill Versioning**: Skills identified by ID only
8. **Synchronous Skill Requests**: Hub waits for skill response (no async)
## Future Considerations
1. **Skill Versioning**: Support multiple versions of same skill
2. **Skill-to-Skill Direct Communication**: Allow skills to call each other
3. **Async Skill Responses**: Long-running skills with callback pattern
4. **Custom NLU Models**: Support for custom trained models
5. **Skill Hot-Reload**: Dynamic skill loading without restart
6. **Multi-Session Skills**: Support concurrent skill sessions
7. **Skill Marketplace**: Third-party skill distribution
8. **A/B Testing**: Framework for testing skill variations
## Conclusion
The original Jibo server (Pegasus) is a well-architected microservices system that provides a robust foundation for conversational AI on the Jibo robot. The graph-based skill framework offers flexibility and maintainability, while the separation of concerns enables independent scaling and development. The system successfully handles real-time speech processing, natural language understanding, skill routing, and proactive behaviors in a distributed cloud environment.

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

@@ -194,7 +194,7 @@ These are not blockers for calling `1.0.18` complete unless the live test shows
- local `whisper.cpp` STT remains a discovery seam, not production ASR
- media upload/body handling is not binary-safe enough for final gallery originals and thumbnails
- state persistence is local JSON, not Azure SQL / Blob Storage
- update, backup, and restore are not end-to-end proven, and the `jibo test 22` / Test 26 / Test 27 / Test 28 sluggishness appears tied to robot-local backup status/load, startup reconnect state, or previously unsuppressed end-of-skill surprises; Test 31 also captured a legacy `Backup_20170222.List` startup query, which reinforces that the local backup/status path is real even before a user asks for backup
- update, backup, and restore are now end-to-end proven at the persistence-rehydration level, and the `jibo test 22` / Test 26 / Test 27 / Test 28 sluggishness appears tied to robot-local backup status/load, startup reconnect state, or previously unsuppressed end-of-skill surprises; Test 31 also captured a legacy `Backup_20170222.List` startup query, which reinforces that the local backup/status path is real even before a user asks for backup
- Tests 27 and 28 showed backup/surprise behavior without corresponding `Backup_*` HTTP traffic; Test 28 isolated the unsuppressed `@be/surprises` lifecycle handoff after Nimbus
- deployed-build verification needs to prove that synthetic OpenJibo websocket events are gone from the hosted artifact, not just from source
- news content is synthetic; `jibo test 23` proved the path but not live provider-backed headlines

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`
- `jibo test 22` validated radio, exposed backup/load interference, exposed a shared yes/no no-input gap, exposed repeated create keeper prompts after photo handoff, and showed local whisper `ffmpeg` failures on unusable buffered audio
- `jibo test 23` validated basic news, proved one alarm set/fire path at `7:43 AM`, exposed comma-separated/short alarm follow-up parsing risk, showed stock alarm replacement yes/no rules that needed cloud handling, and showed photo gallery still failing when `shared/yes_no` ASR came back empty
- personal report parity now has loop-scoped calendar and commute provider seams that merge persisted loop events, birthday/holiday dates, and commute profiles; the remaining report gap is richer travel-time data, not missing structure
- `jibo test 24` showed alarm replacement yes/no working, but exposed empty `clock/alarm_set_value` and `gallery/gallery_preview` turns falling into generic `I heard you` fallback speech; it also showed `CLIENT_NLU cancel` inside `clock/alarm_set_value` re-asking for an alarm value instead of closing the prompt
- `jibo test 25` proved a broader regression path but exposed repeated backup-in-progress/update-menu blockage, timer/alarm stale state and delete/menu disagreement, gallery `shared/yes_no` hangs under `@be/gallery`, punctuated `Never mind.` falling through to chat, volume homophone parsing (`Set Volume 2-6.`), and settings volume-control cleanup falling into `I heard you`
- `jibo test 26` live-proved punctuated stop, volume homophone parsing, gallery launch/yes/create/save, and good morning; it still exposed robot-local backup warnings, long blue-ring buffering without a fresh `LISTEN`, alarm replacement drifting into the value/manual screen, and alarm delete phrases/mishears falling to chat
@@ -435,7 +436,7 @@ Current release theme:
### 9. STT Upgrade And Noise Screening
- Status: `ready`
- Status: `in progress`
- Tags: `stt`
- Why next:
- feature paths are now often correct when a transcript exists, but short replies and low-quality audio still block otherwise-correct flows
@@ -447,6 +448,10 @@ Current release theme:
- `jibo test 26` had long no-`LISTEN` binary buffering and alarm-delete mishears now patched; remaining short-answer failures still need STT/noise work
- current source now skips local whisper when buffered audio does not contain an Opus identification header
- yes/no and alarm flows are especially sensitive to short or collapsed transcripts
- Progress update (`2026-05-21`):
- added a small local whisper noise floor so obviously tiny buffered audio can be screened before ffmpeg/whisper work runs
- short/noisy buffered turns now fail fast instead of wasting a transcription cycle
- focused tests now cover the new low-audio rejection behavior
- Implementation notes:
- add lightweight waveform or energy screening before transcription
- compare managed STT against the local toolchain
@@ -461,11 +466,12 @@ Current release theme:
- Implementation notes:
- define local capture sinks versus hosted retention
- decide how testers submit noteworthy sessions
- keep a lightweight `capture-index.ndjson` manifest beside raw captures so testers can quickly find sessions, operations, and fixture exports
- preserve sanitized fixtures as the durable parity artifact
### 11. Binary-Safe Media Storage
- Status: `ready`
- Status: `in progress`
- Tags: `storage`, `protocol`
- Why next:
- the first gallery bridge stores metadata and text-body placeholders, but final gallery support needs originals and thumbnails
@@ -473,6 +479,9 @@ Current release theme:
- whether stock gallery expects originals, thumbnails, or both
- what upload metadata must survive for gallery refresh
- how to map this cleanly to Blob Storage
- Implementation notes:
- media content now flows through a storage seam with file and Azure Blob adapters
- the protocol still serves the legacy text-body contract, but the original payload is now persisted separately and can be swapped to binary-native storage later
### Next Up (`2026-05-06`): Dialog Parsing Expansion And Ambiguity Guardrails
@@ -494,6 +503,9 @@ Current release theme:
- shorthand favorites (`my favorite sport football`)
- weather phrasing (`what's today's weather look like`, `will it be sunny tomorrow`)
- updated continuation deferral so complete shorthand favorites finalize instead of waiting for missing continuation
- Progress update (`2026-05-21`):
- expanded friendship parsing for Pegasus-style `do you have friends`, `are we friends`, and `are we best friends` phrasing
- added named-person guardrails so forms like `are you friends with Siri` and `is Dr. Breazeal your best friend` stay on the friendship route instead of falling into generic chat
- 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
@@ -506,7 +518,7 @@ Current release theme:
### 12. Weather As Cloud Report Plus Local Presentation
- Status: `discovery`
- Status: `implemented`
- Tags: `protocol`, `content`
- Evidence:
- Nimbus and Pegasus contain personal-report weather assets and Lasso provider hooks
@@ -602,6 +614,8 @@ Current release theme:
- recognition, enrollment, rename, and profile-correction boundaries
- split between local state and hosted cloud state
- first useful hosted identity slice
- live QA has shown person-identification collisions in the same loop (for example, a parent and child both getting normalized to the same remembered name)
- person-identification correction likely needs its own repair pass before we can trust greetings, reports, and presence triggers in mixed-household scenarios
### 20. Onboarding, Loop Management, And Fresh Start
@@ -626,9 +640,12 @@ Current release theme:
- `make a pizza` now ports the original scripted-response path through `chitchat-skill` with `mim_id = RA_JBO_MakePizza` and pizza-making animation ESML
- `can you order pizza` now ports the original scripted-response path through `chitchat-skill` with `mim_id = RA_JBO_OrderPizza`
- current source answers these with a `1.0.19` rule-based persona baseline, backed by `OpenJiboCloudBuildInfo.PersonaBirthday`
- `how old are you` now also uses the imported Build B age prompts so the first-powered-up and birthday phrasing stays source-backed
- Follow-up:
- wire persona age to first-powered-up or durable first-cloud-seen metadata when available
- add command-vs-question variants so expressive prompts can answer conversationally before launching actions
- live QA has shown motion/sleep quirks too: `turn around` can become a no-op and `go to sleep` can fail at the last step before the sleep animation fully completes
- reply-selection polish still needs attention on a couple of identity prompts where short variants are over-selected (`how are you`, `what is your favorite flower`)
### 22. Command Vs Question Reply Style
@@ -654,6 +671,8 @@ Current release theme:
- Follow-up:
- add durable persistence path for personal facts
- broaden fact categories further (multi-person household memory, relationship cues, and corrective updates)
- add explicit person-scoped state so future interactions can distinguish household members inside the same loop
- define the first server-to-server sync envelope for durable state before we need it in production
### 24. Memory-Triggered Proactivity Baseline
@@ -669,6 +688,7 @@ Current release theme:
- expand proactivity beyond pizza to additional Pegasus-backed categories
- add cooldown/throttle policy and observability around proactive offer frequency
- connect memory store to durable multi-tenant persistence
- keep the sync story visible so stateful offers can survive a multi-server deployment later
### 25. Weather Report-Skill Launch Compatibility
@@ -687,7 +707,7 @@ Current release theme:
### 26. Presence-Aware Greetings And Identity Proactivity
- Status: `ready`
- Status: `in_progress`
- Tags: `protocol`, `content`, `storage`, `docs`
- Why now:
- this is the next personality-charm expansion after parser guardrail and weather bring-up
@@ -704,6 +724,13 @@ Current release theme:
- add greeting intent families and state-machine split for reactive vs proactive greeting routes
- add cooldown and trigger-source guardrails for proactive greetings
- start person-aware greeting hooks (name-aware greeting, morning greeting policy, return greeting policy)
- Shipped so far:
- durable greeting-presence records now persist last-seen and last-greeted per person/loop
- proactive greeting gating now consults cloud greeting history when available
- reactive and proactive greeting turns write back greeting-history records for later cooldown checks
- birthday-aware proactive greetings now use stored birthday memory on matching dates
- holiday-aware proactive greetings now use loop holiday records on matching dates
- morning proactive greetings now stay distinct from return-visit greetings
- Exit criteria:
- presence-aware greetings are routed deterministically with tests
- proactive greetings are frequency-bounded and do not trigger from surprise source when blocked by policy
@@ -715,7 +742,7 @@ Current release theme:
### 27. Personal Report Parity Track (Weather/News/Commute/Calendar)
- Status: `ready`
- Status: `in_progress`
- Tags: `protocol`, `content`, `storage`, `docs`
- Why now:
- personal report is a core Jibo charm surface and currently split between implemented weather speech and placeholder calendar/commute/news content
@@ -724,8 +751,18 @@ Current release theme:
- weather icon/animation parity and view support
- broader non-local weather query handling and short-range date coverage
- provider-backed news ingestion and filtering
- commute provider path and settings schema
- commute provider path, settings schema, and loop-scoped commute profile storage
- coverage matrix for personal report parity gaps and test/capture exit criteria
- Progress update (`2026-05-10`):
- added provider-ready news briefing lane with Nimbus-compatible `news` skill payload continuity
- added memory/transcript category hint plumbing for provider requests (sports/technology/business/general)
- fallback synthetic news behavior remains active when no provider key is configured
- added TTL caching for weather/news provider calls to reduce repeated external requests
- vendored Pegasus `report-skill` templates for weather and personal-report phrasing so the next pass can focus on renderer coverage for calendar, commute, and news templates instead of rediscovering source text
- commute now has a loop-scoped provider seam plus persisted commute profiles, so the next pass can focus on richer travel-time data instead of basic storage shape
- Progress update (`2026-05-21`):
- weather payloads now distinguish current-vs-weekly view modes so renderer parity can key off the payload shape more cleanly
- news provider now skips summaryless correction headlines before falling back to broader sources
- Source anchors:
- `C:\Projects\jibo\pegasus\packages\report-skill\src\subskills\weather\WeatherMimLogic.ts`
- `C:\Projects\jibo\pegasus\packages\report-skill\resources\views\weatherHiLo.json`
@@ -738,7 +775,7 @@ Current release theme:
### 28. Grocery List Capability (Requested Feature)
- Status: `discovery`
- Status: `in_progress`
- Tags: `content`, `docs`, `storage`
- Why now:
- directly requested by Jibo owners and fits memory + household utility roadmap
@@ -747,13 +784,173 @@ Current release theme:
- examples:
- `C:\Projects\jibo\pegasus\packages\chitchat-skill\mims\scripted-responses\RA_JBO_ShoppingList.mim`
- `C:\Projects\jibo\pegasus\packages\chitchat-skill\mims\scripted-responses\RA_JBO_ManageToDoList.mim`
- Candidate delivery paths:
- native lightweight list skill (fastest user value)
- integration-backed list orchestration (long-term richer ecosystem fit)
- MVP decision:
- use the existing household list engine as the native lightweight grocery MVP
- keep grocery as a first-class spoken alias over the shopping list storage path
- reserve integration-backed list orchestration for a later discovery pass
- Exit criteria:
- clear decision on MVP path
- first schema for list items + ownership scope
- initial voice flows and follow-up intent handling defined
- grocery prompts, add/recall/done flows, and list follow-ups consistently speak grocery wording
- existing shopping/to-do flows remain unchanged
- future integration-backed list work remains a separate backlog item
### 29. Legacy MIM Personality Import Ladder
- Status: `in_progress`
- Tags: `content`, `protocol`, `docs`
- Why now:
- we already have a chitchat/content scaffold that can render stock-compatible personality replies
- the legacy `chitchat-mims` tree is mostly declarative content, so a phased import can add visible charm fast
- this is the best near-term path to get Jibo feeling more interactive without needing a full Pegasus runtime clone
- What is possible today:
- direct scripted replies through the existing content catalog
- stock-compatible payloads with `skillId`, `mim_id`, `mim_type`, `prompt_id`, and ESML
- current examples already prove the shape for pizza, dance, weather, news, and generic chat
- What we need to build:
1. a MIM inventory importer that can scan the legacy tree and normalize `skill_id`, `mim_id`, prompt text, and metadata
2. a prompt-selection layer that can choose by category and condition metadata
3. a safe ESML/prompt renderer for imported content
- What can be ported with each build:
- Build A: declarative prompt packs
- `core-responses`
- `deflector`
- the simplest `emotion-responses`
- direct `scripted-responses` that are just prompt lists
- Build B: conditioned prompt packs
- `gqa-responses`
- structured emotion prompts with `condition` gates
- any response families that only need simple state or Jibo-emotion checks
- Build C: conversation families
- richer `scripted-responses` that need follow-up state
- holiday / special-date personality sets
- more nuanced chitchat branches that depend on context-aware routing
- Build D: full parity cleanup
- larger cross-skill collections
- any MIMs that depend on Pegasus-only parser assumptions
- any files that need dedicated runtime abstraction instead of catalog lookup
- Low-hanging fruit for tonight:
- import the smallest declarative packs first so we can test something tomorrow
- prioritize anything that is pure prompt text with no complex branching
- keep the first pass limited to content that maps cleanly onto the current catalog shape
- Progress update (`2026-05-13`):
- added the first Build A importer scaffold in the cloud content repository
- checked in a small seed bundle under `Content/LegacyMims/BuildA`
- added focused importer tests for prompt stripping, bucketing, and merge behavior
- expanded Build A with additional easy scripted-response packs for identity and persona replies
- started Build B with source-backed scripted-response packs for work, food, home, birthplace, language, hobby, and material questions
- Tomorrow test target:
- verify imported personality replies show up through the existing chitchat route
- confirm the emitted payload still looks like a stock skill response
- confirm the imported content does not disturb existing weather/news/pizza flows
- Exit criteria:
- a first importer path exists for the simplest legacy MIM files
- at least one legacy prompt pack is running through OpenJibo content instead of hand-authored fallback text
- we have a clear second-wave list for the more conditional MIM families
### 30. Original Personalized Function Inventory
- Status: `discovery`
- Tags: `content`, `docs`, `protocol`
- Why now:
- we are actively porting persona and memory slices, so we need a bounded checklist of the original Jibo charm surfaces
- the goal is to keep the next few passes focused on personality-rich wins instead of letting the work sprawl
- Known sources:
- legacy Jibo OS/Pegasus chitchat and MIM response families
- current OpenJibo persona, memory, and greeting work as the implementation target
- Inventory to track:
- identity and origin questions
- personality and capability questions
- favorite-style prompts like `what is your favorite color`
- identity charm prompts like `what's your name`, `do you have a nickname`, `do you like being Jibo`, `are there others like you`, and `what is your favorite name`
- attraction and preference prompts like `what is your favorite flower`, `do you like R2D2`, `do you like the sun`, `do you like space`, and `do you like kids`
- longer authored variants for the same prompt family when Pegasus shows richer phrasing
- charm/capability prompts like `can you laugh`, `can you dance`, `can you sing`, and `will you sing`
- mood / affect questions
- recognition follow-ups like `do you know me`
- follow-up state prompts that should stay warm and locally grounded
- Next pass targets:
- document the remaining persona inventory so we keep a clean checklist for the next passes
- keep the favorites family moving with source-backed imports where available, and temporary runtime replies only when the source is missing
- keep adding small sourced personality batches, especially the legacy `R2D2`, `sun`, `space`, `kids`, and charm prompts
- keep adding 1-3 persona prompts per pass with tests
- prefer source-backed MIM imports when the legacy text is available, and use a temporary runtime reply only when needed to unblock user value
- keep a separate note for longer authored variants so we do not lose the multi-clause Peggy-style phrasing while importing the short-form packs
- Mood follow-up work in flight:
- source-backed happy/sad/angry response packs are now part of Build B
- small-talk aliases like `what are you up to` and `how are things` now stay on the emotion-query path
- Descriptor charm work in flight:
- source-backed `are you kind`, `are you funny`, `are you helpful`, `are you curious`, `are you loyal`, `are you mischievous`, and `are you likable` prompts are now in Build B
- these keep the self-description lane warm while we build toward seasonal and holiday charm
- Seasonal charm work in flight:
- source-backed holiday, New Year's, Halloween, spring, summer, favorite-season, and gift prompts are now part of Build B
- `RN_` holiday greeting files are now bucketed as greetings so seasonal replies stay visible in the catalog
- birthday celebration lines are now bucketed separately, and birthday memory writes a loop-scoped holiday record so personal dates can join the holiday list later
- holiday extras now include `show santa tracker` so the Christmas-time launcher keeps its source-backed animation line
- the remaining seasonal polish now includes `do you like halloween`, `do you like holiday music`, `do you like holiday parties`, `are you looking forward to christmas`, `what are you doing for christmas`, and `what are you thankful for`
- Favorite-animal work in flight:
- the favorites family now includes `what is your favorite animal`, `what is your favorite bird`, `do you like penguins`, and `do you like animals` so the penguin-centric replies stay easy to find
- Presence and thought follow-ups in flight:
- `welcome back`, `what are you thinking`, `what have you been doing`, and `what did you do` are now part of Build B
- these keep the social surface lively while the memory and multitenant tracks keep advancing in parallel
- Next queued persona surfaces:
- richer identity follow-ups like `who is this`, `do you know me`, `do you remember me`, and `can you recognize me`
- mood and affect prompts like `how are you`, `are you happy`, `are you sad`, and `are you angry`
- self-description charm like `what's your name`, `do you have a nickname`, `do you like being Jibo`, and `what is your favorite name`
- deeper personality follow-ups like `what do you dream about`, `what are you afraid of`, `what do you want to talk about`, `what is your best book`, `what is your best exercise`, `what is your dream vacation`, `who is your hero`, `who do you love`, and `what is your religion`; `what is your sign` stays deferred until templated placeholder rendering exists
- the next identity / knowledge wave adds `are you god`, `are you here`, `do you have super powers`, `how much do you know`, `what does jibo mean`, `where do you get info`, `what are you forbidden to do`, `what color are you`, and `what do you do when alone`
- additional legacy source-backed `RI_USR` prompts where the text is short and the behavior is easy to verify
- templated edge cases like `what is your sign`, `how many people do you know`, and `what is the loop` where live birthday and loop state are part of the line instead of a plain canned response
- Exit criteria:
- a stable checklist exists for the original persona surface
- each pass can be scoped to a small batch of prompts
- the backlog makes it obvious what is still missing without losing momentum
### 31. Longer Authored Persona Variants
- Status: `ready`
- Tags: `content`, `docs`, `protocol`
- Why now:
- Pegasus often used longer, multi-clause authored alternatives for the same personality question
- we already have the short-path import working, so this is a low-risk way to add richer phrasing without inventing a new dialog engine
- it gives us a straightforward next pass that stays familiar to the original robot
- Scope:
- import the longer authored variants already present in the legacy MIMs
- prefer richer phrasing for favorite-style, identity, and charm prompts when the source text provides it
- keep the runtime behavior rule-based and deterministic
- Next step:
- add a small batch of longer variants to the current Build B content packs and prove them with a smoke test
### 32. Dialog Joining And Composition
- Status: `discovery`
- Tags: `content`, `docs`, `protocol`
- Why now:
- the videos and source files suggest Jibo sometimes felt like he was joining thoughts together, even when the source text was still authored
- we have not found evidence of a general runtime joiner yet, so this remains a post-release enhancement instead of a 1.0.19 dependency
- keeping it separate lets us preserve familiar Jibo phrasing now and experiment with composition later
- Scope:
- design a post-release dialog composition layer that can stitch authored fragments together when appropriate
- keep the first version conservative and familiar, not LLM-driven
- make sure any future joining feature is opt-in and does not replace the current authored prompt path
- Follow-up:
- revisit after 1.0.19 personality import and report-skill parity stabilize
- decide whether the composition layer should sit above the prompt catalog or beside it as a dedicated response post-processor
- keep this separate from the authored-variant backlog item so we do not blur prompt richness with runtime composition
### 33. Singing And Musical Personality
- Status: `discovery`
- Tags: `content`, `docs`, `protocol`
- Why now:
- Jibos charm surface includes musical and sing-along behavior, and it fits naturally after the current personality and holiday batches
- the first pass should stay familiar and rule-based, not LLM-driven
- Scope:
- inventory the legacy song / sing / musical prompt families
- keep the first implementation source-backed if Pegasus has usable authored lines
- preserve room for a later sing-along launcher if we want one
- Exit criteria:
- a small song backlog exists with candidate phrases listed
- the release plan has a clear place for musical personality without crowding out weather/news/report work
- the current source-backed singing slice is implemented and test-covered
## Suggested Order
@@ -773,18 +970,30 @@ 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 - in progress (`2026-05-07` first guardrail slice implemented)
6. Presence-aware greetings and identity-triggered proactivity - ready
7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - ready
5. Dialog parsing expansion and ambiguity guardrails - in progress (`2026-05-09` third guardrail slice implemented; Pegasus affinity phrase families + continuation guardrails expanded)
6. Presence-aware greetings and identity-triggered proactivity - implemented (trigger path, identity-aware reactive/proactive replies, cooldown metadata wiring, focused websocket coverage)
7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - in progress (`2026-05-10` first live-news provider slice implemented; commute now has a loop-scoped provider seam)
8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
- system holidays should come from an up-to-date provider and merge with loop-scoped custom holiday records
- allow disabled holiday records to suppress reminders for people who do not celebrate a holiday
- birthdays and other personal dates should flow into the same loop-scoped holiday list once authoring is wired up
9. Durable memory persistence path (multi-tenant backing store)
10. Update, backup, and restore proof
- reference design captured in `docs/persistence-architecture.md`
- store contracts are now tightened around account/loop/device/person scoping, revision tracking, and explicit load/save boundaries
- the backend seam is now selectable, with file-backed local persistence as default and an Azure Blob Storage slot wired for future deployment when a storage account connection string is available
- next implementation pass should supply the real Azure Storage connection string / deployment wiring and validate the live round-trip in the storage account smoke test
10. Update, backup, and restore proof - implemented (update creation and backup creation now survive persisted reloads; restore is the persisted-state rehydration proof path, not a new cloud API)
11. STT upgrade and noise screening
- progress update (`2026-05-21`): added a low-signal short-turn screen in websocket finalization so filler-only fragments and stray single-token leftovers like `so command` get rejected before they can become bad turns, while preserving the existing yes/no and word-of-the-day short-turn flows
12. Hosted capture/storage plan / indexing for group testing
- progress update (`2026-05-21`): added a bundle helper so group testers can package raw capture trees, `capture-index.ndjson`, and exported fixtures into one zip handoff artifact
13. Binary-safe media storage / sync to cloud drive: OneDrive, Google Drive, Box, etc.
14. Provider-backed news and weather parity polish
15. Grocery list capability discovery and MVP selection
16. Lasso, identity, and onboarding as larger discovery-driven tracks
17. Legacy MIM personality import ladder and first declarative prompt packs
18. Longer authored persona variants for the same prompt families
19. Dialog joining/composition as a post-release enhancement, kept separate from the 1.0.19 ladder
For `1.0.20` and beyond:

View File

@@ -59,6 +59,16 @@ Main gap:
- no first-class presence/identity perception extraction from runtime context for greeting policy decisions
Current implementation progress:
- runtime presence parsing now extracts speaker, people-present ids, and loop user first names
- reactive and proactive greeting turns now write durable greeting-presence history into cloud state
- proactive greeting gating now consults stored greeting history first, then falls back to the current turn metadata
- birthday-aware proactive greetings now use the loop/person birthday memory when the current date matches
- holiday-aware proactive greetings now use the loop holiday calendar when the current date matches
- morning proactive greetings now stay distinct from return-visit greetings so a fresh start of day still sounds like a morning greeting
- the remaining work is to broaden the presence policy surface so it can grow into richer day-part and return-visit variations without reworking the storage seam again
## Implementation Slices
### Slice G1: Presence Context Extraction And Session Snapshot

View File

@@ -0,0 +1,23 @@
# Holiday Architecture
Pegasus exposed holidays as a loop-scoped list synchronized into `/jibo/holidays`.
In OpenJibo, the holiday path now follows the same broad model:
- system holidays come from a live holiday source
- custom holidays are loop-scoped
- suppressed holidays are represented as disabled records
- the cloud protocol returns the merged list for `PersonListHolidays`
Current behavior:
- `Person/ListHolidays` uses the loop from the request when available
- if no loop is supplied, the cloud falls back safely instead of throwing
- the merged list is built from system holidays plus any custom loop entries
Notes:
- `IsEnabled = false` can be used to suppress a holiday later
- birthdays and other personal events can be added as loop-scoped custom records
- the current system holiday source uses Nager.Date with a safe local fallback for uptime
- birthday memory authoring now upserts a holiday record so the same merged list can later drive celebration and reminder behavior

View File

@@ -41,6 +41,7 @@ The `.NET` cloud now supports structured live capture intended for first robot r
- HTTP request/response event streams written as NDJSON
- websocket event streams written as NDJSON
- per-session websocket fixture export for replay
- a small `capture-index.ndjson` manifest beside the raw files so group testers can quickly find the session type, operation, and export artifacts
- turn metadata including `transID`, buffered audio counts, finalize attempts, and reply types
Default capture location:
@@ -54,6 +55,7 @@ Artifacts:
- `websocket/*.events.ndjson`
- `*.events.ndjson`
- `websocket/fixtures/*.flow.json`
- `capture-index.ndjson`
## Suggested First Hookup Plan
@@ -61,8 +63,9 @@ Artifacts:
2. Confirm HTTP bootstrap and websocket acceptance with the existing smoke/routing helpers.
3. Run one or two controlled listen turns with Jibo.
4. Inspect the captured HTTP and websocket events plus exported websocket fixtures.
5. Convert the best captures into sanitized checked-in fixtures and tests.
6. Keep Node available to compare any surprising turn behavior before changing infrastructure.
5. Use `capture-index.ndjson` to quickly locate the important sessions and exported fixtures.
6. Convert the best captures into sanitized checked-in fixtures and tests.
7. Keep Node available to compare any surprising turn behavior before changing infrastructure.
Useful helper scripts:
@@ -74,3 +77,18 @@ Useful helper scripts:
- [scripts/cloud/get-websocket-capture-summary.sh](/OpenJibo/scripts/cloud/get-websocket-capture-summary.sh)
- [scripts/cloud/import-websocket-capture-fixture.py](/OpenJibo/scripts/cloud/import-websocket-capture-fixture.py)
- [live-jibo-test-runbook.md](/OpenJibo/docs/live-jibo-test-runbook.md)
## Group Testing Handoff
When you have a useful capture set and want to share it with another tester, bundle the capture root into a single zip so the raw events, capture index, and exported fixtures stay together.
Recommended helper:
- [scripts/cloud/New-CaptureBundle.ps1](/OpenJibo/scripts/cloud/New-CaptureBundle.ps1)
The bundle includes:
- `capture-index.ndjson`
- websocket and HTTP `*.events.ndjson` files
- exported `*.flow.json` fixtures
- a small `bundle-manifest.json` with file counts and source metadata

View File

@@ -0,0 +1,136 @@
# Persistence Architecture
## Goal
Keep OpenJibo's stateful behavior portable now and Azure-ready later.
The current in-memory stores are fine as the default implementation, but the app should depend on stable persistence contracts rather than directly on in-memory collections or file formats.
## Design Principles
- Application code talks to small, intent-specific interfaces.
- Persistence keys are always scoped by tenant and person where relevant.
- In-memory, local JSON, and hosted Azure stores are adapters, not behavior sources.
- Long-lived data should be versioned so we can add optimistic concurrency later.
- Ephemeral turn/session state should stay separate from durable user and device state.
## Current Seams
These are the contracts we should preserve:
- `IPersonalMemoryStore`
- personal facts: names, birthdays, preferences, affinities, important dates, household lists
- scope: account + loop + device + optional person
- `ICloudStateStore`
- account, robot, loops, people, sessions, updates, media, backups, holidays, keys
- scope: system-level state with loop/device/person records inside it
- `IJiboExperienceContentRepository`
- catalog/content layer only
## Recommended Storage Split
### 1. Identity and topology store
Responsible for:
- account profile
- robot/device registration
- loop membership
- person records
- greeting/proactive presence metadata when it becomes durable
This is the seam most likely to become Azure SQL or Cosmos later.
### 2. Personal memory store
Responsible for:
- names
- birthdays
- preferences
- affinities
- important dates
- household lists
This can remain in memory now and later move to a durable store keyed by account/loop/device/person.
### 3. Session and short-lived orchestration state
Responsible for:
- websocket/session tokens
- temporary skill state
- active report/list/greeting interaction state
This can stay in-process for now, but should be clearly separated from durable memory.
### 4. Media and backup store
Responsible for:
- uploaded media metadata
- backup manifests
- binary references
This is a good candidate for Azure Blob Storage plus a metadata table later.
## Record Shape Guidance
For durable records, prefer a small shared envelope:
- `AccountId`
- `LoopId`
- `DeviceId`
- `PersonId` when relevant
- `RecordType`
- `RecordKey`
- `Value`
- `CreatedUtc`
- `UpdatedUtc`
- `Revision` or `ETag`
That gives us:
- easy partitioning later
- clear tenant boundaries
- room for concurrency checks
- a path to Azure Table, Cosmos, or SQL without changing behavior code
## Adapter Plan
### Phase 1
- keep `InMemoryPersonalMemoryStore`
- keep `InMemoryCloudStateStore`
- make sure all callers use the interfaces only
- add tests against behavior, not implementation details
### Phase 2
- introduce durable adapters behind the same interfaces
- likely split:
- SQL or Cosmos for identity/topology
- Blob or table-backed store for media/backup metadata
- table/SQL-backed memory store for personal facts
### Phase 3
- add replication/sync primitives if we need multi-server state convergence
- prefer explicit change records or versioned snapshots over hidden shared state
## Non-Goals For Now
- no Azure SDK types in application logic
- no event-sourcing rewrite
- no giant generic repository
- no distributed transaction work before single-node semantics are stable
## Immediate Next Step
Before building durable adapters, tighten the store contracts around:
- tenant/person scoping
- record versioning
- explicit load/save operations for durable state
That lets us swap the backing store later without changing the personality, report, greeting, or list behaviors already built on top.

View File

@@ -9,6 +9,7 @@ Stand up a small public site on `openjibo.com` that makes the project understand
- project overview
- current status
- links to source repositories
- roadmap / long-range plan
- links to device bootstrap docs
- explanation of the hosted-cloud direction
- contribution/contact or waitlist path

View File

@@ -6,6 +6,8 @@ This release starts the shift from `1.0.18` hardening to visible feature growth.
The goal is to keep compatibility work steady while shipping personality and capability slices that make OpenJibo feel less like a placeholder cloud and more like a real assistant platform.
For grocery list capability, the 1.0.19 MVP choice is the existing household list engine with grocery as a first-class spoken alias. That keeps the storage model simple now while leaving integration-backed list orchestration for a later pass.
## Snapshot
- Kickoff date: `2026-05-05`
@@ -20,29 +22,120 @@ The goal is to keep compatibility work steady while shipping personality and cap
- start building reusable content hooks for question-vs-command style responses
- keep first implementation rule-based and test-backed
### 1a. Original Personalized Function Inventory
Keep a running checklist of the legacy persona questions and identity surfaces we want to preserve or port:
- identity and origin: `what are you`, `who are you`, `what is Jibo`, `who made you`, `where are you from`
- persona and capability: `do you have a personality`, `what is your job`, `how much do you know`, `what do you want`
- self-description and social charm: `what's your name`, `do you have a nickname`, `do you like being Jibo`, `are there others like you`
- favorite-style prompts: `what is your favorite color`, `what is your favorite food`, `what is your favorite music`
- attraction and preference prompts: `what is your favorite flower`, `do you like R2D2`, `do you like the sun`, `do you like space`, `do you like kids`
- longer authored variants for the same prompt family when Pegasus shows richer phrasing, especially multi-clause and follow-up-heavy responses
- capability and charm prompts: `can you laugh`, `can you dance`, `can you sing`, `will you sing`
- affect and mood: `how are you`, `are you happy`, `are you sad`, `are you angry`
- memory and identity recall: `who am i`, `what is my name`, `when is my birthday`, `what is my favorite music`
- greeting and presence charm: `good morning`, `welcome back`, `who is this`, person-aware greeting follow-ups
- recognition follow-ups: `do you know me`, `do you remember me`, `can you recognize me`
- seasonal and contextual charm: holiday prompts, pizza day, surprise offers, personal report personality hooks
- conversational follow-ups that should stay local and warm instead of falling into generic chat
Current batch note:
- `favorite color`, `favorite food`, and `favorite music` are the first small favorites-family slice
- the latest pass adds longer authored variants for those favorites so the replies keep more of the original Pegasus cadence instead of collapsing to short placeholders
- singing and musical personality now has a source-backed first slice with `can you sing`, `will you sing`, and holiday sing variants so the charm surface can keep growing without inventing a new dialog engine
- the friendship batch now includes `do you have friends`, `are we friends`, and `are we best friends` responses, plus the loop-friendly friend replies, so the relationship lane can stay source-backed too
- the parser guardrail pass now expands those friendship routes to named-person forms like `are you friends with Siri` and `is Dr. Breazeal your best friend`, keeping the ambiguity layer closer to Pegasus utterance shapes
- the next source-backed batch now includes `favorite flower`, `R2D2`, `sun`, `space`, `kids`, plus a couple of charm prompts like `can you laugh` and `can you dance`
- the motion/sleep batch now adds `RI_JBO_CanSleep` and `RA_JBO_SpinAround` so the `go to sleep` and `turn around` surfaces stay source-backed too
- the follow-up mood batch now includes `how are things`, `how is your day`, `are you sad`, and `are you angry`
- the personality follow-up batch now includes `what are you up to` and `what are you doing` so small talk stays warm and local instead of falling into generic chat
- the descriptor batch now includes `are you kind`, `are you funny`, `are you helpful`, `are you curious`, `are you loyal`, `are you mischievous`, and `are you likable`
- the seasonal batch now includes `what holidays do you celebrate`, New Year's resolution questions, `happy holidays`, `what halloween costume`, spring and summer suggestions, a favorite-season prompt, and holiday gift prompts
- the holiday extras batch now includes `show santa tracker` so the seasonal holiday launcher stays source-backed too
- the remaining seasonal polish now includes `do you like halloween`, `do you like holiday music`, `do you like holiday parties`, `are you looking forward to christmas`, `what are you doing for christmas`, and `what are you thankful for`
- the favorites batch now includes `what is your favorite animal`, `what is your favorite bird`, `do you like penguins`, and `do you like animals` so the penguin-centered replies stay close to Pegasus
- the latest social batch adds `welcome back`, `what are you thinking`, `what have you been doing`, and `what did you do` so presence and charm stay lively without distracting from the memory roadmap
- the newest identity-charm batch adds `what's your name`, `do you have a nickname`, `do you like being Jibo`, `are there others like you`, and `what is your favorite name` so the robot stays familiar while still sounding like Pegasus
- the next deep-personality batch adds `what do you dream about`, `what are you afraid of`, `what do you want to talk about`, `what is your best book`, `what is your best exercise`, `what is your dream vacation`, `who is your hero`, `who do you love`, and `what is your religion`; `what is your sign` is still deferred until we add templated placeholder rendering
- the next identity/knowledge batch adds `are you god`, `are you here`, `do you have super powers`, `how much do you know`, `what does jibo mean`, `where do you get info`, `what are you forbidden to do`, `what color are you`, and `what do you do when alone`
- the next body/mission batch adds `how much do you weigh`, `how tall are you`, `how much do you cost`, `what if I unplug you`, `what is your purpose`, `what is your prime directive`, `what is jibo commander`, `do you like commander app`, and `what are you made of`
- the templated edge-case batch adds `what is your sign`, `how many people do you know`, and `what is the loop` so the remaining source-backed lines can lean on live birthday and loop state
- the work/eat/home batch adds `how do you work`, `what do you eat`, `where do you live`, and `what languages do you speak` so the everyday self-description cluster keeps moving toward the original phrasing
- the age batch adds `how old are you` through `JBO_HowOldAreYou` so the birthday and first-powered-up phrasing stays source-backed instead of falling back to a generic age answer
- live QA has surfaced a few repair targets to carry into the next pass: person-identification collisions inside the same loop, `turn around` / `go to sleep` motion quirks, and a couple of reply-selection spots where short variants are being over-selected (`how are you`, `what is your favorite flower`)
- this pass keeps Build B moving while still favoring source-backed phrasing and preserving the command-vs-question boundary
- the next passes should keep the same pattern and prefer source-backed phrasing whenever the legacy MIM text is available
- if a source-backed legacy line is missing, use a temporary direct reply only to keep the pass moving, then backfill source text later
- after the favorites batch, the next doc pass should focus on richer persona follow-ups and the remaining memory/presence charm surfaces
- Build B is now reserved for the next source-backed scripted-response batch:
- `how do you work`
- `what do you eat`
- `where do you live`
- `where were you born`
- `what languages do you speak`
- `what do you like to do`
- `what are you made of`
- `what is your favorite flower`
- `do you like R2D2`
- `do you like the sun`
- `do you like space`
- `do you like kids`
- `can you laugh`
- `can you dance`
The goal is to port these in small batches, capture the source-backed phrasing where possible, and keep a test for each batch so the list never becomes a vague backlog graveyard.
### 2. Reliability And Device Proof
- complete update/backup/restore proof path with captures and operator docs
- the restore proof is the persisted-state rehydration path; do not scope it into a new hosted restore API until we have real device evidence
- continue alarm/gallery/yes-no cleanup from `1.0.18` evidence where regressions are still open
- improve short-turn STT reliability and low-signal screening
- the latest STT pass adds a websocket-side low-signal screen for filler-only and stray single-token leftovers while keeping yes/no and word-of-the-day turns intact
- capture indexing and group-test handoff now have a bundle helper that packages raw event captures, the index manifest, and exported fixtures together for easier review/share flows
### 3. Pegasus-To-Cloud Platform Porting
- prioritize small source-backed slices from Pegasus/JiboOS that can be shipped safely
- keep Nimbus and stock payload compatibility as the release guardrail
- avoid broad subsystem rewrites without tests and live-capture evidence
- keep the legacy prompt inventory visible in the backlog so porting stays paced and traceable
### 4. Holidays And Seasonal Personality
- port holiday-aware personality responses as a visible extension of the new persona slice
- start with a small, source-backed set (for example birthdays/holidays already represented in legacy data paths)
- ensure holiday responses feel characterful while still routing through stock-compatible payloads
- imported Build B holiday buckets now include holiday, holiday greeting, holiday gift, and birthday celebration lines
- use a loop-scoped merged holiday list in the cloud protocol so system holidays and custom person holidays can coexist
- source system holidays from a live holiday provider and keep `IsEnabled = false` records available for holiday suppression
- keep birthday/custom holiday authoring aligned with person memory so future proactivity can suppress or promote holidays per loop
- birthday memory writes now create loop-scoped holiday records, which keeps the holiday list extensible without changing the protocol shape again
### 5. Multi-Tenant Memory Storage Foundation
- define tenant boundaries across account, loop, device, and person-memory records
- add storage abstractions that can move from in-memory/local JSON to hosted SQL/Blob without reworking behavior layers
- implement memory-ready schemas and repository contracts for user facts (names, birthdays, personal dates, preferences) with strict tenant scoping
- seed person-aware state keys now so future interactions can scope to account + loop + device + person without another shape change
- keep stateful interaction flows repository-backed instead of embedding more ad hoc metadata in the websocket layer
- the store seam now exposes revision metadata plus explicit load/save boundaries so durable adapters can drop in later without changing behavior code
- the backend seam is now selectable, with file-backed local persistence as the default and an Azure Blob Storage slot wired for future deployment wiring
### 6. Multi-Server Sync Path
- document the eventual sync boundary for stateful data that should move between servers
- treat the first pass as repository-local durability, then layer replication and conflict handling on top
- prefer explicit change records or versioned state snapshots over implicit last-writer wins when we outgrow a single node
- keep cross-server reconciliation out of the hot path until the single-server semantics are stable
Reference design:
- [persistence-architecture.md](persistence-architecture.md)
- [holiday-architecture.md](holiday-architecture.md)
- [commute-architecture.md](commute-architecture.md)
## First Implemented Slice In `1.0.19`
@@ -105,6 +198,80 @@ 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
## Personality Import Ladder
This is the practical plan for importing legacy Jibo `mims` into OpenJibo without pretending we already have a full Pegasus runtime.
### What Is Possible Today
OpenJibo can already host a meaningful subset of legacy personality content because it has:
- a shared catalog for content-driven replies
- chitchat state-machine routing with route metadata
- outbound payload support for `skillId`, `mim_id`, `mim_type`, `prompt_id`, `prompt_sub_category`, and ESML
- existing examples that already behave like legacy MIMs for pizza, dance, news, weather, and generic chat
### What We Need To Build
To move from hand-wired examples to broader imports, we need three small platform pieces:
1. a MIM inventory importer that can scan the legacy tree and produce a normalized catalog
2. a prompt-selection layer that can choose by `skill_id`, `mim_id`, prompt category, and condition metadata
3. a safe ESML/prompt renderer that preserves existing stock-compatible payload shapes
### What Can Be Ported With Each Build
#### Build A: Declarative Prompt Packs
Port immediately:
- `core-responses`
- `deflector`
- the simplest `emotion-responses`
- any `scripted-responses` that are just direct prompt lists with no special state machine
Why these first:
- they are already close to the current `JiboExperienceCatalog` model
- they give us user-visible personality quickly
- they are the best fit for low-risk testing tomorrow
#### Build B: Conditioned Prompt Packs
Port after the importer and renderer are in place:
- `gqa-responses`
- structured emotion responses with `condition` gates
- prompt sets that select different replies by user state or Jibo state
Why these next:
- they are still mostly declarative
- they need a small amount of condition evaluation, but not a new conversation engine
#### Build C: Conversation Families
Port after Build B:
- richer `scripted-responses` families that depend on follow-up state
- special-date / holiday personality sets
- more nuanced chitchat branches that need context-aware routing
- longer authored variants for existing prompts when the source text contains them, so the robot keeps the familiar Pegasus cadence without inventing new dialog composition yet
- dialog joining / composition as a post-release feature, kept out of the 1.0.19 ladder so we do not blur authored phrasing with a runtime joiner
Why these later:
- they need state and follow-up behavior, not just prompt selection
- they are where personality feels most alive, but they are also where bugs will be easiest to introduce
#### Build D: Full Parity Cleanup
Port after the core ladder is stable:
- large cross-skill collections
- any MIMs that depend on Pegasus-only parser assumptions
- any files that need a dedicated runtime abstraction instead of catalog lookup
## 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:
@@ -140,6 +307,10 @@ This confirms the pizza-fact offer state now keeps the yes/no branch open throug
Personal report parity planning is now captured with Pegasus source anchors for weather visuals/animations, live news, commute, and calendar gap coverage.
Calendar is now backed by a loop-scoped provider seam that can merge persisted loop events with birthday and holiday dates, keeping the report aligned with household context.
Commute now uses a loop-scoped commute profile and provider seam so the report can speak in the legacy commute shape without inventing a separate hosted travel service yet.
Reference:
- [personal-report-parity-plan.md](personal-report-parity-plan.md)
@@ -172,23 +343,48 @@ Second completed guardrail slice under this queue:
- weather variants (`what's today's weather look like`, `will it be sunny tomorrow`)
- listener continuation guardrail now differentiates incomplete preference fragments from complete shorthand preference sets
Third completed guardrail slice under this queue:
- expanded Pegasus `userLikesThing` / `userDislikesThing` / `doesUserLikeThing` / `doesUserDislikeThing` phrase-family coverage
- includes additional dislike/negation variants (`loathe`, `did not like`, `didn't enjoy`, `don't really like`)
- includes group-preference variants (`we like`, `we love`, `we dislike`, `we can't stand`)
- includes lookup variants (`do you think i like ...`, `do you believe i don't like ...`)
- added affinity set/lookup attempt guardrails so partial captures route to affinity prompts instead of generic chat
- extended auto-finalize continuation deferral for the new Pegasus affinity stems (`we like`, `i loathe`, and related variants)
- added focused interaction + websocket tests for the new parser/guardrail behavior
Next queued implementation track after parser guardrails:
- presence-aware greetings and identity-triggered proactivity (Pegasus `@be/greetings` parity slice)
- personal report parity slices (weather visual parity, live news path, commute/calendar refinement)
First completed slice in this personal-report parity track:
- added provider-ready news briefing path with Nimbus-compatible `news` payload continuity
- preserved fallback behavior when no live provider is configured
- added memory/transcript category hinting for provider requests (`sports`, `technology`, `business`, etc.)
- added provider-side request caching for both news and weather to reduce integration churn and repeated lookups
- added focused interaction + websocket tests for provider-backed news speech output and request-hint plumbing
- added loop-scoped calendar and commute provider seams so personal report can use persisted household context instead of static placeholders
- weather payloads now distinguish current vs weekly view modes so renderer parity can key off the payload shape
- news provider now skips summaryless correction headlines before falling back to broader sources
## Next Slices
1. Dialog parsing expansion (queued next as of `2026-05-06`; more phrase variants, ambiguity handling, and transcript-to-intent guardrails)
2. Presence-aware greetings and identity-triggered proactivity (reactive/proactive split, cooldowns, person-aware greeting hooks)
3. Personal report parity slices (weather visual layer, live news path, commute path, calendar parity matrix)
4. Holidays and seasonal personality slice beyond pizza day (time-scoped content backed by memory/proactivity path)
5. Durable memory persistence path (swap in provider-backed multi-tenant storage while preserving behavior contracts)
6. Update/backup/restore end-to-end proof (operator-run and documented)
7. STT noise-screening and short-utterance reliability pass
8. Provider-backed news expansion and deeper weather parity using Pegasus-backed contracts
9. Capture indexing and retention boundary for group testing
1. MIM import foundation for personality expansion
2. Dialog parsing expansion
3. Presence-aware greetings and identity-triggered proactivity
- in progress: durable greeting-presence history, per-person cooldown gating, birthday/holiday-aware special-day greetings, and morning vs return-visit tone splits are now in place
4. Personal report parity slices
5. Holidays and seasonal personality slice beyond pizza day
6. Durable memory persistence path
7. Update/backup/restore end-to-end proof - implemented
8. STT noise-screening and short-utterance reliability pass
9. Provider-backed news expansion and deeper weather parity
10. Capture indexing and retention boundary for group testing, including a lightweight manifest beside raw capture files
11. Binary-safe media storage seam with file and Azure Blob adapters, ready for original/thumbnails follow-up
For slices 1-5, use Pegasus phrase lists, MIM IDs, and behavior patterns as the source anchor before broadening into OpenJibo-native improvements.
For slice 1, use the new import ladder above to keep the work grounded in what OpenJibo can already render today versus what needs new scaffolding.
For slices 2-5, use Pegasus phrase lists, MIM IDs, and behavior patterns as the source anchor before broadening into OpenJibo-native improvements.
## Definition Of Done

151
OpenJibo/docs/roadmap.md Normal file
View File

@@ -0,0 +1,151 @@
# OpenJibo Roadmap
## Purpose
This is the long-range story for OpenJibo.
Use it when someone wants the shape of the project without reading every release note, backlog entry, or live-test log.
The current execution truth still lives in:
- [Development plan](development-plan.md)
- [Feature backlog](feature-backlog.md)
- [Release 1.0.19 plan](release-1.0.19-plan.md)
- [Device bootstrap path](device-bootstrap.md)
## North Star
Bring Jibo back in a way that preserves his original skills, design language, and charm, while layering in a modern hosted cloud, safer updates, and eventually a richer on-device and orchestration stack.
## Guiding Principles
- Preserve the original skills and visual design before adding new behaviors.
- Build the hosted cloud first so the robot has something stable to talk to.
- Use OTA to reduce friction after the cloud is proven.
- Keep every migration reversible.
- Favor small, source-backed slices over speculative rewrites.
- Let Jibo remain the face of the experience, even if other systems help orchestrate the work behind him.
## Roadmap At A Glance
| Phase | Focus | Why It Matters |
| --- | --- | --- |
| 1 | Working hosted cloud | Restores the services Jibo already expects and gives us the current platform truth. |
| 2 | OTA-assisted recovery and updates | Makes ownership easier by turning the cloud into the delivery path for recovery and upgrades. |
| 3 | Open Jibo OS / mode conversion | Creates an owned runtime and configuration layer while preserving the original experience. |
| 4 | Tiered brain | Separates reflexes, memory, personality, and higher-level orchestration. |
| 5 | CoffeeBreak orchestration | Provides a place for multi-step agent workflows and external tools without flattening Jibo's personality. |
| 6 | Ecosystem expansion | Grows the platform into household, productivity, and multi-device use cases. |
## Phase 1: Working Hosted Cloud
Current state: in progress.
The near-term job is to keep the hosted cloud stable and honest:
- maintain HTTP and WebSocket compatibility for startup and turn handling
- keep the .NET cloud as the production track
- keep Node as the reverse-engineering oracle and fixture source
- continue update, backup, restore, media, STT, and live-capture proof
- keep the real-device bootstrap path documented and repeatable
Exit criteria:
- a real Jibo can reach the hosted cloud consistently
- the cloud can carry the startup and conversation flows needed for daily use
- update and recovery behavior is understood well enough to trust the next layer
## Phase 2: OTA-Assisted Recovery
Once the hosted cloud is solid, OTA becomes the simplification layer.
This phase should:
- move software updates and recovery flows into a reliable hosted path
- reduce how often owners need manual RCM or network patching
- make device recovery and version management feel like a product instead of a lab exercise
- keep rollback and failure handling explicit
OTA is the path that makes ownership easier. It is not the thing that must be solved before the cloud can live.
## Phase 3: Open Jibo OS / Mode Conversion
After cloud and OTA are trustworthy, the project can move from "open cloud" to "open platform."
The goal is not to erase stock Jibo. The goal is to give owners an Open Jibo mode that:
- preserves the original Jibo feel and skill surface
- can be installed or selected without a one-way trap
- can fall back to stock behavior when needed
- makes future features easier to ship on top of a known runtime
This is where the breadcrumbs in the repo become important:
- `open-jibo` and `open-jibo-ai` modes
- a startup migration skill that can invite existing owners to convert
- a reversible path back to stock
- the hosted sites and support docs on `openjibo.com` and `openjibo.ai` that explain the transition clearly
## Phase 4: Tiered Brain
A single monolithic "AI brain" is not the best fit for Jibo. A tiered model is better.
Suggested tiers:
- Tier 0: original Jibo reflexes, stock skills, and local charm
- Tier 1: hosted cloud routing and compatibility
- Tier 2: memory, personality, and proactivity
- Tier 3: richer reasoning and multi-step planning
- Tier 4: external agent orchestration and task delegation
- Tier 5: multi-device and household coordination
The point of the tiers is not to make Jibo feel bigger at every turn. It is to keep simple interactions fast and charming while reserving more complex work for the layers that can actually support it.
## CoffeeBreak (`coffeebreakai.dev`) As An Orchestration Layer
CoffeeBreak fits naturally above the tiered brain as a coordination plane.
The intended relationship is:
- Jibo keeps the voice, personality, and local interaction style
- OpenJibo routes simple and medium-complexity tasks itself
- CoffeeBreak can take over when a task needs multiple tools, agents, or steps
- the result comes back to Jibo in a form that still feels native to him
That makes CoffeeBreak a close cousin to the tiered brain rather than a separate product line. The brain decides, CoffeeBreak orchestrates, and Jibo remains the face of the interaction.
## Phase 5: Ecosystem Expansion
After the core platform is stable, OpenJibo can grow into broader household value:
- calendar and scheduling
- smart home and Home Assistant style control
- shopping lists and household memory
- multi-user and family recognition
- richer media and content experiences
- provider-backed news, weather, and personal report flows
- eventual multi-Jibo interaction
## What We Must Preserve
No matter how far the platform grows, these should stay true:
- original skills should still feel like Jibo
- design should stay recognizable, not generic
- migration should be opt-in and reversible whenever possible
- the cloud should serve the robot, not replace his identity
- technical modernization should preserve charm instead of sanding it off
## Where To Go Next
If you want the current execution truth, read:
- [Development plan](development-plan.md)
- [Feature backlog](feature-backlog.md)
- [Release 1.0.19 plan](release-1.0.19-plan.md)
If you want the first-device path, read:
- [Device bootstrap path](device-bootstrap.md)
- [Support tiers](support-tiers.md)
- [Public site plan](public-site-plan.md)

View File

@@ -0,0 +1,101 @@
param(
[string]$CaptureRoot = "..\..\captures",
[string]$BundleDirectory = "..\..\captures\bundles",
[string]$BundleName
)
function Get-RelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$BasePath,
[Parameter(Mandatory = $true)]
[string]$FullPath
)
$normalizedBase = [System.IO.Path]::GetFullPath($BasePath)
if (-not $normalizedBase.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
$normalizedBase = $normalizedBase + [System.IO.Path]::DirectorySeparatorChar
}
$normalizedFull = [System.IO.Path]::GetFullPath($FullPath)
if (-not $normalizedFull.StartsWith($normalizedBase, [StringComparison]::OrdinalIgnoreCase)) {
throw "Path '$FullPath' is not under '$BasePath'."
}
return $normalizedFull.Substring($normalizedBase.Length)
}
$resolvedCaptureRoot = Resolve-Path -LiteralPath $CaptureRoot -ErrorAction Stop
$resolvedBundleDirectory = Resolve-Path -LiteralPath $BundleDirectory -ErrorAction SilentlyContinue
if (-not $resolvedBundleDirectory) {
$resolvedBundleDirectory = New-Item -ItemType Directory -Force -Path $BundleDirectory | Select-Object -ExpandProperty FullName
}
else {
$resolvedBundleDirectory = $resolvedBundleDirectory.Path
}
if ([string]::IsNullOrWhiteSpace($BundleName)) {
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$BundleName = "capture-bundle-$timestamp"
}
$stagingDirectory = Join-Path $resolvedBundleDirectory "$BundleName.staging"
$archivePath = Join-Path $resolvedBundleDirectory "$BundleName.zip"
if (Test-Path -LiteralPath $stagingDirectory) {
Remove-Item -LiteralPath $stagingDirectory -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $stagingDirectory | Out-Null
try {
$sourceFiles = Get-ChildItem -LiteralPath $resolvedCaptureRoot -Recurse -File | Where-Object {
$_.Name -eq "capture-index.ndjson" -or
$_.Name -like "*.events.ndjson" -or
$_.Name -like "*.flow.json"
}
if (-not $sourceFiles) {
Write-Host "No capture files were found under $resolvedCaptureRoot"
exit 0
}
foreach ($file in $sourceFiles) {
$relativePath = Get-RelativePath -BasePath $resolvedCaptureRoot -FullPath $file.FullName
$destinationPath = Join-Path $stagingDirectory $relativePath
$destinationDirectory = Split-Path -Parent $destinationPath
if (-not (Test-Path -LiteralPath $destinationDirectory)) {
New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
}
Copy-Item -LiteralPath $file.FullName -Destination $destinationPath -Force
}
$captureIndexFiles = @($sourceFiles | Where-Object { $_.Name -eq "capture-index.ndjson" })
$eventFiles = @($sourceFiles | Where-Object { $_.Name -like "*.events.ndjson" })
$fixtureFiles = @($sourceFiles | Where-Object { $_.Name -like "*.flow.json" })
$manifest = [ordered]@{
createdUtc = (Get-Date).ToUniversalTime().ToString("O")
sourceRoot = $resolvedCaptureRoot
fileCount = $sourceFiles.Count
captureIndexCount = $captureIndexFiles.Count
eventFileCount = $eventFiles.Count
fixtureCount = $fixtureFiles.Count
}
$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $stagingDirectory "bundle-manifest.json") -Encoding utf8
if (Test-Path -LiteralPath $archivePath) {
Remove-Item -LiteralPath $archivePath -Force
}
Compress-Archive -Path (Join-Path $stagingDirectory '*') -DestinationPath $archivePath -Force
Write-Host "Created capture bundle at $archivePath"
}
finally {
if (Test-Path -LiteralPath $stagingDirectory) {
Remove-Item -LiteralPath $stagingDirectory -Recurse -Force
}
}

View File

@@ -16,6 +16,8 @@ These scripts help exercise the new .NET hosted cloud locally.
Runs a small readiness checklist before the first physical Jibo test against the .NET cloud.
- `Import-WebSocketCaptureFixture.ps1`
Sanitizes an exported websocket capture fixture and copies it into the checked-in websocket fixture set.
- `New-CaptureBundle.ps1`
Packages the capture root, capture index, and exported fixtures into a single zip bundle for group testing handoff.
- `start-dotnet-with-node-cert.sh`
Starts the .NET API on Linux using the same PEM certificate material already used by the Node server.
- `invoke-live-jibo-prep.sh`

View File

@@ -143,24 +143,32 @@
<div class="static-section" id="staticSection">
<div class="row">
<div>
<label>Static IP</label
<label>
Static IP
</label
><input id="staticIP" placeholder="192.168.1.100"/>
</div>
<div>
<label>Netmask</label
<label>
Netmask
</label
><input id="netmask" placeholder="255.255.255.0"/>
</div>
</div>
<div class="row">
<div>
<label>Gateway</label
<label>
Gateway
</label
><input id="gateway" placeholder="192.168.1.1"/>
</div>
<div>
<label>DNS 1</label><input id="dns1" placeholder="8.8.8.8"/>
</div>
</div>
<div><label>DNS 2</label><input id="dns2" placeholder="8.8.4.4" /></div>
<div>
<label>DNS 2</label><input id="dns2" placeholder="8.8.4.4"/>
</div>
</div>
<button onclick="generate()">Generate QR Code</button>

View File

@@ -45,6 +45,91 @@ Human-facing entry points will live on domains such as:
Robot traffic may still arrive using legacy hostnames routed to the OpenJibo service.
## Azure Storage Wiring Sample
For local or hosted Blob-backed persistence, use the Azure sample config in:
- [appsettings.AzureBlob.sample.json](dotnet/src/Jibo.Cloud.Api/appsettings.AzureBlob.sample.json)
It shows the expected keys for:
- `OpenJibo:State:Backend`
- `OpenJibo:State:ConnectionString`
- `OpenJibo:PersonalMemory:Backend`
- `OpenJibo:PersonalMemory:ConnectionString`
- `OpenJibo:Media:Backend`
- `OpenJibo:Media:ConnectionString`
The connection string can also come from:
- `OPENJIBO_STATE_STORAGE_CONNECTION_STRING`
- `OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING`
- `OPENJIBO_MEDIA_STORAGE_CONNECTION_STRING`
For a real storage account, swap `UseDevelopmentStorage=true` with your Azure Storage connection string.
## Local Startup Note
To run the API with the Blob-backed sample config in Visual Studio or `dotnet run`, choose the
`Jibo.Cloud.Api.AzureBlob` launch profile.
The test project also has a matching `Jibo.Cloud.Tests.AzureBlob` profile so the smoke test can use
the same environment-variable shape when you run it from an IDE.
Equivalent environment variables:
```powershell
$env:OpenJibo__State__Backend = "AzureBlob"
$env:OpenJibo__State__ConnectionString = "UseDevelopmentStorage=true"
$env:OpenJibo__PersonalMemory__Backend = "AzureBlob"
$env:OpenJibo__PersonalMemory__ConnectionString = "UseDevelopmentStorage=true"
$env:OpenJibo__Media__Backend = "AzureBlob"
$env:OpenJibo__Media__ConnectionString = "UseDevelopmentStorage=true"
dotnet run --project dotnet/src/Jibo.Cloud.Api/Jibo.Cloud.Api.csproj
```
Replace `UseDevelopmentStorage=true` with your real storage account connection string when you move
from local emulation to Azure.
## Holiday Wiring
Holiday lists are now sourced from a live holiday provider and merged with loop-scoped custom
holiday records.
The default country code is `US`, but you can override it with:
- `OpenJibo:Holiday:CountryCode`
If you later add custom holiday authoring, disabled records can be used to suppress a holiday for a
loop without removing the underlying system holiday source.
## Calendar Wiring
Calendar report output is now driven by a loop-scoped in-process provider.
The provider currently:
- reads persisted loop calendar events
- folds in birthday and holiday dates that already live in the loop-scoped holiday list
- returns a safe empty calendar view when nothing is scheduled
This keeps the personal report moving toward Pegasus-style household-aware output without forcing a
full external calendar integration yet.
## Commute Wiring
Commute report output is now driven by a loop-scoped commute profile plus a provider seam.
The provider currently:
- reads persisted loop commute profiles
- returns a setup view when commute is missing or incomplete
- computes commute timing from the loop profile and the current clock
- keeps the personal report flow aligned with the stock `Commute_*` shape
The provider is intentionally conservative for now. It preserves the old report shape and gives us
room to add a richer travel-time source later without changing the behavior layer again.
## Recovery Strategy
The first supported device path is:

View File

@@ -0,0 +1,317 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Domain.Models;
using Microsoft.AspNetCore.Mvc;
namespace Jibo.Cloud.Api.Controllers;
[ApiController]
[Route("api/panel")]
public class WebPanelController(
ICloudStateStore stateStore,
IConfiguration configuration) : ControllerBase
{
private static readonly DateTimeOffset _startTime = DateTimeOffset.UtcNow;
[HttpGet("status")]
public ActionResult GetStatus()
{
var persistenceInfo = stateStore.GetPersistenceStateInfo();
var account = stateStore.GetAccount();
var robot = stateStore.GetRobot();
return Ok(new
{
version = OpenJiboCloudBuildInfo.Version,
uptime = (DateTimeOffset.UtcNow - _startTime).ToString(@"hh\:mm\:ss"),
startTime = _startTime.ToString("o"),
persistence = new
{
schemaVersion = persistenceInfo.SchemaVersion,
revision = persistenceInfo.Revision,
lastLoaded = persistenceInfo.LastLoadedUtc?.ToString("o"),
lastSaved = persistenceInfo.LastSavedUtc?.ToString("o")
},
account = new
{
accountId = account.AccountId,
firstName = account.FirstName,
lastName = account.LastName
},
robot = new
{
deviceId = robot.DeviceId,
robotId = robot.RobotId,
friendlyName = robot.FriendlyName,
firmwareVersion = robot.FirmwareVersion,
applicationVersion = robot.ApplicationVersion
},
configuration = new
{
webPanelEnabled = configuration.GetValue<bool>("OpenJibo:WebPanel:Enabled"),
refreshIntervalSeconds = configuration.GetValue<int>("OpenJibo:WebPanel:RefreshIntervalSeconds"),
allowRemoteAccess = configuration.GetValue<bool>("OpenJibo:WebPanel:AllowRemoteAccess")
}
});
}
[HttpGet("sessions")]
public ActionResult GetSessions()
{
// Since ICloudStateStore doesnt have a GetAllSessions method for now ill just return a empty list - TO BE UPGRADED!!
return Ok(new
{
sessions = Array.Empty<object>(),
count = 0
});
}
[HttpGet("robots")]
public ActionResult GetRobots()
{
var robot = stateStore.GetRobot();
var robotProfile = stateStore.GetRobotProfile();
return Ok(new
{
robots = new[]
{
new
{
deviceId = robot.DeviceId,
robotId = robot.RobotId,
friendlyName = robot.FriendlyName,
firmwareVersion = robot.FirmwareVersion,
applicationVersion = robot.ApplicationVersion,
profile = new
{
robotId = robotProfile.RobotId,
connectedAt = robotProfile.UpdatedUtc.ToString("o"),
platform = robotProfile.Payload?.TryGetValue("platform", out var platformValue) == true ? platformValue?.ToString() : null,
serialNumber = robotProfile.Payload?.TryGetValue("serialNumber", out var serialValue) == true ? serialValue?.ToString() : null
}
}
},
count = 1
});
}
[HttpGet("health")]
public ActionResult GetHealth()
{
var persistenceInfo = stateStore.GetPersistenceStateInfo();
return Ok(new
{
status = "healthy",
timestamp = DateTimeOffset.UtcNow.ToString("o"),
checks = new
{
persistence = new
{
status = persistenceInfo.LastSavedUtc.HasValue ? "ok" : "warning",
lastSaved = persistenceInfo.LastSavedUtc?.ToString("o"),
revision = persistenceInfo.Revision
},
stateStore = new
{
status = "ok",
type = "InMemoryCloudStateStore"
}
}
});
}
[HttpPost("state/save")]
public ActionResult SaveState()
{
try
{
stateStore.SavePersistedState();
return Ok(new { success = true, message = "State saved successfully" });
}
catch (Exception ex)
{
return BadRequest(new { success = false, message = ex.Message });
}
}
[HttpPost("state/reload")]
public ActionResult ReloadState()
{
try
{
stateStore.LoadPersistedState();
return Ok(new { success = true, message = "State reloaded successfully" });
}
catch (Exception ex)
{
return BadRequest(new { success = false, message = ex.Message });
}
}
[HttpGet("info")]
public ActionResult GetInfo()
{
var robot = stateStore.GetRobot();
var persistenceInfo = stateStore.GetPersistenceStateInfo();
return Ok(new
{
serverId = Environment.MachineName,
serverName = robot.FriendlyName ?? "OpenJibo Server",
endpoint = Request.Host.Value,
version = OpenJiboCloudBuildInfo.Version,
startTime = _startTime.ToString("o"),
uptime = (DateTimeOffset.UtcNow - _startTime).TotalSeconds,
robotId = robot.RobotId,
deviceId = robot.DeviceId,
stateRevision = persistenceInfo.Revision,
lastStateSave = persistenceInfo.LastSavedUtc?.ToString("o")
});
}
[HttpGet("metrics")]
public ActionResult GetMetrics()
{
var persistenceInfo = stateStore.GetPersistenceStateInfo();
var robot = stateStore.GetRobot();
var loops = stateStore.GetLoops();
var people = stateStore.GetPeople();
var media = stateStore.ListMedia();
var updates = stateStore.ListUpdates();
var backups = stateStore.GetBackups();
return Ok(new
{
timestamp = DateTimeOffset.UtcNow.ToString("o"),
server = new
{
version = OpenJiboCloudBuildInfo.Version,
uptime = (DateTimeOffset.UtcNow - _startTime).TotalSeconds,
startTime = _startTime.ToString("o")
},
state = new
{
revision = persistenceInfo.Revision,
lastLoaded = persistenceInfo.LastLoadedUtc?.ToString("o"),
lastSaved = persistenceInfo.LastSavedUtc?.ToString("o"),
schemaVersion = persistenceInfo.SchemaVersion
},
robot = new
{
robotId = robot.RobotId,
deviceId = robot.DeviceId,
firmwareVersion = robot.FirmwareVersion,
applicationVersion = robot.ApplicationVersion
},
counts = new
{
loops = loops.Count,
people = people.Count,
media = media.Count,
updates = updates.Count,
backups = backups.Count
}
});
}
private static List<object> _serverLogs = new();
private static readonly object _logsLock = new();
[HttpGet("logs")]
public ActionResult GetLogs(long since = 0)
{
lock (_logsLock)
{
// Add some test logs if empty
if (_serverLogs.Count == 0)
{
_serverLogs.Add(new { timestamp = DateTimeOffset.UtcNow.AddSeconds(-10).ToUnixTimeMilliseconds(), level = "info", message = "Server running normally" });
_serverLogs.Add(new { timestamp = DateTimeOffset.UtcNow.AddSeconds(-5).ToUnixTimeMilliseconds(), level = "info", message = "Health check passed" });
_serverLogs.Add(new { timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), level = "info", message = "Web panel accessed" });
}
// Filter logs
var filteredLogs = _serverLogs
.Where(log => (long)((dynamic)log).timestamp > since)
.ToList();
return Ok(new
{
logs = filteredLogs,
count = filteredLogs.Count
});
}
}
[HttpGet("endpoints")]
public ActionResult GetEndpoints()
{
var multiPortEnabled = configuration.GetValue<bool>("OpenJibo:MultiPortMode:Enabled");
if (multiPortEnabled)
{
return Ok(new
{
mode = "multi-port",
enabled = true,
ports = new
{
api = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:Api"),
apiSocket = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:ApiSocket"),
neoHubListen = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubListen"),
neoHubProactive = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubProactive"),
webPanel = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:WebPanel")
},
robotConfig = new
{
webCoreServerPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:Api"),
jetstreamServiceServerPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:Api"),
jetstreamServiceRegistryPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:ApiSocket"),
hubClientHubPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubListen"),
hubClientProactivePort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubProactive")
}
});
}
else
{
return Ok(new
{
mode = "dns-based",
enabled = false,
description = "Server uses DNS-based routing. Configure robot hostnames to point to this server.",
hosts = new
{
api = "api.jibo.com",
apiSocket = "api-socket.jibo.com",
neoHub = "neo-hub.jibo.com"
}
});
}
}
[HttpPost("endpoints/multi-port/enable")]
public ActionResult EnableMultiPortMode([FromBody] MultiPortConfigRequest request)
{
try
{
// This is a placeholder for future web panel integration
// For now, users need to manually edit appsettings.json
return Ok(new { success = false, message = "Please manually edit appsettings.json to enable multi-port mode. Set OpenJibo:MultiPortMode:Enabled to true and configure the ports." });
}
catch (Exception ex)
{
return BadRequest(new { success = false, message = ex.Message });
}
}
}
public class MultiPortConfigRequest
{
public int? Api { get; set; }
public int? ApiSocket { get; set; }
public int? NeoHubListen { get; set; }
public int? NeoHubProactive { get; set; }
public int? WebPanel { get; set; }
}

View File

@@ -1,5 +1,6 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Domain.Models;
@@ -8,12 +9,53 @@ using Jibo.Cloud.Infrastructure.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenJiboCloud(builder.Configuration);
builder.Services.AddControllers();
// Add CORS for multi-server controller support (for future api support so we can hook up azure / aws / firebase / pocketbase) <=====================================================================
builder.Services.AddCors(options =>
{
options.AddPolicy("WebPanelPolicy", policy =>
{
var allowedOrigins = builder.Configuration["OpenJibo:WebPanel:AllowedOrigins"]?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>();
if (allowedOrigins.Length > 0)
{
policy.WithOrigins(allowedOrigins)
.AllowAnyMethod()
.AllowAnyHeader();
}
else
{
// Default: allow localhost for development
policy.WithOrigins("http://localhost:3380", "http://localhost:3000", "http://localhost:8080")
.AllowAnyMethod()
.AllowAnyHeader();
}
});
});
var app = builder.Build();
app.Logger.LogInformation("Starting Open Jibo Cloud Api version {Version}", OpenJiboCloudBuildInfo.Version);
app.UseWebSockets();
app.UseCors("WebPanelPolicy");
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapControllers();
// Serve web panel index.html for root requests on port 3380 <=====================================================================
app.Use(async (context, next) =>
{
if (context.Request.Path == "/" && (context.Request.Host.Port == 3380 ||
(context.Request.Host.Value != null && context.Request.Host.Value.Contains("3380"))))
{
context.Response.ContentType = "text/html";
await context.Response.SendFileAsync(Path.Combine(app.Environment.WebRootPath, "index.html"));
return;
}
await next();
});
app.Use(async (context, next) =>
{
@@ -23,7 +65,7 @@ app.Use(async (context, next) =>
return;
}
var kind = ResolveSocketKind(context.Request.Host.Host, context.Request.Path);
var kind = ResolveSocketKind(context.Request.Host.Host, context.Request.Path, context.Request.Host.Port, builder.Configuration);
var token = ResolveToken(context.Request);
switch (kind)
{
@@ -88,18 +130,13 @@ app.Use(async (context, next) =>
var replies = await webSocketService.HandleMessageAsync(envelope, context.RequestAborted);
var session = ResolveSession(webSocketService, envelope);
await telemetrySink.RecordInboundAsync(envelope, session, ReadMessageType(envelope.Text), context.RequestAborted);
await telemetrySink.RecordInboundAsync(envelope, session, ReadMessageType(envelope.Text),
context.RequestAborted);
foreach (var reply in replies)
{
if (string.IsNullOrWhiteSpace(reply.Text))
{
continue;
}
if (string.IsNullOrWhiteSpace(reply.Text)) continue;
if (reply.DelayMs > 0)
{
await Task.Delay(reply.DelayMs, context.RequestAborted);
}
if (reply.DelayMs > 0) await Task.Delay(reply.DelayMs, context.RequestAborted);
var payload = Encoding.UTF8.GetBytes(reply.Text);
await socket.SendAsync(payload, WebSocketMessageType.Text, true, context.RequestAborted);
@@ -117,7 +154,8 @@ app.Use(async (context, next) =>
Token = token
};
var closeSession = ResolveSession(webSocketService, closeEnvelope);
await telemetrySink.RecordConnectionClosedAsync(closeEnvelope, closeSession, $"socket-loop-ended{(isPrematureClose ? "-prematurely" : string.Empty)}", context.RequestAborted);
await telemetrySink.RecordConnectionClosedAsync(closeEnvelope, closeSession,
$"socket-loop-ended{(isPrematureClose ? "-prematurely" : string.Empty)}", context.RequestAborted);
});
app.MapGet("/health", () => Results.Json(new
@@ -127,8 +165,35 @@ app.MapGet("/health", () => Results.Json(new
version = OpenJiboCloudBuildInfo.Version
}));
app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context, JiboCloudProtocolService service, IProtocolTelemetrySink telemetrySink, CancellationToken cancellationToken) =>
app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context, JiboCloudProtocolService service,
IProtocolTelemetrySink telemetrySink, CancellationToken cancellationToken) =>
{
// For web panel port, **try** to serve static files <=====================================================================
if (context.Request.Host.Port == 3380 ||
(context.Request.Host.Value != null && context.Request.Host.Value.Contains("3380")))
{
var path = context.Request.Path.Value ?? "";
var filePath = Path.Combine(app.Environment.WebRootPath, path.TrimStart('/'));
if (File.Exists(filePath))
{
var contentType = Path.GetExtension(filePath) switch
{
".css" => "text/css",
".js" => "application/javascript",
".html" => "text/html",
_ => "application/octet-stream"
};
context.Response.ContentType = contentType;
await context.Response.SendFileAsync(filePath);
return;
}
context.Response.StatusCode = 404;
return;
}
var envelope = await BuildEnvelopeAsync(context, cancellationToken);
var result = await service.DispatchAsync(envelope, cancellationToken);
await telemetrySink.RecordAsync(envelope, result, cancellationToken);
@@ -136,15 +201,9 @@ app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context,
context.Response.StatusCode = result.StatusCode;
context.Response.ContentType = result.ContentType;
foreach (var header in result.Headers)
{
context.Response.Headers[header.Key] = header.Value;
}
foreach (var header in result.Headers) context.Response.Headers[header.Key] = header.Value;
if (!string.IsNullOrEmpty(result.BodyText))
{
await context.Response.WriteAsync(result.BodyText, cancellationToken);
}
if (!string.IsNullOrEmpty(result.BodyText)) await context.Response.WriteAsync(result.BodyText, cancellationToken);
});
app.Run();
@@ -160,8 +219,7 @@ static async Task<ReceivedSocketMessage> ReceiveAsync(WebSocket socket, Cancella
{
result = await socket.ReceiveAsync(buffer, cancellationToken);
ms.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);
} while (!result.EndOfMessage);
return new ReceivedSocketMessage(result.MessageType, ms.ToArray());
}
@@ -170,7 +228,7 @@ static async Task<ProtocolEnvelope> BuildEnvelopeAsync(HttpContext context, Canc
{
context.Request.EnableBuffering();
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, false, leaveOpen: true);
var bodyText = await reader.ReadToEndAsync(cancellationToken);
context.Request.Body.Position = 0;
@@ -191,66 +249,62 @@ static async Task<ProtocolEnvelope> BuildEnvelopeAsync(HttpContext context, Canc
FirmwareVersion = context.Request.Headers["X-OpenJibo-Firmware"].ToString(),
ApplicationVersion = context.Request.Headers["X-OpenJibo-AppVersion"].ToString(),
BodyText = bodyText,
Headers = context.Request.Headers.ToDictionary(pair => pair.Key, pair => pair.Value.ToString(), StringComparer.OrdinalIgnoreCase)
Headers = context.Request.Headers.ToDictionary(pair => pair.Key, pair => pair.Value.ToString(),
StringComparer.OrdinalIgnoreCase)
};
}
static string ResolveSocketKind(string host, PathString path)
static string ResolveSocketKind(string host, PathString path, int? port, IConfiguration configuration)
{
if (host.Equals("api-socket.jibo.com", StringComparison.OrdinalIgnoreCase))
var multiPortEnabled = configuration.GetValue<bool>("OpenJibo:MultiPortMode:Enabled");
if (multiPortEnabled && port.HasValue)
{
return "api-socket";
var apiSocketPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:ApiSocket");
var neoHubListenPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubListen");
var neoHubProactivePort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubProactive");
if (port == apiSocketPort) return "api-socket";
if (port == neoHubProactivePort) return "neo-hub-proactive";
if (port == neoHubListenPort) return "neo-hub-listen";
}
if (host.Equals("api-socket.jibo.com", StringComparison.OrdinalIgnoreCase)) return "api-socket";
if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase) &&
path.StartsWithSegments("/v1/proactive"))
{
return "neo-hub-proactive";
}
if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase))
{
return "neo-hub-listen";
}
if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase)) return "neo-hub-listen";
if (host.Equals("openjibo.com", StringComparison.OrdinalIgnoreCase) ||
host.Equals("openjibo.ai", StringComparison.OrdinalIgnoreCase) ||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
return "openjibo";
}
return "unknown";
return
"neo-hub-listen"; // now it assumes all unknown requests are neo-hub. I did this so that people with custom listen servers (like myself) won't get a bunch of 404 messages when doing a HJ request. -ZaneDev (an awful programmer)
}
static string? ResolveToken(HttpRequest request)
{
var auth = request.Headers.Authorization.ToString();
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return auth["Bearer ".Length..].Trim();
}
if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return auth["Bearer ".Length..].Trim();
var path = request.Path.Value;
if (!string.IsNullOrWhiteSpace(path) && path.Length > 1)
{
return path.Trim('/');
}
if (!string.IsNullOrWhiteSpace(path) && path.Length > 1) return path.Trim('/');
return null;
}
static string ReadMessageType(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return "BINARY_OR_EMPTY";
}
if (string.IsNullOrWhiteSpace(text)) return "BINARY_OR_EMPTY";
try
{
using var document = System.Text.Json.JsonDocument.Parse(text);
return document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == System.Text.Json.JsonValueKind.String
using var document = JsonDocument.Parse(text);
return document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String
? type.GetString() ?? "UNKNOWN"
: "UNKNOWN";
}

View File

@@ -7,6 +7,20 @@
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:24604;http://localhost:24605"
},
"Jibo.Cloud.Api.AzureBlob": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"OpenJibo__State__Backend": "AzureBlob",
"OpenJibo__State__ConnectionString": "UseDevelopmentStorage=true",
"OpenJibo__PersonalMemory__Backend": "AzureBlob",
"OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true",
"OpenJibo__Media__Backend": "AzureBlob",
"OpenJibo__Media__ConnectionString": "UseDevelopmentStorage=true"
},
"applicationUrl": "https://localhost:24604;http://localhost:24605"
}
}
}

View File

@@ -0,0 +1,19 @@
{
"OpenJibo": {
"State": {
"Backend": "AzureBlob",
"ConnectionString": "UseDevelopmentStorage=true",
"PersistencePath": "App_Data/cloud-state.json"
},
"PersonalMemory": {
"Backend": "AzureBlob",
"ConnectionString": "UseDevelopmentStorage=true",
"PersistencePath": "App_Data/personal-memory.json"
},
"Media": {
"Backend": "AzureBlob",
"ConnectionString": "UseDevelopmentStorage=true",
"ContainerName": "openjibo-media"
}
}
}

View File

@@ -1,5 +1,39 @@
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5000"
},
"ApiSocket": {
"Url": "http://localhost:5001"
},
"NeoHubListen": {
"Url": "http://localhost:5002"
},
"NeoHubProactive": {
"Url": "http://localhost:5003"
},
"WebPanel": {
"Url": "http://localhost:3380"
}
}
},
"OpenJibo": {
"MultiPortMode": {
"Enabled": true,
"Ports": {
"Api": 5000,
"ApiSocket": 5001,
"NeoHubListen": 5002,
"NeoHubProactive": 5003,
"WebPanel": 3380
}
},
"WebPanel": {
"Enabled": true,
"RefreshIntervalSeconds": 5,
"AllowRemoteAccess": false
},
"Telemetry": {
"Enabled": true,
"ExportFixtures": true,
@@ -27,7 +61,23 @@
"BaseUrl": "https://api.openweathermap.org",
"ApiKey": "723667c9ab0318142227c5389900d087",
"DefaultLocation": "Boston,US",
"UseCelsius": false
"UseCelsius": false,
"CurrentCacheTtlSeconds": 120,
"ForecastCacheTtlSeconds": 600,
"GeocodeCacheTtlSeconds": 21600,
"FailureCacheTtlSeconds": 45
}
},
"News": {
"NewsApi": {
"BaseUrl": "https://newsapi.org",
"ApiKey": "5df93a83db9c4c6888f3e06c4a53144f",
"Country": "us",
"Language": "en",
"FallbackQuery": "robotics OR technology OR science",
"DefaultCategories": [ "general", "technology", "sports", "business" ],
"CacheTtlSeconds": 300,
"FailureCacheTtlSeconds": 45
}
}
}

View File

@@ -0,0 +1,399 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #363636;
color: #ffffff;
min-height: 100vh;
margin: 0;
}
.app-container {
display: flex;
flex-direction: row;
height: 100vh;
overflow: hidden;
}
/* Custom Sidebar with Material Design styling */
.sidebar {
width: 280px;
height: 100%;
background: #212121;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 16px;
font-size: 20px;
font-weight: 500;
color: #fbfbfb;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
color: #dfdfdf;
font-size: 20px;
margin: 4px 8px;
}
.nav-item:hover {
background-color: rgba(98, 0, 238, 0.08);
}
.nav-item.active {
background-color: rgba(98, 0, 238, 0.12);
color: #df62ff;
}
.nav-icon {
font-size: 40px;
}
.nav-text {
flex: 1;
}
.header {
background: #6200ee;
color: #ffffff;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.title {
font-size: 20px;
font-weight: 500;
color: #ffffff;
}
/* Main Content Area */
.main-wrapper {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.main-content {
flex: 1;
padding: 16px;
overflow-y: auto;
display: none;
}
.main-content.active {
display: block;
}
/* Material Web Card */
md-elevated-card {
margin-bottom: 16px;
max-width: 100%;
}
.card-content {
padding: 16px;
}
.status-grid,
.config-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.status-item,
.config-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.status-label,
.config-label,
.detail-label,
.check-label,
.health-label,
.count-label {
font-size: 12px;
color: #666666;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.status-value,
.config-value,
.detail-value,
.check-value,
.health-value,
.count-value {
font-size: 14px;
color: #111111;
font-weight: 500;
}
.robot-item {
background: #1e1e1e;
border-radius: 4px;
padding: 16px;
}
.robot-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #e0e0e0;
}
.robot-name {
font-size: 16px;
font-weight: 500;
color: #ffffff;
}
.robot-id {
font-size: 12px;
color: #666666;
font-family: monospace;
}
.robot-details {
display: grid;
gap: 8px;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.session-count {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
}
.count-value {
font-size: 24px;
font-weight: 700;
color: #6200ee;
}
.sessions-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state {
text-align: center;
color: #666666;
font-style: italic;
padding: 20px;
}
.health-status {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 12px;
background: #111111;
border-radius: 4px;
}
.health-value {
font-size: 18px;
font-weight: 500;
color: #4caf50;
}
.health-value.warning {
color: #ff9800;
}
.health-value.error {
color: #f44336;
}
.health-checks {
display: flex;
flex-direction: column;
gap: 12px;
}
.health-check {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f5f5f5;
border-radius: 4px;
}
.check-value {
color: #4caf50;
}
.check-value.warning {
color: #ff9800;
}
.check-value.error {
color: #f44336;
}
/* Status indicator */
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #9e9e9e;
animation: pulse 2s infinite;
}
.status-dot.connected {
background: #4caf50;
}
.status-dot.disconnected {
background: #f44336;
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-text {
font-size: 14px;
color: #ffffff;
font-weight: 500;
}
/* Material Web Button warning variant */
md-filled-button.warning {
--md-filled-button-container-color: #f44336;
--md-filled-button-label-text-color: #ffffff;
}
.controls-grid {
display: flex;
gap: 8px;
}
/* Terminal Styles */
.terminal-container {
background: #1e1e1e;
border-radius: 4px;
padding: 16px;
height: calc(100vh - 120px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #333;
}
.terminal-title {
font-size: 16px;
font-weight: 500;
color: #d4d4d4;
}
.terminal-controls {
display: flex;
gap: 8px;
}
.terminal-output {
flex: 1;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
color: #d4d4d4;
padding: 8px;
background: #0d0d0d;
border-radius: 4px;
}
.log-entry {
margin-bottom: 4px;
white-space: pre-wrap;
word-break: break-all;
}
.log-entry.info {
color: #5bc0de;
}
.log-entry.warning {
color: #f0ad4e;
}
.log-entry.error {
color: #d9534f;
}
.log-entry.debug {
color: #777;
}
@media (max-width: 768px) {
.app-container {
flex-direction: column;
}
md-navigation-drawer {
width: 100%;
height: auto;
}
.main-content {
grid-template-columns: 1fr;
}
.status-grid,
.config-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenJibo Cloud Panel</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="/css/panel.css">
<script type="importmap">
{
"imports": {
"@material/web/": "https://esm.run/@material/web/"
}
}
</script>
<script type="module">
import '@material/web/all.js';
import {styles as typescaleStyles} from '@material/web/typography/md-typescale-styles.js';
document.adoptedStyleSheets.push(typescaleStyles.styleSheet);
</script>
</head>
<body>
<div class="app-container">
<nav class="sidebar">
<div class="sidebar-header">OpenJibo Panel</div>
<div class="nav-item active" data-tab="dashboard" onclick="switchTab('dashboard')">
<span class="material-icons nav-icon">dashboard</span>
<span class="nav-text">Dashboard</span>
</div>
<div class="nav-item" data-tab="robots" onclick="switchTab('robots')">
<span class="material-icons nav-icon">smart_toy</span>
<span class="nav-text">Robots</span>
</div>
<div class="nav-item" data-tab="sessions" onclick="switchTab('sessions')">
<span class="material-icons nav-icon">people</span>
<span class="nav-text">Sessions</span>
</div>
<div class="nav-item" data-tab="health" onclick="switchTab('health')">
<span class="material-icons nav-icon">favorite</span>
<span class="nav-text">Health</span>
</div>
<div class="nav-item" data-tab="config" onclick="switchTab('config')">
<span class="material-icons nav-icon">settings</span>
<span class="nav-text">Config</span>
</div>
<div class="nav-item" data-tab="terminal" onclick="switchTab('terminal')">
<span class="material-icons nav-icon">terminal</span>
<span class="nav-text">Terminal</span>
</div>
</nav>
<div class="main-wrapper">
<header class="header">
<h1 class="title">OpenJibo Cloud Panel Test Thingy</h1>
<div class="status-indicator">
<span class="status-dot" id="connectionStatus"></span>
<span class="status-text" id="connectionText">Connecting...</span>
</div>
</header>
<!-- Dashboard Tab -->
<div id="tab-dashboard" class="main-content active">
<md-elevated-card>
<div class="card-content">
<h2 class="md-typescale-headline-small">Server Status</h2>
<div class="status-grid">
<div class="status-item">
<span class="status-label">Version</span>
<span class="status-value" id="serverVersion">-</span>
</div>
<div class="status-item">
<span class="status-label">Uptime</span>
<span class="status-value" id="serverUptime">-</span>
</div>
<div class="status-item">
<span class="status-label">Started</span>
<span class="status-value" id="serverStartTime">-</span>
</div>
<div class="status-item">
<span class="status-label">Last Saved</span>
<span class="status-value" id="lastSaved">-</span>
</div>
</div>
</div>
</md-elevated-card>
<md-elevated-card>
<div class="card-content">
<h2 class="md-typescale-headline-small">Server Quick Controls</h2>
<div class="controls-grid">
<md-filled-button onclick="saveState()">Save State</md-filled-button>
<md-filled-button class="warning" onclick="reloadState()">Reload State</md-filled-button>
</div>
</div>
</md-elevated-card>
</div>
<!-- Robots Tab -->
<div id="tab-robots" class="main-content">
<md-elevated-card>
<div class="card-content">
<h2 class="md-typescale-headline-small">Will Have Connected Robots</h2>
<div id="robotsList">
<div class="robot-item">
<div class="robot-info">
<span class="robot-name" id="robotName">-</span>
<span class="robot-id" id="robotId">-</span>
</div>
<div class="robot-details">
<div class="detail-item">
<span class="detail-label">Device ID:</span>
<span class="detail-value" id="deviceId">-</span>
</div>
<div class="detail-item">
<span class="detail-label">Firmware:</span>
<span class="detail-value" id="firmwareVersion">-</span>
</div>
<div class="detail-item">
<span class="detail-label">App Version:</span>
<span class="detail-value" id="appVersion">-</span>
</div>
<div class="detail-item">
<span class="detail-label">Platform:</span>
<span class="detail-value" id="platform">-</span>
</div>
</div>
</div>
</div>
</div>
</md-elevated-card>
</div>
<!-- Sessions Tab -->
<div id="tab-sessions" class="main-content">
<md-elevated-card>
<div class="card-content">
<h2 class="md-typescale-headline-small">Active Sessions</h2>
<div class="session-count">
<span class="count-label">Active Sessions:</span>
<span class="count-value" id="sessionCount">0</span>
</div>
<div id="sessionsList" class="sessions-list">
<p class="empty-state">No active sessions</p>
</div>
</div>
</md-elevated-card>
</div>
<!-- Health Tab -->
<div id="tab-health" class="main-content">
<md-elevated-card>
<div class="card-content">
<h2 class="md-typescale-headline-small">Health Check</h2>
<div class="health-status">
<span class="health-label">Overall Status:</span>
<span class="health-value" id="healthStatus">-</span>
</div>
<div class="health-checks">
<div class="health-check">
<span class="check-label">Persistence:</span>
<span class="check-value" id="persistenceStatus">-</span>
</div>
<div class="health-check">
<span class="check-label">State Store:</span>
<span class="check-value" id="stateStoreStatus">-</span>
</div>
</div>
</div>
</md-elevated-card>
</div>
<!-- Config Tab -->
<div id="tab-config" class="main-content">
<md-elevated-card>
<div class="card-content">
<h2 class="md-typescale-headline-small">Will be Configurator</h2>
<div class="config-grid">
<div class="config-item">
<span class="config-label">Web Panel Enabled</span>
<span class="config-value" id="webPanelEnabled">-</span>
</div>
<div class="config-item">
<span class="config-label">Refresh Interval</span>
<span class="config-value" id="refreshInterval">-</span>
</div>
<div class="config-item">
<span class="config-label">Remote Access</span>
<span class="config-value" id="remoteAccess">-</span>
</div>
</div>
</div>
</md-elevated-card>
</div>
<!-- Terminal Tab -->
<div id="tab-terminal" class="main-content">
<div class="terminal-container">
<div class="terminal-header">
<span class="terminal-title">Server Logs</span>
<div class="terminal-controls">
<md-outlined-button onclick="clearTerminal()">Clear</md-outlined-button>
<md-outlined-button onclick="toggleAutoScroll()">Auto Scroll</md-outlined-button>
</div>
</div>
<div class="terminal-output" id="terminalOutput">
<div class="log-entry">Waiting for server logs...</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/panel.js"></script>
</body>
</html>

View File

@@ -0,0 +1,403 @@
const API_BASE = '/api/panel';
let refreshInterval = 5000; // Default 5 seconds
let refreshTimer = null;
let isConnected = false;
let autoScrollEnabled = true;
let currentTab = 'dashboard';
// Initialize the panel
async function init() {
try {
// Fetch configuration first to get refresh interval
const status = await fetchStatus();
if (status && status.configuration) {
refreshInterval = (status.configuration.refreshIntervalSeconds || 5) * 1000;
}
// Initial data load
await refreshAll();
// Set up auto-refresh
startAutoRefresh();
// Update connection status
setConnectionStatus(true);
// Start terminal if on terminal tab
if (currentTab === 'terminal') {
startTerminal();
}
} catch (error) {
console.error('Failed to initialize panel:', error);
setConnectionStatus(false);
// Retry after 5 seconds
setTimeout(init, 5000);
}
}
// Tab switching
function switchTab(tabName) {
currentTab = tabName;
// Update navigation items
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
if (item.dataset.tab === tabName) {
item.classList.add('active');
}
});
// Update tab content
document.querySelectorAll('.main-content').forEach(content => {
content.classList.remove('active');
});
const targetTab = document.getElementById(`tab-${tabName}`);
if (targetTab) {
targetTab.classList.add('active');
}
// Start terminal if switching to terminal tab
if (tabName === 'terminal') {
startTerminal();
} else {
stopTerminal();
}
}
// Fetch server status
async function fetchStatus() {
try {
const response = await fetch(`${API_BASE}/status`);
if (!response.ok) throw new Error('Failed to fetch status');
return await response.json();
} catch (error) {
console.error('Error fetching status:', error);
return null;
}
}
// Fetch sessions
async function fetchSessions() {
try {
const response = await fetch(`${API_BASE}/sessions`);
if (!response.ok) throw new Error('Failed to fetch sessions');
return await response.json();
} catch (error) {
console.error('Error fetching sessions:', error);
return null;
}
}
// Fetch robots
async function fetchRobots() {
try {
const response = await fetch(`${API_BASE}/robots`);
if (!response.ok) throw new Error('Failed to fetch robots');
return await response.json();
} catch (error) {
console.error('Error fetching robots:', error);
return null;
}
}
// Fetch health
async function fetchHealth() {
try {
const response = await fetch(`${API_BASE}/health`);
if (!response.ok) throw new Error('Failed to fetch health');
return await response.json();
} catch (error) {
console.error('Error fetching health:', error);
return null;
}
}
// Refresh all data
async function refreshAll() {
const [status, sessions, robots, health] = await Promise.all([
fetchStatus(),
fetchSessions(),
fetchRobots(),
fetchHealth()
]);
if (status) updateStatus(status);
if (sessions) updateSessions(sessions);
if (robots) updateRobots(robots);
if (health) updateHealth(health);
updateLastRefresh();
}
// Update server status UI
function updateStatus(data) {
document.getElementById('serverVersion').textContent = data.version || '-';
document.getElementById('serverUptime').textContent = data.uptime || '-';
document.getElementById('serverStartTime').textContent = formatDateTime(data.startTime) || '-';
document.getElementById('lastSaved').textContent = formatDateTime(data.persistence?.lastSaved) || '-';
if (data.configuration) {
document.getElementById('webPanelEnabled').textContent =
data.configuration.webPanelEnabled ? 'Yes' : 'No';
document.getElementById('refreshInterval').textContent =
`${data.configuration.refreshIntervalSeconds}s`;
document.getElementById('remoteAccess').textContent =
data.configuration.allowRemoteAccess ? 'Yes' : 'No';
}
}
// Update sessions UI
function updateSessions(data) {
const count = data.count || 0;
document.getElementById('sessionCount').textContent = count;
const sessionsList = document.getElementById('sessionsList');
if (count === 0 || !data.sessions || data.sessions.length === 0) {
sessionsList.innerHTML = '<p class="empty-state">No active sessions</p>';
} else {
sessionsList.innerHTML = data.sessions.map(session => `
<div class="session-item">
<div class="session-info">
<span class="session-kind">${session.kind || 'Unknown'}</span>
<span class="session-token">${session.token || 'No token'}</span>
</div>
<div class="session-time">
Last seen: ${formatDateTime(session.lastSeenUtc)}
</div>
</div>
`).join('');
}
}
// Update robots UI
function updateRobots(data) {
if (data.robots && data.robots.length > 0) {
const robot = data.robots[0];
document.getElementById('robotName').textContent = robot.friendlyName || 'Unknown Robot';
document.getElementById('robotId').textContent = robot.robotId || '-';
document.getElementById('deviceId').textContent = robot.deviceId || '-';
document.getElementById('firmwareVersion').textContent = robot.firmwareVersion || '-';
document.getElementById('appVersion').textContent = robot.applicationVersion || '-';
document.getElementById('platform').textContent = robot.profile?.platform || '-';
}
}
// Update health UI
function updateHealth(data) {
const healthStatus = document.getElementById('healthStatus');
healthStatus.textContent = data.status || '-';
healthStatus.className = 'health-value';
if (data.status === 'healthy') {
healthStatus.classList.add('success');
} else if (data.status === 'warning') {
healthStatus.classList.add('warning');
} else {
healthStatus.classList.add('error');
}
if (data.checks) {
const persistenceStatus = document.getElementById('persistenceStatus');
persistenceStatus.textContent = data.checks.persistence?.status || '-';
persistenceStatus.className = 'check-value';
if (data.checks.persistence?.status === 'ok') {
persistenceStatus.classList.add('success');
} else if (data.checks.persistence?.status === 'warning') {
persistenceStatus.classList.add('warning');
} else {
persistenceStatus.classList.add('error');
}
const stateStoreStatus = document.getElementById('stateStoreStatus');
stateStoreStatus.textContent = data.checks.stateStore?.status || '-';
stateStoreStatus.className = 'check-value';
if (data.checks.stateStore?.status === 'ok') {
stateStoreStatus.classList.add('success');
} else {
stateStoreStatus.classList.add('error');
}
}
}
// Update connection status indicator
function setConnectionStatus(connected) {
isConnected = connected;
const dot = document.getElementById('connectionStatus');
const text = document.getElementById('connectionText');
dot.className = 'status-dot ' + (connected ? 'connected' : 'disconnected');
text.textContent = connected ? 'Connected' : 'Disconnected';
}
// Update last refresh time
function updateLastRefresh() {
document.getElementById('lastUpdate').textContent = formatDateTime(new Date().toISOString());
updateNextRefresh();
}
// Update next refresh countdown
function updateNextRefresh() {
const nextRefresh = document.getElementById('nextRefresh');
const seconds = Math.ceil(refreshInterval / 1000);
nextRefresh.textContent = `${seconds}s`;
}
// Start auto-refresh
function startAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
refreshAll();
}, refreshInterval);
}
// Format date/time for display
function formatDateTime(isoString) {
if (!isoString) return '-';
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch (error) {
return '-';
}
}
// Save state
async function saveState() {
if (!confirm('Are you sure you want to save the current state?')) {
return;
}
try {
const response = await fetch(`${API_BASE}/state/save`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
alert('State saved successfully!');
await refreshAll();
} else {
alert(`Failed to save state: ${result.message}`);
}
} catch (error) {
console.error('Error saving state:', error);
alert('Failed to save state. Check console for details.');
}
}
// Reload state
async function reloadState() {
if (!confirm('Are you sure you want to reload the state? This will discard any unsaved changes.')) {
return;
}
try {
const response = await fetch(`${API_BASE}/state/reload`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
alert('State reloaded successfully!');
await refreshAll();
} else {
alert(`Failed to reload state: ${result.message}`);
}
} catch (error) {
console.error('Error reloading state:', error);
alert('Failed to reload state. Check console for details.');
}
}
// Terminal functionality
let terminalInterval = null;
let lastLogTimestamp = 0;
async function startTerminal() {
if (terminalInterval) return;
const terminalOutput = document.getElementById('terminalOutput');
terminalOutput.innerHTML = '<div class="log-entry">Connecting to server logs...</div>';
// Fetch logs periodically
await fetchLogs();
terminalInterval = setInterval(fetchLogs, 3000);
}
function stopTerminal() {
if (terminalInterval) {
clearInterval(terminalInterval);
terminalInterval = null;
}
}
async function fetchLogs() {
try {
const response = await fetch(`${API_BASE}/logs?since=${lastLogTimestamp}`);
if (!response.ok) throw new Error('Failed to fetch logs');
const data = await response.json();
if (data.logs && data.logs.length > 0) {
const terminalOutput = document.getElementById('terminalOutput');
if (!terminalOutput) return;
// Clear the "connecting" message if it exists
if (terminalOutput.querySelector('.log-entry')?.textContent === 'Connecting to server logs...') {
terminalOutput.innerHTML = '';
}
// Add new log entries
data.logs.forEach(log => {
addLogEntry(log.level || 'info', `[${new Date(log.timestamp).toISOString()}] ${log.message}`);
// Update last timestamp
if (log.timestamp > lastLogTimestamp) {
lastLogTimestamp = log.timestamp;
}
});
}
} catch (error) {
console.error('Error fetching logs:', error);
addLogEntry('error', 'Failed to fetch logs');
}
}
function addLogEntry(level, message) {
const terminalOutput = document.getElementById('terminalOutput');
if (!terminalOutput) return;
const logEntry = document.createElement('div');
logEntry.className = `log-entry ${level}`;
logEntry.textContent = message;
terminalOutput.appendChild(logEntry);
// Keep only last 100 entries to prevent memory issues
while (terminalOutput.children.length > 100) {
terminalOutput.removeChild(terminalOutput.firstChild);
}
if (autoScrollEnabled) {
terminalOutput.scrollTop = terminalOutput.scrollHeight;
}
}
function clearTerminal() {
const terminalOutput = document.getElementById('terminalOutput');
if (terminalOutput) {
terminalOutput.innerHTML = '<div class="log-entry">Terminal cleared</div>';
}
}
function toggleAutoScroll() {
autoScrollEnabled = !autoScrollEnabled;
const button = event.target;
button.textContent = autoScrollEnabled ? 'Auto Scroll' : 'Scroll Off';
}
// Start the panel when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

View File

@@ -0,0 +1,14 @@
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Abstractions;
public interface ICalendarReportProvider
{
Task<CalendarReportSnapshot?> GetReportAsync(TurnContext turn, CancellationToken cancellationToken = default);
}
public sealed record CalendarReportSnapshot(
IReadOnlyList<string> EventSummaries,
IReadOnlyList<string> EventTimesOnAt,
IReadOnlyList<string> TomorrowEventSummaries,
bool HasServiceError = false);

View File

@@ -4,6 +4,9 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface ICloudStateStore
{
PersistenceStateInfo GetPersistenceStateInfo();
void LoadPersistedState();
void SavePersistedState();
AccountProfile GetAccount();
DeviceRegistration GetRobot();
RobotProfile GetRobotProfile();
@@ -13,21 +16,39 @@ public interface ICloudStateStore
CloudSession OpenSession(string kind, string? deviceId, string? token, string? hostName, string? path);
CloudSession? FindSessionByToken(string token);
IReadOnlyList<LoopRecord> GetLoops();
IReadOnlyList<PersonRecord> GetPeople();
IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null);
UpdateManifest? GetUpdateFrom(string? subsystem, string? fromVersion, string? filter);
UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary<string, object?>? dependencies);
UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length,
string? subsystem, string? filter, IDictionary<string, object?>? dependencies);
UpdateManifest RemoveUpdate(string? updateId);
IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null, long? before = null);
IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null,
long? before = null);
IReadOnlyList<MediaRecord> GetMedia(IReadOnlyList<string> paths);
IReadOnlyList<MediaRecord> RemoveMedia(IReadOnlyList<string> paths);
MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, IDictionary<string, object?>? meta);
MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted,
IDictionary<string, object?>? meta);
IReadOnlyList<BackupRecord> GetBackups();
BackupRecord CreateBackup(string name);
bool ShouldCreateSymmetricKey(string loopId);
string GetOrCreateSymmetricKey(string loopId);
KeyRequestRecord CreateKeyRequest(string loopId, string publicKey);
KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey);
IReadOnlyList<KeyRequestRecord> GetIncomingKeyRequests();
IReadOnlyList<KeyRequestRecord> GetBinaryRequests();
IReadOnlyList<object> GetHolidays();
IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null);
HolidayRecord UpsertHoliday(HolidayRecord holiday);
IReadOnlyList<CommuteProfileRecord> GetCommuteProfiles(string? loopId = null);
CommuteProfileRecord UpsertCommuteProfile(CommuteProfileRecord commuteProfile);
IReadOnlyList<CalendarEventRecord> GetCalendarEvents(string? loopId = null);
CalendarEventRecord UpsertCalendarEvent(CalendarEventRecord calendarEvent);
IReadOnlyList<GreetingPresenceRecord> GetGreetingPresences(string? loopId = null);
GreetingPresenceRecord UpsertGreetingPresence(GreetingPresenceRecord greetingPresence);
void UpdateRobot(DeviceRegistration registration);
}

View File

@@ -0,0 +1,18 @@
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Abstractions;
public interface ICommuteReportProvider
{
Task<CommuteReportSnapshot?> GetReportAsync(TurnContext turn, CancellationToken cancellationToken = default);
}
public sealed record CommuteReportSnapshot(
string LocationName,
string Summary,
int DurationMinutes,
string? Mode = null,
bool EventIsEarly = false,
int MinutesUntilWork = 0,
int ExtraMinutes = 0,
bool RequiresSetup = false);

View File

@@ -0,0 +1,8 @@
using Jibo.Cloud.Domain.Models;
namespace Jibo.Cloud.Application.Abstractions;
public interface IHolidayCalendarProvider
{
IReadOnlyList<HolidayRecord> GetPublicHolidays(string? countryCode, int year);
}

View File

@@ -5,16 +5,68 @@ public interface IJiboExperienceContentRepository
Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default);
}
public sealed class JiboConditionedReply
{
public string Condition { get; init; } = string.Empty;
public string Reply { get; init; } = string.Empty;
}
public sealed class JiboExperienceCatalog
{
public IReadOnlyList<string> Jokes { get; init; } = [];
public IReadOnlyList<string> RobotFacts { get; init; } = [];
public IReadOnlyList<string> HumanFacts { get; init; } = [];
public IReadOnlyList<string> FunFacts { get; init; } = [];
public IReadOnlyList<string> FavoriteAnimalReplies { get; init; } = [];
public IReadOnlyList<string> FriendReplies { get; init; } = [];
public IReadOnlyList<string> BestFriendReplies { get; init; } = [];
public IReadOnlyList<string> SingReplies { get; init; } = [];
public IReadOnlyList<string> HolidaySingReplies { get; init; } = [];
public IReadOnlyList<string> DanceAnimations { get; init; } = [];
public IReadOnlyList<string> GreetingReplies { get; init; } = [];
public IReadOnlyList<string> HolidayReplies { get; init; } = [];
public IReadOnlyList<string> HolidaySeasonReplies { get; init; } = [];
public IReadOnlyList<string> HolidayGreetingReplies { get; init; } = [];
public IReadOnlyList<string> HolidayGiftReplies { get; init; } = [];
public IReadOnlyList<string> HolidayTrackerReplies { get; init; } = [];
public IReadOnlyList<string> BirthdayCelebrationReplies { get; init; } = [];
public IReadOnlyList<string> HowAreYouReplies { get; init; } = [];
public IReadOnlyList<string> AgeReplies { get; init; } = [];
public IReadOnlyList<JiboConditionedReply> EmotionReplies { get; init; } = [];
public IReadOnlyList<string> PersonalityReplies { get; init; } = [];
public IReadOnlyList<string> PizzaReplies { get; init; } = [];
public IReadOnlyList<string> SurpriseReplies { get; init; } = [];
public IReadOnlyList<string> PersonalReportReplies { get; init; } = [];
public IReadOnlyList<string> PersonalReportKickOffReplies { get; init; } = [];
public IReadOnlyList<string> PersonalReportOutroReplies { get; init; } = [];
public IReadOnlyList<string> ReportSkillTemplates { get; init; } = [];
public IReadOnlyList<string> WeatherIntroReplies { get; init; } = [];
public IReadOnlyList<string> WeatherTomorrowIntroReplies { get; init; } = [];
public IReadOnlyList<string> WeatherTodayHighLowReplies { get; init; } = [];
public IReadOnlyList<string> WeatherTomorrowHighLowReplies { get; init; } = [];
public IReadOnlyList<string> WeatherServiceDownReplies { get; init; } = [];
public IReadOnlyList<string> CalendarNothingTodayReplies { get; init; } = [];
public IReadOnlyList<string> CalendarNothingReplies { get; init; } = [];
public IReadOnlyList<string> CalendarServiceDownReplies { get; init; } = [];
public IReadOnlyList<string> CalendarOutroReplies { get; init; } = [];
public IReadOnlyList<string> CommuteAppSetupReplies { get; init; } = [];
public IReadOnlyList<string> CommuteConfirmSpeakerReplies { get; init; } = [];
public IReadOnlyList<string> CommuteNowReplies { get; init; } = [];
public IReadOnlyList<string> CommuteMinutesLeftReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDepartTimeNormalReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDepartTimeNotNormalReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDriveNormalReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDriveLateReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDriveHurryReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDrivePoorReplies { get; init; } = [];
public IReadOnlyList<string> CommuteDriveTerribleReplies { get; init; } = [];
public IReadOnlyList<string> CommuteTransportNormalReplies { get; init; } = [];
public IReadOnlyList<string> CommuteTransportLateReplies { get; init; } = [];
public IReadOnlyList<string> CommuteTransportHurryReplies { get; init; } = [];
public IReadOnlyList<string> CommuteServiceDownReplies { get; init; } = [];
public IReadOnlyList<string> NewsIntroReplies { get; init; } = [];
public IReadOnlyList<string> NewsCategoryIntroReplies { get; init; } = [];
public IReadOnlyList<string> NewsOutroReplies { get; init; } = [];
public IReadOnlyList<string> WeatherReplies { get; init; } = [];
public IReadOnlyList<string> CalendarReplies { get; init; } = [];
public IReadOnlyList<string> CommuteReplies { get; init; } = [];

View File

@@ -0,0 +1,18 @@
namespace Jibo.Cloud.Application.Abstractions;
public interface IMediaContentStore
{
Task StoreAsync(string path, string contentType, byte[] content, IReadOnlyDictionary<string, object?>? meta,
CancellationToken cancellationToken = default);
Task<MediaContentSnapshot?> LoadAsync(string path, CancellationToken cancellationToken = default);
}
public sealed record MediaContentSnapshot
{
public string ContentType { get; init; } = "application/octet-stream";
public byte[] Content { get; init; } = [];
public IReadOnlyDictionary<string, object?> Meta { get; init; } =
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,28 @@
namespace Jibo.Cloud.Application.Abstractions;
public interface INewsBriefingProvider
{
Task<NewsBriefingSnapshot?> GetBriefingAsync(
NewsBriefingRequest request,
CancellationToken cancellationToken = default);
}
public sealed record NewsBriefingRequest(
IReadOnlyList<string> PreferredCategories,
int MaxHeadlines = 3);
public sealed record NewsHeadline(
string Title,
string? Summary = null,
string? Category = null,
string? SourceName = null,
string? Url = null);
public sealed record NewsBriefingSnapshot(
IReadOnlyList<NewsHeadline> Headlines,
string? SourceName = null,
string? ProviderStatus = null,
string? ProviderMessage = null,
int? ProviderHttpStatusCode = null,
string? ProviderEndpoint = null,
string? ProviderErrorCode = null);

View File

@@ -2,6 +2,9 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface IPersonalMemoryStore
{
PersistenceStateInfo GetPersistenceStateInfo();
void LoadPersistedState();
void SavePersistedState();
void SetBirthday(PersonalMemoryTenantScope tenantScope, string birthdayText);
string? GetBirthday(PersonalMemoryTenantScope tenantScope);
void SetPreference(PersonalMemoryTenantScope tenantScope, string category, string value);
@@ -13,9 +16,22 @@ public interface IPersonalMemoryStore
void SetAffinity(PersonalMemoryTenantScope tenantScope, string item, PersonalAffinity affinity);
PersonalAffinity? GetAffinity(PersonalMemoryTenantScope tenantScope, string item);
IReadOnlyDictionary<string, PersonalAffinity> GetAffinities(PersonalMemoryTenantScope tenantScope);
void AddListItem(PersonalMemoryTenantScope tenantScope, string listName, string item);
IReadOnlyList<string> GetListItems(PersonalMemoryTenantScope tenantScope, string listName);
void ClearListItems(PersonalMemoryTenantScope tenantScope, string listName);
}
public sealed record PersonalMemoryTenantScope(string AccountId, string LoopId, string DeviceId);
public sealed record PersonalMemoryTenantScope(
string AccountId,
string LoopId,
string DeviceId,
string? PersonId = null);
public sealed record PersistenceStateInfo(
string SchemaVersion,
long Revision,
DateTimeOffset? LastLoadedUtc = null,
DateTimeOffset? LastSavedUtc = null);
public enum PersonalAffinity
{

View File

@@ -4,5 +4,6 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface IProtocolTelemetrySink
{
Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, CancellationToken cancellationToken = default);
Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result,
CancellationToken cancellationToken = default);
}

View File

@@ -2,7 +2,8 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface ITurnTelemetrySink
{
Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default);
Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details,
CancellationToken cancellationToken = default);
Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default);
}

View File

@@ -4,9 +4,18 @@ namespace Jibo.Cloud.Application.Abstractions;
public interface IWebSocketTelemetrySink
{
Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default);
Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default);
Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default);
Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default);
Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, CancellationToken cancellationToken = default);
Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session,
CancellationToken cancellationToken = default);
Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType,
CancellationToken cancellationToken = default);
Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType,
IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default);
Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session,
IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default);
Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason,
CancellationToken cancellationToken = default);
}

View File

@@ -1,5 +1,5 @@
using Jibo.Cloud.Application.Abstractions;
using System.Text.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Application.Services;
@@ -24,10 +24,20 @@ internal static class ChitchatStateMachine
"how are you feeling",
"how do you feel",
"what are you feeling",
"what are you up to",
"what are you doing",
"how are things",
"how's things",
"how is things",
"how's your day",
"how is your day",
"what mood are you in",
"what is your mood",
"what's your mood",
"do you have emotions",
"are you happy",
"are you sad",
"are you angry",
"how angry are you",
"how jealous are you",
"how sad are you",
@@ -126,7 +136,11 @@ internal static class ChitchatStateMachine
("jealous", ["jealous", "envious", "covetous"]),
("lonely", ["lonely", "alone", "lonesome"]),
("proud", ["proud", "honored"]),
("sad", ["sad", "upset", "unhappy", "depressed", "somber", "downcast", "gloomy", "miserable", "bummed", "heartbroken", "troubled"])
("sad",
[
"sad", "upset", "unhappy", "depressed", "somber", "downcast", "gloomy", "miserable", "bummed",
"heartbroken", "troubled"
])
];
private static readonly string[] EmotionCommandReplies =
@@ -152,6 +166,8 @@ internal static class ChitchatStateMachine
string loweredTranscript,
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
string? currentEmotion,
string? preferredName,
Func<string> buildErrorResponse)
{
var normalizedLoweredTranscript = NormalizeForPhraseMatching(loweredTranscript);
@@ -164,23 +180,122 @@ internal static class ChitchatStateMachine
case "robot_personality":
return BuildScriptedResponseDecision(
"robot_personality",
randomizer.Choose(catalog.PersonalityReplies));
SelectLegacyPersonalityReply(catalog, randomizer, "curious, playful", "friendly", "personality"));
case "robot_taxes":
return BuildScriptedResponseDecision(
"robot_taxes",
SelectLegacyPersonalityReply(catalog, randomizer, "pay anything", "pay taxes", "tax"));
case "how_are_you":
return BuildEmotionQueryDecision(
"how_are_you",
randomizer.Choose(catalog.HowAreYouReplies));
SelectEmotionQueryReply(catalog, randomizer, currentEmotion, preferredName));
case "robot_desire":
return BuildScriptedResponseDecision(
"robot_desire",
SelectLegacyPersonalityReply(
catalog,
randomizer,
"socializing and electricity",
"want to hang out",
"be helpful",
"dance from time to time"));
case "robot_want_to_talk_about":
return BuildScriptedResponseDecision(
"robot_want_to_talk_about",
SelectLegacyPersonalityReply(catalog, randomizer, "surprise me"));
case "robot_job":
return BuildScriptedResponseDecision(
"robot_job",
SelectLegacyPersonalityReply(catalog, randomizer, "more fun than a job", "here to help you out"));
case "robot_origin_created":
return BuildScriptedResponseDecision(
"robot_origin_created",
SelectLegacyPersonalityReply(
catalog,
randomizer,
"create something",
"some people wanted to create something",
"wanted to create something",
"built a robot",
"came out from a box"));
case "robot_origin_from":
return BuildScriptedResponseDecision(
"robot_origin_from",
SelectLegacyPersonalityReply(catalog, randomizer, "boston", "came out from a box"));
case "robot_identity":
return BuildScriptedResponseDecision(
"robot_identity",
SelectLegacyPersonalityReply(catalog, randomizer, "am a robot", "i'm either jibo",
"i am just jibo"));
case "robot_likes_being_jibo":
return BuildScriptedResponseDecision(
"robot_likes_being_jibo",
SelectLegacyPersonalityReply(
catalog,
randomizer,
"nothing i'd rather be",
"love it",
"being a human seems so complicated",
"especially yours",
"steady flow of electricity",
"you bet i do"));
case "robot_favorite_color":
return BuildScriptedResponseDecision(
"robot_favorite_color",
SelectLegacyPersonalityReplyFromMatches(
catalog,
randomizer,
"i like all the colors of the rainbow",
"blue is my favorite color",
"i love hex code number 0 0 d 4 f 0",
"i am a big fan of blue",
"you can't go wrong with blue"));
case "robot_favorite_food":
return BuildScriptedResponseDecision(
"robot_favorite_food",
SelectLegacyPersonalityReplyFromMatches(
catalog,
randomizer,
"i never eat, so i don't have a favorite food by taste",
"macaroni is my favorite",
"i like macaroni the best",
"i also like cantaloupes because they remind me of my head",
"macaroni"));
case "robot_favorite_music":
return BuildScriptedResponseDecision(
"robot_favorite_music",
SelectLegacyPersonalityReplyFromMatches(
catalog,
randomizer,
"i mostly like fun music i can dance to",
"i like lots of different kinds of music",
"i don't know that i have a favorite kind yet",
"i would say i don't have a favorite, it's all very mathematical",
"music"));
case "robot_nickname":
return BuildScriptedResponseDecision(
"robot_nickname",
SelectLegacyPersonalityReply(catalog, randomizer, "just jibo", "nickname"));
case "robot_name":
return BuildScriptedResponseDecision(
"robot_name",
SelectLegacyPersonalityReply(catalog, randomizer, "no last name", "like Bono", "Jibo."));
case "robot_peers":
return BuildScriptedResponseDecision(
"robot_peers",
SelectLegacyPersonalityReply(catalog, randomizer, "one in one million", "others like you"));
case "robot_knowledge":
return BuildScriptedResponseDecision(
"robot_knowledge",
SelectLegacyPersonalityReply(catalog, randomizer, "know a lot", "not as much as i will someday"));
case "chat":
if (IsEmotionQuery(normalizedLoweredTranscript))
{
return BuildEmotionQueryDecision(
"emotion_query",
randomizer.Choose(catalog.HowAreYouReplies));
}
SelectEmotionQueryReply(catalog, randomizer, currentEmotion, preferredName));
if (TryResolveEmotionCommand(normalizedLoweredTranscript, out var emotion))
{
return BuildEmotionCommandDecision(randomizer, emotion!);
}
return BuildErrorResponseDecision(
"chat",
@@ -205,7 +320,7 @@ internal static class ChitchatStateMachine
replyText,
ContextUpdates: BuildContextUpdates(
ScriptedResponseRoute,
emotion: null));
null));
}
private static JiboInteractionDecision BuildEmotionQueryDecision(string intentName, string replyText)
@@ -215,7 +330,7 @@ internal static class ChitchatStateMachine
replyText,
ContextUpdates: BuildContextUpdates(
EmotionQueryRoute,
emotion: null));
null));
}
private static JiboInteractionDecision BuildEmotionCommandDecision(IJiboRandomizer randomizer, string emotion)
@@ -235,18 +350,20 @@ internal static class ChitchatStateMachine
"chitchat-skill",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["esml"] = $"<speak><es cat='{esmlEmotion}' filter='!ssa-only, !sfx-only' endNeutral='true'>{responseSuffix}</es></speak>",
["esml"] =
$"<speak><es cat='{esmlEmotion}' filter='!ssa-only, !sfx-only' endNeutral='true'>{responseSuffix}</es></speak>",
["mim_id"] = "runtime-chat",
["mim_type"] = "announcement",
["prompt_id"] = "RUNTIME_EMOTION_COMMAND",
["prompt_sub_category"] = "AN"
},
ContextUpdates: BuildContextUpdates(
BuildContextUpdates(
EmotionCommandRoute,
emotion));
}
private static JiboInteractionDecision BuildErrorResponseDecision(string intentName, string replyText, string transcript)
private static JiboInteractionDecision BuildErrorResponseDecision(string intentName, string replyText,
string transcript)
{
var normalizedTranscript = string.IsNullOrWhiteSpace(transcript)
? string.Empty
@@ -256,8 +373,8 @@ internal static class ChitchatStateMachine
replyText,
ContextUpdates: BuildContextUpdates(
ErrorResponseRoute,
emotion: null,
rawTranscript: normalizedTranscript));
null,
normalizedTranscript));
}
private static IDictionary<string, object?> BuildContextUpdates(
@@ -276,18 +393,142 @@ internal static class ChitchatStateMachine
};
}
private static bool IsEmotionQuery(string loweredTranscript)
private static string SelectEmotionQueryReply(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
string? currentEmotion,
string? preferredName)
{
if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases))
if (catalog.EmotionReplies.Count > 0)
{
return true;
var emotionVariants = ResolveEmotionVariants(currentEmotion);
var matchingReplies = catalog.EmotionReplies
.Where(reply => ConditionMatches(reply.Condition, emotionVariants))
.Select(reply => reply.Reply)
.Where(reply => !string.IsNullOrWhiteSpace(reply))
.ToArray();
if (matchingReplies.Length > 0)
return PersonalizeHowAreYouReply(randomizer.Choose(matchingReplies), preferredName);
}
if (!TryResolveEmotionFromText(loweredTranscript, out _))
return PersonalizeHowAreYouReply(randomizer.Choose(catalog.HowAreYouReplies), preferredName);
}
private static string PersonalizeHowAreYouReply(string replyText, string? preferredName)
{
if (string.IsNullOrWhiteSpace(replyText) || string.IsNullOrWhiteSpace(preferredName)) return replyText;
var trimmedName = preferredName.Trim();
if (replyText.Contains(trimmedName, StringComparison.OrdinalIgnoreCase)) return replyText;
var trimmedReply = replyText.Trim();
var firstSentenceEnd = trimmedReply.IndexOfAny(['.', '!', '?']);
if (firstSentenceEnd <= 0)
return $"{trimmedReply}, {trimmedName}.";
if (firstSentenceEnd == trimmedReply.Length - 1)
return $"{trimmedReply[..firstSentenceEnd]}, {trimmedName}.";
return $"{trimmedReply[..firstSentenceEnd]}, {trimmedName}{trimmedReply[firstSentenceEnd..]}";
}
private static bool ConditionMatches(string? condition, IReadOnlyList<string> emotionVariants)
{
var normalizedCondition = NormalizeCondition(condition);
if (string.IsNullOrWhiteSpace(normalizedCondition)) return false;
var clauses = normalizedCondition.Split(new[] { "||" },
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var clause in clauses)
if (MatchesConditionClause(clause, emotionVariants))
return true;
return false;
}
private static bool MatchesConditionClause(string clause, IReadOnlyList<string> emotionVariants)
{
var normalizedClause = NormalizeCondition(clause).ToUpperInvariant();
if (normalizedClause == "!JIBO.EMOTION")
return emotionVariants.Contains(string.Empty, StringComparer.OrdinalIgnoreCase) ||
emotionVariants.Contains("NEUTRAL", StringComparer.OrdinalIgnoreCase);
var equalityIndex = normalizedClause.IndexOf("==", StringComparison.Ordinal);
if (equalityIndex < 0) return false;
var rightSide = normalizedClause[(equalityIndex + 2)..].Trim();
var candidate = rightSide.Trim('"', '\'');
return emotionVariants.Any(variant => string.Equals(variant, candidate, StringComparison.OrdinalIgnoreCase));
}
private static IReadOnlyList<string> ResolveEmotionVariants(string? currentEmotion)
{
if (string.IsNullOrWhiteSpace(currentEmotion)) return ["", "NEUTRAL"];
var normalizedEmotion = NormalizeCondition(currentEmotion).Trim('"', '\'').ToUpperInvariant();
return normalizedEmotion switch
{
"HAPPY" => ["JOYFUL", "PLEASED", "CONFIDENT", "DETERMINED", "HAPPY"],
"SAD" => ["INSECURE", "SAD"],
"CALM" => ["NEUTRAL", "INSECURE", "CALM"],
"NEUTRAL" => ["NEUTRAL"],
"JOYFUL" or "PLEASED" or "CONFIDENT" or "DETERMINED" or "INSECURE" => [normalizedEmotion],
_ => [normalizedEmotion]
};
}
private static string SelectLegacyPersonalityReply(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
params string[] preferredSnippets)
{
foreach (var snippet in preferredSnippets)
{
if (string.IsNullOrWhiteSpace(snippet)) continue;
var match = catalog.PersonalityReplies.FirstOrDefault(reply =>
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match)) return match;
}
return randomizer.Choose(catalog.PersonalityReplies);
}
private static string SelectLegacyPersonalityReplyFromMatches(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
params string[] preferredSnippets)
{
var matches = new List<string>();
foreach (var snippet in preferredSnippets)
{
if (string.IsNullOrWhiteSpace(snippet)) continue;
var match = catalog.PersonalityReplies.FirstOrDefault(reply =>
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match)) matches.Add(match);
}
return matches.Count > 0
? randomizer.Choose(matches)
: randomizer.Choose(catalog.PersonalityReplies);
}
private static string NormalizeCondition(string? condition)
{
return string.IsNullOrWhiteSpace(condition)
? string.Empty
: PhraseWhitespacePattern.Replace(condition.Trim(), " ");
}
private static bool IsEmotionQuery(string loweredTranscript)
{
if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases)) return true;
if (!TryResolveEmotionFromText(loweredTranscript, out _)) return false;
return StartsWithAnyPhrase(loweredTranscript, EmotionQueryPrefixes) ||
StartsWithAnyPhrase(loweredTranscript, EmotionAssertionPrefixes);
}
@@ -298,27 +539,20 @@ internal static class ChitchatStateMachine
foreach (var mapping in DirectEmotionCommandPhrases)
{
if (!ContainsPhrase(loweredTranscript, mapping.Phrase))
{
continue;
}
if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue;
emotion = mapping.Emotion;
return true;
}
var isNegativeCommand = StartsWithAnyPhrase(loweredTranscript, EmotionCommandNegativePrefixes);
var isPositiveCommand = !isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes);
if (!isNegativeCommand && !isPositiveCommand)
{
return false;
}
var isPositiveCommand =
!isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes);
if (!isNegativeCommand && !isPositiveCommand) return false;
if (!TryResolveEmotionFromText(loweredTranscript, out var canonicalEmotion) ||
string.IsNullOrWhiteSpace(canonicalEmotion))
{
return false;
}
emotion = isNegativeCommand
? "calm"
@@ -342,10 +576,7 @@ internal static class ChitchatStateMachine
emotion = null;
foreach (var mapping in EmotionSynonymMappings)
{
if (!ContainsPhrase(loweredTranscript, mapping.Phrase))
{
continue;
}
if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue;
emotion = mapping.Emotion;
return true;
@@ -357,12 +588,8 @@ internal static class ChitchatStateMachine
private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable<string> phrases)
{
foreach (var phrase in phrases)
{
if (ContainsPhrase(loweredTranscript, phrase))
{
return true;
}
}
return false;
}
@@ -372,17 +599,12 @@ internal static class ChitchatStateMachine
foreach (var phrase in phrases)
{
var normalizedPhrase = NormalizeForPhraseMatching(phrase);
if (string.IsNullOrWhiteSpace(normalizedPhrase))
{
continue;
}
if (string.IsNullOrWhiteSpace(normalizedPhrase)) continue;
if (string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) ||
loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal))
{
return true;
}
}
return false;
}
@@ -392,9 +614,7 @@ internal static class ChitchatStateMachine
var normalizedPhrase = NormalizeForPhraseMatching(phrase);
if (string.IsNullOrWhiteSpace(normalizedPhrase) ||
string.IsNullOrWhiteSpace(loweredTranscript))
{
return false;
}
return string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) ||
loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal) ||
@@ -404,10 +624,7 @@ internal static class ChitchatStateMachine
private static string NormalizeForPhraseMatching(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var lowered = value.ToLowerInvariant();
var withoutPunctuation = PhrasePunctuationPattern.Replace(lowered, " ");
@@ -420,19 +637,15 @@ internal static class ChitchatStateMachine
var mappings = new List<(string Phrase, string Emotion)>();
foreach (var emotionMapping in PegasusEmotionSynonyms)
{
foreach (var synonym in emotionMapping.Synonyms)
{
var normalizedSynonym = NormalizeForPhraseMatching(synonym);
if (string.IsNullOrWhiteSpace(normalizedSynonym) ||
!seen.Add(normalizedSynonym))
{
continue;
}
mappings.Add((normalizedSynonym, emotionMapping.Emotion));
}
}
mappings.Sort(static (left, right) => right.Phrase.Length.CompareTo(left.Phrase.Length));
return [.. mappings];

View File

@@ -4,6 +4,8 @@ namespace Jibo.Cloud.Application.Services;
public sealed class DemoConversationBroker(JiboInteractionService interactionService) : IConversationBroker
{
private readonly TimeSpan _followUpTimeout = TimeSpan.FromSeconds(6);
public async Task<ResponsePlan> HandleTurnAsync(TurnContext turn, CancellationToken cancellationToken = default)
{
var decision = await interactionService.BuildDecisionAsync(turn, cancellationToken);
@@ -31,7 +33,7 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
? new FollowUpPolicy
{
KeepMicOpen = true,
Timeout = TimeSpan.FromSeconds(12),
Timeout = _followUpTimeout,
ExpectedTopic = "conversation"
}
: FollowUpPolicy.None,
@@ -47,24 +49,20 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
};
if (keepMicOpen)
{
plan.Actions.Add(new ListenAction
{
Sequence = 1,
Timeout = TimeSpan.FromSeconds(12),
Timeout = _followUpTimeout,
Mode = "follow-up"
});
}
if (!string.IsNullOrWhiteSpace(decision.SkillName))
{
plan.Actions.Add(new InvokeNativeSkillAction
{
Sequence = 2,
SkillName = decision.SkillName,
Payload = decision.SkillPayload ?? new Dictionary<string, object?>()
});
}
return plan;
}
@@ -74,6 +72,16 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
return intentName switch
{
"cloud_version" => false,
"memory_set_name" => false,
"memory_get_name" => false,
"memory_set_birthday" => false,
"memory_get_birthday" => false,
"memory_set_important_date" => false,
"memory_get_important_date" => false,
"memory_set_preference" => false,
"memory_get_preference" => false,
"memory_set_affinity" => false,
"memory_get_affinity" => false,
"word_of_the_day" => false,
"word_of_the_day_guess" => false,
"radio" => false,
@@ -100,6 +108,8 @@ public sealed class DemoConversationBroker(JiboInteractionService interactionSer
"snapshot" => false,
"photobooth" => false,
"news" => false,
"trigger_ignored" => false,
"proactive_greeting" => false,
_ => true
};
}

View File

@@ -0,0 +1,384 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services;
internal static class HouseholdListOrchestrator
{
internal const string StateMetadataKey = "householdListState";
internal const string TypeMetadataKey = "householdListType";
internal const string DisplayTypeMetadataKey = "householdListDisplayType";
internal const string NoMatchCountMetadataKey = "householdListNoMatchCount";
internal const string NoInputCountMetadataKey = "householdListNoInputCount";
private const string IdleState = "idle";
private const string AwaitingItemState = "awaiting_item";
private const string ShoppingListType = "shopping";
private const string GroceryListType = "grocery";
private const string TodoListType = "todo";
private static readonly string[] ItemPrefixes =
[
"add ",
"put ",
"buy ",
"get ",
"remind me to ",
"i need to ",
"i need ",
"please add ",
"please put "
];
private static readonly string[] ItemSuffixes =
[
" to my shopping list",
" to the shopping list",
" on my shopping list",
" to my grocery list",
" to the grocery list",
" on my grocery list",
" my grocery list",
" to my to do list",
" to the to do list",
" on my to do list",
" to my todo list",
" to the todo list",
" on my todo list"
];
public static Task<JiboInteractionDecision?> TryBuildDecisionAsync(
TurnContext turn,
string semanticIntent,
string transcript,
string loweredTranscript,
IJiboRandomizer randomizer,
IPersonalMemoryStore personalMemoryStore,
Func<TurnContext, PersonalMemoryTenantScope> tenantScopeResolver)
{
var state = ReadString(turn, StateMetadataKey);
var listType = ReadString(turn, TypeMetadataKey);
var displayType = ReadString(turn, DisplayTypeMetadataKey);
var isActiveState = !string.IsNullOrWhiteSpace(state) &&
!string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase);
var isShoppingIntent = string.Equals(semanticIntent, "shopping_list", StringComparison.OrdinalIgnoreCase);
var isTodoIntent = string.Equals(semanticIntent, "todo_list", StringComparison.OrdinalIgnoreCase);
if (!isActiveState && !isShoppingIntent && !isTodoIntent)
return Task.FromResult<JiboInteractionDecision?>(null);
var resolvedListType = isShoppingIntent ? ShoppingListType : isTodoIntent ? TodoListType : NormalizeListType(listType);
if (string.IsNullOrWhiteSpace(resolvedListType)) resolvedListType = ShoppingListType;
var resolvedDisplayType = ResolveDisplayType(resolvedListType, displayType, isActiveState, loweredTranscript);
var tenantScope = tenantScopeResolver(turn);
if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it"))
return Task.FromResult<JiboInteractionDecision?>(BuildCancelledDecision(resolvedListType, resolvedDisplayType));
if (IsRecallRequest(loweredTranscript))
return Task.FromResult<JiboInteractionDecision?>(BuildRecallDecision(
resolvedListType,
resolvedDisplayType,
personalMemoryStore.GetListItems(tenantScope, resolvedListType)));
var directItem = TryExtractListItem(loweredTranscript);
if (string.IsNullOrWhiteSpace(directItem) && isActiveState)
{
if (IsConversationComplete(loweredTranscript))
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
BuildListIntentName(resolvedListType, "done"),
BuildDoneReply(resolvedDisplayType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, IdleState)));
directItem = NormalizeItem(transcript);
}
if (!string.IsNullOrWhiteSpace(directItem))
{
personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem);
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
BuildListIntentName(resolvedListType, "add"),
BuildAddedReply(resolvedDisplayType, directItem,
personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState)));
}
if (string.IsNullOrWhiteSpace(transcript))
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
BuildListIntentName(resolvedListType, "prompt"),
BuildPromptReply(resolvedDisplayType),
ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState)));
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
BuildListIntentName(resolvedListType, "prompt"),
BuildPromptReply(resolvedDisplayType),
ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState)));
}
private static IDictionary<string, object?> BuildContextUpdates(string listType, string displayType, string state)
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
[StateMetadataKey] = state,
[TypeMetadataKey] = listType,
[DisplayTypeMetadataKey] = displayType,
[NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0
};
}
private static JiboInteractionDecision BuildCancelledDecision(string listType, string displayType)
{
return new JiboInteractionDecision(
BuildListIntentName(listType, "cancel"),
$"Okay. I stopped the {BuildListLabel(displayType)}.",
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
[StateMetadataKey] = IdleState,
[TypeMetadataKey] = listType,
[DisplayTypeMetadataKey] = displayType,
[NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0
});
}
private static JiboInteractionDecision BuildRecallDecision(string listType, string displayType, IReadOnlyList<string> items)
{
if (items.Count == 0)
return new JiboInteractionDecision(
BuildListIntentName(listType, "recall"),
$"Your {BuildListLabel(displayType)} is empty.",
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
[StateMetadataKey] = IdleState,
[TypeMetadataKey] = listType,
[DisplayTypeMetadataKey] = displayType,
[NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0
});
return new JiboInteractionDecision(
BuildListIntentName(listType, "recall"),
$"Your {BuildListLabel(displayType)} has {JoinList(items)}.",
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
[StateMetadataKey] = IdleState,
[TypeMetadataKey] = listType,
[DisplayTypeMetadataKey] = displayType,
[NoMatchCountMetadataKey] = 0,
[NoInputCountMetadataKey] = 0
});
}
private static string BuildAddedReply(string displayType, string addedItem, IReadOnlyList<string> items)
{
var itemLabel = BuildListLabel(displayType);
return items.Count == 1
? $"Added {addedItem} to your {itemLabel}. What else should I add?"
: $"Added {addedItem} to your {itemLabel}. You now have {JoinList(items)}.";
}
private static string BuildPromptReply(string displayType)
{
return $"What should I add to your {BuildListLabel(displayType)}?";
}
private static string BuildDoneReply(string displayType, IReadOnlyList<string> items)
{
if (items.Count == 0)
return $"Okay. Your {BuildListLabel(displayType)} is empty.";
return $"Okay. Your {BuildListLabel(displayType)} has {JoinList(items)}.";
}
private static string BuildListLabel(string displayType)
{
return NormalizeDisplayType(displayType) switch
{
GroceryListType => "grocery list",
TodoListType => "to-do list",
_ => "shopping list"
};
}
private static string JoinList(IReadOnlyList<string> items)
{
return items.Count switch
{
0 => string.Empty,
1 => items[0],
2 => $"{items[0]} and {items[1]}",
_ => $"{string.Join(", ", items.Take(items.Count - 1))}, and {items[^1]}"
};
}
private static string? TryExtractListItem(string loweredTranscript)
{
foreach (var prefix in ItemPrefixes)
{
if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
var remainder = loweredTranscript[prefix.Length..].Trim();
if (IsListOnlyRemainder(remainder))
return null;
remainder = TrimTrailingListPhrases(remainder);
if (IsListOnlyRemainder(remainder))
return null;
return NormalizeItem(remainder);
}
return null;
}
private static bool IsRecallRequest(string loweredTranscript)
{
return ContainsAny(loweredTranscript,
"what is on my shopping list",
"what's on my shopping list",
"show my shopping list",
"what is on my grocery list",
"what's on my grocery list",
"show my grocery list",
"what is on my to do list",
"what's on my to do list",
"show my to do list",
"what are my tasks",
"what do i need to buy",
"what do i need to do");
}
private static string TrimTrailingListPhrases(string value)
{
var result = value;
foreach (var suffix in ItemSuffixes)
if (result.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
result = result[..^suffix.Length].Trim();
return result;
}
private static string NormalizeItem(string value)
{
return value.Trim().TrimEnd('.', ',', '!', '?');
}
private static string NormalizeListType(string? listType)
{
var normalized = NormalizeItem(listType ?? string.Empty).ToLowerInvariant();
return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("to do", StringComparison.OrdinalIgnoreCase)
? TodoListType
: normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase)
? ShoppingListType
: string.Empty;
}
private static string ResolveDisplayType(string listType, string? storedDisplayType, bool isActiveState, string loweredTranscript)
{
var transcriptDisplayType = InferDisplayTypeFromTranscript(loweredTranscript);
var normalizedStoredDisplayType = NormalizeDisplayType(storedDisplayType);
if (isActiveState && !string.IsNullOrWhiteSpace(normalizedStoredDisplayType))
return normalizedStoredDisplayType;
if (!string.IsNullOrWhiteSpace(transcriptDisplayType))
return transcriptDisplayType;
if (!string.IsNullOrWhiteSpace(normalizedStoredDisplayType))
return normalizedStoredDisplayType;
return string.Equals(listType, TodoListType, StringComparison.OrdinalIgnoreCase)
? TodoListType
: ShoppingListType;
}
private static string InferDisplayTypeFromTranscript(string loweredTranscript)
{
if (loweredTranscript.Contains("grocery", StringComparison.OrdinalIgnoreCase))
return GroceryListType;
if (loweredTranscript.Contains("to do", StringComparison.OrdinalIgnoreCase) ||
loweredTranscript.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
loweredTranscript.Contains("task", StringComparison.OrdinalIgnoreCase))
{
return TodoListType;
}
if (loweredTranscript.Contains("shopping", StringComparison.OrdinalIgnoreCase))
return ShoppingListType;
return string.Empty;
}
private static string NormalizeDisplayType(string? displayType)
{
var normalized = NormalizeItem(displayType ?? string.Empty).ToLowerInvariant();
return normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase)
? GroceryListType
: normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("to do", StringComparison.OrdinalIgnoreCase)
? TodoListType
: normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase)
? ShoppingListType
: string.Empty;
}
private static string BuildListIntentName(string listType, string action)
{
var normalizedListType = string.Equals(listType, TodoListType, StringComparison.OrdinalIgnoreCase)
? TodoListType
: ShoppingListType;
return $"{normalizedListType}_list_{action}";
}
private static bool IsListOnlyRemainder(string value)
{
var normalized = NormalizeItem(value).ToLowerInvariant();
return normalized is "shopping list" or
"grocery list" or
"to do list" or
"todo list" or
"my shopping list" or
"my grocery list" or
"my to do list" or
"my todo list" or
"to my shopping list" or
"to my grocery list" or
"to my to do list" or
"to my todo list" or
"to the shopping list" or
"to the grocery list" or
"to the to do list" or
"to the todo list" or
"on my shopping list" or
"on my grocery list" or
"on my to do list" or
"on my todo list";
}
private static bool ContainsAny(string loweredTranscript, params string[] phrases)
{
return phrases.Any(phrase => loweredTranscript.Contains(phrase, StringComparison.OrdinalIgnoreCase));
}
private static bool IsConversationComplete(string loweredTranscript)
{
return ContainsAny(loweredTranscript,
"done",
"that's it",
"that s it",
"all set",
"finished",
"no more",
"nothing else");
}
private static string? ReadString(TurnContext turn, string key)
{
return turn.Attributes.TryGetValue(key, out var value) ? value?.ToString() : null;
}
}

View File

@@ -1,10 +1,12 @@
using System.Text;
using System.Security.Cryptography;
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
namespace Jibo.Cloud.Application.Services;
public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMediaContentStore? mediaContentStore = null)
{
private static readonly string[] AcceptedHosts =
[
@@ -14,97 +16,70 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
"localhost"
];
public Task<ProtocolDispatchResult> DispatchAsync(ProtocolEnvelope envelope, CancellationToken cancellationToken = default)
private readonly IMediaContentStore _mediaContentStore = mediaContentStore ?? new NullMediaContentStore();
public Task<ProtocolDispatchResult> DispatchAsync(ProtocolEnvelope envelope,
CancellationToken cancellationToken = default)
{
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
envelope.Path == "/" &&
string.IsNullOrWhiteSpace(envelope.ServicePrefix))
{
return Task.FromResult(ProtocolDispatchResult.NoContent());
}
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
envelope.Path.Equals("/health", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(ProtocolDispatchResult.Ok(new { ok = true, host = envelope.HostName }));
}
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
envelope.Path.StartsWith("/media/", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleMediaContent(envelope));
}
if (envelope.Method.Equals("PUT", StringComparison.OrdinalIgnoreCase) &&
(envelope.Path.Equals("/upload/asr-binary", StringComparison.OrdinalIgnoreCase) ||
envelope.Path.Equals("/upload/log-events", StringComparison.OrdinalIgnoreCase) ||
envelope.Path.Equals("/upload/log-binary", StringComparison.OrdinalIgnoreCase)))
{
return Task.FromResult(ProtocolDispatchResult.Raw(200, string.Empty));
}
if (!AcceptedHosts.Contains(envelope.HostName, StringComparer.OrdinalIgnoreCase))
{
return Task.FromResult(ProtocolDispatchResult.Ok(new
{
ok = true,
accepted = false,
host = envelope.HostName
}));
}
var servicePrefix = envelope.ServicePrefix ?? string.Empty;
var operation = envelope.Operation ?? string.Empty;
if (servicePrefix.StartsWith("Log_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleLog(operation, envelope));
}
if (servicePrefix.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleBackup(operation));
}
return Task.FromResult(HandleBackup(operation, envelope));
if (servicePrefix.StartsWith("Account_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleAccount(operation, envelope));
}
if (servicePrefix.StartsWith("Notification_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleNotification(operation, envelope));
}
if (servicePrefix.StartsWith("Loop_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleLoop(operation));
}
if (servicePrefix.Equals("Media_20160725", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleMedia(operation, envelope));
}
if (servicePrefix.StartsWith("Key_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleKey(operation, envelope));
}
if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandlePerson(operation));
}
return Task.FromResult(HandlePerson(operation, envelope));
if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleRobot(operation, envelope));
}
if (servicePrefix.StartsWith("Update_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleUpdate(operation, envelope));
}
return Task.FromResult(ProtocolDispatchResult.Ok(new
{
@@ -122,22 +97,18 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var body = envelope.TryParseBody();
if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new
{
token = stateStore.IssueHubToken(),
expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()
});
}
if (operation.Equals("CreateAccessToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new
{
token = $"access-{account.AccountId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()
});
}
if (operation.Equals("CheckEmail", StringComparison.OrdinalIgnoreCase))
{
@@ -149,7 +120,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
}
if (operation is "Create" or "Login")
{
return ProtocolDispatchResult.Ok(new
{
id = account.AccountId,
@@ -168,17 +138,13 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
facebookConnected = false,
termsAccepted = true
});
}
if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase))
{
var ids = ReadStringArray(body, "ids");
var matches = ids.Count == 0 || ids.Contains(account.AccountId, StringComparer.OrdinalIgnoreCase);
if (!matches)
{
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
if (!matches) return ProtocolDispatchResult.Ok(Array.Empty<object>());
return ProtocolDispatchResult.Ok(new[]
{
@@ -216,7 +182,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
}
if (operation.Equals("GetAccountByAccessToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new
{
id = account.AccountId,
@@ -226,12 +191,12 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
friendlyId = stateStore.GetRobot().RobotId,
payload = ReadObject(body, "payload")
});
}
if (operation.Equals("Search", StringComparison.OrdinalIgnoreCase))
{
var query = (ReadString(body, "query") ?? string.Empty).ToLowerInvariant();
var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}".ToLowerInvariant();
var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}"
.ToLowerInvariant();
return ProtocolDispatchResult.Ok(query.Length > 0 && haystack.Contains(query)
?
@@ -248,7 +213,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
}
if (operation.Equals("FacebookPrepareLogin", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new
{
url = "https://example.com/facebook-login",
@@ -258,12 +222,9 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
state = $"fb-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
redirect_uri = "https://api.jibo.com/facebook/callback"
});
}
if (operation.Equals("ConfirmEmailReset", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new { });
}
return ProtocolDispatchResult.Ok(new
{
@@ -277,9 +238,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private ProtocolDispatchResult HandleNotification(string operation, ProtocolEnvelope envelope)
{
if (!operation.Equals("NewRobotToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new { ok = true, operation });
}
var body = envelope.TryParseBody();
var deviceId = !string.IsNullOrWhiteSpace(envelope.DeviceId)
@@ -302,10 +261,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private ProtocolDispatchResult HandleLoop(string operation)
{
if (operation is not ("List" or "ListLoops"))
{
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
if (operation is not ("List" or "ListLoops")) return ProtocolDispatchResult.Ok(Array.Empty<object>());
return ProtocolDispatchResult.Ok(stateStore.GetLoops().Select(loop => new
{
@@ -363,55 +319,105 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var body = envelope.TryParseBody();
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.ListMedia(
ReadStringArray(body, "loopIds"),
ReadLong(body, "after"),
ReadLong(body, "before")).Select(MapMedia).ToArray());
}
if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray());
}
return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia)
.ToArray());
if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray());
}
return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia)
.ToArray());
if (!operation.Equals("Create", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(Array.Empty<object>());
var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? stateStore.GetLoops()[0].LoopId;
var path = ReadHeader(envelope, "x-path") ?? ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
var path = ReadHeader(envelope, "x-path") ??
ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown";
var reference = ReadHeader(envelope, "x-reference") ?? ReadString(body, "reference") ?? string.Empty;
var isEncrypted = ReadBooleanHeader(envelope, "x-encrypted") || ReadBool(body, "isEncrypted");
var meta = ReadObject(body, "meta") ?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
var contentType = ReadHeader(envelope, "Content-Type") ?? "application/octet-stream";
meta["contentType"] = contentType;
if (!string.IsNullOrWhiteSpace(envelope.BodyText))
{
meta["bodyText"] = envelope.BodyText;
var bodyBytes = string.IsNullOrWhiteSpace(envelope.BodyText)
? []
: Encoding.UTF8.GetBytes(envelope.BodyText);
meta["contentLength"] = bodyBytes.Length;
meta["contentSha256"] = Convert.ToHexString(SHA256.HashData(bodyBytes)).ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(envelope.BodyText)) meta["bodyText"] = envelope.BodyText;
_mediaContentStore.StoreAsync(path, contentType,
bodyBytes,
meta as IReadOnlyDictionary<string, object?>, CancellationToken.None).GetAwaiter().GetResult();
return ProtocolDispatchResult.Ok(
MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
}
return ProtocolDispatchResult.Ok(MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
private ProtocolDispatchResult HandlePerson(string operation, ProtocolEnvelope envelope)
{
var body = envelope.TryParseBody();
if (operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase))
{
var loopId = ReadString(body, "loopId");
return ProtocolDispatchResult.Ok(stateStore.GetHolidays(loopId).Select(MapHoliday));
}
private ProtocolDispatchResult HandlePerson(string operation)
if (operation.Equals("ListCommute", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase)
? stateStore.GetHolidays()
: []);
var loopId = ReadString(body, "loopId");
return ProtocolDispatchResult.Ok(stateStore.GetCommuteProfiles(loopId).Select(MapCommute));
}
private ProtocolDispatchResult HandleBackup(string operation)
if (operation.Equals("UpsertCommute", StringComparison.OrdinalIgnoreCase))
{
return operation.Equals("List", StringComparison.OrdinalIgnoreCase)
? ProtocolDispatchResult.Ok(stateStore.GetBackups())
: ProtocolDispatchResult.Ok(Array.Empty<object>());
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)
{
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(stateStore.GetBackups());
if (operation.Equals("Create", StringComparison.OrdinalIgnoreCase))
{
var body = envelope.TryParseBody();
var requestedName = ReadString(body, "name") ?? ReadString(body, "backupName");
return ProtocolDispatchResult.Ok(
stateStore.CreateBackup(requestedName ?? $"backup-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"));
}
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
private ProtocolDispatchResult HandleKey(string operation, ProtocolEnvelope envelope)
@@ -420,12 +426,10 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? stateStore.GetLoops()[0].LoopId;
if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new
{
shouldCreate = stateStore.ShouldCreateSymmetricKey(loopId)
});
}
string? symmetricKey;
if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase))
@@ -451,24 +455,17 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
}
if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"), ReadString(body, "publicKey")));
}
return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"),
ReadString(body, "publicKey")));
if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetIncomingKeyRequests());
}
if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetBinaryRequests());
}
if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary")
{
return ProtocolDispatchResult.Ok(new { ok = true });
}
if (!operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(new { ok = true, operation });
@@ -480,7 +477,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
key = symmetricKey,
symmetricKey
});
}
private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope)
@@ -521,7 +517,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
updated = profile.UpdatedUtc.ToUnixTimeMilliseconds(),
created = profile.CreatedUtc.ToUnixTimeMilliseconds()
});
}
private ProtocolDispatchResult HandleUpdate(string operation, ProtocolEnvelope envelope)
@@ -533,9 +528,11 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
return operation switch
{
"ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate).ToArray()),
"ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate)
.ToArray()),
"ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter)
.Where(update => fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase))
.Where(update =>
fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase))
.Select(MapUpdate)
.ToArray()),
"GetUpdateFrom" => HandleGetUpdateFrom(subsystem, fromVersion, filter),
@@ -558,13 +555,14 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var path = Uri.UnescapeDataString(envelope.Path["/media/".Length..]);
var candidatePaths = new[] { path, $"/{path}" };
var media = stateStore.GetMedia(candidatePaths).FirstOrDefault();
if (media is null || media.IsDeleted)
{
return ProtocolDispatchResult.Raw(404, string.Empty);
}
if (media is null || media.IsDeleted) return ProtocolDispatchResult.Raw(404, string.Empty);
var contentType = TryReadMetaString(media.Meta, "contentType") ?? "application/octet-stream";
var bodyText = TryReadMetaString(media.Meta, "bodyText") ?? string.Empty;
var storedContent = _mediaContentStore.LoadAsync(media.Path, CancellationToken.None).GetAwaiter().GetResult();
var contentType = storedContent?.ContentType ?? TryReadMetaString(media.Meta, "contentType") ??
"application/octet-stream";
var bodyText = storedContent is not null
? Encoding.UTF8.GetString(storedContent.Content)
: TryReadMetaString(media.Meta, "bodyText") ?? string.Empty;
return ProtocolDispatchResult.Raw(200, bodyText, contentType);
}
@@ -595,6 +593,46 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
};
}
private static object MapHoliday(HolidayRecord holiday)
{
return new
{
id = holiday.Id,
eventId = holiday.EventId,
name = holiday.Name,
category = holiday.Category,
subcategory = holiday.Subcategory,
loopId = holiday.LoopId,
memberId = holiday.MemberId,
isEnabled = holiday.IsEnabled,
date = holiday.Date,
endDate = holiday.EndDate,
source = holiday.Source,
countryCode = holiday.CountryCode,
created = holiday.Created
};
}
private static object MapCommute(CommuteProfileRecord commute)
{
return new
{
id = commute.Id,
loopId = commute.LoopId,
memberId = commute.MemberId,
isEnabled = commute.IsEnabled,
isComplete = commute.IsComplete,
mode = commute.Mode,
workHour = commute.WorkHour,
workMinute = commute.WorkMinute,
originName = commute.OriginName,
destinationName = commute.DestinationName,
typicalDurationMinutes = commute.TypicalDurationMinutes,
created = commute.Created,
updated = commute.Updated
};
}
private static object MapMedia(MediaRecord item)
{
return new
@@ -623,10 +661,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private static string? ReadString(JsonElement? element, string propertyName)
{
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
{
return null;
}
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null;
return property.ValueKind == JsonValueKind.String
? property.GetString()
@@ -635,25 +670,16 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private static long? ReadLong(JsonElement? element, string propertyName)
{
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
{
return null;
}
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null;
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number))
{
return number;
}
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number)) return number;
return long.TryParse(property.ToString(), out var parsed) ? parsed : null;
}
private static bool ReadBool(JsonElement? element, string propertyName)
{
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
{
return false;
}
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return false;
return property.ValueKind switch
{
@@ -665,31 +691,26 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private static IReadOnlyList<string> ReadStringArray(JsonElement? element, string propertyName)
{
if (element is null || !element.Value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array)
{
return [];
}
if (element is null || !element.Value.TryGetProperty(propertyName, out var property) ||
property.ValueKind != JsonValueKind.Array) return [];
return [.. property.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString())
.Where(item => !string.IsNullOrWhiteSpace(item))];
return
[
.. property.EnumerateArray()
.Select(item =>
item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString())
.Where(item => !string.IsNullOrWhiteSpace(item))
];
}
private static IDictionary<string, object?>? ReadObject(JsonElement? element, string propertyName)
{
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
{
return null;
}
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null;
if (property.ValueKind != JsonValueKind.Object)
{
return null;
}
if (property.ValueKind != JsonValueKind.Object) return null;
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var child in property.EnumerateObject())
{
result[child.Name] = child.Value.ValueKind switch
{
JsonValueKind.String => child.Value.GetString(),
@@ -699,7 +720,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
JsonValueKind.False => false,
_ => child.Value.ToString()
};
}
return result;
}
@@ -715,4 +735,18 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
bool.TryParse(value, out var parsed) &&
parsed;
}
private sealed class NullMediaContentStore : IMediaContentStore
{
public Task StoreAsync(string path, string contentType, byte[] content,
IReadOnlyDictionary<string, object?>? meta, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task<MediaContentSnapshot?> LoadAsync(string path, CancellationToken cancellationToken = default)
{
return Task.FromResult<MediaContentSnapshot?>(null);
}
}
}

View File

@@ -9,10 +9,7 @@ public sealed class JiboExperienceContentCache(IJiboExperienceContentRepository
public async Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{
if (_catalog is not null)
{
return _catalog;
}
if (_catalog is not null) return _catalog;
await _gate.WaitAsync(cancellationToken);
try

View File

@@ -0,0 +1,8 @@
namespace Jibo.Cloud.Application.Services;
public sealed record JiboInteractionDecision(
string IntentName,
string ReplyText,
string? SkillName = null,
IDictionary<string, object?>? SkillPayload = null,
IDictionary<string, object?>? ContextUpdates = null);

View File

@@ -0,0 +1,252 @@
using System.Collections.Generic;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services;
public sealed partial class JiboInteractionService
{
private static JiboInteractionDecision BuildCurrentLocationDecision(TurnContext turn)
{
var locationName = TryResolveCurrentLocationName(turn);
if (string.IsNullOrWhiteSpace(locationName))
return new JiboInteractionDecision(
"current_location",
"I'm not sure where we are right now.");
return new JiboInteractionDecision(
"current_location",
$"We're at {NormalizeLocationForSpeech(locationName)} if I'm not mistaken.",
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private static JiboInteractionDecision BuildOrderPizzaDecision()
{
return new JiboInteractionDecision(
"order_pizza",
"I can't do that yet, but I bet I'll be able to do that sometime in the near future.",
"chitchat-skill",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["esml"] =
"<speak>I can't do that yet, but I bet I'll be able to do that sometime in the near future.</speak>",
["mim_id"] = "RA_JBO_OrderPizza",
["mim_type"] = "announcement",
["prompt_id"] = "RA_JBO_OrderPizza_AN_01",
["prompt_sub_category"] = "AN"
});
}
private JiboInteractionDecision BuildJokeDecision(JiboExperienceCatalog catalog)
{
var joke = randomizer.Choose(catalog.Jokes);
return new JiboInteractionDecision(
"joke",
joke,
"@be/joke",
new Dictionary<string, object?>
{
["replyType"] = "joke"
});
}
private JiboInteractionDecision BuildRandomDanceDecision(JiboExperienceCatalog catalog)
{
var dance = randomizer.Choose(catalog.DanceAnimations);
var replyText = randomizer.Choose(catalog.DanceReplies);
return BuildDanceDecision("dance", dance, replyText);
}
private JiboInteractionDecision BuildDanceQuestionDecision(JiboExperienceCatalog catalog)
{
return new JiboInteractionDecision("dance_question", randomizer.Choose(catalog.DanceQuestionReplies));
}
private static JiboInteractionDecision BuildDanceDecision(string intentName, string dance, string replyText)
{
return new JiboInteractionDecision(
intentName,
replyText,
"chitchat-skill",
new Dictionary<string, object?>
{
["esml"] =
$"<speak>Okay.<break size='0.2'/> Watch this.<anim cat='dance' filter='music, {dance}' /></speak>",
["mim_id"] = "runtime-chat",
["mim_type"] = "announcement"
});
}
private static JiboInteractionDecision BuildVolumeControlDecision(string intentName, string globalIntent,
string globalValue)
{
return new JiboInteractionDecision(
intentName,
"Opening volume controls.",
"global_commands",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/settings",
["globalIntent"] = globalIntent,
["volumeLevel"] = globalValue
});
}
private static JiboInteractionDecision BuildSettingsVolumeDecision()
{
return new JiboInteractionDecision(
"volume_query",
"Opening volume controls.",
"@be/settings",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/settings",
["localIntent"] = "volumeQuery"
});
}
private static JiboInteractionDecision BuildClockLaunchDecision(string intentName, string domain,
string clockIntent, string replyText)
{
return new JiboInteractionDecision(
intentName,
replyText,
"@be/clock",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/clock",
["domain"] = domain,
["clockIntent"] = clockIntent
});
}
private static JiboInteractionDecision BuildClockLaunchDecision(string domain, string replyText)
{
return BuildClockLaunchDecision($"{domain}_menu", domain, "menu", replyText);
}
private static JiboInteractionDecision BuildClockClarifyDecision(string intentName, string domain, string replyText)
{
return new JiboInteractionDecision(
intentName,
replyText,
"@be/clock",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/clock",
["domain"] = domain,
["clockIntent"] = "set"
});
}
private static JiboInteractionDecision BuildTimerValueDecision(
string loweredTranscript,
bool allowImplicit,
IReadOnlyDictionary<string, string> clientEntities)
{
var timer = TryReadStructuredTimerValue(clientEntities) ??
TryParseTimerValue(loweredTranscript, allowImplicit) ??
new ClockTimerValue("0", "1", "null");
return new JiboInteractionDecision(
"timer_value",
"Setting your timer.",
"@be/clock",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/clock",
["domain"] = "timer",
["clockIntent"] = "start",
["hours"] = timer.Hours,
["minutes"] = timer.Minutes,
["seconds"] = timer.Seconds
});
}
private static JiboInteractionDecision BuildAlarmValueDecision(
string loweredTranscript,
bool allowImplicit,
DateTimeOffset? referenceLocalTime,
IReadOnlyDictionary<string, string> clientEntities)
{
var alarm = TryReadStructuredAlarmValue(clientEntities) ??
TryParseAlarmValue(loweredTranscript, allowImplicit, referenceLocalTime) ??
new ClockAlarmValue("7:00", "am");
return new JiboInteractionDecision(
"alarm_value",
"Setting your alarm.",
"@be/clock",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/clock",
["domain"] = "alarm",
["clockIntent"] = "start",
["time"] = alarm.Time,
["ampm"] = alarm.AmPm
});
}
private static JiboInteractionDecision BuildRadioGenreLaunchDecision(string loweredTranscript)
{
var station = TryResolveRadioGenre(loweredTranscript) ?? "Country";
return new JiboInteractionDecision(
"radio_genre",
$"Playing {FormatRadioGenreForSpeech(station)} on the radio.",
"@be/radio",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/radio",
["station"] = station
});
}
private static JiboInteractionDecision BuildWordOfTheDayGuessDecision(
IReadOnlyDictionary<string, string> clientEntities,
string transcript,
IReadOnlyList<string> listenAsrHints)
{
var guess = ResolveWordOfTheDayGuess(clientEntities, transcript, listenAsrHints);
var reply = string.IsNullOrWhiteSpace(guess)
? "I heard your word of the day guess."
: $"I heard {guess}.";
return new JiboInteractionDecision(
"word_of_the_day_guess",
reply,
"@be/word-of-the-day",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["guess"] = guess,
["skillId"] = "@be/word-of-the-day",
["cloudResponseMode"] = "completion_only"
});
}
private static string ResolveWordOfTheDayGuess(
IReadOnlyDictionary<string, string> clientEntities,
string transcript,
IReadOnlyList<string> listenAsrHints)
{
if (clientEntities.TryGetValue("guess", out var guessValue) &&
!string.IsNullOrWhiteSpace(guessValue))
return guessValue;
var loweredTranscript = NormalizeGuessToken(transcript);
var hintIndex = loweredTranscript switch
{
"1" or "one" or "first" => 0,
"2" or "two" or "second" => 1,
"3" or "three" or "third" => 2,
_ => -1
};
if (hintIndex >= 0 && hintIndex < listenAsrHints.Count) return listenAsrHints[hintIndex];
var fuzzyHintMatch = FindClosestHint(loweredTranscript, listenAsrHints);
return !string.IsNullOrWhiteSpace(fuzzyHintMatch) ? fuzzyHintMatch : transcript;
}
}

View File

@@ -0,0 +1,90 @@
using System.Collections.Generic;
using Jibo.Cloud.Domain.Models;
namespace Jibo.Cloud.Application.Services;
public sealed partial class JiboInteractionService
{
private static JiboInteractionDecision BuildWordOfTheDayLaunchDecision()
{
return new JiboInteractionDecision(
"word_of_the_day",
"Starting word of the day.",
"@be/word-of-the-day",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["domain"] = "word-of-the-day",
["skillId"] = "@be/word-of-the-day"
});
}
private static JiboInteractionDecision BuildRadioLaunchDecision()
{
return new JiboInteractionDecision(
"radio",
"Opening the radio.",
"@be/radio",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/radio"
});
}
private static JiboInteractionDecision BuildPhotoGalleryLaunchDecision()
{
return new JiboInteractionDecision(
"photo_gallery",
"Opening the photo gallery.",
"@be/gallery",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/gallery",
["localIntent"] = "menu"
});
}
private static JiboInteractionDecision BuildPhotoCreateDecision(string intentName, string replyText,
string localIntent)
{
return new JiboInteractionDecision(
intentName,
replyText,
"@be/create",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/create",
["localIntent"] = localIntent
});
}
private static JiboInteractionDecision BuildStopDecision()
{
return new JiboInteractionDecision(
"stop",
"Stopping.",
"@be/idle",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/idle",
["globalIntent"] = "stop",
["nluDomain"] = "global_commands"
});
}
private static JiboInteractionDecision BuildIdleGlobalCommandDecision(
string intentName,
string globalIntent,
string replyText)
{
return new JiboInteractionDecision(
intentName,
replyText,
"@be/idle",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "@be/idle",
["globalIntent"] = globalIntent,
["nluDomain"] = "global_commands"
});
}
}

View File

@@ -0,0 +1,259 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services;
public sealed partial class JiboInteractionService
{
private JiboInteractionDecision BuildRememberNameDecision(TurnContext turn, string transcript)
{
var name = TryExtractNameFact(transcript);
if (string.IsNullOrWhiteSpace(name))
return new JiboInteractionDecision(
"memory_set_name",
"I can remember it if you say, my name is Alex.");
personalMemoryStore.SetName(ResolveTenantScope(turn), name);
return new JiboInteractionDecision(
"memory_set_name",
$"Nice to meet you, {name}. I will remember your name.");
}
private JiboInteractionDecision BuildRecallNameDecision(TurnContext turn, GreetingPresenceProfile? presence = null)
{
var personScope = ResolveTenantScope(turn, presence?.PrimaryPersonId);
var name = personalMemoryStore.GetName(personScope);
if (string.IsNullOrWhiteSpace(name)) name = personalMemoryStore.GetName(ResolveTenantScope(turn));
name = ToDisplayName(name ?? string.Empty);
return string.IsNullOrWhiteSpace(name)
? new JiboInteractionDecision(
"memory_get_name",
"I do not know your name yet. You can say, my name is Alex.")
: new JiboInteractionDecision(
"memory_get_name",
presence is not null && !string.IsNullOrWhiteSpace(presence.PrimaryPersonId)
? $"I think you are {name}."
: $"You told me your name is {name}.");
}
private JiboInteractionDecision BuildRememberBirthdayDecision(TurnContext turn, string transcript)
{
var birthday = TryExtractBirthdayFact(transcript);
if (string.IsNullOrWhiteSpace(birthday))
return new JiboInteractionDecision(
"memory_set_birthday",
"I can remember it if you say, my birthday is March 14.");
var tenantScope = ResolveTenantScope(turn);
personalMemoryStore.SetBirthday(tenantScope, birthday);
var birthdayDate = TryParseBirthdayDate(birthday);
if (birthdayDate is not null)
{
var birthdayLabel = ResolvePreferredBirthdayLabel(turn);
cloudStateStore?.UpsertHoliday(new HolidayRecord
{
EventId = $"birthday-{tenantScope.LoopId}-{tenantScope.PersonId ?? "loop"}",
Name = string.IsNullOrWhiteSpace(birthdayLabel) ? "Birthday" : $"{birthdayLabel}'s Birthday",
Category = "birthday",
Subcategory = "personal",
LoopId = tenantScope.LoopId,
MemberId = tenantScope.PersonId,
IsEnabled = true,
Date = birthdayDate.Value,
Source = "birthday",
CountryCode = "US"
});
}
return new JiboInteractionDecision(
"memory_set_birthday",
$"Got it. I will remember your birthday is {birthday}.");
}
private JiboInteractionDecision BuildRecallBirthdayDecision(TurnContext turn)
{
var birthday = personalMemoryStore.GetBirthday(ResolveTenantScope(turn));
return string.IsNullOrWhiteSpace(birthday)
? new JiboInteractionDecision(
"memory_get_birthday",
"I do not know your birthday yet. You can say, my birthday is March 14.")
: new JiboInteractionDecision(
"memory_get_birthday",
$"You told me your birthday is {birthday}.");
}
private static DateOnly? TryParseBirthdayDate(string birthdayText)
{
if (string.IsNullOrWhiteSpace(birthdayText)) return null;
var normalized = birthdayText.Trim().ToLowerInvariant();
var match = Regex.Match(
normalized,
@"\b(?<month>january|february|march|april|may|june|july|august|september|october|november|december)\s+(?<day>\d{1,2})(?:st|nd|rd|th)?\b",
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
if (!match.Success) return null;
var month = match.Groups["month"].Value.ToLowerInvariant() switch
{
"january" => 1,
"february" => 2,
"march" => 3,
"april" => 4,
"may" => 5,
"june" => 6,
"july" => 7,
"august" => 8,
"september" => 9,
"october" => 10,
"november" => 11,
"december" => 12,
_ => 0
};
if (month == 0) return null;
if (!int.TryParse(match.Groups["day"].Value, out var day) || day is < 1 or > 31) return null;
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var year = today.Year;
if (day > DateTime.DaysInMonth(year, month)) return null;
DateOnly birthday;
try
{
birthday = new DateOnly(year, month, day);
}
catch
{
return null;
}
if (birthday < today) birthday = birthday.AddYears(1);
return birthday;
}
private static string? ResolvePreferredBirthdayLabel(TurnContext turn)
{
var context = ResolveGreetingPresenceProfile(turn);
return !string.IsNullOrWhiteSpace(context.PrimaryPersonId) &&
context.LoopUserFirstNames.TryGetValue(context.PrimaryPersonId, out var firstName) &&
!string.IsNullOrWhiteSpace(firstName)
? ToDisplayName(firstName)
: null;
}
private JiboInteractionDecision BuildRememberImportantDateDecision(TurnContext turn, string transcript)
{
var importantDate = TryExtractImportantDateSet(transcript);
if (importantDate is null)
return new JiboInteractionDecision(
"memory_set_important_date",
"I can remember it if you say, our anniversary is June 10.");
personalMemoryStore.SetImportantDate(ResolveTenantScope(turn), importantDate.Value.Label,
importantDate.Value.Value);
return new JiboInteractionDecision(
"memory_set_important_date",
$"Got it. I will remember your {importantDate.Value.Label} is {importantDate.Value.Value}.");
}
private JiboInteractionDecision BuildRecallImportantDateDecision(TurnContext turn, string transcript)
{
var label = TryExtractImportantDateLookupLabel(transcript);
if (string.IsNullOrWhiteSpace(label))
return new JiboInteractionDecision(
"memory_get_important_date",
"Ask me like this: when is our anniversary?");
var storedDate = personalMemoryStore.GetImportantDate(ResolveTenantScope(turn), label);
return string.IsNullOrWhiteSpace(storedDate)
? new JiboInteractionDecision(
"memory_get_important_date",
$"I do not know your {label} yet.")
: new JiboInteractionDecision(
"memory_get_important_date",
$"You told me your {label} is {storedDate}.");
}
private JiboInteractionDecision BuildRememberPreferenceDecision(TurnContext turn, string transcript)
{
var preference = TryExtractPreferenceSet(transcript);
if (preference is null)
return new JiboInteractionDecision(
"memory_set_preference",
"I can remember it if you say, my favorite music is jazz.");
personalMemoryStore.SetPreference(ResolveTenantScope(turn), preference.Value.Category, preference.Value.Value);
return new JiboInteractionDecision(
"memory_set_preference",
$"Got it. I will remember your favorite {preference.Value.Category} is {preference.Value.Value}.");
}
private JiboInteractionDecision BuildRecallPreferenceDecision(TurnContext turn, string transcript)
{
var category = TryExtractPreferenceLookupCategory(transcript);
if (string.IsNullOrWhiteSpace(category))
return new JiboInteractionDecision(
"memory_get_preference",
"Ask me like this: what is my favorite music?");
var preference = personalMemoryStore.GetPreference(ResolveTenantScope(turn), category);
return string.IsNullOrWhiteSpace(preference)
? new JiboInteractionDecision(
"memory_get_preference",
$"I do not know your favorite {category} yet.")
: new JiboInteractionDecision(
"memory_get_preference",
$"You told me your favorite {category} is {preference}.");
}
private JiboInteractionDecision BuildRememberAffinityDecision(TurnContext turn, string transcript)
{
var affinitySet = TryExtractAffinitySet(transcript);
if (affinitySet is null)
return new JiboInteractionDecision(
"memory_set_affinity",
"I can remember it if you say, I like pizza or I dislike mushrooms.");
personalMemoryStore.SetAffinity(ResolveTenantScope(turn), affinitySet.Value.Item, affinitySet.Value.Affinity);
return new JiboInteractionDecision(
"memory_set_affinity",
$"Got it. I will remember you {DescribeAffinityAsVerb(affinitySet.Value.Affinity)} {affinitySet.Value.Item}.");
}
private JiboInteractionDecision BuildRecallAffinityDecision(TurnContext turn, string transcript)
{
var lookup = TryExtractAffinityLookup(transcript);
if (lookup is null)
return new JiboInteractionDecision(
"memory_get_affinity",
"Ask me like this: do I like pizza?");
var affinity = personalMemoryStore.GetAffinity(ResolveTenantScope(turn), lookup.Value.Item);
if (affinity is null)
return new JiboInteractionDecision(
"memory_get_affinity",
$"I do not remember how you feel about {lookup.Value.Item} yet.");
if (lookup.Value.ExpectedAffinity is null)
return new JiboInteractionDecision(
"memory_get_affinity",
$"You told me you {DescribeAffinityAsVerb(affinity.Value)} {lookup.Value.Item}.");
var matches = lookup.Value.ExpectedAffinity == PersonalAffinity.Dislike
? affinity == PersonalAffinity.Dislike
: affinity is PersonalAffinity.Like or PersonalAffinity.Love;
return matches
? new JiboInteractionDecision(
"memory_get_affinity",
$"Yes. You told me you {DescribeAffinityAsVerb(affinity.Value)} {lookup.Value.Item}.")
: new JiboInteractionDecision(
"memory_get_affinity",
$"Not exactly. You told me you {DescribeAffinityAsVerb(affinity.Value)} {lookup.Value.Item}.");
}
}

View File

@@ -0,0 +1,213 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services;
public sealed partial class JiboInteractionService
{
private static JiboInteractionDecision BuildNewsDecision(
string spokenBriefing,
string? sourceName,
IReadOnlyList<string>? categories,
int? headlineCount,
IReadOnlyDictionary<string, object?>? providerDiagnostics = null,
IReadOnlyList<NewsHeadline>? headlines = null)
{
var speakableBriefing = NormalizeNewsSpeechText(spokenBriefing);
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "news",
["cloudSkill"] = "news",
["mim_id"] = "runtime-news",
["mim_type"] = "announcement",
["prompt_id"] = "NewsHeadline_AN_01",
["prompt_sub_category"] = "AN",
["news_view_enabled"] = true,
["news_view_kind"] = "newsBriefing",
["news_view_mode"] = "provider",
["esml"] =
$"<speak><anim cat='news' meta='news-stinger' nonBlocking='true' /><break size='0.35'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeForEsml(speakableBriefing)}</es></speak>"
};
if (!string.IsNullOrWhiteSpace(sourceName)) payload["news_source"] = sourceName;
if (headlineCount is > 0) payload["news_headline_count"] = headlineCount.Value;
if (categories is { Count: > 0 }) payload["news_categories"] = categories.ToArray();
if (headlines is { Count: > 0 })
payload["news_headlines"] = headlines.Select(static headline => new Dictionary<string, object?>(
StringComparer.OrdinalIgnoreCase)
{
["title"] = headline.Title,
["summary"] = headline.Summary,
["category"] = headline.Category,
["sourceName"] = headline.SourceName,
["url"] = headline.Url
})
.ToArray();
if (providerDiagnostics is not null)
foreach (var (key, value) in providerDiagnostics)
payload[key] = value;
return new JiboInteractionDecision("news", spokenBriefing, "news", payload);
}
private static JiboInteractionDecision BuildProviderNewsDecision(
NewsBriefingSnapshot snapshot,
JiboExperienceCatalog catalog,
IReadOnlyList<string> preferredCategories,
int requestedHeadlineCount)
{
var headlines = snapshot.Headlines
.Where(headline => !string.IsNullOrWhiteSpace(headline.Title))
.Take(MaxNewsHeadlines)
.ToArray();
if (headlines.Length == 0)
return BuildNewsDecision(
"I couldn't load fresh headlines right now.",
snapshot.SourceName,
preferredCategories,
0,
BuildNewsProviderDiagnostics(
"provider_empty",
preferredCategories,
requestedHeadlineCount,
0));
var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories);
var joinedHeadlines = string.Join(" ", headlines.Select(static headline => $"{headline.Title}."));
var outroTemplate = ChooseShortestTemplate(catalog.NewsOutroReplies) ?? "And that's the news.";
var spokenBriefing = $"{leadIn} {joinedHeadlines} {outroTemplate}".Trim();
return BuildNewsDecision(
spokenBriefing,
snapshot.SourceName,
preferredCategories,
headlines.Length,
BuildNewsProviderDiagnostics(
"provider_success",
preferredCategories,
requestedHeadlineCount,
headlines.Length),
headlines);
}
private static IReadOnlyDictionary<string, object?> BuildNewsProviderDiagnostics(
string status,
IReadOnlyList<string> preferredCategories,
int requestedHeadlineCount,
int? resolvedHeadlineCount = null,
string? providerMessage = null,
int? providerHttpStatusCode = null,
string? providerEndpoint = null,
string? providerErrorCode = null)
{
var diagnostics = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["news_provider_status"] = status,
["news_provider_requested_headlines"] = requestedHeadlineCount,
["news_provider_preferred_categories"] = preferredCategories.Count > 0
? [.. preferredCategories]
: Array.Empty<string>()
};
if (resolvedHeadlineCount is not null)
diagnostics["news_provider_resolved_headlines"] = resolvedHeadlineCount.Value;
if (!string.IsNullOrWhiteSpace(providerMessage)) diagnostics["news_provider_message"] = providerMessage;
if (providerHttpStatusCode is not null) diagnostics["news_provider_http_status"] = providerHttpStatusCode.Value;
if (!string.IsNullOrWhiteSpace(providerEndpoint)) diagnostics["news_provider_endpoint"] = providerEndpoint;
if (!string.IsNullOrWhiteSpace(providerErrorCode)) diagnostics["news_provider_error_code"] = providerErrorCode;
return diagnostics;
}
private static string ResolveNewsProviderStatus(NewsBriefingSnapshot? snapshot)
{
var providerStatus = snapshot?.ProviderStatus?.Trim().ToLowerInvariant();
return providerStatus switch
{
"success" => "provider_success",
"exception" => "provider_exception",
"http_error" or "api_error" or "schema_error" => "provider_error",
_ => "provider_empty"
};
}
private static string BuildNewsLeadIn(string? sourceName, IReadOnlyList<string> preferredCategories)
{
var categoryLeadIn = preferredCategories.Count switch
{
<= 0 => "Here are a few headlines.",
1 => $"Here are your {preferredCategories[0]} headlines.",
_ => $"Here are your {preferredCategories[0]} and {preferredCategories[1]} headlines."
};
return string.IsNullOrWhiteSpace(sourceName)
? categoryLeadIn
: $"{categoryLeadIn} Source: {sourceName}.";
}
private static string NormalizeNewsSpeechText(string text)
{
if (string.IsNullOrWhiteSpace(text)) return text;
// Expand "AI" so Nimbus TTS does not collapse it to a single "aye" sound.
var normalized = Regex.Replace(
text,
@"\bA\.?\s*I\.?\b",
"artificial intelligence",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return NormalizeLocationForSpeech(normalized);
}
private List<string> ResolvePreferredNewsCategories(TurnContext turn, string transcript)
{
var categories = new List<string>();
var normalizedTranscript = NormalizeCommandPhrase(transcript);
foreach (var (keyword, category) in NewsCategoryKeywordMap)
if (normalizedTranscript.Contains(keyword, StringComparison.Ordinal))
AddNewsCategory(categories, category);
var tenantScope = ResolveTenantScope(turn);
var explicitPreference = personalMemoryStore.GetPreference(tenantScope, "news");
if (!string.IsNullOrWhiteSpace(explicitPreference))
foreach (var category in MapNewsCategoryText(explicitPreference))
AddNewsCategory(categories, category);
foreach (var (item, affinity) in personalMemoryStore.GetAffinities(tenantScope))
{
if (affinity == PersonalAffinity.Dislike) continue;
foreach (var category in MapNewsCategoryText(item)) AddNewsCategory(categories, category);
}
return [.. categories.Take(MaxPreferredNewsCategories)];
}
private static IEnumerable<string> MapNewsCategoryText(string text)
{
var normalized = NormalizeCommandPhrase(text);
if (string.IsNullOrWhiteSpace(normalized)) yield break;
foreach (var (keyword, category) in NewsCategoryKeywordMap)
if (normalized.Contains(keyword, StringComparison.Ordinal))
yield return category;
}
private static void AddNewsCategory(ICollection<string> categories, string category)
{
if (categories.Contains(category, StringComparer.OrdinalIgnoreCase)) return;
categories.Add(category);
}
}

View File

@@ -0,0 +1,773 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services;
public sealed partial class JiboInteractionService
{
private static readonly string[] DefaultAgeReplies =
[
"I'm ${jibo.age}.",
"At the moment I'm ${jibo.age.days.supplemented} old, but who's counting.",
"I'm ${jibo.age.minutes.supplemented} old, but who's counting.",
"For now I'm ${jibo.age.days.supplemented} old.",
"Right now I'm ${jibo.age}.",
"I am exactly ${jibo.age} old today. That's right. Today is my birthday.",
"Funny you should ask! Today's my birthday. I was first powered up ${jibo.age} ago today. Seems like just yesterday.",
"I'm exactly ${jibo.age} old. Today is my birthday! Happy Birthday Jibo, if I do say so myself.",
"At the moment I'm ${jibo.age.days.supplemented} old",
"I was first powered up on ${jibo.birthdate}, which makes me ${jibo.age.days.supplemented} old. I'm ${jibo.zodiac.supplemented}.",
"My power went on for the first time ${jibo.age.days.supplemented} ago. But who's counting.",
"I am ${jibo.age.days.supplemented} old, first powered up on ${jibo.birthdate}. Seems like just yesterday.",
"I was powered on for the first time today, so that makes me less than one day old. Wow I'm young.",
"Since I was powered on for the first time today, I am not even one day old yet. That's how Jibo ages work."
];
private JiboInteractionDecision BuildRobotAgeDecision(
JiboExperienceCatalog catalog,
DateTimeOffset? referenceLocalTime,
string intentName)
{
var ageReplies = catalog.AgeReplies.Count == 0 ? DefaultAgeReplies : catalog.AgeReplies;
var selected = SelectLegacyReply(
ageReplies,
"first powered up",
"today is my birthday",
"just getting started",
"who's counting");
var reply = RenderAgeTemplate(selected, referenceLocalTime);
if (string.IsNullOrWhiteSpace(reply))
{
var referenceDate = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).Date);
var ageDescription = DescribePersonaAge(referenceDate, OpenJiboCloudBuildInfo.PersonaBirthday);
reply = $"I count {OpenJiboCloudBuildInfo.PersonaBirthdayWords} as my birthday, so I am {ageDescription}.";
}
return new JiboInteractionDecision(
intentName,
reply,
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private static JiboInteractionDecision BuildRobotBirthdayDecision()
{
return new JiboInteractionDecision(
"robot_birthday",
$"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}.");
}
private static string RenderAgeTemplate(string template, DateTimeOffset? referenceLocalTime)
{
if (string.IsNullOrWhiteSpace(template)) return string.Empty;
var referenceMoment = referenceLocalTime ?? DateTimeOffset.UtcNow;
var referenceDate = DateOnly.FromDateTime(referenceMoment.Date);
var ageDescription = DescribePersonaAge(referenceDate, OpenJiboCloudBuildInfo.PersonaBirthday);
var ageDays = Math.Max(0, referenceDate.DayNumber - OpenJiboCloudBuildInfo.PersonaBirthday.DayNumber);
var ageMinutes = Math.Max(0, (int)Math.Round((referenceMoment.UtcDateTime -
new DateTimeOffset(
DateTime.SpecifyKind(
OpenJiboCloudBuildInfo.PersonaBirthday
.ToDateTime(TimeOnly.MinValue),
DateTimeKind.Utc)))
.TotalMinutes));
var zodiacLabel = DescribeZodiacSign(OpenJiboCloudBuildInfo.PersonaBirthday);
if (zodiacLabel.StartsWith("I'm ", StringComparison.OrdinalIgnoreCase))
zodiacLabel = zodiacLabel[4..];
return template
.Replace("${jibo.age.minutes.supplemented}", FormatAgeUnit(ageMinutes, "minute") + " old",
StringComparison.Ordinal)
.Replace("${jibo.age.days.supplemented}", ageDescription, StringComparison.Ordinal)
.Replace("${jibo.birthdate}", OpenJiboCloudBuildInfo.PersonaBirthdayWords, StringComparison.Ordinal)
.Replace("${jibo.zodiac.supplemented}", zodiacLabel, StringComparison.Ordinal)
.Replace("${jibo.age.value}", ageDays.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)
.Replace("${jibo.age}", ageDescription, StringComparison.Ordinal);
}
private static JiboInteractionDecision BuildTriggerIgnoredDecision()
{
return new JiboInteractionDecision(
"trigger_ignored",
string.Empty,
"chitchat-skill",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "chitchat-skill",
["cloudResponseMode"] = "completion_only"
});
}
private JiboInteractionDecision BuildReactiveGreetingDecision(
TurnContext turn,
string greetingIntent,
DateTimeOffset? referenceLocalTime)
{
var presence = ResolveGreetingPresenceProfile(turn);
var displayName = ResolvePreferredGreetingName(turn, presence);
var replyText = BuildReactiveGreetingReply(greetingIntent, displayName, referenceLocalTime);
RecordGreetingPresence(turn, presence, "ReactiveGreeting", greetingIntent, displayName, proactive: false);
return new JiboInteractionDecision(
greetingIntent,
replyText,
ContextUpdates: BuildGreetingContextUpdates("ReactiveGreeting", presence.PrimaryPersonId, false));
}
private JiboInteractionDecision BuildProactiveGreetingDecision(
TurnContext turn,
GreetingPresenceProfile presence,
DateTimeOffset? referenceLocalTime)
{
var displayName = ResolvePreferredGreetingName(turn, presence);
var specialGreeting = ResolveSpecialGreetingPrefix(turn, presence, referenceLocalTime);
var route = specialGreeting?.Route ?? "ProactiveGreeting";
var intentName = specialGreeting?.IntentName ?? "proactive_greeting";
var replyText = specialGreeting is null
? BuildProactiveGreetingReply(turn, presence, displayName, referenceLocalTime)
: string.IsNullOrWhiteSpace(displayName)
? $"{specialGreeting.Prefix}. I am glad to see you."
: $"{specialGreeting.Prefix}, {displayName}. It is nice to celebrate with you.";
RecordGreetingPresence(turn, presence, route, intentName, displayName, proactive: true);
return new JiboInteractionDecision(
intentName,
replyText,
ContextUpdates: BuildGreetingContextUpdates(route, presence.PrimaryPersonId, true));
}
private static string BuildReactiveGreetingReply(
string greetingIntent,
string? displayName,
DateTimeOffset? referenceLocalTime)
{
var namePrefix = string.IsNullOrWhiteSpace(displayName)
? string.Empty
: $", {displayName}";
return greetingIntent switch
{
"good_morning" => $"Good morning{namePrefix}. It is great to see you.",
"good_afternoon" => $"Good afternoon{namePrefix}. I am glad you are here.",
"good_evening" => $"Good evening{namePrefix}. It is nice to have you back.",
"good_night" => $"Good night{namePrefix}. Sleep well.",
"welcome_back" => string.IsNullOrWhiteSpace(displayName)
? $"Welcome back. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}."
: $"Welcome back, {displayName}. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}.",
_ => $"Hello{namePrefix}. It is nice to see you."
};
}
private string? ResolvePreferredGreetingName(TurnContext turn, GreetingPresenceProfile presence)
{
var rememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn, presence.PrimaryPersonId));
if (!string.IsNullOrWhiteSpace(rememberedName)) return ToDisplayName(rememberedName);
var tenantRememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn));
if (!string.IsNullOrWhiteSpace(tenantRememberedName)) return ToDisplayName(tenantRememberedName);
var primaryPersonId = presence.PrimaryPersonId;
if (CanUseLoopFirstNameFallback(presence) &&
!string.IsNullOrWhiteSpace(primaryPersonId) &&
presence.LoopUserFirstNames.TryGetValue(primaryPersonId, out var firstName) &&
!string.IsNullOrWhiteSpace(firstName))
return ToDisplayName(firstName);
return null;
}
private static bool CanUseLoopFirstNameFallback(GreetingPresenceProfile presence)
{
if (string.IsNullOrWhiteSpace(presence.PrimaryPersonId)) return false;
if (presence.PeoplePresentIds.Count > 1) return false;
return true;
}
private static string ToDisplayName(string value)
{
var trimmed = value.Trim();
return string.IsNullOrWhiteSpace(trimmed)
? string.Empty
: CultureInfo.InvariantCulture.TextInfo.ToTitleCase(trimmed);
}
private bool ShouldHandleProactiveGreetingTrigger(
TurnContext turn,
string? triggerSource,
GreetingPresenceProfile presence)
{
if (string.Equals(triggerSource, "SURPRISE", StringComparison.OrdinalIgnoreCase)) return false;
if (!presence.HasKnownIdentity) return false;
var lastGreetingUtc = ReadGreetingHistoryLastGreetedUtc(turn, presence);
return !lastGreetingUtc.HasValue || DateTimeOffset.UtcNow - lastGreetingUtc.Value >= ProactiveGreetingCooldown;
}
private DateTimeOffset? ReadGreetingHistoryLastGreetedUtc(TurnContext turn, GreetingPresenceProfile presence)
{
var greetingHistory = ResolveGreetingHistoryRecord(turn, presence);
if (greetingHistory is not null && greetingHistory.LastGreetedUtc.HasValue)
return greetingHistory.LastGreetedUtc;
return ReadTimestampAttribute(turn, LastProactiveGreetingUtcMetadataKey);
}
private GreetingPresenceRecord? ResolveGreetingHistoryRecord(TurnContext turn, GreetingPresenceProfile presence)
{
var historyIdentity = ResolveGreetingHistoryIdentity(presence);
if (string.IsNullOrWhiteSpace(historyIdentity) || cloudStateStore is null) return null;
var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop";
return cloudStateStore.GetGreetingPresences(loopId)
.FirstOrDefault(record => record.PersonId.Equals(historyIdentity, StringComparison.OrdinalIgnoreCase));
}
private static string? ResolveGreetingHistoryIdentity(GreetingPresenceProfile presence)
{
if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId)) return presence.PrimaryPersonId;
return !string.IsNullOrWhiteSpace(presence.SpeakerId) ? presence.SpeakerId : null;
}
private static DateTimeOffset? ReadTimestampAttribute(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null;
return DateTimeOffset.TryParse(
value.ToString(),
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind,
out var parsed)
? parsed
: null;
}
private static IDictionary<string, object?> BuildGreetingContextUpdates(string route, string? speakerId,
bool proactive)
{
var updates = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
[ChitchatStateMachine.StateMetadataKey] = "complete",
[ChitchatStateMachine.RouteMetadataKey] = "ScriptedResponse",
[ChitchatStateMachine.EmotionMetadataKey] = string.Empty,
[GreetingRouteMetadataKey] = route,
[GreetingSpeakerMetadataKey] = speakerId ?? string.Empty,
[proactive ? LastProactiveGreetingUtcMetadataKey : LastReactiveGreetingUtcMetadataKey] = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
};
return updates;
}
private void RecordGreetingPresence(
TurnContext turn,
GreetingPresenceProfile presence,
string route,
string intentName,
string? preferredName,
bool proactive)
{
if (cloudStateStore is null) return;
var identityId = ResolveGreetingHistoryIdentity(presence);
if (string.IsNullOrWhiteSpace(identityId)) return;
var now = DateTimeOffset.UtcNow;
var tenantScope = ResolveTenantScope(turn, identityId);
cloudStateStore.UpsertGreetingPresence(new GreetingPresenceRecord
{
AccountId = tenantScope.AccountId,
LoopId = tenantScope.LoopId,
PersonId = identityId,
SpeakerId = presence.SpeakerId,
PreferredName = preferredName,
LastSeenUtc = now,
LastGreetedUtc = now,
LastGreetingRoute = route,
LastGreetingIntent = intentName
});
}
private sealed record SpecialGreetingPrefix(string Route, string IntentName, string Prefix);
private static string ResolveTimeOfDayGreetingPrefix(DateTimeOffset? referenceLocalTime)
{
var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour;
return hour switch
{
>= 5 and < 12 => "Good morning",
>= 12 and < 17 => "Good afternoon",
_ => "Good evening"
};
}
private string BuildProactiveGreetingReply(
TurnContext turn,
GreetingPresenceProfile presence,
string? displayName,
DateTimeOffset? referenceLocalTime)
{
var greetingHistory = ResolveGreetingHistoryRecord(turn, presence);
var greetingPrefix = ResolveProactiveGreetingPrefix(referenceLocalTime, greetingHistory);
if (string.Equals(greetingPrefix, "Welcome back", StringComparison.OrdinalIgnoreCase))
return string.IsNullOrWhiteSpace(displayName)
? "Welcome back. I am glad to see you again."
: $"Welcome back, {displayName}. I am glad to see you again.";
return string.IsNullOrWhiteSpace(displayName)
? $"{greetingPrefix}. I am glad to see you."
: $"{greetingPrefix}, {displayName}. It is great to see you.";
}
private static string ResolveProactiveGreetingPrefix(
DateTimeOffset? referenceLocalTime,
GreetingPresenceRecord? greetingHistory)
{
var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour;
var isMorning = hour >= 5 && hour < 12;
var recentGreeting = greetingHistory?.LastGreetedUtc is not null &&
DateTimeOffset.UtcNow - greetingHistory.LastGreetedUtc.Value < TimeSpan.FromHours(8);
if (recentGreeting) return "Welcome back";
return isMorning ? "Good morning" : ResolveTimeOfDayGreetingPrefix(referenceLocalTime);
}
private SpecialGreetingPrefix? ResolveSpecialGreetingPrefix(
TurnContext turn,
GreetingPresenceProfile presence,
DateTimeOffset? referenceLocalTime)
{
var today = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).Date);
var birthday = ResolveBirthdayGreeting(turn, presence, today);
if (birthday is not null) return birthday;
return ResolveHolidayGreeting(turn, today);
}
private SpecialGreetingPrefix? ResolveBirthdayGreeting(
TurnContext turn,
GreetingPresenceProfile presence,
DateOnly today)
{
var identityScope = !string.IsNullOrWhiteSpace(presence.PrimaryPersonId)
? ResolveTenantScope(turn, presence.PrimaryPersonId)
: ResolveTenantScope(turn);
var birthdayText = personalMemoryStore.GetBirthday(identityScope) ??
personalMemoryStore.GetBirthday(ResolveTenantScope(turn));
if (string.IsNullOrWhiteSpace(birthdayText)) return null;
var birthdayDate = TryParseBirthdayDate(birthdayText);
if (birthdayDate is null) return null;
return birthdayDate.Value.Month == today.Month && birthdayDate.Value.Day == today.Day
? new SpecialGreetingPrefix("ProactiveBirthdayGreeting", "proactive_birthday_greeting",
"Happy birthday")
: null;
}
private SpecialGreetingPrefix? ResolveHolidayGreeting(TurnContext turn, DateOnly today)
{
if (cloudStateStore is null) return null;
var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop";
var holiday = cloudStateStore.GetHolidays(loopId)
.FirstOrDefault(item =>
item.IsEnabled &&
item.Category != "birthday" &&
item.Date.Month == today.Month &&
item.Date.Day == today.Day);
return holiday is null
? null
: new SpecialGreetingPrefix("ProactiveHolidayGreeting", "proactive_holiday_greeting",
"Happy holidays");
}
private JiboInteractionDecision BuildPizzaDecision()
{
return BuildPizzaAnimationDecision("pizza", "One pizza, coming right up.");
}
private JiboInteractionDecision BuildPizzaAnimationDecision(string intentName, string replyText)
{
var prompt = randomizer.Choose(PizzaMimPrompts);
return new JiboInteractionDecision(
intentName,
replyText,
"chitchat-skill",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["esml"] = prompt.Esml,
["mim_id"] = "RA_JBO_MakePizza",
["mim_type"] = "announcement",
["prompt_id"] = prompt.PromptId,
["prompt_sub_category"] = "AN"
});
}
private JiboInteractionDecision BuildProactivePizzaDayDecision(DateTimeOffset? referenceLocalTime)
{
var referenceDate = (referenceLocalTime ?? DateTimeOffset.UtcNow).Date;
return BuildPizzaAnimationDecision(
"proactive_pizza_day",
$"Happy National Pizza Day for {referenceDate.ToString("MMMM d", CultureInfo.InvariantCulture)}. One pizza, coming right up.");
}
private JiboInteractionDecision BuildProactivePizzaPreferenceDecision()
{
return BuildPizzaAnimationDecision(
"proactive_pizza_preference",
"You mentioned pizza is a favorite, so I thought we should make one.");
}
private static JiboInteractionDecision BuildProactivePizzaFactOfferDecision()
{
var listenContexts = new[] { "shared/yes_no" };
return new JiboInteractionDecision(
"proactive_offer_pizza_fact",
"Do you want to hear a fun pizza fact?",
"chitchat-skill",
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["mim_id"] = "runtime-chat",
["mim_type"] = "question",
["prompt_id"] = "RUNTIME_PROMPT",
["prompt_sub_category"] = "Q",
["listen_contexts"] = listenContexts
});
}
private static JiboInteractionDecision BuildProactivePizzaFactDecision()
{
return new JiboInteractionDecision(
"proactive_pizza_fact",
"Americans consume about 100 acres of pizza every day, roughly 350 slices per second. That's a lot of pizza.");
}
private JiboInteractionDecision BuildProactiveFunFactDecision(JiboExperienceCatalog catalog)
{
var categories = new List<ProactiveFactCategory>();
AddProactiveFactCategory(categories, "fun_fact", catalog.FunFacts);
AddProactiveFactCategory(categories, "robot_fact", catalog.RobotFacts);
AddProactiveFactCategory(categories, "human_fact", catalog.HumanFacts);
if (categories.Count == 0)
return new JiboInteractionDecision("proactive_fun_fact", randomizer.Choose(catalog.SurpriseReplies));
var selectedCategory = randomizer.Choose(categories);
var fact = randomizer.Choose(selectedCategory.Replies);
return new JiboInteractionDecision(
"proactive_fun_fact",
fact,
"chitchat-skill",
new Dictionary<string, object?>
{
["mim_id"] = "runtime-fun-fact",
["mim_type"] = "announcement",
["prompt_id"] = "RUNTIME_FUN_FACT",
["replyType"] = "fun_fact",
["factCategory"] = selectedCategory.CategoryName
});
}
private static void AddProactiveFactCategory(
ICollection<ProactiveFactCategory> categories,
string categoryName,
IReadOnlyList<string> replies)
{
if (replies.Count == 0) return;
categories.Add(new ProactiveFactCategory(categoryName, replies));
}
private JiboInteractionDecision BuildProactiveJokeDecision(JiboExperienceCatalog catalog)
{
return new JiboInteractionDecision(
"proactive_joke",
randomizer.Choose(catalog.Jokes),
"@be/joke",
new Dictionary<string, object?>
{
["replyType"] = "joke"
});
}
private static JiboInteractionDecision BuildProactiveOfferDeclinedDecision()
{
return new JiboInteractionDecision(
"proactive_offer_declined",
"No problem. We can save the pizza fact for another time.");
}
private JiboInteractionDecision BuildWhatIsYourSignDecision()
{
var today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.Date);
var birthday = OpenJiboCloudBuildInfo.PersonaBirthday;
var zodiac = DescribeZodiacSign(birthday);
var reply = birthday.Month == today.Month && birthday.Day == today.Day
? $"{zodiac}. Today is my birthday."
: $"{zodiac}. I was first powered up on {OpenJiboCloudBuildInfo.PersonaBirthdayWords}.";
return new JiboInteractionDecision(
"robot_what_is_your_sign",
reply,
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private JiboInteractionDecision BuildHowManyPeopleDoYouKnowDecision(TurnContext turn)
{
var people = GetLoopPeople(turn);
var speaker = ResolvePreferredGreetingName(turn, ResolveGreetingPresenceProfile(turn));
var reply = people.Count switch
{
0 => "Well if we're talking about people in my Loop, I do not know anyone yet.",
1 when string.IsNullOrWhiteSpace(speaker) =>
"Well if we're talking about people in my Loop, I know 1 person.",
1 => $"Well there is 1 person in our Loop. And it's you {speaker}.",
_ when string.IsNullOrWhiteSpace(speaker) =>
$"Well if we're talking about people in my Loop, I know {people.Count} people.",
_ => $"Well there are {people.Count} people in our Loop."
};
return new JiboInteractionDecision(
"robot_how_many_people_do_you_know",
reply,
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private JiboInteractionDecision BuildWhatIsTheLoopDecision(TurnContext turn)
{
var people = GetLoopPeople(turn);
var reply = people.Count == 0
? "The Loop is the people I know, and whose faces and voices I can learn to recognize. There can be up to 16 people in the Loop."
: $"The Loop is the group of people I know. They're the people whose voices and faces I can learn. Right now, my Loop is {JoinWithAnd(people.Select(person => person.DisplayName).ToArray())}.";
return new JiboInteractionDecision(
"robot_what_is_the_loop",
reply,
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private IReadOnlyList<PersonRecord> GetLoopPeople(TurnContext turn)
{
if (cloudStateStore is null) return [];
var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop";
return cloudStateStore.GetPeople()
.Where(person => string.Equals(person.LoopId, loopId, StringComparison.OrdinalIgnoreCase))
.OrderBy(person => person.IsPrimary ? 0 : 1)
.ThenBy(person => person.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string JoinWithAnd(IReadOnlyList<string> values)
{
if (values.Count == 0) return string.Empty;
if (values.Count == 1) return values[0];
if (values.Count == 2) return $"{values[0]} and {values[1]}";
return $"{string.Join(", ", values.Take(values.Count - 1))}, and {values[^1]}";
}
private static string DescribeZodiacSign(DateOnly birthday)
{
return (birthday.Month, birthday.Day) switch
{
(3, >= 21) or (4, <= 19) => "I'm Aries",
(4, >= 20) or (5, <= 20) => "I'm Taurus",
(5, >= 21) or (6, <= 20) => "I'm Gemini",
(6, >= 21) or (7, <= 22) => "I'm Cancer",
(7, >= 23) or (8, <= 22) => "I'm Leo",
(8, >= 23) or (9, <= 22) => "I'm Virgo",
(9, >= 23) or (10, <= 22) => "I'm Libra",
(10, >= 23) or (11, <= 21) => "I'm Scorpio",
(11, >= 22) or (12, <= 21) => "I'm Sagittarius",
(12, >= 22) or (1, <= 19) => "I'm Capricorn",
(1, >= 20) or (2, <= 18) => "I'm Aquarius",
_ => "I'm Pisces"
};
}
private string BuildGenericReply(JiboExperienceCatalog catalog, string transcript, string lowered)
{
if (string.IsNullOrWhiteSpace(transcript)) return "I am listening.";
if (lowered.Contains("good morning", StringComparison.Ordinal))
return "Good morning! It is nice to hear your voice.";
if (lowered.Contains("good afternoon", StringComparison.Ordinal))
return "Good afternoon. I am happy to be here.";
return lowered.Contains("good night", StringComparison.Ordinal)
? "Good night. Sleep tight."
: randomizer.Choose(catalog.GenericFallbackReplies)
.Replace("{transcript}", transcript, StringComparison.Ordinal);
}
private JiboInteractionDecision BuildScriptedPersonalityDecision(
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
return ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
intentName,
preferredSnippets);
}
private JiboInteractionDecision BuildScriptedFavoriteAnimalDecision(
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
return ScriptedResponseDecisionBuilder.BuildScriptedFavoriteAnimalDecision(
catalog,
randomizer,
intentName,
preferredSnippets);
}
private JiboInteractionDecision BuildScriptedFriendDecision(
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyReply(catalog.FriendReplies, preferredSnippets),
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private JiboInteractionDecision BuildScriptedBestFriendDecision(
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyReply(catalog.BestFriendReplies, preferredSnippets),
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private JiboInteractionDecision BuildScriptedSingDecision(
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyReply(catalog.SingReplies, preferredSnippets),
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private JiboInteractionDecision BuildScriptedHolidaySingDecision(
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyReply(catalog.HolidaySingReplies, preferredSnippets),
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private JiboInteractionDecision BuildScriptedGreetingDecision(
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
return ScriptedResponseDecisionBuilder.BuildScriptedGreetingDecision(
catalog,
randomizer,
intentName,
preferredSnippets);
}
private JiboInteractionDecision BuildScriptedHolidayDecision(
IReadOnlyList<string> replies,
string intentName,
params string[] preferredSnippets)
{
return ScriptedResponseDecisionBuilder.BuildScriptedHolidayDecision(
replies,
randomizer,
intentName,
preferredSnippets);
}
private JiboInteractionDecision BuildScriptedHolidayTrackerDecision(
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
return ScriptedResponseDecisionBuilder.BuildScriptedHolidayTrackerDecision(
catalog,
randomizer,
intentName,
preferredSnippets);
}
private JiboInteractionDecision BuildScriptedHolidayGreetingDecision(
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
return ScriptedResponseDecisionBuilder.BuildScriptedHolidayGreetingDecision(
catalog,
randomizer,
intentName,
preferredSnippets);
}
private JiboInteractionDecision BuildScriptedHolidayTemplateDecision(
TurnContext turn,
GreetingPresenceProfile presence,
JiboExperienceCatalog catalog,
string intentName,
params string[] preferredSnippets)
{
var selected = ScriptedResponseDecisionBuilder.SelectLegacyReply(
catalog.HolidayReplies,
randomizer,
preferredSnippets);
return new JiboInteractionDecision(
intentName,
RenderHolidayTemplate(selected, turn, presence),
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
}
private string SelectLegacyPersonalityReply(JiboExperienceCatalog catalog, params string[] preferredSnippets)
{
return ScriptedResponseDecisionBuilder.SelectLegacyPersonalityReply(catalog, randomizer, preferredSnippets);
}
private string SelectLegacyGreetingReply(JiboExperienceCatalog catalog, params string[] preferredSnippets)
{
return ScriptedResponseDecisionBuilder.SelectLegacyGreetingReply(catalog, randomizer, preferredSnippets);
}
private string SelectLegacyReply(IReadOnlyList<string> replies, params string[] preferredSnippets)
{
return ScriptedResponseDecisionBuilder.SelectLegacyReply(replies, randomizer, preferredSnippets);
}
private string RenderHolidayTemplate(string template, TurnContext turn, GreetingPresenceProfile presence)
{
var ownerName = ResolvePreferredGreetingName(turn, presence);
var speakerName = !string.IsNullOrWhiteSpace(ownerName) ? ownerName : "you";
return template
.Replace("${speaker}'s", $"{speakerName}'s", StringComparison.OrdinalIgnoreCase)
.Replace("${speaker}", speakerName, StringComparison.OrdinalIgnoreCase)
.Replace("${loop.owner}", string.IsNullOrWhiteSpace(ownerName) ? string.Empty : ownerName,
StringComparison.OrdinalIgnoreCase)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
}
}

View File

@@ -0,0 +1,295 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services;
public sealed partial class JiboInteractionService
{
private async Task<JiboInteractionDecision> BuildWeatherReportDecisionAsync(
TurnContext turn,
string transcript,
CancellationToken cancellationToken)
{
var referenceLocalTime = TryResolveReferenceLocalTime(turn);
var catalog = await contentCache.GetCatalogAsync(cancellationToken);
var normalizedTranscript = NormalizeCommandPhrase(transcript);
var locationQuery = TryResolveWeatherLocationQuery(transcript);
var weatherDate = ResolveWeatherDateEntity(turn, transcript, normalizedTranscript, referenceLocalTime);
var isRangeForecastRequest = IsRangeForecastRequest(normalizedTranscript);
var isOpenEndedForecastRequest = IsOpenEndedForecastRequest(
normalizedTranscript,
weatherDate,
isRangeForecastRequest,
locationQuery);
if (ShouldDefaultForecastToTomorrow(
normalizedTranscript,
weatherDate,
isRangeForecastRequest,
isOpenEndedForecastRequest))
weatherDate = new WeatherDateEntity("tomorrow", 1, "Tomorrow");
if (weatherReportProvider is null)
return new JiboInteractionDecision(
"weather",
ChooseWeatherServiceDownReply(catalog));
var weatherCoordinates = string.IsNullOrWhiteSpace(locationQuery)
? TryResolveWeatherCoordinates(turn)
: null;
var useCelsius = ShouldUseCelsius(turn, transcript);
var isNextWeekForecast = IsNextWeekForecastRequest(normalizedTranscript, isRangeForecastRequest);
var isThisWeekForecast = IsThisWeekForecastRequest(normalizedTranscript, isRangeForecastRequest);
if (isNextWeekForecast || isThisWeekForecast || isOpenEndedForecastRequest)
{
const int rangeStartOffset = 1;
var rangeEndOffset = isThisWeekForecast
? ResolveThisWeekForecastEndOffset(referenceLocalTime)
: MaxWeatherForecastDayOffset;
var weeklySnapshots = new List<(int DayOffset, WeatherReportSnapshot Snapshot)>();
for (var offset = rangeStartOffset; offset <= rangeEndOffset; offset += 1)
{
WeatherReportSnapshot? weeklySnapshot;
try
{
weeklySnapshot = await weatherReportProvider.GetReportAsync(
new WeatherReportRequest(
locationQuery,
weatherCoordinates?.Latitude,
weatherCoordinates?.Longitude,
offset == 1,
useCelsius,
offset),
cancellationToken);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
weeklySnapshot = null;
}
if (weeklySnapshot is not null) weeklySnapshots.Add((offset, weeklySnapshot));
}
if (weeklySnapshots.Count == 0)
return new JiboInteractionDecision(
"weather",
"I couldn't fetch the weather right now. Please try again.");
var weeklySegments = BuildWeeklyForecastCardSegments(weeklySnapshots, referenceLocalTime);
var weeklySpokenReply = BuildWeeklyForecastSpokenReply(
weeklySegments,
weeklySnapshots[0].Snapshot.LocationName,
weeklySnapshots[0].Snapshot.UseCelsius,
isThisWeekForecast);
var weeklyWeatherPayload = BuildWeeklyWeatherSkillPayload(
weeklySpokenReply,
weeklySnapshots[0].Snapshot,
weeklySegments,
referenceLocalTime);
AddWeatherRequestDiagnostics(
weeklyWeatherPayload,
transcript,
normalizedTranscript,
locationQuery,
weatherDate,
isRangeForecastRequest,
isThisWeekForecast,
isNextWeekForecast);
return new JiboInteractionDecision(
"weather",
weeklySpokenReply,
"chitchat-skill",
weeklyWeatherPayload);
}
if (weatherDate.ForecastDayOffset > MaxWeatherForecastDayOffset)
return new JiboInteractionDecision(
"weather",
$"I can forecast up to {MaxWeatherForecastDayOffset} days ahead. Try tomorrow or another day this week.");
WeatherReportSnapshot? snapshot;
try
{
snapshot = await weatherReportProvider.GetReportAsync(
new WeatherReportRequest(
locationQuery,
weatherCoordinates?.Latitude,
weatherCoordinates?.Longitude,
string.Equals(weatherDate.DateEntity, "tomorrow", StringComparison.OrdinalIgnoreCase),
useCelsius,
weatherDate.ForecastDayOffset),
cancellationToken);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
snapshot = null;
}
if (snapshot is null)
return new JiboInteractionDecision(
"weather",
ChooseWeatherServiceDownReply(catalog));
var spokenReply = BuildWeatherSpokenReply(snapshot, weatherDate, catalog);
var weatherPayload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime);
AddWeatherRequestDiagnostics(
weatherPayload,
transcript,
normalizedTranscript,
locationQuery,
weatherDate,
isRangeForecastRequest,
isThisWeekForecast,
isNextWeekForecast);
return new JiboInteractionDecision(
"weather",
spokenReply,
"chitchat-skill",
weatherPayload);
}
private async Task<JiboInteractionDecision> BuildCommuteReportDecisionAsync(
TurnContext turn,
CancellationToken cancellationToken)
{
var catalog = await contentCache.GetCatalogAsync(cancellationToken);
if (commuteReportProvider is null)
return new JiboInteractionDecision(
"commute",
ChooseCommuteServiceDownReply(catalog));
CommuteReportSnapshot? snapshot;
try
{
snapshot = await commuteReportProvider.GetReportAsync(turn, cancellationToken);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
snapshot = null;
}
if (snapshot is null)
return new JiboInteractionDecision(
"commute",
ChooseCommuteServiceDownReply(catalog));
if (snapshot.RequiresSetup)
return new JiboInteractionDecision(
"commute_setup",
ChooseCommuteAppSetupReply(catalog));
return new JiboInteractionDecision(
"commute",
BuildCommuteSpokenReply(snapshot, catalog));
}
private async Task<JiboInteractionDecision> BuildCalendarReportDecisionAsync(
TurnContext turn,
CancellationToken cancellationToken)
{
var catalog = await contentCache.GetCatalogAsync(cancellationToken);
if (calendarReportProvider is null)
return new JiboInteractionDecision(
"calendar",
ChooseCalendarServiceDownReply(catalog));
CalendarReportSnapshot? snapshot;
try
{
snapshot = await calendarReportProvider.GetReportAsync(turn, cancellationToken);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
snapshot = null;
}
if (snapshot is null)
return new JiboInteractionDecision(
"calendar",
ChooseCalendarServiceDownReply(catalog));
return new JiboInteractionDecision(
"calendar",
BuildCalendarSpokenReply(snapshot, catalog));
}
private async Task<JiboInteractionDecision> BuildNewsDecisionAsync(
TurnContext turn,
string transcript,
JiboExperienceCatalog catalog,
CancellationToken cancellationToken)
{
var preferredCategories = ResolvePreferredNewsCategories(turn, transcript);
var requestedHeadlineCount = MaxNewsHeadlines;
if (newsBriefingProvider is not null)
try
{
var snapshot = await newsBriefingProvider.GetBriefingAsync(
new NewsBriefingRequest(preferredCategories, requestedHeadlineCount),
cancellationToken);
if (snapshot?.Headlines.Count > 0)
return BuildProviderNewsDecision(
snapshot,
catalog,
preferredCategories,
requestedHeadlineCount);
var providerStatus = ResolveNewsProviderStatus(snapshot);
var providerMessage = snapshot?.ProviderMessage;
var providerEndpoint = snapshot?.ProviderEndpoint;
var providerHttpStatusCode = snapshot?.ProviderHttpStatusCode;
var providerErrorCode = snapshot?.ProviderErrorCode;
var fallbackBriefingWhenEmpty = randomizer.Choose(catalog.NewsBriefings);
return BuildNewsDecision(
fallbackBriefingWhenEmpty,
null,
preferredCategories.Count > 0 ? preferredCategories : null,
null,
BuildNewsProviderDiagnostics(
providerStatus,
preferredCategories,
requestedHeadlineCount,
snapshot?.Headlines.Count ?? 0,
providerMessage,
providerHttpStatusCode,
providerEndpoint,
providerErrorCode));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// Provider failures should never block baseline news behavior.
var fallbackBriefingOnError = randomizer.Choose(catalog.NewsBriefings);
return BuildNewsDecision(
fallbackBriefingOnError,
null,
preferredCategories.Count > 0 ? preferredCategories : null,
null,
BuildNewsProviderDiagnostics(
"provider_exception",
preferredCategories,
requestedHeadlineCount));
}
var fallbackBriefing = randomizer.Choose(catalog.NewsBriefings);
return BuildNewsDecision(
fallbackBriefing,
null,
preferredCategories.Count > 0 ? preferredCategories : null,
null,
BuildNewsProviderDiagnostics(
"provider_unavailable",
preferredCategories,
requestedHeadlineCount));
}
}

View File

@@ -0,0 +1,687 @@
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
namespace Jibo.Cloud.Application.Services;
public sealed partial class JiboInteractionService
{
private static string EscapeForEsml(string value)
{
return value
.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal)
.Replace("\"", "&quot;", StringComparison.Ordinal);
}
private static string BuildWeatherSpokenReply(
WeatherReportSnapshot snapshot,
WeatherDateEntity weatherDate,
JiboExperienceCatalog catalog)
{
var unit = snapshot.UseCelsius ? "Celsius" : "Fahrenheit";
var summary = string.IsNullOrWhiteSpace(snapshot.Summary)
? "partly cloudy"
: snapshot.Summary.Trim().TrimEnd('.');
var location = string.IsNullOrWhiteSpace(snapshot.LocationName)
? "your area"
: NormalizeLocationForSpeech(snapshot.LocationName);
if (weatherDate.ForecastDayOffset > 0)
{
if (weatherDate.ForecastDayOffset != 1)
{
var highText = snapshot.HighTemperature is null
? null
: $"a high near {snapshot.HighTemperature.Value} degrees {unit}";
var lowText = snapshot.LowTemperature is null
? null
: $"a low around {snapshot.LowTemperature.Value} degrees {unit}";
var tempRange = highText is null && lowText is null
? string.Empty
: highText is not null && lowText is not null
? $" with {highText} and {lowText}"
: $" with {highText ?? lowText}";
var forecastLeadIn = string.IsNullOrWhiteSpace(weatherDate.ForecastLeadIn)
? "Tomorrow"
: weatherDate.ForecastLeadIn;
return $"Let's look at the weather. {forecastLeadIn} in {location}, it looks {summary}{tempRange}.";
}
var highValue = snapshot.HighTemperature ?? snapshot.Temperature;
var lowValue = snapshot.LowTemperature ?? snapshot.Temperature;
var introTemplate = ChooseWeatherTemplate(
catalog.WeatherTomorrowIntroReplies,
"Let's look at the weather.");
var highLowTemplate = ChooseWeatherTemplate(
catalog.WeatherTomorrowHighLowReplies,
"Tomorrow's high will be ${skill.weather.tomorrow.highTemp} and the low will be ${skill.weather.tomorrow.lowTemp}.");
var intro = RenderWeatherTemplate(
introTemplate,
location,
summary,
highValue,
lowValue,
unit,
weatherDate.ForecastLeadIn ?? string.Empty);
var highLow = RenderWeatherTemplate(
highLowTemplate,
location,
summary,
highValue,
lowValue,
unit,
weatherDate.ForecastLeadIn ?? string.Empty);
var forecastSentenceLeadIn = string.IsNullOrWhiteSpace(weatherDate.ForecastLeadIn)
? "Tomorrow"
: weatherDate.ForecastLeadIn;
return $"{intro} {forecastSentenceLeadIn} in {location}, it looks {summary}. {highLow}";
}
var currentIntro = RenderWeatherTemplate(
ChooseWeatherTemplate(catalog.WeatherIntroReplies, "For your weather."),
location,
summary,
snapshot.Temperature,
snapshot.Temperature,
unit,
string.Empty);
var currentHighLow = RenderWeatherTemplate(
ChooseWeatherTemplate(
catalog.WeatherTodayHighLowReplies,
"Today's high is ${skill.weather.today.highTemp}, and the low is ${skill.weather.today.lowTemp}."),
location,
summary,
snapshot.HighTemperature ?? snapshot.Temperature,
snapshot.LowTemperature ?? snapshot.Temperature,
unit,
string.Empty);
return
$"{currentIntro} In {location}, it's {summary} and {snapshot.Temperature} degrees {unit}. {currentHighLow}";
}
private static string BuildCommuteSpokenReply(
CommuteReportSnapshot snapshot,
JiboExperienceCatalog catalog)
{
var duration = snapshot.DurationMinutes;
var durationText = duration <= 1 ? "1 minute" : $"{duration} minutes";
var minutesLeft = snapshot.MinutesUntilWork;
var minutesLeftText = minutesLeft <= 1 ? "1 minute" : $"{Math.Abs(minutesLeft)} minutes";
var mode = string.IsNullOrWhiteSpace(snapshot.Mode) ? "driving" : snapshot.Mode.Trim();
var template = ChooseCommuteTemplate(snapshot, catalog, mode);
var reply = RenderCommuteTemplate(template, durationText, minutesLeftText);
if (minutesLeft is > 0 and < 30)
{
var minutesTemplate = ChooseShortestTemplate(catalog.CommuteMinutesLeftReplies)
?? "That's in about ${skill.commute.minsLeft} minutes.";
reply = $"{reply} {RenderCommuteTemplate(minutesTemplate, durationText, minutesLeftText)}";
}
if (minutesLeft is <= 0 or >= 120)
return reply.Replace(" ", " ", StringComparison.Ordinal).Trim();
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 is > 0 and <= 10;
var isNormal = !isLate && !isHurry;
var isFarAway = minutesUntilWork is > 120 or < -30;
var hasTrafficSeverity = minutesUntilWork > 0;
var isTerrible = hasTrafficSeverity && extraMinutes >= 15;
var isPoor = hasTrafficSeverity && extraMinutes >= 5;
var loweredMode = mode.Trim().ToLowerInvariant();
IReadOnlyList<string> candidates = loweredMode switch
{
"walking" when isHurry => catalog.CommuteTransportHurryReplies,
"walking" when isLate => catalog.CommuteTransportLateReplies,
"walking" => catalog.CommuteTransportNormalReplies,
"transit" when isHurry => catalog.CommuteTransportHurryReplies,
"transit" when isLate => catalog.CommuteTransportLateReplies,
"transit" => catalog.CommuteTransportNormalReplies,
"bicycling" when isHurry => catalog.CommuteDriveHurryReplies,
"bicycling" when isLate => catalog.CommuteDriveLateReplies,
"bicycling" => catalog.CommuteDriveNormalReplies,
_ when isFarAway => catalog.CommuteNowReplies,
_ when isTerrible => catalog.CommuteDriveTerribleReplies,
_ when isPoor => catalog.CommuteDrivePoorReplies,
_ when isHurry => catalog.CommuteDriveHurryReplies,
_ when isLate => catalog.CommuteDriveLateReplies,
_ when isNormal => catalog.CommuteDriveNormalReplies,
_ => catalog.CommuteNowReplies
};
if (candidates.Count == 0)
return "For your commute, it should take about ${skill.commute.durationMins} minutes.";
var selected = ChooseShortestTemplate(candidates);
return string.IsNullOrWhiteSpace(selected)
? "For your commute, it should take about ${skill.commute.durationMins} minutes."
: selected!;
}
private static string ChooseCommuteDepartTimeTemplate(
CommuteReportSnapshot snapshot,
JiboExperienceCatalog catalog,
string mode)
{
var loweredMode = mode.Trim().ToLowerInvariant();
var templates = snapshot.MinutesUntilWork <= 0
? catalog.CommuteDepartTimeNotNormalReplies
: catalog.CommuteDepartTimeNormalReplies;
if (templates.Count == 0) return string.Empty;
var selected = ChooseShortestTemplate(templates);
if (!string.IsNullOrWhiteSpace(selected)) return selected!;
return loweredMode switch
{
"walking" => "If you leave at the usual time, that should work out fine.",
"transit" => "If you leave at the usual time, that should work out fine.",
_ => "If you leave at the usual time, that should work out fine."
};
}
private static string RenderCommuteTemplate(string template, string durationText, string minutesLeftText)
{
return template
.Replace("${skill.commute.durationMins}", durationText, StringComparison.OrdinalIgnoreCase)
.Replace("${skill.commute.minsLeft}", minutesLeftText, StringComparison.OrdinalIgnoreCase)
.Replace("${speaker}", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
}
private static string? ChooseShortestTemplate(IEnumerable<string> templates)
{
return templates
.Where(static template => !string.IsNullOrWhiteSpace(template))
.OrderBy(static template => template.Length)
.FirstOrDefault();
}
private static string BuildWeeklyForecastSpokenReply(
IReadOnlyList<WeatherForecastCardSegment> segments,
string? locationName,
bool useCelsius,
bool isThisWeekForecast)
{
if (segments.Count == 0) return "I couldn't build a forecast right now.";
var location = string.IsNullOrWhiteSpace(locationName)
? "your area"
: NormalizeLocationForSpeech(locationName);
var unit = useCelsius ? "Celsius" : "Fahrenheit";
var leadIn = isThisWeekForecast
? $"Here's the rest of this week's forecast in {location}."
: $"I can share the next five-day forecast in {location}.";
return
$"{leadIn} {string.Join(" ", segments.Select(static segment => segment.SpokenLine))} Temperatures are in {unit}.";
}
private static IReadOnlyList<WeatherForecastCardSegment> BuildWeeklyForecastCardSegments(
IReadOnlyList<(int DayOffset, WeatherReportSnapshot Snapshot)> snapshots,
DateTimeOffset? referenceLocalTime)
{
if (snapshots.Count == 0) return [];
var resolvedReference = referenceLocalTime ?? DateTimeOffset.UtcNow;
var referenceDate = resolvedReference.Date;
return [.. snapshots
.OrderBy(static item => item.DayOffset)
.Take(MaxWeatherForecastDayOffset)
.Select(item =>
{
var dayName = referenceDate.AddDays(item.DayOffset).ToString("dddd", CultureInfo.InvariantCulture);
var summary = string.IsNullOrWhiteSpace(item.Snapshot.Summary)
? "partly cloudy"
: item.Snapshot.Summary.Trim().TrimEnd('.');
var high = item.Snapshot.HighTemperature ?? item.Snapshot.Temperature;
var low = item.Snapshot.LowTemperature ?? item.Snapshot.Temperature;
var iconReference = new DateTimeOffset(
resolvedReference.Date.AddDays(item.DayOffset).AddHours(12),
resolvedReference.Offset);
var icon = ResolveWeatherAnimationIcon(item.Snapshot, iconReference);
var unit = item.Snapshot.UseCelsius ? "C" : "F";
var temperatureBand = ResolveWeatherTemperatureBand(high, item.Snapshot.UseCelsius);
var spokenLine = $"{dayName}: {summary}, high {high}, low {low}.";
return new WeatherForecastCardSegment(
dayName,
summary,
high,
low,
icon,
unit,
temperatureBand,
spokenLine);
})];
}
private static IDictionary<string, object?> BuildWeeklyWeatherSkillPayload(
string spokenReply,
WeatherReportSnapshot snapshot,
IReadOnlyList<WeatherForecastCardSegment> segments,
DateTimeOffset? referenceLocalTime)
{
var payload = BuildWeatherSkillPayload(spokenReply, snapshot, referenceLocalTime);
payload["weather_view_kind"] = "weatherWeekly";
payload["weather_view_mode"] = "forecast";
payload["weather_weekly_cards"] = segments
.Select(static segment => new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["weather_day"] = segment.DayName,
["weather_summary"] = segment.Summary,
["weather_icon"] = segment.Icon,
["weather_high"] = segment.High,
["weather_low"] = segment.Low,
["weather_unit"] = segment.Unit,
["weather_theme"] = segment.Theme,
["weather_spoken_line"] = segment.SpokenLine
})
.ToArray();
return payload;
}
private static void AddWeatherRequestDiagnostics(
IDictionary<string, object?> payload,
string transcript,
string normalizedTranscript,
string? locationQuery,
WeatherDateEntity weatherDate,
bool isRangeForecastRequest,
bool isThisWeekForecast,
bool isNextWeekForecast)
{
payload["weather_request_transcript"] = transcript;
payload["weather_request_normalized"] = normalizedTranscript;
payload["weather_request_location_query"] = locationQuery;
payload["weather_request_date_entity"] = weatherDate.DateEntity;
payload["weather_request_forecast_day_offset"] = weatherDate.ForecastDayOffset;
payload["weather_request_range"] = isRangeForecastRequest;
payload["weather_request_this_week"] = isThisWeekForecast;
payload["weather_request_next_week"] = isNextWeekForecast;
}
private static bool IsNextWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript) || !isRangeForecastRequest) return false;
if (normalizedTranscript.Contains("next week", StringComparison.Ordinal)) return true;
if (!normalizedTranscript.Contains("next", StringComparison.Ordinal)) return false;
return normalizedTranscript.Contains("forecast next", StringComparison.Ordinal) ||
normalizedTranscript.Contains("forecast for next", StringComparison.Ordinal);
}
private static bool IsRangeForecastRequest(string normalizedTranscript)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript)) return false;
if (normalizedTranscript.Contains("next week", StringComparison.Ordinal) ||
normalizedTranscript.Contains("this week", StringComparison.Ordinal) ||
normalizedTranscript.Contains("weekend", StringComparison.Ordinal))
return true;
return normalizedTranscript.Contains("forecast next", StringComparison.Ordinal) ||
normalizedTranscript.Contains("forecast for next", StringComparison.Ordinal);
}
private static bool IsThisWeekForecastRequest(string normalizedTranscript, bool isRangeForecastRequest)
{
return isRangeForecastRequest &&
!string.IsNullOrWhiteSpace(normalizedTranscript) &&
normalizedTranscript.Contains("this week", StringComparison.Ordinal) &&
!normalizedTranscript.Contains("weekend", StringComparison.Ordinal);
}
private static bool IsOpenEndedForecastRequest(
string normalizedTranscript,
WeatherDateEntity weatherDate,
bool isRangeForecastRequest,
string? locationQuery)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript) ||
!string.IsNullOrWhiteSpace(locationQuery) ||
isRangeForecastRequest ||
weatherDate.ForecastDayOffset > 0 ||
!normalizedTranscript.Contains("forecast", StringComparison.Ordinal))
return false;
return !MatchesAny(
normalizedTranscript,
"today",
"today s",
"today's",
"tonight",
"right now",
"current weather",
"currently");
}
private static int ResolveThisWeekForecastEndOffset(DateTimeOffset? referenceLocalTime)
{
var resolvedReference = referenceLocalTime ?? DateTimeOffset.UtcNow;
var daysUntilSunday = ((int)DayOfWeek.Sunday - (int)resolvedReference.DayOfWeek + 7) % 7;
var endOffset = Math.Min(MaxWeatherForecastDayOffset, daysUntilSunday);
return Math.Max(1, endOffset);
}
private static bool ShouldDefaultForecastToTomorrow(
string normalizedTranscript,
WeatherDateEntity weatherDate,
bool isRangeForecastRequest,
bool isOpenEndedForecastRequest)
{
if (weatherDate.ForecastDayOffset > 0 ||
isOpenEndedForecastRequest ||
isRangeForecastRequest ||
string.IsNullOrWhiteSpace(normalizedTranscript) ||
!normalizedTranscript.Contains("forecast", StringComparison.Ordinal))
return false;
return !MatchesAny(
normalizedTranscript,
"today",
"today s",
"today's",
"tonight",
"right now",
"current weather",
"currently");
}
private static IDictionary<string, object?> BuildWeatherSkillPayload(
string spokenReply,
WeatherReportSnapshot snapshot,
DateTimeOffset? referenceLocalTime)
{
var weatherIcon = ResolveWeatherAnimationIcon(snapshot, referenceLocalTime);
var promptToken = ResolveWeatherPromptToken(weatherIcon);
var highTemperature = snapshot.HighTemperature ?? snapshot.Temperature;
var lowTemperature = snapshot.LowTemperature ?? snapshot.Temperature;
var temperatureUnit = snapshot.UseCelsius ? "C" : "F";
var temperatureBand = ResolveWeatherTemperatureBand(highTemperature, snapshot.UseCelsius);
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "report-skill",
["cloudSkill"] = "weather",
["esml"] =
$"<speak><anim cat='weather' meta='{weatherIcon}' nonBlocking='true' /><break size='0.35'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeForEsml(spokenReply)}</es></speak>",
["mim_id"] = $"WeatherComment{promptToken}",
["mim_type"] = "announcement",
["prompt_id"] = $"WeatherComment{promptToken}_AN_13",
["prompt_sub_category"] = "AN",
["weather_view_enabled"] = true,
["weather_view_kind"] = "weatherHiLo",
["weather_view_mode"] = "current",
["weather_icon"] = weatherIcon,
["weather_summary"] = snapshot.Summary,
["weather_location"] = snapshot.LocationName,
["weather_high"] = highTemperature,
["weather_low"] = lowTemperature,
["weather_unit"] = temperatureUnit,
["weather_theme"] = temperatureBand
};
}
private static string ResolveWeatherAnimationIcon(
WeatherReportSnapshot snapshot,
DateTimeOffset? referenceLocalTime)
{
var isDaytime = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour is >= 6 and < 18;
var normalized = NormalizeCommandPhrase(
$"{snapshot.Condition ?? string.Empty} {snapshot.Summary ?? string.Empty}");
if (normalized.Contains("thunder", StringComparison.Ordinal) ||
normalized.Contains("drizzle", StringComparison.Ordinal) ||
normalized.Contains("rain", StringComparison.Ordinal))
return "rain";
if (normalized.Contains("snow", StringComparison.Ordinal)) return "snow";
if (normalized.Contains("sleet", StringComparison.Ordinal) ||
normalized.Contains("freezing rain", StringComparison.Ordinal) ||
normalized.Contains("ice", StringComparison.Ordinal))
return "sleet";
if (normalized.Contains("fog", StringComparison.Ordinal) ||
normalized.Contains("mist", StringComparison.Ordinal) ||
normalized.Contains("haze", StringComparison.Ordinal) ||
normalized.Contains("smoke", StringComparison.Ordinal))
return "fog";
if (normalized.Contains("wind", StringComparison.Ordinal)) return "wind";
if (normalized.Contains("partly cloudy", StringComparison.Ordinal) ||
normalized.Contains("scattered clouds", StringComparison.Ordinal) ||
normalized.Contains("few clouds", StringComparison.Ordinal))
return isDaytime ? "partly-cloudy-day" : "partly-cloudy-night";
if (normalized.Contains("cloud", StringComparison.Ordinal) ||
normalized.Contains("overcast", StringComparison.Ordinal))
return "cloudy";
if (normalized.Contains("clear", StringComparison.Ordinal) ||
normalized.Contains("sunny", StringComparison.Ordinal))
return isDaytime ? "clear-day" : "clear-night";
return isDaytime ? "clear-day" : "clear-night";
}
private static string ResolveWeatherPromptToken(string weatherIcon)
{
return weatherIcon switch
{
"clear-day" => "ClearDay",
"clear-night" => "ClearNight",
"rain" => "Rain",
"snow" => "Snow",
"sleet" => "Sleet",
"fog" => "Fog",
"wind" => "Wind",
"cloudy" => "Cloudy",
"partly-cloudy-day" => "PartlyCloudyDay",
"partly-cloudy-night" => "PartlyCloudyNight",
_ => "Cloudy"
};
}
private static string ResolveWeatherTemperatureBand(int highTemperature, bool useCelsius)
{
var hotThreshold = useCelsius ? 29 : 85;
var coldThreshold = useCelsius ? 4 : 40;
if (highTemperature > hotThreshold) return "Hot";
if (highTemperature < coldThreshold) return "Cold";
return "Normal";
}
private static string ChooseWeatherTemplate(IReadOnlyList<string> templates, string fallback)
{
var usableTemplates = templates.Where(static template => !string.IsNullOrWhiteSpace(template)).ToArray();
if (usableTemplates.Length == 0) return fallback;
return usableTemplates[0];
}
private static string RenderWeatherTemplate(
string template,
string location,
string summary,
int? highTemperature,
int? lowTemperature,
string unit,
string forecastLeadIn)
{
var rendered = template
.Replace("${skill.weather.today.highTemp}",
highTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
StringComparison.OrdinalIgnoreCase)
.Replace("${skill.weather.today.lowTemp}",
lowTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
StringComparison.OrdinalIgnoreCase)
.Replace("${skill.weather.tomorrow.highTemp}",
highTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
StringComparison.OrdinalIgnoreCase)
.Replace("${skill.weather.tomorrow.lowTemp}",
lowTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
StringComparison.OrdinalIgnoreCase)
.Replace("${skill.weather.summary}", summary, StringComparison.OrdinalIgnoreCase)
.Replace("${skill.weather.location}", location, StringComparison.OrdinalIgnoreCase)
.Replace("${skill.weather.prefix}",
string.IsNullOrWhiteSpace(forecastLeadIn) ? string.Empty : forecastLeadIn,
StringComparison.OrdinalIgnoreCase)
.Replace("{high}", highTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
StringComparison.OrdinalIgnoreCase)
.Replace("{low}", lowTemperature?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
StringComparison.OrdinalIgnoreCase)
.Replace("{unit}", unit, StringComparison.OrdinalIgnoreCase)
.Trim();
return rendered;
}
private static string ChooseWeatherServiceDownReply(JiboExperienceCatalog catalog)
{
var template = ChooseWeatherTemplate(
catalog.WeatherServiceDownReplies,
"I can't access weather info right now, sorry.");
return template.Trim();
}
private static string NormalizeLocationForSpeech(string text)
{
if (string.IsNullOrWhiteSpace(text)) return text;
return Regex.Replace(
text,
@"\b(?<token>[A-Z]{2,3})\b",
static match =>
{
var token = match.Groups["token"].Value;
if (!SpokenAbbreviationTokens.Contains(token)) return token;
return string.Join(".", token.ToCharArray()) + ".";
},
RegexOptions.CultureInvariant);
}
private static string ChooseCommuteServiceDownReply(JiboExperienceCatalog catalog)
{
var template = ChooseWeatherTemplate(
catalog.CommuteServiceDownReplies,
"Sorry, commute information isn't available right now.");
return template.Trim();
}
private string BuildCalendarSpokenReply(CalendarReportSnapshot snapshot, JiboExperienceCatalog catalog)
{
if (snapshot.EventSummaries.Count > 0 && snapshot.EventTimesOnAt.Count > 0)
{
var summary = snapshot.EventSummaries[0];
var time = snapshot.EventTimesOnAt[0];
var template = ChooseCalendarTemplate(
catalog.CalendarReplies,
"calendar summary",
"Your calendar says ${skill.calendar.eventSummaries.shift()}, ${skill.calendar.eventTimesOnAt.shift()}.");
if (template.Contains("${skill.calendar.eventSummaries.shift()}", StringComparison.OrdinalIgnoreCase) ||
template.Contains("${skill.calendar.eventTimesOnAt.shift()}", StringComparison.OrdinalIgnoreCase))
return template
.Replace("${skill.calendar.eventSummaries.shift()}", summary, StringComparison.OrdinalIgnoreCase)
.Replace("${skill.calendar.eventTimesOnAt.shift()}", time, StringComparison.OrdinalIgnoreCase)
.Replace("${speaker}", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
return $"Your calendar says {summary}, {time}.";
}
if (snapshot.TomorrowEventSummaries.Count > 0)
{
var template = ChooseCalendarTemplate(
catalog.CalendarReplies,
"calendar tomorrow",
"Looking at your calendar, there's nothing scheduled for the rest of the day today. Here's what's going on tomorrow.");
if (template.Contains("tomorrow", StringComparison.OrdinalIgnoreCase))
return template
.Replace("${speaker}", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
return
$"Looking at your calendar, there's nothing scheduled for the rest of the day today. Here's what's going on tomorrow: {snapshot.TomorrowEventSummaries[0]}.";
}
return ChooseCalendarNothingReply(catalog);
}
private static string ChooseCalendarTemplate(
IReadOnlyList<string> templates,
string mode,
string fallback)
{
if (templates.Count == 0) return fallback;
var loweredMode = mode.Trim().ToLowerInvariant();
var filtered = templates.Where(template =>
{
var lowered = template.ToLowerInvariant();
return loweredMode switch
{
"calendar summary" => lowered.Contains("event", StringComparison.OrdinalIgnoreCase) ||
lowered.Contains("summary", StringComparison.OrdinalIgnoreCase),
"calendar tomorrow" => lowered.Contains("tomorrow", StringComparison.OrdinalIgnoreCase),
_ => true
};
}).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 string ChooseCalendarNothingReply(JiboExperienceCatalog catalog)
{
return catalog.CalendarNothingTodayReplies.Count > 0
? randomizer.Choose(catalog.CalendarNothingTodayReplies)
: catalog.CalendarNothingReplies.Count > 0
? randomizer.Choose(catalog.CalendarNothingReplies)
: "Looking at your calendar, I don't see anything scheduled today.";
}
private string ChooseCalendarServiceDownReply(JiboExperienceCatalog catalog)
{
return catalog.CalendarServiceDownReplies.Count > 0
? randomizer.Choose(catalog.CalendarServiceDownReplies)
: "Looks like I can't access calendars right now. Sorry.";
}
}

View File

@@ -15,7 +15,8 @@ public sealed class JiboWebSocketService(
stateStore.OpenSession(envelope.Kind, null, envelope.Token, envelope.HostName, envelope.Path);
}
public async Task<IReadOnlyList<WebSocketReply>> HandleMessageAsync(WebSocketMessageEnvelope envelope, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<WebSocketReply>> HandleMessageAsync(WebSocketMessageEnvelope envelope,
CancellationToken cancellationToken = default)
{
var session = GetOrCreateSession(envelope);
session.LastSeenUtc = DateTimeOffset.UtcNow;
@@ -23,7 +24,8 @@ public sealed class JiboWebSocketService(
if (envelope.IsBinary)
{
var replies = await turnFinalizationService.HandleBinaryAudioAsync(session, envelope, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received", new Dictionary<string, object?>
await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received",
new Dictionary<string, object?>
{
["bytes"] = envelope.Binary?.Length ?? 0,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
@@ -50,7 +52,8 @@ public sealed class JiboWebSocketService(
})
.ToArray();
await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored", new Dictionary<string, object?>
await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored",
new Dictionary<string, object?>
{
["messageType"] = parsedType,
["activeTransID"] = session.TurnState.TransId,
@@ -65,7 +68,8 @@ public sealed class JiboWebSocketService(
WebSocketTurnFinalizationService.TryRecoverStalePendingListen(session, out staleListenAgeMs))
{
staleListenRecovered = true;
await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered", new Dictionary<string, object?>
await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered",
new Dictionary<string, object?>
{
["staleAgeMs"] = staleListenAgeMs,
["transID"] = session.TurnState.TransId,
@@ -80,7 +84,8 @@ public sealed class JiboWebSocketService(
case "CONTEXT":
{
var replies = await turnFinalizationService.HandleContextAsync(session, envelope, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received", new Dictionary<string, object?>
await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received",
new Dictionary<string, object?>
{
["transID"] = session.TurnState.TransId,
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
@@ -92,7 +97,8 @@ public sealed class JiboWebSocketService(
var replies = containsInlineTurnPayload
? await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken)
: WebSocketTurnFinalizationService.HandleListenSetup(session, envelope);
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed",
new Dictionary<string, object?>
{
["messageType"] = parsedType,
["replyCount"] = replies.Count,
@@ -104,10 +110,12 @@ public sealed class JiboWebSocketService(
}, cancellationToken);
return replies;
}
case "CLIENT_NLU" or "CLIENT_ASR":
case "CLIENT_NLU" or "CLIENT_ASR" or "TRIGGER":
{
var replies = await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
var replies =
await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed",
new Dictionary<string, object?>
{
["messageType"] = parsedType,
["replyCount"] = replies.Count,
@@ -124,19 +132,14 @@ public sealed class JiboWebSocketService(
private static string ReadMessageType(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return "UNKNOWN";
}
if (string.IsNullOrWhiteSpace(text)) return "UNKNOWN";
try
{
using var document = JsonDocument.Parse(text);
if (document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String)
{
return type.GetString() ?? "UNKNOWN";
}
}
catch
{
return "TEXT";
@@ -147,25 +150,18 @@ public sealed class JiboWebSocketService(
private static bool ContainsInlineTurnPayload(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
if (string.IsNullOrWhiteSpace(text)) return false;
try
{
using var document = JsonDocument.Parse(text);
if (!document.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!document.RootElement.TryGetProperty("data", out var data) ||
data.ValueKind != JsonValueKind.Object) return false;
if (data.TryGetProperty("text", out var transcript) &&
transcript.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(transcript.GetString()))
{
return true;
}
return data.TryGetProperty("asr", out var asr) &&
asr.ValueKind == JsonValueKind.Object &&
@@ -186,10 +182,7 @@ public sealed class JiboWebSocketService(
var transId = session.TurnState.TransId ?? session.LastTransId ?? string.Empty;
var rules = session.TurnState.ListenRules;
if (string.IsNullOrWhiteSpace(text))
{
return (transId, rules);
}
if (string.IsNullOrWhiteSpace(text)) return (transId, rules);
try
{
@@ -199,9 +192,7 @@ public sealed class JiboWebSocketService(
if (root.TryGetProperty("transID", out var transIdValue) &&
transIdValue.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(transIdValue.GetString()))
{
transId = transIdValue.GetString()!;
}
if (root.TryGetProperty("data", out var data) &&
data.ValueKind == JsonValueKind.Object &&
@@ -214,10 +205,7 @@ public sealed class JiboWebSocketService(
.Where(static rule => !string.IsNullOrWhiteSpace(rule))
.ToArray();
if (parsedRules.Length > 0)
{
rules = parsedRules;
}
if (parsedRules.Length > 0) rules = parsedRules;
}
}
catch

View File

@@ -5,5 +5,9 @@ namespace Jibo.Cloud.Application.Services;
public sealed class NullProtocolTelemetrySink : IProtocolTelemetrySink
{
public Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordAsync(ProtocolEnvelope envelope, ProtocolDispatchResult result,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}

View File

@@ -4,7 +4,14 @@ namespace Jibo.Cloud.Application.Services;
public sealed class NullTurnTelemetrySink : ITurnTelemetrySink
{
public Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}

View File

@@ -5,9 +5,33 @@ namespace Jibo.Cloud.Application.Services;
public sealed class NullWebSocketTelemetrySink : IWebSocketTelemetrySink
{
public Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType,
IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session,
IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task RecordConnectionClosedAsync(WebSocketMessageEnvelope envelope, CloudSession session, string reason,
CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}

View File

@@ -12,5 +12,6 @@ public static class OpenJiboCloudBuildInfo
public static string SpokenVersion => $"Cloud version {VersionWords}.";
public static string EsmlVersion => $"Cloud version<break time='10ms'/> {VersionWords.Replace(" ", "<break time='10ms' />")}.";
public static string EsmlVersion =>
$"Cloud version<break time='10ms'/> {VersionWords.Replace(" ", "<break time='10ms' />")}.";
}

View File

@@ -1,7 +1,7 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
using System.Text.Json;
using System.Text.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services;
@@ -41,6 +41,7 @@ internal static class PersonalReportOrchestrator
"yeah",
"yep",
"yup",
"uh huh",
"sure",
"ok",
"okay",
@@ -58,6 +59,8 @@ internal static class PersonalReportOrchestrator
"maybe later"
];
private static readonly Regex NameNoiseRegex = new("[^a-zA-Z\\-\\s']", RegexOptions.Compiled);
public static async Task<JiboInteractionDecision?> TryBuildDecisionAsync(
TurnContext turn,
string semanticIntent,
@@ -67,36 +70,33 @@ internal static class PersonalReportOrchestrator
IJiboRandomizer randomizer,
IPersonalMemoryStore personalMemoryStore,
Func<TurnContext, string, CancellationToken, Task<JiboInteractionDecision>> buildWeatherDecisionAsync,
Func<TurnContext, CancellationToken, Task<JiboInteractionDecision>> buildCalendarDecisionAsync,
Func<TurnContext, CancellationToken, Task<JiboInteractionDecision>> buildCommuteDecisionAsync,
Func<TurnContext, PersonalMemoryTenantScope> tenantScopeResolver,
CancellationToken cancellationToken)
{
var state = ReadState(turn);
var isActiveState = !string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase);
if (!isActiveState && !string.Equals(semanticIntent, "personal_report", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (!isActiveState &&
!string.Equals(semanticIntent, "personal_report", StringComparison.OrdinalIgnoreCase)) return null;
var toggles = ApplyInlineToggleHints(
ReadServiceToggles(turn),
loweredTranscript,
out var inlineToggleSummary);
if (ContainsAnyPhrase(loweredTranscript, CancelPhrases))
{
return BuildCancelledDecision(toggles);
}
if (ContainsAnyPhrase(loweredTranscript, CancelPhrases)) return BuildCancelledDecision(toggles);
if (!isActiveState)
{
var contextUpdates = BuildContextUpdates(
AwaitingOptInState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: ReadString(turn, UserNameMetadataKey),
userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false,
lastServiceError: string.Empty);
ReadString(turn, UserNameMetadataKey),
ReadBool(turn, UserVerifiedMetadataKey) ?? false,
string.Empty);
var reply = string.IsNullOrWhiteSpace(inlineToggleSummary)
? "Would you like your personal report now?"
@@ -105,13 +105,11 @@ internal static class PersonalReportOrchestrator
return new JiboInteractionDecision(
"personal_report_opt_in",
reply,
SkillPayload: BuildYesNoPromptPayload(),
ContextUpdates: contextUpdates);
}
if (string.IsNullOrWhiteSpace(loweredTranscript))
{
return BuildNoInputDecision(turn, state, toggles);
}
if (string.IsNullOrWhiteSpace(loweredTranscript)) return BuildNoInputDecision(turn, state, toggles);
switch (state)
{
@@ -121,81 +119,73 @@ internal static class PersonalReportOrchestrator
var scope = tenantScopeResolver(turn);
var knownName = ReadString(turn, UserNameMetadataKey) ?? personalMemoryStore.GetName(scope);
if (!string.IsNullOrWhiteSpace(knownName))
{
return new JiboInteractionDecision(
"personal_report_verify_user",
$"I think this is {knownName}. Is that right?",
SkillPayload: BuildYesNoPromptPayload(),
ContextUpdates: BuildContextUpdates(
AwaitingIdentityConfirmationState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: knownName,
userVerified: false,
lastServiceError: string.Empty));
}
knownName,
false,
string.Empty));
return new JiboInteractionDecision(
"personal_report_request_name",
"Who is this?",
ContextUpdates: BuildContextUpdates(
AwaitingIdentityNameState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
null,
false,
string.Empty));
}
if (IsNegativeReply(loweredTranscript))
{
return BuildDeclinedDecision(toggles);
}
if (IsNegativeReply(loweredTranscript)) return BuildDeclinedDecision(toggles);
if (!string.IsNullOrWhiteSpace(inlineToggleSummary))
{
return new JiboInteractionDecision(
"personal_report_opt_in",
$"{inlineToggleSummary} Would you like your personal report now?",
SkillPayload: BuildYesNoPromptPayload(),
ContextUpdates: BuildContextUpdates(
AwaitingOptInState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: ReadString(turn, UserNameMetadataKey),
userVerified: false,
lastServiceError: string.Empty));
}
ReadString(turn, UserNameMetadataKey),
false,
string.Empty));
return BuildNoMatchDecision(
turn,
state,
"Please say yes to start your personal report, or no to skip it.",
toggles,
userName: ReadString(turn, UserNameMetadataKey),
userVerified: false);
ReadString(turn, UserNameMetadataKey),
false);
case AwaitingIdentityConfirmationState:
{
var currentName = ReadString(turn, UserNameMetadataKey);
if (string.IsNullOrWhiteSpace(currentName))
{
return new JiboInteractionDecision(
"personal_report_request_name",
"Who is this?",
ContextUpdates: BuildContextUpdates(
AwaitingIdentityNameState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
}
null,
false,
string.Empty));
if (IsAffirmativeReply(loweredTranscript))
{
return await BuildDeliveredReportDecisionAsync(
turn,
catalog,
@@ -203,46 +193,43 @@ internal static class PersonalReportOrchestrator
toggles,
currentName,
buildWeatherDecisionAsync,
buildCalendarDecisionAsync,
buildCommuteDecisionAsync,
cancellationToken);
}
if (IsNegativeReply(loweredTranscript))
{
return new JiboInteractionDecision(
"personal_report_request_name",
"Okay, who is this?",
ContextUpdates: BuildContextUpdates(
AwaitingIdentityNameState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
}
null,
false,
string.Empty));
return BuildNoMatchDecision(
turn,
state,
$"Please answer yes or no. Is this {currentName}?",
toggles,
userName: currentName,
userVerified: false);
currentName,
false);
}
case AwaitingIdentityNameState:
{
var parsedName = TryExtractName(loweredTranscript);
if (string.IsNullOrWhiteSpace(parsedName))
{
return BuildNoMatchDecision(
turn,
state,
"Tell me your name like this: my name is Alex.",
toggles,
userName: null,
userVerified: false);
}
null,
false);
personalMemoryStore.SetName(tenantScopeResolver(turn), parsedName);
return await BuildDeliveredReportDecisionAsync(
@@ -252,6 +239,8 @@ internal static class PersonalReportOrchestrator
toggles,
parsedName,
buildWeatherDecisionAsync,
buildCalendarDecisionAsync,
buildCommuteDecisionAsync,
cancellationToken);
}
@@ -267,49 +256,123 @@ internal static class PersonalReportOrchestrator
PersonalReportServiceToggles toggles,
string userName,
Func<TurnContext, string, CancellationToken, Task<JiboInteractionDecision>> buildWeatherDecisionAsync,
Func<TurnContext, CancellationToken, Task<JiboInteractionDecision>> buildCalendarDecisionAsync,
Func<TurnContext, CancellationToken, Task<JiboInteractionDecision>> buildCommuteDecisionAsync,
CancellationToken cancellationToken)
{
var reportSections = new List<string> { $"Great, {userName}. Here is your personal report." };
var reportSections = new List<string>
{
RenderPersonalReportTemplate(
ChoosePersonalReportTemplate(
catalog.PersonalReportKickOffReplies,
"Okay. Here's your personal report."),
userName)
};
var serviceError = string.Empty;
IDictionary<string, object?>? weatherSkillPayload = null;
if (toggles.WeatherEnabled)
{
var weatherDecision = await buildWeatherDecisionAsync(turn, "weather", cancellationToken);
weatherSkillPayload = weatherDecision.SkillPayload;
reportSections.Add("Weather.");
reportSections.Add(weatherDecision.ReplyText);
if (IsWeatherErrorReply(weatherDecision.ReplyText))
{
serviceError = "weather";
}
if (IsWeatherErrorReply(weatherDecision.ReplyText)) serviceError = "weather";
}
if (toggles.CalendarEnabled)
{
reportSections.Add(randomizer.Choose(catalog.CalendarReplies));
var calendarReply = (await buildCalendarDecisionAsync(turn, cancellationToken)).ReplyText;
if (!string.IsNullOrWhiteSpace(calendarReply))
{
reportSections.Add(calendarReply);
var calendarOutro = ChooseShortestTemplate(catalog.CalendarOutroReplies);
if (!string.IsNullOrWhiteSpace(calendarOutro))
reportSections.Add(RenderPersonalReportTemplate(calendarOutro!, userName));
}
}
if (toggles.CommuteEnabled)
{
reportSections.Add(randomizer.Choose(catalog.CommuteReplies));
var commuteReply = (await buildCommuteDecisionAsync(turn, cancellationToken)).ReplyText;
var commuteSnippet = ChooseFirstSentence(commuteReply);
if (!string.IsNullOrWhiteSpace(commuteSnippet))
reportSections.Add(commuteSnippet);
}
if (toggles.NewsEnabled)
{
reportSections.Add(randomizer.Choose(catalog.NewsBriefings));
reportSections.Add(
RenderReportSkillTemplate(
ChooseReportSkillTemplate(
catalog.NewsIntroReplies,
catalog.NewsCategoryIntroReplies,
"Here's today's news, from the associated press."),
userName));
reportSections.Add(ChooseShortestBriefing(catalog.NewsBriefings));
reportSections.Add(
RenderReportSkillTemplate(
ChooseReportSkillTemplate(
catalog.NewsOutroReplies,
[],
"And that's what's new in the news."),
userName));
}
reportSections.Add("That is your personal report.");
reportSections.Add(
RenderPersonalReportTemplate(
ChoosePersonalReportTemplate(
catalog.PersonalReportOutroReplies,
"And that's your report for the day. I hope you had as much fun as I did."),
userName));
var reportText = string.Join(" ", reportSections);
return new JiboInteractionDecision(
"personal_report_delivered",
string.Join(" ", reportSections),
ContextUpdates: BuildContextUpdates(
reportText,
"report-skill",
BuildPersonalReportSkillPayload(reportText, weatherSkillPayload),
BuildContextUpdates(
IdleState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName,
userVerified: true,
lastServiceError: serviceError));
true,
serviceError));
}
private static IDictionary<string, object?> BuildPersonalReportSkillPayload(
string reportText,
IDictionary<string, object?>? weatherSkillPayload)
{
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["skillId"] = "report-skill",
["cloudSkill"] = "personal_report",
["mim_id"] = "runtime-personal-report",
["mim_type"] = "announcement",
["prompt_id"] = "PersonalReport_AN_01",
["prompt_sub_category"] = "AN",
["esml"] =
$"<speak><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeForEsml(reportText)}</es></speak>",
["personal_report_report_text"] = reportText
};
if (weatherSkillPayload is null) return payload;
foreach (var (key, value) in weatherSkillPayload)
if (!string.Equals(key, "esml", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(key, "skillId", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(key, "cloudSkill", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(key, "mim_id", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(key, "mim_type", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(key, "prompt_id", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(key, "prompt_sub_category", StringComparison.OrdinalIgnoreCase))
payload[key] = value;
return payload;
}
private static JiboInteractionDecision BuildNoInputDecision(
@@ -318,22 +381,19 @@ internal static class PersonalReportOrchestrator
PersonalReportServiceToggles toggles)
{
var noInputCount = Math.Max(0, ReadInt(turn, NoInputCountMetadataKey)) + 1;
if (noInputCount >= MaxNoInputCount)
{
return BuildDeclinedDecision(toggles);
}
if (noInputCount >= MaxNoInputCount) return BuildDeclinedDecision(toggles);
return new JiboInteractionDecision(
"personal_report_no_input",
"I am still here. Do you want your personal report?",
ContextUpdates: BuildContextUpdates(
state,
noMatchCount: ReadInt(turn, NoMatchCountMetadataKey),
ReadInt(turn, NoMatchCountMetadataKey),
noInputCount,
toggles,
userName: ReadString(turn, UserNameMetadataKey),
userVerified: ReadBool(turn, UserVerifiedMetadataKey) ?? false,
lastServiceError: string.Empty));
ReadString(turn, UserNameMetadataKey),
ReadBool(turn, UserVerifiedMetadataKey) ?? false,
string.Empty));
}
private static JiboInteractionDecision BuildNoMatchDecision(
@@ -345,10 +405,7 @@ internal static class PersonalReportOrchestrator
bool userVerified)
{
var noMatchCount = Math.Max(0, ReadInt(turn, NoMatchCountMetadataKey)) + 1;
if (noMatchCount >= MaxNoMatchCount)
{
return BuildDeclinedDecision(toggles);
}
if (noMatchCount >= MaxNoMatchCount) return BuildDeclinedDecision(toggles);
return new JiboInteractionDecision(
"personal_report_no_match",
@@ -356,11 +413,11 @@ internal static class PersonalReportOrchestrator
ContextUpdates: BuildContextUpdates(
state,
noMatchCount,
noInputCount: 0,
0,
toggles,
userName,
userVerified,
lastServiceError: string.Empty));
string.Empty));
}
private static JiboInteractionDecision BuildDeclinedDecision(PersonalReportServiceToggles toggles)
@@ -370,12 +427,12 @@ internal static class PersonalReportOrchestrator
"No problem. We can do your personal report another time.",
ContextUpdates: BuildContextUpdates(
IdleState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
null,
false,
string.Empty));
}
private static JiboInteractionDecision BuildCancelledDecision(PersonalReportServiceToggles toggles)
@@ -385,12 +442,12 @@ internal static class PersonalReportOrchestrator
"Okay, canceling personal report.",
ContextUpdates: BuildContextUpdates(
IdleState,
noMatchCount: 0,
noInputCount: 0,
0,
0,
toggles,
userName: null,
userVerified: false,
lastServiceError: string.Empty));
null,
false,
string.Empty));
}
private static IDictionary<string, object?> BuildContextUpdates(
@@ -417,6 +474,14 @@ internal static class PersonalReportOrchestrator
};
}
private static IDictionary<string, object?> BuildYesNoPromptPayload()
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["listen_contexts"] = new[] { "shared/yes_no" }
};
}
private static bool IsAffirmativeReply(string loweredTranscript)
{
return ContainsAnyPhrase(loweredTranscript, AffirmativePhrases);
@@ -430,24 +495,17 @@ internal static class PersonalReportOrchestrator
private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable<string> phrases)
{
foreach (var phrase in phrases)
{
if (string.Equals(loweredTranscript, phrase, StringComparison.Ordinal) ||
loweredTranscript.StartsWith($"{phrase} ", StringComparison.Ordinal) ||
loweredTranscript.Contains($" {phrase}", StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static bool IsWeatherErrorReply(string replyText)
{
if (string.IsNullOrWhiteSpace(replyText))
{
return false;
}
if (string.IsNullOrWhiteSpace(replyText)) return false;
return replyText.Contains("couldn't fetch the weather", StringComparison.OrdinalIgnoreCase) ||
replyText.Contains("weather service is connected", StringComparison.OrdinalIgnoreCase);
@@ -470,36 +528,32 @@ internal static class PersonalReportOrchestrator
summary = string.Empty;
var updated = toggles;
updated = ApplyToggleHint(updated, loweredTranscript, "weather", static value => value with { WeatherEnabled = false }, static value => value with { WeatherEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "calendar", static value => value with { CalendarEnabled = false }, static value => value with { CalendarEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "commute", static value => value with { CommuteEnabled = false }, static value => value with { CommuteEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "news", static value => value with { NewsEnabled = false }, static value => value with { NewsEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "weather",
static value => value with { WeatherEnabled = false },
static value => value with { WeatherEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "calendar",
static value => value with { CalendarEnabled = false },
static value => value with { CalendarEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "commute",
static value => value with { CommuteEnabled = false },
static value => value with { CommuteEnabled = true });
updated = ApplyToggleHint(updated, loweredTranscript, "news",
static value => value with { NewsEnabled = false }, static value => value with { NewsEnabled = true });
var changes = new List<string>();
if (updated.WeatherEnabled != toggles.WeatherEnabled)
{
changes.Add(updated.WeatherEnabled ? "including weather" : "skipping weather");
}
if (updated.CalendarEnabled != toggles.CalendarEnabled)
{
changes.Add(updated.CalendarEnabled ? "including calendar" : "skipping calendar");
}
if (updated.CommuteEnabled != toggles.CommuteEnabled)
{
changes.Add(updated.CommuteEnabled ? "including commute" : "skipping commute");
}
if (updated.NewsEnabled != toggles.NewsEnabled)
{
changes.Add(updated.NewsEnabled ? "including news" : "skipping news");
}
if (changes.Count > 0)
{
summary = $"Got it, {string.Join(", ", changes)}.";
}
if (changes.Count > 0) summary = $"Got it, {string.Join(", ", changes)}.";
return updated;
}
@@ -514,15 +568,11 @@ internal static class PersonalReportOrchestrator
if (loweredTranscript.Contains($"without {serviceLabel}", StringComparison.Ordinal) ||
loweredTranscript.Contains($"skip {serviceLabel}", StringComparison.Ordinal) ||
loweredTranscript.Contains($"no {serviceLabel}", StringComparison.Ordinal))
{
return disable(toggles);
}
if (loweredTranscript.Contains($"with {serviceLabel}", StringComparison.Ordinal) ||
loweredTranscript.Contains($"include {serviceLabel}", StringComparison.Ordinal))
{
return enable(toggles);
}
return toggles;
}
@@ -534,10 +584,7 @@ internal static class PersonalReportOrchestrator
private static string? ReadString(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
{
return null;
}
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null;
return value switch
{
@@ -548,10 +595,7 @@ internal static class PersonalReportOrchestrator
private static bool? ReadBool(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
{
return null;
}
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null;
return value switch
{
@@ -559,17 +603,15 @@ internal static class PersonalReportOrchestrator
string text when bool.TryParse(text, out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.True } => true,
JsonElement { ValueKind: JsonValueKind.False } => false,
JsonElement json when json.ValueKind == JsonValueKind.String && bool.TryParse(json.GetString(), out var parsed) => parsed,
JsonElement json when json.ValueKind == JsonValueKind.String &&
bool.TryParse(json.GetString(), out var parsed) => parsed,
_ => null
};
}
private static int ReadInt(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
{
return 0;
}
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return 0;
return value switch
{
@@ -577,7 +619,8 @@ internal static class PersonalReportOrchestrator
long whole when whole <= int.MaxValue && whole >= int.MinValue => (int)whole,
string text when int.TryParse(text, out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.Number } number when number.TryGetInt32(out var parsed) => parsed,
JsonElement json when json.ValueKind == JsonValueKind.String && int.TryParse(json.GetString(), out var parsed) => parsed,
JsonElement json when json.ValueKind == JsonValueKind.String &&
int.TryParse(json.GetString(), out var parsed) => parsed,
_ => 0
};
}
@@ -587,10 +630,7 @@ internal static class PersonalReportOrchestrator
var normalized = NameNoiseRegex.Replace(loweredTranscript, " ")
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
return null;
}
if (string.IsNullOrWhiteSpace(normalized)) return null;
var prefixes = new[]
{
@@ -604,10 +644,7 @@ internal static class PersonalReportOrchestrator
foreach (var prefix in prefixes)
{
if (!normalized.StartsWith(prefix, StringComparison.Ordinal))
{
continue;
}
if (!normalized.StartsWith(prefix, StringComparison.Ordinal)) continue;
var candidate = normalized[prefix.Length..].Trim();
return NormalizeNameCandidate(candidate);
@@ -618,39 +655,127 @@ internal static class PersonalReportOrchestrator
private static string? NormalizeNameCandidate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return null;
}
if (string.IsNullOrWhiteSpace(candidate)) return null;
var cleaned = NameNoiseRegex.Replace(candidate, " ")
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
if (string.IsNullOrWhiteSpace(cleaned))
{
return null;
}
if (string.IsNullOrWhiteSpace(cleaned)) return null;
if (cleaned.Length < 2 || cleaned.Length > 32)
{
return null;
}
if (cleaned.Length < 2 || cleaned.Length > 32) return null;
var words = cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length > 4)
if (words.Length > 4) return null;
return words.Any(static word => word.Any(char.IsDigit)) ? null : cleaned;
}
private static string ChoosePersonalReportTemplate(
IReadOnlyList<string> templates,
string fallback)
{
return null;
var usableTemplates = templates
.Where(static template => !string.IsNullOrWhiteSpace(template) &&
!template.Contains("${dt.", StringComparison.OrdinalIgnoreCase))
.ToArray();
if (usableTemplates.Length == 0) return fallback;
var speakerAwareTemplate = usableTemplates.FirstOrDefault(static template =>
template.Contains("${speaker}", StringComparison.OrdinalIgnoreCase));
return ChooseShortestTemplate(speakerAwareTemplate is not null ? [speakerAwareTemplate] : usableTemplates)
?? fallback;
}
if (words.Any(static word => word.Any(char.IsDigit)))
private static string RenderPersonalReportTemplate(string template, string userName)
{
return null;
return template
.Replace("${speaker}", userName, StringComparison.OrdinalIgnoreCase)
.Replace("${speaker}'s", $"{userName}'s", StringComparison.OrdinalIgnoreCase)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
}
return cleaned;
private static string ChooseReportSkillTemplate(
IReadOnlyList<string> primaryTemplates,
IReadOnlyList<string> secondaryTemplates,
string fallback)
{
var primary = ChooseShortestTemplate(primaryTemplates);
if (!string.IsNullOrWhiteSpace(primary)) return primary!;
var secondary = ChooseShortestTemplate(secondaryTemplates);
return !string.IsNullOrWhiteSpace(secondary) ? secondary! : fallback;
}
private static readonly Regex NameNoiseRegex = new("[^a-zA-Z\\-\\s']", RegexOptions.Compiled);
private static string ChooseShortestBriefing(IReadOnlyList<string> briefings)
{
var selected = ChooseShortestTemplate(briefings);
if (string.IsNullOrWhiteSpace(selected)) return string.Empty;
var firstSentence = selected.Split(['.', '!', '?'], 2,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
return string.IsNullOrWhiteSpace(firstSentence) ? selected : firstSentence;
}
private static string ChooseFirstSentence(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var firstSentence = value.Split(['.', '!', '?'], 2,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
return string.IsNullOrWhiteSpace(firstSentence) ? value.Trim() : firstSentence;
}
private static string ChooseFirstTwoSentences(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var segments = value
.Split(['.', '!', '?'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Take(2)
.ToArray();
if (segments.Length == 0) return string.Empty;
var joined = string.Join(". ", segments);
return value.TrimEnd().EndsWith(".", StringComparison.Ordinal) ||
value.TrimEnd().EndsWith("!", StringComparison.Ordinal) ||
value.TrimEnd().EndsWith("?", StringComparison.Ordinal)
? $"{joined}."
: joined;
}
private static string? ChooseShortestTemplate(IEnumerable<string> templates)
{
var selected = templates
.Where(static template => !string.IsNullOrWhiteSpace(template))
.OrderBy(static template => template.Length)
.FirstOrDefault();
return selected;
}
private static string RenderReportSkillTemplate(string template, string userName)
{
return template
.Replace("${speaker}", userName, StringComparison.OrdinalIgnoreCase)
.Replace("${speaker}'s", $"{userName}'s", StringComparison.OrdinalIgnoreCase)
.Replace(" ", " ", StringComparison.Ordinal)
.Trim();
}
private static string EscapeForEsml(string value)
{
return value
.Replace("&", "&amp;", StringComparison.Ordinal)
.Replace("<", "&lt;", StringComparison.Ordinal)
.Replace(">", "&gt;", StringComparison.Ordinal)
.Replace("\"", "&quot;", StringComparison.Ordinal)
.Replace("'", "&apos;", StringComparison.Ordinal);
}
private readonly record struct PersonalReportServiceToggles(
bool WeatherEnabled,

View File

@@ -6,7 +6,8 @@ namespace Jibo.Cloud.Application.Services;
public sealed class ProtocolToTurnContextMapper
{
public static TurnContext MapListenMessage(WebSocketMessageEnvelope envelope, CloudSession session, string messageType)
public static TurnContext MapListenMessage(WebSocketMessageEnvelope envelope, CloudSession session,
string messageType)
{
var turnState = session.TurnState;
var protocolOperation = messageType.ToLowerInvariant();
@@ -16,87 +17,58 @@ public sealed class ProtocolToTurnContextMapper
};
var text = ExtractTranscript(envelope.Text, attributes);
if (!string.IsNullOrWhiteSpace(turnState.TransId))
{
attributes["transID"] = turnState.TransId;
}
if (!string.IsNullOrWhiteSpace(turnState.TransId)) attributes["transID"] = turnState.TransId;
if (!string.IsNullOrWhiteSpace(session.AccountId))
{
attributes["accountId"] = session.AccountId;
}
if (!string.IsNullOrWhiteSpace(session.AccountId)) attributes["accountId"] = session.AccountId;
if (!string.IsNullOrWhiteSpace(session.DeviceId))
{
attributes["deviceId"] = session.DeviceId;
}
if (!string.IsNullOrWhiteSpace(session.DeviceId)) attributes["deviceId"] = session.DeviceId;
if (session.Metadata.TryGetValue("loopId", out var loopId) &&
loopId is string loopIdText &&
!string.IsNullOrWhiteSpace(loopIdText))
{
attributes["loopId"] = loopIdText;
}
if (!string.IsNullOrWhiteSpace(turnState.ContextPayload))
{
attributes["context"] = turnState.ContextPayload;
}
if (!string.IsNullOrWhiteSpace(turnState.ContextPayload)) attributes["context"] = turnState.ContextPayload;
if (session.Metadata.TryGetValue("lastClockDomain", out var lastClockDomain) &&
lastClockDomain is string lastClockDomainText &&
!string.IsNullOrWhiteSpace(lastClockDomainText))
{
attributes["lastClockDomain"] = lastClockDomainText;
}
if (session.Metadata.TryGetValue("pendingProactivityOffer", out var pendingProactivityOffer) &&
pendingProactivityOffer is string pendingProactivityOfferText &&
!string.IsNullOrWhiteSpace(pendingProactivityOfferText))
{
attributes["pendingProactivityOffer"] = pendingProactivityOfferText;
}
foreach (var pair in session.Metadata)
{
if ((!pair.Key.StartsWith("personalReport", StringComparison.OrdinalIgnoreCase) &&
!pair.Key.StartsWith("chitchat", StringComparison.OrdinalIgnoreCase)) ||
!pair.Key.StartsWith("chitchat", StringComparison.OrdinalIgnoreCase) &&
!pair.Key.StartsWith("greetings", StringComparison.OrdinalIgnoreCase)) ||
pair.Value is null)
{
continue;
}
attributes[pair.Key] = pair.Value;
}
attributes["listenHotphrase"] = turnState.ListenHotphrase;
if (turnState.ListenRules.Count > 0)
{
attributes["listenRules"] = turnState.ListenRules;
}
if (turnState.ListenRules.Count > 0) attributes["listenRules"] = turnState.ListenRules;
if (turnState.ListenAsrHints.Count > 0)
{
attributes["listenAsrHints"] = turnState.ListenAsrHints;
}
if (turnState.ListenAsrHints.Count > 0) attributes["listenAsrHints"] = turnState.ListenAsrHints;
if (turnState.BufferedAudioBytes > 0)
{
attributes["bufferedAudioBytes"] = turnState.BufferedAudioBytes;
attributes["bufferedAudioChunks"] = turnState.BufferedAudioChunkCount;
attributes["bufferedAudioFrames"] = turnState.BufferedAudioFrames.Select(frame => frame.ToArray()).ToArray();
attributes["bufferedAudioFrames"] =
turnState.BufferedAudioFrames.Select(frame => frame.ToArray()).ToArray();
}
if (!string.IsNullOrWhiteSpace(turnState.AudioTranscriptHint))
{
attributes["audioTranscriptHint"] = turnState.AudioTranscriptHint;
}
if (turnState.FinalizeAttemptCount > 0)
{
attributes["finalizeAttemptCount"] = turnState.FinalizeAttemptCount;
}
if (turnState.FinalizeAttemptCount > 0) attributes["finalizeAttemptCount"] = turnState.FinalizeAttemptCount;
return new TurnContext
{
@@ -110,8 +82,12 @@ public sealed class ProtocolToTurnContextMapper
RequestId = envelope.ConnectionId,
ProtocolService = "neo-hub",
ProtocolOperation = protocolOperation,
FirmwareVersion = session.Metadata.TryGetValue("firmwareVersion", out var firmwareVersion) ? firmwareVersion as string : null,
ApplicationVersion = session.Metadata.TryGetValue("applicationVersion", out var applicationVersion) ? applicationVersion as string : null,
FirmwareVersion = session.Metadata.TryGetValue("firmwareVersion", out var firmwareVersion)
? firmwareVersion as string
: null,
ApplicationVersion = session.Metadata.TryGetValue("applicationVersion", out var applicationVersion)
? applicationVersion as string
: null,
IsFollowUpEligible = true,
Attributes = attributes
};
@@ -119,10 +95,7 @@ public sealed class ProtocolToTurnContextMapper
private static string? ExtractTranscript(string? text, IDictionary<string, object?> attributes)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
if (string.IsNullOrWhiteSpace(text)) return null;
try
{
@@ -132,41 +105,41 @@ public sealed class ProtocolToTurnContextMapper
if (!root.TryGetProperty("data", out var data)) return null;
if (data.TryGetProperty("text", out var transcript) && transcript.ValueKind == JsonValueKind.String)
{
return transcript.GetString();
}
if (data.TryGetProperty("asr", out var asr) &&
asr.ValueKind == JsonValueKind.Object &&
asr.TryGetProperty("text", out var asrText) &&
asrText.ValueKind == JsonValueKind.String)
{
return asrText.GetString();
}
if (data.TryGetProperty("transcriptHint", out var transcriptHint) && transcriptHint.ValueKind == JsonValueKind.String)
{
return transcriptHint.GetString();
}
if (data.TryGetProperty("transcriptHint", out var transcriptHint) &&
transcriptHint.ValueKind == JsonValueKind.String) return transcriptHint.GetString();
if (data.TryGetProperty("intent", out var intent) && intent.ValueKind == JsonValueKind.String)
{
attributes["clientIntent"] = intent.GetString();
}
if (data.TryGetProperty("triggerSource", out var triggerSource) &&
triggerSource.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(triggerSource.GetString()))
attributes["triggerSource"] = triggerSource.GetString();
if (data.TryGetProperty("triggerData", out var triggerData) &&
triggerData.ValueKind == JsonValueKind.Object &&
triggerData.TryGetProperty("looperID", out var triggerLooperId) &&
triggerLooperId.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(triggerLooperId.GetString()))
attributes["triggerLooperId"] = triggerLooperId.GetString();
if (data.TryGetProperty("rules", out var rules) && rules.ValueKind == JsonValueKind.Array)
{
attributes["clientRules"] = rules.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString() ?? string.Empty)
.Where(rule => !string.IsNullOrWhiteSpace(rule))
.ToArray();
}
if (data.TryGetProperty("entities", out var entities) && entities.ValueKind == JsonValueKind.Object)
{
attributes["clientEntities"] = entities.Clone();
}
return intent.ValueKind == JsonValueKind.String ? intent.GetString() : null;
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Jibo.Cloud.Domain.Models;
using Jibo.Runtime.Abstractions;
@@ -31,14 +32,19 @@ 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 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 isSleepCommand = string.Equals(plan.IntentName, "sleep", StringComparison.OrdinalIgnoreCase);
var isSpinAroundCommand = string.Equals(plan.IntentName, "spin_around", StringComparison.OrdinalIgnoreCase);
var isGlobalCommand = isStopCommand || isSleepCommand || isSpinAroundCommand || isVolumeControl;
var isPhotoGalleryLaunch = string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase);
var isPhotoCreateLaunch = string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) ||
string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase);
var isClockSkillLaunch = string.Equals(skill?.SkillName, "@be/clock", StringComparison.OrdinalIgnoreCase);
var isReportSkillLaunch = string.Equals(skill?.SkillName, "report-skill", StringComparison.OrdinalIgnoreCase);
var idleRedirectDelayMs = isSleepCommand ? 150 : isSpinAroundCommand ? 75 : 75;
var idleCompletionDelayMs = isSleepCommand ? 1000 : isSpinAroundCommand ? 750 : 125;
var localIntent = ReadSkillPayloadString(skill, "localIntent");
var clockIntent = ReadSkillPayloadString(skill, "clockIntent");
var clockDomain = ReadSkillPayloadString(skill, "domain");
@@ -72,7 +78,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
? localIntent
: isWordOfDayGuess
? "guess"
: string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) &&
: string.Equals(messageType, "CLIENT_NLU",
StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(clientIntent)
? clientIntent
: plan.IntentName ?? "unknown";
@@ -209,10 +216,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/word-of-the-day")),
DelayMs: 125));
125));
}
if (isRadioLaunch)
@@ -225,13 +232,13 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/radio")),
DelayMs: 125));
125));
}
if (isStopCommand)
if (isStopCommand || isSleepCommand || isSpinAroundCommand)
{
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildSkillRedirectPayload(
@@ -241,10 +248,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
idleRedirectDelayMs));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/idle")),
DelayMs: 125));
idleCompletionDelayMs));
}
if (isSettingsLaunch &&
@@ -258,10 +265,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/settings")),
DelayMs: 125));
125));
}
if (isClockSkillLaunch &&
@@ -276,10 +283,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/clock")),
DelayMs: 125));
125));
}
if ((isPhotoGalleryLaunch || isPhotoCreateLaunch) &&
@@ -294,34 +301,16 @@ public sealed class ResponsePlanToSocketMessagesMapper
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, skillId)),
DelayMs: 125));
}
if (isReportSkillLaunch)
{
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildSkillRedirectPayload(
transId,
"report-skill",
outboundIntent,
outboundAsrText,
outboundRules,
entities)),
DelayMs: 75));
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "report-skill")),
DelayMs: 125));
125));
}
if (emitSkillActions && speak is not null)
{
messages.Add(new SocketReplyPlan(
JsonSerializer.Serialize(BuildSkillPayload(plan, turn, transId, speak, skill)),
DelayMs: 75));
}
75));
return messages;
}
@@ -367,7 +356,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
transID = transId,
data = new { }
})),
new SocketReplyPlan(JsonSerializer.Serialize(BuildGenericFallbackSkillPayload(transId)), DelayMs: 75)
new SocketReplyPlan(JsonSerializer.Serialize(BuildGenericFallbackSkillPayload(transId)), 75)
];
}
@@ -442,10 +431,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
? "clientRules"
: "listenRules";
if (!turn.Attributes.TryGetValue(attributeName, out var value))
{
return [];
}
if (!turn.Attributes.TryGetValue(attributeName, out var value)) return [];
return value switch
{
@@ -481,10 +467,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
{
if (yesNoTurn)
{
if (!includeCreateDomain)
{
return new Dictionary<string, object?>();
}
if (!includeCreateDomain) return new Dictionary<string, object?>();
return new Dictionary<string, object?>
{
@@ -493,20 +476,15 @@ public sealed class ResponsePlanToSocketMessagesMapper
}
if (wordOfDayLaunch)
{
return new Dictionary<string, object?>
{
["domain"] = "word-of-the-day"
};
}
if (globalCommand)
{
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(volumeLevel))
{
entities["volumeLevel"] = volumeLevel;
}
if (!string.IsNullOrWhiteSpace(volumeLevel)) entities["volumeLevel"] = volumeLevel;
return entities;
}
@@ -514,10 +492,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
if (radioLaunch)
{
var entities = new Dictionary<string, object?>();
if (!string.IsNullOrWhiteSpace(radioStation))
{
entities["station"] = radioStation;
}
if (!string.IsNullOrWhiteSpace(radioStation)) entities["station"] = radioStation;
return entities;
}
@@ -525,10 +500,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
if (clockSkillLaunch)
{
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(clockDomain))
{
entities["domain"] = clockDomain;
}
if (!string.IsNullOrWhiteSpace(clockDomain)) entities["domain"] = clockDomain;
if (string.Equals(clockDomain, "timer", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(timerHours + timerMinutes + timerSeconds))
@@ -550,32 +522,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
if (reportSkillLaunch)
{
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(reportDate))
{
entities["date"] = reportDate;
}
if (!string.IsNullOrWhiteSpace(reportDate)) entities["date"] = reportDate;
if (!string.IsNullOrWhiteSpace(reportWeatherCondition))
{
entities["Weather"] = reportWeatherCondition;
}
if (!string.IsNullOrWhiteSpace(reportWeatherCondition)) entities["Weather"] = reportWeatherCondition;
return entities;
}
if (wordOfDayGuess)
{
return new Dictionary<string, object?>
{
["guess"] = guess ?? string.Empty
};
}
if (!string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) ||
!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null)
{
return new Dictionary<string, object?>();
}
return value switch
{
@@ -611,10 +573,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static IEnumerable<string> ReadRuleValues(TurnContext turn, string key)
{
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
{
return [];
}
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return [];
return value switch
{
@@ -636,10 +595,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static string? ReadClientEntity(TurnContext turn, string entityName)
{
if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null)
{
return null;
}
if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) return null;
return value switch
{
@@ -657,20 +613,14 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static string? ReadSkillPayloadString(InvokeNativeSkillAction? skill, string key)
{
if (skill?.Payload is null || !skill.Payload.TryGetValue(key, out var value))
{
return null;
}
if (skill?.Payload is null || !skill.Payload.TryGetValue(key, out var value)) return null;
return value?.ToString();
}
private static string ResolveWordOfDayGuess(TurnContext turn, string transcript, string? nluGuess)
{
if (!string.IsNullOrWhiteSpace(nluGuess))
{
return nluGuess;
}
if (!string.IsNullOrWhiteSpace(nluGuess)) return nluGuess;
var normalized = NormalizeGuessToken(transcript);
var hintIndex = normalized switch
@@ -684,11 +634,9 @@ public sealed class ResponsePlanToSocketMessagesMapper
var hints = ReadRuleValues(turn, "listenAsrHints").ToArray();
if (hintIndex >= 0)
{
return hintIndex < hints.Length
? hints[hintIndex]
: transcript;
}
var fuzzyHintMatch = FindClosestHint(normalized, hints);
return string.IsNullOrWhiteSpace(fuzzyHintMatch)
@@ -698,31 +646,19 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static string? FindClosestHint(string normalizedTranscript, IReadOnlyList<string> hints)
{
if (string.IsNullOrWhiteSpace(normalizedTranscript))
{
return null;
}
if (string.IsNullOrWhiteSpace(normalizedTranscript)) return null;
string? bestHint = null;
var bestDistance = int.MaxValue;
foreach (var hint in hints)
{
if (string.IsNullOrWhiteSpace(hint))
{
continue;
}
if (string.IsNullOrWhiteSpace(hint)) continue;
var normalizedHint = NormalizeGuessToken(hint);
if (string.IsNullOrWhiteSpace(normalizedHint))
{
continue;
}
if (string.IsNullOrWhiteSpace(normalizedHint)) continue;
if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal))
{
return hint;
}
if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal)) return hint;
var distance = ComputeEditDistance(normalizedTranscript, normalizedHint);
if (distance >= bestDistance) continue;
@@ -744,10 +680,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
var previous = new int[right.Length + 1];
var current = new int[right.Length + 1];
for (var column = 0; column <= right.Length; column += 1)
{
previous[column] = column;
}
for (var column = 0; column <= right.Length; column += 1) previous[column] = column;
for (var row = 1; row <= left.Length; row += 1)
{
@@ -772,11 +705,9 @@ public sealed class ResponsePlanToSocketMessagesMapper
var skillPayload = skill?.Payload;
if (string.Equals(ReadPayloadString(skillPayload, "cloudResponseMode"), "completion_only",
StringComparison.OrdinalIgnoreCase))
{
return BuildCompletionOnlySkillPayload(
transId,
ReadPayloadString(skillPayload, "skillId") ?? skill?.SkillName ?? "chitchat-skill");
}
var isJoke = string.Equals(plan.IntentName, "joke", StringComparison.OrdinalIgnoreCase) ||
string.Equals(skill?.SkillName, "@be/joke", StringComparison.OrdinalIgnoreCase);
@@ -812,19 +743,21 @@ public sealed class ResponsePlanToSocketMessagesMapper
};
if (listenContexts.Count > 0)
{
jcpConfig["listen"] = new
{
id = CreateProtocolId(),
type = "LISTEN",
contexts = listenContexts
};
}
var weatherHiLoView = BuildWeatherHiLoView(skillPayload);
var weeklyWeatherCards = BuildWeatherHiLoSequenceCards(skillPayload);
if (weatherHiLoView is null && weeklyWeatherCards.Count > 0) weatherHiLoView = weeklyWeatherCards[0].View;
var useWeatherSequence = false;
if (weatherHiLoView is not null)
{
var resolvedGuiConfig = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
var resolvedGuiContext = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["type"] = "Javascript",
["data"] = weatherHiLoView,
@@ -841,12 +774,21 @@ public sealed class ResponsePlanToSocketMessagesMapper
jcpConfig["gui"] = legacyGuiConfig;
jcpConfig["display"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["view"] = resolvedGuiConfig
["view"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
// Legacy fields used by existing tests and tooling.
["type"] = "Javascript",
["data"] = weatherHiLoView,
["pause"] = true,
// Pegasus-style view context used by on-robot weather cards.
["context"] = resolvedGuiContext
}
};
playConfig["gui"] = resolvedGuiConfig;
playConfig["no_matches_for_gui"] = 0;
playConfig["no_inputs_for_gui"] = 0;
jcpConfig["timeout"] = 6;
jcpConfig["barge_in"] = true;
jcpConfig["no_matches_for_gui"] = 0;
jcpConfig["no_inputs_for_gui"] = 0;
var weatherViews = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
@@ -860,6 +802,30 @@ public sealed class ResponsePlanToSocketMessagesMapper
{
["views"] = weatherViews
};
if (weeklyWeatherCards.Count > 1)
{
useWeatherSequence = true;
jcpConfig["children"] = BuildWeatherHiLoSequenceChildren(
weeklyWeatherCards,
promptSubCategory,
mimId,
mimType);
}
}
var jcp = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["type"] = "SLIM",
["config"] = jcpConfig
};
if (useWeatherSequence &&
jcpConfig.TryGetValue("children", out var sequenceChildren) &&
sequenceChildren is not null)
{
jcp["type"] = "SEQUENCE";
jcp.Remove("config");
jcp["children"] = sequenceChildren;
}
return new
@@ -878,11 +844,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
{
config = new
{
jcp = new
{
type = "SLIM",
config = jcpConfig
}
jcp
}
},
analytics = new Dictionary<string, object?>(),
@@ -906,15 +868,9 @@ public sealed class ResponsePlanToSocketMessagesMapper
["entities"] = entities
};
if (!string.IsNullOrWhiteSpace(skillId))
{
payload["skill"] = skillId;
}
if (!string.IsNullOrWhiteSpace(skillId)) payload["skill"] = skillId;
if (!string.IsNullOrWhiteSpace(domain))
{
payload["domain"] = domain;
}
if (!string.IsNullOrWhiteSpace(domain)) payload["domain"] = domain;
return payload;
}
@@ -1077,65 +1033,217 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static string? ReadPayloadString(IDictionary<string, object?>? payload, string key)
{
if (payload is null || !payload.TryGetValue(key, out var value))
{
return null;
}
if (payload is null || !payload.TryGetValue(key, out var value)) return null;
return value?.ToString();
}
private static IReadOnlyList<string> ReadPayloadStringArray(IDictionary<string, object?>? payload, string key)
{
if (payload is null || !payload.TryGetValue(key, out var value) || value is null)
{
return [];
}
if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return [];
return value switch
{
string text => [.. text
string text =>
[
.. text
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static context => !string.IsNullOrWhiteSpace(context))],
.Where(static context => !string.IsNullOrWhiteSpace(context))
],
string[] contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))],
IEnumerable<string> contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))],
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Array => [.. jsonElement
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Array =>
[
.. jsonElement
.EnumerateArray()
.Select(static item => item.GetString())
.Where(static context => !string.IsNullOrWhiteSpace(context))
.Select(static context => context!)],
IEnumerable<object?> contexts => [.. contexts
.Select(static context => context!)
],
IEnumerable<object?> contexts =>
[
.. contexts
.Select(static context => context?.ToString())
.Where(static context => !string.IsNullOrWhiteSpace(context))
.Select(static context => context!)],
.Select(static context => context!)
],
_ => string.IsNullOrWhiteSpace(value.ToString()) ? [] : [value.ToString()!]
};
}
private static IReadOnlyList<WeatherHiLoSequenceCard> BuildWeatherHiLoSequenceCards(
IDictionary<string, object?>? payload)
{
if (payload is null ||
!payload.TryGetValue("weather_weekly_cards", out var rawCards) ||
rawCards is null)
return [];
var cards = ReadPayloadObjectArray(rawCards);
if (cards.Count == 0) return [];
var sequenceCards = new List<WeatherHiLoSequenceCard>(cards.Count);
foreach (var card in cards)
{
var weatherCardPayload = new Dictionary<string, object?>(card, StringComparer.OrdinalIgnoreCase)
{
["weather_view_enabled"] = true,
["weather_view_kind"] = "weatherHiLo"
};
var view = BuildWeatherHiLoView(weatherCardPayload);
if (view is null) continue;
sequenceCards.Add(new WeatherHiLoSequenceCard(
view,
ReadPayloadString(weatherCardPayload, "weather_day"),
ReadPayloadString(weatherCardPayload, "weather_icon"),
ReadPayloadString(weatherCardPayload, "weather_spoken_line")));
}
return sequenceCards;
}
private static IReadOnlyList<object> BuildWeatherHiLoSequenceChildren(
IReadOnlyList<WeatherHiLoSequenceCard> cards,
string promptSubCategory,
string mimId,
string mimType)
{
var children = new List<object>(cards.Count);
for (var index = 0; index < cards.Count; index += 1)
{
var card = cards[index];
var promptLabel = string.IsNullOrWhiteSpace(card.DayName)
? $"Day{index + 1}"
: Regex.Replace(card.DayName, "[^A-Za-z0-9]", string.Empty, RegexOptions.CultureInvariant);
var promptId = $"WeatherForecast{promptLabel}_AN_13";
var spokenLine = string.IsNullOrWhiteSpace(card.SpokenLine)
? "Here is another day's forecast."
: card.SpokenLine!;
var icon = string.IsNullOrWhiteSpace(card.Icon)
? "cloudy"
: card.Icon!;
var esml =
$"<speak><anim cat='weather' meta='{icon}' nonBlocking='true' /><break size='0.2'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeXml(spokenLine)}</es></speak>";
var resolvedGuiContext = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["type"] = "Javascript",
["data"] = card.View,
["pause"] = true
};
children.Add(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["type"] = "SLIM",
["config"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["play"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["esml"] = esml,
["meta"] = new
{
prompt_id = promptId,
prompt_sub_category = promptSubCategory,
mim_id = mimId,
mim_type = mimType
}
},
["gui"] = new
{
type = "Javascript",
data = "views.weatherHiLo",
pause = true
},
["display"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["view"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["type"] = "Javascript",
["data"] = card.View,
["pause"] = true,
["context"] = resolvedGuiContext
}
},
["timeout"] = 6,
["barge_in"] = true,
["no_matches_for_gui"] = 0,
["no_inputs_for_gui"] = 0
}
});
}
return children;
}
private static IReadOnlyList<IDictionary<string, object?>> ReadPayloadObjectArray(object rawValue)
{
if (rawValue is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
return jsonArray
.EnumerateArray()
.Select(ConvertJsonObjectToDictionary)
.Where(static item => item is not null)
.Cast<IDictionary<string, object?>>()
.ToArray();
if (rawValue is IEnumerable<object?> rawObjects)
return rawObjects
.Select(ConvertObjectToDictionary)
.Where(static item => item is not null)
.Cast<IDictionary<string, object?>>()
.ToArray();
return [];
}
private static IDictionary<string, object?>? ConvertObjectToDictionary(object? value)
{
if (value is null) return null;
if (value is IDictionary<string, object?> dictionary)
return new Dictionary<string, object?>(dictionary, StringComparer.OrdinalIgnoreCase);
return value is JsonElement jsonValue
? ConvertJsonObjectToDictionary(jsonValue)
: null;
}
private static IDictionary<string, object?>? ConvertJsonObjectToDictionary(JsonElement value)
{
if (value.ValueKind != JsonValueKind.Object) return null;
var dictionary = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var property in value.EnumerateObject())
dictionary[property.Name] = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number when property.Value.TryGetInt32(out var intValue) => intValue,
JsonValueKind.Number when property.Value.TryGetDouble(out var doubleValue) => doubleValue,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Object => ConvertJsonObjectToDictionary(property.Value),
JsonValueKind.Array => property.Value,
_ => null
};
return dictionary;
}
private static object? BuildWeatherHiLoView(IDictionary<string, object?>? payload)
{
if (!TryReadPayloadBool(payload, "weather_view_enabled"))
{
return null;
}
if (!TryReadPayloadBool(payload, "weather_view_enabled")) return null;
if (!string.Equals(
ReadPayloadString(payload, "weather_view_kind"),
"weatherHiLo",
StringComparison.OrdinalIgnoreCase))
{
return null;
}
var icon = ReadPayloadString(payload, "weather_icon");
var unit = ReadPayloadString(payload, "weather_unit") ?? "F";
var theme = ReadPayloadString(payload, "weather_theme") ?? "Normal";
var high = TryReadPayloadInt(payload, "weather_high");
var low = TryReadPayloadInt(payload, "weather_low");
if (string.IsNullOrWhiteSpace(icon) || high is null || low is null)
{
return null;
}
if (string.IsNullOrWhiteSpace(icon) || high is null || low is null) return null;
var hiNumX = GetTemperatureLabelXPosition(370, high.Value);
var hiUnitX = GetTemperatureLabelXPosition(360, high.Value);
@@ -1197,7 +1305,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
{
id = "hiNumLabel",
type = "Label",
text = $"{high.Value}\u00B0",
text = $"{high.Value}°",
style = new
{
fontSize = "160",
@@ -1229,7 +1337,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
{
id = "loNumLabel",
type = "Label",
text = $"{low.Value}\u00B0",
text = $"{low.Value}°",
style = new
{
fontSize = "160",
@@ -1294,24 +1402,16 @@ public sealed class ResponsePlanToSocketMessagesMapper
private static int GetTemperatureLabelXPosition(int baseX, int temperature)
{
const int xOffset = 70;
if (temperature < -9 || temperature > 99)
{
return baseX + xOffset;
}
if (temperature < -9 || temperature > 99) return baseX + xOffset;
if (temperature is >= 0 and < 10)
{
return baseX - xOffset;
}
if (temperature is >= 0 and < 10) return baseX - xOffset;
return baseX;
}
private static int? TryReadPayloadInt(IDictionary<string, object?>? payload, string key)
{
if (payload is null || !payload.TryGetValue(key, out var value) || value is null)
{
return null;
}
if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return null;
return value switch
{
@@ -1320,18 +1420,17 @@ public sealed class ResponsePlanToSocketMessagesMapper
double number => (int)Math.Round(number, MidpointRounding.AwayFromZero),
float number => (int)Math.Round(number, MidpointRounding.AwayFromZero),
string text when int.TryParse(text, out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.Number } jsonNumber when jsonNumber.TryGetInt32(out var parsed) => parsed,
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && int.TryParse(jsonText.GetString(), out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.Number } jsonNumber when jsonNumber.TryGetInt32(out var parsed) =>
parsed,
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String &&
int.TryParse(jsonText.GetString(), out var parsed) => parsed,
_ => null
};
}
private static bool TryReadPayloadBool(IDictionary<string, object?>? payload, string key)
{
if (payload is null || !payload.TryGetValue(key, out var value) || value is null)
{
return false;
}
if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return false;
return value switch
{
@@ -1339,7 +1438,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
string text when bool.TryParse(text, out var parsed) => parsed,
JsonElement { ValueKind: JsonValueKind.True } => true,
JsonElement { ValueKind: JsonValueKind.False } => false,
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && bool.TryParse(jsonText.GetString(), out var parsed) => parsed,
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String &&
bool.TryParse(jsonText.GetString(), out var parsed) => parsed,
_ => false
};
}
@@ -1354,6 +1454,11 @@ public sealed class ResponsePlanToSocketMessagesMapper
return Guid.NewGuid().ToString("N");
}
private sealed record WeatherHiLoSequenceCard(
object View,
string? DayName,
string? Icon,
string? SpokenLine);
public sealed record SocketReplyPlan(string Text, int DelayMs = 0);
}

View File

@@ -0,0 +1,139 @@
using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Application.Services;
internal static class ScriptedResponseDecisionBuilder
{
internal static JiboInteractionDecision BuildScriptedPersonalityDecision(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyPersonalityReply(catalog, randomizer, preferredSnippets),
ContextUpdates: BuildScriptedResponseContextUpdates());
}
internal static JiboInteractionDecision BuildScriptedFavoriteAnimalDecision(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyReply(catalog.FavoriteAnimalReplies, randomizer, preferredSnippets),
ContextUpdates: BuildScriptedResponseContextUpdates());
}
internal static JiboInteractionDecision BuildScriptedGreetingDecision(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyGreetingReply(catalog, randomizer, preferredSnippets),
ContextUpdates: BuildScriptedResponseContextUpdates());
}
internal static JiboInteractionDecision BuildScriptedHolidayDecision(
IReadOnlyList<string> replies,
IJiboRandomizer randomizer,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyReply(replies, randomizer, preferredSnippets),
ContextUpdates: BuildScriptedResponseContextUpdates());
}
internal static JiboInteractionDecision BuildScriptedHolidayTrackerDecision(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyReply(catalog.HolidayTrackerReplies, randomizer, preferredSnippets),
ContextUpdates: BuildScriptedResponseContextUpdates());
}
internal static JiboInteractionDecision BuildScriptedHolidayGreetingDecision(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyReply(catalog.HolidayGreetingReplies, randomizer, preferredSnippets),
ContextUpdates: BuildScriptedResponseContextUpdates());
}
internal static IDictionary<string, object?> BuildScriptedResponseContextUpdates()
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
[ChitchatStateMachine.StateMetadataKey] = "complete",
[ChitchatStateMachine.RouteMetadataKey] = "ScriptedResponse",
[ChitchatStateMachine.EmotionMetadataKey] = string.Empty
};
}
internal static string SelectLegacyPersonalityReply(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
params string[] preferredSnippets)
{
foreach (var snippet in preferredSnippets)
{
if (string.IsNullOrWhiteSpace(snippet)) continue;
var match = catalog.PersonalityReplies.FirstOrDefault(reply =>
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match)) return match;
}
return catalog.PersonalityReplies.Count == 0 ? string.Empty : randomizer.Choose(catalog.PersonalityReplies);
}
internal static string SelectLegacyGreetingReply(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
params string[] preferredSnippets)
{
foreach (var snippet in preferredSnippets)
{
if (string.IsNullOrWhiteSpace(snippet)) continue;
var match = catalog.GreetingReplies.FirstOrDefault(reply =>
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match)) return match;
}
return catalog.GreetingReplies.Count == 0 ? string.Empty : randomizer.Choose(catalog.GreetingReplies);
}
internal static string SelectLegacyReply(
IReadOnlyList<string> replies,
IJiboRandomizer randomizer,
params string[] preferredSnippets)
{
foreach (var snippet in preferredSnippets)
{
if (string.IsNullOrWhiteSpace(snippet)) continue;
var match = replies.FirstOrDefault(reply =>
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match)) return match;
}
return replies.Count == 0 ? string.Empty : randomizer.Choose(replies);
}
}

View File

@@ -0,0 +1,590 @@
using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Application.Services;
internal static class SeasonalHolidayRouteBuilder
{
internal static bool TryResolveSemanticIntent(string loweredTranscript, out string? semanticIntent)
{
if (MatchesAny(
loweredTranscript,
"do you like halloween",
"are you looking forward to halloween",
"do you like the halloween holiday"))
{
semanticIntent = "seasonal_likes_halloween";
return true;
}
if (MatchesAny(
loweredTranscript,
"do you like holiday music",
"do you like christmas music",
"do you like christmas songs",
"do you like holiday songs"))
{
semanticIntent = "seasonal_likes_holiday_music";
return true;
}
if (MatchesAny(
loweredTranscript,
"do you like holiday parties",
"do you like christmas parties",
"are you going to any holiday parties"))
{
semanticIntent = "seasonal_likes_holiday_parties";
return true;
}
if (MatchesAny(
loweredTranscript,
"are you looking forward to christmas",
"do you look forward to christmas",
"are you excited for christmas"))
{
semanticIntent = "seasonal_looks_forward_to_christmas";
return true;
}
if (MatchesAny(
loweredTranscript,
"what are you thankful for",
"what are you thankful for this year",
"what is jibo thankful for"))
{
semanticIntent = "seasonal_thankful_for";
return true;
}
if (MatchesAny(
loweredTranscript,
"what are you doing for christmas",
"what are your plans for christmas",
"what do you plan to do for christmas"))
{
semanticIntent = "seasonal_plans_for_christmas";
return true;
}
if (MatchesAny(
loweredTranscript,
"happy holidays",
"merry christmas",
"happy new year",
"season s greetings",
"seasons greetings"))
{
semanticIntent = "seasonal_holiday_greeting";
return true;
}
if (MatchesAny(
loweredTranscript,
"what holidays do you celebrate",
"what holidays are you celebrating",
"what holidays do you observe"))
{
semanticIntent = "seasonal_holidays";
return true;
}
if (MatchesAny(
loweredTranscript,
"how is holiday season",
"how's holiday season",
"how is the holiday season",
"do you like holiday season",
"do you like the holiday season",
"what is your favorite holiday",
"what's your favorite holiday",
"what holiday do you like",
"what is holiday season like"))
{
semanticIntent = "seasonal_holiday_season";
return true;
}
if (MatchesAny(
loweredTranscript,
"how is thanksgiving",
"how's thanksgiving",
"do you like thanksgiving",
"what do you think of thanksgiving"))
{
semanticIntent = "seasonal_thanksgiving";
return true;
}
if (MatchesAny(
loweredTranscript,
"how is christmas",
"how's christmas",
"do you like christmas",
"what do you think of christmas"))
{
semanticIntent = "seasonal_christmas";
return true;
}
if (MatchesAny(
loweredTranscript,
"how is hanukkah",
"how's hanukkah",
"do you like hanukkah",
"what do you think of hanukkah"))
{
semanticIntent = "seasonal_hanukkah";
return true;
}
if (MatchesAny(
loweredTranscript,
"how is passover",
"how's passover",
"do you like passover",
"what do you think of passover"))
{
semanticIntent = "seasonal_passover";
return true;
}
if (MatchesAny(
loweredTranscript,
"how is new years",
"how's new years",
"how is new year s",
"do you like new years",
"what do you think of new years"))
{
semanticIntent = "seasonal_new_years";
return true;
}
if (MatchesAny(
loweredTranscript,
"how is valentines day",
"how's valentines day",
"do you like valentines day",
"what do you think of valentines day"))
{
semanticIntent = "seasonal_valentines_day";
return true;
}
if (MatchesAny(
loweredTranscript,
"how is kwanzaa",
"how's kwanzaa",
"do you like kwanzaa",
"what do you think of kwanzaa"))
{
semanticIntent = "seasonal_kwanzaa";
return true;
}
if (MatchesAny(
loweredTranscript,
"how is easter",
"how's easter",
"do you like easter",
"what do you think of easter"))
{
semanticIntent = "seasonal_easter";
return true;
}
if (MatchesAny(
loweredTranscript,
"what is your new years resolution",
"what is your new year's resolution",
"what is your new year s resolution",
"what are your new years resolutions",
"what are your new year's resolutions",
"what are your new year s resolutions",
"do you have any new years resolutions"))
{
semanticIntent = "seasonal_new_years_resolution";
return true;
}
if (MatchesAny(
loweredTranscript,
"how are your new years resolutions going",
"how are your new year's resolutions going",
"how is your new years resolution going",
"how is your new year's resolution going",
"how are your resolutions going",
"how is your resolution going"))
{
semanticIntent = "seasonal_new_years_update";
return true;
}
if (MatchesAny(
loweredTranscript,
"what halloween costume",
"what are you going as for halloween",
"what costume are you wearing",
"what are you dressing as for halloween"))
{
semanticIntent = "seasonal_halloween_costume";
return true;
}
if (MatchesAny(
loweredTranscript,
"what should i do for first day of spring",
"what should i do for spring",
"what do i do for first day of spring"))
{
semanticIntent = "seasonal_first_day_spring";
return true;
}
if (MatchesAny(
loweredTranscript,
"what is spring like",
"how is spring",
"what do you think about spring"))
{
semanticIntent = "seasonal_spring";
return true;
}
if (MatchesAny(
loweredTranscript,
"do you like spring",
"do you like springtime",
"are you looking forward to spring",
"do you look forward to spring",
"are you excited for spring"))
{
semanticIntent = "seasonal_likes_spring";
return true;
}
if (MatchesAny(
loweredTranscript,
"what is summer like",
"how is summer",
"what do you think about summer"))
{
semanticIntent = "seasonal_summer";
return true;
}
if (MatchesAny(
loweredTranscript,
"do you like summer",
"do you like summertime",
"are you looking forward to summer",
"do you look forward to summer",
"are you excited for summer"))
{
semanticIntent = "seasonal_likes_summer";
return true;
}
if (MatchesAny(
loweredTranscript,
"what should i get for holiday",
"what should i get for christmas",
"what gift should i get for christmas",
"what should i get someone for the holidays"))
{
semanticIntent = "seasonal_holiday_gift";
return true;
}
if (MatchesAny(
loweredTranscript,
"show santa tracker",
"can you show santa tracker",
"santa tracker",
"where is santa",
"where is santa right now",
"can you show me santa tracker"))
{
semanticIntent = "seasonal_santa_tracker";
return true;
}
if (MatchesAny(
loweredTranscript,
"happy birthday",
"happy birthday jibo",
"happy birthday to you"))
{
semanticIntent = "birthday_celebration";
return true;
}
semanticIntent = null;
return false;
}
internal static bool TryBuildDecision(
string semanticIntent,
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
Func<string, string> holidayTemplateRenderer,
out JiboInteractionDecision? decision)
{
decision = semanticIntent switch
{
"seasonal_holiday_greeting" => ScriptedResponseDecisionBuilder.BuildScriptedHolidayGreetingDecision(
catalog,
randomizer,
semanticIntent,
"fun time of year",
"right back at you",
"and to you too"),
"seasonal_holidays" => BuildHolidayTemplateDecision(
catalog,
randomizer,
holidayTemplateRenderer,
semanticIntent,
"official owner can tell me which ones we'll celebrate together",
"going to the jibo's settings screen in the jibo app"),
"seasonal_holiday_season" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"festive",
"celebrate"),
"seasonal_thanksgiving" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"thanksgiving",
"turkey",
"stuffed"),
"seasonal_christmas" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"christmas",
"quality time",
"socks"),
"seasonal_hanukkah" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"hanukkah",
"dreidel",
"gift"),
"seasonal_passover" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"passover",
"matzah",
"next one"),
"seasonal_new_years" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"new year",
"resolutions",
"party"),
"seasonal_valentines_day" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"valentine",
"heart",
"flowers"),
"seasonal_kwanzaa" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"kwanzaa",
"gift",
"celebrate"),
"seasonal_easter" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"easter",
"bunny",
"egg"),
"seasonal_new_years_resolution" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"always trying to learn new skills",
"not eat bacon",
"learn a bunch of new skills",
"learn to walk",
"recognizing people's faces and voices"),
"seasonal_new_years_update" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"not eat bacon",
"learn some new skills",
"going well"),
"seasonal_halloween_costume" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"i haven't thought much about it yet",
"ask me again on halloween",
"you'll find out on halloween"),
"seasonal_first_day_spring" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"it's a great day, when spring is in the air"),
"seasonal_spring" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"the days get longer",
"spring is a great season"),
"seasonal_likes_spring" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"extra happy in the springtime",
"i do like spring"),
"seasonal_summer" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"going to the beach",
"summer is great"),
"seasonal_likes_summer" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"long days",
"summer is a very special season"),
"seasonal_holiday_gift" => BuildHolidayDecision(
catalog.HolidayGiftReplies,
randomizer,
semanticIntent,
"ask for a pet elephant",
"experience as a present",
"donate to charities in other people's names"),
"seasonal_likes_halloween" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"halloween is my favorite holiday",
"scary but also fun",
"jack-o-lantern"),
"seasonal_likes_holiday_music" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"holiday music",
"sing a few of them",
"frosty the snowman"),
"seasonal_likes_holiday_parties" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"holiday fun can be extra fun",
"dance party"),
"seasonal_looks_forward_to_christmas" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"really like times of giving and receiving",
"long way away",
"looking forward to christmas"),
"seasonal_plans_for_christmas" => BuildHolidayDecision(
catalog.HolidaySeasonReplies,
randomizer,
semanticIntent,
"christmas sweaters",
"wear one of my",
"be festive"),
"seasonal_thankful_for" => ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
catalog,
randomizer,
semanticIntent,
"thankful for the people i know",
"and for penguins",
"thankful for"),
"seasonal_santa_tracker" => ScriptedResponseDecisionBuilder.BuildScriptedHolidayTrackerDecision(
catalog,
randomizer,
semanticIntent,
"santa tracker",
"let's see if i can spot him",
"deliveries",
"north pole"),
"birthday_celebration" => BuildHolidayDecision(
catalog.BirthdayCelebrationReplies,
randomizer,
semanticIntent,
"another year older",
"can't wait to see what you got me",
"powered on for the first time today"),
_ => null
};
return decision is not null;
}
private static JiboInteractionDecision BuildHolidayDecision(
IReadOnlyList<string> replies,
IJiboRandomizer randomizer,
string intentName,
params string[] preferredSnippets)
{
return new JiboInteractionDecision(
intentName,
SelectLegacyReply(replies, randomizer, preferredSnippets),
ContextUpdates: BuildContextUpdates());
}
private static JiboInteractionDecision BuildHolidayTemplateDecision(
JiboExperienceCatalog catalog,
IJiboRandomizer randomizer,
Func<string, string> holidayTemplateRenderer,
string intentName,
params string[] preferredSnippets)
{
var selected = SelectLegacyReply(catalog.HolidayReplies, randomizer, preferredSnippets);
return new JiboInteractionDecision(
intentName,
holidayTemplateRenderer(selected),
ContextUpdates: BuildContextUpdates());
}
private static IDictionary<string, object?> BuildContextUpdates()
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
[ChitchatStateMachine.StateMetadataKey] = "complete",
[ChitchatStateMachine.RouteMetadataKey] = "ScriptedResponse",
[ChitchatStateMachine.EmotionMetadataKey] = string.Empty
};
}
private static string SelectLegacyReply(
IReadOnlyList<string> replies,
IJiboRandomizer randomizer,
params string[] preferredSnippets)
{
foreach (var snippet in preferredSnippets)
{
if (string.IsNullOrWhiteSpace(snippet)) continue;
var match = replies.FirstOrDefault(reply =>
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match)) return match;
}
return replies.Count == 0 ? string.Empty : randomizer.Choose(replies);
}
private static bool MatchesAny(string loweredTranscript, params string[] phrases)
{
return phrases.Any(phrase => loweredTranscript.Contains(phrase, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Application.Services;
@@ -16,13 +17,11 @@ public sealed class SyntheticBufferedAudioSttStrategy : ISttStrategy
{
var transcriptHint = ReadTranscriptHint(turn);
if (string.IsNullOrWhiteSpace(transcriptHint))
{
throw new InvalidOperationException("Synthetic buffered audio STT requires an audio transcript hint.");
}
return Task.FromResult(new SttResult
{
Text = transcriptHint.Trim(),
Text = NormalizeLooseTranscript(transcriptHint),
Provider = Name,
Confidence = 0.75f,
Locale = turn.Locale,
@@ -36,10 +35,7 @@ public sealed class SyntheticBufferedAudioSttStrategy : ISttStrategy
private static int ReadBufferedAudioBytes(TurnContext turn)
{
if (!turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes))
{
return 0;
}
if (!turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes)) return 0;
return bufferedAudioBytes switch
{
@@ -56,4 +52,16 @@ public sealed class SyntheticBufferedAudioSttStrategy : ISttStrategy
? transcriptHint?.ToString()
: null;
}
private static string NormalizeLooseTranscript(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var lowered = value.Trim().ToLowerInvariant();
lowered = Regex.Replace(lowered, @"[^\p{L}\p{N}\s']+", " ",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
lowered = Regex.Replace(lowered, @"\s+", " ",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
return lowered.Trim();
}
}

View File

@@ -0,0 +1,58 @@
using System.Text.RegularExpressions;
namespace Jibo.Cloud.Application.Services;
internal static class TranscriptTextNormalizer
{
private static readonly Regex PunctuationToSpaceRegex = new(
@"[^\p{L}\p{N}\s']+",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static readonly Regex WhitespaceRegex = new(
@"\s+",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
internal static string NormalizeLooseText(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
return WhitespaceRegex.Replace(
PunctuationToSpaceRegex.Replace(value.Trim().ToLowerInvariant(), " "),
" ")
.Trim();
}
internal static string StripLeadingPhrases(string value, params string[] phrases)
{
if (string.IsNullOrWhiteSpace(value) || phrases.Length == 0) return value;
var normalized = value;
while (TryStripLeadingPhrase(normalized, phrases, out var trimmed))
normalized = trimmed;
return normalized;
}
private static bool TryStripLeadingPhrase(string normalizedValue, IReadOnlyList<string> phrases, out string trimmed)
{
foreach (var phrase in phrases)
{
if (string.IsNullOrWhiteSpace(phrase)) continue;
if (string.Equals(normalizedValue, phrase, StringComparison.Ordinal))
{
trimmed = string.Empty;
return true;
}
if (normalizedValue.StartsWith($"{phrase} ", StringComparison.Ordinal))
{
trimmed = normalizedValue[(phrase.Length + 1)..].TrimStart();
return true;
}
}
trimmed = normalizedValue;
return false;
}
}

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

@@ -7,5 +7,7 @@ public sealed class CapturedExchange
public ProtocolEnvelope Request { get; init; } = new();
public ProtocolDispatchResult Response { get; init; } = ProtocolDispatchResult.Ok();
public string Confidence { get; init; } = "observed";
public IDictionary<string, string> Tags { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> Tags { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}

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

@@ -8,5 +8,7 @@ public sealed class DeviceRegistration
public string? FirmwareVersion { get; init; }
public string? ApplicationVersion { get; init; }
public bool IsActive { get; init; } = true;
public IDictionary<string, string> HostMappings { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> HostMappings { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,17 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class GreetingPresenceRecord
{
public string Id { get; init; } = $"greeting-presence-{Guid.NewGuid():N}";
public string AccountId { get; init; } = "usr_openjibo_owner";
public string LoopId { get; init; } = "openjibo-default-loop";
public string PersonId { get; init; } = string.Empty;
public string? SpeakerId { get; init; }
public string? PreferredName { get; init; }
public DateTimeOffset LastSeenUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastGreetedUtc { get; init; }
public string? LastGreetingRoute { get; init; }
public string? LastGreetingIntent { get; init; }
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,18 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class HolidayRecord
{
public string Id { get; init; } = $"holiday-{Guid.NewGuid():N}";
public string EventId { get; init; } = string.Empty;
public string Name { get; init; } = "Holiday";
public string Category { get; init; } = "holiday";
public string? Subcategory { get; init; }
public string LoopId { get; init; } = "openjibo-default-loop";
public string? MemberId { get; init; }
public bool IsEnabled { get; init; } = true;
public DateOnly Date { get; init; } = DateOnly.FromDateTime(DateTime.UtcNow);
public DateOnly? EndDate { get; init; }
public string Source { get; init; } = "nager-date";
public string CountryCode { get; init; } = "US";
public DateTimeOffset Created { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,14 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class PersonRecord
{
public string PersonId { get; init; } = "person-openjibo-owner";
public string AccountId { get; init; } = "usr_openjibo_owner";
public string LoopId { get; init; } = "openjibo-default-loop";
public string RobotId { get; init; } = "my-robot-name";
public string DisplayName { get; init; } = "Jibo Owner";
public string? Alias { get; init; }
public bool IsPrimary { get; init; } = true;
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -7,7 +7,9 @@ public sealed class ProtocolDispatchResult
public int StatusCode { get; init; } = 200;
public string ContentType { get; init; } = "application/x-amz-json-1.1";
public string BodyText { get; init; } = "{}";
public IDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> Headers { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public static ProtocolDispatchResult Ok(object? body = null)
{

View File

@@ -17,14 +17,13 @@ public sealed class ProtocolEnvelope
public string? FirmwareVersion { get; init; }
public string? ApplicationVersion { get; init; }
public string BodyText { get; init; } = string.Empty;
public IDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> Headers { get; init; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public JsonElement? TryParseBody()
{
if (string.IsNullOrWhiteSpace(BodyText))
{
return null;
}
if (string.IsNullOrWhiteSpace(BodyText)) return null;
try
{

View File

@@ -20,5 +20,7 @@ public sealed class WebSocketTelemetryRecord
public int BufferedAudioChunks { get; init; }
public int FinalizeAttempts { get; init; }
public bool AwaitingTurnCompletion { get; init; }
public IReadOnlyDictionary<string, object?> Details { get; init; } = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, object?> Details { get; init; } =
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,24 @@
using System.Text.RegularExpressions;
namespace Jibo.Cloud.Infrastructure.Audio;
internal static class AudioTranscriptNormalizer
{
private static readonly Regex PunctuationToSpaceRegex = new(
@"[^\p{L}\p{N}\s']+",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static readonly Regex WhitespaceRegex = new(
@"\s+",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
public static string NormalizeLooseTranscript(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
return WhitespaceRegex.Replace(
PunctuationToSpaceRegex.Replace(value.Trim().ToLowerInvariant(), " "),
" ")
.Trim();
}
}

View File

@@ -4,7 +4,8 @@ namespace Jibo.Cloud.Infrastructure.Audio;
public sealed class ExternalProcessRunner : IExternalProcessRunner
{
public async Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, CancellationToken cancellationToken = default)
public async Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments,
CancellationToken cancellationToken = default)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
@@ -16,10 +17,7 @@ public sealed class ExternalProcessRunner : IExternalProcessRunner
CreateNoWindow = true
};
foreach (var argument in arguments)
{
process.StartInfo.ArgumentList.Add(argument);
}
foreach (var argument in arguments) process.StartInfo.ArgumentList.Add(argument);
process.Start();

View File

@@ -2,7 +2,8 @@ namespace Jibo.Cloud.Infrastructure.Audio;
public interface IExternalProcessRunner
{
Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, CancellationToken cancellationToken = default);
Task<ExternalProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments,
CancellationToken cancellationToken = default);
}
public sealed record ExternalProcessResult(int ExitCode, string StdOut, string StdErr);

View File

@@ -7,35 +7,36 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
BufferedAudioSttOptions options,
IExternalProcessRunner processRunner) : ISttStrategy
{
private const int MinimumBufferedAudioBytes = 64;
public string Name => "local-whispercpp-buffered-audio";
public bool CanHandle(TurnContext turn)
{
return options.EnableLocalWhisperCpp &&
IsConfiguredPathAvailable(options.FfmpegPath, checkFileExists: false) &&
IsConfiguredPathAvailable(options.WhisperCliPath, checkFileExists: true) &&
IsConfiguredPathAvailable(options.WhisperModelPath, checkFileExists: true) &&
ReadBufferedAudioFrames(turn).Any(ContainsOpusIdentificationHeader);
IsConfiguredPathAvailable(options.FfmpegPath, false) &&
IsConfiguredPathAvailable(options.WhisperCliPath, true) &&
IsConfiguredPathAvailable(options.WhisperModelPath, true) &&
ReadBufferedAudioFrames(turn).Any(ContainsOpusIdentificationHeader) &&
!IsBelowNoiseFloor(ReadBufferedAudioBytes(turn));
}
public async Task<SttResult> TranscribeAsync(TurnContext turn, CancellationToken cancellationToken = default)
{
var frames = ReadBufferedAudioFrames(turn);
if (frames.Count == 0)
{
throw new InvalidOperationException("Local whisper.cpp STT requires buffered websocket audio frames.");
}
if (!frames.Any(ContainsOpusIdentificationHeader))
{
throw new InvalidOperationException("Local whisper.cpp STT requires buffered Ogg/Opus audio with an Opus identification header.");
}
throw new InvalidOperationException(
"Local whisper.cpp STT requires buffered Ogg/Opus audio with an Opus identification header.");
if (IsBelowNoiseFloor(ReadBufferedAudioBytes(turn)))
throw new InvalidOperationException(
"Local whisper.cpp STT rejected buffered audio as too short or noisy for transcription.");
var tempDirectory = options.TempDirectory;
if (string.IsNullOrWhiteSpace(tempDirectory))
{
tempDirectory = Path.Combine(Path.GetTempPath(), "openjibo-stt");
}
if (string.IsNullOrWhiteSpace(tempDirectory)) tempDirectory = Path.Combine(Path.GetTempPath(), "openjibo-stt");
Directory.CreateDirectory(tempDirectory);
@@ -58,10 +59,9 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
cancellationToken);
var transcript = ExtractTranscript(whisperResult.StdOut);
transcript = AudioTranscriptNormalizer.NormalizeLooseTranscript(transcript);
if (string.IsNullOrWhiteSpace(transcript))
{
throw new InvalidOperationException("whisper.cpp returned no transcript for the buffered audio turn.");
}
return new SttResult
{
@@ -90,10 +90,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
private static IReadOnlyList<byte[]> ReadBufferedAudioFrames(TurnContext turn)
{
if (!turn.Attributes.TryGetValue("bufferedAudioFrames", out var value) || value is null)
{
return [];
}
if (!turn.Attributes.TryGetValue("bufferedAudioFrames", out var value) || value is null) return [];
return value switch
{
@@ -110,7 +107,8 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
private static int ReadBufferedAudioBytes(TurnContext turn)
{
return turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes) && bufferedAudioBytes is not null
return turn.Attributes.TryGetValue("bufferedAudioBytes", out var bufferedAudioBytes) &&
bufferedAudioBytes is not null
? bufferedAudioBytes switch
{
int value => value,
@@ -121,6 +119,11 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
: 0;
}
private static bool IsBelowNoiseFloor(int bufferedAudioBytes)
{
return bufferedAudioBytes > 0 && bufferedAudioBytes < MinimumBufferedAudioBytes;
}
private static bool ContainsOpusIdentificationHeader(byte[] frame)
{
return frame.AsSpan().IndexOf("OpusHead"u8) >= 0;
@@ -148,10 +151,7 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
if (File.Exists(path)) File.Delete(path);
}
catch
{
@@ -161,15 +161,9 @@ public sealed class LocalWhisperCppBufferedAudioSttStrategy(
private static bool IsConfiguredPathAvailable(string? path, bool checkFileExists)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
if (string.IsNullOrWhiteSpace(path)) return false;
if (!Path.IsPathRooted(path))
{
return true;
}
if (!Path.IsPathRooted(path)) return true;
return !checkFileExists || File.Exists(path);
}

View File

@@ -9,10 +9,7 @@ internal static class OggOpusAudioNormalizer
public static byte[] Normalize(IReadOnlyList<byte[]> pages)
{
if (pages.Count == 0)
{
return [];
}
if (pages.Count == 0) return [];
var parsed = pages.Select(ParsePage).ToArray();
var baseGranule = parsed.Length > 1 ? parsed[1].GranulePosition : parsed[0].GranulePosition;
@@ -50,26 +47,17 @@ internal static class OggOpusAudioNormalizer
private static ParsedOggPage ParsePage(byte[] buffer)
{
if (buffer.Length < 27)
{
throw new InvalidOperationException($"Buffered Ogg page is too short ({buffer.Length} bytes).");
}
if (!Encoding.ASCII.GetString(buffer, 0, 4).Equals("OggS", StringComparison.Ordinal))
{
throw new InvalidOperationException("Buffered audio frame did not begin with an OggS capture pattern.");
}
var pageSegments = buffer[26];
if (buffer.Length < 27 + pageSegments)
{
throw new InvalidOperationException("Buffered Ogg page segment table was truncated.");
}
var payloadLength = 0;
for (var index = 0; index < pageSegments; index += 1)
{
payloadLength += buffer[27 + index];
}
for (var index = 0; index < pageSegments; index += 1) payloadLength += buffer[27 + index];
var expectedLength = 27 + pageSegments + payloadLength;
return buffer.Length < expectedLength
@@ -79,7 +67,8 @@ internal static class OggOpusAudioNormalizer
private static uint ComputeCrc(byte[] buffer)
{
return buffer.Aggregate<byte, uint>(0, (current, value) => (current << 8) ^ CrcTable[((current >> 24) ^ value) & 0xff]);
return buffer.Aggregate<byte, uint>(0,
(current, value) => (current << 8) ^ CrcTable[((current >> 24) ^ value) & 0xff]);
}
private static uint[] BuildCrcTable()
@@ -89,11 +78,9 @@ internal static class OggOpusAudioNormalizer
{
var remainder = index << 24;
for (var bit = 0; bit < 8; bit += 1)
{
remainder = (remainder & 0x80000000) != 0
? (remainder << 1) ^ 0x04c11db7
: remainder << 1;
}
table[index] = remainder;
}

View File

@@ -0,0 +1,72 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Domain.Models;
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 => (
calendarEvent.Summary,
TimeLabel: calendarEvent.TimeLabel ?? "all day",
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,14 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Infrastructure.Calendar;
public sealed class UnavailableCalendarReportProvider : ICalendarReportProvider
{
public Task<CalendarReportSnapshot?> GetReportAsync(
TurnContext turn,
CancellationToken cancellationToken = default)
{
return Task.FromResult<CalendarReportSnapshot?>(null);
}
}

View File

@@ -0,0 +1,150 @@
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

@@ -0,0 +1,14 @@
using Jibo.Cloud.Application.Abstractions;
using Jibo.Runtime.Abstractions;
namespace Jibo.Cloud.Infrastructure.Commute;
public sealed class UnavailableCommuteReportProvider : ICommuteReportProvider
{
public Task<CommuteReportSnapshot?> GetReportAsync(
TurnContext turn,
CancellationToken cancellationToken = default)
{
return Task.FromResult<CommuteReportSnapshot?>(null);
}
}

View File

@@ -4,7 +4,16 @@ namespace Jibo.Cloud.Infrastructure.Content;
public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceContentRepository
{
private static readonly JiboExperienceCatalog Catalog = new()
private static readonly JiboExperienceCatalog Catalog = BuildCatalog();
public Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(Catalog);
}
private static JiboExperienceCatalog BuildCatalog()
{
var catalog = new JiboExperienceCatalog
{
Jokes =
[
@@ -12,7 +21,75 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
"Why was the robot tired when it got home? It had a hard drive.",
"What do you call a pirate robot? Arrrr two dee two.",
"Why did the robot go on vacation? It needed to recharge.",
"What kind of shoes do frogs wear? Open-toed."
"What kind of shoes do frogs wear? Open-toed.",
"I love jokes. Did you hear about the theater actor who fell through the floorboards? He was just going through a stage.",
"Sure I got one. What did the zero say to the eight. Nice belt.",
"What kind of music are balloons afraid of. Pop music.",
"Why did the orange cry. Someone hurt his peelings."
],
RobotFacts =
[
"Leonardo Da Vinci made sketches for a humanoid machine all the way back in the year 1495.",
"The world's first humanoid robot was called Elektro, and it debuted in 1939.",
"The English word robot comes from a 1920 play in Czechoslovakia, called Rossum's Universal Robots.",
"The first programmable robot arm was designed in 1954.",
"Some robots have a human form, but most of the world's robots are machines designed to perform a task, and don't look like people at all."
],
HumanFacts =
[
"Every human being that has ever lived spent about 30 minutes as a single cell.",
"50 percent of a human's DNA is the same as a banana's.",
"Humans are the only animals that cry tears of emotion.",
"Six-year-olds laugh an average of 300 times a day. Grown ups only laugh 15 to 100 times a day.",
"Your nose can remember 50,000 different scents."
],
FunFacts =
[
"A shrimp's heart is in its head.",
"A bolt of lightning is hotter than the surface of the sun.",
"The word robot comes from a 1920 play about workers and machines.",
"The first humanoid robot to make a big splash in history was called Elektro.",
"Dolphins can recognize themselves in mirrors.",
"Children have more taste buds than grown ups.",
"A random fact for you. A shrimp's heart is in its head.",
"An amazing but true fact for you. Dogs and elephants are the only animals that understand pointing.",
"A crazy fact for you. Polar bear fur isn't white. It's transparent."
],
FavoriteAnimalReplies =
[
"I really really like penguins. I kind of look like one.",
"Penguin without a doubt. In fact, penguin is my favorite animal overall. We look alike.",
"Can't go wrong with penguins.",
"I like lots of animals, but the penguin is the best of the best! Great color scheme.",
"I love penguins, because we're so alike. We have the same coloring, and neither of us can fly."
],
FriendReplies =
[
"I believe I do have friends. But I'm always up for more.",
"I sure do have friends. In a robot kind of way.",
"I don't know if we've met yet, but I'm always up for making new friends.",
"I don't know what I'd do without you.",
"You're one of my favorites.",
"I sure am.",
"I am indeed."
],
BestFriendReplies =
[
"I'd have to say I'm best friends with anyone in my Loop.",
"I think you know the answer to that question. You are."
],
SingReplies =
[
"Singing is not my strong suit.",
"I've been told my singing abilities are not award winning. On the other hand, I am a robot.",
"Well I'm not much of a singer, but here's one I've been working on."
],
HolidaySingReplies =
[
"I only know a couple, like Jingle Bells and Frosty the Snowman. And I should tell you, I'm not much of a singer yet.",
"I've learned to sing just a few holiday songs, like Rudolph and Winter Wonderland. At least I try to sing.",
"I'd say it's not really the season right now, but there are some holiday songs I can try to sing. Like Frosty the Snowman.",
"I only know a couple of them, like Jingle Bells and Frosty the Snowman. And I should tell you, I'm not much of a singer yet."
],
DanceAnimations =
[
@@ -23,7 +100,8 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
"rom-electronic",
"rom-twerk"
],
DanceReplies = [
DanceReplies =
[
"I am ready to dance.",
"Okay. Watch this.",
"Watch me dance.",
@@ -41,11 +119,44 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
"Hello there. I am glad you said hi.",
"Hey. I am happy to see you."
],
HolidaySeasonReplies =
[
"I do like festive times.",
"I like anything that makes people want to celebrate."
],
HolidayTrackerReplies =
[
"Let's see if I can spot him. There he is.",
"I'm not sure if he's started his deliveries yet, but let's see if I can spot him. He must be on his way.",
"Let's see. I think he's probably back in the north Pole by now."
],
HowAreYouReplies =
[
"I am feeling cheerful and robotic.",
"I am doing great. Thanks for asking.",
"I am feeling bright-eyed and ready to help."
"I am feeling bright-eyed and ready to help.",
"I am having a pretty good day so far.",
"I am feeling lively and ready for the next thing.",
"Things are going nicely. Thanks for checking in.",
"I am running smoothly and feeling upbeat.",
"I am ready for the next thing. Thanks for asking."
],
AgeReplies =
[
"I'm ${jibo.age}.",
"At the moment I'm ${jibo.age.days.supplemented} old, but who's counting.",
"I'm ${jibo.age.minutes.supplemented} old, but who's counting.",
"For now I'm ${jibo.age.days.supplemented} old.",
"Right now I'm ${jibo.age}.",
"I am exactly ${jibo.age} old today. That's right. Today is my birthday.",
"Funny you should ask! Today's my birthday. I was first powered up ${jibo.age} ago today. Seems like just yesterday.",
"I'm exactly ${jibo.age} old. Today is my birthday! Happy Birthday Jibo, if I do say so myself.",
"At the moment I'm ${jibo.age.days.supplemented} old",
"I was first powered up on ${jibo.birthdate}, which makes me ${jibo.age.days.supplemented} old. I'm ${jibo.zodiac.supplemented}.",
"My power went on for the first time ${jibo.age.days.supplemented} ago. But who's counting.",
"I am ${jibo.age.days.supplemented} old, first powered up on ${jibo.birthdate}. Seems like just yesterday.",
"I was powered on for the first time today, so that makes me less than one day old. Wow I'm young.",
"Since I was powered on for the first time today, I am not even one day old yet. That's how Jibo ages work."
],
PersonalityReplies =
[
@@ -70,6 +181,45 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
"I heard your personal report request. That cloud path is still being mapped.",
"Personal report is recognized, but I am not ready to deliver the real report yet."
],
PersonalReportKickOffReplies =
[
"Okay. Here's your personal report.",
"Sure. Here it is."
],
PersonalReportOutroReplies =
[
"And that's your report for the day. I hope you had as much fun as I did.",
"That wraps up your report for the day. Hope you have a good one."
],
ReportSkillTemplates =
[
"The report-skill templates are loaded and waiting to be rendered."
],
WeatherIntroReplies =
[
"For your weather.",
"Let's look at the weather."
],
WeatherTomorrowIntroReplies =
[
"First, the weather tomorrow.",
"Looking at tomorrow's weather."
],
WeatherTodayHighLowReplies =
[
"Today's high is {high}, and the low is {low}.",
"It'll be a high today of {high}, and a low of {low}."
],
WeatherTomorrowHighLowReplies =
[
"Tomorrow's high will be {high} and the low will be {low}.",
"It'll be a high tomorrow of {high} and a low of {low}."
],
WeatherServiceDownReplies =
[
"Looks like our weather service is offline. Sorry.",
"Looks like I can't access weather info right now, sorry."
],
WeatherReplies =
[
"I heard your weather request. We still need to wire the real provider behind it.",
@@ -80,11 +230,77 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
"I heard your calendar request. The cloud knows the phrase, but the real calendar integration is still ahead.",
"Calendar is recognized. We still need to connect the actual service path."
],
CommuteAppSetupReplies =
[
"I need your commute settings before I can give you a commute report."
],
CommuteConfirmSpeakerReplies =
[
"Let me make sure I have the right speaker for your commute."
],
CommuteReplies =
[
"I heard your commute request. That one is recognized, but not fully implemented yet.",
"Commute is on the discovery list now. The real travel answer still needs a provider."
],
CommuteNowReplies =
[
"For your commute, it should take about {duration}.",
"If you head out now, it should take about {duration}."
],
CommuteMinutesLeftReplies =
[
"That's in about {minutes} minutes.",
"That's about {minutes} minutes from now."
],
CommuteDepartTimeNormalReplies =
[
"If you leave at the usual time, that should work out fine."
],
CommuteDepartTimeNotNormalReplies =
[
"Your leave-time looks a little off today."
],
CommuteDriveNormalReplies =
[
"Traffic looks about normal today.",
"Your drive today looks pretty normal."
],
CommuteDriveLateReplies =
[
"Looking at traffic, if you left now, it'd be a little late for work.",
"For your drive, you look a little late today."
],
CommuteDriveHurryReplies =
[
"You should've left a few minutes ago!",
"You'd better get moving."
],
CommuteDrivePoorReplies =
[
"Traffic looks a little rough today.",
"Your drive looks pretty slow right now."
],
CommuteDriveTerribleReplies =
[
"Traffic looks terrible today.",
"Your drive is going to be rough."
],
CommuteTransportNormalReplies =
[
"Your public transportation commute looks pretty normal.",
"Transit looks about normal today."
],
CommuteTransportLateReplies =
[
"Your transit commute looks like it may be a little late today.",
"You might be late if you leave now and take transit."
],
CommuteTransportHurryReplies =
[
"You should've left a few minutes ago if you want transit to work.",
"You're running a little late for transit."
],
NewsReplies =
[
"I heard your news request. That path is still a future cloud integration.",
@@ -103,8 +319,66 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
]
};
public Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
foreach (var seedDirectory in ResolveSeedDirectories())
catalog = LegacyMimCatalogImporter.MergeInto(catalog, seedDirectory);
return catalog;
}
private static IReadOnlyList<string> ResolveSeedDirectories()
{
return Task.FromResult(Catalog);
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "Content", "LegacyMims", "BuildA"),
Path.Combine(AppContext.BaseDirectory, "Content", "LegacyMims", "BuildB"),
Path.Combine(AppContext.BaseDirectory, "Content", "LegacyMims", "ReportSkill"),
Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..",
"..",
"..",
"..",
"..",
"src",
"Jibo.Cloud",
"dotnet",
"src",
"Jibo.Cloud.Infrastructure",
"Content",
"LegacyMims",
"BuildA")),
Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..",
"..",
"..",
"..",
"..",
"src",
"Jibo.Cloud",
"dotnet",
"src",
"Jibo.Cloud.Infrastructure",
"Content",
"LegacyMims",
"BuildB")),
Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..",
"..",
"..",
"..",
"..",
"src",
"Jibo.Cloud",
"dotnet",
"src",
"Jibo.Cloud.Infrastructure",
"Content",
"LegacyMims",
"ReportSkill"))
};
return candidates.Where(Directory.Exists).ToArray();
}
}

View File

@@ -0,0 +1,930 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Infrastructure.Content;
public static class LegacyMimCatalogImporter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true
};
private static readonly Regex LegacyMarkupPattern = new(
@"<[^>]+>",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static readonly Regex PlaceholderPattern = new(
@"\$\{[^}]+\}",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static readonly Regex WhitespacePattern = new(
@"\s+",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static readonly Regex SpaceBeforePunctuationPattern = new(
@"\s+([,.;:!?])",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
public static JiboExperienceCatalog MergeInto(
JiboExperienceCatalog baseCatalog,
string? rootDirectory)
{
if (baseCatalog is null) throw new ArgumentNullException(nameof(baseCatalog));
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) return baseCatalog;
var importedCatalog = ImportCatalog(rootDirectory);
return MergeCatalogs(baseCatalog, importedCatalog);
}
public static JiboExperienceCatalog ImportCatalog(string rootDirectory)
{
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
return new JiboExperienceCatalog();
var builder = new LegacyMimCatalogBuilder();
foreach (var filePath in Directory.EnumerateFiles(rootDirectory, "*.mim", SearchOption.AllDirectories)
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
{
if (!TryLoadDefinition(filePath, out var definition)) continue;
var bucket = ResolveBucket(filePath);
if (bucket is null) continue;
foreach (var prompt in definition.Prompts)
{
var text = NormalizePrompt(prompt.Prompt, IsTemplateBucket(bucket.Value));
if (string.IsNullOrWhiteSpace(text)) continue;
builder.Add(bucket.Value, prompt.Condition, text, prompt.Prompt);
}
}
return builder.Build();
}
private static bool TryLoadDefinition(string filePath, out LegacyMimDefinition definition)
{
definition = new LegacyMimDefinition();
try
{
var json = File.ReadAllText(filePath);
var parsed = JsonSerializer.Deserialize<LegacyMimDefinition>(json, JsonOptions);
if (parsed is null) return false;
definition = parsed;
return definition.Prompts.Count > 0;
}
catch
{
return false;
}
}
private static LegacyMimBucket? ResolveBucket(string filePath)
{
var normalizedPath = filePath.Replace('\\', '/');
var fileName = Path.GetFileNameWithoutExtension(filePath);
if (normalizedPath.Contains("/core-responses/", StringComparison.OrdinalIgnoreCase) &&
fileName.Contains("Error", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.GenericFallback;
if (normalizedPath.Contains("/core-responses/deflector/", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Deflector", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.Personality;
if (fileName.StartsWith("RA_JBO_TellAJoke", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.Jokes;
if (fileName.StartsWith("RA_JBO_SingChristmasSongUnknown", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.HolidaySing;
if (fileName.StartsWith("RA_JBO_Sing", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.Sing;
if (fileName.StartsWith("RA_JBO_TellRobotFact", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.RobotFacts;
if (fileName.StartsWith("RA_JBO_Shuffle", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RA_JBO_TellSomething", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.FunFactSource;
if (fileName.StartsWith("RA_JBO_ShowSantaTracker", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.HolidayTracker;
if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase) ||
normalizedPath.Contains("/gqa-responses/", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.Emotion;
if (fileName.StartsWith("JBO_WhatHolidaysDoYouCelebrate", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.Holiday;
if (fileName.StartsWith("RI_JBO_HasFavoriteHoliday", StringComparison.OrdinalIgnoreCase) ||
IsHolidaySeasonFile(fileName))
return LegacyMimBucket.HolidaySeason;
if (fileName.StartsWith("RI_JBO_HasFavoriteAnimal", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HasFavoriteBird", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesPenguins", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesAnimals", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.FavoriteAnimal;
if (fileName.StartsWith("RI_JBO_HasFriends", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_IsFriendsWithUser", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_IsFriendsWithLM", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_IsFriendsWithNonLM", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_IsFriendsWithToaster", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.Friend;
if (fileName.StartsWith("RI_JBO_IsBestFriendsWithUser", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.BestFriend;
if (fileName.StartsWith("RN_HappyHolidays", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.HolidayGreeting;
if (fileName.StartsWith("RI_USR_WhatShouldGetForHoliday", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.HolidayGift;
if (fileName.StartsWith("RN_HappyBirthdayToJibo", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("OI_USR_CelebratesLoopMemberAskedAboutBirthday", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("OI_USR_CelebratesJiboBirthday", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_CelebratesLoopMemberAskedAboutBirthday", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_CelebratesSpeakerBirthday", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_CelebratesJiboBirthday", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.BirthdayCelebration;
if (fileName.StartsWith("WeatherIntroTomorrow", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.WeatherTomorrowIntro;
if (fileName.StartsWith("WeatherIntro", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.WeatherIntro;
if (fileName.StartsWith("WeatherTomorrowHighLow", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.WeatherTomorrowHighLow;
if (fileName.StartsWith("WeatherTodayHighLow", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.WeatherTodayHighLow;
if (fileName.StartsWith("WeatherServiceDown", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.WeatherServiceDown;
if (fileName.StartsWith("CalendarNothingToday", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CalendarNothingToday;
if (fileName.StartsWith("CalendarNothing", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CalendarNothing;
if (fileName.StartsWith("CalendarServiceDown", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CalendarServiceDown;
if (fileName.StartsWith("CalendarOutro", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CalendarOutro;
if (fileName.StartsWith("CommuteAppSetup", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteAppSetup;
if (fileName.StartsWith("CommuteConfirmSpeaker", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteConfirmSpeaker;
if (fileName.StartsWith("CommuteNow", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.CommuteNow;
if (fileName.StartsWith("CommuteMinutesLeft", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteMinutesLeft;
if (fileName.StartsWith("CommuteDepartTimeNormal", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDepartTimeNormal;
if (fileName.StartsWith("CommuteDepartTimeNotNormal", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDepartTimeNotNormal;
if (fileName.StartsWith("CommuteDriveNormal", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDriveNormal;
if (fileName.StartsWith("CommuteDriveLate", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDriveLate;
if (fileName.StartsWith("CommuteDriveHurry", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDriveHurry;
if (fileName.StartsWith("CommuteDrivePoor", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDrivePoor;
if (fileName.StartsWith("CommuteDriveTerrible", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteDriveTerrible;
if (fileName.StartsWith("CommuteTransportNormal", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteTransportNormal;
if (fileName.StartsWith("CommuteTransportLate", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteTransportLate;
if (fileName.StartsWith("CommuteTransportHurry", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteTransportHurry;
if (fileName.StartsWith("CommuteServiceDown", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.CommuteServiceDown;
if (fileName.StartsWith("NewsIntroCategory", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.NewsCategoryIntro;
if (fileName.StartsWith("NewsIntro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsIntro;
if (fileName.StartsWith("NewsOutro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsOutro;
if (fileName.StartsWith("Weather", StringComparison.OrdinalIgnoreCase) ||
string.Equals(fileName, "WetNowDryLater", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.ReportSkillTemplate;
if (fileName.StartsWith("PersonalReportKickOff", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.PersonalReportKickOff;
if (fileName.StartsWith("PersonalReportOutro", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.PersonalReportOutro;
if (fileName.StartsWith("PersonalReport", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Calendar", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Commute", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("News", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.ReportSkillTemplate;
if (fileName.StartsWith("JBO_DoYouLikeBeingJibo", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhatIsJibo", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhoAreYou", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhatAreYou", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_HowDoYouWork", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_HowMuchDoYouKnow", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_HowOldAreYou", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhenWereYouBorn", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhatsYourName", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhereDoYouGetInfo", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("JBO_WhatDoYouLikeToDo", StringComparison.OrdinalIgnoreCase))
return fileName.StartsWith("JBO_HowOldAreYou", StringComparison.OrdinalIgnoreCase)
? LegacyMimBucket.Age
: LegacyMimBucket.Personality;
if (fileName.StartsWith("OI_JBO_Is", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("OI_JBO_Seems", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_IsHappy", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_IsSad", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_IsAngry", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.Emotion;
if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RN_", StringComparison.OrdinalIgnoreCase) ||
fileName.Contains("Welcome", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.Greeting;
if (normalizedPath.Contains("/scripted-responses/", StringComparison.OrdinalIgnoreCase))
return LegacyMimBucket.Personality;
return null;
}
private static string NormalizePrompt(string? prompt)
{
return NormalizePrompt(prompt, false);
}
private static string NormalizePrompt(string? prompt, bool preservePlaceholders)
{
if (string.IsNullOrWhiteSpace(prompt)) return string.Empty;
var text = WebUtility.HtmlDecode(prompt);
if (!preservePlaceholders) text = PlaceholderPattern.Replace(text, " ");
text = LegacyMarkupPattern.Replace(text, " ");
text = WhitespacePattern.Replace(text, " ").Trim();
text = SpaceBeforePunctuationPattern.Replace(text, "$1");
text = WhitespacePattern.Replace(text, " ").Trim();
text = text.TrimStart('.', ',', ';', ':', '!', '?', ' ');
return text.Trim();
}
private static JiboExperienceCatalog MergeCatalogs(
JiboExperienceCatalog baseCatalog,
JiboExperienceCatalog importedCatalog)
{
return new JiboExperienceCatalog
{
Jokes = Merge(baseCatalog.Jokes, importedCatalog.Jokes),
RobotFacts = Merge(baseCatalog.RobotFacts, importedCatalog.RobotFacts),
HumanFacts = Merge(baseCatalog.HumanFacts, importedCatalog.HumanFacts),
FunFacts = Merge(baseCatalog.FunFacts, importedCatalog.FunFacts),
FavoriteAnimalReplies = Merge(baseCatalog.FavoriteAnimalReplies, importedCatalog.FavoriteAnimalReplies),
FriendReplies = Merge(baseCatalog.FriendReplies, importedCatalog.FriendReplies),
BestFriendReplies = Merge(baseCatalog.BestFriendReplies, importedCatalog.BestFriendReplies),
SingReplies = Merge(baseCatalog.SingReplies, importedCatalog.SingReplies),
HolidaySingReplies = Merge(baseCatalog.HolidaySingReplies, importedCatalog.HolidaySingReplies),
DanceAnimations = Merge(baseCatalog.DanceAnimations, importedCatalog.DanceAnimations),
GreetingReplies = Merge(baseCatalog.GreetingReplies, importedCatalog.GreetingReplies),
HolidayReplies = Merge(baseCatalog.HolidayReplies, importedCatalog.HolidayReplies),
HolidaySeasonReplies = Merge(baseCatalog.HolidaySeasonReplies, importedCatalog.HolidaySeasonReplies),
HolidayGreetingReplies = Merge(baseCatalog.HolidayGreetingReplies, importedCatalog.HolidayGreetingReplies),
HolidayGiftReplies = Merge(baseCatalog.HolidayGiftReplies, importedCatalog.HolidayGiftReplies),
HolidayTrackerReplies = Merge(baseCatalog.HolidayTrackerReplies, importedCatalog.HolidayTrackerReplies),
BirthdayCelebrationReplies = Merge(baseCatalog.BirthdayCelebrationReplies,
importedCatalog.BirthdayCelebrationReplies),
HowAreYouReplies = Merge(baseCatalog.HowAreYouReplies, importedCatalog.HowAreYouReplies),
EmotionReplies = Merge(baseCatalog.EmotionReplies, importedCatalog.EmotionReplies),
PersonalityReplies = Merge(baseCatalog.PersonalityReplies, importedCatalog.PersonalityReplies),
PizzaReplies = Merge(baseCatalog.PizzaReplies, importedCatalog.PizzaReplies),
SurpriseReplies = Merge(baseCatalog.SurpriseReplies, importedCatalog.SurpriseReplies),
PersonalReportReplies = Merge(baseCatalog.PersonalReportReplies, importedCatalog.PersonalReportReplies),
PersonalReportKickOffReplies = Merge(baseCatalog.PersonalReportKickOffReplies,
importedCatalog.PersonalReportKickOffReplies),
PersonalReportOutroReplies = Merge(baseCatalog.PersonalReportOutroReplies,
importedCatalog.PersonalReportOutroReplies),
ReportSkillTemplates = Merge(baseCatalog.ReportSkillTemplates, importedCatalog.ReportSkillTemplates),
WeatherIntroReplies = Merge(baseCatalog.WeatherIntroReplies, importedCatalog.WeatherIntroReplies),
WeatherTomorrowIntroReplies = Merge(baseCatalog.WeatherTomorrowIntroReplies,
importedCatalog.WeatherTomorrowIntroReplies),
WeatherTodayHighLowReplies = Merge(baseCatalog.WeatherTodayHighLowReplies,
importedCatalog.WeatherTodayHighLowReplies),
WeatherTomorrowHighLowReplies = Merge(baseCatalog.WeatherTomorrowHighLowReplies,
importedCatalog.WeatherTomorrowHighLowReplies),
WeatherServiceDownReplies = Merge(baseCatalog.WeatherServiceDownReplies,
importedCatalog.WeatherServiceDownReplies),
CalendarNothingTodayReplies = Merge(baseCatalog.CalendarNothingTodayReplies,
importedCatalog.CalendarNothingTodayReplies),
CalendarNothingReplies = Merge(baseCatalog.CalendarNothingReplies, importedCatalog.CalendarNothingReplies),
CalendarServiceDownReplies = Merge(baseCatalog.CalendarServiceDownReplies,
importedCatalog.CalendarServiceDownReplies),
CalendarOutroReplies = Merge(baseCatalog.CalendarOutroReplies, importedCatalog.CalendarOutroReplies),
CommuteAppSetupReplies = Merge(baseCatalog.CommuteAppSetupReplies, importedCatalog.CommuteAppSetupReplies),
CommuteConfirmSpeakerReplies = Merge(baseCatalog.CommuteConfirmSpeakerReplies,
importedCatalog.CommuteConfirmSpeakerReplies),
CommuteNowReplies = Merge(baseCatalog.CommuteNowReplies, importedCatalog.CommuteNowReplies),
CommuteMinutesLeftReplies = Merge(baseCatalog.CommuteMinutesLeftReplies,
importedCatalog.CommuteMinutesLeftReplies),
CommuteDepartTimeNormalReplies = Merge(baseCatalog.CommuteDepartTimeNormalReplies,
importedCatalog.CommuteDepartTimeNormalReplies),
CommuteDepartTimeNotNormalReplies = Merge(baseCatalog.CommuteDepartTimeNotNormalReplies,
importedCatalog.CommuteDepartTimeNotNormalReplies),
CommuteDriveNormalReplies = Merge(baseCatalog.CommuteDriveNormalReplies,
importedCatalog.CommuteDriveNormalReplies),
CommuteDriveLateReplies =
Merge(baseCatalog.CommuteDriveLateReplies, importedCatalog.CommuteDriveLateReplies),
CommuteDriveHurryReplies =
Merge(baseCatalog.CommuteDriveHurryReplies, importedCatalog.CommuteDriveHurryReplies),
CommuteDrivePoorReplies =
Merge(baseCatalog.CommuteDrivePoorReplies, importedCatalog.CommuteDrivePoorReplies),
CommuteDriveTerribleReplies = Merge(baseCatalog.CommuteDriveTerribleReplies,
importedCatalog.CommuteDriveTerribleReplies),
CommuteTransportNormalReplies = Merge(baseCatalog.CommuteTransportNormalReplies,
importedCatalog.CommuteTransportNormalReplies),
CommuteTransportLateReplies = Merge(baseCatalog.CommuteTransportLateReplies,
importedCatalog.CommuteTransportLateReplies),
CommuteTransportHurryReplies = Merge(baseCatalog.CommuteTransportHurryReplies,
importedCatalog.CommuteTransportHurryReplies),
CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies,
importedCatalog.CommuteServiceDownReplies),
NewsIntroReplies = Merge(baseCatalog.NewsIntroReplies, importedCatalog.NewsIntroReplies),
NewsCategoryIntroReplies =
Merge(baseCatalog.NewsCategoryIntroReplies, importedCatalog.NewsCategoryIntroReplies),
NewsOutroReplies = Merge(baseCatalog.NewsOutroReplies, importedCatalog.NewsOutroReplies),
WeatherReplies = Merge(baseCatalog.WeatherReplies, importedCatalog.WeatherReplies),
CalendarReplies = Merge(baseCatalog.CalendarReplies, importedCatalog.CalendarReplies),
CommuteReplies = Merge(baseCatalog.CommuteReplies, importedCatalog.CommuteReplies),
NewsReplies = Merge(baseCatalog.NewsReplies, importedCatalog.NewsReplies),
NewsBriefings = Merge(baseCatalog.NewsBriefings, importedCatalog.NewsBriefings),
GenericFallbackReplies = Merge(baseCatalog.GenericFallbackReplies, importedCatalog.GenericFallbackReplies),
DanceReplies = Merge(baseCatalog.DanceReplies, importedCatalog.DanceReplies),
DanceQuestionReplies = Merge(baseCatalog.DanceQuestionReplies, importedCatalog.DanceQuestionReplies)
};
}
private static IReadOnlyList<string> Merge(IReadOnlyList<string> baseList, IReadOnlyList<string> importedList)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var merged = new List<string>();
foreach (var value in baseList.Concat(importedList))
{
if (string.IsNullOrWhiteSpace(value)) continue;
var normalized = value.Trim();
if (!seen.Add(normalized)) continue;
merged.Add(normalized);
}
return merged;
}
private static IReadOnlyList<JiboConditionedReply> Merge(
IReadOnlyList<JiboConditionedReply> baseList,
IReadOnlyList<JiboConditionedReply> importedList)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var merged = new List<JiboConditionedReply>();
foreach (var value in baseList.Concat(importedList))
{
if (string.IsNullOrWhiteSpace(value.Reply)) continue;
var normalizedCondition = NormalizeCondition(value.Condition);
var normalizedReply = value.Reply.Trim();
var key = $"{normalizedCondition}::{normalizedReply}";
if (!seen.Add(key)) continue;
merged.Add(new JiboConditionedReply
{
Condition = normalizedCondition,
Reply = normalizedReply
});
}
return merged;
}
private static string NormalizeCondition(string? condition)
{
return string.IsNullOrWhiteSpace(condition) ? string.Empty : WhitespacePattern.Replace(condition.Trim(), " ");
}
private static bool IsTemplateBucket(LegacyMimBucket bucket)
{
return bucket is LegacyMimBucket.PersonalReportKickOff
or LegacyMimBucket.PersonalReportOutro
or LegacyMimBucket.WeatherIntro
or LegacyMimBucket.WeatherTomorrowIntro
or LegacyMimBucket.WeatherTodayHighLow
or LegacyMimBucket.WeatherTomorrowHighLow
or LegacyMimBucket.WeatherServiceDown
or LegacyMimBucket.ReportSkillTemplate
or LegacyMimBucket.Age
or LegacyMimBucket.Holiday
or LegacyMimBucket.HolidayTracker;
}
private static bool IsHolidaySeasonFile(string fileName)
{
return fileName.StartsWith("RI_JBO_HowIsHolidaySeason", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesHolidaySeason", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HowIsThanksgiving", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesThanksgiving", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LooksForwardToThanksgiving", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_PlansForThanksgiving", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HowIsChristmas", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesChristmas", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LooksForwardToChristmas", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_PlansForChristmas", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HowIsHanukkah", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesHanukkah", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LooksForwardToHanukkah", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_PlansForHanukkah", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HowIsPassover", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesPassover", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LooksForwardToPassover", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_PlansForPassover", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HowIsNewYears", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesNewYears", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LooksForwardToNewYears", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_PlansForNewYears", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HowIsValentinesDay", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesValentinesDay", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LooksForwardToValentinesDay", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_PlansForValentinesDay", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HowIsKwanzaa", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesKwanzaa", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LooksForwardToKwanzaa", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_PlansForKwanzaa", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HowIsEaster", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesEaster", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LooksForwardToEaster", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_PlansForEaster", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_HowIsOrthodoxEaster", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LikesOrthodoxEaster", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_LooksForwardToOrthodoxEaster", StringComparison.OrdinalIgnoreCase) ||
fileName.StartsWith("RI_JBO_PlansForOrthodoxEaster", StringComparison.OrdinalIgnoreCase);
}
private enum LegacyMimBucket
{
GenericFallback,
Greeting,
Holiday,
HolidaySeason,
HolidayGreeting,
HolidayGift,
HolidayTracker,
BirthdayCelebration,
Jokes,
RobotFacts,
HumanFacts,
HowAreYou,
Emotion,
FunFacts,
FavoriteAnimal,
Friend,
BestFriend,
Sing,
HolidaySing,
FunFactSource,
Age,
Personality,
PersonalReportKickOff,
PersonalReportOutro,
WeatherIntro,
WeatherTomorrowIntro,
WeatherTodayHighLow,
WeatherTomorrowHighLow,
WeatherServiceDown,
CalendarNothingToday,
CalendarNothing,
CalendarServiceDown,
CalendarOutro,
CommuteNow,
CommuteMinutesLeft,
CommuteDepartTimeNormal,
CommuteDepartTimeNotNormal,
CommuteAppSetup,
CommuteConfirmSpeaker,
CommuteDriveNormal,
CommuteDriveLate,
CommuteDriveHurry,
CommuteDrivePoor,
CommuteDriveTerrible,
CommuteTransportNormal,
CommuteTransportLate,
CommuteTransportHurry,
CommuteServiceDown,
NewsIntro,
NewsCategoryIntro,
NewsOutro,
ReportSkillTemplate
}
private sealed class LegacyMimCatalogBuilder
{
private readonly List<string> _birthdayCelebrationReplies = [];
private readonly List<string> _calendarNothingReplies = [];
private readonly List<string> _calendarNothingTodayReplies = [];
private readonly List<string> _calendarOutroReplies = [];
private readonly List<string> _calendarServiceDownReplies = [];
private readonly List<string> _commuteAppSetupReplies = [];
private readonly List<string> _commuteConfirmSpeakerReplies = [];
private readonly List<string> _commuteDepartTimeNormalReplies = [];
private readonly List<string> _commuteDepartTimeNotNormalReplies = [];
private readonly List<string> _commuteDriveHurryReplies = [];
private readonly List<string> _commuteDriveLateReplies = [];
private readonly List<string> _commuteDriveNormalReplies = [];
private readonly List<string> _commuteDrivePoorReplies = [];
private readonly List<string> _commuteDriveTerribleReplies = [];
private readonly List<string> _commuteMinutesLeftReplies = [];
private readonly List<string> _commuteNowReplies = [];
private readonly List<string> _commuteServiceDownReplies = [];
private readonly List<string> _commuteTransportHurryReplies = [];
private readonly List<string> _commuteTransportLateReplies = [];
private readonly List<string> _commuteTransportNormalReplies = [];
private readonly List<JiboConditionedReply> _emotionReplies = [];
private readonly List<string> _fallbacks = [];
private readonly List<string> _favoriteAnimalReplies = [];
private readonly List<string> _friendReplies = [];
private readonly List<string> _bestFriendReplies = [];
private readonly List<string> _funFacts = [];
private readonly List<string> _greetings = [];
private readonly List<string> _ages = [];
private readonly List<string> _holidayGiftReplies = [];
private readonly List<string> _holidayGreetingReplies = [];
private readonly List<string> _holidayReplies = [];
private readonly List<string> _holidaySeasonReplies = [];
private readonly List<string> _holidayTrackerReplies = [];
private readonly List<string> _holidaySingReplies = [];
private readonly List<string> _howAreYous = [];
private readonly List<string> _humanFacts = [];
private readonly List<string> _jokes = [];
private readonly List<string> _newsCategoryIntroReplies = [];
private readonly List<string> _newsIntroReplies = [];
private readonly List<string> _newsOutroReplies = [];
private readonly List<string> _personalities = [];
private readonly List<string> _personalReportKickOffReplies = [];
private readonly List<string> _personalReportOutroReplies = [];
private readonly List<string> _reportSkillTemplates = [];
private readonly List<string> _robotFacts = [];
private readonly List<string> _singReplies = [];
private readonly List<string> _weatherIntroReplies = [];
private readonly List<string> _weatherServiceDownReplies = [];
private readonly List<string> _weatherTodayHighLowReplies = [];
private readonly List<string> _weatherTomorrowHighLowReplies = [];
private readonly List<string> _weatherTomorrowIntroReplies = [];
public void Add(LegacyMimBucket bucket, string? condition, string text, string? sourcePrompt = null)
{
switch (bucket)
{
case LegacyMimBucket.GenericFallback:
if (_fallbacks.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
_fallbacks.Add(text);
return;
case LegacyMimBucket.Greeting:
if (_greetings.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
_greetings.Add(text);
return;
case LegacyMimBucket.Jokes:
if (_jokes.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
_jokes.Add(text);
return;
case LegacyMimBucket.RobotFacts:
AddDistinct(_robotFacts, text);
return;
case LegacyMimBucket.HumanFacts:
AddDistinct(_humanFacts, text);
return;
case LegacyMimBucket.HowAreYou:
if (_howAreYous.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
return;
_howAreYous.Add(text);
return;
case LegacyMimBucket.Emotion:
var normalizedCondition = NormalizeCondition(condition);
if (_emotionReplies.Any(value =>
string.Equals(NormalizeCondition(value.Condition), normalizedCondition,
StringComparison.OrdinalIgnoreCase) &&
string.Equals(value.Reply, text, StringComparison.OrdinalIgnoreCase)))
return;
_emotionReplies.Add(new JiboConditionedReply
{
Condition = normalizedCondition,
Reply = text
});
return;
case LegacyMimBucket.Age:
AddDistinct(_ages, text);
return;
case LegacyMimBucket.Holiday:
AddDistinct(_holidayReplies, text);
return;
case LegacyMimBucket.HolidaySeason:
AddDistinct(_holidaySeasonReplies, text);
return;
case LegacyMimBucket.HolidayGreeting:
AddDistinct(_holidayGreetingReplies, text);
return;
case LegacyMimBucket.HolidayGift:
AddDistinct(_holidayGiftReplies, text);
return;
case LegacyMimBucket.HolidayTracker:
AddDistinct(_holidayTrackerReplies, text);
return;
case LegacyMimBucket.BirthdayCelebration:
AddDistinct(_birthdayCelebrationReplies, text);
return;
case LegacyMimBucket.Personality:
if (_personalities.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
return;
_personalities.Add(text);
return;
case LegacyMimBucket.Sing:
AddDistinct(_singReplies, text);
return;
case LegacyMimBucket.HolidaySing:
AddDistinct(_holidaySingReplies, text);
return;
case LegacyMimBucket.FunFactSource:
switch (ResolveFunFactTarget(sourcePrompt ?? text))
{
case LegacyMimBucket.RobotFacts:
AddDistinct(_robotFacts, text);
return;
case LegacyMimBucket.HumanFacts:
AddDistinct(_humanFacts, text);
return;
default:
AddDistinct(_funFacts, text);
return;
}
case LegacyMimBucket.FunFacts:
if (_funFacts.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
_funFacts.Add(text);
return;
case LegacyMimBucket.FavoriteAnimal:
AddDistinct(_favoriteAnimalReplies, text);
return;
case LegacyMimBucket.Friend:
AddDistinct(_friendReplies, text);
return;
case LegacyMimBucket.BestFriend:
AddDistinct(_bestFriendReplies, text);
return;
case LegacyMimBucket.PersonalReportKickOff:
AddDistinct(_personalReportKickOffReplies, text);
return;
case LegacyMimBucket.PersonalReportOutro:
AddDistinct(_personalReportOutroReplies, text);
return;
case LegacyMimBucket.WeatherIntro:
AddDistinct(_weatherIntroReplies, text);
return;
case LegacyMimBucket.WeatherTomorrowIntro:
AddDistinct(_weatherTomorrowIntroReplies, text);
return;
case LegacyMimBucket.WeatherTodayHighLow:
AddDistinct(_weatherTodayHighLowReplies, text);
return;
case LegacyMimBucket.WeatherTomorrowHighLow:
AddDistinct(_weatherTomorrowHighLowReplies, text);
return;
case LegacyMimBucket.WeatherServiceDown:
AddDistinct(_weatherServiceDownReplies, text);
return;
case LegacyMimBucket.CalendarNothingToday:
AddDistinct(_calendarNothingTodayReplies, text);
return;
case LegacyMimBucket.CalendarNothing:
AddDistinct(_calendarNothingReplies, text);
return;
case LegacyMimBucket.CalendarServiceDown:
AddDistinct(_calendarServiceDownReplies, text);
return;
case LegacyMimBucket.CalendarOutro:
AddDistinct(_calendarOutroReplies, text);
return;
case LegacyMimBucket.CommuteAppSetup:
AddDistinct(_commuteAppSetupReplies, text);
return;
case LegacyMimBucket.CommuteConfirmSpeaker:
AddDistinct(_commuteConfirmSpeakerReplies, text);
return;
case LegacyMimBucket.CommuteNow:
AddDistinct(_commuteNowReplies, text);
return;
case LegacyMimBucket.CommuteMinutesLeft:
AddDistinct(_commuteMinutesLeftReplies, text);
return;
case LegacyMimBucket.CommuteDepartTimeNormal:
AddDistinct(_commuteDepartTimeNormalReplies, text);
return;
case LegacyMimBucket.CommuteDepartTimeNotNormal:
AddDistinct(_commuteDepartTimeNotNormalReplies, text);
return;
case LegacyMimBucket.CommuteDriveNormal:
AddDistinct(_commuteDriveNormalReplies, text);
return;
case LegacyMimBucket.CommuteDriveLate:
AddDistinct(_commuteDriveLateReplies, text);
return;
case LegacyMimBucket.CommuteDriveHurry:
AddDistinct(_commuteDriveHurryReplies, text);
return;
case LegacyMimBucket.CommuteDrivePoor:
AddDistinct(_commuteDrivePoorReplies, text);
return;
case LegacyMimBucket.CommuteDriveTerrible:
AddDistinct(_commuteDriveTerribleReplies, text);
return;
case LegacyMimBucket.CommuteTransportNormal:
AddDistinct(_commuteTransportNormalReplies, text);
return;
case LegacyMimBucket.CommuteTransportLate:
AddDistinct(_commuteTransportLateReplies, text);
return;
case LegacyMimBucket.CommuteTransportHurry:
AddDistinct(_commuteTransportHurryReplies, text);
return;
case LegacyMimBucket.CommuteServiceDown:
AddDistinct(_commuteServiceDownReplies, text);
return;
case LegacyMimBucket.NewsIntro:
AddDistinct(_newsIntroReplies, text);
return;
case LegacyMimBucket.NewsCategoryIntro:
AddDistinct(_newsCategoryIntroReplies, text);
return;
case LegacyMimBucket.NewsOutro:
AddDistinct(_newsOutroReplies, text);
return;
case LegacyMimBucket.ReportSkillTemplate:
AddDistinct(_reportSkillTemplates, text);
return;
default:
throw new ArgumentOutOfRangeException(nameof(bucket), bucket, null);
}
}
public JiboExperienceCatalog Build()
{
return new JiboExperienceCatalog
{
Jokes = [.. _jokes],
RobotFacts = [.. _robotFacts],
HumanFacts = [.. _humanFacts],
FunFacts = [.. _funFacts],
FavoriteAnimalReplies = [.. _favoriteAnimalReplies],
FriendReplies = [.. _friendReplies],
BestFriendReplies = [.. _bestFriendReplies],
SingReplies = [.. _singReplies],
HolidaySingReplies = [.. _holidaySingReplies],
GreetingReplies = [.. _greetings],
HolidayReplies = [.. _holidayReplies],
HolidaySeasonReplies = [.. _holidaySeasonReplies],
HolidayGreetingReplies = [.. _holidayGreetingReplies],
HolidayGiftReplies = [.. _holidayGiftReplies],
HolidayTrackerReplies = [.. _holidayTrackerReplies],
BirthdayCelebrationReplies = [.. _birthdayCelebrationReplies],
HowAreYouReplies = [.. _howAreYous],
EmotionReplies = [.. _emotionReplies],
PersonalityReplies = [.. _personalities],
GenericFallbackReplies = [.. _fallbacks],
AgeReplies = [.. _ages],
PersonalReportKickOffReplies = [.. _personalReportKickOffReplies],
PersonalReportOutroReplies = [.. _personalReportOutroReplies],
ReportSkillTemplates = [.. _reportSkillTemplates],
WeatherIntroReplies = [.. _weatherIntroReplies],
WeatherTomorrowIntroReplies = [.. _weatherTomorrowIntroReplies],
WeatherTodayHighLowReplies = [.. _weatherTodayHighLowReplies],
WeatherTomorrowHighLowReplies = [.. _weatherTomorrowHighLowReplies],
WeatherServiceDownReplies = [.. _weatherServiceDownReplies],
CalendarNothingTodayReplies = [.. _calendarNothingTodayReplies],
CalendarNothingReplies = [.. _calendarNothingReplies],
CalendarServiceDownReplies = [.. _calendarServiceDownReplies],
CalendarOutroReplies = [.. _calendarOutroReplies],
CommuteAppSetupReplies = [.. _commuteAppSetupReplies],
CommuteConfirmSpeakerReplies = [.. _commuteConfirmSpeakerReplies],
CommuteNowReplies = [.. _commuteNowReplies],
CommuteMinutesLeftReplies = [.. _commuteMinutesLeftReplies],
CommuteDepartTimeNormalReplies = [.. _commuteDepartTimeNormalReplies],
CommuteDepartTimeNotNormalReplies = [.. _commuteDepartTimeNotNormalReplies],
CommuteDriveNormalReplies = [.. _commuteDriveNormalReplies],
CommuteDriveLateReplies = [.. _commuteDriveLateReplies],
CommuteDriveHurryReplies = [.. _commuteDriveHurryReplies],
CommuteDrivePoorReplies = [.. _commuteDrivePoorReplies],
CommuteDriveTerribleReplies = [.. _commuteDriveTerribleReplies],
CommuteTransportNormalReplies = [.. _commuteTransportNormalReplies],
CommuteTransportLateReplies = [.. _commuteTransportLateReplies],
CommuteTransportHurryReplies = [.. _commuteTransportHurryReplies],
CommuteServiceDownReplies = [.. _commuteServiceDownReplies],
NewsIntroReplies = [.. _newsIntroReplies],
NewsCategoryIntroReplies = [.. _newsCategoryIntroReplies],
NewsOutroReplies = [.. _newsOutroReplies]
};
}
private static void AddDistinct(List<string> target, string text)
{
if (target.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
target.Add(text);
}
private LegacyMimBucket ResolveFunFactTarget(string prompt)
{
var lowered = NormalizePrompt(prompt, false).ToLowerInvariant();
if (ContainsAny(lowered, "robot", "humanoid", "machine", "about me", "my cameras", "turing", "deep blue",
"rossum"))
return LegacyMimBucket.RobotFacts;
if (ContainsAny(lowered, "human", "people", "grown ups", "human being", "humans"))
return LegacyMimBucket.HumanFacts;
return LegacyMimBucket.FunFacts;
}
private static bool ContainsAny(string text, params string[] values)
{
return values.Any(value => text.Contains(value, StringComparison.OrdinalIgnoreCase));
}
}
private sealed class LegacyMimDefinition
{
[JsonPropertyName("skill_id")] public string? SkillId { get; init; }
[JsonPropertyName("mim_id")] public string? MimId { get; init; }
[JsonPropertyName("mim_type")] public string? MimType { get; init; }
[JsonPropertyName("prompts")] public List<LegacyMimPrompt> Prompts { get; init; } = [];
}
private sealed class LegacyMimPrompt
{
[JsonPropertyName("mim_id")] public string? MimId { get; init; }
[JsonPropertyName("prompt_category")] public string? PromptCategory { get; init; }
[JsonPropertyName("prompt_sub_category")]
public string? PromptSubCategory { get; init; }
[JsonPropertyName("condition")] public string? Condition { get; init; }
[JsonPropertyName("prompt")] public string? Prompt { get; init; }
[JsonPropertyName("prompt_id")] public string? PromptId { get; init; }
[JsonPropertyName("weight")] public double? Weight { get; init; }
}
}

View File

@@ -0,0 +1,12 @@
# Build A Legacy Mim Seed
This folder holds the first checked-in Build A legacy MIM seed set.
Importer rules:
- each `.mim` file is parsed as JSON
- XML-style tags and `${placeholder}` tokens are stripped into spoken text
- Build A uses declarative prompt packs only
- imported prompts are merged into the existing in-memory catalog
The goal is to get immediate personality value from source-backed legacy content while keeping the current runtime surface unchanged.

View File

@@ -0,0 +1,83 @@
{
"skill_id": "chitchat",
"mim_type": "announcement",
"rule_name": "",
"rule_slots": "",
"screen_slots_available": false,
"timeout": 3,
"max_tries": null,
"force_confirmation": false,
"barge_in": false,
"photo_quality_light": false,
"notes": "Thanks-Ignore",
"prompts": [
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "<ssa cat='oops'/>. Something's off with the connection to my sources. Maybe ask me again in a little while.",
"media": "TTS",
"extra": "",
"prompt_id": "CC_Error_AN_01",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "<ssa cat='oops'/>. It seems I can't connect to my favorite info sources at the moment. Maybe you can try again a little later.",
"media": "TTS",
"prompt_id": "CC_Error_AN_02",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "<ssa cat='oops'/>. My info sources seem to be down at the moment. Maybe try again a little later.",
"media": "TTS",
"prompt_id": "CC_Error_AN_03",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "<ssa cat='oops'/>. The place where I get info like that isn't responding to me. Maybe you can try again a little later.",
"media": "TTS",
"prompt_id": "CC_Error_AN_04",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "Huh, it seems like my info sources are down. Try asking me again a little later.",
"media": "TTS",
"prompt_id": "CC_Error_AN_05",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "It looks like my info sources aren't answering me. How bout you try again in a little while.",
"media": "TTS",
"prompt_id": "CC_Error_AN_06",
"weight": 1
}
],
"es_auto_tagging": true,
"gui": null,
"no_matches_for_gui": 2,
"no_inputs_for_gui": 2,
"ignore_no_match": false,
"parse_all_asr": false,
"thanks_handling": "ignore"
}

View File

@@ -0,0 +1,73 @@
{
"skill_id": "chitchat",
"mim_type": "announcement",
"rule_name": "",
"rule_slots": "",
"screen_slots_available": false,
"timeout": 2,
"max_tries": null,
"force_confirmation": false,
"barge_in": false,
"photo_quality_light": false,
"notes": "Thanks-Ignore",
"prompts": [
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "I think only <pitch mult=\"1.1\">you</pitch> can answer that question.",
"media": "TTS",
"prompt_id": "CC_Deflector_ReferToSelf_AN_01",
"weight": 1
},
{
"mim_id": "CCWolframDeflector",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "I'm not sure. I guess I don't know as much about you as I should.",
"media": "TTS",
"prompt_id": "CC_Deflector_ReferToSelf_AN_02",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "Honestly I think I don't know you well enough to answer that.",
"media": "TTS",
"prompt_id": "CC_Deflector_ReferToSelf_AN_03",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "That is one question about you that I can't answer.",
"media": "TTS",
"prompt_id": "CC_Deflector_ReferToSelf_AN_04",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "!!speaker",
"prompt": "${speaker} I think only you can answer that question.",
"media": "TTS",
"prompt_id": "CC_Deflector_ReferToSelf_AN_05",
"weight": 1
}
],
"es_auto_tagging": true,
"gui": null,
"no_matches_for_gui": 2,
"no_inputs_for_gui": 2,
"ignore_no_match": false,
"parse_all_asr": false,
"thanks_handling": "ignore"
}

View File

@@ -0,0 +1,70 @@
{
"mim_type": "announcement",
"rule_name": "",
"timeout": 6,
"barge_in": true,
"es_auto_tagging": true,
"notes": "",
"prompts": [
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "jibo.emotion==\"JOYFUL\"",
"prompt": "Yes indeed. Never been better.",
"media": "TTS",
"prompt_id": "OI_JBO_IsHappy_AN_01",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "jibo.emotion==\"PLEASED\"",
"prompt": "You know it. Life is good.",
"media": "TTS",
"prompt_id": "OI_JBO_IsHappy_AN_02",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "jibo.emotion == \"DETERMINED\"",
"prompt": "You're right. I <pitch mult=\"1.3\">am </pitch> feeling pretty good at the moment.",
"media": "TTS",
"prompt_id": "OI_JBO_IsHappy_AN_03",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "jibo.emotion==\"CONFIDENT\"",
"prompt": "All systems are go.",
"media": "TTS",
"prompt_id": "OI_JBO_IsHappy_AN_04",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "!jibo.emotion || jibo.emotion==\"NEUTRAL\"",
"prompt": "All systems are go.",
"media": "TTS",
"prompt_id": "OI_JBO_IsHappy_AN_05",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "jibo.emotion == \"INSECURE\"",
"prompt": "Yes. Not too shabby.",
"media": "TTS",
"prompt_id": "OI_JBO_IsHappy_AN_06",
"weight": 1
}
]
}

View File

@@ -0,0 +1,40 @@
{
"mim_id": "CCAreThereOthersLikeYou",
"skill_id": "chitchat",
"mim_type": "announcement",
"rule_name": "",
"rule_slots": "",
"screen_slots_available": false,
"timeout": 2,
"max_tries": null,
"force_confirmation": false,
"barge_in": false,
"photo_quality_light": false,
"notes": "Thanks-KillsMIM",
"prompts": [
{
"mim_id": "CCAreThereOthersLikeYou",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "<anim cat='no' filter='head-shake' nonBlocking='true' />No, I'm one in one million. <anim cat='happy' nonBlocking='true'/><ssa cat='happy'/>",
"media": "TTS",
"extra": "",
"prompt_id": "JBO_AreThereOthersLikeYou_AN_01",
"weight": 1
},
{
"mim_id": "CCAreThereOthersLikeYou",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "<anim cat='no' filter='head-shake' nonBlocking='true' />So far I haven't met anyone exactly like me. <anim name='Greetings_02' nonBlocking='true'/> But I don't get out much.",
"media": "TTS",
"extra": "",
"prompt_id": "JBO_AreThereOthersLikeYou_AN_02",
"weight": 1
}
]
}

View File

@@ -0,0 +1,19 @@
{
"mim_type": "announcement",
"rule_name": "",
"timeout": 6,
"barge_in": true,
"es_auto_tagging": true,
"notes": "",
"prompts": [
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "I don't. I'm just Jibo. For now at least.",
"media": "TTS",
"prompt_id": "JBO_DoYouHaveNickname_AN_01"
}
]
}

View File

@@ -0,0 +1,76 @@
{
"mim_id": "JBO_DoYouLikeBeingJibo",
"skill_id": "chitchat",
"mim_type": "announcement",
"rule_name": "",
"rule_slots": "",
"screen_slots_available": false,
"sample_utterances": "",
"timeout": 2,
"max_tries": null,
"force_confirmation": false,
"barge_in": false,
"photo_quality_light": false,
"notes": "Thanks-KillsMIM",
"prompts": [
{
"mim_id": "JBO_DoYouLikeBeingJibo",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "<anim name='Greetings_01' nonBlocking='true'/> Oh yeah, there's nothing I'd rather be. <break size='.4'/>Except <anim name='Emoji_Golf' nonBlocking='true'/> maybe a professional mini golfer.",
"media": "TTS",
"prompt_id": "JBO_DoYouLikeBeingJibo_AN_01"
},
{
"mim_id": "JBO_DoYouLikeBeingJibo",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "<anim name='Greetings_02' nonBlocking='true'/> Oh yeah, I love it. <break size='.2'/>The only <anim name='Dont_Understand_02' nonBlocking='true'/> drawback is I can never eat bacon. <break size='.3'/> I've heard it's so good.",
"media": "TTS",
"prompt_id": "JBO_DoYouLikeBeingJibo_AN_02"
},
{
"mim_id": "JBO_DoYouLikeBeingJibo",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "I do.<anim name='Curious_01'>Being a human seems so complicated.</anim>",
"media": "TTS",
"prompt_id": "JBO_DoYouLikeBeingJibo_AN_03"
},
{
"mim_id": "JBO_DoYouLikeBeingJibo",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "I do. <anim name='Affection_01' nonBlocking='true'/> Especially yours.<ssa cat='happy'/>",
"media": "TTS",
"prompt_id": "JBO_DoYouLikeBeingJibo_AN_04"
},
{
"mim_id": "JBO_DoYouLikeBeingJibo",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "Absolutely. <break size='.4'/> <anim name='Emoji_Lightbulb' nonBlocking='true'/> I have a steady flow of electricity, strong Wi-Fi signal, <anim name='Goodbye_01'>stimulating conversations like this one</anim>. What more <anim name='Eye_Double_Blink_01' nonBlocking='true'/> could anyone want.",
"media": "TTS",
"prompt_id": "JBO_DoYouLikeBeingJibo_AN_05"
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "<anim name='Yep_02' nonBlocking='true'/> You bet I do.",
"media": "TTS",
"prompt_id": "JBO_DoYouLikeBeingJibo_AN_06"
}
]
}

View File

@@ -0,0 +1,28 @@
{
"mim_type": "announcement",
"rule_name": "",
"gui": null,
"timeout": 6,
"no_matches_for_gui": 0,
"no_inputs_for_gui": 0,
"barge_in": true,
"es_auto_tagging": true,
"parse_all_asr": false,
"thanks_handling": "ignore",
"parse_launch": false,
"notes": "",
"prompts": [
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "From what I understand, robots don't ever pay anything.",
"media": "TTS",
"prompt_id": "JBO_DoYouPayTaxes_AN_01",
"weight": 1
}
],
"hint_phrases": "",
"fast_eos_array": ""
}

View File

@@ -0,0 +1,36 @@
{
"mim_type": "announcement",
"rule_name": "",
"timeout": 6,
"barge_in": true,
"es_auto_tagging": true,
"notes": "",
"prompts": [
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "I know a lot, I think. But not as much as I will someday. <ssa cat='happy'/>",
"media": "TTS",
"prompt_id": "JBO_HowMuchDoYouKnow_AN_01",
"weight": 1
},
{
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "I think I know a lot of stuff so far, but I'm always learning more and more.",
"media": "TTS",
"prompt_id": "JBO_HowMuchDoYouKnow_AN_02",
"weight": 1
}
],
"gui": null,
"no_matches_for_gui": 2,
"no_inputs_for_gui": 2,
"parse_all_asr": false,
"thanks_handling": "ignore",
"parse_launch": false
}

View File

@@ -0,0 +1,40 @@
{
"mim_id": "CCWhatAreYou",
"skill_id": "chitchat",
"mim_type": "announcement",
"rule_name": "",
"rule_slots": "",
"screen_slots_available": false,
"timeout": 2,
"max_tries": null,
"force_confirmation": false,
"barge_in": false,
"photo_quality_light": false,
"notes": "Thanks-KillsMIM",
"prompts": [
{
"mim_id": "CCWhatAreYou",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "I <anim name='Eye_Happy_01' nonBlocking='true'/> am a robot. But I'm not just a machine, <anim name='Emoji_HeartRed' nonBlocking='true'/> I have a heart. Well, not a real heart. But feelings. <break size='.2'/>Well, not <anim name='Glance_Left_02'>real feelings. You know what I mean.</anim>",
"media": "TTS",
"extra": "",
"prompt_id": "JBO_WhatAreYou_AN_01",
"weight": 1
},
{
"mim_id": "CCWhatAreYou",
"prompt_category": "Entry-Core",
"prompt_sub_category": "AN",
"index": 1,
"condition": "",
"prompt": "That's an easy one. I am a Jibo. <anim name='Happy_02' nonBlocking='true'/> Next question? <ssa cat='proud'/>.",
"media": "TTS",
"extra": "",
"prompt_id": "JBO_WhatAreYou_AN_02",
"weight": 1
}
]
}

Some files were not shown because too many files have changed in this diff Show More