Spaces:
Sleeping
Sleeping
File size: 11,252 Bytes
58e6885 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | """
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:
- <output-dir>/_tasks.csv per-task report (one row per (tpl, data))
- <output-dir>/_summary.csv per-template rollup, worst-first
- <output-dir>/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"<text\b", content))
return n_shapes, n_text
def _run_one(args_tuple):
"""Run one (template, data_file) job. Returns a dict of metrics."""
chart_name, input_path, data_key, template_out_dir, base_url, api_key = args_tuple
# Lazy import inside the worker (after worker_init sets env).
from modules.infographics_generator.infographics_generator import process
input_basename = os.path.basename(input_path)
# process() expects output like "<dir>/<basename>" - it will create
# a "<timestamp>_<chart_name>_<basename_stem>/" subfolder next to it
# AND write a "<timestamp>_<chart_name>_<basename_stem>.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 "<dir>/<filename>") 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 "<dir-name>/<filename>" -> 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()
|