@juspay/neurolink 13.6.5 → 13.8.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/CHANGELOG.md +6 -2
- package/dist/auth/sessionManager.js +8 -3
- package/dist/browser/neurolink.min.js +480 -460
- package/dist/cli/commands/serve.js +11 -1
- package/dist/cli/commands/transcribe.js +39 -3
- package/dist/constants/langfuse.d.ts +2 -0
- package/dist/constants/langfuse.js +2 -0
- package/dist/core/redisConversationMemoryManager.js +6 -16
- package/dist/factories/sttDescriptors.js +7 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -2
- package/dist/neurolink.js +33 -1
- package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
- package/dist/observability/exporters/langfuseExporter.js +29 -8
- package/dist/observability/otlpEncoding.js +2 -2
- package/dist/server/abstract/baseServerAdapter.js +2 -0
- package/dist/server/adapters/expressAdapter.js +1 -1
- package/dist/server/adapters/fastifyAdapter.js +4 -1
- package/dist/server/adapters/honoAdapter.js +1 -1
- package/dist/server/adapters/koaAdapter.js +1 -1
- package/dist/server/routes/transcribeRoutes.d.ts +0 -8
- package/dist/server/routes/transcribeRoutes.js +130 -3
- package/dist/server/utils/validation.d.ts +409 -15
- package/dist/server/utils/validation.js +106 -4
- package/dist/server/websocket/frameQueue.d.ts +16 -0
- package/dist/server/websocket/frameQueue.js +55 -0
- package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
- package/dist/server/websocket/realtimeTranscription.js +603 -0
- package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
- package/dist/server/websocket/transcribeWebSocket.js +263 -64
- package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
- package/dist/telemetry/attributes.d.ts +1 -1
- package/dist/telemetry/attributes.js +84 -8
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/exporter.d.ts +3 -1
- package/dist/types/server.d.ts +304 -2
- package/dist/types/stt.d.ts +38 -3
- package/dist/types/transcribe.d.ts +401 -5
- package/dist/utils/redis.d.ts +17 -0
- package/dist/utils/redis.js +31 -0
- package/dist/voice/audioLevel.d.ts +14 -0
- package/dist/voice/audioLevel.js +60 -0
- package/dist/voice/codeMix.d.ts +40 -0
- package/dist/voice/codeMix.js +282 -0
- package/dist/voice/correction/align.d.ts +12 -0
- package/dist/voice/correction/align.js +48 -0
- package/dist/voice/correction/guards.d.ts +39 -0
- package/dist/voice/correction/guards.js +122 -0
- package/dist/voice/correction/index.d.ts +25 -3
- package/dist/voice/correction/index.js +132 -4
- package/dist/voice/correction/rewrite.d.ts +64 -3
- package/dist/voice/correction/rewrite.js +295 -25
- package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
- package/dist/voice/eval/sttEvalLangfuse.js +182 -0
- package/dist/voice/eval/sttEvalStore.d.ts +27 -0
- package/dist/voice/eval/sttEvalStore.js +397 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/languages.d.ts +34 -0
- package/dist/voice/languages.js +290 -0
- package/dist/voice/modelAssets.d.ts +28 -0
- package/dist/voice/modelAssets.js +113 -0
- package/dist/voice/output/render.d.ts +32 -0
- package/dist/voice/output/render.js +368 -0
- package/dist/voice/providers/OpenAISTT.d.ts +11 -1
- package/dist/voice/providers/OpenAISTT.js +57 -6
- package/dist/voice/session/transcriptionSession.d.ts +55 -0
- package/dist/voice/session/transcriptionSession.js +225 -0
- package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
- package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
- package/dist/voice/streaming/endpointer.d.ts +50 -17
- package/dist/voice/streaming/endpointer.js +115 -19
- package/dist/voice/transcribe.d.ts +18 -1
- package/dist/voice/transcribe.js +459 -18
- package/dist/voice/transcribeStream.d.ts +18 -1
- package/dist/voice/transcribeStream.js +413 -16
- package/dist/voice/vad/assets.d.ts +36 -0
- package/dist/voice/vad/assets.js +142 -0
- package/dist/voice/vad/frameScorer.d.ts +28 -0
- package/dist/voice/vad/frameScorer.js +90 -0
- package/dist/voice/vad/sileroVad.d.ts +43 -0
- package/dist/voice/vad/sileroVad.js +178 -0
- package/dist/voice/whistle/assets.d.ts +3 -4
- package/dist/voice/whistle/assets.js +11 -95
- package/docs-site/static/search-index.json +21 -6
- package/models/silero-vad/README.md +19 -0
- package/models/silero-vad/silero_vad.onnx +0 -0
- package/package.json +4 -1
|
@@ -18,9 +18,103 @@
|
|
|
18
18
|
* @module voice/correction/rewrite
|
|
19
19
|
*/
|
|
20
20
|
import { TimeoutError, withTimeout } from "../../utils/async/withTimeout.js";
|
|
21
|
+
import { describeOutputTag, hasIndicScript, isIndicLanguage, normalizeLanguageCode, } from "../languages.js";
|
|
22
|
+
import { analyzeCodeMix } from "../codeMix.js";
|
|
21
23
|
import { dictionaryBlock } from "./dictionary.js";
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The rewrite's provider/model/timeout settings, whatever shape `rewrite`
|
|
26
|
+
* took: `false` and `"auto"` carry none, an object carries its own.
|
|
27
|
+
*/
|
|
28
|
+
export function rewriteSettings(rewrite) {
|
|
29
|
+
return rewrite && typeof rewrite === "object" ? rewrite : undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether the rewrite is wanted for a text, under the "auto" rule: it runs
|
|
33
|
+
* when there is something for it to do and is skipped otherwise, which is
|
|
34
|
+
* what makes an already clean English sentence cost nothing. `"always"`
|
|
35
|
+
* (object form with `mode: "always"`) runs it every time; `false` never.
|
|
36
|
+
*/
|
|
37
|
+
export function rewriteWanted(text, options, signals = {}) {
|
|
38
|
+
const rewrite = options?.rewrite;
|
|
39
|
+
if (rewrite === false) {
|
|
40
|
+
return { wanted: false, reason: "rewrite off" };
|
|
41
|
+
}
|
|
42
|
+
if (typeof rewrite === "object" && rewrite.mode === "always") {
|
|
43
|
+
return { wanted: true, reason: "always" };
|
|
44
|
+
}
|
|
45
|
+
const trimmed = text.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return { wanted: false, reason: "empty" };
|
|
48
|
+
}
|
|
49
|
+
if (signals.hasSecondOpinion) {
|
|
50
|
+
return { wanted: true, reason: "second opinion to reconcile" };
|
|
51
|
+
}
|
|
52
|
+
if (options?.cleanup === "clean") {
|
|
53
|
+
return { wanted: true, reason: "clean verbatim" };
|
|
54
|
+
}
|
|
55
|
+
if ((signals.termsApplied ?? 0) > 0) {
|
|
56
|
+
return { wanted: true, reason: "dictionary term applied" };
|
|
57
|
+
}
|
|
58
|
+
const transliterate = resolveTransliterate(options, signals.language);
|
|
59
|
+
if (transliterate === "latin" && needsRomanising(trimmed, signals.language)) {
|
|
60
|
+
return { wanted: true, reason: "romanise" };
|
|
61
|
+
}
|
|
62
|
+
if (options?.punctuate !== false) {
|
|
63
|
+
const words = trimmed.split(/\s+/u).length;
|
|
64
|
+
if (words >= 4 && !TERMINAL_PUNCTUATION.test(trimmed)) {
|
|
65
|
+
return { wanted: true, reason: "punctuate" };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { wanted: false, reason: "nothing to change" };
|
|
69
|
+
}
|
|
70
|
+
/** Any letter outside the Latin blocks (Indic, Arabic, CJK, Cyrillic …). */
|
|
71
|
+
const NON_LATIN_LETTER = /(?!\p{Script=Latin})\p{L}/u;
|
|
72
|
+
/** Arabic-script letters, which are Indian-language text only for Urdu, Sindhi and Kashmiri. */
|
|
73
|
+
const ARABIC_LETTER = /\p{Script=Arabic}/u;
|
|
74
|
+
/**
|
|
75
|
+
* The script setting in effect. Unset, Indian languages (and speech whose
|
|
76
|
+
* language is unknown, as before) are written in Latin letters; any other
|
|
77
|
+
* known language keeps its own script.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveTransliterate(options, language) {
|
|
80
|
+
if (options?.transliterate) {
|
|
81
|
+
return options.transliterate;
|
|
82
|
+
}
|
|
83
|
+
const code = normalizeLanguageCode(language);
|
|
84
|
+
return code && code !== "en" && !isIndicLanguage(code) ? "native" : "latin";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether `text` holds script the Latin setting converts: Indic letters, or
|
|
88
|
+
* Arabic letters when the language is Urdu, Sindhi or Kashmiri, or any
|
|
89
|
+
* non-Latin letters when the caller explicitly asked for Latin output in a
|
|
90
|
+
* language outside India.
|
|
91
|
+
*/
|
|
92
|
+
function needsRomanising(text, language) {
|
|
93
|
+
if (hasIndicScript(text)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
const code = normalizeLanguageCode(language);
|
|
97
|
+
if (ARABIC_LETTER.test(text) &&
|
|
98
|
+
(code === "ur" || code === "sd" || code === "ks")) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return (code !== undefined &&
|
|
102
|
+
code !== "en" &&
|
|
103
|
+
!isIndicLanguage(code) &&
|
|
104
|
+
NON_LATIN_LETTER.test(text));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether the Indian-language prompt applies: Indian languages, English
|
|
108
|
+
* (code-mixed speech is often reported as English) and speech whose language
|
|
109
|
+
* is unknown. Any other known language gets the general prompt.
|
|
110
|
+
*/
|
|
111
|
+
function indicPrompt(language) {
|
|
112
|
+
const code = normalizeLanguageCode(language);
|
|
113
|
+
return code === undefined || code === "en" || isIndicLanguage(code);
|
|
114
|
+
}
|
|
115
|
+
/** Ends with a sentence mark, the danda, or a closing quote/bracket after one. */
|
|
116
|
+
const TERMINAL_PUNCTUATION = /[.!?\u0964\u0965\u2026][\s"'\u201D\u2019)\]]*$/u;
|
|
117
|
+
const DEFAULT_REWRITE_TIMEOUT_MS = 12000;
|
|
24
118
|
/** Default per-batch timeout for `rewriteLines`: a batch carries up to 25 lines. */
|
|
25
119
|
export const DEFAULT_REWRITE_LINES_TIMEOUT_MS = 60000;
|
|
26
120
|
/** Default share of words a rewrite may drop before it is discarded. */
|
|
@@ -68,9 +162,13 @@ function scriptRule(transliterate) {
|
|
|
68
162
|
* transcripts: numbered lines in, the same numbered lines out.
|
|
69
163
|
*/
|
|
70
164
|
export function buildRewriteSystemPrompt(options) {
|
|
165
|
+
if (!indicPrompt(options?.language)) {
|
|
166
|
+
return buildGeneralRewriteSystemPrompt(options ?? {});
|
|
167
|
+
}
|
|
71
168
|
const transliterate = options?.transliterate ?? "latin";
|
|
72
169
|
const punctuate = options?.punctuate ?? true;
|
|
73
170
|
const lines = options?.lines ?? false;
|
|
171
|
+
const clean = options?.cleanup === "clean";
|
|
74
172
|
const table = dictionaryBlock(options?.dictionary);
|
|
75
173
|
const rules = [
|
|
76
174
|
scriptRule(transliterate),
|
|
@@ -82,18 +180,36 @@ export function buildRewriteSystemPrompt(options) {
|
|
|
82
180
|
rules.push('- Use the dictionary: a word or phrase that sounds like a term or one of its "also heard as" forms, ' +
|
|
83
181
|
"and means that term here, is written as the term exactly.");
|
|
84
182
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
183
|
+
// Indic engines mis-hear short words into near-homophones that read as
|
|
184
|
+
// real words (कल "tomorrow" → खाल "skin"); only context reveals them.
|
|
185
|
+
rules.push("- Fix near-homophones that make no sense in context when the intended word is obvious " +
|
|
186
|
+
'(खाल heard where कल, "tomorrow", is meant); never change a word that makes sense as heard.');
|
|
187
|
+
const fillers = clean ? "remove fillers" : "keep fillers";
|
|
88
188
|
rules.push(punctuate
|
|
89
|
-
?
|
|
90
|
-
:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"
|
|
96
|
-
|
|
189
|
+
? `- Add natural punctuation and capitalization; write numbers as digits; ${fillers}.`
|
|
190
|
+
: `- Do not add or change punctuation or capitalization; ${fillers}.`);
|
|
191
|
+
if (clean) {
|
|
192
|
+
rules.push("- Clean verbatim: drop fillers (um, uh, hmm, a 'like' or 'you know' that carries no meaning), " +
|
|
193
|
+
'stutters and immediate word repeats ("they they they" → "they", "no, no, no" → "no"), ' +
|
|
194
|
+
"and false starts that the speaker restarted. Never drop a word that carries meaning, never " +
|
|
195
|
+
"summarize, add or reorder.");
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
rules.push(lines
|
|
199
|
+
? "- Keep every word: never summarize, add, drop, reorder or merge. Keep any speaker label at the " +
|
|
200
|
+
"start of a line unchanged. A line that needs no change is returned verbatim."
|
|
201
|
+
: "- Keep every word: never summarize, add, drop or reorder. With two transcripts, take " +
|
|
202
|
+
"Indian-language words from the one that has them in an Indian language and English words " +
|
|
203
|
+
"from the one that spells them in English; elsewhere prefer Transcript 1. When a word " +
|
|
204
|
+
"alignment is given, decide word by word: a pair that is the same English word (मीटिंग ≈ meeting) " +
|
|
205
|
+
"is that English word; where the English transcript has an English-sounding guess at an " +
|
|
206
|
+
"Indian-language word (भेज देना | page dinner), keep the Indian-language word. Sounding alike " +
|
|
207
|
+
"is not enough: when the English word makes no sense in the sentence, the speaker said the " +
|
|
208
|
+
'Indian-language word (खाल ≈ Call before "meeting hai" is kal, tomorrow).');
|
|
209
|
+
}
|
|
210
|
+
if (clean && lines) {
|
|
211
|
+
rules.push("- Never merge or reorder lines. Keep any speaker label at the start of a line unchanged.");
|
|
212
|
+
}
|
|
97
213
|
const head = [
|
|
98
214
|
"You are a post-editor for speech-recognition (ASR) transcripts. The speaker talks English, " +
|
|
99
215
|
"an Indian language (Hindi, Bengali, Marathi, Gujarati, Punjabi, Odia, Assamese, Urdu, Tamil, " +
|
|
@@ -111,6 +227,56 @@ export function buildRewriteSystemPrompt(options) {
|
|
|
111
227
|
].join("\n");
|
|
112
228
|
return table ? `${head}\n\n${table}` : head;
|
|
113
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* The rewrite prompt for a known language outside India: fix what the engine
|
|
232
|
+
* got wrong, keep the language and its script (unless Latin letters were
|
|
233
|
+
* asked for), never translate.
|
|
234
|
+
*/
|
|
235
|
+
function buildGeneralRewriteSystemPrompt(options) {
|
|
236
|
+
const code = normalizeLanguageCode(options.language) ?? "und";
|
|
237
|
+
const name = describeOutputTag(code);
|
|
238
|
+
const punctuate = options.punctuate ?? true;
|
|
239
|
+
const lines = options.lines ?? false;
|
|
240
|
+
const clean = options.cleanup === "clean";
|
|
241
|
+
const table = dictionaryBlock(options.dictionary);
|
|
242
|
+
const rules = [
|
|
243
|
+
options.transliterate === "latin"
|
|
244
|
+
? `- Write ${name} in Latin letters using its standard romanization; keep the words in ${name}.`
|
|
245
|
+
: `- Keep the text in ${name} and in its own writing system; never transliterate it.`,
|
|
246
|
+
`- NEVER translate. A ${name} word stays ${name} and an English word stays English.`,
|
|
247
|
+
"- Fix words the recognizer mis-heard when the intended word is obvious from context; " +
|
|
248
|
+
"English words get their correct English spelling.",
|
|
249
|
+
];
|
|
250
|
+
if (table) {
|
|
251
|
+
rules.push('- Use the dictionary: a word or phrase that sounds like a term or one of its "also heard as" forms, ' +
|
|
252
|
+
"and means that term here, is written as the term exactly.");
|
|
253
|
+
}
|
|
254
|
+
const fillers = clean ? "remove fillers" : "keep fillers";
|
|
255
|
+
rules.push(punctuate
|
|
256
|
+
? `- Add natural punctuation and capitalization in ${name}'s own conventions; write numbers as digits; ${fillers}.`
|
|
257
|
+
: `- Do not add or change punctuation or capitalization; ${fillers}.`);
|
|
258
|
+
rules.push(clean
|
|
259
|
+
? "- Clean verbatim: drop fillers, stutters, immediate repeats and restarted false starts. " +
|
|
260
|
+
"Never drop a word that carries meaning, never summarize, add or reorder."
|
|
261
|
+
: lines
|
|
262
|
+
? "- Keep every word: never summarize, add, drop, reorder or merge. Keep any speaker label at the " +
|
|
263
|
+
"start of a line unchanged. A line that needs no change is returned verbatim."
|
|
264
|
+
: "- Keep every word: never summarize, add, drop or reorder. With two transcripts, take each word " +
|
|
265
|
+
"from the one that wrote it correctly; elsewhere prefer Transcript 1.");
|
|
266
|
+
const head = [
|
|
267
|
+
`You are a post-editor for speech-recognition (ASR) transcripts. The speaker talks ${name}, ` +
|
|
268
|
+
"possibly mixed with English.",
|
|
269
|
+
lines
|
|
270
|
+
? "Input: a numbered list of transcript lines."
|
|
271
|
+
: "Input: an ASR transcript, and possibly a second transcript of the same audio from another engine.",
|
|
272
|
+
"Write the speaker's words exactly as they said them:",
|
|
273
|
+
...rules,
|
|
274
|
+
lines
|
|
275
|
+
? "Output: the same numbered lines, one per input line, nothing else."
|
|
276
|
+
: "Output only the corrected text, nothing else.",
|
|
277
|
+
].join("\n");
|
|
278
|
+
return table ? `${head}\n\n${table}` : head;
|
|
279
|
+
}
|
|
114
280
|
/**
|
|
115
281
|
* User message for the rewrite. With a second opinion the two transcripts are
|
|
116
282
|
* labelled; `literal` lists aliases the guard decided were meant literally, so
|
|
@@ -118,14 +284,75 @@ export function buildRewriteSystemPrompt(options) {
|
|
|
118
284
|
*/
|
|
119
285
|
export function buildRewriteUserPrompt(primary, secondOpinion, extras) {
|
|
120
286
|
const second = secondOpinion?.trim();
|
|
287
|
+
const previous = (extras?.previous ?? [])
|
|
288
|
+
.map((line) => line.trim())
|
|
289
|
+
.filter(Boolean);
|
|
290
|
+
const context = previous.length
|
|
291
|
+
? "Earlier in this conversation (context only: do not correct, translate or repeat these):\n" +
|
|
292
|
+
previous.map((line) => `- ${line}`).join("\n") +
|
|
293
|
+
"\n\n"
|
|
294
|
+
: "";
|
|
121
295
|
const body = second
|
|
122
296
|
? `Transcript 1 (${extras?.labels?.primary ?? "primary engine"}): ${primary}\n` +
|
|
123
297
|
`Transcript 2 (${extras?.labels?.secondary ?? "second engine"}): ${second}`
|
|
124
298
|
: `Transcript: ${primary}`;
|
|
299
|
+
const aligned = alignmentBlock(extras?.pairs);
|
|
125
300
|
const literal = [...new Set(extras?.literal ?? [])].filter(Boolean);
|
|
301
|
+
if (aligned) {
|
|
302
|
+
return literal.length
|
|
303
|
+
? `${context}${body}\n${aligned}\nMeant literally here, not dictionary terms: ${literal.map((w) => `"${w}"`).join(", ")}`
|
|
304
|
+
: `${context}${body}\n${aligned}`;
|
|
305
|
+
}
|
|
126
306
|
return literal.length
|
|
127
|
-
? `${body}\nMeant literally here, not dictionary terms: ${literal.map((w) => `"${w}"`).join(", ")}`
|
|
128
|
-
: body
|
|
307
|
+
? `${context}${body}\nMeant literally here, not dictionary terms: ${literal.map((w) => `"${w}"`).join(", ")}`
|
|
308
|
+
: `${context}${body}`;
|
|
309
|
+
}
|
|
310
|
+
/** Most aligned positions shown to the model. */
|
|
311
|
+
const MAX_ALIGNED_PAIRS = 120;
|
|
312
|
+
/** Largest word grid (primary × second opinion) aligned for reconciliation: ~500 × 500. */
|
|
313
|
+
const MAX_ALIGN_CELLS = 250_000;
|
|
314
|
+
/**
|
|
315
|
+
* The word alignment of two transcripts as a prompt block: same-sounding
|
|
316
|
+
* words side by side ("मीटिंग ≈ meeting"), and runs where only one engine
|
|
317
|
+
* heard something, so the rewrite decides word by word which reading holds
|
|
318
|
+
* the speaker's word in which language.
|
|
319
|
+
*/
|
|
320
|
+
export function alignmentBlock(pairs) {
|
|
321
|
+
if (!pairs?.length) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
const cells = pairs
|
|
325
|
+
.slice(0, MAX_ALIGNED_PAIRS)
|
|
326
|
+
.map((p) => p.alike
|
|
327
|
+
? `${p.indic} ≈ ${p.latin}`
|
|
328
|
+
: `${p.indic ?? "∅"} | ${p.latin ?? "∅"}`);
|
|
329
|
+
return ("Word alignment of the two transcripts, in order (≈ sounds alike, | heard differently, ∅ nothing there):\n" +
|
|
330
|
+
cells.join("; "));
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Word pairs for a reconciliation: the Indic-script transcript aligned
|
|
334
|
+
* against the Latin-script one, whichever order they came in; `undefined`
|
|
335
|
+
* when both are in the same script (nothing to line up across scripts).
|
|
336
|
+
*/
|
|
337
|
+
export function reconcilePairs(primary, secondOpinion) {
|
|
338
|
+
if (!secondOpinion?.trim()) {
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
const indicFirst = hasIndicScript(primary) && !hasIndicScript(secondOpinion);
|
|
342
|
+
const indicSecond = hasIndicScript(secondOpinion) && !hasIndicScript(primary);
|
|
343
|
+
if (!indicFirst && !indicSecond) {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
// The alignment is quadratic; a whole long transcript is reconciled
|
|
347
|
+
// without pairs rather than stalling the event loop.
|
|
348
|
+
const cells = primary.split(/\s+/u).length * secondOpinion.split(/\s+/u).length;
|
|
349
|
+
if (cells > MAX_ALIGN_CELLS) {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
const mix = indicFirst
|
|
353
|
+
? analyzeCodeMix(primary, secondOpinion)
|
|
354
|
+
: analyzeCodeMix(secondOpinion, primary);
|
|
355
|
+
return { pairs: mix.pairs, englishShare: mix.englishShare };
|
|
129
356
|
}
|
|
130
357
|
/** A label the model may echo in front of its answer ("Transcript 1 (…):"). */
|
|
131
358
|
const ECHOED_LABEL_RE = /^(?:corrected\s+)?transcript(?:\s*\d+)?(?:\s*\([^)]*\))?\s*:\s*/iu;
|
|
@@ -152,10 +379,14 @@ function dropRatio(before, after) {
|
|
|
152
379
|
}
|
|
153
380
|
return Math.max(0, (inWords - countWords(after)) / inWords);
|
|
154
381
|
}
|
|
155
|
-
|
|
382
|
+
/** Share of words a clean-verbatim rewrite may remove when the caller set no `maxDropRatio`. */
|
|
383
|
+
const CLEAN_MAX_DROP_RATIO = 0.5;
|
|
384
|
+
function resolveDropRatio(value, cleanup) {
|
|
156
385
|
return typeof value === "number" && Number.isFinite(value)
|
|
157
386
|
? Math.min(1, Math.max(0, value))
|
|
158
|
-
:
|
|
387
|
+
: cleanup === "clean"
|
|
388
|
+
? CLEAN_MAX_DROP_RATIO
|
|
389
|
+
: DEFAULT_MAX_DROP_RATIO;
|
|
159
390
|
}
|
|
160
391
|
function errorMessage(error) {
|
|
161
392
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -168,7 +399,7 @@ function errorMessage(error) {
|
|
|
168
399
|
export async function rewriteTranscript(input, rewrite) {
|
|
169
400
|
const started = Date.now();
|
|
170
401
|
const { primary, secondOpinion, options } = input;
|
|
171
|
-
const settings = options?.rewrite
|
|
402
|
+
const settings = rewriteSettings(options?.rewrite);
|
|
172
403
|
const timeoutMs = settings?.timeoutMs && settings.timeoutMs > 0
|
|
173
404
|
? settings.timeoutMs
|
|
174
405
|
: DEFAULT_REWRITE_TIMEOUT_MS;
|
|
@@ -195,13 +426,17 @@ export async function rewriteTranscript(input, rewrite) {
|
|
|
195
426
|
try {
|
|
196
427
|
content = await withTimeout(rewrite({
|
|
197
428
|
system: buildRewriteSystemPrompt({
|
|
198
|
-
transliterate: options
|
|
429
|
+
transliterate: resolveTransliterate(options, input.language),
|
|
199
430
|
punctuate: options?.punctuate,
|
|
431
|
+
cleanup: options?.cleanup,
|
|
200
432
|
dictionary: input.dictionary,
|
|
433
|
+
language: input.language,
|
|
201
434
|
}),
|
|
202
435
|
user: buildRewriteUserPrompt(primary, secondOpinion, {
|
|
203
436
|
labels: input.labels,
|
|
204
437
|
literal: input.literal,
|
|
438
|
+
previous: input.previous,
|
|
439
|
+
...(input.pairs ? { pairs: input.pairs } : {}),
|
|
205
440
|
}),
|
|
206
441
|
provider: settings?.provider,
|
|
207
442
|
model: settings?.model,
|
|
@@ -222,11 +457,46 @@ export async function rewriteTranscript(input, rewrite) {
|
|
|
222
457
|
return keep("empty");
|
|
223
458
|
}
|
|
224
459
|
const ratio = dropRatio(primary, text);
|
|
225
|
-
if (ratio > resolveDropRatio(options?.maxDropRatio)) {
|
|
460
|
+
if (ratio > resolveDropRatio(options?.maxDropRatio, options?.cleanup)) {
|
|
226
461
|
return keep("dropped", { dropRatio: ratio });
|
|
227
462
|
}
|
|
463
|
+
if (addedWords(primary, secondOpinion, text, input.previous)) {
|
|
464
|
+
return keep("added");
|
|
465
|
+
}
|
|
228
466
|
return { text, status: "ok", ms: Date.now() - started };
|
|
229
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Whether a rewrite says more than was spoken: noticeably more words than
|
|
470
|
+
* the longer of the two readings it was given (a model that joins both
|
|
471
|
+
* readings one after the other instead of choosing between them), or a run
|
|
472
|
+
* of six words copied from an earlier utterance that the input does not
|
|
473
|
+
* contain (a model that repeats its context).
|
|
474
|
+
*/
|
|
475
|
+
function addedWords(primary, secondOpinion, output, previous) {
|
|
476
|
+
const given = Math.max(countWords(primary), countWords(secondOpinion ?? ""));
|
|
477
|
+
const out = countWords(output);
|
|
478
|
+
if (out > given * 1.3 + 3) {
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
const norm = (t) => t
|
|
482
|
+
.toLowerCase()
|
|
483
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
484
|
+
.split(/\s+/u)
|
|
485
|
+
.filter(Boolean);
|
|
486
|
+
const inputText = ` ${[...norm(primary), ...norm(secondOpinion ?? "")].join(" ")} `;
|
|
487
|
+
const outWords = norm(output);
|
|
488
|
+
for (const line of previous ?? []) {
|
|
489
|
+
const prev = norm(line);
|
|
490
|
+
for (let i = 0; i + 6 <= prev.length; i++) {
|
|
491
|
+
const run = prev.slice(i, i + 6).join(" ");
|
|
492
|
+
if (` ${outWords.join(" ")} `.includes(` ${run} `) &&
|
|
493
|
+
!inputText.includes(` ${run} `)) {
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
230
500
|
/** Run `task` over `items` with at most `limit` in flight; results keep input order. */
|
|
231
501
|
async function mapLimited(items, limit, task) {
|
|
232
502
|
const results = new Array(items.length);
|
|
@@ -252,18 +522,18 @@ const NUMBERED_LINE_RE = /^\s*(\d+)[.)]\s?(.*)$/gmu;
|
|
|
252
522
|
export async function rewriteLines(lines, rewrite, options) {
|
|
253
523
|
const started = Date.now();
|
|
254
524
|
const out = [...lines];
|
|
255
|
-
const settings = options?.correction?.rewrite
|
|
256
|
-
? undefined
|
|
257
|
-
: options?.correction?.rewrite;
|
|
525
|
+
const settings = rewriteSettings(options?.correction?.rewrite);
|
|
258
526
|
const timeoutMs = settings?.timeoutMs && settings.timeoutMs > 0
|
|
259
527
|
? settings.timeoutMs
|
|
260
528
|
: DEFAULT_REWRITE_LINES_TIMEOUT_MS;
|
|
261
|
-
const maxDrop = resolveDropRatio(options?.correction?.maxDropRatio);
|
|
529
|
+
const maxDrop = resolveDropRatio(options?.correction?.maxDropRatio, options?.correction?.cleanup);
|
|
262
530
|
const system = buildRewriteSystemPrompt({
|
|
263
|
-
transliterate: options?.correction?.
|
|
531
|
+
transliterate: resolveTransliterate(options?.correction, options?.language),
|
|
264
532
|
punctuate: options?.correction?.punctuate,
|
|
533
|
+
cleanup: options?.correction?.cleanup,
|
|
265
534
|
dictionary: options?.dictionary,
|
|
266
535
|
lines: true,
|
|
536
|
+
language: options?.language,
|
|
267
537
|
});
|
|
268
538
|
const indices = lines
|
|
269
539
|
.map((line, index) => (line.trim() ? index : -1))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ServerSTTEvalBatchRequest, ServerSTTEvalLangfuseConfig, ServerSTTEvalReport } from "../../types/index.js";
|
|
2
|
+
/** The export configuration from the environment, or `undefined` when it is not set up. */
|
|
3
|
+
export declare function sttEvalLangfuseConfigFromEnv(env?: NodeJS.ProcessEnv): ServerSTTEvalLangfuseConfig | undefined;
|
|
4
|
+
export declare class STTEvalLangfuseExporter {
|
|
5
|
+
private readonly _config;
|
|
6
|
+
private _tail;
|
|
7
|
+
constructor(config: ServerSTTEvalLangfuseConfig);
|
|
8
|
+
/** Queues one batch (and the call's comparison after it) for export. Never throws. */
|
|
9
|
+
export(batch: ServerSTTEvalBatchRequest, report: ServerSTTEvalReport | undefined): Promise<void>;
|
|
10
|
+
/** Resolves once every export queued so far has been attempted. */
|
|
11
|
+
flush(): Promise<void>;
|
|
12
|
+
private _eventsFor;
|
|
13
|
+
private _send;
|
|
14
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Langfuse export for the STT evaluation collector.
|
|
3
|
+
*
|
|
4
|
+
* Configured only by the server's environment, never by a request or a route
|
|
5
|
+
* option: where evaluation data goes is the operator's decision.
|
|
6
|
+
*
|
|
7
|
+
* NEUROLINK_STT_EVAL_LANGFUSE_PUBLIC_KEY / _SECRET_KEY both required
|
|
8
|
+
* NEUROLINK_STT_EVAL_LANGFUSE_BASE_URL default https://cloud.langfuse.com
|
|
9
|
+
* NEUROLINK_STT_EVAL_LANGFUSE_ENVIRONMENT optional
|
|
10
|
+
*
|
|
11
|
+
* These are separate from NeuroLink's own `LANGFUSE_*` keys so evaluations
|
|
12
|
+
* land in a project of their own. Each call is one trace (`stt-eval`), each
|
|
13
|
+
* segment one event under it, and the disagreement figures are scores on the
|
|
14
|
+
* trace. Ids are derived from the call and segment ids, so a resent batch
|
|
15
|
+
* updates what it sent before instead of duplicating it. An export never
|
|
16
|
+
* fails or delays the batch that caused it: it runs in the background, in
|
|
17
|
+
* order, and a failure is logged.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
20
|
+
import { logger } from "../../utils/logger.js";
|
|
21
|
+
const DEFAULT_BASE_URL = "https://cloud.langfuse.com";
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
23
|
+
/** Events per ingestion request, well under Langfuse's request-size limit. */
|
|
24
|
+
const EVENTS_PER_REQUEST = 500;
|
|
25
|
+
/** The export configuration from the environment, or `undefined` when it is not set up. */
|
|
26
|
+
export function sttEvalLangfuseConfigFromEnv(env = process.env) {
|
|
27
|
+
const publicKey = env.NEUROLINK_STT_EVAL_LANGFUSE_PUBLIC_KEY?.trim();
|
|
28
|
+
const secretKey = env.NEUROLINK_STT_EVAL_LANGFUSE_SECRET_KEY?.trim();
|
|
29
|
+
if (!publicKey || !secretKey) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const environment = env.NEUROLINK_STT_EVAL_LANGFUSE_ENVIRONMENT?.trim();
|
|
33
|
+
return {
|
|
34
|
+
publicKey,
|
|
35
|
+
secretKey,
|
|
36
|
+
baseUrl: (env.NEUROLINK_STT_EVAL_LANGFUSE_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/u, ""),
|
|
37
|
+
...(environment ? { environment } : {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export class STTEvalLangfuseExporter {
|
|
41
|
+
_config;
|
|
42
|
+
_tail = Promise.resolve();
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this._config = config;
|
|
45
|
+
}
|
|
46
|
+
/** Queues one batch (and the call's comparison after it) for export. Never throws. */
|
|
47
|
+
export(batch, report) {
|
|
48
|
+
const events = this._eventsFor(batch, report);
|
|
49
|
+
this._tail = this._tail.then(() => this._send(batch.call_id, events));
|
|
50
|
+
return this._tail;
|
|
51
|
+
}
|
|
52
|
+
/** Resolves once every export queued so far has been attempted. */
|
|
53
|
+
flush() {
|
|
54
|
+
return this._tail;
|
|
55
|
+
}
|
|
56
|
+
_eventsFor(batch, report) {
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
const traceId = idFor("trace", batch.call_id);
|
|
59
|
+
const env = this._config.environment
|
|
60
|
+
? { environment: this._config.environment }
|
|
61
|
+
: {};
|
|
62
|
+
const event = (type, body) => ({
|
|
63
|
+
id: randomUUID(),
|
|
64
|
+
timestamp: now,
|
|
65
|
+
type,
|
|
66
|
+
body: { ...body, ...env },
|
|
67
|
+
});
|
|
68
|
+
const events = [
|
|
69
|
+
event("trace-create", {
|
|
70
|
+
id: traceId,
|
|
71
|
+
name: "stt-eval",
|
|
72
|
+
sessionId: batch.call_id,
|
|
73
|
+
tags: ["stt-eval", batch.models.primary, batch.models.shadow],
|
|
74
|
+
metadata: {
|
|
75
|
+
call_id: batch.call_id,
|
|
76
|
+
room: batch.room ?? null,
|
|
77
|
+
models: batch.models,
|
|
78
|
+
final: report?.final ?? batch.final === true,
|
|
79
|
+
batches: report?.batches,
|
|
80
|
+
segments: report?.segments,
|
|
81
|
+
speakers: report?.speakers,
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
];
|
|
85
|
+
for (const segment of batch.segments) {
|
|
86
|
+
const start = segment.start_time ?? segment.received_at;
|
|
87
|
+
events.push(event("event-create", {
|
|
88
|
+
id: idFor("segment", `${batch.call_id}\u0000${segment.id}`),
|
|
89
|
+
traceId,
|
|
90
|
+
name: `${segment.source}:${segment.participant_name || segment.participant_identity}`,
|
|
91
|
+
startTime: isoFromSeconds(start),
|
|
92
|
+
output: segment.text,
|
|
93
|
+
metadata: {
|
|
94
|
+
segment_id: segment.id,
|
|
95
|
+
source: segment.source,
|
|
96
|
+
model: segment.model,
|
|
97
|
+
participant_identity: segment.participant_identity,
|
|
98
|
+
participant_name: segment.participant_name ?? null,
|
|
99
|
+
language: segment.language ?? null,
|
|
100
|
+
start_time: segment.start_time ?? null,
|
|
101
|
+
end_time: segment.end_time ?? null,
|
|
102
|
+
received_at: segment.received_at,
|
|
103
|
+
},
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
if (report) {
|
|
107
|
+
const score = (key, name, value, extra = {}) => {
|
|
108
|
+
if (value === null) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
events.push(event("score-create", {
|
|
112
|
+
id: idFor("score", `${batch.call_id}\u0000${key}`),
|
|
113
|
+
traceId,
|
|
114
|
+
name,
|
|
115
|
+
value,
|
|
116
|
+
dataType: "NUMERIC",
|
|
117
|
+
...extra,
|
|
118
|
+
}));
|
|
119
|
+
};
|
|
120
|
+
score("overall", "stt_disagreement", report.overall.disagreement);
|
|
121
|
+
score("overall-phonetic", "stt_phonetic_disagreement", report.overall.phonetic_disagreement);
|
|
122
|
+
for (const speaker of report.speakers_compared) {
|
|
123
|
+
const who = {
|
|
124
|
+
comment: speaker.participant_name,
|
|
125
|
+
metadata: {
|
|
126
|
+
participant_identity: speaker.participant_identity,
|
|
127
|
+
primary: speaker.primary,
|
|
128
|
+
shadow: speaker.shadow,
|
|
129
|
+
method: speaker.method,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
score(`speaker\u0000${speaker.participant_identity}`, "stt_speaker_disagreement", speaker.disagreement, who);
|
|
133
|
+
score(`speaker-phonetic\u0000${speaker.participant_identity}`, "stt_speaker_phonetic_disagreement", speaker.phonetic_disagreement, who);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return events;
|
|
137
|
+
}
|
|
138
|
+
async _send(callId, events) {
|
|
139
|
+
const auth = Buffer.from(`${this._config.publicKey}:${this._config.secretKey}`).toString("base64");
|
|
140
|
+
for (let i = 0; i < events.length; i += EVENTS_PER_REQUEST) {
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(`${this._config.baseUrl}/api/public/ingestion`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
authorization: `Basic ${auth}`,
|
|
146
|
+
"content-type": "application/json",
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
batch: events.slice(i, i + EVENTS_PER_REQUEST),
|
|
150
|
+
}),
|
|
151
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
152
|
+
});
|
|
153
|
+
// 207 carries per-event results; anything in `errors` was refused.
|
|
154
|
+
const result = (await response.json().catch(() => ({})));
|
|
155
|
+
const refused = Array.isArray(result.errors) ? result.errors.length : 0;
|
|
156
|
+
if (!response.ok || refused > 0) {
|
|
157
|
+
logger.warn("[stt-eval] Langfuse export incomplete", {
|
|
158
|
+
callId,
|
|
159
|
+
status: response.status,
|
|
160
|
+
refused,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
logger.warn("[stt-eval] Langfuse export failed", {
|
|
166
|
+
callId,
|
|
167
|
+
error: error instanceof Error ? error.message : String(error),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** A stable 32-hex id, so a resend upserts the same Langfuse object. */
|
|
174
|
+
function idFor(kind, key) {
|
|
175
|
+
return createHash("sha256")
|
|
176
|
+
.update(`${kind}\u0000${key}`)
|
|
177
|
+
.digest("hex")
|
|
178
|
+
.slice(0, 32);
|
|
179
|
+
}
|
|
180
|
+
function isoFromSeconds(seconds) {
|
|
181
|
+
return new Date(seconds * 1000).toISOString();
|
|
182
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ServerSTTEvalBatchRequest, ServerSTTEvalBatchResponse, ServerSTTEvalCallSummary, ServerSTTEvalOptions, ServerSTTEvalReport } from "../../types/index.js";
|
|
2
|
+
export declare class STTEvalStore {
|
|
3
|
+
private readonly _dir;
|
|
4
|
+
private readonly _maxCalls;
|
|
5
|
+
private readonly _maxSegments;
|
|
6
|
+
private readonly _calls;
|
|
7
|
+
/** Per call: the tail of its accept queue, so batches apply and append in arrival order. */
|
|
8
|
+
private readonly _queues;
|
|
9
|
+
private _ready;
|
|
10
|
+
private readonly _langfuse;
|
|
11
|
+
constructor(options?: ServerSTTEvalOptions);
|
|
12
|
+
accept(batch: ServerSTTEvalBatchRequest): Promise<ServerSTTEvalBatchResponse>;
|
|
13
|
+
list(): Promise<ServerSTTEvalCallSummary[]>;
|
|
14
|
+
report(callId: string, includeSegments?: boolean): Promise<ServerSTTEvalReport | undefined>;
|
|
15
|
+
private _reportOf;
|
|
16
|
+
/** Runs `task` after every earlier task for the same call has settled. */
|
|
17
|
+
private _serial;
|
|
18
|
+
private _create;
|
|
19
|
+
private _apply;
|
|
20
|
+
private _callFor;
|
|
21
|
+
private _evict;
|
|
22
|
+
private _fileFor;
|
|
23
|
+
/** Replays the directory once; every caller awaits the same load. */
|
|
24
|
+
private _load;
|
|
25
|
+
private _loadDir;
|
|
26
|
+
private _replay;
|
|
27
|
+
}
|