Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import json | |
| import os | |
| import sys | |
| import time | |
| from collections import Counter | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from pathlib import Path | |
| from typing import Any | |
| from lxml import etree | |
| ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(ROOT)) | |
| os.chdir(ROOT) | |
| from modules.slot_layout_planner.chart_sanitizer import ( # noqa: E402 | |
| DATA_BEARING_ATTRS, | |
| DATA_BEARING_ROLE_VALUES, | |
| sanitize_chart_svg, | |
| ) | |
| from scripts.generate_template_samples import ( # noqa: E402 | |
| configure_chart_type_filter, | |
| nested_chart_svg, | |
| run_sample, | |
| safe_slug, | |
| ) | |
| GEOMETRY_ATTRS = ( | |
| "x", | |
| "y", | |
| "x1", | |
| "y1", | |
| "x2", | |
| "y2", | |
| "cx", | |
| "cy", | |
| "r", | |
| "rx", | |
| "ry", | |
| "width", | |
| "height", | |
| "d", | |
| ) | |
| def read_jsonl(path: Path) -> list[dict[str, Any]]: | |
| with open(path, "r", encoding="utf-8") as f: | |
| return [json.loads(line) for line in f if line.strip()] | |
| def append_jsonl(path: Path, record: dict[str, Any]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with open(path, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(record, ensure_ascii=False) + "\n") | |
| def write_json(path: Path, payload: Any) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with open(path, "w", encoding="utf-8") as f: | |
| json.dump(payload, f, indent=2, ensure_ascii=False) | |
| def local_name(elem: etree._Element) -> str: | |
| return etree.QName(elem).localname if isinstance(elem.tag, str) else "" | |
| def normalized_role(elem: etree._Element) -> str: | |
| role = str(elem.get("data-role") or "").strip().lower().replace("_", " ").replace("-", " ") | |
| return " ".join(role.split()) | |
| def is_data_bearing(elem: etree._Element) -> bool: | |
| for attr in DATA_BEARING_ATTRS: | |
| value = elem.get(attr) | |
| if value is not None and str(value).strip(): | |
| return True | |
| role = normalized_role(elem) | |
| return role in DATA_BEARING_ROLE_VALUES | |
| def data_signature(elem: etree._Element) -> str: | |
| attrs: dict[str, str] = { | |
| "tag": local_name(elem), | |
| "class": str(elem.get("class") or ""), | |
| "id": str(elem.get("id") or ""), | |
| } | |
| for name, value in sorted(elem.attrib.items()): | |
| if name.startswith("data-") or name in GEOMETRY_ATTRS: | |
| attrs[name] = str(value) | |
| text = "".join(elem.itertext()).strip() | |
| if text: | |
| attrs["text"] = " ".join(text.split()) | |
| return json.dumps(attrs, sort_keys=True, ensure_ascii=False) | |
| def data_signatures(svg_path: Path) -> Counter[str]: | |
| parser = etree.XMLParser(remove_blank_text=False, recover=True, huge_tree=True) | |
| root = etree.parse(str(svg_path), parser).getroot() | |
| signatures: Counter[str] = Counter() | |
| for elem in root.xpath(".//*"): | |
| if is_data_bearing(elem): | |
| signatures[data_signature(elem)] += 1 | |
| return signatures | |
| def metrics(svg_path: Path) -> dict[str, int]: | |
| text = svg_path.read_text(encoding="utf-8", errors="ignore") | |
| return { | |
| "rect": text.count("<rect"), | |
| "path": text.count("<path"), | |
| "circle": text.count("<circle"), | |
| "line": text.count("<line"), | |
| "text": text.count("<text"), | |
| "image": text.count("<image"), | |
| } | |
| def resolve_existing_chart_svg(record: dict[str, Any]) -> Path | None: | |
| chart_svg = record.get("chart_svg") | |
| if chart_svg: | |
| path = Path(chart_svg) | |
| if path.is_file(): | |
| return path | |
| sample_dir = record.get("sample_dir") | |
| if sample_dir: | |
| nested = nested_chart_svg(Path(sample_dir)) | |
| if nested and nested.is_file(): | |
| return nested | |
| return None | |
| def render_chart(record: dict[str, Any], sample_dir: Path, output_png: bool) -> dict[str, Any]: | |
| result = run_sample( | |
| template_key=record["template_key"], | |
| data_path=Path(record["data_source"]), | |
| sample_dir=sample_dir, | |
| output_png=output_png, | |
| chart_only=True, | |
| ) | |
| return result | |
| def validate_record( | |
| record: dict[str, Any], | |
| output_root: Path, | |
| render: bool, | |
| output_png: bool, | |
| ) -> dict[str, Any]: | |
| template_key = record["template_key"] | |
| sample_index = int(record.get("sample_index", 0)) | |
| sample_dir = output_root / "charts" / safe_slug(template_key) / f"sample_{sample_index:02d}" | |
| render_result: dict[str, Any] = {} | |
| if render: | |
| render_result = render_chart(record, sample_dir, output_png) | |
| chart_svg = Path(render_result.get("chart_svg") or "") if render_result.get("chart_svg") else None | |
| else: | |
| chart_svg = resolve_existing_chart_svg(record) | |
| if chart_svg is None or not chart_svg.is_file(): | |
| return { | |
| "template_key": template_key, | |
| "sample_index": sample_index, | |
| "data_source": record.get("data_source", ""), | |
| "success": False, | |
| "error": "chart_svg_missing", | |
| } | |
| sanitize_dir = output_root / "sanitized" / safe_slug(template_key) / f"sample_{sample_index:02d}" | |
| sanitized_svg = sanitize_dir / "sanitized_chart.svg" | |
| report_path = sanitize_dir / "sanitized_chart.report.json" | |
| before = data_signatures(chart_svg) | |
| before_metrics = metrics(chart_svg) | |
| report = sanitize_chart_svg(chart_svg, sanitized_svg, report_path=report_path) | |
| after = data_signatures(sanitized_svg) | |
| after_metrics = metrics(sanitized_svg) | |
| lost = before - after | |
| gained = after - before | |
| row = { | |
| "template_key": template_key, | |
| "sample_index": sample_index, | |
| "data_source": record.get("data_source", ""), | |
| "success": True, | |
| "render_success": render_result.get("success") if render else "", | |
| "chart_svg": str(chart_svg), | |
| "sanitized_svg": str(sanitized_svg), | |
| "sanitizer_report": str(report_path), | |
| "removed_count": report.removed_count, | |
| "removed_by_reason": report.removed_by_reason, | |
| "data_bearing_before": sum(before.values()), | |
| "data_bearing_after": sum(after.values()), | |
| "data_bearing_lost": sum(lost.values()), | |
| "data_bearing_gained": sum(gained.values()), | |
| "lost_examples": list(lost.keys())[:5], | |
| "rect_before": before_metrics["rect"], | |
| "rect_after": after_metrics["rect"], | |
| "path_before": before_metrics["path"], | |
| "path_after": after_metrics["path"], | |
| "circle_before": before_metrics["circle"], | |
| "circle_after": after_metrics["circle"], | |
| "text_before": before_metrics["text"], | |
| "text_after": after_metrics["text"], | |
| "image_before": before_metrics["image"], | |
| "image_after": after_metrics["image"], | |
| "error": "", | |
| } | |
| return row | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description="Batch validate that SVG sanitization does not remove data-bearing chart elements." | |
| ) | |
| parser.add_argument("--records", type=Path, required=True, help="JSONL records with template_key/data_source/sample_index.") | |
| parser.add_argument("--output-root", type=Path, required=True) | |
| parser.add_argument("--render-chart", action="store_true", help="Render current chart SVGs before validation.") | |
| parser.add_argument("--output-png", action="store_true", help="Generate chart PNGs during chart-only render.") | |
| parser.add_argument("--workers", type=int, default=8) | |
| parser.add_argument("--limit", type=int, default=0) | |
| parser.add_argument("--resume", action="store_true") | |
| return parser.parse_args() | |
| def main() -> int: | |
| args = parse_args() | |
| configure_chart_type_filter(False) | |
| args.output_root.mkdir(parents=True, exist_ok=True) | |
| records = read_jsonl(args.records) | |
| if args.limit: | |
| records = records[: args.limit] | |
| manifest_path = args.output_root / "manifest.jsonl" | |
| done: set[tuple[str, int]] = set() | |
| if args.resume and manifest_path.exists(): | |
| for row in read_jsonl(manifest_path): | |
| done.add((row.get("template_key", ""), int(row.get("sample_index", -1)))) | |
| elif manifest_path.exists(): | |
| manifest_path.unlink() | |
| pending = [ | |
| record | |
| for record in records | |
| if (record.get("template_key", ""), int(record.get("sample_index", -1))) not in done | |
| ] | |
| write_json( | |
| args.output_root / "run_config.json", | |
| { | |
| "records": str(args.records), | |
| "output_root": str(args.output_root), | |
| "render_chart": args.render_chart, | |
| "output_png": args.output_png, | |
| "workers": args.workers, | |
| "limit": args.limit, | |
| "selected_records": len(records), | |
| "pending_records": len(pending), | |
| }, | |
| ) | |
| started = time.time() | |
| print(f"validating records={len(records)} pending={len(pending)} workers={args.workers}", flush=True) | |
| if pending and args.workers > 1: | |
| with ThreadPoolExecutor(max_workers=args.workers) as executor: | |
| futures = [ | |
| executor.submit(validate_record, record, args.output_root, args.render_chart, args.output_png) | |
| for record in pending | |
| ] | |
| for index, future in enumerate(as_completed(futures), 1): | |
| row = future.result() | |
| append_jsonl(manifest_path, row) | |
| elapsed = time.time() - started | |
| rate = index / elapsed if elapsed else 0 | |
| remaining = (len(pending) - index) / rate if rate else 0 | |
| print( | |
| f"[{index}/{len(pending)}] {row.get('template_key')} " | |
| f"sample_{int(row.get('sample_index', 0)):02d} " | |
| f"success={row.get('success')} lost={row.get('data_bearing_lost', '')} " | |
| f"eta={remaining/60:.1f}m", | |
| flush=True, | |
| ) | |
| else: | |
| for index, record in enumerate(pending, 1): | |
| row = validate_record(record, args.output_root, args.render_chart, args.output_png) | |
| append_jsonl(manifest_path, row) | |
| elapsed = time.time() - started | |
| rate = index / elapsed if elapsed else 0 | |
| remaining = (len(pending) - index) / rate if rate else 0 | |
| print( | |
| f"[{index}/{len(pending)}] {row.get('template_key')} " | |
| f"sample_{int(row.get('sample_index', 0)):02d} " | |
| f"success={row.get('success')} lost={row.get('data_bearing_lost', '')} " | |
| f"eta={remaining/60:.1f}m", | |
| flush=True, | |
| ) | |
| rows = read_jsonl(manifest_path) if manifest_path.exists() else [] | |
| successes = [row for row in rows if row.get("success")] | |
| failures = [row for row in rows if not row.get("success")] | |
| suspicious = [row for row in successes if int(row.get("data_bearing_lost") or 0) > 0] | |
| removed_reasons: Counter[str] = Counter() | |
| for row in successes: | |
| for reason, count in (row.get("removed_by_reason") or {}).items(): | |
| removed_reasons[reason] += int(count) | |
| summary = { | |
| "total_records": len(rows), | |
| "success": len(successes), | |
| "failed": len(failures), | |
| "templates": len({row.get("template_key") for row in rows}), | |
| "suspicious_data_bearing_loss": len(suspicious), | |
| "total_data_bearing_lost": sum(int(row.get("data_bearing_lost") or 0) for row in successes), | |
| "total_removed_nodes": sum(int(row.get("removed_count") or 0) for row in successes), | |
| "removed_by_reason": dict(removed_reasons.most_common()), | |
| "failure_examples": failures[:20], | |
| "suspicious_examples": suspicious[:20], | |
| "manifest": str(manifest_path), | |
| "csv": str(args.output_root / "summary.csv"), | |
| } | |
| write_json(args.output_root / "summary.json", summary) | |
| fieldnames = [ | |
| "template_key", | |
| "sample_index", | |
| "success", | |
| "render_success", | |
| "removed_count", | |
| "removed_by_reason", | |
| "data_bearing_before", | |
| "data_bearing_after", | |
| "data_bearing_lost", | |
| "data_bearing_gained", | |
| "rect_before", | |
| "rect_after", | |
| "path_before", | |
| "path_after", | |
| "circle_before", | |
| "circle_after", | |
| "text_before", | |
| "text_after", | |
| "image_before", | |
| "image_after", | |
| "chart_svg", | |
| "sanitized_svg", | |
| "sanitizer_report", | |
| "data_source", | |
| "error", | |
| ] | |
| with open(args.output_root / "summary.csv", "w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") | |
| writer.writeheader() | |
| writer.writerows(rows) | |
| print(json.dumps(summary, indent=2, ensure_ascii=False), flush=True) | |
| return 0 if not failures and not suspicious else 1 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |