Commit Graph

6 Commits

Author SHA1 Message Date
6b69deb37d chore: latest stable deps, and a suite that no longer reads the real .env
ruff 0.16.6, pnpm 11.25.0, @testing-library/user-event 14.6.7,
@types/node 26.4.1, eslint-plugin-perfectionist 5.11.0 and
typescript-eslint 8.69.0. The freshness gate flagged all six; ruff, tsc,
eslint and both suites stay green on the new versions. typescript stays
on 6.0.3: 8.69.0 still declares `typescript: ">=4.8.4 <6.1.0"`, so the
fleet-wide hold moves its blocker version and nothing else.

In the same commit because the gate would not let it land alone:
test_app.py drove the real ASGI lifespan, whose load_config() calls
load_dotenv(), which put the operator's PHOTO_PACK_DIR straight back
after conftest deleted it. The suite loaded the real album on every run
and only passed while that album loaded. Today it does not (tags.json
names two files media/ no longer has), and three ASGI tests failed with
it. load_dotenv is now a no-op for the whole suite, as test_main.py
already did for itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 09:47:17 +02:00
405541be9b release: 0.2.6 «Stephanus I» — dodano !analityka
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
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
6ec746fad2 feat: a word the bot does not know gets an answer, and gets kept
An unrecognised "!word" was dropped in silence, which made a typo
indistinguishable from the bot being down and threw away the best available
list of commands worth writing. Three things now happen at the one place in
dispatcher.py that used to be a bare `return`.

A near miss is corrected -- "!cta" asks whether you meant "!kot". The
distance is optimal string alignment rather than difflib's ratio: a
transposition is the typo people actually make, it costs one edit here, and
it shreds the matching blocks a ratio counts ("!cta"/"!cat" scores 0.75 but
"!cta"/"!kot" scores 0.50, so no cutoff separates them on principle).

A word with no near miss points at !zaproponuj, which files an idea from
inside the group. Either way the word and the message are recorded, and the
management page grows an Ideas tab that lists them.

Three things stay silent, each for its own reason. "!kot ladny" is a real
trigger with words after it and always has been silent. "!!" is punctuation.
And a command claimed in some *other* group is neither answered nor recorded
-- the album's triggers are real people's names, and pack_scoping.yaml says
it out loud: a refusal would itself announce that the album exists. The
existence check is therefore global, which is what pack_add already asks.

Measured rather than guessed, against the shipped aliases.json:

- a minimum candidate length, because "!ja" is one edit from ~40 of the 676
  two-letter words somebody could type; 69 of them got a suggestion before
  the rule and 18 after.
- the two-edit budget starts at six characters, not five. At five it put
  four pairs of *different* commands within reach of each other --
  !pies/!popes, !days/!cats, !dzis/!dni, !mnie/!dni -- and costs nothing to
  give up, because every real typo this was written for is one edit away.
  test_no_two_commands_are_within_reach_of_each_other fails if a future
  alias reopens the gap.

Both new tables are keyed on the envelope's own sent_at_ms, never the wall
clock: signal-cli replays undelivered frames on reconnect, and a clock-keyed
table collects a duplicate row per reconnect instead of ignoring one.

The branch is gated on should_count -- which excludes the bot's own envelope,
and that is the loop guard, since handle() checks is_own nowhere else -- and
wrapped in its own try/except, because it runs above the one that wraps a
handler and Bot.listen has no exception handling of its own.

Suggestions live in SQLite, not in a tracked file, and the export goes to the
state directory. check_personal_data learns the names it refuses from the
live database, so it catches a name somebody has typed and cannot catch a
misspelling of one -- and an unrecognised-command corpus is made of
misspellings. A group-writable file in a public tree is both a leak and a
wedge. The export therefore carries
deliberate !zaproponuj notes in full and unrecognised words as counts only;
bodies stay on the loopback-only page.

Verified on the live bot: restarted, both tables created with every existing
row count unchanged, !cta and !zaproponuj !pogoda delivered through
/_selftest, both rows read back out of the live database, and the journal
names neither the word nor the body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 16:48:26 +02:00
34b9900a90 fix: green CI, and a page that has actually been looked at
Two things the last commits could not have caught.

CI was red on every commit for as long as scripts/e2e.sh has existed, and
for two reasons, not one. ci.yml never installed ffmpeg or exiftool while
install_service.sh installs both for the host, so tool/e2e_fixtures.py could
not build its JPEG and MP4 fixtures. That step is added and verified by
running the binaries rather than by trusting apt's exit code.

