merge: integrate remote speech capabilities into feature/N7Web

This commit is contained in:
minghanchen
2026-09-01 17:01:50 +08:00
39 changed files with 6869 additions and 52 deletions
@@ -2,23 +2,66 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
@dataclass(frozen=True)
class CapabilityDefinition:
"""One capability together with its authoritative catalog provenance."""
name: str
domain: str
availability: str
catalog_id: str
catalog_path: Path
executor_binding: str
spec: dict[str, Any]
@property
def target_devices(self) -> tuple[str, ...]:
return tuple(str(value) for value in self.spec.get("target_devices", ()))
@property
def target_scopes(self) -> tuple[str, ...]:
return tuple(str(value) for value in self.spec.get("target_scopes", ()))
@property
def parameters(self) -> tuple[dict[str, Any], ...]:
return tuple(dict(value) for value in self.spec.get("parameters", ()))
class CapabilityRegistry:
"""Expose domain catalogs without embedding domain semantics in prompts or code."""
def __init__(self, repository_root: Path, executable_domains: set[str]) -> None:
self.root = repository_root
self.executable_domains = set(executable_domains)
self._definitions: list[CapabilityDefinition] = []
self._snapshot = self._load()
def snapshot(self) -> dict[str, Any]:
return self._snapshot
def definitions(self, *, domain: str | None = None) -> tuple[CapabilityDefinition, ...]:
"""Return catalog entries without inventing aliases or execution support."""
return tuple(
definition
for definition in self._definitions
if domain is None or definition.domain == domain
)
def find(self, name: str) -> tuple[CapabilityDefinition, ...]:
"""Return every authoritative definition for a canonical capability id."""
return tuple(
definition for definition in self._definitions if definition.name == name
)
def _load(self) -> dict[str, Any]:
domains = []
catalog_paths = [
@@ -31,16 +74,37 @@ class CapabilityRegistry:
catalog = yaml.safe_load(catalog_path.read_text(encoding="utf-8"))
domain = catalog["spec"]["domain"]
native_can_catalog = catalog_path.parent.name == "native_can_capabilities"
availability = (
"native_can"
if native_can_catalog
else "executable"
if domain in self.executable_domains
else "catalog_only"
)
executor_binding = (
"NativeCanRuntime/NativeCanExecutor"
if native_can_catalog
else "SeatCompiler/SeatExecutor"
if domain == "seat"
else f"ConfiguredDomainRuntime[{domain}]"
)
catalog_id = str(catalog.get("metadata", {}).get("id", catalog_path.stem))
self._definitions.extend(
CapabilityDefinition(
name=str(capability["name"]),
domain=str(domain),
availability=availability,
catalog_id=catalog_id,
catalog_path=catalog_path,
executor_binding=executor_binding,
spec=dict(capability),
)
for capability in catalog["spec"]["capabilities"]
)
domains.append(
{
"domain": domain,
"availability": (
"native_can"
if native_can_catalog
else "executable"
if domain in self.executable_domains
else "catalog_only"
),
"availability": availability,
"capabilities": catalog["spec"]["capabilities"],
}
)
@@ -0,0 +1,128 @@
"""Shared deterministic parsing for Chinese scalar control values.
This module only normalizes values that the user actually spoke. Capability
validation remains responsible for deciding whether a target accepts the
result and whether its type/range is executable.
"""
from __future__ import annotations
import re
_ARABIC_PERCENT = re.compile(r"(-?\d+(?:\.\d+)?)\s*[%%]")
_FRACTION = re.compile(
r"([零〇一二两三四五六七八九十百\d]+)分之"
r"([零〇一二两三四五六七八九十百点\d.]+)"
)
_SLASH_FRACTION = re.compile(r"(?<!\d)(\d+)\s*/\s*(\d+)(?!\d)")
_DIGITS = {
"零": 0,
"〇": 0,
"一": 1,
"二": 2,
"两": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
}
def _chinese_integer(token: str) -> int | None:
if not token:
return 0
if token.isdigit():
return int(token)
if "百" in token:
left, right = token.split("百", 1)
hundreds = _DIGITS.get(left[-1], 1) if left else 1
tail = _chinese_integer(right)
return None if tail is None else hundreds * 100 + tail
if "十" in token:
left, right = token.split("十", 1)
tens = _DIGITS.get(left[-1], 1) if left else 1
ones = _DIGITS.get(right[-1], 0) if right else 0
return tens * 10 + ones
if all(char in _DIGITS for char in token):
return int("".join(str(_DIGITS[char]) for char in token))
return None
def _number(token: str) -> int | float | None:
try:
value = float(token)
except ValueError:
if "点" in token:
whole, fraction = token.split("点", 1)
whole_value = _chinese_integer(whole)
if (
whole_value is None
or not fraction
or any(char not in _DIGITS for char in fraction)
):
return None
value = float(
f"{whole_value}."
+ "".join(str(_DIGITS[char]) for char in fraction)
)
else:
integer = _chinese_integer(token)
if integer is None:
return None
value = float(integer)
return int(value) if value.is_integer() else value
def parse_percentage_value(text: str) -> int | float | None:
"""Return an explicitly spoken percentage/ratio/extreme position.
Bare numbers intentionally do not become percentages. Fractions are
rounded to the nearest whole percentage because every current continuous
vehicle position capability is integer-valued; the resolver still checks
the final range and type.
"""
match = _ARABIC_PERCENT.search(text)
if match:
value = float(match.group(1))
return int(value) if value.is_integer() else value
match = _FRACTION.search(text)
if match:
denominator = _number(match.group(1))
numerator = _number(match.group(2))
if denominator not in {None, 0} and numerator is not None:
return round(float(numerator) * 100 / float(denominator))
match = _SLASH_FRACTION.search(text)
if match and int(match.group(2)) != 0:
return round(int(match.group(1)) * 100 / int(match.group(2)))
if re.search(r"一半|半开|开一半", text):
return 50
extrema = (
(
(
r"最大(?:位置|开度)|最高(?:位置)?|"
r"(?:调|调整|升|抬|开)(?:到|至)(?:最大|最高)|到顶|"
r"全开|完全打开|打开到底"
),
100,
),
(
(
r"最小(?:位置|开度)|最低(?:位置)?|"
r"(?:调|调整|降|收|关)(?:到|至)(?:最小|最低)|"
r"全关|完全关闭|关闭到底"
),
0,
),
)
for pattern, value in extrema:
if re.search(pattern, text):
return value
return None
@@ -9,6 +9,7 @@ from __future__ import annotations
import re
from typing import Any
from cognitive_agent.application.chinese_values import parse_percentage_value
WINDOW_TARGETS = {
"全部": "all",
@@ -626,8 +627,20 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
("加热", "seat.set_driver_heating_native"),
)
present_thermal = [item for item in thermal_features if item[0] in compact]
if len(present_thermal) == 1:
label, capability = present_thermal[0]
coordinated_thermal = re.search(
r"(?:座椅)?(?:通风|加热)(?:和|与|以及|及|、)"
r"(?:打开|开启|启动|关闭|关掉|停止)?"
r"(?:座椅)?(?:通风|加热)",
compact,
)
shared_thermal_state: str | None = None
if coordinated_thermal is not None:
shared_prefix = compact[: coordinated_thermal.start()]
if re.search(r"关闭|关掉|停止|取消|不要|别开", shared_prefix):
shared_thermal_state = "off"
elif re.search(r"打开|开启|启动|启用", shared_prefix):
shared_thermal_state = "on"
for label, capability in present_thermal:
thermal_text = _feature_context(compact, label)
thermal_grade = _grade_alias(thermal_text, include_numeric=False)
parameters: dict[str, Any] | None = None
@@ -639,9 +652,11 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
parameters = {"state": "on", "level": 1}
elif thermal_grade == "low" or re.search(
r"低速|(?:2|二)(?:档|级)", thermal_text
):
) or re.search(r"打开|开启|启动|启用", thermal_text):
parameters = {"state": "on", "level": 2}
elif re.search(r"打开|开启|启动|启用", thermal_text):
elif shared_thermal_state == "off":
parameters = {"state": "off"}
elif shared_thermal_state == "on":
parameters = {"state": "on", "level": 2}
if parameters is not None:
actions.append(_action(
@@ -881,24 +896,11 @@ def _parse_chinese_window_commands(compact: str) -> list[dict[str, Any]]:
window_text = f"{window_text},{clauses[index + 1]}"
targets, explicit_window_target = _window_targets(window_text)
position = re.search(
r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)"
r"(\d{1,3})%",
window_text,
)
half_position = bool(
re.search(
r"(?:开启|打开|开到|开度)?(?:一半|半开)",
window_text,
)
)
position_pct = (
int(position[1]) if position else 50 if half_position else None
)
position_pct = parse_percentage_value(window_text)
if (
position_pct is not None
and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
and position_pct <= 100
and 0 <= position_pct <= 100
):
reason = (
"用户指定了单个车窗的绝对开度。"
+163 -1
View File
@@ -1689,6 +1689,139 @@ class SeatDemoService:
_direct_can_batch=direct_can_batch,
)
def _adapt_speech_understanding(
self,
understanding: dict[str, Any],
context: dict[str, Any],
vehicle_profile: Any,
) -> dict[str, Any]:
"""Revalidate a server-owned speech result into the normal control IR.
Speech is allowed to skip a second natural-language parse, but it never
skips the authoritative capability catalog or ModelPlanAdapter. Native
CAN actions and configured-domain/seat actions are routed through the
same structures used by the rest of one-sentence orchestration.
"""
if understanding.get("execution_eligible") is not True:
raise ValueError("speech understanding is not execution eligible")
commands = understanding.get("commands")
if not isinstance(commands, list) or not commands:
raise ValueError("speech understanding has no executable commands")
native_actions: list[dict[str, Any]] = []
model_requests: list[dict[str, Any]] = []
detected_domains: list[str] = []
def add_payload(payload: object, source_text: str) -> None:
if not isinstance(payload, dict):
raise ValueError("speech command has no validated executor payload")
domain = str(payload.get("domain", ""))
capability = str(payload.get("capability", ""))
target = str(payload.get("target", ""))
operation = str(payload.get("operation", "set"))
parameters = payload.get("parameters")
if not domain or not capability or not target or not isinstance(parameters, dict):
raise ValueError("speech executor payload is incomplete")
definitions = tuple(
definition
for definition in self.capability_registry.find(capability)
if definition.domain == domain
)
if len(definitions) != 1:
raise ValueError(
f"speech capability is not uniquely registered: {domain}.{capability}"
)
if domain not in detected_domains:
detected_domains.append(domain)
# Catalog availability describes where a capability is declared,
# not necessarily which executor owns it. Roof, climate and
# ambient capabilities are declared in domain catalogs but some
# are byte-exact NativeCanRuntime operations. Keep this routing in
# lockstep with CapabilityResolver._executor_binding().
if capability in self.native_can_runtime.supported_capabilities:
native_actions.append(
{
"domain": domain,
"capability": capability,
"target": target,
"operation": operation,
"parameters": deepcopy(parameters),
"parse_source": "speech_semantic",
"source_span": source_text,
"reason": "语音语义层已按真实能力目录解析并由服务端重新校验。",
}
)
return
model_requests.append(
{
"domain": domain,
"capability": capability,
"target_scope": target,
"operation": operation,
"parameters": [
{"name": name, "value": value}
for name, value in parameters.items()
],
"reason": "语音语义层已按真实能力目录解析并由服务端重新校验。",
}
)
for command in commands:
if not isinstance(command, dict):
raise ValueError("speech command must be an object")
if (
command.get("status") != "active"
or command.get("negated") is True
or command.get("resolution_status") != "executable"
or command.get("validation_issues")
):
raise ValueError("speech command failed execution invariants")
source_text = str(command.get("source_text") or understanding.get("raw_text") or "")
payload = command.get("executor_payload")
if command.get("mode_profile_id"):
if not isinstance(payload, dict) or not isinstance(payload.get("operations"), list):
raise ValueError("speech mode command has no validated operations")
for operation_payload in payload["operations"]:
add_payload(operation_payload, source_text)
else:
add_payload(payload, source_text)
plan = {
"detected_domains": detected_domains,
"requests": model_requests,
"ambiguities": [],
"decision_summary": [
"复用服务端语音理解结果;未对 cleanText 进行第二次自然语言解析。"
],
"confidence": min(
float(command.get("confidence", 0.0))
for command in commands
if isinstance(command, dict)
),
}
parsed = self.model_plan_adapter.adapt(
plan,
context=context,
vehicle_profile=vehicle_profile,
utterance=str(understanding.get("raw_text") or ""),
)
parsed["native_can_actions"] = native_actions
parsed.setdefault("semantic_constraints", [])
parsed.setdefault("unresolved_spans", [])
if parsed.get("ambiguities") or parsed.get("deferred_requests"):
raise ValueError("speech command no longer passes capability resolution")
mapped_count = (
len(parsed.get("actions", ()))
+ len(parsed.get("domain_actions", ()))
+ len(parsed.get("native_can_actions", ()))
)
expected_count = len(model_requests) + len(native_actions)
if mapped_count != expected_count:
raise ValueError("speech command mapping lost a validated capability mutation")
return parsed
def process(
self,
utterance: str,
@@ -1710,6 +1843,7 @@ class SeatDemoService:
_on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
_direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
_direct_mode_profile_id: str | None = None,
_speech_understanding: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
@@ -1736,6 +1870,7 @@ class SeatDemoService:
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
)
if control_transport == WIRELESS_MQTT_TRANSPORT:
# The head unit owns both physical CAN channels in wireless mode.
@@ -1758,6 +1893,7 @@ class SeatDemoService:
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
@@ -1780,6 +1916,7 @@ class SeatDemoService:
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -1801,6 +1938,7 @@ class SeatDemoService:
_on_commands_validated,
_direct_can_batch,
_direct_mode_profile_id,
_speech_understanding,
)
def _process_once(
@@ -1821,6 +1959,7 @@ class SeatDemoService:
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
direct_can_batch: tuple[list[str], dict[str, Any]] | None = None,
direct_mode_profile_id: str | None = None,
speech_understanding: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -1901,7 +2040,7 @@ class SeatDemoService:
[direct_mode_profile]
if direct_mode_profile is not None
else []
if direct_can_batch is not None
if direct_can_batch is not None or speech_understanding is not None
else self.mode_profiles.match_text(
utterance,
vehicle_id=vehicle_profile.id,
@@ -1951,6 +2090,16 @@ class SeatDemoService:
"can_commands": (
list(direct_can_batch[0]) if direct_can_batch is not None else []
),
"speech_understanding": (
{
"utterance_id": speech_understanding.get("utterance_id"),
"raw_text": speech_understanding.get("raw_text"),
"clean_text": speech_understanding.get("clean_text"),
"semantic_status": speech_understanding.get("semantic_status"),
}
if speech_understanding is not None
else None
),
}
started = time.perf_counter()
@@ -2042,6 +2191,19 @@ class SeatDemoService:
parsed = deepcopy(direct_can_batch[1])
provider_used = "direct_can_control"
provider_notice = None
elif speech_understanding is not None:
parsed = self._adapt_speech_understanding(
speech_understanding,
context,
vehicle_profile,
)
parsed = self._mark_control_parse_source(
parsed,
"speech_semantic",
overwrite=True,
)
provider_used = "speech_semantic"
provider_notice = "使用服务端缓存并重新校验的语音结构化意图。"
else:
parsed, provider_used, provider_notice = self._interpret(
utterance,