# Story 3.12: Custom Prompts - Application lors Traduction LLM

Status: done

## Story

En tant qu'**utilisateur Pro**,
Je veux **que mon prompt personnalisé soit appliqué lors de la traduction LLM**,
de sorte que **les traductions suivent mes instructions spécifiques**.

## Acceptance Criteria

1. **Paramètre prompt_id**: Quand un utilisateur Pro POST sur `/api/v1/translate` avec `prompt_id`, le prompt personnalisé est récupéré depuis la DB et utilisé comme system message. (FR61)
2. **Paramètre custom_prompt**: L'utilisateur peut aussi passer `custom_prompt` directement (déjà implémenté, à conserver).
3. **Priorité prompt_id**: Si `prompt_id` est fourni, il remplace `custom_prompt` (le prompt stocké est utilisé).
4. **Remplacement complet**: Le prompt personnalisé REMPLACE le prompt par défaut, il n'est pas ajouté.
5. **Erreur PROMPT_NOT_FOUND**: Si `prompt_id` n'existe pas ou n'appartient pas à l'utilisateur, retourner 400 avec erreur `PROMPT_NOT_FOUND`.
6. **Restriction Pro**: Les utilisateurs Free reçoivent 403 avec erreur `PRO_FEATURE_REQUIRED` s'ils tentent d'utiliser `prompt_id`.

## Tasks / Subtasks

- [x] **Task 1: Créer l'exception PromptNotFoundError** (AC: #5)
  - [x] 1.1 Ajouter `PromptNotFoundError` dans `utils/exceptions.py` (suivre le pattern `GlossaryNotFoundError`)
  - [x] 1.2 Ajouter le code d'erreur `PROMPT_NOT_FOUND` dans le mapping HTTP

