mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 17:54:04 +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
114 lines
4.0 KiB
Python
114 lines
4.0 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Family E, the behavioural half: sittings, bursts and what goes together.
|
|
|
|
These are the only questions that need a *trustworthy ordering in time*, so
|
|
they are the only ones restricted to ``live`` rows plus the journald ones
|
|
that carry a real hour. A ``daily_uses`` row is a count with a synthetic
|
|
timestamp built from midnight; treating those as a sequence would invent a
|
|
sitting in which somebody ran forty commands in forty milliseconds.
|
|
|
|
A session is one person's run of commands with no gap longer than
|
|
``SESSION_GAP_MS``. It exists to make two things sayable:
|
|
|
|
* **burstiness** -- how many commands a sitting contains. Three or four
|
|
means somebody is re-rolling for a better photo, which is a fact about
|
|
the pool rather than about them.
|
|
* **co-occurrence** -- which commands turn up in the same sitting, which is
|
|
what says whether two commands are alternatives or a habit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
from dataclasses import dataclass
|
|
from itertools import combinations
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from signal_bot.analytics.scope import SESSION_GAP_MS
|
|
|
|
if TYPE_CHECKING:
|
|
import sqlite3
|
|
|
|
from signal_bot.analytics.scope import Scope
|
|
|
|
# Only rows whose timestamp is a real moment. daily_uses rows are excluded
|
|
# by local_hour, which is the one column that distinguishes them.
|
|
ORDERED_SQL: Final = """
|
|
SELECT user_uuid, command, invoked_at_ms
|
|
FROM command_events
|
|
WHERE (:group IS NULL OR group_id = :group)
|
|
AND local_hour >= 0
|
|
AND outcome != 'unknown_word'
|
|
ORDER BY user_uuid, invoked_at_ms
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Sessions:
|
|
"""How the group actually uses the bot, sitting by sitting."""
|
|
|
|
count: int = 0
|
|
commands: int = 0
|
|
longest: int = 0
|
|
pairs: tuple[tuple[str, str, int], ...] = ()
|
|
repeats: tuple[tuple[str, int], ...] = ()
|
|
|
|
@property
|
|
def average_length(self) -> float:
|
|
"""Commands per sitting. One means nobody re-rolls."""
|
|
return self.commands / self.count if self.count else 0.0
|
|
|
|
|
|
def _split(rows: list[tuple[str, str, int]]) -> list[list[str]]:
|
|
"""Cut one person's ordered commands into sittings on the idle gap."""
|
|
sittings: list[list[str]] = []
|
|
current: list[str] = []
|
|
last_uuid = ""
|
|
last_at = 0
|
|
for uuid, command, at_ms in rows:
|
|
broken = uuid != last_uuid or at_ms - last_at > SESSION_GAP_MS
|
|
if broken and current:
|
|
sittings.append(current)
|
|
current = []
|
|
current.append(command)
|
|
last_uuid = uuid
|
|
last_at = at_ms
|
|
if current:
|
|
sittings.append(current)
|
|
return sittings
|
|
|
|
|
|
def collect(conn: sqlite3.Connection, scope: Scope) -> Sessions:
|
|
"""Split the history into sittings and describe what they contain."""
|
|
rows = [
|
|
(str(row["user_uuid"]), str(row["command"]), int(row["invoked_at_ms"]))
|
|
for row in conn.execute(ORDERED_SQL, scope.params())
|
|
]
|
|
sittings = _split(rows)
|
|
if not sittings:
|
|
return Sessions()
|
|
|
|
pairs: Counter[tuple[str, str]] = Counter()
|
|
repeats: Counter[str] = Counter()
|
|
for sitting in sittings:
|
|
# Sorted and de-duplicated, so (cat, dog) and (dog, cat) are one
|
|
# fact rather than two, and running !kot four times in a sitting
|
|
# does not make it co-occur with itself.
|
|
distinct = sorted(set(sitting))
|
|
for left, right in combinations(distinct, 2):
|
|
pairs[(left, right)] += 1
|
|
for command, times in Counter(sitting).items():
|
|
if times > 1:
|
|
repeats[command] += times - 1
|
|
|
|
return Sessions(
|
|
count=len(sittings),
|
|
commands=sum(len(sitting) for sitting in sittings),
|
|
longest=max(len(sitting) for sitting in sittings),
|
|
pairs=tuple(
|
|
(left, right, count)
|
|
for (left, right), count in sorted(pairs.items(), key=lambda p: (-p[1], p[0]))
|
|
),
|
|
repeats=tuple(sorted(repeats.items(), key=lambda pair: (-pair[1], pair[0]))),
|
|
)
|