Files
talkscore-asr/app/store.py
T
Vladimir BryzgalovandClaude Opus 5 96c2a27c4c Плеер на странице разбора и хранение записей
Записи складываются в папку records и удаляются вместе с задачей по тому же
сроку. На странице разбора появился плеер, а щелчок по времени слева от
реплики перематывает запись - можно послушать спорное место, не таская
ползунок.

Настройка keep_records в config.toml. Если запись не сохранена, вместо
пустого плеера показывается объяснение.

Черновик с подсветкой расхождений пока остаётся отдельной командой:
build/make_draft.py. Ложные расхождения по написанию больше не подсвечиваются.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:11:23 +05:00

211 lines
9.5 KiB
Python

"""Хранилище задач на SQLite.
SQLite, а не память: задачи должны переживать перезапуск сервиса, иначе
после обновления или падения клиент навсегда останется с id, по которому
ничего нет.
"""
import json
import sqlite3
import threading
import time
import uuid
from pathlib import Path
__all__ = ["JobStore", "JobStatus"]
class JobStatus:
QUEUED = "queued"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
_SCHEMA = """
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
duration_sec REAL NOT NULL,
status TEXT NOT NULL,
created_at REAL NOT NULL,
started_at REAL,
finished_at REAL,
result TEXT,
error TEXT,
options TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, created_at);
"""
class JobStore:
def __init__(self, path: Path):
self._path = Path(path)
self._path.parent.mkdir(parents=True, exist_ok=True)
# Один процесс, несколько потоков (веб + воркер), поэтому check_same_thread
# выключен, а доступ сериализуется собственным замком.
self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self._path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.executescript(_SCHEMA)
self._conn.commit()
self._recover_stuck()
self._backfill_duration()
def _backfill_duration(self) -> None:
"""Проставляет длительность задачам, обработанным до её сохранения.
Раньше поле оставалось нулевым: при загрузке файл ещё не декодирован,
а после обработки никто не возвращал значение обратно. В сохранённом
результате оно есть, поэтому историю можно починить.
"""
with self._lock:
rows = self._conn.execute(
"SELECT id, result FROM jobs "
"WHERE duration_sec <= 0 AND result IS NOT NULL").fetchall()
for row in rows:
try:
value = float(json.loads(row["result"]).get("duration_sec") or 0)
except (ValueError, TypeError, json.JSONDecodeError):
continue
if value > 0:
self._update(row["id"], duration_sec=value)
def _recover_stuck(self) -> None:
"""После аварийного перезапуска задачи «в работе» никто не доделает."""
with self._lock:
self._conn.execute(
"UPDATE jobs SET status=?, error=? WHERE status=?",
(JobStatus.FAILED, "сервис был перезапущен во время обработки", JobStatus.RUNNING),
)
self._conn.commit()
def create(self, filename: str, duration_sec: float, options: dict | None = None) -> str:
job_id = uuid.uuid4().hex
with self._lock:
self._conn.execute(
"INSERT INTO jobs (id, filename, duration_sec, status, created_at, options)"
" VALUES (?,?,?,?,?,?)",
(job_id, filename, duration_sec, JobStatus.QUEUED, time.time(),
json.dumps(options or {}, ensure_ascii=False)),
)
self._conn.commit()
return job_id
def get(self, job_id: str) -> dict | None:
with self._lock:
row = self._conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
if row is None:
return None
job = dict(row)
job["result"] = json.loads(job["result"]) if job["result"] else None
job["options"] = json.loads(job["options"]) if job["options"] else {}
return job
def claim_next(self) -> str | None:
"""Забирает самую старую задачу из очереди и сразу помечает её в работе.
Выборка и пометка выполняются одним оператором под общим замком: иначе
два воркера успевают увидеть одну и ту же задачу и берут её оба.
"""
with self._lock:
row = self._conn.execute(
"UPDATE jobs SET status=?, started_at=? WHERE id = ("
" SELECT id FROM jobs WHERE status=? ORDER BY created_at LIMIT 1"
") RETURNING id",
(JobStatus.RUNNING, time.time(), JobStatus.QUEUED),
).fetchone()
self._conn.commit()
return row["id"] if row else None
def queue_position(self, job_id: str) -> int:
"""Сколько задач будет обработано до этой.
Считается и та, что уже выполняется: для клиента она тоже стоит впереди,
и ждать ему придётся вместе с ней. 0 означает «следующая на обработку».
"""
with self._lock:
row = self._conn.execute(
"SELECT COUNT(*) AS n FROM jobs WHERE status IN (?,?) AND created_at <"
" (SELECT created_at FROM jobs WHERE id=?)",
(JobStatus.QUEUED, JobStatus.RUNNING, job_id),
).fetchone()
return row["n"] if row else 0
def mark_running(self, job_id: str) -> None:
self._update(job_id, status=JobStatus.RUNNING, started_at=time.time())
def mark_done(self, job_id: str, result: dict) -> None:
# Длительность становится известна только после декодирования, а в
# списке задач нужна без разбора всего результата - записываем сюда.
self._update(job_id, status=JobStatus.DONE, finished_at=time.time(),
duration_sec=float(result.get("duration_sec") or 0.0),
result=json.dumps(result, ensure_ascii=False))
def mark_failed(self, job_id: str, error: str) -> None:
self._update(job_id, status=JobStatus.FAILED, finished_at=time.time(), error=error)
def _update(self, job_id: str, **fields) -> None:
assigns = ", ".join(f"{k}=?" for k in fields)
with self._lock:
self._conn.execute(f"UPDATE jobs SET {assigns} WHERE id=?",
(*fields.values(), job_id))
self._conn.commit()
def delete(self, job_id: str) -> bool:
with self._lock:
cur = self._conn.execute("DELETE FROM jobs WHERE id=?", (job_id,))
self._conn.commit()
return cur.rowcount > 0
def cleanup(self, max_age_hours: float) -> list[str]:
"""Удаляет завершённые задачи старше срока. Возвращает их идентификаторы.
Идентификаторы нужны вызывающему: рядом с записью в базе лежит файл
разговора, и удалять их надо вместе, иначе папка растёт вечно.
"""
cutoff = time.time() - max_age_hours * 3600
with self._lock:
rows = self._conn.execute(
"SELECT id FROM jobs WHERE status IN (?,?) "
"AND COALESCE(finished_at, created_at) <= ?",
(JobStatus.DONE, JobStatus.FAILED, cutoff)).fetchall()
ids = [row["id"] for row in rows]
self._conn.execute(
"DELETE FROM jobs WHERE status IN (?,?) AND COALESCE(finished_at, created_at) <= ?",
(JobStatus.DONE, JobStatus.FAILED, cutoff),
)
self._conn.commit()
removed = len(ids)
if removed:
self.vacuum()
return ids
def vacuum(self) -> None:
"""Возвращает системе место, освобождённое удалением.
SQLite помечает страницы свободными, но файл не уменьшает: после
чистки многомесячной истории он остаётся прежнего размера.
"""
with self._lock:
self._conn.execute("VACUUM")
self._conn.commit()
def recent(self, limit: int = 200) -> list[dict]:
"""Последние задачи без результатов: для списка нужны только заголовки.
Результат каждой задачи весит сотни килобайт, и тянуть их ради списка
значит держать в памяти десятки мегабайт ради нескольких строк.
"""
with self._lock:
rows = self._conn.execute(
"SELECT id, filename, status, created_at, duration_sec, error "
"FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
return [dict(row) for row in rows]
def stats(self) -> dict:
with self._lock:
rows = self._conn.execute(
"SELECT status, COUNT(*) AS n FROM jobs GROUP BY status").fetchall()
return {r["status"]: r["n"] for r in rows}