Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
Forgejo Docker Build / Build Docker image (push) Successful in 13s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The shape vocabulary is deliberately small, which leaves the question of what to
add next. Rather than guess, it now records demand.
Two signals, because a model asks both ways. It can say so outright —
{"kind":"unsupported","need":"a SmartArt cycle of four stages"}, which draws
nothing and is told about in the same file that validates it — or it can reach
for a kind, chart type or slide type that does not exist, which is the more
common way of asking and just as much of a signal.
Both produce a log line naming what was wanted and the topic it came up on, and
increment ped_ai_deck_vocabulary_gap_total{wanted}, so it can be counted over
time in Grafana rather than noticed once and forgotten. Deduplicated per
generation and capped at twelve: a model that asks for a hundred things it cannot
have should not write a hundred log lines. It can never fail a generation — it is
a note to whoever decides what to build next.
This is also the answer to whether to run model-authored code in a sandbox
instead. The log will say whether the gap is real. Some of it is not closeable by
any sandbox, being python-pptx's own ceiling — no SmartArt, no animations or
transitions, limited chart types — and a sandbox would only let a model write
code against the same library and hit the same wall. Documented in
docs/my-resources.md, which the in-app Docs tab serves directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
202 lines
9.3 KiB
JavaScript
202 lines
9.3 KiB
JavaScript
// ============================================================
|
||
// SLIDE PRIMITIVES
|
||
// ============================================================
|
||
// What a model may draw when the named layouts are not enough.
|
||
//
|
||
// The typed layouts — bullets, compare, table, callout, figure — are good
|
||
// defaults and cheap to emit, and most slides should stay as one of them. But
|
||
// they are a fixed vocabulary, so "put the staging boxes across the top with
|
||
// arrows between them" had no expression at all. A "custom" slide carries a list
|
||
// of shapes instead, and those cover what python-pptx can actually draw.
|
||
//
|
||
// The model never emits Python. It names shapes and the renderer draws them:
|
||
// running model-authored code on the server to lay out a slide would be an
|
||
// enormous amount of trust to buy a feature.
|
||
//
|
||
// Coordinates are percentages of the slide, 0–100, so a model can reason about
|
||
// position without knowing anything about EMU. Everything is clamped, every
|
||
// enum is an allowlist, and counts are capped — a malformed shape costs that
|
||
// shape, never the deck.
|
||
|
||
var KINDS = ['text', 'rect', 'roundRect', 'ellipse', 'arrow', 'arrowDown', 'chevron',
|
||
'diamond', 'hexagon', 'line', 'image', 'table', 'chart'];
|
||
// Not drawn. A way for the model to say what it wanted and could not have, so
|
||
// the vocabulary grows from what people actually ask for rather than from
|
||
// guesses about what might be useful.
|
||
var UNSUPPORTED = 'unsupported';
|
||
var CHARTS = ['column', 'bar', 'line', 'pie', 'doughnut'];
|
||
var ALIGN = ['left', 'center', 'right'];
|
||
var VALIGN = ['top', 'middle', 'bottom'];
|
||
|
||
var MAX_SHAPES = 24;
|
||
var MAX_RUNS = 40;
|
||
|
||
function num(value, min, max, fallback) {
|
||
var n = typeof value === 'number' ? value : parseFloat(value);
|
||
if (!isFinite(n)) return fallback;
|
||
return Math.max(min, Math.min(max, n));
|
||
}
|
||
|
||
function text(value, max) {
|
||
return String(value === undefined || value === null ? '' : value).slice(0, max || 2000);
|
||
}
|
||
|
||
// Six hex digits or nothing. A colour is the easiest place for a model to put
|
||
// something that is not a colour.
|
||
function colour(value) {
|
||
var hex = String(value || '').trim().replace(/^#/, '');
|
||
return /^[0-9a-fA-F]{6}$/.test(hex) ? hex.toUpperCase() : null;
|
||
}
|
||
|
||
function runs(list) {
|
||
if (typeof list === 'string') list = [{ text: list }];
|
||
if (!Array.isArray(list)) return [];
|
||
return list.slice(0, MAX_RUNS).map(function (run) {
|
||
if (typeof run === 'string') run = { text: run };
|
||
return {
|
||
text: text(run && run.text, 2000),
|
||
size: num(run && run.size, 8, 60, 0) || null,
|
||
bold: Boolean(run && run.bold),
|
||
italic: Boolean(run && run.italic),
|
||
color: colour(run && run.color),
|
||
bullet: Boolean(run && run.bullet),
|
||
level: Math.round(num(run && run.level, 0, 4, 0)),
|
||
align: ALIGN.indexOf(run && run.align) === -1 ? null : run.align
|
||
};
|
||
}).filter(function (run) { return run.text; });
|
||
}
|
||
|
||
function table(shape) {
|
||
var header = (Array.isArray(shape.header) ? shape.header : []).slice(0, 8)
|
||
.map(function (c) { return text(c, 200); });
|
||
var rows = (Array.isArray(shape.rows) ? shape.rows : []).slice(0, 14).map(function (row) {
|
||
return (Array.isArray(row) ? row : []).slice(0, 8).map(function (c) { return text(c, 300); });
|
||
}).filter(function (row) { return row.some(Boolean); });
|
||
return rows.length ? { header: header, rows: rows } : null;
|
||
}
|
||
|
||
function chart(shape) {
|
||
var kind = CHARTS.indexOf(shape.chart) === -1 ? 'column' : shape.chart;
|
||
var categories = (Array.isArray(shape.categories) ? shape.categories : [])
|
||
.slice(0, 12).map(function (c) { return text(c, 60); });
|
||
var series = (Array.isArray(shape.series) ? shape.series : []).slice(0, 4).map(function (s) {
|
||
return {
|
||
name: text(s && s.name, 60) || 'Series',
|
||
values: (Array.isArray(s && s.values) ? s.values : []).slice(0, 12)
|
||
.map(function (v) { return num(v, -1e9, 1e9, 0); })
|
||
};
|
||
}).filter(function (s) { return s.values.length; });
|
||
if (!categories.length || !series.length) return null;
|
||
// A pie has one series by definition; more would be silently ignored anyway.
|
||
if (kind === 'pie' || kind === 'doughnut') series = series.slice(0, 1);
|
||
return { chart: kind, categories: categories, series: series };
|
||
}
|
||
|
||
/**
|
||
* Accept only what the renderer can draw. Never throws: a shape that cannot be
|
||
* understood is dropped, and a slide that loses every shape falls back to being
|
||
* a plain one rather than an empty frame.
|
||
*/
|
||
function normalise(list, gaps) {
|
||
if (!Array.isArray(list)) return [];
|
||
var out = [];
|
||
var note = function (wanted, detail) {
|
||
if (!Array.isArray(gaps)) return;
|
||
var name = String(wanted || 'unknown').slice(0, 60);
|
||
if (gaps.length < 12) gaps.push({ wanted: name, detail: String(detail || '').slice(0, 240) });
|
||
};
|
||
list.slice(0, MAX_SHAPES).forEach(function (raw) {
|
||
if (!raw || typeof raw !== 'object') return;
|
||
|
||
// Said outright: "I wanted a SmartArt cycle here." Recorded and not drawn.
|
||
if (raw.kind === UNSUPPORTED) {
|
||
note(raw.need || raw.kind, raw.need);
|
||
return;
|
||
}
|
||
// Reached for something that does not exist. Just as much of a signal as
|
||
// saying so, and more common — a model asks by trying.
|
||
if (raw.kind && KINDS.indexOf(raw.kind) === -1) {
|
||
note(raw.kind, 'used as a shape kind');
|
||
}
|
||
var kind = KINDS.indexOf(raw.kind) === -1 ? 'text' : raw.kind;
|
||
var shape = {
|
||
kind: kind,
|
||
x: num(raw.x, 0, 100, 5),
|
||
y: num(raw.y, 0, 100, 5),
|
||
w: num(raw.w, 1, 100, 30),
|
||
h: num(raw.h, 1, 100, 15),
|
||
fill: colour(raw.fill),
|
||
line: colour(raw.line),
|
||
lineWidth: num(raw.lineWidth, 0, 8, 0) || null,
|
||
radius: num(raw.radius, 0, 1, 0) || null,
|
||
align: ALIGN.indexOf(raw.align) === -1 ? null : raw.align,
|
||
valign: VALIGN.indexOf(raw.valign) === -1 ? null : raw.valign,
|
||
rotation: num(raw.rotation, -180, 180, 0) || null
|
||
};
|
||
// Nothing may be positioned off the slide: a shape at x=95 w=30 would be
|
||
// drawn half outside it, which no instruction is asking for.
|
||
if (shape.x + shape.w > 100) shape.w = Math.max(1, 100 - shape.x);
|
||
if (shape.y + shape.h > 100) shape.h = Math.max(1, 100 - shape.y);
|
||
|
||
if (kind === 'table') {
|
||
var built = table(raw);
|
||
if (!built) return;
|
||
shape.header = built.header; shape.rows = built.rows;
|
||
} else if (kind === 'chart') {
|
||
if (raw.chart && CHARTS.indexOf(raw.chart) === -1) note(raw.chart, 'asked for as a chart type');
|
||
var data = chart(raw);
|
||
if (!data) return;
|
||
shape.chart = data.chart; shape.categories = data.categories; shape.series = data.series;
|
||
} else if (kind === 'image') {
|
||
shape.image_prompt = text(raw.image_prompt || raw.imagePrompt, 1200);
|
||
shape.image_job = text(raw.image_job, 200) || null;
|
||
shape.fit = raw.fit === 'fill' ? 'fill' : 'contain';
|
||
if (!shape.image_prompt && !shape.image_job) return;
|
||
} else {
|
||
shape.runs = runs(raw.runs !== undefined ? raw.runs : raw.text);
|
||
// A line needs no words; everything else without any is an empty box.
|
||
if (!shape.runs.length && kind !== 'line' && !shape.fill) return;
|
||
}
|
||
out.push(shape);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// Described for the model, in the prompt. Kept beside the validator so the two
|
||
// cannot drift: what is documented here is exactly what normalise() accepts.
|
||
function instructions() {
|
||
return [
|
||
'{"type":"custom","heading":"...","shapes":[ ... ]}',
|
||
' Anything the named layouts cannot express — a diagram of boxes and arrows,',
|
||
' a timeline, a figure with labels beside it, a chart. Use it when the author',
|
||
' asks for a specific arrangement, and prefer the named layouts otherwise.',
|
||
'',
|
||
' Every shape is positioned in percentages of the slide, 0-100:',
|
||
' x, y top-left corner w, h width and height',
|
||
' A heading, when given, occupies roughly the top 22%, so content starts at y=26.',
|
||
'',
|
||
' {"kind":"text","x":6,"y":26,"w":44,"h":40,"valign":"middle",',
|
||
' "runs":[{"text":"Point one","bullet":true},{"text":"Bold bit","bold":true,"size":20}]}',
|
||
' {"kind":"rect","x":6,"y":30,"w":26,"h":18,"fill":"EFF6FF","line":"2563EB",',
|
||
' "runs":[{"text":"Mild","bold":true,"align":"center"}]} // also roundRect, ellipse,',
|
||
' // diamond, hexagon, chevron',
|
||
' {"kind":"arrow","x":33,"y":37,"w":8,"h":5,"fill":"94A3B8"} // also arrowDown',
|
||
' {"kind":"line","x":6,"y":50,"w":88,"h":0,"line":"E5E7EB"}',
|
||
' {"kind":"table","x":6,"y":28,"w":88,"h":40,"header":["A","B"],"rows":[["1","2"]]}',
|
||
' {"kind":"chart","chart":"column","x":6,"y":28,"w":50,"h":50,',
|
||
' "categories":["0-6m","6-12m"],"series":[{"name":"Cases","values":[12,30]}]}',
|
||
' {"kind":"image","x":55,"y":26,"w":40,"h":50,"image_prompt":"..."}',
|
||
'',
|
||
' Colours are six hex digits with no "#". Shapes are drawn in the order given,',
|
||
' so a later one sits on top. Keep text inside its own shape: nothing is',
|
||
' measured for you on a custom slide.',
|
||
'',
|
||
' If you need something this list cannot express, include',
|
||
' {"kind":"unsupported","need":"a SmartArt cycle of four stages"} and lay the',
|
||
' slide out as well as you can without it. Nothing is drawn for that entry —',
|
||
' it is how the vocabulary learns what to add next, so say plainly what you',
|
||
' wanted rather than working around it silently.'
|
||
].join('\n');
|
||
}
|
||
|
||
module.exports = { normalise, instructions, KINDS, CHARTS, MAX_SHAPES, UNSUPPORTED };
|