260 lines
10 KiB
Python
260 lines
10 KiB
Python
"""
|
|
ResearchOwl Generators
|
|
Produces structured outputs from processed research using Claude or Ollama
|
|
"""
|
|
import structlog
|
|
|
|
from src.config import settings
|
|
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."
|
|
)
|
|
|
|
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
|
|
- Objetivo: 1000-1500 palabras
|
|
|
|
ESTRUCTURA:
|
|
# [Titular impactante]
|
|
|
|
[Párrafo gancho — el hecho más sorprendente]
|
|
|
|
## Contexto
|
|
[Contexto — qué, cuándo, quién — solo hechos no cubiertos en otro lugar]
|
|
|
|
## Hechos Clave
|
|
[Los hallazgos más significativos — cada punto debe ser distinto]
|
|
|
|
## Análisis / Importancia
|
|
[Qué significa esto — sin repetir la sección de Hechos Clave]
|
|
|
|
## Conclusión
|
|
[Conclusión — 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):"""
|
|
}
|
|
|
|
|
|
class OutputGenerator:
|
|
def __init__(self, db: ResearchDB, ollama: OllamaClient, processor: ContentProcessor):
|
|
self.db = db
|
|
self.ollama = ollama
|
|
self.processor = processor
|
|
|
|
async def generate(self, session_id: int, output_type: OutputType,
|
|
progress_callback=None) -> str:
|
|
"""Generate an output for a research session"""
|
|
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=30)
|
|
|
|
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.")
|
|
|
|
# Truncate context to avoid Ollama context limits
|
|
context_words = context.split()
|
|
if len(context_words) > 6000:
|
|
context = " ".join(context_words[:6000]) + "\n\n[... additional material truncated ...]"
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
logger.info("Output generated", type=output_type, length=len(full_output))
|
|
return full_output
|
|
|
|
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:
|
|
import anthropic
|
|
max_tokens = 4096 if output_type == OutputType.THREAD else 8192
|
|
try:
|
|
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
|
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.")
|
|
|
|
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):,}
|
|
---
|
|
"""
|