139 lines
7.3 KiB
Python
139 lines
7.3 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 render_report(period: str = "day", 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"}
|
|
def line(label: str, value: object) -> str:
|
|
return f"<b>{label}:</b> {value}"
|
|
rows = ["<h2>📊 Статистика бота</h2>", f"<p>Период: {period}</p>", "<h3>Пользователи</h3>", line("Пользователей", metrics["users"]), line("Активных", metrics["active_users"]), "<h3>Генерация</h3>", line("Постов", metrics["post_generations"]), line("Идей", metrics["idea_generations"]), line("Правок", metrics["post_revisions"]), line("Сообщений", metrics["generation_messages"]), line("Типы", ", ".join(f"{k}: {v}" for k, v in generation_types.items()) or "нет"), "<h3>Обучение</h3>", line("Сообщений", metrics["training_messages"]), line("Типы", ", ".join(f"{k}: {v}" for k, v in training_types.items()) or "нет")]
|
|
return "<p>" + "<br/>".join(rows) + "</p>"
|