@voicethere/agent 0.5.4 → 0.5.6

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.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Pure conversation state machine for the recording-consent template.
3
+ * Tests import this module directly — no defineAgent dependency.
4
+ */
5
+
6
+ export const CONSENT_PROMPT =
7
+ "This call may be recorded for quality purposes. Is that OK?";
8
+
9
+ export const NAME_PROMPT = "May I have your name please?";
10
+
11
+ export const BIRTHDATE_PROMPT = "And your date of birth?";
12
+
13
+ export const RECORDING_DISABLED_SKIP_MESSAGE =
14
+ "Conversation recording is not enabled for this project.";
15
+
16
+ export type ConversationPhase =
17
+ "awaitingConsent" | "awaitingName" | "awaitingBirthdate" | "complete";
18
+
19
+ export type RecordingAction = "pause" | "stop" | "start" | "resume" | null;
20
+
21
+ export interface ConversationState {
22
+ phase: ConversationPhase;
23
+ recordingAvailable: boolean;
24
+ consent?: boolean;
25
+ /** True when consent was skipped because project recording is off. */
26
+ consentSkipped: boolean;
27
+ name?: string;
28
+ birthdate?: string;
29
+ }
30
+
31
+ export interface OutboundMessage {
32
+ type: "chat_reply" | "agent_event";
33
+ text?: string;
34
+ event?: string;
35
+ sessionId?: string;
36
+ }
37
+
38
+ export interface ConversationTurnResult {
39
+ state: ConversationState;
40
+ speakLines: string[];
41
+ messages: OutboundMessage[];
42
+ recordingAction: RecordingAction;
43
+ /** When true, agent should warn that project recording is disabled. */
44
+ warnRecordingDisabled?: boolean;
45
+ }
46
+
47
+ export function createInitialState(
48
+ recordingAvailable: boolean,
49
+ ): ConversationState {
50
+ if (recordingAvailable) {
51
+ return {
52
+ phase: "awaitingConsent",
53
+ recordingAvailable,
54
+ consentSkipped: false,
55
+ };
56
+ }
57
+ return {
58
+ phase: "awaitingName",
59
+ recordingAvailable,
60
+ consentSkipped: true,
61
+ };
62
+ }
63
+
64
+ function speakAndChat(text: string): {
65
+ speakLines: string[];
66
+ messages: OutboundMessage[];
67
+ } {
68
+ return {
69
+ speakLines: [text],
70
+ messages: [{ type: "chat_reply", text }],
71
+ };
72
+ }
73
+
74
+ export function beginSession(
75
+ recordingAvailable: boolean,
76
+ ): ConversationTurnResult {
77
+ const state = createInitialState(recordingAvailable);
78
+ if (recordingAvailable) {
79
+ const prompt = speakAndChat(CONSENT_PROMPT);
80
+ return {
81
+ state,
82
+ speakLines: prompt.speakLines,
83
+ messages: prompt.messages,
84
+ recordingAction: null,
85
+ };
86
+ }
87
+ const skip = speakAndChat(RECORDING_DISABLED_SKIP_MESSAGE);
88
+ const name = speakAndChat(NAME_PROMPT);
89
+ return {
90
+ state,
91
+ speakLines: [...skip.speakLines, ...name.speakLines],
92
+ messages: [...skip.messages, ...name.messages],
93
+ recordingAction: null,
94
+ warnRecordingDisabled: true,
95
+ };
96
+ }
97
+
98
+ export function isConsentNo(utterance: string): boolean {
99
+ const lower = utterance.toLowerCase().trim();
100
+ if (/\bnot\s+ok(?:ay)?\b/i.test(lower)) return true;
101
+ const noPhrases = [
102
+ "no",
103
+ "nope",
104
+ "nah",
105
+ "don't",
106
+ "do not",
107
+ "decline",
108
+ "refuse",
109
+ ];
110
+ if (noPhrases.some((p) => lower === p || lower.startsWith(`${p} `))) {
111
+ return true;
112
+ }
113
+ return /\b(no|nope|nah)\b/i.test(utterance) && !/\bknow\b/i.test(utterance);
114
+ }
115
+
116
+ export function isConsentYes(utterance: string): boolean {
117
+ if (isConsentNo(utterance)) return false;
118
+ const lower = utterance.toLowerCase().trim();
119
+ const yesPhrases = [
120
+ "yes",
121
+ "yeah",
122
+ "yep",
123
+ "sure",
124
+ "ok",
125
+ "okay",
126
+ "that's fine",
127
+ "that is fine",
128
+ "go ahead",
129
+ "fine",
130
+ "absolutely",
131
+ ];
132
+ if (yesPhrases.some((p) => lower === p || lower.startsWith(`${p} `))) {
133
+ return true;
134
+ }
135
+ return /\b(yes|yeah|yep|sure|ok|okay)\b/i.test(utterance);
136
+ }
137
+
138
+ export function extractName(utterance: string): string | null {
139
+ const trimmed = utterance.trim();
140
+ const patterns = [/(?:my name is|i'm|i am|call me)\s+(.+)/i];
141
+ for (const pattern of patterns) {
142
+ const match = trimmed.match(pattern);
143
+ if (match?.[1]) {
144
+ return sanitizeToken(match[1], 40);
145
+ }
146
+ }
147
+ if (trimmed.length > 0 && trimmed.length <= 60) {
148
+ return sanitizeToken(trimmed, 40);
149
+ }
150
+ return null;
151
+ }
152
+
153
+ export function extractBirthdate(utterance: string): string | null {
154
+ const trimmed = utterance.trim();
155
+ const iso = trimmed.match(/\b(\d{4}-\d{2}-\d{2})\b/);
156
+ if (iso?.[1]) return iso[1];
157
+ const slash = trimmed.match(/\b(\d{1,2}\/\d{1,2}\/\d{2,4})\b/);
158
+ if (slash?.[1]) return slash[1];
159
+ const spoken = trimmed.match(
160
+ /\b(january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4}\b/i,
161
+ );
162
+ if (spoken?.[0]) return spoken[0];
163
+ if (trimmed.length >= 4 && trimmed.length <= 40) {
164
+ return sanitizeToken(trimmed, 40);
165
+ }
166
+ return null;
167
+ }
168
+
169
+ function sanitizeToken(raw: string, maxLen: number): string {
170
+ let value = raw
171
+ .trim()
172
+ .replace(/[.,!?;:]+$/g, "")
173
+ .trim();
174
+ if (value.length > maxLen) {
175
+ value = value.slice(0, maxLen).trim();
176
+ }
177
+ return value;
178
+ }
179
+
180
+ function askNameAgain(state: ConversationState): ConversationTurnResult {
181
+ const prompt = speakAndChat(
182
+ "Sorry, I didn't catch your name. May I have your name please?",
183
+ );
184
+ return {
185
+ state,
186
+ speakLines: prompt.speakLines,
187
+ messages: prompt.messages,
188
+ recordingAction: null,
189
+ };
190
+ }
191
+
192
+ function askBirthdateAgain(state: ConversationState): ConversationTurnResult {
193
+ const prompt = speakAndChat(
194
+ "Sorry, I didn't catch your date of birth. Could you repeat it?",
195
+ );
196
+ return {
197
+ state,
198
+ speakLines: prompt.speakLines,
199
+ messages: prompt.messages,
200
+ recordingAction: null,
201
+ };
202
+ }
203
+
204
+ function finishAfterPii(state: ConversationState): ConversationTurnResult {
205
+ const next: ConversationState = { ...state, phase: "complete" };
206
+ const thankYou = speakAndChat(
207
+ `Thank you, ${state.name}. We have your date of birth on file.`,
208
+ );
209
+ let recordingAction: RecordingAction = null;
210
+ if (state.consent === true && state.recordingAvailable) {
211
+ recordingAction = "resume";
212
+ }
213
+ return {
214
+ state: next,
215
+ speakLines: thankYou.speakLines,
216
+ messages: thankYou.messages,
217
+ recordingAction,
218
+ };
219
+ }
220
+
221
+ export function handleUtterance(
222
+ state: ConversationState,
223
+ utterance: string,
224
+ ): ConversationTurnResult {
225
+ switch (state.phase) {
226
+ case "awaitingConsent": {
227
+ if (isConsentNo(utterance)) {
228
+ const next: ConversationState = {
229
+ ...state,
230
+ phase: "awaitingName",
231
+ consent: false,
232
+ };
233
+ const name = speakAndChat(NAME_PROMPT);
234
+ return {
235
+ state: next,
236
+ speakLines: name.speakLines,
237
+ messages: name.messages,
238
+ recordingAction: "stop",
239
+ };
240
+ }
241
+ if (isConsentYes(utterance)) {
242
+ const next: ConversationState = {
243
+ ...state,
244
+ phase: "awaitingName",
245
+ consent: true,
246
+ };
247
+ const name = speakAndChat(NAME_PROMPT);
248
+ return {
249
+ state: next,
250
+ speakLines: name.speakLines,
251
+ messages: name.messages,
252
+ recordingAction: "pause",
253
+ };
254
+ }
255
+ const retry = speakAndChat(
256
+ "Please say yes or no — may we record this conversation?",
257
+ );
258
+ return {
259
+ state,
260
+ speakLines: retry.speakLines,
261
+ messages: retry.messages,
262
+ recordingAction: null,
263
+ };
264
+ }
265
+ case "awaitingName": {
266
+ const name = extractName(utterance);
267
+ if (!name) {
268
+ return askNameAgain(state);
269
+ }
270
+ const next: ConversationState = {
271
+ ...state,
272
+ phase: "awaitingBirthdate",
273
+ name,
274
+ };
275
+ const birthdate = speakAndChat(BIRTHDATE_PROMPT);
276
+ return {
277
+ state: next,
278
+ speakLines: birthdate.speakLines,
279
+ messages: birthdate.messages,
280
+ recordingAction: null,
281
+ };
282
+ }
283
+ case "awaitingBirthdate": {
284
+ const birthdate = extractBirthdate(utterance);
285
+ if (!birthdate) {
286
+ return askBirthdateAgain(state);
287
+ }
288
+ return finishAfterPii({ ...state, birthdate });
289
+ }
290
+ case "complete": {
291
+ const done = speakAndChat("We are all set. How can I help you today?");
292
+ return {
293
+ state,
294
+ speakLines: done.speakLines,
295
+ messages: done.messages,
296
+ recordingAction: null,
297
+ };
298
+ }
299
+ }
300
+ }
@@ -8,6 +8,7 @@ import { pickFunFact } from "./fun-facts.js";
8
8
  import {
9
9
  formatWeatherSpeech,
10
10
  lookupWeather,
11
+ matchCountryName,
11
12
  parseLocationUtterance,
12
13
  type FetchFn,
13
14
  type WeatherResult,
@@ -400,8 +401,19 @@ export function handleUtterance(
400
401
 
401
402
  case "weatherAwaitingLocation": {
402
403
  const parsed = parseLocationUtterance(text);
403
- const city = parsed?.city ?? state.weatherCity;
404
- const country = parsed?.country ?? state.weatherCountry;
404
+ let city = parsed?.city || state.weatherCity;
405
+ let country = parsed?.country || state.weatherCountry;
406
+
407
+ // Country-only follow-up: "Thailand" must not overwrite a stored ZIP as city.
408
+ if (state.weatherCity && !country) {
409
+ const followUp =
410
+ matchCountryName(text) ??
411
+ (parsed?.city ? matchCountryName(parsed.city) : null);
412
+ if (followUp) {
413
+ city = state.weatherCity;
414
+ country = followUp;
415
+ }
416
+ }
405
417
 
406
418
  if (!city) {
407
419
  const ask = speakAndChat(
@@ -414,7 +426,7 @@ export function handleUtterance(
414
426
  };
415
427
  }
416
428
 
417
- if (!country && !parsed?.country && !state.weatherCountry) {
429
+ if (!country) {
418
430
  return {
419
431
  state: {
420
432
  ...state,
@@ -428,12 +440,11 @@ export function handleUtterance(
428
440
  };
429
441
  }
430
442
 
431
- const resolvedCountry = country ?? state.weatherCountry;
432
443
  return {
433
- state: { ...state, weatherCity: city, weatherCountry: resolvedCountry },
444
+ state: { ...state, weatherCity: city, weatherCountry: country },
434
445
  speakLines: [],
435
446
  messages: [],
436
- pendingWeather: { city, country: resolvedCountry },
447
+ pendingWeather: { city, country },
437
448
  };
438
449
  }
439
450
 
@@ -20,6 +20,163 @@ export interface WeatherResult {
20
20
 
21
21
  export type FetchFn = typeof fetch;
22
22
 
23
+ export type ParsedLocation = {
24
+ city?: string;
25
+ country?: string;
26
+ };
27
+
28
+ const DIGIT_WORDS: Record<string, string> = {
29
+ zero: "0",
30
+ oh: "0",
31
+ o: "0",
32
+ one: "1",
33
+ two: "2",
34
+ three: "3",
35
+ four: "4",
36
+ five: "5",
37
+ six: "6",
38
+ seven: "7",
39
+ eight: "8",
40
+ nine: "9",
41
+ };
42
+
43
+ /** Lowercase aliases → Open-Meteo-friendly country names. */
44
+ const COUNTRY_ALIASES: Record<string, string> = {
45
+ thailand: "Thailand",
46
+ us: "United States",
47
+ usa: "United States",
48
+ america: "United States",
49
+ "united states": "United States",
50
+ "united states of america": "United States",
51
+ uk: "United Kingdom",
52
+ britain: "United Kingdom",
53
+ england: "United Kingdom",
54
+ "united kingdom": "United Kingdom",
55
+ "great britain": "United Kingdom",
56
+ germany: "Germany",
57
+ france: "France",
58
+ spain: "Spain",
59
+ italy: "Italy",
60
+ japan: "Japan",
61
+ china: "China",
62
+ india: "India",
63
+ australia: "Australia",
64
+ canada: "Canada",
65
+ brazil: "Brazil",
66
+ mexico: "Mexico",
67
+ netherlands: "Netherlands",
68
+ holland: "Netherlands",
69
+ "the netherlands": "Netherlands",
70
+ belgium: "Belgium",
71
+ switzerland: "Switzerland",
72
+ sweden: "Sweden",
73
+ norway: "Norway",
74
+ denmark: "Denmark",
75
+ finland: "Finland",
76
+ poland: "Poland",
77
+ portugal: "Portugal",
78
+ greece: "Greece",
79
+ turkey: "Turkey",
80
+ egypt: "Egypt",
81
+ "south africa": "South Africa",
82
+ "new zealand": "New Zealand",
83
+ ireland: "Ireland",
84
+ singapore: "Singapore",
85
+ malaysia: "Malaysia",
86
+ indonesia: "Indonesia",
87
+ vietnam: "Vietnam",
88
+ philippines: "Philippines",
89
+ "south korea": "South Korea",
90
+ korea: "South Korea",
91
+ taiwan: "Taiwan",
92
+ "hong kong": "Hong Kong",
93
+ israel: "Israel",
94
+ uae: "United Arab Emirates",
95
+ "united arab emirates": "United Arab Emirates",
96
+ "saudi arabia": "Saudi Arabia",
97
+ pakistan: "Pakistan",
98
+ bangladesh: "Bangladesh",
99
+ nigeria: "Nigeria",
100
+ kenya: "Kenya",
101
+ argentina: "Argentina",
102
+ chile: "Chile",
103
+ colombia: "Colombia",
104
+ peru: "Peru",
105
+ austria: "Austria",
106
+ "czech republic": "Czechia",
107
+ czechia: "Czechia",
108
+ romania: "Romania",
109
+ hungary: "Hungary",
110
+ ukraine: "Ukraine",
111
+ };
112
+
113
+ export function matchCountryName(text: string): string | null {
114
+ const key = text
115
+ .trim()
116
+ .toLowerCase()
117
+ .replace(/[.,!?]+$/g, "")
118
+ .replace(/\s+/g, " ");
119
+ if (!key) return null;
120
+ return COUNTRY_ALIASES[key] ?? null;
121
+ }
122
+
123
+ /** "eight four three two zero" → "84320" (4–6 digits). */
124
+ export function spokenDigitsToPostal(text: string): string | null {
125
+ const tokens = text
126
+ .toLowerCase()
127
+ .split(/[^a-z0-9]+/)
128
+ .filter(Boolean);
129
+ const digits: string[] = [];
130
+ for (const token of tokens) {
131
+ if (/^\d$/.test(token)) {
132
+ digits.push(token);
133
+ continue;
134
+ }
135
+ const mapped = DIGIT_WORDS[token];
136
+ if (mapped) {
137
+ digits.push(mapped);
138
+ }
139
+ // Skip STT filler ("welcome", "down", "there", …).
140
+ }
141
+ if (digits.length >= 4 && digits.length <= 6) {
142
+ return digits.join("");
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function splitTrailingCountry(
148
+ text: string,
149
+ ): { rest: string; country: string } | null {
150
+ const words = text.trim().split(/\s+/).filter(Boolean);
151
+ if (words.length < 2) return null;
152
+ for (let n = Math.min(3, words.length - 1); n >= 1; n -= 1) {
153
+ const tail = words.slice(-n).join(" ");
154
+ const country = matchCountryName(tail);
155
+ if (country) {
156
+ return { rest: words.slice(0, -n).join(" "), country };
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function cityFromRemainder(rest: string): string | null {
163
+ const trimmed = rest
164
+ .trim()
165
+ .replace(/[.,!?;:]+$/g, "")
166
+ .replace(/\s+(?:in the|in|of)$/i, "")
167
+ .trim();
168
+ if (!trimmed) return null;
169
+ if (/^\d{4,6}(-\d{4})?$/.test(trimmed)) {
170
+ return trimmed;
171
+ }
172
+ const spoken = spokenDigitsToPostal(trimmed);
173
+ if (spoken) return spoken;
174
+ if (trimmed.length >= 2 && trimmed.length <= 60) {
175
+ return trimmed;
176
+ }
177
+ return null;
178
+ }
179
+
23
180
  /** Map WMO weather_code to a short English phrase. */
24
181
  export function wmoCodeToPhrase(code: number): string {
25
182
  if (code === 0) return "clear sky";
@@ -43,20 +200,36 @@ export function formatWeatherSpeech(result: WeatherResult): string {
43
200
  /** Parse city/zip and country from a single utterance when possible. */
44
201
  export function parseLocationUtterance(
45
202
  utterance: string,
46
- ): { city: string; country?: string } | null {
203
+ ): ParsedLocation | null {
47
204
  const text = utterance.trim();
48
205
  if (!text) return null;
49
206
 
207
+ const countryOnly = matchCountryName(text);
208
+ if (countryOnly) {
209
+ return { country: countryOnly };
210
+ }
211
+
212
+ const trailing = splitTrailingCountry(text);
213
+ if (trailing) {
214
+ const city = cityFromRemainder(trailing.rest);
215
+ if (city) {
216
+ return { city, country: trailing.country };
217
+ }
218
+ return { country: trailing.country };
219
+ }
220
+
50
221
  const inMatch = text.match(
51
222
  /^(?:in\s+)?(.+?)\s+in\s+([a-zA-Z][\w\s.-]{1,40})$/i,
52
223
  );
53
224
  if (inMatch) {
54
- return { city: inMatch[1]!.trim(), country: inMatch[2]!.trim() };
225
+ const country = matchCountryName(inMatch[2]!) ?? inMatch[2]!.trim();
226
+ return { city: inMatch[1]!.trim(), country };
55
227
  }
56
228
 
57
229
  const commaMatch = text.match(/^(.+?),\s*([a-zA-Z][\w\s.-]{1,40})$/);
58
230
  if (commaMatch) {
59
- return { city: commaMatch[1]!.trim(), country: commaMatch[2]!.trim() };
231
+ const country = matchCountryName(commaMatch[2]!) ?? commaMatch[2]!.trim();
232
+ return { city: commaMatch[1]!.trim(), country };
60
233
  }
61
234
 
62
235
  const countryMatch = text.match(
@@ -64,16 +237,22 @@ export function parseLocationUtterance(
64
237
  );
65
238
  if (countryMatch && countryMatch[2]!.split(/\s+/).length <= 3) {
66
239
  const city = countryMatch[1]!.trim();
67
- const country = countryMatch[2]!.trim();
240
+ const countryRaw = countryMatch[2]!.trim();
241
+ const country = matchCountryName(countryRaw) ?? countryRaw;
68
242
  if (city.length >= 2 && country.length >= 2) {
69
243
  return { city, country };
70
244
  }
71
245
  }
72
246
 
73
- if (/^\d{5}(-\d{4})?$/.test(text)) {
247
+ if (/^\d{4,6}(-\d{4})?$/.test(text)) {
74
248
  return { city: text };
75
249
  }
76
250
 
251
+ const spokenPostal = spokenDigitsToPostal(text);
252
+ if (spokenPostal) {
253
+ return { city: spokenPostal };
254
+ }
255
+
77
256
  if (text.length >= 2 && text.length <= 60) {
78
257
  return { city: text };
79
258
  }