# Story 2.4: Provider Ollama (LLM Local)

Status: done

## Story

As a **system**,
I want **to integrate Ollama as an LLM provider with custom system prompt support**,
so that **Pro users can translate documents with local LLMs for privacy and cost efficiency**.

## Acceptance Criteria

1. **AC1: API Integration** - Given `OLLAMA_BASE_URL` is configured (default: http://localhost:11434), when `OllamaProvider.translate_text()` is called with model and prompt, then text is translated using the specified Ollama model
2. **AC2: Graceful Error Handling** - Connection/timeout returns error code `OLLAMA_UNAVAILABLE` / `OLLAMA_TIMEOUT` with clear message (e.g. "Service Ollama indisponible..."), never HTTP 500
3. **AC3: Custom System Prompt** - Custom system prompt can be injected via the request to guide translation context
4. **AC4: Health Check** - Provider `is_available()` returns `True` when Ollama is reachable and model is pulled, `False` otherwise
5. **AC5: Registry Integration** - Provider is registered in `ProviderRegistry` and appears in fallback chain
6. **AC6: Unit Tests** - Tests verify all error scenarios, connection handling, and mock Ollama API responses

## Tasks / Subtasks

- [x] **Task 1: Create Ollama Provider Implementation** (AC: 1, 3)
  - [x] 1.1 Create `services/providers/ollama_provider.py`
  - [x] 1.2 Implement `OllamaTranslationProvider` class extending `TranslationProvider`
  - [x] 1.3 Implement `translate_text()` using Ollama REST API (`/api/generate` or `/api/chat`)
  - [x] 1.4 Support custom system prompt injection via request metadata
  - [x] 1.5 Configure default translation system prompt

- [x] **Task 2: Implement Error Handling** (AC: 2)
  - [x] 2.1 Define error codes: `OLLAMA_UNAVAILABLE`, `OLLAMA_MODEL_NOT_FOUND`, `OLLAMA_TIMEOUT`, `OLLAMA_GENERATION_ERROR`, `OLLAMA_CONTEXT_TOO_LONG`
  - [x] 2.2 Implement `OllamaProviderError` exception class (follow existing pattern)
  - [x] 2.3 Map Ollama API errors to structured error responses
  - [x] 2.4 Add retry logic with exponential backoff for transient errors
  - [x] 2.5 Add timeout configuration (default 120s for LLM - longer than classic)
  - [x] 2.6 Ensure all errors return JSON: `{error, message, details?}` format

- [x] **Task 3: Implement Health Check** (AC: 4)
  - [x] 3.1 Implement `is_available()` to check Ollama service reachability
  - [x] 3.2 Add `health_check()` with caching (TTL 60s) matching existing provider pattern
  - [x] 3.3 Verify configured model is available (pulled) via `/api/tags`
  - [x] 3.4 Return `ProviderHealthStatus` with availability, latency, and model info

- [x] **Task 4: Registry Integration** (AC: 5)
  - [x] 4.1 Add `register_ollama_provider()` function
  - [x] 4.2 Add `get_ollama_provider()` singleton function
  - [x] 4.3 Update `services/providers/__init__.py` to auto-register Ollama when enabled
  - [x] 4.4 Verify provider appears in fallback chain when configured

- [x] **Task 5: Configuration Updates** (AC: 1, 2)
  - [x] 5.1 Verify `OLLAMA_BASE_URL`, `OLLAMA_MODEL`, `OLLAMA_ENABLED` in `config.py` (already present)
  - [x] 5.2 Add Ollama-specific configuration options to `config.py`:
    - `OLLAMA_TIMEOUT=120` (LLM needs longer timeout)
    - `OLLAMA_MAX_RETRIES=2`
    - `OLLAMA_RETRY_DELAY=2`
  - [x] 5.3 Update `.env.example` with Ollama-specific config

- [x] **Task 6: Create Unit Tests** (AC: 6)
  - [x] 6.1 Create `tests/test_providers/test_ollama_provider.py`
  - [x] 6.2 Test successful translation with mocked Ollama API
  - [x] 6.3 Test all error scenarios (unavailable, model not found, timeout)
  - [x] 6.4 Test custom system prompt injection
  - [x] 6.5 Test retry logic
  - [x] 6.6 Test health check functionality
  - [x] 6.7 Test registry integration

- [x] **Task 7: Update Documentation** (AC: 1-6)
  - [x] 7.1 Update `services/providers/README.md` with Ollama section
  - [x] 7.2 Document Ollama setup requirements (pull models first)
  - [x] 7.3 Document supported models and recommendations for translation

## Dev Notes

### Ollama API Specifics

**Ollama REST API Endpoints:**

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/generate` | POST | Generate text (streaming or not) |
| `/api/chat` | POST | Chat completion with messages |
| `/api/tags` | GET | List pulled models |
| `/api/show` | POST | Show model info |

**Recommended: Use `/api/chat` for translation** (better prompt handling):

```python
OLLAMA_CHAT_URL = f"{OLLAMA_BASE_URL}/api/chat"
OLLAMA_TAGS_URL = f"{OLLAMA_BASE_URL}/api/tags"

payload = {
    "model": "llama3",
    "messages": [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": text_to_translate}
    ],
    "stream": False,
    "options": {
        "temperature": 0.3  # Lower for more consistent translation
    }
}
```

**API Response Format:**
```json
{
  "model": "llama3",
  "created_at": "2024-01-15T10:30:00Z",
  "message": {
    "role": "assistant",
    "content": "Bonjour, comment allez-vous?"
  },
  "done": true
}
```

### Recommended Models for Translation

| Model | Size | Best For | Notes |
|-------|------|----------|-------|
| `llama3` | 8B | General translation | Good balance of speed/quality |
| `llama3:70b` | 70B | High-quality translation | Requires significant RAM |
| `mistral` | 7B | Fast translation | Good for real-time |
| `qwen2` | 7B | Multi-language | Strong non-English support |
| `deepseek-coder` | 6.7B | Technical docs | Good for code comments |

**Pre-requisite**: Models must be pulled before use:
```bash
ollama pull llama3
ollama pull mistral
```

### Default System Prompt for Translation

```python
DEFAULT_TRANSLATION_PROMPT = """You are a professional translator. Translate the following text from {source_lang} to {target_lang}.

Rules:
- Translate ONLY the text, do not add explanations or notes
- Preserve the original formatting, line breaks, and structure
- Maintain the original tone and style
- For technical terms, use the standard translation in the target language
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""

def _build_system_prompt(
    source_lang: str, 
    target_lang: str, 
    custom_prompt: Optional[str] = None
) -> str:
    if custom_prompt:
        return custom_prompt
    return DEFAULT_TRANSLATION_PROMPT.format(
        source_lang=source_lang, 
        target_lang=target_lang
    )
```

### Architecture Compliance

Per `_bmad-output/planning-artifacts/architecture.md`:

**Error Format:**
```json
{
  "error": "OLLAMA_UNAVAILABLE",
  "message": "Service Ollama indisponible. Vérifiez que Ollama est en cours d'exécution.",
  "details": {
    "provider": "ollama",
    "base_url": "http://localhost:11434",
    "model": "llama3"
  }
}
```

**Never return HTTP 500** - All errors must be 4xx or 502 (upstream error).

**Naming Conventions:**
- File: `ollama_provider.py` (snake_case)
- Class: `OllamaTranslationProvider` (PascalCase)
- Error codes: `OLLAMA_*` (UPPER_SNAKE_CASE)
- JSON fields: snake_case

### Previous Story Intelligence (Story 2.2 & 2.3)

**What Worked Well:**
- `deep_translator` library integration for Google/DeepL
- Thread-safe translator instances per thread
- Error codes with `to_dict()` method
- Retry logic with exponential backoff
- Health check with 60s TTL caching
- Structlog-compatible logging with keyword args

**Patterns to Reuse:**
```python
# Error codes pattern
OLLAMA_UNAVAILABLE = "OLLAMA_UNAVAILABLE"
OLLAMA_MODEL_NOT_FOUND = "OLLAMA_MODEL_NOT_FOUND"
OLLAMA_TIMEOUT = "OLLAMA_TIMEOUT"
OLLAMA_GENERATION_ERROR = "OLLAMA_GENERATION_ERROR"
OLLAMA_CONTEXT_TOO_LONG = "OLLAMA_CONTEXT_TOO_LONG"

_RETRYABLE_ERRORS = {OLLAMA_UNAVAILABLE, OLLAMA_TIMEOUT}

# Exception class pattern (from Google/DeepL providers)
class OllamaProviderError(Exception):
    def __init__(self, code: str, message: str, details: Optional[Dict[str, Any]] = None):
        self.code = code
        self.message = message
        self.details = details or {}
        super().__init__(message)

    def to_dict(self) -> Dict[str, Any]:
        result = {"error": self.code, "message": self.message}
        if self.details:
            result["details"] = self.details
        return result
```

**Key Difference for Ollama:**
- Uses HTTP requests (not a library like `deep_translator`)
- Longer timeout required (120s default vs 30s for classic)
- Model must be pre-pulled before use
- Custom system prompt support is essential

### File Structure

**Files to Create:**
- `services/providers/ollama_provider.py` - Main provider implementation
- `tests/test_providers/test_ollama_provider.py` - Unit tests

**Files to Modify:**
- `services/providers/__init__.py` - Add Ollama auto-registration
- `services/providers/config.py` - Add OLLAMA_TIMEOUT, OLLAMA_MAX_RETRIES, OLLAMA_RETRY_DELAY
- `.env.example` - Add Ollama-specific config (may already have basic config)
- `services/providers/README.md` - Add Ollama documentation

### Error Codes to Implement

| Code | HTTP | Scenario | Message Template |
|------|------|----------|------------------|
| `OLLAMA_UNAVAILABLE` | 502 | Ollama service not reachable | "Service Ollama indisponible. Vérifiez que Ollama est en cours d'exécution." |
| `OLLAMA_MODEL_NOT_FOUND` | 400 | Model not pulled | "Modèle '{model}' non trouvé. Exécutez: ollama pull {model}" |
| `OLLAMA_TIMEOUT` | 502 | Request timeout | "Délai d'attente Ollama dépassé. Réessayez avec un texte plus court." |
| `OLLAMA_GENERATION_ERROR` | 502 | LLM generation failed | "Erreur de génération Ollama: {error}" |
| `OLLAMA_CONTEXT_TOO_LONG` | 413 | Context exceeds model limit | "Texte trop long pour le modèle (max ~{max_tokens} tokens)." |

### Configuration

**Environment Variables (`.env.example`):**
```bash
# Ollama Provider (Local LLM)
OLLAMA_ENABLED=true
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama3
OLLAMA_VISION_MODEL=llava
OLLAMA_TIMEOUT=120
OLLAMA_MAX_RETRIES=2
OLLAMA_RETRY_DELAY=2
```

**Provider Config (`services/providers/config.py`):**
Add after existing OLLAMA config:
```python
OLLAMA_TIMEOUT: int = int(os.getenv("OLLAMA_TIMEOUT", "120"))
OLLAMA_MAX_RETRIES: int = int(os.getenv("OLLAMA_MAX_RETRIES", "2"))
OLLAMA_RETRY_DELAY: float = float(os.getenv("OLLAMA_RETRY_DELAY", "2.0"))
```

### Testing Strategy

**Unit Tests (Mocked):**
- Mock `httpx` or `requests` responses
- Test successful translation
- Test all error scenarios (unavailable, model not found, timeout)
- Test custom system prompt injection
- Test health check logic
- Test retry logic
- Test registry integration

**Integration Tests (Optional):**
- With Ollama running locally: real API calls
- Without Ollama: skip integration tests
- Use pytest markers: `@pytest.mark.integration`

**Test Commands:**
```bash
# Unit tests only
pytest tests/test_providers/test_ollama_provider.py -v

# All provider tests
pytest tests/test_providers/ -v

# With coverage
pytest tests/test_providers/ --cov=services/providers -v
```

### Logging Pattern

```python
try:
    import structlog
    logger = structlog.get_logger(__name__)
except ImportError:
    import logging
    logger = logging.getLogger(__name__)

# Good - metadata only (NO document content)
logger.info(
    "ollama_translation_success",
    chars=len(text),
    source_lang=source_language,
    target_lang=target_language,
    model=self._model,
    latency_ms=round(latency * 1000, 2),
)

logger.error(
    "ollama_translation_failed",
    error_code=error.code,
    text_length=len(text),
    source_lang=source_language,
    target_lang=target_language,
    model=self._model,
)
```

### Dependencies

**Internal:**
- `services/providers/base.py` - TranslationProvider abstract class
- `services/providers/registry.py` - ProviderRegistry
- `services/providers/config.py` - Configuration
- `services/providers/schemas.py` - TranslationRequest/Response models

**External:**
- `httpx` - HTTP client (preferred over requests for async support)
- `structlog` or standard `logging` - Structured logging

### HTTP Client Pattern

Use `httpx` for Ollama API calls (supports async and sync):

```python
import httpx

class OllamaTranslationProvider(TranslationProvider):
    def __init__(self, base_url: str, model: str, timeout: int = 120):
        self._base_url = base_url.rstrip("/")
        self._model = model
        self._timeout = timeout
        self._client = httpx.Client(timeout=timeout)
    
    def _make_api_request(self, text: str, system_prompt: str) -> str:
        response = self._client.post(
            f"{self._base_url}/api/chat",
            json={
                "model": self._model,
                "messages": [
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": text}
                ],
                "stream": False,
                "options": {"temperature": 0.3}
            }
        )
        # ... error handling
        return response.json()["message"]["content"]
```

### References

- [Source: _bmad-output/planning-artifacts/architecture.md#Error Handling]
- [Source: _bmad-output/planning-artifacts/architecture.md#API Response Formats]
- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.4]
- [Source: _bmad-output/planning-artifacts/prd.md#FR7 LLM providers (Ollama, OpenAI)]
- [Source: _bmad-output/planning-artifacts/prd.md#NFR12 Zero HTTP 500 errors]
- [Source: _bmad-output/planning-artifacts/prd.md#NFR13 Provider fallback]
- [Source: _bmad-output/implementation-artifacts/2-2-provider-google-translate.md]
- [Source: _bmad-output/implementation-artifacts/2-3-provider-deepl.md]
- [Source: services/providers/google_provider.py - Implementation pattern]
- [Source: services/providers/registry.py - Registration pattern]
- [Source: https://github.com/ollama/ollama/blob/main/docs/api.md - Ollama API docs]

### Security Considerations

**Local Deployment:**
- Ollama runs locally by default (no external API calls)
- No API key required for local Ollama
- If using remote Ollama, consider network security

**Data Privacy:**
- Never log document content (NFR11)
- Only log metadata: text length, languages, model, timestamps
- Ollama keeps data local (privacy advantage over cloud LLMs)

### Pro Feature Integration

Per PRD FR26: "Pro users can access LLM translation modes"

This provider will be used when:
- User tier is "pro"
- User selects "LLM" mode
- User selects "Ollama" as LLM provider

The tier check happens in the translation service/router, not in the provider itself.

## Dev Agent Record

### Agent Model Used

Claude (GLM-5) via opencode

### Debug Log References

- Fixed logging compatibility issue: standard logging doesn't support keyword arguments like structlog
- Created helper functions `_log_info`, `_log_warning`, `_log_error` to bridge the gap
- Updated test file to use `requests` instead of `httpx` (httpx not in requirements)

### Completion Notes List

- ✅ Implemented `OllamaTranslationProvider` class with all required features
- ✅ Uses `/api/chat` endpoint for translation with system prompt support
- ✅ All 5 error codes implemented with French messages
- ✅ Retry logic with exponential backoff for `OLLAMA_UNAVAILABLE` and `OLLAMA_TIMEOUT`
- ✅ Health check with 60s TTL caching and model availability verification
- ✅ Registry integration with auto-registration when `OLLAMA_ENABLED=true`
- ✅ Custom system prompt injection via `request.metadata["custom_prompt"]`
- ✅ Language name mapping for better LLM understanding
- ✅ 29 unit tests created and all passing
- ✅ Documentation updated in README.md with Ollama section

### Code Review Fixes (AI) – 2026-02-21

- **AC4 / ProviderHealthStatus** – Added optional fields `model` and `model_available` to `ProviderHealthStatus` in `schemas.py`; Ollama `health_check()` now returns model info (availability, latency, model name).
- **Health check messages** – Unified to French in `health_check()` (e.g. "Service Ollama indisponible...", "Modèle 'x' non trouvé...").
- **Tests** – Removed unused `import socket`; added `test_timeout_returns_ollama_timeout_error`; strengthened `test_health_check_caching` with mock to assert no API call when cache is valid; added assertions for `model` and `model_available` in health check tests.
- **AC2** – Story AC2 wording updated to reflect implementation (error codes `OLLAMA_UNAVAILABLE` / `OLLAMA_TIMEOUT`).

### File List

**Files Created:**
- `services/providers/ollama_provider.py` - Main Ollama provider implementation
- `tests/test_providers/test_ollama_provider.py` - 29 unit tests

**Files Modified:**
- `services/providers/__init__.py` - Added Ollama auto-registration
- `services/providers/config.py` - Added OLLAMA_TIMEOUT, OLLAMA_MAX_RETRIES, OLLAMA_RETRY_DELAY
- `services/providers/schemas.py` - Added metadata field to TranslationRequest for custom prompt support
- `services/providers/README.md` - Added comprehensive Ollama documentation
- `.env.example` - Added Ollama-specific configuration options

### Change Log

- 2026-02-21: Story 2.4 implementation complete - Ollama provider with local LLM translation, custom prompts, error handling, and 29 passing tests
- 2026-02-21: Code review fixes - ProviderHealthStatus model info (model, model_available), health check messages in French, tests (timeout error, cache assertion, model assertions), AC2 wording aligned
