# 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