Skip to content

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_search et text_search.

?destination=

AspectValeur
Typestring
FormatISO-2 alpha (CH, FR, IT, AT, …)
Valeurs multiples❌ non supporté (1 destination par requête)
WHEREdestination = %(destination)s
Sourceraw_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=

AspectValeur
Typestring
Formatslug(s) séparés par , (ex: ski,bon-plan,reveillons)
Valeurs multiples✅ OR (au moins un slug doit matcher)
WHEREtravel_ranges && %(categories)s::text[]
Sourceraw_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és ski.
  • ?category=ski,bon-plan → voyages tagués ski OU bon-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=

AspectValeur
Typestring
Formatun ou plusieurs ranges YYYY-MM-DD_YYYY-MM-DD, séparés par ,
Valeurs multiples✅ OR (chaque range est testé en OR)
WHEREEXISTS (SELECT 1 FROM unnest(departure_dates) AS d WHERE d BETWEEN %(start)s AND %(end)s)
Sourceraw_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 > end dans un range : aucun match (Postgres BETWEEN est inclusif).

?discountclub=

AspectValeur
Typebool (true / false)
WHEREdiscount_club = %(discountclub)s
SourcepricePerPersonWithClubSpecialOffers < 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=

AspectValeur
Typebool
WHEREis_valid = TRUE (uniquement si hide_invalid=true)
SourceJSONPath `$.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=false ou 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=

AspectValeur
Typebool (3 états utiles : true, false, absent)
WHEREis_seaside = %(seaside)s (uniquement si fourni)
Sourceraw_data.isSeaside (injecté par le reindex sur la source /seaside)
ValeurComportement
trueUniquement les voyages balnéaires (ingérés depuis /seaside)
falseUniquement les voyages non-balnéaires (catalogue régulier)
absentLes deux

⚠️ Sémantique tri-état importante côté front : un toggle « voyages balnéaires uniquement » doit envoyer ?seaside=true quand activé et ne rien envoyer du tout quand désactivé (pas seaside=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=

AspectValeur
pageint ≥ 1, défaut 1
sizeint 1-100, défaut 8
WHERE / LIMITLIMIT %(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=

AspectValeur
orderByrating / departure / duration (autres = pas de tri)
orderDirectionasc (défaut) / desc

Mapping : _ORDER_BY_MAP dans src/search_query.py :

  • rating(raw_data->>'commentsAverageRating')::NUMERIC
  • departurenext_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 alors RRF DESC. Voir architecture/hybrid-search.md.

Paramètres de pertinence (override par requête)

?rrfVectorWeight=

TypePlageEffet
float[0, 1]Pondération RRF de la branche vectorielle vs textuelle

Override de DEFAULT_RRF_VECTOR_WEIGHT pour cette requête.

?semanticFloor=

TypePlageEffet
float[0, 1]Plancher de similarité cosinus. Voyages avec score plus bas filtrés

Override de DEFAULT_SEMANTIC_FLOOR.

?tsvectorFloor=

TypePlageEffet
float≥ 0Plancher 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 (ValueError depuis datetime.fromisoformat). Le client doit envoyer du YYYY-MM-DD ou YYYY-MM-DDTHH:MM:SS.
  • Slug inexistant en category : pas d'erreur, retourne juste 0 résultat.
  • destination en 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 avec is_valid=false sont inclus. Côté front, l'attendu est généralement de passer ?hide_invalid=true.
  • L'ordre n'est pas garanti (pas de ORDER BY injecté), c'est l'ordre d'index physique. Pour un ordre stable, passer orderBy.

Contributors

No contributors

Changelog

No recent changes