ALLOWED_TAGS son SLUGS y Ghost casa los tags de un post por NOMBRE. Mandarlos
como {"name": slug} funcionaba en EN por casualidad — allí los tags se llaman
igual que su slug ("military-cases") — y en ES rompía: no hay ningún tag
llamado "casos-militares" (se llama "Casos Militares"), así que Ghost creaba
uno nuevo con ese nombre y, con el slug ya pillado, lo dejaba en
`casos-militares-2`.
Encontrado el 2026-07-29 al preparar el hub: 5 tags duplicados, 7 posts
repartidos entre dos archivos flacos cada uno, y los cinco `-2` ofrecidos a
Google en sitemap-tags.xml. Los datos ya están fusionados en Ghost; esto es
para que no vuelva.
Se resuelve el slug a ID contra la Admin API, que es lo único no ambiguo justo
en el punto donde falla la ambigüedad. Un slug que no exista se DESCARTA con
aviso en vez de crearse: la lista es cerrada, así que no existir significa que
está mal escrita, y crear el tag es exactamente el bug. Si no resuelve nada, o
si Ghost no contesta, se cae al comportamiento anterior antes que publicar un
post sin ninguna categoría.
7 tests nuevos, incluido uno para EN — donde el bug era invisible por la
coincidencia nombre==slug y el resolutor no debe depender de ella.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1286 lines
54 KiB
Python
1286 lines
54 KiB
Python
"""
|
|
ResearchOwl Generators
|
|
Produces structured outputs from processed research using Claude or Ollama
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import re
|
|
import time
|
|
|
|
import aiohttp
|
|
import structlog
|
|
|
|
from src.config import settings, SAFE_ACCEPT_ENCODING
|
|
from src.llm import get_anthropic_client
|
|
from src.processor.processor import OllamaClient, ContentProcessor
|
|
from src.db.database import ResearchDB, OutputType
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
PODCAST_SYSTEM = (
|
|
"Escribe SIEMPRE en español. "
|
|
"Eres un guionista de podcast. Escribe exactamente como un presentador HABLA — contracciones, "
|
|
"frases naturales, pausas, preguntas retóricas. "
|
|
"NUNCA repitas una frase o idea que ya escribiste. "
|
|
"Cada párrafo debe introducir información NUEVA. "
|
|
"Usa marcadores [PAUSA], [ÉNFASIS], [MÚSICA] con moderación."
|
|
)
|
|
|
|
BLOG_SYSTEM = (
|
|
"Escribe SIEMPRE en español. "
|
|
"Eres un periodista escribiendo un artículo de blog. Usa encabezados markdown claros. "
|
|
"NUNCA repitas el mismo dato o frase dos veces — si ya lo dijiste, avanza. "
|
|
"Cada sección debe añadir información nueva no cubierta en secciones anteriores."
|
|
)
|
|
|
|
BLOG_SYSTEM_EN = (
|
|
"You write ALWAYS in English. "
|
|
"You are a journalist writing a blog article. Use clear markdown headers. "
|
|
"NEVER repeat the same fact or phrase twice — if you said it, move on. "
|
|
"Each section must add new information not covered in other sections."
|
|
)
|
|
|
|
BLOG_PROMPT_EN = """\
|
|
Write a blog article about: "{topic}"
|
|
|
|
RULES — follow strictly:
|
|
- Each section under a heading must add NEW information not covered elsewhere
|
|
- Do NOT summarize previous sections at the start of each new section
|
|
- Do NOT repeat facts — if a fact appears once, do not mention it again
|
|
- Use concrete details, numbers, names — avoid vague generalities
|
|
- DESCRIPTIVE HEADINGS: every ## must name the specific fact of ITS section
|
|
("The summer of 1947", "The diary nobody has finished analysing").
|
|
NEVER write the template label as a heading or as a prefix: no "Background",
|
|
"Key Facts", "Analysis", "Significance", "Conclusion", "Hook: ..."
|
|
- Target: 1,800-2,500 words and AT MOST 12 ## headings. Going beyond that adds
|
|
no value and multiplies the editorial review
|
|
|
|
STRUCTURE (bracketed text is internal guidance: do NOT copy it verbatim)
|
|
# [Impactful headline]
|
|
|
|
[Hook paragraph — the most surprising fact]
|
|
|
|
## [descriptive heading for the background: what, when, who]
|
|
[Only facts not covered elsewhere]
|
|
|
|
## [descriptive heading for the main findings]
|
|
[Most significant findings — each point must be distinct]
|
|
|
|
## [descriptive heading for the analysis: what this means]
|
|
[What this means — without repeating the previous section]
|
|
|
|
## [descriptive heading for the closing]
|
|
[No more than 2 sentences summarizing, then a forward-looking statement]
|
|
|
|
RESEARCH MATERIAL:
|
|
{context}
|
|
|
|
Write the complete article in markdown:"""
|
|
|
|
REPORT_SYSTEM = (
|
|
"Escribe SIEMPRE en español. "
|
|
"Eres un analista de investigación. Escribe un informe estructurado y factual. "
|
|
"Sé conciso — NO rellenes con resúmenes redundantes. "
|
|
"NUNCA repitas un hallazgo ya listado. Cada hallazgo numerado debe ser distinto."
|
|
)
|
|
|
|
THREAD_SYSTEM = (
|
|
"Escribe SIEMPRE en español. "
|
|
"Escribes hilos de Twitter/X. Cada tweet debe tener menos de 280 caracteres. "
|
|
"NUNCA repitas información de un tweet anterior. "
|
|
"Cada tweet debe revelar algo NUEVO. Numéralos 1/N, 2/N..."
|
|
)
|
|
|
|
|
|
PROMPTS = {
|
|
OutputType.PODCAST: """\
|
|
Escribe un guion de podcast sobre: "{topic}"
|
|
|
|
REGLAS — sigue estrictamente:
|
|
- Escribe como PALABRA HABLADA: contracciones, ritmo natural, como si hablaras con un amigo
|
|
- NO uses encabezados formales como "SEGMENTO 1:" — fluye de forma natural
|
|
- Cada párrafo debe introducir un NUEVO hecho o ángulo — nunca repitas algo ya dicho
|
|
- Si te encuentras repitiendo, para y salta al siguiente punto nuevo
|
|
- Objetivo: 800-1200 palabras de contenido hablado real
|
|
|
|
ESTRUCTURA (usa transiciones naturales, no encabezados):
|
|
1. Gancho: abre con el hecho más sorprendente o dramático
|
|
2. Contexto: ¿cómo llegamos aquí?
|
|
3. Las evidencias o eventos clave (elige los 3 más interesantes)
|
|
4. La controversia o debate sobre el tema
|
|
5. ¿Qué significa esto / qué pasó después?
|
|
|
|
MATERIAL DE INVESTIGACIÓN:
|
|
{context}
|
|
|
|
Escribe el guion ahora (solo palabra hablada, sin acotaciones excepto [PAUSA] ocasional):""",
|
|
|
|
OutputType.BLOG: """\
|
|
Escribe un artículo de blog sobre: "{topic}"
|
|
|
|
REGLAS — sigue estrictamente:
|
|
- Cada sección bajo un encabezado debe añadir información NUEVA no cubierta en otro lugar
|
|
- NO resumas secciones anteriores al inicio de cada nueva sección
|
|
- NO repitas hechos — si un hecho aparece una vez, no lo menciones de nuevo
|
|
- Usa detalles concretos, números, nombres — evita generalidades vagas
|
|
- ENCABEZADOS DESCRIPTIVOS: cada ## nombra el hecho concreto de SU sección
|
|
(«El verano de 1947», «El diario que nadie ha terminado de analizar»).
|
|
NUNCA escribas la etiqueta de la plantilla como encabezado ni como prefijo:
|
|
nada de «Contexto», «Hechos Clave», «Análisis», «Conclusión», «Gancho: ...»
|
|
- ENCABEZADOS EN MAYÚSCULA DE ORACIÓN: solo la primera palabra y los nombres
|
|
propios llevan mayúscula. «La noche que el cielo se apagó», NUNCA «La Noche
|
|
Que El Cielo Se Apagó» — eso es estilo inglés y en español es incorrecto.
|
|
Tampoco va mayúscula después de dos puntos ni de raya
|
|
- Objetivo: 1.800-2.500 palabras y COMO MUCHO 12 encabezados ##. Pasarse de ahí
|
|
no mejora nada y multiplica el trabajo de revisión
|
|
|
|
ESTRUCTURA (lo que va entre corchetes es guía interna: NO lo copies literal)
|
|
# [Titular impactante]
|
|
|
|
[Párrafo gancho — el hecho más sorprendente]
|
|
|
|
## [encabezado descriptivo del contexto: qué, cuándo, quién]
|
|
[Solo hechos no cubiertos en otro lugar]
|
|
|
|
## [encabezado descriptivo de los hallazgos principales]
|
|
[Los hallazgos más significativos — cada punto debe ser distinto]
|
|
|
|
## [encabezado descriptivo del análisis: qué significa]
|
|
[Qué significa esto — sin repetir la sección anterior]
|
|
|
|
## [encabezado descriptivo del cierre]
|
|
[No más de 2 oraciones resumiendo, luego una declaración prospectiva]
|
|
|
|
MATERIAL DE INVESTIGACIÓN:
|
|
{context}
|
|
|
|
Escribe el artículo completo en markdown:""",
|
|
|
|
OutputType.REPORT: """\
|
|
Escribe un informe de investigación sobre: "{topic}"
|
|
|
|
REGLAS — sigue estrictamente:
|
|
- Cada hallazgo numerado debe ser DISTINTO — sin contenido que se superponga
|
|
- El Resumen Ejecutivo NO debe repetir los hallazgos literalmente — solo los 2-3 puntos más críticos
|
|
- La calidad de las fuentes y contradicciones deben referenciar afirmaciones específicas, no declaraciones genéricas
|
|
- Sé preciso y conciso — sin relleno
|
|
|
|
ESTRUCTURA:
|
|
1. Resumen Ejecutivo (3-4 oraciones, solo puntos clave)
|
|
2. Hallazgos Clave (5-10 numerados, cada uno completamente distinto)
|
|
3. Análisis de Evidencia (lo que muestran las fuentes, con cualquier contradicción)
|
|
4. Cronología (si aplica — fechas/eventos específicos)
|
|
5. Conclusiones y Preguntas Abiertas
|
|
|
|
MATERIAL DE INVESTIGACIÓN:
|
|
{context}
|
|
|
|
Escribe el informe completo en markdown:""",
|
|
|
|
OutputType.THREAD: """\
|
|
Escribe un hilo de Twitter/X sobre: "{topic}"
|
|
|
|
REGLAS — sigue estrictamente:
|
|
- Cada tweet debe revelar UN nuevo hecho o idea — nunca repetir un tweet anterior
|
|
- Máximo 280 caracteres por tweet (cuenta cuidadosamente)
|
|
- Formato de numeración: 1/ 2/ 3/ ... N/
|
|
- El tweet gancho debe ser el hecho más sorprendente/provocador
|
|
- Avanza hacia una conclusión — no repitas el gancho al final
|
|
- 12-18 tweets en total
|
|
|
|
MATERIAL DE INVESTIGACIÓN:
|
|
{context}
|
|
|
|
Escribe el hilo (un tweet por línea, nada más):"""
|
|
}
|
|
|
|
|
|
OUTLINE_REPORT = """
|
|
IMPORTANTE: Los títulos y queries deben estar en español, independientemente del idioma del material de investigación.
|
|
|
|
Eres un editor de investigación. Dado el tema "{topic}" y el material
|
|
disponible, genera un outline detallado para un informe exhaustivo.
|
|
|
|
Devuelve SOLO una lista JSON de secciones, sin texto adicional, sin
|
|
markdown, sin explicaciones. Formato exacto:
|
|
[
|
|
{{"title": "Título de la sección", "query": "términos de búsqueda específicos para esta sección", "words": 800}},
|
|
...
|
|
]
|
|
|
|
Genera entre 6 y 10 secciones. Cada sección debe:
|
|
- Cubrir un ángulo distinto del tema
|
|
- Tener una query específica para recuperar chunks relevantes
|
|
- Indicar longitud objetivo en palabras (400-1200)
|
|
|
|
Material disponible (resumen):
|
|
{context_summary}
|
|
"""
|
|
|
|
OUTLINE_BLOG = """
|
|
IMPORTANTE: Los títulos y queries deben estar en español, independientemente del idioma del material de investigación.
|
|
|
|
Eres un editor de contenido. Dado el tema "{topic}" y el material
|
|
disponible, genera un outline para un artículo de blog exhaustivo.
|
|
|
|
Devuelve SOLO una lista JSON de secciones, sin texto adicional:
|
|
[
|
|
{{"title": "Título de sección", "query": "términos búsqueda", "words": 600}},
|
|
...
|
|
]
|
|
|
|
Genera entre 5 y 8 secciones. Primera sección = introducción gancho.
|
|
Última sección = conclusión con perspectiva original.
|
|
|
|
Material disponible (resumen):
|
|
{context_summary}
|
|
"""
|
|
|
|
OUTLINE_PODCAST = """
|
|
IMPORTANTE: Los títulos y queries deben estar en español, independientemente del idioma del material de investigación.
|
|
|
|
Eres un productor de podcast. Dado el tema "{topic}" y el material
|
|
disponible, genera un outline para un guion de podcast exhaustivo.
|
|
|
|
Devuelve SOLO una lista JSON de segmentos, sin texto adicional:
|
|
[
|
|
{{"title": "Nombre del segmento", "query": "términos búsqueda", "words": 700}},
|
|
...
|
|
]
|
|
|
|
Genera entre 5 y 7 segmentos. Flujo natural: gancho → contexto →
|
|
desarrollo → controversia → conclusión.
|
|
|
|
Material disponible (resumen):
|
|
{context_summary}
|
|
"""
|
|
|
|
|
|
# ─── Ghost CMS ────────────────────────────────────────────────────────────────
|
|
|
|
def _b64url(data: bytes | str) -> str:
|
|
if isinstance(data, str):
|
|
data = data.encode()
|
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
|
|
|
|
def _extract_title(content: str) -> str:
|
|
"""Return first H1 heading from markdown, skipping the ResearchOwl header block."""
|
|
in_header = False
|
|
for line in content.splitlines():
|
|
stripped = line.strip()
|
|
if stripped == "---":
|
|
in_header = not in_header
|
|
continue
|
|
if in_header:
|
|
continue
|
|
if stripped.startswith("# ") and not stripped.startswith("## "):
|
|
return stripped[2:].strip()
|
|
return ""
|
|
|
|
|
|
def _strip_researchowl_header(content: str) -> str:
|
|
"""Remove the ---...--- metadata block that ResearchOwl prepends to outputs."""
|
|
lines = content.splitlines(keepends=True)
|
|
dashes_seen = 0
|
|
for i, line in enumerate(lines):
|
|
if line.strip() == "---":
|
|
dashes_seen += 1
|
|
if dashes_seen == 2:
|
|
return "".join(lines[i + 1:]).lstrip("\n")
|
|
return content
|
|
|
|
|
|
def _seo_checklist(slug: str) -> str:
|
|
"""Static pre-publish SEO reminder appended to the draft-published notice.
|
|
NOT a validator call: the fresh draft has no meta yet, so checking now would
|
|
be all-fail noise. Jose runs `seo-check <slug>` after filling these in."""
|
|
return (
|
|
"\n\n⚠️ Antes de publicar, añade: meta title, meta description (≤145), "
|
|
"custom excerpt, OG/Twitter, feature image + alt (campo Alt, NO Caption), "
|
|
"2-3 internal links (donde sea natural)\n"
|
|
f"Luego valida: seo-check {slug}"
|
|
)
|
|
|
|
|
|
# Language-aware default tag when no seo/tags are supplied. Fixes the historical
|
|
# hardcoded "investigacion" that mis-tagged EN posts; EN canonical is "investigation".
|
|
_DEFAULT_TAG = {"en": "investigation", "es": "investigacion"}
|
|
|
|
|
|
def _resolve_seo_mode(override: str | None = None) -> str:
|
|
"""Effective SEO autofill mode: 'off' | 'on' | 'dryrun'.
|
|
A per-call override ('dryrun'/'on', e.g. from `/generate blog en dry`) wins;
|
|
otherwise it derives from settings.seo_autofill."""
|
|
if override in ("on", "dryrun"):
|
|
return override
|
|
if settings.seo_autofill_dryrun:
|
|
return "dryrun"
|
|
if settings.seo_autofill_enabled:
|
|
return "on"
|
|
return "off"
|
|
|
|
|
|
def _seo_checklist_slim(slug: str) -> str:
|
|
"""Slimmed checklist for the autofill path: meta/OG/excerpt/tags/links are
|
|
already on the draft, so only the human-only bits remain."""
|
|
return (
|
|
"⚠️ Antes de publicar: añade *feature image + alt* (campo Alt, NO Caption), luego:\n"
|
|
f"`seo-check {slug}`"
|
|
)
|
|
|
|
|
|
def _bare_ghost_notice(ghost: "GhostPublisher", post: dict) -> str:
|
|
"""Today's exact draft-published notice (editor link + full checklist).
|
|
Used for the flag-off path and as the autofill failure fallback."""
|
|
return (
|
|
f"\n\n---\n"
|
|
f"📤 *Borrador publicado en Ghost*\n"
|
|
f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}"
|
|
f"{_seo_checklist(post.get('slug', ''))}"
|
|
)
|
|
|
|
|
|
def _seo_link_summary(inserted_pairs: list[dict], n_suggestions: int) -> str:
|
|
"""Report ACTUALLY-INSERTED links only ("phrase → /slug/"), with a separate
|
|
count of well-formed suggestions that were skipped (no verbatim body match).
|
|
Never surfaces raw rejected suggestions."""
|
|
n = len(inserted_pairs)
|
|
skipped = max(0, n_suggestions - n)
|
|
head = f"{n} insertados" + (f", {skipped} omitidos" if skipped else "")
|
|
if not inserted_pairs:
|
|
return head
|
|
body = "; ".join(f"{p['phrase']} → /{p['slug']}/" for p in inserted_pairs)
|
|
return f"{head} — {body}"
|
|
|
|
|
|
def _seo_live_message(ghost: "GhostPublisher", post: dict, seo: dict, inserted_pairs: list[dict]) -> str:
|
|
"""Separate Telegram message for the live autofill path (SEO written to draft)."""
|
|
slug = post.get("slug", "")
|
|
warns = seo.get("seo_warnings") or []
|
|
warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else ""
|
|
n_sug = len(seo.get("internal_links", []))
|
|
return (
|
|
f"📤 *Borrador en Ghost — SEO autorrelleno*\n"
|
|
f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}\n\n"
|
|
f"🔖 *SEO escrito en el draft:*\n"
|
|
f"• meta title ({len(seo['meta_title'])}): {seo['meta_title']}\n"
|
|
f"• meta desc ({len(seo['meta_description'])}): {seo['meta_description']}\n"
|
|
f"• excerpt: {len(seo['custom_excerpt'])} car.\n"
|
|
f"• tags: {', '.join(seo['tags'])}\n"
|
|
f"• internal links: {_seo_link_summary(inserted_pairs, n_sug)}"
|
|
f"{warn_line}\n\n"
|
|
f"🖼️ *Imagen sugerida:*\n"
|
|
f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\n"
|
|
f"{_seo_checklist_slim(slug)}"
|
|
)
|
|
|
|
|
|
def _seo_dryrun_message(ghost: "GhostPublisher", post: dict, seo: dict, inserted_pairs: list[dict]) -> str:
|
|
"""Separate Telegram message for DRY-RUN: bare draft created, SEO only proposed."""
|
|
slug = post.get("slug", "")
|
|
warns = seo.get("seo_warnings") or []
|
|
warn_line = ("\n⚠️ revisar: " + "; ".join(warns)) if warns else ""
|
|
n_sug = len(seo.get("internal_links", []))
|
|
return (
|
|
f"🧪 *DRY-RUN SEO* — draft creado SIN escribir SEO (solo revisión)\n"
|
|
f"Editar: {ghost.url}/ghost/#/editor/post/{post['id']}\n\n"
|
|
f"🔖 *SEO propuesto (NO escrito en Ghost):*\n"
|
|
f"• meta title ({len(seo['meta_title'])}): {seo['meta_title']}\n"
|
|
f"• meta desc ({len(seo['meta_description'])}): {seo['meta_description']}\n"
|
|
f"• excerpt ({len(seo['custom_excerpt'])} car.): {seo['custom_excerpt']}\n"
|
|
f"• tags: {', '.join(seo['tags'])}\n"
|
|
f"• internal links que se insertarían: {_seo_link_summary(inserted_pairs, n_sug)}"
|
|
f"{warn_line}\n\n"
|
|
f"🖼️ *Imagen sugerida:*\n"
|
|
f"`imagefinder --query \"{seo['image_query']}\" --context \"{seo['image_context']}\" --lang {ghost.lang}`\n\n"
|
|
f"{_seo_checklist_slim(slug)}"
|
|
)
|
|
|
|
|
|
def _norm_title(t: str) -> str:
|
|
"""Normaliza un título para compararlo con lo que Ghost almacenó: colapsa
|
|
whitespace y trunca a 255 chars (límite de posts.title en Ghost — un H1
|
|
largo del LLM puede volver truncado/normalizado, y la igualdad exacta
|
|
fallaría justo en los casos que el guard debe cubrir)."""
|
|
return " ".join((t or "").split())[:255]
|
|
|
|
|
|
def _parse_ghost_ts(ts: str) -> float | None:
|
|
"""created_at de la Admin API (ISO 8601, p.ej. 2026-07-04T20:26:00.000+00:00
|
|
o sufijo Z) → epoch. None si no parsea."""
|
|
try:
|
|
from datetime import datetime
|
|
return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
class GhostPublisher:
|
|
def __init__(self, lang: str = "es"):
|
|
self.lang = lang
|
|
if lang == "en":
|
|
self.url = (settings.ghost_url_en or "").rstrip("/")
|
|
self.api_key = settings.ghost_api_key_en or ""
|
|
else:
|
|
self.url = (settings.ghost_url or "").rstrip("/")
|
|
self.api_key = settings.ghost_api_key or ""
|
|
|
|
def is_configured(self) -> bool:
|
|
return bool(self.url and self.api_key)
|
|
|
|
def _build_html(self, markdown_content: str) -> str:
|
|
"""Canonical markdown → Ghost body HTML conversion: strip the ResearchOwl
|
|
header, render markdown, drop the first <h1> (Ghost renders the title).
|
|
Shared so callers that need the linked HTML produce the SAME html that gets
|
|
posted (no drift between link-insertion and the mobiledoc body)."""
|
|
import markdown as _md
|
|
clean = _strip_researchowl_header(markdown_content)
|
|
html = _md.markdown(clean, extensions=["extra"])
|
|
return re.sub(r"<h1[^>]*>.*?</h1>", "", html, count=1, flags=re.DOTALL).lstrip()
|
|
|
|
def _make_token(self) -> str:
|
|
key_id, secret = self.api_key.split(":", 1)
|
|
now = int(time.time())
|
|
header = _b64url(json.dumps({"alg": "HS256", "typ": "JWT", "kid": key_id}))
|
|
payload = _b64url(json.dumps({"iat": now, "exp": now + 300, "aud": "/admin/"}))
|
|
signing = f"{header}.{payload}"
|
|
sig = _b64url(
|
|
hmac.new(bytes.fromhex(secret), signing.encode(), hashlib.sha256).digest()
|
|
)
|
|
return f"{signing}.{sig}"
|
|
|
|
def _admin_headers(self) -> dict:
|
|
"""Headers canónicos de la Admin API (token JWT fresco por llamada).
|
|
Único sitio donde se construyen: cualquier cambio (Accept-Version,
|
|
encoding) se hace aquí y cubre todas las llamadas a Ghost."""
|
|
return {
|
|
"Authorization": f"Ghost {self._make_token()}",
|
|
"Accept-Version": "v5.0",
|
|
# Nunca heredar el default de aiohttp: con un backend brotli
|
|
# instalado anunciaría br y el decode roto de aiohttp 3.14 rompe
|
|
# la lectura de respuestas (incidente 2026-07-04, KNOWN-ISSUES.md).
|
|
"Accept-Encoding": SAFE_ACCEPT_ENCODING,
|
|
}
|
|
|
|
async def _admin_get(self, query: str, timeout: float = 15) -> dict | None:
|
|
"""GET a {url}/ghost/api/admin/{query} con auth + headers canónicos.
|
|
None si non-200 (logueado); las excepciones de red/parseo suben tal
|
|
cual para que cada caller aplique su propia política best-effort."""
|
|
async with aiohttp.ClientSession(
|
|
timeout=aiohttp.ClientTimeout(total=timeout)) as sess:
|
|
async with sess.get(f"{self.url}/ghost/api/admin/{query}",
|
|
headers=self._admin_headers()) as resp:
|
|
if resp.status != 200:
|
|
body = await resp.text()
|
|
logger.warning("Ghost admin GET non-200", query=query[:80],
|
|
status=resp.status, body=body[:200])
|
|
return None
|
|
return await resp.json()
|
|
|
|
async def _resolve_tags(self, slugs: list[str]) -> list[dict]:
|
|
"""ALLOWED_TAGS son SLUGS; Ghost casa los tags de un post por NOMBRE.
|
|
|
|
Mandarlos como `{"name": slug}` funciona por casualidad en EN, donde los
|
|
tags se llaman igual que su slug ("military-cases"), y rompe en ES,
|
|
donde se llaman "Casos Militares": Ghost no encuentra ninguno con ese
|
|
nombre y CREA uno nuevo llamado "casos-militares", que como ya tiene el
|
|
slug pillado acaba en `casos-militares-2`. Detectado el 2026-07-29 con
|
|
5 tags duplicados y 7 posts repartidos entre dos archivos flacos, uno
|
|
de ellos ofrecido a Google en el sitemap.
|
|
|
|
Se resuelve el slug a ID contra Ghost, que es lo único no ambiguo. Un
|
|
slug que no exista se descarta con aviso en vez de crearse: la lista es
|
|
cerrada, así que no existir significa que está mal escrita, y crear el
|
|
tag es precisamente el bug. Si no se resuelve nada (o Ghost no
|
|
responde) se cae al comportamiento anterior antes que dejar el post sin
|
|
ninguna categoría.
|
|
"""
|
|
data = await self._admin_get("tags/?limit=all")
|
|
if not data:
|
|
logger.warning("Ghost tags no legibles: se mandan por nombre",
|
|
slugs=slugs)
|
|
return [{"name": t} for t in slugs]
|
|
|
|
por_slug = {t.get("slug"): t.get("id") for t in data.get("tags", [])}
|
|
resueltos, perdidos = [], []
|
|
for s in slugs:
|
|
tid = por_slug.get(s)
|
|
if tid:
|
|
resueltos.append({"id": tid})
|
|
else:
|
|
perdidos.append(s)
|
|
if perdidos:
|
|
logger.warning("Ghost: slugs de tag inexistentes, descartados",
|
|
slugs=perdidos, lang=self.lang)
|
|
if not resueltos:
|
|
defecto = _DEFAULT_TAG.get(self.lang, "investigation")
|
|
tid = por_slug.get(defecto)
|
|
return [{"id": tid}] if tid else [{"name": defecto}]
|
|
return resueltos
|
|
|
|
async def find_draft_by_title(self, title: str,
|
|
since: float | None = None) -> dict | None:
|
|
"""Busca entre los drafts recientes uno con título exacto.
|
|
|
|
Mitad de recuperación del guard anti-duplicados de publish_draft: si el
|
|
POST fue aceptado por Ghost pero la lectura de la respuesta falló
|
|
(p.ej. decode roto), el draft existe aunque el caller viera una
|
|
excepción. Con `since` (epoch) solo casan drafts creados en este
|
|
intento — un draft viejo del mismo título de una run anterior NO cuenta
|
|
como "ya publicado". La comparación usa _norm_title (whitespace
|
|
colapsado + truncado a 255) para sobrevivir la normalización de Ghost.
|
|
Best-effort: None si no hay match o si falla."""
|
|
data = await self._admin_get(
|
|
"posts/?filter=status:draft&order=created_at%20desc&limit=15"
|
|
"&fields=id,title,slug,created_at"
|
|
)
|
|
if data is None:
|
|
return None
|
|
wanted = _norm_title(title)
|
|
for p in data.get("posts", []):
|
|
if _norm_title(p.get("title") or "") != wanted:
|
|
continue
|
|
if since is not None:
|
|
created = _parse_ghost_ts(p.get("created_at") or "")
|
|
# 60s de margen por posible desfase de reloj Ghost/bot.
|
|
if created is None or created < since - 60:
|
|
continue
|
|
return p
|
|
return None
|
|
|
|
async def publish_draft(self, title: str, markdown_content: str,
|
|
tags: list[str] | None = None,
|
|
seo: dict | None = None,
|
|
body_html: str | None = None) -> dict:
|
|
"""Create a Ghost DRAFT. status is ALWAYS "draft" — never published.
|
|
|
|
Purely-additive SEO hooks (both default None → byte-identical to the
|
|
original behavior):
|
|
* body_html — pre-converted + internal-linked HTML from the caller; used
|
|
verbatim when given, else markdown_content is converted as before.
|
|
* seo — a generate_seo_fields dict; when given, its meta/OG/Twitter fields
|
|
are added to the post and its (allow-list) tags are used.
|
|
"""
|
|
# Caller-supplied linked HTML wins; otherwise convert markdown as before.
|
|
html = body_html if body_html is not None else self._build_html(markdown_content)
|
|
|
|
logger.info("Ghost publish_draft", html_length=len(html),
|
|
html_preview=html[:200], seo=bool(seo))
|
|
|
|
if not html.strip():
|
|
raise ValueError("Ghost: HTML vacío tras conversión markdown — contenido no enviado")
|
|
|
|
# Ghost 5.x (Lexical editor): el campo "html" es solo de lectura en la API.
|
|
# La forma fiable de enviar HTML arbitrario es via mobiledoc con HTML card,
|
|
# que Ghost acepta en todas las versiones de v5 y renderiza sin conversión.
|
|
mobiledoc = json.dumps({
|
|
"version": "0.3.1",
|
|
"atoms": [],
|
|
"cards": [["html", {"html": html}]],
|
|
"markups": [],
|
|
"sections": [[10, 0]],
|
|
})
|
|
|
|
# Language-aware default tag (fixes the hardcoded "investigacion" for EN).
|
|
tag_names = tags or [_DEFAULT_TAG.get(self.lang, "investigation")]
|
|
post_obj = {
|
|
"title": title,
|
|
"mobiledoc": mobiledoc,
|
|
"status": "draft", # NEVER "published" — draft only, always.
|
|
"tags": await self._resolve_tags(tag_names),
|
|
}
|
|
if seo:
|
|
post_obj.update({
|
|
"meta_title": seo["meta_title"],
|
|
"meta_description": seo["meta_description"],
|
|
"custom_excerpt": seo["custom_excerpt"],
|
|
"og_title": seo["og_title"],
|
|
"og_description": seo["og_description"],
|
|
"twitter_title": seo["twitter_title"],
|
|
"twitter_description": seo["twitter_description"],
|
|
})
|
|
body = {"posts": [post_obj]}
|
|
attempt_start = time.time()
|
|
async with aiohttp.ClientSession() as sess:
|
|
async with sess.post(
|
|
f"{self.url}/ghost/api/admin/posts/",
|
|
json=body,
|
|
headers=self._admin_headers(),
|
|
) as resp:
|
|
if resp.status not in (200, 201):
|
|
text = await resp.text()
|
|
raise ValueError(f"Ghost API {resp.status}: {text[:300]}")
|
|
# Guard anti-duplicados (incidente 2026-07-04): el 2xx confirma
|
|
# que Ghost YA creó el draft; si la lectura del body falla
|
|
# (p.ej. decode br roto), recuperamos el draft recién creado en
|
|
# vez de propagar — propagar hacía que el caller (o el usuario
|
|
# con /publish) reintentara y duplicara. El guard vive AQUÍ para
|
|
# cubrir a todos los callers, y solo se activa tras un POST
|
|
# aceptado: un fallo pre-POST jamás lo dispara.
|
|
try:
|
|
return await resp.json()
|
|
except Exception as read_err:
|
|
existing = None
|
|
try:
|
|
existing = await self.find_draft_by_title(
|
|
title, since=attempt_start)
|
|
except Exception as find_err:
|
|
logger.warning("publish_draft: draft-exists check failed",
|
|
error=str(find_err))
|
|
if existing:
|
|
logger.warning(
|
|
"publish_draft: POST aceptado pero lectura de la "
|
|
"respuesta falló; draft recuperado sin re-publicar",
|
|
post_id=existing["id"], error=str(read_err))
|
|
return {"posts": [existing]}
|
|
raise
|
|
|
|
|
|
# ─── Output generation ────────────────────────────────────────────────────────
|
|
|
|
class OutputGenerator:
|
|
def __init__(self, db: ResearchDB, ollama: OllamaClient, processor: ContentProcessor):
|
|
self.db = db
|
|
self.ollama = ollama
|
|
self.processor = processor
|
|
# Set during a blog generation when the autofill/dry-run path runs: a short
|
|
# markdown SEO summary the bot sends as a SEPARATE Telegram message (so it is
|
|
# never buried inside the long .md document). None on the flag-off path.
|
|
self.last_publish_notice: str | None = None
|
|
|
|
async def _publish_blog_to_ghost(self, lang: str, full_output: str, topic: str,
|
|
session_id: int, seo_override: str | 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 /
|
|
fallback bare-draft path only). For the autofill 'on'/'dryrun' paths it sets
|
|
self.last_publish_notice (a separate Telegram message) and returns "".
|
|
|
|
NEVER raises and NEVER blocks publishing: any autofill failure degrades to
|
|
today's exact bare-draft publish. status is always "draft".
|
|
"""
|
|
ghost = GhostPublisher(lang=lang)
|
|
if not ghost.is_configured():
|
|
return ""
|
|
title = _extract_title(full_output) or topic
|
|
mode = _resolve_seo_mode(seo_override)
|
|
collision_note = await self._collision_note(lang, title)
|
|
|
|
if mode in ("on", "dryrun"):
|
|
try:
|
|
from src.seo.autofill import (
|
|
fetch_published_menu, generate_seo_fields, insert_internal_links,
|
|
)
|
|
article_md = _strip_researchowl_header(full_output)
|
|
menu = await fetch_published_menu(lang)
|
|
seo = await generate_seo_fields(
|
|
article_md, menu, lang, title=title,
|
|
db=self.db, session_id=session_id,
|
|
)
|
|
if seo is None:
|
|
raise RuntimeError("generate_seo_fields returned None")
|
|
# Build the SAME html that will be posted, then link it deterministically.
|
|
html = ghost._build_html(article_md)
|
|
linked_html, inserted_pairs = insert_internal_links(
|
|
html, seo["internal_links"], menu, lang)
|
|
|
|
if mode == "dryrun":
|
|
# Bare draft (today's write path) — do NOT write seo fields; only
|
|
# surface the proposal so Jose can inspect before trusting writes.
|
|
result = await ghost.publish_draft(title, full_output)
|
|
post = result["posts"][0]
|
|
self.last_publish_notice = (
|
|
_seo_dryrun_message(ghost, post, seo, inserted_pairs)
|
|
+ collision_note)
|
|
else:
|
|
result = await ghost.publish_draft(
|
|
title, full_output, tags=seo["tags"], seo=seo,
|
|
body_html=linked_html)
|
|
post = result["posts"][0]
|
|
self.last_publish_notice = (
|
|
_seo_live_message(ghost, post, seo, inserted_pairs)
|
|
+ collision_note)
|
|
logger.info("Auto-published blog to Ghost",
|
|
mode=mode, post_id=post["id"], links=len(inserted_pairs))
|
|
return ""
|
|
except Exception as e:
|
|
logger.warning("SEO autofill failed; falling back to bare draft",
|
|
error=str(e))
|
|
# Sin guard anti-duplicados aquí: vive DENTRO de publish_draft
|
|
# (solo se activa tras un POST aceptado). Un fallo pre-POST
|
|
# (Ollama caído, menú, links) cae aquí y DEBE publicar el
|
|
# contenido nuevo — un guard por título casaba con drafts
|
|
# viejos del mismo topic y lo descartaba en silencio.
|
|
# fall through to the bare-draft publish below
|
|
|
|
# OFF mode, or autofill failed → today's exact bare-draft publish.
|
|
try:
|
|
result = await ghost.publish_draft(title, full_output)
|
|
post = result["posts"][0]
|
|
logger.info("Auto-published blog to Ghost (bare)", post_id=post["id"])
|
|
return _bare_ghost_notice(ghost, post) + collision_note
|
|
except Exception as e:
|
|
logger.warning("Auto-publish to Ghost failed", error=str(e))
|
|
return ""
|
|
|
|
async def _collision_note(self, lang: str, title: str) -> str:
|
|
"""Aviso de canibalización del título propuesto contra los posts
|
|
published+scheduled del sitio. Los DOS idiomas desde 2026-07-21: estuvo
|
|
capado a EN porque las stopwords del motor eran inglesas y en español
|
|
«que» o «los» contaban como identidad de caso; con las stopwords ES y el
|
|
tokenizador sin acentos ya no. Se destapó generando a propósito un
|
|
Canarias 1976 que ya existía: el motor lo detectaba y el aviso no salía.
|
|
Nunca lanza y nunca bloquea: el draft se crea igual y el aviso viaja en
|
|
el notice de Telegram.
|
|
"""
|
|
try:
|
|
from src.seo.autofill import collision_notice, fetch_collision_corpus
|
|
corpus = await fetch_collision_corpus(lang)
|
|
return collision_notice(title, corpus) or ""
|
|
except Exception as e:
|
|
logger.warning("Topic collision check failed — skipped", error=str(e))
|
|
return ""
|
|
|
|
async def generate(self, session_id: int, output_type: OutputType,
|
|
progress_callback=None, lang: str = "es",
|
|
seo_override: str | None = None) -> str:
|
|
"""Generate an output for a research session"""
|
|
self.last_publish_notice = None
|
|
if output_type in (OutputType.REPORT_EXTENDED,
|
|
OutputType.BLOG_EXTENDED,
|
|
OutputType.PODCAST_EXTENDED):
|
|
return await self.generate_extended(session_id, output_type, progress_callback,
|
|
lang=lang, seo_override=seo_override)
|
|
|
|
session = await self.db.get_session(session_id)
|
|
if not session:
|
|
raise ValueError(f"Session {session_id} not found")
|
|
|
|
topic = session["topic"]
|
|
logger.info("Generating output", type=output_type, topic=topic)
|
|
|
|
if progress_callback:
|
|
await progress_callback(f"🔍 Retrieving best research material for {output_type}...")
|
|
|
|
# RAG: get most relevant context for this output type
|
|
query = self._get_rag_query(output_type, topic)
|
|
context = await self.processor.rag_query(session_id, query, top_k=80)
|
|
|
|
if not context:
|
|
# Fallback: use raw top chunks
|
|
chunks = await self.db.get_top_chunks(session_id, limit=20)
|
|
context = "\n\n---\n\n".join(c["content"] for c in chunks)
|
|
|
|
if not context:
|
|
raise ValueError("No processed content available. Run /process first.")
|
|
|
|
backend = "Claude Haiku" if settings.anthropic_api_key else "Ollama"
|
|
if progress_callback:
|
|
await progress_callback(f"✍️ Generando {output_type} con {backend}... (2-5 min)")
|
|
|
|
# Build prompt
|
|
system = self._get_system(output_type)
|
|
prompt = PROMPTS[output_type].format(topic=topic, context=context)
|
|
|
|
if lang == "en" and output_type == OutputType.BLOG:
|
|
system = BLOG_SYSTEM_EN
|
|
prompt = BLOG_PROMPT_EN.format(topic=topic, context=context)
|
|
|
|
output = await self._generate(prompt, system, output_type, session_id)
|
|
|
|
# Add metadata header
|
|
stats = await self.db.get_session_stats(session_id)
|
|
header = self._build_header(topic, output_type, session, stats)
|
|
full_output = header + "\n\n" + output
|
|
|
|
# Save to DB
|
|
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)
|
|
|
|
logger.info("Output generated", type=output_type, length=len(full_output))
|
|
return full_output + ghost_notice
|
|
|
|
async def _generate(self, prompt: str, system: str, output_type: OutputType,
|
|
session_id: int | None = None) -> str:
|
|
if settings.anthropic_api_key:
|
|
return await self._generate_with_claude(prompt, system, output_type, session_id)
|
|
return await self._generate_with_ollama(prompt, system)
|
|
|
|
async def _generate_with_claude(self, prompt: str, system: str, output_type: OutputType,
|
|
session_id: int | None = None) -> str:
|
|
max_tokens = 4096 if output_type == OutputType.THREAD else 16000
|
|
try:
|
|
client = get_anthropic_client()
|
|
msg = await client.messages.create(
|
|
model=settings.claude_model,
|
|
max_tokens=max_tokens,
|
|
system=system,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
)
|
|
if session_id is not None:
|
|
try:
|
|
await self.db.log_api_call(
|
|
session_id, "generation", settings.claude_model,
|
|
msg.usage.input_tokens, msg.usage.output_tokens
|
|
)
|
|
except Exception as log_err:
|
|
logger.warning("Failed to log API usage", error=str(log_err))
|
|
return msg.content[0].text.strip()
|
|
except Exception as e:
|
|
logger.warning("Claude generation failed, falling back to Ollama", error=str(e))
|
|
return await self._generate_with_ollama(prompt, system)
|
|
|
|
async def _generate_with_ollama(self, prompt: str, system: str) -> str:
|
|
return await self.ollama.generate(prompt, system=system, timeout=300, temperature=0.7)
|
|
|
|
def _get_rag_query(self, output_type: OutputType, topic: str) -> str:
|
|
queries = {
|
|
OutputType.PODCAST: f"{topic} story narrative facts interesting",
|
|
OutputType.BLOG: f"{topic} key facts evidence analysis",
|
|
OutputType.REPORT: f"{topic} evidence data official findings",
|
|
OutputType.THREAD: f"{topic} surprising facts shocking revelations",
|
|
}
|
|
return queries.get(output_type, topic)
|
|
|
|
def _get_system(self, output_type: OutputType) -> str:
|
|
systems = {
|
|
OutputType.PODCAST: PODCAST_SYSTEM,
|
|
OutputType.BLOG: BLOG_SYSTEM,
|
|
OutputType.REPORT: REPORT_SYSTEM,
|
|
OutputType.THREAD: THREAD_SYSTEM,
|
|
}
|
|
return systems.get(output_type, "You are a helpful research assistant.")
|
|
|
|
async def generate_extended(self, session_id: int, output_type: OutputType,
|
|
progress_callback=None, lang: str = "es",
|
|
seo_override: str | None = None) -> str:
|
|
"""
|
|
Generación por secciones para outputs exhaustivos.
|
|
1. Recupera muestra de contexto para el outline
|
|
2. Genera outline con Claude (lista de secciones)
|
|
3. Para cada sección: RAG específico → genera sección
|
|
4. Concatena y guarda
|
|
"""
|
|
session = await self.db.get_session(session_id)
|
|
if not session:
|
|
raise ValueError(f"Session {session_id} not found")
|
|
topic = session["topic"]
|
|
|
|
# Paso 1: contexto resumen para el outline (top 10 chunks)
|
|
top_chunks = await self.db.get_top_chunks(session_id, limit=10)
|
|
if not top_chunks:
|
|
raise ValueError("No processed content available. Run /process first.")
|
|
context_summary = "\n\n".join(
|
|
f"- {c.get('title', '')}: {c['content'][:300]}"
|
|
for c in top_chunks
|
|
)
|
|
|
|
if progress_callback:
|
|
await progress_callback("🗂️ Generando estructura del documento…")
|
|
|
|
# Paso 2: outline
|
|
base_type = output_type.value.replace("_extended", "")
|
|
outline_prompts = {
|
|
"report": OUTLINE_REPORT,
|
|
"blog": OUTLINE_BLOG,
|
|
"podcast": OUTLINE_PODCAST,
|
|
}
|
|
outline_prompt = outline_prompts[base_type].format(
|
|
topic=topic, context_summary=context_summary
|
|
)
|
|
|
|
outline_json = await self._generate_raw(outline_prompt, session_id)
|
|
try:
|
|
import json as _json
|
|
clean = outline_json.strip()
|
|
if clean.startswith("```"):
|
|
clean = "\n".join(clean.split("\n")[1:])
|
|
if clean.endswith("```"):
|
|
clean = "\n".join(clean.split("\n")[:-1])
|
|
sections = _json.loads(clean.strip())
|
|
except Exception as e:
|
|
logger.error("Failed to parse outline", error=str(e), raw=outline_json[:200])
|
|
raise ValueError(f"No se pudo generar el outline: {e}")
|
|
|
|
if progress_callback:
|
|
await progress_callback(
|
|
f"✍️ Generando {len(sections)} secciones… (esto tardará varios minutos)"
|
|
)
|
|
|
|
# Paso 3: generar cada sección
|
|
base_output_type = OutputType(base_type)
|
|
system = self._get_system(base_output_type)
|
|
if lang == "en" and output_type == OutputType.BLOG_EXTENDED:
|
|
system = BLOG_SYSTEM_EN
|
|
sections_text = []
|
|
|
|
for i, section in enumerate(sections, 1):
|
|
title = section.get("title", f"Sección {i}")
|
|
query = section.get("query", topic)
|
|
target_words = section.get("words", 600)
|
|
|
|
if progress_callback:
|
|
await progress_callback(
|
|
f"✍️ Sección {i}/{len(sections)}: {title[:40]}…"
|
|
)
|
|
|
|
section_context = await self.processor.rag_query(session_id, query, top_k=40)
|
|
if not section_context:
|
|
section_context = context_summary
|
|
|
|
lang_rule = "- Write in English\n" if lang == "en" else "- Escribe en español\n"
|
|
section_prompt = (
|
|
f"Escribe la sección '{title}' del {base_type} sobre: '{topic}'\n\n"
|
|
f"REGLAS:\n"
|
|
f"- NO incluyas ningún título o encabezado al inicio de tu respuesta — el título de la sección ya está incluido externamente\n"
|
|
f"- Esta es UNA sección de un documento más largo — no repitas introducción ni conclusión general\n"
|
|
f"- No incluyas encabezados del documento completo, solo el contenido de esta sección\n"
|
|
f"- Objetivo: aproximadamente {target_words} palabras\n"
|
|
f"- Usa SOLO información del material siguiente — no inventes datos\n"
|
|
f"{lang_rule}"
|
|
f"\nMATERIAL:\n{section_context}"
|
|
)
|
|
|
|
section_text = await self._generate(
|
|
section_prompt, system, base_output_type, session_id
|
|
)
|
|
sections_text.append(f"## {title}\n\n{section_text}")
|
|
|
|
# Paso 4: concatenar
|
|
full_content = "\n\n---\n\n".join(sections_text)
|
|
stats = await self.db.get_session_stats(session_id)
|
|
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)
|
|
|
|
# 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)
|
|
|
|
logger.info("Extended output generated", type=output_type,
|
|
sections=len(sections), length=len(full_output))
|
|
return full_output + ghost_notice
|
|
|
|
async def _generate_raw(self, prompt: str,
|
|
session_id: int | None = None) -> str:
|
|
if settings.anthropic_api_key:
|
|
try:
|
|
client = get_anthropic_client()
|
|
msg = await client.messages.create(
|
|
model=settings.claude_model,
|
|
max_tokens=2048,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
)
|
|
if session_id is not None:
|
|
try:
|
|
await self.db.log_api_call(
|
|
session_id, "outline", settings.claude_model,
|
|
msg.usage.input_tokens, msg.usage.output_tokens
|
|
)
|
|
except Exception:
|
|
pass
|
|
return msg.content[0].text.strip()
|
|
except Exception as e:
|
|
logger.warning("Claude outline failed", error=str(e))
|
|
raise
|
|
raise ValueError("Claude API key required for extended generation")
|
|
|
|
def _build_header(self, topic: str, output_type: OutputType,
|
|
session: dict, stats: dict) -> str:
|
|
from datetime import datetime
|
|
dt = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
|
return f"""---
|
|
ResearchOwl | {output_type.upper()} OUTPUT
|
|
Topic: {topic}
|
|
Generated: {dt}
|
|
Sources: {stats.get('scraped', 0)} scraped | {stats.get('failed', 0)} failed
|
|
Iterations: {session.get('iterations', 0)}
|
|
Total words researched: {session.get('total_words', 0):,}
|
|
---
|
|
"""
|
|
|
|
|
|
def _remove_duplicate_headings(text: str) -> str:
|
|
lines = text.split('\n')
|
|
result = []
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i].rstrip()
|
|
if line.startswith('# ') and not line.startswith('## '):
|
|
h1_text = line[2:].strip().lower()
|
|
window = result[-5:] if len(result) >= 5 else result[:]
|
|
recent_h2s = {
|
|
p.strip()[3:].strip().lower()
|
|
for p in window
|
|
if p.strip().startswith('## ')
|
|
}
|
|
if h1_text in recent_h2s:
|
|
i += 1
|
|
continue
|
|
result.append(lines[i])
|
|
i += 1
|
|
return '\n'.join(result)
|
|
|
|
|
|
def generate_pdf(content: str, title: str = "ResearchOwl Output") -> bytes:
|
|
content = _remove_duplicate_headings(content)
|
|
try:
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.units import cm
|
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
|
|
from reportlab.lib.enums import TA_LEFT
|
|
from reportlab.lib import colors
|
|
import io
|
|
import re
|
|
except ImportError:
|
|
raise ImportError("reportlab is required for PDF export — pip install reportlab")
|
|
|
|
buf = io.BytesIO()
|
|
doc = SimpleDocTemplate(
|
|
buf,
|
|
pagesize=A4,
|
|
rightMargin=2 * cm,
|
|
leftMargin=2 * cm,
|
|
topMargin=2.5 * cm,
|
|
bottomMargin=2 * cm,
|
|
title=title,
|
|
)
|
|
|
|
base = getSampleStyleSheet()
|
|
normal = ParagraphStyle("RO_Normal", parent=base["Normal"],
|
|
fontSize=10, leading=14, spaceAfter=4)
|
|
h1 = ParagraphStyle("RO_H1", parent=base["Heading1"],
|
|
fontSize=18, spaceBefore=12, spaceAfter=6,
|
|
textColor=colors.HexColor("#1a1a2e"))
|
|
h2 = ParagraphStyle("RO_H2", parent=base["Heading2"],
|
|
fontSize=14, spaceBefore=10, spaceAfter=4,
|
|
textColor=colors.HexColor("#16213e"))
|
|
h3 = ParagraphStyle("RO_H3", parent=base["Heading3"],
|
|
fontSize=12, spaceBefore=8, spaceAfter=4)
|
|
code_style = ParagraphStyle("RO_Code", parent=base["Code"],
|
|
fontSize=9, leading=12, fontName="Courier",
|
|
backColor=colors.HexColor("#f4f4f4"), spaceAfter=4)
|
|
bullet_style = ParagraphStyle("RO_Bullet", parent=normal,
|
|
leftIndent=20, bulletIndent=10, spaceAfter=2)
|
|
|
|
def md_to_para(text: str) -> str:
|
|
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
|
text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
|
|
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
|
text = re.sub(r'_(.+?)_', r'<i>\1</i>', text)
|
|
text = re.sub(r'`(.+?)`', r'<font name="Courier">\1</font>', text)
|
|
return text
|
|
|
|
story = []
|
|
lines = content.split("\n")
|
|
in_code = False
|
|
code_buf = []
|
|
|
|
for line in lines:
|
|
if line.startswith("```"):
|
|
if not in_code:
|
|
in_code = True
|
|
code_buf = []
|
|
else:
|
|
in_code = False
|
|
try:
|
|
story.append(Paragraph(
|
|
"<br/>".join(l.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
for l in code_buf),
|
|
code_style
|
|
))
|
|
except Exception:
|
|
pass
|
|
continue
|
|
|
|
if in_code:
|
|
code_buf.append(line)
|
|
continue
|
|
|
|
if re.match(r'^[-*_]{3,}$', line.strip()):
|
|
story.append(HRFlowable(width="100%", thickness=0.5,
|
|
color=colors.grey, spaceAfter=6))
|
|
continue
|
|
|
|
try:
|
|
if line.startswith("### "):
|
|
story.append(Paragraph(md_to_para(line[4:]), h3))
|
|
elif line.startswith("## "):
|
|
story.append(Paragraph(md_to_para(line[3:]), h2))
|
|
elif line.startswith("# "):
|
|
story.append(Paragraph(md_to_para(line[2:]), h1))
|
|
elif re.match(r'^[-*+] ', line):
|
|
story.append(Paragraph("• " + md_to_para(line[2:]), bullet_style))
|
|
elif re.match(r'^\d+\. ', line):
|
|
story.append(Paragraph(md_to_para(line), bullet_style))
|
|
elif line.strip() == "":
|
|
story.append(Spacer(1, 6))
|
|
else:
|
|
story.append(Paragraph(md_to_para(line), normal))
|
|
except Exception:
|
|
try:
|
|
story.append(Paragraph(line[:300], normal))
|
|
except Exception:
|
|
pass
|
|
|
|
doc.build(story)
|
|
return buf.getvalue()
|
|
|
|
|
|
async def generate_diff_summary(
|
|
topic: str,
|
|
new_urls: set,
|
|
old_urls: set,
|
|
new_chunks: list,
|
|
session_id: int,
|
|
db,
|
|
) -> str | None:
|
|
from src.config import settings
|
|
import structlog
|
|
diff_logger = structlog.get_logger()
|
|
|
|
added_urls = new_urls - old_urls
|
|
pct_new = len(added_urls) / max(len(new_urls), 1)
|
|
|
|
diff_logger.info("Diff analysis", topic=topic,
|
|
new_urls=len(new_urls), old_urls=len(old_urls),
|
|
added=len(added_urls), pct_new=round(pct_new, 2))
|
|
|
|
if pct_new < 0.20 and len(added_urls) < 5:
|
|
diff_logger.info("Diff: no significant new sources", topic=topic)
|
|
return None
|
|
|
|
if not new_chunks:
|
|
return None
|
|
|
|
context = "\n\n---\n\n".join(
|
|
f"[{c.get('source_type', 'web').upper()}] {c.get('title', '')}\n{c['content'][:400]}"
|
|
for c in new_chunks[:20]
|
|
)
|
|
|
|
if not settings.anthropic_api_key:
|
|
return (
|
|
f"📊 *Novedades detectadas sobre {topic}*\n\n"
|
|
f"• {len(added_urls)} fuentes nuevas encontradas\n"
|
|
f"• {len(new_chunks)} chunks de contenido procesados\n\n"
|
|
f"Usa /generate report para ver el análisis completo."
|
|
)
|
|
|
|
try:
|
|
client = get_anthropic_client()
|
|
prompt = (
|
|
f'Analiza el siguiente material de investigación sobre "{topic}" '
|
|
f'y genera un resumen BREVE (máximo 300 palabras) de las novedades '
|
|
f'más importantes encontradas. Escribe en español.\n\n'
|
|
f'Si el contenido es muy similar a investigaciones anteriores o no '
|
|
f'contiene información genuinamente nueva, responde SOLO con: '
|
|
f'"SIN_NOVEDADES"\n\n'
|
|
f'Material nuevo:\n{context}'
|
|
)
|
|
msg = await client.messages.create(
|
|
model=settings.claude_model,
|
|
max_tokens=500,
|
|
messages=[{"role": "user", "content": prompt}]
|
|
)
|
|
summary = msg.content[0].text.strip()
|
|
|
|
if summary == "SIN_NOVEDADES":
|
|
diff_logger.info("Diff: Claude found no new information", topic=topic)
|
|
return None
|
|
|
|
try:
|
|
await db.log_api_call(session_id, "diff", settings.claude_model,
|
|
msg.usage.input_tokens, msg.usage.output_tokens)
|
|
except Exception:
|
|
pass
|
|
|
|
return f"🔔 *Novedades — {topic}*\n\n{summary}\n\nUsa /generate para report completo."
|
|
|
|
except Exception as e:
|
|
diff_logger.warning("Diff summary generation failed", error=str(e))
|
|
return (
|
|
f"📊 *Actualización — {topic}*\n\n"
|
|
f"• {len(added_urls)} fuentes nuevas\n"
|
|
f"Usa /generate report para ver el análisis completo."
|
|
)
|
|
|
|
|
|
async def generate_comparison(
|
|
topic_a: str,
|
|
topic_b: str,
|
|
context_a: str,
|
|
context_b: str,
|
|
session_id_a: int,
|
|
db,
|
|
) -> str:
|
|
from src.config import settings
|
|
import structlog
|
|
cmp_logger = structlog.get_logger()
|
|
|
|
if not settings.anthropic_api_key:
|
|
raise ValueError("Claude API key required for comparison")
|
|
|
|
def _truncate(text: str, max_words: int = 3000) -> str:
|
|
words = text.split()
|
|
if len(words) > max_words:
|
|
return " ".join(words[:max_words]) + "\n\n[... contenido adicional truncado ...]"
|
|
return text
|
|
|
|
context_a = _truncate(context_a)
|
|
context_b = _truncate(context_b)
|
|
|
|
prompt = (
|
|
f'Eres un analista experto. Compara en profundidad estos dos temas:\n'
|
|
f'TEMA A: "{topic_a}"\n'
|
|
f'TEMA B: "{topic_b}"\n\n'
|
|
f'Escribe el análisis en español con esta estructura:\n\n'
|
|
f'## Resumen comparativo\n'
|
|
f'(2-3 párrafos con las diferencias y similitudes más importantes)\n\n'
|
|
f'## {topic_a}\n'
|
|
f'(Puntos clave únicos de este tema)\n\n'
|
|
f'## {topic_b}\n'
|
|
f'(Puntos clave únicos de este tema)\n\n'
|
|
f'## Similitudes\n'
|
|
f'(Qué tienen en común)\n\n'
|
|
f'## Diferencias clave\n'
|
|
f'(Tabla markdown o lista de las diferencias más relevantes)\n\n'
|
|
f'## Conclusión\n'
|
|
f'(Cuál es mejor/más relevante según el contexto, o qué conclusión se extrae)\n\n'
|
|
f'---\n'
|
|
f'MATERIAL DE INVESTIGACIÓN — {topic_a}:\n{context_a}\n\n'
|
|
f'---\n'
|
|
f'MATERIAL DE INVESTIGACIÓN — {topic_b}:\n{context_b}\n'
|
|
)
|
|
|
|
try:
|
|
client = get_anthropic_client()
|
|
msg = await client.messages.create(
|
|
model=settings.claude_model,
|
|
max_tokens=8192,
|
|
messages=[{"role": "user", "content": prompt}]
|
|
)
|
|
try:
|
|
await db.log_api_call(
|
|
session_id_a, "comparison", settings.claude_model,
|
|
msg.usage.input_tokens, msg.usage.output_tokens
|
|
)
|
|
except Exception:
|
|
pass
|
|
return msg.content[0].text.strip()
|
|
except Exception as e:
|
|
cmp_logger.error("Comparison generation failed", error=str(e))
|
|
raise
|