feat: release v1.0.2 LeKiwi 语言控制与网站嵌入
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
集成服务器托管模型、自然语言移动与有界抓放、内置 LeKiwi URL 导入和双摄像头;同步部署契约与指定域名 iframe 白名单,保留原有物理安全、会话及调用预算防护。 更新 npm 包及锁文件版本、CHANGELOG 与发布文档。提交前 typecheck、120 项定向前端测试和 44 项后端测试通过(3 项可选跳过);真实 v2 云模型抓放仍待单独验收,不包含运行密钥或构建产物。
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from decision_server.commands import checked
|
||||
from decision_server.connections import Connection
|
||||
from decision_server.hosted_budget import HostedBudget
|
||||
from decision_server.protocol import DecisionError
|
||||
from decision_server.server import PREFIX
|
||||
from decision_server.tests import test_website
|
||||
from decision_server.web_server import MANAGER, create_website_app
|
||||
|
||||
|
||||
class HostedTests(unittest.IsolatedAsyncioTestCase):
|
||||
visitor = test_website.WebsiteTests.visitor
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.temp.name) / "budget.sqlite"
|
||||
self.defaults = {
|
||||
"llm": Connection(
|
||||
"responses", "https://api.deepseek.com", "deepseek-flash", "llm-test-secret"
|
||||
),
|
||||
"jev": Connection(
|
||||
"openrouter-decisions",
|
||||
"https://openrouter.ai/api/alpha/decisions",
|
||||
"typesafe/jev-1.13",
|
||||
"jev-test-secret",
|
||||
),
|
||||
}
|
||||
self.origin = "https://site.test"
|
||||
self.headers = {"Host": "site.test", "Origin": self.origin}
|
||||
self.app = create_website_app(
|
||||
self.origin, defaults=self.defaults, budget=HostedBudget(self.path, 4, 8)
|
||||
)
|
||||
self.manager = self.app[MANAGER]
|
||||
self.client = TestClient(TestServer(self.app))
|
||||
await self.client.start_server()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
await self.client.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def request(self, ident="request1"):
|
||||
return {
|
||||
"instruction": "前进0.5m",
|
||||
"stamp": {
|
||||
"runId": "run1",
|
||||
"requestId": ident,
|
||||
"sceneRevision": 0,
|
||||
"sequence": 0,
|
||||
"planRevision": 0,
|
||||
},
|
||||
}
|
||||
|
||||
async def test_defaults_locked_and_private(self):
|
||||
a, av = await self.visitor()
|
||||
b, bv = await self.visitor()
|
||||
response = await self.client.get(PREFIX + "/status", headers=a)
|
||||
text = await response.text()
|
||||
self.assertTrue((await response.json())["ready"])
|
||||
for conn in self.defaults.values():
|
||||
self.assertNotIn(conn.key, text)
|
||||
for method, path in [
|
||||
("put", "/configuration"),
|
||||
("post", "/test"),
|
||||
("post", "/codex/login"),
|
||||
("get", "/models"),
|
||||
]:
|
||||
response = await getattr(self.client, method)(PREFIX + path, headers=a, json={})
|
||||
self.assertEqual(response.status, 403)
|
||||
await self.client.delete(PREFIX + "/session", headers=a)
|
||||
self.assertFalse(av.service.connections.values)
|
||||
self.assertTrue(bv.service.connections.values)
|
||||
self.assertEqual(self.manager.defaults, self.defaults)
|
||||
|
||||
async def test_real_shape_dual_calls_no_retry_and_shared_persistent_budget(self):
|
||||
llm = AsyncMock(
|
||||
return_value=(
|
||||
{"action": "move", "value": 0.5, "summary": "前进0.5米"},
|
||||
{"input_tokens": 1},
|
||||
)
|
||||
)
|
||||
gate = AsyncMock(return_value={"answers": {"gate": {"choice": "allow"}}, "usage": {}})
|
||||
with (
|
||||
patch("decision_server.providers.openai.structured", llm),
|
||||
patch("decision_server.providers.jev.post", gate),
|
||||
):
|
||||
for i in range(3):
|
||||
headers, _ = await self.visitor()
|
||||
response = await self.client.post(
|
||||
PREFIX + "/command", json=self.request(str(i)), headers=headers
|
||||
)
|
||||
self.assertEqual(response.status, 200 if i < 2 else 429)
|
||||
self.assertEqual(llm.await_count, 2)
|
||||
self.assertEqual(gate.await_count, 2)
|
||||
with self.assertRaisesRegex(DecisionError, "shared_budget_exceeded"):
|
||||
HostedBudget(self.path, 4, 8).reserve()
|
||||
|
||||
async def test_cancel_owns_jev_and_other_session_cannot_cancel(self):
|
||||
a, _ = await self.visitor()
|
||||
b, bv = await self.visitor()
|
||||
started = asyncio.Event()
|
||||
|
||||
async def delayed(*_):
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"decision_server.providers.openai.structured",
|
||||
AsyncMock(return_value=({"action": "move", "value": 0.5, "summary": "前进"}, {})),
|
||||
),
|
||||
patch("decision_server.providers.jev.post", side_effect=delayed),
|
||||
):
|
||||
job = asyncio.create_task(
|
||||
self.client.post(PREFIX + "/command", headers=b, json=self.request())
|
||||
)
|
||||
await asyncio.wait_for(started.wait(), 2)
|
||||
identity = {"runId": "run1", "requestId": "request1"}
|
||||
response = await self.client.post(PREFIX + "/cancel", headers=a, json=identity)
|
||||
self.assertFalse((await response.json())["cancelled"])
|
||||
response = await self.client.post(PREFIX + "/cancel", headers=b, json=identity)
|
||||
self.assertTrue((await response.json())["cancelled"])
|
||||
self.assertEqual((await job).status, 409)
|
||||
self.assertFalse(bv.service.active)
|
||||
|
||||
def test_output_rejects_bad_direction_units_values_and_ambiguity(self):
|
||||
for value in [True, float("nan"), 2, -0.5]:
|
||||
with self.assertRaises(DecisionError):
|
||||
checked({"action": "move", "value": value, "summary": "走"}, "前进0.5m")
|
||||
self.assertEqual(
|
||||
checked({"action": "turn", "value": 90, "summary": "转"}, "转动90度")["action"],
|
||||
"clarify",
|
||||
)
|
||||
self.assertEqual(
|
||||
checked({"action": "move", "value": 0.5, "summary": "走"}, "不要前进0.5米")["action"],
|
||||
"clarify",
|
||||
)
|
||||
self.assertEqual(
|
||||
checked({"action": "move", "value": 0.5, "summary": "走"}, "前进50cm")["value"], 0.5
|
||||
)
|
||||
with self.assertRaises(DecisionError):
|
||||
checked({"action": "turn", "value": 90, "summary": "转"}, "右转90度")
|
||||
@@ -0,0 +1,99 @@
|
||||
import unittest
|
||||
|
||||
from decision_server.commands import checked as motion_checked
|
||||
from decision_server.language_tasks import checked, resolve_target
|
||||
from decision_server.protocol import (
|
||||
LANGUAGE_PRECONDITIONS,
|
||||
LANGUAGE_SKILLS,
|
||||
VERSION,
|
||||
DecisionError,
|
||||
validate_jev,
|
||||
validate_plan,
|
||||
)
|
||||
from decision_server.protocol import (
|
||||
LANGUAGE_VERSION as V,
|
||||
)
|
||||
|
||||
|
||||
def command(**changes):
|
||||
return (
|
||||
dict(
|
||||
version=V,
|
||||
action="pick_place",
|
||||
value=0,
|
||||
summary="搬运方块",
|
||||
objectId="block",
|
||||
targetId="B",
|
||||
position=[],
|
||||
supportId="table",
|
||||
)
|
||||
| changes
|
||||
)
|
||||
|
||||
|
||||
class LanguageTests(unittest.TestCase):
|
||||
def test_named_and_coordinates(self):
|
||||
self.assertEqual(
|
||||
checked(command(), "抓起方块并搬到B区放下", motion_checked)["targetId"], "B"
|
||||
)
|
||||
for text, values in [
|
||||
("搬到(25,50)厘米", [0.25, 0.5]),
|
||||
("搬到 X=0.25 Y=0.50 Z=0.128", [0.25, 0.5, 0.128]),
|
||||
]:
|
||||
result = checked(command(targetId="coordinates", position=values), text, motion_checked)
|
||||
self.assertEqual(
|
||||
resolve_target(result["targetId"], result["position"]),
|
||||
([0.25, 0.5, 0.128], "table"),
|
||||
)
|
||||
|
||||
def test_bad_targets_and_mismatch(self):
|
||||
for pos in ([0.8, 0.5], [0.25, 0.5, 0.5], [float("nan"), 0.5]):
|
||||
with self.assertRaises(DecisionError):
|
||||
resolve_target("coordinates", pos)
|
||||
with self.assertRaisesRegex(DecisionError, "command_mismatch"):
|
||||
checked(
|
||||
command(targetId="coordinates", position=[0.25, 0.6]),
|
||||
"搬到(0.25,0.50)",
|
||||
motion_checked,
|
||||
)
|
||||
for text in [
|
||||
"不要搬到 B 区",
|
||||
"搬到指定位置",
|
||||
"前进0.5米然后搬到 B 区",
|
||||
"搬到 A 区或者 B 区",
|
||||
]:
|
||||
self.assertEqual(checked(command(), text, motion_checked)["action"], "clarify")
|
||||
|
||||
def test_multiple_or_contradictory_coordinates_rejected(self):
|
||||
for instruction in ["搬到(0.25,0.5)然后到(0.25,0.6)", "搬到 B 区 (0.25,0.5)"]:
|
||||
with self.assertRaises(DecisionError):
|
||||
checked(command(), instruction, motion_checked)
|
||||
|
||||
def test_v2_does_not_weaken_v1(self):
|
||||
plan = dict(
|
||||
version=V,
|
||||
objectId="block",
|
||||
goalId="placement",
|
||||
summary="计划",
|
||||
steps=[
|
||||
dict(skill=s, precondition=LANGUAGE_PRECONDITIONS[s], onFailure="stop")
|
||||
for s in LANGUAGE_SKILLS
|
||||
],
|
||||
)
|
||||
validate_plan(plan, LANGUAGE_SKILLS, V)
|
||||
with self.assertRaises(DecisionError):
|
||||
validate_plan(plan, LANGUAGE_SKILLS, VERSION)
|
||||
d = dict(
|
||||
version=V,
|
||||
choice="stow",
|
||||
grasp="empty",
|
||||
diagnosis="none",
|
||||
recovery="continue",
|
||||
noul="allow",
|
||||
score="good",
|
||||
reason="verified_progress",
|
||||
)
|
||||
validate_jev(d, ["stow", "stop"], V)
|
||||
for field, value in [("noul", "yes"), ("score", 100), ("choice", "carry")]:
|
||||
with self.assertRaises(DecisionError):
|
||||
validate_jev(d | {field: value}, ["stow", "stop"], V)
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -19,19 +21,88 @@ async def main():
|
||||
|
||||
async def respond(request):
|
||||
body = await request.json()
|
||||
import json
|
||||
|
||||
if request.path == "/decisions":
|
||||
choices = {name: next(iter(q["criteria"])) for name, q in body["questions"].items()}
|
||||
if "grasp" in choices:
|
||||
evidence = json.loads(body["state"])["observation"]["evidence"]
|
||||
choices.update(
|
||||
grasp="secure"
|
||||
if evidence["secure"]
|
||||
else "uncertain"
|
||||
if any(v > 0.2 for v in evidence["fingerForces"])
|
||||
else "empty",
|
||||
diagnosis="none",
|
||||
recovery="continue",
|
||||
)
|
||||
if "noul" in choices:
|
||||
choices.update(noul="allow", score="good", reason="verified_progress")
|
||||
return web.json_response(
|
||||
{
|
||||
"answers": {
|
||||
name: {"choice": next(iter(q["criteria"]))}
|
||||
for name, q in body["questions"].items()
|
||||
},
|
||||
"answers": {name: {"choice": choice} for name, choice in choices.items()},
|
||||
"usage": {"input_tokens": 1},
|
||||
}
|
||||
)
|
||||
import json
|
||||
|
||||
value = '{"ok":true}' if '"ok"' in str(body) else json.dumps(plan_value())
|
||||
if '"action"' in json.dumps(body.get("text", {})):
|
||||
instruction = json.loads(body["input"])["instruction"]
|
||||
choices = {
|
||||
"前进0.5米": ("move", 0.5),
|
||||
"前进0.2米": ("move", 0.2),
|
||||
"后退0.2米": ("move", -0.2),
|
||||
"左转90度": ("turn", 90),
|
||||
"右转90度": ("turn", -90),
|
||||
"把方块搬到支撑台": ("pick_place", 0),
|
||||
"把方块搬到 B 区": ("pick_place", 0),
|
||||
"把方块搬到 (0.25,0.50) 米": ("pick_place", 0),
|
||||
}
|
||||
action, number = choices.get(instruction, ("clarify", 0))
|
||||
context = json.loads(body["input"])
|
||||
extra = {}
|
||||
if "sceneContext" in context:
|
||||
extra = dict(
|
||||
version="lekiwi-language-v2",
|
||||
objectId="none",
|
||||
targetId="none",
|
||||
position=[],
|
||||
supportId="none",
|
||||
)
|
||||
if action == "pick_place":
|
||||
extra.update(
|
||||
objectId="block",
|
||||
targetId="B" if "B" in instruction else "coordinates",
|
||||
position=[] if "B" in instruction else [0.25, 0.50],
|
||||
supportId="table",
|
||||
)
|
||||
value = json.dumps(
|
||||
{
|
||||
**extra,
|
||||
"action": action,
|
||||
"value": number,
|
||||
"summary": instruction
|
||||
if action != "clarify"
|
||||
else "请说明左转还是右转,一次一个动作。",
|
||||
}
|
||||
)
|
||||
else:
|
||||
value = '{"ok":true}' if '"ok"' in str(body) else json.dumps(plan_value())
|
||||
context = json.loads(body.get("input", "{}"))
|
||||
if context.get("observation", {}).get("version") == "lekiwi-language-v2":
|
||||
from decision_server.protocol import LANGUAGE_PRECONDITIONS
|
||||
|
||||
value = json.dumps(
|
||||
dict(
|
||||
version="lekiwi-language-v2",
|
||||
objectId="block",
|
||||
goalId="placement",
|
||||
summary="测试规划",
|
||||
steps=[
|
||||
dict(skill=s, precondition=LANGUAGE_PRECONDITIONS[s], onFailure="stop")
|
||||
for s in context["remaining"]
|
||||
],
|
||||
)
|
||||
)
|
||||
if request.path == "/chat/completions":
|
||||
return web.json_response(
|
||||
{"choices": [{"finish_reason": "stop", "message": {"content": value}}]}
|
||||
@@ -62,7 +133,26 @@ async def main():
|
||||
from decision_server import server
|
||||
|
||||
server.post = fake_post
|
||||
app = create_website_app("http://127.0.0.1:4180", development=True)
|
||||
from decision_server.connections import Connection
|
||||
from decision_server.hosted_budget import HostedBudget
|
||||
|
||||
quota_dir = tempfile.TemporaryDirectory()
|
||||
app = create_website_app(
|
||||
os.environ.get("CADWORLD_E2E_ORIGIN", "http://127.0.0.1:4180"),
|
||||
development=True,
|
||||
defaults={
|
||||
"llm": Connection(
|
||||
"responses", "https://api.deepseek.com", "deepseek-flash", "fixture-llm"
|
||||
),
|
||||
"jev": Connection(
|
||||
"openrouter-decisions",
|
||||
"https://openrouter.ai/api/alpha/decisions",
|
||||
"typesafe/jev-1.13",
|
||||
"fixture-jev",
|
||||
),
|
||||
},
|
||||
budget=HostedBudget(Path(quota_dir.name) / "budget.sqlite"),
|
||||
)
|
||||
catalog = app[CATALOG]
|
||||
catalog.models = {"fixture/structured": "Fixture structured model (not real)"}
|
||||
catalog.available = True
|
||||
@@ -73,12 +163,15 @@ async def main():
|
||||
catalog.refresh = refresh
|
||||
gateway = web.AppRunner(app, access_log=None, handler_cancellation=True)
|
||||
await gateway.setup()
|
||||
await web.TCPSite(gateway, "127.0.0.1", 8769).start()
|
||||
await web.TCPSite(
|
||||
gateway, "127.0.0.1", int(os.environ.get("CADWORLD_E2E_PORT", "8769"))
|
||||
).start()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await gateway.cleanup()
|
||||
await runner.cleanup()
|
||||
quota_dir.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user