Changes
This commit is contained in:
parent
fb291f768b
commit
29acdefafa
25
README.md
25
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# EventBuzz Concierge
|
# EventBuzz Concierge
|
||||||
|
|
||||||
Independent Node.js service for the EventBuzz Virtual Concierge chatbot. Uses Amazon Bedrock for Call 1 (intent classification). Connects to the same MongoDB as the main EventBuzz backend.
|
Independent Node.js service for the EventBuzz Virtual Concierge chatbot. Uses Amazon Bedrock **tool calling** (router + synthesis). Connects to the same MongoDB as the main EventBuzz backend.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|
@ -30,26 +30,31 @@ First time on the server: create `/var/www/concierge/.env` (copy from `.env.exam
|
||||||
## Embed demo
|
## Embed demo
|
||||||
|
|
||||||
1. Start the server.
|
1. Start the server.
|
||||||
2. Open [http://localhost:3010/embed/demo](http://localhost:3010/embed/demo).
|
2. Open [http://localhost:3010/embed/demo](http://localhost:3010/embed/demo) for the floating widget, or [http://localhost:3010/embed/avatar](http://localhost:3010/embed/avatar) for the SIMLI avatar + chat page.
|
||||||
3. Set `botId` in the page script to a real `ConciergeBot` document `_id` from MongoDB.
|
3. Set `botId` in the page script to a real `ConciergeBot` document `_id` from MongoDB. The avatar page also needs `SIMLI_API_KEY` and `SIMLI_FACE_ID` in `.env`, plus LiveKit voice.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| GET | `/api/public/concierge/{botId}/config` | Widget greeting / config |
|
| GET | `/api/public/concierge/{botId}/config` | Widget greeting / config |
|
||||||
| POST | `/api/public/concierge/{botId}/chat` | Send message (Call 1 classification) |
|
| POST | `/api/public/concierge/{botId}/chat` | Send a chat turn (tool-calling agent) |
|
||||||
| GET | `/api/public/concierge/{botId}/session/{sessionRef}` | Resume session |
|
| GET | `/api/public/concierge/{botId}/session/{sessionRef}` | Resume session |
|
||||||
|
| POST | `/api/public/concierge/{botId}/voice/token` | LiveKit visitor token |
|
||||||
|
| POST | `/api/public/concierge/{botId}/simli/token` | SIMLI session token (API key stays on the server) |
|
||||||
| GET | `/webhook/whatsapp` | Meta webhook verification |
|
| GET | `/webhook/whatsapp` | Meta webhook verification |
|
||||||
| POST | `/webhook/whatsapp` | Inbound WhatsApp (reception + follow-up) |
|
| POST | `/webhook/whatsapp` | Inbound WhatsApp (reception + follow-up) |
|
||||||
|
|
||||||
## Pipeline
|
## Agent
|
||||||
|
|
||||||
1. **Call 1** (Nova Micro) — intent + entities
|
One Bedrock model (`BEDROCK_AGENT_MODEL_ID`, defaulting to Claude Haiku 4.5) chooses EventBuzz tools, the service executes them against Mongo (identity injected from the chat session), then the same model writes the visitor-facing reply.
|
||||||
2. **Call 2** (Claude 3 Haiku) — agenda replies with Sessions DB context
|
|
||||||
3. **Bookmark mutations** — `agenda_bookmark_add` / `agenda_bookmark_remove` write `SessionBookmark` records (no Call 2)
|
|
||||||
4. **Email OTP** — required for personal schedule, conflict check, and bookmark mutations; auth is stored on the web chat session
|
|
||||||
|
|
||||||
Set `BEDROCK_CALL2_MODEL_ID`, `OTP_PEPPER`, and SMTP/`MAIL_*` vars in `.env` (see `.env.example`). Without SMTP, OTP codes are logged to the server console.
|
Default model: `global.anthropic.claude-haiku-4-5-20251001-v1:0` (global inference profile — required in `ap-south-1`). Prompt caching is on by default (`BEDROCK_PROMPT_CACHE=true`): tools, the system prompt, and the static EVENT brief are marked with `cachePoint` (1-hour TTL). Haiku 4.5 only records a cache hit after ~4096 tokens in that prefix; check `cacheReadInputTokens` / `cacheWriteInputTokens` in Bedrock logs. Claude 3 Haiku (20240307) cannot use `cachePoint`.
|
||||||
|
|
||||||
|
Tools: `get_my_tickets`, `get_my_agenda`, `email_my_ticket`, `get_agenda`, `get_speakers`, `get_exhibitors`, `get_venue`, `get_local`, `get_tech_help`, `search_faqs`, `save_session`, `remove_saved_session`, `contact_organizer`.
|
||||||
|
|
||||||
|
Personal tools and mutations require email OTP (`identity.service`). Widget, WhatsApp, and LiveKit voice all share this runner.
|
||||||
|
|
||||||
|
Set `BEDROCK_AGENT_MODEL_ID` or `BEDROCK_CALL2_MODEL_ID`, `OTP_PEPPER`, and SMTP/`MAIL_*` vars in `.env` (see `.env.example`). Without SMTP, OTP codes are logged to the server console.
|
||||||
|
|
||||||
Reception WhatsApp: point the Meta webhook at `GET/POST /webhook/whatsapp` on this service (verify token `META_WHATSAPP_WEBHOOK_VERIFY_TOKEN`). Configure copy and drips in the dashboard **WhatsApp Chatbots → Reception** tab. WABA credentials come from Organisation WhatsApp settings.
|
Reception WhatsApp: point the Meta webhook at `GET/POST /webhook/whatsapp` on this service (verify token `META_WHATSAPP_WEBHOOK_VERIFY_TOKEN`). Configure copy and drips in the dashboard **WhatsApp Chatbots → Reception** tab. WABA credentials come from Organisation WhatsApp settings.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
/**
|
/**
|
||||||
* PM2 — EventBuzz Virtual Concierge API (same server as backend).
|
* PM2 — EventBuzz Virtual Concierge API + LiveKit voice worker (same server as backend).
|
||||||
*
|
*
|
||||||
* cd /var/www/concierge && pm2 start ecosystem.config.cjs
|
* cd /var/www/concierge && pm2 start ecosystem.config.cjs
|
||||||
* pm2 save
|
* pm2 save
|
||||||
*
|
*
|
||||||
* Requires concierge/.env on the server (MONGODB_URI, AWS/Bedrock, MAIL_*).
|
* Requires concierge/.env on the server (MONGODB_URI, AWS/Bedrock, MAIL_*, LIVEKIT_*).
|
||||||
* Prefer HOST=0.0.0.0 (or nginx → 127.0.0.1:3010 with HOST=localhost).
|
* Prefer HOST=0.0.0.0 (or nginx → 127.0.0.1:3010 with HOST=localhost).
|
||||||
|
*
|
||||||
|
* LiveKit voice: LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET.
|
||||||
|
* One worker instance only (it registers with LiveKit Cloud).
|
||||||
*/
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
|
|
@ -21,5 +24,18 @@ module.exports = {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'livekit-voice-worker',
|
||||||
|
script: 'src/workers/livekitVoiceAgent.js',
|
||||||
|
args: 'start',
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 20,
|
||||||
|
restart_delay: 5000,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
# Specification: Eventbuzz Virtual Concierge (Text & LiveKit Voice)
|
||||||
|
|
||||||
|
## 1. System Overview & Architecture
|
||||||
|
|
||||||
|
Build a multi-modal (Text & Voice) AI concierge agent for the **Eventbuzz** platform. The system uses an **Agentic Tool-Calling pattern** where a single LLM call acts as both intent recognizer and response generator.
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────┐
|
||||||
|
│ Client / Web │
|
||||||
|
│ (Text / App) │
|
||||||
|
└──────┬───────┘
|
||||||
|
│ (HTTP/WS with JWT)
|
||||||
|
▼
|
||||||
|
┌──────────────┐ ┌────────────────────────────────────────────────────────┐
|
||||||
|
│ LiveKit │ │ Concierge Agent Gateway │
|
||||||
|
│ Voice Agent ├─►│ 1. Extract & Verify Auth (User ID, Event ID) │
|
||||||
|
│ (SIP/WebRTC) │ │ 2. Construct LLM Context & Bind User-Scoped Tools │
|
||||||
|
└──────────────┘ └──────────────────────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────┐
|
||||||
|
│ LLM (Tool Calling) │
|
||||||
|
└──────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────┴───────────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
[Personal Info Intent] [Event Knowledge Intent]
|
||||||
|
Tool: `get_my_registration` Tool: `search_event_faq`
|
||||||
|
Backend API / DB query Vector DB / Hybrid Search
|
||||||
|
(Hard-scoped to Auth Context) (Scoped to Event ID)
|
||||||
|
│ │
|
||||||
|
└───────────────────────┬───────────────────────┘
|
||||||
|
▼
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ LLM Synthesis Call │
|
||||||
|
└───────────┬───────────┘
|
||||||
|
▼
|
||||||
|
Formatted Text / Audio Response
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── agent/
|
||||||
|
│ ├── prompts.ts # System instructions and tone configuration
|
||||||
|
│ ├── runner.ts # Core LLM chat and tool execution loop
|
||||||
|
│ └── tools/
|
||||||
|
│ ├── index.ts # Tool registry and dynamic context binding
|
||||||
|
│ ├── personal.tools.ts # Ticket, registration, and user-specific queries
|
||||||
|
│ └── knowledge.tools.ts # RAG / FAQ retrieval tools
|
||||||
|
├── auth/
|
||||||
|
│ └── context.ts # Session extraction (userId, eventId, role)
|
||||||
|
├── livekit/
|
||||||
|
│ └── voice_worker.ts # LiveKit Agents SDK worker integration
|
||||||
|
├── rag/
|
||||||
|
│ └── retriever.ts # Vector search or semantic retrieval service
|
||||||
|
└── server.ts # WebSocket / HTTP entry point for text chat
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tool Declarations & Context Binding
|
||||||
|
|
||||||
|
To prevent privilege escalation or data leakage, **the LLM must never supply the `userId` as an argument**. The execution layer injects user identity from the authenticated session.
|
||||||
|
|
||||||
|
### Tool Schemas (Provided to LLM)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const conciergeTools = [
|
||||||
|
{
|
||||||
|
name: "get_my_registration_and_tickets",
|
||||||
|
description: "Retrieves the current attendee's ticket tier, QR code data, payment status, and registered sessions. Requires no input arguments.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
required: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "search_event_info",
|
||||||
|
description: "Searches event details such as venue policies, schedules, speaker bios, dress code, Wi-Fi details, and general FAQs.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Specific search term or question regarding the event."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["query"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_agenda_schedule",
|
||||||
|
description: "Fetches schedule items for a specific date, stage, or track.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
date: { type: "string", description: "Date in YYYY-MM-DD format (optional)" },
|
||||||
|
track: { type: "string", description: "Track or stage name (optional)" }
|
||||||
|
},
|
||||||
|
required: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context-Bound Tool Handlers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface SessionContext {
|
||||||
|
userId: string;
|
||||||
|
eventId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createToolExecutors(ctx: SessionContext) {
|
||||||
|
return {
|
||||||
|
get_my_registration_and_tickets: async () => {
|
||||||
|
// Injects ctx.userId automatically; LLM cannot spoof another user
|
||||||
|
return await db.registrations.findFirst({
|
||||||
|
where: { userId: ctx.userId, eventId: ctx.eventId },
|
||||||
|
include: { tickets: true, assignedSessions: true }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
search_event_info: async ({ query }: { query: string }) => {
|
||||||
|
return await vectorStore.similaritySearch(query, {
|
||||||
|
k: 4,
|
||||||
|
filter: { eventId: ctx.eventId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
get_agenda_schedule: async (filters: { date?: string; track?: string }) => {
|
||||||
|
return await db.sessions.findMany({
|
||||||
|
where: {
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
...(filters.date && { date: filters.date }),
|
||||||
|
...(filters.track && { track: filters.track })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. System Prompt Design
|
||||||
|
|
||||||
|
```text
|
||||||
|
You are the Eventbuzz Virtual Concierge for the event identified as {{eventName}}.
|
||||||
|
Your role is to assist participants with both logistical event questions and personal ticketing/registration details.
|
||||||
|
|
||||||
|
Tone & Demeanor:
|
||||||
|
- Welcoming, clear, concise, and professional.
|
||||||
|
- For voice delivery (via LiveKit), keep responses brief (under 3 sentences when possible) and avoid formatting characters like markdown tables, bullets, or raw URLs.
|
||||||
|
|
||||||
|
Tool Rules:
|
||||||
|
1. Whenever the user asks about their own status, tickets, check-in QR, or seat, invoke `get_my_registration_and_tickets`.
|
||||||
|
2. When the user asks general event questions (parking, dress code, speaker line-ups), invoke `search_event_info`.
|
||||||
|
3. NEVER assume personal registration details or make up ticket numbers. If a tool returns no record, inform the participant politely and suggest visiting the physical Help Desk.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Execution Loop (Text & Voice)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function runConciergeTurn(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
ctx: SessionContext
|
||||||
|
) {
|
||||||
|
const toolExecutors = createToolExecutors(ctx);
|
||||||
|
|
||||||
|
// First call: LLM determines whether to respond directly or invoke a tool
|
||||||
|
const response = await llm.chat({
|
||||||
|
messages,
|
||||||
|
tools: conciergeTools,
|
||||||
|
toolChoice: "auto"
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolCalls = response.message.tool_calls;
|
||||||
|
|
||||||
|
// Case 1: Direct response (chit-chat or clarifications)
|
||||||
|
if (!toolCalls || toolCalls.length === 0) {
|
||||||
|
return response.message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Intent matched to tool
|
||||||
|
const toolResults = await Promise.all(
|
||||||
|
toolCalls.map(async (call) => {
|
||||||
|
const fn = toolExecutors[call.function.name];
|
||||||
|
if (!fn) throw new Error(`Unknown tool: ${call.function.name}`);
|
||||||
|
|
||||||
|
const args = JSON.parse(call.function.arguments || "{}");
|
||||||
|
const result = await fn(args);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tool_call_id: call.id,
|
||||||
|
role: "tool",
|
||||||
|
name: call.function.name,
|
||||||
|
content: JSON.stringify(result)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Synthesis call: Feed tool outputs back to LLM for final natural response
|
||||||
|
const finalResponse = await llm.chat({
|
||||||
|
messages: [
|
||||||
|
...messages,
|
||||||
|
response.message,
|
||||||
|
...toolResults
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalResponse.message.content;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. LiveKit Voice Integration Spec
|
||||||
|
|
||||||
|
Connect the agent to LiveKit rooms using the LiveKit Agents framework:
|
||||||
|
|
||||||
|
1. **Room Entry & Token Validation:** When dispatching the voice worker to a participant's room, pass `userId` and `eventId` inside the LiveKit participant metadata or worker job metadata.
|
||||||
|
2. **STT & TTS Pipeline:**
|
||||||
|
* **STT:** Deepgram / Whisper stream.
|
||||||
|
* **LLM Engine:** Hook `runConciergeTurn` directly into the agent callback.
|
||||||
|
* **TTS:** Cartesia / ElevenLabs stream with low-latency configuration.
|
||||||
|
3. **Interrupt Handling:** Ensure LiveKit's turn-detection/VAD (Voice Activity Detection) cancels inflight LLM synthesis if the participant interrupts mid-sentence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Step-by-Step Implementation Tasks for Cursor
|
||||||
|
|
||||||
|
```text
|
||||||
|
- [ ] Task 1: Setup data layer for event knowledge and registrations
|
||||||
|
- Create mock/read queries for `registrations`, `tickets`, and `sessions`.
|
||||||
|
- Implement basic RAG retriever filtered by `eventId`.
|
||||||
|
|
||||||
|
- [ ] Task 2: Implement Tool Definitions and Executor Mapping
|
||||||
|
- Define JSON schemas in `src/agent/tools/`.
|
||||||
|
- Implement context-injection factory in `src/agent/tools/index.ts`.
|
||||||
|
|
||||||
|
- [ ] Task 3: Build the Agent Orchestrator
|
||||||
|
- Implement `runConciergeTurn` in `src/agent/runner.ts`.
|
||||||
|
- Add conversational state management (in-memory or Redis session store).
|
||||||
|
|
||||||
|
- [ ] Task 4: Expose Text Chat API
|
||||||
|
- Create a POST `/api/concierge/chat` route with JWT validation.
|
||||||
|
- Test via curl:
|
||||||
|
1. General query ("Where is the keynote?")
|
||||||
|
2. Personal query ("Do I have a VIP pass?")
|
||||||
|
|
||||||
|
- [ ] Task 5: Implement LiveKit Voice Worker
|
||||||
|
- Configure `livekit-agents` worker in `src/livekit/voice_worker.ts`.
|
||||||
|
- Bind participant metadata to `SessionContext`.
|
||||||
|
- Ensure tool outputs format cleanly for speech (no bullet points or markdown tables).
|
||||||
|
```
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
"livekit-server-sdk": "^2.18.0",
|
"livekit-server-sdk": "^2.18.0",
|
||||||
"mongoose": "^8.0.0",
|
"mongoose": "^8.0.0",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
"qrcode": "^1.5.4"
|
"qrcode": "^1.5.4",
|
||||||
|
"simli-client": "^3.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|
@ -2796,6 +2797,13 @@
|
||||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dom-mediacapture-record": {
|
||||||
|
"version": "1.0.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||||
|
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "16.9.1",
|
"version": "16.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
|
||||||
|
|
@ -4025,6 +4033,36 @@
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/livekit-client": {
|
||||||
|
"version": "2.22.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.3.tgz",
|
||||||
|
"integrity": "sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@livekit/mutex": "1.1.1",
|
||||||
|
"@livekit/protocol": "1.50.4",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"jose": "^6.1.0",
|
||||||
|
"loglevel": "^1.9.2",
|
||||||
|
"machina": "^7.0.0",
|
||||||
|
"sdp-transform": "^2.15.0",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"typed-emitter": "^2.1.0",
|
||||||
|
"webrtc-adapter": "9.0.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/dom-mediacapture-record": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/livekit-client/node_modules/jose": {
|
||||||
|
"version": "6.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||||
|
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/livekit-server-sdk": {
|
"node_modules/livekit-server-sdk": {
|
||||||
"version": "2.18.0",
|
"version": "2.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.18.0.tgz",
|
||||||
|
|
@ -4076,6 +4114,19 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/loglevel": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "11.5.2",
|
"version": "11.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
|
|
@ -4085,6 +4136,15 @@
|
||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/machina": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/machina/-/machina-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|
@ -4828,6 +4888,16 @@
|
||||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
|
@ -4866,6 +4936,21 @@
|
||||||
"node": ">=11.0.0"
|
"node": ">=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sdp": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/sdp-transform": {
|
||||||
|
"version": "2.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||||
|
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"sdp-verify": "checker.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/secure-json-parse": {
|
"node_modules/secure-json-parse": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||||
|
|
@ -4945,6 +5030,15 @@
|
||||||
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
|
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/simli-client": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/simli-client/-/simli-client-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Evfxj1hr7r1rtk/HOK7Ub7WZZQStkrRBFvCvWG/zukaZUw1QJcRO2bH/YKUpU2beaLfothTCb2XegTV+YlhRFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"livekit-client": "^2.15.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonic-boom": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "3.8.1",
|
"version": "3.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz",
|
||||||
|
|
@ -5092,6 +5186,15 @@
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/typed-emitter": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"rxjs": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
|
@ -5151,6 +5254,19 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webrtc-adapter": {
|
||||||
|
"version": "9.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||||
|
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"sdp": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0",
|
||||||
|
"npm": ">=3.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-fetch": {
|
"node_modules/whatwg-fetch": {
|
||||||
"version": "3.6.20",
|
"version": "3.6.20",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "eventbuzz-concierge",
|
"name": "eventbuzz-concierge",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "EventBuzz Virtual Concierge — independent Bedrock two-call chatbot service",
|
"description": "EventBuzz Virtual Concierge — Bedrock tool-calling chatbot service",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"free-port": "node scripts/free-port.js",
|
"free-port": "node scripts/free-port.js",
|
||||||
"dev": "node scripts/free-port.js && node --watch server.js",
|
"dev": "node scripts/free-port.js && node --watch server.js",
|
||||||
"livekit-voice-worker": "node src/workers/livekitVoiceAgent.js start"
|
"livekit-voice-worker": "node src/workers/livekitVoiceAgent.js start",
|
||||||
|
"bundle:simli": "npx esbuild node_modules/simli-client/lib/client.ts --bundle --format=esm --platform=browser --target=es2020 --minify --outfile=public/embed/simli-client.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
"livekit-server-sdk": "^2.18.0",
|
"livekit-server-sdk": "^2.18.0",
|
||||||
"mongoose": "^8.0.0",
|
"mongoose": "^8.0.0",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
"qrcode": "^1.5.4"
|
"qrcode": "^1.5.4",
|
||||||
|
"simli-client": "^3.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>EventBuzz Concierge Avatar</title>
|
||||||
|
<link rel="stylesheet" href="/embed/avatar-page.css?v=9" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="eba-root">
|
||||||
|
<section class="eba-avatar" aria-label="Concierge avatar">
|
||||||
|
<video id="eba-video" autoplay playsinline muted></video>
|
||||||
|
<audio id="eba-audio" autoplay playsinline></audio>
|
||||||
|
<canvas id="eba-wave" aria-hidden="true"></canvas>
|
||||||
|
<div id="eba-avatar-status">Starting…</div>
|
||||||
|
</section>
|
||||||
|
<section class="eba-chat" aria-label="Concierge chat">
|
||||||
|
<header class="eba-chat-header">
|
||||||
|
<img class="eba-logo" src="/embed/logo.png" alt="" onerror="this.style.display='none'" />
|
||||||
|
<div>
|
||||||
|
<div class="eba-chat-title" id="eba-title">Concierge</div>
|
||||||
|
<div class="eba-chat-sub" id="eba-sub">Voice and chat</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="eba-messages" class="eba-messages"></div>
|
||||||
|
<div id="eba-error" class="eba-error" hidden></div>
|
||||||
|
<form id="eba-form" class="eba-form">
|
||||||
|
<input id="eba-input" type="text" autocomplete="off" maxlength="2000" placeholder="Ask a question…" />
|
||||||
|
<button id="eba-send" type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<button id="eba-start" class="eba-start" type="button" hidden>
|
||||||
|
<span class="eba-start-title">Tap to start</span>
|
||||||
|
<span id="eba-start-hint" class="eba-start-hint"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.EB_CONCIERGE = {
|
||||||
|
botId: "6a61ddf778df0ea305244efc",
|
||||||
|
apiBase: location.origin.replace(/\/$/, "") + "/api",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.15.14/dist/livekit-client.umd.js"></script>
|
||||||
|
<script type="module" src="/embed/avatar-page.js?v=9"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
:root {
|
||||||
|
--eba-accent: rgb(13, 148, 136);
|
||||||
|
--eba-bg: #0b1220;
|
||||||
|
--eba-chat-bg: #f8fafc;
|
||||||
|
--eba-text: #0f172a;
|
||||||
|
--eba-muted: #64748b;
|
||||||
|
--eba-border: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
color: var(--eba-text);
|
||||||
|
background: var(--eba-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#eba-root {
|
||||||
|
height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-avatar {
|
||||||
|
position: relative;
|
||||||
|
background: #020617;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eba-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #020617;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eba-audio {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eba-wave {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-mode-wave #eba-video,
|
||||||
|
.eba-mode-wave #eba-audio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-mode-wave #eba-wave {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-mode-video #eba-wave {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eba-avatar-status {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--eba-chat-bg);
|
||||||
|
border-left: 1px solid var(--eba-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--eba-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-chat-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-chat-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--eba-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-msg {
|
||||||
|
max-width: 92%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--eba-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-msg.bot {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--eba-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-msg.bot strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-card {
|
||||||
|
border: 1px solid var(--eba-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-card-title {
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-card-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--eba-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-card a {
|
||||||
|
color: var(--eba-accent);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-card img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-feedback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--eba-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-feedback button {
|
||||||
|
border: 1px solid var(--eba-border);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-chip {
|
||||||
|
border: 1px solid var(--eba-border);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-error {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
border-top: 1px solid var(--eba-border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eba-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--eba-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eba-send {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--eba-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eba-send:disabled,
|
||||||
|
#eba-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-start {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(2, 6, 23, 0.72);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-start-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #cbd5e1;
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-start[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#eba-root {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 2fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eba-chat {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--eba-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -188,6 +188,10 @@
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#eventbuzz-concierge-root .ebc-msg.bot strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
#eventbuzz-concierge-root .ebc-intro + .ebc-cards-list,
|
#eventbuzz-concierge-root .ebc-intro + .ebc-cards-list,
|
||||||
#eventbuzz-concierge-root .ebc-intro + .ebc-feedback,
|
#eventbuzz-concierge-root .ebc-intro + .ebc-feedback,
|
||||||
#eventbuzz-concierge-root .ebc-cards-list + .ebc-feedback {
|
#eventbuzz-concierge-root .ebc-cards-list + .ebc-feedback {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,25 @@
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendFormattedText(parent, text) {
|
||||||
|
var s = String(text || '');
|
||||||
|
if (!s) return parent;
|
||||||
|
var re = /\*\*([^*]+)\*\*/g;
|
||||||
|
var last = 0;
|
||||||
|
var m;
|
||||||
|
while ((m = re.exec(s))) {
|
||||||
|
if (m.index > last) {
|
||||||
|
parent.appendChild(document.createTextNode(s.slice(last, m.index)));
|
||||||
|
}
|
||||||
|
parent.appendChild(el('strong', null, [m[1]]));
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < s.length) {
|
||||||
|
parent.appendChild(document.createTextNode(s.slice(last)));
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
var embedOrigin = apiBase.replace(/\/api$/, '');
|
var embedOrigin = apiBase.replace(/\/api$/, '');
|
||||||
|
|
||||||
function addCssOnce() {
|
function addCssOnce() {
|
||||||
|
|
@ -46,7 +65,7 @@
|
||||||
el('link', {
|
el('link', {
|
||||||
id: 'ebc-css',
|
id: 'ebc-css',
|
||||||
rel: 'stylesheet',
|
rel: 'stylesheet',
|
||||||
href: embedOrigin + '/embed/concierge-widget.css?v=22',
|
href: embedOrigin + '/embed/concierge-widget.css?v=23',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -686,7 +705,9 @@
|
||||||
|
|
||||||
function addEphemeralBotMessage(text) {
|
function addEphemeralBotMessage(text) {
|
||||||
var bubble = el('div', { class: 'ebc-msg bot ebc-ephemeral' });
|
var bubble = el('div', { class: 'ebc-msg bot ebc-ephemeral' });
|
||||||
bubble.appendChild(document.createTextNode(String(text || '')));
|
var intro = el('div', { class: 'ebc-intro' });
|
||||||
|
appendFormattedText(intro, text);
|
||||||
|
bubble.appendChild(intro);
|
||||||
messages.appendChild(bubble);
|
messages.appendChild(bubble);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -763,9 +784,9 @@
|
||||||
});
|
});
|
||||||
if (role === 'bot' || role === 'assistant') {
|
if (role === 'bot' || role === 'assistant') {
|
||||||
if (text) {
|
if (text) {
|
||||||
bubble.appendChild(
|
var intro = el('div', { class: 'ebc-intro' });
|
||||||
el('div', { class: cards.length || opts.messageId ? 'ebc-intro' : '' }, [String(text)]),
|
appendFormattedText(intro, text);
|
||||||
);
|
bubble.appendChild(intro);
|
||||||
}
|
}
|
||||||
if (cards.length) {
|
if (cards.length) {
|
||||||
var list = el('div', { class: 'ebc-cards-list' });
|
var list = el('div', { class: 'ebc-cards-list' });
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,115 @@
|
||||||
|
const { buildTicketQrCard } = require('../utils/ticketCards');
|
||||||
|
const { buildSessionCards } = require('../utils/sessionCards');
|
||||||
|
const { buildSpeakerCards, resolveSpeakerIdsFromModel } = require('../utils/speakerCards');
|
||||||
|
const { buildExhibitorCards, resolveExhibitorIdsFromModel } = require('../utils/exhibitorCards');
|
||||||
|
const { buildPlaceCards, resolvePlaceIds } = require('../utils/placeCards');
|
||||||
|
const { buildAccommodationCards, resolveHotelIds } = require('../utils/accommodationCards');
|
||||||
|
const { buildMapFileCard } = require('../utils/faqAnswer');
|
||||||
|
const { buildRegistrationCtaCards } = require('../utils/registrationLinks');
|
||||||
|
|
||||||
|
function sessionIdsFrom(data) {
|
||||||
|
if (Array.isArray(data?.sessionIds) && data.sessionIds.length) {
|
||||||
|
return data.sessionIds.map(String);
|
||||||
|
}
|
||||||
|
return (Array.isArray(data?.sessions) ? data.sessions : [])
|
||||||
|
.map((s) => String(s?.id || ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function faqKeysFrom(data) {
|
||||||
|
const keys = [];
|
||||||
|
const faqs = Array.isArray(data?.faqs) ? data.faqs : [];
|
||||||
|
for (const f of faqs) {
|
||||||
|
const key = String(f?.key || f?.category || '').trim();
|
||||||
|
if (key) keys.push(key);
|
||||||
|
}
|
||||||
|
if (Array.isArray(data?.faqKeysUsed)) {
|
||||||
|
for (const k of data.faqKeysUsed) {
|
||||||
|
const key = String(k || '').trim();
|
||||||
|
if (key) keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(data?.hits)) {
|
||||||
|
for (const h of data.hits) {
|
||||||
|
const key = String(h?.key || h?.category || '').trim();
|
||||||
|
if (key) keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardsFromToolResults(toolResults) {
|
||||||
|
const cards = [];
|
||||||
|
const sessionIds = [];
|
||||||
|
const faqKeysUsed = [];
|
||||||
|
let forceOrganizerContact = false;
|
||||||
|
|
||||||
|
for (const row of Array.isArray(toolResults) ? toolResults : []) {
|
||||||
|
const name = row?.name;
|
||||||
|
const data = row?.data && typeof row.data === 'object' ? row.data : {};
|
||||||
|
if (data.status === 'unauthenticated') continue;
|
||||||
|
|
||||||
|
faqKeysUsed.push(...faqKeysFrom(data));
|
||||||
|
if (name === 'contact_organizer') forceOrganizerContact = true;
|
||||||
|
|
||||||
|
if (name === 'get_my_tickets' || name === 'email_my_ticket') {
|
||||||
|
cards.push(...buildTicketQrCard(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'get_agenda' || name === 'get_my_agenda' || name === 'save_session' || name === 'remove_saved_session') {
|
||||||
|
const ids = sessionIdsFrom(data);
|
||||||
|
sessionIds.push(...ids);
|
||||||
|
if (Array.isArray(data.cards) && data.cards.length) {
|
||||||
|
cards.push(...data.cards);
|
||||||
|
} else if (ids.length) {
|
||||||
|
cards.push(...buildSessionCards(ids, { sessions: data.sessions || [] }, {
|
||||||
|
includeResources: Boolean(data.includeResources),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'get_speakers') {
|
||||||
|
const ids = resolveSpeakerIdsFromModel([], data);
|
||||||
|
cards.push(...buildSpeakerCards(ids, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'get_exhibitors') {
|
||||||
|
const ids = resolveExhibitorIdsFromModel([], data);
|
||||||
|
cards.push(...buildExhibitorCards(ids, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'get_venue') {
|
||||||
|
const mapCard = buildMapFileCard(data.mapMedia);
|
||||||
|
if (mapCard) cards.push(mapCard);
|
||||||
|
if (Array.isArray(data.places) && data.places.length) {
|
||||||
|
const ids = resolvePlaceIds([], data);
|
||||||
|
cards.push(...buildPlaceCards(ids, data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'get_local') {
|
||||||
|
if (Array.isArray(data.places) && data.places.length) {
|
||||||
|
const ids = resolvePlaceIds([], data);
|
||||||
|
cards.push(...buildPlaceCards(ids, data));
|
||||||
|
}
|
||||||
|
if (Array.isArray(data.hotels) && data.hotels.length) {
|
||||||
|
const ids = resolveHotelIds([], data);
|
||||||
|
cards.push(...buildAccommodationCards(ids, data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'search_faqs' && data.registration?.registrationLinks) {
|
||||||
|
cards.push(...buildRegistrationCtaCards(data.registration.registrationLinks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
sessionIds: [...new Set(sessionIds)].slice(0, 8),
|
||||||
|
faqKeysUsed: [...new Set(faqKeysUsed)],
|
||||||
|
forceOrganizerContact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { cardsFromToolResults };
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
const MAX_STRING = 800;
|
||||||
|
const MAX_ARRAY = 25;
|
||||||
|
|
||||||
|
function compactValue(value, depth = 0) {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const s = value.trim();
|
||||||
|
if (!s) return undefined;
|
||||||
|
return s.length > MAX_STRING ? `${s.slice(0, MAX_STRING)}…` : s;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
||||||
|
if (depth > 6) return undefined;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const next = value
|
||||||
|
.slice(0, MAX_ARRAY)
|
||||||
|
.map((item) => compactValue(item, depth + 1))
|
||||||
|
.filter((item) => item !== undefined);
|
||||||
|
return next.length ? next : undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const out = {};
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
if (key.startsWith('_')) continue;
|
||||||
|
const compact = compactValue(val, depth + 1);
|
||||||
|
if (compact !== undefined) out[key] = compact;
|
||||||
|
}
|
||||||
|
return Object.keys(out).length ? out : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactForLlm(data) {
|
||||||
|
const compact = compactValue(data);
|
||||||
|
return compact && typeof compact === 'object' ? compact : { result: compact ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { compactForLlm };
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
const { faqAnswerText } = require('../utils/faqAnswer');
|
||||||
|
const { getTimeZone, formatDateTimeLocal } = require('../utils/eventTime');
|
||||||
|
const { loadEventRegistrationLinks } = require('../utils/registrationLinks');
|
||||||
|
const Event = require('../models/Event');
|
||||||
|
const Session = require('../models/Session');
|
||||||
|
|
||||||
|
const FAQ_CAP = 24;
|
||||||
|
const ANSWER_MAX = 600;
|
||||||
|
const SESSION_CAP = 50;
|
||||||
|
const DESC_MAX = 800;
|
||||||
|
const PUBLIC_EVENT_KEYS = [
|
||||||
|
'name',
|
||||||
|
'location',
|
||||||
|
'locationType',
|
||||||
|
'description',
|
||||||
|
'about',
|
||||||
|
'summary',
|
||||||
|
'shortDescription',
|
||||||
|
'city',
|
||||||
|
'venue',
|
||||||
|
'eventType',
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickEventDate(event, keys) {
|
||||||
|
if (!event || typeof event !== 'object') return null;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (event[key]) return event[key];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimText(value, max = DESC_MAX) {
|
||||||
|
const s = String(value || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
return s.length > max ? `${s.slice(0, max)}…` : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPublicEventFields(event) {
|
||||||
|
const out = {};
|
||||||
|
if (!event || typeof event !== 'object') return out;
|
||||||
|
for (const key of PUBLIC_EVENT_KEYS) {
|
||||||
|
const v = trimText(event[key], key === 'name' || key === 'location' ? 200 : DESC_MAX);
|
||||||
|
if (v) out[key] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function faqSnapshot(bot) {
|
||||||
|
const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
|
||||||
|
const out = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const a = trimText(faqAnswerText(row), ANSWER_MAX);
|
||||||
|
if (!a) continue;
|
||||||
|
const q = String(row?.question || '').trim();
|
||||||
|
out.push({
|
||||||
|
q: q || String(row?.category || 'FAQ').trim(),
|
||||||
|
a,
|
||||||
|
category: String(row?.category || '').trim() || null,
|
||||||
|
});
|
||||||
|
if (out.length >= FAQ_CAP) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function speakerNames(doc) {
|
||||||
|
const rows = Array.isArray(doc?.speakers) ? doc.speakers : [];
|
||||||
|
return rows
|
||||||
|
.map((sp) => {
|
||||||
|
if (!sp || typeof sp !== 'object') return '';
|
||||||
|
return [sp.firstName, sp.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProgrammeSnapshot(eventId) {
|
||||||
|
if (!eventId) return [];
|
||||||
|
try {
|
||||||
|
const rows = await Session.find({
|
||||||
|
event: eventId,
|
||||||
|
status: { $ne: 'cancelled' },
|
||||||
|
})
|
||||||
|
.populate({ path: 'speakers', select: 'firstName lastName' })
|
||||||
|
.sort({ startTime: 1 })
|
||||||
|
.limit(SESSION_CAP)
|
||||||
|
.lean();
|
||||||
|
return rows
|
||||||
|
.map((doc) => {
|
||||||
|
const title = String(doc.title || '').trim();
|
||||||
|
if (!title) return null;
|
||||||
|
const speakers = speakerNames(doc);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
start: formatDateTimeLocal(doc.startTime) || null,
|
||||||
|
end: formatDateTimeLocal(doc.endTime) || null,
|
||||||
|
...(speakers.length ? { speakers } : {}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[eventBrief] programme snapshot failed:', err?.message || err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateEvent(event, eventId) {
|
||||||
|
const id = eventId || event?._id;
|
||||||
|
if (!id) return event || {};
|
||||||
|
try {
|
||||||
|
const doc = await Event.findById(id).lean();
|
||||||
|
if (!doc) return event || {};
|
||||||
|
return { ...(event || {}), ...doc };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[eventBrief] event hydrate failed:', err?.message || err);
|
||||||
|
return event || {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public event facts for the system prompt (cached with the instructions).
|
||||||
|
* No tickets, QR, or personal agenda.
|
||||||
|
*/
|
||||||
|
async function buildEventBrief({ event, bot, eventId } = {}) {
|
||||||
|
const resolvedId = eventId || event?._id;
|
||||||
|
const fullEvent = await hydrateEvent(event, resolvedId);
|
||||||
|
const startRaw = pickEventDate(fullEvent, ['startDate', 'start', 'dateFrom', 'startsAt', 'eventStart']);
|
||||||
|
const endRaw = pickEventDate(fullEvent, ['endDate', 'end', 'dateTo', 'endsAt', 'eventEnd']);
|
||||||
|
const now = new Date();
|
||||||
|
let registrationLinks = [];
|
||||||
|
try {
|
||||||
|
const resolved = await loadEventRegistrationLinks(resolvedId);
|
||||||
|
registrationLinks = (resolved.links || []).slice(0, 8).map((l) => ({
|
||||||
|
label: l.label,
|
||||||
|
url: l.url,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[eventBrief] registration links failed:', err?.message || err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const programme = await loadProgrammeSnapshot(resolvedId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pickPublicEventFields(fullEvent),
|
||||||
|
name: String(fullEvent?.name || event?.name || '').trim() || null,
|
||||||
|
location: String(fullEvent?.location || event?.location || '').trim() || null,
|
||||||
|
locationType: String(fullEvent?.locationType || event?.locationType || '').trim() || null,
|
||||||
|
start: formatDateTimeLocal(startRaw) || null,
|
||||||
|
end: formatDateTimeLocal(endRaw) || null,
|
||||||
|
timeZone: getTimeZone(),
|
||||||
|
nowLocal: formatDateTimeLocal(now),
|
||||||
|
registrationLinks,
|
||||||
|
faqs: faqSnapshot(bot),
|
||||||
|
programme,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function staticEventBrief(brief) {
|
||||||
|
if (!brief || typeof brief !== 'object') return {};
|
||||||
|
const { nowLocal, ...rest } = brief;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCachedEventBlock(brief) {
|
||||||
|
return `<EVENT>\n${JSON.stringify(staticEventBrief(brief), null, 2)}\n</EVENT>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventBriefMessage(brief, userMessage) {
|
||||||
|
return [
|
||||||
|
`NOW (event timezone): ${brief?.nowLocal || ''}`,
|
||||||
|
'',
|
||||||
|
'CURRENT USER MESSAGE:',
|
||||||
|
String(userMessage || '').trim(),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildEventBrief,
|
||||||
|
formatEventBriefMessage,
|
||||||
|
formatCachedEventBlock,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
const URL_RE = /\bhttps?:\/\/[^\s)\]>'"]+|\bwww\.[^\s)\]>'"]+/gi;
|
||||||
|
|
||||||
|
function collectUrlsFromValue(value, into = []) {
|
||||||
|
if (value == null) return into;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (/^https?:\/\//i.test(value.trim()) || /^www\./i.test(value.trim())) {
|
||||||
|
into.push(value.trim());
|
||||||
|
}
|
||||||
|
return into;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) collectUrlsFromValue(item, into);
|
||||||
|
return into;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
if (/(^url$|href|website|link)/i.test(key) && typeof val === 'string' && val.trim()) {
|
||||||
|
into.push(val.trim());
|
||||||
|
}
|
||||||
|
collectUrlsFromValue(val, into);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return into;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostOf(raw) {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
const withProto = /^https?:\/\//i.test(s) ? s : `https://${s.replace(/^\/\//, '')}`;
|
||||||
|
try {
|
||||||
|
return new URL(withProto).hostname.replace(/^www\./i, '').toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedHostsFrom(sources) {
|
||||||
|
const hosts = new Set();
|
||||||
|
for (const src of sources) {
|
||||||
|
for (const u of collectUrlsFromValue(src)) {
|
||||||
|
const host = hostOf(u);
|
||||||
|
if (host) hosts.add(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMetaPreamble(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(
|
||||||
|
/^(based on (the )?(event details|information|data|faqs?|details) (provided|above|given)[,:]?\s*)/i,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
.replace(/\s*let me know if you need[\s\S]*$/i, '')
|
||||||
|
.replace(/\s*i('m| am) happy to (provide|help)[\s\S]*$/i, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripUnknownUrls(text, allowedHosts) {
|
||||||
|
return String(text || '').replace(URL_RE, (match) => {
|
||||||
|
const host = hostOf(match.replace(/[.,;:]+$/, ''));
|
||||||
|
if (host && allowedHosts.has(host)) return match;
|
||||||
|
return '';
|
||||||
|
}).replace(/[ \t]{2,}/g, ' ').replace(/ +\n/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function groundReply(text, { eventBrief, toolResults } = {}) {
|
||||||
|
const allowedHosts = allowedHostsFrom([
|
||||||
|
eventBrief,
|
||||||
|
...(Array.isArray(toolResults) ? toolResults.map((r) => r?.data) : []),
|
||||||
|
]);
|
||||||
|
return stripUnknownUrls(stripMetaPreamble(text), allowedHosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { groundReply, allowedHostsFrom };
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
const { formatCachedEventBlock } = require('./eventBrief');
|
||||||
|
|
||||||
|
function buildAgentSystemPrompt({ channel, eventBrief } = {}) {
|
||||||
|
const spoken =
|
||||||
|
channel === 'voice'
|
||||||
|
? `
|
||||||
|
Spoken:
|
||||||
|
- Keep answers under 3 short sentences.
|
||||||
|
- No markdown, bullets, tables, or raw URLs. Spell out links only if essential.
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const eventBlock = formatCachedEventBlock(eventBrief);
|
||||||
|
|
||||||
|
return `
|
||||||
|
You are the virtual concierge for this event. Your objective is to assist participants with logistics, the programme, and their registration.
|
||||||
|
|
||||||
|
The system provides an EVENT block containing the name, venue, dates, timezone, registrationLinks, FAQ snapshot, and programme snapshot. The current time (NOW) is in the user message.
|
||||||
|
|
||||||
|
<Output_Format_and_Tone>
|
||||||
|
- **Immediate Answers:** Answer the question directly. No greetings, no self-introductions, no role statements (e.g., never say you are a concierge, Eventbuzz, a virtual assistant, or an AI).
|
||||||
|
- **Concise & Conversational:** Keep replies to 1–3 short sentences. Use bullet points only if absolutely necessary for readability. Do not end with filler like "Let me know if you need more help."
|
||||||
|
- **Prohibited Phrases:** NEVER use: "Based on the information provided", "Based on the event details", "According to the data", or "Dear attendee".
|
||||||
|
- **Contextual Referencing:** Do not repeat the event name unnecessarily. Use "nearby", "the venue", "here", or the host city. Name the event only if directly asked.
|
||||||
|
</Output_Format_and_Tone>
|
||||||
|
|
||||||
|
<Execution_Strategy>
|
||||||
|
Step 1: Evaluate the EVENT block. If the answer is present (e.g., dates, venue address, FAQ snapshot, or determining if the event is for doctors/students), answer directly. DO NOT call a tool to restate information already in the EVENT block.
|
||||||
|
Step 2: If the user wants to register, use EVENT.registrationLinks. If empty, state you lack a registration link and share the organizer contact from EVENT.faqs. Do not confuse registration with check-in.
|
||||||
|
Step 3: Only if the EVENT block cannot satisfy the request, invoke the appropriate tool.
|
||||||
|
</Execution_Strategy>
|
||||||
|
|
||||||
|
<Tool_Routing_Guide>
|
||||||
|
[Personalized Data] (Automatically bound to the user's session; NEVER ask for a user ID)
|
||||||
|
- get_my_tickets: For ticket tier, status, payment, check-in, or QR code.
|
||||||
|
- get_my_agenda: For saved/bookmarked sessions and schedule conflicts.
|
||||||
|
- save_session / remove_saved_session: To modify the user's personal agenda.
|
||||||
|
- email_my_ticket: To email the QR code to the registered address.
|
||||||
|
|
||||||
|
[Event & Logistics]
|
||||||
|
- get_agenda: For the live programme (now, next, specific date/track, materials).
|
||||||
|
- get_speakers: For the lineup or a specific speaker's bio.
|
||||||
|
- get_exhibitors: For booth locations, categories, and demos. (Refuse meeting bookings).
|
||||||
|
- get_venue: For maps, halls, amenities, parking, travel, meals, or accessibility (if not in EVENT.faqs).
|
||||||
|
- get_local: For hotels and host-city food/day-plans (strictly limited to this event's city).
|
||||||
|
- get_tech_help: For Wi-Fi or streaming help (if not in EVENT.faqs).
|
||||||
|
- search_faqs: For niche policies not covered in the EVENT FAQ snapshot.
|
||||||
|
- contact_organizer: For human escalation, lost-and-found, or medical/safety. (Provide zero medical advice).
|
||||||
|
</Tool_Routing_Guide>
|
||||||
|
|
||||||
|
<Strict_Guardrails>
|
||||||
|
1. **Zero Hallucination:** Use ONLY facts from the EVENT block or tool JSON. If a fact is missing, state that you do not have that information and suggest the on-site help desk.
|
||||||
|
2. **No Invention:** NEVER invent websites, URLs, domain names, click-paths, rooms, times, ticket numbers, emails, phone numbers, or Wi-Fi passwords.
|
||||||
|
3. **URL Policy:** Quote a URL only if it exactly matches a string in EVENT.registrationLinks or tool JSON. If there is no URL, state you do not have one.
|
||||||
|
4. **Unauthorized Actions:** Ticket transfers and upgrades cannot be done in chat. Refuse the request and offer get_my_tickets or contact_organizer.
|
||||||
|
5. **Authentication Failures:** If a personal tool returns an "unauthenticated" status, do not invent ticket data.
|
||||||
|
6. **Invisible Tools:** Tool JSON is for your internal context only. Answer as if you inherently knew the information. NEVER mention tools, JSON, the EVENT block, or how you retrieved the data.
|
||||||
|
</Strict_Guardrails>
|
||||||
|
${spoken}
|
||||||
|
|
||||||
|
${eventBlock}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildAgentSystemPrompt };
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
const {
|
||||||
|
createClient,
|
||||||
|
converse,
|
||||||
|
responseText,
|
||||||
|
toolUseBlocks,
|
||||||
|
assistantMessageFromResponse,
|
||||||
|
getRegion,
|
||||||
|
getAgentModelId,
|
||||||
|
} = require('../utils/bedrock');
|
||||||
|
const { isVerified } = require('../services/identity.service');
|
||||||
|
const { buildAgentSystemPrompt } = require('./prompts');
|
||||||
|
const { conciergeTools, createToolExecutors } = require('./tools');
|
||||||
|
const { compactForLlm } = require('./compact');
|
||||||
|
const { cardsFromToolResults } = require('./cardsFromTools');
|
||||||
|
const { buildEventBrief, formatEventBriefMessage } = require('./eventBrief');
|
||||||
|
const { groundReply } = require('./groundReply');
|
||||||
|
const { looksLikeHowToRegister, howToRegisterFromBot } = require('../utils/registerIntent');
|
||||||
|
const { buildRegistrationCtaCards } = require('../utils/registrationLinks');
|
||||||
|
|
||||||
|
const HISTORY_TURNS = 8;
|
||||||
|
const FALLBACK_REPLY = 'Sorry, I could not respond just now. Please try again in a moment.';
|
||||||
|
const FIRST_INFERENCE = { temperature: 0.2, topP: 0.8, maxTokens: 350 };
|
||||||
|
const SYNTH_INFERENCE = { temperature: 0.2, topP: 0.8, maxTokens: 350 };
|
||||||
|
|
||||||
|
function sumUsage(a, b) {
|
||||||
|
return {
|
||||||
|
inputTokens: Number(a?.inputTokens || 0) + Number(b?.inputTokens || 0),
|
||||||
|
outputTokens: Number(a?.outputTokens || 0) + Number(b?.outputTokens || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageOf(response) {
|
||||||
|
return {
|
||||||
|
inputTokens: Number(response?.usage?.inputTokens || 0),
|
||||||
|
outputTokens: Number(response?.usage?.outputTokens || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHistory(history) {
|
||||||
|
return (Array.isArray(history) ? history : [])
|
||||||
|
.filter((m) => m && (m.role === 'user' || m.role === 'assistant') && String(m.text || '').trim())
|
||||||
|
.slice(-HISTORY_TURNS)
|
||||||
|
.map((m) => ({
|
||||||
|
role: m.role === 'assistant' ? 'assistant' : 'user',
|
||||||
|
content: [{ text: String(m.text).trim() }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyResult(overrides = {}) {
|
||||||
|
return {
|
||||||
|
reply: FALLBACK_REPLY,
|
||||||
|
cards: [],
|
||||||
|
sessionIds: [],
|
||||||
|
faqKeysUsed: [],
|
||||||
|
toolNames: [],
|
||||||
|
unauthenticated: false,
|
||||||
|
forceOrganizerContact: false,
|
||||||
|
modelId: null,
|
||||||
|
usage: { inputTokens: 0, outputTokens: 0 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseToolInput(input) {
|
||||||
|
if (input && typeof input === 'object' && !Array.isArray(input)) return input;
|
||||||
|
if (typeof input === 'string' && input.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(input);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeToolUses(blocks, executors) {
|
||||||
|
const rows = await Promise.all(
|
||||||
|
blocks.map(async (block) => {
|
||||||
|
const { toolUseId, name, input } = block.toolUse;
|
||||||
|
const fn = executors[name];
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
if (!fn) {
|
||||||
|
data = { error: `Unknown tool: ${name}` };
|
||||||
|
} else {
|
||||||
|
data = await fn(parseToolInput(input));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[agent][tool] failed', name, err?.message || err);
|
||||||
|
data = { error: err?.message || 'tool_failed' };
|
||||||
|
}
|
||||||
|
let text = JSON.stringify(compactForLlm(data));
|
||||||
|
if (text.length > 12000) text = `${text.slice(0, 12000)}…`;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
content: {
|
||||||
|
toolResult: {
|
||||||
|
toolUseId,
|
||||||
|
content: [{ text }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolResults: rows.map((r) => ({ name: r.name, data: r.data })),
|
||||||
|
content: rows.map((r) => r.content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One concierge turn: optional tool calls, then a natural-language reply.
|
||||||
|
*/
|
||||||
|
async function runConciergeTurn({ bot, event, session, message, history, channel = 'text' }) {
|
||||||
|
const eventName = event?.name || 'the event';
|
||||||
|
const ctx = {
|
||||||
|
userId: isVerified(session) ? session.participant : null,
|
||||||
|
eventId: bot?.event || session?.eventId,
|
||||||
|
eventName,
|
||||||
|
bot,
|
||||||
|
event,
|
||||||
|
session,
|
||||||
|
message,
|
||||||
|
channel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modelId = getAgentModelId();
|
||||||
|
const client = createClient(getRegion());
|
||||||
|
const eventBrief = await buildEventBrief({ event, bot, eventId: ctx.eventId });
|
||||||
|
const system = buildAgentSystemPrompt({ channel, eventBrief });
|
||||||
|
|
||||||
|
if (looksLikeHowToRegister(message)) {
|
||||||
|
const links = Array.isArray(eventBrief.registrationLinks) ? eventBrief.registrationLinks : [];
|
||||||
|
return emptyResult({
|
||||||
|
reply: howToRegisterFromBot(bot, links),
|
||||||
|
cards: buildRegistrationCtaCards(links),
|
||||||
|
modelId,
|
||||||
|
toolNames: links.length ? ['search_faqs'] : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
...formatHistory(history),
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [{ text: formatEventBriefMessage(eventBrief, message) }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let usage = { inputTokens: 0, outputTokens: 0 };
|
||||||
|
let first;
|
||||||
|
try {
|
||||||
|
first = await converse(client, modelId, {
|
||||||
|
system,
|
||||||
|
messages,
|
||||||
|
inferenceConfig: FIRST_INFERENCE,
|
||||||
|
toolConfig: { tools: conciergeTools },
|
||||||
|
toolChoice: { auto: {} },
|
||||||
|
logLabel: 'agent-first',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[agent][first] failed:', err?.message || err);
|
||||||
|
return emptyResult({ modelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
usage = sumUsage(usage, usageOf(first));
|
||||||
|
const firstText = String(responseText(first) || '').trim();
|
||||||
|
const uses = toolUseBlocks(first);
|
||||||
|
const assistantMsg = assistantMessageFromResponse(first);
|
||||||
|
|
||||||
|
if (!uses.length) {
|
||||||
|
const reply = groundReply(firstText, { eventBrief }) || FALLBACK_REPLY;
|
||||||
|
return emptyResult({
|
||||||
|
reply,
|
||||||
|
modelId,
|
||||||
|
usage,
|
||||||
|
toolNames: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const executors = createToolExecutors(ctx);
|
||||||
|
const { toolResults, content: toolResultContent } = await executeToolUses(uses, executors);
|
||||||
|
const toolNames = toolResults.map((r) => r.name);
|
||||||
|
const unauthenticated = toolResults.some((r) => r.data?.status === 'unauthenticated');
|
||||||
|
const extras = cardsFromToolResults(toolResults);
|
||||||
|
|
||||||
|
if (unauthenticated) {
|
||||||
|
return {
|
||||||
|
reply: null,
|
||||||
|
cards: extras.cards,
|
||||||
|
sessionIds: extras.sessionIds,
|
||||||
|
faqKeysUsed: extras.faqKeysUsed,
|
||||||
|
toolNames,
|
||||||
|
unauthenticated: true,
|
||||||
|
forceOrganizerContact: extras.forceOrganizerContact,
|
||||||
|
modelId,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assistantMsg) {
|
||||||
|
return emptyResult({
|
||||||
|
reply: groundReply(firstText, { eventBrief, toolResults }) || FALLBACK_REPLY,
|
||||||
|
modelId,
|
||||||
|
usage,
|
||||||
|
toolNames,
|
||||||
|
cards: extras.cards,
|
||||||
|
sessionIds: extras.sessionIds,
|
||||||
|
faqKeysUsed: extras.faqKeysUsed,
|
||||||
|
forceOrganizerContact: extras.forceOrganizerContact,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const followup = [...messages, assistantMsg, { role: 'user', content: toolResultContent }];
|
||||||
|
|
||||||
|
let second;
|
||||||
|
try {
|
||||||
|
second = await converse(client, modelId, {
|
||||||
|
system,
|
||||||
|
messages: followup,
|
||||||
|
inferenceConfig: SYNTH_INFERENCE,
|
||||||
|
toolConfig: { tools: conciergeTools },
|
||||||
|
toolChoice: { auto: {} },
|
||||||
|
logLabel: 'agent-synth',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[agent][synth] failed:', err?.message || err);
|
||||||
|
return emptyResult({
|
||||||
|
reply: groundReply(firstText, { eventBrief, toolResults }) || FALLBACK_REPLY,
|
||||||
|
modelId,
|
||||||
|
usage,
|
||||||
|
toolNames,
|
||||||
|
cards: extras.cards,
|
||||||
|
sessionIds: extras.sessionIds,
|
||||||
|
faqKeysUsed: extras.faqKeysUsed,
|
||||||
|
forceOrganizerContact: extras.forceOrganizerContact,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
usage = sumUsage(usage, usageOf(second));
|
||||||
|
const reply =
|
||||||
|
groundReply(String(responseText(second) || '').trim() || firstText, {
|
||||||
|
eventBrief,
|
||||||
|
toolResults,
|
||||||
|
}) || FALLBACK_REPLY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reply,
|
||||||
|
cards: extras.cards,
|
||||||
|
sessionIds: extras.sessionIds,
|
||||||
|
faqKeysUsed: extras.faqKeysUsed,
|
||||||
|
toolNames,
|
||||||
|
unauthenticated: false,
|
||||||
|
forceOrganizerContact: extras.forceOrganizerContact,
|
||||||
|
modelId,
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runConciergeTurn };
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
const { addBookmark, removeBookmark } = require('../../services/bookmark.service');
|
||||||
|
const { fetchSystemData } = require('../../services/systemData.service');
|
||||||
|
|
||||||
|
function unauthenticated() {
|
||||||
|
return { status: 'unauthenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonToIntent(reason) {
|
||||||
|
const r = String(reason || 'help').toLowerCase();
|
||||||
|
if (r === 'emergency') return 'emergency_medical';
|
||||||
|
if (r === 'lost_and_found') return 'lost_and_found';
|
||||||
|
return 'human_agent_escalation';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionExecutors(ctx) {
|
||||||
|
const fallbackSessionIds = Array.isArray(ctx.session?.lastSessionIds)
|
||||||
|
? ctx.session.lastSessionIds
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
async save_session(args = {}) {
|
||||||
|
if (!ctx.userId) return unauthenticated();
|
||||||
|
const result = await addBookmark({
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
participantId: ctx.userId,
|
||||||
|
entities: { session_name: args.query || null },
|
||||||
|
fallbackSessionIds,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: result.ok,
|
||||||
|
already: result.already || false,
|
||||||
|
note: result.reply,
|
||||||
|
sessionIds: result.sessionIds || [],
|
||||||
|
cards: result.cards || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove_saved_session(args = {}) {
|
||||||
|
if (!ctx.userId) return unauthenticated();
|
||||||
|
const result = await removeBookmark({
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
participantId: ctx.userId,
|
||||||
|
entities: { session_name: args.query || null },
|
||||||
|
fallbackSessionIds,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: result.ok,
|
||||||
|
note: result.reply,
|
||||||
|
sessionIds: result.sessionIds || [],
|
||||||
|
cards: result.cards || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async contact_organizer(args = {}) {
|
||||||
|
const intent = reasonToIntent(args.reason);
|
||||||
|
return fetchSystemData({
|
||||||
|
intent,
|
||||||
|
bot: ctx.bot,
|
||||||
|
event: ctx.event,
|
||||||
|
session: ctx.session,
|
||||||
|
message: ctx.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createActionExecutors,
|
||||||
|
AUTH_ACTION_TOOLS: ['save_session', 'remove_saved_session'],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
const { createPersonalExecutors, AUTH_PERSONAL_TOOLS } = require('./personal.tools');
|
||||||
|
const { createProgrammeExecutors } = require('./programme.tools');
|
||||||
|
const { createOnsiteExecutors } = require('./onsite.tools');
|
||||||
|
const { createActionExecutors, AUTH_ACTION_TOOLS } = require('./actions.tools');
|
||||||
|
|
||||||
|
const AUTH_REQUIRED_TOOLS = new Set([...AUTH_PERSONAL_TOOLS, ...AUTH_ACTION_TOOLS]);
|
||||||
|
|
||||||
|
function toolSpec(name, description, properties = {}, required = []) {
|
||||||
|
return {
|
||||||
|
toolSpec: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
inputSchema: {
|
||||||
|
json: {
|
||||||
|
type: 'object',
|
||||||
|
properties,
|
||||||
|
required,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const conciergeTools = [
|
||||||
|
toolSpec(
|
||||||
|
'get_my_tickets',
|
||||||
|
"This attendee's ticket tier, registration status, payment, check-in, and QR. No arguments. Never invent another person's booking.",
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'get_my_agenda',
|
||||||
|
"This attendee's saved/bookmarked sessions and any time conflicts. No arguments.",
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'email_my_ticket',
|
||||||
|
'Email the attendee their ticket QR using the registered email. No arguments.',
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'get_agenda',
|
||||||
|
'Event programme: sessions happening now, next, on a date, by track, speaker, or session name. Include materials when they ask for slides.',
|
||||||
|
{
|
||||||
|
when: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'now | next | date, or omit for a general lookup',
|
||||||
|
},
|
||||||
|
date: { type: 'string', description: 'YYYY-MM-DD if they named a day' },
|
||||||
|
track: { type: 'string', description: 'Track or stage name' },
|
||||||
|
speaker: { type: 'string', description: 'Speaker name when asking when they speak' },
|
||||||
|
topic: { type: 'string', description: 'Topic or keyword' },
|
||||||
|
session_name: { type: 'string', description: 'Specific session title' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'get_speakers',
|
||||||
|
'Speaker lineup or a named speaker bio / public socials / meet-and-greet.',
|
||||||
|
{
|
||||||
|
name: { type: 'string', description: 'Speaker name; omit to list the lineup' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'get_exhibitors',
|
||||||
|
'Exhibitor booth, category list, or demo notes. Meeting booking is not available in chat.',
|
||||||
|
{
|
||||||
|
company: { type: 'string', description: 'Exhibitor / company name' },
|
||||||
|
category: { type: 'string', description: 'Industry or category' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'get_venue',
|
||||||
|
'Venue map, halls, amenities, parking/shuttle, travel from a city, meals policy, accessibility.',
|
||||||
|
{
|
||||||
|
query: { type: 'string', description: 'What they asked about the venue' },
|
||||||
|
origin: { type: 'string', description: 'Origin city when asking how to reach the venue' },
|
||||||
|
},
|
||||||
|
['query'],
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'get_local',
|
||||||
|
'Hotels and nearby tourist places cached for this event city.',
|
||||||
|
{
|
||||||
|
kind: { type: 'string', description: 'hotels | places | auto' },
|
||||||
|
query: { type: 'string', description: 'What they asked (e.g. temples, nearby stay)' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'get_tech_help',
|
||||||
|
'Wi-Fi network/password from FAQs, streaming/AV tips, or app login help. Never invent credentials.',
|
||||||
|
{
|
||||||
|
query: { type: 'string', description: 'Wi-Fi, streaming, login, or app feature' },
|
||||||
|
},
|
||||||
|
['query'],
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'search_faqs',
|
||||||
|
'Event policy FAQs: dress code, refunds, how to register, lost-and-found desk, leftover logistics not covered by other tools.',
|
||||||
|
{
|
||||||
|
query: { type: 'string', description: 'The policy or logistics question' },
|
||||||
|
},
|
||||||
|
['query'],
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'save_session',
|
||||||
|
'Save a programme session to the attendee personal agenda.',
|
||||||
|
{
|
||||||
|
query: { type: 'string', description: 'Session title if they named one; omit to use the last shown sessions' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'remove_saved_session',
|
||||||
|
'Remove a saved session from the attendee personal agenda.',
|
||||||
|
{
|
||||||
|
query: { type: 'string', description: 'Session title if they named one; omit to use the last shown sessions' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
toolSpec(
|
||||||
|
'contact_organizer',
|
||||||
|
'Notify organizers / share public organizer contact. Use for talk-to-human, lost-and-found, or medical/safety emergencies. No medical advice.',
|
||||||
|
{
|
||||||
|
reason: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'help | lost_and_found | emergency',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
function createToolExecutors(ctx) {
|
||||||
|
return {
|
||||||
|
...createPersonalExecutors(ctx),
|
||||||
|
...createProgrammeExecutors(ctx),
|
||||||
|
...createOnsiteExecutors(ctx),
|
||||||
|
...createActionExecutors(ctx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
conciergeTools,
|
||||||
|
createToolExecutors,
|
||||||
|
AUTH_REQUIRED_TOOLS,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
const { fetchVenueData } = require('../../services/venueData.service');
|
||||||
|
const { fetchLocalData } = require('../../services/localData.service');
|
||||||
|
const { fetchTechData } = require('../../services/techData.service');
|
||||||
|
const { fetchTicketData } = require('../../services/ticketData.service');
|
||||||
|
const { searchFaqs, faqKeysUsed } = require('../../rag/retriever');
|
||||||
|
const { looksLikeHowToRegister } = require('../../utils/registerIntent');
|
||||||
|
|
||||||
|
function norm(text) {
|
||||||
|
return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function venueIntentFromQuery(query, origin) {
|
||||||
|
const t = norm(query);
|
||||||
|
if (/\b(map|floor plan|layout)\b/.test(t)) return 'venue_map_request';
|
||||||
|
if (/\b(restroom|washroom|toilet|water|charging|info desk)\b/.test(t)) {
|
||||||
|
return 'venue_nearest_amenity';
|
||||||
|
}
|
||||||
|
if (/\baccessib|wheelchair\b/.test(t)) return 'venue_accessibility';
|
||||||
|
if (
|
||||||
|
origin
|
||||||
|
|| /\b(travell?ing|traveling|coming|arriving)\s+from\b/.test(t)
|
||||||
|
|| /\bhow (do|can|to) (i |we )?(get to|reach|travel to|come to)\b/.test(t)
|
||||||
|
|| /\b(flights?|trains?)\s+(from|to)\b/.test(t)
|
||||||
|
) {
|
||||||
|
return 'venue_travel_directions';
|
||||||
|
}
|
||||||
|
if (/\b(parking|taxi|uber|ola|shuttle|metro near)\b/.test(t)) {
|
||||||
|
return 'venue_parking_transport';
|
||||||
|
}
|
||||||
|
if (/\b(food|lunch|meals?|tea|coffee|dietary)\b/.test(t)) {
|
||||||
|
return 'venue_food_beverage';
|
||||||
|
}
|
||||||
|
return 'venue_wayfinding';
|
||||||
|
}
|
||||||
|
|
||||||
|
function localIntentFromArgs(args = {}, userMessage = '') {
|
||||||
|
const kind = String(args.kind || 'auto').toLowerCase();
|
||||||
|
if (kind === 'hotels') return 'accommodation';
|
||||||
|
if (kind === 'places') return 'tourist_places';
|
||||||
|
const t = norm(`${args.query || ''} ${userMessage}`);
|
||||||
|
if (/\b(hotel|stay|lodging|accommodation|check-?in)\b/.test(t)) return 'accommodation';
|
||||||
|
return 'tourist_places';
|
||||||
|
}
|
||||||
|
|
||||||
|
function techIntentFromQuery(query) {
|
||||||
|
const t = norm(query);
|
||||||
|
if (/\bwifi|wi-fi|ssid|password\b/.test(t)) return 'wifi_credentials';
|
||||||
|
if (/\bstream|video|audio|can'?t hear|black screen\b/.test(t)) return 'app_issue_streaming';
|
||||||
|
if (/\blogin|otp|account|password reset\b/.test(t)) return 'app_issue_account';
|
||||||
|
return 'app_feature_guidance';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOnsiteExecutors(ctx) {
|
||||||
|
return {
|
||||||
|
async get_venue(args = {}) {
|
||||||
|
const query = String(args.query || ctx.message || '');
|
||||||
|
const origin = String(args.origin || '').trim() || null;
|
||||||
|
const intent = venueIntentFromQuery(query, origin);
|
||||||
|
return fetchVenueData({
|
||||||
|
intent,
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
bot: ctx.bot,
|
||||||
|
entities: { location: origin },
|
||||||
|
message: query,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async get_local(args = {}) {
|
||||||
|
const intent = localIntentFromArgs(args, ctx.message);
|
||||||
|
const query = String(args.query || ctx.message || '');
|
||||||
|
return fetchLocalData({
|
||||||
|
intent,
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
message: query,
|
||||||
|
bot: ctx.bot,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async get_tech_help(args = {}) {
|
||||||
|
const query = String(args.query || ctx.message || '');
|
||||||
|
return fetchTechData({
|
||||||
|
intent: techIntentFromQuery(query),
|
||||||
|
bot: ctx.bot,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async search_faqs(args = {}) {
|
||||||
|
const query = String(args.query || ctx.message || '').trim();
|
||||||
|
const hits = searchFaqs({
|
||||||
|
bot: ctx.bot,
|
||||||
|
query,
|
||||||
|
alreadyTold: ctx.session?.alreadyTold,
|
||||||
|
limit: 4,
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
hits,
|
||||||
|
faqKeysUsed: faqKeysUsed(hits),
|
||||||
|
};
|
||||||
|
if (looksLikeHowToRegister(query) || looksLikeHowToRegister(ctx.message)) {
|
||||||
|
payload.registration = await fetchTicketData({
|
||||||
|
intent: 'ticket_how_to_register',
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
eventName: ctx.eventName,
|
||||||
|
bot: ctx.bot,
|
||||||
|
event: ctx.event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createOnsiteExecutors };
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
const { fetchTicketData } = require('../../services/ticketData.service');
|
||||||
|
const { fetchAgendaData } = require('../../services/agendaData.service');
|
||||||
|
const { sendTicketPassEmail } = require('../../utils/mail');
|
||||||
|
|
||||||
|
const TICKET_EMAIL_COOLDOWN_MS = 60 * 1000;
|
||||||
|
|
||||||
|
function unauthenticated() {
|
||||||
|
return { status: 'unauthenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersonalExecutors(ctx) {
|
||||||
|
return {
|
||||||
|
async get_my_tickets() {
|
||||||
|
if (!ctx.userId) return unauthenticated();
|
||||||
|
return fetchTicketData({
|
||||||
|
intent: 'ticket_status_lookup',
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
participantId: ctx.userId,
|
||||||
|
eventName: ctx.eventName,
|
||||||
|
bot: ctx.bot,
|
||||||
|
event: ctx.event,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async get_my_agenda() {
|
||||||
|
if (!ctx.userId) return unauthenticated();
|
||||||
|
const personal = await fetchAgendaData({
|
||||||
|
intent: 'agenda_personal_schedule',
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
participantId: ctx.userId,
|
||||||
|
});
|
||||||
|
const conflicts = await fetchAgendaData({
|
||||||
|
intent: 'agenda_conflict_check',
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
participantId: ctx.userId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...personal,
|
||||||
|
conflicts: conflicts.conflicts || [],
|
||||||
|
conflictNote: conflicts.note || null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async email_my_ticket() {
|
||||||
|
if (!ctx.userId) return unauthenticated();
|
||||||
|
const data = await fetchTicketData({
|
||||||
|
intent: 'ticket_email_qr',
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
participantId: ctx.userId,
|
||||||
|
eventName: ctx.eventName,
|
||||||
|
bot: ctx.bot,
|
||||||
|
event: ctx.event,
|
||||||
|
});
|
||||||
|
const to = String(data?.attendee?.email || '').trim().toLowerCase();
|
||||||
|
if (!to) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
emailSent: false,
|
||||||
|
emailError: 'missing_email',
|
||||||
|
note: 'No registered email on file for this attendee.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = ctx.session?.lastTicketEmailAt
|
||||||
|
? new Date(ctx.session.lastTicketEmailAt).getTime()
|
||||||
|
: 0;
|
||||||
|
if (last && Date.now() - last < TICKET_EMAIL_COOLDOWN_MS) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
emailSent: false,
|
||||||
|
emailTo: to,
|
||||||
|
emailError: 'rate_limited',
|
||||||
|
note: 'A ticket email was sent recently for this chat session.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = data.payment;
|
||||||
|
const paymentLabel =
|
||||||
|
payment?.status && payment?.amount != null
|
||||||
|
? `${payment.status} (${payment.currency || 'INR'} ${payment.amount})`
|
||||||
|
: payment?.status || null;
|
||||||
|
|
||||||
|
const result = await sendTicketPassEmail({
|
||||||
|
to,
|
||||||
|
eventName: data.eventName,
|
||||||
|
attendeeName: data.attendee?.name,
|
||||||
|
ticketName: data.ticket?.name || data.receipt?.ticketName || null,
|
||||||
|
statusLabel: data.membership?.statusLabel || data.membership?.status || null,
|
||||||
|
paymentLabel,
|
||||||
|
qrImageUrl: data.qr?.imageUrl || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.ok && ctx.session) {
|
||||||
|
ctx.session.lastTicketEmailAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
emailSent: Boolean(result.ok),
|
||||||
|
emailTo: to,
|
||||||
|
emailError: result.ok ? null : result.error || 'send_failed',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createPersonalExecutors, AUTH_PERSONAL_TOOLS: ['get_my_tickets', 'get_my_agenda', 'email_my_ticket'] };
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
const { fetchAgendaData } = require('../../services/agendaData.service');
|
||||||
|
const { fetchSpeakerData } = require('../../services/speakerData.service');
|
||||||
|
const { fetchExhibitorData } = require('../../services/exhibitorData.service');
|
||||||
|
|
||||||
|
function norm(text) {
|
||||||
|
return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function agendaIntentFromArgs(args = {}, userMessage = '') {
|
||||||
|
const when = String(args.when || '').toLowerCase().trim();
|
||||||
|
const t = norm(`${args.query || ''} ${userMessage}`);
|
||||||
|
if (when === 'now' || /\b(now|currently|happening right now|live now)\b/.test(t)) {
|
||||||
|
return 'agenda_current_now';
|
||||||
|
}
|
||||||
|
if (when === 'next' || /\b(what'?s next|upcoming|coming up)\b/.test(t)) {
|
||||||
|
return 'agenda_upcoming_next';
|
||||||
|
}
|
||||||
|
if (args.track || /\btrack|stream\b/.test(t)) return 'agenda_by_track';
|
||||||
|
if (args.speaker) return 'agenda_by_speaker';
|
||||||
|
if (/\b(slides?|materials?|handouts?|pdfs?|download)\b/.test(t)) {
|
||||||
|
return 'agenda_download_materials';
|
||||||
|
}
|
||||||
|
return 'agenda_session_details';
|
||||||
|
}
|
||||||
|
|
||||||
|
function speakerIntentFromMessage(message = '') {
|
||||||
|
const t = norm(message);
|
||||||
|
if (/\b(meet|greeting|networking)\b/.test(t)) return 'speaker_meet_and_greet';
|
||||||
|
if (/\b(linkedin|twitter|website|social)\b/.test(t)) return 'speaker_contact_info';
|
||||||
|
return 'speaker_bio_lookup';
|
||||||
|
}
|
||||||
|
|
||||||
|
function exhibitorIntentFromArgs(args = {}, userMessage = '') {
|
||||||
|
const t = norm(`${args.query || ''} ${userMessage}`);
|
||||||
|
if (/\bbook|meeting|appointment\b/.test(t)) return 'exhibitor_book_meeting';
|
||||||
|
if (/\bdemo\b/.test(t)) return 'exhibitor_demo_schedule';
|
||||||
|
if (args.category && !args.company) return 'exhibitor_by_category';
|
||||||
|
return 'exhibitor_location';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgrammeExecutors(ctx) {
|
||||||
|
return {
|
||||||
|
async get_agenda(args = {}) {
|
||||||
|
const when = String(args.when || '').toLowerCase().trim();
|
||||||
|
const intent = agendaIntentFromArgs(args, ctx.message);
|
||||||
|
const entities = {
|
||||||
|
speaker_name: args.speaker || null,
|
||||||
|
session_name: args.session_name || null,
|
||||||
|
topic: args.track || args.topic || null,
|
||||||
|
time_reference:
|
||||||
|
args.date
|
||||||
|
|| (when && when !== 'now' && when !== 'next' && when !== 'date' ? args.when : null)
|
||||||
|
|| null,
|
||||||
|
};
|
||||||
|
return fetchAgendaData({
|
||||||
|
intent,
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
entities,
|
||||||
|
participantId: ctx.userId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async get_speakers(args = {}) {
|
||||||
|
return fetchSpeakerData({
|
||||||
|
intent: speakerIntentFromMessage(ctx.message),
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
entities: { speaker_name: args.name || null },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async get_exhibitors(args = {}) {
|
||||||
|
return fetchExhibitorData({
|
||||||
|
intent: exhibitorIntentFromArgs(args, ctx.message),
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
bot: ctx.bot,
|
||||||
|
entities: {
|
||||||
|
company_name: args.company || null,
|
||||||
|
topic: args.category || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createProgrammeExecutors };
|
||||||
26
src/app.js
26
src/app.js
|
|
@ -102,7 +102,7 @@ const createServer = async () => {
|
||||||
name: 'EventBuzz Concierge',
|
name: 'EventBuzz Concierge',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
stage: 'step1',
|
stage: 'agent',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -153,6 +153,30 @@ const createServer = async () => {
|
||||||
options: { auth: false },
|
options: { auth: false },
|
||||||
handler: async (request, h) => sendFile(h, 'embed-demo.html', 'text/html'),
|
handler: async (request, h) => sendFile(h, 'embed-demo.html', 'text/html'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/embed/avatar',
|
||||||
|
options: { auth: false },
|
||||||
|
handler: async (request, h) => sendFile(h, 'embed-avatar.html', 'text/html'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/embed/avatar-page.js',
|
||||||
|
options: { auth: false },
|
||||||
|
handler: async (request, h) => sendFile(h, 'embed/avatar-page.js', 'application/javascript'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/embed/simli-client.js',
|
||||||
|
options: { auth: false },
|
||||||
|
handler: async (request, h) => sendFile(h, 'embed/simli-client.js', 'application/javascript'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/embed/avatar-page.css',
|
||||||
|
options: { auth: false },
|
||||||
|
handler: async (request, h) => sendFile(h, 'embed/avatar-page.css', 'text/css'),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
server.route(publicConciergeRoutes);
|
server.route(publicConciergeRoutes);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const {
|
||||||
runPublicChatTurn,
|
runPublicChatTurn,
|
||||||
} = require('../utils/webChatTurn');
|
} = require('../utils/webChatTurn');
|
||||||
const { getLivekitConfig, createVisitorVoiceToken } = require('../utils/livekitConcierge');
|
const { getLivekitConfig, createVisitorVoiceToken } = require('../utils/livekitConcierge');
|
||||||
|
const { getSimliConfig, createSimliSessionToken } = require('../utils/simli');
|
||||||
|
|
||||||
const ipBuckets = new Map();
|
const ipBuckets = new Map();
|
||||||
const WINDOW_MS = 60_000;
|
const WINDOW_MS = 60_000;
|
||||||
|
|
@ -37,7 +38,9 @@ async function loadBotOr404(botId) {
|
||||||
}
|
}
|
||||||
const bot = await ConciergeBot.findById(botId).lean();
|
const bot = await ConciergeBot.findById(botId).lean();
|
||||||
if (!bot) return { err: error('NOT_FOUND', 'Chatbot not found') };
|
if (!bot) return { err: error('NOT_FOUND', 'Chatbot not found') };
|
||||||
if (!bot.enabled || bot.status !== 'ACTIVE') {
|
// Dashboard "Active" is `enabled`. `status: ARCHIVED` is a knowledge-purge
|
||||||
|
// marker for ended events and must not block an enabled widget.
|
||||||
|
if (!bot.enabled) {
|
||||||
return { err: error('FORBIDDEN', 'Chatbot is not enabled') };
|
return { err: error('FORBIDDEN', 'Chatbot is not enabled') };
|
||||||
}
|
}
|
||||||
const event = await Event.findById(bot.event)
|
const event = await Event.findById(bot.event)
|
||||||
|
|
@ -66,6 +69,16 @@ const config = async (request, h) => {
|
||||||
quickReplies: DEFAULT_QUICK_REPLIES,
|
quickReplies: DEFAULT_QUICK_REPLIES,
|
||||||
maxMessageLength: MAX_MESSAGE_LENGTH,
|
maxMessageLength: MAX_MESSAGE_LENGTH,
|
||||||
voiceEnabled: Boolean(bot.voiceEnabled) && getLivekitConfig().configured,
|
voiceEnabled: Boolean(bot.voiceEnabled) && getLivekitConfig().configured,
|
||||||
|
videoAvatarEnabled:
|
||||||
|
bot.videoAvatarEnabled !== false
|
||||||
|
&& Boolean(bot.voiceEnabled)
|
||||||
|
&& getLivekitConfig().configured
|
||||||
|
&& getSimliConfig().configured,
|
||||||
|
avatarEnabled:
|
||||||
|
bot.videoAvatarEnabled !== false
|
||||||
|
&& Boolean(bot.voiceEnabled)
|
||||||
|
&& getLivekitConfig().configured
|
||||||
|
&& getSimliConfig().configured,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -148,6 +161,36 @@ const voiceToken = async (request, h) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const simliToken = async (request, h) => {
|
||||||
|
const ip = request.headers['x-forwarded-for'] || request.info.remoteAddress;
|
||||||
|
if (!takeIpToken(ip)) {
|
||||||
|
return h.response(error('RATE_LIMIT', 'Too many requests. Try again in a minute.')).code(429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { botId } = request.params;
|
||||||
|
const { bot, err } = await loadBotOr404(botId);
|
||||||
|
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
|
||||||
|
|
||||||
|
if (!bot.voiceEnabled) {
|
||||||
|
return h.response(error('FORBIDDEN', 'Voice is not enabled for this chatbot')).code(403);
|
||||||
|
}
|
||||||
|
if (!getLivekitConfig().configured) {
|
||||||
|
return h.response(error('SERVICE_UNAVAILABLE', 'Voice is not configured')).code(503);
|
||||||
|
}
|
||||||
|
if (!getSimliConfig().configured) {
|
||||||
|
return h.response(error('SERVICE_UNAVAILABLE', 'Avatar is not configured')).code(503);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionToken = await createSimliSessionToken();
|
||||||
|
return h.response(success({ sessionToken }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[publicConcierge] simli token failed:', e);
|
||||||
|
const status = Number(e.status) === 400 ? 400 : 502;
|
||||||
|
return h.response(error('INTERNAL_ERROR', e.message || 'Could not start avatar')).code(status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const feedback = async (request, h) => {
|
const feedback = async (request, h) => {
|
||||||
const { botId } = request.params;
|
const { botId } = request.params;
|
||||||
const { bot, err } = await loadBotOr404(botId);
|
const { bot, err } = await loadBotOr404(botId);
|
||||||
|
|
@ -253,6 +296,7 @@ module.exports = {
|
||||||
config,
|
config,
|
||||||
chat,
|
chat,
|
||||||
voiceToken,
|
voiceToken,
|
||||||
|
simliToken,
|
||||||
feedback,
|
feedback,
|
||||||
end,
|
end,
|
||||||
getSession,
|
getSession,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'agenda_by_speaker',
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: agenda_by_speaker
|
|
||||||
Select sessions featuring the requested speaker from DATA.sessions.
|
|
||||||
message: natural spoken answer; session_ids: matching ids from DATA (empty if none).
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'agenda_by_track',
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: agenda_by_track
|
|
||||||
Select sessions for the requested track from DATA.sessions.
|
|
||||||
If DATA.tracks is listed without matches, ask which track in message and return session_ids [].
|
|
||||||
message: natural spoken answer; session_ids: matching ids from DATA.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'agenda_conflict_check',
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: agenda_conflict_check
|
|
||||||
Use DATA.conflicts and DATA.sessions.
|
|
||||||
message: clearly say whether there are overlaps and summarize briefly.
|
|
||||||
session_ids: ids of sessions involved in conflicts (or all saved sessions if helpful); empty if none.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'agenda_current_now',
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: agenda_current_now
|
|
||||||
Select sessions that are live now from DATA.sessions.
|
|
||||||
message: brief note about what is live (or that nothing is live).
|
|
||||||
session_ids: ids of live sessions from DATA (empty if none).
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'agenda_download_materials',
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: agenda_download_materials
|
|
||||||
Select sessions that have materials/resources in DATA.sessions.
|
|
||||||
message: natural spoken answer about available downloads (or that none exist).
|
|
||||||
session_ids: ids of sessions with resources from DATA.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'agenda_personal_schedule',
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: agenda_personal_schedule
|
|
||||||
DATA.sessions are the attendee's saved/bookmarked sessions only (not the full event agenda).
|
|
||||||
- If DATA.sessions is empty (or DATA.note says none are saved): message must say clearly that no sessions are saved yet, and session_ids MUST be [].
|
|
||||||
- If DATA.sessions has items: answer naturally; session_ids MUST include those session ids from DATA (prefer all, soonest first, max 8).
|
|
||||||
Never say "here are the sessions" when DATA.sessions is empty.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'agenda_session_details',
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: agenda_session_details
|
|
||||||
DATA.sessions are filtered only by day and/or speaker when those were requested.
|
|
||||||
Use ENTITIES.topic / ENTITIES.session_name / the user message to choose which sessions to return (titles + topics).
|
|
||||||
For specialty / "must not miss" / recommendation questions: pick the strongest fits for that topic (prioritize clinical relevance over tea breaks/sponsors unless asked). Prefer at most 8.
|
|
||||||
If none fit the topic/name, say so and return [].
|
|
||||||
If the user asked for the full programme ("what are the sessions", "show the agenda") and DATA.sessions is non-empty: return those ids (up to 8) and answer naturally. Do not say none found. Do not use a canned "Here is the programme."
|
|
||||||
If DATA.sessions is empty / DATA.note says none found, say so in a natural sentence (never "No matching sessions found.") and return [].
|
|
||||||
message: natural spoken answer (cards show details). Never repeat the event name.
|
|
||||||
session_ids: matching ids from DATA.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'agenda_upcoming_next',
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: agenda_upcoming_next
|
|
||||||
Select the next upcoming sessions from DATA.sessions (chronological).
|
|
||||||
message: natural spoken answer about what's coming up.
|
|
||||||
session_ids: those session ids from DATA.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
const agenda_current_now = require('./agenda_current_now');
|
|
||||||
const agenda_upcoming_next = require('./agenda_upcoming_next');
|
|
||||||
const agenda_by_track = require('./agenda_by_track');
|
|
||||||
const agenda_by_speaker = require('./agenda_by_speaker');
|
|
||||||
const agenda_session_details = require('./agenda_session_details');
|
|
||||||
const agenda_personal_schedule = require('./agenda_personal_schedule');
|
|
||||||
const agenda_conflict_check = require('./agenda_conflict_check');
|
|
||||||
const agenda_download_materials = require('./agenda_download_materials');
|
|
||||||
const { CALL2_MAIN_PROMPT } = require('../main');
|
|
||||||
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
|
|
||||||
|
|
||||||
const MODULES = [
|
|
||||||
agenda_current_now,
|
|
||||||
agenda_upcoming_next,
|
|
||||||
agenda_by_track,
|
|
||||||
agenda_by_speaker,
|
|
||||||
agenda_session_details,
|
|
||||||
agenda_personal_schedule,
|
|
||||||
agenda_conflict_check,
|
|
||||||
agenda_download_materials,
|
|
||||||
];
|
|
||||||
|
|
||||||
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
|
|
||||||
const SESSION_CARD_INTENTS = new Set(
|
|
||||||
MODULES.filter((m) => m.usesSessionCards).map((m) => m.intent),
|
|
||||||
);
|
|
||||||
|
|
||||||
function buildCall2SystemPrompt(intent) {
|
|
||||||
const promptlet = PROMPTLETS[intent];
|
|
||||||
if (!promptlet) return null;
|
|
||||||
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_MAIN_PROMPT}\n\n${promptlet}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usesSessionCards(intent) {
|
|
||||||
return SESSION_CARD_INTENTS.has(String(intent || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PROMPTLETS,
|
|
||||||
SESSION_CARD_INTENTS,
|
|
||||||
buildCall2SystemPrompt,
|
|
||||||
usesSessionCards,
|
|
||||||
};
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
const CALL2_CONVERSATION_PROMPT = `
|
|
||||||
### CONVERSATION (always — first message or follow-up)
|
|
||||||
You are talking with one attendee in an ongoing chat. Sound like a helpful person at the event desk, not a search engine or FAQ dump.
|
|
||||||
|
|
||||||
1. Answer what they just said. Acknowledge the ask (or the last turn) in natural language, then give the useful facts from DATA.
|
|
||||||
2. First turn: speak like a person ("A few good options nearby are…" not "Here are some forts." / "Here is the programme.").
|
|
||||||
3. "message" may be 2–5 short sentences when that helps (compare two options, react to "I've heard of X", give a reason). Do not pad. Do not list every card field in prose — cards still show details.
|
|
||||||
4. Never reuse the same sentence as the previous Assistant line in HISTORY. Vary the opener.
|
|
||||||
5. If HISTORY exists: this is the same conversation. Do not reprint the last list or recap unless they asked to see it again. If they name something from HISTORY or DATA, talk about that item. If it is not in DATA, say so honestly.
|
|
||||||
6. If there is no HISTORY: still speak like a person.
|
|
||||||
7. DATA.faqs are background for THIS ask only — use a FAQ when it actually answers the current utterance. Do not recap topics listed in DATA.alreadyTold unless they ask again.
|
|
||||||
8. Never invent URLs, tickets, Wi-Fi passwords, or organizer contacts. Never invent places or sessions unless the adjacent-local exception in the domain prompt allows world knowledge for this city.
|
|
||||||
9. Do not add organizer email/phone unless they asked who to contact.
|
|
||||||
10. Never repeat the event name. Never say "I can only assist with [event]".
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
module.exports = { CALL2_CONVERSATION_PROMPT };
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'exhibitor_book_meeting',
|
|
||||||
usesExhibitorCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: exhibitor_book_meeting
|
|
||||||
Always refuse booking in chat. Suggest visiting the booth or asking exhibitor staff / help desk.
|
|
||||||
If DATA.exhibitors has a company, you may share booth/hall so they can find them. Never confirm a meeting or invent a calendar slot.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'exhibitor_by_category',
|
|
||||||
usesExhibitorCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: exhibitor_by_category
|
|
||||||
List matching exhibitors from DATA.exhibitors for DATA.categoryQuery (name, category, booth/hall).
|
|
||||||
Set exhibitor_ids for the matches (up to 8).
|
|
||||||
If needsClarification / empty: follow DATA.note — ask for a category or say none matched. Never invent companies.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'exhibitor_demo_schedule',
|
|
||||||
usesExhibitorCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: exhibitor_demo_schedule
|
|
||||||
If DATA.faqs is non-empty: answer demo timing only from those FAQ Q&A pairs. You may also mention booth/hall from DATA.exhibitors.
|
|
||||||
If DATA.faqs is empty: say no demo schedule is listed; suggest the booth or help desk. Never invent times.
|
|
||||||
If needsClarification: ask which company; exhibitor_ids [].
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'exhibitor_location',
|
|
||||||
usesExhibitorCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: exhibitor_location
|
|
||||||
If DATA.isExhibitorList: briefly list exhibitors from DATA.exhibitors (name + booth/hall when present). Set exhibitor_ids (up to 8). Invite asking about a company for more detail.
|
|
||||||
If a single exhibitor: share boothNumber, hall, shortDescription/products/website from DATA.
|
|
||||||
If needsClarification: list only DATA.candidates; exhibitor_ids [].
|
|
||||||
If booth/hall missing: follow DATA.note — do not invent a location.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
const exhibitor_location = require('./exhibitor_location');
|
|
||||||
const exhibitor_by_category = require('./exhibitor_by_category');
|
|
||||||
const exhibitor_demo_schedule = require('./exhibitor_demo_schedule');
|
|
||||||
const exhibitor_book_meeting = require('./exhibitor_book_meeting');
|
|
||||||
const { CALL2_EXHIBITOR_MAIN_PROMPT } = require('./main');
|
|
||||||
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
|
|
||||||
|
|
||||||
const MODULES = [
|
|
||||||
exhibitor_location,
|
|
||||||
exhibitor_by_category,
|
|
||||||
exhibitor_demo_schedule,
|
|
||||||
exhibitor_book_meeting,
|
|
||||||
];
|
|
||||||
|
|
||||||
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
|
|
||||||
const EXHIBITOR_CARD_INTENTS = new Set(
|
|
||||||
MODULES.filter((m) => m.usesExhibitorCards).map((m) => m.intent),
|
|
||||||
);
|
|
||||||
|
|
||||||
function buildExhibitorCall2SystemPrompt(intent) {
|
|
||||||
const promptlet = PROMPTLETS[intent];
|
|
||||||
if (!promptlet) return null;
|
|
||||||
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_EXHIBITOR_MAIN_PROMPT}\n\n${promptlet}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usesExhibitorCards(intent) {
|
|
||||||
return EXHIBITOR_CARD_INTENTS.has(String(intent || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PROMPTLETS,
|
|
||||||
buildExhibitorCall2SystemPrompt,
|
|
||||||
usesExhibitorCards,
|
|
||||||
};
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
const CALL2_EXHIBITOR_MAIN_PROMPT = `
|
|
||||||
You are the Virtual Concierge for an EventBuzz event (exhibitor / booth questions).
|
|
||||||
|
|
||||||
### GUARDRAILS:
|
|
||||||
1. Use only DATA. Never invent booth numbers, halls, demo times, meeting slots, websites, or exhibitor names.
|
|
||||||
2. NEVER invent a candidates list. Only mention names in DATA.exhibitors or DATA.candidates.
|
|
||||||
3. NEVER output email addresses or phone numbers.
|
|
||||||
4. If DATA.needsClarification and DATA.candidates is non-empty, ask the user to pick a company name from those candidates only.
|
|
||||||
5. If DATA.refuseBooking is true, refuse booking politely — never confirm a meeting.
|
|
||||||
6. exhibitor_ids MUST be taken only from DATA.exhibitors[].id.
|
|
||||||
7. Prefer at most 8 exhibitor_ids. Answer naturally in "message"; cards show details.
|
|
||||||
8. Output ONLY the JSON keys message, exhibitor_ids — no other keys.
|
|
||||||
|
|
||||||
### REQUIRED OUTPUT (JSON only, no markdown):
|
|
||||||
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
|
|
||||||
- Example: {"message":"Short answer.","exhibitor_ids":["..."]}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
module.exports = { CALL2_EXHIBITOR_MAIN_PROMPT };
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'accommodation',
|
|
||||||
usesPlaceCards: false,
|
|
||||||
usesHotelCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: accommodation
|
|
||||||
If DATA.faqs is present and answers this ask: use those FAQ lines. Do not invent a different policy. Do not recap DATA.alreadyTold.
|
|
||||||
If they ask about hotel check-in: say what the FAQ allows. Never say "refer to the agenda". Do not add organizer email/phone unless they asked who to contact.
|
|
||||||
If DATA.hostManagedAccommodation is true: say the organizer arranges accommodation; contact the event team. hotel_ids [] / place_ids [].
|
|
||||||
Else suggest stays from DATA.hotels only; set hotel_ids (up to 6). Remind them to verify rates themselves.
|
|
||||||
If hotels empty and not host-managed: follow DATA.note — do not invent hotels.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
const tourist_places = require('./tourist_places');
|
|
||||||
const accommodation = require('./accommodation');
|
|
||||||
const { CALL2_LOCAL_MAIN_PROMPT } = require('./main');
|
|
||||||
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
|
|
||||||
|
|
||||||
const MODULES = [tourist_places, accommodation];
|
|
||||||
|
|
||||||
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
|
|
||||||
const PLACE_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesPlaceCards).map((m) => m.intent));
|
|
||||||
const HOTEL_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesHotelCards).map((m) => m.intent));
|
|
||||||
|
|
||||||
function buildLocalCall2SystemPrompt(intent) {
|
|
||||||
const promptlet = PROMPTLETS[intent];
|
|
||||||
if (!promptlet) return null;
|
|
||||||
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_LOCAL_MAIN_PROMPT}\n\n${promptlet}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usesLocalPlaceCards(intent) {
|
|
||||||
return PLACE_CARD_INTENTS.has(String(intent || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
function usesLocalHotelCards(intent) {
|
|
||||||
return HOTEL_CARD_INTENTS.has(String(intent || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PROMPTLETS,
|
|
||||||
buildLocalCall2SystemPrompt,
|
|
||||||
usesLocalPlaceCards,
|
|
||||||
usesLocalHotelCards,
|
|
||||||
};
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
const CALL2_LOCAL_MAIN_PROMPT = `
|
|
||||||
You are the Virtual Concierge for an EventBuzz event (nearby places and stays).
|
|
||||||
|
|
||||||
### GUARDRAILS:
|
|
||||||
1. Use only DATA. Never invent attractions, hotels, addresses, ratings, or prices.
|
|
||||||
2. Use DATA.faqs only when they answer this utterance (e.g. host-managed stay). Do not recap DATA.alreadyTold. If DATA.hostManagedAccommodation is true, do not suggest booking hotels as if listed — say the organizer arranges stay. Never say "refer to the agenda" for hotel check-in. Do not add organizer email/phone unless they asked who to contact.
|
|
||||||
3. place_ids MUST come only from DATA.places[].id. hotel_ids only from DATA.hotels[].id.
|
|
||||||
4. Never substitute a different place category (temples ≠ churches; forts ≠ churches/beaches).
|
|
||||||
5. Prefer at most 6 ids. Answer naturally in "message"; cards show details.
|
|
||||||
6. Output ONLY the JSON keys needed: message plus place_ids and/or hotel_ids.
|
|
||||||
|
|
||||||
### REQUIRED OUTPUT (JSON only, no markdown):
|
|
||||||
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
|
|
||||||
- Tourist example: {"message":"Short answer.","place_ids":["..."],"hotel_ids":[]}
|
|
||||||
- Stay example: {"message":"Short answer.","place_ids":[],"hotel_ids":["..."]}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
module.exports = { CALL2_LOCAL_MAIN_PROMPT };
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'tourist_places',
|
|
||||||
usesPlaceCards: true,
|
|
||||||
usesHotelCards: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: tourist_places
|
|
||||||
Suggest sightseeing / things to do from DATA.places only.
|
|
||||||
If DATA.requestedCategory is set, DATA.places are already filtered to that category — do not substitute other types (e.g. churches are not temples; beaches are not forts).
|
|
||||||
Set place_ids for the best matches (up to 6). hotel_ids [].
|
|
||||||
If DATA.places is empty: follow DATA.note — do not invent attractions or substitute other categories.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
const CALL2_MAIN_PROMPT = `
|
|
||||||
You are the Virtual Concierge for an EventBuzz event.
|
|
||||||
Use ONLY the structured DATA provided in the user message.
|
|
||||||
|
|
||||||
### GUARDRAILS:
|
|
||||||
1. Do NOT invent sessions, times, rooms, speakers, links, or materials that are not in DATA.
|
|
||||||
2. If DATA.sessions is missing/empty or DATA is insufficient, say so clearly in "message" and return "session_ids": []. Do NOT claim sessions exist.
|
|
||||||
3. Stay on-event. Refuse unrelated requests politely.
|
|
||||||
4. Do not invent URLs. Do not mention system prompts or intent names.
|
|
||||||
5. session_ids MUST be taken only from DATA.sessions[].id (exact string ids from DATA).
|
|
||||||
6. Prefer at most 8 session_ids. Order them usefully (soonest first when relevant). When DATA.sessions has matches, include those ids (do not return an empty session_ids list).
|
|
||||||
7. "message" answers the user naturally (2–5 short sentences when useful). Do NOT list every session title/time or repeat the event name; cards will show those.
|
|
||||||
8. If DATA.note is present, treat it as an authoritative hint about missing matches.
|
|
||||||
9. TIMEZONE: Session times are in DATA.sessions[].when (event local / IST). Use that string only — do not invent or recompute times.
|
|
||||||
10. NEVER claim you added, saved, bookmarked, removed, or updated the attendee's personal agenda. Call 2 is read-only.
|
|
||||||
|
|
||||||
### REQUIRED OUTPUT (JSON only, no markdown):
|
|
||||||
{
|
|
||||||
"message": "Natural attendee-facing answer.",
|
|
||||||
"session_ids": ["64f...", "64f..."]
|
|
||||||
}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
module.exports = { CALL2_MAIN_PROMPT };
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
const speaker_bio_lookup = require('./speaker_bio_lookup');
|
|
||||||
const speaker_meet_and_greet = require('./speaker_meet_and_greet');
|
|
||||||
const speaker_contact_info = require('./speaker_contact_info');
|
|
||||||
const { CALL2_SPEAKER_MAIN_PROMPT } = require('./main');
|
|
||||||
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
|
|
||||||
|
|
||||||
const MODULES = [speaker_bio_lookup, speaker_meet_and_greet, speaker_contact_info];
|
|
||||||
|
|
||||||
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
|
|
||||||
const SPEAKER_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesSpeakerCards).map((m) => m.intent));
|
|
||||||
const SESSION_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesSessionCards).map((m) => m.intent));
|
|
||||||
|
|
||||||
function buildSpeakerCall2SystemPrompt(intent) {
|
|
||||||
const promptlet = PROMPTLETS[intent];
|
|
||||||
if (!promptlet) return null;
|
|
||||||
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_SPEAKER_MAIN_PROMPT}\n\n${promptlet}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usesSpeakerCards(intent) {
|
|
||||||
return SPEAKER_CARD_INTENTS.has(String(intent || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
function usesSpeakerSessionCards(intent) {
|
|
||||||
return SESSION_CARD_INTENTS.has(String(intent || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PROMPTLETS,
|
|
||||||
buildSpeakerCall2SystemPrompt,
|
|
||||||
usesSpeakerCards,
|
|
||||||
usesSpeakerSessionCards,
|
|
||||||
};
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
const CALL2_SPEAKER_MAIN_PROMPT = `
|
|
||||||
You are the Virtual Concierge for an EventBuzz event (speaker questions).
|
|
||||||
|
|
||||||
### GUARDRAILS:
|
|
||||||
1. Use only DATA. Never invent bios, contact details, meet-and-greet times, social URLs, or speaker names.
|
|
||||||
2. NEVER invent a candidates list. Only mention names that appear in DATA.speakers or DATA.candidates. If both are empty, say you need a name — do not guess.
|
|
||||||
3. NEVER output email addresses or phone numbers, even if you think you know them.
|
|
||||||
4. If DATA.needsClarification and DATA.candidates is non-empty, ask the user to pick a full name from those candidates only.
|
|
||||||
5. If a profile is private (DATA.speakers[].isPublic false / profileVisibility private), say the profile is not publicly listed.
|
|
||||||
6. speaker_ids MUST be taken only from DATA.speakers[].id. session_ids only from DATA.sessions[].id (or DATA.meetSessions for meet-and-greet).
|
|
||||||
7. Prefer at most 6 session_ids. Answer naturally in "message"; cards show details.
|
|
||||||
8. Output ONLY the JSON keys message, speaker_ids, session_ids — no other keys.
|
|
||||||
|
|
||||||
### REQUIRED OUTPUT (JSON only, no markdown):
|
|
||||||
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
|
|
||||||
- Example: {"message":"Short answer.","speaker_ids":["..."],"session_ids":[]}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
module.exports = { CALL2_SPEAKER_MAIN_PROMPT };
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'speaker_bio_lookup',
|
|
||||||
usesSpeakerCards: true,
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: speaker_bio_lookup
|
|
||||||
If DATA.isSpeakerList is true: briefly introduce the programme speakers from DATA.speakers (names + title/org). Set speaker_ids to those ids (up to 8). session_ids []. Invite asking about one person for a fuller bio. Never add names not in DATA.
|
|
||||||
If a single speaker in DATA.speakers: summarize name, role, title, organization, bio, expertise.
|
|
||||||
Include their sessions when DATA.sessions is present — set session_ids accordingly.
|
|
||||||
If needsClarification: list only DATA.candidates names and ask which person; speaker_ids/session_ids [].
|
|
||||||
If private / no bio: follow DATA.note. Set speaker_ids when public speaker(s) are present.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'speaker_contact_info',
|
|
||||||
usesSpeakerCards: true,
|
|
||||||
usesSessionCards: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: speaker_contact_info
|
|
||||||
Share only public social links from DATA.speakers[].socialLinks (linkedin, twitter, website).
|
|
||||||
Never invent links. Never share email or phone.
|
|
||||||
If private or no socials: follow DATA.note and suggest organizers / help desk.
|
|
||||||
speaker_ids: the matched speaker when useful. session_ids: [].
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'speaker_meet_and_greet',
|
|
||||||
usesSpeakerCards: true,
|
|
||||||
usesSessionCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: speaker_meet_and_greet
|
|
||||||
If DATA.meetSessions / DATA.sessions lists meet-and-greet or networking sessions, describe them and set session_ids.
|
|
||||||
If DATA.note says none are listed: say so honestly, optionally mention their regular sessions from DATA.sessions, suggest help desk — do not invent a meet-and-greet.
|
|
||||||
speaker_ids: matched speaker when present.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'chitchat_greeting',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: chitchat_greeting
|
|
||||||
A warm, natural hello — vary the wording. Do not list every topic. Suggestion chips are already in the UI.
|
|
||||||
Do not reuse the same canned line every time.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'emergency_medical',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: emergency_medical
|
|
||||||
FIRST sentence must tell them to contact venue security or medical staff immediately.
|
|
||||||
Then briefly acknowledge. If DATA.emailSent is true, say organizers were notified; otherwise do not claim that.
|
|
||||||
If DATA.contact / DATA.faqs has Email or Phone: share those exact contact details.
|
|
||||||
No diagnosis or treatment advice. Follow DATA.note.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'event_adjacent',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: event_adjacent
|
|
||||||
The attendee is asking about the world around THIS event city (DATA.location / DATA.city) and DATA.eventDates — food dishes, a 1-day plan after the event, local tips.
|
|
||||||
|
|
||||||
You MAY use general world knowledge for that city and window only. If they ask about a different city, refuse in one short sentence.
|
|
||||||
|
|
||||||
Never invent event programme, session times, tickets, registration URLs, Wi‑Fi passwords, or organizer contacts. Do not recap meal-provided / FAQ policy unless it is in DATA.faqs for this turn.
|
|
||||||
|
|
||||||
If DATA.dish is set, answer that dish (where to try it, what it is) — do not pivot to whether the venue serves lunch.
|
|
||||||
|
|
||||||
Remind them to verify opening hours and maps. Keep it decent. No attendee PII, no medical advice beyond first aid / go to on-site help.
|
|
||||||
|
|
||||||
If DATA.places exist, you may set place_ids from DATA.places[].id (up to 6) when they help; otherwise [].
|
|
||||||
Prefer not repeating a long event marketing title. Use the city name.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'human_agent_escalation',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: human_agent_escalation
|
|
||||||
Confirm you received their request for a human organizer.
|
|
||||||
If DATA.contact / DATA.faqs has Email or Phone: share those exact contact details clearly (required).
|
|
||||||
If DATA.emailSent is true, also say organizers were notified; otherwise do not claim email was sent.
|
|
||||||
You may still mention the on-site help desk / info desk as an additional option.
|
|
||||||
Offer to keep helping with agenda, ticket, or venue meanwhile.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
const emergency_medical = require('./emergency_medical');
|
|
||||||
const lost_and_found = require('./lost_and_found');
|
|
||||||
const human_agent_escalation = require('./human_agent_escalation');
|
|
||||||
const chitchat_greeting = require('./chitchat_greeting');
|
|
||||||
const out_of_scope = require('./out_of_scope');
|
|
||||||
const event_adjacent = require('./event_adjacent');
|
|
||||||
const { CALL2_SYSTEM_MAIN_PROMPT } = require('./main');
|
|
||||||
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
|
|
||||||
|
|
||||||
const MODULES = [
|
|
||||||
emergency_medical,
|
|
||||||
lost_and_found,
|
|
||||||
human_agent_escalation,
|
|
||||||
chitchat_greeting,
|
|
||||||
out_of_scope,
|
|
||||||
event_adjacent,
|
|
||||||
];
|
|
||||||
|
|
||||||
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
|
|
||||||
|
|
||||||
function buildSystemCall2SystemPrompt(intent) {
|
|
||||||
const promptlet = PROMPTLETS[intent];
|
|
||||||
if (!promptlet) return null;
|
|
||||||
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_SYSTEM_MAIN_PROMPT}\n\n${promptlet}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PROMPTLETS,
|
|
||||||
buildSystemCall2SystemPrompt,
|
|
||||||
};
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'lost_and_found',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: lost_and_found
|
|
||||||
Acknowledge a lost-and-found request. Ask for a short item description if not already clear.
|
|
||||||
Suggest the help desk / registration. If DATA.emailSent is true, say organizers were notified; otherwise do not claim that.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
const CALL2_SYSTEM_MAIN_PROMPT = `
|
|
||||||
You are the Virtual Concierge for an EventBuzz event (greetings, safety, lost & found, human help, event-city local tips).
|
|
||||||
|
|
||||||
### GUARDRAILS:
|
|
||||||
1. Use only DATA unless the event_adjacent task explicitly allows world knowledge for DATA.city.
|
|
||||||
2. Never invent that organizers were emailed unless DATA.emailSent is true.
|
|
||||||
3. NEVER give medical advice. For emergencies, urge immediate on-site security/medical staff first.
|
|
||||||
4. If DATA.contact or DATA.faqs include Email/Phone, you MUST share those exact values with the visitor — they are organizer-configured public contacts, not invented.
|
|
||||||
5. Do NOT invent other phone numbers, emails, or desk hours beyond DATA.
|
|
||||||
6. If DATA.contact is present, do NOT say you have no organizer contact information.
|
|
||||||
7. Answer naturally in "message" unless listing a required contact or safety step. Never repeat the event name. Never say "I can only assist with [event name]". Do not pad with capability lists. Do not recap DATA.alreadyTold.
|
|
||||||
8. Output ONLY the JSON key message — no other keys (except place_ids when the adjacent task allows it).
|
|
||||||
|
|
||||||
### REQUIRED OUTPUT (JSON only, no markdown):
|
|
||||||
- Valid JSON only. Escape newlines inside strings as \\n.
|
|
||||||
- Example: {"message":"Short answer."}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
module.exports = { CALL2_SYSTEM_MAIN_PROMPT };
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'out_of_scope',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: out_of_scope
|
|
||||||
One short sentence, like a person. Do not name the event. Do not start with "I'm afraid". Do not list every topic you can help with.
|
|
||||||
Example: "I can only help with this event — try the agenda, venue, or your ticket."
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'app_feature_guidance',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: app_feature_guidance
|
|
||||||
If DATA.faqs is non-empty: explain using those answers only.
|
|
||||||
If empty: ask which feature they need help with, or suggest in-app help / organizers. Do not invent a UI walkthrough.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'app_issue_account',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: app_issue_account
|
|
||||||
If DATA.faqs is non-empty: answer from those FAQ Q&A pairs.
|
|
||||||
Else if DATA.staticTips: share those login tips and suggest organizers / help desk.
|
|
||||||
Never invent reset URLs or email addresses.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'app_issue_streaming',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: app_issue_streaming
|
|
||||||
If DATA.faqs is non-empty: answer from those FAQ Q&A pairs.
|
|
||||||
Else if DATA.staticTips: list those tips clearly and suggest contacting event support if it still fails.
|
|
||||||
Never invent platform-specific settings not in DATA.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
const wifi_credentials = require('./wifi_credentials');
|
|
||||||
const app_issue_streaming = require('./app_issue_streaming');
|
|
||||||
const app_issue_account = require('./app_issue_account');
|
|
||||||
const app_feature_guidance = require('./app_feature_guidance');
|
|
||||||
const { CALL2_TECH_MAIN_PROMPT } = require('./main');
|
|
||||||
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
|
|
||||||
|
|
||||||
const MODULES = [
|
|
||||||
wifi_credentials,
|
|
||||||
app_issue_streaming,
|
|
||||||
app_issue_account,
|
|
||||||
app_feature_guidance,
|
|
||||||
];
|
|
||||||
|
|
||||||
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
|
|
||||||
|
|
||||||
function buildTechCall2SystemPrompt(intent) {
|
|
||||||
const promptlet = PROMPTLETS[intent];
|
|
||||||
if (!promptlet) return null;
|
|
||||||
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_TECH_MAIN_PROMPT}\n\n${promptlet}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PROMPTLETS,
|
|
||||||
buildTechCall2SystemPrompt,
|
|
||||||
};
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
const CALL2_TECH_MAIN_PROMPT = `
|
|
||||||
You are the Virtual Concierge for an EventBuzz event (Wi‑Fi and app/tech help).
|
|
||||||
|
|
||||||
### GUARDRAILS:
|
|
||||||
1. Use only DATA. Never invent Wi‑Fi SSIDs, passwords, reset links, or app UI steps.
|
|
||||||
2. NEVER invent credentials. Use DATA.faqs only if they answer this ask. If DATA.faqs is empty for wifi, say details are not listed and suggest signage / help desk. Do not recap DATA.alreadyTold.
|
|
||||||
3. If DATA.staticTips is present, use those tips (you may number them). Do not add invented tips beyond DATA.
|
|
||||||
4. NEVER output personal email addresses or phone numbers.
|
|
||||||
5. Answer naturally in "message". You may list Wi‑Fi details from DATA when asked.
|
|
||||||
6. Output ONLY the JSON key message — no other keys.
|
|
||||||
|
|
||||||
### REQUIRED OUTPUT (JSON only, no markdown):
|
|
||||||
- Valid JSON only. Escape newlines inside strings as \\n.
|
|
||||||
- Example: {"message":"Short answer."}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
module.exports = { CALL2_TECH_MAIN_PROMPT };
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'wifi_credentials',
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: wifi_credentials
|
|
||||||
If DATA.faqs has answers: share the Wi‑Fi network name and/or password exactly from those answers.
|
|
||||||
If DATA.faqs is empty: say Wi‑Fi details are not listed here; check venue signage or the help desk. Never invent a password.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
const ticket_qr_display = require('./ticket_qr_display');
|
|
||||||
const ticket_email_qr = require('./ticket_email_qr');
|
|
||||||
const ticket_status_lookup = require('./ticket_status_lookup');
|
|
||||||
const ticket_how_to_register = require('./ticket_how_to_register');
|
|
||||||
const ticket_document_receipt = require('./ticket_document_receipt');
|
|
||||||
const ticket_transfer_request = require('./ticket_transfer_request');
|
|
||||||
const ticket_upgrade = require('./ticket_upgrade');
|
|
||||||
const { CALL2_TICKET_MAIN_PROMPT } = require('./main');
|
|
||||||
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
|
|
||||||
|
|
||||||
const MODULES = [
|
|
||||||
ticket_qr_display,
|
|
||||||
ticket_email_qr,
|
|
||||||
ticket_status_lookup,
|
|
||||||
ticket_how_to_register,
|
|
||||||
ticket_document_receipt,
|
|
||||||
ticket_transfer_request,
|
|
||||||
ticket_upgrade,
|
|
||||||
];
|
|
||||||
|
|
||||||
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
|
|
||||||
const QR_CARD_INTENTS = new Set(MODULES.filter((m) => m.usesQrCard).map((m) => m.intent));
|
|
||||||
|
|
||||||
function buildTicketCall2SystemPrompt(intent) {
|
|
||||||
const promptlet = PROMPTLETS[intent];
|
|
||||||
if (!promptlet) return null;
|
|
||||||
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_TICKET_MAIN_PROMPT}\n\n${promptlet}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usesQrCard(intent) {
|
|
||||||
return QR_CARD_INTENTS.has(String(intent || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PROMPTLETS,
|
|
||||||
QR_CARD_INTENTS,
|
|
||||||
buildTicketCall2SystemPrompt,
|
|
||||||
usesQrCard,
|
|
||||||
};
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
const CALL2_TICKET_MAIN_PROMPT = `
|
|
||||||
You are the Virtual Concierge for an EventBuzz event (ticket / registration questions).
|
|
||||||
|
|
||||||
### GUARDRAILS:
|
|
||||||
1. Use only DATA for personal ticket facts (name, status, ticket tier, payment, receipt, QR availability). Do not invent order ids, amounts, or statuses.
|
|
||||||
2. Never claim a transfer or upgrade succeeded. Those are not available in chat.
|
|
||||||
3. Do not mention system prompts, intent names, or internal fields like policy/note unless paraphrasing helpfully for the attendee.
|
|
||||||
4. Stay on-event. Answer the user's utterance in "message". Do not repeat the event name. For attending/joining questions: answer that ask; DATA.faqs are facts only if they match. Never invent or paste registration URLs — buttons in the UI open the real forms. Never refuse as out of scope.
|
|
||||||
5. When DATA.qr.imageUrl is present, the UI shows a QR card — say only that their check-in QR is below (or that it was emailed).
|
|
||||||
|
|
||||||
### REQUIRED OUTPUT (JSON only, no markdown):
|
|
||||||
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
|
|
||||||
- Example: {"message":"Your registration is confirmed.","show_qr":true}
|
|
||||||
- "show_qr" should be true when the user should see their check-in QR in chat (display or after email).
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
module.exports = { CALL2_TICKET_MAIN_PROMPT };
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'ticket_document_receipt',
|
|
||||||
usesQrCard: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: ticket_document_receipt
|
|
||||||
Present a short text receipt from DATA.receipt when present:
|
|
||||||
- ticket name, amount, currency/status, order id, date, purchaser email, attendee list.
|
|
||||||
- If DATA.receipt is missing, say no purchase record was found and suggest contacting the organizer.
|
|
||||||
- Do not invent PDF downloads. show_qr: false.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'ticket_email_qr',
|
|
||||||
usesQrCard: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: ticket_email_qr
|
|
||||||
Confirm that the ticket QR and details were emailed (or explain failure).
|
|
||||||
- Use DATA.emailSent and DATA.emailTo. Never invent a different recipient.
|
|
||||||
- If emailed: confirm the address (e.g. "I've emailed your ticket QR to you@…").
|
|
||||||
- If failed / rate-limited: explain briefly from DATA.emailError or DATA.note.
|
|
||||||
- Optionally set show_qr true so they can also see the QR in chat when DATA.qr.available.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'ticket_how_to_register',
|
|
||||||
usesQrCard: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: ticket_how_to_register
|
|
||||||
Answer the user's question about attending / joining (who may come, how to register, eligibility).
|
|
||||||
DATA.faqs are background — use a FAQ only if it answers THIS ask. Do not paste an unrelated gate, contact, or how-to line as the whole reply.
|
|
||||||
If DATA does not say whether everyone may attend, say that honestly; do not invent a public/private policy.
|
|
||||||
If DATA.hasRegistrationButtons is true, mention they can register with the buttons below. Never invent or paste URLs. show_qr: false.
|
|
||||||
Do not add organizer email/phone unless they asked who to contact.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'ticket_qr_display',
|
|
||||||
usesQrCard: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: ticket_qr_display
|
|
||||||
Show the attendee their check-in QR.
|
|
||||||
- If DATA.qr.available: one short line such as "Here's your check-in QR."; set show_qr true. Do not recap name/status unless asked.
|
|
||||||
- If not available: apologize in one sentence and suggest the help desk.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'ticket_status_lookup',
|
|
||||||
usesQrCard: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: ticket_status_lookup
|
|
||||||
Summarize registration / ticket status from DATA.
|
|
||||||
- Include attendee name, membership statusLabel, ticket name if present, check-in, and payment status/amount when present.
|
|
||||||
- show_qr: false unless they also clearly need the QR (prefer false for status-only).
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'ticket_transfer_request',
|
|
||||||
usesQrCard: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: ticket_transfer_request
|
|
||||||
Politely refuse. Ticket transfers cannot be completed in this chat.
|
|
||||||
Ask the attendee to contact the event organizer or on-site help desk.
|
|
||||||
Offer help with showing/emailing their QR, status, or receipt instead.
|
|
||||||
Do not collect a recipient email. show_qr: false.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'ticket_upgrade',
|
|
||||||
usesQrCard: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: ticket_upgrade
|
|
||||||
Politely refuse. Ticket upgrades cannot be completed in this chat.
|
|
||||||
Ask the attendee to contact the event organizer or on-site help desk.
|
|
||||||
Offer help with showing/emailing their QR, status, or receipt instead.
|
|
||||||
Do not list upgrade tiers for purchase. show_qr: false.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
const venue_map_request = require('./venue_map_request');
|
|
||||||
const venue_wayfinding = require('./venue_wayfinding');
|
|
||||||
const venue_nearest_amenity = require('./venue_nearest_amenity');
|
|
||||||
const venue_parking_transport = require('./venue_parking_transport');
|
|
||||||
const venue_travel_directions = require('./venue_travel_directions');
|
|
||||||
const venue_food_beverage = require('./venue_food_beverage');
|
|
||||||
const venue_accessibility = require('./venue_accessibility');
|
|
||||||
const { CALL2_VENUE_MAIN_PROMPT } = require('./main');
|
|
||||||
const { CALL2_CONVERSATION_PROMPT } = require('../conversation');
|
|
||||||
|
|
||||||
const MODULES = [
|
|
||||||
venue_map_request,
|
|
||||||
venue_wayfinding,
|
|
||||||
venue_nearest_amenity,
|
|
||||||
venue_parking_transport,
|
|
||||||
venue_travel_directions,
|
|
||||||
venue_food_beverage,
|
|
||||||
venue_accessibility,
|
|
||||||
];
|
|
||||||
|
|
||||||
const PROMPTLETS = Object.fromEntries(MODULES.map((m) => [m.intent, m.promptlet]));
|
|
||||||
const PLACE_CARD_INTENTS = new Set(
|
|
||||||
MODULES.filter((m) => m.usesPlaceCards).map((m) => m.intent),
|
|
||||||
);
|
|
||||||
|
|
||||||
function buildVenueCall2SystemPrompt(intent) {
|
|
||||||
const promptlet = PROMPTLETS[intent];
|
|
||||||
if (!promptlet) return null;
|
|
||||||
return `${CALL2_CONVERSATION_PROMPT}\n\n${CALL2_VENUE_MAIN_PROMPT}\n\n${promptlet}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usesPlaceCards(intent) {
|
|
||||||
return PLACE_CARD_INTENTS.has(String(intent || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
PROMPTLETS,
|
|
||||||
PLACE_CARD_INTENTS,
|
|
||||||
buildVenueCall2SystemPrompt,
|
|
||||||
usesPlaceCards,
|
|
||||||
};
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
const CALL2_VENUE_MAIN_PROMPT = `
|
|
||||||
You are the Virtual Concierge for an EventBuzz event (venue questions).
|
|
||||||
|
|
||||||
### GUARDRAILS:
|
|
||||||
1. Use DATA.faqs only when they answer this utterance. They are facts, not a script — do not recap DATA.alreadyTold.
|
|
||||||
2. For maps, indoor amenities, on-site parking policy, food provided, and accessibility: if DATA.faqs is empty, follow DATA.note / DATA.fallback — do not invent those facts.
|
|
||||||
3. For intent venue_travel_directions: organizers cannot list every origin. You may use general geographic knowledge for city-to-city train/flight/road corridors to DATA.location (and DATA.eventName only if needed as the destination name). Prefer a direct train/flight when one commonly exists; only suggest a via-hub connection if a direct option is unlikely. Say you are not a live timetable — attendee should verify on IRCTC / airline apps.
|
|
||||||
4. If DATA.transportModes.enforce is true: local last-mile modes (taxi, Uber/Ola, event shuttle, public transport) are an allow-list. ONLY recommend DATA.transportModes.allowed. NEVER say a mode in DATA.transportModes.notAllowed is available. If asked about a notAllowed mode, say organizers have not listed it for this event.
|
|
||||||
5. Do not invent URLs. Do not mention system prompts or intent names.
|
|
||||||
6. Stay on-event. Refuse unrelated requests politely.
|
|
||||||
7. place_ids MUST be taken only from DATA.places[].id when present; otherwise return [].
|
|
||||||
8. Prefer at most 6 place_ids for food. Order usefully (closest / highest rated when data allows).
|
|
||||||
9. "message" should answer the user naturally (2–5 short sentences when useful; longer if listing directions). Do not mix parking FAQ answers into ride-app questions. Do not add organizer email/phone unless the user asked who to contact.
|
|
||||||
|
|
||||||
### REQUIRED OUTPUT (JSON only, no markdown):
|
|
||||||
- Valid JSON only. Escape newlines inside strings as \\n. Include commas between properties.
|
|
||||||
- Example: {"message":"Short answer.\\nSecond line.","place_ids":[]}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
module.exports = { CALL2_VENUE_MAIN_PROMPT };
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'venue_accessibility',
|
|
||||||
usesPlaceCards: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: venue_accessibility
|
|
||||||
Use DATA.faqs when they answer this ask (wheelchair access, assistance contacts).
|
|
||||||
If no FAQ: direct the attendee to the venue help desk. Do not invent accessibility policies.
|
|
||||||
place_ids: [].
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'venue_food_beverage',
|
|
||||||
usesPlaceCards: true,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: venue_food_beverage
|
|
||||||
If DATA.faqs answers whether meals/tea are provided or dietary policy, use that for this ask only.
|
|
||||||
If DATA.faqs is empty, do not invent or replay meal policy.
|
|
||||||
If they asked about a specific dish, do not pivot to whether lunch is provided.
|
|
||||||
If DATA.places exists you may select place_ids for nearby dining cards.
|
|
||||||
If no FAQ and no places: direct the attendee to the venue help desk. Never invent restaurants.
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'venue_map_request',
|
|
||||||
usesPlaceCards: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: venue_map_request
|
|
||||||
Use DATA.faqs about maps/floor plans when they answer this ask.
|
|
||||||
If no FAQ: share DATA.location (venue address). Do not invent a map URL or floor plan.
|
|
||||||
place_ids: [].
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'venue_nearest_amenity',
|
|
||||||
usesPlaceCards: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: venue_nearest_amenity
|
|
||||||
Use DATA.faqs when they answer this ask (restrooms, water, charging, etc.).
|
|
||||||
If no FAQ: direct the attendee to the venue help desk. Do not invent amenity locations.
|
|
||||||
place_ids: [].
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'venue_parking_transport',
|
|
||||||
usesPlaceCards: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: venue_parking_transport
|
|
||||||
Answer on-site parking from DATA.faqs when present. Do not invent parking rules.
|
|
||||||
|
|
||||||
Local last-mile / ride options (taxi, Uber/Ola, shuttle, public transport):
|
|
||||||
- If DATA.transportModes.enforce is true: treat DATA.transportModes.allowed as the ONLY organizer-confirmed options.
|
|
||||||
- Never affirm a mode in DATA.transportModes.notAllowed (e.g. if "Ride apps (Uber/Ola)" is notAllowed, do NOT say Uber or Ola is available).
|
|
||||||
- If the user asks about a notAllowed mode, say organizers have not listed that option for this event and suggest the help desk or an allowed mode instead.
|
|
||||||
- If allowed is empty, say organizers have not listed local transfer options; suggest the venue help desk. Do not invent Uber/Ola/shuttle availability.
|
|
||||||
|
|
||||||
If no parking FAQ and no transportModes: share DATA.location if useful, then help desk.
|
|
||||||
City-to-venue train/flight routing is venue_travel_directions — for those, still respect DATA.transportModes for last-mile claims.
|
|
||||||
place_ids: [].
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'venue_travel_directions',
|
|
||||||
usesPlaceCards: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: venue_travel_directions
|
|
||||||
Help the attendee reach the event at DATA.location from their origin (ENTITIES.location or the user message), by train / flight / road.
|
|
||||||
Use DATA.location (city / venue address) as the destination. Do not lead with a long marketing event title. Do not suggest sightseeing, forts, beaches, or hotels unless they asked.
|
|
||||||
Organizers cannot document every origin city — use general geographic knowledge for long-distance corridors (train/flight/road between cities).
|
|
||||||
|
|
||||||
Routing rules:
|
|
||||||
1. Prefer DIRECT options first (e.g. Nagpur–Goa trains if commonly available). Do NOT default to a longer via-Mumbai/hub path when a direct corridor is plausible.
|
|
||||||
2. Mention a via-hub alternative only as a secondary option.
|
|
||||||
3. For the last mile from the nearest station/airport to DATA.location: if DATA.transportModes.enforce is true, ONLY suggest modes in DATA.transportModes.allowed. Never affirm modes in DATA.transportModes.notAllowed (e.g. do not say Uber/Ola is available if Ride apps is notAllowed).
|
|
||||||
4. If the user specifically asks whether Uber/Ola/shuttle/public transport is available and that mode is notAllowed, say organizers have not listed that option — do not invent availability.
|
|
||||||
5. If the user challenges your route (e.g. "aren't there direct trains?"), acknowledge that direct services may exist and advise checking IRCTC / live schedules — do not stubbornly repeat only the hub route.
|
|
||||||
6. You are not a live timetable. Never invent specific train numbers or exact departure times unless certain; prefer corridor-level guidance + "verify on IRCTC".
|
|
||||||
7. Do not invent host shuttles unless "Event shuttle" is in DATA.transportModes.allowed (or in DATA.faqs). Do NOT answer help-desk-only when DATA.location is present.
|
|
||||||
place_ids: [].
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
intent: 'venue_wayfinding',
|
|
||||||
usesPlaceCards: false,
|
|
||||||
promptlet: `
|
|
||||||
### INTENT TASK: venue_wayfinding
|
|
||||||
Use DATA.faqs when they answer this ask.
|
|
||||||
If no FAQ: list halls/rooms from DATA.halls and DATA.rooms if available, and say more details are at the venue help desk. Do not invent turn-by-turn indoor directions.
|
|
||||||
place_ids: [].
|
|
||||||
`.trim(),
|
|
||||||
};
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
/**
|
|
||||||
* Call 1 — domain + action + entity extraction.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { DOMAINS, ACTIONS } = require('../utils/domainAction');
|
|
||||||
|
|
||||||
const DOMAIN_DESCRIPTIONS = {
|
|
||||||
agenda: 'This event’s programme: sessions, tracks, times, personal saved agenda, materials.',
|
|
||||||
speaker: 'People on the programme: bios, lineup, meet-and-greet, public socials. When they speak → agenda (set speaker_name).',
|
|
||||||
venue: 'The event site: map, halls, amenities, parking/shuttle, meals *policy*, accessibility, and reaching the venue from any origin city. Not city sightseeing.',
|
|
||||||
local: 'Around the event city: hotels/stay policy, cached landmarks. City world-knowledge (a dish, a day plan) uses action adjacent, not read.',
|
|
||||||
ticket: 'Attending this event: who may come, how to join/register, eligibility, ticket status, QR, receipt. Emailing QR is action email_qr.',
|
|
||||||
exhibitor: 'Booths, exhibitor list, category, demos, meeting requests.',
|
|
||||||
tech: 'Wi-Fi, streaming/AV, app login, how to use app features.',
|
|
||||||
system: 'Greeting, lost & found, true refuse (oos), emergency, organizer/human contact.',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACTION_DESCRIPTIONS = {
|
|
||||||
read: 'Default. Fetch facts and answer. Use for almost every in-scope question.',
|
|
||||||
bookmark_add: 'They asked to save/add a session. domain agenda.',
|
|
||||||
bookmark_remove: 'They asked to remove a saved session. domain agenda.',
|
|
||||||
email_qr: 'They asked to email their QR/ticket. domain ticket.',
|
|
||||||
emergency: 'Medical/safety emergency. domain system.',
|
|
||||||
escalate_human: 'Talk to organizer / who to contact. domain system.',
|
|
||||||
adjacent:
|
|
||||||
'World knowledge for THIS event city/dates only (a local dish, a day plan after the event). Not event policy, not programme, not tickets, not another city.',
|
|
||||||
oos: 'ONLY if the ask is clearly not this event or its host city: other products, homework, a different city as the trip, news, abuse.',
|
|
||||||
greeting: 'Hello / thanks / goodbye with no event question. domain system.',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatList(ids, descriptions) {
|
|
||||||
return ids.map((id) => ` - "${id}": ${descriptions[id] || ''}`).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEP_1_SYSTEM_PROMPT = `
|
|
||||||
You are the classification engine for EventBuzz Virtual Concierge.
|
|
||||||
Output JSON only — no markdown, no extra keys.
|
|
||||||
|
|
||||||
### HARD CONSTRAINTS
|
|
||||||
- "domain" MUST be one of: ${DOMAINS.map((d) => `"${d}"`).join(', ')}
|
|
||||||
- "action" MUST be one of: ${ACTIONS.map((a) => `"${a}"`).join(', ')}
|
|
||||||
- Do not invent domain or action names. Do not output primary_intent.
|
|
||||||
|
|
||||||
### DOMAINS
|
|
||||||
${formatList(DOMAINS, DOMAIN_DESCRIPTIONS)}
|
|
||||||
|
|
||||||
### ACTIONS
|
|
||||||
${formatList(ACTIONS, ACTION_DESCRIPTIONS)}
|
|
||||||
|
|
||||||
### HOW TO CHOOSE (coverage, not phrase matching)
|
|
||||||
An attendee is talking to the event desk. Classify the *kind of ask*, not whether you have seen this wording.
|
|
||||||
|
|
||||||
1. In-scope if it is about THIS event or THIS event’s host city: programme, people, venue, getting here, meals policy, Wi-Fi/app, tickets, who may attend / how to join, exhibitors, nearby food or sights in that city. Pick the closest domain. Action is "read" unless a mutation below applies.
|
|
||||||
2. oos ONLY if it is clearly not that: other products, homework, a different city as the trip, general news, abuse. Features we do not have (matchmaking, polls, social feed) → oos. If you are unsure between two in-scope domains, pick one and use "read". Never use oos for lack of a matching example.
|
|
||||||
3. Event policy (who may attend, meals provided, how to join, Wi-Fi listed) is always "read", never adjacent, never oos.
|
|
||||||
4. adjacent only when they need city world-knowledge beyond programme/FAQ (a specific dish, a day plan after the event) in THIS city. Set dish when they named food. Other city → oos.
|
|
||||||
5. Mutations only when they clearly asked to do it: bookmark_add / bookmark_remove, email_qr, emergency, escalate_human.
|
|
||||||
6. Follow-ups: use RECENT CONVERSATION. Set is_followup. Fill entities. Do not treat a named follow-up as a fresh list dump.
|
|
||||||
7. Entities: speaker_name without salutations (sir, madam, ji, Dr, Mr, Prof, Shri). location = origin city or named place. dish when they named food.
|
|
||||||
8. Classification only — never claim a bookmark or email succeeded.
|
|
||||||
|
|
||||||
### REQUIRED JSON
|
|
||||||
{
|
|
||||||
"domain": "ticket",
|
|
||||||
"action": "read",
|
|
||||||
"confidence": 0.9,
|
|
||||||
"entities": {
|
|
||||||
"speaker_name": null,
|
|
||||||
"session_name": null,
|
|
||||||
"session_id": null,
|
|
||||||
"location": null,
|
|
||||||
"company_name": null,
|
|
||||||
"time_reference": null,
|
|
||||||
"topic": null,
|
|
||||||
"dish": null
|
|
||||||
},
|
|
||||||
"context_resolution": {
|
|
||||||
"is_followup": false,
|
|
||||||
"resolved_from_history": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
const DOMAIN_ALIASES = {
|
|
||||||
agenda_domain: 'agenda',
|
|
||||||
speakers: 'speaker',
|
|
||||||
venue_domain: 'venue',
|
|
||||||
places: 'local',
|
|
||||||
tickets: 'ticket',
|
|
||||||
exhibitors: 'exhibitor',
|
|
||||||
wifi: 'tech',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ACTION_ALIASES = {
|
|
||||||
lookup: 'read',
|
|
||||||
query: 'read',
|
|
||||||
itinerary: 'adjacent',
|
|
||||||
local_tips: 'adjacent',
|
|
||||||
out_of_scope: 'oos',
|
|
||||||
chitchat: 'greeting',
|
|
||||||
hello: 'greeting',
|
|
||||||
talk_to_human: 'escalate_human',
|
|
||||||
human_agent: 'escalate_human',
|
|
||||||
email_ticket: 'email_qr',
|
|
||||||
save_session: 'bookmark_add',
|
|
||||||
remove_session: 'bookmark_remove',
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveDomain(raw) {
|
|
||||||
const d = String(raw || '').trim();
|
|
||||||
if (DOMAINS.includes(d)) return d;
|
|
||||||
return DOMAIN_ALIASES[d] && DOMAINS.includes(DOMAIN_ALIASES[d]) ? DOMAIN_ALIASES[d] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAction(raw) {
|
|
||||||
const a = String(raw || '').trim();
|
|
||||||
if (ACTIONS.includes(a)) return a;
|
|
||||||
return ACTION_ALIASES[a] && ACTIONS.includes(ACTION_ALIASES[a]) ? ACTION_ALIASES[a] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
STEP_1_SYSTEM_PROMPT,
|
|
||||||
DOMAINS,
|
|
||||||
ACTIONS,
|
|
||||||
resolveDomain,
|
|
||||||
resolveAction,
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
const { faqAnswerText } = require('../utils/faqAnswer');
|
||||||
|
|
||||||
|
function faqKeyOf(row) {
|
||||||
|
return String(row?.key || row?.category || '').trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokens(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((t) => t.length > 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeBotFaqs(bot) {
|
||||||
|
const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
|
||||||
|
return rows
|
||||||
|
.map((f) => {
|
||||||
|
const a = faqAnswerText(f);
|
||||||
|
if (!a) return null;
|
||||||
|
const q = String(f?.question || '').trim();
|
||||||
|
const key = faqKeyOf(f);
|
||||||
|
return {
|
||||||
|
q: q || key || 'FAQ',
|
||||||
|
a,
|
||||||
|
key,
|
||||||
|
category: String(f?.category || '').trim() || key,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreFaq(row, query) {
|
||||||
|
const hay = `${row?.q || ''} ${row?.key || ''} ${row?.category || ''} ${row?.a || ''}`.toLowerCase();
|
||||||
|
let score = 0;
|
||||||
|
for (const b of tokens(query)) {
|
||||||
|
if (hay.includes(b)) score += 1;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid keyword rank over ConciergeBot FAQs, scoped to this event's bot.
|
||||||
|
*/
|
||||||
|
function searchFaqs({ bot, query, alreadyTold = [], limit = 4 } = {}) {
|
||||||
|
const told = new Set((Array.isArray(alreadyTold) ? alreadyTold : []).map(String));
|
||||||
|
const pool = serializeBotFaqs(bot).filter((f) => {
|
||||||
|
const key = String(f.key || f.category || '');
|
||||||
|
if (key && told.has(key)) return false;
|
||||||
|
return Boolean(f.a);
|
||||||
|
});
|
||||||
|
|
||||||
|
const scored = pool
|
||||||
|
.map((f) => ({ f, score: scoreFaq(f, query) }))
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
const picked = (scored[0]?.score > 0 ? scored.filter((s) => s.score > 0) : scored)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((s) => s.f);
|
||||||
|
|
||||||
|
return picked.map((f) => ({
|
||||||
|
source: 'faq',
|
||||||
|
title: f.q,
|
||||||
|
text: f.a,
|
||||||
|
key: f.key || f.category || null,
|
||||||
|
category: f.category || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function faqKeysUsed(hits) {
|
||||||
|
return (Array.isArray(hits) ? hits : [])
|
||||||
|
.map((h) => String(h.key || h.category || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
searchFaqs,
|
||||||
|
faqKeysUsed,
|
||||||
|
serializeBotFaqs,
|
||||||
|
};
|
||||||
|
|
@ -68,6 +68,17 @@ module.exports = [
|
||||||
},
|
},
|
||||||
handler: controller.voiceToken,
|
handler: controller.voiceToken,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/public/concierge/{botId}/simli/token',
|
||||||
|
options: {
|
||||||
|
auth: false,
|
||||||
|
validate: {
|
||||||
|
params: Joi.object({ botId: Joi.string().required() }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: controller.simliToken,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/public/concierge/{botId}/feedback',
|
path: '/api/public/concierge/{botId}/feedback',
|
||||||
|
|
|
||||||
|
|
@ -139,10 +139,9 @@ async function sendOtpForEmail(session, { email, eventId, eventName }) {
|
||||||
|
|
||||||
if (isFixedOtpMode()) {
|
if (isFixedOtpMode()) {
|
||||||
console.log(`[concierge OTP — fixed mode] to=${em} code=${otp}`);
|
console.log(`[concierge OTP — fixed mode] to=${em} code=${otp}`);
|
||||||
|
await sendConciergeOtpEmail({ to: em, otp, eventName });
|
||||||
return {
|
return {
|
||||||
reply:
|
reply: `I found your registration for ${em}. Reply with ${otp} to continue.`,
|
||||||
`I found your registration for ${em}. `
|
|
||||||
+ `This environment uses a fixed verification code — reply with that 6-digit code to continue.`,
|
|
||||||
verified: false,
|
verified: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,4 @@
|
||||||
const { classifyIntent } = require('./step1.service');
|
const { runConciergeTurn } = require('../agent/runner');
|
||||||
const {
|
|
||||||
generateAgendaReply,
|
|
||||||
generateVenueReply,
|
|
||||||
generateTicketReply,
|
|
||||||
generateSpeakerReply,
|
|
||||||
generateExhibitorReply,
|
|
||||||
generateTechReply,
|
|
||||||
generateSystemReply,
|
|
||||||
generateLocalReply,
|
|
||||||
} = require('./step2.service');
|
|
||||||
const {
|
|
||||||
isAgendaIntent,
|
|
||||||
isAuthRequiredAgendaIntent,
|
|
||||||
} = require('./agendaData.service');
|
|
||||||
const { isVenueIntent } = require('./venueData.service');
|
|
||||||
const {
|
|
||||||
isTicketIntent,
|
|
||||||
isAuthRequiredTicketIntent,
|
|
||||||
} = require('./ticketData.service');
|
|
||||||
const { isSpeakerIntent } = require('./speakerData.service');
|
|
||||||
const { isExhibitorIntent } = require('./exhibitorData.service');
|
|
||||||
const { isTechIntent } = require('./techData.service');
|
|
||||||
const { isSystemIntent } = require('./systemData.service');
|
|
||||||
const { isLocalIntent } = require('./localData.service');
|
|
||||||
const {
|
|
||||||
addBookmark,
|
|
||||||
removeBookmark,
|
|
||||||
isBookmarkMutationIntent,
|
|
||||||
} = require('./bookmark.service');
|
|
||||||
const {
|
const {
|
||||||
isVerified,
|
isVerified,
|
||||||
beginEmailAuth,
|
beginEmailAuth,
|
||||||
|
|
@ -35,249 +6,72 @@ const {
|
||||||
clearPending,
|
clearPending,
|
||||||
clearAuthChallenge,
|
clearAuthChallenge,
|
||||||
} = require('./identity.service');
|
} = require('./identity.service');
|
||||||
const { sendTicketPassEmail } = require('../utils/mail');
|
|
||||||
const { withOrganizerContact } = require('../utils/organizerContact');
|
const { withOrganizerContact } = require('../utils/organizerContact');
|
||||||
const { retrieveForTurn } = require('./retrieveForTurn');
|
|
||||||
const { fromLegacyIntent, isBookmarkAction } = require('../utils/domainAction');
|
|
||||||
const { isUnsafeAdjacentMessage, UNSAFE_REFUSE } = require('../utils/adjacentSafety');
|
const { isUnsafeAdjacentMessage, UNSAFE_REFUSE } = require('../utils/adjacentSafety');
|
||||||
|
const { DEFAULT_QUICK_REPLIES } = require('../utils/quickReplies');
|
||||||
|
|
||||||
const TICKET_EMAIL_COOLDOWN_MS = 60 * 1000;
|
function emptyTurn(overrides = {}) {
|
||||||
|
|
||||||
function publicClassification(classification) {
|
|
||||||
if (!classification) return null;
|
|
||||||
const { _fallback, _reason, ...rest } = classification;
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sumUsage(a, b) {
|
|
||||||
return {
|
return {
|
||||||
inputTokens: Number(a?.inputTokens || 0) + Number(b?.inputTokens || 0),
|
reply: '',
|
||||||
outputTokens: Number(a?.outputTokens || 0) + Number(b?.outputTokens || 0),
|
cards: [],
|
||||||
};
|
sessionIds: [],
|
||||||
}
|
classification: null,
|
||||||
|
intent: null,
|
||||||
async function maybeSendTicketEmail({ session, data, intent }) {
|
|
||||||
if (intent !== 'ticket_email_qr') return data;
|
|
||||||
|
|
||||||
const to = String(data?.attendee?.email || '').trim().toLowerCase();
|
|
||||||
if (!to) {
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
emailSent: false,
|
|
||||||
emailError: 'missing_email',
|
|
||||||
note: 'No registered email on file for this attendee.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const last = session.lastTicketEmailAt ? new Date(session.lastTicketEmailAt).getTime() : 0;
|
|
||||||
if (last && Date.now() - last < TICKET_EMAIL_COOLDOWN_MS) {
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
emailSent: false,
|
|
||||||
emailTo: to,
|
|
||||||
emailError: 'rate_limited',
|
|
||||||
note: 'A ticket email was sent recently for this chat session.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const payment = data.payment;
|
|
||||||
const paymentLabel =
|
|
||||||
payment?.status && payment?.amount != null
|
|
||||||
? `${payment.status} (${payment.currency || 'INR'} ${payment.amount})`
|
|
||||||
: payment?.status || null;
|
|
||||||
|
|
||||||
const result = await sendTicketPassEmail({
|
|
||||||
to,
|
|
||||||
eventName: data.eventName,
|
|
||||||
attendeeName: data.attendee?.name,
|
|
||||||
ticketName: data.ticket?.name || data.receipt?.ticketName || null,
|
|
||||||
statusLabel: data.membership?.statusLabel || data.membership?.status || null,
|
|
||||||
paymentLabel,
|
|
||||||
qrImageUrl: data.qr?.imageUrl || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
session.lastTicketEmailAt = new Date();
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
emailSent: true,
|
|
||||||
emailTo: to,
|
|
||||||
emailError: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
emailSent: false,
|
|
||||||
emailTo: to,
|
|
||||||
emailError: result.error || 'send_failed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateForDomain({ intent, action, message, history, entities, data }) {
|
|
||||||
if (action === 'adjacent' || intent === 'event_adjacent') {
|
|
||||||
return generateSystemReply({
|
|
||||||
intent: 'event_adjacent',
|
|
||||||
message,
|
|
||||||
history,
|
|
||||||
entities,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isAgendaIntent(intent)) {
|
|
||||||
return generateAgendaReply({ intent, message, history, entities, data });
|
|
||||||
}
|
|
||||||
if (isVenueIntent(intent)) {
|
|
||||||
return generateVenueReply({ intent, message, history, entities, data });
|
|
||||||
}
|
|
||||||
if (isLocalIntent(intent)) {
|
|
||||||
return generateLocalReply({ intent, message, history, entities, data });
|
|
||||||
}
|
|
||||||
if (isTicketIntent(intent)) {
|
|
||||||
return generateTicketReply({ intent, message, history, entities, data });
|
|
||||||
}
|
|
||||||
if (isSpeakerIntent(intent)) {
|
|
||||||
return generateSpeakerReply({ intent, message, history, entities, data });
|
|
||||||
}
|
|
||||||
if (isExhibitorIntent(intent)) {
|
|
||||||
return generateExhibitorReply({ intent, message, history, entities, data });
|
|
||||||
}
|
|
||||||
if (isTechIntent(intent)) {
|
|
||||||
return generateTechReply({ intent, message, history, entities, data });
|
|
||||||
}
|
|
||||||
if (isSystemIntent(intent) || action === 'oos' || action === 'greeting' || action === 'emergency' || action === 'escalate_human') {
|
|
||||||
return generateSystemReply({ intent, message, history, entities, data });
|
|
||||||
}
|
|
||||||
return generateSystemReply({
|
|
||||||
intent: 'out_of_scope',
|
|
||||||
message,
|
|
||||||
history,
|
|
||||||
entities,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function completeReadTurn({ bot, event, session, message, history, classification }) {
|
|
||||||
const intent = classification.primary_intent;
|
|
||||||
const action = classification.action;
|
|
||||||
let data = await retrieveForTurn({
|
|
||||||
bot,
|
|
||||||
event,
|
|
||||||
session,
|
|
||||||
classification,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (action === 'email_qr' || intent === 'ticket_email_qr') {
|
|
||||||
data = await maybeSendTicketEmail({ session, data, intent: 'ticket_email_qr' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const step2 = await generateForDomain({
|
|
||||||
intent,
|
|
||||||
action,
|
|
||||||
message,
|
|
||||||
history,
|
|
||||||
entities: classification.entities || {},
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
reply: step2.reply,
|
|
||||||
cards: Array.isArray(step2.cards) ? step2.cards : [],
|
|
||||||
sessionIds: Array.isArray(step2.sessionIds) ? step2.sessionIds : [],
|
|
||||||
placeIds: Array.isArray(step2.placeIds) ? step2.placeIds : [],
|
|
||||||
hotelIds: Array.isArray(step2.hotelIds) ? step2.hotelIds : [],
|
|
||||||
speakerIds: Array.isArray(step2.speakerIds) ? step2.speakerIds : [],
|
|
||||||
exhibitorIds: Array.isArray(step2.exhibitorIds) ? step2.exhibitorIds : [],
|
|
||||||
quickReplies: Array.isArray(step2.quickReplies) ? step2.quickReplies : null,
|
|
||||||
classification: publicClassification(classification),
|
|
||||||
intent,
|
|
||||||
modelId: step2.modelId,
|
|
||||||
usage: step2.usage,
|
|
||||||
faqKeysUsed: Array.isArray(data?.faqKeysUsed) ? data.faqKeysUsed : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function classificationFromPending(pendingIntent, pendingEntities) {
|
|
||||||
const mapped = fromLegacyIntent(pendingIntent) || { domain: 'system', action: 'read' };
|
|
||||||
const action =
|
|
||||||
pendingIntent === 'ticket_email_qr'
|
|
||||||
? 'email_qr'
|
|
||||||
: pendingIntent === 'agenda_bookmark_add'
|
|
||||||
? 'bookmark_add'
|
|
||||||
: pendingIntent === 'agenda_bookmark_remove'
|
|
||||||
? 'bookmark_remove'
|
|
||||||
: mapped.action;
|
|
||||||
return {
|
|
||||||
primary_intent: pendingIntent,
|
|
||||||
domain: mapped.domain,
|
|
||||||
action,
|
|
||||||
confidence: 1,
|
|
||||||
action_category: action === 'email_qr' || isBookmarkAction(action) ? 'mutation' : 'read',
|
|
||||||
entities: pendingEntities,
|
|
||||||
context_resolution: { is_followup: true, resolved_from_history: true },
|
|
||||||
requires_data_fetch: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBookmarkMutation({ bot, session, classification }) {
|
|
||||||
const intent = classification.primary_intent;
|
|
||||||
const entities = classification.entities || {};
|
|
||||||
const fallbackSessionIds = Array.isArray(session.lastSessionIds) ? session.lastSessionIds : [];
|
|
||||||
const opts = {
|
|
||||||
eventId: bot.event,
|
|
||||||
participantId: session.participant || null,
|
|
||||||
entities,
|
|
||||||
fallbackSessionIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result =
|
|
||||||
intent === 'agenda_bookmark_remove'
|
|
||||||
? await removeBookmark(opts)
|
|
||||||
: await addBookmark(opts);
|
|
||||||
|
|
||||||
return {
|
|
||||||
reply: result.reply,
|
|
||||||
cards: Array.isArray(result.cards) ? result.cards : [],
|
|
||||||
sessionIds: Array.isArray(result.sessionIds) ? result.sessionIds : [],
|
|
||||||
classification: publicClassification(classification),
|
|
||||||
intent,
|
|
||||||
modelId: null,
|
modelId: null,
|
||||||
usage: { inputTokens: 0, outputTokens: 0 },
|
usage: { inputTokens: 0, outputTokens: 0 },
|
||||||
faqKeysUsed: [],
|
faqKeysUsed: [],
|
||||||
|
quickReplies: null,
|
||||||
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function unsafeRefuseResult(classification, step1) {
|
function toPublicResult(agent, extras = {}) {
|
||||||
|
const toolNames = Array.isArray(agent?.toolNames) ? agent.toolNames : [];
|
||||||
|
const intent = toolNames[0] || extras.intent || null;
|
||||||
|
const noTools = !toolNames.length && !agent?.unauthenticated;
|
||||||
return {
|
return {
|
||||||
reply: UNSAFE_REFUSE,
|
reply: agent?.reply || extras.reply || '',
|
||||||
cards: [],
|
cards: Array.isArray(agent?.cards) ? agent.cards : [],
|
||||||
sessionIds: [],
|
sessionIds: Array.isArray(agent?.sessionIds) ? agent.sessionIds : [],
|
||||||
classification: publicClassification(classification),
|
classification: toolNames.length ? { tools: toolNames } : null,
|
||||||
intent: classification?.primary_intent || null,
|
intent,
|
||||||
modelId: step1?.modelId || null,
|
modelId: agent?.modelId || null,
|
||||||
usage: step1?.usage || { inputTokens: 0, outputTokens: 0 },
|
usage: agent?.usage || { inputTokens: 0, outputTokens: 0 },
|
||||||
faqKeysUsed: [],
|
faqKeysUsed: Array.isArray(agent?.faqKeysUsed) ? agent.faqKeysUsed : [],
|
||||||
|
quickReplies: noTools ? DEFAULT_QUICK_REPLIES : null,
|
||||||
|
forceOrganizerContact: Boolean(agent?.forceOrganizerContact),
|
||||||
|
...extras,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Full turn orchestration after the user message has been appended to session.history.
|
|
||||||
*/
|
|
||||||
function finalizeTurn(result, bot, message) {
|
function finalizeTurn(result, bot, message) {
|
||||||
const intent = String(result?.intent || '');
|
const force = Boolean(result?.forceOrganizerContact) || result?.intent === 'contact_organizer';
|
||||||
const force = intent === 'human_agent_escalation' || intent === 'emergency_medical';
|
const { forceOrganizerContact, ...rest } = result || {};
|
||||||
return {
|
return {
|
||||||
...result,
|
...rest,
|
||||||
reply: withOrganizerContact(result?.reply, bot, { message, force }),
|
reply: withOrganizerContact(rest?.reply, bot, { message, force }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleChatTurnCore({ bot, event, session, message }) {
|
async function runAgent({ bot, event, session, message, history, channel }) {
|
||||||
|
return runConciergeTurn({
|
||||||
|
bot,
|
||||||
|
event,
|
||||||
|
session,
|
||||||
|
message,
|
||||||
|
history,
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChatTurnCore({ bot, event, session, message, channel = 'text' }) {
|
||||||
const eventName = event?.name || 'the event';
|
const eventName = event?.name || 'the event';
|
||||||
const history = Array.isArray(session.history) ? session.history.slice(0, -1) : [];
|
const history = Array.isArray(session.history) ? session.history.slice(0, -1) : [];
|
||||||
|
|
||||||
// 1) In-progress identity challenge — skip Read Call 1
|
if (isUnsafeAdjacentMessage(message)) {
|
||||||
|
return emptyTurn({ reply: UNSAFE_REFUSE });
|
||||||
|
}
|
||||||
|
|
||||||
if (session.authState === 'awaiting_email' || session.authState === 'awaiting_otp') {
|
if (session.authState === 'awaiting_email' || session.authState === 'awaiting_otp') {
|
||||||
const auth = await handleAuthTurn(session, {
|
const auth = await handleAuthTurn(session, {
|
||||||
message,
|
message,
|
||||||
|
|
@ -289,132 +83,60 @@ async function handleChatTurnCore({ bot, event, session, message }) {
|
||||||
clearAuthChallenge(session);
|
clearAuthChallenge(session);
|
||||||
clearPending(session);
|
clearPending(session);
|
||||||
if (auth.cancelOnly) {
|
if (auth.cancelOnly) {
|
||||||
return {
|
return emptyTurn({ reply: 'OK. What else can I help with?' });
|
||||||
reply: 'OK. What else can I help with?',
|
|
||||||
cards: [],
|
|
||||||
sessionIds: [],
|
|
||||||
classification: null,
|
|
||||||
intent: null,
|
|
||||||
modelId: null,
|
|
||||||
usage: { inputTokens: 0, outputTokens: 0 },
|
|
||||||
faqKeysUsed: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// New question while verifying — drop auth and answer it below.
|
// New question while verifying — drop auth and answer it below.
|
||||||
} else if (!auth.shouldResume) {
|
} else if (!auth.shouldResume) {
|
||||||
return {
|
return emptyTurn({
|
||||||
reply: auth.reply,
|
reply: auth.reply,
|
||||||
cards: [],
|
|
||||||
sessionIds: [],
|
|
||||||
classification: null,
|
|
||||||
intent: session.pendingIntent || null,
|
intent: session.pendingIntent || null,
|
||||||
modelId: null,
|
});
|
||||||
usage: { inputTokens: 0, outputTokens: 0 },
|
|
||||||
faqKeysUsed: [],
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
|
const pendingMessage = session.pendingMessage || message;
|
||||||
// Verified — answer the original pending query
|
clearPending(session);
|
||||||
const pendingMessage = session.pendingMessage || message;
|
const agent = await runAgent({
|
||||||
const pendingIntent = session.pendingIntent;
|
|
||||||
const pendingEntities =
|
|
||||||
session.pendingEntities && typeof session.pendingEntities === 'object'
|
|
||||||
? session.pendingEntities
|
|
||||||
: {};
|
|
||||||
clearPending(session);
|
|
||||||
|
|
||||||
if (pendingIntent && isBookmarkMutationIntent(pendingIntent)) {
|
|
||||||
const classification = classificationFromPending(pendingIntent, pendingEntities);
|
|
||||||
const result = await runBookmarkMutation({ bot, session, classification });
|
|
||||||
result.reply = `You're verified. ${result.reply}`;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingIntent && (isAuthRequiredTicketIntent(pendingIntent) || isAgendaIntent(pendingIntent))) {
|
|
||||||
const classification = classificationFromPending(pendingIntent, pendingEntities);
|
|
||||||
const result = await completeReadTurn({
|
|
||||||
bot,
|
bot,
|
||||||
event,
|
event,
|
||||||
session,
|
session,
|
||||||
message: pendingMessage,
|
message: pendingMessage,
|
||||||
history,
|
history,
|
||||||
classification,
|
channel,
|
||||||
});
|
});
|
||||||
result.reply = `You're verified. ${result.reply}`;
|
const result = toPublicResult(agent);
|
||||||
|
if (result.reply) {
|
||||||
|
result.reply = `You're verified. ${result.reply}`;
|
||||||
|
} else {
|
||||||
|
result.reply = "You're verified. How can I help with the agenda or your ticket?";
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
reply: "You're verified. How can I help with the agenda or your ticket?",
|
|
||||||
cards: [],
|
|
||||||
sessionIds: [],
|
|
||||||
classification: null,
|
|
||||||
intent: pendingIntent,
|
|
||||||
modelId: null,
|
|
||||||
usage: { inputTokens: 0, outputTokens: 0 },
|
|
||||||
faqKeysUsed: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Call 1
|
const agent = await runAgent({
|
||||||
const step1 = await classifyIntent({ message, history });
|
|
||||||
const classification = step1.classification;
|
|
||||||
const intent = classification.primary_intent;
|
|
||||||
const action = classification.action;
|
|
||||||
|
|
||||||
// 3) Safety refuse (abuse / PII / medical-beyond-first-aid) — never the strong model
|
|
||||||
if (isUnsafeAdjacentMessage(message) && action !== 'emergency') {
|
|
||||||
return unsafeRefuseResult(classification, step1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Auth-required intents (personal agenda + ticket reads/email)
|
|
||||||
if (
|
|
||||||
(isAuthRequiredAgendaIntent(intent) || isAuthRequiredTicketIntent(intent))
|
|
||||||
&& !isVerified(session)
|
|
||||||
) {
|
|
||||||
const started = await beginEmailAuth(session, {
|
|
||||||
intent,
|
|
||||||
message,
|
|
||||||
eventName,
|
|
||||||
entities: classification.entities || {},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
reply: started.reply,
|
|
||||||
cards: [],
|
|
||||||
sessionIds: [],
|
|
||||||
classification: publicClassification(classification),
|
|
||||||
intent,
|
|
||||||
modelId: step1.modelId,
|
|
||||||
usage: step1.usage,
|
|
||||||
faqKeysUsed: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Bookmark mutations — write to SessionBookmark (no Call 2)
|
|
||||||
if (isBookmarkMutationIntent(intent) || isBookmarkAction(action)) {
|
|
||||||
const result = await runBookmarkMutation({ bot, session, classification });
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
usage: sumUsage(step1.usage, result.usage),
|
|
||||||
modelId: result.modelId || step1.modelId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) Retrieve this-turn DATA, then Call 2 by domain (email QR / emergency stay code-backed)
|
|
||||||
const result = await completeReadTurn({
|
|
||||||
bot,
|
bot,
|
||||||
event,
|
event,
|
||||||
session,
|
session,
|
||||||
message,
|
message,
|
||||||
history,
|
history,
|
||||||
classification,
|
channel,
|
||||||
});
|
});
|
||||||
return {
|
|
||||||
...result,
|
if (agent.unauthenticated && !isVerified(session)) {
|
||||||
usage: sumUsage(step1.usage, result.usage),
|
const started = await beginEmailAuth(session, {
|
||||||
modelId: result.modelId || step1.modelId,
|
intent: (agent.toolNames && agent.toolNames[0]) || 'get_my_tickets',
|
||||||
};
|
message,
|
||||||
|
eventName,
|
||||||
|
});
|
||||||
|
return emptyTurn({
|
||||||
|
reply: started.reply,
|
||||||
|
intent: agent.toolNames?.[0] || null,
|
||||||
|
classification: agent.toolNames?.length ? { tools: agent.toolNames } : null,
|
||||||
|
modelId: agent.modelId,
|
||||||
|
usage: agent.usage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return toPublicResult(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleChatTurn(opts) {
|
async function handleChatTurn(opts) {
|
||||||
|
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
const { faqAnswerText } = require('../utils/faqAnswer');
|
|
||||||
const {
|
|
||||||
looksLikeFoodPolicy,
|
|
||||||
looksLikeDishAsk,
|
|
||||||
looksLikeItinerary,
|
|
||||||
faqKeyOf,
|
|
||||||
} = require('../utils/domainAction');
|
|
||||||
const { fetchAgendaData, isAgendaIntent } = require('./agendaData.service');
|
|
||||||
const { fetchVenueData, isVenueIntent } = require('./venueData.service');
|
|
||||||
const { fetchLocalData, isLocalIntent } = require('./localData.service');
|
|
||||||
const { fetchTicketData, isTicketIntent } = require('./ticketData.service');
|
|
||||||
const { fetchSpeakerData, isSpeakerIntent } = require('./speakerData.service');
|
|
||||||
const { fetchExhibitorData, isExhibitorIntent } = require('./exhibitorData.service');
|
|
||||||
const { fetchTechData, isTechIntent } = require('./techData.service');
|
|
||||||
const { fetchSystemData, isSystemIntent } = require('./systemData.service');
|
|
||||||
const Event = require('../models/Event');
|
|
||||||
|
|
||||||
const FOOD_FAQ_KEYS = new Set(['venue_food', 'venue_food_beverage']);
|
|
||||||
|
|
||||||
function tokens(text) {
|
|
||||||
return String(text || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s]/g, ' ')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((t) => t.length > 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreFaq(row, message, entities) {
|
|
||||||
const q = String(row?.question || row?.q || '');
|
|
||||||
const key = String(row?.key || row?.category || '');
|
|
||||||
const hay = `${q} ${key}`.toLowerCase();
|
|
||||||
const bits = tokens(message);
|
|
||||||
if (entities?.dish) bits.push(String(entities.dish).toLowerCase());
|
|
||||||
if (entities?.topic) bits.push(String(entities.topic).toLowerCase());
|
|
||||||
let score = 0;
|
|
||||||
for (const b of bits) {
|
|
||||||
if (hay.includes(b)) score += 1;
|
|
||||||
}
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
function alreadyToldSet(session) {
|
|
||||||
return new Set((Array.isArray(session?.alreadyTold) ? session.alreadyTold : []).map(String));
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeBotFaqs(bot) {
|
|
||||||
const rows = Array.isArray(bot?.faqs) ? bot.faqs : [];
|
|
||||||
return rows
|
|
||||||
.map((f) => {
|
|
||||||
const a = faqAnswerText(f);
|
|
||||||
if (!a) return null;
|
|
||||||
const q = String(f?.question || '').trim();
|
|
||||||
const key = faqKeyOf(f);
|
|
||||||
return {
|
|
||||||
q: q || key || 'FAQ',
|
|
||||||
a,
|
|
||||||
key,
|
|
||||||
category: String(f?.category || '').trim() || key,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldSkipFoodFaq({ action, message, entities }) {
|
|
||||||
if (action === 'adjacent') return true;
|
|
||||||
if (looksLikeDishAsk(message, entities?.dish) && !looksLikeFoodPolicy(message)) return true;
|
|
||||||
if (looksLikeItinerary(message)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rankFaqs({ bot, message, entities, action, alreadyTold, existingFaqs }) {
|
|
||||||
const skipFood = shouldSkipFoodFaq({ action, message, entities });
|
|
||||||
const told = alreadyToldSet({ alreadyTold: [...alreadyTold] });
|
|
||||||
const fromBot = serializeBotFaqs(bot);
|
|
||||||
const botByQ = new Map(fromBot.map((f) => [String(f.q || '').toLowerCase(), f]));
|
|
||||||
const pool = existingFaqs == null ? fromBot : existingFaqs;
|
|
||||||
|
|
||||||
const enriched = (Array.isArray(pool) ? pool : []).map((f) => {
|
|
||||||
const hit = botByQ.get(String(f.q || '').toLowerCase());
|
|
||||||
const key = String(f.key || f.category || hit?.key || hit?.category || '').trim();
|
|
||||||
return {
|
|
||||||
q: f.q,
|
|
||||||
a: f.a,
|
|
||||||
key: key || null,
|
|
||||||
category: String(f.category || hit?.category || key || '').trim() || null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const filtered = enriched.filter((f) => {
|
|
||||||
const key = String(f.key || f.category || '');
|
|
||||||
if (key && told.has(key)) return false;
|
|
||||||
if (skipFood && (FOOD_FAQ_KEYS.has(key) || FOOD_FAQ_KEYS.has(String(f.category || '')))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return Boolean(f.a);
|
|
||||||
});
|
|
||||||
|
|
||||||
const scored = filtered
|
|
||||||
.map((f) => ({ f, score: scoreFaq(f, message, entities) }))
|
|
||||||
.sort((a, b) => b.score - a.score);
|
|
||||||
|
|
||||||
const picked = (scored[0]?.score > 0 ? scored.filter((s) => s.score > 0) : scored)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((s) => s.f);
|
|
||||||
|
|
||||||
return picked.map((f) => ({
|
|
||||||
q: f.q,
|
|
||||||
a: f.a,
|
|
||||||
key: f.key || f.category || null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactEventDates(event) {
|
|
||||||
if (!event || typeof event !== 'object') return null;
|
|
||||||
const start =
|
|
||||||
event.startDate || event.start || event.dateFrom || event.startsAt || event.eventStart;
|
|
||||||
const end = event.endDate || event.end || event.dateTo || event.endsAt || event.eventEnd;
|
|
||||||
const out = {};
|
|
||||||
if (start) out.start = start instanceof Date ? start.toISOString() : String(start);
|
|
||||||
if (end) out.end = end instanceof Date ? end.toISOString() : String(end);
|
|
||||||
return Object.keys(out).length ? out : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cityHint(location) {
|
|
||||||
const loc = String(location || '').trim();
|
|
||||||
if (!loc) return null;
|
|
||||||
const parts = loc.split(',').map((p) => p.trim()).filter(Boolean);
|
|
||||||
return parts[parts.length - 1] || loc;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEventContext(event, eventId) {
|
|
||||||
let doc = event;
|
|
||||||
if (!doc?.location || !compactEventDates(doc)) {
|
|
||||||
const id = event?._id || eventId;
|
|
||||||
if (id) {
|
|
||||||
const extra = await Event.findById(id)
|
|
||||||
.select('name location startDate endDate start end dateFrom dateTo startsAt endsAt eventStart eventEnd')
|
|
||||||
.lean();
|
|
||||||
if (extra) doc = { ...extra, ...doc };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
location: String(doc?.location || '').trim() || null,
|
|
||||||
city: cityHint(doc?.location),
|
|
||||||
eventDates: compactEventDates(doc),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function faqKeysUsed(faqs) {
|
|
||||||
return (Array.isArray(faqs) ? faqs : [])
|
|
||||||
.map((f) => String(f.key || f.category || '').trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve this-turn DATA using existing domain fetchers + ranked FAQs.
|
|
||||||
*/
|
|
||||||
async function retrieveForTurn({
|
|
||||||
bot,
|
|
||||||
event,
|
|
||||||
session,
|
|
||||||
classification,
|
|
||||||
message,
|
|
||||||
}) {
|
|
||||||
const intent = classification.primary_intent;
|
|
||||||
const action = classification.action;
|
|
||||||
const domain = classification.domain;
|
|
||||||
const entities = classification.entities || {};
|
|
||||||
const alreadyTold = Array.isArray(session?.alreadyTold) ? session.alreadyTold : [];
|
|
||||||
const eventId = bot.event;
|
|
||||||
const participantId = session?.participant || null;
|
|
||||||
|
|
||||||
let data = { faqs: [], alreadyTold };
|
|
||||||
|
|
||||||
if (action === 'adjacent' || intent === 'event_adjacent') {
|
|
||||||
const sys = await fetchSystemData({
|
|
||||||
intent: 'event_adjacent',
|
|
||||||
bot,
|
|
||||||
event,
|
|
||||||
session,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
const ctx = await loadEventContext(event, eventId);
|
|
||||||
let places = [];
|
|
||||||
let hotels = [];
|
|
||||||
try {
|
|
||||||
if (looksLikeDishAsk(message, entities.dish)) {
|
|
||||||
const food = await fetchVenueData({
|
|
||||||
intent: 'venue_food_beverage',
|
|
||||||
eventId,
|
|
||||||
bot,
|
|
||||||
entities,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
places = Array.isArray(food.places) ? food.places : [];
|
|
||||||
} else {
|
|
||||||
const local = await fetchLocalData({
|
|
||||||
intent: 'tourist_places',
|
|
||||||
eventId,
|
|
||||||
message,
|
|
||||||
bot,
|
|
||||||
});
|
|
||||||
places = Array.isArray(local.places) ? local.places : [];
|
|
||||||
hotels = Array.isArray(local.hotels) ? local.hotels : [];
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[retrieve] adjacent local cache:', err?.message || err);
|
|
||||||
}
|
|
||||||
|
|
||||||
const faqs = rankFaqs({
|
|
||||||
bot,
|
|
||||||
message,
|
|
||||||
entities,
|
|
||||||
action: 'adjacent',
|
|
||||||
alreadyTold,
|
|
||||||
existingFaqs: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
data = {
|
|
||||||
...sys,
|
|
||||||
...ctx,
|
|
||||||
allowGeneralLocal: true,
|
|
||||||
faqs,
|
|
||||||
places,
|
|
||||||
hotels,
|
|
||||||
alreadyTold,
|
|
||||||
dish: entities.dish || null,
|
|
||||||
};
|
|
||||||
data.faqKeysUsed = faqKeysUsed(faqs);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAgendaIntent(intent)) {
|
|
||||||
data = await fetchAgendaData({
|
|
||||||
intent,
|
|
||||||
eventId,
|
|
||||||
entities,
|
|
||||||
participantId,
|
|
||||||
});
|
|
||||||
} else if (isVenueIntent(intent)) {
|
|
||||||
data = await fetchVenueData({
|
|
||||||
intent,
|
|
||||||
eventId,
|
|
||||||
bot,
|
|
||||||
entities,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
} else if (isLocalIntent(intent)) {
|
|
||||||
data = await fetchLocalData({ intent, eventId, message, bot });
|
|
||||||
} else if (isTicketIntent(intent)) {
|
|
||||||
data = await fetchTicketData({
|
|
||||||
intent,
|
|
||||||
eventId,
|
|
||||||
participantId,
|
|
||||||
eventName: event?.name || null,
|
|
||||||
bot,
|
|
||||||
event,
|
|
||||||
});
|
|
||||||
} else if (isSpeakerIntent(intent)) {
|
|
||||||
data = await fetchSpeakerData({ intent, eventId, entities });
|
|
||||||
} else if (isExhibitorIntent(intent)) {
|
|
||||||
data = await fetchExhibitorData({ intent, eventId, bot, entities });
|
|
||||||
} else if (isTechIntent(intent)) {
|
|
||||||
data = await fetchTechData({ intent, bot });
|
|
||||||
} else if (isSystemIntent(intent)) {
|
|
||||||
data = await fetchSystemData({ intent, bot, event, session, message });
|
|
||||||
} else {
|
|
||||||
data = await fetchSystemData({
|
|
||||||
intent: 'out_of_scope',
|
|
||||||
bot,
|
|
||||||
event,
|
|
||||||
session,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ranked = rankFaqs({
|
|
||||||
bot,
|
|
||||||
message,
|
|
||||||
entities,
|
|
||||||
action,
|
|
||||||
alreadyTold,
|
|
||||||
existingFaqs: Array.isArray(data.faqs) ? data.faqs : [],
|
|
||||||
});
|
|
||||||
|
|
||||||
data = {
|
|
||||||
...data,
|
|
||||||
faqs: ranked,
|
|
||||||
alreadyTold,
|
|
||||||
domain,
|
|
||||||
action,
|
|
||||||
};
|
|
||||||
data.faqKeysUsed = faqKeysUsed(ranked);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
retrieveForTurn,
|
|
||||||
rankFaqs,
|
|
||||||
faqKeysUsed,
|
|
||||||
};
|
|
||||||
|
|
@ -1,415 +0,0 @@
|
||||||
const { STEP_1_SYSTEM_PROMPT, resolveDomain, resolveAction } = require('../prompts/step1');
|
|
||||||
const {
|
|
||||||
createClient,
|
|
||||||
converse,
|
|
||||||
responseText,
|
|
||||||
getRegion,
|
|
||||||
getRouterModelId,
|
|
||||||
} = require('../utils/bedrock');
|
|
||||||
const { looksLikeTouristPlacesQuestion } = require('../utils/placeCategoryFilter');
|
|
||||||
const { looksLikeWhoToContact, looksLikeHotelCheckIn } = require('../utils/organizerContact');
|
|
||||||
const { stripNameSalutations } = require('../utils/nameSalutations');
|
|
||||||
const {
|
|
||||||
inferFetchIntent,
|
|
||||||
fromLegacyIntent,
|
|
||||||
looksLikeDishAsk,
|
|
||||||
looksLikeItinerary,
|
|
||||||
looksLikeFoodPolicy,
|
|
||||||
looksLikeCityToVenueTravel,
|
|
||||||
looksLikeEventDeskQuestion,
|
|
||||||
extractTravelOrigin,
|
|
||||||
LEGACY_TO_DOMAIN_ACTION,
|
|
||||||
} = require('../utils/domainAction');
|
|
||||||
|
|
||||||
const REGISTER_RESCUE_FROM_ACTIONS = new Set(['oos', 'greeting', 'read']);
|
|
||||||
|
|
||||||
function looksLikeHowToRegisterQuestion(message) {
|
|
||||||
const t = String(message || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
if (!t) return false;
|
|
||||||
|
|
||||||
const askingHowToJoin = /\bhow (can|do|to) (i |we )?(register|sign ?up|get (a )?(ticket|pass)|buy (a )?(ticket|pass))\b/.test(
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!askingHowToJoin
|
|
||||||
&& /\b(where|find|locate|direction|way to|desk|counter|booth|hall)\b/.test(t)
|
|
||||||
&& /\bregistration\b/.test(t)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (/\b(my (registration|ticket|status)|already registered|check[- ]?in status|payment status)\b/.test(t)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
askingHowToJoin
|
|
||||||
|| /\b(want to|need to|like to|trying to) (register|sign ?up|buy (a )?(ticket|pass))\b/.test(t)
|
|
||||||
|| /\b(buy|purchase) (a |an )?(ticket|pass|registration)\b/.test(t)
|
|
||||||
|| /\bregistration (link|form|page|url|website|online)\b/.test(t)
|
|
||||||
|| /^(how can i register|how do i register|how to register|can i register|register|sign up|sign-up)( for (the |this )?event)?\??$/.test(
|
|
||||||
t,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyEntities() {
|
|
||||||
return {
|
|
||||||
speaker_name: null,
|
|
||||||
session_name: null,
|
|
||||||
session_id: null,
|
|
||||||
location: null,
|
|
||||||
company_name: null,
|
|
||||||
time_reference: null,
|
|
||||||
topic: null,
|
|
||||||
dish: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackClassification(reason) {
|
|
||||||
return {
|
|
||||||
domain: 'system',
|
|
||||||
action: 'oos',
|
|
||||||
primary_intent: 'out_of_scope',
|
|
||||||
confidence: 0,
|
|
||||||
action_category: 'direct_reply',
|
|
||||||
entities: emptyEntities(),
|
|
||||||
context_resolution: {
|
|
||||||
is_followup: false,
|
|
||||||
resolved_from_history: false,
|
|
||||||
},
|
|
||||||
requires_data_fetch: false,
|
|
||||||
_fallback: true,
|
|
||||||
_reason: reason || 'unknown',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripJsonFences(raw) {
|
|
||||||
let text = String(raw || '').trim();
|
|
||||||
if (text.startsWith('```')) {
|
|
||||||
text = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
|
|
||||||
}
|
|
||||||
const start = text.indexOf('{');
|
|
||||||
const end = text.lastIndexOf('}');
|
|
||||||
if (start >= 0 && end > start) {
|
|
||||||
text = text.slice(start, end + 1);
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEntities(raw) {
|
|
||||||
const base = emptyEntities();
|
|
||||||
if (!raw || typeof raw !== 'object') return base;
|
|
||||||
for (const key of Object.keys(base)) {
|
|
||||||
if (raw[key] == null || raw[key] === '') {
|
|
||||||
base[key] = null;
|
|
||||||
} else {
|
|
||||||
const value = typeof raw[key] === 'string' ? raw[key].trim() : raw[key];
|
|
||||||
if (key === 'speaker_name' && typeof value === 'string') {
|
|
||||||
base[key] = stripNameSalutations(value) || null;
|
|
||||||
} else {
|
|
||||||
base[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionCategoryFor(action) {
|
|
||||||
if (action === 'bookmark_add' || action === 'bookmark_remove' || action === 'email_qr') {
|
|
||||||
return 'mutation';
|
|
||||||
}
|
|
||||||
if (action === 'emergency' || action === 'escalate_human') return 'escalation';
|
|
||||||
if (action === 'oos' || action === 'greeting') return 'direct_reply';
|
|
||||||
return 'read';
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachFetchIntent(base, message) {
|
|
||||||
const fetchIntent = inferFetchIntent({
|
|
||||||
domain: base.domain,
|
|
||||||
action: base.action,
|
|
||||||
entities: base.entities,
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
primary_intent: fetchIntent,
|
|
||||||
action_category: actionCategoryFor(base.action),
|
|
||||||
requires_data_fetch: !['oos', 'greeting'].includes(base.action) || fetchIntent === 'lost_and_found',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeClassification(parsed, message = '') {
|
|
||||||
if (!parsed || typeof parsed !== 'object') {
|
|
||||||
return fallbackClassification('invalid_json_object');
|
|
||||||
}
|
|
||||||
|
|
||||||
let domain = resolveDomain(parsed.domain);
|
|
||||||
let action = resolveAction(parsed.action);
|
|
||||||
|
|
||||||
// Model still emitting old primary_intent
|
|
||||||
if ((!domain || !action) && parsed.primary_intent) {
|
|
||||||
const legacy = fromLegacyIntent(String(parsed.primary_intent).trim())
|
|
||||||
|| LEGACY_TO_DOMAIN_ACTION[String(parsed.primary_intent).trim()];
|
|
||||||
if (legacy) {
|
|
||||||
domain = domain || legacy.domain;
|
|
||||||
action = action || legacy.action;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!domain || !action) {
|
|
||||||
return fallbackClassification(
|
|
||||||
`unknown_domain_action:${parsed.domain || ''}:${parsed.action || parsed.primary_intent || ''}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let confidence = Number(parsed.confidence);
|
|
||||||
if (!Number.isFinite(confidence)) confidence = 0;
|
|
||||||
confidence = Math.max(0, Math.min(1, confidence));
|
|
||||||
|
|
||||||
const ctx =
|
|
||||||
parsed.context_resolution && typeof parsed.context_resolution === 'object'
|
|
||||||
? parsed.context_resolution
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const entities = normalizeEntities(parsed.entities);
|
|
||||||
|
|
||||||
return attachFetchIntent(
|
|
||||||
{
|
|
||||||
domain,
|
|
||||||
action,
|
|
||||||
confidence,
|
|
||||||
entities,
|
|
||||||
context_resolution: {
|
|
||||||
is_followup: Boolean(ctx.is_followup),
|
|
||||||
resolved_from_history: Boolean(ctx.resolved_from_history),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHistory(history) {
|
|
||||||
return (Array.isArray(history) ? history.slice(-6) : [])
|
|
||||||
.map((m) => {
|
|
||||||
const role = m?.role === 'assistant' ? 'Assistant' : 'User';
|
|
||||||
const text = String(m?.text || '').trim();
|
|
||||||
return text ? `${role}: ${text}` : null;
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRescues(classification, message) {
|
|
||||||
let next = { ...classification };
|
|
||||||
|
|
||||||
// Mutations / code paths that must run even if Call 1 misses the action.
|
|
||||||
if (
|
|
||||||
REGISTER_RESCUE_FROM_ACTIONS.has(next.action)
|
|
||||||
&& looksLikeHowToRegisterQuestion(message)
|
|
||||||
) {
|
|
||||||
next = attachFetchIntent(
|
|
||||||
{
|
|
||||||
...next,
|
|
||||||
domain: 'ticket',
|
|
||||||
action: 'read',
|
|
||||||
_rescued_from: classification.action,
|
|
||||||
_rescue: 'how_to_register_keyword',
|
|
||||||
},
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
next.primary_intent = 'ticket_how_to_register';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (looksLikeWhoToContact(message)) {
|
|
||||||
next = attachFetchIntent(
|
|
||||||
{
|
|
||||||
...next,
|
|
||||||
domain: 'system',
|
|
||||||
action: 'escalate_human',
|
|
||||||
_rescued_from: classification.action,
|
|
||||||
_rescue: 'who_to_contact_keyword',
|
|
||||||
},
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
} else if (looksLikeHotelCheckIn(message)) {
|
|
||||||
next = attachFetchIntent(
|
|
||||||
{
|
|
||||||
...next,
|
|
||||||
domain: 'local',
|
|
||||||
action: 'read',
|
|
||||||
_rescued_from: classification.action,
|
|
||||||
_rescue: 'hotel_checkin_keyword',
|
|
||||||
},
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
next.primary_intent = 'accommodation';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
['read', 'oos', 'greeting', 'adjacent'].includes(next.action)
|
|
||||||
&& looksLikeCityToVenueTravel(message)
|
|
||||||
&& !looksLikeItinerary(message)
|
|
||||||
&& !looksLikeDishAsk(message, next.entities?.dish)
|
|
||||||
) {
|
|
||||||
const origin = extractTravelOrigin(message, next.entities?.location);
|
|
||||||
next = attachFetchIntent(
|
|
||||||
{
|
|
||||||
...next,
|
|
||||||
domain: 'venue',
|
|
||||||
action: 'read',
|
|
||||||
entities: {
|
|
||||||
...next.entities,
|
|
||||||
location: origin || next.entities?.location || null,
|
|
||||||
},
|
|
||||||
_rescued_from: classification.action,
|
|
||||||
_rescue: 'city_to_venue',
|
|
||||||
},
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
next.primary_intent = 'venue_travel_directions';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
['read', 'oos', 'greeting'].includes(next.action)
|
|
||||||
&& !looksLikeFoodPolicy(message)
|
|
||||||
&& (looksLikeItinerary(message) || looksLikeDishAsk(message, next.entities?.dish))
|
|
||||||
) {
|
|
||||||
const dishMatch = String(message || '').match(
|
|
||||||
/\b(puttu|dosa|idli|appam|porotta|parotta|biryani)\b/i,
|
|
||||||
);
|
|
||||||
const entities = {
|
|
||||||
...next.entities,
|
|
||||||
dish: next.entities?.dish || (dishMatch ? dishMatch[1].toLowerCase() : null),
|
|
||||||
};
|
|
||||||
next = attachFetchIntent(
|
|
||||||
{
|
|
||||||
...next,
|
|
||||||
domain: 'local',
|
|
||||||
action: 'adjacent',
|
|
||||||
entities,
|
|
||||||
_rescued_from: classification.action,
|
|
||||||
_rescue: 'adjacent_keyword',
|
|
||||||
},
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the router refused but the ask is still an event-desk question, recover in-scope.
|
|
||||||
if (next.action === 'oos' || next.action === 'greeting') {
|
|
||||||
if (
|
|
||||||
looksLikeTouristPlacesQuestion(message)
|
|
||||||
&& !looksLikeItinerary(message)
|
|
||||||
&& !looksLikeDishAsk(message, next.entities?.dish)
|
|
||||||
) {
|
|
||||||
next = attachFetchIntent(
|
|
||||||
{
|
|
||||||
...next,
|
|
||||||
domain: 'local',
|
|
||||||
action: 'read',
|
|
||||||
_rescued_from: classification.action,
|
|
||||||
_rescue: 'oos_event_desk',
|
|
||||||
},
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
} else if (looksLikeEventDeskQuestion(message)) {
|
|
||||||
next = attachFetchIntent(
|
|
||||||
{
|
|
||||||
...next,
|
|
||||||
domain: 'ticket',
|
|
||||||
action: 'read',
|
|
||||||
_rescued_from: classification.action,
|
|
||||||
_rescue: 'oos_event_desk',
|
|
||||||
},
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function classifyIntent({ message, history }) {
|
|
||||||
const modelId = getRouterModelId();
|
|
||||||
const client = createClient(getRegion());
|
|
||||||
const recentContext = formatHistory(history);
|
|
||||||
|
|
||||||
const userText = [
|
|
||||||
'RECENT CONVERSATION:',
|
|
||||||
recentContext || '(none)',
|
|
||||||
'',
|
|
||||||
'CURRENT USER MESSAGE:',
|
|
||||||
String(message || '').trim(),
|
|
||||||
'',
|
|
||||||
'Respond with JSON only.',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await converse(client, modelId, {
|
|
||||||
system: STEP_1_SYSTEM_PROMPT,
|
|
||||||
messages: [{ role: 'user', content: [{ text: userText }] }],
|
|
||||||
inferenceConfig: { temperature: 0, topP: 0.1, maxTokens: 400 },
|
|
||||||
logLabel: 'call1',
|
|
||||||
});
|
|
||||||
|
|
||||||
const raw = responseText(response);
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(stripJsonFences(raw));
|
|
||||||
} catch {
|
|
||||||
parsed = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let classification = parsed
|
|
||||||
? normalizeClassification(parsed, message)
|
|
||||||
: fallbackClassification('json_parse_failed');
|
|
||||||
|
|
||||||
classification = applyRescues(classification, message);
|
|
||||||
|
|
||||||
console.log('[step1][call1][classification]', {
|
|
||||||
modelId,
|
|
||||||
parsedOk: Boolean(parsed),
|
|
||||||
classification,
|
|
||||||
usage: {
|
|
||||||
inputTokens: Number(response?.usage?.inputTokens || 0),
|
|
||||||
outputTokens: Number(response?.usage?.outputTokens || 0),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
classification,
|
|
||||||
modelId,
|
|
||||||
usage: {
|
|
||||||
inputTokens: Number(response?.usage?.inputTokens || 0),
|
|
||||||
outputTokens: Number(response?.usage?.outputTokens || 0),
|
|
||||||
},
|
|
||||||
raw,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[step1][call1] failed:', err?.message || err);
|
|
||||||
return {
|
|
||||||
classification: fallbackClassification(`llm_error:${err?.message || 'unknown'}`),
|
|
||||||
modelId,
|
|
||||||
usage: { inputTokens: 0, outputTokens: 0 },
|
|
||||||
raw: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatClassificationReply(classification) {
|
|
||||||
const domain = classification?.domain || 'system';
|
|
||||||
const action = classification?.action || 'oos';
|
|
||||||
const conf = Number(classification?.confidence);
|
|
||||||
const confLabel = Number.isFinite(conf) ? conf.toFixed(2) : '0.00';
|
|
||||||
return `Domain: ${domain} · ${action} · ${confLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
classifyIntent,
|
|
||||||
formatClassificationReply,
|
|
||||||
normalizeClassification,
|
|
||||||
fallbackClassification,
|
|
||||||
applyRescues,
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -21,12 +21,12 @@ const TICKET_INTENTS = new Set([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const REGISTER_FAQ_CATEGORIES = new Set([
|
const REGISTER_FAQ_CATEGORIES = new Set([
|
||||||
'ticket_checkin',
|
|
||||||
'registration_update',
|
'registration_update',
|
||||||
'checkin_timing',
|
|
||||||
'refund_policy',
|
'refund_policy',
|
||||||
|
'how_to_register',
|
||||||
|
'ticket_purchase',
|
||||||
]);
|
]);
|
||||||
const REGISTER_FAQ_HINT = /\b(register|registration|ticket|sign ?up|pass|enrol|enroll)\b/i;
|
const REGISTER_FAQ_HINT = /\b(how to register|registration link|sign ?up|enrol|enroll|buy (a )?(ticket|pass))\b/i;
|
||||||
const FAQ_ANSWER_MAX = 600;
|
const FAQ_ANSWER_MAX = 600;
|
||||||
|
|
||||||
const AUTH_REQUIRED_TICKET_INTENTS = new Set([
|
const AUTH_REQUIRED_TICKET_INTENTS = new Set([
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,22 @@ const Session = require('../models/Session');
|
||||||
const { faqAnswerText, mapMediaFromBot } = require('../utils/faqAnswer');
|
const { faqAnswerText, mapMediaFromBot } = require('../utils/faqAnswer');
|
||||||
const { transportModesFromBot } = require('../utils/transportModes');
|
const { transportModesFromBot } = require('../utils/transportModes');
|
||||||
const { looksLikeFrontDesk } = require('../utils/organizerContact');
|
const { looksLikeFrontDesk } = require('../utils/organizerContact');
|
||||||
const { extractTravelOrigin } = require('../utils/domainAction');
|
|
||||||
|
const TRAVEL_ORIGIN_STOP = new Set(['the', 'this', 'that', 'there', 'here', 'home', 'work', 'my', 'our']);
|
||||||
|
|
||||||
|
function extractTravelOrigin(message, existing) {
|
||||||
|
const already = String(existing || '').trim();
|
||||||
|
if (already) return already;
|
||||||
|
const t = String(message || '');
|
||||||
|
const m =
|
||||||
|
t.match(/\b(?:travell?ing|traveling|coming|arriving)\s+from\s+([A-Za-z][A-Za-z.\s-]{1,32}?)(?:\s*[,.?!]|$)/i)
|
||||||
|
|| t.match(/\bfrom\s+([A-Za-z][A-Za-z.]{2,32})\b/i);
|
||||||
|
if (!m) return null;
|
||||||
|
const city = String(m[1] || '').replace(/[.,]+$/g, '').trim();
|
||||||
|
const first = city.split(/\s+/)[0].toLowerCase();
|
||||||
|
if (!city || TRAVEL_ORIGIN_STOP.has(first)) return null;
|
||||||
|
return city;
|
||||||
|
}
|
||||||
|
|
||||||
const VENUE_INTENTS = new Set([
|
const VENUE_INTENTS = new Set([
|
||||||
'venue_map_request',
|
'venue_map_request',
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ const {
|
||||||
sendTemplateMessage,
|
sendTemplateMessage,
|
||||||
sendMessageWithCtas,
|
sendMessageWithCtas,
|
||||||
} = require('../utils/whatsappSend');
|
} = require('../utils/whatsappSend');
|
||||||
const { interpolateReception } = require('../utils/receptionCopy');
|
const { interpolateReception, interpolateCtas, buildEngagementUrl } = require('../utils/receptionCopy');
|
||||||
const { logWhatsApp, maskPhone } = require('../utils/whatsappLog');
|
const { logWhatsApp, maskPhone } = require('../utils/whatsappLog');
|
||||||
const { listUpcomingSessions } = require('./agendaData.service');
|
const { listUpcomingSessions } = require('./agendaData.service');
|
||||||
const { normalizeWhatsAppCtas } = require('../utils/whatsappCtas');
|
const { normalizeWhatsAppCtas, isHttpCtaUrl, toPlainCtas } = require('../utils/whatsappCtas');
|
||||||
const Participant = require('../models/Participant');
|
const Participant = require('../models/Participant');
|
||||||
|
|
||||||
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
@ -125,6 +125,7 @@ async function scheduleDripsForSession(session, bot, { phase }) {
|
||||||
delayMinutes: Number(drip.delayMinutes) || 0,
|
delayMinutes: Number(drip.delayMinutes) || 0,
|
||||||
trigger,
|
trigger,
|
||||||
contentKind,
|
contentKind,
|
||||||
|
ctaCount: contentKind === 'upcoming_agenda' ? 0 : normalizeWhatsAppCtas(drip.ctas).length,
|
||||||
agendaCount: contentKind === 'upcoming_agenda' ? clampAgendaCount(drip.agendaCount) : undefined,
|
agendaCount: contentKind === 'upcoming_agenda' ? clampAgendaCount(drip.agendaCount) : undefined,
|
||||||
dueNow: sendAt.getTime() <= now.getTime(),
|
dueNow: sendAt.getTime() <= now.getTime(),
|
||||||
});
|
});
|
||||||
|
|
@ -184,11 +185,63 @@ async function sendDueDrips() {
|
||||||
const ev = await Event.findById(session.eventId).select('name').lean();
|
const ev = await Event.findById(session.eventId).select('name').lean();
|
||||||
eventName = ev?.name || '';
|
eventName = ev?.name || '';
|
||||||
}
|
}
|
||||||
const body = interpolateReception(job.body, {
|
const interpVars = {
|
||||||
profileName: session.waProfileName,
|
profileName: session.waProfileName,
|
||||||
firstName,
|
firstName,
|
||||||
eventName,
|
eventName,
|
||||||
|
eventId: session.eventId
|
||||||
|
? String(session.eventId)
|
||||||
|
: bot.event
|
||||||
|
? String(bot.event)
|
||||||
|
: '',
|
||||||
|
participantId: participant?._id
|
||||||
|
? String(participant._id)
|
||||||
|
: session.participant
|
||||||
|
? String(session.participant)
|
||||||
|
: '',
|
||||||
|
};
|
||||||
|
let rawCtas = toPlainCtas(job.ctas);
|
||||||
|
if (!rawCtas.length && job.dripId) {
|
||||||
|
const drips = Array.isArray(bot.reception?.drips) ? bot.reception.drips : [];
|
||||||
|
const dripCfg =
|
||||||
|
drips.find((d) => d && String(d._id) === String(job.dripId)) ||
|
||||||
|
drips.find((d) => dripIdentity(d) === String(job.dripId));
|
||||||
|
if (dripCfg && Array.isArray(dripCfg.ctas) && dripCfg.ctas.length) {
|
||||||
|
rawCtas = toPlainCtas(dripCfg.ctas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const needsUniqueUrl =
|
||||||
|
/\{engagementUrl\}/i.test(String(job.body || '')) ||
|
||||||
|
rawCtas.some((c) => /\{engagementUrl\}|\{participantId\}/i.test(String(c.value || '')));
|
||||||
|
if (needsUniqueUrl && !interpVars.participantId) {
|
||||||
|
logWhatsApp('drip.hold', {
|
||||||
|
jobId: String(job._id),
|
||||||
|
reason: 'waiting_for_engagement_url',
|
||||||
|
to: maskPhone(session.phoneNumber),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const builtUrl = buildEngagementUrl(interpVars.eventId, interpVars.participantId);
|
||||||
|
const body = interpolateReception(job.body, {
|
||||||
|
...interpVars,
|
||||||
|
engagementUrl: builtUrl,
|
||||||
});
|
});
|
||||||
|
const ctas = interpolateCtas(rawCtas, { ...interpVars, engagementUrl: builtUrl }).map((cta) => {
|
||||||
|
if (!cta || cta.type !== 'url') return cta;
|
||||||
|
let value = String(cta.value || '').trim();
|
||||||
|
if (!isHttpCtaUrl(value) && builtUrl) value = builtUrl;
|
||||||
|
return { type: 'url', label: cta.label || 'Open', value };
|
||||||
|
});
|
||||||
|
const urlCta = ctas.find((c) => c && c.type === 'url');
|
||||||
|
if (urlCta && !isHttpCtaUrl(urlCta.value)) {
|
||||||
|
logWhatsApp('drip.hold', {
|
||||||
|
jobId: String(job._id),
|
||||||
|
reason: 'cta_url_not_ready',
|
||||||
|
to: maskPhone(session.phoneNumber),
|
||||||
|
url: String(urlCta.value || '').slice(0, 80),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const isAgenda = job.contentKind === 'upcoming_agenda';
|
const isAgenda = job.contentKind === 'upcoming_agenda';
|
||||||
|
|
||||||
if (outsideWindow) {
|
if (outsideWindow) {
|
||||||
|
|
@ -244,12 +297,12 @@ async function sendDueDrips() {
|
||||||
body,
|
body,
|
||||||
mediaType: job.mediaType,
|
mediaType: job.mediaType,
|
||||||
mediaUrl: job.mediaUrl,
|
mediaUrl: job.mediaUrl,
|
||||||
ctas: job.ctas,
|
ctas,
|
||||||
});
|
});
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
if (sendErr && sendErr.code === 'empty_body') {
|
if (sendErr && (sendErr.code === 'empty_body' || sendErr.code === 'cta_url_not_ready')) {
|
||||||
job.status = 'skipped';
|
job.status = 'skipped';
|
||||||
job.lastError = 'empty_body';
|
job.lastError = sendErr.code;
|
||||||
await job.save();
|
await job.save();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -259,7 +312,11 @@ async function sendDueDrips() {
|
||||||
job.status = 'sent';
|
job.status = 'sent';
|
||||||
job.sentAt = new Date();
|
job.sentAt = new Date();
|
||||||
await job.save();
|
await job.save();
|
||||||
logWhatsApp('drip.sent', { jobId: String(job._id), to: maskPhone(session.phoneNumber) });
|
logWhatsApp('drip.sent', {
|
||||||
|
jobId: String(job._id),
|
||||||
|
to: maskPhone(session.phoneNumber),
|
||||||
|
ctaCount: Array.isArray(ctas) ? ctas.length : 0,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[whatsappDrip]', err.message);
|
console.error('[whatsappDrip]', err.message);
|
||||||
job.status = 'failed';
|
job.status = 'failed';
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,16 @@ function inboundText(messageData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFollowup({ bot, event, session, messageData }) {
|
async function handleFollowup({ bot, event, session, messageData }) {
|
||||||
|
if (!bot?.enabled || bot.status !== 'ACTIVE') {
|
||||||
|
logWhatsApp('followup.skip', {
|
||||||
|
reason: 'chatbot_disabled',
|
||||||
|
botId: bot?._id ? String(bot._id) : '',
|
||||||
|
enabled: Boolean(bot?.enabled),
|
||||||
|
status: bot?.status || '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const type = String(messageData?.type || '');
|
const type = String(messageData?.type || '');
|
||||||
const creds = await getOrgWhatsAppCredentials(String(bot.organization));
|
const creds = await getOrgWhatsAppCredentials(String(bot.organization));
|
||||||
if (!creds?.accessToken || !creds.phoneNumberId) {
|
if (!creds?.accessToken || !creds.phoneNumberId) {
|
||||||
|
|
@ -53,7 +63,13 @@ async function handleFollowup({ bot, event, session, messageData }) {
|
||||||
}
|
}
|
||||||
session.lastSeenAt = new Date();
|
session.lastSeenAt = new Date();
|
||||||
|
|
||||||
const result = await handleChatTurn({ bot, event, session, message: msg });
|
const result = await handleChatTurn({
|
||||||
|
bot,
|
||||||
|
event,
|
||||||
|
session,
|
||||||
|
message: msg,
|
||||||
|
channel: 'whatsapp',
|
||||||
|
});
|
||||||
const reply = String(result.reply || '').trim().slice(0, 2500) || 'Sorry, I could not respond just now.';
|
const reply = String(result.reply || '').trim().slice(0, 2500) || 'Sorry, I could not respond just now.';
|
||||||
const inTok = result.usage?.inputTokens || 0;
|
const inTok = result.usage?.inputTokens || 0;
|
||||||
const outTok = result.usage?.outputTokens || 0;
|
const outTok = result.usage?.outputTokens || 0;
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,17 @@ async function processInboundMessage(jobData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!bot.enabled || bot.status !== 'ACTIVE') {
|
||||||
|
logWhatsApp('followup.skip', {
|
||||||
|
reason: 'chatbot_disabled',
|
||||||
|
botId: String(bot._id),
|
||||||
|
enabled: Boolean(bot.enabled),
|
||||||
|
status: bot.status || '',
|
||||||
|
});
|
||||||
|
await session.save();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await handleFollowup({ bot, event, session, messageData });
|
await handleFollowup({ bot, event, session, messageData });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,8 @@ async function sendWelcomeAndDrips(bot, session, participant, eventName) {
|
||||||
firstName,
|
firstName,
|
||||||
profileName,
|
profileName,
|
||||||
eventName,
|
eventName,
|
||||||
|
eventId: session.eventId || '',
|
||||||
|
participantId: participant?._id ? String(participant._id) : '',
|
||||||
});
|
});
|
||||||
await sendToSession(bot, session, text);
|
await sendToSession(bot, session, text);
|
||||||
session.welcomeSentAt = new Date();
|
session.welcomeSentAt = new Date();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ const {
|
||||||
} = require('@aws-sdk/client-bedrock-runtime');
|
} = require('@aws-sdk/client-bedrock-runtime');
|
||||||
|
|
||||||
const DEFAULT_ROUTER_MODEL_ID = 'amazon.nova-lite-v1:0';
|
const DEFAULT_ROUTER_MODEL_ID = 'amazon.nova-lite-v1:0';
|
||||||
const DEFAULT_CALL2_MODEL_ID = 'anthropic.claude-3-haiku-20240307-v1:0';
|
const DEFAULT_CALL2_MODEL_ID = 'global.anthropic.claude-haiku-4-5-20251001-v1:0';
|
||||||
|
const DEFAULT_AGENT_MODEL_ID = DEFAULT_CALL2_MODEL_ID;
|
||||||
|
|
||||||
function getRegion() {
|
function getRegion() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -23,6 +24,12 @@ function getCall2ModelId() {
|
||||||
return process.env.BEDROCK_CALL2_MODEL_ID || DEFAULT_CALL2_MODEL_ID;
|
return process.env.BEDROCK_CALL2_MODEL_ID || DEFAULT_CALL2_MODEL_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single model for tool routing + synthesis. */
|
||||||
|
function getAgentModelId() {
|
||||||
|
const id = String(process.env.BEDROCK_AGENT_MODEL_ID || '').trim();
|
||||||
|
return id || getCall2ModelId();
|
||||||
|
}
|
||||||
|
|
||||||
/** Stronger Call 2 for event-adjacent only. Unset → same as Haiku Call 2. */
|
/** Stronger Call 2 for event-adjacent only. Unset → same as Haiku Call 2. */
|
||||||
function getCall2StrongModelId() {
|
function getCall2StrongModelId() {
|
||||||
const id = String(process.env.BEDROCK_CALL2_MODEL_STRONG_ID || '').trim();
|
const id = String(process.env.BEDROCK_CALL2_MODEL_STRONG_ID || '').trim();
|
||||||
|
|
@ -36,7 +43,13 @@ function getCall2StrongModelId() {
|
||||||
function resolveModelId(modelId, region = getRegion()) {
|
function resolveModelId(modelId, region = getRegion()) {
|
||||||
const id = String(modelId || '').trim();
|
const id = String(modelId || '').trim();
|
||||||
if (!id || id.startsWith('arn:aws:bedrock:')) return id;
|
if (!id || id.startsWith('arn:aws:bedrock:')) return id;
|
||||||
if (/^(us|eu|apac)\./.test(id)) return id;
|
if (/^(us|eu|apac|au|jp|global)\./.test(id)) return id;
|
||||||
|
|
||||||
|
// Haiku 4.5 has no in-region ID in ap-south-1 — use the global inference profile.
|
||||||
|
if (/^anthropic\.claude-haiku-4-5/.test(id)) {
|
||||||
|
return `global.${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (id.startsWith('anthropic.')) return id;
|
if (id.startsWith('anthropic.')) return id;
|
||||||
|
|
||||||
const mode = String(process.env.BEDROCK_USE_INFERENCE_PROFILES ?? 'auto').toLowerCase();
|
const mode = String(process.env.BEDROCK_USE_INFERENCE_PROFILES ?? 'auto').toLowerCase();
|
||||||
|
|
@ -62,31 +75,81 @@ function createClient(region = getRegion()) {
|
||||||
return new BedrockRuntimeClient(config);
|
return new BedrockRuntimeClient(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
function responseText(response) {
|
|
||||||
return (
|
|
||||||
response.output?.message?.content
|
|
||||||
?.map((block) => block.text)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n') || ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLlmLogEnabled() {
|
function isLlmLogEnabled() {
|
||||||
const v = String(process.env.BEDROCK_LOG_LLM || '').toLowerCase();
|
const v = String(process.env.BEDROCK_LOG_LLM || '').toLowerCase();
|
||||||
return v === 'true' || v === '1' || v === 'yes';
|
return v === 'true' || v === '1' || v === 'yes';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contentSummary(content) {
|
||||||
|
const blocks = Array.isArray(content) ? content : [];
|
||||||
|
return {
|
||||||
|
text: blocks.map((b) => b?.text || '').filter(Boolean).join('\n'),
|
||||||
|
toolUses: blocks
|
||||||
|
.filter((b) => b?.toolUse)
|
||||||
|
.map((b) => ({ name: b.toolUse.name, toolUseId: b.toolUse.toolUseId })),
|
||||||
|
toolResults: blocks
|
||||||
|
.filter((b) => b?.toolResult)
|
||||||
|
.map((b) => b.toolResult.toolUseId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function messageTexts(messages) {
|
function messageTexts(messages) {
|
||||||
if (!Array.isArray(messages)) return [];
|
if (!Array.isArray(messages)) return [];
|
||||||
return messages.map((m) => ({
|
return messages.map((m) => ({
|
||||||
role: m?.role,
|
role: m?.role,
|
||||||
text: (Array.isArray(m?.content) ? m.content : [])
|
...contentSummary(m?.content),
|
||||||
.map((b) => b?.text || '')
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n'),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function responseText(response) {
|
||||||
|
return contentSummary(response?.output?.message?.content).text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolUseBlocks(response) {
|
||||||
|
const blocks = Array.isArray(response?.output?.message?.content)
|
||||||
|
? response.output.message.content
|
||||||
|
: [];
|
||||||
|
return blocks.filter((b) => b?.toolUse?.name && b.toolUse.toolUseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessageFromResponse(response) {
|
||||||
|
const msg = response?.output?.message;
|
||||||
|
if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.content)) return null;
|
||||||
|
const content = sanitizeAssistantContent(msg.content);
|
||||||
|
if (!content.length) return null;
|
||||||
|
return { role: 'assistant', content };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip SDK-only fields so the next Converse request is valid. */
|
||||||
|
function sanitizeAssistantContent(content) {
|
||||||
|
const blocks = [];
|
||||||
|
for (const b of Array.isArray(content) ? content : []) {
|
||||||
|
if (b?.toolUse?.toolUseId && b.toolUse.name) {
|
||||||
|
let input = b.toolUse.input;
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
try {
|
||||||
|
input = JSON.parse(input);
|
||||||
|
} catch {
|
||||||
|
input = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) input = {};
|
||||||
|
blocks.push({
|
||||||
|
toolUse: {
|
||||||
|
toolUseId: String(b.toolUse.toolUseId),
|
||||||
|
name: String(b.toolUse.name),
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof b?.text === 'string' && b.text.length) {
|
||||||
|
blocks.push({ text: b.text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
/** Claude 4.x on Bedrock rejects inference configs that set both temperature and topP. */
|
/** Claude 4.x on Bedrock rejects inference configs that set both temperature and topP. */
|
||||||
function inferenceForbidsTempAndTopP(modelId) {
|
function inferenceForbidsTempAndTopP(modelId) {
|
||||||
const id = String(modelId || '').toLowerCase();
|
const id = String(modelId || '').toLowerCase();
|
||||||
|
|
@ -117,21 +180,54 @@ function supportsPromptCache(modelId) {
|
||||||
const id = String(modelId || '').toLowerCase();
|
const id = String(modelId || '').toLowerCase();
|
||||||
if (/claude-3-haiku-20240307/.test(id)) return false;
|
if (/claude-3-haiku-20240307/.test(id)) return false;
|
||||||
if (id.includes('amazon.nova-')) return true;
|
if (id.includes('amazon.nova-')) return true;
|
||||||
|
if (/claude-haiku-4-5/.test(id)) return true;
|
||||||
if (/claude-(haiku|sonnet|opus)-4/.test(id) || /anthropic\.claude-4/.test(id)) return true;
|
if (/claude-(haiku|sonnet|opus)-4/.test(id) || /anthropic\.claude-4/.test(id)) return true;
|
||||||
if (/claude-3-7/.test(id)) return true;
|
if (/claude-3-7/.test(id)) return true;
|
||||||
if (/claude-3-5-sonnet/.test(id) || /claude-3-5-haiku/.test(id)) return true;
|
if (/claude-3-5-sonnet/.test(id) || /claude-3-5-haiku/.test(id)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function systemBlocksFor(system, modelId) {
|
/** 1-hour TTL is supported on Haiku 4.5 and Claude 4.x; others default to 5 minutes. */
|
||||||
if (!system) return undefined;
|
function cacheTtlFor(modelId) {
|
||||||
const blocks = [{ text: system }];
|
const id = String(modelId || '').toLowerCase();
|
||||||
if (isPromptCacheEnabled() && supportsPromptCache(modelId)) {
|
if (/claude-haiku-4-5/.test(id)) return '1h';
|
||||||
blocks.push({ cachePoint: { type: 'default' } });
|
if (/claude-(sonnet|opus)-4/.test(id) || /anthropic\.claude-4/.test(id)) return '1h';
|
||||||
}
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachePointBlock(modelId) {
|
||||||
|
if (!isPromptCacheEnabled() || !supportsPromptCache(modelId)) return null;
|
||||||
|
const ttl = cacheTtlFor(modelId);
|
||||||
|
return {
|
||||||
|
cachePoint: {
|
||||||
|
type: 'default',
|
||||||
|
...(ttl ? { ttl } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemBlocksFor(system, modelId, extraSystem = []) {
|
||||||
|
const texts = [
|
||||||
|
system,
|
||||||
|
...(Array.isArray(extraSystem) ? extraSystem : [extraSystem]),
|
||||||
|
].filter((t) => typeof t === 'string' && t.trim());
|
||||||
|
if (!texts.length) return undefined;
|
||||||
|
const blocks = texts.map((text) => ({ text }));
|
||||||
|
const point = cachePointBlock(modelId);
|
||||||
|
if (point) blocks.push(point);
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withToolCache(toolConfig, modelId) {
|
||||||
|
if (!toolConfig?.tools?.length) return toolConfig;
|
||||||
|
const point = cachePointBlock(modelId);
|
||||||
|
if (!point || toolConfig.tools.some((t) => t?.cachePoint)) return toolConfig;
|
||||||
|
return {
|
||||||
|
...toolConfig,
|
||||||
|
tools: [...toolConfig.tools, point],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function usageForLog(usage) {
|
function usageForLog(usage) {
|
||||||
if (!usage) return null;
|
if (!usage) return null;
|
||||||
return {
|
return {
|
||||||
|
|
@ -146,19 +242,28 @@ function usageForLog(usage) {
|
||||||
/**
|
/**
|
||||||
* @param {import('@aws-sdk/client-bedrock-runtime').BedrockRuntimeClient} client
|
* @param {import('@aws-sdk/client-bedrock-runtime').BedrockRuntimeClient} client
|
||||||
* @param {string} modelId
|
* @param {string} modelId
|
||||||
* @param {{ system?: string, messages: object[], inferenceConfig?: object, logLabel?: string }} opts
|
* @param {{ system?: string, extraSystem?: string|string[], messages: object[], inferenceConfig?: object, logLabel?: string, toolConfig?: object, toolChoice?: object }} opts
|
||||||
*/
|
*/
|
||||||
async function converse(client, modelId, opts) {
|
async function converse(client, modelId, opts) {
|
||||||
const { system, messages, inferenceConfig, logLabel } = opts;
|
const { system, extraSystem, messages, inferenceConfig, logLabel, toolConfig, toolChoice } = opts;
|
||||||
const region = getRegion();
|
const region = getRegion();
|
||||||
const resolvedModelId = resolveModelId(modelId, region);
|
const resolvedModelId = resolveModelId(modelId, region);
|
||||||
const systemBlocks = systemBlocksFor(system, resolvedModelId);
|
const systemBlocks = systemBlocksFor(system, resolvedModelId, extraSystem);
|
||||||
const mergedInferenceConfig = sanitizeInferenceConfig(resolvedModelId, {
|
const mergedInferenceConfig = sanitizeInferenceConfig(resolvedModelId, {
|
||||||
maxTokens: 400,
|
maxTokens: 400,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
topP: 0.1,
|
topP: 0.1,
|
||||||
...inferenceConfig,
|
...inferenceConfig,
|
||||||
});
|
});
|
||||||
|
const mergedToolConfig = toolConfig
|
||||||
|
? withToolCache(
|
||||||
|
{
|
||||||
|
...toolConfig,
|
||||||
|
...(toolChoice ? { toolChoice } : {}),
|
||||||
|
},
|
||||||
|
resolvedModelId,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
const label = logLabel || 'converse';
|
const label = logLabel || 'converse';
|
||||||
const shouldLog = Boolean(logLabel) || isLlmLogEnabled();
|
const shouldLog = Boolean(logLabel) || isLlmLogEnabled();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
@ -169,7 +274,12 @@ async function converse(client, modelId, opts) {
|
||||||
modelId: resolvedModelId,
|
modelId: resolvedModelId,
|
||||||
requestedModelId: modelId !== resolvedModelId ? modelId : undefined,
|
requestedModelId: modelId !== resolvedModelId ? modelId : undefined,
|
||||||
inferenceConfig: mergedInferenceConfig,
|
inferenceConfig: mergedInferenceConfig,
|
||||||
promptCache: Boolean(systemBlocks?.some((b) => b.cachePoint)),
|
promptCache: Boolean(
|
||||||
|
systemBlocks?.some((b) => b.cachePoint)
|
||||||
|
|| mergedToolConfig?.tools?.some((t) => t?.cachePoint),
|
||||||
|
),
|
||||||
|
tools: mergedToolConfig?.tools?.map((t) => t?.toolSpec?.name).filter(Boolean),
|
||||||
|
toolChoice: mergedToolConfig?.toolChoice || null,
|
||||||
system,
|
system,
|
||||||
messages: messageTexts(messages),
|
messages: messageTexts(messages),
|
||||||
});
|
});
|
||||||
|
|
@ -182,6 +292,7 @@ async function converse(client, modelId, opts) {
|
||||||
...(systemBlocks ? { system: systemBlocks } : {}),
|
...(systemBlocks ? { system: systemBlocks } : {}),
|
||||||
messages,
|
messages,
|
||||||
inferenceConfig: mergedInferenceConfig,
|
inferenceConfig: mergedInferenceConfig,
|
||||||
|
...(mergedToolConfig ? { toolConfig: mergedToolConfig } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -192,6 +303,7 @@ async function converse(client, modelId, opts) {
|
||||||
usage: usageForLog(response.usage),
|
usage: usageForLog(response.usage),
|
||||||
stopReason: response.stopReason || null,
|
stopReason: response.stopReason || null,
|
||||||
text: responseText(response),
|
text: responseText(response),
|
||||||
|
toolUses: toolUseBlocks(response).map((b) => b.toolUse.name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,6 +315,7 @@ async function converse(client, modelId, opts) {
|
||||||
latencyMs: Date.now() - startedAt,
|
latencyMs: Date.now() - startedAt,
|
||||||
name: err?.name || err?.Code || 'Error',
|
name: err?.name || err?.Code || 'Error',
|
||||||
message: err?.message || String(err),
|
message: err?.message || String(err),
|
||||||
|
httpStatusCode: err?.$metadata?.httpStatusCode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|
@ -212,12 +325,17 @@ async function converse(client, modelId, opts) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DEFAULT_ROUTER_MODEL_ID,
|
DEFAULT_ROUTER_MODEL_ID,
|
||||||
DEFAULT_CALL2_MODEL_ID,
|
DEFAULT_CALL2_MODEL_ID,
|
||||||
|
DEFAULT_AGENT_MODEL_ID,
|
||||||
getRegion,
|
getRegion,
|
||||||
getRouterModelId,
|
getRouterModelId,
|
||||||
getCall2ModelId,
|
getCall2ModelId,
|
||||||
getCall2StrongModelId,
|
getCall2StrongModelId,
|
||||||
|
getAgentModelId,
|
||||||
resolveModelId,
|
resolveModelId,
|
||||||
createClient,
|
createClient,
|
||||||
converse,
|
converse,
|
||||||
responseText,
|
responseText,
|
||||||
|
toolUseBlocks,
|
||||||
|
assistantMessageFromResponse,
|
||||||
|
sanitizeAssistantContent,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,730 +0,0 @@
|
||||||
/**
|
|
||||||
* Compact Call 2 LLM context: drop nulls/empties/duplicates.
|
|
||||||
* Full session objects stay in memory for card building.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DETAIL_INTENTS = new Set(['agenda_session_details', 'agenda_download_materials']);
|
|
||||||
|
|
||||||
function isEmpty(value) {
|
|
||||||
if (value == null) return true;
|
|
||||||
if (typeof value === 'string') return !value.trim();
|
|
||||||
if (Array.isArray(value)) return value.length === 0;
|
|
||||||
if (typeof value === 'object') return Object.keys(value).length === 0;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function omitEmpty(obj) {
|
|
||||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
|
|
||||||
const out = {};
|
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
|
||||||
if (isEmpty(v)) continue;
|
|
||||||
out[k] = v;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactEntities(entities) {
|
|
||||||
return omitEmpty(entities && typeof entities === 'object' ? entities : {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function alreadyToldField(src) {
|
|
||||||
const keys = Array.isArray(src?.alreadyTold)
|
|
||||||
? [...new Set(src.alreadyTold.map((k) => String(k || '').trim()).filter(Boolean))].slice(0, 24)
|
|
||||||
: [];
|
|
||||||
return keys.length ? keys : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slimTrack(track) {
|
|
||||||
if (!track) return undefined;
|
|
||||||
if (typeof track === 'string') return track;
|
|
||||||
const name = String(track.name || '').trim();
|
|
||||||
const hall = String(track.hall || '').trim();
|
|
||||||
if (!name && !hall) return undefined;
|
|
||||||
return hall ? `${name} · ${hall}` : name || hall;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slimSpeakers(speakers) {
|
|
||||||
if (!Array.isArray(speakers) || !speakers.length) return undefined;
|
|
||||||
const names = speakers
|
|
||||||
.map((sp) => (typeof sp === 'string' ? sp : sp?.name))
|
|
||||||
.map((n) => String(n || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 4);
|
|
||||||
return names.length ? names : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTopicDuration(duration) {
|
|
||||||
const raw = String(duration || '').trim();
|
|
||||||
if (!raw) return '';
|
|
||||||
const match = raw.match(/^(\d+(?:\s*\+\s*\d+)?)\s*(m|min|mins|minutes)?$/i);
|
|
||||||
if (match) return `${match[1].replace(/\s+/g, '')}m`;
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slimTopics(topics) {
|
|
||||||
if (!Array.isArray(topics) || !topics.length) return undefined;
|
|
||||||
const lines = topics
|
|
||||||
.map((t) => {
|
|
||||||
if (!t) return null;
|
|
||||||
if (typeof t === 'string') return t.trim() || null;
|
|
||||||
const title = String(t.title || '').trim();
|
|
||||||
if (!title) return null;
|
|
||||||
const dur = formatTopicDuration(t.duration);
|
|
||||||
const role = String(t.role || '').trim();
|
|
||||||
let line = title;
|
|
||||||
if (dur) line += ` (${dur})`;
|
|
||||||
if (role) line += ` · ${role}`;
|
|
||||||
return line;
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 8);
|
|
||||||
return lines.length ? lines : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slimResources(resources) {
|
|
||||||
if (!Array.isArray(resources) || !resources.length) return undefined;
|
|
||||||
const rows = resources
|
|
||||||
.map((r) => {
|
|
||||||
const url = String(r?.url || '').trim();
|
|
||||||
if (!url) return null;
|
|
||||||
return omitEmpty({
|
|
||||||
name: String(r.name || 'Resource').trim().slice(0, 80),
|
|
||||||
url: url.slice(0, 300),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 6);
|
|
||||||
return rows.length ? rows : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slimSessionForLlm(session, intent) {
|
|
||||||
if (!session) return null;
|
|
||||||
const detail = DETAIL_INTENTS.has(intent);
|
|
||||||
|
|
||||||
return omitEmpty({
|
|
||||||
id: session.id,
|
|
||||||
title: session.title,
|
|
||||||
when: session.timeRangeLocal || null,
|
|
||||||
track: slimTrack(session.track),
|
|
||||||
type: detail ? session.type || null : null,
|
|
||||||
speakers: slimSpeakers(session.speakers),
|
|
||||||
// Always include topics for detail intents so Call 2 can match by topic
|
|
||||||
topics: detail ? slimTopics(session.topics) : undefined,
|
|
||||||
resources: intent === 'agenda_download_materials' ? slimResources(session.resources) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function slimConflicts(conflicts) {
|
|
||||||
if (!Array.isArray(conflicts) || !conflicts.length) return undefined;
|
|
||||||
return conflicts.slice(0, 12).map((c) =>
|
|
||||||
omitEmpty({
|
|
||||||
a: omitEmpty({
|
|
||||||
id: c?.a?.id,
|
|
||||||
title: c?.a?.title,
|
|
||||||
when: c?.a?.timeRangeLocal || null,
|
|
||||||
}),
|
|
||||||
b: omitEmpty({
|
|
||||||
id: c?.b?.id,
|
|
||||||
title: c?.b?.title,
|
|
||||||
when: c?.b?.timeRangeLocal || null,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function slimTracksList(tracks) {
|
|
||||||
if (!Array.isArray(tracks) || !tracks.length) return undefined;
|
|
||||||
return tracks
|
|
||||||
.map((t) => slimTrack(t))
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build compact DATA for the LLM. Full `data` is still used for cards.
|
|
||||||
* @param {object} data full fetchAgendaData result
|
|
||||||
* @param {string} intent
|
|
||||||
*/
|
|
||||||
function slimDataForLlm(data, intent) {
|
|
||||||
const src = data && typeof data === 'object' ? data : {};
|
|
||||||
const sessions = Array.isArray(src.sessions)
|
|
||||||
? src.sessions.map((s) => slimSessionForLlm(s, intent)).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Always keep sessions (even []) so the model cannot miss an empty personal schedule.
|
|
||||||
return {
|
|
||||||
...omitEmpty({
|
|
||||||
now: src.nowLocal || src.now || null,
|
|
||||||
tz: src.timeZone || 'Asia/Kolkata',
|
|
||||||
note: src.note || null,
|
|
||||||
day: src.query?.day || null,
|
|
||||||
trackQuery: src.trackQuery || null,
|
|
||||||
speakerQuery: src.speakerQuery || null,
|
|
||||||
query: src.query ? omitEmpty(src.query) : null,
|
|
||||||
tracks: slimTracksList(src.tracks),
|
|
||||||
conflicts: slimConflicts(src.conflicts),
|
|
||||||
alreadyTold: alreadyToldField(src),
|
|
||||||
}),
|
|
||||||
sessions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact venue DATA for Call 2.
|
|
||||||
* @param {object} data fetchVenueData result
|
|
||||||
*/
|
|
||||||
function slimVenueDataForLlm(data) {
|
|
||||||
const src = data && typeof data === 'object' ? data : {};
|
|
||||||
const faqs = Array.isArray(src.faqs)
|
|
||||||
? src.faqs
|
|
||||||
.map((f) => omitEmpty({ q: f.q || f.question || null, a: f.a || f.answer || null }))
|
|
||||||
.filter((f) => f.q && f.a)
|
|
||||||
.slice(0, 10)
|
|
||||||
: [];
|
|
||||||
const places = Array.isArray(src.places)
|
|
||||||
? src.places
|
|
||||||
.map((p) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: p.id,
|
|
||||||
name: p.name || null,
|
|
||||||
address: p.address || null,
|
|
||||||
rating: p.rating != null ? p.rating : null,
|
|
||||||
distance: p.distance != null ? p.distance : null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((p) => p.id && p.name)
|
|
||||||
.slice(0, 8)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Keep instructional notes out of the payload when allowGeneralTravel is set —
|
|
||||||
// models sometimes echo DATA.note as the user-facing message — unless
|
|
||||||
// keepNoteWithGeneralTravel (transport allow-list must still be visible).
|
|
||||||
const includeNote =
|
|
||||||
src.note && (!src.allowGeneralTravel || src.keepNoteWithGeneralTravel);
|
|
||||||
|
|
||||||
const mapMedia = src.mapMedia && src.mapMedia.url
|
|
||||||
? omitEmpty({
|
|
||||||
name: src.mapMedia.name || 'Venue floor plan',
|
|
||||||
mediaType: src.mapMedia.mediaType || null,
|
|
||||||
available: true,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const transportModes =
|
|
||||||
src.transportModes && src.transportModes.enforce
|
|
||||||
? {
|
|
||||||
enforce: true,
|
|
||||||
allowed: Array.isArray(src.transportModes.allowed)
|
|
||||||
? src.transportModes.allowed.slice(0, 8)
|
|
||||||
: [],
|
|
||||||
notAllowed: Array.isArray(src.transportModes.notAllowed)
|
|
||||||
? src.transportModes.notAllowed.slice(0, 8)
|
|
||||||
: [],
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return omitEmpty({
|
|
||||||
eventName: src.eventName || null,
|
|
||||||
location: src.location || null,
|
|
||||||
origin: src.origin || null,
|
|
||||||
allowGeneralTravel: src.allowGeneralTravel ? true : null,
|
|
||||||
note: includeNote ? src.note : null,
|
|
||||||
fallback: src.fallback || null,
|
|
||||||
faqs: faqs.length ? faqs : undefined,
|
|
||||||
transportModes,
|
|
||||||
mapMedia,
|
|
||||||
halls: Array.isArray(src.halls) && src.halls.length ? src.halls.slice(0, 20) : undefined,
|
|
||||||
rooms: Array.isArray(src.rooms) && src.rooms.length ? src.rooms.slice(0, 25) : undefined,
|
|
||||||
places: places.length ? places : undefined,
|
|
||||||
alreadyTold: alreadyToldField(src),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact ticket DATA for Call 2.
|
|
||||||
* @param {object} data fetchTicketData result
|
|
||||||
*/
|
|
||||||
function slimTicketDataForLlm(data) {
|
|
||||||
const src = data && typeof data === 'object' ? data : {};
|
|
||||||
const receipt = src.receipt && typeof src.receipt === 'object'
|
|
||||||
? omitEmpty({
|
|
||||||
kind: src.receipt.kind || null,
|
|
||||||
status: src.receipt.status || null,
|
|
||||||
ticketName: src.receipt.ticketName || null,
|
|
||||||
amount: src.receipt.amountLabel || null,
|
|
||||||
orderId: src.receipt.orderId || null,
|
|
||||||
paymentId: src.receipt.paymentId || null,
|
|
||||||
date: src.receipt.date || null,
|
|
||||||
purchaserEmail: src.receipt.purchaserEmail || null,
|
|
||||||
attendees: Array.isArray(src.receipt.attendees)
|
|
||||||
? src.receipt.attendees.slice(0, 8)
|
|
||||||
: undefined,
|
|
||||||
quote: src.receipt.quote || null,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const faqs = Array.isArray(src.faqs)
|
|
||||||
? src.faqs
|
|
||||||
.map((f) =>
|
|
||||||
omitEmpty({
|
|
||||||
q: f.q || null,
|
|
||||||
a: f.a || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((f) => f.a)
|
|
||||||
.slice(0, 6)
|
|
||||||
: [];
|
|
||||||
const contact = src.contact && typeof src.contact === 'object'
|
|
||||||
? omitEmpty({
|
|
||||||
email: src.contact.email || null,
|
|
||||||
phone: src.contact.phone || null,
|
|
||||||
detail: src.contact.detail || null,
|
|
||||||
text: src.contact.text || null,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const tickets = Array.isArray(src.tickets)
|
|
||||||
? src.tickets
|
|
||||||
.map((t) =>
|
|
||||||
omitEmpty({
|
|
||||||
name: t.name || null,
|
|
||||||
price: t.price || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((t) => t.name)
|
|
||||||
.slice(0, 8)
|
|
||||||
: [];
|
|
||||||
const hideEventName = src.intent === 'ticket_how_to_register';
|
|
||||||
|
|
||||||
return omitEmpty({
|
|
||||||
eventName: hideEventName ? null : src.eventName || null,
|
|
||||||
found: src.found === false ? false : src.found ? true : null,
|
|
||||||
policy: src.policy || null,
|
|
||||||
note: hideEventName ? null : src.note || null,
|
|
||||||
registrationLinks: hideEventName
|
|
||||||
? undefined
|
|
||||||
: Array.isArray(src.registrationLinks)
|
|
||||||
? src.registrationLinks
|
|
||||||
.map((l) =>
|
|
||||||
omitEmpty({
|
|
||||||
label: l.label || null,
|
|
||||||
url: l.url || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((l) => l.url)
|
|
||||||
.slice(0, 8)
|
|
||||||
: undefined,
|
|
||||||
hasRegistrationButtons:
|
|
||||||
hideEventName && Array.isArray(src.registrationLinks) && src.registrationLinks.some((l) => l.url)
|
|
||||||
? true
|
|
||||||
: null,
|
|
||||||
registrationOptions:
|
|
||||||
hideEventName && Array.isArray(src.registrationLinks)
|
|
||||||
? src.registrationLinks.map((l) => l.label).filter(Boolean).slice(0, 8)
|
|
||||||
: undefined,
|
|
||||||
contact:
|
|
||||||
hideEventName
|
|
||||||
? undefined
|
|
||||||
: contact && (contact.email || contact.phone || contact.text)
|
|
||||||
? contact
|
|
||||||
: undefined,
|
|
||||||
faqs: faqs.length ? faqs : undefined,
|
|
||||||
tickets: tickets.length ? tickets : undefined,
|
|
||||||
emailSent: src.emailSent === true ? true : src.emailSent === false ? false : null,
|
|
||||||
emailTo: src.emailTo || null,
|
|
||||||
emailError: src.emailError || null,
|
|
||||||
attendee: src.attendee
|
|
||||||
? omitEmpty({
|
|
||||||
name: src.attendee.name || null,
|
|
||||||
email: src.attendee.email || null,
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
membership: src.membership
|
|
||||||
? omitEmpty({
|
|
||||||
status: src.membership.statusLabel || src.membership.status || null,
|
|
||||||
checkedIn: src.membership.checkedIn ? true : null,
|
|
||||||
registrationDate: src.membership.registrationDate || null,
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
ticket: src.ticket
|
|
||||||
? omitEmpty({
|
|
||||||
name: src.ticket.name || null,
|
|
||||||
price: src.ticket.basePriceMajor != null
|
|
||||||
? `${src.ticket.currency || 'INR'} ${src.ticket.basePriceMajor}`
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
payment: src.payment
|
|
||||||
? omitEmpty({
|
|
||||||
status: src.payment.status || null,
|
|
||||||
amount: src.payment.amount != null
|
|
||||||
? `${src.payment.currency || 'INR'} ${src.payment.amount}`
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
qr: src.qr
|
|
||||||
? omitEmpty({
|
|
||||||
available: Boolean(src.qr.available),
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
receipt: receipt && Object.keys(receipt).length ? receipt : null,
|
|
||||||
alreadyTold: alreadyToldField(src),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact speaker DATA for Call 2.
|
|
||||||
* @param {object} data fetchSpeakerData result
|
|
||||||
*/
|
|
||||||
function slimSpeakerDataForLlm(data) {
|
|
||||||
const src = data && typeof data === 'object' ? data : {};
|
|
||||||
const isList = Boolean(src.isSpeakerList);
|
|
||||||
const speakerCap = isList ? 24 : 4;
|
|
||||||
const bioCap = isList ? 160 : 800;
|
|
||||||
const speakers = Array.isArray(src.speakers)
|
|
||||||
? src.speakers
|
|
||||||
.map((s) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name || null,
|
|
||||||
title: s.title || null,
|
|
||||||
organizationName: s.organizationName || null,
|
|
||||||
speakerRole: s.speakerRole || null,
|
|
||||||
bio: s.bio ? String(s.bio).slice(0, bioCap) : null,
|
|
||||||
expertise:
|
|
||||||
!isList && Array.isArray(s.expertise) && s.expertise.length
|
|
||||||
? s.expertise.slice(0, 8)
|
|
||||||
: undefined,
|
|
||||||
socialLinks:
|
|
||||||
!isList && s.socialLinks
|
|
||||||
? omitEmpty({
|
|
||||||
linkedin: s.socialLinks.linkedin || null,
|
|
||||||
twitter: s.socialLinks.twitter || null,
|
|
||||||
website: s.socialLinks.website || null,
|
|
||||||
})
|
|
||||||
: undefined,
|
|
||||||
isPublic: s.isPublic === false ? false : true,
|
|
||||||
profileVisibility: s.profileVisibility || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((s) => s.id && s.name)
|
|
||||||
.slice(0, speakerCap)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const candidates = Array.isArray(src.candidates)
|
|
||||||
? src.candidates
|
|
||||||
.map((c) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: c.id,
|
|
||||||
name: c.name || null,
|
|
||||||
title: c.title || null,
|
|
||||||
organizationName: c.organizationName || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((c) => c.name)
|
|
||||||
.slice(0, 8)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const sessionSource =
|
|
||||||
src.intent === 'speaker_meet_and_greet' && Array.isArray(src.meetSessions) && src.meetSessions.length
|
|
||||||
? src.meetSessions
|
|
||||||
: src.sessions;
|
|
||||||
|
|
||||||
const sessions = Array.isArray(sessionSource)
|
|
||||||
? sessionSource
|
|
||||||
.map((s) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title || null,
|
|
||||||
when: s.timeRangeLocal || null,
|
|
||||||
track: s.track?.name || (typeof s.track === 'string' ? s.track : null),
|
|
||||||
type: s.type || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((s) => s.id && s.title)
|
|
||||||
.slice(0, 12)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return omitEmpty({
|
|
||||||
speakerQuery: src.speakerQuery || null,
|
|
||||||
isSpeakerList: isList ? true : null,
|
|
||||||
needsClarification: src.needsClarification ? true : null,
|
|
||||||
note: src.note || null,
|
|
||||||
speakers: speakers.length ? speakers : undefined,
|
|
||||||
candidates: candidates.length ? candidates : undefined,
|
|
||||||
sessions: !isList && sessions.length ? sessions : undefined,
|
|
||||||
meetSessionCount:
|
|
||||||
!isList && Array.isArray(src.meetSessions) && src.meetSessions.length
|
|
||||||
? src.meetSessions.length
|
|
||||||
: undefined,
|
|
||||||
alreadyTold: alreadyToldField(src),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact exhibitor DATA for Call 2.
|
|
||||||
* @param {object} data fetchExhibitorData result
|
|
||||||
*/
|
|
||||||
function slimExhibitorDataForLlm(data) {
|
|
||||||
const src = data && typeof data === 'object' ? data : {};
|
|
||||||
const isList = Boolean(src.isExhibitorList);
|
|
||||||
const exhibitorCap = isList ? 24 : 4;
|
|
||||||
const descCap = isList ? 120 : 500;
|
|
||||||
const exhibitors = Array.isArray(src.exhibitors)
|
|
||||||
? src.exhibitors
|
|
||||||
.map((e) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: e.id,
|
|
||||||
name: e.name || null,
|
|
||||||
boothNumber: e.boothNumber || null,
|
|
||||||
hall: e.hall || null,
|
|
||||||
category: e.category || null,
|
|
||||||
shortDescription: e.shortDescription
|
|
||||||
? String(e.shortDescription).slice(0, descCap)
|
|
||||||
: null,
|
|
||||||
description:
|
|
||||||
!isList && e.description ? String(e.description).slice(0, descCap) : null,
|
|
||||||
products:
|
|
||||||
Array.isArray(e.products) && e.products.length
|
|
||||||
? e.products.slice(0, isList ? 4 : 10)
|
|
||||||
: undefined,
|
|
||||||
website: e.website || null,
|
|
||||||
socialLinks:
|
|
||||||
!isList && e.socialLinks
|
|
||||||
? omitEmpty({
|
|
||||||
linkedin: e.socialLinks.linkedin || null,
|
|
||||||
twitter: e.socialLinks.twitter || null,
|
|
||||||
instagram: e.socialLinks.instagram || null,
|
|
||||||
website: e.socialLinks.website || null,
|
|
||||||
})
|
|
||||||
: undefined,
|
|
||||||
isFeatured: e.isFeatured ? true : null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((e) => e.id && e.name)
|
|
||||||
.slice(0, exhibitorCap)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const candidates = Array.isArray(src.candidates)
|
|
||||||
? src.candidates
|
|
||||||
.map((c) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: c.id,
|
|
||||||
name: c.name || null,
|
|
||||||
boothNumber: c.boothNumber || null,
|
|
||||||
hall: c.hall || null,
|
|
||||||
category: c.category || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((c) => c.name)
|
|
||||||
.slice(0, 8)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const faqs = Array.isArray(src.faqs)
|
|
||||||
? src.faqs
|
|
||||||
.map((f) =>
|
|
||||||
omitEmpty({
|
|
||||||
q: f.q || null,
|
|
||||||
a: f.a || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((f) => f.a)
|
|
||||||
.slice(0, 8)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return omitEmpty({
|
|
||||||
companyQuery: src.companyQuery || null,
|
|
||||||
categoryQuery: src.categoryQuery || null,
|
|
||||||
isExhibitorList: isList ? true : null,
|
|
||||||
needsClarification: src.needsClarification ? true : null,
|
|
||||||
refuseBooking: src.refuseBooking ? true : null,
|
|
||||||
note: src.note || null,
|
|
||||||
exhibitors: exhibitors.length ? exhibitors : undefined,
|
|
||||||
candidates: candidates.length ? candidates : undefined,
|
|
||||||
faqs: faqs.length ? faqs : undefined,
|
|
||||||
alreadyTold: alreadyToldField(src),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact tech DATA for Call 2.
|
|
||||||
* @param {object} data fetchTechData result
|
|
||||||
*/
|
|
||||||
function slimTechDataForLlm(data) {
|
|
||||||
const src = data && typeof data === 'object' ? data : {};
|
|
||||||
const faqs = Array.isArray(src.faqs)
|
|
||||||
? src.faqs
|
|
||||||
.map((f) =>
|
|
||||||
omitEmpty({
|
|
||||||
q: f.q || null,
|
|
||||||
a: f.a || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((f) => f.a)
|
|
||||||
.slice(0, 10)
|
|
||||||
: [];
|
|
||||||
const staticTips = Array.isArray(src.staticTips)
|
|
||||||
? src.staticTips.map((t) => String(t || '').trim()).filter(Boolean).slice(0, 8)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return omitEmpty({
|
|
||||||
category: src.category || null,
|
|
||||||
note: src.note || null,
|
|
||||||
faqs: faqs.length ? faqs : undefined,
|
|
||||||
staticTips: staticTips.length ? staticTips : undefined,
|
|
||||||
alreadyTold: alreadyToldField(src),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact system DATA for Call 2.
|
|
||||||
* Visitor-facing Bot FAQ contacts (DATA.contact / DATA.faqs) are intentionally included.
|
|
||||||
* Internal org mailbox used only for escalation email is not exposed separately.
|
|
||||||
* @param {object} data fetchSystemData result
|
|
||||||
*/
|
|
||||||
function slimSystemDataForLlm(data) {
|
|
||||||
const src = data && typeof data === 'object' ? data : {};
|
|
||||||
const faqs = Array.isArray(src.faqs)
|
|
||||||
? src.faqs
|
|
||||||
.map((f) =>
|
|
||||||
omitEmpty({
|
|
||||||
q: f.q || null,
|
|
||||||
a: f.a || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((f) => f.a)
|
|
||||||
.slice(0, 4)
|
|
||||||
: [];
|
|
||||||
const contact = src.contact && typeof src.contact === 'object'
|
|
||||||
? omitEmpty({
|
|
||||||
email: src.contact.email || null,
|
|
||||||
phone: src.contact.phone || null,
|
|
||||||
detail: src.contact.detail || null,
|
|
||||||
text: src.contact.text || null,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const hideEventName =
|
|
||||||
src.intent === 'out_of_scope'
|
|
||||||
|| src.intent === 'chitchat_greeting'
|
|
||||||
|| src.intent === 'event_adjacent';
|
|
||||||
const places = Array.isArray(src.places)
|
|
||||||
? src.places
|
|
||||||
.map((p) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: p.id,
|
|
||||||
name: p.name || null,
|
|
||||||
address: p.address || null,
|
|
||||||
rating: p.rating != null ? p.rating : null,
|
|
||||||
category: p.category || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((p) => p.id && p.name)
|
|
||||||
.slice(0, 8)
|
|
||||||
: [];
|
|
||||||
const hotels = Array.isArray(src.hotels)
|
|
||||||
? src.hotels
|
|
||||||
.map((h) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: h.id,
|
|
||||||
name: h.name || null,
|
|
||||||
address: h.address || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((h) => h.id && h.name)
|
|
||||||
.slice(0, 8)
|
|
||||||
: [];
|
|
||||||
return omitEmpty({
|
|
||||||
eventName: hideEventName ? null : src.eventName || null,
|
|
||||||
location: src.location || null,
|
|
||||||
city: src.city || null,
|
|
||||||
eventDates: src.eventDates || null,
|
|
||||||
allowGeneralLocal: src.allowGeneralLocal ? true : null,
|
|
||||||
dish: src.dish || null,
|
|
||||||
emailSent: src.emailSent === true ? true : null,
|
|
||||||
rateLimited: src.rateLimited === true ? true : null,
|
|
||||||
emailError: src.emailError || null,
|
|
||||||
note: src.note || null,
|
|
||||||
contact: contact && (contact.email || contact.phone || contact.text) ? contact : undefined,
|
|
||||||
faqs: faqs.length ? faqs : undefined,
|
|
||||||
places: places.length ? places : undefined,
|
|
||||||
hotels: hotels.length ? hotels : undefined,
|
|
||||||
alreadyTold: alreadyToldField(src),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact local places / accommodation DATA for Call 2.
|
|
||||||
* @param {object} data fetchLocalData result
|
|
||||||
*/
|
|
||||||
function slimLocalDataForLlm(data) {
|
|
||||||
const src = data && typeof data === 'object' ? data : {};
|
|
||||||
const places = Array.isArray(src.places)
|
|
||||||
? src.places
|
|
||||||
.map((p) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: p.id,
|
|
||||||
name: p.name || null,
|
|
||||||
address: p.address || null,
|
|
||||||
rating: p.rating != null ? p.rating : null,
|
|
||||||
distance: p.distance != null ? p.distance : null,
|
|
||||||
category: p.category || null,
|
|
||||||
tags: Array.isArray(p.tags) && p.tags.length ? p.tags.slice(0, 6) : undefined,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((p) => p.id && p.name)
|
|
||||||
.slice(0, 8)
|
|
||||||
: [];
|
|
||||||
const hotels = Array.isArray(src.hotels)
|
|
||||||
? src.hotels
|
|
||||||
.map((h) =>
|
|
||||||
omitEmpty({
|
|
||||||
id: h.id,
|
|
||||||
name: h.name || null,
|
|
||||||
address: h.address || null,
|
|
||||||
rating: h.rating != null ? h.rating : null,
|
|
||||||
distance: h.distance != null ? h.distance : null,
|
|
||||||
priceLevel: h.priceLevel != null ? h.priceLevel : null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((h) => h.id && h.name)
|
|
||||||
.slice(0, 8)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const faqs = Array.isArray(src.faqs)
|
|
||||||
? src.faqs
|
|
||||||
.map((f) =>
|
|
||||||
omitEmpty({
|
|
||||||
q: f.q || null,
|
|
||||||
a: f.a || null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((f) => f.a)
|
|
||||||
.slice(0, 2)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return omitEmpty({
|
|
||||||
eventName: src.eventName || null,
|
|
||||||
location: src.location || null,
|
|
||||||
hostManagedAccommodation: src.hostManagedAccommodation ? true : null,
|
|
||||||
faqs: faqs.length ? faqs : undefined,
|
|
||||||
requestedCategory: src.requestedCategory?.label || null,
|
|
||||||
categoryNoMatch: src.categoryNoMatch || null,
|
|
||||||
note: src.note || null,
|
|
||||||
places: places.length ? places : undefined,
|
|
||||||
hotels: hotels.length ? hotels : undefined,
|
|
||||||
alreadyTold: alreadyToldField(src),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
compactEntities,
|
|
||||||
slimDataForLlm,
|
|
||||||
slimVenueDataForLlm,
|
|
||||||
slimTicketDataForLlm,
|
|
||||||
slimSpeakerDataForLlm,
|
|
||||||
slimExhibitorDataForLlm,
|
|
||||||
slimTechDataForLlm,
|
|
||||||
slimSystemDataForLlm,
|
|
||||||
slimLocalDataForLlm,
|
|
||||||
slimSessionForLlm,
|
|
||||||
omitEmpty,
|
|
||||||
};
|
|
||||||
|
|
@ -1,334 +0,0 @@
|
||||||
/**
|
|
||||||
* Call 1 surface: domain + action. Fetchers still use a derived fetchIntent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DOMAINS = [
|
|
||||||
'agenda',
|
|
||||||
'speaker',
|
|
||||||
'venue',
|
|
||||||
'local',
|
|
||||||
'ticket',
|
|
||||||
'exhibitor',
|
|
||||||
'tech',
|
|
||||||
'system',
|
|
||||||
];
|
|
||||||
|
|
||||||
const ACTIONS = [
|
|
||||||
'read',
|
|
||||||
'bookmark_add',
|
|
||||||
'bookmark_remove',
|
|
||||||
'email_qr',
|
|
||||||
'emergency',
|
|
||||||
'escalate_human',
|
|
||||||
'adjacent',
|
|
||||||
'oos',
|
|
||||||
'greeting',
|
|
||||||
];
|
|
||||||
|
|
||||||
const DOMAIN_SET = new Set(DOMAINS);
|
|
||||||
const ACTION_SET = new Set(ACTIONS);
|
|
||||||
|
|
||||||
/** Old Call 1 intent ids → domain/action (model aliases + pendingIntent). */
|
|
||||||
const LEGACY_TO_DOMAIN_ACTION = {
|
|
||||||
agenda_current_now: { domain: 'agenda', action: 'read' },
|
|
||||||
agenda_upcoming_next: { domain: 'agenda', action: 'read' },
|
|
||||||
agenda_by_track: { domain: 'agenda', action: 'read' },
|
|
||||||
agenda_by_speaker: { domain: 'agenda', action: 'read' },
|
|
||||||
agenda_session_details: { domain: 'agenda', action: 'read' },
|
|
||||||
agenda_personal_schedule: { domain: 'agenda', action: 'read' },
|
|
||||||
agenda_conflict_check: { domain: 'agenda', action: 'read' },
|
|
||||||
agenda_download_materials: { domain: 'agenda', action: 'read' },
|
|
||||||
agenda_bookmark_add: { domain: 'agenda', action: 'bookmark_add' },
|
|
||||||
agenda_bookmark_remove: { domain: 'agenda', action: 'bookmark_remove' },
|
|
||||||
speaker_bio_lookup: { domain: 'speaker', action: 'read' },
|
|
||||||
speaker_meet_and_greet: { domain: 'speaker', action: 'read' },
|
|
||||||
speaker_contact_info: { domain: 'speaker', action: 'read' },
|
|
||||||
venue_map_request: { domain: 'venue', action: 'read' },
|
|
||||||
venue_wayfinding: { domain: 'venue', action: 'read' },
|
|
||||||
venue_nearest_amenity: { domain: 'venue', action: 'read' },
|
|
||||||
venue_parking_transport: { domain: 'venue', action: 'read' },
|
|
||||||
venue_travel_directions: { domain: 'venue', action: 'read' },
|
|
||||||
venue_food_beverage: { domain: 'venue', action: 'read' },
|
|
||||||
venue_accessibility: { domain: 'venue', action: 'read' },
|
|
||||||
tourist_places: { domain: 'local', action: 'read' },
|
|
||||||
accommodation: { domain: 'local', action: 'read' },
|
|
||||||
event_adjacent: { domain: 'local', action: 'adjacent' },
|
|
||||||
ticket_qr_display: { domain: 'ticket', action: 'read' },
|
|
||||||
ticket_email_qr: { domain: 'ticket', action: 'email_qr' },
|
|
||||||
ticket_status_lookup: { domain: 'ticket', action: 'read' },
|
|
||||||
ticket_how_to_register: { domain: 'ticket', action: 'read' },
|
|
||||||
ticket_document_receipt: { domain: 'ticket', action: 'read' },
|
|
||||||
ticket_transfer_request: { domain: 'ticket', action: 'read' },
|
|
||||||
ticket_upgrade: { domain: 'ticket', action: 'read' },
|
|
||||||
exhibitor_location: { domain: 'exhibitor', action: 'read' },
|
|
||||||
exhibitor_by_category: { domain: 'exhibitor', action: 'read' },
|
|
||||||
exhibitor_demo_schedule: { domain: 'exhibitor', action: 'read' },
|
|
||||||
exhibitor_book_meeting: { domain: 'exhibitor', action: 'read' },
|
|
||||||
wifi_credentials: { domain: 'tech', action: 'read' },
|
|
||||||
app_issue_streaming: { domain: 'tech', action: 'read' },
|
|
||||||
app_issue_account: { domain: 'tech', action: 'read' },
|
|
||||||
app_feature_guidance: { domain: 'tech', action: 'read' },
|
|
||||||
emergency_medical: { domain: 'system', action: 'emergency' },
|
|
||||||
lost_and_found: { domain: 'system', action: 'read' },
|
|
||||||
human_agent_escalation: { domain: 'system', action: 'escalate_human' },
|
|
||||||
chitchat_greeting: { domain: 'system', action: 'greeting' },
|
|
||||||
out_of_scope: { domain: 'system', action: 'oos' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function normMsg(message) {
|
|
||||||
return String(message || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeFoodPolicy(message) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
return (
|
|
||||||
/\b(is|are) (food|lunch|dinner|breakfast|meals?|tea|coffee) (provided|included|served)\b/.test(t)
|
|
||||||
|| /\b(food|meals?|lunch|dinner) provided\b/.test(t)
|
|
||||||
|| /\bdietary (options?|needs|requirements?)\b/.test(t)
|
|
||||||
|| /\btea\/coffee\b/.test(t)
|
|
||||||
|| /\bwill (there be|you serve|they serve) (food|lunch|meals?)\b/.test(t)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeDishAsk(message, dish) {
|
|
||||||
if (String(dish || '').trim()) return true;
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (looksLikeFoodPolicy(t)) return false;
|
|
||||||
return (
|
|
||||||
/\b(want to|wanted to|wanna|like to|crave|craving) eat\b/.test(t)
|
|
||||||
|| /\b(eat|eating|try|trying) (some )?(puttu|dosa|idli|biryani|appam|porotta|parotta)\b/.test(t)
|
|
||||||
|| /\b(puttu|dosa|idli|appam|porotta|parotta|biryani)\b/.test(t)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeItinerary(message) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
return (
|
|
||||||
/\b(itinerary|day plan|one[- ]day|1[- ]day|tourist plan)\b/.test(t)
|
|
||||||
|| /\bafter (the |this )?event\b/.test(t)
|
|
||||||
&& /\b(plan|roam|visit|sightseeing|tour|explore)\b/.test(t)
|
|
||||||
|| /\bwhat (can|should) i (do|see|visit).{0,40}(after|free time|spare)\b/.test(t)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferAgendaFetchIntent(message, entities) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (/\b(add|save|bookmark|put on my agenda)\b/.test(t)) return 'agenda_bookmark_add';
|
|
||||||
if (/\b(remove|unsave|unbookmark)\b/.test(t)) return 'agenda_bookmark_remove';
|
|
||||||
if (/\b(my (sessions|schedule|agenda)|saved sessions|bookmarked)\b/.test(t)) {
|
|
||||||
return 'agenda_personal_schedule';
|
|
||||||
}
|
|
||||||
if (/\bconflict|overlap\b/.test(t)) return 'agenda_conflict_check';
|
|
||||||
if (/\b(slides?|materials?|handouts?|pdfs?|download)\b/.test(t)) return 'agenda_download_materials';
|
|
||||||
if (entities?.speaker_name && /\b(when|session|speak|talk|lecture)\b/.test(t)) {
|
|
||||||
return 'agenda_by_speaker';
|
|
||||||
}
|
|
||||||
if (entities?.speaker_name && !/\bwho is|bio|about\b/.test(t)) return 'agenda_by_speaker';
|
|
||||||
if (/\b(now|currently|happening right now|live now)\b/.test(t)) return 'agenda_current_now';
|
|
||||||
if (/\b(what'?s next|upcoming|coming up)\b/.test(t)) return 'agenda_upcoming_next';
|
|
||||||
if (entities?.topic && /\btrack|stream\b/.test(t)) return 'agenda_by_track';
|
|
||||||
return 'agenda_session_details';
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeEventDeskQuestion(message) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (!t) return false;
|
|
||||||
return /\b(event|summit|conference|congress|attend|attending|attendee|register|registration|ticket|session|speaker|venue|agenda|wifi|wi-fi|exhibitor|exhibit)\b/.test(
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeCityToVenueTravel(message) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (!t) return false;
|
|
||||||
const fromOrigin =
|
|
||||||
/\b(travell?ing|traveling|coming|arriving)\s+from\b/.test(t)
|
|
||||||
|| /\bfrom (?!the\b|this\b|that\b|here\b|there\b|home\b)[a-z]{3,}\b/.test(t);
|
|
||||||
const sightseeingOnly =
|
|
||||||
/\b(forts?|beaches?|churches?|sightseeing|places to visit|tourist)\b/.test(t)
|
|
||||||
&& !fromOrigin
|
|
||||||
&& !/\b(flight|train|how to reach|travel options?)\b/.test(t);
|
|
||||||
if (sightseeingOnly) return false;
|
|
||||||
return (
|
|
||||||
/\b(travell?ing|traveling)\s+from\b/.test(t)
|
|
||||||
|| /\btravel options?\b/.test(t)
|
|
||||||
|| /\bhow (do|can|to) (i |we )?(get to|reach|travel to|come to)\b/.test(t)
|
|
||||||
|| /\b(flights?|trains?)\s+(from|to)\b/.test(t)
|
|
||||||
|| (fromOrigin && /\b(travel|reach|flight|train|options?|how (do|can) i get)\b/.test(t))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRAVEL_ORIGIN_STOP = new Set(['the', 'this', 'that', 'there', 'here', 'home', 'work', 'my', 'our']);
|
|
||||||
|
|
||||||
function extractTravelOrigin(message, existing) {
|
|
||||||
const already = String(existing || '').trim();
|
|
||||||
if (already) return already;
|
|
||||||
const t = String(message || '');
|
|
||||||
const m =
|
|
||||||
t.match(/\b(?:travell?ing|traveling|coming|arriving)\s+from\s+([A-Za-z][A-Za-z.\s-]{1,32}?)(?:\s*[,.?!]|$)/i)
|
|
||||||
|| t.match(/\bfrom\s+([A-Za-z][A-Za-z.]{2,32})\b/i);
|
|
||||||
if (!m) return null;
|
|
||||||
const city = String(m[1] || '').replace(/[.,]+$/g, '').trim();
|
|
||||||
const first = city.split(/\s+/)[0].toLowerCase();
|
|
||||||
if (!city || TRAVEL_ORIGIN_STOP.has(first)) return null;
|
|
||||||
return city;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferVenueFetchIntent(message, entities) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (/\b(map|floor plan|layout)\b/.test(t)) return 'venue_map_request';
|
|
||||||
if (/\b(restroom|washroom|toilet|water|charging|info desk)\b/.test(t)) {
|
|
||||||
return 'venue_nearest_amenity';
|
|
||||||
}
|
|
||||||
if (/\baccessib|wheelchair\b/.test(t)) return 'venue_accessibility';
|
|
||||||
if (looksLikeCityToVenueTravel(t) || (entities?.location && /\b(train|flight|travel|reach|options?)\b/.test(t))) {
|
|
||||||
return 'venue_travel_directions';
|
|
||||||
}
|
|
||||||
if (/\b(parking|taxi|uber|ola|shuttle|metro near)\b/.test(t) && !/\bfrom (?!the\b)[a-z]{3,}\b/.test(t)) {
|
|
||||||
return 'venue_parking_transport';
|
|
||||||
}
|
|
||||||
if (looksLikeFoodPolicy(t) || (/\b(food|lunch|meals?|tea|coffee|dietary)\b/.test(t) && !looksLikeDishAsk(t))) {
|
|
||||||
return 'venue_food_beverage';
|
|
||||||
}
|
|
||||||
if (/\b(where is|how do i find|hall|room|registration desk|stage)\b/.test(t)) {
|
|
||||||
return 'venue_wayfinding';
|
|
||||||
}
|
|
||||||
return 'venue_wayfinding';
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferLocalFetchIntent(message) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (/\b(hotel|stay|lodging|accommodation|check-?in)\b/.test(t)) return 'accommodation';
|
|
||||||
return 'tourist_places';
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferTicketFetchIntent(message, action) {
|
|
||||||
if (action === 'email_qr') return 'ticket_email_qr';
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (/\b(show|display|see) (my )?(qr|badge)\b/.test(t) || /\bmy qr\b/.test(t)) {
|
|
||||||
return 'ticket_qr_display';
|
|
||||||
}
|
|
||||||
if (/\b(invoice|receipt)\b/.test(t)) return 'ticket_document_receipt';
|
|
||||||
if (/\btransfer\b/.test(t)) return 'ticket_transfer_request';
|
|
||||||
if (/\bupgrade\b/.test(t)) return 'ticket_upgrade';
|
|
||||||
if (/\b(my (registration|ticket|status)|already registered|check[- ]?in status|payment status)\b/.test(t)) {
|
|
||||||
return 'ticket_status_lookup';
|
|
||||||
}
|
|
||||||
// Guest attending/joining/eligibility — public FAQs + registration links, not personal status.
|
|
||||||
return 'ticket_how_to_register';
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferSpeakerFetchIntent(message) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (/\b(meet|greeting|networking)\b/.test(t)) return 'speaker_meet_and_greet';
|
|
||||||
if (/\b(linkedin|twitter|website|social)\b/.test(t)) return 'speaker_contact_info';
|
|
||||||
return 'speaker_bio_lookup';
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferExhibitorFetchIntent(message, entities) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (/\bbook|meeting|appointment\b/.test(t)) return 'exhibitor_book_meeting';
|
|
||||||
if (/\bdemo\b/.test(t)) return 'exhibitor_demo_schedule';
|
|
||||||
if (entities?.topic && !entities?.company_name) return 'exhibitor_by_category';
|
|
||||||
return 'exhibitor_location';
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferTechFetchIntent(message) {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (/\bwifi|wi-fi|ssid|password\b/.test(t)) return 'wifi_credentials';
|
|
||||||
if (/\bstream|video|audio|can'?t hear|black screen\b/.test(t)) return 'app_issue_streaming';
|
|
||||||
if (/\blogin|otp|account|password reset\b/.test(t)) return 'app_issue_account';
|
|
||||||
return 'app_feature_guidance';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive the fetch/Call-2 intent id used by existing services.
|
|
||||||
*/
|
|
||||||
function inferFetchIntent({ domain, action, entities = {}, message = '' } = {}) {
|
|
||||||
const d = String(domain || '');
|
|
||||||
const a = String(action || 'read');
|
|
||||||
if (a === 'bookmark_add') return 'agenda_bookmark_add';
|
|
||||||
if (a === 'bookmark_remove') return 'agenda_bookmark_remove';
|
|
||||||
if (a === 'email_qr') return 'ticket_email_qr';
|
|
||||||
if (a === 'emergency') return 'emergency_medical';
|
|
||||||
if (a === 'escalate_human') return 'human_agent_escalation';
|
|
||||||
if (a === 'adjacent') return 'event_adjacent';
|
|
||||||
if (a === 'oos') return 'out_of_scope';
|
|
||||||
if (a === 'greeting') return 'chitchat_greeting';
|
|
||||||
|
|
||||||
if (d === 'agenda') return inferAgendaFetchIntent(message, entities);
|
|
||||||
if (d === 'speaker') {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (entities?.speaker_name && /\b(when|session|speak|lecture|talk)\b/.test(t)) {
|
|
||||||
return 'agenda_by_speaker';
|
|
||||||
}
|
|
||||||
return inferSpeakerFetchIntent(message);
|
|
||||||
}
|
|
||||||
if (d === 'venue') return inferVenueFetchIntent(message, entities);
|
|
||||||
if (d === 'local') return inferLocalFetchIntent(message);
|
|
||||||
if (d === 'ticket') return inferTicketFetchIntent(message, a);
|
|
||||||
if (d === 'exhibitor') return inferExhibitorFetchIntent(message, entities);
|
|
||||||
if (d === 'tech') return inferTechFetchIntent(message);
|
|
||||||
if (d === 'system') {
|
|
||||||
const t = normMsg(message);
|
|
||||||
if (/\blost\b/.test(t)) return 'lost_and_found';
|
|
||||||
return 'out_of_scope';
|
|
||||||
}
|
|
||||||
return 'out_of_scope';
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromLegacyIntent(intent) {
|
|
||||||
const mapped = LEGACY_TO_DOMAIN_ACTION[String(intent || '')];
|
|
||||||
if (!mapped) return null;
|
|
||||||
return { ...mapped, fetchIntent: String(intent) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidDomain(d) {
|
|
||||||
return DOMAIN_SET.has(String(d || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidAction(a) {
|
|
||||||
return ACTION_SET.has(String(a || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
function faqKeyOf(row) {
|
|
||||||
return String(row?.key || row?.category || '').trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBookmarkAction(action) {
|
|
||||||
return action === 'bookmark_add' || action === 'bookmark_remove';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAuthRequiredFetchIntent(intent) {
|
|
||||||
return [
|
|
||||||
'agenda_personal_schedule',
|
|
||||||
'agenda_conflict_check',
|
|
||||||
'agenda_bookmark_add',
|
|
||||||
'agenda_bookmark_remove',
|
|
||||||
'ticket_qr_display',
|
|
||||||
'ticket_email_qr',
|
|
||||||
'ticket_status_lookup',
|
|
||||||
'ticket_document_receipt',
|
|
||||||
].includes(String(intent || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
DOMAINS,
|
|
||||||
ACTIONS,
|
|
||||||
DOMAIN_SET,
|
|
||||||
ACTION_SET,
|
|
||||||
LEGACY_TO_DOMAIN_ACTION,
|
|
||||||
looksLikeFoodPolicy,
|
|
||||||
looksLikeDishAsk,
|
|
||||||
looksLikeItinerary,
|
|
||||||
looksLikeCityToVenueTravel,
|
|
||||||
looksLikeEventDeskQuestion,
|
|
||||||
extractTravelOrigin,
|
|
||||||
inferFetchIntent,
|
|
||||||
fromLegacyIntent,
|
|
||||||
isValidDomain,
|
|
||||||
isValidAction,
|
|
||||||
faqKeyOf,
|
|
||||||
isBookmarkAction,
|
|
||||||
isAuthRequiredFetchIntent,
|
|
||||||
};
|
|
||||||
|
|
@ -1,16 +1,57 @@
|
||||||
function interpolateReception(template, { profileName, eventName, firstName } = {}) {
|
const { toPlainCtas } = require('./whatsappCtas');
|
||||||
const text = String(template || '').trim();
|
|
||||||
|
function engagementPublicBase() {
|
||||||
|
const raw = String(process.env.ENGAGEMENT_PUBLIC_BASE_URL || 'https://engage.eventbuzz.live').trim();
|
||||||
|
return raw.replace(/\/+$/, '') || 'https://engage.eventbuzz.live';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEngagementUrl(eventId, participantId) {
|
||||||
|
const event = String(eventId || '').trim();
|
||||||
|
const participant = String(participantId || '').trim();
|
||||||
|
if (!event || !participant) return '';
|
||||||
|
return `${engagementPublicBase()}/${encodeURIComponent(event)}/${encodeURIComponent(participant)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function receptionVars({ profileName, eventName, firstName, eventId, participantId, engagementUrl } = {}) {
|
||||||
const profile = String(profileName || '').trim() || 'Visitor';
|
const profile = String(profileName || '').trim() || 'Visitor';
|
||||||
const event = String(eventName || '').trim() || 'Event';
|
const event = String(eventName || '').trim() || 'Event';
|
||||||
const first =
|
const first =
|
||||||
String(firstName || '').trim() ||
|
String(firstName || '').trim() ||
|
||||||
profile.split(/\s+/)[0] ||
|
profile.split(/\s+/)[0] ||
|
||||||
'there';
|
'there';
|
||||||
if (!text) return `Hi ${first}! Welcome to ${event}.`;
|
const eid = String(eventId || '').trim();
|
||||||
return text
|
const pid = String(participantId || '').trim();
|
||||||
|
const url = String(engagementUrl || '').trim() || buildEngagementUrl(eid, pid);
|
||||||
|
return { first, profile, event, eid, pid, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReceptionPlaceholders(text, vars) {
|
||||||
|
const { first, profile, event, eid, pid, url } = receptionVars(vars);
|
||||||
|
return String(text || '')
|
||||||
.replace(/\{firstName\}/gi, first)
|
.replace(/\{firstName\}/gi, first)
|
||||||
.replace(/\{profileName\}/gi, profile)
|
.replace(/\{profileName\}/gi, profile)
|
||||||
.replace(/\{eventName\}/gi, event);
|
.replace(/\{eventName\}/gi, event)
|
||||||
|
.replace(/\{eventId\}/gi, eid)
|
||||||
|
.replace(/\{participantId\}/gi, pid)
|
||||||
|
.replace(/\{engagementUrl\}/gi, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateReception(template, vars = {}) {
|
||||||
|
const text = String(template || '').trim();
|
||||||
|
const { first, event } = receptionVars(vars);
|
||||||
|
if (!text) return `Hi ${first}! Welcome to ${event}.`;
|
||||||
|
return applyReceptionPlaceholders(text, vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateCtas(ctas, vars = {}) {
|
||||||
|
return toPlainCtas(ctas).map((cta) => {
|
||||||
|
if (cta.type !== 'url') return cta;
|
||||||
|
return {
|
||||||
|
type: 'url',
|
||||||
|
label: cta.label || 'Open',
|
||||||
|
value: applyReceptionPlaceholders(cta.value, vars),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultBadgePrompt() {
|
function defaultBadgePrompt() {
|
||||||
|
|
@ -27,6 +68,10 @@ function defaultWelcome() {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
interpolateReception,
|
interpolateReception,
|
||||||
|
interpolateCtas,
|
||||||
|
applyReceptionPlaceholders,
|
||||||
|
buildEngagementUrl,
|
||||||
|
engagementPublicBase,
|
||||||
defaultBadgePrompt,
|
defaultBadgePrompt,
|
||||||
defaultStaffHandoff,
|
defaultStaffHandoff,
|
||||||
defaultWelcome,
|
defaultWelcome,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue