"""Per-user product analytics stored in a separate SQLite database.""" from __future__ import annotations from contextlib import closing import datetime as dt import os import sqlite3 from pathlib import Path DEFAULT_DB_PATH = "/app/data/analytics.sqlite3" def connect(db_path: str | None = None) -> sqlite3.Connection: path = Path(db_path or os.getenv("ANALYTICS_DB_PATH", DEFAULT_DB_PATH)) path.parent.mkdir(parents=True, exist_ok=True) connection = sqlite3.connect(path, timeout=10) connection.row_factory = sqlite3.Row connection.execute("PRAGMA busy_timeout = 10000") connection.execute("PRAGMA journal_mode = WAL") return connection def init_db(db_path: str | None = None) -> None: with closing(connect(db_path)) as connection, connection: connection.execute( """ CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, telegram_user_id INTEGER NOT NULL, mode TEXT NOT NULL, event_type TEXT NOT NULL, message_type TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ ) connection.execute( "CREATE INDEX IF NOT EXISTS idx_events_user_time ON events(telegram_user_id, created_at)" ) connection.execute( "CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(event_type, created_at)" ) def record_event( user_id: int, mode: str, event_type: str, message_type: str | None = None, db_path: str | None = None, ) -> None: with closing(connect(db_path)) as connection, connection: connection.execute( "CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, telegram_user_id INTEGER NOT NULL, mode TEXT NOT NULL, event_type TEXT NOT NULL, message_type TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)" ) connection.execute( "INSERT INTO events (telegram_user_id, mode, event_type, message_type) VALUES (?, ?, ?, ?)", (int(user_id), mode[:30], event_type[:50], message_type[:20] if message_type else None), ) def _period_start(period: str, now: dt.datetime | None = None) -> str: now = now or dt.datetime.now(dt.timezone.utc) days = {"day": 1, "week": 7, "month": 30}.get(period, 0) return (now - dt.timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") def user_metrics(user_id: int, period: str = "all", db_path: str | None = None) -> dict[str, object]: where = "telegram_user_id = ?" args: list[object] = [int(user_id)] if period != "all": where += " AND created_at >= ?" args.append(_period_start(period)) with closing(connect(db_path)) as connection: rows = connection.execute( f"SELECT mode, event_type, message_type, COUNT(*) AS total FROM events WHERE {where} GROUP BY mode, event_type, message_type", args, ).fetchall() metrics: dict[str, object] = { "user_id": int(user_id), "period": period, "post_generations": 0, "idea_generations": 0, "post_revisions": 0, "generation_messages": 0, "training_messages": 0, "generation_message_types": {}, "training_message_types": {}, } generation_types: dict[str, int] = {} training_types: dict[str, int] = {} for row in rows: event_type, message_type, total = row["event_type"], row["message_type"], int(row["total"]) if event_type == "post_generation": metrics["post_generations"] = int(metrics["post_generations"]) + total elif event_type == "idea_generation": metrics["idea_generations"] = int(metrics["idea_generations"]) + total elif event_type == "post_revision": metrics["post_revisions"] = int(metrics["post_revisions"]) + total elif event_type == "user_message": target = generation_types if row["mode"] == "generation" else training_types if row["mode"] == "generation": metrics["generation_messages"] = int(metrics["generation_messages"]) + total elif row["mode"] == "training": metrics["training_messages"] = int(metrics["training_messages"]) + total if message_type: target[str(message_type)] = target.get(str(message_type), 0) + total metrics["generation_message_types"] = generation_types metrics["training_message_types"] = training_types return metrics def all_metrics(period: str = "all", db_path: str | None = None) -> dict[str, object]: """Return owner-facing aggregate metrics from per-user events.""" where = "" args: list[object] = [] if period != "all": where = " WHERE created_at >= ?" args.append(_period_start(period)) with closing(connect(db_path)) as connection: row = connection.execute( f"""SELECT COUNT(DISTINCT telegram_user_id) AS users, COUNT(DISTINCT CASE WHEN event_type = 'user_message' THEN telegram_user_id END) AS active_users, SUM(CASE WHEN event_type = 'post_generation' THEN 1 ELSE 0 END) AS post_generations, SUM(CASE WHEN event_type = 'idea_generation' THEN 1 ELSE 0 END) AS idea_generations, SUM(CASE WHEN event_type = 'post_revision' THEN 1 ELSE 0 END) AS post_revisions, SUM(CASE WHEN event_type = 'user_message' AND mode = 'generation' THEN 1 ELSE 0 END) AS generation_messages, SUM(CASE WHEN event_type = 'user_message' AND mode = 'training' THEN 1 ELSE 0 END) AS training_messages FROM events{where}""", args ).fetchone() types = connection.execute( f"SELECT mode, message_type, COUNT(*) AS total FROM events WHERE event_type='user_message' AND message_type IS NOT NULL{(' AND created_at >= ?' if period != 'all' else '')} GROUP BY mode, message_type", args, ).fetchall() result = {key: int(row[key] or 0) for key in ("users", "active_users", "post_generations", "idea_generations", "post_revisions", "generation_messages", "training_messages")} result["message_types"] = {(str(r["mode"]), str(r["message_type"])): int(r["total"]) for r in types} result["period"] = period return result def _html_table(headers: list[str], rows: list[list[object]]) -> str: header = "".join(f"{h}" for h in headers) body = "".join("" + "".join(f"{v}" for v in row) + "" for row in rows) return f"{header}{body}
" def render_report(period: str = "all", db_path: str | None = None) -> str: labels = {"day": "Сегодня", "week": "7 дней", "month": "30 дней", "all": "Всё время"} metrics_by_period = {key: all_metrics(key, db_path) for key in labels} titles = ["Сегодня", "7 дней", "30 дней", "Всё время"] def table(title: str, metric_rows: list[tuple[str, str]]) -> str: rows = [[label, *(metrics_by_period[key][metric] for key in labels)] for label, metric in metric_rows] return "

" + title + "

" + _html_table(["Показатель", *titles], rows) def table(title: str, metric_rows: list[tuple[str, str]]) -> str: rows = [[label, *(metrics_by_period[key][metric] for key in labels)] for label, metric in metric_rows] return "

" + title + "

" + _html_table(["Показатель", *titles], rows) type_names = sorted({key[1] for metrics in metrics_by_period.values() for key in metrics["message_types"]}) generation_rows = [[kind, *(metrics_by_period[p]["message_types"].get(("generation", kind), 0) for p in labels)] for kind in type_names] training_type_rows = [[kind, *(metrics_by_period[p]["message_types"].get(("training", kind), 0) for p in labels)] for kind in type_names] if not generation_rows: generation_rows = [["Нет данных", 0, 0, 0, 0]] training_type_rows = [["Нет данных", 0, 0, 0, 0]] return ( "

📊 Статистика бота

" + table("Пользователи", [("Активные", "active_users")]) + table("Генерация", [("Посты", "post_generations"), ("Идеи", "idea_generations"), ("Правки", "post_revisions"), ("Сообщения", "generation_messages")]) + "

Типы сообщений в генерации

" + _html_table(["Тип", *titles], generation_rows) + table("Обучение", [("Сообщения", "training_messages")]) + "

Типы сообщений в обучении

" + _html_table(["Тип", *titles], training_type_rows) )