signal-bot/signal_bot/analytics/demand.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

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,
)