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

157 lines
8.2 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()
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"<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:
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 (
"<h2>📊 Полная статистика бота</h2>"
f"<p>Период: {period}</p>"
"<h3>Пользователи</h3>" + _html_table(["Показатель", "Количество"], user_rows)
+ "<h3>Генерация</h3>" + _html_table(["Показатель", "Количество"], activity_rows)
+ "<h3>Типы сообщений в генерации</h3>" + _html_table(["Тип", "Количество"], generation_type_rows)
+ "<h3>Обучение</h3>" + _html_table(["Показатель", "Количество"], training_rows)
+ "<h3>Типы сообщений в обучении</h3>" + _html_table(["Тип", "Количество"], training_type_rows)
)