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>
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
- Paramètre prompt_id: Quand un utilisateur Pro POST sur
/api/v1/translateavecprompt_id, le prompt personnalisé est récupéré depuis la DB et utilisé comme system message. (FR61) - Paramètre custom_prompt: L'utilisateur peut aussi passer
custom_promptdirectement (déjà implémenté, à conserver). - Priorité prompt_id: Si
prompt_idest fourni, il remplacecustom_prompt(le prompt stocké est utilisé). - Remplacement complet: Le prompt personnalisé REMPLACE le prompt par défaut, il n'est pas ajouté.
- Erreur PROMPT_NOT_FOUND: Si
prompt_idn'existe pas ou n'appartient pas à l'utilisateur, retourner 400 avec erreurPROMPT_NOT_FOUND. - Restriction Pro: Les utilisateurs Free reçoivent 403 avec erreur
PRO_FEATURE_REQUIREDs'ils tentent d'utiliserprompt_id.
Tasks / Subtasks
-
Task 1: Créer l'exception PromptNotFoundError (AC: #5)
- 1.1 Ajouter
PromptNotFoundErrordansutils/exceptions.py(suivre le patternGlossaryNotFoundError) - 1.2 Ajouter le code d'erreur
PROMPT_NOT_FOUNDdans le mapping HTTP
- 1.1 Ajouter
-
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)
- 2.1 Créer
-
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)dansroutes/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()siprompt_idfourni - 3.5 Passer le prompt à
build_full_prompt()(déjà appelé, adapter la logique) - 3.6 Mettre à jour la documentation OpenAPI
- 3.1 Ajouter le paramètre
-
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_idfourni, utiliser le contenu du prompt stocké (ignorercustom_promptdirect) - 4.3 Documenter le comportement de priorité
- 4.1 Modifier
-
Task 5: Tests (AC: Tous)
- 5.1 Test: Traduction avec
prompt_idvalide → prompt appliqué - 5.2 Test: Traduction avec
prompt_idinvalide → 400 PROMPT_NOT_FOUND - 5.3 Test: Traduction avec
prompt_idd'un autre utilisateur → 400 PROMPT_NOT_FOUND - 5.4 Test: Utilisateur Free avec
prompt_id→ 403 PRO_FEATURE_REQUIRED - 5.5 Test: Priorité
prompt_idsurcustom_prompt - 5.6 Test:
custom_promptseul continue de fonctionner (rétrocompatibilité)
- 5.1 Test: Traduction avec
-
Task 6: Documentation OpenAPI (AC: Tous)
- 6.1 Documenter le paramètre
prompt_idavec description et exemple - 6.2 Documenter l'erreur
PROMPT_NOT_FOUND - 6.3 Documenter la priorité entre
prompt_idetcustom_prompt - 6.4 Vérifier sur
/docsque la documentation est complète
- 6.1 Documenter le paramètre
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
-
NE PAS modifier le comportement existant de
custom_prompt- Il doit continuer à fonctionner pour la rétrocompatibilité -
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)
-
NE PAS oublier la priorité -
prompt_ida priorité surcustom_promptsi les deux sont fournis -
NE PAS créer de nouvelle dependency - Utiliser le pattern existant avec
get_sync_session() -
Toujours valider UUID - Le prompt_id doit être un UUID valide avant de query la DB
-
Logger les actions - Utiliser
logger.info()pour récupération etlogger.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
PromptNotFoundErrorexception inutils/exceptions.pyfollowing the exact pattern ofGlossaryNotFoundError. Error codePROMPT_NOT_FOUNDis properly set. -
✅ Task 2 Complete: Created
services/prompt_service.pywith:get_prompt_content(prompt_id, user_id)- Retrieves prompt content from databasevalidate_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
- Added
-
✅ Task 4 Complete: Implemented priority logic in
_run_translation_job:- If
prompt_idis provided, fetch content from database - Otherwise, use
custom_promptif provided - Pass
effective_prompttobuild_full_prompt()
- If
-
✅ Task 5 Complete: Created
tests/test_prompt_translation.pywith 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 functionstests/test_prompt_translation.py- Unit tests for prompt translation
Modified Files:
utils/exceptions.py- AddedPromptNotFoundErrorexceptionroutes/translate_routes.py- Addedprompt_idparameter and integration logic