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"
|
||||
"`/finish` — Stop research and proceed to generation\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"
|
||||
"`/outputs` — List generated outputs\n"
|
||||
"`/costs` — Show API usage costs\n"
|
||||
@@ -274,6 +276,10 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"thread": OutputType.THREAD,
|
||||
"hilo": OutputType.THREAD,
|
||||
"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:
|
||||
@@ -331,6 +337,9 @@ async def cmd_generate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
OutputType.BLOG: "post.md",
|
||||
OutputType.REPORT: "report.md",
|
||||
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]}"
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ class OutputType(str, Enum):
|
||||
BLOG = "blog"
|
||||
REPORT = "report"
|
||||
THREAD = "thread"
|
||||
REPORT_EXTENDED = "report_extended"
|
||||
BLOG_EXTENDED = "blog_extended"
|
||||
PODCAST_EXTENDED = "podcast_extended"
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self, db: ResearchDB, ollama: OllamaClient, processor: ContentProcessor):
|
||||
self.db = db
|
||||
@@ -144,6 +199,11 @@ class OutputGenerator:
|
||||
async def generate(self, session_id: int, output_type: OutputType,
|
||||
progress_callback=None) -> str:
|
||||
"""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)
|
||||
if not session:
|
||||
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.")
|
||||
|
||||
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,
|
||||
session: dict, stats: dict) -> str:
|
||||
from datetime import datetime
|
||||
|
||||
Reference in New Issue
Block a user