feat: 完善 N7 智能座舱交互与控制链路

This commit is contained in:
vee
2026-09-07 15:34:59 +08:00
parent 32d179143f
commit c96970f068
100 changed files with 30088 additions and 1174 deletions
+279 -42
View File
@@ -62,21 +62,56 @@ _ACTION_SYNONYM_GROUPS = (
("白色", "白灯", "白光", "white"),
)
_RESERVED_VEHICLE_DEVICE_TERMS = {
"空调",
"座椅",
"主驾座椅",
"副驾座椅",
"车窗",
"车门",
"后门",
"遮阳帘",
"氛围灯",
"阅读灯",
"室内灯",
"出风口",
"风口",
}
_RESERVED_VEHICLE_DEVICE_TERMS = frozenset(
{
"ac",
"airconditioner",
"ambientlight",
"door",
"interiorlight",
"light",
"readinglight",
"seat",
"shade",
"window",
"空调",
"空调压缩机",
"压缩机",
"空调出风口",
"座椅",
"主驾座椅",
"副驾座椅",
"驾驶员座椅",
"乘客座椅",
"全车座椅",
"车窗",
"前排车窗",
"后排车窗",
"左前车窗",
"右前车窗",
"左后车窗",
"右后车窗",
"车门",
"门锁",
"后门",
"后排车门",
"遮阳帘",
"天幕帘",
"智能天幕",
"天幕",
"氛围灯",
"阅读灯",
"室内灯",
"车内灯",
"车内灯光",
"灯光",
"出风口",
"风口",
}
)
_VEHICLE_PHRASE_PRIORITY_TERMS = frozenset(
term for term in _RESERVED_VEHICLE_DEVICE_TERMS if not term.isascii()
)
class GatewayError(RuntimeError):
@@ -186,7 +221,9 @@ class InfraredSlotCatalog:
"slot": int(row["slot"]),
"device_id": str(row["device_id"] or ""),
"name": str(row["name"]),
"object_names": cls._stored_names(row["object_names_json"]),
"object_names": _safe_peripheral_identity_names(
cls._stored_names(row["object_names_json"])
),
"action_names": cls._stored_names(row["action_names_json"]),
"source": str(row["source"]),
"learned_at": str(row["learned_at"]),
@@ -263,8 +300,10 @@ class InfraredSlotCatalog:
r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", device_id
):
continue
object_names = self._stored_names(
json.dumps(capability.get("object_names") or [])
object_names = _safe_peripheral_identity_names(
self._stored_names(
json.dumps(capability.get("object_names") or [])
)
)
action_names = self._stored_names(
json.dumps(capability.get("action_names") or [])
@@ -324,7 +363,20 @@ class InfraredSlotCatalog:
except json.JSONDecodeError:
continue
if isinstance(record, dict):
capabilities.append(record)
device_name = str(record.get("device_name") or "").strip()
if not _peripheral_identity_name_is_safe(
device_name,
strip_display_index=True,
):
continue
sanitized = dict(record)
sanitized["device_aliases"] = _safe_peripheral_identity_names(
record.get("device_aliases")
)
sanitized["object_names"] = _safe_peripheral_identity_names(
record.get("object_names")
)
capabilities.append(sanitized)
return capabilities
def sync_capabilities(
@@ -355,9 +407,12 @@ class InfraredSlotCatalog:
).fetchone()
if slot_row is not None:
stored_device_id = str(slot_row["device_id"] or "").strip()
stored_objects = self._stored_names(
raw_stored_objects = self._stored_names(
slot_row["object_names_json"]
)
stored_objects = _safe_peripheral_identity_names(
raw_stored_objects
)
stored_actions = self._stored_names(
slot_row["action_names_json"]
)
@@ -370,6 +425,7 @@ class InfraredSlotCatalog:
if (
not stored_device_id
or not stored_objects
or stored_objects != raw_stored_objects
or not stored_actions
):
connection.execute(
@@ -464,6 +520,10 @@ class InfraredSlotCatalog:
normalized_objects = _normalized_aliases(
object_names, field="控制对象名称"
)
_validate_peripheral_identity_names(
normalized_objects,
field="控制对象名称",
)
normalized_actions = _normalized_aliases(
action_names, field="执行动作"
)
@@ -769,6 +829,138 @@ def _normalized_aliases(
return aliases[:24]
def _identity_name_variants(value: Any, *, strip_display_index: bool = False) -> set[str]:
raw_name = str(value or "").strip()
variants = {_compact_match_text(raw_name)}
if strip_display_index:
variants.add(
_compact_match_text(
re.sub(r"^\s*#?\d+\s*[·.、:_-]*\s*", "", raw_name)
)
)
variants.discard("")
return variants
def _peripheral_identity_name_is_safe(
value: Any,
*,
strip_display_index: bool = False,
) -> bool:
"""Reject identities that can match a vehicle actuator without extra text."""
variants = _identity_name_variants(
value,
strip_display_index=strip_display_index,
)
if not variants:
return False
for identity in variants:
contains_cjk = bool(re.search(r"[\u4e00-\u9fff]", identity))
minimum_length = 2 if contains_cjk else 3
if len(identity) < minimum_length or identity.isdigit():
return False
if any(identity in reserved for reserved in _RESERVED_VEHICLE_DEVICE_TERMS):
return False
return True
def _validate_peripheral_identity_names(
values: list[str],
*,
field: str,
strip_display_index: bool = False,
) -> None:
for value in values:
if not _peripheral_identity_name_is_safe(
value,
strip_display_index=strip_display_index,
):
raise GatewayError(
f"{field}“{value}”过短或与车端设备保留名称冲突,"
"请使用可唯一识别外设的完整名称。"
)
def _safe_peripheral_identity_names(value: Any) -> list[str]:
"""Sanitize legacy persisted aliases instead of returning them to a router."""
if isinstance(value, str):
value = re.split(r"[,,、;;|]", value)
if not isinstance(value, list):
return []
names: list[str] = []
for raw_name in value:
name = str(raw_name or "").strip()
if (
name
and name not in names
and _peripheral_identity_name_is_safe(name)
):
names.append(name)
return names[:24]
def _capability_identity_variants(capability: dict[str, Any]) -> set[str]:
variants: set[str] = set()
raw_name = str(capability.get("device_name") or "")
for candidate in _identity_name_variants(raw_name, strip_display_index=True):
if _peripheral_identity_name_is_safe(candidate):
variants.add(candidate)
for field in ("device_aliases", "object_names"):
values = capability.get(field, [])
if isinstance(values, str):
values = re.split(r"[,,、;;|]", values)
if not isinstance(values, list):
continue
for value in values:
if _peripheral_identity_name_is_safe(value):
variants.update(_identity_name_variants(value))
return variants
def _has_unshadowed_vehicle_device_phrase(
utterance: str,
candidates: list[dict[str, Any]],
) -> bool:
"""Return true when a native vehicle phrase is not inside an explicit IR name."""
spans: list[tuple[int, int, str]] = []
for capability in candidates:
for identity in _capability_identity_variants(capability):
start = utterance.find(identity)
while start >= 0:
spans.append((start, start + len(identity), identity))
start = utterance.find(identity, start + 1)
for vehicle_term in _VEHICLE_PHRASE_PRIORITY_TERMS:
start = utterance.find(vehicle_term)
while start >= 0:
end = start + len(vehicle_term)
covered_by_explicit_peripheral = any(
identity != vehicle_term
and len(identity) > len(vehicle_term)
and identity_start <= start
and identity_end >= end
for identity_start, identity_end, identity in spans
)
if not covered_by_explicit_peripheral:
return True
start = utterance.find(vehicle_term, start + 1)
return False
def _peripheral_execution_identity_authorized(
utterance: str,
capability: dict[str, Any],
) -> bool:
# Saved modes execute an immutable, previously validated command flow and
# are rebound to the same live NFC/device/slot tuple before reaching here.
if capability.get("parse_source") == "saved_mode_command_flow":
return True
return bool(_device_match_score(_compact_match_text(utterance), capability))
def _normalize_peripheral_capability(value: Any) -> dict[str, Any]:
"""Validate one browser-synchronized learned infrared capability."""
@@ -820,6 +1012,19 @@ def _normalize_peripheral_capability(value: Any) -> dict[str, Any]:
action_names = _normalized_aliases(
[action, *action_aliases], field="执行动作"
)
_validate_peripheral_identity_names(
[device_name],
field="设备名称",
strip_display_index=True,
)
_validate_peripheral_identity_names(
device_aliases,
field="设备别名",
)
_validate_peripheral_identity_names(
object_names,
field="控制对象名称",
)
return {
"id": capability_id,
@@ -843,26 +1048,7 @@ def _normalize_peripheral_capability(value: Any) -> dict[str, Any]:
def _device_match_score(utterance: str, capability: dict[str, Any]) -> int:
raw_name = str(capability.get("device_name") or "")
raw_aliases = capability.get("device_aliases", [])
if isinstance(raw_aliases, str):
raw_aliases = re.split(r"[,,、;;|]", raw_aliases)
if not isinstance(raw_aliases, list):
raw_aliases = []
object_names = capability.get("object_names", [])
if isinstance(object_names, str):
object_names = re.split(r"[,,、;;|]", object_names)
if not isinstance(object_names, list):
object_names = []
name_variants = {
_compact_match_text(raw_name),
_compact_match_text(
re.sub(r"^\s*#?\d+\s*[·.、:_-]*\s*", "", raw_name)
),
*(_compact_match_text(alias) for alias in raw_aliases),
*(_compact_match_text(name) for name in object_names),
}
name_variants.difference_update(_RESERVED_VEHICLE_DEVICE_TERMS)
name_variants = _capability_identity_variants(capability)
return max(
(len(name) for name in name_variants if name and name in utterance),
default=0,
@@ -917,7 +1103,27 @@ def _deterministic_peripheral_steps(
]
if not compact_utterance or len(learned) < 2:
return []
if len({str(item.get("device_id") or "") for item in learned}) != 1:
explicit = [
item for item in learned if _device_match_score(compact_utterance, item)
]
if not explicit or _has_unshadowed_vehicle_device_phrase(
compact_utterance,
explicit,
):
return []
explicit_device_ids = {
str(item.get("device_id") or "") for item in explicit
}
if len(explicit_device_ids) != 1:
return []
selected_device_id = next(iter(explicit_device_ids))
learned = [
item
for item in learned
if str(item.get("device_id") or "") == selected_device_id
and _capability_identity_variants(item)
]
if len(learned) < 2:
return []
events: list[tuple[int, int, dict[str, Any]]] = []
@@ -1084,6 +1290,11 @@ def _resolve_bound_peripheral_command(
if not matches:
return None
if _has_unshadowed_vehicle_device_phrase(
compact_utterance,
[item[2] for item in matches],
):
return None
matches.sort(key=lambda item: (item[0], item[1]), reverse=True)
best_score = matches[0][:2]
best_matches = [item for item in matches if item[:2] == best_score]
@@ -1182,6 +1393,14 @@ def partition_active_peripheral_intent(
for item in explicit_bound_candidates
if item.get("enabled", True) is not False
]
if explicit_bound_candidates and _has_unshadowed_vehicle_device_phrase(
compact_clause,
explicit_bound_candidates,
):
residual.append(clause)
contextual_device_id = None
contextual_device_name = ""
continue
clause_candidates = explicit_candidates
bound_clause_candidates = explicit_bound_candidates
resolution_text = clause
@@ -1216,7 +1435,7 @@ def partition_active_peripheral_intent(
else:
mentions_vehicle_device = any(
_compact_match_text(term) in compact_clause
for term in _RESERVED_VEHICLE_DEVICE_TERMS
for term in _VEHICLE_PHRASE_PRIORITY_TERMS
)
requires_ordered_plan = bool(
re.search(
@@ -1357,6 +1576,8 @@ def active_peripheral_plan_catalog(
item["slot"] = validate_slot(item.get("slot"))
item["card_uid"] = active_uid
active.append(item)
if _has_unshadowed_vehicle_device_phrase(compact_utterance, active):
return []
return active
@@ -1805,6 +2026,7 @@ class TcpGateway(BaseGateway):
"gateway_acknowledged": False,
"physical_state_confirmed": False,
}
return result
class MqttGateway(BaseGateway):
@@ -2168,6 +2390,10 @@ class SelfExtensionService:
) -> dict[str, Any]:
"""Execute a live-NFC peripheral match through the main request API."""
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
"外设名称未在原始指令中被明确提及,已拒绝红外执行。"
)
slot = validate_slot(capability.get("slot"))
request_id = f"req-extension-{uuid.uuid4().hex}"
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
@@ -2374,6 +2600,10 @@ class SelfExtensionService:
raise ExtensionIntegrationError(
"GPT 计划引用了不在当前 NFC 目录中的能力。"
)
if not _peripheral_execution_identity_authorized(utterance, capability):
raise ExtensionIntegrationError(
"GPT 计划引用的外设未在原始指令中被明确提及。"
)
slot = validate_slot(capability.get("slot"))
operation_ref = f"{capability_id}-{index + 1}"
compiled_peripheral_steps[index] = (
@@ -2701,6 +2931,13 @@ class SelfExtensionService:
peripherals = [
item for item in peripherals[:MAX_CATALOG_ITEMS] if isinstance(item, dict)
]
compact_utterance = _compact_match_text(utterance)
if _has_unshadowed_vehicle_device_phrase(compact_utterance, peripherals):
peripherals = [
item
for item in peripherals
if _device_match_score(compact_utterance, item)
]
deterministic_steps = _deterministic_peripheral_steps(utterance, peripherals)
if deterministic_steps and not vehicle_devices:
return {