refactor(docs): réorganiser la documentation selon principes DDD
Réorganise la documentation du projet selon les principes du Domain-Driven Design (DDD) pour améliorer la cohésion, la maintenabilité et l'alignement avec l'architecture modulaire du backend. **Structure cible:** ``` docs/domains/ ├── README.md (Context Map) ├── _shared/ (Core Domain) ├── recommendation/ (Supporting Subdomain) ├── content/ (Supporting Subdomain) ├── moderation/ (Supporting Subdomain) ├── advertising/ (Generic Subdomain) ├── premium/ (Generic Subdomain) └── monetization/ (Generic Subdomain) ``` **Changements effectués:** Phase 1: Création de l'arborescence des 7 bounded contexts Phase 2: Déplacement des règles métier (01-19) vers domains/*/rules/ Phase 3: Déplacement des diagrammes d'entités vers domains/*/entities/ Phase 4: Déplacement des diagrammes flux/états/séquences vers domains/*/ Phase 5: Création des README.md pour chaque domaine Phase 6: Déplacement des features Gherkin vers domains/*/features/ Phase 7: Création du Context Map (domains/README.md) Phase 8: Mise à jour de mkdocs.yml pour la nouvelle navigation Phase 9: Correction automatique des liens internes (script fix-markdown-links.sh) Phase 10: Nettoyage de l'ancienne structure (regles-metier/, diagrammes/, features/) **Configuration des tests:** - Makefile: godog run docs/domains/*/features/ - scripts/generate-bdd-docs.py: features_dir → docs/domains **Avantages:** ✅ Cohésion forte: toute la doc d'un domaine au même endroit ✅ Couplage faible: domaines indépendants, dépendances explicites ✅ Navigabilité améliorée: README par domaine = entrée claire ✅ Alignement code/docs: miroir de backend/internal/ ✅ Onboarding facilité: exploration domaine par domaine ✅ Tests BDD intégrés: features au plus près des règles métier Voir docs/REFACTOR-DDD.md pour le plan complet.
This commit is contained in:
45
docs/domains/recommendation/README.md
Normal file
45
docs/domains/recommendation/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Domaine : Recommendation
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le domaine **Recommendation** gère le système de recommandation de contenus basé sur la géolocalisation et les centres d'intérêt des utilisateurs. C'est un **Supporting Subdomain** clé qui différencie RoadWave des autres plateformes audio.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
- **Jauges de centres d'intérêt** : Gestion et évolution dynamique des préférences utilisateurs
|
||||
- **Algorithme de recommandation** : Scoring et classement des contenus pertinents
|
||||
- **Interactions et navigation** : Adaptation des recommandations selon le comportement utilisateur
|
||||
|
||||
## Règles métier
|
||||
|
||||
- [Centres d'intérêt et jauges](rules/centres-interet-jauges.md)
|
||||
- [Algorithme de recommandation](rules/algorithme-recommandation.md)
|
||||
- [Interactions et navigation](rules/interactions-navigation.md)
|
||||
|
||||
## Modèle de données
|
||||
|
||||
- [Diagramme entités recommandation](entities/modele-recommandation.md) - Entités : USER_INTERESTS, INTEREST_CATEGORIES
|
||||
|
||||
## Diagrammes
|
||||
|
||||
- [Séquence : Scoring et recommandation](sequences/scoring-recommandation.md) *(à créer si existant)*
|
||||
|
||||
## Tests BDD
|
||||
|
||||
- Features de recommandation *(voir Phase 6)*
|
||||
|
||||
## Ubiquitous Language
|
||||
|
||||
**Termes métier du domaine** :
|
||||
- **Interest Gauge** : Jauge de centre d'intérêt (score de 0 à 100)
|
||||
- **Interest Category** : Catégorie d'intérêt (automobile, voyage, musique, etc.)
|
||||
- **Recommendation Score** : Score combinant distance géographique et affinité d'intérêt
|
||||
- **Content Scoring** : Algorithme de calcul du score de pertinence
|
||||
- **Geographic Priority** : Priorisation GPS > Ville > Département > Région > Pays
|
||||
- **Interest Decay** : Diminution progressive de la jauge sans interaction
|
||||
|
||||
## Dépendances
|
||||
|
||||
- ✅ Dépend de : `_shared` (users, contents)
|
||||
- ✅ Dépend de : `content` (métadonnées de contenus)
|
||||
- ⚠️ Utilisé par : interface mobile, API publique
|
||||
@@ -0,0 +1,54 @@
|
||||
# Modèle de données - Recommandation
|
||||
|
||||
📖 Voir [Règles métier - Section 03 : Centres d'intérêt](../rules/centres-interet-jauges.md) | [Section 04 : Algorithme](../rules/algorithme-recommandation.md) | [Entités globales](../../_shared/entities/modele-global.md)
|
||||
|
||||
## Diagramme
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USER_INTERESTS }o--|| USERS : "préférences"
|
||||
USER_INTERESTS }o--|| INTEREST_CATEGORIES : "catégorie"
|
||||
|
||||
LISTENING_HISTORY }o--|| USERS : "historique"
|
||||
LISTENING_HISTORY }o--|| CONTENTS : "écoute"
|
||||
LISTENING_HISTORY }o--|| USERS : "créateur"
|
||||
|
||||
INTEREST_CATEGORIES {
|
||||
uuid id PK
|
||||
string name UK
|
||||
string slug UK
|
||||
string icon
|
||||
int sort_order
|
||||
boolean is_active
|
||||
}
|
||||
|
||||
USER_INTERESTS {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
uuid category_id FK
|
||||
decimal gauge_value
|
||||
timestamp last_updated
|
||||
int update_count
|
||||
}
|
||||
|
||||
LISTENING_HISTORY {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
uuid content_id FK
|
||||
uuid creator_id FK
|
||||
boolean is_subscribed
|
||||
decimal completion_rate
|
||||
int last_position_seconds
|
||||
string source
|
||||
boolean auto_like
|
||||
timestamp listened_at
|
||||
}
|
||||
```
|
||||
|
||||
## Légende
|
||||
|
||||
**Entités recommandation** :
|
||||
|
||||
- **INTEREST_CATEGORIES** : Catégories centres d'intérêt - Liste : Automobile, Voyage, Famille, Amour, Musique, Économie, Cryptomonnaie, Politique, Culture, Sport, Technologie, Santé - Extensible dynamiquement
|
||||
- **USER_INTERESTS** : Jauges utilisateur par catégorie - Valeur 0-100% (init 50% à l'inscription) - Évolution : Like auto renforcé (+2%), Like auto standard (+1%), Like manuel (+2%), Abonnement créateur (+5% sur tous ses tags), Skip rapide non-abonné (-0.5%) - Calcul temps réel à chaque action - Pas de dégradation temporelle automatique
|
||||
- **LISTENING_HISTORY** : Historique écoutes - Source : `recommendation`, `search`, `direct_link`, `profile`, `history`, `live_notification`, `audio_guide` - Completion_rate : 0.0-1.0 (≥0.8 = écoute complète) - Auto_like : true si like automatique déclenché (≥30% écoute) - Is_subscribed : snapshot au moment de l'écoute (pour calcul engagement)
|
||||
@@ -0,0 +1,296 @@
|
||||
# language: fr
|
||||
Fonctionnalité: API - Pas de dégradation temporelle
|
||||
En tant qu'API backend
|
||||
Je veux que les jauges n'évoluent que par les actions utilisateur
|
||||
Afin d'avoir un comportement prévisible sans automatisme caché
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
Et que la base de données PostgreSQL est accessible
|
||||
|
||||
Scénario: API ne dégrade jamais les jauges automatiquement
|
||||
Étant donné qu'un utilisateur "user123" a une jauge "Économie" à 80% en base
|
||||
Et que la colonne updated_at = "2026-01-01T10:00:00Z"
|
||||
Quand 30 jours s'écoulent sans activité
|
||||
Et je GET /api/v1/users/user123/interest-gauges le "2026-02-01T10:00:00Z"
|
||||
Alors le statut de réponse est 200
|
||||
Et la jauge "Économie" est toujours à 80%
|
||||
Et aucune dégradation temporelle n'a été appliquée
|
||||
Et la colonne updated_at n'a pas changé
|
||||
|
||||
Scénario: Aucun cron job de dégradation n'existe
|
||||
Étant donné que le système vérifie les tâches cron planifiées
|
||||
Quand je liste tous les cron jobs du backend
|
||||
Alors aucun job nommé "degrade_interest_gauges" n'existe
|
||||
Et aucun job périodique ne modifie la table interest_gauges
|
||||
Et aucune ressource CPU n'est consommée pour la dégradation
|
||||
|
||||
Scénario: API GET jauges après 6 mois d'inactivité
|
||||
Étant donné qu'un utilisateur "user123" a les jauges suivantes:
|
||||
| catégorie | niveau | updated_at |
|
||||
| Automobile | 75% | 2025-08-01T10:00:00 |
|
||||
| Voyage | 60% | 2025-08-01T10:00:00 |
|
||||
| Musique | 45% | 2025-08-01T10:00:00 |
|
||||
Et qu'il ne se connecte pas pendant 6 mois
|
||||
Quand je GET /api/v1/users/user123/interest-gauges le "2026-02-01T10:00:00Z"
|
||||
Alors le statut de réponse est 200
|
||||
Et les jauges sont exactement les mêmes:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 75% |
|
||||
| Voyage | 60% |
|
||||
| Musique | 45% |
|
||||
Et aucune modification n'a été appliquée
|
||||
|
||||
Scénario: Évolution par actions utilisateur uniquement
|
||||
Étant donné qu'un utilisateur "user123" a une jauge "Économie" à 80%
|
||||
Et qu'il skip 50 contenus "Économie" en 1 an
|
||||
Quand je calcule l'évolution via les events
|
||||
Alors la jauge "Économie" descend via les skips:
|
||||
| action | impact | nouveau_niveau |
|
||||
| 50 skips × -0.5%| -25% | 55% |
|
||||
Et la dégradation vient des actions, pas du temps
|
||||
Et la colonne updated_at reflète la date du dernier skip
|
||||
|
||||
Scénario: API POST réinitialiser centres d'intérêt
|
||||
Étant donné qu'un utilisateur "user123" a des jauges personnalisées:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 75% |
|
||||
| Voyage | 60% |
|
||||
| Économie | 34% |
|
||||
| Sport | 88% |
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/reset
|
||||
"""json
|
||||
{
|
||||
"confirmation": true
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"message": "Vos centres d'intérêt ont été réinitialisés",
|
||||
"previous_gauges_saved": true,
|
||||
"new_gauges": {
|
||||
"all_categories": 50
|
||||
}
|
||||
}
|
||||
"""
|
||||
Et en base de données, toutes les jauges de "user123" sont à 50%:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 50 |
|
||||
| Voyage | 50 |
|
||||
| Économie | 50 |
|
||||
| Sport | 50 |
|
||||
| Musique | 50 |
|
||||
| Technologie | 50 |
|
||||
| Santé | 50 |
|
||||
| Politique | 50 |
|
||||
| Cryptomonnaie | 50 |
|
||||
| Culture générale | 50 |
|
||||
| Famille | 50 |
|
||||
| Amour | 50 |
|
||||
|
||||
Scénario: API sauvegarde jauges précédentes avant réinitialisation
|
||||
Étant donné qu'un utilisateur "user123" a des jauges personnalisées
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/reset
|
||||
"""json
|
||||
{
|
||||
"confirmation": true
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 200
|
||||
Et une ligne est insérée dans interest_gauges_snapshots:
|
||||
| user_id | snapshot_type | snapshot_date | gauges_json |
|
||||
| user123 | manual_reset | 2026-02-02T14:00:00 | {"Automobile": 75, "Voyage": 60, ...} |
|
||||
Et l'historique permet de restaurer si besoin
|
||||
|
||||
Scénario: API rejette réinitialisation sans confirmation
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/reset
|
||||
"""json
|
||||
{
|
||||
"confirmation": false
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 400
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"error": "CONFIRMATION_REQUIRED",
|
||||
"message": "Vous devez confirmer la réinitialisation"
|
||||
}
|
||||
"""
|
||||
Et les jauges ne sont pas modifiées
|
||||
|
||||
Scénario: API recommandations après réinitialisation
|
||||
Étant donné qu'un utilisateur "user123" avait "Économie" à 85%
|
||||
Et qu'il réinitialise ses jauges (toutes à 50%)
|
||||
Quand je POST /api/v1/recommendations
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"limit": 10
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 200
|
||||
Et les recommandations utilisent 50% pour toutes les catégories
|
||||
Et plus aucun biais "Économie" n'est appliqué
|
||||
Et la géolocalisation redevient le critère principal
|
||||
|
||||
Scénario: Historique d'écoute conservé après réinitialisation
|
||||
Étant donné qu'un utilisateur "user123" a écouté 500 contenus
|
||||
Et que la table listening_history contient 500 lignes pour "user123"
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/reset
|
||||
Alors le statut de réponse est 200
|
||||
Et la table listening_history conserve toujours les 500 lignes
|
||||
Et aucune donnée d'historique n'est supprimée
|
||||
Et l'utilisateur peut toujours consulter ses anciens contenus écoutés
|
||||
|
||||
Scénario: API GET historique après réinitialisation
|
||||
Étant donné qu'un utilisateur "user123" a réinitialisé ses jauges
|
||||
Quand je GET /api/v1/users/user123/listening-history
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient tous les anciens contenus écoutés
|
||||
Et l'historique est intact
|
||||
|
||||
Scénario: API enregistre timestamp réinitialisation
|
||||
Étant donné qu'un utilisateur "user123" réinitialise ses jauges
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/reset
|
||||
Alors en base de données, la table users est mise à jour:
|
||||
| user_id | interest_gauges_reset_at | reset_count |
|
||||
| user123 | 2026-02-02T14:00:00Z | 1 |
|
||||
Et un compteur permet de tracker les réinitialisations multiples
|
||||
|
||||
Scénario: API permet réinitialisations multiples
|
||||
Étant donné qu'un utilisateur "user123" a déjà réinitialisé une fois
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/reset une 2ème fois
|
||||
Alors le statut de réponse est 200
|
||||
Et le reset_count passe à 2
|
||||
Et toutes les jauges reviennent à 50%
|
||||
|
||||
Scénario: API n'envoie jamais de suggestion de réinitialisation
|
||||
Étant donné qu'un utilisateur "user123" n'a pas utilisé l'app depuis 1 an
|
||||
Quand je GET /api/v1/users/user123/notifications
|
||||
Alors le statut de réponse est 200
|
||||
Et aucune notification "Réinitialiser vos centres d'intérêt" n'est présente
|
||||
Et le système ne suggère jamais de réinitialisation automatique
|
||||
|
||||
Scénario: API GET statistiques respect historique utilisateur
|
||||
Étant donné qu'un utilisateur "user123" aime "Cryptomonnaie" depuis 2 ans
|
||||
Et que sa jauge est à 90%
|
||||
Et qu'il n'a pas écouté de contenu "Cryptomonnaie" depuis 6 mois
|
||||
Quand je GET /api/v1/users/user123/interest-gauges
|
||||
Alors le statut de réponse est 200
|
||||
Et la jauge "Cryptomonnaie" est toujours à 90%
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"gauges": [
|
||||
{
|
||||
"category": "Cryptomonnaie",
|
||||
"level": 90,
|
||||
"last_updated": "2025-08-02T10:00:00Z",
|
||||
"days_since_update": 183,
|
||||
"preserved": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
Et le système respecte l'historique des goûts
|
||||
|
||||
Scénario: API métrique temps écoulé sans modifier la jauge
|
||||
Étant donné qu'un utilisateur "user123" a une jauge "Sport" à 65%
|
||||
Et que la dernière modification date de 90 jours
|
||||
Quand je GET /api/v1/users/user123/interest-gauges/sport
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"category": "Sport",
|
||||
"level": 65,
|
||||
"last_updated": "2025-11-03T12:00:00Z",
|
||||
"days_since_update": 90
|
||||
}
|
||||
"""
|
||||
Et la métrique days_since_update est informative uniquement
|
||||
Et elle ne modifie jamais la jauge
|
||||
|
||||
Scénario: Requête SQL n'utilise jamais de calcul temporel
|
||||
Étant donné que je trace les requêtes SQL du backend
|
||||
Quand je GET /api/v1/users/user123/interest-gauges
|
||||
Alors la requête SQL exécutée est:
|
||||
"""sql
|
||||
SELECT category, level, updated_at
|
||||
FROM interest_gauges
|
||||
WHERE user_id = $1
|
||||
"""
|
||||
Et aucune clause WHERE avec date/timestamp n'est présente
|
||||
Et aucune fonction NOW(), CURRENT_TIMESTAMP, ou DATEDIFF n'est utilisée
|
||||
Et le calcul est minimal (simple SELECT)
|
||||
|
||||
Scénario: API coût CPU minimal - pas de calcul de dates
|
||||
Étant donné que 10000 utilisateurs consultent leurs jauges simultanément
|
||||
Quand les requêtes /api/v1/users/{id}/interest-gauges sont exécutées
|
||||
Alors aucun calcul de date n'est nécessaire
|
||||
Et aucun appel à time.Now() ou time.Since() n'est fait
|
||||
Et le coût CPU par requête est < 1ms
|
||||
Et aucune dégradation de performance liée aux dates
|
||||
|
||||
Scénario: API pas de risque de bug fuseau horaire
|
||||
Étant donné qu'aucune logique temporelle n'existe
|
||||
Quand un utilisateur change de fuseau horaire (Paris → Tokyo)
|
||||
Alors ses jauges ne sont pas affectées
|
||||
Et aucun bug de conversion UTC/local ne peut survenir
|
||||
Et le comportement reste déterministe
|
||||
|
||||
Scénario: Audit log réinitialisation manuelle
|
||||
Étant donné qu'un utilisateur "user123" réinitialise ses jauges
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/reset
|
||||
Alors une ligne est insérée dans audit_log:
|
||||
| user_id | action | timestamp | details |
|
||||
| user123 | interest_gauges_reset | 2026-02-02T14:00:00 | {"previous_snapshot_id": 42} |
|
||||
Et l'audit permet de tracer toutes les réinitialisations
|
||||
|
||||
Scénario: API empêche réinitialisation trop fréquente
|
||||
Étant donné qu'un utilisateur "user123" a réinitialisé il y a 10 minutes
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/reset
|
||||
Alors le statut de réponse est 429
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"error": "RATE_LIMIT_EXCEEDED",
|
||||
"message": "Vous ne pouvez réinitialiser qu'une fois par heure",
|
||||
"retry_after_seconds": 3000
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API documentation endpoints réinitialisation
|
||||
Quand je GET /api/v1/openapi.json
|
||||
Alors le endpoint POST /api/v1/users/{id}/interest-gauges/reset est documenté:
|
||||
"""yaml
|
||||
/users/{id}/interest-gauges/reset:
|
||||
post:
|
||||
summary: Réinitialise toutes les jauges à 50%
|
||||
description: |
|
||||
Remet toutes les jauges d'intérêt à leur valeur par défaut (50%).
|
||||
Cette action est manuelle et requiert une confirmation.
|
||||
Les jauges précédentes sont sauvegardées.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
confirmation:
|
||||
type: boolean
|
||||
description: Doit être true
|
||||
responses:
|
||||
200:
|
||||
description: Réinitialisation réussie
|
||||
400:
|
||||
description: Confirmation manquante
|
||||
429:
|
||||
description: Trop de réinitialisations
|
||||
"""
|
||||
@@ -0,0 +1,567 @@
|
||||
# language: fr
|
||||
Fonctionnalité: API - Évolution des jauges d'intérêt
|
||||
En tant qu'API backend
|
||||
Je veux calculer et persister les évolutions de jauges d'intérêt
|
||||
Afin d'alimenter l'algorithme de recommandation
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
Et que la base de données PostgreSQL est accessible
|
||||
Et qu'un utilisateur "user123" existe avec token JWT valide
|
||||
|
||||
Scénario: API calcule like automatique renforcé (≥80% écoute)
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Automobile" à 45% en base
|
||||
Et qu'un contenu "content456" de 5 minutes est tagué "Automobile"
|
||||
Quand je POST /api/v1/listening-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content456",
|
||||
"listened_duration_seconds": 270,
|
||||
"total_duration_seconds": 300,
|
||||
"completion_percentage": 90
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"like_type": "automatic_reinforced",
|
||||
"gauge_updates": [
|
||||
{
|
||||
"category": "Automobile",
|
||||
"previous_value": 45,
|
||||
"delta": 2,
|
||||
"new_value": 47
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
Et en base de données, la jauge "Automobile" de "user123" est à 47%
|
||||
|
||||
Scénario: API calcule like automatique standard (30-79% écoute)
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Voyage" à 60% en base
|
||||
Et qu'un contenu "content789" de 10 minutes est tagué "Voyage"
|
||||
Quand je POST /api/v1/listening-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content789",
|
||||
"listened_duration_seconds": 300,
|
||||
"total_duration_seconds": 600,
|
||||
"completion_percentage": 50
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"like_type": "automatic_standard",
|
||||
"gauge_updates": [
|
||||
{
|
||||
"category": "Voyage",
|
||||
"previous_value": 60,
|
||||
"delta": 1,
|
||||
"new_value": 61
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
Et en base de données, la jauge "Voyage" de "user123" est à 61%
|
||||
|
||||
Scénario: API applique like manuel explicite (+2%)
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Musique" à 55% en base
|
||||
Et qu'un contenu "content999" est tagué "Musique"
|
||||
Quand je POST /api/v1/likes
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content999",
|
||||
"like_type": "manual"
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"like_id": "<uuid>",
|
||||
"gauge_updates": [
|
||||
{
|
||||
"category": "Musique",
|
||||
"previous_value": 55,
|
||||
"delta": 2,
|
||||
"new_value": 57
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
Et en base de données, la jauge "Musique" de "user123" est à 57%
|
||||
|
||||
Scénario: API applique unlike (retire like manuel)
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Sport" à 57% en base
|
||||
Et qu'il a liké manuellement le contenu "content888" tagué "Sport"
|
||||
Et que le like a l'ID "like_abc123"
|
||||
Quand je DELETE /api/v1/likes/like_abc123
|
||||
Alors le statut de réponse est 204
|
||||
Et en base de données, la jauge "Sport" de "user123" est à 55%
|
||||
Et le like "like_abc123" est supprimé de la table likes
|
||||
|
||||
Scénario: API refuse unlike d'un like automatique
|
||||
Étant donné que l'utilisateur "user123" a écouté un contenu à 90%
|
||||
Et qu'il a reçu un like automatique "like_auto456"
|
||||
Quand je DELETE /api/v1/likes/like_auto456
|
||||
Alors le statut de réponse est 403
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"error": "CANNOT_UNLIKE_AUTOMATIC",
|
||||
"message": "Les likes automatiques ne peuvent pas être retirés"
|
||||
}
|
||||
"""
|
||||
Et en base de données, le like "like_auto456" existe toujours
|
||||
|
||||
Scénario: API cumule like automatique + like manuel
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Technologie" à 50% en base
|
||||
Et qu'un contenu "content777" de 10 minutes est tagué "Technologie"
|
||||
Quand je POST /api/v1/listening-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content777",
|
||||
"completion_percentage": 50
|
||||
}
|
||||
"""
|
||||
Alors la jauge "Technologie" passe à 51% (+1% auto)
|
||||
Quand je POST ensuite /api/v1/likes
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content777",
|
||||
"like_type": "manual"
|
||||
}
|
||||
"""
|
||||
Alors la jauge "Technologie" passe à 53% (+2% manuel)
|
||||
Et le delta total est de 3%
|
||||
|
||||
Scénario: API applique bonus abonnement créateur (+5% tous tags)
|
||||
Étant donné que l'utilisateur "user123" a les jauges suivantes:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 50% |
|
||||
| Technologie | 45% |
|
||||
Et qu'un créateur "creator456" publie des contenus tagués "Automobile" et "Technologie"
|
||||
Quand je POST /api/v1/subscriptions
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"creator_id": "creator456"
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et en base de données:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 55% |
|
||||
| Technologie | 50% |
|
||||
Et l'abonnement est créé avec bonus appliqué
|
||||
|
||||
Scénario: API retire bonus désabonnement créateur (-5% tous tags)
|
||||
Étant donné que l'utilisateur "user123" a les jauges suivantes:
|
||||
| catégorie | niveau |
|
||||
| Voyage | 65% |
|
||||
| Culture | 58% |
|
||||
Et qu'il est abonné au créateur "creator789" qui publie "Voyage" et "Culture"
|
||||
Quand je DELETE /api/v1/subscriptions/creator789
|
||||
Alors le statut de réponse est 204
|
||||
Et en base de données:
|
||||
| catégorie | niveau |
|
||||
| Voyage | 60% |
|
||||
| Culture | 53% |
|
||||
|
||||
Scénario: API applique pénalité skip rapide (<10s)
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Politique" à 50% en base
|
||||
Et qu'un contenu "content555" de 300 secondes est tagué "Politique"
|
||||
Quand je POST /api/v1/skip-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content555",
|
||||
"listened_duration_seconds": 5,
|
||||
"total_duration_seconds": 300
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"skip_type": "early",
|
||||
"gauge_updates": [
|
||||
{
|
||||
"category": "Politique",
|
||||
"previous_value": 50,
|
||||
"delta": -0.5,
|
||||
"new_value": 49.5
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
Et en base de données, la jauge "Politique" de "user123" est à 49.5%
|
||||
|
||||
Scénario: API n'applique pas de pénalité pour skip ≥10s et <30%
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Économie" à 60% en base
|
||||
Et qu'un contenu "content333" de 600 secondes est tagué "Économie"
|
||||
Quand je POST /api/v1/skip-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content333",
|
||||
"listened_duration_seconds": 120,
|
||||
"total_duration_seconds": 600,
|
||||
"completion_percentage": 20
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"skip_type": "neutral",
|
||||
"gauge_updates": []
|
||||
}
|
||||
"""
|
||||
Et en base de données, la jauge "Économie" de "user123" reste à 60%
|
||||
|
||||
Scénario: API n'applique pas de pénalité pour skip ≥30%
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Sport" à 55% en base
|
||||
Et qu'un contenu "content222" de 600 secondes est tagué "Sport"
|
||||
Quand je POST /api/v1/skip-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content222",
|
||||
"listened_duration_seconds": 300,
|
||||
"total_duration_seconds": 600,
|
||||
"completion_percentage": 50
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"skip_type": "late",
|
||||
"gauge_updates": []
|
||||
}
|
||||
"""
|
||||
Et en base de données, la jauge "Sport" de "user123" reste à 55%
|
||||
|
||||
Scénario: API applique évolution sur plusieurs tags simultanément
|
||||
Étant donné que l'utilisateur "user123" a les jauges suivantes:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 45% |
|
||||
| Voyage | 60% |
|
||||
Et qu'un contenu "content111" est tagué "Automobile" et "Voyage"
|
||||
Quand je POST /api/v1/listening-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content111",
|
||||
"completion_percentage": 90
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"like_type": "automatic_reinforced",
|
||||
"gauge_updates": [
|
||||
{
|
||||
"category": "Automobile",
|
||||
"previous_value": 45,
|
||||
"delta": 2,
|
||||
"new_value": 47
|
||||
},
|
||||
{
|
||||
"category": "Voyage",
|
||||
"previous_value": 60,
|
||||
"delta": 2,
|
||||
"new_value": 62
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
Et en base de données:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 47% |
|
||||
| Voyage | 62% |
|
||||
|
||||
Scénario: API respecte la borne maximum 100%
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Cryptomonnaie" à 99% en base
|
||||
Et qu'un contenu "content_crypto" est tagué "Cryptomonnaie"
|
||||
Quand je POST /api/v1/listening-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content_crypto",
|
||||
"completion_percentage": 95
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"like_type": "automatic_reinforced",
|
||||
"gauge_updates": [
|
||||
{
|
||||
"category": "Cryptomonnaie",
|
||||
"previous_value": 99,
|
||||
"delta": 2,
|
||||
"new_value": 100,
|
||||
"capped": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
Et en base de données, la jauge "Cryptomonnaie" de "user123" est à 100%
|
||||
Et la jauge n'a pas dépassé 100%
|
||||
|
||||
Scénario: API respecte la borne minimum 0%
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Politique" à 0.3% en base
|
||||
Et qu'un contenu "content_pol" est tagué "Politique"
|
||||
Quand je POST /api/v1/skip-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content_pol",
|
||||
"listened_duration_seconds": 3,
|
||||
"total_duration_seconds": 300
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"skip_type": "early",
|
||||
"gauge_updates": [
|
||||
{
|
||||
"category": "Politique",
|
||||
"previous_value": 0.3,
|
||||
"delta": -0.5,
|
||||
"new_value": 0,
|
||||
"capped": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
Et en base de données, la jauge "Politique" de "user123" est à 0%
|
||||
Et la jauge n'est pas devenue négative
|
||||
|
||||
Scénario: API respecte borne minimum lors désabonnement
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Économie" à 3% en base
|
||||
Et qu'il est abonné au créateur "creator_eco" qui publie "Économie"
|
||||
Quand je DELETE /api/v1/subscriptions/creator_eco
|
||||
Alors le statut de réponse est 204
|
||||
Et en base de données, la jauge "Économie" de "user123" est à 0% (et non -2%)
|
||||
|
||||
Scénario: API GET retourne toutes les jauges utilisateur
|
||||
Étant donné que l'utilisateur "user123" a les jauges suivantes en base:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 67% |
|
||||
| Voyage | 82% |
|
||||
| Économie | 34% |
|
||||
| Sport | 50% |
|
||||
| Musique | 45% |
|
||||
| Technologie | 71% |
|
||||
Quand je GET /api/v1/users/user123/interest-gauges
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient les 12 catégories avec leurs niveaux:
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"gauges": [
|
||||
{"category": "Automobile", "level": 67},
|
||||
{"category": "Voyage", "level": 82},
|
||||
{"category": "Économie", "level": 34},
|
||||
{"category": "Sport", "level": 50},
|
||||
{"category": "Musique", "level": 45},
|
||||
{"category": "Technologie", "level": 71}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API calcule évolution immédiate (pas de batch différé)
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Voyage" à 50% en base
|
||||
Quand je POST /api/v1/listening-events à 12:00:00
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content_travel",
|
||||
"completion_percentage": 85
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Quand je GET /api/v1/users/user123/interest-gauges à 12:00:01 (1 seconde après)
|
||||
Alors la jauge "Voyage" est à 52%
|
||||
Et la mise à jour est visible immédiatement
|
||||
|
||||
Scénario: API rejette token JWT invalide
|
||||
Quand je POST /api/v1/listening-events sans token JWT
|
||||
Alors le statut de réponse est 401
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "Token JWT manquant ou invalide"
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API valide format des données d'entrée
|
||||
Quand je POST /api/v1/listening-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content456",
|
||||
"completion_percentage": 150
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 400
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": "completion_percentage doit être entre 0 et 100"
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API gère contenu avec tags inexistants en base
|
||||
Étant donné qu'un contenu "content_new" est tagué "NouvelleCategorie" (non encore en base)
|
||||
Quand je POST /api/v1/listening-events
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"content_id": "content_new",
|
||||
"completion_percentage": 90
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et une nouvelle ligne est créée dans la table interest_gauges:
|
||||
| user_id | category | level |
|
||||
| user123 | NouvelleCategorie | 52 |
|
||||
Et l'initialisation démarre à 50% + 2% de like auto = 52%
|
||||
|
||||
Scénario: API persiste historique des modifications de jauges
|
||||
Étant donné que l'utilisateur "user123" a une jauge "Sport" à 50%
|
||||
Quand je POST /api/v1/listening-events qui applique +2%
|
||||
Alors une ligne est insérée dans interest_gauge_history:
|
||||
| user_id | category | previous_value | delta | new_value | event_type | event_id | timestamp |
|
||||
| user123 | Sport | 50 | 2 | 52 | listening_event| <event_uuid> | 2026-02-02T12:00:00 |
|
||||
Et cet historique permet d'auditer les évolutions
|
||||
|
||||
Scénario: API retourne métriques d'évolution utilisateur
|
||||
Étant donné que l'utilisateur "user123" a un historique d'évolution en base
|
||||
Quand je GET /api/v1/users/user123/interest-gauges/evolution?since=7d
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"period": "7d",
|
||||
"evolution": [
|
||||
{
|
||||
"category": "Automobile",
|
||||
"start_value": 60,
|
||||
"end_value": 67,
|
||||
"delta": 7,
|
||||
"events_count": 15
|
||||
},
|
||||
{
|
||||
"category": "Voyage",
|
||||
"start_value": 80,
|
||||
"end_value": 82,
|
||||
"delta": 2,
|
||||
"events_count": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
# Architecture backend - Services séparés
|
||||
|
||||
Scénario: Gauge Calculation Service calcule l'ajustement (stateless)
|
||||
Étant donné un événement d'écoute avec 85% de complétion
|
||||
Quand le Gauge Calculation Service calcule l'ajustement
|
||||
Alors le service retourne:
|
||||
"""json
|
||||
{
|
||||
"adjustment_type": "automatic_reinforced",
|
||||
"adjustment_value": 2.0,
|
||||
"reason": "completion_percentage >= 80%"
|
||||
}
|
||||
"""
|
||||
Et le service est stateless (aucune lecture DB)
|
||||
Et le service est testable unitairement
|
||||
|
||||
Scénario: Gauge Update Service applique l'ajustement (stateful)
|
||||
Étant donné qu'un ajustement de +2% a été calculé
|
||||
Et que la jauge "Automobile" de "user123" est à 45%
|
||||
Quand le Gauge Update Service applique l'ajustement
|
||||
Alors la nouvelle valeur est 47% (45 + 2)
|
||||
Et la borne [0, 100] est respectée via fonction clamp
|
||||
Et la mise à jour est persistée en Redis (immédiat)
|
||||
Et la mise à jour est persistée en PostgreSQL (batch async)
|
||||
|
||||
Scénario: Pattern de calcul - Addition de points absolus
|
||||
Étant donné qu'une jauge est à 60%
|
||||
Et qu'un ajustement de +2% est calculé
|
||||
Quand le calcul est effectué
|
||||
Alors la formule est: newValue = currentValue + adjustment
|
||||
Et la formule est: newValue = clamp(newValue, 0.0, 100.0)
|
||||
Et la nouvelle valeur est 62%
|
||||
|
||||
Scénario: Pattern de calcul - Éviter multiplication relative
|
||||
Étant donné qu'une jauge est à 50%
|
||||
Et qu'un ajustement de +2% est calculé
|
||||
Quand le calcul est effectué
|
||||
Alors la formule utilisée est: 50 + 2 = 52
|
||||
Et la formule utilisée n'est PAS: 50 * (1 + 2/100) = 51
|
||||
Car l'ajustement est en points absolus, pas relatifs
|
||||
|
||||
Scénario: Multi-tags - Mise à jour de N jauges simultanément
|
||||
Étant donné qu'un contenu a 3 tags: ["Automobile", "Voyage", "Technologie"]
|
||||
Et qu'un ajustement de +2% est calculé (écoute 85%)
|
||||
Quand le Gauge Update Service applique
|
||||
Alors 3 jauges sont mises à jour:
|
||||
| catégorie | ajustement |
|
||||
| Automobile | +2% |
|
||||
| Voyage | +2% |
|
||||
| Technologie | +2% |
|
||||
Et toutes les mises à jour sont effectuées en une seule transaction
|
||||
|
||||
Scénario: Persistance Redis immédiate (latence <10ms)
|
||||
Étant donné qu'un ajustement doit être persisté
|
||||
Quand le Gauge Update Service écrit en Redis
|
||||
Alors la latence est < 10ms
|
||||
Et la jauge est immédiatement disponible pour recommandations
|
||||
Et Redis sert de cache haute performance
|
||||
|
||||
Scénario: Persistance PostgreSQL asynchrone (batch 5 min)
|
||||
Étant donné que 100 ajustements ont été appliqués en Redis
|
||||
Quand le batch async s'exécute toutes les 5 minutes
|
||||
Alors les 100 ajustements sont écrits en PostgreSQL en batch
|
||||
Et la cohérence finale est garantie
|
||||
Et l'application reste performante (pas de write sync)
|
||||
|
||||
Scénario: Séparation responsabilités - Calculation vs Update
|
||||
Étant donné un événement d'écoute
|
||||
Quand il est traité
|
||||
Alors le Gauge Calculation Service calcule l'ajustement (logique métier pure)
|
||||
Et le Gauge Update Service applique l'ajustement (persistance)
|
||||
Et les deux services sont indépendants
|
||||
Et chaque service a une responsabilité unique (SRP)
|
||||
|
||||
Scénario: Réutilisabilité Calculation Service
|
||||
Étant donné le Gauge Calculation Service
|
||||
Quand il est utilisé pour:
|
||||
| contexte |
|
||||
| Like automatique |
|
||||
| Skip rapide |
|
||||
| Like manuel |
|
||||
| Abonnement créateur |
|
||||
Alors le même service calcule tous les ajustements
|
||||
Et la logique métier est centralisée
|
||||
Et il n'y a pas de duplication de code
|
||||
@@ -0,0 +1,337 @@
|
||||
# language: fr
|
||||
Fonctionnalité: API - Jauge initiale et cold start
|
||||
En tant qu'API backend
|
||||
Je veux initialiser toutes les jauges à 50% lors de l'inscription
|
||||
Afin de garantir un démarrage neutre et équitable
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
Et que la base de données PostgreSQL est accessible
|
||||
|
||||
Scénario: API initialise toutes les jauges à 50% lors inscription
|
||||
Quand je POST /api/v1/auth/register
|
||||
"""json
|
||||
{
|
||||
"email": "nouveau@example.com",
|
||||
"password": "SecureP@ss123",
|
||||
"birth_date": "1990-01-15",
|
||||
"username": "nouveau_user"
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"user_id": "<uuid>",
|
||||
"email": "nouveau@example.com",
|
||||
"username": "nouveau_user"
|
||||
}
|
||||
"""
|
||||
Et en base de données, la table interest_gauges contient 12 lignes pour ce user_id:
|
||||
| category | level |
|
||||
| Automobile | 50 |
|
||||
| Voyage | 50 |
|
||||
| Famille | 50 |
|
||||
| Amour | 50 |
|
||||
| Musique | 50 |
|
||||
| Économie | 50 |
|
||||
| Cryptomonnaie | 50 |
|
||||
| Politique | 50 |
|
||||
| Culture générale | 50 |
|
||||
| Sport | 50 |
|
||||
| Technologie | 50 |
|
||||
| Santé | 50 |
|
||||
|
||||
Scénario: API retourne les 12 catégories disponibles
|
||||
Quand je GET /api/v1/interest-gauges/categories
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"categories": [
|
||||
{"id": "automobile", "name": "Automobile", "icon": "car"},
|
||||
{"id": "voyage", "name": "Voyage", "icon": "plane"},
|
||||
{"id": "famille", "name": "Famille", "icon": "users"},
|
||||
{"id": "amour", "name": "Amour", "icon": "heart"},
|
||||
{"id": "musique", "name": "Musique", "icon": "music"},
|
||||
{"id": "economie", "name": "Économie", "icon": "chart"},
|
||||
{"id": "cryptomonnaie", "name": "Cryptomonnaie", "icon": "bitcoin"},
|
||||
{"id": "politique", "name": "Politique", "icon": "landmark"},
|
||||
{"id": "culture-generale", "name": "Culture générale", "icon": "book"},
|
||||
{"id": "sport", "name": "Sport", "icon": "running"},
|
||||
{"id": "technologie", "name": "Technologie", "icon": "cpu"},
|
||||
{"id": "sante", "name": "Santé", "icon": "heart-pulse"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API GET retourne jauges utilisateur nouvellement inscrit
|
||||
Étant donné qu'un utilisateur "user_new" vient de s'inscrire
|
||||
Quand je GET /api/v1/users/user_new/interest-gauges
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient 12 jauges toutes à 50%:
|
||||
"""json
|
||||
{
|
||||
"user_id": "user_new",
|
||||
"gauges": [
|
||||
{"category": "Automobile", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Voyage", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Famille", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Amour", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Musique", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Économie", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Cryptomonnaie", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Politique", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Culture générale", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Sport", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Technologie", "level": 50, "evolution_since_signup": 0},
|
||||
{"category": "Santé", "level": 50, "evolution_since_signup": 0}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API calcule recommandations avec jauges à 50% - pas de biais
|
||||
Étant donné qu'un utilisateur "user_new" vient de s'inscrire
|
||||
Et que toutes ses jauges sont à 50%
|
||||
Et qu'il est à la position GPS (48.8566, 2.3522) - Paris
|
||||
Quand je POST /api/v1/recommendations
|
||||
"""json
|
||||
{
|
||||
"user_id": "user_new",
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"limit": 10
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient 10 contenus
|
||||
Et le scoring est basé uniquement sur:
|
||||
| critère | poids |
|
||||
| Distance géographique| 100% |
|
||||
| Intérêts (50% égal) | 0% |
|
||||
Et aucune catégorie n'a d'avantage initial
|
||||
|
||||
Scénario: API permet ajout de nouvelles catégories
|
||||
Étant donné qu'un admin ajoute une nouvelle catégorie "Gastronomie"
|
||||
Quand je POST /api/v1/admin/interest-gauges/categories
|
||||
"""json
|
||||
{
|
||||
"id": "gastronomie",
|
||||
"name": "Gastronomie",
|
||||
"icon": "utensils"
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 201
|
||||
Et pour tous les utilisateurs existants:
|
||||
| action |
|
||||
| Une ligne est créée dans interest_gauges |
|
||||
| category = "Gastronomie" |
|
||||
| level = 50 |
|
||||
Et les nouveaux utilisateurs auront aussi cette catégorie à 50%
|
||||
|
||||
Scénario: API calcule évolution depuis inscription
|
||||
Étant donné qu'un utilisateur "user123" s'est inscrit il y a 30 jours
|
||||
Et qu'il a les jauges suivantes en base:
|
||||
| catégorie | niveau | initial |
|
||||
| Automobile | 67% | 50% |
|
||||
| Voyage | 82% | 50% |
|
||||
| Économie | 34% | 50% |
|
||||
| Sport | 50% | 50% |
|
||||
Quand je GET /api/v1/users/user123/interest-gauges
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"user_id": "user123",
|
||||
"signup_date": "2026-01-03T10:00:00Z",
|
||||
"gauges": [
|
||||
{
|
||||
"category": "Automobile",
|
||||
"level": 67,
|
||||
"evolution_since_signup": 17
|
||||
},
|
||||
{
|
||||
"category": "Voyage",
|
||||
"level": 82,
|
||||
"evolution_since_signup": 32
|
||||
},
|
||||
{
|
||||
"category": "Économie",
|
||||
"level": 34,
|
||||
"evolution_since_signup": -16
|
||||
},
|
||||
{
|
||||
"category": "Sport",
|
||||
"level": 50,
|
||||
"evolution_since_signup": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API transaction atomique lors inscription
|
||||
Quand je POST /api/v1/auth/register
|
||||
"""json
|
||||
{
|
||||
"email": "test@example.com",
|
||||
"password": "SecureP@ss123",
|
||||
"birth_date": "1995-03-20",
|
||||
"username": "test_user"
|
||||
}
|
||||
"""
|
||||
Alors l'insertion en base de données est atomique:
|
||||
| action |
|
||||
| INSERT INTO users |
|
||||
| INSERT INTO interest_gauges (12 lignes) |
|
||||
| Tout ou rien (transaction) |
|
||||
Et si une erreur survient, aucune donnée partielle n'est créée
|
||||
|
||||
Scénario: API rollback si initialisation jauges échoue
|
||||
Étant donné que la table interest_gauges a une contrainte violée
|
||||
Quand je POST /api/v1/auth/register avec données valides
|
||||
Alors le statut de réponse est 500
|
||||
Et aucune ligne n'est créée dans la table users
|
||||
Et aucune ligne n'est créée dans la table interest_gauges
|
||||
Et la transaction est rollback complètement
|
||||
|
||||
Scénario: API POST questionnaire optionnel post-MVP
|
||||
Étant donné qu'un utilisateur "user123" a écouté 3 contenus
|
||||
Et qu'il décide de remplir le questionnaire optionnel
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/quick-setup
|
||||
"""json
|
||||
{
|
||||
"selected_categories": ["Automobile", "Voyage", "Sport"]
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 200
|
||||
Et en base de données:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 70 |
|
||||
| Voyage | 70 |
|
||||
| Sport | 70 |
|
||||
| Musique | 30 |
|
||||
| Économie | 30 |
|
||||
| Cryptomonnaie | 30 |
|
||||
| Politique | 30 |
|
||||
| Culture générale | 30 |
|
||||
| Technologie | 30 |
|
||||
| Santé | 30 |
|
||||
| Famille | 30 |
|
||||
| Amour | 30 |
|
||||
Et un flag quick_setup_completed = true est enregistré
|
||||
|
||||
Scénario: API rejette questionnaire optionnel si déjà rempli
|
||||
Étant donné qu'un utilisateur "user123" a déjà rempli le questionnaire optionnel
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/quick-setup
|
||||
"""json
|
||||
{
|
||||
"selected_categories": ["Musique", "Technologie"]
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 409
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"error": "QUICK_SETUP_ALREADY_COMPLETED",
|
||||
"message": "Le questionnaire a déjà été rempli"
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API valide nombre de catégories sélectionnées
|
||||
Quand je POST /api/v1/users/user123/interest-gauges/quick-setup
|
||||
"""json
|
||||
{
|
||||
"selected_categories": ["Automobile"]
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 400
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": "Vous devez sélectionner entre 2 et 5 catégories"
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API déterministe - deux users identiques
|
||||
Étant donné que l'utilisateur "userA" s'inscrit à 10:00:00
|
||||
Et que l'utilisateur "userB" s'inscrit à 10:00:01
|
||||
Quand je GET /api/v1/users/userA/interest-gauges
|
||||
Et je GET /api/v1/users/userB/interest-gauges
|
||||
Alors les deux réponses ont des jauges identiques (toutes à 50%)
|
||||
Et le comportement est déterministe
|
||||
|
||||
Scénario: API retourne statistiques cold start
|
||||
Étant donné qu'un utilisateur "user_new" vient de s'inscrire
|
||||
Quand je GET /api/v1/users/user_new/stats
|
||||
Alors le statut de réponse est 200
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"user_id": "user_new",
|
||||
"signup_date": "2026-02-02T14:00:00Z",
|
||||
"total_listened_content": 0,
|
||||
"gauges_summary": {
|
||||
"all_at_default": true,
|
||||
"default_value": 50,
|
||||
"total_categories": 12,
|
||||
"personalization_level": "none"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API recommandations cold start priorité géo
|
||||
Étant donné qu'un utilisateur "user_new" vient de s'inscrire
|
||||
Et qu'il est à Paris avec 100 contenus disponibles dans un rayon de 5km
|
||||
Et que ces contenus ont des catégories variées
|
||||
Quand je POST /api/v1/recommendations
|
||||
"""json
|
||||
{
|
||||
"user_id": "user_new",
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"limit": 10
|
||||
}
|
||||
"""
|
||||
Alors le statut de réponse est 200
|
||||
Et les 10 contenus retournés sont les plus proches géographiquement
|
||||
Et les catégories sont variées (pas de biais intérêts)
|
||||
Et la réponse contient:
|
||||
"""json
|
||||
{
|
||||
"recommendations": [
|
||||
{
|
||||
"content_id": "<uuid>",
|
||||
"distance_meters": 150,
|
||||
"interest_match": 50,
|
||||
"final_score": 0.95
|
||||
}
|
||||
],
|
||||
"cold_start": true,
|
||||
"personalization_level": "none"
|
||||
}
|
||||
"""
|
||||
|
||||
Scénario: API index optimisé pour lecture jauges
|
||||
Étant donné que la table interest_gauges a 1 million de lignes
|
||||
Quand je GET /api/v1/users/user123/interest-gauges
|
||||
Alors la requête SQL utilise l'index (user_id, category)
|
||||
Et le temps de réponse est < 50ms
|
||||
Et le plan d'exécution confirme l'utilisation de l'index
|
||||
|
||||
Scénario: API cache jauges utilisateur en Redis
|
||||
Étant donné qu'un utilisateur "user123" a ses jauges en base
|
||||
Quand je GET /api/v1/users/user123/interest-gauges
|
||||
Alors le backend vérifie d'abord Redis avec clé "user:user123:gauges"
|
||||
Et si absent, lit depuis PostgreSQL
|
||||
Et met en cache dans Redis avec TTL = 300 secondes
|
||||
Quand je GET à nouveau dans les 5 minutes
|
||||
Alors la réponse vient directement de Redis
|
||||
Et aucune requête PostgreSQL n'est faite
|
||||
|
||||
Scénario: API invalide cache Redis lors mise à jour jauge
|
||||
Étant donné que les jauges de "user123" sont en cache Redis
|
||||
Quand je POST /api/v1/listening-events qui modifie une jauge
|
||||
Alors le cache Redis "user:user123:gauges" est supprimé
|
||||
Et le prochain GET recharge depuis PostgreSQL
|
||||
Et remet en cache avec nouvelles valeurs
|
||||
@@ -0,0 +1,204 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Neutralisation des pénalités de skip pour abonnés
|
||||
En tant que système de jauges d'intérêt
|
||||
Je veux neutraliser les pénalités de skip pour les abonnés d'un créateur
|
||||
Afin de reconnaître l'affinité globale malgré des skips ponctuels contextuels
|
||||
|
||||
Contexte:
|
||||
Étant donné qu'un utilisateur existe avec les jauges suivantes:
|
||||
| catégorie | niveau |
|
||||
| Automobile | 45% |
|
||||
| Voyage | 60% |
|
||||
Et qu'un créateur "CreateurA" publie des contenus tagués "Automobile"
|
||||
|
||||
# Skip <10s - Utilisateur NON abonné
|
||||
|
||||
Scénario: Skip rapide <10s par non-abonné - Pénalité -0.5%
|
||||
Étant donné que l'utilisateur n'est PAS abonné à "CreateurA"
|
||||
Et qu'un contenu "Podcast Auto" de "CreateurA" est tagué "Automobile"
|
||||
Et que la jauge "Automobile" est à 45%
|
||||
Quand l'utilisateur skip le contenu après 5 secondes
|
||||
Alors la jauge "Automobile" descend de -0.5%
|
||||
Et la jauge "Automobile" passe de 45% à 44.5%
|
||||
Et cela indique un désintérêt marqué pour ce contenu
|
||||
|
||||
Scénario: Skip rapide <10s par non-abonné - Colonne is_subscribed=false
|
||||
Étant donné que l'utilisateur n'est PAS abonné à "CreateurA"
|
||||
Et qu'un contenu est skippé après 8 secondes
|
||||
Quand l'événement est enregistré dans user_listening_history
|
||||
Alors la colonne is_subscribed = false
|
||||
Et la colonne completion_rate = 0.05 (8s sur 160s)
|
||||
Et la colonne source = "recommendation"
|
||||
Et ce skip compte dans les métriques d'engagement du contenu
|
||||
|
||||
# Skip <10s - Utilisateur ABONNÉ
|
||||
|
||||
Scénario: Skip rapide <10s par abonné - Pénalité neutre 0%
|
||||
Étant donné que l'utilisateur EST abonné à "CreateurA"
|
||||
Et qu'un contenu "Podcast Auto" de "CreateurA" est tagué "Automobile"
|
||||
Et que la jauge "Automobile" est à 45%
|
||||
Quand l'utilisateur skip le contenu après 5 secondes
|
||||
Alors la jauge "Automobile" reste à 45% (pénalité 0%)
|
||||
Et aucune pénalité n'est appliquée
|
||||
Et cela reflète que l'abonnement indique une affinité globale
|
||||
|
||||
Scénario: Skip rapide <10s par abonné - Colonne is_subscribed=true
|
||||
Étant donné que l'utilisateur EST abonné à "CreateurA"
|
||||
Et qu'un contenu est skippé après 7 secondes
|
||||
Quand l'événement est enregistré dans user_listening_history
|
||||
Alors la colonne is_subscribed = true
|
||||
Et la colonne completion_rate = 0.04 (7s sur 180s)
|
||||
Et la colonne source = "recommendation"
|
||||
Et ce skip NE compte PAS dans les métriques d'engagement du contenu
|
||||
|
||||
# Calcul métriques engagement créateur
|
||||
|
||||
Scénario: Métriques engagement - Skip d'abonné ne pénalise pas
|
||||
Étant donné qu'un contenu "Podcast A" de "CreateurA" a reçu:
|
||||
| utilisateur | abonné ? | action | source | completion_rate |
|
||||
| User1 | Non | Skip <10s | recommendation | 0.05 |
|
||||
| User2 | Oui | Skip <10s | recommendation | 0.04 |
|
||||
| User3 | Non | Écoute complète | recommendation | 0.90 |
|
||||
| User4 | Oui | Skip <10s | recommendation | 0.03 |
|
||||
| User5 | Non | Écoute partielle | recommendation | 0.60 |
|
||||
Quand le système calcule les métriques d'engagement du contenu
|
||||
Alors les écoutes pertinentes comptabilisées sont:
|
||||
| utilisateur | comptabilisé ? | raison |
|
||||
| User1 | ✅ Oui | Non-abonné, source pertinente |
|
||||
| User2 | ❌ Non | Abonné, skip contextuel |
|
||||
| User3 | ✅ Oui | Non-abonné, source pertinente |
|
||||
| User4 | ❌ Non | Abonné, skip contextuel |
|
||||
| User5 | ✅ Oui | Non-abonné, source pertinente |
|
||||
Et le total écoutes pertinentes = 3 (User1, User3, User5)
|
||||
Et le taux de complétion = 2 complètes (User3, User5 >80%) / 3 = 66.7%
|
||||
|
||||
# Sources d'écoute et neutralisation
|
||||
|
||||
Scénario: Skip d'abonné via "recommendation" - Ne compte pas
|
||||
Étant donné que l'utilisateur EST abonné à "CreateurA"
|
||||
Et qu'un contenu est recommandé via l'algorithme (source="recommendation")
|
||||
Quand l'utilisateur skip après 5s
|
||||
Alors l'écoute NE compte PAS dans "total écoutes" pour métriques
|
||||
Et la pénalité jauge est neutralisée (0%)
|
||||
|
||||
Scénario: Skip d'abonné via "live_notification" - Ne compte pas
|
||||
Étant donné que l'utilisateur EST abonné à "CreateurA"
|
||||
Et que "CreateurA" publie un contenu en live
|
||||
Et qu'une notification live est envoyée (source="live_notification")
|
||||
Quand l'utilisateur skip après 6s
|
||||
Alors l'écoute NE compte PAS dans "total écoutes" pour métriques
|
||||
Et la pénalité jauge est neutralisée (0%)
|
||||
|
||||
Scénario: Skip d'abonné via "search" - Ne compte pas (indépendamment abonnement)
|
||||
Étant donné que l'utilisateur EST abonné à "CreateurA"
|
||||
Et qu'il trouve un contenu via la recherche (source="search")
|
||||
Quand l'utilisateur skip après 4s
|
||||
Alors l'écoute NE compte PAS dans "total écoutes" (source non pertinente)
|
||||
Et la pénalité jauge est neutralisée (0%)
|
||||
Et cela s'applique à tous users (abonnés ou non) pour source="search"
|
||||
|
||||
Scénario: Skip non-abonné via "direct_link" - Ne compte pas
|
||||
Étant donné que l'utilisateur N'est PAS abonné à "CreateurA"
|
||||
Et qu'il clique sur un lien direct partagé (source="direct_link")
|
||||
Quand l'utilisateur skip après 3s
|
||||
Alors l'écoute NE compte PAS dans "total écoutes" (source non pertinente)
|
||||
Mais la pénalité jauge s'applique quand même (-0.5%) pour non-abonné
|
||||
|
||||
# Reproposition
|
||||
|
||||
Scénario: Skip <10s non-abonné - Pas de reproposition
|
||||
Étant donné que l'utilisateur N'est PAS abonné à "CreateurA"
|
||||
Et qu'un contenu est skippé après 8 secondes
|
||||
Quand l'algorithme calcule les prochaines recommandations
|
||||
Alors ce contenu n'est jamais reproposé à cet utilisateur
|
||||
Car c'est un signal négatif clair de désintérêt
|
||||
|
||||
Scénario: Skip <10s abonné - Reproposition possible
|
||||
Étant donné que l'utilisateur EST abonné à "CreateurA"
|
||||
Et qu'un contenu est skippé après 7 secondes
|
||||
Quand l'algorithme calcule les prochaines recommandations plusieurs jours plus tard
|
||||
Alors ce contenu PEUT être reproposé à cet utilisateur
|
||||
Car l'abonnement indique une affinité globale
|
||||
Et le skip peut être contextuel ("pas maintenant", "pas ce sujet")
|
||||
|
||||
Scénario: Stockage is_subscribed dans user_content_history
|
||||
Étant donné qu'un utilisateur EST abonné à "CreateurA" au moment de l'écoute
|
||||
Quand un contenu de "CreateurA" est écouté/skippé
|
||||
Alors la table user_content_history enregistre:
|
||||
| colonne | valeur |
|
||||
| user_id | 123 |
|
||||
| content_id | 456 |
|
||||
| creator_id | 789 (CreateurA) |
|
||||
| is_subscribed | true |
|
||||
| completion_rate| 0.05 |
|
||||
| source | "recommendation" |
|
||||
| listened_at | 2026-02-07 10:30:00 |
|
||||
|
||||
# Cohérence UX
|
||||
|
||||
Scénario: Abonnement = Signal affinité fort malgré skip ponctuel
|
||||
Étant donné que l'utilisateur est abonné à "CreateurA" depuis 6 mois
|
||||
Et qu'il a écouté 50 contenus de "CreateurA" avec 90% de complétion moyenne
|
||||
Quand il skip 1 contenu après 5 secondes aujourd'hui
|
||||
Alors ce skip ponctuel ne pénalise pas:
|
||||
| aspect | impact |
|
||||
| Jauges d'intérêt user | 0% (neutre) |
|
||||
| Métriques engagement créateur | Ne compte pas dans total écoutes |
|
||||
| Reproposition future | Contenu peut être reproposé |
|
||||
Et cela reflète que le skip est contextuel, pas un rejet du créateur
|
||||
|
||||
# Anti-raid naturel
|
||||
|
||||
Scénario: Raid malveillant via liens directs - Inefficace
|
||||
Étant donné qu'un groupe malveillant veut nuire à "CreateurA"
|
||||
Et qu'ils partagent des liens directs pour inciter au skip massif
|
||||
Quand 1000 personnes cliquent sur le lien et skip après 2s
|
||||
Alors ces 1000 skips NE comptent PAS dans les métriques engagement
|
||||
Car source="direct_link" n'est pas une source pertinente
|
||||
Et "CreateurA" est protégé contre ce type de raid
|
||||
|
||||
Scénario: Raid malveillant via recherche - Inefficace
|
||||
Étant donné qu'un groupe cherche à nuire à "CreateurA"
|
||||
Et qu'ils trouvent le contenu via recherche et skip massivement
|
||||
Quand 500 skips rapides arrivent via source="search"
|
||||
Alors ces 500 skips NE comptent PAS dans les métriques engagement
|
||||
Car source="search" n'est pas une source pertinente
|
||||
Et "CreateurA" est protégé
|
||||
|
||||
# Cas limites
|
||||
|
||||
Scénario: Utilisateur s'abonne pendant l'écoute d'un contenu
|
||||
Étant donné qu'un utilisateur N'est PAS abonné à "CreateurA"
|
||||
Et qu'il démarre l'écoute d'un contenu de "CreateurA"
|
||||
Et que is_subscribed=false est enregistré au démarrage
|
||||
Quand l'utilisateur s'abonne à "CreateurA" pendant l'écoute
|
||||
Et qu'il skip le contenu après 8 secondes
|
||||
Alors is_subscribed=false reste enregistré (état au moment du démarrage)
|
||||
Et la pénalité -0.5% s'applique (car non-abonné au démarrage)
|
||||
|
||||
Scénario: Utilisateur se désabonne puis écoute ancien contenu
|
||||
Étant donné qu'un utilisateur ÉTAIT abonné à "CreateurA"
|
||||
Et qu'il se désabonne aujourd'hui
|
||||
Quand il écoute un ancien contenu de "CreateurA" demain
|
||||
Et qu'il skip après 6 secondes
|
||||
Alors is_subscribed=false (état au moment de l'écoute)
|
||||
Et la pénalité -0.5% s'applique
|
||||
Et l'écoute compte dans les métriques d'engagement
|
||||
|
||||
# Comparaison tableaux sources
|
||||
|
||||
Scénario: Table récapitulative sources et abonnements
|
||||
Étant donné les règles de comptabilisation définies
|
||||
Quand on résume le comportement par source et abonnement
|
||||
Alors le tableau complet est:
|
||||
| Source | Abonné ? | Skip <10s pénalise ? | Compte "total écoutes" ? |
|
||||
| recommendation | Non | ✅ Oui (-0.5%) | ✅ Oui |
|
||||
| recommendation | Oui | ❌ Non (0%) | ❌ Non |
|
||||
| search | Peu imp. | Variable* | ❌ Non |
|
||||
| direct_link | Peu imp. | Variable* | ❌ Non |
|
||||
| profile | Peu imp. | Variable* | ❌ Non |
|
||||
| history | Peu imp. | Variable* | ❌ Non |
|
||||
| live_notification | Non | ✅ Oui (-0.5%) | ✅ Oui |
|
||||
| live_notification | Oui | ❌ Non (0%) | ❌ Non |
|
||||
| audio_guide | Peu imp. | ❌ Non | ❌ Non |
|
||||
(* Variable = -0.5% si non-abonné, 0% si abonné, mais source non pertinente donc pas dans métriques)
|
||||
@@ -0,0 +1,95 @@
|
||||
# language: fr
|
||||
|
||||
@ui @search @filters @mvp
|
||||
Fonctionnalité: Filtres avancés de recherche
|
||||
|
||||
En tant qu'utilisateur
|
||||
Je veux filtrer les résultats de recherche
|
||||
Afin de trouver précisément le contenu qui m'intéresse
|
||||
|
||||
Scénario: Filtres de base toujours visibles
|
||||
Étant donné un utilisateur sur la page de recherche
|
||||
Quand il consulte les filtres
|
||||
Alors il voit les filtres de base:
|
||||
| Filtre | Options |
|
||||
| Catégorie | Tourisme, Culture, Gastronomie, etc. |
|
||||
| Durée | < 30min, 30min-1h, 1h-2h, 2h+ |
|
||||
| Prix | Gratuit, Payant |
|
||||
| Note | 4+ étoiles, 3+ étoiles |
|
||||
| Distance | < 5km, 5-10km, 10-50km, 50km+ |
|
||||
Et un événement "SEARCH_FILTERS_DISPLAYED" est enregistré
|
||||
|
||||
Scénario: Filtres avancés dépliables
|
||||
Étant donné un utilisateur qui clique sur "Filtres avancés"
|
||||
Alors des filtres supplémentaires apparaissent:
|
||||
| Filtre | Options |
|
||||
| Langue | Français, Anglais, etc. |
|
||||
| Accessibilité PMR | Oui / Non |
|
||||
| Mode de déplacement | Piéton, Voiture, Vélo |
|
||||
| Créateur vérifié | Oui / Non |
|
||||
| Date de publication | Dernière semaine, mois, année |
|
||||
| Nombre de séquences | 1-5, 6-10, 11-20, 20+ |
|
||||
Et un événement "ADVANCED_FILTERS_EXPANDED" est enregistré
|
||||
|
||||
Scénario: Application des filtres en temps réel
|
||||
Étant donné un utilisateur qui sélectionne:
|
||||
| Filtre | Valeur choisie |
|
||||
| Catégorie | Tourisme |
|
||||
| Durée | 1h-2h |
|
||||
| Distance | < 10km |
|
||||
Quand il applique les filtres
|
||||
Alors les résultats se mettent à jour instantanément (< 500ms)
|
||||
Et le compteur affiche: "23 résultats trouvés"
|
||||
Et un événement "SEARCH_FILTERS_APPLIED" est enregistré
|
||||
|
||||
Scénario: Sauvegarde des filtres préférés
|
||||
Étant donné un utilisateur "alice@roadwave.fr" connecté
|
||||
Quand elle configure des filtres spécifiques
|
||||
Et clique sur "Sauvegarder ces filtres"
|
||||
Alors les filtres sont sauvegardés dans son profil
|
||||
Et automatiquement appliqués à sa prochaine recherche
|
||||
Et un événement "SEARCH_FILTERS_SAVED" est enregistré
|
||||
|
||||
Scénario: Suggestions de filtres intelligentes
|
||||
Étant donné un utilisateur qui recherche "Louvre"
|
||||
Quand les résultats s'affichent
|
||||
Alors des filtres suggérés apparaissent:
|
||||
"Peut aussi vous intéresser: Musées à Paris, Art classique"
|
||||
Et un clic applique automatiquement ces filtres
|
||||
Et un événement "SMART_FILTERS_SUGGESTED" est enregistré
|
||||
|
||||
Scénario: Compteur de résultats par filtre
|
||||
Étant donné un utilisateur qui survole un filtre
|
||||
Alors un badge affiche le nombre de résultats:
|
||||
| Filtre | Badge |
|
||||
| Tourisme | (45) |
|
||||
| Culture | (23) |
|
||||
| Gastronomie | (12) |
|
||||
| Gratuit | (34) |
|
||||
| Payant | (28) |
|
||||
Et aide à la décision de filtrage
|
||||
Et un événement "FILTER_COUNTS_DISPLAYED" est enregistré
|
||||
|
||||
Scénario: Réinitialisation des filtres
|
||||
Étant donné un utilisateur avec plusieurs filtres actifs
|
||||
Quand il clique sur "Réinitialiser les filtres"
|
||||
Alors tous les filtres sont désactivés
|
||||
Et tous les résultats sont affichés
|
||||
Et un événement "SEARCH_FILTERS_RESET" est enregistré
|
||||
|
||||
Scénario: Filtres persistants dans l'URL
|
||||
Étant donné un utilisateur qui applique des filtres
|
||||
Quand l'URL se met à jour
|
||||
Alors elle contient: /search?category=tourisme&duration=1-2h&distance=10km
|
||||
Et le lien peut être partagé avec les filtres actifs
|
||||
Et un événement "SEARCH_URL_UPDATED_WITH_FILTERS" est enregistré
|
||||
|
||||
Scénario: Métriques d'utilisation des filtres
|
||||
Étant donné que 10 000 recherches ont été effectuées
|
||||
Alors les indicateurs suivants sont disponibles:
|
||||
| Métrique | Valeur |
|
||||
| % d'utilisateurs utilisant filtres| 68% |
|
||||
| Nombre moyen de filtres/recherche | 2.3 |
|
||||
| Filtre le plus utilisé | Distance|
|
||||
| Filtre le moins utilisé | PMR |
|
||||
Et les métriques sont exportées vers le monitoring
|
||||
@@ -0,0 +1,134 @@
|
||||
# language: fr
|
||||
|
||||
@ui @search @map @mvp
|
||||
Fonctionnalité: Page de résultats avec carte interactive
|
||||
|
||||
En tant qu'utilisateur
|
||||
Je veux visualiser les résultats sur une carte
|
||||
Afin de choisir des contenus proches de ma position ou d'une zone
|
||||
|
||||
Scénario: Affichage par défaut en mode liste + carte
|
||||
Étant donné un utilisateur qui effectue une recherche
|
||||
Quand les résultats s'affichent
|
||||
Alors l'écran est divisé en 2 parties:
|
||||
| Section | Largeur | Contenu |
|
||||
| Liste | 40% | Résultats scrollables |
|
||||
| Carte | 60% | Marqueurs des résultats |
|
||||
Et la carte est synchronisée avec la liste
|
||||
Et un événement "SEARCH_RESULTS_MAP_VIEW" est enregistré
|
||||
|
||||
Scénario: Bascule entre vue liste, carte, et mixte
|
||||
Étant donné un utilisateur sur la page de résultats
|
||||
Quand il clique sur les boutons de vue:
|
||||
| Bouton | Vue résultante |
|
||||
| [Liste] | Liste 100%, carte masquée |
|
||||
| [Carte] | Carte 100%, liste masquée |
|
||||
| [Mixte] | Liste 40% + Carte 60% |
|
||||
Alors la vue change instantanément
|
||||
Et la préférence est sauvegardée
|
||||
Et un événement "SEARCH_VIEW_MODE_CHANGED" est enregistré
|
||||
|
||||
Scénario: Marqueurs groupés par zone (clustering)
|
||||
Étant donné 50 résultats dans une zone de 10km
|
||||
Quand la carte est affichée en zoom large
|
||||
Alors les marqueurs sont regroupés en clusters:
|
||||
| Cluster | Nombre de contenus |
|
||||
| Paris 1 | 15 |
|
||||
| Paris 2 | 12 |
|
||||
| Paris 5 | 23 |
|
||||
Et un clic sur un cluster zoome sur la zone
|
||||
Et un événement "MAP_CLUSTERING_DISPLAYED" est enregistré
|
||||
|
||||
Scénario: Survol d'un marqueur affiche une preview
|
||||
Étant donné un utilisateur qui survole un marqueur sur la carte
|
||||
Alors une popup s'affiche avec:
|
||||
| Élément | Contenu |
|
||||
| Image miniature | Photo de couverture |
|
||||
| Titre | Visite du Quartier Latin |
|
||||
| Durée | 2h 30min |
|
||||
| Note | 4.8/5 (1,234 avis) |
|
||||
| Prix | Gratuit |
|
||||
| Bouton | [Voir détails] |
|
||||
Et un événement "MAP_MARKER_PREVIEW_SHOWN" est enregistré
|
||||
|
||||
Scénario: Clic sur un marqueur ouvre la fiche
|
||||
Étant donné un utilisateur qui clique sur un marqueur
|
||||
Alors la fiche complète du contenu s'ouvre en modal
|
||||
Et la carte reste visible en arrière-plan
|
||||
Et un événement "MAP_MARKER_CLICKED" est enregistré
|
||||
|
||||
Scénario: Synchronisation liste-carte bidirectionnelle
|
||||
Étant donné un utilisateur en vue mixte (liste + carte)
|
||||
Quand il scroll dans la liste
|
||||
Alors la carte se centre automatiquement sur les contenus visibles
|
||||
Et inversement, quand il déplace la carte
|
||||
Alors la liste affiche les contenus de la zone visible
|
||||
Et un événement "LIST_MAP_SYNC" est enregistré
|
||||
|
||||
Scénario: Recherche par zone dessinée sur la carte
|
||||
Étant donné un utilisateur qui clique sur "Dessiner une zone"
|
||||
Quand il dessine un polygone sur la carte
|
||||
Alors seuls les contenus dans ce polygone sont affichés
|
||||
Et le filtre "Zone personnalisée" s'active
|
||||
Et un événement "MAP_CUSTOM_ZONE_DRAWN" est enregistré
|
||||
|
||||
Scénario: Calcul d'itinéraire depuis la carte
|
||||
Étant donné un utilisateur qui clique sur un marqueur
|
||||
Quand il clique sur "Itinéraire"
|
||||
Alors un calcul d'itinéraire démarre depuis sa position
|
||||
Et s'affiche sur la carte avec:
|
||||
| Information | Exemple |
|
||||
| Distance | 3.2 km |
|
||||
| Temps piéton | 40 min |
|
||||
| Temps voiture | 12 min |
|
||||
| Temps vélo | 18 min |
|
||||
Et un événement "MAP_ROUTE_CALCULATED" est enregistré
|
||||
|
||||
Scénario: Sauvegarde des contenus depuis la carte
|
||||
Étant donné un utilisateur qui consulte la carte
|
||||
Quand il clique sur l'icône "♡" d'un marqueur
|
||||
Alors le contenu est ajouté à ses favoris
|
||||
Et le marqueur change de couleur (rouge)
|
||||
Et un événement "CONTENT_SAVED_FROM_MAP" est enregistré
|
||||
|
||||
Scénario: Affichage de la position utilisateur en temps réel
|
||||
Étant donné un utilisateur avec géolocalisation activée
|
||||
Quand il consulte la carte
|
||||
Alors sa position est affichée par un point bleu
|
||||
Et se met à jour en temps réel si il se déplace
|
||||
Et un cercle indique la précision GPS (±10m)
|
||||
Et un événement "USER_LOCATION_TRACKED_ON_MAP" est enregistré
|
||||
|
||||
Scénario: Légende de la carte avec codes couleur
|
||||
Étant donné un utilisateur sur la carte
|
||||
Alors une légende affiche:
|
||||
| Couleur | Signification |
|
||||
| Vert | Gratuit |
|
||||
| Bleu | Payant |
|
||||
| Or | Créateur vérifié |
|
||||
| Rouge | Favoris |
|
||||
Et un événement "MAP_LEGEND_DISPLAYED" est enregistré
|
||||
|
||||
Scénario: Export de la carte en image
|
||||
Étant donné un utilisateur qui clique sur "Exporter la carte"
|
||||
Alors une image PNG de la carte actuelle est générée
|
||||
Et téléchargeable avec résultats visibles
|
||||
Et un événement "MAP_EXPORTED" est enregistré
|
||||
|
||||
Scénario: Mode hors ligne de la carte
|
||||
Étant donné un utilisateur qui télécharge une zone
|
||||
Quand il active le mode hors ligne
|
||||
Alors les tuiles de carte sont disponibles localement
|
||||
Et les contenus téléchargés sont accessibles
|
||||
Et un événement "MAP_OFFLINE_MODE_ENABLED" est enregistré
|
||||
|
||||
Scénario: Métriques d'utilisation de la carte
|
||||
Étant donné que 10 000 utilisateurs ont consulté la carte
|
||||
Alors les indicateurs suivants sont disponibles:
|
||||
| Métrique | Valeur |
|
||||
| % d'utilisations en mode carte | 42% |
|
||||
| % d'utilisations en mode mixte | 48% |
|
||||
| % d'utilisations en mode liste | 10% |
|
||||
| Nombre moyen de clics sur marqueurs| 3.2 |
|
||||
| Taux de conversion depuis carte | 18% |
|
||||
Et les métriques sont exportées vers le monitoring
|
||||
472
docs/domains/recommendation/features/recherche/recherche.feature
Normal file
472
docs/domains/recommendation/features/recherche/recherche.feature
Normal file
@@ -0,0 +1,472 @@
|
||||
# language: fr
|
||||
|
||||
Fonctionnalité: Recherche de contenu
|
||||
En tant qu'utilisateur de RoadWave
|
||||
Je veux rechercher des contenus audio par mots-clés, localisation et filtres
|
||||
Afin de trouver facilement le contenu qui m'intéresse
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'application RoadWave est démarrée
|
||||
Et que l'utilisateur "jean@example.com" est connecté
|
||||
|
||||
# 15.3.1 - Recherche par mot-clé
|
||||
|
||||
Scénario: Recherche full-text basique
|
||||
Étant donné que la base contient les contenus suivants:
|
||||
| titre | description | créateur |
|
||||
| Balade à Paris | Visite du quartier Latin | @paris_stories |
|
||||
| Secrets de Montmartre | Histoire de la butte | @explore_paris |
|
||||
| Voyage en Normandie | Découverte des plages | @voyages_fr |
|
||||
Quand l'utilisateur recherche "paris"
|
||||
Alors 2 résultats sont retournés
|
||||
Et les résultats incluent "Balade à Paris"
|
||||
Et les résultats incluent "Secrets de Montmartre"
|
||||
|
||||
Scénario: Recherche avec stemming français
|
||||
Étant donné un contenu avec le titre "Voyage en Bretagne"
|
||||
Quand l'utilisateur recherche "voyages"
|
||||
Alors le contenu "Voyage en Bretagne" est trouvé
|
||||
Et le stemming a transformé "voyages" en racine "voyag"
|
||||
|
||||
Plan du Scénario: Stemming français sur différentes formes
|
||||
Étant donné un contenu avec le mot "<mot_original>"
|
||||
Quand l'utilisateur recherche "<recherche>"
|
||||
Alors le contenu est trouvé grâce au stemming français
|
||||
|
||||
Exemples:
|
||||
| mot_original | recherche |
|
||||
| voyage | voyages |
|
||||
| voyager | voyage |
|
||||
| balades | balade |
|
||||
| historique | histoire |
|
||||
|
||||
Scénario: Recherche avec accents ignorés
|
||||
Étant donné un contenu avec le titre "Découverte de l'Élysée"
|
||||
Quand l'utilisateur recherche "decouverte elysee"
|
||||
Alors le contenu est trouvé
|
||||
Et les accents sont normalisés automatiquement
|
||||
|
||||
Scénario: Champs indexés avec pondération
|
||||
Étant donné les contenus suivants:
|
||||
| titre | description | créateur | tags |
|
||||
| Voyage Paris | Balade sympa | @user1 | Tourisme |
|
||||
| Balade Lyon | Voyage en ville | @paris_guide | Voyage |
|
||||
Quand l'utilisateur recherche "paris"
|
||||
Alors "Voyage Paris" est en première position
|
||||
Parce que le titre a un poids × 3
|
||||
Et "@paris_guide" apparaît en second
|
||||
Parce que le créateur a un poids × 2
|
||||
|
||||
Scénario: Ranking par pertinence et popularité
|
||||
Étant donné les contenus suivants:
|
||||
| titre | écoutes | rang_texte |
|
||||
| Balade Paris | 50000 | 0.8 |
|
||||
| Paris la nuit | 1000 | 0.9 |
|
||||
Quand l'utilisateur recherche "paris"
|
||||
Alors le score final combine rang_texte × (1 + log(écoutes + 1))
|
||||
Et "Balade Paris" est mieux classé grâce à sa popularité
|
||||
|
||||
Scénario: Autocomplete pendant la frappe
|
||||
Étant donné que l'utilisateur commence à taper "par"
|
||||
Quand 3 caractères sont saisis
|
||||
Alors des suggestions apparaissent:
|
||||
| suggestion |
|
||||
| paris |
|
||||
| parc naturel |
|
||||
| parvis notre-dame |
|
||||
Et le top 5 des suggestions est affiché
|
||||
|
||||
Scénario: Historique des 10 dernières recherches
|
||||
Étant donné que l'utilisateur a effectué les recherches suivantes:
|
||||
| recherche | date |
|
||||
| voyage paris | 2026-01-20 |
|
||||
| audio-guide louvre | 2026-01-19 |
|
||||
| podcast automobile | 2026-01-18 |
|
||||
Quand l'utilisateur ouvre la barre de recherche
|
||||
Alors les 10 dernières recherches sont affichées
|
||||
Et elles sont triées par date décroissante
|
||||
|
||||
Scénario: Correction automatique si aucun résultat
|
||||
Étant donné que l'utilisateur recherche "ballade paris" (faute d'orthographe)
|
||||
Et qu'aucun résultat n'est trouvé
|
||||
Quand la page de résultats s'affiche
|
||||
Alors une suggestion "Essayez plutôt : balade paris" est affichée
|
||||
|
||||
Scénario: Recherches populaires suggérées
|
||||
Étant donné qu'aucun résultat n'est trouvé pour une recherche
|
||||
Quand la page s'affiche
|
||||
Alors des suggestions populaires sont affichées:
|
||||
| suggestion |
|
||||
| balade paris |
|
||||
| audio-guide louvre |
|
||||
| visite montmartre |
|
||||
|
||||
# 15.3.2 - Recherche géographique
|
||||
|
||||
Scénario: Saisie d'un lieu avec autocomplete
|
||||
Étant donné que l'utilisateur ouvre le filtre "Lieu"
|
||||
Quand il tape "Louv"
|
||||
Alors Nominatim retourne des suggestions:
|
||||
| suggestion | type |
|
||||
| Musée du Louvre, Paris | monument |
|
||||
| Louvres, Val-d'Oise | commune |
|
||||
|
||||
Scénario: Sélection d'un lieu et définition du rayon
|
||||
Étant donné que l'utilisateur sélectionne "Paris, France"
|
||||
Et que les coordonnées sont (48.8566, 2.3522)
|
||||
Quand il définit un rayon de 50 km
|
||||
Alors la recherche PostGIS utilise ST_DWithin avec 50000 mètres
|
||||
|
||||
Plan du Scénario: Recherche géographique avec différents rayons
|
||||
Étant donné un contenu à 30 km de Paris
|
||||
Quand l'utilisateur recherche autour de Paris avec un rayon de <rayon>
|
||||
Alors le contenu est <résultat>
|
||||
|
||||
Exemples:
|
||||
| rayon | résultat |
|
||||
| 20 km | non trouvé |
|
||||
| 50 km | trouvé |
|
||||
| 100 km | trouvé |
|
||||
|
||||
Scénario: Utilisation de "Autour de moi" (GPS actuel)
|
||||
Étant donné que l'utilisateur active le GPS
|
||||
Et que sa position est (48.8566, 2.3522)
|
||||
Quand il sélectionne "Autour de moi"
|
||||
Alors la recherche utilise ses coordonnées GPS actuelles
|
||||
Et un rayon par défaut de 10 km est appliqué
|
||||
|
||||
Scénario: Curseur de rayon avec limites
|
||||
Étant donné que l'utilisateur ouvre le curseur de rayon
|
||||
Quand il ajuste le curseur
|
||||
Alors les valeurs disponibles vont de 5 km à 500 km
|
||||
Et la valeur s'affiche en temps réel "50 km"
|
||||
|
||||
Scénario: Affichage de la distance dans les résultats
|
||||
Étant donné une recherche géographique autour de Paris
|
||||
Et un contenu à 2.3 km de distance
|
||||
Quand les résultats sont affichés
|
||||
Alors la distance "À 2.3 km" est indiquée pour chaque résultat
|
||||
|
||||
Plan du Scénario: Tri par proximité géographique
|
||||
Étant donné des contenus à différentes distances de Paris:
|
||||
| contenu | distance |
|
||||
| Louvre Guide | 0.5 km |
|
||||
| Tour Eiffel | 2.0 km |
|
||||
| Versailles | 20 km |
|
||||
Quand l'utilisateur trie par "Proximité"
|
||||
Alors les résultats sont affichés dans l'ordre:
|
||||
| position | contenu |
|
||||
| 1 | Louvre Guide |
|
||||
| 2 | Tour Eiffel |
|
||||
| 3 | Versailles |
|
||||
|
||||
Scénario: Géocodage avec Nominatim (MVP)
|
||||
Étant donné que l'application est en phase MVP
|
||||
Quand une requête de géocodage est effectuée
|
||||
Alors l'API publique Nominatim est utilisée
|
||||
Et le rate limit de 1 req/s est respecté
|
||||
|
||||
Scénario: Géocodage avec fallback Mapbox
|
||||
Étant donné que Nominatim ne retourne aucun résultat
|
||||
Quand l'application tente un fallback
|
||||
Alors l'API Mapbox Geocoding est utilisée
|
||||
Et le coût de 0.50€ / 1000 requêtes est appliqué
|
||||
|
||||
# 15.3.3 - Filtres avancés
|
||||
|
||||
Scénario: Ouverture du panneau de filtres
|
||||
Étant donné que l'utilisateur est sur la page de recherche
|
||||
Quand il clique sur "Filtres"
|
||||
Alors un panneau latéral s'ouvre
|
||||
Et 7 catégories de filtres sont affichées:
|
||||
| catégorie |
|
||||
| Type de contenu |
|
||||
| Durée |
|
||||
| Classification âge |
|
||||
| Géo-pertinence |
|
||||
| Tags |
|
||||
| Date de publication |
|
||||
| Abonnement |
|
||||
|
||||
Scénario: Filtre par type de contenu (multi-sélection)
|
||||
Étant donné que l'utilisateur ouvre les filtres
|
||||
Quand il sélectionne:
|
||||
| type |
|
||||
| Contenu court |
|
||||
| Audio-guide |
|
||||
Alors seuls ces types de contenus sont recherchés
|
||||
Et les podcasts et radios live sont exclus
|
||||
|
||||
Plan du Scénario: Filtre par durée
|
||||
Étant donné un contenu de <durée> minutes
|
||||
Quand l'utilisateur filtre par "<tranche>"
|
||||
Alors le contenu est <résultat>
|
||||
|
||||
Exemples:
|
||||
| durée | tranche | résultat |
|
||||
| 3 | <5 min | trouvé |
|
||||
| 3 | 5-15 min | non trouvé |
|
||||
| 10 | 5-15 min | trouvé |
|
||||
| 20 | 15-30 min | trouvé |
|
||||
| 45 | >30 min | trouvé |
|
||||
|
||||
Scénario: Filtre par classification âge
|
||||
Étant donné des contenus avec différentes classifications:
|
||||
| contenu | classification |
|
||||
| Conte enfants | Tout public |
|
||||
| Podcast news | 13+ |
|
||||
| Débat politique | 16+ |
|
||||
Quand l'utilisateur filtre "Tout public"
|
||||
Alors seul "Conte enfants" est affiché
|
||||
|
||||
Scénario: Filtre par géo-pertinence
|
||||
Étant donné des contenus avec différents types géo:
|
||||
| contenu | type_geo |
|
||||
| Guide Louvre | Ancré |
|
||||
| Podcast Paris | Contextuel |
|
||||
| News nationales | Neutre |
|
||||
Quand l'utilisateur filtre "Ancré, Contextuel"
|
||||
Alors "Guide Louvre" et "Podcast Paris" sont affichés
|
||||
Et "News nationales" est exclu
|
||||
|
||||
Scénario: Filtre par tags (multi-sélection)
|
||||
Étant donné des contenus taggés:
|
||||
| contenu | tags |
|
||||
| Voyage en Italie | Voyage, Gastronomie |
|
||||
| Histoire de Rome | Voyage, Histoire |
|
||||
| Économie italienne | Économie |
|
||||
Quand l'utilisateur sélectionne les tags "Voyage, Histoire"
|
||||
Alors "Histoire de Rome" est en priorité (2 tags correspondants)
|
||||
Et "Voyage en Italie" est affiché (1 tag correspondant)
|
||||
Et "Économie italienne" est exclu
|
||||
|
||||
Plan du Scénario: Filtre par date de publication
|
||||
Étant donné un contenu publié il y a <délai>
|
||||
Quand l'utilisateur filtre par "<période>"
|
||||
Alors le contenu est <résultat>
|
||||
|
||||
Exemples:
|
||||
| délai | période | résultat |
|
||||
| 12 heures | Dernières 24h | trouvé |
|
||||
| 3 jours | Cette semaine | trouvé |
|
||||
| 15 jours | Ce mois | trouvé |
|
||||
| 8 mois | Cette année | trouvé |
|
||||
| 2 ans | Toutes dates | trouvé |
|
||||
| 2 ans | Cette année | non trouvé |
|
||||
|
||||
Scénario: Filtre par type d'abonnement
|
||||
Étant donné des contenus gratuits et Premium:
|
||||
| contenu | type |
|
||||
| Balade Paris | Gratuit |
|
||||
| Visite VIP Louvre | Premium |
|
||||
Quand l'utilisateur filtre "Premium uniquement 👑"
|
||||
Alors seul "Visite VIP Louvre" est affiché
|
||||
|
||||
Scénario: Combinaison de filtres multiples (AND logic)
|
||||
Étant donné que l'utilisateur applique les filtres:
|
||||
| filtre | valeur |
|
||||
| Type | Audio-guide |
|
||||
| Durée | 5-15 min |
|
||||
| Tags | Voyage |
|
||||
| Classification | Tout public |
|
||||
Quand la recherche est lancée
|
||||
Alors seuls les contenus respectant TOUS les critères sont affichés
|
||||
|
||||
Scénario: Réinitialisation des filtres
|
||||
Étant donné que l'utilisateur a appliqué 5 filtres différents
|
||||
Quand il clique sur "Réinitialiser"
|
||||
Alors tous les filtres sont désactivés
|
||||
Et la recherche affiche tous les résultats
|
||||
|
||||
Scénario: Sauvegarde d'une recherche
|
||||
Étant donné que l'utilisateur a appliqué plusieurs filtres
|
||||
Quand il clique sur "💾 Sauvegarder cette recherche"
|
||||
Et qu'il entre le nom "Podcasts voyage Paris"
|
||||
Alors la recherche est sauvegardée
|
||||
Et elle apparaît dans l'onglet "Recherches sauvegardées"
|
||||
|
||||
Scénario: Limite de 5 recherches sauvegardées
|
||||
Étant donné que l'utilisateur a déjà 5 recherches sauvegardées
|
||||
Quand il tente de sauvegarder une 6ème recherche
|
||||
Alors un message d'erreur s'affiche
|
||||
Et il doit supprimer une recherche existante avant d'en ajouter une nouvelle
|
||||
|
||||
Scénario: Notifications pour recherches sauvegardées
|
||||
Étant donné une recherche sauvegardée "Podcasts voyage Paris"
|
||||
Et que l'utilisateur a activé les notifications
|
||||
Quand 3 nouveaux contenus correspondants sont publiés
|
||||
Alors une notification "3 nouveaux contenus dans 'Podcasts voyage Paris'" est envoyée
|
||||
|
||||
Plan du Scénario: Options de tri des résultats
|
||||
Étant donné une recherche avec plusieurs résultats
|
||||
Quand l'utilisateur sélectionne le tri "<option>"
|
||||
Alors les résultats sont triés selon <algorithme>
|
||||
|
||||
Exemples:
|
||||
| option | algorithme |
|
||||
| Pertinence | Score recherche × (1 + log(écoutes + 1)) |
|
||||
| Popularité | Écoutes complètes derniers 30j DESC |
|
||||
| Récent | Date publication DESC |
|
||||
| Proximité | Distance GPS ASC (si recherche géo) |
|
||||
| Durée | Durée audio ASC ou DESC |
|
||||
|
||||
# 15.3.4 - Page de résultats
|
||||
|
||||
Scénario: Structure d'un résultat de recherche
|
||||
Étant donné un résultat de recherche
|
||||
Quand la page est affichée
|
||||
Alors chaque résultat contient:
|
||||
| élément | exemple |
|
||||
| Cover image | 120×68 px (16:9) |
|
||||
| Titre | Balade à Paris (2 lignes max) |
|
||||
| Créateur | @paris_stories ✓ |
|
||||
| Durée | 12 min |
|
||||
| Écoutes | 🎧 2.3K |
|
||||
| Localisation | 📍 Paris 5e · Ancré |
|
||||
| Tags | 🏷️ #Voyage #Histoire |
|
||||
| Badge Premium | 👑 (si applicable) |
|
||||
| Distance | À 2.3 km (si recherche géo) |
|
||||
| Bouton lecture | ▶️ Écouter |
|
||||
| Menu contextuel | ⋮ |
|
||||
|
||||
Scénario: Lazy loading des images
|
||||
Étant donné une page avec 20 résultats de recherche
|
||||
Quand la page se charge
|
||||
Alors seules les 5 premières images sont chargées
|
||||
Et les images suivantes se chargent au scroll
|
||||
|
||||
Scénario: Troncature du titre sur 2 lignes maximum
|
||||
Étant donné un contenu avec un titre de 120 caractères
|
||||
Quand le résultat est affiché
|
||||
Alors le titre est tronqué après 2 lignes
|
||||
Et "..." est ajouté à la fin
|
||||
|
||||
Scénario: Lien cliquable vers le profil créateur
|
||||
Étant donné un résultat de recherche pour "@paris_stories"
|
||||
Quand l'utilisateur clique sur "@paris_stories"
|
||||
Alors il est redirigé vers "https://roadwave.fr/@paris_stories"
|
||||
|
||||
Scénario: Menu contextuel d'un résultat [⋮]
|
||||
Étant donné que l'utilisateur clique sur [⋮] pour un résultat
|
||||
Quand le menu s'ouvre
|
||||
Alors les actions suivantes sont disponibles:
|
||||
| action |
|
||||
| Partager |
|
||||
| Ajouter à une playlist |
|
||||
| Télécharger (offline) |
|
||||
| Signaler |
|
||||
|
||||
Scénario: Pagination avec 20 résultats par page
|
||||
Étant donné une recherche retournant 100 résultats
|
||||
Quand la page est affichée
|
||||
Alors 20 résultats sont chargés initialement
|
||||
Et un indicateur "1-20 sur 100 résultats" est visible
|
||||
|
||||
Scénario: Infinite scroll automatique
|
||||
Étant donné que l'utilisateur scroll dans les résultats
|
||||
Quand il atteint 80% de la page
|
||||
Alors les 20 résultats suivants sont chargés automatiquement
|
||||
Et un loader est affiché pendant le chargement
|
||||
|
||||
Scénario: Bouton fallback "Charger 20 suivants"
|
||||
Étant donné que l'infinite scroll est désactivé (paramètres)
|
||||
Quand l'utilisateur atteint la fin de la page
|
||||
Alors un bouton "Charger 20 suivants" est affiché
|
||||
Et les résultats se chargent au clic
|
||||
|
||||
# Vue carte
|
||||
|
||||
Scénario: Basculement entre vue liste et vue carte
|
||||
Étant donné que l'utilisateur est sur la page de résultats
|
||||
Quand il clique sur le toggle "Liste / Carte"
|
||||
Alors la vue carte Leaflet s'affiche
|
||||
Et les résultats sont affichés comme markers sur la carte
|
||||
|
||||
Scénario: Affichage de la carte Leaflet
|
||||
Étant donné que la vue carte est activée
|
||||
Quand la carte se charge
|
||||
Alors la carte utilise les tuiles OpenStreetMap
|
||||
Et le centre est la position de recherche (ou GPS utilisateur)
|
||||
Et le zoom initial montre tous les résultats
|
||||
|
||||
Scénario: Markers cliquables sur la carte
|
||||
Étant donné que 10 résultats sont affichés sur la carte
|
||||
Quand l'utilisateur clique sur un marker
|
||||
Alors une popup s'affiche avec:
|
||||
| élément |
|
||||
| Titre |
|
||||
| Créateur |
|
||||
| Durée |
|
||||
| Distance |
|
||||
| Bouton ▶️ Écouter|
|
||||
|
||||
Scénario: Clustering des markers proches
|
||||
Étant donné que 50 résultats sont très proches géographiquement
|
||||
Quand la carte est affichée
|
||||
Alors les markers proches sont groupés en clusters
|
||||
Et le nombre de contenus est affiché sur le cluster
|
||||
Et le cluster se décompose au zoom
|
||||
|
||||
Scénario: Synchronisation liste / carte
|
||||
Étant donné que l'utilisateur est en vue carte
|
||||
Quand il clique sur un marker et écoute le contenu
|
||||
Et qu'il rebascule en vue liste
|
||||
Alors le contenu écouté est marqué dans la liste
|
||||
Et la position de scroll est maintenue
|
||||
|
||||
# Performances et index
|
||||
|
||||
Scénario: Index PostgreSQL full-text pour performances
|
||||
Étant donné que la base contient 100K contenus
|
||||
Quand une recherche full-text est effectuée
|
||||
Alors l'index GIN sur to_tsvector est utilisé
|
||||
Et la requête retourne en moins de 100ms
|
||||
|
||||
Scénario: Index PostGIS GIST pour recherche géo
|
||||
Étant donné une recherche géographique avec rayon 50 km
|
||||
Quand la requête PostGIS ST_DWithin est exécutée
|
||||
Alors l'index GIST sur la colonne location est utilisé
|
||||
Et la requête retourne en moins de 50ms
|
||||
|
||||
Scénario: Index composites pour filtres
|
||||
Étant donné une recherche avec filtres multiples
|
||||
Quand les filtres type, durée, âge, géo, date sont appliqués
|
||||
Alors l'index composite idx_content_filters est utilisé
|
||||
Et les performances restent optimales
|
||||
|
||||
Scénario: Index GIN pour recherche par tags
|
||||
Étant donné une recherche filtrée par tags "Voyage, Histoire"
|
||||
Quand la requête est exécutée
|
||||
Alors l'index GIN sur la colonne tags est utilisé
|
||||
Et la recherche est performante même avec 500K contenus
|
||||
|
||||
# Cas d'erreur
|
||||
|
||||
Scénario: Aucun résultat trouvé
|
||||
Étant donné que l'utilisateur recherche "xyzabc123"
|
||||
Quand aucun résultat n'est trouvé
|
||||
Alors un message "Aucun résultat pour 'xyzabc123'" s'affiche
|
||||
Et des suggestions de recherches populaires sont proposées
|
||||
|
||||
Scénario: Recherche vide
|
||||
Étant donné que l'utilisateur clique sur "Rechercher" sans saisir de texte
|
||||
Quand la recherche est lancée
|
||||
Alors un message "Veuillez entrer au moins 2 caractères" s'affiche
|
||||
|
||||
Scénario: Erreur de géocodage Nominatim
|
||||
Étant donné que l'API Nominatim est indisponible
|
||||
Quand l'utilisateur tente une recherche géographique
|
||||
Alors un message "Service de localisation temporairement indisponible" s'affiche
|
||||
Et la recherche continue sans filtre géographique
|
||||
|
||||
Scénario: GPS désactivé pour "Autour de moi"
|
||||
Étant donné que l'utilisateur a désactivé le GPS
|
||||
Quand il sélectionne "Autour de moi"
|
||||
Alors un message "Veuillez activer la localisation" s'affiche
|
||||
Et un bouton "Activer" ouvre les paramètres système
|
||||
|
||||
Scénario: Timeout de recherche après 10 secondes
|
||||
Étant donné qu'une recherche complexe est lancée
|
||||
Quand la requête dépasse 10 secondes
|
||||
Alors la recherche est annulée
|
||||
Et un message "La recherche a pris trop de temps, veuillez réessayer" s'affiche
|
||||
240
docs/domains/recommendation/features/recommendation/README.md
Normal file
240
docs/domains/recommendation/features/recommendation/README.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Tests Gherkin - Algorithme de Recommandation
|
||||
|
||||
Tests BDD pour la section [04-algorithme-recommandation.md](../rules/algorithme-recommandation.md)
|
||||
|
||||
## Fichiers de tests
|
||||
|
||||
### [classification-geo.feature](classification-geo.feature)
|
||||
**Couverture** : Section 2.1 des règles métier
|
||||
|
||||
- ✅ Classification 3 types (Géo-ancré 70%, Géo-contextuel 50%, Géo-neutre 20%)
|
||||
- ✅ Choix par créateur
|
||||
- ✅ Reclassification par modérateur
|
||||
- ✅ Modification après publication
|
||||
- ✅ Impact sur pondération algorithme
|
||||
|
||||
**Scénarios** : 11
|
||||
|
||||
---
|
||||
|
||||
### [scoring-recommandation.feature](scoring-recommandation.feature)
|
||||
**Couverture** : Sections 2.2, 2.3, 2.4 des règles métier
|
||||
|
||||
- ✅ Calcul score géographique linéaire (1 - distance/200km)
|
||||
- ✅ Calcul score d'intérêts (moyenne jauges tags)
|
||||
- ✅ Calcul score engagement (complétion 50%, likes 30%, abonnements 20%)
|
||||
- ✅ Seuil minimum 50 écoutes
|
||||
- ✅ Score final combiné selon type contenu
|
||||
- ✅ Bonus aléatoire 10% configurable
|
||||
- ✅ Contenu viral peut être recommandé loin
|
||||
- ✅ Pré-calcul 5 contenus suivants
|
||||
- ✅ Recalcul si >10 km ou >10 min
|
||||
|
||||
**Scénarios** : 23
|
||||
|
||||
---
|
||||
|
||||
### [contenu-politique.feature](contenu-politique.feature)
|
||||
**Couverture** : Section 2.5 des règles métier (MVP simplifié)
|
||||
|
||||
- ✅ Tag simple "Politique" sans classification gauche/droite
|
||||
- ✅ Filtrage utilisateur "Masquer contenu politique"
|
||||
- ✅ Par défaut tous contenus visibles
|
||||
- ✅ Mode Kids filtre automatiquement le politique
|
||||
- ✅ Pas d'équilibrage imposé en MVP
|
||||
|
||||
**Scénarios** : 13
|
||||
|
||||
---
|
||||
|
||||
### [mode-kids.feature](mode-kids.feature)
|
||||
**Couverture** : Section 2.6 des règles métier
|
||||
|
||||
- ✅ Activation manuelle (pas automatique car âge min 13 ans)
|
||||
- ✅ Filtrage contenus "Tous publics" uniquement
|
||||
- ✅ Exclusion automatique contenu politique
|
||||
- ✅ Pas de publicité (ou validée manuellement)
|
||||
- ✅ Interface standard (pas d'UI enfant)
|
||||
- ✅ Désactivation possible à tout moment
|
||||
|
||||
**Scénarios** : 15
|
||||
|
||||
---
|
||||
|
||||
### [declenchement-geo.feature](declenchement-geo.feature)
|
||||
**Couverture** : Section 2.7 des règles métier
|
||||
|
||||
- ✅ Notification sonore + visuelle au passage <500m
|
||||
- ✅ Délai réaction 5 secondes
|
||||
- ✅ Pas d'interruption contenu en cours
|
||||
- ✅ Logos différenciés (📍🏛️🍴🎭)
|
||||
- ✅ Publicité uniquement entre contenus
|
||||
- ✅ Gestion demi-tour (pas de répétition avant 24h)
|
||||
- ✅ Rayon configurable par admin
|
||||
|
||||
**Scénarios** : 17
|
||||
|
||||
---
|
||||
|
||||
### [historique-reproposition.feature](historique-reproposition.feature)
|
||||
**Couverture** : Section 2.8 des règles métier
|
||||
|
||||
- ✅ Contenu >80% jamais reproposé (sauf replayable=true)
|
||||
- ✅ Contenu <10s ne pas reproposer (signal négatif)
|
||||
- ✅ Contenu 10-80% reproposer avec reprise position
|
||||
- ✅ Stockage illimité PostgreSQL
|
||||
- ✅ Algorithme considère 100 derniers pour performance
|
||||
- ✅ Export complet RGPD
|
||||
|
||||
**Scénarios** : 17
|
||||
|
||||
---
|
||||
|
||||
### [parametrabilite-admin.feature](parametrabilite-admin.feature)
|
||||
**Couverture** : Section 2.9 des règles métier
|
||||
|
||||
- ✅ Dashboard admin avec tous paramètres configurables à chaud
|
||||
- ✅ Validation plages de valeurs
|
||||
- ✅ Aucun recalcul batch (économie CPU)
|
||||
- ✅ Versioning configurations (git-like)
|
||||
- ✅ Rollback 1 clic
|
||||
- ✅ A/B testing avec split 50/50
|
||||
- ✅ Métriques comparatives temps réel
|
||||
- ✅ Graphiques évolution engagement
|
||||
- ✅ Export CSV analyse externe
|
||||
|
||||
**Scénarios** : 17
|
||||
|
||||
---
|
||||
|
||||
### [parametrabilite-utilisateur.feature](parametrabilite-utilisateur.feature)
|
||||
**Couverture** : Section 2.10 des règles métier
|
||||
|
||||
- ✅ 3 curseurs (Géolocalisation, Découverte, Politique)
|
||||
- ✅ Profils sauvegardables (Trajet quotidien, Road trip, Enfants)
|
||||
- ✅ Synchronisation multi-devices
|
||||
- ✅ Auto-switch selon contexte GPS
|
||||
- ✅ Blocage modification si vitesse >10 km/h
|
||||
- ✅ Warning avant de prendre la route
|
||||
- ✅ Limite 10 profils par utilisateur
|
||||
|
||||
**Scénarios** : 22
|
||||
|
||||
---
|
||||
|
||||
### [medias-traditionnels.feature](medias-traditionnels.feature)
|
||||
**Couverture** : Section 2.11 des règles métier
|
||||
|
||||
- ✅ Compte média vérifié (badge ✓)
|
||||
- ✅ Pas de validation 3 premiers contenus
|
||||
- ✅ Modération a posteriori uniquement
|
||||
- ✅ Formats: flash info, chroniques, éditos, reportages
|
||||
- ✅ Classification âge obligatoire
|
||||
- ✅ Monétisation standard (3€/1000 écoutes)
|
||||
- ✅ Sponsoring direct autorisé
|
||||
- ✅ Statistiques détaillées
|
||||
|
||||
**Scénarios** : 19
|
||||
|
||||
---
|
||||
|
||||
## Statistiques
|
||||
|
||||
| Métrique | Valeur |
|
||||
|----------|--------|
|
||||
| **Fichiers** | 9 |
|
||||
| **Scénarios** | 154 |
|
||||
| **Règles métier** | 100% couverture section 2 |
|
||||
|
||||
## Formules mathématiques testées
|
||||
|
||||
### Score géographique
|
||||
```
|
||||
score_geo = 1 - (distance_km / distance_max_km)
|
||||
```
|
||||
|
||||
### Score intérêts
|
||||
```
|
||||
score_interets = moyenne(jauges_tags_correspondants)
|
||||
```
|
||||
|
||||
### Score engagement
|
||||
```
|
||||
score_engagement = (taux_completion × 0.5) + (ratio_likes × 0.3) + (ratio_abonnements × 0.2)
|
||||
```
|
||||
|
||||
### Score final
|
||||
```
|
||||
score_final = (score_geo × poids_geo_type)
|
||||
+ (score_interets × poids_interets_type)
|
||||
+ (score_engagement × 0.2)
|
||||
+ bonus_aleatoire
|
||||
```
|
||||
|
||||
## Paramètres par défaut
|
||||
|
||||
| Paramètre | Défaut | Plage |
|
||||
|-----------|--------|-------|
|
||||
| poids_geo_ancre | 0.7 | 0.5 - 1.0 |
|
||||
| poids_geo_contextuel | 0.5 | 0.3 - 0.7 |
|
||||
| poids_geo_neutre | 0.2 | 0.0 - 0.4 |
|
||||
| poids_engagement | 0.2 | 0.0 - 0.5 |
|
||||
| part_aleatoire_global | 0.1 | 0.0 - 0.3 |
|
||||
| distance_max_km | 200 | 50 - 500 |
|
||||
| rayon_gps_point_m | 500 | 100 - 2000 |
|
||||
| seuil_min_ecoutes_engagement | 50 | 10 - 200 |
|
||||
|
||||
## Exécution des tests
|
||||
|
||||
### Tous les tests de recommandation
|
||||
```bash
|
||||
godog run features/recommendation/
|
||||
```
|
||||
|
||||
### Un fichier spécifique
|
||||
```bash
|
||||
godog run features/recommendation/scoring-recommandation.feature
|
||||
```
|
||||
|
||||
### Tests calculs mathématiques uniquement
|
||||
```bash
|
||||
godog run features/recommendation/scoring-recommandation.feature --tags=@calcul
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
Ces tests sont exécutés :
|
||||
- ✅ Avant chaque release
|
||||
- ✅ Sur les PRs modifiant l'algorithme de recommandation
|
||||
- ✅ Nightly builds (tous les tests)
|
||||
|
||||
## Implémentation des steps
|
||||
|
||||
Les steps definitions seront implémentées dans :
|
||||
```
|
||||
features/steps/recommendation_steps.go
|
||||
```
|
||||
|
||||
Exemple avec calculs :
|
||||
```go
|
||||
func (s *RecommendationSteps) lalgorithmeCalculeLeScoreGeo(expectedScore float64) error {
|
||||
actualScore := 1.0 - (s.distance / s.distanceMax)
|
||||
if math.Abs(actualScore - expectedScore) > 0.01 {
|
||||
return fmt.Errorf("score_geo: attendu %.2f, obtenu %.2f", expectedScore, actualScore)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Prochaines étapes
|
||||
|
||||
1. ⏳ Implémenter les steps definitions en Go
|
||||
2. ⏳ Tester les formules mathématiques avec valeurs edge cases
|
||||
3. ⏳ Configurer Testcontainers pour PostgreSQL + PostGIS
|
||||
4. ⏳ Tests de performance (calcul score pour 1000 contenus <100ms)
|
||||
5. ⏳ Créer les Gherkin pour les 14 autres sections
|
||||
|
||||
---
|
||||
|
||||
**Statut** : ✅ Spécifications complètes
|
||||
**Dernière mise à jour** : 2026-01-21
|
||||
@@ -0,0 +1,100 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Classification de géo-pertinence des contenus
|
||||
En tant que plateforme de contenu géolocalisé
|
||||
Je veux classifier les contenus selon leur pertinence géographique
|
||||
Afin d'adapter l'algorithme de recommandation
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
|
||||
Scénario: Créateur choisit le type géo-ancré pour un audio-guide
|
||||
Étant donné que je suis un créateur connecté
|
||||
Quand je publie un audio-guide de la Tour Eiffel
|
||||
Et que je choisis la classification "Géo-ancré"
|
||||
Alors le contenu est enregistré avec:
|
||||
| champ | valeur |
|
||||
| type_geo | geo_ancre |
|
||||
| ponderation_geo | 0.7 |
|
||||
| ponderation_interets | 0.1 |
|
||||
|
||||
Scénario: Créateur choisit le type géo-contextuel pour actualité régionale
|
||||
Étant donné que je suis un créateur connecté
|
||||
Quand je publie une actualité régionale en Bretagne
|
||||
Et que je choisis la classification "Géo-contextuel"
|
||||
Alors le contenu est enregistré avec:
|
||||
| champ | valeur |
|
||||
| type_geo | geo_contextuel |
|
||||
| ponderation_geo | 0.5 |
|
||||
| ponderation_interets | 0.3 |
|
||||
|
||||
Scénario: Créateur choisit le type géo-neutre pour un podcast philosophie
|
||||
Étant donné que je suis un créateur connecté
|
||||
Quand je publie un podcast de philosophie
|
||||
Et que je choisis la classification "Géo-neutre"
|
||||
Alors le contenu est enregistré avec:
|
||||
| champ | valeur |
|
||||
| type_geo | geo_neutre |
|
||||
| ponderation_geo | 0.2 |
|
||||
| ponderation_interets | 0.6 |
|
||||
|
||||
Scénario: Publication impossible sans classification géographique
|
||||
Étant donné que je crée un contenu audio
|
||||
Quand j'essaie de publier sans sélectionner de type géographique
|
||||
Alors la publication échoue
|
||||
Et je vois le message "Vous devez sélectionner un type de géo-pertinence"
|
||||
|
||||
Scénario: Modérateur reclassifie un contenu mal catégorisé
|
||||
Étant donné qu'un contenu podcast générique est classifié "Géo-ancré"
|
||||
Et que le modérateur examine le contenu
|
||||
Quand le modérateur le reclassifie en "Géo-neutre"
|
||||
Alors la nouvelle classification est appliquée immédiatement
|
||||
Et l'algorithme utilise la pondération géo = 0.2
|
||||
Et le créateur reçoit une notification de reclassification
|
||||
|
||||
Scénario: Créateur modifie la classification après publication
|
||||
Étant donné que j'ai publié un contenu classifié "Géo-contextuel"
|
||||
Et que je réalise qu'il devrait être "Géo-neutre"
|
||||
Quand je modifie la classification en "Géo-neutre"
|
||||
Alors la modification est enregistrée
|
||||
Et l'algorithme utilise la nouvelle pondération
|
||||
Et je vois le message "Classification modifiée avec succès"
|
||||
|
||||
Scénario: Statistiques de classification dans le profil créateur
|
||||
Étant donné que je suis un créateur
|
||||
Et que j'ai publié 30 contenus:
|
||||
| type | nombre |
|
||||
| Géo-ancré | 10 |
|
||||
| Géo-contextuel | 15 |
|
||||
| Géo-neutre | 5 |
|
||||
Quand je consulte mes statistiques
|
||||
Alors je vois la répartition de mes classifications
|
||||
Et des suggestions pour optimiser la portée
|
||||
|
||||
Scénario: Contenu géo-ancré fortement pondéré par la proximité
|
||||
Étant donné qu'un audio-guide "Géo-ancré" existe à la Tour Eiffel
|
||||
Et qu'un utilisateur est à 100m de la Tour Eiffel
|
||||
Quand l'algorithme calcule le score
|
||||
Alors la pondération géo est de 0.7
|
||||
Et le score géo est proche de 1 (très proche)
|
||||
Et le contenu a un score final élevé
|
||||
|
||||
Scénario: Contenu géo-neutre moins sensible à la distance
|
||||
Étant donné qu'un podcast philosophie "Géo-neutre" existe à Paris
|
||||
Et qu'un utilisateur est à Marseille (750 km)
|
||||
Quand l'algorithme calcule le score
|
||||
Alors la pondération géo est de 0.2
|
||||
Et le score géo est bas (distance élevée)
|
||||
Mais le score intérêts (0.6) peut compenser
|
||||
Et le contenu peut quand même être recommandé si intérêts match
|
||||
|
||||
Scénario: Comparaison scores entre types géo pour même distance
|
||||
Étant donné 3 contenus au même endroit (Paris):
|
||||
| type | ponderation_geo |
|
||||
| Géo-ancré | 0.7 |
|
||||
| Géo-contextuel | 0.5 |
|
||||
| Géo-neutre | 0.2 |
|
||||
Et qu'un utilisateur est à 50 km de Paris
|
||||
Quand l'algorithme calcule les scores
|
||||
Alors le contenu "Géo-ancré" a le score géo le plus élevé
|
||||
Et le contenu "Géo-neutre" a le score géo le plus faible
|
||||
Mais peut avoir un score final plus élevé si forte correspondance intérêts
|
||||
@@ -0,0 +1,100 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Gestion du contenu politique (MVP simplifié)
|
||||
En tant qu'utilisateur
|
||||
Je veux pouvoir filtrer le contenu politique
|
||||
Afin de contrôler mon exposition à ce type de contenu
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
|
||||
Scénario: Créateur tagge son contenu comme "Politique"
|
||||
Étant donné que je suis un créateur connecté
|
||||
Quand je publie un contenu sur un débat politique
|
||||
Et que je sélectionne le tag "Politique"
|
||||
Alors le contenu est enregistré avec le tag "Politique"
|
||||
Et aucune classification gauche/droite n'est demandée (MVP)
|
||||
|
||||
Scénario: Tag "Politique" au même niveau que les autres tags
|
||||
Étant donné que je crée un contenu
|
||||
Quand je consulte la liste des tags disponibles
|
||||
Alors je vois les tags suivants au même niveau:
|
||||
| tag |
|
||||
| Économie |
|
||||
| Sport |
|
||||
| Culture |
|
||||
| Politique |
|
||||
| Automobile |
|
||||
| Voyage |
|
||||
| Musique |
|
||||
|
||||
Scénario: Par défaut, tous les contenus politiques sont visibles
|
||||
Étant donné que je suis un nouvel utilisateur
|
||||
Et que je n'ai pas modifié les paramètres de contenu politique
|
||||
Quand je demande des recommandations
|
||||
Alors les contenus tagués "Politique" sont inclus normalement
|
||||
Et aucun filtrage n'est appliqué
|
||||
|
||||
Scénario: Activer le filtrage "Masquer contenu politique"
|
||||
Étant donné que je suis connecté
|
||||
Quand j'active l'option "Masquer contenu politique" dans les paramètres
|
||||
Alors tous les contenus tagués "Politique" sont exclus de mes recommandations
|
||||
Et je vois le message "Contenu politique masqué"
|
||||
|
||||
Scénario: Filtrage politique actif - aucun contenu politique recommandé
|
||||
Étant donné que j'ai activé "Masquer contenu politique"
|
||||
Et qu'il existe 100 contenus dont 20 tagués "Politique"
|
||||
Quand je demande 50 recommandations
|
||||
Alors je reçois 50 contenus parmi les 80 non-politiques
|
||||
Et 0% de contenus politiques sont proposés
|
||||
|
||||
Scénario: Désactiver le filtrage "Masquer contenu politique"
|
||||
Étant donné que j'ai activé "Masquer contenu politique"
|
||||
Quand je désactive cette option dans les paramètres
|
||||
Alors les contenus politiques sont à nouveau inclus dans mes recommandations
|
||||
Et le filtrage est levé immédiatement
|
||||
|
||||
Scénario: Mode Kids filtre automatiquement le contenu politique
|
||||
Étant donné que je suis un utilisateur de 14 ans
|
||||
Et que le mode Kids est activé
|
||||
Quand je demande des recommandations
|
||||
Alors tous les contenus tagués "Politique" sont automatiquement exclus
|
||||
Et ce indépendamment du paramètre "Masquer contenu politique"
|
||||
|
||||
Scénario: Statistiques créateur sur contenu politique
|
||||
Étant donné que je suis un créateur
|
||||
Et que j'ai publié 20 contenus dont 5 tagués "Politique"
|
||||
Quand je consulte mes statistiques
|
||||
Alors je vois le nombre d'utilisateurs ayant masqué le contenu politique
|
||||
Et le taux d'engagement comparé aux autres tags
|
||||
|
||||
Scénario: Recherche avec tag "Politique"
|
||||
Étant donné que je recherche du contenu
|
||||
Quand je filtre par tag "Politique"
|
||||
Alors seuls les contenus tagués "Politique" sont affichés
|
||||
Et ce même si j'ai activé "Masquer contenu politique" (recherche explicite)
|
||||
|
||||
Scénario: Partage de contenu politique avec filtre actif
|
||||
Étant donné que j'ai activé "Masquer contenu politique"
|
||||
Et qu'un ami me partage un lien vers un contenu tagué "Politique"
|
||||
Quand j'ouvre le lien
|
||||
Alors je peux accéder au contenu (partage explicite)
|
||||
Et je vois un avertissement "Ce contenu est tagué Politique"
|
||||
|
||||
Scénario: Pas de classification gauche/droite en MVP
|
||||
Étant donné que je suis un créateur
|
||||
Quand je publie un contenu tagué "Politique"
|
||||
Alors aucune option de classification idéologique n'est proposée
|
||||
Et je ne peux pas indiquer "Gauche", "Droite", "Centre", etc.
|
||||
|
||||
Scénario: Pas d'équilibrage imposé en MVP
|
||||
Étant donné qu'un utilisateur écoute majoritairement du contenu politique de gauche
|
||||
Quand l'algorithme génère des recommandations
|
||||
Alors aucun équilibrage droite/gauche n'est appliqué
|
||||
Et les recommandations suivent l'algorithme standard (intérêts, géo, engagement)
|
||||
|
||||
Scénario: Notification post-MVP pour classification avancée
|
||||
Étant donné que RoadWave passe en phase post-MVP
|
||||
Et que la classification politique avancée est activée
|
||||
Quand je me connecte
|
||||
Alors je reçois une notification m'informant des nouvelles options
|
||||
Et je peux configurer mes préférences d'équilibrage politique
|
||||
@@ -0,0 +1,327 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Contenus géolocalisés en mode voiture
|
||||
En tant qu'utilisateur en voiture
|
||||
Je veux recevoir des notifications de contenus géolocalisés au bon moment
|
||||
Afin de découvrir du contenu contextuel sans distraction au volant
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
Et que l'application est ouverte (premier plan)
|
||||
Et que le GPS est activé
|
||||
Et que l'utilisateur est en mode voiture (vitesse ≥ 5 km/h)
|
||||
|
||||
# 17.2 - Détection et notification (Calcul ETA)
|
||||
|
||||
Scénario: Calcul ETA et notification 7 secondes avant le point GPS
|
||||
Étant donné qu'un contenu géolocalisé existe à la Tour Eiffel (48.8584, 2.2945)
|
||||
Et que je me déplace à 50 km/h vers ce point
|
||||
Et que je suis à 98 mètres du point (ETA = 7 secondes)
|
||||
Quand le système calcule l'ETA
|
||||
Alors une notification est déclenchée immédiatement
|
||||
Et le compteur "7" s'affiche avec l'icône 🏛️
|
||||
Et une notification sonore (bip court) est jouée
|
||||
|
||||
Plan du Scénario: Calcul ETA à différentes vitesses
|
||||
Étant donné qu'un contenu géolocalisé existe à un point GPS
|
||||
Et que je me déplace à <vitesse> km/h
|
||||
Quand je suis à <distance> mètres du point
|
||||
Alors l'ETA calculé est <eta> secondes
|
||||
Et la notification est déclenchée : <notification>
|
||||
|
||||
Exemples:
|
||||
| vitesse | distance | eta | notification |
|
||||
| 10 | 19 | 7 | Oui |
|
||||
| 50 | 98 | 7 | Oui |
|
||||
| 130 | 252 | 7 | Oui |
|
||||
| 50 | 200 | 14 | Non |
|
||||
| 10 | 50 | 18 | Non |
|
||||
|
||||
Scénario: Notification immédiate si vitesse <5 km/h ET distance <50m
|
||||
Étant donné qu'un contenu géolocalisé existe à 30m de ma position
|
||||
Et que ma vitesse est 3 km/h (arrêté à un feu rouge)
|
||||
Quand le système détecte cette situation
|
||||
Alors une notification est déclenchée immédiatement
|
||||
Et je n'ai pas besoin d'attendre le calcul ETA
|
||||
|
||||
# 17.2.2 - Format de notification minimaliste
|
||||
|
||||
Scénario: Notification minimaliste sans texte (sécurité routière)
|
||||
Étant donné qu'une notification géolocalisée est déclenchée
|
||||
Quand la notification s'affiche
|
||||
Alors les éléments suivants sont visibles:
|
||||
| élément | présent |
|
||||
| Icône du tag | ✅ |
|
||||
| Compteur 7→1 | ✅ |
|
||||
| Son bref (bip) | ✅ |
|
||||
| Titre texte | ❌ |
|
||||
| Description | ❌ |
|
||||
| Cover image | ❌ |
|
||||
| Bouton Annuler | ❌ |
|
||||
|
||||
Plan du Scénario: Icônes selon le tag du contenu
|
||||
Étant donné qu'un contenu géolocalisé avec le tag <tag> est disponible
|
||||
Quand la notification s'affiche
|
||||
Alors l'icône <icone> est affichée
|
||||
|
||||
Exemples:
|
||||
| tag | icone |
|
||||
| Culture générale | 🏛️ |
|
||||
| Histoire | 📜 |
|
||||
| Voyage | ✈️ |
|
||||
| Famille | 👨👩👧 |
|
||||
| Musique | 🎵 |
|
||||
| Sport | ⚽ |
|
||||
| Technologie | 💻 |
|
||||
| Automobile | 🚗 |
|
||||
|
||||
Scénario: Compteur décrémentant de 7 à 1
|
||||
Étant donné qu'une notification géolocalisée s'affiche
|
||||
Quand le compteur démarre
|
||||
Alors le compteur affiche "7"
|
||||
Et après 1 seconde, il affiche "6"
|
||||
Et après 2 secondes, il affiche "5"
|
||||
Et après 3 secondes, il affiche "4"
|
||||
Et après 4 secondes, il affiche "3"
|
||||
Et après 5 secondes, il affiche "2"
|
||||
Et après 6 secondes, il affiche "1"
|
||||
Et après 7 secondes, la notification disparaît
|
||||
|
||||
# 17.2.2b - Conformité CarPlay / Android Auto
|
||||
|
||||
Scénario: Notification sonore uniquement en mode CarPlay
|
||||
Étant donné que l'application est connectée à CarPlay
|
||||
Et qu'un contenu géolocalisé est détecté (ETA 7s)
|
||||
Quand la notification est déclenchée
|
||||
Alors seule la notification sonore (bip) est jouée
|
||||
Et aucun overlay visuel n'est affiché (icône, compteur)
|
||||
Et l'utilisateur peut valider via le bouton "Suivant" au volant
|
||||
|
||||
Scénario: Notification sonore uniquement en mode Android Auto
|
||||
Étant donné que l'application est connectée à Android Auto
|
||||
Et qu'un contenu géolocalisé est détecté (ETA 7s)
|
||||
Quand la notification est déclenchée
|
||||
Alors seule la notification sonore (bip) est jouée
|
||||
Et aucun overlay visuel n'est affiché
|
||||
Et l'utilisateur peut valider via le bouton "Suivant" au volant
|
||||
|
||||
Scénario: Notification complète (sonore + visuelle) en mode normal
|
||||
Étant donné que l'application n'est PAS connectée à CarPlay/Android Auto
|
||||
Et qu'un contenu géolocalisé est détecté
|
||||
Quand la notification est déclenchée
|
||||
Alors la notification sonore (bip) est jouée
|
||||
Et l'overlay visuel s'affiche (icône + compteur 7→1)
|
||||
|
||||
# 17.2.3 - Décompte après validation
|
||||
|
||||
Scénario: Validation via bouton "Suivant" et décompte 5 secondes
|
||||
Étant donné qu'une notification géolocalisée est affichée (compteur à 5)
|
||||
Et que j'écoute un podcast
|
||||
Quand j'appuie sur le bouton "Suivant"
|
||||
Alors le compteur bascule à "5" (décompte final)
|
||||
Et le contenu actuel continue de jouer normalement
|
||||
Et le compteur décrémente: 5→4→3→2→1
|
||||
Et après 5 secondes, le contenu géolocalisé démarre (fade in 0.3s)
|
||||
|
||||
Scénario: Transition fluide avec fade out/in
|
||||
Étant donné que le décompte atteint "0"
|
||||
Quand le contenu géolocalisé doit démarrer
|
||||
Alors le contenu actuel fait un fade out de 0.3s
|
||||
Et le contenu géolocalisé fait un fade in de 0.3s
|
||||
Et il n'y a pas de silence entre les deux
|
||||
|
||||
Scénario: Contenu actuel se termine pendant le décompte
|
||||
Étant donné que j'ai validé la notification (décompte 5s démarre)
|
||||
Et que mon contenu actuel se termine après 2 secondes
|
||||
Quand le contenu actuel se termine
|
||||
Alors le contenu suivant du buffer démarre immédiatement
|
||||
Et le décompte continue (3→2→1)
|
||||
Et à la fin du décompte, le contenu géolocalisé remplace le buffer
|
||||
|
||||
Scénario: Ignorance de la notification (pas de clic pendant 7s)
|
||||
Étant donné qu'une notification géolocalisée s'affiche (compteur 7)
|
||||
Quand 7 secondes s'écoulent sans que j'appuie sur "Suivant"
|
||||
Alors la notification disparaît automatiquement
|
||||
Et le contenu géolocalisé est perdu (pas d'insertion dans la file)
|
||||
Et un cooldown de 10 minutes est activé
|
||||
|
||||
# 17.3 - Limitation anti-spam
|
||||
|
||||
Scénario: Quota de 6 contenus géolocalisés par heure
|
||||
Étant donné que j'ai validé 6 notifications géolocalisées dans la dernière heure
|
||||
Quand un 7ème contenu géolocalisé est détecté
|
||||
Alors aucune notification n'est envoyée
|
||||
Et le contenu n'est pas inséré dans la file
|
||||
|
||||
Scénario: Fenêtre glissante de 60 minutes
|
||||
Étant donné que j'ai validé 6 contenus géolocalisés
|
||||
Et que le premier contenu a été validé il y a 61 minutes
|
||||
Quand un nouveau contenu géolocalisé est détecté
|
||||
Alors la notification est envoyée (quota libéré : 5/6)
|
||||
Et le compteur horaire est mis à jour
|
||||
|
||||
Plan du Scénario: Gestion du quota horaire
|
||||
Étant donné que <nb_valides> notifications ont été validées dans la dernière heure
|
||||
Quand un nouveau contenu géolocalisé est détecté
|
||||
Alors la notification est <action>
|
||||
|
||||
Exemples:
|
||||
| nb_valides | action |
|
||||
| 0 | envoyée |
|
||||
| 3 | envoyée |
|
||||
| 5 | envoyée |
|
||||
| 6 | non envoyée |
|
||||
| 7 | non envoyée |
|
||||
|
||||
Scénario: Exception audio-guides multi-séquences (comptent comme 1)
|
||||
Étant donné que j'ai démarré un audio-guide avec 8 séquences
|
||||
Et que cet audio-guide compte comme 1 contenu dans le quota
|
||||
Quand toutes les séquences de l'audio-guide sont lues
|
||||
Alors mon quota reste à 1/6
|
||||
Et je peux encore valider 5 contenus géolocalisés simples
|
||||
|
||||
# 17.3.2 - Cooldown après ignorance
|
||||
|
||||
Scénario: Cooldown de 10 minutes après notification ignorée
|
||||
Étant donné qu'une notification géolocalisée a été ignorée (pas de clic)
|
||||
Et qu'un cooldown de 10 minutes est activé
|
||||
Quand 5 minutes s'écoulent
|
||||
Et qu'un nouveau contenu géolocalisé est détecté
|
||||
Alors aucune notification n'est envoyée (cooldown actif)
|
||||
|
||||
Scénario: Cooldown expire après 10 minutes
|
||||
Étant donné qu'un cooldown a été activé il y a 10 minutes
|
||||
Quand un nouveau contenu géolocalisé est détecté
|
||||
Alors la notification est envoyée (cooldown expiré)
|
||||
|
||||
Scénario: Pas de cooldown si notification validée
|
||||
Étant donné qu'une notification géolocalisée est affichée
|
||||
Quand j'appuie sur "Suivant" dans les 7 secondes
|
||||
Alors aucun cooldown n'est activé
|
||||
Et la prochaine notification pourra être envoyée normalement
|
||||
|
||||
# 17.4 - Navigation avec contenus géolocalisés
|
||||
|
||||
Scénario: Contenu géolocalisé dans l'historique de navigation
|
||||
Étant donné que j'écoute un contenu du buffer
|
||||
Et que j'ai validé un contenu géolocalisé "Tour Eiffel"
|
||||
Et que j'ai écouté 42 secondes du contenu géolocalisé
|
||||
Quand j'appuie sur "Suivant" (skip)
|
||||
Et que j'appuie ensuite sur "Précédent"
|
||||
Alors le contenu géolocalisé reprend à 42 secondes
|
||||
|
||||
Scénario: Contenu ignoré n'entre pas dans l'historique
|
||||
Étant donné qu'une notification géolocalisée a été ignorée
|
||||
Quand j'appuie sur "Précédent"
|
||||
Alors le contenu géolocalisé ignoré n'apparaît PAS dans l'historique
|
||||
Et je reviens au contenu d'avant
|
||||
|
||||
Scénario: Skip pendant le décompte annule l'insertion
|
||||
Étant donné que j'ai validé une notification (décompte 5s en cours)
|
||||
Et que le compteur affiche "3"
|
||||
Quand j'appuie à nouveau sur "Suivant"
|
||||
Alors le décompte est annulé
|
||||
Et le contenu suivant du buffer démarre
|
||||
Et le contenu géolocalisé n'entre PAS dans l'historique
|
||||
|
||||
# 17.5 - Basculement automatique voiture ↔ piéton
|
||||
|
||||
Scénario: Détection mode piéton (vitesse <5 km/h stable 10s)
|
||||
Étant donné que je suis en mode voiture
|
||||
Et que ma vitesse passe à 3 km/h
|
||||
Quand cette vitesse reste stable pendant 10 secondes
|
||||
Alors le mode piéton est activé automatiquement
|
||||
Et les notifications passent en mode push arrière-plan (si permission accordée)
|
||||
|
||||
Scénario: Détection mode voiture (vitesse ≥5 km/h stable 10s)
|
||||
Étant donné que je suis en mode piéton
|
||||
Et que ma vitesse passe à 15 km/h
|
||||
Quand cette vitesse reste stable pendant 10 secondes
|
||||
Alors le mode voiture est activé automatiquement
|
||||
Et les notifications passent en mode sonore + icône (app premier plan requise)
|
||||
|
||||
Scénario: Hysteresis pour éviter basculements intempestifs
|
||||
Étant donné que ma vitesse passe de 20 km/h à 3 km/h (arrêt feu rouge)
|
||||
Et que ma vitesse remonte à 20 km/h après 8 secondes
|
||||
Quand le système vérifie le mode
|
||||
Alors aucun basculement n'a lieu (hysteresis de 10s non atteinte)
|
||||
Et je reste en mode voiture
|
||||
|
||||
Plan du Scénario: Effets du basculement voiture → piéton
|
||||
Étant donné que je bascule de voiture à piéton
|
||||
Quand le basculement est effectué
|
||||
Alors les paramètres suivants changent:
|
||||
| paramètre | voiture | piéton |
|
||||
| App requise | Premier plan | Arrière-plan OK |
|
||||
| Notification | Sonore + icône + compteur| Push système |
|
||||
| Rayon détection | ETA 7s (variable) | 200m fixes |
|
||||
| Type contenu | Tous géolocalisés | Audio-guides uniquement |
|
||||
|
||||
# 17.6 - Edge cases
|
||||
|
||||
Scénario: Haute vitesse (130 km/h sur autoroute)
|
||||
Étant donné que je roule à 130 km/h (36.1 m/s)
|
||||
Et qu'un contenu géolocalisé est à 252 mètres
|
||||
Quand l'ETA de 7s est atteint
|
||||
Et que je valide la notification
|
||||
Alors le décompte 5s démarre
|
||||
Et le contenu géolocalisé démarre encore avant le point GPS (72m avant)
|
||||
|
||||
Scénario: Multiples points géolocalisés proches (route touristique)
|
||||
Étant donné que 3 châteaux sont espacés de 800m chacun
|
||||
Et que je valide la notification du Château A
|
||||
Quand j'arrive près du Château B (57s plus tard à 50 km/h)
|
||||
Alors la notification du Château B est envoyée (quota 2/6, pas de cooldown)
|
||||
|
||||
Scénario: Mode stationnement (vitesse <1 km/h pendant 2 min)
|
||||
Étant donné que je me gare à 30m d'un château
|
||||
Et que ma vitesse est <1 km/h pendant 2 minutes
|
||||
Quand le mode stationnement est détecté
|
||||
Alors aucune notification de contenu géolocalisé n'est envoyée
|
||||
Et le système bascule automatiquement en mode piéton
|
||||
|
||||
Scénario: Reprise conduite après stationnement
|
||||
Étant donné que je suis en mode stationnement
|
||||
Et que ma vitesse passe à 20 km/h pendant 10 secondes
|
||||
Quand le système détecte la reprise de conduite
|
||||
Alors le mode voiture est réactivé
|
||||
Et les notifications géolocalisées reprennent (si quota non atteint)
|
||||
|
||||
# Distinction contenus géolocalisés simples vs audio-guides
|
||||
|
||||
Scénario: Contenu géolocalisé simple (1 séquence unique)
|
||||
Étant donné qu'un contenu géolocalisé simple existe à un point GPS
|
||||
Quand la notification est déclenchée (ETA 7s)
|
||||
Et que je valide
|
||||
Alors le contenu démarre après décompte 5s
|
||||
Et à la fin du contenu, le buffer normal reprend
|
||||
Et ce contenu compte 1/6 dans le quota
|
||||
|
||||
Scénario: Audio-guide multi-séquences (2+ séquences enchaînées)
|
||||
Étant donné qu'un audio-guide avec 8 séquences existe
|
||||
Quand je démarre l'audio-guide
|
||||
Et que les séquences s'enchaînent automatiquement (GPS ou manuel)
|
||||
Alors l'audio-guide entier compte 1/6 dans le quota
|
||||
Et les séquences ne déclenchent PAS de notification avec compteur 7s
|
||||
Et elles se déclenchent au point GPS exact (rayon 30m)
|
||||
|
||||
# Gestion erreurs
|
||||
|
||||
Scénario: GPS désactivé en mode voiture
|
||||
Étant donné que je suis en mode voiture
|
||||
Quand le GPS est désactivé
|
||||
Alors aucune notification géolocalisée ne peut être envoyée
|
||||
Et un message d'erreur s'affiche: "GPS requis pour les contenus géolocalisés"
|
||||
|
||||
Scénario: App en arrière-plan en mode voiture
|
||||
Étant donné que je suis en mode voiture
|
||||
Et que l'app passe en arrière-plan
|
||||
Quand un contenu géolocalisé est détecté
|
||||
Alors aucune notification n'est envoyée (app premier plan requise)
|
||||
Et le contenu n'est pas perdu (sera proposé si app rouverte dans le rayon)
|
||||
|
||||
Scénario: Permission "Always Location" refusée (mode piéton indisponible)
|
||||
Étant donné que je refuse la permission "Always Location"
|
||||
Quand ma vitesse passe <5 km/h
|
||||
Alors le mode piéton n'est PAS activé
|
||||
Et le mode voiture reste actif (avec permission "When In Use")
|
||||
Et aucune notification arrière-plan n'est envoyée
|
||||
@@ -0,0 +1,140 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Gestion de l'historique et reproposition
|
||||
En tant que système de recommandation
|
||||
Je veux gérer l'historique d'écoute intelligemment
|
||||
Afin d'éviter les répétitions et offrir une découverte maximale
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
|
||||
Scénario: Contenu écouté complètement (>80%) - jamais reproposé
|
||||
Étant donné qu'un utilisateur a écouté un contenu à 85%
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors ce contenu n'est jamais reproposé
|
||||
Et il est marqué comme "écouté" dans l'historique
|
||||
|
||||
Scénario: Contenu écouté à 80% exactement - jamais reproposé
|
||||
Étant donné qu'un utilisateur a écouté un contenu exactement à 80%
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors ce contenu n'est pas reproposé (seuil >= 80%)
|
||||
|
||||
Scénario: Contenu skippé rapidement (<10s) - ne pas reproposer
|
||||
Étant donné qu'un utilisateur a skippé un contenu après 8 secondes
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors ce contenu n'est pas reproposé (signal négatif fort)
|
||||
Et la jauge d'intérêt correspondante est réduite de 0.5%
|
||||
|
||||
Scénario: Contenu skippé exactement à 10s - ne pas reproposer
|
||||
Étant donné qu'un utilisateur a skippé un contenu après exactement 10 secondes
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors ce contenu n'est pas reproposé (seuil < 10s strict)
|
||||
|
||||
Scénario: Contenu partiellement écouté (10-80%) - reproposer avec reprise
|
||||
Étant donné qu'un utilisateur a écouté un contenu à 45%
|
||||
Et qu'il est arrivé à la position 2:30 (150 secondes)
|
||||
Quand l'algorithme propose à nouveau ce contenu
|
||||
Alors le contenu peut être reproposé
|
||||
Et la position de reprise est 150 secondes
|
||||
Et l'utilisateur voit "Reprendre à 2:30"
|
||||
|
||||
Scénario: Contenu écouté à 11% - reproposition possible
|
||||
Étant donné qu'un utilisateur a écouté un contenu à 11%
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors ce contenu peut être reproposé (>10%)
|
||||
Et la position de reprise est sauvegardée
|
||||
|
||||
Scénario: Contenu écouté à 79% - reproposition possible
|
||||
Étant donné qu'un utilisateur a écouté un contenu à 79%
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors ce contenu peut être reproposé (<80%)
|
||||
Et l'utilisateur peut terminer l'écoute
|
||||
|
||||
Scénario: Audio-guide avec flag replayable=true
|
||||
Étant donné qu'un audio-guide a le flag "replayable = true"
|
||||
Et qu'un utilisateur l'a écouté à 95%
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors l'audio-guide peut être reproposé
|
||||
Et il est marqué comme "Écouté - Rejouable"
|
||||
|
||||
Scénario: Podcast standard sans flag replayable
|
||||
Étant donné qu'un podcast n'a pas de flag replayable
|
||||
Et qu'un utilisateur l'a écouté à 90%
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors le podcast n'est jamais reproposé
|
||||
|
||||
Scénario: Stockage dans user_content_history
|
||||
Étant donné qu'un utilisateur écoute un contenu
|
||||
Quand l'écoute se termine ou est skippée
|
||||
Alors les données suivantes sont enregistrées:
|
||||
| champ | exemple |
|
||||
| user_id | user-123 |
|
||||
| content_id | content-456 |
|
||||
| completion_rate | 0.45 (45%) |
|
||||
| last_position | 150 (secondes) |
|
||||
| listened_at | 2026-01-21 14:30:00 |
|
||||
|
||||
Scénario: Historique illimité stocké
|
||||
Étant donné qu'un utilisateur a écouté 5000 contenus
|
||||
Quand il consulte son historique
|
||||
Alors tous les 5000 contenus sont disponibles
|
||||
Et aucun contenu n'est supprimé automatiquement
|
||||
|
||||
Scénario: Algorithme considère les 100 derniers pour performance
|
||||
Étant donné qu'un utilisateur a écouté 500 contenus
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors il vérifie uniquement les 100 derniers contenus pour exclusion
|
||||
Et cette limite est une optimisation de requête SQL
|
||||
|
||||
Scénario: Export historique complet (RGPD)
|
||||
Étant donné qu'un utilisateur demande l'export RGPD
|
||||
Quand l'export est généré
|
||||
Alors l'historique complet est inclus avec:
|
||||
| information | inclus |
|
||||
| Tous les contenus | ✅ |
|
||||
| Dates d'écoute | ✅ |
|
||||
| Taux complétion | ✅ |
|
||||
| Positions reprise | ✅ |
|
||||
|
||||
Scénario: Reprise automatique d'un contenu partiellement écouté
|
||||
Étant donné que j'ai écouté un podcast à 60% (position 10:00)
|
||||
Quand ce podcast est reproposé par l'algorithme
|
||||
Et que je lance la lecture
|
||||
Alors l'écoute reprend automatiquement à 10:00
|
||||
Et je vois une notification "Reprise à 10:00"
|
||||
|
||||
Scénario: Option "Reprendre du début" pour contenu partiellement écouté
|
||||
Étant donné que j'ai écouté un podcast à 60%
|
||||
Quand ce podcast est reproposé
|
||||
Alors je vois deux options:
|
||||
| option | action |
|
||||
| Reprendre à 10:00 | Lecture à partir de 10:00 |
|
||||
| Depuis le début | Lecture à partir de 0:00 |
|
||||
|
||||
Scénario: Contenu écouté il y a 6 mois - toujours en historique
|
||||
Étant donné qu'un utilisateur a écouté un contenu il y a 6 mois à 90%
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors ce contenu n'est toujours pas reproposé
|
||||
Et l'historique n'a pas de limite temporelle
|
||||
|
||||
Scénario: Nouveau contenu du même créateur après écoute complète
|
||||
Étant donné qu'un utilisateur a écouté un contenu de "Créateur A" à 90%
|
||||
Et que "Créateur A" publie un nouveau contenu
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors le nouveau contenu peut être recommandé
|
||||
Et seul l'ancien contenu est exclu (pas tout le créateur)
|
||||
|
||||
Scénario: Statistiques personnelles d'historique
|
||||
Étant donné que je consulte mon profil
|
||||
Quand j'accède à la section "Historique"
|
||||
Alors je vois:
|
||||
| métrique | exemple |
|
||||
| Nombre total d'écoutes | 1,234 |
|
||||
| Heures écoutées | 456h |
|
||||
| Taux complétion moyen | 72% |
|
||||
| Top 5 catégories | Voyage, Sport |
|
||||
|
||||
Scénario: Filtrer l'historique par date
|
||||
Étant donné que je consulte mon historique
|
||||
Quand je filtre par "Dernière semaine"
|
||||
Alors seuls les contenus écoutés dans les 7 derniers jours sont affichés
|
||||
Et je peux exporter cette sélection
|
||||
@@ -0,0 +1,162 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Médias traditionnels sur RoadWave
|
||||
En tant que média établi
|
||||
Je veux publier du contenu géolocalisé sur RoadWave
|
||||
Afin d'atteindre une audience locale et mobile
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
|
||||
Scénario: Création d'un compte média vérifié
|
||||
Étant donné que je représente Le Monde
|
||||
Quand je crée un compte média
|
||||
Et que je fournis les justificatifs (SIRET, documents officiels)
|
||||
Alors mon compte est créé en attente de vérification
|
||||
Et l'équipe RoadWave examine ma demande sous 48-72h
|
||||
|
||||
Scénario: Validation compte média par l'équipe RoadWave
|
||||
Étant donné qu'un compte média "Le Parisien" est en attente
|
||||
Quand l'équipe RoadWave valide le compte
|
||||
Alors le compte reçoit le badge vérifié ✓
|
||||
Et le média peut publier sans validation des 3 premiers contenus
|
||||
Et je vois le message "Compte média vérifié avec succès"
|
||||
|
||||
Scénario: Badge vérifié visible sur profil média
|
||||
Étant donné que "France Inter" a un compte vérifié
|
||||
Quand un utilisateur consulte le profil
|
||||
Alors il voit le badge ✓ à côté du nom
|
||||
Et une mention "Média vérifié"
|
||||
|
||||
Scénario: Pas de validation des 3 premiers contenus pour médias
|
||||
Étant donné que je suis un média vérifié
|
||||
Quand je publie mon premier contenu
|
||||
Alors le contenu est publié immédiatement sans validation
|
||||
Et il est visible pour tous les utilisateurs
|
||||
Et je ne passe pas par la modération initiale
|
||||
|
||||
Scénario: Modération a posteriori uniquement
|
||||
Étant donné que "Libération" publie un contenu
|
||||
Quand le contenu est publié
|
||||
Alors il est immédiatement disponible
|
||||
Mais peut être signalé et modéré a posteriori
|
||||
Et suit les mêmes règles de modération que les créateurs
|
||||
|
||||
Scénario: Publication flash info géolocalisé
|
||||
Étant donné que je suis "Ouest-France" (média régional)
|
||||
Quand je publie un flash info sur un événement à Rennes
|
||||
Et que je le géolocalise en Bretagne (géo-contextuel)
|
||||
Alors le contenu est publié immédiatement
|
||||
Et il est recommandé aux utilisateurs en Bretagne
|
||||
|
||||
Scénario: Publication chronique thématique
|
||||
Étant donné que je suis "France Culture"
|
||||
Quand je publie une chronique philosophie (géo-neutre)
|
||||
Alors le contenu est disponible partout en France
|
||||
Et suit l'algorithme de recommandation standard
|
||||
|
||||
Scénario: Publication édito politique
|
||||
Étant donné que je suis "Le Figaro"
|
||||
Quand je publie un édito politique
|
||||
Et que je le tague "Politique"
|
||||
Alors le contenu est publié immédiatement
|
||||
Et la classification politique MVP s'applique (pas gauche/droite)
|
||||
Et les utilisateurs ayant activé "Masquer politique" ne le voient pas
|
||||
|
||||
Scénario: Formats de contenu autorisés pour médias
|
||||
Étant donné que je suis un média vérifié
|
||||
Quand je publie du contenu
|
||||
Alors je peux publier:
|
||||
| format | exemple |
|
||||
| Flash info géolocalisé | Actualité régionale 2-5 min |
|
||||
| Chronique thématique | Culture, économie, sport 5-15min|
|
||||
| Édito et débats | Opinion 10-30 min |
|
||||
| Reportage | Investigation 15-45 min |
|
||||
|
||||
Scénario: Médias suivent les règles standard de classification âge
|
||||
Étant donné que je suis "RTL"
|
||||
Quand je publie un contenu sensible
|
||||
Alors je dois obligatoirement classifier par âge:
|
||||
| classification | type contenu |
|
||||
| Tout public | Info générale |
|
||||
| 13+ | Actualité avec sujets sensibles |
|
||||
| 16+ | Débats avec violence verbale |
|
||||
| 18+ | Sujets adultes |
|
||||
|
||||
Scénario: Monétisation médias - partage revenus pub standard
|
||||
Étant donné que je suis un média vérifié
|
||||
Et que mes contenus génèrent des écoutes
|
||||
Quand le mois se termine
|
||||
Alors je reçois 3€ / 1000 écoutes complètes (même taux que créateurs)
|
||||
Et le paiement suit les mêmes règles (seuil 50€, mensuel)
|
||||
|
||||
Scénario: Sponsoring direct non géré par plateforme
|
||||
Étant donné que je suis "Europe 1"
|
||||
Et que je veux intégrer un sponsor dans mon contenu
|
||||
Quand je mentionne le sponsor dans l'audio
|
||||
Alors c'est autorisé (sponsoring éditorial)
|
||||
Mais RoadWave ne gère pas la transaction
|
||||
Et je gère la relation sponsor directement
|
||||
|
||||
Scénario: Médias peuvent avoir plusieurs comptes créateurs
|
||||
Étant donné que je suis "Le Monde"
|
||||
Quand je veux créer des sous-comptes par rubrique
|
||||
Alors je peux créer:
|
||||
| compte | description |
|
||||
| @lemonde_politique | Actualité politique |
|
||||
| @lemonde_economie | Économie et entreprises |
|
||||
| @lemonde_culture | Culture et spectacles |
|
||||
Et tous sont liés au compte média principal
|
||||
|
||||
Scénario: Médias régionaux privilégiés localement
|
||||
Étant donné que "Sud-Ouest" publie du contenu géo-contextuel en Nouvelle-Aquitaine
|
||||
Et qu'un utilisateur est à Bordeaux
|
||||
Quand l'algorithme calcule les recommandations
|
||||
Alors le contenu de "Sud-Ouest" a un score géo élevé
|
||||
Et il est privilégié pour l'audience locale
|
||||
|
||||
Scénario: Médias nationaux accessibles partout
|
||||
Étant donné que "France Inter" publie un podcast géo-neutre
|
||||
Quand des utilisateurs à Paris, Lyon, Marseille demandent des recommandations
|
||||
Alors le podcast est accessible partout sans distinction géographique
|
||||
Et suit l'algorithme de recommandation standard
|
||||
|
||||
Scénario: Statistiques détaillées pour médias
|
||||
Étant donné que je suis un média vérifié
|
||||
Quand je consulte mes statistiques
|
||||
Alors je vois:
|
||||
| métrique | exemple |
|
||||
| Écoutes par région | Île-de-France: 45% |
|
||||
| Taux complétion | 72% |
|
||||
| Démographie auditeurs | 25-34 ans: 35% |
|
||||
| Top contenus | Flash info Paris|
|
||||
| Revenus générés | 1,234€ |
|
||||
|
||||
Scénario: Médias peuvent exporter analytics
|
||||
Étant donné que je suis "Libération"
|
||||
Quand je clique sur "Exporter analytics"
|
||||
Alors je reçois un CSV avec données détaillées
|
||||
Et je peux analyser les données avec mes outils internes
|
||||
|
||||
Scénario: Contact prioritaire équipe RoadWave
|
||||
Étant donné que je suis un média vérifié
|
||||
Quand j'ai un problème technique ou question
|
||||
Alors je peux contacter le support média prioritaire
|
||||
Et j'obtiens une réponse sous 24h (vs 48-72h standard)
|
||||
|
||||
Scénario: Médias peuvent programmer la publication
|
||||
Étant donné que je suis "France Culture"
|
||||
Quand je prépare un contenu à l'avance
|
||||
Alors je peux programmer la publication pour une date/heure future
|
||||
Et le contenu sera publié automatiquement au moment choisi
|
||||
|
||||
Scénario: API dédiée pour médias (post-MVP)
|
||||
Étant donné que je suis un grand média avec beaucoup de contenus
|
||||
Quand RoadWave développe l'API médias
|
||||
Alors je peux automatiser la publication via API
|
||||
Et intégrer RoadWave dans mon workflow de production
|
||||
|
||||
Scénario: Signalement d'un contenu média traité en priorité
|
||||
Étant donné qu'un contenu de "Le Monde" est signalé
|
||||
Quand le signalement arrive en modération
|
||||
Alors il est traité avec la même priorité qu'un créateur standard
|
||||
Et le badge vérifié ne donne pas d'immunité modération
|
||||
@@ -0,0 +1,115 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Mode Kids pour utilisateurs 13-15 ans
|
||||
En tant que parent ou adolescent
|
||||
Je veux activer un mode Kids avec filtrage de contenu
|
||||
Afin de protéger les mineurs des contenus inappropriés
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
|
||||
Scénario: Activation manuelle du mode Kids
|
||||
Étant donné que je suis un utilisateur de 14 ans
|
||||
Et que le mode Kids n'est pas activé par défaut
|
||||
Quand j'active le mode Kids dans les paramètres
|
||||
Alors le mode Kids est activé sur mon compte
|
||||
Et je vois le message "Mode Kids activé - Contenus filtrés pour 13-15 ans"
|
||||
|
||||
Scénario: Parent active le mode Kids pour son enfant
|
||||
Étant donné que je suis le parent d'un utilisateur de 13 ans
|
||||
Et que j'ai accès au compte de mon enfant
|
||||
Quand j'active le mode Kids
|
||||
Alors le mode Kids est activé sur le compte enfant
|
||||
Et seuls les contenus "Tous publics" sont accessibles
|
||||
|
||||
Scénario: Filtrage contenu - uniquement "Tous publics"
|
||||
Étant donné que le mode Kids est activé sur mon compte
|
||||
Et qu'il existe des contenus avec les classifications:
|
||||
| classification | nombre |
|
||||
| Tous publics | 100 |
|
||||
| 13+ | 50 |
|
||||
| 16+ | 30 |
|
||||
| 18+ | 20 |
|
||||
Quand je demande des recommandations
|
||||
Alors seuls les 100 contenus "Tous publics" sont proposés
|
||||
Et les contenus 13+, 16+, 18+ sont exclus
|
||||
|
||||
Scénario: Exclusion automatique du contenu politique
|
||||
Étant donné que le mode Kids est activé
|
||||
Et qu'il existe 20 contenus "Tous publics" dont 5 tagués "Politique"
|
||||
Quand je demande des recommandations
|
||||
Alors seuls les 15 contenus non-politiques sont proposés
|
||||
Et les 5 contenus politiques sont automatiquement exclus
|
||||
|
||||
Scénario: Pas de publicité en mode Kids
|
||||
Étant donné que le mode Kids est activé
|
||||
Et que je suis un utilisateur gratuit
|
||||
Quand j'écoute du contenu
|
||||
Alors aucune publicité n'est diffusée
|
||||
Et je n'ai pas d'insertion publicitaire (règle 1/5 désactivée)
|
||||
|
||||
Scénario: Publicité validée manuellement en mode Kids (post-MVP)
|
||||
Étant donné que le mode Kids est activé
|
||||
Et qu'une publicité a été validée manuellement pour le mode Kids
|
||||
Quand j'écoute du contenu
|
||||
Alors cette publicité peut être diffusée
|
||||
Mais la fréquence reste inférieure au mode standard
|
||||
|
||||
Scénario: Interface standard même en mode Kids
|
||||
Étant donné que le mode Kids est activé
|
||||
Quand j'ouvre l'application
|
||||
Alors l'interface est identique au mode normal
|
||||
Et seul le filtrage de contenu est actif (pas d'UI enfant)
|
||||
|
||||
Scénario: Désactivation du mode Kids
|
||||
Étant donné que le mode Kids est activé
|
||||
Quand je désactive le mode Kids dans les paramètres
|
||||
Alors tous les contenus sont à nouveau accessibles selon mon âge
|
||||
Et je vois le message "Mode Kids désactivé"
|
||||
|
||||
Scénario: Utilisateur 16 ans ne peut pas activer le mode Kids 13-15 ans
|
||||
Étant donné que je suis un utilisateur de 16 ans
|
||||
Quand j'essaie d'activer le mode Kids
|
||||
Alors l'activation réussit
|
||||
Et le mode Kids filtre les contenus 16+ et 18+ (pas seulement 13+)
|
||||
Et je vois uniquement les contenus "Tous publics"
|
||||
|
||||
Scénario: Tentative d'accès direct à contenu 16+ en mode Kids
|
||||
Étant donné que le mode Kids est activé
|
||||
Et qu'un ami me partage un contenu 16+
|
||||
Quand j'essaie d'accéder au contenu via le lien
|
||||
Alors l'accès est refusé
|
||||
Et je vois le message "Ce contenu n'est pas accessible en mode Kids"
|
||||
|
||||
Scénario: Recherche en mode Kids filtre automatiquement
|
||||
Étant donné que le mode Kids est activé
|
||||
Quand je recherche "débat"
|
||||
Alors seuls les contenus "Tous publics" apparaissent dans les résultats
|
||||
Et les contenus 13+, 16+, 18+ sont exclus de la recherche
|
||||
|
||||
Scénario: Audio-guide en mode Kids
|
||||
Étant donné que le mode Kids est activé
|
||||
Et qu'un audio-guide "Tous publics" existe au musée du Louvre
|
||||
Quand je suis à proximité du Louvre
|
||||
Alors l'audio-guide est proposé normalement
|
||||
Et toutes les séquences sont accessibles
|
||||
|
||||
Scénario: Statistiques créateur - audience mode Kids
|
||||
Étant donné que je suis un créateur
|
||||
Et que mes contenus "Tous publics" sont écoutés par des utilisateurs mode Kids
|
||||
Quand je consulte mes statistiques
|
||||
Alors je vois le pourcentage d'écoutes en mode Kids
|
||||
Et je peux adapter mes contenus en conséquence
|
||||
|
||||
Scénario: Notification lors de l'activation du mode Kids
|
||||
Quand j'active le mode Kids
|
||||
Alors je reçois une notification explicative:
|
||||
| information | description |
|
||||
| Contenu | Seuls les contenus "Tous publics" accessibles |
|
||||
| Politique | Contenus politiques automatiquement masqués |
|
||||
| Publicité | Aucune publicité affichée |
|
||||
|
||||
Scénario: Badge mode Kids visible dans le profil
|
||||
Étant donné que le mode Kids est activé
|
||||
Quand je consulte mon profil
|
||||
Alors je vois un badge "Mode Kids actif 🛡️"
|
||||
Et je peux le désactiver en un clic
|
||||
@@ -0,0 +1,163 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Paramétrabilité admin et A/B testing
|
||||
En tant qu'administrateur RoadWave
|
||||
Je veux configurer les paramètres de l'algorithme à chaud
|
||||
Afin d'optimiser l'engagement sans redéploiement
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
Et que je suis connecté en tant qu'admin
|
||||
|
||||
Scénario: Accès au dashboard admin
|
||||
Quand j'accède au dashboard admin
|
||||
Alors je vois tous les paramètres configurables de l'algorithme
|
||||
Et je vois les valeurs actuelles et par défaut
|
||||
|
||||
Scénario: Modifier le poids géo pour contenu ancré
|
||||
Étant donné que le poids_geo_ancre est à 0.7 (défaut)
|
||||
Quand je modifie le poids_geo_ancre à 0.8
|
||||
Et que je sauvegarde
|
||||
Alors la nouvelle valeur est appliquée immédiatement
|
||||
Et tous les nouveaux calculs utilisent 0.8
|
||||
Et je vois le message "Paramètre mis à jour avec succès"
|
||||
|
||||
Scénario: Validation des plages de valeurs
|
||||
Quand j'essaie de configurer poids_geo_ancre à 1.5 (hors plage 0.5-1.0)
|
||||
Alors la modification échoue
|
||||
Et je vois le message "Valeur hors plage autorisée (0.5 - 1.0)"
|
||||
|
||||
Plan du Scénario: Modification de tous les paramètres configurables
|
||||
Quand je modifie "<parametre>" à "<nouvelle_valeur>"
|
||||
Alors la modification est appliquée immédiatement
|
||||
Et la nouvelle valeur respecte la plage "<plage>"
|
||||
|
||||
Exemples:
|
||||
| parametre | nouvelle_valeur | plage |
|
||||
| poids_geo_ancre | 0.8 | 0.5 - 1.0 |
|
||||
| poids_geo_contextuel | 0.6 | 0.3 - 0.7 |
|
||||
| poids_geo_neutre | 0.3 | 0.0 - 0.4 |
|
||||
| poids_engagement | 0.3 | 0.0 - 0.5 |
|
||||
| part_aleatoire_global | 0.15 | 0.0 - 0.3 |
|
||||
| distance_max_km | 150 | 50 - 500 |
|
||||
| rayon_gps_point_m | 1000 | 100 - 2000 |
|
||||
| seuil_min_ecoutes_engagement | 100 | 10 - 200 |
|
||||
|
||||
Scénario: Aucun recalcul batch après modification
|
||||
Étant donné que le poids_geo_ancre est modifié de 0.7 à 0.8
|
||||
Quand la modification est appliquée
|
||||
Alors aucun recalcul batch n'est lancé (économie CPU)
|
||||
Et seuls les nouveaux calculs utilisent la valeur 0.8
|
||||
|
||||
Scénario: Versioning des configurations
|
||||
Étant donné que je modifie plusieurs paramètres
|
||||
Quand je sauvegarde la configuration
|
||||
Alors une nouvelle version est créée (ex: v1.2.3)
|
||||
Et je peux voir l'historique des versions
|
||||
Et je peux comparer deux versions
|
||||
|
||||
Scénario: Rollback en 1 clic
|
||||
Étant donné que la configuration actuelle est v1.2.3
|
||||
Et que la version précédente était v1.2.2
|
||||
Quand je clique sur "Restaurer v1.2.2"
|
||||
Alors tous les paramètres de v1.2.2 sont réappliqués
|
||||
Et je vois le message "Configuration restaurée à v1.2.2"
|
||||
|
||||
Scénario: Créer une variante A/B testing
|
||||
Quand je crée une nouvelle variante "Test engagement élevé"
|
||||
Et que je configure:
|
||||
| parametre | valeur |
|
||||
| poids_engagement | 0.4 |
|
||||
| poids_geo_ancre | 0.6 |
|
||||
Et que je lance le test A/B
|
||||
Alors 50% des utilisateurs reçoivent la Config A (défaut)
|
||||
Et 50% des utilisateurs reçoivent la Config B (test)
|
||||
|
||||
Scénario: Split utilisateurs aléatoire pour A/B test
|
||||
Étant donné qu'un test A/B est actif
|
||||
Quand 1000 nouveaux utilisateurs se connectent
|
||||
Alors environ 500 sont assignés à la Config A
|
||||
Et environ 500 sont assignés à la Config B
|
||||
Et l'assignation est aléatoire et équilibrée
|
||||
|
||||
Scénario: Utilisateur reste dans la même variante
|
||||
Étant donné qu'un utilisateur est assigné à la Config B
|
||||
Quand il se reconnecte plusieurs fois
|
||||
Alors il reste toujours dans la Config B
|
||||
Et il ne change pas de variante pendant le test
|
||||
|
||||
Scénario: Métriques comparatives A/B testing
|
||||
Étant donné qu'un test A/B est actif depuis 7 jours
|
||||
Quand je consulte le dashboard A/B testing
|
||||
Alors je vois les métriques suivantes pour chaque config:
|
||||
| metrique | Config A | Config B |
|
||||
| Taux complétion moyen | 68% | 72% |
|
||||
| Engagement (likes) | 15% | 18% |
|
||||
| Durée session moyenne | 23 min | 27 min |
|
||||
| Taux skip rapide (<10s) | 12% | 9% |
|
||||
|
||||
Scénario: Dashboard graphique temps réel
|
||||
Étant donné qu'un test A/B est actif
|
||||
Quand je consulte le dashboard
|
||||
Alors je vois des graphiques temps réel:
|
||||
| graphique | type |
|
||||
| Évolution engagement | Ligne |
|
||||
| Répartition utilisateurs| Camembert |
|
||||
| Taux complétion | Barres |
|
||||
| Durée session | Ligne |
|
||||
|
||||
Scénario: Terminer un test A/B et appliquer la meilleure config
|
||||
Étant donné qu'un test A/B montre que Config B est meilleure
|
||||
Quand je clique sur "Appliquer Config B pour tous"
|
||||
Alors la Config B devient la configuration par défaut
|
||||
Et tous les utilisateurs utilisent maintenant Config B
|
||||
Et l'ancien test est archivé
|
||||
|
||||
Scénario: Audit engagement global
|
||||
Quand je consulte la section "Audit engagement"
|
||||
Alors je vois:
|
||||
| metrique | valeur |
|
||||
| Temps écoute moyen/session | 25 min |
|
||||
| Temps écoute médian/session | 18 min |
|
||||
| Taux complétion moyen | 70% |
|
||||
| % sessions avec ≥1 like | 35% |
|
||||
|
||||
Scénario: Graphiques évolution engagement selon config
|
||||
Étant donné que plusieurs modifications de config ont été faites
|
||||
Quand je consulte les graphiques d'évolution
|
||||
Alors je vois l'impact de chaque changement de config
|
||||
Et je peux corréler changements config avec métriques
|
||||
|
||||
Scénario: Export CSV pour analyse externe
|
||||
Quand je clique sur "Exporter données"
|
||||
Alors je peux télécharger un CSV avec:
|
||||
| colonne | exemple |
|
||||
| date | 2026-01-21 |
|
||||
| version_config | v1.2.3 |
|
||||
| taux_completion | 0.72 |
|
||||
| engagement_moyen | 0.45 |
|
||||
| duree_session_min | 27 |
|
||||
|
||||
Scénario: Alerte automatique si métrique critique baisse
|
||||
Étant donné que le taux de complétion moyen est à 70%
|
||||
Quand une nouvelle config fait baisser le taux à 55%
|
||||
Alors je reçois une alerte email "Baisse critique du taux de complétion"
|
||||
Et je peux rollback rapidement
|
||||
|
||||
Scénario: Prévisualisation impact avant application
|
||||
Étant donné que je modifie poids_geo_ancre de 0.7 à 0.9
|
||||
Quand je clique sur "Prévisualiser impact"
|
||||
Alors je vois une simulation sur échantillon de 1000 utilisateurs
|
||||
Et je vois l'estimation d'impact sur les métriques clés
|
||||
|
||||
Scénario: Notes et commentaires sur modifications config
|
||||
Quand je modifie une configuration
|
||||
Alors je peux ajouter une note "Test pour améliorer contenu local"
|
||||
Et cette note est visible dans l'historique des versions
|
||||
Et l'équipe peut comprendre le contexte des changements
|
||||
|
||||
Scénario: Permissions admin pour modification config
|
||||
Étant donné que je suis un admin junior
|
||||
Quand j'essaie de modifier un paramètre critique
|
||||
Alors l'accès est refusé
|
||||
Et je vois "Permission admin senior requise"
|
||||
Et seuls les admins seniors peuvent modifier les paramètres
|
||||
@@ -0,0 +1,188 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Paramétrabilité utilisateur et profils
|
||||
En tant qu'utilisateur
|
||||
Je veux personnaliser mon expérience de recommandation
|
||||
Afin d'adapter l'application à mes différents contextes d'usage
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
Et que je suis connecté
|
||||
|
||||
Scénario: Accès aux paramètres de personnalisation
|
||||
Quand j'ouvre les paramètres de personnalisation
|
||||
Alors je vois trois curseurs disponibles:
|
||||
| curseur | description |
|
||||
| Géolocalisation | Local ← slider → National |
|
||||
| Découverte | 0% ← slider → 50% |
|
||||
| Politique | Masquer / Équilibré / Mes préférences |
|
||||
|
||||
Scénario: Modifier le curseur Géolocalisation vers Local
|
||||
Étant donné que le curseur Géolocalisation est au centre (défaut)
|
||||
Quand je déplace le curseur vers "Local" (gauche)
|
||||
Alors l'algorithme privilégie fortement les contenus proches
|
||||
Et la pondération géographique augmente
|
||||
Et je vois le message "Recommandations locales privilégiées"
|
||||
|
||||
Scénario: Modifier le curseur Géolocalisation vers National
|
||||
Étant donné que le curseur Géolocalisation est au centre
|
||||
Quand je déplace le curseur vers "National" (droite)
|
||||
Alors l'algorithme privilégie la découverte nationale
|
||||
Et la pondération géographique diminue
|
||||
Et je reçois des contenus de toute la France
|
||||
|
||||
Scénario: Curseur Découverte à 0% - aucun aléatoire
|
||||
Quand je règle le curseur Découverte à 0%
|
||||
Alors 0% de contenus aléatoires dans mes recommandations
|
||||
Et 100% de contenus calculés selon score combiné
|
||||
Et je vois le message "Personnalisation maximale"
|
||||
|
||||
Scénario: Curseur Découverte à 10% - défaut équilibré
|
||||
Quand je règle le curseur Découverte à 10%
|
||||
Alors 10% de contenus aléatoires
|
||||
Et 90% de contenus calculés
|
||||
Et je vois le message "Équilibre découverte/personnalisation"
|
||||
|
||||
Scénario: Curseur Découverte à 30% - découverte élevée
|
||||
Quand je règle le curseur Découverte à 30%
|
||||
Alors 30% de contenus aléatoires
|
||||
Et 70% de contenus calculés
|
||||
Et je vois le message "Découverte élevée activée"
|
||||
|
||||
Scénario: Curseur Découverte à 50% - découverte maximale
|
||||
Quand je règle le curseur Découverte à 50%
|
||||
Alors 50% de contenus aléatoires
|
||||
Et 50% de contenus calculés
|
||||
Et je vois le message "Découverte maximale (équivaut à national)"
|
||||
|
||||
Scénario: Créer un profil personnalisé "Trajet quotidien"
|
||||
Quand je crée un nouveau profil nommé "🚗 Trajet quotidien"
|
||||
Et que je configure:
|
||||
| parametre | valeur |
|
||||
| Géolocalisation | Local |
|
||||
| Découverte | 5% |
|
||||
| Politique | Masquer |
|
||||
Et que je sauvegarde
|
||||
Alors le profil "🚗 Trajet quotidien" est créé
|
||||
Et je peux l'activer en un clic
|
||||
|
||||
Scénario: Créer un profil "Road trip"
|
||||
Quand je crée un profil "🛣️ Road trip"
|
||||
Et que je configure:
|
||||
| parametre | valeur |
|
||||
| Géolocalisation | Régional |
|
||||
| Découverte | 30% |
|
||||
| Politique | Équilibré |
|
||||
Alors le profil est sauvegardé
|
||||
Et je peux switcher entre profils facilement
|
||||
|
||||
Scénario: Créer un profil "Enfants"
|
||||
Quand je crée un profil "👶 Enfants"
|
||||
Et que j'active le Mode Kids
|
||||
Alors tous les paramètres sont adaptés pour enfants:
|
||||
| parametre | valeur |
|
||||
| Mode Kids | Activé |
|
||||
| Politique | Masquer (forcé) |
|
||||
| Publicité | Aucune |
|
||||
|
||||
Scénario: Activer un profil existant
|
||||
Étant donné que j'ai créé un profil "🚗 Trajet quotidien"
|
||||
Quand je clique sur "Activer" pour ce profil
|
||||
Alors tous les paramètres du profil sont appliqués
|
||||
Et je vois le message "Profil 'Trajet quotidien' activé"
|
||||
Et l'algorithme utilise ces paramètres immédiatement
|
||||
|
||||
Scénario: Synchronisation profils entre devices
|
||||
Étant donné que j'ai créé 3 profils sur mon iPhone
|
||||
Quand je me connecte sur mon iPad
|
||||
Alors mes 3 profils sont automatiquement synchronisés
|
||||
Et je peux les utiliser sur l'iPad
|
||||
|
||||
Scénario: Modification d'un profil synchronisée
|
||||
Étant donné que j'ai un profil "Road trip" sur iPhone
|
||||
Quand je modifie ce profil sur iPhone
|
||||
Alors la modification est synchronisée sur tous mes devices
|
||||
Et le profil est mis à jour partout en temps réel
|
||||
|
||||
Scénario: Pas de partage de profils entre utilisateurs
|
||||
Étant donné que j'ai créé des profils personnalisés
|
||||
Et que ma conjointe a un compte RoadWave
|
||||
Quand elle se connecte sur son compte
|
||||
Alors elle ne voit pas mes profils
|
||||
Et chaque utilisateur a ses propres profils
|
||||
|
||||
Scénario: Auto-switch selon contexte (détection trajet récurrent)
|
||||
Étant donné que j'utilise toujours le profil "Trajet quotidien"
|
||||
Et que je pars de chez moi vers mon travail tous les matins à 8h
|
||||
Quand le système détecte ce trajet récurrent
|
||||
Alors le profil "Trajet quotidien" est activé automatiquement
|
||||
Et je reçois une notification "Profil 'Trajet quotidien' activé"
|
||||
|
||||
Scénario: Désactiver l'auto-switch
|
||||
Étant donné que l'auto-switch de profil est actif
|
||||
Quand je désactive cette option dans les paramètres
|
||||
Alors les profils ne changent plus automatiquement
|
||||
Et je dois les activer manuellement
|
||||
|
||||
Scénario: Blocage modification si vitesse GPS >10 km/h
|
||||
Étant donné que je conduis à 50 km/h
|
||||
Quand j'essaie de modifier un curseur
|
||||
Alors la modification est bloquée
|
||||
Et je vois le message "Modification impossible pendant la conduite"
|
||||
Et je dois m'arrêter ou être passager pour modifier
|
||||
|
||||
Scénario: Modification possible si vitesse <10 km/h
|
||||
Étant donné que je suis arrêté à un feu rouge (5 km/h)
|
||||
Quand j'essaie de modifier un curseur
|
||||
Alors la modification est autorisée
|
||||
Et je peux ajuster les paramètres
|
||||
|
||||
Scénario: Warning au lancement app
|
||||
Quand je lance l'application pour la première fois
|
||||
Alors je vois un warning "Configurez vos préférences avant de prendre la route"
|
||||
Et un bouton "Configurer maintenant"
|
||||
Et je peux accéder rapidement aux paramètres
|
||||
|
||||
Scénario: Modification uniquement app arrêtée ou mode passager
|
||||
Étant donné que je suis passager dans une voiture
|
||||
Et que le mode passager est activé
|
||||
Quand j'essaie de modifier les paramètres
|
||||
Alors la modification est autorisée
|
||||
Et le blocage vitesse GPS ne s'applique pas
|
||||
|
||||
Scénario: Statistiques d'utilisation des profils
|
||||
Étant donné que j'utilise plusieurs profils
|
||||
Quand je consulte mes statistiques
|
||||
Alors je vois:
|
||||
| metrique | exemple |
|
||||
| Profil le plus utilisé | Trajet quotidien |
|
||||
| Heures par profil | 25h / 10h / 5h |
|
||||
| Dernier profil actif | Road trip |
|
||||
|
||||
Scénario: Supprimer un profil
|
||||
Étant donné que j'ai créé un profil "Test"
|
||||
Quand je supprime ce profil
|
||||
Alors le profil est définitivement supprimé
|
||||
Et je vois le message "Profil 'Test' supprimé"
|
||||
Et il disparaît de tous mes devices
|
||||
|
||||
Scénario: Limite de profils par utilisateur
|
||||
Étant donné que j'ai créé 10 profils
|
||||
Quand j'essaie de créer un 11ème profil
|
||||
Alors la création échoue
|
||||
Et je vois le message "Maximum 10 profils par utilisateur"
|
||||
|
||||
Scénario: Dupliquer un profil existant
|
||||
Étant donné que j'ai un profil "Trajet quotidien"
|
||||
Quand je clique sur "Dupliquer"
|
||||
Alors un nouveau profil "Trajet quotidien (copie)" est créé
|
||||
Et il a les mêmes paramètres que l'original
|
||||
Et je peux le modifier indépendamment
|
||||
|
||||
Scénario: Réinitialiser un profil aux valeurs par défaut
|
||||
Étant donné que j'ai modifié un profil
|
||||
Quand je clique sur "Réinitialiser"
|
||||
Alors tous les paramètres reviennent aux valeurs par défaut:
|
||||
| parametre | valeur défaut |
|
||||
| Géolocalisation | Équilibré |
|
||||
| Découverte | 10% |
|
||||
| Politique | Équilibré |
|
||||
@@ -0,0 +1,221 @@
|
||||
# language: fr
|
||||
Fonctionnalité: Formule de scoring et recommandation
|
||||
En tant que système de recommandation
|
||||
Je veux calculer un score combiné pour chaque contenu
|
||||
Afin de proposer les contenus les plus pertinents à l'utilisateur
|
||||
|
||||
Contexte:
|
||||
Étant donné que l'API RoadWave est disponible
|
||||
|
||||
Scénario: Calcul du score géographique linéaire
|
||||
Étant donné qu'un contenu existe à Paris
|
||||
Et que la distance_max_km est configurée à 200 km
|
||||
Quand un utilisateur est à 50 km du contenu
|
||||
Alors le score_geo = 1 - (50 / 200) = 0.75
|
||||
|
||||
Scénario: Score géo à distance nulle (sur place)
|
||||
Étant donné qu'un contenu existe à un point GPS précis
|
||||
Quand un utilisateur est exactement au même point (0 km)
|
||||
Alors le score_geo = 1.0 (maximum)
|
||||
|
||||
Scénario: Score géo à distance_max (200 km)
|
||||
Étant donné qu'un contenu existe à Paris
|
||||
Quand un utilisateur est à 200 km du contenu
|
||||
Alors le score_geo = 1 - (200 / 200) = 0.0
|
||||
|
||||
Scénario: Score géo au-delà de distance_max
|
||||
Étant donné qu'un contenu existe à Paris
|
||||
Quand un utilisateur est à 250 km du contenu (au-delà de 200 km max)
|
||||
Alors le score_geo = 0.0 (minimum)
|
||||
Et le contenu a peu de chances d'être recommandé sauf engagement très élevé
|
||||
|
||||
Scénario: Calcul du score d'intérêts avec jauges utilisateur
|
||||
Étant donné qu'un utilisateur a les jauges suivantes:
|
||||
| categorie | niveau |
|
||||
| Automobile | 80% |
|
||||
| Voyage | 60% |
|
||||
| Musique | 40% |
|
||||
Et qu'un contenu est tagué "Automobile" et "Voyage"
|
||||
Quand l'algorithme calcule le score_interets
|
||||
Alors score_interets = (0.8 + 0.6) / 2 = 0.7
|
||||
|
||||
Scénario: Score d'intérêts avec un seul tag
|
||||
Étant donné qu'un utilisateur a la jauge "Économie" à 90%
|
||||
Et qu'un contenu est tagué uniquement "Économie"
|
||||
Quand l'algorithme calcule le score_interets
|
||||
Alors score_interets = 0.9
|
||||
|
||||
Scénario: Score d'intérêts avec tags non matchés
|
||||
Étant donné qu'un utilisateur a des jauges "Sport" et "Politique" élevées
|
||||
Et qu'un contenu est tagué "Musique" et "Philosophie"
|
||||
Et que l'utilisateur n'a pas ces catégories
|
||||
Quand l'algorithme calcule le score_interets
|
||||
Alors score_interets = 0.5 (neutre par défaut pour catégories inconnues)
|
||||
|
||||
Scénario: Calcul du score d'engagement avec métriques
|
||||
Étant donné qu'un contenu a:
|
||||
| metrique | valeur |
|
||||
| ecoutes | 1000 |
|
||||
| ecoutes_completes | 700 |
|
||||
| likes | 300 |
|
||||
| abonnements_apres | 50 |
|
||||
Quand l'algorithme calcule le score_engagement
|
||||
Alors taux_completion = 700 / 1000 = 0.7
|
||||
Et ratio_likes = 300 / 1000 = 0.3
|
||||
Et ratio_abonnements = 50 / 1000 = 0.05
|
||||
Et score_engagement = (0.7 × 0.5) + (0.3 × 0.3) + (0.05 × 0.2) = 0.35 + 0.09 + 0.01 = 0.45
|
||||
|
||||
Scénario: Contenu avec moins de 50 écoutes - score neutre
|
||||
Étant donné qu'un contenu a seulement 30 écoutes
|
||||
Quand l'algorithme calcule le score_engagement
|
||||
Alors score_engagement = 0.5 (neutre par défaut)
|
||||
Et le contenu n'est pas pénalisé pour manque de données
|
||||
|
||||
Scénario: Contenu avec exactement 50 écoutes - calcul réel
|
||||
Étant donné qu'un contenu a exactement 50 écoutes
|
||||
Et des métriques d'engagement complètes
|
||||
Quand l'algorithme calcule le score_engagement
|
||||
Alors le score est calculé normalement (pas de seuil neutre)
|
||||
|
||||
Scénario: Bonus aléatoire - 10% des recommandations
|
||||
Étant donné qu'un utilisateur demande 10 recommandations
|
||||
Et que la part_aleatoire_global est à 10%
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors 1 contenu sur 10 est tiré aléatoirement
|
||||
Et 9 contenus sont calculés avec le score combiné
|
||||
Et le contenu aléatoire n'est pas dans l'historique déjà écouté
|
||||
|
||||
Scénario: Curseur utilisateur découverte à 0% - aucun aléatoire
|
||||
Étant donné qu'un utilisateur configure le curseur découverte à 0%
|
||||
Quand l'utilisateur demande 20 recommandations
|
||||
Alors les 20 contenus sont calculés avec le score combiné
|
||||
Et aucun contenu aléatoire n'est proposé
|
||||
|
||||
Scénario: Curseur utilisateur découverte à 50% - découverte max
|
||||
Étant donné qu'un utilisateur configure le curseur découverte à 50%
|
||||
Quand l'utilisateur demande 20 recommandations
|
||||
Alors 10 contenus sont tirés aléatoirement
|
||||
Et 10 contenus sont calculés avec le score combiné
|
||||
|
||||
Scénario: Score final combiné pour contenu géo-ancré
|
||||
Étant donné qu'un contenu "Géo-ancré" a:
|
||||
| parametre | valeur |
|
||||
| score_geo | 0.9 |
|
||||
| score_interets | 0.6 |
|
||||
| score_engagement | 0.45 |
|
||||
| poids_geo | 0.7 |
|
||||
| poids_interets | 0.1 |
|
||||
| poids_engagement | 0.2 |
|
||||
Quand l'algorithme calcule le score_final
|
||||
Alors score_final = (0.9 × 0.7) + (0.6 × 0.1) + (0.45 × 0.2)
|
||||
Et score_final = 0.63 + 0.06 + 0.09 = 0.78
|
||||
|
||||
Scénario: Score final combiné pour contenu géo-neutre
|
||||
Étant donné qu'un contenu "Géo-neutre" a:
|
||||
| parametre | valeur |
|
||||
| score_geo | 0.3 |
|
||||
| score_interets | 0.9 |
|
||||
| score_engagement | 0.6 |
|
||||
| poids_geo | 0.2 |
|
||||
| poids_interets | 0.6 |
|
||||
| poids_engagement | 0.2 |
|
||||
Quand l'algorithme calcule le score_final
|
||||
Alors score_final = (0.3 × 0.2) + (0.9 × 0.6) + (0.6 × 0.2)
|
||||
Et score_final = 0.06 + 0.54 + 0.12 = 0.72
|
||||
Et le contenu peut être recommandé malgré la distance
|
||||
|
||||
Scénario: Contenu viral lointain peut être recommandé
|
||||
Étant donné qu'un contenu viral existe à Paris
|
||||
Et qu'il a un score_engagement très élevé de 0.95
|
||||
Et qu'un utilisateur est à Marseille (score_geo = 0.1)
|
||||
Quand l'algorithme calcule le score_final
|
||||
Alors le score_engagement élevé compense le score_geo faible
|
||||
Et le contenu peut apparaître dans les recommandations
|
||||
|
||||
Scénario: Ordre de recommandation par score décroissant
|
||||
Étant donné 5 contenus avec les scores suivants:
|
||||
| contenu | score_final |
|
||||
| Contenu A | 0.85 |
|
||||
| Contenu B | 0.72 |
|
||||
| Contenu C | 0.90 |
|
||||
| Contenu D | 0.65 |
|
||||
| Contenu E | 0.78 |
|
||||
Quand l'utilisateur demande des recommandations
|
||||
Alors l'ordre de proposition est:
|
||||
| position | contenu |
|
||||
| 1 | Contenu C |
|
||||
| 2 | Contenu A |
|
||||
| 3 | Contenu E |
|
||||
| 4 | Contenu B |
|
||||
| 5 | Contenu D |
|
||||
|
||||
Scénario: Exclusion de l'historique déjà écouté >80%
|
||||
Étant donné qu'un utilisateur a écouté les contenus suivants:
|
||||
| contenu | completion |
|
||||
| Contenu A | 85% |
|
||||
| Contenu B | 95% |
|
||||
| Contenu C | 30% |
|
||||
Quand l'algorithme génère les recommandations
|
||||
Alors "Contenu A" et "Contenu B" ne sont jamais proposés
|
||||
Mais "Contenu C" peut être reproposé
|
||||
|
||||
Scénario: Pré-calcul de 5 contenus suivants
|
||||
Étant donné qu'un utilisateur écoute un contenu
|
||||
Quand l'algorithme prépare les contenus suivants
|
||||
Alors 5 contenus sont pré-calculés selon le score
|
||||
Et ces contenus sont mis en cache pour performance
|
||||
|
||||
Scénario: Recalcul si déplacement >10 km
|
||||
Étant donné que 5 contenus suivants sont pré-calculés
|
||||
Et que l'utilisateur se déplace de 12 km
|
||||
Quand l'utilisateur demande le contenu suivant
|
||||
Alors l'algorithme recalcule les scores avec la nouvelle position
|
||||
Et propose de nouveaux contenus plus pertinents géographiquement
|
||||
|
||||
Scénario: Recalcul après 10 minutes d'inactivité
|
||||
Étant donné que 5 contenus suivants sont pré-calculés
|
||||
Et que 11 minutes se sont écoulées sans action
|
||||
Quand l'utilisateur demande le contenu suivant
|
||||
Alors l'algorithme recalcule les scores
|
||||
Et prend en compte les nouveaux contenus publiés
|
||||
|
||||
# Règle: Score géo excellent + intérêts nuls = recommandation possible (MVP)
|
||||
Scénario: Contenu géo-ancré proche avec intérêts nuls reste recommandable
|
||||
Étant donné qu'un contenu géo-ancré "Info trafic local" est à 100m de l'utilisateur
|
||||
Et que le contenu est tagué "Actualités" et "Trafic"
|
||||
Et que l'utilisateur a des jauges à 0% pour ces tags (aucun intérêt marqué)
|
||||
Et que le score_geo = 1.0 (distance 100m, excellent)
|
||||
Et que le score_interets = 0.0 (jauges nulles)
|
||||
Et que le score_engagement = 0.6 (contenu récent, peu d'historique)
|
||||
Quand l'algorithme calcule le score_final pour un contenu géo-ancré
|
||||
Alors score_final = (1.0 × 0.7) + (0.0 × 0.1) + (0.6 × 0.2)
|
||||
Et score_final = 0.7 + 0.0 + 0.12 = 0.82
|
||||
Et le contenu peut être recommandé malgré l'intérêt nul
|
||||
Et ce comportement est accepté pour MVP car:
|
||||
| justification |
|
||||
| Le quota 6 contenus géolocalisés/h protège du spam |
|
||||
| L'info peut être utile contextuellement |
|
||||
| La distinction info/divertissement est reportée post-MVP|
|
||||
|
||||
Scénario: Contenu géo-neutre loin avec intérêts élevés recommandé
|
||||
Étant donné qu'un contenu géo-neutre "Podcast philosophie" est à 150 km
|
||||
Et que le contenu est tagué "Philosophie" et "Culture"
|
||||
Et que l'utilisateur a des jauges à 90% pour ces tags
|
||||
Et que le score_geo = 0.25 (150 km de distance)
|
||||
Et que le score_interets = 0.9 (jauges élevées)
|
||||
Et que le score_engagement = 0.7
|
||||
Quand l'algorithme calcule le score_final pour un contenu géo-neutre
|
||||
Alors score_final = (0.25 × 0.2) + (0.9 × 0.6) + (0.7 × 0.2)
|
||||
Et score_final = 0.05 + 0.54 + 0.14 = 0.73
|
||||
Et le contenu est bien recommandé grâce aux intérêts élevés
|
||||
|
||||
Scénario: Comparaison scores - géo proche vs intérêts élevés
|
||||
Étant donné deux contenus:
|
||||
| contenu | type | distance | score_geo | tags | jauges_user | score_interets | score_engagement |
|
||||
| Info trafic locale | Géo-ancré | 100m | 1.0 | Trafic | 0% | 0.0 | 0.6 |
|
||||
| Podcast philosophie | Géo-neutre | 150 km | 0.25 | Philosophie | 90% | 0.9 | 0.7 |
|
||||
Quand l'algorithme calcule les scores finaux
|
||||
Alors score_final("Info trafic locale") = 0.82
|
||||
Et score_final("Podcast philosophie") = 0.73
|
||||
Et "Info trafic locale" sera proposé avant "Podcast philosophie"
|
||||
Et les deux contenus sont recommandables selon leurs critères différents
|
||||
@@ -0,0 +1,325 @@
|
||||
# language: fr
|
||||
|
||||
@api @search @geolocation @mvp
|
||||
Fonctionnalité: Recherche géographique de contenus avec Nominatim
|
||||
|
||||
En tant qu'utilisateur de RoadWave
|
||||
Je veux rechercher des contenus audio dans une zone géographique spécifique
|
||||
En utilisant un nom de lieu (ville, monument, région) et un rayon de recherche
|
||||
Afin de découvrir des contenus avant de me déplacer ou planifier un trajet
|
||||
|
||||
Contexte:
|
||||
Étant donné un utilisateur authentifié
|
||||
Et l'API Nominatim est accessible
|
||||
|
||||
# ============================================================================
|
||||
# GEOCODAGE AVEC NOMINATIM (lieu → coordonnées GPS)
|
||||
# ============================================================================
|
||||
|
||||
Scénario: Recherche par nom de ville (cas simple)
|
||||
Étant donné l'utilisateur saisit "Paris" dans la recherche géographique
|
||||
Quand le système interroge Nominatim avec la requête "Paris, France"
|
||||
Alors Nominatim doit retourner les coordonnées :
|
||||
| lat | 48.8566 |
|
||||
| lon | 2.3522 |
|
||||
| type | city |
|
||||
| bbox | (48.815, 48.902, 2.224, 2.470) |
|
||||
Et le système doit utiliser ces coordonnées comme centre de recherche
|
||||
|
||||
Scénario: Recherche par monument ou POI (Point of Interest)
|
||||
Étant donné l'utilisateur saisit "Tour Eiffel"
|
||||
Quand le système interroge Nominatim
|
||||
Alors Nominatim doit retourner :
|
||||
| lat | 48.8584 |
|
||||
| lon | 2.2945 |
|
||||
| display_name| Tour Eiffel, Paris, France |
|
||||
| type | tourism |
|
||||
Et ces coordonnées doivent être utilisées pour la recherche de contenus
|
||||
|
||||
Scénario: Recherche avec ambiguïté (plusieurs résultats)
|
||||
Étant donné l'utilisateur saisit "Montmartre"
|
||||
Quand le système interroge Nominatim
|
||||
Alors plusieurs résultats doivent être retournés :
|
||||
| display_name | lat | lon |
|
||||
| Montmartre, Paris 18e, France | 48.8867 | 2.3431 |
|
||||
| Montmartre, Saskatchewan, Canada | 49.3000 | -106.0 |
|
||||
| Montmartre-de-Bretagne, Ille-et-Vilaine | 48.1867 | -1.1833|
|
||||
Et l'utilisateur doit choisir dans une liste déroulante
|
||||
Et le choix par défaut doit être le résultat français si détecté
|
||||
|
||||
Scénario: Recherche avec contexte géographique (biais local)
|
||||
Étant donné l'utilisateur est actuellement à Lyon (GPS actif)
|
||||
Et l'utilisateur saisit "Bellecour"
|
||||
Quand le système interroge Nominatim avec viewbox="Lyon area"
|
||||
Alors Nominatim doit prioriser les résultats proches de Lyon
|
||||
Et "Place Bellecour, Lyon" doit apparaître en premier
|
||||
Avant "Bellecour, Jura" ou d'autres homonymes
|
||||
|
||||
Scénario: Recherche par code postal
|
||||
Étant donné l'utilisateur saisit "75001"
|
||||
Quand le système interroge Nominatim
|
||||
Alors Nominatim doit retourner le centre du 1er arrondissement de Paris
|
||||
Et un rayon par défaut de 1km doit être appliqué
|
||||
|
||||
Scénario: Recherche invalide ou introuvable
|
||||
Étant donné l'utilisateur saisit "Azertyuiopqsdfghjklm"
|
||||
Quand le système interroge Nominatim
|
||||
Alors Nominatim doit retourner 0 résultat
|
||||
Et un message d'erreur doit être affiché :
|
||||
"""
|
||||
Aucun lieu trouvé pour "Azertyuiopqsdfghjklm".
|
||||
Essayez un nom de ville, monument ou adresse.
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# RAYON DE RECHERCHE CONFIGURABLE
|
||||
# ============================================================================
|
||||
|
||||
Scénario: Recherche avec rayon par défaut (5 km)
|
||||
Étant donné l'utilisateur cherche "Louvre, Paris"
|
||||
Et Nominatim retourne les coordonnées (48.8606, 2.3376)
|
||||
Et aucun rayon n'est spécifié
|
||||
Quand le système effectue la recherche de contenus
|
||||
Alors un rayon par défaut de 5 km doit être appliqué
|
||||
Et une requête PostGIS doit être exécutée :
|
||||
"""
|
||||
SELECT * FROM contents
|
||||
WHERE ST_DWithin(location::geography, ST_MakePoint(2.3376, 48.8606)::geography, 5000)
|
||||
"""
|
||||
|
||||
Scénario: Recherche avec rayon personnalisé (slider 1-50 km)
|
||||
Étant donné l'utilisateur cherche "Lyon"
|
||||
Et l'utilisateur ajuste le slider de rayon à 15 km
|
||||
Quand le système effectue la recherche de contenus
|
||||
Alors une requête PostGIS avec rayon 15000m doit être exécutée
|
||||
Et tous les contenus dans un rayon de 15 km autour de Lyon doivent être retournés
|
||||
|
||||
Scénario: Rayon minimum (1 km) - recherche ultra locale
|
||||
Étant donné l'utilisateur cherche "Place de la Concorde"
|
||||
Et l'utilisateur définit le rayon à 1 km (minimum)
|
||||
Quand le système effectue la recherche
|
||||
Alors seuls les contenus très proches (<1 km) doivent être retournés
|
||||
Et le nombre de résultats peut être très faible (0-10)
|
||||
|
||||
Scénario: Rayon maximum (50 km) - recherche large
|
||||
Étant donné l'utilisateur cherche "Versailles"
|
||||
Et l'utilisateur définit le rayon à 50 km (maximum)
|
||||
Quand le système effectue la recherche
|
||||
Alors tous les contenus dans un rayon de 50 km doivent être retournés
|
||||
Et cela peut inclure Paris, banlieue et zones périphériques
|
||||
Et un avertissement "Résultats nombreux, affinez votre recherche" peut s'afficher si >500 résultats
|
||||
|
||||
Scénario: Mise à jour temps réel du rayon avec slider
|
||||
Étant donné l'utilisateur visualise les résultats pour "Bordeaux" avec rayon 10 km
|
||||
Quand l'utilisateur ajuste le slider de 10 km à 20 km
|
||||
Alors une nouvelle requête API doit être déclenchée automatiquement
|
||||
Et les résultats doivent être mis à jour en temps réel
|
||||
Et la carte (si affichée) doit ajuster le cercle de rayon
|
||||
|
||||
# ============================================================================
|
||||
# FILTRES COMBINÉS AVEC RECHERCHE GÉO
|
||||
# ============================================================================
|
||||
|
||||
Scénario: Recherche géo + filtre catégorie
|
||||
Étant donné l'utilisateur cherche "Marseille" avec rayon 10 km
|
||||
Et l'utilisateur filtre par catégorie "Tourisme"
|
||||
Quand le système effectue la recherche
|
||||
Alors seuls les contenus touristiques dans le rayon doivent être retournés
|
||||
Et la requête SQL doit combiner :
|
||||
"""
|
||||
WHERE ST_DWithin(...) AND category_id IN (SELECT id FROM categories WHERE name = 'Tourisme')
|
||||
"""
|
||||
|
||||
Scénario: Recherche géo + filtre type de contenu
|
||||
Étant donné l'utilisateur cherche "Strasbourg" avec rayon 5 km
|
||||
Et l'utilisateur filtre par type "Audio-guides"
|
||||
Quand le système effectue la recherche
|
||||
Alors seuls les audio-guides dans le rayon doivent être retournés
|
||||
Et les podcasts et radios live doivent être exclus
|
||||
|
||||
Scénario: Recherche géo + filtre durée
|
||||
Étant donné l'utilisateur cherche "Nice" avec rayon 15 km
|
||||
Et l'utilisateur filtre par durée "Court (<10 min)"
|
||||
Quand le système effectue la recherche
|
||||
Alors seuls les contenus de <10 minutes dans le rayon doivent être retournés
|
||||
|
||||
Scénario: Recherche géo + mode Kids actif
|
||||
Étant donné l'utilisateur cherche "Disneyland Paris" avec rayon 5 km
|
||||
Et le mode Kids est activé (utilisateur 13-15 ans)
|
||||
Quand le système effectue la recherche
|
||||
Alors seuls les contenus "Tout public" doivent être retournés
|
||||
Et les contenus 16+ et 18+ doivent être exclus automatiquement
|
||||
|
||||
Scénario: Recherche géo + filtre politique désactivé
|
||||
Étant donné l'utilisateur cherche "Assemblée Nationale" avec rayon 2 km
|
||||
Et l'utilisateur a désactivé les contenus politiques dans ses préférences
|
||||
Quand le système effectue la recherche
|
||||
Alors les contenus taggés "politique" doivent être exclus
|
||||
Même s'ils sont géographiquement pertinents
|
||||
|
||||
# ============================================================================
|
||||
# AFFICHAGE DES RÉSULTATS
|
||||
# ============================================================================
|
||||
|
||||
Scénario: Résultats triés par distance croissante
|
||||
Étant donné l'utilisateur cherche "Arc de Triomphe" avec rayon 3 km
|
||||
Quand les résultats sont retournés
|
||||
Alors ils doivent être triés par distance croissante :
|
||||
| contenu | distance |
|
||||
| Balade sur les Champs | 0.1 km |
|
||||
| Histoire de l'Arc | 0.2 km |
|
||||
| Quartier de l'Étoile | 0.5 km |
|
||||
| Secrets du 16e | 2.8 km |
|
||||
Et la distance doit être affichée pour chaque résultat
|
||||
|
||||
Scénario: Affichage carte avec marqueurs de contenus
|
||||
Étant donné l'utilisateur cherche "Toulouse" avec rayon 10 km
|
||||
Quand les résultats sont affichés en mode "Carte"
|
||||
Alors une carte Leaflet doit être affichée
|
||||
Et un marqueur doit être placé pour chaque contenu :
|
||||
| contenu | lat | lon | icone |
|
||||
| Capitole de Toulouse | 43.6045 | 1.4442 | pin-tourisme |
|
||||
| Bords de Garonne | 43.5986 | 1.4330 | pin-nature |
|
||||
Et un cercle représentant le rayon de 10 km doit être affiché
|
||||
Et le centre doit être le point géocodé de Toulouse
|
||||
|
||||
Scénario: Clustering de marqueurs si résultats nombreux
|
||||
Étant donné l'utilisateur cherche "Paris" avec rayon 20 km
|
||||
Et la recherche retourne 350 contenus
|
||||
Quand la carte est affichée
|
||||
Alors les marqueurs proches doivent être groupés en clusters :
|
||||
| cluster_center | nb_contenus |
|
||||
| Centre Paris | 120 |
|
||||
| La Défense | 45 |
|
||||
| Bois de Vincennes | 18 |
|
||||
Et en cliquant sur un cluster, le zoom doit s'approcher
|
||||
Et révéler les marqueurs individuels
|
||||
|
||||
Scénario: Affichage liste + carte simultanés (vue hybride)
|
||||
Étant donné l'utilisateur cherche "Nantes" avec rayon 8 km
|
||||
Quand l'utilisateur active la vue "Hybride"
|
||||
Alors la liste de résultats doit s'afficher à gauche (60% écran)
|
||||
Et la carte doit s'afficher à droite (40% écran)
|
||||
Et en cliquant sur un résultat dans la liste, le marqueur doit être highlighté sur la carte
|
||||
Et vice-versa
|
||||
|
||||
# ============================================================================
|
||||
# PERFORMANCES & OPTIMISATIONS
|
||||
# ============================================================================
|
||||
|
||||
Scénario: Cache résultats recherche géo fréquente (Paris, Lyon, Marseille)
|
||||
Étant donné l'utilisateur cherche "Paris" avec rayon 5 km
|
||||
Quand la requête est exécutée pour la 1ère fois
|
||||
Alors les résultats doivent être mis en cache Redis pendant 10 minutes
|
||||
Et les requêtes suivantes identiques doivent utiliser le cache
|
||||
Et un header "X-Cache: HIT" doit être retourné
|
||||
|
||||
Scénario: Index PostGIS pour performances requêtes spatiales
|
||||
Étant donné la table "contents" contient 100 000 contenus
|
||||
Quand une recherche géo est exécutée avec ST_DWithin
|
||||
Alors un index GIST sur la colonne "location" doit être utilisé
|
||||
Et le temps de réponse doit être <100ms (p95)
|
||||
|
||||
Scénario: Pagination résultats nombreux
|
||||
Étant donné l'utilisateur cherche "Île-de-France" avec rayon 50 km
|
||||
Et la recherche retourne 1200 contenus
|
||||
Quand les résultats sont affichés
|
||||
Alors seuls les 50 premiers résultats doivent être chargés initialement
|
||||
Et un scroll infini doit charger les résultats suivants par batch de 50
|
||||
Et le total "1200 contenus trouvés" doit être affiché en haut
|
||||
|
||||
# ============================================================================
|
||||
# EDGE CASES & ERREURS
|
||||
# ============================================================================
|
||||
|
||||
Scénario: Nominatim API indisponible (fallback)
|
||||
Étant donné l'utilisateur cherche "Lille"
|
||||
Quand l'API Nominatim retourne une erreur 503 (service unavailable)
|
||||
Alors un message d'erreur doit être affiché :
|
||||
"""
|
||||
Le service de recherche géographique est temporairement indisponible.
|
||||
Veuillez réessayer dans quelques instants.
|
||||
"""
|
||||
Et l'utilisateur doit pouvoir utiliser la recherche textuelle classique
|
||||
|
||||
Scénario: Recherche géo sans connexion Internet
|
||||
Étant donné l'utilisateur est en mode offline
|
||||
Quand l'utilisateur tente une recherche géographique
|
||||
Alors un message doit indiquer :
|
||||
"""
|
||||
La recherche géographique nécessite une connexion Internet.
|
||||
Vous pouvez parcourir les contenus téléchargés.
|
||||
"""
|
||||
|
||||
Scénario: Rate limiting Nominatim (1 req/s)
|
||||
Étant donné Nominatim impose un rate limit de 1 requête/seconde
|
||||
Et l'utilisateur tape rapidement "Par" → "Pari" → "Paris"
|
||||
Quand le système détecte plusieurs requêtes rapides
|
||||
Alors un debounce de 500ms doit être appliqué
|
||||
Et seule la dernière requête ("Paris") doit être envoyée à Nominatim
|
||||
|
||||
Scénario: Recherche avec caractères spéciaux ou injection SQL
|
||||
Étant donné l'utilisateur saisit "Paris'; DROP TABLE contents; --"
|
||||
Quand le système traite la requête
|
||||
Alors les caractères spéciaux doivent être échappés
|
||||
Et aucune injection SQL ne doit être possible
|
||||
Et Nominatim doit retourner 0 résultat (lieu invalide)
|
||||
|
||||
# ============================================================================
|
||||
# SAUVEGARDE DES RECHERCHES (max 5)
|
||||
# ============================================================================
|
||||
|
||||
Scénario: Sauvegarde automatique d'une recherche géo
|
||||
Étant donné l'utilisateur effectue une recherche "Bordeaux" avec rayon 10 km
|
||||
Quand la recherche est validée
|
||||
Alors elle doit être sauvegardée dans l'historique :
|
||||
| search_query | Bordeaux |
|
||||
| radius_km | 10 |
|
||||
| lat | 44.8378 |
|
||||
| lon | -0.5792 |
|
||||
| timestamp | 2026-02-03 14:30:00 |
|
||||
Et l'utilisateur peut la rappeler via "Recherches récentes"
|
||||
|
||||
Scénario: Limite de 5 recherches sauvegardées (FIFO)
|
||||
Étant donné l'utilisateur a déjà 5 recherches sauvegardées
|
||||
Quand l'utilisateur effectue une 6ème recherche "Montpellier"
|
||||
Alors la recherche la plus ancienne doit être supprimée
|
||||
Et "Montpellier" doit être ajoutée en 1ère position
|
||||
Et la limite de 5 recherches doit être respectée
|
||||
|
||||
Scénario: Rejouer une recherche sauvegardée
|
||||
Étant donné l'utilisateur a une recherche sauvegardée "Lyon - 15 km"
|
||||
Quand l'utilisateur clique sur cette recherche dans l'historique
|
||||
Alors la recherche doit être ré-exécutée avec les mêmes paramètres
|
||||
Et les résultats actualisés doivent être affichés
|
||||
Et le rayon slider doit être positionné à 15 km
|
||||
|
||||
# ============================================================================
|
||||
# MÉTRIQUES & ANALYTICS
|
||||
# ============================================================================
|
||||
|
||||
Scénario: Logging des recherches géographiques pour analytics
|
||||
Étant donné l'utilisateur effectue une recherche "Grenoble" avec rayon 12 km
|
||||
Quand la recherche est exécutée
|
||||
Alors un événement analytics doit être loggé :
|
||||
| event_type | geo_search_executed |
|
||||
| search_query | Grenoble |
|
||||
| radius_km | 12 |
|
||||
| results_count | 87 |
|
||||
| response_time_ms | 145 |
|
||||
| cache_hit | false |
|
||||
| user_id | user-123 |
|
||||
| timestamp | 2026-02-03 14:30:00 |
|
||||
Et ces données doivent alimenter le dashboard de monitoring
|
||||
|
||||
Scénario: Top recherches géographiques (analytics)
|
||||
Étant donné le système analyse les recherches sur 30 jours
|
||||
Quand le dashboard analytics est consulté
|
||||
Alors les lieux les plus recherchés doivent être affichés :
|
||||
| lieu | nb_recherches |
|
||||
| Paris | 12450 |
|
||||
| Lyon | 5632 |
|
||||
| Marseille | 4521 |
|
||||
| Toulouse | 3890 |
|
||||
| Nice | 3124 |
|
||||
Et ces données doivent guider la création de contenus ciblés
|
||||
420
docs/domains/recommendation/rules/algorithme-recommandation.md
Normal file
420
docs/domains/recommendation/rules/algorithme-recommandation.md
Normal file
@@ -0,0 +1,420 @@
|
||||
## 2. Algorithme de recommandation
|
||||
|
||||
### 2.1 Classification de géo-pertinence
|
||||
|
||||
**Décision** : 3 types de contenus selon leur pertinence géographique
|
||||
|
||||
| Type | Description | Exemple | Pondération géo |
|
||||
|------|-------------|---------|-----------------|
|
||||
| **Géo-ancré** | Contenu lié à un lieu précis | Audio-guide monument, pub restaurant local | 70% |
|
||||
| **Géo-contextuel** | Pertinent dans une zone | Actualité régionale, événement local | 50% |
|
||||
| **Géo-neutre** | Universel, pas de lien géo | Podcast philosophie, musique | 20% |
|
||||
|
||||
**Qui décide** :
|
||||
- ✅ Créateur choisit le type à la publication
|
||||
- ✅ Modération peut reclassifier après validation
|
||||
- ✅ Modification possible après publication (tout le monde a le droit de se tromper)
|
||||
|
||||
**Justification** :
|
||||
- Différencie audio-guide (hyper-local) des podcasts génériques
|
||||
- Algorithme adapte automatiquement la pondération
|
||||
- Coût : champ supplémentaire en DB + règle algo
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Formule de scoring
|
||||
|
||||
**Décision** : Score combiné dynamique selon type de contenu
|
||||
|
||||
```
|
||||
score_final = (score_geo * poids_geo_type)
|
||||
+ (score_interets * poids_interets_type)
|
||||
+ (score_engagement * 0.2)
|
||||
+ (bonus_aleatoire)
|
||||
|
||||
où :
|
||||
- score_geo = 1 - (distance_km / distance_max_km)
|
||||
- score_interets = moyenne des jauges utilisateur pour les tags du contenu
|
||||
- score_engagement = (taux_completion * 0.5) + (ratio_likes * 0.3) + (ratio_abonnements * 0.2)
|
||||
- bonus_aleatoire = 10% des recommandations tirées aléatoirement
|
||||
```
|
||||
|
||||
#### Calcul détaillé du score_interets
|
||||
|
||||
**Domaine des données** :
|
||||
- Jauges utilisateur : stockées en pourcentage [0-100]
|
||||
- score_interets : normalisé dans l'intervalle [0.0-1.0] pour pondération
|
||||
|
||||
**Formule exacte** :
|
||||
```
|
||||
score_interets = (SUM(gauge_values_for_tags) / NB_TAGS) / 100
|
||||
|
||||
où :
|
||||
- gauge_values_for_tags = valeurs des jauges correspondant aux tags du contenu
|
||||
- NB_TAGS = nombre de tags du contenu (minimum 1, maximum 3)
|
||||
- Division par 100 pour normaliser [0-100] → [0.0-1.0]
|
||||
```
|
||||
|
||||
**Exemple concret** :
|
||||
```
|
||||
Contenu : "Visite du Louvre"
|
||||
Tags : ["Musique", "Tourisme"]
|
||||
|
||||
Utilisateur :
|
||||
- Jauge "Musique" = 75%
|
||||
- Jauge "Tourisme" = 60%
|
||||
- Jauge "Automobile" = 40% (non pertinente, ignorée)
|
||||
|
||||
Calcul :
|
||||
score_interets = ((75 + 60) / 2) / 100
|
||||
= (135 / 2) / 100
|
||||
= 67.5 / 100
|
||||
= 0.675
|
||||
|
||||
Impact dans le scoring final (type géo-contextuel) :
|
||||
score_final = (score_geo * 0.5) + (score_interets * 0.3) + (score_engagement * 0.2) + bonus_aleatoire
|
||||
= (0.8 * 0.5) + (0.675 * 0.3) + (0.5 * 0.2) + 0
|
||||
= 0.4 + 0.2025 + 0.1
|
||||
= 0.7025 / 1.0
|
||||
```
|
||||
|
||||
**Cas limites** :
|
||||
- Utilisateur n'a aucune jauge pour les tags du contenu → score_interets = 0.5 (valeur neutre par défaut)
|
||||
- Contenu avec 1 seul tag → score_interets = gauge_value / 100
|
||||
- Jauges multiples → moyenne arithmétique simple (pas de pondération différente par tag)
|
||||
- **Score géo excellent MAIS intérêts nuls** : Le contenu peut quand même être recommandé grâce à la pondération géographique. Exemple : contenu géo-ancré à 100m avec score_geo=1.0 et score_interets=0.0 obtient score_final = (1.0 × 0.7) + (0.0 × 0.1) + engagement = 0.7 + engagement. Ce comportement est accepté pour MVP car (1) le quota 6 contenus géolocalisés/h protège du spam, (2) l'info peut être utile contextuellement même sans intérêt marqué, (3) la distinction info/divertissement est reportée post-MVP.
|
||||
|
||||
**Pondérations par type** :
|
||||
|
||||
| Type | Poids géo | Poids intérêts |
|
||||
|------|-----------|----------------|
|
||||
| Géo-ancré | 0.7 | 0.1 |
|
||||
| Géo-contextuel | 0.5 | 0.3 |
|
||||
| Géo-neutre | 0.2 | 0.6 |
|
||||
|
||||
**Paramètres** :
|
||||
- Distance max recommandée : **200 km**
|
||||
- Dégradation : **linéaire** (1 - distance/200km)
|
||||
- Rayon point GPS : **500m** (adapté au volume de contenu local)
|
||||
|
||||
**Tous ces paramètres sont configurables à chaud via interface admin.**
|
||||
|
||||
**Justification** :
|
||||
- Flexibilité totale selon type de contenu
|
||||
- Linéaire = rattrapage naturel du contenu viral ancien
|
||||
- Auditable via métriques engagement (moyenne/médiane)
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Score d'engagement et popularité
|
||||
|
||||
**Décision** : Intégration popularité avec poids 0.2
|
||||
|
||||
**Métriques** :
|
||||
- **Taux de complétion** : écoutes >80% / total écoutes pertinentes (poids 0.5)
|
||||
- **Ratio likes** : likes / écoutes (poids 0.3)
|
||||
- **Ratio abonnements** : nouveaux abonnés après écoute / écoutes (poids 0.2)
|
||||
|
||||
**Distinction sources et abonnements** (neutralisation pénalités) :
|
||||
|
||||
Les métriques d'engagement **ne comptent que les écoutes pertinentes** pour éviter de pénaliser injustement les créateurs :
|
||||
|
||||
| Source écoute | Abonné au créateur ? | Skip <10s pénalise ? | Compte dans "total écoutes" ? | Justification |
|
||||
|---------------|---------------------|---------------------|------------------------------|---------------|
|
||||
| **`recommendation`** | ❌ Non | ✅ Oui | ✅ Oui | Skip = mauvaise recommandation OU mauvais contenu |
|
||||
| **`recommendation`** | ✅ Oui | ❌ **Non** | ❌ **Non** | Abonné intéressé globalement, skip contextuel |
|
||||
| **`search`** | Peu importe | ❌ Non | ❌ Non | User cherchait quelque chose de précis, skip = "pas maintenant" |
|
||||
| **`direct_link`** | Peu importe | ❌ Non | ❌ Non | User curieux, peut skip sans jugement qualité |
|
||||
| **`profile`** | Peu importe | ❌ Non | ❌ Non | User explore catalogue créateur |
|
||||
| **`history`** | Peu importe | ❌ Non | ❌ Non | Pas une première écoute |
|
||||
| **`live_notification`** | ❌ Non | ✅ Oui | ✅ Oui | Abonné normalement intéressé |
|
||||
| **`live_notification`** | ✅ Oui | ❌ **Non** | ❌ **Non** | Abonné = affinité, skip contextuel |
|
||||
| **`audio_guide`** | Peu importe | ❌ Non | ❌ Non | Navigation guidée, pas jugement qualité |
|
||||
|
||||
**Calcul engagement créateur** (exemple SQL) :
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
content_id,
|
||||
AVG(completion_rate) as avg_completion,
|
||||
COUNT(*) FILTER (WHERE completion_rate > 0.8) as complete_listens,
|
||||
COUNT(*) FILTER (WHERE completion_rate < 0.1 AND NOT is_subscribed) as penalizing_skips
|
||||
FROM user_listening_history
|
||||
WHERE source IN ('recommendation', 'live_notification') -- Sources pertinentes
|
||||
GROUP BY content_id;
|
||||
```
|
||||
|
||||
**Seuil minimum** :
|
||||
- Minimum **50 écoutes pertinentes** avant de considérer l'engagement
|
||||
- Contenu <50 écoutes : score engagement = 0.5 (neutre)
|
||||
|
||||
**Contenu viral** :
|
||||
- Un contenu viral à Paris **peut** être proposé à Marseille
|
||||
- Score géo faible compensé par score engagement élevé
|
||||
- Paramétrable admin
|
||||
|
||||
**Dépréciation temporelle** :
|
||||
- Pas de dépréciation automatique
|
||||
- Ratio linéaire = contenu ancien mais toujours apprécié reste pertinent
|
||||
|
||||
**Justification** :
|
||||
- Équilibre découverte / qualité
|
||||
- **Protection créateur** : abonnés fidèles ne pénalisent pas les métriques
|
||||
- **Anti-raid naturel** : skips via search/direct_link ne comptent pas (raid inefficace)
|
||||
- **Cohérence UX** : abonnement = signal d'affinité fort, skip ponctuel ≠ rejet créateur
|
||||
- Pas de pénalisation arbitraire des contenus anciens
|
||||
- Coût : calculs sur métriques existantes + colonne `source` + colonne `is_subscribed`
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Part d'aléatoire (exploration)
|
||||
|
||||
**Décision** : 10% par défaut, paramétrable utilisateur
|
||||
|
||||
**Fonctionnement** :
|
||||
- 1 contenu sur 10 = tirage aléatoire (hors historique déjà écouté)
|
||||
- Utilisateur peut ajuster : curseur 0% (aucun aléatoire) à 50% (exploration max)
|
||||
|
||||
**Curseur utilisateur** :
|
||||
- 🎯 **0%** : Personnalisé max (recommandations strictes)
|
||||
- ⚖️ **10%** : Équilibré (défaut)
|
||||
- 🎲 **30%** : Découverte élevée
|
||||
- 🌍 **50%** : Découverte max (équivaut à national = découverte)
|
||||
|
||||
**Justification** :
|
||||
- Évite la bulle de filtre
|
||||
- Laisse l'utilisateur maître de son expérience
|
||||
- Coût : variable aléatoire en algo
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Contenu politique (version MVP simplifiée)
|
||||
|
||||
> ⚠️ **Note** : La classification politique avancée (échelle gauche/droite, équilibrage imposé) a été reportée post-MVP. Voir [ANNEXE-POST-MVP.md](ANNEXE-POST-MVP.md) pour la version complète.
|
||||
|
||||
**Décision MVP** : Tag simple "Politique" sans classification idéologique
|
||||
|
||||
**Tagging** :
|
||||
- Créateur peut taguer son contenu comme "Politique" (optionnel)
|
||||
- Tag "Politique" au même niveau que "Économie", "Sport", "Culture", etc.
|
||||
- **Pas de classification gauche/droite**
|
||||
- **Pas d'équilibrage imposé**
|
||||
|
||||
**Filtrage utilisateur** :
|
||||
- Option paramètres : **"Masquer contenu politique"**
|
||||
- Si activé → 0% de contenus tagués "Politique" dans le feed
|
||||
- Par défaut : désactivé (tous contenus visibles)
|
||||
|
||||
**Justification MVP** :
|
||||
- **Simplicité** : Pas de modération politique coûteuse (~2000€/mois économisés)
|
||||
- **Neutralité technique** : Aucun jugement éditorial sur orientation
|
||||
- **Risque minimal** : Évite controverses et contentieux DSA au lancement
|
||||
- **Fonctionnel** : Utilisateurs peuvent filtrer si souhaité
|
||||
|
||||
**Post-MVP** :
|
||||
- Classification avancée possible si forte demande utilisateurs
|
||||
- Nécessite ressources modération dédiées et audit DSA
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Mode Kids (13-15 ans)
|
||||
|
||||
**Décision** : Mode optionnel pour adolescents 13-15 ans uniquement
|
||||
|
||||
> ⚠️ **Note** : Âge minimum d'inscription = **13 ans** (obligation légale EU). Pas d'utilisateurs <13 ans sur la plateforme.
|
||||
|
||||
**Tranche concernée** :
|
||||
|
||||
| Tranche | Description | Contenus autorisés | Restrictions |
|
||||
|---------|-------------|-------------------|--------------|
|
||||
| **13-15 ans** | Collège | Contenus "Tous publics" uniquement | Filtrage 16+ et 18+ |
|
||||
|
||||
**Activation** :
|
||||
- ❌ **Pas d'activation automatique** (tous les utilisateurs ont ≥13 ans)
|
||||
- ✅ **Activation manuelle** via toggle paramètres
|
||||
- ✅ Parents peuvent activer pour leurs enfants 13-15 ans
|
||||
- ✅ Utilisateur peut désactiver à tout moment
|
||||
|
||||
**Filtrage quand Mode Kids activé** :
|
||||
- ✅ Contenus "Tous publics" uniquement
|
||||
- ❌ Exclusion contenus 16+ et 18+
|
||||
- ❌ Pas de contenu politique (automatiquement filtré)
|
||||
- ❌ Pas de publicité (ou uniquement pub validée manuellement)
|
||||
|
||||
**Interface** :
|
||||
- Interface standard (pas d'interface dédiée enfants pour MVP)
|
||||
- Filtrage algorithmique des contenus inappropriés
|
||||
|
||||
**Justification** :
|
||||
- **Conformité légale** : Âge minimum 13 ans (RGPD, DSA)
|
||||
- **Simplicité MVP** : Un seul mode optionnel vs 4 tranches d'âge
|
||||
- **Protection mineurs** : Filtrage contenus adultes pour 13-15 ans
|
||||
- **Flexibilité** : Parents décident d'activer ou non
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Déclenchement géographique
|
||||
|
||||
**Décision** : Notification au passage, pas d'anticipation
|
||||
|
||||
**Fonctionnement** :
|
||||
1. Utilisateur passe à <500m d'un point GPS (contenu géo-ancré)
|
||||
2. **Notification sonore** (bip court) + **visuelle** (logo selon type)
|
||||
3. Types de logos : 📍 Info, 🏛️ Culturel, 🍴 Commercial, 🎭 Événement
|
||||
4. Délai réaction utilisateur : **5 secondes** pour accepter (bouton volant ou commande vocale)
|
||||
5. Si accepté → lecture immédiate
|
||||
6. Si ignoré → contenu proposé normalement en file d'attente
|
||||
|
||||
**Publicités** :
|
||||
- ⚠️ **Jamais d'interruption** de contenu en cours
|
||||
- Pub s'intercale **entre deux séquences** uniquement
|
||||
- Notification pub : son différent (facultatif selon paramètres)
|
||||
|
||||
**Gestion demi-tour** :
|
||||
- Si utilisateur repart du point après notification → pas de nouvelle notification (déjà proposé)
|
||||
- Réinitialisation après 24h
|
||||
|
||||
**Justification** :
|
||||
- Respect écoute en cours (pas de coupure brutale)
|
||||
- UX fluide (utilisateur garde contrôle)
|
||||
- Simplicité technique (pas de prédiction trajectoire)
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Historique et repropositon
|
||||
|
||||
**Décision** : Pas de reproposition sauf contenu partiel ou skip d'abonné
|
||||
|
||||
**Règles** :
|
||||
|
||||
| État écoute | Completion | Abonné au créateur ? | Action |
|
||||
|-------------|------------|---------------------|--------|
|
||||
| **Écouté complètement** | >80% | Peu importe | ❌ Ne jamais reproposer (sauf flag `replayable = true` pour audio-guides) |
|
||||
| **Skippé rapidement** | <10s | ❌ Non | ❌ Ne pas reproposer (signal négatif clair) |
|
||||
| **Skippé rapidement** | <10s | ✅ **Oui** | ✅ **Peut reproposer** (abonnement = affinité, skip contextuel) |
|
||||
| **Partiellement écouté** | 10-80% | Peu importe | ✅ Reproposer avec reprise position (`last_position_seconds`) |
|
||||
|
||||
**Stockage historique** :
|
||||
- Table `user_content_history` (user_id, content_id, creator_id, **is_subscribed**, completion_rate, last_position, listened_at)
|
||||
- Historique **illimité** (PostgreSQL)
|
||||
- Algorithme considère les **100 derniers** pour optimisation requêtes
|
||||
- Export complet disponible (RGPD)
|
||||
|
||||
**Colonne `is_subscribed`** :
|
||||
- Booléen stockant si l'utilisateur était abonné au créateur **au moment de l'écoute**
|
||||
- Permet de distinguer les skips d'abonnés (contextuels) des skips de non-abonnés (désintérêt)
|
||||
- Utilisé pour décisions de reproposition et calculs d'engagement
|
||||
|
||||
**Justification** :
|
||||
- Découverte maximale (pas de redites)
|
||||
- **Cohérence abonnement** : un skip ponctuel d'un abonné ≠ rejet du créateur (peut être contextuel : "pas maintenant", "pas ce sujet", "mauvais timing")
|
||||
- Respect erreurs de clic (contenu partiel = 2nde chance)
|
||||
- Coût stockage négligeable (PostgreSQL scalable)
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Paramétrabilité admin (interface dashboard)
|
||||
|
||||
**Décision** : Tous paramètres scoring exposés + A/B testing
|
||||
|
||||
**Paramètres configurables à chaud** :
|
||||
|
||||
| Paramètre | Plage | Défaut | Unité |
|
||||
|-----------|-------|--------|-------|
|
||||
| `poids_geo_ancre` | 0.5 - 1.0 | 0.7 | % |
|
||||
| `poids_geo_contextuel` | 0.3 - 0.7 | 0.5 | % |
|
||||
| `poids_geo_neutre` | 0.0 - 0.4 | 0.2 | % |
|
||||
| `poids_engagement` | 0.0 - 0.5 | 0.2 | % |
|
||||
| `part_aleatoire_global` | 0.0 - 0.3 | 0.1 | % |
|
||||
| `distance_max_km` | 50 - 500 | 200 | km |
|
||||
| `rayon_gps_point_m` | 100 - 2000 | 500 | m |
|
||||
| `seuil_min_ecoutes_engagement` | 10 - 200 | 50 | nb |
|
||||
|
||||
**Application changements** :
|
||||
- Immédiat : nouveaux calculs utilisent nouvelle config
|
||||
- Aucun recalcul batch (coût CPU)
|
||||
- Version config trackée (git-like)
|
||||
- Rollback 1 clic
|
||||
|
||||
**A/B Testing** :
|
||||
- Création variantes (Config A vs Config B)
|
||||
- Split utilisateurs 50/50 aléatoire
|
||||
- Métriques comparatives : taux complétion, engagement, session duration
|
||||
- Dashboard graphique temps réel
|
||||
|
||||
**Audit engagement** :
|
||||
- Métriques clés : moyenne/médiane temps d'écoute par session
|
||||
- Graphiques : évolution engagement selon config
|
||||
- Export CSV pour analyse externe
|
||||
|
||||
**Justification** :
|
||||
- Optimisation continue sans redéploiement
|
||||
- Data-driven decisions (métriques objectives)
|
||||
- Coût : dashboard admin à développer (one-time)
|
||||
|
||||
---
|
||||
|
||||
### 2.10 Paramétrabilité utilisateur
|
||||
|
||||
**Décision** : Curseurs avancés avec profils sauvegardables
|
||||
|
||||
**Niveaux de personnalisation** :
|
||||
|
||||
**Curseurs disponibles** :
|
||||
- 📍 **Géolocalisation** : Local ← slider → National (découverte = national)
|
||||
- 🎲 **Découverte** : 0% ← slider → 50% (part aléatoire)
|
||||
- ⚖️ **Politique** : Masquer / Équilibré / Mes préférences
|
||||
|
||||
**Profils sauvegardables** :
|
||||
- 🚗 Trajet quotidien (boulot) : géo local, découverte 5%, politique masqué
|
||||
- 🛣️ Road trip : géo régional, découverte 30%, politique équilibré
|
||||
- 👶 Enfants : Mode Kids activé
|
||||
|
||||
**Synchronisation** :
|
||||
- ✅ Sync profils entre devices (cloud PostgreSQL)
|
||||
- ❌ Pas de partage profils entre utilisateurs (famille)
|
||||
- Auto-switch selon context (détection trajet récurrent via GPS)
|
||||
|
||||
**Sécurité conduite** :
|
||||
- ⚠️ **Blocage modification si vitesse GPS >10 km/h**
|
||||
- Warning au lancement app : "Configurez avant de prendre la route"
|
||||
- Modifications uniquement app arrêtée/passager
|
||||
|
||||
**Justification** :
|
||||
- Utilisateur maître de son expérience
|
||||
- Contextes d'usage différents (quotidien vs voyage)
|
||||
- Sécurité routière (pas de distraction)
|
||||
|
||||
---
|
||||
|
||||
### 2.11 Médias traditionnels
|
||||
|
||||
**Décision** : Ouverture aux médias établis
|
||||
|
||||
**Médias autorisés** :
|
||||
- Presse nationale : Le Monde, Le Parisien, Libération, Le Figaro, etc.
|
||||
- Radios : France Inter, RTL, Europe 1, etc.
|
||||
- Médias régionaux : Ouest-France, Sud-Ouest, etc.
|
||||
|
||||
**Format contenus** :
|
||||
- Flashs info géolocalisés (actualité régionale)
|
||||
- Chroniques thématiques (culture, économie, sport)
|
||||
- Éditos et débats (classification politique appliquée)
|
||||
|
||||
**Validation** :
|
||||
- Compte média vérifié (badge ✓)
|
||||
- Pas de validation 3 premiers contenus (confiance établie)
|
||||
- Modération a posteriori uniquement
|
||||
|
||||
**Monétisation** :
|
||||
- Partage revenus pub standard (même conditions créateurs)
|
||||
- Possibilité sponsoring direct (pas via plateforme)
|
||||
|
||||
**Justification** :
|
||||
- Crédibilité plateforme (contenus professionnels)
|
||||
- Diversité éditoriale
|
||||
- Attractivité grand public (noms reconnus)
|
||||
|
||||
---
|
||||
|
||||
## Récapitulatif Section 2
|
||||
160
docs/domains/recommendation/rules/centres-interet-jauges.md
Normal file
160
docs/domains/recommendation/rules/centres-interet-jauges.md
Normal file
@@ -0,0 +1,160 @@
|
||||
## 3. Centres d'intérêt et jauges
|
||||
|
||||
### 3.1 Évolution des jauges
|
||||
|
||||
**Décision** : Système simple avec valeurs fixes (points de pourcentage absolus)
|
||||
|
||||
| Action | Impact jauge | Justification |
|
||||
|--------|--------------|---------------|
|
||||
| **Like automatique renforcé (≥80% écoute)** | **+2%** | Signal fort d'intérêt (écoute quasi-complète) |
|
||||
| **Like automatique standard (30-79% écoute)** | **+1%** | Signal modéré d'intérêt |
|
||||
| **Like explicite (manuel)** | **+2%** | Signal fort, cumulable avec auto |
|
||||
| **Abonnement créateur** | **+5%** sur tous ses tags | Signal très fort d'affinité |
|
||||
| **Skip rapide (<10s), NON abonné** | **-0.5%** | Désintérêt marqué (signal négatif légitime) |
|
||||
| **Skip rapide (<10s), ABONNÉ au créateur** | **0%** (neutre) | Abonnement = affinité forte, skip contextuel (pas ce contenu spécifique) |
|
||||
| **Skip tardif (≥30%)** | **0%** | Neutre (contenu essayé suffisamment) |
|
||||
|
||||
**Note importante** : Les pourcentages indiqués sont des **points de pourcentage absolus**, **PAS des pourcentages relatifs**.
|
||||
|
||||
**Calcul** :
|
||||
- Si jauge "Automobile" = 45%
|
||||
- Like renforcé (+2%) → 45 + 2 = **47%** ✅
|
||||
- **NOT** 45 × 1.02 = 45.9% ❌
|
||||
|
||||
Cette approche garantit une **progression linéaire** et **équitable** pour tous les utilisateurs, indépendamment de leur niveau actuel dans une jauge.
|
||||
|
||||
**Paramètres techniques** :
|
||||
- Les jauges sont bornées strictement entre **0% et 100%**
|
||||
- Calcul immédiat à chaque action (pas de batch différé)
|
||||
- Les tags du contenu sont définis par le créateur à la publication
|
||||
- Si un contenu a plusieurs tags, chaque jauge correspondante est impactée
|
||||
|
||||
**Exemple de calcul** :
|
||||
```
|
||||
Contenu de 5 minutes tagué "Automobile" + "Voyage"
|
||||
|
||||
Scénario 1 : Écoute 4min30 (90%)
|
||||
→ Like automatique renforcé (+2%)
|
||||
→ Jauge Automobile : 45% → 47%
|
||||
→ Jauge Voyage : 60% → 62%
|
||||
|
||||
Scénario 2 : Écoute 2min30 (50%)
|
||||
→ Like automatique standard (+1%)
|
||||
→ Jauge Automobile : 45% → 46%
|
||||
→ Jauge Voyage : 60% → 61%
|
||||
|
||||
Scénario 3 : Écoute 2min30 (50%) + Like manuel
|
||||
→ Like auto +1% puis like manuel +2% = +3% total
|
||||
→ Jauge Automobile : 45% → 48%
|
||||
→ Jauge Voyage : 60% → 63%
|
||||
|
||||
Scénario 4 : Skip après 5s (NON abonné au créateur)
|
||||
→ Signal négatif (-0.5%)
|
||||
→ Jauge Automobile : 45% → 44.5%
|
||||
→ Jauge Voyage : 60% → 59.5%
|
||||
|
||||
Scénario 5 : Skip après 5s (ABONNÉ au créateur)
|
||||
→ Neutre (0%, pas de pénalité)
|
||||
→ Jauge Automobile : 45% → 45%
|
||||
→ Jauge Voyage : 60% → 60%
|
||||
→ Raison : Abonnement signale affinité globale, skip ponctuel = pas intéressé par CE contenu spécifique
|
||||
```
|
||||
|
||||
**Justification** :
|
||||
- **Like automatique** : Reflète l'engagement réel (voir [Règle 05 - Section 5.3](05-interactions-navigation.md#53-interactions-au-volant--like-automatique-et-engagement))
|
||||
- **Sécurité routière** : Pas d'action complexe en conduite
|
||||
- **Prévisibilité** : Règles claires et déterministes
|
||||
- **Progression linéaire** : Évite l'effet "rich get richer" (progression équitable)
|
||||
- **Coût minimal** : Calculs simples en backend (addition/soustraction uniquement)
|
||||
- **Fiabilité** : Pas d'edge cases complexes (pas de risque d'overflow avec multiplication)
|
||||
- **Ajustable** : Valeurs modifiables via dashboard admin si besoin
|
||||
|
||||
> 📋 **Référence technique** : Voir [Règle 05 - Implémentation Technique](05-interactions-navigation.md#implémentation-technique-backend) pour l'architecture backend détaillée.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Jauge initiale
|
||||
|
||||
**Décision** : Démarrage neutre à 50%, pas de questionnaire
|
||||
|
||||
**À l'inscription** :
|
||||
- Toutes les jauges d'intérêt sont initialisées à **50%**
|
||||
- Pas de questionnaire onboarding (friction zéro)
|
||||
- L'algorithme apprend naturellement via les premières écoutes
|
||||
|
||||
**Catégories disponibles** :
|
||||
- Automobile
|
||||
- Voyage
|
||||
- Famille
|
||||
- Amour
|
||||
- Musique
|
||||
- Économie
|
||||
- Cryptomonnaie
|
||||
- Politique
|
||||
- Culture générale
|
||||
- Sport
|
||||
- Technologie
|
||||
- Santé
|
||||
- *... (extensible)*
|
||||
|
||||
**Cold start (premiers jours)** :
|
||||
1. Nouvel utilisateur s'inscrit → toutes jauges à 50%
|
||||
2. Écoute premier podcast "Automobile" → jauge Auto monte à 51%
|
||||
3. Skip un contenu "Économie" → jauge Éco descend à 48%
|
||||
4. Après 10-15 écoutes, profil commence à se dessiner clairement
|
||||
|
||||
**Alternative optionnelle (post-MVP)** :
|
||||
- Questionnaire **optionnel** proposé après 3 écoutes (in-app)
|
||||
- Message : "Améliorez vos recommandations en sélectionnant vos centres d'intérêt"
|
||||
- Si rempli : jauges sélectionnées passent à 70%, non sélectionnées à 30%
|
||||
- Si skip : conserve 50% partout
|
||||
|
||||
**Justification** :
|
||||
- **Inscription ultra-rapide** : pas de questionnaire = moins de churn
|
||||
- **Découverte naturelle** : l'algorithme apprend en quelques écoutes
|
||||
- **Équitable** : pas de biais initial vers certains créateurs
|
||||
- **Comportement déterministe** : facile à tester et débugger
|
||||
- **Cold start acceptable** : à 50%, tous les contenus ont une chance égale initialement
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Dégradation temporelle
|
||||
|
||||
**Décision** : Pas de dégradation automatique
|
||||
|
||||
Les jauges **ne diminuent jamais** avec le temps de manière automatique.
|
||||
|
||||
**Règle** :
|
||||
- Une jauge ne change **que par les actions utilisateur** (like, écoute, skip)
|
||||
- Pas de cron job de dégradation périodique
|
||||
- Pas de "rafraîchissement" artificiel
|
||||
|
||||
**Scénario illustratif** :
|
||||
```
|
||||
Utilisateur aimait "Économie" (jauge 80%) il y a 1 an
|
||||
→ Depuis, skip tous les contenus Éco
|
||||
→ Jauge descend naturellement à 40% via les skips
|
||||
→ Pas besoin de dégradation temporelle
|
||||
```
|
||||
|
||||
**Si utilisateur inactif longtemps** :
|
||||
- Utilisateur part en vacances 6 mois → jauges conservées
|
||||
- Au retour : ses jauges reflètent toujours ses goûts d'avant
|
||||
- Comportement cohérent et prévisible
|
||||
|
||||
**Alternative utilisateur (contrôle explicite)** :
|
||||
- Bouton "Réinitialiser mes centres d'intérêt" dans paramètres
|
||||
- Action manuelle : remet toutes les jauges à 50%
|
||||
- Permet nouveau départ si souhaité (changement de vie, etc.)
|
||||
|
||||
**Justification** :
|
||||
- **Principe KISS** (Keep It Simple, Stupid)
|
||||
- **Coût 0** : pas de batch nocturne, pas de calculs temporels
|
||||
- **Fiabilité maximale** : pas de bugs de fuseaux horaires, dates, etc.
|
||||
- **UX prévisible** : jauge = reflet des actions, pas d'automatisme caché
|
||||
- **Respect historique** : si utilisateur aimait X depuis 2 ans, pourquoi "oublier" ?
|
||||
- **Évolution naturelle** : les actions récentes suffisent à faire évoluer les jauges
|
||||
|
||||
---
|
||||
|
||||
## Récapitulatif Section 3
|
||||
574
docs/domains/recommendation/rules/interactions-navigation.md
Normal file
574
docs/domains/recommendation/rules/interactions-navigation.md
Normal file
@@ -0,0 +1,574 @@
|
||||
## 5. Interactions et navigation
|
||||
|
||||
### 5.1 File d'attente et commande "Suivant"
|
||||
|
||||
**Décision** : Pré-calcul 5 contenus avec insertion prioritaire pour points géographiques
|
||||
|
||||
**File d'attente** :
|
||||
- **5 contenus pré-calculés** en cache (Redis)
|
||||
- Recalcul automatique si :
|
||||
- Déplacement >10km
|
||||
- Toutes les 10 minutes (rafraîchissement contenu)
|
||||
- File d'attente <3 contenus restants
|
||||
|
||||
**Insertion prioritaire géo-ancrée (mode voiture uniquement)** :
|
||||
|
||||
**Détection** :
|
||||
- Calcul ETA (Estimated Time of Arrival) via API GPS native iOS/Android
|
||||
- Notification déclenchée **7 secondes avant** d'arriver au point GPS
|
||||
- Si vitesse < 5 km/h ET distance < 50m → notification immédiate
|
||||
- ⚠️ **App doit être ouverte** (pas de détection en arrière-plan en mode voiture)
|
||||
|
||||
**Notification** :
|
||||
- **Sonore uniquement** : bip court ou son personnalisé RoadWave
|
||||
- **Visuelle minimale** : icône selon type de contenu (🏛️ culture, 👨👩👧 famille, 🎵 musique, etc.)
|
||||
- **Compteur visible** : 7...6...5...4...3...2...1 (décompte des secondes)
|
||||
- **Pas de texte affiché** (éviter distraction conducteur)
|
||||
- **Pas de bouton "Annuler"** : seul le bouton "Suivant" permet validation
|
||||
|
||||
**Actions utilisateur** :
|
||||
1. User entend notification sonore + voit icône et compteur
|
||||
2. User appuie "Suivant" dans les 7 secondes → décompte 5s démarre
|
||||
3. Pendant décompte : contenu actuel continue, compteur visible (5...4...3...2...1)
|
||||
4. Si contenu actuel se termine pendant décompte → contenu suivant du buffer démarre
|
||||
5. À la fin du décompte → contenu géolocalisé démarre (fade out/in 0.3s)
|
||||
|
||||
**Si user n'appuie pas sur "Suivant"** :
|
||||
- Notification disparaît après 7 secondes
|
||||
- Contenu géolocalisé est perdu (pas d'insertion dans file)
|
||||
- Pas de nouveau contenu géolocalisé pendant **10 minutes** (éviter spam)
|
||||
|
||||
**Limitation anti-spam** :
|
||||
- Maximum **6 contenus géolocalisés par heure**
|
||||
- Timer reset toutes les heures (rolling window)
|
||||
- Exception : séquences d'un même audio-guide multi-séquences (comptent comme 1)
|
||||
- Si quota atteint : notifications suivantes ignorées jusqu'à libération du quota
|
||||
|
||||
**Invalidation immédiate** :
|
||||
- Utilisateur change ses préférences (curseurs géo/découverte/politique)
|
||||
- ⚠️ **Modification bloquée si vitesse GPS >10 km/h** (sécurité routière)
|
||||
- Live démarre d'un créateur suivi dans la zone
|
||||
|
||||
**Implémentation** :
|
||||
```
|
||||
Redis cache :
|
||||
- Clé : user:{user_id}:queue
|
||||
- Structure : [content_1, content_2, ..., content_5]
|
||||
- Métadonnées : {last_lat, last_lon, computed_at, mode: "voiture"|"pieton"}
|
||||
- TTL : 15 minutes
|
||||
|
||||
Tracking GPS temps réel (mobile) :
|
||||
- Vérification toutes les 1 seconde
|
||||
- Calcul ETA vers points géolocalisés proches (rayon 500m)
|
||||
- Si ETA ≤ 7s → trigger notification
|
||||
- Historique GPS : 30 derniers points pour calcul vitesse moyenne
|
||||
|
||||
Quota anti-spam (Redis) :
|
||||
- Clé : user:{user_id}:geo_quota
|
||||
- Structure : sorted set avec timestamps des 6 derniers contenus
|
||||
- TTL : 1 heure
|
||||
- Vérification avant notification : ZCOUNT pour compter contenus dernière heure
|
||||
|
||||
Cooldown après ignorance (Redis) :
|
||||
- Clé : user:{user_id}:geo_cooldown
|
||||
- TTL : 10 minutes
|
||||
- Set après notification ignorée
|
||||
```
|
||||
|
||||
**Justification** :
|
||||
- **Expérience fluide** : pas de latence au clic "Suivant"
|
||||
- **Réactivité géo** : contenu local inséré immédiatement
|
||||
- **Coût optimisé** : recalcul uniquement si nécessaire
|
||||
- **Sécurité** : pas de modification en conduite
|
||||
|
||||
---
|
||||
|
||||
### 5.1.2 Mode piéton (audio-guides)
|
||||
|
||||
**Décision** : Notifications push en arrière-plan avec rayon large
|
||||
|
||||
**Contexte** :
|
||||
- Mode piéton détecté automatiquement si vitesse moyenne < 5 km/h
|
||||
- Cas d'usage : visites à pied, musées, monuments, quartiers historiques
|
||||
- User n'a pas besoin d'avoir l'app ouverte
|
||||
- ⚠️ **Fonctionnalité optionnelle** : requiert permission "localisation en arrière-plan" (activée par user)
|
||||
|
||||
**Détection** :
|
||||
- App peut être en arrière-plan (si permission accordée)
|
||||
- Rayon de détection : **200 mètres** autour du point GPS
|
||||
- Geofencing iOS/Android pour minimiser consommation batterie
|
||||
- Permission demandée uniquement si user active "Notifications audio-guides piéton" dans settings
|
||||
|
||||
**Notification push système** :
|
||||
|
||||
Format :
|
||||
```
|
||||
Titre : "Audio-guide à proximité"
|
||||
Body : "[Nom du contenu] - [Nom créateur]"
|
||||
Action : Tap → ouvre app sur le contenu
|
||||
```
|
||||
|
||||
Exemple :
|
||||
```
|
||||
Audio-guide à proximité
|
||||
Musée du Louvre : La Joconde - @paris_museum
|
||||
```
|
||||
|
||||
**Permissions requises** :
|
||||
|
||||
⚠️ **Important** : RoadWave utilise une **stratégie de permissions progressive** pour maximiser l'acceptation utilisateur et la validation stores.
|
||||
|
||||
**Étape 1 - Permission de base (tous utilisateurs)** :
|
||||
- iOS : "Allow While Using App" (`locationWhenInUse`)
|
||||
- Android : `ACCESS_FINE_LOCATION`
|
||||
- **Demandée** : Au premier lancement (onboarding)
|
||||
- **Permet** : Mode voiture complet ✅
|
||||
|
||||
**Étape 2 - Permission arrière-plan (optionnelle, mode piéton uniquement)** :
|
||||
- iOS : "Allow Always" (`locationAlways`)
|
||||
- Android : `ACCESS_BACKGROUND_LOCATION`
|
||||
- **Demandée** : Uniquement si user active "Notifications audio-guides piéton" dans settings
|
||||
- **Précédée** : Écran d'éducation expliquant l'usage (requis stores)
|
||||
- **Permet** : Mode piéton avec notifications push en arrière-plan ✅
|
||||
|
||||
**Si permission arrière-plan refusée** :
|
||||
- Mode piéton **désactivé** (toggle grisé dans settings)
|
||||
- Mode voiture reste **pleinement fonctionnel**
|
||||
- Audio-guides accessibles en mode **manuel** (user ouvre app, lance contenu)
|
||||
- **Garantie RGPD** : App utilisable sans permission arrière-plan ✅
|
||||
|
||||
iOS (`Info.plist`) :
|
||||
```xml
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>RoadWave utilise votre position pour vous proposer des contenus audio géolocalisés adaptés à votre trajet en temps réel.</string>
|
||||
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Si vous activez les notifications audio-guides piéton, RoadWave peut vous alerter lorsque vous passez près d'un monument ou musée, même quand l'app est en arrière-plan. Cette fonctionnalité est optionnelle et peut être désactivée à tout moment dans les réglages.</string>
|
||||
```
|
||||
|
||||
Android (`AndroidManifest.xml`) :
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
```
|
||||
|
||||
> 📋 **Référence technique** : Voir [ADR-010 - Stratégie de Permissions](../adr/010-frontend-mobile.md#stratégie-de-permissions-iosandroid) pour détails d'implémentation.
|
||||
|
||||
**Disclosure avant demande permission** (Android requis, iOS recommandé) :
|
||||
|
||||
Écran affiché avant demande permission "Always Location" :
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ 📍 Notifications audio-guides piéton │
|
||||
├────────────────────────────────────────┤
|
||||
│ Pour vous alerter d'audio-guides à │
|
||||
│ proximité même quand vous marchez avec │
|
||||
│ l'app fermée, RoadWave a besoin de │
|
||||
│ votre position en arrière-plan. │
|
||||
│ │
|
||||
│ Votre position sera utilisée pour : │
|
||||
│ ✅ Détecter monuments à 200m │
|
||||
│ ✅ Vous envoyer une notification │
|
||||
│ │
|
||||
│ Votre position ne sera jamais : │
|
||||
│ ❌ Vendue à des tiers │
|
||||
│ ❌ Utilisée pour de la publicité │
|
||||
│ │
|
||||
│ Cette fonctionnalité est optionnelle. │
|
||||
│ Vous pouvez utiliser RoadWave sans │
|
||||
│ cette permission. │
|
||||
│ │
|
||||
│ [Continuer] [Non merci] │
|
||||
│ │
|
||||
│ Plus d'infos : Politique confidentialité│
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Si user refuse** :
|
||||
- Mode piéton désactivé (uniquement mode voiture disponible)
|
||||
- App fonctionne normalement avec permission "When In Use"
|
||||
- Audio-guides accessibles en mode manuel (user ouvre app, sélectionne contenu)
|
||||
|
||||
**Comportement après tap sur notification** :
|
||||
1. User tap notification push
|
||||
2. App s'ouvre sur la page du contenu
|
||||
3. User peut démarrer la lecture manuellement
|
||||
4. Navigation libre (voir section 16.2 pour audio-guides piéton)
|
||||
|
||||
**Basculement automatique voiture ↔ piéton** :
|
||||
|
||||
Détection par vitesse GPS moyenne sur 30 secondes :
|
||||
- Vitesse < 5 km/h (stable 10s) → mode piéton
|
||||
- Vitesse ≥ 5 km/h (stable 10s) → mode voiture
|
||||
|
||||
Changements de mode :
|
||||
|
||||
| Mode actuel | Vitesse détectée | Nouveau mode | Effet |
|
||||
|-------------|------------------|--------------|-------|
|
||||
| Piéton | ≥ 5 km/h | Voiture | Notifications push → sonores + icône (app ouverte requise) |
|
||||
| Voiture | < 5 km/h | Piéton | Notifications sonores → push arrière-plan |
|
||||
|
||||
**Pas de popup confirmation** :
|
||||
- Basculement transparent et automatique
|
||||
- User n'a rien à faire
|
||||
- Hysteresis (10s) pour éviter basculements intempestifs
|
||||
|
||||
**Quota anti-spam mode piéton** :
|
||||
- Même limitation que mode voiture : **6 contenus/heure**
|
||||
- Cooldown 10 min si notification ignorée (app pas ouverte après tap)
|
||||
|
||||
**Justification** :
|
||||
- ✅ Expérience adaptée aux visites à pied (rayon large, pas de timing précis)
|
||||
- ✅ Économie batterie (geofencing natif iOS/Android)
|
||||
- ✅ User peut garder téléphone en poche
|
||||
- ✅ Basculement automatique = pas de friction
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Commande "Précédent"
|
||||
|
||||
**Décision** : Comportement smart selon progression écoute
|
||||
|
||||
**Règles** :
|
||||
|
||||
| Situation | Temps écouté | Action "Précédent" |
|
||||
|-----------|--------------|-------------------|
|
||||
| **Début de contenu** | <10 secondes | Retour au contenu précédent (position exacte) |
|
||||
| **Milieu/fin** | ≥10 secondes | Replay contenu actuel depuis le début |
|
||||
| **Premier de session** | N/A | Replay depuis début (rien avant) |
|
||||
|
||||
**Historique de navigation** :
|
||||
- **10 contenus maximum** en mémoire (Redis List)
|
||||
- Structure : `[{content_id, position_seconds, listened_at}, ...]`
|
||||
- FIFO : au-delà de 10, suppression du plus ancien
|
||||
|
||||
**Exemple scénario** :
|
||||
```
|
||||
Utilisateur écoute :
|
||||
1. Contenu A → écoute 5s → "Suivant"
|
||||
2. Contenu B → écoute 2min30 → "Suivant"
|
||||
3. Contenu C → écoute 5s → "Précédent"
|
||||
→ Retour Contenu B à 2min30 (car >10s)
|
||||
4. Sur Contenu B → "Précédent"
|
||||
→ Retour Contenu A à 5s (position exacte)
|
||||
```
|
||||
|
||||
**Interface (responsabilité front)** :
|
||||
- ❌ Pas de message UI
|
||||
- ✅ Progress bar revient au début ou à position exacte
|
||||
- ✅ Animation fluide (transition 0.3s)
|
||||
|
||||
**Justification** :
|
||||
- **UX intuitive** : comportement standard Spotify/YouTube
|
||||
- **Pas de frustration** : si début, vraiment revenir en arrière
|
||||
- **Simplicité** : règle unique (seuil 10s)
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Interactions au volant : Like automatique et engagement
|
||||
|
||||
**Décision** : Like automatique basé sur le temps d'écoute
|
||||
|
||||
**Problème technique identifié** :
|
||||
- iOS et Android ne supportent **pas nativement** les appuis longs ou doubles-appuis sur les commandes média
|
||||
- Les commandes physiques au volant varient selon les véhicules (pas de bouton "Pause" dédié sur beaucoup de modèles)
|
||||
- Système de double-appui/appui long = **non-intuitif** et **risques sécurité** (regarder écran pour feedback)
|
||||
|
||||
---
|
||||
|
||||
#### Commandes au volant simplifiées
|
||||
|
||||
**Actions disponibles** (100% compatibles tous véhicules) :
|
||||
|
||||
| Commande physique | Action RoadWave |
|
||||
|-------------------|-----------------|
|
||||
| **Suivant** | Passer au contenu suivant |
|
||||
| **Précédent** | Revenir au contenu précédent (règle 10s, voir section 5.2) |
|
||||
| **Play/Pause** | Pause/reprise lecture (fade out 0.3s) |
|
||||
|
||||
**Aucune action complexe au volant** → Sécurité routière maximale.
|
||||
|
||||
---
|
||||
|
||||
#### Like automatique implicite
|
||||
|
||||
**Principe** : Le système détecte automatiquement l'intérêt utilisateur selon le temps d'écoute.
|
||||
|
||||
**Règles d'attribution** :
|
||||
|
||||
| Durée écoutée | Action automatique | Points jauge | Justification |
|
||||
|---------------|-------------------|--------------|---------------|
|
||||
| **≥ 80% du contenu** | Like renforcé | +2.0 | Écoute quasi-complète = fort intérêt |
|
||||
| **30-79% du contenu** | Like standard | +1.0 | Écoute significative = intérêt |
|
||||
| **< 30% du contenu** | Pas de like | 0 | Écoute trop courte |
|
||||
| **Skip après <10s** | Signal négatif | -0.5 | Désintérêt marqué |
|
||||
|
||||
**Exemples concrets** :
|
||||
```
|
||||
Contenu de 3 minutes (180s) :
|
||||
- Écoute 2min30 (83%) → Like renforcé (+2 points)
|
||||
- Écoute 1min15 (42%) → Like standard (+1 point)
|
||||
- Écoute 30s (17%) puis skip → Pas de like
|
||||
- Skip après 5s → Signal négatif (-0.5 point)
|
||||
|
||||
Contenu de 15 minutes (900s) :
|
||||
- Écoute 13min (87%) → Like renforcé (+2 points)
|
||||
- Écoute 6min (40%) → Like standard (+1 point)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Actions complémentaires (mode piéton uniquement)
|
||||
|
||||
**Interface mobile** (vitesse < 5 km/h) :
|
||||
|
||||
| Action | Moyen | Effet |
|
||||
|--------|-------|-------|
|
||||
| **Like explicite** | Bouton cœur | +2 points jauge (même si déjà liké auto) |
|
||||
| **Unlike** | Re-clic cœur (toggle) | -2 points jauge |
|
||||
| **Abonnement** | Bouton "S'abonner" profil créateur | +5 points toutes jauges tags créateur |
|
||||
| **Désabonnement** | Bouton "Se désabonner" | -5 points |
|
||||
| **Signalement** | Menu contextuel "⋮" | Ouverture flux modération |
|
||||
|
||||
**Feedback visuel** :
|
||||
- **Like automatique** : Badge discret "♥ Ajouté à vos favoris" (2s, bas de l'écran)
|
||||
- **Like explicite** : Animation cœur rouge + vibration courte
|
||||
- **Abonnement** : Animation étoile dorée + badge "Abonné ✓"
|
||||
|
||||
**Disponibilité** :
|
||||
- ✅ Mode piéton (vitesse < 5 km/h) : toutes les actions disponibles
|
||||
- ❌ Mode voiture (vitesse ≥ 5 km/h) : aucune de ces actions (sauf like automatique)
|
||||
|
||||
---
|
||||
|
||||
#### Gestion impacts jauges (algorithme)
|
||||
|
||||
**Like automatique** :
|
||||
- Like renforcé (≥80%) → **+2% jauges** de tous les tags du contenu
|
||||
- Like standard (30-79%) → **+1% jauges** des tags du contenu
|
||||
- Signal négatif (skip <10s) → **-0.5% jauges** des tags du contenu
|
||||
|
||||
**Actions explicites** :
|
||||
- Like manuel → **+2% jauges** (cumulable avec like auto)
|
||||
- Unlike → **-2% jauges**
|
||||
- Abonnement → **+5% toutes jauges** tags créateur
|
||||
- Désabonnement → **-5% toutes jauges**
|
||||
|
||||
**Persistance** :
|
||||
- Événements stockés en base (table `listen_events`)
|
||||
- Mise à jour jauges : **immédiate** (Redis) + **async batch** (PostgreSQL)
|
||||
|
||||
---
|
||||
|
||||
#### Implémentation technique
|
||||
|
||||
**Backend** (Go) :
|
||||
|
||||
```go
|
||||
type ListenEvent struct {
|
||||
UserID string
|
||||
ContentID string
|
||||
StartedAt time.Time
|
||||
StoppedAt time.Time
|
||||
Duration int // secondes écoutées
|
||||
ContentTotal int // durée totale contenu
|
||||
Percentage float64 // duration / contentTotal * 100
|
||||
Action string // "completed", "skipped", "paused"
|
||||
}
|
||||
|
||||
func ProcessListenEvent(event ListenEvent) {
|
||||
percentage := event.Percentage
|
||||
|
||||
// Signal négatif fort
|
||||
if event.Action == "skipped" && event.Duration < 10 {
|
||||
UpdateJauges(event.UserID, event.ContentID, -0.5)
|
||||
return
|
||||
}
|
||||
|
||||
// Like automatique
|
||||
if percentage >= 80 {
|
||||
AutoLike(event.UserID, event.ContentID, 2.0) // Renforcé
|
||||
} else if percentage >= 30 {
|
||||
AutoLike(event.UserID, event.ContentID, 1.0) // Standard
|
||||
}
|
||||
// < 30% : pas de like
|
||||
}
|
||||
```
|
||||
|
||||
**Mobile** (iOS/Android) :
|
||||
|
||||
```swift
|
||||
// iOS - Tracking écoute
|
||||
class AudioPlayerManager {
|
||||
var startTime: Date?
|
||||
let contentDuration: TimeInterval
|
||||
|
||||
func onPlay() {
|
||||
startTime = Date()
|
||||
}
|
||||
|
||||
func onStop(action: String) { // "completed" | "skipped" | "paused"
|
||||
guard let start = startTime else { return }
|
||||
let duration = Date().timeIntervalSince(start)
|
||||
let percentage = (duration / contentDuration) * 100
|
||||
|
||||
// API call
|
||||
API.track(ListenEvent(
|
||||
contentId: currentContentId,
|
||||
duration: Int(duration),
|
||||
percentage: percentage,
|
||||
action: action
|
||||
))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Justification
|
||||
|
||||
**Avantages** :
|
||||
- ✅ **Sécurité routière maximale** : aucune action complexe au volant
|
||||
- ✅ **UX intuitive** : comportement standard industrie (Spotify, YouTube Music, Deezer)
|
||||
- ✅ **Compatibilité 100%** : fonctionne sur tous véhicules, tous OS
|
||||
- ✅ **Engagement amélioré** : tous les contenus écoutés génèrent des signaux
|
||||
- ✅ **Algorithme plus précis** : données granulaires (30%, 50%, 80%, 100%)
|
||||
- ✅ **Simplicité développement** : pas de workarounds complexes iOS/Android
|
||||
|
||||
**Inconvénients mitigés** :
|
||||
- ⚠️ Pas de like explicite en conduite → **Mitigation** : like automatique + vocal (CarPlay/Android Auto)
|
||||
- ⚠️ Pas d'abonnement en conduite → **Mitigation** : liste "Créateurs à découvrir" dans app
|
||||
- ⚠️ Like automatique peut surprendre → **Mitigation** : onboarding clair + unlike possible
|
||||
|
||||
---
|
||||
|
||||
#### Communication utilisateurs (onboarding)
|
||||
|
||||
**Écran onboarding 1** :
|
||||
```
|
||||
🚗 Conduite sécurisée
|
||||
|
||||
RoadWave détecte automatiquement vos goûts
|
||||
selon vos écoutes.
|
||||
|
||||
Plus vous écoutez longtemps, plus
|
||||
l'algorithme s'améliore !
|
||||
|
||||
[Suivant]
|
||||
```
|
||||
|
||||
**Écran onboarding 2** :
|
||||
```
|
||||
❤️ Likes automatiques
|
||||
|
||||
Pas besoin de liker manuellement :
|
||||
si vous écoutez >50% d'un contenu,
|
||||
on comprend que vous aimez !
|
||||
|
||||
[Suivant]
|
||||
```
|
||||
|
||||
**Écran onboarding 3** :
|
||||
```
|
||||
⏸️ Commandes simples
|
||||
|
||||
Utilisez les boutons au volant :
|
||||
• Suivant → Prochain contenu
|
||||
• Précédent → Contenu d'avant
|
||||
• Pause → Mettre en pause
|
||||
|
||||
[Commencer]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Implémentation Technique (Backend)
|
||||
|
||||
**Architecture** : 2 services séparés pour responsabilités distinctes
|
||||
|
||||
1. **Gauge Calculation Service** :
|
||||
- Calcule l'ajustement basé sur % écoute (≥80% → +2%, 30-79% → +1%, skip <10s → -0.5%)
|
||||
- Retourne un ajustement absolu (float64, en points de pourcentage)
|
||||
- **Stateless** : logique métier pure, pas d'accès DB
|
||||
- **Testable** : unitairement sans dépendances
|
||||
|
||||
2. **Gauge Update Service** :
|
||||
- Applique l'ajustement aux jauges concernées (addition/soustraction)
|
||||
- Applique les bornes [0, 100] (via fonction `clamp`)
|
||||
- Gère les multi-tags : si contenu a N tags → mise à jour de N jauges
|
||||
- Persiste : Redis (immédiat) + PostgreSQL (batch async)
|
||||
|
||||
**Séparation des responsabilités** :
|
||||
- ✅ **Calculation** : Logique métier pure (réutilisable pour auto-like, skip, actions manuelles)
|
||||
- ✅ **Update** : Persistance et contraintes techniques
|
||||
|
||||
**Pattern de calcul** :
|
||||
```go
|
||||
// ✅ CORRECT : Addition de points absolus
|
||||
newValue := currentValue + adjustment
|
||||
newValue = clamp(newValue, 0.0, 100.0)
|
||||
|
||||
// ❌ INCORRECT : Multiplication (pourcentage relatif)
|
||||
newValue := currentValue * (1 + adjustment/100) // NE PAS FAIRE
|
||||
```
|
||||
|
||||
**Persistance** :
|
||||
- **Redis** : Mise à jour immédiate (latence <10ms)
|
||||
- **PostgreSQL** : Batch async toutes les 5 minutes (cohérence finale)
|
||||
- Raison : Jauges consultées fréquemment (recommandations temps réel)
|
||||
|
||||
---
|
||||
|
||||
### 5.4 Lecture en boucle et enchaînement
|
||||
|
||||
**Décision** : Passage automatique après 2s + insertion pub paramétrable
|
||||
|
||||
**Fin de contenu** :
|
||||
1. Audio termine → **Timer 2 secondes** démarre
|
||||
2. UI overlay : "Contenu suivant dans 2s..." + barre décompte
|
||||
3. Possibilité annuler : bouton "Rester sur ce contenu" (optionnel)
|
||||
4. Timer atteint 0 → passage automatique au contenu suivant
|
||||
|
||||
**Délai selon contexte** :
|
||||
|
||||
| Mode | Délai | Justification |
|
||||
|------|-------|---------------|
|
||||
| **Standard** | 2 secondes | Temps réaction confortable |
|
||||
| **Mode Kids** | 1 seconde | Attention courte enfants |
|
||||
| **Live** | 0 seconde | Enchaînement immédiat |
|
||||
|
||||
**Insertion publicité** :
|
||||
- Pub s'insère **pendant le délai de 2s** (transition naturelle)
|
||||
- Fréquence : **paramétrable admin** (défaut : 1 pub / 5 contenus)
|
||||
- Message : "Publicité (15s)" puis lecture pub
|
||||
- ⚠️ **Jamais d'interruption** d'un contenu en cours
|
||||
|
||||
**Publicité skippable** :
|
||||
- Durée minimale visionnage : **paramétrable** (défaut : 5 secondes)
|
||||
- Bouton "Passer" apparaît après délai
|
||||
- Métriques engagement : taux skip, durée écoute moyenne
|
||||
- **Like et abonnement autorisés sur pub** (engagement créateur pub)
|
||||
|
||||
**Si aucun contenu disponible** :
|
||||
1. Message : "Aucun contenu disponible dans cette zone"
|
||||
2. Proposition : "Élargir la zone de recherche ?" (bouton)
|
||||
3. Si accepté → relance algo avec rayon +50km
|
||||
4. Sinon → lecture en pause, attente action utilisateur
|
||||
|
||||
**Gestion erreurs** :
|
||||
- Échec chargement contenu suivant → **retry 3× avec backoff exponentiel**
|
||||
- Si 3 échecs → message "Connexion instable, basculement mode offline"
|
||||
- Mode offline → lecture contenus téléchargés uniquement
|
||||
|
||||
**Justification** :
|
||||
- **Fluidité** : enchaînement naturel sans action utilisateur
|
||||
- **Contrôle** : possibilité annuler pendant délai
|
||||
- **Paramétrabilité pub** : évite frustration excès publicité
|
||||
- **Engagement pub** : like/abonnement autorisé = monétisation créateurs pub
|
||||
|
||||
---
|
||||
|
||||
## Récapitulatif Section 5
|
||||
Reference in New Issue
Block a user