Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
455 lines
17 KiB
Markdown
455 lines
17 KiB
Markdown
# 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
|