feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user