mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-16 11:58:07 +00:00
930 lines
36 KiB
Python
930 lines
36 KiB
Python
"""Video transcript ingestion, transcription, and analysis service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
from typing import Any, Literal
|
|
from urllib.parse import urlparse
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
from fastapi import UploadFile
|
|
|
|
from server.db.unified_repo import repo
|
|
from server.services.gemini_service import generate_text
|
|
from server.services.text_chunker import _split_into_chunks, smart_chunk
|
|
|
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
|
import ffmpeg
|
|
except ImportError: # pragma: no cover - tests run without multimedia stack installed.
|
|
ffmpeg = None
|
|
|
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
|
import imageio_ffmpeg
|
|
except ImportError: # pragma: no cover - tests run without fallback binary installed.
|
|
imageio_ffmpeg = None
|
|
|
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
|
from faster_whisper import WhisperModel
|
|
except ImportError: # pragma: no cover - tests run without Whisper installed.
|
|
WhisperModel = None
|
|
|
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
|
import srt
|
|
except ImportError: # pragma: no cover - tests run without subtitle stack installed.
|
|
srt = None
|
|
|
|
try: # pragma: no cover - optional dependency is exercised in integration use.
|
|
from yt_dlp import YoutubeDL
|
|
except ImportError: # pragma: no cover - tests run without downloader installed.
|
|
YoutubeDL = None
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_YOUTUBE_CACHE_TTL = 30 * 24 * 60 * 60
|
|
_REMOTE_MAX_BYTES = 500 * 1024 * 1024
|
|
_PENDING_STATUSES = {"queued", "fetching", "transcribing", "analyzing"}
|
|
_WHISPER_MODEL_LOCK = Lock()
|
|
_WHISPER_MODEL: Any | None = None
|
|
_TASKS: dict[str, asyncio.Task[None]] = {}
|
|
|
|
_TEXT_STOPWORDS = {
|
|
"about", "after", "again", "also", "been", "being", "because", "between", "could", "every",
|
|
"from", "have", "into", "just", "like", "more", "most", "only", "other", "over", "should",
|
|
"some", "such", "than", "that", "their", "there", "these", "they", "this", "those", "through",
|
|
"very", "were", "what", "when", "where", "which", "while", "with", "would", "your", "ourselves",
|
|
"ours", "you", "them", "then", "well", "will", "call", "speaker", "question", "answer", "video",
|
|
}
|
|
_POSITIVE_WORDS = {"growth", "strong", "improve", "upside", "record", "bullish", "accelerate", "opportunity"}
|
|
_NEGATIVE_WORDS = {"risk", "weak", "decline", "pressure", "downside", "loss", "uncertain", "headwind"}
|
|
_YOUTUBE_HOSTS = {
|
|
"youtube.com",
|
|
"www.youtube.com",
|
|
"m.youtube.com",
|
|
"youtu.be",
|
|
"www.youtu.be",
|
|
}
|
|
_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"}
|
|
_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".webm"}
|
|
_CAPTION_EXTENSIONS = {".srt", ".vtt"}
|
|
|
|
|
|
@dataclass
|
|
class SourceMaterial:
|
|
"""Resolved source asset for one transcript job."""
|
|
|
|
title: str | None = None
|
|
duration_sec: int | None = None
|
|
language: str | None = None
|
|
transcript_text: str | None = None
|
|
media_path: Path | None = None
|
|
cleanup_paths: list[Path] = field(default_factory=list)
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _resolved_gemini_api_key(api_key: str | None = None) -> str | None:
|
|
resolved = (api_key or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or "").strip()
|
|
return resolved or None
|
|
|
|
|
|
def _cache_key(source_url: str) -> str:
|
|
return f"video-transcript:{source_url.strip()}"
|
|
|
|
|
|
def _display_name_from_source(source_url: str) -> str:
|
|
parsed = urlparse(source_url)
|
|
if parsed.scheme:
|
|
if parsed.path:
|
|
tail = Path(parsed.path).name
|
|
return tail or parsed.netloc or source_url
|
|
return parsed.netloc or source_url
|
|
return Path(source_url).expanduser().name or source_url
|
|
|
|
|
|
def _detect_source_type(url_or_path: str, source_type: Literal["youtube", "url", "local"]) -> Literal["youtube", "url", "local"]:
|
|
candidate = (url_or_path or "").strip()
|
|
if source_type == "youtube":
|
|
return "youtube"
|
|
if source_type == "local":
|
|
return "local"
|
|
parsed = urlparse(candidate)
|
|
if parsed.scheme in {"http", "https"} and parsed.netloc.lower() in _YOUTUBE_HOSTS:
|
|
return "youtube"
|
|
if parsed.scheme in {"http", "https"}:
|
|
return "url"
|
|
if Path(candidate).expanduser().exists():
|
|
return "local"
|
|
return source_type
|
|
|
|
|
|
def _is_caption_file(path: Path) -> bool:
|
|
return path.suffix.lower() in _CAPTION_EXTENSIONS
|
|
|
|
|
|
def _requires_audio_extraction(path: Path) -> bool:
|
|
return path.suffix.lower() in _VIDEO_EXTENSIONS
|
|
|
|
|
|
def _is_supported_remote_content(content_type: str, suffix: str) -> bool:
|
|
if suffix in _CAPTION_EXTENSIONS | _VIDEO_EXTENSIONS | _AUDIO_EXTENSIONS:
|
|
return True
|
|
if content_type.startswith("video/") or content_type.startswith("audio/"):
|
|
return True
|
|
return content_type in {"text/plain", "text/vtt", "application/x-subrip", "application/octet-stream"}
|
|
|
|
|
|
def _infer_suffix(source_url: str, content_type: str) -> str:
|
|
parsed = urlparse(source_url)
|
|
suffix = Path(parsed.path).suffix.lower()
|
|
if suffix:
|
|
return suffix
|
|
mapping = {
|
|
"video/mp4": ".mp4",
|
|
"video/quicktime": ".mov",
|
|
"audio/mpeg": ".mp3",
|
|
"audio/mp4": ".m4a",
|
|
"audio/x-m4a": ".m4a",
|
|
"text/plain": ".txt",
|
|
"text/vtt": ".vtt",
|
|
"application/x-subrip": ".srt",
|
|
}
|
|
return mapping.get(content_type, ".bin")
|
|
|
|
|
|
def _cleanup_paths(paths: list[Path]) -> None:
|
|
seen: set[str] = set()
|
|
for path in paths:
|
|
raw = str(path)
|
|
if not raw or raw in seen:
|
|
continue
|
|
seen.add(raw)
|
|
if path.is_dir():
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
else:
|
|
with suppress(FileNotFoundError):
|
|
path.unlink()
|
|
|
|
|
|
def _resolve_ffmpeg_binary() -> str:
|
|
binary = shutil.which("ffmpeg")
|
|
if binary:
|
|
return binary
|
|
if imageio_ffmpeg is not None:
|
|
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
raise RuntimeError(
|
|
"ffmpeg is not installed. Install Homebrew ffmpeg or add `imageio-ffmpeg` to the Python environment."
|
|
)
|
|
|
|
|
|
def _persist_upload(upload: UploadFile, target_path: Path) -> None:
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
upload.file.seek(0)
|
|
with target_path.open("wb") as handle:
|
|
shutil.copyfileobj(upload.file, handle)
|
|
|
|
|
|
def _pick_caption_file(directory: Path) -> Path | None:
|
|
candidates = [path for path in directory.iterdir() if path.suffix.lower() in _CAPTION_EXTENSIONS]
|
|
if not candidates:
|
|
return None
|
|
|
|
def _score(path: Path) -> tuple[int, str]:
|
|
name = path.name.lower()
|
|
if ".ko" in name:
|
|
return (0, name)
|
|
if ".en" in name:
|
|
return (1, name)
|
|
return (2, name)
|
|
|
|
return sorted(candidates, key=_score)[0]
|
|
|
|
|
|
def _language_from_filename(path: Path) -> str | None:
|
|
name = path.name.lower()
|
|
if ".ko" in name:
|
|
return "ko"
|
|
if ".en" in name:
|
|
return "en"
|
|
return None
|
|
|
|
|
|
def _parse_vtt_text(vtt_content: str) -> str:
|
|
lines: list[str] = []
|
|
for raw_line in vtt_content.splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line == "WEBVTT" or "-->" in line:
|
|
continue
|
|
if line.isdigit() or line.startswith(("NOTE", "Kind:", "Language:")):
|
|
continue
|
|
lines.append(line)
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def _parse_caption_text(content: str, suffix: str) -> str:
|
|
if suffix == ".vtt":
|
|
return _parse_vtt_text(content)
|
|
return _parse_srt_text(content)
|
|
|
|
|
|
def _extract_json_object(text: str) -> dict[str, Any] | None:
|
|
if not text or not text.strip():
|
|
return None
|
|
cleaned = re.sub(r"^```(?:json)?\s*", "", text.strip())
|
|
cleaned = re.sub(r"\s*```$", "", cleaned).strip()
|
|
try:
|
|
parsed = json.loads(cleaned)
|
|
except json.JSONDecodeError:
|
|
match = re.search(r"\{.*\}", cleaned, flags=re.DOTALL)
|
|
if not match:
|
|
return None
|
|
try:
|
|
parsed = json.loads(match.group(0))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
return parsed if isinstance(parsed, dict) else None
|
|
|
|
|
|
def _top_terms(text: str, limit: int = 6) -> list[str]:
|
|
counter: dict[str, int] = {}
|
|
for token in re.findall(r"[A-Za-z][A-Za-z\-]{3,}", text.lower()):
|
|
if token in _TEXT_STOPWORDS:
|
|
continue
|
|
counter[token] = counter.get(token, 0) + 1
|
|
ranked = sorted(counter.items(), key=lambda item: (-item[1], item[0]))
|
|
return [token for token, _count in ranked[:limit]]
|
|
|
|
|
|
def _fallback_analysis(text: str) -> dict[str, Any]:
|
|
excerpt = smart_chunk(text.strip(), max_chars=720)
|
|
positives = sum(text.lower().count(word) for word in _POSITIVE_WORDS)
|
|
negatives = sum(text.lower().count(word) for word in _NEGATIVE_WORDS)
|
|
if positives > negatives:
|
|
sentiment = "positive"
|
|
elif negatives > positives:
|
|
sentiment = "negative"
|
|
else:
|
|
sentiment = "neutral"
|
|
keywords = _top_terms(text, limit=6)
|
|
return {
|
|
"summary": excerpt or "Transcript extracted, but AI summary is unavailable.",
|
|
"keywords": keywords,
|
|
"topics": keywords[:4],
|
|
"sentiment": sentiment,
|
|
"intent": "Review transcript manually or add a Gemini API key for richer analysis.",
|
|
}
|
|
|
|
|
|
def _language_display_name(target_language: str) -> str:
|
|
return {
|
|
"ko": "Korean",
|
|
"ja": "Japanese",
|
|
"en": "English",
|
|
}.get(target_language.lower(), target_language.upper())
|
|
|
|
|
|
def _normalise_analysis(payload: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
|
|
keywords = payload.get("keywords")
|
|
topics = payload.get("topics")
|
|
sentiment = str(payload.get("sentiment") or fallback["sentiment"]).strip().lower()
|
|
if sentiment not in {"positive", "neutral", "negative"}:
|
|
sentiment = fallback["sentiment"]
|
|
return {
|
|
"summary": str(payload.get("summary") or fallback["summary"]).strip(),
|
|
"keywords": [str(item).strip() for item in (keywords if isinstance(keywords, list) else fallback["keywords"]) if str(item).strip()],
|
|
"topics": [str(item).strip() for item in (topics if isinstance(topics, list) else fallback["topics"]) if str(item).strip()],
|
|
"sentiment": sentiment,
|
|
"intent": str(payload.get("intent") or fallback["intent"]).strip(),
|
|
}
|
|
|
|
|
|
def _get_whisper_model() -> Any:
|
|
global _WHISPER_MODEL
|
|
if _WHISPER_MODEL is not None:
|
|
return _WHISPER_MODEL
|
|
if WhisperModel is None:
|
|
raise RuntimeError("faster-whisper is not installed. Run `pip install -r requirements.txt`.")
|
|
with _WHISPER_MODEL_LOCK:
|
|
if _WHISPER_MODEL is None:
|
|
_WHISPER_MODEL = WhisperModel(
|
|
os.getenv("WHISPER_MODEL_SIZE", "base"),
|
|
device="cpu",
|
|
compute_type=os.getenv("WHISPER_COMPUTE_TYPE", "int8"),
|
|
)
|
|
return _WHISPER_MODEL
|
|
|
|
|
|
async def submit_job(
|
|
url_or_path: str,
|
|
source_type: Literal["youtube", "url", "local"],
|
|
*,
|
|
language: str | None = None,
|
|
api_key: str | None = None,
|
|
source_label: str | None = None,
|
|
cleanup_source: bool = False,
|
|
) -> str:
|
|
"""Create a new transcript job and start asynchronous processing."""
|
|
|
|
resolved_source_type = _detect_source_type(url_or_path, source_type)
|
|
source_value = url_or_path.strip() if resolved_source_type != "local" else str(Path(url_or_path).expanduser())
|
|
job_id = uuid4().hex
|
|
await repo.add_video_job(job_id, source_value, resolved_source_type)
|
|
if source_label:
|
|
await repo.update_video_job(job_id, title=source_label)
|
|
|
|
task = asyncio.create_task(
|
|
_process_job(
|
|
job_id,
|
|
language=language,
|
|
api_key=(api_key or "").strip() or None,
|
|
source_label=source_label,
|
|
cleanup_source=cleanup_source,
|
|
)
|
|
)
|
|
_TASKS[job_id] = task
|
|
task.add_done_callback(lambda _: _TASKS.pop(job_id, None))
|
|
return job_id
|
|
|
|
|
|
async def submit_upload(upload: UploadFile, *, language: str | None = None, api_key: str | None = None) -> str:
|
|
"""Persist a multipart upload locally and enqueue it as a transcript job."""
|
|
|
|
temp_dir = Path(tempfile.mkdtemp(prefix="atlas-video-upload-"))
|
|
suffix = Path(upload.filename or "upload.bin").suffix
|
|
target_path = temp_dir / f"upload{suffix}"
|
|
await asyncio.to_thread(_persist_upload, upload, target_path)
|
|
await upload.close()
|
|
return await submit_job(
|
|
str(target_path),
|
|
"local",
|
|
language=language,
|
|
api_key=api_key,
|
|
source_label=upload.filename or target_path.name,
|
|
cleanup_source=True,
|
|
)
|
|
|
|
|
|
async def list_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
|
return await repo.list_video_jobs(limit=limit)
|
|
|
|
|
|
async def get_job_detail(job_id: str) -> dict[str, Any] | None:
|
|
row = await repo.get_video_job(job_id)
|
|
if row is None:
|
|
return None
|
|
return {
|
|
"job": {
|
|
"job_id": row["job_id"],
|
|
"status": row["status"],
|
|
"source_url": row["source_url"],
|
|
"source_type": row["source_type"],
|
|
"progress": row.get("progress", 0),
|
|
"error": row.get("error"),
|
|
"title": row.get("title"),
|
|
"duration_sec": row.get("duration_sec"),
|
|
"language": row.get("language"),
|
|
"created_at": row["created_at"],
|
|
"completed_at": row.get("completed_at"),
|
|
},
|
|
"transcript": None if not row.get("transcript_text") else {
|
|
"job_id": row["job_id"],
|
|
"text": row.get("transcript_text", ""),
|
|
"summary": row.get("summary"),
|
|
"keywords": row.get("keywords", []),
|
|
"topics": row.get("topics", []),
|
|
"sentiment": row.get("sentiment"),
|
|
"intent": row.get("intent"),
|
|
},
|
|
}
|
|
|
|
|
|
async def search_jobs(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
|
return await repo.search_videos(query, limit=limit)
|
|
|
|
|
|
async def delete_job(job_id: str) -> bool:
|
|
task = _TASKS.pop(job_id, None)
|
|
if task is not None and not task.done():
|
|
task.cancel()
|
|
with suppress(asyncio.CancelledError, Exception):
|
|
await task
|
|
return await repo.delete_video_job(job_id)
|
|
|
|
|
|
async def translate_job_content(
|
|
job_id: str,
|
|
*,
|
|
target_language: str = "ko",
|
|
api_key: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Translate a stored transcript payload on demand."""
|
|
|
|
row = await repo.get_video_job(job_id)
|
|
if row is None:
|
|
return None
|
|
|
|
transcript_text = (row.get("transcript_text") or "").strip()
|
|
if not transcript_text:
|
|
raise ValueError("Transcript text is not available yet.")
|
|
|
|
resolved_api_key = _resolved_gemini_api_key(api_key)
|
|
if not resolved_api_key:
|
|
raise RuntimeError("Set a Gemini API key in Settings to use Korean translation.")
|
|
|
|
target_label = _language_display_name(target_language)
|
|
translated_text = await _translate_text_chunks(
|
|
transcript_text,
|
|
target_language=target_language,
|
|
api_key=resolved_api_key,
|
|
)
|
|
|
|
source_payload = {
|
|
"summary": row.get("summary") or "",
|
|
"keywords": row.get("keywords", []),
|
|
"topics": row.get("topics", []),
|
|
"intent": row.get("intent") or "",
|
|
}
|
|
meta_prompt = (
|
|
f"Translate the following analyst metadata into natural {target_label}.\n"
|
|
"Return ONLY valid JSON with this exact shape:\n"
|
|
'{"summary":"...","keywords":["..."],"topics":["..."],"intent":"..."}\n'
|
|
"- Preserve company names, proper nouns, and figures.\n"
|
|
"- Translate keywords and topics into concise Korean finance/media terms.\n"
|
|
"- Do not add markdown fences or commentary.\n\n"
|
|
f"SOURCE_JSON:\n{json.dumps(source_payload, ensure_ascii=False)}"
|
|
)
|
|
translated_meta = _extract_json_object(
|
|
await generate_text(meta_prompt, temperature=0.2, max_tokens=1200, api_key=resolved_api_key)
|
|
) or {}
|
|
|
|
translated_summary = str(translated_meta.get("summary") or "").strip()
|
|
if not translated_summary:
|
|
translated_summary = await _translate_text_chunks(
|
|
source_payload["summary"] or smart_chunk(transcript_text, max_chars=1500),
|
|
target_language=target_language,
|
|
api_key=resolved_api_key,
|
|
max_chars=4_500,
|
|
min_chunk=1_200,
|
|
)
|
|
|
|
return {
|
|
"job_id": job_id,
|
|
"target_language": target_language,
|
|
"summary": translated_summary,
|
|
"keywords": [str(item).strip() for item in (translated_meta.get("keywords") or source_payload["keywords"]) if str(item).strip()],
|
|
"topics": [str(item).strip() for item in (translated_meta.get("topics") or source_payload["topics"]) if str(item).strip()],
|
|
"intent": str(translated_meta.get("intent") or source_payload["intent"]).strip(),
|
|
"text": translated_text,
|
|
}
|
|
|
|
|
|
async def _process_job(
|
|
job_id: str,
|
|
*,
|
|
language: str | None = None,
|
|
api_key: str | None = None,
|
|
source_label: str | None = None,
|
|
cleanup_source: bool = False,
|
|
) -> None:
|
|
"""Resolve source media, extract transcript text, run analysis, and persist results."""
|
|
|
|
row = await repo.get_video_job(job_id)
|
|
if row is None:
|
|
return
|
|
|
|
cleanup_paths: list[Path] = []
|
|
try:
|
|
source_url = row["source_url"]
|
|
source_type = _detect_source_type(source_url, row["source_type"])
|
|
await repo.update_video_job(job_id, source_type=source_type, status="fetching", progress=15, error=None)
|
|
|
|
if source_type == "youtube":
|
|
cached = await repo.cache_get(_cache_key(source_url))
|
|
if isinstance(cached, dict) and cached.get("transcript_text"):
|
|
await repo.update_video_job(
|
|
job_id,
|
|
title=source_label or cached.get("title"),
|
|
duration_sec=cached.get("duration_sec"),
|
|
language=cached.get("language"),
|
|
transcript_text=cached.get("transcript_text"),
|
|
summary=cached.get("summary"),
|
|
keywords=cached.get("keywords", []),
|
|
topics=cached.get("topics", []),
|
|
sentiment=cached.get("sentiment"),
|
|
intent=cached.get("intent"),
|
|
status="completed",
|
|
progress=100,
|
|
completed_at=_now_iso(),
|
|
error=None,
|
|
)
|
|
return
|
|
material = await _fetch_youtube_source(source_url, preferred_language=language)
|
|
elif source_type == "local":
|
|
material = await _prepare_local_source(source_url, owned_source=cleanup_source)
|
|
else:
|
|
material = await _fetch_remote_source(source_url)
|
|
|
|
cleanup_paths.extend(material.cleanup_paths)
|
|
title = source_label or material.title or row.get("title") or _display_name_from_source(source_url)
|
|
detected_language = material.language or language
|
|
duration_sec = material.duration_sec
|
|
|
|
await repo.update_video_job(
|
|
job_id,
|
|
title=title,
|
|
duration_sec=duration_sec,
|
|
language=detected_language,
|
|
status="transcribing",
|
|
progress=45,
|
|
)
|
|
|
|
transcript_text = (material.transcript_text or "").strip()
|
|
if not transcript_text:
|
|
if material.media_path is None:
|
|
raise RuntimeError("No subtitle track or downloadable media was found for this source.")
|
|
transcribe_path = material.media_path
|
|
if _requires_audio_extraction(material.media_path):
|
|
transcribe_path = await asyncio.to_thread(_extract_audio_track, material.media_path)
|
|
cleanup_paths.append(transcribe_path)
|
|
whisper_result = await asyncio.to_thread(_whisper_transcribe, str(transcribe_path), language)
|
|
transcript_text = (whisper_result.get("text") or "").strip()
|
|
detected_language = whisper_result.get("language") or detected_language
|
|
duration_value = whisper_result.get("duration_sec") or duration_sec
|
|
duration_sec = int(round(duration_value)) if duration_value else duration_sec
|
|
|
|
if not transcript_text:
|
|
raise RuntimeError("Transcript extraction finished, but no text was produced.")
|
|
|
|
await repo.update_video_job(
|
|
job_id,
|
|
title=title,
|
|
duration_sec=duration_sec,
|
|
language=detected_language,
|
|
transcript_text=transcript_text,
|
|
progress=65,
|
|
)
|
|
|
|
await repo.update_video_job(job_id, status="analyzing", progress=80)
|
|
analysis = await _analyze_text(transcript_text, api_key=api_key, title=title)
|
|
completed_at = _now_iso()
|
|
await repo.update_video_job(
|
|
job_id,
|
|
status="completed",
|
|
progress=100,
|
|
completed_at=completed_at,
|
|
error=None,
|
|
title=title,
|
|
duration_sec=duration_sec,
|
|
language=detected_language,
|
|
transcript_text=transcript_text,
|
|
summary=analysis["summary"],
|
|
keywords=analysis["keywords"],
|
|
topics=analysis["topics"],
|
|
sentiment=analysis["sentiment"],
|
|
intent=analysis["intent"],
|
|
)
|
|
|
|
if source_type == "youtube":
|
|
await repo.cache_set(
|
|
_cache_key(source_url),
|
|
{
|
|
"title": title,
|
|
"duration_sec": duration_sec,
|
|
"language": detected_language,
|
|
"transcript_text": transcript_text,
|
|
"summary": analysis["summary"],
|
|
"keywords": analysis["keywords"],
|
|
"topics": analysis["topics"],
|
|
"sentiment": analysis["sentiment"],
|
|
"intent": analysis["intent"],
|
|
"completed_at": completed_at,
|
|
},
|
|
ttl=_YOUTUBE_CACHE_TTL,
|
|
)
|
|
except asyncio.CancelledError:
|
|
logger.info("Video transcript job %s cancelled.", job_id)
|
|
raise
|
|
except Exception as exc:
|
|
logger.exception("Video transcript job %s failed", job_id)
|
|
await repo.update_video_job(
|
|
job_id,
|
|
status="failed",
|
|
progress=100,
|
|
error=str(exc),
|
|
completed_at=_now_iso(),
|
|
)
|
|
finally:
|
|
await asyncio.to_thread(_cleanup_paths, cleanup_paths)
|
|
|
|
|
|
async def _prepare_local_source(source_path: str, *, owned_source: bool = False) -> SourceMaterial:
|
|
path = Path(source_path).expanduser()
|
|
if not path.exists():
|
|
raise RuntimeError(f"Local file not found: {path}")
|
|
cleanup_paths = [path.parent] if owned_source else []
|
|
if _is_caption_file(path):
|
|
content = await asyncio.to_thread(path.read_text, encoding="utf-8", errors="ignore")
|
|
return SourceMaterial(
|
|
title=path.name,
|
|
language=_language_from_filename(path),
|
|
transcript_text=_parse_caption_text(content, path.suffix.lower()),
|
|
cleanup_paths=cleanup_paths,
|
|
)
|
|
return SourceMaterial(
|
|
title=path.name,
|
|
media_path=path,
|
|
cleanup_paths=cleanup_paths,
|
|
)
|
|
|
|
|
|
async def _fetch_remote_source(source_url: str) -> SourceMaterial:
|
|
if _detect_source_type(source_url, "url") == "youtube":
|
|
return await _fetch_youtube_source(source_url)
|
|
|
|
temp_dir = Path(tempfile.mkdtemp(prefix="atlas-video-remote-"))
|
|
content_type = ""
|
|
target_path = temp_dir / "download.bin"
|
|
try:
|
|
async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(60.0, connect=20.0)) as client:
|
|
async with client.stream("GET", source_url) as response:
|
|
response.raise_for_status()
|
|
content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
|
|
content_length = int(response.headers.get("content-length") or 0)
|
|
if content_length and content_length > _REMOTE_MAX_BYTES:
|
|
raise RuntimeError("Remote media is larger than the 500MB safety limit.")
|
|
suffix = _infer_suffix(source_url, content_type)
|
|
if not _is_supported_remote_content(content_type, suffix):
|
|
raise RuntimeError(f"Unsupported remote content type: {content_type or suffix}")
|
|
target_path = temp_dir / f"download{suffix}"
|
|
total_bytes = 0
|
|
with target_path.open("wb") as handle:
|
|
async for chunk in response.aiter_bytes():
|
|
total_bytes += len(chunk)
|
|
if total_bytes > _REMOTE_MAX_BYTES:
|
|
raise RuntimeError("Remote media exceeded the 500MB safety limit while downloading.")
|
|
handle.write(chunk)
|
|
except Exception:
|
|
await asyncio.to_thread(_cleanup_paths, [temp_dir])
|
|
raise
|
|
|
|
if _is_caption_file(target_path):
|
|
content = await asyncio.to_thread(target_path.read_text, encoding="utf-8", errors="ignore")
|
|
return SourceMaterial(
|
|
title=_display_name_from_source(source_url),
|
|
language=_language_from_filename(target_path),
|
|
transcript_text=_parse_caption_text(content, target_path.suffix.lower()),
|
|
cleanup_paths=[temp_dir],
|
|
)
|
|
|
|
return SourceMaterial(
|
|
title=_display_name_from_source(source_url),
|
|
media_path=target_path,
|
|
cleanup_paths=[temp_dir],
|
|
)
|
|
|
|
|
|
async def _fetch_youtube_source(source_url: str, preferred_language: str | None = None) -> SourceMaterial:
|
|
temp_dir = Path(tempfile.mkdtemp(prefix="atlas-video-youtube-"))
|
|
try:
|
|
info: dict[str, Any] = {}
|
|
try:
|
|
info = await asyncio.to_thread(_download_youtube_subtitles, source_url, temp_dir, preferred_language)
|
|
except Exception:
|
|
logger.warning("YouTube subtitle download failed for %s; falling back to audio transcription.", source_url, exc_info=True)
|
|
title = str(info.get("title") or _display_name_from_source(source_url))
|
|
duration_sec = int(info.get("duration")) if info.get("duration") else None
|
|
caption_path = _pick_caption_file(temp_dir)
|
|
if caption_path is not None:
|
|
content = await asyncio.to_thread(caption_path.read_text, encoding="utf-8", errors="ignore")
|
|
return SourceMaterial(
|
|
title=title,
|
|
duration_sec=duration_sec,
|
|
language=_language_from_filename(caption_path),
|
|
transcript_text=_parse_caption_text(content, caption_path.suffix.lower()),
|
|
cleanup_paths=[temp_dir],
|
|
)
|
|
|
|
media_path, audio_info = await asyncio.to_thread(_download_youtube_audio, source_url, temp_dir)
|
|
return SourceMaterial(
|
|
title=str(audio_info.get("title") or title),
|
|
duration_sec=int(audio_info.get("duration")) if audio_info.get("duration") else duration_sec,
|
|
media_path=media_path,
|
|
cleanup_paths=[temp_dir],
|
|
)
|
|
except Exception:
|
|
await asyncio.to_thread(_cleanup_paths, [temp_dir])
|
|
raise
|
|
|
|
|
|
def _normalise_subtitle_languages(preferred_language: str | None = None) -> list[str]:
|
|
ordered: list[str] = []
|
|
for value in [preferred_language, "en", "ko", "en-US", "ko-KR"]:
|
|
candidate = (value or "").strip()
|
|
if candidate and candidate not in ordered:
|
|
ordered.append(candidate)
|
|
return ordered
|
|
|
|
|
|
def _download_youtube_subtitles(source_url: str, output_dir: Path, preferred_language: str | None = None) -> dict[str, Any]:
|
|
if YoutubeDL is None:
|
|
raise RuntimeError("yt-dlp is not installed. Run `pip install -r requirements.txt`.")
|
|
last_error: Exception | None = None
|
|
for language in _normalise_subtitle_languages(preferred_language):
|
|
options = {
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"noprogress": True,
|
|
"noplaylist": True,
|
|
"skip_download": True,
|
|
"writesubtitles": True,
|
|
"writeautomaticsub": True,
|
|
"subtitleslangs": [language],
|
|
"subtitlesformat": "srt/vtt/best",
|
|
"outtmpl": str(output_dir / "%(id)s.%(ext)s"),
|
|
}
|
|
try:
|
|
with YoutubeDL(options) as ydl:
|
|
info = ydl.extract_info(source_url, download=True)
|
|
if _pick_caption_file(output_dir) is not None:
|
|
return info
|
|
except Exception as exc:
|
|
last_error = exc
|
|
logger.warning("Subtitle attempt failed for language %s on %s", language, source_url, exc_info=True)
|
|
|
|
if last_error is not None:
|
|
raise last_error
|
|
return {}
|
|
|
|
|
|
def _download_youtube_audio(source_url: str, output_dir: Path) -> tuple[Path, dict[str, Any]]:
|
|
if YoutubeDL is None:
|
|
raise RuntimeError("yt-dlp is not installed. Run `pip install -r requirements.txt`.")
|
|
options = {
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"noprogress": True,
|
|
"noplaylist": True,
|
|
"format": "bestaudio[ext=m4a]/bestaudio/best",
|
|
"outtmpl": str(output_dir / "%(id)s.%(ext)s"),
|
|
}
|
|
with YoutubeDL(options) as ydl:
|
|
info = ydl.extract_info(source_url, download=True)
|
|
return Path(ydl.prepare_filename(info)), info
|
|
|
|
|
|
def _extract_audio_track(media_path: Path) -> Path:
|
|
if ffmpeg is None:
|
|
raise RuntimeError("ffmpeg-python is not installed. Run `pip install -r requirements.txt`.")
|
|
output_path = media_path.parent / f"{media_path.stem}.atlas.wav"
|
|
ffmpeg_binary = _resolve_ffmpeg_binary()
|
|
try:
|
|
stream = ffmpeg.input(str(media_path))
|
|
stream = ffmpeg.output(stream, str(output_path), acodec="pcm_s16le", ac=1, ar="16000", format="wav")
|
|
ffmpeg.run(stream.overwrite_output(), cmd=ffmpeg_binary, capture_stdout=True, capture_stderr=True)
|
|
except ffmpeg.Error as exc: # type: ignore[attr-defined]
|
|
stderr = exc.stderr.decode("utf-8", errors="ignore") if getattr(exc, "stderr", None) else str(exc)
|
|
raise RuntimeError(f"ffmpeg failed to extract audio: {stderr.strip()}") from exc
|
|
return output_path
|
|
|
|
|
|
def _whisper_transcribe(audio_path: str, language: str | None = None) -> dict[str, Any]:
|
|
"""Transcribe audio or video with a lazily loaded faster-whisper model."""
|
|
|
|
model = _get_whisper_model()
|
|
segments, info = model.transcribe(audio_path, language=language, vad_filter=True, beam_size=5)
|
|
parsed_segments: list[dict[str, Any]] = []
|
|
for segment in segments:
|
|
text = (segment.text or "").strip()
|
|
if not text:
|
|
continue
|
|
parsed_segments.append(
|
|
{
|
|
"start": round(float(segment.start), 2),
|
|
"end": round(float(segment.end), 2),
|
|
"text": text,
|
|
}
|
|
)
|
|
full_text = " ".join(segment["text"] for segment in parsed_segments).strip()
|
|
duration_sec = getattr(info, "duration", None) or (parsed_segments[-1]["end"] if parsed_segments else 0)
|
|
return {
|
|
"text": full_text,
|
|
"language": getattr(info, "language", None) or language,
|
|
"duration_sec": duration_sec,
|
|
"segments": parsed_segments,
|
|
}
|
|
|
|
|
|
def _parse_srt_text(srt_content: str) -> str:
|
|
"""Strip timing metadata and join subtitle text into a clean transcript."""
|
|
|
|
if not srt_content.strip():
|
|
return ""
|
|
if srt is None:
|
|
cleaned = re.sub(r"\d+\s+\d{2}:\d{2}:\d{2},\d{3}\s+-->\s+\d{2}:\d{2}:\d{2},\d{3}", "", srt_content)
|
|
cleaned = re.sub(r"^\d+\s*$", "", cleaned, flags=re.MULTILINE)
|
|
return "\n".join(line.strip() for line in cleaned.splitlines() if line.strip())
|
|
|
|
subtitles = []
|
|
for item in srt.parse(srt_content):
|
|
text = " ".join(line.strip() for line in item.content.splitlines() if line.strip()).strip()
|
|
if text:
|
|
subtitles.append(text)
|
|
return "\n".join(subtitles)
|
|
|
|
|
|
async def _analyze_text(text: str, *, api_key: str | None = None, title: str | None = None) -> dict[str, Any]:
|
|
"""Generate a summary, topics, and sentiment for a transcript."""
|
|
|
|
fallback = _fallback_analysis(text)
|
|
chunk_candidates = _split_into_chunks(text, max_chars=10_000, min_chunk=3_000)
|
|
chunks = [smart_chunk(chunk, max_chars=10_000) for chunk in chunk_candidates if chunk.strip()] or [smart_chunk(text, max_chars=10_000)]
|
|
|
|
try:
|
|
partial_summaries: list[str] = []
|
|
for index, chunk in enumerate(chunks, start=1):
|
|
prompt = (
|
|
"You are preparing analyst notes from one transcript chunk.\n"
|
|
f"Chunk {index} of {len(chunks)} for: {title or 'Untitled media'}\n\n"
|
|
"Return 4-6 concise bullet points covering factual takeaways, recurring themes, risks, and speaker tone.\n"
|
|
"Do not invent facts. Keep names, products, and figures exact when present.\n\n"
|
|
f"TRANSCRIPT CHUNK:\n{chunk}"
|
|
)
|
|
partial_summaries.append(
|
|
await generate_text(prompt, temperature=0.2, max_tokens=700, api_key=api_key)
|
|
)
|
|
|
|
summary_blob = "\n\n".join(f"Chunk {idx} notes:\n{summary.strip()}" for idx, summary in enumerate(partial_summaries, start=1))
|
|
head_sample = smart_chunk(text[:10_000], max_chars=2_000)
|
|
tail_sample = smart_chunk(text[-10_000:], max_chars=2_000)
|
|
final_prompt = (
|
|
"You are an expert media and transcript analyst.\n"
|
|
"Use the chunk notes and transcript samples below.\n"
|
|
"Return ONLY valid JSON with this exact shape:\n"
|
|
'{"summary":"...","keywords":["..."],"topics":["..."],"sentiment":"positive|neutral|negative","intent":"..."}\n'
|
|
"Rules:\n"
|
|
"- summary: 3-5 sentences, specific and factual\n"
|
|
"- keywords: 5-8 concise phrases\n"
|
|
"- topics: 3-5 broader themes\n"
|
|
"- sentiment: choose only positive, neutral, or negative\n"
|
|
"- intent: one concise sentence describing what the speaker or content is trying to achieve\n"
|
|
"- Do not add markdown fences or commentary.\n\n"
|
|
f"TITLE: {title or 'Untitled media'}\n\n"
|
|
f"CHUNK NOTES:\n{summary_blob}\n\n"
|
|
f"HEAD SAMPLE:\n{head_sample}\n\n"
|
|
f"TAIL SAMPLE:\n{tail_sample}"
|
|
)
|
|
parsed = _extract_json_object(
|
|
await generate_text(final_prompt, temperature=0.2, max_tokens=1200, api_key=api_key)
|
|
)
|
|
if parsed is None:
|
|
return fallback
|
|
return _normalise_analysis(parsed, fallback)
|
|
except Exception:
|
|
logger.warning("Falling back to heuristic transcript analysis.", exc_info=True)
|
|
return fallback
|
|
|
|
|
|
async def _translate_text_chunks(
|
|
text: str,
|
|
*,
|
|
target_language: str,
|
|
api_key: str,
|
|
max_chars: int = 7_000,
|
|
min_chunk: int = 2_000,
|
|
) -> str:
|
|
"""Translate long text in chunks while preserving structure."""
|
|
|
|
source = text.strip()
|
|
if not source:
|
|
return ""
|
|
|
|
target_label = _language_display_name(target_language)
|
|
chunks = _split_into_chunks(source, max_chars=max_chars, min_chunk=min_chunk) or [source]
|
|
translated_chunks: list[str] = []
|
|
for index, chunk in enumerate(chunks, start=1):
|
|
prompt = (
|
|
f"Translate the following transcript chunk into natural {target_label}.\n"
|
|
"- Preserve meaning, numbers, names, and paragraph breaks.\n"
|
|
"- Keep speaker turns and emphasis where obvious.\n"
|
|
"- Return ONLY the translated text.\n"
|
|
f"- This is chunk {index} of {len(chunks)}.\n\n"
|
|
f"TRANSCRIPT CHUNK:\n{chunk}"
|
|
)
|
|
translated_chunks.append(
|
|
(await generate_text(prompt, temperature=0.2, max_tokens=2200, api_key=api_key)).strip()
|
|
)
|
|
return "\n\n".join(chunk for chunk in translated_chunks if chunk)
|