Files
office_translator/_bmad-output/implementation-artifacts/3-12-custom-prompts-application-lors-traduction-llm.md
Sepehr Ramezani 26bd096a06 feat: production deployment - full update with providers, admin, glossaries, pricing, tests
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>
2026-04-25 15:01:47 +02:00

19 KiB

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

  • Task 1: Créer l'exception PromptNotFoundError (AC: #5)

    • 1.1 Ajouter PromptNotFoundError dans utils/exceptions.py (suivre le pattern GlossaryNotFoundError)
    • 1.2 Ajouter le code d'erreur PROMPT_NOT_FOUND dans le mapping HTTP
  • Task 2: Créer les fonctions de service pour les prompts (AC: #1, #5)

    • 2.1 Créer services/prompt_service.py (ou ajouter à glossary_service.py)
    • 2.2 Implémenter get_prompt_content(prompt_id: str, user_id: str) -> str
    • 2.3 Implémenter validate_prompt_access(prompt_id: str, user_id: str) -> bool
    • 2.4 Logger les actions (récupération, erreur)
  • Task 3: Modifier l'endpoint de traduction (AC: #1, #2, #3, #5, #6)

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

    • 4.1 Modifier services/glossary_service.py::build_full_prompt() pour gérer la priorité
    • 4.2 Si prompt_id fourni, utiliser le contenu du prompt stocké (ignorer custom_prompt direct)
    • 4.3 Documenter le comportement de priorité
  • Task 5: Tests (AC: Tous)

    • 5.1 Test: Traduction avec prompt_id valide → prompt appliqué
    • 5.2 Test: Traduction avec prompt_id invalide → 400 PROMPT_NOT_FOUND
    • 5.3 Test: Traduction avec prompt_id d'un autre utilisateur → 400 PROMPT_NOT_FOUND
    • 5.4 Test: Utilisateur Free avec prompt_id → 403 PRO_FEATURE_REQUIRED
    • 5.5 Test: Priorité prompt_id sur custom_prompt
    • 5.6 Test: custom_prompt seul continue de fonctionner (rétrocompatibilité)
  • Task 6: Documentation OpenAPI (AC: Tous)

    • 6.1 Documenter le paramètre prompt_id avec description et exemple
    • 6.2 Documenter l'erreur PROMPT_NOT_FOUND
    • 6.3 Documenter la priorité entre prompt_id et custom_prompt
    • 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)

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)

"""
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:

@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):

# 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):

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:

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

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

# 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

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