Jump to content

A Cabinet of Brief Curiosities

From Yusupov's House
Revision as of 14:50, 23 April 2026 by Mvuijlst (talk | contribs) (Created page with "{{Infobox | 01_name = A Cabinet of Brief Curiosities | 02_url = https://acbc.yusupov.cloud | 03_developer = Michel Vuijlsteke | 04_released = 2025 | 05_genre = AI-generated short-fiction application | 06_language = Python | 07_framework = Flask 3.0 | 08_license = MIT }} '''A Cabinet of Brief Curiosities''' (abbreviated '''acbc''') is a web application hosted at <code>acbc.yusupov.cloud</code> that generates illustrated thre...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Infobox
nameA Cabinet of Brief Curiosities
urlhttps://acbc.yusupov.cloud
developerMichel Vuijlsteke
released2025
genreAI-generated short-fiction application
languagePython
frameworkFlask 3.0
licenseMIT

A Cabinet of Brief Curiosities (abbreviated acbc) is a web application hosted at acbc.yusupov.cloud that generates illustrated three-sentence short stories in the style of H. P. Lovecraft. Each story is composed by a large language model from a structured set of randomised "knobs" and optional user-supplied seed words, and is paired with a black-and-white illustration designed to resemble a 19th-century engraved book plate. The site's rotating tagline — drawn every thirty minutes from a pool of forty-five variants — frames the act of generation in deliberately archaic terms; the canonical opening reads "Dredge slivers of impossible worlds from the black gulfs of imagination and press them into trembling mortal words."

Technology stack

The application is built on Flask 3.0 with SQLite as its database, accessed through SQLAlchemy (Flask-SQLAlchemy 3.1).[1] Authentication is handled by Flask-Login. It is deployed on an Ubuntu VPS behind Nginx with Gunicorn running over a Unix socket under a dedicated django system user, supervised by systemd. Additional dependencies include the OpenAI Python client for both text and image generation, Pillow for image post-processing, python-dotenv for environment configuration, and httpx as the underlying HTTP transport. The front end is rendered from Jinja templates using a small Bootstrap-derived stylesheet (static/style.css) and is registered as an installable progressive web app via static/manifest.json and a service worker (static/sw.js).

Data model

The database contains two tables.

User

User stores a single bootstrapped administrator account with an e-mail address, a Werkzeug password hash, and a creation timestamp. Sign-ups are disabled at the route level: the /signup endpoint flashes a notice and redirects to login. The administrator is created on application start from the ADMIN_EMAIL and ADMIN_PASSWORD environment variables if no matching row exists.

Story

Story stores the generated short fiction:

  • id (primary key), an optional user_id foreign key (null for guest submissions), the three-sentence story_text, the optional mood, nouns and verbs seeds supplied by the requester, an optional image_path (relative to the static folder, e.g. images/story_42.png), the originating ip_address (indexed), and an indexed created_at timestamp.

Story generation pipeline

Story generation is initiated by a POST to /generate and proceeds through several deterministic-and-random stages before the OpenAI call.

Seeds and knobs

A request may carry up to three optional free-text fields — noun, verb, and mood — which are passed to the model as "seeds" and validated for presence in the output. Around the seeds, the application constructs a compact JSON knobs object that nudges the model along several axes:

Dimension Pool size Examples
Perspective 4 first, second, third, omniscient
Structure 8 Discovery → Investigation → Revelation; Object → Rumor → Catastrophe; Signal → Interpretation → Realization; …
Time 8 (forced ambiguous) Victorian era, distant past, mythic period, interwar, near-future of obsolete technology
Location 50 lighthouse, salt marsh, foundry, signal box, scriptorium, observatory, shipbreaker's yard, …
Situation 32 during a storm, at low tide, during a blackout, on the eve of demolition, while the clock refuses to strike, …
Lexical palette 53 nautical, horological, astronomical, archival, cartographic, mycological, glaciological, heraldic, …
Style constraint 5 include exactly one short line of dialogue; include a question; avoid the words "shadow" and "dim"; …
Sentence pattern 3 short → long → medium; medium → short → long; long → medium → short
Theme (only when no seeds) 18 forbidden knowledge, cosmic entities, inherited curses, mathematical theorems, astronomical observations, …

The knobs object always carries the seeds and at most three additional dimensions. Three of the knobs — perspective, structure, and time — are selected deterministically from a SHA-256 hash of a salt composed of the current 30-minute time bucket, the requester's IP address, and the seed words; this guarantees that the same client requesting the same seeds inside the same half-hour window draws the same priority knobs. The previous story's knobs for that IP are read from the choices log and avoided where possible. Time is hard-pinned to ambiguous to suppress dated or era-specific references in the output. Lexicon and constraint values are sampled with the regular pseudo-random generator. Once all priority knobs are forced into the object, any non-priority extras are dropped at random until at most three non-seed knobs remain.

System rules

The system prompt instructs the model to produce a coherent short story of exactly three sentences in the style of H. P. Lovecraft, with no titles, lists, numbering, or blank lines. The tone must be grave, ominous, and unsettling, and never humorous. The model is forbidden to use the words "eldritch", "cyclopean", or "loathsome", and is forbidden to begin any line with one of six banned openings — "In the", "Beneath", "Under the", "Within the", "In the dim", and "In the shadow". Whimsical seeds are to be adapted with synonyms that preserve tone.

Model selection

The default text model is GPT-4o (configurable via OPENAI_MODEL) with a configurable fallback (OPENAI_FALLBACK_MODEL, also GPT-4o by default). A helper function inspects the installed OpenAI SDK at request time: if the configured model is in the gpt-5 family but the SDK does not expose the Responses API, the call silently falls back to a chat-compatible model. When the Responses API is available it is preferred for all models, with the system rules supplied via the explicit instructions field. For gpt-5 family models the call additionally injects a reasoning object (defaulting to effort: low but configurable via OPENAI_REASONING_EFFORT), drops the temperature and top_p parameters (which those models reject), and grants a higher max_output_tokens budget (1600 by default, raised to 2400 on retry) to absorb reasoning-token consumption. For non-reasoning models, temperature is sampled uniformly from [0.88, 1.05] and top_p is fixed at 0.92. Where the Responses API is unavailable, the call degrades to Chat Completions.

Validation and retry

After the call returns, the output is validated by a lightweight checker that enforces:

  • Exactly three non-empty lines.
  • No line beginning with any of the banned opening prefixes.
  • Absence of the banned words.
  • One terminal punctuation mark per line (no missing terminator, no more than two strong delimiters).

If the request supplied seeds, a second pass verifies that the noun appears in the text and that the verb (or a simple inflection thereof) appears as a whole word.

When validation fails, a single targeted retry is issued with a corrective prompt that names the specific failures, lowers the temperature slightly, and reuses the same knobs JSON. The retry result is accepted if it passes, or if it fails with strictly fewer issues than the original. If the model call itself fails — including the specific case where a gpt-5 response is marked incomplete due to max_output_tokens — the pipeline retries once with a higher token budget, then attempts the configured fallback model, and finally falls back to a fixed three-sentence placeholder ("The moon borrowed a suitcase from a bewildered pigeon. …"). All failures surface to the user as Flask flash messages with appropriate severity.

Persistence and logging

The validated story text is written to a new Story row together with the seeds and originating IP. Image generation is then dispatched in a background daemon thread so the HTTP response returns immediately. Two structured logs are appended in JSON-Lines format under the Flask instance folder:

  • instance/choices.log.jsonl records, per story, the seeds, the knobs JSON, the selected temperature and top_p, the model actually used, the configured model, and the validation outcome (including reasons and whether a retry was attempted).
  • instance/image.log.jsonl records each image generation attempt with status (success, retry, failed, skipped), error text, attempt count, image size, and image model.

Image generation

Each story is paired with a square 1024×1024 illustration generated through the OpenAI image API.

Style cycling

The application maintains a counter file at instance/image_style_cycle.txt that walks deterministically through a library of fifteen monochrome engraving styles, including Victorian wood engraving, antique grimoire pen-and-ink, penny-dreadful frontispiece, steel engraving, copperplate etching, drypoint, mezzotint, aquatint-grained etching, scratchboard, Victorian scientific plate, woodcut, scanned 1860s book plate, fin-de-siècle symbolism, and Victorian reportage sketch. A threading lock guards the read-modify-write of the counter so that concurrent generations do not collide on the same style. If the counter file is missing or unreadable, a random style is chosen instead.

Prompt construction

The image prompt is assembled from four blocks:

  1. A composition directive specifying a square 1:1 aspect ratio, full-bleed framing with no borders or margins, and a centred subject.
  2. The selected style sentence from the cycling library.
  3. A "scanned 1860s book plate" line that asks for slight ink unevenness and faint paper texture.
  4. The story text itself, truncated to 900 characters with an ellipsis if longer.
  5. A trailing "Avoid:" clause that explicitly forbids colour, grayscale wash, painterly shading, photorealism, 3D rendering, borders, frames, mats, vignettes, modern comic or anime style, halftone dots, captions, readable text, watermarks, and signatures.

Image API and retries

The configured image model defaults to gpt-image-1; common typos such as gpt-image-1.5 are normalised back to gpt-image-1 at start-up. The output size is fixed at 1024×1024. Each generation tolerates up to three attempts (configurable via IMAGE_RETRIES) with exponential backoff starting at 1.5 seconds and capped at 10 seconds. If all attempts fail, a tiny placeholder GIF is written to disk under the name story_<id>_placeholder.gif and recorded as the story's image path so the front end always has something to display.

Post-processing

Successfully returned PNG bytes are passed through a Pillow-based post-processor that:

  • Estimates the background colour from the four corner patches.
  • Computes a difference image against a uniform background of that colour, raises its contrast, and finds the bounding box of meaningful content.
  • Crops away any uniform border of more than ten pixels on any side, padded by two pixels to avoid clipping ink.
  • Resizes the result back to 1024×1024 using Lanczos resampling and re-encodes as optimised PNG.

If Pillow is unavailable or any step fails, the original bytes are written through unchanged.

Public interface

Home

The home page (/) renders the generation form together with a gallery of the five most recent stories that successfully produced an image. For unauthenticated visitors, the form is hidden and replaced by a rotating notice (one of twenty-two phrasings, selected from a SHA-256 hash of the visitor's IP and a six-hour bucket) when the visitor has already generated a story in the last 24 hours or when the site-wide guest cap has been reached. A footer-level statistics block displays the number of guest stories in the last 24 hours, the number of stories created by signed-in users in the same window, the all-time total, and the time elapsed since the most recent story.

Story detail

Each story is reachable at /story/<id> and is publicly viewable regardless of authorship. The page presents the three sentences alongside the illustration; for the administrator, controls are exposed to regenerate the image (/regenerate-image/<id>) or delete the story (/story/<id>/delete). A polling endpoint at /api/story/<id>/status returns a JSON document indicating whether a real image has yet been written, the URL of the image (or placeholder), and a flag distinguishing the two; the front end uses this to swap a placeholder GIF for the final image once background generation completes.

Archive

The archive (/archive) is a paginated chronological listing of all stories, nine per page, with previous/next navigation. There is no per-user filter — all stories are visible.

Authentication

/login accepts only the e-mail address configured in ADMIN_EMAIL and verifies the password against the stored Werkzeug hash. /logout ends the session. /signup is intentionally disabled.

Tagline rotation

The base template injects a "current tagline" string into every response. The selection is deterministic on the current 30-minute interval since the Unix epoch: the interval timestamp is used as a seed for the standard library random module, which then picks one of forty-five variant phrasings. All visitors served within the same half-hour see the same tagline; the tagline rotates without any database state.

Rate limiting

Rate limiting is enforced for unauthenticated visitors only:

  • Per-IP daily limit: any IP that has produced a story within the last 24 hours is blocked from generating another (one story per visitor per day).
  • Site-wide guest cap: the total number of guest-authored stories in the last 24 hours must not exceed GUEST_DAILY_CAP (default 24). When the cap is reached, the form is hidden for all guests until older stories age out.

Both limits are evaluated at form render time (to hide the form) and at form submission time (to short-circuit the POST with a flashed warning and a redirect). Authenticated users have no rate limits and no per-IP enforcement.

The originating IP for limit accounting and for the Story.ip_address column is read from X-Forwarded-For when the request carries it (taking the first comma-separated value), and from request.remote_addr otherwise.

The application reads its configuration from environment variables loaded via python-dotenv:

Deployment

The production deployment is an Ubuntu VPS running a systemd unit (acbc.service) that launches Gunicorn with three workers, bound to a Unix domain socket under the project directory. Nginx terminates HTTPS (provisioned by Let's Encrypt via Certbot), serves the static/ directory directly with a one-year immutable cache header, and proxies all other requests to the Gunicorn socket. The service runs as the django system user out of /home/django/acbc.

Security and authorisation

  • Sign-ups are disabled; only the bootstrapped administrator account can authenticate.
  • Login attempts for any e-mail other than ADMIN_EMAIL are rejected without a database query.
  • The image-deletion helper refuses to remove any path that does not begin with images/ or that, after canonicalisation, falls outside the configured upload folder, providing protection against path-traversal in stored data.
  • Story deletion requires either the administrator session or ownership of the row; image regeneration requires the administrator session.
  • The story-status JSON endpoint returns no user-identifying information beyond the public fields rendered on the story page.

See also

References

  1. requirements.txt in the project repository pins Flask 3.0.3, Flask-Login 0.6.3, Flask-SQLAlchemy 3.1.1, python-dotenv 1.0.1, openai ≥1.50, httpx 0.27.2, and Pillow ≥10.