merge: integrate remote speech capabilities into feature/N7Web
This commit is contained in:
@@ -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 = (
|
||||
"用户指定了单个车窗的绝对开度。"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user