Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README/WHATS_NEW_zh-CN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# 本次更新 — AutoControl

## 本次更新 (2026-06-24) — 声明式动作后置条件

以 JSON 规格断言动作的预期结果,并对照 before 帧做差异。完整参考:[`docs/source/Zh/doc/new_features/v169_features_doc.rst`](../docs/source/Zh/doc/new_features/v169_features_doc.rst)。

- **`check_postcondition` / `compile_postcondition`**(`AC_check_postcondition`):`expect_poll`/`assert_eventually` 轮询单一条件,没有与动作绑定的规格、也没有 before 基准(因此无法表达「一个*新*对话框出现了」);`trajectory_eval` 是整条轨迹层级。本功能对 after 观测评估一个小型 JSON 子句规格——`appears`/`disappears`(对照 `before`)、`enabled`/`disabled`、`text_present`/`text_absent`、`count`——返回逐子句的 `{ok, clauses, failed}` 报告。`compile_postcondition` 把规格转成 `after -> bool` 判定函数以供 `expect_poll` 使用。纯标准库;不导入 `PySide6`。

## 本次更新 (2026-06-24) — 边缘形状(Chamfer)模板匹配

以轮廓定位扁平图标,对填充 / 主题 / 抗锯齿稳健。完整参考:[`docs/source/Zh/doc/new_features/v168_features_doc.rst`](../docs/source/Zh/doc/new_features/v168_features_doc.rst)。
Expand Down
6 changes: 6 additions & 0 deletions README/WHATS_NEW_zh-TW.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# 本次更新 — AutoControl

## 本次更新 (2026-06-24) — 宣告式動作後置條件

以 JSON 規格斷言動作的預期結果,並對照 before 幀做差異。完整參考:[`docs/source/Zh/doc/new_features/v169_features_doc.rst`](../docs/source/Zh/doc/new_features/v169_features_doc.rst)。

- **`check_postcondition` / `compile_postcondition`**(`AC_check_postcondition`):`expect_poll`/`assert_eventually` 輪詢單一條件,沒有與動作綁定的規格、也沒有 before 基準(因此無法表達「一個*新*對話框出現了」);`trajectory_eval` 是整條軌跡層級。本功能對 after 觀測評估一個小型 JSON 子句規格——`appears`/`disappears`(對照 `before`)、`enabled`/`disabled`、`text_present`/`text_absent`、`count`——回傳逐子句的 `{ok, clauses, failed}` 報告。`compile_postcondition` 把規格轉成 `after -> bool` 判定函式以供 `expect_poll` 使用。純標準函式庫;不匯入 `PySide6`。

## 本次更新 (2026-06-24) — 邊緣形狀(Chamfer)樣板比對

以輪廓定位扁平圖示,對填充 / 主題 / 抗鋸齒穩健。完整參考:[`docs/source/Zh/doc/new_features/v168_features_doc.rst`](../docs/source/Zh/doc/new_features/v168_features_doc.rst)。
Expand Down
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# What's New — AutoControl

## What's new (2026-06-24) — Declarative Action Postconditions

Assert an action's expected outcome as a JSON spec, diffed against the before-frame. Full reference: [`docs/source/Eng/doc/new_features/v169_features_doc.rst`](docs/source/Eng/doc/new_features/v169_features_doc.rst).

