添加NFC绑定红外设备方式,融合车端设备和扩展设备统一控制

This commit is contained in:
minghanchen
2026-09-01 11:04:59 +08:00
parent 6d75944c0f
commit ca26259e63
14 changed files with 3694 additions and 316 deletions
+499 -18
View File
@@ -12,6 +12,7 @@ import re
import socket
import threading
import time
from copy import deepcopy
from http.cookies import CookieError, SimpleCookie
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -39,6 +40,7 @@ from cognitive_agent.application.self_extension import ( # noqa: E402
GatewayError,
SelfExtensionService,
active_peripheral_plan_catalog,
partition_active_peripheral_intent,
resolve_active_peripheral_command,
)
from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402
@@ -324,6 +326,26 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.SERVICE_UNAVAILABLE,
)
return
if path == "/api/extension/peripherals":
try:
catalog = self.extension.list_peripheral_capabilities()
self._json(
{
"success": True,
"catalog": catalog,
"count": len(catalog),
}
)
except GatewayError as exc:
self._json(
{
"success": False,
"error": "catalog_unavailable",
"message": str(exc),
},
HTTPStatus.SERVICE_UNAVAILABLE,
)
return
if path == "/api/extension/gscu/info":
self._handle_extension_gateway("info", {})
return
@@ -537,11 +559,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
else:
result = None
if path in {"/api/requests", "/api/requests/preview"}:
result = self._execute_active_peripheral_request(
utterance,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
result = self._execute_request_with_peripherals(
path,
common_arguments,
)
if result is None:
if path == "/api/requests/preview":
@@ -670,7 +690,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) -> dict[str, object] | None:
"""Route a command to the NFC-bound peripheral when one is present."""
extension_catalog = self.vehicle_display_sync.extension_catalog()
# The browser may disappear or reload between learning and execution.
# Natural-language routing therefore reads the durable, validated
# whitelist instead of trusting the display-sync snapshot.
extension_catalog = self.extension.list_peripheral_capabilities()
nfc_snapshot = self.nfc.snapshot()
plan_catalog = active_peripheral_plan_catalog(
utterance,
@@ -703,6 +726,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"utterance": utterance,
"peripherals": plan_catalog,
"vehicle_devices": [],
"provider": provider,
}
)
if plan_result.get("needs_confirmation"):
@@ -748,6 +772,465 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
return result
@staticmethod
def _residual_has_vehicle_language(utterance: str) -> bool:
"""Recognize only obvious vehicle-domain text in a claimed mixed request."""
return bool(
re.search(
r"空调|温度|风量|循环|主驾|副驾|座椅|车窗|车门|后门|"
r"遮阳帘|阅读灯|氛围灯|室内灯|出风口|风口|"
r"\b(?:ac|seat|window|door|shade|light|outlet)\b",
utterance,
flags=re.IGNORECASE,
)
)
def _execute_local_peripheral_actions(
self,
utterance: str,
entries: list[dict[str, object]],
*,
provider: str,
conversation_id: str | None,
execution_mode: str,
) -> dict[str, object]:
"""Execute already validated local-rule infrared capabilities."""
capabilities = [
{
**dict(entry["capability"]),
"parse_source": "local_rule",
}
for entry in entries
if isinstance(entry, dict) and isinstance(entry.get("capability"), dict)
]
if not capabilities:
raise ExtensionIntegrationError("没有可执行的已学习红外能力。")
conversation_turn = 1
if conversation_id:
context = self.service.conversations.snapshot(conversation_id)
conversation_turn = int(context.get("turn_count", 0)) + 1
if len(capabilities) == 1:
result = self.extension.execute_peripheral_request(
utterance,
capabilities[0],
conversation_id=conversation_id,
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
)
else:
result = self.extension.execute_peripheral_plan_request(
utterance,
{
"summary": f"本地规则命中 {len(capabilities)} 个已学习红外动作。",
"provider_used": "infrared_local_rules",
"steps": [
{
"kind": "peripheral",
"capability_id": capability["id"],
}
for capability in capabilities
],
},
capabilities,
conversation_id=conversation_id,
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
)
result["provider_used"] = "infrared_local_rules"
self.vehicle_display_sync.update_extension(
action={
"type": "emit_sequence" if len(capabilities) > 1 else "emit",
"source": "main_orchestration_local_rule",
"capability_ids": [item["id"] for item in capabilities],
"slots": [item["slot"] for item in capabilities],
},
result=result,
)
return result
@staticmethod
def _merge_can_and_peripheral_results(
utterance: str,
can_result: dict[str, object],
infrared_result: dict[str, object],
entries: list[dict[str, object]],
) -> dict[str, object]:
"""Create one response while retaining each transport's evidence."""
merged = deepcopy(can_result)
can_status = str(can_result.get("status") or "failed")
infrared_status = str(infrared_result.get("status") or "failed")
successful = {"succeeded", "partially_succeeded", "no_action"}
can_ok = can_status in successful
infrared_ok = infrared_status in successful
function_configs = deepcopy(can_result.get("function_configs") or {})
function_configs.update(
deepcopy(infrared_result.get("function_configs") or {})
)
command_plans = deepcopy(can_result.get("command_plans") or {})
command_plans.update(deepcopy(infrared_result.get("command_plans") or {}))
can_operations = list(can_result.get("operation_results") or [])
infrared_operations = list(infrared_result.get("operation_results") or [])
operation_results = [*can_operations, *infrared_operations]
can_report = deepcopy(can_result.get("execution_report"))
infrared_report = deepcopy(infrared_result.get("execution_report"))
execution_report = can_report or infrared_report
if isinstance(execution_report, dict):
spec = execution_report.setdefault("spec", {})
if execution_report is can_report and isinstance(infrared_report, dict):
spec["results"] = [
*list(spec.get("results") or []),
*list((infrared_report.get("spec") or {}).get("results") or []),
]
spec["status"] = (
"succeeded" if can_ok and infrared_ok else "partially_succeeded"
)
metadata = execution_report.setdefault("metadata", {})
labels = metadata.setdefault("labels", {})
labels["route"] = "unified_can_infrared"
infrared_actions = [
{
"index": entry.get("index"),
"clause": entry.get("clause"),
"capability_id": capability.get("id"),
"device_id": capability.get("device_id"),
"device_name": capability.get("device_name"),
"action": capability.get("action"),
"slot": capability.get("slot"),
"parse_source": str(
capability.get("parse_source") or "local_rule"
),
"transport": str(infrared_result.get("control_transport") or "gscu_tcp"),
}
for entry in entries
for capability in [entry.get("capability")]
if isinstance(capability, dict)
]
merged.update(
{
"utterance": utterance,
"status": (
"succeeded"
if can_ok and infrared_ok
else "partially_succeeded"
if can_ok or infrared_ok
else "failed"
),
"success": can_ok and infrared_ok,
"orchestration_route": "unified_can_infrared",
"provider_used": (
f"{can_result.get('provider_used') or 'vehicle'}+"
f"{infrared_result.get('provider_used') or 'infrared_local_rules'}"
),
"decision_summary": [
*list(can_result.get("decision_summary") or []),
*list(infrared_result.get("decision_summary") or []),
"CAN 与红外动作已通过独立白名单校验并按各自传输链路执行。",
],
"function_configs": function_configs,
"command_plans": command_plans,
"function_config": next(iter(function_configs.values()), None),
"command_plan": next(iter(command_plans.values()), None),
"execution_report": execution_report,
"operation_results": operation_results,
"infrared_actions": infrared_actions,
"peripheral": infrared_result.get("peripheral"),
"control_transports": {
"vehicle": can_result.get("control_transport"),
"infrared": infrared_result.get("control_transport"),
},
"phase_execution_reports": {
**dict(can_result.get("phase_execution_reports") or {}),
"vehicle": can_report,
"infrared": infrared_report,
},
}
)
return merged
@staticmethod
def _apply_nfc_command_denials(
result: dict[str, object],
entries: list[dict[str, object]],
) -> dict[str, object]:
"""Expose command-level NFC denials without discarding allowed work."""
if not entries:
return result
denied_actions = [
{
"index": entry.get("index"),
"clause": entry.get("clause"),
"capability_id": capability.get("id"),
"device_id": capability.get("device_id"),
"device_name": capability.get("device_name"),
"action": capability.get("action"),
"slot": capability.get("slot"),
"reason": str(entry.get("reason") or "nfc_command_disabled"),
}
for entry in entries
for capability in [entry.get("capability")]
if isinstance(entry, dict) and isinstance(capability, dict)
]
if not denied_actions:
return result
result["denied_peripheral_actions"] = denied_actions
result["authorization_filtered"] = True
summaries = list(result.get("decision_summary") or [])
labels = "、".join(
str(item.get("clause") or item.get("action") or "未命名动作")
for item in denied_actions
)
summaries.append(
f"当前 NFC 未授权 {len(denied_actions)} 条红外命令,已过滤:{labels}。"
)
result["decision_summary"] = summaries
prior_status = str(result.get("status") or "failed")
if prior_status in {"succeeded", "partially_succeeded", "no_action"}:
result["status"] = "partially_succeeded"
result["success"] = False
execution_report = result.get("execution_report")
if isinstance(execution_report, dict):
spec = execution_report.get("spec")
if isinstance(spec, dict):
spec["status"] = "partially_succeeded"
return result
def _execute_request_with_peripherals(
self,
path: str,
common_arguments: dict[str, object],
*,
on_commands_validated: object | None = None,
) -> dict[str, object] | None:
"""Route IR-only or mixed IR/CAN requests without changing CAN parsing."""
utterance = str(common_arguments.get("utterance") or "").strip()
catalog = self.extension.list_peripheral_capabilities()
nfc_snapshot = self.nfc.snapshot()
partition = partition_active_peripheral_intent(
utterance,
catalog,
nfc_snapshot,
)
entries = list(partition.get("peripheral_actions") or [])
residual = str(partition.get("residual_utterance") or "").strip()
unresolved = list(partition.get("unresolved_peripheral_clauses") or [])
denied = list(partition.get("denied_peripheral_actions") or [])
provider = str(common_arguments.get("provider") or "auto")
conversation_id = (
str(common_arguments["conversation_id"])
if common_arguments.get("conversation_id")
else None
)
execution_mode = str(common_arguments.get("execution_mode") or "dry_run")
infrared_result: dict[str, object] | None = None
if unresolved:
if provider == "local":
raise ExtensionIntegrationError(
"本地规则已识别外设,但以下动作未唯一命中已学习能力:"
+ "、".join(str(item) for item in unresolved)
)
candidates = list(partition.get("unresolved_candidates") or [])
if not candidates:
raise ExtensionIntegrationError("没有可供云端补充选择的已学习红外能力。")
plan_result = self.extension.plan(
{
"utterance": ",".join(str(item) for item in unresolved),
"peripherals": candidates,
"vehicle_devices": [],
"provider": provider,
}
)
if plan_result.get("needs_confirmation"):
raise ExtensionIntegrationError(
str(plan_result.get("question") or "请明确要控制的红外动作。")
)
cloud_steps = list(plan_result.get("steps") or [])
if not cloud_steps:
raise ExtensionIntegrationError("云端模型没有命中已学习红外能力。")
candidate_by_id = {
str(item.get("id")): {**item, "parse_source": "cloud_model"}
for item in candidates
if isinstance(item, dict) and item.get("id")
}
local_capabilities = [
{
**dict(entry["capability"]),
"parse_source": "local_rule",
}
for entry in entries
if isinstance(entry, dict)
and isinstance(entry.get("capability"), dict)
]
selected_cloud = [
candidate_by_id[str(step.get("capability_id"))]
for step in cloud_steps
if isinstance(step, dict)
and step.get("kind") == "peripheral"
and str(step.get("capability_id")) in candidate_by_id
]
execution_catalog = {
str(item["id"]): item for item in [*local_capabilities, *selected_cloud]
}
combined_steps = [
{"kind": "peripheral", "capability_id": item["id"]}
for item in local_capabilities
] + cloud_steps
conversation_turn = 1
if conversation_id:
context = self.service.conversations.snapshot(conversation_id)
conversation_turn = int(context.get("turn_count", 0)) + 1
infrared_result = self.extension.execute_peripheral_plan_request(
utterance,
{
**plan_result,
"steps": combined_steps,
},
list(execution_catalog.values()),
conversation_id=conversation_id,
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
)
next_index = max(
[int(entry.get("index", 0)) for entry in entries if isinstance(entry, dict)],
default=-1,
) + 1
entries.extend(
{
"index": next_index + index,
"clause": ",".join(str(item) for item in unresolved),
"capability": capability,
}
for index, capability in enumerate(selected_cloud)
)
if not entries and not denied:
return self._execute_active_peripheral_request(
utterance,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
)
if not entries:
if not residual:
labels = "、".join(
str(item.get("clause") or "未命名动作")
for item in denied
if isinstance(item, dict)
)
raise ExtensionIntegrationError(
f"当前 NFC 未授权执行以下红外命令:{labels}。"
)
can_arguments = {**common_arguments, "utterance": residual}
callback = (
on_commands_validated
if callable(on_commands_validated)
else None
)
if path == "/api/requests/preview":
result = self.service.preview_native_can(
**can_arguments,
on_commands_validated=callback,
)
else:
result = self.service.process(
**can_arguments,
_on_commands_validated=callback,
)
result = self._apply_nfc_command_denials(result, denied)
self.service.persist_composed_result(result)
if callback is not None:
callback(result)
return result
if (
infrared_result is None
and residual
and not self._residual_has_vehicle_language(residual)
):
# Preserve the richer existing ordered/delay peripheral planner
# when the remaining text contains no vehicle-domain request.
planned = self._execute_active_peripheral_request(
utterance,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
)
if planned is not None:
return planned
if infrared_result is None:
infrared_result = self._execute_local_peripheral_actions(
utterance,
entries,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
)
if not residual:
infrared_result = self._apply_nfc_command_denials(
infrared_result, denied
)
if denied:
self.service.persist_composed_result(infrared_result)
return infrared_result
can_arguments = {**common_arguments, "utterance": residual}
callback = on_commands_validated if callable(on_commands_validated) else None
can_validation_callback = None
if callback is not None:
def publish_combined_validation(
validated_can: dict[str, object],
) -> None:
# Native CAN publishes its fully compiled commands before the
# physical executor starts. A mixed request has already
# compiled (and in hardware mode may already have submitted)
# the infrared phase, so never replace the UI with this
# CAN-only intermediate snapshot. If CAN subsequently fails,
# this combined validation event remains the authoritative
# parsed-command view with per-operation delivery evidence.
validated = self._merge_can_and_peripheral_results(
utterance,
validated_can,
infrared_result,
entries,
)
validated = self._apply_nfc_command_denials(validated, denied)
validated["status"] = "processing"
validated["phase"] = "commands_validated"
callback(validated)
can_validation_callback = publish_combined_validation
if path == "/api/requests/preview":
can_result = self.service.preview_native_can(
**can_arguments,
on_commands_validated=can_validation_callback,
)
else:
can_result = self.service.process(
**can_arguments,
_on_commands_validated=can_validation_callback,
)
combined = self._merge_can_and_peripheral_results(
utterance,
can_result,
infrared_result,
entries,
)
combined = self._apply_nfc_command_denials(combined, denied)
self.service.persist_composed_result(combined)
if callback is not None:
callback(combined)
return combined
def _stream_control_request(
self,
path: str,
@@ -819,17 +1302,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
try:
result = None
if path in {"/api/requests", "/api/requests/preview"}:
result = self._execute_active_peripheral_request(
utterance,
provider=str(common_arguments.get("provider") or "auto"),
conversation_id=(
str(common_arguments["conversation_id"])
if common_arguments.get("conversation_id")
else None
),
execution_mode=str(
common_arguments.get("execution_mode") or "dry_run"
),
result = self._execute_request_with_peripherals(
path,
common_arguments,
on_commands_validated=on_commands_validated,
)
if result is None:
if path == "/api/requests/preview":
@@ -1052,7 +1528,12 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
catalog = payload.get("peripherals", payload.get("catalog", []))
if not isinstance(catalog, list) or len(catalog) > 248:
raise ValueError("外设能力目录必须是至多 248 项的数组。")
sync_result = {"status": "ready", "count": len(catalog)}
durable_sync = self.extension.sync_peripheral_catalog(catalog)
sync_result = {
"status": "ready",
"count": len(catalog),
"learned_count": durable_sync["count"],
}
self.vehicle_display_sync.update_extension(
catalog=catalog,
action={