Módulo 6: Code Quality Patterns para AI

1. Introducción: Code Quality para AI

Descripción

Tienes código funcional con tests (Módulos 2-3), guardrails (Módulo 4) y logging (Módulo 5). Pero "funcional" no es lo mismo que "mantenible." Este módulo aborda la diferencia: cómo organizar ese código con clean architecture específicamente adaptada a apps LLM, de forma que un equipo pueda extenderlo, debuggearlo y cambiarlo sin reescribir todo.


El estado del código después de los módulos anteriores

# Código funcional pero monolítico — lo que probablemente tienes ahora:

from openai import OpenAI
import json

client = OpenAI(api_key="sk-...")  # Acoplado al proveedor

SENTIMENT_PROMPT = "Analyze sentiment of: {text}"  # Prompt hardcodeado en el módulo

def analyze(text: str) -> dict:
    # God function: hace todo en un lugar
    
    # Guardrail (mezclado con lógica)
    if len(text) > 5000:
        text = text[:5000]
    
    # Prompt construction (mezclado con llamada)
    prompt = SENTIMENT_PROMPT.format(text=text)
    
    # LLM call (negocio acoplado a infraestructura)
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.7  # Magic number
    )
    
    # Parsing (mezclado con todo)
    raw = response.choices[0].message.content
    try:
        result = json.loads(raw)
    except:
        result = {"sentiment": "unknown", "score": 0.0}
    
    # Logging (mezclado con lógica)
    print(f"Result: {result}")
    
    return result

# ¿Qué está mal con esto?
# 1. Si cambias de OpenAI a Anthropic: reescribir el módulo
# 2. Si quieres A/B testear el prompt: editar el código
# 3. Si quieres mock en tests: patch("openai.chat.completions.create") — frágil
# 4. Si quieres cambiar temperatura por entorno: constante o env var dispersa
# 5. Si quieres reutilizar el parser en otro lugar: está mezclado

Los 4 problemas específicos de AI apps

Problema 1: Prompts hardcodeados

# ❌ El prompt como string en el código:
PROMPT = "Analyze the sentiment of the following text and return a JSON..."

# Problemas:
# - Para A/B testear el prompt, necesitas cambiar el código y redeployar
# - Para traducir el prompt a otro idioma, igual
# - No puedes versionar el prompt separado del código
# - No puedes saber qué versión del prompt produjo qué resultado

# ✅ El prompt como configuración:
# prompts/sentiment/v1.yaml
# template: "Analyze the sentiment of..."
# version: "v1"
# author: "mike"
# last_modified: "2024-01-15"

Problema 2: Tight coupling al proveedor

# ❌ Acoplado directamente a OpenAI:
from openai import OpenAI
client = OpenAI()

def analyze(text: str) -> dict:
    response = client.chat.completions.create(...)  # API específica de OpenAI

# Si mañana Anthropic tiene mejor precio/calidad:
# → Reescribir TODA la lógica de negocio

# ✅ Desacoplado con Protocol:
class LLMProvider(Protocol):
    def complete(self, messages: list, **kwargs) -> str: ...

def analyze(text: str, provider: LLMProvider) -> dict:
    response = provider.complete([{"role": "user", "content": text}])

# Cambiar a Anthropic: nueva implementación de LLMProvider, una línea en config

Problema 3: Configuración dispersa

# ❌ Config dispersa en múltiples lugares:
MODEL = "gpt-4o-mini"              # En models.py
TEMPERATURE = 0.7                   # En utils.py
MAX_TOKENS = int(os.getenv("MT"))   # En main.py
RETRY_ATTEMPTS = 3                  # En llm_client.py

# Resultado: para cambiar la config de staging, buscar en 4 archivos

# ✅ Config centralizada con pydantic-settings:
class Settings(BaseSettings):
    model: str = "gpt-4o-mini"
    temperature: float = 0.7
    max_tokens: int = 500
    retry_attempts: int = 3
    
    class Config:
        env_file = ".env"

Problema 4: God functions

# ❌ Una función que hace todo:
def process_request(text: str) -> dict:
    # 1. Sanitizar (debería ser guardrails)
    # 2. Construir prompt (debería ser capa de prompts)
    # 3. Llamar LLM (debería ser infrastructure)
    # 4. Parsear respuesta (debería ser output processing)
    # 5. Validar resultado (debería ser domain)
    # 6. Loguear (debería ser wrapper de infrastructure)
    # 7. Retornar (ok)
    pass

