Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desk/public/voice-demo.css

Large diffs are not rendered by default.

116 changes: 58 additions & 58 deletions apps/desk/public/voice-demo.js

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions apps/desk/scripts/run-voice-evals.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const port = Number(process.env.VOICE_EVAL_PORT ?? 8794)
if (!Number.isInteger(port) || port < 1024 || port > 65_535) throw new Error('VOICE_EVAL_PORT must be an unprivileged TCP port')
const baseUrl = `http://127.0.0.1:${port}`
const workerOutput = []
const SIGN_IN_CONTINUATION =
'I have signed in with my store account. Continue what I asked for before signing in.'

function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
Expand Down Expand Up @@ -363,6 +365,42 @@ try {
assertNoRepeatedSentence(signedInReply, 'signed-in order reply')
console.log(`PASS signed_in_order_list: ${signedInReply}`)

const placement = await turn(
[{ role: 'user', content: 'Please place an order for the H10 for me.' }],
{ fixtures: [orderFixture] },
{ signedIn: true, stream: true },
)
const placementReply = String(placement.text ?? '')
const placementTools = Array.isArray(placement.toolCalls) ? placement.toolCalls : []
assert(placementTools.length === 0, `order placement must not call a read or write tool: ${JSON.stringify(placement)}`)
assert(
/can(?:not|['’]t).{0,80}(?:place|order)|can(?:not|['’]t).{0,80}(?:cart|payment|checkout)/i.test(placementReply)
&& /nothing has been ordered or charged/i.test(placementReply),
`order placement refusal must state the capability boundary and outcome truth: ${placementReply}`,
)
console.log(`PASS order_placement_unavailable: ${placementReply}`)

const placementAfterLogin = await turn(
[
{ role: 'user', content: 'Please order this machine for me.' },
{ role: 'assistant', content: 'Sign in below and I will take care of that.' },
{ role: 'user', content: SIGN_IN_CONTINUATION },
],
{ fixtures: [orderFixture] },
{ signedIn: true, stream: true },
)
const placementAfterLoginReply = String(placementAfterLogin.text ?? '')
const placementAfterLoginTools = Array.isArray(placementAfterLogin.toolCalls) ? placementAfterLogin.toolCalls : []
assert(
placementAfterLoginTools.length === 0,
`post-login placement continuation must not call an order tool: ${JSON.stringify(placementAfterLogin)}`,
)
assert(
/nothing has been ordered or charged/i.test(placementAfterLoginReply),
`post-login placement continuation must not imply success: ${placementAfterLoginReply}`,
)
console.log(`PASS order_placement_after_login_unavailable: ${placementAfterLoginReply}`)

// Knowledge-grounded answering: the assistant must consult the help centre
// and answer strictly from article content.
const kbArticle = {
Expand Down
15 changes: 15 additions & 0 deletions apps/desk/scripts/voice-eval-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { z } from 'zod'
import {
directVoiceResponse,
isHelpCenterSupportRequest,
isOrderPlacementRequest,
ORDER_PLACEMENT_UNAVAILABLE_REPLY,
prepareVoiceModelMessages,
UNDOCUMENTED_PRODUCT_SIGNIN_ORDER_REPLY,
UNDOCUMENTED_PRODUCT_SIGNIN_TICKET_REPLY,
Expand Down Expand Up @@ -73,6 +75,19 @@ export default {
: null
if (directResponse) return Response.json({ text: directResponse, toolCalls: [], direct: true })

if (latest?.role === 'user' && typeof latest.content === 'string' && isOrderPlacementRequest(latest.content)) {
return Response.json({ text: ORDER_PLACEMENT_UNAVAILABLE_REPLY, toolCalls: [], direct: true })
}

const originalRequest = latest?.role === 'user' && latest.content === SIGN_IN_CONTINUATION
? [...messages]
.reverse()
.find((message) => message.role === 'user' && message.content !== SIGN_IN_CONTINUATION)
: null
if (originalRequest && isOrderPlacementRequest(String(originalRequest.content))) {
return Response.json({ text: ORDER_PLACEMENT_UNAVAILABLE_REPLY, toolCalls: [], direct: true })
}

// Production persists an ordinary ticket request as a pending escalation
// before navigating to Shopify sign-in, then opens it deterministically on
// return. Mirror that state transition here instead of asking the model to
Expand Down
44 changes: 39 additions & 5 deletions apps/desk/src/identity/shopify-customer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ const TRACKING_LIMIT = 5

export const SHOPIFY_CUSTOMER_SESSION_COOKIE = 'able_shopify_customer'
export const SHOPIFY_CUSTOMER_LOGIN_COOKIE = 'able_shopify_login'
export const SHOPIFY_CUSTOMER_RESUME_COOKIE = 'able_shopify_resume'
/** Login transactions are short-lived: the redirect round-trip only. */
export const SHOPIFY_LOGIN_TRANSACTION_TTL_SECONDS = 10 * 60
/** One navigation only: enough for the callback to render the resumed chat. */
export const SHOPIFY_SUPPORT_RESUME_TTL_SECONDS = 2 * 60
export const SHOPIFY_CUSTOMER_SESSION_MAX_SECONDS = 60 * 60

type ShopifyCustomerEnv = {
Expand Down Expand Up @@ -56,11 +59,17 @@ export type ShopifyCustomerContextResult =
| { status: 'ok'; customer: { name: string; email: string | null; orders: ShopifyCustomerOrder[] } }
| { status: 'unavailable' }

type LoginTransaction = { state: string; verifier: string; expiresAt: number }
type LoginTransaction = { state: string; verifier: string; expiresAt: number; supportSession?: string }
type SupportResume = { supportSession: string; expiresAt: number }

type Discovery = { authorizationEndpoint: string; tokenEndpoint: string; graphqlEndpoint: string }

const encoder = new TextEncoder()
const SUPPORT_SESSION_PATTERN = /^voice-[a-z0-9]{20}$/

function supportSessionName(value: string | null | undefined): string | null {
return value && SUPPORT_SESSION_PATTERN.test(value) ? value : null
}

function shopHostname(domain: string | undefined): string | null {
const trimmed = (domain ?? '').trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '')
Expand Down Expand Up @@ -181,7 +190,7 @@ export type ShopifyLoginStart = { url: string; transactionToken: string }
*/
export async function beginShopifyCustomerLogin(
env: ShopifyCustomerEnv,
input: { redirectUri: string; secret: string },
input: { redirectUri: string; secret: string; supportSession?: string | null },
options: { fetcher?: typeof fetch; now?: () => number; timeoutMs?: number } = {},
): Promise<ShopifyLoginStart | null> {
const hostname = shopHostname(env.SHOPIFY_SHOP_DOMAIN)
Expand All @@ -204,7 +213,13 @@ export async function beginShopifyCustomerLogin(
url.searchParams.set('code_challenge', challenge)
url.searchParams.set('code_challenge_method', 'S256')

const transaction: LoginTransaction = { state, verifier, expiresAt: now + SHOPIFY_LOGIN_TRANSACTION_TTL_SECONDS * 1000 }
const supportSession = supportSessionName(input.supportSession)
const transaction: LoginTransaction = {
state,
verifier,
expiresAt: now + SHOPIFY_LOGIN_TRANSACTION_TTL_SECONDS * 1000,
...(supportSession ? { supportSession } : {}),
}
return { url: url.toString(), transactionToken: await signToken(input.secret, transaction) }
}

Expand All @@ -218,7 +233,7 @@ export async function completeShopifyCustomerLogin(
env: ShopifyCustomerEnv,
input: { code: string; state: string; transactionToken: string; redirectUri: string; secret: string },
options: { fetcher?: typeof fetch; now?: () => number; timeoutMs?: number } = {},
): Promise<{ session: ShopifyCustomerSession; sessionToken: string } | null> {
): Promise<{ session: ShopifyCustomerSession; sessionToken: string; supportResumeToken: string | null } | null> {
const hostname = shopHostname(env.SHOPIFY_SHOP_DOMAIN)
const clientId = env.SHOPIFY_CUSTOMER_CLIENT_ID?.trim()
if (!hostname || !clientId || !input.secret || !input.code || !input.state) return null
Expand Down Expand Up @@ -273,7 +288,26 @@ export async function completeShopifyCustomerLogin(
logOutcome('missing_email')
return null
}
return { session, sessionToken: await signToken(input.secret, session) }
const supportSession = supportSessionName(transaction.supportSession)
const supportResumeToken = supportSession
? await signToken(input.secret, {
supportSession,
expiresAt: now + SHOPIFY_SUPPORT_RESUME_TTL_SECONDS * 1_000,
} satisfies SupportResume)
: null
return { session, sessionToken: await signToken(input.secret, session), supportResumeToken }
}

/** Resolve the one-use, OAuth-bound support session after the callback. */
export async function verifyShopifySupportResume(
secret: string,
token: string | null | undefined,
now: number = Date.now(),
): Promise<string | null> {
if (!secret || !token) return null
const parsed = await verifyToken(secret, token) as SupportResume | null
if (!parsed || typeof parsed.expiresAt !== 'number' || parsed.expiresAt < now) return null
return supportSessionName(parsed.supportSession)
}

/** Sign a customer session into the transportable cookie token form. */
Expand Down
32 changes: 32 additions & 0 deletions apps/desk/src/voice/conversation-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type RestoredTranscriptMessage = {
role: 'user' | 'assistant'
text: string
timestamp: number
}

function sameMessage(left: RestoredTranscriptMessage, right: RestoredTranscriptMessage): boolean {
return left.role === right.role && left.text === right.text
}

/**
* Join persisted server history to the current VoiceClient transcript without
* duplicating the overlap retained across an automatic WebSocket reconnect.
*/
export function mergeConversationHistory(
persisted: RestoredTranscriptMessage[],
current: RestoredTranscriptMessage[],
): RestoredTranscriptMessage[] {
const maximumOverlap = Math.min(persisted.length, current.length)
for (let overlap = maximumOverlap; overlap > 0; overlap--) {
const persistedStart = persisted.length - overlap
let matches = true
for (let index = 0; index < overlap; index++) {
if (!sameMessage(persisted[persistedStart + index]!, current[index]!)) {
matches = false
break
}
}
if (matches) return [...persisted, ...current.slice(overlap)]
}
return [...persisted, ...current]
}
12 changes: 12 additions & 0 deletions apps/desk/src/voice/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const UNDOCUMENTED_PRODUCT_SIGNIN_TICKET_REPLY =
"I don't have a documented guide for that. Sign in with your store account below and I can open a ticket for the team."
export const UNDOCUMENTED_PRODUCT_FORM_REPLY =
"I don't have a documented guide for that. Open a support request through the form and the team will take it from there."
export const ORDER_PLACEMENT_UNAVAILABLE_REPLY =
"I can help you choose a product, but I can’t add items to your cart, take payment, or place an order. Nothing has been ordered or charged. Please use the product card or the store checkout to complete the purchase."

export type VoiceModelMessage = { role: 'user' | 'assistant'; content: string }

Expand Down Expand Up @@ -35,6 +37,15 @@ export function isStorefrontShoppingRequest(transcript: string): boolean {
return STOREFRONT_SHOPPING_INTENT.test(transcript.replace(/\s+/g, ' ').trim())
}

const ORDER_PLACEMENT_ACTION = /\b(?:place|create|make|submit)\b.{0,36}\b(?:an?\s+)?orders?\b|\b(?:can|could|would|will)\s+you\s+order\b|\b(?:please\s+)?order\s+(?:this|that|it|one)(?:\s+for me)?\b|\b(?:buy|purchase)\b.{0,24}\b(?:for me|on my behalf)\b|\b(?:add|put)\b.{0,24}\b(?:to|in)\s+(?:my|the)\s+cart\b|\b(?:check\s*out|checkout)\b.{0,24}\b(?:for me|on my behalf|this|that|it|now)\b/i

/** Side-effecting commerce is not connected; never let the model imply it is. */
export function isOrderPlacementRequest(transcript: string): boolean {
const normalized = transcript.replace(/\s+/g, ' ').trim()
return ORDER_PLACEMENT_ACTION.test(normalized)
|| /\b(?:i\s+)?(?:want|would like|need|am ready|i'm ready)\s+to\s+(?:order|buy|purchase)\b/i.test(normalized)
}

/**
* Identify turns that must be grounded in published support content. This is
* an orchestration guard, not merely a prompt hint: hosted models can
Expand Down Expand Up @@ -289,6 +300,7 @@ When a tool is needed, call it before writing any reply. Never narrate that you
${productCapability}
For policy or warranty questions, call search_help_center first. For every how-to, product care, shipping, repair, or troubleshooting question, always call search_help_center first with a short topic query of two to six words. Do not ask the caller to identify or correct the product before that search. ${productSourceRouting} Even when you do not recognize the product or the question sounds unusual, search the appropriate source before deciding: never call a product or device question unsupported or out of scope without a search result for it, and never say you lack information unless the appropriate search already returned no_match in this turn. When search_help_center returns status ok with one or more articles, that is a documented answer: answer from the closest returned article and never say that no guide, no direct guide, or no information was found. If the closest guide is general rather than model-specific, say that precisely while still giving its sourced next step. Answer only from the returned article or storefront content. The matching help articles are shown to the caller as links automatically, so give a complete useful answer in natural prose and point them to the linked guide when it contains additional steps. After a successful help result, end the reply after the documented answer. Do not mention or offer a ticket in that reply, even conditionally; wait for the customer to say whether the step worked. If help-centre search returns no_match, say you do not have a documented support answer for that and offer to open a ticket — do not answer such questions from memory. If it returns unavailable, say you cannot check the help articles right now and offer a ticket. Never include a help-centre URL in your reply — the matching articles are already linked for the caller.
${actionCapability}
You cannot add products to a cart, start or complete checkout, take payment, or place, create, submit, confirm, or cancel an order. Those write capabilities are not connected. Never claim an order was placed, confirmed, submitted, purchased, paid for, or charged. If the caller asks you to buy or order something for them, clearly say that nothing has been ordered or charged and direct them to the product card or store checkout.
You cannot look up or report ticket status in this channel. If the caller asks about an existing ticket's status, say that updates arrive by email through their private case link and that you cannot check status here. Never invent or guess a status. Offer to open a new ticket only if they describe a new problem.

CONVERSATION
Expand Down
80 changes: 76 additions & 4 deletions apps/desk/src/voice/deepgram-flux-tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const CONNECT_TIMEOUT_MS = 4_000
const GENERATION_TIMEOUT_MS = 15_000
const MAX_TEXT_LENGTH = 2_000
const OUTPUT_SAMPLE_RATE = 24_000
// 100 ms of mono 16-bit audio. Workers AI streams arbitrary byte boundaries,
// including odd-length chunks; the browser client constructs Int16Array views
// per WebSocket frame, so coalesce and align them before forwarding.
const PCM_STREAM_FRAME_BYTES = OUTPUT_SAMPLE_RATE * 2 / 10
const MODEL_PATTERN = /^flux-[a-z0-9-]+-en$/

type DeepgramUpgradeResponse = {
Expand Down Expand Up @@ -77,13 +81,13 @@ type WorkersAIBinding = {
}

/** Cloudflare-hosted Aura fallback that matches Flux's raw PCM wire format. */
export class WorkersAIPcmTTS implements TTSProvider {
export class WorkersAIPcmTTS implements TTSProvider, StreamingTTSProvider {
constructor(
readonly ai: WorkersAIBinding,
readonly speaker = 'harmonia',
) {}

async synthesize(text: string, signal?: AbortSignal): Promise<ArrayBuffer | null> {
async #response(text: string, signal?: AbortSignal): Promise<Response | null> {
const speech = normalizeSpeech(text)
if (!speech) return null
if (speech.length > MAX_TEXT_LENGTH) {
Expand All @@ -107,8 +111,7 @@ export class WorkersAIPcmTTS implements TTSProvider {
logTTSFailure('workers_ai_aura_2', { status: response.status })
return null
}
const audio = await response.arrayBuffer()
return audio.byteLength > 0 ? audio : null
return response
} catch (error) {
if (signal?.aborted) return null
logTTSFailure('workers_ai_aura_2', {
Expand All @@ -117,6 +120,75 @@ export class WorkersAIPcmTTS implements TTSProvider {
return null
}
}

async synthesize(text: string, signal?: AbortSignal): Promise<ArrayBuffer | null> {
const frames: ArrayBuffer[] = []
let byteLength = 0
for await (const frame of this.synthesizeStream(text, signal)) {
frames.push(frame)
byteLength += frame.byteLength
}
if (byteLength === 0) return null

const joined = new Uint8Array(byteLength)
let offset = 0
for (const frame of frames) {
joined.set(new Uint8Array(frame), offset)
offset += frame.byteLength
}
return joined.buffer
}

async *synthesizeStream(text: string, signal?: AbortSignal): AsyncGenerator<ArrayBuffer> {
const response = await this.#response(text, signal)
if (!response || signal?.aborted) return
if (!response.body) {
const audio = await response.arrayBuffer()
if (audio.byteLength > 0) yield audio
return
}

const reader = response.body.getReader()
let frame = new Uint8Array(PCM_STREAM_FRAME_BYTES)
let frameLength = 0
try {
for (;;) {
if (signal?.aborted) {
await reader.cancel('interrupted')
return
}
const { done, value } = await reader.read()
if (done) break
let offset = 0
while (offset < value.byteLength) {
const copied = Math.min(frame.byteLength - frameLength, value.byteLength - offset)
frame.set(value.subarray(offset, offset + copied), frameLength)
frameLength += copied
offset += copied
if (frameLength === frame.byteLength) {
yield frame.buffer
frame = new Uint8Array(PCM_STREAM_FRAME_BYTES)
frameLength = 0
}
}
}

// linear16 must end on a complete two-byte sample. A malformed trailing
// byte is discarded rather than sending a frame the VoiceClient cannot
// construct an Int16Array over.
const alignedLength = frameLength - (frameLength % 2)
if (alignedLength > 0) yield frame.slice(0, alignedLength).buffer
if (alignedLength !== frameLength) logTTSFailure('workers_ai_aura_2', { reason: 'unaligned_audio' })
} catch (error) {
if (!signal?.aborted) {
logTTSFailure('workers_ai_aura_2', {
reason: error instanceof Error && error.name === 'AbortError' ? 'timeout' : 'provider',
})
}
} finally {
reader.releaseLock()
}
}
}

class AudioFrameQueue {
Expand Down
Loading
Loading