Fase 3, con una corrección sobre lo que decía la §11 de la spec de fase 2. El bloqueo no es OAuth. Los vídeos subidos por videos.insert desde un proyecto de API sin auditar quedan restringidos a privado, y el candado es del proyecto, no del vídeo: no se abre desde Studio, se abre pasando la auditoría de cumplimiento de Google. Así que esto no publica. Deja el vídeo en el canal con los metadatos puestos y devuelve el enlace de Studio para que una persona lo revise y le dé a publicar — la misma forma que /publish con los borradores de Ghost, y por la misma razón: el informe de fundamento no sirve de nada si el vídeo ya está subido cuando lo lees. Comando aparte, no un paso de /generate short_en. - src/generator/youtube.py: refresco de token contra oauth2.googleapis.com, subida resumable en dos pasos y metadatos derivados del shot spec ya guardado (título, enlace al artículo, fuentes que el Short cita en pantalla, etiquetas del tema). Sin google-api-python-client: es síncrono y bloquearía el loop del bot; son dos peticiones HTTP y el repo ya firma los JWT de Ghost a mano. aiohttp con SAFE_ACCEPT_ENCODING como todo lo demás. - Scope youtube.upload y nada más: un token filtrado no puede leer ni borrar nada del canal, sólo subir. - forced_private detecta que YouTube devolvió "private" cuando se pidió otra cosa, y el aviso lo dice. Es la firma del candado, y tragárselo haría creer que salió publicado. - invalid_grant se traduce a su causa real: la pantalla de consentimiento quedó en "Testing" y Google revoca esos tokens a los siete días. Es el fallo que menos se adivina y el que más probable es encontrarse. - get_article_url ahora excluye las filas short_en. Su published_url pasa a ser la URL de YouTube, y sin el filtro el siguiente Short de la sesión enlazaría al Short anterior: un bucle silencioso, porque la URL es válida y nadie la mira dos veces. - scripts/youtube_oauth.py, sólo stdlib: corre en el portátil, no en el contenedor, y no debería exigir instalar nada. - Las tres claves van optional:true en el Deployment. Sin eso, una clave que aún no está en Infisical deja el pod en CreateContainerConfigError y tira el bot entero por una función que nadie ha pedido todavía. 30 tests nuevos contra un servidor falso. No hay test en vivo a propósito: cualquier ejecución real sube un vídeo a un canal de verdad, y eso no es algo que deba pasar por teclear pytest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
155 lines
6.8 KiB
Python
155 lines
6.8 KiB
Python
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
from pydantic import Field
|
|
from typing import Optional
|
|
|
|
# Accept-Encoding para TODA petición aiohttp del proyecto. Nunca anunciar "br":
|
|
# el descompresor incremental de aiohttp 3.14 está roto, y con un backend brotli
|
|
# instalado aiohttp lo anunciaría POR DEFECTO en cualquier sesión sin
|
|
# Accept-Encoding explícito (incidente 2026-07-04, ver KNOWN-ISSUES.md).
|
|
# Toda sesión/petición nueva debe usar esta constante, no un literal.
|
|
SAFE_ACCEPT_ENCODING = "gzip, deflate"
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
# Telegram
|
|
telegram_bot_token: str = Field(...)
|
|
telegram_allowed_users: str = Field("") # comma-separated user IDs
|
|
|
|
# Ollama
|
|
ollama_url: str = Field("http://ollama.chemavx.xyz")
|
|
ollama_model: str = Field("qwen2.5:7b")
|
|
ollama_embed_model: str = Field("bge-m3")
|
|
|
|
# Claude fallback (optional)
|
|
anthropic_api_key: Optional[str] = Field(None)
|
|
claude_model: str = Field("claude-haiku-4-5")
|
|
|
|
# Database
|
|
db_path: str = Field("/data/researchowl.db")
|
|
|
|
# Scraping
|
|
max_depth: int = Field(3) # recursion depth
|
|
max_sources: int = Field(150) # hard cap
|
|
max_pages_per_search: int = Field(5)
|
|
searxng_url: str = Field(
|
|
"http://searxng-svc.researchowl.svc.cluster.local:8080/search",
|
|
)
|
|
# Motores que el scraper pide a SearXNG (todos vienen configurados por los
|
|
# defaults de la instancia, use_default_settings: true). Verificados en vivo
|
|
# 2026-07-03: la lista ampliada duplica resultados y dominios únicos (~2x)
|
|
# por ~+2s/query, y da redundancia cuando brave/DDG caen en rate limit o
|
|
# CAPTCHA. Excluidos: qwant (access denied), yahoo (roto), wikidata (0
|
|
# resultados en queries de texto), wayback machine (duplica contenido vivo).
|
|
searxng_engines: str = Field(
|
|
"duckduckgo,google,bing,brave,startpage,mojeek,presearch,"
|
|
"google news,bing news,internet archive scholar"
|
|
)
|
|
request_timeout: int = Field(30)
|
|
request_delay: float = Field(1.0) # seconds between requests
|
|
min_content_length: int = Field(200) # chars
|
|
# Libros/dumps enteros (100k+ palabras) inflan RAM y DB sin aportar al RAG
|
|
max_content_length: int = Field(300_000) # chars
|
|
|
|
# Fuentes opcionales — desactivadas por defecto: la IP del homelab está
|
|
# bloqueada por Reddit (403) y YouTube (transcripts vacíos), eran peso muerto.
|
|
enable_youtube: bool = Field(False)
|
|
enable_reddit: bool = Field(False)
|
|
# Bing News RSS como semilla de actualidad — off hasta activarlo a propósito
|
|
enable_news_seed: bool = Field(False)
|
|
|
|
# Processing
|
|
chunk_size: int = Field(800) # tokens per chunk
|
|
chunk_overlap: int = Field(100)
|
|
quality_threshold: float = Field(0.3) # 0-1, chunks below discarded
|
|
|
|
# Ghost CMS
|
|
ghost_url: Optional[str] = Field(None)
|
|
ghost_api_key: Optional[str] = Field(None)
|
|
ghost_url_en: str = Field("")
|
|
ghost_api_key_en: str = Field("")
|
|
|
|
# shortsmith (renderizador de Shorts) — env directo, sin secreto.
|
|
# El bot habla con el Service interno; el renderizador no se expone fuera
|
|
# del cluster. shortsmith_enabled=false es el kill switch: /generate short_en
|
|
# responde "desactivado" en vez de fallar.
|
|
shortsmith_url: str = Field("http://shortsmith-svc.shortsmith.svc.cluster.local:8080")
|
|
shortsmith_timeout: float = Field(600.0)
|
|
shortsmith_enabled: bool = Field(True)
|
|
# MP4 renderizados: en disco, NUNCA en SQLite (blobs en la DB hacen
|
|
# patológico el WAL — misma razón que source_contents guarda texto y ya).
|
|
shorts_dir: str = Field("/data/shorts")
|
|
|
|
# YouTube (subida de Shorts) — todo opt-in: sin credenciales, /upload_short
|
|
# contesta que no está configurado y no rompe nada más.
|
|
#
|
|
# OJO con youtube_privacy: los vídeos subidos por API desde un proyecto sin
|
|
# auditar quedan restringidos a privado por Google, sea cual sea lo que se
|
|
# pida aquí. Poner "public" sin haber pasado la auditoría no publica nada;
|
|
# sólo hace que el aviso de Telegram diga que YouTube te lo forzó.
|
|
youtube_enabled: bool = Field(True)
|
|
youtube_client_id: Optional[str] = Field(None)
|
|
youtube_client_secret: Optional[str] = Field(None)
|
|
#: Se saca una vez con scripts/youtube_oauth.py y vive en Infisical.
|
|
youtube_refresh_token: Optional[str] = Field(None)
|
|
youtube_privacy: str = Field("private")
|
|
youtube_category_id: str = Field("27") # 27 = Education
|
|
youtube_timeout: float = Field(300.0)
|
|
|
|
# SEO autofill — "off" | "on" | "dryrun" (default off).
|
|
# off = today's exact behavior (bare draft, no second LLM call).
|
|
# on = adds best-effort meta/OG/Twitter/tags/internal-links to the DRAFT
|
|
# (status stays "draft" — NEVER auto-publishes; see src/seo/autofill.py).
|
|
# dryrun = runs the full pipeline + reports the proposed SEO to Telegram, but
|
|
# writes a BARE draft (no seo fields), for inspection before trusting
|
|
# the write path. A `/generate blog en dry` arg forces dryrun per-call.
|
|
seo_autofill: str = Field("off")
|
|
|
|
# News monitor (RSS de noticias UAP/OVNI) — inerte hasta NEWS_ENABLED=true.
|
|
NEWS_ENABLED: bool = Field(False)
|
|
NEWS_POLL_INTERVAL_HOURS: int = Field(6)
|
|
# Chat al que el monitor empuja novedades; si None -> 1er TELEGRAM_ALLOWED_USERS
|
|
# (ver propiedad news_chat_id).
|
|
NEWS_CHAT_ID: Optional[int] = Field(None)
|
|
NEWS_KEYWORDS: str = Field(
|
|
"ovni,ovnis,uap,ufo,desclasificado,desclasificacion,declassified,"
|
|
"avistamiento,sighting,extraterrestre,non-human,nhi,grusch,"
|
|
"aaro,pursue,fenomeno aereo,whistleblower",
|
|
)
|
|
NEWS_MAX_ITEMS: int = Field(15)
|
|
|
|
# Alerts
|
|
cost_alert_threshold: float = Field(0.15)
|
|
|
|
# App
|
|
log_level: str = Field("INFO")
|
|
timezone: str = Field("Europe/Madrid")
|
|
|
|
@property
|
|
def allowed_user_ids(self) -> list[int]:
|
|
if not self.telegram_allowed_users:
|
|
return []
|
|
return [int(uid.strip()) for uid in self.telegram_allowed_users.split(",") if uid.strip()]
|
|
|
|
@property
|
|
def news_chat_id(self) -> Optional[int]:
|
|
"""Chat destino del monitor de noticias: NEWS_CHAT_ID explícito o, si no
|
|
está definido, el primer TELEGRAM_ALLOWED_USERS."""
|
|
if self.NEWS_CHAT_ID is not None:
|
|
return self.NEWS_CHAT_ID
|
|
ids = self.allowed_user_ids
|
|
return ids[0] if ids else None
|
|
|
|
@property
|
|
def seo_autofill_enabled(self) -> bool:
|
|
"""True when autofill should run (either live 'on' or 'dryrun')."""
|
|
return (self.seo_autofill or "").strip().lower() in ("on", "true", "1", "yes", "dryrun")
|
|
|
|
@property
|
|
def seo_autofill_dryrun(self) -> bool:
|
|
return (self.seo_autofill or "").strip().lower() == "dryrun"
|
|
|
|
model_config = SettingsConfigDict(env_file=".env")
|
|
|
|
|
|
settings = Settings()
|