File size: 14,092 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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
"""Free augmentations of the replay (no API calls; the memory model is the local base model).

The MiniWeb validation failures of cycle 3 (2026-09-29) had two causes the plain replay cannot teach:
every kept trajectory is a clean success (the student declared success although its own memory said the
step had failed), and 97% of the episodes are one macro long (it stopped after the first macro of a
WebArena task). Two variants of kept trajectories address them, graded by the same gates as any replay:

recovery  one kept trajectory with one mistake injected right before a correctable op of its macro, then
          the correct op:
            input (a text field)  -> a near-miss of the right text first, then the right text (cleared)
            select                -> another option of the same dropdown first
            slider (a click on an input[type=range]) -> a click a fifth of the track away first
          The mistake's row is kept out of training (row["inject"]), and so are the rows before it (a copy
          of the plain replay, row["aug_prefix"]); the next step's memory is asked to notice the mistake
          on the screen (replay.RECOVERY_HINT).
chain     two kept trajectories of one site as one episode, both macros, the instruction merged by the memory
          model (values checked, else joined), graded by both parts' gates. Two ways to reach the second
          one's start page: the same page (the first stays on it: on-page filters, sorts, reactions), or the
          site's home page (the second starts there; the student clicks the site's own home link between).
"""
from __future__ import annotations

import hashlib
import json
import random
import re
from collections import defaultdict
from urllib.parse import urlsplit

from miniwebgen import replay as R

# ── recovery ──────────────────────────────────────────────────────────────────

NO_NEAR_MISS = {"date", "time", "datetime-local", "month", "week", "color", "range", "password"}


def near_miss(text, seed):
    """A plausible slip on `text`: another last digit of a number, two swapped letters of its longest word,
    else its last character dropped. None when no slip changes it."""
    rng = random.Random(seed)
    t = text or ""
    digits = [i for i, ch in enumerate(t) if ch.isdigit()]
    if re.fullmatch(r"\s*[-+]?[\d.,]+\s*", t) and digits:
        i = digits[-1]
        return t[:i] + str((int(t[i]) + rng.randint(1, 8)) % 10) + t[i + 1:]
    words = sorted(re.finditer(r"[A-Za-z]{4,}", t), key=lambda m: -len(m.group(0)))
    for m in words[:1]:
        w = m.group(0)
        ks = [k for k in range(1, len(w) - 2) if w[k] != w[k + 1]]
        if ks:
            k = rng.choice(ks)
            return t[:m.start()] + w[:k] + w[k + 1] + w[k] + w[k + 2:] + t[m.end():]
    return t[:-1] if len(t) > 3 else None


def _eligible(op, task):
    el = op.get("el") or {}
    ty = (el.get("type") or "").lower()
    if op["op"] == "input":
        return (R._is_text_field(el) and ty not in NO_NEAR_MISS and len(op.get("text") or "") >= 3
                and task["control"]["kind"] != "reveal")
    if op["op"] == "select":
        return bool(R._css(el))
    if op["op"] == "click":
        return (el.get("tag") or "").lower() == "input" and ty == "range" and bool(el.get("box")) and bool(op.get("point"))
    return False


def inject_mistake(plan, task, seed):
    """-> a copy of `plan` with one mistake before an eligible op of the macro's own (last) segment, the op
    itself marked as the recovery; raises R.Unsupported when the plan has no such op."""
    last = max((op["segment"] for op in plan), default=0)
    cands = [i for i, op in enumerate(plan) if op["segment"] == last and _eligible(op, task)]
    if not cands:
        raise R.Unsupported("no correctable op to inject a mistake before")
    i = cands[int(hashlib.sha1(str(seed).encode()).hexdigest(), 16) % len(cands)]
    right = dict(plan[i], recover=True)
    if right["op"] == "input":
        wrong_text = near_miss(right["text"], seed)
        if not wrong_text or wrong_text == right["text"]:
            raise R.Unsupported("no near miss for the typed text")
        wrong = dict(right, text=wrong_text, inject=True, recover=False,
                     what=f'it typed "{wrong_text}" instead of "{right["text"]}"')
        right["select_all"] = True                 # the field now holds the wrong text: replace it
    elif right["op"] == "select":
        wrong = dict(right, text="", right=right["text"], inject=True, recover=False)   # chosen live (Replayer)
    else:                                          # a slider click, a fifth of the track to the other side
        x0, y0, w = right["el"]["box"][0], right["point"][1], right["el"]["box"][2]
        fx = (right["point"][0] - x0) / (w or 1)
        fx2 = fx - 0.2 if fx > 0.5 else fx + 0.2
        wrong = dict(right, point=[x0 + fx2 * w, y0], inject=True, recover=False,
                     what="it clicked the slider's track at the wrong spot, so the slider shows a different value "
                          "than the task asks for")
    out = [dict(op, aug_prefix=True) for op in plan[:i]] + [wrong, right] + [dict(op) for op in plan[i + 1:]]
    return out


