Merge branch 'feature/N7Web' of https://git.kendeji.fun/vee/Cognitive_Agent into feature/N7Web

# Conflicts:
#	apps/web-console/app.js
#	apps/web-console/index.html
#	apps/web-console/service-worker.js
#	src/cognitive_agent/server.py
#	tests/e2e/test_seat_web_api.py
This commit is contained in:
minghanchen
2026-08-27 14:43:44 +08:00
19 changed files with 3808 additions and 115 deletions
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / "apps" / "web-console"
def source(name: str) -> str:
return (WEB / name).read_text(encoding="utf-8")
def test_unified_console_exposes_nfc_discovery_and_learning_flow() -> None:
script = source("self-extension.js")
styles = source("self-extension.css")
html = source("index.html")
assert 'data-open-nfc-learning' in script
assert '靠近读卡器后' in script
assert '感应玩具 NFC' in script
assert 'api("nfc/status")' in script
assert 'function createToyFromNfc()' in script
assert 'function bindCardToDevice(device, cardUid)' in script
assert 'function importLegacyProfiles(value)' in script
assert 'N7WEB_REQUEST_IR_TOY_PROFILES' in script
assert 'IR_TOY_LEGACY_PROFILES' in script
assert 'value.legacy_profiles' in script
assert 'value.legacy_bridge_addresses' in script
assert 'cardUid: normalizeCardUid(cardUid)' in script
assert 'data-nfc-run-capability' in script
assert 'data-nfc-learn-capability' in script
assert 'data-nfc-relearn-capability' in script
assert '重新学习' in script
assert 'const targetSlot = relearning ? Number(capability.slot) : allocateSlot()' in script
assert 'sendGatewayCommand("read", { slot: learning.draft.slot })' in script
assert 'function infraredSignalFromResponse(value)' in script
assert 'function infraredFingerprint(signal)' in script
assert '学习回执成功,但物理槽位回读为空' in script
assert 'signalSource: "n7web"' in script
assert 'if (capability.signalSource === "n7web") return' in script
assert '建立档案并开始学习' in script
assert 'const activeCatalog = sensedCardUid' in script
assert 'normalizeCardUid(item.card_uid) === sensedCardUid' in script
assert '&& item.status === "learned"' in script
assert 'slot: capability.slot' in script
assert '当前未感应到外设' in script
assert '将已绑定且已学习的 NFC 外设靠近读卡器后显示能力' in script
assert '.v2-nfc-inline' in styles
assert '.v2-nfc-dialog' in styles
assert '.v2-nfc-capability-grid' in styles
assert 'self-extension.js?v=nfc-toy-33' in html
assert 'self-extension.css?v=nfc-toy-33' in html
def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None:
server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text(encoding="utf-8")
assert 'nfc = NfcReaderService()' in server
assert 'path == "/api/extension/nfc/status"' in server
assert '"nfc": self.nfc.snapshot()' in server
assert 'SeatDemoHandler.nfc.start()' in server
assert 'SeatDemoHandler.nfc.close()' in server
assert "frame-src 'self' http://*:3001" in server
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from cognitive_agent.infrastructure.nfc.reader import NfcReaderService
@dataclass
class FakePort:
device: str
description: str = ""
hwid: str = ""
class FakeReader:
def __init__(self, port: str) -> None:
self.port = port
self.closed = False
self.sam_configured = False
def close(self) -> None:
self.closed = True
def get_firmware_version(self) -> tuple[int, int]:
return (1, 6)
def sam_configuration(self) -> bool:
self.sam_configured = True
return True
def read_uid(self) -> bytes:
return bytes.fromhex("04A1B2C3")
def wait_until(predicate, timeout: float = 1.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return predicate()
def test_resolve_port_prefers_a_single_ch340_reader() -> None:
ports = [
FakePort("COM3", "Bluetooth serial"),
FakePort("COM5", "USB-SERIAL CH340", "USB VID:PID=1A86:7523"),
]
assert NfcReaderService.resolve_port("auto", ports) == "COM5"
assert NfcReaderService.resolve_port("COM8", ports) == "COM8"
def test_reader_service_publishes_reader_and_card_state() -> None:
created: list[FakeReader] = []
def create_reader(port: str) -> FakeReader:
reader = FakeReader(port)
created.append(reader)
return reader
service = NfcReaderService(
reader_factory=create_reader,
ports_provider=lambda: [FakePort("COM5", "PCR532 CH340")],
retry_seconds=0.01,
poll_seconds=0.01,
mqtt_enabled=False,
)
service.start()
try:
assert wait_until(lambda: service.snapshot()["card"] is not None)
snapshot = service.snapshot()
assert snapshot["reader"] == {
"online": True,
"state": "ready",
"port": "COM5",
"firmware": "1.6",
"error": None,
"source": "serial",
}
assert snapshot["card"]["uid"] == "04A1B2C3"
assert snapshot["card"]["action"] == "present"
assert created[0].sam_configured is True
finally:
service.close()
assert created[0].closed is True
def test_disabled_reader_is_non_blocking_and_reported() -> None:
service = NfcReaderService(enabled=False, mqtt_enabled=False)
service.start()
snapshot = service.snapshot()
assert snapshot["reader"]["online"] is False
assert snapshot["reader"]["state"] == "disabled"
assert snapshot["card"] is None
def test_existing_mqtt_bridge_is_reused_when_it_owns_the_reader() -> None:
service = NfcReaderService(enabled=True, mqtt_enabled=False)
service._handle_bridge_status(
{"online": True, "state": "ready", "port": "COM5", "firmware": "1.6"}
)
service._handle_bridge_card(
{"action": "present", "uid": "1a53dd95"}
)
service._handle_bridge_profiles(
{
"profiles": [
{
"id": "ambient-light",
"name": "第三方氛围灯",
"cardUid": "1a53dd95",
"actions": [
{"id": "red", "slot": 7, "name": "红色", "learned": True}
],
}
]
}
)
service._handle_local_bridge_status({"addresses": ["172.20.10.8"]})
snapshot = service.snapshot()
assert snapshot["reader"] == {
"online": True,
"state": "ready",
"port": "COM5",
"firmware": "1.6",
"error": None,
"source": "mqtt_bridge",
}
assert snapshot["card"]["uid"] == "1A53DD95"
assert snapshot["legacy_profiles"][0]["name"] == "第三方氛围灯"
assert snapshot["legacy_profiles"][0]["cardUid"] == "1A53DD95"
assert snapshot["legacy_profiles"][0]["actions"][0]["slot"] == 7
assert snapshot["legacy_bridge_addresses"] == ["172.20.10.8"]
service._handle_bridge_card({"action": "removed", "uid": "1A53DD95"})
assert service.snapshot()["card"] is None
+366
View File
@@ -16,7 +16,9 @@ from cognitive_agent.application.self_extension import (
SelfExtensionService,
SimulationGateway,
TcpGateway,
active_peripheral_plan_catalog,
build_gateway_command,
resolve_active_peripheral_command,
validate_slot,
)
from cognitive_agent.application.zcan_seat_executor import ZcanBridgeSeatExecutor
@@ -54,6 +56,250 @@ class SelfExtensionTests(unittest.TestCase):
self.assertFalse(result["physical_state_confirmed"])
self.assertEqual(len(result["device_response"]), 2)
def test_main_orchestration_resolves_only_live_bound_nfc_peripheral(self) -> None:
catalog = [
{
"id": "cap-third-party-light-on",
"status": "learned",
"device_id": "third-party-light",
"device_name": "第三方氛围灯",
"card_uid": "1A:53:DD:95",
"action": "开启",
"aliases": ["开灯"],
"slot": 9,
},
{
"id": "cap-third-party-light-off",
"status": "learned",
"device_id": "third-party-light",
"device_name": "第三方氛围灯",
"card_uid": "1A53DD95",
"action": "关闭",
"slot": 10,
},
]
present = {"card": {"action": "present", "uid": "1a53dd95"}}
resolved = resolve_active_peripheral_command(
"打开第三方氛围灯", catalog, present
)
self.assertIsNotNone(resolved)
self.assertEqual(resolved["id"], "cap-third-party-light-on")
self.assertEqual(resolved["slot"], 9)
self.assertEqual(resolved["card_uid"], "1A53DD95")
self.assertIsNone(
resolve_active_peripheral_command("打开氛围灯", catalog, present)
)
self.assertIsNone(
resolve_active_peripheral_command(
"打开第三方氛围灯", catalog, {"card": None}
)
)
self.assertIsNone(
resolve_active_peripheral_command(
"打开第三方氛围灯",
catalog,
{"card": {"uid": "FFFFFFFF"}},
)
)
def test_timed_phrase_selects_live_nfc_catalog_for_gpt_plan(self) -> None:
catalog = [
{
"id": "on",
"status": "learned",
"device_name": "#001 · 第三方氛围灯",
"card_uid": "1A53DD95",
"action": "开启灯光",
"slot": 1,
},
{
"id": "red",
"status": "learned",
"device_name": "#001 · 第三方氛围灯",
"card_uid": "1A53DD95",
"action": "红色灯光",
"slot": 2,
},
]
selected = active_peripheral_plan_catalog(
"打开第三方氛围灯,红灯闪烁5s后关闭",
catalog,
{"card": {"uid": "1A53DD95"}},
)
self.assertEqual([item["id"] for item in selected], ["on", "red"])
self.assertEqual(
active_peripheral_plan_catalog(
"打开第三方氛围灯",
catalog,
{"card": {"uid": "1A53DD95"}},
),
[],
)
def test_main_peripheral_result_uses_gscu_without_vehicle_can(self) -> None:
service = SelfExtensionService(SimulationGateway())
result = service.execute_peripheral_request(
"打开第三方氛围灯",
{
"id": "cap-third-party-light-on",
"device_id": "third-party-light",
"device_name": "第三方氛围灯",
"card_uid": "1A53DD95",
"action": "开启",
"slot": 9,
},
conversation_id="conv-main-extension",
conversation_turn=2,
execution_mode="hardware",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["orchestration_route"], "nfc_peripheral")
self.assertEqual(result["peripheral"]["slot"], 9)
self.assertEqual(result["execution_report"]["spec"]["mode"], "hil")
operation = result["execution_report"]["spec"]["results"][0]
self.assertEqual(operation["domain"], "self_extension")
self.assertTrue(operation["telemetry"]["gateway_acknowledged"])
def test_main_peripheral_sequence_executes_validated_actions_and_delays(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
{
"id": "red",
"status": "learned",
"device_id": "third-party-light",
"device_name": "第三方氛围灯",
"card_uid": "1A53DD95",
"action": "红色灯光",
"slot": 3,
},
{
"id": "blue",
"status": "learned",
"device_id": "third-party-light",
"device_name": "第三方氛围灯",
"card_uid": "1A53DD95",
"action": "蓝色灯光",
"slot": 4,
},
{
"id": "off",
"status": "learned",
"device_id": "third-party-light",
"device_name": "第三方氛围灯",
"card_uid": "1A53DD95",
"action": "关闭灯光",
"slot": 5,
},
]
plan_result = {
"model": "gpt-5.6-sol",
"summary": "红灯保持五秒后切换蓝灯",
"steps": [
{"kind": "peripheral", "capability_id": "red"},
{"kind": "delay", "delay_ms": 5000},
{"kind": "peripheral", "capability_id": "blue"},
],
}
with patch("cognitive_agent.application.self_extension.time.sleep") as sleep:
result = service.execute_peripheral_plan_request(
"第三方氛围灯红灯闪烁5秒后切换蓝灯",
plan_result,
catalog,
conversation_id="conv-main-extension-sequence",
execution_mode="hardware",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(
result["orchestration_route"], "nfc_peripheral_sequence"
)
self.assertEqual(result["sequence"]["step_count"], 3)
self.assertEqual(result["sequence"]["action_count"], 2)
self.assertEqual(result["sequence"]["emit_count"], 2)
self.assertEqual(result["sequence"]["total_delay_ms"], 5000)
self.assertEqual(result["sequence"]["steady_blink_fallbacks"], 1)
self.assertEqual(
[item["status"] for item in result["operation_results"]],
["succeeded", "succeeded", "succeeded"],
)
sleep.assert_any_call(5.0)
self.assertNotIn(0.5, [call.args[0] for call in sleep.call_args_list])
def test_color_sequence_never_cycles_the_power_off_capability(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
{
"id": capability_id,
"status": "learned",
"device_id": "third-party-light",
"device_name": "第三方氛围灯",
"card_uid": "1A53DD95",
"action": action,
"slot": slot,
}
for capability_id, action, slot in (
("on", "开启灯光", 1),
("off", "关闭灯光", 2),
("red", "红色灯光", 3),
("green", "绿色灯光", 4),
("blue", "蓝色灯光", 5),
("white", "白色灯光", 6),
)
]
plan_result = {
"model": "gpt-5.6-sol",
"summary": "按顺序显示四种颜色",
"steps": [
{"kind": "peripheral", "capability_id": "on"},
{"kind": "peripheral", "capability_id": "red"},
{"kind": "delay", "delay_ms": 5000},
{"kind": "peripheral", "capability_id": "blue"},
{"kind": "delay", "delay_ms": 5000},
{"kind": "peripheral", "capability_id": "green"},
{"kind": "delay", "delay_ms": 5000},
{"kind": "peripheral", "capability_id": "white"},
{"kind": "delay", "delay_ms": 5000},
{"kind": "peripheral", "capability_id": "off"},
],
}
with patch("cognitive_agent.application.self_extension.time.sleep") as sleep:
result = service.execute_peripheral_plan_request(
(
"打开第三方氛围灯,红灯闪烁5s后,蓝光闪烁5s后,"
"绿灯闪烁5s后,白色闪烁5s,最后关闭第三方氛围灯"
),
plan_result,
catalog,
conversation_id="conv-color-sequence-no-power-cycle",
execution_mode="hardware",
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["sequence"]["emit_count"], 6)
self.assertEqual(result["sequence"]["steady_blink_fallbacks"], 4)
self.assertEqual(result["sequence"]["total_delay_ms"], 20_000)
self.assertEqual(
[
operation["telemetry"]["slot"]
for operation in result["operation_results"]
if operation["telemetry"].get("slot")
],
[1, 3, 5, 4, 6, 2],
)
self.assertEqual(
[call.args[0] for call in sleep.call_args_list].count(5.0),
4,
)
self.assertNotIn(0.5, [call.args[0] for call in sleep.call_args_list])
def test_plan_only_accepts_learned_catalog_capabilities(self) -> None:
service = SelfExtensionService(SimulationGateway())
proposed = {
@@ -117,6 +363,122 @@ class SelfExtensionTests(unittest.TestCase):
self.assertEqual(result["steps"][0]["capability_id"], "cap-light-on")
self.assertEqual(result["steps"][1]["command"], "light reading all on")
def test_plan_maps_short_planner_ids_back_to_opaque_capability_ids(self) -> None:
service = SelfExtensionService(SimulationGateway())
opaque_id = "cap-b0ab02a2-3640-4468-8cdc-3bb7164c7078"
def planned_response(
_utterance: str,
peripherals: list[dict[str, object]],
_vehicle_devices: list[dict[str, object]],
_api_key: str,
) -> str:
self.assertEqual(peripherals[0]["id"], "peripheral_1")
self.assertNotIn(opaque_id, [item["id"] for item in peripherals])
return json.dumps(
{
"summary": "切换红灯",
"needs_confirmation": False,
"question": "",
"steps": [
{
"kind": "peripheral",
"capability_id": "peripheral_1",
"command": "红色灯光",
"label": "第三方氛围灯 · 红色灯光",
"delay_ms": 0,
}
],
},
ensure_ascii=False,
)
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False):
with patch.object(
service,
"_request_gpt_plan",
side_effect=planned_response,
):
result = service.plan(
{
"utterance": "第三方氛围灯切换红灯",
"peripherals": [
{
"id": opaque_id,
"status": "learned",
"device_name": "第三方氛围灯",
"action": "红色灯光",
"risk": "low",
}
],
"vehicle_devices": [],
}
)
self.assertEqual(result["steps"][0]["capability_id"], opaque_id)
def test_plan_recovers_full_color_sequence_when_gpt_returns_no_steps(self) -> None:
service = SelfExtensionService(SimulationGateway())
actions = (
("on", "开启灯光", 1),
("off", "关闭灯光", 2),
("red", "红色灯光", 3),
("green", "绿色灯光", 4),
("blue", "蓝色灯光", 5),
("white", "白色灯光", 6),
)
proposed = {
"summary": "模型未能生成动作",
"needs_confirmation": False,
"question": "",
"steps": [],
}
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False):
with patch.object(
service,
"_request_gpt_plan",
return_value=json.dumps(proposed, ensure_ascii=False),
):
result = service.plan(
{
"utterance": (
"打开第三方氛围灯,红灯闪烁5s后,蓝光闪烁5s后,"
"绿灯闪烁5s后,白色闪烁5s,最后关闭第三方氛围灯"
),
"peripherals": [
{
"id": capability_id,
"status": "learned",
"device_id": "third-party-light",
"device_name": "第三方氛围灯",
"action": action,
"aliases": [action],
"slot": slot,
"risk": "low",
}
for capability_id, action, slot in actions
],
"vehicle_devices": [],
}
)
self.assertEqual(
[
step.get("capability_id")
for step in result["steps"]
if step["kind"] == "peripheral"
],
["on", "red", "blue", "green", "white", "off"],
)
self.assertEqual(
[
step.get("delay_ms")
for step in result["steps"]
if step["kind"] == "delay"
],
[5000, 5000, 5000, 5000],
)
def test_plan_rejects_vehicle_command_outside_current_catalog(self) -> None:
service = SelfExtensionService(SimulationGateway())
proposed = {
@@ -235,6 +597,10 @@ class SelfExtensionTests(unittest.TestCase):
]["items"]["properties"]["command"]
self.assertEqual(command_schema["type"], "string")
self.assertEqual(command_schema["maxLength"], 300)
capability_schema = request_payload["text"]["format"]["schema"]["properties"][
"steps"
]["items"]["properties"]["capability_id"]
self.assertEqual(capability_schema["enum"], [""])
def test_plan_accepts_bounded_passenger_position_for_declared_ch0_executor(
self,