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
+208 -4
View File
@@ -152,6 +152,8 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"`/generate <type>` — Generate output\n"
" Tipos: podcast|blog|report|thread\n"
" Extended: podcast_extended|blog_extended|report_extended\n"
"`/generate short_en` — Short vertical (vídeo) + informe de claims\n"
"`/short_spec` — Último shot spec como fichero JSON\n"
"`/sources` — List all sources found\n"
"`/outputs` — List generated outputs\n"
"`/export` — Exportar último output como PDF\n"
@@ -285,6 +287,12 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
# Telegram, writes a bare draft). Global default still comes from SEO_AUTOFILL.
seo_override = "dryrun" if ("dry" in rest or "dryrun" in rest) else None
# El Short no es un output de texto: sale del pipeline de shortsmith y se
# entrega como vídeo + informe de claims. Se desvía antes del type_map.
if output_arg in ("short_en", "short", "corto"):
await cmd_short(update, ctx)
return
type_map = {
"podcast": OutputType.PODCAST,
"blog": OutputType.BLOG,
@@ -301,7 +309,7 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if output_arg not in type_map:
await update.message.reply_text(
"❌ Invalid output type.\n"
"Use: `/generate podcast|blog|report|thread`",
"Use: `/generate podcast|blog|report|thread|short_en`",
parse_mode=ParseMode.MARKDOWN
)
return
@@ -421,6 +429,177 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await db_conn.close()
async def _session_row(db_conn, chat_id: int):
"""La sesión activa del chat si la hay, si no la más reciente."""
session_id = _active_sessions.get(chat_id)
if session_id:
cursor = await db_conn.execute(
"SELECT * FROM research_sessions WHERE id = ?", (session_id,))
else:
cursor = await db_conn.execute(
"""SELECT * FROM research_sessions WHERE telegram_chat_id = ?
ORDER BY created_at DESC LIMIT 1""", (chat_id,))
row = await cursor.fetchone()
return dict(row) if row else None
def _claims_message(result) -> str:
"""El informe de claims: la puerta de revisión humana.
Se manda SIEMPRE y como mensaje aparte, incluso con cero avisos. Un éxito
silencioso enseña al lector a dejar de mirar. En texto plano a propósito:
lleva citas y comillas del modelo, y un Markdown desbalanceado haría que
Telegram rechazara justo el mensaje que no puede faltar.
"""
lines = []
if result.grounding:
lines.append(result.grounding.summary())
else:
lines.append("⚠️ Sin comprobación de fundamento: no se llegó a escribir un spec.")
if result.render_warnings:
lines.append("")
lines.append(f"✂️ {len(result.render_warnings)} textos recortados al dibujar:")
for w in result.render_warnings[:5]:
lines.append(f" • [{w.get('template', '?')}] {str(w.get('text', ''))[:60]}")
if result.notes:
lines.append("")
lines.extend(f"📏 {n}" for n in result.notes)
lines.append("")
if result.duration_s:
lines.append(f"Duración: {result.duration_s:.0f}s · "
f"{len(result.spec.get('shots', []))} shots · "
f"intentos hasta válido: {result.attempts}")
lines.append(f"Coste: ${result.cost_usd:.4f}")
if not result.article_url:
lines.append("⚠️ Esta sesión no tiene URL de artículo: publica antes el blog "
"(`/generate blog en`) para que el Short pueda enlazarlo.")
return "\n".join(lines)
async def _send_spec_file(message, result, session_id: int, reason: str):
"""Fallback universal: el spec vuelve como fichero pase lo que pase.
La parte cara es la generación, no el render. Un spec que no se pudo
renderizar se edita a mano y se reenvía; uno que se tira hay que pagarlo
otra vez.
"""
import io
payload = result.spec_json or result.raw_response
if not payload:
await message.reply_text(f"{reason}\n(no hay ni spec que devolver)")
return
suffix = "json" if result.spec else "txt"
await message.reply_document(
document=io.BytesIO(payload.encode("utf-8")),
filename=f"short_{session_id}_spec.{suffix}",
caption=f"⚠️ Sin vídeo — {reason[:800]}",
)
async def cmd_short(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""`/generate short_en` — spec → fundamento → render → vídeo a revisar.
La subida a YouTube NO entra aquí: es fase 3, y el humano de en medio es
justo lo más valioso del proceso.
"""
if not is_authorized(update.effective_user.id):
return
chat_id = update.effective_chat.id
db_conn = await get_db()
db = ResearchDB(db_conn)
try:
session = await _session_row(db_conn, chat_id)
if not session:
await update.message.reply_text(
"No research sessions found. Start with /research <topic>")
return
session_id = session["id"]
from src.generator.short import ShortProducer, ShortsDisabled
reporter = ProgressReporter(update.message)
await reporter.start(f"🎬 Writing shot spec for: {session['topic']}")
producer = ShortProducer(db, ContentProcessor(db, OllamaClient()))
try:
result = await producer.produce(session_id, reporter.update)
except ShortsDisabled:
await reporter.done(
"🚫 Los Shorts están desactivados (`SHORTSMITH_ENABLED=false`).")
return
if result.has_video:
await reporter.done("✅ Short renderizado")
caption = f"🎬 {result.title}"
if result.article_url:
caption += f"\n{result.article_url}"
caption += f"\n\n{session['topic']} · {result.duration_s:.0f}s"
with open(result.video_path, "rb") as f:
await update.message.reply_video(
video=f,
filename=f"short_{session_id}.mp4",
caption=caption[:1024],
supports_streaming=True,
write_timeout=180,
)
else:
await reporter.done("⚠️ Short sin vídeo — te devuelvo el spec")
await _send_spec_file(update.message, result, session_id,
result.failure or "razón desconocida")
# Informe de claims: SIEMPRE, y en su propio mensaje.
await update.message.reply_text(_claims_message(result))
except Exception as e:
logger.error("Short generation failed", error=str(e), exc_info=True)
await update.message.reply_text(f"❌ Short failed: {str(e)[:300]}")
finally:
await db_conn.close()
async def cmd_short_spec(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Devuelve el último shot spec como fichero, para editarlo a mano y
volver a renderizar sin pagar otra generación."""
if not is_authorized(update.effective_user.id):
return
chat_id = update.effective_chat.id
db_conn = await get_db()
db = ResearchDB(db_conn)
try:
session = await _session_row(db_conn, chat_id)
if not session:
await update.message.reply_text("No sessions found.")
return
output = await db.get_latest_output(session["id"], OutputType.SHORT_EN)
if not output:
await update.message.reply_text(
"No hay ningún shot spec en esta sesión. Genera uno con "
"`/generate short_en`.", parse_mode=ParseMode.MARKDOWN)
return
import io
from datetime import datetime
created = datetime.utcfromtimestamp(output["created_at"]).strftime("%Y-%m-%d %H:%M")
await update.message.reply_document(
document=io.BytesIO(output["content"].encode("utf-8")),
filename=f"short_{session['id']}_spec.json",
caption=f"🎬 Shot spec — {session['topic']}\n{created} UTC",
)
except Exception as e:
logger.error("short_spec failed", error=str(e))
await update.message.reply_text(f"{str(e)[:200]}")
finally:
await db_conn.close()
async def cmd_sources(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_authorized(update.effective_user.id):
return
@@ -1062,7 +1241,15 @@ async def cmd_export(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if chosen:
break
if not chosen:
chosen = outputs[0]
# Un short_en es JSON, no prosa: maquetarlo en PDF no tiene sentido.
# Se usa /short_spec para eso.
prose = [o for o in outputs if o["output_type"] != OutputType.SHORT_EN]
if not prose:
await update.message.reply_text(
"El único output de esta sesión es un shot spec. "
"Úsalo con `/short_spec`.", parse_mode=ParseMode.MARKDOWN)
return
chosen = prose[0]
msg = await update.message.reply_text(
f"📄 Generando PDF para `{topic}`…",
@@ -1136,7 +1323,8 @@ async def cmd_purge(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
f"🗑️ Purged: {result['sessions']} sessions, "
f"{result['sources']} sources, "
f"{result['chunks']} chunks, "
f"{result['outputs']} outputs"
f"{result['outputs']} outputs, "
f"{result.get('shorts', 0)} vídeos"
)
except Exception as e:
logger.error("Purge command failed", error=str(e))
@@ -1200,7 +1388,14 @@ async def cmd_publish(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if chosen:
break
if not chosen:
chosen = outputs[-1]
# Nunca un short_en: su contenido es el shot spec en JSON.
prose = [o for o in outputs if o["output_type"] != OutputType.SHORT_EN]
if not prose:
await update.message.reply_text(
"El único output de esta sesión es un shot spec — eso no se "
"publica en Ghost.")
return
chosen = prose[-1]
msg = await update.message.reply_text("📤 Publicando en Ghost como borrador…")
@@ -1209,6 +1404,14 @@ async def cmd_publish(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
post = result["posts"][0]
admin_url = f"{ghost.url}/ghost/#/editor/post/{post['id']}"
# La URL pública queda apuntada en la fila del output: el Short la
# necesita después para enlazar al artículo (best-effort).
if post.get("slug"):
try:
await db.set_output_url(chosen["id"], f"{ghost.url}/{post['slug']}/")
except Exception as e:
logger.warning("No se pudo guardar la URL del artículo", error=str(e))
await msg.edit_text(
f"✅ *Publicado en Ghost como borrador*\n\n"
f"📝 Título: `{title}`\n"
@@ -1380,6 +1583,7 @@ def create_bot() -> Application:
app.add_handler(CommandHandler("status", cmd_status))
app.add_handler(CommandHandler("finish", cmd_finish))
app.add_handler(CommandHandler("generate", cmd_generate))
app.add_handler(CommandHandler("short_spec", cmd_short_spec))
app.add_handler(CommandHandler("sources", cmd_sources))
app.add_handler(CommandHandler("outputs", cmd_outputs))
app.add_handler(CommandHandler("news", cmd_news))