Módulo 6: Code Quality Patterns para AI

7. Proyecto: Refactored AI App

Descripción

Este es el proyecto integrador del Módulo 6. Tomas la app de análisis de sentimiento que has venido construyendo (con tests, guardrails, y logging) y la refactorizas aplicando todos los patterns del módulo: clean architecture en 4 capas, prompts externalizados, LLMProvider con DI, pydantic-settings, y environment management. El resultado es un codebase que un equipo puede mantener, extender, y debuggear.


La app antes del refactoring

# src/app/main.py — ANTES (estado típico después de M1-M5)
import os, json, time, structlog
from openai import OpenAI
from fastapi import FastAPI
from pydantic import BaseModel
from src.guardrails.pipeline import GuardrailsPipeline, PUBLIC_API_CONFIG

# Config dispersa
API_KEY = os.getenv("OPENAI_API_KEY")
MODEL = os.getenv("MODEL", "gpt-4o-mini")
TEMP = float(os.getenv("TEMPERATURE", "0.7"))

# Prompt hardcodeado en el módulo
SYSTEM = "You are a sentiment analysis expert."
PROMPT = """Analyze the sentiment of: {text}
Return JSON: {{"sentiment": "positive|negative|neutral|mixed", "score": float}}"""

log = structlog.get_logger()

app = FastAPI()
client = OpenAI(api_key=API_KEY)
pipeline = GuardrailsPipeline(config=PUBLIC_API_CONFIG, openai_client=client)

class AnalyzeRequest(BaseModel):
    text: str

@app.post("/analyze")
async def analyze(req: AnalyzeRequest):
    # God function: hace todo
    guardrail = pipeline.process(req.text)
    if guardrail.blocked:
        return {"error": guardrail.block_reason}
    
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": PROMPT.format(text=guardrail.processed_input)}
    ]
    
    start = time.time()
    response = client.chat.completions.create(
        model=MODEL, messages=messages, temperature=TEMP, max_tokens=500
    )
    
    raw = response.choices[0].message.content
    try:
        result = json.loads(raw)
    except:
        result = {"sentiment": "unknown", "score": 0.0}
    
    log.info("done", duration_ms=(time.time()-start)*1000,
             tokens=response.usage.total_tokens)
    
    return result

La estructura del proyecto después del refactoring

proyecto/
├── prompts/
│   ├── sentiment/
│   │   └── v1.yaml
│   └── README.md
│
├── src/
│   ├── config.py                    ← pydantic-settings centralizado
│   ├── startup.py                   ← Startup checks
│   │
│   ├── prompts/
│   │   └── loader.py                ← Cargador de prompts YAML
│   │
│   ├── domain/
│   │   ├── __init__.py
│   │   ├── sentiment_service.py     ← Use case: analyze_sentiment()
│   │   └── exceptions.py            ← LowConfidenceError, etc.
│   │
│   ├── infrastructure/
│   │   ├── __init__.py
│   │   ├── llm_provider.py          ← Protocol LLMProvider
│   │   ├── openai_provider.py       ← Implementación OpenAI
│   │   ├── mock_provider.py         ← Mock para tests
│   │   └── fallback_provider.py     ← Fallback entre providers
│   │
│   ├── processing/
│   │   ├── __init__.py
│   │   └── sentiment_parser.py      ← Parser y SentimentOutput
│   │
│   ├── guardrails/                  ← Del Módulo 4
│   │   └── pipeline.py
│   │
│   ├── logging_config.py            ← Del Módulo 5
│   ├── tracing.py
│   └── middleware.py
│
├── src/app/
│   ├── __init__.py
│   ├── main.py                      ← App factory
│   ├── dependencies.py              ← FastAPI Depends() providers
│   └── routers/
│       └── sentiment.py             ← Endpoint /analyze
│
├── tests/
│   ├── conftest.py
│   ├── unit/
│   │   ├── test_sentiment_service.py
│   │   ├── test_sentiment_parser.py
│   │   ├── test_config.py
│   │   └── test_mock_provider.py
│   └── integration/
│       └── test_e2e.py
│
├── .env
├── .env.development
├── .env.example
├── requirements.txt
└── README.md

Paso 1: Crear el archivo de prompt

