Add src/selectel_ml_tui/storage.py
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
"""Локальный кэш и история данных (SQLite).
|
||||
|
||||
База данных в каталоге данных платформы (Linux: ~/.local/share,
|
||||
macOS: ~/Library/Application Support). Хранит снимки потребления и метрик
|
||||
при каждом успешном обновлении, а также историю по периодам для графиков.
|
||||
При недоступности API TUI может показать последние данные из кэша.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import datetime as dt
|
||||
import json
|
||||
import sqlite3
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from selectel_ml_tui.config import data_dir
|
||||
from selectel_ml_tui.models import (
|
||||
Consumption,
|
||||
HistoryPoint,
|
||||
Metrics,
|
||||
ModelMetrics,
|
||||
TokenUsage,
|
||||
parse_iso_dt,
|
||||
)
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS consumption_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
amount_rub TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_write_tokens INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS metrics_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
availability_pct REAL NOT NULL DEFAULT 100,
|
||||
avg_ttft_ms REAL NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_write_tokens INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
availability_pct REAL NOT NULL DEFAULT 100,
|
||||
avg_ttft_ms REAL NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_write_tokens INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
timestamp TEXT PRIMARY KEY,
|
||||
amount_rub TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_write_tokens INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def default_db_path() -> Path:
|
||||
"""Путь к базе данных по умолчанию (каталог данных платформы)."""
|
||||
return data_dir() / "cache.db"
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return dt.datetime.now(dt.timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _history_columns(point: HistoryPoint) -> tuple[Any, ...]:
|
||||
return (
|
||||
point.timestamp.isoformat(),
|
||||
str(point.amount_rub),
|
||||
point.tokens.input_tokens,
|
||||
point.tokens.output_tokens,
|
||||
point.tokens.cache_read_tokens,
|
||||
point.tokens.cache_write_tokens,
|
||||
)
|
||||
|
||||
|
||||
def _history_from_row(row: sqlite3.Row) -> HistoryPoint:
|
||||
return HistoryPoint(
|
||||
timestamp=parse_iso_dt(cast(str, row["timestamp"])),
|
||||
amount_rub=Decimal(str(row["amount_rub"])),
|
||||
tokens=TokenUsage(
|
||||
input_tokens=cast(int, row["input_tokens"]),
|
||||
output_tokens=cast(int, row["output_tokens"]),
|
||||
cache_read_tokens=cast(int, row["cache_read_tokens"]),
|
||||
cache_write_tokens=cast(int, row["cache_write_tokens"]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Storage:
|
||||
"""SQLite-кэш: снимки потребления/метрик и история для графиков."""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self._path = path if path is not None else default_db_path()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = sqlite3.connect(self._path)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._init_schema()
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self._path
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
self._conn.executescript(SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
def save_consumption(self, item: Consumption) -> None:
|
||||
"""Сохранить снимок потребления."""
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO consumption_snapshots
|
||||
(created_at, period, amount_rub, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_write_tokens)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
_utc_now(),
|
||||
item.period.isoformat(),
|
||||
str(item.amount_rub),
|
||||
item.input_tokens,
|
||||
item.output_tokens,
|
||||
item.cache_read_tokens,
|
||||
item.cache_write_tokens,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def save_metrics(self, metrics: Metrics) -> None:
|
||||
"""Сохранить снимок общих метрик."""
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO metrics_snapshots
|
||||
(created_at, availability_pct, avg_ttft_ms, input_tokens,
|
||||
output_tokens, cache_read_tokens, cache_write_tokens)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
_utc_now(),
|
||||
metrics.availability_pct,
|
||||
metrics.avg_ttft_ms,
|
||||
metrics.input_tokens,
|
||||
metrics.output_tokens,
|
||||
metrics.cache_read_tokens,
|
||||
metrics.cache_write_tokens,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def save_model_metrics(self, models: list[ModelMetrics]) -> None:
|
||||
"""Сохранить снимок метрик по моделям (один пакет = один created_at)."""
|
||||
batch = _utc_now()
|
||||
for model in models:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO model_metrics
|
||||
(created_at, model_id, model_name, availability_pct,
|
||||
avg_ttft_ms, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_write_tokens)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
batch,
|
||||
model.model_id,
|
||||
model.model_name,
|
||||
model.availability_pct,
|
||||
model.avg_ttft_ms,
|
||||
model.input_tokens,
|
||||
model.output_tokens,
|
||||
model.cache_read_tokens,
|
||||
model.cache_write_tokens,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def save_history(self, point: HistoryPoint) -> None:
|
||||
"""Сохранить/обновить точку истории (upsert по timestamp)."""
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO history
|
||||
(timestamp, amount_rub, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_write_tokens)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
_history_columns(point),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def save_snapshot(
|
||||
self,
|
||||
*,
|
||||
consumption: Consumption | None = None,
|
||||
metrics: Metrics | None = None,
|
||||
models: list[ModelMetrics] | None = None,
|
||||
history_point: HistoryPoint | None = None,
|
||||
) -> None:
|
||||
"""Сохранить полный снимок данных после успешного обновления."""
|
||||
if consumption is not None:
|
||||
self.save_consumption(consumption)
|
||||
if metrics is not None:
|
||||
self.save_metrics(metrics)
|
||||
if models:
|
||||
self.save_model_metrics(models)
|
||||
if history_point is not None:
|
||||
self.save_history(history_point)
|
||||
|
||||
def get_latest_consumption(self) -> Consumption | None:
|
||||
"""Последний снимок потребления (для офлайн-режима)."""
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM consumption_snapshots ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return Consumption(
|
||||
period=parse_iso_dt(cast(str, row["period"])),
|
||||
amount_rub=Decimal(str(row["amount_rub"])),
|
||||
input_tokens=cast(int, row["input_tokens"]),
|
||||
output_tokens=cast(int, row["output_tokens"]),
|
||||
cache_read_tokens=cast(int, row["cache_read_tokens"]),
|
||||
cache_write_tokens=cast(int, row["cache_write_tokens"]),
|
||||
)
|
||||
|
||||
def get_latest_metrics(self) -> Metrics | None:
|
||||
"""Последний снимок общих метрик."""
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM metrics_snapshots ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return Metrics(
|
||||
availability_pct=cast(float, row["availability_pct"]),
|
||||
avg_ttft_ms=cast(float, row["avg_ttft_ms"]),
|
||||
input_tokens=cast(int, row["input_tokens"]),
|
||||
output_tokens=cast(int, row["output_tokens"]),
|
||||
cache_read_tokens=cast(int, row["cache_read_tokens"]),
|
||||
cache_write_tokens=cast(int, row["cache_write_tokens"]),
|
||||
)
|
||||
|
||||
def get_latest_model_metrics(self) -> list[ModelMetrics]:
|
||||
"""Снимок метрик по моделям за последнее обновление."""
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT * FROM model_metrics
|
||||
WHERE created_at = (SELECT MAX(created_at) FROM model_metrics)
|
||||
ORDER BY model_id
|
||||
"""
|
||||
).fetchall()
|
||||
models: list[ModelMetrics] = []
|
||||
for row in rows:
|
||||
models.append(
|
||||
ModelMetrics(
|
||||
model_id=cast(str, row["model_id"]),
|
||||
model_name=cast(str, row["model_name"]),
|
||||
availability_pct=cast(float, row["availability_pct"]),
|
||||
avg_ttft_ms=cast(float, row["avg_ttft_ms"]),
|
||||
input_tokens=cast(int, row["input_tokens"]),
|
||||
output_tokens=cast(int, row["output_tokens"]),
|
||||
cache_read_tokens=cast(int, row["cache_read_tokens"]),
|
||||
cache_write_tokens=cast(int, row["cache_write_tokens"]),
|
||||
)
|
||||
)
|
||||
return models
|
||||
|
||||
def get_history(self, days: int = 7) -> list[HistoryPoint]:
|
||||
"""Серия точек истории за последние ``days`` дней для графика."""
|
||||
cutoff = (
|
||||
dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)
|
||||
).isoformat()
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM history WHERE timestamp >= ? ORDER BY timestamp ASC",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
return [_history_from_row(row) for row in rows]
|
||||
|
||||
def cleanup(self, max_age_days: int = 180) -> int:
|
||||
"""Удалить записи старше ``max_age_days`` дней. Возвращает число удалённых."""
|
||||
cutoff = (
|
||||
dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=max_age_days)
|
||||
).isoformat()
|
||||
deleted = 0
|
||||
for table, column in (
|
||||
("consumption_snapshots", "created_at"),
|
||||
("metrics_snapshots", "created_at"),
|
||||
("model_metrics", "created_at"),
|
||||
("history", "timestamp"),
|
||||
):
|
||||
cursor = self._conn.execute(
|
||||
f"DELETE FROM {table} WHERE {column} < ?", (cutoff,)
|
||||
)
|
||||
deleted += cursor.rowcount
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
def export_json(self, path: Path) -> None:
|
||||
"""Экспортировать всю историю в JSON."""
|
||||
points = self.get_history(days=3650)
|
||||
data = [
|
||||
{
|
||||
"timestamp": point.timestamp.isoformat(),
|
||||
"amount_rub": str(point.amount_rub),
|
||||
"input_tokens": point.tokens.input_tokens,
|
||||
"output_tokens": point.tokens.output_tokens,
|
||||
"cache_read_tokens": point.tokens.cache_read_tokens,
|
||||
"cache_write_tokens": point.tokens.cache_write_tokens,
|
||||
}
|
||||
for point in points
|
||||
]
|
||||
path.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
def export_csv(self, path: Path) -> None:
|
||||
"""Экспортировать всю историю в CSV."""
|
||||
points = self.get_history(days=3650)
|
||||
with path.open("w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(
|
||||
[
|
||||
"timestamp",
|
||||
"amount_rub",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
]
|
||||
)
|
||||
for point in points:
|
||||
writer.writerow(_history_columns(point))
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Полностью очистить кэш. Возвращает число удалённых записей."""
|
||||
deleted = 0
|
||||
for table in (
|
||||
"consumption_snapshots",
|
||||
"metrics_snapshots",
|
||||
"model_metrics",
|
||||
"history",
|
||||
):
|
||||
cursor = self._conn.execute(f"DELETE FROM {table}")
|
||||
deleted += cursor.rowcount
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
Reference in New Issue
Block a user