signal-bot/signal_bot/art_command.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
One row per command attempt, not two aggregates written only on success.

command_uses holds last-use only and daily_uses counts successes; both are
rate-limiter state and both are written by store.record_use, which the
dispatcher calls only when a handler returns cleanly. A gate refusal and a
crash returned before it, so a command that failed every time looked exactly
like one nobody ran, and nothing knew which command a counted message was.

command_events now takes every attempt with its outcome, its duration and
the item it served; upstream_calls takes every outbound request made while
serving one. The two writes stay separate on purpose -- recording a refusal
as a use is what slid the cooldown window forward every time somebody was
told to wait -- and test_dispatcher_outcomes asserts last_used does not move.

Backfilled from journald joined against message_events, with daily_uses
filling the residual so the two sources cannot double-count. Two things were
wrong on the first attempt and both were found by measuring rather than by
reasoning: the window has to be symmetric, because sent_at_ms comes from the
sender's phone and the journal stamp from this host (194 of 299 lines landed
*before* their own message, by up to 3.2 s); and matching has to be a global
closest-pair assignment, because per-row greedy cannot separate two commands
seconds apart. Together those took the dropped rows from 137 to zero, and
recovered 418 invocations back to 29 August.

Also: !analityka in the group, twelve Polish lines, naming at most three
people and only ones whose display name has been seen; an Analytics tab with
Nivo charts; and continuous latency measurement, which answers the open
question in DOCS-latency.md -- a text send is 235 ms and an image send about
2.2 s, so the upload really is where the time goes.

EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a
schema bump rebuilds, and what leaving a group clears. daily_uses left the
first (it is a backfill source now) and stayed in the second, and
_drop_reshaped took over the repair a bump used to do for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:19:51 +02:00

144 lines
5.5 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.analytics import probe
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.
page_id = random_art(polish, world)
# A Commons page id: public, and an identifier cannot carry a
# name. It is what turns "the art pool feels repetitive" into a
# repeat rate. The caption is fetched and posted, never recorded.
probe.note_item(str(page_id))
document = await fetcher.fetch_json(
api_url,
imageinfo_params(THUMB_WIDTH, page_id=page_id, 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)