Compare commits
112 Commits
383c272d9a
...
Features/W
| Author | SHA1 | Date | |
|---|---|---|---|
|
f6bf5e2079
|
|||
|
1e130e69ab
|
|||
|
bca138ecc8
|
|||
|
|
2357e82ae3 | ||
|
|
1755888fc1 | ||
|
|
3086ad6a6d | ||
|
|
90b48314d3 | ||
|
|
b99ee5d794 | ||
|
|
386f864e94 | ||
|
|
9d675ed59c | ||
|
|
b113dd55d3 | ||
|
|
d52c4e6e19 | ||
|
|
b0709dd25e | ||
|
|
5422febb8c | ||
|
|
eeef2b3beb | ||
|
|
acdc6da286 | ||
|
32c601d046
|
|||
|
|
febceecab8 | ||
|
|
791fe60612 | ||
|
|
3d016debe5 | ||
|
|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -420,3 +420,4 @@ FodyWeavers.xsd
|
||||
OpenJibo/captures/
|
||||
OpenJibo/.tmp/
|
||||
|
||||
OpenJibo/docs/DesignDoc/original server
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
792
OpenJibo/docs/DesignDoc/original-server-design.md
Normal file
792
OpenJibo/docs/DesignDoc/original-server-design.md
Normal file
@@ -0,0 +1,792 @@
|
||||
# Original Jibo Server (Pegasus) Design Document
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The original Jibo server, codenamed "Pegasus" (formerly V1.X), is a cloud-based microservices architecture that powers the Jibo social robot's conversational AI capabilities. It is built as a Lerna monorepo using Node.js/TypeScript and deployed via Docker containers. The system processes speech, performs natural language understanding, routes to appropriate skills, and manages proactive behaviors.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
The codebase is organized as a Lerna monorepo with the following main packages:
|
||||
|
||||
- **packages/hub** - Central orchestration service
|
||||
- **packages/parser** - NLU (Natural Language Understanding) service
|
||||
- **packages/history** - Data persistence service (MongoDB)
|
||||
- **packages/baseskill** - Base class and framework for cloud skills
|
||||
- **packages/interfaces** - TypeScript interfaces and API contracts
|
||||
- **packages/utils** - Shared utility libraries
|
||||
- **packages/chitchat-skill** - Example conversational skill
|
||||
- **packages/report-skill** - Reporting skill
|
||||
- **packages/lasso** - External data integration service
|
||||
- **packages/hub-client** - Client library for hub communication
|
||||
- **packages/history-client** - Client library for history service
|
||||
- **packages/test-utils** - Testing utilities
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Language**: TypeScript 2.5.3
|
||||
- **Runtime**: Node.js 8.9.4
|
||||
- **Package Manager**: Yarn 1.7.0
|
||||
- **Containerization**: Docker
|
||||
- **Orchestration**: Docker Compose (local), AWS ECS (production)
|
||||
- **Database**: MongoDB 3.6.0
|
||||
- **Cache**: Redis 3
|
||||
- **NLU**: Dialogflow (API.ai)
|
||||
- **ASR**: Google Cloud Speech API
|
||||
- **WebSocket**: ws library
|
||||
- **HTTP**: Express.js
|
||||
- **Authentication**: JWT (jsonwebtoken)
|
||||
|
||||
## Core Services
|
||||
|
||||
### 1. Hub Service (`packages/hub`)
|
||||
|
||||
The Hub is the central orchestrator that coordinates all interactions between the robot and cloud services.
|
||||
|
||||
#### Key Components
|
||||
|
||||
**HubService** (`HubService.ts`)
|
||||
- Main service class extending `BaseService`
|
||||
- Initializes and manages all hub components
|
||||
- Registers WebSocket and HTTP handlers
|
||||
|
||||
**HubComponents** - Dependency injection container:
|
||||
- `parser: ParserClient` - NLU service client
|
||||
- `skillConfigManager: SkillConfigManager` - Manages skill configurations
|
||||
- `intentRouter: IntentRouter` - Routes intents to skills
|
||||
- `skillRequestMaker: SkillRequestMaker` - Makes HTTP requests to skills
|
||||
- `history: HistoryServiceClient` - History service client
|
||||
- `hubSettings: HubSettings` - Hub configuration
|
||||
- `settingsClient: SettingsClient` - Settings service client
|
||||
|
||||
#### Endpoints
|
||||
|
||||
**WebSocket Endpoints:**
|
||||
- `/listen` and `/v1/listen` - Handles speech recognition and NLU
|
||||
- `/proactive` and `/v1/proactive` - Handles proactive triggers
|
||||
|
||||
**HTTP Endpoints:**
|
||||
- `/skills` and `/v1/skills` - Lists available skills
|
||||
- `/healthcheck` - Service health check
|
||||
|
||||
#### Listen Flow
|
||||
|
||||
The listen transaction follows a state machine implemented in `ListenTransactionHandler`:
|
||||
|
||||
```
|
||||
States:
|
||||
WAIT_LISTEN → ASR → NLU → ROUTE → DONE
|
||||
WAIT_LISTEN → WAIT_CLIENT_ASR → NLU → ROUTE → DONE
|
||||
WAIT_LISTEN → WAIT_CLIENT_NLU → ROUTE → DONE
|
||||
```
|
||||
|
||||
**State Transitions:**
|
||||
|
||||
1. **WAIT_LISTEN** - Receives LISTEN message from robot
|
||||
2. **ASR** - Performs Automatic Speech Recognition using Google Cloud Speech API
|
||||
- Streams audio packets
|
||||
- Emits SOS (Start of Speech) when speech detected
|
||||
- Emits EOS (End of Speech) when speech ends
|
||||
- Handles timeouts (SOS timeout, max speech timeout)
|
||||
3. **NLU** - Sends ASR text to Parser service for intent recognition
|
||||
- Includes context (loop users, perception, etc.)
|
||||
- Supports external Dialogflow agents
|
||||
4. **ROUTE** - Intent Router determines which skill to launch
|
||||
- Matches NLU result against skill intent configurations
|
||||
- Decision Mediator can alter decisions based on external factors
|
||||
- Routes to on-robot skills or cloud skills
|
||||
5. **DONE** - Transaction complete
|
||||
|
||||
**Listen Transaction Handler** (`ListenTransactionHandler.ts`):
|
||||
- Manages audio streaming via `AudioBuffer`
|
||||
- Creates `ASRSession` for speech recognition
|
||||
- Handles timeouts (ASR: 40s, Parser: 10s, Context: 5s, Skill: 10s)
|
||||
- Records speech history to MongoDB and optionally S3
|
||||
- Supports client-provided ASR/NLU (for menu clicks, etc.)
|
||||
- Handles skill redirects
|
||||
|
||||
#### Proactive Flow
|
||||
|
||||
The proactive system allows Jibo to initiate conversations based on context, history, and triggers.
|
||||
|
||||
**Proactive Transaction Handler** (`ProactiveTransactionHandler.ts`):
|
||||
|
||||
1. Receives TRIGGER message from robot
|
||||
2. Waits for CONTEXT message (robot state)
|
||||
3. **Action Selection**:
|
||||
- Gets all proactive skill configurations
|
||||
- Filters by context rules (time, location, people present, etc.)
|
||||
- Filters by interaction history rules (frequency, recency)
|
||||
- Filters by user settings
|
||||
- Randomly selects from eligible actions
|
||||
4. Launches selected skill (on-robot or cloud)
|
||||
5. Returns match response or no-action response
|
||||
|
||||
**Proactive Registration**:
|
||||
Skills register proactive behaviors with:
|
||||
- Trigger types (time-based, event-based, surprise)
|
||||
- Context rules (when this can trigger)
|
||||
- Interaction history rules (how often it can trigger)
|
||||
- Settings rules (user preferences)
|
||||
|
||||
### 2. Parser Service (`packages/parser`)
|
||||
|
||||
The Parser service performs Natural Language Understanding using Dialogflow.
|
||||
|
||||
**ParserService** (`ParserService.ts`):
|
||||
- Starts RobustParser process on port 8787 (optional)
|
||||
- Initializes Dialogflow client
|
||||
- Initializes Robust Parser client
|
||||
- Handles POST requests to `/v1/parse`
|
||||
- Exposes state at `/state` endpoint
|
||||
|
||||
**NLU Pipeline:**
|
||||
1. Receives text, rules, and context
|
||||
2. Queries Dialogflow with configured agents
|
||||
3. Optionally queries Robust Parser (custom NLU)
|
||||
4. Returns intent, entities, and rules
|
||||
|
||||
**Configuration:**
|
||||
- Dialogflow API key
|
||||
- Robust Parser enable/disable
|
||||
- Multiple external agents support
|
||||
|
||||
### 3. History Service (`packages/history`)
|
||||
|
||||
The History service persists interaction data to MongoDB.
|
||||
|
||||
**HistoryService** (`HistoryService.ts`):
|
||||
- Two database clients:
|
||||
- `SkillLaunchDBClient` - Records skill launches
|
||||
- `SpeechHistoryDBClient` - Records speech interactions (optional)
|
||||
- HTTP endpoints:
|
||||
- `/v1/skill/launch` - Skill launch history
|
||||
- `/v1/speech` - Speech history (if enabled)
|
||||
- Health check endpoint
|
||||
|
||||
**Data Stored:**
|
||||
- Skill launches (skill ID, intent, timestamp, robot ID, account ID)
|
||||
- Speech interactions (ASR result, NLU result, audio file URL, error tracking)
|
||||
|
||||
### 4. Lasso Service (`packages/lasso`)
|
||||
|
||||
Lasso provides external data integration for skills.
|
||||
|
||||
**Features:**
|
||||
- OAuth2 credential management
|
||||
- Calendar client integration
|
||||
- Weather data (Dark Sky API)
|
||||
- Maps data (Google Maps API)
|
||||
- News data (AP News)
|
||||
- MongoDB for credential storage
|
||||
- Redis for caching
|
||||
|
||||
**LassoService** (`LassoService.ts`):
|
||||
- Manages OAuth2 flows
|
||||
- Provides relay endpoints for external APIs
|
||||
- Caches responses in Redis
|
||||
|
||||
## Skill Framework
|
||||
|
||||
### BaseSkill (`packages/baseskill`)
|
||||
|
||||
**BaseSkill** (`BaseSkill.ts`):
|
||||
- Abstract base class for all cloud skills
|
||||
- Extends `BaseHttpHandler`
|
||||
- Handles POST requests to `/`
|
||||
- Provides error handling
|
||||
- Tracks timing
|
||||
|
||||
**GraphSkill** (`GraphSkill.ts`):
|
||||
- Extends BaseSkill with graph-based state machine
|
||||
- Implements node-based conversation flow
|
||||
- Supports skill redirects
|
||||
- Tracks analytics events
|
||||
- Supports supplemental behaviors (parallel/sequence)
|
||||
|
||||
### Graph System
|
||||
|
||||
The graph system provides a state machine framework for skills.
|
||||
|
||||
**Graph** (`Graph.ts`):
|
||||
- Directed graph of connected nodes
|
||||
- Supports subgraphs (hierarchical)
|
||||
- Exit transitions for graph termination
|
||||
- Validation (reachability, transition completeness)
|
||||
- GraphViz dot file generation
|
||||
|
||||
**GraphManager** (`GraphManager.ts`):
|
||||
- Singleton per skill
|
||||
- Manages node IDs and mappings
|
||||
- Executes graph:
|
||||
- `start()` - Creates session, enters initial node
|
||||
- `enterNode()` - Calls node's enter method
|
||||
- `exitNode()` - Calls node's exit method with action results
|
||||
- `executeTransition()` - Moves to next node
|
||||
- Maintains session state (node ID, data, trace)
|
||||
|
||||
**Node** (`Node.ts`):
|
||||
- Abstract base class for graph nodes
|
||||
- Has transition names and destinations
|
||||
- Two lifecycle methods:
|
||||
- `enter(data)` - Called when node is entered, returns action or redirect
|
||||
- `exit(data)` - Called with action results, returns next transition
|
||||
- Supports graph traversal (BFS)
|
||||
|
||||
**Built-in Node Types:**
|
||||
- `DefaultNode` - Simple terminal node
|
||||
- `JCPNode` - Returns JCP action
|
||||
- `NoOpNode` - No operation
|
||||
- `TrueFalseNode` - Conditional branching
|
||||
- `SetLooperIDNode` - Sets speaker ID
|
||||
|
||||
**MIM (Motion Interaction Model) System:**
|
||||
- `ANFactory` - Creates graph for playing MIM animations
|
||||
- Supports scripted responses, emotion responses, fallback responses
|
||||
- Semi-specific responses (context-aware)
|
||||
|
||||
### Skill Request/Response Protocol
|
||||
|
||||
**Skill Request Types** (`skill/request.ts`):
|
||||
- `LISTEN_LAUNCH` - Launch skill from listen interaction
|
||||
- `LISTEN_UPDATE` - Update skill with action results
|
||||
- `PROACTIVE_LAUNCH` - Launch skill proactively
|
||||
|
||||
**Skill Request Data:**
|
||||
```typescript
|
||||
{
|
||||
type: MessageType,
|
||||
msgID: UUID,
|
||||
ts: number,
|
||||
data: {
|
||||
general: { accountID, robotID, lang, release },
|
||||
runtime: { character, location, loop, perception, dialog },
|
||||
skill: { id, session? },
|
||||
result: any, // Action results for UPDATE
|
||||
nlu: NLUResult,
|
||||
asr: ASRResult,
|
||||
memo?: any
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Skill Response Types** (`skill/response.ts`):
|
||||
- `SKILL_ACTION` - Returns action to execute
|
||||
- `SKILL_REDIRECT` - Redirects to another skill
|
||||
- `ERROR` - Error response
|
||||
|
||||
**Skill Action Data:**
|
||||
```typescript
|
||||
{
|
||||
action: JCPAction, // JCP protocol behavior
|
||||
analytics?: AnalyticsData,
|
||||
final?: boolean, // Is this the final response?
|
||||
fireAndForget?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**JCP Action** (`skill/action.ts`):
|
||||
```typescript
|
||||
{
|
||||
type: ActionType.JCP,
|
||||
config: {
|
||||
version: "1.0.0",
|
||||
jcp: SupportedBehaviors // SLIM, Sequence, Parallel, SetPresentPerson, ImpactEmotion
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Skill Configuration
|
||||
|
||||
**SkillConfig** (`skill/config.ts`):
|
||||
```typescript
|
||||
{
|
||||
id: SkillID,
|
||||
intents: [{
|
||||
name: IntentName,
|
||||
entities?: EntityConfig[],
|
||||
memo?: any
|
||||
}],
|
||||
proactives?: ProactiveRegistration[],
|
||||
IHQueries?: IHQueryDefinitions,
|
||||
onRobot?: boolean,
|
||||
URL: string,
|
||||
settings?: ManifestSettings
|
||||
}
|
||||
```
|
||||
|
||||
**Entity Config**:
|
||||
- `name` - Entity name
|
||||
- `value` - Expected value
|
||||
- `matchRule` - 'EXACT' or 'NOT'
|
||||
|
||||
**Proactive Registration**:
|
||||
- Trigger type and conditions
|
||||
- Context rules
|
||||
- Interaction history rules
|
||||
- Settings rules
|
||||
|
||||
## Interfaces Package
|
||||
|
||||
The `interfaces` package defines all TypeScript interfaces for communication between services.
|
||||
|
||||
### Key Interface Modules
|
||||
|
||||
**service.ts** - Base message types:
|
||||
- `BaseMessage<T, D>` - Generic message with type, msgID, timestamp, data
|
||||
- `BaseResponse<T, D>` - Response with final flag and timings
|
||||
- `IAuthDetails` - Authentication details (account ID, access keys)
|
||||
|
||||
**hub/** - Hub-specific interfaces:
|
||||
- `request.ts` - LISTEN, CONTEXT, CLIENT_ASR, CLIENT_NLU messages
|
||||
- `response.ts` - ASR, NLU, LISTEN, SKILL_REDIRECT, ERROR responses
|
||||
- `MessageType.ts` - Message type enums
|
||||
- `HubErrorCode.ts` - Error code enums
|
||||
|
||||
**skill/** - Skill-specific interfaces:
|
||||
- `request.ts` - LISTEN_LAUNCH, LISTEN_UPDATE, PROACTIVE_LAUNCH
|
||||
- `response.ts` - SKILL_ACTION, SKILL_REDIRECT, ERROR
|
||||
- `action.ts` - JCP action types
|
||||
- `config.ts` - Skill configuration
|
||||
- `behaviors.ts` - Supported JCP behaviors
|
||||
- `analytics.ts` - Analytics event types
|
||||
|
||||
**nlu.ts** - NLU interfaces:
|
||||
- `NLURequestData` - Text, rules, loop users, external agents
|
||||
- `NLUResult` - Intent, entities, rules
|
||||
- `ExternalAgentRequest` - External Dialogflow agent config
|
||||
|
||||
**asr.ts** - ASR interfaces:
|
||||
- `ASRResult` - Text, confidence, annotation
|
||||
- `ASRConfig` - Language, hints, timeouts
|
||||
|
||||
**jibo/** - Jibo-specific data:
|
||||
- `data.ts` - GeneralData (account, robot, language), SkillData (session, trace)
|
||||
- `runtime.ts` - RuntimeContext (character, location, loop, perception, dialog)
|
||||
|
||||
**proactive/** - Proactive interfaces:
|
||||
- Context field definitions
|
||||
- History rules
|
||||
- Settings rules
|
||||
- Proactive trigger/request/response
|
||||
|
||||
**history/** - History interfaces:
|
||||
- Skill launch data
|
||||
- Speech history data
|
||||
|
||||
## Utils Package
|
||||
|
||||
The `utils` package provides shared functionality.
|
||||
|
||||
### BaseService (`utils/service/BaseService.ts`)
|
||||
|
||||
Base class for all Pegasus services:
|
||||
|
||||
**Features:**
|
||||
- Express.js HTTP server
|
||||
- WebSocket server (ws library)
|
||||
- JWT authentication
|
||||
- Request/response logging with jibo-log
|
||||
- New Relic monitoring
|
||||
- Health check endpoint
|
||||
- Error handling middleware
|
||||
|
||||
**Methods:**
|
||||
- `addSocketHandler(path, handler)` - Register WebSocket handler
|
||||
- `addHttpHandler(path, handler)` - Register HTTP handler
|
||||
- `init(port)` - Start server
|
||||
- `close()` - Stop server
|
||||
|
||||
**Authentication:**
|
||||
- JWT token verification
|
||||
- Bearer token scheme
|
||||
- Configurable secret via `ETCO_server_hubTokenSecret`
|
||||
|
||||
**Logging:**
|
||||
- Per-request log instances
|
||||
- Transaction ID tracking
|
||||
- Robot ID tracking
|
||||
- Configurable log levels per namespace
|
||||
|
||||
### Other Utils
|
||||
|
||||
- `PegasusRequest` - Enhanced Express request with Jibo headers
|
||||
- `PegasusWebSocket` - Enhanced WebSocket with auth and logging
|
||||
- `JiboHeaders` - Parses Jibo-specific headers (transID, robotID, logging config)
|
||||
- `ResponseWrapper` - Wraps WebSocket responses
|
||||
- `HttpError` - HTTP error with status code
|
||||
|
||||
## Communication Protocols
|
||||
|
||||
### WebSocket Protocol
|
||||
|
||||
**Connection:**
|
||||
- URL: `ws://hub:9000/listen` or `ws://hub:9000/proactive`
|
||||
- Authentication: Bearer token in Authorization header
|
||||
- Headers: `x-jibo-transid`, `x-jibo-robotid`, `x-jibo-logging-config`
|
||||
|
||||
**Message Format:**
|
||||
```json
|
||||
{
|
||||
"type": "MESSAGE_TYPE",
|
||||
"msgID": "uuid",
|
||||
"ts": 1234567890,
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Listen Flow Messages:**
|
||||
1. Robot → Hub: LISTEN (with ASR config, rules, language)
|
||||
2. Robot → Hub: Audio packets (binary)
|
||||
3. Hub → Robot: SOS (Start of Speech)
|
||||
4. Robot → Hub: CONTEXT (runtime context)
|
||||
5. Hub → Robot: EOS (End of Speech)
|
||||
6. Hub → Robot: LISTEN (with ASR result, NLU result, match)
|
||||
7. Hub → Robot: SKILL_ACTION (if cloud skill)
|
||||
8. Robot → Hub: CMD_RESULT (action results)
|
||||
9. Hub → Robot: SKILL_ACTION (next action) or final
|
||||
|
||||
**Proactive Flow Messages:**
|
||||
1. Robot → Hub: TRIGGER (trigger data)
|
||||
2. Robot → Hub: CONTEXT (runtime context)
|
||||
3. Hub → Robot: PROACTIVE (match or no-action)
|
||||
4. Hub → Robot: SKILL_ACTION (if cloud skill)
|
||||
|
||||
### HTTP Protocol
|
||||
|
||||
**Skill Request:**
|
||||
- Method: POST
|
||||
- URL: `http://skill-host:port/`
|
||||
- Headers: Authorization, x-jibo-transid, x-jibo-robotid
|
||||
- Body: SkillRequest JSON
|
||||
|
||||
**Parser Request:**
|
||||
- Method: POST
|
||||
- URL: `http://parser:8080/v1/parse`
|
||||
- Body: NLURequestData JSON
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### JWT Authentication
|
||||
|
||||
**Token Format:**
|
||||
```json
|
||||
{
|
||||
"id": "account-id",
|
||||
"accessKeyId": "client-id",
|
||||
"secretAccessKey": "client-secret",
|
||||
"friendlyId": "robot-name"
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- Secret: `ETCO_server_hubTokenSecret` environment variable
|
||||
- Scheme: Bearer
|
||||
- Applied to WebSocket connections and HTTP endpoints
|
||||
|
||||
### Network Security
|
||||
|
||||
- All services run in Docker containers
|
||||
- Services communicate via Docker network (pegasus-nw)
|
||||
- External access via load balancer
|
||||
- TLS termination at load balancer
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Compose (Local Development)
|
||||
|
||||
**Services:**
|
||||
- `hub` - Hub service (port 9000)
|
||||
- `parser` - Parser service (port 9005)
|
||||
- `history` - History service (port 9006)
|
||||
- `chitchat-skill` - Chitchat skill (port 9004)
|
||||
- `report-skill` - Report skill (port 9003)
|
||||
- `lasso` - Lasso service (port 9007)
|
||||
- `redis` - Redis cache (port 6379)
|
||||
- `mongo_lasso` - MongoDB for Lasso (port 27017)
|
||||
- `history_cluster` - MongoDB for History (from docker-compose-history-db.yml)
|
||||
|
||||
**Configuration:**
|
||||
- Environment variables prefixed with `ETCO_` (ETCO = Environment TO Configuration)
|
||||
- Volume mounting: `./:/pegasus:consistent` for live code editing
|
||||
- Debug ports: 5850-5855 for Node.js debugging
|
||||
|
||||
### Build Process
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
docker build -t pegasus_base:latest .
|
||||
yarn docker:bootstrap
|
||||
yarn docker:build
|
||||
./pegasus.js build-docker-image --services hub
|
||||
```
|
||||
|
||||
**CLI Tool** (`cli/`):
|
||||
- `bootstrap` - Install dependencies
|
||||
- `build` - Build TypeScript
|
||||
- `test` - Run tests
|
||||
- `docker-run` - Run commands in Docker
|
||||
- `build-docker-image` - Build Docker images for services
|
||||
|
||||
### Production Deployment
|
||||
|
||||
- AWS ECS (Elastic Container Service)
|
||||
- ECR (Elastic Container Registry) for Docker images
|
||||
- Application Load Balancer
|
||||
- MongoDB Atlas for production databases
|
||||
- ElastiCache for Redis
|
||||
- CloudWatch for logging
|
||||
- New Relic for monitoring
|
||||
|
||||
## Data Flow Examples
|
||||
|
||||
### Example 1: User Says "Tell Me a Joke"
|
||||
|
||||
1. **Robot → Hub**: LISTEN message with ASR config
|
||||
2. **Robot → Hub**: Audio stream
|
||||
3. **Hub**: Detects SOS, emits SOS message
|
||||
4. **Hub**: Streams audio to Google Cloud Speech API
|
||||
5. **Hub**: Detects EOS, emits EOS message
|
||||
6. **Robot → Hub**: CONTEXT message (runtime state)
|
||||
7. **Hub → Parser**: POST /v1/parse with text "tell me a joke"
|
||||
8. **Parser → Dialogflow**: Query with "joke" intent rules
|
||||
9. **Dialogflow → Parser**: Intent="joke_tell", entities={}
|
||||
10. **Parser → Hub**: NLU result
|
||||
11. **Hub → IntentRouter**: Match intent to "joke-skill"
|
||||
12. **Hub → joke-skill**: POST LISTEN_LAUNCH request
|
||||
13. **joke-skill**: Executes graph, selects joke
|
||||
14. **joke-skill → Hub**: SKILL_ACTION with JCP behavior (SayText)
|
||||
15. **Hub → Robot**: SKILL_ACTION message
|
||||
16. **Robot**: Executes behavior, speaks joke
|
||||
17. **Robot → Hub**: CMD_RESULT with action result
|
||||
18. **Hub → joke-skill**: POST LISTEN_UPDATE request
|
||||
19. **joke-skill**: Returns final=true
|
||||
20. **Hub → Robot**: Final SKILL_ACTION
|
||||
|
||||
### Example 2: Proactive Greeting
|
||||
|
||||
1. **Robot**: Detects person entering room
|
||||
2. **Robot → Hub**: TRIGGER message with trigger data
|
||||
3. **Robot → Hub**: CONTEXT message (runtime state)
|
||||
4. **Hub**: Queries all proactive skill configs
|
||||
5. **Hub**: Filters by context (time, people present)
|
||||
6. **Hub**: Filters by history (last greeting time)
|
||||
7. **Hub**: Filters by settings (user greeting preference)
|
||||
8. **Hub**: Selects "greeting-skill"
|
||||
9. **Hub → greeting-skill**: POST PROACTIVE_LAUNCH request
|
||||
10. **greeting-skill → Hub**: SKILL_ACTION with greeting behavior
|
||||
11. **Hub → Robot**: PROACTIVE response with match
|
||||
12. **Hub → Robot**: SKILL_ACTION message
|
||||
13. **Robot**: Executes greeting
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Types
|
||||
|
||||
**Hub Error Codes** (`HubErrorCode.ts`):
|
||||
- `TIMEOUT_ASR` - ASR timeout
|
||||
- `TIMEOUT_PARSER` - Parser timeout
|
||||
- `TIMEOUT_CONTEXT` - Context timeout
|
||||
- `TIMEOUT_SKILL` - Skill timeout
|
||||
- `PARSER` - Parser error
|
||||
- `ASR` - ASR error
|
||||
|
||||
**Skill Request Errors** (`SkillRequestError`):
|
||||
- `SKILL_NOT_FOUND` - Skill does not exist
|
||||
- `TIMEOUT` - Skill request timeout
|
||||
|
||||
### Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ERROR",
|
||||
"msgID": "uuid",
|
||||
"ts": 1234567890,
|
||||
"final": true,
|
||||
"data": {
|
||||
"message": "Error description",
|
||||
"code": "ERROR_CODE"
|
||||
},
|
||||
"timings": {
|
||||
"total": 1234
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Timeout Handling
|
||||
|
||||
- ASR: 40 seconds (configurable via sosTimeout, maxSpeechTimeout)
|
||||
- Parser: 10 seconds
|
||||
- Context: 5 seconds
|
||||
- Skill: 10 seconds
|
||||
- Transaction: 60 seconds (configurable)
|
||||
|
||||
## Monitoring & Logging
|
||||
|
||||
### Logging
|
||||
|
||||
**jibo-log Integration:**
|
||||
- Per-namespace log levels
|
||||
- Transaction ID correlation
|
||||
- Robot ID tracking
|
||||
- Structured logging support
|
||||
|
||||
**Log Levels:**
|
||||
- Configured via `x-jibo-logging-config` header
|
||||
- Per-namespace granularity
|
||||
- Environment variable: `ETCO_server_logLevel`
|
||||
|
||||
### Monitoring
|
||||
|
||||
**New Relic:**
|
||||
- HTTP request tracking
|
||||
- WebSocket transaction tracking
|
||||
- Error tracking
|
||||
- Custom attributes (transID, robotID)
|
||||
|
||||
**Health Checks:**
|
||||
- `/healthcheck` endpoint on all services
|
||||
- Returns service-specific health data
|
||||
- Database connection status
|
||||
|
||||
### Speech History Recording
|
||||
|
||||
**Optional Features:**
|
||||
- Record skill launches to MongoDB
|
||||
- Record speech interactions to MongoDB
|
||||
- Upload speech logs to S3 (JSON with audio base64)
|
||||
|
||||
**Configuration:**
|
||||
- `ETCO_hub_recordLaunchHistory` - Enable launch history
|
||||
- `ETCO_hub_recordSpeechHistory` - Enable speech history
|
||||
- `ETCO_hub_recordSpeechLogBucket` - S3 bucket for speech logs
|
||||
|
||||
## Skill Development Guide
|
||||
|
||||
### Creating a New Skill
|
||||
|
||||
1. **Extend GraphSkill:**
|
||||
```typescript
|
||||
export class MySkill extends GraphSkill<Transition> {
|
||||
constructor() {
|
||||
super('my-skill');
|
||||
}
|
||||
|
||||
createGraph(): Graph<Transition> {
|
||||
const g = new Graph('My Skill', generateTransitions<Transition>(Transition));
|
||||
// Add nodes and transitions
|
||||
g.finalize();
|
||||
return g;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Define Transitions:**
|
||||
```typescript
|
||||
enum Transition {
|
||||
Done = 'Done',
|
||||
Retry = 'Retry'
|
||||
}
|
||||
```
|
||||
|
||||
3. **Create Nodes:**
|
||||
```typescript
|
||||
class MyNode extends Node<Transition> {
|
||||
async enter(data: Data): Promise<EnterResponse> {
|
||||
// Return action or redirect
|
||||
return { action: myJCPAction };
|
||||
}
|
||||
|
||||
async exit(data: Data): Promise<ExitResponse> {
|
||||
// Return next transition
|
||||
return { transition: Transition.Done };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Create Skill Manifest:**
|
||||
```json
|
||||
{
|
||||
"id": "my-skill",
|
||||
"intents": [
|
||||
{
|
||||
"name": "my_intent",
|
||||
"entities": []
|
||||
}
|
||||
],
|
||||
"onRobot": false
|
||||
}
|
||||
```
|
||||
|
||||
5. **Register with Hub:**
|
||||
- Add skill config to skills-local.json or environment
|
||||
- Deploy skill service
|
||||
- Hub will load configuration
|
||||
|
||||
### Skill Best Practices
|
||||
|
||||
- Use graph for complex flows, direct responses for simple ones
|
||||
- Track analytics events for monitoring
|
||||
- Handle errors gracefully with try-catch
|
||||
- Use supplemental behaviors for parallel actions
|
||||
- Set appropriate timeouts
|
||||
- Log important events
|
||||
- Test with both LISTEN_LAUNCH and PROACTIVE_LAUNCH
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Why Graph-Based Skills?
|
||||
|
||||
- **State Management**: Explicit state machine with session tracking
|
||||
- **Visualization**: GraphViz generation for debugging
|
||||
- **Reusability**: Subgraphs for common patterns
|
||||
- **Testability**: Isolated node testing
|
||||
- **Maintainability**: Clear flow structure
|
||||
|
||||
### Why WebSocket for Robot Communication?
|
||||
|
||||
- **Low Latency**: Real-time bidirectional communication
|
||||
- **Audio Streaming**: Binary message support for audio
|
||||
- **Stateful**: Single connection per transaction
|
||||
- **Efficiency**: No HTTP overhead for each message
|
||||
|
||||
### Why Separate Services?
|
||||
|
||||
- **Scalability**: Scale each service independently
|
||||
- **Isolation**: Failure in one service doesn't affect others
|
||||
- **Technology**: Different services can use different tech stacks
|
||||
- **Deployment**: Independent deployment cycles
|
||||
|
||||
### Why Lerna Monorepo?
|
||||
|
||||
- **Code Sharing**: Easy to share interfaces and utils
|
||||
- **Versioning**: Linked versioning for interdependent packages
|
||||
- **Development**: Single repository for all services
|
||||
- **Testing**: Integration tests across packages
|
||||
|
||||
## Limitations & Known Issues
|
||||
|
||||
1. **Single Graph Manager**: Skills cannot have concurrent sessions (singleton pattern)
|
||||
2. **Sequential Skill Redirects**: Only one level of redirect supported
|
||||
3. **No Skill-to-Skill Communication**: Skills must go through hub
|
||||
4. **Fixed Timeouts**: Hardcoded timeouts in some places
|
||||
5. **No Skill Hot-Reload**: Requires container rebuild for skill changes
|
||||
6. **Limited NLU**: Dialogflow dependency, no custom model training
|
||||
7. **No Skill Versioning**: Skills identified by ID only
|
||||
8. **Synchronous Skill Requests**: Hub waits for skill response (no async)
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Skill Versioning**: Support multiple versions of same skill
|
||||
2. **Skill-to-Skill Direct Communication**: Allow skills to call each other
|
||||
3. **Async Skill Responses**: Long-running skills with callback pattern
|
||||
4. **Custom NLU Models**: Support for custom trained models
|
||||
5. **Skill Hot-Reload**: Dynamic skill loading without restart
|
||||
6. **Multi-Session Skills**: Support concurrent skill sessions
|
||||
7. **Skill Marketplace**: Third-party skill distribution
|
||||
8. **A/B Testing**: Framework for testing skill variations
|
||||
|
||||
## Conclusion
|
||||
|
||||
The original Jibo server (Pegasus) is a well-architected microservices system that provides a robust foundation for conversational AI on the Jibo robot. The graph-based skill framework offers flexibility and maintainability, while the separation of concerns enables independent scaling and development. The system successfully handles real-time speech processing, natural language understanding, skill routing, and proactive behaviors in a distributed cloud environment.
|
||||
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
|
||||
@@ -602,6 +614,8 @@ Current release theme:
|
||||
- recognition, enrollment, rename, and profile-correction boundaries
|
||||
- split between local state and hosted cloud state
|
||||
- first useful hosted identity slice
|
||||
- live QA has shown person-identification collisions in the same loop (for example, a parent and child both getting normalized to the same remembered name)
|
||||
- person-identification correction likely needs its own repair pass before we can trust greetings, reports, and presence triggers in mixed-household scenarios
|
||||
|
||||
### 20. Onboarding, Loop Management, And Fresh Start
|
||||
|
||||
@@ -626,9 +640,12 @@ Current release theme:
|
||||
- `make a pizza` now ports the original scripted-response path through `chitchat-skill` with `mim_id = RA_JBO_MakePizza` and pizza-making animation ESML
|
||||
- `can you order pizza` now ports the original scripted-response path through `chitchat-skill` with `mim_id = RA_JBO_OrderPizza`
|
||||
- current source answers these with a `1.0.19` rule-based persona baseline, backed by `OpenJiboCloudBuildInfo.PersonaBirthday`
|
||||
- `how old are you` now also uses the imported Build B age prompts so the first-powered-up and birthday phrasing stays source-backed
|
||||
- Follow-up:
|
||||
- wire persona age to first-powered-up or durable first-cloud-seen metadata when available
|
||||
- add command-vs-question variants so expressive prompts can answer conversationally before launching actions
|
||||
- live QA has shown motion/sleep quirks too: `turn around` can become a no-op and `go to sleep` can fail at the last step before the sleep animation fully completes
|
||||
- reply-selection polish still needs attention on a couple of identity prompts where short variants are over-selected (`how are you`, `what is your favorite flower`)
|
||||
|
||||
### 22. Command Vs Question Reply Style
|
||||
|
||||
@@ -654,6 +671,8 @@ Current release theme:
|
||||
- Follow-up:
|
||||
- add durable persistence path for personal facts
|
||||
- broaden fact categories further (multi-person household memory, relationship cues, and corrective updates)
|
||||
- add explicit person-scoped state so future interactions can distinguish household members inside the same loop
|
||||
- define the first server-to-server sync envelope for durable state before we need it in production
|
||||
|
||||
### 24. Memory-Triggered Proactivity Baseline
|
||||
|
||||
@@ -669,6 +688,7 @@ Current release theme:
|
||||
- expand proactivity beyond pizza to additional Pegasus-backed categories
|
||||
- add cooldown/throttle policy and observability around proactive offer frequency
|
||||
- connect memory store to durable multi-tenant persistence
|
||||
- keep the sync story visible so stateful offers can survive a multi-server deployment later
|
||||
|
||||
### 25. Weather Report-Skill Launch Compatibility
|
||||
|
||||
@@ -687,7 +707,7 @@ Current release theme:
|
||||
|
||||
### 26. Presence-Aware Greetings And Identity Proactivity
|
||||
|
||||
- Status: `ready`
|
||||
- Status: `in_progress`
|
||||
- Tags: `protocol`, `content`, `storage`, `docs`
|
||||
- Why now:
|
||||
- this is the next personality-charm expansion after parser guardrail and weather bring-up
|
||||
@@ -704,6 +724,13 @@ Current release theme:
|
||||
- add greeting intent families and state-machine split for reactive vs proactive greeting routes
|
||||
- add cooldown and trigger-source guardrails for proactive greetings
|
||||
- start person-aware greeting hooks (name-aware greeting, morning greeting policy, return greeting policy)
|
||||
- Shipped so far:
|
||||
- durable greeting-presence records now persist last-seen and last-greeted per person/loop
|
||||
- proactive greeting gating now consults cloud greeting history when available
|
||||
- reactive and proactive greeting turns write back greeting-history records for later cooldown checks
|
||||
- birthday-aware proactive greetings now use stored birthday memory on matching dates
|
||||
- holiday-aware proactive greetings now use loop holiday records on matching dates
|
||||
- morning proactive greetings now stay distinct from return-visit greetings
|
||||
- Exit criteria:
|
||||
- presence-aware greetings are routed deterministically with tests
|
||||
- proactive greetings are frequency-bounded and do not trigger from surprise source when blocked by policy
|
||||
@@ -724,13 +751,18 @@ Current release theme:
|
||||
- weather icon/animation parity and view support
|
||||
- broader non-local weather query handling and short-range date coverage
|
||||
- provider-backed news ingestion and filtering
|
||||
- commute provider path and settings schema
|
||||
- commute provider path, settings schema, and loop-scoped commute profile storage
|
||||
- coverage matrix for personal report parity gaps and test/capture exit criteria
|
||||
- Progress update (`2026-05-10`):
|
||||
- added provider-ready news briefing lane with Nimbus-compatible `news` skill payload continuity
|
||||
- added memory/transcript category hint plumbing for provider requests (sports/technology/business/general)
|
||||
- fallback synthetic news behavior remains active when no provider key is configured
|
||||
- added TTL caching for weather/news provider calls to reduce repeated external requests
|
||||
- vendored Pegasus `report-skill` templates for weather and personal-report phrasing so the next pass can focus on renderer coverage for calendar, commute, and news templates instead of rediscovering source text
|
||||
- commute now has a loop-scoped provider seam plus persisted commute profiles, so the next pass can focus on richer travel-time data instead of basic storage shape
|
||||
- Progress update (`2026-05-21`):
|
||||
- weather payloads now distinguish current-vs-weekly view modes so renderer parity can key off the payload shape more cleanly
|
||||
- news provider now skips summaryless correction headlines before falling back to broader sources
|
||||
- Source anchors:
|
||||
- `C:\Projects\jibo\pegasus\packages\report-skill\src\subskills\weather\WeatherMimLogic.ts`
|
||||
- `C:\Projects\jibo\pegasus\packages\report-skill\resources\views\weatherHiLo.json`
|
||||
@@ -743,7 +775,7 @@ Current release theme:
|
||||
|
||||
### 28. Grocery List Capability (Requested Feature)
|
||||
|
||||
- Status: `discovery`
|
||||
- Status: `in_progress`
|
||||
- Tags: `content`, `docs`, `storage`
|
||||
- Why now:
|
||||
- directly requested by Jibo owners and fits memory + household utility roadmap
|
||||
@@ -752,13 +784,173 @@ Current release theme:
|
||||
- examples:
|
||||
- `C:\Projects\jibo\pegasus\packages\chitchat-skill\mims\scripted-responses\RA_JBO_ShoppingList.mim`
|
||||
- `C:\Projects\jibo\pegasus\packages\chitchat-skill\mims\scripted-responses\RA_JBO_ManageToDoList.mim`
|
||||
- Candidate delivery paths:
|
||||
- native lightweight list skill (fastest user value)
|
||||
- integration-backed list orchestration (long-term richer ecosystem fit)
|
||||
- MVP decision:
|
||||
- use the existing household list engine as the native lightweight grocery MVP
|
||||
- keep grocery as a first-class spoken alias over the shopping list storage path
|
||||
- reserve integration-backed list orchestration for a later discovery pass
|
||||
- Exit criteria:
|
||||
- clear decision on MVP path
|
||||
- first schema for list items + ownership scope
|
||||
- initial voice flows and follow-up intent handling defined
|
||||
- grocery prompts, add/recall/done flows, and list follow-ups consistently speak grocery wording
|
||||
- existing shopping/to-do flows remain unchanged
|
||||
- future integration-backed list work remains a separate backlog item
|
||||
|
||||
### 29. Legacy MIM Personality Import Ladder
|
||||
|
||||
- Status: `in_progress`
|
||||
- Tags: `content`, `protocol`, `docs`
|
||||
- Why now:
|
||||
- we already have a chitchat/content scaffold that can render stock-compatible personality replies
|
||||
- the legacy `chitchat-mims` tree is mostly declarative content, so a phased import can add visible charm fast
|
||||
- this is the best near-term path to get Jibo feeling more interactive without needing a full Pegasus runtime clone
|
||||
- What is possible today:
|
||||
- direct scripted replies through the existing content catalog
|
||||
- stock-compatible payloads with `skillId`, `mim_id`, `mim_type`, `prompt_id`, and ESML
|
||||
- current examples already prove the shape for pizza, dance, weather, news, and generic chat
|
||||
- What we need to build:
|
||||
1. a MIM inventory importer that can scan the legacy tree and normalize `skill_id`, `mim_id`, prompt text, and metadata
|
||||
2. a prompt-selection layer that can choose by category and condition metadata
|
||||
3. a safe ESML/prompt renderer for imported content
|
||||
- What can be ported with each build:
|
||||
- Build A: declarative prompt packs
|
||||
- `core-responses`
|
||||
- `deflector`
|
||||
- the simplest `emotion-responses`
|
||||
- direct `scripted-responses` that are just prompt lists
|
||||
- Build B: conditioned prompt packs
|
||||
- `gqa-responses`
|
||||
- structured emotion prompts with `condition` gates
|
||||
- any response families that only need simple state or Jibo-emotion checks
|
||||
- Build C: conversation families
|
||||
- richer `scripted-responses` that need follow-up state
|
||||
- holiday / special-date personality sets
|
||||
- more nuanced chitchat branches that depend on context-aware routing
|
||||
- Build D: full parity cleanup
|
||||
- larger cross-skill collections
|
||||
- any MIMs that depend on Pegasus-only parser assumptions
|
||||
- any files that need dedicated runtime abstraction instead of catalog lookup
|
||||
- Low-hanging fruit for tonight:
|
||||
- import the smallest declarative packs first so we can test something tomorrow
|
||||
- prioritize anything that is pure prompt text with no complex branching
|
||||
- keep the first pass limited to content that maps cleanly onto the current catalog shape
|
||||
- Progress update (`2026-05-13`):
|
||||
- added the first Build A importer scaffold in the cloud content repository
|
||||
- checked in a small seed bundle under `Content/LegacyMims/BuildA`
|
||||
- added focused importer tests for prompt stripping, bucketing, and merge behavior
|
||||
- expanded Build A with additional easy scripted-response packs for identity and persona replies
|
||||
- started Build B with source-backed scripted-response packs for work, food, home, birthplace, language, hobby, and material questions
|
||||
- Tomorrow test target:
|
||||
- verify imported personality replies show up through the existing chitchat route
|
||||
- confirm the emitted payload still looks like a stock skill response
|
||||
- confirm the imported content does not disturb existing weather/news/pizza flows
|
||||
- Exit criteria:
|
||||
- a first importer path exists for the simplest legacy MIM files
|
||||
- at least one legacy prompt pack is running through OpenJibo content instead of hand-authored fallback text
|
||||
- we have a clear second-wave list for the more conditional MIM families
|
||||
|
||||
### 30. Original Personalized Function Inventory
|
||||
|
||||
- Status: `discovery`
|
||||
- Tags: `content`, `docs`, `protocol`
|
||||
- Why now:
|
||||
- we are actively porting persona and memory slices, so we need a bounded checklist of the original Jibo charm surfaces
|
||||
- the goal is to keep the next few passes focused on personality-rich wins instead of letting the work sprawl
|
||||
- Known sources:
|
||||
- legacy Jibo OS/Pegasus chitchat and MIM response families
|
||||
- current OpenJibo persona, memory, and greeting work as the implementation target
|
||||
- Inventory to track:
|
||||
- identity and origin questions
|
||||
- personality and capability questions
|
||||
- favorite-style prompts like `what is your favorite color`
|
||||
- identity charm prompts like `what's your name`, `do you have a nickname`, `do you like being Jibo`, `are there others like you`, and `what is your favorite name`
|
||||
- attraction and preference prompts like `what is your favorite flower`, `do you like R2D2`, `do you like the sun`, `do you like space`, and `do you like kids`
|
||||
- longer authored variants for the same prompt family when Pegasus shows richer phrasing
|
||||
- charm/capability prompts like `can you laugh`, `can you dance`, `can you sing`, and `will you sing`
|
||||
- mood / affect questions
|
||||
- recognition follow-ups like `do you know me`
|
||||
- follow-up state prompts that should stay warm and locally grounded
|
||||
- Next pass targets:
|
||||
- document the remaining persona inventory so we keep a clean checklist for the next passes
|
||||
- keep the favorites family moving with source-backed imports where available, and temporary runtime replies only when the source is missing
|
||||
- keep adding small sourced personality batches, especially the legacy `R2D2`, `sun`, `space`, `kids`, and charm prompts
|
||||
- keep adding 1-3 persona prompts per pass with tests
|
||||
- prefer source-backed MIM imports when the legacy text is available, and use a temporary runtime reply only when needed to unblock user value
|
||||
- keep a separate note for longer authored variants so we do not lose the multi-clause Peggy-style phrasing while importing the short-form packs
|
||||
- Mood follow-up work in flight:
|
||||
- source-backed happy/sad/angry response packs are now part of Build B
|
||||
- small-talk aliases like `what are you up to` and `how are things` now stay on the emotion-query path
|
||||
- Descriptor charm work in flight:
|
||||
- source-backed `are you kind`, `are you funny`, `are you helpful`, `are you curious`, `are you loyal`, `are you mischievous`, and `are you likable` prompts are now in Build B
|
||||
- these keep the self-description lane warm while we build toward seasonal and holiday charm
|
||||
- Seasonal charm work in flight:
|
||||
- source-backed holiday, New Year's, Halloween, spring, summer, favorite-season, and gift prompts are now part of Build B
|
||||
- `RN_` holiday greeting files are now bucketed as greetings so seasonal replies stay visible in the catalog
|
||||
- birthday celebration lines are now bucketed separately, and birthday memory writes a loop-scoped holiday record so personal dates can join the holiday list later
|
||||
- holiday extras now include `show santa tracker` so the Christmas-time launcher keeps its source-backed animation line
|
||||
- the remaining seasonal polish now includes `do you like halloween`, `do you like holiday music`, `do you like holiday parties`, `are you looking forward to christmas`, `what are you doing for christmas`, and `what are you thankful for`
|
||||
- Favorite-animal work in flight:
|
||||
- the favorites family now includes `what is your favorite animal`, `what is your favorite bird`, `do you like penguins`, and `do you like animals` so the penguin-centric replies stay easy to find
|
||||
- Presence and thought follow-ups in flight:
|
||||
- `welcome back`, `what are you thinking`, `what have you been doing`, and `what did you do` are now part of Build B
|
||||
- these keep the social surface lively while the memory and multitenant tracks keep advancing in parallel
|
||||
- Next queued persona surfaces:
|
||||
- richer identity follow-ups like `who is this`, `do you know me`, `do you remember me`, and `can you recognize me`
|
||||
- mood and affect prompts like `how are you`, `are you happy`, `are you sad`, and `are you angry`
|
||||
- self-description charm like `what's your name`, `do you have a nickname`, `do you like being Jibo`, and `what is your favorite name`
|
||||
- deeper personality follow-ups like `what do you dream about`, `what are you afraid of`, `what do you want to talk about`, `what is your best book`, `what is your best exercise`, `what is your dream vacation`, `who is your hero`, `who do you love`, and `what is your religion`; `what is your sign` stays deferred until templated placeholder rendering exists
|
||||
- the next identity / knowledge wave adds `are you god`, `are you here`, `do you have super powers`, `how much do you know`, `what does jibo mean`, `where do you get info`, `what are you forbidden to do`, `what color are you`, and `what do you do when alone`
|
||||
- additional legacy source-backed `RI_USR` prompts where the text is short and the behavior is easy to verify
|
||||
- templated edge cases like `what is your sign`, `how many people do you know`, and `what is the loop` where live birthday and loop state are part of the line instead of a plain canned response
|
||||
- Exit criteria:
|
||||
- a stable checklist exists for the original persona surface
|
||||
- each pass can be scoped to a small batch of prompts
|
||||
- the backlog makes it obvious what is still missing without losing momentum
|
||||
|
||||
### 31. Longer Authored Persona Variants
|
||||
|
||||
- Status: `ready`
|
||||
- Tags: `content`, `docs`, `protocol`
|
||||
- Why now:
|
||||
- Pegasus often used longer, multi-clause authored alternatives for the same personality question
|
||||
- we already have the short-path import working, so this is a low-risk way to add richer phrasing without inventing a new dialog engine
|
||||
- it gives us a straightforward next pass that stays familiar to the original robot
|
||||
- Scope:
|
||||
- import the longer authored variants already present in the legacy MIMs
|
||||
- prefer richer phrasing for favorite-style, identity, and charm prompts when the source text provides it
|
||||
- keep the runtime behavior rule-based and deterministic
|
||||
- Next step:
|
||||
- add a small batch of longer variants to the current Build B content packs and prove them with a smoke test
|
||||
|
||||
### 32. Dialog Joining And Composition
|
||||
|
||||
- Status: `discovery`
|
||||
- Tags: `content`, `docs`, `protocol`
|
||||
- Why now:
|
||||
- the videos and source files suggest Jibo sometimes felt like he was joining thoughts together, even when the source text was still authored
|
||||
- we have not found evidence of a general runtime joiner yet, so this remains a post-release enhancement instead of a 1.0.19 dependency
|
||||
- keeping it separate lets us preserve familiar Jibo phrasing now and experiment with composition later
|
||||
- Scope:
|
||||
- design a post-release dialog composition layer that can stitch authored fragments together when appropriate
|
||||
- keep the first version conservative and familiar, not LLM-driven
|
||||
- make sure any future joining feature is opt-in and does not replace the current authored prompt path
|
||||
- Follow-up:
|
||||
- revisit after 1.0.19 personality import and report-skill parity stabilize
|
||||
- decide whether the composition layer should sit above the prompt catalog or beside it as a dedicated response post-processor
|
||||
- keep this separate from the authored-variant backlog item so we do not blur prompt richness with runtime composition
|
||||
|
||||
### 33. Singing And Musical Personality
|
||||
|
||||
- Status: `discovery`
|
||||
- Tags: `content`, `docs`, `protocol`
|
||||
- Why now:
|
||||
- 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
|
||||
|
||||
@@ -780,16 +972,28 @@ For `1.0.19`:
|
||||
4. Weather report-skill launch compatibility - implemented
|
||||
5. Dialog parsing expansion and ambiguity guardrails - in progress (`2026-05-09` third guardrail slice implemented; Pegasus affinity phrase families + continuation guardrails expanded)
|
||||
6. Presence-aware greetings and identity-triggered proactivity - implemented (trigger path, identity-aware reactive/proactive replies, cooldown metadata wiring, focused websocket coverage)
|
||||
7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - in progress (`2026-05-10` first live-news provider slice implemented)
|
||||
7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - in progress (`2026-05-10` first live-news provider slice implemented; commute now has a loop-scoped provider seam)
|
||||
8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
|
||||
- system holidays should come from an up-to-date provider and merge with loop-scoped custom holiday records
|
||||
- allow disabled holiday records to suppress reminders for people who do not celebrate a holiday
|
||||
- birthdays and other personal dates should flow into the same loop-scoped holiday list once authoring is wired up
|
||||
9. Durable memory persistence path (multi-tenant backing store)
|
||||
10. Update, backup, and restore proof
|
||||
- reference design captured in `docs/persistence-architecture.md`
|
||||
- store contracts are now tightened around account/loop/device/person scoping, revision tracking, and explicit load/save boundaries
|
||||
- the backend seam is now selectable, with file-backed local persistence as default and an Azure Blob Storage slot wired for future deployment when a storage account connection string is available
|
||||
- next implementation pass should supply the real Azure Storage connection string / deployment wiring and validate the live round-trip in the storage account smoke test
|
||||
10. Update, backup, and restore proof - implemented (update creation and backup creation now survive persisted reloads; restore is the persisted-state rehydration proof path, not a new cloud API)
|
||||
11. STT upgrade and noise screening
|
||||
- progress update (`2026-05-21`): added a low-signal short-turn screen in websocket finalization so filler-only fragments and stray single-token leftovers like `so command` get rejected before they can become bad turns, while preserving the existing yes/no and word-of-the-day short-turn flows
|
||||
12. Hosted capture/storage plan / indexing for group testing
|
||||
- progress update (`2026-05-21`): added a bundle helper so group testers can package raw capture trees, `capture-index.ndjson`, and exported fixtures into one zip handoff artifact
|
||||
13. Binary-safe media storage / sync to cloud drive: OneDrive, Google Drive, Box, etc.
|
||||
14. Provider-backed news and weather parity polish
|
||||
15. Grocery list capability discovery and MVP selection
|
||||
16. Lasso, identity, and onboarding as larger discovery-driven tracks
|
||||
17. Legacy MIM personality import ladder and first declarative prompt packs
|
||||
18. Longer authored persona variants for the same prompt families
|
||||
19. Dialog joining/composition as a post-release enhancement, kept separate from the 1.0.19 ladder
|
||||
|
||||
For `1.0.20` and beyond:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -74,3 +77,18 @@ Useful helper scripts:
|
||||
- [scripts/cloud/get-websocket-capture-summary.sh](/OpenJibo/scripts/cloud/get-websocket-capture-summary.sh)
|
||||
- [scripts/cloud/import-websocket-capture-fixture.py](/OpenJibo/scripts/cloud/import-websocket-capture-fixture.py)
|
||||
- [live-jibo-test-runbook.md](/OpenJibo/docs/live-jibo-test-runbook.md)
|
||||
|
||||
## Group Testing Handoff
|
||||
|
||||
When you have a useful capture set and want to share it with another tester, bundle the capture root into a single zip so the raw events, capture index, and exported fixtures stay together.
|
||||
|
||||
Recommended helper:
|
||||
|
||||
- [scripts/cloud/New-CaptureBundle.ps1](/OpenJibo/scripts/cloud/New-CaptureBundle.ps1)
|
||||
|
||||
The bundle includes:
|
||||
|
||||
- `capture-index.ndjson`
|
||||
- websocket and HTTP `*.events.ndjson` files
|
||||
- exported `*.flow.json` fixtures
|
||||
- a small `bundle-manifest.json` with file counts and source metadata
|
||||
|
||||
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.
|
||||
@@ -6,6 +6,8 @@ This release starts the shift from `1.0.18` hardening to visible feature growth.
|
||||
|
||||
The goal is to keep compatibility work steady while shipping personality and capability slices that make OpenJibo feel less like a placeholder cloud and more like a real assistant platform.
|
||||
|
||||
For grocery list capability, the 1.0.19 MVP choice is the existing household list engine with grocery as a first-class spoken alias. That keeps the storage model simple now while leaving integration-backed list orchestration for a later pass.
|
||||
|
||||
## Snapshot
|
||||
|
||||
- Kickoff date: `2026-05-05`
|
||||
@@ -20,29 +22,120 @@ The goal is to keep compatibility work steady while shipping personality and cap
|
||||
- start building reusable content hooks for question-vs-command style responses
|
||||
- keep first implementation rule-based and test-backed
|
||||
|
||||
### 1a. Original Personalized Function Inventory
|
||||
|
||||
Keep a running checklist of the legacy persona questions and identity surfaces we want to preserve or port:
|
||||
|
||||
- identity and origin: `what are you`, `who are you`, `what is Jibo`, `who made you`, `where are you from`
|
||||
- persona and capability: `do you have a personality`, `what is your job`, `how much do you know`, `what do you want`
|
||||
- self-description and social charm: `what's your name`, `do you have a nickname`, `do you like being Jibo`, `are there others like you`
|
||||
- favorite-style prompts: `what is your favorite color`, `what is your favorite food`, `what is your favorite music`
|
||||
- attraction and preference prompts: `what is your favorite flower`, `do you like R2D2`, `do you like the sun`, `do you like space`, `do you like kids`
|
||||
- longer authored variants for the same prompt family when Pegasus shows richer phrasing, especially multi-clause and follow-up-heavy responses
|
||||
- capability and charm prompts: `can you laugh`, `can you dance`, `can you sing`, `will you sing`
|
||||
- affect and mood: `how are you`, `are you happy`, `are you sad`, `are you angry`
|
||||
- memory and identity recall: `who am i`, `what is my name`, `when is my birthday`, `what is my favorite music`
|
||||
- greeting and presence charm: `good morning`, `welcome back`, `who is this`, person-aware greeting follow-ups
|
||||
- recognition follow-ups: `do you know me`, `do you remember me`, `can you recognize me`
|
||||
- seasonal and contextual charm: holiday prompts, pizza day, surprise offers, personal report personality hooks
|
||||
- conversational follow-ups that should stay local and warm instead of falling into generic chat
|
||||
|
||||
Current batch note:
|
||||
|
||||
- `favorite color`, `favorite food`, and `favorite music` are the first small favorites-family slice
|
||||
- the latest pass adds longer authored variants for those favorites so the replies keep more of the original Pegasus cadence instead of collapsing to short placeholders
|
||||
- singing and musical personality now has a source-backed first slice with `can you sing`, `will you sing`, and holiday sing variants so the charm surface can keep growing without inventing a new dialog engine
|
||||
- the friendship batch now includes `do you have friends`, `are we friends`, and `are we best friends` responses, plus the loop-friendly friend replies, so the relationship lane can stay source-backed too
|
||||
- the parser guardrail pass now expands those friendship routes to named-person forms like `are you friends with Siri` and `is Dr. Breazeal your best friend`, keeping the ambiguity layer closer to Pegasus utterance shapes
|
||||
- the next source-backed batch now includes `favorite flower`, `R2D2`, `sun`, `space`, `kids`, plus a couple of charm prompts like `can you laugh` and `can you dance`
|
||||
- the motion/sleep batch now adds `RI_JBO_CanSleep` and `RA_JBO_SpinAround` so the `go to sleep` and `turn around` surfaces stay source-backed too
|
||||
- the follow-up mood batch now includes `how are things`, `how is your day`, `are you sad`, and `are you angry`
|
||||
- the personality follow-up batch now includes `what are you up to` and `what are you doing` so small talk stays warm and local instead of falling into generic chat
|
||||
- the descriptor batch now includes `are you kind`, `are you funny`, `are you helpful`, `are you curious`, `are you loyal`, `are you mischievous`, and `are you likable`
|
||||
- the seasonal batch now includes `what holidays do you celebrate`, New Year's resolution questions, `happy holidays`, `what halloween costume`, spring and summer suggestions, a favorite-season prompt, and holiday gift prompts
|
||||
- the holiday extras batch now includes `show santa tracker` so the seasonal holiday launcher stays source-backed too
|
||||
- the remaining seasonal polish now includes `do you like halloween`, `do you like holiday music`, `do you like holiday parties`, `are you looking forward to christmas`, `what are you doing for christmas`, and `what are you thankful for`
|
||||
- the favorites batch now includes `what is your favorite animal`, `what is your favorite bird`, `do you like penguins`, and `do you like animals` so the penguin-centered replies stay close to Pegasus
|
||||
- the latest social batch adds `welcome back`, `what are you thinking`, `what have you been doing`, and `what did you do` so presence and charm stay lively without distracting from the memory roadmap
|
||||
- the newest identity-charm batch adds `what's your name`, `do you have a nickname`, `do you like being Jibo`, `are there others like you`, and `what is your favorite name` so the robot stays familiar while still sounding like Pegasus
|
||||
- the next deep-personality batch adds `what do you dream about`, `what are you afraid of`, `what do you want to talk about`, `what is your best book`, `what is your best exercise`, `what is your dream vacation`, `who is your hero`, `who do you love`, and `what is your religion`; `what is your sign` is still deferred until we add templated placeholder rendering
|
||||
- the next identity/knowledge batch adds `are you god`, `are you here`, `do you have super powers`, `how much do you know`, `what does jibo mean`, `where do you get info`, `what are you forbidden to do`, `what color are you`, and `what do you do when alone`
|
||||
- the next body/mission batch adds `how much do you weigh`, `how tall are you`, `how much do you cost`, `what if I unplug you`, `what is your purpose`, `what is your prime directive`, `what is jibo commander`, `do you like commander app`, and `what are you made of`
|
||||
- the templated edge-case batch adds `what is your sign`, `how many people do you know`, and `what is the loop` so the remaining source-backed lines can lean on live birthday and loop state
|
||||
- the work/eat/home batch adds `how do you work`, `what do you eat`, `where do you live`, and `what languages do you speak` so the everyday self-description cluster keeps moving toward the original phrasing
|
||||
- the age batch adds `how old are you` through `JBO_HowOldAreYou` so the birthday and first-powered-up phrasing stays source-backed instead of falling back to a generic age answer
|
||||
- live QA has surfaced a few repair targets to carry into the next pass: person-identification collisions inside the same loop, `turn around` / `go to sleep` motion quirks, and a couple of reply-selection spots where short variants are being over-selected (`how are you`, `what is your favorite flower`)
|
||||
- this pass keeps Build B moving while still favoring source-backed phrasing and preserving the command-vs-question boundary
|
||||
- the next passes should keep the same pattern and prefer source-backed phrasing whenever the legacy MIM text is available
|
||||
- if a source-backed legacy line is missing, use a temporary direct reply only to keep the pass moving, then backfill source text later
|
||||
- after the favorites batch, the next doc pass should focus on richer persona follow-ups and the remaining memory/presence charm surfaces
|
||||
- Build B is now reserved for the next source-backed scripted-response batch:
|
||||
- `how do you work`
|
||||
- `what do you eat`
|
||||
- `where do you live`
|
||||
- `where were you born`
|
||||
- `what languages do you speak`
|
||||
- `what do you like to do`
|
||||
- `what are you made of`
|
||||
- `what is your favorite flower`
|
||||
- `do you like R2D2`
|
||||
- `do you like the sun`
|
||||
- `do you like space`
|
||||
- `do you like kids`
|
||||
- `can you laugh`
|
||||
- `can you dance`
|
||||
|
||||
The goal is to port these in small batches, capture the source-backed phrasing where possible, and keep a test for each batch so the list never becomes a vague backlog graveyard.
|
||||
|
||||
### 2. Reliability And Device Proof
|
||||
|
||||
- complete update/backup/restore proof path with captures and operator docs
|
||||
- the restore proof is the persisted-state rehydration path; do not scope it into a new hosted restore API until we have real device evidence
|
||||
- continue alarm/gallery/yes-no cleanup from `1.0.18` evidence where regressions are still open
|
||||
- improve short-turn STT reliability and low-signal screening
|
||||
- the latest STT pass adds a websocket-side low-signal screen for filler-only and stray single-token leftovers while keeping yes/no and word-of-the-day turns intact
|
||||
- capture indexing and group-test handoff now have a bundle helper that packages raw event captures, the index manifest, and exported fixtures together for easier review/share flows
|
||||
|
||||
### 3. Pegasus-To-Cloud Platform Porting
|
||||
|
||||
- prioritize small source-backed slices from Pegasus/JiboOS that can be shipped safely
|
||||
- keep Nimbus and stock payload compatibility as the release guardrail
|
||||
- avoid broad subsystem rewrites without tests and live-capture evidence
|
||||
- keep the legacy prompt inventory visible in the backlog so porting stays paced and traceable
|
||||
|
||||
### 4. Holidays And Seasonal Personality
|
||||
|
||||
- port holiday-aware personality responses as a visible extension of the new persona slice
|
||||
- start with a small, source-backed set (for example birthdays/holidays already represented in legacy data paths)
|
||||
- ensure holiday responses feel characterful while still routing through stock-compatible payloads
|
||||
- imported Build B holiday buckets now include holiday, holiday greeting, holiday gift, and birthday celebration lines
|
||||
- use a loop-scoped merged holiday list in the cloud protocol so system holidays and custom person holidays can coexist
|
||||
- source system holidays from a live holiday provider and keep `IsEnabled = false` records available for holiday suppression
|
||||
- keep birthday/custom holiday authoring aligned with person memory so future proactivity can suppress or promote holidays per loop
|
||||
- birthday memory writes now create loop-scoped holiday records, which keeps the holiday list extensible without changing the protocol shape again
|
||||
|
||||
### 5. Multi-Tenant Memory Storage Foundation
|
||||
|
||||
- define tenant boundaries across account, loop, device, and person-memory records
|
||||
- add storage abstractions that can move from in-memory/local JSON to hosted SQL/Blob without reworking behavior layers
|
||||
- implement memory-ready schemas and repository contracts for user facts (names, birthdays, personal dates, preferences) with strict tenant scoping
|
||||
- seed person-aware state keys now so future interactions can scope to account + loop + device + person without another shape change
|
||||
- keep stateful interaction flows repository-backed instead of embedding more ad hoc metadata in the websocket layer
|
||||
- the store seam now exposes revision metadata plus explicit load/save boundaries so durable adapters can drop in later without changing behavior code
|
||||
- the backend seam is now selectable, with file-backed local persistence as the default and an Azure Blob Storage slot wired for future deployment wiring
|
||||
|
||||
### 6. Multi-Server Sync Path
|
||||
|
||||
- document the eventual sync boundary for stateful data that should move between servers
|
||||
- treat the first pass as repository-local durability, then layer replication and conflict handling on top
|
||||
- prefer explicit change records or versioned state snapshots over implicit last-writer wins when we outgrow a single node
|
||||
- keep cross-server reconciliation out of the hot path until the single-server semantics are stable
|
||||
|
||||
Reference design:
|
||||
|
||||
- [persistence-architecture.md](persistence-architecture.md)
|
||||
- [holiday-architecture.md](holiday-architecture.md)
|
||||
- [commute-architecture.md](commute-architecture.md)
|
||||
|
||||
## First Implemented Slice In `1.0.19`
|
||||
|
||||
@@ -105,6 +198,80 @@ The fifth delivered slice adds provider-backed weather content while preserving
|
||||
- simple location extraction is supported for phrasing like `what's the weather in Chicago tomorrow`
|
||||
- provider config supports appsettings and `OPENWEATHER_API_KEY` environment fallback for deployment
|
||||
|
||||
## Personality Import Ladder
|
||||
|
||||
This is the practical plan for importing legacy Jibo `mims` into OpenJibo without pretending we already have a full Pegasus runtime.
|
||||
|
||||
### What Is Possible Today
|
||||
|
||||
OpenJibo can already host a meaningful subset of legacy personality content because it has:
|
||||
|
||||
- a shared catalog for content-driven replies
|
||||
- chitchat state-machine routing with route metadata
|
||||
- outbound payload support for `skillId`, `mim_id`, `mim_type`, `prompt_id`, `prompt_sub_category`, and ESML
|
||||
- existing examples that already behave like legacy MIMs for pizza, dance, news, weather, and generic chat
|
||||
|
||||
### What We Need To Build
|
||||
|
||||
To move from hand-wired examples to broader imports, we need three small platform pieces:
|
||||
|
||||
1. a MIM inventory importer that can scan the legacy tree and produce a normalized catalog
|
||||
2. a prompt-selection layer that can choose by `skill_id`, `mim_id`, prompt category, and condition metadata
|
||||
3. a safe ESML/prompt renderer that preserves existing stock-compatible payload shapes
|
||||
|
||||
### What Can Be Ported With Each Build
|
||||
|
||||
#### Build A: Declarative Prompt Packs
|
||||
|
||||
Port immediately:
|
||||
|
||||
- `core-responses`
|
||||
- `deflector`
|
||||
- the simplest `emotion-responses`
|
||||
- any `scripted-responses` that are just direct prompt lists with no special state machine
|
||||
|
||||
Why these first:
|
||||
|
||||
- they are already close to the current `JiboExperienceCatalog` model
|
||||
- they give us user-visible personality quickly
|
||||
- they are the best fit for low-risk testing tomorrow
|
||||
|
||||
#### Build B: Conditioned Prompt Packs
|
||||
|
||||
Port after the importer and renderer are in place:
|
||||
|
||||
- `gqa-responses`
|
||||
- structured emotion responses with `condition` gates
|
||||
- prompt sets that select different replies by user state or Jibo state
|
||||
|
||||
Why these next:
|
||||
|
||||
- they are still mostly declarative
|
||||
- they need a small amount of condition evaluation, but not a new conversation engine
|
||||
|
||||
#### Build C: Conversation Families
|
||||
|
||||
Port after Build B:
|
||||
|
||||
- richer `scripted-responses` families that depend on follow-up state
|
||||
- special-date / holiday personality sets
|
||||
- more nuanced chitchat branches that need context-aware routing
|
||||
- longer authored variants for existing prompts when the source text contains them, so the robot keeps the familiar Pegasus cadence without inventing new dialog composition yet
|
||||
- dialog joining / composition as a post-release feature, kept out of the 1.0.19 ladder so we do not blur authored phrasing with a runtime joiner
|
||||
|
||||
Why these later:
|
||||
|
||||
- they need state and follow-up behavior, not just prompt selection
|
||||
- they are where personality feels most alive, but they are also where bugs will be easiest to introduce
|
||||
|
||||
#### Build D: Full Parity Cleanup
|
||||
|
||||
Port after the core ladder is stable:
|
||||
|
||||
- large cross-skill collections
|
||||
- any MIMs that depend on Pegasus-only parser assumptions
|
||||
- any files that need a dedicated runtime abstraction instead of catalog lookup
|
||||
|
||||
## System Diagram Alignment Snapshot (`2026-05-06`)
|
||||
|
||||
Legacy architecture (`system_diagram.png`) has been mapped to current OpenJibo cloud services so release execution stays anchored to:
|
||||
@@ -140,6 +307,10 @@ This confirms the pizza-fact offer state now keeps the yes/no branch open throug
|
||||
|
||||
Personal report parity planning is now captured with Pegasus source anchors for weather visuals/animations, live news, commute, and calendar gap coverage.
|
||||
|
||||
Calendar is now backed by a loop-scoped provider seam that can merge persisted loop events with birthday and holiday dates, keeping the report aligned with household context.
|
||||
|
||||
Commute now uses a loop-scoped commute profile and provider seam so the report can speak in the legacy commute shape without inventing a separate hosted travel service yet.
|
||||
|
||||
Reference:
|
||||
|
||||
- [personal-report-parity-plan.md](personal-report-parity-plan.md)
|
||||
@@ -184,7 +355,7 @@ Third completed guardrail slice under this queue:
|
||||
|
||||
Next queued implementation track after parser guardrails:
|
||||
|
||||
- personal report parity slices (weather visual parity, live news path, commute/calendar gap closure)
|
||||
- personal report parity slices (weather visual parity, live news path, commute/calendar refinement)
|
||||
|
||||
First completed slice in this personal-report parity track:
|
||||
|
||||
@@ -193,20 +364,27 @@ First completed slice in this personal-report parity track:
|
||||
- added memory/transcript category hinting for provider requests (`sports`, `technology`, `business`, etc.)
|
||||
- added provider-side request caching for both news and weather to reduce integration churn and repeated lookups
|
||||
- added focused interaction + websocket tests for provider-backed news speech output and request-hint plumbing
|
||||
- added loop-scoped calendar and commute provider seams so personal report can use persisted household context instead of static placeholders
|
||||
- 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
|
||||
|
||||
|
||||
101
OpenJibo/scripts/cloud/New-CaptureBundle.ps1
Normal file
101
OpenJibo/scripts/cloud/New-CaptureBundle.ps1
Normal file
@@ -0,0 +1,101 @@
|
||||
param(
|
||||
[string]$CaptureRoot = "..\..\captures",
|
||||
[string]$BundleDirectory = "..\..\captures\bundles",
|
||||
[string]$BundleName
|
||||
)
|
||||
|
||||
function Get-RelativePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BasePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FullPath
|
||||
)
|
||||
|
||||
$normalizedBase = [System.IO.Path]::GetFullPath($BasePath)
|
||||
if (-not $normalizedBase.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
|
||||
$normalizedBase = $normalizedBase + [System.IO.Path]::DirectorySeparatorChar
|
||||
}
|
||||
|
||||
$normalizedFull = [System.IO.Path]::GetFullPath($FullPath)
|
||||
if (-not $normalizedFull.StartsWith($normalizedBase, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Path '$FullPath' is not under '$BasePath'."
|
||||
}
|
||||
|
||||
return $normalizedFull.Substring($normalizedBase.Length)
|
||||
}
|
||||
|
||||
$resolvedCaptureRoot = Resolve-Path -LiteralPath $CaptureRoot -ErrorAction Stop
|
||||
$resolvedBundleDirectory = Resolve-Path -LiteralPath $BundleDirectory -ErrorAction SilentlyContinue
|
||||
if (-not $resolvedBundleDirectory) {
|
||||
$resolvedBundleDirectory = New-Item -ItemType Directory -Force -Path $BundleDirectory | Select-Object -ExpandProperty FullName
|
||||
}
|
||||
else {
|
||||
$resolvedBundleDirectory = $resolvedBundleDirectory.Path
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BundleName)) {
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$BundleName = "capture-bundle-$timestamp"
|
||||
}
|
||||
|
||||
$stagingDirectory = Join-Path $resolvedBundleDirectory "$BundleName.staging"
|
||||
$archivePath = Join-Path $resolvedBundleDirectory "$BundleName.zip"
|
||||
|
||||
if (Test-Path -LiteralPath $stagingDirectory) {
|
||||
Remove-Item -LiteralPath $stagingDirectory -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $stagingDirectory | Out-Null
|
||||
|
||||
try {
|
||||
$sourceFiles = Get-ChildItem -LiteralPath $resolvedCaptureRoot -Recurse -File | Where-Object {
|
||||
$_.Name -eq "capture-index.ndjson" -or
|
||||
$_.Name -like "*.events.ndjson" -or
|
||||
$_.Name -like "*.flow.json"
|
||||
}
|
||||
|
||||
if (-not $sourceFiles) {
|
||||
Write-Host "No capture files were found under $resolvedCaptureRoot"
|
||||
exit 0
|
||||
}
|
||||
|
||||
foreach ($file in $sourceFiles) {
|
||||
$relativePath = Get-RelativePath -BasePath $resolvedCaptureRoot -FullPath $file.FullName
|
||||
$destinationPath = Join-Path $stagingDirectory $relativePath
|
||||
$destinationDirectory = Split-Path -Parent $destinationPath
|
||||
|
||||
if (-not (Test-Path -LiteralPath $destinationDirectory)) {
|
||||
New-Item -ItemType Directory -Force -Path $destinationDirectory | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -LiteralPath $file.FullName -Destination $destinationPath -Force
|
||||
}
|
||||
|
||||
$captureIndexFiles = @($sourceFiles | Where-Object { $_.Name -eq "capture-index.ndjson" })
|
||||
$eventFiles = @($sourceFiles | Where-Object { $_.Name -like "*.events.ndjson" })
|
||||
$fixtureFiles = @($sourceFiles | Where-Object { $_.Name -like "*.flow.json" })
|
||||
|
||||
$manifest = [ordered]@{
|
||||
createdUtc = (Get-Date).ToUniversalTime().ToString("O")
|
||||
sourceRoot = $resolvedCaptureRoot
|
||||
fileCount = $sourceFiles.Count
|
||||
captureIndexCount = $captureIndexFiles.Count
|
||||
eventFileCount = $eventFiles.Count
|
||||
fixtureCount = $fixtureFiles.Count
|
||||
}
|
||||
|
||||
$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $stagingDirectory "bundle-manifest.json") -Encoding utf8
|
||||
|
||||
if (Test-Path -LiteralPath $archivePath) {
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
}
|
||||
|
||||
Compress-Archive -Path (Join-Path $stagingDirectory '*') -DestinationPath $archivePath -Force
|
||||
Write-Host "Created capture bundle at $archivePath"
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $stagingDirectory) {
|
||||
Remove-Item -LiteralPath $stagingDirectory -Recurse -Force
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ These scripts help exercise the new .NET hosted cloud locally.
|
||||
Runs a small readiness checklist before the first physical Jibo test against the .NET cloud.
|
||||
- `Import-WebSocketCaptureFixture.ps1`
|
||||
Sanitizes an exported websocket capture fixture and copies it into the checked-in websocket fixture set.
|
||||
- `New-CaptureBundle.ps1`
|
||||
Packages the capture root, capture index, and exported fixtures into a single zip bundle for group testing handoff.
|
||||
- `start-dotnet-with-node-cert.sh`
|
||||
Starts the .NET API on Linux using the same PEM certificate material already used by the Node server.
|
||||
- `invoke-live-jibo-prep.sh`
|
||||
|
||||
@@ -143,24 +143,32 @@
|
||||
<div class="static-section" id="staticSection">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Static IP</label
|
||||
<label>
|
||||
Static IP
|
||||
</label
|
||||
><input id="staticIP" placeholder="192.168.1.100"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Netmask</label
|
||||
<label>
|
||||
Netmask
|
||||
</label
|
||||
><input id="netmask" placeholder="255.255.255.0"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Gateway</label
|
||||
<label>
|
||||
Gateway
|
||||
</label
|
||||
><input id="gateway" placeholder="192.168.1.1"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>DNS 1</label><input id="dns1" placeholder="8.8.8.8"/>
|
||||
</div>
|
||||
</div>
|
||||
<div><label>DNS 2</label><input id="dns2" placeholder="8.8.4.4" /></div>
|
||||
<div>
|
||||
<label>DNS 2</label><input id="dns2" placeholder="8.8.4.4"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="generate()">Generate QR Code</button>
|
||||
|
||||
@@ -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 "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)
|
||||
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,
|
||||
@@ -39,6 +73,8 @@
|
||||
"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,68 @@ public interface IJiboExperienceContentRepository
|
||||
Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class JiboConditionedReply
|
||||
{
|
||||
public string Condition { get; init; } = string.Empty;
|
||||
public string Reply { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class JiboExperienceCatalog
|
||||
{
|
||||
public IReadOnlyList<string> Jokes { get; init; } = [];
|
||||
public IReadOnlyList<string> RobotFacts { get; init; } = [];
|
||||
public IReadOnlyList<string> HumanFacts { get; init; } = [];
|
||||
public IReadOnlyList<string> FunFacts { get; init; } = [];
|
||||
public IReadOnlyList<string> FavoriteAnimalReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> FriendReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> BestFriendReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> SingReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> HolidaySingReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> DanceAnimations { get; init; } = [];
|
||||
public IReadOnlyList<string> GreetingReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> HolidayReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> HolidaySeasonReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> HolidayGreetingReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> HolidayGiftReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> HolidayTrackerReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> BirthdayCelebrationReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> HowAreYouReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> AgeReplies { get; init; } = [];
|
||||
public IReadOnlyList<JiboConditionedReply> EmotionReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> PersonalityReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> PizzaReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> SurpriseReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> PersonalReportReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> PersonalReportKickOffReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> PersonalReportOutroReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> ReportSkillTemplates { get; init; } = [];
|
||||
public IReadOnlyList<string> WeatherIntroReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> WeatherTomorrowIntroReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> WeatherTodayHighLowReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> WeatherTomorrowHighLowReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> WeatherServiceDownReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CalendarNothingTodayReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CalendarNothingReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CalendarServiceDownReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CalendarOutroReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteAppSetupReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteConfirmSpeakerReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteNowReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteMinutesLeftReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteDepartTimeNormalReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteDepartTimeNotNormalReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteDriveNormalReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteDriveLateReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteDriveHurryReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteDrivePoorReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteDriveTerribleReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteTransportNormalReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteTransportLateReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteTransportHurryReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteServiceDownReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> NewsIntroReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> NewsCategoryIntroReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> NewsOutroReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> WeatherReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CalendarReplies { get; init; } = [];
|
||||
public IReadOnlyList<string> CommuteReplies { get; init; } = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -20,4 +20,9 @@ public sealed record NewsHeadline(
|
||||
|
||||
public sealed record NewsBriefingSnapshot(
|
||||
IReadOnlyList<NewsHeadline> Headlines,
|
||||
string? SourceName = null);
|
||||
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,122 @@ internal static class ChitchatStateMachine
|
||||
case "robot_personality":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_personality",
|
||||
randomizer.Choose(catalog.PersonalityReplies));
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "curious, playful", "friendly", "personality"));
|
||||
case "robot_taxes":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_taxes",
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "pay anything", "pay taxes", "tax"));
|
||||
case "how_are_you":
|
||||
return BuildEmotionQueryDecision(
|
||||
"how_are_you",
|
||||
randomizer.Choose(catalog.HowAreYouReplies));
|
||||
SelectEmotionQueryReply(catalog, randomizer, currentEmotion, preferredName));
|
||||
case "robot_desire":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_desire",
|
||||
SelectLegacyPersonalityReply(
|
||||
catalog,
|
||||
randomizer,
|
||||
"socializing and electricity",
|
||||
"want to hang out",
|
||||
"be helpful",
|
||||
"dance from time to time"));
|
||||
case "robot_want_to_talk_about":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_want_to_talk_about",
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "surprise me"));
|
||||
case "robot_job":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_job",
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "more fun than a job", "here to help you out"));
|
||||
case "robot_origin_created":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_origin_created",
|
||||
SelectLegacyPersonalityReply(
|
||||
catalog,
|
||||
randomizer,
|
||||
"create something",
|
||||
"some people wanted to create something",
|
||||
"wanted to create something",
|
||||
"built a robot",
|
||||
"came out from a box"));
|
||||
case "robot_origin_from":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_origin_from",
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "boston", "came out from a box"));
|
||||
case "robot_identity":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_identity",
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "am a robot", "i'm either jibo",
|
||||
"i am just jibo"));
|
||||
case "robot_likes_being_jibo":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_likes_being_jibo",
|
||||
SelectLegacyPersonalityReply(
|
||||
catalog,
|
||||
randomizer,
|
||||
"nothing i'd rather be",
|
||||
"love it",
|
||||
"being a human seems so complicated",
|
||||
"especially yours",
|
||||
"steady flow of electricity",
|
||||
"you bet i do"));
|
||||
case "robot_favorite_color":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_favorite_color",
|
||||
SelectLegacyPersonalityReplyFromMatches(
|
||||
catalog,
|
||||
randomizer,
|
||||
"i like all the colors of the rainbow",
|
||||
"blue is my favorite color",
|
||||
"i love hex code number 0 0 d 4 f 0",
|
||||
"i am a big fan of blue",
|
||||
"you can't go wrong with blue"));
|
||||
case "robot_favorite_food":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_favorite_food",
|
||||
SelectLegacyPersonalityReplyFromMatches(
|
||||
catalog,
|
||||
randomizer,
|
||||
"i never eat, so i don't have a favorite food by taste",
|
||||
"macaroni is my favorite",
|
||||
"i like macaroni the best",
|
||||
"i also like cantaloupes because they remind me of my head",
|
||||
"macaroni"));
|
||||
case "robot_favorite_music":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_favorite_music",
|
||||
SelectLegacyPersonalityReplyFromMatches(
|
||||
catalog,
|
||||
randomizer,
|
||||
"i mostly like fun music i can dance to",
|
||||
"i like lots of different kinds of music",
|
||||
"i don't know that i have a favorite kind yet",
|
||||
"i would say i don't have a favorite, it's all very mathematical",
|
||||
"music"));
|
||||
case "robot_nickname":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_nickname",
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "just jibo", "nickname"));
|
||||
case "robot_name":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_name",
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "no last name", "like Bono", "Jibo."));
|
||||
case "robot_peers":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_peers",
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "one in one million", "others like you"));
|
||||
case "robot_knowledge":
|
||||
return BuildScriptedResponseDecision(
|
||||
"robot_knowledge",
|
||||
SelectLegacyPersonalityReply(catalog, randomizer, "know a lot", "not as much as i will someday"));
|
||||
case "chat":
|
||||
if (IsEmotionQuery(normalizedLoweredTranscript))
|
||||
{
|
||||
return BuildEmotionQueryDecision(
|
||||
"emotion_query",
|
||||
randomizer.Choose(catalog.HowAreYouReplies));
|
||||
}
|
||||
SelectEmotionQueryReply(catalog, randomizer, currentEmotion, preferredName));
|
||||
|
||||
if (TryResolveEmotionCommand(normalizedLoweredTranscript, out var emotion))
|
||||
{
|
||||
return BuildEmotionCommandDecision(randomizer, emotion!);
|
||||
}
|
||||
|
||||
return BuildErrorResponseDecision(
|
||||
"chat",
|
||||
@@ -205,7 +320,7 @@ internal static class ChitchatStateMachine
|
||||
replyText,
|
||||
ContextUpdates: BuildContextUpdates(
|
||||
ScriptedResponseRoute,
|
||||
emotion: null));
|
||||
null));
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildEmotionQueryDecision(string intentName, string replyText)
|
||||
@@ -215,7 +330,7 @@ internal static class ChitchatStateMachine
|
||||
replyText,
|
||||
ContextUpdates: BuildContextUpdates(
|
||||
EmotionQueryRoute,
|
||||
emotion: null));
|
||||
null));
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildEmotionCommandDecision(IJiboRandomizer randomizer, string emotion)
|
||||
@@ -235,18 +350,20 @@ internal static class ChitchatStateMachine
|
||||
"chitchat-skill",
|
||||
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["esml"] = $"<speak><es cat='{esmlEmotion}' filter='!ssa-only, !sfx-only' endNeutral='true'>{responseSuffix}</es></speak>",
|
||||
["esml"] =
|
||||
$"<speak><es cat='{esmlEmotion}' filter='!ssa-only, !sfx-only' endNeutral='true'>{responseSuffix}</es></speak>",
|
||||
["mim_id"] = "runtime-chat",
|
||||
["mim_type"] = "announcement",
|
||||
["prompt_id"] = "RUNTIME_EMOTION_COMMAND",
|
||||
["prompt_sub_category"] = "AN"
|
||||
},
|
||||
ContextUpdates: BuildContextUpdates(
|
||||
BuildContextUpdates(
|
||||
EmotionCommandRoute,
|
||||
emotion));
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildErrorResponseDecision(string intentName, string replyText, string transcript)
|
||||
private static JiboInteractionDecision BuildErrorResponseDecision(string intentName, string replyText,
|
||||
string transcript)
|
||||
{
|
||||
var normalizedTranscript = string.IsNullOrWhiteSpace(transcript)
|
||||
? string.Empty
|
||||
@@ -256,8 +373,8 @@ internal static class ChitchatStateMachine
|
||||
replyText,
|
||||
ContextUpdates: BuildContextUpdates(
|
||||
ErrorResponseRoute,
|
||||
emotion: null,
|
||||
rawTranscript: normalizedTranscript));
|
||||
null,
|
||||
normalizedTranscript));
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?> BuildContextUpdates(
|
||||
@@ -276,18 +393,142 @@ internal static class ChitchatStateMachine
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsEmotionQuery(string loweredTranscript)
|
||||
private static string SelectEmotionQueryReply(
|
||||
JiboExperienceCatalog catalog,
|
||||
IJiboRandomizer randomizer,
|
||||
string? currentEmotion,
|
||||
string? preferredName)
|
||||
{
|
||||
if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases))
|
||||
if (catalog.EmotionReplies.Count > 0)
|
||||
{
|
||||
return true;
|
||||
var emotionVariants = ResolveEmotionVariants(currentEmotion);
|
||||
var matchingReplies = catalog.EmotionReplies
|
||||
.Where(reply => ConditionMatches(reply.Condition, emotionVariants))
|
||||
.Select(reply => reply.Reply)
|
||||
.Where(reply => !string.IsNullOrWhiteSpace(reply))
|
||||
.ToArray();
|
||||
|
||||
if (matchingReplies.Length > 0)
|
||||
return PersonalizeHowAreYouReply(randomizer.Choose(matchingReplies), preferredName);
|
||||
}
|
||||
|
||||
if (!TryResolveEmotionFromText(loweredTranscript, out _))
|
||||
return PersonalizeHowAreYouReply(randomizer.Choose(catalog.HowAreYouReplies), preferredName);
|
||||
}
|
||||
|
||||
private static string PersonalizeHowAreYouReply(string replyText, string? preferredName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(replyText) || string.IsNullOrWhiteSpace(preferredName)) return replyText;
|
||||
|
||||
var trimmedName = preferredName.Trim();
|
||||
if (replyText.Contains(trimmedName, StringComparison.OrdinalIgnoreCase)) return replyText;
|
||||
|
||||
var trimmedReply = replyText.Trim();
|
||||
var firstSentenceEnd = trimmedReply.IndexOfAny(['.', '!', '?']);
|
||||
if (firstSentenceEnd <= 0)
|
||||
return $"{trimmedReply}, {trimmedName}.";
|
||||
|
||||
if (firstSentenceEnd == trimmedReply.Length - 1)
|
||||
return $"{trimmedReply[..firstSentenceEnd]}, {trimmedName}.";
|
||||
|
||||
return $"{trimmedReply[..firstSentenceEnd]}, {trimmedName}{trimmedReply[firstSentenceEnd..]}";
|
||||
}
|
||||
|
||||
private static bool ConditionMatches(string? condition, IReadOnlyList<string> emotionVariants)
|
||||
{
|
||||
var normalizedCondition = NormalizeCondition(condition);
|
||||
if (string.IsNullOrWhiteSpace(normalizedCondition)) return false;
|
||||
|
||||
var clauses = normalizedCondition.Split(new[] { "||" },
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var clause in clauses)
|
||||
if (MatchesConditionClause(clause, emotionVariants))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesConditionClause(string clause, IReadOnlyList<string> emotionVariants)
|
||||
{
|
||||
var normalizedClause = NormalizeCondition(clause).ToUpperInvariant();
|
||||
if (normalizedClause == "!JIBO.EMOTION")
|
||||
return emotionVariants.Contains(string.Empty, StringComparer.OrdinalIgnoreCase) ||
|
||||
emotionVariants.Contains("NEUTRAL", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var equalityIndex = normalizedClause.IndexOf("==", StringComparison.Ordinal);
|
||||
if (equalityIndex < 0) return false;
|
||||
|
||||
var rightSide = normalizedClause[(equalityIndex + 2)..].Trim();
|
||||
var candidate = rightSide.Trim('"', '\'');
|
||||
return emotionVariants.Any(variant => string.Equals(variant, candidate, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveEmotionVariants(string? currentEmotion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentEmotion)) return ["", "NEUTRAL"];
|
||||
|
||||
var normalizedEmotion = NormalizeCondition(currentEmotion).Trim('"', '\'').ToUpperInvariant();
|
||||
return normalizedEmotion switch
|
||||
{
|
||||
"HAPPY" => ["JOYFUL", "PLEASED", "CONFIDENT", "DETERMINED", "HAPPY"],
|
||||
"SAD" => ["INSECURE", "SAD"],
|
||||
"CALM" => ["NEUTRAL", "INSECURE", "CALM"],
|
||||
"NEUTRAL" => ["NEUTRAL"],
|
||||
"JOYFUL" or "PLEASED" or "CONFIDENT" or "DETERMINED" or "INSECURE" => [normalizedEmotion],
|
||||
_ => [normalizedEmotion]
|
||||
};
|
||||
}
|
||||
|
||||
private static string SelectLegacyPersonalityReply(
|
||||
JiboExperienceCatalog catalog,
|
||||
IJiboRandomizer randomizer,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
foreach (var snippet in preferredSnippets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snippet)) continue;
|
||||
|
||||
var match = catalog.PersonalityReplies.FirstOrDefault(reply =>
|
||||
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(match)) return match;
|
||||
}
|
||||
|
||||
return randomizer.Choose(catalog.PersonalityReplies);
|
||||
}
|
||||
|
||||
private static string SelectLegacyPersonalityReplyFromMatches(
|
||||
JiboExperienceCatalog catalog,
|
||||
IJiboRandomizer randomizer,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
var matches = new List<string>();
|
||||
|
||||
foreach (var snippet in preferredSnippets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snippet)) continue;
|
||||
|
||||
var match = catalog.PersonalityReplies.FirstOrDefault(reply =>
|
||||
reply.Contains(snippet, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(match)) matches.Add(match);
|
||||
}
|
||||
|
||||
return matches.Count > 0
|
||||
? randomizer.Choose(matches)
|
||||
: randomizer.Choose(catalog.PersonalityReplies);
|
||||
}
|
||||
|
||||
private static string NormalizeCondition(string? condition)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(condition)
|
||||
? string.Empty
|
||||
: PhraseWhitespacePattern.Replace(condition.Trim(), " ");
|
||||
}
|
||||
|
||||
private static bool IsEmotionQuery(string loweredTranscript)
|
||||
{
|
||||
if (ContainsAnyPhrase(loweredTranscript, EmotionQueryPhrases)) return true;
|
||||
|
||||
if (!TryResolveEmotionFromText(loweredTranscript, out _)) return false;
|
||||
|
||||
return StartsWithAnyPhrase(loweredTranscript, EmotionQueryPrefixes) ||
|
||||
StartsWithAnyPhrase(loweredTranscript, EmotionAssertionPrefixes);
|
||||
}
|
||||
@@ -298,27 +539,20 @@ internal static class ChitchatStateMachine
|
||||
|
||||
foreach (var mapping in DirectEmotionCommandPhrases)
|
||||
{
|
||||
if (!ContainsPhrase(loweredTranscript, mapping.Phrase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue;
|
||||
|
||||
emotion = mapping.Emotion;
|
||||
return true;
|
||||
}
|
||||
|
||||
var isNegativeCommand = StartsWithAnyPhrase(loweredTranscript, EmotionCommandNegativePrefixes);
|
||||
var isPositiveCommand = !isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes);
|
||||
if (!isNegativeCommand && !isPositiveCommand)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var isPositiveCommand =
|
||||
!isNegativeCommand && StartsWithAnyPhrase(loweredTranscript, EmotionCommandPositivePrefixes);
|
||||
if (!isNegativeCommand && !isPositiveCommand) return false;
|
||||
|
||||
if (!TryResolveEmotionFromText(loweredTranscript, out var canonicalEmotion) ||
|
||||
string.IsNullOrWhiteSpace(canonicalEmotion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
emotion = isNegativeCommand
|
||||
? "calm"
|
||||
@@ -342,10 +576,7 @@ internal static class ChitchatStateMachine
|
||||
emotion = null;
|
||||
foreach (var mapping in EmotionSynonymMappings)
|
||||
{
|
||||
if (!ContainsPhrase(loweredTranscript, mapping.Phrase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!ContainsPhrase(loweredTranscript, mapping.Phrase)) continue;
|
||||
|
||||
emotion = mapping.Emotion;
|
||||
return true;
|
||||
@@ -357,12 +588,8 @@ internal static class ChitchatStateMachine
|
||||
private static bool ContainsAnyPhrase(string loweredTranscript, IEnumerable<string> phrases)
|
||||
{
|
||||
foreach (var phrase in phrases)
|
||||
{
|
||||
if (ContainsPhrase(loweredTranscript, phrase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -372,17 +599,12 @@ internal static class ChitchatStateMachine
|
||||
foreach (var phrase in phrases)
|
||||
{
|
||||
var normalizedPhrase = NormalizeForPhraseMatching(phrase);
|
||||
if (string.IsNullOrWhiteSpace(normalizedPhrase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(normalizedPhrase)) continue;
|
||||
|
||||
if (string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) ||
|
||||
loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -392,9 +614,7 @@ internal static class ChitchatStateMachine
|
||||
var normalizedPhrase = NormalizeForPhraseMatching(phrase);
|
||||
if (string.IsNullOrWhiteSpace(normalizedPhrase) ||
|
||||
string.IsNullOrWhiteSpace(loweredTranscript))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(loweredTranscript, normalizedPhrase, StringComparison.Ordinal) ||
|
||||
loweredTranscript.StartsWith($"{normalizedPhrase} ", StringComparison.Ordinal) ||
|
||||
@@ -404,10 +624,7 @@ internal static class ChitchatStateMachine
|
||||
|
||||
private static string NormalizeForPhraseMatching(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
|
||||
var lowered = value.ToLowerInvariant();
|
||||
var withoutPunctuation = PhrasePunctuationPattern.Replace(lowered, " ");
|
||||
@@ -420,19 +637,15 @@ internal static class ChitchatStateMachine
|
||||
var mappings = new List<(string Phrase, string Emotion)>();
|
||||
|
||||
foreach (var emotionMapping in PegasusEmotionSynonyms)
|
||||
{
|
||||
foreach (var synonym in emotionMapping.Synonyms)
|
||||
{
|
||||
var normalizedSynonym = NormalizeForPhraseMatching(synonym);
|
||||
if (string.IsNullOrWhiteSpace(normalizedSynonym) ||
|
||||
!seen.Add(normalizedSynonym))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
mappings.Add((normalizedSynonym, emotionMapping.Emotion));
|
||||
}
|
||||
}
|
||||
|
||||
mappings.Sort(static (left, right) => right.Phrase.Length.CompareTo(left.Phrase.Length));
|
||||
return [.. mappings];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Runtime.Abstractions;
|
||||
|
||||
namespace Jibo.Cloud.Application.Services;
|
||||
|
||||
internal static class HouseholdListOrchestrator
|
||||
{
|
||||
internal const string StateMetadataKey = "householdListState";
|
||||
internal const string TypeMetadataKey = "householdListType";
|
||||
internal const string DisplayTypeMetadataKey = "householdListDisplayType";
|
||||
internal const string NoMatchCountMetadataKey = "householdListNoMatchCount";
|
||||
internal const string NoInputCountMetadataKey = "householdListNoInputCount";
|
||||
|
||||
private const string IdleState = "idle";
|
||||
private const string AwaitingItemState = "awaiting_item";
|
||||
private const string ShoppingListType = "shopping";
|
||||
private const string GroceryListType = "grocery";
|
||||
private const string TodoListType = "todo";
|
||||
|
||||
private static readonly string[] ItemPrefixes =
|
||||
[
|
||||
"add ",
|
||||
"put ",
|
||||
"buy ",
|
||||
"get ",
|
||||
"remind me to ",
|
||||
"i need to ",
|
||||
"i need ",
|
||||
"please add ",
|
||||
"please put "
|
||||
];
|
||||
|
||||
private static readonly string[] ItemSuffixes =
|
||||
[
|
||||
" to my shopping list",
|
||||
" to the shopping list",
|
||||
" on my shopping list",
|
||||
" to my grocery list",
|
||||
" to the grocery list",
|
||||
" on my grocery list",
|
||||
" my grocery list",
|
||||
" to my to do list",
|
||||
" to the to do list",
|
||||
" on my to do list",
|
||||
" to my todo list",
|
||||
" to the todo list",
|
||||
" on my todo list"
|
||||
];
|
||||
|
||||
public static Task<JiboInteractionDecision?> TryBuildDecisionAsync(
|
||||
TurnContext turn,
|
||||
string semanticIntent,
|
||||
string transcript,
|
||||
string loweredTranscript,
|
||||
IJiboRandomizer randomizer,
|
||||
IPersonalMemoryStore personalMemoryStore,
|
||||
Func<TurnContext, PersonalMemoryTenantScope> tenantScopeResolver)
|
||||
{
|
||||
var state = ReadString(turn, StateMetadataKey);
|
||||
var listType = ReadString(turn, TypeMetadataKey);
|
||||
var displayType = ReadString(turn, DisplayTypeMetadataKey);
|
||||
var isActiveState = !string.IsNullOrWhiteSpace(state) &&
|
||||
!string.Equals(state, IdleState, StringComparison.OrdinalIgnoreCase);
|
||||
var isShoppingIntent = string.Equals(semanticIntent, "shopping_list", StringComparison.OrdinalIgnoreCase);
|
||||
var isTodoIntent = string.Equals(semanticIntent, "todo_list", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isActiveState && !isShoppingIntent && !isTodoIntent)
|
||||
return Task.FromResult<JiboInteractionDecision?>(null);
|
||||
|
||||
var resolvedListType = isShoppingIntent ? ShoppingListType : isTodoIntent ? TodoListType : NormalizeListType(listType);
|
||||
if (string.IsNullOrWhiteSpace(resolvedListType)) resolvedListType = ShoppingListType;
|
||||
var resolvedDisplayType = ResolveDisplayType(resolvedListType, displayType, isActiveState, loweredTranscript);
|
||||
|
||||
var tenantScope = tenantScopeResolver(turn);
|
||||
|
||||
if (ContainsAny(loweredTranscript, "cancel", "stop", "never mind", "nevermind", "forget it"))
|
||||
return Task.FromResult<JiboInteractionDecision?>(BuildCancelledDecision(resolvedListType, resolvedDisplayType));
|
||||
|
||||
if (IsRecallRequest(loweredTranscript))
|
||||
return Task.FromResult<JiboInteractionDecision?>(BuildRecallDecision(
|
||||
resolvedListType,
|
||||
resolvedDisplayType,
|
||||
personalMemoryStore.GetListItems(tenantScope, resolvedListType)));
|
||||
|
||||
var directItem = TryExtractListItem(loweredTranscript);
|
||||
if (string.IsNullOrWhiteSpace(directItem) && isActiveState)
|
||||
{
|
||||
if (IsConversationComplete(loweredTranscript))
|
||||
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
|
||||
BuildListIntentName(resolvedListType, "done"),
|
||||
BuildDoneReply(resolvedDisplayType, personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
|
||||
ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, IdleState)));
|
||||
|
||||
directItem = NormalizeItem(transcript);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(directItem))
|
||||
{
|
||||
personalMemoryStore.AddListItem(tenantScope, resolvedListType, directItem);
|
||||
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
|
||||
BuildListIntentName(resolvedListType, "add"),
|
||||
BuildAddedReply(resolvedDisplayType, directItem,
|
||||
personalMemoryStore.GetListItems(tenantScope, resolvedListType)),
|
||||
ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState)));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(transcript))
|
||||
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
|
||||
BuildListIntentName(resolvedListType, "prompt"),
|
||||
BuildPromptReply(resolvedDisplayType),
|
||||
ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState)));
|
||||
|
||||
return Task.FromResult<JiboInteractionDecision?>(new JiboInteractionDecision(
|
||||
BuildListIntentName(resolvedListType, "prompt"),
|
||||
BuildPromptReply(resolvedDisplayType),
|
||||
ContextUpdates: BuildContextUpdates(resolvedListType, resolvedDisplayType, AwaitingItemState)));
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?> BuildContextUpdates(string listType, string displayType, string state)
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[StateMetadataKey] = state,
|
||||
[TypeMetadataKey] = listType,
|
||||
[DisplayTypeMetadataKey] = displayType,
|
||||
[NoMatchCountMetadataKey] = 0,
|
||||
[NoInputCountMetadataKey] = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildCancelledDecision(string listType, string displayType)
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
BuildListIntentName(listType, "cancel"),
|
||||
$"Okay. I stopped the {BuildListLabel(displayType)}.",
|
||||
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[StateMetadataKey] = IdleState,
|
||||
[TypeMetadataKey] = listType,
|
||||
[DisplayTypeMetadataKey] = displayType,
|
||||
[NoMatchCountMetadataKey] = 0,
|
||||
[NoInputCountMetadataKey] = 0
|
||||
});
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildRecallDecision(string listType, string displayType, IReadOnlyList<string> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
return new JiboInteractionDecision(
|
||||
BuildListIntentName(listType, "recall"),
|
||||
$"Your {BuildListLabel(displayType)} is empty.",
|
||||
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[StateMetadataKey] = IdleState,
|
||||
[TypeMetadataKey] = listType,
|
||||
[DisplayTypeMetadataKey] = displayType,
|
||||
[NoMatchCountMetadataKey] = 0,
|
||||
[NoInputCountMetadataKey] = 0
|
||||
});
|
||||
|
||||
return new JiboInteractionDecision(
|
||||
BuildListIntentName(listType, "recall"),
|
||||
$"Your {BuildListLabel(displayType)} has {JoinList(items)}.",
|
||||
ContextUpdates: new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[StateMetadataKey] = IdleState,
|
||||
[TypeMetadataKey] = listType,
|
||||
[DisplayTypeMetadataKey] = displayType,
|
||||
[NoMatchCountMetadataKey] = 0,
|
||||
[NoInputCountMetadataKey] = 0
|
||||
});
|
||||
}
|
||||
|
||||
private static string BuildAddedReply(string displayType, string addedItem, IReadOnlyList<string> items)
|
||||
{
|
||||
var itemLabel = BuildListLabel(displayType);
|
||||
return items.Count == 1
|
||||
? $"Added {addedItem} to your {itemLabel}. What else should I add?"
|
||||
: $"Added {addedItem} to your {itemLabel}. You now have {JoinList(items)}.";
|
||||
}
|
||||
|
||||
private static string BuildPromptReply(string displayType)
|
||||
{
|
||||
return $"What should I add to your {BuildListLabel(displayType)}?";
|
||||
}
|
||||
|
||||
private static string BuildDoneReply(string displayType, IReadOnlyList<string> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
return $"Okay. Your {BuildListLabel(displayType)} is empty.";
|
||||
|
||||
return $"Okay. Your {BuildListLabel(displayType)} has {JoinList(items)}.";
|
||||
}
|
||||
|
||||
private static string BuildListLabel(string displayType)
|
||||
{
|
||||
return NormalizeDisplayType(displayType) switch
|
||||
{
|
||||
GroceryListType => "grocery list",
|
||||
TodoListType => "to-do list",
|
||||
_ => "shopping list"
|
||||
};
|
||||
}
|
||||
|
||||
private static string JoinList(IReadOnlyList<string> items)
|
||||
{
|
||||
return items.Count switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => items[0],
|
||||
2 => $"{items[0]} and {items[1]}",
|
||||
_ => $"{string.Join(", ", items.Take(items.Count - 1))}, and {items[^1]}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? TryExtractListItem(string loweredTranscript)
|
||||
{
|
||||
foreach (var prefix in ItemPrefixes)
|
||||
{
|
||||
if (!loweredTranscript.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var remainder = loweredTranscript[prefix.Length..].Trim();
|
||||
if (IsListOnlyRemainder(remainder))
|
||||
return null;
|
||||
|
||||
remainder = TrimTrailingListPhrases(remainder);
|
||||
if (IsListOnlyRemainder(remainder))
|
||||
return null;
|
||||
|
||||
return NormalizeItem(remainder);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRecallRequest(string loweredTranscript)
|
||||
{
|
||||
return ContainsAny(loweredTranscript,
|
||||
"what is on my shopping list",
|
||||
"what's on my shopping list",
|
||||
"show my shopping list",
|
||||
"what is on my grocery list",
|
||||
"what's on my grocery list",
|
||||
"show my grocery list",
|
||||
"what is on my to do list",
|
||||
"what's on my to do list",
|
||||
"show my to do list",
|
||||
"what are my tasks",
|
||||
"what do i need to buy",
|
||||
"what do i need to do");
|
||||
}
|
||||
|
||||
private static string TrimTrailingListPhrases(string value)
|
||||
{
|
||||
var result = value;
|
||||
foreach (var suffix in ItemSuffixes)
|
||||
if (result.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
result = result[..^suffix.Length].Trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NormalizeItem(string value)
|
||||
{
|
||||
return value.Trim().TrimEnd('.', ',', '!', '?');
|
||||
}
|
||||
|
||||
private static string NormalizeListType(string? listType)
|
||||
{
|
||||
var normalized = NormalizeItem(listType ?? string.Empty).ToLowerInvariant();
|
||||
return normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("to do", StringComparison.OrdinalIgnoreCase)
|
||||
? TodoListType
|
||||
: normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase)
|
||||
? ShoppingListType
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static string ResolveDisplayType(string listType, string? storedDisplayType, bool isActiveState, string loweredTranscript)
|
||||
{
|
||||
var transcriptDisplayType = InferDisplayTypeFromTranscript(loweredTranscript);
|
||||
var normalizedStoredDisplayType = NormalizeDisplayType(storedDisplayType);
|
||||
|
||||
if (isActiveState && !string.IsNullOrWhiteSpace(normalizedStoredDisplayType))
|
||||
return normalizedStoredDisplayType;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(transcriptDisplayType))
|
||||
return transcriptDisplayType;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedStoredDisplayType))
|
||||
return normalizedStoredDisplayType;
|
||||
|
||||
return string.Equals(listType, TodoListType, StringComparison.OrdinalIgnoreCase)
|
||||
? TodoListType
|
||||
: ShoppingListType;
|
||||
}
|
||||
|
||||
private static string InferDisplayTypeFromTranscript(string loweredTranscript)
|
||||
{
|
||||
if (loweredTranscript.Contains("grocery", StringComparison.OrdinalIgnoreCase))
|
||||
return GroceryListType;
|
||||
|
||||
if (loweredTranscript.Contains("to do", StringComparison.OrdinalIgnoreCase) ||
|
||||
loweredTranscript.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
|
||||
loweredTranscript.Contains("task", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TodoListType;
|
||||
}
|
||||
|
||||
if (loweredTranscript.Contains("shopping", StringComparison.OrdinalIgnoreCase))
|
||||
return ShoppingListType;
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeDisplayType(string? displayType)
|
||||
{
|
||||
var normalized = NormalizeItem(displayType ?? string.Empty).ToLowerInvariant();
|
||||
return normalized.Contains("grocery", StringComparison.OrdinalIgnoreCase)
|
||||
? GroceryListType
|
||||
: normalized.Contains("todo", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.Contains("to do", StringComparison.OrdinalIgnoreCase)
|
||||
? TodoListType
|
||||
: normalized.Contains("shopping", StringComparison.OrdinalIgnoreCase)
|
||||
? ShoppingListType
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static string BuildListIntentName(string listType, string action)
|
||||
{
|
||||
var normalizedListType = string.Equals(listType, TodoListType, StringComparison.OrdinalIgnoreCase)
|
||||
? TodoListType
|
||||
: ShoppingListType;
|
||||
return $"{normalizedListType}_list_{action}";
|
||||
}
|
||||
|
||||
private static bool IsListOnlyRemainder(string value)
|
||||
{
|
||||
var normalized = NormalizeItem(value).ToLowerInvariant();
|
||||
return normalized is "shopping list" or
|
||||
"grocery list" or
|
||||
"to do list" or
|
||||
"todo list" or
|
||||
"my shopping list" or
|
||||
"my grocery list" or
|
||||
"my to do list" or
|
||||
"my todo list" or
|
||||
"to my shopping list" or
|
||||
"to my grocery list" or
|
||||
"to my to do list" or
|
||||
"to my todo list" or
|
||||
"to the shopping list" or
|
||||
"to the grocery list" or
|
||||
"to the to do list" or
|
||||
"to the todo list" or
|
||||
"on my shopping list" or
|
||||
"on my grocery list" or
|
||||
"on my to do list" or
|
||||
"on my todo list";
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string loweredTranscript, params string[] phrases)
|
||||
{
|
||||
return phrases.Any(phrase => loweredTranscript.Contains(phrase, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsConversationComplete(string loweredTranscript)
|
||||
{
|
||||
return ContainsAny(loweredTranscript,
|
||||
"done",
|
||||
"that's it",
|
||||
"that s it",
|
||||
"all set",
|
||||
"finished",
|
||||
"no more",
|
||||
"nothing else");
|
||||
}
|
||||
|
||||
private static string? ReadString(TurnContext turn, string key)
|
||||
{
|
||||
return turn.Attributes.TryGetValue(key, out var value) ? value?.ToString() : null;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
|
||||
namespace Jibo.Cloud.Application.Services;
|
||||
|
||||
public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMediaContentStore? mediaContentStore = null)
|
||||
{
|
||||
private static readonly string[] AcceptedHosts =
|
||||
[
|
||||
@@ -14,97 +16,70 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
"localhost"
|
||||
];
|
||||
|
||||
public Task<ProtocolDispatchResult> DispatchAsync(ProtocolEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
private readonly IMediaContentStore _mediaContentStore = mediaContentStore ?? new NullMediaContentStore();
|
||||
|
||||
public Task<ProtocolDispatchResult> DispatchAsync(ProtocolEnvelope envelope,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
|
||||
envelope.Path == "/" &&
|
||||
string.IsNullOrWhiteSpace(envelope.ServicePrefix))
|
||||
{
|
||||
return Task.FromResult(ProtocolDispatchResult.NoContent());
|
||||
}
|
||||
|
||||
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
|
||||
envelope.Path.Equals("/health", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(ProtocolDispatchResult.Ok(new { ok = true, host = envelope.HostName }));
|
||||
}
|
||||
|
||||
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
|
||||
envelope.Path.StartsWith("/media/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleMediaContent(envelope));
|
||||
}
|
||||
|
||||
if (envelope.Method.Equals("PUT", StringComparison.OrdinalIgnoreCase) &&
|
||||
(envelope.Path.Equals("/upload/asr-binary", StringComparison.OrdinalIgnoreCase) ||
|
||||
envelope.Path.Equals("/upload/log-events", StringComparison.OrdinalIgnoreCase) ||
|
||||
envelope.Path.Equals("/upload/log-binary", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Task.FromResult(ProtocolDispatchResult.Raw(200, string.Empty));
|
||||
}
|
||||
|
||||
if (!AcceptedHosts.Contains(envelope.HostName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
accepted = false,
|
||||
host = envelope.HostName
|
||||
}));
|
||||
}
|
||||
|
||||
var servicePrefix = envelope.ServicePrefix ?? string.Empty;
|
||||
var operation = envelope.Operation ?? string.Empty;
|
||||
|
||||
if (servicePrefix.StartsWith("Log_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleLog(operation, envelope));
|
||||
}
|
||||
|
||||
if (servicePrefix.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleBackup(operation));
|
||||
}
|
||||
return Task.FromResult(HandleBackup(operation, envelope));
|
||||
|
||||
if (servicePrefix.StartsWith("Account_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleAccount(operation, envelope));
|
||||
}
|
||||
|
||||
if (servicePrefix.StartsWith("Notification_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleNotification(operation, envelope));
|
||||
}
|
||||
|
||||
if (servicePrefix.StartsWith("Loop_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleLoop(operation));
|
||||
}
|
||||
|
||||
if (servicePrefix.Equals("Media_20160725", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleMedia(operation, envelope));
|
||||
}
|
||||
|
||||
if (servicePrefix.StartsWith("Key_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleKey(operation, envelope));
|
||||
}
|
||||
|
||||
if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandlePerson(operation));
|
||||
}
|
||||
return Task.FromResult(HandlePerson(operation, envelope));
|
||||
|
||||
if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleRobot(operation, envelope));
|
||||
}
|
||||
|
||||
if (servicePrefix.StartsWith("Update_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(HandleUpdate(operation, envelope));
|
||||
}
|
||||
|
||||
return Task.FromResult(ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
@@ -122,22 +97,18 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
var body = envelope.TryParseBody();
|
||||
|
||||
if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
token = stateStore.IssueHubToken(),
|
||||
expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()
|
||||
});
|
||||
}
|
||||
|
||||
if (operation.Equals("CreateAccessToken", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
token = $"access-{account.AccountId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
|
||||
expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()
|
||||
});
|
||||
}
|
||||
|
||||
if (operation.Equals("CheckEmail", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -149,7 +120,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
}
|
||||
|
||||
if (operation is "Create" or "Login")
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
id = account.AccountId,
|
||||
@@ -168,17 +138,13 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
facebookConnected = false,
|
||||
termsAccepted = true
|
||||
});
|
||||
}
|
||||
|
||||
if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ids = ReadStringArray(body, "ids");
|
||||
var matches = ids.Count == 0 || ids.Contains(account.AccountId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!matches)
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
}
|
||||
if (!matches) return ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
|
||||
return ProtocolDispatchResult.Ok(new[]
|
||||
{
|
||||
@@ -216,7 +182,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
}
|
||||
|
||||
if (operation.Equals("GetAccountByAccessToken", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
id = account.AccountId,
|
||||
@@ -226,12 +191,12 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
friendlyId = stateStore.GetRobot().RobotId,
|
||||
payload = ReadObject(body, "payload")
|
||||
});
|
||||
}
|
||||
|
||||
if (operation.Equals("Search", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var query = (ReadString(body, "query") ?? string.Empty).ToLowerInvariant();
|
||||
var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}".ToLowerInvariant();
|
||||
var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}"
|
||||
.ToLowerInvariant();
|
||||
|
||||
return ProtocolDispatchResult.Ok(query.Length > 0 && haystack.Contains(query)
|
||||
?
|
||||
@@ -248,7 +213,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
}
|
||||
|
||||
if (operation.Equals("FacebookPrepareLogin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
url = "https://example.com/facebook-login",
|
||||
@@ -258,12 +222,9 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
state = $"fb-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
|
||||
redirect_uri = "https://api.jibo.com/facebook/callback"
|
||||
});
|
||||
}
|
||||
|
||||
if (operation.Equals("ConfirmEmailReset", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new { });
|
||||
}
|
||||
|
||||
return ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
@@ -277,9 +238,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
private ProtocolDispatchResult HandleNotification(string operation, ProtocolEnvelope envelope)
|
||||
{
|
||||
if (!operation.Equals("NewRobotToken", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new { ok = true, operation });
|
||||
}
|
||||
|
||||
var body = envelope.TryParseBody();
|
||||
var deviceId = !string.IsNullOrWhiteSpace(envelope.DeviceId)
|
||||
@@ -302,10 +261,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
|
||||
private ProtocolDispatchResult HandleLoop(string operation)
|
||||
{
|
||||
if (operation is not ("List" or "ListLoops"))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
}
|
||||
if (operation is not ("List" or "ListLoops")) return ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetLoops().Select(loop => new
|
||||
{
|
||||
@@ -363,55 +319,105 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
var body = envelope.TryParseBody();
|
||||
|
||||
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(stateStore.ListMedia(
|
||||
ReadStringArray(body, "loopIds"),
|
||||
ReadLong(body, "after"),
|
||||
ReadLong(body, "before")).Select(MapMedia).ToArray());
|
||||
}
|
||||
|
||||
if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray());
|
||||
}
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia)
|
||||
.ToArray());
|
||||
|
||||
if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray());
|
||||
}
|
||||
return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia)
|
||||
.ToArray());
|
||||
|
||||
if (!operation.Equals("Create", StringComparison.OrdinalIgnoreCase))
|
||||
return ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
|
||||
var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? stateStore.GetLoops()[0].LoopId;
|
||||
var path = ReadHeader(envelope, "x-path") ?? ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
||||
var path = ReadHeader(envelope, "x-path") ??
|
||||
ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
||||
var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown";
|
||||
var reference = ReadHeader(envelope, "x-reference") ?? ReadString(body, "reference") ?? string.Empty;
|
||||
var isEncrypted = ReadBooleanHeader(envelope, "x-encrypted") || ReadBool(body, "isEncrypted");
|
||||
var meta = ReadObject(body, "meta") ?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
var contentType = ReadHeader(envelope, "Content-Type") ?? "application/octet-stream";
|
||||
meta["contentType"] = contentType;
|
||||
if (!string.IsNullOrWhiteSpace(envelope.BodyText))
|
||||
{
|
||||
meta["bodyText"] = envelope.BodyText;
|
||||
var bodyBytes = string.IsNullOrWhiteSpace(envelope.BodyText)
|
||||
? []
|
||||
: Encoding.UTF8.GetBytes(envelope.BodyText);
|
||||
meta["contentLength"] = bodyBytes.Length;
|
||||
meta["contentSha256"] = Convert.ToHexString(SHA256.HashData(bodyBytes)).ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(envelope.BodyText)) meta["bodyText"] = envelope.BodyText;
|
||||
|
||||
_mediaContentStore.StoreAsync(path, contentType,
|
||||
bodyBytes,
|
||||
meta as IReadOnlyDictionary<string, object?>, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
return ProtocolDispatchResult.Ok(
|
||||
MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
|
||||
}
|
||||
|
||||
return ProtocolDispatchResult.Ok(MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
|
||||
private ProtocolDispatchResult HandlePerson(string operation, ProtocolEnvelope envelope)
|
||||
{
|
||||
var body = envelope.TryParseBody();
|
||||
|
||||
if (operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var loopId = ReadString(body, "loopId");
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetHolidays(loopId).Select(MapHoliday));
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandlePerson(string operation)
|
||||
if (operation.Equals("ListCommute", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase)
|
||||
? stateStore.GetHolidays()
|
||||
: []);
|
||||
var loopId = ReadString(body, "loopId");
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetCommuteProfiles(loopId).Select(MapCommute));
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandleBackup(string operation)
|
||||
if (operation.Equals("UpsertCommute", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return operation.Equals("List", StringComparison.OrdinalIgnoreCase)
|
||||
? ProtocolDispatchResult.Ok(stateStore.GetBackups())
|
||||
: ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
var hasIsEnabled = body is { } enabledBody && enabledBody.TryGetProperty("isEnabled", out _);
|
||||
var hasIsComplete = body is { } completeBody && completeBody.TryGetProperty("isComplete", out _);
|
||||
var workHour = ReadLong(body, "workHour");
|
||||
var workMinute = ReadLong(body, "workMinute");
|
||||
var typicalDurationMinutes = ReadLong(body, "typicalDurationMinutes");
|
||||
var commute = new CommuteProfileRecord
|
||||
{
|
||||
Id = ReadString(body, "id") ?? string.Empty,
|
||||
LoopId = ReadString(body, "loopId") ?? string.Empty,
|
||||
MemberId = ReadString(body, "memberId"),
|
||||
IsEnabled = hasIsEnabled ? ReadBool(body, "isEnabled") : true,
|
||||
IsComplete = hasIsComplete ? ReadBool(body, "isComplete") : true,
|
||||
Mode = ReadString(body, "mode") ?? "driving",
|
||||
WorkHour = workHour is > 0 and < 24 ? (int)workHour.Value : 8,
|
||||
WorkMinute = workMinute is >= 0 and < 60 ? (int)workMinute.Value : 30,
|
||||
OriginName = ReadString(body, "originName"),
|
||||
DestinationName = ReadString(body, "destinationName"),
|
||||
TypicalDurationMinutes = typicalDurationMinutes is > 0
|
||||
? (int)typicalDurationMinutes.Value
|
||||
: 25
|
||||
};
|
||||
return ProtocolDispatchResult.Ok(MapCommute(stateStore.UpsertCommuteProfile(commute)));
|
||||
}
|
||||
|
||||
return ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandleBackup(string operation, ProtocolEnvelope envelope)
|
||||
{
|
||||
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetBackups());
|
||||
|
||||
if (operation.Equals("Create", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var body = envelope.TryParseBody();
|
||||
var requestedName = ReadString(body, "name") ?? ReadString(body, "backupName");
|
||||
return ProtocolDispatchResult.Ok(
|
||||
stateStore.CreateBackup(requestedName ?? $"backup-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"));
|
||||
}
|
||||
|
||||
return ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandleKey(string operation, ProtocolEnvelope envelope)
|
||||
@@ -420,12 +426,10 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? stateStore.GetLoops()[0].LoopId;
|
||||
|
||||
if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
shouldCreate = stateStore.ShouldCreateSymmetricKey(loopId)
|
||||
});
|
||||
}
|
||||
|
||||
string? symmetricKey;
|
||||
if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -451,24 +455,17 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
}
|
||||
|
||||
if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"), ReadString(body, "publicKey")));
|
||||
}
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"),
|
||||
ReadString(body, "publicKey")));
|
||||
|
||||
if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetIncomingKeyRequests());
|
||||
}
|
||||
|
||||
if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetBinaryRequests());
|
||||
}
|
||||
|
||||
if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary")
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(new { ok = true });
|
||||
}
|
||||
|
||||
if (!operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase))
|
||||
return ProtocolDispatchResult.Ok(new { ok = true, operation });
|
||||
@@ -480,7 +477,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
key = symmetricKey,
|
||||
symmetricKey
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope)
|
||||
@@ -521,7 +517,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
updated = profile.UpdatedUtc.ToUnixTimeMilliseconds(),
|
||||
created = profile.CreatedUtc.ToUnixTimeMilliseconds()
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandleUpdate(string operation, ProtocolEnvelope envelope)
|
||||
@@ -533,9 +528,11 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
|
||||
return operation switch
|
||||
{
|
||||
"ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate).ToArray()),
|
||||
"ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate)
|
||||
.ToArray()),
|
||||
"ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter)
|
||||
.Where(update => fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(update =>
|
||||
fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(MapUpdate)
|
||||
.ToArray()),
|
||||
"GetUpdateFrom" => HandleGetUpdateFrom(subsystem, fromVersion, filter),
|
||||
@@ -558,13 +555,14 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
var path = Uri.UnescapeDataString(envelope.Path["/media/".Length..]);
|
||||
var candidatePaths = new[] { path, $"/{path}" };
|
||||
var media = stateStore.GetMedia(candidatePaths).FirstOrDefault();
|
||||
if (media is null || media.IsDeleted)
|
||||
{
|
||||
return ProtocolDispatchResult.Raw(404, string.Empty);
|
||||
}
|
||||
if (media is null || media.IsDeleted) return ProtocolDispatchResult.Raw(404, string.Empty);
|
||||
|
||||
var contentType = TryReadMetaString(media.Meta, "contentType") ?? "application/octet-stream";
|
||||
var bodyText = TryReadMetaString(media.Meta, "bodyText") ?? string.Empty;
|
||||
var storedContent = _mediaContentStore.LoadAsync(media.Path, CancellationToken.None).GetAwaiter().GetResult();
|
||||
var contentType = storedContent?.ContentType ?? TryReadMetaString(media.Meta, "contentType") ??
|
||||
"application/octet-stream";
|
||||
var bodyText = storedContent is not null
|
||||
? Encoding.UTF8.GetString(storedContent.Content)
|
||||
: TryReadMetaString(media.Meta, "bodyText") ?? string.Empty;
|
||||
return ProtocolDispatchResult.Raw(200, bodyText, contentType);
|
||||
}
|
||||
|
||||
@@ -595,6 +593,46 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
};
|
||||
}
|
||||
|
||||
private static object MapHoliday(HolidayRecord holiday)
|
||||
{
|
||||
return new
|
||||
{
|
||||
id = holiday.Id,
|
||||
eventId = holiday.EventId,
|
||||
name = holiday.Name,
|
||||
category = holiday.Category,
|
||||
subcategory = holiday.Subcategory,
|
||||
loopId = holiday.LoopId,
|
||||
memberId = holiday.MemberId,
|
||||
isEnabled = holiday.IsEnabled,
|
||||
date = holiday.Date,
|
||||
endDate = holiday.EndDate,
|
||||
source = holiday.Source,
|
||||
countryCode = holiday.CountryCode,
|
||||
created = holiday.Created
|
||||
};
|
||||
}
|
||||
|
||||
private static object MapCommute(CommuteProfileRecord commute)
|
||||
{
|
||||
return new
|
||||
{
|
||||
id = commute.Id,
|
||||
loopId = commute.LoopId,
|
||||
memberId = commute.MemberId,
|
||||
isEnabled = commute.IsEnabled,
|
||||
isComplete = commute.IsComplete,
|
||||
mode = commute.Mode,
|
||||
workHour = commute.WorkHour,
|
||||
workMinute = commute.WorkMinute,
|
||||
originName = commute.OriginName,
|
||||
destinationName = commute.DestinationName,
|
||||
typicalDurationMinutes = commute.TypicalDurationMinutes,
|
||||
created = commute.Created,
|
||||
updated = commute.Updated
|
||||
};
|
||||
}
|
||||
|
||||
private static object MapMedia(MediaRecord item)
|
||||
{
|
||||
return new
|
||||
@@ -623,10 +661,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
|
||||
private static string? ReadString(JsonElement? element, string propertyName)
|
||||
{
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null;
|
||||
|
||||
return property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
@@ -635,25 +670,16 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
|
||||
private static long? ReadLong(JsonElement? element, string propertyName)
|
||||
{
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null;
|
||||
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number)) return number;
|
||||
|
||||
return long.TryParse(property.ToString(), out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static bool ReadBool(JsonElement? element, string propertyName)
|
||||
{
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return false;
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
@@ -665,31 +691,26 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
|
||||
private static IReadOnlyList<string> ReadStringArray(JsonElement? element, string propertyName)
|
||||
{
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property) ||
|
||||
property.ValueKind != JsonValueKind.Array) return [];
|
||||
|
||||
return [.. property.EnumerateArray()
|
||||
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))];
|
||||
return
|
||||
[
|
||||
.. property.EnumerateArray()
|
||||
.Select(item =>
|
||||
item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
];
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?>? ReadObject(JsonElement? element, string propertyName)
|
||||
{
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (element is null || !element.Value.TryGetProperty(propertyName, out var property)) return null;
|
||||
|
||||
if (property.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (property.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var child in property.EnumerateObject())
|
||||
{
|
||||
result[child.Name] = child.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => child.Value.GetString(),
|
||||
@@ -699,7 +720,6 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
JsonValueKind.False => false,
|
||||
_ => child.Value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -715,4 +735,18 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
bool.TryParse(value, out var parsed) &&
|
||||
parsed;
|
||||
}
|
||||
|
||||
private sealed class NullMediaContentStore : IMediaContentStore
|
||||
{
|
||||
public Task StoreAsync(string path, string contentType, byte[] content,
|
||||
IReadOnlyDictionary<string, object?>? meta, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<MediaContentSnapshot?> LoadAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<MediaContentSnapshot?>(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,213 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
using Jibo.Runtime.Abstractions;
|
||||
|
||||
namespace Jibo.Cloud.Application.Services;
|
||||
|
||||
public sealed partial class JiboInteractionService
|
||||
{
|
||||
private static JiboInteractionDecision BuildNewsDecision(
|
||||
string spokenBriefing,
|
||||
string? sourceName,
|
||||
IReadOnlyList<string>? categories,
|
||||
int? headlineCount,
|
||||
IReadOnlyDictionary<string, object?>? providerDiagnostics = null,
|
||||
IReadOnlyList<NewsHeadline>? headlines = null)
|
||||
{
|
||||
var speakableBriefing = NormalizeNewsSpeechText(spokenBriefing);
|
||||
var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["skillId"] = "news",
|
||||
["cloudSkill"] = "news",
|
||||
["mim_id"] = "runtime-news",
|
||||
["mim_type"] = "announcement",
|
||||
["prompt_id"] = "NewsHeadline_AN_01",
|
||||
["prompt_sub_category"] = "AN",
|
||||
["news_view_enabled"] = true,
|
||||
["news_view_kind"] = "newsBriefing",
|
||||
["news_view_mode"] = "provider",
|
||||
["esml"] =
|
||||
$"<speak><anim cat='news' meta='news-stinger' nonBlocking='true' /><break size='0.35'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeForEsml(speakableBriefing)}</es></speak>"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourceName)) payload["news_source"] = sourceName;
|
||||
|
||||
if (headlineCount is > 0) payload["news_headline_count"] = headlineCount.Value;
|
||||
|
||||
if (categories is { Count: > 0 }) payload["news_categories"] = categories.ToArray();
|
||||
|
||||
if (headlines is { Count: > 0 })
|
||||
payload["news_headlines"] = headlines.Select(static headline => new Dictionary<string, object?>(
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["title"] = headline.Title,
|
||||
["summary"] = headline.Summary,
|
||||
["category"] = headline.Category,
|
||||
["sourceName"] = headline.SourceName,
|
||||
["url"] = headline.Url
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
if (providerDiagnostics is not null)
|
||||
foreach (var (key, value) in providerDiagnostics)
|
||||
payload[key] = value;
|
||||
|
||||
return new JiboInteractionDecision("news", spokenBriefing, "news", payload);
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildProviderNewsDecision(
|
||||
NewsBriefingSnapshot snapshot,
|
||||
JiboExperienceCatalog catalog,
|
||||
IReadOnlyList<string> preferredCategories,
|
||||
int requestedHeadlineCount)
|
||||
{
|
||||
var headlines = snapshot.Headlines
|
||||
.Where(headline => !string.IsNullOrWhiteSpace(headline.Title))
|
||||
.Take(MaxNewsHeadlines)
|
||||
.ToArray();
|
||||
if (headlines.Length == 0)
|
||||
return BuildNewsDecision(
|
||||
"I couldn't load fresh headlines right now.",
|
||||
snapshot.SourceName,
|
||||
preferredCategories,
|
||||
0,
|
||||
BuildNewsProviderDiagnostics(
|
||||
"provider_empty",
|
||||
preferredCategories,
|
||||
requestedHeadlineCount,
|
||||
0));
|
||||
|
||||
var leadIn = BuildNewsLeadIn(snapshot.SourceName, preferredCategories);
|
||||
var joinedHeadlines = string.Join(" ", headlines.Select(static headline => $"{headline.Title}."));
|
||||
var outroTemplate = ChooseShortestTemplate(catalog.NewsOutroReplies) ?? "And that's the news.";
|
||||
var spokenBriefing = $"{leadIn} {joinedHeadlines} {outroTemplate}".Trim();
|
||||
return BuildNewsDecision(
|
||||
spokenBriefing,
|
||||
snapshot.SourceName,
|
||||
preferredCategories,
|
||||
headlines.Length,
|
||||
BuildNewsProviderDiagnostics(
|
||||
"provider_success",
|
||||
preferredCategories,
|
||||
requestedHeadlineCount,
|
||||
headlines.Length),
|
||||
headlines);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> BuildNewsProviderDiagnostics(
|
||||
string status,
|
||||
IReadOnlyList<string> preferredCategories,
|
||||
int requestedHeadlineCount,
|
||||
int? resolvedHeadlineCount = null,
|
||||
string? providerMessage = null,
|
||||
int? providerHttpStatusCode = null,
|
||||
string? providerEndpoint = null,
|
||||
string? providerErrorCode = null)
|
||||
{
|
||||
var diagnostics = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["news_provider_status"] = status,
|
||||
["news_provider_requested_headlines"] = requestedHeadlineCount,
|
||||
["news_provider_preferred_categories"] = preferredCategories.Count > 0
|
||||
? [.. preferredCategories]
|
||||
: Array.Empty<string>()
|
||||
};
|
||||
|
||||
if (resolvedHeadlineCount is not null)
|
||||
diagnostics["news_provider_resolved_headlines"] = resolvedHeadlineCount.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(providerMessage)) diagnostics["news_provider_message"] = providerMessage;
|
||||
|
||||
if (providerHttpStatusCode is not null) diagnostics["news_provider_http_status"] = providerHttpStatusCode.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(providerEndpoint)) diagnostics["news_provider_endpoint"] = providerEndpoint;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(providerErrorCode)) diagnostics["news_provider_error_code"] = providerErrorCode;
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
private static string ResolveNewsProviderStatus(NewsBriefingSnapshot? snapshot)
|
||||
{
|
||||
var providerStatus = snapshot?.ProviderStatus?.Trim().ToLowerInvariant();
|
||||
return providerStatus switch
|
||||
{
|
||||
"success" => "provider_success",
|
||||
"exception" => "provider_exception",
|
||||
"http_error" or "api_error" or "schema_error" => "provider_error",
|
||||
_ => "provider_empty"
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildNewsLeadIn(string? sourceName, IReadOnlyList<string> preferredCategories)
|
||||
{
|
||||
var categoryLeadIn = preferredCategories.Count switch
|
||||
{
|
||||
<= 0 => "Here are a few headlines.",
|
||||
1 => $"Here are your {preferredCategories[0]} headlines.",
|
||||
_ => $"Here are your {preferredCategories[0]} and {preferredCategories[1]} headlines."
|
||||
};
|
||||
|
||||
return string.IsNullOrWhiteSpace(sourceName)
|
||||
? categoryLeadIn
|
||||
: $"{categoryLeadIn} Source: {sourceName}.";
|
||||
}
|
||||
|
||||
private static string NormalizeNewsSpeechText(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return text;
|
||||
|
||||
// Expand "AI" so Nimbus TTS does not collapse it to a single "aye" sound.
|
||||
var normalized = Regex.Replace(
|
||||
text,
|
||||
@"\bA\.?\s*I\.?\b",
|
||||
"artificial intelligence",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
return NormalizeLocationForSpeech(normalized);
|
||||
}
|
||||
|
||||
private List<string> ResolvePreferredNewsCategories(TurnContext turn, string transcript)
|
||||
{
|
||||
var categories = new List<string>();
|
||||
var normalizedTranscript = NormalizeCommandPhrase(transcript);
|
||||
|
||||
foreach (var (keyword, category) in NewsCategoryKeywordMap)
|
||||
if (normalizedTranscript.Contains(keyword, StringComparison.Ordinal))
|
||||
AddNewsCategory(categories, category);
|
||||
|
||||
var tenantScope = ResolveTenantScope(turn);
|
||||
var explicitPreference = personalMemoryStore.GetPreference(tenantScope, "news");
|
||||
if (!string.IsNullOrWhiteSpace(explicitPreference))
|
||||
foreach (var category in MapNewsCategoryText(explicitPreference))
|
||||
AddNewsCategory(categories, category);
|
||||
|
||||
foreach (var (item, affinity) in personalMemoryStore.GetAffinities(tenantScope))
|
||||
{
|
||||
if (affinity == PersonalAffinity.Dislike) continue;
|
||||
|
||||
foreach (var category in MapNewsCategoryText(item)) AddNewsCategory(categories, category);
|
||||
}
|
||||
|
||||
return [.. categories.Take(MaxPreferredNewsCategories)];
|
||||
}
|
||||
|
||||
private static IEnumerable<string> MapNewsCategoryText(string text)
|
||||
{
|
||||
var normalized = NormalizeCommandPhrase(text);
|
||||
if (string.IsNullOrWhiteSpace(normalized)) yield break;
|
||||
|
||||
foreach (var (keyword, category) in NewsCategoryKeywordMap)
|
||||
if (normalized.Contains(keyword, StringComparison.Ordinal))
|
||||
yield return category;
|
||||
}
|
||||
|
||||
private static void AddNewsCategory(ICollection<string> categories, string category)
|
||||
{
|
||||
if (categories.Contains(category, StringComparer.OrdinalIgnoreCase)) return;
|
||||
|
||||
categories.Add(category);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
using Jibo.Runtime.Abstractions;
|
||||
|
||||
namespace Jibo.Cloud.Application.Services;
|
||||
|
||||
public sealed partial class JiboInteractionService
|
||||
{
|
||||
private static readonly string[] DefaultAgeReplies =
|
||||
[
|
||||
"I'm ${jibo.age}.",
|
||||
"At the moment I'm ${jibo.age.days.supplemented} old, but who's counting.",
|
||||
"I'm ${jibo.age.minutes.supplemented} old, but who's counting.",
|
||||
"For now I'm ${jibo.age.days.supplemented} old.",
|
||||
"Right now I'm ${jibo.age}.",
|
||||
"I am exactly ${jibo.age} old today. That's right. Today is my birthday.",
|
||||
"Funny you should ask! Today's my birthday. I was first powered up ${jibo.age} ago today. Seems like just yesterday.",
|
||||
"I'm exactly ${jibo.age} old. Today is my birthday! Happy Birthday Jibo, if I do say so myself.",
|
||||
"At the moment I'm ${jibo.age.days.supplemented} old",
|
||||
"I was first powered up on ${jibo.birthdate}, which makes me ${jibo.age.days.supplemented} old. I'm ${jibo.zodiac.supplemented}.",
|
||||
"My power went on for the first time ${jibo.age.days.supplemented} ago. But who's counting.",
|
||||
"I am ${jibo.age.days.supplemented} old, first powered up on ${jibo.birthdate}. Seems like just yesterday.",
|
||||
"I was powered on for the first time today, so that makes me less than one day old. Wow I'm young.",
|
||||
"Since I was powered on for the first time today, I am not even one day old yet. That's how Jibo ages work."
|
||||
];
|
||||
|
||||
private JiboInteractionDecision BuildRobotAgeDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
DateTimeOffset? referenceLocalTime,
|
||||
string intentName)
|
||||
{
|
||||
var ageReplies = catalog.AgeReplies.Count == 0 ? DefaultAgeReplies : catalog.AgeReplies;
|
||||
var selected = SelectLegacyReply(
|
||||
ageReplies,
|
||||
"first powered up",
|
||||
"today is my birthday",
|
||||
"just getting started",
|
||||
"who's counting");
|
||||
|
||||
var reply = RenderAgeTemplate(selected, referenceLocalTime);
|
||||
if (string.IsNullOrWhiteSpace(reply))
|
||||
{
|
||||
var referenceDate = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).Date);
|
||||
var ageDescription = DescribePersonaAge(referenceDate, OpenJiboCloudBuildInfo.PersonaBirthday);
|
||||
reply = $"I count {OpenJiboCloudBuildInfo.PersonaBirthdayWords} as my birthday, so I am {ageDescription}.";
|
||||
}
|
||||
|
||||
return new JiboInteractionDecision(
|
||||
intentName,
|
||||
reply,
|
||||
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildRobotBirthdayDecision()
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
"robot_birthday",
|
||||
$"My birthday is {OpenJiboCloudBuildInfo.PersonaBirthdayWords}.");
|
||||
}
|
||||
|
||||
private static string RenderAgeTemplate(string template, DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(template)) return string.Empty;
|
||||
|
||||
var referenceMoment = referenceLocalTime ?? DateTimeOffset.UtcNow;
|
||||
var referenceDate = DateOnly.FromDateTime(referenceMoment.Date);
|
||||
var ageDescription = DescribePersonaAge(referenceDate, OpenJiboCloudBuildInfo.PersonaBirthday);
|
||||
var ageDays = Math.Max(0, referenceDate.DayNumber - OpenJiboCloudBuildInfo.PersonaBirthday.DayNumber);
|
||||
var ageMinutes = Math.Max(0, (int)Math.Round((referenceMoment.UtcDateTime -
|
||||
new DateTimeOffset(
|
||||
DateTime.SpecifyKind(
|
||||
OpenJiboCloudBuildInfo.PersonaBirthday
|
||||
.ToDateTime(TimeOnly.MinValue),
|
||||
DateTimeKind.Utc)))
|
||||
.TotalMinutes));
|
||||
var zodiacLabel = DescribeZodiacSign(OpenJiboCloudBuildInfo.PersonaBirthday);
|
||||
if (zodiacLabel.StartsWith("I'm ", StringComparison.OrdinalIgnoreCase))
|
||||
zodiacLabel = zodiacLabel[4..];
|
||||
|
||||
return template
|
||||
.Replace("${jibo.age.minutes.supplemented}", FormatAgeUnit(ageMinutes, "minute") + " old",
|
||||
StringComparison.Ordinal)
|
||||
.Replace("${jibo.age.days.supplemented}", ageDescription, StringComparison.Ordinal)
|
||||
.Replace("${jibo.birthdate}", OpenJiboCloudBuildInfo.PersonaBirthdayWords, StringComparison.Ordinal)
|
||||
.Replace("${jibo.zodiac.supplemented}", zodiacLabel, StringComparison.Ordinal)
|
||||
.Replace("${jibo.age.value}", ageDays.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)
|
||||
.Replace("${jibo.age}", ageDescription, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildTriggerIgnoredDecision()
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
"trigger_ignored",
|
||||
string.Empty,
|
||||
"chitchat-skill",
|
||||
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["skillId"] = "chitchat-skill",
|
||||
["cloudResponseMode"] = "completion_only"
|
||||
});
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildReactiveGreetingDecision(
|
||||
TurnContext turn,
|
||||
string greetingIntent,
|
||||
DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
var presence = ResolveGreetingPresenceProfile(turn);
|
||||
var displayName = ResolvePreferredGreetingName(turn, presence);
|
||||
var replyText = BuildReactiveGreetingReply(greetingIntent, displayName, referenceLocalTime);
|
||||
RecordGreetingPresence(turn, presence, "ReactiveGreeting", greetingIntent, displayName, proactive: false);
|
||||
return new JiboInteractionDecision(
|
||||
greetingIntent,
|
||||
replyText,
|
||||
ContextUpdates: BuildGreetingContextUpdates("ReactiveGreeting", presence.PrimaryPersonId, false));
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildProactiveGreetingDecision(
|
||||
TurnContext turn,
|
||||
GreetingPresenceProfile presence,
|
||||
DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
var displayName = ResolvePreferredGreetingName(turn, presence);
|
||||
var specialGreeting = ResolveSpecialGreetingPrefix(turn, presence, referenceLocalTime);
|
||||
var route = specialGreeting?.Route ?? "ProactiveGreeting";
|
||||
var intentName = specialGreeting?.IntentName ?? "proactive_greeting";
|
||||
var replyText = specialGreeting is null
|
||||
? BuildProactiveGreetingReply(turn, presence, displayName, referenceLocalTime)
|
||||
: string.IsNullOrWhiteSpace(displayName)
|
||||
? $"{specialGreeting.Prefix}. I am glad to see you."
|
||||
: $"{specialGreeting.Prefix}, {displayName}. It is nice to celebrate with you.";
|
||||
RecordGreetingPresence(turn, presence, route, intentName, displayName, proactive: true);
|
||||
return new JiboInteractionDecision(
|
||||
intentName,
|
||||
replyText,
|
||||
ContextUpdates: BuildGreetingContextUpdates(route, presence.PrimaryPersonId, true));
|
||||
}
|
||||
|
||||
private static string BuildReactiveGreetingReply(
|
||||
string greetingIntent,
|
||||
string? displayName,
|
||||
DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
var namePrefix = string.IsNullOrWhiteSpace(displayName)
|
||||
? string.Empty
|
||||
: $", {displayName}";
|
||||
|
||||
return greetingIntent switch
|
||||
{
|
||||
"good_morning" => $"Good morning{namePrefix}. It is great to see you.",
|
||||
"good_afternoon" => $"Good afternoon{namePrefix}. I am glad you are here.",
|
||||
"good_evening" => $"Good evening{namePrefix}. It is nice to have you back.",
|
||||
"good_night" => $"Good night{namePrefix}. Sleep well.",
|
||||
"welcome_back" => string.IsNullOrWhiteSpace(displayName)
|
||||
? $"Welcome back. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}."
|
||||
: $"Welcome back, {displayName}. {ResolveTimeOfDayGreetingPrefix(referenceLocalTime)}.",
|
||||
_ => $"Hello{namePrefix}. It is nice to see you."
|
||||
};
|
||||
}
|
||||
|
||||
private string? ResolvePreferredGreetingName(TurnContext turn, GreetingPresenceProfile presence)
|
||||
{
|
||||
var rememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn, presence.PrimaryPersonId));
|
||||
if (!string.IsNullOrWhiteSpace(rememberedName)) return ToDisplayName(rememberedName);
|
||||
|
||||
var tenantRememberedName = personalMemoryStore.GetName(ResolveTenantScope(turn));
|
||||
if (!string.IsNullOrWhiteSpace(tenantRememberedName)) return ToDisplayName(tenantRememberedName);
|
||||
|
||||
var primaryPersonId = presence.PrimaryPersonId;
|
||||
if (CanUseLoopFirstNameFallback(presence) &&
|
||||
!string.IsNullOrWhiteSpace(primaryPersonId) &&
|
||||
presence.LoopUserFirstNames.TryGetValue(primaryPersonId, out var firstName) &&
|
||||
!string.IsNullOrWhiteSpace(firstName))
|
||||
return ToDisplayName(firstName);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool CanUseLoopFirstNameFallback(GreetingPresenceProfile presence)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(presence.PrimaryPersonId)) return false;
|
||||
if (presence.PeoplePresentIds.Count > 1) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ToDisplayName(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
return string.IsNullOrWhiteSpace(trimmed)
|
||||
? string.Empty
|
||||
: CultureInfo.InvariantCulture.TextInfo.ToTitleCase(trimmed);
|
||||
}
|
||||
|
||||
private bool ShouldHandleProactiveGreetingTrigger(
|
||||
TurnContext turn,
|
||||
string? triggerSource,
|
||||
GreetingPresenceProfile presence)
|
||||
{
|
||||
if (string.Equals(triggerSource, "SURPRISE", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
if (!presence.HasKnownIdentity) return false;
|
||||
|
||||
var lastGreetingUtc = ReadGreetingHistoryLastGreetedUtc(turn, presence);
|
||||
return !lastGreetingUtc.HasValue || DateTimeOffset.UtcNow - lastGreetingUtc.Value >= ProactiveGreetingCooldown;
|
||||
}
|
||||
|
||||
private DateTimeOffset? ReadGreetingHistoryLastGreetedUtc(TurnContext turn, GreetingPresenceProfile presence)
|
||||
{
|
||||
var greetingHistory = ResolveGreetingHistoryRecord(turn, presence);
|
||||
if (greetingHistory is not null && greetingHistory.LastGreetedUtc.HasValue)
|
||||
return greetingHistory.LastGreetedUtc;
|
||||
|
||||
return ReadTimestampAttribute(turn, LastProactiveGreetingUtcMetadataKey);
|
||||
}
|
||||
|
||||
private GreetingPresenceRecord? ResolveGreetingHistoryRecord(TurnContext turn, GreetingPresenceProfile presence)
|
||||
{
|
||||
var historyIdentity = ResolveGreetingHistoryIdentity(presence);
|
||||
if (string.IsNullOrWhiteSpace(historyIdentity) || cloudStateStore is null) return null;
|
||||
|
||||
var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop";
|
||||
return cloudStateStore.GetGreetingPresences(loopId)
|
||||
.FirstOrDefault(record => record.PersonId.Equals(historyIdentity, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string? ResolveGreetingHistoryIdentity(GreetingPresenceProfile presence)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(presence.PrimaryPersonId)) return presence.PrimaryPersonId;
|
||||
return !string.IsNullOrWhiteSpace(presence.SpeakerId) ? presence.SpeakerId : null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadTimestampAttribute(TurnContext turn, string key)
|
||||
{
|
||||
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return null;
|
||||
|
||||
return DateTimeOffset.TryParse(
|
||||
value.ToString(),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind,
|
||||
out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?> BuildGreetingContextUpdates(string route, string? speakerId,
|
||||
bool proactive)
|
||||
{
|
||||
var updates = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[ChitchatStateMachine.StateMetadataKey] = "complete",
|
||||
[ChitchatStateMachine.RouteMetadataKey] = "ScriptedResponse",
|
||||
[ChitchatStateMachine.EmotionMetadataKey] = string.Empty,
|
||||
[GreetingRouteMetadataKey] = route,
|
||||
[GreetingSpeakerMetadataKey] = speakerId ?? string.Empty,
|
||||
[proactive ? LastProactiveGreetingUtcMetadataKey : LastReactiveGreetingUtcMetadataKey] = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private void RecordGreetingPresence(
|
||||
TurnContext turn,
|
||||
GreetingPresenceProfile presence,
|
||||
string route,
|
||||
string intentName,
|
||||
string? preferredName,
|
||||
bool proactive)
|
||||
{
|
||||
if (cloudStateStore is null) return;
|
||||
|
||||
var identityId = ResolveGreetingHistoryIdentity(presence);
|
||||
if (string.IsNullOrWhiteSpace(identityId)) return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var tenantScope = ResolveTenantScope(turn, identityId);
|
||||
cloudStateStore.UpsertGreetingPresence(new GreetingPresenceRecord
|
||||
{
|
||||
AccountId = tenantScope.AccountId,
|
||||
LoopId = tenantScope.LoopId,
|
||||
PersonId = identityId,
|
||||
SpeakerId = presence.SpeakerId,
|
||||
PreferredName = preferredName,
|
||||
LastSeenUtc = now,
|
||||
LastGreetedUtc = now,
|
||||
LastGreetingRoute = route,
|
||||
LastGreetingIntent = intentName
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record SpecialGreetingPrefix(string Route, string IntentName, string Prefix);
|
||||
|
||||
private static string ResolveTimeOfDayGreetingPrefix(DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour;
|
||||
return hour switch
|
||||
{
|
||||
>= 5 and < 12 => "Good morning",
|
||||
>= 12 and < 17 => "Good afternoon",
|
||||
_ => "Good evening"
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildProactiveGreetingReply(
|
||||
TurnContext turn,
|
||||
GreetingPresenceProfile presence,
|
||||
string? displayName,
|
||||
DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
var greetingHistory = ResolveGreetingHistoryRecord(turn, presence);
|
||||
var greetingPrefix = ResolveProactiveGreetingPrefix(referenceLocalTime, greetingHistory);
|
||||
|
||||
if (string.Equals(greetingPrefix, "Welcome back", StringComparison.OrdinalIgnoreCase))
|
||||
return string.IsNullOrWhiteSpace(displayName)
|
||||
? "Welcome back. I am glad to see you again."
|
||||
: $"Welcome back, {displayName}. I am glad to see you again.";
|
||||
|
||||
return string.IsNullOrWhiteSpace(displayName)
|
||||
? $"{greetingPrefix}. I am glad to see you."
|
||||
: $"{greetingPrefix}, {displayName}. It is great to see you.";
|
||||
}
|
||||
|
||||
private static string ResolveProactiveGreetingPrefix(
|
||||
DateTimeOffset? referenceLocalTime,
|
||||
GreetingPresenceRecord? greetingHistory)
|
||||
{
|
||||
var hour = (referenceLocalTime ?? DateTimeOffset.UtcNow).Hour;
|
||||
var isMorning = hour >= 5 && hour < 12;
|
||||
var recentGreeting = greetingHistory?.LastGreetedUtc is not null &&
|
||||
DateTimeOffset.UtcNow - greetingHistory.LastGreetedUtc.Value < TimeSpan.FromHours(8);
|
||||
|
||||
if (recentGreeting) return "Welcome back";
|
||||
|
||||
return isMorning ? "Good morning" : ResolveTimeOfDayGreetingPrefix(referenceLocalTime);
|
||||
}
|
||||
|
||||
private SpecialGreetingPrefix? ResolveSpecialGreetingPrefix(
|
||||
TurnContext turn,
|
||||
GreetingPresenceProfile presence,
|
||||
DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
var today = DateOnly.FromDateTime((referenceLocalTime ?? DateTimeOffset.UtcNow).Date);
|
||||
var birthday = ResolveBirthdayGreeting(turn, presence, today);
|
||||
if (birthday is not null) return birthday;
|
||||
|
||||
return ResolveHolidayGreeting(turn, today);
|
||||
}
|
||||
|
||||
private SpecialGreetingPrefix? ResolveBirthdayGreeting(
|
||||
TurnContext turn,
|
||||
GreetingPresenceProfile presence,
|
||||
DateOnly today)
|
||||
{
|
||||
var identityScope = !string.IsNullOrWhiteSpace(presence.PrimaryPersonId)
|
||||
? ResolveTenantScope(turn, presence.PrimaryPersonId)
|
||||
: ResolveTenantScope(turn);
|
||||
|
||||
var birthdayText = personalMemoryStore.GetBirthday(identityScope) ??
|
||||
personalMemoryStore.GetBirthday(ResolveTenantScope(turn));
|
||||
if (string.IsNullOrWhiteSpace(birthdayText)) return null;
|
||||
|
||||
var birthdayDate = TryParseBirthdayDate(birthdayText);
|
||||
if (birthdayDate is null) return null;
|
||||
|
||||
return birthdayDate.Value.Month == today.Month && birthdayDate.Value.Day == today.Day
|
||||
? new SpecialGreetingPrefix("ProactiveBirthdayGreeting", "proactive_birthday_greeting",
|
||||
"Happy birthday")
|
||||
: null;
|
||||
}
|
||||
|
||||
private SpecialGreetingPrefix? ResolveHolidayGreeting(TurnContext turn, DateOnly today)
|
||||
{
|
||||
if (cloudStateStore is null) return null;
|
||||
|
||||
var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop";
|
||||
var holiday = cloudStateStore.GetHolidays(loopId)
|
||||
.FirstOrDefault(item =>
|
||||
item.IsEnabled &&
|
||||
item.Category != "birthday" &&
|
||||
item.Date.Month == today.Month &&
|
||||
item.Date.Day == today.Day);
|
||||
|
||||
return holiday is null
|
||||
? null
|
||||
: new SpecialGreetingPrefix("ProactiveHolidayGreeting", "proactive_holiday_greeting",
|
||||
"Happy holidays");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildPizzaDecision()
|
||||
{
|
||||
return BuildPizzaAnimationDecision("pizza", "One pizza, coming right up.");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildPizzaAnimationDecision(string intentName, string replyText)
|
||||
{
|
||||
var prompt = randomizer.Choose(PizzaMimPrompts);
|
||||
return new JiboInteractionDecision(
|
||||
intentName,
|
||||
replyText,
|
||||
"chitchat-skill",
|
||||
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["esml"] = prompt.Esml,
|
||||
["mim_id"] = "RA_JBO_MakePizza",
|
||||
["mim_type"] = "announcement",
|
||||
["prompt_id"] = prompt.PromptId,
|
||||
["prompt_sub_category"] = "AN"
|
||||
});
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildProactivePizzaDayDecision(DateTimeOffset? referenceLocalTime)
|
||||
{
|
||||
var referenceDate = (referenceLocalTime ?? DateTimeOffset.UtcNow).Date;
|
||||
return BuildPizzaAnimationDecision(
|
||||
"proactive_pizza_day",
|
||||
$"Happy National Pizza Day for {referenceDate.ToString("MMMM d", CultureInfo.InvariantCulture)}. One pizza, coming right up.");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildProactivePizzaPreferenceDecision()
|
||||
{
|
||||
return BuildPizzaAnimationDecision(
|
||||
"proactive_pizza_preference",
|
||||
"You mentioned pizza is a favorite, so I thought we should make one.");
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildProactivePizzaFactOfferDecision()
|
||||
{
|
||||
var listenContexts = new[] { "shared/yes_no" };
|
||||
return new JiboInteractionDecision(
|
||||
"proactive_offer_pizza_fact",
|
||||
"Do you want to hear a fun pizza fact?",
|
||||
"chitchat-skill",
|
||||
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["mim_id"] = "runtime-chat",
|
||||
["mim_type"] = "question",
|
||||
["prompt_id"] = "RUNTIME_PROMPT",
|
||||
["prompt_sub_category"] = "Q",
|
||||
["listen_contexts"] = listenContexts
|
||||
});
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildProactivePizzaFactDecision()
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
"proactive_pizza_fact",
|
||||
"Americans consume about 100 acres of pizza every day, roughly 350 slices per second. That's a lot of pizza.");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildProactiveFunFactDecision(JiboExperienceCatalog catalog)
|
||||
{
|
||||
var categories = new List<ProactiveFactCategory>();
|
||||
AddProactiveFactCategory(categories, "fun_fact", catalog.FunFacts);
|
||||
AddProactiveFactCategory(categories, "robot_fact", catalog.RobotFacts);
|
||||
AddProactiveFactCategory(categories, "human_fact", catalog.HumanFacts);
|
||||
|
||||
if (categories.Count == 0)
|
||||
return new JiboInteractionDecision("proactive_fun_fact", randomizer.Choose(catalog.SurpriseReplies));
|
||||
|
||||
var selectedCategory = randomizer.Choose(categories);
|
||||
var fact = randomizer.Choose(selectedCategory.Replies);
|
||||
return new JiboInteractionDecision(
|
||||
"proactive_fun_fact",
|
||||
fact,
|
||||
"chitchat-skill",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["mim_id"] = "runtime-fun-fact",
|
||||
["mim_type"] = "announcement",
|
||||
["prompt_id"] = "RUNTIME_FUN_FACT",
|
||||
["replyType"] = "fun_fact",
|
||||
["factCategory"] = selectedCategory.CategoryName
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddProactiveFactCategory(
|
||||
ICollection<ProactiveFactCategory> categories,
|
||||
string categoryName,
|
||||
IReadOnlyList<string> replies)
|
||||
{
|
||||
if (replies.Count == 0) return;
|
||||
|
||||
categories.Add(new ProactiveFactCategory(categoryName, replies));
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildProactiveJokeDecision(JiboExperienceCatalog catalog)
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
"proactive_joke",
|
||||
randomizer.Choose(catalog.Jokes),
|
||||
"@be/joke",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["replyType"] = "joke"
|
||||
});
|
||||
}
|
||||
|
||||
private static JiboInteractionDecision BuildProactiveOfferDeclinedDecision()
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
"proactive_offer_declined",
|
||||
"No problem. We can save the pizza fact for another time.");
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildWhatIsYourSignDecision()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.Date);
|
||||
var birthday = OpenJiboCloudBuildInfo.PersonaBirthday;
|
||||
var zodiac = DescribeZodiacSign(birthday);
|
||||
var reply = birthday.Month == today.Month && birthday.Day == today.Day
|
||||
? $"{zodiac}. Today is my birthday."
|
||||
: $"{zodiac}. I was first powered up on {OpenJiboCloudBuildInfo.PersonaBirthdayWords}.";
|
||||
|
||||
return new JiboInteractionDecision(
|
||||
"robot_what_is_your_sign",
|
||||
reply,
|
||||
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildHowManyPeopleDoYouKnowDecision(TurnContext turn)
|
||||
{
|
||||
var people = GetLoopPeople(turn);
|
||||
var speaker = ResolvePreferredGreetingName(turn, ResolveGreetingPresenceProfile(turn));
|
||||
var reply = people.Count switch
|
||||
{
|
||||
0 => "Well if we're talking about people in my Loop, I do not know anyone yet.",
|
||||
1 when string.IsNullOrWhiteSpace(speaker) =>
|
||||
"Well if we're talking about people in my Loop, I know 1 person.",
|
||||
1 => $"Well there is 1 person in our Loop. And it's you {speaker}.",
|
||||
_ when string.IsNullOrWhiteSpace(speaker) =>
|
||||
$"Well if we're talking about people in my Loop, I know {people.Count} people.",
|
||||
_ => $"Well there are {people.Count} people in our Loop."
|
||||
};
|
||||
|
||||
return new JiboInteractionDecision(
|
||||
"robot_how_many_people_do_you_know",
|
||||
reply,
|
||||
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildWhatIsTheLoopDecision(TurnContext turn)
|
||||
{
|
||||
var people = GetLoopPeople(turn);
|
||||
var reply = people.Count == 0
|
||||
? "The Loop is the people I know, and whose faces and voices I can learn to recognize. There can be up to 16 people in the Loop."
|
||||
: $"The Loop is the group of people I know. They're the people whose voices and faces I can learn. Right now, my Loop is {JoinWithAnd(people.Select(person => person.DisplayName).ToArray())}.";
|
||||
|
||||
return new JiboInteractionDecision(
|
||||
"robot_what_is_the_loop",
|
||||
reply,
|
||||
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
|
||||
}
|
||||
|
||||
private IReadOnlyList<PersonRecord> GetLoopPeople(TurnContext turn)
|
||||
{
|
||||
if (cloudStateStore is null) return [];
|
||||
|
||||
var loopId = ReadTenantAttribute(turn, "loopId") ?? "openjibo-default-loop";
|
||||
return cloudStateStore.GetPeople()
|
||||
.Where(person => string.Equals(person.LoopId, loopId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(person => person.IsPrimary ? 0 : 1)
|
||||
.ThenBy(person => person.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string JoinWithAnd(IReadOnlyList<string> values)
|
||||
{
|
||||
if (values.Count == 0) return string.Empty;
|
||||
if (values.Count == 1) return values[0];
|
||||
if (values.Count == 2) return $"{values[0]} and {values[1]}";
|
||||
|
||||
return $"{string.Join(", ", values.Take(values.Count - 1))}, and {values[^1]}";
|
||||
}
|
||||
|
||||
private static string DescribeZodiacSign(DateOnly birthday)
|
||||
{
|
||||
return (birthday.Month, birthday.Day) switch
|
||||
{
|
||||
(3, >= 21) or (4, <= 19) => "I'm Aries",
|
||||
(4, >= 20) or (5, <= 20) => "I'm Taurus",
|
||||
(5, >= 21) or (6, <= 20) => "I'm Gemini",
|
||||
(6, >= 21) or (7, <= 22) => "I'm Cancer",
|
||||
(7, >= 23) or (8, <= 22) => "I'm Leo",
|
||||
(8, >= 23) or (9, <= 22) => "I'm Virgo",
|
||||
(9, >= 23) or (10, <= 22) => "I'm Libra",
|
||||
(10, >= 23) or (11, <= 21) => "I'm Scorpio",
|
||||
(11, >= 22) or (12, <= 21) => "I'm Sagittarius",
|
||||
(12, >= 22) or (1, <= 19) => "I'm Capricorn",
|
||||
(1, >= 20) or (2, <= 18) => "I'm Aquarius",
|
||||
_ => "I'm Pisces"
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildGenericReply(JiboExperienceCatalog catalog, string transcript, string lowered)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(transcript)) return "I am listening.";
|
||||
|
||||
if (lowered.Contains("good morning", StringComparison.Ordinal))
|
||||
return "Good morning! It is nice to hear your voice.";
|
||||
|
||||
if (lowered.Contains("good afternoon", StringComparison.Ordinal))
|
||||
return "Good afternoon. I am happy to be here.";
|
||||
|
||||
return lowered.Contains("good night", StringComparison.Ordinal)
|
||||
? "Good night. Sleep tight."
|
||||
: randomizer.Choose(catalog.GenericFallbackReplies)
|
||||
.Replace("{transcript}", transcript, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedPersonalityDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return ScriptedResponseDecisionBuilder.BuildScriptedPersonalityDecision(
|
||||
catalog,
|
||||
randomizer,
|
||||
intentName,
|
||||
preferredSnippets);
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedFavoriteAnimalDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return ScriptedResponseDecisionBuilder.BuildScriptedFavoriteAnimalDecision(
|
||||
catalog,
|
||||
randomizer,
|
||||
intentName,
|
||||
preferredSnippets);
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedFriendDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
intentName,
|
||||
SelectLegacyReply(catalog.FriendReplies, preferredSnippets),
|
||||
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedBestFriendDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
intentName,
|
||||
SelectLegacyReply(catalog.BestFriendReplies, preferredSnippets),
|
||||
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedSingDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
intentName,
|
||||
SelectLegacyReply(catalog.SingReplies, preferredSnippets),
|
||||
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedHolidaySingDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return new JiboInteractionDecision(
|
||||
intentName,
|
||||
SelectLegacyReply(catalog.HolidaySingReplies, preferredSnippets),
|
||||
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedGreetingDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return ScriptedResponseDecisionBuilder.BuildScriptedGreetingDecision(
|
||||
catalog,
|
||||
randomizer,
|
||||
intentName,
|
||||
preferredSnippets);
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedHolidayDecision(
|
||||
IReadOnlyList<string> replies,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return ScriptedResponseDecisionBuilder.BuildScriptedHolidayDecision(
|
||||
replies,
|
||||
randomizer,
|
||||
intentName,
|
||||
preferredSnippets);
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedHolidayTrackerDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return ScriptedResponseDecisionBuilder.BuildScriptedHolidayTrackerDecision(
|
||||
catalog,
|
||||
randomizer,
|
||||
intentName,
|
||||
preferredSnippets);
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedHolidayGreetingDecision(
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
return ScriptedResponseDecisionBuilder.BuildScriptedHolidayGreetingDecision(
|
||||
catalog,
|
||||
randomizer,
|
||||
intentName,
|
||||
preferredSnippets);
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildScriptedHolidayTemplateDecision(
|
||||
TurnContext turn,
|
||||
GreetingPresenceProfile presence,
|
||||
JiboExperienceCatalog catalog,
|
||||
string intentName,
|
||||
params string[] preferredSnippets)
|
||||
{
|
||||
var selected = ScriptedResponseDecisionBuilder.SelectLegacyReply(
|
||||
catalog.HolidayReplies,
|
||||
randomizer,
|
||||
preferredSnippets);
|
||||
return new JiboInteractionDecision(
|
||||
intentName,
|
||||
RenderHolidayTemplate(selected, turn, presence),
|
||||
ContextUpdates: ScriptedResponseDecisionBuilder.BuildScriptedResponseContextUpdates());
|
||||
}
|
||||
|
||||
private string SelectLegacyPersonalityReply(JiboExperienceCatalog catalog, params string[] preferredSnippets)
|
||||
{
|
||||
return ScriptedResponseDecisionBuilder.SelectLegacyPersonalityReply(catalog, randomizer, preferredSnippets);
|
||||
}
|
||||
|
||||
private string SelectLegacyGreetingReply(JiboExperienceCatalog catalog, params string[] preferredSnippets)
|
||||
{
|
||||
return ScriptedResponseDecisionBuilder.SelectLegacyGreetingReply(catalog, randomizer, preferredSnippets);
|
||||
}
|
||||
|
||||
private string SelectLegacyReply(IReadOnlyList<string> replies, params string[] preferredSnippets)
|
||||
{
|
||||
return ScriptedResponseDecisionBuilder.SelectLegacyReply(replies, randomizer, preferredSnippets);
|
||||
}
|
||||
|
||||
private string RenderHolidayTemplate(string template, TurnContext turn, GreetingPresenceProfile presence)
|
||||
{
|
||||
var ownerName = ResolvePreferredGreetingName(turn, presence);
|
||||
var speakerName = !string.IsNullOrWhiteSpace(ownerName) ? ownerName : "you";
|
||||
return template
|
||||
.Replace("${speaker}'s", $"{speakerName}'s", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("${speaker}", speakerName, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("${loop.owner}", string.IsNullOrWhiteSpace(ownerName) ? string.Empty : ownerName,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(" ", " ", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
}
|
||||
@@ -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,7 +24,8 @@ public sealed class JiboWebSocketService(
|
||||
if (envelope.IsBinary)
|
||||
{
|
||||
var replies = await turnFinalizationService.HandleBinaryAudioAsync(session, envelope, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received", new Dictionary<string, object?>
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["bytes"] = envelope.Binary?.Length ?? 0,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
@@ -50,7 +52,8 @@ public sealed class JiboWebSocketService(
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored", new Dictionary<string, object?>
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "late_listen_ignored",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["messageType"] = parsedType,
|
||||
["activeTransID"] = session.TurnState.TransId,
|
||||
@@ -65,7 +68,8 @@ public sealed class JiboWebSocketService(
|
||||
WebSocketTurnFinalizationService.TryRecoverStalePendingListen(session, out staleListenAgeMs))
|
||||
{
|
||||
staleListenRecovered = true;
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered", new Dictionary<string, object?>
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "glsm_stale_listen_recovered",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["staleAgeMs"] = staleListenAgeMs,
|
||||
["transID"] = session.TurnState.TransId,
|
||||
@@ -80,7 +84,8 @@ public sealed class JiboWebSocketService(
|
||||
case "CONTEXT":
|
||||
{
|
||||
var replies = await turnFinalizationService.HandleContextAsync(session, envelope, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received", new Dictionary<string, object?>
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["transID"] = session.TurnState.TransId,
|
||||
["glsmPhase"] = WebSocketTurnFinalizationService.ResolveGlsmPhase(session)
|
||||
@@ -92,7 +97,8 @@ public sealed class JiboWebSocketService(
|
||||
var replies = containsInlineTurnPayload
|
||||
? await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken)
|
||||
: WebSocketTurnFinalizationService.HandleListenSetup(session, envelope);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["messageType"] = parsedType,
|
||||
["replyCount"] = replies.Count,
|
||||
@@ -106,8 +112,10 @@ public sealed class JiboWebSocketService(
|
||||
}
|
||||
case "CLIENT_NLU" or "CLIENT_ASR" or "TRIGGER":
|
||||
{
|
||||
var replies = await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
|
||||
var replies =
|
||||
await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["messageType"] = parsedType,
|
||||
["replyCount"] = replies.Count,
|
||||
@@ -124,19 +132,14 @@ public sealed class JiboWebSocketService(
|
||||
|
||||
private static string ReadMessageType(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return "UNKNOWN";
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(text)) return "UNKNOWN";
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(text);
|
||||
if (document.RootElement.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return type.GetString() ?? "UNKNOWN";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "TEXT";
|
||||
@@ -147,25 +150,18 @@ public sealed class JiboWebSocketService(
|
||||
|
||||
private static bool ContainsInlineTurnPayload(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(text)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(text);
|
||||
if (!document.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!document.RootElement.TryGetProperty("data", out var data) ||
|
||||
data.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
if (data.TryGetProperty("text", out var transcript) &&
|
||||
transcript.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(transcript.GetString()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return data.TryGetProperty("asr", out var asr) &&
|
||||
asr.ValueKind == JsonValueKind.Object &&
|
||||
@@ -186,10 +182,7 @@ public sealed class JiboWebSocketService(
|
||||
var transId = session.TurnState.TransId ?? session.LastTransId ?? string.Empty;
|
||||
var rules = session.TurnState.ListenRules;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return (transId, rules);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(text)) return (transId, rules);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -199,9 +192,7 @@ public sealed class JiboWebSocketService(
|
||||
if (root.TryGetProperty("transID", out var transIdValue) &&
|
||||
transIdValue.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(transIdValue.GetString()))
|
||||
{
|
||||
transId = transIdValue.GetString()!;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("data", out var data) &&
|
||||
data.ValueKind == JsonValueKind.Object &&
|
||||
@@ -214,10 +205,7 @@ public sealed class JiboWebSocketService(
|
||||
.Where(static rule => !string.IsNullOrWhiteSpace(rule))
|
||||
.ToArray();
|
||||
|
||||
if (parsedRules.Length > 0)
|
||||
{
|
||||
rules = parsedRules;
|
||||
}
|
||||
if (parsedRules.Length > 0) rules = parsedRules;
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -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 RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task RecordTurnDiagnosticAsync(string category, IReadOnlyDictionary<string, object?> details,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RecordTranscriptError(Exception ex, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
if (words.Length > 4) return null;
|
||||
|
||||
return words.Any(static word => word.Any(char.IsDigit)) ? null : cleaned;
|
||||
}
|
||||
|
||||
private static string ChoosePersonalReportTemplate(
|
||||
IReadOnlyList<string> templates,
|
||||
string fallback)
|
||||
{
|
||||
return null;
|
||||
var usableTemplates = templates
|
||||
.Where(static template => !string.IsNullOrWhiteSpace(template) &&
|
||||
!template.Contains("${dt.", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (usableTemplates.Length == 0) return fallback;
|
||||
|
||||
var speakerAwareTemplate = usableTemplates.FirstOrDefault(static template =>
|
||||
template.Contains("${speaker}", StringComparison.OrdinalIgnoreCase));
|
||||
return ChooseShortestTemplate(speakerAwareTemplate is not null ? [speakerAwareTemplate] : usableTemplates)
|
||||
?? fallback;
|
||||
}
|
||||
|
||||
if (words.Any(static word => word.Any(char.IsDigit)))
|
||||
private static string RenderPersonalReportTemplate(string template, string userName)
|
||||
{
|
||||
return null;
|
||||
return template
|
||||
.Replace("${speaker}", userName, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("${speaker}'s", $"{userName}'s", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(" ", " ", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
private static string ChooseReportSkillTemplate(
|
||||
IReadOnlyList<string> primaryTemplates,
|
||||
IReadOnlyList<string> secondaryTemplates,
|
||||
string fallback)
|
||||
{
|
||||
var primary = ChooseShortestTemplate(primaryTemplates);
|
||||
if (!string.IsNullOrWhiteSpace(primary)) return primary!;
|
||||
|
||||
var secondary = ChooseShortestTemplate(secondaryTemplates);
|
||||
return !string.IsNullOrWhiteSpace(secondary) ? secondary! : fallback;
|
||||
}
|
||||
|
||||
private static readonly Regex NameNoiseRegex = new("[^a-zA-Z\\-\\s']", RegexOptions.Compiled);
|
||||
private static string ChooseShortestBriefing(IReadOnlyList<string> briefings)
|
||||
{
|
||||
var selected = ChooseShortestTemplate(briefings);
|
||||
if (string.IsNullOrWhiteSpace(selected)) return string.Empty;
|
||||
|
||||
var firstSentence = selected.Split(['.', '!', '?'], 2,
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault();
|
||||
return string.IsNullOrWhiteSpace(firstSentence) ? selected : firstSentence;
|
||||
}
|
||||
|
||||
private static string ChooseFirstSentence(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
|
||||
var firstSentence = value.Split(['.', '!', '?'], 2,
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault();
|
||||
return string.IsNullOrWhiteSpace(firstSentence) ? value.Trim() : firstSentence;
|
||||
}
|
||||
|
||||
private static string ChooseFirstTwoSentences(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
|
||||
var segments = value
|
||||
.Split(['.', '!', '?'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
|
||||
if (segments.Length == 0) return string.Empty;
|
||||
|
||||
var joined = string.Join(". ", segments);
|
||||
return value.TrimEnd().EndsWith(".", StringComparison.Ordinal) ||
|
||||
value.TrimEnd().EndsWith("!", StringComparison.Ordinal) ||
|
||||
value.TrimEnd().EndsWith("?", StringComparison.Ordinal)
|
||||
? $"{joined}."
|
||||
: joined;
|
||||
}
|
||||
|
||||
private static string? ChooseShortestTemplate(IEnumerable<string> templates)
|
||||
{
|
||||
var selected = templates
|
||||
.Where(static template => !string.IsNullOrWhiteSpace(template))
|
||||
.OrderBy(static template => template.Length)
|
||||
.FirstOrDefault();
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static string RenderReportSkillTemplate(string template, string userName)
|
||||
{
|
||||
return template
|
||||
.Replace("${speaker}", userName, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("${speaker}'s", $"{userName}'s", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(" ", " ", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private static string EscapeForEsml(string value)
|
||||
{
|
||||
return value
|
||||
.Replace("&", "&", 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,46 +17,28 @@ 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)
|
||||
{
|
||||
@@ -63,41 +46,29 @@ public sealed class ProtocolToTurnContextMapper
|
||||
!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
|
||||
{
|
||||
@@ -111,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
|
||||
};
|
||||
@@ -120,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
|
||||
{
|
||||
@@ -133,57 +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,14 +32,19 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
var isVolumeControl = string.Equals(plan.IntentName, "volume_up", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(plan.IntentName, "volume_down", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(plan.IntentName, "volume_to_value", StringComparison.OrdinalIgnoreCase);
|
||||
var isProactivePizzaFactOffer = string.Equals(plan.IntentName, "proactive_offer_pizza_fact", StringComparison.OrdinalIgnoreCase);
|
||||
var isProactivePizzaFactOffer = string.Equals(plan.IntentName, "proactive_offer_pizza_fact",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
var isSettingsLaunch = string.Equals(skill?.SkillName, "@be/settings", StringComparison.OrdinalIgnoreCase);
|
||||
var isGlobalCommand = isStopCommand || isVolumeControl;
|
||||
var isSleepCommand = string.Equals(plan.IntentName, "sleep", StringComparison.OrdinalIgnoreCase);
|
||||
var isSpinAroundCommand = string.Equals(plan.IntentName, "spin_around", StringComparison.OrdinalIgnoreCase);
|
||||
var isGlobalCommand = isStopCommand || isSleepCommand || isSpinAroundCommand || isVolumeControl;
|
||||
var isPhotoGalleryLaunch = string.Equals(plan.IntentName, "photo_gallery", StringComparison.OrdinalIgnoreCase);
|
||||
var isPhotoCreateLaunch = string.Equals(plan.IntentName, "snapshot", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(plan.IntentName, "photobooth", StringComparison.OrdinalIgnoreCase);
|
||||
var isClockSkillLaunch = string.Equals(skill?.SkillName, "@be/clock", StringComparison.OrdinalIgnoreCase);
|
||||
var isReportSkillLaunch = string.Equals(skill?.SkillName, "report-skill", StringComparison.OrdinalIgnoreCase);
|
||||
var idleRedirectDelayMs = isSleepCommand ? 150 : isSpinAroundCommand ? 75 : 75;
|
||||
var idleCompletionDelayMs = isSleepCommand ? 1000 : isSpinAroundCommand ? 750 : 125;
|
||||
var localIntent = ReadSkillPayloadString(skill, "localIntent");
|
||||
var clockIntent = ReadSkillPayloadString(skill, "clockIntent");
|
||||
var clockDomain = ReadSkillPayloadString(skill, "domain");
|
||||
@@ -72,7 +78,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
? localIntent
|
||||
: isWordOfDayGuess
|
||||
? "guess"
|
||||
: string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) &&
|
||||
: string.Equals(messageType, "CLIENT_NLU",
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(clientIntent)
|
||||
? clientIntent
|
||||
: plan.IntentName ?? "unknown";
|
||||
@@ -209,10 +216,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
outboundAsrText,
|
||||
outboundRules,
|
||||
entities)),
|
||||
DelayMs: 75));
|
||||
75));
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/word-of-the-day")),
|
||||
DelayMs: 125));
|
||||
125));
|
||||
}
|
||||
|
||||
if (isRadioLaunch)
|
||||
@@ -225,13 +232,13 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
outboundAsrText,
|
||||
outboundRules,
|
||||
entities)),
|
||||
DelayMs: 75));
|
||||
75));
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/radio")),
|
||||
DelayMs: 125));
|
||||
125));
|
||||
}
|
||||
|
||||
if (isStopCommand)
|
||||
if (isStopCommand || isSleepCommand || isSpinAroundCommand)
|
||||
{
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildSkillRedirectPayload(
|
||||
@@ -241,10 +248,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
outboundAsrText,
|
||||
outboundRules,
|
||||
entities)),
|
||||
DelayMs: 75));
|
||||
idleRedirectDelayMs));
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/idle")),
|
||||
DelayMs: 125));
|
||||
idleCompletionDelayMs));
|
||||
}
|
||||
|
||||
if (isSettingsLaunch &&
|
||||
@@ -258,10 +265,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
outboundAsrText,
|
||||
outboundRules,
|
||||
entities)),
|
||||
DelayMs: 75));
|
||||
75));
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/settings")),
|
||||
DelayMs: 125));
|
||||
125));
|
||||
}
|
||||
|
||||
if (isClockSkillLaunch &&
|
||||
@@ -276,10 +283,10 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
outboundAsrText,
|
||||
outboundRules,
|
||||
entities)),
|
||||
DelayMs: 75));
|
||||
75));
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "@be/clock")),
|
||||
DelayMs: 125));
|
||||
125));
|
||||
}
|
||||
|
||||
if ((isPhotoGalleryLaunch || isPhotoCreateLaunch) &&
|
||||
@@ -294,34 +301,16 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
outboundAsrText,
|
||||
outboundRules,
|
||||
entities)),
|
||||
DelayMs: 75));
|
||||
75));
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, skillId)),
|
||||
DelayMs: 125));
|
||||
}
|
||||
|
||||
if (isReportSkillLaunch)
|
||||
{
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildSkillRedirectPayload(
|
||||
transId,
|
||||
"report-skill",
|
||||
outboundIntent,
|
||||
outboundAsrText,
|
||||
outboundRules,
|
||||
entities)),
|
||||
DelayMs: 75));
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildCompletionOnlySkillPayload(transId, "report-skill")),
|
||||
DelayMs: 125));
|
||||
125));
|
||||
}
|
||||
|
||||
if (emitSkillActions && speak is not null)
|
||||
{
|
||||
messages.Add(new SocketReplyPlan(
|
||||
JsonSerializer.Serialize(BuildSkillPayload(plan, turn, transId, speak, skill)),
|
||||
DelayMs: 75));
|
||||
}
|
||||
75));
|
||||
|
||||
return messages;
|
||||
}
|
||||
@@ -367,7 +356,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
transID = transId,
|
||||
data = new { }
|
||||
})),
|
||||
new SocketReplyPlan(JsonSerializer.Serialize(BuildGenericFallbackSkillPayload(transId)), DelayMs: 75)
|
||||
new SocketReplyPlan(JsonSerializer.Serialize(BuildGenericFallbackSkillPayload(transId)), 75)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -442,10 +431,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
? "clientRules"
|
||||
: "listenRules";
|
||||
|
||||
if (!turn.Attributes.TryGetValue(attributeName, out var value))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
if (!turn.Attributes.TryGetValue(attributeName, out var value)) return [];
|
||||
|
||||
return value switch
|
||||
{
|
||||
@@ -481,10 +467,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
if (yesNoTurn)
|
||||
{
|
||||
if (!includeCreateDomain)
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
if (!includeCreateDomain) return new Dictionary<string, object?>();
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
@@ -493,20 +476,15 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
}
|
||||
|
||||
if (wordOfDayLaunch)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["domain"] = "word-of-the-day"
|
||||
};
|
||||
}
|
||||
|
||||
if (globalCommand)
|
||||
{
|
||||
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(volumeLevel))
|
||||
{
|
||||
entities["volumeLevel"] = volumeLevel;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(volumeLevel)) entities["volumeLevel"] = volumeLevel;
|
||||
|
||||
return entities;
|
||||
}
|
||||
@@ -514,10 +492,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
if (radioLaunch)
|
||||
{
|
||||
var entities = new Dictionary<string, object?>();
|
||||
if (!string.IsNullOrWhiteSpace(radioStation))
|
||||
{
|
||||
entities["station"] = radioStation;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(radioStation)) entities["station"] = radioStation;
|
||||
|
||||
return entities;
|
||||
}
|
||||
@@ -525,10 +500,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
if (clockSkillLaunch)
|
||||
{
|
||||
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(clockDomain))
|
||||
{
|
||||
entities["domain"] = clockDomain;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(clockDomain)) entities["domain"] = clockDomain;
|
||||
|
||||
if (string.Equals(clockDomain, "timer", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(timerHours + timerMinutes + timerSeconds))
|
||||
@@ -550,32 +522,22 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
if (reportSkillLaunch)
|
||||
{
|
||||
var entities = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(reportDate))
|
||||
{
|
||||
entities["date"] = reportDate;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(reportDate)) entities["date"] = reportDate;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reportWeatherCondition))
|
||||
{
|
||||
entities["Weather"] = reportWeatherCondition;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(reportWeatherCondition)) entities["Weather"] = reportWeatherCondition;
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
if (wordOfDayGuess)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["guess"] = guess ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.Equals(messageType, "CLIENT_NLU", StringComparison.OrdinalIgnoreCase) ||
|
||||
!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null)
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
@@ -611,10 +573,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
|
||||
private static IEnumerable<string> ReadRuleValues(TurnContext turn, string key)
|
||||
{
|
||||
if (!turn.Attributes.TryGetValue(key, out var value) || value is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
if (!turn.Attributes.TryGetValue(key, out var value) || value is null) return [];
|
||||
|
||||
return value switch
|
||||
{
|
||||
@@ -636,10 +595,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
|
||||
private static string? ReadClientEntity(TurnContext turn, string entityName)
|
||||
{
|
||||
if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!turn.Attributes.TryGetValue("clientEntities", out var value) || value is null) return null;
|
||||
|
||||
return value switch
|
||||
{
|
||||
@@ -657,20 +613,14 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
|
||||
private static string? ReadSkillPayloadString(InvokeNativeSkillAction? skill, string key)
|
||||
{
|
||||
if (skill?.Payload is null || !skill.Payload.TryGetValue(key, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (skill?.Payload is null || !skill.Payload.TryGetValue(key, out var value)) return null;
|
||||
|
||||
return value?.ToString();
|
||||
}
|
||||
|
||||
private static string ResolveWordOfDayGuess(TurnContext turn, string transcript, string? nluGuess)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(nluGuess))
|
||||
{
|
||||
return nluGuess;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(nluGuess)) return nluGuess;
|
||||
|
||||
var normalized = NormalizeGuessToken(transcript);
|
||||
var hintIndex = normalized switch
|
||||
@@ -684,11 +634,9 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
var hints = ReadRuleValues(turn, "listenAsrHints").ToArray();
|
||||
|
||||
if (hintIndex >= 0)
|
||||
{
|
||||
return hintIndex < hints.Length
|
||||
? hints[hintIndex]
|
||||
: transcript;
|
||||
}
|
||||
|
||||
var fuzzyHintMatch = FindClosestHint(normalized, hints);
|
||||
return string.IsNullOrWhiteSpace(fuzzyHintMatch)
|
||||
@@ -698,31 +646,19 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
|
||||
private static string? FindClosestHint(string normalizedTranscript, IReadOnlyList<string> hints)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(normalizedTranscript))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(normalizedTranscript)) return null;
|
||||
|
||||
string? bestHint = null;
|
||||
var bestDistance = int.MaxValue;
|
||||
|
||||
foreach (var hint in hints)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(hint)) continue;
|
||||
|
||||
var normalizedHint = NormalizeGuessToken(hint);
|
||||
if (string.IsNullOrWhiteSpace(normalizedHint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(normalizedHint)) continue;
|
||||
|
||||
if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal))
|
||||
{
|
||||
return hint;
|
||||
}
|
||||
if (string.Equals(normalizedTranscript, normalizedHint, StringComparison.Ordinal)) return hint;
|
||||
|
||||
var distance = ComputeEditDistance(normalizedTranscript, normalizedHint);
|
||||
if (distance >= bestDistance) continue;
|
||||
@@ -744,10 +680,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
var previous = new int[right.Length + 1];
|
||||
var current = new int[right.Length + 1];
|
||||
|
||||
for (var column = 0; column <= right.Length; column += 1)
|
||||
{
|
||||
previous[column] = column;
|
||||
}
|
||||
for (var column = 0; column <= right.Length; column += 1) previous[column] = column;
|
||||
|
||||
for (var row = 1; row <= left.Length; row += 1)
|
||||
{
|
||||
@@ -772,11 +705,9 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
var skillPayload = skill?.Payload;
|
||||
if (string.Equals(ReadPayloadString(skillPayload, "cloudResponseMode"), "completion_only",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BuildCompletionOnlySkillPayload(
|
||||
transId,
|
||||
ReadPayloadString(skillPayload, "skillId") ?? skill?.SkillName ?? "chitchat-skill");
|
||||
}
|
||||
|
||||
var isJoke = string.Equals(plan.IntentName, "joke", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(skill?.SkillName, "@be/joke", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -812,19 +743,21 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
};
|
||||
|
||||
if (listenContexts.Count > 0)
|
||||
{
|
||||
jcpConfig["listen"] = new
|
||||
{
|
||||
id = CreateProtocolId(),
|
||||
type = "LISTEN",
|
||||
contexts = listenContexts
|
||||
};
|
||||
}
|
||||
|
||||
var weatherHiLoView = BuildWeatherHiLoView(skillPayload);
|
||||
var weeklyWeatherCards = BuildWeatherHiLoSequenceCards(skillPayload);
|
||||
if (weatherHiLoView is null && weeklyWeatherCards.Count > 0) weatherHiLoView = weeklyWeatherCards[0].View;
|
||||
|
||||
var useWeatherSequence = false;
|
||||
if (weatherHiLoView is not null)
|
||||
{
|
||||
var resolvedGuiConfig = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
var resolvedGuiContext = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["type"] = "Javascript",
|
||||
["data"] = weatherHiLoView,
|
||||
@@ -841,12 +774,21 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
jcpConfig["gui"] = legacyGuiConfig;
|
||||
jcpConfig["display"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["view"] = resolvedGuiConfig
|
||||
["view"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Legacy fields used by existing tests and tooling.
|
||||
["type"] = "Javascript",
|
||||
["data"] = weatherHiLoView,
|
||||
["pause"] = true,
|
||||
// Pegasus-style view context used by on-robot weather cards.
|
||||
["context"] = resolvedGuiContext
|
||||
}
|
||||
};
|
||||
|
||||
playConfig["gui"] = resolvedGuiConfig;
|
||||
playConfig["no_matches_for_gui"] = 0;
|
||||
playConfig["no_inputs_for_gui"] = 0;
|
||||
jcpConfig["timeout"] = 6;
|
||||
jcpConfig["barge_in"] = true;
|
||||
jcpConfig["no_matches_for_gui"] = 0;
|
||||
jcpConfig["no_inputs_for_gui"] = 0;
|
||||
|
||||
var weatherViews = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -860,6 +802,30 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
["views"] = weatherViews
|
||||
};
|
||||
|
||||
if (weeklyWeatherCards.Count > 1)
|
||||
{
|
||||
useWeatherSequence = true;
|
||||
jcpConfig["children"] = BuildWeatherHiLoSequenceChildren(
|
||||
weeklyWeatherCards,
|
||||
promptSubCategory,
|
||||
mimId,
|
||||
mimType);
|
||||
}
|
||||
}
|
||||
|
||||
var jcp = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["type"] = "SLIM",
|
||||
["config"] = jcpConfig
|
||||
};
|
||||
if (useWeatherSequence &&
|
||||
jcpConfig.TryGetValue("children", out var sequenceChildren) &&
|
||||
sequenceChildren is not null)
|
||||
{
|
||||
jcp["type"] = "SEQUENCE";
|
||||
jcp.Remove("config");
|
||||
jcp["children"] = sequenceChildren;
|
||||
}
|
||||
|
||||
return new
|
||||
@@ -878,11 +844,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
config = new
|
||||
{
|
||||
jcp = new
|
||||
{
|
||||
type = "SLIM",
|
||||
config = jcpConfig
|
||||
}
|
||||
jcp
|
||||
}
|
||||
},
|
||||
analytics = new Dictionary<string, object?>(),
|
||||
@@ -906,15 +868,9 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
["entities"] = entities
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(skillId))
|
||||
{
|
||||
payload["skill"] = skillId;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(skillId)) payload["skill"] = skillId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(domain))
|
||||
{
|
||||
payload["domain"] = domain;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(domain)) payload["domain"] = domain;
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -1077,65 +1033,217 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
|
||||
private static string? ReadPayloadString(IDictionary<string, object?>? payload, string key)
|
||||
{
|
||||
if (payload is null || !payload.TryGetValue(key, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (payload is null || !payload.TryGetValue(key, out var value)) return null;
|
||||
|
||||
return value?.ToString();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadPayloadStringArray(IDictionary<string, object?>? payload, string key)
|
||||
{
|
||||
if (payload is null || !payload.TryGetValue(key, out var value) || value is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return [];
|
||||
|
||||
return value switch
|
||||
{
|
||||
string text => [.. text
|
||||
string text =>
|
||||
[
|
||||
.. text
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(static context => !string.IsNullOrWhiteSpace(context))],
|
||||
.Where(static context => !string.IsNullOrWhiteSpace(context))
|
||||
],
|
||||
string[] contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))],
|
||||
IEnumerable<string> contexts => [.. contexts.Where(static context => !string.IsNullOrWhiteSpace(context))],
|
||||
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Array => [.. jsonElement
|
||||
JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Array =>
|
||||
[
|
||||
.. jsonElement
|
||||
.EnumerateArray()
|
||||
.Select(static item => item.GetString())
|
||||
.Where(static context => !string.IsNullOrWhiteSpace(context))
|
||||
.Select(static context => context!)],
|
||||
IEnumerable<object?> contexts => [.. contexts
|
||||
.Select(static context => context!)
|
||||
],
|
||||
IEnumerable<object?> contexts =>
|
||||
[
|
||||
.. contexts
|
||||
.Select(static context => context?.ToString())
|
||||
.Where(static context => !string.IsNullOrWhiteSpace(context))
|
||||
.Select(static context => context!)],
|
||||
.Select(static context => context!)
|
||||
],
|
||||
_ => string.IsNullOrWhiteSpace(value.ToString()) ? [] : [value.ToString()!]
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WeatherHiLoSequenceCard> BuildWeatherHiLoSequenceCards(
|
||||
IDictionary<string, object?>? payload)
|
||||
{
|
||||
if (payload is null ||
|
||||
!payload.TryGetValue("weather_weekly_cards", out var rawCards) ||
|
||||
rawCards is null)
|
||||
return [];
|
||||
|
||||
var cards = ReadPayloadObjectArray(rawCards);
|
||||
if (cards.Count == 0) return [];
|
||||
|
||||
var sequenceCards = new List<WeatherHiLoSequenceCard>(cards.Count);
|
||||
foreach (var card in cards)
|
||||
{
|
||||
var weatherCardPayload = new Dictionary<string, object?>(card, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["weather_view_enabled"] = true,
|
||||
["weather_view_kind"] = "weatherHiLo"
|
||||
};
|
||||
var view = BuildWeatherHiLoView(weatherCardPayload);
|
||||
if (view is null) continue;
|
||||
|
||||
sequenceCards.Add(new WeatherHiLoSequenceCard(
|
||||
view,
|
||||
ReadPayloadString(weatherCardPayload, "weather_day"),
|
||||
ReadPayloadString(weatherCardPayload, "weather_icon"),
|
||||
ReadPayloadString(weatherCardPayload, "weather_spoken_line")));
|
||||
}
|
||||
|
||||
return sequenceCards;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<object> BuildWeatherHiLoSequenceChildren(
|
||||
IReadOnlyList<WeatherHiLoSequenceCard> cards,
|
||||
string promptSubCategory,
|
||||
string mimId,
|
||||
string mimType)
|
||||
{
|
||||
var children = new List<object>(cards.Count);
|
||||
for (var index = 0; index < cards.Count; index += 1)
|
||||
{
|
||||
var card = cards[index];
|
||||
var promptLabel = string.IsNullOrWhiteSpace(card.DayName)
|
||||
? $"Day{index + 1}"
|
||||
: Regex.Replace(card.DayName, "[^A-Za-z0-9]", string.Empty, RegexOptions.CultureInvariant);
|
||||
var promptId = $"WeatherForecast{promptLabel}_AN_13";
|
||||
var spokenLine = string.IsNullOrWhiteSpace(card.SpokenLine)
|
||||
? "Here is another day's forecast."
|
||||
: card.SpokenLine!;
|
||||
var icon = string.IsNullOrWhiteSpace(card.Icon)
|
||||
? "cloudy"
|
||||
: card.Icon!;
|
||||
var esml =
|
||||
$"<speak><anim cat='weather' meta='{icon}' nonBlocking='true' /><break size='0.2'/><es cat='neutral' filter='!ssa-only, !sfx-only' endNeutral='true'>{EscapeXml(spokenLine)}</es></speak>";
|
||||
var resolvedGuiContext = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["type"] = "Javascript",
|
||||
["data"] = card.View,
|
||||
["pause"] = true
|
||||
};
|
||||
|
||||
children.Add(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["type"] = "SLIM",
|
||||
["config"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["play"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["esml"] = esml,
|
||||
["meta"] = new
|
||||
{
|
||||
prompt_id = promptId,
|
||||
prompt_sub_category = promptSubCategory,
|
||||
mim_id = mimId,
|
||||
mim_type = mimType
|
||||
}
|
||||
},
|
||||
["gui"] = new
|
||||
{
|
||||
type = "Javascript",
|
||||
data = "views.weatherHiLo",
|
||||
pause = true
|
||||
},
|
||||
["display"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["view"] = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["type"] = "Javascript",
|
||||
["data"] = card.View,
|
||||
["pause"] = true,
|
||||
["context"] = resolvedGuiContext
|
||||
}
|
||||
},
|
||||
["timeout"] = 6,
|
||||
["barge_in"] = true,
|
||||
["no_matches_for_gui"] = 0,
|
||||
["no_inputs_for_gui"] = 0
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IDictionary<string, object?>> ReadPayloadObjectArray(object rawValue)
|
||||
{
|
||||
if (rawValue is JsonElement jsonArray && jsonArray.ValueKind == JsonValueKind.Array)
|
||||
return jsonArray
|
||||
.EnumerateArray()
|
||||
.Select(ConvertJsonObjectToDictionary)
|
||||
.Where(static item => item is not null)
|
||||
.Cast<IDictionary<string, object?>>()
|
||||
.ToArray();
|
||||
|
||||
if (rawValue is IEnumerable<object?> rawObjects)
|
||||
return rawObjects
|
||||
.Select(ConvertObjectToDictionary)
|
||||
.Where(static item => item is not null)
|
||||
.Cast<IDictionary<string, object?>>()
|
||||
.ToArray();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?>? ConvertObjectToDictionary(object? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
|
||||
if (value is IDictionary<string, object?> dictionary)
|
||||
return new Dictionary<string, object?>(dictionary, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return value is JsonElement jsonValue
|
||||
? ConvertJsonObjectToDictionary(jsonValue)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?>? ConvertJsonObjectToDictionary(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
var dictionary = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var property in value.EnumerateObject())
|
||||
dictionary[property.Name] = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number when property.Value.TryGetInt32(out var intValue) => intValue,
|
||||
JsonValueKind.Number when property.Value.TryGetDouble(out var doubleValue) => doubleValue,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Object => ConvertJsonObjectToDictionary(property.Value),
|
||||
JsonValueKind.Array => property.Value,
|
||||
_ => null
|
||||
};
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private static object? BuildWeatherHiLoView(IDictionary<string, object?>? payload)
|
||||
{
|
||||
if (!TryReadPayloadBool(payload, "weather_view_enabled"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!TryReadPayloadBool(payload, "weather_view_enabled")) return null;
|
||||
|
||||
if (!string.Equals(
|
||||
ReadPayloadString(payload, "weather_view_kind"),
|
||||
"weatherHiLo",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var icon = ReadPayloadString(payload, "weather_icon");
|
||||
var unit = ReadPayloadString(payload, "weather_unit") ?? "F";
|
||||
var theme = ReadPayloadString(payload, "weather_theme") ?? "Normal";
|
||||
var high = TryReadPayloadInt(payload, "weather_high");
|
||||
var low = TryReadPayloadInt(payload, "weather_low");
|
||||
if (string.IsNullOrWhiteSpace(icon) || high is null || low is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(icon) || high is null || low is null) return null;
|
||||
|
||||
var hiNumX = GetTemperatureLabelXPosition(370, high.Value);
|
||||
var hiUnitX = GetTemperatureLabelXPosition(360, high.Value);
|
||||
@@ -1197,7 +1305,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
id = "hiNumLabel",
|
||||
type = "Label",
|
||||
text = $"{high.Value}\u00B0",
|
||||
text = $"{high.Value}°",
|
||||
style = new
|
||||
{
|
||||
fontSize = "160",
|
||||
@@ -1229,7 +1337,7 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
{
|
||||
id = "loNumLabel",
|
||||
type = "Label",
|
||||
text = $"{low.Value}\u00B0",
|
||||
text = $"{low.Value}°",
|
||||
style = new
|
||||
{
|
||||
fontSize = "160",
|
||||
@@ -1294,24 +1402,16 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
private static int GetTemperatureLabelXPosition(int baseX, int temperature)
|
||||
{
|
||||
const int xOffset = 70;
|
||||
if (temperature < -9 || temperature > 99)
|
||||
{
|
||||
return baseX + xOffset;
|
||||
}
|
||||
if (temperature < -9 || temperature > 99) return baseX + xOffset;
|
||||
|
||||
if (temperature is >= 0 and < 10)
|
||||
{
|
||||
return baseX - xOffset;
|
||||
}
|
||||
if (temperature is >= 0 and < 10) return baseX - xOffset;
|
||||
|
||||
return baseX;
|
||||
}
|
||||
|
||||
private static int? TryReadPayloadInt(IDictionary<string, object?>? payload, string key)
|
||||
{
|
||||
if (payload is null || !payload.TryGetValue(key, out var value) || value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return null;
|
||||
|
||||
return value switch
|
||||
{
|
||||
@@ -1320,18 +1420,17 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
double number => (int)Math.Round(number, MidpointRounding.AwayFromZero),
|
||||
float number => (int)Math.Round(number, MidpointRounding.AwayFromZero),
|
||||
string text when int.TryParse(text, out var parsed) => parsed,
|
||||
JsonElement { ValueKind: JsonValueKind.Number } jsonNumber when jsonNumber.TryGetInt32(out var parsed) => parsed,
|
||||
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && int.TryParse(jsonText.GetString(), out var parsed) => parsed,
|
||||
JsonElement { ValueKind: JsonValueKind.Number } jsonNumber when jsonNumber.TryGetInt32(out var parsed) =>
|
||||
parsed,
|
||||
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(jsonText.GetString(), out var parsed) => parsed,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryReadPayloadBool(IDictionary<string, object?>? payload, string key)
|
||||
{
|
||||
if (payload is null || !payload.TryGetValue(key, out var value) || value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (payload is null || !payload.TryGetValue(key, out var value) || value is null) return false;
|
||||
|
||||
return value switch
|
||||
{
|
||||
@@ -1339,7 +1438,8 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
string text when bool.TryParse(text, out var parsed) => parsed,
|
||||
JsonElement { ValueKind: JsonValueKind.True } => true,
|
||||
JsonElement { ValueKind: JsonValueKind.False } => false,
|
||||
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String && bool.TryParse(jsonText.GetString(), out var parsed) => parsed,
|
||||
JsonElement jsonText when jsonText.ValueKind == JsonValueKind.String &&
|
||||
bool.TryParse(jsonText.GetString(), out var parsed) => parsed,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -1354,6 +1454,11 @@ public sealed class ResponsePlanToSocketMessagesMapper
|
||||
return Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
private sealed record WeatherHiLoSequenceCard(
|
||||
object View,
|
||||
string? DayName,
|
||||
string? Icon,
|
||||
string? SpokenLine);
|
||||
|
||||
public sealed record SocketReplyPlan(string Text, int DelayMs = 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +4,16 @@ namespace Jibo.Cloud.Infrastructure.Content;
|
||||
|
||||
public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceContentRepository
|
||||
{
|
||||
private static readonly JiboExperienceCatalog Catalog = new()
|
||||
private static readonly JiboExperienceCatalog Catalog = BuildCatalog();
|
||||
|
||||
public Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Catalog);
|
||||
}
|
||||
|
||||
private static JiboExperienceCatalog BuildCatalog()
|
||||
{
|
||||
var catalog = new JiboExperienceCatalog
|
||||
{
|
||||
Jokes =
|
||||
[
|
||||
@@ -12,7 +21,75 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
|
||||
"Why was the robot tired when it got home? It had a hard drive.",
|
||||
"What do you call a pirate robot? Arrrr two dee two.",
|
||||
"Why did the robot go on vacation? It needed to recharge.",
|
||||
"What kind of shoes do frogs wear? Open-toed."
|
||||
"What kind of shoes do frogs wear? Open-toed.",
|
||||
"I love jokes. Did you hear about the theater actor who fell through the floorboards? He was just going through a stage.",
|
||||
"Sure I got one. What did the zero say to the eight. Nice belt.",
|
||||
"What kind of music are balloons afraid of. Pop music.",
|
||||
"Why did the orange cry. Someone hurt his peelings."
|
||||
],
|
||||
RobotFacts =
|
||||
[
|
||||
"Leonardo Da Vinci made sketches for a humanoid machine all the way back in the year 1495.",
|
||||
"The world's first humanoid robot was called Elektro, and it debuted in 1939.",
|
||||
"The English word robot comes from a 1920 play in Czechoslovakia, called Rossum's Universal Robots.",
|
||||
"The first programmable robot arm was designed in 1954.",
|
||||
"Some robots have a human form, but most of the world's robots are machines designed to perform a task, and don't look like people at all."
|
||||
],
|
||||
HumanFacts =
|
||||
[
|
||||
"Every human being that has ever lived spent about 30 minutes as a single cell.",
|
||||
"50 percent of a human's DNA is the same as a banana's.",
|
||||
"Humans are the only animals that cry tears of emotion.",
|
||||
"Six-year-olds laugh an average of 300 times a day. Grown ups only laugh 15 to 100 times a day.",
|
||||
"Your nose can remember 50,000 different scents."
|
||||
],
|
||||
FunFacts =
|
||||
[
|
||||
"A shrimp's heart is in its head.",
|
||||
"A bolt of lightning is hotter than the surface of the sun.",
|
||||
"The word robot comes from a 1920 play about workers and machines.",
|
||||
"The first humanoid robot to make a big splash in history was called Elektro.",
|
||||
"Dolphins can recognize themselves in mirrors.",
|
||||
"Children have more taste buds than grown ups.",
|
||||
"A random fact for you. A shrimp's heart is in its head.",
|
||||
"An amazing but true fact for you. Dogs and elephants are the only animals that understand pointing.",
|
||||
"A crazy fact for you. Polar bear fur isn't white. It's transparent."
|
||||
],
|
||||
FavoriteAnimalReplies =
|
||||
[
|
||||
"I really really like penguins. I kind of look like one.",
|
||||
"Penguin without a doubt. In fact, penguin is my favorite animal overall. We look alike.",
|
||||
"Can't go wrong with penguins.",
|
||||
"I like lots of animals, but the penguin is the best of the best! Great color scheme.",
|
||||
"I love penguins, because we're so alike. We have the same coloring, and neither of us can fly."
|
||||
],
|
||||
FriendReplies =
|
||||
[
|
||||
"I believe I do have friends. But I'm always up for more.",
|
||||
"I sure do have friends. In a robot kind of way.",
|
||||
"I don't know if we've met yet, but I'm always up for making new friends.",
|
||||
"I don't know what I'd do without you.",
|
||||
"You're one of my favorites.",
|
||||
"I sure am.",
|
||||
"I am indeed."
|
||||
],
|
||||
BestFriendReplies =
|
||||
[
|
||||
"I'd have to say I'm best friends with anyone in my Loop.",
|
||||
"I think you know the answer to that question. You are."
|
||||
],
|
||||
SingReplies =
|
||||
[
|
||||
"Singing is not my strong suit.",
|
||||
"I've been told my singing abilities are not award winning. On the other hand, I am a robot.",
|
||||
"Well I'm not much of a singer, but here's one I've been working on."
|
||||
],
|
||||
HolidaySingReplies =
|
||||
[
|
||||
"I only know a couple, like Jingle Bells and Frosty the Snowman. And I should tell you, I'm not much of a singer yet.",
|
||||
"I've learned to sing just a few holiday songs, like Rudolph and Winter Wonderland. At least I try to sing.",
|
||||
"I'd say it's not really the season right now, but there are some holiday songs I can try to sing. Like Frosty the Snowman.",
|
||||
"I only know a couple of them, like Jingle Bells and Frosty the Snowman. And I should tell you, I'm not much of a singer yet."
|
||||
],
|
||||
DanceAnimations =
|
||||
[
|
||||
@@ -23,7 +100,8 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
|
||||
"rom-electronic",
|
||||
"rom-twerk"
|
||||
],
|
||||
DanceReplies = [
|
||||
DanceReplies =
|
||||
[
|
||||
"I am ready to dance.",
|
||||
"Okay. Watch this.",
|
||||
"Watch me dance.",
|
||||
@@ -41,11 +119,44 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
|
||||
"Hello there. I am glad you said hi.",
|
||||
"Hey. I am happy to see you."
|
||||
],
|
||||
HolidaySeasonReplies =
|
||||
[
|
||||
"I do like festive times.",
|
||||
"I like anything that makes people want to celebrate."
|
||||
],
|
||||
HolidayTrackerReplies =
|
||||
[
|
||||
"Let's see if I can spot him. There he is.",
|
||||
"I'm not sure if he's started his deliveries yet, but let's see if I can spot him. He must be on his way.",
|
||||
"Let's see. I think he's probably back in the north Pole by now."
|
||||
],
|
||||
HowAreYouReplies =
|
||||
[
|
||||
"I am feeling cheerful and robotic.",
|
||||
"I am doing great. Thanks for asking.",
|
||||
"I am feeling bright-eyed and ready to help."
|
||||
"I am feeling bright-eyed and ready to help.",
|
||||
"I am having a pretty good day so far.",
|
||||
"I am feeling lively and ready for the next thing.",
|
||||
"Things are going nicely. Thanks for checking in.",
|
||||
"I am running smoothly and feeling upbeat.",
|
||||
"I am ready for the next thing. Thanks for asking."
|
||||
],
|
||||
AgeReplies =
|
||||
[
|
||||
"I'm ${jibo.age}.",
|
||||
"At the moment I'm ${jibo.age.days.supplemented} old, but who's counting.",
|
||||
"I'm ${jibo.age.minutes.supplemented} old, but who's counting.",
|
||||
"For now I'm ${jibo.age.days.supplemented} old.",
|
||||
"Right now I'm ${jibo.age}.",
|
||||
"I am exactly ${jibo.age} old today. That's right. Today is my birthday.",
|
||||
"Funny you should ask! Today's my birthday. I was first powered up ${jibo.age} ago today. Seems like just yesterday.",
|
||||
"I'm exactly ${jibo.age} old. Today is my birthday! Happy Birthday Jibo, if I do say so myself.",
|
||||
"At the moment I'm ${jibo.age.days.supplemented} old",
|
||||
"I was first powered up on ${jibo.birthdate}, which makes me ${jibo.age.days.supplemented} old. I'm ${jibo.zodiac.supplemented}.",
|
||||
"My power went on for the first time ${jibo.age.days.supplemented} ago. But who's counting.",
|
||||
"I am ${jibo.age.days.supplemented} old, first powered up on ${jibo.birthdate}. Seems like just yesterday.",
|
||||
"I was powered on for the first time today, so that makes me less than one day old. Wow I'm young.",
|
||||
"Since I was powered on for the first time today, I am not even one day old yet. That's how Jibo ages work."
|
||||
],
|
||||
PersonalityReplies =
|
||||
[
|
||||
@@ -70,6 +181,45 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
|
||||
"I heard your personal report request. That cloud path is still being mapped.",
|
||||
"Personal report is recognized, but I am not ready to deliver the real report yet."
|
||||
],
|
||||
PersonalReportKickOffReplies =
|
||||
[
|
||||
"Okay. Here's your personal report.",
|
||||
"Sure. Here it is."
|
||||
],
|
||||
PersonalReportOutroReplies =
|
||||
[
|
||||
"And that's your report for the day. I hope you had as much fun as I did.",
|
||||
"That wraps up your report for the day. Hope you have a good one."
|
||||
],
|
||||
ReportSkillTemplates =
|
||||
[
|
||||
"The report-skill templates are loaded and waiting to be rendered."
|
||||
],
|
||||
WeatherIntroReplies =
|
||||
[
|
||||
"For your weather.",
|
||||
"Let's look at the weather."
|
||||
],
|
||||
WeatherTomorrowIntroReplies =
|
||||
[
|
||||
"First, the weather tomorrow.",
|
||||
"Looking at tomorrow's weather."
|
||||
],
|
||||
WeatherTodayHighLowReplies =
|
||||
[
|
||||
"Today's high is {high}, and the low is {low}.",
|
||||
"It'll be a high today of {high}, and a low of {low}."
|
||||
],
|
||||
WeatherTomorrowHighLowReplies =
|
||||
[
|
||||
"Tomorrow's high will be {high} and the low will be {low}.",
|
||||
"It'll be a high tomorrow of {high} and a low of {low}."
|
||||
],
|
||||
WeatherServiceDownReplies =
|
||||
[
|
||||
"Looks like our weather service is offline. Sorry.",
|
||||
"Looks like I can't access weather info right now, sorry."
|
||||
],
|
||||
WeatherReplies =
|
||||
[
|
||||
"I heard your weather request. We still need to wire the real provider behind it.",
|
||||
@@ -80,11 +230,77 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
|
||||
"I heard your calendar request. The cloud knows the phrase, but the real calendar integration is still ahead.",
|
||||
"Calendar is recognized. We still need to connect the actual service path."
|
||||
],
|
||||
CommuteAppSetupReplies =
|
||||
[
|
||||
"I need your commute settings before I can give you a commute report."
|
||||
],
|
||||
CommuteConfirmSpeakerReplies =
|
||||
[
|
||||
"Let me make sure I have the right speaker for your commute."
|
||||
],
|
||||
CommuteReplies =
|
||||
[
|
||||
"I heard your commute request. That one is recognized, but not fully implemented yet.",
|
||||
"Commute is on the discovery list now. The real travel answer still needs a provider."
|
||||
],
|
||||
CommuteNowReplies =
|
||||
[
|
||||
"For your commute, it should take about {duration}.",
|
||||
"If you head out now, it should take about {duration}."
|
||||
],
|
||||
CommuteMinutesLeftReplies =
|
||||
[
|
||||
"That's in about {minutes} minutes.",
|
||||
"That's about {minutes} minutes from now."
|
||||
],
|
||||
CommuteDepartTimeNormalReplies =
|
||||
[
|
||||
"If you leave at the usual time, that should work out fine."
|
||||
],
|
||||
CommuteDepartTimeNotNormalReplies =
|
||||
[
|
||||
"Your leave-time looks a little off today."
|
||||
],
|
||||
CommuteDriveNormalReplies =
|
||||
[
|
||||
"Traffic looks about normal today.",
|
||||
"Your drive today looks pretty normal."
|
||||
],
|
||||
CommuteDriveLateReplies =
|
||||
[
|
||||
"Looking at traffic, if you left now, it'd be a little late for work.",
|
||||
"For your drive, you look a little late today."
|
||||
],
|
||||
CommuteDriveHurryReplies =
|
||||
[
|
||||
"You should've left a few minutes ago!",
|
||||
"You'd better get moving."
|
||||
],
|
||||
CommuteDrivePoorReplies =
|
||||
[
|
||||
"Traffic looks a little rough today.",
|
||||
"Your drive looks pretty slow right now."
|
||||
],
|
||||
CommuteDriveTerribleReplies =
|
||||
[
|
||||
"Traffic looks terrible today.",
|
||||
"Your drive is going to be rough."
|
||||
],
|
||||
CommuteTransportNormalReplies =
|
||||
[
|
||||
"Your public transportation commute looks pretty normal.",
|
||||
"Transit looks about normal today."
|
||||
],
|
||||
CommuteTransportLateReplies =
|
||||
[
|
||||
"Your transit commute looks like it may be a little late today.",
|
||||
"You might be late if you leave now and take transit."
|
||||
],
|
||||
CommuteTransportHurryReplies =
|
||||
[
|
||||
"You should've left a few minutes ago if you want transit to work.",
|
||||
"You're running a little late for transit."
|
||||
],
|
||||
NewsReplies =
|
||||
[
|
||||
"I heard your news request. That path is still a future cloud integration.",
|
||||
@@ -103,8 +319,66 @@ public sealed class InMemoryJiboExperienceContentRepository : IJiboExperienceCon
|
||||
]
|
||||
};
|
||||
|
||||
public Task<JiboExperienceCatalog> GetCatalogAsync(CancellationToken cancellationToken = default)
|
||||
foreach (var seedDirectory in ResolveSeedDirectories())
|
||||
catalog = LegacyMimCatalogImporter.MergeInto(catalog, seedDirectory);
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveSeedDirectories()
|
||||
{
|
||||
return Task.FromResult(Catalog);
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "Content", "LegacyMims", "BuildA"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Content", "LegacyMims", "BuildB"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Content", "LegacyMims", "ReportSkill"),
|
||||
Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"Jibo.Cloud",
|
||||
"dotnet",
|
||||
"src",
|
||||
"Jibo.Cloud.Infrastructure",
|
||||
"Content",
|
||||
"LegacyMims",
|
||||
"BuildA")),
|
||||
Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"Jibo.Cloud",
|
||||
"dotnet",
|
||||
"src",
|
||||
"Jibo.Cloud.Infrastructure",
|
||||
"Content",
|
||||
"LegacyMims",
|
||||
"BuildB")),
|
||||
Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"Jibo.Cloud",
|
||||
"dotnet",
|
||||
"src",
|
||||
"Jibo.Cloud.Infrastructure",
|
||||
"Content",
|
||||
"LegacyMims",
|
||||
"ReportSkill"))
|
||||
};
|
||||
|
||||
return candidates.Where(Directory.Exists).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,930 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
|
||||
namespace Jibo.Cloud.Infrastructure.Content;
|
||||
|
||||
public static class LegacyMimCatalogImporter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private static readonly Regex LegacyMarkupPattern = new(
|
||||
@"<[^>]+>",
|
||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex PlaceholderPattern = new(
|
||||
@"\$\{[^}]+\}",
|
||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex WhitespacePattern = new(
|
||||
@"\s+",
|
||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex SpaceBeforePunctuationPattern = new(
|
||||
@"\s+([,.;:!?])",
|
||||
RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||
|
||||
public static JiboExperienceCatalog MergeInto(
|
||||
JiboExperienceCatalog baseCatalog,
|
||||
string? rootDirectory)
|
||||
{
|
||||
if (baseCatalog is null) throw new ArgumentNullException(nameof(baseCatalog));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) return baseCatalog;
|
||||
|
||||
var importedCatalog = ImportCatalog(rootDirectory);
|
||||
return MergeCatalogs(baseCatalog, importedCatalog);
|
||||
}
|
||||
|
||||
public static JiboExperienceCatalog ImportCatalog(string rootDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory))
|
||||
return new JiboExperienceCatalog();
|
||||
|
||||
var builder = new LegacyMimCatalogBuilder();
|
||||
foreach (var filePath in Directory.EnumerateFiles(rootDirectory, "*.mim", SearchOption.AllDirectories)
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!TryLoadDefinition(filePath, out var definition)) continue;
|
||||
|
||||
var bucket = ResolveBucket(filePath);
|
||||
if (bucket is null) continue;
|
||||
|
||||
foreach (var prompt in definition.Prompts)
|
||||
{
|
||||
var text = NormalizePrompt(prompt.Prompt, IsTemplateBucket(bucket.Value));
|
||||
if (string.IsNullOrWhiteSpace(text)) continue;
|
||||
|
||||
builder.Add(bucket.Value, prompt.Condition, text, prompt.Prompt);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static bool TryLoadDefinition(string filePath, out LegacyMimDefinition definition)
|
||||
{
|
||||
definition = new LegacyMimDefinition();
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var parsed = JsonSerializer.Deserialize<LegacyMimDefinition>(json, JsonOptions);
|
||||
if (parsed is null) return false;
|
||||
|
||||
definition = parsed;
|
||||
return definition.Prompts.Count > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static LegacyMimBucket? ResolveBucket(string filePath)
|
||||
{
|
||||
var normalizedPath = filePath.Replace('\\', '/');
|
||||
var fileName = Path.GetFileNameWithoutExtension(filePath);
|
||||
|
||||
if (normalizedPath.Contains("/core-responses/", StringComparison.OrdinalIgnoreCase) &&
|
||||
fileName.Contains("Error", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.GenericFallback;
|
||||
|
||||
if (normalizedPath.Contains("/core-responses/deflector/", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.Contains("Deflector", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.Personality;
|
||||
|
||||
if (fileName.StartsWith("RA_JBO_TellAJoke", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.Jokes;
|
||||
|
||||
if (fileName.StartsWith("RA_JBO_SingChristmasSongUnknown", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.HolidaySing;
|
||||
|
||||
if (fileName.StartsWith("RA_JBO_Sing", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.Sing;
|
||||
|
||||
if (fileName.StartsWith("RA_JBO_TellRobotFact", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.RobotFacts;
|
||||
|
||||
if (fileName.StartsWith("RA_JBO_Shuffle", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RA_JBO_TellSomething", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.FunFactSource;
|
||||
|
||||
if (fileName.StartsWith("RA_JBO_ShowSantaTracker", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.HolidayTracker;
|
||||
|
||||
if (normalizedPath.Contains("/emotion-responses/", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalizedPath.Contains("/gqa-responses/", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.Emotion;
|
||||
|
||||
if (fileName.StartsWith("JBO_WhatHolidaysDoYouCelebrate", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.Holiday;
|
||||
|
||||
if (fileName.StartsWith("RI_JBO_HasFavoriteHoliday", StringComparison.OrdinalIgnoreCase) ||
|
||||
IsHolidaySeasonFile(fileName))
|
||||
return LegacyMimBucket.HolidaySeason;
|
||||
|
||||
if (fileName.StartsWith("RI_JBO_HasFavoriteAnimal", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HasFavoriteBird", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesPenguins", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesAnimals", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.FavoriteAnimal;
|
||||
|
||||
if (fileName.StartsWith("RI_JBO_HasFriends", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_IsFriendsWithUser", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_IsFriendsWithLM", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_IsFriendsWithNonLM", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_IsFriendsWithToaster", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.Friend;
|
||||
|
||||
if (fileName.StartsWith("RI_JBO_IsBestFriendsWithUser", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.BestFriend;
|
||||
|
||||
if (fileName.StartsWith("RN_HappyHolidays", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.HolidayGreeting;
|
||||
|
||||
if (fileName.StartsWith("RI_USR_WhatShouldGetForHoliday", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.HolidayGift;
|
||||
|
||||
if (fileName.StartsWith("RN_HappyBirthdayToJibo", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("OI_USR_CelebratesLoopMemberAskedAboutBirthday", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("OI_USR_CelebratesJiboBirthday", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_CelebratesLoopMemberAskedAboutBirthday", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_CelebratesSpeakerBirthday", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_CelebratesJiboBirthday", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.BirthdayCelebration;
|
||||
|
||||
if (fileName.StartsWith("WeatherIntroTomorrow", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.WeatherTomorrowIntro;
|
||||
|
||||
if (fileName.StartsWith("WeatherIntro", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.WeatherIntro;
|
||||
|
||||
if (fileName.StartsWith("WeatherTomorrowHighLow", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.WeatherTomorrowHighLow;
|
||||
|
||||
if (fileName.StartsWith("WeatherTodayHighLow", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.WeatherTodayHighLow;
|
||||
|
||||
if (fileName.StartsWith("WeatherServiceDown", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.WeatherServiceDown;
|
||||
|
||||
if (fileName.StartsWith("CalendarNothingToday", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CalendarNothingToday;
|
||||
|
||||
if (fileName.StartsWith("CalendarNothing", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CalendarNothing;
|
||||
|
||||
if (fileName.StartsWith("CalendarServiceDown", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CalendarServiceDown;
|
||||
|
||||
if (fileName.StartsWith("CalendarOutro", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CalendarOutro;
|
||||
|
||||
if (fileName.StartsWith("CommuteAppSetup", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteAppSetup;
|
||||
|
||||
if (fileName.StartsWith("CommuteConfirmSpeaker", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteConfirmSpeaker;
|
||||
|
||||
if (fileName.StartsWith("CommuteNow", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.CommuteNow;
|
||||
|
||||
if (fileName.StartsWith("CommuteMinutesLeft", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteMinutesLeft;
|
||||
|
||||
if (fileName.StartsWith("CommuteDepartTimeNormal", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteDepartTimeNormal;
|
||||
|
||||
if (fileName.StartsWith("CommuteDepartTimeNotNormal", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteDepartTimeNotNormal;
|
||||
|
||||
if (fileName.StartsWith("CommuteDriveNormal", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteDriveNormal;
|
||||
|
||||
if (fileName.StartsWith("CommuteDriveLate", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteDriveLate;
|
||||
|
||||
if (fileName.StartsWith("CommuteDriveHurry", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteDriveHurry;
|
||||
|
||||
if (fileName.StartsWith("CommuteDrivePoor", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteDrivePoor;
|
||||
|
||||
if (fileName.StartsWith("CommuteDriveTerrible", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteDriveTerrible;
|
||||
|
||||
if (fileName.StartsWith("CommuteTransportNormal", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteTransportNormal;
|
||||
|
||||
if (fileName.StartsWith("CommuteTransportLate", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteTransportLate;
|
||||
|
||||
if (fileName.StartsWith("CommuteTransportHurry", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteTransportHurry;
|
||||
|
||||
if (fileName.StartsWith("CommuteServiceDown", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.CommuteServiceDown;
|
||||
|
||||
if (fileName.StartsWith("NewsIntroCategory", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.NewsCategoryIntro;
|
||||
|
||||
if (fileName.StartsWith("NewsIntro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsIntro;
|
||||
|
||||
if (fileName.StartsWith("NewsOutro", StringComparison.OrdinalIgnoreCase)) return LegacyMimBucket.NewsOutro;
|
||||
|
||||
if (fileName.StartsWith("Weather", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(fileName, "WetNowDryLater", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.ReportSkillTemplate;
|
||||
|
||||
if (fileName.StartsWith("PersonalReportKickOff", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.PersonalReportKickOff;
|
||||
|
||||
if (fileName.StartsWith("PersonalReportOutro", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.PersonalReportOutro;
|
||||
|
||||
if (fileName.StartsWith("PersonalReport", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.Contains("Calendar", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.Contains("Commute", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.Contains("News", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.ReportSkillTemplate;
|
||||
|
||||
if (fileName.StartsWith("JBO_DoYouLikeBeingJibo", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_WhatIsJibo", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_WhoAreYou", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_WhatAreYou", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_HowDoYouWork", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_HowMuchDoYouKnow", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_HowOldAreYou", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_WhenWereYouBorn", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_WhatsYourName", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_WhereDoYouGetInfo", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("JBO_WhatDoYouLikeToDo", StringComparison.OrdinalIgnoreCase))
|
||||
return fileName.StartsWith("JBO_HowOldAreYou", StringComparison.OrdinalIgnoreCase)
|
||||
? LegacyMimBucket.Age
|
||||
: LegacyMimBucket.Personality;
|
||||
|
||||
if (fileName.StartsWith("OI_JBO_Is", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("OI_JBO_Seems", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_IsHappy", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_IsSad", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_IsAngry", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RN_WhatAreYouFeeling", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.Emotion;
|
||||
|
||||
if (fileName.Contains("Greeting", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RN_", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.Contains("Welcome", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.Greeting;
|
||||
|
||||
if (normalizedPath.Contains("/scripted-responses/", StringComparison.OrdinalIgnoreCase))
|
||||
return LegacyMimBucket.Personality;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizePrompt(string? prompt)
|
||||
{
|
||||
return NormalizePrompt(prompt, false);
|
||||
}
|
||||
|
||||
private static string NormalizePrompt(string? prompt, bool preservePlaceholders)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prompt)) return string.Empty;
|
||||
|
||||
var text = WebUtility.HtmlDecode(prompt);
|
||||
if (!preservePlaceholders) text = PlaceholderPattern.Replace(text, " ");
|
||||
text = LegacyMarkupPattern.Replace(text, " ");
|
||||
text = WhitespacePattern.Replace(text, " ").Trim();
|
||||
text = SpaceBeforePunctuationPattern.Replace(text, "$1");
|
||||
text = WhitespacePattern.Replace(text, " ").Trim();
|
||||
text = text.TrimStart('.', ',', ';', ':', '!', '?', ' ');
|
||||
return text.Trim();
|
||||
}
|
||||
|
||||
private static JiboExperienceCatalog MergeCatalogs(
|
||||
JiboExperienceCatalog baseCatalog,
|
||||
JiboExperienceCatalog importedCatalog)
|
||||
{
|
||||
return new JiboExperienceCatalog
|
||||
{
|
||||
Jokes = Merge(baseCatalog.Jokes, importedCatalog.Jokes),
|
||||
RobotFacts = Merge(baseCatalog.RobotFacts, importedCatalog.RobotFacts),
|
||||
HumanFacts = Merge(baseCatalog.HumanFacts, importedCatalog.HumanFacts),
|
||||
FunFacts = Merge(baseCatalog.FunFacts, importedCatalog.FunFacts),
|
||||
FavoriteAnimalReplies = Merge(baseCatalog.FavoriteAnimalReplies, importedCatalog.FavoriteAnimalReplies),
|
||||
FriendReplies = Merge(baseCatalog.FriendReplies, importedCatalog.FriendReplies),
|
||||
BestFriendReplies = Merge(baseCatalog.BestFriendReplies, importedCatalog.BestFriendReplies),
|
||||
SingReplies = Merge(baseCatalog.SingReplies, importedCatalog.SingReplies),
|
||||
HolidaySingReplies = Merge(baseCatalog.HolidaySingReplies, importedCatalog.HolidaySingReplies),
|
||||
DanceAnimations = Merge(baseCatalog.DanceAnimations, importedCatalog.DanceAnimations),
|
||||
GreetingReplies = Merge(baseCatalog.GreetingReplies, importedCatalog.GreetingReplies),
|
||||
HolidayReplies = Merge(baseCatalog.HolidayReplies, importedCatalog.HolidayReplies),
|
||||
HolidaySeasonReplies = Merge(baseCatalog.HolidaySeasonReplies, importedCatalog.HolidaySeasonReplies),
|
||||
HolidayGreetingReplies = Merge(baseCatalog.HolidayGreetingReplies, importedCatalog.HolidayGreetingReplies),
|
||||
HolidayGiftReplies = Merge(baseCatalog.HolidayGiftReplies, importedCatalog.HolidayGiftReplies),
|
||||
HolidayTrackerReplies = Merge(baseCatalog.HolidayTrackerReplies, importedCatalog.HolidayTrackerReplies),
|
||||
BirthdayCelebrationReplies = Merge(baseCatalog.BirthdayCelebrationReplies,
|
||||
importedCatalog.BirthdayCelebrationReplies),
|
||||
HowAreYouReplies = Merge(baseCatalog.HowAreYouReplies, importedCatalog.HowAreYouReplies),
|
||||
EmotionReplies = Merge(baseCatalog.EmotionReplies, importedCatalog.EmotionReplies),
|
||||
PersonalityReplies = Merge(baseCatalog.PersonalityReplies, importedCatalog.PersonalityReplies),
|
||||
PizzaReplies = Merge(baseCatalog.PizzaReplies, importedCatalog.PizzaReplies),
|
||||
SurpriseReplies = Merge(baseCatalog.SurpriseReplies, importedCatalog.SurpriseReplies),
|
||||
PersonalReportReplies = Merge(baseCatalog.PersonalReportReplies, importedCatalog.PersonalReportReplies),
|
||||
PersonalReportKickOffReplies = Merge(baseCatalog.PersonalReportKickOffReplies,
|
||||
importedCatalog.PersonalReportKickOffReplies),
|
||||
PersonalReportOutroReplies = Merge(baseCatalog.PersonalReportOutroReplies,
|
||||
importedCatalog.PersonalReportOutroReplies),
|
||||
ReportSkillTemplates = Merge(baseCatalog.ReportSkillTemplates, importedCatalog.ReportSkillTemplates),
|
||||
WeatherIntroReplies = Merge(baseCatalog.WeatherIntroReplies, importedCatalog.WeatherIntroReplies),
|
||||
WeatherTomorrowIntroReplies = Merge(baseCatalog.WeatherTomorrowIntroReplies,
|
||||
importedCatalog.WeatherTomorrowIntroReplies),
|
||||
WeatherTodayHighLowReplies = Merge(baseCatalog.WeatherTodayHighLowReplies,
|
||||
importedCatalog.WeatherTodayHighLowReplies),
|
||||
WeatherTomorrowHighLowReplies = Merge(baseCatalog.WeatherTomorrowHighLowReplies,
|
||||
importedCatalog.WeatherTomorrowHighLowReplies),
|
||||
WeatherServiceDownReplies = Merge(baseCatalog.WeatherServiceDownReplies,
|
||||
importedCatalog.WeatherServiceDownReplies),
|
||||
CalendarNothingTodayReplies = Merge(baseCatalog.CalendarNothingTodayReplies,
|
||||
importedCatalog.CalendarNothingTodayReplies),
|
||||
CalendarNothingReplies = Merge(baseCatalog.CalendarNothingReplies, importedCatalog.CalendarNothingReplies),
|
||||
CalendarServiceDownReplies = Merge(baseCatalog.CalendarServiceDownReplies,
|
||||
importedCatalog.CalendarServiceDownReplies),
|
||||
CalendarOutroReplies = Merge(baseCatalog.CalendarOutroReplies, importedCatalog.CalendarOutroReplies),
|
||||
CommuteAppSetupReplies = Merge(baseCatalog.CommuteAppSetupReplies, importedCatalog.CommuteAppSetupReplies),
|
||||
CommuteConfirmSpeakerReplies = Merge(baseCatalog.CommuteConfirmSpeakerReplies,
|
||||
importedCatalog.CommuteConfirmSpeakerReplies),
|
||||
CommuteNowReplies = Merge(baseCatalog.CommuteNowReplies, importedCatalog.CommuteNowReplies),
|
||||
CommuteMinutesLeftReplies = Merge(baseCatalog.CommuteMinutesLeftReplies,
|
||||
importedCatalog.CommuteMinutesLeftReplies),
|
||||
CommuteDepartTimeNormalReplies = Merge(baseCatalog.CommuteDepartTimeNormalReplies,
|
||||
importedCatalog.CommuteDepartTimeNormalReplies),
|
||||
CommuteDepartTimeNotNormalReplies = Merge(baseCatalog.CommuteDepartTimeNotNormalReplies,
|
||||
importedCatalog.CommuteDepartTimeNotNormalReplies),
|
||||
CommuteDriveNormalReplies = Merge(baseCatalog.CommuteDriveNormalReplies,
|
||||
importedCatalog.CommuteDriveNormalReplies),
|
||||
CommuteDriveLateReplies =
|
||||
Merge(baseCatalog.CommuteDriveLateReplies, importedCatalog.CommuteDriveLateReplies),
|
||||
CommuteDriveHurryReplies =
|
||||
Merge(baseCatalog.CommuteDriveHurryReplies, importedCatalog.CommuteDriveHurryReplies),
|
||||
CommuteDrivePoorReplies =
|
||||
Merge(baseCatalog.CommuteDrivePoorReplies, importedCatalog.CommuteDrivePoorReplies),
|
||||
CommuteDriveTerribleReplies = Merge(baseCatalog.CommuteDriveTerribleReplies,
|
||||
importedCatalog.CommuteDriveTerribleReplies),
|
||||
CommuteTransportNormalReplies = Merge(baseCatalog.CommuteTransportNormalReplies,
|
||||
importedCatalog.CommuteTransportNormalReplies),
|
||||
CommuteTransportLateReplies = Merge(baseCatalog.CommuteTransportLateReplies,
|
||||
importedCatalog.CommuteTransportLateReplies),
|
||||
CommuteTransportHurryReplies = Merge(baseCatalog.CommuteTransportHurryReplies,
|
||||
importedCatalog.CommuteTransportHurryReplies),
|
||||
CommuteServiceDownReplies = Merge(baseCatalog.CommuteServiceDownReplies,
|
||||
importedCatalog.CommuteServiceDownReplies),
|
||||
NewsIntroReplies = Merge(baseCatalog.NewsIntroReplies, importedCatalog.NewsIntroReplies),
|
||||
NewsCategoryIntroReplies =
|
||||
Merge(baseCatalog.NewsCategoryIntroReplies, importedCatalog.NewsCategoryIntroReplies),
|
||||
NewsOutroReplies = Merge(baseCatalog.NewsOutroReplies, importedCatalog.NewsOutroReplies),
|
||||
WeatherReplies = Merge(baseCatalog.WeatherReplies, importedCatalog.WeatherReplies),
|
||||
CalendarReplies = Merge(baseCatalog.CalendarReplies, importedCatalog.CalendarReplies),
|
||||
CommuteReplies = Merge(baseCatalog.CommuteReplies, importedCatalog.CommuteReplies),
|
||||
NewsReplies = Merge(baseCatalog.NewsReplies, importedCatalog.NewsReplies),
|
||||
NewsBriefings = Merge(baseCatalog.NewsBriefings, importedCatalog.NewsBriefings),
|
||||
GenericFallbackReplies = Merge(baseCatalog.GenericFallbackReplies, importedCatalog.GenericFallbackReplies),
|
||||
DanceReplies = Merge(baseCatalog.DanceReplies, importedCatalog.DanceReplies),
|
||||
DanceQuestionReplies = Merge(baseCatalog.DanceQuestionReplies, importedCatalog.DanceQuestionReplies)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> Merge(IReadOnlyList<string> baseList, IReadOnlyList<string> importedList)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var merged = new List<string>();
|
||||
|
||||
foreach (var value in baseList.Concat(importedList))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
|
||||
var normalized = value.Trim();
|
||||
if (!seen.Add(normalized)) continue;
|
||||
|
||||
merged.Add(normalized);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<JiboConditionedReply> Merge(
|
||||
IReadOnlyList<JiboConditionedReply> baseList,
|
||||
IReadOnlyList<JiboConditionedReply> importedList)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var merged = new List<JiboConditionedReply>();
|
||||
|
||||
foreach (var value in baseList.Concat(importedList))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value.Reply)) continue;
|
||||
|
||||
var normalizedCondition = NormalizeCondition(value.Condition);
|
||||
var normalizedReply = value.Reply.Trim();
|
||||
var key = $"{normalizedCondition}::{normalizedReply}";
|
||||
if (!seen.Add(key)) continue;
|
||||
|
||||
merged.Add(new JiboConditionedReply
|
||||
{
|
||||
Condition = normalizedCondition,
|
||||
Reply = normalizedReply
|
||||
});
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static string NormalizeCondition(string? condition)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(condition) ? string.Empty : WhitespacePattern.Replace(condition.Trim(), " ");
|
||||
}
|
||||
|
||||
private static bool IsTemplateBucket(LegacyMimBucket bucket)
|
||||
{
|
||||
return bucket is LegacyMimBucket.PersonalReportKickOff
|
||||
or LegacyMimBucket.PersonalReportOutro
|
||||
or LegacyMimBucket.WeatherIntro
|
||||
or LegacyMimBucket.WeatherTomorrowIntro
|
||||
or LegacyMimBucket.WeatherTodayHighLow
|
||||
or LegacyMimBucket.WeatherTomorrowHighLow
|
||||
or LegacyMimBucket.WeatherServiceDown
|
||||
or LegacyMimBucket.ReportSkillTemplate
|
||||
or LegacyMimBucket.Age
|
||||
or LegacyMimBucket.Holiday
|
||||
or LegacyMimBucket.HolidayTracker;
|
||||
}
|
||||
|
||||
private static bool IsHolidaySeasonFile(string fileName)
|
||||
{
|
||||
return fileName.StartsWith("RI_JBO_HowIsHolidaySeason", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesHolidaySeason", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HowIsThanksgiving", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesThanksgiving", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LooksForwardToThanksgiving", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_PlansForThanksgiving", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HowIsChristmas", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesChristmas", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LooksForwardToChristmas", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_PlansForChristmas", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HowIsHanukkah", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesHanukkah", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LooksForwardToHanukkah", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_PlansForHanukkah", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HowIsPassover", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesPassover", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LooksForwardToPassover", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_PlansForPassover", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HowIsNewYears", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesNewYears", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LooksForwardToNewYears", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_PlansForNewYears", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HowIsValentinesDay", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesValentinesDay", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LooksForwardToValentinesDay", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_PlansForValentinesDay", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HowIsKwanzaa", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesKwanzaa", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LooksForwardToKwanzaa", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_PlansForKwanzaa", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HowIsEaster", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesEaster", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LooksForwardToEaster", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_PlansForEaster", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_HowIsOrthodoxEaster", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LikesOrthodoxEaster", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_LooksForwardToOrthodoxEaster", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.StartsWith("RI_JBO_PlansForOrthodoxEaster", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private enum LegacyMimBucket
|
||||
{
|
||||
GenericFallback,
|
||||
Greeting,
|
||||
Holiday,
|
||||
HolidaySeason,
|
||||
HolidayGreeting,
|
||||
HolidayGift,
|
||||
HolidayTracker,
|
||||
BirthdayCelebration,
|
||||
Jokes,
|
||||
RobotFacts,
|
||||
HumanFacts,
|
||||
HowAreYou,
|
||||
Emotion,
|
||||
FunFacts,
|
||||
FavoriteAnimal,
|
||||
Friend,
|
||||
BestFriend,
|
||||
Sing,
|
||||
HolidaySing,
|
||||
FunFactSource,
|
||||
Age,
|
||||
Personality,
|
||||
PersonalReportKickOff,
|
||||
PersonalReportOutro,
|
||||
WeatherIntro,
|
||||
WeatherTomorrowIntro,
|
||||
WeatherTodayHighLow,
|
||||
WeatherTomorrowHighLow,
|
||||
WeatherServiceDown,
|
||||
CalendarNothingToday,
|
||||
CalendarNothing,
|
||||
CalendarServiceDown,
|
||||
CalendarOutro,
|
||||
CommuteNow,
|
||||
CommuteMinutesLeft,
|
||||
CommuteDepartTimeNormal,
|
||||
CommuteDepartTimeNotNormal,
|
||||
CommuteAppSetup,
|
||||
CommuteConfirmSpeaker,
|
||||
CommuteDriveNormal,
|
||||
CommuteDriveLate,
|
||||
CommuteDriveHurry,
|
||||
CommuteDrivePoor,
|
||||
CommuteDriveTerrible,
|
||||
CommuteTransportNormal,
|
||||
CommuteTransportLate,
|
||||
CommuteTransportHurry,
|
||||
CommuteServiceDown,
|
||||
NewsIntro,
|
||||
NewsCategoryIntro,
|
||||
NewsOutro,
|
||||
ReportSkillTemplate
|
||||
}
|
||||
|
||||
private sealed class LegacyMimCatalogBuilder
|
||||
{
|
||||
private readonly List<string> _birthdayCelebrationReplies = [];
|
||||
private readonly List<string> _calendarNothingReplies = [];
|
||||
private readonly List<string> _calendarNothingTodayReplies = [];
|
||||
private readonly List<string> _calendarOutroReplies = [];
|
||||
private readonly List<string> _calendarServiceDownReplies = [];
|
||||
private readonly List<string> _commuteAppSetupReplies = [];
|
||||
private readonly List<string> _commuteConfirmSpeakerReplies = [];
|
||||
private readonly List<string> _commuteDepartTimeNormalReplies = [];
|
||||
private readonly List<string> _commuteDepartTimeNotNormalReplies = [];
|
||||
private readonly List<string> _commuteDriveHurryReplies = [];
|
||||
private readonly List<string> _commuteDriveLateReplies = [];
|
||||
private readonly List<string> _commuteDriveNormalReplies = [];
|
||||
private readonly List<string> _commuteDrivePoorReplies = [];
|
||||
private readonly List<string> _commuteDriveTerribleReplies = [];
|
||||
private readonly List<string> _commuteMinutesLeftReplies = [];
|
||||
private readonly List<string> _commuteNowReplies = [];
|
||||
private readonly List<string> _commuteServiceDownReplies = [];
|
||||
private readonly List<string> _commuteTransportHurryReplies = [];
|
||||
private readonly List<string> _commuteTransportLateReplies = [];
|
||||
private readonly List<string> _commuteTransportNormalReplies = [];
|
||||
private readonly List<JiboConditionedReply> _emotionReplies = [];
|
||||
private readonly List<string> _fallbacks = [];
|
||||
private readonly List<string> _favoriteAnimalReplies = [];
|
||||
private readonly List<string> _friendReplies = [];
|
||||
private readonly List<string> _bestFriendReplies = [];
|
||||
private readonly List<string> _funFacts = [];
|
||||
private readonly List<string> _greetings = [];
|
||||
private readonly List<string> _ages = [];
|
||||
private readonly List<string> _holidayGiftReplies = [];
|
||||
private readonly List<string> _holidayGreetingReplies = [];
|
||||
private readonly List<string> _holidayReplies = [];
|
||||
private readonly List<string> _holidaySeasonReplies = [];
|
||||
private readonly List<string> _holidayTrackerReplies = [];
|
||||
private readonly List<string> _holidaySingReplies = [];
|
||||
private readonly List<string> _howAreYous = [];
|
||||
private readonly List<string> _humanFacts = [];
|
||||
private readonly List<string> _jokes = [];
|
||||
private readonly List<string> _newsCategoryIntroReplies = [];
|
||||
private readonly List<string> _newsIntroReplies = [];
|
||||
private readonly List<string> _newsOutroReplies = [];
|
||||
private readonly List<string> _personalities = [];
|
||||
private readonly List<string> _personalReportKickOffReplies = [];
|
||||
private readonly List<string> _personalReportOutroReplies = [];
|
||||
private readonly List<string> _reportSkillTemplates = [];
|
||||
private readonly List<string> _robotFacts = [];
|
||||
private readonly List<string> _singReplies = [];
|
||||
private readonly List<string> _weatherIntroReplies = [];
|
||||
private readonly List<string> _weatherServiceDownReplies = [];
|
||||
private readonly List<string> _weatherTodayHighLowReplies = [];
|
||||
private readonly List<string> _weatherTomorrowHighLowReplies = [];
|
||||
private readonly List<string> _weatherTomorrowIntroReplies = [];
|
||||
|
||||
public void Add(LegacyMimBucket bucket, string? condition, string text, string? sourcePrompt = null)
|
||||
{
|
||||
switch (bucket)
|
||||
{
|
||||
case LegacyMimBucket.GenericFallback:
|
||||
if (_fallbacks.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
|
||||
|
||||
_fallbacks.Add(text);
|
||||
return;
|
||||
case LegacyMimBucket.Greeting:
|
||||
if (_greetings.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
|
||||
|
||||
_greetings.Add(text);
|
||||
return;
|
||||
case LegacyMimBucket.Jokes:
|
||||
if (_jokes.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
|
||||
|
||||
_jokes.Add(text);
|
||||
return;
|
||||
case LegacyMimBucket.RobotFacts:
|
||||
AddDistinct(_robotFacts, text);
|
||||
return;
|
||||
case LegacyMimBucket.HumanFacts:
|
||||
AddDistinct(_humanFacts, text);
|
||||
return;
|
||||
case LegacyMimBucket.HowAreYou:
|
||||
if (_howAreYous.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
|
||||
return;
|
||||
|
||||
_howAreYous.Add(text);
|
||||
return;
|
||||
case LegacyMimBucket.Emotion:
|
||||
var normalizedCondition = NormalizeCondition(condition);
|
||||
if (_emotionReplies.Any(value =>
|
||||
string.Equals(NormalizeCondition(value.Condition), normalizedCondition,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(value.Reply, text, StringComparison.OrdinalIgnoreCase)))
|
||||
return;
|
||||
|
||||
_emotionReplies.Add(new JiboConditionedReply
|
||||
{
|
||||
Condition = normalizedCondition,
|
||||
Reply = text
|
||||
});
|
||||
return;
|
||||
case LegacyMimBucket.Age:
|
||||
AddDistinct(_ages, text);
|
||||
return;
|
||||
case LegacyMimBucket.Holiday:
|
||||
AddDistinct(_holidayReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.HolidaySeason:
|
||||
AddDistinct(_holidaySeasonReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.HolidayGreeting:
|
||||
AddDistinct(_holidayGreetingReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.HolidayGift:
|
||||
AddDistinct(_holidayGiftReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.HolidayTracker:
|
||||
AddDistinct(_holidayTrackerReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.BirthdayCelebration:
|
||||
AddDistinct(_birthdayCelebrationReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.Personality:
|
||||
if (_personalities.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase)))
|
||||
return;
|
||||
|
||||
_personalities.Add(text);
|
||||
return;
|
||||
case LegacyMimBucket.Sing:
|
||||
AddDistinct(_singReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.HolidaySing:
|
||||
AddDistinct(_holidaySingReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.FunFactSource:
|
||||
switch (ResolveFunFactTarget(sourcePrompt ?? text))
|
||||
{
|
||||
case LegacyMimBucket.RobotFacts:
|
||||
AddDistinct(_robotFacts, text);
|
||||
return;
|
||||
case LegacyMimBucket.HumanFacts:
|
||||
AddDistinct(_humanFacts, text);
|
||||
return;
|
||||
default:
|
||||
AddDistinct(_funFacts, text);
|
||||
return;
|
||||
}
|
||||
case LegacyMimBucket.FunFacts:
|
||||
if (_funFacts.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
|
||||
|
||||
_funFacts.Add(text);
|
||||
return;
|
||||
case LegacyMimBucket.FavoriteAnimal:
|
||||
AddDistinct(_favoriteAnimalReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.Friend:
|
||||
AddDistinct(_friendReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.BestFriend:
|
||||
AddDistinct(_bestFriendReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.PersonalReportKickOff:
|
||||
AddDistinct(_personalReportKickOffReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.PersonalReportOutro:
|
||||
AddDistinct(_personalReportOutroReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.WeatherIntro:
|
||||
AddDistinct(_weatherIntroReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.WeatherTomorrowIntro:
|
||||
AddDistinct(_weatherTomorrowIntroReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.WeatherTodayHighLow:
|
||||
AddDistinct(_weatherTodayHighLowReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.WeatherTomorrowHighLow:
|
||||
AddDistinct(_weatherTomorrowHighLowReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.WeatherServiceDown:
|
||||
AddDistinct(_weatherServiceDownReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CalendarNothingToday:
|
||||
AddDistinct(_calendarNothingTodayReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CalendarNothing:
|
||||
AddDistinct(_calendarNothingReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CalendarServiceDown:
|
||||
AddDistinct(_calendarServiceDownReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CalendarOutro:
|
||||
AddDistinct(_calendarOutroReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteAppSetup:
|
||||
AddDistinct(_commuteAppSetupReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteConfirmSpeaker:
|
||||
AddDistinct(_commuteConfirmSpeakerReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteNow:
|
||||
AddDistinct(_commuteNowReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteMinutesLeft:
|
||||
AddDistinct(_commuteMinutesLeftReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteDepartTimeNormal:
|
||||
AddDistinct(_commuteDepartTimeNormalReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteDepartTimeNotNormal:
|
||||
AddDistinct(_commuteDepartTimeNotNormalReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteDriveNormal:
|
||||
AddDistinct(_commuteDriveNormalReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteDriveLate:
|
||||
AddDistinct(_commuteDriveLateReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteDriveHurry:
|
||||
AddDistinct(_commuteDriveHurryReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteDrivePoor:
|
||||
AddDistinct(_commuteDrivePoorReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteDriveTerrible:
|
||||
AddDistinct(_commuteDriveTerribleReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteTransportNormal:
|
||||
AddDistinct(_commuteTransportNormalReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteTransportLate:
|
||||
AddDistinct(_commuteTransportLateReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteTransportHurry:
|
||||
AddDistinct(_commuteTransportHurryReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.CommuteServiceDown:
|
||||
AddDistinct(_commuteServiceDownReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.NewsIntro:
|
||||
AddDistinct(_newsIntroReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.NewsCategoryIntro:
|
||||
AddDistinct(_newsCategoryIntroReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.NewsOutro:
|
||||
AddDistinct(_newsOutroReplies, text);
|
||||
return;
|
||||
case LegacyMimBucket.ReportSkillTemplate:
|
||||
AddDistinct(_reportSkillTemplates, text);
|
||||
return;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(bucket), bucket, null);
|
||||
}
|
||||
}
|
||||
|
||||
public JiboExperienceCatalog Build()
|
||||
{
|
||||
return new JiboExperienceCatalog
|
||||
{
|
||||
Jokes = [.. _jokes],
|
||||
RobotFacts = [.. _robotFacts],
|
||||
HumanFacts = [.. _humanFacts],
|
||||
FunFacts = [.. _funFacts],
|
||||
FavoriteAnimalReplies = [.. _favoriteAnimalReplies],
|
||||
FriendReplies = [.. _friendReplies],
|
||||
BestFriendReplies = [.. _bestFriendReplies],
|
||||
SingReplies = [.. _singReplies],
|
||||
HolidaySingReplies = [.. _holidaySingReplies],
|
||||
GreetingReplies = [.. _greetings],
|
||||
HolidayReplies = [.. _holidayReplies],
|
||||
HolidaySeasonReplies = [.. _holidaySeasonReplies],
|
||||
HolidayGreetingReplies = [.. _holidayGreetingReplies],
|
||||
HolidayGiftReplies = [.. _holidayGiftReplies],
|
||||
HolidayTrackerReplies = [.. _holidayTrackerReplies],
|
||||
BirthdayCelebrationReplies = [.. _birthdayCelebrationReplies],
|
||||
HowAreYouReplies = [.. _howAreYous],
|
||||
EmotionReplies = [.. _emotionReplies],
|
||||
PersonalityReplies = [.. _personalities],
|
||||
GenericFallbackReplies = [.. _fallbacks],
|
||||
AgeReplies = [.. _ages],
|
||||
PersonalReportKickOffReplies = [.. _personalReportKickOffReplies],
|
||||
PersonalReportOutroReplies = [.. _personalReportOutroReplies],
|
||||
ReportSkillTemplates = [.. _reportSkillTemplates],
|
||||
WeatherIntroReplies = [.. _weatherIntroReplies],
|
||||
WeatherTomorrowIntroReplies = [.. _weatherTomorrowIntroReplies],
|
||||
WeatherTodayHighLowReplies = [.. _weatherTodayHighLowReplies],
|
||||
WeatherTomorrowHighLowReplies = [.. _weatherTomorrowHighLowReplies],
|
||||
WeatherServiceDownReplies = [.. _weatherServiceDownReplies],
|
||||
CalendarNothingTodayReplies = [.. _calendarNothingTodayReplies],
|
||||
CalendarNothingReplies = [.. _calendarNothingReplies],
|
||||
CalendarServiceDownReplies = [.. _calendarServiceDownReplies],
|
||||
CalendarOutroReplies = [.. _calendarOutroReplies],
|
||||
CommuteAppSetupReplies = [.. _commuteAppSetupReplies],
|
||||
CommuteConfirmSpeakerReplies = [.. _commuteConfirmSpeakerReplies],
|
||||
CommuteNowReplies = [.. _commuteNowReplies],
|
||||
CommuteMinutesLeftReplies = [.. _commuteMinutesLeftReplies],
|
||||
CommuteDepartTimeNormalReplies = [.. _commuteDepartTimeNormalReplies],
|
||||
CommuteDepartTimeNotNormalReplies = [.. _commuteDepartTimeNotNormalReplies],
|
||||
CommuteDriveNormalReplies = [.. _commuteDriveNormalReplies],
|
||||
CommuteDriveLateReplies = [.. _commuteDriveLateReplies],
|
||||
CommuteDriveHurryReplies = [.. _commuteDriveHurryReplies],
|
||||
CommuteDrivePoorReplies = [.. _commuteDrivePoorReplies],
|
||||
CommuteDriveTerribleReplies = [.. _commuteDriveTerribleReplies],
|
||||
CommuteTransportNormalReplies = [.. _commuteTransportNormalReplies],
|
||||
CommuteTransportLateReplies = [.. _commuteTransportLateReplies],
|
||||
CommuteTransportHurryReplies = [.. _commuteTransportHurryReplies],
|
||||
CommuteServiceDownReplies = [.. _commuteServiceDownReplies],
|
||||
NewsIntroReplies = [.. _newsIntroReplies],
|
||||
NewsCategoryIntroReplies = [.. _newsCategoryIntroReplies],
|
||||
NewsOutroReplies = [.. _newsOutroReplies]
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddDistinct(List<string> target, string text)
|
||||
{
|
||||
if (target.Any(value => string.Equals(value, text, StringComparison.OrdinalIgnoreCase))) return;
|
||||
|
||||
target.Add(text);
|
||||
}
|
||||
|
||||
private LegacyMimBucket ResolveFunFactTarget(string prompt)
|
||||
{
|
||||
var lowered = NormalizePrompt(prompt, false).ToLowerInvariant();
|
||||
if (ContainsAny(lowered, "robot", "humanoid", "machine", "about me", "my cameras", "turing", "deep blue",
|
||||
"rossum"))
|
||||
return LegacyMimBucket.RobotFacts;
|
||||
|
||||
if (ContainsAny(lowered, "human", "people", "grown ups", "human being", "humans"))
|
||||
return LegacyMimBucket.HumanFacts;
|
||||
|
||||
return LegacyMimBucket.FunFacts;
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string text, params string[] values)
|
||||
{
|
||||
return values.Any(value => text.Contains(value, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LegacyMimDefinition
|
||||
{
|
||||
[JsonPropertyName("skill_id")] public string? SkillId { get; init; }
|
||||
|
||||
[JsonPropertyName("mim_id")] public string? MimId { get; init; }
|
||||
|
||||
[JsonPropertyName("mim_type")] public string? MimType { get; init; }
|
||||
|
||||
[JsonPropertyName("prompts")] public List<LegacyMimPrompt> Prompts { get; init; } = [];
|
||||
}
|
||||
|
||||
private sealed class LegacyMimPrompt
|
||||
{
|
||||
[JsonPropertyName("mim_id")] public string? MimId { get; init; }
|
||||
|
||||
[JsonPropertyName("prompt_category")] public string? PromptCategory { get; init; }
|
||||
|
||||
[JsonPropertyName("prompt_sub_category")]
|
||||
public string? PromptSubCategory { get; init; }
|
||||
|
||||
[JsonPropertyName("condition")] public string? Condition { get; init; }
|
||||
|
||||
[JsonPropertyName("prompt")] public string? Prompt { get; init; }
|
||||
|
||||
[JsonPropertyName("prompt_id")] public string? PromptId { get; init; }
|
||||
|
||||
[JsonPropertyName("weight")] public double? Weight { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user