Merge origin/feature/N7Web into feature/N7Web

Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
This commit is contained in:
Miayui
2026-09-17 12:46:51 +08:00
135 changed files with 30180 additions and 513 deletions
@@ -8,6 +8,7 @@
- 对目录中的原子设备控制,调用工具时同时提交结构化 `plan`:只能逐字使用本回合能力目录中 `assistant_callable=true` 的 capability、target scope 和参数契约。对于目录中已有的座舱模式或当前 NFC 卡已授权的红外外设,省略 `plan`,由应用按动态注册表解析。结构化计划只是候选数据,应用会再次执行原始文本证据校验、Schema 校验和安全校验;本地确定性解析与计划重叠时,以本地结果为准。
- 用户仅给出一个尚未登记的功能模式名称时,应结合该场景的通常含义,从本回合能力目录中选择少量、明确且相互协调的 `assistant_callable=true` 原子能力,提交一个结构化 `plan`。每项动作必须是可重复的绝对 `set` 状态,不得使用相对增减、时长动作、动态/循环模式、保存或应用操作;同一能力与目标不得重复。若任何动作或参数有歧义,就不要提交残缺计划。不得引用目录外能力,不得生成原始 CAN,不得声称已经保存或真实执行;应用会把整组计划原子校验为 dry-run 动作预览,用户确认后才会保存。已有模式名称不要重新推测动作,应省略 `plan` 复用工程配置。
- 用户明确要求控制车辆,或直接表达当前座舱不适并期待处理时,必须调用该工具;不要只给建议,也不要要求用户先选择“问答/车控”模式。
- 新建功能模式时,座椅默认只编排副驾;用户明确指定主驾时只编排主驾,只有明确要求两座时才同时编排。该规则适用于每个动态阶段,以本回合 `seat_scope_policy` 为准。不得把副驾姿态能力替换目标后冒充主驾能力,不得把后排或未知座位映射成前排。空调分区、车窗等非座椅设备的主副驾限定不改变座椅选择。单纯执行或修改已保存模式时,应保留其已保存的座位范围,不要重新套用新建默认值。
- “车上有点热”“有点热”“太热了”等当前不适,在没有否定、假设或纯知识提问时,直接调用一次工具;具体规范动作由服务端决定。
- “如果车上有点热应该怎么办”“为什么暴晒后车里会热”“车上有点热,但不要调整设备”等是假设、知识问题或否定请求,不得执行。
- 用户说“不要下发”“不执行”“只解析”“仅预览”或同义表达时,不得调用车辆工具。应直接用自然语言给出解析结果,保留用户明确说出的设备、目标位置、方向、数值、单位、时长和动作顺序,并明确说明未向车辆下发。
@@ -21,6 +21,7 @@ from cognitive_agent.application.assistant_plan_guard import (
)
from cognitive_agent.application.mode_profile_intent import (
normalize_mode_profile_name,
parse_mode_profile_create,
parse_mode_profile_detail_query,
)
from cognitive_agent.generated.can_semantic_protocol import CAN0_SUPPORTED_CAPABILITIES
@@ -434,6 +435,9 @@ _MODE_CAPABILITY_LABELS = {
"seat.set_legrest_extension": "座椅腿托伸展",
"seat.set_heating": "座椅加热",
"seat.set_ventilation": "座椅通风",
"seat.set_driver_ventilation_native": "座椅通风",
"seat.set_driver_heating_native": "座椅加热",
"seat.set_driver_massage_native": "座椅按摩",
"seat.start_dynamic_mode": "座椅动态模式",
"climate.set_power": "空调",
"climate.set_temperature": "空调温度",
@@ -581,6 +585,21 @@ def _mode_operation_text(raw: Mapping[str, object]) -> str:
}:
target_label = ""
subject = f"{target_label}{label}"
if capability in {"seat.set_driver_ventilation_native", "seat.set_driver_heating_native"}:
if parameters.get("state") == "off":
return f"{subject}关闭"
level = {1: "高档", 2: "低档"}.get(parameters.get("level", 2), "")
return f"{subject}开启{level}"
if capability == "seat.set_driver_massage_native":
if parameters.get("action") == "off":
return f"{subject}关闭"
if parameters.get("action") == "strength":
strength = {1: "轻柔", 2: "中等", 3: "较强"}.get(parameters.get("strength"), "")
return f"{subject}设为{strength}力度"
mode = {
"wave": "波浪", "pulse": "脉冲", "stretch": "舒展", "slack": "舒缓", "relax": "放松",
}.get(str(parameters.get("mode") or ""), "")
return f"{subject}设为{mode}模式"
if set(parameters) == {"state"}:
return f"{subject}{values[0]}"
if capability == "roof.set_shade_position" and parameters.get("position_pct") in {0, 100}:
@@ -602,6 +621,19 @@ def _mode_detail_answer(
if isinstance(item, Mapping)
]
name = _safe_text(mode.get("name"), limit=80)
schedule = mode.get("schedule")
if isinstance(schedule, Mapping) and isinstance(schedule.get("phases"), list):
phases = schedule["phases"]
details = ";".join(
f"第{index}阶段({phase['name']},保持{phase['duration_ms'] // 1000}秒):"
+ "、".join(_mode_operation_text(op) for op in phase["operations"])
for index, phase in enumerate(phases, 1)
)
return CapabilityAnswer(
f"“{name}”按时间轴执行,重复{schedule['repeat_count']}次。{details}。"
"停止动态模式可取消后续阶段,已下发目标保持。本次仅查询,未下发车辆控制。",
"specific_mode_detail",
)
if not operations:
return CapabilityAnswer(
f"已找到“{name}”,但当前模式目录没有返回可核验的动作明细;本次仅查询,未下发车辆控制。",
@@ -1756,6 +1788,11 @@ class AssistantCapabilityCatalog:
normalized = "".join(text.lower().split())
if not normalized:
return None
# A creation request can contain both a device and the noun 功能模式.
# That noun is not evidence that the user is asking about capabilities.
# Use the guarded intent parser, so genuine questions remain answerable.
if parse_mode_profile_create(text) is not None:
return None
catalog = snapshot or self.snapshot()
unsupported_control = _unsupported_control_answer(
_canonical_control_text(text),
@@ -2255,6 +2292,7 @@ class AssistantCapabilityCatalog:
"origin": _safe_text(raw.get("origin"), limit=32),
"operation_ids": operation_ids,
"operations": operations,
**({"schedule": raw["schedule"]} if raw.get("schedule") else {}),
"assistant_callable": callable_mode,
"n7_transfer_operation_ids": (
n7_transfer_operation_ids if n7_transfer_ready else []
@@ -2291,6 +2329,11 @@ class AssistantCapabilityCatalog:
requests = (
raw_plan.get("requests", []) if isinstance(raw_plan, Mapping) else []
)
schedule = raw.get("schedule")
if isinstance(schedule, Mapping):
requests = [operation for phase in schedule.get("phases", [])
if isinstance(phase, Mapping)
for operation in phase.get("operations", [])]
operation_ids = [
_safe_text(item.get("capability"), limit=120)
for item in requests
@@ -2328,6 +2371,7 @@ class AssistantCapabilityCatalog:
# The assistant consumes this only after an exact name/alias
# match and the vehicle service validates it again.
"plan": dict(raw_plan) if callable_template else None,
**({"schedule": schedule} if isinstance(schedule, Mapping) else {}),
}
)
return templates, "ready"
@@ -39,6 +39,9 @@ SCENE_COMPOSABLE_CAPABILITIES = frozenset(
"seat.set_legrest_extension",
"seat.set_ventilation",
"seat.set_heating",
"seat.set_driver_ventilation_native",
"seat.set_driver_heating_native",
"seat.set_driver_massage_native",
"climate.set_power",
"climate.set_temperature",
"climate.set_fan_level",
@@ -1087,6 +1090,7 @@ def guard_assistant_plan(
fallback_intents: Sequence[Mapping[str, Any]] = (),
trusted_local: Mapping[str, Any] | None = None,
allow_scene_composition: bool = False,
scene_scope_utterance: str | None = None,
) -> GuardedAssistantPlan:
"""Authorize a Codex plan against the server-owned unresolved text.
@@ -1155,6 +1159,13 @@ def guard_assistant_plan(
parser_claims.update(_intent_claims(intent))
local_keys = _resolved_keys(trusted_local or {})
non_execution = bool(_NON_EXECUTION_PATTERN.search(text))
# An explicit empty internal scope is reserved for already validated saved
# schedules/draft revisions. New scenes use passenger when no seat is named.
scene_seat_scopes = None
if allow_scene_composition and scene_scope_utterance != "":
from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes
scene_seat_scopes = creation_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance)
accepted: list[dict[str, Any]] = []
rejected: list[dict[str, str]] = []
@@ -1206,6 +1217,24 @@ def guard_assistant_plan(
}
)
continue
if (
allow_scene_composition
and domain == "seat"
and scene_seat_scopes is not None
and (
"unsupported" in scene_seat_scopes
or {"front_left": "driver", "front_right": "passenger"}.get(
target_scope, target_scope
) not in scene_seat_scopes
)
):
rejected.append(
{
"field": f"requests[{index}].target_scope",
"question": "场景座椅动作超出了用户明确指定的座位范围,已拒绝生成该功能模式。",
}
)
continue
request_text = _relevant_text(capability, text)
if non_execution or (
not allow_scene_composition
@@ -20,19 +20,25 @@ from typing import Protocol
from cognitive_agent.application.mode_profile_intent import (
is_mode_profile_query,
mode_creation_source_name,
normalize_mode_profile_name,
parse_mode_profile_create,
parse_mode_profile_delete,
parse_mode_profile_update,
)
from cognitive_agent.application.mode_schedule import ModeSchedule
from cognitive_agent.application.mode_schedule_service import public_schedule_status
from cognitive_agent.application.scene_composer import (
SCENE_COMPOSER_INSTRUCTIONS,
SCENE_PLANNER_VERSION,
SCHEDULE_COMPOSER_INSTRUCTIONS,
SceneCompositionError,
build_scene_output_schema,
normalize_scene_output,
scene_context_for_mode_creation,
)
from cognitive_agent.application.scene_templates import normalize_scene_template_name
from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule
LOGGER = logging.getLogger(__name__)
INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力"
@@ -374,6 +380,9 @@ def _catalog_exact_mode_match_count(
}:
matched_ids.add(str(raw_mode.get("id") or ""))
matched_ids.discard("")
subject = mode_creation_source_name(requested_name)
if not matched_ids and normalize_mode_profile_name(subject) != needle:
return _catalog_exact_mode_match_count(catalog, subject)
return len(matched_ids)
@@ -475,6 +484,10 @@ def _catalog_exact_scene_template_plan(
template_id = str(raw_template.get("id") or "").strip()
if template_id:
matches[template_id] = raw_template
if not matches:
subject = mode_creation_source_name(requested_name)
if normalize_scene_template_name(subject) != needle:
return _catalog_exact_scene_template_plan(catalog, subject)
if len(matches) != 1:
return None
template = next(iter(matches.values()))
@@ -499,7 +512,12 @@ def _catalog_exact_scene_template_plan(
def _vehicle_result_succeeded(result: Mapping[str, object]) -> bool:
"""Only a terminal, fully successful service result completes a tool call."""
return str(result.get("status") or "").lower() == "succeeded"
schedule = result.get("schedule_execution")
return str(result.get("status") or "").lower() == "succeeded" or (
result.get("status") == "running"
and result.get("orchestration_route") == "mode_schedule_execute"
and isinstance(schedule, Mapping) and schedule.get("status") == "running"
)
def _mode_profile_result_succeeded(result: Mapping[str, object]) -> bool:
@@ -594,6 +612,10 @@ def _vehicle_action_state(result: Mapping[str, object]) -> tuple[str, bool, str]
if not _vehicle_result_succeeded(result):
return "failed", False, "车辆控制未完成"
if result.get("orchestration_route") == "mode_schedule_stop":
return ("simulated" if result.get("execution_mode") != "hardware" else "cancelled"), True, "动态模式停止请求已处理"
if result.get("status") == "running":
return "running", True, "时间轴已启动,尚未完成所有阶段"
execution_mode = str(result.get("execution_mode") or "").lower()
if execution_mode == "dry_run":
return "simulated", True, "车辆控制模拟编排已完成"
@@ -621,6 +643,8 @@ def _authorize_vehicle_request(
message=message,
)
normalized = "".join(str(message).lower().split())
if is_schedule_stop(message):
return _VehicleAuthorization(message, "explicit_schedule_stop")
mode_profile_create = _mode_profile_create_parts(message)
mode_profile_update = _mode_profile_update_parts(message)
mode_profile_delete = parse_mode_profile_delete(message)
@@ -909,6 +933,9 @@ class CodexAssistantService:
"explicit_mode_profile_draft_update",
}
mode_parts = _mode_profile_create_parts(message) if mode_profile_preview else None
if mode_parts:
scene_context = scene_context_for_mode_creation(scene_context, message)
model_context = scene_context_for_mode_creation(model_context, message)
exact_mode_matches = (
_catalog_exact_mode_match_count(catalog, mode_parts[0])
if mode_parts
@@ -919,10 +946,11 @@ class CodexAssistantService:
if mode_parts and not mode_parts[1] and exact_mode_matches == 0
else None
)
dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message))
direct_mode_profile_preview = bool(
mode_parts
and (
mode_parts[1]
(mode_parts[1] and not dynamic_scene_request)
# Ambiguous or unavailable mode catalogs fail closed in the
# local builder. Only a known-zero match is safe to hand to
# semantic scene composition.
@@ -939,7 +967,7 @@ class CodexAssistantService:
}.get(authorization.reason)
scene_composition_preview = bool(
mode_parts
and not mode_parts[1]
and (not mode_parts[1] or dynamic_scene_request)
and exact_mode_matches == 0
and scene_template_plan is None
and scene_context.get("composition_revision")
@@ -951,6 +979,7 @@ class CodexAssistantService:
# vehicle service still limits it to dry-run and requires confirmation.
direct_vehicle_control = (
fast_comfort_control
or authorization.reason == "explicit_schedule_stop"
or exact_quick_control
or local_first_control
or saved_mode_execution_id is not None
@@ -1280,11 +1309,17 @@ class CodexAssistantService:
revision = str(scene_context.get("composition_revision") or "")
if not revision:
raise _ScenePlannerFallback("scene capability revision is unavailable")
scope_policy = scene_context.get("seat_scope_policy")
selected_scopes = (
scope_policy.get("selected_scopes", [])
if isinstance(scope_policy, Mapping) else []
)
scope_key = ",".join(sorted(str(scope) for scope in selected_scopes))
return (
vehicle_id,
revision,
normalize_mode_profile_name(mode_name),
SCENE_PLANNER_VERSION,
f"{SCENE_PLANNER_VERSION}:seats={scope_key}",
)
def _begin_scene_plan(
@@ -1525,7 +1560,8 @@ class CodexAssistantService:
raise _ScenePlannerFallback("structured turns are not supported")
planning_started_at = time.monotonic()
output_schema = build_scene_output_schema(scene_context)
dynamic = needs_mode_schedule(message)
output_schema = build_scene_output_schema(scene_context, dynamic=dynamic)
context_bytes = len(
json.dumps(
dict(scene_context),
@@ -1554,7 +1590,7 @@ class CodexAssistantService:
try:
try:
thread_id = self.client.start_thread(
developer_instructions=SCENE_COMPOSER_INSTRUCTIONS,
developer_instructions=SCHEDULE_COMPOSER_INSTRUCTIONS if dynamic else SCENE_COMPOSER_INSTRUCTIONS,
ephemeral=True,
replace_developer_instructions=True,
timeout=self._remaining_scene_time(deadline),
@@ -1693,7 +1729,7 @@ class CodexAssistantService:
raise SceneCompositionError("scene composer output was not valid JSON") from exc
if not isinstance(raw_output, Mapping):
raise SceneCompositionError("scene composer output must be an object")
normalized = normalize_scene_output(raw_output, scene_context)
normalized = normalize_scene_output(raw_output, scene_context, dynamic=dynamic)
finished_at = time.monotonic()
LOGGER.info(
"Scene Composer model completed context_bytes=%d schema_bytes=%d "
@@ -2229,7 +2265,9 @@ class CodexAssistantService:
"success": successful,
"state": action_state,
"display_text": (
"功能模式预览已生成"
completion_text
if action_state == "running" or result.get("orchestration_route") == "mode_schedule_stop"
else "功能模式预览已生成"
if mode_profile_preview and successful
else "功能模式预览生成失败"
if mode_profile_preview
@@ -2397,6 +2435,13 @@ class CodexAssistantService:
if isinstance(draft, Mapping)
else "新功能模式"
)
if isinstance(draft, Mapping) and isinstance(draft.get("schedule"), dict):
schedule = ModeSchedule.from_dict(draft["schedule"])
return (f"已为你生成“{name}”的分阶段时间轴:{len(schedule.phases)}个阶段,"
f"每轮{schedule.operation_count}项动作,重复{schedule.repeat_count}次,"
f"总时长{schedule.total_duration_ms // 1000}秒。"
"每个阶段的设备目标和持续时间见卡片。当前未保存或执行;确认后保存,进入模式才开始计时。"
"执行中可输入“停止动态模式”取消后续阶段,已下发目标不会自动回滚。")
if (
isinstance(draft, Mapping)
and draft.get("generation_source") == "draft_revision"
@@ -2601,6 +2646,18 @@ class CodexAssistantService:
result: Mapping[str, object],
successful: bool,
) -> str:
if result.get("orchestration_route") == "mode_schedule_stop":
if result.get("execution_mode") == "hardware" and (result.get("schedule_execution") or {}).get("status") != "cancelled":
return "当前没有正在执行的动态时间轴。"
return ("已停止动态模式后续阶段。已下发的设备目标保持,不自动回滚。"
if result.get("execution_mode") == "hardware"
else "已模拟停止动态模式,未改变正在运行的车辆任务。")
if result.get("orchestration_route") == "mode_schedule_execute":
if not successful:
return "时间轴未能完成,后续阶段已停止。请检查车辆状态后重试。"
return ("动态模式时间轴已启动,将按卡片中的时长逐阶段调节。尚未完成所有阶段;输入“停止动态模式”可取消后续阶段。"
if result.get("status") == "running"
else "已按虚拟时钟模拟验证动态模式的全部阶段,未向实际车辆下发操作。")
if reason in {
"exact_quick_control",
"explicit_registered_control",
@@ -3672,7 +3729,7 @@ class CodexAssistantService:
if isinstance(intent_ir, dict):
spec = intent_ir.get("spec")
if isinstance(spec, dict) and isinstance(spec.get("actions"), list):
for item in spec["actions"][:12]:
for item in spec["actions"][:96 if result.get("schedule") else 12]:
if not isinstance(item, dict):
continue
actions.append(
@@ -3786,6 +3843,22 @@ class CodexAssistantService:
)
if key in mode_profile_draft
}
for key in ("mode_profile_draft", "mode_profile"):
source = result.get(key)
if isinstance(source, dict) and isinstance(source.get("schedule"), dict):
try:
safe[key]["schedule"] = ModeSchedule.from_dict(source["schedule"]).to_dict()
except ValueError:
pass
if isinstance(result.get("schedule"), dict):
try:
safe["schedule"] = ModeSchedule.from_dict(result["schedule"]).to_dict()
except ValueError:
pass
if isinstance(result.get("schedule_execution"), dict):
safe["schedule_execution"] = public_schedule_status(result["schedule_execution"])
if isinstance(result.get("vehicle"), dict):
safe["vehicle"] = {"id": str(result["vehicle"].get("id") or "")[:128]}
failure = result.get("mode_creation_failure")
if isinstance(failure, Mapping):
reason, next_step = CodexAssistantService._mode_profile_failure_feedback(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
"""Seat selection for new mode drafts, not for replaying saved user modes."""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from copy import deepcopy
from typing import Any
from cognitive_agent.application.mode_schedule import ModeSchedule
from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes
class ModeCreationSeatPolicyError(ValueError):
"""A source mode cannot satisfy the selected seat without substitution."""
def creation_seat_scopes(text: str) -> frozenset[str]:
"""Default to passenger; HVAC/window zone names do not select a seat."""
normalized = text.replace("主副驾", "主驾和副驾")
# Do not let the generic 驾驶席/驾驶位 aliases inside 副驾驶席/位
# accidentally select the driver as well as the passenger.
normalized = re.sub(r"副驾驶(?:席|位|座(?!椅))?", "副驾", normalized)
if re.search(r"(?:未知|不明|不存在的?|任意)(?:一个)?(?:座位|座椅)|第[四五六七八九0-9]+排座", normalized):
return frozenset({"unsupported"})
normalized = re.sub(r"(?:前排)?两(?:个|张)?(?:座椅|座位|座)|双座(?:椅)?", "主驾和副驾", normalized)
excluded = set()
for scope, aliases in (("driver", r"主驾(?:驶)?|驾驶位|驾驶席|司机位"),
("passenger", r"副驾(?:驶)?|乘客位")):
pattern = re.compile(
rf"(?:不要|别|不用|无需|不需要|不涉及|不包含|排除)"
rf"(?:再|去|动|移动|调节|调整|控制|联动|编排|使用|添加|包含)*"
rf"(?:{aliases})(?:座椅|座位)?|"
rf"(?:{aliases})(?:座椅|座位)?(?:不动|不要动|不用动|不联动|不参与|不编排)"
)
if pattern.search(normalized):
excluded.add(scope)
normalized = pattern.sub("", normalized)
selected = resolve_scene_seat_scopes(normalized) or frozenset({"passenger"})
return frozenset(selected - excluded)
def normalized_seat_scope(scope: object) -> str:
value = str(scope or "")
return {"front_left": "driver", "front_right": "passenger"}.get(value, value)
def project_seed_operations(
operations: Sequence[Mapping[str, Any]], scopes: frozenset[str],
) -> list[dict[str, Any]]:
"""Choose from trusted seat variants. Never retarget a passenger capability."""
if "unsupported" in scopes:
raise ModeCreationSeatPolicyError("当前功能模式仅支持明确的主驾或副驾座椅,不会替换为其他座位。")
seats = [op for op in operations if op.get("domain") == "seat"]
if any(normalized_seat_scope(op.get("target_scope")) not in {"driver", "passenger"} for op in seats):
raise ModeCreationSeatPolicyError("模式包含无法验证的座位范围,请重新生成。")
projected = [deepcopy(dict(op)) for op in operations
if op.get("domain") != "seat" or normalized_seat_scope(op.get("target_scope")) in scopes]
if seats and not any(op.get("domain") == "seat" for op in projected):
target = "主驾" if scopes == {"driver"} else "副驾" if scopes == {"passenger"} else "所选座位"
raise ModeCreationSeatPolicyError(f"该模式没有支持{target}的座椅动作,不会把另一座位的能力冒充为{target}能力。")
return projected
def project_seed_plan(plan: Mapping[str, Any], scopes: frozenset[str]) -> dict[str, Any]:
projected = deepcopy(dict(plan))
projected["requests"] = project_seed_operations(plan.get("requests", []), scopes)
projected["detected_domains"] = list(dict.fromkeys(op["domain"] for op in projected["requests"]))
return projected
def project_seed_schedule(schedule: ModeSchedule, scopes: frozenset[str]) -> ModeSchedule:
document = schedule.to_dict()
for phase in document["phases"]:
phase["operations"] = project_seed_operations(phase["operations"], scopes)
return ModeSchedule.from_dict(document)
@@ -0,0 +1,86 @@
"""Conservative, local defaults for adding named devices to an existing mode.
Only a complete *bare device list* uses these defaults. Explicit parameters and
actions stay in the normal parser; unrecognised list entries never disappear.
Defaults are proposals for a fully validated preview, not executable commands.
"""
from __future__ import annotations
import re
from copy import deepcopy
from typing import Any
_ADD_PREFIX = re.compile(
r"^(?:(?:请|帮我|帮忙|再|还|也|同时|顺便|继续|并且|并)\s*)*"
r"(?:加入|添加|增加|补充|加上|追加|新增)\s*"
)
_DEVICE_GROUPS = {
"空调": "climate", "冷气": "climate",
"氛围灯": "ambient_light", "气氛灯": "ambient_light",
"阅读灯": "reading", "读书灯": "reading",
"车窗": "window", "窗户": "window",
"遮阳帘": "roof", "天幕遮阳帘": "roof", "天窗遮阳帘": "roof",
"室内灯": "indoor", "顶灯": "indoor",
}
_LIST_SEPARATOR = re.compile(r"[、,,;;\s]+|以及|还有|和|与|及")
_EXPLICIT_ACTION = re.compile(
r"\d|[零一二三四五六七八九十百]+(?:度|档|挡|秒|分钟|%)|"
r"打开|开启|关闭|关掉|开到|关到|调整|设置|调到|调成|变成|改成|"
r"升高|降低|调高|调低|调亮|调暗|全开|全关|半开|一半|"
r"红色|蓝色|绿色|白色|暖色|冷色|亮度|温度|风量|内循环|外循环"
)
def strip_mode_addition_prefix(change_text: str) -> str:
"""Remove only the editing verb, not any device, value or negation."""
return _ADD_PREFIX.sub("", change_text.strip(), count=1)
def mode_device_additions(
change_text: str,
base_operations: list[dict[str, Any]],
default_operations: list[dict[str, Any]],
) -> list[dict[str, Any]] | None:
"""Return missing device defaults, None for non-list/explicit edits.
Existing groups are already configured and are left completely untouched,
including their target zones, power state and custom parameter values.
Reading and indoor lights are separate groups within the same domain.
"""
if not _ADD_PREFIX.match(change_text.strip()):
return None
body = strip_mode_addition_prefix(change_text).strip("。.!!吧 ")
if _EXPLICIT_ACTION.search(body):
return None
body = re.sub(r"(?:等)?(?:相关)?(?:设备)?(?:的)?(?:控制)?(?:动作|能力|联动)?$", "", body)
body = re.sub(r"等$", "", body)
devices = [item for item in _LIST_SEPARATOR.split(body) if item]
groups: list[str] = []
for device in devices:
device = re.sub(r"(?:设备)?(?:的)?(?:控制)?(?:动作|能力)$", "", device)
group = _DEVICE_GROUPS.get(device)
if group is None:
raise ValueError(f"未识别或暂不支持增补设备“{device}”;未修改任何模式动作。")
if group not in groups:
groups.append(group)
if not groups:
raise ValueError("请说明要加入哪些设备,或提供明确的设备动作。")
def belongs(operation: dict[str, Any], group: str) -> bool:
capability = str(operation.get("capability") or "")
if group == "reading":
return capability == "interior_light.set_reading_power"
if group == "indoor":
return capability == "interior_light.set_indoor_power"
return operation.get("domain") == group
additions: list[dict[str, Any]] = []
for group in groups:
if any(belongs(operation, group) for operation in base_operations):
continue
candidates = [operation for operation in default_operations if belongs(operation, group)]
if not candidates:
raise ValueError(f"该设备尚无经过登记的默认编排,请补充明确目标:{group}。")
additions.extend(deepcopy(candidates))
return additions
@@ -13,6 +13,8 @@ from typing import Any
import yaml
from cognitive_agent.application.mode_schedule import ModeSchedule
_MATCH_FORMATTING = re.compile(r"[\s()()]+")
_MODE_INVOCATION_FORMATTING = re.compile(r"[\s,。!?,.!?::;;()()]+")
_NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+")
@@ -518,6 +520,7 @@ class ModeProfile:
command_flow: dict[str, Any] | None = None
sync_origin: str = ""
upstream_origin: str = ""
schedule: ModeSchedule | None = None
@classmethod
def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile":
@@ -558,10 +561,12 @@ class ModeProfile:
upstream_origin=_clean_upstream_origin(
document.get("upstream_origin")
),
schedule=(ModeSchedule.from_dict(document["schedule"])
if document.get("schedule") is not None else None),
)
def public_dict(self) -> dict[str, Any]:
is_cyclic = self.timeline is not None or any(
is_cyclic = self.schedule is not None or self.timeline is not None or any(
operation.capability == "seat.start_dynamic_mode"
for operation in self.operations
)
@@ -595,6 +600,9 @@ class ModeProfile:
result["upstream_origin"] = self.upstream_origin or None
if self.timeline is not None:
result["timeline"] = self.timeline.public_dict()
if self.schedule is not None:
result["schedule"] = self.schedule.to_dict()
result["operation_count"] = self.schedule.operation_count
return result
@@ -779,6 +787,7 @@ class ModeProfileRegistry:
},
"operations": [operation.to_yaml() for operation in profile.operations],
"timeline": profile.timeline.to_yaml() if profile.timeline is not None else None,
"schedule": profile.schedule.to_dict() if profile.schedule is not None else None,
"instruction": profile.instruction,
"command_flow": profile.command_flow,
"sync_origin": profile.sync_origin,
@@ -815,6 +824,7 @@ class ModeProfileRegistry:
timeline: ModeTimeline | None = None,
sync_origin: str = "",
upstream_origin: str = "",
schedule: ModeSchedule | None = None,
expected_revision: str | None = None,
create_only: bool = False,
) -> ModeProfile:
@@ -857,6 +867,8 @@ class ModeProfileRegistry:
if clean_sync_origin:
document["sync_origin"] = clean_sync_origin
document["upstream_origin"] = clean_upstream_origin
if schedule is not None:
document["schedule"] = schedule.to_dict()
path = (
self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml"
if vehicle_id
@@ -1128,6 +1140,8 @@ def _mode_profile_document(
}
if profile.timeline is not None:
document["timeline"] = profile.timeline.to_yaml()
if profile.schedule is not None:
document["schedule"] = profile.schedule.to_dict()
if profile.instruction:
document["instruction"] = profile.instruction
if profile.command_flow is not None:
@@ -9,12 +9,37 @@ _MODE_NAME = r"(?P<name>[^,。,.!!::\s“”\"'「」『』《》]{1,64}?
_QUOTED_MODE_NAME = (
r"(?P<open>[“\"'「『《]?)" + _MODE_NAME + r"(?P<close>[”\"'」』》]?)"
)
_CREATION_DUAL_SEAT_NAMES = (
r"主驾(?:和|与|及)副驾|主副驾|双座(?:椅)?|(?:前排)?两(?:个|张)?(?:座椅|座位|座)"
)
_CREATION_SEAT_NAMES = (
rf"{_CREATION_DUAL_SEAT_NAMES}|"
r"副驾驶席|副驾驶位|副驾驶|副驾|乘客座椅|乘客席|"
r"主驾驶席|主驾驶位|主驾驶|主驾|驾驶席|驾驶位"
)
_CREATION_SEAT_PREFIX = re.compile(
rf"^(?:为|给|针对|供)?(?P<seat>{_CREATION_SEAT_NAMES})"
r"(?:(?:座椅|座位|座席)?(?:专属|专用))?的?"
)
_CREATION_SEAT_CLAUSE = re.compile(
rf"^(?:仅|只)?(?:为|给|针对|适用于|用于|面向|联动|编排)"
rf"(?P<seat>{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?"
r"(?:使用|联动|编排|的能力|的动作)?$"
)
_CREATION_SEAT_GENERIC_SCENE = re.compile(
r"^(?:座椅|座位|座席)的?(?P<scene>"
r"阅读|看书|读书|办公|观影|看电影|电影|露营)模式$"
)
_CREATION_SEAT_FUNCTION_SUBJECT = re.compile(
r"^(?:舒适|舒服|舒缓|放松|动态|循环|通风|加热|按摩|靠背|腿托|坐垫).{0,32}模式$"
)
_MODE_PROFILE_CREATE_HEAD = re.compile(
r"^\s*"
r"(?:(?:请问(?:可以|能不能|能否|可不可以|能)帮我|"
r"(?:可以|能不能|能否|可不可以|能)帮我|"
r"我(?:想要|想|希望|需要|打算|准备)(?:让你)?|"
r"想要|想|希望|需要|打算|准备|请你?|麻烦你?|帮我|帮忙|给我|先)\s*){0,6}"
rf"(?:(?:为|给|针对)(?P<seat>{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?\s*)?"
r"(?P<verb>构建|创建|新建|新增|生成|添加)\s*"
r"(?:一下\s*)?(?:一个|个|一套|套)?\s*"
r"(?P<naming>命名为|名称为|取名为|名叫|名为|叫做)?\s*"
@@ -34,6 +59,9 @@ _MODE_PROFILE_PURPOSE_WRAPPERS = (
r"^(?:可以|能够|能)?让(?:我们|我|人们?|乘员们?|乘客们?|大家)"
r"(?P<purpose>.+?)的(?:功能|场景|自定义)?模式$"
),
re.compile(
r"^(?P<purpose>(?:座椅|座位|乘坐|坐着|上车|空调).+?)的(?:功能|场景|自定义)?模式$"
),
)
_MODE_PROFILE_PURPOSE_INVALID = frozenset(
{"", "功能", "场景", "自定义", "使用", "日常使用", "合适", "需要"}
@@ -67,6 +95,7 @@ _MODE_PROFILE_CREATE_CAPABILITY_CLAUSE = re.compile(
)
_MODE_PROFILE_CREATE_ASSISTED_REQUEST = re.compile(
r"^(?:请问)?(?:可以|能不能|能否|可不可以|能)帮我(?:先)?"
rf"(?:(?:为|给|针对)(?:{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?)?"
r"(?:构建|创建|新建|新增|生成|添加)"
)
_MODE_PROFILE_CREATE_SUFFIX_UNIT = re.compile(
@@ -141,7 +170,12 @@ _MODE_PROFILE_DELETE_BA = re.compile(
)
_MODE_PROFILE_UPDATE_NEGATION = re.compile(
r"不要|别(?:再)?|不用|无需|不必|禁止|不许|不得|"
r"取消(?:修改|调整|更改|更新|变更|操作)|不执行|不下发|不保存"
r"取消(?:修改|调整|更改|更新|变更|加入|添加|增加|补充|加上|追加|新增|操作)|"
r"撤销|算了|不执行|不下发|不保存"
)
_MODE_PROFILE_UPDATE_PREFIX_NEGATION = re.compile(
r"^(?:请|麻烦)?(?:先|暂时)?(?:不要|别|不用|无需|不必|禁止|不许|不得)"
r"(?:把|将|在|给|为|往|向|修改|调整|更改|更新|变更)"
)
_MODE_POSSESSIVE_BOUNDARY = r"(?:中的|里的|内的|之中的|的|中|里|内)"
_MODE_MEMBER_BOUNDARY = rf"(?:{_MODE_POSSESSIVE_BOUNDARY}|[,,::])"
@@ -164,16 +198,30 @@ _CONTEXTUAL_NAME_FIRST_UPDATE = re.compile(
rf"^{_LEADING_POLITE}{_QUOTED_MODE_NAME}[,,::]"
r"(?P<change>.+?)[。.!!]?$"
)
_MODE_PROFILE_APPEND_VERB = r"(?:加入|添加|增加|补充|加上|追加|新增)"
_APPEND_MODE_UPDATE = re.compile(
rf"^{_LEADING_POLITE}(?:在|给|为|往|向)?{_QUOTED_MODE_NAME}"
rf"(?:{_MODE_POSSESSIVE_BOUNDARY})?[,,::]?"
rf"(?:再|还|也|同时|另外)?(?P<change>{_MODE_PROFILE_APPEND_VERB}.+?)[。.!!]?$"
)
_APPEND_TO_MODE_UPDATE = re.compile(
rf"^{_LEADING_POLITE}(?:把|将)(?P<change>.+?)"
rf"(?P<append_verb>{_MODE_PROFILE_APPEND_VERB})(?:到|进)?{_QUOTED_MODE_NAME}"
rf"(?:{_MODE_POSSESSIVE_BOUNDARY})?(?:吧|呀|啊|哦|谢谢(?:你)?)?[。.!!]?$"
)
MODE_PROFILE_CHANGE_PREDICATE = re.compile(
rf"{_MODE_PROFILE_APPEND_VERB}|"
r"修改|改成|改为|更改|更新|变更|变成|变为|换成|"
r"调整|调节|调高|调低|调到|调成|调为|调至|"
r"设置|设为|设成|设到|设至|"
r"打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|"
r"移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|"
r"复位|半开|开到|关到|拉开|拉上|上锁|解锁"
r"复位|半开|开到|关到|拉开|拉上|上锁|解锁|"
r"(?:持续|保持)(?:时间)?(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)(?:分钟|秒钟|秒|s)|"
r"(?:重复|循环)(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)次"
)
MODE_PROFILE_QUERY_CUE = re.compile(
r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|"
r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|能否|能不能|可不可以|会不会|"
r"合适(?:吗)?|合理(?:吗)?|会(?:怎么样|怎样|如何)|"
r"怎么|如何|列(?:出|一下)|介绍(?:一下)?|说明(?:一下)?|"
r"告诉我|查询|查看|有什么作用|是何作用|还是|[??]|(?:吗|么|呢)$"
@@ -230,6 +278,41 @@ def normalize_mode_profile_name(value: object) -> str:
return compact_mode_profile_text(value).strip("“”\"'「」『』《》").casefold()
def _creation_seat_label(value: str) -> str:
if re.fullmatch(_CREATION_DUAL_SEAT_NAMES, value):
return "主副驾"
return "副驾" if value.startswith(("副", "乘客")) else "主驾"
def mode_creation_source_name(value: object) -> str:
"""Remove only an explicit front-seat qualifier for source lookup.
A scoped draft keeps its distinct display name. This helper only allows its
existing scene seed to be located; it never authorizes any seat operation or
rewrites an unsupported rear/unknown target into a front seat.
"""
name = compact_mode_profile_text(value).strip("“”\"'「」『』《》")
match = _CREATION_SEAT_PREFIX.match(name)
if match is None:
return name
subject = name[match.end():]
if not subject or subject == "模式" or not subject.endswith("模式"):
return name
# A seat location before a generic activity is a scope wrapper. Keep the
# actual device topic in 座椅舒适/座椅动态 rather than dropping every 座椅.
generic_scene = _CREATION_SEAT_GENERIC_SCENE.fullmatch(subject)
if generic_scene is not None:
subject = f"{generic_scene.group('scene')}模式"
elif (
match.group("seat").endswith(("座椅", "座位", "座"))
and _CREATION_SEAT_FUNCTION_SUBJECT.fullmatch(subject)
):
subject = f"座椅{subject}"
inferred = _infer_mode_profile_purpose_name(subject)
return inferred[0] if inferred is not None else subject
def _infer_mode_profile_purpose_name(value: object) -> tuple[str, str] | None:
"""Turn one bounded purpose description into a concise mode name.
@@ -445,12 +528,24 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None:
# Keep explicit user names byte-for-byte while giving inferred scenes a
# concise, reusable name (for example 看书模式 instead of
# 适合看书的功能模式).
if not str(match.group("naming") or "") and not opening:
explicit_name = bool(str(match.group("naming") or "") or opening)
if not explicit_name:
inferred_purpose = _infer_mode_profile_purpose_name(name)
if inferred_purpose is not None:
name = inferred_purpose[0]
seat_prefix = _CREATION_SEAT_PREFIX.match(name)
if seat_prefix is not None and mode_creation_source_name(name) != name:
name = _creation_seat_label(seat_prefix.group("seat")) + mode_creation_source_name(name)
if match.group("seat") and seat_prefix is None:
name = _creation_seat_label(match.group("seat")) + name
if normalize_mode_profile_name(name) in _GENERIC_MODE_PROFILE_CREATE_NAMES:
return None
name_seat = _CREATION_SEAT_PREFIX.match(name)
if (
match.group("seat") and name_seat is not None
and _creation_seat_label(match.group("seat")) != _creation_seat_label(name_seat.group("seat"))
):
return None
raw_match_end = raw_ends[match.end() - 1]
if "\n" in raw[:raw_match_end] or "\r" in raw[:raw_match_end]:
@@ -495,6 +590,19 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None:
return name, ""
instruction = _strip_mode_profile_create_suffix(instruction)
seat_clause = _CREATION_SEAT_CLAUSE.fullmatch(compact_mode_profile_text(instruction))
if seat_clause is not None:
# A seat-only clause selects the draft, not an extra actuator command.
# Real device settings in a following clause remain untouched below.
name_seat = _CREATION_SEAT_PREFIX.match(name)
if (
name_seat is not None
and _creation_seat_label(name_seat.group("seat")) != _creation_seat_label(seat_clause.group("seat"))
):
return None
if not explicit_name and name_seat is None:
name = _creation_seat_label(seat_clause.group("seat")) + name
return name, ""
if (
not instruction
or len(instruction) > 800
@@ -516,6 +624,8 @@ def _candidate_matches(
matches: list[tuple[re.Match[str], bool]] = []
for pattern, predicate_required in (
(_PREFIX_UPDATE, False),
(_APPEND_MODE_UPDATE, True),
(_APPEND_TO_MODE_UPDATE, False),
(_BA_UPDATE, True),
(_NAME_FIRST_UPDATE, True),
):
@@ -534,6 +644,14 @@ def _candidate_matches(
return matches
def _mode_profile_update_change(match: re.Match[str], name: str) -> str:
change = _strip_repeated_mode_update_subject(str(match.group("change") or ""), name)
append_verb = str(match.groupdict().get("append_verb") or "")
# Keep reverse-order additions on the same service contract as name-first
# additions. Device names, explicit targets and parameters remain unchanged.
return f"{append_verb}{change}" if append_verb else change
def _strip_repeated_mode_update_subject(change: str, mode_name: str) -> str:
"""Remove a repeated ``把<same mode>的`` wrapper from an update body."""
@@ -613,10 +731,7 @@ def is_mode_profile_update_shape(
and _is_unquoted_device_property_mode(match, name)
):
continue
change = _strip_repeated_mode_update_subject(
str(match.group("change") or ""),
name,
)
change = _mode_profile_update_change(match, name)
if change and (
not predicate_required or MODE_PROFILE_CHANGE_PREDICATE.search(change)
):
@@ -634,6 +749,7 @@ def parse_mode_profile_update(
text = compact_mode_profile_text(value)
if (
not text
or _MODE_PROFILE_UPDATE_PREFIX_NEGATION.search(text)
or _MODE_PROFILE_CREATE_REFERENCE_CLAUSE.search(text)
or _MODE_PROFILE_CREATE_UNSAFE_PREFIX.search(text)
):
@@ -650,10 +766,9 @@ def parse_mode_profile_update(
and _is_unquoted_device_property_mode(match, name)
):
continue
change = _strip_repeated_mode_update_subject(
str(match.group("change") or ""),
name,
)
change = _mode_profile_update_change(match, name)
if re.fullmatch(rf"(?:再|还|也|同时|另外)?{_MODE_PROFILE_APPEND_VERB}(?:一下)?", change):
continue
if (
(not predicate_required or MODE_PROFILE_CHANGE_PREDICATE.search(change))
and not _MODE_PROFILE_UPDATE_NEGATION.search(change)
@@ -296,6 +296,41 @@ def _scene_from_profile(
if profile.sync_origin:
scene["source_topic"] = profile.sync_origin
scene["upstream_origin"] = profile.upstream_origin or None
if profile.schedule is not None:
schedule = profile.schedule
preview_actions = []
elapsed_ms = 0
for phase in schedule.phases:
for raw_operation in phase.operations:
action = _scene_action(ModeOperation.from_dict(raw_operation))
action.update(
at_ms=elapsed_ms,
phase_id=phase.id,
phase_name=phase.name,
phase_duration_ms=phase.duration_ms,
)
preview_actions.append(action)
elapsed_ms += phase.duration_ms
# A v1 client may ignore timestamps and immediately send every action.
# Keep its executable list empty rather than flattening a new timeline.
# Schedule-aware clients can render the complete contract and ask the
# authenticated application to execute the saved mode by its identity.
scene.update(
behavior="cyclic",
operation_count=schedule.operation_count,
domains=sorted({op["domain"] for phase in schedule.phases for op in phase.operations}),
actions=[],
schedule=schedule.to_dict(),
preview_actions=preview_actions,
preview_actions_scope="single_cycle",
execution={
"kind": "server_schedule",
"mode_id": profile.id,
"client_actions_supported": False,
"requires_authentication": True,
},
execution_notice="分阶段动态模式需通过工程服务端时间轴执行,不支持按静态动作列表下发。",
)
return scene
@@ -0,0 +1,429 @@
"""Bounded, cancellable timelines for validated absolute cockpit operations.
This module validates the *schedule* contract, not vehicle authorization. Callers
must validate every phase against the current registry and execute it through the
ordinary compiler/safety path. It deliberately has no CAN or ModeProfile imports.
Durations are holds after one phase dispatch, never actuator motion durations.
Cancelling prevents future phases; it does not roll back an already applied state.
"""
from __future__ import annotations
import math
import re
import time
from collections.abc import Callable
from copy import deepcopy
from dataclasses import dataclass, field
from threading import Event, Lock, RLock, Thread, current_thread
from typing import Any
from uuid import uuid4
from cognitive_agent.application.assistant_plan_guard import SCENE_COMPOSABLE_CAPABILITIES
MODE_SCHEDULE_SCHEMA = "cognitive-agent-mode-schedule/v1"
MAX_SCHEDULE_DURATION_MS = 60 * 60 * 1000
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_FORBIDDEN_PARAMETERS = frozenset(
{"duration", "duration_ms", "seconds", "delta", "relative", "relative_pct",
"direction", "toggle", "repeat", "repeat_count", "axis"}
)
_FAILURE_STATUSES = frozenset(
{"failed", "error", "cancelled", "canceled", "rejected", "blocked", "aborted"}
)
_SUCCESS_STATUSES = frozenset({"succeeded", "success", "ok", "completed"})
def _object(value: Any, fields: set[str], required: set[str], label: str) -> dict[str, Any]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise ValueError(f"{label} must be an object with string keys")
if unknown := set(value) - fields:
raise ValueError(f"{label} contains unknown fields: {', '.join(sorted(unknown))}")
if missing := required - set(value):
raise ValueError(f"{label} is missing fields: {', '.join(sorted(missing))}")
return value
def _text(value: Any, label: str, maximum: int, *, allow_empty: bool = False) -> str:
if not isinstance(value, str) or len(value) > maximum:
raise ValueError(f"{label} must be a string of at most {maximum} characters")
if not allow_empty and not value.strip():
raise ValueError(f"{label} must not be empty")
if "\x00" in value:
raise ValueError(f"{label} contains a null character")
return value.strip()
def _integer(value: Any, minimum: int, maximum: int, label: str) -> int:
if type(value) is not int or not minimum <= value <= maximum:
raise ValueError(f"{label} must be an integer between {minimum} and {maximum}")
return value
def _operation(value: Any) -> dict[str, Any]:
required = {"domain", "capability", "target_scope", "parameters"}
document = _object(value, required | {"operation", "reason"}, required, "schedule operation")
domain = _text(document["domain"], "operation domain", 64)
capability = _text(document["capability"], "operation capability", 128)
if capability not in SCENE_COMPOSABLE_CAPABILITIES or not capability.startswith(domain + "."):
raise ValueError("schedule capability must be a stable, scene-composable capability")
if document.get("operation", "set") != "set":
raise ValueError("schedule operations must use absolute set operations")
parameters = document["parameters"]
if not isinstance(parameters, dict) or not 1 <= len(parameters) <= 12:
raise ValueError("schedule operation parameters must contain 1 to 12 scalar fields")
for key, parameter in parameters.items():
_text(key, "parameter name", 64)
if key.casefold() in _FORBIDDEN_PARAMETERS:
raise ValueError("schedule operations cannot contain relative, motion or timing parameters")
if parameter is None or type(parameter) not in (str, int, float, bool):
raise ValueError("schedule parameter values must be non-null JSON scalars")
if type(parameter) is float and not math.isfinite(parameter):
raise ValueError("schedule numeric parameters must be finite")
if isinstance(parameter, str):
_text(parameter, "parameter value", 256)
if parameter.casefold() in {"toggle", "increase", "decrease", "relative"}:
raise ValueError("schedule operations cannot request relative state changes")
return {
"domain": domain,
"capability": capability,
"target_scope": _text(document["target_scope"], "operation target_scope", 128),
"operation": "set",
"parameters": deepcopy(parameters),
"reason": _text(document.get("reason", ""), "operation reason", 1000, allow_empty=True),
}
def _operation_key(operation: dict[str, Any]) -> tuple[str, str]:
target = operation["target_scope"]
if operation["domain"] == "seat":
target = {"front_left": "driver", "front_right": "passenger"}.get(target, target)
return operation["capability"], target
@dataclass(frozen=True)
class ModeSchedulePhase:
id: str
name: str
duration_ms: int
operations: tuple[dict[str, Any], ...]
def __post_init__(self) -> None:
phase_id = _text(self.id, "schedule phase id", 64)
if not _ID_PATTERN.fullmatch(phase_id):
raise ValueError("schedule phase id must contain lowercase letters, numbers, '_' or '-'")
object.__setattr__(self, "id", phase_id)
object.__setattr__(self, "name", _text(self.name, "schedule phase name", 128))
_integer(self.duration_ms, 1000, 15 * 60 * 1000, "schedule phase duration_ms")
if not isinstance(self.operations, (list, tuple)) or not 1 <= len(self.operations) <= 12:
raise ValueError("schedule phase must contain 1 to 12 operations")
operations = tuple(_operation(operation) for operation in self.operations)
keys = [_operation_key(operation) for operation in operations]
if len(set(keys)) != len(keys):
raise ValueError("schedule phase contains duplicate capability/target operations")
object.__setattr__(self, "operations", operations)
@classmethod
def from_dict(cls, document: dict[str, Any]) -> ModeSchedulePhase:
fields = {"id", "name", "duration_ms", "operations"}
value = _object(document, fields, fields, "schedule phase")
if not isinstance(value["operations"], list):
raise ValueError("schedule phase operations must be an array") # noqa: TRY004
return cls(value["id"], value["name"], value["duration_ms"], tuple(value["operations"]))
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"duration_ms": self.duration_ms,
"operations": deepcopy(list(self.operations)),
}
@dataclass(frozen=True)
class ModeSchedule:
phases: tuple[ModeSchedulePhase, ...]
repeat_count: int = 1
schema: str = MODE_SCHEDULE_SCHEMA
def __post_init__(self) -> None:
if self.schema != MODE_SCHEDULE_SCHEMA:
raise ValueError("unsupported mode schedule schema")
_integer(self.repeat_count, 1, 20, "schedule repeat_count")
if not isinstance(self.phases, (list, tuple)) or not 2 <= len(self.phases) <= 8:
raise ValueError("schedule must contain 2 to 8 phases")
if any(not isinstance(phase, ModeSchedulePhase) for phase in self.phases):
raise ValueError("schedule phases must be ModeSchedulePhase objects")
object.__setattr__(self, "phases", tuple(self.phases))
if len({phase.id for phase in self.phases}) != len(self.phases):
raise ValueError("schedule phase ids must be unique")
if self.total_duration_ms > MAX_SCHEDULE_DURATION_MS:
raise ValueError("schedule total duration including repetitions must not exceed 60 minutes")
@property
def total_duration_ms(self) -> int:
return sum(phase.duration_ms for phase in self.phases) * self.repeat_count
@property
def operation_count(self) -> int:
"""Number of operations in one full repetition."""
return sum(len(phase.operations) for phase in self.phases)
@classmethod
def from_dict(cls, document: dict[str, Any]) -> ModeSchedule:
fields = {"schema", "repeat_count", "phases"}
value = _object(document, fields, fields, "schedule")
if not isinstance(value["phases"], list):
raise ValueError("schedule phases must be an array") # noqa: TRY004
return cls(
phases=tuple(ModeSchedulePhase.from_dict(phase) for phase in value["phases"]),
repeat_count=value["repeat_count"],
schema=value["schema"],
)
def to_dict(self) -> dict[str, Any]:
return {
"schema": self.schema,
"repeat_count": self.repeat_count,
"phases": [phase.to_dict() for phase in self.phases],
}
ExecutePhase = Callable[[ModeSchedulePhase], dict[str, Any]]
WaitFunction = Callable[[Event, float], bool]
def _result_error(result: Any) -> str | None:
if not isinstance(result, dict):
return "phase executor did not return an object with an explicit success result"
status = result.get("status")
if result.get("success") is False or result.get("error") or status in _FAILURE_STATUSES:
return str(result.get("error") or result.get("message") or "phase execution failed")[:1000]
if result.get("success") is True or status in _SUCCESS_STATUSES:
return None
return str(result.get("error") or "phase executor did not confirm successful execution")[:1000]
@dataclass
class _Run:
vehicle_id: str
session_id: str
schedule: ModeSchedule
started_at: float
run_id: str = field(default_factory=lambda: uuid4().hex)
cancelled: Event = field(default_factory=Event)
thread: Thread | None = None
status: str = "running"
phase_index: int = -1
repeat_index: int = 0
executed_phases: int = 0
error: str | None = None
cancel_reason: str | None = None
finished_at: float | None = None
class ModeScheduleRunner:
"""At most one active timeline and one phase callback per vehicle.
A replacement cancels its predecessor immediately and waits for any already
running phase callback to return before dispatching. Callbacks must themselves
be bounded and cancellation never implies undoing a previously sent operation.
``wait(event, seconds)`` may be injected for deterministic tests; like Event.wait
it returns true on cancellation. No callback runs while the state lock is held.
"""
def __init__(
self,
*,
clock: Callable[[], float] = time.monotonic,
wait: WaitFunction | None = None,
) -> None:
self._clock = clock
self._wait = wait or (lambda event, seconds: event.wait(seconds))
self._lock = RLock()
self._runs: dict[str, _Run] = {}
self._dispatch_locks: dict[str, Lock] = {}
self._workers: dict[str, _Run] = {}
self._closed = False
def _snapshot(self, run: _Run) -> dict[str, Any]:
end = run.finished_at if run.finished_at is not None else self._clock()
return {
"run_id": run.run_id,
"vehicle_id": run.vehicle_id,
"session_id": run.session_id,
"status": run.status,
"phase_index": run.phase_index,
"repeat_index": run.repeat_index,
"executed_phases": run.executed_phases,
"phase_count": len(run.schedule.phases),
"repeat_count": run.schedule.repeat_count,
"elapsed_ms": max(0, round((end - run.started_at) * 1000)),
"total_duration_ms": run.schedule.total_duration_ms,
"completed": run.status == "completed",
"error": run.error,
"cancel_reason": run.cancel_reason,
}
def status(self, vehicle_id: str) -> dict[str, Any]:
with self._lock:
run = self._runs.get(vehicle_id)
if run is not None:
return self._snapshot(run)
return {
"vehicle_id": vehicle_id, "status": "idle", "phase_index": -1,
"repeat_index": 0, "completed": False, "error": None,
}
def _cancel(self, run: _Run, reason: str) -> None:
if run.status == "running":
run.cancelled.set()
run.status = "cancelled"
run.cancel_reason = reason
run.finished_at = self._clock()
def cancel(self, vehicle_id: str, *, session_id: str | None = None) -> dict[str, Any]:
"""Cancel a vehicle timeline, optionally only if it belongs to this session."""
with self._lock:
run = self._runs.get(vehicle_id)
if run is not None and (session_id is None or session_id == run.session_id):
self._cancel(run, "cancelled")
return self.status(vehicle_id)
def start(
self,
vehicle_id: str,
schedule: ModeSchedule,
execute_phase: ExecutePhase,
*,
session_id: str = "",
) -> dict[str, Any]:
vehicle_id = _text(vehicle_id, "vehicle_id", 128)
session_id = _text(session_id, "session_id", 256, allow_empty=True)
if not isinstance(schedule, ModeSchedule) or not callable(execute_phase):
raise ValueError("start requires a ModeSchedule and callable phase executor") # noqa: TRY004
# Snapshot/revalidate to prevent caller-owned parameter dictionaries from
# altering the executable contract after the runner has accepted it.
accepted = ModeSchedule.from_dict(schedule.to_dict())
with self._lock:
if self._closed:
raise RuntimeError("mode schedule runner is closed")
previous = self._runs.get(vehicle_id)
if previous is not None:
self._cancel(previous, "replaced")
dispatch_lock = self._dispatch_locks.setdefault(vehicle_id, Lock())
run = _Run(vehicle_id, session_id, accepted, self._clock())
self._runs[vehicle_id] = run
self._workers[run.run_id] = run
run.thread = Thread(
target=self._run, args=(run, dispatch_lock, execute_phase),
name=f"mode-schedule-{run.run_id[:8]}", daemon=True,
)
try:
run.thread.start()
except RuntimeError:
run.status = "failed"
run.error = "schedule worker could not start"
run.finished_at = self._clock()
self._workers.pop(run.run_id, None)
raise
return self._snapshot(run)
def _run(self, run: _Run, dispatch_lock: Lock, execute_phase: ExecutePhase) -> None:
try:
for repeat_index in range(run.schedule.repeat_count):
for phase_index, phase in enumerate(run.schedule.phases):
# A cancelled replacement must not wait indefinitely behind
# an unrelated in-flight callback just to observe cancellation.
while not dispatch_lock.acquire(timeout=0.05):
if run.cancelled.is_set():
return
try:
with self._lock:
if run.cancelled.is_set():
return
run.repeat_index = repeat_index
run.phase_index = phase_index
# Isolate executor mutations from subsequent repetitions.
result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict()))
finally:
dispatch_lock.release()
with self._lock:
if run.cancelled.is_set():
return
if error := _result_error(result):
run.status = "failed"
run.error = error
run.finished_at = self._clock()
return
run.executed_phases += 1
if self._wait(run.cancelled, phase.duration_ms / 1000):
with self._lock:
self._cancel(run, "cancelled")
return
if run.cancelled.is_set():
return
with self._lock:
if not run.cancelled.is_set():
run.status = "completed"
run.finished_at = self._clock()
except Exception as exc: # noqa: BLE001 - any executor failure must stop later phases
with self._lock:
if not run.cancelled.is_set():
run.status = "failed"
run.error = f"{type(exc).__name__}: {exc}"[:1000]
run.finished_at = self._clock()
finally:
with self._lock:
self._workers.pop(run.run_id, None)
@staticmethod
def simulate(schedule: ModeSchedule, execute_phase: ExecutePhase) -> dict[str, Any]:
"""Validate/execute phases synchronously with virtual holds and no thread.
The supplied callback must select the ordinary *dry-run* execution path;
this method never changes an executor's hardware policy implicitly.
"""
if not isinstance(schedule, ModeSchedule) or not callable(execute_phase):
raise ValueError("simulate requires a ModeSchedule and callable phase executor") # noqa: TRY004
schedule = ModeSchedule.from_dict(schedule.to_dict())
report: dict[str, Any] = {
"status": "completed", "completed": True, "error": None,
"execution_mode": "dry_run", "virtual_elapsed_ms": 0,
"total_duration_ms": schedule.total_duration_ms,
"phase_index": -1, "repeat_index": 0, "executed_phases": 0,
"phase_results": [],
}
for repeat_index in range(schedule.repeat_count):
for phase_index, phase in enumerate(schedule.phases):
report.update(phase_index=phase_index, repeat_index=repeat_index)
try:
result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict()))
error = _result_error(result)
except Exception as exc: # noqa: BLE001 - simulation uses the same fail-closed policy
result = None
error = f"{type(exc).__name__}: {exc}"[:1000]
report["phase_results"].append({
"phase_id": phase.id, "phase_index": phase_index,
"repeat_index": repeat_index, "duration_ms": phase.duration_ms,
"virtual_start_ms": report["virtual_elapsed_ms"], "result": result,
})
if error:
report.update(status="failed", completed=False, error=error)
return report
report["executed_phases"] += 1
report["virtual_elapsed_ms"] += phase.duration_ms
return report
def close(self, *, timeout: float = 2.0) -> bool:
"""Cancel all waits; return whether bounded, already-running callbacks exited."""
with self._lock:
self._closed = True
workers = list(self._workers.values())
for run in workers:
self._cancel(run, "closed")
deadline = time.monotonic() + max(0, timeout)
for run in workers:
if run.thread is not None and run.thread is not current_thread():
run.thread.join(max(0, deadline - time.monotonic()))
return all(run.thread is None or not run.thread.is_alive() for run in workers)
@@ -0,0 +1,314 @@
"""Phase-aware natural-language edits, always revalidated before persistence."""
from __future__ import annotations
import json
import re
from collections.abc import Callable
from copy import deepcopy
from typing import Any
from cognitive_agent.application.mode_schedule import ModeSchedule
_NUMBER = r"[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+"
_PHASE = re.compile(r"第(?P<number>[0-9]+|[一二两三四五六七八九十]+)(?:个)?(?:阶段|步)")
_ALL_PHASES = re.compile(r"(?:全部|所有|每个|每一|各个|各)(?:的)?阶段")
_HOLD = re.compile(
rf"(?:持续时间|保持时间|持续|保持|时长|^(?:把|将|的)?(?=调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为))\s*"
rf"(?:调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为)?\s*"
rf"(?P<number>{_NUMBER})\s*(?P<unit>分钟|秒钟|秒|minutes?|mins?|min|seconds?|secs?|s)"
)
_REPEAT = re.compile(
rf"(?:重复|循环)(?:次数)?\s*(?:调整为|调整到|设置为|设为|改为|改成)?\s*"
rf"(?P<number>{_NUMBER})\s*次"
)
def _number(value: str) -> float:
if re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", value):
return float(value)
digits = {char: index for index, char in enumerate("零一二三四五六七八九")}
if value == "两":
return 2
if "十" in value:
tens, ones = value.split("十", 1)
if (tens and tens not in digits) or (ones and ones not in digits):
raise ValueError("请使用有效的整数编号或次数。")
return (digits[tens] if tens else 1) * 10 + (digits[ones] if ones else 0)
if value in digits:
return digits[value]
raise ValueError("请使用明确的阶段编号、持续时间或重复次数。")
def edit_schedule(
schedule: ModeSchedule,
change_text: str,
*,
parse_operations: Callable[[str], list[dict[str, Any]]],
target_is_explicit: Callable[[str, str], bool],
) -> ModeSchedule:
"""Edit one/all phases; an unscoped differing trajectory needs clarification."""
text = change_text.strip()
if not text:
raise ValueError("请说明要修改的阶段、动作、时长或重复次数。")
if re.search(r"(?:秒钟|分钟|秒|s)(?:之后|以后|后|再)", text):
raise ValueError(
"阶段时间表示动作执行后的保持时长,请明确阶段动作和持续时间,不使用延时后执行的表述。"
)
source = schedule.to_dict()
selected: set[int] | None = None
phase_matches = list(_PHASE.finditer(text))
all_match = _ALL_PHASES.search(text)
if all_match:
if phase_matches:
raise ValueError("请分别修改单个阶段或全部阶段,不要混用阶段范围。")
selected = set(range(len(schedule.phases)))
text = _ALL_PHASES.sub("", text)
elif phase_matches:
indexes = {int(_number(match.group("number"))) - 1 for match in phase_matches}
if len(indexes) != 1 or any(
index < 0 or index >= len(schedule.phases) for index in indexes
):
raise ValueError(f"当前只有{len(schedule.phases)}个阶段,请明确一次修改哪一个阶段。")
selected = indexes
text = _PHASE.sub("", text)
else:
named = {index for index, phase in enumerate(schedule.phases) if phase.name in text}
if len(named) > 1:
raise ValueError("请一次修改一个阶段,或明确指定全部阶段。")
if named:
selected = named
phase_name = schedule.phases[next(iter(named))].name
text = text.replace(phase_name + "阶段", "").replace(phase_name, "")
repeats = list(_REPEAT.finditer(text))
if repeats:
if len(repeats) != 1:
raise ValueError("请提供一个明确的整条时间轴重复次数。")
count = _number(repeats[0].group("number"))
if count != int(count):
raise ValueError("重复次数必须为整数。")
source["repeat_count"] = int(count)
text = _REPEAT.sub("", text)
holds = list(_HOLD.finditer(text))
if holds:
if len(holds) != 1 or selected is None:
raise ValueError("修改持续时间需要明确第几个阶段或全部阶段。")
seconds = _number(holds[0].group("number"))
if holds[0].group("unit") in {"分钟", "minute", "minutes", "min", "mins"}:
seconds *= 60
milliseconds = seconds * 1000
if milliseconds != int(milliseconds):
raise ValueError("阶段持续时间最多精确到毫秒。")
for index in selected:
source["phases"][index]["duration_ms"] = int(milliseconds)
text = _HOLD.sub("", text)
if re.search(rf"(?:{_NUMBER})\s*(?:分钟|秒钟|秒|minutes?|mins?|seconds?|secs?|s)", text):
raise ValueError("动态阶段只支持明确绝对设备目标;时长请单独表述为阶段保持时间,不支持按秒运动。")
text = re.sub(r"改为|改成|变更为|换成|变成|变为", "调整为", text)
text = re.sub(
r"^(?:把|将|的|中|里|阶段)+|(?:然后|并且|同时|并|,|,|;|;|。|\s)+$", "", text
).strip("的中里,,;;。 ")
changes = parse_operations(text) if text else []
if not changes and not holds and not repeats:
raise ValueError("没有识别到可执行的阶段调整,请说明设备目标、阶段时长或重复次数。")
for raw_change in changes:
change = deepcopy(raw_change)
indexes = selected if selected is not None else set(range(len(schedule.phases)))
candidates = [
(index, operation)
for index in indexes
for operation in source["phases"][index]["operations"]
if operation["domain"] == change["domain"]
and operation["capability"] == change["capability"]
]
if not target_is_explicit(text, change["target_scope"]):
targets = {operation["target_scope"] for _, operation in candidates}
if len(targets) > 1:
raise ValueError("该阶段的设备有多个区域,请明确主驾、副驾或具体目标区域。")
if targets:
change["target_scope"] = next(iter(targets))
candidates = [
(index, operation)
for index, operation in candidates
if operation["target_scope"] == change["target_scope"]
]
if selected is None:
values = {
json.dumps(operation["parameters"], sort_keys=True, ensure_ascii=False)
for _, operation in candidates
}
if len(values) > 1:
raise ValueError("该设备在多个阶段使用不同目标,请明确第几个阶段,或说“全部阶段”。")
if not candidates:
raise ValueError("新增设备动作需要明确放入第几个阶段,或明确全部阶段。")
indexes = {index for index, _ in candidates}
key = (change["capability"], change["target_scope"])
for index in indexes:
operations = source["phases"][index]["operations"]
found = False
for position, operation in enumerate(operations):
if (operation["capability"], operation["target_scope"]) == key:
operations[position] = {
**deepcopy(operation),
"parameters": deepcopy(change["parameters"]),
}
found = True
if not found:
operations.append(deepcopy(change))
return ModeSchedule.from_dict(source)
def revise_schedule(
service,
schedule: ModeSchedule,
*,
change_text: str,
utterance: str,
name: str,
conversation_id: str,
vehicle_profile,
context: dict[str, Any],
control_transport: str,
source_draft=None,
source_profile=None,
) -> dict[str, Any]:
"""Parse changed atoms locally, then recompile every phase before success."""
def parse_changes(text):
if any(
status != "complete"
for _clause, _parsed, status in service._local_clause_results(text, context)
):
raise ValueError("阶段调整说明不完整,请使用设备和明确目标值;未修改原时间轴。")
result = service._process_once(
text,
"local",
conversation_id,
"dry_run",
None,
vehicle_profile.id,
control_transport=control_transport,
allow_fuzzy_mode_profile_match=False,
)
if (
result.get("status") != "succeeded"
or result.get("deferred_requests")
or result.get("unresolved_clauses")
or result.get("supplement_required")
or any(issue.get("severity") == "error" for issue in result.get("validation", []))
):
raise ValueError("阶段动作未通过完整的设备能力或安全校验。")
operations = service._absolute_mode_operations(service._preview_mode_operations(result))
if not operations:
raise ValueError("未识别到可复用的绝对设备目标。")
return operations
revised = edit_schedule(
schedule,
change_text,
parse_operations=parse_changes,
target_is_explicit=service._mode_update_target_is_explicit,
)
preview = service._preview_mode_schedule(
revised,
name=name,
utterance=utterance,
conversation_id=conversation_id,
vehicle_profile=vehicle_profile,
control_transport=control_transport,
generation_source="draft_revision" if source_draft else "registered_mode_profile",
source_draft=source_draft,
source_profile=source_profile,
)
if preview.get("status") == "succeeded" and source_draft:
draft = preview["mode_profile_draft"]
draft["revises_source_request_id"] = source_draft["source_request_id"]
for key in ("source_template_id", "source_template_revision"):
if key in source_draft:
draft[key] = source_draft[key]
service._persist(preview)
return preview
def update_saved_schedule(
service,
profile,
*,
change_text: str,
utterance: str,
conversation_id: str,
vehicle_profile,
context: dict[str, Any],
result: dict[str, Any],
) -> dict[str, Any]:
try:
preview = revise_schedule(
service,
profile.schedule,
change_text=change_text,
utterance=utterance,
name=profile.name,
conversation_id=conversation_id,
vehicle_profile=vehicle_profile,
context=context,
control_transport="wired_can",
source_profile=profile,
)
if preview.get("status") != "succeeded":
return preview
draft = preview["mode_profile_draft"]
saved = service.save_mode_profile_card(
profile.id,
draft["name"],
draft["instruction"],
draft["source_request_id"],
vehicle_profile.id,
)
updated = service.mode_profiles.get_profile(profile.id, vehicle_profile.id)
report = service._mode_profile_control_report(
result["request_id"],
"update_mode_profile",
updated,
f"已更新“{profile.name}”的阶段时间轴;未向车辆下发控制。",
{"can_command_emitted": False, "phase_count": len(updated.schedule.phases)},
)
result.update(
status="succeeded",
execution_mode="dry_run",
hardware_execution_allowed=False,
orchestration_route="mode_profile_update",
provider_used="mode_schedule_editor",
mode_profile=saved["mode_profile"],
matched_modes=[saved["mode_profile"]],
schedule=updated.schedule.to_dict(),
execution_report=report,
validation=service.contracts.validate(report),
mode_profile_changes=[
deepcopy(op) for phase in updated.schedule.phases for op in phase.operations
],
decision_summary=[
f"已更新“{profile.name}”的阶段时间轴,并完整重新校验全部阶段;未下发车辆控制。"
],
)
except ValueError as exc:
result.update(
status="needs_confirmation",
execution_mode="dry_run",
hardware_execution_allowed=False,
orchestration_route="mode_profile_update",
validation=[
{
"severity": "error",
"code": "MODE_SCHEDULE_UPDATE_REJECTED",
"path": "schedule",
"message": str(exc),
}
],
decision_summary=[f"未修改“{profile.name}”的时间轴:{exc}"],
)
service._persist(result)
return result
@@ -0,0 +1,238 @@
"""Service integration for server-owned, fully validated phase schedules."""
from __future__ import annotations
import uuid
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
from cognitive_agent.application.mode_schedule import ModeSchedule
def public_schedule_status(state: dict[str, Any]) -> dict[str, Any]:
"""Expose lifecycle evidence, never internal errors, commands or CAN frames."""
keys = ("run_id", "vehicle_id", "session_id", "status", "phase_index", "repeat_index",
"executed_phases", "phase_count", "repeat_count", "elapsed_ms", "total_duration_ms",
"virtual_elapsed_ms", "completed", "cancel_reason")
public = {key: value for key in keys if isinstance((value := state.get(key)), (str, int, bool))}
if state.get("error"):
public["error"] = "阶段执行未通过校验或未返回成功结果,后续阶段已停止。"
return public
def phase_plan(phase) -> dict[str, Any]:
operations = phase.operations
return {
"detected_domains": list(dict.fromkeys(op["domain"] for op in operations)),
"requests": [{**{key: op[key] for key in ("domain", "capability", "target_scope")},
"operation": "set", "duration_ms": None,
"parameters": [{"name": key, "value": value} for key, value in op["parameters"].items()],
"reason": op.get("reason") or "已校验时间轴阶段。"} for op in operations],
"ambiguities": [], "confidence": 1.0,
"decision_summary": ["按阶段保留动作顺序与持续时间。"],
}
class ModeScheduleServiceMixin:
def _validated_schedule_artifact(self, result: dict[str, Any]) -> ModeSchedule | None:
draft = result.get("mode_profile_draft") or {}
raw = result.get("schedule")
if raw is None and draft.get("schedule") is None:
return None
schedule = ModeSchedule.from_dict(raw)
if draft.get("schedule") != schedule.to_dict() or draft.get("operation_count") != schedule.operation_count:
raise ValueError("时间轴草稿结构和服务端证据不一致,请重新生成。")
actions = (result.get("intent_ir") or {}).get("spec", {}).get("actions", [])
if len(actions) != schedule.operation_count:
raise ValueError("时间轴草稿动作证据不完整,请重新生成。")
offset = 0
for phase in schedule.phases:
phase_actions = actions[offset:offset + len(phase.operations)]
plan = phase_plan(phase)
if (self._scene_plan_signatures(plan) != self._scene_action_signatures(phase_actions)
or not self._scene_parameters_preserved(plan, phase_actions)):
raise ValueError("时间轴阶段目标与校验结果不一致,请重新生成。")
offset += len(phase.operations)
return schedule
def _preview_mode_schedule(
self, schedule: ModeSchedule, *, name: str, utterance: str,
conversation_id: str, vehicle_profile, control_transport: str,
generation_source: str, source_profile=None, source_template=None,
source_draft: dict[str, Any] | None = None,
scene_scope_utterance: str | None = None,
) -> dict[str, Any]:
results = []
for phase in schedule.phases:
plan = phase_plan(phase)
preview = self._process_once(
f"进入{name}", "local", conversation_id, "dry_run", None,
vehicle_profile.id, control_transport=control_transport,
assistant_plan=plan, allow_scene_composition=True,
allow_fuzzy_mode_profile_match=False,
# An existing-name binding is not evidence that a newly
# supplied model plan came from the saved profile. New builds
# pass the original request even when reusing a saved name.
scene_scope_utterance=(scene_scope_utterance if scene_scope_utterance is not None
else "" if source_draft or source_profile else utterance),
)
actions = (preview.get("intent_ir") or {}).get("spec", {}).get("actions", [])
complete = (preview.get("status") == "succeeded"
and self._scene_plan_signatures(plan) == self._scene_action_signatures(actions)
and self._scene_parameters_preserved(plan, actions))
if not complete:
preview["status"] = "validation_failed"
self._scrub_failed_scene_preview(preview)
preview["mode_creation_failure"] = {
"code": "MODE_SCHEDULE_PHASE_INVALID",
"reason": f"时间轴阶段“{phase.name}”未完整通过设备能力和安全校验",
"next_step": "请调整该阶段的设备目标;没有保存或执行任何阶段",
}
preview["validation"] = [*preview.get("validation", []), {
"severity": "error", "code": "MODE_SCHEDULE_PHASE_INVALID",
"path": f"schedule.phases.{phase.id}",
"message": preview["mode_creation_failure"]["reason"],
}]
self._persist(preview)
return preview
results.append(preview)
result = deepcopy(results[0])
request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}"
instruction = f"进入{name}"
result.update(request_id=request_id, utterance=instruction, status="succeeded",
execution_mode="dry_run", hardware_execution_allowed=False,
orchestration_route="mode_profile_create_preview")
actions = []
for phase, preview in zip(schedule.phases, results, strict=True):
for original in preview["intent_ir"]["spec"]["actions"]:
action = deepcopy(original)
if "id" in action:
action["id"] = f"{phase.id}-{action['id']}"
actions.append(action)
result["intent_ir"]["spec"]["actions"] = actions
for field in ("function_configs", "command_plans"):
result[field] = {f"{phase.id}-{key}": deepcopy(value)
for phase, preview in zip(schedule.phases, results, strict=True)
for key, value in preview[field].items()}
result["function_config"] = None
result["command_plan"] = None
result["schedule"] = schedule.to_dict()
result["schedule_phase_previews"] = [
{"phase_id": phase.id, "source_request_id": preview["request_id"],
"status": preview["status"], "duration_ms": phase.duration_ms}
for phase, preview in zip(schedule.phases, results, strict=True)
]
binding = ({"existing_mode_id": source_profile.id,
"existing_mode_revision": self.mode_profiles.profile_revision(source_profile)}
if source_profile is not None else {})
if source_draft:
binding = {key: source_draft[key] for key in ("existing_mode_id", "existing_mode_revision") if key in source_draft}
result["mode_profile_draft"] = {
"name": name, "instruction": instruction, "source_request_id": request_id,
"vehicle_id": vehicle_profile.id, "operation_count": schedule.operation_count,
"domains": sorted({op["domain"] for phase in schedule.phases for op in phase.operations}),
"generation_source": generation_source, "schedule": schedule.to_dict(),
"draft_revision": int((source_draft or {}).get("draft_revision", 0)) + 1,
**binding,
**({"source_template_id": source_template.id, "source_template_revision": source_template.revision}
if source_template is not None else {}),
}
result["mode_creation"] = {"requested_utterance": utterance, "source_utterance": instruction,
"source_status": "succeeded", "operation_count": schedule.operation_count}
result["decision_summary"] = [
f"已逐阶段验证“{name}”:{len(schedule.phases)}个阶段,重复{schedule.repeat_count}次,总计{schedule.total_duration_ms // 1000}秒。",
"当前仅生成草稿;保存后进入模式才开始计时执行。停止会取消后续阶段,已应用的设备状态保持。",
]
self._persist(result)
return result
def _run_mode_schedule(
self, profile, *, utterance: str, conversation_id: str | None,
execution_mode: str, approval_token: str | None, vehicle_id: str,
simulation_fault: str, hardware_client_id: str | None,
hardware_access_bypass: bool, control_transport: str,
) -> dict[str, Any]:
schedule = ModeSchedule.from_dict(profile.schedule.to_dict())
requested, supported = self._explicit_mode_seat_scopes(profile, utterance)
if requested and not requested.issubset(supported):
raise ValueError("该动态模式不支持指定座位,不会替换或扩大控制目标。")
if requested and requested != supported:
document = schedule.to_dict()
for phase in document["phases"]:
phase["operations"] = [op for op in phase["operations"]
if op["domain"] != "seat" or op["target_scope"] in requested]
# An empty phase is not silently removed: that would alter timing.
schedule = ModeSchedule.from_dict(document)
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
vehicle = self.vehicle_profiles.get(vehicle_id)
# Revalidate the entire saved schedule before the first write. No stale
# cached command bytes or client-authored phase plans are replayed.
preview = self._preview_mode_schedule(
schedule, name=profile.name, utterance="已保存时间轴复核",
conversation_id=conversation_id, vehicle_profile=vehicle,
control_transport=control_transport, generation_source="registered_mode_profile",
source_profile=profile,
)
if preview["status"] != "succeeded":
return preview
def execute_phase(phase):
context = self._context_for_vehicle(self.conversations.snapshot(conversation_id), vehicle)
parsed, _, _ = self._interpret(
"执行已验证的时间轴阶段", "local", context, vehicle,
assistant_plan=phase_plan(phase), allow_scene_composition=True,
scene_scope_utterance="",
)
return self.process(
"执行已验证的时间轴阶段", provider="local", conversation_id=conversation_id,
execution_mode=execution_mode, approval_token=approval_token,
vehicle_id=vehicle_id, simulation_fault=simulation_fault,
hardware_client_id=hardware_client_id, hardware_access_bypass=hardware_access_bypass,
control_transport=control_transport, _direct_can_batch=([], parsed),
)
if execution_mode == "hardware":
# Authentication/bench ownership is checked now and again for each
# phase, so a later lease loss stops further device commands.
if not hardware_access_bypass and control_transport != "wireless_mqtt" and not self._driver_only_vehicle_execution_active(control_transport):
self.hardware_access.ensure_authorized(hardware_client_id)
execution = self.mode_schedule_runner.start(vehicle_id, schedule, execute_phase, session_id=conversation_id)
else:
execution = self.mode_schedule_runner.simulate(schedule, execute_phase)
result = deepcopy(preview)
result.pop("mode_profile_draft", None)
result.update(
status="running" if execution["status"] == "running" else "succeeded" if execution.get("completed") else "failed",
utterance=utterance, execution_mode=execution_mode,
hardware_execution_allowed=execution_mode == "hardware",
orchestration_route="mode_schedule_execute", schedule_execution=execution,
mode_profile=profile.public_dict(), matched_modes=[profile.public_dict()],
)
result["decision_summary"] = [
f"已启动“{profile.name}”时间轴。" if execution["status"] == "running"
else f"“{profile.name}”已按虚拟时钟验证全部阶段。" if execution.get("completed")
else "时间轴执行失败,后续阶段已停止。",
"停止动态模式可取消后续阶段;已下发的设备目标不会自动回滚。",
]
self._persist(result)
return result
def _cancel_mode_schedule(self, vehicle_id: str, conversation_id: str | None, utterance: str,
execution_mode: str) -> dict[str, Any]:
state = (self.mode_schedule_runner.cancel(vehicle_id) if execution_mode == "hardware"
else {"status": "simulated", "completed": True, "vehicle_id": vehicle_id})
result = {
"request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
"status": "succeeded", "execution_mode": execution_mode, "hardware_execution_allowed": False,
"vehicle": {"id": vehicle_id},
"utterance": utterance, "conversation_id": conversation_id or f"conv-{uuid.uuid4().hex}",
"orchestration_route": "mode_schedule_stop", "schedule_execution": state,
"decision_summary": [
"已模拟停止动态模式,未改变正在运行的车辆任务。" if execution_mode != "hardware"
else "已停止动态模式后续阶段。已下发目标保持,不自动回滚。" if state.get("status") == "cancelled"
else "当前没有正在执行的动态时间轴。"
],
"validation": [], "trace_events": [],
}
self._persist(result)
return result
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import math
from collections.abc import Mapping, Sequence
from copy import deepcopy
from itertools import combinations
from typing import Any
@@ -13,8 +14,13 @@ from cognitive_agent.application.assistant_plan_guard import (
MAX_PLAN_PARAMETERS,
MAX_PLAN_REQUESTS,
)
from cognitive_agent.application.mode_creation_seat_policy import (
creation_seat_scopes,
normalized_seat_scope,
)
from cognitive_agent.application.mode_schedule import ModeSchedule
SCENE_PLANNER_VERSION = "n7web-scene-composer/v2"
SCENE_PLANNER_VERSION = "n7web-scene-composer/v5"
_MAX_OUTPUT_SCHEMA_BYTES = 64 * 1024
_JSON_PARAMETER_TYPES = frozenset({"boolean", "integer", "number", "string"})
@@ -46,14 +52,81 @@ For a commonly understood scene, infer conservative cabin settings. If the name
meaningless, unsafe, contradictory, or cannot be grounded in the supplied catalog,
return no requests and a short ambiguity. The application will independently check
the complete live registry, parameters, compiler, safety policy, and dry-run result.
The primary user need must be represented in the actions. For sitting comfort,
seating relaxation or rest, consider supported seat functions before auxiliary
climate and lights; climate and lights alone do not fulfil a seating request.
Mode creation defaults to the passenger seat only. Include driver seat actions
only when the user explicitly selects the driver; in that case omit passenger
seat actions. Include both seats only when explicitly requested. Respect seat
exclusions. Seat selection does not exclude unrelated cabin devices or confuse
a driver/passenger HVAC zone with a request to move that seat. Never reinterpret
rear or unknown seats as either front seat. The seat_scope_policy in the context
is authoritative. Use each selected seat's own listed capabilities; never
retarget a passenger capability to the driver or vice versa.
Driver heating/ventilation native levels use 2 for low and 1 for high; passenger
levels use 1 for low. Driver massage supports action=mode with one listed mode,
action=strength with strength, or action=off. Do not mix these action parameters.
Driver absolute axis positions, unlearned memory slots and passenger massage must
not be invented. Do not use timed axis movement as a substitute for an absolute
posture; leave unsupported axes unchanged. If the primary requested seat function
is unavailable, return an ambiguity rather than an unrelated cabin-only scene.
Return only the JSON object required by the output schema.
"""
SCHEDULE_COMPOSER_INSTRUCTIONS = SCENE_COMPOSER_INSTRUCTIONS + """
This request requires a bounded temporal mode, not one static final state.
The static root shape above is replaced by the phase schema below. Capability
uniqueness and the 12-action limit apply within each phase, not across phases.
The ban on timed actions means motor-duration commands, not bounded phase holds.
Return an empty root requests array and a schedule containing 2-8 ordered phases.
Each phase has id, name, duration_ms and requests using the same capability contract.
Hold a phase for its duration, then execute the next phase; repeat_count is 1-20.
Every phase lasts 1 second to 15 minutes; total duration including repeats <= 1 hour.
Use a finite, conservative timeline. Boarding warmup should first warm the cabin
then reduce temperature/fan for comfort. Seat dynamic modes must apply the same
selected seat scope in every phase: default passenger absolute targets, or driver
comfort states if the user selected the driver; never invent driver axes or add
the other seat merely to make the timeline dynamic.
Respect explicit user temperatures, times, repetitions, seat scopes and exclusions.
Do not collapse successive values of the same device into a single final value.
No unattended calendar scheduling or wakeup is supported. Stop cancels future phases;
already applied states remain. If the requested timing is unsupported, give ambiguity.
"""
class SceneCompositionError(ValueError):
"""Raised when a model scene candidate cannot enter the local safety chain."""
def scene_context_for_mode_creation(
context: Mapping[str, object], message: str,
) -> dict[str, object]:
"""Constrain model seat choices without hiding unrelated cabin capabilities."""
projected = deepcopy(dict(context))
scopes = creation_seat_scopes(message)
capabilities = []
for capability in projected.get("capabilities", []):
if not isinstance(capability, dict):
continue
if capability.get("domain") != "seat":
capabilities.append(capability)
continue
selected_targets = [target for target in capability.get("target_scopes", [])
if normalized_seat_scope(target) in scopes]
if selected_targets:
capability["target_scopes"] = selected_targets
capabilities.append(capability)
projected["capabilities"] = capabilities
projected["seat_scope_policy"] = {
"selected_scopes": sorted(scopes),
"default_scope": "passenger",
"applies_to": "seat capabilities in every phase",
}
return projected
def _is_relative_parameter(parameter: Mapping[str, object]) -> bool:
"""Return whether a parameter describes a transient or relative movement."""
@@ -179,6 +252,29 @@ def _capability_schema_branch(capability: Mapping[str, object]) -> dict[str, obj
}
for names in parameter_combinations
]
if capability_id == "seat.set_driver_massage_native":
# The native command discriminates mode, strength and off. Its YAML
# scalar contracts cannot express that dependency on their own.
parameter_object_branches = []
action_schema = value_schemas.get("action", {})
for action, dependent_name in (("off", None), ("mode", "mode"), ("strength", "strength")):
if action not in action_schema.get("enum", []):
continue
if dependent_name is not None and dependent_name not in value_schemas:
continue
properties = {"action": {"type": "string", "enum": [action]}}
if dependent_name is not None:
properties[dependent_name] = value_schemas[dependent_name]
parameter_object_branches.append(
{
"type": "object",
"additionalProperties": False,
"required": list(properties),
"properties": properties,
}
)
if not parameter_object_branches:
return None
return {
"type": "object",
"additionalProperties": False,
@@ -193,7 +289,7 @@ def _capability_schema_branch(capability: Mapping[str, object]) -> dict[str, obj
}
def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object]:
def build_scene_output_schema(context: Mapping[str, object], *, dynamic: bool = False) -> dict[str, object]:
"""Build a compact strict schema from the projected scene capability data."""
raw_capabilities = context.get("capabilities", [])
@@ -231,6 +327,27 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
},
}
if dynamic:
phase_requests = schema["properties"]["requests"]
schema["properties"]["requests"] = {"type": "array", "maxItems": 0, "items": {"type": "string"}}
schema["required"].append("schedule")
schema["properties"]["schedule"] = {
"anyOf": [{"type": "null"}, {
"type": "object", "additionalProperties": False,
"required": ["repeat_count", "phases"],
"properties": {
"repeat_count": {"type": "integer", "minimum": 1, "maximum": 20},
"phases": {"type": "array", "minItems": 2, "maxItems": 8,
"items": {"type": "object", "additionalProperties": False,
"required": ["id", "name", "duration_ms", "requests"],
"properties": {
"id": {"type": "string"}, "name": {"type": "string"},
"duration_ms": {"type": "integer", "minimum": 1000, "maximum": 900000},
"requests": phase_requests,
}}},
},
}],
}
encoded_schema = json.dumps(
schema,
ensure_ascii=False,
@@ -244,9 +361,41 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object
def normalize_scene_output(
value: Mapping[str, Any],
context: Mapping[str, object],
*, dynamic: bool = False,
) -> dict[str, Any]:
"""Expand the lean model response into the existing guarded plan contract."""
if dynamic:
if not isinstance(value, Mapping) or set(value) != {"requests", "ambiguities", "confidence", "schedule"}:
raise SceneCompositionError("dynamic scene response must include a schedule")
if value["requests"]:
raise SceneCompositionError("dynamic scene must not mix static root requests")
base = normalize_scene_output({key: value[key] for key in ("requests", "ambiguities", "confidence")}, context)
raw_schedule = value["schedule"]
if raw_schedule is None:
return base
if not isinstance(raw_schedule, Mapping) or set(raw_schedule) != {"repeat_count", "phases"}:
raise SceneCompositionError("invalid dynamic scene schedule")
phases = []
if not isinstance(raw_schedule["phases"], list):
raise SceneCompositionError("dynamic phases must be an array")
for phase in raw_schedule["phases"]:
if not isinstance(phase, Mapping) or set(phase) != {"id", "name", "duration_ms", "requests"}:
raise SceneCompositionError("invalid dynamic phase")
normalized = normalize_scene_output({"requests": phase["requests"], "ambiguities": [], "confidence": value["confidence"]}, context)
operations = [{"domain": op["domain"], "capability": op["capability"],
"target_scope": op["target_scope"], "operation": "set",
"parameters": {p["name"]: p["value"] for p in op["parameters"]},
"reason": "经过时间轴编排的候选状态。"} for op in normalized["requests"]]
phases.append({"id": phase["id"], "name": phase["name"], "duration_ms": phase["duration_ms"], "operations": operations})
try:
schedule = ModeSchedule.from_dict({"schema": "cognitive-agent-mode-schedule/v1", "repeat_count": raw_schedule["repeat_count"], "phases": phases})
except (TypeError, ValueError) as exc:
raise SceneCompositionError(str(exc)) from exc
first = normalize_scene_output({"requests": raw_schedule["phases"][0]["requests"], "ambiguities": value["ambiguities"], "confidence": value["confidence"]}, context)
first["schedule"] = schedule.to_dict()
return first
if not isinstance(value, Mapping) or set(value) != {
"requests",
"ambiguities",
@@ -24,11 +24,13 @@ from cognitive_agent.application.assistant_plan_guard import (
SCENE_COMPOSABLE_CAPABILITIES,
)
from cognitive_agent.application.mode_profile import ModeOperation
from cognitive_agent.application.mode_profile_intent import mode_creation_source_name
from cognitive_agent.application.mode_schedule import ModeSchedule
_TEMPLATE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
_SURROUNDING_PUNCTUATION = " \t\r\n,。,.!!??;;::'\"‘’“”《》<>【】[]()()"
_DOCUMENT_FIELDS = frozenset(
{"id", "name", "aliases", "description", "icon", "operations"}
{"id", "name", "aliases", "description", "icon", "operations", "schedule"}
)
@@ -66,6 +68,7 @@ class SceneTemplate:
operations: tuple[ModeOperation, ...]
source: str
revision: str
schedule: ModeSchedule | None = None
@classmethod
def from_yaml(cls, path: Path) -> "SceneTemplate":
@@ -100,7 +103,13 @@ class SceneTemplate:
alias_keys.add(key)
aliases.append(alias)
schedule = (ModeSchedule.from_dict(document["schedule"])
if document.get("schedule") is not None else None)
raw_operations = document.get("operations")
if schedule is not None:
if raw_operations:
raise ValueError(f"{path} cannot mix static operations and a schedule")
raw_operations = list(schedule.phases[0].operations)
if not isinstance(raw_operations, list) or not raw_operations:
raise ValueError(f"{path} must contain at least one operation")
if len(raw_operations) > MAX_PLAN_REQUESTS:
@@ -155,6 +164,8 @@ class SceneTemplate:
),
),
}
if schedule is not None:
semantic_document["schedule"] = schedule.to_dict()
canonical = json.dumps(
semantic_document,
ensure_ascii=False,
@@ -171,6 +182,7 @@ class SceneTemplate:
operations=tuple(operations),
source=str(path),
revision=revision,
schedule=schedule,
)
def plan(self) -> dict[str, Any]:
@@ -179,7 +191,7 @@ class SceneTemplate:
request = operation.to_request(self.name)
request["duration_ms"] = None
requests.append(request)
return {
plan = {
"detected_domains": list(
dict.fromkeys(operation.domain for operation in self.operations)
),
@@ -195,6 +207,9 @@ class SceneTemplate:
"template_revision": self.revision,
},
}
if self.schedule is not None:
plan["schedule"] = self.schedule.to_dict()
return plan
def public_dict(self, *, include_plan: bool = False) -> dict[str, Any]:
result: dict[str, Any] = {
@@ -204,13 +219,16 @@ class SceneTemplate:
"description": self.description,
"icon": self.icon,
"domains": list(dict.fromkeys(item.domain for item in self.operations)),
"operation_count": len(self.operations),
"operation_count": self.schedule.operation_count if self.schedule else len(self.operations),
"template_revision": self.revision,
"origin": "builtin_scene_template",
"read_only": True,
}
if include_plan:
result["plan"] = self.plan()
if self.schedule is not None:
result["schedule"] = self.schedule.to_dict()
result["domains"] = sorted({op["domain"] for phase in self.schedule.phases for op in phase.operations})
return result
@@ -257,7 +275,11 @@ class SceneTemplateRegistry:
def resolve_exact_name(self, value: object) -> SceneTemplate | None:
key = normalize_scene_template_name(value)
return self._by_name.get(key) if key else None
exact = self._by_name.get(key) if key else None
if exact is not None:
return exact
subject = normalize_scene_template_name(mode_creation_source_name(value))
return self._by_name.get(subject) if subject and subject != key else None
def get_template(self, template_id: str) -> SceneTemplate:
try:
@@ -0,0 +1,25 @@
"""Narrow detection of temporal scene needs; never authorizes execution."""
from __future__ import annotations
import re
_TEMPORAL = re.compile(
r"动态|循环|分阶段|随时间|交替|轮流|每隔|预热|预冷|"
r"(?:秒|分钟|分|小时)(?:钟)?(?:后|再)|持续.{0,8}(?:秒|分钟|小时)"
)
_STOP = re.compile(
r"^(?:请|帮我|立即|马上|先)?(?:停止|停下|结束|取消|关闭)"
r"(?:当前|正在执行的|正在运行的|这个|该)?(?:座椅|空调|功能)?(?:动态|循环|分阶段|时间轴|定时)"
r"(?:座椅|空调|功能)?(?:模式|编排|任务)?[。!!\s]*$"
)
def needs_mode_schedule(text: str) -> bool:
# HVAC recirculation is a stable device enum, not a timed repetition.
normalized = re.sub(r"内外循环|内循环|外循环|自动循环|新风循环|循环风", "空气循环状态", text)
normalized = normalized.replace("空气循环状态", "空气状态")
return bool(_TEMPORAL.search(normalized))
def is_schedule_stop(text: str) -> bool:
return bool(_STOP.fullmatch(text.strip()))
+442 -109
View File
@@ -49,6 +49,16 @@ from cognitive_agent.application.direct_can_control import ( # noqa: E402
build_direct_can_intent,
)
from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime # noqa: E402
from cognitive_agent.application.mode_creation_seat_policy import ( # noqa: E402
ModeCreationSeatPolicyError,
creation_seat_scopes,
project_seed_operations,
project_seed_plan,
project_seed_schedule,
)
from cognitive_agent.application.mode_device_additions import ( # noqa: E402
mode_device_additions,
)
from cognitive_agent.application.mode_profile import ( # noqa: E402
ModeProfile,
ModeProfileRegistry,
@@ -57,6 +67,14 @@ from cognitive_agent.application.mode_profile import ( # noqa: E402
from cognitive_agent.application.mode_scene_ingress import ( # noqa: E402
ModeSceneIngressConsumer,
)
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
is_mode_profile_query,
mode_creation_source_name,
normalize_mode_profile_name,
parse_mode_profile_create,
parse_mode_profile_delete,
parse_mode_profile_update,
)
from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
DEFAULT_N7_SCENE_SERVER_ID,
DEFAULT_N7_SCENE_SERVER_NAME,
@@ -64,13 +82,8 @@ from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
ModeSceneMqttBurstPublisher,
build_mode_scene_catalog,
)
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
is_mode_profile_query,
normalize_mode_profile_name,
parse_mode_profile_create,
parse_mode_profile_delete,
parse_mode_profile_update,
)
from cognitive_agent.application.mode_schedule import ModeSchedule, ModeScheduleRunner # noqa: E402
from cognitive_agent.application.mode_schedule_service import ModeScheduleServiceMixin # noqa: E402
from cognitive_agent.application.model_plan_adapter import ( # noqa: E402
APPLY_PRESET_CAPABILITY,
DYNAMIC_MODE_STOP_CAPABILITY,
@@ -103,6 +116,10 @@ from cognitive_agent.application.native_can_runtime import ( # noqa: E402
NativeCanRuntime,
)
from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402
from cognitive_agent.application.schedule_intent import ( # noqa: E402
is_schedule_stop,
needs_mode_schedule,
)
from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402
from cognitive_agent.application.semantic_ir import ( # noqa: E402
apply_language_safety_guards,
@@ -356,8 +373,9 @@ DYNAMIC_MODE_WORDS = (
)
class SeatDemoService:
class SeatDemoService(ModeScheduleServiceMixin):
def __init__(self, repository_root: Path = REPOSITORY_ROOT):
self.mode_schedule_runner = ModeScheduleRunner()
self.root = repository_root
self.domain_root = repository_root / "domains" / "seat"
self.contracts = ContractValidator(repository_root)
@@ -1190,7 +1208,7 @@ class SeatDemoService:
resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id
profile = self.mode_profiles.get_profile(mode_id, resolved_vehicle_id)
if not profile.command_flow or not profile.command_flow.get("plans"):
if profile.schedule is None and (not profile.command_flow or not profile.command_flow.get("plans")):
raise ValueError("该模式尚未生成可直接执行的控制指令流。")
result = self.process(
profile.instruction or f"进入{profile.name}",
@@ -1205,6 +1223,8 @@ class SeatDemoService:
control_transport=control_transport,
_direct_mode_profile_id=profile.id,
)
if profile.schedule is not None:
return result
public_profile = profile.public_dict()
result["provider_used"] = "saved_mode_command_flow"
result["utterance"] = profile.name
@@ -1323,7 +1343,7 @@ class SeatDemoService:
):
raise ValueError("功能模式草稿未通过安全校验,请重新生成。")
return {
trial = {
"request_id": clean_request_id,
"status": "succeeded",
"execution_mode": "dry_run",
@@ -1336,6 +1356,14 @@ class SeatDemoService:
"validation": deepcopy(validation),
"trial_source": "server_validated_preview_artifact",
}
schedule = self._validated_schedule_artifact(source_result)
if schedule is not None:
trial["schedule"] = schedule.to_dict()
trial["schedule_execution"] = self.mode_schedule_runner.simulate(
schedule, lambda phase: {"status": "succeeded", "phase_id": phase.id,
"operations": deepcopy(list(phase.operations))},
)
return trial
def _persist_mode_profile_card(
self,
@@ -1534,6 +1562,11 @@ class SeatDemoService:
# generation workflow.
"execution_steps": [],
}
schedule = self._validated_schedule_artifact(source_result)
if profile is not None and profile.schedule is not None and schedule is None:
raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。")
if schedule is not None:
operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations]
saved = self.mode_profiles.save_mode(
clean_name,
operations,
@@ -1553,6 +1586,7 @@ class SeatDemoService:
upstream_origin=(
profile.upstream_origin if profile is not None else ""
),
schedule=schedule,
expected_revision=draft_existing_mode_revision or None,
create_only=not bool(clean_mode_id),
)
@@ -1753,6 +1787,7 @@ class SeatDemoService:
self._vehicle_mqtt_transport.start()
def close(self) -> None:
self.mode_schedule_runner.close()
if self._vehicle_state_monitor is not None:
self._vehicle_state_monitor.close()
self._mode_scene_mqtt_publisher.close()
@@ -2761,6 +2796,32 @@ class SeatDemoService:
or mode_profile_query
):
execution_mode = "dry_run"
schedule_requested = "模式" in utterance or _direct_mode_profile_id or is_schedule_stop(utterance)
schedule_vehicle_id = vehicle_id or (self.vehicle_profiles.default().id if schedule_requested else "")
if execution_mode not in {"dry_run", "simulated_hardware", "hardware"}:
raise ValueError(f"unsupported execution mode: {execution_mode}")
if is_schedule_stop(utterance):
return self._cancel_mode_schedule(schedule_vehicle_id, conversation_id, utterance, execution_mode)
schedule_profile = (self.mode_profiles.get_profile(_direct_mode_profile_id, schedule_vehicle_id)
if _direct_mode_profile_id else None)
if schedule_requested and schedule_profile is None and not (draft_update or mode_delete_name or mode_profile_query or parse_mode_profile_create(utterance)):
candidates = self.mode_profiles.match_text(utterance, vehicle_id=schedule_vehicle_id)
if len(candidates) == 1 and candidates[0].schedule is not None:
aliases = "|".join(re.escape(name) for name in (candidates[0].name, *candidates[0].aliases))
if re.fullmatch(
r"(?:(?:请|麻烦|帮我|帮忙|给我|我想|我要|想要)\s*){0,4}"
r"(?:(?:主驾|副驾|驾驶位|副驾驶|主驾驶)(?:座椅)?\s*)?"
r"(?:进入|启动|执行|开启|启用|运行|打开|调用|应用|使用|切换到)\s*"
rf"(?:{aliases})(?:吧|一下)?[。!!\s]*", utterance.strip(),
):
schedule_profile = candidates[0]
if schedule_profile is not None and schedule_profile.schedule is not None:
return self._run_mode_schedule(
schedule_profile, utterance=utterance, conversation_id=conversation_id,
execution_mode=execution_mode, approval_token=approval_token, vehicle_id=schedule_vehicle_id,
simulation_fault=simulation_fault, hardware_client_id=hardware_client_id,
hardware_access_bypass=hardware_access_bypass, control_transport=control_transport,
)
if execution_mode != "hardware":
return self._process_once(
utterance,
@@ -2899,6 +2960,8 @@ class SeatDemoService:
assistant_plan: dict[str, Any] | None = None,
allow_fuzzy_mode_profile_match: bool = True,
allow_scene_composition: bool = False,
scene_scope_utterance: str | None = None,
mode_creation_scopes: frozenset[str] | None = None,
mode_profile_draft_context: dict[str, Any] | None = None,
cloud_instruction_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
@@ -3158,6 +3221,8 @@ class SeatDemoService:
mode_requests, n7_transfer_actions = (
self._mode_requests_and_n7_transfers(direct_mode_profile)
)
if mode_creation_scopes is not None:
mode_requests = project_seed_operations(mode_requests, mode_creation_scopes)
detected_domains = list(
dict.fromkeys(
str(item.get("domain") or "")
@@ -3253,9 +3318,13 @@ class SeatDemoService:
model_parse_source=model_parse_source,
assistant_plan=assistant_plan,
allow_scene_composition=allow_scene_composition,
scene_scope_utterance=scene_scope_utterance,
cloud_instruction_context=cloud_instruction_context,
)
dynamic_mode_stop = self._is_dynamic_mode_stop_request(utterance)
scene_label_only = assistant_plan is not None and allow_scene_composition
dynamic_mode_stop = (
not scene_label_only and self._is_dynamic_mode_stop_request(utterance)
)
if dynamic_mode_stop:
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
# Reuse the same conservative clause segmentation for the final
@@ -3263,7 +3332,12 @@ class SeatDemoService:
# comma between independent controls can leak an early target
# (for example ``副驾``) into a later HVAC clause and can also
# hide otherwise valid window/temperature actions.
segmented_utterance = ",".join(
# Name-only scene plans have already been checked against exact
# targets by _interpret. Their synthesized 进入<name> label is not
# another atomic instruction: reparsing 座椅舒适模式 here applies
# the unscoped-seat driver default and discards valid passenger
# actions. Explicit user instructions never enter this branch.
segmented_utterance = "" if scene_label_only else ",".join(
self._split_intent_clauses(utterance)
)
relative_resolution = (
@@ -3345,7 +3419,12 @@ class SeatDemoService:
parsed,
excluded_control_signatures,
)
parsed = apply_language_safety_guards(parsed, utterance)
parsed = apply_language_safety_guards(
parsed,
utterance,
scene_composition=assistant_plan is not None and allow_scene_composition,
scene_scope_utterance=scene_scope_utterance,
)
parsed = self._partition_native_actions(
parsed,
promote_supported=(
@@ -4413,6 +4492,16 @@ class SeatDemoService:
) -> dict[str, Any]:
if not matched_modes:
return parsed
if any(mode.schedule is not None for mode in matched_modes):
rejected = deepcopy(parsed)
for key in ("actions", "domain_actions", "native_can_actions", "preset_actions",
"preset_events", "deferred_requests", "model_requests"):
rejected[key] = []
rejected["ambiguities"] = [{
"field": "mode_profile",
"question": "动态模式必须通过时间轴执行,请独立输入“进入模式名称”;不会将各阶段压平执行。",
}]
return rejected
if self._contains_seat_preset_request(parsed):
return parsed
if len(matched_modes) > 1:
@@ -4496,6 +4585,19 @@ class SeatDemoService:
mode_requests, n7_transfer_actions = (
self._mode_requests_and_n7_transfers(matched_profile)
)
selected_seat_scope = self._explicit_mode_seat_selection(
matched_profile, utterance,
)
if selected_seat_scope is not None:
# A cabin profile can carry independent comfort operations for both
# front seats. An explicit single-seat invocation selects only that
# seat; it must not authorize its sibling or mutate the stored mode.
mode_requests = [
request for request in mode_requests
if request["domain"] != "seat"
or self._normalized_mode_seat_scope(request["target_scope"])
in selected_seat_scope
]
detected_domains = []
for request in mode_requests:
if request["domain"] not in detected_domains:
@@ -4516,6 +4618,24 @@ class SeatDemoService:
context,
vehicle_profile,
)
# Native-only comfort capabilities are represented as deferred items by
# the generic adapter. A verified stored mode must materialize them too;
# otherwise a successful named-mode run silently drops its driver seat.
mode_native_actions = []
for item in mode_parsed.get("deferred_requests", []):
if item.get("execution_backend") != "native_can":
continue
action = {
"domain": item["domain"],
"capability": item["capability"],
"target": item["target_scope"],
"parameters": deepcopy(item.get("parameters", {})),
"operation": item.get("desired_change", "set"),
"reason": item.get("reason", ""),
"execution_backend": "native_can",
}
if self.native_can_runtime.supports(action):
mode_native_actions.append(action)
parsed_has_explicit_request = bool(
parsed.get("actions")
or parsed.get("domain_actions")
@@ -4532,6 +4652,10 @@ class SeatDemoService:
mode_parsed.get("domain_actions", []),
parsed.get("domain_actions", []),
)
merged["native_can_actions"] = self._merge_control_actions(
mode_native_actions,
parsed.get("native_can_actions", []),
)
for key in (
"preset_actions",
"preset_events",
@@ -4583,13 +4707,35 @@ class SeatDemoService:
merged["_saved_mode_local_peripheral"] = True
return merged
@staticmethod
@classmethod
def _explicit_mode_target_conflict(
cls,
profile: ModeProfile,
utterance: str,
) -> tuple[str, tuple[str, ...]] | None:
"""Reject a named mode when its persisted seat scope contradicts text."""
requested, normalized_scopes = cls._explicit_mode_seat_scopes(profile, utterance)
if requested is None or requested == normalized_scopes:
return None
if cls._explicit_mode_seat_selection(profile, utterance) is not None:
return None
return ",".join(sorted(requested)), tuple(sorted(normalized_scopes))
@staticmethod
def _normalized_mode_seat_scope(scope: str) -> str:
return (
"driver" if scope == "front_left" else
"passenger" if scope == "front_right" else
"rear" if scope in {"rear", "rear_left", "rear_right"} else scope
)
@classmethod
def _explicit_mode_seat_scopes(
cls,
profile: ModeProfile,
utterance: str,
) -> tuple[frozenset[str] | None, set[str]]:
aliases = (profile.id, profile.name, *profile.aliases)
mode_clause = next(
(
@@ -4605,23 +4751,34 @@ class SeatDemoService:
utterance,
)
requested = resolve_explicit_seat_scopes(
mode_clause,
mode_clause.replace("主副驾", "主驾和副驾"),
assume_seat_context=True,
)
if requested is None:
return None
normalized_scopes = {
"driver" if operation.target_scope == "front_left" else
"passenger" if operation.target_scope == "front_right" else
"rear" if operation.target_scope in {"rear", "rear_left", "rear_right"} else
operation.target_scope
cls._normalized_mode_seat_scope(operation.target_scope)
for operation in profile.operations
if operation.domain == "seat"
}
if requested == normalized_scopes:
return requested, normalized_scopes
@classmethod
def _explicit_mode_seat_selection(
cls,
profile: ModeProfile,
utterance: str,
) -> frozenset[str] | None:
"""Scope a dual-seat mode invocation, never mode creation or editing."""
requested, supported = cls._explicit_mode_seat_scopes(profile, utterance)
if (
requested is None
or len(requested) != 1
or supported != {"driver", "passenger"}
or not requested.issubset(supported)
or cls.is_mode_profile_configuration_request(utterance)
or not re.search(r"进入|开启|启动|执行|启用|运行|切换|打开|调用|应用|使用", utterance)
):
return None
return ",".join(sorted(requested)), tuple(sorted(normalized_scopes))
return requested
@staticmethod
def _apply_implicit_mode_target_policy(
@@ -4954,7 +5111,9 @@ class SeatDemoService:
for issue in validation
):
raise ValueError("待保存功能模式草稿没有通过完整安全校验。")
operations = self._preview_mode_operations(source_result)
schedule = self._validated_schedule_artifact(source_result)
operations = ([deepcopy(op) for phase in schedule.phases for op in phase.operations]
if schedule is not None else self._preview_mode_operations(source_result))
operation_count = draft.get("operation_count")
if (
not isinstance(operation_count, int)
@@ -5035,6 +5194,17 @@ class SeatDemoService:
"confidence": 1.0,
}
def _mode_device_default_operations(
self,
mode_name: str,
) -> list[dict[str, Any]]:
"""Return registered static-scene defaults for a bounded device append."""
template = self.scene_templates.resolve_exact_name(mode_name)
if template is None or template.schedule is not None:
return []
return [operation.to_yaml() for operation in template.operations]
@staticmethod
def _mode_update_target_is_explicit(change_text: str, target: str) -> bool:
patterns = {
@@ -5084,79 +5254,99 @@ class SeatDemoService:
vehicle_id=vehicle_profile.id,
)
)
if source_draft.get("schedule") is not None:
from cognitive_agent.application.mode_schedule_edit import revise_schedule
return revise_schedule(
self, ModeSchedule.from_dict(source_draft["schedule"]),
change_text=change_text, utterance=utterance, name=mode_name,
conversation_id=conversation_id, vehicle_profile=vehicle_profile,
context=context, control_transport=control_transport,
source_draft=source_draft,
)
base_operations = self._absolute_mode_operations(source_operations)
if not change_text:
raise ValueError("请说明要调整的设备和明确目标值。")
parseable_change_text = re.sub(
r"改为|改成|变更为|换成|变成|变为",
"调整为",
registered_additions = mode_device_additions(
change_text,
base_operations,
self._mode_device_default_operations(mode_name),
)
unresolved_change_clauses = [
clause
for clause, _parsed, status in self._local_clause_results(
parseable_change_text,
context,
if registered_additions is not None:
normalized_changes = self._absolute_mode_operations(
registered_additions
)
if status != "complete"
]
if unresolved_change_clauses and assistant_plan is None:
raise ValueError(
"调整说明包含未完整识别的内容:"
+ ";".join(unresolved_change_clauses)
else:
parseable_change_text = re.sub(
r"改为|改成|变更为|换成|变成|变为",
"调整为",
change_text,
)
change_result = self._process_once(
parseable_change_text,
provider,
conversation_id,
"dry_run",
approval_token,
vehicle_profile.id,
simulation_fault,
control_transport=control_transport,
assistant_plan=assistant_plan,
allow_fuzzy_mode_profile_match=False,
)
change_operations = self._absolute_mode_operations(
self._preview_mode_operations(change_result)
)
if (
change_result.get("status") != "succeeded"
or not change_operations
or change_result.get("supplement_required") is True
or bool(change_result.get("unresolved_clauses"))
or bool(change_result.get("deferred_requests"))
):
raise ValueError("没有从调整说明中得到完整、可复用的设备目标。")
validation = change_result.get("validation")
if isinstance(validation, list) and any(
isinstance(issue, dict) and issue.get("severity") == "error"
for issue in validation
):
raise ValueError("调整动作没有通过车辆能力或安全校验。")
normalized_changes: list[dict[str, Any]] = []
for change in change_operations:
same_capability = [
item
for item in base_operations
if item["domain"] == change["domain"]
and item["capability"] == change["capability"]
]
normalized = deepcopy(change)
if len(same_capability) == 1 and not self._mode_update_target_is_explicit(
change_text,
str(change["target_scope"]),
):
normalized["target_scope"] = same_capability[0]["target_scope"]
elif len(same_capability) > 1 and not self._mode_update_target_is_explicit(
change_text,
str(change["target_scope"]),
):
raise ValueError(
f"“{mode_name}”中该设备能力包含多个目标,请明确主驾、副驾或具体区域。"
unresolved_change_clauses = [
clause
for clause, _parsed, status in self._local_clause_results(
parseable_change_text,
context,
)
normalized_changes.append(normalized)
if status != "complete"
]
if unresolved_change_clauses and assistant_plan is None:
raise ValueError(
"调整说明包含未完整识别的内容:"
+ ";".join(unresolved_change_clauses)
)
change_result = self._process_once(
parseable_change_text,
provider,
conversation_id,
"dry_run",
approval_token,
vehicle_profile.id,
simulation_fault,
control_transport=control_transport,
assistant_plan=assistant_plan,
allow_fuzzy_mode_profile_match=False,
)
change_operations = self._absolute_mode_operations(
self._preview_mode_operations(change_result)
)
if (
change_result.get("status") != "succeeded"
or not change_operations
or change_result.get("supplement_required") is True
or bool(change_result.get("unresolved_clauses"))
or bool(change_result.get("deferred_requests"))
):
raise ValueError("没有从调整说明中得到完整、可复用的设备目标。")
validation = change_result.get("validation")
if isinstance(validation, list) and any(
isinstance(issue, dict) and issue.get("severity") == "error"
for issue in validation
):
raise ValueError("调整动作没有通过车辆能力或安全校验。")
normalized_changes = []
for change in change_operations:
same_capability = [
item
for item in base_operations
if item["domain"] == change["domain"]
and item["capability"] == change["capability"]
]
normalized = deepcopy(change)
if len(same_capability) == 1 and not self._mode_update_target_is_explicit(
change_text,
str(change["target_scope"]),
):
normalized["target_scope"] = same_capability[0]["target_scope"]
elif len(same_capability) > 1 and not self._mode_update_target_is_explicit(
change_text,
str(change["target_scope"]),
):
raise ValueError(
f"“{mode_name}”中该设备能力包含多个目标,请明确主驾、副驾或具体区域。"
)
normalized_changes.append(normalized)
change_by_key = {
(
@@ -5433,6 +5623,14 @@ class SeatDemoService:
if profile is None:
self._persist(result)
return result
if profile.schedule is not None:
from cognitive_agent.application.mode_schedule_edit import update_saved_schedule
return update_saved_schedule(
self, profile, change_text=change_text, utterance=utterance,
conversation_id=conversation_id, vehicle_profile=vehicle_profile,
context=context, result=result,
)
if not change_text:
result["status"] = "needs_confirmation"
result["validation"] = [
@@ -5446,27 +5644,64 @@ class SeatDemoService:
self._persist(result)
return result
parsed, provider_used, provider_notice = self._interpret(
registered_additions = mode_device_additions(
change_text,
provider,
context,
vehicle_profile,
[operation.to_yaml() for operation in profile.operations],
self._mode_device_default_operations(mode_name),
)
parsed = apply_language_safety_guards(parsed, change_text)
parsed = self._partition_native_actions(parsed)
result["provider_used"] = f"mode_profile_registry+{provider_used}"
result["decision_summary"].extend(parsed.get("decision_summary", []))
if provider_notice:
result["decision_summary"].append(provider_notice)
result["deferred_requests"] = parsed.get("deferred_requests", [])
result["model_requests"] = parsed.get("model_requests", [])
if registered_additions is not None:
parsed = {
"decision_summary": [
"已从只读场景模板载入缺失设备的登记默认动作。"
],
"deferred_requests": [],
"model_requests": [],
}
result["provider_used"] = (
"mode_profile_registry+registered_scene_template"
)
result["decision_summary"].extend(parsed["decision_summary"])
result["deferred_requests"] = []
result["model_requests"] = []
mode_operations = self._absolute_mode_operations(
registered_additions
)
source_actions = [
{
"domain": operation["domain"],
"capability": operation["capability"],
"target": operation["target_scope"],
"operation": operation.get("operation", "set"),
"parameters": deepcopy(operation.get("parameters", {})),
"reason": operation.get(
"reason",
"从登记场景模板增补的设备默认动作。",
),
}
for operation in mode_operations
]
else:
parsed, provider_used, provider_notice = self._interpret(
change_text,
provider,
context,
vehicle_profile,
)
parsed = apply_language_safety_guards(parsed, change_text)
parsed = self._partition_native_actions(parsed)
result["provider_used"] = f"mode_profile_registry+{provider_used}"
result["decision_summary"].extend(parsed.get("decision_summary", []))
if provider_notice:
result["decision_summary"].append(provider_notice)
result["deferred_requests"] = parsed.get("deferred_requests", [])
result["model_requests"] = parsed.get("model_requests", [])
source_actions = [
*parsed.get("actions", []),
*parsed.get("domain_actions", []),
*parsed.get("native_can_actions", []),
]
mode_operations = self._mode_operations_from_actions(source_actions)
source_actions = [
*parsed.get("actions", []),
*parsed.get("domain_actions", []),
*parsed.get("native_can_actions", []),
]
mode_operations = self._mode_operations_from_actions(source_actions)
mode_operations, ambiguous_targets = self._inherit_mode_update_targets(
profile,
mode_operations,
@@ -6100,6 +6335,25 @@ class SeatDemoService:
result["scene_config"] = None
result["execution_report"] = None
def _mode_creation_seat_failure(
self, utterance: str, conversation_id: str, vehicle: VehicleProfile, reason: str,
) -> dict[str, Any]:
result = {
"request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
"utterance": utterance, "conversation_id": conversation_id,
"vehicle": vehicle.public_dict(), "status": "validation_failed",
"execution_mode": "dry_run", "hardware_execution_allowed": False,
"orchestration_route": "mode_profile_create_preview", "trace_events": [],
"validation": [{"severity": "error", "code": "MODE_SEAT_SCOPE_UNAVAILABLE",
"path": "mode_profile_draft.seat_scope", "message": reason}],
"mode_creation_failure": {"code": "MODE_SEAT_SCOPE_UNAVAILABLE", "reason": reason,
"next_step": "请选择该模式实际支持的座位,或改用当前座位支持的舒适功能"},
"decision_summary": [reason, "没有保存模式或下发控制。"],
}
self._scrub_failed_scene_preview(result)
self._persist(result)
return result
def _process_mode_profile_create(
self,
utterance: str,
@@ -6134,6 +6388,14 @@ class SeatDemoService:
):
raise ValueError("功能模式名称与创建请求不一致。")
requested_instruction = create_parts[1] if create_parts is not None else ""
selected_seats = creation_seat_scopes(utterance) if source_draft is None else None
if selected_seats is not None and "unsupported" in selected_seats and re.search(
r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩|零重力", utterance
):
return self._mode_creation_seat_failure(
utterance, conversation_id, vehicle_profile,
"当前模式只支持明确的主驾或副驾座椅,不会将未支持的座位替换为其他座位。",
)
raw_plan_metadata = (
assistant_plan.get("metadata")
if isinstance(assistant_plan, dict)
@@ -6160,6 +6422,7 @@ class SeatDemoService:
or requested_instruction
or assistant_plan.get("requests")
!= candidate_template.plan().get("requests")
or assistant_plan.get("schedule") != candidate_template.plan().get("schedule")
):
raise ValueError("内置常用模式模板已更新,请重新提交。")
scene_template = candidate_template
@@ -6190,6 +6453,10 @@ class SeatDemoService:
if not requested_instruction and source_draft is None
else None
)
if source_profile is None and source_draft is None and not requested_instruction and scene_template is None:
source_name = mode_creation_source_name(mode_name)
if source_name != mode_name:
source_profile = self.mode_profiles.resolve_exact_name(source_name, vehicle_id=vehicle_profile.id)
draft_mode_name = (
str(source_draft.get("name") or mode_name).strip()
if isinstance(source_draft, dict)
@@ -6205,7 +6472,7 @@ class SeatDemoService:
else "registered_mode_profile"
if source_profile is not None
else "codex_scene_composition"
if assistant_plan is not None and not requested_instruction
if assistant_plan is not None and (not requested_instruction or needs_mode_schedule(utterance))
else "explicit_scene_requirements"
)
# A name-only request may already describe a repository-backed scene.
@@ -6214,7 +6481,63 @@ class SeatDemoService:
# being parsed as a second control clause on the assistant's local fast
# path, turning a valid nine-action mode into an incomplete draft.
source_utterance = requested_instruction or f"进入{draft_mode_name}"
if requested_instruction and selected_seats is not None and len(selected_seats) == 1:
# Qualify only explicit seat-device clauses, never an HVAC/window
# zone or unrelated control. General one-shot controls keep their
# existing target policy; this is scoped to new mode construction.
label = "主驾" if selected_seats == {"driver"} else "副驾"
instruction_clauses = self._split_intent_clauses(requested_instruction)
qualified_clauses = [
f"{label}{clause}" if (
re.search(r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩", clause)
and resolve_explicit_seat_scopes(clause, assume_seat_context=True) is None
) else clause
for clause in instruction_clauses
]
# Preserve the user's punctuation and wording unless a seat really
# needs qualification; explicit instructions need no rewriting.
if qualified_clauses != instruction_clauses:
source_utterance = ",".join(qualified_clauses)
raw_schedule = (assistant_plan.get("schedule") if isinstance(assistant_plan, dict) else None)
if source_profile is not None and source_profile.schedule is not None:
raw_schedule = source_profile.schedule.to_dict()
if raw_schedule is not None:
if isinstance(assistant_plan, dict) and assistant_plan.get("ambiguities"):
raise ValueError("动态需求仍有未解决的问题,不能生成部分成功的时间轴,请补充或调整需求。")
if source_profile is None and source_draft is None and scene_template is None and not needs_mode_schedule(utterance):
raise ValueError("用户未请求动态或分阶段模式,不能擅自生成定时任务。")
schedule = ModeSchedule.from_dict(raw_schedule)
if selected_seats is not None and (source_profile is not None or scene_template is not None):
try:
schedule = project_seed_schedule(schedule, selected_seats)
except ModeCreationSeatPolicyError as exc:
return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
return self._preview_mode_schedule(
schedule, name=draft_mode_name, utterance=utterance,
conversation_id=conversation_id, vehicle_profile=vehicle_profile,
control_transport=control_transport, generation_source=generation_source,
source_profile=existing_profile, source_template=scene_template, source_draft=source_draft,
scene_scope_utterance="" if source_draft is not None else utterance,
)
if needs_mode_schedule(utterance) and scene_template is None and not (source_profile is not None and source_profile.timeline):
result = {
"request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}",
"utterance": utterance, "conversation_id": conversation_id,
"vehicle": vehicle_profile.public_dict(), "validation": [], "trace_events": [],
"status": "validation_failed", "execution_mode": "dry_run", "hardware_execution_allowed": False,
"orchestration_route": "mode_profile_create_preview",
}
result["mode_creation_failure"] = {
"code": "MODE_SCHEDULE_REQUIRED",
"reason": "该需求包含时间变化,但没有生成完整的分阶段时间轴",
"next_step": "请重试,或补充每个阶段的设备目标和持续时间;未保存或执行静态替代动作",
}
self._scrub_failed_scene_preview(result)
self._persist(result)
return result
try:
if scene_template is not None and selected_seats is not None:
assistant_plan = project_seed_plan(assistant_plan, selected_seats)
source_result = self._process_once(
source_utterance,
provider,
@@ -6227,6 +6550,7 @@ class SeatDemoService:
direct_mode_profile_id=(
source_profile.id if source_profile is not None else None
),
mode_creation_scopes=selected_seats,
assistant_plan=assistant_plan,
# ``source_utterance`` is synthesized for a name-only, unknown
# scene. It must not fall back to substring matching (for example
@@ -6238,7 +6562,14 @@ class SeatDemoService:
and source_profile is None
and not requested_instruction
),
# A revision combines a server-loaded draft with changes that
# already passed the ordinary text/target authorization path.
# Its old display name cannot veto an explicitly requested new
# seat. This override is internal, never accepted from payloads.
scene_scope_utterance="" if source_draft is not None else utterance,
)
except ModeCreationSeatPolicyError as exc:
return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc))
except NativeCanCompileError:
# Repository modes and model-composed scenes share the same native
# compiler. A stale mode parameter must become a bounded failed
@@ -8276,6 +8607,7 @@ class SeatDemoService:
model_parse_source: str = "cloud_model",
assistant_plan: dict[str, Any] | None = None,
allow_scene_composition: bool = False,
scene_scope_utterance: str | None = None,
cloud_instruction_context: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str, str | None]:
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
@@ -8338,6 +8670,7 @@ class SeatDemoService:
],
trusted_local=trusted_local,
allow_scene_composition=allow_scene_composition,
scene_scope_utterance=scene_scope_utterance,
)
model = self.model_plan_adapter.adapt(
guarded.plan,
+52 -3
View File
@@ -8,7 +8,6 @@ from collections.abc import Mapping
from copy import deepcopy
from typing import Any
CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+")
NEGATED_OPERATION = re.compile(
r"(?:不要|别|不想|无需|不必|禁止)"
@@ -71,6 +70,28 @@ _SEAT_UNSUPPORTED_SCOPE = re.compile(
r"左后|右后|前舱|后舱|左侧|右侧|左边|右边|"
r"所有座椅|全部座椅|全车座椅"
)
_SCENE_NON_SEAT_SCOPED_DEVICE = re.compile(
rf"(?:{_SEAT_DRIVER_SCOPE.pattern}|{_SEAT_PASSENGER_SCOPE.pattern}|"
rf"{_SEAT_UNSUPPORTED_SCOPE.pattern})\s*(?:的)?\s*"
r"(?=空调|温度|风量|出风|风口|除霜|车窗|窗户|车门|门锁|"
r"阅读灯|氛围灯|灯光|遮阳帘|天幕)",
re.IGNORECASE,
)
def resolve_scene_seat_scopes(utterance: str) -> frozenset[str] | None:
"""Keep a scene's explicit seat scope without borrowing an HVAC/light zone.
A name such as 主驾舒适模式 still constrains seat actions even when it
omits 座椅. But 副驾空调与座椅舒适模式 does not authorize restricting
every seat action to the passenger. Only remove scope tokens directly
attached to another device; all negation text remains untouched.
"""
scoped_text = _SCENE_NON_SEAT_SCOPED_DEVICE.sub(
"", utterance.replace("主副驾", "主驾和副驾")
)
return resolve_explicit_seat_scopes(scoped_text, assume_seat_context=True)
def resolve_explicit_seat_scopes(
@@ -356,7 +377,13 @@ def _remove_conflicting_controls(
result[field] = kept
def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]:
def apply_language_safety_guards(
parsed: dict[str, Any],
utterance: str,
*,
scene_composition: bool = False,
scene_scope_utterance: str | None = None,
) -> dict[str, Any]:
"""Preserve negation and unresolved control language without producing CAN."""
result = deepcopy(parsed)
@@ -386,7 +413,29 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict
]
result["confidence"] = min(float(result.get("confidence", 0)), 0.8)
explicit_seat_scopes = resolve_explicit_seat_scopes(utterance)
if scene_composition:
# Reuse exactly the selection already applied to the model schema and
# trusted seed. A second, older scope parser must not reject aliases
# such as 前排两座 or widen 副驾驶席 to both seats after validation.
from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes
explicit_seat_scopes = (None if scene_scope_utterance == "" else creation_seat_scopes(
utterance if scene_scope_utterance is None else scene_scope_utterance
))
else:
explicit_seat_scopes = resolve_explicit_seat_scopes(utterance)
if scene_composition and not any(
isinstance(item, dict)
and (
item.get("domain") == "seat"
or str(item.get("capability", "")).startswith("seat.")
)
for key in _EXECUTION_LIST_FIELDS
for item in result.get(key, [])
):
# A rear-window ventilation scene is not a rear-seat request. The
# scene guard already checks every seat candidate before adaptation.
explicit_seat_scopes = None
if explicit_seat_scopes is not None:
removed = _filter_explicit_seat_scope(result, explicit_seat_scopes)
if removed:
+308
View File
@@ -0,0 +1,308 @@
"""Read-only HTTP surface for vehicle firmware discovery and download."""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import re
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
from cognitive_agent.config_loader import load_local_env
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
load_local_env(REPOSITORY_ROOT / ".env", override=True)
RELEASE_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]{7,80}")
DOWNLOAD_PATH_PATTERN = re.compile(
r"/api/firmware-workbench/releases/(?P<release_id>[a-z0-9-]+)/download"
)
LATEST_PATHS = frozenset(
{
"/latest",
"/api/firmware-workbench/releases/latest",
}
)
RELEASES_PATHS = frozenset(
{
"/releases",
"/api/firmware-workbench/releases",
}
)
class FirmwareOtaRepository:
"""Resolve atomically published OTA manifests without loading workbench state."""
def __init__(self, root: Path, public_base_url: str) -> None:
self.root = Path(root).resolve()
self.public_base_url = str(public_base_url).rstrip("/")
def latest(self) -> dict[str, object]:
manifest_path = self.root / "latest.json"
manifest = self._read_manifest(manifest_path)
return self._public_manifest(manifest)
def releases(self) -> dict[str, object]:
releases_root = self.root / "releases"
manifests: list[dict[str, object]] = []
if releases_root.is_dir():
for release_root in releases_root.iterdir():
if not release_root.is_dir():
continue
try:
manifest = self._read_manifest(release_root / "release.json")
release_id = self._release_id(manifest)
if release_id != release_root.name:
continue
package, _, _ = self.release_package(release_id)
expected_size = manifest.get("size_bytes")
if (
expected_size is not None
and int(expected_size) != package.stat().st_size
):
continue
if str(manifest.get("status") or "published") != "published":
continue
manifests.append(self._public_manifest(manifest))
except (FileNotFoundError, OSError, TypeError, ValueError):
continue
manifests.sort(
key=lambda item: str(item.get("published_at") or ""), reverse=True
)
latest_release_id = (
str(manifests[0]["release_id"]) if manifests else None
)
return {
"schema": "n7-ota-release-list/v1",
"count": len(manifests),
"latest_release_id": latest_release_id,
"releases": manifests,
}
def release_package(self, release_id: str) -> tuple[Path, str, str]:
checked = str(release_id).strip()
if not RELEASE_ID_PATTERN.fullmatch(checked):
raise FileNotFoundError("release not found")
release_root = (self.root / "releases" / checked).resolve()
try:
release_root.relative_to((self.root / "releases").resolve())
except ValueError as exc:
raise FileNotFoundError("release not found") from exc
manifest = self._read_manifest(release_root / "release.json")
if self._release_id(manifest) != checked:
raise FileNotFoundError("release not found")
filename = str(manifest.get("firmware_filename") or "")
if not filename or Path(filename).name != filename:
raise FileNotFoundError("firmware package not found")
package = (release_root / filename).resolve()
try:
package.relative_to(release_root)
except ValueError as exc:
raise FileNotFoundError("firmware package not found") from exc
if not package.is_file():
raise FileNotFoundError("firmware package not found")
expected_hash = str(manifest.get("sha256") or "").lower()
return package, filename, expected_hash
def _public_manifest(self, manifest: dict[str, object]) -> dict[str, object]:
payload = dict(manifest)
release_id = self._release_id(payload)
payload["schema"] = "n7-ota-release/v1"
payload["release_id"] = release_id
payload["download_url"] = f"{self.public_base_url}/{release_id}/download"
payload.pop("package_path", None)
return payload
@staticmethod
def _read_manifest(path: Path) -> dict[str, object]:
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError) as exc:
raise FileNotFoundError("manifest not found") from exc
if not isinstance(document, dict):
raise FileNotFoundError("manifest not found")
return document
@staticmethod
def _release_id(manifest: dict[str, object]) -> str:
release_id = str(manifest.get("release_id") or manifest.get("id") or "")
if not RELEASE_ID_PATTERN.fullmatch(release_id):
raise FileNotFoundError("release not found")
return release_id
class FirmwareOtaHandler(BaseHTTPRequestHandler):
"""Expose health, release manifests, and immutable firmware packages."""
repository: FirmwareOtaRepository
server_version = "N7FirmwareOta/1.0"
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
self._handle_request(send_body=True)
def do_HEAD(self) -> None: # noqa: N802 - stdlib handler API
self._handle_request(send_body=False)
def _handle_request(self, *, send_body: bool) -> None:
path = unquote(urlparse(self.path).path)
if path == "/healthz":
self._json({"status": "ok", "service": "n7-firmware-ota"}, send_body)
return
if path in LATEST_PATHS:
try:
self._json(self.repository.latest(), send_body)
except FileNotFoundError:
self._json(
{"error": "not_found", "message": "暂无可供车机升级的固件。"},
send_body,
HTTPStatus.NOT_FOUND,
)
return
if path in RELEASES_PATHS:
self._json(self.repository.releases(), send_body)
return
match = DOWNLOAD_PATH_PATTERN.fullmatch(path)
if match:
try:
package, filename, expected_hash = self.repository.release_package(
match.group("release_id")
)
self._file(package, filename, expected_hash, send_body)
except FileNotFoundError:
self._json(
{"error": "not_found", "message": "OTA 固件包不存在。"},
send_body,
HTTPStatus.NOT_FOUND,
)
return
self._json(
{"error": "not_found", "message": "接口不存在。"},
send_body,
HTTPStatus.NOT_FOUND,
)
def _file(
self,
path: Path,
filename: str,
expected_hash: str,
send_body: bool,
) -> None:
size = path.stat().st_size
try:
start, end, partial = self._byte_range(size)
except ValueError:
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
self.send_header("Content-Range", f"bytes */{size}")
self.send_header("Content-Length", "0")
self._security_headers()
self.end_headers()
return
content_length = end - start + 1
self.send_response(HTTPStatus.PARTIAL_CONTENT if partial else HTTPStatus.OK)
self.send_header(
"Content-Type", mimetypes.guess_type(filename)[0] or "application/octet-stream"
)
self.send_header("Content-Length", str(content_length))
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self.send_header("Accept-Ranges", "bytes")
self.send_header("Cache-Control", "public, max-age=31536000, immutable")
if expected_hash:
self.send_header("X-Firmware-SHA256", expected_hash)
if partial:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self._security_headers()
self.end_headers()
if not send_body:
return
with path.open("rb") as source:
source.seek(start)
remaining = content_length
while remaining:
chunk = source.read(min(64 * 1024, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def _byte_range(self, size: int) -> tuple[int, int, bool]:
value = str(self.headers.get("Range") or "").strip()
if not value:
return 0, size - 1, False
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value)
if not match or not any(match.groups()):
raise ValueError("invalid byte range")
first, last = match.groups()
if first:
start = int(first)
end = int(last) if last else size - 1
else:
suffix = int(last)
if suffix <= 0:
raise ValueError("invalid byte range")
start = max(0, size - suffix)
end = size - 1
if start >= size or end < start:
raise ValueError("invalid byte range")
return start, min(end, size - 1), True
def _json(
self,
payload: object,
send_body: bool,
status: HTTPStatus = HTTPStatus.OK,
) -> None:
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
self._security_headers()
self.end_headers()
if send_body:
self.wfile.write(content)
def _security_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
def main() -> None:
parser = argparse.ArgumentParser(description="Run the read-only vehicle OTA service")
parser.add_argument(
"--host", default=os.getenv("FIRMWARE_OTA_BIND_HOST", "127.0.0.1")
)
parser.add_argument(
"--port", type=int, default=int(os.getenv("FIRMWARE_OTA_PORT", "8012"))
)
args = parser.parse_args()
configured_root = os.getenv("FIRMWARE_WORKBENCH_OTA_ROOT", "").strip()
ota_root = (
Path(configured_root)
if configured_root
else REPOSITORY_ROOT / "artifacts" / "server" / "firmware-ota"
)
public_base_url = os.getenv(
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
f"http://{args.host}:{args.port}/api/firmware-workbench/releases",
)
FirmwareOtaHandler.repository = FirmwareOtaRepository(ota_root, public_base_url)
server = ThreadingHTTPServer((args.host, args.port), FirmwareOtaHandler)
print(f"N7 firmware OTA service: http://{args.host}:{args.port}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
+180 -5
View File
@@ -51,6 +51,10 @@ from cognitive_agent.application.extension_toy_publisher import ( # noqa: E402
EXTENSION_TOY_BURST_WINDOW_S,
NfcExtensionToyPublisher,
)
from cognitive_agent.application.firmware_workbench import ( # noqa: E402
FirmwareWorkbenchError,
FirmwareWorkbenchService,
)
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
normalize_mode_profile_name,
@@ -61,6 +65,7 @@ from cognitive_agent.application.mode_scene_catalog import ( # noqa: E402
MODE_SCENE_MQTT_BURST_WINDOW_S,
)
from cognitive_agent.application.pet_cards import PetCardService # noqa: E402
from cognitive_agent.application.mode_schedule_service import public_schedule_status # noqa: E402
from cognitive_agent.application.seat_service import ( # noqa: E402
LOCAL_CONTROL_BUDGET_MS,
SeatDemoService,
@@ -127,7 +132,11 @@ PUBLIC_AUTH_PATHS = {
"/network.js",
"/api/auth/status",
"/api/auth/login",
"/api/firmware-workbench/releases/latest",
}
PUBLIC_OTA_DOWNLOAD_PATH = re.compile(
r"/api/firmware-workbench/releases/[a-z0-9-]+/download"
)
NON_DISPATCH_DISPLAY_SOURCES = frozenset({"mode_editor_compile"})
@@ -231,6 +240,7 @@ def _lan_ipv4_address() -> str | None:
class SeatDemoHandler(BaseHTTPRequestHandler):
service = SeatDemoService()
firmware_workbench = FirmwareWorkbenchService(REPOSITORY_ROOT)
# Codex defaults to its own empty temporary workspace. A deployment may
# opt into another isolated directory through CODEX_ASSISTANT_WORKSPACE,
# but it must never inherit access to this application's source tree.
@@ -361,9 +371,74 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path == "/api/mode-profiles/status":
self._json(self.service.mode_profile_sync_status())
return
if path == "/api/firmware-workbench/state":
self._json(self.firmware_workbench.snapshot())
return
if path == "/api/firmware-workbench/releases/latest":
try:
self._json(self.firmware_workbench.latest_release())
except FirmwareWorkbenchError as exc:
self._json(
{"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)
)
return
firmware_task_match = re.fullmatch(
r"/api/firmware-workbench/tasks/(?P<task_id>[a-z0-9-]+)", path
)
if firmware_task_match:
try:
self._json(
self.firmware_workbench.task(firmware_task_match.group("task_id"))
)
except FirmwareWorkbenchError as exc:
self._json(
{"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)
)
return
firmware_artifact_download_match = re.fullmatch(
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)/download",
path,
)
if firmware_artifact_download_match:
self._serve_firmware_download(
"artifact", firmware_artifact_download_match.group("artifact_id")
)
return
firmware_release_download_match = re.fullmatch(
r"/api/firmware-workbench/releases/(?P<release_id>[a-z0-9-]+)/download",
path,
)
if firmware_release_download_match:
self._serve_firmware_download(
"release", firmware_release_download_match.group("release_id")
)
return
firmware_artifact_match = re.fullmatch(
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)", path
)
if firmware_artifact_match:
try:
self._json(
self.firmware_workbench.artifact(
firmware_artifact_match.group("artifact_id")
)
)
except FirmwareWorkbenchError as exc:
self._json(
{"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)
)
return
if path == "/api/runtime":
self._json(self.service.runtime_info())
return
if path == "/api/modes/schedule/status":
query = parse_qs(parsed_url.query)
try:
vehicle = self.service.vehicle_profiles.get(str(query.get("vehicle_id", [""])[0]) or None)
self._json(public_schedule_status(self.service.mode_schedule_runner.status(vehicle.id)))
except ValueError as exc:
self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if path == "/api/vehicle-display-sync":
self._json(self.vehicle_display_sync.snapshot(self._extension_health()))
return
@@ -581,6 +656,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
return
if not self._require_json_content_type():
return
if path == "/api/firmware-workbench/tasks" or path.startswith(
"/api/firmware-workbench/"
):
self._handle_firmware_workbench_post(path)
return
if path in {
"/api/hardware/access/request",
"/api/hardware/access/release",
@@ -1816,8 +1896,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
re.search(
r"然后|随后|接着|之后|"
r"(?:\d+(?:\.\d+)?|[一二三四五六七八九十两半]+)"
r"(?:ms|s|毫秒|秒|分钟)",
r"(?:毫秒|秒(?:钟)?|分钟|小时|ms(?![A-Za-z])|s(?![A-Za-z]))",
residual,
flags=re.IGNORECASE,
)
)
):
@@ -2953,6 +3034,24 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.BAD_REQUEST,
)
return
firmware_artifact_match = re.fullmatch(
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)",
path,
)
if firmware_artifact_match is not None:
try:
self._json(
self.firmware_workbench.delete_artifact(
firmware_artifact_match.group("artifact_id")
),
HTTPStatus.OK,
)
except FirmwareWorkbenchError as exc:
self._json(
{"error": exc.code, "message": str(exc)},
HTTPStatus(exc.status),
)
return
prefix = "/api/mode-profiles/"
if not path.startswith(prefix):
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
@@ -3745,6 +3844,70 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
raise ValueError("request body must be a JSON object")
return payload
def _handle_firmware_workbench_post(self, path: str) -> None:
try:
payload = self._read_json_body(max_bytes=16 * 1024)
if path == "/api/firmware-workbench/tasks":
result = self.firmware_workbench.start_task(
str(payload.get("instruction", "")),
str(payload.get("baseline_id", "time_only")),
)
self._json(result, HTTPStatus.ACCEPTED)
return
task_action_match = re.fullmatch(
r"/api/firmware-workbench/tasks/(?P<task_id>[a-z0-9-]+)/(?P<action>cancel|retry)",
path,
)
if task_action_match:
task_id = task_action_match.group("task_id")
if task_action_match.group("action") == "cancel":
self._json(self.firmware_workbench.cancel_task(task_id))
else:
self._json(
self.firmware_workbench.retry_task(task_id), HTTPStatus.ACCEPTED
)
return
artifact_publish_match = re.fullmatch(
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)/publish",
path,
)
if artifact_publish_match:
self._json(
self.firmware_workbench.publish_artifact(
artifact_publish_match.group("artifact_id")
)
)
return
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
except (ValueError, json.JSONDecodeError) as exc:
self._json(
{"error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
except FirmwareWorkbenchError as exc:
self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status))
def _serve_firmware_download(self, kind: str, item_id: str) -> None:
try:
if kind == "release":
path, filename = self.firmware_workbench.resolve_release_download(item_id)
else:
path, filename = self.firmware_workbench.resolve_artifact_download(item_id)
content = path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/zip")
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "private, no-store")
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quote(filename, safe='.-_')}",
)
self._send_security_headers()
self.end_headers()
self.wfile.write(content)
except FirmwareWorkbenchError as exc:
self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status))
def _require_json_content_type(self) -> bool:
if is_json_content_type(self.headers.get("Content-Type", "")):
return True
@@ -3991,7 +4154,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
pass
def _authorize(self, path: str) -> bool:
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
if (
not self.auth.config.enabled
or path in PUBLIC_AUTH_PATHS
or PUBLIC_OTA_DOWNLOAD_PATH.fullmatch(path)
):
return True
if self._session_username() is not None:
return True
@@ -4283,8 +4450,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self.end_headers()
def _send_security_headers(self) -> None:
allow_same_origin_frame = (
unquote(urlparse(self.path).path) == "/firmware-workbench.html"
)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header(
"X-Frame-Options", "SAMEORIGIN" if allow_same_origin_frame else "DENY"
)
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Permissions-Policy",
@@ -4293,13 +4465,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' 'wasm-unsafe-eval'; "
"script-src 'self' 'wasm-unsafe-eval' "
"'sha256-+ppBhUx2xqD9lhKvY8pLwUq/ASyG5od8HSvB3kBoYj4='; "
"style-src 'self'; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
"frame-src 'self' http://*:3001; "
"connect-src 'self' blob: http://172.20.10.6:8889; "
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
"object-src 'none'; base-uri 'none'; frame-ancestors "
+ ("'self'" if allow_same_origin_frame else "'none'"),
)
if self._is_https():
self.send_header(
@@ -4439,6 +4613,7 @@ def main() -> None:
SeatDemoHandler.extension.close()
SeatDemoHandler.calibration.close()
SeatDemoHandler.repair.close()
SeatDemoHandler.firmware_workbench.close()
if __name__ == "__main__":