mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-20 04:45:11 -05:00
Replace pointers to the private project rules/docs tree (relative .claude/rules
and .claude/docs paths) in code comments and docstrings with neutral phrasing
("the project prose style guide", etc.). Load-bearing runtime paths that the
tooling reads or writes are left intact.
1017 lines
44 KiB
Python
1017 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
"""svg_audit.py v2 — batched, phase-based SVG quality audit for 238 figures.
|
|
|
|
Architecture (Approach C, iteration-based, holistic):
|
|
|
|
PHASE 1: BATCHED REVIEW (8 figures per Gemini call)
|
|
Input: rendered PNGs of N figures
|
|
Prompt: "For each figure answer design-sound + aesthetic-clean +
|
|
top-3 problems. Apply the book's SVG style rules."
|
|
Output: per-figure JSON with {clean, design_sound, problems, recommendation}
|
|
|
|
PHASE 2: INDIVIDUAL REWRITE (1 figure per Gemini call)
|
|
Input: SVG source + problems + rejected_attempts memory
|
|
Prompt: "Content-preserving, geometry-fluid rewrite. Preserve every text
|
|
label, colour, and semantic element; you have full latitude
|
|
over positions, sizes, and layout."
|
|
Output: complete revised SVG
|
|
|
|
GATE: Content audit (local, no Gemini)
|
|
- Extract all <text> content from BEFORE; same from AFTER.
|
|
- If any text was changed/dropped/reordered, REJECT the candidate.
|
|
- Check canonical SVG hash; if identical to cycle-2-ago, mark oscillating.
|
|
|
|
PHASE 3: BATCHED VERIFY (8 before/after pairs per Gemini call)
|
|
Input: 16 PNG attachments (BEFORE and AFTER for 8 figures)
|
|
Prompt: "For each figure: were the listed problems resolved? Any regression?"
|
|
Output: per-figure JSON with {next_action: accept|revert|iterate, regression}
|
|
|
|
APPLY per figure:
|
|
accept -> git add + commit atomically; candidate becomes baseline
|
|
iterate -> candidate stays, keep trying next cycle
|
|
revert -> git checkout SVG, log rejected_attempts, try different approach
|
|
next cycle
|
|
|
|
LOOP PHASES 1-3 up to MAX_CYCLES (4). Terminate when figure is clean or cap hit.
|
|
|
|
State persists to .audit/state.json atomically after every figure transition,
|
|
so a killed run resumes exactly where it left off.
|
|
|
|
Hard-pinned model: gemini-3.1-pro-preview.
|
|
Budget cap: 12 h wall-time at .audit/gemini-budget.txt.
|
|
|
|
Usage:
|
|
python3 svg_audit.py audit --scope smoke --skip-drafts # diagnose + fix
|
|
python3 svg_audit.py audit --scope full --skip-drafts
|
|
python3 svg_audit.py diagnose --scope full # review only
|
|
python3 svg_audit.py fix --scope full --skip-drafts # fix after diagnose
|
|
python3 svg_audit.py report
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass, field, asdict
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Any
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
GEMINI_MODEL = "gemini-3.1-pro-preview"
|
|
GEMINI_TIMEOUT_SEC = 420 # rewrite prompts carry full SVG source
|
|
RETRY_BACKOFFS_SEC = (2, 8, 24)
|
|
WALL_TIME_BUDGET_SEC = 24 * 60 * 60 # 24h safety cap for overnight full sweep
|
|
RENDER_WIDTH_PX = 600
|
|
REVIEW_BATCH_SIZE = 8
|
|
VERIFY_BATCH_SIZE = 8 # 16 images per call
|
|
MAX_CYCLES = 2 # full-sweep default; smoke showed cycles 1-2 capture bulk of improvements
|
|
|
|
TERMINAL_STATUSES = {"clean", "fixed", "needs-human", "render-failed", "skipped-draft"}
|
|
|
|
SVG_NS = "http://www.w3.org/2000/svg"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Paths
|
|
# ---------------------------------------------------------------------------
|
|
def worktree_root() -> Path:
|
|
return Path(__file__).resolve().parents[4]
|
|
|
|
def audit_dir() -> Path: return worktree_root() / ".audit"
|
|
def png_dir() -> Path: return audit_dir() / "png"
|
|
def before_dir() -> Path: return audit_dir() / "png_before"
|
|
def after_dir() -> Path: return audit_dir() / "png_after"
|
|
def backups_dir() -> Path: return audit_dir() / "svg_backups"
|
|
def logs_dir() -> Path: return audit_dir() / "logs"
|
|
def state_path() -> Path: return audit_dir() / "state.json"
|
|
def budget_path() -> Path: return audit_dir() / "gemini-budget.txt"
|
|
def inventory_path() -> Path: return audit_dir() / "svg-inventory.txt"
|
|
def report_path() -> Path: return audit_dir() / "SVG_AUDIT_REPORT.md"
|
|
|
|
def ensure_dirs() -> None:
|
|
for d in (audit_dir(), png_dir(), before_dir(), after_dir(),
|
|
backups_dir(), logs_dir()):
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data model
|
|
# ---------------------------------------------------------------------------
|
|
@dataclass
|
|
class Figure:
|
|
svg_path: str
|
|
chapter: str
|
|
is_draft: bool = False
|
|
# Rendering
|
|
png_path: str = ""
|
|
render_ok: bool = False
|
|
render_error: str = ""
|
|
# Current review state
|
|
clean_this_cycle: bool = False
|
|
design_sound: bool = True
|
|
current_problems: List[str] = field(default_factory=list)
|
|
recommendation: str = "pending" # pending | polish | redraw-required | clean
|
|
overall_assessment: str = ""
|
|
# Loop state
|
|
cycles_completed: int = 0
|
|
rejected_attempts: List[str] = field(default_factory=list)
|
|
canonical_hashes: List[str] = field(default_factory=list) # for oscillation detection
|
|
# Commits
|
|
commit_shas: List[str] = field(default_factory=list)
|
|
# Terminal
|
|
status: str = "pending"
|
|
final_notes: str = ""
|
|
# Audit trail
|
|
iterations: List[Dict[str, Any]] = field(default_factory=list)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inventory
|
|
# ---------------------------------------------------------------------------
|
|
CHAPTER_RE = re.compile(r"book/quarto/contents/(vol[12]/[^/]+)/images/svg/")
|
|
|
|
def _chapter_of(svg_path: str) -> str:
|
|
m = CHAPTER_RE.search(svg_path)
|
|
return m.group(1) if m else "unknown"
|
|
|
|
def build_inventory() -> List[Figure]:
|
|
p = inventory_path()
|
|
if not p.exists():
|
|
raise FileNotFoundError(f"Missing inventory: {p}")
|
|
figs = []
|
|
for line in p.read_text().splitlines():
|
|
line = line.strip()
|
|
if not line: continue
|
|
name = Path(line).name
|
|
figs.append(Figure(svg_path=line, chapter=_chapter_of(line),
|
|
is_draft=name.startswith("_")))
|
|
return figs
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Render
|
|
# ---------------------------------------------------------------------------
|
|
def _rsvg(svg: Path, png: Path, width: int) -> Optional[str]:
|
|
try:
|
|
cp = subprocess.run(
|
|
["rsvg-convert", f"--width={width}", "--keep-aspect-ratio",
|
|
"--output", str(png), str(svg)],
|
|
check=False, capture_output=True, text=True, timeout=30)
|
|
except Exception as e:
|
|
return f"rsvg exception: {e}"
|
|
if cp.returncode != 0:
|
|
return f"rsvg rc={cp.returncode} {cp.stderr[:160]}"
|
|
if not png.exists() or png.stat().st_size == 0:
|
|
return "rsvg empty"
|
|
return None
|
|
|
|
def _inkscape(svg: Path, png: Path, width: int) -> Optional[str]:
|
|
if not shutil.which("inkscape"): return "no inkscape"
|
|
try:
|
|
cp = subprocess.run(
|
|
["inkscape", str(svg), "--export-type=png",
|
|
f"--export-width={width}", f"--export-filename={png}"],
|
|
check=False, capture_output=True, text=True, timeout=90)
|
|
except Exception as e:
|
|
return f"inkscape exception: {e}"
|
|
if cp.returncode != 0:
|
|
return f"inkscape rc={cp.returncode} {cp.stderr[:160]}"
|
|
if not png.exists() or png.stat().st_size == 0:
|
|
return "inkscape empty"
|
|
return None
|
|
|
|
def render_svg(svg_rel: str, png_rel: str, width: int = RENDER_WIDTH_PX) -> Optional[str]:
|
|
svg_abs = worktree_root() / svg_rel
|
|
png_abs = worktree_root() / png_rel
|
|
png_abs.parent.mkdir(parents=True, exist_ok=True)
|
|
err = _rsvg(svg_abs, png_abs, width)
|
|
if err is None: return None
|
|
err2 = _inkscape(svg_abs, png_abs, width)
|
|
if err2 is None: return None
|
|
return f"{err}; {err2}"
|
|
|
|
def png_rel_for(fig: Figure, variant: str = "") -> str:
|
|
subdir = {"before": before_dir(), "after": after_dir()}.get(variant, png_dir())
|
|
name = f"{fig.chapter.replace('/', '__')}__{Path(fig.svg_path).stem}.png"
|
|
return str((subdir / name).relative_to(worktree_root()))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gemini budget + invocation
|
|
# ---------------------------------------------------------------------------
|
|
def _read_budget() -> float:
|
|
if not budget_path().exists(): return 0.0
|
|
try: return float(budget_path().read_text().strip() or "0")
|
|
except Exception: return 0.0
|
|
|
|
def _add_budget(secs: float) -> float:
|
|
budget_path().parent.mkdir(parents=True, exist_ok=True)
|
|
total = _read_budget() + max(0.0, secs)
|
|
budget_path().write_text(f"{total:.2f}\n")
|
|
return total
|
|
|
|
def _budget_exhausted() -> bool: return _read_budget() >= WALL_TIME_BUDGET_SEC
|
|
|
|
def gemini_call(prompt: str, timeout: int = GEMINI_TIMEOUT_SEC,
|
|
log_prefix: str = "") -> Dict[str, Any]:
|
|
"""Returns {stdout, error, wall_time_sec}."""
|
|
if _budget_exhausted():
|
|
return {"stdout": "", "error": "budget-exhausted", "wall_time_sec": 0.0}
|
|
if log_prefix:
|
|
(logs_dir() / f"{log_prefix}.prompt.txt").write_text(prompt)
|
|
started = time.monotonic()
|
|
last_err = ""
|
|
for attempt, backoff in enumerate(RETRY_BACKOFFS_SEC, start=1):
|
|
try:
|
|
cp = subprocess.run(
|
|
["gemini", "-m", GEMINI_MODEL, "-o", "json",
|
|
"--approval-mode", "plan", "-p", prompt],
|
|
cwd=str(worktree_root()),
|
|
check=False, text=True, capture_output=True, timeout=timeout)
|
|
if cp.returncode == 0 and cp.stdout.strip():
|
|
wall = time.monotonic() - started
|
|
_add_budget(wall)
|
|
if log_prefix:
|
|
(logs_dir() / f"{log_prefix}.response.json").write_text(cp.stdout)
|
|
return {"stdout": cp.stdout, "error": "", "wall_time_sec": wall}
|
|
last_err = f"rc={cp.returncode} {cp.stderr[:300]}"
|
|
except subprocess.TimeoutExpired:
|
|
last_err = "timeout"
|
|
except Exception as e:
|
|
last_err = f"exception:{e}"
|
|
if attempt < len(RETRY_BACKOFFS_SEC):
|
|
time.sleep(backoff)
|
|
wall = time.monotonic() - started
|
|
_add_budget(wall)
|
|
if log_prefix:
|
|
(logs_dir() / f"{log_prefix}.response.json").write_text(
|
|
json.dumps({"error": last_err, "wall": wall}, indent=2))
|
|
return {"stdout": "", "error": last_err, "wall_time_sec": wall}
|
|
|
|
def _unwrap_response(stdout: str) -> str:
|
|
s = stdout.strip()
|
|
try:
|
|
env = json.loads(s)
|
|
if isinstance(env, dict):
|
|
return (env.get("response") or env.get("text") or env.get("output") or s)
|
|
except Exception:
|
|
pass
|
|
return s
|
|
|
|
def _parse_json_body(body: str) -> Any:
|
|
body = body.strip()
|
|
fence = re.match(r"^```(?:json|xml|svg)?\s*(.*?)\s*```$", body, re.S)
|
|
if fence: body = fence.group(1)
|
|
try: return json.loads(body)
|
|
except Exception: pass
|
|
# Find first {...} or [...] block
|
|
for opener, closer in (("{", "}"), ("[", "]")):
|
|
m = re.search(re.escape(opener) + r".*" + re.escape(closer), body, re.S)
|
|
if m:
|
|
try: return json.loads(m.group(0))
|
|
except Exception: continue
|
|
return None
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Content audit (LOCAL, no Gemini) — THE critical content-preservation gate
|
|
# ---------------------------------------------------------------------------
|
|
def _strip_ns(tag: str) -> str:
|
|
return tag.split("}", 1)[1] if "}" in tag else tag
|
|
|
|
def extract_text_content(svg_src: str) -> List[str]:
|
|
"""Return a sorted list of all text content from <text> and <tspan> nodes.
|
|
Normalises whitespace so trivial reflow doesn't trip the audit."""
|
|
try:
|
|
root = ET.fromstring(svg_src)
|
|
except Exception:
|
|
return []
|
|
texts = []
|
|
for el in root.iter():
|
|
if _strip_ns(el.tag) in ("text", "tspan"):
|
|
raw = "".join(el.itertext()) if el.text is None else (el.text or "")
|
|
# Normalise: collapse whitespace, strip
|
|
norm = " ".join(raw.split()).strip()
|
|
if norm: texts.append(norm)
|
|
texts.sort()
|
|
return texts
|
|
|
|
def content_audit_ok(before_svg: str, after_svg: str) -> (bool, str):
|
|
"""Hard gate: every text element in BEFORE must survive into AFTER.
|
|
Returns (ok, reason-if-not)."""
|
|
before_texts = extract_text_content(before_svg)
|
|
after_texts = extract_text_content(after_svg)
|
|
if not before_texts:
|
|
# Trivial case: no text to preserve
|
|
return (True, "")
|
|
missing = [t for t in before_texts if t not in after_texts]
|
|
if missing:
|
|
sample = "; ".join(missing[:3])
|
|
more = f" (and {len(missing)-3} more)" if len(missing) > 3 else ""
|
|
return (False, f"text content lost: {sample}{more}")
|
|
# Optional: check added content (Gemini shouldn't add new text either)
|
|
added = [t for t in after_texts if t not in before_texts]
|
|
if len(added) > 2: # tolerate tiny additions (e.g. SVG title/desc meta)
|
|
sample = "; ".join(added[:3])
|
|
return (False, f"unexpected new text: {sample}")
|
|
return (True, "")
|
|
|
|
def canonical_svg_hash(svg_src: str) -> str:
|
|
"""Hash a canonicalised form of the SVG so whitespace/attribute-order
|
|
changes don't create false non-convergence signals."""
|
|
try:
|
|
root = ET.fromstring(svg_src)
|
|
except Exception:
|
|
return hashlib.sha256(svg_src.encode()).hexdigest()[:16]
|
|
# Sort attribute keys per element
|
|
def _canon(el: ET.Element) -> str:
|
|
tag = _strip_ns(el.tag)
|
|
attrs = ",".join(f"{k}={v}" for k, v in sorted(el.attrib.items()))
|
|
text = (el.text or "").strip()
|
|
children = "".join(_canon(c) for c in el)
|
|
return f"<{tag} {attrs}>{text}{children}</{tag}>"
|
|
canon = _canon(root)
|
|
return hashlib.sha256(canon.encode()).hexdigest()[:16]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PHASE 1: Batched review prompt
|
|
# ---------------------------------------------------------------------------
|
|
RULES_SUMMARY = """
|
|
ML SYSTEMS TEXTBOOK SVG STYLE RULES (condensed — full rules in the project SVG style rules):
|
|
|
|
SEMANTIC PALETTE (any colour outside this is a defect unless domain-justified):
|
|
Compute blue fill=#cfe2f3 stroke=#4a90c4 — GPU, forward/backward, inference
|
|
Data green fill=#d4edda stroke=#3d9e5a — data flow, healthy paths, memory
|
|
Routing orange fill=#fdebd0 stroke=#c87b2a — scheduler, load balancer, batching
|
|
Error red fill=#f9d6d5 stroke=#c44 — bottleneck, waste, decode-bound
|
|
MIT red accent #a31f34 — GPU badges, SLA pills, critical annotations
|
|
Neutral fill=#f7f7f7 stroke=#bbb — background regions, idle state
|
|
AllReduce green fill=#e8f5e9 stroke=#2d7a2d — deeper sync green
|
|
|
|
SHAPE DISCIPLINE:
|
|
- Rectangles are the default primitive (rx=4). Do not mix shape languages.
|
|
- Uniform sizing within a role: three "Worker" boxes are identical width & height.
|
|
- No decorative shapes (stars, banners, speech bubbles, scrolls).
|
|
|
|
ARROW DISCIPLINE:
|
|
- Every arrow either has a head or explicitly does not. No ambiguity.
|
|
- Arrows land at box borders — never inside, never short.
|
|
- Orthogonal routing (H/V). Diagonals only for physical network topologies.
|
|
- Parallel arrows evenly spaced (10px) along the source edge.
|
|
- Stroke-width 1.5 primary / 1.2 secondary / 0.8 tertiary — semantic, not decorative.
|
|
|
|
ALIGNMENT & GRID:
|
|
- All elements snap to 10px grid. Coordinates are integers or one decimal place.
|
|
- Baselines match across panels. Column widths match in comparison figures.
|
|
|
|
WHITESPACE:
|
|
- >=20px padding between any box and panel edge.
|
|
- >=40px gutter between side-by-side panels.
|
|
- >=4px clearance between text bounding box and any line or border. Labels NEVER touch strokes.
|
|
|
|
BORDER SEMANTICS:
|
|
- Solid = realised/present. Dashed = optional/planned. Dotted = approximate/boundary.
|
|
- Fill and stroke come from the same semantic family (blue fill + blue stroke).
|
|
|
|
CHARTJUNK AVOIDANCE:
|
|
- No drop-shadows, gradients, 3-D, bevels, glows. Textbook, not keynote.
|
|
- Every element carries meaning or does not belong.
|
|
|
|
ACCESSIBILITY:
|
|
- Colour is never the only carrier of meaning; add label/shape/pattern cue.
|
|
- Text on coloured fills meets WCAG AA contrast (4.5:1).
|
|
|
|
ITERATIVE IMPROVEMENT — "CONTENT-PRESERVING, GEOMETRY-FLUID":
|
|
- Content is INVARIANT: every text label, colour role, and semantic element must survive.
|
|
- Geometry is FLUID: positions, sizes, viewBox, spacings may be freely adjusted.
|
|
- If a label overlaps a line, move the label. If cramped, widen the viewBox.
|
|
- Two-pass: (1) is the DESIGN sound? (2) is the figure AESTHETICALLY clean?
|
|
A figure passes only when both are true at the same time.
|
|
"""
|
|
|
|
REVIEW_PROMPT_HEADER = (
|
|
"You are the definitive figure-quality reviewer for an MIT Press machine "
|
|
"learning systems textbook. Apply the book's SVG style rules (below) "
|
|
"rigorously. For EACH figure in the batch, answer both questions: "
|
|
"(1) is the DESIGN sound — does the layout serve the message, or does "
|
|
"the structure itself need restructuring? (2) is the figure "
|
|
"AESTHETICALLY clean — no overlaps, boxes balanced, labels clear of "
|
|
"strokes, whitespace used purposefully?\n\n"
|
|
"Return ONLY a JSON array (no prose, no fence) with one object per "
|
|
"figure in input order:\n\n"
|
|
'[\n'
|
|
' {\n'
|
|
' "figure": "<svg filename>",\n'
|
|
' "design_sound": true | false,\n'
|
|
' "design_critique": "<one sentence if not sound, else empty string>",\n'
|
|
' "aesthetic_clean": true | false,\n'
|
|
' "problems": [ "<specific problem 1>", "<specific problem 2>", ... up to 3 most impactful ],\n'
|
|
' "recommendation": "clean" | "polish" | "redraw-required",\n'
|
|
' "overall_assessment": "<one sentence summary>"\n'
|
|
' }\n'
|
|
']\n\n'
|
|
"Recommendation:\n"
|
|
" \"clean\" — design sound AND aesthetic clean; no edit needed.\n"
|
|
" \"polish\" — content preservation-friendly edits can resolve problems.\n"
|
|
" \"redraw-required\" — figure is structurally so broken that human redraw is warranted.\n\n"
|
|
"Be specific about WHICH element is the problem (not 'there is overlap' but 'arrow from Worker B to Shared Storage crosses text label Worker C Task partition 3'). Limit problems to the 3 most impactful — the ones whose resolution most improves the figure as a whole.\n\n"
|
|
+ RULES_SUMMARY
|
|
)
|
|
|
|
def build_review_prompt(batch: List[Figure]) -> str:
|
|
lines = [REVIEW_PROMPT_HEADER, "", "--- FIGURES IN THIS BATCH ---"]
|
|
for i, fig in enumerate(batch, 1):
|
|
if not fig.render_ok: continue
|
|
lines.extend(["",
|
|
f"Figure {i}: {Path(fig.svg_path).name}",
|
|
f"Chapter: {fig.chapter}",
|
|
f"@{fig.png_path}"])
|
|
return "\n".join(lines)
|
|
|
|
def phase_review_batch(batch: List[Figure], batch_id: str) -> None:
|
|
"""PHASE 1 — batched review for up to REVIEW_BATCH_SIZE figures."""
|
|
# Render first
|
|
for fig in batch:
|
|
png_rel = png_rel_for(fig)
|
|
err = render_svg(fig.svg_path, png_rel)
|
|
if err:
|
|
fig.render_ok = False
|
|
fig.render_error = err
|
|
fig.status = "render-failed"
|
|
fig.final_notes = f"render failed: {err[:120]}"
|
|
else:
|
|
fig.render_ok = True
|
|
fig.png_path = png_rel
|
|
|
|
rendered = [f for f in batch if f.render_ok]
|
|
if not rendered: return
|
|
|
|
prompt = build_review_prompt(rendered)
|
|
result = gemini_call(prompt, log_prefix=f"review-{batch_id}")
|
|
if result["error"]:
|
|
for fig in rendered:
|
|
fig.iterations.append({"phase": "review", "batch": batch_id,
|
|
"error": result["error"]})
|
|
return
|
|
|
|
parsed = _parse_json_body(_unwrap_response(result["stdout"]))
|
|
if not isinstance(parsed, list):
|
|
for fig in rendered:
|
|
fig.iterations.append({"phase": "review", "batch": batch_id,
|
|
"error": "parse-failed"})
|
|
return
|
|
|
|
by_name = {Path(f.svg_path).name: f for f in rendered}
|
|
for entry in parsed:
|
|
fname = entry.get("figure", "")
|
|
fig = by_name.get(fname)
|
|
if not fig: continue
|
|
fig.design_sound = bool(entry.get("design_sound", True))
|
|
fig.clean_this_cycle = bool(entry.get("aesthetic_clean", False)) and fig.design_sound
|
|
fig.current_problems = entry.get("problems", []) or []
|
|
if entry.get("design_critique"):
|
|
fig.current_problems = [f"DESIGN: {entry['design_critique']}"] + fig.current_problems
|
|
fig.recommendation = entry.get("recommendation", "polish")
|
|
fig.overall_assessment = entry.get("overall_assessment", "")
|
|
fig.iterations.append({
|
|
"phase": "review", "batch": batch_id,
|
|
"design_sound": fig.design_sound,
|
|
"aesthetic_clean": fig.clean_this_cycle,
|
|
"problems_count": len(fig.current_problems),
|
|
"recommendation": fig.recommendation,
|
|
})
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PHASE 2: Individual rewrite
|
|
# ---------------------------------------------------------------------------
|
|
REWRITE_PROMPT_HEADER = (
|
|
"You are editing an SVG figure for an MIT Press machine learning systems "
|
|
"textbook. Resolve the listed problems. Follow this invariant strictly:\n\n"
|
|
"CONTENT IS INVARIANT. Every text label, numeric value, colour role, "
|
|
"and semantic element MUST survive unchanged. Do NOT reword, truncate, "
|
|
"abbreviate, reorder, or change ANY text. Do NOT change colour "
|
|
"semantics (compute blue stays compute blue).\n\n"
|
|
"GEOMETRY IS FLUID. Positions, sizes, spacings, groupings, alignments, "
|
|
"and the viewBox itself may be freely adjusted. Move labels out of the "
|
|
"way of lines. Widen the canvas if cramped. Rebalance heavy columns. "
|
|
"Re-route arrows orthogonally if they cross content.\n\n"
|
|
"The goal is a figure that, as a whole, reads cleanly — not a minimum "
|
|
"diff. Apply the full SVG style rules (below).\n\n"
|
|
"Return ONLY the complete revised SVG — nothing else, no prose, no "
|
|
"markdown fence. Your response must start with <?xml or <svg.\n"
|
|
+ RULES_SUMMARY
|
|
)
|
|
|
|
def build_rewrite_prompt(fig: Figure, svg_src: str) -> str:
|
|
parts = [REWRITE_PROMPT_HEADER, "",
|
|
"--- PROBLEMS TO RESOLVE ---"]
|
|
for i, p in enumerate(fig.current_problems, 1):
|
|
parts.append(f"{i}. {p}")
|
|
if fig.rejected_attempts:
|
|
parts.append("")
|
|
parts.append("--- PREVIOUS ATTEMPTS THAT FAILED (do NOT repeat these approaches) ---")
|
|
for i, r in enumerate(fig.rejected_attempts[-4:], 1):
|
|
parts.append(f"{i}. {r}")
|
|
parts.extend(["", "--- ORIGINAL SVG SOURCE ---", svg_src])
|
|
return "\n".join(parts)
|
|
|
|
def phase_rewrite_one(fig: Figure, cycle: int) -> Optional[str]:
|
|
"""PHASE 2 — generate a candidate revised SVG. Returns new SVG or None."""
|
|
svg_abs = worktree_root() / fig.svg_path
|
|
try:
|
|
svg_src = svg_abs.read_text()
|
|
except Exception as e:
|
|
fig.iterations.append({"phase": "rewrite", "cycle": cycle,
|
|
"error": f"read-failed:{e}"})
|
|
return None
|
|
|
|
prompt = build_rewrite_prompt(fig, svg_src)
|
|
log_id = f"rewrite-c{cycle}-{Path(fig.svg_path).stem}"
|
|
result = gemini_call(prompt, log_prefix=log_id)
|
|
if result["error"]:
|
|
fig.iterations.append({"phase": "rewrite", "cycle": cycle,
|
|
"error": result["error"]})
|
|
return None
|
|
|
|
body = _unwrap_response(result["stdout"]).strip()
|
|
fence = re.match(r"^```(?:xml|svg)?\s*(.*?)\s*```$", body, re.S)
|
|
if fence: body = fence.group(1).strip()
|
|
if not (body.startswith("<?xml") or body.startswith("<svg")):
|
|
m = re.search(r"(<\?xml.*?</svg>|<svg.*?</svg>)", body, re.S)
|
|
if not m:
|
|
fig.iterations.append({"phase": "rewrite", "cycle": cycle,
|
|
"error": "no-svg-in-response",
|
|
"response_head": body[:200]})
|
|
return None
|
|
body = m.group(1)
|
|
|
|
fig.iterations.append({"phase": "rewrite", "cycle": cycle,
|
|
"wall_sec": round(result["wall_time_sec"], 1)})
|
|
return body
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PHASE 3: Batched verify
|
|
# ---------------------------------------------------------------------------
|
|
VERIFY_PROMPT_HEADER = (
|
|
"You are verifying figure fixes for an MIT Press ML systems textbook. "
|
|
"For each figure in the batch you are shown TWO renderings: BEFORE "
|
|
"(original) and AFTER (with the proposed fix applied). For each figure, "
|
|
"determine: were the listed problems resolved? Was any regression "
|
|
"introduced elsewhere?\n\n"
|
|
"Return ONLY a JSON array (no prose, no fence), one entry per figure "
|
|
"in input order:\n\n"
|
|
'[\n'
|
|
' {\n'
|
|
' "figure": "<svg filename>",\n'
|
|
' "resolved": true | false,\n'
|
|
' "regression": "<description of new problem introduced, or null>",\n'
|
|
' "next_action": "accept" | "revert" | "iterate"\n'
|
|
' }\n'
|
|
']\n\n'
|
|
"next_action rules:\n"
|
|
" \"accept\" — problems resolved AND no regression. Commit the fix.\n"
|
|
" \"revert\" — regression worse than the original problems. Roll back.\n"
|
|
" \"iterate\" — improvement present but not complete. Keep the new SVG "
|
|
"as baseline, propose further polish next cycle.\n"
|
|
)
|
|
|
|
def build_verify_prompt(batch: List[Dict[str, Any]]) -> str:
|
|
lines = [VERIFY_PROMPT_HEADER, "", "--- FIGURES TO VERIFY ---"]
|
|
for i, item in enumerate(batch, 1):
|
|
lines.extend(["",
|
|
f"Figure {i}: {item['name']}",
|
|
f"Original problems: {'; '.join(item['problems'])}",
|
|
f"BEFORE: @{item['before_png']}",
|
|
f"AFTER: @{item['after_png']}"])
|
|
return "\n".join(lines)
|
|
|
|
def phase_verify_batch(items: List[Dict[str, Any]], batch_id: str) -> Dict[str, Dict]:
|
|
"""PHASE 3 — batched before/after verify. Returns {filename: verdict_dict}."""
|
|
if not items: return {}
|
|
prompt = build_verify_prompt(items)
|
|
result = gemini_call(prompt, log_prefix=f"verify-{batch_id}")
|
|
if result["error"]:
|
|
return {item["name"]: {"error": result["error"]} for item in items}
|
|
parsed = _parse_json_body(_unwrap_response(result["stdout"]))
|
|
if not isinstance(parsed, list):
|
|
return {item["name"]: {"error": "parse-failed"} for item in items}
|
|
out = {}
|
|
for entry in parsed:
|
|
name = entry.get("figure", "")
|
|
if name:
|
|
out[name] = entry
|
|
return out
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Git operations
|
|
# ---------------------------------------------------------------------------
|
|
def git_commit(fig: Figure, cycle: int, description: str) -> Optional[str]:
|
|
try:
|
|
subprocess.run(["git", "-C", str(worktree_root()), "add", fig.svg_path],
|
|
check=True, capture_output=True)
|
|
short = description.strip().split("\n")[0][:60] or "quality fix"
|
|
msg = f"style({fig.chapter}/svg): [c{cycle}] {short} ({Path(fig.svg_path).name})"
|
|
cp = subprocess.run(
|
|
["git", "-C", str(worktree_root()), "commit", "-m", msg],
|
|
check=False, capture_output=True, text=True)
|
|
if cp.returncode != 0: return None
|
|
rev = subprocess.run(
|
|
["git", "-C", str(worktree_root()), "rev-parse", "--short", "HEAD"],
|
|
check=False, capture_output=True, text=True)
|
|
return rev.stdout.strip() if rev.returncode == 0 else None
|
|
except Exception:
|
|
return None
|
|
|
|
def git_revert(fig: Figure) -> None:
|
|
subprocess.run(["git", "-C", str(worktree_root()),
|
|
"checkout", "--", fig.svg_path],
|
|
check=False, capture_output=True)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main fix loop
|
|
# ---------------------------------------------------------------------------
|
|
def run_fix_cycles(figures: List[Figure], scope_set: set, skip_drafts: bool,
|
|
meta: Dict) -> None:
|
|
"""PHASE 1-3 loop for up to MAX_CYCLES. Mutates figures in-place, saves state."""
|
|
|
|
def active_queue() -> List[Figure]:
|
|
out = []
|
|
for f in figures:
|
|
if f.svg_path not in scope_set: continue
|
|
if f.status in TERMINAL_STATUSES: continue
|
|
if skip_drafts and f.is_draft:
|
|
if f.status == "pending":
|
|
f.status = "skipped-draft"; f.final_notes = "draft, skipped"
|
|
continue
|
|
out.append(f)
|
|
return out
|
|
|
|
for cycle in range(1, MAX_CYCLES + 1):
|
|
queue = active_queue()
|
|
if not queue:
|
|
print(f"[cycle {cycle}] queue empty — stopping.")
|
|
break
|
|
if _budget_exhausted():
|
|
print(f"[cycle {cycle}] budget exhausted — stopping.")
|
|
break
|
|
|
|
print(f"\n━━━ CYCLE {cycle} / {MAX_CYCLES} ━━━ "
|
|
f"queue={len(queue)} budget={_read_budget():.0f}s")
|
|
|
|
# ── PHASE 1: review in batches ─────────────────────────────────────
|
|
for i in range(0, len(queue), REVIEW_BATCH_SIZE):
|
|
batch = queue[i:i + REVIEW_BATCH_SIZE]
|
|
bid = f"c{cycle}-review-b{i//REVIEW_BATCH_SIZE + 1}"
|
|
print(f"[{bid}] reviewing {len(batch)} figures ...", flush=True)
|
|
phase_review_batch(batch, bid)
|
|
save_state(figures, meta)
|
|
cleans = sum(1 for f in batch if f.clean_this_cycle)
|
|
polish = sum(1 for f in batch if f.recommendation == "polish")
|
|
redraw = sum(1 for f in batch if f.recommendation == "redraw-required")
|
|
print(f" clean={cleans} polish={polish} redraw-req={redraw} "
|
|
f"budget={_read_budget():.0f}s", flush=True)
|
|
|
|
# ── Finalise clean / redraw-required figures ──────────────────────
|
|
for fig in queue:
|
|
if fig.clean_this_cycle:
|
|
fig.status = "fixed" if fig.cycles_completed > 0 else "clean"
|
|
fig.final_notes = fig.final_notes or f"converged at cycle {cycle}"
|
|
elif fig.recommendation == "redraw-required":
|
|
fig.status = "needs-human"
|
|
fig.final_notes = "redraw-required (structural issue beyond polish)"
|
|
save_state(figures, meta)
|
|
|
|
# ── PHASE 2: individual rewrites ──────────────────────────────────
|
|
polish_queue = [f for f in queue
|
|
if f.status not in TERMINAL_STATUSES
|
|
and f.recommendation == "polish"
|
|
and f.current_problems]
|
|
if not polish_queue:
|
|
print(f"[cycle {cycle}] nothing to polish.")
|
|
save_state(figures, meta)
|
|
continue
|
|
|
|
verify_items = [] # populated as we rewrite + render
|
|
for j, fig in enumerate(polish_queue, 1):
|
|
if _budget_exhausted(): break
|
|
print(f"[c{cycle} rewrite {j}/{len(polish_queue)}] "
|
|
f"{Path(fig.svg_path).name} ...", flush=True)
|
|
|
|
svg_abs = worktree_root() / fig.svg_path
|
|
before_content = svg_abs.read_text()
|
|
|
|
# Snapshot backup + BEFORE render (frozen per-cycle)
|
|
backup_file = backups_dir() / f"{Path(fig.svg_path).stem}.cycle{cycle}.svg"
|
|
backup_file.write_text(before_content)
|
|
before_rel = png_rel_for(fig, "before")
|
|
before_abs = worktree_root() / before_rel
|
|
shutil.copy2(worktree_root() / fig.png_path, before_abs)
|
|
|
|
# Request rewrite
|
|
candidate = phase_rewrite_one(fig, cycle)
|
|
if candidate is None:
|
|
fig.rejected_attempts.append(f"c{cycle}: no-rewrite-produced")
|
|
save_state(figures, meta); continue
|
|
|
|
# LOCAL GATE: content audit
|
|
ok, reason = content_audit_ok(before_content, candidate)
|
|
if not ok:
|
|
fig.rejected_attempts.append(f"c{cycle}: content-audit-failed ({reason})")
|
|
fig.iterations.append({"phase": "content-audit", "cycle": cycle,
|
|
"rejected": reason})
|
|
save_state(figures, meta); continue
|
|
|
|
# LOCAL GATE: convergence / oscillation
|
|
h = canonical_svg_hash(candidate)
|
|
if h in fig.canonical_hashes[:-1]: # seen this exact form before
|
|
fig.status = "needs-human"
|
|
fig.final_notes = f"oscillating at cycle {cycle} (hash repeat)"
|
|
save_state(figures, meta); continue
|
|
fig.canonical_hashes.append(h)
|
|
if len(fig.canonical_hashes) > 6:
|
|
fig.canonical_hashes = fig.canonical_hashes[-6:]
|
|
|
|
# Write + render AFTER
|
|
svg_abs.write_text(candidate)
|
|
after_rel = png_rel_for(fig, "after")
|
|
err = render_svg(fig.svg_path, after_rel)
|
|
if err:
|
|
git_revert(fig)
|
|
fig.rejected_attempts.append(f"c{cycle}: render-failed ({err[:80]})")
|
|
fig.iterations.append({"phase": "render-after", "cycle": cycle,
|
|
"error": err})
|
|
save_state(figures, meta); continue
|
|
|
|
# Queue for verify
|
|
verify_items.append({
|
|
"fig": fig,
|
|
"name": Path(fig.svg_path).name,
|
|
"problems": list(fig.current_problems),
|
|
"before_png": before_rel,
|
|
"after_png": after_rel,
|
|
"before_content": before_content,
|
|
"candidate": candidate,
|
|
"cycle": cycle,
|
|
})
|
|
|
|
save_state(figures, meta)
|
|
|
|
# ── PHASE 3: batched verify ───────────────────────────────────────
|
|
for k in range(0, len(verify_items), VERIFY_BATCH_SIZE):
|
|
if _budget_exhausted(): break
|
|
vbatch = verify_items[k:k + VERIFY_BATCH_SIZE]
|
|
bid = f"c{cycle}-verify-b{k//VERIFY_BATCH_SIZE + 1}"
|
|
print(f"[{bid}] verifying {len(vbatch)} before/after pairs ...", flush=True)
|
|
verdicts = phase_verify_batch(vbatch, bid)
|
|
|
|
# APPLY verdicts
|
|
for item in vbatch:
|
|
fig = item["fig"]
|
|
v = verdicts.get(item["name"], {})
|
|
action = v.get("next_action", "revert")
|
|
regression = v.get("regression")
|
|
|
|
fig.iterations.append({
|
|
"phase": "verify", "cycle": cycle,
|
|
"action": action, "regression": regression,
|
|
"resolved": v.get("resolved"),
|
|
})
|
|
|
|
if action == "accept":
|
|
# Commit the fix atomically
|
|
desc = (fig.current_problems[0] if fig.current_problems
|
|
else "aesthetic polish")
|
|
sha = git_commit(fig, cycle, desc)
|
|
if sha: fig.commit_shas.append(sha)
|
|
fig.cycles_completed += 1
|
|
# Stay in queue — may still have more problems to find next cycle
|
|
elif action == "iterate":
|
|
# Keep candidate SVG as baseline; don't revert, don't commit yet
|
|
# The next cycle's review will re-evaluate and propose further polish.
|
|
# But we SHOULD commit the partial progress so it's not lost on Ctrl-C.
|
|
desc = (fig.current_problems[0] if fig.current_problems
|
|
else "partial polish")
|
|
sha = git_commit(fig, cycle, f"[iter] {desc}")
|
|
if sha: fig.commit_shas.append(sha)
|
|
fig.cycles_completed += 1
|
|
else: # revert (or error/unknown)
|
|
git_revert(fig)
|
|
reason = regression or "verify-rejected"
|
|
fig.rejected_attempts.append(f"c{cycle}: {reason[:120]}")
|
|
|
|
save_state(figures, meta)
|
|
print(f" -> processed {len(vbatch)} pairs "
|
|
f"budget={_read_budget():.0f}s", flush=True)
|
|
|
|
# ── Post-cycles: close out anything still pending ─────────────────────
|
|
for fig in figures:
|
|
if fig.svg_path not in scope_set: continue
|
|
if fig.status in TERMINAL_STATUSES: continue
|
|
if skip_drafts and fig.is_draft:
|
|
fig.status = "skipped-draft"
|
|
fig.final_notes = "draft, skipped"
|
|
continue
|
|
# Still pending after MAX_CYCLES
|
|
if fig.cycles_completed > 0:
|
|
fig.status = "fixed"
|
|
fig.final_notes = (f"partial — {fig.cycles_completed} cycle(s) of edits "
|
|
f"accepted but still has open problems")
|
|
else:
|
|
fig.status = "needs-human"
|
|
fig.final_notes = (f"exhausted {MAX_CYCLES} cycles, {len(fig.current_problems)} "
|
|
f"problems remaining")
|
|
save_state(figures, meta)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State IO
|
|
# ---------------------------------------------------------------------------
|
|
def load_state() -> Optional[Dict[str, Any]]:
|
|
if not state_path().exists(): return None
|
|
try: return json.loads(state_path().read_text())
|
|
except Exception: return None
|
|
|
|
def save_state(figures: List[Figure], meta: Dict) -> None:
|
|
state = {
|
|
"meta": meta,
|
|
"figures": [asdict(f) for f in figures],
|
|
"totals": compute_totals(figures),
|
|
"gemini_wall_time_sec": _read_budget(),
|
|
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
}
|
|
tmp = state_path().with_suffix(".json.tmp")
|
|
tmp.write_text(json.dumps(state, indent=2))
|
|
tmp.replace(state_path())
|
|
|
|
def compute_totals(figures: List[Figure]) -> Dict[str, int]:
|
|
t = {"total": len(figures), "live": 0, "draft": 0,
|
|
"pending": 0, "clean": 0, "fixed": 0,
|
|
"needs-human": 0, "render-failed": 0, "skipped-draft": 0}
|
|
for f in figures:
|
|
t[f.status] = t.get(f.status, 0) + 1
|
|
if f.is_draft: t["draft"] += 1
|
|
else: t["live"] += 1
|
|
return t
|
|
|
|
def figures_from_state(state: Dict) -> List[Figure]:
|
|
return [Figure(**f) for f in state.get("figures", [])]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Modes
|
|
# ---------------------------------------------------------------------------
|
|
def _scope_paths(figs: List[Figure], scope: str) -> set:
|
|
if scope == "full": return {f.svg_path for f in figs}
|
|
if scope == "smoke": return {f.svg_path for f in figs if f.chapter == "vol2/introduction"}
|
|
return {f.svg_path for f in figs if f.chapter == scope}
|
|
|
|
def mode_audit(scope: str, skip_drafts: bool) -> int:
|
|
ensure_dirs()
|
|
state = load_state()
|
|
if state:
|
|
figures = figures_from_state(state)
|
|
meta = state.get("meta", {})
|
|
else:
|
|
figures = build_inventory()
|
|
meta = {
|
|
"model": GEMINI_MODEL,
|
|
"render_width_px": RENDER_WIDTH_PX,
|
|
"max_cycles": MAX_CYCLES,
|
|
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
"scope": scope,
|
|
"skip_drafts": skip_drafts,
|
|
}
|
|
scope_set = _scope_paths(figures, scope)
|
|
print(f"[audit] scope={scope} figures_in_scope={len(scope_set)} "
|
|
f"skip_drafts={skip_drafts}", flush=True)
|
|
|
|
run_fix_cycles(figures, scope_set, skip_drafts, meta)
|
|
|
|
totals = compute_totals(figures)
|
|
print(f"\n[audit] done. totals={totals}")
|
|
return 0
|
|
|
|
def mode_diagnose(scope: str, skip_drafts: bool) -> int:
|
|
"""Single review pass, no rewrites. Useful for pre-audit surveys."""
|
|
ensure_dirs()
|
|
figures = build_inventory()
|
|
meta = {
|
|
"model": GEMINI_MODEL, "mode": "diagnose-only",
|
|
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
}
|
|
scope_set = _scope_paths(figures, scope)
|
|
queue = [f for f in figures
|
|
if f.svg_path in scope_set and not (skip_drafts and f.is_draft)]
|
|
print(f"[diagnose-only] figures={len(queue)}", flush=True)
|
|
|
|
for i in range(0, len(queue), REVIEW_BATCH_SIZE):
|
|
batch = queue[i:i + REVIEW_BATCH_SIZE]
|
|
bid = f"diag-b{i//REVIEW_BATCH_SIZE + 1}"
|
|
print(f"[{bid}] reviewing {len(batch)} ...", flush=True)
|
|
phase_review_batch(batch, bid)
|
|
save_state(figures, meta)
|
|
print(f" budget={_read_budget():.0f}s", flush=True)
|
|
print(f"\n[diagnose-only] done. totals={compute_totals(figures)}")
|
|
return 0
|
|
|
|
def mode_report() -> int:
|
|
state = load_state()
|
|
if not state:
|
|
print("No state.json.", file=sys.stderr); return 2
|
|
figures = figures_from_state(state)
|
|
meta = state.get("meta", {})
|
|
totals = compute_totals(figures)
|
|
|
|
lines = [
|
|
"# SVG Figure Audit Report",
|
|
"",
|
|
f"- Run started: {meta.get('started_at','?')}",
|
|
f"- Model: `{meta.get('model','?')}`",
|
|
f"- Scope: `{meta.get('scope','?')}`",
|
|
f"- Skip drafts: {meta.get('skip_drafts','?')}",
|
|
f"- Max cycles: {meta.get('max_cycles', MAX_CYCLES)}",
|
|
f"- Gemini wall time: {_read_budget():.0f} s "
|
|
f"({_read_budget()/60:.1f} min)",
|
|
"",
|
|
"## Totals", "",
|
|
f"- Total figures: {totals['total']}",
|
|
f"- Live / Draft: {totals['live']} / {totals['draft']}",
|
|
f"- Fixed (committed): **{totals.get('fixed',0)}**",
|
|
f"- Clean (no edit needed): {totals.get('clean',0)}",
|
|
f"- Needs human: **{totals.get('needs-human',0)}**",
|
|
f"- Render-failed: {totals.get('render-failed',0)}",
|
|
f"- Skipped drafts: {totals.get('skipped-draft',0)}",
|
|
f"- Still pending: {totals.get('pending',0)}",
|
|
"",
|
|
"## Fixed (with commit SHAs)", "",
|
|
]
|
|
fixed = sorted([f for f in figures if f.status == "fixed"],
|
|
key=lambda x: (x.chapter, x.svg_path))
|
|
if not fixed: lines.append("_None._")
|
|
else:
|
|
lines.append("| Figure | Chapter | Cycles | Commits | Notes |")
|
|
lines.append("|---|---|---|---|---|")
|
|
for f in fixed:
|
|
shas = ", ".join(f.commit_shas) or "-"
|
|
lines.append(f"| `{Path(f.svg_path).name}` | {f.chapter} | "
|
|
f"{f.cycles_completed} | {shas} | {f.final_notes} |")
|
|
|
|
lines += ["", "## Needs human review", ""]
|
|
needs = sorted([f for f in figures if f.status == "needs-human"],
|
|
key=lambda x: (x.chapter, x.svg_path))
|
|
if not needs: lines.append("_None._")
|
|
else:
|
|
for f in needs:
|
|
lines.append(f"### `{Path(f.svg_path).name}` ({f.chapter})")
|
|
lines.append("")
|
|
lines.append(f"**Notes:** {f.final_notes}")
|
|
lines.append("")
|
|
lines.append(f"**Assessment:** {f.overall_assessment or '—'}")
|
|
lines.append("")
|
|
if f.current_problems:
|
|
lines.append("**Remaining problems:**")
|
|
for p in f.current_problems:
|
|
lines.append(f"- {p}")
|
|
lines.append("")
|
|
if f.rejected_attempts:
|
|
lines.append("**Rejected fix attempts:**")
|
|
for r in f.rejected_attempts[-5:]:
|
|
lines.append(f"- {r}")
|
|
lines.append("")
|
|
if f.png_path:
|
|
lines.append(f"**Rendered PNG:** `{f.png_path}`")
|
|
lines.append("")
|
|
|
|
lines += ["", "## Render-failed", ""]
|
|
rf = [f for f in figures if f.status == "render-failed"]
|
|
if not rf: lines.append("_None._")
|
|
else:
|
|
for f in rf:
|
|
lines.append(f"- `{f.svg_path}` — {f.render_error}")
|
|
|
|
report_path().write_text("\n".join(lines))
|
|
print(f"report -> {report_path().relative_to(worktree_root())}")
|
|
return 0
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
sub = ap.add_subparsers(dest="mode", required=True)
|
|
for name in ("audit", "diagnose", "fix"):
|
|
p = sub.add_parser(name)
|
|
p.add_argument("--scope", default="smoke",
|
|
help="smoke | full | <chapter slug>")
|
|
p.add_argument("--skip-drafts", action="store_true")
|
|
sub.add_parser("report")
|
|
args = ap.parse_args()
|
|
|
|
if args.mode in ("audit", "fix"):
|
|
return mode_audit(args.scope, getattr(args, "skip_drafts", False))
|
|
if args.mode == "diagnose":
|
|
return mode_diagnose(args.scope, getattr(args, "skip_drafts", False))
|
|
if args.mode == "report":
|
|
return mode_report()
|
|
return 2
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|