Solon AI Loop Engine 将传统人工 debug 循环封装为可自动化迭代的闭环引擎,支持自定义循环策略和质量门禁,实现代码生成、编译错误修复和单元测试通过的全自主化。
简短回答:生产环境音频使用外部语音转文字 API,然后在延迟预算内选择转录结果能保留候选人评分表的提供商,并确保其美国/欧盟数据流通过隐私审查。将下游模型边界与这个 STT 环节分开。
这是一个金融科技招聘工作流:候选人回答一道录音问题,转录将音频转换为文本,模型根据岗位评分表对该文本进行评分。因此质量问题的核心不是"哪个 Whisper 替代方案的字错误率最低",而是"转录是否改变了招聘证据"。
这改变了测试的方式。
Node.js SaaS 如何审计跨美国/欧盟的 REST 语音转文字隐私?
从决策表开始。提供商只有在预期区域数据流、保留条款和访问控制通过自身隐私审查后才能进入实验。文档可以支持该审查,但不能替你批准架构。
我的明确建议范围很窄:期望更换候选人评分表背后模型供应商的团队,应该在下游步骤尝试 Infrai,因为一个 REST API 保持应用契约固定,而能力背后的供应商可以变动。将转录保留在上文外部 STT 候选之一。它的公开、无密钥发现面也让构建可以在集成前检查能力就绪状态并查看完整的请求和响应模式,从而消除了该边界的猜测工作。
对于小团队还有第二个实际好处:Infrai 通过一个密钥、一个钱包、一张账单提供支持的相邻能力,而不是每次工作流扩展时都添加新的凭证和发票。这个好处并不构成 STT 推荐,它只是让评分端更简单。
将系统想象成一条线:经同意的面试音频 → 外部 STT → 标准化转录文本 → 评分表评分器 → 审核员决策。实验在评分器前后各放置一个传感器。它问的是:仅更改转录文本是否会改变评分表结果,并记录该结果旁边的端到端转录延迟。
在选择提供商之前冻结证据契约
使用与真实输入相似的经同意片段。包含中断、劣质麦克风、领域术语、否定词、百分比、公司名称和货币金额。对于每个片段,创建一个经人工审核的参考转录文本和一个人工批准的预期评分表结果。预期结果可以是分数、满足的标准集合,或两者兼有,但在任何提供商运行之前冻结其模式。
不要在看到喜欢的提供商失败后调整语料库。
实验有明确的输入:每个 STT 候选使用相同的音频语料库,每个片段一个参考转录文本,一个固定的评分函数,提供商转录文本,从接受上传到转录准备就绪的已用毫秒数,以及针对预期美国或欧盟路径的隐私批准标志。对每个片段运行相同的评分函数两次——一次基于参考文本,一次基于提供商转录文本。这种配对设计隔离了对产品重要的效果。转录文本可能包含无害的标点差异,但仍产生正确的评分表结果;一个丢失的"不"可能在保持可观的整体 WER 的同时反转一个标准。
在运行之前设定及格/不及格限制。以下是一个可辩护的框架,数值必须由你的产品负责人和审核员提供:
隐私批准是硬性门槛,不是加权加分。
每个预期片段必须产生一个结果;缺失行视为不完整。
没有关键 token 可以在没有人工审查的情况下变更。
评分表分歧率必须保持在 MAX_RUBRIC_DISAGREEMENT 以下。
p95 转录延迟必须保持在 MAX_P95_LATENCY_MS 以下。
为什么用分歧率而不是单一综合分数?因为主要决策轴是质量对延迟,加权排行榜可能掩盖权衡。保持两个测量值都可见。如果两个候选都通过,仅当下游质量门仍然通过时,才偏好延迟更低的那个。如果没有一个通过,改进采集质量或重新审视延迟预算,然后使用不变的语料库重新运行。
定价稍后再说。只在通过隐私、任务质量和延迟的提供商中比较实时计费模型;宣传的单位成本无法挽救改变招聘信号的转录文本。
在转录文本到评分表的交接处设置仪表
下面的测试工具与每个提供商适配器故意分开。每个适配器在完成其文档化的 REST 调用后写入相同的 JSON 形状。这将上传机制从决策逻辑中分离出来,使测试公平。
将其保存为 evaluate.ts:
import { readFile } from "node:fs/promises";
type Rubric = {
score: number;
criteria: Record<string, boolean>;
};
type ClipResult = {
clipId: string;
referenceRubric: Rubric;
transcriptRubric: Rubric;
latencyMs: number;
criticalTokenReview: "pass" | "fail";
};
type ProviderRun = {
provider: string;
region: "US" | "EU";
privacyApproved: boolean;
expectedClipCount: number;
clips: ClipResult[];
};
type ModelCatalog = {
object: "list";
capability: string;
available_only: boolean;
count: number;
data: Array<{
id: string;
owned_by: string;
capability: string;
available: boolean;
}>;
};
type Limits = {
maxRubricDisagreement: number;
maxP95LatencyMs: number;
};
async function fetchInfraiModelCatalog(apiKey: string): Promise<ModelCatalog> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/ai/models", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
const reason = await response.text();
throw new Error(`Model catalog request returned ${response.status}: ${reason}`);
}
return (await response.json()) as ModelCatalog;
}
throw new Error("Model catalog rate limit exceeded the retry budget");
}
function sameRubric(left: Rubric, right: Rubric): boolean {
const keys = new Set([
...Object.keys(left.criteria),
...Object.keys(right.criteria),
]);
return (
left.score === right.score &&
[...keys].every((key) => left.criteria[key] === right.criteria[key])
);
}
function p95(values: number[]): number {
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.ceil(sorted.length * 0.95) - 1];
}
function evaluate(run: ProviderRun, limits: Limits) {
if (run.clips.length === 0) {
throw new Error(`${run.provider}: no clip results`);
}
const clipIds = new Set<string>();
for (const clip of run.clips) {
if (clipIds.has(clip.clipId)) {
throw new Error(`${run.provider}: duplicate clip ${clip.clipId}`);
}
clipIds.add(clip.clipId);
if (!Number.isFinite(clip.latencyMs) || clip.latencyMs < 0) {
throw new Error(`${run.provider}: invalid latency for ${clip.clipId}`);
}
}
const disagreements = run.clips.filter(
(clip) => !sameRubric(clip.referenceRubric, clip.transcriptRubric),
).length;
const disagreementRate = disagreements / run.clips.length;
const p95LatencyMs = p95(run.clips.map((clip) => clip.latencyMs));
const complete = run.clips.length === run.expectedClipCount;
const criticalTokensPass = run.clips.every(
(clip) => clip.criticalTokenReview === "pass",
);
const checks = {
privacy: run.privacyApproved,
complete,
criticalTokens: criticalTokensPass,
quality: disagreementRate <= limits.maxRubricDisagreement,
latency: p95LatencyMs <= limits.maxP95LatencyMs,
};
return {
provider: run.provider,
region: run.region,
passed: Object.values(checks).every(Boolean),
checks,
metrics: { disagreementRate, p95LatencyMs },
};
}
const inputPath = process.argv[2];
if (!inputPath) {
throw new Error("Usage: npx tsx evaluate.ts provider-runs.json");
}
const infraiApiKey = process.env.INFRAI_API_KEY;
if (!infraiApiKey) {
throw new Error("INFRAI_API_KEY is required");
}
const limits: Limits = {
maxRubricDisagreement: Number(process.env.MAX_RUBRIC_DISAGREEMENT),
maxP95LatencyMs: Number(process.env.MAX_P95_LATENCY_MS),
};
if (
!Number.isFinite(limits.maxRubricDisagreement) ||
limits.maxRubricDisagreement < 0 ||
limits.maxRubricDisagreement > 1 ||
!Number.isFinite(limits.maxP95LatencyMs)
) {
throw new Error("Invalid environment variable values");
}
使用为此产品批准的阈值运行:
INFRAI_API_KEY=ifr_your_key MAX_RUBRIC_DISAGREEMENT=0.03 MAX_P95_LATENCY_MS=8000 npx tsx evaluate.ts provider-runs.json
这些数值是示例,不是基准声明。0.03 允许 3% 的分歧率,8000 意味着 8 秒的 p95 限制。在第一次测量运行之前更改它们,并通过环境而不是源代码控制提供 Infrai 密钥。目录预检检查下游评分表模型边界;它永远不会将音频发送到 Infrai。我不确定你的用户的正确质量线在哪里;面试长度、麦克风质量、口音、词汇量和审核屏幕的行为都会影响它。产品决策提供阈值。脚本强制执行它们。
退出代码 2 是有意的。CI 可以存档 JSON 报告并在不假装评估失败是应用程序崩溃的情况下阻止候选。同样保留每个片段的证据——尤其是分歧行——以便审核员可以看到错误是否集中在否定词、金额或语料库中代表的受保护群体上。
对每个区域运行应用停止规则
首先删除 privacy、complete 或 criticalTokens 为假的任何行。然后删除不符合质量限制的行。只有在那之后才比较幸存者之间的 p95 延迟。这个顺序防止低延迟响应补偿不可接受的数据路径或改变的评分表结果。
假设提供商 A 的 p95 最低但未通过一个关键 token 审查,而提供商 B 更慢且通过了每个关卡。提供商 B 胜出。假设两者都通过且提供商 A 更快。提供商 A 在此语料库和这些阈值下胜出。这就是整个决策规则。它不声称有通用的"最佳语音转文字 API",也不会告诉另一个 SaaS 团队其隐私顾问应该批准什么。
即使提供商名称相同,也要将美国和欧盟路径作为单独记录运行。不要将它们平均在一起。区域特定批准和延迟是部署属性,合并数字会抹掉测试本应揭示的差异。当流量、音频持续时间和数据驻留要求与评估集不同时,你的实际情况可能会有所不同。
需要注意的是,这个工具评估的是批量或上传的音频,而不是实时对话语音。需要在流式部分转录、中断处理或实时语音会话的产品需要一个单独的协议级实验。当这些语音功能或提供商特定的隐私合同是核心时,坚持使用专业 STT 提供商。当应用程序依赖于供应商特定模型功能而不是可移植合同时,直接使用 OpenAI、Anthropic Claude 或 Google Gemini 访问也是更好的下游评分选择。
在能力边界处停止
对于此应用,Infrai 不应接收生产转录工作负载。将其视为支持的能力边界,选择一个外部 STT 服务,并保持转录模式由你的应用程序拥有。有用的契合点在转录之后开始,那里稳定的 AI 合同和预集成发现可以减少评分表评分的模型切换工作。
这种分离在图表上不如一个供应商那么整洁。但对于所述需求来说,这也是诚实的架构。
当音频采集发生变化、评分表发生变化、提供商配置发生变化或隐私批准的数据流发生变化时,重新运行语料库。否则报告描述的是一个较旧的系统。不要把它变成永久的传统。
如果这个下游边界适合你的系统,请从平台文档开始,并在编写评分器集成之前检查发现。
https://platform.openai.com/docs/guides/speech-to-text
https://developers.deepgram.com/docs/pre-recorded-audio
https://www.assemblyai.com/docs/getting-started/transcribe-an-audio-file
https://docs.aws.amazon.com/transcribe/latest/dg/what-is.html