Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ S3_PRESIGN_EXPIRY=3600
# Воркер
MAX_CONCURRENT_TASKS=2

# Очистка диска (необязательно; значения ниже — дефолты).
# TTL workspace упавших задач; кеш транскриптов с ним не связан.
# WORK_DIR_CLEANUP_ENABLED=true
# WORK_DIR_FAILED_RETENTION_HOURS=24
# WORK_DIR_SWEEP_INTERVAL_MINUTES=60
# Срок хранения неиспользуемой записи кеша транскриптов (дни, максимум 365,
# не меньше WORK_DIR_FAILED_RETENTION_HOURS).
# TRANSCRIPT_CACHE_RETENTION_DAYS=30

# Вебхук на терминальные события (опционально; без URL движок работает автономно)
# PLATFORM_CALLBACK_URL=https://platform.example/lecturelog/callback
# LECTURELOG_WEBHOOK_SECRET=
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ python scripts/submit_task.py --base http://my-host:8000/api/v1 status <task_id>
| `DEEPGRAM_LANGUAGE` | Язык Deepgram (по умолчанию `ru`). |
| `DEEPGRAM_DETECT_LANGUAGE` | Автоопределение доминирующего языка (`true`/`false`). При `true` фиксированный `DEEPGRAM_LANGUAGE` не отправляется. |
| `DEEPGRAM_UTT_SPLIT` | Порог паузы utterance в секундах (по умолчанию `0.8`). |
| `TRANSCRIPT_CACHE_DIR` | Кеш транскриптов (перезапуск без повторной транскрипции). По умолчанию `$WORK_DIR/transcript_cache` (в compose это именованный volume `lecturelog-core-transcript-cache`, переживает деплой). Ключ: хеш аудио + провайдер/модель/язык/utt_split. |
| `WORK_DIR_CLEANUP_ENABLED` | TTL-очистка workspace упавших задач (по умолчанию `true`). |
| `WORK_DIR_FAILED_RETENTION_HOURS` | Срок хранения workspace упавшей задачи в часах (по умолчанию `24`). Кеш транскриптов с этим сроком не связан. |
| `TRANSCRIPT_CACHE_RETENTION_DAYS` | Срок хранения записи кеша транскриптов в днях (по умолчанию `30`, максимум `365`). Записи, не использованные дольше срока (mtime; обновляется при каждом попадании), удаляет тот же sweeper. Не может быть меньше `WORK_DIR_FAILED_RETENTION_HOURS`. |
| `WORK_DIR_SWEEP_INTERVAL_MINUTES` | Период прохода sweeper (по умолчанию `60`). |
| `OPENROUTER_API_KEY` | Ключ OpenRouter; LLM-вызовы идут через BYOK Google AI Studio. |
| `OPENROUTER_BASE_URL` | Base URL OpenRouter (по умолчанию `https://openrouter.ai/api/v1`). |
| `VIDEO_TARGET_RESOLUTION` | Целевое разрешение URL-видео: `144..4320` либо `best`; по умолчанию `720`. |
Expand Down
5 changes: 5 additions & 0 deletions deploy/compose.vps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ services:
S3_BUCKET: ${S3_BUCKET:-lectures}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?S3_ACCESS_KEY is required}
S3_SECRET_KEY: ${S3_SECRET_KEY:?S3_SECRET_KEY is required}
volumes:
# Только кеш транскриптов: переживает пересоздание контейнера при деплое.
- lecturelog-core-transcript-cache:/app/data/transcript_cache
ports:
- 127.0.0.1:${CORE_HTTP_PORT:-8000}:8000
depends_on:
Expand Down Expand Up @@ -109,3 +112,5 @@ volumes:
name: lecturelog-core-postgres-data
lecturelog-core-minio-data:
name: lecturelog-core-minio-data
lecturelog-core-transcript-cache:
name: lecturelog-core-transcript-cache
9 changes: 9 additions & 0 deletions deploy/env.core.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ S3_PRESIGN_EXPIRY=3600

MAX_CONCURRENT_TASKS=2

# Очистка диска (необязательно; значения ниже — дефолты).
# TTL workspace упавших задач; кеш транскриптов с ним не связан.
# WORK_DIR_CLEANUP_ENABLED=true
# WORK_DIR_FAILED_RETENTION_HOURS=24
# WORK_DIR_SWEEP_INTERVAL_MINUTES=60
# Срок хранения неиспользуемой записи кеша транскриптов (дни, максимум 365,
# не меньше WORK_DIR_FAILED_RETENTION_HOURS).
# TRANSCRIPT_CACHE_RETENTION_DAYS=30

