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,
@@ -0,0 +1,29 @@
"""Local speech-recognition infrastructure."""
from cognitive_agent.infrastructure.speech.assembler import TranscriptAssembler
from cognitive_agent.infrastructure.speech.contracts import (
SpeechCommandIntent,
UnderstandingTranscript,
)
from cognitive_agent.infrastructure.speech.semantic import (
SpeechIntentNormalization,
normalize_speech_intent,
)
from cognitive_agent.infrastructure.speech.sensevoice import (
SpeechRecognitionError,
SpeechTranscriber,
)
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
__all__ = [
"SpeechCommandIntent",
"SpeechIntentNormalization",
"SpeechRecognitionError",
"SpeechTranscriber",
"TranscriptAssembler",
"UnderstandingTranscript",
"UnderstandingTranscriptProcessor",
"normalize_speech_intent",
]
@@ -0,0 +1,156 @@
"""Idempotent assembly of partial and final ASR segment callbacks."""
from __future__ import annotations
import re
from dataclasses import replace
from cognitive_agent.infrastructure.speech.contracts import (
AssembledTranscript,
SpeechSegmentResult,
)
_PUNCTUATION = re.compile(r"^[,,。!?!?;;\s]+|[,,。!?!?;;\s]+$")
class TranscriptAssembler:
"""Maintain ordered segment state with replace/commit and overlap semantics."""
def __init__(self, utterance_id: str) -> None:
self.utterance_id = utterance_id
self._segments: dict[str, SpeechSegmentResult] = {}
self._duplicate_callbacks = 0
self._out_of_order_callbacks = 0
self._conflicts: list[str] = []
self._received = 0
self._last_sequence = -1
self._complete = False
def update(self, segment: SpeechSegmentResult) -> AssembledTranscript:
if segment.utterance_id != self.utterance_id:
raise ValueError("segment utterance_id does not match assembler")
if self._complete:
self._duplicate_callbacks += 1
return self.snapshot()
self._received += 1
segment = replace(segment, received_index=self._received)
if segment.sequence < self._last_sequence:
self._out_of_order_callbacks += 1
self._last_sequence = max(self._last_sequence, segment.sequence)
previous = self._segments.get(segment.segment_id)
if previous is not None:
if previous.is_final:
if segment.is_final and self._same_commit(previous, segment):
self._duplicate_callbacks += 1
else:
self._conflicts.append(
f"segment {segment.segment_id} changed after final commit"
)
return self.snapshot()
if self._same_callback(previous, segment):
self._duplicate_callbacks += 1
return self.snapshot()
self._segments[segment.segment_id] = segment
return self.snapshot()
def finish(self) -> AssembledTranscript:
self._complete = True
return self.snapshot()
def reset(self, utterance_id: str) -> None:
self.__init__(utterance_id)
def snapshot(self) -> AssembledTranscript:
ordered = sorted(
self._segments.values(),
key=lambda item: (item.sequence, item.start_ms, item.received_index),
)
finals = tuple(item for item in ordered if item.is_final)
partials = tuple(item for item in ordered if not item.is_final)
text, overlaps = self._assemble_text(finals, partials)
return AssembledTranscript(
utterance_id=self.utterance_id,
text=text,
final_segments=finals,
partial_segments=partials,
duplicate_callbacks=self._duplicate_callbacks,
out_of_order_callbacks=self._out_of_order_callbacks,
conflicts=tuple(dict.fromkeys(self._conflicts)),
overlaps_removed=tuple(overlaps),
complete=self._complete,
)
@staticmethod
def _same_commit(first: SpeechSegmentResult, second: SpeechSegmentResult) -> bool:
return (
first.sequence == second.sequence
and first.start_ms == second.start_ms
and first.end_ms == second.end_ms
and first.text == second.text
)
@classmethod
def _same_callback(cls, first: SpeechSegmentResult, second: SpeechSegmentResult) -> bool:
return first.is_final == second.is_final and cls._same_commit(first, second)
@classmethod
def _assemble_text(
cls,
finals: tuple[SpeechSegmentResult, ...],
partials: tuple[SpeechSegmentResult, ...],
) -> tuple[str, list[str]]:
display = [*finals]
final_ids = {item.segment_id for item in finals}
display.extend(item for item in partials if item.segment_id not in final_ids)
display.sort(key=lambda item: (item.sequence, item.start_ms, item.received_index))
if not display:
return "", []
assembled = display[0].text.strip()
previous = display[0]
overlaps: list[str] = []
for current in display[1:]:
addition = current.text.strip()
overlap = cls._safe_overlap(assembled, addition, previous, current)
if overlap:
overlaps.append(overlap)
addition = addition[len(overlap) :]
assembled = cls._join(assembled, addition)
previous = current
return assembled.strip(), overlaps
@staticmethod
def _safe_overlap(
left: str,
right: str,
previous: SpeechSegmentResult,
current: SpeechSegmentResult,
) -> str:
"""Remove only boundary overlap supported by audio overlap or a long phrase."""
left_body = _PUNCTUATION.sub("", left)
right_body = _PUNCTUATION.sub("", right)
maximum = min(len(left_body), len(right_body), 24)
for length in range(maximum, 1, -1):
candidate = left_body[-length:]
if candidate != right_body[:length]:
continue
audio_evidence = current.audio_overlap_ms > 0 or (
current.start_ms < previous.end_ms
)
if length >= 4 or (audio_evidence and length >= 2):
return candidate
return ""
@staticmethod
def _join(left: str, right: str) -> str:
if not right:
return left
if not left:
return right
if left[-1] in ",,。!?!?;;" or right[0] in ",,。!?!?;;":
return f"{left}{right}"
return f"{left},{right}"
@@ -0,0 +1,396 @@
"""Resolve speech slots against the application's authoritative capability catalogs."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from cognitive_agent.application.capability_registry import (
CapabilityDefinition,
CapabilityRegistry,
)
from cognitive_agent.application.mode_profile import ModeProfile, ModeProfileRegistry
from cognitive_agent.application.native_can_runtime import NativeCanRuntime
ResolutionStatus = Literal[
"executable",
"unsupported",
"ambiguous",
"incomplete",
]
@dataclass(frozen=True)
class CapabilityResolution:
"""Catalog-backed outcome for one semantic mutation."""
status: ResolutionStatus
definition: CapabilityDefinition | None
issues: tuple[str, ...] = ()
executor_binding: str | None = None
executor_payload: dict[str, Any] | None = None
intent_ir_action: dict[str, Any] | None = None
@dataclass(frozen=True)
class ModeResolution:
"""A repository mode profile and whether it applies to the requested target."""
status: ResolutionStatus
profile: ModeProfile | None
issues: tuple[str, ...] = ()
executor_payloads: tuple[dict[str, Any], ...] = ()
# These are lexical-to-schema selectors, not a second capability registry. A
# selector can only resolve a capability that is present in CapabilityRegistry.
_PROPERTY_SELECTORS: dict[str, tuple[str, ...]] = {
"hvac.power": ("set_power",),
"hvac.temperature": ("temperature",),
"hvac.fan_level": ("fan_level",),
"seat.ventilation": ("ventilation",),
"seat.heating": ("heating",),
"seat.longitudinal_position": ("longitudinal_position",),
"seat.backrest_recline": ("backrest",),
"seat.cushion_tilt": ("cushion_tilt",),
"seat.legrest_height": ("legrest_height",),
"seat.legrest_extension": ("legrest_extension",),
"window.motion": ("set_motion",),
"window.position": ("set_position",),
"roof.shade_motion": ("shade_motion",),
"roof.shade_position": ("shade_position",),
"roof.transmittance": ("transmittance",),
"ambient.power": ("set_power",),
"ambient.color": ("illumination", "color_index"),
"ambient.brightness": ("illumination", "brightness_level"),
"reading_light.power": ("reading_power",),
"indoor_light.power": ("indoor_power",),
"front_light.power": ("front_light", "headlight", "head_lamp"),
}
class CapabilityResolver:
"""Adapt natural-language slots to the capability/executor facts in code."""
def __init__(self, repository_root: Path) -> None:
self.root = Path(repository_root)
self.registry = CapabilityRegistry(
self.root,
{"seat", "climate", "roof", "ambient_light"},
)
self.native_runtime = NativeCanRuntime(self.root)
self.native_supported = self.native_runtime.supported_capabilities
self.mode_profiles = ModeProfileRegistry(self.root / "config" / "modes")
def definitions(self) -> tuple[CapabilityDefinition, ...]:
return self.registry.definitions()
def executor_binding_for(self, definition: CapabilityDefinition) -> str:
"""Return the runtime that actually owns an executable definition."""
return self._executor_binding(definition)
@staticmethod
def semantic_property_for(capability: str, parameters: dict[str, Any] | None = None) -> str:
"""Project an authoritative capability into the speech display vocabulary.
This is deliberately a projection, not an availability table: whether the
capability exists and can execute still comes exclusively from the catalog
and executor runtime.
"""
values = parameters or {}
if capability == "seat.set_driver_axis_native":
return {
"horizontal": "seat.longitudinal_position",
"backrest": "seat.backrest_recline",
"basin": "seat.cushion_tilt",
"height": "seat.height",
}.get(str(values.get("axis")), capability)
projections = (
("set_driver_ventilation", "seat.ventilation"),
("seat.set_ventilation", "seat.ventilation"),
("set_driver_heating", "seat.heating"),
("seat.set_heating", "seat.heating"),
("set_backrest_recline", "seat.backrest_recline"),
("set_longitudinal_position", "seat.longitudinal_position"),
("set_cushion_tilt", "seat.cushion_tilt"),
("set_legrest_height", "seat.legrest_height"),
("set_legrest_extension", "seat.legrest_extension"),
("window.set_motion", "window.motion"),
("window.set_position", "window.position"),
("roof.set_shade_motion", "roof.shade_motion"),
("roof.set_shade_position", "roof.shade_position"),
("roof.set_transmittance", "roof.transmittance"),
("ambient_light.set_power", "ambient.power"),
("ambient_light.set_illumination", "ambient.color"),
("ambient_light.set_color_index", "ambient.color_index"),
("ambient_light.set_brightness_level", "ambient.brightness"),
("climate.set_power", "hvac.power"),
("climate.set_temperature", "hvac.temperature"),
("climate.set_fan_level", "hvac.fan_level"),
("interior_light.set_reading_power", "reading_light.power"),
("interior_light.set_indoor_power", "indoor_light.power"),
)
return next(
(property_name for token, property_name in projections if token in capability),
capability,
)
def resolve_action(self, action: dict[str, Any], *, source_text: str) -> CapabilityResolution:
"""Validate an action produced by the application's existing intent parser."""
capability = str(action.get("capability", ""))
return self.resolve(
domain=str(action.get("domain", capability.partition(".")[0])),
semantic_property=self.semantic_property_for(
capability, dict(action.get("parameters", {}))
),
target_scope=str(action.get("target", action.get("target_scope", "vehicle"))),
target_device=None,
operation=str(action.get("operation", "set")),
parameters=dict(action.get("parameters", {})),
source_text=source_text,
preferred_capability=capability,
)
def match_mode(self, text: str) -> ModeProfile | None:
matches = self.mode_profiles.match_text(text, vehicle_id="denza_n7")
return matches[0] if len(matches) == 1 else None
def resolve_mode(
self,
text: str,
*,
target_scope: str | None,
target_explicit: bool,
) -> ModeResolution:
matches = self.mode_profiles.match_text(text, vehicle_id="denza_n7")
if not matches:
return ModeResolution("unsupported", None, ("mode_profile_not_registered",))
if len(matches) > 1:
return ModeResolution("ambiguous", None, ("multiple_mode_profiles_matched",))
profile = matches[0]
operation_scopes = {operation.target_scope for operation in profile.operations}
if target_explicit and target_scope not in operation_scopes:
scopes = ",".join(sorted(operation_scopes))
return ModeResolution(
"unsupported",
profile,
(f"mode_profile.{profile.id}仅支持范围{scopes}",),
)
payloads: list[dict[str, Any]] = []
issues: list[str] = []
for operation in profile.operations:
resolution = self.resolve(
domain=operation.domain,
semantic_property="",
target_scope=operation.target_scope,
target_device=None,
operation=operation.operation,
parameters=operation.parameters,
source_text=text,
preferred_capability=operation.capability,
)
if resolution.status != "executable" or resolution.executor_payload is None:
issues.extend(resolution.issues or ("mode_operation_not_executable",))
continue
payloads.append(resolution.executor_payload)
if issues or len(payloads) != len(profile.operations):
return ModeResolution("unsupported", profile, tuple(dict.fromkeys(issues)))
return ModeResolution("executable", profile, executor_payloads=tuple(payloads))
def resolve(
self,
*,
domain: str,
semantic_property: str,
target_scope: str,
target_device: str | None,
operation: str,
parameters: dict[str, Any],
source_text: str,
preferred_capability: str | None = None,
) -> CapabilityResolution:
candidates = list(self.registry.definitions(domain=domain))
if preferred_capability:
candidates = [item for item in candidates if item.name == preferred_capability]
else:
selectors = _PROPERTY_SELECTORS.get(semantic_property, ())
candidates = [
item for item in candidates if any(selector in item.name for selector in selectors)
]
if not candidates:
return CapabilityResolution(
"unsupported",
None,
(f"capability_not_registered:{domain}.{semantic_property}",),
)
parameter_names = set(parameters)
ranked = sorted(
candidates,
key=lambda item: self._score(
item,
target_scope=target_scope,
target_device=target_device,
parameter_names=parameter_names,
),
reverse=True,
)
definition = ranked[0]
top_score = self._score(
definition,
target_scope=target_scope,
target_device=target_device,
parameter_names=parameter_names,
)
equally_ranked = [
item
for item in ranked
if self._score(
item,
target_scope=target_scope,
target_device=target_device,
parameter_names=parameter_names,
)
== top_score
]
if len(equally_ranked) > 1 and not preferred_capability:
return CapabilityResolution(
"ambiguous",
None,
("multiple_capabilities_match:" + ",".join(item.name for item in equally_ranked),),
)
issues = self._validate(definition, target_scope, target_device, parameters)
missing = [issue for issue in issues if issue.startswith("required_parameter_missing:")]
if missing:
status: ResolutionStatus = "incomplete"
elif issues:
status = "unsupported"
elif definition.availability == "catalog_only":
status = "unsupported"
issues.append(f"capability_catalog_only:{definition.name}")
elif (
definition.availability == "native_can" and definition.name not in self.native_supported
):
status = "unsupported"
issues.append(f"native_executor_not_implemented:{definition.name}")
else:
status = "executable"
binding = self._executor_binding(definition) if status == "executable" else None
payload = None
intent_action = None
if status == "executable":
executor_operation = "set" if operation in {"on", "off", "open", "close"} else operation
payload = {
"domain": domain,
"capability": definition.name,
"target": target_scope,
"operation": executor_operation,
"parameters": dict(parameters),
"parse_source": "local_rule",
}
intent_action = {
"domain": domain,
"capability": definition.name,
"target": {
"device": definition.target_devices[0] if definition.target_devices else domain,
"zone": target_scope,
},
"operation": executor_operation,
"parameters": dict(parameters),
"source_span": source_text,
"risk_level": str(definition.spec.get("risk_level", "high")),
"confirmation_policy": str(
definition.spec.get("confirmation_policy", "on_ambiguity")
),
}
return CapabilityResolution(
status,
definition,
tuple(dict.fromkeys(issues)),
binding,
payload,
intent_action,
)
@staticmethod
def _score(
definition: CapabilityDefinition,
*,
target_scope: str,
target_device: str | None,
parameter_names: set[str],
) -> tuple[int, int, int, int, int]:
specs = {str(item["name"]): item for item in definition.parameters}
accepted = set(specs)
missing_required = sum(
1
for name, spec in specs.items()
if spec.get("required") and name not in parameter_names
)
return (
int(target_scope in definition.target_scopes),
int(not parameter_names - accepted),
-missing_required,
int(target_device is not None and target_device in definition.target_devices),
int(definition.availability == "native_can"),
)
@staticmethod
def _validate(
definition: CapabilityDefinition,
target_scope: str,
target_device: str | None,
parameters: dict[str, Any],
) -> list[str]:
issues: list[str] = []
if definition.target_scopes and target_scope not in definition.target_scopes:
issues.append(f"{definition.name}不支持范围{target_scope}")
if (
target_device
and definition.target_devices
and target_device not in definition.target_devices
):
issues.append(f"{definition.name}不支持设备{target_device}")
specs = {str(item["name"]): item for item in definition.parameters}
for name in parameters.keys() - specs.keys():
issues.append(f"unknown_parameter:{definition.name}:{name}")
for name, spec in specs.items():
if spec.get("required") and name not in parameters:
issues.append(f"required_parameter_missing:{definition.name}:{name}")
continue
if name not in parameters:
continue
value = parameters[name]
expected = str(spec.get("type"))
numeric = isinstance(value, (int, float)) and not isinstance(value, bool)
valid_type = {
"number": numeric,
"integer": numeric and float(value).is_integer(),
"string": isinstance(value, str),
"boolean": isinstance(value, bool),
"object": isinstance(value, dict),
"array": isinstance(value, list),
}.get(expected, False)
if not valid_type:
issues.append(f"invalid_parameter_type:{definition.name}:{name}:{expected}")
continue
if numeric and spec.get("minimum") is not None and value < spec["minimum"]:
issues.append(f"{definition.name}.{name}低于工程下限{spec['minimum']}")
if numeric and spec.get("maximum") is not None and value > spec["maximum"]:
issues.append(f"{definition.name}.{name}高于工程上限{spec['maximum']}")
allowed = spec.get("allowed_values")
if allowed is not None and value not in allowed:
issues.append(f"{definition.name}.{name}不在工程允许值中")
return issues
def _executor_binding(self, definition: CapabilityDefinition) -> str:
if definition.name in self.native_supported:
return "NativeCanRuntime/NativeCanExecutor"
return definition.executor_binding
@@ -0,0 +1,233 @@
"""Stable data contracts for the speech-understanding pipeline."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
CommandStatus = Literal["active", "cancelled"]
ResolutionStatus = Literal["executable", "unsupported", "ambiguous", "incomplete"]
TranscriptStatus = Literal[
"recognized_executable",
"recognized_unsupported",
"ambiguous",
"incomplete",
"no_command",
]
@dataclass(frozen=True)
class SpeechCommandIntent:
"""A conservative speech intent aligned with the project's capability catalog."""
domain: str
capability: str | None
target_scope: str
target_device: str
property_name: str
operation: str
parameters: dict[str, Any]
value: Any
unit: str | None
qualifiers: dict[str, Any]
negated: bool
confidence: float
source_text: str
status: CommandStatus = "active"
resolution_status: ResolutionStatus = "executable"
validation_issues: tuple[str, ...] = ()
executor_binding: str | None = None
executor_payload: dict[str, Any] | None = None
intent_ir_action: dict[str, Any] | None = None
mode_profile_id: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"domain": self.domain,
"capability": self.capability,
"target_scope": self.target_scope,
"target_device": self.target_device,
"property": self.property_name,
"operation": self.operation,
"parameters": dict(self.parameters),
"value": self.value,
"unit": self.unit,
"qualifiers": dict(self.qualifiers),
"negated": self.negated,
"confidence": round(self.confidence, 3),
"source_text": self.source_text,
"status": self.status,
"resolution_status": self.resolution_status,
"validation_issues": list(self.validation_issues),
"executor_binding": self.executor_binding,
"executor_payload": self.executor_payload,
"intent_ir_action": self.intent_ir_action,
"mode_profile_id": self.mode_profile_id,
}
@dataclass(frozen=True)
class CancelledSpeechFragment:
"""Text that was heard but must never become an active control."""
text: str
reason: str
marker: str = ""
commands: tuple[SpeechCommandIntent, ...] = ()
def to_dict(self) -> dict[str, Any]:
return {
"text": self.text,
"reason": self.reason,
"marker": self.marker,
"commands": [command.to_dict() for command in self.commands],
}
@dataclass(frozen=True)
class UnderstandingTranscript:
"""Raw ASR evidence plus display text and non-executable semantic analysis."""
raw_text: str
clean_text: str
commands: tuple[SpeechCommandIntent, ...] = ()
cancelled_fragments: tuple[CancelledSpeechFragment, ...] = ()
semantic_status: TranscriptStatus = "no_command"
uncertain: bool = False
reasons: tuple[str, ...] = ()
removed_fillers: tuple[str, ...] = ()
repair_resolved: bool = False
intent_domains: tuple[str, ...] = ()
changed: bool = False
processing_ms: float = 0.0
command_resolution_ms: float = 0.0
semantic_trace: tuple[dict[str, Any], ...] = ()
@property
def text(self) -> str:
"""Backward-compatible display-text alias."""
return self.clean_text
@property
def execution_eligible(self) -> bool:
return bool(self.commands) and not self.uncertain and all(
command.status == "active"
and not command.negated
and command.resolution_status == "executable"
and not command.validation_issues
for command in self.commands
)
def metadata(self) -> dict[str, object]:
return {
"changed": self.changed,
"removed_fillers": list(self.removed_fillers),
"repair_resolved": self.repair_resolved,
"intent_domains": list(self.intent_domains),
"uncertain": self.uncertain,
"semantic_status": self.semantic_status,
"reason": list(self.reasons),
"execution_eligible": self.execution_eligible,
"processing_ms": round(self.processing_ms, 3),
"command_resolution_ms": round(self.command_resolution_ms, 3),
}
def to_dict(self) -> dict[str, Any]:
return {
"raw_text": self.raw_text,
"clean_text": self.clean_text,
"commands": [command.to_dict() for command in self.commands],
"cancelled_fragments": [
fragment.to_dict() for fragment in self.cancelled_fragments
],
"semantic_status": self.semantic_status,
"uncertain": self.uncertain,
"reason": list(self.reasons),
"execution_eligible": self.execution_eligible,
"normalization": self.metadata(),
"semantic_trace": [dict(item) for item in self.semantic_trace],
}
@dataclass(frozen=True)
class SpeechSegmentResult:
"""One ordered partial or final ASR callback."""
utterance_id: str
segment_id: str
sequence: int
start_ms: int
end_ms: int
text: str
is_final: bool
audio_overlap_ms: int = 0
inference_ms: float = 0.0
received_index: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"utterance_id": self.utterance_id,
"segment_id": self.segment_id,
"sequence": self.sequence,
"start_ms": self.start_ms,
"end_ms": self.end_ms,
"raw_text": self.text,
"state": "final" if self.is_final else "partial",
"audio_overlap_ms": self.audio_overlap_ms,
"inference_ms": round(self.inference_ms, 3),
}
@dataclass(frozen=True)
class AssembledTranscript:
utterance_id: str
text: str
final_segments: tuple[SpeechSegmentResult, ...]
partial_segments: tuple[SpeechSegmentResult, ...]
duplicate_callbacks: int = 0
out_of_order_callbacks: int = 0
conflicts: tuple[str, ...] = ()
overlaps_removed: tuple[str, ...] = ()
complete: bool = False
@property
def uncertain(self) -> bool:
return bool(self.conflicts)
def to_dict(self) -> dict[str, Any]:
return {
"utterance_id": self.utterance_id,
"text": self.text,
"complete": self.complete,
"duplicate_callbacks": self.duplicate_callbacks,
"out_of_order_callbacks": self.out_of_order_callbacks,
"conflicts": list(self.conflicts),
"overlaps_removed": list(self.overlaps_removed),
"segments": [segment.to_dict() for segment in self.final_segments],
}
@dataclass
class SpeechPipelineTimings:
audio_read_ms: float = 0.0
vad_ms: float = 0.0
asr_ms: float = 0.0
assembly_ms: float = 0.0
semantic_ms: float = 0.0
recovery_ms: float = 0.0
total_ms: float = 0.0
extras: dict[str, float] = field(default_factory=dict)
def to_dict(self) -> dict[str, float]:
result = {
"audio_read_ms": round(self.audio_read_ms, 3),
"vad_ms": round(self.vad_ms, 3),
"asr_ms": round(self.asr_ms, 3),
"assembly_ms": round(self.assembly_ms, 3),
"semantic_ms": round(self.semantic_ms, 3),
"recovery_ms": round(self.recovery_ms, 3),
"total_ms": round(self.total_ms, 3),
}
result.update({key: round(value, 3) for key, value in self.extras.items()})
return result
@@ -0,0 +1,46 @@
"""Optional recovery recognizer contract for uncertain fast-path results."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from cognitive_agent.infrastructure.speech.contracts import UnderstandingTranscript
@dataclass(frozen=True)
class RecoveryResult:
raw_text: str
model: str
confidence: float
elapsed_ms: float
class RecoveryRecognizer(Protocol):
@property
def available(self) -> bool: ...
def recognize(
self,
samples: Sequence[float],
sample_rate: int,
fast_path: UnderstandingTranscript,
) -> RecoveryResult | None: ...
class DisabledRecoveryRecognizer:
"""Default recovery implementation; never downloads or guesses a model."""
@property
def available(self) -> bool:
return False
def recognize(
self,
samples: Sequence[float],
sample_rate: int,
fast_path: UnderstandingTranscript,
) -> RecoveryResult | None:
del samples, sample_rate, fast_path
return None
@@ -0,0 +1,61 @@
"""Backward-compatible entry point for structured speech understanding."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
@dataclass(frozen=True)
class SpeechIntentNormalization:
text: str
removed_fillers: tuple[str, ...]
repair_resolved: bool
intent_domains: tuple[str, ...]
changed: bool
commands: tuple[object, ...] = ()
cancelled_fragments: tuple[object, ...] = ()
uncertain: bool = False
reasons: tuple[str, ...] = ()
def metadata(self) -> dict[str, object]:
return {
"changed": self.changed,
"removed_fillers": list(self.removed_fillers),
"repair_resolved": self.repair_resolved,
"intent_domains": list(self.intent_domains),
"uncertain": self.uncertain,
"reason": list(self.reasons),
}
_DEFAULT_PROCESSOR: UnderstandingTranscriptProcessor | None = None
def normalize_speech_intent(
text: str, repository_root: Path | None = None
) -> SpeechIntentNormalization:
global _DEFAULT_PROCESSOR
if repository_root is not None:
processor = UnderstandingTranscriptProcessor(repository_root)
else:
if _DEFAULT_PROCESSOR is None:
repository = Path(__file__).resolve().parents[4]
_DEFAULT_PROCESSOR = UnderstandingTranscriptProcessor(repository)
processor = _DEFAULT_PROCESSOR
result = processor.process(text)
return SpeechIntentNormalization(
text=result.clean_text,
removed_fillers=result.removed_fillers,
repair_resolved=result.repair_resolved,
intent_domains=result.intent_domains,
changed=result.changed,
commands=result.commands,
cancelled_fragments=result.cancelled_fragments,
uncertain=result.uncertain,
reasons=result.reasons,
)
@@ -0,0 +1,493 @@
"""Low-latency local Chinese speech understanding backed by SenseVoiceSmall."""
from __future__ import annotations
import copy
import hashlib
import importlib.metadata
import importlib.util
import math
import os
import re
import sys
import threading
import time
import uuid
import wave
from array import array
from collections import OrderedDict
from collections.abc import Sequence
from dataclasses import replace
from io import BytesIO
from pathlib import Path
from typing import Any
from cognitive_agent.infrastructure.speech.assembler import TranscriptAssembler
from cognitive_agent.infrastructure.speech.contracts import (
SpeechPipelineTimings,
SpeechSegmentResult,
)
from cognitive_agent.infrastructure.speech.recovery import DisabledRecoveryRecognizer
from cognitive_agent.infrastructure.speech.tracing import SpeechTrace, env_flag
from cognitive_agent.infrastructure.speech.understanding import (
UnderstandingTranscriptProcessor,
)
from cognitive_agent.infrastructure.speech.vad import SpeechVadSegmenter
SENSEVOICE_MODEL_NAME = "SenseVoiceSmall"
SENSEVOICE_SAMPLE_RATE = 16_000
SENSEVOICE_MAX_DURATION_SECONDS = 30.0
SENSEVOICE_MIN_DURATION_SECONDS = 0.2
SENSEVOICE_MAX_WAV_BYTES = 2 * 1024 * 1024
_SENSEVOICE_TAG = re.compile(r"<\|[^|>]+\|>")
_UTTERANCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$")
class SpeechRecognitionError(RuntimeError):
"""A safe, user-facing speech-recognition failure."""
def __init__(self, code: str, message: str, *, status: int = 422) -> None:
super().__init__(message)
self.code = code
self.status = status
class SpeechTranscriber:
"""One recognizer, one VAD, and idempotent per-utterance orchestration."""
def __init__(self, repository_root: Path) -> None:
self.repository_root = repository_root
default_dir = repository_root / "artifacts" / "models" / "sensevoice-small"
self.model_dir = Path(os.getenv("SPEECH_MODEL_DIR", str(default_dir))).expanduser()
configured_model = os.getenv("SPEECH_MODEL_FILE", "model.onnx").strip() or "model.onnx"
self.model_path = self.model_dir / configured_model
self.tokens_path = self.model_dir / "tokens.txt"
self.language = os.getenv("SPEECH_LANGUAGE", "zh").strip() or "zh"
self.use_itn = env_flag("SPEECH_USE_ITN", True)
default_threads = min(8, max(2, (os.cpu_count() or 4) // 2))
self.num_threads = max(
1, min(16, int(os.getenv("SPEECH_NUM_THREADS", str(default_threads))))
)
self.provider = os.getenv("SPEECH_PROVIDER", "cpu").strip().lower() or "cpu"
self.hr_lexicon = Path(os.getenv("SPEECH_HR_LEXICON", "")).expanduser()
self.hr_rule_fsts = Path(os.getenv("SPEECH_HR_RULE_FSTS", "")).expanduser()
self.processor = UnderstandingTranscriptProcessor(repository_root)
self.segmenter = SpeechVadSegmenter(repository_root)
self.recovery = DisabledRecoveryRecognizer()
self._recognizer: Any | None = None
self._load_lock = threading.Lock()
self._decode_lock = threading.Lock()
self._cache_lock = threading.Lock()
self._cache: OrderedDict[str, tuple[str, dict[str, object]]] = OrderedDict()
self._inflight: dict[str, threading.Event] = {}
def status(self) -> dict[str, object]:
runtime_installed = importlib.util.find_spec("sherpa_onnx") is not None
model_installed = self.model_path.is_file() and self.tokens_path.is_file()
try:
runtime_version = importlib.metadata.version("sherpa-onnx")
except importlib.metadata.PackageNotFoundError:
runtime_version = ""
homophone_enabled = self.hr_lexicon.is_file() and self.hr_rule_fsts.is_file()
return {
"enabled": runtime_installed and model_installed,
"runtime_installed": runtime_installed,
"runtime_version": runtime_version,
"model_installed": model_installed,
"loaded": self._recognizer is not None,
"model": SENSEVOICE_MODEL_NAME,
"model_file": self.model_path.name,
"language": self.language,
"use_itn": self.use_itn,
"sample_rate": SENSEVOICE_SAMPLE_RATE,
"max_duration_seconds": SENSEVOICE_MAX_DURATION_SECONDS,
"provider": self.provider,
"num_threads": self.num_threads,
"decoder": "offline-sensevoice-final-only",
"vad": self.segmenter.status(),
"homophone_replacer": {
"enabled": homophone_enabled,
"lexicon": str(self.hr_lexicon) if homophone_enabled else "",
"rule_fsts": str(self.hr_rule_fsts) if homophone_enabled else "",
},
"recovery_recognizer": {"available": self.recovery.available},
"debug_logging": env_flag("SPEECH_DEBUG"),
}
def transcribe_wav(
self, content: bytes, *, utterance_id: str | None = None
) -> dict[str, object]:
resolved_id = self._resolve_utterance_id(utterance_id)
digest = hashlib.sha256(content).hexdigest()
cached = self._claim_or_wait(resolved_id, digest)
if cached is not None:
cached["idempotent_replay"] = True
return cached
try:
result = self._transcribe_wav(content, resolved_id)
with self._cache_lock:
self._cache[resolved_id] = (digest, copy.deepcopy(result))
self._cache.move_to_end(resolved_id)
while len(self._cache) > 64:
self._cache.popitem(last=False)
return result
finally:
with self._cache_lock:
event = self._inflight.pop(resolved_id, None)
if event is not None:
event.set()
def cached_result(self, utterance_id: str) -> dict[str, object]:
"""Return a server-owned recognition result for a later control request.
The browser submits only the opaque utterance id. Executor payloads are
recovered from this bounded server cache so client-side JSON can never be
promoted into a trusted control plan.
"""
resolved_id = self._resolve_utterance_id(utterance_id)
with self._cache_lock:
cached = self._cache.get(resolved_id)
if cached is None:
raise SpeechRecognitionError(
"speech_result_expired",
"语音理解结果已过期,请重新听写后再执行。",
status=409,
)
self._cache.move_to_end(resolved_id)
return copy.deepcopy(cached[1])
def _transcribe_wav(self, content: bytes, utterance_id: str) -> dict[str, object]:
trace = SpeechTrace(utterance_id)
timings = SpeechPipelineTimings()
total_started = time.perf_counter()
read_started = time.perf_counter()
sample_rate, original_samples = self._read_pcm16_wav(content)
samples = self._resample(original_samples, sample_rate, SENSEVOICE_SAMPLE_RATE)
timings.audio_read_ms = (time.perf_counter() - read_started) * 1000
duration_seconds = len(samples) / SENSEVOICE_SAMPLE_RATE
self._validate_duration(duration_seconds)
rms = math.sqrt(math.fsum(sample * sample for sample in samples) / len(samples))
trace.event(
"audio_preprocessed",
sampleRate=SENSEVOICE_SAMPLE_RATE,
sourceSampleRate=sample_rate,
durationMs=round(duration_seconds * 1000),
rms=round(rms, 6),
)
if rms < 0.001:
raise SpeechRecognitionError(
"no_speech", "未检测到清晰的说话声,请靠近麦克风重试。"
)
segmentation = self.segmenter.segment(samples)
timings.vad_ms = segmentation.elapsed_ms
trace.event(
"vad_complete",
usedVad=segmentation.used_vad,
fallbackReason=segmentation.fallback_reason,
segmentCount=len(segmentation.segments),
segments=[
{
"segmentId": item.segment_id,
"startMs": item.start_ms,
"endMs": item.end_ms,
"audioOverlapMs": item.audio_overlap_ms,
}
for item in segmentation.segments
],
stageMs=round(segmentation.elapsed_ms, 3),
)
if segmentation.fallback_reason == "vad_found_no_segment":
raise SpeechRecognitionError(
"no_speech", "VAD 未检测到可靠语音,请靠近麦克风重试。"
)
recognizer = self._get_recognizer()
assembler = TranscriptAssembler(utterance_id)
labels: dict[str, str] = {}
asr_started = time.perf_counter()
with self._decode_lock:
for segment in segmentation.segments:
segment_started = time.perf_counter()
try:
stream = recognizer.create_stream()
stream.accept_waveform(SENSEVOICE_SAMPLE_RATE, segment.samples)
recognizer.decode_stream(stream)
native_result = stream.result
except Exception as exc: # pragma: no cover - native errors vary
raise SpeechRecognitionError(
"inference_failed",
"语音模型暂时无法完成转写,请稍后重试。",
status=503,
) from exc
inference_ms = (time.perf_counter() - segment_started) * 1000
raw_segment = self._clean_text(str(getattr(native_result, "text", "")))
labels = {
"language": self._clean_label(
getattr(native_result, "lang", self.language)
),
"emotion": self._clean_label(getattr(native_result, "emotion", "")),
"event": self._clean_label(getattr(native_result, "event", "")),
}
callback = SpeechSegmentResult(
utterance_id=utterance_id,
segment_id=segment.segment_id,
sequence=segment.sequence,
start_ms=segment.start_ms,
end_ms=segment.end_ms,
text=raw_segment,
is_final=True,
audio_overlap_ms=segment.audio_overlap_ms,
inference_ms=inference_ms,
)
before = assembler.snapshot().text
assembled = assembler.update(callback)
trace.event(
"asr_callback",
segmentId=segment.segment_id,
sequence=segment.sequence,
segmentStartMs=segment.start_ms,
segmentEndMs=segment.end_ms,
state="final",
rawAsrResult=raw_segment,
beforeAssembler=before,
assembledText=assembled.text,
inferenceMs=round(inference_ms, 3),
)
timings.asr_ms = (time.perf_counter() - asr_started) * 1000
assembly_started = time.perf_counter()
assembled = assembler.finish()
timings.assembly_ms = (time.perf_counter() - assembly_started) * 1000
raw_text = assembled.text.strip()
if not raw_text:
raise SpeechRecognitionError(
"no_transcript", "没有识别出有效文字,请重新说一次。"
)
semantic_started = time.perf_counter()
understanding = self.processor.process(raw_text)
timings.semantic_ms = (time.perf_counter() - semantic_started) * 1000
timings.extras["command_resolution_ms"] = understanding.command_resolution_ms
if assembled.uncertain:
understanding = replace(
understanding,
uncertain=True,
reasons=tuple(
dict.fromkeys((*understanding.reasons, "segment_assembly_conflict"))
),
)
trace.event(
"understanding_complete",
rawText=raw_text,
cleanText=understanding.clean_text,
commands=[command.to_dict() for command in understanding.commands],
cancelledFragments=[
fragment.to_dict() for fragment in understanding.cancelled_fragments
],
semanticStatus=understanding.semantic_status,
semanticTrace=list(understanding.semantic_trace),
uncertain=understanding.uncertain,
reason=list(understanding.reasons),
stageMs=round(timings.semantic_ms, 3),
)
if understanding.uncertain and self.recovery.available:
recovery_started = time.perf_counter()
recovered = self.recovery.recognize(
samples, SENSEVOICE_SAMPLE_RATE, understanding
)
timings.recovery_ms = (time.perf_counter() - recovery_started) * 1000
trace.event(
"recovery_complete",
recovered=bool(recovered),
stageMs=round(timings.recovery_ms, 3),
)
timings.total_ms = (time.perf_counter() - total_started) * 1000
payload: dict[str, object] = {
"utterance_id": utterance_id,
"text": understanding.clean_text,
"clean_text": understanding.clean_text,
"raw_text": raw_text,
"commands": [command.to_dict() for command in understanding.commands],
"cancelled_fragments": [
fragment.to_dict() for fragment in understanding.cancelled_fragments
],
"semantic_status": understanding.semantic_status,
"semantic_trace": list(understanding.semantic_trace),
"uncertain": understanding.uncertain,
"reason": list(understanding.reasons),
"execution_eligible": understanding.execution_eligible,
"semantic_cleaned": understanding.changed,
"normalization": understanding.metadata(),
"assembly": assembled.to_dict(),
"segments": [item.to_dict() for item in assembled.final_segments],
**labels,
"duration_ms": round(duration_seconds * 1000),
"inference_ms": round(timings.asr_ms),
"timings": timings.to_dict(),
"model": SENSEVOICE_MODEL_NAME,
"provider": self.provider,
"idempotent_replay": False,
}
debug_events = trace.response_events()
if debug_events:
payload["debug_trace"] = debug_events
return payload
def _claim_or_wait(
self, utterance_id: str, digest: str
) -> dict[str, object] | None:
while True:
with self._cache_lock:
cached = self._cache.get(utterance_id)
if cached is not None:
cached_digest, payload = cached
if cached_digest != digest:
raise SpeechRecognitionError(
"utterance_id_conflict",
"同一 utteranceId 收到了不同录音,已拒绝重复提交。",
status=409,
)
self._cache.move_to_end(utterance_id)
return copy.deepcopy(payload)
event = self._inflight.get(utterance_id)
if event is None:
self._inflight[utterance_id] = threading.Event()
return None
if not event.wait(timeout=50):
raise SpeechRecognitionError(
"duplicate_request_timeout", "重复语音请求等待超时。", status=503
)
def _get_recognizer(self) -> Any:
if self._recognizer is not None:
return self._recognizer
with self._load_lock:
if self._recognizer is not None:
return self._recognizer
if importlib.util.find_spec("sherpa_onnx") is None:
raise SpeechRecognitionError(
"runtime_unavailable",
"语音识别运行时未安装,请安装 speech 可选依赖。",
status=503,
)
if not self.model_path.is_file() or not self.tokens_path.is_file():
raise SpeechRecognitionError(
"model_unavailable", "SenseVoiceSmall 模型尚未部署。", status=503
)
try:
import sherpa_onnx
kwargs: dict[str, object] = {
"model": str(self.model_path),
"tokens": str(self.tokens_path),
"num_threads": self.num_threads,
"sample_rate": SENSEVOICE_SAMPLE_RATE,
"provider": self.provider,
"language": self.language,
"use_itn": self.use_itn,
"debug": env_flag("SPEECH_ASR_DEBUG"),
}
if self.hr_lexicon.is_file() and self.hr_rule_fsts.is_file():
kwargs["hr_lexicon"] = str(self.hr_lexicon)
kwargs["hr_rule_fsts"] = str(self.hr_rule_fsts)
self._recognizer = sherpa_onnx.OfflineRecognizer.from_sense_voice(
**kwargs
)
except Exception as exc: # pragma: no cover - native failures vary
raise SpeechRecognitionError(
"model_load_failed",
"SenseVoiceSmall 模型加载失败,请检查模型文件与运行时。",
status=503,
) from exc
return self._recognizer
@staticmethod
def _resolve_utterance_id(value: str | None) -> str:
if value is None or not value.strip():
return f"server-{uuid.uuid4()}"
candidate = value.strip()
if not _UTTERANCE_ID.fullmatch(candidate):
raise SpeechRecognitionError(
"invalid_utterance_id", "utteranceId 格式无效。", status=400
)
return candidate
@staticmethod
def _validate_duration(duration_seconds: float) -> None:
if duration_seconds < SENSEVOICE_MIN_DURATION_SECONDS:
raise SpeechRecognitionError("audio_too_short", "录音时间太短,请重新说一次。")
if duration_seconds > SENSEVOICE_MAX_DURATION_SECONDS + 0.5:
raise SpeechRecognitionError(
"audio_too_long",
f"单次听写不能超过 {int(SENSEVOICE_MAX_DURATION_SECONDS)} 秒。",
status=413,
)
@staticmethod
def _clean_text(text: str) -> str:
return re.sub(r"\s+", " ", _SENSEVOICE_TAG.sub("", text)).strip()
@staticmethod
def _clean_label(value: object) -> str:
return str(value or "").removeprefix("<|").removesuffix("|>").strip()
@staticmethod
def _resample(
samples: Sequence[float], input_rate: int, output_rate: int
) -> list[float]:
if input_rate == output_rate:
return list(samples)
output_length = max(1, round(len(samples) * output_rate / input_rate))
if output_length == 1:
return [float(samples[0])]
ratio = input_rate / output_rate
result: list[float] = []
for output_index in range(output_length):
position = min(len(samples) - 1, output_index * ratio)
left = int(position)
right = min(len(samples) - 1, left + 1)
fraction = position - left
result.append(
float(samples[left] * (1.0 - fraction) + samples[right] * fraction)
)
return result
@staticmethod
def _read_pcm16_wav(content: bytes) -> tuple[int, list[float]]:
if not content or len(content) > SENSEVOICE_MAX_WAV_BYTES:
raise SpeechRecognitionError(
"invalid_audio_size", "录音数据为空或超过大小限制。", status=413
)
try:
with wave.open(BytesIO(content), "rb") as source:
channels = source.getnchannels()
sample_width = source.getsampwidth()
sample_rate = source.getframerate()
frame_count = source.getnframes()
compression = source.getcomptype()
frames = source.readframes(frame_count)
except (EOFError, wave.Error) as exc:
raise SpeechRecognitionError("invalid_audio", "录音格式无效,请重新录制。") from exc
if compression != "NONE" or sample_width != 2:
raise SpeechRecognitionError("unsupported_audio", "仅支持 16 位 PCM WAV 录音。")
if channels < 1 or channels > 2 or sample_rate < 8_000 or sample_rate > 96_000:
raise SpeechRecognitionError(
"unsupported_audio", "录音声道数或采样率不受支持。"
)
pcm = array("h")
pcm.frombytes(frames)
if sys.byteorder != "little":
pcm.byteswap()
if channels == 2:
mono = [
(pcm[index] + pcm[index + 1]) / (2 * 32768.0)
for index in range(0, len(pcm) - 1, 2)
]
else:
mono = [sample / 32768.0 for sample in pcm]
if not mono:
raise SpeechRecognitionError("invalid_audio", "录音中没有音频采样。")
return sample_rate, mono
@@ -0,0 +1,59 @@
"""Opt-in structured diagnostics for one speech utterance."""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from dataclasses import dataclass, field
from typing import Any
LOGGER = logging.getLogger("cognitive_agent.speech")
_HANDLER_LOCK = threading.Lock()
def env_flag(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass
class SpeechTrace:
utterance_id: str
enabled: bool = field(default_factory=lambda: env_flag("SPEECH_DEBUG"))
include_response: bool = field(
default_factory=lambda: env_flag("SPEECH_DEBUG_RESPONSE")
)
started_at: float = field(default_factory=time.perf_counter)
events: list[dict[str, Any]] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.enabled or LOGGER.handlers:
return
with _HANDLER_LOCK:
if LOGGER.handlers:
return
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(message)s"))
LOGGER.addHandler(handler)
LOGGER.setLevel(logging.INFO)
LOGGER.propagate = False
def event(self, stage: str, **details: Any) -> None:
if not self.enabled:
return
payload = {
"utteranceId": self.utterance_id,
"stage": stage,
"elapsedMs": round((time.perf_counter() - self.started_at) * 1000, 3),
**details,
}
self.events.append(payload)
LOGGER.info("speech_trace %s", json.dumps(payload, ensure_ascii=False))
def response_events(self) -> list[dict[str, Any]]:
return list(self.events) if self.enabled and self.include_response else []
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
"""Sherpa-ONNX Silero VAD with conservative boundary padding."""
from __future__ import annotations
import importlib.util
import os
import threading
import time
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from cognitive_agent.infrastructure.speech.tracing import env_flag
@dataclass(frozen=True)
class VadAudioSegment:
segment_id: str
sequence: int
start_sample: int
end_sample: int
samples: tuple[float, ...]
audio_overlap_ms: int = 0
@property
def start_ms(self) -> int:
return round(self.start_sample / 16)
@property
def end_ms(self) -> int:
return round(self.end_sample / 16)
@dataclass(frozen=True)
class VadSegmentation:
segments: tuple[VadAudioSegment, ...]
elapsed_ms: float
used_vad: bool
fallback_reason: str = ""
class SpeechVadSegmenter:
"""Reuse one native VAD instance and reset its state per utterance."""
sample_rate = 16_000
def __init__(self, repository_root: Path) -> None:
default_model = repository_root / "artifacts" / "models" / "silero-vad" / "silero_vad.onnx"
self.model_path = Path(os.getenv("SPEECH_VAD_MODEL", str(default_model))).expanduser()
self.enabled = env_flag("SPEECH_VAD_ENABLED", True)
self.threshold = float(os.getenv("SPEECH_VAD_THRESHOLD", "0.45"))
self.min_speech_seconds = float(os.getenv("SPEECH_VAD_MIN_SPEECH", "0.16"))
self.min_silence_seconds = float(os.getenv("SPEECH_VAD_MIN_SILENCE", "0.65"))
self.max_speech_seconds = float(os.getenv("SPEECH_VAD_MAX_SPEECH", "12.0"))
self.pre_roll_seconds = float(os.getenv("SPEECH_VAD_PRE_ROLL", "0.25"))
self.post_roll_seconds = float(os.getenv("SPEECH_VAD_POST_ROLL", "0.35"))
self.buffer_seconds = float(os.getenv("SPEECH_VAD_BUFFER", "32"))
self.num_threads = max(1, min(4, int(os.getenv("SPEECH_VAD_THREADS", "1"))))
self._vad: object | None = None
self._lock = threading.Lock()
@property
def available(self) -> bool:
return (
self.enabled
and self.model_path.is_file()
and importlib.util.find_spec("sherpa_onnx") is not None
)
def status(self) -> dict[str, object]:
return {
"enabled": self.enabled,
"available": self.available,
"loaded": self._vad is not None,
"model_path": str(self.model_path),
"threshold": self.threshold,
"min_speech_seconds": self.min_speech_seconds,
"min_silence_seconds": self.min_silence_seconds,
"max_speech_seconds": self.max_speech_seconds,
"pre_roll_seconds": self.pre_roll_seconds,
"post_roll_seconds": self.post_roll_seconds,
}
def segment(self, samples: Sequence[float]) -> VadSegmentation:
started = time.perf_counter()
if not self.available:
return self._fallback(samples, started, "vad_unavailable")
with self._lock:
vad = self._get_vad()
try:
vad.reset()
chunk_size = 512
for offset in range(0, len(samples), chunk_size):
vad.accept_waveform(samples[offset : offset + chunk_size])
vad.flush()
raw_boundaries: list[tuple[int, int]] = []
while not vad.empty():
segment = vad.front
start = max(0, int(segment.start))
end = min(len(samples), start + len(segment.samples))
if end > start:
raw_boundaries.append((start, end))
vad.pop()
finally:
vad.reset()
if not raw_boundaries:
return self._fallback(samples, started, "vad_found_no_segment")
pre_roll = round(self.pre_roll_seconds * self.sample_rate)
post_roll = round(self.post_roll_seconds * self.sample_rate)
padded = [
(max(0, start - pre_roll), min(len(samples), end + post_roll))
for start, end in raw_boundaries
]
# Padding must not create overlapped audio submitted to two ASR streams.
merged: list[tuple[int, int]] = []
for start, end in padded:
if merged and start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
segments = tuple(
VadAudioSegment(
segment_id=f"segment-{sequence}",
sequence=sequence,
start_sample=start,
end_sample=end,
samples=tuple(samples[start:end]),
)
for sequence, (start, end) in enumerate(merged)
)
return VadSegmentation(
segments,
(time.perf_counter() - started) * 1000,
used_vad=True,
)
def _get_vad(self):
if self._vad is not None:
return self._vad
import sherpa_onnx
silero = sherpa_onnx.SileroVadModelConfig(
model=str(self.model_path),
threshold=self.threshold,
min_silence_duration=self.min_silence_seconds,
min_speech_duration=self.min_speech_seconds,
window_size=512,
max_speech_duration=self.max_speech_seconds,
)
config = sherpa_onnx.VadModelConfig(
silero_vad=silero,
sample_rate=self.sample_rate,
num_threads=self.num_threads,
provider="cpu",
debug=env_flag("SPEECH_VAD_DEBUG"),
)
self._vad = sherpa_onnx.VoiceActivityDetector(config, self.buffer_seconds)
return self._vad
@staticmethod
def _fallback(
samples: Sequence[float], started: float, reason: str
) -> VadSegmentation:
segment = VadAudioSegment(
"segment-0", 0, 0, len(samples), tuple(samples)
)
return VadSegmentation(
(segment,),
(time.perf_counter() - started) * 1000,
used_vad=False,
fallback_reason=reason,
)
@@ -0,0 +1,283 @@
"""Chinese lexical aliases used before authoritative capability resolution."""
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True)
class DeviceTerm:
canonical: str
domain: str
semantic_key: str
target_device: str
aliases: tuple[str, ...]
DEVICE_TERMS: tuple[DeviceTerm, ...] = (
DeviceTerm("空调", "climate", "hvac.power", "hvac", ("空调", "空调系统")),
DeviceTerm(
"空调温度",
"climate",
"hvac.temperature",
"hvac",
("空调温度", "温度"),
),
DeviceTerm(
"空调风量",
"climate",
"hvac.fan_level",
"hvac",
("空调风量", "风量", "风速"),
),
DeviceTerm(
"座椅靠背",
"seat",
"seat.backrest_recline",
"seat_backrest_motor",
("座椅靠背", "座椅的靠背", "靠背", "椅背"),
),
DeviceTerm(
"座椅",
"seat",
"seat.longitudinal_position",
"seat_longitudinal_motor",
("座椅", "坐椅", "座位"),
),
DeviceTerm(
"座椅坐垫",
"seat",
"seat.cushion_tilt",
"seat_cushion_tilt_motor",
("座椅坐垫", "坐垫", "坐盆"),
),
DeviceTerm(
"座椅腿托",
"seat",
"seat.legrest_height",
"seat_legrest_lift_motor",
("座椅腿托", "腿托", "脚托", "小腿托"),
),
DeviceTerm(
"座椅通风",
"seat",
"seat.ventilation",
"seat",
("座椅通风", "通风座椅"),
),
DeviceTerm(
"座椅加热",
"seat",
"seat.heating",
"seat",
("座椅加热", "加热座椅"),
),
DeviceTerm(
"车窗",
"window",
"window.motion",
"window",
("车窗", "窗户", "玻璃窗"),
),
DeviceTerm(
"遮阳帘",
"roof",
"roof.shade_motion",
"roof_shade",
("遮阳帘", "天幕帘", "天窗遮阳帘"),
),
DeviceTerm(
"天幕",
"roof",
"roof.transmittance",
"roof_glass",
("天幕", "天窗玻璃"),
),
DeviceTerm(
"氛围灯",
"ambient_light",
"ambient.power",
"ambient_light",
("氛围灯", "环境灯"),
),
DeviceTerm(
"阅读灯",
"interior_light",
"reading_light.power",
"reading_light",
("阅读灯",),
),
DeviceTerm(
"车内灯",
"interior_light",
"indoor_light.power",
"indoor_light",
("车内灯", "室内灯", "顶灯"),
),
DeviceTerm(
"前大灯",
"front_light",
"front_light.power",
"front_light",
("前大灯", "大灯", "前照灯"),
),
)
SCOPE_ALIASES: dict[str, tuple[str, ...]] = {
"driver": ("主驾", "主驾驶", "驾驶位", "司机位", "左前"),
"passenger": ("副驾", "副驾驶", "乘客位", "右前"),
"front_row": ("前排",),
"rear_row": ("后排",),
"rear_left": ("左后", "后排左侧"),
"rear_right": ("右后", "后排右侧"),
"vehicle": ("全车", "整车", "车内"),
"all": ("全部", "所有", "四个"),
}
ACTION_ALIASES: dict[str, tuple[str, ...]] = {
"on": ("打开", "开启", "启动", "开"),
"off": ("关闭", "关掉", "关上", "停止", "关"),
"set": (
"调整到",
"调整为",
"调节到",
"调节为",
"设置到",
"设置为",
"调到",
"调成",
"变成",
"设为",
"开到",
),
"forward": ("向前", "往前", "朝前", "前移", "前倾"),
"backward": ("向后", "往后", "朝后", "后移", "后仰"),
"up": ("向上", "往上", "升高", "抬高", "抬起"),
"down": ("向下", "往下", "降低", "降下"),
"adjust": ("调整", "调节", "移动", "调", "移"),
}
CORRECTION_MARKERS: tuple[str, ...] = (
"重新说",
"说错了",
"准确地说",
"应该是",
"我是说",
"更正为",
"更正成",
"不对",
"口误",
"改成",
"改为",
"重来",
)
CANCELLATION_MARKERS: tuple[str, ...] = ("算了", "取消", "作废")
POSITIVE_CONNECTORS: tuple[str, ...] = (
"然后",
"同时",
"并且",
"接着",
"随后",
"再",
"并",
)
NEGATION_PREFIXES: tuple[str, ...] = ("不要", "别", "不想", "无需", "不必", "禁止")
UNITS: tuple[str, ...] = ("摄氏度", "百分比", "毫米", "厘米", "毫秒", "分钟", "度", "%", "档", "秒")
COLOR_ALIASES: dict[str, tuple[str, ...]] = {
"#FF0000": ("红色", "红灯", "正红"),
"#00FF00": ("绿色", "绿灯"),
"#0000FF": ("蓝色", "蓝灯"),
"#FFFFFF": ("白色", "白灯"),
"#FFFF00": ("黄色", "黄灯"),
"#FFA500": ("橙色", "橘色"),
"#800080": ("紫色", "紫灯"),
}
def _alias_pattern(values: Iterable[str]) -> re.Pattern[str]:
ordered = sorted(set(values), key=len, reverse=True)
return re.compile("|".join(re.escape(value) for value in ordered))
DEVICE_ALIAS_PATTERN = _alias_pattern(
alias for term in DEVICE_TERMS for alias in term.aliases
)
ACTION_ALIAS_PATTERN = _alias_pattern(
alias for aliases in ACTION_ALIASES.values() for alias in aliases
)
SCOPE_ALIAS_PATTERN = _alias_pattern(
alias for aliases in SCOPE_ALIASES.values() for alias in aliases
)
def device_terms_in(text: str) -> list[tuple[int, int, DeviceTerm, str]]:
"""Return longest non-overlapping device terms in source order."""
alias_map = {
alias: term
for term in DEVICE_TERMS
for alias in term.aliases
}
return [
(match.start(), match.end(), alias_map[match.group(0)], match.group(0))
for match in DEVICE_ALIAS_PATTERN.finditer(text)
]
def action_terms_in(text: str) -> list[tuple[int, int, str, str]]:
alias_map = {
alias: action
for action, aliases in ACTION_ALIASES.items()
for alias in aliases
}
device_spans = [(start, end) for start, end, _, _ in device_terms_in(text)]
matches: list[tuple[int, int, str, str]] = []
for match in ACTION_ALIAS_PATTERN.finditer(text):
# Single-character actions such as ``调`` are useful in ``往后调``, but
# must not turn the ``调`` inside ``空调`` into a later action callback.
starts_inside_device = any(
start <= match.start() < end for start, end in device_spans
)
if starts_inside_device:
continue
matches.append(
(match.start(), match.end(), alias_map[match.group(0)], match.group(0))
)
return matches
def scope_in(text: str, *, default: str, domain: str | None = None) -> str:
matches = [
(match.start(), scope)
for scope, aliases in SCOPE_ALIASES.items()
for alias in aliases
for match in re.finditer(re.escape(alias), text)
]
scope = min(matches)[1] if matches else default
if domain == "window":
return {
"driver": "front_left",
"passenger": "front_right",
"front_row": "front",
"rear_row": "rear",
}.get(scope, scope)
return scope
def color_in(text: str) -> tuple[str, str] | None:
"""Return a canonical RGB value and the exact spoken color alias."""
matches = [
(match.start(), color, alias)
for color, aliases in COLOR_ALIASES.items()
for alias in aliases
for match in re.finditer(re.escape(alias), text)
]
if not matches:
return None
_, color, alias = min(matches)
return color, alias
+88 -10
View File
@@ -13,28 +13,28 @@ import socket
import threading
import time
from copy import deepcopy
from http.cookies import CookieError, SimpleCookie
from http import HTTPStatus
from http.cookies import CookieError, SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import ParseResult, parse_qs, quote, unquote, urlparse
from cognitive_agent.config_loader import load_local_env
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
# Repository-local launch configuration is deliberate and must not be shadowed
# by stale variables inherited from an IDE or an earlier model experiment.
load_local_env(REPOSITORY_ROOT / ".env", override=True)
load_local_env(REPOSITORY_ROOT / ".env.auth", override=True)
from cognitive_agent.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
from cognitive_agent.application.calibration.service import ( # noqa: E402
ApiError as CalibrationApiError,
)
from cognitive_agent.application.calibration.service import (
CalibrationService,
)
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
from cognitive_agent.application.self_extension import ( # noqa: E402
ExtensionIntegrationError,
GatewayError,
@@ -44,17 +44,21 @@ from cognitive_agent.application.self_extension import ( # noqa: E402
resolve_active_peripheral_command,
)
from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402
from cognitive_agent.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
HardwareAccessRequiredError,
)
from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
HardwareBusyError,
HardwareSafetyError,
)
from cognitive_agent.infrastructure.can.gateway import CanGatewayError # noqa: E402
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
HardwareAccessRequiredError,
)
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError # noqa: E402
from cognitive_agent.infrastructure.nfc import NfcReaderService # noqa: E402
from cognitive_agent.infrastructure.speech import ( # noqa: E402
SpeechRecognitionError,
SpeechTranscriber,
)
STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console"
mimetypes.add_type("application/manifest+json", ".webmanifest")
@@ -179,6 +183,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
repository_root=REPOSITORY_ROOT,
)
repair = SelfRepairService()
speech = SpeechTranscriber(REPOSITORY_ROOT)
auth = AuthRuntime(AuthConfig.from_env())
vehicle_display_sync = VehicleDisplaySync()
server_version = "CognitiveAgentCabinDemo/2.0"
@@ -374,6 +379,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
return
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
return
if path == "/api/speech/status":
self._json(self.speech.status())
return
if path.startswith("/api/requests/"):
self._serve_request(path.removeprefix("/api/requests/"))
return
@@ -389,6 +397,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
return
if not self._authorize(path):
return
if path == "/api/speech/transcribe":
self._handle_speech_transcription()
return
if path in {
"/api/hardware/access/request",
"/api/hardware/access/release",
@@ -518,6 +529,32 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
payload.get("control_transport", "wired_can")
),
}
speech_utterance_id = str(
payload.get("speech_utterance_id", "")
).strip()
if speech_utterance_id:
if path != "/api/requests":
raise ValueError(
"speech semantic execution is only accepted by /api/requests"
)
speech_understanding = self.speech.cached_result(
speech_utterance_id
)
if str(speech_understanding.get("clean_text", "")).strip() != utterance:
raise SpeechRecognitionError(
"speech_text_changed",
"语音文本已被修改,请确认修改后的内容再执行。",
status=409,
)
if speech_understanding.get("execution_eligible") is not True:
raise SpeechRecognitionError(
"speech_not_executable",
"语音意图尚未通过能力或参数校验,已拒绝执行。",
status=422,
)
provider = "local"
common_arguments["provider"] = provider
common_arguments["_speech_understanding"] = speech_understanding
if (
path != "/api/can/commands"
and "application/x-ndjson" in self.headers.get("Accept", "")
@@ -646,6 +683,12 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
{"error": "extension_request_ambiguous", "message": str(exc)},
HTTPStatus.UNPROCESSABLE_ENTITY,
)
except SpeechRecognitionError as exc:
fail_display_sync(exc.code, exc)
self._json(
{"error": exc.code, "message": str(exc)},
HTTPStatus(exc.status),
)
except (ValueError, json.JSONDecodeError) as exc:
fail_display_sync("invalid_request", exc)
self._json(
@@ -2135,6 +2178,41 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
raise ValueError("request body must be a JSON object")
return payload
def _handle_speech_transcription(self) -> None:
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
if content_type not in {"audio/wav", "audio/wave", "audio/x-wav"}:
self._json(
{
"error": "unsupported_media_type",
"message": "语音听写接口仅接受 16 位 PCM WAV 音频。",
},
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
)
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
if content_length <= 0 or content_length > 2 * 1024 * 1024:
raise SpeechRecognitionError(
"invalid_audio_size",
"录音数据为空或超过大小限制。",
status=413,
)
result = self.speech.transcribe_wav(
self.rfile.read(content_length),
utterance_id=self.headers.get("X-Speech-Utterance-Id"),
)
self._json(result)
except SpeechRecognitionError as exc:
self._json(
{"error": exc.code, "message": str(exc)},
HTTPStatus(exc.status),
)
except (TypeError, ValueError):
self._json(
{"error": "invalid_audio", "message": "录音请求格式无效。"},
HTTPStatus.BAD_REQUEST,
)
def _authorize(self, path: str) -> bool:
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
return True
@@ -2407,7 +2485,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Permissions-Policy",
"camera=(self), microphone=(), geolocation=(), payment=(), usb=()",
"camera=(self), microphone=(self), geolocation=(), payment=(), usb=()",
)
self.send_header(
"Content-Security-Policy",