354 lines
13 KiB
JavaScript
354 lines
13 KiB
JavaScript
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;
|
||
}
|