""" Per-template quality-check driver. Reads a plan JSON (produced by pick_smoke_templates.py), runs infographics_generator.process(input, output, "", "", chart_name) for each (template, data) pair in a process pool, and incrementally writes: - /_tasks.csv per-task report (one row per (tpl, data)) - /_summary.csv per-template rollup, worst-first - /PROGRESS.md live status, ETA, per-tpl table Resume is supported: if --resume is passed and _tasks.csv already has rows, those (chart_name, input) pairs are skipped on this run. Usage: python scripts/run_quality_check.py \\ --plan scripts/_smoke_templates.json \\ --output-dir output/quality_check \\ --threads 8 """ import argparse import json import os import sys import time import traceback from concurrent.futures import ProcessPoolExecutor, as_completed from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from scripts._progress import ProgressTracker def parse_args(): p = argparse.ArgumentParser() p.add_argument("--plan", default="scripts/_smoke_templates.json") p.add_argument("--data-dir", nargs="+", default=None, help="Override data dir(s) (defaults to plan.data_dirs / data_dir)") p.add_argument("--output-dir", default="output/quality_check") p.add_argument("--threads", type=int, default=8) p.add_argument("--chrome-path", default="/usr/bin/google-chrome", help="Chrome executable for puppeteer (sets PUPPETEER_EXECUTABLE_PATH)") p.add_argument("--limit", type=int, default=None, help="Optional cap on total tasks (for ultra-quick smoke)") p.add_argument("--resume", action="store_true", help="Read existing _tasks.csv and skip already-done jobs") p.add_argument("--match-csv", default="scripts/_template_match.csv", help="Used to list skipped templates in PROGRESS.md") p.add_argument("--min-data-for-skipped", type=int, default=5, help="Show skipped templates whose match count is below this") return p.parse_args() def _worker_init(chrome_path: str): # Each worker process inherits its own env; set puppeteer's chrome here. os.environ.setdefault("PUPPETEER_EXECUTABLE_PATH", chrome_path) # Reduce torch / faiss threading noise inside workers. os.environ.setdefault("OMP_NUM_THREADS", "1") os.environ.setdefault("MKL_NUM_THREADS", "1") # FALLBACK_MARKER mirrors what html_to_svg.py writes when puppeteer cannot # extract a real SVG from the DOM. Same string used by the loader to flag it. FALLBACK_MARKER = "This is a fallback SVG using a PNG screenshot" def _is_fallback_svg(svg_path: str) -> bool: if not os.path.exists(svg_path): return False with open(svg_path, "r", encoding="utf-8", errors="ignore") as fh: head = fh.read(4096) return FALLBACK_MARKER in head def _count_svg_elements(svg_path: str): """Cheap structural metric: rough count of drawing/text tags. Doesn't fully parse; just regex-counts common shape and text tags. Good enough as a sanity signal for "is this SVG basically empty?". """ if not os.path.exists(svg_path): return 0, 0 import re with open(svg_path, "r", encoding="utf-8", errors="ignore") as fh: content = fh.read() shape_tags = ("path", "rect", "circle", "line", "polygon", "polyline", "ellipse", "use") n_shapes = sum(len(re.findall(rf"<{t}\b", content)) for t in shape_tags) n_text = len(re.findall(r"/" - it will create # a "__/" subfolder next to it # AND write a "__.svg" SVG. output_path = os.path.join(template_out_dir, input_basename) t0 = time.time() ok = False err_msg = "" try: ok = process( input=input_path, output=output_path, base_url=base_url, api_key=api_key, chart_name=chart_name, ) except BaseException as e: err_msg = f"{type(e).__name__}: {e}" tb = traceback.format_exc() err_msg = (err_msg + " | " + tb.splitlines()[-1])[:300] elapsed = time.time() - t0 # Find the final SVG that was produced (most-recent matching file). stem = os.path.splitext(input_basename)[0] final_svg = None final_svg_size = 0 chart_svg_path = None chart_svg_is_fallback = False n_paths = 0 # actually n_shapes; legacy local name n_text = 0 if os.path.isdir(template_out_dir): candidates = [ f for f in os.listdir(template_out_dir) if f.endswith(f"_{chart_name}_{stem}.svg") ] if candidates: candidates.sort() final_svg = os.path.join(template_out_dir, candidates[-1]) final_svg_size = os.path.getsize(final_svg) n_paths, n_text = _count_svg_elements(final_svg) # The per-chart subfolder also contains the raw chart SVG. sub_candidates = [ d for d in os.listdir(template_out_dir) if os.path.isdir(os.path.join(template_out_dir, d)) and d.endswith(f"_{chart_name}_{stem}") ] if sub_candidates: sub_candidates.sort() chart_subdir = os.path.join(template_out_dir, sub_candidates[-1]) chart_svg_path = os.path.join(chart_subdir, "chart.svg") chart_svg_is_fallback = _is_fallback_svg(chart_svg_path) return { "chart_name": chart_name, # Use the plan-level key (may include "/") so resume # matches across multi-pool plans. Falls back to bare basename when # the caller didn't provide one. "input": data_key or input_basename, "ok": bool(ok), "elapsed_s": round(elapsed, 2), "final_svg": final_svg or "", "final_svg_bytes": final_svg_size, "chart_svg": chart_svg_path or "", "chart_svg_fallback_png": chart_svg_is_fallback, "n_shapes": n_paths, "n_text": n_text, "err": err_msg, } def main(): args = parse_args() with open(args.plan, "r") as fh: plan = json.load(fh) # Resolve data dirs: CLI override > plan.data_dirs > legacy plan.data_dir. if args.data_dir: data_dirs = [Path(p) for p in args.data_dir] elif "data_dirs" in plan: data_dirs = [Path(p) for p in plan["data_dirs"]] else: data_dirs = [Path(plan["data_dir"])] for d in data_dirs: if not d.is_dir(): raise SystemExit(f"data dir is not a directory: {d}") # Map "/" -> absolute path, falling back to # bare basenames when an old plan was generated against a single dir. file_lookup: dict[str, str] = {} for d in data_dirs: for f in d.glob("*.json"): file_lookup.setdefault(f.name, str(f)) file_lookup[f"{d.name}/{f.name}"] = str(f) def resolve_data(rel: str) -> str: if rel in file_lookup: return file_lookup[rel] bn = os.path.basename(rel) if bn in file_lookup: return file_lookup[bn] raise FileNotFoundError( f"plan references data file '{rel}' not found in any of: " + ", ".join(str(d) for d in data_dirs) ) output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) # Ensure parent process also has chrome path set so any in-proc work works. os.environ.setdefault("PUPPETEER_EXECUTABLE_PATH", args.chrome_path) tracker = ProgressTracker( plan=plan, output_dir=output_dir, match_csv_path=Path(args.match_csv) if args.match_csv else None, min_data_for_plan=args.min_data_for_skipped, ) tracker.open_csv(resume=args.resume) # Build job list, skipping already-done ones when --resume. jobs = [] skipped_resume = 0 for tpl in plan["templates"]: chart_name = tpl["chart_name"] template_out_dir = output_dir / chart_name template_out_dir.mkdir(parents=True, exist_ok=True) for data_basename in tpl["picked_data_files"]: if tracker.already_done(chart_name, data_basename): skipped_resume += 1 continue input_path = resolve_data(data_basename) jobs.append((chart_name, input_path, data_basename, str(template_out_dir), "", "")) if args.limit: jobs = jobs[: args.limit] print( f"Plan: templates={len(plan['templates'])} " f"total-tasks={tracker.total_tasks} resume-skipped={skipped_resume} " f"to-run={len(jobs)} threads={args.threads}" ) # Persist an initial PROGRESS.md so the file exists right away. tracker.flush() t_start = time.time() with ProcessPoolExecutor( max_workers=args.threads, initializer=_worker_init, initargs=(args.chrome_path,), ) as ex: futures = [ex.submit(_run_one, j) for j in jobs] done = 0 for fut in as_completed(futures): res = fut.result() tracker.add(res) done += 1 elapsed = time.time() - t_start rate = done / max(elapsed, 1e-6) remaining = (len(jobs) - done) / max(rate, 1e-6) ok_str = "T" if res["ok"] else "F" print( f"[{done:4d}/{len(jobs):4d}] " f"ok={ok_str} " f"{res['chart_name']:38s} " f"data={res['input'][:32]:32s} " f"t={res['elapsed_s']:5.1f}s " f"size={res['final_svg_bytes']/1024:5.0f}KB " f"shapes={res['n_shapes']:4d} " f"| eta {remaining/60:.1f}min", flush=True, ) tracker.close() total_t = time.time() - t_start print() print(f"Wrote per-task report: {tracker.tasks_csv}") print(f"Wrote per-template summary: {tracker.summary_csv}") print(f"Wrote progress doc: {tracker.progress_md}") print(f"Total time this session: {total_t/60:.1f} min") # Always (re)generate the HTML preview at the end of a session so the # user has a single browsable artifact. Failure to build the preview # shouldn't fail the whole run. preview_path = output_dir / "preview.html" try: from scripts.build_quality_preview import build_preview build_preview(output_dir, preview_path) print(f"Wrote HTML preview: {preview_path}") except Exception as e: print(f"WARN: failed to build HTML preview: {e}") if __name__ == "__main__": main()