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
129 lines
6.8 KiB
JavaScript
129 lines
6.8 KiB
JavaScript
// ============================================================
|
|
// SLIDE PRIMITIVES
|
|
// ============================================================
|
|
// What a model may draw when the named layouts have no word for what was asked.
|
|
// Everything here is about what the validator refuses: a shape arriving from a
|
|
// model is untrusted input that ends up in a file somebody downloads.
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const shapes = require('../src/utils/slideShapes');
|
|
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
|
|
const one = spec => shapes.normalise([spec])[0];
|
|
|
|
test('a shape is positioned in percentages, and never off the slide', () => {
|
|
// A model reasons about "left half" far better than about EMU.
|
|
const clamped = one({ kind: 'rect', x: 95, y: 90, w: 30, h: 40, fill: 'FFFFFF' });
|
|
assert.equal(clamped.x, 95);
|
|
assert.equal(clamped.w, 5, 'width is cut to the slide edge, not drawn past it');
|
|
assert.equal(clamped.h, 10);
|
|
// Nonsense coordinates fall back rather than becoming NaN in the renderer.
|
|
const junk = one({ kind: 'rect', x: 'left', y: null, w: undefined, h: 'big', fill: 'FFFFFF' });
|
|
assert.equal(junk.x, 5);
|
|
assert.equal(junk.w, 30);
|
|
});
|
|
|
|
test('colours are six hex digits or nothing at all', () => {
|
|
assert.equal(one({ kind: 'rect', fill: '#eff6ff' }).fill, 'EFF6FF', 'a leading # and case are tolerated');
|
|
// The easiest place for a model to put something that is not a colour.
|
|
for (const bad of ['red', 'rgb(1,2,3)', '#fff', 'EFF6F', 'EFF6FFF', '<script>']) {
|
|
assert.equal(shapes.normalise([{ kind: 'rect', fill: bad, text: 'x' }])[0].fill, null, bad);
|
|
}
|
|
});
|
|
|
|
test('what cannot be drawn is dropped, not guessed at', () => {
|
|
// An unknown kind becomes text, and text with nothing in it is not a shape.
|
|
assert.equal(shapes.normalise([{ kind: 'bogus', x: 1, y: 1 }]).length, 0);
|
|
assert.equal(shapes.normalise([{ kind: 'table', header: ['A'], rows: [] }]).length, 0, 'an empty table');
|
|
assert.equal(shapes.normalise([{ kind: 'chart', chart: 'column', categories: [], series: [] }]).length, 0);
|
|
assert.equal(shapes.normalise([{ kind: 'image' }]).length, 0, 'an image with nothing to draw');
|
|
assert.equal(shapes.normalise('not an array').length, 0);
|
|
// A line carries no words and is still a shape.
|
|
assert.equal(shapes.normalise([{ kind: 'line', x: 6, y: 50, w: 88, h: 0, line: 'E5E7EB' }]).length, 1);
|
|
});
|
|
|
|
test('counts are capped, because a model can always ask for more', () => {
|
|
assert.equal(shapes.normalise(new Array(200).fill({ kind: 'rect', fill: 'FFFFFF' })).length, shapes.MAX_SHAPES);
|
|
const wide = one({ kind: 'table', header: new Array(20).fill('h'), rows: [new Array(20).fill('c')] });
|
|
assert.equal(wide.header.length, 8);
|
|
assert.equal(wide.rows[0].length, 8);
|
|
// A pie has one series by definition; more would be silently ignored anyway.
|
|
const pie = one({ kind: 'chart', chart: 'pie', categories: ['a', 'b'],
|
|
series: [{ name: 'S', values: [1, 2] }, { name: 'T', values: [3, 4] }] });
|
|
assert.equal(pie.series.length, 1);
|
|
// An unknown chart type falls back rather than reaching the renderer.
|
|
assert.equal(one({ kind: 'chart', chart: 'radar', categories: ['a'], series: [{ values: [1] }] }).chart, 'column');
|
|
});
|
|
|
|
test('the documented vocabulary is the accepted vocabulary', () => {
|
|
// The prompt and the validator live in the same file so they cannot drift:
|
|
// a kind the model is told about but the validator drops is a silent failure.
|
|
const doc = shapes.instructions();
|
|
for (const kind of shapes.KINDS) {
|
|
assert.ok(doc.includes('"' + kind + '"') || doc.includes(kind), kind + ' is described to the model');
|
|
}
|
|
// And the renderer can draw every one of them.
|
|
const py = read('scripts/render_pptx.py');
|
|
for (const kind of ['rect', 'roundRect', 'ellipse', 'arrow', 'arrowDown', 'chevron', 'diamond', 'hexagon']) {
|
|
assert.match(py, new RegExp('"' + kind + '": MSO_SHAPE'), kind);
|
|
}
|
|
for (const kind of shapes.CHARTS) {
|
|
assert.match(py, new RegExp('"' + kind + '": XL_CHART_TYPE'), kind);
|
|
}
|
|
assert.match(py, /"custom": slide_custom/);
|
|
// One bad shape must not cost the slide it is on.
|
|
assert.match(py, /except Exception as exc: # one bad shape must not cost the slide/);
|
|
});
|
|
|
|
test('a custom slide that loses every shape becomes a plain one', () => {
|
|
const deckSchema = require('../src/utils/deckSchema');
|
|
const deck = deckSchema.normalise({ slides: [
|
|
{ type: 'custom', heading: 'Nothing usable', shapes: [{ kind: 'bogus' }], bullets: ['fallback'] },
|
|
]});
|
|
// A heading over an empty frame is worse than the bullets it also sent.
|
|
assert.equal(deck.slides[0].type, 'bullets');
|
|
assert.equal(deck.slides[0].bullets[0].text, 'fallback');
|
|
});
|
|
|
|
test('what the model wanted and could not have is recorded', () => {
|
|
// The vocabulary is deliberately small. This is how it learns what to grow
|
|
// into — from what is actually asked for, not from guesses about what might
|
|
// be useful. Both signals count: saying so, and trying anyway.
|
|
const gaps = [];
|
|
const drawn = shapes.normalise([
|
|
{ kind: 'unsupported', need: 'a SmartArt cycle of four stages' },
|
|
{ kind: 'smartart', x: 1, y: 1, w: 10, h: 10, text: 'tried anyway' },
|
|
{ kind: 'chart', chart: 'radar', x: 1, y: 1, w: 10, h: 10, categories: ['a'], series: [{ values: [1] }] },
|
|
{ kind: 'rect', x: 1, y: 1, w: 10, h: 10, fill: 'FFFFFF', text: 'fine' },
|
|
], gaps);
|
|
|
|
assert.deepEqual(gaps.map(g => g.wanted),
|
|
['a SmartArt cycle of four stages', 'smartart', 'radar']);
|
|
// "unsupported" draws nothing; the rest of the slide still stands.
|
|
assert.deepEqual(drawn.map(d => d.kind), ['text', 'chart', 'rect']);
|
|
// An unknown chart type still renders, as a column chart, rather than vanishing.
|
|
assert.equal(drawn[1].chart, 'column');
|
|
|
|
// A slide type that does not exist is the same signal one level up.
|
|
const deckGaps = [];
|
|
require('../src/utils/deckSchema').normalise({ slides: [{ type: 'timeline', bullets: ['x'] }] }, deckGaps);
|
|
assert.deepEqual(deckGaps, [{ wanted: 'timeline', detail: 'used as a slide type' }]);
|
|
|
|
// Capped: a model that asks for a hundred things it cannot have should not
|
|
// write a hundred log lines.
|
|
const many = [];
|
|
shapes.normalise(new Array(30).fill(0).map((_, i) => ({ kind: 'unsupported', need: 'thing ' + i })), many);
|
|
assert.equal(many.length, 12);
|
|
|
|
// The model is told how to say it, in the same file that records it.
|
|
assert.match(shapes.instructions(), /"kind":"unsupported","need":/);
|
|
|
|
// And it reaches somewhere a person will see it.
|
|
const route = read('src/routes/myResources.js');
|
|
assert.match(route, /function reportVocabularyGaps\(gaps, topic\)/);
|
|
assert.match(route, /deckVocabularyGaps\.inc\(\{ wanted: gap\.wanted \}\)/);
|
|
assert.match(route, /\[deck-vocabulary\] wanted/);
|
|
assert.match(read('src/utils/metrics.js'), /ped_ai_deck_vocabulary_gap_total/);
|
|
});
|