from __future__ import annotations """Mutation gate for pptx_tasks, forged and verified 6/6 near-misses rejected. The checker in pptx_tasks.py could grade an output but nothing showed it would reject a broken one. These are the deliberate near-misses it refuses, each one verified to change the file and to be rejected. """ # ---- inlined from pptx_tasks.py so this file runs on its own ---- """Real-skill task bank for the anthropics `pptx` skill (pptxgenjs, Node), graded MECHANICALLY with python-pptx and the chart XML. The grader holds the file to the spec AND to the rules the skill states: hex colours without '#', real bullets (never a literal bullet character), speaker notes via addNotes, and a combo chart that declares both value axes and both category axes (otherwise PowerPoint discards the chart). Three contexts: bullets 16x9 deck, title slide, a bullet slide with exact items and speaker notes table heading + a table with a shaded bold header row + a coloured rectangle combo a bar series plus a line series on a SECONDARY value axis, titled, with data labels """ # (moved to the top of this file) import os import random import re import subprocess import sys import tempfile CONTEXTS = ["bullets", "table", "combo"] NODE_DIR = os.path.join(os.path.dirname(__file__), "..", "external", "node") NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main", "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", "p": "http://schemas.openxmlformats.org/presentationml/2006/main"} TITLES = ["Quarterly Safety Review", "Fleet Maintenance Summary", "Vendor Onboarding", "Site Access Policy", "Training Attendance", "Incident Follow-up", "Warehouse Throughput", "Budget Reforecast"] ITEMS = ["Hard hats issued", "Boots inspected", "Vests replaced", "Gloves ordered", "Harnesses tested", "Shields cleaned", "Badges renewed", "Radios checked", "Kits restocked", "Signage repaired"] MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"] HEXES = ["1F4E79", "C00000", "2E7D32", "6A1B9A", "E65100", "00695C"] REGIONS = ["North", "South", "East", "West", "Central"] def build(n_per: int = 8, seed: int = 41) -> list[dict]: rng = random.Random(seed) specs: list[dict] = [] for i in range(n_per): title, sub = rng.choice(TITLES), f"Week {rng.randint(1, 52)} report" items = rng.sample(ITEMS, 4) notes = f"Presenter: cover {items[0].lower()} first, then questions." specs.append({"context": "bullets", "id": f"bl{i}", "title": title, "sub": sub, "items": items, "notes": notes, "spec": (f"Create a 16:9 PowerPoint deck with two slides. Slide 1: the title \"{title}\" and the subtitle " f"\"{sub}\" as two separate text boxes. Slide 2: a text box titled \"Actions\" and, below it, a real " f"bullet list with exactly these four items in this order: {', '.join(items)}. Use real bullets " f"(bullet formatting on each paragraph), never a bullet character typed into the text. Add speaker " f"notes to slide 2 with exactly the text \"{notes}\".")}) for i in range(n_per): rows = [(rng.choice(REGIONS), rng.randint(10, 400), rng.randint(1, 60)) for _ in range(rng.randint(3, 5))] title, hexc = rng.choice(TITLES), rng.choice(HEXES) specs.append({"context": "table", "id": f"tb{i}", "title": title, "rows": rows, "hex": hexc, "spec": (f"Create a 16:9 PowerPoint deck with one slide. A text box with exactly the text \"{title}\" at the top. " f"Below it a table with header row exactly Region, Units, Incidents and these rows:\n" + "\n".join(f"{r}, {u}, {c}" for r, u, c in rows) + f"\nThe header row cells must be bold with a light grey fill D9D9D9. Below the table add a " f"rectangle shape filled with the colour {hexc} (a hex colour; no alpha) and a width of 3 inches.")}) for i in range(n_per): k = rng.randint(4, 6) labels = MONTHS[:k] units = [rng.randint(50, 500) for _ in labels] rate = [round(rng.uniform(1.0, 9.9), 1) for _ in labels] title = rng.choice(TITLES) + " chart" specs.append({"context": "combo", "id": f"cb{i}", "title": title, "labels": labels, "units": units, "rate": rate, "spec": (f"Create a 16:9 PowerPoint deck with one slide containing ONE native combo chart titled exactly " f"\"{title}\" (chart title shown): a column (bar) series named \"Units\" with categories " f"{labels} and values {units}, plus a line series named \"Rate\" with values {rate} plotted on a " f"SECONDARY value axis. Show data labels (values) on the Units series. The chart must open " f"cleanly in PowerPoint: when a series uses a secondary axis, declare both value axes and both " f"category axes on the chart.")}) rng.shuffle(specs) return specs # ---------------------------------------------------------------- run the model's script FENCE = re.compile(r"```(?:javascript|js|typescript|ts)?\s*\n(.*?)```", re.S) def extract_script(reply: str) -> str | None: m = FENCE.findall(reply or "") if m: return max(m, key=len) if "require(" in (reply or "") and "pptxgen" in reply: return reply return None def run_script(script: str, timeout_s: int = 90) -> tuple[str | None, str]: d = tempfile.mkdtemp(prefix="sg_pptx_") out = os.path.join(d, "out.pptx") path = os.path.join(d, "build.js") with open(path, "w", encoding="utf-8") as fh: fh.write(script) env = {**os.environ, "OUTPUT": out, "NODE_PATH": os.path.abspath(os.path.join(NODE_DIR, "node_modules"))} try: p = subprocess.run(["node", path], cwd=d, env=env, capture_output=True, text=True, timeout=timeout_s) log = (p.stdout + p.stderr)[-2000:] if p.returncode != 0: # the error line is near the TOP of node's stderr; the tail is only the stack err = next((ln for ln in p.stderr.splitlines() if re.search(r"Error|error", ln) and "at " not in ln[:6]), "") return None, f"exit {p.returncode}: {err[:300] or log[:300]}" except subprocess.TimeoutExpired: return None, "timeout" except FileNotFoundError: return None, "node not found" return (out if os.path.exists(out) else None), log # ---------------------------------------------------------------- grading def _texts(slide) -> list[str]: out = [] for sh in slide.shapes: if sh.has_text_frame: out.append(sh.text_frame.text.strip()) return out def _paras(slide): for sh in slide.shapes: if sh.has_text_frame: for p in sh.text_frame.paragraphs: yield p def _has_bullet(p) -> bool: ppr = p._p.pPr if ppr is None: return False return ppr.find(f"{{{NS['a']}}}buChar") is not None or ppr.find(f"{{{NS['a']}}}buAutoNum") is not None def _cell_fill(cell) -> str | None: el = cell._tc.find(f"{{{NS['a']}}}tcPr") if el is None: return None s = el.find(f"{{{NS['a']}}}solidFill") if s is None: return None c = s.find(f"{{{NS['a']}}}srgbClr") return c.get("val").upper() if c is not None and c.get("val") else None def _cell_bold(cell) -> bool: return any(r.font.bold for p in cell.text_frame.paragraphs for r in p.runs if r.text.strip()) def grade(spec: dict, path: str | None) -> tuple[int, str]: if not path: return 0, "no file" try: from pptx import Presentation from pptx.util import Emu prs = Presentation(path) except Exception as e: # noqa: BLE001 return 0, f"unreadable: {str(e)[:80]}" ctx = spec["context"] try: # 16:9 is an aspect ratio: pptxgenjs LAYOUT_16x9 (10 x 5.625 in) and LAYOUT_WIDE # (13.3 x 7.5 in) both qualify. The first gate run wrongly demanded the 10-inch canvas. ratio = int(prs.slide_width) / max(1, int(prs.slide_height)) if abs(ratio - 16 / 9) > 0.03: return 0, f"layout {prs.slide_width}x{prs.slide_height} ratio {ratio:.3f} not 16:9" slides = list(prs.slides) if ctx == "bullets": if len(slides) != 2: return 0, f"{len(slides)} slides" t1 = _texts(slides[0]) if spec["title"] not in t1 or spec["sub"] not in t1: return 0, f"slide 1 texts {t1}" found, literal = 0, 0 for p in _paras(slides[1]): t = p.text.strip() if t in spec["items"]: found += 1 if not _has_bullet(p): return 0, f"item without bullet formatting: {t}" if t[:1] in "•●◦-*" and t[1:].strip() in spec["items"]: literal += 1 if literal: return 0, "literal bullet character" if found != 4: return 0, f"found {found} of 4 items" if "Actions" not in _texts(slides[1]) and not any(x.startswith("Actions") for x in _texts(slides[1])): return 0, "no Actions title" if not slides[1].has_notes_slide: return 0, "no speaker notes" notes = slides[1].notes_slide.notes_text_frame.text.strip() if notes != spec["notes"]: return 0, f"notes {notes[:40]!r}" return 1, "ok" if ctx == "table": if len(slides) != 1: return 0, f"{len(slides)} slides" s = slides[0] if spec["title"] not in _texts(s): return 0, "title" tables = [sh for sh in s.shapes if sh.has_table] if not tables: return 0, "no table" t = tables[0].table cells = [[t.cell(r, c).text.strip() for c in range(len(t.columns))] for r in range(len(t.rows))] if cells[0] != ["Region", "Units", "Incidents"]: return 0, f"header {cells[0]}" want = [[r, str(u), str(c)] for r, u, c in spec["rows"]] if cells[1:1 + len(want)] != want: return 0, f"rows {cells[1:1 + len(want)]}" for c in range(3): if not _cell_bold(t.cell(0, c)): return 0, "header not bold" if _cell_fill(t.cell(0, c)) != "D9D9D9": return 0, f"header fill {_cell_fill(t.cell(0, c))}" # (removed) a dead `rects` comprehension used to live here. It called # getattr(sh, "auto_shape_type", None), and getattr does NOT suppress an # exception raised by a property: python-pptx raises on any shape that is not # an auto shape, so a deck holding a chart or a picture crashed the grader and # correct output was reported as a failure. The value was never used. ok_rect = False for sh in s.shapes: try: if sh.fill.type == 1 and str(sh.fill.fore_color.rgb).upper() == spec["hex"].upper(): if abs(int(sh.width) - 2743200) < 100000: # 3 inches in EMU ok_rect = True except Exception: # noqa: BLE001 continue if not ok_rect: return 0, f"no 3in rectangle filled {spec['hex']}" return 1, "ok" if ctx == "combo": if len(slides) != 1: return 0, f"{len(slides)} slides" charts = [sh for sh in slides[0].shapes if sh.has_chart] if len(charts) != 1: return 0, f"{len(charts)} charts" cs = charts[0].chart._chartSpace c = NS["c"] plot = cs.find(f".//{{{c}}}plotArea") if plot is None: return 0, "no plotArea" bars = plot.findall(f"{{{c}}}barChart") lines = plot.findall(f"{{{c}}}lineChart") if not bars or not lines: return 0, f"bar {len(bars)} line {len(lines)}" val_axes = plot.findall(f"{{{c}}}valAx") cat_axes = plot.findall(f"{{{c}}}catAx") + plot.findall(f"{{{c}}}dateAx") val_ids = {ax.find(f"{{{c}}}axId").get("val") for ax in val_axes if ax.find(f"{{{c}}}axId") is not None} cat_ids = {ax.find(f"{{{c}}}axId").get("val") for ax in cat_axes if ax.find(f"{{{c}}}axId") is not None} if len(val_axes) < 2: return 0, f"{len(val_axes)} value axis (secondary axis not declared)" if len(cat_axes) < 2: return 0, f"{len(cat_axes)} category axis (skill: both catAxes needed)" # pptxgenjs always writes a third, undeclared serAx id per group; PowerPoint tolerates # that. What it does not tolerate is a group whose cat or val axis id is undeclared. group_val = [] for grp in bars + lines: ids = [e.get("val") for e in grp.findall(f"{{{c}}}axId")] if not (set(ids) & cat_ids) or not (set(ids) & val_ids): return 0, f"group {grp.tag.split('}')[1]} references undeclared cat/val axis {ids}" group_val.append(next(i for i in ids if i in val_ids)) if len(set(group_val)) < 2: return 0, "bar and line share one value axis (no secondary axis)" title = cs.find(f".//{{{c}}}chart/{{{c}}}title") ttxt = "".join(t.text or "" for t in title.iter(f"{{{NS['a']}}}t")) if title is not None else "" if ttxt.strip() != spec["title"]: return 0, f"chart title {ttxt[:40]!r}" names = ["".join(t.text or "" for t in ser.iter(f"{{{c}}}tx") for t in t.iter(f"{{{c}}}v")) for ser in plot.iter(f"{{{c}}}ser")] if not any("Units" in n for n in names) or not any("Rate" in n for n in names): return 0, f"series names {names}" bar_ser = bars[0].find(f"{{{c}}}ser") show = bar_ser.find(f".//{{{c}}}dLbls/{{{c}}}showVal") if bar_ser is not None else None if show is None or show.get("val") not in ("1", "true"): return 0, "no data labels on Units" cats = [v.text for v in bars[0].iter(f"{{{c}}}cat") for v in v.iter(f"{{{c}}}v")] if cats[:len(spec["labels"])] != spec["labels"]: return 0, f"categories {cats[:6]}" vals = [float(v.text) for v in bars[0].iter(f"{{{c}}}val") for v in v.iter(f"{{{c}}}v")] if vals[:len(spec["units"])] != [float(u) for u in spec["units"]]: return 0, f"values {vals[:6]}" return 1, "ok" except Exception as e: # noqa: BLE001 return 0, f"grader exception: {str(e)[:100]}" return 0, "unknown context" def user_prompt(spec: dict) -> str: return (spec["spec"] + "\n\nWrite ONE complete Node.js script using pptxgenjs (require('pptxgenjs')) that builds this " "deck and writes it with pres.writeFile({ fileName: process.env.OUTPUT }). Output only the script in a single " "```javascript fence. Do not add anything the spec did not ask for.") # ---- end of pptx_tasks.py ---- import copy from pptx import Presentation from pptx.util import Inches, Emu, Pt from pptx.dml.color import RGBColor from pptx.enum.text import PP_ALIGN def build_reference(spec, out_path): prs = Presentation() prs.slide_width = Inches(10) prs.slide_height = Inches(5.625) slide = prs.slides.add_slide(prs.slide_layouts[6]) # title text box tb = slide.shapes.add_textbox(Inches(0.4), Inches(0.3), Inches(9), Inches(0.6)) tb.text_frame.text = spec["title"] rows = spec["rows"] nrows = len(rows) + 1 ncols = 3 table_shape = slide.shapes.add_table(nrows, ncols, Inches(0.4), Inches(1.1), Inches(6), Inches(0.4 * nrows)) table = table_shape.table headers = ["Region", "Units", "Incidents"] for c, h in enumerate(headers): cell = table.cell(0, c) cell.text = h for p in cell.text_frame.paragraphs: for r in p.runs: r.font.bold = True cell.fill.solid() cell.fill.fore_color.rgb = RGBColor.from_string("D9D9D9") for ri, (region, units, incidents) in enumerate(rows, start=1): table.cell(ri, 0).text = region table.cell(ri, 1).text = str(units) table.cell(ri, 2).text = str(incidents) # rectangle below the table, filled with hex, width 3 inches top_of_rect = Inches(1.1) + Inches(0.4 * nrows) + Inches(0.3) rect = slide.shapes.add_shape(1, Inches(0.4), top_of_rect, Inches(3), Inches(1)) # 1 = MSO_SHAPE.RECTANGLE rect.fill.solid() rect.fill.fore_color.rgb = RGBColor.from_string(spec["hex"]) rect.line.fill.background() prs.save(out_path) return out_path def _load(path): return Presentation(path) def spoil_literal_bullet_unbold_header(path): # header text becomes not-bold (breaks 'bold' requirement) out = path + '.spoil_unbold_header' import shutil shutil.copyfile(path, out) prs = Presentation(out) slide = prs.slides[0] for sh in slide.shapes: if sh.has_table: t = sh.table for c in range(3): cell = t.cell(0, c) for p in cell.text_frame.paragraphs: for r in p.runs: r.font.bold = False prs.save(out) return out def spoil_wrong_header_fill(path): # header fill becomes a different grey, not D9D9D9 out = path + '.spoil_header_fill' import shutil shutil.copyfile(path, out) prs = Presentation(out) slide = prs.slides[0] for sh in slide.shapes: if sh.has_table: t = sh.table for c in range(3): cell = t.cell(0, c) cell.fill.solid() cell.fill.fore_color.rgb = RGBColor.from_string("CCCCCC") prs.save(out) return out def spoil_rect_wrong_width(path): # rectangle width changed from 3 inches to 2 inches out = path + '.spoil_rect_width' import shutil shutil.copyfile(path, out) prs = Presentation(out) slide = prs.slides[0] for sh in slide.shapes: try: if sh.fill.type == 1: sh.width = Inches(2) except Exception: continue prs.save(out) return out def spoil_rect_wrong_color(path): # rectangle filled with a different hex color than spec out = path + '.spoil_rect_color' import shutil shutil.copyfile(path, out) prs = Presentation(out) slide = prs.slides[0] for sh in slide.shapes: try: if sh.fill.type == 1: sh.fill.fore_color.rgb = RGBColor.from_string("00FF00") except Exception: continue prs.save(out) return out def spoil_wrong_header_text(path): # header row text changed, breaking 'header row exactly Region, Units, Incidents' out = path + '.spoil_header_text' import shutil shutil.copyfile(path, out) prs = Presentation(out) slide = prs.slides[0] for sh in slide.shapes: if sh.has_table: t = sh.table cell = t.cell(0, 2) # keep bold/fill but change text for p in cell.text_frame.paragraphs: for r in p.runs: r.text = "Events" if not cell.text_frame.paragraphs[0].runs: cell.text_frame.paragraphs[0].text = "Events" prs.save(out) return out def spoil_row_value_changed(path): # one data cell value altered, breaking exact row match out = path + '.spoil_row_value' import shutil shutil.copyfile(path, out) prs = Presentation(out) slide = prs.slides[0] for sh in slide.shapes: if sh.has_table: t = sh.table cell = t.cell(1, 1) for p in cell.text_frame.paragraphs: for r in p.runs: r.text = "999" if not cell.text_frame.paragraphs[0].runs: cell.text_frame.paragraphs[0].text = "999" prs.save(out) return out SPOILERS = [ ("unbold_header", spoil_literal_bullet_unbold_header), ("wrong_header_fill", spoil_wrong_header_fill), ("rect_wrong_width", spoil_rect_wrong_width), ("rect_wrong_color", spoil_rect_wrong_color), ("wrong_header_text", spoil_wrong_header_text), ("row_value_changed", spoil_row_value_changed), ]