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>
445 lines
19 KiB
Markdown
445 lines
19 KiB
Markdown
# 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
|