- **`check_postcondition` / `compile_postcondition`** (`AC_check_postcondition`): `expect_poll`/`assert_eventually` poll a single condition with no action-bound spec and no before-baseline (so they can't express "a *new* dialog appeared"); `trajectory_eval` is whole-trajectory. This evaluates a small JSON spec of clauses — `appears`/`disappears` (diffed vs `before`), `enabled`/`disabled`, `text_present`/`text_absent`, `count` — against the after-observation, returning a per-clause `{ok, clauses, failed}` report. `compile_postcondition` turns a spec into an `after -> bool` predicate for `expect_poll`. Pure-stdlib; no `PySide6`.

## What's new (2026-06-24) — Edge-Shape (Chamfer) Template Matching

Locate flat icons by outline, robust to fill / theme / anti-aliasing. Full reference: [`docs/source/Eng/doc/new_features/v168_features_doc.rst`](docs/source/Eng/doc/new_features/v168_features_doc.rst).
Expand Down
45 changes: 45 additions & 0 deletions docs/source/Eng/doc/new_features/v169_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
Declarative Action Postconditions
=================================

After an action an agent (or a replay harness) usually has a concrete expectation: "a dialog
saying 'Saved' should appear AND the Submit button should disable". ``expect_poll`` /
``assert_eventually`` poll a *single condition* but have no notion of an action-bound
*postcondition spec*, and they don't diff against a *before* baseline (so they cannot express
"a NEW dialog appeared" — only "a dialog exists"). ``trajectory_eval`` rubrics are
whole-trajectory, not per-step screen state. ``postcondition`` fills the gap: a small JSON spec
of clauses evaluated against the after-observation (optionally diffed against the
before-observation), returning a per-clause pass/fail report.

Clauses: ``appears`` / ``disappears`` (diffed against ``before``), ``enabled`` / ``disabled``,
``text_present`` / ``text_absent``, and ``count`` (``equals`` / ``min``). Pure-stdlib over
element dicts; the spec is plain JSON so it rides into action files / MCP / the scheduler.
Imports no ``PySide6``.

Headless API
------------

.. code-block:: python

from je_auto_control import check_postcondition, compile_postcondition

spec = {"appears": {"role": "dialog", "name": "Saved"},
"disabled": {"name": "Submit"}}
report = check_postcondition(after_elements, spec, before=before_elements)
if not report.ok:
print("failed clauses:", report.failed)

# turn a spec into a predicate to drive expect_poll
predicate = compile_postcondition({"text_present": "Saved"})

``check_postcondition`` returns a ``PostconditionReport`` (``ok`` / ``clauses`` —
``[{type, ok, detail}]`` — / ``failed``). ``appears`` succeeds only when the element is in
``after`` and *not* in ``before`` (a genuinely new element); ``disappears`` requires a
``before`` frame. ``compile_postcondition`` returns an ``after -> bool`` predicate for pairing
with ``expect_poll`` / ``assert_eventually``.

Executor command
----------------

``AC_check_postcondition`` (``after`` / ``spec`` / ``before`` →
``{ok, clauses, failed}``) is exposed as the MCP tool ``ac_check_postcondition`` (read-only)
and as the Script Builder command **Check Postcondition** under **Native UI**.
1 change: 1 addition & 0 deletions docs/source/Eng/eng_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ Comprehensive guides for all AutoControl features.
doc/new_features/v166_features_doc
doc/new_features/v167_features_doc
doc/new_features/v168_features_doc
doc/new_features/v169_features_doc
doc/ocr_backends/ocr_backends_doc
doc/observability/observability_doc
doc/operations_layer/operations_layer_doc
Expand Down
41 changes: 41 additions & 0 deletions docs/source/Zh/doc/new_features/v169_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
宣告式動作後置條件
==================

動作之後,代理(或重播框架)通常有具體的預期:「應出現寫著『Saved』的對話框,且 Submit
按鈕應停用」。``expect_poll`` / ``assert_eventually`` 輪詢*單一條件*,卻沒有與動作綁定的
*後置條件規格*概念,也不對照 *before* 基準做差異(因此無法表達「一個*新*對話框出現了」
——只能表達「存在對話框」)。``trajectory_eval`` 的評分準則是整條軌跡層級,而非每步畫面
狀態。``postcondition`` 補上這個缺口:用一個小型 JSON 子句規格,對照 after 觀測(可選擇與
before 觀測做差異)評估,回傳逐子句的通過 / 失敗報告。

