@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.
- package/dist/agent.js +6 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +13 -1
- package/dist/runtime.js.map +1 -1
- package/dist/templates/echo/agent.js +3 -0
- package/dist/templates/echo-dc/agent.js +3 -0
- package/dist/templates/game-sync/agent.js +119 -49
- package/dist/templates/recording-consent/agent.js +1003 -0
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +11 -0
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/voice-showcase/agent.js +168 -10
- package/dist/templates/voice-starter/agent.js +3 -0
- package/dist/templates/webhooks/agent.js +3 -0
- package/dist/templates/webhooks-redis/agent.js +3 -0
- package/package.json +1 -1
- package/templates/README.md +10 -4
- package/templates/game-sync-protocol.ts +13 -0
- package/templates/game-sync-world-layout.ts +27 -0
- package/templates/game-sync.ts +114 -60
- package/templates/recording-consent/agent.ts +162 -0
- package/templates/recording-consent/conversation.ts +300 -0
- package/templates/voice-showcase/conversation.ts +17 -6
- package/templates/voice-showcase/weather.ts +184 -5
|
@@ -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
|
-
|
|
404
|
-
|
|
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
|
|
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:
|
|
444
|
+
state: { ...state, weatherCity: city, weatherCountry: country },
|
|
434
445
|
speakLines: [],
|
|
435
446
|
messages: [],
|
|
436
|
-
pendingWeather: { city, country
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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{
|
|
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
|
}
|