259 lines
9.6 KiB
Python
259 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
|
|
import requests
|
|
|
|
from video_render.config import BASE_DIR, Settings
|
|
from video_render.transcription import TranscriptionResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OPENROUTER_ENDPOINT = os.environ.get("OPENROUTER_API_URL", "https://openrouter.ai/api/v1/chat/completions")
|
|
|
|
|
|
class OpenRouterCopywriter:
|
|
def __init__(self, settings: Settings) -> None:
|
|
if not settings.openrouter.api_key:
|
|
raise RuntimeError("OPENROUTER_API_KEY nao foi definido")
|
|
self.settings = settings
|
|
prompt_path = Path(settings.openrouter.prompt_path)
|
|
|
|
if not prompt_path.is_absolute():
|
|
prompt_path = BASE_DIR / prompt_path
|
|
if not prompt_path.exists():
|
|
raise FileNotFoundError(f"Prompt nao encontrado: {prompt_path}")
|
|
self.highlights_prompt_template = prompt_path.read_text(encoding="utf-8")
|
|
|
|
def generate_highlights(self, transcription: TranscriptionResult) -> List[Dict]:
|
|
"""Generate video highlights using OpenRouter GPT-OSS with retry logic."""
|
|
payload = {
|
|
"transcript": transcription.full_text,
|
|
"segments": [
|
|
{
|
|
"start": segment.start,
|
|
"end": segment.end,
|
|
"text": segment.text,
|
|
}
|
|
for segment in transcription.segments
|
|
],
|
|
}
|
|
|
|
body = {
|
|
"model": self.settings.openrouter.model,
|
|
"temperature": self.settings.openrouter.temperature,
|
|
"messages": [
|
|
{"role": "system", "content": self.highlights_prompt_template},
|
|
{
|
|
"role": "user",
|
|
"content": json.dumps(payload, ensure_ascii=False),
|
|
},
|
|
],
|
|
}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {self.settings.openrouter.api_key}",
|
|
"Content-Type": "application/json",
|
|
"X-Title": "Video Render - Highlights Detection"
|
|
}
|
|
|
|
logger.info(f"Calling OpenRouter with model: {self.settings.openrouter.model}")
|
|
logger.debug(f"Request payload keys: transcript_length={len(payload['transcript'])}, segments_count={len(payload['segments'])}")
|
|
|
|
# Retry configuration for rate limits (especially free tier)
|
|
max_retries = 5
|
|
base_delay = 5 # Start with 5s delay
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
response = requests.post(
|
|
url=OPENROUTER_ENDPOINT,
|
|
data=json.dumps(body),
|
|
headers=headers,
|
|
timeout=120,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
break
|
|
|
|
except requests.exceptions.HTTPError as exc:
|
|
if exc.response.status_code == 429:
|
|
if attempt < max_retries - 1:
|
|
# Exponential backoff: 5s, 10s, 20s, 40s, 80s
|
|
delay = base_delay * (2 ** attempt)
|
|
logger.warning(f"Rate limit atingido (429). Aguardando {delay}s antes de tentar novamente (tentativa {attempt + 1}/{max_retries})")
|
|
time.sleep(delay)
|
|
continue
|
|
else:
|
|
logger.error("Rate limit atingido apos todas as tentativas")
|
|
logger.error("Solucao: Use um modelo pago ou adicione creditos na OpenRouter")
|
|
raise RuntimeError("OpenRouter rate limit excedido") from exc
|
|
else:
|
|
logger.error(f"OpenRouter API request falhou com status {exc.response.status_code}: {exc}")
|
|
raise RuntimeError("OpenRouter API request falhou") from exc
|
|
|
|
except Exception as exc:
|
|
logger.error("OpenRouter API request falhou: %s", exc)
|
|
raise RuntimeError("OpenRouter API request falhou") from exc
|
|
|
|
# Debug: log response structure
|
|
logger.info(f"OpenRouter response keys: {list(data.keys())}")
|
|
if "error" in data:
|
|
logger.error(f"OpenRouter API error: {data.get('error')}")
|
|
raise RuntimeError(f"OpenRouter API error: {data.get('error')}")
|
|
|
|
choices = data.get("choices") or []
|
|
if not choices:
|
|
logger.error(f"OpenRouter response completa: {json.dumps(data, indent=2)}")
|
|
raise RuntimeError("OpenRouter nao retornou escolhas")
|
|
|
|
message = choices[0].get("message", {}).get("content")
|
|
if not message:
|
|
raise RuntimeError("Resposta do OpenRouter sem conteudo")
|
|
|
|
parsed = self._extract_json(message)
|
|
highlights = parsed.get("highlights")
|
|
if not isinstance(highlights, list):
|
|
raise ValueError("Resposta do OpenRouter invalida: campo 'highlights' ausente")
|
|
|
|
valid_highlights = []
|
|
for highlight in highlights:
|
|
try:
|
|
start = float(highlight.get("start", 0))
|
|
end = float(highlight.get("end", 0))
|
|
summary = str(highlight.get("summary", "")).strip()
|
|
|
|
if start < 0 or end < 0:
|
|
logger.warning(f"Highlight ignorado: timestamps negativos (start={start}, end={end})")
|
|
continue
|
|
|
|
if end <= start:
|
|
logger.warning(f"Highlight ignorado: end <= start (start={start}, end={end})")
|
|
continue
|
|
|
|
duration = end - start
|
|
if duration < 45:
|
|
logger.warning(f"Highlight ignorado: muito curto ({duration}s, minimo 45s)")
|
|
continue
|
|
|
|
if duration > 90:
|
|
logger.warning(f"Highlight ignorado: muito longo ({duration}s, maximo 90s)")
|
|
continue
|
|
|
|
if not summary:
|
|
logger.warning(f"Highlight ignorado: summary vazio")
|
|
continue
|
|
|
|
valid_highlights.append({
|
|
"start": start,
|
|
"end": end,
|
|
"summary": summary
|
|
})
|
|
|
|
except (TypeError, ValueError) as e:
|
|
logger.warning(f"Highlight invalido ignorado: {highlight} - {e}")
|
|
continue
|
|
|
|
if not valid_highlights:
|
|
logger.warning("Nenhum highlight valido retornado pelo OpenRouter")
|
|
total_duration = 75.0
|
|
if transcription.segments:
|
|
total_duration = max(seg.end for seg in transcription.segments)
|
|
|
|
fallback_end = min(75.0, total_duration)
|
|
if fallback_end < 60.0:
|
|
fallback_end = min(60.0, total_duration)
|
|
|
|
return [{
|
|
"start": 0.0,
|
|
"end": fallback_end,
|
|
"summary": "Trecho inicial do video (fallback automatico)"
|
|
}]
|
|
|
|
logger.info(f"OpenRouter retornou {len(valid_highlights)} highlights validos")
|
|
return valid_highlights
|
|
|
|
def generate_titles(self, highlights: List[Dict]) -> List[str]:
|
|
if not highlights:
|
|
return []
|
|
|
|
prompt = (
|
|
"Voce e um copywriter especializado em titulos curtos e virais para reels.\n"
|
|
"Recebera uma lista de trechos destacados de um video com resumo e tempo.\n"
|
|
"Produza um titulo envolvente (ate 60 caracteres) para cada item.\n"
|
|
"Responda apenas em JSON com a seguinte estrutura:\n"
|
|
'{"titles": ["titulo 1", "titulo 2"]}\n'
|
|
"Titulos devem ser em portugues, usar verbos fortes e refletir o resumo."
|
|
)
|
|
|
|
user_payload = {
|
|
"highlights": [
|
|
{
|
|
"start": item.get("start"),
|
|
"end": item.get("end"),
|
|
"summary": item.get("summary"),
|
|
}
|
|
for item in highlights
|
|
]
|
|
}
|
|
|
|
body = {
|
|
"model": self.settings.openrouter.model,
|
|
"temperature": self.settings.openrouter.temperature,
|
|
"messages": [
|
|
{"role": "system", "content": prompt},
|
|
{
|
|
"role": "user",
|
|
"content": json.dumps(user_payload, ensure_ascii=False),
|
|
},
|
|
],
|
|
}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {self.settings.openrouter.api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
response = requests.post(
|
|
url=OPENROUTER_ENDPOINT,
|
|
data=json.dumps(body),
|
|
headers=headers,
|
|
timeout=120,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
choices = data.get("choices") or []
|
|
|
|
if not choices:
|
|
raise RuntimeError("OpenRouter nao retornou escolhas")
|
|
|
|
message = choices[0].get("message", {}).get("content")
|
|
|
|
if not message:
|
|
raise RuntimeError("Resposta do OpenRouter sem conteudo")
|
|
|
|
parsed = self._extract_json(message)
|
|
titles = parsed.get("titles")
|
|
|
|
if not isinstance(titles, list):
|
|
raise ValueError("Resposta do OpenRouter invalida: campo 'titles'")
|
|
|
|
return [str(title) for title in titles]
|
|
|
|
@staticmethod
|
|
def _extract_json(response_text: str) -> Dict:
|
|
try:
|
|
return json.loads(response_text)
|
|
except json.JSONDecodeError:
|
|
start = response_text.find("{")
|
|
end = response_text.rfind("}")
|
|
if start == -1 or end == -1:
|
|
raise
|
|
subset = response_text[start : end + 1]
|
|
return json.loads(subset)
|