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:
+208
-4
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user