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
42 changes: 32 additions & 10 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,23 @@ def find_template(screen_png: bytes, template_png: bytes, *,
# ocr.py (rapidocr_onnxruntime; instantiate the engine once, module-level lazy)
class OcrLine(BaseModel):
text: str; region: Region; confidence: float
class OcrResolutionRefused(RuntimeError): ...
class AmbiguousOcrMatchError(OcrResolutionRefused): ...
class ContradictoryOcrEvidenceError(OcrResolutionRefused): ...
def ocr(screen_png: bytes, *, region: Region | None = None) -> list[OcrLine]
def find_text(screen_png: bytes, text: str, *,
region: Region | None = None, min_ratio: float = 0.8) -> Match | None
# normalized fuzzy match (difflib ratio on lowercased/stripped text)
region: Region | None = None, min_ratio: float = 0.8,
raise_on_ambiguity: bool = False) -> Match | None
def find_text_candidates(screen_png: bytes, text: str, *,
region: Region | None = None,
min_ratio: float = 0.8) -> list[Match]
# normalized fuzzy match (difflib ratio on lowercased/stripped text);
# resolution enables the typed ambiguity signal so duplicates halt instead
# of falling through, while presence/readiness callers retain best-match.
# Candidate enumeration never selects by order: the resolver requires a
# unique recorded-region or landmark-relation choice for repeated labels.
# Contradictory independent choices and unresolved repeats halt rather than
# averaging coordinates or delegating to a weaker model-backed rung.

# hashing.py
def phash_png(png: bytes, region: Region | None = None) -> str
Expand Down Expand Up @@ -202,15 +215,24 @@ implemented rungs are:
irreversible risk gate still fire on it — structure makes identity STRONGER
(an exact element), it never bypasses it. Rungs 1–5 remain the visual
FALLBACK floor and are unchanged:
1. `template` — find_template within anchor.region padded by search_pad
2. `template_global` — find_template full frame; for UNLABELED anchors
(no ocr_text) the match is rejected when every locatable landmark places
the target more than 40px away (repeated-icon UIs: an identical glyph on
another card can outscore the true target when mutable content near the
target changed) — the ladder then continues to ocr/geometry
3. `ocr` — find_text(anchor.ocr_text) full frame
1. `template` — find_template within anchor.region padded by search_pad, with
recorded-position locality; repeated-widget frames are refused when no
candidate is near the recorded position
2. `template_global` — find_template full frame; for labeled and unlabeled
anchors alike, a match is rejected when locatable landmarks contradict its
position, and repeated look-alikes with no expected-position candidate halt
3. `ocr` — enumerate qualifying `anchor.ocr_text` candidates in the padded
local region; only a true miss searches globally. A sole label is accepted
even when legitimate layout reflow makes old fixed landmark offsets stale.
Repeated labels require exactly one candidate established by the recorded
target region or retained landmark relations. Unresolved repeats or
independent evidence selecting different observed candidates halt instead
of falling through to weaker rungs
4. `geometry` — landmarks: locate landmark text, offset by the exact
`dx_px`/`dy_px` offsets when recorded, else by relation/distance
`dx_px`/`dy_px` offsets when recorded, else by relation/distance. Coherent
estimates may be averaged; incompatible estimates never are. A unique
recorded-region estimate or unique largest coherent cluster may resolve;
otherwise disagreement is a typed halt
5. `grounder` — optional injected `Grounder.locate(png, intent) -> Match|None`
(protocol in `runtime/grounder.py`; ship a `NullGrounder`; an Anthropic
implementation goes behind the `grounder` extra and is NOT used in tests)
Expand Down
2 changes: 2 additions & 0 deletions benchmark/openemr_e2e/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,9 @@ def find_text(
*,
region: Any = None,
min_ratio: float = 0.8,
raise_on_ambiguity: bool = False,
) -> Optional[_Match]:
del raise_on_ambiguity
if text == "Saved":
# The app always paints "Saved" after the commit key -- the whole
# point of effect verification is that this can be true while the
Expand Down
15 changes: 14 additions & 1 deletion openadapt_flow/runtime/replayer.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
reconcile_or_escalate,
)
from openadapt_flow.runtime.resolver import is_below_ocr, pad_region, resolve
from openadapt_flow.vision.ocr import OcrResolutionRefused

# REGION_STABLE template check: how far the expected content may shift from
# the recorded region (real apps re-layout by a few pixels between runs),
Expand Down Expand Up @@ -243,7 +244,9 @@ class Replayer:
``phash_distance``, and ``wait_settled``. Defaults to the
real ``openadapt_flow.vision``
module, imported lazily so unit tests can inject a fake without
the OCR stack ever loading.
the OCR stack ever loading. Target-resolution providers implement
``find_text(..., raise_on_ambiguity=True)`` so repeated candidates
produce a typed refusal instead of a first-match action.
grounder: Optional Grounder used as the last resolution rung.
identity_vlm: Optional IdentityVLM (see runtime.identity) used as the
optional local-VLM veto tier of the pre-click identity ladder;
Expand Down Expand Up @@ -2718,6 +2721,16 @@ def _run_step(
result.ok = False
error = heal_outcome.halt_reason
result.error = error
except OcrResolutionRefused as exc:
# Repeated target/landmark OCR is a deterministic ambiguity, not a
# transient absence. Surface it as a typed operator-visible safety
# halt immediately; never retry into weaker evidence or actuation.
result.ok = False
result.safety_halt = True
result.error = (
f"OCR safety refusal for step '{step.id}' "
f"({step.intent}): {exc} — no action was admitted"
)
except StructuralResolutionRefused as exc:
# Ambiguity, disappearance, or a stale resolve->act fingerprint is
# an intentional fail-closed refusal. Record it as a safety halt,
Expand Down
Loading