# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """What a question is being asked *about*, and which rows may answer it. Every query in this package is a complete literal string. None of them is built by concatenation or by an f-string, which is the only arrangement ruff S608 accepts and this repository takes no suppressions -- ``forget_group`` spells its DELETEs out for the same reason. "One group, or all of them" is therefore not a clause that gets appended. It is written into every query once, as:: WHERE (:group IS NULL OR group_id = :group) Binding ``:group`` to None makes the first half true and the filter vanish. So the roll-up and the per-group answer run *the same SQL*, which is what stops them drifting apart -- two queries written to agree eventually will not. The other shared decision here is the source filter. Three classes of row live in ``command_events``: * ``live`` -- observed as it happened; the only rows with real durations. * ``journald`` -- reconstructed from the log, with a real hour taken from the message it was matched to, but no duration and an outcome that was inferred rather than seen. * ``daily_uses`` -- a count with no hour at all, carrying ``UNKNOWN_HOUR``. Counting all three is right for "how often was this used" and wrong for "at what hour" or "how slow is it". Those two predicates are named here, once, so that the exclusion is a shared constant rather than something six modules each have to remember. ``stats/backfill.py`` records what forgetting it costs: a synthetic hour reached ``favourite_hour`` and the answer was quietly wrong. """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING, Final if TYPE_CHECKING: import sqlite3 # How long a person may go quiet before their next command starts a new # session. Used only by burstiness and co-occurrence, the two questions that # need "in one sitting" to mean anything. # # Ten minutes: comfortably longer than the pause between two commands # somebody is idly firing off, far shorter than the gap between two separate # times of day they picked their phone up. SESSION_GAP_MS: Final = 10 * 60 * 1000 # Below this many days, a direction is not stated. The line is still drawn -- # the reader can see it -- but no arrow and no "rośnie"/"spada" verdict, # because three points of a new bot's novelty curve are not a trend. TREND_MIN_DAYS: Final = 14 @dataclass(frozen=True, slots=True) class Scope: """One group, or all of them.""" group_id: str | None = None def params(self, **extra: object) -> dict[str, object]: """The bound parameters every query in this package expects.""" return {"group": self.group_id, **extra} @dataclass(frozen=True, slots=True) class Window: """Which days an answer actually covers, and where its rows came from. Carried on every response because this bot is days old, not months. A month panel drawn from three days is not wrong, but a reader who cannot see that it is three days will read it as though it were a month. """ first_day: str = "" last_day: str = "" days: int = 0 events: int = 0 live: int = 0 journald: int = 0 daily: int = 0 @property def enough_for_a_trend(self) -> bool: """Whether a direction may be stated rather than merely plotted.""" return self.days >= TREND_MIN_DAYS WINDOW_SQL: Final = """ SELECT MIN(day) AS first_day, MAX(day) AS last_day, COUNT(DISTINCT day) AS days, COUNT(*) AS events, SUM(source = 'live') AS live, SUM(source = 'journald') AS journald, SUM(source = 'daily_uses') AS daily FROM command_events WHERE (:group IS NULL OR group_id = :group) """ def window(conn: sqlite3.Connection, scope: Scope) -> Window: """Measure what the data actually covers, before anything is drawn.""" row = conn.execute(WINDOW_SQL, scope.params()).fetchone() if row is None or not row["events"]: return Window() return Window( first_day=str(row["first_day"]), last_day=str(row["last_day"]), days=int(row["days"]), events=int(row["events"]), live=int(row["live"] or 0), journald=int(row["journald"] or 0), daily=int(row["daily"] or 0), )