@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
162 lines (161 loc) • 6.8 kB
JavaScript
/**
* Decision-model guard for dictionary substitutions.
*
* An alias is usually an ordinary word as well ("part" is how engines hear a
* name that sounds like it), so applying the dictionary blindly rewrites the
* literal word every time it is said. The guard asks a decision model, in one
* request, one question per candidate: did the speaker mean the term, or the
* word as written?
*
* Why a `choice` and not a yes/no: a boolean "is this the name X?" question
* was measured to be leading — it said yes to nearly everything. A choice
* between the term and the literal reading, each with its own rubric,
* discriminates: "my name is part" → the term at 97%, "part of the plan" →
* literal. Answers are matched by position (`candidate__N`), never by the
* alias text, so an alias the wire normalises cannot misroute an answer.
*
* Fail-open, like every other `decide` consumer: with no decision provider,
* on timeout (default 4 s) or on any error the dictionary is applied as is,
* and each record says why in `note`.
*
* @module voice/correction/guard
*/
import { decisionKey } from "../../utils/decisionAnswers.js";
import { TimeoutError, withTimeout } from "../../utils/async/withTimeout.js";
import { logger } from "../../utils/logger.js";
/** Namespace for the per-candidate questions. */
const CANDIDATE_NAMESPACE = "candidate";
/** Default guard timeout; on expiry the dictionary is applied as is. */
export const DEFAULT_DECIDE_TIMEOUT_MS = 4000;
/** Sent as `context` when the caller gave none. */
const DEFAULT_CONTEXT = "Speech transcript; names and product terms are often misheard.";
/**
* Upper bound on the transcript sent as state. Decision providers declare
* state windows (some well under 1,000 tokens); an over-limit request is
* refused before any network call and the guard fails open, so a long
* transcript costs nothing but the decision — still, the words around a
* candidate carry the signal, and a bounded state keeps the request cheap.
*/
const MAX_STATE_CHARS = 4000;
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
const finiteNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
function candidateQuestion(candidate) {
const { entry, heard } = candidate;
const meaning = entry.meaning?.trim();
return {
type: "choice",
instructions: `The speech recognizer wrote "${heard}". Which did the speaker mean?`,
criteria: {
term: `the term "${entry.term.trim()}"${meaning ? `: ${meaning}` : ""}`,
literal: `"${heard}" as an ordinary word or other name, meant literally`,
},
};
}
/**
* Read one choice answer without trusting its shape: the deps type hands the
* answer map over as `unknown`, and a provider may omit an id or answer with
* a different type. Returns undefined when there is no usable term/literal
* verdict.
*/
function readTermOrLiteral(answer) {
if (!isRecord(answer) ||
(answer.type !== undefined && answer.type !== "choice")) {
return undefined;
}
const probabilities = isRecord(answer.probabilities)
? answer.probabilities
: {};
const pTerm = finiteNumber(probabilities.term);
const pLiteral = finiteNumber(probabilities.literal);
let choice;
if (answer.choice === "term" || answer.choice === "literal") {
choice = answer.choice;
}
else if (pTerm !== undefined || pLiteral !== undefined) {
// No winner named, but a distribution: take its argmax.
const term = pTerm ?? 1 - (pLiteral ?? 0);
const literal = pLiteral ?? 1 - term;
choice = term >= literal ? "term" : "literal";
}
if (!choice) {
return undefined;
}
const probability = choice === "term"
? (pTerm ?? (pLiteral !== undefined ? 1 - pLiteral : undefined))
: (pLiteral ?? (pTerm !== undefined ? 1 - pTerm : undefined));
return { choice, probability, confidence: finiteNumber(answer.confidence) };
}
/**
* Judge every candidate in one decision request. Returns one record per
* candidate, in candidate order; a candidate the guard could not judge is
* recorded as `"term"` with a `note` saying why, so `applyDictionary` applies
* it exactly as it would without a guard.
*/
export async function decideCandidates(text, candidates, decide, options) {
if (candidates.length === 0) {
return [];
}
const asIs = (note) => candidates.map((candidate) => ({
heard: candidate.heard,
term: candidate.entry.term.trim(),
choice: "term",
note,
}));
if (typeof decide !== "function") {
return asIs("no decision provider");
}
const questions = {};
candidates.forEach((candidate, index) => {
questions[decisionKey(CANDIDATE_NAMESPACE, index)] =
candidateQuestion(candidate);
});
const timeoutMs = options?.timeoutMs && options.timeoutMs > 0
? options.timeoutMs
: DEFAULT_DECIDE_TIMEOUT_MS;
let result;
try {
// The provider gets the timeout too, but the race is what guarantees it:
// a decide implementation that ignores `timeoutMs` must not stall the
// transcript.
result = await withTimeout(decide({
state: {
transcript: text.slice(0, MAX_STATE_CHARS),
context: options?.context?.trim() || DEFAULT_CONTEXT,
},
questions,
timeoutMs,
}), timeoutMs);
}
catch (error) {
const timedOut = error instanceof TimeoutError;
logger.debug("[transcribe] decision guard failed open", {
candidates: candidates.length,
timedOut,
});
return asIs(timedOut ? "decision timed out" : "decision unavailable");
}
// `tryDecide` returns null both when no decision provider is configured and
// when the call failed; the two are indistinguishable from here.
if (!result || !isRecord(result.answers)) {
return asIs("decision unavailable");
}
const answers = result.answers;
return candidates.map((candidate, index) => {
const verdict = readTermOrLiteral(answers[decisionKey(CANDIDATE_NAMESPACE, index)]);
const record = {
heard: candidate.heard,
term: candidate.entry.term.trim(),
choice: verdict?.choice ?? "term",
};
if (verdict?.probability !== undefined) {
record.probability = verdict.probability;
}
if (verdict?.confidence !== undefined) {
record.confidence = verdict.confidence;
}
if (!verdict) {
record.note = "no answer · applied as is";
}
return record;
});
}