# URL web-платформы для вебхука ядра. Это не Google OAuth callback.
PLATFORM_CALLBACK_URL=https://app.example.com/webhooks/core

Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,15 @@ services:
S3_SECRET_KEY: ${S3_SECRET_KEY:?S3_SECRET_KEY is required}
# S3_PUBLIC_ENDPOINT не задан -> presigned выключен (автономный режим):
# /uploads и /result-url отдают 409, стрим GET /result работает.
volumes:
# Только кеш транскриптов: он должен переживать пересоздание контейнера
# (деплой нового образа), иначе перезапуск лекции снова платит за STT.
# Остальной WORK_DIR остаётся во writable layer.
- lecturelog-core-transcript-cache:/app/data/transcript_cache
ports:
- "8000:8000"

volumes:
pgdata:
miniodata:
lecturelog-core-transcript-cache:
8 changes: 8 additions & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ public-endpoint, иначе `409`.
навигацию и собственное представление строит платформа. Способы забрать результат —
см. «Два способа забрать результат».

## Транскрипт упавшей задачи

`GET /tasks/{id}/transcript` для задачи в статусе `failed`, у которой транскрипта нет
(не дошла до STT либо workspace удалён по TTL `WORK_DIR_FAILED_RETENTION_HOURS`), отвечает
`410 Gone` с телом `{"error": "transcript_unavailable", "detail": <task.error>}`, а не
`202 in_progress`. Сбой самой транскрибации по-прежнему даёт `409 transcribe_failed`.
Клиент не должен поллить `410`.

## Удаление задачи (идемпотентно)

