Skip to content

Commit 67ebec2

Browse files
authored
fix(compiler): retain exact opaque field labels
Retain one unique row-aligned exact label for opaque select fields and halt when post-focus resolution moves.
1 parent 3481b42 commit 67ebec2

10 files changed

Lines changed: 572 additions & 25 deletions

File tree

openadapt_flow/compiler/compile.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import logging
1515
import math
1616
import re
17+
from collections import Counter
1718
from datetime import date, datetime
1819
from pathlib import Path
1920
from typing import TYPE_CHECKING, Iterable, Literal, Optional, Sequence, cast
@@ -983,6 +984,83 @@ def _field_label_from_ocr(
983984
return best[2] if best is not None else None
984985

985986

987+
def _exact_left_field_label_landmark(
988+
lines: list[OcrLine],
989+
target_region: Region,
990+
click: Point,
991+
*,
992+
exclude_texts: tuple[str, ...] = (),
993+
reference_date: Optional[date] = None,
994+
) -> Optional[tuple[str, Landmark]]:
995+
"""Return one unique, row-aligned label left of an opaque field.
996+
997+
An open native select can repeat its current option in the closed field,
998+
popup, and suggestion list. The option text is then not target identity.
999+
Pixel-only recordings need an independent relation that survives the open
1000+
menu. This function mines only a label whose normalized OCR text occurs
1001+
exactly once in the recorded frame, whose box is left of and vertically
1002+
aligned with the qualified field region, and whose text is stable and
1003+
passes the configured exclusions, volatility classification, and identifier
1004+
heuristic. It records an exact normalized OCR match and the exact
1005+
label-center-to-click offset. There is no fuzzy fallback in this contract.
1006+
"""
1007+
1008+
rx, ry, rw, rh = (int(value) for value in target_region)
1009+
if rw <= 0 or rh <= 0:
1010+
return None
1011+
recognized = [line for line in lines if normalize_text(line.text)]
1012+
counts = Counter(normalize_text(line.text) for line in recognized)
1013+
candidates: list[tuple[float, float, str, Landmark]] = []
1014+
for line in recognized:
1015+
if line.confidence < MIN_OCR_CONFIDENCE:
1016+
continue
1017+
text = " ".join(line.text.split())
1018+
normalized = normalize_text(text)
1019+
if counts[normalized] != 1 or len(text) > LABEL_OCR_MAX_CHARS:
1020+
continue
1021+
if _contains_excluded(text, exclude_texts):
1022+
continue
1023+
if volatility.classify_text(text, reference_date=reference_date):
1024+
continue
1025+
if _text_carries_phi(text):
1026+
continue
1027+
lx, ly, lw, lh = (int(value) for value in line.region)
1028+
if lw <= 0 or lh <= 0 or lx + lw > rx:
1029+
continue
1030+
if not ly <= click[1] <= ly + lh:
1031+
continue
1032+
gap = float(rx - (lx + lw))
1033+
if gap > LABEL_OCR_MAX_LEFT_GAP_PX:
1034+
continue
1035+
center = (lx + lw // 2, ly + lh // 2)
1036+
dx = int(click[0] - center[0])
1037+
dy = int(click[1] - center[1])
1038+
if dx <= 0:
1039+
continue
1040+
candidates.append(
1041+
(
1042+
gap,
1043+
abs(float(dy)),
1044+
text,
1045+
Landmark(
1046+
relation="left_of",
1047+
ocr_text=text,
1048+
distance_px=int(round(math.hypot(dx, dy))),
1049+
match_mode="exact",
1050+
dx_px=dx,
1051+
dy_px=dy,
1052+
),
1053+
)
1054+
)
1055+
if not candidates:
1056+
return None
1057+
_gap, _vertical_delta, text, landmark = min(
1058+
candidates,
1059+
key=lambda candidate: (candidate[1], candidate[0], candidate[2].casefold()),
1060+
)
1061+
return text, landmark
1062+
1063+
9861064
def _identity_unarmed_reason(
9871065
frame_lines: list[OcrLine],
9881066
*,
@@ -1944,6 +2022,35 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]:
19442022
key_after, demonstrated, selection_region
19452023
)
19462024
):
2025+
selected_anchor = previous_anchor.model_copy(deep=True)
2026+
selected_field_label = step.field_label
2027+
exact_label = _exact_left_field_label_landmark(
2028+
cached_lines(
2029+
int(type_event["i"]),
2030+
"before",
2031+
type_before,
2032+
),
2033+
selected_anchor.region,
2034+
selected_anchor.click_point,
2035+
exclude_texts=exclude_texts,
2036+
reference_date=reference_date,
2037+
)
2038+
if exact_label is not None:
2039+
selected_field_label, label_landmark = exact_label
2040+
retained_landmarks = [
2041+
landmark
2042+
for landmark in selected_anchor.landmarks
2043+
if normalize_text(landmark.ocr_text)
2044+
!= normalize_text(label_landmark.ocr_text)
2045+
]
2046+
selected_anchor = selected_anchor.model_copy(
2047+
update={
2048+
"landmarks": [
2049+
label_landmark,
2050+
*retained_landmarks,
2051+
]
2052+
}
2053+
)
19472054
merged_event = dict(type_event)
19482055
for name, value in key_event.items():
19492056
if name.endswith("_after"):
@@ -1958,7 +2065,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]:
19582065
),
19592066
"selection_commit_key": commit,
19602067
"selection_region": selection_region,
1961-
"anchor": previous_anchor.model_copy(deep=True),
2068+
"anchor": selected_anchor,
2069+
"field_label": selected_field_label,
19622070
"identity_armed": previous.identity_armed,
19632071
"identity_unarmed_reason": (
19642072
previous.identity_unarmed_reason

openadapt_flow/ir.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ class Landmark(BaseModel):
102102
relation: Literal["left_of", "right_of", "above", "below"]
103103
ocr_text: str
104104
distance_px: int
105+
match_mode: Literal["fuzzy", "exact"] = Field(
106+
default="fuzzy",
107+
description=(
108+
"OCR comparison mode for this retained relation. Compiler-mined "
109+
"generic context remains fuzzy; a qualified opaque-field label "
110+
"uses exact normalized text so a near label cannot authorize input."
111+
),
112+
)
105113
dx_px: Optional[int] = Field(
106114
default=None,
107115
description="Exact x offset landmark center -> target click point",
@@ -111,6 +119,20 @@ class Landmark(BaseModel):
111119
description="Exact y offset landmark center -> target click point",
112120
)
113121

122+
@model_serializer(mode="wrap")
123+
def _serialize_compatible(self, handler: Any) -> dict[str, Any]:
124+
"""Keep legacy fuzzy landmarks byte-semantically unchanged.
125+
126+
The package supports Pydantic 2.5, before ``Field(exclude_if=...)``.
127+
This v2-compatible serializer omits the additive default while keeping
128+
an explicit exact-label contract inside new bundle digests.
129+
"""
130+
131+
data: dict[str, Any] = handler(self)
132+
if self.match_mode == "fuzzy":
133+
data.pop("match_mode", None)
134+
return data
135+
114136

115137
class StructuralLocator(BaseModel):
116138
"""A stable structural (DOM / accessibility) locator for a step's target.

openadapt_flow/runtime/replayer.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5213,6 +5213,7 @@ def _act(
52135213
# re-run target/identity before typing; the focusing click
52145214
# consumed the first one-shot remote lease.
52155215
if self._step_needs_consequential_revalidation(step, workflow):
5216+
focused_field_point = field_point
52165217
(
52175218
refreshed,
52185219
refreshed_region,
@@ -5232,6 +5233,24 @@ def _act(
52325233
if remote_error is not None:
52335234
return remote_error
52345235
if refreshed is not None:
5236+
if (
5237+
step.action is ActionKind.SELECT_OPTION
5238+
and focused_field_point is not None
5239+
and step.anchor is not None
5240+
and not self._selection_target_continuous(
5241+
step.anchor.region,
5242+
focused_field_point,
5243+
refreshed.point,
5244+
)
5245+
):
5246+
self._cancel_guarded_keyboard()
5247+
result.safety_halt = True
5248+
result.failure_category = "safety_halt"
5249+
return (
5250+
f"Step '{step.id}' ({step.intent}) re-resolved "
5251+
"to a different field after focus; refusing "
5252+
"option selection"
5253+
)
52355254
resolution = refreshed
52365255
result.resolution = refreshed
52375256
field_point = refreshed.point
@@ -7190,6 +7209,26 @@ def _selection_region_compatible(
71907209
and 0.75 <= live_h / qualified_h <= 1.25
71917210
)
71927211

7212+
@staticmethod
7213+
def _selection_target_continuous(
7214+
qualified: Region,
7215+
focused: Point,
7216+
refreshed: Point,
7217+
) -> bool:
7218+
"""Require both select phases to name the same focused field.
7219+
7220+
Reuse the selection contract's 25% size tolerance for point drift.
7221+
This permits small OCR/template jitter but refuses a second control.
7222+
"""
7223+
7224+
_, _, qualified_w, qualified_h = qualified
7225+
if qualified_w <= 0 or qualified_h <= 0:
7226+
return False
7227+
return (
7228+
abs(refreshed[0] - focused[0]) <= qualified_w * 0.25
7229+
and abs(refreshed[1] - focused[1]) <= qualified_h * 0.25
7230+
)
7231+
71937232
@staticmethod
71947233
def _mapped_selection_readback_region(
71957234
recorded_target: Region,

openadapt_flow/runtime/resolver.py

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
from typing import Any, Optional
4343

4444
from openadapt_flow.backend import StructuralResolutionRefused
45-
from openadapt_flow.ir import Anchor, Point, Region, Resolution, Rung
45+
from openadapt_flow.ir import Anchor, Landmark, Point, Region, Resolution, Rung
46+
from openadapt_flow.vision.match import Match
4647
from openadapt_flow.vision.ocr import (
4748
AmbiguousOcrMatchError,
4849
ContradictoryOcrEvidenceError,
@@ -75,6 +76,7 @@
7576
# (they fall through to the geometry rung) while true labels, which OCR
7677
# reads near-verbatim, still match at ≈ 1.0.
7778
OCR_MIN_RATIO = 0.9
79+
EXACT_LANDMARK_MIN_OCR_CONFIDENCE = 0.5
7880

7981
# The global template rung must not accept a match that contradicts the
8082
# anchor's landmarks by more than this many pixels. Repeated-widget UIs (an
@@ -87,6 +89,28 @@
8789
GLOBAL_LANDMARK_TOLERANCE_PX = 40
8890

8991

92+
def _landmark_min_ratio(landmark: Landmark) -> float:
93+
"""Use exact normalized OCR for compiler-qualified field labels."""
94+
95+
return 1.0 if landmark.match_mode == "exact" else OCR_MIN_RATIO
96+
97+
98+
def _find_landmark_text(
99+
vision: Any,
100+
screen_png: bytes,
101+
landmark: Landmark,
102+
) -> Optional[Match]:
103+
"""Locate a landmark with the compiler's exact-label confidence floor."""
104+
105+
kwargs: dict[str, Any] = {
106+
"min_ratio": _landmark_min_ratio(landmark),
107+
"raise_on_ambiguity": True,
108+
}
109+
if landmark.match_mode == "exact":
110+
kwargs["min_ocr_confidence"] = EXACT_LANDMARK_MIN_OCR_CONFIDENCE
111+
return vision.find_text(screen_png, landmark.ocr_text, **kwargs)
112+
113+
90114
def is_below_ocr(rung: Rung) -> bool:
91115
"""Return True if ``rung`` is weaker evidence than the ``ocr`` rung.
92116
@@ -226,12 +250,7 @@ def _landmarks_contradict(
226250
if landmark.dx_px is None or landmark.dy_px is None:
227251
continue
228252
try:
229-
match = vision.find_text(
230-
screen_png,
231-
landmark.ocr_text,
232-
min_ratio=OCR_MIN_RATIO,
233-
raise_on_ambiguity=True,
234-
)
253+
match = _find_landmark_text(vision, screen_png, landmark)
235254
except AmbiguousOcrMatchError:
236255
# A repeated/generic landmark cannot corroborate or contradict a
237256
# candidate. It abstains while any independent unique landmark
@@ -311,12 +330,7 @@ def _select_ocr_candidate(
311330
if landmark.dx_px is None or landmark.dy_px is None:
312331
continue
313332
try:
314-
match = vision.find_text(
315-
screen_png,
316-
landmark.ocr_text,
317-
min_ratio=OCR_MIN_RATIO,
318-
raise_on_ambiguity=True,
319-
)
333+
match = _find_landmark_text(vision, screen_png, landmark)
320334
except AmbiguousOcrMatchError:
321335
# A repeated context label supplies no unique relation.
322336
continue
@@ -652,12 +666,7 @@ def elapsed_ms() -> float:
652666
ambiguous_landmark = False
653667
for landmark in anchor.landmarks:
654668
try:
655-
lm_match = vision.find_text(
656-
screen_png,
657-
landmark.ocr_text,
658-
min_ratio=OCR_MIN_RATIO,
659-
raise_on_ambiguity=True,
660-
)
669+
lm_match = _find_landmark_text(vision, screen_png, landmark)
661670
except AmbiguousOcrMatchError:
662671
# An ambiguous landmark contributes no coordinate. Other unique
663672
# landmarks remain independently usable under the existing

openadapt_flow/vision/ocr.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ def find_text(
224224
*,
225225
region: Region | None = None,
226226
min_ratio: float = 0.8,
227+
min_ocr_confidence: float = 0.0,
227228
raise_on_ambiguity: bool = False,
228229
) -> Match | None:
229230
"""Locate a text label on screen via OCR plus fuzzy matching.
@@ -240,6 +241,9 @@ def find_text(
240241
text: Target text to find.
241242
region: Optional ``(x, y, w, h)`` sub-region to search within.
242243
min_ratio: Minimum similarity ratio in ``[0, 1]`` to accept.
244+
min_ocr_confidence: Minimum OCR-engine confidence to accept. The
245+
compatibility default keeps historical generic lookup behavior;
246+
compiler-qualified exact labels opt into a stricter floor.
243247
raise_on_ambiguity: Raise :class:`AmbiguousOcrMatchError` instead of
244248
selecting the best line when multiple lines qualify. Target
245249
resolution enables this so ambiguity cannot be mistaken for a miss
@@ -249,17 +253,25 @@ def find_text(
249253
A :class:`Match` centered on the best qualifying line's bounding box,
250254
or ``None`` if no line is similar enough.
251255
"""
256+
exact_confidence_contract = (
257+
raise_on_ambiguity and min_ratio == 1.0 and min_ocr_confidence > 0.0
258+
)
252259
qualifying = _qualifying_text_lines(
253260
screen_png,
254261
text,
255262
region=region,
256263
min_ratio=min_ratio,
264+
min_ocr_confidence=(0.0 if exact_confidence_contract else min_ocr_confidence),
257265
)
258266
if len(qualifying) > 1:
259267
if raise_on_ambiguity:
260268
raise AmbiguousOcrMatchError(
261269
f"{len(qualifying)} OCR lines qualify for target text"
262270
)
271+
if exact_confidence_contract:
272+
qualifying = [
273+
item for item in qualifying if item[1].confidence >= min_ocr_confidence
274+
]
263275
if not qualifying:
264276
return None
265277
ratio, line = max(qualifying, key=lambda item: item[0])
@@ -273,6 +285,7 @@ def find_text_candidates(
273285
*,
274286
region: Region | None = None,
275287
min_ratio: float = 0.8,
288+
min_ocr_confidence: float = 0.0,
276289
) -> list[Match]:
277290
"""Return every OCR line that qualifies for ``text``.
278291
@@ -291,6 +304,7 @@ def find_text_candidates(
291304
text,
292305
region=region,
293306
min_ratio=min_ratio,
307+
min_ocr_confidence=min_ocr_confidence,
294308
):
295309
x, y, w, h = line.region
296310
matches.append(
@@ -309,13 +323,16 @@ def _qualifying_text_lines(
309323
*,
310324
region: Region | None,
311325
min_ratio: float,
326+
min_ocr_confidence: float,
312327
) -> list[tuple[float, OcrLine]]:
313328
"""Return qualifying ``(similarity, line)`` pairs without selecting one."""
314329
target = normalize_text(text)
315330
if not target:
316331
return []
317332
qualifying: list[tuple[float, OcrLine]] = []
318333
for line in ocr(screen_png, region=region):
334+
if line.confidence < min_ocr_confidence:
335+
continue
319336
ratio = difflib.SequenceMatcher(None, normalize_text(line.text), target).ratio()
320337
if ratio >= min_ratio:
321338
qualifying.append((ratio, line))

0 commit comments

Comments
 (0)