# Consecuencia: no puedes testear cada parte por separado
# No puedes reutilizar el parser para otro endpoint
# No puedes cambiar el LLM sin riesgo de romper el parser

La solución: 4 capas adaptadas a AI

┌─────────────────────────────────────────────────────────┐
│           PROMPT TEMPLATES (configuración)              │
│  Archivos YAML/JSON con templates, versión, metadata    │
│  Versionables, editables sin tocar código, A/B testeables│
├─────────────────────────────────────────────────────────┤
│              BUSINESS LOGIC (domain)                    │
│  Orquestación, reglas de negocio, use cases             │
│  Solo conoce interfaces — no conoce OpenAI ni JSON      │
├─────────────────────────────────────────────────────────┤
│           LLM INFRASTRUCTURE (infrastructure)           │
│  Implementaciones: OpenAIProvider, AnthropicProvider,   │
│  MockProvider, FallbackProvider                         │
├─────────────────────────────────────────────────────────┤
│           OUTPUT PROCESSING (processing)                │
│  Parsers, validators, transformers                      │
│  Reciben strings, retornan tipos Python                 │
└─────────────────────────────────────────────────────────┘

Regla: las dependencias solo apuntan hacia ADENTRO.
Processing no conoce Infrastructure.
Domain no conoce Infrastructure.
Infrastructure no conoce Domain.

La transformación completa

# ANTES: todo mezclado en una función

# DESPUÉS: cada capa tiene su responsabilidad

# ─── prompts/sentiment/v1.yaml ───────────────────────────────────────────
# template: |
#   Analyze the sentiment of the following text.
#   Return JSON: {"sentiment": "positive|negative|neutral|mixed", "score": float}
#   Text: {text}
# version: "v1"

# ─── src/domain/sentiment_service.py ─────────────────────────────────────
from src.infrastructure.llm_provider import LLMProvider
from src.processing.sentiment_parser import parse_sentiment_output
from src.prompts.loader import load_prompt

def analyze_sentiment(text: str, provider: LLMProvider) -> dict:
    """Business logic pura: orquesta sin conocer detalles de infraestructura."""
    prompt = load_prompt("sentiment/v1").render(text=text)
    raw_response = provider.complete([
        {"role": "system", "content": "You are a sentiment analysis expert."},
        {"role": "user", "content": prompt}
    ])
    return parse_sentiment_output(raw_response)

# ─── src/infrastructure/openai_provider.py ───────────────────────────────
class OpenAIProvider:
    def __init__(self, client, model: str, temperature: float, max_tokens: int):
        self._client = client
        self._model = model
        self._temperature = temperature
        self._max_tokens = max_tokens
    
    def complete(self, messages: list) -> str:
        response = self._client.chat.completions.create(
            model=self._model,
            messages=messages,
            temperature=self._temperature,
            max_tokens=self._max_tokens
        )
        return response.choices[0].message.content

# ─── src/processing/sentiment_parser.py ──────────────────────────────────
from pydantic import BaseModel
import json

class SentimentOutput(BaseModel):
    sentiment: str
    score: float

def parse_sentiment_output(raw: str) -> dict:
    """Parser independiente: toma string, retorna dict validado."""
    data = json.loads(raw)
    return SentimentOutput(**data).model_dump()

# ─── src/app/main.py ──────────────────────────────────────────────────────
from src.domain.sentiment_service import analyze_sentiment
from src.infrastructure.openai_provider import OpenAIProvider
from src.config import get_settings

settings = get_settings()

def get_provider():
    return OpenAIProvider(
        client=OpenAI(api_key=settings.openai_api_key.get_secret_value()),
        model=settings.model,
        temperature=settings.temperature,
        max_tokens=settings.max_tokens
    )

Por qué este módulo viene AQUÍ en la guía

Timeline de la guía:

M1-3: Tests ────────────────────────────────────────────────────
      Escribiste tests para el código. Ahora tienes una red de
      seguridad. El refactoring de M6 solo es seguro porque
      tienes tests.

M4: Guardrails ──────────────────────────────────────────────────
      Añadiste lógica de validación. Ahora tienes código sustancial
      que vale la pena organizar.

M5: Logging ─────────────────────────────────────────────────────
      Añadiste infraestructura de observabilidad. Ahora el código
      tiene tres capas mezcladas: negocio + guardrails + logging.