# prompts/sentiment/v1.yaml
version: "v1"
author: "team"
created: "2024-01-15"
description: >
  Sentiment analysis prompt. Classifies text as positive, negative,
  neutral, or mixed. Returns JSON with sentiment label and score.

system: >
  You are a sentiment analysis expert. You always respond with
  valid JSON and nothing else.

template: |
  Analyze the sentiment of the following text.
  Return a JSON object with exactly these fields:
  - "sentiment": one of "positive", "negative", "neutral", "mixed"
  - "score": float from -1.0 (very negative) to 1.0 (very positive)
  - "confidence": float from 0.0 to 1.0

  Text to analyze:
  {text}

Paso 2: El cargador de prompts

# src/prompts/loader.py
import yaml
from pathlib import Path
from dataclasses import dataclass
from functools import lru_cache

PROMPTS_DIR = Path(__file__).parent.parent.parent / "prompts"

@dataclass
class PromptTemplate:
    version: str
    system: str
    template: str
    description: str = ""
    
    def render(self, **kwargs) -> str:
        try:
            return self.template.format(**kwargs)
        except KeyError as e:
            raise ValueError(f"Missing variable in prompt: {e}") from e

@lru_cache(maxsize=32)
def load_prompt(name: str) -> PromptTemplate:
    """
    Carga un prompt desde YAML con caché.
    
    Args:
        name: Ruta relativa al directorio prompts/ sin extensión.
              Ejemplo: "sentiment/v1"
    """
    path = PROMPTS_DIR / f"{name}.yaml"
    if not path.exists():
        raise FileNotFoundError(f"Prompt not found: {path}")
    
    with open(path, encoding="utf-8") as f:
        data = yaml.safe_load(f)
    
    return PromptTemplate(
        version=str(data.get("version", "v1")),
        system=str(data.get("system", "")).strip(),
        template=str(data.get("template", "")),
        description=str(data.get("description", ""))
    )

Paso 3: El domain service limpio

# src/domain/exceptions.py
class SentimentAnalysisError(Exception):
    """Error base del dominio de análisis de sentimiento."""

class LowConfidenceError(SentimentAnalysisError):
    """El LLM no pudo analizar con suficiente confianza."""

class ProviderUnavailableError(SentimentAnalysisError):
    """El LLM provider no está disponible."""
# src/domain/sentiment_service.py
from src.infrastructure.llm_provider import LLMProvider, LLMProviderError
from src.processing.sentiment_parser import parse_sentiment_output
from src.prompts.loader import load_prompt
from src.domain.exceptions import LowConfidenceError, ProviderUnavailableError

MINIMUM_CONFIDENCE = 0.3

def analyze_sentiment(text: str, provider: LLMProvider) -> dict:
    """
    Use case: analizar el sentimiento de un texto.
    
    Args:
        text: Texto ya sanitizado por guardrails
        provider: LLM provider inyectado
    
    Returns:
        dict con: sentiment (str), score (float), confidence (float)
    
    Raises:
        LowConfidenceError: confidence < MINIMUM_CONFIDENCE
        ProviderUnavailableError: El provider falló
    """
    template = load_prompt("sentiment/v1")
    
    messages = [
        {"role": "system", "content": template.system},
        {"role": "user", "content": template.render(text=text)}
    ]
    
    try:
        raw_response = provider.complete(messages)
    except LLMProviderError as e:
        raise ProviderUnavailableError(
            f"LLM provider unavailable: {e}"
        ) from e
    
    result = parse_sentiment_output(raw_response)
    
    if result["confidence"] < MINIMUM_CONFIDENCE:
        raise LowConfidenceError(
            f"Confidence too low: {result['confidence']:.2f} "
            f"(minimum: {MINIMUM_CONFIDENCE})"
        )
    
    return result

Paso 4: El parser en processing

# src/processing/sentiment_parser.py
import json
import re
from pydantic import BaseModel, field_validator
from typing import Optional

class SentimentOutput(BaseModel):
    sentiment: str
    score: float
    confidence: float = 1.0
    
    @field_validator("sentiment")
    @classmethod
    def normalize_sentiment(cls, v: str) -> str:
        VALID = {"positive", "negative", "neutral", "mixed"}
        normalized = v.lower().strip()
        return normalized if normalized in VALID else "unknown"
    
    @field_validator("score")
    @classmethod
    def clamp_score(cls, v: float) -> float:
        return max(-1.0, min(1.0, float(v)))
    
    @field_validator("confidence")
    @classmethod
    def clamp_confidence(cls, v: float) -> float:
        return max(0.0, min(1.0, float(v)))

