优化语音输入/普通文本输入
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user