Module 6: Code Quality Patterns for AI

7. Project: Refactored AI App

Description

This is the integrative project for Module 6. You take the sentiment analysis app you've been building (with tests, guardrails, and logging) and refactor it applying all the module's patterns: 4-layer clean architecture, externalized prompts, LLMProvider with DI, pydantic-settings, and environment management. The result is a codebase that a team can maintain, extend, and debug.


The app before the refactoring

# src/app/main.py — BEFORE (typical state after 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

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

# Prompt hardcoded in the module
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: does everything
    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

The project structure after the refactoring

project/
├── prompts/
│   ├── sentiment/
│   │   └── v1.yaml
│   └── README.md
│
├── src/
│   ├── config.py                    ← Centralized pydantic-settings
│   ├── startup.py                   ← Startup checks
│   │
│   ├── prompts/
│   │   └── loader.py                ← YAML prompt loader
│   │
│   ├── domain/
│   │   ├── __init__.py
│   │   ├── sentiment_service.py     ← Use case: analyze_sentiment()
│   │   └── exceptions.py            ← LowConfidenceError, etc.
│   │
│   ├── infrastructure/
│   │   ├── __init__.py
│   │   ├── llm_provider.py          ← LLMProvider Protocol
│   │   ├── openai_provider.py       ← OpenAI implementation
│   │   ├── mock_provider.py         ← Mock for tests
│   │   └── fallback_provider.py     ← Fallback between providers
│   │
│   ├── processing/
│   │   ├── __init__.py
│   │   └── sentiment_parser.py      ← Parser and SentimentOutput
│   │
│   ├── guardrails/                  ← From Module 4
│   │   └── pipeline.py
│   │
│   ├── logging_config.py            ← From Module 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

Step 1: Create the prompt file

# 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}

Step 2: The prompt loader

# 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:
    """
    Load a prompt from YAML with caching.
    
    Args:
        name: Path relative to the prompts/ directory without extension.
              Example: "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", ""))
    )

Step 3: The clean domain service

# src/domain/exceptions.py
class SentimentAnalysisError(Exception):
    """Base error for the sentiment analysis domain."""

class LowConfidenceError(SentimentAnalysisError):
    """The LLM couldn't analyze with enough confidence."""

class ProviderUnavailableError(SentimentAnalysisError):
    """The LLM provider is not available."""
# 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: analyze the sentiment of a text.
    
    Args:
        text: Text already sanitized by guardrails
        provider: injected LLM provider
    
    Returns:
        dict with: sentiment (str), score (float), confidence (float)
    
    Raises:
        LowConfidenceError: confidence < MINIMUM_CONFIDENCE
        ProviderUnavailableError: The provider failed
    """
    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

Step 4: The parser in 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]:
    """Extract JSON from a string that may contain extra text."""
    stripped = raw.strip()
    if stripped.startswith("{"):
        return stripped
    
    # JSON in a markdown code block
    match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)
    if match:
        return match.group(1)
    
    # JSON in the text
    match = re.search(r"\{[^{}]+\}", raw, re.DOTALL)
    if match:
        return match.group()
    
    return None

def parse_sentiment_output(raw: str) -> dict:
    """Parse the LLM output and return a validated dict."""
    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()

Step 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

Step 6: Refactored FastAPI app

# 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:
    """The only place where you decide which provider to use."""
    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 — creates and configures the FastAPI app.
    Separating app creation from execution makes tests easier.
    """
    settings = get_settings()
    
    # Configure 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()
    
    # Create the 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()

Step 7: Tests of the refactored project

# 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():
    """Clear the settings cache before/after each 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="PRODUCTION"):
        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

Verification: the Phase 1 tests still pass

# The goal of the refactoring is that the external behavior doesn't change

# Before the refactoring:
pytest tests/ -v → 47 passed

# After each refactoring step:
pytest tests/ -v → 47 passed (+ new Module 6 tests)

# If any test fails after a step:
# 1. Do NOT commit
# 2. Review what changed
# 3. Fix the code (or update the test if the behavior
#    changed intentionally)
# 4. Re-run until they all pass

# The cycle:
# EXTRACT CODE → RUN TESTS → IF THEY PASS: CONTINUE → IF THEY FAIL: REVERT

Project checklist

[ ] prompts/sentiment/v1.yaml created with system and template
[ ] src/prompts/loader.py with load_prompt() and lru_cache
[ ] src/domain/exceptions.py with LowConfidenceError, ProviderUnavailableError
[ ] src/domain/sentiment_service.py clean — doesn't import openai
[ ] src/processing/sentiment_parser.py with SentimentOutput and parse_sentiment_output()
[ ] src/infrastructure/llm_provider.py with Protocol and LLMProviderError
[ ] src/infrastructure/openai_provider.py implements LLMProvider
[ ] src/infrastructure/mock_provider.py with call tracking
[ ] src/app/dependencies.py with get_llm_provider()
[ ] src/app/routers/sentiment.py with an endpoint that uses Depends()
[ ] src/app/main.py as an app factory
[ ] src/config.py with pydantic-settings and per-environment validators
[ ] .env.example documented
[ ] tests/unit/test_sentiment_service.py — uses MockProvider, without patch()
[ ] tests/unit/test_sentiment_parser.py — tests the parser independently
[ ] tests/unit/test_config.py — verifies production validations

[ ] FINAL VERIFICATION: pytest tests/ → all pass