Spaces:
Running
Running
| """ | |
| UICopilot CLI — analyze a screenshot or HTML file from the command line. | |
| Usage: | |
| python backend/cli.py check screenshot.png | |
| python backend/cli.py check page.html --threshold 60 | |
| python backend/cli.py check screenshot.png --format json --out report.json | |
| python backend/cli.py check screenshot.png --device mobile --no-attention | |
| Exit codes: | |
| 0 — score >= threshold (PASS) | |
| 1 — score < threshold (FAIL) | |
| 2 — input error / file not found | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import pathlib | |
| import sys | |
| if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf-16"): | |
| sys.stdout.reconfigure(encoding="utf-8", errors="replace") | |
| _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"} | |
| _HTML_EXTS = {".html", ".htm"} | |
| def _die(msg: str, code: int = 2) -> None: | |
| print(f"ERROR: {msg}", file=sys.stderr) | |
| sys.exit(code) | |
| def _build_parser() -> argparse.ArgumentParser: | |
| ap = argparse.ArgumentParser( | |
| prog="uicopilot", | |
| description="UICopilot — UI quality analysis from the command line", | |
| ) | |
| sub = ap.add_subparsers(dest="cmd", metavar="COMMAND") | |
| ck = sub.add_parser("check", help="Analyze a file and exit non-zero if score < threshold") | |
| ck.add_argument("file", help="Screenshot (PNG/JPG/WebP) or HTML file to analyze") | |
| ck.add_argument("--threshold", type=int, default=70, | |
| help="Minimum passing score, 0-100 (default: 70)") | |
| ck.add_argument("--format", choices=["text", "json"], default="text", | |
| help="Output format (default: text)") | |
| ck.add_argument("--out", metavar="FILE", | |
| help="Write JSON report to this file (in addition to stdout)") | |
| ck.add_argument("--device", choices=["auto", "mobile", "desktop"], default="auto", | |
| help="Device type hint for screenshots (default: auto)") | |
| ck.add_argument("--no-attention", action="store_true", | |
| help="Skip attention heatmap computation (faster)") | |
| return ap | |
| def _analyze(path: pathlib.Path, device: str, include_attention: bool) -> dict: | |
| from backend.analyzers import html_analyzer, screenshot_analyzer | |
| from backend.services import attention_service, scoring_engine | |
| ext = path.suffix.lower() | |
| if ext in _HTML_EXTS: | |
| html = path.read_text(encoding="utf-8", errors="replace") | |
| parsed = html_analyzer.parse(html) | |
| image_bytes = None | |
| elif ext in _IMAGE_EXTS: | |
| image_bytes = path.read_bytes() | |
| parsed = screenshot_analyzer.analyze(image_bytes, device_hint=device) | |
| else: | |
| _die(f"Unsupported file type '{ext}'. Use {sorted(_IMAGE_EXTS | _HTML_EXTS)}") | |
| result = scoring_engine.analyze(parsed) | |
| out: dict = { | |
| "file": str(path), | |
| "score": round(result.overall_score, 1), | |
| "issue_count": len(result.issues), | |
| "category_scores": { | |
| cs.category.value: round(cs.score, 1) | |
| for cs in result.category_scores | |
| }, | |
| "top_issues": [ | |
| { | |
| "rule_id": i.rule_id, | |
| "severity": i.severity.value, | |
| "message": i.message, | |
| "gain": round(i.estimated_gain, 1), | |
| "time": i.estimated_time, | |
| } | |
| for i in result.top_issues[:5] | |
| ], | |
| "quick_wins": [ | |
| {"rule_id": i.rule_id, "message": i.message, "gain": round(i.estimated_gain, 1)} | |
| for i in result.quick_wins[:3] | |
| ], | |
| } | |
| if include_attention and image_bytes is not None: | |
| attn = attention_service.analyze(image_bytes) | |
| out["focus_score"] = attn["focus_score"] | |
| out["attention_regions"] = len(attn.get("top_regions", [])) | |
| return out | |
| def _print_text(out: dict, threshold: int) -> None: | |
| score = out["score"] | |
| passed = score >= threshold | |
| status = "PASS" if passed else "FAIL" | |
| mark = "+" if passed else "-" | |
| print(f"\nUICopilot {out['file']}") | |
| print("─" * 52) | |
| print(f" Score {score}/100 (threshold: {threshold}) [{status}]") | |
| print(f" Issues {out['issue_count']}") | |
| if "focus_score" in out: | |
| print(f" Focus {out['focus_score']}/100") | |
| if out["top_issues"]: | |
| print("\n Top issues:") | |
| for i in out["top_issues"]: | |
| sev = i["severity"].upper() | |
| print(f" [{sev:<8}] +{i['gain']:.1f}pt {i['message']}") | |
| if out["quick_wins"]: | |
| print("\n Quick wins (fix first):") | |
| for w in out["quick_wins"]: | |
| print(f" +{w['gain']:.1f}pt {w['message']}") | |
| print("\n Category scores:") | |
| for cat, s in sorted(out["category_scores"].items(), key=lambda x: x[1]): | |
| bar = int(s / 5) | |
| print(f" {cat.replace('_',' '):<24} {'█'*bar}{'░'*(20-bar)} {s}") | |
| print(f"\n {mark * 3} {status} — {score}/100 (threshold {threshold}) {mark * 3}\n") | |
| def cmd_check(args: argparse.Namespace) -> None: | |
| path = pathlib.Path(args.file) | |
| if not path.exists(): | |
| _die(f"File not found: {path}") | |
| include_attention = not args.no_attention | |
| out = _analyze(path, args.device, include_attention) | |
| out["threshold"] = args.threshold | |
| out["passed"] = out["score"] >= args.threshold | |
| if args.out: | |
| pathlib.Path(args.out).write_text( | |
| json.dumps(out, indent=2), encoding="utf-8" | |
| ) | |
| if args.format == "json": | |
| print(json.dumps(out, indent=2)) | |
| else: | |
| _print_text(out, args.threshold) | |
| sys.exit(0 if out["passed"] else 1) | |
| def main() -> None: | |
| ap = _build_parser() | |
| args = ap.parse_args() | |
| if args.cmd == "check": | |
| cmd_check(args) | |
| else: | |
| ap.print_help() | |
| sys.exit(2) | |
| if __name__ == "__main__": | |
| main() | |