# ── chains ────────────────────────────────────────────────────────────────────

# macros whose trajectory leaves the browser on the page it started from (a GET reload of the same path or
# an in-page request), so a second trajectory recorded from that page can follow
CHAIN_FIRST = {"filter_by_dropdown", "filter_by_options", "filter_by_slider", "filter_by_date_range", "sort_by_form",
               "feedback_by_react", "toggle_relationship", "feedback_by_star"}
CHAIN_SECOND = CHAIN_FIRST | {"search", "export", "copy_content", "share_by_form"}

MERGE_ASK = ("Rewrite these requests to a web assistant as ONE natural request that asks for the first and then the "
             "second, in that order. Keep every name, label, number and value exactly as written; add nothing.\n"
             "1. {a}\n2. {b}\nReply with the merged request only.")


def _path(url):
    return urlsplit(url or "").path.rstrip("/") or "/"


def _stays(traj, start):
    """Every recorded step happened on the start page (no navigation, no answer)."""
    if traj.get("history") or traj.get("history_macro"):
        return False
    for s in traj.get("steps") or []:
        a = s["action"]
        if a["type"] in ("goto", "new_tab", "switch_tab", "answer") or _path(a.get("url")) != start:
            return False
    return True


def chain_candidates(items, per_page=3, limit=None, seed=0):
    """items: [(task_id, task_dir)] of kept trajectories (their plain replays passed) -> [(dir_a, dir_b)].
    Pairs start on the same page of one site, act on different controls, and the first stays on the page."""
    groups = defaultdict(list)
    for tid, d in items:
        try:
            traj = json.loads((d / "trajectory.json").read_text())
        except OSError:
            continue
        if traj.get("start_kind") != "fresh" or traj.get("history_macro"):
            continue
        task = R.load_task(d.parent.parent.name, tid)
        if task["start"].get("keep") or task["check"].get("kind") == "answer":
            continue
        start = _path(task["start"]["url"])
        groups[(traj["site"], start)].append({"dir": d, "traj": traj, "task": task, "start": start})
    rng, out = random.Random(seed), []
    for key in sorted(groups):
        g = groups[key]
        pairs = [(a, b) for a in g for b in g
                 if a is not b and a["traj"]["macro"] in CHAIN_FIRST and b["traj"]["macro"] in CHAIN_SECOND
                 and a["task"]["control"].get("control_id") != b["task"]["control"].get("control_id")
                 and not _scope(a["task"]) & _scope(b["task"]) and _stays(a["traj"], a["start"])]
        rng.shuffle(pairs)
        # prefer pairs of two different macros, then distinct first trajectories
        pairs.sort(key=lambda p: p[0]["traj"]["macro"] == p[1]["traj"]["macro"])
        used, n = set(), 0
        for a, b in pairs:
            if n >= per_page or a["traj"]["id"] in used or b["traj"]["id"] in used:
                continue
            used |= {a["traj"]["id"], b["traj"]["id"]}
            out.append((a["dir"], b["dir"]))
            n += 1
    rng.shuffle(out)
    return out[:limit] if limit else out


# a second part that starts on the home page must not need a special session (datagen prepare flags / cart)
_FLAGGED = {"authenticate_by_form", "pay_by_form", "checkout_by_form", "book_by_form"}


def _scope(task):
    """(site, collection) pairs a part's state check watches: two parts changing one table fail each other's
    'no other changes' rule (2026-09-30: two votes on one forum page)."""
    c = task.get("check") or {}
    if c.get("kind") != "state":
        return set()
    return {(c.get("site"), x) for x in (c.get("scope") or [e.get("collection") for e in c.get("expect") or []])}


def _answers(traj):
    return any(s["action"]["type"] == "answer" for s in (traj.get("steps") or []) + (traj.get("history") or []))


