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()