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