"""STT 전사 워커 — services/stt(faster-whisper) 호출 + audio_segments 저장. queue_consumer 가 stage='stt' pending 큐 행을 pickup 하여 본 process() 를 호출. services/stt 는 /transcribe {filePath, langs?, beamSize?} → {text, segments, language, language_probability, duration}. 성공 시: - Document.extracted_text = text (기존 classify/embed 파이프 재사용) - Document.extractor_version = "faster-whisper@large-v3" (모델명 기록) - Document.extracted_at = now() - audio_segments INSERT 일괄 (기존 세그먼트는 삭제 후 재삽입, 재전사 대응) audio 파이프라인: file_watcher 가 category='audio' + stage='stt' 등록 → stt → classify → embed/chunk (extract 건너뜀). queue_consumer 의 next_stages 에서 처리. """ from datetime import datetime, timezone from pathlib import Path import httpx from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession from core.config import settings from core.utils import setup_logger from models.audio_segment import AudioSegment from models.document import Document logger = setup_logger("stt_worker") # /transcribe 는 장시간 (30분 녹음 ≈ 수분). 충분히 여유. connect 는 짧게. STT_TIMEOUT = httpx.Timeout(connect=10.0, read=1800.0, write=60.0, pool=10.0) async def process(document_id: int, session: AsyncSession) -> None: """audio 문서 전사 — STT_ENDPOINT 호출 후 텍스트/세그먼트 저장.""" doc = await session.get(Document, document_id) if not doc: logger.error(f"[stt] document_id={document_id} 없음") return if not doc.file_path: logger.warning(f"[stt] id={document_id} file_path 없음 — skip") return # NAS 마운트 경로로 절대화 (services/stt 컨테이너도 동일 경로에 bind mount) container_path = str(Path(settings.nas_mount_path) / doc.file_path) try: async with httpx.AsyncClient(timeout=STT_TIMEOUT) as client: resp = await client.post( f"{settings.stt_endpoint}/transcribe", json={"filePath": container_path}, ) resp.raise_for_status() data = resp.json() except httpx.HTTPError as e: logger.error(f"[stt] id={document_id} 호출 실패: {e}") raise if "error" in data and not data.get("text"): logger.error(f"[stt] id={document_id} 서비스 에러: {data['error']}") raise RuntimeError(f"stt error: {data['error']}") text = (data.get("text") or "").strip() segments = data.get("segments") or [] # 기존 audio_segments 삭제 (재전사 대응) — 새 세그먼트로 교체 await session.execute(delete(AudioSegment).where(AudioSegment.document_id == document_id)) for seg in segments: session.add(AudioSegment( document_id=document_id, start_s=float(seg["start"]), end_s=float(seg["end"]), text=str(seg["text"]), )) doc.extracted_text = text doc.extracted_at = datetime.now(timezone.utc) model_name = None # /ready 응답의 "model" 을 신뢰할 수 있지만, 매 호출마다 조회하지 않고 # 환경에 안 맞으면 /transcribe 응답에서 추론: language / duration 만 쓰고 모델명은 설정 기반 # (services/stt 가 여러 모델 swap 가능해지면 응답에 포함시킬 것) doc.extractor_version = f"faster-whisper@{data.get('language', 'auto')}" logger.info( f"[stt] id={document_id} segments={len(segments)} chars={len(text)} " f"lang={data.get('language')} dur={data.get('duration')}s" )