M6: Code Quality ────────────────────────────────────────────────
      Con tests (red de seguridad) y suficiente código que organizar,
      ahora el refactoring tiene tanto valor como es seguro hacerlo.

M7: Reliability ─────────────────────────────────────────────────
      La clean architecture de M6 hace que añadir retry, circuit
      breakers y fallbacks sea plug-and-play.

Prerequisitos del módulo

# Dependencias nuevas para este módulo
pip install pydantic-settings   # Configuration management
pip install jinja2              # Prompt templating (opcional)
pip install python-dotenv       # .env loading
pip install pyyaml              # YAML para archivos de prompts

Roadmap del módulo

#CápsulaFeature centralResultado
01IntroducciónEl problema del código AI monolíticoEsta cápsula
02Clean architectureLas 4 capas, estructura de directoriosArquitectura definida
03Separation of concernsExtraer cada responsabilidadGod functions eliminadas
04Config managementpydantic-settings type-safeConfig centralizada
05Dependency injectionLLMProvider ProtocolProvider desacoplado
06Environment managementdev/staging/prod configsConfig por entorno
07Proyecto Refactored AI AppRefactoring completoApp organizada
08Resumen y troubleshootingCierre y anti-patterns

Ejercicios

Ejercicio 1: Diagnóstico de tu código actual

Lista los 3 principales problemas de code quality en el código que has construido en módulos anteriores. Para cada uno, identifica el pattern que lo resuelve:

Ver guía

Problemas comunes:

  1. "El prompt está hardcodeado en main.py dentro de la función" → Solución: externalizar a archivo YAML + load_prompt()
  2. "Importo from openai import OpenAI directamente en sentiment_service.py" → Solución: Protocol LLMProvider + dependency injection
  3. "La configuración está en .env, config.py, y main.py mezclada" → Solución: pydantic-settings centralizado

Ejercicio 2: Clasificar código

Para cada fragmento, indica en qué capa de la arquitectura debería estar:

# A)
def complete(self, messages: list) -> str:
    return openai.chat.completions.create(...)

# B)
def analyze_sentiment(text: str, provider: LLMProvider) -> dict:
    prompt = load_prompt("sentiment").render(text=text)
    raw = provider.complete([{"role": "user", "content": prompt}])
    return parse_output(raw)

# C)
def parse_output(raw: str) -> dict:
    return SentimentOutput.model_validate_json(raw).model_dump()

# D)
# sentiment/v1.yaml
# template: "Analyze sentiment: {text}"
Ver solución

A) Infrastructure — Es la llamada al API, específica a un proveedor. B) Business Logic / Domain — Orquesta los pasos, define el use case. C) Output Processing — Transforma raw output en tipo Python. D) Prompt Templates — Configuración del prompt, no código.


Ejercicio 3: ROI del refactoring

Para cada mejora de code quality, estima cuánto tiempo ahorras en el futuro:

  1. Externalizar prompts a YAML
  2. Crear LLMProvider Protocol
  3. Centralizar config con pydantic-settings
Ver guía
  1. Prompts externalizados: A/B testing de prompts sin redeployar = horas ahorradas por iteración × todas las iteraciones. Si optimizas prompts una vez por semana: 2h/semana ahorradas.
  2. LLMProvider Protocol: Cambiar de OpenAI a otro proveedor = 30 minutos (nueva implementación) vs 2-3 días (refactoring todo el código). ROI: enorme si cambias una sola vez.
  3. pydantic-settings: Debugging de config en producción = 5 minutos (startup error) vs 2 horas (buscar dónde está la config incorrecta). ROI: cada vez que hay un error de config.

Resumen

  • El código funcional pero monolítico tiene 4 problemas AI-específicos: prompts hardcodeados, tight coupling, config dispersa, y god functions
  • La solución es clean architecture en 4 capas: prompt templates, business logic, infrastructure, output processing
  • El timing es correcto: los tests de Phase 1 son la red de seguridad para el refactoring
  • El objetivo es pragmatismo, no purismo — cada abstracción debe justificarse con un caso de uso real

Recursos adicionales

  1. Clean Architecture (Robert C. Martin) — El framework conceptual
  2. pydantic-settings — Configuration management
  3. typing.Protocol — Interfaces en Python
  4. Dependency Injection in Python (Martin Fowler) — El patrón fundamental
  5. Refactoring (Martin Fowler) — El libro de referencia para refactoring seguro