feat(short): generación y render de Shorts vía shortsmith
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
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:
+75
-2
@@ -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,)
|
||||
|
||||
Reference in New Issue
Block a user