@framers/agentos-ext-endpoint-semantic 0.2.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/LICENSE +23 -0
- package/SKILL.md +74 -0
- package/dist/SemanticEndpointDetector.d.ts +186 -0
- package/dist/SemanticEndpointDetector.d.ts.map +1 -0
- package/dist/SemanticEndpointDetector.js +308 -0
- package/dist/SemanticEndpointDetector.js.map +1 -0
- package/dist/TurnCompletenessClassifier.d.ts +82 -0
- package/dist/TurnCompletenessClassifier.d.ts.map +1 -0
- package/dist/TurnCompletenessClassifier.js +130 -0
- package/dist/TurnCompletenessClassifier.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/manifest.json +8 -0
- package/package.json +42 -0
- package/src/SemanticEndpointDetector.ts +440 -0
- package/src/TurnCompletenessClassifier.ts +157 -0
- package/src/index.ts +125 -0
- package/src/types.ts +53 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file SemanticEndpointDetector.ts
|
|
3
|
+
* @description IEndpointDetector implementation that combines the heuristic
|
|
4
|
+
* detector's punctuation and backchannel rules with LLM-based turn-completeness
|
|
5
|
+
* classification for ambiguous cases.
|
|
6
|
+
*
|
|
7
|
+
* Detection strategy (in priority order):
|
|
8
|
+
* 1. **Backchannel** — recognised phrases emitted as `backchannel_detected`
|
|
9
|
+
* without advancing the turn.
|
|
10
|
+
* 2. **Terminal punctuation** — immediate `turn_complete` with reason
|
|
11
|
+
* `'punctuation'`.
|
|
12
|
+
* 3. **LLM classification** — invoked after `minSilenceBeforeCheckMs` of
|
|
13
|
+
* silence; `COMPLETE` fires `turn_complete` with reason `'semantic_model'`.
|
|
14
|
+
* `INCOMPLETE` defers to the hard silence timeout.
|
|
15
|
+
* 4. **Silence timeout** — hard fallback after `silenceTimeoutMs`, reason
|
|
16
|
+
* `'silence_timeout'`.
|
|
17
|
+
*
|
|
18
|
+
* @module endpoint-semantic/SemanticEndpointDetector
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { EventEmitter } from 'node:events';
|
|
22
|
+
import { TurnCompletenessClassifier } from './TurnCompletenessClassifier.js';
|
|
23
|
+
import type { SemanticEndpointConfig } from './types.js';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Local type mirror — avoids a hard runtime dep on @framers/agentos
|
|
27
|
+
// These match the shapes in packages/agentos/src/voice-pipeline/types.ts
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimal VAD event shape required by this detector.
|
|
32
|
+
* Mirrors `VadEvent` from `@framers/agentos` voice-pipeline types.
|
|
33
|
+
*/
|
|
34
|
+
interface VadEvent {
|
|
35
|
+
/** Type of the VAD transition. */
|
|
36
|
+
type: 'speech_start' | 'speech_end' | 'silence';
|
|
37
|
+
/** Unix epoch millisecond timestamp of the transition. */
|
|
38
|
+
timestamp: number;
|
|
39
|
+
/** Optional raw energy level (implementation-defined). */
|
|
40
|
+
energyLevel?: number;
|
|
41
|
+
/** Origin of the VAD event. */
|
|
42
|
+
source?: 'vad' | 'stt';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Minimal transcript event shape required by this detector.
|
|
47
|
+
* Mirrors `TranscriptEvent` from `@framers/agentos` voice-pipeline types.
|
|
48
|
+
*/
|
|
49
|
+
interface TranscriptEvent {
|
|
50
|
+
/** Full transcript text for this event. */
|
|
51
|
+
text: string;
|
|
52
|
+
/** Aggregate confidence score in the range [0, 1]. */
|
|
53
|
+
confidence: number;
|
|
54
|
+
/** Word-level tokens (may be empty for interim events). */
|
|
55
|
+
words: unknown[];
|
|
56
|
+
/** `true` when this hypothesis is stable and will not be revised. */
|
|
57
|
+
isFinal: boolean;
|
|
58
|
+
/** Duration of the recognised speech segment, ms (final events only). */
|
|
59
|
+
durationMs?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Semantic reason for turn completion.
|
|
64
|
+
* Mirrors `EndpointReason` from `@framers/agentos` voice-pipeline types.
|
|
65
|
+
*/
|
|
66
|
+
type EndpointReason =
|
|
67
|
+
| 'silence_timeout'
|
|
68
|
+
| 'punctuation'
|
|
69
|
+
| 'syntax_complete'
|
|
70
|
+
| 'semantic_model'
|
|
71
|
+
| 'manual'
|
|
72
|
+
| 'timeout';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Payload emitted on `turn_complete`.
|
|
76
|
+
* Mirrors `TurnCompleteEvent` from `@framers/agentos` voice-pipeline types.
|
|
77
|
+
*/
|
|
78
|
+
interface TurnCompleteEvent {
|
|
79
|
+
/** Final consolidated transcript for this turn. */
|
|
80
|
+
transcript: string;
|
|
81
|
+
/** Aggregate STT confidence in the range [0, 1]. */
|
|
82
|
+
confidence: number;
|
|
83
|
+
/** Total detected speech duration in milliseconds. */
|
|
84
|
+
durationMs: number;
|
|
85
|
+
/** The semantic reason that triggered turn completion. */
|
|
86
|
+
reason: EndpointReason;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Minimal IEndpointDetector interface required by this pack.
|
|
91
|
+
* Mirrors `IEndpointDetector` from `@framers/agentos` voice-pipeline types.
|
|
92
|
+
*/
|
|
93
|
+
interface IEndpointDetector extends EventEmitter {
|
|
94
|
+
readonly mode: 'acoustic' | 'heuristic' | 'semantic';
|
|
95
|
+
pushVadEvent(event: VadEvent): void;
|
|
96
|
+
pushTranscript(event: TranscriptEvent): void;
|
|
97
|
+
reset(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Constants
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Default silence timeout (ms) — hard fallback if the LLM returns INCOMPLETE. */
|
|
105
|
+
const DEFAULT_SILENCE_TIMEOUT_MS = 1_500;
|
|
106
|
+
|
|
107
|
+
/** Default delay (ms) between speech_end and LLM invocation. */
|
|
108
|
+
const DEFAULT_MIN_SILENCE_BEFORE_CHECK_MS = 500;
|
|
109
|
+
|
|
110
|
+
/** Terminal punctuation that indicates sentence completion. */
|
|
111
|
+
const TERMINAL_PUNCTUATION = /[.?!]$/;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Normalised backchannel phrases that indicate acknowledgement without a full
|
|
115
|
+
* conversational turn. Compared after `.trim().toLowerCase()`.
|
|
116
|
+
*/
|
|
117
|
+
const BACKCHANNEL_PHRASES = new Set([
|
|
118
|
+
'uh huh',
|
|
119
|
+
'yeah',
|
|
120
|
+
'okay',
|
|
121
|
+
'ok',
|
|
122
|
+
'mm hmm',
|
|
123
|
+
'mmhmm',
|
|
124
|
+
'mhm',
|
|
125
|
+
'mm-hmm',
|
|
126
|
+
'right',
|
|
127
|
+
'sure',
|
|
128
|
+
'yep',
|
|
129
|
+
'yup',
|
|
130
|
+
'gotcha',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Implementation
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Semantic endpoint detector that augments heuristic rules with LLM-based
|
|
139
|
+
* turn-completeness classification.
|
|
140
|
+
*
|
|
141
|
+
* ### Events emitted
|
|
142
|
+
* - `'turn_complete'` ({@link TurnCompleteEvent}) — user turn has ended.
|
|
143
|
+
* - `'backchannel_detected'` (`{ text: string }`) — backchannel phrase
|
|
144
|
+
* recognised; accumulation suppressed.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* const detector = new SemanticEndpointDetector(
|
|
149
|
+
* async (prompt) => callMyLLM(prompt),
|
|
150
|
+
* { minSilenceBeforeCheckMs: 600, silenceTimeoutMs: 2000 }
|
|
151
|
+
* );
|
|
152
|
+
* detector.on('turn_complete', (e) => console.log(e.reason));
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export class SemanticEndpointDetector extends EventEmitter implements IEndpointDetector {
|
|
156
|
+
/** Active detection strategy label. */
|
|
157
|
+
readonly mode: IEndpointDetector['mode'] = 'semantic';
|
|
158
|
+
|
|
159
|
+
/** LLM-backed completeness classifier. */
|
|
160
|
+
private readonly classifier: TurnCompletenessClassifier;
|
|
161
|
+
|
|
162
|
+
/** Resolved silence timeout (ms) — hard fallback. */
|
|
163
|
+
private readonly silenceTimeoutMs: number;
|
|
164
|
+
|
|
165
|
+
/** Silence duration (ms) before invoking the LLM classifier. */
|
|
166
|
+
private readonly minSilenceBeforeCheckMs: number;
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Mutable state (reset on each turn)
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/** Accumulated final transcript text for the current turn. */
|
|
173
|
+
private accumulatedText = '';
|
|
174
|
+
|
|
175
|
+
/** Whether VAD reports active speech. */
|
|
176
|
+
private speechActive = false;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Handle to the "check" timer — fires after `minSilenceBeforeCheckMs` and
|
|
180
|
+
* invokes the LLM classifier.
|
|
181
|
+
*/
|
|
182
|
+
private checkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Handle to the hard silence-timeout timer — fires after `silenceTimeoutMs`.
|
|
186
|
+
*/
|
|
187
|
+
private silenceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
188
|
+
|
|
189
|
+
/** Wall-clock timestamp (ms) when speech began for the current turn. */
|
|
190
|
+
private turnStartMs: number | null = null;
|
|
191
|
+
|
|
192
|
+
/** Confidence score from the most recent final STT transcript. */
|
|
193
|
+
private lastConfidence = 1;
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Constructor
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param llmCall — Async function that sends a prompt to an LLM and resolves
|
|
201
|
+
* with the raw response text.
|
|
202
|
+
* @param config — Optional configuration overrides.
|
|
203
|
+
*/
|
|
204
|
+
constructor(
|
|
205
|
+
llmCall: (prompt: string) => Promise<string>,
|
|
206
|
+
config: SemanticEndpointConfig = {},
|
|
207
|
+
) {
|
|
208
|
+
super();
|
|
209
|
+
|
|
210
|
+
this.silenceTimeoutMs = config.silenceTimeoutMs ?? DEFAULT_SILENCE_TIMEOUT_MS;
|
|
211
|
+
this.minSilenceBeforeCheckMs =
|
|
212
|
+
config.minSilenceBeforeCheckMs ?? DEFAULT_MIN_SILENCE_BEFORE_CHECK_MS;
|
|
213
|
+
|
|
214
|
+
this.classifier = new TurnCompletenessClassifier(llmCall, config.timeoutMs);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// IEndpointDetector — pushTranscript
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Ingest a transcript event from the upstream STT session.
|
|
223
|
+
*
|
|
224
|
+
* Only final events (`isFinal: true`) affect state. Interim results are
|
|
225
|
+
* silently ignored as they are unstable and arrive at high frequency.
|
|
226
|
+
*
|
|
227
|
+
* Recognised backchannel phrases are emitted as `'backchannel_detected'`
|
|
228
|
+
* without being accumulated, preventing a subsequent `speech_end` from
|
|
229
|
+
* triggering `turn_complete` for an acknowledgement.
|
|
230
|
+
*
|
|
231
|
+
* @param transcript — Transcript event from the upstream STT session.
|
|
232
|
+
*/
|
|
233
|
+
pushTranscript(transcript: TranscriptEvent): void {
|
|
234
|
+
if (!transcript.isFinal) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const text = transcript.text;
|
|
239
|
+
const normalised = text.trim().toLowerCase();
|
|
240
|
+
|
|
241
|
+
if (BACKCHANNEL_PHRASES.has(normalised)) {
|
|
242
|
+
this.emit('backchannel_detected', { text });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.accumulatedText = text;
|
|
247
|
+
this.lastConfidence = transcript.confidence;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// IEndpointDetector — pushVadEvent
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Ingest a VAD (voice activity detection) event.
|
|
256
|
+
*
|
|
257
|
+
* - `speech_start` — marks the turn as active, cancels pending timers.
|
|
258
|
+
* - `speech_end` — if text has been accumulated, either fires immediately
|
|
259
|
+
* on terminal punctuation, or schedules the LLM check
|
|
260
|
+
* timer + hard silence-timeout timer.
|
|
261
|
+
* - `silence` — periodic heartbeats; no action required.
|
|
262
|
+
*
|
|
263
|
+
* @param event — VAD transition event.
|
|
264
|
+
*/
|
|
265
|
+
pushVadEvent(event: VadEvent): void {
|
|
266
|
+
switch (event.type) {
|
|
267
|
+
case 'speech_start': {
|
|
268
|
+
this.speechActive = true;
|
|
269
|
+
this._clearAllTimers();
|
|
270
|
+
if (this.turnStartMs === null) {
|
|
271
|
+
this.turnStartMs = event.timestamp;
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case 'speech_end': {
|
|
277
|
+
this.speechActive = false;
|
|
278
|
+
|
|
279
|
+
if (!this.accumulatedText) {
|
|
280
|
+
// No transcript available yet — nothing to evaluate.
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (TERMINAL_PUNCTUATION.test(this.accumulatedText)) {
|
|
285
|
+
// Sentence-terminal punctuation: fire immediately, identical to the
|
|
286
|
+
// heuristic detector.
|
|
287
|
+
this._emitTurnComplete('punctuation', event.timestamp);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Ambiguous case: schedule the LLM check and the hard silence fallback.
|
|
292
|
+
this._startCheckTimer(event.timestamp);
|
|
293
|
+
this._startSilenceTimer(event.timestamp);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
case 'silence': {
|
|
298
|
+
// Periodic heartbeat — no action; timers already handle delayed firing.
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// IEndpointDetector — reset
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Cancel all pending timers and clear accumulated state so the detector is
|
|
310
|
+
* ready for the next turn. Should be called by the pipeline after each
|
|
311
|
+
* `turn_complete` event.
|
|
312
|
+
*/
|
|
313
|
+
reset(): void {
|
|
314
|
+
this._clearAllTimers();
|
|
315
|
+
this.accumulatedText = '';
|
|
316
|
+
this.speechActive = false;
|
|
317
|
+
this.turnStartMs = null;
|
|
318
|
+
this.lastConfidence = 1;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Private helpers
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Schedule the LLM completeness check after `minSilenceBeforeCheckMs`.
|
|
327
|
+
*
|
|
328
|
+
* If the LLM returns `COMPLETE`, `turn_complete` is emitted with reason
|
|
329
|
+
* `'semantic_model'` and both timers are cancelled. If the LLM returns
|
|
330
|
+
* `INCOMPLETE` or `TIMEOUT`, the check timer is done and the hard silence
|
|
331
|
+
* timer (already running) continues as the fallback.
|
|
332
|
+
*
|
|
333
|
+
* @param speechEndTimestamp — Timestamp of the triggering `speech_end` event.
|
|
334
|
+
*/
|
|
335
|
+
private _startCheckTimer(speechEndTimestamp: number): void {
|
|
336
|
+
this._clearCheckTimer();
|
|
337
|
+
|
|
338
|
+
// Capture state at scheduling time so async re-entrancy cannot corrupt it.
|
|
339
|
+
const transcriptSnapshot = this.accumulatedText;
|
|
340
|
+
|
|
341
|
+
this.checkTimer = setTimeout(async () => {
|
|
342
|
+
this.checkTimer = null;
|
|
343
|
+
|
|
344
|
+
// Guard: if the user resumed speaking before this timer fired, bail out.
|
|
345
|
+
if (this.speechActive || this.accumulatedText !== transcriptSnapshot) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let result: Awaited<ReturnType<TurnCompletenessClassifier['classify']>>;
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
result = await this.classifier.classify(transcriptSnapshot);
|
|
353
|
+
} catch {
|
|
354
|
+
// Unexpected classifier error — let the silence timer handle it.
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (result === 'COMPLETE') {
|
|
359
|
+
// Cancel the hard silence timer — LLM beat it.
|
|
360
|
+
this._clearSilenceTimer();
|
|
361
|
+
|
|
362
|
+
// Another guard: state may have changed while we awaited the LLM.
|
|
363
|
+
if (this.accumulatedText === transcriptSnapshot && !this.speechActive) {
|
|
364
|
+
this._emitTurnComplete('semantic_model', speechEndTimestamp);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// INCOMPLETE or TIMEOUT → do nothing; the hard silence timer continues.
|
|
368
|
+
}, this.minSilenceBeforeCheckMs);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Start the hard silence-timeout timer. When it fires, `turn_complete` is
|
|
373
|
+
* emitted with reason `'silence_timeout'` regardless of the LLM result.
|
|
374
|
+
*
|
|
375
|
+
* @param speechEndTimestamp — Timestamp of the triggering `speech_end` event.
|
|
376
|
+
*/
|
|
377
|
+
private _startSilenceTimer(speechEndTimestamp: number): void {
|
|
378
|
+
this._clearSilenceTimer();
|
|
379
|
+
|
|
380
|
+
this.silenceTimer = setTimeout(() => {
|
|
381
|
+
this.silenceTimer = null;
|
|
382
|
+
this._clearCheckTimer();
|
|
383
|
+
this._emitTurnComplete('silence_timeout', speechEndTimestamp);
|
|
384
|
+
}, this.silenceTimeoutMs);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Emit `turn_complete` with the currently accumulated transcript and reset
|
|
389
|
+
* internal state so the detector is clean for the next turn.
|
|
390
|
+
*
|
|
391
|
+
* State is reset **before** emitting so that re-entrant listeners see a
|
|
392
|
+
* clean detector.
|
|
393
|
+
*
|
|
394
|
+
* @param reason — Semantic reason driving turn completion.
|
|
395
|
+
* @param speechEndTimestamp — Timestamp used to compute `durationMs`.
|
|
396
|
+
*/
|
|
397
|
+
private _emitTurnComplete(reason: EndpointReason, speechEndTimestamp: number): void {
|
|
398
|
+
const durationMs =
|
|
399
|
+
this.turnStartMs !== null ? speechEndTimestamp - this.turnStartMs : 0;
|
|
400
|
+
|
|
401
|
+
const event: TurnCompleteEvent = {
|
|
402
|
+
transcript: this.accumulatedText,
|
|
403
|
+
confidence: this.lastConfidence,
|
|
404
|
+
durationMs,
|
|
405
|
+
reason,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
this.reset();
|
|
409
|
+
|
|
410
|
+
this.emit('turn_complete', event);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Cancel the LLM check timer without any other side effects.
|
|
415
|
+
*/
|
|
416
|
+
private _clearCheckTimer(): void {
|
|
417
|
+
if (this.checkTimer !== null) {
|
|
418
|
+
clearTimeout(this.checkTimer);
|
|
419
|
+
this.checkTimer = null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Cancel the hard silence timer without any other side effects.
|
|
425
|
+
*/
|
|
426
|
+
private _clearSilenceTimer(): void {
|
|
427
|
+
if (this.silenceTimer !== null) {
|
|
428
|
+
clearTimeout(this.silenceTimer);
|
|
429
|
+
this.silenceTimer = null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Cancel both the check timer and the silence timer.
|
|
435
|
+
*/
|
|
436
|
+
private _clearAllTimers(): void {
|
|
437
|
+
this._clearCheckTimer();
|
|
438
|
+
this._clearSilenceTimer();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file TurnCompletenessClassifier.ts
|
|
3
|
+
* @description LLM-backed binary classifier that decides whether an utterance
|
|
4
|
+
* is a complete conversational turn (COMPLETE) or likely to continue
|
|
5
|
+
* (INCOMPLETE). Results are LRU-cached to avoid redundant LLM round-trips for
|
|
6
|
+
* repeated short phrases.
|
|
7
|
+
*
|
|
8
|
+
* @module endpoint-semantic/TurnCompletenessClassifier
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Public types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The three possible results from {@link TurnCompletenessClassifier.classify}.
|
|
17
|
+
*
|
|
18
|
+
* - `COMPLETE` — The utterance is a self-contained thought expecting a reply.
|
|
19
|
+
* - `INCOMPLETE` — The speaker is likely to continue before yielding the floor.
|
|
20
|
+
* - `TIMEOUT` — The LLM did not respond within the configured time budget;
|
|
21
|
+
* the caller should fall back to silence-timeout behaviour.
|
|
22
|
+
*/
|
|
23
|
+
export type ClassifyResult = 'COMPLETE' | 'INCOMPLETE' | 'TIMEOUT';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Implementation
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Classifies a transcript as {@link ClassifyResult.COMPLETE} or
|
|
31
|
+
* {@link ClassifyResult.INCOMPLETE} using an LLM prompt.
|
|
32
|
+
*
|
|
33
|
+
* ### Caching
|
|
34
|
+
* Results are stored in a fixed-capacity LRU cache keyed on the first 100
|
|
35
|
+
* characters of the transcript. When the cache is full, the oldest entry is
|
|
36
|
+
* evicted (insertion-order eviction via `Map` iteration order, which is
|
|
37
|
+
* guaranteed by the ES2015 spec).
|
|
38
|
+
*
|
|
39
|
+
* ### Prompt format
|
|
40
|
+
* The LLM is asked to respond with exactly `COMPLETE` or `INCOMPLETE` as the
|
|
41
|
+
* first word, followed by one sentence of reasoning. Only the first word is
|
|
42
|
+
* examined — anything after the first space is treated as explanatory text and
|
|
43
|
+
* discarded.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* const classifier = new TurnCompletenessClassifier(
|
|
48
|
+
* async (prompt) => openai.chat.completions.create({ ... }).then(r => r.choices[0].message.content ?? ''),
|
|
49
|
+
* 400,
|
|
50
|
+
* );
|
|
51
|
+
* const result = await classifier.classify('Tell me about your', 'User: Hello\nAgent: Hi there!');
|
|
52
|
+
* // → 'INCOMPLETE' (sentence is unfinished)
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export class TurnCompletenessClassifier {
|
|
56
|
+
/**
|
|
57
|
+
* LRU result cache. Keys are the first 100 chars of the transcript; values
|
|
58
|
+
* are the cached classification. `Map` preserves insertion order, so
|
|
59
|
+
* eviction iterates from the oldest entry (`.keys().next().value`).
|
|
60
|
+
*/
|
|
61
|
+
private readonly cache = new Map<string, 'COMPLETE' | 'INCOMPLETE'>();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Maximum number of entries to keep in {@link cache} before evicting the
|
|
65
|
+
* oldest one.
|
|
66
|
+
*/
|
|
67
|
+
private readonly maxCacheSize = 100;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param llmCall — Async function that sends a prompt string to an LLM and
|
|
71
|
+
* resolves with the raw response text.
|
|
72
|
+
* @param timeoutMs — Maximum milliseconds to wait for the LLM before
|
|
73
|
+
* returning `TIMEOUT`. Defaults to 500 ms.
|
|
74
|
+
*/
|
|
75
|
+
constructor(
|
|
76
|
+
private readonly llmCall: (prompt: string) => Promise<string>,
|
|
77
|
+
private readonly timeoutMs: number = 500,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Public API
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Classify `transcript` as `COMPLETE`, `INCOMPLETE`, or `TIMEOUT`.
|
|
86
|
+
*
|
|
87
|
+
* The result is cached on the first 100 characters of `transcript` so that
|
|
88
|
+
* repeated short phrases (common in voice interaction) never hit the LLM
|
|
89
|
+
* twice.
|
|
90
|
+
*
|
|
91
|
+
* @param transcript — The utterance text to evaluate.
|
|
92
|
+
* @param context — Optional string containing the last 2 conversation turns,
|
|
93
|
+
* formatted as `"Speaker: text\n..."`. When omitted, the
|
|
94
|
+
* prompt uses `'N/A'`.
|
|
95
|
+
* @returns A {@link ClassifyResult} resolving to `COMPLETE`, `INCOMPLETE`,
|
|
96
|
+
* or `TIMEOUT`.
|
|
97
|
+
*/
|
|
98
|
+
async classify(transcript: string, context?: string): Promise<ClassifyResult> {
|
|
99
|
+
// -----------------------------------------------------------------------
|
|
100
|
+
// Cache lookup
|
|
101
|
+
// -----------------------------------------------------------------------
|
|
102
|
+
const cacheKey = transcript.slice(0, 100);
|
|
103
|
+
|
|
104
|
+
if (this.cache.has(cacheKey)) {
|
|
105
|
+
return this.cache.get(cacheKey)!;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
// Build prompt
|
|
110
|
+
// -----------------------------------------------------------------------
|
|
111
|
+
const prompt =
|
|
112
|
+
`Is the following utterance a complete thought that expects a response, ` +
|
|
113
|
+
`or is the speaker likely to continue?\n\n` +
|
|
114
|
+
`Context (last 2 turns):\n${context ?? 'N/A'}\n\n` +
|
|
115
|
+
`Current utterance: "${transcript}"\n\n` +
|
|
116
|
+
`Respond with exactly COMPLETE or INCOMPLETE, then one sentence of reasoning.`;
|
|
117
|
+
|
|
118
|
+
// -----------------------------------------------------------------------
|
|
119
|
+
// LLM call with timeout race
|
|
120
|
+
// -----------------------------------------------------------------------
|
|
121
|
+
let rawResult: string;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
rawResult = await Promise.race([
|
|
125
|
+
this.llmCall(prompt),
|
|
126
|
+
new Promise<never>((_, reject) =>
|
|
127
|
+
setTimeout(() => reject(new Error('timeout')), this.timeoutMs),
|
|
128
|
+
),
|
|
129
|
+
]);
|
|
130
|
+
} catch {
|
|
131
|
+
// Either the LLM threw or the timeout won — return TIMEOUT without
|
|
132
|
+
// caching so the next call re-attempts the LLM.
|
|
133
|
+
return 'TIMEOUT';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// -----------------------------------------------------------------------
|
|
137
|
+
// Parse response — only the first word matters
|
|
138
|
+
// -----------------------------------------------------------------------
|
|
139
|
+
const classification: 'COMPLETE' | 'INCOMPLETE' = rawResult.trim().startsWith('COMPLETE')
|
|
140
|
+
? 'COMPLETE'
|
|
141
|
+
: 'INCOMPLETE';
|
|
142
|
+
|
|
143
|
+
// -----------------------------------------------------------------------
|
|
144
|
+
// LRU eviction + cache write
|
|
145
|
+
// -----------------------------------------------------------------------
|
|
146
|
+
if (this.cache.size >= this.maxCacheSize) {
|
|
147
|
+
// Map.keys() returns an iterator in insertion order; the first key is the
|
|
148
|
+
// oldest entry.
|
|
149
|
+
const firstKey = this.cache.keys().next().value as string;
|
|
150
|
+
this.cache.delete(firstKey);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.cache.set(cacheKey, classification);
|
|
154
|
+
|
|
155
|
+
return classification;
|
|
156
|
+
}
|
|
157
|
+
}
|