From 7dd49a11ec6aab3ac53ddaddd0efcad636356f2b Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Mon, 17 Aug 2026 19:20:23 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BF=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2=20Word,=20=D0=B4=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B5,=20=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B2=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B4=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Длительность в списке всегда была нулевой: при загрузке файл ещё не декодирован, и mark_done её не записывал. Кнопка копирования кладёт в буфер и разметку, и простой текст: Word берёт первое и сохраняет полужирные имена говорящих, простые редакторы - второе. Срок хранения поднят с 3 суток до 336 часов, после чистки выполняется VACUUM: SQLite помечает страницы свободными, но файл не уменьшает. Co-Authored-By: Claude Opus 5 (1M context) --- app/config.py | 9 +++++---- app/store.py | 18 +++++++++++++++++- app/view.py | 46 +++++++++++++++++++++++++++++++++++++++++++++- tests/test_api.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_view.py | 20 ++++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) diff --git a/app/config.py b/app/config.py index 788a2c0..33ef516 100644 --- a/app/config.py +++ b/app/config.py @@ -75,8 +75,9 @@ normalize = true recover_gaps = true # Максимальный размер загружаемого файла, МБ max_upload_mb = 500 -# Сколько часов хранить результаты завершённых задач -keep_results_hours = 72 +# Сколько часов хранить результаты завершённых задач. Старые удаляются, +# файл базы после этого сжимается. 336 часов - две недели. +keep_results_hours = 336 [webhook] # Куда сообщать о готовых задачах. Пусто = не сообщать, забирайте опросом. @@ -118,7 +119,7 @@ class Settings: diarize: bool = False recover_gaps: bool = True max_upload_mb: int = 500 - keep_results_hours: float = 72.0 + keep_results_hours: float = 336.0 webhook_url: str = "" webhook_secret: str = "" update_enabled: bool = False @@ -206,7 +207,7 @@ def load_settings(config_path: Path | None = None) -> Settings: diarize=bool(proc.get("diarize", False)), recover_gaps=bool(proc.get("recover_gaps", True)), max_upload_mb=int(proc.get("max_upload_mb", 500)), - keep_results_hours=float(proc.get("keep_results_hours", 72)), + keep_results_hours=float(proc.get("keep_results_hours", 336)), webhook_url=str(hook.get("url", "")), webhook_secret=str(hook.get("secret", "")), https_domains=str(https.get("domains", "")), diff --git a/app/store.py b/app/store.py index 74bfbad..cb99066 100644 --- a/app/store.py +++ b/app/store.py @@ -116,7 +116,10 @@ class JobStore: 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: @@ -144,7 +147,20 @@ class JobStore: (JobStatus.DONE, JobStatus.FAILED, cutoff), ) self._conn.commit() - return cur.rowcount + removed = cur.rowcount + if removed: + self.vacuum() + return removed + + def vacuum(self) -> None: + """Возвращает системе место, освобождённое удалением. + + SQLite помечает страницы свободными, но файл не уменьшает: после + чистки многомесячной истории он остаётся прежнего размера. + """ + with self._lock: + self._conn.execute("VACUUM") + self._conn.commit() def recent(self, limit: int = 200) -> list[dict]: """Последние задачи без результатов: для списка нужны только заголовки. diff --git a/app/view.py b/app/view.py index fcddbdf..4420883 100644 --- a/app/view.py +++ b/app/view.py @@ -174,11 +174,15 @@ def render_job(job: dict) -> str: .roles .line {{ display:grid; grid-template-columns:76px 1fr; gap:10px; padding:4px 0; border-bottom:1px solid var(--line); }} .who2 {{ font-weight:600; font-size:13px; }} + button.copy {{ font:13px inherit; color:var(--fg); background:transparent; + border:1px solid var(--line); border-radius:4px; padding:3px 10px; + margin-left:10px; cursor:pointer; font-weight:400; }} + button.copy:hover {{ border-color:var(--dim); }}

Расшифровка {html.escape(str(job.get("job_id", ""))[:8])} - {html.escape(str(job.get("filename", "")))}

{_stats_row(job)}
-

Что отдал сервис

+

Что отдал сервис

Реплики так, как их нарезал сервис. Г0 значит, что разделение по голосам выключено и говорящий неизвестен - это норма.
{"".join(rows) or "
реплик нет
"} @@ -187,6 +191,7 @@ def render_job(job: dict) -> str: {roles_block}
+
""" diff --git a/tests/test_api.py b/tests/test_api.py index 91230f6..5f192ca 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -359,3 +359,39 @@ class TestJobsIndexRoute: def test_unknown_job_still_404(self, client): assert client.get("/v1/jobs/" + "0" * 32).status_code == 404 + + +class TestRetention: + """Записи храним две недели, после чистки файл базы сжимается.""" + + def test_default_is_two_weeks(self, tmp_path): + from app.config import load_settings + + config = tmp_path / "config.toml" + config.write_text("[processing]\nthreads=1\n", encoding="utf-8") + assert load_settings(config).keep_results_hours == 336.0 + + def test_cleanup_compacts_the_file(self, tmp_path): + import json as _json + import time as _time + + from app.store import JobStore + + store = JobStore(tmp_path / "jobs.db") + for _ in range(50): + job_id = store.create("call.mp3", 100.0) + store.mark_done(job_id, {"duration_sec": 100.0, + "turns": [{"text": "текст " * 500}]}) + store._update(job_id, finished_at=_time.time() - 400 * 3600) + before = (tmp_path / "jobs.db").stat().st_size + assert store.cleanup(336) == 50 + assert (tmp_path / "jobs.db").stat().st_size < before + + def test_duration_appears_after_processing(self, tmp_path): + """В списке задач длительность бралась из поля, которое никто не заполнял.""" + from app.store import JobStore + + store = JobStore(tmp_path / "jobs.db") + job_id = store.create("call.mp3", 0.0) + store.mark_done(job_id, {"duration_sec": 903.0, "turns": []}) + assert store.recent()[0]["duration_sec"] == 903.0 diff --git a/tests/test_view.py b/tests/test_view.py index e645c69..07c70c6 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -126,3 +126,23 @@ class TestIndex: from app.view import render_index assert "задач пока нет" in render_index([], "tok") + + +class TestCopyButton: + """Разрыв строки в JS ломается тихо: страница открывается, кнопка не работает.""" + + def test_javascript_has_no_raw_newline_in_literals(self): + page = render_job(job()) + script = page.split("")[0] + for line in script.splitlines(): + quotes = line.count("'") - line.count("\\'") + assert quotes % 2 == 0, f"незакрытая строка: {line}" + + def test_copy_buttons_present(self): + page = render_job(job()) + assert page.count('class="copy"') == 2 + + def test_copies_both_formats(self): + """Word берёт разметку, простые редакторы - текст.""" + page = render_job(job()) + assert "text/html" in page and "text/plain" in page