287 lines
9.8 KiB
Python
287 lines
9.8 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from cognitive_agent.application.self_extension import (
|
|
ExtensionIntegrationError,
|
|
MAX_PLAN_DELAY_MS,
|
|
MAX_TOTAL_PLAN_DELAY_MS,
|
|
SelfExtensionService,
|
|
SimulationGateway,
|
|
)
|
|
|
|
|
|
class SelfExtensionDelayPlanTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.service = SelfExtensionService(SimulationGateway())
|
|
|
|
@staticmethod
|
|
def _vehicle(command: str) -> dict[str, object]:
|
|
return {
|
|
"kind": "vehicle",
|
|
"capability_id": "",
|
|
"command": command,
|
|
"label": command,
|
|
"delay_ms": 0,
|
|
}
|
|
|
|
@staticmethod
|
|
def _delay(value: object) -> dict[str, object]:
|
|
return {
|
|
"kind": "delay",
|
|
"capability_id": "",
|
|
"command": "",
|
|
"label": "wait",
|
|
"delay_ms": value,
|
|
}
|
|
|
|
def _plan(
|
|
self,
|
|
steps: list[object],
|
|
*,
|
|
peripherals: list[dict[str, object]] | None = None,
|
|
) -> dict[str, object]:
|
|
proposed = {
|
|
"summary": "test plan",
|
|
"needs_confirmation": False,
|
|
"question": "",
|
|
"steps": steps,
|
|
}
|
|
vehicle_commands = [
|
|
str(step.get("command"))
|
|
for step in steps
|
|
if isinstance(step, dict)
|
|
and step.get("kind") == "vehicle"
|
|
and step.get("command")
|
|
]
|
|
with patch.dict(os.environ, {"OPENAI_API_KEY": "offline-test-key"}, clear=False):
|
|
with patch.object(
|
|
self.service,
|
|
"_request_gpt_plan",
|
|
return_value=json.dumps(proposed, ensure_ascii=False),
|
|
):
|
|
return self.service.plan(
|
|
{
|
|
"utterance": "test utterance",
|
|
"peripherals": peripherals or [],
|
|
"vehicle_devices": [
|
|
{
|
|
"name": "test vehicle device",
|
|
"actions": [
|
|
{"name": command, "command": command}
|
|
for command in vehicle_commands
|
|
],
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
def test_preserves_action_delay_action_order_and_canonicalizes_label(self) -> None:
|
|
peripherals = [
|
|
{
|
|
"id": "cactus-on",
|
|
"status": "learned",
|
|
"device_name": "跳舞仙人掌",
|
|
"action": "开启",
|
|
"risk": "low",
|
|
},
|
|
{
|
|
"id": "cactus-off",
|
|
"status": "learned",
|
|
"device_name": "跳舞仙人掌",
|
|
"action": "关闭",
|
|
"risk": "low",
|
|
},
|
|
]
|
|
steps = [
|
|
{
|
|
"kind": "peripheral",
|
|
"capability_id": "cactus-on",
|
|
"command": "",
|
|
"label": "ignored",
|
|
"delay_ms": 0,
|
|
},
|
|
self._delay(5_000),
|
|
{
|
|
"kind": "peripheral",
|
|
"capability_id": "cactus-off",
|
|
"command": "",
|
|
"label": "ignored",
|
|
"delay_ms": 0,
|
|
},
|
|
]
|
|
|
|
result = self._plan(steps, peripherals=peripherals)
|
|
|
|
self.assertEqual(
|
|
[step["kind"] for step in result["steps"]],
|
|
["peripheral", "delay", "peripheral"],
|
|
)
|
|
self.assertEqual(result["steps"][1]["delay_ms"], 5_000)
|
|
self.assertEqual(result["steps"][1]["label"], "等待 5 秒")
|
|
self.assertEqual(result["steps"][1]["risk"], "low")
|
|
|
|
def test_rejects_non_integer_or_out_of_range_delay_values(self) -> None:
|
|
invalid_values: tuple[object, ...] = (
|
|
"5000",
|
|
True,
|
|
False,
|
|
1.5,
|
|
None,
|
|
0,
|
|
-1,
|
|
MAX_PLAN_DELAY_MS + 1,
|
|
)
|
|
for value in invalid_values:
|
|
with self.subTest(value=value):
|
|
with self.assertRaises(ExtensionIntegrationError):
|
|
self._plan(
|
|
[
|
|
self._vehicle("light ambient on"),
|
|
self._delay(value),
|
|
self._vehicle("light ambient off"),
|
|
]
|
|
)
|
|
|
|
def test_rejects_delay_at_plan_boundaries_or_next_to_another_delay(self) -> None:
|
|
invalid_plans = (
|
|
[self._delay(1), self._vehicle("ac on")],
|
|
[self._vehicle("ac on"), self._delay(1)],
|
|
[
|
|
self._vehicle("ac on"),
|
|
self._delay(1),
|
|
self._delay(1),
|
|
self._vehicle("ac off"),
|
|
],
|
|
)
|
|
for steps in invalid_plans:
|
|
with self.subTest(steps=steps):
|
|
with self.assertRaises(ExtensionIntegrationError):
|
|
self._plan(steps)
|
|
|
|
def test_rejects_delay_that_becomes_boundary_after_invalid_action_is_removed(self) -> None:
|
|
with self.assertRaisesRegex(ExtensionIntegrationError, "两个可执行动作之间"):
|
|
self._plan(
|
|
[
|
|
{
|
|
"kind": "peripheral",
|
|
"capability_id": "not-learned",
|
|
"command": "",
|
|
"label": "invalid",
|
|
"delay_ms": 0,
|
|
},
|
|
self._delay(1_000),
|
|
self._vehicle("ac off"),
|
|
]
|
|
)
|
|
|
|
def test_enforces_cumulative_delay_limit(self) -> None:
|
|
accepted = self._plan(
|
|
[
|
|
self._vehicle("ac on"),
|
|
self._delay(MAX_PLAN_DELAY_MS),
|
|
self._vehicle("ac temp driver 24"),
|
|
self._delay(MAX_PLAN_DELAY_MS),
|
|
self._vehicle("ac off"),
|
|
]
|
|
)
|
|
self.assertEqual(
|
|
sum(
|
|
step.get("delay_ms", 0)
|
|
for step in accepted["steps"]
|
|
if step["kind"] == "delay"
|
|
),
|
|
MAX_TOTAL_PLAN_DELAY_MS,
|
|
)
|
|
|
|
with self.assertRaisesRegex(ExtensionIntegrationError, "累计延时"):
|
|
self._plan(
|
|
[
|
|
self._vehicle("ac on"),
|
|
self._delay(MAX_PLAN_DELAY_MS),
|
|
self._vehicle("ac temp driver 24"),
|
|
self._delay(MAX_PLAN_DELAY_MS),
|
|
self._vehicle("ac fan 3"),
|
|
self._delay(1),
|
|
self._vehicle("ac off"),
|
|
]
|
|
)
|
|
|
|
def test_rejects_device_fields_on_delay_step(self) -> None:
|
|
for field_name, value in (("command", "wait 5 seconds"), ("capability_id", "cap-1")):
|
|
with self.subTest(field_name=field_name):
|
|
delay_step = self._delay(5_000)
|
|
delay_step[field_name] = value
|
|
with self.assertRaisesRegex(ExtensionIntegrationError, "不能包含"):
|
|
self._plan(
|
|
[
|
|
self._vehicle("ac on"),
|
|
delay_step,
|
|
self._vehicle("ac off"),
|
|
]
|
|
)
|
|
|
|
def test_rejects_implicit_or_unknown_delay_representation(self) -> None:
|
|
non_delay_with_wait = self._vehicle("ac on")
|
|
non_delay_with_wait["delay_ms"] = 5_000
|
|
with self.assertRaisesRegex(ExtensionIntegrationError, "非延时步骤"):
|
|
self._plan([non_delay_with_wait, self._vehicle("ac off")])
|
|
|
|
with self.assertRaisesRegex(ExtensionIntegrationError, "未知步骤类型"):
|
|
self._plan(
|
|
[
|
|
self._vehicle("ac on"),
|
|
{
|
|
"kind": "wait",
|
|
"capability_id": "",
|
|
"command": "",
|
|
"label": "wait",
|
|
"delay_ms": 5_000,
|
|
},
|
|
self._vehicle("ac off"),
|
|
]
|
|
)
|
|
|
|
def test_gpt_schema_and_prompt_define_millisecond_delay_contract(self) -> None:
|
|
response = MagicMock()
|
|
response.__enter__.return_value = response
|
|
response.read.return_value = json.dumps({"output_text": "{}"}).encode("utf-8")
|
|
|
|
with patch.object(self.service, "openai_streaming", False):
|
|
with patch(
|
|
"cognitive_agent.application.self_extension.urlopen",
|
|
return_value=response,
|
|
) as open_request:
|
|
self.service._request_gpt_plan(
|
|
"开启跳舞仙人掌,5s 后关闭", [], [], "test-key"
|
|
)
|
|
|
|
request_payload = json.loads(open_request.call_args.args[0].data.decode("utf-8"))
|
|
plan_format = request_payload["text"]["format"]
|
|
step_schema = plan_format["schema"]["properties"]["steps"]["items"]
|
|
self.assertTrue(plan_format["strict"])
|
|
self.assertIn("delay", step_schema["properties"]["kind"]["enum"])
|
|
self.assertIn("delay_ms", step_schema["required"])
|
|
self.assertEqual(
|
|
step_schema["properties"]["delay_ms"]["maximum"],
|
|
MAX_PLAN_DELAY_MS,
|
|
)
|
|
prompt = "\n".join(
|
|
content["text"]
|
|
for message in request_payload["input"]
|
|
if message.get("role") == "system"
|
|
for content in message.get("content", [])
|
|
if content.get("type") == "input_text"
|
|
)
|
|
self.assertIn("delay_ms=5000", prompt)
|
|
self.assertIn(str(MAX_TOTAL_PLAN_DELAY_MS), prompt)
|
|
self.assertIn("不能位于首尾", prompt)
|
|
self.assertIn("绝不能把等待时间写进 command", prompt)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|