diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 4d3b17f3..6f38371c 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -26,6 +26,12 @@ jobs: - name: Check slug naming conventions run: node scripts/check-slugs.mjs + - name: Validate homepage slides + run: node scripts/slides/slides.js validate + + - name: Slides pipeline unit tests + run: node --test scripts/slides/slides.test.js + - name: Build (static output) run: pnpm build env: diff --git a/.github/workflows/refresh-highlights.yml b/.github/workflows/refresh-highlights.yml new file mode 100644 index 00000000..3e49250b --- /dev/null +++ b/.github/workflows/refresh-highlights.yml @@ -0,0 +1,143 @@ +name: Refresh Highlights + +on: + schedule: + - cron: '0 7 * * 1' # Mon ~08:00 Europe/Oslo (UTC; ±1h DST drift) + - cron: '0 15 * * 5' # Fri ~16:00 Europe/Oslo + workflow_dispatch: + +concurrency: + group: refresh-highlights + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Install OpenCode (pinned; agent is optional) + continue-on-error: true + run: npm install -g opencode-ai@0.5.29 + + - name: Run refresh pipeline + id: refresh + continue-on-error: true + env: + # Optional: set repo secrets to enable the caption agent. Absent → the + # pipeline uses summary-derived captions (fully functional). + SLIDES_AGENT_MODEL: ${{ vars.SLIDES_AGENT_MODEL }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + run: node scripts/slides/slides.js refresh --diff-scope + + - name: Build sanity gate + id: build + if: steps.refresh.outcome == 'success' && steps.refresh.outputs.result == 'changed' + continue-on-error: true + run: pnpm build + env: + GITHUB_PAGES: true + + - name: Open PR + id: pr + if: steps.refresh.outputs.result == 'changed' && steps.build.outcome == 'success' + continue-on-error: true + env: + # A PAT makes pr-test.yml run on the PR; PRs opened with the default + # GITHUB_TOKEN get no checks. Validation and pnpm build above already + # gated this branch either way. + GH_TOKEN: ${{ secrets.SLIDES_BOT_TOKEN || github.token }} + run: | + set -euo pipefail + BRANCH="bot/slides-refresh-${{ github.run_id }}" + git config user.name "elixir-no-bot" + git config user.email "actions@github.com" + git checkout -b "$BRANCH" + git add src/data/slides.json src/data/slides + git commit -m "chore(slides): refresh homepage highlights" + + # Read before creating, or the new PR supersedes itself. This runs + # before the push because a bare assignment from a command + # substitution carries its exit status: if the list call fails, + # set -e ends the step here, and there is no pushed branch to orphan. + SUPERSEDED=$(gh pr list --state open --base main --limit 100 --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("bot/slides-refresh-")) | .number') + + git push origin "$BRANCH" + + cat > "$RUNNER_TEMP/pr-body.md" <<'EOF' + Automated highlights refresh, open for review. + + Slide selection and captions were regenerated from recent content. The + pipeline validated the result after writing it, and `pnpm build` passed, + both in the workflow run that opened this. + + Merge it or close it, but do not leave it sitting. The branch holds a + whole `slides.json` written against main as of this run, so the next + run replaces this PR rather than rebasing it. + EOF + + if ! PR_URL=$(gh pr create --base main --head "$BRANCH" \ + --title "chore(slides): refresh homepage highlights" \ + --body-file "$RUNNER_TEMP/pr-body.md"); then + git push origin --delete "$BRANCH" || true + exit 1 + fi + + # One open refresh at a time. Each run recomputes the carousel from + # main as it stands now, so an older PR is not a smaller change, it is + # a different answer to the same question; merging both can put back a + # slide the newer run deliberately dropped. Closed PRs keep a Restore + # branch button, so nothing is lost. + # A close that fails must not turn this run red: the new PR is already + # open and mergeable, and someone merging the old one by hand in the + # seconds since the list above is a normal way to get here. + for N in $SUPERSEDED; do + gh pr close "$N" --delete-branch \ + --comment "Superseded by ${PR_URL}, computed from the current main." \ + || echo "::warning::could not close superseded PR #${N}; close it by hand" + done + + - name: Report failure + # failure() catches the steps above these three: a broken `pnpm install` + # stops the carousel refreshing just as effectively as a broken pipeline, + # and would otherwise only show up as a red run nobody is watching. + if: failure() || steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + # gh reads the repo from the git remote, and a failure before checkout + # leaves no remote to read. + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + TITLE="Highlights refresh failed" + BODY="The scheduled highlights refresh failed on run ${{ github.run_id }}. See the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." + EXISTING=$(gh issue list --label slides-bot --state open --json number --jq '.[0].number // empty' || echo "") + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --body "$BODY" + else + gh label create slides-bot --color BFD4F2 --description "Automated highlights refresh" 2>/dev/null || true + gh issue create --title "$TITLE" --label slides-bot --body "$BODY" + fi + + - name: Fail job if pipeline, build, or PR failed + if: steps.refresh.outcome == 'failure' || steps.build.outcome == 'failure' || steps.pr.outcome == 'failure' + run: exit 1 diff --git a/package.json b/package.json index 15754da1..164ffa10 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,11 @@ "astro": "astro", "postbuild": "pagefind --site dist", "test:slugs": "node scripts/check-slugs.mjs", - "test:pages": "node scripts/test-pages.mjs" + "test:pages": "node scripts/test-pages.mjs", + "slides:collect": "node scripts/slides/slides.js collect", + "slides:refresh": "node scripts/slides/slides.js refresh", + "slides:validate": "node scripts/slides/slides.js validate", + "slides:test": "node --test scripts/slides/slides.test.js" }, "dependencies": { "@astrojs/check": "^0.9.0", @@ -46,6 +50,7 @@ "@types/dompurify": "^3.2.0", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", + "gray-matter": "^4.0.3", "npm-run-all2": "^9.0.2", "pagefind": "^1.5.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcdbb1a4..48881979 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: '@types/react-dom': specifier: ^18.2.15 version: 18.3.1 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 npm-run-all2: specifier: ^9.0.2 version: 9.0.2 @@ -1362,6 +1365,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1697,6 +1703,11 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} @@ -1724,6 +1735,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1828,6 +1843,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + h3@1.15.9: resolution: {integrity: sha512-H7UPnyIupUOYUQu7f2x7ABVeMyF/IbJjqn20WSXpMdnQB260luADUkSgJU7QTWLutq8h3tUayMQ1DdbSYX5LkA==} @@ -1917,6 +1936,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1966,6 +1989,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -1997,6 +2024,10 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -2716,6 +2747,10 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2781,6 +2816,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} @@ -2807,6 +2845,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + strnum@2.2.3: resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} @@ -4534,6 +4576,10 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -4977,6 +5023,8 @@ snapshots: escape-string-regexp@5.0.0: {} + esprima@4.0.1: {} + estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.8 @@ -5014,6 +5062,10 @@ snapshots: eventemitter3@5.0.4: {} + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -5106,6 +5158,13 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + gray-matter@4.0.3: + dependencies: + js-yaml: 3.15.0 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + h3@1.15.9: dependencies: cookie-es: 1.2.3 @@ -5297,6 +5356,8 @@ snapshots: is-docker@3.0.0: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -5333,6 +5394,11 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -5353,6 +5419,8 @@ snapshots: jsonc-parser@3.3.1: {} + kind-of@6.0.3: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -6511,6 +6579,11 @@ snapshots: dependencies: loose-envify: 1.4.0 + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + semver@6.3.1: {} semver@7.7.4: {} @@ -6609,6 +6682,8 @@ snapshots: space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} + stream-replace-string@2.0.0: {} string-width@4.2.3: @@ -6642,6 +6717,8 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-bom-string@1.0.0: {} + strnum@2.2.3: {} style-to-js@1.1.21: diff --git a/scripts/slides/README.md b/scripts/slides/README.md new file mode 100644 index 00000000..74b243c4 --- /dev/null +++ b/scripts/slides/README.md @@ -0,0 +1,105 @@ +# Slides automation + +Keeps `src/data/slides.json` (the homepage highlights carousel) fresh via a +twice-weekly GitHub Actions job. Canonical data lives in `src/data/slides.json` +and `src/data/slides/`; `public/data/slides/` is gitignored and regenerated at +build by `src/plugins/content-assets.mjs`. Never edit `public/`. + +## Ownership tags + +Every slide entry carries exactly one signal: + +- `"evergreen": true`, pinned. Always kept, text frozen, image never deleted by + the bot. Set this to protect a slide. +- `"sourceArticle": "collection/year/slug"`, bot-managed. Scored from that + article each run; rotated by recency + editorial weight; dropped when it ages + out. `funding-and-projects` refs have two segments (no year), and nothing in + that collection surfaces today because no entry declares a `cover`: the + selector only considers articles that have one. Frontmatter is read straight + off the file with gray-matter rather than through the collection schema, so + adding a `cover` to an entry is all it takes. +- Neither key, treated as evergreen (fail closed) and stamped `evergreen: true` + on the next run, so the tag shows up in that run's PR diff. Should not occur: + the CMS pins the slides it creates, and `slides:validate` rejects an untagged + entry, so only a hand-edit gets here. + +## The bot never guesses + +It acts only where one reading of the file is possible, and stops otherwise. +Every state below is one a human can author but no bot run can produce, so +stopping costs a rotation and resolving one silently costs a slide: + +- an entry carrying both ownership keys (dropping either one unclaims the + article, and the next run puts it on screen a second time) +- two entries naming the same `sourceArticle` (keeping one deletes the other + and its image) +- a `sourceArticle` that is not a ref string +- more pinned slides than `MAX_SLIDES` + +Each halts the run with the offending `src` named and writes nothing. The +workflow reports it on the `slides-bot` issue, and a human resolves it by +editing `slides.json`. `pnpm slides:validate` catches all of them before a +merge, which is why `pr-test.yml` runs it on every PR. + +Bot-created image files are named `--.`, and the bot +only ever deletes unreferenced files matching that shape (`BOT_FILE_RE`). The +collection is in the name for two reasons: a slug is unique only within its +collection (news and events both hold `2025/elixir-industry-engagement-day`), +and it keeps bot names clear of CMS uploads, which are slugified from the alt +text and so can start with a year. `apply()` additionally refuses to copy over +any file a retained slide still points at. + +Only bot-created images are subject to the width, aspect and size gates. +Anything a human put there predates the automation and is grandfathered, which +is why a pinned image is best left under a name the bot cannot generate. + +## CMS interaction + +The `/admin` SlidesEditor seeds its form with `useState({ ...slide })` and edits +only `alt`/`caption`/`src`, so `sourceArticle`/`evergreen` survive both editing +and reordering. Ownership keys are preserved end to end; no action required. + +Carousel order survives too. Everything the bot retains keeps the position it +had, so an arrangement made with the up/down buttons stands, and new slides are +appended after it. + +## Layout + +Four files, and `README.md`: + +- `slides.js`, the whole pipeline. Sections run in dependency order (constants, + article reading, ranking, acceptance rules, collection, selection, captions, + apply, refresh) and everything is a pure function of its arguments except the + clearly marked file writes in `apply` and the `spawnSync` in the caption agent. +- `slides.test.js`, the suite, sectioned to match. +- `slides.AGENTS.md`, the caption agent's rules. Loaded via the `instructions` + key below, not by filename: opencode only auto-discovers a file called + exactly `AGENTS.md`. +- `opencode.json`, model plus a tool allowlist that denies everything. The agent + gets JSON in and returns JSON out; it cannot read, write, or run anything. + +## Operator commands + +- `pnpm slides:collect`, print the ranked candidate pool + current state (dry). + Its `rejected` list names every article that has a cover but cannot be used, + and why. Check it first when an article you expected on the homepage never + appears: a phone photo straight off a camera fails on both size and aspect. +- `pnpm slides:refresh`, run the full pipeline locally (writes files). +- `pnpm slides:validate`, run the sanity gate against the working tree. +- `pnpm slides:test`, the unit suite. +- `bash scripts/manage-slides.sh`, interactive manual editor (unchanged). + +Each maps to `node scripts/slides/slides.js `; `refresh` and `validate` +also take `--diff-scope` to assert nothing outside `src/data/slides*` changed. + +The GitHub workflow `.github/workflows/refresh-highlights.yml` runs the pipeline +on cron (Mon 07:00 UTC, Fri 15:00 UTC) and on manual dispatch, then opens a PR +from `bot/slides-refresh-` for a human to merge. On any hard failure it +opens/updates one `slides-bot`-labelled issue and opens no PR. + +Only one refresh PR is open at a time: opening a new one closes any older one. +Each run recomputes the whole carousel against main as it stands then, so an +older PR is a competing answer rather than an earlier instalment, and merging +both can restore a slide the newer run dropped. Review promptly or the work is +thrown away; the closed PR still offers a Restore branch button if you need it +back. diff --git a/scripts/slides/opencode.json b/scripts/slides/opencode.json new file mode 100644 index 00000000..d7b46519 --- /dev/null +++ b/scripts/slides/opencode.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "{env:SLIDES_AGENT_MODEL}", + "instructions": ["slides.AGENTS.md"], + "tools": { + "write": false, + "edit": false, + "bash": false, + "read": false, + "glob": false, + "grep": false, + "webfetch": false, + "task": false + } +} diff --git a/scripts/slides/slides.AGENTS.md b/scripts/slides/slides.AGENTS.md new file mode 100644 index 00000000..534f6b38 --- /dev/null +++ b/scripts/slides/slides.AGENTS.md @@ -0,0 +1,39 @@ +# Slides caption agent + +You write short captions and alt text for homepage highlight slides of ELIXIR +Norway, the Norwegian node of the European life-science data infrastructure. + +## Input + +A JSON object `{ "slides": [ { "id", "title", "summary" } ] }`. Each entry is a +new slide that needs text. + +## Output, follow exactly + +Return **only** a single JSON array, no prose, no code fences: + +``` +[ { "id": "", "alt": "", "caption": "" } ] +``` + +One object per input slide, same `id`. + +## Rules (hard) + +1. Output is one JSON array in the exact schema above. No fences, no commentary. +2. Use only the provided `id` values. Never invent slides, ids, images, or paths. +3. Derive all wording solely from that slide's `title` and `summary`. Do not add + outside facts, numbers, dates, or claims. +4. Every proper noun you write, whether a person, city, country, organisation or + project, must appear verbatim in that slide's `title` or `summary`. A single + invented place name is the easiest mistake to make here and the hardest to + catch, so when in doubt leave the name out and describe the thing generically. +5. Plain text only, no HTML, markdown, emoji, backticks, or line breaks. + `caption` ≤ 280 characters, `alt` ≤ 125 characters. +6. You never receive the image, only `title` and `summary`. So `alt` names the + slide's subject in a few words drawn from the `title`, and asserts nothing + about what the picture shows. Do not use "photo", "image", "picture" or + "shown" at all unless the word is already in the title or summary. Never + copy the caption. +7. Neutral institutional English. No superlatives, marketing, or speculation. +8. Keep Norwegian characters (Å, å, Ø, ø, Æ, æ) intact. diff --git a/scripts/slides/slides.js b/scripts/slides/slides.js new file mode 100644 index 00000000..690d43ec --- /dev/null +++ b/scripts/slides/slides.js @@ -0,0 +1,816 @@ +// The slides pipeline, whole. Sections run in dependency order: constants, +// article reading, ranking, the acceptance rules every producer checks itself +// against, candidate collection, selection, captions, apply, and the refresh +// that drives them. `slides.js ` is the only entry. +import fs from 'node:fs'; +import path from 'node:path'; +import {execFileSync, spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import matter from 'gray-matter'; + +// ======================================================================== +// Constants +// ======================================================================== + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +export const SLIDES_JSON = path.join(REPO_ROOT, 'src/data/slides.json'); +export const SLIDES_DIR = path.join(REPO_ROOT, 'src/data/slides'); +export const CONTENT_DIR = path.join(REPO_ROOT, 'src/content'); + +export const COLLECTIONS = ['news', 'events', 'funding-and-projects']; + +export const MAX_SLIDES = 6; +export const MIN_SLIDES = 1; +export const CANDIDATE_POOL = 12; +export const HYSTERESIS_MARGIN = 0.15; // fraction of score an incumbent gets as a stay bonus +export const MAX_SWAPS = 2; + +export const MAX_CAPTION = 280; +export const MAX_ALT = 125; +export const MIN_IMG_WIDTH = 800; +export const MAX_IMG_BYTES = 3_000_000; +export const MIN_ASPECT = 0.9; // width/height must be >= this (landscape-ish) + +// Control characters plus the three that break MDX/JSX or shell-quote a caption. +export const ILLEGAL_TEXT_RE = /[\x00-\x1f<>`]/; + +export const SRC_RE = /^\/data\/slides\/[a-z0-9-]+\.(png|jpe?g|webp)$/; + +// Bot-created images are `--.`. The collection is +// part of the name because a slug is only unique within its collection: news +// and events both hold `2025/elixir-industry-engagement-day`. +export const BOT_FILE_RE = + new RegExp(`^(?:${COLLECTIONS.join('|')})-\\d{4}-[a-z0-9-]+\\.(?:png|jpe?g|webp)$`); + +// Editorial weighting: matched against lowercased `${title} ${summary} ${tags}`. +export const FLAGSHIP_TOPICS = [ + {re: /\ball hands\b|all-hands/, weight: 1.0}, + {re: /\bgdi\b|genomic data infrastructure/, weight: 0.9}, + {re: /\bfega\b|federated ega/, weight: 0.9}, + {re: /\beosc\b/, weight: 0.8}, + {re: /1\+ ?million genomes|1\+mg|genome of europe|\bgoe\b/, weight: 0.8}, + {re: /infrastructure|hackathon|workshop/, weight: 0.5}, + {re: /training|course|webinar/, weight: 0.4}, +]; +export const DEMOTE_TOPICS = [ + {re: /scheduled maintenance|maintenance window|downtime/, weight: -1.0}, + {re: /job vacancy|call for|deadline reminder/, weight: -0.4}, +]; + +export const NEWS_HALFLIFE_DAYS = 120; // news/funding recency half-life +export const EVENT_DECAY_DAYS = 21; // events die ~this fast after their date + +// ======================================================================== +// Dates +// ======================================================================== + +const MONTHS = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +// Article dates are free-text English "Month D, YYYY" (full or abbreviated +// month, optional trailing period on the abbreviation). Returns a UTC-midnight +// Date, or null if the string does not match this exact shape. +export function parseArticleDate(str) { + if (typeof str !== 'string') return null; + const m = str.trim().match(/^([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})$/); + if (!m) return null; + const month = MONTHS[m[1].slice(0, 3).toLowerCase()]; + if (month === undefined) return null; + const day = Number(m[2]); + const year = Number(m[3]); + if (day < 1 || day > 31) return null; + const d = new Date(Date.UTC(year, month, day)); + if (d.getUTCMonth() !== month || d.getUTCDate() !== day) return null; // reject e.g. Feb 30 + return d; +} + +// ======================================================================== +// Image probe +// ======================================================================== + +function readPng(buf) { + const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (buf.length < 24 || !sig.every((b, i) => buf[i] === b)) return null; + return {format: 'png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20)}; +} + +function readJpeg(buf) { + if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null; + let o = 2; + while (o + 9 < buf.length) { + if (buf[o] !== 0xff) return null; + const marker = buf[o + 1]; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {o += 2; continue;} + const len = buf.readUInt16BE(o + 2); + const isSOF = marker >= 0xc0 && marker <= 0xcf && + marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; + if (isSOF) return {format: 'jpeg', height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7)}; + o += 2 + len; + } + return null; +} + +function readWebp(buf) { + if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || + buf.toString('ascii', 8, 12) !== 'WEBP') return null; + const chunk = buf.toString('ascii', 12, 16); + if (chunk === 'VP8 ') { + return {format: 'webp', width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff}; + } + if (chunk === 'VP8L') { + const b = buf.subarray(21); + return { + format: 'webp', + width: 1 + (((b[1] & 0x3f) << 8) | b[0]), + height: 1 + (((b[3] & 0x0f) << 10) | (b[2] << 2) | ((b[1] & 0xc0) >> 6)), + }; + } + if (chunk === 'VP8X') { + return { + format: 'webp', + width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)), + height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)), + }; + } + return null; +} + +// Reads image dimensions from the file header without any native dependency. +// Throws if the file is missing, empty, or not a valid PNG/JPEG/WebP. +export function probeImage(absPath) { + const buf = fs.readFileSync(absPath); + if (buf.length === 0) throw new Error(`empty file: ${absPath}`); + const r = readPng(buf) || readJpeg(buf) || readWebp(buf); + if (!r || !r.width || !r.height) throw new Error(`unrecognized or corrupt image: ${absPath}`); + return {...r, bytes: buf.length}; +} + +// ======================================================================== +// Frontmatter +// ======================================================================== + +function findEntryDirs(root, rel, out) { + const abs = path.join(root, rel); + const entries = fs.readdirSync(abs, {withFileTypes: true}); + if (entries.some(e => e.isFile() && /^index\.mdx?$/i.test(e.name))) { + out.push(rel); + return; + } + for (const e of entries) { + if (e.isDirectory()) findEntryDirs(root, path.join(rel, e.name), out); + } +} + +function readArticle(collection, ref) { + const dir = path.join(CONTENT_DIR, ref); + const file = ['index.mdx', 'index.md'].map(f => path.join(dir, f)).find(fs.existsSync); + if (!file) return null; + const {data} = matter(fs.readFileSync(file, 'utf8')); + const parts = ref.split('/'); + const slug = parts[parts.length - 1]; + const date = parseArticleDate(data.date); + + let coverAbsPath = null, coverExt = null; + if (data.cover?.source) { + const p = path.join(dir, String(data.cover.source).replace(/^\.\//, '')); + if (fs.existsSync(p)) { + coverAbsPath = p; + coverExt = path.extname(p).slice(1).toLowerCase(); + } + } + + return { + ref, collection, slug, + year: date ? date.getUTCFullYear() : (Number(parts[1]) || null), + title: data.title ?? slug, + summary: data.summary ?? '', + tags: Array.isArray(data.tags) ? data.tags : [], + date, coverAbsPath, coverExt, + }; +} + +export function listArticles() { + const out = []; + for (const collection of COLLECTIONS) { + const collRoot = path.join(CONTENT_DIR, collection); + if (!fs.existsSync(collRoot)) continue; + const dirs = []; + for (const child of fs.readdirSync(collRoot, {withFileTypes: true})) { + if (child.isDirectory()) findEntryDirs(CONTENT_DIR, path.join(collection, child.name), dirs); + } + for (const rel of dirs) { + const a = readArticle(collection, rel); + if (a) out.push(a); + } + } + return out; +} + +export function resolveArticle(ref) { + const collection = ref.split('/')[0]; + if (!COLLECTIONS.includes(collection)) return null; + if (!fs.existsSync(path.join(CONTENT_DIR, ref))) return null; + return readArticle(collection, ref); +} + +export function withCover(articles) { + return articles.filter(a => a.coverAbsPath); +} + +// ======================================================================== +// Ranking +// ======================================================================== + +const DAY = 86_400_000; + +function haystack(a) { + return `${a.title} ${a.summary} ${(a.tags || []).join(' ')}`.toLowerCase(); +} + +export function topicsOf(a) { + const h = haystack(a); + return FLAGSHIP_TOPICS.filter(t => t.re.test(h)).map(t => t.re.source); +} + +function editorial(a) { + const h = haystack(a); + let w = 0; + for (const t of FLAGSHIP_TOPICS) if (t.re.test(h)) w = Math.max(w, t.weight); + for (const t of DEMOTE_TOPICS) if (t.re.test(h)) w += t.weight; + return w; +} + +function recency(a, now) { + if (!a.date) return 0.2; // dateless (e.g. some funding) rely on editorial weight + const ageDays = (now - a.date) / DAY; + if (a.collection === 'events') { + if (ageDays < 0) { + // upcoming: rises as the date approaches, capped + return Math.min(1, 1 - Math.min(1, -ageDays / 90)); + } + return Math.exp(-ageDays / EVENT_DECAY_DAYS); // dies fast after the date + } + if (ageDays < 0) return 1; // future-dated news treated as brand new + return Math.pow(0.5, ageDays / NEWS_HALFLIFE_DAYS); +} + +// Combined score: recency/lifecycle weighted, plus editorial topic weight. +export function scoreArticle(a, now) { + return recency(a, now) + 0.6 * editorial(a); +} + +export function rankCandidates(articles, now) { + const scored = articles + .filter(a => a.coverAbsPath) + .map(a => ({...a, score: scoreArticle(a, now), topics: topicsOf(a)})) + .sort((x, y) => + y.score - x.score || + (y.date?.getTime() || 0) - (x.date?.getTime() || 0) || + x.slug.localeCompare(y.slug)); + + const topicCount = new Map(); + const kept = []; + for (const a of scored) { + const primary = a.topics[0]; + if (primary) { + const n = topicCount.get(primary) || 0; + if (n >= 2) continue; // anti-repeat floor + topicCount.set(primary, n + 1); + } + kept.push(a); + if (kept.length >= CANDIDATE_POOL) break; + } + return kept; +} + +// ======================================================================== +// Acceptance rules and the validation gate +// ======================================================================== + +const EXT_FORMAT = {png: 'png', jpg: 'jpeg', jpeg: 'jpeg', webp: 'webp'}; + +export const extensionMatches = (img, ext) => EXT_FORMAT[ext] === img.format; + +// The acceptance rules live here so producers can check themselves against the +// same predicate the gate enforces. `collect-candidates` screens covers with +// imageQualityIssues, `caption-agent` screens model output with textIssues; if +// either drifted from the gate the pipeline would pick work it then rejects. +export function imageQualityIssues({width, height, bytes}) { + const issues = []; + if (width < MIN_IMG_WIDTH) issues.push(`width ${width} < ${MIN_IMG_WIDTH}`); + if (width / height < MIN_ASPECT) issues.push(`not landscape (${width}x${height})`); + if (bytes > MAX_IMG_BYTES) issues.push(`file too large (${bytes} > ${MAX_IMG_BYTES})`); + return issues; +} + +export function textIssues(alt, caption) { + const issues = []; + for (const [field, val, max] of [['caption', caption, MAX_CAPTION], ['alt', alt, MAX_ALT]]) { + if (typeof val !== 'string' || !val.trim()) {issues.push(`${field} empty`); continue;} + if (val.length > max) issues.push(`${field} too long (${val.length} > ${max})`); + if (ILLEGAL_TEXT_RE.test(val)) issues.push(`${field} has illegal characters`); + } + if (typeof alt === 'string' && alt.trim() === (caption || '').trim()) issues.push('alt equals caption'); + return issues; +} + +export function validateSlides(slides, {slidesDir = SLIDES_DIR} = {}) { + const v = []; + if (!Array.isArray(slides)) return ['slides.json is not an array']; + if (slides.length < MIN_SLIDES || slides.length > MAX_SLIDES) + v.push(`slide count ${slides.length} outside ${MIN_SLIDES}..${MAX_SLIDES}`); + + const seen = new Set(); + const seenRefs = new Set(); + for (const [i, s] of slides.entries()) { + const at = `slide[${i}]`; + if (!s || typeof s !== 'object') {v.push(`${at} is not an object`); continue;} + if (!SRC_RE.test(s.src || '')) {v.push(`${at} src invalid: ${s.src}`); continue;} + if (seen.has(s.src)) v.push(`${at} duplicate src: ${s.src}`); + seen.add(s.src); + + // Mirrors what selectSlides halts on, so a human PR cannot land a state + // that would stop the bot on its next run. + if (s.evergreen === true && s.sourceArticle) v.push(`${at} has both evergreen and sourceArticle`); + else if (!(s.evergreen === true) && !s.sourceArticle) v.push(`${at} untracked (no evergreen/sourceArticle)`); + else if (s.sourceArticle && typeof s.sourceArticle !== 'string') v.push(`${at} sourceArticle is not a string`); + else if (s.sourceArticle) { + if (seenRefs.has(s.sourceArticle)) v.push(`${at} duplicate sourceArticle: ${s.sourceArticle}`); + seenRefs.add(s.sourceArticle); + } + + for (const issue of textIssues(s.alt, s.caption)) v.push(`${at} ${issue}`); + + const abs = path.join(slidesDir, path.basename(s.src)); + if (!fs.existsSync(abs)) {v.push(`${at} image missing: ${abs}`); continue;} + try { + const img = probeImage(abs); + const ext = path.extname(abs).slice(1).toLowerCase(); + if (!extensionMatches(img, ext)) v.push(`${at} format ${img.format} != extension .${ext}`); + // Quality gates apply only to bot-created images (BOT_FILE_RE). + // Legacy/human pins predate the automation and are grandfathered. + if (BOT_FILE_RE.test(path.basename(abs))) + for (const issue of imageQualityIssues(img)) v.push(`${at} ${issue}`); + } catch (e) { + v.push(`${at} image probe failed: ${e.message}`); + } + } + return v; +} + +export function diffScopeViolations() { + // `git status`, not `git diff`, which cannot see untracked files: a stray + // temp file written outside the slides paths is exactly what this guards. + // -z because porcelain otherwise C-quotes any path holding a space or a + // non-ASCII byte, and a quoted path matches no prefix here. + const out = execFileSync('git', ['status', '--porcelain', '-z', '--untracked-files=all'], {encoding: 'utf8'}); + return out.split('\0').map(s => s.slice(3).trim()).filter(Boolean) + .filter(p => p !== 'src/data/slides.json' && !p.startsWith('src/data/slides/')) + .map(p => `out-of-scope change: ${p}`); +} + +// ======================================================================== +// Candidate collection +// ======================================================================== + +export function readCurrent() { + return JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); +} + +// A fresh candidate's cover becomes a bot-created slide image, so it must pass +// the same quality gates the validator enforces on bot images. Filtering here +// keeps selection from ever picking an unusable cover (e.g. a raw portrait phone +// photo), which would otherwise abort every run. Incumbents are unaffected: +// their image was already copied and validated when the slide was created. +export function usableCover(a) { + if (!a.coverAbsPath) return false; + try { + const img = probeImage(a.coverAbsPath); + return extensionMatches(img, a.coverExt) && !imageQualityIssues(img).length; + } catch { + return false; + } +} + +// The captions an article would get if the agent is off or rejected must +// themselves pass the gate. Without this an article whose summary repeats its +// title yields alt === caption, which fails validation after every apply. +// Why an article with a cover still cannot be used. Editors hit this by +// uploading a photo straight off a camera, and a silent rejection reads as the +// bot ignoring them, so `collect` reports these rather than dropping them. +export function candidateIssues(a) { + if (!a.coverAbsPath) return ['no cover in frontmatter']; + // Checked before probing so an SVG logo reports as unsupported rather than + // as a corrupt raster, which sends the reader hunting for a broken file. + if (!EXT_FORMAT[a.coverExt]) return [`unsupported cover format .${a.coverExt}`]; + let img; + try { + img = probeImage(a.coverAbsPath); + } catch (e) { + return [`cover unreadable: ${e.message}`]; + } + const issues = []; + if (!extensionMatches(img, a.coverExt)) issues.push(`format ${img.format} != extension .${a.coverExt}`); + issues.push(...imageQualityIssues(img)); + // fallbackText would otherwise caption it with the title. Typed rather than + // truthy: YAML yields a number for an unquoted `summary: 2024`, and the bot + // runs before the build that would reject it. + if (typeof a.summary !== 'string' || !a.summary.trim()) { + issues.push('no summary'); + } else { + const {alt, caption} = fallbackText(a); + issues.push(...textIssues(alt, caption)); + } + return issues; +} + +export function usableCandidate(a) { + return !candidateIssues(a).length; +} + +function toCandidate(a) { + return { + id: a.ref, ref: a.ref, collection: a.collection, year: a.year, slug: a.slug, + title: a.title, summary: a.summary, + date: a.date ? a.date.toISOString() : null, + coverAbsPath: a.coverAbsPath, coverExt: a.coverExt, + topics: a.topics ?? topicsOf(a), score: a.score, + }; +} + +export function collect(now = new Date(), {current = readCurrent()} = {}) { + const ranked = rankCandidates(withCover(listArticles()).filter(usableCandidate), now); + const byRef = new Map(ranked.map(a => [a.ref, a])); + for (const s of current) { + if (s.sourceArticle && !byRef.has(s.sourceArticle)) { + const a = resolveArticle(s.sourceArticle); + if (a && a.coverAbsPath) byRef.set(a.ref, {...a, score: scoreArticle(a, now), topics: topicsOf(a)}); + } + } + const rejected = withCover(listArticles()) + .filter(a => !byRef.has(a.ref)) + .map(a => ({ref: a.ref, issues: candidateIssues(a)})) + .filter(r => r.issues.length); + return {current, candidates: [...byRef.values()].map(toCandidate), rejected}; +} + +// ======================================================================== +// Selection +// ======================================================================== + +const botFilename = c => `${c.collection}-${c.year ?? '0000'}-${c.slug}.${c.coverExt}`; +const botSrc = c => `/data/slides/${botFilename(c)}`; +// Ownership keys are part of the comparison: a run whose only effect is +// stamping an untracked entry `evergreen` must still be reported as changed, +// or the tag is never persisted and the entry stays untracked forever. +const pick = s => ({ + src: s.src, alt: s.alt ?? null, caption: s.caption ?? null, + evergreen: s.evergreen === true, sourceArticle: s.sourceArticle ?? null, +}); +const sameSeq = (a, b) => + JSON.stringify(a.map(pick)) === JSON.stringify(b.map(pick)); + +export function selectSlides({current, candidates}) { + const byRef = new Map(candidates.map(c => [c.ref, c])); + const scoreOf = ref => byRef.get(ref)?.score ?? 0; + + // Evergreen pins AND untracked entries (e.g. a slide freshly added via the + // CMS, which has no ownership key yet) are retained in place. Untracked ones + // are stamped `evergreen: true` so they are protected and self-heal their + // tag, never dropped. This is the spec's fail-closed rule. + const halt = reason => ({slides: current, changed: false, budget: 0, dropped: 0, blocked: reason}); + + const notAnEntry = current.findIndex(s => !s || typeof s !== 'object'); + if (notAnEntry !== -1) return halt(`slide[${notAnEntry}] is not an object`); + + // Which key wins is a guess either way, and guessing defers the problem: + // dropping sourceArticle unclaims the ref, so the next run picks the same + // article up again and shows it twice. + const ambiguous = current.find(s => s.evergreen === true && s.sourceArticle); + if (ambiguous) return halt(`${ambiguous.src} carries both evergreen and sourceArticle; remove one`); + + const malformed = current.find(s => s.sourceArticle && typeof s.sourceArticle !== 'string'); + if (malformed) return halt(`${malformed.src} has a non-string sourceArticle`); + + const pinned = current.filter(s => s.evergreen === true || !s.sourceArticle); + const evergreens = pinned.map(s => (s.evergreen === true ? s : {...s, evergreen: true})); + if (evergreens.length > MAX_SLIDES) + return halt(`${evergreens.length} pinned slides exceed the ${MAX_SLIDES} slot limit; ` + + `unpin one of ${evergreens.map(s => s.src).join(', ')}`); + const budget = MAX_SLIDES - evergreens.length; + + const botIncumbents = current.filter(s => s.sourceArticle); + const claimedRefs = new Set(botIncumbents.map(s => s.sourceArticle)); + // Keeping one of two slides that name the same article means deleting the + // other and its image, on a guess. Same unanswerable question as a dual-key + // entry, so it gets the same answer. + if (claimedRefs.size < botIncumbents.length) { + const dupe = botIncumbents.find((s, i) => botIncumbents.findIndex(o => o.sourceArticle === s.sourceArticle) < i); + const pair = botIncumbents.filter(s => s.sourceArticle === dupe.sourceArticle).map(s => s.src); + return halt(`${pair.join(' and ')} both name ${dupe.sourceArticle}; remove one`); + } + + // One slide per file: a generated filename already taken by a pin, or by a + // higher-scored candidate this same run, disqualifies the candidate. + const claimedSrcs = new Set(current.map(s => s.src)); + const fresh = []; + for (const c of candidates) { + if (claimedRefs.has(c.ref) || claimedSrcs.has(botSrc(c))) continue; + claimedSrcs.add(botSrc(c)); + fresh.push(c); + } + + const eff = (ref, isInc) => scoreOf(ref) * (isInc ? 1 + HYSTERESIS_MARGIN : 1); + const pool = [ + ...botIncumbents.map(s => ({ref: s.sourceArticle, isInc: true, entry: s})), + ...fresh.map(c => ({ref: c.ref, isInc: false, cand: c})), + ].sort((x, y) => + eff(y.ref, y.isInc) - eff(x.ref, x.isInc) || + (y.isInc === x.isInc ? 0 : y.isInc ? 1 : -1) || + x.ref.localeCompare(y.ref)); + + let chosen = pool.slice(0, budget); + + // Swap cap: at most MAX_SWAPS fresh refs enter per run; backfill from + // remaining incumbents if we blocked some. + const freshChosen = chosen.filter(p => !p.isInc); + if (freshChosen.length > MAX_SWAPS) { + const allowed = new Set(freshChosen.slice(0, MAX_SWAPS).map(p => p.ref)); + chosen = chosen.filter(p => p.isInc || allowed.has(p.ref)); + const spare = pool.filter(p => p.isInc && !chosen.includes(p)); + while (chosen.length < budget && spare.length) chosen.push(spare.shift()); + chosen = chosen.slice(0, budget); + } + + // Matched by identity, not by ref: two entries can share a sourceArticle. + const survivors = botIncumbents.filter(s => chosen.some(p => p.entry === s)); + const news = chosen + .filter(p => !p.isInc) + .map(p => ({ + src: botSrc(p.cand), + alt: null, caption: null, sourceArticle: p.cand.ref, _candidate: p.cand, + })); + + // Everything retained keeps the position it already had, pins included. The + // CMS offers up/down reordering, and hoisting pins to the front would undo + // an editor's arrangement twice a week, in a PR that does nothing else. + const replacement = new Map(pinned.map((s, i) => [s, evergreens[i]])); + for (const s of survivors) replacement.set(s, s); + const slides = [...current.filter(s => replacement.has(s)).map(s => replacement.get(s)), ...news]; + return { + slides, changed: !sameSeq(current, slides), budget, + dropped: botIncumbents.length - survivors.length, + }; +} + +// ======================================================================== +// Captions +// ======================================================================== + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +export function clamp(str, n) { + const s = String(str ?? '').replace(/\s+/g, ' ').trim(); + return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + '…'; +} + +export function fallbackText(cand) { + const caption = clamp(cand.summary || cand.title, MAX_CAPTION); + return {alt: clamp(cand.title, MAX_ALT), caption}; +} + +export function properNounsOk(text, cand) { + const src = `${cand.title} ${cand.summary}`; + const runs = text.match(/[A-ZÅØÆ][\wÅØÆåøæ.'-]+(?:\s+[A-ZÅØÆ][\wÅØÆåøæ.'-]+)+/g) || []; + return runs.every(r => src.includes(r)); +} + +// The agent is sent title and summary, never the image, so any alt asserting +// what the picture shows is unfounded by construction. Allowed only when the +// source itself uses the word, as an article about a photo competition would. +const IMAGE_CLAIM_RE = /\b(photo|photograph|picture|image|pictured|depicts?|depicted|shown)\w*/i; + +export function imageClaimOk(alt, cand) { + const claim = String(alt || '').match(IMAGE_CLAIM_RE); + if (!claim) return true; + return `${cand.title} ${cand.summary}`.toLowerCase().includes(claim[0].toLowerCase()); +} + +export function validAgentText(alt, caption, cand) { + if (textIssues(alt, caption).length) return false; + if (!imageClaimOk(alt, cand)) return false; + return properNounsOk(caption, cand) && properNounsOk(alt, cand); +} + +const parseArray = s => { + try { + const v = JSON.parse(s); + return Array.isArray(v) ? v : null; + } catch { + return null; + } +}; + +export function extractJsonArray(text) { + const t = String(text || '').trim(); + if (!t) return null; + const whole = parseArray(t); + if (whole) return whole; + + // Balanced scan rather than first `[` to last `]`: a model preamble often + // carries a stray bracket, and that greedy span then parses as nothing. + for (let i = 0; i < t.length; i++) { + if (t[i] !== '[') continue; + let depth = 0, inString = false, escaped = false; + for (let j = i; j < t.length; j++) { + const ch = t[j]; + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === '[') depth++; + else if (ch === ']' && --depth === 0) { + const v = parseArray(t.slice(i, j + 1)); + if (v) return v; + break; + } + } + } + return null; +} + +export function defaultRunAgent(inputJson) { + const model = process.env.SLIDES_AGENT_MODEL; + if (!model || process.env.SLIDES_AGENT === 'off') return Promise.resolve(''); + // State the task, not just the format. "Return only the JSON array" alone + // reads as "echo the array you were given", and small models do exactly that. + const prompt = 'Write alt and caption for every slide below, following your ' + + `rules. Return only the JSON array of {id, alt, caption}.\n${inputJson}`; + const r = spawnSync('opencode', ['run', '--model', model, prompt], + {cwd: HERE, encoding: 'utf8', timeout: 120_000, maxBuffer: 4 << 20}); + return Promise.resolve(r.status === 0 ? (r.stdout || '') : ''); +} + +export async function writeCaptions(slides, {runAgent = defaultRunAgent} = {}) { + const news = slides.filter(s => s._candidate && (s.alt == null || s.caption == null)); + if (!news.length) return slides; + + const input = JSON.stringify({ + slides: news.map(s => ({id: s._candidate.id, title: s._candidate.title, summary: s._candidate.summary})), + }); + + let byId = new Map(); + try { + const arr = extractJsonArray(await runAgent(input)); + if (arr) byId = new Map(arr.map(o => [o.id, o])); + } catch { /* fall back below */ } + + for (const s of news) { + const c = s._candidate; + const a = byId.get(c.id); + // Trim first, then judge what will actually be stored. A trailing + // newline is the commonest thing in model output, and validating the + // raw string threw away otherwise good text for a character we strip. + const alt = typeof a?.alt === 'string' ? a.alt.trim() : a?.alt; + const caption = typeof a?.caption === 'string' ? a.caption.trim() : a?.caption; + if (a && validAgentText(alt, caption, c)) { + s.alt = alt; + s.caption = caption; + } else { + const fb = fallbackText(c); + s.alt = fb.alt; + s.caption = fb.caption; + } + } + return slides; +} + +// ======================================================================== +// Apply +// ======================================================================== + +export function cleanEntry(s) { + const out = {src: s.src, alt: s.alt, caption: s.caption}; + if (s.evergreen === true) out.evergreen = true; + else if (s.sourceArticle) out.sourceArticle = s.sourceArticle; + return out; +} + +export function referencedBasenames(slides) { + return new Set(slides.map(s => path.basename(s.src))); +} + +export function staleBotFiles(existing, referenced) { + return existing.filter(f => BOT_FILE_RE.test(f) && !referenced.has(f)); +} + +export function apply(slides) { + const retained = new Set(slides.filter(s => !s._candidate).map(s => path.basename(s.src))); + for (const s of slides) { + if (s._candidate) { + const name = path.basename(s.src); + if (retained.has(name)) + throw new Error(`refusing to overwrite an image already in use: ${name}`); + retained.add(name); + fs.copyFileSync(s._candidate.coverAbsPath, path.join(SLIDES_DIR, name)); + } + } + const clean = slides.map(cleanEntry); + const referenced = referencedBasenames(clean); + const existing = fs.readdirSync(SLIDES_DIR); + const deleted = staleBotFiles(existing, referenced); + for (const f of deleted) fs.rmSync(path.join(SLIDES_DIR, f)); + + fs.writeFileSync(SLIDES_JSON, JSON.stringify(clean, null, 4) + '\n'); + return {deleted, slides: clean}; +} + +// ======================================================================== +// Refresh +// ======================================================================== + +function setOutput(result) { + const out = process.env.GITHUB_OUTPUT; + if (out) fs.appendFileSync(out, `result=${result}\n`); + console.log(`result=${result}`); +} + +export async function refresh({diffScope = false} = {}) { + const {current, candidates} = collect(new Date()); + const {slides, changed, blocked, budget, dropped} = selectSlides({current, candidates}); + if (blocked) { + console.error(`Cannot refresh: ${blocked}.`); + return 1; + } + if (budget === 0) { + console.warn(dropped + ? `Pins fill every slot; dropping ${dropped} bot slide(s) to make room.` + : 'Every slot is pinned; the bot has nothing to rotate.'); + } + if (!changed) { + // Selection being stable says nothing about the file being sound. A + // deleted image or an emptied caption would otherwise report healthy + // forever, and this job is the thing best placed to notice. + const standing = validateSlides(slides); + if (standing.length) { + console.error('Slides are unchanged but invalid:\n' + standing.map(m => ' - ' + m).join('\n')); + return 1; + } + console.log('No slide changes needed.'); + setOutput('noop'); + return 0; + } + + await writeCaptions(slides); + const {deleted, slides: applied} = apply(slides); + + const violations = validateSlides(applied); + if (diffScope) violations.push(...diffScopeViolations()); + if (violations.length) { + console.error('Validation failed after apply:\n' + violations.map(m => ' - ' + m).join('\n')); + return 1; + } + + console.log(`Applied ${applied.length} slides; deleted ${deleted.length} stale file(s).`); + setOutput('changed'); + return 0; +} + +// ======================================================================== +// CLI +// ======================================================================== + +const COMMANDS = { + collect: () => { + process.stdout.write(JSON.stringify(collect(), null, 2) + '\n'); + return 0; + }, + refresh: () => refresh({diffScope: process.argv.includes('--diff-scope')}), + validate: () => { + const slides = JSON.parse(fs.readFileSync(SLIDES_JSON, 'utf8')); + const v = validateSlides(slides); + if (process.argv.includes('--diff-scope')) v.push(...diffScopeViolations()); + if (v.length) { + console.error('Slide validation failed:\n' + v.map(m => ' - ' + m).join('\n')); + return 1; + } + console.log(`Slides valid (${slides.length}).`); + return 0; + }, +}; + +if (import.meta.url === `file://${process.argv[1]}`) { + const command = COMMANDS[process.argv[2]]; + if (!command) { + console.error(`Usage: slides.js <${Object.keys(COMMANDS).join('|')}> [--diff-scope]`); + process.exit(2); + } + Promise.resolve(command()) + .then(code => process.exit(code)) + .catch(e => {console.error(e); process.exit(1);}); +} diff --git a/scripts/slides/slides.test.js b/scripts/slides/slides.test.js new file mode 100644 index 00000000..5c786575 --- /dev/null +++ b/scripts/slides/slides.test.js @@ -0,0 +1,529 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {test} from 'node:test'; +import { + cleanEntry, collect, extractJsonArray, fallbackText, imageClaimOk, listArticles, parseArticleDate, probeImage, + properNounsOk, rankCandidates, readCurrent, referencedBasenames, resolveArticle, + scoreArticle, selectSlides, SLIDES_DIR, staleBotFiles, usableCandidate, + usableCover, validateSlides, withCover, writeCaptions, +} from './slides.js'; + +// ======================================================================== +// dates +// ======================================================================== + +test('parses full and abbreviated English month dates', () => { + assert.equal(parseArticleDate('September 17, 2025').toISOString(), '2025-09-17T00:00:00.000Z'); + assert.equal(parseArticleDate('Apr 16, 2026').toISOString(), '2026-04-16T00:00:00.000Z'); + assert.equal(parseArticleDate('Sept 1, 2024').toISOString(), '2024-09-01T00:00:00.000Z'); +}); + +test('returns null for unparseable input', () => { + assert.equal(parseArticleDate('2025-09-17'), null); + assert.equal(parseArticleDate('someday'), null); + assert.equal(parseArticleDate(''), null); + assert.equal(parseArticleDate(undefined), null); +}); + +// ======================================================================== +// image-probe +// ======================================================================== + +test('reads PNG dimensions and format', () => { + const r = probeImage(path.join(SLIDES_DIR, 'nels.png')); + assert.equal(r.format, 'png'); + assert.ok(r.width > 100 && r.height > 100); + assert.ok(r.bytes > 0); +}); + +test('reads JPEG dimensions', () => { + const r = probeImage(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg')); + assert.equal(r.format, 'jpeg'); + assert.ok(r.width > 100 && r.height > 100); +}); + +test('throws on a non-image', () => { + assert.throws(() => probeImage(path.join(SLIDES_DIR, '..', 'slides.json'))); +}); + +// ======================================================================== +// frontmatter +// ======================================================================== + +test('lists real news articles with parsed fields', () => { + const all = listArticles(); + const eosc = resolveArticle('news/2025/eosc-entrust-workshop'); + assert.ok(eosc, 'eosc-entrust-workshop resolves'); + assert.equal(eosc.title, 'EOSC-ENTRUST workshop hosted by ELIXIR Norway'); + assert.equal(eosc.date.getUTCFullYear(), 2025); + assert.ok(eosc.coverAbsPath.endsWith('.jpeg')); + assert.equal(eosc.coverExt, 'jpeg'); + assert.ok(all.length > 20); +}); + +test('withCover drops articles without a cover image', () => { + const covered = withCover(listArticles()); + assert.ok(covered.every(a => a.coverAbsPath)); +}); + +// ======================================================================== +// rank +// ======================================================================== + +const now = new Date(Date.UTC(2026, 6, 15)); +const mk = (o) => ({collection: 'news', slug: o.slug, title: o.title ?? '', summary: '', tags: [], date: o.date, coverAbsPath: '/x.png', ...o}); + +test('recent flagship news outranks an old routine notice', () => { + const flagship = mk({slug: 'gdi-go-live', title: 'GDI infrastructure go-live', date: new Date(Date.UTC(2026, 6, 1))}); + const routine = mk({slug: 'maint', title: 'Scheduled maintenance window', date: new Date(Date.UTC(2026, 6, 10))}); + assert.ok(scoreArticle(flagship, now) > scoreArticle(routine, now)); +}); + +test('a past event decays below a fresh news item', () => { + const pastEvent = mk({collection: 'events', slug: 'old-workshop', title: 'Workshop', date: new Date(Date.UTC(2026, 4, 1))}); + const freshNews = mk({slug: 'news', title: 'Infrastructure update', date: new Date(Date.UTC(2026, 6, 12))}); + assert.ok(scoreArticle(freshNews, now) > scoreArticle(pastEvent, now)); +}); + +test('anti-repeat caps flagship topic at 2', () => { + const arts = [1, 2, 3, 4].map(i => mk({collection: 'events', slug: `all-hands-${i}`, title: 'ELIXIR All Hands', date: new Date(Date.UTC(2026, 6, i))})); + const ranked = rankCandidates(arts, now); + assert.equal(ranked.filter(a => /all hands/.test(a.title.toLowerCase())).length, 2); +}); + +// ======================================================================== +// validate-slides +// ======================================================================== + +const ok = {src: '/data/slides/nels.png', alt: 'NeLS landing page', caption: 'The Norwegian e-Infrastructure for Life Sciences.', evergreen: true}; + +test('flags empty slide set', () => { + const v = validateSlides([], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /count/i.test(m))); +}); + +test('flags a bad src and a too-long caption', () => { + const v = validateSlides([ + {src: '/data/slides/BAD NAME.png', alt: 'a', caption: 'c', evergreen: true}, + {...ok, caption: 'x'.repeat(400)}, + ], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /src/i.test(m))); + assert.ok(v.some(m => /caption/i.test(m))); +}); + +test('accepts a valid evergreen slide backed by a real image', () => { + const v = validateSlides([ok], {slidesDir: SLIDES_DIR}); + assert.deepEqual(v, []); +}); + +test('grandfathers a large legacy-named evergreen image (quality gates are bot-only)', () => { + const bigLegacy = { + src: '/data/slides/elixir-no-all-hands-2025.jpg', + alt: 'Group photo for ELIXIR Norway All Hands 2025', + caption: "This year's ELIXIR Norway All Hands was organised physically in Ås!", + evergreen: true, + }; + // 3.37MB and a legacy filename (no - prefix) → exempt from size/width/aspect. + assert.deepEqual(validateSlides([bigLegacy], {slidesDir: SLIDES_DIR}), []); +}); + +test('rejects two slides naming the same article', () => { + const v = validateSlides([ + {...ok, evergreen: undefined, sourceArticle: 'news/2026/a'}, + {...ok, src: '/data/slides/rdm-promotion.png', evergreen: undefined, sourceArticle: 'news/2026/a'}, + ], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /duplicate sourceArticle/i.test(m)), v.join('; ')); +}); + +test('rejects a sourceArticle that is not a ref string', () => { + const v = validateSlides([{...ok, evergreen: undefined, sourceArticle: {ref: 'news/2026/a'}}], + {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /sourceArticle/i.test(m)), v.join('; ')); +}); + +test('rejects a slide carrying both ownership tags', () => { + const v = validateSlides([{...ok, sourceArticle: 'news/2026/x'}], {slidesDir: SLIDES_DIR}); + assert.ok(v.some(m => /both/i.test(m)), v.join('; ')); +}); + +test('still enforces quality gates on a bot-named image (guard is not a blanket exemption)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slides-validate-')); + try { + // The 3.37MB image copied under a bot-style name (BOT_FILE_RE matches), + // so the size gate must fire even though the same bytes are exempt under + // the legacy filename. + fs.copyFileSync(path.join(SLIDES_DIR, 'elixir-no-all-hands-2025.jpg'), path.join(dir, 'news-2025-all-hands.jpg')); + const slide = { + src: '/data/slides/news-2025-all-hands.jpg', + alt: 'A group photo', + caption: 'A caption about the meeting.', + sourceArticle: 'news/2025/all-hands', + }; + const violations = validateSlides([slide], {slidesDir: dir}); + assert.ok(violations.some(m => /file too large/.test(m)), violations.join('; ')); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } +}); + +// ======================================================================== +// collect-candidates +// ======================================================================== + +const goodCandidate = { + title: 'A perfectly ordinary headline', + summary: 'A summary that says something else entirely.', + coverAbsPath: path.join(SLIDES_DIR, 'nels.png'), + coverExt: 'png', +}; + +test('collect returns current slides and a ranked candidate pool', () => { + const {current, candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(Array.isArray(current) && current.length >= 1); + assert.ok(candidates.length >= 1 && candidates.length <= 12); + for (const c of candidates) { + assert.equal(c.id, c.ref); + assert.ok(c.coverAbsPath, 'candidate has a cover'); + assert.equal(typeof c.score, 'number'); + } +}); + +test('readCurrent parses slides.json', () => { + assert.ok(Array.isArray(readCurrent())); +}); + +test('usableCover rejects a raw portrait/oversized cover and accepts a good one', { + skip: ['news/2026/elixir-norway-all-hands', 'news/2025/eosc-entrust-workshop'] + .some(ref => !resolveArticle(ref)) && 'fixture articles no longer present', +}, () => { + const badArt = resolveArticle('news/2026/elixir-norway-all-hands'); // 3888x5184, 24.9MB + const goodArt = resolveArticle('news/2025/eosc-entrust-workshop'); // landscape, small + assert.equal(usableCover(badArt), false); + assert.equal(usableCover(goodArt), true); +}); + +test('collect excludes candidates whose cover fails the quality gates', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(!candidates.some(c => c.ref === 'news/2026/elixir-norway-all-hands')); +}); + +test('an incumbent that has aged out of the ranked pool is still scored', () => { + // Hysteresis compares an incumbent against its challengers, so an incumbent + // missing from the pool would score 0 and be dropped the moment it left the + // top slots. Driven from a synthetic current: the committed slides.json has + // no bot-managed entry to exercise this with. + const aged = 'news/2018/fair-data-management-in-molecular-life-sciences'; + const current = [{src: '/data/slides/x.png', alt: 'X', caption: 'c', sourceArticle: aged}]; + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15)), {current}); + const rescued = candidates.find(c => c.ref === aged); + assert.ok(rescued, 'an on-screen article must be scored even when it ranks below the pool'); + assert.equal(typeof rescued.score, 'number'); +}); + +test('usableCandidate accepts a well-formed article', () => { + assert.equal(usableCandidate(goodCandidate), true); +}); + +test('usableCandidate rejects an article whose summary repeats its title', () => { + // The fallback caption is the summary and the fallback alt is the title, so + // an article like this would produce alt === caption and fail the gate. + assert.equal(usableCandidate({...goodCandidate, summary: goodCandidate.title}), false); +}); + +test('usableCandidate rejects an article with no summary even when its title is long', () => { + // A title over MAX_ALT clamps, so alt and caption differ by the ellipsis and + // the alt-equals-caption rule alone would let this through. + assert.equal(usableCandidate({...goodCandidate, title: 'T'.repeat(200), summary: ''}), false); +}); + +test('usableCandidate rejects a non-string summary instead of throwing', () => { + // YAML turns an unquoted `summary: 2024` into a number. The bot runs before + // the build that would reject it, so it must not crash the pipeline. + for (const summary of [2024, true, ['a'], {a: 1}]) + assert.equal(usableCandidate({...goodCandidate, summary}), false); +}); + +test('usableCandidate rejects a cover whose extension disagrees with its bytes', () => { + assert.equal(usableCandidate({...goodCandidate, coverExt: 'jpg'}), false); +}); + +test('no article in the repo would produce a caption identical to its alt', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(candidates.every(c => c.title.trim() !== c.summary.trim())); +}); + +test('every candidate has a non-empty summary (fallback caption needs it)', () => { + const {candidates} = collect(new Date(Date.UTC(2026, 6, 15))); + assert.ok(candidates.length > 0); + assert.ok(candidates.every(c => c.summary && c.summary.trim()), 'no candidate may have an empty summary'); +}); + +// ======================================================================== +// select +// ======================================================================== + +const cand = (ref, slug, score, over = {}) => ({ + id: ref, ref, collection: 'news', year: 2026, slug, + title: slug, summary: 's', date: '2026-07-01T00:00:00.000Z', + coverAbsPath: `/x/${slug}.png`, coverExt: 'png', topics: [], score, ...over, +}); + +test('no-op when only evergreens and budget is full', () => { + const current = [ + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + {src: '/data/slides/rdm.png', alt: 'RDM', caption: 'c', evergreen: true}, + ]; + const {slides, changed} = selectSlides({current, candidates: [cand('news/2026/x', 'x', 0.1)]}); + assert.equal(changed, true); // one free slot gets filled + assert.equal(slides[0].evergreen, true); +}); + +test('caps fresh additions at MAX_SWAPS (2)', () => { + const current = []; + const candidates = ['a', 'b', 'c', 'd'].map((s, i) => cand(`news/2026/${s}`, s, 1 - i * 0.1)); + const {slides} = selectSlides({current, candidates}); + assert.equal(slides.filter(s => s._candidate).length, 2); +}); + +test('unchanged selection reports changed=false', () => { + const current = [{src: '/data/slides/2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}]; + const candidates = [cand('news/2026/a', 'a', 0.9)]; + const {changed} = selectSlides({current, candidates}); + assert.equal(changed, false); +}); + +test('incumbent keeps its slot unless a challenger beats it by the hysteresis margin', () => { + const evergreens = ['a', 'b', 'c', 'd', 'e'].map(s => ({src: `/data/slides/${s}.png`, alt: s.toUpperCase(), caption: 'c', evergreen: true})); + const incumbent = {src: '/data/slides/2026-inc.png', alt: 'Inc', caption: 'c', sourceArticle: 'news/2026/inc'}; + // budget = 6 - 5 evergreens = 1 bot slot. incumbent eff = 0.5 * 1.15 = 0.575. + const near = selectSlides({current: [...evergreens, incumbent], candidates: [cand('news/2026/inc', 'inc', 0.5), cand('news/2026/new', 'new', 0.55)]}); + assert.equal(near.slides.at(-1).sourceArticle, 'news/2026/inc'); // 0.55 < 0.575 -> incumbent stays + const beats = selectSlides({current: [...evergreens, incumbent], candidates: [cand('news/2026/inc', 'inc', 0.5), cand('news/2026/new', 'new', 0.58)]}); + assert.equal(beats.slides.at(-1).sourceArticle, 'news/2026/new'); // 0.58 > 0.575 -> challenger wins +}); + +test('swap cap admits the top 2 fresh and backfills freed slots from displaced incumbents', () => { + const incs = [1, 2, 3, 4, 5].map(i => ({src: `/data/slides/2026-i${i}.png`, alt: `I${i}`, caption: 'c', sourceArticle: `news/2026/i${i}`})); + const incCands = [1, 2, 3, 4, 5].map(i => cand(`news/2026/i${i}`, `i${i}`, 0.5 - i * 0.01)); // i1 highest .49 .. i5 .45 + const fresh = ['a', 'b', 'c', 'd', 'e'].map((s, i) => cand(`news/2026/${s}`, s, 0.9 - i * 0.05)); // a .9 .. e .7 (all outrank incumbents) + const {slides} = selectSlides({current: incs, candidates: [...incCands, ...fresh]}); + const news = slides.filter(s => s._candidate).map(s => s.sourceArticle).sort(); + assert.deepEqual(news, ['news/2026/a', 'news/2026/b']); // only top-2 fresh admitted + const survivors = slides.filter(s => s.sourceArticle && !s._candidate).map(s => s.sourceArticle); + assert.equal(survivors.length, 4); // 4 slots backfilled from incumbents + assert.ok(!survivors.includes('news/2026/i5')); // lowest-scored incumbent dropped +}); + +test('the swap cap keeps the two highest-scored fresh, not any two', () => { + const candidates = ['a', 'b', 'c', 'd'].map((s, i) => cand(`news/2026/${s}`, s, 1 - i * 0.1)); // a highest + const {slides} = selectSlides({current: [], candidates}); + const news = slides.filter(s => s._candidate).map(s => s.sourceArticle).sort(); + assert.deepEqual(news, ['news/2026/a', 'news/2026/b']); // top two by score, not c/d +}); + +test('retains an untracked (CMS-added) current entry and tags it evergreen', () => { + const current = [ + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + {src: '/data/slides/human-added.png', alt: 'Human highlight', caption: 'Added via CMS'}, + ]; + const {slides} = selectSlides({current, candidates: []}); + const human = slides.find(s => s.src === '/data/slides/human-added.png'); + assert.ok(human, 'untracked entry must survive'); + assert.equal(human.evergreen, true, 'untracked entry must be tagged evergreen'); +}); + +test('refuses to act on a dual-key entry instead of silently resolving it', () => { + // Stripping the redundant key would only defer the problem: the ref stops + // being claimed, and the next run picks the article up again as fresh. + const current = [ + {src: '/data/slides/eosc.png', alt: 'EOSC', caption: 'c', evergreen: true, sourceArticle: 'news/2026/a'}, + ]; + const {slides, changed, blocked} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked, 'ambiguous ownership must be reported, not guessed at'); + assert.equal(changed, false); + assert.deepEqual(slides, current); +}); + +test('reports a malformed entry instead of throwing at the operator', () => { + // `[null]` is valid JSON and hand-editable. A stack trace names no slide. + assert.deepEqual(validateSlides([null]), ['slide[0] is not an object']); + assert.match(selectSlides({current: [null], candidates: []}).blocked, /slide\[0\]/); +}); + +test('leaves a human arrangement alone instead of hoisting pins', () => { + // The CMS offers up/down reordering. Rebuilding the order every run would + // undo an editor twice a week, in a PR whose only content is the reversion. + const current = [ + {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + ]; + const {slides, changed} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.deepEqual(slides.map(s => s.src), current.map(s => s.src)); + assert.equal(changed, false, 'a pure reordering must not open a PR'); +}); + +test('refuses to act on a sourceArticle that is not a ref string', () => { + const current = [{src: '/data/slides/x.png', alt: 'X', caption: 'c', sourceArticle: {ref: 'news/2026/a'}}]; + const {blocked, changed} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked, 'a non-string ref must be named, not crash the comparator'); + assert.equal(changed, false); +}); + +test('refuses to act when two incumbents name the same article', () => { + // Which of the two to keep is the same unanswerable question as a dual-key + // entry. Picking one silently deletes the other slide and its image. + const dupes = [ + {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, + {src: '/data/slides/news-2026-a-copy.png', alt: 'A copy', caption: 'c', sourceArticle: 'news/2026/a'}, + ]; + const {slides, changed, blocked} = selectSlides({current: dupes, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked); + // Naming both srcs is the whole value of halting: the operator has to find + // the pair by hand otherwise. + for (const s of dupes) assert.match(blocked, new RegExp(s.src)); + assert.equal(changed, false); + assert.deepEqual(slides, dupes); +}); + +test('two candidates that would generate one filename cannot both be selected', () => { + // Same collection, slug and date-year, different refs: reachable because the + // year comes from the frontmatter date rather than the directory. + const candidates = [ + cand('news/2025/foo', 'foo', 0.9, {year: 2025}), + cand('news/2024/foo', 'foo', 0.8, {year: 2025}), + ]; + const {slides} = selectSlides({current: [], candidates}); + assert.equal(new Set(slides.map(s => s.src)).size, slides.length); + assert.equal(slides.length, 1); +}); + +test('reports bot slides dropped for want of a slot rather than claiming a no-op', () => { + const pins = [1, 2, 3, 4, 5, 6].map(i => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})); + const inc = {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}; + const {budget, dropped} = selectSlides({current: [...pins, inc], candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.equal(budget, 0); + assert.equal(dropped, 1, 'the purge must be visible to the caller'); +}); + +test('filenames stay unique when two collections share a slug and year', () => { + const candidates = [ + cand('news/2025/x', 'x', 0.9, {collection: 'news', year: 2025}), + cand('events/2025/x', 'x', 0.8, {collection: 'events', year: 2025}), + ]; + const {slides} = selectSlides({current: [], candidates}); + assert.equal(new Set(slides.map(s => s.src)).size, 2); +}); + +test('skips a candidate whose generated filename is already claimed by a pin', () => { + const current = [{src: '/data/slides/news-2025-x.png', alt: 'Human pin', caption: 'c', evergreen: true}]; + const {slides} = selectSlides({current, candidates: [cand('news/2025/x', 'x', 0.9, {year: 2025})]}); + assert.equal(slides.length, 1, 'the pin must not be shadowed by a same-named bot slide'); + assert.equal(slides[0].alt, 'Human pin'); +}); + +test('refuses to act when pins alone exceed MAX_SLIDES rather than emitting an invalid set', () => { + const current = [ + ...Array.from({length: 7}, (_, i) => ({src: `/data/slides/p${i}.png`, alt: `P${i}`, caption: 'c', evergreen: true})), + {src: '/data/slides/news-2026-a.png', alt: 'A', caption: 'c', sourceArticle: 'news/2026/a'}, + ]; + const {slides, changed, blocked} = selectSlides({current, candidates: [cand('news/2026/a', 'a', 0.9)]}); + assert.ok(blocked, 'over-pinned state must be reported, not written'); + assert.match(blocked, /\/data\/slides\/p0\.png/, 'the pins to choose between must be named'); + assert.equal(changed, false, 'must not drop the bot slide or write an over-length set'); + assert.equal(slides.length, current.length); +}); + +test('stamping an untracked entry counts as a change so the tag is written back', () => { + const current = [ + {src: '/data/slides/nels.png', alt: 'NeLS', caption: 'c', evergreen: true}, + {src: '/data/slides/human-added.png', alt: 'Human highlight', caption: 'Added via CMS'}, + ]; + // src/alt/caption are all identical to current; only the new evergreen tag + // differs. Reporting no-op here would strand the entry untracked forever. + const {changed} = selectSlides({current, candidates: []}); + assert.equal(changed, true); +}); + +// ======================================================================== +// caption-agent +// ======================================================================== + +const newSlide = (id, title, summary) => ({ + src: `/data/slides/2026-${id}.png`, alt: null, caption: null, + sourceArticle: `news/2026/${id}`, + _candidate: {id: `news/2026/${id}`, title, summary}, +}); + +test('falls back to summary/title when the agent returns nothing', async () => { + const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; + const out = await writeCaptions(s, {runAgent: async () => ''}); + assert.equal(out[0].alt, 'GDI go-live'); + assert.equal(out[0].caption, 'ELIXIR Norway deploys GDI infrastructure.'); +}); + +test('uses valid agent text', async () => { + const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; + const agent = async () => JSON.stringify([{id: 'news/2026/x', alt: 'A network diagram', caption: 'ELIXIR Norway deploys GDI infrastructure across Europe.'}]); + const out = await writeCaptions(s, {runAgent: agent}); + assert.equal(out[0].alt, 'A network diagram'); +}); + +test('rejects hallucinated proper nouns', () => { + assert.equal(properNounsOk('Written by Jane Doe', {title: 'GDI', summary: 'about gdi'}), false); + assert.equal(properNounsOk('About the GDI project', {title: 'GDI project', summary: 'the GDI project'}), true); +}); + +test('rejects alt that claims to describe the picture', () => { + // The agent is never sent the image, so "Group photo of ..." is invented + // whatever the article is. It stands only when the source uses the word. + const cand = {title: 'GDI Node Hackathon', summary: 'Developers met in Lapland.'}; + assert.equal(imageClaimOk('Group photo from the GDI Node Hackathon', cand), false); + assert.equal(imageClaimOk('Participants shown at the hackathon', cand), false); + assert.equal(imageClaimOk('GDI Node Hackathon in Lapland', cand), true); + assert.equal( + imageClaimOk('Winning photo', {title: 'Photo competition', summary: 'Our photo competition.'}), + true, 'a source that talks about photos may say photo'); +}); + +test('accepts agent text that only needs trimming', async () => { + // A trailing newline is the commonest artifact in model output. Judging the + // untrimmed string threw away good text over a character we strip anyway. + const s = [newSlide('x', 'GDI go-live', 'ELIXIR Norway deploys GDI infrastructure.')]; + const agent = async () => JSON.stringify([{id: 'news/2026/x', alt: 'A network diagram\n', caption: 'ELIXIR Norway deploys GDI infrastructure.\n'}]); + const out = await writeCaptions(s, {runAgent: agent}); + assert.equal(out[0].alt, 'A network diagram'); + assert.equal(out[0].caption, 'ELIXIR Norway deploys GDI infrastructure.'); +}); + +test('finds the array even when the model wrote a bracket before it', () => { + // First-`[`-to-last-`]` spans the preamble too and parses as nothing, which + // silently fell back for the whole batch. + assert.deepEqual(extractJsonArray('Here [is] your answer: [{"id":"x"}]'), [{id: 'x'}]); + assert.deepEqual(extractJsonArray('```json\n[{"id":"x"}]\n```'), [{id: 'x'}]); + assert.deepEqual(extractJsonArray('{"slides":[{"id":"x"}]}'), [{id: 'x'}]); + assert.deepEqual(extractJsonArray('note: [{"id":"a]b"}]'), [{id: 'a]b'}]); + assert.equal(extractJsonArray('no array here [nope'), null); +}); + +// ======================================================================== +// apply-slides +// ======================================================================== + +test('cleanEntry strips transient fields', () => { + const e = cleanEntry({src: '/data/slides/2026-x.png', alt: 'A', caption: 'C', sourceArticle: 'news/2026/x', _candidate: {}, _new: true}); + assert.deepEqual(e, {src: '/data/slides/2026-x.png', alt: 'A', caption: 'C', sourceArticle: 'news/2026/x'}); +}); + +test('staleBotFiles only targets bot-named unreferenced files', () => { + const referenced = referencedBasenames([{src: '/data/slides/news-2026-keep.png'}, {src: '/data/slides/nels.png'}]); + const existing = ['news-2026-keep.png', 'events-2025-drop.jpeg', 'nels.png', 'rdm-promotion.png']; + assert.deepEqual(staleBotFiles(existing, referenced), ['events-2025-drop.jpeg']); +}); + +test('staleBotFiles spares a CMS upload that merely starts with a year', () => { + // The CMS names uploads by slugifying the alt text, so "2025 All Hands" + // becomes 2025-all-hands.png. That must never look bot-owned. + assert.deepEqual(staleBotFiles(['2025-all-hands.png'], new Set()), []); +}); diff --git a/src/components/admin/SlidesEditor.tsx b/src/components/admin/SlidesEditor.tsx index 6ff7c70e..02fc693b 100644 --- a/src/components/admin/SlidesEditor.tsx +++ b/src/components/admin/SlidesEditor.tsx @@ -148,8 +148,10 @@ export default function SlidesEditor({ token, username, branchOverride, onBack } } if (editing !== null) { - const slide = editing === 'new' - ? { src: '', alt: '', caption: '' } + const slide: Slide = editing === 'new' + // Pinned on creation: a slide added here is a human's choice, and an + // entry with no ownership key fails validation on the PR it opens. + ? { src: '', alt: '', caption: '', evergreen: true } : slides[editing]; return ( diff --git a/src/components/admin/schema.ts b/src/components/admin/schema.ts index d99b893a..517f9f86 100644 --- a/src/components/admin/schema.ts +++ b/src/components/admin/schema.ts @@ -193,6 +193,10 @@ export interface Slide { src: string; alt: string; caption?: string; + // Exactly one of these. `evergreen` pins a slide; `sourceArticle` marks one + // the refresh job owns. An entry carrying neither fails slide validation. + evergreen?: true; + sourceArticle?: string; } export const ELIXIR_GROUPS = [ diff --git a/src/data/slides.json b/src/data/slides.json index 58f57c84..e3606be8 100644 --- a/src/data/slides.json +++ b/src/data/slides.json @@ -1,27 +1,20 @@ [ - { - "src": "/data/slides/eosc-entrust.png", - "alt": "EOSC-ENTRUST", - "caption": "Pål Sætrom and Miikka Kallberg co-led the 2nd TRE Evaluation Workshop, bringing together 30 stakeholders to advance the TRE Blueprint and strengthen Trusted Research Environments across Europe. Organizers included Ingeborg Winge, Christine Stansberg, and Stefanie Kirschenmann." - }, - { - "src": "/data/slides/elixir-no-all-hands-2025.jpg", - "alt": "Group photo for ELIXIR Norway All Hands 2025", - "caption": "This year's ELIXIR Norway All Hands was organised physically in Ås!" - }, { "src": "/data/slides/nels.png", "alt": "NeLS Landing Page", - "caption": "NeLS, the Norwegian e-Infrastructure for Life Sciences, for data analysis, sharing and storage" + "caption": "NeLS, the Norwegian e-Infrastructure for Life Sciences, for data analysis, sharing and storage", + "evergreen": true }, { "src": "/data/slides/genomic-data-infrastructure.png", "alt": "Genomic Data Infrastructure (GDI)", - "caption": "ELIXIR Norway is deploying GDI infrastructure to go live by 2026 with existing datasets and Genome of Europe (GoE) reference data, enabling federated discovery and analysis across 27+ European countries." + "caption": "ELIXIR Norway is deploying GDI infrastructure to go live by 2026 with existing datasets and Genome of Europe (GoE) reference data, enabling federated discovery and analysis across 27+ European countries.", + "evergreen": true }, { "src": "/data/slides/rdm-promotion.png", "alt": "RDMkit", - "caption": "The ELIXIR RDMkit: research data management made simple" + "caption": "The ELIXIR RDMkit: research data management made simple", + "evergreen": true } ]