Claude Skill

plotting-agent

Step 2 of the PaperOrchestra pipeline (arXiv:2604.05018). Execute the visualization plan from outline.json — render plots and conceptual diagrams from experimental_log.md and idea.md, optionally refine via VLM critique loop, and produce context-aware captions. Runs in parallel wi

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ar9av-paperorchestra-skills_plotting-agent-36c3cc4.zip · 29 KB
Part of ar9av/paperorchestra — 8 skills

Install

skills CLI npx skills add https://github.com/Ar9av/PaperOrchestra/tree/main/skills/plotting-agent
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ar9av-paperorchestra@llmmart
Git git clone https://github.com/Ar9av/PaperOrchestra.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ar9av/paperorchestra collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Plotting Agent (Step 2)

Faithful implementation of the Plotting Agent from PaperOrchestra (Song et al., 2026, arXiv:2604.05018, §4 Step 2 and App. F.1 p.45).

Cost: ~20–30 LLM calls. The paper uses PaperBanana (Zhu et al., 2026) as the default backbone with a closed-loop VLM-critique refinement. This skill expresses that loop in host-agent terms: you (the host agent) generate matplotlib code with your own LLM, render via your Bash/Python tool, optionally critique the rendered PNG with your vision model, redraw, and finally caption.

Inputs

  • workspace/outline.json — specifically the plotting_plan array
  • workspace/inputs/idea.md and workspace/inputs/experimental_log.md — the source data
  • workspace/inputs/figures/ — optional pre-existing figures (PlotOn mode)

Outputs

  • workspace/figures/<figure_id>.png — one PNG per plotting_plan entry (300 DPI, sized to the requested aspect ratio)
  • workspace/figures/captions.json — {figure_id: caption_text} map

Workflow

