fix: support conversational mode draft updates
This commit is contained in:
@@ -18,6 +18,12 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from cognitive_agent.application.mode_profile_intent import (
|
||||
is_mode_profile_query,
|
||||
normalize_mode_profile_name,
|
||||
parse_mode_profile_update,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力"
|
||||
|
||||
@@ -145,6 +151,7 @@ _MODE_PROFILE_CREATE_PATTERN = re.compile(
|
||||
r"(?:配置)?(?:并(?:添加|加入)(?:到)?功能模式(?:栏)?)?"
|
||||
r"(?:[,,::](?P<instruction>.{1,800}))?[。.!!]?$"
|
||||
)
|
||||
_MODE_EDITOR_REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]{6,100}$")
|
||||
_HOT_DISCOMFORT_PATTERN = re.compile(r"热|闷热|好热|太晒")
|
||||
_COLD_DISCOMFORT_PATTERN = re.compile(r"冷|好冷")
|
||||
_IMPLICIT_HOT_REQUEST_PATTERN = re.compile(
|
||||
@@ -185,6 +192,59 @@ def is_mode_profile_create_request(message: str) -> bool:
|
||||
return _mode_profile_create_parts(message) is not None
|
||||
|
||||
|
||||
def _mode_profile_update_parts(
|
||||
message: str,
|
||||
*,
|
||||
expected_mode_name: object | None = None,
|
||||
) -> tuple[str, str] | None:
|
||||
"""Return a bounded named-mode update without interpreting its action."""
|
||||
|
||||
return parse_mode_profile_update(
|
||||
message,
|
||||
expected_mode_name=expected_mode_name,
|
||||
)
|
||||
|
||||
|
||||
def _validated_mode_profile_draft_context(
|
||||
value: Mapping[str, object] | None,
|
||||
*,
|
||||
message: str,
|
||||
) -> dict[str, str] | None:
|
||||
"""Validate the browser's opaque draft reference, never client actions."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
update = _mode_profile_update_parts(
|
||||
message,
|
||||
expected_mode_name=(value.get("name") if isinstance(value, Mapping) else None),
|
||||
)
|
||||
if update is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("mode_profile_draft_context must be an object")
|
||||
referenced_name = " ".join(str(value.get("name") or "").strip().split())
|
||||
if normalize_mode_profile_name(update[0]) != normalize_mode_profile_name(
|
||||
referenced_name
|
||||
):
|
||||
return None
|
||||
required = {"name", "source_request_id", "vehicle_id"}
|
||||
if set(value) != required:
|
||||
raise ValueError("mode_profile_draft_context fields are invalid")
|
||||
clean = {key: " ".join(str(value.get(key) or "").strip().split()) for key in required}
|
||||
if (
|
||||
not clean["name"]
|
||||
or len(clean["name"]) > 64
|
||||
or not clean["vehicle_id"]
|
||||
or len(clean["vehicle_id"]) > 80
|
||||
or not _MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(clean["source_request_id"])
|
||||
):
|
||||
raise ValueError("mode_profile_draft_context values are invalid")
|
||||
# The UI keeps the latest card selected while ordinary questions are
|
||||
# asked. A reference is only a routing hint when this exact sentence edits
|
||||
# that exact draft; all non-matching references returned above are inert.
|
||||
return clean
|
||||
|
||||
|
||||
def _catalog_exact_mode_match_count(
|
||||
catalog: Mapping[str, object],
|
||||
requested_name: str,
|
||||
@@ -314,13 +374,32 @@ def _vehicle_action_state(result: Mapping[str, object]) -> tuple[str, bool, str]
|
||||
def _authorize_vehicle_request(
|
||||
message: str,
|
||||
control_terms: Sequence[str] = (),
|
||||
mode_profile_draft_context: Mapping[str, object] | None = None,
|
||||
) -> _VehicleAuthorization:
|
||||
"""Create a server-owned capability envelope from the original user text."""
|
||||
|
||||
expected_draft_name = (
|
||||
mode_profile_draft_context.get("name")
|
||||
if isinstance(mode_profile_draft_context, Mapping)
|
||||
else None
|
||||
)
|
||||
mode_profile_draft_context = _validated_mode_profile_draft_context(
|
||||
mode_profile_draft_context,
|
||||
message=message,
|
||||
)
|
||||
normalized = "".join(str(message).lower().split())
|
||||
if _DENIAL_PATTERN.search(normalized):
|
||||
return _VehicleAuthorization(None, "denied_by_user")
|
||||
|
||||
if is_mode_profile_query(
|
||||
normalized,
|
||||
expected_mode_name=expected_draft_name,
|
||||
):
|
||||
# A question about a named mode may contain device names, control
|
||||
# verbs and numeric targets. It remains a question and must never
|
||||
# fall through to the registered-control executor.
|
||||
return _VehicleAuthorization(None, "mode_profile_query")
|
||||
|
||||
implicit_request = re.sub(r"[,。!?!?、]", "", normalized)
|
||||
if not _KNOWLEDGE_PATTERN.search(normalized):
|
||||
if _IMPLICIT_HOT_REQUEST_PATTERN.fullmatch(implicit_request):
|
||||
@@ -342,6 +421,12 @@ def _authorize_vehicle_request(
|
||||
# user as a saveable draft.
|
||||
return _VehicleAuthorization(message, "explicit_mode_profile_create")
|
||||
|
||||
if mode_profile_draft_context is not None:
|
||||
# A validated opaque reference means this sentence edits a pending
|
||||
# server-generated preview. It is a configuration operation, never a
|
||||
# request to execute the mentioned device against the live vehicle.
|
||||
return _VehicleAuthorization(message, "explicit_mode_profile_draft_update")
|
||||
|
||||
normalized_terms = {
|
||||
"".join(str(term).lower().split()) for term in control_terms if str(term).strip()
|
||||
}
|
||||
@@ -453,6 +538,7 @@ class CodexAssistantService:
|
||||
control_terms: Sequence[str] = (),
|
||||
query_capabilities: CapabilityQuery | None = None,
|
||||
authoritative_answer: str | None = None,
|
||||
mode_profile_draft_context: Mapping[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
del history # Codex owns conversation history after the thread is created.
|
||||
message = " ".join(str(message).strip().split())
|
||||
@@ -472,11 +558,22 @@ class CodexAssistantService:
|
||||
authoritative_answer = " ".join(str(authoritative_answer or "").strip().split())
|
||||
if len(authoritative_answer) > 16_000:
|
||||
raise ValueError("authoritative_answer must not exceed 16000 characters")
|
||||
authorization = _authorize_vehicle_request(message, control_terms)
|
||||
draft_context = _validated_mode_profile_draft_context(
|
||||
mode_profile_draft_context,
|
||||
message=message,
|
||||
)
|
||||
authorization = _authorize_vehicle_request(
|
||||
message,
|
||||
control_terms,
|
||||
draft_context,
|
||||
)
|
||||
fast_comfort_control = (
|
||||
self._fast_implicit_controls and authorization.reason in _FAST_COMFORT_REASONS
|
||||
)
|
||||
mode_profile_preview = authorization.reason == "explicit_mode_profile_create"
|
||||
mode_profile_preview = authorization.reason in {
|
||||
"explicit_mode_profile_create",
|
||||
"explicit_mode_profile_draft_update",
|
||||
}
|
||||
mode_parts = _mode_profile_create_parts(message) if mode_profile_preview else None
|
||||
exact_mode_matches = (
|
||||
_catalog_exact_mode_match_count(catalog, mode_parts[0])
|
||||
@@ -493,11 +590,18 @@ class CodexAssistantService:
|
||||
or exact_mode_matches != 0
|
||||
)
|
||||
)
|
||||
direct_mode_profile_update = (
|
||||
authorization.reason == "explicit_mode_profile_draft_update"
|
||||
)
|
||||
# Explicit device settings and repository-backed scenes remain on the
|
||||
# zero-model-latency path. A genuinely new name-only scene needs Codex
|
||||
# to compose a candidate plan from the live capability catalog; the
|
||||
# vehicle service still limits it to dry-run and requires confirmation.
|
||||
direct_vehicle_control = fast_comfort_control or direct_mode_profile_preview
|
||||
direct_vehicle_control = (
|
||||
fast_comfort_control
|
||||
or direct_mode_profile_preview
|
||||
or authorization.reason == "explicit_mode_profile_draft_update"
|
||||
)
|
||||
route = (
|
||||
"capability_catalog"
|
||||
if authoritative_answer
|
||||
@@ -509,7 +613,7 @@ class CodexAssistantService:
|
||||
"n7web-capability-registry"
|
||||
if authoritative_answer
|
||||
else "n7web-mode-builder"
|
||||
if direct_mode_profile_preview
|
||||
if direct_mode_profile_preview or direct_mode_profile_update
|
||||
else "n7web-control-router"
|
||||
if fast_comfort_control
|
||||
else "codex-app-server"
|
||||
@@ -538,7 +642,7 @@ class CodexAssistantService:
|
||||
"正在读取工程能力目录……"
|
||||
if authoritative_answer
|
||||
else "正在构建并校验功能模式……"
|
||||
if direct_mode_profile_preview
|
||||
if direct_mode_profile_preview or direct_mode_profile_update
|
||||
else "Codex 正在理解场景并组合工程能力……"
|
||||
if mode_profile_preview
|
||||
else "正在快速处理座舱舒适需求……"
|
||||
@@ -622,7 +726,13 @@ class CodexAssistantService:
|
||||
instruction = authorization.instruction
|
||||
if instruction is None:
|
||||
raise CodexAssistantError("direct vehicle control was not authorized")
|
||||
mode_profile_preview = authorization.reason == "explicit_mode_profile_create"
|
||||
mode_profile_preview = authorization.reason in {
|
||||
"explicit_mode_profile_create",
|
||||
"explicit_mode_profile_draft_update",
|
||||
}
|
||||
mode_profile_draft_update = (
|
||||
authorization.reason == "explicit_mode_profile_draft_update"
|
||||
)
|
||||
if (
|
||||
emit(
|
||||
{
|
||||
@@ -630,7 +740,9 @@ class CodexAssistantService:
|
||||
"message_id": message_id,
|
||||
"display_text": (
|
||||
"正在生成模式动作预览……"
|
||||
if mode_profile_preview
|
||||
if mode_profile_preview and not mode_profile_draft_update
|
||||
else "正在调整待保存模式动作……"
|
||||
if mode_profile_draft_update
|
||||
else "正在通过车辆安全链执行舒适性调节……"
|
||||
),
|
||||
}
|
||||
@@ -854,6 +966,28 @@ class CodexAssistantService:
|
||||
if isinstance(draft, Mapping)
|
||||
else "新功能模式"
|
||||
)
|
||||
if (
|
||||
isinstance(draft, Mapping)
|
||||
and draft.get("generation_source") == "draft_revision"
|
||||
):
|
||||
raw_operation_count = draft.get("operation_count")
|
||||
operation_count = (
|
||||
raw_operation_count
|
||||
if isinstance(raw_operation_count, int)
|
||||
and not isinstance(raw_operation_count, bool)
|
||||
and raw_operation_count > 0
|
||||
else 0
|
||||
)
|
||||
persistence_hint = (
|
||||
"确认后可更新现有模式。"
|
||||
if draft.get("existing_mode_id")
|
||||
else "确认后可保存为功能模式。"
|
||||
)
|
||||
return (
|
||||
f"已调整待保存的“{name}”模式,并重新校验全部"
|
||||
f"{operation_count}项组合动作。原草稿未被保存或执行,"
|
||||
f"可以继续调整;{persistence_hint}"
|
||||
)
|
||||
if isinstance(draft, Mapping) and draft.get("existing_mode_id"):
|
||||
raw_operation_count = draft.get("operation_count")
|
||||
operation_count = (
|
||||
@@ -1614,6 +1748,10 @@ class CodexAssistantService:
|
||||
"status": str(result.get("status") or "unknown"),
|
||||
"execution_mode": str(result.get("execution_mode") or "dry_run"),
|
||||
}
|
||||
if isinstance(result.get("hardware_execution_allowed"), bool):
|
||||
safe["hardware_execution_allowed"] = result[
|
||||
"hardware_execution_allowed"
|
||||
]
|
||||
for key in ("request_id", "utterance", "decision_summary"):
|
||||
if result.get(key) is not None:
|
||||
safe[key] = CodexAssistantService._safe_result_value(
|
||||
@@ -1699,6 +1837,8 @@ class CodexAssistantService:
|
||||
"existing_mode_id",
|
||||
"existing_mode_revision",
|
||||
"generation_source",
|
||||
"draft_revision",
|
||||
"revises_source_request_id",
|
||||
)
|
||||
if key in mode_profile_draft
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user