Underneath it was a flake that ffmpeg alone would not have fixed. With the
tools present, e2e still failed two scenarios per run -- never the same two
twice, always one reporting "captured 0" and another "captured 2". The cause
is fake_signal/cli.py sleeping a fixed settle after each injection and then
reading the sends: a reply that fetches an image and shells out to exiftool
lands after that expires, so it arrives after the NEXT scenario's reset and
is counted against that one instead. It now waits for the count it expects,
with a deadline, and then for a beat of quiet -- returning the moment the
count arrives would make "expected 1, captured 2" unobservable, because the
surplus send is exactly the one that has not landed yet. A scenario expecting
nothing skips the poll and waits out the settle, which is the only way to
assert that nothing happens. --patience is exposed so the tests can drive the
give-up path without waiting it out; test_cli went from 45s to 0.4s. Two
consecutive full runs: 28 ok, 0 FAIL, exit 0.

And the page had no styling at all. Times New Roman on white, default
buttons, and the tagger's charcoal theme lost in the absorption -- the jsdom
suite asserts behaviour and cannot see any of that. @kuhyx/web-ui's tokens.css
is now imported and src/index.css lays the page out with those tokens only,
inventing no values.

The visual pass also found a real defect the tests could not: the active tab
was unreadable after being clicked. `button:hover` is more specific than a
bare attribute selector, so with the pointer still resting on the tab you
just clicked, the active rule lost and --on-fill dark text repainted onto
--surface-2 dark. Confirmed by reading the computed style, not by eye:
bg was rgb(56,49,46) where it should have been the accent. The active rule
now claims :hover too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 15:38:24 +02:00
8500a1add1 fix: the absorbed tagger gets its setup form and untagged filter back
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
The first pass shipped the tagging checkboxes and nothing else, which was a
regression dressed as an absorption: an album with no people.json has an
empty roster, so the checkboxes rendered an empty list and there was no way
to add anybody. The old page gated a setup form on `configured` for exactly
that reason. So does this one now.

Restored, behaviour-for-behaviour:

- The roster grammar, in src/api/setup.ts. One person per line, aliases by
  comma, `=` for a combination with `+` meaning both and `|` meaning either.
  Keys are reused for anyone already listed and otherwise handed out one past
  the highest in use -- never the count, or a key deleted by hand gets
  reissued and inherits somebody else's photos. Mirrors pack_people.next_key.
- The add-person box, for the usual case of noticing a new face in the photo
  already on screen.
- `--untagged`, which was a no-op: it is a view filter, and anything seen
  this session stays navigable, or tagging the photo you are looking at
  removes it from the list you are walking.

DOCS-web-ui.md claimed group ids were "picked, never typed" while settings
rendered a plain text box. That was the mitigation cited for accepting an
.env editor at all, so the claim is now backed by a <select> filled from
/api/groups -- and a failed listing leaves it empty rather than unhandled.

parseSetup returns a result instead of throwing, and readCombos has one
return type: a try/catch in the view carried an "and if it was some other
error" branch that could not happen, and an untestable branch is an untested
one. Same reason the album view splits into roster.tsx and tagger.tsx.

Still 100% on all four metrics with no exclude list, and verified against the
real album: 185 items, 4 groups in the picker, guard still answering 421.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 15:08:38 +02:00
a24d0d5905 feat: one page to manage the bot, and the tagger folded into it
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
./web.sh serves health, the configuration and the photo album on
127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8,
TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked +
unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics.

No coverage exclude list, including for main.tsx. This repository treats a
coverage omit as a suppression, and "the DOM bootstrap is awkward to assert"
is the same sentence as "this code is untested" -- so the bootstrap is a shim
thin enough to test in jsdom, and the album view holds its file list apart
from the album so the update path stopped carrying a "what if it is null"
branch that cannot happen. An untestable branch is an untested one.

TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares
typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the
compiler waits for the linter. The freshness gate then caught two pins the
reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a
floating node-version: "24" in CI -- both bumped rather than allowlisted.

Traps the gates found, all of them real:
- .gitignore has a bare lib/, which matches at any depth, so web/src/lib/
  would have been silently untracked. The source lives in src/api/.
- CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in
  CI for its whole life and web.sh would have inherited that. Now ./*.sh.
- tsconfig.json is strict JSON, not JSONC: check-json is generic and
  tsconfig being commentable is a convention, not a requirement.
- The personal-data gate refused an invented phone number in a test, which
  is exactly what it is for.

The album's warning that names a command moved with the command, and every
path that pointed at tool/tagger.py now points at ./web.sh. The historical
mentions stay: they explain why the code is shaped as it is.

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