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

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