@absolutejs/voice 0.0.22-beta.6 → 0.0.22-beta.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +205 -0
- package/dist/angular/index.d.ts +4 -0
- package/dist/angular/index.js +587 -43
- package/dist/angular/voice-app-kit-status.service.d.ts +12 -0
- package/dist/angular/voice-ops-status.component.d.ts +15 -0
- package/dist/angular/voice-provider-status.service.d.ts +12 -0
- package/dist/angular/voice-routing-status.service.d.ts +11 -0
- package/dist/angular/voice-stream.service.d.ts +2 -0
- package/dist/angular/voice-workflow-status.service.d.ts +12 -0
- package/dist/appKit.d.ts +92 -0
- package/dist/assistantHealth.d.ts +81 -0
- package/dist/client/actions.d.ts +22 -0
- package/dist/client/appKitStatus.d.ts +19 -0
- package/dist/client/connection.d.ts +3 -0
- package/dist/client/htmxBootstrap.js +44 -2
- package/dist/client/index.d.ts +18 -0
- package/dist/client/index.js +893 -2
- package/dist/client/opsStatusWidget.d.ts +40 -0
- package/dist/client/providerSimulationControls.d.ts +33 -0
- package/dist/client/providerSimulationControlsWidget.d.ts +20 -0
- package/dist/client/providerStatus.d.ts +19 -0
- package/dist/client/providerStatusWidget.d.ts +32 -0
- package/dist/client/routingStatus.d.ts +19 -0
- package/dist/client/routingStatusWidget.d.ts +28 -0
- package/dist/client/workflowStatus.d.ts +19 -0
- package/dist/diagnosticsRoutes.d.ts +44 -0
- package/dist/evalRoutes.d.ts +213 -0
- package/dist/handoff.d.ts +54 -0
- package/dist/handoffHealth.d.ts +94 -0
- package/dist/index.d.ts +32 -4
- package/dist/index.js +4222 -133
- package/dist/modelAdapters.d.ts +75 -0
- package/dist/opsConsoleRoutes.d.ts +77 -0
- package/dist/opsWebhook.d.ts +126 -0
- package/dist/providerAdapters.d.ts +48 -0
- package/dist/providerHealth.d.ts +79 -0
- package/dist/qualityRoutes.d.ts +76 -0
- package/dist/queue.d.ts +52 -0
- package/dist/react/VoiceOpsStatus.d.ts +6 -0
- package/dist/react/VoiceProviderSimulationControls.d.ts +5 -0
- package/dist/react/VoiceProviderStatus.d.ts +6 -0
- package/dist/react/VoiceRoutingStatus.d.ts +6 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.js +1295 -11
- package/dist/react/useVoiceAppKitStatus.d.ts +8 -0
- package/dist/react/useVoiceController.d.ts +2 -0
- package/dist/react/useVoiceProviderSimulationControls.d.ts +10 -0
- package/dist/react/useVoiceProviderStatus.d.ts +8 -0
- package/dist/react/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/react/useVoiceStream.d.ts +2 -0
- package/dist/react/useVoiceWorkflowStatus.d.ts +8 -0
- package/dist/resilienceRoutes.d.ts +117 -0
- package/dist/sessionReplay.d.ts +175 -0
- package/dist/svelte/createVoiceAppKitStatus.d.ts +8 -0
- package/dist/svelte/createVoiceOpsStatus.d.ts +9 -0
- package/dist/svelte/createVoiceProviderSimulationControls.d.ts +11 -0
- package/dist/svelte/createVoiceProviderStatus.d.ts +10 -0
- package/dist/svelte/createVoiceRoutingStatus.d.ts +10 -0
- package/dist/svelte/createVoiceWorkflowStatus.d.ts +8 -0
- package/dist/svelte/index.d.ts +6 -0
- package/dist/svelte/index.js +923 -3
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1537 -7
- package/dist/testing/ioProviderSimulator.d.ts +41 -0
- package/dist/testing/providerSimulator.d.ts +44 -0
- package/dist/trace.d.ts +1 -1
- package/dist/types.d.ts +84 -2
- package/dist/vue/VoiceOpsStatus.d.ts +30 -0
- package/dist/vue/VoiceProviderSimulationControls.d.ts +88 -0
- package/dist/vue/VoiceProviderStatus.d.ts +51 -0
- package/dist/vue/VoiceRoutingStatus.d.ts +51 -0
- package/dist/vue/index.d.ts +9 -0
- package/dist/vue/index.js +1354 -25
- package/dist/vue/useVoiceAppKitStatus.d.ts +9 -0
- package/dist/vue/useVoiceProviderSimulationControls.d.ts +24 -0
- package/dist/vue/useVoiceProviderStatus.d.ts +9 -0
- package/dist/vue/useVoiceRoutingStatus.d.ts +8 -0
- package/dist/vue/useVoiceStream.d.ts +2 -0
- package/dist/vue/useVoiceWorkflowStatus.d.ts +9 -0
- package/dist/workflowContract.d.ts +91 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2992,6 +2992,289 @@ var toVoiceSessionSummary = (session) => ({
|
|
|
2992
2992
|
// src/session.ts
|
|
2993
2993
|
import { Buffer } from "buffer";
|
|
2994
2994
|
|
|
2995
|
+
// src/handoff.ts
|
|
2996
|
+
var toHex3 = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2997
|
+
var signHandoffBody = async (input) => {
|
|
2998
|
+
const encoder = new TextEncoder;
|
|
2999
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
|
|
3000
|
+
hash: "SHA-256",
|
|
3001
|
+
name: "HMAC"
|
|
3002
|
+
}, false, ["sign"]);
|
|
3003
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${input.timestamp}.${input.body}`));
|
|
3004
|
+
return `sha256=${toHex3(new Uint8Array(signature))}`;
|
|
3005
|
+
};
|
|
3006
|
+
var toErrorMessage2 = (error) => error instanceof Error ? error.message : String(error);
|
|
3007
|
+
var createSkippedDelivery = (adapter) => ({
|
|
3008
|
+
adapterId: adapter.id,
|
|
3009
|
+
adapterKind: adapter.kind,
|
|
3010
|
+
status: "skipped"
|
|
3011
|
+
});
|
|
3012
|
+
var aggregateHandoffStatus = (deliveries) => {
|
|
3013
|
+
const statuses = Object.values(deliveries).map((delivery) => delivery.status);
|
|
3014
|
+
if (statuses.some((status) => status === "failed")) {
|
|
3015
|
+
return "failed";
|
|
3016
|
+
}
|
|
3017
|
+
if (statuses.some((status) => status === "delivered")) {
|
|
3018
|
+
return "delivered";
|
|
3019
|
+
}
|
|
3020
|
+
return "skipped";
|
|
3021
|
+
};
|
|
3022
|
+
var createHandoffDeliveryId = (input) => [
|
|
3023
|
+
"voice-handoff",
|
|
3024
|
+
input.sessionId,
|
|
3025
|
+
input.action,
|
|
3026
|
+
Date.now(),
|
|
3027
|
+
crypto.randomUUID()
|
|
3028
|
+
].join(":");
|
|
3029
|
+
var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
|
|
3030
|
+
var defaultWebhookBody = (input) => ({
|
|
3031
|
+
action: input.action,
|
|
3032
|
+
metadata: input.metadata,
|
|
3033
|
+
reason: input.reason,
|
|
3034
|
+
result: input.result,
|
|
3035
|
+
session: {
|
|
3036
|
+
id: input.session.id,
|
|
3037
|
+
scenarioId: input.session.scenarioId,
|
|
3038
|
+
status: input.session.status
|
|
3039
|
+
},
|
|
3040
|
+
source: "absolutejs-voice",
|
|
3041
|
+
target: input.target
|
|
3042
|
+
});
|
|
3043
|
+
var deliverVoiceHandoff = async (input) => {
|
|
3044
|
+
if (!input.config || input.config.adapters.length === 0) {
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
const deliveries = {};
|
|
3048
|
+
for (const adapter of input.config.adapters) {
|
|
3049
|
+
if (adapter.actions && !adapter.actions.includes(input.handoff.action)) {
|
|
3050
|
+
deliveries[adapter.id] = createSkippedDelivery(adapter);
|
|
3051
|
+
continue;
|
|
3052
|
+
}
|
|
3053
|
+
try {
|
|
3054
|
+
const result = await adapter.handoff(input.handoff);
|
|
3055
|
+
deliveries[adapter.id] = {
|
|
3056
|
+
...result,
|
|
3057
|
+
adapterId: adapter.id,
|
|
3058
|
+
adapterKind: adapter.kind
|
|
3059
|
+
};
|
|
3060
|
+
} catch (error) {
|
|
3061
|
+
deliveries[adapter.id] = {
|
|
3062
|
+
adapterId: adapter.id,
|
|
3063
|
+
adapterKind: adapter.kind,
|
|
3064
|
+
error: toErrorMessage2(error),
|
|
3065
|
+
status: "failed"
|
|
3066
|
+
};
|
|
3067
|
+
if (input.config.failMode === "throw") {
|
|
3068
|
+
throw error;
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
return {
|
|
3073
|
+
action: input.handoff.action,
|
|
3074
|
+
deliveries,
|
|
3075
|
+
status: aggregateHandoffStatus(deliveries)
|
|
3076
|
+
};
|
|
3077
|
+
};
|
|
3078
|
+
var createVoiceHandoffDeliveryRecord = (input) => {
|
|
3079
|
+
const now = Date.now();
|
|
3080
|
+
return {
|
|
3081
|
+
action: input.action,
|
|
3082
|
+
context: input.context,
|
|
3083
|
+
createdAt: now,
|
|
3084
|
+
deliveryAttempts: 0,
|
|
3085
|
+
deliveryStatus: "pending",
|
|
3086
|
+
id: input.id ?? createHandoffDeliveryId({
|
|
3087
|
+
action: input.action,
|
|
3088
|
+
sessionId: input.session.id
|
|
3089
|
+
}),
|
|
3090
|
+
metadata: input.metadata,
|
|
3091
|
+
reason: input.reason,
|
|
3092
|
+
result: input.result,
|
|
3093
|
+
session: input.session,
|
|
3094
|
+
sessionId: input.session.id,
|
|
3095
|
+
target: input.target,
|
|
3096
|
+
updatedAt: now
|
|
3097
|
+
};
|
|
3098
|
+
};
|
|
3099
|
+
var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
|
|
3100
|
+
...delivery,
|
|
3101
|
+
deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
|
|
3102
|
+
deliveries: result.deliveries,
|
|
3103
|
+
deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
|
|
3104
|
+
deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
|
|
3105
|
+
deliveryStatus: result.status,
|
|
3106
|
+
updatedAt: Date.now()
|
|
3107
|
+
});
|
|
3108
|
+
var deliverVoiceHandoffDelivery = async (options) => {
|
|
3109
|
+
const result = await deliverVoiceHandoff({
|
|
3110
|
+
config: {
|
|
3111
|
+
adapters: options.adapters,
|
|
3112
|
+
failMode: options.failMode
|
|
3113
|
+
},
|
|
3114
|
+
handoff: {
|
|
3115
|
+
action: options.delivery.action,
|
|
3116
|
+
api: options.api,
|
|
3117
|
+
context: options.delivery.context,
|
|
3118
|
+
metadata: options.delivery.metadata,
|
|
3119
|
+
reason: options.delivery.reason,
|
|
3120
|
+
result: options.delivery.result,
|
|
3121
|
+
session: options.delivery.session,
|
|
3122
|
+
target: options.delivery.target
|
|
3123
|
+
}
|
|
3124
|
+
});
|
|
3125
|
+
return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
|
|
3126
|
+
...options.delivery,
|
|
3127
|
+
deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
|
|
3128
|
+
deliveryStatus: "skipped",
|
|
3129
|
+
updatedAt: Date.now()
|
|
3130
|
+
};
|
|
3131
|
+
};
|
|
3132
|
+
var createVoiceMemoryHandoffDeliveryStore = () => {
|
|
3133
|
+
const deliveries = new Map;
|
|
3134
|
+
return {
|
|
3135
|
+
get: async (id) => deliveries.get(id),
|
|
3136
|
+
list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
|
|
3137
|
+
remove: async (id) => {
|
|
3138
|
+
deliveries.delete(id);
|
|
3139
|
+
},
|
|
3140
|
+
set: async (id, delivery) => {
|
|
3141
|
+
deliveries.set(id, delivery);
|
|
3142
|
+
}
|
|
3143
|
+
};
|
|
3144
|
+
};
|
|
3145
|
+
var createVoiceWebhookHandoffAdapter = (options) => ({
|
|
3146
|
+
actions: options.actions,
|
|
3147
|
+
handoff: async (input) => {
|
|
3148
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
3149
|
+
if (typeof fetchImpl !== "function") {
|
|
3150
|
+
return {
|
|
3151
|
+
deliveredTo: options.url,
|
|
3152
|
+
error: "Handoff delivery failed: fetch is not available in this runtime.",
|
|
3153
|
+
status: "failed"
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
const body = JSON.stringify(await options.body?.(input) ?? defaultWebhookBody(input));
|
|
3157
|
+
const headers = {
|
|
3158
|
+
"content-type": "application/json",
|
|
3159
|
+
...options.headers
|
|
3160
|
+
};
|
|
3161
|
+
if (options.signingSecret) {
|
|
3162
|
+
const timestamp = String(Date.now());
|
|
3163
|
+
headers["x-absolutejs-timestamp"] = timestamp;
|
|
3164
|
+
headers["x-absolutejs-signature"] = await signHandoffBody({
|
|
3165
|
+
body,
|
|
3166
|
+
secret: options.signingSecret,
|
|
3167
|
+
timestamp
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
3171
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
3172
|
+
try {
|
|
3173
|
+
const response = await fetchImpl(options.url, {
|
|
3174
|
+
body,
|
|
3175
|
+
headers,
|
|
3176
|
+
method: options.method ?? "POST",
|
|
3177
|
+
signal: controller?.signal
|
|
3178
|
+
});
|
|
3179
|
+
if (!response.ok) {
|
|
3180
|
+
return {
|
|
3181
|
+
deliveredTo: options.url,
|
|
3182
|
+
error: `Handoff delivery failed with response ${response.status}.`,
|
|
3183
|
+
status: "failed"
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
return {
|
|
3187
|
+
deliveredAt: Date.now(),
|
|
3188
|
+
deliveredTo: options.url,
|
|
3189
|
+
status: "delivered"
|
|
3190
|
+
};
|
|
3191
|
+
} finally {
|
|
3192
|
+
if (timeout) {
|
|
3193
|
+
clearTimeout(timeout);
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
},
|
|
3197
|
+
id: options.id,
|
|
3198
|
+
kind: options.kind ?? "webhook"
|
|
3199
|
+
});
|
|
3200
|
+
var escapeXml = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
3201
|
+
var defaultTwilioTransferTwiML = (input) => {
|
|
3202
|
+
if (!input.target) {
|
|
3203
|
+
return "<Response><Hangup /></Response>";
|
|
3204
|
+
}
|
|
3205
|
+
return `<Response><Dial>${escapeXml(input.target)}</Dial></Response>`;
|
|
3206
|
+
};
|
|
3207
|
+
var resolveTwilioCallSid = async (resolver, input) => {
|
|
3208
|
+
if (typeof resolver === "function") {
|
|
3209
|
+
return resolver(input);
|
|
3210
|
+
}
|
|
3211
|
+
if (typeof resolver === "string" && resolver.length > 0) {
|
|
3212
|
+
return resolver;
|
|
3213
|
+
}
|
|
3214
|
+
const metadataSid = typeof input.metadata?.callSid === "string" ? input.metadata.callSid : undefined;
|
|
3215
|
+
const sessionMetadata = input.session.metadata && typeof input.session.metadata === "object" ? input.session.metadata : undefined;
|
|
3216
|
+
const sessionSid = typeof sessionMetadata?.callSid === "string" ? sessionMetadata.callSid : undefined;
|
|
3217
|
+
return metadataSid ?? sessionSid;
|
|
3218
|
+
};
|
|
3219
|
+
var createVoiceTwilioRedirectHandoffAdapter = (options) => ({
|
|
3220
|
+
actions: options.actions ?? ["transfer"],
|
|
3221
|
+
handoff: async (input) => {
|
|
3222
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
3223
|
+
const callSid = await resolveTwilioCallSid(options.callSid, input);
|
|
3224
|
+
if (!callSid) {
|
|
3225
|
+
return {
|
|
3226
|
+
error: "Twilio handoff requires a callSid.",
|
|
3227
|
+
status: "failed"
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
if (typeof fetchImpl !== "function") {
|
|
3231
|
+
return {
|
|
3232
|
+
error: "Twilio handoff failed: fetch is not available in this runtime.",
|
|
3233
|
+
status: "failed"
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(options.accountSid)}/Calls/${encodeURIComponent(callSid)}.json`;
|
|
3237
|
+
const body = new URLSearchParams({
|
|
3238
|
+
Twiml: await (options.buildTwiML?.(input) ?? defaultTwilioTransferTwiML(input))
|
|
3239
|
+
});
|
|
3240
|
+
const auth = btoa(`${options.accountSid}:${options.authToken}`);
|
|
3241
|
+
const controller = options.timeoutMs && options.timeoutMs > 0 ? new AbortController : undefined;
|
|
3242
|
+
const timeout = controller && options.timeoutMs ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
|
3243
|
+
try {
|
|
3244
|
+
const response = await fetchImpl(url, {
|
|
3245
|
+
body,
|
|
3246
|
+
headers: {
|
|
3247
|
+
authorization: `Basic ${auth}`,
|
|
3248
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
3249
|
+
},
|
|
3250
|
+
method: "POST",
|
|
3251
|
+
signal: controller?.signal
|
|
3252
|
+
});
|
|
3253
|
+
if (!response.ok) {
|
|
3254
|
+
return {
|
|
3255
|
+
deliveredTo: url,
|
|
3256
|
+
error: `Twilio handoff failed with response ${response.status}.`,
|
|
3257
|
+
status: "failed"
|
|
3258
|
+
};
|
|
3259
|
+
}
|
|
3260
|
+
return {
|
|
3261
|
+
deliveredAt: Date.now(),
|
|
3262
|
+
deliveredTo: url,
|
|
3263
|
+
metadata: {
|
|
3264
|
+
callSid
|
|
3265
|
+
},
|
|
3266
|
+
status: "delivered"
|
|
3267
|
+
};
|
|
3268
|
+
} finally {
|
|
3269
|
+
if (timeout) {
|
|
3270
|
+
clearTimeout(timeout);
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
},
|
|
3274
|
+
id: options.id ?? "twilio-redirect",
|
|
3275
|
+
kind: "twilio-redirect"
|
|
3276
|
+
});
|
|
3277
|
+
|
|
2995
3278
|
// src/turnDetection.ts
|
|
2996
3279
|
var DEFAULT_SILENCE_MS = 700;
|
|
2997
3280
|
var DEFAULT_SPEECH_THRESHOLD = 0.015;
|
|
@@ -3288,6 +3571,7 @@ var pushCallLifecycleEvent = (session, input) => {
|
|
|
3288
3571
|
}
|
|
3289
3572
|
return lifecycle;
|
|
3290
3573
|
};
|
|
3574
|
+
var getLatestCallLifecycleEvent = (session) => session.call?.events.at(-1);
|
|
3291
3575
|
var createVoiceSession = (options) => {
|
|
3292
3576
|
const logger = resolveLogger(options.logger);
|
|
3293
3577
|
const reconnect = {
|
|
@@ -3388,6 +3672,64 @@ var createVoiceSession = (options) => {
|
|
|
3388
3672
|
});
|
|
3389
3673
|
}
|
|
3390
3674
|
};
|
|
3675
|
+
const sendCallLifecycle = async (session) => {
|
|
3676
|
+
const event = getLatestCallLifecycleEvent(session);
|
|
3677
|
+
if (!event) {
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
await send({
|
|
3681
|
+
event,
|
|
3682
|
+
sessionId: options.id,
|
|
3683
|
+
type: "call_lifecycle"
|
|
3684
|
+
});
|
|
3685
|
+
};
|
|
3686
|
+
const runHandoff = async (input) => {
|
|
3687
|
+
const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
|
|
3688
|
+
action: input.action,
|
|
3689
|
+
context: options.context,
|
|
3690
|
+
metadata: input.metadata,
|
|
3691
|
+
reason: input.reason,
|
|
3692
|
+
result: input.result,
|
|
3693
|
+
session: input.session,
|
|
3694
|
+
target: input.target
|
|
3695
|
+
}) : undefined;
|
|
3696
|
+
if (queuedDelivery) {
|
|
3697
|
+
await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
|
|
3698
|
+
}
|
|
3699
|
+
if (options.handoff?.enqueueOnly) {
|
|
3700
|
+
return;
|
|
3701
|
+
}
|
|
3702
|
+
const result = await deliverVoiceHandoff({
|
|
3703
|
+
config: options.handoff,
|
|
3704
|
+
handoff: {
|
|
3705
|
+
action: input.action,
|
|
3706
|
+
api,
|
|
3707
|
+
context: options.context,
|
|
3708
|
+
metadata: input.metadata,
|
|
3709
|
+
reason: input.reason,
|
|
3710
|
+
result: input.result,
|
|
3711
|
+
session: input.session,
|
|
3712
|
+
target: input.target
|
|
3713
|
+
}
|
|
3714
|
+
});
|
|
3715
|
+
if (!result) {
|
|
3716
|
+
return;
|
|
3717
|
+
}
|
|
3718
|
+
if (queuedDelivery) {
|
|
3719
|
+
const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
|
|
3720
|
+
await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
|
|
3721
|
+
}
|
|
3722
|
+
await appendTrace({
|
|
3723
|
+
metadata: input.metadata,
|
|
3724
|
+
payload: {
|
|
3725
|
+
...result,
|
|
3726
|
+
reason: input.reason,
|
|
3727
|
+
target: input.target
|
|
3728
|
+
},
|
|
3729
|
+
session: input.session,
|
|
3730
|
+
type: "call.handoff"
|
|
3731
|
+
});
|
|
3732
|
+
};
|
|
3391
3733
|
const readSession = async () => options.store.getOrCreate(options.id);
|
|
3392
3734
|
const writeSession = async (mutate) => {
|
|
3393
3735
|
const session = await options.store.getOrCreate(options.id);
|
|
@@ -3578,6 +3920,7 @@ var createVoiceSession = (options) => {
|
|
|
3578
3920
|
await appendTrace({
|
|
3579
3921
|
payload: {
|
|
3580
3922
|
disposition,
|
|
3923
|
+
metadata: input.metadata,
|
|
3581
3924
|
reason: input.reason,
|
|
3582
3925
|
target: input.target,
|
|
3583
3926
|
type: "end"
|
|
@@ -3585,6 +3928,7 @@ var createVoiceSession = (options) => {
|
|
|
3585
3928
|
session,
|
|
3586
3929
|
type: "call.lifecycle"
|
|
3587
3930
|
});
|
|
3931
|
+
await sendCallLifecycle(session);
|
|
3588
3932
|
await send({
|
|
3589
3933
|
sessionId: options.id,
|
|
3590
3934
|
type: "complete"
|
|
@@ -3664,6 +4008,15 @@ var createVoiceSession = (options) => {
|
|
|
3664
4008
|
session,
|
|
3665
4009
|
type: "call.lifecycle"
|
|
3666
4010
|
});
|
|
4011
|
+
await sendCallLifecycle(session);
|
|
4012
|
+
await runHandoff({
|
|
4013
|
+
action: "transfer",
|
|
4014
|
+
metadata: input.metadata,
|
|
4015
|
+
reason: input.reason,
|
|
4016
|
+
result: input.result,
|
|
4017
|
+
session,
|
|
4018
|
+
target: input.target
|
|
4019
|
+
});
|
|
3667
4020
|
await completeInternal(input.result, {
|
|
3668
4021
|
disposition: "transferred",
|
|
3669
4022
|
invokeOnComplete: false,
|
|
@@ -3689,6 +4042,14 @@ var createVoiceSession = (options) => {
|
|
|
3689
4042
|
session,
|
|
3690
4043
|
type: "call.lifecycle"
|
|
3691
4044
|
});
|
|
4045
|
+
await sendCallLifecycle(session);
|
|
4046
|
+
await runHandoff({
|
|
4047
|
+
action: "escalate",
|
|
4048
|
+
metadata: input.metadata,
|
|
4049
|
+
reason: input.reason,
|
|
4050
|
+
result: input.result,
|
|
4051
|
+
session
|
|
4052
|
+
});
|
|
3692
4053
|
await completeInternal(input.result, {
|
|
3693
4054
|
disposition: "escalated",
|
|
3694
4055
|
invokeOnComplete: false,
|
|
@@ -3711,6 +4072,13 @@ var createVoiceSession = (options) => {
|
|
|
3711
4072
|
session,
|
|
3712
4073
|
type: "call.lifecycle"
|
|
3713
4074
|
});
|
|
4075
|
+
await sendCallLifecycle(session);
|
|
4076
|
+
await runHandoff({
|
|
4077
|
+
action: "no-answer",
|
|
4078
|
+
metadata: input?.metadata,
|
|
4079
|
+
result: input?.result,
|
|
4080
|
+
session
|
|
4081
|
+
});
|
|
3714
4082
|
await completeInternal(input?.result, {
|
|
3715
4083
|
disposition: "no-answer",
|
|
3716
4084
|
invokeOnComplete: false,
|
|
@@ -3732,6 +4100,13 @@ var createVoiceSession = (options) => {
|
|
|
3732
4100
|
session,
|
|
3733
4101
|
type: "call.lifecycle"
|
|
3734
4102
|
});
|
|
4103
|
+
await sendCallLifecycle(session);
|
|
4104
|
+
await runHandoff({
|
|
4105
|
+
action: "voicemail",
|
|
4106
|
+
metadata: input?.metadata,
|
|
4107
|
+
result: input?.result,
|
|
4108
|
+
session
|
|
4109
|
+
});
|
|
3735
4110
|
await completeInternal(input?.result, {
|
|
3736
4111
|
disposition: "voicemail",
|
|
3737
4112
|
invokeOnComplete: false,
|
|
@@ -4518,6 +4893,7 @@ var createVoiceSession = (options) => {
|
|
|
4518
4893
|
session,
|
|
4519
4894
|
type: "call.lifecycle"
|
|
4520
4895
|
});
|
|
4896
|
+
await sendCallLifecycle(session);
|
|
4521
4897
|
}
|
|
4522
4898
|
await send({
|
|
4523
4899
|
sessionId: options.id,
|
|
@@ -4755,6 +5131,14 @@ var isVoiceClientMessage = (value) => {
|
|
|
4755
5131
|
return false;
|
|
4756
5132
|
}
|
|
4757
5133
|
switch (value.type) {
|
|
5134
|
+
case "call_control":
|
|
5135
|
+
if (!("action" in value)) {
|
|
5136
|
+
return false;
|
|
5137
|
+
}
|
|
5138
|
+
if (value.action !== "complete" && value.action !== "escalate" && value.action !== "no-answer" && value.action !== "transfer" && value.action !== "voicemail") {
|
|
5139
|
+
return false;
|
|
5140
|
+
}
|
|
5141
|
+
return (!("metadata" in value) || value.metadata === undefined || value.metadata !== null && typeof value.metadata === "object") && (!("reason" in value) || value.reason === undefined || typeof value.reason === "string") && (!("target" in value) || value.target === undefined || typeof value.target === "string");
|
|
4758
5142
|
case "close":
|
|
4759
5143
|
return true;
|
|
4760
5144
|
case "end_turn":
|
|
@@ -4895,6 +5279,7 @@ var voice = (config) => {
|
|
|
4895
5279
|
audioConditioning: sessionOptions.audioConditioning,
|
|
4896
5280
|
context,
|
|
4897
5281
|
id: sessionId,
|
|
5282
|
+
handoff: config.handoff,
|
|
4898
5283
|
languageStrategy: config.languageStrategy,
|
|
4899
5284
|
lexicon,
|
|
4900
5285
|
logger: sessionOptions.logger,
|
|
@@ -5006,6 +5391,42 @@ var voice = (config) => {
|
|
|
5006
5391
|
await current.close(message.reason);
|
|
5007
5392
|
runtime.activeSessions.delete(sessionState.sessionId);
|
|
5008
5393
|
}
|
|
5394
|
+
if (message.type === "call_control" && current) {
|
|
5395
|
+
if (message.action === "transfer") {
|
|
5396
|
+
if (message.target) {
|
|
5397
|
+
await current.transfer({
|
|
5398
|
+
metadata: message.metadata,
|
|
5399
|
+
reason: message.reason,
|
|
5400
|
+
target: message.target
|
|
5401
|
+
});
|
|
5402
|
+
} else {
|
|
5403
|
+
ws.send(JSON.stringify({
|
|
5404
|
+
message: "call_control transfer requires target",
|
|
5405
|
+
recoverable: true,
|
|
5406
|
+
type: "error"
|
|
5407
|
+
}));
|
|
5408
|
+
}
|
|
5409
|
+
}
|
|
5410
|
+
if (message.action === "escalate") {
|
|
5411
|
+
await current.escalate({
|
|
5412
|
+
metadata: message.metadata,
|
|
5413
|
+
reason: message.reason ?? "client-requested-escalation"
|
|
5414
|
+
});
|
|
5415
|
+
}
|
|
5416
|
+
if (message.action === "voicemail") {
|
|
5417
|
+
await current.markVoicemail({
|
|
5418
|
+
metadata: message.metadata
|
|
5419
|
+
});
|
|
5420
|
+
}
|
|
5421
|
+
if (message.action === "no-answer") {
|
|
5422
|
+
await current.markNoAnswer({
|
|
5423
|
+
metadata: message.metadata
|
|
5424
|
+
});
|
|
5425
|
+
}
|
|
5426
|
+
if (message.action === "complete") {
|
|
5427
|
+
await current.complete();
|
|
5428
|
+
}
|
|
5429
|
+
}
|
|
5009
5430
|
if (message.type === "start" && message.sessionId && message.sessionId !== sessionState.sessionId) {
|
|
5010
5431
|
const currentSession = runtime.activeSessions.get(sessionState.sessionId);
|
|
5011
5432
|
if (currentSession) {
|
|
@@ -5052,9 +5473,15 @@ var voice = (config) => {
|
|
|
5052
5473
|
}
|
|
5053
5474
|
}).use(htmxRoutes());
|
|
5054
5475
|
};
|
|
5476
|
+
// src/appKit.ts
|
|
5477
|
+
import { Elysia as Elysia11 } from "elysia";
|
|
5478
|
+
|
|
5479
|
+
// src/assistantHealth.ts
|
|
5480
|
+
import { Elysia as Elysia3 } from "elysia";
|
|
5481
|
+
|
|
5055
5482
|
// src/agent.ts
|
|
5056
5483
|
var normalizeText3 = (value) => typeof value === "string" ? value.trim() : "";
|
|
5057
|
-
var
|
|
5484
|
+
var toErrorMessage3 = (error) => error instanceof Error ? error.message : String(error);
|
|
5058
5485
|
var createHistoryMessages = (session, turn) => {
|
|
5059
5486
|
const messages = [];
|
|
5060
5487
|
for (const previousTurn of session.turns) {
|
|
@@ -5235,7 +5662,7 @@ var createVoiceAgent = (options) => {
|
|
|
5235
5662
|
toolCallId: toolCall.id
|
|
5236
5663
|
});
|
|
5237
5664
|
} catch (error) {
|
|
5238
|
-
const errorMessage =
|
|
5665
|
+
const errorMessage = toErrorMessage3(error);
|
|
5239
5666
|
toolResults.push({
|
|
5240
5667
|
error: errorMessage,
|
|
5241
5668
|
status: "error",
|
|
@@ -6101,52 +6528,350 @@ var summarizeVoiceAssistantRuns = async (input) => {
|
|
|
6101
6528
|
totalRuns: assistantRuns.length
|
|
6102
6529
|
};
|
|
6103
6530
|
};
|
|
6104
|
-
// src/fileStore.ts
|
|
6105
|
-
import { mkdir, readFile, readdir, rename, rm, writeFile } from "fs/promises";
|
|
6106
|
-
import { join } from "path";
|
|
6107
6531
|
|
|
6108
|
-
// src/
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
}
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
deliveryAttempts: input.deliveryAttempts,
|
|
6141
|
-
deliveryError: input.deliveryError,
|
|
6142
|
-
deliveryStatus: input.deliveryStatus ?? "pending",
|
|
6143
|
-
events: input.events,
|
|
6144
|
-
id: input.id ?? createVoiceTraceSinkDeliveryId(input.events),
|
|
6145
|
-
sinkDeliveries: input.sinkDeliveries,
|
|
6146
|
-
updatedAt: input.updatedAt ?? createdAt
|
|
6532
|
+
// src/providerHealth.ts
|
|
6533
|
+
import { Elysia as Elysia2 } from "elysia";
|
|
6534
|
+
var getString = (value) => typeof value === "string" ? value : undefined;
|
|
6535
|
+
var getNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
6536
|
+
var isProviderStatus = (value) => value === "success" || value === "fallback" || value === "error";
|
|
6537
|
+
var summarizeVoiceProviderHealth = async (input) => {
|
|
6538
|
+
const options = Array.isArray(input) ? { events: input } : input;
|
|
6539
|
+
const events = options.events ?? await options.store?.list() ?? [];
|
|
6540
|
+
const providers = options.providers ?? [];
|
|
6541
|
+
const providerSet = new Set(providers);
|
|
6542
|
+
const now = options.now ?? Date.now();
|
|
6543
|
+
const entries = new Map;
|
|
6544
|
+
const isAllowedProvider = (value) => typeof value === "string" && (providerSet.size === 0 || providerSet.has(value));
|
|
6545
|
+
const getEntry = (provider) => {
|
|
6546
|
+
const existing = entries.get(provider);
|
|
6547
|
+
if (existing) {
|
|
6548
|
+
return existing;
|
|
6549
|
+
}
|
|
6550
|
+
const entry = {
|
|
6551
|
+
elapsedCount: 0,
|
|
6552
|
+
elapsedTotal: 0,
|
|
6553
|
+
errorCount: 0,
|
|
6554
|
+
fallbackCount: 0,
|
|
6555
|
+
provider,
|
|
6556
|
+
rateLimited: false,
|
|
6557
|
+
recommended: false,
|
|
6558
|
+
runCount: 0,
|
|
6559
|
+
status: "idle",
|
|
6560
|
+
timeoutCount: 0
|
|
6561
|
+
};
|
|
6562
|
+
entries.set(provider, entry);
|
|
6563
|
+
return entry;
|
|
6147
6564
|
};
|
|
6148
|
-
|
|
6149
|
-
|
|
6565
|
+
for (const provider of providers) {
|
|
6566
|
+
getEntry(provider);
|
|
6567
|
+
}
|
|
6568
|
+
const hasProviderRouterEvents = events.some((event) => event.type === "session.error" && isAllowedProvider(event.payload.provider) && isProviderStatus(event.payload.providerStatus));
|
|
6569
|
+
for (const event of events) {
|
|
6570
|
+
if (event.type === "assistant.run") {
|
|
6571
|
+
if (hasProviderRouterEvents) {
|
|
6572
|
+
continue;
|
|
6573
|
+
}
|
|
6574
|
+
const provider2 = event.payload.variantId;
|
|
6575
|
+
if (!isAllowedProvider(provider2)) {
|
|
6576
|
+
continue;
|
|
6577
|
+
}
|
|
6578
|
+
const entry2 = getEntry(provider2);
|
|
6579
|
+
entry2.runCount += 1;
|
|
6580
|
+
const elapsedMs = getNumber(event.payload.elapsedMs);
|
|
6581
|
+
if (elapsedMs !== undefined) {
|
|
6582
|
+
entry2.elapsedCount += 1;
|
|
6583
|
+
entry2.elapsedTotal += elapsedMs;
|
|
6584
|
+
}
|
|
6585
|
+
continue;
|
|
6586
|
+
}
|
|
6587
|
+
if (event.type !== "session.error") {
|
|
6588
|
+
continue;
|
|
6589
|
+
}
|
|
6590
|
+
const provider = event.payload.provider;
|
|
6591
|
+
if (!isAllowedProvider(provider)) {
|
|
6592
|
+
continue;
|
|
6593
|
+
}
|
|
6594
|
+
const providerStatus = isProviderStatus(event.payload.providerStatus) ? event.payload.providerStatus : undefined;
|
|
6595
|
+
const applyProviderHealth = () => {
|
|
6596
|
+
const entry2 = getEntry(provider);
|
|
6597
|
+
const providerHealth = event.payload.providerHealth;
|
|
6598
|
+
if (providerHealth && typeof providerHealth === "object") {
|
|
6599
|
+
const suppressedUntil2 = getNumber(providerHealth.suppressedUntil);
|
|
6600
|
+
if (suppressedUntil2 !== undefined) {
|
|
6601
|
+
entry2.suppressedUntil = suppressedUntil2;
|
|
6602
|
+
}
|
|
6603
|
+
}
|
|
6604
|
+
const suppressedUntil = getNumber(event.payload.suppressedUntil);
|
|
6605
|
+
if (suppressedUntil !== undefined) {
|
|
6606
|
+
entry2.suppressedUntil = suppressedUntil;
|
|
6607
|
+
}
|
|
6608
|
+
const suppressionRemainingMs = getNumber(event.payload.suppressionRemainingMs);
|
|
6609
|
+
if (suppressionRemainingMs !== undefined) {
|
|
6610
|
+
entry2.suppressionRemainingMs = suppressionRemainingMs;
|
|
6611
|
+
}
|
|
6612
|
+
return entry2;
|
|
6613
|
+
};
|
|
6614
|
+
if (providerStatus === "success" || providerStatus === "fallback") {
|
|
6615
|
+
const entry2 = applyProviderHealth();
|
|
6616
|
+
entry2.runCount += 1;
|
|
6617
|
+
entry2.lastSuccessAt = event.at;
|
|
6618
|
+
if (providerStatus === "success") {
|
|
6619
|
+
entry2.lastError = undefined;
|
|
6620
|
+
entry2.rateLimited = false;
|
|
6621
|
+
entry2.suppressedUntil = undefined;
|
|
6622
|
+
entry2.suppressionRemainingMs = undefined;
|
|
6623
|
+
}
|
|
6624
|
+
const elapsedMs = getNumber(event.payload.elapsedMs);
|
|
6625
|
+
if (elapsedMs !== undefined) {
|
|
6626
|
+
entry2.elapsedCount += 1;
|
|
6627
|
+
entry2.elapsedTotal += elapsedMs;
|
|
6628
|
+
}
|
|
6629
|
+
const selectedProvider = event.payload.selectedProvider;
|
|
6630
|
+
if (providerStatus === "fallback" && isAllowedProvider(selectedProvider) && selectedProvider !== provider) {
|
|
6631
|
+
getEntry(selectedProvider).fallbackCount += 1;
|
|
6632
|
+
}
|
|
6633
|
+
continue;
|
|
6634
|
+
}
|
|
6635
|
+
const entry = applyProviderHealth();
|
|
6636
|
+
entry.errorCount += 1;
|
|
6637
|
+
if (event.payload.timedOut === true) {
|
|
6638
|
+
entry.timeoutCount += 1;
|
|
6639
|
+
}
|
|
6640
|
+
entry.lastError = getString(event.payload.error);
|
|
6641
|
+
entry.lastErrorAt = event.at;
|
|
6642
|
+
entry.rateLimited ||= event.payload.rateLimited === true;
|
|
6643
|
+
}
|
|
6644
|
+
const summaries = [...entries.values()].map((entry) => {
|
|
6645
|
+
const hadSuppression = typeof entry.suppressedUntil === "number" || typeof entry.suppressionRemainingMs === "number";
|
|
6646
|
+
const suppressionRemainingMs = typeof entry.suppressedUntil === "number" ? Math.max(0, entry.suppressedUntil - now) : entry.suppressionRemainingMs;
|
|
6647
|
+
const activeSuppression = typeof suppressionRemainingMs === "number" && suppressionRemainingMs > 0;
|
|
6648
|
+
const recoverable = hadSuppression && !activeSuppression;
|
|
6649
|
+
const averageElapsedMs = entry.elapsedCount > 0 ? Math.round(entry.elapsedTotal / entry.elapsedCount) : undefined;
|
|
6650
|
+
const status = activeSuppression ? "suppressed" : recoverable ? "recoverable" : entry.rateLimited ? "rate-limited" : entry.errorCount > 0 && (!entry.lastSuccessAt || !entry.lastErrorAt || entry.lastErrorAt > entry.lastSuccessAt) ? "degraded" : entry.runCount > 0 ? "healthy" : "idle";
|
|
6651
|
+
return {
|
|
6652
|
+
averageElapsedMs,
|
|
6653
|
+
errorCount: entry.errorCount,
|
|
6654
|
+
fallbackCount: entry.fallbackCount,
|
|
6655
|
+
lastError: entry.lastError,
|
|
6656
|
+
lastErrorAt: entry.lastErrorAt,
|
|
6657
|
+
lastSuccessAt: entry.lastSuccessAt,
|
|
6658
|
+
provider: entry.provider,
|
|
6659
|
+
rateLimited: entry.rateLimited,
|
|
6660
|
+
recommended: false,
|
|
6661
|
+
runCount: entry.runCount,
|
|
6662
|
+
status,
|
|
6663
|
+
suppressionRemainingMs: activeSuppression ? suppressionRemainingMs : undefined,
|
|
6664
|
+
suppressedUntil: entry.suppressedUntil,
|
|
6665
|
+
timeoutCount: entry.timeoutCount
|
|
6666
|
+
};
|
|
6667
|
+
});
|
|
6668
|
+
const recommended = summaries.filter((entry) => entry.status === "healthy").sort((left, right) => (left.averageElapsedMs ?? Number.MAX_SAFE_INTEGER) - (right.averageElapsedMs ?? Number.MAX_SAFE_INTEGER))[0];
|
|
6669
|
+
if (recommended) {
|
|
6670
|
+
recommended.recommended = true;
|
|
6671
|
+
}
|
|
6672
|
+
return summaries;
|
|
6673
|
+
};
|
|
6674
|
+
var escapeHtml3 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
6675
|
+
var renderVoiceProviderHealthHTML = (providers) => providers.length === 0 ? '<p class="voice-provider-empty">No provider status yet.</p>' : [
|
|
6676
|
+
'<div class="voice-provider-health">',
|
|
6677
|
+
...providers.map((provider) => {
|
|
6678
|
+
const suppressionSeconds = typeof provider.suppressionRemainingMs === "number" ? Math.ceil(provider.suppressionRemainingMs / 1000) : undefined;
|
|
6679
|
+
return [
|
|
6680
|
+
`<article class="voice-provider-card ${escapeHtml3(provider.status)}">`,
|
|
6681
|
+
'<div class="voice-provider-card-header">',
|
|
6682
|
+
`<strong>${escapeHtml3(provider.provider)}</strong>`,
|
|
6683
|
+
`<span>${escapeHtml3(provider.status)}${provider.recommended ? " \xB7 recommended" : ""}</span>`,
|
|
6684
|
+
"</div>",
|
|
6685
|
+
"<dl>",
|
|
6686
|
+
`<div><dt>Runs</dt><dd>${String(provider.runCount)}</dd></div>`,
|
|
6687
|
+
`<div><dt>Avg latency</dt><dd>${String(provider.averageElapsedMs ?? 0)}ms</dd></div>`,
|
|
6688
|
+
`<div><dt>Errors</dt><dd>${String(provider.errorCount)}</dd></div>`,
|
|
6689
|
+
`<div><dt>Timeouts</dt><dd>${String(provider.timeoutCount)}</dd></div>`,
|
|
6690
|
+
`<div><dt>Fallbacks</dt><dd>${String(provider.fallbackCount)}</dd></div>`,
|
|
6691
|
+
"</dl>",
|
|
6692
|
+
suppressionSeconds ? `<p>Temporarily suppressed for ${String(suppressionSeconds)}s.</p>` : "",
|
|
6693
|
+
provider.lastError ? `<p>${escapeHtml3(provider.lastError)}</p>` : "",
|
|
6694
|
+
"</article>"
|
|
6695
|
+
].join("");
|
|
6696
|
+
}),
|
|
6697
|
+
"</div>"
|
|
6698
|
+
].join("");
|
|
6699
|
+
var createVoiceProviderHealthJSONHandler = (options) => async () => summarizeVoiceProviderHealth(options);
|
|
6700
|
+
var createVoiceProviderHealthHTMLHandler = (options) => async () => {
|
|
6701
|
+
const providers = await summarizeVoiceProviderHealth(options);
|
|
6702
|
+
const render = options.render ?? renderVoiceProviderHealthHTML;
|
|
6703
|
+
const body = await render(providers);
|
|
6704
|
+
return new Response(body, {
|
|
6705
|
+
headers: {
|
|
6706
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
6707
|
+
...options.headers
|
|
6708
|
+
}
|
|
6709
|
+
});
|
|
6710
|
+
};
|
|
6711
|
+
var createVoiceProviderHealthRoutes = (options) => {
|
|
6712
|
+
const path = options.path ?? "/api/provider-status";
|
|
6713
|
+
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
6714
|
+
const routes = new Elysia2({
|
|
6715
|
+
name: options.name ?? "absolutejs-voice-provider-health"
|
|
6716
|
+
}).get(path, createVoiceProviderHealthJSONHandler(options));
|
|
6717
|
+
if (htmlPath) {
|
|
6718
|
+
routes.get(htmlPath, createVoiceProviderHealthHTMLHandler(options));
|
|
6719
|
+
}
|
|
6720
|
+
return routes;
|
|
6721
|
+
};
|
|
6722
|
+
|
|
6723
|
+
// src/assistantHealth.ts
|
|
6724
|
+
var escapeHtml4 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
6725
|
+
var renderCountMap = (values) => {
|
|
6726
|
+
const entries = Object.entries(values).sort((left, right) => right[1] - left[1]);
|
|
6727
|
+
if (entries.length === 0) {
|
|
6728
|
+
return '<p class="voice-assistant-health-empty">No data yet.</p>';
|
|
6729
|
+
}
|
|
6730
|
+
return [
|
|
6731
|
+
'<div class="voice-assistant-health-metrics">',
|
|
6732
|
+
...entries.map(([label, value]) => `<div><span>${escapeHtml4(label)}</span><strong>${String(value)}</strong></div>`),
|
|
6733
|
+
"</div>"
|
|
6734
|
+
].join("");
|
|
6735
|
+
};
|
|
6736
|
+
var getString2 = (value) => typeof value === "string" ? value : undefined;
|
|
6737
|
+
var getRecentFailures = (events, maxFailures, replayHref) => events.filter((event) => event.type === "session.error" && (event.payload.providerStatus === "error" || typeof event.payload.error === "string") || event.type === "assistant.guardrail" && event.payload.action === "blocked").toReversed().slice(0, maxFailures).map((event) => {
|
|
6738
|
+
const failure = {
|
|
6739
|
+
at: event.at,
|
|
6740
|
+
assistantId: getString2(event.payload.assistantId),
|
|
6741
|
+
error: getString2(event.payload.error),
|
|
6742
|
+
provider: getString2(event.payload.provider),
|
|
6743
|
+
rateLimited: event.payload.rateLimited === true ? true : undefined,
|
|
6744
|
+
sessionId: event.sessionId,
|
|
6745
|
+
status: getString2(event.payload.providerStatus),
|
|
6746
|
+
turnId: event.turnId,
|
|
6747
|
+
type: event.type
|
|
6748
|
+
};
|
|
6749
|
+
const href = replayHref === false ? undefined : typeof replayHref === "function" ? replayHref(failure) : `${replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(event.sessionId)}/replay/htmx`;
|
|
6750
|
+
return {
|
|
6751
|
+
...failure,
|
|
6752
|
+
replayHref: href
|
|
6753
|
+
};
|
|
6754
|
+
});
|
|
6755
|
+
var summarizeVoiceAssistantHealth = async (options) => {
|
|
6756
|
+
const events = options.events ?? await options.store?.list() ?? [];
|
|
6757
|
+
return {
|
|
6758
|
+
assistantRuns: await summarizeVoiceAssistantRuns({ events }),
|
|
6759
|
+
providerHealth: await summarizeVoiceProviderHealth({
|
|
6760
|
+
events,
|
|
6761
|
+
providers: options.providers
|
|
6762
|
+
}),
|
|
6763
|
+
recentFailures: getRecentFailures(events, options.maxFailures ?? 8, options.replayHref)
|
|
6764
|
+
};
|
|
6765
|
+
};
|
|
6766
|
+
var renderVoiceAssistantHealthHTML = (summary) => {
|
|
6767
|
+
const assistant = summary.assistantRuns.assistants[0];
|
|
6768
|
+
const failures = summary.recentFailures;
|
|
6769
|
+
return [
|
|
6770
|
+
'<div class="voice-assistant-health">',
|
|
6771
|
+
'<section class="voice-assistant-health-grid">',
|
|
6772
|
+
`<article><span>Runs</span><strong>${String(assistant?.runCount ?? 0)}</strong></article>`,
|
|
6773
|
+
`<article><span>Sessions</span><strong>${String(assistant?.sessions ?? 0)}</strong></article>`,
|
|
6774
|
+
`<article><span>Guardrails</span><strong>${String(assistant?.guardrailCount ?? 0)}</strong></article>`,
|
|
6775
|
+
`<article><span>Avg latency</span><strong>${String(assistant?.averageElapsedMs ?? 0)}ms</strong></article>`,
|
|
6776
|
+
"</section>",
|
|
6777
|
+
"<section>",
|
|
6778
|
+
"<h3>Provider Health</h3>",
|
|
6779
|
+
renderVoiceProviderHealthHTML(summary.providerHealth),
|
|
6780
|
+
"</section>",
|
|
6781
|
+
'<section class="voice-assistant-health-columns">',
|
|
6782
|
+
`<article><h3>Outcomes</h3>${renderCountMap(assistant?.outcomes ?? {})}</article>`,
|
|
6783
|
+
`<article><h3>Variants</h3>${renderCountMap(assistant?.variants ?? {})}</article>`,
|
|
6784
|
+
`<article><h3>Tools</h3>${renderCountMap(assistant?.toolCalls ?? {})}</article>`,
|
|
6785
|
+
`<article><h3>Artifact Plans</h3>${renderCountMap(assistant?.artifactPlans ?? {})}</article>`,
|
|
6786
|
+
"</section>",
|
|
6787
|
+
"<section>",
|
|
6788
|
+
"<h3>Recent Failures</h3>",
|
|
6789
|
+
failures.length === 0 ? '<p class="voice-assistant-health-empty">No failures yet.</p>' : [
|
|
6790
|
+
'<div class="voice-assistant-health-failures">',
|
|
6791
|
+
...failures.map((failure) => [
|
|
6792
|
+
"<article>",
|
|
6793
|
+
`<strong>${escapeHtml4(failure.provider ?? failure.assistantId ?? failure.type)}</strong>`,
|
|
6794
|
+
`<span>${escapeHtml4(failure.status ?? (failure.rateLimited ? "rate-limited" : "error"))}</span>`,
|
|
6795
|
+
failure.error ? `<p>${escapeHtml4(failure.error)}</p>` : "",
|
|
6796
|
+
`<small>${escapeHtml4(failure.sessionId)}${failure.turnId ? ` / ${escapeHtml4(failure.turnId)}` : ""}</small>`,
|
|
6797
|
+
failure.replayHref ? `<p><a href="${escapeHtml4(failure.replayHref)}">Open replay</a></p>` : "",
|
|
6798
|
+
"</article>"
|
|
6799
|
+
].join("")),
|
|
6800
|
+
"</div>"
|
|
6801
|
+
].join(""),
|
|
6802
|
+
"</section>",
|
|
6803
|
+
"</div>"
|
|
6804
|
+
].join("");
|
|
6805
|
+
};
|
|
6806
|
+
var createVoiceAssistantHealthJSONHandler = (options) => async () => summarizeVoiceAssistantHealth(options);
|
|
6807
|
+
var createVoiceAssistantHealthHTMLHandler = (options) => async () => {
|
|
6808
|
+
const summary = await summarizeVoiceAssistantHealth(options);
|
|
6809
|
+
const render = options.render ?? renderVoiceAssistantHealthHTML;
|
|
6810
|
+
const body = await render(summary);
|
|
6811
|
+
return new Response(body, {
|
|
6812
|
+
headers: {
|
|
6813
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
6814
|
+
...options.headers
|
|
6815
|
+
}
|
|
6816
|
+
});
|
|
6817
|
+
};
|
|
6818
|
+
var createVoiceAssistantHealthRoutes = (options) => {
|
|
6819
|
+
const path = options.path ?? "/api/assistant-health";
|
|
6820
|
+
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
6821
|
+
const routes = new Elysia3({
|
|
6822
|
+
name: options.name ?? "absolutejs-voice-assistant-health"
|
|
6823
|
+
}).get(path, createVoiceAssistantHealthJSONHandler(options));
|
|
6824
|
+
if (htmlPath) {
|
|
6825
|
+
routes.get(htmlPath, createVoiceAssistantHealthHTMLHandler(options));
|
|
6826
|
+
}
|
|
6827
|
+
return routes;
|
|
6828
|
+
};
|
|
6829
|
+
|
|
6830
|
+
// src/diagnosticsRoutes.ts
|
|
6831
|
+
import { Elysia as Elysia4 } from "elysia";
|
|
6832
|
+
|
|
6833
|
+
// src/trace.ts
|
|
6834
|
+
var createVoiceTraceEventId = (event) => [
|
|
6835
|
+
event.sessionId,
|
|
6836
|
+
event.turnId ?? "session",
|
|
6837
|
+
event.type,
|
|
6838
|
+
String(event.at ?? Date.now()),
|
|
6839
|
+
crypto.randomUUID()
|
|
6840
|
+
].map(encodeURIComponent).join(":");
|
|
6841
|
+
var createVoiceTraceEvent = (event) => ({
|
|
6842
|
+
...event,
|
|
6843
|
+
at: event.at,
|
|
6844
|
+
id: event.id ?? createVoiceTraceEventId({
|
|
6845
|
+
at: event.at,
|
|
6846
|
+
sessionId: event.sessionId,
|
|
6847
|
+
turnId: event.turnId,
|
|
6848
|
+
type: event.type
|
|
6849
|
+
})
|
|
6850
|
+
});
|
|
6851
|
+
var createVoiceTraceSinkDeliveryId = (events) => {
|
|
6852
|
+
const firstEvent = events[0];
|
|
6853
|
+
return [
|
|
6854
|
+
firstEvent?.sessionId ?? "trace",
|
|
6855
|
+
firstEvent?.traceId ?? "sink",
|
|
6856
|
+
String(firstEvent?.at ?? Date.now()),
|
|
6857
|
+
crypto.randomUUID()
|
|
6858
|
+
].map(encodeURIComponent).join(":");
|
|
6859
|
+
};
|
|
6860
|
+
var createVoiceTraceSinkDeliveryRecord = (input) => {
|
|
6861
|
+
const createdAt = input.createdAt ?? Date.now();
|
|
6862
|
+
return {
|
|
6863
|
+
createdAt,
|
|
6864
|
+
deliveredAt: input.deliveredAt,
|
|
6865
|
+
deliveryAttempts: input.deliveryAttempts,
|
|
6866
|
+
deliveryError: input.deliveryError,
|
|
6867
|
+
deliveryStatus: input.deliveryStatus ?? "pending",
|
|
6868
|
+
events: input.events,
|
|
6869
|
+
id: input.id ?? createVoiceTraceSinkDeliveryId(input.events),
|
|
6870
|
+
sinkDeliveries: input.sinkDeliveries,
|
|
6871
|
+
updatedAt: input.updatedAt ?? createdAt
|
|
6872
|
+
};
|
|
6873
|
+
};
|
|
6874
|
+
var matchesTraceFilter = (event, filter) => {
|
|
6150
6875
|
if (filter.sessionId !== undefined && event.sessionId !== filter.sessionId) {
|
|
6151
6876
|
return false;
|
|
6152
6877
|
}
|
|
@@ -6207,7 +6932,7 @@ var sleep3 = async (delayMs) => {
|
|
|
6207
6932
|
}
|
|
6208
6933
|
await new Promise((resolve2) => setTimeout(resolve2, delayMs));
|
|
6209
6934
|
};
|
|
6210
|
-
var
|
|
6935
|
+
var toHex4 = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
6211
6936
|
var signVoiceTraceSinkBody = async (input) => {
|
|
6212
6937
|
const encoder = new TextEncoder;
|
|
6213
6938
|
const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
|
|
@@ -6216,7 +6941,7 @@ var signVoiceTraceSinkBody = async (input) => {
|
|
|
6216
6941
|
}, false, ["sign"]);
|
|
6217
6942
|
const payload = encoder.encode(`${input.timestamp}.${input.body}`);
|
|
6218
6943
|
const signature = await crypto.subtle.sign("HMAC", key, payload);
|
|
6219
|
-
return `sha256=${
|
|
6944
|
+
return `sha256=${toHex4(new Uint8Array(signature))}`;
|
|
6220
6945
|
};
|
|
6221
6946
|
var createVoiceTraceSinkDeliveryError = (input) => {
|
|
6222
6947
|
if (input.response) {
|
|
@@ -6437,7 +7162,7 @@ var exportVoiceTrace = async (input) => {
|
|
|
6437
7162
|
};
|
|
6438
7163
|
};
|
|
6439
7164
|
var toNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
6440
|
-
var
|
|
7165
|
+
var escapeHtml5 = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
6441
7166
|
var formatTraceValue = (value) => {
|
|
6442
7167
|
if (value === undefined || value === null) {
|
|
6443
7168
|
return "";
|
|
@@ -6715,10 +7440,10 @@ var renderVoiceTraceHTML = (events, options = {}) => {
|
|
|
6715
7440
|
const offset = summary.startedAt === undefined ? event.at : Math.max(0, event.at - summary.startedAt);
|
|
6716
7441
|
return [
|
|
6717
7442
|
"<tr>",
|
|
6718
|
-
`<td>${
|
|
6719
|
-
`<td>${
|
|
6720
|
-
`<td>${
|
|
6721
|
-
`<td><code>${
|
|
7443
|
+
`<td>${escapeHtml5(String(offset))}</td>`,
|
|
7444
|
+
`<td>${escapeHtml5(event.type)}</td>`,
|
|
7445
|
+
`<td>${escapeHtml5(event.turnId ?? "")}</td>`,
|
|
7446
|
+
`<td><code>${escapeHtml5(JSON.stringify(event.payload))}</code></td>`,
|
|
6722
7447
|
"</tr>"
|
|
6723
7448
|
].join("");
|
|
6724
7449
|
}).join(`
|
|
@@ -6729,7 +7454,7 @@ var renderVoiceTraceHTML = (events, options = {}) => {
|
|
|
6729
7454
|
"<head>",
|
|
6730
7455
|
'<meta charset="utf-8" />',
|
|
6731
7456
|
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
6732
|
-
`<title>${
|
|
7457
|
+
`<title>${escapeHtml5(options.title ?? "Voice Trace")}</title>`,
|
|
6733
7458
|
"<style>",
|
|
6734
7459
|
"body{font-family:ui-sans-serif,system-ui,sans-serif;margin:2rem;line-height:1.45;background:#f8f7f2;color:#181713}",
|
|
6735
7460
|
"main{max-width:1100px;margin:auto}",
|
|
@@ -6743,7 +7468,7 @@ var renderVoiceTraceHTML = (events, options = {}) => {
|
|
|
6743
7468
|
"</style>",
|
|
6744
7469
|
"</head>",
|
|
6745
7470
|
"<body><main>",
|
|
6746
|
-
`<h1>${
|
|
7471
|
+
`<h1>${escapeHtml5(options.title ?? `Voice Trace ${summary.sessionId ?? ""}`.trim())}</h1>`,
|
|
6747
7472
|
`<p class="${evaluation.pass ? "pass" : "fail"}">QA: ${evaluation.pass ? "pass" : "fail"}</p>`,
|
|
6748
7473
|
'<section class="summary">',
|
|
6749
7474
|
`<div class="card"><strong>Events</strong><br>${summary.eventCount}</div>`,
|
|
@@ -6757,19 +7482,2255 @@ var renderVoiceTraceHTML = (events, options = {}) => {
|
|
|
6757
7482
|
eventRows,
|
|
6758
7483
|
"</tbody></table>",
|
|
6759
7484
|
"<h2>Markdown Export</h2>",
|
|
6760
|
-
`<pre>${
|
|
7485
|
+
`<pre>${escapeHtml5(markdown)}</pre>`,
|
|
6761
7486
|
"</main></body></html>"
|
|
6762
7487
|
].join(`
|
|
6763
7488
|
`);
|
|
6764
7489
|
};
|
|
6765
|
-
var buildVoiceTraceReplay = (events, options = {}) => ({
|
|
6766
|
-
evaluation: evaluateVoiceTrace(options.redact ? redactVoiceTraceEvents(events, options.redact) : events, options.evaluation),
|
|
6767
|
-
html: renderVoiceTraceHTML(events, options),
|
|
6768
|
-
markdown: renderVoiceTraceMarkdown(events, options),
|
|
6769
|
-
summary: summarizeVoiceTrace(options.redact ? redactVoiceTraceEvents(events, options.redact) : events)
|
|
7490
|
+
var buildVoiceTraceReplay = (events, options = {}) => ({
|
|
7491
|
+
evaluation: evaluateVoiceTrace(options.redact ? redactVoiceTraceEvents(events, options.redact) : events, options.evaluation),
|
|
7492
|
+
html: renderVoiceTraceHTML(events, options),
|
|
7493
|
+
markdown: renderVoiceTraceMarkdown(events, options),
|
|
7494
|
+
summary: summarizeVoiceTrace(options.redact ? redactVoiceTraceEvents(events, options.redact) : events)
|
|
7495
|
+
});
|
|
7496
|
+
|
|
7497
|
+
// src/diagnosticsRoutes.ts
|
|
7498
|
+
var escapeHtml6 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
7499
|
+
var getString3 = (value) => typeof value === "string" && value.trim() ? value : undefined;
|
|
7500
|
+
var getNumber2 = (value) => {
|
|
7501
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : undefined;
|
|
7502
|
+
return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : undefined;
|
|
7503
|
+
};
|
|
7504
|
+
var getBoolean = (value) => value === true || value === "true" || value === "1";
|
|
7505
|
+
var parseTraceTypeFilter = (value) => {
|
|
7506
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
7507
|
+
return;
|
|
7508
|
+
}
|
|
7509
|
+
const types = value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
7510
|
+
return types.length <= 1 ? types[0] : types;
|
|
7511
|
+
};
|
|
7512
|
+
var resolveVoiceDiagnosticsTraceFilter = (query) => ({
|
|
7513
|
+
limit: getNumber2(query.limit),
|
|
7514
|
+
scenarioId: getString3(query.scenarioId),
|
|
7515
|
+
sessionId: getString3(query.sessionId),
|
|
7516
|
+
traceId: getString3(query.traceId),
|
|
7517
|
+
turnId: getString3(query.turnId),
|
|
7518
|
+
type: parseTraceTypeFilter(query.type)
|
|
7519
|
+
});
|
|
7520
|
+
var filterByDiagnosticsQuery = (events, query) => {
|
|
7521
|
+
const provider = getString3(query.provider);
|
|
7522
|
+
const status = getString3(query.status);
|
|
7523
|
+
const since = getNumber2(query.since);
|
|
7524
|
+
const until = getNumber2(query.until);
|
|
7525
|
+
return filterVoiceTraceEvents(events, resolveVoiceDiagnosticsTraceFilter(query)).filter((event) => (!provider || event.payload.provider === provider) && (!status || event.payload.providerStatus === status || event.payload.status === status) && (since === undefined || event.at >= since) && (until === undefined || event.at <= until));
|
|
7526
|
+
};
|
|
7527
|
+
var buildVoiceDiagnosticsMarkdown = (events, options = {}) => {
|
|
7528
|
+
const summary = summarizeVoiceTrace(events);
|
|
7529
|
+
const evaluation = evaluateVoiceTrace(events, options.evaluation);
|
|
7530
|
+
const trace = renderVoiceTraceMarkdown(events, {
|
|
7531
|
+
evaluation: options.evaluation,
|
|
7532
|
+
title: options.title ?? `Voice Diagnostics ${summary.sessionId ?? ""}`.trim()
|
|
7533
|
+
});
|
|
7534
|
+
return [
|
|
7535
|
+
`# ${options.title ?? "Voice Diagnostics Bug Report"}`,
|
|
7536
|
+
"",
|
|
7537
|
+
`Session: ${summary.sessionId ?? "unknown"}`,
|
|
7538
|
+
`Pass: ${evaluation.pass ? "yes" : "no"}`,
|
|
7539
|
+
`Events: ${summary.eventCount}`,
|
|
7540
|
+
`Turns: ${summary.turnCount}`,
|
|
7541
|
+
`Errors: ${summary.errorCount}`,
|
|
7542
|
+
`Tool errors: ${summary.toolErrorCount}`,
|
|
7543
|
+
`Estimated cost units: ${summary.cost.estimatedRelativeCostUnits}`,
|
|
7544
|
+
"",
|
|
7545
|
+
"## Issues",
|
|
7546
|
+
"",
|
|
7547
|
+
evaluation.issues.length ? evaluation.issues.map((issue) => `- [${issue.severity}] ${issue.code}: ${issue.message}`).join(`
|
|
7548
|
+
`) : "- none",
|
|
7549
|
+
"",
|
|
7550
|
+
"## Trace",
|
|
7551
|
+
"",
|
|
7552
|
+
trace
|
|
7553
|
+
].join(`
|
|
7554
|
+
`);
|
|
7555
|
+
};
|
|
7556
|
+
var renderDiagnosticsIndex = (input) => {
|
|
7557
|
+
const sessions = new Map;
|
|
7558
|
+
for (const event of input.events) {
|
|
7559
|
+
sessions.set(event.sessionId, [...sessions.get(event.sessionId) ?? [], event]);
|
|
7560
|
+
}
|
|
7561
|
+
const rows = [...sessions.entries()].sort(([, left], [, right]) => (right.at(-1)?.at ?? 0) - (left.at(-1)?.at ?? 0)).slice(0, 50).map(([sessionId, events]) => {
|
|
7562
|
+
const summary = summarizeVoiceTrace(events);
|
|
7563
|
+
const encoded = encodeURIComponent(sessionId);
|
|
7564
|
+
return `<tr><td>${escapeHtml6(sessionId)}</td><td>${summary.eventCount}</td><td>${summary.turnCount}</td><td>${summary.errorCount}</td><td><a href="${input.basePath}/html?sessionId=${encoded}&redact=true">HTML</a> \xB7 <a href="${input.basePath}/markdown?sessionId=${encoded}&redact=true">Markdown</a> \xB7 <a href="${input.basePath}/json?sessionId=${encoded}&redact=true">JSON</a></td></tr>`;
|
|
7565
|
+
}).join("");
|
|
7566
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml6(input.title)}</title><style>body{font-family:ui-sans-serif,system-ui,sans-serif;margin:2rem;background:#f8f7f2;color:#181713}main{max-width:1100px;margin:auto}table{width:100%;border-collapse:collapse;background:white}td,th{border-bottom:1px solid #eee;padding:.7rem;text-align:left}a{color:#9a3412}</style></head><body><main><h1>${escapeHtml6(input.title)}</h1><p>Recent voice trace diagnostics. Exports support filters: sessionId, traceId, turnId, scenarioId, type, provider, status, since, until, limit, redact.</p><table><thead><tr><th>Session</th><th>Events</th><th>Turns</th><th>Errors</th><th>Exports</th></tr></thead><tbody>${rows}</tbody></table></main></body></html>`;
|
|
7567
|
+
};
|
|
7568
|
+
var withRedaction = (events, query, defaultRedact) => {
|
|
7569
|
+
const shouldRedact = query.redact === undefined ? defaultRedact : getBoolean(query.redact);
|
|
7570
|
+
return shouldRedact ? redactVoiceTraceEvents(events, shouldRedact) : events;
|
|
7571
|
+
};
|
|
7572
|
+
var createVoiceDiagnosticsRoutes = (options) => {
|
|
7573
|
+
const path = options.path ?? "/diagnostics";
|
|
7574
|
+
const title = options.title ?? "AbsoluteJS Voice Diagnostics";
|
|
7575
|
+
const routes = new Elysia4({
|
|
7576
|
+
name: options.name ?? "absolutejs-voice-diagnostics"
|
|
7577
|
+
});
|
|
7578
|
+
routes.get(path, async () => {
|
|
7579
|
+
const events = await options.store.list();
|
|
7580
|
+
return new Response(renderDiagnosticsIndex({ basePath: path, events, title }), {
|
|
7581
|
+
headers: {
|
|
7582
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
7583
|
+
...options.headers
|
|
7584
|
+
}
|
|
7585
|
+
});
|
|
7586
|
+
});
|
|
7587
|
+
routes.get(`${path}/json`, async ({ query }) => {
|
|
7588
|
+
const events = filterByDiagnosticsQuery(await options.store.list(), query);
|
|
7589
|
+
const redacted = withRedaction(events, query, options.redact);
|
|
7590
|
+
return Response.json({
|
|
7591
|
+
...await exportVoiceTrace({
|
|
7592
|
+
filter: resolveVoiceDiagnosticsTraceFilter(query),
|
|
7593
|
+
redact: false,
|
|
7594
|
+
store: {
|
|
7595
|
+
...options.store,
|
|
7596
|
+
list: async () => redacted
|
|
7597
|
+
}
|
|
7598
|
+
}),
|
|
7599
|
+
filteredCount: events.length,
|
|
7600
|
+
redacted: redacted !== events
|
|
7601
|
+
});
|
|
7602
|
+
});
|
|
7603
|
+
routes.get(`${path}/markdown`, async ({ query }) => {
|
|
7604
|
+
const events = withRedaction(filterByDiagnosticsQuery(await options.store.list(), query), query, options.redact ?? true);
|
|
7605
|
+
const body = buildVoiceDiagnosticsMarkdown(events, {
|
|
7606
|
+
evaluation: options.evaluation,
|
|
7607
|
+
title
|
|
7608
|
+
});
|
|
7609
|
+
return new Response(body, {
|
|
7610
|
+
headers: {
|
|
7611
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
7612
|
+
...options.headers
|
|
7613
|
+
}
|
|
7614
|
+
});
|
|
7615
|
+
});
|
|
7616
|
+
routes.get(`${path}/html`, async ({ query }) => {
|
|
7617
|
+
const events = withRedaction(filterByDiagnosticsQuery(await options.store.list(), query), query, options.redact ?? true);
|
|
7618
|
+
const body = renderVoiceTraceHTML(events, {
|
|
7619
|
+
evaluation: options.evaluation,
|
|
7620
|
+
title
|
|
7621
|
+
});
|
|
7622
|
+
return new Response(body, {
|
|
7623
|
+
headers: {
|
|
7624
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
7625
|
+
...options.headers
|
|
7626
|
+
}
|
|
7627
|
+
});
|
|
7628
|
+
});
|
|
7629
|
+
return routes;
|
|
7630
|
+
};
|
|
7631
|
+
|
|
7632
|
+
// src/evalRoutes.ts
|
|
7633
|
+
import { Elysia as Elysia7 } from "elysia";
|
|
7634
|
+
import { mkdir } from "fs/promises";
|
|
7635
|
+
import { dirname } from "path";
|
|
7636
|
+
|
|
7637
|
+
// src/qualityRoutes.ts
|
|
7638
|
+
import { Elysia as Elysia6 } from "elysia";
|
|
7639
|
+
|
|
7640
|
+
// src/handoffHealth.ts
|
|
7641
|
+
import { Elysia as Elysia5 } from "elysia";
|
|
7642
|
+
var escapeHtml7 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
7643
|
+
var getString4 = (value) => typeof value === "string" && value.length > 0 ? value : undefined;
|
|
7644
|
+
var isStatus = (value) => value === "delivered" || value === "failed" || value === "skipped";
|
|
7645
|
+
var increment2 = (record, key) => {
|
|
7646
|
+
record[key] = (record[key] ?? 0) + 1;
|
|
7647
|
+
};
|
|
7648
|
+
var normalizeDelivery = (adapterId, value) => {
|
|
7649
|
+
const record = value && typeof value === "object" ? value : {};
|
|
7650
|
+
return {
|
|
7651
|
+
adapterId: getString4(record.adapterId) ?? adapterId,
|
|
7652
|
+
adapterKind: getString4(record.adapterKind),
|
|
7653
|
+
deliveredAt: typeof record.deliveredAt === "number" ? record.deliveredAt : undefined,
|
|
7654
|
+
deliveredTo: getString4(record.deliveredTo),
|
|
7655
|
+
error: getString4(record.error),
|
|
7656
|
+
status: isStatus(record.status) ? record.status : "failed"
|
|
7657
|
+
};
|
|
7658
|
+
};
|
|
7659
|
+
var normalizeDeliveries = (payload) => {
|
|
7660
|
+
const deliveries = payload.deliveries;
|
|
7661
|
+
if (!deliveries || typeof deliveries !== "object") {
|
|
7662
|
+
return [];
|
|
7663
|
+
}
|
|
7664
|
+
return Object.entries(deliveries).map(([adapterId, value]) => normalizeDelivery(adapterId, value));
|
|
7665
|
+
};
|
|
7666
|
+
var resolveReplayHref = (event, replayHref) => {
|
|
7667
|
+
if (replayHref === false) {
|
|
7668
|
+
return;
|
|
7669
|
+
}
|
|
7670
|
+
if (typeof replayHref === "function") {
|
|
7671
|
+
return replayHref(event);
|
|
7672
|
+
}
|
|
7673
|
+
return `${replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(event.sessionId)}/replay/htmx`;
|
|
7674
|
+
};
|
|
7675
|
+
var summarizeVoiceHandoffHealth = async (options = {}) => {
|
|
7676
|
+
const sourceEvents = options.events ?? await options.store?.list() ?? [];
|
|
7677
|
+
const search = options.q?.trim().toLowerCase();
|
|
7678
|
+
const byAction = {};
|
|
7679
|
+
const byAdapter = {};
|
|
7680
|
+
const byStatus = {
|
|
7681
|
+
delivered: 0,
|
|
7682
|
+
failed: 0,
|
|
7683
|
+
skipped: 0
|
|
7684
|
+
};
|
|
7685
|
+
const events = sourceEvents.filter((event) => event.type === "call.handoff").map((event) => {
|
|
7686
|
+
const status = isStatus(event.payload.status) ? event.payload.status : "failed";
|
|
7687
|
+
const deliveries = normalizeDeliveries(event.payload);
|
|
7688
|
+
const item = {
|
|
7689
|
+
action: getString4(event.payload.action),
|
|
7690
|
+
at: event.at,
|
|
7691
|
+
deliveries,
|
|
7692
|
+
reason: getString4(event.payload.reason),
|
|
7693
|
+
sessionId: event.sessionId,
|
|
7694
|
+
status,
|
|
7695
|
+
target: getString4(event.payload.target)
|
|
7696
|
+
};
|
|
7697
|
+
return {
|
|
7698
|
+
...item,
|
|
7699
|
+
replayHref: resolveReplayHref(item, options.replayHref)
|
|
7700
|
+
};
|
|
7701
|
+
}).filter((event) => {
|
|
7702
|
+
if (options.status && options.status !== "all" && event.status !== options.status) {
|
|
7703
|
+
return false;
|
|
7704
|
+
}
|
|
7705
|
+
if (!search) {
|
|
7706
|
+
return true;
|
|
7707
|
+
}
|
|
7708
|
+
return [
|
|
7709
|
+
event.action,
|
|
7710
|
+
event.reason,
|
|
7711
|
+
event.sessionId,
|
|
7712
|
+
event.status,
|
|
7713
|
+
event.target,
|
|
7714
|
+
...event.deliveries.flatMap((delivery) => [
|
|
7715
|
+
delivery.adapterId,
|
|
7716
|
+
delivery.adapterKind,
|
|
7717
|
+
delivery.deliveredTo,
|
|
7718
|
+
delivery.error,
|
|
7719
|
+
delivery.status
|
|
7720
|
+
])
|
|
7721
|
+
].some((value) => value?.toLowerCase().includes(search));
|
|
7722
|
+
}).sort((left, right) => right.at - left.at).slice(0, options.limit ?? 50);
|
|
7723
|
+
for (const event of events) {
|
|
7724
|
+
byStatus[event.status] += 1;
|
|
7725
|
+
if (event.action) {
|
|
7726
|
+
increment2(byAction, event.action);
|
|
7727
|
+
}
|
|
7728
|
+
for (const delivery of event.deliveries) {
|
|
7729
|
+
byAdapter[delivery.adapterId] ??= {
|
|
7730
|
+
delivered: 0,
|
|
7731
|
+
failed: 0,
|
|
7732
|
+
skipped: 0
|
|
7733
|
+
};
|
|
7734
|
+
byAdapter[delivery.adapterId][delivery.status] += 1;
|
|
7735
|
+
}
|
|
7736
|
+
}
|
|
7737
|
+
return {
|
|
7738
|
+
byAction,
|
|
7739
|
+
byAdapter,
|
|
7740
|
+
byStatus,
|
|
7741
|
+
events,
|
|
7742
|
+
failed: byStatus.failed,
|
|
7743
|
+
total: events.length
|
|
7744
|
+
};
|
|
7745
|
+
};
|
|
7746
|
+
var renderMetricGrid = (summary) => [
|
|
7747
|
+
'<section class="voice-handoff-health-grid">',
|
|
7748
|
+
`<article><span>Total</span><strong>${String(summary.total)}</strong></article>`,
|
|
7749
|
+
`<article><span>Delivered</span><strong>${String(summary.byStatus.delivered)}</strong></article>`,
|
|
7750
|
+
`<article><span>Failed</span><strong>${String(summary.byStatus.failed)}</strong></article>`,
|
|
7751
|
+
`<article><span>Skipped</span><strong>${String(summary.byStatus.skipped)}</strong></article>`,
|
|
7752
|
+
"</section>"
|
|
7753
|
+
].join("");
|
|
7754
|
+
var renderActionSummary = (summary) => {
|
|
7755
|
+
const actions = Object.entries(summary.byAction).sort((left, right) => right[1] - left[1]);
|
|
7756
|
+
const adapters = Object.entries(summary.byAdapter).sort(([left], [right]) => left.localeCompare(right));
|
|
7757
|
+
return [
|
|
7758
|
+
'<section class="voice-handoff-health-columns">',
|
|
7759
|
+
"<article><h3>Actions</h3>",
|
|
7760
|
+
actions.length === 0 ? "<p>No handoff actions yet.</p>" : `<ul>${actions.map(([action, count]) => `<li>${escapeHtml7(action)}: ${String(count)}</li>`).join("")}</ul>`,
|
|
7761
|
+
"</article>",
|
|
7762
|
+
"<article><h3>Adapters</h3>",
|
|
7763
|
+
adapters.length === 0 ? "<p>No adapter deliveries yet.</p>" : `<ul>${adapters.map(([adapterId, counts]) => `<li>${escapeHtml7(adapterId)}: ${String(counts.delivered)} delivered / ${String(counts.failed)} failed / ${String(counts.skipped)} skipped</li>`).join("")}</ul>`,
|
|
7764
|
+
"</article>",
|
|
7765
|
+
"</section>"
|
|
7766
|
+
].join("");
|
|
7767
|
+
};
|
|
7768
|
+
var renderVoiceHandoffHealthHTML = (summary) => [
|
|
7769
|
+
'<div class="voice-handoff-health">',
|
|
7770
|
+
renderMetricGrid(summary),
|
|
7771
|
+
renderActionSummary(summary),
|
|
7772
|
+
"<section>",
|
|
7773
|
+
"<h3>Recent Handoffs</h3>",
|
|
7774
|
+
summary.events.length === 0 ? '<p class="voice-handoff-health-empty">No handoffs found.</p>' : [
|
|
7775
|
+
'<div class="voice-handoff-health-events">',
|
|
7776
|
+
...summary.events.map((event) => [
|
|
7777
|
+
`<article class="${escapeHtml7(event.status)}">`,
|
|
7778
|
+
'<div class="voice-handoff-health-event-header">',
|
|
7779
|
+
`<strong>${escapeHtml7(event.action ?? "handoff")}</strong>`,
|
|
7780
|
+
`<span>${escapeHtml7(event.status)}</span>`,
|
|
7781
|
+
"</div>",
|
|
7782
|
+
`<p><small>${escapeHtml7(event.sessionId)}</small></p>`,
|
|
7783
|
+
event.target ? `<p>Target: ${escapeHtml7(event.target)}</p>` : "",
|
|
7784
|
+
event.reason ? `<p>Reason: ${escapeHtml7(event.reason)}</p>` : "",
|
|
7785
|
+
event.deliveries.length ? `<ul>${event.deliveries.map((delivery) => [
|
|
7786
|
+
"<li>",
|
|
7787
|
+
`${escapeHtml7(delivery.adapterId)}: ${escapeHtml7(delivery.status)}`,
|
|
7788
|
+
delivery.deliveredTo ? ` to ${escapeHtml7(delivery.deliveredTo)}` : "",
|
|
7789
|
+
delivery.error ? ` (${escapeHtml7(delivery.error)})` : "",
|
|
7790
|
+
"</li>"
|
|
7791
|
+
].join("")).join("")}</ul>` : "",
|
|
7792
|
+
event.replayHref ? `<p><a href="${escapeHtml7(event.replayHref)}">Open replay</a></p>` : "",
|
|
7793
|
+
"</article>"
|
|
7794
|
+
].join("")),
|
|
7795
|
+
"</div>"
|
|
7796
|
+
].join(""),
|
|
7797
|
+
"</section>",
|
|
7798
|
+
"</div>"
|
|
7799
|
+
].join("");
|
|
7800
|
+
var createVoiceHandoffHealthJSONHandler = (options = {}) => async ({ query }) => summarizeVoiceHandoffHealth({
|
|
7801
|
+
...options,
|
|
7802
|
+
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
7803
|
+
q: query?.q ?? options.q,
|
|
7804
|
+
status: query?.status === "delivered" || query?.status === "failed" || query?.status === "skipped" || query?.status === "all" ? query.status : options.status
|
|
7805
|
+
});
|
|
7806
|
+
var createVoiceHandoffHealthHTMLHandler = (options = {}) => async ({ query }) => {
|
|
7807
|
+
const summary = await summarizeVoiceHandoffHealth({
|
|
7808
|
+
...options,
|
|
7809
|
+
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
7810
|
+
q: query?.q ?? options.q,
|
|
7811
|
+
status: query?.status === "delivered" || query?.status === "failed" || query?.status === "skipped" || query?.status === "all" ? query.status : options.status
|
|
7812
|
+
});
|
|
7813
|
+
const body = await (options.render?.(summary) ?? renderVoiceHandoffHealthHTML(summary));
|
|
7814
|
+
return new Response(body, {
|
|
7815
|
+
headers: {
|
|
7816
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
7817
|
+
...options.headers
|
|
7818
|
+
}
|
|
7819
|
+
});
|
|
7820
|
+
};
|
|
7821
|
+
var createVoiceHandoffHealthRoutes = (options = {}) => {
|
|
7822
|
+
const path = options.path ?? "/api/voice-handoffs";
|
|
7823
|
+
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
7824
|
+
const routes = new Elysia5({
|
|
7825
|
+
name: options.name ?? "absolutejs-voice-handoff-health"
|
|
7826
|
+
}).get(path, createVoiceHandoffHealthJSONHandler(options));
|
|
7827
|
+
if (htmlPath) {
|
|
7828
|
+
routes.get(htmlPath, createVoiceHandoffHealthHTMLHandler(options));
|
|
7829
|
+
}
|
|
7830
|
+
return routes;
|
|
7831
|
+
};
|
|
7832
|
+
|
|
7833
|
+
// src/qualityRoutes.ts
|
|
7834
|
+
var DEFAULT_THRESHOLDS = {
|
|
7835
|
+
maxDuplicateTurnRate: 0,
|
|
7836
|
+
maxEmptyTurnRate: 0.02,
|
|
7837
|
+
maxHandoffFailureRate: 0,
|
|
7838
|
+
maxMissingAssistantReplyRate: 0.05,
|
|
7839
|
+
maxProviderAverageLatencyMs: 3000,
|
|
7840
|
+
maxProviderErrorRate: 0.05,
|
|
7841
|
+
maxProviderFallbackRate: 0.25,
|
|
7842
|
+
maxProviderTimeoutRate: 0.03
|
|
7843
|
+
};
|
|
7844
|
+
var getString5 = (value) => typeof value === "string" ? value : undefined;
|
|
7845
|
+
var getNumber3 = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
7846
|
+
var rate = (count, total) => count / Math.max(1, total);
|
|
7847
|
+
var roundMetric2 = (value) => Math.round(value * 1e4) / 1e4;
|
|
7848
|
+
var createMetric = (input) => ({
|
|
7849
|
+
...input,
|
|
7850
|
+
actual: roundMetric2(input.actual),
|
|
7851
|
+
pass: input.actual <= input.threshold
|
|
7852
|
+
});
|
|
7853
|
+
var evaluateVoiceQuality = async (input) => {
|
|
7854
|
+
const events = filterVoiceTraceEvents(input.events ?? await input.store?.list() ?? []);
|
|
7855
|
+
const thresholds = {
|
|
7856
|
+
...DEFAULT_THRESHOLDS,
|
|
7857
|
+
...input.thresholds
|
|
7858
|
+
};
|
|
7859
|
+
const committedTurns = events.filter((event) => event.type === "turn.committed");
|
|
7860
|
+
const assistantReplies = events.filter((event) => event.type === "turn.assistant");
|
|
7861
|
+
const sessionIdsWithAssistantReply = new Set(assistantReplies.map((event) => event.sessionId));
|
|
7862
|
+
const sessionsWithTurns = new Set(committedTurns.map((event) => event.sessionId));
|
|
7863
|
+
const emptyTurns = committedTurns.filter((event) => !getString5(event.payload.text)?.trim());
|
|
7864
|
+
const turnTextsBySession = new Map;
|
|
7865
|
+
let duplicateTurns = 0;
|
|
7866
|
+
for (const turn of committedTurns) {
|
|
7867
|
+
const normalized = getString5(turn.payload.text)?.trim().toLowerCase();
|
|
7868
|
+
if (!normalized) {
|
|
7869
|
+
continue;
|
|
7870
|
+
}
|
|
7871
|
+
const seen = turnTextsBySession.get(turn.sessionId) ?? new Set;
|
|
7872
|
+
if (seen.has(normalized)) {
|
|
7873
|
+
duplicateTurns += 1;
|
|
7874
|
+
}
|
|
7875
|
+
seen.add(normalized);
|
|
7876
|
+
turnTextsBySession.set(turn.sessionId, seen);
|
|
7877
|
+
}
|
|
7878
|
+
const missingAssistantReplySessions = [...sessionsWithTurns].filter((sessionId) => !sessionIdsWithAssistantReply.has(sessionId)).length;
|
|
7879
|
+
const providerEvents = events.filter((event) => event.type === "session.error" && typeof event.payload.provider === "string" && typeof event.payload.providerStatus === "string");
|
|
7880
|
+
const providerErrors = providerEvents.filter((event) => event.payload.providerStatus === "error");
|
|
7881
|
+
const providerFallbacks = providerEvents.filter((event) => event.payload.providerStatus === "fallback");
|
|
7882
|
+
const providerTimeouts = providerEvents.filter((event) => event.payload.timedOut === true);
|
|
7883
|
+
const providerLatencies = providerEvents.map((event) => getNumber3(event.payload.elapsedMs)).filter((value) => value !== undefined);
|
|
7884
|
+
const averageProviderLatencyMs = providerLatencies.length > 0 ? providerLatencies.reduce((sum, value) => sum + value, 0) / providerLatencies.length : 0;
|
|
7885
|
+
const handoffHealth = await summarizeVoiceHandoffHealth({ events });
|
|
7886
|
+
const metrics = {
|
|
7887
|
+
duplicateTurnRate: createMetric({
|
|
7888
|
+
actual: rate(duplicateTurns, committedTurns.length),
|
|
7889
|
+
label: "Duplicate turn rate",
|
|
7890
|
+
threshold: thresholds.maxDuplicateTurnRate,
|
|
7891
|
+
unit: "rate"
|
|
7892
|
+
}),
|
|
7893
|
+
emptyTurnRate: createMetric({
|
|
7894
|
+
actual: rate(emptyTurns.length, committedTurns.length),
|
|
7895
|
+
label: "Empty turn rate",
|
|
7896
|
+
threshold: thresholds.maxEmptyTurnRate,
|
|
7897
|
+
unit: "rate"
|
|
7898
|
+
}),
|
|
7899
|
+
handoffFailureRate: createMetric({
|
|
7900
|
+
actual: rate(handoffHealth.failed, handoffHealth.total),
|
|
7901
|
+
label: "Handoff failure rate",
|
|
7902
|
+
threshold: thresholds.maxHandoffFailureRate,
|
|
7903
|
+
unit: "rate"
|
|
7904
|
+
}),
|
|
7905
|
+
missingAssistantReplyRate: createMetric({
|
|
7906
|
+
actual: rate(missingAssistantReplySessions, sessionsWithTurns.size),
|
|
7907
|
+
label: "Missing assistant reply rate",
|
|
7908
|
+
threshold: thresholds.maxMissingAssistantReplyRate,
|
|
7909
|
+
unit: "rate"
|
|
7910
|
+
}),
|
|
7911
|
+
providerAverageLatencyMs: createMetric({
|
|
7912
|
+
actual: averageProviderLatencyMs,
|
|
7913
|
+
label: "Average provider latency",
|
|
7914
|
+
threshold: thresholds.maxProviderAverageLatencyMs,
|
|
7915
|
+
unit: "ms"
|
|
7916
|
+
}),
|
|
7917
|
+
providerErrorRate: createMetric({
|
|
7918
|
+
actual: rate(providerErrors.length, providerEvents.length),
|
|
7919
|
+
label: "Provider error rate",
|
|
7920
|
+
threshold: thresholds.maxProviderErrorRate,
|
|
7921
|
+
unit: "rate"
|
|
7922
|
+
}),
|
|
7923
|
+
providerFallbackRate: createMetric({
|
|
7924
|
+
actual: rate(providerFallbacks.length, providerEvents.length),
|
|
7925
|
+
label: "Provider fallback rate",
|
|
7926
|
+
threshold: thresholds.maxProviderFallbackRate,
|
|
7927
|
+
unit: "rate"
|
|
7928
|
+
}),
|
|
7929
|
+
providerTimeoutRate: createMetric({
|
|
7930
|
+
actual: rate(providerTimeouts.length, providerEvents.length),
|
|
7931
|
+
label: "Provider timeout rate",
|
|
7932
|
+
threshold: thresholds.maxProviderTimeoutRate,
|
|
7933
|
+
unit: "rate"
|
|
7934
|
+
})
|
|
7935
|
+
};
|
|
7936
|
+
const status = Object.values(metrics).every((metric) => metric.pass) ? "pass" : "fail";
|
|
7937
|
+
return {
|
|
7938
|
+
checkedAt: Date.now(),
|
|
7939
|
+
eventCount: events.length,
|
|
7940
|
+
metrics,
|
|
7941
|
+
status,
|
|
7942
|
+
thresholds
|
|
7943
|
+
};
|
|
7944
|
+
};
|
|
7945
|
+
var escapeHtml8 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
7946
|
+
var formatMetricValue = (metric) => metric.unit === "rate" ? `${(metric.actual * 100).toFixed(2)}%` : metric.unit === "ms" ? `${Math.round(metric.actual)}ms` : String(metric.actual);
|
|
7947
|
+
var formatThreshold = (metric) => metric.unit === "rate" ? `${(metric.threshold * 100).toFixed(2)}%` : metric.unit === "ms" ? `${Math.round(metric.threshold)}ms` : String(metric.threshold);
|
|
7948
|
+
var renderVoiceQualityHTML = (report, options = {}) => {
|
|
7949
|
+
const rows = Object.entries(report.metrics).map(([key, metric]) => `<tr class="${metric.pass ? "pass" : "fail"}"><td>${escapeHtml8(metric.label)}</td><td>${escapeHtml8(formatMetricValue(metric))}</td><td>${escapeHtml8(formatThreshold(metric))}</td><td>${metric.pass ? "pass" : "fail"}</td><td><code>${escapeHtml8(key)}</code></td></tr>`).join("");
|
|
7950
|
+
const links = options.links?.length ? `<nav>${options.links.map((link) => `<a href="${escapeHtml8(link.href)}">${escapeHtml8(link.label)}</a>`).join("")}</nav>` : "";
|
|
7951
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>AbsoluteJS Voice Quality</title><style>body{font-family:ui-sans-serif,system-ui,sans-serif;margin:2rem;background:#f8f7f2;color:#181713}main{max-width:1100px;margin:auto}nav{display:flex;flex-wrap:wrap;gap:.5rem;margin:0 0 1.25rem}nav a{background:#181713;border-radius:999px;color:white;padding:.35rem .7rem;text-decoration:none}.status{border-radius:999px;display:inline-flex;padding:.35rem .75rem;font-weight:800}.status.pass{background:#dcfce7;color:#166534}.status.fail{background:#fee2e2;color:#991b1b}table{border-collapse:collapse;width:100%;background:white;margin-top:1rem}td,th{border-bottom:1px solid #eee;padding:.75rem;text-align:left}.pass td{border-left:4px solid #16a34a}.fail td{border-left:4px solid #dc2626}code{background:#f3f4f6;padding:.15rem .3rem;border-radius:.3rem}</style></head><body><main>${links}<h1>Voice quality gates</h1><p class="status ${report.status}">${report.status}</p><p>${report.eventCount} event(s) checked.</p><table><thead><tr><th>Metric</th><th>Actual</th><th>Threshold</th><th>Status</th><th>Key</th></tr></thead><tbody>${rows}</tbody></table></main></body></html>`;
|
|
7952
|
+
};
|
|
7953
|
+
var createVoiceQualityRoutes = (options) => {
|
|
7954
|
+
const path = options.path ?? "/quality";
|
|
7955
|
+
const routes = new Elysia6({
|
|
7956
|
+
name: options.name ?? "absolutejs-voice-quality"
|
|
7957
|
+
});
|
|
7958
|
+
const getReport = () => evaluateVoiceQuality({
|
|
7959
|
+
events: options.events,
|
|
7960
|
+
store: options.store,
|
|
7961
|
+
thresholds: options.thresholds
|
|
7962
|
+
});
|
|
7963
|
+
routes.get(path, async () => {
|
|
7964
|
+
const report = await getReport();
|
|
7965
|
+
return new Response(renderVoiceQualityHTML(report, { links: options.links }), {
|
|
7966
|
+
headers: {
|
|
7967
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
7968
|
+
...options.headers
|
|
7969
|
+
}
|
|
7970
|
+
});
|
|
7971
|
+
});
|
|
7972
|
+
routes.get(`${path}/json`, async () => getReport());
|
|
7973
|
+
routes.get(`${path}/status`, async ({ set }) => {
|
|
7974
|
+
const report = await getReport();
|
|
7975
|
+
if (report.status === "fail") {
|
|
7976
|
+
set.status = 503;
|
|
7977
|
+
}
|
|
7978
|
+
return report;
|
|
7979
|
+
});
|
|
7980
|
+
return routes;
|
|
7981
|
+
};
|
|
7982
|
+
|
|
7983
|
+
// src/evalRoutes.ts
|
|
7984
|
+
var escapeHtml9 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
7985
|
+
var rate2 = (count, total) => count / Math.max(1, total);
|
|
7986
|
+
var normalizeSearchText = (value) => value.trim().toLowerCase();
|
|
7987
|
+
var getString6 = (value) => typeof value === "string" ? value : undefined;
|
|
7988
|
+
var getObject = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
7989
|
+
var getPathValue = (value, path) => {
|
|
7990
|
+
let current = value;
|
|
7991
|
+
for (const part of path.split(".").filter(Boolean)) {
|
|
7992
|
+
const record = getObject(current);
|
|
7993
|
+
if (!record || !(part in record)) {
|
|
7994
|
+
return;
|
|
7995
|
+
}
|
|
7996
|
+
current = record[part];
|
|
7997
|
+
}
|
|
7998
|
+
return current;
|
|
7999
|
+
};
|
|
8000
|
+
var includesAll = (haystack, needles) => {
|
|
8001
|
+
const normalized = normalizeSearchText(haystack);
|
|
8002
|
+
return needles.filter((needle) => !normalized.includes(normalizeSearchText(needle)));
|
|
8003
|
+
};
|
|
8004
|
+
var sessionTime = (events) => {
|
|
8005
|
+
const sorted = filterVoiceTraceEvents(events);
|
|
8006
|
+
return {
|
|
8007
|
+
endedAt: sorted.at(-1)?.at,
|
|
8008
|
+
startedAt: sorted[0]?.at
|
|
8009
|
+
};
|
|
8010
|
+
};
|
|
8011
|
+
var bucketKey = (timestamp) => new Date(timestamp).toISOString().slice(0, 10);
|
|
8012
|
+
var buildTrend = (sessions) => {
|
|
8013
|
+
const buckets = new Map;
|
|
8014
|
+
for (const session of sessions) {
|
|
8015
|
+
const endedAt = session.endedAt ?? session.startedAt ?? session.quality.checkedAt;
|
|
8016
|
+
const key = bucketKey(endedAt);
|
|
8017
|
+
const bucket = buckets.get(key) ?? {
|
|
8018
|
+
endedAt,
|
|
8019
|
+
failed: 0,
|
|
8020
|
+
key,
|
|
8021
|
+
passed: 0,
|
|
8022
|
+
total: 0
|
|
8023
|
+
};
|
|
8024
|
+
bucket.endedAt = Math.max(bucket.endedAt, endedAt);
|
|
8025
|
+
bucket.total += 1;
|
|
8026
|
+
if (session.status === "pass") {
|
|
8027
|
+
bucket.passed += 1;
|
|
8028
|
+
} else {
|
|
8029
|
+
bucket.failed += 1;
|
|
8030
|
+
}
|
|
8031
|
+
buckets.set(key, bucket);
|
|
8032
|
+
}
|
|
8033
|
+
return [...buckets.values()].sort((left, right) => right.endedAt - left.endedAt);
|
|
8034
|
+
};
|
|
8035
|
+
var runVoiceSessionEvals = async (options = {}) => {
|
|
8036
|
+
const events = filterVoiceTraceEvents(options.events ?? await options.store?.list() ?? []);
|
|
8037
|
+
const grouped = new Map;
|
|
8038
|
+
for (const event of events) {
|
|
8039
|
+
grouped.set(event.sessionId, [...grouped.get(event.sessionId) ?? [], event]);
|
|
8040
|
+
}
|
|
8041
|
+
const sessions = await Promise.all([...grouped.entries()].map(async ([sessionId, sessionEvents]) => {
|
|
8042
|
+
const sorted = filterVoiceTraceEvents(sessionEvents);
|
|
8043
|
+
const quality = await evaluateVoiceQuality({
|
|
8044
|
+
events: sorted,
|
|
8045
|
+
thresholds: options.thresholds
|
|
8046
|
+
});
|
|
8047
|
+
const { endedAt, startedAt } = sessionTime(sorted);
|
|
8048
|
+
const summary = summarizeVoiceTrace(sorted);
|
|
8049
|
+
const scenarioId = sorted.find((event) => event.scenarioId)?.scenarioId;
|
|
8050
|
+
return {
|
|
8051
|
+
endedAt,
|
|
8052
|
+
eventCount: sorted.length,
|
|
8053
|
+
quality,
|
|
8054
|
+
scenarioId,
|
|
8055
|
+
sessionId,
|
|
8056
|
+
startedAt,
|
|
8057
|
+
status: quality.status,
|
|
8058
|
+
summary
|
|
8059
|
+
};
|
|
8060
|
+
}));
|
|
8061
|
+
const limitedSessions = sessions.sort((left, right) => (right.endedAt ?? right.startedAt ?? 0) - (left.endedAt ?? left.startedAt ?? 0)).slice(0, options.limit ?? 100);
|
|
8062
|
+
const failed = limitedSessions.filter((session) => session.status === "fail").length;
|
|
8063
|
+
const passed = limitedSessions.length - failed;
|
|
8064
|
+
return {
|
|
8065
|
+
checkedAt: Date.now(),
|
|
8066
|
+
failed,
|
|
8067
|
+
passed,
|
|
8068
|
+
sessions: limitedSessions,
|
|
8069
|
+
status: failed > 0 ? "fail" : "pass",
|
|
8070
|
+
total: limitedSessions.length,
|
|
8071
|
+
trend: buildTrend(limitedSessions)
|
|
8072
|
+
};
|
|
8073
|
+
};
|
|
8074
|
+
var getSessionText = (events, type) => events.filter((event) => event.type === type).map((event) => getString6(event.payload.text)).filter((text) => Boolean(text?.trim())).join(`
|
|
8075
|
+
`);
|
|
8076
|
+
var countProviderErrors = (events) => events.filter((event) => event.type === "session.error" && (event.payload.providerStatus === "error" || typeof event.payload.provider === "string")).length;
|
|
8077
|
+
var evaluateScenarioSession = (scenario, sessionId, events) => {
|
|
8078
|
+
const issues = [];
|
|
8079
|
+
const committedText = getSessionText(events, "turn.committed");
|
|
8080
|
+
const assistantText = getSessionText(events, "turn.assistant");
|
|
8081
|
+
const lifecycleTypes = events.filter((event) => event.type === "call.lifecycle").map((event) => getString6(event.payload.type)).filter((type) => Boolean(type));
|
|
8082
|
+
const dispositions = events.filter((event) => event.type === "call.lifecycle").map((event) => getString6(event.payload.disposition)).filter((disposition) => Boolean(disposition));
|
|
8083
|
+
const handoffActions = events.filter((event) => event.type === "call.handoff").map((event) => getString6(event.payload.action)).filter((action) => Boolean(action));
|
|
8084
|
+
const turnCount = events.filter((event) => event.type === "turn.committed").length;
|
|
8085
|
+
const sessionErrorCount = events.filter((event) => event.type === "session.error").length;
|
|
8086
|
+
const providerErrorCount = countProviderErrors(events);
|
|
8087
|
+
const workflowContractEvents = events.filter((event) => event.type === "workflow.contract");
|
|
8088
|
+
for (const missing of includesAll(committedText, scenario.requiredTranscriptIncludes ?? [])) {
|
|
8089
|
+
issues.push(`Missing transcript text: ${missing}`);
|
|
8090
|
+
}
|
|
8091
|
+
for (const missing of includesAll(assistantText, scenario.requiredAssistantIncludes ?? [])) {
|
|
8092
|
+
issues.push(`Missing assistant text: ${missing}`);
|
|
8093
|
+
}
|
|
8094
|
+
for (const type of scenario.requiredLifecycleTypes ?? []) {
|
|
8095
|
+
if (!lifecycleTypes.includes(type)) {
|
|
8096
|
+
issues.push(`Missing lifecycle event: ${type}`);
|
|
8097
|
+
}
|
|
8098
|
+
}
|
|
8099
|
+
for (const type of scenario.forbiddenLifecycleTypes ?? []) {
|
|
8100
|
+
if (lifecycleTypes.includes(type)) {
|
|
8101
|
+
issues.push(`Forbidden lifecycle event occurred: ${type}`);
|
|
8102
|
+
}
|
|
8103
|
+
}
|
|
8104
|
+
for (const action of scenario.requiredHandoffActions ?? []) {
|
|
8105
|
+
if (!handoffActions.includes(action)) {
|
|
8106
|
+
issues.push(`Missing handoff action: ${action}`);
|
|
8107
|
+
}
|
|
8108
|
+
}
|
|
8109
|
+
for (const action of scenario.forbiddenHandoffActions ?? []) {
|
|
8110
|
+
if (handoffActions.includes(action)) {
|
|
8111
|
+
issues.push(`Forbidden handoff action occurred: ${action}`);
|
|
8112
|
+
}
|
|
8113
|
+
}
|
|
8114
|
+
if (scenario.requiredDisposition && !dispositions.includes(scenario.requiredDisposition)) {
|
|
8115
|
+
issues.push(`Missing disposition: ${scenario.requiredDisposition}`);
|
|
8116
|
+
}
|
|
8117
|
+
if (scenario.minTurns !== undefined && turnCount < scenario.minTurns) {
|
|
8118
|
+
issues.push(`Expected at least ${scenario.minTurns} turn(s), saw ${turnCount}.`);
|
|
8119
|
+
}
|
|
8120
|
+
if (scenario.maxSessionErrors !== undefined && sessionErrorCount > scenario.maxSessionErrors) {
|
|
8121
|
+
issues.push(`Expected at most ${scenario.maxSessionErrors} session error(s), saw ${sessionErrorCount}.`);
|
|
8122
|
+
}
|
|
8123
|
+
if (scenario.maxProviderErrors !== undefined && providerErrorCount > scenario.maxProviderErrors) {
|
|
8124
|
+
issues.push(`Expected at most ${scenario.maxProviderErrors} provider error(s), saw ${providerErrorCount}.`);
|
|
8125
|
+
}
|
|
8126
|
+
for (const path of scenario.requiredPayloadPaths ?? []) {
|
|
8127
|
+
if (events.every((event) => getPathValue(event.payload, path) === undefined)) {
|
|
8128
|
+
issues.push(`Missing payload path: ${path}`);
|
|
8129
|
+
}
|
|
8130
|
+
}
|
|
8131
|
+
for (const contractId of scenario.requiredWorkflowContracts ?? []) {
|
|
8132
|
+
const matching = workflowContractEvents.filter((event) => getString6(event.payload.contractId) === contractId);
|
|
8133
|
+
if (matching.length === 0) {
|
|
8134
|
+
issues.push(`Missing workflow contract: ${contractId}`);
|
|
8135
|
+
continue;
|
|
8136
|
+
}
|
|
8137
|
+
if (matching.some((event) => getString6(event.payload.status) !== "pass")) {
|
|
8138
|
+
issues.push(`Workflow contract failed: ${contractId}`);
|
|
8139
|
+
}
|
|
8140
|
+
}
|
|
8141
|
+
return {
|
|
8142
|
+
eventCount: events.length,
|
|
8143
|
+
issues,
|
|
8144
|
+
sessionId,
|
|
8145
|
+
status: issues.length > 0 ? "fail" : "pass"
|
|
8146
|
+
};
|
|
8147
|
+
};
|
|
8148
|
+
var runVoiceScenarioEvals = async (options = {}) => {
|
|
8149
|
+
const scenarios = options.scenarios ?? [];
|
|
8150
|
+
const events = filterVoiceTraceEvents(options.events ?? await options.store?.list() ?? []);
|
|
8151
|
+
const grouped = new Map;
|
|
8152
|
+
for (const event of events) {
|
|
8153
|
+
grouped.set(event.sessionId, [...grouped.get(event.sessionId) ?? [], event]);
|
|
8154
|
+
}
|
|
8155
|
+
const results = scenarios.map((scenario) => {
|
|
8156
|
+
const sessions = [...grouped.entries()].filter(([, sessionEvents]) => scenario.scenarioId ? sessionEvents.some((event) => event.scenarioId === scenario.scenarioId) : true).map(([sessionId, sessionEvents]) => evaluateScenarioSession(scenario, sessionId, filterVoiceTraceEvents(sessionEvents))).sort((left, right) => left.sessionId.localeCompare(right.sessionId));
|
|
8157
|
+
const issues = [];
|
|
8158
|
+
const minSessions = scenario.minSessions ?? 1;
|
|
8159
|
+
if (sessions.length < minSessions) {
|
|
8160
|
+
issues.push(`Expected at least ${minSessions} matching session(s), saw ${sessions.length}.`);
|
|
8161
|
+
}
|
|
8162
|
+
const failed2 = sessions.filter((session) => session.status === "fail").length;
|
|
8163
|
+
const passed2 = sessions.length - failed2;
|
|
8164
|
+
return {
|
|
8165
|
+
description: scenario.description,
|
|
8166
|
+
failed: failed2,
|
|
8167
|
+
id: scenario.id,
|
|
8168
|
+
issues,
|
|
8169
|
+
label: scenario.label ?? scenario.id,
|
|
8170
|
+
matchedSessions: sessions.length,
|
|
8171
|
+
passed: passed2,
|
|
8172
|
+
sessions,
|
|
8173
|
+
status: issues.length > 0 || failed2 > 0 ? "fail" : "pass"
|
|
8174
|
+
};
|
|
8175
|
+
});
|
|
8176
|
+
const failed = results.filter((scenario) => scenario.status === "fail").length;
|
|
8177
|
+
const passed = results.length - failed;
|
|
8178
|
+
return {
|
|
8179
|
+
checkedAt: Date.now(),
|
|
8180
|
+
failed,
|
|
8181
|
+
passed,
|
|
8182
|
+
scenarios: results,
|
|
8183
|
+
status: failed > 0 ? "fail" : "pass",
|
|
8184
|
+
total: results.length
|
|
8185
|
+
};
|
|
8186
|
+
};
|
|
8187
|
+
var resolveScenarioFixtures = async (options) => [...options.fixtures ?? [], ...await options.fixtureStore?.list() ?? []];
|
|
8188
|
+
var runVoiceScenarioFixtureEvals = async (options = {}) => {
|
|
8189
|
+
const fixtures = await resolveScenarioFixtures(options);
|
|
8190
|
+
const results = await Promise.all(fixtures.map(async (fixture) => {
|
|
8191
|
+
const report = await runVoiceScenarioEvals({
|
|
8192
|
+
events: fixture.events,
|
|
8193
|
+
scenarios: options.scenarios
|
|
8194
|
+
});
|
|
8195
|
+
return {
|
|
8196
|
+
description: fixture.description,
|
|
8197
|
+
fixtureId: fixture.id,
|
|
8198
|
+
label: fixture.label ?? fixture.id,
|
|
8199
|
+
report,
|
|
8200
|
+
status: report.status
|
|
8201
|
+
};
|
|
8202
|
+
}));
|
|
8203
|
+
const failed = results.filter((fixture) => fixture.status === "fail").length;
|
|
8204
|
+
const passed = results.length - failed;
|
|
8205
|
+
return {
|
|
8206
|
+
checkedAt: Date.now(),
|
|
8207
|
+
failed,
|
|
8208
|
+
fixtures: results,
|
|
8209
|
+
passed,
|
|
8210
|
+
status: failed > 0 ? "fail" : "pass",
|
|
8211
|
+
total: results.length
|
|
8212
|
+
};
|
|
8213
|
+
};
|
|
8214
|
+
var summarizeEvalBaseline = (report) => {
|
|
8215
|
+
const failedSessionIds = report.sessions.filter((session) => session.status === "fail").map((session) => session.sessionId).sort();
|
|
8216
|
+
return {
|
|
8217
|
+
failed: report.failed,
|
|
8218
|
+
failedSessionIds,
|
|
8219
|
+
passRate: rate2(report.passed, report.total),
|
|
8220
|
+
passed: report.passed,
|
|
8221
|
+
total: report.total
|
|
8222
|
+
};
|
|
8223
|
+
};
|
|
8224
|
+
var compareVoiceEvalBaseline = (currentReport, baselineReport, options = {}) => {
|
|
8225
|
+
const baseline = summarizeEvalBaseline(baselineReport);
|
|
8226
|
+
const current = summarizeEvalBaseline(currentReport);
|
|
8227
|
+
const maxFailedDelta = options.maxFailedDelta ?? 0;
|
|
8228
|
+
const maxPassRateDrop = options.maxPassRateDrop ?? 0;
|
|
8229
|
+
const failOnNewFailedSessions = options.failOnNewFailedSessions ?? true;
|
|
8230
|
+
const baselineFailed = new Set(baseline.failedSessionIds);
|
|
8231
|
+
const currentFailed = new Set(current.failedSessionIds);
|
|
8232
|
+
const newFailedSessionIds = current.failedSessionIds.filter((sessionId) => !baselineFailed.has(sessionId));
|
|
8233
|
+
const recoveredSessionIds = baseline.failedSessionIds.filter((sessionId) => !currentFailed.has(sessionId));
|
|
8234
|
+
const deltas = {
|
|
8235
|
+
failed: current.failed - baseline.failed,
|
|
8236
|
+
passRate: current.passRate - baseline.passRate,
|
|
8237
|
+
passed: current.passed - baseline.passed,
|
|
8238
|
+
total: current.total - baseline.total
|
|
8239
|
+
};
|
|
8240
|
+
const reasons = [];
|
|
8241
|
+
if (deltas.failed > maxFailedDelta) {
|
|
8242
|
+
reasons.push(`Failed sessions increased by ${deltas.failed}, above allowed delta ${maxFailedDelta}.`);
|
|
8243
|
+
}
|
|
8244
|
+
if (deltas.passRate < -maxPassRateDrop) {
|
|
8245
|
+
reasons.push(`Pass rate dropped by ${Math.abs(deltas.passRate).toFixed(4)}, above allowed drop ${maxPassRateDrop}.`);
|
|
8246
|
+
}
|
|
8247
|
+
if (failOnNewFailedSessions && newFailedSessionIds.length > 0) {
|
|
8248
|
+
reasons.push(`${newFailedSessionIds.length} session(s) failed that were not failing in the baseline.`);
|
|
8249
|
+
}
|
|
8250
|
+
return {
|
|
8251
|
+
baseline,
|
|
8252
|
+
checkedAt: Date.now(),
|
|
8253
|
+
current,
|
|
8254
|
+
deltas,
|
|
8255
|
+
newFailedSessionIds,
|
|
8256
|
+
recoveredSessionIds,
|
|
8257
|
+
reasons,
|
|
8258
|
+
status: reasons.length > 0 ? "fail" : "pass"
|
|
8259
|
+
};
|
|
8260
|
+
};
|
|
8261
|
+
var createVoiceFileEvalBaselineStore = (filePath) => ({
|
|
8262
|
+
get: async () => {
|
|
8263
|
+
const file = Bun.file(filePath);
|
|
8264
|
+
if (!await file.exists()) {
|
|
8265
|
+
return;
|
|
8266
|
+
}
|
|
8267
|
+
const text = await file.text();
|
|
8268
|
+
return text.trim() ? JSON.parse(text) : undefined;
|
|
8269
|
+
},
|
|
8270
|
+
set: async (report) => {
|
|
8271
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
8272
|
+
await Bun.write(filePath, JSON.stringify(report, null, 2));
|
|
8273
|
+
}
|
|
8274
|
+
});
|
|
8275
|
+
var createVoiceFileScenarioFixtureStore = (filePath) => ({
|
|
8276
|
+
list: async () => {
|
|
8277
|
+
const file = Bun.file(filePath);
|
|
8278
|
+
if (!await file.exists()) {
|
|
8279
|
+
return [];
|
|
8280
|
+
}
|
|
8281
|
+
const text = await file.text();
|
|
8282
|
+
if (!text.trim()) {
|
|
8283
|
+
return [];
|
|
8284
|
+
}
|
|
8285
|
+
const parsed = JSON.parse(text);
|
|
8286
|
+
return Array.isArray(parsed) ? parsed : parsed.fixtures ?? [];
|
|
8287
|
+
}
|
|
8288
|
+
});
|
|
8289
|
+
var formatTime = (value) => value === undefined ? "unknown" : new Date(value).toLocaleString();
|
|
8290
|
+
var formatPercent = (value) => `${(value * 100).toFixed(2)}%`;
|
|
8291
|
+
var renderVoiceEvalHTML = (report, options = {}) => {
|
|
8292
|
+
const title = options.title ?? "AbsoluteJS Voice Evals";
|
|
8293
|
+
const links = options.links?.length ? `<nav>${options.links.map((link) => `<a href="${escapeHtml9(link.href)}">${escapeHtml9(link.label)}</a>`).join("")}</nav>` : "";
|
|
8294
|
+
const trend = report.trend.length ? report.trend.map((bucket) => `<tr><td>${escapeHtml9(bucket.key)}</td><td>${bucket.total}</td><td>${bucket.passed}</td><td>${bucket.failed}</td></tr>`).join("") : '<tr><td colspan="4">No eval buckets yet.</td></tr>';
|
|
8295
|
+
const sessions = report.sessions.length ? report.sessions.map((session) => {
|
|
8296
|
+
const failedMetrics = Object.entries(session.quality.metrics).filter(([, metric]) => !metric.pass).map(([, metric]) => metric.label).join(", ");
|
|
8297
|
+
return `<tr class="${session.status}"><td>${escapeHtml9(session.sessionId)}</td><td>${escapeHtml9(session.status)}</td><td>${session.eventCount}</td><td>${session.summary.turnCount}</td><td>${session.summary.errorCount}</td><td>${escapeHtml9(formatTime(session.endedAt))}</td><td>${escapeHtml9(failedMetrics || "none")}</td></tr>`;
|
|
8298
|
+
}).join("") : '<tr><td colspan="7">No sessions found.</td></tr>';
|
|
8299
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml9(title)}</title><style>body{font-family:ui-sans-serif,system-ui,sans-serif;margin:2rem;background:#f8f7f2;color:#181713}main{max-width:1180px;margin:auto}nav{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem}nav a{background:#181713;border-radius:999px;color:white;padding:.35rem .7rem;text-decoration:none}.status{border-radius:999px;display:inline-flex;font-weight:800;padding:.35rem .75rem}.pass{color:#166534}.fail{color:#991b1b}.status.pass{background:#dcfce7}.status.fail{background:#fee2e2}.grid{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));margin:1rem 0}.card{background:white;border:1px solid #e7e5e4;border-radius:1rem;padding:1rem}.card strong{display:block;font-size:2rem}table{border-collapse:collapse;background:white;width:100%;margin:1rem 0 2rem}td,th{border-bottom:1px solid #eee;padding:.75rem;text-align:left}tr.fail td{border-left:4px solid #dc2626}tr.pass td{border-left:4px solid #16a34a}</style></head><body><main>${links}<h1>${escapeHtml9(title)}</h1><p class="status ${report.status}">${report.status}</p><div class="grid"><article class="card"><span>Total</span><strong>${report.total}</strong></article><article class="card"><span>Passed</span><strong>${report.passed}</strong></article><article class="card"><span>Failed</span><strong>${report.failed}</strong></article></div><h2>Trend</h2><table><thead><tr><th>Day</th><th>Total</th><th>Passed</th><th>Failed</th></tr></thead><tbody>${trend}</tbody></table><h2>Session Eval Results</h2><table><thead><tr><th>Session</th><th>Status</th><th>Events</th><th>Turns</th><th>Errors</th><th>Last event</th><th>Failed metrics</th></tr></thead><tbody>${sessions}</tbody></table></main></body></html>`;
|
|
8300
|
+
};
|
|
8301
|
+
var renderVoiceEvalBaselineHTML = (comparison, options = {}) => {
|
|
8302
|
+
const title = options.title ?? "AbsoluteJS Voice Eval Baseline";
|
|
8303
|
+
const links = options.links?.length ? `<nav>${options.links.map((link) => `<a href="${escapeHtml9(link.href)}">${escapeHtml9(link.label)}</a>`).join("")}</nav>` : "";
|
|
8304
|
+
const reasons = comparison.reasons.length ? comparison.reasons.map((reason) => `<li>${escapeHtml9(reason)}</li>`).join("") : "<li>No baseline regressions detected.</li>";
|
|
8305
|
+
const newFailures = comparison.newFailedSessionIds.length ? comparison.newFailedSessionIds.map((id) => `<li>${escapeHtml9(id)}</li>`).join("") : "<li>none</li>";
|
|
8306
|
+
const recovered = comparison.recoveredSessionIds.length ? comparison.recoveredSessionIds.map((id) => `<li>${escapeHtml9(id)}</li>`).join("") : "<li>none</li>";
|
|
8307
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml9(title)}</title><style>body{font-family:ui-sans-serif,system-ui,sans-serif;margin:2rem;background:#f8f7f2;color:#181713}main{max-width:1000px;margin:auto}nav{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem}nav a{background:#181713;border-radius:999px;color:white;padding:.35rem .7rem;text-decoration:none}.status{border-radius:999px;display:inline-flex;font-weight:800;padding:.35rem .75rem}.pass{background:#dcfce7;color:#166534}.fail{background:#fee2e2;color:#991b1b}.grid{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));margin:1rem 0}.card{background:white;border:1px solid #e7e5e4;border-radius:1rem;padding:1rem}.card strong{display:block;font-size:2rem}section{background:white;border:1px solid #e7e5e4;border-radius:1rem;margin:1rem 0;padding:1rem}</style></head><body><main>${links}<h1>${escapeHtml9(title)}</h1><p class="status ${comparison.status}">${comparison.status}</p><div class="grid"><article class="card"><span>Baseline pass rate</span><strong>${escapeHtml9(formatPercent(comparison.baseline.passRate))}</strong></article><article class="card"><span>Current pass rate</span><strong>${escapeHtml9(formatPercent(comparison.current.passRate))}</strong></article><article class="card"><span>Failed delta</span><strong>${comparison.deltas.failed}</strong></article><article class="card"><span>Pass rate delta</span><strong>${escapeHtml9(formatPercent(comparison.deltas.passRate))}</strong></article></div><section><h2>Regression Reasons</h2><ul>${reasons}</ul></section><section><h2>New Failed Sessions</h2><ul>${newFailures}</ul></section><section><h2>Recovered Sessions</h2><ul>${recovered}</ul></section></main></body></html>`;
|
|
8308
|
+
};
|
|
8309
|
+
var renderVoiceScenarioEvalHTML = (report, options = {}) => {
|
|
8310
|
+
const title = options.title ?? "AbsoluteJS Voice Scenario Evals";
|
|
8311
|
+
const links = options.links?.length ? `<nav>${options.links.map((link) => `<a href="${escapeHtml9(link.href)}">${escapeHtml9(link.label)}</a>`).join("")}</nav>` : "";
|
|
8312
|
+
const scenarios = report.scenarios.length ? report.scenarios.map((scenario) => {
|
|
8313
|
+
const scenarioIssues = scenario.issues.length ? `<ul>${scenario.issues.map((issue) => `<li>${escapeHtml9(issue)}</li>`).join("")}</ul>` : "";
|
|
8314
|
+
const sessions = scenario.sessions.length ? scenario.sessions.map((session) => `<tr class="${session.status}"><td>${escapeHtml9(session.sessionId)}</td><td>${escapeHtml9(session.status)}</td><td>${session.eventCount}</td><td>${escapeHtml9(session.issues.join(", ") || "none")}</td></tr>`).join("") : '<tr><td colspan="4">No matching sessions.</td></tr>';
|
|
8315
|
+
return `<section class="scenario ${scenario.status}"><h2>${escapeHtml9(scenario.label)}</h2>${scenario.description ? `<p>${escapeHtml9(scenario.description)}</p>` : ""}<p class="status ${scenario.status}">${scenario.status}</p><p>${scenario.passed} passed, ${scenario.failed} failed, ${scenario.matchedSessions} matched.</p>${scenarioIssues}<table><thead><tr><th>Session</th><th>Status</th><th>Events</th><th>Issues</th></tr></thead><tbody>${sessions}</tbody></table></section>`;
|
|
8316
|
+
}).join("") : "<section><p>No scenarios configured.</p></section>";
|
|
8317
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml9(title)}</title><style>body{font-family:ui-sans-serif,system-ui,sans-serif;margin:2rem;background:#f8f7f2;color:#181713}main{max-width:1180px;margin:auto}nav{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem}nav a{background:#181713;border-radius:999px;color:white;padding:.35rem .7rem;text-decoration:none}.status{border-radius:999px;display:inline-flex;font-weight:800;padding:.35rem .75rem}.status.pass{background:#dcfce7;color:#166534}.status.fail{background:#fee2e2;color:#991b1b}.grid{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));margin:1rem 0}.card,section{background:white;border:1px solid #e7e5e4;border-radius:1rem;padding:1rem}.card strong{display:block;font-size:2rem}section{margin:1rem 0}table{border-collapse:collapse;width:100%;margin-top:1rem}td,th{border-bottom:1px solid #eee;padding:.75rem;text-align:left}tr.fail td{border-left:4px solid #dc2626}tr.pass td{border-left:4px solid #16a34a}</style></head><body><main>${links}<h1>${escapeHtml9(title)}</h1><p class="status ${report.status}">${report.status}</p><div class="grid"><article class="card"><span>Total</span><strong>${report.total}</strong></article><article class="card"><span>Passed</span><strong>${report.passed}</strong></article><article class="card"><span>Failed</span><strong>${report.failed}</strong></article></div>${scenarios}</main></body></html>`;
|
|
8318
|
+
};
|
|
8319
|
+
var renderVoiceScenarioFixtureEvalHTML = (report, options = {}) => {
|
|
8320
|
+
const title = options.title ?? "AbsoluteJS Voice Fixture Evals";
|
|
8321
|
+
const links = options.links?.length ? `<nav>${options.links.map((link) => `<a href="${escapeHtml9(link.href)}">${escapeHtml9(link.label)}</a>`).join("")}</nav>` : "";
|
|
8322
|
+
const fixtures = report.fixtures.length ? report.fixtures.map((fixture) => {
|
|
8323
|
+
const scenarios = fixture.report.scenarios.map((scenario) => `<tr class="${scenario.status}"><td>${escapeHtml9(scenario.label)}</td><td>${escapeHtml9(scenario.status)}</td><td>${scenario.matchedSessions}</td><td>${escapeHtml9([...scenario.issues, ...scenario.sessions.flatMap((session) => session.issues)].join(", ") || "none")}</td></tr>`).join("");
|
|
8324
|
+
return `<section class="${fixture.status}"><h2>${escapeHtml9(fixture.label)}</h2>${fixture.description ? `<p>${escapeHtml9(fixture.description)}</p>` : ""}<p class="status ${fixture.status}">${fixture.status}</p><table><thead><tr><th>Scenario</th><th>Status</th><th>Sessions</th><th>Issues</th></tr></thead><tbody>${scenarios}</tbody></table></section>`;
|
|
8325
|
+
}).join("") : "<section><p>No scenario fixtures configured.</p></section>";
|
|
8326
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml9(title)}</title><style>body{font-family:ui-sans-serif,system-ui,sans-serif;margin:2rem;background:#f8f7f2;color:#181713}main{max-width:1180px;margin:auto}nav{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem}nav a{background:#181713;border-radius:999px;color:white;padding:.35rem .7rem;text-decoration:none}.status{border-radius:999px;display:inline-flex;font-weight:800;padding:.35rem .75rem}.status.pass{background:#dcfce7;color:#166534}.status.fail{background:#fee2e2;color:#991b1b}.grid{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));margin:1rem 0}.card,section{background:white;border:1px solid #e7e5e4;border-radius:1rem;padding:1rem}.card strong{display:block;font-size:2rem}section{margin:1rem 0}table{border-collapse:collapse;width:100%;margin-top:1rem}td,th{border-bottom:1px solid #eee;padding:.75rem;text-align:left}tr.fail td{border-left:4px solid #dc2626}tr.pass td{border-left:4px solid #16a34a}</style></head><body><main>${links}<h1>${escapeHtml9(title)}</h1><p class="status ${report.status}">${report.status}</p><div class="grid"><article class="card"><span>Total</span><strong>${report.total}</strong></article><article class="card"><span>Passed</span><strong>${report.passed}</strong></article><article class="card"><span>Failed</span><strong>${report.failed}</strong></article></div>${fixtures}</main></body></html>`;
|
|
8327
|
+
};
|
|
8328
|
+
var createVoiceEvalRoutes = (options) => {
|
|
8329
|
+
const path = options.path ?? "/evals";
|
|
8330
|
+
const routes = new Elysia7({
|
|
8331
|
+
name: options.name ?? "absolutejs-voice-evals"
|
|
8332
|
+
});
|
|
8333
|
+
const getReport = () => runVoiceSessionEvals({
|
|
8334
|
+
events: options.events,
|
|
8335
|
+
limit: options.limit,
|
|
8336
|
+
store: options.store,
|
|
8337
|
+
thresholds: options.thresholds
|
|
8338
|
+
});
|
|
8339
|
+
const getBaseline = async () => typeof options.baseline === "function" ? options.baseline() : options.baseline ?? await options.baselineStore?.get();
|
|
8340
|
+
const getBaselineComparison = async () => {
|
|
8341
|
+
const [current, baseline] = await Promise.all([getReport(), getBaseline()]);
|
|
8342
|
+
return baseline ? compareVoiceEvalBaseline(current, baseline, options.baselineComparison) : undefined;
|
|
8343
|
+
};
|
|
8344
|
+
const getScenarioReport = () => runVoiceScenarioEvals({
|
|
8345
|
+
events: options.events,
|
|
8346
|
+
scenarios: options.scenarios,
|
|
8347
|
+
store: options.store
|
|
8348
|
+
});
|
|
8349
|
+
const getFixtureReport = () => runVoiceScenarioFixtureEvals({
|
|
8350
|
+
fixtures: options.fixtures,
|
|
8351
|
+
fixtureStore: options.fixtureStore,
|
|
8352
|
+
scenarios: options.scenarios
|
|
8353
|
+
});
|
|
8354
|
+
routes.get(path, async () => {
|
|
8355
|
+
const report = await getReport();
|
|
8356
|
+
return new Response(renderVoiceEvalHTML(report, {
|
|
8357
|
+
links: options.links,
|
|
8358
|
+
title: options.title
|
|
8359
|
+
}), {
|
|
8360
|
+
headers: {
|
|
8361
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
8362
|
+
...options.headers
|
|
8363
|
+
}
|
|
8364
|
+
});
|
|
8365
|
+
});
|
|
8366
|
+
routes.get(`${path}/json`, async () => getReport());
|
|
8367
|
+
routes.get(`${path}/status`, async ({ set }) => {
|
|
8368
|
+
const report = await getReport();
|
|
8369
|
+
if (report.status === "fail") {
|
|
8370
|
+
set.status = 503;
|
|
8371
|
+
}
|
|
8372
|
+
return report;
|
|
8373
|
+
});
|
|
8374
|
+
routes.get(`${path}/baseline`, async ({ set }) => {
|
|
8375
|
+
const comparison = await getBaselineComparison();
|
|
8376
|
+
if (!comparison) {
|
|
8377
|
+
set.status = 404;
|
|
8378
|
+
return Response.json({ error: "No voice eval baseline found." });
|
|
8379
|
+
}
|
|
8380
|
+
return new Response(renderVoiceEvalBaselineHTML(comparison, {
|
|
8381
|
+
links: options.links,
|
|
8382
|
+
title: `${options.title ?? "AbsoluteJS Voice Evals"} Baseline`
|
|
8383
|
+
}), {
|
|
8384
|
+
headers: {
|
|
8385
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
8386
|
+
...options.headers
|
|
8387
|
+
}
|
|
8388
|
+
});
|
|
8389
|
+
});
|
|
8390
|
+
routes.get(`${path}/baseline/json`, async ({ set }) => {
|
|
8391
|
+
const comparison = await getBaselineComparison();
|
|
8392
|
+
if (!comparison) {
|
|
8393
|
+
set.status = 404;
|
|
8394
|
+
return { error: "No voice eval baseline found." };
|
|
8395
|
+
}
|
|
8396
|
+
return comparison;
|
|
8397
|
+
});
|
|
8398
|
+
routes.get(`${path}/baseline/status`, async ({ set }) => {
|
|
8399
|
+
const comparison = await getBaselineComparison();
|
|
8400
|
+
if (!comparison) {
|
|
8401
|
+
set.status = 404;
|
|
8402
|
+
return { error: "No voice eval baseline found." };
|
|
8403
|
+
}
|
|
8404
|
+
if (comparison.status === "fail") {
|
|
8405
|
+
set.status = 503;
|
|
8406
|
+
}
|
|
8407
|
+
return comparison;
|
|
8408
|
+
});
|
|
8409
|
+
routes.post(`${path}/baseline`, async ({ set }) => {
|
|
8410
|
+
if (!options.baselineStore) {
|
|
8411
|
+
set.status = 501;
|
|
8412
|
+
return { error: "No voice eval baseline store configured." };
|
|
8413
|
+
}
|
|
8414
|
+
const report = await getReport();
|
|
8415
|
+
await options.baselineStore.set(report);
|
|
8416
|
+
return {
|
|
8417
|
+
baseline: report,
|
|
8418
|
+
status: "saved"
|
|
8419
|
+
};
|
|
8420
|
+
});
|
|
8421
|
+
routes.get(`${path}/scenarios`, async () => {
|
|
8422
|
+
const report = await getScenarioReport();
|
|
8423
|
+
return new Response(renderVoiceScenarioEvalHTML(report, {
|
|
8424
|
+
links: options.links,
|
|
8425
|
+
title: `${options.title ?? "AbsoluteJS Voice Evals"} Scenarios`
|
|
8426
|
+
}), {
|
|
8427
|
+
headers: {
|
|
8428
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
8429
|
+
...options.headers
|
|
8430
|
+
}
|
|
8431
|
+
});
|
|
8432
|
+
});
|
|
8433
|
+
routes.get(`${path}/scenarios/json`, async () => getScenarioReport());
|
|
8434
|
+
routes.get(`${path}/scenarios/status`, async ({ set }) => {
|
|
8435
|
+
const report = await getScenarioReport();
|
|
8436
|
+
if (report.status === "fail") {
|
|
8437
|
+
set.status = 503;
|
|
8438
|
+
}
|
|
8439
|
+
return report;
|
|
8440
|
+
});
|
|
8441
|
+
routes.get(`${path}/fixtures`, async () => {
|
|
8442
|
+
const report = await getFixtureReport();
|
|
8443
|
+
return new Response(renderVoiceScenarioFixtureEvalHTML(report, {
|
|
8444
|
+
links: options.links,
|
|
8445
|
+
title: `${options.title ?? "AbsoluteJS Voice Evals"} Fixtures`
|
|
8446
|
+
}), {
|
|
8447
|
+
headers: {
|
|
8448
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
8449
|
+
...options.headers
|
|
8450
|
+
}
|
|
8451
|
+
});
|
|
8452
|
+
});
|
|
8453
|
+
routes.get(`${path}/fixtures/json`, async () => getFixtureReport());
|
|
8454
|
+
routes.get(`${path}/fixtures/status`, async ({ set }) => {
|
|
8455
|
+
const report = await getFixtureReport();
|
|
8456
|
+
if (report.status === "fail") {
|
|
8457
|
+
set.status = 503;
|
|
8458
|
+
}
|
|
8459
|
+
return report;
|
|
8460
|
+
});
|
|
8461
|
+
return routes;
|
|
8462
|
+
};
|
|
8463
|
+
|
|
8464
|
+
// src/opsConsoleRoutes.ts
|
|
8465
|
+
import { Elysia as Elysia10 } from "elysia";
|
|
8466
|
+
|
|
8467
|
+
// src/resilienceRoutes.ts
|
|
8468
|
+
import { Elysia as Elysia8 } from "elysia";
|
|
8469
|
+
var escapeHtml10 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
8470
|
+
var getString7 = (value) => typeof value === "string" ? value : undefined;
|
|
8471
|
+
var getNumber4 = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
8472
|
+
var getBoolean2 = (value) => value === true;
|
|
8473
|
+
var isProviderStatus2 = (value) => value === "error" || value === "fallback" || value === "success";
|
|
8474
|
+
var listVoiceRoutingEvents = (events) => {
|
|
8475
|
+
const routingEvents = [];
|
|
8476
|
+
for (const event of events) {
|
|
8477
|
+
if (event.type !== "session.error") {
|
|
8478
|
+
continue;
|
|
8479
|
+
}
|
|
8480
|
+
const provider = getString7(event.payload.provider);
|
|
8481
|
+
const providerStatus = isProviderStatus2(event.payload.providerStatus) ? event.payload.providerStatus : undefined;
|
|
8482
|
+
if (!provider || !providerStatus) {
|
|
8483
|
+
continue;
|
|
8484
|
+
}
|
|
8485
|
+
const kind = getString7(event.payload.kind);
|
|
8486
|
+
routingEvents.push({
|
|
8487
|
+
at: event.at,
|
|
8488
|
+
attempt: getNumber4(event.payload.attempt),
|
|
8489
|
+
elapsedMs: getNumber4(event.payload.elapsedMs),
|
|
8490
|
+
error: getString7(event.payload.error),
|
|
8491
|
+
fallbackProvider: getString7(event.payload.fallbackProvider),
|
|
8492
|
+
kind: kind === "stt" || kind === "tts" ? kind : "llm",
|
|
8493
|
+
latencyBudgetMs: getNumber4(event.payload.latencyBudgetMs),
|
|
8494
|
+
operation: getString7(event.payload.operation),
|
|
8495
|
+
provider,
|
|
8496
|
+
routing: getString7(event.payload.routing),
|
|
8497
|
+
selectedProvider: getString7(event.payload.selectedProvider),
|
|
8498
|
+
sessionId: event.sessionId,
|
|
8499
|
+
status: providerStatus,
|
|
8500
|
+
suppressionRemainingMs: getNumber4(event.payload.suppressionRemainingMs),
|
|
8501
|
+
timedOut: getBoolean2(event.payload.timedOut),
|
|
8502
|
+
turnId: event.turnId
|
|
8503
|
+
});
|
|
8504
|
+
}
|
|
8505
|
+
return routingEvents.sort((left, right) => right.at - left.at);
|
|
8506
|
+
};
|
|
8507
|
+
var summarizeVoiceRoutingDecision = (events, options = {}) => {
|
|
8508
|
+
const routingEvents = listVoiceRoutingEvents(events).filter((event) => {
|
|
8509
|
+
if (options.kind && event.kind !== options.kind) {
|
|
8510
|
+
return false;
|
|
8511
|
+
}
|
|
8512
|
+
if (options.sessionId && event.sessionId !== options.sessionId) {
|
|
8513
|
+
return false;
|
|
8514
|
+
}
|
|
8515
|
+
return true;
|
|
8516
|
+
});
|
|
8517
|
+
const limited = typeof options.limit === "number" && options.limit >= 0 ? routingEvents.slice(0, options.limit) : routingEvents;
|
|
8518
|
+
return limited[0] ?? null;
|
|
8519
|
+
};
|
|
8520
|
+
var createVoiceRoutingDecisionSummary = async (options) => {
|
|
8521
|
+
const events = await options.store.list({
|
|
8522
|
+
sessionId: options.sessionId,
|
|
8523
|
+
type: "session.error"
|
|
8524
|
+
});
|
|
8525
|
+
return summarizeVoiceRoutingDecision(events, options);
|
|
8526
|
+
};
|
|
8527
|
+
var summarizeRoutingEvents = (events) => {
|
|
8528
|
+
const byKind = new Map;
|
|
8529
|
+
let errors = 0;
|
|
8530
|
+
let fallbacks = 0;
|
|
8531
|
+
let timeouts = 0;
|
|
8532
|
+
for (const event of events) {
|
|
8533
|
+
byKind.set(event.kind, (byKind.get(event.kind) ?? 0) + 1);
|
|
8534
|
+
if (event.status === "error") {
|
|
8535
|
+
errors += 1;
|
|
8536
|
+
}
|
|
8537
|
+
if (event.status === "fallback") {
|
|
8538
|
+
fallbacks += 1;
|
|
8539
|
+
}
|
|
8540
|
+
if (event.timedOut) {
|
|
8541
|
+
timeouts += 1;
|
|
8542
|
+
}
|
|
8543
|
+
}
|
|
8544
|
+
return {
|
|
8545
|
+
byKind,
|
|
8546
|
+
errors,
|
|
8547
|
+
fallbacks,
|
|
8548
|
+
timeouts,
|
|
8549
|
+
total: events.length
|
|
8550
|
+
};
|
|
8551
|
+
};
|
|
8552
|
+
var renderProviderCards = (title, providers) => {
|
|
8553
|
+
if (providers.length === 0) {
|
|
8554
|
+
return `<p class="muted">No ${escapeHtml10(title)} provider health yet.</p>`;
|
|
8555
|
+
}
|
|
8556
|
+
return `<div class="provider-grid">${providers.map((provider) => `
|
|
8557
|
+
<article class="card provider ${escapeHtml10(provider.status)}">
|
|
8558
|
+
<div class="card-header">
|
|
8559
|
+
<strong>${escapeHtml10(provider.provider)}</strong>
|
|
8560
|
+
<span>${escapeHtml10(provider.status)}${provider.recommended ? " \xB7 recommended" : ""}</span>
|
|
8561
|
+
</div>
|
|
8562
|
+
<dl>
|
|
8563
|
+
<div><dt>Runs</dt><dd>${provider.runCount}</dd></div>
|
|
8564
|
+
<div><dt>Avg latency</dt><dd>${provider.averageElapsedMs ?? 0}ms</dd></div>
|
|
8565
|
+
<div><dt>Errors</dt><dd>${provider.errorCount}</dd></div>
|
|
8566
|
+
<div><dt>Timeouts</dt><dd>${provider.timeoutCount}</dd></div>
|
|
8567
|
+
<div><dt>Fallbacks</dt><dd>${provider.fallbackCount}</dd></div>
|
|
8568
|
+
</dl>
|
|
8569
|
+
${provider.lastError ? `<p class="muted">${escapeHtml10(provider.lastError)}</p>` : ""}
|
|
8570
|
+
</article>
|
|
8571
|
+
`).join("")}</div>`;
|
|
8572
|
+
};
|
|
8573
|
+
var renderTimeline2 = (events) => {
|
|
8574
|
+
if (events.length === 0) {
|
|
8575
|
+
return '<p class="muted">No provider routing events yet. Run the app or simulate provider failover.</p>';
|
|
8576
|
+
}
|
|
8577
|
+
return `<div class="timeline">${events.slice(0, 40).map((event) => `
|
|
8578
|
+
<article class="card event ${escapeHtml10(event.status ?? "unknown")}">
|
|
8579
|
+
<div class="card-header">
|
|
8580
|
+
<strong>${escapeHtml10(event.kind.toUpperCase())} ${escapeHtml10(event.operation ?? "generate")}</strong>
|
|
8581
|
+
<span>${new Date(event.at).toLocaleString()}</span>
|
|
8582
|
+
</div>
|
|
8583
|
+
<p>
|
|
8584
|
+
<span class="pill">${escapeHtml10(event.status ?? "unknown")}</span>
|
|
8585
|
+
<span class="pill">provider: ${escapeHtml10(event.provider ?? "unknown")}</span>
|
|
8586
|
+
${event.fallbackProvider ? `<span class="pill">fallback: ${escapeHtml10(event.fallbackProvider)}</span>` : ""}
|
|
8587
|
+
${event.timedOut ? '<span class="pill danger">timed out</span>' : ""}
|
|
8588
|
+
</p>
|
|
8589
|
+
<dl>
|
|
8590
|
+
<div><dt>Attempt</dt><dd>${event.attempt ?? 0}</dd></div>
|
|
8591
|
+
<div><dt>Elapsed</dt><dd>${event.elapsedMs ?? 0}ms</dd></div>
|
|
8592
|
+
<div><dt>Budget</dt><dd>${event.latencyBudgetMs ?? 0}ms</dd></div>
|
|
8593
|
+
<div><dt>Session</dt><dd>${escapeHtml10(event.sessionId)}</dd></div>
|
|
8594
|
+
</dl>
|
|
8595
|
+
${event.error ? `<p class="muted">${escapeHtml10(event.error)}</p>` : ""}
|
|
8596
|
+
</article>
|
|
8597
|
+
`).join("")}</div>`;
|
|
8598
|
+
};
|
|
8599
|
+
var renderSimulationControls = (kind, simulation) => {
|
|
8600
|
+
if (!simulation) {
|
|
8601
|
+
return "";
|
|
8602
|
+
}
|
|
8603
|
+
const configuredProviders = simulation.providers.filter((provider) => provider.configured !== false);
|
|
8604
|
+
if (configuredProviders.length === 0) {
|
|
8605
|
+
return `<p class="muted">No ${kind.toUpperCase()} providers are configured for simulation.</p>`;
|
|
8606
|
+
}
|
|
8607
|
+
const pathPrefix = simulation.pathPrefix ?? `/api/${kind}-simulate`;
|
|
8608
|
+
const failureProviders = simulation.failureProviders ?? configuredProviders.map(({ provider }) => provider);
|
|
8609
|
+
const canFail = (provider) => configuredProviders.some((entry) => entry.provider === provider) && (!simulation.fallbackRequiredProvider || configuredProviders.some((entry) => entry.provider === simulation.fallbackRequiredProvider));
|
|
8610
|
+
return `<div class="simulate-panel" data-sim-kind="${kind}" data-sim-prefix="${escapeHtml10(pathPrefix)}">
|
|
8611
|
+
<p class="muted">${escapeHtml10(simulation.failureMessage ?? `Simulate ${kind.toUpperCase()} provider failure without changing provider credentials.`)}</p>
|
|
8612
|
+
<div class="simulate-actions">
|
|
8613
|
+
${failureProviders.map((provider) => `<button type="button" data-provider-fail="${escapeHtml10(provider)}"${canFail(provider) ? "" : " disabled"}>Simulate ${escapeHtml10(provider)} ${kind.toUpperCase()} failure</button>`).join("")}
|
|
8614
|
+
${configuredProviders.map((provider) => `<button type="button" data-provider-recover="${escapeHtml10(provider.provider)}">Mark ${escapeHtml10(provider.provider)} recovered</button>`).join("")}
|
|
8615
|
+
</div>
|
|
8616
|
+
${simulation.fallbackRequiredProvider && !configuredProviders.some((entry) => entry.provider === simulation.fallbackRequiredProvider) ? `<p class="muted">${escapeHtml10(simulation.fallbackRequiredMessage ?? `Configure ${simulation.fallbackRequiredProvider} to enable fallback simulation.`)}</p>` : ""}
|
|
8617
|
+
<pre class="simulate-output" hidden></pre>
|
|
8618
|
+
</div>`;
|
|
8619
|
+
};
|
|
8620
|
+
var renderVoiceResilienceHTML = (input) => {
|
|
8621
|
+
const summary = summarizeRoutingEvents(input.routingEvents);
|
|
8622
|
+
const kindCounts = [...summary.byKind.entries()].map(([kind, count]) => `<span class="pill">${escapeHtml10(kind)}: ${String(count)}</span>`).join("");
|
|
8623
|
+
const links = input.links?.length ? input.links.map((link) => `<a href="${escapeHtml10(link.href)}">${escapeHtml10(link.label)}</a>`).join(" \xB7 ") : "";
|
|
8624
|
+
return `<!doctype html>
|
|
8625
|
+
<html lang="en">
|
|
8626
|
+
<head>
|
|
8627
|
+
<meta charset="utf-8" />
|
|
8628
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8629
|
+
<title>${escapeHtml10(input.title ?? "AbsoluteJS Voice Resilience")}</title>
|
|
8630
|
+
<style>
|
|
8631
|
+
:root { color-scheme: dark; }
|
|
8632
|
+
body { background: radial-gradient(circle at top left, #172554, #09090b 36%, #050505); color: #f4f4f5; font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 24px; }
|
|
8633
|
+
main { display: grid; gap: 16px; margin: 0 auto; max-width: 1180px; }
|
|
8634
|
+
section, .card { background: rgba(19, 22, 27, 0.92); border: 1px solid #27272a; border-radius: 20px; padding: 20px; }
|
|
8635
|
+
.hero { background: linear-gradient(135deg, rgba(14, 165, 233, 0.18), rgba(245, 158, 11, 0.12)); }
|
|
8636
|
+
.grid, .provider-grid { display: grid; gap: 14px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
8637
|
+
.timeline { display: grid; gap: 12px; }
|
|
8638
|
+
.card-header { align-items: center; display: flex; gap: 12px; justify-content: space-between; }
|
|
8639
|
+
.card-header strong { font-size: 1.05rem; }
|
|
8640
|
+
.metric strong { display: block; font-size: 2rem; margin-top: 6px; }
|
|
8641
|
+
.muted, dt, span { color: #a1a1aa; }
|
|
8642
|
+
dl { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
8643
|
+
dl div { background: #0f1217; border: 1px solid #27272a; border-radius: 12px; padding: 10px; }
|
|
8644
|
+
dd { font-weight: 800; margin: 4px 0 0; }
|
|
8645
|
+
.pill { background: #0f1217; border: 1px solid #3f3f46; border-radius: 999px; color: #d4d4d8; display: inline-flex; margin: 3px 4px 3px 0; padding: 5px 9px; }
|
|
8646
|
+
.danger { border-color: rgba(239, 68, 68, 0.75); color: #fecaca; }
|
|
8647
|
+
.event.error { border-color: rgba(239, 68, 68, 0.7); }
|
|
8648
|
+
.event.fallback { border-color: rgba(245, 158, 11, 0.7); }
|
|
8649
|
+
.event.success, .provider.healthy { border-color: rgba(34, 197, 94, 0.5); }
|
|
8650
|
+
.provider.suppressed, .provider.degraded, .provider.rate-limited { border-color: rgba(239, 68, 68, 0.7); }
|
|
8651
|
+
.provider.recoverable { border-color: rgba(59, 130, 246, 0.7); }
|
|
8652
|
+
button { background: #f59e0b; border: 0; border-radius: 999px; color: #111827; cursor: pointer; font-weight: 800; padding: 10px 14px; }
|
|
8653
|
+
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
|
8654
|
+
.simulate-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
|
|
8655
|
+
.simulate-output { background: #050505; border: 1px solid #27272a; border-radius: 14px; color: #d4d4d8; overflow: auto; padding: 12px; white-space: pre-wrap; }
|
|
8656
|
+
a { color: #f59e0b; }
|
|
8657
|
+
@media (max-width: 850px) { .grid, .provider-grid, dl { grid-template-columns: 1fr; } }
|
|
8658
|
+
</style>
|
|
8659
|
+
</head>
|
|
8660
|
+
<body>
|
|
8661
|
+
<main>
|
|
8662
|
+
<section class="hero">
|
|
8663
|
+
<h1>Provider routing and resilience</h1>
|
|
8664
|
+
<p>One view for the production reliability story: LLM failover, STT/TTS routing, latency budgets, timeouts, and fallback decisions.</p>
|
|
8665
|
+
${links ? `<p>${links}</p>` : ""}
|
|
8666
|
+
<p>${kindCounts || '<span class="pill">No routing events yet</span>'}</p>
|
|
8667
|
+
</section>
|
|
8668
|
+
<section class="grid">
|
|
8669
|
+
<article class="card metric"><span>Total routing events</span><strong>${summary.total}</strong></article>
|
|
8670
|
+
<article class="card metric"><span>Fallbacks</span><strong>${summary.fallbacks}</strong></article>
|
|
8671
|
+
<article class="card metric"><span>Errors</span><strong>${summary.errors}</strong></article>
|
|
8672
|
+
<article class="card metric"><span>Timeouts</span><strong>${summary.timeouts}</strong></article>
|
|
8673
|
+
</section>
|
|
8674
|
+
<section>
|
|
8675
|
+
<h2>LLM provider health</h2>
|
|
8676
|
+
${renderProviderCards("LLM", input.llmProviderHealth)}
|
|
8677
|
+
</section>
|
|
8678
|
+
<section>
|
|
8679
|
+
<h2>STT provider health</h2>
|
|
8680
|
+
${renderSimulationControls("stt", input.sttSimulation)}
|
|
8681
|
+
${renderProviderCards("STT", input.sttProviderHealth)}
|
|
8682
|
+
</section>
|
|
8683
|
+
<section>
|
|
8684
|
+
<h2>TTS provider health</h2>
|
|
8685
|
+
${renderSimulationControls("tts", input.ttsSimulation)}
|
|
8686
|
+
${renderProviderCards("TTS", input.ttsProviderHealth)}
|
|
8687
|
+
</section>
|
|
8688
|
+
<section>
|
|
8689
|
+
<h2>Routing timeline</h2>
|
|
8690
|
+
${renderTimeline2(input.routingEvents)}
|
|
8691
|
+
</section>
|
|
8692
|
+
</main>
|
|
8693
|
+
<script>
|
|
8694
|
+
const showResult = (panel, result) => {
|
|
8695
|
+
const output = panel.querySelector(".simulate-output");
|
|
8696
|
+
if (!output) return;
|
|
8697
|
+
output.hidden = false;
|
|
8698
|
+
output.textContent = JSON.stringify(result, null, 2);
|
|
8699
|
+
};
|
|
8700
|
+
document.querySelectorAll("[data-sim-prefix]").forEach((panel) => {
|
|
8701
|
+
const prefix = panel.getAttribute("data-sim-prefix");
|
|
8702
|
+
panel.querySelectorAll("[data-provider-fail]").forEach((button) => {
|
|
8703
|
+
button.addEventListener("click", async () => {
|
|
8704
|
+
const provider = button.getAttribute("data-provider-fail");
|
|
8705
|
+
const response = await fetch(prefix + "/failure?provider=" + encodeURIComponent(provider || ""), { method: "POST" });
|
|
8706
|
+
showResult(panel, await response.json());
|
|
8707
|
+
if (response.ok) window.setTimeout(() => window.location.reload(), 450);
|
|
8708
|
+
});
|
|
8709
|
+
});
|
|
8710
|
+
panel.querySelectorAll("[data-provider-recover]").forEach((button) => {
|
|
8711
|
+
button.addEventListener("click", async () => {
|
|
8712
|
+
const provider = button.getAttribute("data-provider-recover");
|
|
8713
|
+
const response = await fetch(prefix + "/recovery?provider=" + encodeURIComponent(provider || ""), { method: "POST" });
|
|
8714
|
+
showResult(panel, await response.json());
|
|
8715
|
+
if (response.ok) window.setTimeout(() => window.location.reload(), 450);
|
|
8716
|
+
});
|
|
8717
|
+
});
|
|
8718
|
+
});
|
|
8719
|
+
</script>
|
|
8720
|
+
</body>
|
|
8721
|
+
</html>`;
|
|
8722
|
+
};
|
|
8723
|
+
var providerFromQuery = (value, providers) => typeof value === "string" && providers.some((provider) => provider.provider === value && provider.configured !== false) ? value : undefined;
|
|
8724
|
+
var registerSimulationRoutes = (routes, simulation, defaultPathPrefix) => {
|
|
8725
|
+
if (!simulation) {
|
|
8726
|
+
return routes;
|
|
8727
|
+
}
|
|
8728
|
+
const pathPrefix = simulation.pathPrefix ?? defaultPathPrefix;
|
|
8729
|
+
routes.post(`${pathPrefix}/failure`, async ({ query, set }) => {
|
|
8730
|
+
const provider = providerFromQuery(query.provider, simulation.providers);
|
|
8731
|
+
if (!provider) {
|
|
8732
|
+
set.status = 400;
|
|
8733
|
+
return {
|
|
8734
|
+
error: "Provider is not configured for simulation."
|
|
8735
|
+
};
|
|
8736
|
+
}
|
|
8737
|
+
if (simulation.failureProviders && !simulation.failureProviders.includes(provider)) {
|
|
8738
|
+
set.status = 400;
|
|
8739
|
+
return {
|
|
8740
|
+
error: `${provider} is not configured for failure simulation.`
|
|
8741
|
+
};
|
|
8742
|
+
}
|
|
8743
|
+
if (simulation.fallbackRequiredProvider && !simulation.providers.some((entry) => entry.provider === simulation.fallbackRequiredProvider && entry.configured !== false)) {
|
|
8744
|
+
set.status = 400;
|
|
8745
|
+
return {
|
|
8746
|
+
error: simulation.fallbackRequiredMessage ?? `Configure ${simulation.fallbackRequiredProvider} before simulating fallback.`
|
|
8747
|
+
};
|
|
8748
|
+
}
|
|
8749
|
+
return simulation.run(provider, "failure");
|
|
8750
|
+
});
|
|
8751
|
+
routes.post(`${pathPrefix}/recovery`, async ({ query, set }) => {
|
|
8752
|
+
const provider = providerFromQuery(query.provider, simulation.providers);
|
|
8753
|
+
if (!provider) {
|
|
8754
|
+
set.status = 400;
|
|
8755
|
+
return {
|
|
8756
|
+
error: "Provider is not configured for simulation."
|
|
8757
|
+
};
|
|
8758
|
+
}
|
|
8759
|
+
return simulation.run(provider, "recovery");
|
|
8760
|
+
});
|
|
8761
|
+
return routes;
|
|
8762
|
+
};
|
|
8763
|
+
var createVoiceResilienceRoutes = (options) => {
|
|
8764
|
+
const path = options.path ?? "/resilience";
|
|
8765
|
+
const routes = new Elysia8({
|
|
8766
|
+
name: options.name ?? "absolutejs-voice-resilience"
|
|
8767
|
+
}).get(path, async () => {
|
|
8768
|
+
const events = await options.store.list();
|
|
8769
|
+
const sttEvents = events.filter((event) => event.payload.kind === "stt");
|
|
8770
|
+
const ttsEvents = events.filter((event) => event.payload.kind === "tts");
|
|
8771
|
+
const data = {
|
|
8772
|
+
links: options.links,
|
|
8773
|
+
llmProviderHealth: await summarizeVoiceProviderHealth({
|
|
8774
|
+
events,
|
|
8775
|
+
providers: options.llmProviders ?? []
|
|
8776
|
+
}),
|
|
8777
|
+
routingEvents: listVoiceRoutingEvents(events),
|
|
8778
|
+
sttProviderHealth: await summarizeVoiceProviderHealth({
|
|
8779
|
+
events: sttEvents,
|
|
8780
|
+
providers: options.sttProviders ?? []
|
|
8781
|
+
}),
|
|
8782
|
+
sttSimulation: options.sttSimulation,
|
|
8783
|
+
title: options.title,
|
|
8784
|
+
ttsProviderHealth: await summarizeVoiceProviderHealth({
|
|
8785
|
+
events: ttsEvents,
|
|
8786
|
+
providers: options.ttsProviders ?? []
|
|
8787
|
+
}),
|
|
8788
|
+
ttsSimulation: options.ttsSimulation
|
|
8789
|
+
};
|
|
8790
|
+
const body = await (options.render ?? renderVoiceResilienceHTML)(data);
|
|
8791
|
+
return new Response(body, {
|
|
8792
|
+
headers: {
|
|
8793
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
8794
|
+
...options.headers
|
|
8795
|
+
}
|
|
8796
|
+
});
|
|
8797
|
+
});
|
|
8798
|
+
registerSimulationRoutes(routes, options.sttSimulation, "/api/stt-simulate");
|
|
8799
|
+
registerSimulationRoutes(routes, options.ttsSimulation, "/api/tts-simulate");
|
|
8800
|
+
return routes;
|
|
8801
|
+
};
|
|
8802
|
+
|
|
8803
|
+
// src/sessionReplay.ts
|
|
8804
|
+
import { Elysia as Elysia9 } from "elysia";
|
|
8805
|
+
var getString8 = (value) => typeof value === "string" ? value : undefined;
|
|
8806
|
+
var escapeHtml11 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
8807
|
+
var increment3 = (record, key) => {
|
|
8808
|
+
record[key] = (record[key] ?? 0) + 1;
|
|
8809
|
+
};
|
|
8810
|
+
var buildReplayTurns = (events) => {
|
|
8811
|
+
const turns = new Map;
|
|
8812
|
+
const getTurn = (turnId) => {
|
|
8813
|
+
const existing = turns.get(turnId);
|
|
8814
|
+
if (existing) {
|
|
8815
|
+
return existing;
|
|
8816
|
+
}
|
|
8817
|
+
const turn = {
|
|
8818
|
+
assistantReplies: [],
|
|
8819
|
+
errors: [],
|
|
8820
|
+
id: turnId,
|
|
8821
|
+
modelCalls: [],
|
|
8822
|
+
tools: [],
|
|
8823
|
+
transcripts: []
|
|
8824
|
+
};
|
|
8825
|
+
turns.set(turnId, turn);
|
|
8826
|
+
return turn;
|
|
8827
|
+
};
|
|
8828
|
+
for (const event of events) {
|
|
8829
|
+
const turnId = event.turnId ?? "session";
|
|
8830
|
+
const turn = getTurn(turnId);
|
|
8831
|
+
switch (event.type) {
|
|
8832
|
+
case "turn.transcript":
|
|
8833
|
+
turn.transcripts.push({
|
|
8834
|
+
isFinal: event.payload.isFinal === true,
|
|
8835
|
+
text: getString8(event.payload.text)
|
|
8836
|
+
});
|
|
8837
|
+
break;
|
|
8838
|
+
case "turn.committed":
|
|
8839
|
+
turn.committedText = getString8(event.payload.text);
|
|
8840
|
+
break;
|
|
8841
|
+
case "turn.assistant": {
|
|
8842
|
+
const text = getString8(event.payload.text);
|
|
8843
|
+
if (text) {
|
|
8844
|
+
turn.assistantReplies.push(text);
|
|
8845
|
+
}
|
|
8846
|
+
break;
|
|
8847
|
+
}
|
|
8848
|
+
case "agent.model":
|
|
8849
|
+
case "assistant.run":
|
|
8850
|
+
turn.modelCalls.push(event.payload);
|
|
8851
|
+
break;
|
|
8852
|
+
case "agent.tool":
|
|
8853
|
+
turn.tools.push(event.payload);
|
|
8854
|
+
break;
|
|
8855
|
+
case "session.error":
|
|
8856
|
+
turn.errors.push(event.payload);
|
|
8857
|
+
break;
|
|
8858
|
+
}
|
|
8859
|
+
}
|
|
8860
|
+
return [...turns.values()];
|
|
8861
|
+
};
|
|
8862
|
+
var summarizeVoiceSessionReplay = async (options) => {
|
|
8863
|
+
const sourceEvents = options.events ?? await options.store?.list({ sessionId: options.sessionId }) ?? [];
|
|
8864
|
+
const events = filterVoiceTraceEvents(sourceEvents, {
|
|
8865
|
+
sessionId: options.sessionId
|
|
8866
|
+
});
|
|
8867
|
+
const replay = buildVoiceTraceReplay(events, {
|
|
8868
|
+
evaluation: options.evaluation,
|
|
8869
|
+
redact: options.redact,
|
|
8870
|
+
title: options.title ?? `Voice Session ${options.sessionId}`
|
|
8871
|
+
});
|
|
8872
|
+
const startedAt = replay.summary.startedAt;
|
|
8873
|
+
return {
|
|
8874
|
+
evaluation: replay.evaluation,
|
|
8875
|
+
events,
|
|
8876
|
+
html: replay.html,
|
|
8877
|
+
markdown: replay.markdown,
|
|
8878
|
+
sessionId: options.sessionId,
|
|
8879
|
+
summary: replay.summary,
|
|
8880
|
+
timeline: events.map((event) => ({
|
|
8881
|
+
at: event.at,
|
|
8882
|
+
offsetMs: startedAt === undefined ? undefined : Math.max(0, event.at - startedAt),
|
|
8883
|
+
payload: event.payload,
|
|
8884
|
+
turnId: event.turnId,
|
|
8885
|
+
type: event.type
|
|
8886
|
+
})),
|
|
8887
|
+
turns: buildReplayTurns(events)
|
|
8888
|
+
};
|
|
8889
|
+
};
|
|
8890
|
+
var summarizeVoiceSessions = async (options = {}) => {
|
|
8891
|
+
const events = options.events ?? await options.store?.list() ?? [];
|
|
8892
|
+
const grouped = new Map;
|
|
8893
|
+
for (const event of events) {
|
|
8894
|
+
grouped.set(event.sessionId, [...grouped.get(event.sessionId) ?? [], event]);
|
|
8895
|
+
}
|
|
8896
|
+
const sessions = [...grouped.entries()].map(([sessionId, sessionEvents]) => {
|
|
8897
|
+
const sorted = filterVoiceTraceEvents(sessionEvents);
|
|
8898
|
+
const summary = buildVoiceTraceReplay(sorted, {
|
|
8899
|
+
evaluation: {
|
|
8900
|
+
requireAssistantReply: false,
|
|
8901
|
+
requireCompletedCall: false,
|
|
8902
|
+
requireTranscript: false,
|
|
8903
|
+
requireTurn: false
|
|
8904
|
+
}
|
|
8905
|
+
}).summary;
|
|
8906
|
+
const providerErrors = {};
|
|
8907
|
+
const providers = new Set;
|
|
8908
|
+
let latestOutcome;
|
|
8909
|
+
let errorCount = 0;
|
|
8910
|
+
for (const event of sorted) {
|
|
8911
|
+
const provider = getString8(event.payload.provider);
|
|
8912
|
+
if (provider) {
|
|
8913
|
+
providers.add(provider);
|
|
8914
|
+
}
|
|
8915
|
+
if (event.type === "session.error" && (event.payload.providerStatus === "error" || typeof event.payload.error === "string")) {
|
|
8916
|
+
errorCount += 1;
|
|
8917
|
+
increment3(providerErrors, provider ?? "unknown");
|
|
8918
|
+
}
|
|
8919
|
+
const outcome = getString8(event.payload.outcome);
|
|
8920
|
+
if (outcome) {
|
|
8921
|
+
latestOutcome = outcome;
|
|
8922
|
+
}
|
|
8923
|
+
}
|
|
8924
|
+
const item = {
|
|
8925
|
+
endedAt: summary.endedAt,
|
|
8926
|
+
errorCount,
|
|
8927
|
+
eventCount: summary.eventCount,
|
|
8928
|
+
latestOutcome,
|
|
8929
|
+
providerErrors,
|
|
8930
|
+
providers: [...providers].sort(),
|
|
8931
|
+
sessionId,
|
|
8932
|
+
startedAt: summary.startedAt,
|
|
8933
|
+
status: errorCount > 0 ? "failed" : "healthy",
|
|
8934
|
+
transcriptCount: summary.transcriptCount,
|
|
8935
|
+
turnCount: summary.turnCount
|
|
8936
|
+
};
|
|
8937
|
+
const replayHref = options.replayHref === false ? "" : typeof options.replayHref === "function" ? options.replayHref(item) : `${options.replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(sessionId)}/replay/htmx`;
|
|
8938
|
+
return {
|
|
8939
|
+
...item,
|
|
8940
|
+
replayHref
|
|
8941
|
+
};
|
|
8942
|
+
});
|
|
8943
|
+
const search = options.q?.trim().toLowerCase();
|
|
8944
|
+
return sessions.filter((session) => {
|
|
8945
|
+
if (options.status && options.status !== "all" && session.status !== options.status) {
|
|
8946
|
+
return false;
|
|
8947
|
+
}
|
|
8948
|
+
if (options.provider && !session.providers.includes(options.provider)) {
|
|
8949
|
+
return false;
|
|
8950
|
+
}
|
|
8951
|
+
if (!search) {
|
|
8952
|
+
return true;
|
|
8953
|
+
}
|
|
8954
|
+
return [
|
|
8955
|
+
session.sessionId,
|
|
8956
|
+
session.latestOutcome,
|
|
8957
|
+
session.status,
|
|
8958
|
+
...session.providers
|
|
8959
|
+
].some((value) => value?.toLowerCase().includes(search));
|
|
8960
|
+
}).sort((left, right) => (right.endedAt ?? right.startedAt ?? 0) - (left.endedAt ?? left.startedAt ?? 0)).slice(0, options.limit ?? 50);
|
|
8961
|
+
};
|
|
8962
|
+
var renderVoiceSessionsHTML = (sessions) => sessions.length === 0 ? '<p class="voice-sessions-empty">No voice sessions found.</p>' : [
|
|
8963
|
+
'<div class="voice-sessions-list">',
|
|
8964
|
+
...sessions.map((session) => [
|
|
8965
|
+
`<article class="voice-session-card ${escapeHtml11(session.status)}">`,
|
|
8966
|
+
'<div class="voice-session-card-header">',
|
|
8967
|
+
`<strong>${escapeHtml11(session.sessionId)}</strong>`,
|
|
8968
|
+
`<span>${escapeHtml11(session.status)}</span>`,
|
|
8969
|
+
"</div>",
|
|
8970
|
+
"<dl>",
|
|
8971
|
+
`<div><dt>Events</dt><dd>${String(session.eventCount)}</dd></div>`,
|
|
8972
|
+
`<div><dt>Turns</dt><dd>${String(session.turnCount)}</dd></div>`,
|
|
8973
|
+
`<div><dt>Transcripts</dt><dd>${String(session.transcriptCount)}</dd></div>`,
|
|
8974
|
+
`<div><dt>Errors</dt><dd>${String(session.errorCount)}</dd></div>`,
|
|
8975
|
+
"</dl>",
|
|
8976
|
+
session.latestOutcome ? `<p>Outcome: ${escapeHtml11(session.latestOutcome)}</p>` : "",
|
|
8977
|
+
session.providers.length ? `<p>Providers: ${session.providers.map(escapeHtml11).join(", ")}</p>` : "",
|
|
8978
|
+
session.replayHref ? `<p><a href="${escapeHtml11(session.replayHref)}">Open replay</a></p>` : "",
|
|
8979
|
+
"</article>"
|
|
8980
|
+
].join("")),
|
|
8981
|
+
"</div>"
|
|
8982
|
+
].join("");
|
|
8983
|
+
var createVoiceSessionsJSONHandler = (options = {}) => async ({ query }) => summarizeVoiceSessions({
|
|
8984
|
+
...options,
|
|
8985
|
+
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
8986
|
+
provider: query?.provider ?? options.provider,
|
|
8987
|
+
q: query?.q ?? options.q,
|
|
8988
|
+
status: query?.status === "failed" || query?.status === "healthy" || query?.status === "all" ? query.status : options.status
|
|
8989
|
+
});
|
|
8990
|
+
var createVoiceSessionsHTMLHandler = (options = {}) => async ({ query }) => {
|
|
8991
|
+
const sessions = await summarizeVoiceSessions({
|
|
8992
|
+
...options,
|
|
8993
|
+
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
8994
|
+
provider: query?.provider ?? options.provider,
|
|
8995
|
+
q: query?.q ?? options.q,
|
|
8996
|
+
status: query?.status === "failed" || query?.status === "healthy" || query?.status === "all" ? query.status : options.status
|
|
8997
|
+
});
|
|
8998
|
+
const body = await (options.render?.(sessions) ?? renderVoiceSessionsHTML(sessions));
|
|
8999
|
+
return new Response(body, {
|
|
9000
|
+
headers: {
|
|
9001
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
9002
|
+
...options.headers
|
|
9003
|
+
}
|
|
9004
|
+
});
|
|
9005
|
+
};
|
|
9006
|
+
var createVoiceSessionListRoutes = (options = {}) => {
|
|
9007
|
+
const path = options.path ?? "/api/voice-sessions";
|
|
9008
|
+
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
9009
|
+
const routes = new Elysia9({
|
|
9010
|
+
name: options.name ?? "absolutejs-voice-session-list"
|
|
9011
|
+
}).get(path, createVoiceSessionsJSONHandler(options));
|
|
9012
|
+
if (htmlPath) {
|
|
9013
|
+
routes.get(htmlPath, createVoiceSessionsHTMLHandler(options));
|
|
9014
|
+
}
|
|
9015
|
+
return routes;
|
|
9016
|
+
};
|
|
9017
|
+
var createVoiceSessionReplayJSONHandler = (options) => async ({ params }) => summarizeVoiceSessionReplay({
|
|
9018
|
+
...options,
|
|
9019
|
+
sessionId: params.sessionId ?? ""
|
|
9020
|
+
});
|
|
9021
|
+
var createVoiceSessionReplayHTMLHandler = (options) => async ({ params }) => {
|
|
9022
|
+
const replay = await summarizeVoiceSessionReplay({
|
|
9023
|
+
...options,
|
|
9024
|
+
sessionId: params.sessionId ?? ""
|
|
9025
|
+
});
|
|
9026
|
+
const body = await (options.render?.(replay) ?? replay.html);
|
|
9027
|
+
return new Response(body, {
|
|
9028
|
+
headers: {
|
|
9029
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
9030
|
+
...options.headers
|
|
9031
|
+
}
|
|
9032
|
+
});
|
|
9033
|
+
};
|
|
9034
|
+
var createVoiceSessionReplayRoutes = (options) => {
|
|
9035
|
+
const path = options.path ?? "/api/voice-sessions/:sessionId/replay";
|
|
9036
|
+
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
9037
|
+
const routes = new Elysia9({
|
|
9038
|
+
name: options.name ?? "absolutejs-voice-session-replay"
|
|
9039
|
+
}).get(path, createVoiceSessionReplayJSONHandler(options));
|
|
9040
|
+
if (htmlPath) {
|
|
9041
|
+
routes.get(htmlPath, createVoiceSessionReplayHTMLHandler(options));
|
|
9042
|
+
}
|
|
9043
|
+
return routes;
|
|
9044
|
+
};
|
|
9045
|
+
|
|
9046
|
+
// src/opsConsoleRoutes.ts
|
|
9047
|
+
var DEFAULT_LINKS = [
|
|
9048
|
+
{
|
|
9049
|
+
description: "Quality gates for CI, deploy checks, and production readiness.",
|
|
9050
|
+
href: "/quality",
|
|
9051
|
+
label: "Quality",
|
|
9052
|
+
statusHref: "/quality/status"
|
|
9053
|
+
},
|
|
9054
|
+
{
|
|
9055
|
+
description: "Replay stored sessions against acceptance gates over time.",
|
|
9056
|
+
href: "/evals",
|
|
9057
|
+
label: "Evals",
|
|
9058
|
+
statusHref: "/evals/status"
|
|
9059
|
+
},
|
|
9060
|
+
{
|
|
9061
|
+
description: "Provider health, fallback paths, and failure simulation.",
|
|
9062
|
+
href: "/resilience",
|
|
9063
|
+
label: "Resilience"
|
|
9064
|
+
},
|
|
9065
|
+
{
|
|
9066
|
+
description: "Redacted trace exports for debugging and support handoffs.",
|
|
9067
|
+
href: "/diagnostics",
|
|
9068
|
+
label: "Diagnostics"
|
|
9069
|
+
},
|
|
9070
|
+
{
|
|
9071
|
+
description: "Recent sessions with replay links.",
|
|
9072
|
+
href: "/sessions",
|
|
9073
|
+
label: "Sessions"
|
|
9074
|
+
},
|
|
9075
|
+
{
|
|
9076
|
+
description: "Transfer and webhook delivery health.",
|
|
9077
|
+
href: "/handoffs",
|
|
9078
|
+
label: "Handoffs"
|
|
9079
|
+
}
|
|
9080
|
+
];
|
|
9081
|
+
var escapeHtml12 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
9082
|
+
var countProviderStatuses = (providers) => {
|
|
9083
|
+
const degradedStatuses = new Set(["degraded", "rate-limited", "suppressed"]);
|
|
9084
|
+
const healthy = providers.filter((provider) => provider.status === "healthy").length;
|
|
9085
|
+
const degraded = providers.filter((provider) => degradedStatuses.has(provider.status)).length;
|
|
9086
|
+
return {
|
|
9087
|
+
degraded,
|
|
9088
|
+
healthy,
|
|
9089
|
+
total: providers.length
|
|
9090
|
+
};
|
|
9091
|
+
};
|
|
9092
|
+
var buildVoiceOpsConsoleReport = async (options) => {
|
|
9093
|
+
const events = await options.store.list();
|
|
9094
|
+
const providers = [
|
|
9095
|
+
...await summarizeVoiceProviderHealth({
|
|
9096
|
+
events,
|
|
9097
|
+
providers: options.llmProviders
|
|
9098
|
+
}),
|
|
9099
|
+
...await summarizeVoiceProviderHealth({
|
|
9100
|
+
events,
|
|
9101
|
+
providers: options.sttProviders
|
|
9102
|
+
}),
|
|
9103
|
+
...await summarizeVoiceProviderHealth({
|
|
9104
|
+
events,
|
|
9105
|
+
providers: options.ttsProviders
|
|
9106
|
+
})
|
|
9107
|
+
];
|
|
9108
|
+
const handoffs = await summarizeVoiceHandoffHealth({ events });
|
|
9109
|
+
const sessions = await summarizeVoiceSessions({
|
|
9110
|
+
events,
|
|
9111
|
+
limit: 8,
|
|
9112
|
+
status: "all"
|
|
9113
|
+
});
|
|
9114
|
+
const quality = await evaluateVoiceQuality({ events });
|
|
9115
|
+
const routingEvents = listVoiceRoutingEvents(events).slice(0, 10);
|
|
9116
|
+
const trace = summarizeVoiceTrace(events);
|
|
9117
|
+
return {
|
|
9118
|
+
checkedAt: Date.now(),
|
|
9119
|
+
eventCount: events.length,
|
|
9120
|
+
handoffs: {
|
|
9121
|
+
failed: handoffs.failed,
|
|
9122
|
+
total: handoffs.total
|
|
9123
|
+
},
|
|
9124
|
+
links: options.links ?? DEFAULT_LINKS,
|
|
9125
|
+
providers: countProviderStatuses(providers),
|
|
9126
|
+
quality,
|
|
9127
|
+
recentRoutingEvents: routingEvents,
|
|
9128
|
+
recentSessions: sessions,
|
|
9129
|
+
sessions: {
|
|
9130
|
+
failed: sessions.filter((session) => session.status === "failed").length,
|
|
9131
|
+
healthy: sessions.filter((session) => session.status === "healthy").length,
|
|
9132
|
+
total: sessions.length
|
|
9133
|
+
},
|
|
9134
|
+
trace
|
|
9135
|
+
};
|
|
9136
|
+
};
|
|
9137
|
+
var renderMetricCard = (input) => `<article class="metric"><span>${escapeHtml12(input.label)}</span><strong>${escapeHtml12(String(input.value))}</strong>${input.status ? `<p class="${escapeHtml12(input.status)}">${escapeHtml12(input.status)}</p>` : ""}${input.href ? `<a href="${escapeHtml12(input.href)}">Open</a>` : ""}</article>`;
|
|
9138
|
+
var renderVoiceOpsConsoleHTML = (report, options = {}) => {
|
|
9139
|
+
const links = report.links.map((link) => `<article class="surface">
|
|
9140
|
+
<div><h2>${escapeHtml12(link.label)}</h2>${link.description ? `<p>${escapeHtml12(link.description)}</p>` : ""}</div>
|
|
9141
|
+
<p><a href="${escapeHtml12(link.href)}">Open ${escapeHtml12(link.label)}</a>${link.statusHref ? ` \xB7 <a href="${escapeHtml12(link.statusHref)}">Status</a>` : ""}</p>
|
|
9142
|
+
</article>`).join("");
|
|
9143
|
+
const sessions = report.recentSessions.length ? report.recentSessions.map((session) => `<tr><td>${escapeHtml12(session.sessionId)}</td><td>${escapeHtml12(session.status)}</td><td>${session.turnCount}</td><td>${session.errorCount}</td><td>${session.replayHref ? `<a href="${escapeHtml12(session.replayHref)}">Replay</a>` : ""}</td></tr>`).join("") : '<tr><td colspan="5">No sessions yet.</td></tr>';
|
|
9144
|
+
const routing = report.recentRoutingEvents.length ? report.recentRoutingEvents.map((event) => `<tr><td>${escapeHtml12(event.kind)}</td><td>${escapeHtml12(event.provider ?? "unknown")}</td><td>${escapeHtml12(event.status ?? "unknown")}</td><td>${event.elapsedMs ?? 0}ms</td><td>${escapeHtml12(event.sessionId)}</td></tr>`).join("") : '<tr><td colspan="5">No provider routing events yet.</td></tr>';
|
|
9145
|
+
const title = options.title ?? "AbsoluteJS Voice Ops Console";
|
|
9146
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml12(title)}</title><style>body{font-family:ui-sans-serif,system-ui,sans-serif;background:#101316;color:#f6f2e8;margin:0}main{max-width:1180px;margin:auto;padding:32px}a{color:#fbbf24}header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:24px}.eyebrow{color:#fbbf24;font-weight:800;letter-spacing:.08em;text-transform:uppercase}h1{font-size:clamp(2.2rem,5vw,4.5rem);line-height:.95;margin:.2rem 0 1rem}.muted{color:#a8b0b8}.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));margin:20px 0}.metric,.surface{background:#181d22;border:1px solid #2a323a;border-radius:20px;padding:18px}.metric strong{display:block;font-size:2.2rem;margin:.25rem 0}.pass,.healthy{color:#86efac}.fail,.failed,.degraded{color:#fca5a5}.surfaces{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));margin:24px 0}table{width:100%;border-collapse:collapse;background:#181d22;border-radius:16px;overflow:hidden;margin:12px 0 28px}td,th{border-bottom:1px solid #2a323a;padding:12px;text-align:left}section{margin-top:30px}@media(max-width:700px){main{padding:20px}header{display:block}}</style></head><body><main><header><div><p class="eyebrow">Self-hosted voice operations</p><h1>${escapeHtml12(title)}</h1><p class="muted">One deployable control plane for quality gates, failover, traces, sessions, handoffs, and provider health.</p></div><p class="muted">Checked ${escapeHtml12(new Date(report.checkedAt).toLocaleString())}</p></header><div class="grid">${renderMetricCard({ label: "Quality", value: report.quality.status, status: report.quality.status, href: "/quality" })}${renderMetricCard({ label: "Events", value: report.eventCount, href: "/diagnostics" })}${renderMetricCard({ label: "Sessions", value: report.sessions.total, status: report.sessions.failed > 0 ? "failed" : "healthy", href: "/sessions" })}${renderMetricCard({ label: "Handoffs failed", value: report.handoffs.failed, status: report.handoffs.failed > 0 ? "failed" : "healthy", href: "/handoffs" })}${renderMetricCard({ label: "Providers degraded", value: report.providers.degraded, status: report.providers.degraded > 0 ? "degraded" : "healthy", href: "/resilience" })}</div><section><h2>Operational Surfaces</h2><div class="surfaces">${links}</div></section><section><h2>Recent Sessions</h2><table><thead><tr><th>Session</th><th>Status</th><th>Turns</th><th>Errors</th><th>Replay</th></tr></thead><tbody>${sessions}</tbody></table></section><section><h2>Recent Provider Routing</h2><table><thead><tr><th>Kind</th><th>Provider</th><th>Status</th><th>Elapsed</th><th>Session</th></tr></thead><tbody>${routing}</tbody></table></section></main></body></html>`;
|
|
9147
|
+
};
|
|
9148
|
+
var createVoiceOpsConsoleRoutes = (options) => {
|
|
9149
|
+
const path = options.path ?? "/ops-console";
|
|
9150
|
+
const routes = new Elysia10({
|
|
9151
|
+
name: options.name ?? "absolutejs-voice-ops-console"
|
|
9152
|
+
});
|
|
9153
|
+
const getReport = () => buildVoiceOpsConsoleReport(options);
|
|
9154
|
+
routes.get(path, async () => {
|
|
9155
|
+
const report = await getReport();
|
|
9156
|
+
return new Response(renderVoiceOpsConsoleHTML(report, { title: options.title }), {
|
|
9157
|
+
headers: {
|
|
9158
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
9159
|
+
...options.headers
|
|
9160
|
+
}
|
|
9161
|
+
});
|
|
9162
|
+
});
|
|
9163
|
+
routes.get(`${path}/json`, async () => getReport());
|
|
9164
|
+
return routes;
|
|
9165
|
+
};
|
|
9166
|
+
|
|
9167
|
+
// src/appKit.ts
|
|
9168
|
+
var DEFAULT_LINKS2 = [
|
|
9169
|
+
{
|
|
9170
|
+
description: "Integrated voice operations console.",
|
|
9171
|
+
href: "/ops-console",
|
|
9172
|
+
label: "Ops Console"
|
|
9173
|
+
},
|
|
9174
|
+
{
|
|
9175
|
+
description: "Production quality gates.",
|
|
9176
|
+
href: "/quality",
|
|
9177
|
+
label: "Quality",
|
|
9178
|
+
statusHref: "/quality/status"
|
|
9179
|
+
},
|
|
9180
|
+
{
|
|
9181
|
+
description: "Replay sessions against evals and workflow contracts.",
|
|
9182
|
+
href: "/evals",
|
|
9183
|
+
label: "Evals",
|
|
9184
|
+
statusHref: "/evals/status"
|
|
9185
|
+
},
|
|
9186
|
+
{
|
|
9187
|
+
description: "Provider routing, fallback, and resilience controls.",
|
|
9188
|
+
href: "/resilience",
|
|
9189
|
+
label: "Resilience"
|
|
9190
|
+
},
|
|
9191
|
+
{
|
|
9192
|
+
description: "Recent sessions and replay links.",
|
|
9193
|
+
href: "/sessions",
|
|
9194
|
+
label: "Sessions"
|
|
9195
|
+
},
|
|
9196
|
+
{
|
|
9197
|
+
description: "Handoff delivery health.",
|
|
9198
|
+
href: "/handoffs",
|
|
9199
|
+
label: "Handoffs"
|
|
9200
|
+
},
|
|
9201
|
+
{
|
|
9202
|
+
description: "Redacted traces and bug-report exports.",
|
|
9203
|
+
href: "/diagnostics",
|
|
9204
|
+
label: "Diagnostics"
|
|
9205
|
+
}
|
|
9206
|
+
];
|
|
9207
|
+
var resolveLinks = (links) => links ?? DEFAULT_LINKS2;
|
|
9208
|
+
var toBasicLinks = (links) => links.map(({ href, label }) => ({ href, label }));
|
|
9209
|
+
var toOpsLinks = (links) => links.map((link) => ({
|
|
9210
|
+
description: link.description ?? link.label,
|
|
9211
|
+
href: link.href,
|
|
9212
|
+
label: link.label,
|
|
9213
|
+
statusHref: link.statusHref
|
|
9214
|
+
}));
|
|
9215
|
+
var toResilienceLinks = (links) => links.map(({ href, label }) => ({ href, label }));
|
|
9216
|
+
var countStatus = (statuses) => ({
|
|
9217
|
+
failed: statuses.filter((status) => status === "fail").length,
|
|
9218
|
+
passed: statuses.filter((status) => status === "pass").length,
|
|
9219
|
+
total: statuses.length
|
|
9220
|
+
});
|
|
9221
|
+
var summarizeVoiceAppKitStatus = async (options) => {
|
|
9222
|
+
const links = resolveLinks(options.links);
|
|
9223
|
+
const statusOptions = options.appStatus && typeof options.appStatus === "object" ? options.appStatus : undefined;
|
|
9224
|
+
const evalOptions = options.evals === false ? undefined : options.evals;
|
|
9225
|
+
const include = statusOptions?.include;
|
|
9226
|
+
const shouldInclude = (surface) => include?.[surface] !== false;
|
|
9227
|
+
const events = filterVoiceTraceEvents(await options.store.list());
|
|
9228
|
+
const [quality, workflows, providers, sessions, handoffs] = await Promise.all([
|
|
9229
|
+
options.quality === false || !shouldInclude("quality") ? undefined : evaluateVoiceQuality({
|
|
9230
|
+
events,
|
|
9231
|
+
thresholds: options.quality?.thresholds
|
|
9232
|
+
}),
|
|
9233
|
+
!evalOptions || !shouldInclude("workflows") ? undefined : (async () => {
|
|
9234
|
+
const fixtureReport = await runVoiceScenarioFixtureEvals({
|
|
9235
|
+
fixtures: evalOptions.fixtures,
|
|
9236
|
+
fixtureStore: evalOptions.fixtureStore,
|
|
9237
|
+
scenarios: evalOptions.scenarios
|
|
9238
|
+
});
|
|
9239
|
+
if ((statusOptions?.preferFixtureWorkflows ?? true) && fixtureReport.total > 0) {
|
|
9240
|
+
return {
|
|
9241
|
+
failed: fixtureReport.failed,
|
|
9242
|
+
source: "fixtures",
|
|
9243
|
+
status: fixtureReport.status,
|
|
9244
|
+
total: fixtureReport.total
|
|
9245
|
+
};
|
|
9246
|
+
}
|
|
9247
|
+
const liveReport = await runVoiceScenarioEvals({
|
|
9248
|
+
events,
|
|
9249
|
+
scenarios: evalOptions.scenarios
|
|
9250
|
+
});
|
|
9251
|
+
return {
|
|
9252
|
+
failed: liveReport.failed,
|
|
9253
|
+
source: "live",
|
|
9254
|
+
status: liveReport.status,
|
|
9255
|
+
total: liveReport.total
|
|
9256
|
+
};
|
|
9257
|
+
})(),
|
|
9258
|
+
options.providerHealth === false || !shouldInclude("providers") ? undefined : summarizeVoiceProviderHealth({
|
|
9259
|
+
events,
|
|
9260
|
+
providers: options.llmProviders
|
|
9261
|
+
}),
|
|
9262
|
+
options.sessions === false || !shouldInclude("sessions") ? undefined : summarizeVoiceSessions({
|
|
9263
|
+
events
|
|
9264
|
+
}),
|
|
9265
|
+
options.handoffs === false || !shouldInclude("handoffs") ? undefined : summarizeVoiceHandoffHealth({
|
|
9266
|
+
events
|
|
9267
|
+
})
|
|
9268
|
+
]);
|
|
9269
|
+
const surfaces = {};
|
|
9270
|
+
const statuses = [];
|
|
9271
|
+
if (quality) {
|
|
9272
|
+
surfaces.quality = { status: quality.status };
|
|
9273
|
+
statuses.push(quality.status);
|
|
9274
|
+
}
|
|
9275
|
+
if (workflows) {
|
|
9276
|
+
const status = workflows.status;
|
|
9277
|
+
surfaces.workflows = {
|
|
9278
|
+
failed: workflows.failed,
|
|
9279
|
+
source: workflows.source,
|
|
9280
|
+
status,
|
|
9281
|
+
total: workflows.total
|
|
9282
|
+
};
|
|
9283
|
+
statuses.push(status);
|
|
9284
|
+
}
|
|
9285
|
+
if (providers) {
|
|
9286
|
+
const degraded = providers.filter((provider) => provider.status === "degraded" || provider.status === "rate-limited" || provider.status === "suppressed").length;
|
|
9287
|
+
const status = degraded > 0 ? "fail" : "pass";
|
|
9288
|
+
surfaces.providers = {
|
|
9289
|
+
degraded,
|
|
9290
|
+
status,
|
|
9291
|
+
total: providers.length
|
|
9292
|
+
};
|
|
9293
|
+
statuses.push(status);
|
|
9294
|
+
}
|
|
9295
|
+
if (sessions) {
|
|
9296
|
+
const failed = sessions.filter((session) => session.status === "failed").length;
|
|
9297
|
+
const status = failed > 0 ? "fail" : "pass";
|
|
9298
|
+
surfaces.sessions = {
|
|
9299
|
+
failed,
|
|
9300
|
+
status,
|
|
9301
|
+
total: sessions.length
|
|
9302
|
+
};
|
|
9303
|
+
statuses.push(status);
|
|
9304
|
+
}
|
|
9305
|
+
if (handoffs) {
|
|
9306
|
+
const status = handoffs.failed > 0 ? "fail" : "pass";
|
|
9307
|
+
surfaces.handoffs = {
|
|
9308
|
+
failed: handoffs.failed,
|
|
9309
|
+
status,
|
|
9310
|
+
total: handoffs.total
|
|
9311
|
+
};
|
|
9312
|
+
statuses.push(status);
|
|
9313
|
+
}
|
|
9314
|
+
return {
|
|
9315
|
+
checkedAt: Date.now(),
|
|
9316
|
+
links,
|
|
9317
|
+
status: statuses.includes("fail") ? "fail" : "pass",
|
|
9318
|
+
surfaces,
|
|
9319
|
+
...countStatus(statuses)
|
|
9320
|
+
};
|
|
9321
|
+
};
|
|
9322
|
+
var createVoiceAppKitRoutes = (options) => {
|
|
9323
|
+
const routes = new Elysia11({
|
|
9324
|
+
name: options.name ?? "absolutejs-voice-app-kit"
|
|
9325
|
+
});
|
|
9326
|
+
const links = resolveLinks(options.links);
|
|
9327
|
+
const common = {
|
|
9328
|
+
headers: options.headers,
|
|
9329
|
+
store: options.store
|
|
9330
|
+
};
|
|
9331
|
+
const surfaces = [];
|
|
9332
|
+
if (options.appStatus !== false) {
|
|
9333
|
+
routes.get(options.appStatus?.path ?? "/app-kit/status", () => summarizeVoiceAppKitStatus(options));
|
|
9334
|
+
}
|
|
9335
|
+
if (options.providerHealth !== false) {
|
|
9336
|
+
surfaces.push("providerHealth");
|
|
9337
|
+
routes.use(createVoiceProviderHealthRoutes({
|
|
9338
|
+
...common,
|
|
9339
|
+
providers: options.llmProviders,
|
|
9340
|
+
...options.providerHealth
|
|
9341
|
+
}));
|
|
9342
|
+
}
|
|
9343
|
+
if (options.assistantHealth !== false) {
|
|
9344
|
+
surfaces.push("assistantHealth");
|
|
9345
|
+
routes.use(createVoiceAssistantHealthRoutes({
|
|
9346
|
+
...common,
|
|
9347
|
+
providers: options.llmProviders,
|
|
9348
|
+
...options.assistantHealth
|
|
9349
|
+
}));
|
|
9350
|
+
}
|
|
9351
|
+
if (options.quality !== false) {
|
|
9352
|
+
surfaces.push("quality");
|
|
9353
|
+
routes.use(createVoiceQualityRoutes({
|
|
9354
|
+
...common,
|
|
9355
|
+
links: toBasicLinks(links),
|
|
9356
|
+
...options.quality
|
|
9357
|
+
}));
|
|
9358
|
+
}
|
|
9359
|
+
if (options.evals !== false) {
|
|
9360
|
+
surfaces.push("evals");
|
|
9361
|
+
routes.use(createVoiceEvalRoutes({
|
|
9362
|
+
...common,
|
|
9363
|
+
links: toBasicLinks(links),
|
|
9364
|
+
title: options.title ? `${options.title} Evals` : undefined,
|
|
9365
|
+
...options.evals
|
|
9366
|
+
}));
|
|
9367
|
+
}
|
|
9368
|
+
if (options.sessions !== false) {
|
|
9369
|
+
surfaces.push("sessions");
|
|
9370
|
+
routes.use(createVoiceSessionListRoutes({
|
|
9371
|
+
...common,
|
|
9372
|
+
htmlPath: "/sessions",
|
|
9373
|
+
path: "/api/voice-sessions",
|
|
9374
|
+
replayHref: "/sessions/:sessionId",
|
|
9375
|
+
...options.sessions
|
|
9376
|
+
}));
|
|
9377
|
+
}
|
|
9378
|
+
if (options.sessionReplay !== false) {
|
|
9379
|
+
surfaces.push("sessionReplay");
|
|
9380
|
+
routes.use(createVoiceSessionReplayRoutes({
|
|
9381
|
+
...common,
|
|
9382
|
+
htmlPath: "/sessions/:sessionId",
|
|
9383
|
+
path: "/api/voice-sessions/:sessionId/replay",
|
|
9384
|
+
...options.sessionReplay
|
|
9385
|
+
}));
|
|
9386
|
+
}
|
|
9387
|
+
if (options.handoffs !== false) {
|
|
9388
|
+
surfaces.push("handoffs");
|
|
9389
|
+
routes.use(createVoiceHandoffHealthRoutes({
|
|
9390
|
+
...common,
|
|
9391
|
+
htmlPath: "/handoffs",
|
|
9392
|
+
path: "/api/voice-handoffs",
|
|
9393
|
+
...options.handoffs
|
|
9394
|
+
}));
|
|
9395
|
+
}
|
|
9396
|
+
if (options.diagnostics !== false) {
|
|
9397
|
+
surfaces.push("diagnostics");
|
|
9398
|
+
routes.use(createVoiceDiagnosticsRoutes({
|
|
9399
|
+
...common,
|
|
9400
|
+
path: "/diagnostics",
|
|
9401
|
+
title: options.title ? `${options.title} Diagnostics` : undefined,
|
|
9402
|
+
...options.diagnostics
|
|
9403
|
+
}));
|
|
9404
|
+
}
|
|
9405
|
+
if (options.resilience !== false) {
|
|
9406
|
+
surfaces.push("resilience");
|
|
9407
|
+
routes.use(createVoiceResilienceRoutes({
|
|
9408
|
+
...common,
|
|
9409
|
+
links: toResilienceLinks(links),
|
|
9410
|
+
llmProviders: options.llmProviders,
|
|
9411
|
+
sttProviders: options.sttProviders,
|
|
9412
|
+
title: options.title ? `${options.title} Resilience` : undefined,
|
|
9413
|
+
ttsProviders: options.ttsProviders,
|
|
9414
|
+
...options.resilience
|
|
9415
|
+
}));
|
|
9416
|
+
}
|
|
9417
|
+
if (options.opsConsole !== false) {
|
|
9418
|
+
surfaces.push("opsConsole");
|
|
9419
|
+
routes.use(createVoiceOpsConsoleRoutes({
|
|
9420
|
+
...common,
|
|
9421
|
+
links: toOpsLinks(links),
|
|
9422
|
+
llmProviders: options.llmProviders,
|
|
9423
|
+
sttProviders: options.sttProviders,
|
|
9424
|
+
title: options.title,
|
|
9425
|
+
ttsProviders: options.ttsProviders,
|
|
9426
|
+
...options.opsConsole
|
|
9427
|
+
}));
|
|
9428
|
+
}
|
|
9429
|
+
return {
|
|
9430
|
+
links,
|
|
9431
|
+
routes,
|
|
9432
|
+
surfaces,
|
|
9433
|
+
use: routes.use.bind(routes)
|
|
9434
|
+
};
|
|
9435
|
+
};
|
|
9436
|
+
var createVoiceAppKit = createVoiceAppKitRoutes;
|
|
9437
|
+
// src/workflowContract.ts
|
|
9438
|
+
var getObject2 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
9439
|
+
var getPathValue2 = (value, path) => {
|
|
9440
|
+
let current = value;
|
|
9441
|
+
for (const part of path.split(".").filter(Boolean)) {
|
|
9442
|
+
const record = getObject2(current);
|
|
9443
|
+
if (!record || !(part in record)) {
|
|
9444
|
+
return;
|
|
9445
|
+
}
|
|
9446
|
+
current = record[part];
|
|
9447
|
+
}
|
|
9448
|
+
return current;
|
|
9449
|
+
};
|
|
9450
|
+
var hasValue = (value, match) => {
|
|
9451
|
+
switch (match) {
|
|
9452
|
+
case "boolean":
|
|
9453
|
+
return typeof value === "boolean";
|
|
9454
|
+
case "number":
|
|
9455
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
9456
|
+
case "string":
|
|
9457
|
+
return typeof value === "string";
|
|
9458
|
+
case "truthy":
|
|
9459
|
+
return Boolean(value);
|
|
9460
|
+
case "non-empty":
|
|
9461
|
+
default:
|
|
9462
|
+
return Array.isArray(value) ? value.length > 0 : typeof value === "string" ? value.trim().length > 0 : value !== undefined && value !== null;
|
|
9463
|
+
}
|
|
9464
|
+
};
|
|
9465
|
+
var resolveOutcome2 = (routeResult) => {
|
|
9466
|
+
if (routeResult.complete)
|
|
9467
|
+
return "complete";
|
|
9468
|
+
if (routeResult.transfer)
|
|
9469
|
+
return "transfer";
|
|
9470
|
+
if (routeResult.escalate)
|
|
9471
|
+
return "escalate";
|
|
9472
|
+
if (routeResult.voicemail)
|
|
9473
|
+
return "voicemail";
|
|
9474
|
+
if (routeResult.noAnswer)
|
|
9475
|
+
return "no-answer";
|
|
9476
|
+
return;
|
|
9477
|
+
};
|
|
9478
|
+
var validateVoiceWorkflowRouteResult = (definition, routeResult) => {
|
|
9479
|
+
const issues = [];
|
|
9480
|
+
const requiredFields = (definition.fields ?? []).filter((field) => field.required !== false).map((field) => field.path);
|
|
9481
|
+
const missingFields = [];
|
|
9482
|
+
const outcome = resolveOutcome2(routeResult);
|
|
9483
|
+
if (definition.outcome && outcome !== definition.outcome) {
|
|
9484
|
+
issues.push({
|
|
9485
|
+
code: "workflow.outcome_mismatch",
|
|
9486
|
+
message: `Expected workflow outcome ${definition.outcome}, saw ${outcome ?? "none"}.`
|
|
9487
|
+
});
|
|
9488
|
+
}
|
|
9489
|
+
for (const field of definition.fields ?? []) {
|
|
9490
|
+
if (field.required === false)
|
|
9491
|
+
continue;
|
|
9492
|
+
const paths = [field.path, ...field.aliases ?? []];
|
|
9493
|
+
const present = paths.some((path) => hasValue(getPathValue2(routeResult.result, path), field.match ?? "non-empty"));
|
|
9494
|
+
if (!present) {
|
|
9495
|
+
missingFields.push(field.path);
|
|
9496
|
+
issues.push({
|
|
9497
|
+
code: "workflow.missing_field",
|
|
9498
|
+
field: field.path,
|
|
9499
|
+
message: `Missing required workflow field: ${field.label ?? field.path}.`
|
|
9500
|
+
});
|
|
9501
|
+
}
|
|
9502
|
+
}
|
|
9503
|
+
issues.push(...definition.validate?.({
|
|
9504
|
+
result: routeResult.result,
|
|
9505
|
+
routeResult
|
|
9506
|
+
}) ?? []);
|
|
9507
|
+
return {
|
|
9508
|
+
contractId: definition.id,
|
|
9509
|
+
issues,
|
|
9510
|
+
missingFields,
|
|
9511
|
+
outcome,
|
|
9512
|
+
pass: issues.length === 0,
|
|
9513
|
+
requiredFields
|
|
9514
|
+
};
|
|
9515
|
+
};
|
|
9516
|
+
var createVoiceWorkflowScenario = (definition, overrides = {}) => ({
|
|
9517
|
+
description: definition.description,
|
|
9518
|
+
forbiddenHandoffActions: definition.forbiddenHandoffActions,
|
|
9519
|
+
id: definition.id,
|
|
9520
|
+
label: definition.label,
|
|
9521
|
+
maxProviderErrors: definition.maxProviderErrors,
|
|
9522
|
+
maxSessionErrors: definition.maxSessionErrors,
|
|
9523
|
+
minSessions: definition.minSessions,
|
|
9524
|
+
minTurns: definition.minTurns,
|
|
9525
|
+
requiredAssistantIncludes: definition.requiredAssistantIncludes,
|
|
9526
|
+
requiredDisposition: definition.requiredDisposition,
|
|
9527
|
+
requiredHandoffActions: definition.requiredHandoffActions,
|
|
9528
|
+
requiredLifecycleTypes: definition.requiredLifecycleTypes,
|
|
9529
|
+
requiredTranscriptIncludes: definition.requiredTranscriptIncludes,
|
|
9530
|
+
requiredWorkflowContracts: [definition.id],
|
|
9531
|
+
scenarioId: definition.scenarioId,
|
|
9532
|
+
...overrides
|
|
9533
|
+
});
|
|
9534
|
+
var createVoiceWorkflowContract = (definition) => ({
|
|
9535
|
+
assertRouteResult: (routeResult) => {
|
|
9536
|
+
const validation = validateVoiceWorkflowRouteResult(definition, routeResult);
|
|
9537
|
+
if (!validation.pass) {
|
|
9538
|
+
throw new Error(`Voice workflow contract ${definition.id} failed: ${validation.issues.map((issue) => issue.message).join(" ")}`);
|
|
9539
|
+
}
|
|
9540
|
+
},
|
|
9541
|
+
definition,
|
|
9542
|
+
toScenarioEval: (overrides) => createVoiceWorkflowScenario(definition, overrides),
|
|
9543
|
+
validateRouteResult: (routeResult) => validateVoiceWorkflowRouteResult(definition, routeResult)
|
|
9544
|
+
});
|
|
9545
|
+
var presetDefinitions = {
|
|
9546
|
+
"appointment-booking": {
|
|
9547
|
+
description: "Appointment booking should complete with enough identity, appointment, and follow-up details to act on.",
|
|
9548
|
+
fields: [
|
|
9549
|
+
{ aliases: ["name", "customer.name"], label: "Caller name", path: "caller.name" },
|
|
9550
|
+
{
|
|
9551
|
+
aliases: ["phone", "customer.phone"],
|
|
9552
|
+
label: "Caller phone",
|
|
9553
|
+
path: "caller.phone"
|
|
9554
|
+
},
|
|
9555
|
+
{
|
|
9556
|
+
aliases: ["appointment.start", "appointment.time", "scheduledAt"],
|
|
9557
|
+
label: "Appointment time",
|
|
9558
|
+
path: "appointment.startsAt"
|
|
9559
|
+
},
|
|
9560
|
+
{
|
|
9561
|
+
aliases: ["summary", "assistantSummary"],
|
|
9562
|
+
label: "Summary",
|
|
9563
|
+
path: "appointment.summary"
|
|
9564
|
+
}
|
|
9565
|
+
],
|
|
9566
|
+
id: "appointment-booking",
|
|
9567
|
+
label: "Appointment booking",
|
|
9568
|
+
outcome: "complete",
|
|
9569
|
+
requiredDisposition: "completed"
|
|
9570
|
+
},
|
|
9571
|
+
"lead-qualification": {
|
|
9572
|
+
description: "Lead qualification should complete with contact, need, qualification, and next-step fields.",
|
|
9573
|
+
fields: [
|
|
9574
|
+
{ aliases: ["name", "lead.name"], label: "Lead name", path: "contact.name" },
|
|
9575
|
+
{
|
|
9576
|
+
aliases: ["email", "lead.email"],
|
|
9577
|
+
label: "Lead email",
|
|
9578
|
+
path: "contact.email"
|
|
9579
|
+
},
|
|
9580
|
+
{
|
|
9581
|
+
aliases: ["need", "pain", "summary"],
|
|
9582
|
+
label: "Need",
|
|
9583
|
+
path: "qualification.need"
|
|
9584
|
+
},
|
|
9585
|
+
{
|
|
9586
|
+
aliases: ["qualified", "qualification.qualified"],
|
|
9587
|
+
label: "Qualified",
|
|
9588
|
+
match: "boolean",
|
|
9589
|
+
path: "qualification.isQualified"
|
|
9590
|
+
},
|
|
9591
|
+
{
|
|
9592
|
+
aliases: ["nextStep", "followUp"],
|
|
9593
|
+
label: "Next step",
|
|
9594
|
+
path: "qualification.nextStep"
|
|
9595
|
+
}
|
|
9596
|
+
],
|
|
9597
|
+
id: "lead-qualification",
|
|
9598
|
+
label: "Lead qualification",
|
|
9599
|
+
outcome: "complete",
|
|
9600
|
+
requiredDisposition: "completed"
|
|
9601
|
+
},
|
|
9602
|
+
"support-triage": {
|
|
9603
|
+
description: "Support triage should capture identity, issue summary, severity, and the operational follow-up.",
|
|
9604
|
+
fields: [
|
|
9605
|
+
{
|
|
9606
|
+
aliases: ["name", "customer.name"],
|
|
9607
|
+
label: "Customer name",
|
|
9608
|
+
path: "customer.name"
|
|
9609
|
+
},
|
|
9610
|
+
{
|
|
9611
|
+
aliases: ["issue", "summary", "assistantSummary"],
|
|
9612
|
+
label: "Issue summary",
|
|
9613
|
+
path: "issue.summary"
|
|
9614
|
+
},
|
|
9615
|
+
{
|
|
9616
|
+
aliases: ["priority", "severity"],
|
|
9617
|
+
label: "Severity",
|
|
9618
|
+
path: "issue.severity"
|
|
9619
|
+
},
|
|
9620
|
+
{
|
|
9621
|
+
aliases: ["nextStep", "task.title"],
|
|
9622
|
+
label: "Next step",
|
|
9623
|
+
path: "resolution.nextStep"
|
|
9624
|
+
}
|
|
9625
|
+
],
|
|
9626
|
+
id: "support-triage",
|
|
9627
|
+
label: "Support triage",
|
|
9628
|
+
outcome: "complete",
|
|
9629
|
+
requiredDisposition: "completed"
|
|
9630
|
+
},
|
|
9631
|
+
"transfer-handoff": {
|
|
9632
|
+
description: "Transfer handoff should produce a routed transfer plus handoff evidence.",
|
|
9633
|
+
fields: [
|
|
9634
|
+
{
|
|
9635
|
+
aliases: ["target", "callTarget"],
|
|
9636
|
+
label: "Transfer target",
|
|
9637
|
+
path: "transfer.target"
|
|
9638
|
+
},
|
|
9639
|
+
{
|
|
9640
|
+
aliases: ["reason", "callReason"],
|
|
9641
|
+
label: "Transfer reason",
|
|
9642
|
+
path: "transfer.reason"
|
|
9643
|
+
},
|
|
9644
|
+
{
|
|
9645
|
+
aliases: ["summary", "assistantSummary"],
|
|
9646
|
+
label: "Transfer summary",
|
|
9647
|
+
path: "transfer.summary"
|
|
9648
|
+
}
|
|
9649
|
+
],
|
|
9650
|
+
id: "transfer-handoff",
|
|
9651
|
+
label: "Transfer handoff",
|
|
9652
|
+
outcome: "transfer",
|
|
9653
|
+
requiredDisposition: "transferred",
|
|
9654
|
+
requiredHandoffActions: ["transfer"]
|
|
9655
|
+
},
|
|
9656
|
+
"voicemail-callback": {
|
|
9657
|
+
description: "Voicemail callback should preserve enough caller and callback context for follow-up.",
|
|
9658
|
+
fields: [
|
|
9659
|
+
{
|
|
9660
|
+
aliases: ["name", "caller.name"],
|
|
9661
|
+
label: "Caller name",
|
|
9662
|
+
path: "voicemail.callerName"
|
|
9663
|
+
},
|
|
9664
|
+
{
|
|
9665
|
+
aliases: ["phone", "caller.phone"],
|
|
9666
|
+
label: "Callback phone",
|
|
9667
|
+
path: "voicemail.callbackPhone"
|
|
9668
|
+
},
|
|
9669
|
+
{
|
|
9670
|
+
aliases: ["message", "summary", "assistantSummary"],
|
|
9671
|
+
label: "Voicemail summary",
|
|
9672
|
+
path: "voicemail.summary"
|
|
9673
|
+
}
|
|
9674
|
+
],
|
|
9675
|
+
id: "voicemail-callback",
|
|
9676
|
+
label: "Voicemail callback",
|
|
9677
|
+
outcome: "voicemail",
|
|
9678
|
+
requiredDisposition: "voicemail",
|
|
9679
|
+
requiredHandoffActions: ["voicemail"]
|
|
9680
|
+
}
|
|
9681
|
+
};
|
|
9682
|
+
var createVoiceWorkflowContractPreset = (name, options = {}) => {
|
|
9683
|
+
const preset = presetDefinitions[name];
|
|
9684
|
+
return createVoiceWorkflowContract({
|
|
9685
|
+
...preset,
|
|
9686
|
+
...options,
|
|
9687
|
+
fields: options.fields ?? preset.fields,
|
|
9688
|
+
id: options.id ?? preset.id
|
|
9689
|
+
});
|
|
9690
|
+
};
|
|
9691
|
+
var recordVoiceWorkflowContractTrace = async (input) => input.store.append({
|
|
9692
|
+
at: input.at ?? Date.now(),
|
|
9693
|
+
payload: {
|
|
9694
|
+
contractId: input.contractId ?? input.validation.contractId,
|
|
9695
|
+
issues: input.validation.issues,
|
|
9696
|
+
missingFields: input.validation.missingFields,
|
|
9697
|
+
outcome: input.validation.outcome,
|
|
9698
|
+
requiredFields: input.validation.requiredFields,
|
|
9699
|
+
status: input.validation.pass ? "pass" : "fail"
|
|
9700
|
+
},
|
|
9701
|
+
scenarioId: input.scenarioId,
|
|
9702
|
+
sessionId: input.sessionId,
|
|
9703
|
+
traceId: input.traceId,
|
|
9704
|
+
turnId: input.turnId,
|
|
9705
|
+
type: "workflow.contract"
|
|
6770
9706
|
});
|
|
6771
|
-
|
|
9707
|
+
var createVoiceWorkflowContractHandler = (input) => {
|
|
9708
|
+
return async (session, turn, api, context) => {
|
|
9709
|
+
const legacyHandler = input.handler;
|
|
9710
|
+
const objectHandler = input.handler;
|
|
9711
|
+
const result = input.handler.length >= 4 ? await legacyHandler(session, turn, api, context) : await objectHandler({ api, context, session, turn });
|
|
9712
|
+
if (!result)
|
|
9713
|
+
return result;
|
|
9714
|
+
const resolved = input.resolveContract?.({ context, result, session, turn }) ?? input.contract;
|
|
9715
|
+
if (!resolved)
|
|
9716
|
+
return result;
|
|
9717
|
+
const contract = "validateRouteResult" in resolved ? resolved : createVoiceWorkflowContract(resolved);
|
|
9718
|
+
const validation = contract.validateRouteResult(result);
|
|
9719
|
+
if (input.store) {
|
|
9720
|
+
await recordVoiceWorkflowContractTrace({
|
|
9721
|
+
scenarioId: session.scenarioId,
|
|
9722
|
+
sessionId: session.id,
|
|
9723
|
+
store: input.store,
|
|
9724
|
+
turnId: turn.id,
|
|
9725
|
+
validation
|
|
9726
|
+
});
|
|
9727
|
+
}
|
|
9728
|
+
return result;
|
|
9729
|
+
};
|
|
9730
|
+
};
|
|
6772
9731
|
// src/fileStore.ts
|
|
9732
|
+
import { mkdir as mkdir2, readFile, readdir, rename, rm, writeFile } from "fs/promises";
|
|
9733
|
+
import { join } from "path";
|
|
6773
9734
|
var listJsonFiles = async (directory) => {
|
|
6774
9735
|
try {
|
|
6775
9736
|
const entries = await readdir(directory, {
|
|
@@ -6788,7 +9749,7 @@ var resolveFilePath = (directory, id) => join(directory, encodeStoreId(id));
|
|
|
6788
9749
|
var createMemoryStoreId = (input) => `${input.assistantId}:${input.namespace}:${input.key}`;
|
|
6789
9750
|
var readJsonFile = async (path) => JSON.parse(await readFile(path, "utf8"));
|
|
6790
9751
|
var writeJsonFile = async (path, value, options) => {
|
|
6791
|
-
await
|
|
9752
|
+
await mkdir2(options.directory, {
|
|
6792
9753
|
recursive: true
|
|
6793
9754
|
});
|
|
6794
9755
|
const tempPath = `${path}.${crypto.randomUUID()}.tmp`;
|
|
@@ -7085,6 +10046,47 @@ var createStoredVoiceExternalObjectMap = (mapping) => createVoiceExternalObjectM
|
|
|
7085
10046
|
sourceType: mapping.sourceType
|
|
7086
10047
|
});
|
|
7087
10048
|
// src/modelAdapters.ts
|
|
10049
|
+
var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
|
|
10050
|
+
switch (preset) {
|
|
10051
|
+
case "balanced":
|
|
10052
|
+
return {
|
|
10053
|
+
fallbackMode: "provider-error",
|
|
10054
|
+
strategy: "balanced",
|
|
10055
|
+
weights: {
|
|
10056
|
+
cost: 1,
|
|
10057
|
+
latencyMs: 0.005,
|
|
10058
|
+
priority: 1,
|
|
10059
|
+
quality: 10,
|
|
10060
|
+
...options.weights
|
|
10061
|
+
},
|
|
10062
|
+
...options
|
|
10063
|
+
};
|
|
10064
|
+
case "cost-cap":
|
|
10065
|
+
return {
|
|
10066
|
+
fallbackMode: "provider-error",
|
|
10067
|
+
strategy: "prefer-cheapest",
|
|
10068
|
+
...options
|
|
10069
|
+
};
|
|
10070
|
+
case "cost-first":
|
|
10071
|
+
return {
|
|
10072
|
+
fallbackMode: "provider-error",
|
|
10073
|
+
strategy: "prefer-cheapest",
|
|
10074
|
+
...options
|
|
10075
|
+
};
|
|
10076
|
+
case "latency-first":
|
|
10077
|
+
return {
|
|
10078
|
+
fallbackMode: "provider-error",
|
|
10079
|
+
strategy: "prefer-fastest",
|
|
10080
|
+
...options
|
|
10081
|
+
};
|
|
10082
|
+
case "quality-first":
|
|
10083
|
+
return {
|
|
10084
|
+
fallbackMode: "provider-error",
|
|
10085
|
+
strategy: "quality-first",
|
|
10086
|
+
...options
|
|
10087
|
+
};
|
|
10088
|
+
}
|
|
10089
|
+
};
|
|
7088
10090
|
var OUTPUT_SCHEMA = {
|
|
7089
10091
|
additionalProperties: false,
|
|
7090
10092
|
properties: {
|
|
@@ -7152,10 +10154,15 @@ var OUTPUT_SCHEMA = {
|
|
|
7152
10154
|
},
|
|
7153
10155
|
type: "object"
|
|
7154
10156
|
};
|
|
7155
|
-
var ROUTE_RESULT_INSTRUCTION = "Return a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools.";
|
|
10157
|
+
var ROUTE_RESULT_INSTRUCTION = "Return only a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools. Only set transfer, escalate, voicemail, or noAnswer when the user explicitly asks for that lifecycle outcome or a tool result says that exact outcome. Do not infer voicemail from generic words like voice, voice app, or voice integration.";
|
|
10158
|
+
var stripJSONCodeFence = (value) => {
|
|
10159
|
+
const trimmed = value.trim();
|
|
10160
|
+
const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
10161
|
+
return match?.[1]?.trim() ?? value;
|
|
10162
|
+
};
|
|
7156
10163
|
var parseJSON = (value) => {
|
|
7157
10164
|
try {
|
|
7158
|
-
const parsed = JSON.parse(value);
|
|
10165
|
+
const parsed = JSON.parse(stripJSONCodeFence(value));
|
|
7159
10166
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
7160
10167
|
} catch {
|
|
7161
10168
|
return {
|
|
@@ -7170,11 +10177,27 @@ var parseJSONValue = (value) => {
|
|
|
7170
10177
|
return value;
|
|
7171
10178
|
}
|
|
7172
10179
|
};
|
|
10180
|
+
|
|
10181
|
+
class VoiceProviderTimeoutError extends Error {
|
|
10182
|
+
provider;
|
|
10183
|
+
timeoutMs;
|
|
10184
|
+
constructor(provider, timeoutMs) {
|
|
10185
|
+
super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
|
|
10186
|
+
this.name = "VoiceProviderTimeoutError";
|
|
10187
|
+
this.provider = provider;
|
|
10188
|
+
this.timeoutMs = timeoutMs;
|
|
10189
|
+
}
|
|
10190
|
+
}
|
|
7173
10191
|
var getMessageToolCalls = (message) => {
|
|
7174
10192
|
const toolCalls = message.metadata?.toolCalls;
|
|
7175
10193
|
return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
|
|
7176
10194
|
};
|
|
7177
10195
|
var createHTTPError = (provider, response) => new Error(`${provider} voice assistant model failed: HTTP ${response.status}`);
|
|
10196
|
+
var sleep4 = (ms) => new Promise((resolve2) => {
|
|
10197
|
+
setTimeout(resolve2, ms);
|
|
10198
|
+
});
|
|
10199
|
+
var errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
10200
|
+
var defaultIsRateLimitError = (error) => /(\b429\b|rate limit|quota|too many requests)/i.test(errorMessage(error));
|
|
7178
10201
|
var normalizeRouteOutput = (output) => {
|
|
7179
10202
|
const result = {};
|
|
7180
10203
|
if (typeof output.assistantText === "string") {
|
|
@@ -7228,19 +10251,272 @@ var createJSONVoiceAssistantModel = (options) => ({
|
|
|
7228
10251
|
return options.mapOutput?.(output) ?? normalizeRouteOutput(output);
|
|
7229
10252
|
}
|
|
7230
10253
|
});
|
|
7231
|
-
var
|
|
7232
|
-
|
|
10254
|
+
var createVoiceProviderRouter = (options) => {
|
|
10255
|
+
const providerIds = Object.keys(options.providers);
|
|
10256
|
+
const firstProvider = providerIds[0];
|
|
10257
|
+
const policy = typeof options.policy === "string" ? options.policy === "balanced" || options.policy === "cost-cap" || options.policy === "cost-first" || options.policy === "latency-first" || options.policy === "quality-first" ? resolveVoiceProviderRoutingPolicyPreset(options.policy) : {
|
|
10258
|
+
strategy: options.policy
|
|
10259
|
+
} : options.policy;
|
|
10260
|
+
const strategy = policy?.strategy ?? "prefer-selected";
|
|
10261
|
+
const fallbackMode = policy?.fallbackMode ?? options.fallbackMode ?? "provider-error";
|
|
10262
|
+
const healthOptions = typeof options.providerHealth === "object" ? options.providerHealth : options.providerHealth ? {} : undefined;
|
|
10263
|
+
const healthState = new Map;
|
|
10264
|
+
const now = () => healthOptions?.now?.() ?? Date.now();
|
|
10265
|
+
const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
|
|
10266
|
+
const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
|
|
10267
|
+
const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
|
|
10268
|
+
const getProviderTimeoutMs = (provider) => {
|
|
10269
|
+
const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
|
|
10270
|
+
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
|
|
10271
|
+
};
|
|
10272
|
+
const getHealth = (provider) => {
|
|
10273
|
+
const existing = healthState.get(provider);
|
|
10274
|
+
if (existing) {
|
|
10275
|
+
return existing;
|
|
10276
|
+
}
|
|
10277
|
+
const next = {
|
|
10278
|
+
consecutiveFailures: 0,
|
|
10279
|
+
provider,
|
|
10280
|
+
status: "healthy"
|
|
10281
|
+
};
|
|
10282
|
+
healthState.set(provider, next);
|
|
10283
|
+
return next;
|
|
10284
|
+
};
|
|
10285
|
+
const cloneHealth = (provider) => {
|
|
10286
|
+
if (!healthOptions) {
|
|
10287
|
+
return;
|
|
10288
|
+
}
|
|
7233
10289
|
return {
|
|
7234
|
-
|
|
7235
|
-
output: message.content,
|
|
7236
|
-
type: "function_call_output"
|
|
10290
|
+
...getHealth(provider)
|
|
7237
10291
|
};
|
|
7238
|
-
}
|
|
10292
|
+
};
|
|
10293
|
+
const getSuppressionRemainingMs = (provider) => {
|
|
10294
|
+
if (!healthOptions) {
|
|
10295
|
+
return;
|
|
10296
|
+
}
|
|
10297
|
+
const suppressedUntil = getHealth(provider).suppressedUntil;
|
|
10298
|
+
return typeof suppressedUntil === "number" ? Math.max(0, suppressedUntil - now()) : undefined;
|
|
10299
|
+
};
|
|
10300
|
+
const isSuppressed = (provider) => {
|
|
10301
|
+
if (!healthOptions) {
|
|
10302
|
+
return false;
|
|
10303
|
+
}
|
|
10304
|
+
const health = getHealth(provider);
|
|
10305
|
+
return typeof health.suppressedUntil === "number" && health.suppressedUntil > now();
|
|
10306
|
+
};
|
|
10307
|
+
const recordProviderSuccess = (provider) => {
|
|
10308
|
+
if (!healthOptions) {
|
|
10309
|
+
return;
|
|
10310
|
+
}
|
|
10311
|
+
const health = getHealth(provider);
|
|
10312
|
+
health.consecutiveFailures = 0;
|
|
10313
|
+
health.status = "healthy";
|
|
10314
|
+
health.suppressedUntil = undefined;
|
|
10315
|
+
return cloneHealth(provider);
|
|
10316
|
+
};
|
|
10317
|
+
const recordProviderError = (provider, isProviderError, rateLimited) => {
|
|
10318
|
+
if (!healthOptions || !isProviderError) {
|
|
10319
|
+
return cloneHealth(provider);
|
|
10320
|
+
}
|
|
10321
|
+
const currentTime = now();
|
|
10322
|
+
const health = getHealth(provider);
|
|
10323
|
+
health.consecutiveFailures += 1;
|
|
10324
|
+
health.lastFailureAt = currentTime;
|
|
10325
|
+
if (rateLimited) {
|
|
10326
|
+
health.lastRateLimitedAt = currentTime;
|
|
10327
|
+
}
|
|
10328
|
+
if (rateLimited || health.consecutiveFailures >= failureThreshold) {
|
|
10329
|
+
health.status = "suppressed";
|
|
10330
|
+
health.suppressedUntil = currentTime + (rateLimited ? rateLimitCooldownMs : cooldownMs);
|
|
10331
|
+
}
|
|
10332
|
+
return cloneHealth(provider);
|
|
10333
|
+
};
|
|
10334
|
+
const resolveAllowedProviders = async (input) => {
|
|
10335
|
+
const allowProviders = policy?.allowProviders ?? options.allowProviders;
|
|
10336
|
+
const allowed = typeof allowProviders === "function" ? await allowProviders(input) : allowProviders;
|
|
10337
|
+
return new Set(allowed ?? providerIds);
|
|
10338
|
+
};
|
|
10339
|
+
const passesBudgetFilters = (provider) => {
|
|
10340
|
+
const profile = options.providerProfiles?.[provider];
|
|
10341
|
+
if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
|
|
10342
|
+
return false;
|
|
10343
|
+
}
|
|
10344
|
+
if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
|
|
10345
|
+
return false;
|
|
10346
|
+
}
|
|
10347
|
+
if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
|
|
10348
|
+
return false;
|
|
10349
|
+
}
|
|
10350
|
+
return true;
|
|
10351
|
+
};
|
|
10352
|
+
const getBalancedScore = (provider) => {
|
|
10353
|
+
const profile = options.providerProfiles?.[provider];
|
|
10354
|
+
if (policy?.scoreProvider) {
|
|
10355
|
+
return policy.scoreProvider(provider, profile);
|
|
10356
|
+
}
|
|
10357
|
+
const weights = policy?.weights ?? {};
|
|
10358
|
+
return (profile?.cost ?? Number.MAX_SAFE_INTEGER) * (weights.cost ?? 1) + (profile?.latencyMs ?? Number.MAX_SAFE_INTEGER) * (weights.latencyMs ?? 0.005) + (profile?.priority ?? 0) * (weights.priority ?? 1) - (profile?.quality ?? 0) * (weights.quality ?? 10);
|
|
10359
|
+
};
|
|
10360
|
+
const sortProviders = (providers) => {
|
|
10361
|
+
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
|
|
10362
|
+
return providers;
|
|
10363
|
+
}
|
|
10364
|
+
return [...providers].sort((left, right) => {
|
|
10365
|
+
const leftProfile = options.providerProfiles?.[left];
|
|
10366
|
+
const rightProfile = options.providerProfiles?.[right];
|
|
10367
|
+
if (strategy === "quality-first") {
|
|
10368
|
+
return (rightProfile?.quality ?? Number.MIN_SAFE_INTEGER) - (leftProfile?.quality ?? Number.MIN_SAFE_INTEGER) || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.cost ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.cost ?? Number.MAX_SAFE_INTEGER);
|
|
10369
|
+
}
|
|
10370
|
+
if (strategy === "balanced") {
|
|
10371
|
+
return getBalancedScore(left) - getBalancedScore(right);
|
|
10372
|
+
}
|
|
10373
|
+
const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
10374
|
+
const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
10375
|
+
return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
|
|
10376
|
+
});
|
|
10377
|
+
};
|
|
10378
|
+
const resolveOrder = async (input) => {
|
|
10379
|
+
const selectedProvider = await options.selectProvider?.(input);
|
|
10380
|
+
const allowedProviders = await resolveAllowedProviders(input);
|
|
10381
|
+
const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
|
|
10382
|
+
const allowedRankedProviders = sortProviders([
|
|
10383
|
+
...fallbackOrder ?? providerIds
|
|
10384
|
+
]).filter((provider) => allowedProviders.has(provider));
|
|
10385
|
+
const rankedProviders = allowedRankedProviders.filter(passesBudgetFilters);
|
|
10386
|
+
const healthyRankedProviders = healthOptions ? rankedProviders.filter((provider) => !isSuppressed(provider)) : rankedProviders;
|
|
10387
|
+
const candidateRankedProviders = healthyRankedProviders.length ? healthyRankedProviders : rankedProviders;
|
|
10388
|
+
const preferred = selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : candidateRankedProviders[0] ?? firstProvider;
|
|
10389
|
+
const seen = new Set;
|
|
10390
|
+
const order = [];
|
|
10391
|
+
const candidates = strategy === "ordered" ? candidateRankedProviders : [
|
|
10392
|
+
preferred,
|
|
10393
|
+
...candidateRankedProviders,
|
|
10394
|
+
...providerIds.filter((provider) => !healthOptions || !isSuppressed(provider))
|
|
10395
|
+
];
|
|
10396
|
+
for (const provider of candidates) {
|
|
10397
|
+
if (!provider || seen.has(provider) || !allowedProviders.has(provider) || !options.providers[provider]) {
|
|
10398
|
+
continue;
|
|
10399
|
+
}
|
|
10400
|
+
seen.add(provider);
|
|
10401
|
+
order.push(provider);
|
|
10402
|
+
}
|
|
10403
|
+
return {
|
|
10404
|
+
order,
|
|
10405
|
+
selectedProvider: preferred
|
|
10406
|
+
};
|
|
10407
|
+
};
|
|
10408
|
+
const emit = async (event, input) => {
|
|
10409
|
+
await options.onProviderEvent?.(event, input);
|
|
10410
|
+
};
|
|
10411
|
+
const runProvider = async (provider, model, input) => {
|
|
10412
|
+
const timeoutMs = getProviderTimeoutMs(provider);
|
|
10413
|
+
if (!timeoutMs) {
|
|
10414
|
+
return model.generate(input);
|
|
10415
|
+
}
|
|
10416
|
+
let timeout;
|
|
10417
|
+
try {
|
|
10418
|
+
return await Promise.race([
|
|
10419
|
+
model.generate(input),
|
|
10420
|
+
new Promise((_, reject) => {
|
|
10421
|
+
timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
|
|
10422
|
+
})
|
|
10423
|
+
]);
|
|
10424
|
+
} finally {
|
|
10425
|
+
if (timeout) {
|
|
10426
|
+
clearTimeout(timeout);
|
|
10427
|
+
}
|
|
10428
|
+
}
|
|
10429
|
+
};
|
|
7239
10430
|
return {
|
|
7240
|
-
|
|
7241
|
-
|
|
10431
|
+
generate: async (input) => {
|
|
10432
|
+
const { order, selectedProvider } = await resolveOrder(input);
|
|
10433
|
+
if (!selectedProvider || order.length === 0) {
|
|
10434
|
+
throw new Error("Voice provider router has no available providers.");
|
|
10435
|
+
}
|
|
10436
|
+
let lastError;
|
|
10437
|
+
for (const [index, provider] of order.entries()) {
|
|
10438
|
+
const model = options.providers[provider];
|
|
10439
|
+
if (!model) {
|
|
10440
|
+
continue;
|
|
10441
|
+
}
|
|
10442
|
+
const startedAt = Date.now();
|
|
10443
|
+
try {
|
|
10444
|
+
const output = await runProvider(provider, model, input);
|
|
10445
|
+
const providerHealth = recordProviderSuccess(provider);
|
|
10446
|
+
await emit({
|
|
10447
|
+
at: Date.now(),
|
|
10448
|
+
attempt: index + 1,
|
|
10449
|
+
elapsedMs: Date.now() - startedAt,
|
|
10450
|
+
fallbackProvider: provider === selectedProvider ? undefined : provider,
|
|
10451
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
10452
|
+
provider,
|
|
10453
|
+
providerHealth,
|
|
10454
|
+
recovered: provider !== selectedProvider,
|
|
10455
|
+
selectedProvider,
|
|
10456
|
+
status: provider === selectedProvider ? "success" : "fallback"
|
|
10457
|
+
}, input);
|
|
10458
|
+
return output;
|
|
10459
|
+
} catch (error) {
|
|
10460
|
+
lastError = error;
|
|
10461
|
+
const hasNextProvider = index < order.length - 1;
|
|
10462
|
+
const isProviderError = options.isProviderError?.(error, provider) ?? true;
|
|
10463
|
+
const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
|
|
10464
|
+
const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
|
|
10465
|
+
const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
|
|
10466
|
+
const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
|
|
10467
|
+
const nextProvider = hasNextProvider ? order[index + 1] : undefined;
|
|
10468
|
+
await emit({
|
|
10469
|
+
at: Date.now(),
|
|
10470
|
+
attempt: index + 1,
|
|
10471
|
+
elapsedMs: Date.now() - startedAt,
|
|
10472
|
+
error: errorMessage(error),
|
|
10473
|
+
fallbackProvider: shouldFallback ? nextProvider : undefined,
|
|
10474
|
+
latencyBudgetMs: getProviderTimeoutMs(provider),
|
|
10475
|
+
provider,
|
|
10476
|
+
providerHealth,
|
|
10477
|
+
rateLimited,
|
|
10478
|
+
selectedProvider,
|
|
10479
|
+
suppressionRemainingMs: getSuppressionRemainingMs(provider),
|
|
10480
|
+
suppressedUntil: providerHealth?.suppressedUntil,
|
|
10481
|
+
status: "error",
|
|
10482
|
+
timedOut
|
|
10483
|
+
}, input);
|
|
10484
|
+
if (!hasNextProvider || !shouldFallback) {
|
|
10485
|
+
throw error;
|
|
10486
|
+
}
|
|
10487
|
+
}
|
|
10488
|
+
}
|
|
10489
|
+
throw lastError ?? new Error("Voice provider router did not run a provider.");
|
|
10490
|
+
}
|
|
7242
10491
|
};
|
|
7243
10492
|
};
|
|
10493
|
+
var messageToOpenAIInput = (message) => {
|
|
10494
|
+
if (message.role === "tool") {
|
|
10495
|
+
return [
|
|
10496
|
+
{
|
|
10497
|
+
call_id: message.toolCallId ?? message.name ?? crypto.randomUUID(),
|
|
10498
|
+
output: message.content,
|
|
10499
|
+
type: "function_call_output"
|
|
10500
|
+
}
|
|
10501
|
+
];
|
|
10502
|
+
}
|
|
10503
|
+
const toolCalls = getMessageToolCalls(message);
|
|
10504
|
+
if (message.role === "assistant" && toolCalls.length) {
|
|
10505
|
+
return toolCalls.map((toolCall) => ({
|
|
10506
|
+
arguments: JSON.stringify(toolCall.args),
|
|
10507
|
+
call_id: toolCall.id ?? crypto.randomUUID(),
|
|
10508
|
+
name: toolCall.name,
|
|
10509
|
+
type: "function_call"
|
|
10510
|
+
}));
|
|
10511
|
+
}
|
|
10512
|
+
return [
|
|
10513
|
+
{
|
|
10514
|
+
content: message.content,
|
|
10515
|
+
role: message.role === "system" ? "developer" : message.role
|
|
10516
|
+
}
|
|
10517
|
+
];
|
|
10518
|
+
};
|
|
10519
|
+
var messagesToOpenAIInput = (messages) => messages.flatMap(messageToOpenAIInput);
|
|
7244
10520
|
var messageToAnthropicMessage = (message) => {
|
|
7245
10521
|
if (message.role === "system") {
|
|
7246
10522
|
return;
|
|
@@ -7410,7 +10686,7 @@ var createOpenAIVoiceAssistantModel = (options) => {
|
|
|
7410
10686
|
generate: async (input) => {
|
|
7411
10687
|
const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, {
|
|
7412
10688
|
body: JSON.stringify({
|
|
7413
|
-
input: input.messages
|
|
10689
|
+
input: messagesToOpenAIInput(input.messages),
|
|
7414
10690
|
instructions: [
|
|
7415
10691
|
input.system,
|
|
7416
10692
|
"Return a JSON object with assistantText, complete, transfer, escalate, voicemail, noAnswer, and result when you are not calling tools."
|
|
@@ -7574,64 +10850,492 @@ var extractGeminiToolCalls = (response) => {
|
|
|
7574
10850
|
}
|
|
7575
10851
|
return toolCalls;
|
|
7576
10852
|
};
|
|
7577
|
-
var createGeminiVoiceAssistantModel = (options) => {
|
|
7578
|
-
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
7579
|
-
const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
|
|
7580
|
-
const model = options.model ?? "gemini-2.5-flash";
|
|
10853
|
+
var createGeminiVoiceAssistantModel = (options) => {
|
|
10854
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
10855
|
+
const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
|
|
10856
|
+
const model = options.model ?? "gemini-2.5-flash";
|
|
10857
|
+
const maxRetries = Math.max(0, options.maxRetries ?? 2);
|
|
10858
|
+
return {
|
|
10859
|
+
generate: async (input) => {
|
|
10860
|
+
const endpoint = `${baseUrl.replace(/\/$/, "")}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
|
|
10861
|
+
let response;
|
|
10862
|
+
for (let attempt = 0;attempt <= maxRetries; attempt += 1) {
|
|
10863
|
+
response = await fetchImpl(endpoint, {
|
|
10864
|
+
body: JSON.stringify({
|
|
10865
|
+
contents: input.messages.map(messageToGeminiContent).filter(Boolean),
|
|
10866
|
+
generationConfig: {
|
|
10867
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
10868
|
+
...input.tools.length ? {} : {
|
|
10869
|
+
responseMimeType: "application/json",
|
|
10870
|
+
responseSchema: toGeminiSchema(OUTPUT_SCHEMA)
|
|
10871
|
+
},
|
|
10872
|
+
temperature: options.temperature
|
|
10873
|
+
},
|
|
10874
|
+
systemInstruction: {
|
|
10875
|
+
parts: [
|
|
10876
|
+
{
|
|
10877
|
+
text: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
|
|
10878
|
+
|
|
10879
|
+
`)
|
|
10880
|
+
}
|
|
10881
|
+
]
|
|
10882
|
+
},
|
|
10883
|
+
tools: input.tools.length ? [
|
|
10884
|
+
{
|
|
10885
|
+
functionDeclarations: input.tools.map((tool) => ({
|
|
10886
|
+
description: tool.description,
|
|
10887
|
+
name: tool.name,
|
|
10888
|
+
parameters: toGeminiSchema(tool.parameters ?? {
|
|
10889
|
+
additionalProperties: true,
|
|
10890
|
+
type: "object"
|
|
10891
|
+
})
|
|
10892
|
+
}))
|
|
10893
|
+
}
|
|
10894
|
+
] : undefined
|
|
10895
|
+
}),
|
|
10896
|
+
headers: {
|
|
10897
|
+
"content-type": "application/json"
|
|
10898
|
+
},
|
|
10899
|
+
method: "POST"
|
|
10900
|
+
});
|
|
10901
|
+
if (response.ok || response.status !== 429 && response.status < 500 || attempt === maxRetries) {
|
|
10902
|
+
break;
|
|
10903
|
+
}
|
|
10904
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
10905
|
+
await sleep4(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
|
|
10906
|
+
}
|
|
10907
|
+
if (!response) {
|
|
10908
|
+
throw new Error("Gemini voice assistant model failed: no response");
|
|
10909
|
+
}
|
|
10910
|
+
if (!response.ok) {
|
|
10911
|
+
throw createHTTPError("Gemini", response);
|
|
10912
|
+
}
|
|
10913
|
+
const body = await response.json();
|
|
10914
|
+
if (body.usageMetadata && typeof body.usageMetadata === "object") {
|
|
10915
|
+
await options.onUsage?.(body.usageMetadata);
|
|
10916
|
+
}
|
|
10917
|
+
const toolCalls = extractGeminiToolCalls(body);
|
|
10918
|
+
if (toolCalls.length) {
|
|
10919
|
+
return {
|
|
10920
|
+
assistantText: extractGeminiText(body) || undefined,
|
|
10921
|
+
toolCalls
|
|
10922
|
+
};
|
|
10923
|
+
}
|
|
10924
|
+
return normalizeRouteOutput(parseJSON(extractGeminiText(body)));
|
|
10925
|
+
}
|
|
10926
|
+
};
|
|
10927
|
+
};
|
|
10928
|
+
// src/providerAdapters.ts
|
|
10929
|
+
class VoiceIOProviderTimeoutError extends Error {
|
|
10930
|
+
provider;
|
|
10931
|
+
timeoutMs;
|
|
10932
|
+
constructor(kind, provider, timeoutMs) {
|
|
10933
|
+
super(`Voice ${kind} provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
|
|
10934
|
+
this.name = "VoiceIOProviderTimeoutError";
|
|
10935
|
+
this.provider = provider;
|
|
10936
|
+
this.timeoutMs = timeoutMs;
|
|
10937
|
+
}
|
|
10938
|
+
}
|
|
10939
|
+
var errorMessage2 = (error) => error instanceof Error ? error.message : String(error);
|
|
10940
|
+
var createEmitter = () => {
|
|
10941
|
+
const listeners = new Map;
|
|
10942
|
+
return {
|
|
10943
|
+
emit: async (event, payload) => {
|
|
10944
|
+
await Promise.all([...listeners.get(event) ?? []].map((handler) => Promise.resolve(handler(payload))));
|
|
10945
|
+
},
|
|
10946
|
+
on: (event, handler) => {
|
|
10947
|
+
const set = listeners.get(event) ?? new Set;
|
|
10948
|
+
set.add(handler);
|
|
10949
|
+
listeners.set(event, set);
|
|
10950
|
+
return () => {
|
|
10951
|
+
set.delete(handler);
|
|
10952
|
+
};
|
|
10953
|
+
}
|
|
10954
|
+
};
|
|
10955
|
+
};
|
|
10956
|
+
var getTimeoutMs = (options, provider) => {
|
|
10957
|
+
const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
|
|
10958
|
+
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
|
|
10959
|
+
};
|
|
10960
|
+
var withTimeout = async (input) => {
|
|
10961
|
+
if (!input.timeoutMs) {
|
|
10962
|
+
return input.run();
|
|
10963
|
+
}
|
|
10964
|
+
let timeout;
|
|
10965
|
+
try {
|
|
10966
|
+
return await Promise.race([
|
|
10967
|
+
Promise.resolve(input.run()),
|
|
10968
|
+
new Promise((_, reject) => {
|
|
10969
|
+
timeout = setTimeout(() => reject(new VoiceIOProviderTimeoutError(input.kind, input.provider, input.timeoutMs)), input.timeoutMs);
|
|
10970
|
+
})
|
|
10971
|
+
]);
|
|
10972
|
+
} finally {
|
|
10973
|
+
if (timeout) {
|
|
10974
|
+
clearTimeout(timeout);
|
|
10975
|
+
}
|
|
10976
|
+
}
|
|
10977
|
+
};
|
|
10978
|
+
var isVoiceProviderRoutingPolicyPreset = (policy) => policy === "balanced" || policy === "cost-cap" || policy === "cost-first" || policy === "latency-first" || policy === "quality-first";
|
|
10979
|
+
var createResolver = (options) => {
|
|
10980
|
+
const providerIds = Object.keys(options.adapters);
|
|
10981
|
+
const firstProvider = providerIds[0];
|
|
10982
|
+
const policy = typeof options.policy === "string" ? isVoiceProviderRoutingPolicyPreset(options.policy) ? resolveVoiceProviderRoutingPolicyPreset(options.policy) : {
|
|
10983
|
+
strategy: options.policy
|
|
10984
|
+
} : options.policy;
|
|
10985
|
+
const strategy = policy?.strategy ?? "prefer-selected";
|
|
10986
|
+
const healthOptions = typeof options.providerHealth === "object" ? options.providerHealth : options.providerHealth ? {} : undefined;
|
|
10987
|
+
const healthState = new Map;
|
|
10988
|
+
const now = () => healthOptions?.now?.() ?? Date.now();
|
|
10989
|
+
const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
|
|
10990
|
+
const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
|
|
10991
|
+
const getHealth = (provider) => {
|
|
10992
|
+
const existing = healthState.get(provider);
|
|
10993
|
+
if (existing) {
|
|
10994
|
+
return existing;
|
|
10995
|
+
}
|
|
10996
|
+
const next = {
|
|
10997
|
+
consecutiveFailures: 0,
|
|
10998
|
+
provider,
|
|
10999
|
+
status: "healthy"
|
|
11000
|
+
};
|
|
11001
|
+
healthState.set(provider, next);
|
|
11002
|
+
return next;
|
|
11003
|
+
};
|
|
11004
|
+
const cloneHealth = (provider) => {
|
|
11005
|
+
if (!healthOptions) {
|
|
11006
|
+
return;
|
|
11007
|
+
}
|
|
11008
|
+
return {
|
|
11009
|
+
...getHealth(provider)
|
|
11010
|
+
};
|
|
11011
|
+
};
|
|
11012
|
+
const getSuppressionRemainingMs = (provider) => {
|
|
11013
|
+
if (!healthOptions) {
|
|
11014
|
+
return;
|
|
11015
|
+
}
|
|
11016
|
+
const suppressedUntil = getHealth(provider).suppressedUntil;
|
|
11017
|
+
return typeof suppressedUntil === "number" ? Math.max(0, suppressedUntil - now()) : undefined;
|
|
11018
|
+
};
|
|
11019
|
+
const isSuppressed = (provider) => {
|
|
11020
|
+
if (!healthOptions) {
|
|
11021
|
+
return false;
|
|
11022
|
+
}
|
|
11023
|
+
const suppressedUntil = getHealth(provider).suppressedUntil;
|
|
11024
|
+
return typeof suppressedUntil === "number" && suppressedUntil > now();
|
|
11025
|
+
};
|
|
11026
|
+
const recordSuccess = (provider) => {
|
|
11027
|
+
if (!healthOptions) {
|
|
11028
|
+
return;
|
|
11029
|
+
}
|
|
11030
|
+
const health = getHealth(provider);
|
|
11031
|
+
health.consecutiveFailures = 0;
|
|
11032
|
+
health.status = "healthy";
|
|
11033
|
+
health.suppressedUntil = undefined;
|
|
11034
|
+
return cloneHealth(provider);
|
|
11035
|
+
};
|
|
11036
|
+
const recordError = (provider, isProviderError) => {
|
|
11037
|
+
if (!healthOptions || !isProviderError) {
|
|
11038
|
+
return cloneHealth(provider);
|
|
11039
|
+
}
|
|
11040
|
+
const health = getHealth(provider);
|
|
11041
|
+
health.consecutiveFailures += 1;
|
|
11042
|
+
health.lastFailureAt = now();
|
|
11043
|
+
if (health.consecutiveFailures >= failureThreshold) {
|
|
11044
|
+
health.status = "suppressed";
|
|
11045
|
+
health.suppressedUntil = now() + cooldownMs;
|
|
11046
|
+
}
|
|
11047
|
+
return cloneHealth(provider);
|
|
11048
|
+
};
|
|
11049
|
+
const resolveAllowedProviders = async (input) => {
|
|
11050
|
+
const allowed = typeof policy?.allowProviders === "function" ? await policy.allowProviders(input) : policy?.allowProviders;
|
|
11051
|
+
return new Set(allowed ?? providerIds);
|
|
11052
|
+
};
|
|
11053
|
+
const passesBudgetFilters = (provider) => {
|
|
11054
|
+
const profile = options.providerProfiles?.[provider];
|
|
11055
|
+
if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
|
|
11056
|
+
return false;
|
|
11057
|
+
}
|
|
11058
|
+
if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
|
|
11059
|
+
return false;
|
|
11060
|
+
}
|
|
11061
|
+
if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
|
|
11062
|
+
return false;
|
|
11063
|
+
}
|
|
11064
|
+
return true;
|
|
11065
|
+
};
|
|
11066
|
+
const getBalancedScore = (provider) => {
|
|
11067
|
+
const profile = options.providerProfiles?.[provider];
|
|
11068
|
+
if (policy?.scoreProvider) {
|
|
11069
|
+
return policy.scoreProvider(provider, profile);
|
|
11070
|
+
}
|
|
11071
|
+
const weights = policy?.weights ?? {};
|
|
11072
|
+
return (profile?.cost ?? Number.MAX_SAFE_INTEGER) * (weights.cost ?? 1) + (profile?.latencyMs ?? Number.MAX_SAFE_INTEGER) * (weights.latencyMs ?? 0.005) + (profile?.priority ?? 0) * (weights.priority ?? 1) - (profile?.quality ?? 0) * (weights.quality ?? 10);
|
|
11073
|
+
};
|
|
11074
|
+
const sortProviders = (providers) => {
|
|
11075
|
+
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
|
|
11076
|
+
return providers;
|
|
11077
|
+
}
|
|
11078
|
+
return [...providers].sort((left, right) => {
|
|
11079
|
+
const leftProfile = options.providerProfiles?.[left];
|
|
11080
|
+
const rightProfile = options.providerProfiles?.[right];
|
|
11081
|
+
if (strategy === "quality-first") {
|
|
11082
|
+
return (rightProfile?.quality ?? Number.MIN_SAFE_INTEGER) - (leftProfile?.quality ?? Number.MIN_SAFE_INTEGER) || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.cost ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.cost ?? Number.MAX_SAFE_INTEGER);
|
|
11083
|
+
}
|
|
11084
|
+
if (strategy === "balanced") {
|
|
11085
|
+
return getBalancedScore(left) - getBalancedScore(right);
|
|
11086
|
+
}
|
|
11087
|
+
const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
11088
|
+
const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
11089
|
+
return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
|
|
11090
|
+
});
|
|
11091
|
+
};
|
|
11092
|
+
const resolveOrder = async (input) => {
|
|
11093
|
+
const requestedProvider = await options.selectProvider?.(input);
|
|
11094
|
+
const selectedProvider = requestedProvider ?? firstProvider;
|
|
11095
|
+
const allowedProviders = await resolveAllowedProviders(input);
|
|
11096
|
+
const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
|
|
11097
|
+
const candidates = [selectedProvider, ...fallbackOrder ?? providerIds];
|
|
11098
|
+
const seen = new Set;
|
|
11099
|
+
const orderedCandidates = candidates.filter((provider) => {
|
|
11100
|
+
if (!provider || seen.has(provider) || !options.adapters[provider]) {
|
|
11101
|
+
return false;
|
|
11102
|
+
}
|
|
11103
|
+
seen.add(provider);
|
|
11104
|
+
return true;
|
|
11105
|
+
});
|
|
11106
|
+
const rankedOrder = sortProviders(orderedCandidates).filter((provider) => allowedProviders.has(provider)).filter(passesBudgetFilters);
|
|
11107
|
+
const healthyOrder = healthOptions ? rankedOrder.filter((provider) => !isSuppressed(provider)) : rankedOrder;
|
|
11108
|
+
const order = healthyOrder.length ? healthyOrder : rankedOrder;
|
|
11109
|
+
const preferred = strategy === "prefer-selected" && selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : order[0];
|
|
11110
|
+
return {
|
|
11111
|
+
order,
|
|
11112
|
+
selectedProvider: preferred
|
|
11113
|
+
};
|
|
11114
|
+
};
|
|
11115
|
+
const emit = async (event, input) => {
|
|
11116
|
+
await options.onProviderEvent?.(event, input);
|
|
11117
|
+
};
|
|
11118
|
+
return {
|
|
11119
|
+
emit,
|
|
11120
|
+
getSuppressionRemainingMs,
|
|
11121
|
+
providerIds,
|
|
11122
|
+
recordError,
|
|
11123
|
+
recordSuccess,
|
|
11124
|
+
resolveOrder
|
|
11125
|
+
};
|
|
11126
|
+
};
|
|
11127
|
+
var createVoiceSTTProviderRouter = (options) => {
|
|
11128
|
+
const resolver = createResolver(options);
|
|
7581
11129
|
return {
|
|
7582
|
-
|
|
7583
|
-
|
|
7584
|
-
const
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
|
|
7605
|
-
|
|
7606
|
-
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
11130
|
+
kind: "stt",
|
|
11131
|
+
open: async (input) => {
|
|
11132
|
+
const { order, selectedProvider } = await resolver.resolveOrder(input);
|
|
11133
|
+
if (!selectedProvider || order.length === 0) {
|
|
11134
|
+
throw new Error("Voice STT provider router has no available providers.");
|
|
11135
|
+
}
|
|
11136
|
+
let lastError;
|
|
11137
|
+
for (const [index, provider] of order.entries()) {
|
|
11138
|
+
const adapter = options.adapters[provider];
|
|
11139
|
+
if (!adapter) {
|
|
11140
|
+
continue;
|
|
11141
|
+
}
|
|
11142
|
+
const startedAt = Date.now();
|
|
11143
|
+
try {
|
|
11144
|
+
const session = await withTimeout({
|
|
11145
|
+
kind: "stt",
|
|
11146
|
+
operation: "open",
|
|
11147
|
+
provider,
|
|
11148
|
+
run: () => adapter.open(input),
|
|
11149
|
+
timeoutMs: getTimeoutMs(options, provider)
|
|
11150
|
+
});
|
|
11151
|
+
const providerHealth = resolver.recordSuccess(provider);
|
|
11152
|
+
await resolver.emit({
|
|
11153
|
+
at: Date.now(),
|
|
11154
|
+
attempt: index + 1,
|
|
11155
|
+
elapsedMs: Date.now() - startedAt,
|
|
11156
|
+
fallbackProvider: provider === selectedProvider ? undefined : provider,
|
|
11157
|
+
kind: "stt",
|
|
11158
|
+
latencyBudgetMs: getTimeoutMs(options, provider),
|
|
11159
|
+
operation: "open",
|
|
11160
|
+
provider,
|
|
11161
|
+
providerHealth,
|
|
11162
|
+
selectedProvider,
|
|
11163
|
+
status: provider === selectedProvider ? "success" : "fallback"
|
|
11164
|
+
}, input);
|
|
11165
|
+
return session;
|
|
11166
|
+
} catch (error) {
|
|
11167
|
+
lastError = error;
|
|
11168
|
+
const hasNextProvider = index < order.length - 1;
|
|
11169
|
+
const shouldFallback = options.isProviderError?.(error, provider) ?? true;
|
|
11170
|
+
const providerHealth = resolver.recordError(provider, shouldFallback);
|
|
11171
|
+
await resolver.emit({
|
|
11172
|
+
at: Date.now(),
|
|
11173
|
+
attempt: index + 1,
|
|
11174
|
+
elapsedMs: Date.now() - startedAt,
|
|
11175
|
+
error: errorMessage2(error),
|
|
11176
|
+
fallbackProvider: shouldFallback ? order[index + 1] : undefined,
|
|
11177
|
+
kind: "stt",
|
|
11178
|
+
latencyBudgetMs: getTimeoutMs(options, provider),
|
|
11179
|
+
operation: "open",
|
|
11180
|
+
provider,
|
|
11181
|
+
providerHealth,
|
|
11182
|
+
selectedProvider,
|
|
11183
|
+
status: "error",
|
|
11184
|
+
suppressionRemainingMs: resolver.getSuppressionRemainingMs(provider),
|
|
11185
|
+
suppressedUntil: providerHealth?.suppressedUntil,
|
|
11186
|
+
timedOut: error instanceof VoiceIOProviderTimeoutError
|
|
11187
|
+
}, input);
|
|
11188
|
+
if (!hasNextProvider || !shouldFallback) {
|
|
11189
|
+
throw error;
|
|
11190
|
+
}
|
|
11191
|
+
}
|
|
7622
11192
|
}
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
11193
|
+
throw lastError ?? new Error("Voice STT provider router did not open a provider.");
|
|
11194
|
+
}
|
|
11195
|
+
};
|
|
11196
|
+
};
|
|
11197
|
+
var createVoiceTTSProviderRouter = (options) => {
|
|
11198
|
+
const resolver = createResolver(options);
|
|
11199
|
+
return {
|
|
11200
|
+
kind: "tts",
|
|
11201
|
+
open: async (input) => {
|
|
11202
|
+
const { order, selectedProvider } = await resolver.resolveOrder(input);
|
|
11203
|
+
if (!selectedProvider || order.length === 0) {
|
|
11204
|
+
throw new Error("Voice TTS provider router has no available providers.");
|
|
11205
|
+
}
|
|
11206
|
+
const emitter = createEmitter();
|
|
11207
|
+
let activeSession;
|
|
11208
|
+
let activeProvider;
|
|
11209
|
+
let nextProviderIndex = 0;
|
|
11210
|
+
const attach = (session) => {
|
|
11211
|
+
session.on("audio", (event) => emitter.emit("audio", event));
|
|
11212
|
+
session.on("error", (event) => emitter.emit("error", event));
|
|
11213
|
+
session.on("close", (event) => emitter.emit("close", event));
|
|
11214
|
+
};
|
|
11215
|
+
const openProvider = async (provider, attempt) => {
|
|
11216
|
+
const adapter = options.adapters[provider];
|
|
11217
|
+
if (!adapter) {
|
|
11218
|
+
throw new Error(`Voice TTS provider ${provider} is not configured.`);
|
|
11219
|
+
}
|
|
11220
|
+
const startedAt = Date.now();
|
|
11221
|
+
const session = await withTimeout({
|
|
11222
|
+
kind: "tts",
|
|
11223
|
+
operation: "open",
|
|
11224
|
+
provider,
|
|
11225
|
+
run: () => adapter.open(input),
|
|
11226
|
+
timeoutMs: getTimeoutMs(options, provider)
|
|
11227
|
+
});
|
|
11228
|
+
attach(session);
|
|
11229
|
+
activeSession = session;
|
|
11230
|
+
activeProvider = provider;
|
|
11231
|
+
const providerHealth = resolver.recordSuccess(provider);
|
|
11232
|
+
await resolver.emit({
|
|
11233
|
+
at: Date.now(),
|
|
11234
|
+
attempt,
|
|
11235
|
+
elapsedMs: Date.now() - startedAt,
|
|
11236
|
+
fallbackProvider: provider === selectedProvider ? undefined : provider,
|
|
11237
|
+
kind: "tts",
|
|
11238
|
+
latencyBudgetMs: getTimeoutMs(options, provider),
|
|
11239
|
+
operation: "open",
|
|
11240
|
+
provider,
|
|
11241
|
+
providerHealth,
|
|
11242
|
+
selectedProvider,
|
|
11243
|
+
status: provider === selectedProvider ? "success" : "fallback"
|
|
11244
|
+
}, input);
|
|
11245
|
+
return session;
|
|
11246
|
+
};
|
|
11247
|
+
const failProvider = async (inputEvent) => {
|
|
11248
|
+
const shouldFallback = options.isProviderError?.(inputEvent.error, inputEvent.provider) ?? true;
|
|
11249
|
+
const providerHealth = resolver.recordError(inputEvent.provider, shouldFallback);
|
|
11250
|
+
await resolver.emit({
|
|
11251
|
+
at: Date.now(),
|
|
11252
|
+
attempt: inputEvent.attempt,
|
|
11253
|
+
elapsedMs: Date.now() - inputEvent.startedAt,
|
|
11254
|
+
error: errorMessage2(inputEvent.error),
|
|
11255
|
+
fallbackProvider: shouldFallback ? order[nextProviderIndex] : undefined,
|
|
11256
|
+
kind: "tts",
|
|
11257
|
+
latencyBudgetMs: getTimeoutMs(options, inputEvent.provider),
|
|
11258
|
+
operation: inputEvent.operation,
|
|
11259
|
+
provider: inputEvent.provider,
|
|
11260
|
+
providerHealth,
|
|
11261
|
+
selectedProvider,
|
|
11262
|
+
status: "error",
|
|
11263
|
+
suppressionRemainingMs: resolver.getSuppressionRemainingMs(inputEvent.provider),
|
|
11264
|
+
suppressedUntil: providerHealth?.suppressedUntil,
|
|
11265
|
+
timedOut: inputEvent.error instanceof VoiceIOProviderTimeoutError
|
|
11266
|
+
}, input);
|
|
11267
|
+
return shouldFallback;
|
|
11268
|
+
};
|
|
11269
|
+
for (const [index, provider] of order.entries()) {
|
|
11270
|
+
nextProviderIndex = index + 1;
|
|
11271
|
+
const startedAt = Date.now();
|
|
11272
|
+
try {
|
|
11273
|
+
await openProvider(provider, index + 1);
|
|
11274
|
+
break;
|
|
11275
|
+
} catch (error) {
|
|
11276
|
+
const shouldFallback = await failProvider({
|
|
11277
|
+
attempt: index + 1,
|
|
11278
|
+
error,
|
|
11279
|
+
operation: "open",
|
|
11280
|
+
provider,
|
|
11281
|
+
startedAt
|
|
11282
|
+
});
|
|
11283
|
+
if (!shouldFallback || index >= order.length - 1) {
|
|
11284
|
+
throw error;
|
|
11285
|
+
}
|
|
11286
|
+
}
|
|
7626
11287
|
}
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
return {
|
|
7630
|
-
assistantText: extractGeminiText(body) || undefined,
|
|
7631
|
-
toolCalls
|
|
7632
|
-
};
|
|
11288
|
+
if (!activeSession || !activeProvider) {
|
|
11289
|
+
throw new Error("Voice TTS provider router did not open a provider.");
|
|
7633
11290
|
}
|
|
7634
|
-
|
|
11291
|
+
const sendWithFallback = async (text) => {
|
|
11292
|
+
for (;; ) {
|
|
11293
|
+
const session = activeSession;
|
|
11294
|
+
const provider = activeProvider;
|
|
11295
|
+
if (!session || !provider) {
|
|
11296
|
+
throw new Error("Voice TTS provider router has no active provider.");
|
|
11297
|
+
}
|
|
11298
|
+
const startedAt = Date.now();
|
|
11299
|
+
try {
|
|
11300
|
+
await withTimeout({
|
|
11301
|
+
kind: "tts",
|
|
11302
|
+
operation: "send",
|
|
11303
|
+
provider,
|
|
11304
|
+
run: () => session.send(text),
|
|
11305
|
+
timeoutMs: getTimeoutMs(options, provider)
|
|
11306
|
+
});
|
|
11307
|
+
return;
|
|
11308
|
+
} catch (error) {
|
|
11309
|
+
const shouldFallback = await failProvider({
|
|
11310
|
+
attempt: nextProviderIndex,
|
|
11311
|
+
error,
|
|
11312
|
+
operation: "send",
|
|
11313
|
+
provider,
|
|
11314
|
+
startedAt
|
|
11315
|
+
});
|
|
11316
|
+
const nextProvider = order[nextProviderIndex];
|
|
11317
|
+
if (!shouldFallback || !nextProvider) {
|
|
11318
|
+
throw error;
|
|
11319
|
+
}
|
|
11320
|
+
nextProviderIndex += 1;
|
|
11321
|
+
await session.close("tts-provider-fallback").catch(() => {});
|
|
11322
|
+
await openProvider(nextProvider, nextProviderIndex);
|
|
11323
|
+
}
|
|
11324
|
+
}
|
|
11325
|
+
};
|
|
11326
|
+
return {
|
|
11327
|
+
close: async (reason) => {
|
|
11328
|
+
await activeSession?.close(reason);
|
|
11329
|
+
activeSession = undefined;
|
|
11330
|
+
activeProvider = undefined;
|
|
11331
|
+
await emitter.emit("close", {
|
|
11332
|
+
reason,
|
|
11333
|
+
type: "close"
|
|
11334
|
+
});
|
|
11335
|
+
},
|
|
11336
|
+
on: emitter.on,
|
|
11337
|
+
send: sendWithFallback
|
|
11338
|
+
};
|
|
7635
11339
|
}
|
|
7636
11340
|
};
|
|
7637
11341
|
};
|
|
@@ -8116,6 +11820,169 @@ var createVoiceMemoryStore = () => {
|
|
|
8116
11820
|
};
|
|
8117
11821
|
return { get, getOrCreate, list, remove, set };
|
|
8118
11822
|
};
|
|
11823
|
+
// src/opsWebhook.ts
|
|
11824
|
+
import { Elysia as Elysia12 } from "elysia";
|
|
11825
|
+
var toHex5 = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
11826
|
+
var signVoiceOpsWebhookBody = async (input) => {
|
|
11827
|
+
const encoder = new TextEncoder;
|
|
11828
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(input.secret), {
|
|
11829
|
+
hash: "SHA-256",
|
|
11830
|
+
name: "HMAC"
|
|
11831
|
+
}, false, ["sign"]);
|
|
11832
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${input.timestamp}.${input.body}`));
|
|
11833
|
+
return `sha256=${toHex5(new Uint8Array(signature))}`;
|
|
11834
|
+
};
|
|
11835
|
+
var timingSafeEqual = (left, right) => {
|
|
11836
|
+
const encoder = new TextEncoder;
|
|
11837
|
+
const leftBytes = encoder.encode(left);
|
|
11838
|
+
const rightBytes = encoder.encode(right);
|
|
11839
|
+
if (leftBytes.length !== rightBytes.length) {
|
|
11840
|
+
return false;
|
|
11841
|
+
}
|
|
11842
|
+
let diff = 0;
|
|
11843
|
+
for (let index = 0;index < leftBytes.length; index += 1) {
|
|
11844
|
+
diff |= leftBytes[index] ^ rightBytes[index];
|
|
11845
|
+
}
|
|
11846
|
+
return diff === 0;
|
|
11847
|
+
};
|
|
11848
|
+
var resolveWebhookLink = async (resolver, event) => {
|
|
11849
|
+
if (typeof resolver === "function") {
|
|
11850
|
+
return resolver({
|
|
11851
|
+
event
|
|
11852
|
+
});
|
|
11853
|
+
}
|
|
11854
|
+
return resolver;
|
|
11855
|
+
};
|
|
11856
|
+
var joinBaseUrl = (baseUrl, path) => `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
11857
|
+
var asString = (value) => typeof value === "string" && value.length > 0 ? value : undefined;
|
|
11858
|
+
var buildVoiceOpsWebhookEntity = (event) => ({
|
|
11859
|
+
disposition: asString(event.payload.disposition),
|
|
11860
|
+
outcome: asString(event.payload.outcome),
|
|
11861
|
+
priority: asString(event.payload.priority),
|
|
11862
|
+
queue: asString(event.payload.queue),
|
|
11863
|
+
reviewId: asString(event.payload.reviewId),
|
|
11864
|
+
scenarioId: asString(event.payload.scenarioId),
|
|
11865
|
+
sessionId: asString(event.payload.sessionId),
|
|
11866
|
+
status: asString(event.payload.status),
|
|
11867
|
+
target: asString(event.payload.target),
|
|
11868
|
+
taskId: asString(event.payload.taskId)
|
|
11869
|
+
});
|
|
11870
|
+
var createVoiceOpsWebhookEnvelope = async (input) => {
|
|
11871
|
+
const entity = buildVoiceOpsWebhookEntity(input.event);
|
|
11872
|
+
const replayHref = await resolveWebhookLink(input.replayHref, input.event) ?? (input.baseUrl && entity.sessionId ? joinBaseUrl(input.baseUrl, `/api/voice-sessions/${encodeURIComponent(entity.sessionId)}/replay`) : undefined);
|
|
11873
|
+
const links = {
|
|
11874
|
+
event: await resolveWebhookLink(input.eventHref, input.event),
|
|
11875
|
+
replay: replayHref,
|
|
11876
|
+
review: await resolveWebhookLink(input.reviewHref, input.event),
|
|
11877
|
+
task: await resolveWebhookLink(input.taskHref, input.event)
|
|
11878
|
+
};
|
|
11879
|
+
return {
|
|
11880
|
+
entity,
|
|
11881
|
+
event: {
|
|
11882
|
+
createdAt: input.event.createdAt,
|
|
11883
|
+
id: input.event.id,
|
|
11884
|
+
payload: input.event.payload,
|
|
11885
|
+
type: input.event.type
|
|
11886
|
+
},
|
|
11887
|
+
links: links.event || links.replay || links.review || links.task ? links : undefined,
|
|
11888
|
+
schemaVersion: 1,
|
|
11889
|
+
source: "absolutejs-voice"
|
|
11890
|
+
};
|
|
11891
|
+
};
|
|
11892
|
+
var createVoiceOpsWebhookSink = (options) => createVoiceIntegrationHTTPSink({
|
|
11893
|
+
...options,
|
|
11894
|
+
body: ({ event }) => createVoiceOpsWebhookEnvelope({
|
|
11895
|
+
baseUrl: options.baseUrl,
|
|
11896
|
+
event,
|
|
11897
|
+
eventHref: options.eventHref,
|
|
11898
|
+
replayHref: options.replayHref,
|
|
11899
|
+
reviewHref: options.reviewHref,
|
|
11900
|
+
taskHref: options.taskHref
|
|
11901
|
+
}),
|
|
11902
|
+
kind: options.kind ?? "ops-webhook"
|
|
11903
|
+
});
|
|
11904
|
+
var verifyVoiceOpsWebhookSignature = async (input) => {
|
|
11905
|
+
if (!input.secret) {
|
|
11906
|
+
return {
|
|
11907
|
+
ok: false,
|
|
11908
|
+
reason: "missing-secret"
|
|
11909
|
+
};
|
|
11910
|
+
}
|
|
11911
|
+
if (!input.signature) {
|
|
11912
|
+
return {
|
|
11913
|
+
ok: false,
|
|
11914
|
+
reason: "missing-signature"
|
|
11915
|
+
};
|
|
11916
|
+
}
|
|
11917
|
+
if (!input.signature.startsWith("sha256=")) {
|
|
11918
|
+
return {
|
|
11919
|
+
ok: false,
|
|
11920
|
+
reason: "unsupported-algorithm"
|
|
11921
|
+
};
|
|
11922
|
+
}
|
|
11923
|
+
if (!input.timestamp) {
|
|
11924
|
+
return {
|
|
11925
|
+
ok: false,
|
|
11926
|
+
reason: "missing-timestamp"
|
|
11927
|
+
};
|
|
11928
|
+
}
|
|
11929
|
+
const timestampMs = Number(input.timestamp);
|
|
11930
|
+
const toleranceMs = Math.max(0, input.toleranceMs ?? 5 * 60 * 1000);
|
|
11931
|
+
if (!Number.isFinite(timestampMs) || toleranceMs > 0 && Math.abs((input.now ?? Date.now()) - timestampMs) > toleranceMs) {
|
|
11932
|
+
return {
|
|
11933
|
+
ok: false,
|
|
11934
|
+
reason: "stale-timestamp"
|
|
11935
|
+
};
|
|
11936
|
+
}
|
|
11937
|
+
const expected = await signVoiceOpsWebhookBody({
|
|
11938
|
+
body: input.body,
|
|
11939
|
+
secret: input.secret,
|
|
11940
|
+
timestamp: input.timestamp
|
|
11941
|
+
});
|
|
11942
|
+
if (!timingSafeEqual(expected, input.signature)) {
|
|
11943
|
+
return {
|
|
11944
|
+
ok: false,
|
|
11945
|
+
reason: "invalid-signature"
|
|
11946
|
+
};
|
|
11947
|
+
}
|
|
11948
|
+
return {
|
|
11949
|
+
ok: true
|
|
11950
|
+
};
|
|
11951
|
+
};
|
|
11952
|
+
var createVoiceOpsWebhookReceiverRoutes = (options = {}) => {
|
|
11953
|
+
const path = options.path ?? "/api/voice-ops/webhook";
|
|
11954
|
+
return new Elysia12().post(path, async ({ body, request, set }) => {
|
|
11955
|
+
const bodyText = typeof body === "string" ? body : JSON.stringify(body);
|
|
11956
|
+
if (options.signingSecret) {
|
|
11957
|
+
const verification = await verifyVoiceOpsWebhookSignature({
|
|
11958
|
+
body: bodyText,
|
|
11959
|
+
secret: options.signingSecret,
|
|
11960
|
+
signature: request.headers.get("x-absolutejs-signature"),
|
|
11961
|
+
timestamp: request.headers.get("x-absolutejs-timestamp"),
|
|
11962
|
+
toleranceMs: options.toleranceMs
|
|
11963
|
+
});
|
|
11964
|
+
if (!verification.ok) {
|
|
11965
|
+
set.status = 401;
|
|
11966
|
+
return {
|
|
11967
|
+
ok: false,
|
|
11968
|
+
reason: verification.reason
|
|
11969
|
+
};
|
|
11970
|
+
}
|
|
11971
|
+
}
|
|
11972
|
+
const envelope = JSON.parse(bodyText);
|
|
11973
|
+
await options.onEnvelope?.({
|
|
11974
|
+
envelope,
|
|
11975
|
+
request
|
|
11976
|
+
});
|
|
11977
|
+
return {
|
|
11978
|
+
eventId: envelope.event?.id,
|
|
11979
|
+
ok: true,
|
|
11980
|
+
type: envelope.event?.type
|
|
11981
|
+
};
|
|
11982
|
+
}, {
|
|
11983
|
+
parse: "text"
|
|
11984
|
+
});
|
|
11985
|
+
};
|
|
8119
11986
|
// src/queue.ts
|
|
8120
11987
|
var releaseLeaseScript = `
|
|
8121
11988
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
@@ -8187,6 +12054,8 @@ var shouldDeadLetterSinkEvent = (event, sinks, maxFailures) => typeof maxFailure
|
|
|
8187
12054
|
var shouldDeadLetterTask = (task, maxFailures) => typeof maxFailures === "number" && maxFailures > 0 && (task.processingAttempts ?? 0) >= maxFailures;
|
|
8188
12055
|
var shouldProcessTraceDeliveryStatus = (status, allowed) => allowed.includes(status);
|
|
8189
12056
|
var shouldDeadLetterTraceDelivery = (delivery, maxFailures) => typeof maxFailures === "number" && maxFailures > 0 && (delivery.deliveryAttempts ?? 0) >= maxFailures;
|
|
12057
|
+
var shouldProcessHandoffDeliveryStatus = (status, allowed) => allowed.includes(status);
|
|
12058
|
+
var shouldDeadLetterHandoffDelivery = (delivery, maxFailures) => typeof maxFailures === "number" && maxFailures > 0 && (delivery.deliveryAttempts ?? 0) >= maxFailures;
|
|
8190
12059
|
var summarizeVoiceIntegrationEvents = (events, input = {}) => {
|
|
8191
12060
|
const buildSummary = async () => {
|
|
8192
12061
|
const deadLetterIds = new Set(input.deadLetters ? (await input.deadLetters.list()).map((event) => event.id) : []);
|
|
@@ -8268,6 +12137,48 @@ var summarizeVoiceTraceSinkDeliveries = (deliveries, input = {}) => {
|
|
|
8268
12137
|
};
|
|
8269
12138
|
return buildSummary();
|
|
8270
12139
|
};
|
|
12140
|
+
var summarizeVoiceHandoffDeliveries = (deliveries, input = {}) => {
|
|
12141
|
+
const buildSummary = async () => {
|
|
12142
|
+
const deadLetterIds = new Set(input.deadLetters ? (await input.deadLetters.list()).map((delivery) => delivery.id) : []);
|
|
12143
|
+
const byAction = new Map;
|
|
12144
|
+
const summary = {
|
|
12145
|
+
byAction: [],
|
|
12146
|
+
deadLettered: 0,
|
|
12147
|
+
delivered: 0,
|
|
12148
|
+
failed: 0,
|
|
12149
|
+
pending: 0,
|
|
12150
|
+
retryEligible: 0,
|
|
12151
|
+
skipped: 0,
|
|
12152
|
+
total: deliveries.length
|
|
12153
|
+
};
|
|
12154
|
+
for (const delivery of deliveries) {
|
|
12155
|
+
byAction.set(delivery.action, (byAction.get(delivery.action) ?? 0) + 1);
|
|
12156
|
+
if (deadLetterIds.has(delivery.id)) {
|
|
12157
|
+
summary.deadLettered += 1;
|
|
12158
|
+
}
|
|
12159
|
+
switch (delivery.deliveryStatus) {
|
|
12160
|
+
case "delivered":
|
|
12161
|
+
summary.delivered += 1;
|
|
12162
|
+
break;
|
|
12163
|
+
case "failed":
|
|
12164
|
+
summary.failed += 1;
|
|
12165
|
+
if ((delivery.deliveryAttempts ?? 0) > 0) {
|
|
12166
|
+
summary.retryEligible += 1;
|
|
12167
|
+
}
|
|
12168
|
+
break;
|
|
12169
|
+
case "skipped":
|
|
12170
|
+
summary.skipped += 1;
|
|
12171
|
+
break;
|
|
12172
|
+
case "pending":
|
|
12173
|
+
summary.pending += 1;
|
|
12174
|
+
break;
|
|
12175
|
+
}
|
|
12176
|
+
}
|
|
12177
|
+
summary.byAction = [...byAction.entries()].sort((left, right) => right[1] - left[1]);
|
|
12178
|
+
return summary;
|
|
12179
|
+
};
|
|
12180
|
+
return buildSummary();
|
|
12181
|
+
};
|
|
8271
12182
|
var summarizeVoiceOpsTaskQueue = (tasks, input = {}) => {
|
|
8272
12183
|
const buildSummary = async () => {
|
|
8273
12184
|
const deadLetterIds = new Set(input.deadLetters ? (await input.deadLetters.list()).map((task) => task.id) : []);
|
|
@@ -8697,6 +12608,108 @@ var createVoiceTraceSinkDeliveryWorkerLoop = (options) => {
|
|
|
8697
12608
|
tick
|
|
8698
12609
|
};
|
|
8699
12610
|
};
|
|
12611
|
+
var createVoiceHandoffDeliveryWorker = (options) => {
|
|
12612
|
+
const allowedStatuses = options.statuses ?? ["pending", "failed"];
|
|
12613
|
+
const leaseMs = Math.max(1, options.leaseMs ?? 30000);
|
|
12614
|
+
return {
|
|
12615
|
+
drain: async () => {
|
|
12616
|
+
const result = {
|
|
12617
|
+
alreadyProcessed: 0,
|
|
12618
|
+
attempted: 0,
|
|
12619
|
+
deadLettered: 0,
|
|
12620
|
+
delivered: 0,
|
|
12621
|
+
failed: 0,
|
|
12622
|
+
skipped: 0
|
|
12623
|
+
};
|
|
12624
|
+
const deliveries = [...await options.deliveries.list()].sort((left, right) => left.createdAt - right.createdAt);
|
|
12625
|
+
for (const delivery of deliveries) {
|
|
12626
|
+
if (!shouldProcessHandoffDeliveryStatus(delivery.deliveryStatus, allowedStatuses)) {
|
|
12627
|
+
continue;
|
|
12628
|
+
}
|
|
12629
|
+
if (shouldDeadLetterHandoffDelivery(delivery, options.maxFailures)) {
|
|
12630
|
+
await options.deadLetters?.set(delivery.id, delivery);
|
|
12631
|
+
await options.onDeadLetter?.(delivery);
|
|
12632
|
+
result.deadLettered += 1;
|
|
12633
|
+
continue;
|
|
12634
|
+
}
|
|
12635
|
+
const claimed = await options.leases.claim({
|
|
12636
|
+
leaseMs,
|
|
12637
|
+
taskId: delivery.id,
|
|
12638
|
+
workerId: options.workerId
|
|
12639
|
+
});
|
|
12640
|
+
if (!claimed) {
|
|
12641
|
+
continue;
|
|
12642
|
+
}
|
|
12643
|
+
try {
|
|
12644
|
+
const idempotencyKey = `${delivery.id}:handoff`;
|
|
12645
|
+
if (options.idempotency && await options.idempotency.has(idempotencyKey)) {
|
|
12646
|
+
result.alreadyProcessed += 1;
|
|
12647
|
+
continue;
|
|
12648
|
+
}
|
|
12649
|
+
result.attempted += 1;
|
|
12650
|
+
const updatedDelivery = await deliverVoiceHandoffDelivery({
|
|
12651
|
+
adapters: options.adapters,
|
|
12652
|
+
api: options.api,
|
|
12653
|
+
delivery,
|
|
12654
|
+
failMode: options.failMode
|
|
12655
|
+
});
|
|
12656
|
+
await options.deliveries.set(updatedDelivery.id, updatedDelivery);
|
|
12657
|
+
if (updatedDelivery.deliveryStatus === "delivered" || updatedDelivery.deliveryStatus === "skipped") {
|
|
12658
|
+
await options.idempotency?.set(idempotencyKey, {
|
|
12659
|
+
ttlSeconds: options.idempotencyTtlSeconds
|
|
12660
|
+
});
|
|
12661
|
+
}
|
|
12662
|
+
if (updatedDelivery.deliveryStatus === "delivered") {
|
|
12663
|
+
result.delivered += 1;
|
|
12664
|
+
} else if (updatedDelivery.deliveryStatus === "skipped") {
|
|
12665
|
+
result.skipped += 1;
|
|
12666
|
+
} else if (updatedDelivery.deliveryStatus === "failed") {
|
|
12667
|
+
result.failed += 1;
|
|
12668
|
+
if (shouldDeadLetterHandoffDelivery(updatedDelivery, options.maxFailures)) {
|
|
12669
|
+
await options.deadLetters?.set(updatedDelivery.id, updatedDelivery);
|
|
12670
|
+
await options.onDeadLetter?.(updatedDelivery);
|
|
12671
|
+
result.deadLettered += 1;
|
|
12672
|
+
}
|
|
12673
|
+
}
|
|
12674
|
+
} finally {
|
|
12675
|
+
await options.leases.release({
|
|
12676
|
+
taskId: delivery.id,
|
|
12677
|
+
workerId: options.workerId
|
|
12678
|
+
});
|
|
12679
|
+
}
|
|
12680
|
+
}
|
|
12681
|
+
return result;
|
|
12682
|
+
}
|
|
12683
|
+
};
|
|
12684
|
+
};
|
|
12685
|
+
var createVoiceHandoffDeliveryWorkerLoop = (options) => {
|
|
12686
|
+
const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? 1000);
|
|
12687
|
+
let timer;
|
|
12688
|
+
let running = false;
|
|
12689
|
+
const tick = async () => options.worker.drain();
|
|
12690
|
+
return {
|
|
12691
|
+
isRunning: () => running,
|
|
12692
|
+
start: () => {
|
|
12693
|
+
if (timer) {
|
|
12694
|
+
return;
|
|
12695
|
+
}
|
|
12696
|
+
running = true;
|
|
12697
|
+
timer = setInterval(() => {
|
|
12698
|
+
tick().catch((error) => {
|
|
12699
|
+
options.onError?.(error);
|
|
12700
|
+
});
|
|
12701
|
+
}, pollIntervalMs);
|
|
12702
|
+
},
|
|
12703
|
+
stop: () => {
|
|
12704
|
+
if (timer) {
|
|
12705
|
+
clearInterval(timer);
|
|
12706
|
+
timer = undefined;
|
|
12707
|
+
}
|
|
12708
|
+
running = false;
|
|
12709
|
+
},
|
|
12710
|
+
tick
|
|
12711
|
+
};
|
|
12712
|
+
};
|
|
8700
12713
|
var createVoiceOpsTaskWorker = (options) => {
|
|
8701
12714
|
const leaseMs = Math.max(1, options.leaseMs ?? 30000);
|
|
8702
12715
|
const getTask = async (taskId) => {
|
|
@@ -8832,10 +12845,10 @@ var createVoiceOpsTaskProcessorWorker = (options) => ({
|
|
|
8832
12845
|
result.completed += 1;
|
|
8833
12846
|
} catch (error) {
|
|
8834
12847
|
await options.onError?.(error, task);
|
|
8835
|
-
const
|
|
12848
|
+
const errorMessage3 = error instanceof Error ? error.message : String(error);
|
|
8836
12849
|
const failedTask = failVoiceOpsTask(task, {
|
|
8837
12850
|
actor: task.claimedBy ?? "ops-worker",
|
|
8838
|
-
error:
|
|
12851
|
+
error: errorMessage3
|
|
8839
12852
|
});
|
|
8840
12853
|
if (shouldDeadLetterTask(failedTask, options.maxFailures)) {
|
|
8841
12854
|
const deadLetterTask = deadLetterVoiceOpsTask(failedTask, {
|
|
@@ -9648,7 +13661,7 @@ var createVoiceSTTRoutingCorrectionHandler = (mode = "generic") => {
|
|
|
9648
13661
|
import { Buffer as Buffer2 } from "buffer";
|
|
9649
13662
|
var TWILIO_MULAW_SAMPLE_RATE = 8000;
|
|
9650
13663
|
var VOICE_PCM_SAMPLE_RATE = 16000;
|
|
9651
|
-
var
|
|
13664
|
+
var escapeXml2 = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">");
|
|
9652
13665
|
var normalizeOnTurn2 = (handler) => {
|
|
9653
13666
|
if (handler.length > 1) {
|
|
9654
13667
|
const directHandler = handler;
|
|
@@ -9844,8 +13857,8 @@ var createTwilioSocketAdapter = (socket, getState) => ({
|
|
|
9844
13857
|
}
|
|
9845
13858
|
});
|
|
9846
13859
|
var createTwilioVoiceResponse = (options) => {
|
|
9847
|
-
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${
|
|
9848
|
-
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${
|
|
13860
|
+
const parameters = Object.entries(options.parameters ?? {}).filter((entry) => entry[1] !== undefined).map(([name, value]) => `<Parameter name="${escapeXml2(name)}" value="${escapeXml2(String(value))}" />`).join("");
|
|
13861
|
+
return `<?xml version="1.0" encoding="UTF-8"?><Response><Connect><Stream url="${escapeXml2(options.streamUrl)}"${options.track ? ` track="${escapeXml2(options.track)}"` : ""}${options.streamName ? ` name="${escapeXml2(options.streamName)}"` : ""}>${parameters}</Stream></Connect></Response>`;
|
|
9849
13862
|
};
|
|
9850
13863
|
var createTwilioMediaStreamBridge = (socket, options) => {
|
|
9851
13864
|
const runtimePreset = resolveVoiceRuntimePreset(options.preset);
|
|
@@ -10081,26 +14094,41 @@ export {
|
|
|
10081
14094
|
withVoiceOpsTaskId,
|
|
10082
14095
|
withVoiceIntegrationEventId,
|
|
10083
14096
|
voice,
|
|
14097
|
+
verifyVoiceOpsWebhookSignature,
|
|
14098
|
+
validateVoiceWorkflowRouteResult,
|
|
10084
14099
|
transcodeTwilioInboundPayloadToPCM16,
|
|
10085
14100
|
transcodePCMToTwilioOutboundPayload,
|
|
10086
14101
|
summarizeVoiceTraceSinkDeliveries,
|
|
10087
14102
|
summarizeVoiceTrace,
|
|
14103
|
+
summarizeVoiceSessions,
|
|
14104
|
+
summarizeVoiceSessionReplay,
|
|
14105
|
+
summarizeVoiceRoutingDecision,
|
|
14106
|
+
summarizeVoiceProviderHealth,
|
|
10088
14107
|
summarizeVoiceOpsTasks,
|
|
10089
14108
|
summarizeVoiceOpsTaskQueue,
|
|
10090
14109
|
summarizeVoiceOpsTaskAnalytics,
|
|
10091
14110
|
summarizeVoiceIntegrationEvents,
|
|
14111
|
+
summarizeVoiceHandoffHealth,
|
|
14112
|
+
summarizeVoiceHandoffDeliveries,
|
|
10092
14113
|
summarizeVoiceAssistantRuns,
|
|
14114
|
+
summarizeVoiceAssistantHealth,
|
|
14115
|
+
summarizeVoiceAppKitStatus,
|
|
10093
14116
|
startVoiceOpsTask,
|
|
10094
14117
|
shapeTelephonyAssistantText,
|
|
10095
14118
|
selectVoiceTraceEventsForPrune,
|
|
14119
|
+
runVoiceSessionEvals,
|
|
14120
|
+
runVoiceScenarioFixtureEvals,
|
|
14121
|
+
runVoiceScenarioEvals,
|
|
10096
14122
|
resolveVoiceTraceRedactionOptions,
|
|
10097
14123
|
resolveVoiceSTTRoutingStrategy,
|
|
10098
14124
|
resolveVoiceRuntimePreset,
|
|
14125
|
+
resolveVoiceProviderRoutingPolicyPreset,
|
|
10099
14126
|
resolveVoiceOutcomeRecipe,
|
|
10100
14127
|
resolveVoiceOpsTaskPolicy,
|
|
10101
14128
|
resolveVoiceOpsTaskAssignment,
|
|
10102
14129
|
resolveVoiceOpsTaskAgeBucket,
|
|
10103
14130
|
resolveVoiceOpsPreset,
|
|
14131
|
+
resolveVoiceDiagnosticsTraceFilter,
|
|
10104
14132
|
resolveVoiceAssistantMemoryNamespace,
|
|
10105
14133
|
resolveTurnDetectionConfig,
|
|
10106
14134
|
resolveAudioConditioningConfig,
|
|
@@ -10108,15 +14136,28 @@ export {
|
|
|
10108
14136
|
reopenVoiceOpsTask,
|
|
10109
14137
|
renderVoiceTraceMarkdown,
|
|
10110
14138
|
renderVoiceTraceHTML,
|
|
14139
|
+
renderVoiceSessionsHTML,
|
|
14140
|
+
renderVoiceScenarioFixtureEvalHTML,
|
|
14141
|
+
renderVoiceScenarioEvalHTML,
|
|
14142
|
+
renderVoiceResilienceHTML,
|
|
14143
|
+
renderVoiceQualityHTML,
|
|
14144
|
+
renderVoiceProviderHealthHTML,
|
|
14145
|
+
renderVoiceOpsConsoleHTML,
|
|
14146
|
+
renderVoiceHandoffHealthHTML,
|
|
14147
|
+
renderVoiceEvalHTML,
|
|
14148
|
+
renderVoiceEvalBaselineHTML,
|
|
10111
14149
|
renderVoiceCallReviewMarkdown,
|
|
10112
14150
|
renderVoiceCallReviewHTML,
|
|
14151
|
+
renderVoiceAssistantHealthHTML,
|
|
10113
14152
|
redactVoiceTraceText,
|
|
10114
14153
|
redactVoiceTraceEvents,
|
|
10115
14154
|
redactVoiceTraceEvent,
|
|
14155
|
+
recordVoiceWorkflowContractTrace,
|
|
10116
14156
|
recordVoiceRuntimeOps,
|
|
10117
14157
|
pruneVoiceTraceEvents,
|
|
10118
14158
|
matchesVoiceOpsTaskAssignmentRule,
|
|
10119
14159
|
markVoiceOpsTaskSLABreached,
|
|
14160
|
+
listVoiceRoutingEvents,
|
|
10120
14161
|
listVoiceOpsTasks,
|
|
10121
14162
|
isVoiceOpsTaskOverdue,
|
|
10122
14163
|
heartbeatVoiceOpsTask,
|
|
@@ -10125,17 +14166,26 @@ export {
|
|
|
10125
14166
|
failVoiceOpsTask,
|
|
10126
14167
|
exportVoiceTrace,
|
|
10127
14168
|
evaluateVoiceTrace,
|
|
14169
|
+
evaluateVoiceQuality,
|
|
10128
14170
|
encodeTwilioMulawBase64,
|
|
10129
14171
|
deliverVoiceTraceEventsToSinks,
|
|
10130
14172
|
deliverVoiceIntegrationEventToSinks,
|
|
10131
14173
|
deliverVoiceIntegrationEvent,
|
|
14174
|
+
deliverVoiceHandoffDelivery,
|
|
14175
|
+
deliverVoiceHandoff,
|
|
10132
14176
|
decodeTwilioMulawBase64,
|
|
10133
14177
|
deadLetterVoiceOpsTask,
|
|
10134
14178
|
createVoiceZendeskTicketUpdateSink,
|
|
10135
14179
|
createVoiceZendeskTicketSyncSinks,
|
|
10136
14180
|
createVoiceZendeskTicketSink,
|
|
14181
|
+
createVoiceWorkflowScenario,
|
|
14182
|
+
createVoiceWorkflowContractPreset,
|
|
14183
|
+
createVoiceWorkflowContractHandler,
|
|
14184
|
+
createVoiceWorkflowContract,
|
|
14185
|
+
createVoiceWebhookHandoffAdapter,
|
|
10137
14186
|
createVoiceWebhookDeliveryWorkerLoop,
|
|
10138
14187
|
createVoiceWebhookDeliveryWorker,
|
|
14188
|
+
createVoiceTwilioRedirectHandoffAdapter,
|
|
10139
14189
|
createVoiceTraceSinkStore,
|
|
10140
14190
|
createVoiceTraceSinkDeliveryWorkerLoop,
|
|
10141
14191
|
createVoiceTraceSinkDeliveryWorker,
|
|
@@ -10147,9 +14197,17 @@ export {
|
|
|
10147
14197
|
createVoiceTaskUpdatedEvent,
|
|
10148
14198
|
createVoiceTaskSLABreachedEvent,
|
|
10149
14199
|
createVoiceTaskCreatedEvent,
|
|
14200
|
+
createVoiceTTSProviderRouter,
|
|
14201
|
+
createVoiceSessionsJSONHandler,
|
|
14202
|
+
createVoiceSessionsHTMLHandler,
|
|
14203
|
+
createVoiceSessionReplayRoutes,
|
|
14204
|
+
createVoiceSessionReplayJSONHandler,
|
|
14205
|
+
createVoiceSessionReplayHTMLHandler,
|
|
10150
14206
|
createVoiceSessionRecord,
|
|
14207
|
+
createVoiceSessionListRoutes,
|
|
10151
14208
|
createVoiceSession,
|
|
10152
14209
|
createVoiceSTTRoutingCorrectionHandler,
|
|
14210
|
+
createVoiceSTTProviderRouter,
|
|
10153
14211
|
createVoiceSQLiteTraceSinkDeliveryStore,
|
|
10154
14212
|
createVoiceSQLiteTraceEventStore,
|
|
10155
14213
|
createVoiceSQLiteTaskStore,
|
|
@@ -10159,9 +14217,16 @@ export {
|
|
|
10159
14217
|
createVoiceSQLiteIntegrationEventStore,
|
|
10160
14218
|
createVoiceSQLiteExternalObjectMapStore,
|
|
10161
14219
|
createVoiceS3ReviewStore,
|
|
14220
|
+
createVoiceRoutingDecisionSummary,
|
|
10162
14221
|
createVoiceReviewSavedEvent,
|
|
14222
|
+
createVoiceResilienceRoutes,
|
|
10163
14223
|
createVoiceRedisTaskLeaseCoordinator,
|
|
10164
14224
|
createVoiceRedisIdempotencyStore,
|
|
14225
|
+
createVoiceQualityRoutes,
|
|
14226
|
+
createVoiceProviderRouter,
|
|
14227
|
+
createVoiceProviderHealthRoutes,
|
|
14228
|
+
createVoiceProviderHealthJSONHandler,
|
|
14229
|
+
createVoiceProviderHealthHTMLHandler,
|
|
10165
14230
|
createVoicePostgresTraceSinkDeliveryStore,
|
|
10166
14231
|
createVoicePostgresTraceEventStore,
|
|
10167
14232
|
createVoicePostgresTaskStore,
|
|
@@ -10170,13 +14235,18 @@ export {
|
|
|
10170
14235
|
createVoicePostgresReviewStore,
|
|
10171
14236
|
createVoicePostgresIntegrationEventStore,
|
|
10172
14237
|
createVoicePostgresExternalObjectMapStore,
|
|
14238
|
+
createVoiceOpsWebhookSink,
|
|
14239
|
+
createVoiceOpsWebhookReceiverRoutes,
|
|
14240
|
+
createVoiceOpsWebhookEnvelope,
|
|
10173
14241
|
createVoiceOpsTaskWorker,
|
|
10174
14242
|
createVoiceOpsTaskProcessorWorkerLoop,
|
|
10175
14243
|
createVoiceOpsTaskProcessorWorker,
|
|
10176
14244
|
createVoiceOpsRuntime,
|
|
14245
|
+
createVoiceOpsConsoleRoutes,
|
|
10177
14246
|
createVoiceMemoryTraceSinkDeliveryStore,
|
|
10178
14247
|
createVoiceMemoryTraceEventStore,
|
|
10179
14248
|
createVoiceMemoryStore,
|
|
14249
|
+
createVoiceMemoryHandoffDeliveryStore,
|
|
10180
14250
|
createVoiceMemoryAssistantMemoryStore,
|
|
10181
14251
|
createVoiceLinearIssueUpdateSink,
|
|
10182
14252
|
createVoiceLinearIssueSyncSinks,
|
|
@@ -10189,18 +14259,28 @@ export {
|
|
|
10189
14259
|
createVoiceHubSpotTaskSyncSinks,
|
|
10190
14260
|
createVoiceHubSpotTaskSink,
|
|
10191
14261
|
createVoiceHelpdeskTicketSink,
|
|
14262
|
+
createVoiceHandoffHealthRoutes,
|
|
14263
|
+
createVoiceHandoffHealthJSONHandler,
|
|
14264
|
+
createVoiceHandoffHealthHTMLHandler,
|
|
14265
|
+
createVoiceHandoffDeliveryWorkerLoop,
|
|
14266
|
+
createVoiceHandoffDeliveryWorker,
|
|
14267
|
+
createVoiceHandoffDeliveryRecord,
|
|
10192
14268
|
createVoiceFileTraceSinkDeliveryStore,
|
|
10193
14269
|
createVoiceFileTraceEventStore,
|
|
10194
14270
|
createVoiceFileTaskStore,
|
|
10195
14271
|
createVoiceFileSessionStore,
|
|
14272
|
+
createVoiceFileScenarioFixtureStore,
|
|
10196
14273
|
createVoiceFileRuntimeStorage,
|
|
10197
14274
|
createVoiceFileReviewStore,
|
|
10198
14275
|
createVoiceFileIntegrationEventStore,
|
|
10199
14276
|
createVoiceFileExternalObjectMapStore,
|
|
14277
|
+
createVoiceFileEvalBaselineStore,
|
|
10200
14278
|
createVoiceFileAssistantMemoryStore,
|
|
10201
14279
|
createVoiceExternalObjectMapId,
|
|
10202
14280
|
createVoiceExternalObjectMap,
|
|
10203
14281
|
createVoiceExperiment,
|
|
14282
|
+
createVoiceEvalRoutes,
|
|
14283
|
+
createVoiceDiagnosticsRoutes,
|
|
10204
14284
|
createVoiceCallReviewRecorder,
|
|
10205
14285
|
createVoiceCallReviewFromSession,
|
|
10206
14286
|
createVoiceCallReviewFromLiveTelephonyReport,
|
|
@@ -10208,7 +14288,12 @@ export {
|
|
|
10208
14288
|
createVoiceCRMActivitySink,
|
|
10209
14289
|
createVoiceAssistantMemoryRecord,
|
|
10210
14290
|
createVoiceAssistantMemoryHandle,
|
|
14291
|
+
createVoiceAssistantHealthRoutes,
|
|
14292
|
+
createVoiceAssistantHealthJSONHandler,
|
|
14293
|
+
createVoiceAssistantHealthHTMLHandler,
|
|
10211
14294
|
createVoiceAssistant,
|
|
14295
|
+
createVoiceAppKitRoutes,
|
|
14296
|
+
createVoiceAppKit,
|
|
10212
14297
|
createVoiceAgentTool,
|
|
10213
14298
|
createVoiceAgentSquad,
|
|
10214
14299
|
createVoiceAgent,
|
|
@@ -10229,13 +14314,17 @@ export {
|
|
|
10229
14314
|
createAnthropicVoiceAssistantModel,
|
|
10230
14315
|
conditionAudioChunk,
|
|
10231
14316
|
completeVoiceOpsTask,
|
|
14317
|
+
compareVoiceEvalBaseline,
|
|
10232
14318
|
claimVoiceOpsTask,
|
|
10233
14319
|
buildVoiceTraceReplay,
|
|
10234
14320
|
buildVoiceOpsTaskFromSLABreach,
|
|
10235
14321
|
buildVoiceOpsTaskFromReview,
|
|
14322
|
+
buildVoiceOpsConsoleReport,
|
|
14323
|
+
buildVoiceDiagnosticsMarkdown,
|
|
10236
14324
|
assignVoiceOpsTask,
|
|
10237
14325
|
applyVoiceOpsTaskPolicy,
|
|
10238
14326
|
applyVoiceOpsTaskAssignmentRule,
|
|
14327
|
+
applyVoiceHandoffDeliveryResult,
|
|
10239
14328
|
applyRiskTieredPhraseHintCorrections,
|
|
10240
14329
|
applyPhraseHintCorrections,
|
|
10241
14330
|
TURN_PROFILE_DEFAULTS
|