Spaces:
Running
Running
File size: 5,949 Bytes
22de727 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | #!/usr/bin/env python3
"""The annotation reconstruction pipeline — one implementation, three triggers.
For each task it does, in order:
1. repair form state — the old recorder serialized outerHTML, which stores
ATTRIBUTES, not live DOM properties, so typed text and
the chosen <option> were missing from the snapshot.
Recover them from the action record and inject them.
2. reconstruct — derive the missing observations from the (repaired)
HTML: YAML axtree + FULL-PAGE screenshot, with a
<select>'s option list drawn open when the action was
a select (a native popup is OS-drawn and appears in no
page screenshot — real or derived).
Everything is flagged so provenance stays honest:
form_state_repaired · screenshot_full_page · dropdown_synthesized ·
backfill_method (stored_html | live_url)
Triggers:
save time src/miniweb/annotation/observations.py calls process_task() in a thread
catch-up python src/miniweb/annotation/process_annotations.py (all incomplete)
watch python src/miniweb/annotation/process_annotations.py --watch 60 (poll for new)
Idempotent: a task whose observations are already complete is skipped, so it is
safe to run on a cron, after a data pull, or repeatedly.
"""
from helpers.paths import REPO_ROOT
import argparse
import json
import socket
import sys
import threading
import time
from pathlib import Path
ROOT = REPO_ROOT
sys.path.insert(0, str(ROOT / "src"))
from miniweb.annotation.backfill_observations import ( # noqa: E402
Capturer, _pair_events, _is_yaml_axtree, _free_port, _start_app,
ANNOTATIONS_DIR)
from miniweb.annotation.repair_form_state import repair_task # noqa: E402
def is_complete(task_dir: Path) -> bool:
"""True when every action already has html + axtree + a screenshot on disk."""
tf = task_dir / "trajectory.json"
if not tf.exists():
return True # nothing to do
try:
events = json.loads(tf.read_text())
except json.JSONDecodeError:
return True
for _n, _action, obs, _pos in _pair_events(events):
if obs is None:
return False
if not obs.get("snapshot") and not obs.get("url"):
return False
if not _is_yaml_axtree(obs.get("axtree") or ""):
return False
shot = obs.get("screenshot")
if not shot or not (task_dir / shot).exists():
return False
if not obs.get("screenshot_full_page") and not obs.get("screenshot_captured"):
return False # rendered before full-page capture (real captured
# frames are viewport-sized and complete as-is)
return True
def process_task(task_dir: Path, base_url: str, cap: Capturer = None) -> dict:
"""Repair + reconstruct one task. Creates its own browser if `cap` is None."""
task_dir = Path(task_dir)
tf = task_dir / "trajectory.json"
if not tf.exists():
return {"task": task_dir.name, "error": "no trajectory.json"}
# 1. repair form state (pure text transform, no browser needed)
repaired, unmatched = repair_task(str(tf))
# 2. reconstruct the missing/stale observations
own_browser = cap is None
if own_browser:
cap = Capturer(base_url)
try:
from miniweb.annotation.backfill_observations import process_task as _reconstruct
stats = _reconstruct(task_dir, cap)
finally:
if own_browser:
cap.close()
stats["form_state_repaired"] = repaired
stats["form_state_unmatched"] = unmatched
return stats
def find_incomplete(annotator: str = "") -> list:
pattern = f"{annotator}/*" if annotator else "*/*"
return [d for d in sorted(ANNOTATIONS_DIR.glob(pattern))
if d.is_dir() and (d / "trajectory.json").exists() and not is_complete(d)]
def run_once(base_url: str, annotator: str = "") -> int:
todo = find_incomplete(annotator)
if not todo:
print("all tasks already complete")
return 0
print(f"{len(todo)} task(s) to process")
cap = Capturer(base_url)
try:
for d in todo:
stats = process_task(d, base_url, cap=cap)
print(f" {d.name}: repaired={stats.get('form_state_repaired', 0)} "
f"rendered={stats.get('rendered', 0)} "
f"new_obs={stats.get('created_obs', 0)}"
+ (" !" if stats.get("failures") else ""))
finally:
cap.close()
return len(todo)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--annotator", default="", help="limit to one annotator")
ap.add_argument("--watch", type=int, default=0, metavar="SECONDS",
help="keep polling for newly saved tasks every N seconds")
ap.add_argument("--base-url", default="",
help="app to render against (default: start one on a free port)")
args = ap.parse_args()
base_url = args.base_url
if not base_url:
port = _free_port()
print(f"starting app on :{port} ...")
_start_app(port)
base_url = f"http://127.0.0.1:{port}"
if not args.watch:
run_once(base_url, args.annotator)
return
print(f"watching {ANNOTATIONS_DIR} every {args.watch}s (ctrl-c to stop)")
while True:
try:
n = run_once(base_url, args.annotator)
if n:
print(f" ({time.strftime('%H:%M:%S')}) processed {n}")
except Exception: # noqa: BLE001 — a bad task must not kill the watcher
import traceback
traceback.print_exc()
time.sleep(args.watch)
if __name__ == "__main__":
main()
|