Per figure (executed independently per figure_id)

  1. Read the figure spec from outline.json:

    {
      "figure_id": "fig_main_results",
      "title": "Main Results on Dataset X",
      "plot_type": "plot",
      "data_source": "experimental_log.md",
      "objective": "Visual summary (Grouped Bar Chart) demonstrating ...",
      "aspect_ratio": "5:4"
    }
    
  2. Few-shot retrieval (visual planning): pick the matching pattern from references/chart-patterns.md (for plot_type=="plot") or references/diagram-patterns.md (for plot_type=="diagram").

  3. Extract data: parse idea.md and/or experimental_log.md (data_source field tells you which) to obtain the numeric values or conceptual entities the figure needs. For experimental_log.md, the ## 2. Raw Numeric Data section contains markdown tables.

  4. Render:

    If PAPERBANANA_PATH is set — use the PaperBanana backbone (Zhu et al., 2026). It runs a Retriever → Planner → Stylist → Visualizer → Critic loop and is especially good for plot_type == "diagram". See references/paperbanana-cookbook.md for setup (needs a Gemini API key).

    python skills/plotting-agent/scripts/paperbanana_render.py \
        --figure-id <figure_id> \
        --caption   "<objective from figure spec>" \
        --content-file workspace/inputs/idea.md \
        --task      <diagram|plot> \
        --aspect-ratio <aspect_ratio> \
        --out       workspace/figures/<figure_id>.png
    

    Otherwise — write a matplotlib script and run it via your Bash tool, or use the bundled helper:

    python skills/plotting-agent/scripts/render_matplotlib.py \
        --spec spec.json \
        --out workspace/figures/<figure_id>.png
    

    The script must apply the academic style from chart-patterns.md, use the correct pixel size from aspect-ratios.md, save at 300 DPI, and call plt.close() after savefig.

  5. VLM critique loop (optional, only if your host has vision):

    • Reload the rendered PNG as a multimodal input to your LLM.
    • Critique it against the figure's objective from the outline. Look for: visual artifacts, mislabeled axes, illegible text, color clashes, misleading scaling, missing legend, overlapping labels.
    • If problems are found, regenerate the matplotlib script with corrections and re-render. Cap at 3 critique iterations per figure.
    • This is the closed-loop refinement step the paper inherits from PaperBanana. See references/plotting-pipeline.md for the full loop description.
    • If your host has no vision input, skip this step entirely. The figure will still render correctly, just without iterative refinement.
  6. Generate the caption using the verbatim Caption Generation prompt at references/caption-prompt.md. Inputs to the caption prompt:

    • task_name — the section the figure belongs to (e.g., "Methodology", "Experiments")
    • raw_content — the surrounding section text (or content_bullets from the section_plan if the section isn't drafted yet)
    • description — the objective field from the figure spec
    • figure_desc — a 1-sentence description of what the rendered figure actually shows (from your VLM critique pass, or from the script's plan if no vision)

    Write the caption to workspace/figures/captions.json keyed by figure_id. Captions must NOT contain Figure N: or Caption N: prefixes — the LaTeX template handles numbering. Plain text only, no markdown.

Conceptual diagrams

For plot_type == "diagram", prefer PaperBanana when available — its Retriever grounds the Planner in real published paper diagrams. If PAPERBANANA_PATH is unset, follow references/diagram-patterns.md. Patterns include block diagrams, system overviews, flowcharts, and algorithm-as-graph. The bundled helper:

python skills/plotting-agent/scripts/render_diagram.py \
    --spec diagram_spec.json \
    --out workspace/figures/<figure_id>.png

handles the simple cases (boxes-and-arrows). For complex Fig-1-style overview diagrams, write matplotlib patches code yourself.

Hard rules

  • 300 DPI for every figure. Lower DPI gets rejected at the LaTeX compile step on conference templates.
  • Aspect ratio is exact. The figure spec's aspect_ratio is one of 12 enumerated strings. Use the pixel targets in references/aspect-ratios.md.
  • Hide top and right spines for plots. (Diagrams: no spines at all.)
  • Muted academic colors only. The palette is in chart-patterns.md. Never use matplotlib defaults (too saturated for print).
  • No 3D, no pie charts, no decorative visuals. The paper's evaluators penalize these.
  • Every figure MUST have a caption in captions.json. The Section Writing Agent will fail-stop if a caption is missing for any figure referenced from the outline.
  • No Figure N: prefix in captions — LaTeX adds it.
  • Never describe data you didn't plot. The Plotting Agent must not hallucinate axes, baselines, or trends. Source-of-truth is experimental_log.md or idea.md.

Verification gate (run before handing off to Step 3/4)

The hard rules above are stated everywhere and enforced nowhere. This gate makes the mechanical half checkable:

python skills/plotting-agent/scripts/figure_lint.py \
    --figures workspace/figures \
    --captions workspace/figures/captions.json

ERRORs: a rendered figure with no caption, a caption with no file, an empty caption, a raster too small to print. WARNs: resolution under ~300 DPI at single-column width, aspect ratios past 4:1, captions that number themselves (Figure 3: ...), captions under eight words, and a figure set with no architecture/pipeline/overview figure in it.

PNG geometry is read from the IHDR and pHYs chunks directly — no imaging library, consistent with the repo's deterministic-helpers-only rule.

After Step 4 has produced paper.tex, re-run with --paper to confirm the draft uses every figure Step 2 rendered and references no file that does not exist:

python skills/plotting-agent/scripts/figure_lint.py \
    --figures workspace/figures \
    --paper   workspace/drafts/paper.tex

Fix ERRORs before continuing. A missing caption is the one failure that propagates silently: Step 4 splices the figure with whatever caption it invents, and Step 5 has no way to know the caption was never grounded.

Pre-existing figures (PlotOn mode)

If workspace/inputs/figures/ is non-empty, check whether any pre-existing file matches a figure_id in the outline (by filename prefix). If so, copy it into workspace/figures/ as-is and still generate a caption using the caption prompt. Only generate from scratch the figure_ids that have no pre-existing counterpart.

Resources

  • references/caption-prompt.md — verbatim Caption Generation prompt from App. F.1
  • references/plotting-pipeline.md — the full few-shot → render → critique → caption loop
  • references/chart-patterns.md — matplotlib style + chart type recipes
  • references/diagram-patterns.md — conceptual diagram recipes
  • references/aspect-ratios.md — pixel targets for each of the 12 allowed ratios at 300 DPI
  • references/paperbanana-cookbook.md — NEW PaperBanana setup, usage, cost notes, attribution
  • scripts/render_matplotlib.py — render a JSON plot spec → PNG (matplotlib fallback)
  • scripts/render_diagram.py — render a JSON diagram spec → PNG (matplotlib fallback)
  • scripts/paperbanana_render.py — NEW PaperBanana backbone wrapper (reads PAPERBANANA_PATH from env)
  • scripts/figure_lint.py — NEW resolution / aspect / caption-coverage gate; --paper cross-checks \includegraphics
Files (paperorchestra)
  • references
    • aspect-ratios.md 2 KB
      # Aspect Ratios
      
      The Outline Agent's prompt enumerates exactly 12 allowed aspect ratios (App.
      F.1, page 41). Each one maps to a specific figure size in inches at 300 DPI.
      The plotting agent MUST use these — anything else fails the outline schema
      validator.
      
      | Ratio | Inches (W × H) | Pixels @ 300 DPI | Use case |
      |---|---|---|---|
      | `1:1`  | 3.4 × 3.4    | 1020 × 1020 | Square radar chart, 1×1 ablation grid |
      | `1:4`  | 1.8 × 7.2    | 540 × 2160  | Tall vertical strip (rare; e.g., per-class bars) |
      | `2:3`  | 3.4 × 5.1    | 1020 × 1530 | Portrait single-column figure |
      | `3:2`  | 5.1 × 3.4    | 1530 × 1020 | Landscape single-column figure |
      | `3:4`  | 3.0 × 4.0    | 900 × 1200  | Tall stacked subplots |
      | `4:1`  | 7.0 × 1.75   | 2100 × 525  | Banner / timeline / strip-plot |
      | `4:3`  | 4.0 × 3.0    | 1200 × 900  | Standard single-column rectangle |
      | `4:5`  | 3.2 × 4.0    | 960 × 1200  | Slight portrait, 5-row heatmap |
      | `5:4`  | 4.5 × 3.6    | 1350 × 1080 | Wide single-column |
      | `9:16` | 2.8 × 4.97   | 840 × 1491  | Mobile / tall portrait |
      | `16:9` | 5.5 × 3.09   | 1650 × 927  | Cross-column wide chart |
      | `21:9` | 7.0 × 3.0    | 2100 × 900  | Full-page-width banner |
      
      ## Width budget
      
      The widths are chosen to slot into the most common LaTeX layouts:
      
      - **2-column conference template** (CVPR, ICCV, NeurIPS):
        single-column = 3.3in, double-column = 7.0in. Use widths ≤ 3.5in for
        `\begin{figure}` and ≤ 7.0in for `\begin{figure*}`.
      - **1-column template** (ICLR, plain article): page text width is ~6.5in.
      
      The `fig_size_for(ratio)` helper in `chart-patterns.md` returns the
      matching `(width, height)` tuple. The bundled `render_matplotlib.py` script
      uses the same table.
      
      ## DPI
      
      Always 300. Conference templates reject ≤150 DPI raster figures.
      
      ## Strict mapping in JSON spec
      
      The `render_matplotlib.py` and `render_diagram.py` helpers accept a JSON
      spec with `"aspect_ratio"` set to one of the 12 strings above. They look
      up the size, set `figsize`, set `dpi=300`, and call `tight_layout()` +
      `bbox_inches='tight'` automatically.
      
    • caption-prompt.md 2.7 KB
      # Plotting Agent — Caption Generation prompt
      
      **Source: arXiv:2604.05018, Appendix F.1, page 45 (verbatim).**
      
      The paper uses PaperBanana (Zhu et al., 2026) for the entire visual generation
      pipeline; it then appends a single caption regeneration step at the end using
      the prompt below. We reproduce only this final caption step verbatim because
      it is the part the paper authors actually wrote (PaperBanana's internal
      prompts are external and not reproduced here).
      
      ---
      
      ```
      Input Data
        - Task Type:               {task_name}
        - Contextual Section:      {raw_content}
        - Overall Figure Intent:   {description}
        - Detailed Figure Description: {figure_desc}
      
      Please provide the final caption for this figure based on the system
      instructions.
      
      Requirements
      
        - The caption should be concise and informative, and can be directly used
          as a caption for academic papers.
        - The caption MUST NOT contain a "Figure X:" or "Caption X:" prefix, as
          the latex template will add it automatically.
        - The caption MUST NOT contain any markdown formatting (like bold,
          italics, etc), it should be plain text.
      
      Respond with the plain text caption only.
      ```
      
      ---
      
      ## Field substitution guide
      
      | Field | Source |
      |---|---|
      | `{task_name}` | The section the figure belongs to. Look it up in `outline.json` — find which section's `content_bullets` (or surrounding text in `drafts/paper.tex` if Step 4 has run) reference this `figure_id`. Common values: `"Methodology"`, `"Experiments"`, `"Ablation Studies"`. |
      | `{raw_content}` | The text of the section the figure appears in. If Step 4 has run, paste the relevant paragraph from `drafts/paper.tex`. If not, paste the joined `content_bullets` from the corresponding subsection in `outline.json`. |
      | `{description}` | The `objective` field from the figure spec in `outline.json`. |
      | `{figure_desc}` | A 1-2 sentence factual description of what the rendered PNG actually contains. If your host has vision, generate this by inspecting the rendered PNG. If not, derive it from the matplotlib script you wrote (e.g., "Grouped bar chart comparing methods A, B, C across metrics M1 and M2."). |
      
      ## Output
      
      Plain text. One caption. No prefix, no markdown, no quotes around it.
      Save the result into `workspace/figures/captions.json` keyed by `figure_id`:
      
      ```json
      {
        "fig_main_results": "Comparison of three temporal-attention variants on the Ref-AVS Seen split. Bars show Jaccard index; error bars are 95% confidence intervals over five seeds.",
        "fig_framework_overview": "Overview of the proposed pipeline. Raw video frames flow into the frozen SAM encoder; aligned audio cues are projected through the temporal modality fusion layer before being injected into the mask decoder."
      }
      ```
      
    • chart-patterns.md 6.6 KB
      # Chart Patterns
      
      Matplotlib recipes for academic-paper figures. Adapted from
      `~/.all-skills/academic-paper/references/chart-patterns.md` and tuned for the
      PaperOrchestra plotting agent's aspect-ratio constraints.
      
      ## Global style config
      
      Apply this at the top of every plotting script.
      
      ```python
      import matplotlib
      matplotlib.use('Agg')             # headless
      import matplotlib.pyplot as plt
      import numpy as np
      
      plt.rcParams.update({
          'font.family':       'serif',
          'font.serif':        ['Times New Roman', 'DejaVu Serif'],
          'font.size':         8,
          'axes.titlesize':    9,
          'axes.titleweight':  'bold',
          'axes.labelsize':    8,
          'axes.linewidth':    0.6,
          'legend.fontsize':   7,
          'legend.framealpha': 0.95,
          'legend.edgecolor':  '#cccccc',
          'xtick.labelsize':   7,
          'ytick.labelsize':   7,
          'figure.dpi':        300,
          'savefig.dpi':       300,
          'savefig.bbox':      'tight',
          'savefig.pad_inches': 0.08,
          'grid.alpha':        0.15,
          'grid.linewidth':    0.5,
          'lines.linewidth':   1.3,
      })
      
      # Muted academic palette (print-safe)
      BLUE   = '#2060cc'
      RED    = '#cc3030'
      GREEN  = '#208040'
      ORANGE = '#cc7020'
      PURPLE = '#8040cc'
      GOLD   = '#b08020'
      GRAY   = '#666666'
      PALETTE = [BLUE, RED, GREEN, ORANGE, PURPLE, GOLD, GRAY]
      ```
      
      ## Per-pattern recipes
      
      ### Line chart (training curves, scaling laws)
      
      ```python
      fig, ax = plt.subplots(figsize=fig_size_for("16:9"))
      for i, (name, ys) in enumerate(series.items()):
          ax.plot(xs, ys, color=PALETTE[i], label=name)
      ax.set_xlabel('Training step')
      ax.set_ylabel('Validation loss')
      ax.legend(loc='upper right', frameon=True)
      ax.grid(True)
      ax.spines['top'].set_visible(False)
      ax.spines['right'].set_visible(False)
      fig.tight_layout()
      fig.savefig(out_path)
      plt.close()
      ```
      
      For noisy time-series, smooth with a rolling mean before plotting:
      ```python
      def rolling(y, w=7):
          return np.convolve(y, np.ones(w)/w, mode='valid')
      ```
      
      ### Grouped bar chart (method comparison across metrics)
      
      ```python
      methods = ['Baseline', 'Ours-S', 'Ours-L']
      metrics = ['Acc', 'F1', 'AUC']
      data = np.array([[78, 79, 0.83],
                       [82, 84, 0.87],
                       [85, 87, 0.91]])  # rows=methods, cols=metrics
      
      fig, ax = plt.subplots(figsize=fig_size_for("5:4"))
      x = np.arange(len(metrics))
      w = 0.25
      for i, m in enumerate(methods):
          ax.bar(x + (i-1)*w, data[i], w, color=PALETTE[i], label=m,
                 edgecolor='white', linewidth=0.4)
      ax.set_xticks(x)
      ax.set_xticklabels(metrics)
      ax.set_ylabel('Score')
      ax.legend(loc='upper left')
      ax.spines['top'].set_visible(False)
      ax.spines['right'].set_visible(False)
      ax.grid(axis='y', alpha=0.2)
      fig.tight_layout()
      fig.savefig(out_path)
      plt.close()
      ```
      
      ### Radar chart (multi-axis SOTA comparison — paper's Fig 2 pattern)
      
      ```python
      labels = ['Originality', 'Quality', 'Clarity', 'Significance', 'Soundness']
      ours    = [3.0, 3.4, 3.6, 3.0, 3.4]
      baseline = [2.5, 2.7, 3.0, 2.3, 2.8]
      
      angles = np.linspace(0, 2*np.pi, len(labels), endpoint=False).tolist()
      angles += angles[:1]
      ours    = ours + ours[:1]
      baseline = baseline + baseline[:1]
      
      fig, ax = plt.subplots(figsize=fig_size_for("1:1"), subplot_kw=dict(polar=True))
      ax.plot(angles, ours, color=BLUE, linewidth=1.5, label='Ours')
      ax.fill(angles, ours, color=BLUE, alpha=0.15)
      ax.plot(angles, baseline, color=RED, linewidth=1.5, label='Baseline')
      ax.fill(angles, baseline, color=RED, alpha=0.10)
      ax.set_xticks(angles[:-1])
      ax.set_xticklabels(labels, fontsize=7)
      ax.set_yticks([1, 2, 3, 4])
      ax.set_ylim(0, 4)
      ax.legend(loc='upper right', bbox_to_anchor=(1.2, 1.05))
      fig.tight_layout()
      fig.savefig(out_path)
      plt.close()
      ```
      
      ### Stacked bar / win-rate plot (paper's Fig 2/3 SxS pattern)
      
      ```python
      methods = ['Single Agent', 'AI-Sci-v2', 'PaperOrchestra']
      wins = [33, 22, 65]
      ties = [22, 18, 25]
      losses = [45, 60, 10]
      
      fig, ax = plt.subplots(figsize=fig_size_for("4:3"))
      x = np.arange(len(methods))
      ax.bar(x, losses, color=RED, label='Baseline win', edgecolor='white', linewidth=0.4)
      ax.bar(x, ties, bottom=losses, color=GRAY, label='Tie', edgecolor='white', linewidth=0.4)
      ax.bar(x, wins, bottom=np.array(losses)+np.array(ties),
             color=BLUE, label='Our win', edgecolor='white', linewidth=0.4)
      ax.set_xticks(x)
      ax.set_xticklabels(methods)
      ax.set_ylabel('Percentage (%)')
      ax.set_ylim(0, 100)
      ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.15), ncol=3)
      ax.spines['top'].set_visible(False)
      ax.spines['right'].set_visible(False)
      fig.tight_layout()
      fig.savefig(out_path)
      plt.close()
      ```
      
      ### Heatmap (ablation grid)
      
      ```python
      import matplotlib.colors as mcolors
      data = np.random.rand(5, 6) * 0.4 + 0.5  # placeholder
      xticks = ['M1', 'M2', 'M3', 'M4', 'M5', 'M6']
      yticks = ['A=0.1', 'A=0.2', 'A=0.5', 'A=1.0', 'A=2.0']
      
      fig, ax = plt.subplots(figsize=fig_size_for("4:3"))
      im = ax.imshow(data, cmap='Blues', vmin=0.4, vmax=1.0, aspect='auto')
      ax.set_xticks(range(len(xticks)))
      ax.set_xticklabels(xticks)
      ax.set_yticks(range(len(yticks)))
      ax.set_yticklabels(yticks)
      for i in range(data.shape[0]):
          for j in range(data.shape[1]):
              ax.text(j, i, f'{data[i,j]:.2f}', ha='center', va='center',
                      color='white' if data[i,j] > 0.7 else 'black', fontsize=6)
      fig.colorbar(im, ax=ax, fraction=0.04, pad=0.02)
      fig.tight_layout()
      fig.savefig(out_path)
      plt.close()
      ```
      
      ### Multi-panel (side-by-side ablation/case study)
      
      ```python
      fig, axes = plt.subplots(1, 3, figsize=fig_size_for("21:9"))
      for ax, (label, data) in zip(axes, panels.items()):
          ax.plot(data['x'], data['y'], color=BLUE)
          ax.set_title(label, fontsize=8, fontweight='bold')
          ax.spines['top'].set_visible(False)
          ax.spines['right'].set_visible(False)
      fig.tight_layout()
      fig.savefig(out_path)
      plt.close()
      ```
      
      ## Aspect ratio helper
      
      ```python
      def fig_size_for(ratio: str) -> tuple[float, float]:
          """Return (width_inches, height_inches) at a fixed width target.
          Width target: 5.5in for single-column, 7.0in for full-page wide."""
          w_to_h = {
              "1:1":  (3.4, 3.4),
              "1:4":  (1.8, 7.2),
              "2:3":  (3.4, 5.1),
              "3:2":  (5.1, 3.4),
              "3:4":  (3.0, 4.0),
              "4:1":  (7.0, 1.75),
              "4:3":  (4.0, 3.0),
              "4:5":  (3.2, 4.0),
              "5:4":  (4.5, 3.6),
              "9:16": (2.8, 4.97),
              "16:9": (5.5, 3.09),
              "21:9": (7.0, 3.0),
          }
          return w_to_h[ratio]
      ```
      
      ## Anti-patterns
      
      - Never use 3D charts, pie charts, or decorative visuals.
      - Never use default matplotlib colors (too saturated for print).
      - Never skip axis labels or units.
      - Never place legend outside the axes area without `bbox_to_anchor` (causes overflow).
      - Always `plt.close()` after `savefig()` to prevent memory leaks across many figures.
      - Never put a `Figure N:` text into the chart itself — captions handle that.
      
    • diagram-patterns.md 3.9 KB
      # Conceptual Diagram Patterns
      
      For `plot_type == "diagram"` figures: framework overviews, system pipelines,
      algorithmic flows, architectural block diagrams.
      
      The paper's Fig. 1 (PaperOrchestra overview) is a canonical example: boxes
      representing agents, arrows showing data flow, grouped sub-systems with
      labeled inputs and outputs.
      
      ## Tools
      
      - **matplotlib patches** — best for hand-controlled layouts (boxes-and-arrows
        with exact positioning, labels, and group rectangles). No external deps.
      - **graphviz** — best for DAGs where layout doesn't matter; requires the
        `graphviz` Python binding and the `dot` system binary. Use only if the
        diagram is purely topological.
      
      This skill defaults to matplotlib because it ships in `requirements.txt` and
      needs no system binary.
      
      ## Block diagram pattern (boxes + arrows)
      
      ```python
      import matplotlib.pyplot as plt
      from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
      from matplotlib.lines import Line2D
      
      # 16:9 frame
      fig, ax = plt.subplots(figsize=(7.0, 3.94))
      ax.set_xlim(0, 10); ax.set_ylim(0, 6)
      ax.axis('off')
      
      PALETTE = {
          'input':   '#cfe2f3',
          'agent':   '#9fc5e8',
          'output':  '#b6d7a8',
          'control': '#ead1dc',
          'border':  '#2060cc',
      }
      
      def box(x, y, w, h, text, color):
          bb = FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.06,rounding_size=0.15",
                              ec=PALETTE['border'], fc=color, lw=0.8)
          ax.add_patch(bb)
          ax.text(x + w/2, y + h/2, text, ha='center', va='center',
                  fontsize=8, fontweight='bold')
      
      def arrow(x1, y1, x2, y2):
          a = FancyArrowPatch((x1, y1), (x2, y2),
                              arrowstyle='->', mutation_scale=10,
                              color=PALETTE['border'], lw=0.9)
          ax.add_patch(a)
      
      # Inputs (left column)
      box(0.2, 4.8, 1.6, 0.7, "Idea (I)",            PALETTE['input'])
      box(0.2, 3.6, 1.6, 0.7, "Exp Log (E)",         PALETTE['input'])
      box(0.2, 2.4, 1.6, 0.7, "Template (T)",        PALETTE['input'])
      box(0.2, 1.2, 1.6, 0.7, "Guidelines (G)",      PALETTE['input'])
      
      # Agents (middle)
      box(2.6, 3.6, 1.8, 1.0, "Outline\nAgent",      PALETTE['agent'])
      box(5.0, 4.8, 1.8, 0.9, "Plotting\nAgent",     PALETTE['agent'])
      box(5.0, 3.0, 1.8, 0.9, "Lit Review\nAgent",   PALETTE['agent'])
      box(5.0, 1.2, 1.8, 0.9, "Section\nWriter",     PALETTE['agent'])
      box(7.5, 2.4, 2.0, 1.6, "Refinement\nAgent",   PALETTE['control'])
      
      # Output
      box(7.5, 0.4, 2.0, 0.9, "paper.tex\n+ paper.pdf", PALETTE['output'])
      
      # Arrows
      for y_in in (5.15, 3.95, 2.75, 1.55):
          arrow(1.8, y_in, 2.6, 4.1)
      arrow(4.4, 4.1, 5.0, 5.25)
      arrow(4.4, 4.1, 5.0, 3.45)
      arrow(4.4, 4.1, 5.0, 1.65)
      for y in (5.25, 3.45, 1.65):
          arrow(6.8, y, 7.5, 3.2)
      arrow(8.5, 2.4, 8.5, 1.3)
      
      fig.savefig(out_path, dpi=300, bbox_inches='tight', pad_inches=0.05)
      plt.close()
      ```
      
      ## Pipeline / flowchart pattern (linear with branches)
      
      Same primitives, just arranged left-to-right with `arrow(x1, y1, x2, y2)`
      between successive boxes. For parallel branches (Steps 2 ∥ 3 in
      PaperOrchestra), draw two parallel rows with a fork-and-join.
      
      ## Algorithm-as-graph pattern
      
      For diagrams that are *just* a topological graph (no labels of importance,
      just nodes-and-edges), use graphviz:
      
      ```python
      import graphviz
      g = graphviz.Digraph(format='png')
      g.attr(dpi='300', rankdir='LR', fontname='Times-Roman', fontsize='8')
      g.attr('node', shape='box', style='rounded,filled',
             fillcolor='#cfe2f3', color='#2060cc', fontname='Times-Roman', fontsize='8')
      g.attr('edge', color='#2060cc', fontname='Times-Roman', fontsize='7')
      g.edge('Input', 'Encoder')
      g.edge('Encoder', 'Decoder')
      g.edge('Decoder', 'Output')
      g.render(out_path.replace('.png', ''), cleanup=True)
      ```
      
      ## Anti-patterns
      
      - Never use clip-art icons or emoji.
      - Never use color as the *only* signal (always pair with shape or label).
      - Never let arrows cross labels — re-route around.
      - Never use a bitmap background.
      - Always `axis('off')` for diagrams; no spines, no ticks, no grid.
      
    • paperbanana-cookbook.md 6 KB
      # PaperBanana Cookbook
      
      Optional backbone for the plotting-agent's render step.
      
      PaperBanana (Zhu et al., 2026 — <https://github.com/dwzhu-pku/PaperBanana>)
      is the default figure-generation backbone described in the PaperOrchestra paper
      (arXiv:2604.05018, §4 Step 2).  It runs a
      **Retriever → Planner → Stylist → Visualizer → Critic** multi-agent loop,
      producing publication-quality diagrams and plots that are grounded in a curated
      reference collection.
      
      The plotting-agent works fine without PaperBanana — it falls back to the
      bundled matplotlib renderer (`render_matplotlib.py` / `render_diagram.py`).
      PaperBanana is best for:
      
      | Use case | Recommendation |
      |---|---|
      | Complex architectural / framework overview diagrams (`plot_type == "diagram"`) | **PaperBanana** (Retriever grounds style in real paper examples) |
      | Plots backed by numeric data in `experimental_log.md` | Either; matplotlib gives more deterministic axis values |
      | Hosts with no vision capability | Matplotlib (PaperBanana's Critic loop needs a VLM) |
      | Batch / non-interactive runs | Either; PaperBanana adds API cost per figure |
      
      ---
      
      ## 1. One-time setup
      
      ```bash
      # Clone PaperBanana
      git clone https://github.com/dwzhu-pku/PaperBanana /path/to/PaperBanana
      
      # Install dependencies (Python 3.12 required)
      cd /path/to/PaperBanana
      uv pip install -r requirements.txt      # or: pip install -r requirements.txt
      
      # Configure API key — fill at least one, you do not need both
      cp configs/model_config.template.yaml configs/model_config.yaml
      ```
      
      Open `configs/model_config.yaml` and paste your key:
      
      | Provider | Where to get a key | Field to fill |
      |---|---|---|
      | **Google (Gemini)** | [aistudio.google.com](https://aistudio.google.com/) (free) | `api_keys.google_api_key` |
      | **OpenRouter** | [openrouter.ai](https://openrouter.ai/) | `api_keys.openrouter_api_key` |
      
      If both are set, OpenRouter is preferred. For OpenRouter, set the model names
      to `"openrouter/<model>"` (e.g. `"openrouter/google/gemini-pro-1.5"`).
      
      ```bash
      # Point paper-orchestra at your clone
      export PAPERBANANA_PATH="/path/to/PaperBanana"
      ```
      
      Optional model overrides (take precedence over `model_config.yaml`):
      
      ```bash
      export PAPERBANANA_MAIN_MODEL="gemini-3.1-pro-preview"
      export PAPERBANANA_IMAGE_MODEL="gemini-3.1-flash-image-preview"
      ```
      
      Verify the setup before running the pipeline:
      
      ```bash
      python skills/plotting-agent/scripts/paperbanana_render.py --check-backend
      # Expected output:
      #   PaperBanana found at: /path/to/PaperBanana
      #   Backend is ready.
      ```
      
      ---
      
      ## 2. Rendering a single figure
      
      ```bash
      # Diagram (e.g. Figure 1 overview)
      python skills/plotting-agent/scripts/paperbanana_render.py \
          --figure-id   fig_overview \
          --caption     "Figure 1: Overview of our proposed PaperOrchestra framework." \
          --content-file workspace/inputs/idea.md \
          --task        diagram \
          --aspect-ratio 16:9 \
          --out         workspace/figures/fig_overview.png
      
      # Plot (e.g. main results bar chart)
      python skills/plotting-agent/scripts/paperbanana_render.py \
          --figure-id   fig_main_results \
          --caption     "Figure 3: Comparison of our method against baselines." \
          --content-file workspace/inputs/experimental_log.md \
          --task        plot \
          --aspect-ratio 5:4 \
          --max-critic-rounds 2 \
          --out         workspace/figures/fig_main_results.png
      ```
      
      Exit codes:
      - `0` — success, image saved at `--out`
      - `1` — PaperBanana pipeline error (see stderr)
      - `2` — `PAPERBANANA_PATH` not set or invalid → fall back to matplotlib
      
      ---
      
      ## 3. Input format (PaperBanana)
      
      The wrapper converts the plotting-agent's figure spec to PaperBanana's input dict:
      
      | plotting-agent field | PaperBanana field | Notes |
      |---|---|---|
      | `objective` | `caption` + `visual_intent` | Used as generation prompt |
      | `idea.md` or `experimental_log.md` | `content` | Method/data context |
      | `aspect_ratio` | `additional_info.rounded_ratio` | Same string format |
      | `plot_type` | `task_name` | `diagram` or `plot` |
      
      ---
      
      ## 4. Output format (PaperBanana)
      
      PaperBanana stores all intermediate and final images as base64-encoded JPEG strings
      in the result dictionary.  The wrapper selects the best image using this priority:
      
      1. Latest critic round: `target_{task}_critic_descN_base64_jpg` (N = 0…3)
      2. `eval_image_field` pointer
      3. Stylist output: `target_{task}_stylist_desc0_base64_jpg`
      4. Planner output: `target_{task}_desc0_base64_jpg`
      5. Vanilla baseline: `vanilla_{task}_base64_jpg`
      
      The selected image is decoded and saved as a 300-DPI PNG to `--out`.
      
      ---
      
      ## 5. Pipeline modes
      
      PaperBanana's `exp_mode` controls which agents run.  The wrapper uses
      `demo_full` (full pipeline, no benchmark evaluation):
      
      | Mode | Pipeline |
      |---|---|
      | `demo_full` *(default)* | Retriever → Planner → Stylist → Visualizer → Critic (3×) |
      | `demo_planner_critic` | Retriever → Planner → Visualizer → Critic (3×) |
      
      To override, set `--exp-mode` (future flag — hardcoded to `demo_full` today).
      
      ---
      
      ## 6. Cost and rate limits
      
      - PaperBanana makes multiple LLM calls per figure (~5–10 in `demo_full` mode).
      - API cost depends on the model chosen; Gemini Flash is cheapest for image gen.
      - No S2 / Exa calls — PaperBanana uses its own reference collection for retrieval.
      - Runs one figure at a time when invoked via `paperbanana_render.py`; parallel
        batch processing is available via PaperBanana's native `main.py` if you have
        many figures.
      
      ---
      
      ## 7. Security notes
      
      - `PAPERBANANA_PATH`, `PAPERBANANA_MAIN_MODEL`, and `PAPERBANANA_IMAGE_MODEL`
        are read from the environment only.  This repo never commits these values.
      - API keys for PaperBanana live in `{PAPERBANANA_PATH}/configs/model_config.yaml`,
        which is `.gitignore`'d in the PaperBanana repo.  Never commit that file.
      
      ---
      
      ## 8. Attribution
      
      If you use PaperBanana as the plotting backbone, cite:
      
      ```bibtex
      @article{zhu2026paperbanana,
        title={PaperBanana: A Reference-Driven Multi-Agent Framework for
               Automated Academic Illustration Generation},
        author={Zhu, Dawei and others},
        year={2026},
        url={https://github.com/dwzhu-pku/PaperBanana}
      }
      ```
      
    • plotting-pipeline.md 5.8 KB
      # Plotting Pipeline (per-figure loop)
      
      Source: arXiv:2604.05018, §4 Step 2 ("Plotting Agent"), App. B (cost: ~20-30
      LLM calls including few-shot retrieval, visual planning, image generation,
      VLM-guided critique-and-redraw cycles, and context-aware captioning).
      
      The paper outsources visual generation to PaperBanana (Zhu et al., 2026)
      internally. We can't redistribute PaperBanana, so this skill expresses the
      *loop structure* in terms a host coding agent can execute with its own LLM
      and matplotlib.
      
      ## The 5-stage per-figure loop
      
      ```
                      ┌─────────────────────────────────────┐
                      │  outline.json  →  figure spec        │
                      │  { figure_id, plot_type, objective,  │
                      │    aspect_ratio, data_source }       │
                      └─────────────────┬───────────────────┘
                                        │
                                        ▼
             ┌──────────────────────────────────────────────────┐
             │  STAGE 1 — Few-shot retrieval                     │
             │  Pick the matching pattern from chart-patterns.md │
             │  (plot) or diagram-patterns.md (diagram). Identify│
             │  variables you'll need from the data sources.     │
             └─────────────────┬────────────────────────────────┘
                               │
                               ▼
             ┌──────────────────────────────────────────────────┐
             │  STAGE 2 — Visual planning                        │
             │  Sketch (in your head / in JSON) the chart layout:│
             │  axes, series, colors, legend placement, title.   │
             │  Resolve aspect_ratio → pixel dimensions          │
             │  via aspect-ratios.md.                            │
             └─────────────────┬────────────────────────────────┘
                               │
                               ▼
             ┌──────────────────────────────────────────────────┐
             │  STAGE 3 — Image generation                       │
             │  Write matplotlib code. Apply the global style    │
             │  from chart-patterns.md. Save to                  │
             │  workspace/figures/<figure_id>.png at 300 DPI.    │
             │  Run via your Bash tool, OR call                  │
             │  scripts/render_matplotlib.py with a spec JSON.   │
             └─────────────────┬────────────────────────────────┘
                               │
                               ▼
             ┌──────────────────────────────────────────────────┐
             │  STAGE 4 — VLM critique loop  (skip if no vision) │
             │  for iter in 1..3:                                │
             │      load PNG as multimodal input to your LLM     │
             │      critique against `objective` field           │
             │      if no issues: break                          │
             │      else: regenerate matplotlib code, re-render  │
             └─────────────────┬────────────────────────────────┘
                               │
                               ▼
             ┌──────────────────────────────────────────────────┐
             │  STAGE 5 — Caption                                │
             │  Use the verbatim caption-prompt.md.              │
             │  Save into figures/captions.json.                 │
             └──────────────────────────────────────────────────┘
      ```
      
      ## What to look for in the VLM critique
      
      When you (the host agent) inspect a rendered figure with vision, score it
      against this checklist (derived from the paper's plotting failure modes and
      the academic-paper skill's review-guide.md):
      
      | Issue | Signal |
      |---|---|
      | Mislabeled or missing axes | Numeric tick marks with no axis label |
      | Illegible text | Font size <6pt; tick labels overlapping |
      | Color clash | Two adjacent series indistinguishable in print |
      | Missing legend | More than one series, no legend |
      | Overlapping legend | Legend covers a data point |
      | Misleading scaling | Bar chart Y-axis doesn't start at 0; log scale unannounced |
      | Cropped content | Title or labels cut off at edge |
      | Missing units | Numeric axes without units in label |
      | Decorative noise | Drop shadows, gradients, 3D effects |
      | Unsupported claim | The plot shows a trend not present in the source data |
      
      If any of these fire, regenerate the matplotlib script with the fix and
      re-render. Cap at 3 iterations.
      
      ## Wall-time budget
      
      The paper allocates ~20-30 LLM calls per Plotting Agent invocation. With
      ~5-10 figures per typical paper, that's ~3 calls per figure: roughly
      1 (planning) + 1 (initial generation) + 1 (critique pass) + 0-1 (redraw) +
      1 (caption). Stay within this budget by using deterministic helpers for the
      mechanical parts (`render_matplotlib.py` does pixel sizing, style, save) and
      reserving LLM calls for actual visual judgment.
      
  • scripts
    • figure_lint.py 8.1 KB
      #!/usr/bin/env python3
      """
      figure_lint.py — Check rendered figures and their captions before Step 4
      splices them into the paper.
      
      Figure defects are expensive in exactly the wrong way: they survive every
      prose refinement iteration untouched, and they are the first thing a reviewer
      sees. All of the checks here are mechanical — pixel dimensions, caption
      coverage, whether the draft actually uses what Step 2 rendered.
      
      PNG geometry is read from the IHDR chunk and DPI from pHYs directly, so this
      script needs no imaging library (the repo ships deterministic helpers only).
      
      Severity:
        ERROR — the paper will be wrong or incomplete (missing caption, unused
                figure, \\includegraphics pointing at a file that is not there,
                unusably small raster)
        WARN  — a reviewer will notice (low resolution, extreme aspect ratio,
                caption that numbers itself, caption too thin to carry a setting)
      
      Exit codes:
          0 — no ERRORs (and no WARNs, if --strict)
          1 — at least one ERROR, or any WARN under --strict
          2 — figures directory or captions file missing
      
      Usage:
          python figure_lint.py --figures workspace/figures \\
              --captions workspace/figures/captions.json \\
              [--paper workspace/drafts/paper.tex] [--json report.json] [--strict]
      """
      import argparse
      import json
      import os
      import re
      import struct
      import sys
      
      PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
      
      # A single-column figure in a two-column template is ~3.3in wide; 300 dpi is
      # the usual camera-ready floor, so ~1000px is the practical minimum width.
      MIN_WIDTH_WARN = 1000
      MIN_WIDTH_ERROR = 600
      MAX_ASPECT = 4.0
      
      CAPTION_NUMBERING = re.compile(r"^\s*(?:figure|fig\.?)\s*\d*\s*[:.]?\s", re.IGNORECASE)
      PIPELINE_WORDS = re.compile(
          r"\b(?:architecture|pipeline|overview|framework|system|workflow|schematic)\b",
          re.IGNORECASE,
      )
      
      
      def png_geometry(path: str) -> dict | None:
          """Return {width, height, dpi} for a PNG, or None if it is not one."""
          with open(path, "rb") as f:
              head = f.read(33)
              if not head.startswith(PNG_SIGNATURE) or head[12:16] != b"IHDR":
                  return None
              width, height = struct.unpack(">II", head[16:24])
      
              dpi = None
              f.seek(8)
              while True:
                  header = f.read(8)
                  if len(header) < 8:
                      break
                  length, ctype = struct.unpack(">I", header[:4])[0], header[4:8]
                  if ctype == b"pHYs":
                      ppu_x, _, unit = struct.unpack(">IIB", f.read(9))
                      if unit == 1:                       # 1 = metre
                          dpi = round(ppu_x * 0.0254)
                      break
                  if ctype == b"IDAT":                    # pHYs always precedes IDAT
                      break
                  f.seek(length + 4, os.SEEK_CUR)         # payload + CRC
          return {"width": width, "height": height, "dpi": dpi}
      
      
      def referenced_figures(tex: str) -> set[str]:
          """Basenames (without extension) of every \\includegraphics target."""
          out = set()
          for m in re.finditer(r"\\includegraphics(?:\[[^\]]*\])?\{([^}]*)\}", tex):
              out.add(os.path.splitext(os.path.basename(m.group(1).strip()))[0])
          return out
      
      
      def lint(figures_dir: str, captions: dict, tex: str | None) -> list[dict]:
          findings: list[dict] = []
      
          def add(sev: str, code: str, subject: str, msg: str) -> None:
              findings.append({"severity": sev, "code": code, "figure": subject, "message": msg})
      
          files = sorted(f for f in os.listdir(figures_dir)
                         if f.lower().endswith((".png", ".pdf", ".jpg", ".jpeg")))
          stems = {os.path.splitext(f)[0] for f in files}
      
          for name in files:
              stem = os.path.splitext(name)[0]
              path = os.path.join(figures_dir, name)
      
              geo = png_geometry(path) if name.lower().endswith(".png") else None
              if geo:
                  w, h = geo["width"], geo["height"]
                  if w < MIN_WIDTH_ERROR:
                      add("ERROR", "unusable-resolution", stem,
                          f"{w}x{h}px is too small to print; re-render at dpi>=300")
                  elif w < MIN_WIDTH_WARN:
                      add("WARN", "low-resolution", stem,
                          f"{w}x{h}px (~{w // 3}dpi at single-column width); "
                          "re-render at dpi>=300")
                  ratio = max(w / h, h / w)
                  if ratio > MAX_ASPECT:
                      add("WARN", "extreme-aspect", stem,
                          f"aspect ratio {w}:{h} ({ratio:.1f}:1) will be illegible when "
                          "scaled to column width")
      
              if stem not in captions:
                  add("ERROR", "missing-caption", stem, "rendered figure has no entry in captions.json")
      
              if tex is not None and stem not in referenced_figures(tex):
                  add("ERROR", "unused-figure", stem,
                      "not referenced by any \\includegraphics; the prompt requires every "
                      "rendered figure to appear in the paper")
      
          for stem, caption in captions.items():
              if stem not in stems:
                  add("ERROR", "orphan-caption", stem,
                      "captions.json entry has no corresponding file in the figures directory")
              text = (caption or "").strip()
              if not text:
                  add("ERROR", "empty-caption", stem, "caption is empty")
                  continue
              if CAPTION_NUMBERING.match(text):
                  add("WARN", "self-numbered-caption", stem,
                      "caption starts with 'Figure N'; LaTeX numbers figures itself")
              if len(text.split()) < 8:
                  add("WARN", "thin-caption", stem,
                      f"caption is {len(text.split())} words; state what is plotted, on what "
                      "axes, and what the reader should conclude")
      
          if captions and not any(PIPELINE_WORDS.search(c or "") for c in captions.values()):
              add("WARN", "no-pipeline-figure", "(set)",
                  "no caption describes an architecture/pipeline/overview figure; papers "
                  "without one make the reader reconstruct the method from prose")
      
          if tex is not None:
              for ref in referenced_figures(tex):
                  if ref not in stems:
                      add("ERROR", "missing-file", ref,
                          "\\includegraphics references a file that is not in the figures directory")
      
          return findings
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description=__doc__)
          ap.add_argument("--figures", required=True, help="Path to workspace/figures")
          ap.add_argument("--captions", help="Path to captions.json (default: <figures>/captions.json)")
          ap.add_argument("--paper", help="Optional draft to cross-check \\includegraphics against")
          ap.add_argument("--json", help="Optional path for the machine-readable report")
          ap.add_argument("--strict", action="store_true", help="Exit 1 on WARNs too")
          args = ap.parse_args()
      
          captions_path = args.captions or os.path.join(args.figures, "captions.json")
          if not os.path.isdir(args.figures):
              print(f"ERROR: figures directory not found: {args.figures}", file=sys.stderr)
              return 2
          if not os.path.exists(captions_path):
              print(f"ERROR: captions file not found: {captions_path}", file=sys.stderr)
              return 2
      
          with open(captions_path) as f:
              captions = json.load(f)
      
          tex = None
          if args.paper:
              if not os.path.exists(args.paper):
                  print(f"ERROR: paper not found: {args.paper}", file=sys.stderr)
                  return 2
              with open(args.paper) as f:
                  tex = f.read()
      
          findings = lint(args.figures, captions, tex)
          errors = [f for f in findings if f["severity"] == "ERROR"]
          warns = [f for f in findings if f["severity"] == "WARN"]
      
          print(f"figure_lint: {len(captions)} caption(s), {len(errors)} error(s), "
                f"{len(warns)} warning(s)")
          for f in findings:
              print(f"  {f['severity']:5s} [{f['code']}] {f['figure']}: {f['message']}")
      
          if args.json:
              os.makedirs(os.path.dirname(os.path.abspath(args.json)) or ".", exist_ok=True)
              with open(args.json, "w") as fh:
                  json.dump({"findings": findings}, fh, indent=2, ensure_ascii=False)
      
          if errors or (warns and args.strict):
              return 1
          if not findings:
              print("OK: figures and captions are consistent")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • paperbanana_render.py 12.7 KB
      #!/usr/bin/env python3
      """
      paperbanana_render.py — Optional PaperBanana backbone for the plotting-agent's
      render step.
      
      PaperBanana (Zhu et al., 2026 — https://github.com/dwzhu-pku/PaperBanana) is the
      default backbone used by the PaperOrchestra paper (arXiv:2604.05018, §4 Step 2)
      for generating publication-quality diagrams and plots.  This wrapper bridges the
      plotting-agent's figure spec format to PaperBanana's
      Retriever → Planner → Stylist → Visualizer → Critic pipeline.
      
      SETUP (one-time):
          1. Clone PaperBanana:
                 git clone https://github.com/dwzhu-pku/PaperBanana
          2. Install its dependencies:
                 cd PaperBanana && uv pip install -r requirements.txt
          3. Copy & fill in the config:
                 cp configs/model_config.template.yaml configs/model_config.yaml
                 # add google_api_key or openrouter_api_key
          4. Export the path:
                 export PAPERBANANA_PATH="/path/to/PaperBanana"
      
      OPTIONAL model overrides (read from environment):
          PAPERBANANA_MAIN_MODEL        (default: value in model_config.yaml)
          PAPERBANANA_IMAGE_MODEL       (default: value in model_config.yaml)
      
      Usage:
          # preflight check — prints backend status and exits
          python paperbanana_render.py --check-backend
      
          # render a diagram figure
          python paperbanana_render.py \\
              --figure-id fig_overview \\
              --caption "Figure 1: Overview of our framework." \\
              --content-file workspace/inputs/idea.md \\
              --task diagram \\
              --aspect-ratio "16:9" \\
              --out workspace/figures/fig_overview.png
      
          # render a plot figure with fewer critic rounds
          python paperbanana_render.py \\
              --figure-id fig_results \\
              --caption "Figure 2: Comparison of baselines." \\
              --content-file workspace/inputs/experimental_log.md \\
              --task plot \\
              --aspect-ratio "5:4" \\
              --max-critic-rounds 1 \\
              --out workspace/figures/fig_results.png
      
      Exit codes:
          0   image saved successfully
          1   PaperBanana pipeline error
          2   PAPERBANANA_PATH not set or invalid — caller should fall back to matplotlib
      """
      
      import argparse
      import asyncio
      import base64
      import os
      import sys
      from io import BytesIO
      from pathlib import Path
      
      # ---------------------------------------------------------------------------
      # Helpers
      # ---------------------------------------------------------------------------
      
      _VALID_ASPECT_RATIOS = {
          "1:1", "1:4", "2:3", "3:2", "3:4", "4:1", "4:3",
          "4:5", "5:4", "9:16", "16:9", "21:9",
      }
      
      
      def _paperbanana_path() -> Path | None:
          raw = os.environ.get("PAPERBANANA_PATH", "").strip()
          if not raw:
              return None
          p = Path(raw)
          # Sanity-check that it looks like a PaperBanana clone
          if not (p / "utils" / "paperviz_processor.py").exists():
              return None
          return p
      
      
      def check_backend() -> None:
          pb = _paperbanana_path()
          if pb is None:
              env_val = os.environ.get("PAPERBANANA_PATH", "")
              if env_val:
                  print(
                      f"PAPERBANANA_PATH={env_val!r} is set but does not point to a valid "
                      "PaperBanana clone (utils/paperviz_processor.py not found).\n"
                      "Clone PaperBanana and set PAPERBANANA_PATH to its root directory."
                  )
              else:
                  print(
                      "PAPERBANANA_PATH is NOT set.  PaperBanana backbone is unavailable.\n"
                      "The plotting-agent will use the matplotlib fallback (render_matplotlib.py\n"
                      "/ render_diagram.py).\n\n"
                      "To enable PaperBanana:\n"
                      "  1. git clone https://github.com/dwzhu-pku/PaperBanana\n"
                      "  2. cd PaperBanana && uv pip install -r requirements.txt\n"
                      "  3. cp configs/model_config.template.yaml configs/model_config.yaml\n"
                      "     # fill in google_api_key or openrouter_api_key\n"
                      "  4. export PAPERBANANA_PATH=/path/to/PaperBanana"
                  )
              sys.exit(2)
      
          print(f"PaperBanana found at: {pb}")
          main_model = os.environ.get("PAPERBANANA_MAIN_MODEL", "(from model_config.yaml)")
          img_model = os.environ.get("PAPERBANANA_IMAGE_MODEL", "(from model_config.yaml)")
          print(f"  PAPERBANANA_MAIN_MODEL  = {main_model}")
          print(f"  PAPERBANANA_IMAGE_MODEL = {img_model}")
          print("Backend is ready.")
          sys.exit(0)
      
      
      # ---------------------------------------------------------------------------
      # PaperBanana invocation
      # ---------------------------------------------------------------------------
      
      async def _run_pipeline(
          pb_path: Path,
          input_data: dict,
          task_name: str,
          max_critic_rounds: int,
          main_model: str,
          image_gen_model: str,
      ) -> dict | None:
          """Import PaperBanana from pb_path and run the full pipeline on a single item."""
          sys.path.insert(0, str(pb_path))
          try:
              from utils import config as pb_config  # type: ignore
              from utils import paperviz_processor    # type: ignore
              from agents.vanilla_agent import VanillaAgent      # type: ignore
              from agents.planner_agent import PlannerAgent      # type: ignore
              from agents.visualizer_agent import VisualizerAgent  # type: ignore
              from agents.stylist_agent import StylistAgent      # type: ignore
              from agents.critic_agent import CriticAgent        # type: ignore
              from agents.retriever_agent import RetrieverAgent  # type: ignore
              from agents.polish_agent import PolishAgent        # type: ignore
          except ImportError as exc:
              print(
                  f"ERROR: Could not import PaperBanana from {pb_path}: {exc}\n"
                  "Make sure you have run: uv pip install -r requirements.txt",
                  file=sys.stderr,
              )
              sys.exit(1)
      
          exp_config = pb_config.ExpConfig(
              dataset_name="paper-orchestra",
              task_name=task_name,
              split_name="single",
              exp_mode="demo_full",          # Retriever → Planner → Stylist → Visualizer → Critic
              retrieval_setting="auto",
              max_critic_rounds=max_critic_rounds,
              main_model_name=main_model,
              image_gen_model_name=image_gen_model,
              work_dir=pb_path,
          )
      
          processor = paperviz_processor.PaperVizProcessor(
              exp_config=exp_config,
              vanilla_agent=VanillaAgent(exp_config=exp_config),
              planner_agent=PlannerAgent(exp_config=exp_config),
              visualizer_agent=VisualizerAgent(exp_config=exp_config),
              stylist_agent=StylistAgent(exp_config=exp_config),
              critic_agent=CriticAgent(exp_config=exp_config),
              retriever_agent=RetrieverAgent(exp_config=exp_config),
              polish_agent=PolishAgent(exp_config=exp_config),
          )
      
          results = []
          async for result in processor.process_queries_batch(
              [input_data], max_concurrent=1, do_eval=False
          ):
              results.append(result)
      
          return results[0] if results else None
      
      
      def _extract_best_image_b64(result: dict, task_name: str) -> str | None:
          """
          Find the best (latest critic round) base64 JPEG image in the result dict.
      
          PaperBanana stores images as base64 JPEG strings keyed by:
              target_{task_name}_critic_descN_base64_jpg   (critic rounds, latest wins)
              target_{task_name}_stylist_desc0_base64_jpg  (stylist fallback)
              target_{task_name}_desc0_base64_jpg          (planner fallback)
              vanilla_{task_name}_base64_jpg               (last resort)
          """
          # Try critic rounds from high to low
          for round_idx in range(9, -1, -1):
              key = f"target_{task_name}_critic_desc{round_idx}_base64_jpg"
              if result.get(key):
                  return result[key]
      
          # Try eval_image_field pointer
          eval_key = result.get("eval_image_field")
          if eval_key and result.get(eval_key):
              return result[eval_key]
      
          for key in [
              f"target_{task_name}_stylist_desc0_base64_jpg",
              f"target_{task_name}_desc0_base64_jpg",
              f"vanilla_{task_name}_base64_jpg",
          ]:
              if result.get(key):
                  return result[key]
      
          return None
      
      
      def _save_png(b64_jpeg: str, out_path: Path, dpi: int = 300) -> None:
          """Decode a base64 JPEG and save as a 300-DPI PNG."""
          try:
              from PIL import Image  # type: ignore
          except ImportError:
              print(
                  "ERROR: Pillow is not installed. Run: pip install pillow",
                  file=sys.stderr,
              )
              sys.exit(1)
      
          img_bytes = base64.b64decode(b64_jpeg)
          img = Image.open(BytesIO(img_bytes))
      
          # Preserve pixel dimensions; embed 300 DPI metadata so LaTeX sees it
          out_path.parent.mkdir(parents=True, exist_ok=True)
          img.save(str(out_path), format="PNG", dpi=(dpi, dpi))
      
      
      # ---------------------------------------------------------------------------
      # Main
      # ---------------------------------------------------------------------------
      
      def main() -> int:
          p = argparse.ArgumentParser(
              description=__doc__,
              formatter_class=argparse.RawDescriptionHelpFormatter,
          )
          p.add_argument(
              "--check-backend", action="store_true",
              help="Print PaperBanana availability and exit (no figure generated)",
          )
          p.add_argument("--figure-id", help="Figure ID from the plotting plan (e.g. fig_overview)")
          p.add_argument(
              "--caption", required=False,
              help="Figure caption / objective from the figure spec (used as visual intent)",
          )
          p.add_argument(
              "--content-file", type=Path, required=False,
              help="Path to idea.md or experimental_log.md — used as method context",
          )
          p.add_argument(
              "--task", choices=["diagram", "plot"], default="diagram",
              help="Figure task type (default: diagram)",
          )
          p.add_argument(
              "--aspect-ratio", default="16:9",
              help=f"Aspect ratio string (default: 16:9). "
                   f"Allowed: {', '.join(sorted(_VALID_ASPECT_RATIOS))}",
          )
          p.add_argument(
              "--max-critic-rounds", type=int, default=3,
              help="Number of Critic refinement rounds (default: 3, range: 0–5)",
          )
          p.add_argument(
              "--out", type=Path, required=False,
              help="Output PNG path (e.g. workspace/figures/fig_overview.png)",
          )
          args = p.parse_args()
      
          if args.check_backend:
              check_backend()   # exits internally
      
          # --- validate required args for actual rendering ---
          missing = [f for f, v in [("--caption", args.caption), ("--out", args.out)] if v is None]
          if missing:
              p.error(f"The following arguments are required for rendering: {', '.join(missing)}")
      
          if args.content_file is None or not args.content_file.exists():
              p.error(
                  f"--content-file is required and must exist. "
                  f"Got: {args.content_file}"
              )
      
          aspect_ratio = args.aspect_ratio
          if aspect_ratio not in _VALID_ASPECT_RATIOS:
              print(
                  f"WARN: aspect ratio {aspect_ratio!r} is not in the standard set "
                  f"({', '.join(sorted(_VALID_ASPECT_RATIOS))}). Passing as-is to PaperBanana.",
                  file=sys.stderr,
              )
      
          # --- check backend availability ---
          pb_path = _paperbanana_path()
          if pb_path is None:
              print(
                  "INFO: PAPERBANANA_PATH not set or invalid. "
                  "Falling back to matplotlib renderer.",
                  file=sys.stderr,
              )
              sys.exit(2)   # caller uses exit code 2 to trigger fallback
      
          content = args.content_file.read_text(encoding="utf-8")
          figure_id = args.figure_id or args.out.stem
      
          input_data = {
              "filename":        f"{figure_id}_candidate_0",
              "candidate_id":    0,
              "caption":         args.caption,
              "content":         content,
              "visual_intent":   args.caption,
              "additional_info": {"rounded_ratio": aspect_ratio},
              "max_critic_rounds": max(0, min(5, args.max_critic_rounds)),
          }
      
          main_model = os.environ.get("PAPERBANANA_MAIN_MODEL", "")
          image_gen_model = os.environ.get("PAPERBANANA_IMAGE_MODEL", "")
      
          print(
              f"PaperBanana: generating {args.task} figure '{figure_id}' "
              f"({aspect_ratio}, {args.max_critic_rounds} critic rounds) …",
              file=sys.stderr,
          )
      
          result = asyncio.run(
              _run_pipeline(
                  pb_path=pb_path,
                  input_data=input_data,
                  task_name=args.task,
                  max_critic_rounds=args.max_critic_rounds,
                  main_model=main_model,
                  image_gen_model=image_gen_model,
              )
          )
      
          if result is None:
              print("ERROR: PaperBanana returned no result.", file=sys.stderr)
              return 1
      
          b64 = _extract_best_image_b64(result, args.task)
          if not b64:
              print(
                  f"ERROR: No image found in PaperBanana result. "
                  f"Available keys: {[k for k in result if 'base64' in k]}",
                  file=sys.stderr,
              )
              return 1
      
          _save_png(b64, args.out)
          print(f"Saved: {args.out}", file=sys.stderr)
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • render_diagram.py 4.1 KB
      #!/usr/bin/env python3
      """
      render_diagram.py — Render a simple boxes-and-arrows conceptual diagram from
      a JSON spec using matplotlib patches. 300 DPI PNG output.
      
      For complex Fig-1-style overview diagrams (multi-row branches, grouped
      sub-systems), the host agent should write matplotlib patches code directly
      following references/diagram-patterns.md. This script handles the common
      case: nodes positioned on a grid, edges between named nodes.
      
      Spec format (JSON):
          {
              "aspect_ratio": "16:9",
              "nodes": [
                  {"id": "input",  "x": 0.5, "y": 3.0, "w": 1.5, "h": 0.7,
                   "label": "Idea (I)",  "kind": "input"},
                  {"id": "outline","x": 3.0, "y": 3.0, "w": 1.6, "h": 0.9,
                   "label": "Outline\\nAgent", "kind": "agent"}
              ],
              "edges": [
                  {"from": "input", "to": "outline"}
              ]
          }
      
      Coordinates are in arbitrary units; the script auto-scales to fill the
      figure. `kind` ∈ {"input", "agent", "output", "control"} drives the color.
      
      Usage:
          python render_diagram.py --spec diagram.json --out figure.png
      """
      import argparse
      import json
      import sys
      
      import matplotlib
      matplotlib.use("Agg")
      import matplotlib.pyplot as plt
      from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
      
      ASPECT_TO_SIZE = {
          "1:1":  (3.4, 3.4),  "1:4":  (1.8, 7.2),  "2:3":  (3.4, 5.1),
          "3:2":  (5.1, 3.4),  "3:4":  (3.0, 4.0),  "4:1":  (7.0, 1.75),
          "4:3":  (4.0, 3.0),  "4:5":  (3.2, 4.0),  "5:4":  (4.5, 3.6),
          "9:16": (2.8, 4.97), "16:9": (5.5, 3.09), "21:9": (7.0, 3.0),
      }
      
      KIND_COLORS = {
          "input":   "#cfe2f3",
          "agent":   "#9fc5e8",
          "output":  "#b6d7a8",
          "control": "#ead1dc",
          "default": "#e8e8f0",
      }
      BORDER = "#2060cc"
      
      
      def main() -> int:
          p = argparse.ArgumentParser(description=__doc__)
          p.add_argument("--spec", required=True, help="path to JSON spec")
          p.add_argument("--out",  required=True, help="path to output PNG")
          args = p.parse_args()
      
          with open(args.spec) as f:
              spec = json.load(f)
      
          size = ASPECT_TO_SIZE.get(spec.get("aspect_ratio", "16:9"))
          if size is None:
              print(f"ERROR: unknown aspect_ratio {spec.get('aspect_ratio')}", file=sys.stderr)
              return 1
      
          nodes = {n["id"]: n for n in spec["nodes"]}
          if not nodes:
              print("ERROR: spec has no nodes", file=sys.stderr)
              return 1
      
          # auto-bounds
          xs = [n["x"] for n in nodes.values()] + [n["x"] + n.get("w", 1) for n in nodes.values()]
          ys = [n["y"] for n in nodes.values()] + [n["y"] + n.get("h", 1) for n in nodes.values()]
          pad = 0.4
          xmin, xmax = min(xs) - pad, max(xs) + pad
          ymin, ymax = min(ys) - pad, max(ys) + pad
      
          fig, ax = plt.subplots(figsize=size)
          ax.set_xlim(xmin, xmax)
          ax.set_ylim(ymin, ymax)
          ax.set_aspect("auto")
          ax.axis("off")
      
          # nodes
          for n in nodes.values():
              color = KIND_COLORS.get(n.get("kind", "default"), KIND_COLORS["default"])
              bb = FancyBboxPatch(
                  (n["x"], n["y"]), n.get("w", 1.5), n.get("h", 0.8),
                  boxstyle="round,pad=0.06,rounding_size=0.12",
                  ec=BORDER, fc=color, lw=0.8,
              )
              ax.add_patch(bb)
              cx = n["x"] + n.get("w", 1.5) / 2
              cy = n["y"] + n.get("h", 0.8) / 2
              ax.text(cx, cy, n["label"], ha="center", va="center",
                      fontsize=8, fontweight="bold",
                      fontfamily="serif")
      
          # edges
          for e in spec.get("edges", []):
              n1, n2 = nodes[e["from"]], nodes[e["to"]]
              x1 = n1["x"] + n1.get("w", 1.5)
              y1 = n1["y"] + n1.get("h", 0.8) / 2
              x2 = n2["x"]
              y2 = n2["y"] + n2.get("h", 0.8) / 2
              a = FancyArrowPatch((x1, y1), (x2, y2),
                                  arrowstyle="->", mutation_scale=10,
                                  color=BORDER, lw=0.9)
              ax.add_patch(a)
      
          if spec.get("title"):
              fig.suptitle(spec["title"], fontsize=9, fontweight="bold", y=0.98)
      
          fig.tight_layout()
          fig.savefig(args.out, dpi=300, bbox_inches="tight", pad_inches=0.05)
          plt.close(fig)
          print(f"OK: rendered diagram {args.out} ({len(nodes)} nodes, "
                f"{len(spec.get('edges', []))} edges)")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • render_matplotlib.py 7.1 KB
      #!/usr/bin/env python3
      """
      render_matplotlib.py — Render a JSON plot spec to a 300-DPI PNG using the
      academic-paper matplotlib style.
      
      This is a deterministic helper for the plotting agent. It handles the
      mechanical parts (figsize from aspect ratio, style application, save) so the
      host agent can focus on high-level visual decisions in the LLM.
      
      Spec format (JSON):
          {
              "type": "line" | "bar" | "grouped_bar" | "stacked_bar" | "radar" | "scatter" | "heatmap",
              "aspect_ratio": "16:9",
              "title": "Optional title",
              "xlabel": "...",
              "ylabel": "...",
              "series": [
                  {"name": "Method A", "x": [...], "y": [...]},
                  {"name": "Method B", "x": [...], "y": [...]}
              ],
              "x_labels": [...],     // for bar/grouped_bar/stacked_bar
              "legend_loc": "upper right"
          }
      
      For chart types not covered here, write the matplotlib code yourself directly.
      
      Usage:
          python render_matplotlib.py --spec spec.json --out figure.png
      """
      import argparse
      import json
      import sys
      
      import matplotlib
      matplotlib.use("Agg")
      import matplotlib.pyplot as plt
      import numpy as np
      
      # ── style ──
      plt.rcParams.update({
          "font.family":       "serif",
          "font.serif":        ["Times New Roman", "DejaVu Serif"],
          "font.size":         8,
          "axes.titlesize":    9,
          "axes.titleweight":  "bold",
          "axes.labelsize":    8,
          "axes.linewidth":    0.6,
          "legend.fontsize":   7,
          "legend.framealpha": 0.95,
          "legend.edgecolor":  "#cccccc",
          "xtick.labelsize":   7,
          "ytick.labelsize":   7,
          "figure.dpi":        300,
          "savefig.dpi":       300,
          "savefig.bbox":      "tight",
          "savefig.pad_inches": 0.08,
          "grid.alpha":        0.15,
          "grid.linewidth":    0.5,
          "lines.linewidth":   1.3,
      })
      
      PALETTE = ["#2060cc", "#cc3030", "#208040", "#cc7020", "#8040cc", "#b08020", "#666666"]
      
      ASPECT_TO_SIZE = {
          "1:1":  (3.4, 3.4),
          "1:4":  (1.8, 7.2),
          "2:3":  (3.4, 5.1),
          "3:2":  (5.1, 3.4),
          "3:4":  (3.0, 4.0),
          "4:1":  (7.0, 1.75),
          "4:3":  (4.0, 3.0),
          "4:5":  (3.2, 4.0),
          "5:4":  (4.5, 3.6),
          "9:16": (2.8, 4.97),
          "16:9": (5.5, 3.09),
          "21:9": (7.0, 3.0),
      }
      
      
      def make_axes(spec):
          size = ASPECT_TO_SIZE.get(spec.get("aspect_ratio", "16:9"))
          if size is None:
              raise SystemExit(f"unknown aspect_ratio: {spec.get('aspect_ratio')}")
          is_polar = spec.get("type") == "radar"
          fig, ax = plt.subplots(figsize=size, subplot_kw=dict(polar=True) if is_polar else {})
          return fig, ax
      
      
      def render_line(ax, spec):
          for i, s in enumerate(spec["series"]):
              ax.plot(s["x"], s["y"], color=PALETTE[i % len(PALETTE)], label=s.get("name"))
          if spec.get("xlabel"): ax.set_xlabel(spec["xlabel"])
          if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
          if any("name" in s for s in spec["series"]):
              ax.legend(loc=spec.get("legend_loc", "best"))
          ax.grid(True)
      
      
      def render_bar(ax, spec):
          s = spec["series"][0]
          x = np.arange(len(s["y"]))
          ax.bar(x, s["y"], color=PALETTE[0], edgecolor="white", linewidth=0.4)
          if spec.get("x_labels"):
              ax.set_xticks(x)
              ax.set_xticklabels(spec["x_labels"])
          if spec.get("xlabel"): ax.set_xlabel(spec["xlabel"])
          if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
          ax.grid(axis="y", alpha=0.2)
      
      
      def render_grouped_bar(ax, spec):
          n_groups = len(spec["series"][0]["y"])
          n_series = len(spec["series"])
          x = np.arange(n_groups)
          width = 0.8 / n_series
          for i, s in enumerate(spec["series"]):
              offset = (i - (n_series - 1) / 2) * width
              ax.bar(x + offset, s["y"], width, color=PALETTE[i % len(PALETTE)],
                     label=s.get("name"), edgecolor="white", linewidth=0.4)
          if spec.get("x_labels"):
              ax.set_xticks(x)
              ax.set_xticklabels(spec["x_labels"])
          if spec.get("xlabel"): ax.set_xlabel(spec["xlabel"])
          if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
          ax.legend(loc=spec.get("legend_loc", "best"))
          ax.grid(axis="y", alpha=0.2)
      
      
      def render_stacked_bar(ax, spec):
          x = np.arange(len(spec["x_labels"]))
          bottom = np.zeros(len(spec["x_labels"]))
          for i, s in enumerate(spec["series"]):
              y = np.array(s["y"])
              ax.bar(x, y, bottom=bottom, color=PALETTE[i % len(PALETTE)],
                     label=s.get("name"), edgecolor="white", linewidth=0.4)
              bottom += y
          ax.set_xticks(x)
          ax.set_xticklabels(spec["x_labels"])
          if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
          ax.legend(loc=spec.get("legend_loc", "best"))
      
      
      def render_radar(ax, spec):
          labels = spec["x_labels"]
          angles = np.linspace(0, 2 * np.pi, len(labels), endpoint=False).tolist()
          angles += angles[:1]
          for i, s in enumerate(spec["series"]):
              vals = list(s["y"]) + [s["y"][0]]
              ax.plot(angles, vals, color=PALETTE[i % len(PALETTE)],
                      linewidth=1.5, label=s.get("name"))
              ax.fill(angles, vals, color=PALETTE[i % len(PALETTE)], alpha=0.12)
          ax.set_xticks(angles[:-1])
          ax.set_xticklabels(labels, fontsize=7)
          ax.legend(loc="upper right", bbox_to_anchor=(1.25, 1.05))
      
      
      def render_scatter(ax, spec):
          for i, s in enumerate(spec["series"]):
              ax.scatter(s["x"], s["y"], color=PALETTE[i % len(PALETTE)],
                         s=20, alpha=0.7, label=s.get("name"))
          if spec.get("xlabel"): ax.set_xlabel(spec["xlabel"])
          if spec.get("ylabel"): ax.set_ylabel(spec["ylabel"])
          if any("name" in s for s in spec["series"]):
              ax.legend(loc=spec.get("legend_loc", "best"))
          ax.grid(True)
      
      
      def render_heatmap(ax, spec):
          data = np.array(spec["matrix"])
          im = ax.imshow(data, cmap="Blues", aspect="auto")
          if spec.get("x_labels"):
              ax.set_xticks(range(len(spec["x_labels"])))
              ax.set_xticklabels(spec["x_labels"])
          if spec.get("y_labels"):
              ax.set_yticks(range(len(spec["y_labels"])))
              ax.set_yticklabels(spec["y_labels"])
          plt.colorbar(im, ax=ax, fraction=0.04, pad=0.02)
      
      
      RENDERERS = {
          "line":         render_line,
          "bar":          render_bar,
          "grouped_bar":  render_grouped_bar,
          "stacked_bar":  render_stacked_bar,
          "radar":        render_radar,
          "scatter":      render_scatter,
          "heatmap":      render_heatmap,
      }
      
      
      def main() -> int:
          p = argparse.ArgumentParser(description=__doc__)
          p.add_argument("--spec", required=True, help="path to JSON spec")
          p.add_argument("--out",  required=True, help="path to output PNG")
          args = p.parse_args()
      
          with open(args.spec) as f:
              spec = json.load(f)
      
          chart_type = spec.get("type")
          renderer = RENDERERS.get(chart_type)
          if renderer is None:
              print(f"ERROR: unknown chart type {chart_type!r}. "
                    f"Allowed: {sorted(RENDERERS)}", file=sys.stderr)
              return 1
      
          fig, ax = make_axes(spec)
          renderer(ax, spec)
      
          if spec.get("title"):
              ax.set_title(spec["title"])
      
          if chart_type != "radar":
              ax.spines["top"].set_visible(False)
              ax.spines["right"].set_visible(False)
      
          fig.tight_layout()
          fig.savefig(args.out)
          plt.close(fig)
          print(f"OK: rendered {args.out} ({spec.get('aspect_ratio')}, {chart_type})")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • test_figure_lint.py 6.5 KB
      #!/usr/bin/env python3
      """
      test_figure_lint.py — regression tests for the figure/caption linter.
      
      Stdlib only; PNG fixtures are synthesized in a temp directory rather than
      committed as binaries:
      
          python3 skills/plotting-agent/scripts/test_figure_lint.py
      """
      import os
      import struct
      import tempfile
      import unittest
      import zlib
      
      from figure_lint import lint, png_geometry, referenced_figures
      
      PIPELINE_CAPTION = ("System architecture of the proposed pipeline, showing the three "
                          "enforcement layers and the order in which they evaluate a call.")
      
      
      def chunk(ctype: bytes, payload: bytes) -> bytes:
          return (struct.pack(">I", len(payload)) + ctype + payload
                  + struct.pack(">I", zlib.crc32(ctype + payload) & 0xFFFFFFFF))
      
      
      def make_png(path: str, width: int, height: int, dpi: int | None = None) -> None:
          """Write a structurally valid PNG header; pixel data is not needed here."""
          ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
          data = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
          if dpi:
              ppm = round(dpi / 0.0254)
              data += chunk(b"pHYs", struct.pack(">IIB", ppm, ppm, 1))
          data += chunk(b"IDAT", zlib.compress(b"\x00")) + chunk(b"IEND", b"")
          with open(path, "wb") as f:
              f.write(data)
      
      
      def codes(findings: list[dict]) -> set[str]:
          return {f["code"] for f in findings}
      
      
      class TestPngGeometry(unittest.TestCase):
          def test_dimensions(self):
              with tempfile.TemporaryDirectory() as d:
                  p = os.path.join(d, "a.png")
                  make_png(p, 2370, 1770)
                  geo = png_geometry(p)
                  self.assertEqual((geo["width"], geo["height"]), (2370, 1770))
      
          def test_dpi_from_phys(self):
              with tempfile.TemporaryDirectory() as d:
                  p = os.path.join(d, "a.png")
                  make_png(p, 1200, 900, dpi=300)
                  self.assertEqual(png_geometry(p)["dpi"], 300)
      
          def test_dpi_absent_is_none(self):
              with tempfile.TemporaryDirectory() as d:
                  p = os.path.join(d, "a.png")
                  make_png(p, 1200, 900)
                  self.assertIsNone(png_geometry(p)["dpi"])
      
          def test_non_png_returns_none(self):
              with tempfile.TemporaryDirectory() as d:
                  p = os.path.join(d, "a.png")
                  with open(p, "wb") as f:
                      f.write(b"not a png at all")
                  self.assertIsNone(png_geometry(p))
      
      
      class TestReferencedFigures(unittest.TestCase):
          def test_paths_and_extensions_are_stripped(self):
              tex = (r"\includegraphics[width=0.8\textwidth]{../figures/fig_one.png}"
                     r"\includegraphics{fig_two}")
              self.assertEqual(referenced_figures(tex), {"fig_one", "fig_two"})
      
      
      class LintCase(unittest.TestCase):
          """Base class providing a temp figures directory."""
      
          def run_lint(self, figures: dict, captions: dict, tex=None) -> set[str]:
              with tempfile.TemporaryDirectory() as d:
                  for stem, dims in figures.items():
                      make_png(os.path.join(d, stem + ".png"), *dims)
                  return codes(lint(d, captions, tex))
      
      
      class TestResolution(LintCase):
          def test_print_quality_figure_passes(self):
              found = self.run_lint({"fig_arch": (2370, 1770)}, {"fig_arch": PIPELINE_CAPTION})
              self.assertEqual(found, set())
      
          def test_low_resolution_warns(self):
              found = self.run_lint({"fig_arch": (800, 600)}, {"fig_arch": PIPELINE_CAPTION})
              self.assertIn("low-resolution", found)
              self.assertNotIn("unusable-resolution", found)
      
          def test_tiny_figure_errors(self):
              found = self.run_lint({"fig_arch": (320, 240)}, {"fig_arch": PIPELINE_CAPTION})
              self.assertIn("unusable-resolution", found)
      
          def test_extreme_aspect_warns(self):
              found = self.run_lint({"fig_arch": (4800, 600)}, {"fig_arch": PIPELINE_CAPTION})
              self.assertIn("extreme-aspect", found)
      
          def test_wide_but_reasonable_aspect_passes(self):
              found = self.run_lint({"fig_arch": (4770, 1770)}, {"fig_arch": PIPELINE_CAPTION})
              self.assertNotIn("extreme-aspect", found)
      
      
      class TestCaptions(LintCase):
          def test_figure_without_caption_errors(self):
              found = self.run_lint({"fig_arch": (2370, 1770), "fig_extra": (2370, 1770)},
                                    {"fig_arch": PIPELINE_CAPTION})
              self.assertIn("missing-caption", found)
      
          def test_caption_without_figure_errors(self):
              found = self.run_lint({"fig_arch": (2370, 1770)},
                                    {"fig_arch": PIPELINE_CAPTION, "fig_ghost": PIPELINE_CAPTION})
              self.assertIn("orphan-caption", found)
      
          def test_empty_caption_errors(self):
              found = self.run_lint({"fig_arch": (2370, 1770), "fig_b": (2370, 1770)},
                                    {"fig_arch": PIPELINE_CAPTION, "fig_b": "   "})
              self.assertIn("empty-caption", found)
      
          def test_self_numbered_caption_warns(self):
              found = self.run_lint({"fig_arch": (2370, 1770)},
                                    {"fig_arch": "Figure 3: " + PIPELINE_CAPTION})
              self.assertIn("self-numbered-caption", found)
      
          def test_thin_caption_warns(self):
              found = self.run_lint({"fig_arch": (2370, 1770), "fig_b": (2370, 1770)},
                                    {"fig_arch": PIPELINE_CAPTION, "fig_b": "Accuracy results."})
              self.assertIn("thin-caption", found)
      
          def test_missing_pipeline_figure_warns(self):
              found = self.run_lint(
                  {"fig_acc": (2370, 1770)},
                  {"fig_acc": "Accuracy of each variant across the four evaluation splits "
                              "reported in the main comparison."})
              self.assertIn("no-pipeline-figure", found)
      
      
      class TestPaperCrossCheck(LintCase):
          def test_unused_figure_errors(self):
              found = self.run_lint({"fig_arch": (2370, 1770)}, {"fig_arch": PIPELINE_CAPTION},
                                    tex=r"\section{Method} No figures included here at all.")
              self.assertIn("unused-figure", found)
      
          def test_missing_file_errors(self):
              found = self.run_lint({"fig_arch": (2370, 1770)}, {"fig_arch": PIPELINE_CAPTION},
                                    tex=r"\includegraphics{../figures/fig_arch.png}"
                                        r"\includegraphics{../figures/fig_absent.png}")
              self.assertIn("missing-file", found)
      
          def test_fully_consistent_workspace_passes(self):
              found = self.run_lint({"fig_arch": (2370, 1770)}, {"fig_arch": PIPELINE_CAPTION},
                                    tex=r"\includegraphics[width=\linewidth]{../figures/fig_arch.png}")
              self.assertEqual(found, set())
      
      
      if __name__ == "__main__":
          unittest.main(verbosity=2)
      
  • SKILL.md 9.2 KB
    ---
    name: plotting-agent
    description: Step 2 of the PaperOrchestra pipeline (arXiv:2604.05018). Execute the visualization plan from outline.json — render plots and conceptual diagrams from experimental_log.md and idea.md, optionally refine via VLM critique loop, and produce context-aware captions. Runs in parallel with the literature-review-agent. TRIGGER when the orchestrator delegates Step 2 or when the user asks to "generate the figures for my paper" or "render the plots from this experiment log".
    ---
    
    # Plotting Agent (Step 2)
    
    Faithful implementation of the Plotting Agent from PaperOrchestra
    (Song et al., 2026, arXiv:2604.05018, §4 Step 2 and App. F.1 p.45).
    
    **Cost: ~20–30 LLM calls.** The paper uses PaperBanana (Zhu et al., 2026) as
    the default backbone with a closed-loop VLM-critique refinement. This skill
    expresses that loop in host-agent terms: you (the host agent) generate
    matplotlib code with your own LLM, render via your Bash/Python tool,
    optionally critique the rendered PNG with your vision model, redraw, and
    finally caption.
    
    ## Inputs
    
    - `workspace/outline.json` — specifically the `plotting_plan` array
    - `workspace/inputs/idea.md` and `workspace/inputs/experimental_log.md` —
      the source data
    - `workspace/inputs/figures/` — optional pre-existing figures (`PlotOn` mode)
    
    ## Outputs
    
    - `workspace/figures/<figure_id>.png` — one PNG per `plotting_plan` entry
      (300 DPI, sized to the requested aspect ratio)
    - `workspace/figures/captions.json` — `{figure_id: caption_text}` map
    
    ## Workflow
    
    ### Per figure (executed independently per `figure_id`)
    
    1. **Read the figure spec** from `outline.json`:
       ```json
       {
         "figure_id": "fig_main_results",
         "title": "Main Results on Dataset X",
         "plot_type": "plot",
         "data_source": "experimental_log.md",
         "objective": "Visual summary (Grouped Bar Chart) demonstrating ...",
         "aspect_ratio": "5:4"
       }
       ```
    
    2. **Few-shot retrieval (visual planning)**: pick the matching pattern from
       `references/chart-patterns.md` (for `plot_type=="plot"`) or
       `references/diagram-patterns.md` (for `plot_type=="diagram"`).
    
    3. **Extract data**: parse `idea.md` and/or `experimental_log.md`
       (`data_source` field tells you which) to obtain the numeric values or
       conceptual entities the figure needs. For `experimental_log.md`, the
       `## 2. Raw Numeric Data` section contains markdown tables.
    
    4. **Render**:
    
       **If `PAPERBANANA_PATH` is set** — use the PaperBanana backbone
       (Zhu et al., 2026). It runs a Retriever → Planner → Stylist → Visualizer
       → Critic loop and is especially good for `plot_type == "diagram"`.
       See `references/paperbanana-cookbook.md` for setup (needs a Gemini API key).
    
       ```bash
       python skills/plotting-agent/scripts/paperbanana_render.py \
           --figure-id <figure_id> \
           --caption   "<objective from figure spec>" \
           --content-file workspace/inputs/idea.md \
           --task      <diagram|plot> \
           --aspect-ratio <aspect_ratio> \
           --out       workspace/figures/<figure_id>.png
       ```
    
       **Otherwise** — write a matplotlib script and run it via your Bash tool,
       or use the bundled helper:
       ```bash
       python skills/plotting-agent/scripts/render_matplotlib.py \
           --spec spec.json \
           --out workspace/figures/<figure_id>.png
       ```
       The script must apply the academic style from `chart-patterns.md`, use the
       correct pixel size from `aspect-ratios.md`, save at 300 DPI, and call
       `plt.close()` after `savefig`.
    
    5. **VLM critique loop (optional, only if your host has vision)**:
       - Reload the rendered PNG as a multimodal input to your LLM.
       - Critique it against the figure's `objective` from the outline. Look for:
         visual artifacts, mislabeled axes, illegible text, color clashes,
         misleading scaling, missing legend, overlapping labels.
       - If problems are found, regenerate the matplotlib script with corrections
         and re-render. Cap at 3 critique iterations per figure.
       - This is the closed-loop refinement step the paper inherits from
         PaperBanana. See `references/plotting-pipeline.md` for the full loop
         description.
       - **If your host has no vision input, skip this step entirely.** The
         figure will still render correctly, just without iterative refinement.
    
    6. **Generate the caption** using the verbatim Caption Generation prompt at
       `references/caption-prompt.md`. Inputs to the caption prompt:
       - `task_name` — the section the figure belongs to (e.g., "Methodology",
         "Experiments")
       - `raw_content` — the surrounding section text (or content_bullets from
         the section_plan if the section isn't drafted yet)
       - `description` — the `objective` field from the figure spec
       - `figure_desc` — a 1-sentence description of what the rendered figure
         actually shows (from your VLM critique pass, or from the script's plan
         if no vision)
    
       Write the caption to `workspace/figures/captions.json` keyed by
       `figure_id`. **Captions must NOT contain `Figure N:` or `Caption N:`
       prefixes** — the LaTeX template handles numbering. Plain text only, no
       markdown.
    
    ## Conceptual diagrams
    
    For `plot_type == "diagram"`, prefer PaperBanana when available — its
    Retriever grounds the Planner in real published paper diagrams.  If
    `PAPERBANANA_PATH` is unset, follow `references/diagram-patterns.md`.
    Patterns include block diagrams, system overviews, flowcharts, and
    algorithm-as-graph. The bundled helper:
    
    ```bash
    python skills/plotting-agent/scripts/render_diagram.py \
        --spec diagram_spec.json \
        --out workspace/figures/<figure_id>.png
    ```
    
    handles the simple cases (boxes-and-arrows). For complex Fig-1-style
    overview diagrams, write matplotlib patches code yourself.
    
    ## Hard rules
    
    - **300 DPI** for every figure. Lower DPI gets rejected at the LaTeX compile
      step on conference templates.
    - **Aspect ratio is exact**. The figure spec's `aspect_ratio` is one of 12
      enumerated strings. Use the pixel targets in `references/aspect-ratios.md`.
    - **Hide top and right spines** for plots. (Diagrams: no spines at all.)
    - **Muted academic colors** only. The palette is in `chart-patterns.md`.
      Never use matplotlib defaults (too saturated for print).
    - **No 3D, no pie charts, no decorative visuals.** The paper's evaluators
      penalize these.
    - **Every figure MUST have a caption** in `captions.json`. The Section
      Writing Agent will fail-stop if a caption is missing for any figure
      referenced from the outline.
    - **No `Figure N:` prefix** in captions — LaTeX adds it.
    - **Never describe data you didn't plot.** The Plotting Agent must not
      hallucinate axes, baselines, or trends. Source-of-truth is
      `experimental_log.md` or `idea.md`.
    
    ## Verification gate (run before handing off to Step 3/4)
    
    The hard rules above are stated everywhere and enforced nowhere. This gate
    makes the mechanical half checkable:
    
    ```bash
    python skills/plotting-agent/scripts/figure_lint.py \
        --figures workspace/figures \
        --captions workspace/figures/captions.json
    ```
    
    ERRORs: a rendered figure with no caption, a caption with no file, an empty
    caption, a raster too small to print. WARNs: resolution under ~300 DPI at
    single-column width, aspect ratios past 4:1, captions that number themselves
    (`Figure 3: ...`), captions under eight words, and a figure set with no
    architecture/pipeline/overview figure in it.
    
    PNG geometry is read from the IHDR and pHYs chunks directly — no imaging
    library, consistent with the repo's deterministic-helpers-only rule.
    
    After Step 4 has produced `paper.tex`, re-run with `--paper` to confirm the
    draft uses every figure Step 2 rendered and references no file that does not
    exist:
    
    ```bash
    python skills/plotting-agent/scripts/figure_lint.py \
        --figures workspace/figures \
        --paper   workspace/drafts/paper.tex
    ```
    
    Fix ERRORs before continuing. A missing caption is the one failure that
    propagates silently: Step 4 splices the figure with whatever caption it
    invents, and Step 5 has no way to know the caption was never grounded.
    
    ## Pre-existing figures (PlotOn mode)
    
    If `workspace/inputs/figures/` is non-empty, check whether any pre-existing
    file matches a `figure_id` in the outline (by filename prefix). If so,
    **copy** it into `workspace/figures/` as-is and **still generate a caption**
    using the caption prompt. Only generate from scratch the figure_ids that
    have no pre-existing counterpart.
    
    ## Resources
    
    - `references/caption-prompt.md` — verbatim Caption Generation prompt from App. F.1
    - `references/plotting-pipeline.md` — the full few-shot → render → critique → caption loop
    - `references/chart-patterns.md` — matplotlib style + chart type recipes
    - `references/diagram-patterns.md` — conceptual diagram recipes
    - `references/aspect-ratios.md` — pixel targets for each of the 12 allowed ratios at 300 DPI
    - `references/paperbanana-cookbook.md` — **NEW** PaperBanana setup, usage, cost notes, attribution
    - `scripts/render_matplotlib.py` — render a JSON plot spec → PNG (matplotlib fallback)
    - `scripts/render_diagram.py` — render a JSON diagram spec → PNG (matplotlib fallback)
    - `scripts/paperbanana_render.py` — **NEW** PaperBanana backbone wrapper (reads `PAPERBANANA_PATH` from env)
    - `scripts/figure_lint.py` — **NEW** resolution / aspect / caption-coverage gate; `--paper` cross-checks `\includegraphics`
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related