jevnav 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jevnav/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """jevnav — browser decisions you can replay, test and audit."""
2
+
3
+ __version__ = "0.1.0"
jevnav/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
jevnav/agent.py ADDED
@@ -0,0 +1,422 @@
1
+ """Goal-driven loop: Jev decides every step, jevnav executes, gates and records.
2
+
3
+ One request per step answers four questions together: is the goal already
4
+ achieved (status), what to do (action), on what (target) and with which context
5
+ value (value_key). The loop stops on `done`, on a gate verdict that is not
6
+ `auto`, when the page stops changing, or at `max_steps`.
7
+
8
+ The model's `done` is a claim, not evidence: with ``success`` (a selector) the
9
+ claim is verified against the page, and the result says so either way.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from . import page as page_module
20
+ from .decide import DEFAULT_MODEL, failed_decision
21
+ from .flow import recorded_url
22
+ from .gates import AUTO, verdict
23
+ from .trace import NullWriter, TraceWriter, describe_options, dom_hash
24
+
25
+ STATUSES = ("in_progress", "done", "stuck")
26
+ ACTIONS = ("click", "fill", "select", "check", "hover", "press")
27
+ FILLABLE = {"textbox", "searchbox", "combobox", "spinbutton"}
28
+ ENV_PATTERN = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
29
+
30
+ TEXT_JS = """() => {
31
+ const main = document.querySelector('main') || document.body;
32
+ const lines = (main.innerText || '').split('\\n')
33
+ .map((line) => line.split(/\\s+/).filter(Boolean).join(' '))
34
+ .filter(Boolean);
35
+ return lines.join('\\n');
36
+ }"""
37
+
38
+
39
+ def text_digest(page: Any, limit: int = 700) -> str:
40
+ """Visible text, collapsed — the state the model needs to judge 'done'."""
41
+ try:
42
+ text = page.evaluate(TEXT_JS)
43
+ except Exception:
44
+ return ""
45
+ return text[:limit]
46
+
47
+
48
+ def context_value(raw: str) -> tuple[str | None, str | None]:
49
+ """``${VAR}`` is resolved from the environment and recorded as a name only."""
50
+ match = ENV_PATTERN.match(str(raw))
51
+ if match:
52
+ name = match.group(1)
53
+ if name not in os.environ:
54
+ raise KeyError(f"environment variable {name} is not set (needed by the goal context)")
55
+ return os.environ[name], name
56
+ return str(raw), None
57
+
58
+
59
+ def build_state(
60
+ *,
61
+ goal: str,
62
+ context_keys: list[str],
63
+ page: Any,
64
+ candidates: list[dict[str, Any]],
65
+ history: list[str],
66
+ step: int,
67
+ max_steps: int,
68
+ ) -> str:
69
+ return (
70
+ f"Goal: {goal}\n"
71
+ f"Step {step} of {max_steps}.\n"
72
+ f"Context values available: {', '.join(context_keys) or '(none)'}\n"
73
+ f"Current page: {page.title()!r} — {page.url}\n"
74
+ f"Visible text on the page:\n{text_digest(page)}\n"
75
+ f"{len(candidates)} interactive elements are listed as options of the target question.\n"
76
+ "Steps so far:\n" + ("\n".join(f" {line}" for line in history) if history else " (none)")
77
+ )
78
+
79
+
80
+ def build_questions(
81
+ goal: str, context_keys: list[str], candidates: list[dict[str, Any]]
82
+ ) -> dict[str, Any]:
83
+ options = describe_options(candidates)
84
+ options["none"] = "No visible element fits."
85
+ questions: dict[str, Any] = {
86
+ "status": {
87
+ "type": "choice",
88
+ "instructions": (
89
+ f"The goal is: {goal}. Decide from the current page state and the steps already "
90
+ "taken: is the goal already achieved (done), is there still a sensible next step "
91
+ "(in_progress), or can it not be reached from here (stuck)? Answer done only if "
92
+ "the page itself shows it; answer stuck if no listed element can make progress."
93
+ ),
94
+ "criteria": {
95
+ "in_progress": "A sensible next step exists.",
96
+ "done": "The page already shows the goal is achieved.",
97
+ "stuck": "No listed element can make progress towards the goal.",
98
+ },
99
+ },
100
+ "action": {
101
+ "type": "choice",
102
+ "instructions": (
103
+ "Which action should be performed next? click presses buttons and links, fill "
104
+ "types a context value into a text input, select picks a dropdown option, check "
105
+ "ticks a checkbox, press sends a key, hover moves the pointer."
106
+ ),
107
+ "criteria": {
108
+ "click": "Press the element (buttons, links).",
109
+ "fill": "Type a context value into a text input (textbox, searchbox, combobox).",
110
+ "select": "Pick an option in a dropdown.",
111
+ "check": "Tick a checkbox or radio button.",
112
+ "hover": "Move the pointer over the element.",
113
+ "press": "Send a keyboard key to the element.",
114
+ },
115
+ },
116
+ "target": {
117
+ "type": "choice",
118
+ "instructions": (
119
+ "Which single listed element should the chosen action be performed on? Pick "
120
+ "'none' if no element fits the action and the goal."
121
+ ),
122
+ "criteria": options,
123
+ },
124
+ }
125
+ if context_keys:
126
+ questions["value_key"] = {
127
+ "type": "choice",
128
+ "instructions": (
129
+ "If the action you chose is fill or select, you MUST pick the context value that "
130
+ "belongs in the target element here — never 'none' for a fill. Pick 'none' only "
131
+ "when the chosen action does not need a value (click, check, hover, press)."
132
+ ),
133
+ "criteria": {
134
+ **{key: f"the value of {key}" for key in context_keys},
135
+ "none": "No value is needed.",
136
+ },
137
+ }
138
+ return questions
139
+
140
+
141
+ def alternatives(
142
+ candidates: list[dict[str, Any]], decision: dict[str, Any], limit: int = 3
143
+ ) -> list[dict[str, Any]]:
144
+ """The runners-up, so a caller can resolve a review without guessing.
145
+
146
+ Surfaced when the gate wants a human: a more specific `browse` intent scores
147
+ much higher on the same page (measured on Wikipedia: p 0.44 -> 0.93).
148
+ """
149
+ probabilities = decision.get("probabilities") or {}
150
+ ranked = [
151
+ {
152
+ "name": candidate["name"],
153
+ "role": candidate["role"],
154
+ "confidence": round(probabilities.get(candidate["cid"], 0.0), 3),
155
+ }
156
+ for candidate in candidates
157
+ if candidate["cid"] != decision.get("choice") and probabilities.get(candidate["cid"])
158
+ ]
159
+ ranked.sort(key=lambda item: item["confidence"], reverse=True)
160
+ return ranked[:limit]
161
+
162
+
163
+ def resolve_value_key(
164
+ answer_key: str | None, target_name: str | None, context: dict[str, str]
165
+ ) -> tuple[str | None, str | None]:
166
+ """The model's choice, or a deterministic match of the field name to a context key."""
167
+ if answer_key in context:
168
+ return answer_key, "model"
169
+ if target_name:
170
+ for key in context:
171
+ if key.casefold() in target_name.casefold():
172
+ return key, "name-match"
173
+ return None, None
174
+
175
+
176
+ def run_goal(
177
+ goal: str,
178
+ *,
179
+ page: Any,
180
+ client: Any,
181
+ gates: dict[str, Any],
182
+ writer: TraceWriter | NullWriter,
183
+ model: str = DEFAULT_MODEL,
184
+ context: dict[str, str] | None = None,
185
+ start: str | None = None,
186
+ success: str | None = None,
187
+ max_steps: int = 8,
188
+ min_confidence: float | None = None,
189
+ dry_run: bool = False,
190
+ allow_risky: bool = False,
191
+ settle_ms: int = 300,
192
+ ) -> dict[str, Any]:
193
+ """Walk towards the goal until it is done, blocked, or out of steps."""
194
+ context = context or {}
195
+ if start:
196
+ page.goto(start, wait_until="domcontentloaded")
197
+ history: list[str] = []
198
+ records: list[dict[str, Any]] = []
199
+ status = "max_steps"
200
+ reason: str | None = None
201
+ unchanged = 0
202
+ finished = False
203
+ for step in range(1, max_steps + 1):
204
+ if settle_ms:
205
+ page.wait_for_timeout(settle_ms)
206
+ candidates, total, dropped = page_module.extract(page)
207
+ state = build_state(
208
+ goal=goal,
209
+ context_keys=list(context),
210
+ page=page,
211
+ candidates=candidates,
212
+ history=history,
213
+ step=step,
214
+ max_steps=max_steps,
215
+ )
216
+ questions = build_questions(goal, list(context), candidates)
217
+ try:
218
+ response, latency_ms = client.system_one(state, questions, model=model)
219
+ answers = response.get("answers") or {}
220
+ usage = response.get("usage") or {}
221
+ decision = {
222
+ "status": (answers.get("status") or {}).get("choice"),
223
+ "status_confidence": (answers.get("status") or {}).get("confidence"),
224
+ "action": (answers.get("action") or {}).get("choice"),
225
+ "action_confidence": (answers.get("action") or {}).get("confidence"),
226
+ "choice": (answers.get("target") or {}).get("choice"),
227
+ "confidence": (answers.get("target") or {}).get("confidence"),
228
+ "value_key": (answers.get("value_key") or {}).get("choice"),
229
+ "probabilities": (answers.get("target") or {}).get("probabilities") or {},
230
+ "model": response.get("model") or model,
231
+ "latency_ms": round(latency_ms, 1),
232
+ "usage": usage,
233
+ "cost_usd": (usage.get("input_tokens") or 0) * 0.042 / 1_000_000,
234
+ "error": None,
235
+ }
236
+ except Exception as error:
237
+ decision = failed_decision(error) | {"status": None, "action": None, "value_key": None}
238
+ chosen = page_module.by_cid(candidates, decision.get("choice") or "")
239
+ if chosen is not None:
240
+ decision["chosen_fp"] = chosen["fp"]
241
+ decision["chosen_name"] = chosen["name"]
242
+ else:
243
+ decision["chosen_fp"] = None
244
+ decision["chosen_name"] = None
245
+ decision["alternatives"] = alternatives(candidates, decision)
246
+ gate, gate_reason = verdict(
247
+ decision,
248
+ intent=goal,
249
+ candidate=chosen,
250
+ dropped=dropped,
251
+ gates=gates,
252
+ default_key="loop_min_confidence",
253
+ min_confidence=min_confidence,
254
+ )
255
+ if gate != AUTO and decision.get("choice") in (None, "none"):
256
+ gate, gate_reason = "blocked", gate_reason
257
+ record: dict[str, Any] = {
258
+ "step": step,
259
+ "intent": goal,
260
+ "action": {"type": decision.get("action") or "none"},
261
+ "url": recorded_url(page.url, _base_dir(writer)),
262
+ "title": page.title(),
263
+ "total_on_page": total,
264
+ "dropped": dropped,
265
+ "dom_hash": dom_hash(candidates),
266
+ "candidates": candidates,
267
+ "expected_cid": None,
268
+ "decision": decision,
269
+ "gate": {"verdict": gate, "reason": gate_reason},
270
+ "locator": None,
271
+ "result": {"correct": None, "executed": False, "error": None},
272
+ }
273
+ if chosen is not None:
274
+ selector, unique = page_module.locator_for(page, chosen)
275
+ record["locator"] = {"selector": selector, "unique": unique}
276
+ status = decision.get("status") or "error"
277
+ if status == "done":
278
+ reason = f"model says the goal is achieved (p={decision.get('status_confidence')})"
279
+ verified = None
280
+ if success:
281
+ try:
282
+ verified = page.locator(success).first.is_visible()
283
+ except Exception:
284
+ verified = False
285
+ if not verified:
286
+ status = "unverified"
287
+ reason = f"the model says done but {success!r} is not visible on the page"
288
+ record["verify"] = {"selector": success, "verified": verified}
289
+ record["gate"] = {"verdict": "n/a", "reason": "goal achieved, no action needed"}
290
+ records.append(writer.step(**record))
291
+ finished = True
292
+ break
293
+ if status == "stuck" or chosen is None or gate == "blocked":
294
+ status = "stuck" if status != "error" else "error"
295
+ reason = gate_reason or "no element can make progress"
296
+ records.append(writer.step(**record))
297
+ finished = True
298
+ break
299
+ if gate == "review" and not allow_risky:
300
+ status = "review"
301
+ reason = f"{gate_reason} — the loop stopped before acting"
302
+ records.append(writer.step(**record))
303
+ finished = True
304
+ break
305
+ action = decision.get("action")
306
+ if action not in ACTIONS:
307
+ status, reason = "error", f"model chose an unknown action {action!r}"
308
+ records.append(writer.step(**record))
309
+ finished = True
310
+ break
311
+ if action == "fill" and chosen["role"] not in FILLABLE:
312
+ status, reason = "blocked", f"fill does not apply to role {chosen['role']!r}"
313
+ records.append(writer.step(**record))
314
+ finished = True
315
+ break
316
+ action_dict: dict[str, Any] = {"type": action}
317
+ if action in {"fill", "select"}:
318
+ key, source = resolve_value_key(decision.get("value_key"), chosen["name"], context)
319
+ if key is None:
320
+ status, reason = "blocked", f"no context value fits the field {chosen['name']!r}"
321
+ records.append(writer.step(**record))
322
+ finished = True
323
+ break
324
+ raw = context[key]
325
+ value, env_name = context_value(raw)
326
+ action_dict["value"] = value
327
+ record["action"] = (
328
+ {"type": action, "value_from_env": env_name}
329
+ if env_name
330
+ else {"type": action, "value": value}
331
+ )
332
+ record["value_source"] = source
333
+ if action == "press":
334
+ action_dict["key"] = "Enter"
335
+ if not dry_run:
336
+ try:
337
+ page_module.execute(page, chosen["cid"], action_dict, settle_ms=settle_ms)
338
+ record["result"]["executed"] = True
339
+ except Exception as error:
340
+ record["result"]["error"] = f"{type(error).__name__}: {error}"
341
+ after, _, _ = page_module.extract(page)
342
+ changed = dom_hash(after) != record["dom_hash"]
343
+ unchanged = 0 if changed else unchanged + 1
344
+ history.append(
345
+ f"step {step}: {action} on {chosen['name']!r}"
346
+ + (f" with {decision.get('value_key')}" if action in {"fill", "select"} else "")
347
+ + (
348
+ f" -> ERROR {record['result']['error']}"
349
+ if record["result"]["error"]
350
+ else f" -> page {'changed' if changed else 'unchanged'}"
351
+ )
352
+ )
353
+ records.append(writer.step(**record))
354
+ if unchanged >= 2:
355
+ status, reason = "no_progress", "two steps in a row changed nothing on the page"
356
+ finished = True
357
+ break
358
+ if not finished and status != "max_steps":
359
+ status, reason = "max_steps", f"{max_steps} steps without reaching the goal"
360
+ verified = (records[-1].get("verify") or {}).get("verified") if records else None
361
+ if status == "done" and not success:
362
+ reason = (reason or "") + " — pass --success <selector> to verify the outcome"
363
+ return {
364
+ "goal": goal,
365
+ "status": status,
366
+ "reason": reason,
367
+ "verified": verified,
368
+ "success": success,
369
+ "steps": records,
370
+ "context_keys": list(context),
371
+ "history": history,
372
+ }
373
+
374
+
375
+ def _base_dir(writer: TraceWriter | NullWriter) -> Any:
376
+ return writer.path.resolve().parent if writer.path else Path.cwd()
377
+
378
+
379
+ def summarize_goal(result: dict[str, Any]) -> dict[str, Any]:
380
+ gates = [record["gate"]["verdict"] for record in result["steps"]]
381
+ latencies = [
382
+ r["decision"]["latency_ms"]
383
+ for r in result["steps"]
384
+ if r["decision"].get("latency_ms") is not None
385
+ ]
386
+ return {
387
+ "goal": result["goal"],
388
+ "status": result["status"],
389
+ "verified": result["verified"],
390
+ "reason": result["reason"],
391
+ "steps": len(result["steps"]),
392
+ "auto": gates.count("auto"),
393
+ "review": gates.count("review"),
394
+ "blocked": gates.count("blocked"),
395
+ "stopped": gates.count("n/a"),
396
+ "alternatives": (result["steps"][-1]["decision"].get("alternatives") or [])
397
+ if result["steps"] and result["status"] in {"review", "stuck"}
398
+ else [],
399
+ "hint": (
400
+ "Call browse again with a more specific intent (name the element and where it is); "
401
+ "specific intents score much higher than a broad goal."
402
+ )
403
+ if result["status"] in {"review", "stuck"}
404
+ else None,
405
+ "cost_usd": round(sum(r["decision"].get("cost_usd") or 0 for r in result["steps"]), 6),
406
+ "latency_p50_ms": sorted(latencies)[len(latencies) // 2] if latencies else None,
407
+ "latency_p95_ms": sorted(latencies)[min(len(latencies) - 1, int(0.95 * len(latencies)))]
408
+ if latencies
409
+ else None,
410
+ }
411
+
412
+
413
+ __all__ = [
414
+ "ACTIONS",
415
+ "STATUSES",
416
+ "build_questions",
417
+ "build_state",
418
+ "context_value",
419
+ "run_goal",
420
+ "summarize_goal",
421
+ "text_digest",
422
+ ]