Jump to content

Echoes of What Wasn't

From Yusupov's House
Revision as of 11:03, 12 April 2026 by Mvuijlst (talk | contribs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Infobox
nameEchoes of What Wasn't
urlhttps://echoes.yusupov.cloud
developerMichel Vuijlsteke
released2025
genreAI-generated alternate-history newspaper
languagePython
frameworkDjango 5.2 / Wagtail 7.3
licenseProprietary

Echoes of What Wasn't (also known as Echoes) is a web application hosted at echoes.yusupov.cloud that publishes AI-generated alternate-history newspaper and magazine articles. Each article takes a real historical event, invents a plausible point of divergence, and presents the consequences as an in-world retrospective written decades later by a fictitious journalist for a fictitious period-appropriate publication. The site's tagline is "Dispatches from Histories That Never Were."

Technology stack

The application is built on Django 5.2 with Wagtail 7.3 as its content management system.[1] It uses Django REST framework for a JSON API, SQLite as its database, and is deployed behind Nginx with Gunicorn on a Linux server. Additional dependencies include Pillow for image processing, nh3 for HTML sanitisation, Beautiful Soup and lxml for scraping, and the OpenAI Python client for language-model and image-generation calls. Geographic features use wagtailgeowidget with Google Maps integration.

Data model

Article pages

Each article is a Wagtail ArticlePage, a child of a single ArticleIndexPage in the page tree. An article carries:

  • title, subtitle, publication (the fictitious newspaper/magazine name), date (in-universe publication date), event_date (the historical event's date), location, and a foreign key to an Author snippet (exposed via the API as byline).
  • A body_richtext field (Wagtail RichTextField) containing the article's HTML body, sanitised on ingest.
  • A body StreamField supporting paragraph, heading, callout, quote, pull-quote, bulleted list, numbered list, image, aside (with its own nested stream), and horizontal-rule block types.
  • Separate callouts and quotes StreamFields for editorial pull-outs and attributed quotations.
  • A JSON assets field listing image metadata (src, alt, caption, credit, prompt).
  • A featured_image foreign key to a custom image model (CustomImage, extending Wagtail's AbstractImage with a description field).
  • Internal editorial fields: original_event, departure_point, and ai_context, which are visible in the Wagtail admin but not rendered on the public site.
  • Geographic fields: event_location (address string), geo_location (WKT point), latitude, and longitude. The model's save() method synchronises the WKT point and decimal-degree fields bidirectionally.

Historical events

The HistoricalEvent model stores real events scraped from Wikipedia, keyed by ISO date, astronomical year, event text, language code, and a boolean used flag.

Custom images

CustomImage and CustomRendition extend Wagtail's abstract image models to add a description text field, used to store the original DALL·E prompt.

Article generation pipeline

Article generation is performed by the standalone script _generate_article.py, which orchestrates a six-step pipeline of OpenAI API calls, checkpoint-resumed so that a failure at any step does not waste prior work. The default text model is GPT-5 (with GPT-4o as fallback); the image model is gpt-image-1.

Step 0: Event sourcing

A separate scraper (_scrape_wikipedia_events.py) fetches every day-of-year page from Wikipedia (e.g. "January 1", "February 14") for English, French, and Dutch editions. It parses the "Events" sections, extracts each entry's year and text, and stores them as HistoricalEvent rows. The scraper respects rate limits and identifies itself via a custom User-Agent string.

Step 1: Event selection

The pipeline queries the database for unused Common Era events whose month and day match the current date and whose year is at least 50 years in the past. Five candidates are sampled at random and presented in a structured prompt to the text model, which acts as an "editorial planner." The model returns JSON selecting the single most promising event for an alternate-history feature, together with a proposed divergence date, publication date, publication context (name, type, city, editorial stance, intended readership), article angle, and a list of visual possibilities. The pipeline validates that the chosen event ID was in the candidate set.

Step 2: Timeline brief

The selection JSON is fed into a second prompt that asks the model to build an "internal dossier" — a compact alternate-history world brief. This includes:

  • A core timeline premise.
  • A chronological historical_path of at least five entries spanning from divergence to publication date.
  • Canonical facts to preserve (real-world events outside the divergence's causal chain).
  • "Real-history traps to avoid" — events whose preconditions are disrupted by the divergence.
  • In-world assumptions (what ordinary readers take for granted).
  • Named entities (people, institutions, treaties, technologies) specific to this timeline.
  • An article brief (genre, tone, writer persona, central question, thesis, section ideas).
  • Style constraints (bans on meta-framing, "not X but Y" constructions, poetic codas, excessive em-dashes and exclamation marks).
  • A visual brief for the photo editor.

This step enforces a "proportional divergence rule": the timeline should change only what the divergence actually changes, and leave causally unrelated real-world events intact.

Step 3: Article generation

The selection and timeline-brief JSONs are combined in a third prompt which instructs the model to write the full article as a single JSON object. The article must:

  • Be written entirely from inside the alternate timeline, with no meta-framing or comparison to real history.
  • Be at least 1,500 words across its body_blocks.
  • Include at least two callout blocks and one attributed quote block, interleaved naturally.
  • Use period-appropriate prose matching the in-world date, place, and publication culture.
  • Be entirely in the language of the source event (English, French, or Dutch).
  • Include a hero-image prompt (in English) and geographic coordinates.

The model is asked to self-check for in-world consistency, language correctness, and structural validity before outputting. If the returned JSON is malformed, a repair prompt asks the model to fix it.

Step 4: In-world edit

The draft JSON is passed through a fourth "line editor" prompt. This step:

  • Preserves meaning, canon, and timeline integrity.
  • Removes AI-flavored rhetoric ("not X, but Y" constructions, pseudo-poetic endings, inflated abstraction, repetitive thesis phrasing).
  • Replaces any accidental references to real-world events that should not exist in the diverged timeline.
  • Forces the canonical event and publication dates from the selection step.
  • Populates the internal original_event, departure_point, and ai_context fields.

The pipeline then validates the edited payload: checking required fields, enforcing the ban on meta-themed publication names, verifying date consistency with the selection, confirming minimum callout and quote counts, and ensuring no quote is attributed to the article's own author.

Step 5: Image prompts

A fifth prompt, addressed to a "photo editor and visual archivist," generates one to five image concepts. Each concept specifies:

  • A role (hero or supporting).
  • An aspect ratio (landscape, portrait, or square).
  • A detailed English-language image prompt.
  • Alt text, caption, and credit in the article language.

The prompt includes extensive rules for period-appropriate visual media (daguerreotype for 1840–1880, silver gelatin for 1880–1930, Kodachrome for 1970–2000, and so on), and bans on text in images, symmetrical compositions, "dramatic lighting," and modern digital-art aesthetics. The returned concepts are normalised: roles are deduplicated, aspect ratios are validated, and the set is capped at five images.

Step 6: Image generation and upload

Each image prompt is wrapped in additional instructions enforcing candid documentary realism (or authentic non-photographic media for paintings, engravings, and archival objects) before being sent to OpenAI's image-generation endpoint (gpt-image-1). Generated images are decoded from base64, converted to optimised progressive JPEG via Pillow, and uploaded through the application's /api/media endpoint. The hero image is set as the article's featured_image. Non-hero images are inserted into the body_blocks array at evenly spaced positions.

Step 7: Publication

The assembled JSON payload — with body blocks, assets, featured image, byline, callouts, quotes, and editorial metadata — is POSTed to the /api/articles endpoint. The API serializer sanitises body HTML via nh3, resolves image file paths to Wagtail embed tags, creates or looks up the Author snippet, attaches the article as a child of the ArticleIndexPage, and publishes it. The source HistoricalEvent is marked as used. The checkpoint file is cleared.

REST API

The application exposes a JSON API under /api/, protected by bearer-token authentication for write operations and publicly readable:

  • GET/POST /api/articles — list (paginated) or create articles.
  • GET/PUT /api/articles/<id> — retrieve or update a single article.
  • POST /api/media — upload a base64-encoded image, which is stored as a CustomImage.
  • GET /api/markers — return geographic markers for all geolocated articles.
  • GET /api/schema — serve the OpenAPI specification.

Public documentation is available at /docs (ReDoc) and /swagger (Swagger UI).

Public interface

Home page

The ArticleIndexPage serves as the site's home page, displaying up to seven recent articles, a "picture desk" mosaic of up to fifteen images (sampled hourly for variety), a map teaser with up to four geolocated articles, and navigation links for monthly archives.

Article pages

Each article page features a full-bleed hero image (when available), a masthead with the fictitious publication name, headline, subtitle, byline, dateline, and the event date. The body renders the StreamField blocks: paragraphs, headings, images with captions and credits, callouts, attributed quotes, pull quotes, bulleted and numbered lists, asides with nested content, and horizontal rules.

Where/When

An interactive "Where & When" page presents all geolocated articles on a Leaflet map (using CartoDB tiles with light/dark themes) alongside a zoomable timeline. Articles are plotted by their event location and date. The map and article data are loaded via the /api/markers endpoint.

Monthly archives

Articles can be browsed by the month of their in-universe event date. The index page dynamically builds archive links with article counts per month.

Theme

The site supports light and dark colour themes, toggled via a button in the masthead and persisted in localStorage. Typography uses Playfair Display for display headings, Source Serif 4 for body text, and Inter for UI elements.

Multilingual support

The Wikipedia scraper and article-generation pipeline support English, French, and Dutch. The language of each historical event is stored in the database and carried through to the generation prompts, which instruct the model to write all visible article content — title, subtitle, body, captions, quotes, and credits — in the source event's language. Internal editorial fields and image prompts remain in English.

See also

References

  1. requirements.txt in the project repository lists Django 5.2.12 and Wagtail 7.3.1.