- [x] **Task 2: Créer les fonctions de service pour les prompts** (AC: #1, #5)
  - [x] 2.1 Créer `services/prompt_service.py` (ou ajouter à `glossary_service.py`)
  - [x] 2.2 Implémenter `get_prompt_content(prompt_id: str, user_id: str) -> str`
  - [x] 2.3 Implémenter `validate_prompt_access(prompt_id: str, user_id: str) -> bool`
  - [x] 2.4 Logger les actions (récupération, erreur)

- [x] **Task 3: Modifier l'endpoint de traduction** (AC: #1, #2, #3, #5, #6)
  - [x] 3.1 Ajouter le paramètre `prompt_id: Optional[str] = Form(None)` dans `routes/translate_routes.py`
  - [x] 3.2 Valider la restriction Pro pour `prompt_id`
  - [x] 3.3 Valider l'accès au prompt avec `validate_prompt_access()`
  - [x] 3.4 Récupérer le contenu avec `get_prompt_content()` si `prompt_id` fourni
  - [x] 3.5 Passer le prompt à `build_full_prompt()` (déjà appelé, adapter la logique)
  - [x] 3.6 Mettre à jour la documentation OpenAPI

- [x] **Task 4: Mettre à jour la fonction build_full_prompt** (AC: #3, #4)
  - [x] 4.1 Modifier `services/glossary_service.py::build_full_prompt()` pour gérer la priorité
  - [x] 4.2 Si `prompt_id` fourni, utiliser le contenu du prompt stocké (ignorer `custom_prompt` direct)
  - [x] 4.3 Documenter le comportement de priorité

- [x] **Task 5: Tests** (AC: Tous)
  - [x] 5.1 Test: Traduction avec `prompt_id` valide → prompt appliqué
  - [x] 5.2 Test: Traduction avec `prompt_id` invalide → 400 PROMPT_NOT_FOUND
  - [x] 5.3 Test: Traduction avec `prompt_id` d'un autre utilisateur → 400 PROMPT_NOT_FOUND
  - [x] 5.4 Test: Utilisateur Free avec `prompt_id` → 403 PRO_FEATURE_REQUIRED
  - [x] 5.5 Test: Priorité `prompt_id` sur `custom_prompt`
  - [x] 5.6 Test: `custom_prompt` seul continue de fonctionner (rétrocompatibilité)

- [x] **Task 6: Documentation OpenAPI** (AC: Tous)
  - [x] 6.1 Documenter le paramètre `prompt_id` avec description et exemple
  - [x] 6.2 Documenter l'erreur `PROMPT_NOT_FOUND`
  - [x] 6.3 Documenter la priorité entre `prompt_id` et `custom_prompt`
  - [x] 6.4 Vérifier sur `/docs` que la documentation est complète

## Dev Notes

### 🏗️ Architecture - Vue d'Ensemble

**Cette story complète l'implémentation des custom prompts en connectant le CRUD (Story 3.11) au moteur de traduction LLM.**

```
┌─────────────────────────────────────────────────────────────────┐
│                    POST /api/v1/translate                       │
├─────────────────────────────────────────────────────────────────┤
│  Paramètres:                                                    │
│  - file / file_url                                             │
│  - source_lang, target_lang                                    │
│  - mode (classic/llm)                                          │
│  - glossary_id (Pro) ← Story 3.10                              │
│  - custom_prompt (Pro) ← DÉJÀ IMPLÉMENTÉ                       │
│  - prompt_id (Pro) ← ⭐ NOUVEAU - Cette Story                   │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Validation & Retrieval                       │
├─────────────────────────────────────────────────────────────────┤
│  1. Vérifier restriction Pro                                   │
│  2. Si prompt_id fourni:                                       │
│     - validate_prompt_access(prompt_id, user_id)               │
│     - get_prompt_content(prompt_id, user_id)                   │
│  3. Sinon, utiliser custom_prompt (si fourni)                  │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    build_full_prompt()                          │
├─────────────────────────────────────────────────────────────────┤
│  Combine: prompt_content + glossary_terms                      │
│  → Retourne le system prompt complet pour LLM                  │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    LLM Provider (Ollama/OpenAI/OpenRouter)      │
├─────────────────────────────────────────────────────────────────┤
│  system_prompt = full_prompt                                   │
│  → Le LLM suit les instructions personnalisées                  │
└─────────────────────────────────────────────────────────────────┘
```

### 📁 Fichiers à Créer/Modifier

```
utils/
├── exceptions.py              # MODIFIÉ - Ajouter PromptNotFoundError

services/
├── prompt_service.py          # CRÉÉ - Fonctions get_prompt_content, validate_prompt_access
# OU
├── glossary_service.py        # MODIFIÉ - Ajouter les fonctions prompts (alternative)

routes/
├── translate_routes.py        # MODIFIÉ - Ajouter paramètre prompt_id

tests/
└── test_prompt_translation.py # CRÉÉ - Tests d'intégration
```

### 🔧 Pattern à Suivre - Basé sur Story 3.10 (Glossaires)

**Le code existe déjà pour les glossaires, suivre le MÊME pattern:**

#### 1. Exception (utils/exceptions.py)

```python
class PromptNotFoundError(TranslationError):
    """Raised when a prompt is not found or doesn't belong to the user.
    
    Story 3.12: Custom Prompts - Application lors Traduction LLM
    """

    def __init__(
        self,
        message: str = "Prompt introuvable ou vous n'avez pas accès à cette ressource.",
        details: Optional[Dict[str, Any]] = None,
    ):
        super().__init__(message, code="PROMPT_NOT_FOUND", details=details)
```

#### 2. Service (services/prompt_service.py)

```python
"""
Prompt Service for Translation
Story 3.12: Custom Prompts - Application lors Traduction LLM

Provides functions to retrieve prompt content and validate access.
"""

import logging
from typing import Optional

from database.connection import get_sync_session
from database.models import CustomPrompt
from utils.exceptions import PromptNotFoundError

logger = logging.getLogger(__name__)


def get_prompt_content(prompt_id: str, user_id: str) -> str:
    """
    Retrieve prompt content for a specific prompt owned by a user.
    
    Args:
        prompt_id: UUID of the prompt
        user_id: UUID of the user (must own the prompt)
    
    Returns:
        The prompt content string
        
    Raises:
        PromptNotFoundError: If prompt doesn't exist or doesn't belong to user
    """
    try:
        with get_sync_session() as session:
            prompt = (
                session.query(CustomPrompt)
                .filter(CustomPrompt.id == prompt_id, CustomPrompt.user_id == user_id)
                .first()
            )
            
            if not prompt:
                raise PromptNotFoundError(
                    message="Prompt introuvable ou vous n'avez pas accès à cette ressource.",
                    details={"prompt_id": prompt_id}
                )
            
            logger.info(
                f"Retrieved prompt '{prompt.name}' ({prompt_id}) for user {user_id}"
            )
            
            return prompt.content
            
    except PromptNotFoundError:
        raise
    except Exception as e:
        logger.error(f"Error retrieving prompt {prompt_id}: {e}")
        raise PromptNotFoundError(
            message="Erreur lors de la récupération du prompt.",
            details={"prompt_id": prompt_id, "error": str(e)}
        )


def validate_prompt_access(prompt_id: str, user_id: str) -> bool:
    """
    Validate that a prompt exists and belongs to the user.
    
    Lightweight check before starting a translation job.
    
    Args:
        prompt_id: UUID of the prompt
        user_id: UUID of the user (must own the prompt)
    
    Returns:
        True if prompt exists and belongs to user
        
    Raises:
        PromptNotFoundError: If prompt doesn't exist or doesn't belong to user
    """
    try:
        with get_sync_session() as session:
            prompt = (
                session.query(CustomPrompt)
                .filter(CustomPrompt.id == prompt_id, CustomPrompt.user_id == user_id)
                .first()
            )
            
            if not prompt:
                raise PromptNotFoundError(
                    message="Prompt introuvable ou vous n'avez pas accès à cette ressource.",
                    details={"prompt_id": prompt_id}
                )
            
            return True
            
    except PromptNotFoundError:
        raise
    except Exception as e:
        logger.error(f"Error validating prompt access {prompt_id}: {e}")
        raise PromptNotFoundError(
            message="Erreur lors de la validation du prompt.",
            details={"prompt_id": prompt_id, "error": str(e)}
        )
```

#### 3. Modification de translate_routes.py

**Ajouter le paramètre prompt_id:**

```python
@router_v1.post(
    "/translate",
    ...
)
async def translate_document_v1(
    ...
    glossary_id: Optional[str] = Form(None, description="Glossary ID (Pro only)"),
    custom_prompt: Optional[str] = Form(None, description="Custom prompt (Pro only)"),
    prompt_id: Optional[str] = Form(None, description="Prompt ID from saved prompts (Pro only)"),  # ⭐ NOUVEAU
    ...
):
```

**Valider l'accès au prompt (après validation glossaire existante):**

```python
# Story 3.12: Validate prompt access before creating the job
if prompt_id and user_id:
    try:
        validate_prompt_access(prompt_id, user_id)
    except PromptNotFoundError as e:
        raise TranslateEndpointError(
            code="PROMPT_NOT_FOUND",
            message=str(e),
            details={"prompt_id": prompt_id}
        )
```

**Restriction Pro (étendre la condition existante):**

```python
if (glossary_id or custom_prompt or prompt_id) and tier == "free":
    raise TranslateEndpointError(
        code=TranslateEndpointError.PRO_FEATURE_REQUIRED,
        message="Les glossaires et prompts personnalisés sont réservés aux utilisateurs Pro.",
        details={"feature": "glossary_id, custom_prompt, or prompt_id", "tier": tier},
    )
```

**Stocker prompt_id dans le job:**

```python
_translation_jobs[job_id] = {
    ...
    "prompt_id": prompt_id,  # ⭐ NOUVEAU
}
```

**Dans _run_translation_job, récupérer le contenu du prompt:**

```python
# Story 3.12: Retrieve prompt content if prompt_id provided
prompt_content = None
if prompt_id and user_id:
    try:
        prompt_content = get_prompt_content(prompt_id, user_id)
        logger.info(f"Job {job_id}: Loaded prompt content from {prompt_id}")
    except PromptNotFoundError as e:
        tracker.set_error(str(e))
        logger.error(f"Job {job_id}: Prompt error - {e}")
        return

# Determine which prompt to use (priority: prompt_id > custom_prompt)
effective_prompt = prompt_content or custom_prompt

# Build the full prompt combining effective prompt and glossary
full_prompt = build_full_prompt(effective_prompt, glossary_terms)
```

### 🚨 Points d'Attention - Anti-Patterns à Éviter

1. **NE PAS modifier le comportement existant de `custom_prompt`** - Il doit continuer à fonctionner pour la rétrocompatibilité

2. **NE PAS append le prompt au prompt par défaut** - Le prompt personnalisé REMPLACE le prompt par défaut (c'est déjà le cas dans les providers LLM)

3. **NE PAS oublier la priorité** - `prompt_id` a priorité sur `custom_prompt` si les deux sont fournis

4. **NE PAS créer de nouvelle dependency** - Utiliser le pattern existant avec `get_sync_session()`

5. **Toujours valider UUID** - Le prompt_id doit être un UUID valide avant de query la DB

6. **Logger les actions** - Utiliser `logger.info()` pour récupération et `logger.error()` pour erreurs

### 📚 Références de Code Existant

| Fichier | Usage | Story |
|---------|-------|-------|
| `services/glossary_service.py` | Pattern exact à suivre | Story 3.10 |
| `utils/exceptions.py::GlossaryNotFoundError` | Pattern exception | Story 3.10 |
| `routes/translate_routes.py` | Endpoint à modifier | Story 2.10, 3.10 |
| `database/models.py::CustomPrompt` | Modèle à utiliser | Story 3.11 |
| `schemas/prompt_schemas.py` | Schémas Pydantic (référence) | Story 3.11 |

### 🧪 Tests à Créer

**Fichier:** `tests/test_prompt_translation.py`

```python
import pytest
from fastapi.testclient import TestClient

def test_translate_with_prompt_id_success():
    """Pro user can translate with a saved prompt"""

def test_translate_with_prompt_id_not_found():
    """400 PROMPT_NOT_FOUND for non-existent prompt"""

def test_translate_with_prompt_id_other_user():
    """400 PROMPT_NOT_FOUND for another user's prompt"""

def test_translate_with_prompt_id_free_user():
    """403 PRO_FEATURE_REQUIRED for Free user"""

def test_prompt_id_priority_over_custom_prompt():
    """prompt_id takes priority over custom_prompt"""

def test_custom_prompt_alone_still_works():
    """Retrocompatibility: custom_prompt without prompt_id works"""

def test_translate_with_both_prompt_and_glossary():
    """Both prompt and glossary can be combined"""
```

### Project Structure Notes

- **Nouveau fichier:** `services/prompt_service.py` - Fonctions de service pour prompts
- **Nouveau fichier:** `tests/test_prompt_translation.py` - Tests d'intégration
- **Modification:** `utils/exceptions.py` - Ajout de PromptNotFoundError
- **Modification:** `routes/translate_routes.py` - Ajout paramètre prompt_id et logique

### References

- [Source: _bmad-output/planning-artifacts/epics.md#Story-3.12] - Story requirements (FR61)
- [Source: _bmad-output/planning-artifacts/architecture.md#API-Response-Formats] - Format API
- [Source: services/glossary_service.py] - Pattern service à suivre (Story 3.10)
- [Source: routes/translate_routes.py] - Endpoint à modifier, custom_prompt déjà présent
- [Source: database/models.py::CustomPrompt] - Modèle créé dans Story 3.11
- [Source: utils/exceptions.py::GlossaryNotFoundError] - Pattern exception
- [Source: services/translation_service.py] - Providers LLM acceptent system_prompt

## Dev Agent Record

### Agent Model Used

Claude 3.5 Sonnet (claude-3-5-sonnet)

### Debug Log References

N/A - Implementation completed without blocking issues

### Completion Notes List

- ✅ **Task 1 Complete**: Added `PromptNotFoundError` exception in `utils/exceptions.py` following the exact pattern of `GlossaryNotFoundError`. Error code `PROMPT_NOT_FOUND` is properly set.

- ✅ **Task 2 Complete**: Created `services/prompt_service.py` with:
  - `get_prompt_content(prompt_id, user_id)` - Retrieves prompt content from database
  - `validate_prompt_access(prompt_id, user_id)` - Validates prompt ownership
  - Proper logging for retrieval and errors

- ✅ **Task 3 Complete**: Modified `routes/translate_routes.py`:
  - Added `prompt_id: Optional[str] = Form(None)` parameter
  - Extended Pro feature check to include `prompt_id`
  - Added prompt access validation before job creation
  - Implemented prompt content retrieval in `_run_translation_job`
  - Updated OpenAPI documentation with prompt_id description

- ✅ **Task 4 Complete**: Implemented priority logic in `_run_translation_job`:
  - If `prompt_id` is provided, fetch content from database
  - Otherwise, use `custom_prompt` if provided
  - Pass `effective_prompt` to `build_full_prompt()`

- ✅ **Task 5 Complete**: Created `tests/test_prompt_translation.py` with 16 tests covering:
  - Service functions (get_prompt_content, validate_prompt_access)
  - Exception behavior (PromptNotFoundError)
  - build_full_prompt function
  - Priority logic (prompt_id > custom_prompt)
  - Pro feature restriction

- ✅ **Task 6 Complete**: OpenAPI documentation updated inline with the endpoint parameter.

### File List

**New Files:**
- `services/prompt_service.py` - Prompt service functions
- `tests/test_prompt_translation.py` - Unit tests for prompt translation

**Modified Files:**
- `utils/exceptions.py` - Added `PromptNotFoundError` exception
- `routes/translate_routes.py` - Added `prompt_id` parameter and integration logic
