Domain — Sémantique des filtres
Chaque query param de /travels ajoute des clauses WHERE sur travels_view. Cette page liste la sémantique exacte de chacun : valeur attendue, AND/OR, edge cases.
Règle générale
- Tous les filtres sont combinés en AND.
?destination=FR&category=ski= voyages français ET catégorie ski. - À l'intérieur d'un même filtre, certains supportent OR (
category,dates). Voir détails. - Tous les filtres s'appliquent sur
travels_view, donc après le filtrage automatique des voyages sans départ futur. - Les filtres s'appliquent dans toutes les branches (filtre-seul ET hybride). En branche hybride, ils sont injectés à l'intérieur des CTE
vector_searchettext_search.
?destination=
| Aspect | Valeur |
|---|---|
| Type | string |
| Format | ISO-2 alpha (CH, FR, IT, AT, …) |
| Valeurs multiples | ❌ non supporté (1 destination par requête) |
| WHERE | destination = %(destination)s |
| Source | raw_data.country.code |
Exemples :
?destination=CH→ voyages suisses uniquement.?destination=fr→ ⚠️ sensible à la casse Postgres si les codes sont stockés en majuscules → potentiellement 0 résultat. L'amont retourne en majuscules.
À noter : le param s'appelle destination mais filtre sur le code ISO-2 du pays, pas le nom. La documentation Horizon utilise parfois « Suisse » — c'est faux pour better-search, il faut CH.
?category=
| Aspect | Valeur |
|---|---|
| Type | string |
| Format | slug(s) séparés par , (ex: ski,bon-plan,reveillons) |
| Valeurs multiples | ✅ OR (au moins un slug doit matcher) |
| WHERE | travel_ranges && %(categories)s::text[] |
| Source | raw_data.travelRanges[*].slug |
L'opérateur && est l'intersection d'arrays Postgres : matche si au moins un élément de travel_ranges est dans la liste demandée.
Exemples :
?category=ski→ voyages taguésski.?category=ski,bon-plan→ voyages taguésskiOUbon-plan(un voyage tagué les deux apparaît une fois).?category=nonexistent-slug→ 0 résultat (pas d'erreur).
Slugs courants observés : ski, bon-plan, reveillons, autocar-4, seaside. Inventaire exact en faisant un SELECT DISTINCT unnest(travel_ranges) FROM travels.
?dates=
| Aspect | Valeur |
|---|---|
| Type | string |
| Format | un ou plusieurs ranges YYYY-MM-DD_YYYY-MM-DD, séparés par , |
| Valeurs multiples | ✅ OR (chaque range est testé en OR) |
| WHERE | EXISTS (SELECT 1 FROM unnest(departure_dates) AS d WHERE d BETWEEN %(start)s AND %(end)s) |
| Source | raw_data.occurrences[*].start |
Un voyage matche si au moins une de ses dates de départ tombe dans au moins un des ranges demandés.
Format de date : ISO 8601 sans heure, ex 2026-01-15. Le serveur parse via datetime.fromisoformat(start) — un format invalide lève une exception 500 (pas de validation explicite côté API).
Exemples :
?dates=2026-01-01_2026-01-31→ voyages avec au moins un départ en janvier 2026.?dates=2026-01-01_2026-01-31,2026-03-01_2026-03-31→ janvier OU mars 2026.
Edge cases :
- Range entièrement dans le passé : retourne 0 résultat (les dates passées sont absentes de
travels_view). - Range qui s'étend à la fois avant et après la date du jour : seuls les départs futurs sont considérés (à cause de la vue).
start > enddans un range : aucun match (PostgresBETWEENest inclusif).
?discountclub=
| Aspect | Valeur |
|---|---|
| Type | bool (true / false) |
| WHERE | discount_club = %(discountclub)s |
| Source | pricePerPersonWithClubSpecialOffers < pricePerPersonWithSpecialOffers |
Vrai si et seulement si le prix Club Buchard est strictement inférieur au prix avec offres spéciales standard.
Exemples :
?discountclub=true→ voyages où l'abonnement Club apporte un rabais.?discountclub=false→ voyages où le Club n'apporte rien de plus.- Omettre → pas de filtre (les deux types remontent).
Edge case : si pricePerPersonWithClubSpecialOffers est NULL, le CASE retourne FALSE. C'est cohérent (pas de rabais Club connu).
?hide_invalid=
| Aspect | Valeur |
|---|---|
| Type | bool |
| WHERE | is_valid = TRUE (uniquement si hide_invalid=true) |
| Source | JSONPath `$.occurrences[*] ? (@.bookingState == 0 |
Un voyage est « valide » s'il a au moins une occurrence avec bookingState ∈ {0, 2} (= bookable).
Exemples :
?hide_invalid=true→ seuls les voyages bookables remontent.?hide_invalid=falseou absent → tous les voyages remontent (y compris ceux dont aucune occurrence n'est bookable).
Asymétrie : contrairement aux autres bool, hide_invalid=false n'ajoute pas de clause WHERE is_valid = FALSE — il n'y a juste pas de filtre. Voir src/search_query.py ligne 48 (if hide_invalid:).
Valeurs bookingState observées :
0— bookable normalement.2— bookable (variante, possiblement waitlist).3— non bookable (raisons diverses, ex: complet).
Voir aussi la docstring de tests/test_search.py qui résume les états des fixtures.
?seaside=
| Aspect | Valeur |
|---|---|
| Type | bool (3 états utiles : true, false, absent) |
| WHERE | is_seaside = %(seaside)s (uniquement si fourni) |
| Source | raw_data.isSeaside (injecté par le reindex sur la source /seaside) |
| Valeur | Comportement |
|---|---|
true | Uniquement les voyages balnéaires (ingérés depuis /seaside) |
false | Uniquement les voyages non-balnéaires (catalogue régulier) |
| absent | Les deux |
⚠️ Sémantique tri-état importante côté front : un toggle « voyages balnéaires uniquement » doit envoyer
?seaside=truequand activé et ne rien envoyer du tout quand désactivé (passeaside=false, qui exclurait les balnéaires).
Implémentation reindex : la source /seaside est pull en deuxième et chaque record reçoit record['isSeaside'] = True avant insertion. Si un même id existe dans les deux sources, l'UPSERT seaside gagne. Voir architecture/reindex-lifecycle.md.
Paramètres de pagination & tri
?page= / ?size=
| Aspect | Valeur |
|---|---|
page | int ≥ 1, défaut 1 |
size | int 1-100, défaut 8 |
| WHERE / LIMIT | LIMIT %(size)s OFFSET %((page-1)*size)s |
totalRecords en réponse = count total avant pagination. totalFilteredRecords = taille de la page courante après pagination.
?orderBy= / ?orderDirection=
| Aspect | Valeur |
|---|---|
orderBy | rating / departure / duration (autres = pas de tri) |
orderDirection | asc (défaut) / desc |
Mapping : _ORDER_BY_MAP dans src/search_query.py :
rating→(raw_data->>'commentsAverageRating')::NUMERICdeparture→next_departure(colonne de la vue)duration→(raw_data->>'duration')::NUMERIC
NULLS LAST est toujours appliqué (ORDER BY ... ASC|DESC NULLS LAST). Un voyage sans rating n'est pas mis en tête avec desc.
Le tri est ignoré en branche hybride (présence de
?search=). L'ordre est alorsRRF DESC. Voirarchitecture/hybrid-search.md.
Paramètres de pertinence (override par requête)
?rrfVectorWeight=
| Type | Plage | Effet |
|---|---|---|
| float | [0, 1] | Pondération RRF de la branche vectorielle vs textuelle |
Override de DEFAULT_RRF_VECTOR_WEIGHT pour cette requête.
?semanticFloor=
| Type | Plage | Effet |
|---|---|---|
| float | [0, 1] | Plancher de similarité cosinus. Voyages avec score plus bas filtrés |
Override de DEFAULT_SEMANTIC_FLOOR.
?tsvectorFloor=
| Type | Plage | Effet |
|---|---|---|
| float | ≥ 0 | Plancher de ts_rank_cd. Idem, mais sur la branche FTS |
Override de DEFAULT_TSVECTOR_FLOOR. Pas borné en haut côté API.
Voir architecture/search-tuning.md pour les recettes de réglage.
Validation et erreurs
- Validation Pydantic (FastAPI) sur les types et bornes :
size∈[1, 100],page≥ 1,rrfVectorWeight/semanticFloor∈[0, 1],tsvectorFloor≥ 0. - Format de date invalide : remonte une exception 500 (
ValueErrordepuisdatetime.fromisoformat). Le client doit envoyer duYYYY-MM-DDouYYYY-MM-DDTHH:MM:SS. - Slug inexistant en
category: pas d'erreur, retourne juste 0 résultat. destinationen minuscules : pas d'erreur, retourne 0 résultat.
Comportement par défaut
Quand aucun filtre n'est passé :
- Tous les voyages avec un départ futur (
travels_view) remontent. ?hide_invalidétant absent, les voyages avecis_valid=falsesont inclus. Côté front, l'attendu est généralement de passer?hide_invalid=true.- L'ordre n'est pas garanti (pas de
ORDER BYinjecté), c'est l'ordre d'index physique. Pour un ordre stable, passerorderBy.

