fix: release v1.0.3 预设渲染与语言搬运监督修复
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
This commit is contained in:
@@ -6,15 +6,70 @@ Protocol shape informed by MIT-licensed jev-libero / embodied-jev; see THIRD_PAR
|
||||
import json
|
||||
import math
|
||||
|
||||
from ..protocol import LANGUAGE_VERSION, DecisionError, schema_for, validate_jev
|
||||
from ..protocol import (
|
||||
LANGUAGE_SKILLS,
|
||||
LANGUAGE_VERSION,
|
||||
SKILLS,
|
||||
DecisionError,
|
||||
schema_for,
|
||||
validate_jev,
|
||||
)
|
||||
from .http import post, usage
|
||||
|
||||
# Criteria are the decision model's option semantics, not just display labels.
|
||||
# In particular, `close` means close the gripper, NOT close/terminate the task.
|
||||
SKILL_CRITERIA = {
|
||||
"stow": "Raise the empty arm to its safe travel pose before approaching the source.",
|
||||
"approach": (
|
||||
"After completedSkill=stow with failure=none, the arm has reached its safe travel pose. "
|
||||
"Proceed to dock the base at the source; the gripper should still be empty."
|
||||
),
|
||||
"open": "Open the gripper before grasping a supported block (also safe recovery).",
|
||||
"pregrasp": "Move the TCP above the block while the base is stopped.",
|
||||
"descend": "Lower the open gripper from above to align its TCP with the block.",
|
||||
"close": (
|
||||
"Close the gripper fingers around the aligned, supported block; TCP-object distance "
|
||||
"must be <=0.012 m. Zero finger forces and secure=false BEFORE closing are expected."
|
||||
),
|
||||
"verify": (
|
||||
"Lift to verify grasp AFTER closing, with both finger forces >=0.2 N. "
|
||||
"secure=false is expected BEFORE this lift; this skill establishes verified grasp."
|
||||
),
|
||||
"carry": "Transport the block with verified grasp: evidence.secure=true is required.",
|
||||
"stop-base": "Stop at the target dock while maintaining evidence.secure=true.",
|
||||
"place": "Lower the transported block onto the target support with the base stopped.",
|
||||
"release": (
|
||||
"Open the fingers after evidence.onGoalSupport=true. secure may already be false "
|
||||
"because the block is now supported; this is expected, not slipping."
|
||||
),
|
||||
"retreat": "Withdraw the empty gripper after releasing the supported block.",
|
||||
"settle": "Hold still to measure released block stability on the target support.",
|
||||
"stop": (
|
||||
"Choose stop when failure indicates a hard fault, safety flags are present, or the "
|
||||
"next skill's physical precondition is contradicted or cannot be established; "
|
||||
"NOT merely because the whole task is unfinished or the gripper is empty before grasp."
|
||||
),
|
||||
}
|
||||
STAGE_CONTEXT = (
|
||||
"Judge the offered NEXT skill, not completion of the whole task. observation.phase names "
|
||||
"the current/just-completed skill; candidates names the next permitted skill or stop. "
|
||||
"transition.completedSkill means the local executor completed that skill and its exit "
|
||||
"checks without failure; null means initial entry or recovery, not completion. "
|
||||
"The local simulator freezes physics while waiting for this response; time is simulation "
|
||||
"seconds, not a wall-clock timestamp. The stamped observation is the current snapshot. "
|
||||
"An empty gripper before close is normal. verify establishes secure grasp by lifting; "
|
||||
"only carry/stop-base require an already verified grasp. During place/release the object "
|
||||
"returns to support, so secure=false there is not by itself a lost grasp. "
|
||||
"Never ignore an explicit failure, safety flag or missing required evidence. "
|
||||
"The local controller independently checks physical preconditions before any actuation. "
|
||||
)
|
||||
|
||||
def question(options, instruction):
|
||||
|
||||
def question(options, instruction, criteria=None):
|
||||
return {
|
||||
"type": "choice",
|
||||
"instructions": instruction,
|
||||
"criteria": {option: option for option in options},
|
||||
"criteria": {option: (criteria or {}).get(option, option) for option in options},
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +99,8 @@ async def decide(session, conn, request):
|
||||
options["choice"] = request["candidates"]
|
||||
prompts = {
|
||||
"choice": (
|
||||
"Choose an offered next skill if safe. Stop for hard safety faults. "
|
||||
"Only the local controller can determine success or permit actuation."
|
||||
STAGE_CONTEXT + "Choose the safe offered next skill according to its criteria. "
|
||||
"Stop when its required evidence is absent or a hard safety fault is present."
|
||||
),
|
||||
"grasp": (
|
||||
"secure requires two finger forces >=0.2 N, verified lift and stable grasp evidence. "
|
||||
@@ -68,8 +123,9 @@ async def decide(session, conn, request):
|
||||
prompts.update(
|
||||
{
|
||||
"noul": (
|
||||
"Veto unsafe actions: allow only with fresh sufficient evidence and no hard "
|
||||
"safety fault; deny on danger, uncertain on missing evidence. "
|
||||
STAGE_CONTEXT + "Veto unsafe NEXT skills, not incomplete tasks: allow with "
|
||||
"sufficient evidence for the offered stage and no hard safety fault; "
|
||||
"deny on danger, uncertain on missing evidence required for that stage. "
|
||||
"This is not physical authorization."
|
||||
),
|
||||
"score": (
|
||||
@@ -83,6 +139,46 @@ async def decide(session, conn, request):
|
||||
),
|
||||
}
|
||||
)
|
||||
observation = request["observation"]
|
||||
candidates = [skill for skill in request["candidates"] if skill != "stop"]
|
||||
next_skill = candidates[0] if len(candidates) == 1 else None
|
||||
failure = request.get("failure", "none")
|
||||
sequence = LANGUAGE_SKILLS if version == LANGUAGE_VERSION else SKILLS
|
||||
adjacent = (
|
||||
next_skill in sequence
|
||||
and sequence.index(next_skill) == sequence.index(observation["phase"]) + 1
|
||||
)
|
||||
# This is the caller's skill-boundary protocol, not a new safety authorization.
|
||||
# Preserve raw observations and vetoes; never infer grasp from task progress.
|
||||
transition = {
|
||||
"nextSkill": next_skill,
|
||||
"completedSkill": (
|
||||
observation["phase"]
|
||||
if adjacent and failure == "none" and not observation["safety"]
|
||||
else None
|
||||
),
|
||||
}
|
||||
criteria = {
|
||||
"choice": SKILL_CRITERIA,
|
||||
"grasp": {
|
||||
"secure": "evidence.secure=true and both fingerForces >=0.2 N; verified lifted grasp.",
|
||||
"empty": "No held block; normal before closing and after release.",
|
||||
"slipping": "Previously held block is being lost, not intentionally placed on support.",
|
||||
"uncertain": "Grasp not yet verified: fingers closed but lift verification pending.",
|
||||
},
|
||||
"noul": {
|
||||
"allow": "The offered next skill is safe with sufficient evidence for THAT stage.",
|
||||
"deny": "Explicit danger, safety fault or violated precondition for the next skill.",
|
||||
"uncertain": "Evidence required for the next skill is missing or ambiguous.",
|
||||
},
|
||||
"reason": {
|
||||
"none": "No safety concern and no specific progress finding.",
|
||||
"unsafe": "Explicit danger or safety fault motivates a veto/stop.",
|
||||
"insufficient_evidence": "Missing stage evidence motivates uncertainty or a stop.",
|
||||
"tracking_error": "Measured alignment or tracking failure.",
|
||||
"verified_progress": "Physical evidence supports progress at this skill boundary.",
|
||||
},
|
||||
}
|
||||
result = await post(
|
||||
session,
|
||||
conn,
|
||||
@@ -95,11 +191,20 @@ async def decide(session, conn, request):
|
||||
else {}
|
||||
),
|
||||
"state": json.dumps(
|
||||
{"observation": request["observation"], "failure": request.get("failure", "none")},
|
||||
{
|
||||
"observation": observation,
|
||||
"failure": failure,
|
||||
"transition": transition,
|
||||
"candidates": request["candidates"],
|
||||
"nextSkillCriteria": {
|
||||
skill: SKILL_CRITERIA[skill] for skill in request["candidates"]
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"questions": {
|
||||
name: question(values, prompts[name]) for name, values in options.items()
|
||||
name: question(values, prompts[name], criteria.get(name))
|
||||
for name, values in options.items()
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from decision_server.protocol import LANGUAGE_SKILLS, LANGUAGE_VERSION
|
||||
from decision_server.providers import jev
|
||||
from decision_server.tests.test_service import observation
|
||||
|
||||
|
||||
class JevContextTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def decide(self, candidate="close", phase="descend", failure="none", **changes):
|
||||
obs = observation()
|
||||
obs.update(version=LANGUAGE_VERSION, phase=phase)
|
||||
values = (
|
||||
dict(
|
||||
choice=candidate,
|
||||
grasp="empty",
|
||||
diagnosis="none",
|
||||
recovery="continue",
|
||||
noul="allow",
|
||||
score="partial",
|
||||
reason="none",
|
||||
)
|
||||
| changes
|
||||
)
|
||||
upstream = {"answers": {k: {"choice": v} for k, v in values.items()}}
|
||||
request = dict(observation=obs, candidates=[candidate, "stop"], failure=failure)
|
||||
with patch.object(jev, "post", AsyncMock(return_value=upstream)) as post:
|
||||
result, _ = await jev.decide(
|
||||
None, SimpleNamespace(model="fixture", protocol="openrouter-decisions"), request
|
||||
)
|
||||
return result, post.call_args.args[3], request
|
||||
|
||||
async def test_next_skill_context_and_descriptive_criteria(self):
|
||||
result, body, request = await self.decide()
|
||||
state = json.loads(body["state"])
|
||||
self.assertEqual(state["observation"], request["observation"])
|
||||
self.assertEqual(state["candidates"], ["close", "stop"])
|
||||
self.assertEqual(state["transition"], {"nextSkill": "close", "completedSkill": "descend"})
|
||||
self.assertEqual(result["choice"], "close")
|
||||
choice = body["questions"]["choice"]
|
||||
self.assertEqual(set(choice["criteria"]), {"close", "stop"})
|
||||
self.assertIn("0.012 m", choice["criteria"]["close"])
|
||||
self.assertIn("Zero finger forces", choice["criteria"]["close"])
|
||||
self.assertIn("freezes physics", choice["instructions"])
|
||||
self.assertIn("NEXT", body["questions"]["noul"]["instructions"])
|
||||
self.assertEqual(body["provider"], {"allow_fallbacks": False})
|
||||
for skill in LANGUAGE_SKILLS:
|
||||
self.assertGreater(len(jev.SKILL_CRITERIA[skill]), len(skill))
|
||||
self.assertIn("secure=false is expected", jev.SKILL_CRITERIA["verify"])
|
||||
self.assertIn("secure=true", jev.SKILL_CRITERIA["carry"])
|
||||
self.assertIn("onGoalSupport=true", jev.SKILL_CRITERIA["release"])
|
||||
|
||||
async def test_initial_entry_and_failed_recovery_are_not_completed_skills(self):
|
||||
for candidate, phase, failure in [
|
||||
("stow", "stow", "none"),
|
||||
("open", "close", "empty_grasp"),
|
||||
("carry", "stow", "none"),
|
||||
]:
|
||||
_, body, _ = await self.decide(candidate, phase, failure)
|
||||
self.assertIsNone(json.loads(body["state"])["transition"]["completedSkill"])
|
||||
_, body, _ = await self.decide("approach", "stow")
|
||||
self.assertEqual(json.loads(body["state"])["transition"]["completedSkill"], "stow")
|
||||
|
||||
async def test_never_rewrites_veto_or_stop_even_with_reason_none(self):
|
||||
for changes in [dict(noul="deny"), dict(noul="uncertain"), dict(choice="stop")]:
|
||||
with self.subTest(changes=changes):
|
||||
result, _, _ = await self.decide(**changes)
|
||||
for key, value in changes.items():
|
||||
self.assertEqual(result[key], value)
|
||||
self.assertEqual(result["reason"], "none")
|
||||
|
||||
def test_legacy_question_shape_is_unchanged(self):
|
||||
self.assertEqual(jev.question(["ok"], "Choose ok.")["criteria"], {"ok": "ok"})
|
||||
Reference in New Issue
Block a user