def home_chain_candidates(items, per_second=3, limit=None, seed=0):
    """items as for chain_candidates -> [(dir_a, dir_b)]: b starts fresh on its site's home page, a is any
    other trajectory of that site that does not answer a question (it may end anywhere on the site)."""
    firsts, seconds = defaultdict(list), []
    for tid, d in items:
        try:
            traj = json.loads((d / "trajectory.json").read_text())
        except OSError:
            continue
        task = R.load_task(d.parent.parent.name, tid)
        if _answers(traj) or task["check"].get("kind") == "answer" or task["start"]["kind"] == "mid_chain":
            continue
        rec = {"dir": d, "traj": traj, "task": task}
        firsts[traj["site"]].append(rec)
        ctrl = task["control"]
        if (task["start"]["kind"] == "fresh" and _path(task["start"]["url"]) == f"/sites/{traj['site']}"
                and not task["start"].get("keep") and ctrl.get("role") not in _FLAGGED and not (ctrl.get("prep") or {})):
            seconds.append(rec)
    rng, out = random.Random(seed), []
    for b in sorted(seconds, key=lambda r: r["traj"]["id"]):
        pool = [a for a in firsts[b["traj"]["site"]]
                if a is not b and a["task"]["control"].get("control_id") != b["task"]["control"].get("control_id")
                and not _scope(a["task"]) & _scope(b["task"])]
        rng.shuffle(pool)
        pool.sort(key=lambda a: a["traj"]["macro"] == b["traj"]["macro"])      # other macros first
        out += [(a["dir"], b["dir"]) for a in pool[:per_second]]
    rng.shuffle(out)
    return out[:limit] if limit else out


def _values(text):
    """Names and values an instruction must keep: numbers, quoted strings, Capitalized word runs."""
    vals = set(re.findall(r"\d[\d,.:/-]*\d|\d", text))
    vals |= {m.strip() for m in re.findall(r"[\"'β€œβ€˜]([^\"'”’]{2,60})[\"'”’]", text)}
    return {v for v in vals if v}


async def merge_instructions(a, b):
    """-> one instruction asking for a then b: the memory model's rewrite when it keeps every value of both,
    else the two joined."""
    joined = a.rstrip() + (" " if a.rstrip().endswith((".", "?", "!")) else ". ") + "After that: " + b.strip()
    if not R.MEMORY_LLM:
        return joined, "joined"
    try:
        r = await R.MEMORY_LLM["client"].chat.completions.create(
            model=R.MEMORY_LLM["model"], temperature=0, max_completion_tokens=200,
            messages=[{"role": "user", "content": MERGE_ASK.format(a=a, b=b)}])
        text = R._clean((r.choices[0].message.content or "").strip().strip('"'))
    except Exception:
        return joined, "joined"
    need = _values(a) | _values(b)
    if text and all(v in text for v in need) and len(text) <= len(a) + len(b) + 80:
        return text, "merged"
    return joined, "joined"


def chain_id(dirs, prefix="chain"):
    """accepted/<task id> dirs -> the chain episode's id."""
    ids = [d.name for d in dirs]
    site = json.loads((dirs[0] / "trajectory.json").read_text())["site"]
    return f"{prefix}-{site}-" + hashlib.sha1("+".join(ids).encode()).hexdigest()[:10]


async def replay_chain(dirs, base, out_root=R.OUT_DIR, headless=True, log=print):
    """Replay kept trajectories one after another as one episode (see chain_candidates)."""
    parts = [R.load_part(d, base) for d in dirs]
    ids = [p["traj"]["id"] for p in parts]
    eid = chain_id(dirs)
    plan, seg0 = [], 0
    try:
        for k, p in enumerate(parts):
            ops = R.build_plan(p["traj"], p["elements"])
            if k < len(parts) - 1:
                ops = [op for op in ops if op["op"] != "done"]
            ops = [dict(op, segment=op["segment"] + seg0) for op in ops]
            start = _path(p["task"]["start"]["url"])
            if k and start != _path(parts[k - 1]["task"]["start"]["url"]):
                # the next part starts on the site's home page: its own home link gets there
                ops.insert(0, {"op": "home", "segment": ops[0]["segment"], "macro": ops[0]["macro"],
                               "site": p["traj"]["site"]})
            plan += ops
            seg0 = max(op["segment"] for op in plan) + 1
    except R.Unsupported as e:
        return {"task_id": eid, "variant": "chain", "parts": ids, "ok": False, "error": f"unsupported: {e}", "steps": 0}
    instruction = parts[0]["task"]["instruction"]
    for p in parts[1:]:
        instruction, how = await merge_instructions(instruction, p["task"]["instruction"])
    spec = {"id": eid, "variant": "chain", "parts": parts, "instruction": instruction, "plan": plan}
    res = await R.replay_episode(spec, base, out_root, headless, log)
    res["instruction_by"] = how
    return res