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:
@@ -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()))
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user