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
+26 -5
View File
@@ -651,8 +651,27 @@ class OutputGenerator:
# never buried inside the long .md document). None on the flag-off path.
self.last_publish_notice: str | None = None
async def _remember_article_url(self, ghost: "GhostPublisher", post: dict,
output_id: int | None) -> None:
"""Guarda en la fila de `outputs` la URL que tendrá el artículo.
Se construye desde el slug (`{sitio}/{slug}/`) y no desde el `url` que
devuelve Ghost, porque el post es un DRAFT y ese campo trae la URL de
previsualización. Si Jose cambia el slug al publicar, el enlace habrá
que rehacerlo — el Short lo enseña en la descripción, no en el vídeo.
Best-effort: nunca bloquea la publicación.
"""
if output_id is None or not post.get("slug"):
return
try:
await self.db.set_output_url(output_id, f"{ghost.url}/{post['slug']}/")
except Exception as e:
logger.warning("No se pudo guardar la URL del artículo", error=str(e))
async def _publish_blog_to_ghost(self, lang: str, full_output: str, topic: str,
session_id: int, seo_override: str | None) -> str:
session_id: int, seo_override: str | None,
output_id: int | None = None) -> str:
"""Publish a blog DRAFT to Ghost, gated by the SEO autofill mode.
Returns the ghost_notice to APPEND to the returned document (flag-off /
@@ -703,6 +722,7 @@ class OutputGenerator:
self.last_publish_notice = (
_seo_live_message(ghost, post, seo, inserted_pairs)
+ collision_note)
await self._remember_article_url(ghost, post, output_id)
logger.info("Auto-published blog to Ghost",
mode=mode, post_id=post["id"], links=len(inserted_pairs))
return ""
@@ -720,6 +740,7 @@ class OutputGenerator:
try:
result = await ghost.publish_draft(title, full_output)
post = result["posts"][0]
await self._remember_article_url(ghost, post, output_id)
logger.info("Auto-published blog to Ghost (bare)", post_id=post["id"])
return _bare_ghost_notice(ghost, post) + collision_note
except Exception as e:
@@ -797,13 +818,13 @@ class OutputGenerator:
full_output = header + "\n\n" + output
# Save to DB
await self.db.save_output(session_id, output_type, full_output)
output_id = await self.db.save_output(session_id, output_type, full_output)
# Auto-publish to Ghost for blog outputs (autofill mode gated inside helper).
ghost_notice = ""
if output_type in (OutputType.BLOG, OutputType.BLOG_EXTENDED):
ghost_notice = await self._publish_blog_to_ghost(
lang, full_output, topic, session_id, seo_override)
lang, full_output, topic, session_id, seo_override, output_id)
logger.info("Output generated", type=output_type, length=len(full_output))
return full_output + ghost_notice
@@ -960,13 +981,13 @@ class OutputGenerator:
header = self._build_header(topic, output_type, session, stats)
full_output = header + "\n\n" + full_content
await self.db.save_output(session_id, output_type, full_output)
output_id = await self.db.save_output(session_id, output_type, full_output)
# Auto-publish to Ghost for extended blog outputs (autofill mode gated inside).
ghost_notice = ""
if output_type == OutputType.BLOG_EXTENDED:
ghost_notice = await self._publish_blog_to_ghost(
lang, full_output, topic, session_id, seo_override)
lang, full_output, topic, session_id, seo_override, output_id)
logger.info("Extended output generated", type=output_type,
sections=len(sections), length=len(full_output))