Files
social-post-writer-bot/analytics.py
T

169 lines
8.8 KiB
Python

"""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()
copy_where = " AND created_at >= ?" if where else ""
copy_row = connection.execute(
f"SELECT COUNT(*) AS total FROM events WHERE event_type = 'post_copy'{copy_where}", args
).fetchone()
result = {key: int(row[key] or 0) for key in ("users", "active_users", "post_generations", "idea_generations", "post_revisions", "generation_messages", "training_messages")}
result["post_copies"] = int(copy_row["total"] or 0)
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"<th>{h}</th>" for h in headers)
body = "".join("<tr>" + "".join(f"<td>{v}</td>" for v in row) + "</tr>" for row in rows)
return f"<table><thead><tr>{header}</tr></thead><tbody>{body}</tbody></table>"
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 "<h3>" + title + "</h3>" + _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 "<h3>" + title + "</h3>" + _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 (
"<h2>📊 Статистика бота</h2>"
+ table("Пользователи", [("Активные", "active_users")])
+ table("Генерация", [("Посты", "post_generations"), ("Идеи", "idea_generations"), ("Правки", "post_revisions"), ("Сообщения", "generation_messages"), ("Копирований поста", "post_copies")])
+ "<h3>Типы сообщений в генерации</h3>" + _html_table(["Тип", *titles], generation_rows)
+ table("Обучение", [("Сообщения", "training_messages")])
+ "<h3>Типы сообщений в обучении</h3>" + _html_table(["Тип", *titles], training_type_rows)
)