Improved integration ability
This commit is contained in:
@@ -9,4 +9,5 @@ input.*
|
|||||||
audio.*
|
audio.*
|
||||||
video.*
|
video.*
|
||||||
output.*
|
output.*
|
||||||
|
report.txt
|
||||||
*.json
|
*.json
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ images/
|
|||||||
debug/
|
debug/
|
||||||
output.md
|
output.md
|
||||||
output.pdf
|
output.pdf
|
||||||
|
report.txt
|
||||||
*.json
|
*.json
|
||||||
|
|
||||||
*.mkv
|
*.mkv
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -68,21 +68,59 @@ python main.py
|
|||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
TODO
|
Контейнер рассчитан на один запрос. Для каждого задания создайте отдельную
|
||||||
|
директорию, положите в неё один поддерживаемый файл с именем `input.*` и
|
||||||
|
смонтируйте её в `/work`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
JOB_DIR=$(mktemp -d)
|
||||||
|
cp input.mkv "$JOB_DIR/input.mkv"
|
||||||
|
|
||||||
|
docker run --rm --name sumka-example --gpus all -e AI_API_KEY \
|
||||||
|
--mount type=bind,src="$JOB_DIR",dst=/work \
|
||||||
|
--mount type=volume,src=whisper-cache,dst=/tmp/sumka-cache/whisper \
|
||||||
|
--tmpfs /tmp:rw,size=8g sumka:nvidia
|
||||||
|
```
|
||||||
|
|
||||||
|
Не запускайте два контейнера с одной рабочей директорией. Повторный запуск с
|
||||||
|
той же директорией продолжит работу по уже созданным промежуточным файлам.
|
||||||
|
Кэш Whisper можно безопасно разделять между заданиями.
|
||||||
|
|
||||||
|
### Статус и логирование
|
||||||
|
|
||||||
|
Приложение дописывает в `JOB_DIR/report.txt` по одной JSON-записи на строку.
|
||||||
|
Записи появляются при старте и завершении этапа, а во время долгой операции —
|
||||||
|
каждые 30 секунд. Интервал меняется через `--report-interval`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"timestamp":"2026-09-24T12:00:00Z","run_id":"...","status":"running","step":"voice_recognition","step_index":2,"steps_total":10}
|
||||||
|
{"timestamp":"2026-09-24T12:00:30Z","run_id":"...","status":"heartbeat","elapsed_seconds":30,"step":"voice_recognition","step_index":2,"steps_total":10}
|
||||||
|
{"timestamp":"2026-09-24T12:10:00Z","run_id":"...","status":"succeeded","elapsed_seconds":600,"outputs":["output.md","output.pdf"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` принимает значения `started`, `running`, `step_completed`,
|
||||||
|
`heartbeat`, `succeeded`, `failed` или `cancelled`. Файл не перезаписывается:
|
||||||
|
повторный запуск добавляет записи с новым `run_id`. Смотреть его вручную можно
|
||||||
|
через `tail -f "$JOB_DIR/report.txt"`.
|
||||||
|
|
||||||
|
Matrix-боту рекомендуется запускать `docker run --rm --name <job-id>` через
|
||||||
|
`subprocess.Popen`/`asyncio.create_subprocess_exec`, параллельно читать новые
|
||||||
|
строки `report.txt` и разбирать их через `json.loads`. Код возврата контейнера
|
||||||
|
остаётся окончательным признаком успеха: `0` — успех, ненулевой — ошибка или
|
||||||
|
отмена. Подробные диагностические логи идут в stdout/stderr контейнера; для
|
||||||
|
отмены задания можно выполнить `docker stop <job-id>`.
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
Предполагается, что приложение будет запускаться сторонним приложением всякий
|
Предполагается, что приложение будет запускаться сторонним приложением всякий
|
||||||
раз, когда требуется произвести конвертацию медиафайла в конспект (например,
|
раз, когда требуется произвести конвертацию медиафайла в конспект (например,
|
||||||
Telegram ботом, которому отправили видео).
|
Matrix-ботом, которому отправили видео).
|
||||||
|
|
||||||
**Сервис не сохраняет никаких данных между перезапусками**. Всё, что
|
**Сервис не хранит состояние вне рабочей директории.** Если сервис сгенерировал
|
||||||
сохраняется - это промежуточные результаты. Например, если сервис сгенерировал
|
промежуточный файл, а после этого его принудительно завершили, то при следующем
|
||||||
файл `asr_events.json`, а после этого его принудительно завершили, то при
|
запуске он использует этот файл, чтобы не повторять дорогую операцию. **Поэтому
|
||||||
следующем запуске он будет использовать этот файл, чтобы не повторять дорогие
|
для каждого нового запроса нужна новая рабочая директория; старые директории
|
||||||
операции. **Поэтому при автоматизации рекомендуется на каждый новый запрос
|
можно удалять, когда результат больше не нужен.**
|
||||||
создавать новую промежуточную директорию, а старые директории удалять, когда они
|
|
||||||
становятся не нужны.**
|
|
||||||
|
|
||||||
Алгоритм работы сервиса следующий:
|
Алгоритм работы сервиса следующий:
|
||||||
1. **Проверить, является входной файл звуком или видео со звуком**
|
1. **Проверить, является входной файл звуком или видео со звуком**
|
||||||
|
|||||||
103
main.py
103
main.py
@@ -1,12 +1,10 @@
|
|||||||
"""Application entry point"""
|
"""Application entry point"""
|
||||||
|
|
||||||
import traceback
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import asdict
|
import signal
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
@@ -20,6 +18,7 @@ from structure_builder import Structure, StructureBuilder
|
|||||||
from structure_refiner import StructureRefiner
|
from structure_refiner import StructureRefiner
|
||||||
from markdown_builder import MarkdownBuilder
|
from markdown_builder import MarkdownBuilder
|
||||||
from pdf_builder import PdfBuilder
|
from pdf_builder import PdfBuilder
|
||||||
|
from reporting import StatusReporter
|
||||||
from windowizer import Windowizer
|
from windowizer import Windowizer
|
||||||
|
|
||||||
from agent import Agent
|
from agent import Agent
|
||||||
@@ -27,6 +26,15 @@ from utils import Timeline, ffmpeg_split_video, ffmpeg_to_mp3
|
|||||||
|
|
||||||
ARGS: argparse.Namespace
|
ARGS: argparse.Namespace
|
||||||
|
|
||||||
|
|
||||||
|
class TerminationRequested(Exception):
|
||||||
|
"""Raised when the container receives SIGTERM."""
|
||||||
|
|
||||||
|
def __init__(self, signal_number: int) -> None:
|
||||||
|
super().__init__(f"Received signal {signal_number}")
|
||||||
|
self.signal_number = signal_number
|
||||||
|
|
||||||
|
|
||||||
class Step(Enum):
|
class Step(Enum):
|
||||||
MEDIA_SEPARATION = "media_separation"
|
MEDIA_SEPARATION = "media_separation"
|
||||||
VOICE_RECOGNITION = "voice_recognition"
|
VOICE_RECOGNITION = "voice_recognition"
|
||||||
@@ -158,8 +166,17 @@ def setup_arguments() -> argparse.Namespace:
|
|||||||
type=str,
|
type=str,
|
||||||
default=ai_api_key
|
default=ai_api_key
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--report-interval",
|
||||||
|
type=float,
|
||||||
|
default=30.0,
|
||||||
|
help="seconds between report.txt heartbeat records",
|
||||||
|
)
|
||||||
parser.add_argument("-v", action='store_true')
|
parser.add_argument("-v", action='store_true')
|
||||||
return parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.report_interval <= 0:
|
||||||
|
parser.error("--report-interval must be positive")
|
||||||
|
return args
|
||||||
|
|
||||||
#
|
#
|
||||||
# Workflow
|
# Workflow
|
||||||
@@ -393,23 +410,28 @@ Step.CODE: (
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Application entry point"""
|
def run_workflow(reporter: StatusReporter) -> list[str]:
|
||||||
global ARGS
|
"""Execute the workflow and return paths to its final outputs."""
|
||||||
check_cuda()
|
|
||||||
ARGS = setup_arguments()
|
|
||||||
logging.basicConfig(level=logging.DEBUG if ARGS.v else logging.INFO)
|
|
||||||
step_to_do = Step.MEDIA_SEPARATION
|
step_to_do = Step.MEDIA_SEPARATION
|
||||||
intermediate_result: dict | None = None
|
intermediate_result: dict | None = None
|
||||||
|
step_numbers = {
|
||||||
|
step: index
|
||||||
|
for index, step in enumerate(WORKFLOW_DATA, start=1)
|
||||||
|
}
|
||||||
|
steps_total = len(WORKFLOW_DATA)
|
||||||
|
|
||||||
# execute steps while possible
|
# execute steps while possible
|
||||||
while step_to_do:
|
while step_to_do:
|
||||||
|
current_step = step_to_do
|
||||||
|
step_index = step_numbers[current_step]
|
||||||
|
reporter.step_started(current_step.value, step_index, steps_total)
|
||||||
step_data = WORKFLOW_DATA[step_to_do]
|
step_data = WORKFLOW_DATA[step_to_do]
|
||||||
output_file_path = step_data[0]
|
output_file_path = step_data[0]
|
||||||
func = step_data[1]
|
func = step_data[1]
|
||||||
if not func:
|
if not func:
|
||||||
logging.error(f"Step {step_to_do} has no function, stopping")
|
raise RuntimeError(f"Step {step_to_do} has no function")
|
||||||
break
|
logging.info("Executing step %s", step_to_do.value)
|
||||||
logging.info(f"Executing step {step_to_do}")
|
|
||||||
step_to_do, ret = func(step_to_do, intermediate_result)
|
step_to_do, ret = func(step_to_do, intermediate_result)
|
||||||
if ret is not None:
|
if ret is not None:
|
||||||
with open(output_file_path, "w", encoding="utf-8") as f:
|
with open(output_file_path, "w", encoding="utf-8") as f:
|
||||||
@@ -418,14 +440,53 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
intermediate_result = ret
|
intermediate_result = ret
|
||||||
json.dump(ret, f, ensure_ascii=False, indent=4)
|
json.dump(ret, f, ensure_ascii=False, indent=4)
|
||||||
logging.info(f"Intermediate results are saved {output_file_path}")
|
logging.info("Intermediate results are saved to %s", output_file_path)
|
||||||
# final report
|
if step_to_do is None and current_step is not Step.PDF_BUILDER:
|
||||||
logging.info(f"Workflow was interrupted at step {step_to_do}")
|
raise RuntimeError(f"Workflow stopped at step {current_step.value}")
|
||||||
|
reporter.step_completed(current_step.value, step_index, steps_total)
|
||||||
|
|
||||||
|
return [path for path in ("output.md", "output.pdf") if os.path.isfile(path)]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Application entry point."""
|
||||||
|
global ARGS
|
||||||
|
ARGS = setup_arguments()
|
||||||
|
logging.basicConfig(level=logging.DEBUG if ARGS.v else logging.INFO)
|
||||||
|
reporter = StatusReporter(heartbeat_interval=ARGS.report_interval)
|
||||||
|
reporter.start()
|
||||||
|
|
||||||
|
previous_sigterm_handler = signal.getsignal(signal.SIGTERM)
|
||||||
|
|
||||||
|
def handle_sigterm(signal_number: int, _frame: object) -> None:
|
||||||
|
raise TerminationRequested(signal_number)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||||
|
try:
|
||||||
|
check_cuda()
|
||||||
|
outputs = run_workflow(reporter)
|
||||||
|
except TerminationRequested as error:
|
||||||
|
logging.warning("Workflow was cancelled by SIGTERM")
|
||||||
|
reporter.finish("cancelled", signal=error.signal_number)
|
||||||
|
raise SystemExit(128 + error.signal_number) from None
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logging.warning("Workflow was cancelled by the user")
|
||||||
|
reporter.finish("cancelled")
|
||||||
|
raise
|
||||||
|
except BaseException as error:
|
||||||
|
logging.error("Workflow failed: %s: %s", type(error).__name__, error)
|
||||||
|
reporter.finish(
|
||||||
|
"failed",
|
||||||
|
error=type(error).__name__,
|
||||||
|
message=str(error),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
reporter.finish("succeeded", outputs=outputs)
|
||||||
|
logging.info("Workflow completed successfully")
|
||||||
|
finally:
|
||||||
|
signal.signal(signal.SIGTERM, previous_sigterm_handler)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
|
||||||
main()
|
main()
|
||||||
except SystemExit:
|
|
||||||
raise
|
|
||||||
except:
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|||||||
128
reporting.py
Normal file
128
reporting.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Append-only workflow status reporting for external process supervisors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal, TextIO
|
||||||
|
|
||||||
|
|
||||||
|
TerminalStatus = Literal["succeeded", "failed", "cancelled"]
|
||||||
|
|
||||||
|
|
||||||
|
class StatusReporter:
|
||||||
|
"""Write JSON Lines status events and periodic heartbeats."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path = "report.txt",
|
||||||
|
*,
|
||||||
|
heartbeat_interval: float = 30.0,
|
||||||
|
) -> None:
|
||||||
|
if heartbeat_interval <= 0:
|
||||||
|
raise ValueError("heartbeat_interval must be positive")
|
||||||
|
|
||||||
|
self._path = Path(path)
|
||||||
|
self._heartbeat_interval = heartbeat_interval
|
||||||
|
self._run_id = uuid.uuid4().hex
|
||||||
|
self._started_at = time.monotonic()
|
||||||
|
self._step: str | None = None
|
||||||
|
self._step_index: int | None = None
|
||||||
|
self._steps_total: int | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._file: TextIO | None = None
|
||||||
|
self._finished = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _timestamp() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||||
|
"+00:00",
|
||||||
|
"Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write(self, status: str, **fields: Any) -> None:
|
||||||
|
record = {
|
||||||
|
"timestamp": self._timestamp(),
|
||||||
|
"run_id": self._run_id,
|
||||||
|
"status": status,
|
||||||
|
**fields,
|
||||||
|
}
|
||||||
|
line = json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
with self._lock:
|
||||||
|
if self._file is None:
|
||||||
|
raise RuntimeError("StatusReporter is not running")
|
||||||
|
self._file.write(line + "\n")
|
||||||
|
self._file.flush()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Open the report and start emitting heartbeats."""
|
||||||
|
if self._file is not None or self._finished:
|
||||||
|
raise RuntimeError("StatusReporter cannot be started again")
|
||||||
|
self._file = self._path.open("a", encoding="utf-8", buffering=1)
|
||||||
|
self._write("started")
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._heartbeat_loop,
|
||||||
|
name="status-reporter",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _heartbeat_loop(self) -> None:
|
||||||
|
while not self._stop_event.wait(self._heartbeat_interval):
|
||||||
|
with self._lock:
|
||||||
|
step = self._step
|
||||||
|
step_index = self._step_index
|
||||||
|
steps_total = self._steps_total
|
||||||
|
fields: dict[str, Any] = {
|
||||||
|
"elapsed_seconds": round(time.monotonic() - self._started_at),
|
||||||
|
}
|
||||||
|
if step is not None:
|
||||||
|
fields.update({
|
||||||
|
"step": step,
|
||||||
|
"step_index": step_index,
|
||||||
|
"steps_total": steps_total,
|
||||||
|
})
|
||||||
|
self._write("heartbeat", **fields)
|
||||||
|
|
||||||
|
def step_started(self, step: str, index: int, total: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._step = step
|
||||||
|
self._step_index = index
|
||||||
|
self._steps_total = total
|
||||||
|
self._write(
|
||||||
|
"running",
|
||||||
|
step=step,
|
||||||
|
step_index=index,
|
||||||
|
steps_total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
def step_completed(self, step: str, index: int, total: int) -> None:
|
||||||
|
self._write(
|
||||||
|
"step_completed",
|
||||||
|
step=step,
|
||||||
|
step_index=index,
|
||||||
|
steps_total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
def finish(self, status: TerminalStatus, **fields: Any) -> None:
|
||||||
|
"""Write the terminal event and close the report."""
|
||||||
|
if self._finished:
|
||||||
|
return
|
||||||
|
self._finished = True
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join()
|
||||||
|
self._write(
|
||||||
|
status,
|
||||||
|
elapsed_seconds=round(time.monotonic() - self._started_at),
|
||||||
|
**fields,
|
||||||
|
)
|
||||||
|
assert self._file is not None
|
||||||
|
self._file.close()
|
||||||
|
self._file = None
|
||||||
Reference in New Issue
Block a user