Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """Triage rejected samples into variation repair decisions. | |
| The goal is to use rejected_sample_10k as evidence for fixing chart variations | |
| instead of post-processing individual images. The script joins each rejected | |
| sample with: | |
| - its rejection reasons and blockers, | |
| - one GPT-improved reference image, and | |
| - the source chart variation/template path when available. | |
| It then emits variation-level repair briefs and icon/image generation batch | |
| prompts. The classifier is intentionally conservative and rule-based so that it | |
| is auditable before LLM-assisted code editing is introduced. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import re | |
| from collections import Counter, defaultdict | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any, Iterable | |
| PROJECT_ROOT = Path(__file__).resolve().parents[1] | |
| WORKSPACE_ROOT = PROJECT_ROOT.parent | |
| DEFAULT_REJECTED_ROOT = WORKSPACE_ROOT / "rejected_sample_10k" | |
| GPT_SUFFIX_RE = re.compile(r"^(?P<base>.+)_gpt_image_2_(?P<variant>\d+)\.(?:png|jpg|jpeg|webp)$", re.I) | |
| RULES = { | |
| "asset_semantics": [ | |
| "icon", | |
| "illustration", | |
| "semantically unrelated", | |
| "topic mismatch", | |
| "unrelated to the topic", | |
| "generic", | |
| "decorative", | |
| "cartoon", | |
| ], | |
| "asset_obstruction": [ | |
| "obstruct", | |
| "sits directly", | |
| "overlap", | |
| "cover", | |
| "blocks", | |
| "visual clutter", | |
| ], | |
| "ordering": [ | |
| "chronological", | |
| "not in order", | |
| "out of order", | |
| "jumps between", | |
| "sorted", | |
| "sequence", | |
| ], | |
| "text_readability": [ | |
| "text", | |
| "label", | |
| "readability", | |
| "unreadable", | |
| "overlapping", | |
| "cluttered", | |
| "rotated", | |
| "upside-down", | |
| "sideways", | |
| "cropped", | |
| "small", | |
| ], | |
| "axis_scale": [ | |
| "axis", | |
| "scale", | |
| "tick", | |
| "repeated", | |
| "range", | |
| "number '20'", | |
| ], | |
| "chart_semantics": [ | |
| "chart type", | |
| "radial/polar", | |
| "polar chart", | |
| "does not effectively", | |
| "inefficient", | |
| "confusing", | |
| "meaningless", | |
| "unusable", | |
| "hard to follow", | |
| ], | |
| "layout_coherence": [ | |
| "layout", | |
| "collage", | |
| "disconnected", | |
| "cohesive", | |
| "hierarchy", | |
| "space", | |
| "crowded", | |
| ], | |
| } | |
| class SampleTriage: | |
| sample_id: str | |
| chart_variation: str | |
| chart_type: str | |
| decision: str | |
| categories: list[str] | |
| blockers: list[str] | |
| reasons: list[str] | |
| original_image: str | None | |
| gpt_reference: str | None | |
| scores: dict[str, Any] | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description="Build repair briefs from rejected ChartGalaxy samples.") | |
| parser.add_argument("--rejected-root", default=str(DEFAULT_REJECTED_ROOT)) | |
| parser.add_argument( | |
| "--output-dir", | |
| default=None, | |
| help="Default: <rejected-root>/variation_repair_triage", | |
| ) | |
| parser.add_argument( | |
| "--gpt-variant", | |
| type=int, | |
| default=0, | |
| help="Use exactly one GPT reference variant per sample.", | |
| ) | |
| parser.add_argument("--limit", type=int, default=0) | |
| parser.add_argument("--min-variation-count", type=int, default=1) | |
| parser.add_argument( | |
| "--top", | |
| type=int, | |
| default=80, | |
| help="Max number of variations in the markdown report.", | |
| ) | |
| return parser.parse_args() | |
| def load_json(path: Path) -> Any: | |
| with path.open("r", encoding="utf-8") as handle: | |
| return json.load(handle) | |
| def write_json(path: Path, data: Any) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with path.open("w", encoding="utf-8") as handle: | |
| json.dump(data, handle, indent=2, ensure_ascii=False) | |
| handle.write("\n") | |
| def write_jsonl(path: Path, records: Iterable[dict[str, Any]]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with path.open("w", encoding="utf-8") as handle: | |
| for record in records: | |
| handle.write(json.dumps(record, ensure_ascii=False) + "\n") | |
| def read_manifest_refs(rejected_root: Path, variant: int) -> dict[str, str]: | |
| refs: dict[str, str] = {} | |
| for manifest_path in [ | |
| rejected_root / "gpt_image_2_improved" / "manifest.jsonl", | |
| rejected_root / "gpt_image_2_improved" / "manifest.jsonl.500w", | |
| ]: | |
| if not manifest_path.exists(): | |
| continue | |
| with manifest_path.open("r", encoding="utf-8") as handle: | |
| for line in handle: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| record = json.loads(line) | |
| except json.JSONDecodeError: | |
| continue | |
| input_path = Path(record.get("input", "")) | |
| sample_id = input_path.stem | |
| outputs = record.get("outputs") or [] | |
| chosen = None | |
| for output in outputs: | |
| match = GPT_SUFFIX_RE.match(Path(output).name) | |
| if match and int(match.group("variant")) == variant: | |
| chosen = output | |
| break | |
| if chosen: | |
| chosen_path = Path(chosen) | |
| candidates = [] | |
| if chosen_path.is_absolute(): | |
| candidates.append(chosen_path) | |
| else: | |
| candidates.append((WORKSPACE_ROOT / chosen_path).resolve()) | |
| candidates.append((rejected_root.parent / chosen_path).resolve()) | |
| candidates.append(rejected_root / "gpt_image_2_improved" / Path(chosen).name) | |
| for candidate in candidates: | |
| if candidate.exists(): | |
| refs[sample_id] = str(candidate.resolve()) | |
| break | |
| return refs | |
| def find_gpt_ref_by_scan(rejected_root: Path, sample_id: str, variant: int) -> str | None: | |
| suffix = f"_gpt_image_2_{variant:02d}.png" | |
| candidates = sorted((rejected_root / "gpt_image_2_improved").glob(f"{sample_id}{suffix}")) | |
| if candidates: | |
| return str(candidates[0].resolve()) | |
| return None | |
| def template_paths_by_variation() -> dict[str, list[str]]: | |
| roots = [ | |
| PROJECT_ROOT / "modules" / "chart_engine" / "template", | |
| ] | |
| mapping: dict[str, list[str]] = defaultdict(list) | |
| for root in roots: | |
| if not root.exists(): | |
| continue | |
| for path in root.rglob("*"): | |
| if path.suffix.lower() not in {".js", ".py"}: | |
| continue | |
| if path.name.startswith("__"): | |
| continue | |
| mapping[path.stem].append(str(path.resolve())) | |
| return dict(mapping) | |
| def match_categories(reasons: list[str], blockers: list[str]) -> list[str]: | |
| text = " ".join(reasons + blockers).lower() | |
| categories: list[str] = [] | |
| for category, keywords in RULES.items(): | |
| if any(keyword in text for keyword in keywords): | |
| categories.append(category) | |
| if not categories: | |
| categories.append("needs_manual_review") | |
| return categories | |
| def decide(categories: list[str], blockers: list[str]) -> str: | |
| category_set = set(categories) | |
| blocker_set = set(blockers) | |
| asset_only = category_set <= {"asset_semantics", "asset_obstruction", "layout_coherence"} | |
| if asset_only and ("topic_relevance" in blocker_set or "icon_illustration_quality" in blocker_set): | |
| return "repair_assets" | |
| if category_set & {"ordering", "text_readability", "axis_scale"}: | |
| if "chart_semantics" in category_set and "overall_quality" in blocker_set: | |
| return "repair_or_restrict_variation" | |
| return "repair_template" | |
| if "asset_semantics" in category_set or "asset_obstruction" in category_set: | |
| return "repair_assets_and_layout" | |
| if "chart_semantics" in category_set: | |
| return "restrict_or_abandon_variation" | |
| if "layout_coherence" in category_set: | |
| return "repair_layout" | |
| return "manual_review" | |
| def topic_from_sample_id(sample_id: str) -> str: | |
| if "__" not in sample_id: | |
| return "" | |
| tail = sample_id.split("__", 1)[1] | |
| tail = re.sub(r"_(scenario|temporal|categorical|numerical|tp|xy|xyear|y|group).*", "", tail) | |
| tail = re.sub(r"_\d+$", "", tail) | |
| return tail.replace("_", " ").strip() | |
| def action_brief_for_categories(categories: Iterable[str], decision: str) -> list[str]: | |
| cats = set(categories) | |
| actions: list[str] = [] | |
| if "ordering" in cats: | |
| actions.append("Sort temporal/category inputs before rendering; never let radial/axis labels follow raw unsorted order.") | |
| if "text_readability" in cats: | |
| actions.append("Reduce rotated labels, add collision checks, and fall back to outside labels/legends when label density is high.") | |
| if "axis_scale" in cats: | |
| actions.append("Fix tick generation/formatting so axis labels are unique, monotonic, and data-range aware.") | |
| if "asset_semantics" in cats: | |
| actions.append("Replace retrieved one-off icons with domain/topic-aware generated icon sets.") | |
| if "asset_obstruction" in cats: | |
| actions.append("Keep illustration/image masks outside the chart data region; enforce no-overlap constraints.") | |
| if "layout_coherence" in cats: | |
| actions.append("Align supporting panels with the chart and remove disconnected side boxes when they do not encode data.") | |
| if "chart_semantics" in cats: | |
| actions.append("Keep the chart type unchanged; if the defect requires a different visual encoding, restrict or abandon this variation for that data shape.") | |
| if not actions: | |
| actions.append(f"Manual review required before modifying the variation ({decision}).") | |
| return actions | |
| def make_icon_job(variation: str, samples: list[SampleTriage], template_paths: list[str]) -> dict[str, Any] | None: | |
| asset_samples = [ | |
| sample | |
| for sample in samples | |
| if sample.decision in {"repair_assets", "repair_assets_and_layout"} | |
| or "asset_semantics" in sample.categories | |
| or "asset_obstruction" in sample.categories | |
| ] | |
| if not asset_samples: | |
| return None | |
| topics = [] | |
| for sample in asset_samples: | |
| topic = topic_from_sample_id(sample.sample_id) | |
| if topic and topic not in topics: | |
| topics.append(topic) | |
| if len(topics) >= 24: | |
| break | |
| prompt_topics = topics or [variation.replace("_", " ")] | |
| prompt = ( | |
| "Create one coherent infographic icon sheet in a single consistent visual system. " | |
| "Use flat vector-like pictograms with matching stroke weight, palette, lighting, and perspective. " | |
| "No text, letters, numbers, labels, watermarks, or captions. " | |
| "Use a pure white background. Arrange icons in a clean grid, one icon per cell. " | |
| "Topics: " | |
| + "; ".join(prompt_topics) | |
| + "." | |
| ) | |
| return { | |
| "chart_variation": variation, | |
| "template_paths": template_paths, | |
| "sample_count": len(asset_samples), | |
| "topics": prompt_topics, | |
| "prompt": prompt, | |
| "reference_images": [sample.gpt_reference for sample in asset_samples[:3] if sample.gpt_reference], | |
| "original_images": [sample.original_image for sample in asset_samples[:3] if sample.original_image], | |
| } | |
| def make_llm_review_job(summary: dict[str, Any]) -> dict[str, Any]: | |
| images = [] | |
| for sample in summary["representative_samples"][:3]: | |
| if sample.get("original_image"): | |
| images.append({"role": "original_rejected", "path": sample["original_image"], "sample_id": sample["sample_id"]}) | |
| if sample.get("gpt_reference"): | |
| images.append({"role": "gpt_style_reference", "path": sample["gpt_reference"], "sample_id": sample["sample_id"]}) | |
| prompt = f"""You are repairing a ChartGalaxy chart variation, not post-processing a single output. | |
| Variation: {summary['chart_variation']} | |
| Chart type: {summary['chart_type']} | |
| Template paths: {summary['template_paths']} | |
| Current triage decision: {summary['dominant_decision']} | |
| Aggregated blockers: {summary['blocker_counts']} | |
| Aggregated defect categories: {summary['category_counts']} | |
| Use the original rejected images to identify actual failures. Use GPT-improved images only as style references for layout, visual hierarchy, coherent decoration, and icon style. Do not copy GPT-rendered chart data, numeric values, labels, or geometry. | |
| Do not change the chart type or visual encoding of the variation. A line chart must remain a line chart, a gauge must remain the same gauge family, a proportional-area variation must remain the same proportional-area mark family, etc. If the only good fix would be to turn this into another chart type, return restrict_variation or abandon_variation and describe which data shapes should be routed elsewhere. | |
| Return JSON only: | |
| {{ | |
| "decision": "repair_template | repair_assets | restrict_variation | abandon_variation | manual_review", | |
| "fixability_reason": "...", | |
| "template_patch_brief": ["concrete code-level changes to the variation/template"], | |
| "data_compatibility_rules": ["rules for when this variation should not be selected"], | |
| "asset_generation_brief": {{ | |
| "needed": true, | |
| "icon_sheet_prompt": "single coherent image-generation prompt if icons/images should be regenerated as a set", | |
| "placement_rules": ["where images/icons may be placed relative to chart data"] | |
| }}, | |
| "validation_checks": ["tests or visual checks that must pass after patching"] | |
| }} | |
| """ | |
| return { | |
| "chart_variation": summary["chart_variation"], | |
| "chart_type": summary["chart_type"], | |
| "template_paths": summary["template_paths"], | |
| "dominant_decision": summary["dominant_decision"], | |
| "representative_samples": summary["representative_samples"][:3], | |
| "images": images, | |
| "prompt": prompt, | |
| } | |
| def summarize_variation( | |
| variation: str, | |
| samples: list[SampleTriage], | |
| template_paths: list[str], | |
| ) -> dict[str, Any]: | |
| decision_counts = Counter(sample.decision for sample in samples) | |
| category_counts = Counter(category for sample in samples for category in sample.categories) | |
| blocker_counts = Counter(blocker for sample in samples for blocker in sample.blockers) | |
| dominant_decision = decision_counts.most_common(1)[0][0] | |
| if "repair_template" in decision_counts or "repair_assets" in decision_counts or "repair_assets_and_layout" in decision_counts: | |
| dominant_decision = "repair_variation" | |
| elif "repair_or_restrict_variation" in decision_counts: | |
| dominant_decision = "repair_or_restrict_variation" | |
| elif "restrict_or_abandon_variation" in decision_counts: | |
| dominant_decision = "restrict_or_abandon_variation" | |
| representative = sorted( | |
| samples, | |
| key=lambda sample: ( | |
| -len(sample.blockers), | |
| sample.scores.get("overall_quality", 99), | |
| sample.sample_id, | |
| ), | |
| )[:5] | |
| return { | |
| "chart_variation": variation, | |
| "chart_type": samples[0].chart_type if samples else "", | |
| "sample_count": len(samples), | |
| "dominant_decision": dominant_decision, | |
| "decision_counts": dict(decision_counts), | |
| "category_counts": dict(category_counts), | |
| "blocker_counts": dict(blocker_counts), | |
| "template_paths": template_paths, | |
| "repair_actions": action_brief_for_categories(category_counts.keys(), dominant_decision), | |
| "representative_samples": [ | |
| { | |
| "sample_id": sample.sample_id, | |
| "decision": sample.decision, | |
| "categories": sample.categories, | |
| "blockers": sample.blockers, | |
| "reasons": sample.reasons, | |
| "original_image": sample.original_image, | |
| "gpt_reference": sample.gpt_reference, | |
| } | |
| for sample in representative | |
| ], | |
| } | |
| def markdown_report(variation_summaries: list[dict[str, Any]], top: int) -> str: | |
| lines = [ | |
| "# Variation Repair Triage", | |
| "", | |
| "This report groups rejected samples by chart variation and proposes whether to repair, restrict, or abandon each variation.", | |
| "", | |
| ] | |
| for item in variation_summaries[:top]: | |
| lines.append(f"## {item['chart_variation']} ({item['sample_count']} samples)") | |
| lines.append(f"- decision: `{item['dominant_decision']}`") | |
| lines.append(f"- chart_type: {item['chart_type']}") | |
| lines.append(f"- blockers: {item['blocker_counts']}") | |
| lines.append(f"- categories: {item['category_counts']}") | |
| if item["template_paths"]: | |
| lines.append("- template_paths:") | |
| for path in item["template_paths"]: | |
| lines.append(f" - `{path}`") | |
| lines.append("- repair_actions:") | |
| for action in item["repair_actions"]: | |
| lines.append(f" - {action}") | |
| lines.append("- representative:") | |
| for sample in item["representative_samples"][:3]: | |
| reason = sample["reasons"][0] if sample["reasons"] else "" | |
| lines.append(f" - `{sample['sample_id']}`: {reason}") | |
| if sample.get("gpt_reference"): | |
| lines.append(f" GPT ref: `{sample['gpt_reference']}`") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def main() -> int: | |
| args = parse_args() | |
| rejected_root = Path(args.rejected_root).resolve() | |
| output_dir = Path(args.output_dir).resolve() if args.output_dir else rejected_root / "variation_repair_triage" | |
| reasons_root = rejected_root / "reasons" | |
| images_root = rejected_root / "images" | |
| if not reasons_root.exists(): | |
| raise SystemExit(f"missing reasons directory: {reasons_root}") | |
| gpt_refs = read_manifest_refs(rejected_root, args.gpt_variant) | |
| template_map = template_paths_by_variation() | |
| sample_records: list[SampleTriage] = [] | |
| reason_paths = sorted(reasons_root.glob("*.json")) | |
| if args.limit > 0: | |
| reason_paths = reason_paths[: args.limit] | |
| for reason_path in reason_paths: | |
| data = load_json(reason_path) | |
| sample_id = data.get("sample_id") or reason_path.stem | |
| reasons = [str(item) for item in data.get("reject_reasons") or []] | |
| blockers = [str(item) for item in data.get("filter_blockers") or []] | |
| categories = match_categories(reasons, blockers) | |
| decision = decide(categories, blockers) | |
| original_path = images_root / f"{sample_id}.webp" | |
| gpt_reference = gpt_refs.get(sample_id) or find_gpt_ref_by_scan(rejected_root, sample_id, args.gpt_variant) | |
| sample_records.append( | |
| SampleTriage( | |
| sample_id=sample_id, | |
| chart_variation=str(data.get("chart_variation") or ""), | |
| chart_type=str(data.get("chart_type") or ""), | |
| decision=decision, | |
| categories=categories, | |
| blockers=blockers, | |
| reasons=reasons, | |
| original_image=str(original_path.resolve()) if original_path.exists() else None, | |
| gpt_reference=gpt_reference, | |
| scores=data.get("scores") or {}, | |
| ) | |
| ) | |
| grouped: dict[str, list[SampleTriage]] = defaultdict(list) | |
| for sample in sample_records: | |
| grouped[sample.chart_variation].append(sample) | |
| variation_summaries = [] | |
| for variation, samples in grouped.items(): | |
| if len(samples) < args.min_variation_count: | |
| continue | |
| variation_summaries.append( | |
| summarize_variation( | |
| variation, | |
| samples, | |
| template_map.get(variation, []), | |
| ) | |
| ) | |
| variation_summaries.sort( | |
| key=lambda item: ( | |
| -item["sample_count"], | |
| item["dominant_decision"], | |
| item["chart_variation"], | |
| ) | |
| ) | |
| icon_jobs = [] | |
| llm_review_jobs = [] | |
| for item in variation_summaries: | |
| samples = grouped[item["chart_variation"]] | |
| job = make_icon_job(item["chart_variation"], samples, item["template_paths"]) | |
| if job: | |
| icon_jobs.append(job) | |
| llm_review_jobs.append(make_llm_review_job(item)) | |
| sample_dicts = [sample.__dict__ for sample in sample_records] | |
| write_json(output_dir / "samples.json", sample_dicts) | |
| write_json(output_dir / "variation_summaries.json", variation_summaries) | |
| write_jsonl(output_dir / "icon_generation_jobs.jsonl", icon_jobs) | |
| write_jsonl(output_dir / "llm_variation_review_jobs.jsonl", llm_review_jobs) | |
| (output_dir / "report.md").write_text(markdown_report(variation_summaries, args.top), encoding="utf-8") | |
| write_json( | |
| output_dir / "summary.json", | |
| { | |
| "rejected_root": str(rejected_root), | |
| "output_dir": str(output_dir), | |
| "gpt_variant": args.gpt_variant, | |
| "sample_count": len(sample_records), | |
| "variation_count": len(variation_summaries), | |
| "icon_job_count": len(icon_jobs), | |
| "llm_review_job_count": len(llm_review_jobs), | |
| "decision_counts": dict(Counter(sample.decision for sample in sample_records)), | |
| "category_counts": dict(Counter(category for sample in sample_records for category in sample.categories)), | |
| }, | |
| ) | |
| print(f"samples={len(sample_records)} variations={len(variation_summaries)} icon_jobs={len(icon_jobs)}") | |
| print(f"report={output_dir / 'report.md'}") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |