@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +6 -2
  2. package/dist/auth/sessionManager.js +8 -3
  3. package/dist/browser/neurolink.min.js +480 -460
  4. package/dist/cli/commands/serve.js +11 -1
  5. package/dist/cli/commands/transcribe.js +39 -3
  6. package/dist/constants/langfuse.d.ts +2 -0
  7. package/dist/constants/langfuse.js +2 -0
  8. package/dist/core/redisConversationMemoryManager.js +6 -16
  9. package/dist/factories/sttDescriptors.js +7 -0
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.js +4 -2
  12. package/dist/neurolink.js +33 -1
  13. package/dist/observability/exporters/langfuseExporter.d.ts +4 -0
  14. package/dist/observability/exporters/langfuseExporter.js +29 -8
  15. package/dist/observability/otlpEncoding.js +2 -2
  16. package/dist/server/abstract/baseServerAdapter.js +2 -0
  17. package/dist/server/adapters/expressAdapter.js +1 -1
  18. package/dist/server/adapters/fastifyAdapter.js +4 -1
  19. package/dist/server/adapters/honoAdapter.js +1 -1
  20. package/dist/server/adapters/koaAdapter.js +1 -1
  21. package/dist/server/routes/transcribeRoutes.d.ts +0 -8
  22. package/dist/server/routes/transcribeRoutes.js +130 -3
  23. package/dist/server/utils/validation.d.ts +409 -15
  24. package/dist/server/utils/validation.js +106 -4
  25. package/dist/server/websocket/frameQueue.d.ts +16 -0
  26. package/dist/server/websocket/frameQueue.js +55 -0
  27. package/dist/server/websocket/realtimeTranscription.d.ts +48 -0
  28. package/dist/server/websocket/realtimeTranscription.js +603 -0
  29. package/dist/server/websocket/transcribeWebSocket.d.ts +17 -1
  30. package/dist/server/websocket/transcribeWebSocket.js +263 -64
  31. package/dist/services/server/ai/observability/langfuseSetup.js +6 -1
  32. package/dist/telemetry/attributes.d.ts +1 -1
  33. package/dist/telemetry/attributes.js +84 -8
  34. package/dist/types/cli.d.ts +6 -0
  35. package/dist/types/exporter.d.ts +3 -1
  36. package/dist/types/server.d.ts +304 -2
  37. package/dist/types/stt.d.ts +38 -3
  38. package/dist/types/transcribe.d.ts +401 -5
  39. package/dist/utils/redis.d.ts +17 -0
  40. package/dist/utils/redis.js +31 -0
  41. package/dist/voice/audioLevel.d.ts +14 -0
  42. package/dist/voice/audioLevel.js +60 -0
  43. package/dist/voice/codeMix.d.ts +40 -0
  44. package/dist/voice/codeMix.js +282 -0
  45. package/dist/voice/correction/align.d.ts +12 -0
  46. package/dist/voice/correction/align.js +48 -0
  47. package/dist/voice/correction/guards.d.ts +39 -0
  48. package/dist/voice/correction/guards.js +122 -0
  49. package/dist/voice/correction/index.d.ts +25 -3
  50. package/dist/voice/correction/index.js +132 -4
  51. package/dist/voice/correction/rewrite.d.ts +64 -3
  52. package/dist/voice/correction/rewrite.js +295 -25
  53. package/dist/voice/eval/sttEvalLangfuse.d.ts +14 -0
  54. package/dist/voice/eval/sttEvalLangfuse.js +182 -0
  55. package/dist/voice/eval/sttEvalStore.d.ts +27 -0
  56. package/dist/voice/eval/sttEvalStore.js +397 -0
  57. package/dist/voice/index.js +3 -0
  58. package/dist/voice/languages.d.ts +34 -0
  59. package/dist/voice/languages.js +290 -0
  60. package/dist/voice/modelAssets.d.ts +28 -0
  61. package/dist/voice/modelAssets.js +113 -0
  62. package/dist/voice/output/render.d.ts +32 -0
  63. package/dist/voice/output/render.js +368 -0
  64. package/dist/voice/providers/OpenAISTT.d.ts +11 -1
  65. package/dist/voice/providers/OpenAISTT.js +57 -6
  66. package/dist/voice/session/transcriptionSession.d.ts +55 -0
  67. package/dist/voice/session/transcriptionSession.js +225 -0
  68. package/dist/voice/streaming/chunkedStreamAdapter.d.ts +7 -0
  69. package/dist/voice/streaming/chunkedStreamAdapter.js +711 -28
  70. package/dist/voice/streaming/endpointer.d.ts +50 -17
  71. package/dist/voice/streaming/endpointer.js +115 -19
  72. package/dist/voice/transcribe.d.ts +18 -1
  73. package/dist/voice/transcribe.js +459 -18
  74. package/dist/voice/transcribeStream.d.ts +18 -1
  75. package/dist/voice/transcribeStream.js +413 -16
  76. package/dist/voice/vad/assets.d.ts +36 -0
  77. package/dist/voice/vad/assets.js +142 -0
  78. package/dist/voice/vad/frameScorer.d.ts +28 -0
  79. package/dist/voice/vad/frameScorer.js +90 -0
  80. package/dist/voice/vad/sileroVad.d.ts +43 -0
  81. package/dist/voice/vad/sileroVad.js +178 -0
  82. package/dist/voice/whistle/assets.d.ts +3 -4
  83. package/dist/voice/whistle/assets.js +11 -95
  84. package/docs-site/static/search-index.json +21 -6
  85. package/models/silero-vad/README.md +19 -0
  86. package/models/silero-vad/silero_vad.onnx +0 -0
  87. 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
- /** Default per-call rewrite timeout; on expiry the input text is kept. */
23
- export const DEFAULT_REWRITE_TIMEOUT_MS = 12000;
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
- if (lines) {
86
- rules.push("- Also fix near-homophones that make no sense in context when the intended word is obvious.");
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
- ? "- Add natural punctuation and capitalization; write numbers as digits; keep fillers."
90
- : "- Do not add or change punctuation or capitalization; keep fillers.");
91
- rules.push(lines
92
- ? "- Keep every word: never summarize, add, drop, reorder or merge. Keep any speaker label at the " +
93
- "start of a line unchanged. A line that needs no change is returned verbatim."
94
- : "- Keep every word: never summarize, add, drop or reorder. With two transcripts, take " +
95
- "Indian-language words from the one that has them in an Indian language and English words " +
96
- "from the one that spells them in English; elsewhere prefer Transcript 1.");
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
- function resolveDropRatio(value) {
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
- : DEFAULT_MAX_DROP_RATIO;
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 === false ? undefined : 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?.transliterate,
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 === false
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?.transliterate,
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
+ }