Files
Cognitive_Agent/experiments/device-capability-card-demo/parser-engine.js
T
2026-09-03 13:36:40 +08:00

354 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}