"""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: metrics = all_metrics(period, db_path) types = metrics["message_types"] generation_types = {k[1]: v for k, v in types.items() if k[0] == "generation"} training_types = {k[1]: v for k, v in types.items() if k[0] == "training"} users = metrics["users"] active = metrics["active_users"] user_rows = [["Всего", users], ["Активные", active]] activity_rows = [["Генерации постов", metrics["post_generations"]], ["Идеи", metrics["idea_generations"]], ["Правки постов", metrics["post_revisions"]], ["Сообщения", metrics["generation_messages"]]] generation_type_rows = [[key, value] for key, value in sorted(generation_types.items())] or [["Нет данных", 0]] training_type_rows = [[key, value] for key, value in sorted(training_types.items())] or [["Нет данных", 0]] training_rows = [["Сообщения", metrics["training_messages"]]] return ( "

📊 Полная статистика бота

" f"

Период: {period}

" "

Пользователи

" + _html_table(["Показатель", "Количество"], user_rows) + "

Генерация

" + _html_table(["Показатель", "Количество"], activity_rows) + "

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

" + _html_table(["Тип", "Количество"], generation_type_rows) + "

Обучение

" + _html_table(["Показатель", "Количество"], training_rows) + "

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

" + _html_table(["Тип", "Количество"], training_type_rows) )