feat: Add proper thread handling in content generation
- Detect threads by template name and skip platform truncation - Parse thread content into individual posts with numbering - Add thread_posts array to API response with post details - Evaluate quality on first post (hook) for threads - Add is_thread and thread_posts fields to GenerateV2Response Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,13 @@ class GenerateV2Request(BaseModel):
|
|||||||
max_attempts: int = Field(default=2, ge=1, le=5, description="Máximo intentos de regeneración")
|
max_attempts: int = Field(default=2, ge=1, le=5, description="Máximo intentos de regeneración")
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadPost(BaseModel):
|
||||||
|
"""Un post individual de un hilo."""
|
||||||
|
number: int
|
||||||
|
content: str
|
||||||
|
char_count: int
|
||||||
|
|
||||||
|
|
||||||
class GenerateV2Response(BaseModel):
|
class GenerateV2Response(BaseModel):
|
||||||
"""Respuesta de generación v2."""
|
"""Respuesta de generación v2."""
|
||||||
success: bool
|
success: bool
|
||||||
@@ -47,6 +54,9 @@ class GenerateV2Response(BaseModel):
|
|||||||
attempts: int = 1
|
attempts: int = 1
|
||||||
metadata: Optional[Dict[str, Any]] = None
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
# Campos específicos para hilos
|
||||||
|
is_thread: bool = False
|
||||||
|
thread_posts: Optional[List[ThreadPost]] = None
|
||||||
|
|
||||||
|
|
||||||
class BatchGenerateV2Request(BaseModel):
|
class BatchGenerateV2Request(BaseModel):
|
||||||
@@ -142,15 +152,26 @@ async def generate_content_v2(
|
|||||||
max_attempts=request.max_attempts
|
max_attempts=request.max_attempts
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Detectar si es un hilo
|
||||||
|
is_thread = result.get("is_thread", False)
|
||||||
|
thread_posts = None
|
||||||
|
|
||||||
|
if is_thread and result.get("thread_posts"):
|
||||||
|
thread_posts = [
|
||||||
|
ThreadPost(**post) for post in result["thread_posts"]
|
||||||
|
]
|
||||||
|
|
||||||
return GenerateV2Response(
|
return GenerateV2Response(
|
||||||
success=True,
|
success=True,
|
||||||
content=result["content"],
|
content=result["content"],
|
||||||
adapted_content=result["content"], # Ya está adaptado
|
adapted_content=result["content"], # Para hilos, no truncar
|
||||||
quality_score=result.get("quality_score"),
|
quality_score=result.get("quality_score"),
|
||||||
score_breakdown=result.get("score_breakdown"),
|
score_breakdown=result.get("score_breakdown"),
|
||||||
is_top_quality=result.get("is_top_performer", False),
|
is_top_quality=result.get("is_top_performer", False),
|
||||||
attempts=result.get("attempts", 1),
|
attempts=result.get("attempts", 1),
|
||||||
metadata=result.get("metadata")
|
metadata=result.get("metadata"),
|
||||||
|
is_thread=is_thread,
|
||||||
|
thread_posts=thread_posts
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = await generator._v2.generate(
|
result = await generator._v2.generate(
|
||||||
@@ -163,11 +184,22 @@ async def generate_content_v2(
|
|||||||
personality=request.personality
|
personality=request.personality
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Detectar si es un hilo
|
||||||
|
is_thread = result.get("is_thread", False)
|
||||||
|
thread_posts = None
|
||||||
|
|
||||||
|
if is_thread and result.get("thread_posts"):
|
||||||
|
thread_posts = [
|
||||||
|
ThreadPost(**post) for post in result["thread_posts"]
|
||||||
|
]
|
||||||
|
|
||||||
return GenerateV2Response(
|
return GenerateV2Response(
|
||||||
success=True,
|
success=True,
|
||||||
content=result["content"],
|
content=result["content"],
|
||||||
adapted_content=result["adapted_content"],
|
adapted_content=result["adapted_content"],
|
||||||
metadata=result["metadata"]
|
metadata=result["metadata"],
|
||||||
|
is_thread=is_thread,
|
||||||
|
thread_posts=thread_posts
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ template: |
|
|||||||
- Indica que es un hilo: "🧵 Hilo:"
|
- Indica que es un hilo: "🧵 Hilo:"
|
||||||
- Anticipa lo que van a aprender
|
- Anticipa lo que van a aprender
|
||||||
|
|
||||||
POSTS 2 a {num_posts-1} (CONTENIDO):
|
POSTS INTERMEDIOS (CONTENIDO):
|
||||||
- Cada post = 1 concepto/punto
|
- Cada post = 1 concepto/punto
|
||||||
- Empieza cada post con conexión al anterior
|
- Empieza cada post con conexión al anterior
|
||||||
- Incluye ejemplo práctico cuando sea posible
|
- Incluye ejemplo práctico cuando sea posible
|
||||||
- Usa formato escaneable (bullets, numeración)
|
- Usa formato escaneable (bullets, numeración)
|
||||||
|
|
||||||
POST {num_posts} (CIERRE):
|
ÚLTIMO POST (CIERRE):
|
||||||
- Resume los puntos clave
|
- Resume los puntos clave
|
||||||
- Da un paso accionable
|
- Da un paso accionable
|
||||||
- CTA de engagement (guardar, compartir, seguir)
|
- CTA de engagement (guardar, compartir, seguir)
|
||||||
|
|||||||
@@ -164,12 +164,37 @@ class ContentGeneratorV2:
|
|||||||
|
|
||||||
content = response.choices[0].message.content.strip()
|
content = response.choices[0].message.content.strip()
|
||||||
|
|
||||||
# 6. Adaptar a plataforma
|
# 6. Detectar si es un hilo (no adaptar, devolver posts separados)
|
||||||
|
is_thread = template_name == "thread"
|
||||||
|
|
||||||
|
if is_thread:
|
||||||
|
# Parsear posts del hilo
|
||||||
|
thread_posts = self._parse_thread_posts(content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content": content,
|
||||||
|
"adapted_content": content, # No truncar hilos
|
||||||
|
"is_thread": True,
|
||||||
|
"thread_posts": thread_posts,
|
||||||
|
"truncated": False,
|
||||||
|
"metadata": {
|
||||||
|
"template": template_name,
|
||||||
|
"personality": rendered["personality"],
|
||||||
|
"platform": platform,
|
||||||
|
"temperature": temperature,
|
||||||
|
"tokens_used": response.usage.total_tokens if response.usage else None,
|
||||||
|
"changes_made": [],
|
||||||
|
"num_posts": len(thread_posts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 7. Adaptar a plataforma (solo para contenido normal)
|
||||||
adapted = self.plt_adapter.adapt(content, platform)
|
adapted = self.plt_adapter.adapt(content, platform)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"content": content,
|
"content": content,
|
||||||
"adapted_content": adapted.content,
|
"adapted_content": adapted.content,
|
||||||
|
"is_thread": False,
|
||||||
"truncated": adapted.truncated,
|
"truncated": adapted.truncated,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"template": template_name,
|
"template": template_name,
|
||||||
@@ -181,6 +206,55 @@ class ContentGeneratorV2:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _parse_thread_posts(self, content: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Parsear el contenido de un hilo en posts individuales.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Contenido completo del hilo
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de dicts con cada post y sus metadatos
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
|
||||||
|
# Intentar separar por patrones de numeración: "1/", "2/", "1)", "2)"
|
||||||
|
# Buscar patrones como "\n1/" o inicio con "1/"
|
||||||
|
numbered_pattern = r'(?:^|\n)(\d+)[/\)]\s*'
|
||||||
|
|
||||||
|
# Encontrar todas las posiciones de inicio de posts numerados
|
||||||
|
matches = list(re.finditer(numbered_pattern, content))
|
||||||
|
|
||||||
|
if matches and len(matches) >= 2:
|
||||||
|
# Extraer posts basándose en las posiciones
|
||||||
|
for i, match in enumerate(matches):
|
||||||
|
start = match.start()
|
||||||
|
# El fin es el inicio del siguiente match o el final del contenido
|
||||||
|
end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
|
||||||
|
|
||||||
|
post_content = content[start:end].strip()
|
||||||
|
post_num = int(match.group(1))
|
||||||
|
|
||||||
|
posts.append({
|
||||||
|
"number": post_num,
|
||||||
|
"content": post_content,
|
||||||
|
"char_count": len(post_content)
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Fallback: separar por doble salto de línea
|
||||||
|
raw_posts = [p.strip() for p in content.split("\n\n") if p.strip()]
|
||||||
|
|
||||||
|
for i, post_content in enumerate(raw_posts, 1):
|
||||||
|
posts.append({
|
||||||
|
"number": i,
|
||||||
|
"content": post_content,
|
||||||
|
"char_count": len(post_content)
|
||||||
|
})
|
||||||
|
|
||||||
|
return posts
|
||||||
|
|
||||||
# === Métodos de Conveniencia ===
|
# === Métodos de Conveniencia ===
|
||||||
|
|
||||||
async def generate_tip(
|
async def generate_tip(
|
||||||
|
|||||||
@@ -630,6 +630,7 @@ Responde SOLO con el contenido adaptado."""
|
|||||||
|
|
||||||
attempt = 0
|
attempt = 0
|
||||||
temperature = 0.7
|
temperature = 0.7
|
||||||
|
is_thread = template_name == "thread"
|
||||||
|
|
||||||
while attempt < max_attempts:
|
while attempt < max_attempts:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
@@ -643,36 +644,59 @@ Responde SOLO con el contenido adaptado."""
|
|||||||
temperature_override=temperature
|
temperature_override=temperature
|
||||||
)
|
)
|
||||||
|
|
||||||
content = result["adapted_content"]
|
# Para hilos, usar contenido original (no truncado)
|
||||||
|
if is_thread:
|
||||||
|
content = result["content"]
|
||||||
|
else:
|
||||||
|
content = result["adapted_content"]
|
||||||
|
|
||||||
# Evaluar calidad
|
# Evaluar calidad (para hilos, evaluar solo el primer post como muestra)
|
||||||
quality = await self._validator.evaluate(content, platform)
|
content_to_evaluate = content
|
||||||
|
if is_thread and result.get("thread_posts"):
|
||||||
|
# Evaluar el hook (primer post) como indicador de calidad
|
||||||
|
first_post = result["thread_posts"][0]["content"]
|
||||||
|
content_to_evaluate = first_post
|
||||||
|
|
||||||
|
quality = await self._validator.evaluate(content_to_evaluate, platform)
|
||||||
|
|
||||||
# Si pasa, retornar
|
# Si pasa, retornar
|
||||||
if quality.final_decision == "accept":
|
if quality.final_decision == "accept":
|
||||||
return {
|
response = {
|
||||||
"content": content,
|
"content": content,
|
||||||
"quality_score": quality.scoring.total_score if quality.scoring else None,
|
"quality_score": quality.scoring.total_score if quality.scoring else None,
|
||||||
"score_breakdown": quality.scoring.breakdown if quality.scoring else None,
|
"score_breakdown": quality.scoring.breakdown if quality.scoring else None,
|
||||||
"is_top_performer": quality.scoring.is_top_performer if quality.scoring else False,
|
"is_top_performer": quality.scoring.is_top_performer if quality.scoring else False,
|
||||||
"attempts": attempt,
|
"attempts": attempt,
|
||||||
"metadata": result["metadata"]
|
"metadata": result["metadata"],
|
||||||
|
"is_thread": is_thread,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Agregar posts del hilo si aplica
|
||||||
|
if is_thread:
|
||||||
|
response["thread_posts"] = result.get("thread_posts", [])
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
# Si debe regenerar, aumentar temperature
|
# Si debe regenerar, aumentar temperature
|
||||||
temperature = min(1.0, temperature + 0.1)
|
temperature = min(1.0, temperature + 0.1)
|
||||||
|
|
||||||
# Si llegamos aquí, usar el último intento aunque no sea ideal
|
# Si llegamos aquí, usar el último intento aunque no sea ideal
|
||||||
return {
|
response = {
|
||||||
"content": content,
|
"content": content,
|
||||||
"quality_score": quality.scoring.total_score if quality.scoring else None,
|
"quality_score": quality.scoring.total_score if quality.scoring else None,
|
||||||
"score_breakdown": quality.scoring.breakdown if quality.scoring else None,
|
"score_breakdown": quality.scoring.breakdown if quality.scoring else None,
|
||||||
"is_top_performer": False,
|
"is_top_performer": False,
|
||||||
"attempts": attempt,
|
"attempts": attempt,
|
||||||
"metadata": result["metadata"],
|
"metadata": result["metadata"],
|
||||||
"warning": "Contenido aceptado después de máximos intentos"
|
"warning": "Contenido aceptado después de máximos intentos",
|
||||||
|
"is_thread": is_thread,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if is_thread:
|
||||||
|
response["thread_posts"] = result.get("thread_posts", [])
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
async def save_to_memory(
|
async def save_to_memory(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
Reference in New Issue
Block a user