feat: fase 2 — generación por secciones report_extended, blog_extended, podcast_extended
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
This commit is contained in:
+10
-1
@@ -141,7 +141,9 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
"`/status` — Check current research progress\n"
|
"`/status` — Check current research progress\n"
|
||||||
"`/finish` — Stop research and proceed to generation\n"
|
"`/finish` — Stop research and proceed to generation\n"
|
||||||
"`/process` — Manually trigger chunk processing\n"
|
"`/process` — Manually trigger chunk processing\n"
|
||||||
"`/generate <type>` — Generate output (podcast|blog|report|thread)\n"
|
"`/generate <type>` — Generate output\n"
|
||||||
|
" Tipos: podcast|blog|report|thread\n"
|
||||||
|
" Extended: podcast_extended|blog_extended|report_extended\n"
|
||||||
"`/sources` — List all sources found\n"
|
"`/sources` — List all sources found\n"
|
||||||
"`/outputs` — List generated outputs\n"
|
"`/outputs` — List generated outputs\n"
|
||||||
"`/costs` — Show API usage costs\n"
|
"`/costs` — Show API usage costs\n"
|
||||||
@@ -274,6 +276,10 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
"thread": OutputType.THREAD,
|
"thread": OutputType.THREAD,
|
||||||
"hilo": OutputType.THREAD,
|
"hilo": OutputType.THREAD,
|
||||||
"informe": OutputType.REPORT,
|
"informe": OutputType.REPORT,
|
||||||
|
"report_extended": OutputType.REPORT_EXTENDED,
|
||||||
|
"blog_extended": OutputType.BLOG_EXTENDED,
|
||||||
|
"podcast_extended": OutputType.PODCAST_EXTENDED,
|
||||||
|
"informe_extended": OutputType.REPORT_EXTENDED,
|
||||||
}
|
}
|
||||||
|
|
||||||
if output_arg not in type_map:
|
if output_arg not in type_map:
|
||||||
@@ -331,6 +337,9 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
OutputType.BLOG: "post.md",
|
OutputType.BLOG: "post.md",
|
||||||
OutputType.REPORT: "report.md",
|
OutputType.REPORT: "report.md",
|
||||||
OutputType.THREAD: "thread.txt",
|
OutputType.THREAD: "thread.txt",
|
||||||
|
OutputType.REPORT_EXTENDED: "report_extended.md",
|
||||||
|
OutputType.BLOG_EXTENDED: "blog_extended.md",
|
||||||
|
OutputType.PODCAST_EXTENDED: "script_extended.md",
|
||||||
}
|
}
|
||||||
filename = f"researchowl_{session['topic'][:30].replace(' ', '_')}_{ext_map[output_type]}"
|
filename = f"researchowl_{session['topic'][:30].replace(' ', '_')}_{ext_map[output_type]}"
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class OutputType(str, Enum):
|
|||||||
BLOG = "blog"
|
BLOG = "blog"
|
||||||
REPORT = "report"
|
REPORT = "report"
|
||||||
THREAD = "thread"
|
THREAD = "thread"
|
||||||
|
REPORT_EXTENDED = "report_extended"
|
||||||
|
BLOG_EXTENDED = "blog_extended"
|
||||||
|
PODCAST_EXTENDED = "podcast_extended"
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
|
|||||||
@@ -135,6 +135,61 @@ Escribe el hilo (un tweet por línea, nada más):"""
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
OUTLINE_REPORT = """
|
||||||
|
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 = """
|
||||||
|
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 = """
|
||||||
|
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}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class OutputGenerator:
|
class OutputGenerator:
|
||||||
def __init__(self, db: ResearchDB, ollama: OllamaClient, processor: ContentProcessor):
|
def __init__(self, db: ResearchDB, ollama: OllamaClient, processor: ContentProcessor):
|
||||||
self.db = db
|
self.db = db
|
||||||
@@ -144,6 +199,11 @@ class OutputGenerator:
|
|||||||
async def generate(self, session_id: int, output_type: OutputType,
|
async def generate(self, session_id: int, output_type: OutputType,
|
||||||
progress_callback=None) -> str:
|
progress_callback=None) -> str:
|
||||||
"""Generate an output for a research session"""
|
"""Generate an output for a research session"""
|
||||||
|
if output_type in (OutputType.REPORT_EXTENDED,
|
||||||
|
OutputType.BLOG_EXTENDED,
|
||||||
|
OutputType.PODCAST_EXTENDED):
|
||||||
|
return await self.generate_extended(session_id, output_type, progress_callback)
|
||||||
|
|
||||||
session = await self.db.get_session(session_id)
|
session = await self.db.get_session(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise ValueError(f"Session {session_id} not found")
|
raise ValueError(f"Session {session_id} not found")
|
||||||
@@ -239,6 +299,132 @@ class OutputGenerator:
|
|||||||
}
|
}
|
||||||
return systems.get(output_type, "You are a helpful research assistant.")
|
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) -> 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)
|
||||||
|
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
|
||||||
|
|
||||||
|
section_prompt = (
|
||||||
|
f"Escribe la sección '{title}' del {base_type} sobre: '{topic}'\n\n"
|
||||||
|
f"REGLAS:\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"- Escribe en español\n\n"
|
||||||
|
f"MATERIAL:\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)
|
||||||
|
logger.info("Extended output generated", type=output_type,
|
||||||
|
sections=len(sections), length=len(full_output))
|
||||||
|
return full_output
|
||||||
|
|
||||||
|
async def _generate_raw(self, prompt: str,
|
||||||
|
session_id: int | None = None) -> str:
|
||||||
|
if settings.anthropic_api_key:
|
||||||
|
import anthropic
|
||||||
|
try:
|
||||||
|
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||||
|
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,
|
def _build_header(self, topic: str, output_type: OutputType,
|
||||||
session: dict, stats: dict) -> str:
|
session: dict, stats: dict) -> str:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
Reference in New Issue
Block a user