@absolutejs/voice 0.0.22-beta.40 → 0.0.22-beta.42
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/dist/evalRoutes.d.ts +83 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1142 -892
- package/dist/opsConsoleRoutes.d.ts +77 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7623,230 +7623,186 @@ var createVoiceDiagnosticsRoutes = (options) => {
|
|
|
7623
7623
|
});
|
|
7624
7624
|
return routes;
|
|
7625
7625
|
};
|
|
7626
|
-
// src/
|
|
7626
|
+
// src/evalRoutes.ts
|
|
7627
|
+
import { Elysia as Elysia7 } from "elysia";
|
|
7628
|
+
|
|
7629
|
+
// src/qualityRoutes.ts
|
|
7630
|
+
import { Elysia as Elysia6 } from "elysia";
|
|
7631
|
+
|
|
7632
|
+
// src/handoffHealth.ts
|
|
7627
7633
|
import { Elysia as Elysia5 } from "elysia";
|
|
7628
|
-
var getString4 = (value) => typeof value === "string" ? value : undefined;
|
|
7629
7634
|
var escapeHtml7 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
7635
|
+
var getString4 = (value) => typeof value === "string" && value.length > 0 ? value : undefined;
|
|
7636
|
+
var isStatus = (value) => value === "delivered" || value === "failed" || value === "skipped";
|
|
7630
7637
|
var increment2 = (record, key) => {
|
|
7631
7638
|
record[key] = (record[key] ?? 0) + 1;
|
|
7632
7639
|
};
|
|
7633
|
-
var
|
|
7634
|
-
const
|
|
7635
|
-
const getTurn = (turnId) => {
|
|
7636
|
-
const existing = turns.get(turnId);
|
|
7637
|
-
if (existing) {
|
|
7638
|
-
return existing;
|
|
7639
|
-
}
|
|
7640
|
-
const turn = {
|
|
7641
|
-
assistantReplies: [],
|
|
7642
|
-
errors: [],
|
|
7643
|
-
id: turnId,
|
|
7644
|
-
modelCalls: [],
|
|
7645
|
-
tools: [],
|
|
7646
|
-
transcripts: []
|
|
7647
|
-
};
|
|
7648
|
-
turns.set(turnId, turn);
|
|
7649
|
-
return turn;
|
|
7650
|
-
};
|
|
7651
|
-
for (const event of events) {
|
|
7652
|
-
const turnId = event.turnId ?? "session";
|
|
7653
|
-
const turn = getTurn(turnId);
|
|
7654
|
-
switch (event.type) {
|
|
7655
|
-
case "turn.transcript":
|
|
7656
|
-
turn.transcripts.push({
|
|
7657
|
-
isFinal: event.payload.isFinal === true,
|
|
7658
|
-
text: getString4(event.payload.text)
|
|
7659
|
-
});
|
|
7660
|
-
break;
|
|
7661
|
-
case "turn.committed":
|
|
7662
|
-
turn.committedText = getString4(event.payload.text);
|
|
7663
|
-
break;
|
|
7664
|
-
case "turn.assistant": {
|
|
7665
|
-
const text = getString4(event.payload.text);
|
|
7666
|
-
if (text) {
|
|
7667
|
-
turn.assistantReplies.push(text);
|
|
7668
|
-
}
|
|
7669
|
-
break;
|
|
7670
|
-
}
|
|
7671
|
-
case "agent.model":
|
|
7672
|
-
case "assistant.run":
|
|
7673
|
-
turn.modelCalls.push(event.payload);
|
|
7674
|
-
break;
|
|
7675
|
-
case "agent.tool":
|
|
7676
|
-
turn.tools.push(event.payload);
|
|
7677
|
-
break;
|
|
7678
|
-
case "session.error":
|
|
7679
|
-
turn.errors.push(event.payload);
|
|
7680
|
-
break;
|
|
7681
|
-
}
|
|
7682
|
-
}
|
|
7683
|
-
return [...turns.values()];
|
|
7684
|
-
};
|
|
7685
|
-
var summarizeVoiceSessionReplay = async (options) => {
|
|
7686
|
-
const sourceEvents = options.events ?? await options.store?.list({ sessionId: options.sessionId }) ?? [];
|
|
7687
|
-
const events = filterVoiceTraceEvents(sourceEvents, {
|
|
7688
|
-
sessionId: options.sessionId
|
|
7689
|
-
});
|
|
7690
|
-
const replay = buildVoiceTraceReplay(events, {
|
|
7691
|
-
evaluation: options.evaluation,
|
|
7692
|
-
redact: options.redact,
|
|
7693
|
-
title: options.title ?? `Voice Session ${options.sessionId}`
|
|
7694
|
-
});
|
|
7695
|
-
const startedAt = replay.summary.startedAt;
|
|
7640
|
+
var normalizeDelivery = (adapterId, value) => {
|
|
7641
|
+
const record = value && typeof value === "object" ? value : {};
|
|
7696
7642
|
return {
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
timeline: events.map((event) => ({
|
|
7704
|
-
at: event.at,
|
|
7705
|
-
offsetMs: startedAt === undefined ? undefined : Math.max(0, event.at - startedAt),
|
|
7706
|
-
payload: event.payload,
|
|
7707
|
-
turnId: event.turnId,
|
|
7708
|
-
type: event.type
|
|
7709
|
-
})),
|
|
7710
|
-
turns: buildReplayTurns(events)
|
|
7643
|
+
adapterId: getString4(record.adapterId) ?? adapterId,
|
|
7644
|
+
adapterKind: getString4(record.adapterKind),
|
|
7645
|
+
deliveredAt: typeof record.deliveredAt === "number" ? record.deliveredAt : undefined,
|
|
7646
|
+
deliveredTo: getString4(record.deliveredTo),
|
|
7647
|
+
error: getString4(record.error),
|
|
7648
|
+
status: isStatus(record.status) ? record.status : "failed"
|
|
7711
7649
|
};
|
|
7712
7650
|
};
|
|
7713
|
-
var
|
|
7714
|
-
const
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
grouped.set(event.sessionId, [...grouped.get(event.sessionId) ?? [], event]);
|
|
7651
|
+
var normalizeDeliveries = (payload) => {
|
|
7652
|
+
const deliveries = payload.deliveries;
|
|
7653
|
+
if (!deliveries || typeof deliveries !== "object") {
|
|
7654
|
+
return [];
|
|
7718
7655
|
}
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
if (outcome) {
|
|
7744
|
-
latestOutcome = outcome;
|
|
7745
|
-
}
|
|
7746
|
-
}
|
|
7656
|
+
return Object.entries(deliveries).map(([adapterId, value]) => normalizeDelivery(adapterId, value));
|
|
7657
|
+
};
|
|
7658
|
+
var resolveReplayHref = (event, replayHref) => {
|
|
7659
|
+
if (replayHref === false) {
|
|
7660
|
+
return;
|
|
7661
|
+
}
|
|
7662
|
+
if (typeof replayHref === "function") {
|
|
7663
|
+
return replayHref(event);
|
|
7664
|
+
}
|
|
7665
|
+
return `${replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(event.sessionId)}/replay/htmx`;
|
|
7666
|
+
};
|
|
7667
|
+
var summarizeVoiceHandoffHealth = async (options = {}) => {
|
|
7668
|
+
const sourceEvents = options.events ?? await options.store?.list() ?? [];
|
|
7669
|
+
const search = options.q?.trim().toLowerCase();
|
|
7670
|
+
const byAction = {};
|
|
7671
|
+
const byAdapter = {};
|
|
7672
|
+
const byStatus = {
|
|
7673
|
+
delivered: 0,
|
|
7674
|
+
failed: 0,
|
|
7675
|
+
skipped: 0
|
|
7676
|
+
};
|
|
7677
|
+
const events = sourceEvents.filter((event) => event.type === "call.handoff").map((event) => {
|
|
7678
|
+
const status = isStatus(event.payload.status) ? event.payload.status : "failed";
|
|
7679
|
+
const deliveries = normalizeDeliveries(event.payload);
|
|
7747
7680
|
const item = {
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
startedAt: summary.startedAt,
|
|
7756
|
-
status: errorCount > 0 ? "failed" : "healthy",
|
|
7757
|
-
transcriptCount: summary.transcriptCount,
|
|
7758
|
-
turnCount: summary.turnCount
|
|
7681
|
+
action: getString4(event.payload.action),
|
|
7682
|
+
at: event.at,
|
|
7683
|
+
deliveries,
|
|
7684
|
+
reason: getString4(event.payload.reason),
|
|
7685
|
+
sessionId: event.sessionId,
|
|
7686
|
+
status,
|
|
7687
|
+
target: getString4(event.payload.target)
|
|
7759
7688
|
};
|
|
7760
|
-
const replayHref = options.replayHref === false ? "" : typeof options.replayHref === "function" ? options.replayHref(item) : `${options.replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(sessionId)}/replay/htmx`;
|
|
7761
7689
|
return {
|
|
7762
7690
|
...item,
|
|
7763
|
-
replayHref
|
|
7691
|
+
replayHref: resolveReplayHref(item, options.replayHref)
|
|
7764
7692
|
};
|
|
7765
|
-
})
|
|
7766
|
-
|
|
7767
|
-
return sessions.filter((session) => {
|
|
7768
|
-
if (options.status && options.status !== "all" && session.status !== options.status) {
|
|
7769
|
-
return false;
|
|
7770
|
-
}
|
|
7771
|
-
if (options.provider && !session.providers.includes(options.provider)) {
|
|
7693
|
+
}).filter((event) => {
|
|
7694
|
+
if (options.status && options.status !== "all" && event.status !== options.status) {
|
|
7772
7695
|
return false;
|
|
7773
7696
|
}
|
|
7774
7697
|
if (!search) {
|
|
7775
7698
|
return true;
|
|
7776
7699
|
}
|
|
7777
7700
|
return [
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7701
|
+
event.action,
|
|
7702
|
+
event.reason,
|
|
7703
|
+
event.sessionId,
|
|
7704
|
+
event.status,
|
|
7705
|
+
event.target,
|
|
7706
|
+
...event.deliveries.flatMap((delivery) => [
|
|
7707
|
+
delivery.adapterId,
|
|
7708
|
+
delivery.adapterKind,
|
|
7709
|
+
delivery.deliveredTo,
|
|
7710
|
+
delivery.error,
|
|
7711
|
+
delivery.status
|
|
7712
|
+
])
|
|
7782
7713
|
].some((value) => value?.toLowerCase().includes(search));
|
|
7783
|
-
}).sort((left, right) =>
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
`<div><dt>Transcripts</dt><dd>${String(session.transcriptCount)}</dd></div>`,
|
|
7797
|
-
`<div><dt>Errors</dt><dd>${String(session.errorCount)}</dd></div>`,
|
|
7798
|
-
"</dl>",
|
|
7799
|
-
session.latestOutcome ? `<p>Outcome: ${escapeHtml7(session.latestOutcome)}</p>` : "",
|
|
7800
|
-
session.providers.length ? `<p>Providers: ${session.providers.map(escapeHtml7).join(", ")}</p>` : "",
|
|
7801
|
-
session.replayHref ? `<p><a href="${escapeHtml7(session.replayHref)}">Open replay</a></p>` : "",
|
|
7802
|
-
"</article>"
|
|
7803
|
-
].join("")),
|
|
7804
|
-
"</div>"
|
|
7805
|
-
].join("");
|
|
7806
|
-
var createVoiceSessionsJSONHandler = (options = {}) => async ({ query }) => summarizeVoiceSessions({
|
|
7807
|
-
...options,
|
|
7808
|
-
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
7809
|
-
provider: query?.provider ?? options.provider,
|
|
7810
|
-
q: query?.q ?? options.q,
|
|
7811
|
-
status: query?.status === "failed" || query?.status === "healthy" || query?.status === "all" ? query.status : options.status
|
|
7812
|
-
});
|
|
7813
|
-
var createVoiceSessionsHTMLHandler = (options = {}) => async ({ query }) => {
|
|
7814
|
-
const sessions = await summarizeVoiceSessions({
|
|
7815
|
-
...options,
|
|
7816
|
-
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
7817
|
-
provider: query?.provider ?? options.provider,
|
|
7818
|
-
q: query?.q ?? options.q,
|
|
7819
|
-
status: query?.status === "failed" || query?.status === "healthy" || query?.status === "all" ? query.status : options.status
|
|
7820
|
-
});
|
|
7821
|
-
const body = await (options.render?.(sessions) ?? renderVoiceSessionsHTML(sessions));
|
|
7822
|
-
return new Response(body, {
|
|
7823
|
-
headers: {
|
|
7824
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
7825
|
-
...options.headers
|
|
7714
|
+
}).sort((left, right) => right.at - left.at).slice(0, options.limit ?? 50);
|
|
7715
|
+
for (const event of events) {
|
|
7716
|
+
byStatus[event.status] += 1;
|
|
7717
|
+
if (event.action) {
|
|
7718
|
+
increment2(byAction, event.action);
|
|
7719
|
+
}
|
|
7720
|
+
for (const delivery of event.deliveries) {
|
|
7721
|
+
byAdapter[delivery.adapterId] ??= {
|
|
7722
|
+
delivered: 0,
|
|
7723
|
+
failed: 0,
|
|
7724
|
+
skipped: 0
|
|
7725
|
+
};
|
|
7726
|
+
byAdapter[delivery.adapterId][delivery.status] += 1;
|
|
7826
7727
|
}
|
|
7827
|
-
});
|
|
7828
|
-
};
|
|
7829
|
-
var createVoiceSessionListRoutes = (options = {}) => {
|
|
7830
|
-
const path = options.path ?? "/api/voice-sessions";
|
|
7831
|
-
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
7832
|
-
const routes = new Elysia5({
|
|
7833
|
-
name: options.name ?? "absolutejs-voice-session-list"
|
|
7834
|
-
}).get(path, createVoiceSessionsJSONHandler(options));
|
|
7835
|
-
if (htmlPath) {
|
|
7836
|
-
routes.get(htmlPath, createVoiceSessionsHTMLHandler(options));
|
|
7837
7728
|
}
|
|
7838
|
-
return
|
|
7729
|
+
return {
|
|
7730
|
+
byAction,
|
|
7731
|
+
byAdapter,
|
|
7732
|
+
byStatus,
|
|
7733
|
+
events,
|
|
7734
|
+
failed: byStatus.failed,
|
|
7735
|
+
total: events.length
|
|
7736
|
+
};
|
|
7839
7737
|
};
|
|
7840
|
-
var
|
|
7738
|
+
var renderMetricGrid = (summary) => [
|
|
7739
|
+
'<section class="voice-handoff-health-grid">',
|
|
7740
|
+
`<article><span>Total</span><strong>${String(summary.total)}</strong></article>`,
|
|
7741
|
+
`<article><span>Delivered</span><strong>${String(summary.byStatus.delivered)}</strong></article>`,
|
|
7742
|
+
`<article><span>Failed</span><strong>${String(summary.byStatus.failed)}</strong></article>`,
|
|
7743
|
+
`<article><span>Skipped</span><strong>${String(summary.byStatus.skipped)}</strong></article>`,
|
|
7744
|
+
"</section>"
|
|
7745
|
+
].join("");
|
|
7746
|
+
var renderActionSummary = (summary) => {
|
|
7747
|
+
const actions = Object.entries(summary.byAction).sort((left, right) => right[1] - left[1]);
|
|
7748
|
+
const adapters = Object.entries(summary.byAdapter).sort(([left], [right]) => left.localeCompare(right));
|
|
7749
|
+
return [
|
|
7750
|
+
'<section class="voice-handoff-health-columns">',
|
|
7751
|
+
"<article><h3>Actions</h3>",
|
|
7752
|
+
actions.length === 0 ? "<p>No handoff actions yet.</p>" : `<ul>${actions.map(([action, count]) => `<li>${escapeHtml7(action)}: ${String(count)}</li>`).join("")}</ul>`,
|
|
7753
|
+
"</article>",
|
|
7754
|
+
"<article><h3>Adapters</h3>",
|
|
7755
|
+
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>`,
|
|
7756
|
+
"</article>",
|
|
7757
|
+
"</section>"
|
|
7758
|
+
].join("");
|
|
7759
|
+
};
|
|
7760
|
+
var renderVoiceHandoffHealthHTML = (summary) => [
|
|
7761
|
+
'<div class="voice-handoff-health">',
|
|
7762
|
+
renderMetricGrid(summary),
|
|
7763
|
+
renderActionSummary(summary),
|
|
7764
|
+
"<section>",
|
|
7765
|
+
"<h3>Recent Handoffs</h3>",
|
|
7766
|
+
summary.events.length === 0 ? '<p class="voice-handoff-health-empty">No handoffs found.</p>' : [
|
|
7767
|
+
'<div class="voice-handoff-health-events">',
|
|
7768
|
+
...summary.events.map((event) => [
|
|
7769
|
+
`<article class="${escapeHtml7(event.status)}">`,
|
|
7770
|
+
'<div class="voice-handoff-health-event-header">',
|
|
7771
|
+
`<strong>${escapeHtml7(event.action ?? "handoff")}</strong>`,
|
|
7772
|
+
`<span>${escapeHtml7(event.status)}</span>`,
|
|
7773
|
+
"</div>",
|
|
7774
|
+
`<p><small>${escapeHtml7(event.sessionId)}</small></p>`,
|
|
7775
|
+
event.target ? `<p>Target: ${escapeHtml7(event.target)}</p>` : "",
|
|
7776
|
+
event.reason ? `<p>Reason: ${escapeHtml7(event.reason)}</p>` : "",
|
|
7777
|
+
event.deliveries.length ? `<ul>${event.deliveries.map((delivery) => [
|
|
7778
|
+
"<li>",
|
|
7779
|
+
`${escapeHtml7(delivery.adapterId)}: ${escapeHtml7(delivery.status)}`,
|
|
7780
|
+
delivery.deliveredTo ? ` to ${escapeHtml7(delivery.deliveredTo)}` : "",
|
|
7781
|
+
delivery.error ? ` (${escapeHtml7(delivery.error)})` : "",
|
|
7782
|
+
"</li>"
|
|
7783
|
+
].join("")).join("")}</ul>` : "",
|
|
7784
|
+
event.replayHref ? `<p><a href="${escapeHtml7(event.replayHref)}">Open replay</a></p>` : "",
|
|
7785
|
+
"</article>"
|
|
7786
|
+
].join("")),
|
|
7787
|
+
"</div>"
|
|
7788
|
+
].join(""),
|
|
7789
|
+
"</section>",
|
|
7790
|
+
"</div>"
|
|
7791
|
+
].join("");
|
|
7792
|
+
var createVoiceHandoffHealthJSONHandler = (options = {}) => async ({ query }) => summarizeVoiceHandoffHealth({
|
|
7841
7793
|
...options,
|
|
7842
|
-
|
|
7794
|
+
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
7795
|
+
q: query?.q ?? options.q,
|
|
7796
|
+
status: query?.status === "delivered" || query?.status === "failed" || query?.status === "skipped" || query?.status === "all" ? query.status : options.status
|
|
7843
7797
|
});
|
|
7844
|
-
var
|
|
7845
|
-
const
|
|
7798
|
+
var createVoiceHandoffHealthHTMLHandler = (options = {}) => async ({ query }) => {
|
|
7799
|
+
const summary = await summarizeVoiceHandoffHealth({
|
|
7846
7800
|
...options,
|
|
7847
|
-
|
|
7801
|
+
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
7802
|
+
q: query?.q ?? options.q,
|
|
7803
|
+
status: query?.status === "delivered" || query?.status === "failed" || query?.status === "skipped" || query?.status === "all" ? query.status : options.status
|
|
7848
7804
|
});
|
|
7849
|
-
const body = await (options.render?.(
|
|
7805
|
+
const body = await (options.render?.(summary) ?? renderVoiceHandoffHealthHTML(summary));
|
|
7850
7806
|
return new Response(body, {
|
|
7851
7807
|
headers: {
|
|
7852
7808
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -7854,226 +7810,555 @@ var createVoiceSessionReplayHTMLHandler = (options) => async ({ params }) => {
|
|
|
7854
7810
|
}
|
|
7855
7811
|
});
|
|
7856
7812
|
};
|
|
7857
|
-
var
|
|
7858
|
-
const path = options.path ?? "/api/voice-
|
|
7813
|
+
var createVoiceHandoffHealthRoutes = (options = {}) => {
|
|
7814
|
+
const path = options.path ?? "/api/voice-handoffs";
|
|
7859
7815
|
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
7860
7816
|
const routes = new Elysia5({
|
|
7861
|
-
name: options.name ?? "absolutejs-voice-
|
|
7862
|
-
}).get(path,
|
|
7817
|
+
name: options.name ?? "absolutejs-voice-handoff-health"
|
|
7818
|
+
}).get(path, createVoiceHandoffHealthJSONHandler(options));
|
|
7863
7819
|
if (htmlPath) {
|
|
7864
|
-
routes.get(htmlPath,
|
|
7820
|
+
routes.get(htmlPath, createVoiceHandoffHealthHTMLHandler(options));
|
|
7865
7821
|
}
|
|
7866
7822
|
return routes;
|
|
7867
7823
|
};
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
return [];
|
|
7880
|
-
}
|
|
7881
|
-
throw error;
|
|
7882
|
-
}
|
|
7883
|
-
};
|
|
7884
|
-
var encodeStoreId = (id) => `${encodeURIComponent(id)}.json`;
|
|
7885
|
-
var resolveFilePath = (directory, id) => join(directory, encodeStoreId(id));
|
|
7886
|
-
var createMemoryStoreId = (input) => `${input.assistantId}:${input.namespace}:${input.key}`;
|
|
7887
|
-
var readJsonFile = async (path) => JSON.parse(await readFile(path, "utf8"));
|
|
7888
|
-
var writeJsonFile = async (path, value, options) => {
|
|
7889
|
-
await mkdir(options.directory, {
|
|
7890
|
-
recursive: true
|
|
7891
|
-
});
|
|
7892
|
-
const tempPath = `${path}.${crypto.randomUUID()}.tmp`;
|
|
7893
|
-
await writeFile(tempPath, JSON.stringify(value, null, options.pretty === false ? undefined : 2));
|
|
7894
|
-
await rename(tempPath, path);
|
|
7824
|
+
|
|
7825
|
+
// src/qualityRoutes.ts
|
|
7826
|
+
var DEFAULT_THRESHOLDS = {
|
|
7827
|
+
maxDuplicateTurnRate: 0,
|
|
7828
|
+
maxEmptyTurnRate: 0.02,
|
|
7829
|
+
maxHandoffFailureRate: 0,
|
|
7830
|
+
maxMissingAssistantReplyRate: 0.05,
|
|
7831
|
+
maxProviderAverageLatencyMs: 3000,
|
|
7832
|
+
maxProviderErrorRate: 0.05,
|
|
7833
|
+
maxProviderFallbackRate: 0.25,
|
|
7834
|
+
maxProviderTimeoutRate: 0.03
|
|
7895
7835
|
};
|
|
7896
|
-
var
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7836
|
+
var getString5 = (value) => typeof value === "string" ? value : undefined;
|
|
7837
|
+
var getNumber3 = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
7838
|
+
var rate = (count, total) => count / Math.max(1, total);
|
|
7839
|
+
var roundMetric2 = (value) => Math.round(value * 1e4) / 1e4;
|
|
7840
|
+
var createMetric = (input) => ({
|
|
7841
|
+
...input,
|
|
7842
|
+
actual: roundMetric2(input.actual),
|
|
7843
|
+
pass: input.actual <= input.threshold
|
|
7844
|
+
});
|
|
7845
|
+
var evaluateVoiceQuality = async (input) => {
|
|
7846
|
+
const events = filterVoiceTraceEvents(input.events ?? await input.store?.list() ?? []);
|
|
7847
|
+
const thresholds = {
|
|
7848
|
+
...DEFAULT_THRESHOLDS,
|
|
7849
|
+
...input.thresholds
|
|
7907
7850
|
};
|
|
7908
|
-
const
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7851
|
+
const committedTurns = events.filter((event) => event.type === "turn.committed");
|
|
7852
|
+
const assistantReplies = events.filter((event) => event.type === "turn.assistant");
|
|
7853
|
+
const sessionIdsWithAssistantReply = new Set(assistantReplies.map((event) => event.sessionId));
|
|
7854
|
+
const sessionsWithTurns = new Set(committedTurns.map((event) => event.sessionId));
|
|
7855
|
+
const emptyTurns = committedTurns.filter((event) => !getString5(event.payload.text)?.trim());
|
|
7856
|
+
const turnTextsBySession = new Map;
|
|
7857
|
+
let duplicateTurns = 0;
|
|
7858
|
+
for (const turn of committedTurns) {
|
|
7859
|
+
const normalized = getString5(turn.payload.text)?.trim().toLowerCase();
|
|
7860
|
+
if (!normalized) {
|
|
7861
|
+
continue;
|
|
7912
7862
|
}
|
|
7913
|
-
const
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
};
|
|
7917
|
-
const set = async (id, value) => {
|
|
7918
|
-
await writeJsonFile(resolveFilePath(options.directory, id), value, options);
|
|
7919
|
-
};
|
|
7920
|
-
const list = async () => {
|
|
7921
|
-
const files = await listJsonFiles(options.directory);
|
|
7922
|
-
const sessions = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
7923
|
-
return sessions.map((session) => toVoiceSessionSummary(session)).sort((first, second) => (second.lastActivityAt ?? second.createdAt) - (first.lastActivityAt ?? first.createdAt));
|
|
7924
|
-
};
|
|
7925
|
-
const remove = async (id) => {
|
|
7926
|
-
await rm(resolveFilePath(options.directory, id), {
|
|
7927
|
-
force: true
|
|
7928
|
-
});
|
|
7929
|
-
};
|
|
7930
|
-
return { get, getOrCreate, list, remove, set };
|
|
7931
|
-
};
|
|
7932
|
-
var createVoiceFileReviewStore = (options) => {
|
|
7933
|
-
const get = async (id) => {
|
|
7934
|
-
const path = resolveFilePath(options.directory, id);
|
|
7935
|
-
try {
|
|
7936
|
-
return await readJsonFile(path);
|
|
7937
|
-
} catch (error) {
|
|
7938
|
-
if (error.code === "ENOENT") {
|
|
7939
|
-
return;
|
|
7940
|
-
}
|
|
7941
|
-
throw error;
|
|
7863
|
+
const seen = turnTextsBySession.get(turn.sessionId) ?? new Set;
|
|
7864
|
+
if (seen.has(normalized)) {
|
|
7865
|
+
duplicateTurns += 1;
|
|
7942
7866
|
}
|
|
7867
|
+
seen.add(normalized);
|
|
7868
|
+
turnTextsBySession.set(turn.sessionId, seen);
|
|
7869
|
+
}
|
|
7870
|
+
const missingAssistantReplySessions = [...sessionsWithTurns].filter((sessionId) => !sessionIdsWithAssistantReply.has(sessionId)).length;
|
|
7871
|
+
const providerEvents = events.filter((event) => event.type === "session.error" && typeof event.payload.provider === "string" && typeof event.payload.providerStatus === "string");
|
|
7872
|
+
const providerErrors = providerEvents.filter((event) => event.payload.providerStatus === "error");
|
|
7873
|
+
const providerFallbacks = providerEvents.filter((event) => event.payload.providerStatus === "fallback");
|
|
7874
|
+
const providerTimeouts = providerEvents.filter((event) => event.payload.timedOut === true);
|
|
7875
|
+
const providerLatencies = providerEvents.map((event) => getNumber3(event.payload.elapsedMs)).filter((value) => value !== undefined);
|
|
7876
|
+
const averageProviderLatencyMs = providerLatencies.length > 0 ? providerLatencies.reduce((sum, value) => sum + value, 0) / providerLatencies.length : 0;
|
|
7877
|
+
const handoffHealth = await summarizeVoiceHandoffHealth({ events });
|
|
7878
|
+
const metrics = {
|
|
7879
|
+
duplicateTurnRate: createMetric({
|
|
7880
|
+
actual: rate(duplicateTurns, committedTurns.length),
|
|
7881
|
+
label: "Duplicate turn rate",
|
|
7882
|
+
threshold: thresholds.maxDuplicateTurnRate,
|
|
7883
|
+
unit: "rate"
|
|
7884
|
+
}),
|
|
7885
|
+
emptyTurnRate: createMetric({
|
|
7886
|
+
actual: rate(emptyTurns.length, committedTurns.length),
|
|
7887
|
+
label: "Empty turn rate",
|
|
7888
|
+
threshold: thresholds.maxEmptyTurnRate,
|
|
7889
|
+
unit: "rate"
|
|
7890
|
+
}),
|
|
7891
|
+
handoffFailureRate: createMetric({
|
|
7892
|
+
actual: rate(handoffHealth.failed, handoffHealth.total),
|
|
7893
|
+
label: "Handoff failure rate",
|
|
7894
|
+
threshold: thresholds.maxHandoffFailureRate,
|
|
7895
|
+
unit: "rate"
|
|
7896
|
+
}),
|
|
7897
|
+
missingAssistantReplyRate: createMetric({
|
|
7898
|
+
actual: rate(missingAssistantReplySessions, sessionsWithTurns.size),
|
|
7899
|
+
label: "Missing assistant reply rate",
|
|
7900
|
+
threshold: thresholds.maxMissingAssistantReplyRate,
|
|
7901
|
+
unit: "rate"
|
|
7902
|
+
}),
|
|
7903
|
+
providerAverageLatencyMs: createMetric({
|
|
7904
|
+
actual: averageProviderLatencyMs,
|
|
7905
|
+
label: "Average provider latency",
|
|
7906
|
+
threshold: thresholds.maxProviderAverageLatencyMs,
|
|
7907
|
+
unit: "ms"
|
|
7908
|
+
}),
|
|
7909
|
+
providerErrorRate: createMetric({
|
|
7910
|
+
actual: rate(providerErrors.length, providerEvents.length),
|
|
7911
|
+
label: "Provider error rate",
|
|
7912
|
+
threshold: thresholds.maxProviderErrorRate,
|
|
7913
|
+
unit: "rate"
|
|
7914
|
+
}),
|
|
7915
|
+
providerFallbackRate: createMetric({
|
|
7916
|
+
actual: rate(providerFallbacks.length, providerEvents.length),
|
|
7917
|
+
label: "Provider fallback rate",
|
|
7918
|
+
threshold: thresholds.maxProviderFallbackRate,
|
|
7919
|
+
unit: "rate"
|
|
7920
|
+
}),
|
|
7921
|
+
providerTimeoutRate: createMetric({
|
|
7922
|
+
actual: rate(providerTimeouts.length, providerEvents.length),
|
|
7923
|
+
label: "Provider timeout rate",
|
|
7924
|
+
threshold: thresholds.maxProviderTimeoutRate,
|
|
7925
|
+
unit: "rate"
|
|
7926
|
+
})
|
|
7943
7927
|
};
|
|
7944
|
-
const
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
};
|
|
7952
|
-
const remove = async (id) => {
|
|
7953
|
-
await rm(resolveFilePath(options.directory, id), {
|
|
7954
|
-
force: true
|
|
7955
|
-
});
|
|
7928
|
+
const status = Object.values(metrics).every((metric) => metric.pass) ? "pass" : "fail";
|
|
7929
|
+
return {
|
|
7930
|
+
checkedAt: Date.now(),
|
|
7931
|
+
eventCount: events.length,
|
|
7932
|
+
metrics,
|
|
7933
|
+
status,
|
|
7934
|
+
thresholds
|
|
7956
7935
|
};
|
|
7957
|
-
return { get, list, remove, set };
|
|
7958
7936
|
};
|
|
7959
|
-
var
|
|
7960
|
-
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7937
|
+
var escapeHtml8 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
7938
|
+
var formatMetricValue = (metric) => metric.unit === "rate" ? `${(metric.actual * 100).toFixed(2)}%` : metric.unit === "ms" ? `${Math.round(metric.actual)}ms` : String(metric.actual);
|
|
7939
|
+
var formatThreshold = (metric) => metric.unit === "rate" ? `${(metric.threshold * 100).toFixed(2)}%` : metric.unit === "ms" ? `${Math.round(metric.threshold)}ms` : String(metric.threshold);
|
|
7940
|
+
var renderVoiceQualityHTML = (report, options = {}) => {
|
|
7941
|
+
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("");
|
|
7942
|
+
const links = options.links?.length ? `<nav>${options.links.map((link) => `<a href="${escapeHtml8(link.href)}">${escapeHtml8(link.label)}</a>`).join("")}</nav>` : "";
|
|
7943
|
+
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>`;
|
|
7944
|
+
};
|
|
7945
|
+
var createVoiceQualityRoutes = (options) => {
|
|
7946
|
+
const path = options.path ?? "/quality";
|
|
7947
|
+
const routes = new Elysia6({
|
|
7948
|
+
name: options.name ?? "absolutejs-voice-quality"
|
|
7949
|
+
});
|
|
7950
|
+
const getReport = () => evaluateVoiceQuality({
|
|
7951
|
+
events: options.events,
|
|
7952
|
+
store: options.store,
|
|
7953
|
+
thresholds: options.thresholds
|
|
7954
|
+
});
|
|
7955
|
+
routes.get(path, async () => {
|
|
7956
|
+
const report = await getReport();
|
|
7957
|
+
return new Response(renderVoiceQualityHTML(report, { links: options.links }), {
|
|
7958
|
+
headers: {
|
|
7959
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
7960
|
+
...options.headers
|
|
7967
7961
|
}
|
|
7968
|
-
throw error;
|
|
7969
|
-
}
|
|
7970
|
-
};
|
|
7971
|
-
const list = async () => {
|
|
7972
|
-
const files = await listJsonFiles(options.directory);
|
|
7973
|
-
const tasks = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
7974
|
-
return tasks.sort((left, right) => right.createdAt - left.createdAt);
|
|
7975
|
-
};
|
|
7976
|
-
const set = async (id, task) => {
|
|
7977
|
-
await writeJsonFile(resolveFilePath(options.directory, id), withVoiceOpsTaskId(id, task), options);
|
|
7978
|
-
};
|
|
7979
|
-
const remove = async (id) => {
|
|
7980
|
-
await rm(resolveFilePath(options.directory, id), {
|
|
7981
|
-
force: true
|
|
7982
7962
|
});
|
|
7983
|
-
};
|
|
7984
|
-
|
|
7963
|
+
});
|
|
7964
|
+
routes.get(`${path}/json`, async () => getReport());
|
|
7965
|
+
routes.get(`${path}/status`, async ({ set }) => {
|
|
7966
|
+
const report = await getReport();
|
|
7967
|
+
if (report.status === "fail") {
|
|
7968
|
+
set.status = 503;
|
|
7969
|
+
}
|
|
7970
|
+
return report;
|
|
7971
|
+
});
|
|
7972
|
+
return routes;
|
|
7985
7973
|
};
|
|
7986
|
-
|
|
7987
|
-
|
|
7988
|
-
|
|
7989
|
-
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
|
|
7995
|
-
|
|
7974
|
+
|
|
7975
|
+
// src/evalRoutes.ts
|
|
7976
|
+
var escapeHtml9 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
7977
|
+
var sessionTime = (events) => {
|
|
7978
|
+
const sorted = filterVoiceTraceEvents(events);
|
|
7979
|
+
return {
|
|
7980
|
+
endedAt: sorted.at(-1)?.at,
|
|
7981
|
+
startedAt: sorted[0]?.at
|
|
7982
|
+
};
|
|
7983
|
+
};
|
|
7984
|
+
var bucketKey = (timestamp) => new Date(timestamp).toISOString().slice(0, 10);
|
|
7985
|
+
var buildTrend = (sessions) => {
|
|
7986
|
+
const buckets = new Map;
|
|
7987
|
+
for (const session of sessions) {
|
|
7988
|
+
const endedAt = session.endedAt ?? session.startedAt ?? session.quality.checkedAt;
|
|
7989
|
+
const key = bucketKey(endedAt);
|
|
7990
|
+
const bucket = buckets.get(key) ?? {
|
|
7991
|
+
endedAt,
|
|
7992
|
+
failed: 0,
|
|
7993
|
+
key,
|
|
7994
|
+
passed: 0,
|
|
7995
|
+
total: 0
|
|
7996
|
+
};
|
|
7997
|
+
bucket.endedAt = Math.max(bucket.endedAt, endedAt);
|
|
7998
|
+
bucket.total += 1;
|
|
7999
|
+
if (session.status === "pass") {
|
|
8000
|
+
bucket.passed += 1;
|
|
8001
|
+
} else {
|
|
8002
|
+
bucket.failed += 1;
|
|
7996
8003
|
}
|
|
7997
|
-
|
|
7998
|
-
|
|
7999
|
-
|
|
8000
|
-
|
|
8001
|
-
|
|
8002
|
-
|
|
8003
|
-
const
|
|
8004
|
-
|
|
8005
|
-
|
|
8006
|
-
|
|
8007
|
-
|
|
8008
|
-
|
|
8004
|
+
buckets.set(key, bucket);
|
|
8005
|
+
}
|
|
8006
|
+
return [...buckets.values()].sort((left, right) => right.endedAt - left.endedAt);
|
|
8007
|
+
};
|
|
8008
|
+
var runVoiceSessionEvals = async (options = {}) => {
|
|
8009
|
+
const events = filterVoiceTraceEvents(options.events ?? await options.store?.list() ?? []);
|
|
8010
|
+
const grouped = new Map;
|
|
8011
|
+
for (const event of events) {
|
|
8012
|
+
grouped.set(event.sessionId, [...grouped.get(event.sessionId) ?? [], event]);
|
|
8013
|
+
}
|
|
8014
|
+
const sessions = await Promise.all([...grouped.entries()].map(async ([sessionId, sessionEvents]) => {
|
|
8015
|
+
const sorted = filterVoiceTraceEvents(sessionEvents);
|
|
8016
|
+
const quality = await evaluateVoiceQuality({
|
|
8017
|
+
events: sorted,
|
|
8018
|
+
thresholds: options.thresholds
|
|
8009
8019
|
});
|
|
8020
|
+
const { endedAt, startedAt } = sessionTime(sorted);
|
|
8021
|
+
const summary = summarizeVoiceTrace(sorted);
|
|
8022
|
+
const scenarioId = sorted.find((event) => event.scenarioId)?.scenarioId;
|
|
8023
|
+
return {
|
|
8024
|
+
endedAt,
|
|
8025
|
+
eventCount: sorted.length,
|
|
8026
|
+
quality,
|
|
8027
|
+
scenarioId,
|
|
8028
|
+
sessionId,
|
|
8029
|
+
startedAt,
|
|
8030
|
+
status: quality.status,
|
|
8031
|
+
summary
|
|
8032
|
+
};
|
|
8033
|
+
}));
|
|
8034
|
+
const limitedSessions = sessions.sort((left, right) => (right.endedAt ?? right.startedAt ?? 0) - (left.endedAt ?? left.startedAt ?? 0)).slice(0, options.limit ?? 100);
|
|
8035
|
+
const failed = limitedSessions.filter((session) => session.status === "fail").length;
|
|
8036
|
+
const passed = limitedSessions.length - failed;
|
|
8037
|
+
return {
|
|
8038
|
+
checkedAt: Date.now(),
|
|
8039
|
+
failed,
|
|
8040
|
+
passed,
|
|
8041
|
+
sessions: limitedSessions,
|
|
8042
|
+
status: failed > 0 ? "fail" : "pass",
|
|
8043
|
+
total: limitedSessions.length,
|
|
8044
|
+
trend: buildTrend(limitedSessions)
|
|
8010
8045
|
};
|
|
8011
|
-
return { get, list, remove, set };
|
|
8012
8046
|
};
|
|
8013
|
-
var
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
8047
|
+
var formatTime = (value) => value === undefined ? "unknown" : new Date(value).toLocaleString();
|
|
8048
|
+
var renderVoiceEvalHTML = (report, options = {}) => {
|
|
8049
|
+
const title = options.title ?? "AbsoluteJS Voice Evals";
|
|
8050
|
+
const links = options.links?.length ? `<nav>${options.links.map((link) => `<a href="${escapeHtml9(link.href)}">${escapeHtml9(link.label)}</a>`).join("")}</nav>` : "";
|
|
8051
|
+
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>';
|
|
8052
|
+
const sessions = report.sessions.length ? report.sessions.map((session) => {
|
|
8053
|
+
const failedMetrics = Object.entries(session.quality.metrics).filter(([, metric]) => !metric.pass).map(([, metric]) => metric.label).join(", ");
|
|
8054
|
+
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>`;
|
|
8055
|
+
}).join("") : '<tr><td colspan="7">No sessions found.</td></tr>';
|
|
8056
|
+
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>`;
|
|
8057
|
+
};
|
|
8058
|
+
var createVoiceEvalRoutes = (options) => {
|
|
8059
|
+
const path = options.path ?? "/evals";
|
|
8060
|
+
const routes = new Elysia7({
|
|
8061
|
+
name: options.name ?? "absolutejs-voice-evals"
|
|
8062
|
+
});
|
|
8063
|
+
const getReport = () => runVoiceSessionEvals({
|
|
8064
|
+
events: options.events,
|
|
8065
|
+
limit: options.limit,
|
|
8066
|
+
store: options.store,
|
|
8067
|
+
thresholds: options.thresholds
|
|
8068
|
+
});
|
|
8069
|
+
routes.get(path, async () => {
|
|
8070
|
+
const report = await getReport();
|
|
8071
|
+
return new Response(renderVoiceEvalHTML(report, {
|
|
8072
|
+
links: options.links,
|
|
8073
|
+
title: options.title
|
|
8074
|
+
}), {
|
|
8075
|
+
headers: {
|
|
8076
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
8077
|
+
...options.headers
|
|
8021
8078
|
}
|
|
8022
|
-
throw error;
|
|
8023
|
-
}
|
|
8024
|
-
};
|
|
8025
|
-
const list = async () => {
|
|
8026
|
-
const files = await listJsonFiles(options.directory);
|
|
8027
|
-
const mappings = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8028
|
-
return mappings.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
8029
|
-
};
|
|
8030
|
-
const set = async (id, mapping) => {
|
|
8031
|
-
await writeJsonFile(resolveFilePath(options.directory, id), {
|
|
8032
|
-
...mapping,
|
|
8033
|
-
id
|
|
8034
|
-
}, options);
|
|
8035
|
-
};
|
|
8036
|
-
const remove = async (id) => {
|
|
8037
|
-
await rm(resolveFilePath(options.directory, id), {
|
|
8038
|
-
force: true
|
|
8039
8079
|
});
|
|
8040
|
-
};
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
8080
|
+
});
|
|
8081
|
+
routes.get(`${path}/json`, async () => getReport());
|
|
8082
|
+
routes.get(`${path}/status`, async ({ set }) => {
|
|
8083
|
+
const report = await getReport();
|
|
8084
|
+
if (report.status === "fail") {
|
|
8085
|
+
set.status = 503;
|
|
8086
|
+
}
|
|
8087
|
+
return report;
|
|
8088
|
+
});
|
|
8089
|
+
return routes;
|
|
8046
8090
|
};
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8091
|
+
// src/sessionReplay.ts
|
|
8092
|
+
import { Elysia as Elysia8 } from "elysia";
|
|
8093
|
+
var getString6 = (value) => typeof value === "string" ? value : undefined;
|
|
8094
|
+
var escapeHtml10 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
8095
|
+
var increment3 = (record, key) => {
|
|
8096
|
+
record[key] = (record[key] ?? 0) + 1;
|
|
8097
|
+
};
|
|
8098
|
+
var buildReplayTurns = (events) => {
|
|
8099
|
+
const turns = new Map;
|
|
8100
|
+
const getTurn = (turnId) => {
|
|
8101
|
+
const existing = turns.get(turnId);
|
|
8102
|
+
if (existing) {
|
|
8103
|
+
return existing;
|
|
8104
|
+
}
|
|
8105
|
+
const turn = {
|
|
8106
|
+
assistantReplies: [],
|
|
8107
|
+
errors: [],
|
|
8108
|
+
id: turnId,
|
|
8109
|
+
modelCalls: [],
|
|
8110
|
+
tools: [],
|
|
8111
|
+
transcripts: []
|
|
8112
|
+
};
|
|
8113
|
+
turns.set(turnId, turn);
|
|
8114
|
+
return turn;
|
|
8052
8115
|
};
|
|
8053
|
-
const
|
|
8054
|
-
const
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8116
|
+
for (const event of events) {
|
|
8117
|
+
const turnId = event.turnId ?? "session";
|
|
8118
|
+
const turn = getTurn(turnId);
|
|
8119
|
+
switch (event.type) {
|
|
8120
|
+
case "turn.transcript":
|
|
8121
|
+
turn.transcripts.push({
|
|
8122
|
+
isFinal: event.payload.isFinal === true,
|
|
8123
|
+
text: getString6(event.payload.text)
|
|
8124
|
+
});
|
|
8125
|
+
break;
|
|
8126
|
+
case "turn.committed":
|
|
8127
|
+
turn.committedText = getString6(event.payload.text);
|
|
8128
|
+
break;
|
|
8129
|
+
case "turn.assistant": {
|
|
8130
|
+
const text = getString6(event.payload.text);
|
|
8131
|
+
if (text) {
|
|
8132
|
+
turn.assistantReplies.push(text);
|
|
8133
|
+
}
|
|
8134
|
+
break;
|
|
8060
8135
|
}
|
|
8061
|
-
|
|
8136
|
+
case "agent.model":
|
|
8137
|
+
case "assistant.run":
|
|
8138
|
+
turn.modelCalls.push(event.payload);
|
|
8139
|
+
break;
|
|
8140
|
+
case "agent.tool":
|
|
8141
|
+
turn.tools.push(event.payload);
|
|
8142
|
+
break;
|
|
8143
|
+
case "session.error":
|
|
8144
|
+
turn.errors.push(event.payload);
|
|
8145
|
+
break;
|
|
8062
8146
|
}
|
|
8147
|
+
}
|
|
8148
|
+
return [...turns.values()];
|
|
8149
|
+
};
|
|
8150
|
+
var summarizeVoiceSessionReplay = async (options) => {
|
|
8151
|
+
const sourceEvents = options.events ?? await options.store?.list({ sessionId: options.sessionId }) ?? [];
|
|
8152
|
+
const events = filterVoiceTraceEvents(sourceEvents, {
|
|
8153
|
+
sessionId: options.sessionId
|
|
8154
|
+
});
|
|
8155
|
+
const replay = buildVoiceTraceReplay(events, {
|
|
8156
|
+
evaluation: options.evaluation,
|
|
8157
|
+
redact: options.redact,
|
|
8158
|
+
title: options.title ?? `Voice Session ${options.sessionId}`
|
|
8159
|
+
});
|
|
8160
|
+
const startedAt = replay.summary.startedAt;
|
|
8161
|
+
return {
|
|
8162
|
+
evaluation: replay.evaluation,
|
|
8163
|
+
events,
|
|
8164
|
+
html: replay.html,
|
|
8165
|
+
markdown: replay.markdown,
|
|
8166
|
+
sessionId: options.sessionId,
|
|
8167
|
+
summary: replay.summary,
|
|
8168
|
+
timeline: events.map((event) => ({
|
|
8169
|
+
at: event.at,
|
|
8170
|
+
offsetMs: startedAt === undefined ? undefined : Math.max(0, event.at - startedAt),
|
|
8171
|
+
payload: event.payload,
|
|
8172
|
+
turnId: event.turnId,
|
|
8173
|
+
type: event.type
|
|
8174
|
+
})),
|
|
8175
|
+
turns: buildReplayTurns(events)
|
|
8063
8176
|
};
|
|
8064
|
-
const list = async (filter = {}) => {
|
|
8065
|
-
const files = await listJsonFiles(options.directory);
|
|
8066
|
-
const events = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8067
|
-
return filterVoiceTraceEvents(events, filter);
|
|
8068
|
-
};
|
|
8069
|
-
const remove = async (id) => {
|
|
8070
|
-
await rm(resolveFilePath(options.directory, id), {
|
|
8071
|
-
force: true
|
|
8072
|
-
});
|
|
8073
|
-
};
|
|
8074
|
-
return { append, get, list, remove };
|
|
8075
8177
|
};
|
|
8076
|
-
var
|
|
8178
|
+
var summarizeVoiceSessions = async (options = {}) => {
|
|
8179
|
+
const events = options.events ?? await options.store?.list() ?? [];
|
|
8180
|
+
const grouped = new Map;
|
|
8181
|
+
for (const event of events) {
|
|
8182
|
+
grouped.set(event.sessionId, [...grouped.get(event.sessionId) ?? [], event]);
|
|
8183
|
+
}
|
|
8184
|
+
const sessions = [...grouped.entries()].map(([sessionId, sessionEvents]) => {
|
|
8185
|
+
const sorted = filterVoiceTraceEvents(sessionEvents);
|
|
8186
|
+
const summary = buildVoiceTraceReplay(sorted, {
|
|
8187
|
+
evaluation: {
|
|
8188
|
+
requireAssistantReply: false,
|
|
8189
|
+
requireCompletedCall: false,
|
|
8190
|
+
requireTranscript: false,
|
|
8191
|
+
requireTurn: false
|
|
8192
|
+
}
|
|
8193
|
+
}).summary;
|
|
8194
|
+
const providerErrors = {};
|
|
8195
|
+
const providers = new Set;
|
|
8196
|
+
let latestOutcome;
|
|
8197
|
+
let errorCount = 0;
|
|
8198
|
+
for (const event of sorted) {
|
|
8199
|
+
const provider = getString6(event.payload.provider);
|
|
8200
|
+
if (provider) {
|
|
8201
|
+
providers.add(provider);
|
|
8202
|
+
}
|
|
8203
|
+
if (event.type === "session.error" && (event.payload.providerStatus === "error" || typeof event.payload.error === "string")) {
|
|
8204
|
+
errorCount += 1;
|
|
8205
|
+
increment3(providerErrors, provider ?? "unknown");
|
|
8206
|
+
}
|
|
8207
|
+
const outcome = getString6(event.payload.outcome);
|
|
8208
|
+
if (outcome) {
|
|
8209
|
+
latestOutcome = outcome;
|
|
8210
|
+
}
|
|
8211
|
+
}
|
|
8212
|
+
const item = {
|
|
8213
|
+
endedAt: summary.endedAt,
|
|
8214
|
+
errorCount,
|
|
8215
|
+
eventCount: summary.eventCount,
|
|
8216
|
+
latestOutcome,
|
|
8217
|
+
providerErrors,
|
|
8218
|
+
providers: [...providers].sort(),
|
|
8219
|
+
sessionId,
|
|
8220
|
+
startedAt: summary.startedAt,
|
|
8221
|
+
status: errorCount > 0 ? "failed" : "healthy",
|
|
8222
|
+
transcriptCount: summary.transcriptCount,
|
|
8223
|
+
turnCount: summary.turnCount
|
|
8224
|
+
};
|
|
8225
|
+
const replayHref = options.replayHref === false ? "" : typeof options.replayHref === "function" ? options.replayHref(item) : `${options.replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(sessionId)}/replay/htmx`;
|
|
8226
|
+
return {
|
|
8227
|
+
...item,
|
|
8228
|
+
replayHref
|
|
8229
|
+
};
|
|
8230
|
+
});
|
|
8231
|
+
const search = options.q?.trim().toLowerCase();
|
|
8232
|
+
return sessions.filter((session) => {
|
|
8233
|
+
if (options.status && options.status !== "all" && session.status !== options.status) {
|
|
8234
|
+
return false;
|
|
8235
|
+
}
|
|
8236
|
+
if (options.provider && !session.providers.includes(options.provider)) {
|
|
8237
|
+
return false;
|
|
8238
|
+
}
|
|
8239
|
+
if (!search) {
|
|
8240
|
+
return true;
|
|
8241
|
+
}
|
|
8242
|
+
return [
|
|
8243
|
+
session.sessionId,
|
|
8244
|
+
session.latestOutcome,
|
|
8245
|
+
session.status,
|
|
8246
|
+
...session.providers
|
|
8247
|
+
].some((value) => value?.toLowerCase().includes(search));
|
|
8248
|
+
}).sort((left, right) => (right.endedAt ?? right.startedAt ?? 0) - (left.endedAt ?? left.startedAt ?? 0)).slice(0, options.limit ?? 50);
|
|
8249
|
+
};
|
|
8250
|
+
var renderVoiceSessionsHTML = (sessions) => sessions.length === 0 ? '<p class="voice-sessions-empty">No voice sessions found.</p>' : [
|
|
8251
|
+
'<div class="voice-sessions-list">',
|
|
8252
|
+
...sessions.map((session) => [
|
|
8253
|
+
`<article class="voice-session-card ${escapeHtml10(session.status)}">`,
|
|
8254
|
+
'<div class="voice-session-card-header">',
|
|
8255
|
+
`<strong>${escapeHtml10(session.sessionId)}</strong>`,
|
|
8256
|
+
`<span>${escapeHtml10(session.status)}</span>`,
|
|
8257
|
+
"</div>",
|
|
8258
|
+
"<dl>",
|
|
8259
|
+
`<div><dt>Events</dt><dd>${String(session.eventCount)}</dd></div>`,
|
|
8260
|
+
`<div><dt>Turns</dt><dd>${String(session.turnCount)}</dd></div>`,
|
|
8261
|
+
`<div><dt>Transcripts</dt><dd>${String(session.transcriptCount)}</dd></div>`,
|
|
8262
|
+
`<div><dt>Errors</dt><dd>${String(session.errorCount)}</dd></div>`,
|
|
8263
|
+
"</dl>",
|
|
8264
|
+
session.latestOutcome ? `<p>Outcome: ${escapeHtml10(session.latestOutcome)}</p>` : "",
|
|
8265
|
+
session.providers.length ? `<p>Providers: ${session.providers.map(escapeHtml10).join(", ")}</p>` : "",
|
|
8266
|
+
session.replayHref ? `<p><a href="${escapeHtml10(session.replayHref)}">Open replay</a></p>` : "",
|
|
8267
|
+
"</article>"
|
|
8268
|
+
].join("")),
|
|
8269
|
+
"</div>"
|
|
8270
|
+
].join("");
|
|
8271
|
+
var createVoiceSessionsJSONHandler = (options = {}) => async ({ query }) => summarizeVoiceSessions({
|
|
8272
|
+
...options,
|
|
8273
|
+
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
8274
|
+
provider: query?.provider ?? options.provider,
|
|
8275
|
+
q: query?.q ?? options.q,
|
|
8276
|
+
status: query?.status === "failed" || query?.status === "healthy" || query?.status === "all" ? query.status : options.status
|
|
8277
|
+
});
|
|
8278
|
+
var createVoiceSessionsHTMLHandler = (options = {}) => async ({ query }) => {
|
|
8279
|
+
const sessions = await summarizeVoiceSessions({
|
|
8280
|
+
...options,
|
|
8281
|
+
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
8282
|
+
provider: query?.provider ?? options.provider,
|
|
8283
|
+
q: query?.q ?? options.q,
|
|
8284
|
+
status: query?.status === "failed" || query?.status === "healthy" || query?.status === "all" ? query.status : options.status
|
|
8285
|
+
});
|
|
8286
|
+
const body = await (options.render?.(sessions) ?? renderVoiceSessionsHTML(sessions));
|
|
8287
|
+
return new Response(body, {
|
|
8288
|
+
headers: {
|
|
8289
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
8290
|
+
...options.headers
|
|
8291
|
+
}
|
|
8292
|
+
});
|
|
8293
|
+
};
|
|
8294
|
+
var createVoiceSessionListRoutes = (options = {}) => {
|
|
8295
|
+
const path = options.path ?? "/api/voice-sessions";
|
|
8296
|
+
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
8297
|
+
const routes = new Elysia8({
|
|
8298
|
+
name: options.name ?? "absolutejs-voice-session-list"
|
|
8299
|
+
}).get(path, createVoiceSessionsJSONHandler(options));
|
|
8300
|
+
if (htmlPath) {
|
|
8301
|
+
routes.get(htmlPath, createVoiceSessionsHTMLHandler(options));
|
|
8302
|
+
}
|
|
8303
|
+
return routes;
|
|
8304
|
+
};
|
|
8305
|
+
var createVoiceSessionReplayJSONHandler = (options) => async ({ params }) => summarizeVoiceSessionReplay({
|
|
8306
|
+
...options,
|
|
8307
|
+
sessionId: params.sessionId ?? ""
|
|
8308
|
+
});
|
|
8309
|
+
var createVoiceSessionReplayHTMLHandler = (options) => async ({ params }) => {
|
|
8310
|
+
const replay = await summarizeVoiceSessionReplay({
|
|
8311
|
+
...options,
|
|
8312
|
+
sessionId: params.sessionId ?? ""
|
|
8313
|
+
});
|
|
8314
|
+
const body = await (options.render?.(replay) ?? replay.html);
|
|
8315
|
+
return new Response(body, {
|
|
8316
|
+
headers: {
|
|
8317
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
8318
|
+
...options.headers
|
|
8319
|
+
}
|
|
8320
|
+
});
|
|
8321
|
+
};
|
|
8322
|
+
var createVoiceSessionReplayRoutes = (options) => {
|
|
8323
|
+
const path = options.path ?? "/api/voice-sessions/:sessionId/replay";
|
|
8324
|
+
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
8325
|
+
const routes = new Elysia8({
|
|
8326
|
+
name: options.name ?? "absolutejs-voice-session-replay"
|
|
8327
|
+
}).get(path, createVoiceSessionReplayJSONHandler(options));
|
|
8328
|
+
if (htmlPath) {
|
|
8329
|
+
routes.get(htmlPath, createVoiceSessionReplayHTMLHandler(options));
|
|
8330
|
+
}
|
|
8331
|
+
return routes;
|
|
8332
|
+
};
|
|
8333
|
+
// src/fileStore.ts
|
|
8334
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "fs/promises";
|
|
8335
|
+
import { join } from "path";
|
|
8336
|
+
var listJsonFiles = async (directory) => {
|
|
8337
|
+
try {
|
|
8338
|
+
const entries = await readdir(directory, {
|
|
8339
|
+
withFileTypes: true
|
|
8340
|
+
});
|
|
8341
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => join(directory, entry.name));
|
|
8342
|
+
} catch (error) {
|
|
8343
|
+
if (error.code === "ENOENT") {
|
|
8344
|
+
return [];
|
|
8345
|
+
}
|
|
8346
|
+
throw error;
|
|
8347
|
+
}
|
|
8348
|
+
};
|
|
8349
|
+
var encodeStoreId = (id) => `${encodeURIComponent(id)}.json`;
|
|
8350
|
+
var resolveFilePath = (directory, id) => join(directory, encodeStoreId(id));
|
|
8351
|
+
var createMemoryStoreId = (input) => `${input.assistantId}:${input.namespace}:${input.key}`;
|
|
8352
|
+
var readJsonFile = async (path) => JSON.parse(await readFile(path, "utf8"));
|
|
8353
|
+
var writeJsonFile = async (path, value, options) => {
|
|
8354
|
+
await mkdir(options.directory, {
|
|
8355
|
+
recursive: true
|
|
8356
|
+
});
|
|
8357
|
+
const tempPath = `${path}.${crypto.randomUUID()}.tmp`;
|
|
8358
|
+
await writeFile(tempPath, JSON.stringify(value, null, options.pretty === false ? undefined : 2));
|
|
8359
|
+
await rename(tempPath, path);
|
|
8360
|
+
};
|
|
8361
|
+
var createVoiceFileSessionStore = (options) => {
|
|
8077
8362
|
const get = async (id) => {
|
|
8078
8363
|
const path = resolveFilePath(options.directory, id);
|
|
8079
8364
|
try {
|
|
@@ -8085,16 +8370,49 @@ var createVoiceFileTraceSinkDeliveryStore = (options) => {
|
|
|
8085
8370
|
throw error;
|
|
8086
8371
|
}
|
|
8087
8372
|
};
|
|
8373
|
+
const getOrCreate = async (id) => {
|
|
8374
|
+
const existing = await get(id);
|
|
8375
|
+
if (existing) {
|
|
8376
|
+
return existing;
|
|
8377
|
+
}
|
|
8378
|
+
const session = createVoiceSessionRecord(id);
|
|
8379
|
+
await writeJsonFile(resolveFilePath(options.directory, id), session, options);
|
|
8380
|
+
return session;
|
|
8381
|
+
};
|
|
8382
|
+
const set = async (id, value) => {
|
|
8383
|
+
await writeJsonFile(resolveFilePath(options.directory, id), value, options);
|
|
8384
|
+
};
|
|
8088
8385
|
const list = async () => {
|
|
8089
8386
|
const files = await listJsonFiles(options.directory);
|
|
8090
|
-
const
|
|
8091
|
-
return
|
|
8387
|
+
const sessions = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8388
|
+
return sessions.map((session) => toVoiceSessionSummary(session)).sort((first, second) => (second.lastActivityAt ?? second.createdAt) - (first.lastActivityAt ?? first.createdAt));
|
|
8092
8389
|
};
|
|
8093
|
-
const
|
|
8094
|
-
await
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8390
|
+
const remove = async (id) => {
|
|
8391
|
+
await rm(resolveFilePath(options.directory, id), {
|
|
8392
|
+
force: true
|
|
8393
|
+
});
|
|
8394
|
+
};
|
|
8395
|
+
return { get, getOrCreate, list, remove, set };
|
|
8396
|
+
};
|
|
8397
|
+
var createVoiceFileReviewStore = (options) => {
|
|
8398
|
+
const get = async (id) => {
|
|
8399
|
+
const path = resolveFilePath(options.directory, id);
|
|
8400
|
+
try {
|
|
8401
|
+
return await readJsonFile(path);
|
|
8402
|
+
} catch (error) {
|
|
8403
|
+
if (error.code === "ENOENT") {
|
|
8404
|
+
return;
|
|
8405
|
+
}
|
|
8406
|
+
throw error;
|
|
8407
|
+
}
|
|
8408
|
+
};
|
|
8409
|
+
const list = async () => {
|
|
8410
|
+
const files = await listJsonFiles(options.directory);
|
|
8411
|
+
const reviews = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8412
|
+
return reviews.sort((left, right) => (right.generatedAt ?? 0) - (left.generatedAt ?? 0));
|
|
8413
|
+
};
|
|
8414
|
+
const set = async (id, artifact) => {
|
|
8415
|
+
await writeJsonFile(resolveFilePath(options.directory, id), withVoiceCallReviewId(id, artifact), options);
|
|
8098
8416
|
};
|
|
8099
8417
|
const remove = async (id) => {
|
|
8100
8418
|
await rm(resolveFilePath(options.directory, id), {
|
|
@@ -8103,9 +8421,9 @@ var createVoiceFileTraceSinkDeliveryStore = (options) => {
|
|
|
8103
8421
|
};
|
|
8104
8422
|
return { get, list, remove, set };
|
|
8105
8423
|
};
|
|
8106
|
-
var
|
|
8107
|
-
const get = async (
|
|
8108
|
-
const path = resolveFilePath(options.directory,
|
|
8424
|
+
var createVoiceFileTaskStore = (options) => {
|
|
8425
|
+
const get = async (id) => {
|
|
8426
|
+
const path = resolveFilePath(options.directory, id);
|
|
8109
8427
|
try {
|
|
8110
8428
|
return await readJsonFile(path);
|
|
8111
8429
|
} catch (error) {
|
|
@@ -8115,32 +8433,179 @@ var createVoiceFileAssistantMemoryStore = (options) => {
|
|
|
8115
8433
|
throw error;
|
|
8116
8434
|
}
|
|
8117
8435
|
};
|
|
8118
|
-
const list = async (
|
|
8436
|
+
const list = async () => {
|
|
8119
8437
|
const files = await listJsonFiles(options.directory);
|
|
8120
|
-
const
|
|
8121
|
-
return
|
|
8438
|
+
const tasks = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8439
|
+
return tasks.sort((left, right) => right.createdAt - left.createdAt);
|
|
8122
8440
|
};
|
|
8123
|
-
const set = async (
|
|
8124
|
-
|
|
8125
|
-
const record = createVoiceAssistantMemoryRecord({
|
|
8126
|
-
...input,
|
|
8127
|
-
createdAt: input.createdAt ?? existing?.createdAt,
|
|
8128
|
-
updatedAt: input.updatedAt
|
|
8129
|
-
});
|
|
8130
|
-
await writeJsonFile(resolveFilePath(options.directory, createMemoryStoreId(record)), record, options);
|
|
8131
|
-
return record;
|
|
8441
|
+
const set = async (id, task) => {
|
|
8442
|
+
await writeJsonFile(resolveFilePath(options.directory, id), withVoiceOpsTaskId(id, task), options);
|
|
8132
8443
|
};
|
|
8133
|
-
const remove = async (
|
|
8134
|
-
await rm(resolveFilePath(options.directory,
|
|
8444
|
+
const remove = async (id) => {
|
|
8445
|
+
await rm(resolveFilePath(options.directory, id), {
|
|
8135
8446
|
force: true
|
|
8136
8447
|
});
|
|
8137
8448
|
};
|
|
8138
|
-
return {
|
|
8449
|
+
return { get, list, remove, set };
|
|
8139
8450
|
};
|
|
8140
|
-
var
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8451
|
+
var createVoiceFileIntegrationEventStore = (options) => {
|
|
8452
|
+
const get = async (id) => {
|
|
8453
|
+
const path = resolveFilePath(options.directory, id);
|
|
8454
|
+
try {
|
|
8455
|
+
return await readJsonFile(path);
|
|
8456
|
+
} catch (error) {
|
|
8457
|
+
if (error.code === "ENOENT") {
|
|
8458
|
+
return;
|
|
8459
|
+
}
|
|
8460
|
+
throw error;
|
|
8461
|
+
}
|
|
8462
|
+
};
|
|
8463
|
+
const list = async () => {
|
|
8464
|
+
const files = await listJsonFiles(options.directory);
|
|
8465
|
+
const events = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8466
|
+
return events.sort((left, right) => right.createdAt - left.createdAt);
|
|
8467
|
+
};
|
|
8468
|
+
const set = async (id, event) => {
|
|
8469
|
+
await writeJsonFile(resolveFilePath(options.directory, id), withVoiceIntegrationEventId(id, event), options);
|
|
8470
|
+
};
|
|
8471
|
+
const remove = async (id) => {
|
|
8472
|
+
await rm(resolveFilePath(options.directory, id), {
|
|
8473
|
+
force: true
|
|
8474
|
+
});
|
|
8475
|
+
};
|
|
8476
|
+
return { get, list, remove, set };
|
|
8477
|
+
};
|
|
8478
|
+
var createVoiceFileExternalObjectMapStore = (options) => {
|
|
8479
|
+
const get = async (id) => {
|
|
8480
|
+
const path = resolveFilePath(options.directory, id);
|
|
8481
|
+
try {
|
|
8482
|
+
return await readJsonFile(path);
|
|
8483
|
+
} catch (error) {
|
|
8484
|
+
if (error.code === "ENOENT") {
|
|
8485
|
+
return;
|
|
8486
|
+
}
|
|
8487
|
+
throw error;
|
|
8488
|
+
}
|
|
8489
|
+
};
|
|
8490
|
+
const list = async () => {
|
|
8491
|
+
const files = await listJsonFiles(options.directory);
|
|
8492
|
+
const mappings = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8493
|
+
return mappings.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
8494
|
+
};
|
|
8495
|
+
const set = async (id, mapping) => {
|
|
8496
|
+
await writeJsonFile(resolveFilePath(options.directory, id), {
|
|
8497
|
+
...mapping,
|
|
8498
|
+
id
|
|
8499
|
+
}, options);
|
|
8500
|
+
};
|
|
8501
|
+
const remove = async (id) => {
|
|
8502
|
+
await rm(resolveFilePath(options.directory, id), {
|
|
8503
|
+
force: true
|
|
8504
|
+
});
|
|
8505
|
+
};
|
|
8506
|
+
const find = async (input) => {
|
|
8507
|
+
const mappings = await list();
|
|
8508
|
+
return mappings.find((mapping) => mapping.provider === input.provider && mapping.sourceId === input.sourceId && (input.sinkId === undefined || mapping.sinkId === input.sinkId) && (input.sourceType === undefined || mapping.sourceType === input.sourceType));
|
|
8509
|
+
};
|
|
8510
|
+
return { find, get, list, remove, set };
|
|
8511
|
+
};
|
|
8512
|
+
var createVoiceFileTraceEventStore = (options) => {
|
|
8513
|
+
const append = async (event) => {
|
|
8514
|
+
const stored = createVoiceTraceEvent(event);
|
|
8515
|
+
await writeJsonFile(resolveFilePath(options.directory, stored.id), stored, options);
|
|
8516
|
+
return stored;
|
|
8517
|
+
};
|
|
8518
|
+
const get = async (id) => {
|
|
8519
|
+
const path = resolveFilePath(options.directory, id);
|
|
8520
|
+
try {
|
|
8521
|
+
return await readJsonFile(path);
|
|
8522
|
+
} catch (error) {
|
|
8523
|
+
if (error.code === "ENOENT") {
|
|
8524
|
+
return;
|
|
8525
|
+
}
|
|
8526
|
+
throw error;
|
|
8527
|
+
}
|
|
8528
|
+
};
|
|
8529
|
+
const list = async (filter = {}) => {
|
|
8530
|
+
const files = await listJsonFiles(options.directory);
|
|
8531
|
+
const events = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8532
|
+
return filterVoiceTraceEvents(events, filter);
|
|
8533
|
+
};
|
|
8534
|
+
const remove = async (id) => {
|
|
8535
|
+
await rm(resolveFilePath(options.directory, id), {
|
|
8536
|
+
force: true
|
|
8537
|
+
});
|
|
8538
|
+
};
|
|
8539
|
+
return { append, get, list, remove };
|
|
8540
|
+
};
|
|
8541
|
+
var createVoiceFileTraceSinkDeliveryStore = (options) => {
|
|
8542
|
+
const get = async (id) => {
|
|
8543
|
+
const path = resolveFilePath(options.directory, id);
|
|
8544
|
+
try {
|
|
8545
|
+
return await readJsonFile(path);
|
|
8546
|
+
} catch (error) {
|
|
8547
|
+
if (error.code === "ENOENT") {
|
|
8548
|
+
return;
|
|
8549
|
+
}
|
|
8550
|
+
throw error;
|
|
8551
|
+
}
|
|
8552
|
+
};
|
|
8553
|
+
const list = async () => {
|
|
8554
|
+
const files = await listJsonFiles(options.directory);
|
|
8555
|
+
const deliveries = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8556
|
+
return deliveries.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id));
|
|
8557
|
+
};
|
|
8558
|
+
const set = async (id, delivery) => {
|
|
8559
|
+
await writeJsonFile(resolveFilePath(options.directory, id), {
|
|
8560
|
+
...delivery,
|
|
8561
|
+
id
|
|
8562
|
+
}, options);
|
|
8563
|
+
};
|
|
8564
|
+
const remove = async (id) => {
|
|
8565
|
+
await rm(resolveFilePath(options.directory, id), {
|
|
8566
|
+
force: true
|
|
8567
|
+
});
|
|
8568
|
+
};
|
|
8569
|
+
return { get, list, remove, set };
|
|
8570
|
+
};
|
|
8571
|
+
var createVoiceFileAssistantMemoryStore = (options) => {
|
|
8572
|
+
const get = async (input) => {
|
|
8573
|
+
const path = resolveFilePath(options.directory, createMemoryStoreId(input));
|
|
8574
|
+
try {
|
|
8575
|
+
return await readJsonFile(path);
|
|
8576
|
+
} catch (error) {
|
|
8577
|
+
if (error.code === "ENOENT") {
|
|
8578
|
+
return;
|
|
8579
|
+
}
|
|
8580
|
+
throw error;
|
|
8581
|
+
}
|
|
8582
|
+
};
|
|
8583
|
+
const list = async (input) => {
|
|
8584
|
+
const files = await listJsonFiles(options.directory);
|
|
8585
|
+
const records = await Promise.all(files.map((file) => readJsonFile(file)));
|
|
8586
|
+
return records.filter((record) => record.assistantId === input.assistantId && (input.namespace === undefined || record.namespace === input.namespace)).sort((left, right) => right.updatedAt - left.updatedAt);
|
|
8587
|
+
};
|
|
8588
|
+
const set = async (input) => {
|
|
8589
|
+
const existing = await get(input);
|
|
8590
|
+
const record = createVoiceAssistantMemoryRecord({
|
|
8591
|
+
...input,
|
|
8592
|
+
createdAt: input.createdAt ?? existing?.createdAt,
|
|
8593
|
+
updatedAt: input.updatedAt
|
|
8594
|
+
});
|
|
8595
|
+
await writeJsonFile(resolveFilePath(options.directory, createMemoryStoreId(record)), record, options);
|
|
8596
|
+
return record;
|
|
8597
|
+
};
|
|
8598
|
+
const remove = async (input) => {
|
|
8599
|
+
await rm(resolveFilePath(options.directory, createMemoryStoreId(input)), {
|
|
8600
|
+
force: true
|
|
8601
|
+
});
|
|
8602
|
+
};
|
|
8603
|
+
return { delete: remove, get, list, set };
|
|
8604
|
+
};
|
|
8605
|
+
var createVoiceFileRuntimeStorage = (options) => ({
|
|
8606
|
+
events: createVoiceFileIntegrationEventStore({
|
|
8607
|
+
...options,
|
|
8608
|
+
directory: join(options.directory, "events")
|
|
8144
8609
|
}),
|
|
8145
8610
|
externalObjects: createVoiceFileExternalObjectMapStore({
|
|
8146
8611
|
...options,
|
|
@@ -8880,467 +9345,125 @@ var createAnthropicVoiceAssistantModel = (options) => {
|
|
|
8880
9345
|
return normalizeRouteOutput(parseJSON(extractAnthropicText(body)));
|
|
8881
9346
|
}
|
|
8882
9347
|
};
|
|
8883
|
-
};
|
|
8884
|
-
var extractGeminiCandidateParts = (response) => {
|
|
8885
|
-
const candidates = Array.isArray(response.candidates) ? response.candidates : [];
|
|
8886
|
-
const first = candidates[0];
|
|
8887
|
-
if (!first || typeof first !== "object") {
|
|
8888
|
-
return [];
|
|
8889
|
-
}
|
|
8890
|
-
const content = first.content;
|
|
8891
|
-
if (!content || typeof content !== "object") {
|
|
8892
|
-
return [];
|
|
8893
|
-
}
|
|
8894
|
-
const parts = content.parts;
|
|
8895
|
-
return Array.isArray(parts) ? parts : [];
|
|
8896
|
-
};
|
|
8897
|
-
var extractGeminiText = (response) => extractGeminiCandidateParts(response).map((part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : "").filter(Boolean).join(`
|
|
8898
|
-
`);
|
|
8899
|
-
var extractGeminiToolCalls = (response) => {
|
|
8900
|
-
const toolCalls = [];
|
|
8901
|
-
for (const part of extractGeminiCandidateParts(response)) {
|
|
8902
|
-
if (!part || typeof part !== "object") {
|
|
8903
|
-
continue;
|
|
8904
|
-
}
|
|
8905
|
-
const functionCall = part.functionCall;
|
|
8906
|
-
if (!functionCall || typeof functionCall !== "object") {
|
|
8907
|
-
continue;
|
|
8908
|
-
}
|
|
8909
|
-
const record = functionCall;
|
|
8910
|
-
if (typeof record.name !== "string") {
|
|
8911
|
-
continue;
|
|
8912
|
-
}
|
|
8913
|
-
toolCalls.push({
|
|
8914
|
-
args: record.args && typeof record.args === "object" ? record.args : {},
|
|
8915
|
-
id: typeof record.id === "string" ? record.id : undefined,
|
|
8916
|
-
name: record.name
|
|
8917
|
-
});
|
|
8918
|
-
}
|
|
8919
|
-
return toolCalls;
|
|
8920
|
-
};
|
|
8921
|
-
var createGeminiVoiceAssistantModel = (options) => {
|
|
8922
|
-
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
8923
|
-
const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
|
|
8924
|
-
const model = options.model ?? "gemini-2.5-flash";
|
|
8925
|
-
const maxRetries = Math.max(0, options.maxRetries ?? 2);
|
|
8926
|
-
return {
|
|
8927
|
-
generate: async (input) => {
|
|
8928
|
-
const endpoint = `${baseUrl.replace(/\/$/, "")}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
|
|
8929
|
-
let response;
|
|
8930
|
-
for (let attempt = 0;attempt <= maxRetries; attempt += 1) {
|
|
8931
|
-
response = await fetchImpl(endpoint, {
|
|
8932
|
-
body: JSON.stringify({
|
|
8933
|
-
contents: input.messages.map(messageToGeminiContent).filter(Boolean),
|
|
8934
|
-
generationConfig: {
|
|
8935
|
-
maxOutputTokens: options.maxOutputTokens,
|
|
8936
|
-
...input.tools.length ? {} : {
|
|
8937
|
-
responseMimeType: "application/json",
|
|
8938
|
-
responseSchema: toGeminiSchema(OUTPUT_SCHEMA)
|
|
8939
|
-
},
|
|
8940
|
-
temperature: options.temperature
|
|
8941
|
-
},
|
|
8942
|
-
systemInstruction: {
|
|
8943
|
-
parts: [
|
|
8944
|
-
{
|
|
8945
|
-
text: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
|
|
8946
|
-
|
|
8947
|
-
`)
|
|
8948
|
-
}
|
|
8949
|
-
]
|
|
8950
|
-
},
|
|
8951
|
-
tools: input.tools.length ? [
|
|
8952
|
-
{
|
|
8953
|
-
functionDeclarations: input.tools.map((tool) => ({
|
|
8954
|
-
description: tool.description,
|
|
8955
|
-
name: tool.name,
|
|
8956
|
-
parameters: toGeminiSchema(tool.parameters ?? {
|
|
8957
|
-
additionalProperties: true,
|
|
8958
|
-
type: "object"
|
|
8959
|
-
})
|
|
8960
|
-
}))
|
|
8961
|
-
}
|
|
8962
|
-
] : undefined
|
|
8963
|
-
}),
|
|
8964
|
-
headers: {
|
|
8965
|
-
"content-type": "application/json"
|
|
8966
|
-
},
|
|
8967
|
-
method: "POST"
|
|
8968
|
-
});
|
|
8969
|
-
if (response.ok || response.status !== 429 && response.status < 500 || attempt === maxRetries) {
|
|
8970
|
-
break;
|
|
8971
|
-
}
|
|
8972
|
-
const retryAfter = Number(response.headers.get("retry-after"));
|
|
8973
|
-
await sleep4(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
|
|
8974
|
-
}
|
|
8975
|
-
if (!response) {
|
|
8976
|
-
throw new Error("Gemini voice assistant model failed: no response");
|
|
8977
|
-
}
|
|
8978
|
-
if (!response.ok) {
|
|
8979
|
-
throw createHTTPError("Gemini", response);
|
|
8980
|
-
}
|
|
8981
|
-
const body = await response.json();
|
|
8982
|
-
if (body.usageMetadata && typeof body.usageMetadata === "object") {
|
|
8983
|
-
await options.onUsage?.(body.usageMetadata);
|
|
8984
|
-
}
|
|
8985
|
-
const toolCalls = extractGeminiToolCalls(body);
|
|
8986
|
-
if (toolCalls.length) {
|
|
8987
|
-
return {
|
|
8988
|
-
assistantText: extractGeminiText(body) || undefined,
|
|
8989
|
-
toolCalls
|
|
8990
|
-
};
|
|
8991
|
-
}
|
|
8992
|
-
return normalizeRouteOutput(parseJSON(extractGeminiText(body)));
|
|
8993
|
-
}
|
|
8994
|
-
};
|
|
8995
|
-
};
|
|
8996
|
-
// src/qualityRoutes.ts
|
|
8997
|
-
import { Elysia as Elysia7 } from "elysia";
|
|
8998
|
-
|
|
8999
|
-
// src/handoffHealth.ts
|
|
9000
|
-
import { Elysia as Elysia6 } from "elysia";
|
|
9001
|
-
var escapeHtml8 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
9002
|
-
var getString5 = (value) => typeof value === "string" && value.length > 0 ? value : undefined;
|
|
9003
|
-
var isStatus = (value) => value === "delivered" || value === "failed" || value === "skipped";
|
|
9004
|
-
var increment3 = (record, key) => {
|
|
9005
|
-
record[key] = (record[key] ?? 0) + 1;
|
|
9006
|
-
};
|
|
9007
|
-
var normalizeDelivery = (adapterId, value) => {
|
|
9008
|
-
const record = value && typeof value === "object" ? value : {};
|
|
9009
|
-
return {
|
|
9010
|
-
adapterId: getString5(record.adapterId) ?? adapterId,
|
|
9011
|
-
adapterKind: getString5(record.adapterKind),
|
|
9012
|
-
deliveredAt: typeof record.deliveredAt === "number" ? record.deliveredAt : undefined,
|
|
9013
|
-
deliveredTo: getString5(record.deliveredTo),
|
|
9014
|
-
error: getString5(record.error),
|
|
9015
|
-
status: isStatus(record.status) ? record.status : "failed"
|
|
9016
|
-
};
|
|
9017
|
-
};
|
|
9018
|
-
var normalizeDeliveries = (payload) => {
|
|
9019
|
-
const deliveries = payload.deliveries;
|
|
9020
|
-
if (!deliveries || typeof deliveries !== "object") {
|
|
9021
|
-
return [];
|
|
9022
|
-
}
|
|
9023
|
-
return Object.entries(deliveries).map(([adapterId, value]) => normalizeDelivery(adapterId, value));
|
|
9024
|
-
};
|
|
9025
|
-
var resolveReplayHref = (event, replayHref) => {
|
|
9026
|
-
if (replayHref === false) {
|
|
9027
|
-
return;
|
|
9028
|
-
}
|
|
9029
|
-
if (typeof replayHref === "function") {
|
|
9030
|
-
return replayHref(event);
|
|
9031
|
-
}
|
|
9032
|
-
return `${replayHref ?? "/api/voice-sessions"}/${encodeURIComponent(event.sessionId)}/replay/htmx`;
|
|
9033
|
-
};
|
|
9034
|
-
var summarizeVoiceHandoffHealth = async (options = {}) => {
|
|
9035
|
-
const sourceEvents = options.events ?? await options.store?.list() ?? [];
|
|
9036
|
-
const search = options.q?.trim().toLowerCase();
|
|
9037
|
-
const byAction = {};
|
|
9038
|
-
const byAdapter = {};
|
|
9039
|
-
const byStatus = {
|
|
9040
|
-
delivered: 0,
|
|
9041
|
-
failed: 0,
|
|
9042
|
-
skipped: 0
|
|
9043
|
-
};
|
|
9044
|
-
const events = sourceEvents.filter((event) => event.type === "call.handoff").map((event) => {
|
|
9045
|
-
const status = isStatus(event.payload.status) ? event.payload.status : "failed";
|
|
9046
|
-
const deliveries = normalizeDeliveries(event.payload);
|
|
9047
|
-
const item = {
|
|
9048
|
-
action: getString5(event.payload.action),
|
|
9049
|
-
at: event.at,
|
|
9050
|
-
deliveries,
|
|
9051
|
-
reason: getString5(event.payload.reason),
|
|
9052
|
-
sessionId: event.sessionId,
|
|
9053
|
-
status,
|
|
9054
|
-
target: getString5(event.payload.target)
|
|
9055
|
-
};
|
|
9056
|
-
return {
|
|
9057
|
-
...item,
|
|
9058
|
-
replayHref: resolveReplayHref(item, options.replayHref)
|
|
9059
|
-
};
|
|
9060
|
-
}).filter((event) => {
|
|
9061
|
-
if (options.status && options.status !== "all" && event.status !== options.status) {
|
|
9062
|
-
return false;
|
|
9063
|
-
}
|
|
9064
|
-
if (!search) {
|
|
9065
|
-
return true;
|
|
9066
|
-
}
|
|
9067
|
-
return [
|
|
9068
|
-
event.action,
|
|
9069
|
-
event.reason,
|
|
9070
|
-
event.sessionId,
|
|
9071
|
-
event.status,
|
|
9072
|
-
event.target,
|
|
9073
|
-
...event.deliveries.flatMap((delivery) => [
|
|
9074
|
-
delivery.adapterId,
|
|
9075
|
-
delivery.adapterKind,
|
|
9076
|
-
delivery.deliveredTo,
|
|
9077
|
-
delivery.error,
|
|
9078
|
-
delivery.status
|
|
9079
|
-
])
|
|
9080
|
-
].some((value) => value?.toLowerCase().includes(search));
|
|
9081
|
-
}).sort((left, right) => right.at - left.at).slice(0, options.limit ?? 50);
|
|
9082
|
-
for (const event of events) {
|
|
9083
|
-
byStatus[event.status] += 1;
|
|
9084
|
-
if (event.action) {
|
|
9085
|
-
increment3(byAction, event.action);
|
|
9086
|
-
}
|
|
9087
|
-
for (const delivery of event.deliveries) {
|
|
9088
|
-
byAdapter[delivery.adapterId] ??= {
|
|
9089
|
-
delivered: 0,
|
|
9090
|
-
failed: 0,
|
|
9091
|
-
skipped: 0
|
|
9092
|
-
};
|
|
9093
|
-
byAdapter[delivery.adapterId][delivery.status] += 1;
|
|
9094
|
-
}
|
|
9095
|
-
}
|
|
9096
|
-
return {
|
|
9097
|
-
byAction,
|
|
9098
|
-
byAdapter,
|
|
9099
|
-
byStatus,
|
|
9100
|
-
events,
|
|
9101
|
-
failed: byStatus.failed,
|
|
9102
|
-
total: events.length
|
|
9103
|
-
};
|
|
9104
|
-
};
|
|
9105
|
-
var renderMetricGrid = (summary) => [
|
|
9106
|
-
'<section class="voice-handoff-health-grid">',
|
|
9107
|
-
`<article><span>Total</span><strong>${String(summary.total)}</strong></article>`,
|
|
9108
|
-
`<article><span>Delivered</span><strong>${String(summary.byStatus.delivered)}</strong></article>`,
|
|
9109
|
-
`<article><span>Failed</span><strong>${String(summary.byStatus.failed)}</strong></article>`,
|
|
9110
|
-
`<article><span>Skipped</span><strong>${String(summary.byStatus.skipped)}</strong></article>`,
|
|
9111
|
-
"</section>"
|
|
9112
|
-
].join("");
|
|
9113
|
-
var renderActionSummary = (summary) => {
|
|
9114
|
-
const actions = Object.entries(summary.byAction).sort((left, right) => right[1] - left[1]);
|
|
9115
|
-
const adapters = Object.entries(summary.byAdapter).sort(([left], [right]) => left.localeCompare(right));
|
|
9116
|
-
return [
|
|
9117
|
-
'<section class="voice-handoff-health-columns">',
|
|
9118
|
-
"<article><h3>Actions</h3>",
|
|
9119
|
-
actions.length === 0 ? "<p>No handoff actions yet.</p>" : `<ul>${actions.map(([action, count]) => `<li>${escapeHtml8(action)}: ${String(count)}</li>`).join("")}</ul>`,
|
|
9120
|
-
"</article>",
|
|
9121
|
-
"<article><h3>Adapters</h3>",
|
|
9122
|
-
adapters.length === 0 ? "<p>No adapter deliveries yet.</p>" : `<ul>${adapters.map(([adapterId, counts]) => `<li>${escapeHtml8(adapterId)}: ${String(counts.delivered)} delivered / ${String(counts.failed)} failed / ${String(counts.skipped)} skipped</li>`).join("")}</ul>`,
|
|
9123
|
-
"</article>",
|
|
9124
|
-
"</section>"
|
|
9125
|
-
].join("");
|
|
9126
|
-
};
|
|
9127
|
-
var renderVoiceHandoffHealthHTML = (summary) => [
|
|
9128
|
-
'<div class="voice-handoff-health">',
|
|
9129
|
-
renderMetricGrid(summary),
|
|
9130
|
-
renderActionSummary(summary),
|
|
9131
|
-
"<section>",
|
|
9132
|
-
"<h3>Recent Handoffs</h3>",
|
|
9133
|
-
summary.events.length === 0 ? '<p class="voice-handoff-health-empty">No handoffs found.</p>' : [
|
|
9134
|
-
'<div class="voice-handoff-health-events">',
|
|
9135
|
-
...summary.events.map((event) => [
|
|
9136
|
-
`<article class="${escapeHtml8(event.status)}">`,
|
|
9137
|
-
'<div class="voice-handoff-health-event-header">',
|
|
9138
|
-
`<strong>${escapeHtml8(event.action ?? "handoff")}</strong>`,
|
|
9139
|
-
`<span>${escapeHtml8(event.status)}</span>`,
|
|
9140
|
-
"</div>",
|
|
9141
|
-
`<p><small>${escapeHtml8(event.sessionId)}</small></p>`,
|
|
9142
|
-
event.target ? `<p>Target: ${escapeHtml8(event.target)}</p>` : "",
|
|
9143
|
-
event.reason ? `<p>Reason: ${escapeHtml8(event.reason)}</p>` : "",
|
|
9144
|
-
event.deliveries.length ? `<ul>${event.deliveries.map((delivery) => [
|
|
9145
|
-
"<li>",
|
|
9146
|
-
`${escapeHtml8(delivery.adapterId)}: ${escapeHtml8(delivery.status)}`,
|
|
9147
|
-
delivery.deliveredTo ? ` to ${escapeHtml8(delivery.deliveredTo)}` : "",
|
|
9148
|
-
delivery.error ? ` (${escapeHtml8(delivery.error)})` : "",
|
|
9149
|
-
"</li>"
|
|
9150
|
-
].join("")).join("")}</ul>` : "",
|
|
9151
|
-
event.replayHref ? `<p><a href="${escapeHtml8(event.replayHref)}">Open replay</a></p>` : "",
|
|
9152
|
-
"</article>"
|
|
9153
|
-
].join("")),
|
|
9154
|
-
"</div>"
|
|
9155
|
-
].join(""),
|
|
9156
|
-
"</section>",
|
|
9157
|
-
"</div>"
|
|
9158
|
-
].join("");
|
|
9159
|
-
var createVoiceHandoffHealthJSONHandler = (options = {}) => async ({ query }) => summarizeVoiceHandoffHealth({
|
|
9160
|
-
...options,
|
|
9161
|
-
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
9162
|
-
q: query?.q ?? options.q,
|
|
9163
|
-
status: query?.status === "delivered" || query?.status === "failed" || query?.status === "skipped" || query?.status === "all" ? query.status : options.status
|
|
9164
|
-
});
|
|
9165
|
-
var createVoiceHandoffHealthHTMLHandler = (options = {}) => async ({ query }) => {
|
|
9166
|
-
const summary = await summarizeVoiceHandoffHealth({
|
|
9167
|
-
...options,
|
|
9168
|
-
limit: typeof query?.limit === "string" ? Number(query.limit) : options.limit,
|
|
9169
|
-
q: query?.q ?? options.q,
|
|
9170
|
-
status: query?.status === "delivered" || query?.status === "failed" || query?.status === "skipped" || query?.status === "all" ? query.status : options.status
|
|
9171
|
-
});
|
|
9172
|
-
const body = await (options.render?.(summary) ?? renderVoiceHandoffHealthHTML(summary));
|
|
9173
|
-
return new Response(body, {
|
|
9174
|
-
headers: {
|
|
9175
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
9176
|
-
...options.headers
|
|
9177
|
-
}
|
|
9178
|
-
});
|
|
9179
|
-
};
|
|
9180
|
-
var createVoiceHandoffHealthRoutes = (options = {}) => {
|
|
9181
|
-
const path = options.path ?? "/api/voice-handoffs";
|
|
9182
|
-
const htmlPath = options.htmlPath === undefined ? `${path}/htmx` : options.htmlPath;
|
|
9183
|
-
const routes = new Elysia6({
|
|
9184
|
-
name: options.name ?? "absolutejs-voice-handoff-health"
|
|
9185
|
-
}).get(path, createVoiceHandoffHealthJSONHandler(options));
|
|
9186
|
-
if (htmlPath) {
|
|
9187
|
-
routes.get(htmlPath, createVoiceHandoffHealthHTMLHandler(options));
|
|
9188
|
-
}
|
|
9189
|
-
return routes;
|
|
9190
|
-
};
|
|
9191
|
-
|
|
9192
|
-
// src/qualityRoutes.ts
|
|
9193
|
-
var DEFAULT_THRESHOLDS = {
|
|
9194
|
-
maxDuplicateTurnRate: 0,
|
|
9195
|
-
maxEmptyTurnRate: 0.02,
|
|
9196
|
-
maxHandoffFailureRate: 0,
|
|
9197
|
-
maxMissingAssistantReplyRate: 0.05,
|
|
9198
|
-
maxProviderAverageLatencyMs: 3000,
|
|
9199
|
-
maxProviderErrorRate: 0.05,
|
|
9200
|
-
maxProviderFallbackRate: 0.25,
|
|
9201
|
-
maxProviderTimeoutRate: 0.03
|
|
9202
|
-
};
|
|
9203
|
-
var getString6 = (value) => typeof value === "string" ? value : undefined;
|
|
9204
|
-
var getNumber3 = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
9205
|
-
var rate = (count, total) => count / Math.max(1, total);
|
|
9206
|
-
var roundMetric2 = (value) => Math.round(value * 1e4) / 1e4;
|
|
9207
|
-
var createMetric = (input) => ({
|
|
9208
|
-
...input,
|
|
9209
|
-
actual: roundMetric2(input.actual),
|
|
9210
|
-
pass: input.actual <= input.threshold
|
|
9211
|
-
});
|
|
9212
|
-
var evaluateVoiceQuality = async (input) => {
|
|
9213
|
-
const events = filterVoiceTraceEvents(input.events ?? await input.store?.list() ?? []);
|
|
9214
|
-
const thresholds = {
|
|
9215
|
-
...DEFAULT_THRESHOLDS,
|
|
9216
|
-
...input.thresholds
|
|
9217
|
-
};
|
|
9218
|
-
const committedTurns = events.filter((event) => event.type === "turn.committed");
|
|
9219
|
-
const assistantReplies = events.filter((event) => event.type === "turn.assistant");
|
|
9220
|
-
const sessionIdsWithAssistantReply = new Set(assistantReplies.map((event) => event.sessionId));
|
|
9221
|
-
const sessionsWithTurns = new Set(committedTurns.map((event) => event.sessionId));
|
|
9222
|
-
const emptyTurns = committedTurns.filter((event) => !getString6(event.payload.text)?.trim());
|
|
9223
|
-
const turnTextsBySession = new Map;
|
|
9224
|
-
let duplicateTurns = 0;
|
|
9225
|
-
for (const turn of committedTurns) {
|
|
9226
|
-
const normalized = getString6(turn.payload.text)?.trim().toLowerCase();
|
|
9227
|
-
if (!normalized) {
|
|
9348
|
+
};
|
|
9349
|
+
var extractGeminiCandidateParts = (response) => {
|
|
9350
|
+
const candidates = Array.isArray(response.candidates) ? response.candidates : [];
|
|
9351
|
+
const first = candidates[0];
|
|
9352
|
+
if (!first || typeof first !== "object") {
|
|
9353
|
+
return [];
|
|
9354
|
+
}
|
|
9355
|
+
const content = first.content;
|
|
9356
|
+
if (!content || typeof content !== "object") {
|
|
9357
|
+
return [];
|
|
9358
|
+
}
|
|
9359
|
+
const parts = content.parts;
|
|
9360
|
+
return Array.isArray(parts) ? parts : [];
|
|
9361
|
+
};
|
|
9362
|
+
var extractGeminiText = (response) => extractGeminiCandidateParts(response).map((part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : "").filter(Boolean).join(`
|
|
9363
|
+
`);
|
|
9364
|
+
var extractGeminiToolCalls = (response) => {
|
|
9365
|
+
const toolCalls = [];
|
|
9366
|
+
for (const part of extractGeminiCandidateParts(response)) {
|
|
9367
|
+
if (!part || typeof part !== "object") {
|
|
9228
9368
|
continue;
|
|
9229
9369
|
}
|
|
9230
|
-
const
|
|
9231
|
-
if (
|
|
9232
|
-
|
|
9370
|
+
const functionCall = part.functionCall;
|
|
9371
|
+
if (!functionCall || typeof functionCall !== "object") {
|
|
9372
|
+
continue;
|
|
9233
9373
|
}
|
|
9234
|
-
|
|
9235
|
-
|
|
9374
|
+
const record = functionCall;
|
|
9375
|
+
if (typeof record.name !== "string") {
|
|
9376
|
+
continue;
|
|
9377
|
+
}
|
|
9378
|
+
toolCalls.push({
|
|
9379
|
+
args: record.args && typeof record.args === "object" ? record.args : {},
|
|
9380
|
+
id: typeof record.id === "string" ? record.id : undefined,
|
|
9381
|
+
name: record.name
|
|
9382
|
+
});
|
|
9236
9383
|
}
|
|
9237
|
-
|
|
9238
|
-
const providerEvents = events.filter((event) => event.type === "session.error" && typeof event.payload.provider === "string" && typeof event.payload.providerStatus === "string");
|
|
9239
|
-
const providerErrors = providerEvents.filter((event) => event.payload.providerStatus === "error");
|
|
9240
|
-
const providerFallbacks = providerEvents.filter((event) => event.payload.providerStatus === "fallback");
|
|
9241
|
-
const providerTimeouts = providerEvents.filter((event) => event.payload.timedOut === true);
|
|
9242
|
-
const providerLatencies = providerEvents.map((event) => getNumber3(event.payload.elapsedMs)).filter((value) => value !== undefined);
|
|
9243
|
-
const averageProviderLatencyMs = providerLatencies.length > 0 ? providerLatencies.reduce((sum, value) => sum + value, 0) / providerLatencies.length : 0;
|
|
9244
|
-
const handoffHealth = await summarizeVoiceHandoffHealth({ events });
|
|
9245
|
-
const metrics = {
|
|
9246
|
-
duplicateTurnRate: createMetric({
|
|
9247
|
-
actual: rate(duplicateTurns, committedTurns.length),
|
|
9248
|
-
label: "Duplicate turn rate",
|
|
9249
|
-
threshold: thresholds.maxDuplicateTurnRate,
|
|
9250
|
-
unit: "rate"
|
|
9251
|
-
}),
|
|
9252
|
-
emptyTurnRate: createMetric({
|
|
9253
|
-
actual: rate(emptyTurns.length, committedTurns.length),
|
|
9254
|
-
label: "Empty turn rate",
|
|
9255
|
-
threshold: thresholds.maxEmptyTurnRate,
|
|
9256
|
-
unit: "rate"
|
|
9257
|
-
}),
|
|
9258
|
-
handoffFailureRate: createMetric({
|
|
9259
|
-
actual: rate(handoffHealth.failed, handoffHealth.total),
|
|
9260
|
-
label: "Handoff failure rate",
|
|
9261
|
-
threshold: thresholds.maxHandoffFailureRate,
|
|
9262
|
-
unit: "rate"
|
|
9263
|
-
}),
|
|
9264
|
-
missingAssistantReplyRate: createMetric({
|
|
9265
|
-
actual: rate(missingAssistantReplySessions, sessionsWithTurns.size),
|
|
9266
|
-
label: "Missing assistant reply rate",
|
|
9267
|
-
threshold: thresholds.maxMissingAssistantReplyRate,
|
|
9268
|
-
unit: "rate"
|
|
9269
|
-
}),
|
|
9270
|
-
providerAverageLatencyMs: createMetric({
|
|
9271
|
-
actual: averageProviderLatencyMs,
|
|
9272
|
-
label: "Average provider latency",
|
|
9273
|
-
threshold: thresholds.maxProviderAverageLatencyMs,
|
|
9274
|
-
unit: "ms"
|
|
9275
|
-
}),
|
|
9276
|
-
providerErrorRate: createMetric({
|
|
9277
|
-
actual: rate(providerErrors.length, providerEvents.length),
|
|
9278
|
-
label: "Provider error rate",
|
|
9279
|
-
threshold: thresholds.maxProviderErrorRate,
|
|
9280
|
-
unit: "rate"
|
|
9281
|
-
}),
|
|
9282
|
-
providerFallbackRate: createMetric({
|
|
9283
|
-
actual: rate(providerFallbacks.length, providerEvents.length),
|
|
9284
|
-
label: "Provider fallback rate",
|
|
9285
|
-
threshold: thresholds.maxProviderFallbackRate,
|
|
9286
|
-
unit: "rate"
|
|
9287
|
-
}),
|
|
9288
|
-
providerTimeoutRate: createMetric({
|
|
9289
|
-
actual: rate(providerTimeouts.length, providerEvents.length),
|
|
9290
|
-
label: "Provider timeout rate",
|
|
9291
|
-
threshold: thresholds.maxProviderTimeoutRate,
|
|
9292
|
-
unit: "rate"
|
|
9293
|
-
})
|
|
9294
|
-
};
|
|
9295
|
-
const status = Object.values(metrics).every((metric) => metric.pass) ? "pass" : "fail";
|
|
9296
|
-
return {
|
|
9297
|
-
checkedAt: Date.now(),
|
|
9298
|
-
eventCount: events.length,
|
|
9299
|
-
metrics,
|
|
9300
|
-
status,
|
|
9301
|
-
thresholds
|
|
9302
|
-
};
|
|
9303
|
-
};
|
|
9304
|
-
var escapeHtml9 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
9305
|
-
var formatMetricValue = (metric) => metric.unit === "rate" ? `${(metric.actual * 100).toFixed(2)}%` : metric.unit === "ms" ? `${Math.round(metric.actual)}ms` : String(metric.actual);
|
|
9306
|
-
var formatThreshold = (metric) => metric.unit === "rate" ? `${(metric.threshold * 100).toFixed(2)}%` : metric.unit === "ms" ? `${Math.round(metric.threshold)}ms` : String(metric.threshold);
|
|
9307
|
-
var renderVoiceQualityHTML = (report, options = {}) => {
|
|
9308
|
-
const rows = Object.entries(report.metrics).map(([key, metric]) => `<tr class="${metric.pass ? "pass" : "fail"}"><td>${escapeHtml9(metric.label)}</td><td>${escapeHtml9(formatMetricValue(metric))}</td><td>${escapeHtml9(formatThreshold(metric))}</td><td>${metric.pass ? "pass" : "fail"}</td><td><code>${escapeHtml9(key)}</code></td></tr>`).join("");
|
|
9309
|
-
const links = options.links?.length ? `<nav>${options.links.map((link) => `<a href="${escapeHtml9(link.href)}">${escapeHtml9(link.label)}</a>`).join("")}</nav>` : "";
|
|
9310
|
-
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>`;
|
|
9384
|
+
return toolCalls;
|
|
9311
9385
|
};
|
|
9312
|
-
var
|
|
9313
|
-
const
|
|
9314
|
-
const
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
|
|
9322
|
-
|
|
9323
|
-
|
|
9324
|
-
|
|
9325
|
-
|
|
9326
|
-
|
|
9327
|
-
|
|
9386
|
+
var createGeminiVoiceAssistantModel = (options) => {
|
|
9387
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
9388
|
+
const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
|
|
9389
|
+
const model = options.model ?? "gemini-2.5-flash";
|
|
9390
|
+
const maxRetries = Math.max(0, options.maxRetries ?? 2);
|
|
9391
|
+
return {
|
|
9392
|
+
generate: async (input) => {
|
|
9393
|
+
const endpoint = `${baseUrl.replace(/\/$/, "")}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
|
|
9394
|
+
let response;
|
|
9395
|
+
for (let attempt = 0;attempt <= maxRetries; attempt += 1) {
|
|
9396
|
+
response = await fetchImpl(endpoint, {
|
|
9397
|
+
body: JSON.stringify({
|
|
9398
|
+
contents: input.messages.map(messageToGeminiContent).filter(Boolean),
|
|
9399
|
+
generationConfig: {
|
|
9400
|
+
maxOutputTokens: options.maxOutputTokens,
|
|
9401
|
+
...input.tools.length ? {} : {
|
|
9402
|
+
responseMimeType: "application/json",
|
|
9403
|
+
responseSchema: toGeminiSchema(OUTPUT_SCHEMA)
|
|
9404
|
+
},
|
|
9405
|
+
temperature: options.temperature
|
|
9406
|
+
},
|
|
9407
|
+
systemInstruction: {
|
|
9408
|
+
parts: [
|
|
9409
|
+
{
|
|
9410
|
+
text: [input.system, ROUTE_RESULT_INSTRUCTION].filter(Boolean).join(`
|
|
9411
|
+
|
|
9412
|
+
`)
|
|
9413
|
+
}
|
|
9414
|
+
]
|
|
9415
|
+
},
|
|
9416
|
+
tools: input.tools.length ? [
|
|
9417
|
+
{
|
|
9418
|
+
functionDeclarations: input.tools.map((tool) => ({
|
|
9419
|
+
description: tool.description,
|
|
9420
|
+
name: tool.name,
|
|
9421
|
+
parameters: toGeminiSchema(tool.parameters ?? {
|
|
9422
|
+
additionalProperties: true,
|
|
9423
|
+
type: "object"
|
|
9424
|
+
})
|
|
9425
|
+
}))
|
|
9426
|
+
}
|
|
9427
|
+
] : undefined
|
|
9428
|
+
}),
|
|
9429
|
+
headers: {
|
|
9430
|
+
"content-type": "application/json"
|
|
9431
|
+
},
|
|
9432
|
+
method: "POST"
|
|
9433
|
+
});
|
|
9434
|
+
if (response.ok || response.status !== 429 && response.status < 500 || attempt === maxRetries) {
|
|
9435
|
+
break;
|
|
9436
|
+
}
|
|
9437
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
9438
|
+
await sleep4(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt);
|
|
9328
9439
|
}
|
|
9329
|
-
|
|
9330
|
-
|
|
9331
|
-
|
|
9332
|
-
|
|
9333
|
-
|
|
9334
|
-
|
|
9335
|
-
|
|
9440
|
+
if (!response) {
|
|
9441
|
+
throw new Error("Gemini voice assistant model failed: no response");
|
|
9442
|
+
}
|
|
9443
|
+
if (!response.ok) {
|
|
9444
|
+
throw createHTTPError("Gemini", response);
|
|
9445
|
+
}
|
|
9446
|
+
const body = await response.json();
|
|
9447
|
+
if (body.usageMetadata && typeof body.usageMetadata === "object") {
|
|
9448
|
+
await options.onUsage?.(body.usageMetadata);
|
|
9449
|
+
}
|
|
9450
|
+
const toolCalls = extractGeminiToolCalls(body);
|
|
9451
|
+
if (toolCalls.length) {
|
|
9452
|
+
return {
|
|
9453
|
+
assistantText: extractGeminiText(body) || undefined,
|
|
9454
|
+
toolCalls
|
|
9455
|
+
};
|
|
9456
|
+
}
|
|
9457
|
+
return normalizeRouteOutput(parseJSON(extractGeminiText(body)));
|
|
9336
9458
|
}
|
|
9337
|
-
|
|
9338
|
-
});
|
|
9339
|
-
return routes;
|
|
9459
|
+
};
|
|
9340
9460
|
};
|
|
9461
|
+
// src/opsConsoleRoutes.ts
|
|
9462
|
+
import { Elysia as Elysia10 } from "elysia";
|
|
9463
|
+
|
|
9341
9464
|
// src/resilienceRoutes.ts
|
|
9342
|
-
import { Elysia as
|
|
9343
|
-
var
|
|
9465
|
+
import { Elysia as Elysia9 } from "elysia";
|
|
9466
|
+
var escapeHtml11 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
9344
9467
|
var getString7 = (value) => typeof value === "string" ? value : undefined;
|
|
9345
9468
|
var getNumber4 = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
9346
9469
|
var getBoolean2 = (value) => value === true;
|
|
@@ -9403,13 +9526,13 @@ var summarizeRoutingEvents = (events) => {
|
|
|
9403
9526
|
};
|
|
9404
9527
|
var renderProviderCards = (title, providers) => {
|
|
9405
9528
|
if (providers.length === 0) {
|
|
9406
|
-
return `<p class="muted">No ${
|
|
9529
|
+
return `<p class="muted">No ${escapeHtml11(title)} provider health yet.</p>`;
|
|
9407
9530
|
}
|
|
9408
9531
|
return `<div class="provider-grid">${providers.map((provider) => `
|
|
9409
|
-
<article class="card provider ${
|
|
9532
|
+
<article class="card provider ${escapeHtml11(provider.status)}">
|
|
9410
9533
|
<div class="card-header">
|
|
9411
|
-
<strong>${
|
|
9412
|
-
<span>${
|
|
9534
|
+
<strong>${escapeHtml11(provider.provider)}</strong>
|
|
9535
|
+
<span>${escapeHtml11(provider.status)}${provider.recommended ? " \xB7 recommended" : ""}</span>
|
|
9413
9536
|
</div>
|
|
9414
9537
|
<dl>
|
|
9415
9538
|
<div><dt>Runs</dt><dd>${provider.runCount}</dd></div>
|
|
@@ -9418,7 +9541,7 @@ var renderProviderCards = (title, providers) => {
|
|
|
9418
9541
|
<div><dt>Timeouts</dt><dd>${provider.timeoutCount}</dd></div>
|
|
9419
9542
|
<div><dt>Fallbacks</dt><dd>${provider.fallbackCount}</dd></div>
|
|
9420
9543
|
</dl>
|
|
9421
|
-
${provider.lastError ? `<p class="muted">${
|
|
9544
|
+
${provider.lastError ? `<p class="muted">${escapeHtml11(provider.lastError)}</p>` : ""}
|
|
9422
9545
|
</article>
|
|
9423
9546
|
`).join("")}</div>`;
|
|
9424
9547
|
};
|
|
@@ -9427,24 +9550,24 @@ var renderTimeline2 = (events) => {
|
|
|
9427
9550
|
return '<p class="muted">No provider routing events yet. Run the app or simulate provider failover.</p>';
|
|
9428
9551
|
}
|
|
9429
9552
|
return `<div class="timeline">${events.slice(0, 40).map((event) => `
|
|
9430
|
-
<article class="card event ${
|
|
9553
|
+
<article class="card event ${escapeHtml11(event.status ?? "unknown")}">
|
|
9431
9554
|
<div class="card-header">
|
|
9432
|
-
<strong>${
|
|
9555
|
+
<strong>${escapeHtml11(event.kind.toUpperCase())} ${escapeHtml11(event.operation ?? "generate")}</strong>
|
|
9433
9556
|
<span>${new Date(event.at).toLocaleString()}</span>
|
|
9434
9557
|
</div>
|
|
9435
9558
|
<p>
|
|
9436
|
-
<span class="pill">${
|
|
9437
|
-
<span class="pill">provider: ${
|
|
9438
|
-
${event.fallbackProvider ? `<span class="pill">fallback: ${
|
|
9559
|
+
<span class="pill">${escapeHtml11(event.status ?? "unknown")}</span>
|
|
9560
|
+
<span class="pill">provider: ${escapeHtml11(event.provider ?? "unknown")}</span>
|
|
9561
|
+
${event.fallbackProvider ? `<span class="pill">fallback: ${escapeHtml11(event.fallbackProvider)}</span>` : ""}
|
|
9439
9562
|
${event.timedOut ? '<span class="pill danger">timed out</span>' : ""}
|
|
9440
9563
|
</p>
|
|
9441
9564
|
<dl>
|
|
9442
9565
|
<div><dt>Attempt</dt><dd>${event.attempt ?? 0}</dd></div>
|
|
9443
9566
|
<div><dt>Elapsed</dt><dd>${event.elapsedMs ?? 0}ms</dd></div>
|
|
9444
9567
|
<div><dt>Budget</dt><dd>${event.latencyBudgetMs ?? 0}ms</dd></div>
|
|
9445
|
-
<div><dt>Session</dt><dd>${
|
|
9568
|
+
<div><dt>Session</dt><dd>${escapeHtml11(event.sessionId)}</dd></div>
|
|
9446
9569
|
</dl>
|
|
9447
|
-
${event.error ? `<p class="muted">${
|
|
9570
|
+
${event.error ? `<p class="muted">${escapeHtml11(event.error)}</p>` : ""}
|
|
9448
9571
|
</article>
|
|
9449
9572
|
`).join("")}</div>`;
|
|
9450
9573
|
};
|
|
@@ -9459,26 +9582,26 @@ var renderSimulationControls = (kind, simulation) => {
|
|
|
9459
9582
|
const pathPrefix = simulation.pathPrefix ?? `/api/${kind}-simulate`;
|
|
9460
9583
|
const failureProviders = simulation.failureProviders ?? configuredProviders.map(({ provider }) => provider);
|
|
9461
9584
|
const canFail = (provider) => configuredProviders.some((entry) => entry.provider === provider) && (!simulation.fallbackRequiredProvider || configuredProviders.some((entry) => entry.provider === simulation.fallbackRequiredProvider));
|
|
9462
|
-
return `<div class="simulate-panel" data-sim-kind="${kind}" data-sim-prefix="${
|
|
9463
|
-
<p class="muted">${
|
|
9585
|
+
return `<div class="simulate-panel" data-sim-kind="${kind}" data-sim-prefix="${escapeHtml11(pathPrefix)}">
|
|
9586
|
+
<p class="muted">${escapeHtml11(simulation.failureMessage ?? `Simulate ${kind.toUpperCase()} provider failure without changing provider credentials.`)}</p>
|
|
9464
9587
|
<div class="simulate-actions">
|
|
9465
|
-
${failureProviders.map((provider) => `<button type="button" data-provider-fail="${
|
|
9466
|
-
${configuredProviders.map((provider) => `<button type="button" data-provider-recover="${
|
|
9588
|
+
${failureProviders.map((provider) => `<button type="button" data-provider-fail="${escapeHtml11(provider)}"${canFail(provider) ? "" : " disabled"}>Simulate ${escapeHtml11(provider)} ${kind.toUpperCase()} failure</button>`).join("")}
|
|
9589
|
+
${configuredProviders.map((provider) => `<button type="button" data-provider-recover="${escapeHtml11(provider.provider)}">Mark ${escapeHtml11(provider.provider)} recovered</button>`).join("")}
|
|
9467
9590
|
</div>
|
|
9468
|
-
${simulation.fallbackRequiredProvider && !configuredProviders.some((entry) => entry.provider === simulation.fallbackRequiredProvider) ? `<p class="muted">${
|
|
9591
|
+
${simulation.fallbackRequiredProvider && !configuredProviders.some((entry) => entry.provider === simulation.fallbackRequiredProvider) ? `<p class="muted">${escapeHtml11(simulation.fallbackRequiredMessage ?? `Configure ${simulation.fallbackRequiredProvider} to enable fallback simulation.`)}</p>` : ""}
|
|
9469
9592
|
<pre class="simulate-output" hidden></pre>
|
|
9470
9593
|
</div>`;
|
|
9471
9594
|
};
|
|
9472
9595
|
var renderVoiceResilienceHTML = (input) => {
|
|
9473
9596
|
const summary = summarizeRoutingEvents(input.routingEvents);
|
|
9474
|
-
const kindCounts = [...summary.byKind.entries()].map(([kind, count]) => `<span class="pill">${
|
|
9475
|
-
const links = input.links?.length ? input.links.map((link) => `<a href="${
|
|
9597
|
+
const kindCounts = [...summary.byKind.entries()].map(([kind, count]) => `<span class="pill">${escapeHtml11(kind)}: ${String(count)}</span>`).join("");
|
|
9598
|
+
const links = input.links?.length ? input.links.map((link) => `<a href="${escapeHtml11(link.href)}">${escapeHtml11(link.label)}</a>`).join(" \xB7 ") : "";
|
|
9476
9599
|
return `<!doctype html>
|
|
9477
9600
|
<html lang="en">
|
|
9478
9601
|
<head>
|
|
9479
9602
|
<meta charset="utf-8" />
|
|
9480
9603
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
9481
|
-
<title>${
|
|
9604
|
+
<title>${escapeHtml11(input.title ?? "AbsoluteJS Voice Resilience")}</title>
|
|
9482
9605
|
<style>
|
|
9483
9606
|
:root { color-scheme: dark; }
|
|
9484
9607
|
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; }
|
|
@@ -9614,7 +9737,7 @@ var registerSimulationRoutes = (routes, simulation, defaultPathPrefix) => {
|
|
|
9614
9737
|
};
|
|
9615
9738
|
var createVoiceResilienceRoutes = (options) => {
|
|
9616
9739
|
const path = options.path ?? "/resilience";
|
|
9617
|
-
const routes = new
|
|
9740
|
+
const routes = new Elysia9({
|
|
9618
9741
|
name: options.name ?? "absolutejs-voice-resilience"
|
|
9619
9742
|
}).get(path, async () => {
|
|
9620
9743
|
const events = await options.store.list();
|
|
@@ -9651,6 +9774,127 @@ var createVoiceResilienceRoutes = (options) => {
|
|
|
9651
9774
|
registerSimulationRoutes(routes, options.ttsSimulation, "/api/tts-simulate");
|
|
9652
9775
|
return routes;
|
|
9653
9776
|
};
|
|
9777
|
+
|
|
9778
|
+
// src/opsConsoleRoutes.ts
|
|
9779
|
+
var DEFAULT_LINKS = [
|
|
9780
|
+
{
|
|
9781
|
+
description: "Quality gates for CI, deploy checks, and production readiness.",
|
|
9782
|
+
href: "/quality",
|
|
9783
|
+
label: "Quality",
|
|
9784
|
+
statusHref: "/quality/status"
|
|
9785
|
+
},
|
|
9786
|
+
{
|
|
9787
|
+
description: "Replay stored sessions against acceptance gates over time.",
|
|
9788
|
+
href: "/evals",
|
|
9789
|
+
label: "Evals",
|
|
9790
|
+
statusHref: "/evals/status"
|
|
9791
|
+
},
|
|
9792
|
+
{
|
|
9793
|
+
description: "Provider health, fallback paths, and failure simulation.",
|
|
9794
|
+
href: "/resilience",
|
|
9795
|
+
label: "Resilience"
|
|
9796
|
+
},
|
|
9797
|
+
{
|
|
9798
|
+
description: "Redacted trace exports for debugging and support handoffs.",
|
|
9799
|
+
href: "/diagnostics",
|
|
9800
|
+
label: "Diagnostics"
|
|
9801
|
+
},
|
|
9802
|
+
{
|
|
9803
|
+
description: "Recent sessions with replay links.",
|
|
9804
|
+
href: "/sessions",
|
|
9805
|
+
label: "Sessions"
|
|
9806
|
+
},
|
|
9807
|
+
{
|
|
9808
|
+
description: "Transfer and webhook delivery health.",
|
|
9809
|
+
href: "/handoffs",
|
|
9810
|
+
label: "Handoffs"
|
|
9811
|
+
}
|
|
9812
|
+
];
|
|
9813
|
+
var escapeHtml12 = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
9814
|
+
var countProviderStatuses = (providers) => {
|
|
9815
|
+
const degradedStatuses = new Set(["degraded", "rate-limited", "suppressed"]);
|
|
9816
|
+
const healthy = providers.filter((provider) => provider.status === "healthy").length;
|
|
9817
|
+
const degraded = providers.filter((provider) => degradedStatuses.has(provider.status)).length;
|
|
9818
|
+
return {
|
|
9819
|
+
degraded,
|
|
9820
|
+
healthy,
|
|
9821
|
+
total: providers.length
|
|
9822
|
+
};
|
|
9823
|
+
};
|
|
9824
|
+
var buildVoiceOpsConsoleReport = async (options) => {
|
|
9825
|
+
const events = await options.store.list();
|
|
9826
|
+
const providers = [
|
|
9827
|
+
...await summarizeVoiceProviderHealth({
|
|
9828
|
+
events,
|
|
9829
|
+
providers: options.llmProviders
|
|
9830
|
+
}),
|
|
9831
|
+
...await summarizeVoiceProviderHealth({
|
|
9832
|
+
events,
|
|
9833
|
+
providers: options.sttProviders
|
|
9834
|
+
}),
|
|
9835
|
+
...await summarizeVoiceProviderHealth({
|
|
9836
|
+
events,
|
|
9837
|
+
providers: options.ttsProviders
|
|
9838
|
+
})
|
|
9839
|
+
];
|
|
9840
|
+
const handoffs = await summarizeVoiceHandoffHealth({ events });
|
|
9841
|
+
const sessions = await summarizeVoiceSessions({
|
|
9842
|
+
events,
|
|
9843
|
+
limit: 8,
|
|
9844
|
+
status: "all"
|
|
9845
|
+
});
|
|
9846
|
+
const quality = await evaluateVoiceQuality({ events });
|
|
9847
|
+
const routingEvents = listVoiceRoutingEvents(events).slice(0, 10);
|
|
9848
|
+
const trace = summarizeVoiceTrace(events);
|
|
9849
|
+
return {
|
|
9850
|
+
checkedAt: Date.now(),
|
|
9851
|
+
eventCount: events.length,
|
|
9852
|
+
handoffs: {
|
|
9853
|
+
failed: handoffs.failed,
|
|
9854
|
+
total: handoffs.total
|
|
9855
|
+
},
|
|
9856
|
+
links: options.links ?? DEFAULT_LINKS,
|
|
9857
|
+
providers: countProviderStatuses(providers),
|
|
9858
|
+
quality,
|
|
9859
|
+
recentRoutingEvents: routingEvents,
|
|
9860
|
+
recentSessions: sessions,
|
|
9861
|
+
sessions: {
|
|
9862
|
+
failed: sessions.filter((session) => session.status === "failed").length,
|
|
9863
|
+
healthy: sessions.filter((session) => session.status === "healthy").length,
|
|
9864
|
+
total: sessions.length
|
|
9865
|
+
},
|
|
9866
|
+
trace
|
|
9867
|
+
};
|
|
9868
|
+
};
|
|
9869
|
+
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>`;
|
|
9870
|
+
var renderVoiceOpsConsoleHTML = (report, options = {}) => {
|
|
9871
|
+
const links = report.links.map((link) => `<article class="surface">
|
|
9872
|
+
<div><h2>${escapeHtml12(link.label)}</h2>${link.description ? `<p>${escapeHtml12(link.description)}</p>` : ""}</div>
|
|
9873
|
+
<p><a href="${escapeHtml12(link.href)}">Open ${escapeHtml12(link.label)}</a>${link.statusHref ? ` \xB7 <a href="${escapeHtml12(link.statusHref)}">Status</a>` : ""}</p>
|
|
9874
|
+
</article>`).join("");
|
|
9875
|
+
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>';
|
|
9876
|
+
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>';
|
|
9877
|
+
const title = options.title ?? "AbsoluteJS Voice Ops Console";
|
|
9878
|
+
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>`;
|
|
9879
|
+
};
|
|
9880
|
+
var createVoiceOpsConsoleRoutes = (options) => {
|
|
9881
|
+
const path = options.path ?? "/ops-console";
|
|
9882
|
+
const routes = new Elysia10({
|
|
9883
|
+
name: options.name ?? "absolutejs-voice-ops-console"
|
|
9884
|
+
});
|
|
9885
|
+
const getReport = () => buildVoiceOpsConsoleReport(options);
|
|
9886
|
+
routes.get(path, async () => {
|
|
9887
|
+
const report = await getReport();
|
|
9888
|
+
return new Response(renderVoiceOpsConsoleHTML(report, { title: options.title }), {
|
|
9889
|
+
headers: {
|
|
9890
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
9891
|
+
...options.headers
|
|
9892
|
+
}
|
|
9893
|
+
});
|
|
9894
|
+
});
|
|
9895
|
+
routes.get(`${path}/json`, async () => getReport());
|
|
9896
|
+
return routes;
|
|
9897
|
+
};
|
|
9654
9898
|
// src/providerAdapters.ts
|
|
9655
9899
|
class VoiceIOProviderTimeoutError extends Error {
|
|
9656
9900
|
provider;
|
|
@@ -10495,7 +10739,7 @@ var createVoiceMemoryStore = () => {
|
|
|
10495
10739
|
return { get, getOrCreate, list, remove, set };
|
|
10496
10740
|
};
|
|
10497
10741
|
// src/opsWebhook.ts
|
|
10498
|
-
import { Elysia as
|
|
10742
|
+
import { Elysia as Elysia11 } from "elysia";
|
|
10499
10743
|
var toHex5 = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
10500
10744
|
var signVoiceOpsWebhookBody = async (input) => {
|
|
10501
10745
|
const encoder = new TextEncoder;
|
|
@@ -10625,7 +10869,7 @@ var verifyVoiceOpsWebhookSignature = async (input) => {
|
|
|
10625
10869
|
};
|
|
10626
10870
|
var createVoiceOpsWebhookReceiverRoutes = (options = {}) => {
|
|
10627
10871
|
const path = options.path ?? "/api/voice-ops/webhook";
|
|
10628
|
-
return new
|
|
10872
|
+
return new Elysia11().post(path, async ({ body, request, set }) => {
|
|
10629
10873
|
const bodyText = typeof body === "string" ? body : JSON.stringify(body);
|
|
10630
10874
|
if (options.signingSecret) {
|
|
10631
10875
|
const verification = await verifyVoiceOpsWebhookSignature({
|
|
@@ -12787,6 +13031,7 @@ export {
|
|
|
12787
13031
|
startVoiceOpsTask,
|
|
12788
13032
|
shapeTelephonyAssistantText,
|
|
12789
13033
|
selectVoiceTraceEventsForPrune,
|
|
13034
|
+
runVoiceSessionEvals,
|
|
12790
13035
|
resolveVoiceTraceRedactionOptions,
|
|
12791
13036
|
resolveVoiceSTTRoutingStrategy,
|
|
12792
13037
|
resolveVoiceRuntimePreset,
|
|
@@ -12807,7 +13052,9 @@ export {
|
|
|
12807
13052
|
renderVoiceResilienceHTML,
|
|
12808
13053
|
renderVoiceQualityHTML,
|
|
12809
13054
|
renderVoiceProviderHealthHTML,
|
|
13055
|
+
renderVoiceOpsConsoleHTML,
|
|
12810
13056
|
renderVoiceHandoffHealthHTML,
|
|
13057
|
+
renderVoiceEvalHTML,
|
|
12811
13058
|
renderVoiceCallReviewMarkdown,
|
|
12812
13059
|
renderVoiceCallReviewHTML,
|
|
12813
13060
|
renderVoiceAssistantHealthHTML,
|
|
@@ -12898,6 +13145,7 @@ export {
|
|
|
12898
13145
|
createVoiceOpsTaskProcessorWorkerLoop,
|
|
12899
13146
|
createVoiceOpsTaskProcessorWorker,
|
|
12900
13147
|
createVoiceOpsRuntime,
|
|
13148
|
+
createVoiceOpsConsoleRoutes,
|
|
12901
13149
|
createVoiceMemoryTraceSinkDeliveryStore,
|
|
12902
13150
|
createVoiceMemoryTraceEventStore,
|
|
12903
13151
|
createVoiceMemoryStore,
|
|
@@ -12932,6 +13180,7 @@ export {
|
|
|
12932
13180
|
createVoiceExternalObjectMapId,
|
|
12933
13181
|
createVoiceExternalObjectMap,
|
|
12934
13182
|
createVoiceExperiment,
|
|
13183
|
+
createVoiceEvalRoutes,
|
|
12935
13184
|
createVoiceDiagnosticsRoutes,
|
|
12936
13185
|
createVoiceCallReviewRecorder,
|
|
12937
13186
|
createVoiceCallReviewFromSession,
|
|
@@ -12968,6 +13217,7 @@ export {
|
|
|
12968
13217
|
buildVoiceTraceReplay,
|
|
12969
13218
|
buildVoiceOpsTaskFromSLABreach,
|
|
12970
13219
|
buildVoiceOpsTaskFromReview,
|
|
13220
|
+
buildVoiceOpsConsoleReport,
|
|
12971
13221
|
buildVoiceDiagnosticsMarkdown,
|
|
12972
13222
|
assignVoiceOpsTask,
|
|
12973
13223
|
applyVoiceOpsTaskPolicy,
|