UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

212 lines (211 loc) • 8.75 kB
/** * Transcript correction: dictionary candidates → decision-model guard → LLM * rewrite. * * Every layer fails open and none may lose words. With no decision provider, * on a guard timeout or error, the dictionary is applied as is; on a rewrite * timeout or error, or a rewrite that drops too many words, the * dictionary-applied text is kept. The result's `steps` say which of those * happened, so a silently degraded path is still visible to the caller. * * @module voice/correction */ import { applyDictionary, dictionaryBlock, dictionaryVocabulary, findDictionaryCandidates, } from "./dictionary.js"; import { DEFAULT_DECIDE_TIMEOUT_MS, decideCandidates } from "./guard.js"; import { buildRewriteSystemPrompt, buildRewriteUserPrompt, rewriteLines, rewriteTranscript, } from "./rewrite.js"; export { applyDictionary, buildRewriteSystemPrompt, buildRewriteUserPrompt, decideCandidates, dictionaryBlock, dictionaryVocabulary, findDictionaryCandidates, rewriteLines, rewriteTranscript, }; /** Correction is on when asked for, or by default when a dictionary or a rewrite is configured. */ export function isCorrectionEnabled(dictionary, options) { if (options?.enabled !== undefined) { return options.enabled; } // A `correction` object of any kind is a request for correction (its // transliterate/punctuate settings would be dead otherwise); only // `rewrite: false` without a dictionary leaves nothing to do. return (Boolean(dictionary?.length) || (options !== undefined && options.rewrite !== false)); } /** Run the guard (or skip it) and apply the dictionary to `text`. */ async function guardAndApply(text, dictionary, options, deps) { const candidates = findDictionaryCandidates(text, dictionary); if (candidates.length === 0) { return { text, decisions: [] }; } const started = Date.now(); const decisions = options?.guard === "none" ? candidates.map((candidate) => ({ heard: candidate.heard, term: candidate.entry.term.trim(), choice: "term", note: "guard off", })) : await decideCandidates(text, candidates, deps.decide, { context: options?.context, timeoutMs: options?.decideTimeoutMs ?? DEFAULT_DECIDE_TIMEOUT_MS, }); const ms = Date.now() - started; const applied = decisions.filter((d) => d.choice === "term").length; return { text: applyDictionary(text, candidates, decisions), decisions, step: `dictionary ${applied}/${candidates.length} (${ms} ms)`, decideMs: options?.guard === "none" ? undefined : ms, }; } /** * Correct one transcript (a whole short recording, or one final utterance * when streaming). Never throws on a layer's failure and never returns empty * text when either transcript had words. * * When the primary engine reported a low language score * (`languageDetected === false`) and a second opinion exists, the second * opinion leads: an Indic engine on English speech produces confident-looking * nonsense, and the English-oriented engine's text is the better base. The * engine text then goes to the rewrite as the secondary transcript, so * Indian-language words it did catch are not lost. */ export async function correctTranscript(input, deps) { const started = Date.now(); const { dictionary, options } = input; const steps = []; const timings = {}; const engineText = input.text ?? ""; const second = input.secondOpinion?.trim() ? input.secondOpinion : undefined; let lead = engineText; let secondary = second; let secondLed = false; if (second && input.languageDetected === false) { lead = second; secondary = engineText.trim() ? engineText : undefined; secondLed = true; steps.push("second opinion led (primary unsure)"); } else if (second && !engineText.trim()) { lead = second; secondary = undefined; secondLed = true; steps.push("second opinion led (primary empty)"); } if (!isCorrectionEnabled(dictionary, options) || !lead.trim()) { return { text: lead, decisions: [], steps, timings: { correctionMs: Date.now() - started }, }; } const guarded = await guardAndApply(lead, dictionary, options, deps); let text = guarded.text; if (guarded.step) { steps.push(guarded.step); } if (guarded.decideMs !== undefined) { timings.decideMs = guarded.decideMs; } if (options?.rewrite !== false) { const outcome = await rewriteTranscript({ primary: text, secondOpinion: secondary, dictionary, options, labels: secondLed ? { primary: "second engine", secondary: "primary engine, unsure of the language", } : undefined, literal: guarded.decisions .filter((d) => d.choice === "literal") .map((d) => d.heard), onPartial: input.onPartial, }, deps.rewrite); timings.rewriteMs = outcome.ms; switch (outcome.status) { case "ok": text = outcome.text; steps.push(`rewrite ${outcome.ms} ms`); break; case "timeout": steps.push("rewrite timed out · kept text"); break; case "error": steps.push(`rewrite failed (${outcome.error ?? "error"}) · kept text`); break; case "empty": steps.push("rewrite returned nothing · kept text"); break; case "dropped": steps.push(`rewrite dropped ${Math.round((outcome.dropRatio ?? 0) * 100)}% of words · kept text`); break; } } timings.correctionMs = Date.now() - started; return { text: text.trim() ? text : lead, decisions: guarded.decisions, steps, timings, }; } /** * Correct a long transcript line by line (file transcription, diarized * turns). With the rewrite on, lines go to the model in numbered batches of * 25 and the dictionary travels in the prompt — the model sees each alias in * its sentence, which is the context the guard would otherwise supply. With * `rewrite: false`, each line with candidates gets its own guard call (up to * four at a time) and the dictionary is applied per line. The result always * has exactly as many lines as the input. */ export async function correctTranscriptLines(lines, dictionary, options, deps) { const started = Date.now(); if (!isCorrectionEnabled(dictionary, options) || lines.length === 0) { return { lines: [...lines], decisions: [], steps: [], timings: {} }; } if (options?.rewrite !== false) { const outcome = await rewriteLines(lines, deps.rewrite, { dictionary, correction: options, }); const steps = [ `rewrite ${outcome.batchesOk}/${outcome.batches} batches (${outcome.ms} ms)`, ]; if (outcome.batchesOk < outcome.batches) { steps.push(`${outcome.batches - outcome.batchesOk} batch(es) failed · kept their lines`); } if (outcome.linesKept > 0) { steps.push(`${outcome.linesKept} line(s) dropped words · kept their text`); } return { lines: outcome.lines, decisions: [], steps, timings: { rewriteMs: outcome.ms, correctionMs: Date.now() - started }, }; } const out = [...lines]; // Per line, so the flattened decisions follow line order whatever finishes first. const perLine = lines.map(() => []); let candidatesSeen = 0; let applied = 0; let next = 0; const worker = async () => { while (next < lines.length) { const index = next++; const guarded = await guardAndApply(lines[index], dictionary, options, deps); out[index] = guarded.text; perLine[index] = guarded.decisions; candidatesSeen += guarded.decisions.length; applied += guarded.decisions.filter((d) => d.choice === "term").length; } }; await Promise.all(Array.from({ length: Math.min(4, lines.length) }, () => worker())); const ms = Date.now() - started; return { lines: out, decisions: perLine.flat(), steps: candidatesSeen ? [`dictionary ${applied}/${candidatesSeen} (${ms} ms)`] : [], timings: { decideMs: ms, correctionMs: ms }, }; }