From 9e857961f9a07a3cc08c81678e911c9db2cd62ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20AS?= Date: Wed, 28 Jan 2026 21:23:18 +0000 Subject: [PATCH] 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 --- app/api/routes/generate_v2.py | 38 ++++++++++++++-- app/prompts/templates/thread.yaml | 4 +- app/services/ai/generator.py | 76 ++++++++++++++++++++++++++++++- app/services/content_generator.py | 38 +++++++++++++--- 4 files changed, 143 insertions(+), 13 deletions(-) diff --git a/app/api/routes/generate_v2.py b/app/api/routes/generate_v2.py index dadce3a..9d9fe00 100644 --- a/app/api/routes/generate_v2.py +++ b/app/api/routes/generate_v2.py @@ -36,6 +36,13 @@ class GenerateV2Request(BaseModel): 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): """Respuesta de generación v2.""" success: bool @@ -47,6 +54,9 @@ class GenerateV2Response(BaseModel): attempts: int = 1 metadata: Optional[Dict[str, Any]] = None error: Optional[str] = None + # Campos específicos para hilos + is_thread: bool = False + thread_posts: Optional[List[ThreadPost]] = None class BatchGenerateV2Request(BaseModel): @@ -142,15 +152,26 @@ async def generate_content_v2( 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( success=True, content=result["content"], - adapted_content=result["content"], # Ya está adaptado + adapted_content=result["content"], # Para hilos, no truncar quality_score=result.get("quality_score"), score_breakdown=result.get("score_breakdown"), is_top_quality=result.get("is_top_performer", False), attempts=result.get("attempts", 1), - metadata=result.get("metadata") + metadata=result.get("metadata"), + is_thread=is_thread, + thread_posts=thread_posts ) else: result = await generator._v2.generate( @@ -163,11 +184,22 @@ async def generate_content_v2( 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( success=True, content=result["content"], adapted_content=result["adapted_content"], - metadata=result["metadata"] + metadata=result["metadata"], + is_thread=is_thread, + thread_posts=thread_posts ) except Exception as e: diff --git a/app/prompts/templates/thread.yaml b/app/prompts/templates/thread.yaml index 951b26c..33fa0f9 100644 --- a/app/prompts/templates/thread.yaml +++ b/app/prompts/templates/thread.yaml @@ -49,13 +49,13 @@ template: | - Indica que es un hilo: "🧵 Hilo:" - Anticipa lo que van a aprender - POSTS 2 a {num_posts-1} (CONTENIDO): + POSTS INTERMEDIOS (CONTENIDO): - Cada post = 1 concepto/punto - Empieza cada post con conexión al anterior - Incluye ejemplo práctico cuando sea posible - Usa formato escaneable (bullets, numeración) - POST {num_posts} (CIERRE): + ÚLTIMO POST (CIERRE): - Resume los puntos clave - Da un paso accionable - CTA de engagement (guardar, compartir, seguir) diff --git a/app/services/ai/generator.py b/app/services/ai/generator.py index e6800cc..aeae636 100644 --- a/app/services/ai/generator.py +++ b/app/services/ai/generator.py @@ -164,12 +164,37 @@ class ContentGeneratorV2: 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) return { "content": content, "adapted_content": adapted.content, + "is_thread": False, "truncated": adapted.truncated, "metadata": { "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 === async def generate_tip( diff --git a/app/services/content_generator.py b/app/services/content_generator.py index 54a59c1..4633938 100644 --- a/app/services/content_generator.py +++ b/app/services/content_generator.py @@ -630,6 +630,7 @@ Responde SOLO con el contenido adaptado.""" attempt = 0 temperature = 0.7 + is_thread = template_name == "thread" while attempt < max_attempts: attempt += 1 @@ -643,36 +644,59 @@ Responde SOLO con el contenido adaptado.""" 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 - quality = await self._validator.evaluate(content, platform) + # Evaluar calidad (para hilos, evaluar solo el primer post como muestra) + 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 if quality.final_decision == "accept": - return { + response = { "content": content, "quality_score": quality.scoring.total_score 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, "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 temperature = min(1.0, temperature + 0.1) # Si llegamos aquí, usar el último intento aunque no sea ideal - return { + response = { "content": content, "quality_score": quality.scoring.total_score if quality.scoring else None, "score_breakdown": quality.scoring.breakdown if quality.scoring else None, "is_top_performer": False, "attempts": attempt, "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( self, db: Session,