`DELETE /tasks/{id}` инкапсулирует физику хранения: платформа не знает ключей и
Expand Down
34 changes: 34 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,30 @@
"title": "TranscriptFailedError",
"type": "object"
},
"TranscriptGoneError": {
"properties": {
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Detail"
},
"error": {
"title": "Error",
"type": "string"
}
},
"required": [
"error"
],
"title": "TranscriptGoneError",
"type": "object"
},
"TranscriptInvalidFormatError": {
"properties": {
"allowed": {
Expand Down Expand Up @@ -915,6 +939,16 @@
},
"description": "Транскрибация завершилась ошибкой"
},
"410": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TranscriptGoneError"
}
}
},
"description": "Задача упала, транскрипта нет (не дошла до STT или удалён по TTL)"
},
"422": {
"content": {
"application/json": {
Expand Down
39 changes: 35 additions & 4 deletions lecturelog/api/lifespan.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import asyncio
import logging
import os
from contextlib import asynccontextmanager
from datetime import timedelta
from pathlib import Path

from fastapi import FastAPI
Expand All @@ -15,7 +17,7 @@
)
from lecturelog.application.pipeline_service import PipelineService
from lecturelog.application.progress_plan import ProgressPlan
from lecturelog.application.worker import PipelineWorker
from lecturelog.application.worker import PipelineWorker, WorkDirSweeper
from lecturelog.config.settings import get_config
from lecturelog.infrastructure.export.obsidian_exporter import ObsidianExporter
from lecturelog.infrastructure.frames.provider import VideoFrameProvider
Expand All @@ -32,6 +34,15 @@
logger = logging.getLogger(__name__)


async def _sweep_forever(sweeper: WorkDirSweeper, interval_seconds: float) -> None:
while True:
try:
await sweeper.sweep()
except Exception:
logger.exception("Sweeper: проход завершился ошибкой")
await asyncio.sleep(interval_seconds)


@asynccontextmanager
async def lifespan(app: FastAPI):
cfg = get_config()
Expand All @@ -53,7 +64,12 @@ async def lifespan(app: FastAPI):
cooldown = ModelCooldown()
llm = LlmClient(openai_client, cooldown, max_tokens=cfg.llm.max_tokens)

transcriber = transcriber_factory(cfg.transcribe)
# Локальный эфемерный scratch для внутренних стадий пайплайна (не S3).
work_dir = Path(os.getenv("WORK_DIR", "/app/data"))
# Кеш транскриптов лежит рядом со scratch, но вне каталогов задач: его не сносит
# очистка workspace задачи.
transcript_cache_dir = cfg.transcribe.cache_dir or work_dir / "transcript_cache"
transcriber = transcriber_factory(cfg.transcribe, cache_dir=transcript_cache_dir)
transcribe_model = (
"whisper-large-v3" if cfg.transcribe.provider == "groq" else cfg.transcribe.deepgram_model
)
Expand Down Expand Up @@ -139,19 +155,34 @@ def frames_factory(video_path: Path, srt_path: Path) -> VideoFrameProvider:
worker = PipelineWorker(service=service, concurrency=cfg.worker.max_concurrent_tasks)
await worker.start()

sweeper_task: asyncio.Task | None = None
if cfg.worker.cleanup_enabled:
sweeper = WorkDirSweeper(
repository=repo,
work_root=work_dir,
failed_retention=timedelta(hours=cfg.worker.failed_retention_hours),
transcript_cache_dir=transcript_cache_dir,
cache_retention=timedelta(days=cfg.worker.transcript_cache_retention_days),
)
sweeper_task = asyncio.create_task(
_sweep_forever(sweeper, cfg.worker.sweep_interval_minutes * 60)
)

app.state.config = cfg
app.state.repository = repo
app.state.worker = worker
app.state.storage = storage
app.state.cookie_store = cookie_store
app.state.presign_expiry = cfg.s3.presign_expiry
# Локальный эфемерный scratch для внутренних стадий пайплайна (не S3).
app.state.work_dir = Path(os.getenv("WORK_DIR", "/app/data"))
app.state.work_dir = work_dir
app.state.llm = llm
app.state.prompts_dir = Path("prompts")
try:
yield
finally:
if sweeper_task is not None:
sweeper_task.cancel()
await asyncio.gather(sweeper_task, return_exceptions=True)
await worker.stop()
# Закрываем HTTP-клиент OpenRouter, чтобы не оставлять открытые сокеты.
await openai_client.close()
Expand Down
12 changes: 12 additions & 0 deletions lecturelog/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ResultUrlResponse,
TaskStatusResponse,
TranscriptFailedError,
TranscriptGoneError,
TranscriptInvalidFormatError,
TranscriptNotFoundError,
UploadUrlRequest,
Expand Down Expand Up @@ -318,6 +319,10 @@ async def delete_task(
"model": TranscriptFailedError,
"description": "Транскрибация завершилась ошибкой",
},
410: {
"model": TranscriptGoneError,
"description": "Задача упала, транскрипта нет (не дошла до STT или удалён по TTL)",
},
},
)
async def get_task_transcript(
Expand Down Expand Up @@ -375,6 +380,13 @@ async def get_task_transcript(
headers={"Content-Disposition": 'attachment; filename="transcript.txt"'},
)

if not result.ready and task.status == TaskStatus.FAILED:
# Упавшая задача транскрипт не получит: не маскируем под in_progress.
return JSONResponse(
status_code=410,
content={"error": "transcript_unavailable", "detail": task.error},
)

if not result.ready:
return JSONResponse(
status_code=202,
Expand Down
6 changes: 6 additions & 0 deletions lecturelog/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ class TranscriptFailedError(BaseModel):
detail: str | None = None


class TranscriptGoneError(BaseModel):
# 410: {"error": "transcript_unavailable", "detail": <task.error>}.
error: str
detail: str | None = None


class TaskStatusResponse(BaseModel):
task_id: str
stage: str | None
Expand Down
32 changes: 30 additions & 2 deletions lecturelog/application/factories.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

from lecturelog.config.settings import S3Config, TranscribeConfig
from lecturelog.domain.media_source import MediaSource, is_video_source
from lecturelog.domain.ports import (
Expand All @@ -10,8 +13,9 @@
WebhookNotifier,
)
from lecturelog.infrastructure.storage.s3_storage import S3Storage
from lecturelog.infrastructure.transcribe.cache import CachingTranscriber
from lecturelog.infrastructure.transcribe.deepgram_transcriber import DeepgramTranscriber
from lecturelog.infrastructure.transcribe.groq_transcriber import GroqTranscriber
from lecturelog.infrastructure.transcribe.groq_transcriber import WHISPER_MODEL, GroqTranscriber
from lecturelog.infrastructure.webhook.http_notifier import HttpWebhookNotifier


Expand Down Expand Up @@ -55,7 +59,31 @@ def storage_factory(s3: S3Config) -> Storage:
)


def transcriber_factory(config: TranscribeConfig) -> Transcriber:
def transcribe_cache_settings(config: TranscribeConfig) -> dict[str, Any]:
"""Настройки, влияющие на содержимое транскрипта, — часть ключа кеша.
Секреты и endpoint-регион (не меняют текст) сюда не входят."""
if config.provider == "groq":
return {"provider": "groq", "model": WHISPER_MODEL}
return {
"provider": "deepgram",
"model": config.deepgram_model,
"language": "auto" if config.deepgram_detect_language else config.deepgram_language,
"detect_language": config.deepgram_detect_language,
"utt_split": config.deepgram_utt_split,
}


def transcriber_factory(config: TranscribeConfig, *, cache_dir: Path | None = None) -> Transcriber:
"""Транскрибер по конфигу; с cache_dir — обёрнутый кешем транскриптов."""
transcriber = _build_transcriber(config)
if cache_dir is None:
return transcriber
return CachingTranscriber(
transcriber, cache_dir=cache_dir, settings=transcribe_cache_settings(config)
)


def _build_transcriber(config: TranscribeConfig) -> Transcriber:
if config.provider == "groq":
return GroqTranscriber(groq_api_keys=config.groq_keys)
assert config.deepgram_api_key is not None
Expand Down
Loading
Loading