@chatpanel/events 0.24.0 → 0.28.0
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/extraction.js +293 -0
- package/index.js +17 -0
- package/package.json +7 -3
- package/schedule.js +5 -0
- package/structured.js +901 -0
- package/voice-intents.js +770 -31
package/voice-intents.js
CHANGED
|
@@ -27,6 +27,14 @@
|
|
|
27
27
|
// timestamps come from the host's own timezone via Date. That is the only environmental
|
|
28
28
|
// input, and it is the one users would be astonished to see normalised away.
|
|
29
29
|
|
|
30
|
+
import {
|
|
31
|
+
defineSchema, describeSchema, responseFormat, coerce, createStructuredStream,
|
|
32
|
+
} from './structured.js';
|
|
33
|
+
// The same list meeting triggers read. "Has this person finished the thought?" must have ONE
|
|
34
|
+
// answer in this package — a second copy here would drift, and two features would disagree
|
|
35
|
+
// about the same caption on the same screen.
|
|
36
|
+
import { DANGLING_TAILS } from './schedule.js';
|
|
37
|
+
|
|
30
38
|
export class VoiceIntentError extends Error {
|
|
31
39
|
constructor(code, message) { super(message); this.name = 'VoiceIntentError'; this.code = code; }
|
|
32
40
|
}
|
|
@@ -41,8 +49,12 @@ export const DEFAULT_WAKE = Object.freeze(['chatpanel']);
|
|
|
41
49
|
// the same attempt.
|
|
42
50
|
function slack(len) { return len <= 4 ? 0 : len <= 6 ? 1 : 2; }
|
|
43
51
|
|
|
44
|
-
// The widest span of spoken tokens that may add up to
|
|
52
|
+
// The widest span of spoken tokens that may add up to a ONE-WORD wake phrase ("chat" "pan"
|
|
53
|
+
// "ell"). A longer phrase widens its own window — see compileWake.
|
|
45
54
|
const MAX_WAKE_TOKENS = 3;
|
|
55
|
+
// …but never without limit: the scan is O(tokens x window x phrases) over every utterance,
|
|
56
|
+
// and a wake phrase longer than this is a sentence, not a wake phrase.
|
|
57
|
+
const WAKE_TOKEN_CEILING = 8;
|
|
46
58
|
|
|
47
59
|
// Bounded Levenshtein — returns early once the distance cannot come in under `max`, so a
|
|
48
60
|
// wake scan over a long transcript stays linear in practice.
|
|
@@ -99,39 +111,475 @@ export function tokenize(text) {
|
|
|
99
111
|
* squashed to letters so "chat panel", "ChatPanel" and "chat-panel" are one phrase.
|
|
100
112
|
*/
|
|
101
113
|
export function compileWake(words = DEFAULT_WAKE) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
// SEVERAL PHRASES, however they arrive. People do not say one fixed thing: "ok chatpanel",
|
|
115
|
+
// "okay chat panel" and "hey chatpanel" are one intent with three spellings, and asking
|
|
116
|
+
// someone to pick exactly one is asking them to remember which one they picked.
|
|
117
|
+
//
|
|
118
|
+
// A COMMA SEPARATES THEM — "chatpanel, siri, google" is what anyone would write, and any
|
|
119
|
+
// other separator is a rule to learn. The apparent conflict ("okay, chat panel" is also how
|
|
120
|
+
// you would write ONE phrase) is not a real one: matching strips punctuation from the
|
|
121
|
+
// TRANSCRIPT, so a comma is never needed inside a configured phrase to hear one spoken.
|
|
122
|
+
// Type "okay chat panel" and "okay, chat panel" is heard. `|`, `;` and newlines work too.
|
|
123
|
+
const raw = (Array.isArray(words) ? words : String(words ?? '').split(/[,|;\n]/))
|
|
124
|
+
.map((w) => String(w ?? '').trim())
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
const list = [];
|
|
127
|
+
// The phrases AS TYPED, kept alongside the squashed forms and in the same order. The
|
|
128
|
+
// squashed form is an implementation detail — "okchatpanel" is neither what the user wrote
|
|
129
|
+
// nor what they would say — so any UI that echoes the setting back must have the original
|
|
130
|
+
// to show. Deduped on the squashed form, keeping the first spelling of each.
|
|
131
|
+
const labels = [];
|
|
132
|
+
let widest = 1;
|
|
133
|
+
for (const phrase of raw) {
|
|
134
|
+
// Punctuation and spacing are stripped, so "ok chat panel", "ok, chat panel" and
|
|
135
|
+
// "okchatpanel" compile to one and the same thing — which is what makes the setting
|
|
136
|
+
// forgiving of how it was typed AND of how the transcriber spaced it.
|
|
137
|
+
const squashed = phrase.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
|
|
138
|
+
if (squashed.length < 3) continue; // shorter than this and ordinary speech trips it constantly
|
|
139
|
+
if (list.includes(squashed)) continue;
|
|
140
|
+
list.push(squashed);
|
|
141
|
+
labels.push(phrase);
|
|
142
|
+
// The scan joins adjacent spoken tokens looking for the phrase, so its window has to be
|
|
143
|
+
// at least as wide as the longest phrase is in WORDS — otherwise a three-word wake phrase
|
|
144
|
+
// could never be found, however it was typed. +1 for a transcriber that splits one of
|
|
145
|
+
// them ("chat" "pan" "ell").
|
|
146
|
+
widest = Math.max(widest, phrase.split(/\s+/).filter(Boolean).length + 1);
|
|
147
|
+
}
|
|
105
148
|
if (!list.length) throw new VoiceIntentError('BAD_WAKE', 'wake word must have at least 3 letters');
|
|
106
|
-
return Object.freeze({
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
phrases: Object.freeze(list),
|
|
151
|
+
labels: Object.freeze(labels),
|
|
152
|
+
maxTokens: Math.min(Math.max(widest, MAX_WAKE_TOKENS), WAKE_TOKEN_CEILING),
|
|
153
|
+
});
|
|
107
154
|
}
|
|
108
155
|
|
|
109
156
|
/**
|
|
110
|
-
*
|
|
157
|
+
* How much of what follows the wake word is the command.
|
|
111
158
|
*
|
|
112
|
-
*
|
|
113
|
-
* the
|
|
159
|
+
* THE 720-HOUR TIMER. One caption held six wake words and 420 characters, and the command was
|
|
160
|
+
* "everything after the first one to the end of the line". So "Okay, chat panel. Set a timer
|
|
161
|
+
* for 1 minute" swallowed four later sentences including "…research on the weather for the
|
|
162
|
+
* next 30 days" — and the duration parser, scanning the whole span, found 30 days. The user
|
|
163
|
+
* got a 720-hour timer from a request for one minute.
|
|
164
|
+
*
|
|
165
|
+
* A spoken command is a sentence, occasionally two ("Set a timer for 30 seconds. Make it
|
|
166
|
+
* two."). Never a paragraph. Bounded here, and bounded again by the next wake word: a second
|
|
167
|
+
* address is by definition the end of the first command.
|
|
114
168
|
*/
|
|
115
|
-
export
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
169
|
+
export const MAX_COMMAND_SENTENCES = 2;
|
|
170
|
+
|
|
171
|
+
/** Where every wake phrase sits in this text, in order. Shared by the singular and plural. */
|
|
172
|
+
function wakeHits(raw, tokens, wake) {
|
|
173
|
+
const hits = [];
|
|
174
|
+
const window = wake.maxTokens || MAX_WAKE_TOKENS; // an older compiled wake has no maxTokens
|
|
119
175
|
for (let i = 0; i < tokens.length; i++) {
|
|
120
176
|
let squashed = '';
|
|
121
|
-
|
|
177
|
+
let matched = false;
|
|
178
|
+
for (let n = 0; n < window && i + n < tokens.length && !matched; n++) {
|
|
122
179
|
squashed += tokens[i + n].w.replace(/[^\p{L}\p{N}]/gu, '');
|
|
123
180
|
for (const phrase of wake.phrases) {
|
|
124
181
|
// A window far from the phrase's length cannot match; skip the distance work.
|
|
125
182
|
if (Math.abs(squashed.length - phrase.length) > slack(phrase.length)) continue;
|
|
126
183
|
if (editDistance(squashed, phrase, slack(phrase.length)) <= slack(phrase.length)) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
184
|
+
hits.push({
|
|
185
|
+
phrase,
|
|
186
|
+
start: tokens[i].start,
|
|
187
|
+
end: tokens[i + n].end,
|
|
188
|
+
addressed: isAddressed(raw, tokens, i, n),
|
|
189
|
+
});
|
|
190
|
+
// Skip past the phrase so "chat panel" is one hit, not two overlapping ones.
|
|
191
|
+
i += n;
|
|
192
|
+
matched = true;
|
|
193
|
+
break;
|
|
130
194
|
}
|
|
131
195
|
}
|
|
132
196
|
}
|
|
133
197
|
}
|
|
134
|
-
return
|
|
198
|
+
return hits;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The first `max` sentences of `text`, or all of it when it has fewer.
|
|
203
|
+
*
|
|
204
|
+
* A boundary is terminal punctuation followed by WHITESPACE or the end — not any full stop.
|
|
205
|
+
* "Go to google.com and search" is one sentence; splitting on the dot in a domain cut a
|
|
206
|
+
* command down to "Go to google." and sent that. Decimals ("2.5 minutes") and initials break
|
|
207
|
+
* the same way.
|
|
208
|
+
*/
|
|
209
|
+
function firstSentences(text, max) {
|
|
210
|
+
const t = String(text || '');
|
|
211
|
+
if (!t) return t;
|
|
212
|
+
const re = /[.!?…]+["'\u2019\u201d)\]]*(?=\s|$)/g;
|
|
213
|
+
let taken = 0;
|
|
214
|
+
let m;
|
|
215
|
+
while (taken < max && (m = re.exec(t))) {
|
|
216
|
+
taken += 1;
|
|
217
|
+
if (taken === max) return t.slice(0, m.index + m[0].length);
|
|
218
|
+
}
|
|
219
|
+
// Fewer sentences than asked for — all of it. A trailing fragment with no terminal
|
|
220
|
+
// punctuation is still what they said, and must not be silently emptied.
|
|
221
|
+
return t;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* EVERY "<wake>, <command>" in one utterance, in order.
|
|
226
|
+
*
|
|
227
|
+
* A live caption often carries a whole minute of speech, and a person addressing an assistant
|
|
228
|
+
* addresses it more than once in a minute. Returning only the first match meant the other
|
|
229
|
+
* five requests in the same caption were invisible — and made the first command swallow them.
|
|
230
|
+
*/
|
|
231
|
+
export function findWakeCommands(text, wake = compileWake(), { maxSentences = MAX_COMMAND_SENTENCES } = {}) {
|
|
232
|
+
const raw = String(text || '');
|
|
233
|
+
const tokens = tokenize(raw);
|
|
234
|
+
if (!tokens.length) return [];
|
|
235
|
+
const hits = wakeHits(raw, tokens, wake);
|
|
236
|
+
return hits.map((hit, idx) => {
|
|
237
|
+
// Bounded by the NEXT address, then by sentence count. A second wake word is the end of
|
|
238
|
+
// the first command however the sentences fall.
|
|
239
|
+
const stop = idx + 1 < hits.length ? hits[idx + 1].start : raw.length;
|
|
240
|
+
const span = raw.slice(hit.end, stop);
|
|
241
|
+
const command = trimTrailingLeadIn(stripLeadIn(firstSentences(span, maxSentences)).trim());
|
|
242
|
+
return {
|
|
243
|
+
command,
|
|
244
|
+
wake: hit.phrase,
|
|
245
|
+
heard: raw.slice(hit.start, hit.end),
|
|
246
|
+
at: hit.start,
|
|
247
|
+
addressed: hit.addressed,
|
|
248
|
+
// What was said after the command's own sentences, up to the next address. Not part of
|
|
249
|
+
// the command — kept so a caller refining with a model has the surrounding words.
|
|
250
|
+
rest: raw.slice(hit.end + span.indexOf(command) + command.length, stop).trim(),
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Find "<wake>, <command>" in one utterance — the first one.
|
|
257
|
+
*
|
|
258
|
+
* Returns the command with its ORIGINAL casing, plus which wake phrase matched and where —
|
|
259
|
+
* the host logs the span so a user can see why something fired.
|
|
260
|
+
*/
|
|
261
|
+
export function findWakeCommand(text, wake = compileWake(), opts = {}) {
|
|
262
|
+
return findWakeCommands(text, wake, opts)[0] || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The REQUEST inside a spoken utterance — not everything that followed the wake word.
|
|
267
|
+
*
|
|
268
|
+
* People do not stop talking when they finish asking. A real capture:
|
|
269
|
+
*
|
|
270
|
+
* "Okay, chat panel. Whenever I do anything or ask any question just to do a research for
|
|
271
|
+
* me and get me the answer, okay? All right, so. I want to know how is the weather in
|
|
272
|
+
* Fairview today? All right, so we will see. It does anything. Does it get added to?"
|
|
273
|
+
*
|
|
274
|
+
* Everything after the wake word became the job's name AND its prompt, so the job was a
|
|
275
|
+
* paragraph of thinking-aloud with a weather question buried in the middle — which is what
|
|
276
|
+
* "it didn't separate" means, and why the answer was useless even on the runs that happened.
|
|
277
|
+
*
|
|
278
|
+
* What this does, and deliberately no more: split into sentences, drop the ones that are pure
|
|
279
|
+
* filler, and prefer a QUESTION when one was asked (the LAST one — people circle back, and
|
|
280
|
+
* the restatement is the version they meant). Everything here is free, deterministic and
|
|
281
|
+
* reversible. Turning rambling into a good PROMPT is a model's job, and the parser already
|
|
282
|
+
* says so by returning `needsModel`; this is the FLOOR under that, for when no model is
|
|
283
|
+
* configured and for the instant before one answers. It is a heuristic over speech and it
|
|
284
|
+
* will sometimes pick the wrong sentence — that is precisely why the contract asks the host
|
|
285
|
+
* to pay for a model rather than pretending this is the answer.
|
|
286
|
+
*/
|
|
287
|
+
|
|
288
|
+
// Sentences carrying no request — verbal punctuation, thinking aloud, or narrating the very
|
|
289
|
+
// experiment being run. Matched WHOLE, so "so we will see" goes and "see if the build passed"
|
|
290
|
+
// stays.
|
|
291
|
+
const FILLER_SENTENCE = new RegExp('^(?:'
|
|
292
|
+
+ "ok(?:ay)?|all ?right|right|so|well|um+|uh+|hmm+|yeah|yep|hey|and|but|then|now"
|
|
293
|
+
+ "|let(?:'s| us) see|we(?:'ll| will) see|so we(?:'ll| will) see"
|
|
294
|
+
+ "|i think it is doing something|it does anything|does it (?:do )?anything"
|
|
295
|
+
+ "|hold on(?: a second)?|one second|let me see|i think|i guess|here we go|there we go"
|
|
296
|
+
+ "|test(?:ing)?"
|
|
297
|
+
+ ')[\\s,.!?]*$', 'i');
|
|
298
|
+
|
|
299
|
+
const SENTENCE_SPLIT = /(?<=[.!?])\s+/;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* True when a sentence is only filler — verbal punctuation rather than a request.
|
|
303
|
+
*
|
|
304
|
+
* Checked CLAUSE BY CLAUSE, because people string filler together with commas: "All right,
|
|
305
|
+
* so we will see." is two fillers in one sentence and matches neither whole. Every clause
|
|
306
|
+
* must be filler for the sentence to be, so "right after the demo, remind me" survives on the
|
|
307
|
+
* strength of its first clause even though the second would pass alone.
|
|
308
|
+
*/
|
|
309
|
+
export function isFillerSentence(text) {
|
|
310
|
+
const t = String(text || '').trim().replace(/^[\s,.:;!?-]+/, '');
|
|
311
|
+
if (!t) return true;
|
|
312
|
+
if (FILLER_SENTENCE.test(t)) return true;
|
|
313
|
+
const clauses = t.split(',').map((c) => c.trim()).filter(Boolean);
|
|
314
|
+
return clauses.length > 1 && clauses.every((c) => FILLER_SENTENCE.test(c));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* @returns { request, name, ambiguous } — the text to act on, a short label for it, and
|
|
319
|
+
* whether more than one question was asked (in which case `request` is everything
|
|
320
|
+
* meaningful and a model should be asked to pick). Both strings fall back to the
|
|
321
|
+
* cleaned original rather than to nothing: a command we could not parse is still a
|
|
322
|
+
* command the user gave, and dropping it silently is the worse failure.
|
|
323
|
+
*/
|
|
324
|
+
export function refineSpokenCommand(text, { maxName = 48 } = {}) {
|
|
325
|
+
const raw = stripLeadIn(String(text || '')).trim();
|
|
326
|
+
if (!raw) return { request: '', name: '' };
|
|
327
|
+
const sentences = raw.split(SENTENCE_SPLIT).map((t) => t.trim()).filter(Boolean);
|
|
328
|
+
const meaningful = sentences.filter((t) => !isFillerSentence(t));
|
|
329
|
+
// ONE question is the request. SEVERAL is a guess, and this function refuses to make it.
|
|
330
|
+
//
|
|
331
|
+
// A real capture contained three: a standing preamble ("whenever I ask anything, research
|
|
332
|
+
// it for me, okay?"), the actual request ("how is the weather in Fairview today?") and a
|
|
333
|
+
// meta-question about the tool ("does it get added to?"). Last-wins picks the third,
|
|
334
|
+
// longest-wins picks the first, and every other rule that fits this sample is a rule fitted
|
|
335
|
+
// to this sample. Choosing between them needs to understand them — which is a model's job,
|
|
336
|
+
// and exactly what `needsModel` exists to ask for. So: an unambiguous question is used, and
|
|
337
|
+
// an ambiguous one is handed on WHOLE with `ambiguous` set, for the caller to refine.
|
|
338
|
+
const questions = meaningful.filter((t) => /\?\s*$/.test(t));
|
|
339
|
+
const ambiguous = questions.length > 1;
|
|
340
|
+
const picked = questions.length === 1
|
|
341
|
+
? questions[0]
|
|
342
|
+
: (meaningful.length ? meaningful.join(' ') : raw);
|
|
343
|
+
const request = stripLeadIn(picked).trim() || raw;
|
|
344
|
+
// The name is a label in a list, not the instruction. One line, clipped on a word boundary.
|
|
345
|
+
const flat = request.replace(/\s+/g, ' ').trim();
|
|
346
|
+
const name = flat.length > maxName
|
|
347
|
+
? `${flat.slice(0, maxName - 1).replace(/\s+\S*$/, '')}…`
|
|
348
|
+
: flat;
|
|
349
|
+
return { request, name: name || flat, ambiguous };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Ask a model what was actually being asked — the other half of `needsModel`.
|
|
354
|
+
*
|
|
355
|
+
* The parser has always returned `needsModel: true` for a command it did not recognise, with
|
|
356
|
+
* a comment saying the host "may pay for a small model to read it, and MUST NOT guess". No
|
|
357
|
+
* host ever did, so an unrecognised spoken request became a job whose name and prompt were
|
|
358
|
+
* both the entire utterance. refineSpokenCommand() is the free floor under this; when several
|
|
359
|
+
* questions were asked it declines to choose, and THIS is what chooses.
|
|
360
|
+
*
|
|
361
|
+
* Deliberately a tiny, single-shot classification with a strict output shape: it runs on a
|
|
362
|
+
* fast model while a meeting is happening, so it must cost about as much as one sentence.
|
|
363
|
+
*/
|
|
364
|
+
/**
|
|
365
|
+
* The shape of the answer, declared ONCE.
|
|
366
|
+
*
|
|
367
|
+
* It used to be typed twice — as prose inside the prompt string and again as a list of enum
|
|
368
|
+
* values thirty lines below in the parser — with nothing making the two agree. Adding a kind
|
|
369
|
+
* to one and not the other is a silent, permanent bug: the model answers correctly and the
|
|
370
|
+
* parser maps it to "question" forever. Now the prompt is rendered from this and the parser
|
|
371
|
+
* coerces onto it, so there is only one place a field exists.
|
|
372
|
+
*/
|
|
373
|
+
export const REFINEMENT_SCHEMA = defineSchema({
|
|
374
|
+
name: 'voice_refinement',
|
|
375
|
+
fields: {
|
|
376
|
+
request: {
|
|
377
|
+
type: 'string', required: true, max: 400,
|
|
378
|
+
describe: 'the one thing they actually want done, in their own words, one sentence',
|
|
379
|
+
},
|
|
380
|
+
name: { type: 'string', max: 48, describe: 'a label of at most 6 words' },
|
|
381
|
+
kind: {
|
|
382
|
+
type: 'enum',
|
|
383
|
+
values: ['question', 'monitor', 'note', 'skill', 'none'],
|
|
384
|
+
// An unknown kind becomes a QUESTION — the least surprising thing to do with something
|
|
385
|
+
// someone asked for, and the only kind that is undone by ignoring the answer. Guessing
|
|
386
|
+
// "monitor" instead would leave a card watching the meeting that nobody asked for.
|
|
387
|
+
default: 'question',
|
|
388
|
+
describe: 'the SMALLEST kind that does what they asked',
|
|
389
|
+
},
|
|
390
|
+
skill: { type: 'string', max: 80, describe: 'the skill name, only when kind is skill' },
|
|
391
|
+
},
|
|
392
|
+
// "none" is a real answer and the most important one to honour: it is how the model says
|
|
393
|
+
// "they were just talking", which is the case that produced junk jobs. It arrives two ways —
|
|
394
|
+
// as the whole reply, and as the value of `request` — and both are this.
|
|
395
|
+
nothing: { request: '', name: '', kind: 'none', skill: '' },
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
export function refinementPrompt(utterance) {
|
|
399
|
+
return [
|
|
400
|
+
'A person spoke to their assistant during a meeting. Below is everything they said after',
|
|
401
|
+
'the wake word, transcribed live — so it contains false starts, thinking aloud, and',
|
|
402
|
+
'sometimes several questions where only one is the request.',
|
|
403
|
+
'',
|
|
404
|
+
describeSchema(REFINEMENT_SCHEMA),
|
|
405
|
+
'',
|
|
406
|
+
'Pick the SMALLEST kind that does what they asked:',
|
|
407
|
+
' question — answer it once, now. The DEFAULT for anything they want to know.',
|
|
408
|
+
' monitor — only if they asked to be told as the meeting CONTINUES ("let me know if",',
|
|
409
|
+
' "keep an eye on"). A one-off question is NOT a monitor.',
|
|
410
|
+
' note — they asked for notes written down ("take notes on", "write that up").',
|
|
411
|
+
' skill — they named a saved skill ("use the summarize skill"); put its name in `skill`.',
|
|
412
|
+
' none — not asking for anything: thinking aloud, or talking ABOUT the assistant.',
|
|
413
|
+
'Never invent a request that is not there — return "none". Keep `request` close to their',
|
|
414
|
+
'words; do not answer it.',
|
|
415
|
+
'',
|
|
416
|
+
'WHAT THEY SAID:',
|
|
417
|
+
String(utterance || ''),
|
|
418
|
+
].join('\n');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The body fragment that makes a capable endpoint enforce the shape server-side.
|
|
423
|
+
* Null for an agent CLI, which has no such control — the prompt and the repair pass carry it.
|
|
424
|
+
*/
|
|
425
|
+
export function refinementFormat(mode = 'schema') { return responseFormat(REFINEMENT_SCHEMA, { mode }); }
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Read the model's answer back, defensively.
|
|
429
|
+
*
|
|
430
|
+
* Everything generic — code fences, a prose preamble, single quotes, a trailing comma, the
|
|
431
|
+
* word "none" in place of an object, a key spelled `Request` — is handled by the shared
|
|
432
|
+
* coercer, which means every OTHER structured call in the product gets those repairs too.
|
|
433
|
+
* What stays here is only what is true of THIS answer and no other.
|
|
434
|
+
*
|
|
435
|
+
* Returns null for anything unusable, so the caller falls back to the deterministic pass
|
|
436
|
+
* rather than acting on a hallucinated request.
|
|
437
|
+
*/
|
|
438
|
+
export function parseRefinement(text) {
|
|
439
|
+
const got = coerce(text, REFINEMENT_SCHEMA);
|
|
440
|
+
if (!got) return null;
|
|
441
|
+
return settleRefinement(got.value);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The two rules that are about voice commands rather than about JSON.
|
|
446
|
+
*
|
|
447
|
+
* Shared with the streaming reader below, because a rule applied on the final answer and not
|
|
448
|
+
* on the partial one is a rule the user watches the UI break. Exported because a caller that
|
|
449
|
+
* fetches the answer through the generic structured-call capability gets the raw coerced
|
|
450
|
+
* object and still needs these — the rules must not live only inside one of two paths.
|
|
451
|
+
*/
|
|
452
|
+
export function settleRefinement(v) {
|
|
453
|
+
if (!v) return null;
|
|
454
|
+
const request = String(v.request || '').trim();
|
|
455
|
+
if (v.kind === 'none' || !request) return { request: '', name: '', kind: 'none', skill: '' };
|
|
456
|
+
// A "skill" with no name is a question — there is nothing to run.
|
|
457
|
+
const kind = v.kind === 'skill' && !v.skill ? 'question' : v.kind;
|
|
458
|
+
return { request, name: String(v.name || '').trim() || request, kind, skill: v.skill || '' };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The same answer, AS IT ARRIVES.
|
|
463
|
+
*
|
|
464
|
+
* A refinement is asked for mid-meeting while someone is waiting to see whether they were
|
|
465
|
+
* heard, and the standing rule is that every model output streams with visible progress. The
|
|
466
|
+
* `settled` set is what makes that safe: `request` can be shown growing, and `kind` — which
|
|
467
|
+
* decides whether a monitor gets created — is only acted on once the model has closed it.
|
|
468
|
+
*
|
|
469
|
+
* const s = refinementStream({ onChange: (v, settled) => paint(v, settled) });
|
|
470
|
+
* await stream({ …, onDelta: (d) => s.push(d) });
|
|
471
|
+
* const final = s.end().value; // already settled, or null
|
|
472
|
+
*/
|
|
473
|
+
export function refinementStream({ onChange = null } = {}) {
|
|
474
|
+
const inner = createStructuredStream(REFINEMENT_SCHEMA, {
|
|
475
|
+
onChange: onChange ? (v, settled) => onChange(settleRefinement(v), settled) : null,
|
|
476
|
+
});
|
|
477
|
+
const wrap = (snap) => ({ ...snap, value: settleRefinement(snap.value) });
|
|
478
|
+
return {
|
|
479
|
+
push: (chunk) => wrap(inner.push(chunk)),
|
|
480
|
+
end: () => wrap(inner.end()),
|
|
481
|
+
snapshot: () => wrap(inner.snapshot()),
|
|
482
|
+
reset: () => inner.reset(),
|
|
483
|
+
get text() { return inner.text; },
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Was the assistant SPOKEN TO, or merely spoken about?
|
|
489
|
+
*
|
|
490
|
+
* "we should talk about the chat panel roadmap next week" contains the wake word and is not a
|
|
491
|
+
* command — acting on it is how ordinary conversation quietly set timers. But "Okay, chat
|
|
492
|
+
* panel. Anytime I ask a question…" is unmistakably addressed, and dropping it is why a
|
|
493
|
+
* clearly-spoken request did nothing at all.
|
|
494
|
+
*
|
|
495
|
+
* The signal that separates them is grammatical and cheap: a wake word used as a NOUN is
|
|
496
|
+
* introduced by a determiner or preposition ("the chat panel", "about ChatPanel", "our chat
|
|
497
|
+
* panel"). A wake word used as a VOCATIVE is at the start of what is being said, or follows
|
|
498
|
+
* an address word ("okay", "hey", "hi"), or follows the end of the previous sentence.
|
|
499
|
+
*
|
|
500
|
+
* Wrong sometimes, in both directions — which is exactly why it decides whether to ASK
|
|
501
|
+
* (needsModel, a visible and reversible monitor) rather than whether to act.
|
|
502
|
+
*/
|
|
503
|
+
const NOUN_MARKERS = /^(?:the|a|an|our|your|their|my|this|that|these|those|about|on|in|of|with|via|using|called|named|to)$/i;
|
|
504
|
+
|
|
505
|
+
// Adverbs that sit between a subject and its verb — "chat panel ACTUALLY helps us".
|
|
506
|
+
const SUBJECT_ADVERBS = /^(?:actually|really|also|always|never|just|only|still|often|usually|basically|literally|probably|certainly|definitely|now|then|even|apparently|obviously)$/i;
|
|
507
|
+
// Verb forms that make whatever comes before them the SUBJECT of a claim rather than the
|
|
508
|
+
// person being spoken to. A closed list on purpose: the general rule ("any third-person verb")
|
|
509
|
+
// cannot be told from an imperative without a parser, and guessing wrong here costs a command.
|
|
510
|
+
const SUBJECT_VERBS = /^(?:is|isn't|was|wasn't|are|aren't|were|weren't|has|hasn't|have|had|does|doesn't|did|didn't|can|can't|cannot|could|couldn't|will|won't|would|wouldn't|should|shouldn't|shall|may|might|must|seems|helps|lets|gives|allows|works|looks|means|needs|wants|keeps|makes|shows|tells|comes|goes|takes|runs|becomes|provides|supports)$/i;
|
|
511
|
+
// …unless a pronoun follows, which turns the same auxiliary into a question aimed at us:
|
|
512
|
+
// "chatpanel, can YOU set a timer" against "chat panel can help us".
|
|
513
|
+
const QUESTION_PRONOUNS = /^(?:you|we|i|they|it|there|he|she)$/i;
|
|
514
|
+
|
|
515
|
+
const bare = (t) => String(t?.w || '').replace(/[^\p{L}\p{N}']/gu, '');
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Is the wake phrase the SUBJECT of the sentence rather than the person being addressed?
|
|
519
|
+
*
|
|
520
|
+
* "…what chat panel actually helps us to monitor" is a sentence ABOUT the product, and it
|
|
521
|
+
* fired a request. Nothing in the words before the name says so — the giveaway is what comes
|
|
522
|
+
* after it: a vocative is followed by a comma, an imperative or a question word, while a
|
|
523
|
+
* subject is followed by its verb.
|
|
524
|
+
*/
|
|
525
|
+
function readsAsSubject(raw, tokens, endIdx) {
|
|
526
|
+
const next = tokens[endIdx + 1];
|
|
527
|
+
if (!next) return false; // nothing after the name at all — not a claim about it
|
|
528
|
+
// Punctuation between the name and what follows is the vocative comma (or a sentence
|
|
529
|
+
// break). Either way the name stands alone, which subjects do not do.
|
|
530
|
+
if (/[.!?,;:–—-]/.test(raw.slice(tokens[endIdx].end, next.start))) return false;
|
|
531
|
+
let j = endIdx + 1;
|
|
532
|
+
if (SUBJECT_ADVERBS.test(bare(tokens[j])) && tokens[j + 1]) j += 1;
|
|
533
|
+
if (!SUBJECT_VERBS.test(bare(tokens[j]))) return false;
|
|
534
|
+
return !QUESTION_PRONOUNS.test(bare(tokens[j + 1]));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function isAddressed(raw, tokens, i, n = 0) {
|
|
538
|
+
// The fuzzy match is generous enough to SWALLOW a leading article: "a chat panel" squashes
|
|
539
|
+
// to "achatpanel", one edit from "chatpanel", so the determiner ends up inside the matched
|
|
540
|
+
// span instead of before it. Check the first matched token too, or "a chat panel would be
|
|
541
|
+
// useful here" reads as an address purely because the "a" was absorbed.
|
|
542
|
+
if (n > 0 && NOUN_MARKERS.test(String(tokens[i].w || '').replace(/[^\p{L}\p{N}]/gu, ''))) return false;
|
|
543
|
+
// Whatever came before it, a name followed by its own verb is being TALKED ABOUT.
|
|
544
|
+
if (readsAsSubject(raw, tokens, i + n)) return false;
|
|
545
|
+
if (i === 0) return true; // nothing before it — it opens the utterance
|
|
546
|
+
const prev = tokens[i - 1];
|
|
547
|
+
const word = String(prev.w || '').replace(/[^\p{L}\p{N}]/gu, '');
|
|
548
|
+
if (NOUN_MARKERS.test(word)) return false; // "the chat panel" — a thing, not a listener
|
|
549
|
+
// "hey chatpanel", "ok chatpanel" — an address word is how people open one.
|
|
550
|
+
if (/^(?:ok|okay|hey|hi|yo|hello|so|um|uh)$/i.test(word)) return true;
|
|
551
|
+
// Punctuation before it is the vocative comma or a sentence break — "…here. Okay, chat
|
|
552
|
+
// panel", "so I was thinking. ChatPanel, what did we decide?" — and both mean a fresh
|
|
553
|
+
// address rather than a continuing noun phrase. Measured from the previous token's START,
|
|
554
|
+
// because the tokenizer keeps trailing punctuation ON the token ("thinking."), so the gap
|
|
555
|
+
// between tokens is only the space and the full stop would be missed.
|
|
556
|
+
const gap = raw.slice(prev.start, tokens[i].start);
|
|
557
|
+
if (!/[.!?,;:]["'’”)\]]*\s*$/.test(gap)) return false;
|
|
558
|
+
// …but ONLY a break the speaker actually made. The transcriber invents full stops, and it
|
|
559
|
+
// invents them mid-clause: "…another round of testing to see what. Chat panel actually
|
|
560
|
+
// helps us to monitor" is one sentence about the product, cut in half by a machine, and the
|
|
561
|
+
// half-stop made the second half read as a fresh address. A break is only a break when the
|
|
562
|
+
// words before it are a finished thought — the same test that decides when a command has
|
|
563
|
+
// stopped growing, for exactly the same reason.
|
|
564
|
+
return !/[.!?…]/.test(gap) || commandLooksFinished(raw.slice(0, prev.end));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Drop the NEXT address's run-up from the end of this command.
|
|
569
|
+
*
|
|
570
|
+
* A command is bounded by where the next wake phrase starts — but people open an address with
|
|
571
|
+
* a word or two before the name ("…Okay, chat panel."), and those land on the end of the
|
|
572
|
+
* previous command. Harmless to read, ruinous to identity: as the caption grows, "start
|
|
573
|
+
* monitoring the pricing question." becomes "…pricing question. Okay", which is different
|
|
574
|
+
* words, a different key, and therefore the same request acted on twice.
|
|
575
|
+
*
|
|
576
|
+
* Only the address words are trimmed, and only from the end — the same short list that marks
|
|
577
|
+
* an opening in isAddressed().
|
|
578
|
+
*/
|
|
579
|
+
function trimTrailingLeadIn(text) {
|
|
580
|
+
return String(text)
|
|
581
|
+
.replace(/(?:[\s,.:;!?-]*\b(?:ok|okay|hey|hi|yo|hello|so|um+|uh+|and|then|now)\b)+[\s,.:;!?-]*$/i, '')
|
|
582
|
+
.trim();
|
|
135
583
|
}
|
|
136
584
|
|
|
137
585
|
// "chatpanel, could you please set a timer" — politeness is not part of the command, and
|
|
@@ -590,13 +1038,94 @@ export function parseCommand(text, { wake = compileWake(), intents = defaultVoic
|
|
|
590
1038
|
if (!found) return null;
|
|
591
1039
|
const parsed = intents.parse(found.command, { now });
|
|
592
1040
|
if (!parsed) return null;
|
|
593
|
-
return { ...parsed, wake: found.wake, heard: found.heard, at: found.at };
|
|
1041
|
+
return { ...parsed, wake: found.wake, heard: found.heard, at: found.at, addressed: found.addressed !== false };
|
|
594
1042
|
}
|
|
595
1043
|
|
|
596
1044
|
// ---------------------------------------------------------------------------
|
|
597
1045
|
// Transcript → commands
|
|
598
1046
|
// ---------------------------------------------------------------------------
|
|
599
1047
|
|
|
1048
|
+
/**
|
|
1049
|
+
* Has the speaker finished the thought? — and PUNCTUATION IS NOT THE EVIDENCE.
|
|
1050
|
+
*
|
|
1051
|
+
* This used to be `endsSentence`: a full stop at the end of the caption meant the speaker had
|
|
1052
|
+
* stopped. Live caption engines punctuate as they go, and they punctuate FRAGMENTS. One
|
|
1053
|
+
* capture of this feature in use produced, in order: "Take.", "Take the question and ask
|
|
1054
|
+
* the.", "set a timer for.", "let's summar." — four full stops nobody uttered, and four
|
|
1055
|
+
* half-sentences sent to a model as requests while the speaker was still saying the rest.
|
|
1056
|
+
*
|
|
1057
|
+
* So the full stop is thrown away and the LAST WORD is read instead. A command ending on a
|
|
1058
|
+
* preposition, an article, a conjunction or an auxiliary ("…ask the", "…a timer for") is
|
|
1059
|
+
* someone mid-thought, however the transcriber punctuated it.
|
|
1060
|
+
*
|
|
1061
|
+
* A HINT, NOT A VERDICT. "Tell me what that is" is a real request that ends on 'is', so a
|
|
1062
|
+
* dangling tail must never DISCARD a command — it only makes the gate below wait longer for
|
|
1063
|
+
* the rest to arrive. A command that is never acted on is the worse failure of the two.
|
|
1064
|
+
*/
|
|
1065
|
+
// Words that can end a sentence grammatically but in speech mean the qualifier is still being
|
|
1066
|
+
// chosen — "summarize the last 30 seconds, like, maybe…".
|
|
1067
|
+
const TRAILING_HEDGES = new Set(['maybe', 'perhaps', 'probably', 'basically', 'roughly', 'kinda', 'sorta']);
|
|
1068
|
+
|
|
1069
|
+
export function commandLooksFinished(text) {
|
|
1070
|
+
const t = String(text || '').trim().replace(/[\s.,;:!?…'"’”)\]-]+$/u, '');
|
|
1071
|
+
if (!t) return false;
|
|
1072
|
+
const tokens = t.toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
|
|
1073
|
+
const last = tokens[tokens.length - 1];
|
|
1074
|
+
if (!last) return false;
|
|
1075
|
+
return !DANGLING_TAILS.has(last) && !TRAILING_HEDGES.has(last);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Terminal punctuation. Worth almost nothing on its own — see above — but it is the only
|
|
1079
|
+
// signal a caller with no gate has, so the ungated path keeps asking for it ON TOP of the
|
|
1080
|
+
// tail test rather than getting looser than it was.
|
|
1081
|
+
const endsSentence = (text) => /[.!?…]["'’”)\]]*\s*$/.test(String(text || '').trim());
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The SHORTEST span that parses wins.
|
|
1085
|
+
*
|
|
1086
|
+
* "Set a timer for 30 seconds. And then that should actually set a timer for 30 seconds."
|
|
1087
|
+
* is one request said twice, and the duration parser sums what it finds across the span —
|
|
1088
|
+
* so a two-sentence window turned 30 seconds into 60 and produced a one-minute timer nobody
|
|
1089
|
+
* asked for. Worse, it moved: as the caption grew, the same words re-parsed to a different
|
|
1090
|
+
* duration, which is a different dedupe key, which is another timer. That is the "why is it
|
|
1091
|
+
* creating timers again and again" report.
|
|
1092
|
+
*
|
|
1093
|
+
* So the first sentence is tried alone, and the wider span only when it yields nothing. A
|
|
1094
|
+
* request that genuinely needs two ("Set a timer. Make it five minutes.") still gets them.
|
|
1095
|
+
*/
|
|
1096
|
+
function parseShortest(command, intents, now) {
|
|
1097
|
+
const first = firstSentences(command, 1).trim();
|
|
1098
|
+
if (first && first !== command) {
|
|
1099
|
+
const narrow = intents.parse(first, { now });
|
|
1100
|
+
if (narrow?.intent) return narrow;
|
|
1101
|
+
}
|
|
1102
|
+
return intents.parse(command, { now });
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* A command's words, reduced to what survives re-transcription.
|
|
1107
|
+
*
|
|
1108
|
+
* Case and spacing vary between flushes of the same sentence, and punctuation appears and
|
|
1109
|
+
* disappears as the engine revises — so none of them may be part of an identity that is
|
|
1110
|
+
* supposed to say "you have already done this".
|
|
1111
|
+
*/
|
|
1112
|
+
export const gistText = (text) => String(text || '')
|
|
1113
|
+
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160);
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The OPENING of a request — a looser identity than its full words.
|
|
1117
|
+
*
|
|
1118
|
+
* A transcriber does not only append; it REVISES. "How is the weather in Seattle, Washington
|
|
1119
|
+
* now?" became "How is the weather in Seattle, Washington?" on a later flush — different
|
|
1120
|
+
* words, a different key, and the same question asked twice. The first few words are what a
|
|
1121
|
+
* revision leaves alone.
|
|
1122
|
+
*
|
|
1123
|
+
* Six is chosen against the failure it exists to stop: long enough that two genuinely
|
|
1124
|
+
* different requests rarely share an opening, short enough to survive the tail being rewritten.
|
|
1125
|
+
*/
|
|
1126
|
+
export const OPENING_WORDS = 6;
|
|
1127
|
+
export const gistOpening = (text) => gistText(text).split(' ').slice(0, OPENING_WORDS).join(' ');
|
|
1128
|
+
|
|
600
1129
|
/** How many commands one transcript delta may produce. */
|
|
601
1130
|
export const MAX_COMMANDS_PER_DELTA = 3;
|
|
602
1131
|
|
|
@@ -618,27 +1147,66 @@ export const MAX_COMMANDS_PER_DELTA = 3;
|
|
|
618
1147
|
*/
|
|
619
1148
|
export function commandsFromSegments(segments, {
|
|
620
1149
|
wake = compileWake(), intents = defaultVoiceIntents(), isSelf = null,
|
|
621
|
-
sinceTs = 0, now = Date.now(), meetingId = '', max = MAX_COMMANDS_PER_DELTA,
|
|
1150
|
+
sinceTs = 0, now = Date.now(), meetingId = '', max = MAX_COMMANDS_PER_DELTA, gate = null,
|
|
622
1151
|
} = {}) {
|
|
623
1152
|
const out = [];
|
|
624
1153
|
for (const seg of segments || []) {
|
|
625
1154
|
if (!seg || !seg.text) continue;
|
|
626
1155
|
if (seg.t && seg.t <= sinceTs) continue;
|
|
627
|
-
|
|
1156
|
+
// EVERY address in this caption, not just the first. A live caption carries a minute of
|
|
1157
|
+
// speech, and a person addressing an assistant addresses it more than once in a minute —
|
|
1158
|
+
// "…set a timer for 1 minute. Okay chat panel, go and search…" is two commands. Taking
|
|
1159
|
+
// only the first also made that first command swallow the rest, which is how a one-minute
|
|
1160
|
+
// timer became 720 hours: the duration parser found "30 days" four sentences later.
|
|
1161
|
+
for (const found of findWakeCommands(seg.text, wake)) {
|
|
1162
|
+
if (!found.command) continue; // a bare mention with nothing after it
|
|
1163
|
+
const intent = parseShortest(found.command, intents, now);
|
|
1164
|
+
// `rest` rides along: the deterministic intents parse only the tight span (that is what
|
|
1165
|
+
// stops a duration being found four sentences away), but a MODEL asked to read an
|
|
1166
|
+
// unrecognised request should see the words around it — the question often follows a
|
|
1167
|
+
// sentence of preamble. Parse narrow, refine wide.
|
|
1168
|
+
const parsed = intent && {
|
|
1169
|
+
...intent, wake: found.wake, heard: found.heard, at: found.at,
|
|
1170
|
+
addressed: found.addressed, rest: found.rest,
|
|
1171
|
+
};
|
|
628
1172
|
if (!parsed) continue;
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
1173
|
+
// MENTIONED vs ADDRESSED — and this guard used to conflate them.
|
|
1174
|
+
//
|
|
1175
|
+
// parseCommand returns a shape for anything carrying the wake word, intent or not, so
|
|
1176
|
+
// "we should talk about the chat panel roadmap next week" came back as a command and the
|
|
1177
|
+
// caller acted on it: ordinary conversation quietly setting timers. Dropping every
|
|
1178
|
+
// intentless utterance fixed that, and broke the opposite case just as badly — "Okay,
|
|
1179
|
+
// chat panel. How is the weather in Lakeside?" is unmistakably a request, matches no
|
|
1180
|
+
// built-in intent (there is no weather intent, and there should not be), and was
|
|
1181
|
+
// discarded here. Nothing downstream ever saw it, which is why `needsModel` had no
|
|
1182
|
+
// handler: it could not reach one.
|
|
634
1183
|
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
1184
|
+
// So the test is whether the assistant was SPOKEN TO. A passing mention still stops here.
|
|
1185
|
+
// An address with no matching intent goes on with needsModel set, for a model to read —
|
|
1186
|
+
// which is what the parser has always said should happen.
|
|
1187
|
+
//
|
|
1188
|
+
if (!parsed.intent && !parsed.addressed) continue;
|
|
1189
|
+
// Does this read as a whole thought? Computed once, where the command's own words are:
|
|
1190
|
+
// the guard below and the gate must never be able to answer that differently.
|
|
1191
|
+
const finished = commandLooksFinished(found.command);
|
|
1192
|
+
// WHETHER THE SENTENCE HAS ENDED IS NOT DECIDED HERE — when there is a gate.
|
|
1193
|
+
//
|
|
1194
|
+
// It used to be decided here, on the caption's terminal punctuation, and that is exactly
|
|
1195
|
+
// what sent "Take the question and ask the." to a model as a request. This function sees
|
|
1196
|
+
// ONE delivery of a caption; only something watching the same utterance across
|
|
1197
|
+
// deliveries can tell a finished sentence from a punctuated fragment, and that is the
|
|
1198
|
+
// gate below (`finished` is the hint it reads).
|
|
1199
|
+
//
|
|
1200
|
+
// A caller with no gate has no such thing, so it keeps the old conservative rule and
|
|
1201
|
+
// gains the tail test on top of it: both, or the request waits for the next delivery.
|
|
1202
|
+
// Getting LOOSER than the code being fixed would be a strange way to fix it.
|
|
1203
|
+
if (!parsed.intent && !gate && !(finished && endsSentence(seg.text))) continue;
|
|
638
1204
|
const allowed = isSelf ? !!isSelf(seg.speaker) : false;
|
|
639
1205
|
out.push({
|
|
640
1206
|
...parsed,
|
|
641
1207
|
allowed,
|
|
1208
|
+
// Carried, so the gate reads the same answer this scan did.
|
|
1209
|
+
finished,
|
|
642
1210
|
speaker: seg.speaker || '',
|
|
643
1211
|
t: seg.t || now,
|
|
644
1212
|
meetingId,
|
|
@@ -656,9 +1224,180 @@ export function commandsFromSegments(segments, {
|
|
|
656
1224
|
// one spoken request look like a new request on every update, and a single "set a timer
|
|
657
1225
|
// for 30 seconds" became a screenful of timers. `sid` is assigned once per utterance and
|
|
658
1226
|
// never moves, so the same sentence keeps one key however many times it is rescanned.
|
|
659
|
-
|
|
1227
|
+
// WHAT WAS ASKED, not which delivery of it carried the words.
|
|
1228
|
+
//
|
|
1229
|
+
// This used to key on the caption's identity (`sid`) and the wake word's offset. Both
|
|
1230
|
+
// move: `sid` is re-minted whenever the caption engine loses the overlap between a
|
|
1231
|
+
// growing line and the one before it, and a monologue keeps ONE entry alive for
|
|
1232
|
+
// minutes — re-scanned on every flush, by design, so a half-heard command gets a
|
|
1233
|
+
// second chance. So one spoken "set a timer for 30 seconds" kept arriving as a
|
|
1234
|
+
// brand-new command and kept creating timers.
|
|
1235
|
+
//
|
|
1236
|
+
// The words are what does not move. Two different commands in one breath still differ;
|
|
1237
|
+
// the same command through fifty flushes is one request.
|
|
1238
|
+
// The OPENING, not the whole sentence: a live caption keeps growing ("…10 seconds",
|
|
1239
|
+
// then "…10 seconds and then", then "…and then we moved on"), and gistText over the
|
|
1240
|
+
// full text moves with every one of those — which is the very bug this key exists to
|
|
1241
|
+
// stop, just later in the sentence. The intent and its resolved duration are already
|
|
1242
|
+
// in the key, so two genuinely different commands still differ.
|
|
1243
|
+
key: `voice:${meetingId}:${parsed.intent || 'ask'}:${parsed.ms ?? parsed.when ?? ''}:${gistOpening(found.command)}`,
|
|
660
1244
|
});
|
|
661
|
-
|
|
1245
|
+
}
|
|
662
1246
|
}
|
|
663
|
-
|
|
1247
|
+
// THE NEWEST, not the first.
|
|
1248
|
+
//
|
|
1249
|
+
// The cap exists so a pathological transcript cannot fire fifty actions, and it used to
|
|
1250
|
+
// stop scanning once it had `max` — counting from the START of the caption. A caption entry
|
|
1251
|
+
// in a monologue lives for minutes and is re-scanned on every flush, accumulating every
|
|
1252
|
+
// address spoken into it, so the first three (long since acted on) consumed the whole
|
|
1253
|
+
// budget and everything said AFTER them was never returned at all. The request you just
|
|
1254
|
+
// made was the one thrown away.
|
|
1255
|
+
//
|
|
1256
|
+
// The newest are both the most likely to be fresh and the ones a person is waiting on, so
|
|
1257
|
+
// the cap keeps those. The already-acted ones are dropped downstream by the dedupe anyway.
|
|
1258
|
+
const found = out.length > max ? out.slice(-max) : out;
|
|
1259
|
+
// WITH A GATE, nothing is returned until the words stop moving — see createUtteranceGate.
|
|
1260
|
+
// Without one the old behaviour stands, so a host that has not adopted it (or a test asking
|
|
1261
|
+
// what the grammar sees) is unchanged.
|
|
1262
|
+
return gate ? gate.offer(found, now).due(now) : found;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// ---------------------------------------------------------------------------
|
|
1266
|
+
// One utterance, one action
|
|
1267
|
+
// ---------------------------------------------------------------------------
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* How long a command's words must stop changing before it is acted on.
|
|
1271
|
+
*
|
|
1272
|
+
* LONGER THAN THE CAPTURE'S FLUSH INTERVAL, and that is the whole calculation. Captions reach
|
|
1273
|
+
* a client in batches — the extension debounces its flush by 4s — so "these words have not
|
|
1274
|
+
* changed for 1s" says nothing except that no batch arrived in the last second. Only a wait
|
|
1275
|
+
* that outlasts a flush can distinguish "they stopped talking" from "we have not been told
|
|
1276
|
+
* what they said next".
|
|
1277
|
+
*/
|
|
1278
|
+
export const UTTERANCE_SETTLE_MS = 5_000;
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* How long a command that ends MID-THOUGHT waits instead.
|
|
1282
|
+
*
|
|
1283
|
+
* "Set a timer for" is not a request yet; the duration is in the breath after it. Waiting the
|
|
1284
|
+
* ordinary window and acting on it is exactly the bug this file exists to stop. But the tail
|
|
1285
|
+
* test is a word list, not grammar, and "tell me what that is" ends on 'is' — so a dangling
|
|
1286
|
+
* command is DELAYED, never dropped. If the speaker really did stop there, it still runs.
|
|
1287
|
+
*/
|
|
1288
|
+
export const UTTERANCE_DANGLING_MS = 12_000;
|
|
1289
|
+
|
|
1290
|
+
// How long an utterance is remembered after it was last heard. A caption entry in a monologue
|
|
1291
|
+
// is re-delivered for minutes, and every one of those redeliveries has to find the record
|
|
1292
|
+
// saying "this one is done" — that record IS the one-utterance-one-action guarantee.
|
|
1293
|
+
const UTTERANCE_FORGET_MS = 3 * 60_000;
|
|
1294
|
+
// A cap, because a long meeting must not grow this without bound. Small: only utterances
|
|
1295
|
+
// still in flight or recently acted on matter, and the caller has its own longer-lived record
|
|
1296
|
+
// of what has been done.
|
|
1297
|
+
const UTTERANCE_TRACKED_MAX = 24;
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Is this the same spoken request as that one, a moment later?
|
|
1301
|
+
*
|
|
1302
|
+
* Two relations, because a transcriber does both. It APPENDS — "let's summar" becomes "let's
|
|
1303
|
+
* summarize the notes" — which is a prefix. And it REVISES — "…in Seattle, Washington now?"
|
|
1304
|
+
* came back as "…in Seattle, Washington?" — which is not, but keeps the opening.
|
|
1305
|
+
*/
|
|
1306
|
+
export function sameUtterance(a, b) {
|
|
1307
|
+
if (!a || !b) return false;
|
|
1308
|
+
if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
|
|
1309
|
+
const oa = a.split(' ').slice(0, OPENING_WORDS);
|
|
1310
|
+
const ob = b.split(' ').slice(0, OPENING_WORDS);
|
|
1311
|
+
// A short opening is not enough evidence: "set a timer" opens half the commands ever
|
|
1312
|
+
// spoken, and collapsing two of them into one utterance loses the second silently.
|
|
1313
|
+
return oa.length >= OPENING_WORDS && oa.join(' ') === ob.join(' ');
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* The thing that makes a growing caption ONE request.
|
|
1318
|
+
*
|
|
1319
|
+
* THE BUG THIS IS. A live caption is re-delivered as it grows, and every delivery was acted
|
|
1320
|
+
* on the moment it parsed. So a single spoken "Okay ChatPanel, set a timer for 30 seconds"
|
|
1321
|
+
* arrived first as "set a timer for." — no duration, no intent, addressed, punctuated by the
|
|
1322
|
+
* transcriber — and went to the model as a QUESTION; then, seconds later, arrived whole and
|
|
1323
|
+
* became a TIMER. One thing said, two things done, and the user reasonably reported it as
|
|
1324
|
+
* "it set the timer but it also sent a message". Nothing downstream could relate the two:
|
|
1325
|
+
* they had different words, different intents, and therefore different dedupe keys.
|
|
1326
|
+
*
|
|
1327
|
+
* They can only be related by watching them, so this is the one stateful thing in the file —
|
|
1328
|
+
* and it is still clock-free (`now` is passed in, like everywhere else) and platform-free.
|
|
1329
|
+
* The host owns exactly two things: keeping the object, and calling back when the wait is up.
|
|
1330
|
+
*
|
|
1331
|
+
* const gate = createUtteranceGate();
|
|
1332
|
+
* const ready = commandsFromSegments(segments, { …, gate }); // offers and drains
|
|
1333
|
+
* const later = gate.nextDueIn(); // ms until something becomes actionable, or null
|
|
1334
|
+
* …setTimeout(() => act(gate.due()), later) // silence needs a nudge
|
|
1335
|
+
*
|
|
1336
|
+
* Note what does NOT come out of `due()`: an utterance that already fired. It stays in the
|
|
1337
|
+
* gate, matching its own redeliveries, so the completed version of a request that was already
|
|
1338
|
+
* acted on cannot act again as something else.
|
|
1339
|
+
*/
|
|
1340
|
+
export function createUtteranceGate({
|
|
1341
|
+
settleMs = UTTERANCE_SETTLE_MS,
|
|
1342
|
+
danglingMs = UTTERANCE_DANGLING_MS,
|
|
1343
|
+
forgetMs = UTTERANCE_FORGET_MS,
|
|
1344
|
+
max = UTTERANCE_TRACKED_MAX,
|
|
1345
|
+
} = {}) {
|
|
1346
|
+
let live = [];
|
|
1347
|
+
const waitFor = (e) => (e.command.finished === false ? danglingMs : settleMs);
|
|
1348
|
+
const find = (command, gist) => live.find(
|
|
1349
|
+
(e) => e.meetingId === (command.meetingId || '') && sameUtterance(e.gist, gist),
|
|
1350
|
+
);
|
|
1351
|
+
return {
|
|
1352
|
+
/** Offer this delta's commands. Chainable, so a scan reads as one expression. */
|
|
1353
|
+
offer(commands, now = Date.now()) {
|
|
1354
|
+
for (const command of commands || []) {
|
|
1355
|
+
const gist = gistText(command.command);
|
|
1356
|
+
if (!gist) continue;
|
|
1357
|
+
const entry = find(command, gist);
|
|
1358
|
+
if (!entry) {
|
|
1359
|
+
live.push({ meetingId: command.meetingId || '', gist, command, changedAt: now, seenAt: now, done: false });
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
entry.seenAt = now;
|
|
1363
|
+
if (entry.done) continue; // said once, done once — however many more words arrive
|
|
1364
|
+
if (gist === entry.gist) continue; // unchanged: the clock keeps running, untouched
|
|
1365
|
+
// Still growing (or being revised). The newest wording is the one to act on, and the
|
|
1366
|
+
// wait starts again from here — which is what makes a pause, not a full stop, the
|
|
1367
|
+
// signal that someone has finished.
|
|
1368
|
+
entry.gist = gist;
|
|
1369
|
+
entry.command = command;
|
|
1370
|
+
entry.changedAt = now;
|
|
1371
|
+
}
|
|
1372
|
+
live = live.filter((e) => now - e.seenAt <= forgetMs);
|
|
1373
|
+
if (live.length > max) live = live.slice(-max);
|
|
1374
|
+
return this;
|
|
1375
|
+
},
|
|
1376
|
+
/** The commands whose words have stopped moving. Each is returned exactly once. */
|
|
1377
|
+
due(now = Date.now()) {
|
|
1378
|
+
const out = [];
|
|
1379
|
+
for (const e of live) {
|
|
1380
|
+
if (e.done || now - e.changedAt < waitFor(e)) continue;
|
|
1381
|
+
e.done = true;
|
|
1382
|
+
out.push(e.command);
|
|
1383
|
+
}
|
|
1384
|
+
return out;
|
|
1385
|
+
},
|
|
1386
|
+
/**
|
|
1387
|
+
* ms until the earliest waiting command becomes actionable, or null when none is waiting.
|
|
1388
|
+
* The host needs this because silence produces no deltas to re-scan on: the last thing
|
|
1389
|
+
* said before someone stops talking is exactly the thing they are waiting to see happen.
|
|
1390
|
+
*/
|
|
1391
|
+
nextDueIn(now = Date.now()) {
|
|
1392
|
+
let soonest = null;
|
|
1393
|
+
for (const e of live) {
|
|
1394
|
+
if (e.done) continue;
|
|
1395
|
+
const left = Math.max(0, waitFor(e) - (now - e.changedAt));
|
|
1396
|
+
if (soonest === null || left < soonest) soonest = left;
|
|
1397
|
+
}
|
|
1398
|
+
return soonest;
|
|
1399
|
+
},
|
|
1400
|
+
/** How many utterances are still waiting — for tests and for a diagnostics line. */
|
|
1401
|
+
get waiting() { return live.filter((e) => !e.done).length; },
|
|
1402
|
+
};
|
|
664
1403
|
}
|