Stories — the long-form text on artwork and artist pages
Current process (owner, 2026-10-04): no paid AI API. Stories are written in Claude sessions, English and Vietnamese first, other languages later; all text is stored in D1 (
storiestable). The Workers AI pipeline below is kept but switched off (STORIES_PER_RUN = 0).
node scripts/stories/sources.mjs --limit 40— fetches the next items to write (public/v1/seo/story-queue) with their Wikipedia/Wikidata sources into.story-src/(gitignored).- Write
content/stories/{artwork|artist}/{slug}/{en,vi}.json—{hook, sections[{key, body}], facts, sources}.node scripts/stories/check.mjs— validates (sections, 70–260 words each, sources, no stray CJK) and adds the fixed headings. Also runs in CI.- Push to
main→ CI writes the changed stories into D1 and marks them storied; the next daily publish makes them public. A page in a language without a story shows the English one.
Owner's direction (2026-10-04): every painting needs a detailed, emotional text — the history, the artist's intent, what to look at, the stories around it; every artist page needs the life story and origins.
What a story contains
| Artwork | Artist |
|---|---|
| Hook (1–2 sentences) | Hook |
| The story behind the painting | Origins (birthplace, family, training) |
| What to look for (from the image itself) | Life and turning points |
| Meaning and intent | Style and technique |
| Legacy | Stories and anecdotes |
| "Did you know?" (2–4 sourced facts) | Legacy |
| Sources (links) + an honest AI note | Sources + AI note |
Each section is 90–170 words → ~500–800 words per page, in 6 languages. Section headings are fixed translations (HEADINGS in generate.ts), never model output.
How it is made (grounded, not invented)
- Sources: the painting's Wikipedia article (via its Wikidata sitelinks, en → fr → de → es), the artist's article, Wikidata facts (year, size, museum, movement, subjects).
- The image itself goes to a vision model (
@cf/meta/llama-4-scout-17b-16e-instruct), so "What to look for" describes what is really on the canvas. - Rules in the prompt: only facts from the sources or visible in the image; no guessed places, sitters, dates or quotes; if sources are thin, say so once and stay with what is visible.
- Translation:
@cf/meta/llama-3.3-70b-instruct-fp8-fastwith a per-language style guide and glossary (e.g. vi: cypress = cây bách, still life = tranh tĩnh vật), using the painting's own localized title. - Quality gate:
normalizeStory()rejects missing sections or too-short text (tested); one immediate retry, then up to 3 attempts across cron runs.
Measured: ~45 s per painting for all 6 languages; Van Gogh's The Starry Night came back with the asylum at Saint-Rémy (June 1889), the east-facing window, the imagined village and MoMA since 1941 — all from the cited article. An obscure Renoir without a Wikipedia article came back honest ("details about its creation are scarce") and focused on the visible painting.
Where it lives
- Text in D1, table
stories (kind, slug, locale, data JSON)(migration 0009). Stories first written by the old Workers AI cron sat in R2; the cron copies them into D1 for free (dropping any language with stray CJK characters). - Progress in D1:
artworks.story_status,artists.story_status(none/done/failed),story_attempts. - Artist portrait (
artists.image_url, Wikidata P18) and artist links (artist_links: influenced by / teacher / student) are captured while writing the artist story.
Pipeline
- Cron every 10 minutes (
runStoryPass):STORIES_PER_RUN(6) artworks + 1 artist. Order: published works without a story → works of public artists that still have fewer than 10 → queued works by fame. ≈ 860 stories/day, ahead of publishing. - Publishing is gated: only artworks with
story_status = 'done'are published. - ≥ 10 works per artist: an artist becomes public only with ≥
MIN_ARTIST_WORKS(10) published works; a new artist enters with a block of its 10 best storied works; public artists below 10 are topped up first.
Related artists ("Paintings by related artists")
relatedArtists() in catalog/routes.ts: Wikidata links in both directions (influenced by / inspired, teacher / student), then the artists sharing the most works in the same movement. The artwork page shows the two best works of each, labelled with the relation.
Cost (Workers AI, list prices)
≈ $0.01 per painting for the story in 6 languages → ≈ $3/day at 300/day; the full 277k catalog ≈ $2,500 over the year. Artists ≈ $0.015 each.
Admin
POST /v1/admin/seo/stories with {"artwork": "slug"} or {"artist": "slug"} writes one now; empty body runs a pass. GET /v1/admin/seo/stats shows story progress.