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.
Files changed (50) hide show
  1. agentvision/__init__.py +61 -0
  2. agentvision/adapters/__init__.py +1 -0
  3. agentvision/adapters/_demo_assets.py +35 -0
  4. agentvision/adapters/cli.py +361 -0
  5. agentvision/adapters/doctor.py +123 -0
  6. agentvision/adapters/mcp_server.py +156 -0
  7. agentvision/adapters/rest.py +174 -0
  8. agentvision/backends/__init__.py +9 -0
  9. agentvision/backends/_image.py +21 -0
  10. agentvision/backends/anthropic_backend.py +94 -0
  11. agentvision/backends/base.py +39 -0
  12. agentvision/backends/gemini_backend.py +79 -0
  13. agentvision/backends/local_backend.py +41 -0
  14. agentvision/backends/openai_backend.py +78 -0
  15. agentvision/backends/prompt.py +121 -0
  16. agentvision/backends/registry.py +68 -0
  17. agentvision/backends/schema_adapters.py +96 -0
  18. agentvision/config.py +93 -0
  19. agentvision/core/__init__.py +15 -0
  20. agentvision/core/analyze.py +129 -0
  21. agentvision/core/baseline.py +69 -0
  22. agentvision/core/capture.py +62 -0
  23. agentvision/core/checks/__init__.py +37 -0
  24. agentvision/core/checks/contrast.py +37 -0
  25. agentvision/core/checks/layout.py +96 -0
  26. agentvision/core/diff.py +115 -0
  27. agentvision/core/loop.py +132 -0
  28. agentvision/core/render.py +49 -0
  29. agentvision/errors.py +52 -0
  30. agentvision/logging.py +47 -0
  31. agentvision/models/__init__.py +19 -0
  32. agentvision/models/diff.py +26 -0
  33. agentvision/models/geometry.py +42 -0
  34. agentvision/models/report.py +140 -0
  35. agentvision/ocr/__init__.py +12 -0
  36. agentvision/ocr/base.py +27 -0
  37. agentvision/ocr/tesseract.py +60 -0
  38. agentvision/renderers/__init__.py +31 -0
  39. agentvision/renderers/_extract_js.py +107 -0
  40. agentvision/renderers/base.py +87 -0
  41. agentvision/renderers/image_renderer.py +42 -0
  42. agentvision/renderers/pdf_renderer.py +51 -0
  43. agentvision/renderers/playwright_renderer.py +257 -0
  44. agentvision/sources.py +143 -0
  45. agentvision/workspace.py +127 -0
  46. agentvision-0.1.0.dist-info/METADATA +199 -0
  47. agentvision-0.1.0.dist-info/RECORD +50 -0
  48. agentvision-0.1.0.dist-info/WHEEL +4 -0
  49. agentvision-0.1.0.dist-info/entry_points.txt +4 -0
  50. agentvision-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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