agentvision 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.
- agentvision/__init__.py +61 -0
- agentvision/adapters/__init__.py +1 -0
- agentvision/adapters/_demo_assets.py +35 -0
- agentvision/adapters/cli.py +361 -0
- agentvision/adapters/doctor.py +123 -0
- agentvision/adapters/mcp_server.py +156 -0
- agentvision/adapters/rest.py +174 -0
- agentvision/backends/__init__.py +9 -0
- agentvision/backends/_image.py +21 -0
- agentvision/backends/anthropic_backend.py +94 -0
- agentvision/backends/base.py +39 -0
- agentvision/backends/gemini_backend.py +79 -0
- agentvision/backends/local_backend.py +41 -0
- agentvision/backends/openai_backend.py +78 -0
- agentvision/backends/prompt.py +121 -0
- agentvision/backends/registry.py +68 -0
- agentvision/backends/schema_adapters.py +96 -0
- agentvision/config.py +93 -0
- agentvision/core/__init__.py +15 -0
- agentvision/core/analyze.py +129 -0
- agentvision/core/baseline.py +69 -0
- agentvision/core/capture.py +62 -0
- agentvision/core/checks/__init__.py +37 -0
- agentvision/core/checks/contrast.py +37 -0
- agentvision/core/checks/layout.py +96 -0
- agentvision/core/diff.py +115 -0
- agentvision/core/loop.py +132 -0
- agentvision/core/render.py +49 -0
- agentvision/errors.py +52 -0
- agentvision/logging.py +47 -0
- agentvision/models/__init__.py +19 -0
- agentvision/models/diff.py +26 -0
- agentvision/models/geometry.py +42 -0
- agentvision/models/report.py +140 -0
- agentvision/ocr/__init__.py +12 -0
- agentvision/ocr/base.py +27 -0
- agentvision/ocr/tesseract.py +60 -0
- agentvision/renderers/__init__.py +31 -0
- agentvision/renderers/_extract_js.py +107 -0
- agentvision/renderers/base.py +87 -0
- agentvision/renderers/image_renderer.py +42 -0
- agentvision/renderers/pdf_renderer.py +51 -0
- agentvision/renderers/playwright_renderer.py +257 -0
- agentvision/sources.py +143 -0
- agentvision/workspace.py +127 -0
- agentvision-0.1.0.dist-info/METADATA +199 -0
- agentvision-0.1.0.dist-info/RECORD +50 -0
- agentvision-0.1.0.dist-info/WHEEL +4 -0
- agentvision-0.1.0.dist-info/entry_points.txt +4 -0
- agentvision-0.1.0.dist-info/licenses/LICENSE +21 -0
agentvision/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""AgentVision — Eyes for AI Agents.
|
|
2
|
+
|
|
3
|
+
A machine-graded visual feedback loop that coding agents consume to self-correct before
|
|
4
|
+
claiming a visual task done: render -> perceive -> report -> (fix) -> re-render -> diff.
|
|
5
|
+
|
|
6
|
+
The top-level import is dependency-light. Heavy entry points (which pull in Playlist/CV/
|
|
7
|
+
LLM SDKs) are exposed lazily via ``__getattr__`` so ``import agentvision`` always works,
|
|
8
|
+
even on a bare server.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .config import Settings, load_settings
|
|
14
|
+
from .errors import (
|
|
15
|
+
AgentVisionError,
|
|
16
|
+
BackendAuthError,
|
|
17
|
+
BackendError,
|
|
18
|
+
ConfigError,
|
|
19
|
+
MissingDependencyError,
|
|
20
|
+
RenderError,
|
|
21
|
+
RenderTimeout,
|
|
22
|
+
UnsafeSourceError,
|
|
23
|
+
)
|
|
24
|
+
from .models import (
|
|
25
|
+
BBox,
|
|
26
|
+
Confidence,
|
|
27
|
+
DiffResult,
|
|
28
|
+
Issue,
|
|
29
|
+
IssueKind,
|
|
30
|
+
IssueSource,
|
|
31
|
+
Report,
|
|
32
|
+
Severity,
|
|
33
|
+
Verdict,
|
|
34
|
+
Viewport,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"__version__",
|
|
41
|
+
"Settings", "load_settings",
|
|
42
|
+
"AgentVisionError", "MissingDependencyError", "RenderError", "RenderTimeout",
|
|
43
|
+
"UnsafeSourceError", "BackendError", "BackendAuthError", "ConfigError",
|
|
44
|
+
"BBox", "Viewport", "Issue", "IssueKind", "IssueSource", "Severity", "Confidence",
|
|
45
|
+
"Verdict", "Report", "DiffResult",
|
|
46
|
+
# lazy:
|
|
47
|
+
"render", "analyze", "diff", "check", "LoopSession",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def __getattr__(name: str):
|
|
52
|
+
# Lazy high-level API — imported on demand to keep the base import light.
|
|
53
|
+
if name in {"render", "analyze", "diff", "check"}:
|
|
54
|
+
from . import core
|
|
55
|
+
|
|
56
|
+
return getattr(core, name)
|
|
57
|
+
if name == "LoopSession":
|
|
58
|
+
from .core.loop import LoopSession
|
|
59
|
+
|
|
60
|
+
return LoopSession
|
|
61
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Thin adapters over the core engine: CLI, MCP server, REST service."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Self-contained demo HTML so `agentvision demo` works after a pip install (no repo)."""
|
|
2
|
+
|
|
3
|
+
BROKEN_HTML = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
4
|
+
<title>Dashboard (broken)</title><style>
|
|
5
|
+
body{font-family:system-ui,sans-serif;margin:0;padding:24px;background:#fff}
|
|
6
|
+
h1{color:#111}
|
|
7
|
+
p.note{color:#c9c9c9;background:#fff;font-size:15px}
|
|
8
|
+
.wide-row{width:2200px;background:#eef;padding:16px}
|
|
9
|
+
.card{display:inline-block;width:600px;height:80px;background:#f4f4f4;margin-right:16px}
|
|
10
|
+
.cta{color:#9fcaff;background:#fff;font-size:14px}
|
|
11
|
+
</style></head><body>
|
|
12
|
+
<h1>Quarterly Dashboard</h1>
|
|
13
|
+
<p class="note">Revenue is up 12% over last quarter. This summary text is hard to read.</p>
|
|
14
|
+
<img src="missing-logo.png" alt="Company logo" width="160" height="48">
|
|
15
|
+
<div class="wide-row"><span class="card">Metric A</span><span class="card">Metric B</span>
|
|
16
|
+
<span class="card">Metric C</span></div>
|
|
17
|
+
<p class="cta">Click here to view the full report</p>
|
|
18
|
+
</body></html>"""
|
|
19
|
+
|
|
20
|
+
FIXED_HTML = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
21
|
+
<title>Dashboard (fixed)</title><style>
|
|
22
|
+
body{font-family:system-ui,sans-serif;margin:0;padding:24px;background:#fff}
|
|
23
|
+
h1{color:#111}
|
|
24
|
+
p.note{color:#333;background:#fff;font-size:15px}
|
|
25
|
+
.row{display:flex;flex-wrap:wrap;gap:16px}
|
|
26
|
+
.card{flex:1 1 200px;min-width:0;height:80px;background:#f4f4f4}
|
|
27
|
+
.cta{color:#0b5cad;background:#fff;font-size:14px;font-weight:600}
|
|
28
|
+
</style></head><body>
|
|
29
|
+
<h1>Quarterly Dashboard</h1>
|
|
30
|
+
<p class="note">Revenue is up 12% over last quarter. This summary text is easy to read.</p>
|
|
31
|
+
<img alt="Company logo" width="160" height="48" src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='48'><rect width='160' height='48' fill='%230b5cad'/><text x='12' y='30' fill='white' font-family='sans-serif' font-size='18'>ACME</text></svg>">
|
|
32
|
+
<div class="row"><span class="card">Metric A</span><span class="card">Metric B</span>
|
|
33
|
+
<span class="card">Metric C</span></div>
|
|
34
|
+
<p class="cta">Click here to view the full report</p>
|
|
35
|
+
</body></html>"""
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""AgentVision CLI (Typer). The primary face; every other adapter mirrors it.
|
|
2
|
+
|
|
3
|
+
All commands support ``--json`` for agent/CI consumption and exit non-zero on a FAIL
|
|
4
|
+
verdict.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from .. import __version__
|
|
16
|
+
from ..config import Settings, load_settings
|
|
17
|
+
from ..models.geometry import Viewport
|
|
18
|
+
from ..models.report import Report, Verdict
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
add_completion=False,
|
|
22
|
+
help="AgentVision — eyes for AI agents. Render, see, and self-correct visual output.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_VERDICT_COLOR = {Verdict.PASS: typer.colors.GREEN, Verdict.WARN: typer.colors.YELLOW,
|
|
27
|
+
Verdict.FAIL: typer.colors.RED}
|
|
28
|
+
_SEV_COLOR = {"info": typer.colors.BLUE, "warning": typer.colors.YELLOW,
|
|
29
|
+
"error": typer.colors.RED, "critical": typer.colors.BRIGHT_RED}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_viewport(s: str | None) -> Viewport | None:
|
|
33
|
+
if not s:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
w, h = s.lower().split("x")
|
|
37
|
+
return Viewport(width=int(w), height=int(h))
|
|
38
|
+
except ValueError as e:
|
|
39
|
+
raise typer.BadParameter("viewport must be WxH, e.g. 1280x800") from e
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _settings(backend: str | None = None, full_page: bool | None = None,
|
|
43
|
+
viewport: str | None = None, device_scale: float | None = None,
|
|
44
|
+
timeout: float | None = None) -> Settings:
|
|
45
|
+
overrides: dict = {}
|
|
46
|
+
if backend:
|
|
47
|
+
overrides["vision_backend"] = backend
|
|
48
|
+
if full_page is not None:
|
|
49
|
+
overrides["full_page"] = full_page
|
|
50
|
+
if device_scale is not None:
|
|
51
|
+
overrides["device_scale"] = device_scale
|
|
52
|
+
if timeout is not None:
|
|
53
|
+
overrides["render_timeout_s"] = timeout
|
|
54
|
+
vp = _parse_viewport(viewport)
|
|
55
|
+
if vp:
|
|
56
|
+
overrides["default_viewport_width"] = vp.width
|
|
57
|
+
overrides["default_viewport_height"] = vp.height
|
|
58
|
+
return load_settings(**overrides)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _print_report(report: Report, as_json: bool) -> None:
|
|
62
|
+
if as_json:
|
|
63
|
+
typer.echo(report.model_dump_json(indent=2))
|
|
64
|
+
return
|
|
65
|
+
color = _VERDICT_COLOR.get(report.verdict, typer.colors.WHITE)
|
|
66
|
+
typer.secho(f"\n {report.verdict.value.upper()}", fg=color, bold=True, nl=False)
|
|
67
|
+
typer.secho(f" ({report.backend}{'/' + report.model if report.model else ''})",
|
|
68
|
+
fg=typer.colors.BRIGHT_BLACK)
|
|
69
|
+
typer.echo(f" {report.summary}\n")
|
|
70
|
+
if not report.issues:
|
|
71
|
+
typer.secho(" No issues.\n", fg=typer.colors.GREEN)
|
|
72
|
+
for i in report.issues:
|
|
73
|
+
sev = _SEV_COLOR.get(i.severity.value, typer.colors.WHITE)
|
|
74
|
+
loc = ""
|
|
75
|
+
if i.bbox:
|
|
76
|
+
mark = "" if i.bbox_precise else "~"
|
|
77
|
+
loc = f" @{mark}({i.bbox.x:.0f},{i.bbox.y:.0f})"
|
|
78
|
+
typer.secho(f" • [{i.kind.value}]", fg=sev, nl=False)
|
|
79
|
+
typer.secho(f" {i.message}", nl=False)
|
|
80
|
+
typer.secho(f"{loc} ({i.source.value}/{i.confidence.value})",
|
|
81
|
+
fg=typer.colors.BRIGHT_BLACK)
|
|
82
|
+
if report.image_path:
|
|
83
|
+
typer.secho(f"\n image: {report.image_path}", fg=typer.colors.BRIGHT_BLACK)
|
|
84
|
+
typer.echo()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _exit_for(report: Report) -> None:
|
|
88
|
+
if report.verdict == Verdict.FAIL:
|
|
89
|
+
raise typer.Exit(code=2)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------------------- commands
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def version():
|
|
96
|
+
"""Print the AgentVision version."""
|
|
97
|
+
typer.echo(__version__)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def analyze(
|
|
102
|
+
source: str = typer.Argument(..., help="HTML/file/URL/SVG/PDF/image, or inline HTML."),
|
|
103
|
+
backend: str = typer.Option(None, help="anthropic|openai|gemini|local"),
|
|
104
|
+
instructions: str = typer.Option(None, help="Task context for the vision model."),
|
|
105
|
+
expected: str = typer.Option(None, help="What the artifact was supposed to look like."),
|
|
106
|
+
source_type: str = typer.Option("auto", help="auto|html|file|url|svg|pdf|image"),
|
|
107
|
+
viewport: str = typer.Option(None, help="WxH, e.g. 1280x800"),
|
|
108
|
+
full_page: bool = typer.Option(False, "--full-page/--viewport-only"),
|
|
109
|
+
no_ocr: bool = typer.Option(False, "--no-ocr", help="Disable OCR grounding."),
|
|
110
|
+
json_out: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
111
|
+
):
|
|
112
|
+
"""Render and analyze an artifact with a vision backend (+ DOM/CV grounding)."""
|
|
113
|
+
from ..core import analyze as do_analyze
|
|
114
|
+
|
|
115
|
+
settings = _settings(backend=backend, full_page=full_page, viewport=viewport)
|
|
116
|
+
report = asyncio.run(do_analyze(
|
|
117
|
+
source, settings=settings, backend=backend, instructions=instructions,
|
|
118
|
+
expected=expected, use_ocr=not no_ocr, source_type=source_type, full_page=full_page,
|
|
119
|
+
))
|
|
120
|
+
_print_report(report, json_out)
|
|
121
|
+
_exit_for(report)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command()
|
|
125
|
+
def check(
|
|
126
|
+
source: str = typer.Argument(...),
|
|
127
|
+
source_type: str = typer.Option("auto"),
|
|
128
|
+
viewport: str = typer.Option(None, help="WxH"),
|
|
129
|
+
full_page: bool = typer.Option(True, "--full-page/--viewport-only"),
|
|
130
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
131
|
+
):
|
|
132
|
+
"""Classic DOM/CV checks only — no LLM, no API key, no egress."""
|
|
133
|
+
from ..core import check as do_check
|
|
134
|
+
|
|
135
|
+
settings = _settings(full_page=full_page, viewport=viewport)
|
|
136
|
+
report = asyncio.run(do_check(source, settings=settings, source_type=source_type,
|
|
137
|
+
full_page=full_page))
|
|
138
|
+
_print_report(report, json_out)
|
|
139
|
+
_exit_for(report)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command()
|
|
143
|
+
def render(
|
|
144
|
+
source: str = typer.Argument(...),
|
|
145
|
+
out: str = typer.Option("agentvision-render.png", "-o", "--out"),
|
|
146
|
+
source_type: str = typer.Option("auto"),
|
|
147
|
+
viewport: str = typer.Option(None, help="WxH"),
|
|
148
|
+
full_page: bool = typer.Option(True, "--full-page/--viewport-only"),
|
|
149
|
+
):
|
|
150
|
+
"""Render an artifact to a PNG."""
|
|
151
|
+
from ..core import render as do_render
|
|
152
|
+
|
|
153
|
+
settings = _settings(full_page=full_page, viewport=viewport)
|
|
154
|
+
result = asyncio.run(do_render(source, settings=settings, source_type=source_type,
|
|
155
|
+
full_page=full_page))
|
|
156
|
+
if not result.primary:
|
|
157
|
+
typer.secho("Render produced no image.", fg=typer.colors.RED)
|
|
158
|
+
raise typer.Exit(code=1)
|
|
159
|
+
import shutil
|
|
160
|
+
|
|
161
|
+
shutil.copyfile(result.primary.path, out)
|
|
162
|
+
typer.secho(f"Rendered {result.primary.width}x{result.primary.height} -> {out}",
|
|
163
|
+
fg=typer.colors.GREEN)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command()
|
|
167
|
+
def diff(
|
|
168
|
+
baseline: str = typer.Argument(..., help="Baseline image path."),
|
|
169
|
+
candidate: str = typer.Argument(..., help="Candidate image path."),
|
|
170
|
+
out: str = typer.Option("agentvision-diff.png", "-o", "--out"),
|
|
171
|
+
threshold: float = typer.Option(0.98, help="Min SSIM to pass."),
|
|
172
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
173
|
+
):
|
|
174
|
+
"""Compare two images (SSIM + annotated diff)."""
|
|
175
|
+
from ..core import compute_diff
|
|
176
|
+
|
|
177
|
+
result = compute_diff(baseline, candidate, out)
|
|
178
|
+
if json_out:
|
|
179
|
+
typer.echo(result.model_dump_json(indent=2))
|
|
180
|
+
else:
|
|
181
|
+
typer.echo(f"SSIM {result.ssim:.4f} | changed {result.changed_ratio*100:.2f}% | "
|
|
182
|
+
f"{len(result.regions)} region(s)")
|
|
183
|
+
typer.echo(result.narrative)
|
|
184
|
+
if result.diff_image_path:
|
|
185
|
+
typer.secho(f"diff image: {result.diff_image_path}", fg=typer.colors.BRIGHT_BLACK)
|
|
186
|
+
if result.ssim < threshold:
|
|
187
|
+
raise typer.Exit(code=2)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.command()
|
|
191
|
+
def ocr(
|
|
192
|
+
source: str = typer.Argument(...),
|
|
193
|
+
source_type: str = typer.Option("auto"),
|
|
194
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
195
|
+
):
|
|
196
|
+
"""Extract text (+ word boxes) from an artifact via Tesseract."""
|
|
197
|
+
from ..core import render as do_render
|
|
198
|
+
from ..ocr import get_ocr_backend
|
|
199
|
+
|
|
200
|
+
backend = get_ocr_backend()
|
|
201
|
+
if not backend.available():
|
|
202
|
+
typer.secho("Tesseract not available. Install: tesseract-ocr + tesseract-ocr-eng",
|
|
203
|
+
fg=typer.colors.RED)
|
|
204
|
+
raise typer.Exit(code=1)
|
|
205
|
+
settings = _settings(full_page=True)
|
|
206
|
+
result = asyncio.run(do_render(source, settings=settings, source_type=source_type,
|
|
207
|
+
full_page=True))
|
|
208
|
+
if not result.primary:
|
|
209
|
+
raise typer.Exit(code=1)
|
|
210
|
+
res = backend.run(result.primary.path)
|
|
211
|
+
if json_out:
|
|
212
|
+
typer.echo(res.model_dump_json(indent=2))
|
|
213
|
+
else:
|
|
214
|
+
typer.echo(res.text)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def loop(
|
|
219
|
+
source: str = typer.Argument(...),
|
|
220
|
+
backend: str = typer.Option(None),
|
|
221
|
+
max_iter: int = typer.Option(3, "--max-iter"),
|
|
222
|
+
instructions: str = typer.Option(None),
|
|
223
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
224
|
+
):
|
|
225
|
+
"""Run the visual feedback loop (re-renders the source up to --max-iter times).
|
|
226
|
+
|
|
227
|
+
Agents instead drive the loop programmatically, editing the source between iterations.
|
|
228
|
+
"""
|
|
229
|
+
from ..core.loop import LoopSession
|
|
230
|
+
|
|
231
|
+
settings = _settings(backend=backend, full_page=True)
|
|
232
|
+
session = LoopSession(source, settings=settings, backend=backend, instructions=instructions)
|
|
233
|
+
history = asyncio.run(session.run(max_iter=max_iter))
|
|
234
|
+
if json_out:
|
|
235
|
+
import json as _json
|
|
236
|
+
|
|
237
|
+
typer.echo(_json.dumps([h.model_dump(mode="json") for h in history], indent=2))
|
|
238
|
+
else:
|
|
239
|
+
for h in history:
|
|
240
|
+
tag = "PASS" if h.verdict == Verdict.PASS else ("STUCK" if h.stuck else h.verdict.value.upper())
|
|
241
|
+
typer.secho(f"iter {h.index}: {tag} — {len(h.report.issues)} issue(s)",
|
|
242
|
+
fg=_VERDICT_COLOR.get(h.verdict))
|
|
243
|
+
if h.diff:
|
|
244
|
+
typer.secho(f" Δ {h.diff.narrative}", fg=typer.colors.BRIGHT_BLACK)
|
|
245
|
+
typer.echo(f"\nstop reason: {session.stop_reason or 'max-iter'}")
|
|
246
|
+
last = history[-1] if history else None
|
|
247
|
+
if last and last.verdict == Verdict.FAIL:
|
|
248
|
+
raise typer.Exit(code=2)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.command()
|
|
252
|
+
def sheet(
|
|
253
|
+
source: str = typer.Argument(...),
|
|
254
|
+
breakpoints: str = typer.Option("375,768,1280,1920", help="Comma-separated widths."),
|
|
255
|
+
out: str = typer.Option("agentvision-sheet.png", "-o", "--out"),
|
|
256
|
+
):
|
|
257
|
+
"""Render a responsive contact sheet across breakpoints."""
|
|
258
|
+
from ..core.capture import contact_sheet
|
|
259
|
+
|
|
260
|
+
bps = [int(x) for x in breakpoints.split(",") if x.strip()]
|
|
261
|
+
settings = _settings()
|
|
262
|
+
path, _ = asyncio.run(contact_sheet(source, settings=settings, breakpoints=bps, out_path=out))
|
|
263
|
+
typer.secho(f"Contact sheet -> {path}", fg=typer.colors.GREEN)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@app.command()
|
|
267
|
+
def baseline(
|
|
268
|
+
source: str = typer.Argument(...),
|
|
269
|
+
name: str = typer.Option(..., "--name", help="Baseline name."),
|
|
270
|
+
source_type: str = typer.Option("auto"),
|
|
271
|
+
):
|
|
272
|
+
"""Capture and store a named baseline for regression."""
|
|
273
|
+
from ..core import set_baseline
|
|
274
|
+
|
|
275
|
+
settings = _settings()
|
|
276
|
+
path = asyncio.run(set_baseline(source, name, settings=settings, source_type=source_type))
|
|
277
|
+
typer.secho(f"Baseline '{name}' saved -> {path}", fg=typer.colors.GREEN)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@app.command()
|
|
281
|
+
def regress(
|
|
282
|
+
source: str = typer.Argument(...),
|
|
283
|
+
name: str = typer.Option(..., "--name"),
|
|
284
|
+
out: str = typer.Option("agentvision-regress.png", "-o", "--out"),
|
|
285
|
+
threshold: float = typer.Option(0.98),
|
|
286
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
287
|
+
):
|
|
288
|
+
"""Render a source and compare it to a named baseline."""
|
|
289
|
+
from ..core import regress as do_regress
|
|
290
|
+
|
|
291
|
+
settings = _settings()
|
|
292
|
+
result = asyncio.run(do_regress(source, name, settings=settings, out_path=out))
|
|
293
|
+
if json_out:
|
|
294
|
+
typer.echo(result.model_dump_json(indent=2))
|
|
295
|
+
else:
|
|
296
|
+
typer.echo(f"SSIM {result.ssim:.4f} vs baseline '{name}' — {result.narrative}")
|
|
297
|
+
if result.ssim < threshold:
|
|
298
|
+
typer.secho("Regression detected.", fg=typer.colors.RED)
|
|
299
|
+
raise typer.Exit(code=2)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@app.command()
|
|
303
|
+
def demo():
|
|
304
|
+
"""Run the 60-second demo: broken page -> FAIL -> loop to fixed -> PASS (no API key)."""
|
|
305
|
+
import tempfile
|
|
306
|
+
|
|
307
|
+
from ..core.loop import LoopSession
|
|
308
|
+
from ._demo_assets import BROKEN_HTML, FIXED_HTML
|
|
309
|
+
|
|
310
|
+
settings = _settings(backend="local", full_page=True)
|
|
311
|
+
typer.secho("AgentVision demo — giving an agent eyes (local backend, no API key)\n",
|
|
312
|
+
fg=typer.colors.CYAN, bold=True)
|
|
313
|
+
with tempfile.TemporaryDirectory() as td:
|
|
314
|
+
broken = Path(td) / "broken.html"
|
|
315
|
+
fixed = Path(td) / "fixed.html"
|
|
316
|
+
broken.write_text(BROKEN_HTML)
|
|
317
|
+
fixed.write_text(FIXED_HTML)
|
|
318
|
+
|
|
319
|
+
session = LoopSession(str(broken), settings=settings, backend="local")
|
|
320
|
+
typer.secho("1) The agent renders its page and looks at it:", bold=True)
|
|
321
|
+
it0 = asyncio.run(session.iterate())
|
|
322
|
+
_print_report(it0.report, False)
|
|
323
|
+
|
|
324
|
+
typer.secho("2) The agent fixes the issues and looks again:", bold=True)
|
|
325
|
+
it1 = asyncio.run(session.iterate(str(fixed)))
|
|
326
|
+
_print_report(it1.report, False)
|
|
327
|
+
if it1.diff:
|
|
328
|
+
typer.secho(f" what changed: {it1.diff.narrative}", fg=typer.colors.BRIGHT_BLACK)
|
|
329
|
+
|
|
330
|
+
if it0.verdict == Verdict.FAIL and it1.verdict == Verdict.PASS:
|
|
331
|
+
typer.secho("\n✓ The agent SAW the problems and fixed them — FAIL → PASS.",
|
|
332
|
+
fg=typer.colors.GREEN, bold=True)
|
|
333
|
+
typer.secho(" That is the whole point: eyes for AI agents.\n", fg=typer.colors.GREEN)
|
|
334
|
+
else:
|
|
335
|
+
typer.secho("\nDemo did not reach the expected FAIL→PASS arc.",
|
|
336
|
+
fg=typer.colors.YELLOW)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@app.command()
|
|
340
|
+
def doctor(fix: bool = typer.Option(False, "--fix", help="Install the Chromium browser.")):
|
|
341
|
+
"""Diagnose rendering + backend readiness."""
|
|
342
|
+
from .doctor import run_doctor
|
|
343
|
+
|
|
344
|
+
ok = asyncio.run(run_doctor(fix=fix))
|
|
345
|
+
raise typer.Exit(code=0 if ok else 1)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@app.command()
|
|
349
|
+
def serve(host: str = typer.Option("127.0.0.1"), port: int = typer.Option(8000)):
|
|
350
|
+
"""Start the REST service."""
|
|
351
|
+
from .rest import serve as do_serve
|
|
352
|
+
|
|
353
|
+
do_serve(host=host, port=port)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def main() -> None:
|
|
357
|
+
app()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
sys.exit(app())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""`agentvision doctor` — diagnose rendering + backend readiness.
|
|
2
|
+
|
|
3
|
+
Attempts a real Chromium launch and, on failure, runs ``ldd`` on the browser binary to
|
|
4
|
+
enumerate *all* missing system libraries at once (a launch exception only names the
|
|
5
|
+
first). Prints the right install command for the detected distro.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import platform
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from ..backends.registry import ALL_BACKENDS, build_backend
|
|
16
|
+
from ..config import load_settings
|
|
17
|
+
|
|
18
|
+
_OK = "\033[32m✓\033[0m"
|
|
19
|
+
_BAD = "\033[31m✗\033[0m"
|
|
20
|
+
_WARN = "\033[33m!\033[0m"
|
|
21
|
+
|
|
22
|
+
_DNF_LIBS = ("nss nspr atk at-spi2-atk at-spi2-core cups-libs libdrm libxkbcommon "
|
|
23
|
+
"libXcomposite libXdamage libXrandr libXfixes libXrender mesa-libgbm "
|
|
24
|
+
"pango cairo alsa-lib gtk3")
|
|
25
|
+
_APT_HINT = "playwright install --with-deps chromium (Debian/Ubuntu)"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _distro_install_hint(missing: list[str]) -> str:
|
|
29
|
+
if shutil.which("dnf"):
|
|
30
|
+
return f"sudo dnf install -y {_DNF_LIBS}"
|
|
31
|
+
if shutil.which("apt-get"):
|
|
32
|
+
return f"sudo {_APT_HINT}"
|
|
33
|
+
return f"Install the equivalent of: {_DNF_LIBS}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _check_chromium() -> tuple[bool, str]:
|
|
37
|
+
try:
|
|
38
|
+
from playwright.async_api import async_playwright
|
|
39
|
+
except ImportError:
|
|
40
|
+
return False, "playwright not installed — pip install 'agentvision[render]'"
|
|
41
|
+
try:
|
|
42
|
+
async with async_playwright() as pw:
|
|
43
|
+
exe = pw.chromium.executable_path
|
|
44
|
+
try:
|
|
45
|
+
browser = await pw.chromium.launch(
|
|
46
|
+
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
|
|
47
|
+
)
|
|
48
|
+
await browser.close()
|
|
49
|
+
return True, "Chromium launches"
|
|
50
|
+
except Exception as e: # noqa: BLE001
|
|
51
|
+
missing = _ldd_missing(exe)
|
|
52
|
+
hint = _distro_install_hint(missing)
|
|
53
|
+
detail = (f"missing libs: {', '.join(missing)}" if missing else str(e))
|
|
54
|
+
return False, f"Chromium will not launch ({detail}). Fix: {hint}"
|
|
55
|
+
except Exception as e: # noqa: BLE001
|
|
56
|
+
return False, f"Chromium not installed. Run: agentvision doctor --fix ({e})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ldd_missing(exe: str | None) -> list[str]:
|
|
60
|
+
if not exe or platform.system() != "Linux" or not shutil.which("ldd"):
|
|
61
|
+
return []
|
|
62
|
+
try:
|
|
63
|
+
out = subprocess.run(["ldd", exe], capture_output=True, text=True, timeout=15)
|
|
64
|
+
except (OSError, subprocess.SubprocessError):
|
|
65
|
+
return []
|
|
66
|
+
missing = []
|
|
67
|
+
for line in (out.stdout + out.stderr).splitlines():
|
|
68
|
+
if "not found" in line:
|
|
69
|
+
missing.append(line.strip().split()[0])
|
|
70
|
+
return sorted(set(missing))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def run_doctor(fix: bool = False) -> bool:
|
|
74
|
+
settings = load_settings()
|
|
75
|
+
print("AgentVision doctor\n" + "=" * 40)
|
|
76
|
+
|
|
77
|
+
if fix:
|
|
78
|
+
print("Installing Chromium browser …")
|
|
79
|
+
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"])
|
|
80
|
+
print()
|
|
81
|
+
|
|
82
|
+
ok = True
|
|
83
|
+
|
|
84
|
+
chromium_ok, msg = await _check_chromium()
|
|
85
|
+
print(f" {_OK if chromium_ok else _BAD} Rendering (Chromium): {msg}")
|
|
86
|
+
ok = ok and chromium_ok
|
|
87
|
+
|
|
88
|
+
# OCR
|
|
89
|
+
tess = shutil.which("tesseract")
|
|
90
|
+
print(f" {_OK if tess else _WARN} OCR (tesseract): "
|
|
91
|
+
+ (tess or "not found — install tesseract-ocr + tesseract-ocr-eng (optional)"))
|
|
92
|
+
|
|
93
|
+
# PDF
|
|
94
|
+
poppler = shutil.which("pdftoppm")
|
|
95
|
+
print(f" {_OK if poppler else _WARN} PDF (poppler): "
|
|
96
|
+
+ (poppler or "not found — install poppler-utils (optional)"))
|
|
97
|
+
|
|
98
|
+
# Backends
|
|
99
|
+
print("\n Vision backends:")
|
|
100
|
+
any_cloud = False
|
|
101
|
+
for name in ALL_BACKENDS:
|
|
102
|
+
try:
|
|
103
|
+
available = build_backend(name, settings).available()
|
|
104
|
+
except Exception: # noqa: BLE001
|
|
105
|
+
available = False
|
|
106
|
+
if name == "local":
|
|
107
|
+
print(f" {_OK} local (offline, always available)")
|
|
108
|
+
continue
|
|
109
|
+
if available:
|
|
110
|
+
any_cloud = True
|
|
111
|
+
print(f" {_OK} {name} (key present)")
|
|
112
|
+
else:
|
|
113
|
+
key_env = {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY",
|
|
114
|
+
"gemini": "GOOGLE_API_KEY"}.get(name, "")
|
|
115
|
+
print(f" {_WARN} {name} (set {key_env} to enable)")
|
|
116
|
+
|
|
117
|
+
print("\n" + "=" * 40)
|
|
118
|
+
if chromium_ok:
|
|
119
|
+
print("Ready. Try: agentvision demo"
|
|
120
|
+
+ ("" if any_cloud else " (set an API key for semantic analysis)"))
|
|
121
|
+
else:
|
|
122
|
+
print("Rendering is not ready — fix the Chromium item above, or use the Dockerfile.")
|
|
123
|
+
return ok
|