# Story 2.1: Abstraction Provider (Base + Registry) Status: done ## Story As a **Developer**, I want **to create an abstract TranslationProvider base class with a ProviderRegistry**, so that **multiple translation providers can be plugged in with fallback support and clean architecture compliance**. ## Acceptance Criteria 1. **AC1: Abstract Base Class** - `TranslationProvider` defines abstract methods: `translate_text()`, `get_name()`, `is_available()` 2. **AC2: ProviderRegistry** - Registry can register, retrieve, and list providers by name 3. **AC3: Environment Configuration** - Providers configured via environment variables (API keys, URLs) 4. **AC4: Health Check** - Each provider has `is_available()` method returning `True`/`False` 5. **AC5: Unit Tests** - Tests verify provider interface and registry functionality ## Tasks / Subtasks - [x] **Task 1: Create Provider Base Class** (AC: 1, 4) - [x] 1.1 Create `services/providers/__init__.py` - [x] 1.2 Create `services/providers/base.py` with abstract `TranslationProvider` - [x] 1.3 Define abstract methods: `translate_text()`, `get_name()`, `is_available()` - [x] 1.4 Add optional methods: `translate_batch()`, `health_check()` - [x] 1.5 Add Pydantic models for request/response in `services/providers/schemas.py` - [x] **Task 2: Create Provider Registry** (AC: 2) - [x] 2.1 Create `services/providers/registry.py` with `ProviderRegistry` class - [x] 2.2 Implement `register(name, provider_class)` method - [x] 2.3 Implement `get(name) -> TranslationProvider` method - [x] 2.4 Implement `list_available() -> List[str]` method - [x] 2.5 Implement `get_first_available(names: List[str]) -> TranslationProvider` for fallback - [x] 2.6 Add singleton pattern for global registry - [x] **Task 3: Migrate Existing Google Provider** (AC: 1, 3, 4) - [x] 3.1 Create `services/providers/google_provider.py` - [x] 3.2 Extend `TranslationProvider` base class - [x] 3.3 Implement `is_available()` using a simple ping/test - [x] 3.4 Migrate caching logic from existing `GoogleTranslationProvider` - [x] 3.5 Register in registry on import - [x] **Task 4: Environment Configuration** (AC: 3) - [x] 4.1 Add provider settings to `config.py` (or create `services/providers/config.py`) - [x] 4.2 Load API keys from environment: `GOOGLE_API_KEY`, `DEEPL_API_KEY`, `OPENAI_API_KEY`, `OLLAMA_BASE_URL` - [x] 4.3 Add provider enable/disable flags - [x] 4.4 Update `.env.example` with new variables - [x] **Task 5: Unit Tests** (AC: 5) - [x] 5.1 Create `tests/test_providers/test_base.py` - [x] 5.2 Create `tests/test_providers/test_registry.py` - [x] 5.3 Create `tests/test_providers/test_google_provider.py` - [x] 5.4 Test abstract class cannot be instantiated directly - [x] 5.5 Test registry registration and retrieval - [x] 5.6 Test fallback chain logic ## Dev Notes ### 🚨 BROWNFIELD CONTEXT - CRITICAL This is a **refactoring story**. The project already has provider implementations in `services/translation_service.py`: - `TranslationProvider` (ABC) - lines 116-157 - `GoogleTranslationProvider` - lines 159-282 - `DeepLTranslationProvider` - lines 285-340 - `OllamaTranslationProvider` - lines 397-517 - `OpenAITranslationProvider` - lines 667-767 - `OpenRouterTranslationProvider` - lines 520-654 **Strategy:** Extract and reorganize into clean module structure, keeping backward compatibility. ### Target Module Structure (from Architecture) ``` services/ ├── providers/ │ ├── __init__.py # Exports + registry setup │ ├── base.py # Abstract TranslationProvider │ ├── registry.py # ProviderRegistry singleton │ ├── schemas.py # Pydantic request/response models │ ├── config.py # Provider settings from env │ └── google_provider.py # Google implementation (migrated) ├── translation_service.py # Keep for now, refactor later └── ... ``` ### Existing Code to Preserve **From `services/translation_service.py`:** | Component | Lines | Action | |-----------|-------|--------| | `TranslationCache` | 53-113 | Keep in `services/translation_service.py` (used by all providers) | | `retry_with_backoff` | 27-50 | Keep as utility | | `TranslationProvider` (ABC) | 116-157 | Refactor to `providers/base.py` | | `GoogleTranslationProvider` | 159-282 | Migrate to `providers/google_provider.py` | | Other providers | Various | Migrate in subsequent stories (2.2-2.5) | ### Abstract Base Class Design ```python # services/providers/base.py from abc import ABC, abstractmethod from typing import Optional, List from pydantic import BaseModel class TranslationRequest(BaseModel): text: str target_language: str source_language: str = "auto" class TranslationResponse(BaseModel): translated_text: str provider_name: str from_cache: bool = False class TranslationProvider(ABC): """Abstract base class for translation providers.""" @abstractmethod def translate_text(self, request: TranslationRequest) -> TranslationResponse: """Translate a single text string.""" pass @abstractmethod def get_name(self) -> str: """Return provider name for logging and registry.""" pass @abstractmethod def is_available(self) -> bool: """Check if provider is configured and reachable.""" pass def translate_batch(self, requests: List[TranslationRequest]) -> List[TranslationResponse]: """Default batch implementation using individual calls.""" return [self.translate_text(req) for req in requests] def health_check(self) -> dict: """Return health status details.""" return { "name": self.get_name(), "available": self.is_available() } ``` ### Provider Registry Design ```python # services/providers/registry.py from typing import Dict, List, Optional, Type from .base import TranslationProvider class ProviderRegistry: """Singleton registry for translation providers.""" _instance: Optional['ProviderRegistry'] = None def __new__(cls) -> 'ProviderRegistry': if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._providers: Dict[str, TranslationProvider] = {} return cls._instance def register(self, name: str, provider: TranslationProvider) -> None: self._providers[name] = provider def get(self, name: str) -> Optional[TranslationProvider]: return self._providers.get(name) def list_available(self) -> List[str]: return [name for name, p in self._providers.items() if p.is_available()] def get_first_available(self, names: List[str]) -> Optional[TranslationProvider]: """Get first available provider from a list of names (fallback chain).""" for name in names: provider = self.get(name) if provider and provider.is_available(): return provider return None # Global registry instance registry = ProviderRegistry() ``` ### Environment Variables ```bash # .env.example additions GOOGLE_TRANSLATE_ENABLED=true DEEPL_ENABLED=false DEEPL_API_KEY= OPENAI_ENABLED=false OPENAI_API_KEY= OLLAMA_ENABLED=false OLLAMA_BASE_URL=http://localhost:11434 OPENROUTER_ENABLED=false OPENROUTER_API_KEY= # Provider fallback chain (comma-separated) PROVIDER_FALLBACK_CHAIN=google,deepl,ollama,openrouter ``` ### Backward Compatibility During transition, keep existing `TranslationService` in `services/translation_service.py` working: ```python # services/translation_service.py (modified) from services.providers.registry import registry from services.providers.google_provider import GoogleTranslationProvider # For backward compatibility, existing code continues to work # New code uses registry.get("google").translate_text(...) ``` ### Files to Create | File | Action | Description | |------|--------|-------------| | `services/providers/__init__.py` | CREATE | Package init, exports | | `services/providers/base.py` | CREATE | Abstract TranslationProvider | | `services/providers/registry.py` | CREATE | ProviderRegistry singleton | | `services/providers/schemas.py` | CREATE | Pydantic models | | `services/providers/config.py` | CREATE | Provider settings from env | | `services/providers/google_provider.py` | CREATE | Google provider (migrated) | | `tests/test_providers/__init__.py` | CREATE | Test package | | `tests/test_providers/test_base.py` | CREATE | Base class tests | | `tests/test_providers/test_registry.py` | CREATE | Registry tests | | `tests/test_providers/test_google_provider.py` | CREATE | Google provider tests | ### Files NOT to Modify (Yet) - `services/translation_service.py` - Keep existing classes, we'll deprecate later - `translators/*.py` - File processors, different concern - `routes/*.py` - Will be updated in subsequent stories - `main.py` - No changes in this story ### Testing Commands ```bash # Run tests pytest tests/test_providers/ -v # Test specific file pytest tests/test_providers/test_registry.py -v # With coverage pytest tests/test_providers/ --cov=services/providers ``` ### Project Structure Notes - Python files: `snake_case` (e.g., `google_provider.py`) - Classes: `PascalCase` (e.g., `TranslationProvider`, `ProviderRegistry`) - Variables/functions: `snake_case` - Follow existing patterns in `services/` folder ### Architecture Compliance Per `_bmad-output/planning-artifacts/architecture.md`: - [x] Uses Clean Architecture pattern (providers as pluggable adapters) - [x] Follows naming conventions (snake_case files, PascalCase classes) - [x] Configuration via environment variables - [x] Async support preparation (future story will add async) ### References - [Source: _bmad-output/planning-artifacts/architecture.md#Data Architecture] - [Source: _bmad-output/planning-artifacts/architecture.md#Implementation Patterns] - [Source: _bmad-output/planning-artifacts/epics.md#Story 2.1] - [Source: _bmad-output/planning-artifacts/prd.md#FR6-FR7 Translation Providers] - [Source: _bmad-output/planning-artifacts/prd.md#NFR12-NFR13 Provider Fallback] ### Previous Story Learnings (Epic 1) From Story 1.1: - Use `pytest` with `pytest-asyncio` for async tests - Create `conftest.py` with shared fixtures - Add `__init__.py` to test directories - Keep backward compatibility during refactoring ## Dev Agent Record ### Agent Model Used claude-3-5-sonnet (claude-sonnet-4-20250514) ### Debug Log References None - implementation completed without issues. ### Completion Notes List - **Task 1 Complete**: Created `services/providers/` module with abstract base class, schemas, and Pydantic models for requests/responses - **Task 2 Complete**: Implemented thread-safe singleton `ProviderRegistry` with register, get, list_available, and get_first_available methods - **Task 3 Complete**: Migrated Google provider to new architecture, preserving caching support via existing `_translation_cache` - **Task 4 Complete**: Created `services/providers/config.py` with environment-based configuration for all providers; updated `.env.example` - **Task 5 Complete**: 45 unit tests covering base class, registry, and Google provider functionality - **All 141 tests pass** including new provider tests and existing regression tests - **Backward compatibility maintained**: Existing `services/translation_service.py` unchanged ### File List **New Files Created:** - `services/providers/__init__.py` - `services/providers/base.py` - `services/providers/registry.py` - `services/providers/schemas.py` - `services/providers/config.py` - `services/providers/google_provider.py` - `tests/test_providers/__init__.py` - `tests/test_providers/test_base.py` - `tests/test_providers/test_registry.py` - `tests/test_providers/test_google_provider.py` **Modified Files:** - `.env.example` - Added provider enable flags and fallback chain configuration ## Change Log - 2026-02-20: Story 2.1 completed - Provider abstraction layer with registry, base class, schemas, config, and Google provider migration. All 45 new tests pass. Total test suite: 141 tests passing. - 2026-02-20: **Code Review (AI)** - Fixed 3 HIGH + 4 MEDIUM issues: - [FIXED] Auto-registration of Google provider in `__init__.py` - [FIXED] Added `error` field to `TranslationResponse` for silent failure detection - [FIXED] Added `success` property to `TranslationResponse` - [FIXED] Language code validation in `TranslationRequest` (ISO 639-1 format) - [FIXED] Improved dotenv loading in `config.py` - [ADDED] 3 new tests for error handling and validation - Total tests: 47 passing ## Senior Developer Review (AI) **Review Date:** 2026-02-20 **Reviewer:** AI Code Review (adversarial mode) **Outcome:** ✅ APPROVED (after fixes) ### Issues Found and Resolved | Severity | Issue | File:Line | Status | |----------|-------|-----------|--------| | HIGH | Google provider not auto-registered | `__init__.py:42-50` | ✅ Fixed | | HIGH | Silent failure returns untranslated text | `google_provider.py:135-141` | ✅ Fixed | | HIGH | No error indication on translation failure | `schemas.py:21-36` | ✅ Fixed | | MEDIUM | No language code validation | `schemas.py:9-22` | ✅ Fixed | | MEDIUM | load_dotenv side effect at module level | `config.py:12-19` | ✅ Fixed | | MEDIUM | Circular import risk (deferred) | `google_provider.py:51` | ⚠️ Acceptable | | MEDIUM | No integration tests | `tests/` | ⚠️ Deferred | | LOW | Class-level lock pattern | `registry.py:24-25` | ⚠️ Acceptable | | LOW | Two provider instance patterns | `google_provider.py:178-186` | ⚠️ Acceptable | ### Git vs Story Discrepancy Note 9 modified files not in File List (from Story 1.7 in-progress): - `alembic/env.py`, `database/`, `main.py`, `models/`, `requirements.txt`, `routes/`, `services/auth_service.py` ### Test Coverage - 47 unit tests for providers (all passing) - Coverage: base class, registry, Google provider, schemas, error handling