def extract_json(raw: str) -> Optional[str]:
    """Extrae JSON de un string que puede contener texto extra."""
    stripped = raw.strip()
    if stripped.startswith("{"):
        return stripped
    
    # JSON en markdown code block
    match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)
    if match:
        return match.group(1)
    
    # JSON en el texto
    match = re.search(r"\{[^{}]+\}", raw, re.DOTALL)
    if match:
        return match.group()
    
    return None

def parse_sentiment_output(raw: str) -> dict:
    """Parsea el output del LLM y retorna un dict validado."""
    json_str = extract_json(raw)
    if json_str is None:
        return SentimentOutput(
            sentiment="unknown", score=0.0, confidence=0.0
        ).model_dump()
    
    try:
        data = json.loads(json_str)
        return SentimentOutput(**data).model_dump()
    except (json.JSONDecodeError, ValueError):
        return SentimentOutput(
            sentiment="unknown", score=0.0, confidence=0.0
        ).model_dump()

Paso 5: Infrastructure (providers)

# src/infrastructure/llm_provider.py
from typing import Protocol, runtime_checkable

@runtime_checkable
class LLMProvider(Protocol):
    def complete(self, messages: list[dict], **kwargs) -> str: ...

class LLMProviderError(Exception):
    def __init__(self, message: str, original_error: Exception = None):
        super().__init__(message)
        self.original_error = original_error
# src/infrastructure/openai_provider.py
import time, structlog
from src.infrastructure.llm_provider import LLMProvider, LLMProviderError
from src.logging_config import calculate_cost

log = structlog.get_logger()

class OpenAIProvider:
    def __init__(self, client, model: str, temperature: float,
                 max_tokens: int, seed: int = None):
        self._client = client
        self._model = model
        self._temperature = temperature
        self._max_tokens = max_tokens
        self._seed = seed
    
    def complete(self, messages: list[dict], **kwargs) -> str:
        start = time.time()
        params = dict(
            model=self._model, messages=messages,
            temperature=self._temperature, max_tokens=self._max_tokens
        )
        if self._seed is not None:
            params["seed"] = self._seed
        params.update(kwargs)
        
        try:
            r = self._client.chat.completions.create(**params)
            log.info("llm_call_completed", model=self._model,
                    input_tokens=r.usage.prompt_tokens,
                    output_tokens=r.usage.completion_tokens,
                    cost_usd=calculate_cost(self._model,
                        r.usage.prompt_tokens, r.usage.completion_tokens),
                    duration_ms=round((time.time()-start)*1000, 1))
            return r.choices[0].message.content
        except Exception as e:
            raise LLMProviderError(str(e), original_error=e)
    
    @classmethod
    def from_settings(cls, settings) -> "OpenAIProvider":
        return cls(
            client=settings.create_openai_client(),
            model=settings.model,
            temperature=settings.temperature,
            max_tokens=settings.max_tokens,
            seed=settings.seed
        )
# src/infrastructure/mock_provider.py
from typing import Union, Callable, Optional

class MockProvider:
    def __init__(self, response: Union[str, Callable] = None,
                 responses: list = None, raise_error: Exception = None):
        self._response = response or '{"sentiment":"positive","score":0.8,"confidence":0.9}'
        self._responses = responses
        self._raise_error = raise_error
        self._call_count = 0
        self.calls = []
    
    def complete(self, messages: list[dict], **kwargs) -> str:
        self.calls.append(messages)
        self._call_count += 1
        if self._raise_error:
            raise self._raise_error
        if self._responses:
            return self._responses[(self._call_count-1) % len(self._responses)]
        if callable(self._response):
            return self._response(messages)
        return self._response
    
    @property
    def call_count(self) -> int:
        return self._call_count
    
    def get_last_user_message(self) -> Optional[str]:
        if not self.calls:
            return None
        for msg in reversed(self.calls[-1]):
            if msg.get("role") == "user":
                return msg.get("content")
        return None

Paso 6: App FastAPI refactorizada

