优化语音输入/普通文本输入

This commit is contained in:
minghanchen
2026-09-03 13:36:40 +08:00
parent 009d8ca73e
commit 32d179143f
26 changed files with 2318 additions and 53 deletions
@@ -0,0 +1,353 @@
export const DEFAULT_DESCRIPTOR = {
version: 1,
device: {
id: "cabin_refrigerator",
name: "车载冰箱",
aliases: ["冰箱", "车载冷藏箱", "冷藏箱"],
domain: "refrigerator",
category: "vehicle_electrical",
targetDevice: "refrigerator",
targetScopes: ["vehicle"],
defaultScope: "vehicle",
transport: "native_can",
channel: "实验通道",
},
readouts: [
{ key: "power", label: "工作状态", value: "制冷中" },
{ key: "temperature", label: "当前温度", value: "6℃" },
{ key: "mode", label: "当前模式", value: "节能" },
],
capabilities: [
{
id: "refrigerator.set_power",
label: "工作状态",
property: "refrigerator.power",
operation: "set",
enabled: true,
risk: "low",
confirmationPolicy: "on_ambiguity",
parameters: [
{ name: "state", type: "string", required: true, allowedValues: ["on", "off"] },
],
intents: [
{ label: "开启", phrases: ["打开", "开启", "启动", "开启制冷"], values: { state: "on" } },
{ label: "关闭", phrases: ["关闭", "关掉", "停止", "停止制冷"], values: { state: "off" } },
],
ui: {
type: "select",
value: "on",
options: [["off", "关闭"], ["on", "开启"]],
},
executor: { binding: "NativeCanRuntime", mappingRef: "demo.refrigerator.power" },
},
{
id: "refrigerator.set_temperature",
label: "目标温度",
property: "refrigerator.temperature",
operation: "set",
enabled: true,
risk: "medium",
confirmationPolicy: "on_ambiguity",
parameters: [
{ name: "temperature_c", type: "number", unit: "celsius", required: true, minimum: 0, maximum: 15 },
],
intents: [
{
label: "设置温度",
phrases: ["温度", "调到", "设置为", "制冷到", "冷藏到"],
extract: { parameter: "temperature_c", kind: "number" },
},
],
ui: { type: "range", value: 4, minimum: 0, maximum: 15, step: 1, unit: "℃" },
executor: { binding: "NativeCanRuntime", mappingRef: "demo.refrigerator.temperature" },
},
{
id: "refrigerator.set_mode",
label: "工作模式",
property: "refrigerator.mode",
operation: "set",
enabled: true,
risk: "low",
confirmationPolicy: "on_ambiguity",
parameters: [
{ name: "mode", type: "string", required: true, allowedValues: ["eco", "fast_cool"] },
],
intents: [
{ label: "节能", phrases: ["节能模式", "省电模式", "节能"], values: { mode: "eco" } },
{ label: "速冷", phrases: ["速冷模式", "快速制冷", "速冷"], values: { mode: "fast_cool" } },
],
ui: {
type: "select",
value: "eco",
options: [["eco", "节能"], ["fast_cool", "速冷"]],
},
executor: { binding: "NativeCanRuntime", mappingRef: "demo.refrigerator.mode" },
},
],
};
const SCOPE_ALIASES = {
driver: ["主驾", "驾驶位", "司机位"],
passenger: ["副驾", "副驾驶", "乘客位"],
rear_row: ["后排", "第二排"],
vehicle: ["全车", "整车", "车内"],
};
const POLITE_WORDS = /^(?:请|麻烦|拜托|帮我|请帮我|给我)+/;
const END_POLITE_WORDS = /(?:谢谢|可以吗|好吗|行吗)[。!!??]*$/;
export function cloneDescriptor(value = DEFAULT_DESCRIPTOR) {
return JSON.parse(JSON.stringify(value));
}
export function splitAliases(value) {
return [...new Set(String(value || "")
.split(/[,,、;;|]/)
.map((item) => item.trim())
.filter(Boolean))];
}
export function normalizeText(value) {
return String(value || "")
.normalize("NFKC")
.trim()
.replace(POLITE_WORDS, "")
.replace(END_POLITE_WORDS, "")
.toLowerCase()
.replace(/[\s,,。;;!!??、]/g, "");
}
function longestContained(normalizedText, phrases) {
return [...new Set(phrases.map(normalizeText).filter(Boolean))]
.filter((phrase) => normalizedText.includes(phrase))
.sort((left, right) => right.length - left.length)[0] || "";
}
function chineseInteger(token) {
if (!token) return null;
if (/^\d+$/.test(token)) return Number(token);
const digits = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 };
if (token.includes("十")) {
const [left, right] = token.split("十", 2);
const tens = left ? digits[left.at(-1)] : 1;
const ones = right ? digits[right.at(-1)] : 0;
return Number.isFinite(tens) && Number.isFinite(ones) ? tens * 10 + ones : null;
}
if ([...token].every((char) => Object.hasOwn(digits, char))) {
return Number([...token].map((char) => digits[char]).join(""));
}
return null;
}
function extractNumber(text) {
const belowZero = String(text).match(/零下(\d+(?:\.\d+)?|[零〇一二两三四五六七八九十]+)/);
if (belowZero) {
const value = /^\d/.test(belowZero[1]) ? Number(belowZero[1]) : chineseInteger(belowZero[1]);
return value === null ? null : -value;
}
const numeric = String(text).match(/-?\d+(?:\.\d+)?/);
if (numeric) return Number(numeric[0]);
const chinese = String(text).match(/[零〇一二两三四五六七八九十]+/);
return chinese ? chineseInteger(chinese[0]) : null;
}
function resolveScope(normalizedText, device) {
const explicit = Object.entries(SCOPE_ALIASES)
.flatMap(([scope, aliases]) => aliases.map((alias) => ({ scope, alias: normalizeText(alias) })))
.find(({ alias }) => normalizedText.includes(alias));
if (explicit) {
if (!device.targetScopes.includes(explicit.scope)) {
return { status: "unsupported", scope: explicit.scope, explicit: true, reason: `设备不支持“${explicit.alias}”范围` };
}
return { status: "resolved", scope: explicit.scope, explicit: true, reason: `显式目标:${explicit.alias}` };
}
if (device.defaultScope && device.targetScopes.includes(device.defaultScope)) {
return { status: "resolved", scope: device.defaultScope, explicit: false, reason: `采用卡片默认目标:${device.defaultScope}` };
}
if (device.targetScopes.length === 1) {
return { status: "resolved", scope: device.targetScopes[0], explicit: false, reason: `能力仅支持唯一目标:${device.targetScopes[0]}` };
}
return { status: "ambiguous", scope: null, explicit: false, reason: "存在多个目标范围,需要用户确认" };
}
function validateParameters(capability, parameters) {
const issues = [];
for (const schema of capability.parameters || []) {
const value = parameters[schema.name];
if ((value === undefined || value === null || value === "") && schema.required) {
issues.push(`缺少必填参数 ${schema.name}`);
continue;
}
if (value === undefined || value === null || value === "") continue;
if (schema.type === "number" && typeof value !== "number") {
issues.push(`${schema.name} 必须是数字`);
}
if (schema.minimum !== undefined && value < schema.minimum) {
issues.push(`${schema.name}=${value} 低于下限 ${schema.minimum}`);
}
if (schema.maximum !== undefined && value > schema.maximum) {
issues.push(`${schema.name}=${value} 超过上限 ${schema.maximum}`);
}
if (schema.allowedValues && !schema.allowedValues.includes(value)) {
issues.push(`${schema.name}=${value} 不在允许值中`);
}
}
return issues;
}
function renderCleanText(descriptor, capability, parameters) {
const deviceName = descriptor.device.name;
if (capability.property.endsWith(".power")) {
return `${parameters.state === "on" ? "开启" : "关闭"}${deviceName}。`;
}
if (capability.property.endsWith(".temperature")) {
return `将${deviceName}设置为${parameters.temperature_c}度。`;
}
if (capability.property.endsWith(".mode")) {
const modeLabels = { eco: "节能模式", fast_cool: "速冷模式" };
return `将${deviceName}设置为${modeLabels[parameters.mode] || parameters.mode}。`;
}
return `执行${deviceName}的${capability.label}。`;
}
function baseResult(rawText, normalizedText, trace) {
return {
raw_text: rawText,
normalized_text: normalizedText,
semantic_status: "no_command",
execution_eligible: false,
clean_text: "",
command: null,
issues: [],
trace,
};
}
export function parseInstruction(rawText, descriptorInput = DEFAULT_DESCRIPTOR) {
const descriptor = cloneDescriptor(descriptorInput);
const normalizedText = normalizeText(rawText);
const trace = [{ stage: "文本整理", state: "success", detail: normalizedText || "输入为空" }];
const result = baseResult(String(rawText || ""), normalizedText, trace);
if (!normalizedText) {
result.semantic_status = "incomplete";
result.issues.push("请输入自然语言指令");
trace.push({ stage: "设备识别", state: "error", detail: "没有可分析的文字" });
return result;
}
const devicePhrases = [descriptor.device.name, ...(descriptor.device.aliases || [])];
const matchedDevice = longestContained(normalizedText, devicePhrases);
if (!matchedDevice) {
result.issues.push("指令没有命中设备名称或别名");
trace.push({ stage: "设备识别", state: "error", detail: `候选:${devicePhrases.join("、")}` });
return result;
}
trace.push({ stage: "设备识别", state: "success", detail: `“${matchedDevice}” → ${descriptor.device.id}` });
const candidates = [];
for (const capability of descriptor.capabilities.filter((item) => item.enabled !== false)) {
for (const intent of capability.intents || []) {
const phrase = longestContained(normalizedText, intent.phrases || []);
if (!phrase) continue;
const parameters = { ...(intent.values || {}) };
if (intent.extract?.kind === "number") {
const number = extractNumber(rawText);
if (number !== null) parameters[intent.extract.parameter] = number;
}
candidates.push({
capability,
intent,
phrase,
parameters,
score: matchedDevice.length * 100 + phrase.length,
});
}
}
if (!candidates.length) {
result.semantic_status = "incomplete";
result.issues.push("设备已识别,但没有命中已启用的功能说法");
trace.push({ stage: "功能识别", state: "error", detail: "请使用卡片中定义的功能说法" });
return result;
}
candidates.sort((left, right) => right.score - left.score);
const best = candidates[0];
const tied = candidates.filter((item) => item.score === best.score);
if (tied.length > 1) {
result.semantic_status = "ambiguous";
result.issues.push(`同时命中多个功能:${tied.map((item) => item.intent.label).join("、")}`);
trace.push({ stage: "功能识别", state: "error", detail: "最高匹配分相同,需要补充更明确的动作" });
return result;
}
trace.push({ stage: "功能识别", state: "success", detail: `“${best.phrase}” → ${best.capability.id}` });
const scope = resolveScope(normalizedText, descriptor.device);
trace.push({ stage: "目标解析", state: scope.status === "resolved" ? "success" : "error", detail: scope.reason });
if (scope.status !== "resolved") {
result.semantic_status = scope.status === "ambiguous" ? "ambiguous" : "recognized_unsupported";
result.issues.push(scope.reason);
return result;
}
const issues = validateParameters(best.capability, best.parameters);
trace.push({
stage: "参数校验",
state: issues.length ? "error" : "success",
detail: issues.length ? issues.join(";") : JSON.stringify(best.parameters),
});
if (issues.length) {
result.semantic_status = Object.keys(best.parameters).length ? "recognized_unsupported" : "incomplete";
result.issues.push(...issues);
return result;
}
result.semantic_status = "recognized_executable";
result.execution_eligible = true;
result.clean_text = renderCleanText(descriptor, best.capability, best.parameters);
result.command = {
domain: descriptor.device.domain,
capability: best.capability.id,
property: best.capability.property,
target_device: descriptor.device.targetDevice,
target_scope: scope.scope,
operation: best.capability.operation,
parameters: best.parameters,
risk_level: best.capability.risk,
confirmation_policy: best.capability.confirmationPolicy,
executor_binding: best.capability.executor.binding,
executor_payload: {
transport: descriptor.device.transport,
mapping_ref: best.capability.executor.mappingRef,
capability: best.capability.id,
target: scope.scope,
parameters: best.parameters,
demo_only: true,
},
};
trace.push({ stage: "执行绑定", state: "success", detail: `${best.capability.executor.binding} · 演示载荷,不发送` });
return result;
}
export function buildManualCommand(descriptorInput, values) {
const descriptor = cloneDescriptor(descriptorInput);
const commands = [];
for (const capability of descriptor.capabilities.filter((item) => item.enabled !== false)) {
const value = values[capability.id];
if (value === undefined) continue;
const schema = capability.parameters?.[0];
if (!schema) continue;
const parsedValue = schema.type === "number" ? Number(value) : value;
const parameters = { [schema.name]: parsedValue };
const issues = validateParameters(capability, parameters);
if (issues.length) continue;
commands.push({
capability: capability.id,
target_scope: descriptor.device.defaultScope,
parameters,
demo_only: true,
});
}
return commands;
}