feat: add seat-scoped static and dynamic assistant modes

This commit is contained in:
vee
2026-09-12 14:56:30 +08:00
parent d09b5967f8
commit d8dad22ec0
60 changed files with 5847 additions and 149 deletions
@@ -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