# src/app/dependencies.py
from functools import lru_cache
from src.config import get_settings
from src.infrastructure.llm_provider import LLMProvider
from src.infrastructure.openai_provider import OpenAIProvider
from src.infrastructure.mock_provider import MockProvider

def get_llm_provider() -> LLMProvider:
    """El único lugar donde se decide qué provider usar."""
    settings = get_settings()
    if settings.use_mock_llm:
        return MockProvider(response=settings.mock_response)
    return OpenAIProvider.from_settings(settings)
# src/app/routers/sentiment.py
import structlog
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from src.domain.sentiment_service import analyze_sentiment
from src.domain.exceptions import LowConfidenceError, ProviderUnavailableError
from src.infrastructure.llm_provider import LLMProvider
from src.app.dependencies import get_llm_provider
from src.tracing import get_request_id

router = APIRouter()
log = structlog.get_logger()

class AnalyzeRequest(BaseModel):
    text: str

class AnalyzeResponse(BaseModel):
    sentiment: str
    score: float
    confidence: float
    request_id: str

@router.post("/analyze", response_model=AnalyzeResponse)
async def analyze_sentiment_endpoint(
    body: AnalyzeRequest,
    provider: LLMProvider = Depends(get_llm_provider)
):
    log.info("analyze_requested", text_length=len(body.text))
    
    try:
        result = analyze_sentiment(body.text, provider)
    except LowConfidenceError as e:
        log.warning("low_confidence_result", error=str(e))
        raise HTTPException(422, detail=str(e))
    except ProviderUnavailableError as e:
        log.error("provider_unavailable", error=str(e))
        raise HTTPException(503, detail="LLM service temporarily unavailable")
    
    return AnalyzeResponse(**result, request_id=get_request_id() or "unknown")
# src/app/main.py
import os
from fastapi import FastAPI
from src.config import get_settings
from src.logging_config import configure_logging
from src.middleware import RequestTracingMiddleware
from src.startup import run_startup_checks
from src.app.routers import sentiment

def create_app() -> FastAPI:
    """
    App factory — crea y configura la app FastAPI.
    Separar la creación del app de la ejecución facilita los tests.
    """
    settings = get_settings()
    
    # Configurar logging
    configure_logging(
        env=settings.environment,
        log_level=getattr(__import__("logging"), settings.log_level),
        log_file=settings.log_file_path if settings.log_to_file else None
    )
    
    # Startup checks
    run_startup_checks()
    
    # Crear la app
    app = FastAPI(
        title="AI Sentiment Analyzer",
        description="Sentiment analysis with clean architecture",
        version=settings.app_version
    )
    
    # Middleware
    app.add_middleware(RequestTracingMiddleware)
    
    # Routers
    app.include_router(sentiment.router, prefix="/api/v1", tags=["sentiment"])
    
    @app.get("/health")
    async def health():
        return {"status": "ok", "env": settings.environment}
    
    return app

app = create_app()

Paso 7: Tests del proyecto refactorizado

# tests/conftest.py
import pytest
from src.config import Settings, get_settings
from unittest.mock import patch

@pytest.fixture(autouse=True)
def reset_settings_cache():
    """Limpiar cache de settings antes/después de cada test."""
    get_settings.cache_clear()
    yield
    get_settings.cache_clear()

@pytest.fixture
def mock_settings():
    return Settings(
        environment="testing",
        use_mock_llm=True,
        openai_api_key="sk-test",
        log_level="WARNING",
        temperature=0.0
    )

@pytest.fixture
def override_settings(mock_settings):
    with patch("src.config.get_settings", return_value=mock_settings):
        yield mock_settings
# tests/unit/test_sentiment_service.py
import pytest
from src.domain.sentiment_service import analyze_sentiment
from src.domain.exceptions import LowConfidenceError, ProviderUnavailableError
from src.infrastructure.mock_provider import MockProvider
from src.infrastructure.llm_provider import LLMProviderError

