mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
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
103 lines
3.4 KiB
Python
103 lines
3.4 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Family D: what the group asked for that does not exist.
|
|
|
|
The management page's Ideas tab already lists ``unknown_commands`` with the
|
|
words and the messages they came in. This adds the part it does not have:
|
|
the same words **grouped by the trigger they nearly hit**, so that "17
|
|
near-misses on !pułapka" reads as *this command wants another alias* rather
|
|
than as seventeen unrelated typos.
|
|
|
|
The grouping is done here rather than in SQL because the distance function
|
|
is ``nearest``'s, and it is optimal string alignment rather than a ratio --
|
|
a transposition costs one edit there and shreds the matching blocks a ratio
|
|
counts. Reimplementing that in SQL is not possible and approximating it
|
|
would put a different answer on the page from the one the bot gives in the
|
|
group.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from signal_bot.nearest import nearest
|
|
|
|
if TYPE_CHECKING:
|
|
import sqlite3
|
|
from collections.abc import Sequence
|
|
|
|
from signal_bot.analytics.scope import Scope
|
|
|
|
WORDS_SQL: Final = """
|
|
SELECT word, COUNT(*) AS hits, MAX(sent_at_ms) AS last_at
|
|
FROM unknown_commands
|
|
WHERE (:group IS NULL OR group_id = :group)
|
|
GROUP BY word
|
|
ORDER BY hits DESC, word
|
|
"""
|
|
|
|
FILED_SQL: Final = """
|
|
SELECT COUNT(*) AS filed
|
|
FROM suggestions
|
|
WHERE (:group IS NULL OR group_id = :group)
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class NearMiss:
|
|
"""A real trigger, and how often somebody nearly typed it."""
|
|
|
|
target: str
|
|
hits: int
|
|
variants: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Demand:
|
|
"""What the group tried to type, and what it filed on purpose."""
|
|
|
|
near_misses: tuple[NearMiss, ...] = ()
|
|
unclaimed: int = 0
|
|
unclaimed_words: int = 0
|
|
filed: int = 0
|
|
|
|
|
|
def collect(conn: sqlite3.Connection, scope: Scope, triggers: Sequence[str]) -> Demand:
|
|
"""Group every unrecognised word by the command it was probably meant to be.
|
|
|
|
A word that resolves to nothing at all is counted but not attributed:
|
|
those are the genuinely new ideas, and pretending one of them is a typo
|
|
of an existing command is how a real request gets filed as noise.
|
|
|
|
The words themselves never leave this function. A half-typed name is
|
|
still a name -- ``check_personal_data`` learns the names it refuses from
|
|
the live database, so it catches ``anna`` and could never catch
|
|
``!annna`` -- and the Ideas tab is the one page that already carries
|
|
them, deliberately, and never writes them into the repository.
|
|
"""
|
|
hits: Counter[str] = Counter()
|
|
variants: Counter[str] = Counter()
|
|
unclaimed = 0
|
|
unclaimed_words = 0
|
|
for row in conn.execute(WORDS_SQL, scope.params()):
|
|
word = str(row["word"])
|
|
count = int(row["hits"])
|
|
target = nearest(word, triggers)
|
|
if target is None:
|
|
unclaimed += count
|
|
unclaimed_words += 1
|
|
continue
|
|
hits[target] += count
|
|
variants[target] += 1
|
|
filed_row = conn.execute(FILED_SQL, scope.params()).fetchone()
|
|
return Demand(
|
|
near_misses=tuple(
|
|
NearMiss(target=target, hits=count, variants=variants[target])
|
|
for target, count in sorted(hits.items(), key=lambda pair: (-pair[1], pair[0]))
|
|
),
|
|
unclaimed=unclaimed,
|
|
unclaimed_words=unclaimed_words,
|
|
filed=int(filed_row["filed"] or 0) if filed_row is not None else 0,
|
|
)
|