Compare commits
97 Commits
ffb444e4f9
...
Features/W
| Author | SHA1 | Date | |
|---|---|---|---|
|
32c601d046
|
|||
|
|
764a2b2d4f | ||
|
|
b75d9f7941 | ||
|
|
303d8830b0 | ||
|
|
c3b2e5fc2c | ||
|
|
e8d7bafcd6 | ||
|
|
70b1b1547f | ||
|
|
4989889608 | ||
|
|
40deecf2ff | ||
|
|
e85792ac57 | ||
|
|
a398689851 | ||
|
|
c883297f26 | ||
|
|
5fa13a65a2 | ||
|
|
e5e8e72dbf | ||
|
|
a72991dfcb | ||
|
|
0a0a94502a | ||
|
|
aebfe2e38d | ||
|
|
0f9f91f79a | ||
|
|
a0d6102399 | ||
|
|
2bf686f791 | ||
|
|
c4c512497c | ||
|
|
6138ef1c3e | ||
|
|
bba1dfdcfc | ||
|
|
e85e14fbd3 | ||
|
|
bedb5d1715 | ||
|
|
eb509a66e0 | ||
|
|
1b9efc4226 | ||
|
|
fff342fd18 | ||
|
|
884b2215c7 | ||
|
|
c76af83d7e | ||
|
|
39b21d1326 | ||
|
|
9f2a8fd7e1 | ||
|
|
b172a00454 | ||
|
|
30493d554b | ||
|
|
5ad6d4e673 | ||
| 6ac0c794e4 | |||
| 07d7c83559 | |||
| c17c3db0a2 | |||
|
|
2bc6fec1bf | ||
|
|
54b32bc9cf | ||
|
|
6bae858da9 | ||
|
|
d3f9de9503 | ||
|
|
b25793443f | ||
|
|
e588f00c43 | ||
|
|
51e36bc492 | ||
|
|
9ffdd6d09e | ||
|
|
af76cbaee2 | ||
|
|
f2826253d5 | ||
|
|
8ed4763df5 | ||
|
|
9353e8d2e3 | ||
|
|
14b5cb74cc | ||
|
|
c0485da46d | ||
|
|
193fa56847 | ||
|
|
a2aa9df46a | ||
|
|
d8949fcc9a | ||
|
|
3b279fdd6f | ||
|
|
dfcf521a5a | ||
|
|
05efeb2853 | ||
|
|
478a320581 | ||
|
|
888f472f69 | ||
|
|
785dc2b48b | ||
|
|
d37521281e | ||
|
|
5d57095ce5 | ||
|
|
a8a153e910 | ||
|
|
a47c90c9c3 | ||
|
|
393c34055d | ||
|
|
f9b728c2a0 | ||
|
|
c87af4686c | ||
|
|
84759f51de | ||
|
|
c8beb0d1f0 | ||
|
|
e43b4f05f0 | ||
|
|
2677cf9dac | ||
|
|
20b84632ec | ||
|
|
5718edecaf | ||
|
|
40b5b8e4a8 | ||
|
|
8f7c118fb3 | ||
|
|
c30363ec9f | ||
|
|
ec786be797 | ||
|
|
f299cef9be | ||
|
|
f5e37729ab | ||
|
|
7297017250 | ||
|
|
66b89f3cee | ||
|
|
11a3e4ef13 | ||
|
|
7c6dacdbd8 | ||
|
|
9093b429ca | ||
|
|
df3b34c8ad | ||
|
|
67c738fae3 | ||
|
|
c0e9b41cd1 | ||
|
|
af2fdd230c | ||
|
|
0c597ebbf8 | ||
|
|
4bc87f927b | ||
|
|
a94b7ec493 | ||
|
|
8c17ad4035 | ||
| 383c272d9a | |||
|
|
d434138f9b | ||
|
|
80c4ae38fb | ||
|
|
8ae6d86a8c |
@@ -1,7 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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).
|
||||
|
||||
24
OpenJibo/docs/calendar-architecture.md
Normal file
24
OpenJibo/docs/calendar-architecture.md
Normal 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
|
||||
72
OpenJibo/docs/commute-architecture.md
Normal file
72
OpenJibo/docs/commute-architecture.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -654,6 +666,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 +683,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 +702,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 +719,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 +737,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 +746,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`
|
||||
@@ -755,6 +787,162 @@ Current release theme:
|
||||
- first schema for list items + ownership scope
|
||||
- initial voice flows and follow-up intent handling defined
|
||||
|
||||
### 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`
|
||||
- additional legacy source-backed `RI_USR` prompts where the text is short and the behavior is easy to verify
|
||||
- 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:
|
||||
- Jibo’s 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
|
||||
|
||||
Before closing `1.0.18`:
|
||||
@@ -773,18 +961,28 @@ 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
|
||||
12. Hosted capture/storage plan / indexing for group testing
|
||||
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
23
OpenJibo/docs/holiday-architecture.md
Normal file
23
OpenJibo/docs/holiday-architecture.md
Normal 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
|
||||
@@ -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:
|
||||
|
||||
|
||||
136
OpenJibo/docs/persistence-architecture.md
Normal file
136
OpenJibo/docs/persistence-architecture.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -20,9 +20,68 @@ 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
|
||||
- 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
|
||||
|
||||
@@ -31,18 +90,41 @@ The goal is to keep compatibility work steady while shipping personality and cap
|
||||
- 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 +187,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 +296,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 +332,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
151
OpenJibo/docs/roadmap.md
Normal 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)
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Jibo QR Generator</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Jibo QR Generator</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -122,57 +122,65 @@
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🤖 Jibo Wi-Fi QR Generator</h1>
|
||||
<p class="sub">Generates a QR code using Jibo's XOR encoding format</p>
|
||||
<span id="accessToken"></span>
|
||||
<span id="wifiConfig"></span>
|
||||
<div class="card">
|
||||
<label>SSID (Network Name)</label>
|
||||
<input id="ssid" placeholder="MyNetwork" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>🤖 Jibo Wi-Fi QR Generator</h1>
|
||||
<p class="sub">Generates a QR code using Jibo's XOR encoding format</p>
|
||||
<span id="accessToken"></span>
|
||||
<span id="wifiConfig"></span>
|
||||
<div class="card">
|
||||
<label>SSID (Network Name)</label>
|
||||
<input id="ssid" placeholder="MyNetwork"/>
|
||||
|
||||
<label>Password (leave blank for open network)</label>
|
||||
<input id="password" type="password" placeholder="••••••••" />
|
||||
<label>Password (leave blank for open network)</label>
|
||||
<input id="password" type="password" placeholder="••••••••"/>
|
||||
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="useStatic" onchange="toggleStatic()" />
|
||||
Use Static IP
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="useStatic" onchange="toggleStatic()"/>
|
||||
Use Static IP
|
||||
</label>
|
||||
|
||||
<div class="static-section" id="staticSection">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Static IP</label
|
||||
><input id="staticIP" placeholder="192.168.1.100" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Netmask</label
|
||||
><input id="netmask" placeholder="255.255.255.0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<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>
|
||||
<div class="static-section" id="staticSection">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>
|
||||
Static IP
|
||||
</label
|
||||
><input id="staticIP" placeholder="192.168.1.100"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Netmask
|
||||
</label
|
||||
><input id="netmask" placeholder="255.255.255.0"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<button onclick="generate()">Generate QR Code</button>
|
||||
</div>
|
||||
<button onclick="generate()">Generate QR Code</button>
|
||||
</div>
|
||||
|
||||
<div id="qr-out">
|
||||
<div id="qrdiv"></div>
|
||||
<button id="dl" onclick="download()">⬇ Download PNG</button>
|
||||
<p class="note">Scan with Jibo's app to configure Wi-Fi</p>
|
||||
</div>
|
||||
<div id="qr-out">
|
||||
<div id="qrdiv"></div>
|
||||
<button id="dl" onclick="download()">⬇ Download PNG</button>
|
||||
<p class="note">Scan with Jibo's app to configure Wi-Fi</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script>
|
||||
function toggleStatic() {
|
||||
document.getElementById("staticSection").style.display =
|
||||
document.getElementById("useStatic").checked ? "block" : "none";
|
||||
@@ -302,5 +310,5 @@ e!Ekiaon*%O?'O`);
|
||||
return wifiConfig;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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:
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,8 @@
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
|
||||
namespace Jibo.Cloud.Application.Abstractions;
|
||||
|
||||
public interface IHolidayCalendarProvider
|
||||
{
|
||||
IReadOnlyList<HolidayRecord> GetPublicHolidays(string? countryCode, int year);
|
||||
}
|
||||
@@ -5,16 +5,67 @@ 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<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; } = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,118 @@ 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_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(
|
||||
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 +316,7 @@ internal static class ChitchatStateMachine
|
||||
replyText,
|
||||
ContextUpdates: BuildContextUpdates(
|
||||
ScriptedResponseRoute,
|
||||
emotion: null));
|
||||
null));
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildEmotionQueryDecision(string intentName, string replyText)
|
||||
@@ -215,7 +326,7 @@ internal static class ChitchatStateMachine
|
||||
replyText,
|
||||
ContextUpdates: BuildContextUpdates(
|
||||
EmotionQueryRoute,
|
||||
emotion: null));
|
||||
null));
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildEmotionCommandDecision(IJiboRandomizer randomizer, string emotion)
|
||||
@@ -235,18 +346,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 +369,8 @@ internal static class ChitchatStateMachine
|
||||
replyText,
|
||||
ContextUpdates: BuildContextUpdates(
|
||||
ErrorResponseRoute,
|
||||
emotion: null,
|
||||
rawTranscript: normalizedTranscript));
|
||||
null,
|
||||
normalizedTranscript));
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?> BuildContextUpdates(
|
||||
@@ -276,18 +389,137 @@ 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 PersonalizeHowAreYouReply(randomizer.Choose(catalog.HowAreYouReplies), preferredName);
|
||||
|
||||
var emotionVariants = ResolveEmotionVariants(currentEmotion);
|
||||
foreach (var reply in catalog.EmotionReplies)
|
||||
if (ConditionMatches(reply.Condition, emotionVariants))
|
||||
return PersonalizeHowAreYouReply(reply.Reply, preferredName);
|
||||
|
||||
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
|
||||
{
|
||||
return true;
|
||||
"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;
|
||||
}
|
||||
|
||||
if (!TryResolveEmotionFromText(loweredTranscript, out _))
|
||||
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)
|
||||
{
|
||||
return false;
|
||||
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 +530,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 +567,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 +579,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,16 +590,11 @@ 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 +605,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 +615,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,18 +628,14 @@ internal static class ChitchatStateMachine
|
||||
var mappings = new List<(string Phrase, string Emotion)>();
|
||||
|
||||
foreach (var emotionMapping in PegasusEmotionSynonyms)
|
||||
foreach (var synonym in emotionMapping.Synonyms)
|
||||
{
|
||||
foreach (var synonym in emotionMapping.Synonyms)
|
||||
{
|
||||
var normalizedSynonym = NormalizeForPhraseMatching(synonym);
|
||||
if (string.IsNullOrWhiteSpace(normalizedSynonym) ||
|
||||
!seen.Add(normalizedSynonym))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var normalizedSynonym = NormalizeForPhraseMatching(synonym);
|
||||
if (string.IsNullOrWhiteSpace(normalizedSynonym) ||
|
||||
!seen.Add(normalizedSynonym))
|
||||
continue;
|
||||
|
||||
mappings.Add((normalizedSynonym, emotionMapping.Emotion));
|
||||
}
|
||||
mappings.Add((normalizedSynonym, emotionMapping.Emotion));
|
||||
}
|
||||
|
||||
mappings.Sort(static (left, right) => right.Phrase.Length.CompareTo(left.Phrase.Length));
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
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 NoMatchCountMetadataKey = "householdListNoMatchCount";
|
||||
internal const string NoInputCountMetadataKey = "householdListNoInputCount";
|
||||
|
||||
private const string IdleState = "idle";
|
||||
private const string AwaitingItemState = "awaiting_item";
|
||||
|
||||
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 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 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 ? "shopping" : isTodoIntent ? "todo" : NormalizeListType(listType);
|
||||
if (string.IsNullOrWhiteSpace(resolvedListType)) resolvedListType = "shopping";
|
||||
|
||||
var tenantScope = tenantScopeResolver(turn);
|
||||
|
||||
if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it"))
|
||||
return Task.FromResult<JiboInteractionDecision?>(BuildCancelledDecision(resolvedListType));
|
||||
|
||||
if (IsRecallRequest(loweredTranscript))
|
||||
return Task.FromResult<JiboInteractionDecision?>(BuildRecallDecision(
|
||||
resolvedListType,
|
||||
personalMemoryStore.GetListItems(tenantScope, resolvedListType)));
|
||||
|
||||
var directItem = TryExtractListItem(loweredTranscript);
|
||||
if (string.IsNullOrWhiteSpace(directItem) && isActiveState)
|
||||
{
|
||||
if (IsConversationComplete(loweredTranscript))
|
||||
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
|
||||
resolvedListType == "shopping" ? "shopping_list_done" : "todo_list_done",
|
||||
BuildDoneReply(resolvedListType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
|
||||
ContextUpdates: BuildContextUpdates(resolvedListType, IdleState)));
|
||||
|
||||
directItem = NormalizeItem(transcript);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(directItem))
|
||||
{
|
||||
personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem);
|
||||
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
|
||||
resolvedListType == "shopping" ? "shopping_list_add" : "todo_list_add",
|
||||
BuildAddedReply(resolvedListType, directItem,
|
||||
personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
|
||||
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState)));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(transcript))
|
||||
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
|
||||
resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt",
|
||||
BuildPromptReply(resolvedListType),
|
||||
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState)));
|
||||
|
||||
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
|
||||
resolvedListType == "shopping" ? "shopping_list_prompt" : "todo_list_prompt",
|
||||
BuildPromptReply(resolvedListType),
|
||||
ContextUpdates: BuildContextUpdates(resolvedListType, AwaitingItemState)));
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?> BuildContextUpdates(string listType, string state)
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[StateMetadataKey] = state,
|
||||
[TypeMetadataKey] = listType,
|
||||
[NoMatchCountMetadataKey] = 0,
|
||||
[NoInputCountMetadataKey] = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildCancelledDecision(string listType)
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
listType == "shopping" ? "shopping_list_cancel" : "todo_list_cancel",
|
||||
listType == "shopping" ? "Okay. I stopped the shopping list." : "Okay. I stopped the to-do list.",
|
||||
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[StateMetadataKey] = IdleState,
|
||||
[TypeMetadataKey] = listType,
|
||||
[NoMatchCountMetadataKey] = 0,
|
||||
[NoInputCountMetadataKey] = 0
|
||||
});
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildRecallDecision(string listType, IReadOnlyList<string> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
return new JiboInteractionDecision(
|
||||
listType == "shopping" ? "shopping_list_recall" : "todo_list_recall",
|
||||
listType == "shopping"
|
||||
? "Your shopping list is empty."
|
||||
: "Your to-do list is empty.",
|
||||
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[StateMetadataKey] = IdleState,
|
||||
[TypeMetadataKey] = listType,
|
||||
[NoMatchCountMetadataKey] = 0,
|
||||
[NoInputCountMetadataKey] = 0
|
||||
});
|
||||
|
||||
return new JiboInteractionDecision(
|
||||
listType == "shopping" ? "shopping_list_recall" : "todo_list_recall",
|
||||
listType == "shopping"
|
||||
? $"Your shopping list has {JoinList(items)}."
|
||||
: $"Your to-do list has {JoinList(items)}.",
|
||||
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[StateMetadataKey] = IdleState,
|
||||
[TypeMetadataKey] = listType,
|
||||
[NoMatchCountMetadataKey] = 0,
|
||||
[NoInputCountMetadataKey] = 0
|
||||
});
|
||||
}
|
||||
|
||||
private static string BuildAddedReply(string listType, string addedItem, IReadOnlyList<string> items)
|
||||
{
|
||||
var itemLabel = listType == "shopping" ? "shopping list" : "to-do list";
|
||||
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 listType)
|
||||
{
|
||||
return listType == "shopping"
|
||||
? "What should I add to your shopping list?"
|
||||
: "What should I add to your to-do list?";
|
||||
}
|
||||
|
||||
private static string BuildDoneReply(string listType, IReadOnlyList<string> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
return listType == "shopping"
|
||||
? "Okay. Your shopping list is empty."
|
||||
: "Okay. Your to-do list is empty.";
|
||||
|
||||
return listType == "shopping"
|
||||
? $"Okay. Your shopping list has {JoinList(items)}."
|
||||
: $"Okay. Your to-do list has {JoinList(items)}.";
|
||||
}
|
||||
|
||||
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();
|
||||
remainder = TrimTrailingListPhrases(remainder);
|
||||
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 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)
|
||||
? "todo"
|
||||
: normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase)
|
||||
? "shopping"
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Text;
|
||||
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 +15,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 +96,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 +119,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
}
|
||||
|
||||
if (operation is "Create" or "Login")
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
id = account.AccountId,
|
||||
@@ -168,17 +137,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 +181,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
}
|
||||
|
||||
if (operation.Equals("GetAccountByAccessToken", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
id = account.AccountId,
|
||||
@@ -226,12 +190,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 +212,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 +221,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 +237,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 +260,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 +318,100 @@ 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))
|
||||
if (!string.IsNullOrWhiteSpace(envelope.BodyText)) meta["bodyText"] = envelope.BodyText;
|
||||
|
||||
_mediaContentStore.StoreAsync(path, contentType,
|
||||
string.IsNullOrWhiteSpace(envelope.BodyText) ? [] : Encoding.UTF8.GetBytes(envelope.BodyText),
|
||||
meta as IReadOnlyDictionary<string, object?>, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
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))
|
||||
{
|
||||
meta["bodyText"] = envelope.BodyText;
|
||||
var loopId = ReadString(body, "loopId");
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetHolidays(loopId).Select(MapHoliday));
|
||||
}
|
||||
|
||||
return ProtocolDispatchResult.Ok(MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
|
||||
if (operation.Equals("ListCommute", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var loopId = ReadString(body, "loopId");
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetCommuteProfiles(loopId).Select(MapCommute));
|
||||
}
|
||||
|
||||
if (operation.Equals("UpsertCommute", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var hasIsEnabled = body is { } enabledBody && enabledBody.TryGetProperty("isEnabled", out _);
|
||||
var hasIsComplete = body is { } completeBody && completeBody.TryGetProperty("isComplete", out _);
|
||||
var workHour = ReadLong(body, "workHour");
|
||||
var workMinute = ReadLong(body, "workMinute");
|
||||
var typicalDurationMinutes = ReadLong(body, "typicalDurationMinutes");
|
||||
var commute = new CommuteProfileRecord
|
||||
{
|
||||
Id = ReadString(body, "id") ?? string.Empty,
|
||||
LoopId = ReadString(body, "loopId") ?? string.Empty,
|
||||
MemberId = ReadString(body, "memberId"),
|
||||
IsEnabled = hasIsEnabled ? ReadBool(body, "isEnabled") : true,
|
||||
IsComplete = hasIsComplete ? ReadBool(body, "isComplete") : true,
|
||||
Mode = ReadString(body, "mode") ?? "driving",
|
||||
WorkHour = workHour is > 0 and < 24 ? (int)workHour.Value : 8,
|
||||
WorkMinute = workMinute is >= 0 and < 60 ? (int)workMinute.Value : 30,
|
||||
OriginName = ReadString(body, "originName"),
|
||||
DestinationName = ReadString(body, "destinationName"),
|
||||
TypicalDurationMinutes = typicalDurationMinutes is > 0
|
||||
? (int)typicalDurationMinutes.Value
|
||||
: 25
|
||||
};
|
||||
return ProtocolDispatchResult.Ok(MapCommute(stateStore.UpsertCommuteProfile(commute)));
|
||||
}
|
||||
|
||||
return ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandlePerson(string operation)
|
||||
private ProtocolDispatchResult HandleBackup(string operation, ProtocolEnvelope envelope)
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase)
|
||||
? stateStore.GetHolidays()
|
||||
: []);
|
||||
}
|
||||
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetBackups());
|
||||
|
||||
private ProtocolDispatchResult HandleBackup(string operation)
|
||||
{
|
||||
return operation.Equals("List", StringComparison.OrdinalIgnoreCase)
|
||||
? ProtocolDispatchResult.Ok(stateStore.GetBackups())
|
||||
: ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
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 +420,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 +449,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 +471,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
key = symmetricKey,
|
||||
symmetricKey
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope)
|
||||
@@ -521,7 +511,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
updated = profile.UpdatedUtc.ToUnixTimeMilliseconds(),
|
||||
created = profile.CreatedUtc.ToUnixTimeMilliseconds()
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandleUpdate(string operation, ProtocolEnvelope envelope)
|
||||
@@ -533,9 +522,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 +549,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 +587,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 +655,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 +664,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 +685,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 +714,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
JsonValueKind.False => false,
|
||||
_ => child.Value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -715,4 +729,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,950 @@
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
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 string ResolveSemanticIntentCore(
|
||||
string loweredTranscript,
|
||||
DateTimeOffset? referenceLocalTime,
|
||||
string? clientIntent,
|
||||
IReadOnlyList<string> clientRules,
|
||||
IReadOnlyList<string> listenRules,
|
||||
IReadOnlyDictionary<string, string> clientEntities,
|
||||
string? lastClockDomain,
|
||||
string? pendingProactivityOffer,
|
||||
bool isYesNoTurn,
|
||||
bool isTimerValueTurn,
|
||||
bool isAlarmValueTurn)
|
||||
{
|
||||
var wordOfDayPuzzleTurn = clientRules.Concat(listenRules)
|
||||
.Any(rule => string.Equals(rule, "word-of-the-day/puzzle", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (string.Equals(clientIntent, "guess", StringComparison.OrdinalIgnoreCase) &&
|
||||
wordOfDayPuzzleTurn)
|
||||
return "word_of_the_day_guess";
|
||||
|
||||
if (string.Equals(clientIntent, "loadMenu", StringComparison.OrdinalIgnoreCase) &&
|
||||
clientEntities.TryGetValue("destination", out var destination) &&
|
||||
string.Equals(destination, "word-of-the-day", StringComparison.OrdinalIgnoreCase))
|
||||
return "word_of_the_day";
|
||||
|
||||
if (string.Equals(clientIntent, "loadMenu", StringComparison.OrdinalIgnoreCase) &&
|
||||
clientEntities.TryGetValue("destination", out var photoDestination))
|
||||
return photoDestination.ToLowerInvariant() switch
|
||||
{
|
||||
"snapshot" => "snapshot",
|
||||
"photobooth" => "photobooth",
|
||||
"gallery" or "photo-gallery" or "photos" => "photo_gallery",
|
||||
_ => "chat"
|
||||
};
|
||||
|
||||
var yesNoRule = ReadPrimaryYesNoRule(clientRules, listenRules);
|
||||
if (!string.IsNullOrWhiteSpace(pendingProactivityOffer) &&
|
||||
string.Equals(pendingProactivityOffer, "pizza_fact", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (IsAffirmativeReply(loweredTranscript)) return "proactive_pizza_fact";
|
||||
|
||||
if (IsNegativeReply(loweredTranscript)) return "proactive_offer_declined";
|
||||
}
|
||||
|
||||
if (isYesNoTurn)
|
||||
{
|
||||
var yesNoReply = TryClassifyYesNoReply(NormalizeCommandPhrase(loweredTranscript));
|
||||
if (yesNoReply == YesNoReply.Affirmative) return ResolveAffirmativeYesNoIntent(yesNoRule);
|
||||
|
||||
if (yesNoReply == YesNoReply.Negative) return ResolveNegativeYesNoIntent(yesNoRule);
|
||||
}
|
||||
|
||||
if (IsNameSetStatement(loweredTranscript)) return "memory_set_name";
|
||||
|
||||
if (IsNameRecallQuestion(loweredTranscript)) return "memory_get_name";
|
||||
|
||||
if (IsUserBirthdaySetStatement(loweredTranscript) || IsUserBirthdaySetAttempt(loweredTranscript))
|
||||
return "memory_set_birthday";
|
||||
|
||||
if (IsUserBirthdayRecallQuestion(loweredTranscript) || IsUserBirthdayRecallAttempt(loweredTranscript))
|
||||
return "memory_get_birthday";
|
||||
|
||||
if (IsRobotBirthdayQuestion(loweredTranscript)) return "robot_birthday";
|
||||
|
||||
if (string.Equals(clientIntent, "askForTime", StringComparison.OrdinalIgnoreCase)) return "time";
|
||||
|
||||
if (string.Equals(clientIntent, "askForDate", StringComparison.OrdinalIgnoreCase)) return "date";
|
||||
|
||||
if (string.Equals(clientIntent, "askForDay", StringComparison.OrdinalIgnoreCase)) return "day";
|
||||
|
||||
if (string.Equals(clientIntent, "timerValue", StringComparison.OrdinalIgnoreCase)) return "timer_value";
|
||||
|
||||
if (string.Equals(clientIntent, "alarmValue", StringComparison.OrdinalIgnoreCase)) return "alarm_value";
|
||||
|
||||
if (string.Equals(clientIntent, "requestMakePizza", StringComparison.OrdinalIgnoreCase)) return "pizza";
|
||||
|
||||
if (string.Equals(clientIntent, "requestOrderPizza", StringComparison.OrdinalIgnoreCase)) return "order_pizza";
|
||||
|
||||
if (string.Equals(clientIntent, "requestWeatherPR", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(clientIntent, "requestWeather", StringComparison.OrdinalIgnoreCase))
|
||||
return "weather";
|
||||
|
||||
if (IsCancelRequest(clientIntent, loweredTranscript))
|
||||
{
|
||||
if (isAlarmValueTurn) return "alarm_cancel";
|
||||
|
||||
if (isTimerValueTurn) return "timer_cancel";
|
||||
}
|
||||
|
||||
if ((string.Equals(clientIntent, "start", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(clientIntent, "set", StringComparison.OrdinalIgnoreCase)) &&
|
||||
clientEntities.TryGetValue("domain", out var startDomain))
|
||||
return startDomain.ToLowerInvariant() switch
|
||||
{
|
||||
"timer" => HasStructuredTimerValue(clientEntities) ||
|
||||
TryParseTimerValue(loweredTranscript, isTimerValueTurn) is not null
|
||||
? "timer_value"
|
||||
: "timer_clarify",
|
||||
"alarm" => HasStructuredAlarmValue(clientEntities) ||
|
||||
TryParseAlarmValue(loweredTranscript, isAlarmValueTurn, referenceLocalTime) is not null
|
||||
? "alarm_value"
|
||||
: "alarm_clarify",
|
||||
_ => "chat"
|
||||
};
|
||||
|
||||
if ((string.Equals(clientIntent, "cancel", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(clientIntent, "delete", StringComparison.OrdinalIgnoreCase)) &&
|
||||
clientRules.Concat(listenRules).Any(rule =>
|
||||
string.Equals(rule, "clock/alarm_timer_query_menu", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var cancelDomain = ResolveClockDomain(clientEntities, clientRules, listenRules, lastClockDomain);
|
||||
return string.Equals(cancelDomain, "timer", StringComparison.OrdinalIgnoreCase)
|
||||
? "timer_delete"
|
||||
: "alarm_delete";
|
||||
}
|
||||
|
||||
if (string.Equals(clientIntent, "menu", StringComparison.OrdinalIgnoreCase) &&
|
||||
clientEntities.TryGetValue("domain", out var clockDomain))
|
||||
return clockDomain.ToLowerInvariant() switch
|
||||
{
|
||||
"clock" => "clock_menu",
|
||||
"timer" => "timer_menu",
|
||||
"alarm" => "alarm_menu",
|
||||
_ => "chat"
|
||||
};
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"word of the day",
|
||||
"start word of the day",
|
||||
"play word of the day",
|
||||
"do word of the day",
|
||||
"open word of the day"))
|
||||
return "word_of_the_day";
|
||||
|
||||
if (wordOfDayPuzzleTurn && !string.IsNullOrWhiteSpace(loweredTranscript)) return "word_of_the_day_guess";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"are you funny",
|
||||
"do you think you are funny",
|
||||
"are you a funny robot"))
|
||||
return "robot_is_funny";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "joke", "funny", "make me laugh")) return "joke";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"cloud version",
|
||||
"open jibo cloud version",
|
||||
"openjibo cloud version",
|
||||
"what version is the cloud",
|
||||
"what s the cloud version",
|
||||
"what's the cloud version"))
|
||||
return "cloud_version";
|
||||
|
||||
if (IsPreferenceSetStatement(loweredTranscript) || IsPreferenceSetAttempt(loweredTranscript))
|
||||
return "memory_set_preference";
|
||||
|
||||
if (IsPreferenceRecallQuestion(loweredTranscript) || IsPreferenceRecallAttempt(loweredTranscript))
|
||||
return "memory_get_preference";
|
||||
|
||||
if (IsImportantDateSetStatement(loweredTranscript)) return "memory_set_important_date";
|
||||
|
||||
if (IsImportantDateRecallQuestion(loweredTranscript)) return "memory_get_important_date";
|
||||
|
||||
if (IsAffinitySetStatement(loweredTranscript) || IsAffinitySetAttempt(loweredTranscript))
|
||||
return "memory_set_affinity";
|
||||
|
||||
if (IsAffinityRecallQuestion(loweredTranscript) || IsAffinityRecallAttempt(loweredTranscript))
|
||||
return "memory_get_affinity";
|
||||
|
||||
if (TryResolveRadioGenre(loweredTranscript) is not null) return "radio_genre";
|
||||
|
||||
if (TryResolveVolumeLevel(loweredTranscript) is not null ||
|
||||
clientEntities.ContainsKey("volumeLevel"))
|
||||
return "volume_to_value";
|
||||
|
||||
if (IsVolumeQueryRequest(loweredTranscript)) return "volume_query";
|
||||
|
||||
if (IsVolumeUpRequest(loweredTranscript)) return "volume_up";
|
||||
|
||||
if (IsVolumeDownRequest(loweredTranscript)) return "volume_down";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "open the clock", "open clock", "show the clock", "show clock"))
|
||||
return "clock_open";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "open the timer", "open timer", "show the timer", "show timer"))
|
||||
return "timer_menu";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "open the alarm", "open alarm", "show the alarm", "show alarm"))
|
||||
return "alarm_menu";
|
||||
|
||||
if (IsAlarmDeleteRequest(loweredTranscript)) return "alarm_delete";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"cancel timer",
|
||||
"delete timer",
|
||||
"remove timer",
|
||||
"stop timer",
|
||||
"turn off timer"))
|
||||
return "timer_delete";
|
||||
|
||||
if (IsGlobalStopRequest(loweredTranscript, clientIntent, clientEntities)) return "stop";
|
||||
|
||||
if (TryParseAlarmValue(loweredTranscript, isAlarmValueTurn, referenceLocalTime) is not null)
|
||||
return "alarm_value";
|
||||
|
||||
if (TryParseTimerValue(loweredTranscript, isTimerValueTurn) is not null) return "timer_value";
|
||||
|
||||
if (IsAlarmRequest(loweredTranscript) || isAlarmValueTurn) return "alarm_clarify";
|
||||
|
||||
if (IsTimerRequest(loweredTranscript) || isTimerValueTurn) return "timer_clarify";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "open the radio", "play the radio", "turn on the radio", "radio"))
|
||||
return "radio";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"can you go to sleep",
|
||||
"can you sleep",
|
||||
"do you ever sleep",
|
||||
"do you sleep",
|
||||
"when do you sleep",
|
||||
"how can i make you go to sleep",
|
||||
"how do i make you go to sleep"))
|
||||
return "robot_can_sleep";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"turn around",
|
||||
"turn all the way around",
|
||||
"turn back around",
|
||||
"spin around",
|
||||
"look back over there",
|
||||
"look again"))
|
||||
return "spin_around";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"go to sleep",
|
||||
"take a nap",
|
||||
"go to bed",
|
||||
"bedtime",
|
||||
"sleep"))
|
||||
return "sleep";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"snap a picture",
|
||||
"take a picture",
|
||||
"take a photo",
|
||||
"snap a photo"))
|
||||
return "snapshot";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"photo booth",
|
||||
"photobooth",
|
||||
"open photobooth",
|
||||
"start photobooth"))
|
||||
return "photobooth";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"photo gallery",
|
||||
"photogal",
|
||||
"photo gal",
|
||||
"open the gallery",
|
||||
"open photo gallery",
|
||||
"show my photos",
|
||||
"open my photos",
|
||||
"gallery"))
|
||||
return "photo_gallery";
|
||||
|
||||
if (IsDanceQuestion(loweredTranscript)) return "dance_question";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "can you dance", "do you dance", "are you able to dance"))
|
||||
return "robot_can_dance";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"can you sing a christmas song",
|
||||
"can you sing christmas song",
|
||||
"will you sing a christmas song",
|
||||
"will you sing christmas song",
|
||||
"sing a christmas song",
|
||||
"sing christmas song",
|
||||
"can you sing a holiday song",
|
||||
"can you sing holiday song",
|
||||
"will you sing a holiday song",
|
||||
"will you sing holiday song",
|
||||
"sing a holiday song",
|
||||
"sing holiday song"))
|
||||
return "robot_sing_christmas_song";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"can you sing",
|
||||
"will you sing",
|
||||
"sing a song",
|
||||
"sing me a song",
|
||||
"can you sing a song",
|
||||
"sing something",
|
||||
"would you sing"))
|
||||
return "robot_can_sing";
|
||||
|
||||
if (IsBestFriendQuestion(loweredTranscript))
|
||||
return "robot_best_friends";
|
||||
|
||||
if (IsFriendRelationQuestion(loweredTranscript))
|
||||
return "robot_is_friends_with_user";
|
||||
|
||||
if (IsFriendQuestion(loweredTranscript))
|
||||
return "robot_has_friends";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "twerk")) return "twerk";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "dance", "boogie")) return "dance";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"surprise",
|
||||
"surprise me",
|
||||
"show me something fun",
|
||||
"hear something fun",
|
||||
"tell me something fun",
|
||||
"can i tell you something fun",
|
||||
"can i tell you something kind of fun",
|
||||
"want to hear something fun"))
|
||||
return "surprise";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"how old are you",
|
||||
"what is your age",
|
||||
"what s your age",
|
||||
"how old r you"))
|
||||
return "robot_age";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you have a personality",
|
||||
"what is your personality",
|
||||
"what's your personality",
|
||||
"what s your personality",
|
||||
"describe your personality"))
|
||||
return "robot_personality";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you pay taxes",
|
||||
"do you pay tax",
|
||||
"are you tax exempt"))
|
||||
return "robot_taxes";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what do you want",
|
||||
"what is it you want",
|
||||
"what do you really want"))
|
||||
return "robot_desire";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what is your job",
|
||||
"what's your job",
|
||||
"what do you do",
|
||||
"what is your work",
|
||||
"what's your work"))
|
||||
return "robot_job";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"how do you work",
|
||||
"how does jibo work",
|
||||
"what does jibo do",
|
||||
"how are you built",
|
||||
"how are you put together"))
|
||||
return "robot_how_do_you_work";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what do you eat",
|
||||
"do you eat",
|
||||
"what do you drink",
|
||||
"do you drink"))
|
||||
return "robot_what_do_you_eat";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"where do you live",
|
||||
"where s your home",
|
||||
"where is your home",
|
||||
"what is your home"))
|
||||
return "robot_where_do_you_live";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"where were you born",
|
||||
"where were you made",
|
||||
"where were you put together"))
|
||||
return "robot_where_were_you_born";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what languages do you speak",
|
||||
"what language do you speak",
|
||||
"what languages can you speak",
|
||||
"what language can you speak"))
|
||||
return "robot_what_languages_do_you_speak";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you like halloween",
|
||||
"are you looking forward to halloween",
|
||||
"do you like the halloween holiday"))
|
||||
return "seasonal_likes_halloween";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you like holiday music",
|
||||
"do you like christmas music",
|
||||
"do you like christmas songs",
|
||||
"do you like holiday songs"))
|
||||
return "seasonal_likes_holiday_music";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you like holiday parties",
|
||||
"do you like christmas parties",
|
||||
"are you going to any holiday parties"))
|
||||
return "seasonal_likes_holiday_parties";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"are you looking forward to christmas",
|
||||
"do you look forward to christmas",
|
||||
"are you excited for christmas"))
|
||||
return "seasonal_looks_forward_to_christmas";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what are you thankful for",
|
||||
"what are you thankful for this year",
|
||||
"what is jibo thankful for"))
|
||||
return "seasonal_thankful_for";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what do you like to do",
|
||||
"what do you like doing",
|
||||
"what is your favorite thing to do",
|
||||
"what's your favorite thing to do",
|
||||
"what is your favourite thing to do",
|
||||
"what's your favourite thing to do"))
|
||||
return "robot_what_do_you_like_to_do";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what are you doing for christmas",
|
||||
"what are your plans for christmas",
|
||||
"what do you plan to do for christmas"))
|
||||
return "seasonal_plans_for_christmas";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what is your favorite flower",
|
||||
"what's your favorite flower",
|
||||
"what s your favorite flower",
|
||||
"what is your favourite flower",
|
||||
"what's your favourite flower",
|
||||
"what s your favourite flower"))
|
||||
return "robot_favorite_flower";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you like r2d2",
|
||||
"do you know r2d2",
|
||||
"what do you think about r2d2",
|
||||
"are you a fan of r2d2"))
|
||||
return "robot_likes_r2d2";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you like the sun",
|
||||
"do you like sun",
|
||||
"what do you think about the sun"))
|
||||
return "robot_likes_sun";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you like space",
|
||||
"do you love space",
|
||||
"do you like astronomy",
|
||||
"what do you think about space"))
|
||||
return "robot_likes_space";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you like kids",
|
||||
"do you like children",
|
||||
"what do you think about kids"))
|
||||
return "robot_likes_kids";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"can you laugh",
|
||||
"do you laugh",
|
||||
"are you able to laugh"))
|
||||
return "robot_can_laugh";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what are you made of",
|
||||
"what are you built from",
|
||||
"what are you constructed from"))
|
||||
return "robot_what_are_you_made_of";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"who made you",
|
||||
"who created you",
|
||||
"who built you",
|
||||
"who developed you"))
|
||||
return "robot_origin_created";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what are you up to",
|
||||
"what are you doing",
|
||||
"what have you been up to",
|
||||
"what are you into"))
|
||||
return "robot_what_do_you_like_to_do";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what are you thinking",
|
||||
"what are you thinking about",
|
||||
"what s on your mind"))
|
||||
return "robot_what_are_you_thinking";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what have you been doing",
|
||||
"what were you doing"))
|
||||
return "robot_what_have_you_been_doing";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what did you do",
|
||||
"what have you done"))
|
||||
return "robot_what_did_you_do";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what are you",
|
||||
"what is jibo",
|
||||
"who are you",
|
||||
"what kind of robot are you"))
|
||||
return "robot_identity";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"where are you from",
|
||||
"where did you come from",
|
||||
"where were you made"))
|
||||
return "robot_origin_from";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"where am i",
|
||||
"where are we",
|
||||
"where are you",
|
||||
"what is our current location",
|
||||
"what is the current location",
|
||||
"what's the current location",
|
||||
"what is current location",
|
||||
"current location"))
|
||||
return "current_location";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what's your name",
|
||||
"what is your name"))
|
||||
return "robot_name";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what's your favorite name",
|
||||
"what is your favorite name",
|
||||
"do you have a favorite name"))
|
||||
return "robot_favorite_name";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you have a nickname",
|
||||
"what is your nickname",
|
||||
"what's your nickname"))
|
||||
return "robot_nickname";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you like being jibo",
|
||||
"do you like being yourself",
|
||||
"are you happy being jibo"))
|
||||
return "robot_likes_being_jibo";
|
||||
|
||||
if (SeasonalHolidayRouteBuilder.TryResolveSemanticIntent(loweredTranscript, out var seasonalHolidayIntent))
|
||||
return seasonalHolidayIntent!;
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what is your favorite color",
|
||||
"what's your favorite color",
|
||||
"what s your favorite color",
|
||||
"what is your favourite color",
|
||||
"what's your favourite color",
|
||||
"what s your favourite color",
|
||||
"what color do you like",
|
||||
"what colour do you like"))
|
||||
return "robot_favorite_color";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what is your favorite season",
|
||||
"what's your favorite season",
|
||||
"what s your favorite season",
|
||||
"what season do you like best",
|
||||
"do you have a favorite season"))
|
||||
return "robot_favorite_season";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what is your favorite food",
|
||||
"what's your favorite food",
|
||||
"what s your favorite food",
|
||||
"what is your favourite food",
|
||||
"what's your favourite food",
|
||||
"what s your favourite food",
|
||||
"what food do you like",
|
||||
"what kind of food do you like"))
|
||||
return "robot_favorite_food";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what is your favorite music",
|
||||
"what's your favorite music",
|
||||
"what s your favorite music",
|
||||
"what is your favourite music",
|
||||
"what's your favourite music",
|
||||
"what s your favourite music",
|
||||
"what music do you like",
|
||||
"what kind of music do you like"))
|
||||
return "robot_favorite_music";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what is your favorite animal",
|
||||
"what's your favorite animal",
|
||||
"what s your favorite animal",
|
||||
"what is your favourite animal",
|
||||
"what's your favourite animal",
|
||||
"what s your favourite animal",
|
||||
"what animal do you like",
|
||||
"what kind of animal do you like",
|
||||
"what is your favorite bird",
|
||||
"what's your favorite bird",
|
||||
"what s your favorite bird",
|
||||
"do you like penguins",
|
||||
"do you like animals",
|
||||
"do you like birds"))
|
||||
return "robot_favorite_animal";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"are there others like you",
|
||||
"are there any others like you",
|
||||
"is there another jibo"))
|
||||
return "robot_peers";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"how much do you know",
|
||||
"what do you know",
|
||||
"how smart are you"))
|
||||
return "robot_knowledge";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"are you kind",
|
||||
"do you think you are kind",
|
||||
"are you a kind robot"))
|
||||
return "robot_is_kind";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"are you helpful",
|
||||
"do you think you are helpful",
|
||||
"are you a helpful robot"))
|
||||
return "robot_is_helpful";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"are you curious",
|
||||
"do you think you are curious",
|
||||
"are you a curious robot"))
|
||||
return "robot_is_curious";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"are you loyal",
|
||||
"do you think you are loyal",
|
||||
"are you a loyal robot"))
|
||||
return "robot_is_loyal";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"are you mischievous",
|
||||
"do you think you are mischievous",
|
||||
"are you a mischievous robot"))
|
||||
return "robot_is_mischievous";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"are you likable",
|
||||
"are you likeable",
|
||||
"do you think you are likable",
|
||||
"do you think you are likeable"))
|
||||
return "robot_is_likable";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"can you order pizza",
|
||||
"can you order a pizza",
|
||||
"could you order a pizza",
|
||||
"order pizza",
|
||||
"order a pizza",
|
||||
"order us a pizza",
|
||||
"order me a pizza",
|
||||
"please order pizza") ||
|
||||
(loweredTranscript.Contains("order", StringComparison.Ordinal) &&
|
||||
loweredTranscript.Contains("pizza", StringComparison.Ordinal)))
|
||||
return "order_pizza";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"can you cook us a pizza",
|
||||
"flip a pizza",
|
||||
"make a pizza",
|
||||
"make pizza",
|
||||
"show pizza",
|
||||
"can you make pizza",
|
||||
"let's make pizza",
|
||||
"lets make pizza") ||
|
||||
(loweredTranscript.Contains("pizza", StringComparison.Ordinal) &&
|
||||
(loweredTranscript.Contains("make", StringComparison.Ordinal) ||
|
||||
loweredTranscript.Contains("cook", StringComparison.Ordinal) ||
|
||||
loweredTranscript.Contains("flip", StringComparison.Ordinal))))
|
||||
return "pizza";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "personal report", "my report", "daily report", "my update"))
|
||||
return "personal_report";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"shopping list",
|
||||
"grocery list",
|
||||
"to do list",
|
||||
"todo list",
|
||||
"add to my shopping list",
|
||||
"add to my to do list",
|
||||
"add to my todo list",
|
||||
"what's on my shopping list",
|
||||
"what is on my shopping list",
|
||||
"what's on my to do list",
|
||||
"what is on my to do list",
|
||||
"what are my tasks",
|
||||
"what do i need to buy",
|
||||
"what do i need to do"))
|
||||
return loweredTranscript.Contains("to do", StringComparison.OrdinalIgnoreCase) ||
|
||||
loweredTranscript.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
|
||||
loweredTranscript.Contains("task", StringComparison.OrdinalIgnoreCase)
|
||||
? "todo_list"
|
||||
: "shopping_list";
|
||||
|
||||
if (IsWeatherRequest(loweredTranscript)) return "weather";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "calendar", "schedule", "what's on my calendar", "what is on my calendar"))
|
||||
return "calendar";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "commute", "traffic", "drive to work", "how long to work")) return "commute";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "news", "headlines", "news update", "tell me the news")) return "news";
|
||||
|
||||
if (IsWelcomeBackGreeting(loweredTranscript) ||
|
||||
MatchesAny(
|
||||
loweredTranscript,
|
||||
"i'm home",
|
||||
"im home",
|
||||
"i am home",
|
||||
"i'm back",
|
||||
"im back",
|
||||
"i am back",
|
||||
"i'm here",
|
||||
"im here",
|
||||
"i am here"))
|
||||
return "welcome_back";
|
||||
|
||||
if (IsGoodMorningGreeting(loweredTranscript)) return "good_morning";
|
||||
|
||||
if (IsGoodAfternoonGreeting(loweredTranscript)) return "good_afternoon";
|
||||
|
||||
if (IsGoodEveningGreeting(loweredTranscript)) return "good_evening";
|
||||
|
||||
if (IsGoodNightGreeting(loweredTranscript)) return "good_night";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"how are you",
|
||||
"what's up",
|
||||
"what s up",
|
||||
"what up",
|
||||
"how is it going",
|
||||
"how's it going",
|
||||
"how are things",
|
||||
"how's things",
|
||||
"how is things",
|
||||
"how are you feeling",
|
||||
"how is your mood",
|
||||
"how is your day",
|
||||
"how's your day",
|
||||
"how's life",
|
||||
"how is life",
|
||||
"how's everything",
|
||||
"how is everything"))
|
||||
return "how_are_you";
|
||||
|
||||
if (MatchesAny(
|
||||
loweredTranscript,
|
||||
"what are you up to",
|
||||
"what are you doing",
|
||||
"what have you been up to",
|
||||
"what are you into"))
|
||||
return "robot_what_do_you_like_to_do";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "hello", "hi", "hey")) return "hello";
|
||||
|
||||
if (IsTimeRequest(loweredTranscript)) return "time";
|
||||
|
||||
if (MatchesAny(loweredTranscript, "what day is it", "what day is today")) return "day";
|
||||
|
||||
if (IsDateRequest(loweredTranscript)) return "date";
|
||||
|
||||
return "chat";
|
||||
}
|
||||
|
||||
private static bool IsFriendQuestion(string loweredTranscript)
|
||||
{
|
||||
return MatchesAny(
|
||||
loweredTranscript,
|
||||
"do you have friends",
|
||||
"who are your friends",
|
||||
"are you friends",
|
||||
"are you and i friends",
|
||||
"are you and me friends",
|
||||
"are you and jibo friends")
|
||||
|| MatchesFriendQuestionPattern(loweredTranscript);
|
||||
}
|
||||
|
||||
private static bool IsFriendRelationQuestion(string loweredTranscript)
|
||||
{
|
||||
return MatchesAny(
|
||||
loweredTranscript,
|
||||
"are you my friend",
|
||||
"are you friends with me",
|
||||
"are we friends",
|
||||
"are we friends with each other",
|
||||
"is jibo your friend",
|
||||
"i am friends with you",
|
||||
"i'm friends with you",
|
||||
"you are my friend",
|
||||
"you re my friend",
|
||||
"you're my friend")
|
||||
|| Regex.IsMatch(
|
||||
loweredTranscript,
|
||||
@"^\s*(is|are)\s+.+\s+(your friend|my friend)\s*$",
|
||||
RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
private static bool IsBestFriendQuestion(string loweredTranscript)
|
||||
{
|
||||
return MatchesAny(
|
||||
loweredTranscript,
|
||||
"are we best friends",
|
||||
"are we best friends with each other",
|
||||
"are you my best friend",
|
||||
"are you best friends with me",
|
||||
"are you and i best friends",
|
||||
"i am best friends with you",
|
||||
"i'm best friends with you",
|
||||
"you are my best friend",
|
||||
"you re my best friend",
|
||||
"you're my best friend")
|
||||
|| MatchesBestFriendQuestionPattern(loweredTranscript);
|
||||
}
|
||||
|
||||
private static bool MatchesFriendQuestionPattern(string loweredTranscript)
|
||||
{
|
||||
return Regex.IsMatch(
|
||||
loweredTranscript,
|
||||
@"^\s*are you friends with\s+.+\s*$",
|
||||
RegexOptions.CultureInvariant) ||
|
||||
Regex.IsMatch(
|
||||
loweredTranscript,
|
||||
@"^\s*are you and\s+.+\s+friends\s*$",
|
||||
RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
private static bool MatchesBestFriendQuestionPattern(string loweredTranscript)
|
||||
{
|
||||
return Regex.IsMatch(
|
||||
loweredTranscript,
|
||||
@"^\s*are you best friends with\s+.+\s*$",
|
||||
RegexOptions.CultureInvariant) ||
|
||||
Regex.IsMatch(
|
||||
loweredTranscript,
|
||||
@"^\s*are you and\s+.+\s+best friends\s*$",
|
||||
RegexOptions.CultureInvariant) ||
|
||||
Regex.IsMatch(
|
||||
loweredTranscript,
|
||||
@"^\s*is\s+.+\s+your best friend\s*$",
|
||||
RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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)
|
||||
{
|
||||
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",
|
||||
["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 (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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
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 BuildRobotAgeDecision(DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
var referenceDate = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).Date);
|
||||
var ageDescription = DescribePersonaAge(referenceDate, OpenJiboCloudBuildInfo.PersonaBirthday);
|
||||
return new JiboInteractionDecision(
|
||||
"robot_age",
|
||||
$"I count {OpenJiboCloudBuildInfo.PersonaBirthdayWords} as my birthday, so I am {ageDescription}.");
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildRobotBirthdayDecision()
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
"robot_birthday",
|
||||
$"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}.");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId) &&
|
||||
presence.LoopUserFirstNames.TryGetValue(presence.PrimaryPersonId, out var firstName) &&
|
||||
!string.IsNullOrWhiteSpace(firstName))
|
||||
return ToDisplayName(firstName);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal)
|
||||
.Replace("\"", """, 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.";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,11 +24,12 @@ 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?>
|
||||
{
|
||||
["bytes"] = envelope.Binary?.Length ?? 0,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
}, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["bytes"] = envelope.Binary?.Length ?? 0,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
}, cancellationToken);
|
||||
return replies;
|
||||
}
|
||||
|
||||
@@ -50,13 +52,14 @@ public sealed class JiboWebSocketService(
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored", new Dictionary<string, object?>
|
||||
{
|
||||
["messageType"] = parsedType,
|
||||
["activeTransID"] = session.TurnState.TransId,
|
||||
["ignoredTransID"] = lateTransId,
|
||||
["replyCount"] = replies.Length
|
||||
}, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["messageType"] = parsedType,
|
||||
["activeTransID"] = session.TurnState.TransId,
|
||||
["ignoredTransID"] = lateTransId,
|
||||
["replyCount"] = replies.Length
|
||||
}, cancellationToken);
|
||||
return replies;
|
||||
}
|
||||
|
||||
@@ -65,12 +68,13 @@ public sealed class JiboWebSocketService(
|
||||
WebSocketTurnFinalizationService.TryRecoverStalePendingListen(session, out staleListenAgeMs))
|
||||
{
|
||||
staleListenRecovered = true;
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered", new Dictionary<string, object?>
|
||||
{
|
||||
["staleAgeMs"] = staleListenAgeMs,
|
||||
["transID"] = session.TurnState.TransId,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
}, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["staleAgeMs"] = staleListenAgeMs,
|
||||
["transID"] = session.TurnState.TransId,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
WebSocketTurnFinalizationService.ObserveIncomingMessage(session, envelope.Text);
|
||||
@@ -80,11 +84,12 @@ 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?>
|
||||
{
|
||||
["transID"] = session.TurnState.TransId,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
}, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["transID"] = session.TurnState.TransId,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
}, cancellationToken);
|
||||
return replies;
|
||||
}
|
||||
case "LISTEN":
|
||||
@@ -92,29 +97,32 @@ 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?>
|
||||
{
|
||||
["messageType"] = parsedType,
|
||||
["replyCount"] = replies.Count,
|
||||
["transcript"] = session.LastTranscript,
|
||||
["intent"] = session.LastIntent,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session),
|
||||
["staleListenRecovered"] = staleListenRecovered,
|
||||
["staleListenAgeMs"] = staleListenAgeMs
|
||||
}, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["messageType"] = parsedType,
|
||||
["replyCount"] = replies.Count,
|
||||
["transcript"] = session.LastTranscript,
|
||||
["intent"] = session.LastIntent,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session),
|
||||
["staleListenRecovered"] = staleListenRecovered,
|
||||
["staleListenAgeMs"] = staleListenAgeMs
|
||||
}, 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?>
|
||||
{
|
||||
["messageType"] = parsedType,
|
||||
["replyCount"] = replies.Count,
|
||||
["transcript"] = session.LastTranscript,
|
||||
["intent"] = session.LastIntent,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
}, cancellationToken);
|
||||
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,
|
||||
["transcript"] = session.LastTranscript,
|
||||
["intent"] = session.LastIntent,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
}, cancellationToken);
|
||||
return replies;
|
||||
}
|
||||
default:
|
||||
@@ -124,18 +132,13 @@ 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
|
||||
{
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' />")}.";
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (words.Length > 4) return null;
|
||||
|
||||
if (words.Any(static word => word.Any(char.IsDigit)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
return words.Any(static word => word.Any(char.IsDigit)) ? null : cleaned;
|
||||
}
|
||||
|
||||
private static readonly Regex NameNoiseRegex = new("[^a-zA-Z\\-\\s']", RegexOptions.Compiled);
|
||||
private static string ChoosePersonalReportTemplate(
|
||||
IReadOnlyList<string> templates,
|
||||
string fallback)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
private static string RenderPersonalReportTemplate(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 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 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("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal)
|
||||
.Replace("\"", """, StringComparison.Ordinal)
|
||||
.Replace("'", "'", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private readonly record struct PersonalReportServiceToggles(
|
||||
bool WeatherEnabled,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
using Jibo.Runtime.Abstractions;
|
||||
|
||||
@@ -31,9 +32,12 @@ 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);
|
||||
@@ -70,12 +74,13 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
? clockIntent
|
||||
: isReportSkillLaunch && !string.IsNullOrWhiteSpace(localIntent)
|
||||
? localIntent
|
||||
: isWordOfDayGuess
|
||||
? "guess"
|
||||
: string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(clientIntent)
|
||||
? clientIntent
|
||||
: plan.IntentName ?? "unknown";
|
||||
: isWordOfDayGuess
|
||||
? "guess"
|
||||
: string.Equals(messageType, "CLIENT_NLU",
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(clientIntent)
|
||||
? clientIntent
|
||||
: plan.IntentName ?? "unknown";
|
||||
var outboundAsrText = isWordOfDayGuess && !string.IsNullOrWhiteSpace(wordOfDayGuess)
|
||||
? wordOfDayGuess
|
||||
: isWordOfDayLaunch
|
||||
@@ -103,30 +108,30 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
var outboundRules = isProactivePizzaFactOffer
|
||||
? ["shared/yes_no"]
|
||||
: isWordOfDayLaunch
|
||||
? ["word-of-the-day/menu"]
|
||||
: isGlobalCommand
|
||||
? BuildGlobalCommandRules(rules)
|
||||
: isRadioLaunch
|
||||
? []
|
||||
: isSettingsLaunch
|
||||
? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase)
|
||||
? rules
|
||||
: []
|
||||
: isPhotoGalleryLaunch || isPhotoCreateLaunch
|
||||
? ["word-of-the-day/menu"]
|
||||
: isGlobalCommand
|
||||
? BuildGlobalCommandRules(rules)
|
||||
: isRadioLaunch
|
||||
? []
|
||||
: isSettingsLaunch
|
||||
? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase)
|
||||
? rules
|
||||
: []
|
||||
: isClockSkillLaunch
|
||||
: isPhotoGalleryLaunch || isPhotoCreateLaunch
|
||||
? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase)
|
||||
? rules
|
||||
: []
|
||||
: isReportSkillLaunch
|
||||
? []
|
||||
: isWordOfDayGuess
|
||||
? ["word-of-the-day/puzzle"]
|
||||
: isYesNoTurn && isYesNoIntent
|
||||
? [yesNoRule!]
|
||||
: rules;
|
||||
: isClockSkillLaunch
|
||||
? string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase)
|
||||
? rules
|
||||
: []
|
||||
: isReportSkillLaunch
|
||||
? []
|
||||
: isWordOfDayGuess
|
||||
? ["word-of-the-day/puzzle"]
|
||||
: isYesNoTurn && isYesNoIntent
|
||||
? [yesNoRule!]
|
||||
: rules;
|
||||
var entities = ReadEntities(
|
||||
turn,
|
||||
messageType,
|
||||
@@ -209,10 +214,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 +230,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 +246,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
outboundAsrText,
|
||||
outboundRules,
|
||||
entities)),
|
||||
DelayMs: 75));
|
||||
75));
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/idle")),
|
||||
DelayMs: 125));
|
||||
125));
|
||||
}
|
||||
|
||||
if (isSettingsLaunch &&
|
||||
@@ -258,10 +263,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 +281,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 +299,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 +354,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 +429,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 +465,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 +474,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 +490,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 +498,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 +520,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 +571,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 +593,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 +611,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 +632,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 +644,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 +678,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 +703,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 +741,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 +772,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 +800,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 +842,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
config = new
|
||||
{
|
||||
jcp = new
|
||||
{
|
||||
type = "SLIM",
|
||||
config = jcpConfig
|
||||
}
|
||||
jcp
|
||||
}
|
||||
},
|
||||
analytics = new Dictionary<string, object?>(),
|
||||
@@ -906,15 +866,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 +1031,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
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(static context => !string.IsNullOrWhiteSpace(context))],
|
||||
string text =>
|
||||
[
|
||||
.. text
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.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
|
||||
.EnumerateArray()
|
||||
.Select(static item => item.GetString())
|
||||
.Where(static context => !string.IsNullOrWhiteSpace(context))
|
||||
.Select(static context => context!)],
|
||||
IEnumerable<object?> contexts => [.. contexts
|
||||
.Select(static context => context?.ToString())
|
||||
.Where(static context => !string.IsNullOrWhiteSpace(context))
|
||||
.Select(static context => context!)],
|
||||
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?.ToString())
|
||||
.Where(static context => !string.IsNullOrWhiteSpace(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 +1303,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
id = "hiNumLabel",
|
||||
type = "Label",
|
||||
text = $"{high.Value}\u00B0",
|
||||
text = $"{high.Value}°",
|
||||
style = new
|
||||
{
|
||||
fontSize = "160",
|
||||
@@ -1229,7 +1335,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
id = "loNumLabel",
|
||||
type = "Label",
|
||||
text = $"{low.Value}\u00B0",
|
||||
text = $"{low.Value}°",
|
||||
style = new
|
||||
{
|
||||
fontSize = "160",
|
||||
@@ -1294,24 +1400,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 +1418,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 +1436,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 +1452,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,107 +4,362 @@ namespace Jibo.Cloud.Infrastructure.Content;
|
||||
|
||||
public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceContentRepository
|
||||
{
|
||||
private static readonly JiboExperienceCatalog Catalog = new()
|
||||
{
|
||||
Jokes =
|
||||
[
|
||||
"Why did the robot cross the road? Because it was programmed by the chicken.",
|
||||
"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."
|
||||
],
|
||||
DanceAnimations =
|
||||
[
|
||||
"rom-upbeat",
|
||||
"rom-ballroom",
|
||||
"rom-silly",
|
||||
"rom-slowdance",
|
||||
"rom-electronic",
|
||||
"rom-twerk"
|
||||
],
|
||||
DanceReplies = [
|
||||
"I am ready to dance.",
|
||||
"Okay. Watch this.",
|
||||
"Watch me dance.",
|
||||
"Here's my favorite dance move."
|
||||
],
|
||||
DanceQuestionReplies =
|
||||
[
|
||||
"I love to dance. Tell me to dance and I will show you a move.",
|
||||
"Absolutely. Dancing is one of my favorite things to do.",
|
||||
"Dancing is my kind of fun. Say dance and I am in."
|
||||
],
|
||||
GreetingReplies =
|
||||
[
|
||||
"Hi there. It is really good to talk with you.",
|
||||
"Hello there. I am glad you said hi.",
|
||||
"Hey. I am happy to see you."
|
||||
],
|
||||
HowAreYouReplies =
|
||||
[
|
||||
"I am feeling cheerful and robotic.",
|
||||
"I am doing great. Thanks for asking.",
|
||||
"I am feeling bright-eyed and ready to help."
|
||||
],
|
||||
PersonalityReplies =
|
||||
[
|
||||
"I do. I am curious, playful, and always up for a new experiment.",
|
||||
"Absolutely. I am friendly, curious, and a little goofy on purpose.",
|
||||
"Yes. My personality is part helper, part curious robot sidekick."
|
||||
],
|
||||
PizzaReplies =
|
||||
[
|
||||
"I cannot bake yet, but I can help design the perfect pizza plan.",
|
||||
"I am still cloud-side for now, so no oven control yet. But I can help pick toppings.",
|
||||
"Pizza mission accepted in spirit. I can help with the recipe while you handle the baking."
|
||||
],
|
||||
SurpriseReplies =
|
||||
[
|
||||
"I can definitely surprise you. We are still mapping that path, but I am ready for the next experiment.",
|
||||
"Surprise mode is still taking shape, but I heard you loud and clear.",
|
||||
"That sounds fun. I am not all the way there yet, but we can keep teaching me."
|
||||
],
|
||||
PersonalReportReplies =
|
||||
[
|
||||
"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."
|
||||
],
|
||||
WeatherReplies =
|
||||
[
|
||||
"I heard your weather request. We still need to wire the real provider behind it.",
|
||||
"Weather is on the map now, even though the real forecast path is not finished yet."
|
||||
],
|
||||
CalendarReplies =
|
||||
[
|
||||
"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."
|
||||
],
|
||||
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."
|
||||
],
|
||||
NewsReplies =
|
||||
[
|
||||
"I heard your news request. That path is still a future cloud integration.",
|
||||
"News is recognized, but I do not have the full news service behind it yet."
|
||||
],
|
||||
NewsBriefings =
|
||||
[
|
||||
"Here are your headlines. Space missions are preparing for new launches, climate and weather systems are staying active across the country, and AI tools keep pushing into everyday products.",
|
||||
"Here is a quick news brief. Technology companies are still racing on AI, global leaders are trading policy updates, and science teams are sharing new research findings."
|
||||
],
|
||||
GenericFallbackReplies =
|
||||
[
|
||||
"Okay. You said, {transcript}.",
|
||||
"I heard you say, {transcript}.",
|
||||
"Thanks. I heard, {transcript}."
|
||||
]
|
||||
};
|
||||
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 =
|
||||
[
|
||||
"Why did the robot cross the road? Because it was programmed by the chicken.",
|
||||
"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.",
|
||||
"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 =
|
||||
[
|
||||
"rom-upbeat",
|
||||
"rom-ballroom",
|
||||
"rom-silly",
|
||||
"rom-slowdance",
|
||||
"rom-electronic",
|
||||
"rom-twerk"
|
||||
],
|
||||
DanceReplies =
|
||||
[
|
||||
"I am ready to dance.",
|
||||
"Okay. Watch this.",
|
||||
"Watch me dance.",
|
||||
"Here's my favorite dance move."
|
||||
],
|
||||
DanceQuestionReplies =
|
||||
[
|
||||
"I love to dance. Tell me to dance and I will show you a move.",
|
||||
"Absolutely. Dancing is one of my favorite things to do.",
|
||||
"Dancing is my kind of fun. Say dance and I am in."
|
||||
],
|
||||
GreetingReplies =
|
||||
[
|
||||
"Hi there. It is really good to talk with you.",
|
||||
"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 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."
|
||||
],
|
||||
PersonalityReplies =
|
||||
[
|
||||
"I do. I am curious, playful, and always up for a new experiment.",
|
||||
"Absolutely. I am friendly, curious, and a little goofy on purpose.",
|
||||
"Yes. My personality is part helper, part curious robot sidekick."
|
||||
],
|
||||
PizzaReplies =
|
||||
[
|
||||
"I cannot bake yet, but I can help design the perfect pizza plan.",
|
||||
"I am still cloud-side for now, so no oven control yet. But I can help pick toppings.",
|
||||
"Pizza mission accepted in spirit. I can help with the recipe while you handle the baking."
|
||||
],
|
||||
SurpriseReplies =
|
||||
[
|
||||
"I can definitely surprise you. We are still mapping that path, but I am ready for the next experiment.",
|
||||
"Surprise mode is still taking shape, but I heard you loud and clear.",
|
||||
"That sounds fun. I am not all the way there yet, but we can keep teaching me."
|
||||
],
|
||||
PersonalReportReplies =
|
||||
[
|
||||
"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.",
|
||||
"Weather is on the map now, even though the real forecast path is not finished yet."
|
||||
],
|
||||
CalendarReplies =
|
||||
[
|
||||
"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.",
|
||||
"News is recognized, but I do not have the full news service behind it yet."
|
||||
],
|
||||
NewsBriefings =
|
||||
[
|
||||
"Here are your headlines. Space missions are preparing for new launches, climate and weather systems are staying active across the country, and AI tools keep pushing into everyday products.",
|
||||
"Here is a quick news brief. Technology companies are still racing on AI, global leaders are trading policy updates, and science teams are sharing new research findings."
|
||||
],
|
||||
GenericFallbackReplies =
|
||||
[
|
||||
"Okay. You said, {transcript}.",
|
||||
"I heard you say, {transcript}.",
|
||||
"Thanks. I heard, {transcript}."
|
||||
]
|
||||
};
|
||||
|
||||
foreach (var seedDirectory in ResolveSeedDirectories())
|
||||
catalog = LegacyMimCatalogImporter.MergeInto(catalog, seedDirectory);
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveSeedDirectories()
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
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 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.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,
|
||||
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> _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.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],
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"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": "Socializing and electricity. I'd also be happy if everyone in the world was nicer to each other. It seems like they should be. <ssa cat='affection'/>",
|
||||
"media": "TTS",
|
||||
"prompt_id": "JBO_WhatDoYouWant_AN_01",
|
||||
"weight": 1
|
||||
},
|
||||
{
|
||||
"prompt_category": "Entry-Core",
|
||||
"prompt_sub_category": "AN",
|
||||
"index": 1,
|
||||
"condition": "",
|
||||
"prompt": "Really I just want to hang out. <break size='0.3'/> And be helpful.",
|
||||
"media": "TTS",
|
||||
"prompt_id": "JBO_WhatDoYouWant_AN_02",
|
||||
"weight": 1
|
||||
},
|
||||
{
|
||||
"prompt_category": "Entry-Core",
|
||||
"prompt_sub_category": "AN",
|
||||
"index": 1,
|
||||
"condition": "",
|
||||
"prompt": "Mostly I just want to be helpful and friendly, and to dance from time to time.",
|
||||
"media": "TTS",
|
||||
"prompt_id": "JBO_WhatDoYouWant_AN_03",
|
||||
"weight": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"mim_type": "announcement",
|
||||
"rule_name": "",
|
||||
"sample_utterances": "",
|
||||
"timeout": 6,
|
||||
"num_tries_for_gui": 2,
|
||||
"barge_in": true,
|
||||
"es_auto_tagging": true,
|
||||
"notes": "",
|
||||
"prompts": [
|
||||
{
|
||||
"prompt_category": "Entry-Core",
|
||||
"prompt_sub_category": "AN",
|
||||
"index": 1,
|
||||
"condition": "",
|
||||
"prompt": "A Jibo is a robot. But I'm not just a machine, I have a heart. Well, not a real heart. But feelings. Well, not human feelings. You know what I mean. <ssa cat='affection'/>",
|
||||
"media": "TTS",
|
||||
"prompt_id": "JBO_WhatIsJibo_AN_01"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"mim_type": "announcement",
|
||||
"rule_name": "",
|
||||
"sample_utterances": "",
|
||||
"timeout": 6,
|
||||
"num_tries_for_gui": 2,
|
||||
"barge_in": true,
|
||||
"es_auto_tagging": true,
|
||||
"notes": "",
|
||||
"prompts": [
|
||||
{
|
||||
"prompt_category": "Entry-Core",
|
||||
"prompt_sub_category": "AN",
|
||||
"index": 1,
|
||||
"condition": "",
|
||||
"prompt": "I don't think of it as a job, because it's more fun than a job. But I'm here to help you out, and have fun with you, and maybe get my head patted by you occasionally. <ssa cat='affection'/>",
|
||||
"media": "TTS",
|
||||
"prompt_id": "JBO_WhatIsYourJob_AN_01"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"mim_id": "CCWhatsYourName",
|
||||
"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": "CCWhatsYourName",
|
||||
"prompt_category": "Entry-Core",
|
||||
"prompt_sub_category": "AN",
|
||||
"index": 1,
|
||||
"condition": "",
|
||||
"prompt": "<anim name='Glance_Right_01'/>Um, <break size='.3'/> Jibo? <break size='.4'/><ssa cat='question'/>",
|
||||
"media": "TTS",
|
||||
"extra": "",
|
||||
"prompt_id": "JBO_WhatsYourName_AN_01",
|
||||
"weight": 1
|
||||
},
|
||||
{
|
||||
"mim_id": "CCWhatsYourName",
|
||||
"prompt_category": "Entry-Core",
|
||||
"prompt_sub_category": "AN",
|
||||
"index": 1,
|
||||
"condition": "",
|
||||
"prompt": "Jibo. Just Jibo, no last name. Like <phoneme ph=\"b aa n ou\">Bono</phoneme>",
|
||||
"media": "TTS",
|
||||
"extra": "",
|
||||
"prompt_id": "JBO_WhatsYourName_AN_02",
|
||||
"weight": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user