子句:``appears`` / ``disappears``(對照 ``before``)、``enabled`` / ``disabled``、
``text_present`` / ``text_absent``,以及 ``count``(``equals`` / ``min``)。純標準函式庫,
作用於元素字典;規格為純 JSON,可帶入 action 檔 / MCP / 排程器。不匯入 ``PySide6``。

無頭 API
--------

.. code-block:: python

from je_auto_control import check_postcondition, compile_postcondition

spec = {"appears": {"role": "dialog", "name": "Saved"},
"disabled": {"name": "Submit"}}
report = check_postcondition(after_elements, spec, before=before_elements)
if not report.ok:
print("失敗子句:", report.failed)

# 把規格轉成判定函式以驅動 expect_poll
predicate = compile_postcondition({"text_present": "Saved"})

``check_postcondition`` 回傳 ``PostconditionReport``(``ok`` / ``clauses`` —
``[{type, ok, detail}]`` — / ``failed``)。``appears`` 只有在元素位於 ``after`` 且*不*在
``before``(確為新元素)時才成功;``disappears`` 需要 ``before`` 幀。``compile_postcondition``
回傳 ``after -> bool`` 判定函式,可與 ``expect_poll`` / ``assert_eventually`` 搭配。

執行器指令
----------

``AC_check_postcondition``(``after`` / ``spec`` / ``before`` → ``{ok, clauses, failed}``)
以 MCP 工具 ``ac_check_postcondition``(唯讀)及 Script Builder 指令 **Check Postcondition**
(位於 **Native UI** 分類下)形式提供。
1 change: 1 addition & 0 deletions docs/source/Zh/zh_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ AutoControl 所有功能的完整使用指南。
doc/new_features/v166_features_doc
doc/new_features/v167_features_doc
doc/new_features/v168_features_doc
doc/new_features/v169_features_doc
doc/ocr_backends/ocr_backends_doc
doc/observability/observability_doc
doc/operations_layer/operations_layer_doc
Expand Down
7 changes: 7 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@
from je_auto_control.utils.action_effect import (
EffectVerdict, classify_effect, effect_near_point, is_no_op,
)
# Declarative action postconditions (expected outcome vs before/after)
from je_auto_control.utils.postcondition import (
PostconditionReport, check_postcondition, compile_postcondition,
)
# Locate on-screen regions by colour (mask + connected components)
from je_auto_control.utils.color_region import (
find_color_region, find_color_regions,
Expand Down Expand Up @@ -1260,6 +1264,9 @@ def start_autocontrol_gui(*args, **kwargs):
"classify_effect",
"effect_near_point",
"is_no_op",
"PostconditionReport",
"check_postcondition",
"compile_postcondition",
"find_color_region",
"find_color_regions",
"ssim_compare",
Expand Down
13 changes: 13 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3192,6 +3192,19 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
),
description="Did any before/after change land within radius of a point?",
))
specs.append(CommandSpec(
"AC_check_postcondition", "Native UI", "Check Postcondition",
fields=(
FieldSpec("after", FieldType.STRING,
placeholder='[{"role":"dialog","name":"Saved"}]'),
FieldSpec("spec", FieldType.STRING,
placeholder='{"appears":{"role":"dialog"},'
'"disabled":{"name":"Submit"}}'),
FieldSpec("before", FieldType.STRING, optional=True,
placeholder='[{"role":"button","name":"Submit"}]'),
),
description="Check expected outcome clauses against after/before frames.",
))
specs.append(CommandSpec(
"AC_validate_action", "Native UI", "Validate / Snap Action",
fields=(
Expand Down
14 changes: 14 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4131,6 +4131,19 @@ def _effect_near_point(before: Any, after: Any, point: Any,
return {"near": effect_near_point(before, after, point, radius=int(radius))}


def _check_postcondition(after: Any, spec: Any, before: Any = None) -> Dict[str, Any]:
"""Adapter: evaluate a declarative postcondition spec against after/before frames."""
import json
from je_auto_control.utils.postcondition import check_postcondition
if isinstance(after, str):
after = json.loads(after)
if isinstance(spec, str):
spec = json.loads(spec)
if isinstance(before, str):
before = json.loads(before) if before.strip() else None
return check_postcondition(after, spec, before=before).to_dict()


def _validate_action(action: Any, screen: Any = None,
targets: Any = None) -> Dict[str, Any]:
"""Adapter: validate a coordinate action (bounds + optional snap-to-target)."""
Expand Down Expand Up @@ -6011,6 +6024,7 @@ def __init__(self):
"AC_delta_observation": _delta_observation,
"AC_classify_effect": _classify_effect,
"AC_effect_near_point": _effect_near_point,
"AC_check_postcondition": _check_postcondition,
"AC_validate_action": _validate_action,
"AC_replay_trace": _replay_trace,
"AC_match_elements": _match_elements,
Expand Down
16 changes: 16 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -3379,6 +3379,22 @@ def observation_tools() -> List[MCPTool]:
handler=h.effect_near_point,
annotations=READ_ONLY,
),
MCPTool(
name="ac_check_postcondition",
description=("Check a declarative postcondition 'spec' against the "
"'after' element list (optionally diffed vs 'before'). "
"Clauses: appears / disappears / enabled / disabled / "
"text_present / text_absent / count. Returns {ok, clauses:"
"[{type,ok,detail}], failed}. e.g. spec {\"appears\": "
"{\"role\":\"dialog\"}, \"disabled\": {\"name\":\"Submit\"}}."),
input_schema=schema({
"after": {"type": "array", "items": {"type": "object"}},
"spec": {"type": "object"},
"before": {"type": "array", "items": {"type": "object"}}},
required=["after", "spec"]),
handler=h.check_postcondition,
annotations=READ_ONLY,
),
]


Expand Down
5 changes: 5 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2433,6 +2433,11 @@ def effect_near_point(before, after, point, radius=64):
return _effect_near_point(before, after, point, radius)


def check_postcondition(after, spec, before=None):
from je_auto_control.utils.executor.action_executor import _check_postcondition
return _check_postcondition(after, spec, before)


def validate_action(action, screen=None, targets=None):
from je_auto_control.utils.executor.action_executor import _validate_action
return _validate_action(action, screen, targets)
Expand Down
6 changes: 6 additions & 0 deletions je_auto_control/utils/postcondition/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Declarative expected-outcome specs for an action, checked against the screen."""
from je_auto_control.utils.postcondition.postcondition import (
PostconditionReport, check_postcondition, compile_postcondition,
)

__all__ = ["PostconditionReport", "check_postcondition", "compile_postcondition"]
132 changes: 132 additions & 0 deletions je_auto_control/utils/postcondition/postcondition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Declarative expected-outcome specs for an action, checked against the screen.

After an action an agent (or a replay harness) usually has a concrete expectation: "a dialog
saying 'Saved' should appear AND the Submit button should disable". ``expect_poll`` /
``assert_eventually`` poll a *single condition* but have no notion of an action-bound
*postcondition spec*, and they don't diff against a *before* baseline (so they cannot express
"a NEW dialog appeared" — only "a dialog exists"). ``trajectory_eval`` rubrics are
whole-trajectory, not per-step screen state. ``postcondition`` fills the gap: a small JSON spec
of clauses — ``appears`` / ``disappears`` / ``enabled`` / ``disabled`` / ``text_present`` /
``text_absent`` / ``count`` — evaluated against the after-observation (optionally diffed against
the before-observation), returning a per-clause pass/fail report.

Pure-stdlib over element dicts; deterministic and unit-testable with no device. The spec is
plain JSON so it rides into action files / MCP / the scheduler. Imports no ``PySide6``.
"""
from dataclasses import asdict, dataclass
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple

Element = Dict[str, Any]
Report = Tuple[bool, str]


@dataclass(frozen=True)
class PostconditionReport:
"""The result of evaluating a postcondition spec: overall ok + per-clause detail."""

ok: bool
clauses: List[Dict[str, Any]]
failed: List[str]

def to_dict(self) -> Dict[str, Any]:
"""Return the report as a plain dict."""
return asdict(self)


def _matches(element: Element, criteria: Dict[str, Any]) -> bool:
"""Whether an element matches a ``{role?, name?, name_contains?}`` criteria dict."""
if "role" in criteria and element.get("role") != criteria["role"]:
return False
if "name" in criteria and element.get("name") != criteria["name"]:
return False
contains = criteria.get("name_contains")
return not contains or contains in str(element.get("name", ""))


def _appears(after: Sequence[Element], before: Optional[Sequence[Element]],
param: Dict[str, Any]) -> Report:
in_after = any(_matches(e, param) for e in after)
in_before = before is not None and any(_matches(e, param) for e in before)
ok = in_after and not in_before
return ok, "appeared" if ok else "not a new appearance"


def _disappears(after: Sequence[Element], before: Optional[Sequence[Element]],
param: Dict[str, Any]) -> Report:
if before is None:
return False, "needs a before frame"
ok = any(_matches(e, param) for e in before) and \
not any(_matches(e, param) for e in after)
return ok, "disappeared" if ok else "still present or never there"


def _enabled_state(after: Sequence[Element], param: Dict[str, Any],
want: bool) -> Report:
for element in after:
if _matches(element, param):
return bool(element.get("enabled", True)) == want, \
f"enabled={element.get('enabled', True)}"
return False, "element not found"


def _enabled(after, before, param):
return _enabled_state(after, param, True)


def _disabled(after, before, param):
return _enabled_state(after, param, False)


def _text(after: Sequence[Element], text: Any, want_present: bool) -> Report:
found = any(str(text) in str(e.get("name", "")) for e in after)
return found == want_present, "present" if found else "absent"


def _text_present(after, before, param):
return _text(after, param, True)


def _text_absent(after, before, param):
return _text(after, param, False)


def _count(after, before, param: Dict[str, Any]) -> Report:
number = sum(1 for e in after if _matches(e, param.get("match", {})))
if "equals" in param:
return number == int(param["equals"]), f"count={number}"
if "min" in param:
return number >= int(param["min"]), f"count={number}"
return False, "count clause needs 'equals' or 'min'"


_CLAUSES: Dict[str, Callable[[Sequence[Element], Optional[Sequence[Element]],
Dict[str, Any]], Report]] = {
"appears": _appears, "disappears": _disappears,
"enabled": _enabled, "disabled": _disabled,
"text_present": _text_present, "text_absent": _text_absent, "count": _count,
}


def check_postcondition(after: Sequence[Element], spec: Dict[str, Any], *,
before: Optional[Sequence[Element]] = None
) -> PostconditionReport:
"""Evaluate a postcondition ``spec`` against the after-frame (optional before-frame)."""
after_list = list(after)
before_list = list(before) if before is not None else None
clauses: List[Dict[str, Any]] = []
for key, param in spec.items():
checker = _CLAUSES.get(key)
if checker is None:
clauses.append({"type": key, "ok": False, "detail": "unknown clause"})
continue
ok, detail = checker(after_list, before_list, param)
clauses.append({"type": key, "ok": ok, "detail": detail})
failed = [clause["type"] for clause in clauses if not clause["ok"]]
return PostconditionReport(ok=not failed, clauses=clauses, failed=failed)


def compile_postcondition(spec: Dict[str, Any]) -> Callable[[Sequence[Element]], bool]:
"""Return a predicate ``after -> bool`` for the spec (for use with ``expect_poll``)."""
def predicate(after: Sequence[Element]) -> bool:
return check_postcondition(after, spec).ok
return predicate
Loading
Loading