class TestAnalyzeSentiment:
    def test_positive_result(self):
        mock = MockProvider('{"sentiment":"positive","score":0.9,"confidence":0.95}')
        result = analyze_sentiment("Great product!", mock)
        assert result["sentiment"] == "positive"
        assert result["score"] == 0.9
    
    def test_raises_on_low_confidence(self):
        mock = MockProvider('{"sentiment":"mixed","score":0.0,"confidence":0.05}')
        with pytest.raises(LowConfidenceError):
            analyze_sentiment("ambiguous", mock)
    
    def test_raises_on_provider_failure(self):
        from src.infrastructure.llm_provider import LLMProviderError
        mock = MockProvider(raise_error=LLMProviderError("API down"))
        with pytest.raises(ProviderUnavailableError):
            analyze_sentiment("test", mock)
    
    def test_text_in_prompt(self):
        mock = MockProvider('{"sentiment":"positive","score":0.8,"confidence":0.9}')
        unique_text = "UNIQUETOKEN_12345_UNIQUETOKEN"
        analyze_sentiment(unique_text, mock)
        assert unique_text in mock.get_last_user_message()


# tests/unit/test_sentiment_parser.py
from src.processing.sentiment_parser import parse_sentiment_output, extract_json

class TestExtractJson:
    def test_plain_json(self):
        assert '{"a": 1}' in extract_json('{"a": 1}')
    
    def test_json_in_markdown(self):
        result = extract_json('```json\n{"a":1}\n```')
        assert result == '{"a":1}'
    
    def test_no_json_returns_none(self):
        assert extract_json("no json here") is None

class TestParseSentimentOutput:
    def test_valid(self):
        raw = '{"sentiment":"positive","score":0.8,"confidence":0.9}'
        r = parse_sentiment_output(raw)
        assert r["sentiment"] == "positive"
    
    def test_score_clamped_above(self):
        raw = '{"sentiment":"positive","score":2.5}'
        assert parse_sentiment_output(raw)["score"] == 1.0
    
    def test_invalid_json_fallback(self):
        r = parse_sentiment_output("sorry, i cannot analyze")
        assert r["sentiment"] == "unknown"
        assert r["confidence"] == 0.0


# tests/unit/test_config.py
import pytest
from src.config import Settings

def test_production_rejects_mock():
    with pytest.raises(ValueError, match="PRODUCCIÓN"):
        Settings(environment="production", use_mock_llm=True,
                 openai_api_key="sk-key", log_level="INFO")

def test_production_requires_api_key():
    with pytest.raises(ValueError, match="openai_api_key"):
        Settings(environment="production", use_mock_llm=False,
                 openai_api_key="", log_level="INFO")

def test_development_allows_mock():
    s = Settings(environment="development", use_mock_llm=True)
    assert s.use_mock_llm is True

Verificación: los tests de Phase 1 siguen pasando

# El objetivo del refactoring es que el comportamiento externo no cambie

# Antes del refactoring:
pytest tests/ -v → 47 passed

# Después de cada paso de refactoring:
pytest tests/ -v → 47 passed (+ nuevos tests del módulo 6)

# Si algún test falla después de un paso:
# 1. NO hacer commit
# 2. Revisar qué cambió
# 3. Arreglar el código (o actualizar el test si el comportamiento
#    cambió intencionalmente)
# 4. Volver a correr hasta que todos pasen

# El ciclo:
# EXTRAER CÓDIGO → CORRER TESTS → SI PASAN: CONTINUAR → SI FALLAN: REVERTIR

Checklist del proyecto

[ ] prompts/sentiment/v1.yaml creado con sistema y template
[ ] src/prompts/loader.py con load_prompt() y lru_cache
[ ] src/domain/exceptions.py con LowConfidenceError, ProviderUnavailableError
[ ] src/domain/sentiment_service.py limpio — no importa openai
[ ] src/processing/sentiment_parser.py con SentimentOutput y parse_sentiment_output()
[ ] src/infrastructure/llm_provider.py con Protocol y LLMProviderError
[ ] src/infrastructure/openai_provider.py implementa LLMProvider
[ ] src/infrastructure/mock_provider.py con call tracking
[ ] src/app/dependencies.py con get_llm_provider()
[ ] src/app/routers/sentiment.py con endpoint que usa Depends()
[ ] src/app/main.py como app factory
[ ] src/config.py con pydantic-settings y validadores por entorno
[ ] .env.example documentado
[ ] tests/unit/test_sentiment_service.py — usa MockProvider, sin patch()
[ ] tests/unit/test_sentiment_parser.py — testa parser de forma independiente
[ ] tests/unit/test_config.py — verifica validaciones de producción

[ ] VERIFICACIÓN FINAL: pytest tests/ → todos pasan