from __future__ import annotations import argparse import hashlib import html import json import mimetypes import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, quote, unquote, urlparse WORKSPACE_ROOT = Path(__file__).resolve().parents[1] DEFAULT_SCAN_ROOTS = [ WORKSPACE_ROOT / "output", WORKSPACE_ROOT / "shared_output" / "personal" / "liduan", ] RUN_MARKERS = ( "comparison_summary.json", "comparison.jsonl", "manifest.jsonl", "batch_report.json", "experiment_summary.json", "summary.json", "preview.html", "run_config.json", ) IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"} TEXT_SUFFIXES = {".json", ".jsonl", ".csv", ".txt", ".log"} MAX_JSONL_ITEMS = 3000 def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Serve a browser UI for generated ChartPipeline outputs.") parser.add_argument("--host", default="0.0.0.0", help="Bind host.") parser.add_argument("--port", type=int, default=8787, help="Bind port.") parser.add_argument( "--scan-root", action="append", type=Path, help="Directory to scan for output runs. Can be passed multiple times.", ) parser.add_argument("--max-depth", type=int, default=3, help="Directory scan depth below each scan root.") return parser.parse_args() def safe_resolve(path: Path) -> Path: return path.expanduser().resolve() def read_json(path: Path) -> dict: try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} return data if isinstance(data, dict) else {} def read_jsonl(path: Path, *, limit: int = MAX_JSONL_ITEMS) -> tuple[list[dict], int, bool]: rows: list[dict] = [] total = 0 truncated = False try: with path.open("r", encoding="utf-8") as handle: for line in handle: if not line.strip(): continue total += 1 if len(rows) >= limit: truncated = True continue try: payload = json.loads(line) except json.JSONDecodeError: continue if isinstance(payload, dict): rows.append(payload) except OSError: return [], 0, False return rows, total, truncated def path_mtime_ns(path: Path) -> int: try: return path.stat().st_mtime_ns except OSError: return 0 def path_size(path: Path) -> int: try: return path.stat().st_size except OSError: return 0 def iter_dirs(root: Path, max_depth: int) -> list[Path]: dirs: list[Path] = [] stack: list[tuple[Path, int]] = [(root, 0)] while stack: current, depth = stack.pop() dirs.append(current) if depth >= max_depth: continue try: children = list(current.iterdir()) except OSError: continue for child in children: if child.name.startswith(".") or child.is_symlink(): continue try: if child.is_dir(): stack.append((child, depth + 1)) except OSError: continue return dirs def discover_run_dirs(root: Path, max_depth: int) -> list[Path]: runs: list[Path] = [] stack: list[tuple[Path, int]] = [(root, 0)] while stack: current, depth = stack.pop() if looks_like_run(current): runs.append(current) continue if depth >= max_depth: continue try: children = list(current.iterdir()) except OSError: continue for child in children: if child.name.startswith(".") or child.is_symlink(): continue try: if child.is_dir(): stack.append((child, depth + 1)) except OSError: continue return runs def looks_like_run(path: Path) -> bool: if any((path / marker).is_file() for marker in RUN_MARKERS): return True try: return any(child.is_dir() and (child / "report.json").is_file() for child in path.iterdir()) except OSError: return False def is_under_seen_root(path: Path, seen: set[Path]) -> bool: for root in seen: try: path.relative_to(root) return True except ValueError: continue return False def run_mtime_ns(path: Path) -> int: latest = path_mtime_ns(path) for marker in RUN_MARKERS: latest = max(latest, path_mtime_ns(path / marker)) try: children = list(path.iterdir()) except OSError: return latest for child in children[:250]: latest = max(latest, path_mtime_ns(child)) if child.is_dir(): latest = max( latest, path_mtime_ns(child / "report.json"), path_mtime_ns(child / "task_status.json"), ) return latest def classify_run(root: Path) -> str: if (root / "comparison.jsonl").is_file() or (root / "comparison_summary.json").is_file(): return "comparison" if (root / "manifest.jsonl").is_file(): return "manifest" if (root / "experiment_summary.json").is_file(): return "fci-experiment" if (root / "batch_report.json").is_file() or any(root.glob("*/report.json")): return "fci-batch" if (root / "preview.html").is_file(): return "preview" return "run" class InspectorState: def __init__(self, scan_roots: list[Path], max_depth: int) -> None: self.scan_roots = [] seen_roots: set[Path] = set() for root in scan_roots: resolved = safe_resolve(root) if resolved in seen_roots: continue seen_roots.add(resolved) self.scan_roots.append(resolved) self.max_depth = max_depth self._runs_cache: list[Path] = [] self._runs_cache_until = 0.0 @property def allowed_roots(self) -> list[Path]: roots = [ *self.scan_roots, safe_resolve(WORKSPACE_ROOT / "output"), safe_resolve(WORKSPACE_ROOT / "shared_output"), ] unique: list[Path] = [] seen: set[Path] = set() for root in roots: if root in seen or not root.exists(): continue seen.add(root) unique.append(root) return unique def runs(self, *, force: bool = False) -> list[Path]: now = time.time() if not force and now < self._runs_cache_until: return list(self._runs_cache) found: list[Path] = [] seen: set[Path] = set() for scan_root in self.scan_roots: if not scan_root.is_dir(): continue for candidate in discover_run_dirs(scan_root, self.max_depth): resolved = safe_resolve(candidate) if resolved in seen or is_under_seen_root(resolved, seen): continue seen.add(resolved) found.append(resolved) found.sort(key=lambda path: (run_mtime_ns(path), str(path)), reverse=True) self._runs_cache = found self._runs_cache_until = now + 8.0 return found def resolve_run(self, raw_path: str) -> Path | None: if not raw_path: return None candidate = self.resolve_path(raw_path, require_file=False) if candidate is None or not candidate.is_dir(): return None for run in self.runs(): if candidate == run: return candidate return None def resolve_path(self, raw_path: str, *, require_file: bool = True) -> Path | None: raw_path = unquote(raw_path or "") if not raw_path: return None raw = Path(raw_path) candidates: list[Path] = [] if raw.is_absolute(): candidates.append(safe_resolve(raw)) else: candidates.append(safe_resolve(WORKSPACE_ROOT / raw)) for root in self.scan_roots: candidates.append(safe_resolve(root / raw)) for candidate in candidates: if require_file and not candidate.is_file(): continue if not require_file and not candidate.exists(): continue if self.is_allowed(candidate): return candidate return None def is_allowed(self, path: Path) -> bool: for root in self.allowed_roots: try: path.relative_to(root) return True except ValueError: continue return False def file_entry(self, label: str, raw_path: object) -> dict | None: if not raw_path: return None raw = str(raw_path) resolved = self.resolve_path(raw) suffix = Path(raw).suffix.lower() file_type = "image" if suffix in IMAGE_SUFFIXES else "html" if suffix == ".html" else "text" entry = { "label": label, "path": raw, "type": file_type, "exists": resolved is not None, "url": "", "size": 0, "version": "", } if resolved is not None: stat = resolved.stat() entry.update( { "path": str(resolved), "url": self.web_url(resolved), "size": stat.st_size, "version": f"{stat.st_mtime_ns}-{stat.st_size}", } ) return entry def web_url(self, path: Path) -> str: resolved = safe_resolve(path) return "/fs/" + quote(str(resolved).lstrip("/"), safe="/") def run_info(state: InspectorState, root: Path) -> dict: markers = [marker for marker in RUN_MARKERS if (root / marker).is_file()] kind = classify_run(root) summary = read_json(root / "comparison_summary.json") or read_json(root / "summary.json") config = read_json(root / "run_config.json") preview = state.file_entry("Preview", root / "preview.html") item_count = estimate_item_count(root, summary, kind) latest = run_mtime_ns(root) return { "id": str(root), "name": root.name, "path": str(root), "kind": kind, "markers": markers, "summary": compact_summary(summary), "config": compact_config(config), "item_count": item_count, "mtime_ns": latest, "updated": latest / 1_000_000_000 if latest else 0, "preview": preview if preview and preview.get("exists") else None, } def estimate_item_count(root: Path, summary: dict, kind: str) -> int: if isinstance(summary.get("total_samples"), int): return int(summary["total_samples"]) if kind == "comparison" and (root / "comparison.jsonl").is_file(): _, total, _ = read_jsonl(root / "comparison.jsonl", limit=0) return total if (root / "manifest.jsonl").is_file(): _, total, _ = read_jsonl(root / "manifest.jsonl", limit=0) return total return len(report_task_dirs(root)) def compact_summary(summary: dict) -> dict: if not summary: return {} keys = ( "total_samples", "total_templates", "status_counts", "fallback_counts", "empty_chart_counts", "png_diff_counts", ) return {key: summary[key] for key in keys if key in summary} def compact_config(config: dict) -> dict: if not config: return {} keys = ( "baseline_root", "output_root", "samples_per_template", "workers", "rerun_failures", "chart_only", "slot_polish_after_chart", "planned_slot_polish", "planned_slot_dry_run", "limit", ) return {key: config[key] for key in keys if key in config} def run_items(state: InspectorState, root: Path, *, limit: int = MAX_JSONL_ITEMS) -> dict: if (root / "comparison.jsonl").is_file(): rows, total, truncated = read_jsonl(root / "comparison.jsonl", limit=limit) items = [comparison_item(state, row, index) for index, row in enumerate(rows)] return {"source": "comparison.jsonl", "total": total, "truncated": truncated, "items": items} if (root / "manifest.jsonl").is_file(): rows, total, truncated = read_jsonl(root / "manifest.jsonl", limit=limit) items = [manifest_item(state, row, index) for index, row in enumerate(rows)] return {"source": "manifest.jsonl", "total": total, "truncated": truncated, "items": items} report_dirs = report_task_dirs(root) return { "source": "report.json", "total": len(report_dirs), "truncated": len(report_dirs) > limit, "items": [report_item(state, task_dir, index) for index, task_dir in enumerate(report_dirs[:limit])], } def report_task_dirs(root: Path) -> list[Path]: if (root / "experiment_summary.json").is_file(): dirs: list[Path] = [] try: variant_dirs = [path for path in root.iterdir() if path.is_dir()] except OSError: return [] for variant_dir in variant_dirs: try: dirs.extend(path for path in variant_dir.iterdir() if path.is_dir() and (path / "report.json").is_file()) except OSError: continue return sorted(dirs, key=lambda path: str(path)) try: return sorted( (path for path in root.iterdir() if path.is_dir() and (path / "report.json").is_file()), key=lambda path: str(path), ) except OSError: return [] def status_class(value: object) -> str: text = str(value).lower() if "regressed" in text or "failure" in text or text in {"false", "failed"}: return "bad" if "fixed" in text: return "warn" if "success" in text or text in {"true", "same_success", "passed"}: return "good" return "neutral" def comparison_item(state: InspectorState, row: dict, index: int) -> dict: status = row.get("status_change") if not status: status = "current_success" if row.get("current_success") else "current_failed" files = [ state.file_entry("Baseline final", row.get("baseline_final_png")), state.file_entry("Current final", row.get("current_final_png")), state.file_entry("Baseline chart", row.get("baseline_chart_svg")), state.file_entry("Current chart", row.get("current_chart_svg")), state.file_entry("Baseline SVG", row.get("baseline_final_svg")), state.file_entry("Current SVG", row.get("current_final_svg")), ] metrics = { "png_mae": row.get("png_mae"), "png_rmse": row.get("png_rmse"), "baseline_success": row.get("baseline_success"), "current_success": row.get("current_success"), "empty_chart_change": row.get("empty_chart_change"), "fallback_change": row.get("fallback_change"), "current_chart_visible": row.get("current_chart_visible"), "current_chart_text": row.get("current_chart_text"), "current_chart_shapes": row.get("current_chart_shapes"), } return { "id": f"comparison-{index}", "title": row.get("chart_name") or last_token(row.get("template_key")) or f"row {index}", "subtitle": f"{row.get('template_key', '-')}, sample {row.get('sample_index', '-')}", "status": status, "status_class": status_class(status), "metrics": metrics, "files": [file for file in files if file], "raw": row, } def manifest_item(state: InspectorState, row: dict, index: int) -> dict: status = "success" if row.get("success") or row.get("pipeline_success") else "failed" if row.get("planned_slot_polish"): status = f"{status} / planned_slot:{row.get('planned_slot_success')}" elif row.get("slot_polish_after_chart"): status = f"{status} / slot_polish:{row.get('slot_polish_success')}" files = [ state.file_entry("Final PNG", row.get("final_png")), state.file_entry("Planned polished PNG", row.get("planned_slot_polished_png")), state.file_entry("Slot polished PNG", row.get("slot_polished_png")), state.file_entry("Chart SVG", row.get("chart_svg")), state.file_entry("Final SVG", row.get("final_svg")), state.file_entry("Plan JSON", row.get("planned_slot_plan")), state.file_entry("Reference map", row.get("planned_slot_reference_map")), state.file_entry("Manifest", row.get("planned_slot_manifest") or row.get("slot_polish_manifest")), ] metrics = { "success": row.get("success"), "pipeline_success": row.get("pipeline_success"), "planned_slot_success": row.get("planned_slot_success"), "slot_polish_success": row.get("slot_polish_success"), "chart_empty": row.get("chart_empty"), "chart_visible": row.get("chart_visible"), "chart_text": row.get("chart_text"), "chart_shapes": row.get("chart_shapes"), "total_seconds": row.get("total_seconds"), } return { "id": f"manifest-{index}", "title": last_token(row.get("template_key")) or f"row {index}", "subtitle": f"{row.get('template_key', '-')}, sample {row.get('sample_index', '-')}", "status": status, "status_class": status_class(status), "metrics": metrics, "files": [file for file in files if file], "raw": row, } def report_item(state: InspectorState, task_dir: Path, index: int) -> dict: report = read_json(task_dir / "report.json") status = read_json(task_dir / "task_status.json") export = report.get("export", {}) best = report.get("best_candidate", {}) files = [ state.file_entry("Final composite", export.get("final_composite") or task_dir / "final_composite.png"), state.file_entry("Background", export.get("background_candidate") or task_dir / "background_candidate.png"), state.file_entry("Condition", task_dir / "condition_canvas.png"), state.file_entry("Rendered SVG", task_dir / "rendered_from_svg.png"), state.file_entry("Highlight difference", export.get("highlight_difference") or task_dir / "highlight_difference.png"), state.file_entry("Report", task_dir / "report.json"), ] state_text = status.get("state") or ("completed" if report else "unknown") return { "id": f"report-{index}", "title": report.get("task_id") or status.get("task_id") or task_dir.name, "subtitle": str(task_dir), "status": state_text, "status_class": status_class(state_text), "metrics": { "pipeline_mode": report.get("runtime", {}).get("pipeline_mode"), "variant_id": report.get("runtime", {}).get("variant_id"), "overall": best.get("score", {}).get("overall"), "elapsed_seconds": report.get("runtime", {}).get("elapsed_seconds") or status.get("elapsed_seconds"), }, "files": [file for file in files if file], "raw": {"report": report, "status": status}, } def last_token(value: object) -> str: text = str(value or "") if not text: return "" return text.replace("\\", "/").split("/")[-1] class InspectorHandler(BaseHTTPRequestHandler): state: InspectorState index_version: str def log_message(self, format: str, *args: object) -> None: print(f"[result-inspector] {self.address_string()} - {format % args}") def do_GET(self) -> None: parsed = urlparse(self.path) if parsed.path == "/": self.send_html(INDEX_HTML) return if parsed.path == "/api/version": self.send_json({"version": self.index_version}) return if parsed.path == "/api/runs": roots = self.state.runs(force=True) self.send_json( { "scan_roots": [str(root) for root in self.state.scan_roots], "runs": [run_info(self.state, root) for root in roots], } ) return if parsed.path == "/api/run-items": params = parse_qs(parsed.query) raw_root = params.get("root", [""])[0] limit = parse_int(params.get("limit", ["3000"])[0], MAX_JSONL_ITEMS) root = self.state.resolve_run(raw_root) if root is None: self.send_error(404, "run not found") return self.send_json({"root": run_info(self.state, root), **run_items(self.state, root, limit=limit)}) return if parsed.path == "/asset": params = parse_qs(parsed.query) raw_path = params.get("path", [""])[0] resolved = self.state.resolve_path(raw_path) if resolved is None: self.send_error(404, "asset not found or outside scan roots") return self.send_file(resolved) return if parsed.path.startswith("/fs/"): raw = "/" + unquote(parsed.path[len("/fs/") :]) resolved = self.state.resolve_path(raw) if resolved is None: self.send_error(404, "file not found or outside scan roots") return self.send_file(resolved) return self.send_error(404) def send_json(self, payload: dict) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def send_html(self, body: str) -> None: encoded = body.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(encoded))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(encoded) def send_file(self, path: Path) -> None: stat = path.stat() etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"' if self.headers.get("If-None-Match") == etag: self.send_response(304) self.send_header("ETag", etag) self.end_headers() return mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" body = path.read_bytes() self.send_response(200) self.send_header("Content-Type", mime) self.send_header("Content-Length", str(len(body))) self.send_header("ETag", etag) self.send_header("Last-Modified", self.date_time_string(stat.st_mtime)) self.send_header("Cache-Control", "public, max-age=60") self.end_headers() self.wfile.write(body) def parse_int(value: str, fallback: int) -> int: try: parsed = int(value) except (TypeError, ValueError): return fallback return max(1, min(parsed, MAX_JSONL_ITEMS)) INDEX_HTML = r""" Pipeline Result Inspector

Pipeline Result Inspector

Loading...
""" def main() -> None: args = parse_args() scan_roots = args.scan_root or DEFAULT_SCAN_ROOTS state = InspectorState(scan_roots, args.max_depth) version = hashlib.sha256(INDEX_HTML.encode("utf-8")).hexdigest()[:16] handler = type("PipelineResultInspectorHandler", (InspectorHandler,), {"state": state, "index_version": version}) server = ThreadingHTTPServer((args.host, args.port), handler) print(f"Serving result inspector at http://{args.host}:{args.port}") print("Scan roots: " + ", ".join(html.escape(str(root)) for root in state.scan_roots)) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() if __name__ == "__main__": main()