feat(short): generación y render de Shorts vía shortsmith
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s

Añade /generate short_en y /short_spec. El pipeline genera un shot spec
con Haiku, verifica cada cifra, fecha y cita contra los chunks de la
sesión, lo renderiza en shortsmith y entrega el MP4 por Telegram junto
a un informe de claims.

- ShortsmithClient con sondeo y fallback al spec JSON si el render falla
- Contrato de plantillas obtenido de GET /templates, no codificado
- Comprobación de fundamento determinista, sin LLM
- outputs.published_url para enlazar el artículo de Ghost
- Normalización de comillas rectas a tipográficas (ver KNOWN-ISSUES.md)

Lo que no aparece en los chunks se contrasta contra el ejemplo del
prompt: si casa ahí es fuga, no invención, y se informa como tal. El
purgado de sesiones se lleva también su MP4.

La subida a YouTube queda fuera a propósito: fase 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-08-01 21:55:42 +00:00
co-authored by Claude Opus 5
parent 8b81ef87e4
commit 20c8d03aa7
27 changed files with 4350 additions and 23 deletions
+75 -2
View File
@@ -29,6 +29,9 @@ class OutputType(str, Enum):
REPORT_EXTENDED = "report_extended"
BLOG_EXTENDED = "blog_extended"
PODCAST_EXTENDED = "podcast_extended"
# El contenido de un short_en NO es prosa: es el shot spec (JSON) que
# shortsmith convierte en vídeo. El MP4 vive en disco, nunca en SQLite.
SHORT_EN = "short_en"
SCHEMA = """
@@ -79,7 +82,8 @@ CREATE TABLE IF NOT EXISTS outputs (
session_id INTEGER NOT NULL REFERENCES research_sessions(id),
output_type TEXT NOT NULL,
content TEXT NOT NULL,
created_at REAL NOT NULL
created_at REAL NOT NULL,
published_url TEXT -- URL del artículo publicado (blog -> Ghost)
);
CREATE TABLE IF NOT EXISTS source_contents (
@@ -175,12 +179,33 @@ async def _init_shared() -> aiosqlite.Connection:
# antes de fallar con "database is locked".
await conn.execute("PRAGMA busy_timeout=5000")
await conn.executescript(SCHEMA)
await _ensure_columns(conn)
await conn.commit()
_shared_conn = conn
logger.info("Shared DB connection initialized", path=settings.db_path)
return _shared_conn
#: Columnas añadidas después de que la tabla existiera en producción. El
#: `CREATE TABLE IF NOT EXISTS` no toca una tabla ya creada, así que una
#: columna nueva necesita su ALTER — guardado por PRAGMA table_info para que
#: sea idempotente. No es una migración: no hay versiones ni orden, sólo
#: "¿existe la columna? si no, créala".
_ADDED_COLUMNS: dict[str, dict[str, str]] = {
"outputs": {"published_url": "TEXT"},
}
async def _ensure_columns(conn: aiosqlite.Connection) -> None:
for table, columns in _ADDED_COLUMNS.items():
async with conn.execute(f"PRAGMA table_info({table})") as cur:
existing = {row[1] for row in await cur.fetchall()}
for name, decl in columns.items():
if name not in existing:
await conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {decl}")
logger.info("Columna añadida", table=table, column=name)
async def get_db() -> aiosqlite.Connection:
conn = await _init_shared()
return _SharedConnection(conn)
@@ -399,6 +424,40 @@ class ResearchDB:
row = await cur.fetchone()
return row[0] if row else None
async def set_output_url(self, output_id: int, url: str) -> None:
"""Guarda la URL pública del artículo en su fila de `outputs`.
Hace falta porque el Short enlaza al artículo en su descripción, y hasta
ahora la URL sólo viajaba en el aviso de Telegram: se perdía en cuanto
se cerraba la conversación.
"""
await self.db.execute(
"UPDATE outputs SET published_url = ? WHERE id = ?", (url, output_id))
await self.db.commit()
async def get_latest_output(self, session_id: int,
output_type: Optional[str] = None) -> Optional[dict]:
query = "SELECT * FROM outputs WHERE session_id = ?"
params: list = [session_id]
if output_type:
query += " AND output_type = ?"
params.append(output_type)
query += " ORDER BY created_at DESC LIMIT 1"
cursor = await self.db.execute(query, params)
row = await cursor.fetchone()
return dict(row) if row else None
async def get_article_url(self, session_id: int) -> Optional[str]:
"""La URL del artículo publicado más reciente de la sesión, si la hay."""
cursor = await self.db.execute(
"""SELECT published_url FROM outputs
WHERE session_id = ? AND published_url IS NOT NULL AND published_url != ''
ORDER BY created_at DESC LIMIT 1""",
(session_id,)
)
row = await cursor.fetchone()
return row[0] if row else None
async def get_outputs(self, session_id: int) -> list[dict]:
cursor = await self.db.execute(
"SELECT * FROM outputs WHERE session_id = ? ORDER BY created_at DESC",
@@ -592,9 +651,23 @@ class ResearchDB:
)
session_ids = [row[0] for row in await cursor.fetchall()]
counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0, "api_usage": 0}
counts = {"sessions": 0, "sources": 0, "chunks": 0, "outputs": 0,
"api_usage": 0, "shorts": 0}
for sid in session_ids:
# El MP4 del Short vive en disco (los blobs en SQLite hacen
# patológico el WAL), así que su borrado no lo arrastra ninguna FK:
# se hace aquí, que es el único sitio que sabe qué sesiones
# desaparecen. Best-effort — un fichero que no se puede borrar no
# va a impedir purgar la sesión.
try:
video = Path(settings.shorts_dir) / f"{sid}.mp4"
if video.is_file():
video.unlink()
counts["shorts"] += 1
except OSError as e:
logger.warning("No se pudo borrar el Short de una sesión purgada",
session_id=sid, error=str(e))
await self.db.execute(
"DELETE FROM source_contents WHERE source_id IN (SELECT id FROM sources WHERE session_id = ?)",
(sid,)