mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 14:07:51 +01:00
Reported from the group: the same "Najczęściej piszesz między 12:00 a 13:00" line came back for Ala and for Jan Nowak, which is too much of a coincidence to be a statistic. It was the backfill. The 342 messages carried over from the pre-0.1.0 counter have no timestamps, so the seeded rows got a placeholder hour of 12 -- and favourite_hour was the one query that never excluded the backfilled day. For every person those synthetic noons outnumbered every real message they had sent since, so all seven got the same answer. The genuine hours are 19-21: this group talks in the evening. The comment above that placeholder said "the day is marked anyway, so nothing that cares about hours will read these", which is exactly the kind of reasoning that stops being true the moment somebody adds a query. So the fix is not another `exclude` argument to remember: the absent hour is now -1, which no real hour can be, and hour queries filter `local_hour >= 0`. Forgetting the exclusion can no longer invent an answer, and night_owl gets the same guard even though its 0..2 window happened to miss the old placeholder. Live database migrated with the service stopped and a backup taken: 342 rows moved from noon to unknown. Verified afterwards on the real data -- Ala 19:00, Jan Nowak 19:00, kuhy 20:00, and the two people whose only messages are backfilled correctly get no hour line at all. Not released. Sitting at 0.1.1 until kuhy says otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
95 lines
3.8 KiB
Python
95 lines
3.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Carrying the pre-0.1.0 counter into the event log, once.
|
|
|
|
Before 0.1.0 the bot kept one integer per person and deleted it after each
|
|
nightly post. When the event log arrived there were 319 messages sitting in
|
|
that counter, accumulated since the last successful post. Throwing them away
|
|
would have been tidier, but they are real messages and they are the group's
|
|
only pre-history.
|
|
|
|
What the counter cannot say is *when*. It has no timestamp column, and its
|
|
window straddles two calendar days -- from the previous night's post to
|
|
whenever the migration runs. So a backfilled day is filed under the day that
|
|
holds most of that window, and marked, because a merged 22-hour day pretending
|
|
to be a normal one would hand somebody a personal record they never set and
|
|
would drag every "average day" figure with it.
|
|
|
|
Marked days keep their totals and stay out of records and per-hour statistics.
|
|
``group_meta`` carries the mark, so this needs no schema change.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from signal_bot.stats.events import MessageEvent
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.store import Store
|
|
|
|
BACKFILL_KEY: Final = "backfill_day"
|
|
# -1, because a backfilled event has no hour at all and any value in 0..23 is
|
|
# a lie that reads as data. This started as 12, on the reasoning that the day
|
|
# is marked so "nothing that cares about hours will read these" -- and then
|
|
# favourite_hour did, and told all seven people their favourite hour was
|
|
# 12:00, because 319 synthetic noons outweighed every real evening.
|
|
#
|
|
# The lesson is in the type, not in a comment: an absent value must not be
|
|
# spellable as a present one. Hour queries filter `local_hour >= 0`, so
|
|
# forgetting to exclude the backfilled day can no longer invent an answer.
|
|
UNKNOWN_HOUR: Final = -1
|
|
|
|
|
|
def has_legacy_counter(store: Store) -> bool:
|
|
"""True when this database predates 0.1.0 and still holds the old table."""
|
|
row = store.events.connection.execute(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message_counts'"
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
def backfilled_day(store: Store, group_id: str) -> str | None:
|
|
"""The one day whose totals came from the old counter, if any."""
|
|
return store.meta(group_id, BACKFILL_KEY)
|
|
|
|
|
|
def seed_from_counter(store: Store, day: str) -> dict[str, int]:
|
|
"""Copy every surviving tally into the event log as one marked day.
|
|
|
|
Args:
|
|
store: The open database, which must still hold ``message_counts``.
|
|
day: The Warsaw day to file the counts under. Choose the day covering
|
|
most of the counter's window.
|
|
|
|
Returns:
|
|
Messages seeded per group id, for reporting.
|
|
"""
|
|
rows = store.events.connection.execute(
|
|
"SELECT group_id, uuid, display_name, count FROM message_counts WHERE count > 0"
|
|
).fetchall()
|
|
|
|
seeded: dict[str, int] = {}
|
|
for row in rows:
|
|
group_id = row["group_id"]
|
|
for index in range(row["count"]):
|
|
# Synthetic, strictly increasing, and unique per person per group,
|
|
# because (group, uuid, sent_at_ms) is the primary key. They are
|
|
# not timestamps anybody should read as one, which is what the
|
|
# day marking is for.
|
|
store.events.record(
|
|
MessageEvent(
|
|
group_id=group_id,
|
|
uuid=row["uuid"],
|
|
display_name=row["display_name"],
|
|
sent_at_ms=index + 1,
|
|
day=day,
|
|
local_hour=UNKNOWN_HOUR,
|
|
)
|
|
)
|
|
store.events.note_person(group_id, row["uuid"], row["display_name"], "", day)
|
|
seeded[group_id] = seeded.get(group_id, 0) + row["count"]
|
|
|
|
for group_id in seeded:
|
|
store.set_meta(group_id, BACKFILL_KEY, day)
|
|
return seeded
|