signal-bot/signal_bot/art_command.py
Krzysztof kuhy Rudnicki 08599e6056 feat: two commands that answer with somebody else's work
!sztuka posts a work of art, skewed 60% Polish; !wiersz posts one short
public-domain Polish poem, whole. Both follow the rule the rest of the bot
already keeps: the repository commits identifiers, and the words arrive over
the wire when somebody asks.

!wiersz was written the other way first, and the gate caught it. A pack of
short public-domain poems was harvested, name-filtered and committed, and
check_personal_data.sh refused it: three lines carried a real group member's
given name, and two still did after a filter that had discarded 31% of the
corpus. That is structural, not a tuning problem -- Polish verse capitalises
every line, so a line-initial capital carries no signal, and that is exactly
where a name sits. The pack now holds Wolne Lektury slugs; the poem, its
title and its author are fetched at post time and appear nowhere in git.
It is also the better version: with no name filter there are no false
positives, so the pool is larger and the attribution is a full name again.

The gate then refused the art category list too, over a painter whose given
name is somebody's. That is the popes.json case exactly, so it takes the
same answer: a file-scoped allowlist entry, still refused everywhere else.

The Commons harvest is now three modules by what they are, not by where the
line count fell: commons_api is the conversation with Commons, commons_harvest
turns categories into a pool of licensed page ids, commons_cache makes an
interrupted run resumable. Along the way it gained licences in the same
request that lists a category (the second pass was 1,500 requests at 2.3s
each), maxlag instead of a guessed sleep, 5xx retries because one 504 used to
destroy a whole run, and Wikidata for works by creators of Polish
citizenship: the Polish pool grew 15,221 -> 20,092.

Batching categories through CirrusSearch looked like a further 6x and was
measured out again: 2,404 files where the authoritative enumeration returns
2,429, a different ~1% missing on every run.

The offline run is 19s where it was four minutes, almost all of it the
deafness gate waiting out three thirty-second liveness probes to prove
something provable in three; the tick is configuration now, and e2e enforces
a 40s budget on itself so that never silently comes back.
2026-08-31 21:32:07 +02:00

138 lines
5.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The !sztuka command: one work of art, and what it is.
A near-copy of !pułapka, deliberately: same committed pool of Commons page
ids, same single imageinfo request, same live-fetched caption, same absence
of a cache. What ``traps_command`` says about each of those holds here.
The one thing that is new is the skew. The pool is two lists rather than one,
and ``POLISH_SHARE`` per cent of posts come from the Polish one **whatever
the two sizes are** -- the pool is chosen before the work is. That is worth
saying out loud because the other reading is defensible and this is not it:
if the Polish pool holds two thousand works and the world pool twenty
thousand, an individual Polish work is drawn about ten times as often as an
individual foreign one. Skewing the output is the entire point; a merged pool
weighted by size would produce a command that is 90% foreign and technically
correct.
Nothing is cached, for the reason ``traps_command`` gives: a uniform draw
from tens of thousands warms a cache essentially never, so the disk would
fill with pictures nobody asks for twice.
"""
from __future__ import annotations
import json
import secrets
from base64 import b64encode
from pathlib import Path
from typing import TYPE_CHECKING, Final
from signal_bot.animals import load_aliases
from signal_bot.commons import caption_for, imageinfo_params, page_of
from signal_bot.endpoints import THUMB_WIDTH
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
from signal_bot.context import CommandContext
from signal_bot.decorators import Decorator
from signal_bot.http_client import ImageFetcher
from signal_bot.registry import CommandRegistry, Handler
COMMAND_NAME: Final = "art"
DESCRIPTION: Final = "losowe dzieło sztuki"
ART_FILE: Final = Path(__file__).with_name("art.json")
# Per cent of posts drawn from the Polish pool.
POLISH_SHARE: Final = 60
PER_CENT: Final = 100
def load_art(path: Path = ART_FILE) -> tuple[tuple[int, ...], tuple[int, ...]]:
"""Read both pools.
The file holds Commons page ids -- bare integers -- and that is checked
here rather than assumed, for the reason ``traps_command.load_traps``
gives. It matters more for art than for traps: a Commons painting
filename is very often literally somebody's name, so a pool of filenames
would be a list of names committed to a public repository.
Args:
path: Where to read the pools from. Defaults to the shipped file.
Returns:
The Polish pool and the world pool.
Raises:
ValueError: If either pool is empty or holds anything but integers.
"""
document = json.loads(path.read_text(encoding="utf-8"))
pools: list[tuple[int, ...]] = []
for name in ("polish", "world"):
pool = document[name]
if not pool or not all(isinstance(page, int) for page in pool):
msg = f"{path} must hold a non-empty list of Commons page ids under {name!r}"
raise ValueError(msg)
pools.append(tuple(pool))
return pools[0], pools[1]
def random_art(
polish: Sequence[int],
world: Sequence[int],
randbelow: Callable[[int], int] = secrets.randbelow,
) -> int:
"""Return one page id, skewed towards the Polish pool.
secrets rather than random for the reason animals.paged_url gives: the
choice is not security-sensitive, but ruff's ALL rule set flags `random`
and this repository forbids the noqa that would silence it.
``randbelow`` is a parameter because ``secrets.randbelow`` cannot be
seeded, and a test that asserted the 60/40 split by taking real draws
would either flake or need thousands of iterations. Injecting it lets the
test name the branch it means.
"""
pool = polish if randbelow(PER_CENT) < POLISH_SHARE else world
return pool[randbelow(len(pool))]
def art_handler(
fetcher: ImageFetcher,
api_url: str,
pools: tuple[Sequence[int], Sequence[int]] | None = None,
) -> Handler:
"""Build the handler that posts one random work of art."""
polish, world = load_art() if pools is None else (tuple(pools[0]), tuple(pools[1]))
async def handler(ctx: CommandContext) -> None:
# One request, because the caption and the picture have to describe
# the same file; two would be a chance for them to disagree.
document = await fetcher.fetch_json(
api_url,
imageinfo_params(THUMB_WIDTH, page_id=random_art(polish, world), describe=True),
)
page = page_of(document)
image = await fetcher.fetch_image(str(page["imageinfo"][0]["thumburl"]))
await ctx.reply_image(b64encode(image).decode("ascii"), caption_for(page))
return handler
def register_art_command(
registry: CommandRegistry,
fetcher: ImageFetcher,
api_url: str,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
pools: tuple[Sequence[int], Sequence[int]] | None = None,
) -> None:
"""Register !sztuka under every trigger aliases.json gives it."""
table = load_aliases() if aliases is None else aliases
handler = art_handler(fetcher, api_url, pools)
if decorate is not None:
handler = decorate(handler)
registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION)