Aller au contenu principal

Flow actuel de la recherche et du ranking

Statut : Audit de l'état provisoire — lecture seule, aucune modification du code applicatif
Date : 2026-07-06
Portée : dmv-public — deux surfaces de recherche distinctes
Architecture cible : 34-ranking-engine-synthesis.md — ce document décrit l'état en place ; le doc 34 définit vers quoi migrer (scoring, bonus partenaire, unification des surfaces)


1. Résumé

Il existe deux surfaces de recherche sans ranking commun. La recherche globale (/recherche) est un simple filtre Supabase ilike sur nom et description, trié alphabétiquement, sans scoring. L'annuaire commune (/[commune]) est la surface principale : elle utilise d'abord la RPC Supabase search_acteurs_public (filtre multi-champs côté serveur), puis enrichit les résultats avec tags, coordonnées et coefficient d'abonnement, et applique enfin un scoring client-side via computeActorSearchScore() dans lib/searchUtils.ts. Ce scoring est déterministe — il combine matching textuel (nom, catégorie, tags), boosters binaires (claimed, en_avant) et un multiplicateur commercial (coeff_annuaire). Il n'y a aucun signal utilisateur (historique, localisation, favoris) dans le ranking.


2. Schéma du flux

Recherche globale

/recherche/page.tsx
└── RechercheClient.tsx
└── Supabase ilike (nom OR description)
→ ORDER BY nom ASC
→ limit 30
→ liste de résultats (pas de scoring)

Annuaire commune (avec ranking)

/[commune]/page.tsx (SSG, initialActors pré-chargés)
└── AnnuaireClient.tsx
├── [sans recherche]
│ └── filter(initialActors, kind) → affichage direct

├── [recherche < 3 chars]
│ └── Supabase ilike (nom OR description) → base

└── [recherche >= 3 chars]
└── RPC search_acteurs_public(communeId, kind, search)
→ base (nom, categorie, en_avant)
→ enrichissement (slug, tags, coords, coeff_annuaire)
→ vérification claims
→ sortActorsBySearch(base, searchParam)
└── computeActorSearchScore() ← RANKING
→ résultats triés
→ pagination 10/page

3. Fichiers exacts

dmv-public/app/recherche/page.tsx

  • Rôle : Entrée de route /recherche, wrapper Suspense
  • Ce qu'il décide : Rien — délègue à RechercheClient
  • Ce qu'il délègue : Tout à RechercheClient
  • Logique métier : Non

dmv-public/app/recherche/RechercheClient.tsx

  • Rôle : Moteur de recherche globale (tous acteurs, toutes communes)
  • Ce qu'il décide :
    • Seuil minimum : 2 caractères
    • Query Supabase directe :
      SELECT id, nom, slug, kind, description, logo_url,
      badge_verifie, badge_label, communes(slug, nom)
      FROM acteurs
      WHERE (nom ILIKE '%{term}%' OR description ILIKE '%{term}%')
      AND valide = true
      AND active = true
      ORDER BY nom
      LIMIT 30
    • Navigation : push ?q= dans l'URL
    • Fallback "Aucun résultat" si results.length === 0
  • Ce qu'il délègue : Rien
  • Logique métier : Non — fetch simple, pas de scoring

dmv-public/app/[commune]/page.tsx (SSG)

  • Rôle : Page de commune, charge initialActors côté serveur
  • Ce qu'il décide :
    • initialActors = tous les acteurs valides de la commune, triés en_avant DESC, nom ASC
    • Chaque acteur inclut coeff_annuaire via actor_subscriptions(subscription_plans(coeff_annuaire))
  • Ce qu'il délègue : Affichage → AnnuaireClient
  • Logique métier : Faible — tri initial serveur uniquement

dmv-public/app/[commune]/AnnuaireClient.tsx

  • Rôle : Interface annuaire avec recherche, filtres par kind, ranking et carte (~500 lignes)
  • Ce qu'il décide :
    • Debounce 350ms sur la saisie
    • Seuil de bascule : < 2 chars → reset, 2 chars → ilike, ≥ 3 chars → RPC
    • Filtres par kind (pro / association / public / all)
    • Anti-race : reqIdRef numéroté — les anciennes requêtes sont ignorées si dépassées
    • Enrichissement en 3 requêtes séquentielles :
      1. RPC search_acteurs_public → base (nom, categorie, en_avant)
      2. acteurs select → slug, tags, coords, horaires, badges, coeff_annuaire
      3. claims select → is_claimed par slug
    • Ranking final : sortActorsBySearch(base, searchParam)
    • Pagination : 10 par page (PAGE_SIZE = 10), numérotée
    • Map : pins des acteurs claimed avec coords, dans rayon 15 km du centre commune (haversineKm)
    • NEXT_PUBLIC_DEBUG_SEARCH = "true" → log scores dans la console
    • Tracking search_performed (event analytics)
  • Ce qu'il délègue : Scoring → lib/searchUtils.ts
  • Logique métier : Oui — orchestration du pipeline complet

dmv-public/lib/searchUtils.ts ← CŒUR DU RANKING

  • Rôle : Algorithme de scoring et tri des acteurs par pertinence
  • Ce qu'il décide : Score final et ordre des résultats
  • Ce qu'il délègue : Normalisation texte → lib/textUtils.ts
  • Logique métier : Oui — intégralité du ranking

dmv-public/lib/textUtils.ts

  • Rôle : Normalisation du texte pour comparaisons
  • Ce qu'il décide :
    • normalizeText(v) : NFD → retire accents → lowercase → trim → compacte espaces
    • normalizeCompact(v) : normalizeText + supprime tout ce qui n'est pas [a-z0-9]
    • tokenizeSearch(v) : normalizeText → split sur espaces → array de tokens
  • Ce qu'il délègue : Rien
  • Logique métier : Non — utilitaires purs

RPC Supabase search_acteurs_public

  • Localisation : Migration SQL dans dmv-backoffice/supabase/migrations/
  • Rôle : Filtre multi-champs côté base de données
  • Ce qu'il décide :
    • Match sur : nom (ILIKE), adresse (ILIKE), catégorie (ILIKE), tags (EXISTS)
    • Filtre : valide = true, deleted_at IS NULL, commune_id = p_commune_id
    • Filtre optionnel : kind = p_kind
    • Tri initial : en_avant DESC NULLS LAST, nom ASC
  • Ce qu'il délègue : Tri de pertinence → scoring client-side dans AnnuaireClient
  • Logique métier : Faible — filtre d'inclusion, pas de scoring

4. Algorithme de ranking — détail complet

computeActorSearchScore() (lib/searchUtils.ts:13)

function computeActorSearchScore(actor: SearchableActor, rawSearch: string | null): number {
// Étape 1 — Normalisation de la requête en 3 formes
const fullQueryText = normalizeText(rawSearch); // accents supprimés, lowercase, espaces compactés
const fullQueryCompact = normalizeCompact(rawSearch); // + ponctuation/symboles supprimés
const tokens = tokenizeSearch(rawSearch); // découpe en mots individuels

// Étape 2 — Normalisation des champs de l'acteur
const nom = actor.nom_norm?.trim()
? actor.nom_norm.trim().toLowerCase()
: normalizeCompact(actor.nom); // priorité à nom_norm (prétraité en BDD)
const categorie = normalizeText(actor.categorie_nom);
const tags = (actor.tags ?? []).map(normalizeText);

let score = 0;

// Étape 3 — Fonction de scoring pour un terme unique
const scoreOneTerm = (termText: string, termCompact: string) => {
let termScore = 0;
if (termCompact) {
if (nom.startsWith(termCompact)) termScore += 100; // match début de nom
else if (nom.includes(termCompact)) termScore += 70; // match dans le nom
}
if (termText) {
if (categorie.includes(termText)) termScore += 40; // match catégorie
if (tags.some((tag) => tag.includes(termText))) termScore += 25; // match tags
}
return termScore;
};

// Étape 4 — Score sur la requête complète (comme un seul terme)
if (fullQueryText || fullQueryCompact) {
score += scoreOneTerm(fullQueryText, fullQueryCompact);
}

// Étape 5 — Score cumulatif sur chaque token individuel
for (const token of tokens) {
score += scoreOneTerm(token, normalizeCompact(token));
}

// Étape 6 — Boosters additifs
if (actor.is_claimed) score += 10; // acteur revendiqué
if (actor.en_avant) score += 6; // acteur mis en avant

// Étape 7 — Multiplicateur commercial
const coeff = actor.coeff_annuaire ?? 1;
return score * coeff;
}

Table des points

SignalPointsNotes
nom.startsWith(termCompact)+100Match en début de nom (compacté)
nom.includes(termCompact)+70Match n'importe où dans le nom
categorie.includes(termText)+40Match dans la catégorie
tags.some(tag.includes(termText))+25Match dans au moins un tag
is_claimed = true+10Acteur revendiqué (booster additif) — voir divergence ci-dessous
en_avant = true+6Acteur mis en avant (booster additif) — voir divergence ci-dessous
coeff_annuaire×NMultiplicateur final (abonnement commercial) — voir divergence ci-dessous

Divergences avec l'architecture cible — Ces trois valeurs décrivent l'implémentation actuelle, pas l'architecture cible définie dans 34-ranking-engine-synthesis.md (sections 7.2.1 et 7.3) :

SignalImplémentation actuelleArchitecture cible
is_claimed = true+10 (bonus pour les revendiqués)La fiche revendiquée est la norme (score neutre). C'est is_claimed = false qui reçoit un malus −20 — l'exception (fiche orpheline) est pénalisée, pas la majorité gonflée artificiellement.
en_avant + coeff_annuaire+6 additif puis ×N multiplicatif (deux mécanismes séparés)Fusionnés en un bonus partenaire additif plafonné (+15). Le multiplicateur est explicitement rejeté : un ×2 transforme un score de 216 en 432, pouvant faire remonter un mauvais match devant un excellent match gratuit.

Le point le plus urgent selon le doc 34 (§ 8.2) : « Le multiplicateur coeff_annuaire actuellement en place doit être converti en bonus additif plafonné — c'est le point le plus urgent car il contredit directement le principe de neutralité. »

Double comptage intentionnel : la requête complète ET chaque token sont scorés séparément. Une recherche "boulangerie du village" produit 4 évaluations (1 complète + 3 tokens), ce qui amplifie le score des acteurs qui matchent sur plusieurs termes.

nom_norm : champ prétraité en base de données (accentuation normalisée). Si absent, normalizeCompact(actor.nom) est utilisé comme fallback. La BDD est la source de vérité pour la normalisation du nom.


sortActorsBySearch() (lib/searchUtils.ts:58)

function sortActorsBySearch<T extends SearchableActor>(items: T[], rawSearch: string | null): T[] {
return [...items].sort((a, b) => {
const scoreA = computeActorSearchScore(a, rawSearch);
const scoreB = computeActorSearchScore(b, rawSearch);

if (scoreB !== scoreA) return scoreB - scoreA; // 1. Score décroissant
if (!!b.is_claimed !== !!a.is_claimed) // 2. Claimed en premier
return Number(!!b.is_claimed) - Number(!!a.is_claimed);
if (!!b.en_avant !== !!a.en_avant) // 3. En avant en premier
return Number(!!b.en_avant) - Number(!!a.en_avant);
return a.nom.localeCompare(b.nom, "fr", { sensitivity: "base" }); // 4. Alphabétique fr
});
}

Cascade de tiebreakers :

  1. Score de recherche (décroissant)
  2. Égalité de score → acteurs claimed en premier
  3. Toujours égaux → acteurs en_avant en premier
  4. Toujours égaux → ordre alphabétique (locale française, insensible aux accents)

5. Exemple de scoring chiffré

Recherche : "boulang"
Acteur : "Boulangerie du Village" (nom_norm = "boulangerie du village", claimed = true, en_avant = true, coeff = 2)

ÉvaluationTermeConditionPoints
Requête complète"boulang"nom.startsWith("boulang") → oui+100
Token 1"boulang"nom.startsWith("boulang") → oui+100
Boostersis_claimed+10
Boostersen_avant+6
Total avant coeff216
× coeff_annuaire× 2= 432

Même acteur sans abonnement (coeff = 1) : score = 216
Concurrent : "Village Fleurs" (nom_norm = "village fleurs", claimed = false, en_avant = false, coeff = 1)

  • requête "boulang" : nom.includes("boulang") → non → 0 pts
  • token "boulang" : idem → 0 pts
  • Total = 0 → classé après

6. Pipeline complet de l'annuaire (flux temporel)

[Chargement initial - SSG]
/[commune]/page.tsx
→ SELECT acteurs WHERE commune_id AND valide = true
ORDER BY en_avant DESC, nom ASC
(+ coeff_annuaire via actor_subscriptions)
→ initialActors passés à AnnuaireClient

[Sans recherche]
AnnuaireClient
→ filter(initialActors, kind) ← pas de requête, tri SSG conservé
→ slice(page, PAGE_SIZE)

[Avec recherche ≥ 3 chars]
Saisie → debounce 350ms → searchParam

Requête 1 — RPC search_acteurs_public :
→ filtre multi-champs en BDD (nom, adresse, catégorie, tags)
→ retourne : id, nom, nom_norm, kind, categorie_nom, en_avant
→ tri initial : en_avant DESC, nom ASC (ignoré ensuite)

Requête 2 — enrichissement (parallèle, sur les ids) :
→ acteurs SELECT id, slug, description, slogan, latitude, longitude,
horaires, badge_verifie, badge_label, logo_url,
acteur_tags(tags(label)),
actor_subscriptions(status, subscription_plans(coeff_annuaire))
→ extraction coeff_annuaire : subscription_plans[0].coeff_annuaire WHERE status = "active"
→ fallback coeff = 1

Requête 3 — vérification claims :
→ claims SELECT actor_slug WHERE city = communeSlug AND status = "approved"
→ Set<string> des slugs revendiqués
→ enrichit a.is_claimed

Ranking client-side :
→ sortActorsBySearch(base, searchParam)
→ computeActorSearchScore() pour chaque acteur
→ tri décroissant par score + tiebreakers

Affichage :
→ pagedItems = items.slice((page-1)*10, page*10)
→ pagination numérotée

7. Hooks et utilitaires impliqués

ÉlémentFichierRôle
computeActorSearchScorelib/searchUtils.tsScore de pertinence d'un acteur
sortActorsBySearchlib/searchUtils.tsTri final par score + tiebreakers
normalizeTextlib/textUtils.tsNFD → lowercase → compact espaces
normalizeCompactlib/textUtils.tsnormalizeText + supprime non-alphanumérique
tokenizeSearchlib/textUtils.tsDécoupe en tokens
useDebouncedValue(search, 350)hooks/useDebounce (non listé)Debounce 350ms saisie
haversineKmAnnuaireClient.tsx (inline)Distance GPS pour pins carte
isCurrentlyOpenlib/horairesUtils.tsStatut ouverture dans résultats
useTrackEventlib/useTrackEvent.tsEvent analytics search_performed
RPC search_acteurs_publicMigration SupabaseFiltre BDD multi-champs

8. Logique métier dispersée

Scoring et ranking

: lib/searchUtils.ts — entièrement contenu, bien isolé
Seul fichier à modifier pour changer les poids ou ajouter des signaux.

Normalisation textuelle

: lib/textUtils.ts — bien isolé
Critique pour la cohérence du matching : nom_norm en BDD doit être produit avec la même logique.

Coefficient d'abonnement

: Dispersé sur 3 couches :

  1. Table BDD : subscription_plans.coeff_annuaire
  2. page.tsx (SSG) : chargé dans initialActors
  3. AnnuaireClient.tsx : rechargé lors de la recherche (requête enrichissement)
  4. computeActorSearchScore() : appliqué comme multiplicateur final ← divergence architecturale (voir ci-dessus)

Absence de source unique : le coeff est chargé deux fois (SSG + recherche), sans garantie de cohérence si un abonnement change entre les deux.

Vérification claims

: AnnuaireClient.tsx → requête Supabase claims à chaque recherche
Logique inline dans load(). Non extraite.

Calcul de distance (carte)

: AnnuaireClient.tsxhaversineKm() inline
Formule de Haversine calculée à chaque render de mapPins. Non extraite en lib.

Filtre par kind

: AnnuaireClient.tsx — state type (all/pro/association/public)
Sans recherche : filter(initialActors, kind) côté client.
Avec recherche : paramètre p_kind passé à la RPC.

Pagination

: AnnuaireClient.tsxPAGE_SIZE = 10, pagination numérotée
Entièrement côté client (slice sur items). Pas de curseur serveur.

Fallback "aucun résultat"

:

  • RechercheClient.tsx : affiche "Aucun résultat pour «...»" si results.length === 0
  • AnnuaireClient.tsx : non documenté explicitement — à vérifier dans le rendu

9. Absence de signaux utilisateur

Le ranking est 100% déterministe et sans personnalisation. Les éléments suivants sont confirmés absents :

SignalPrésent ?Notes
Historique de rechercheNonAucun localStorage, aucun cookie
Géolocalisation utilisateurNonhaversineKm utilisé uniquement pour les pins de carte, pas pour le ranking
Acteurs favoris / suivisNonfollowedIds de WallEngine non transmis à l'annuaire
Fréquence de visiteNonAucun compteur
Clics sur résultatsNonSeulement search_performed en analytics, pas de feedback loop
Langue / préférencesNon

10. Comparaison des deux surfaces de recherche

CritèreRecherche globale (/recherche)Annuaire commune (/[commune])
ScopeTous acteurs, toutes communesActeurs d'une commune
Seuil déclenchement2 chars2 chars (ilike) / 3 chars (RPC)
Mécanisme BDDilike nom + descriptionRPC multi-champs (nom, adresse, catégorie, tags)
Scoring clientAucuncomputeActorSearchScore()
RankingAlphabétiqueScore × coeff (multiplicateur à convertir — voir doc 34)
Tags dans le matchingNonOui (via RPC + scoring)
Claimed dans le rankingNonOui (+10 pts + tiebreaker) (à convertir en malus −20 pour non-claimed)
coeff_annuaireNonOui (multiplicateur) (à convertir en additif plafonné +15)
PaginationAucune (max 30)10 par page
CarteNonOui (pins claimed dans rayon 15 km)

11. Conclusion

Meilleur point d'entrée pour modifier le ranking

lib/searchUtils.tscomputeActorSearchScore() est court (56 lignes), bien isolé, sans dépendances vers les composants. Ajouter un signal (favoris, géolocalisation, freshness) se fait en ajoutant une ligne de calcul dans scoreOneTerm ou en ajoutant un booster.

Fichiers à ne pas toucher au début

  • lib/textUtils.ts — normalisation partagée avec la BDD (nom_norm) ; tout changement doit être coordonné avec la migration qui produit nom_norm
  • AnnuaireClient.tsx — orchestration complexe (3 requêtes séquentielles + anti-race), toute modification du pipeline est risquée
  • RPC search_acteurs_public — toute modification impacte le périmètre de résultats avant scoring

Candidats à extraction

  • haversineKm dans AnnuaireClient.tsxlib/geoUtils.ts
  • Logique de claims (claimedSet) dans load() → hook ou util séparé
  • coeff_annuaire chargé deux fois (SSG + recherche) → unifier la source

Modifications requises par l'architecture cible (doc 34)

Ces changements ne sont pas optionnels — ils sont explicitement tranchés dans 34-ranking-engine-synthesis.md § 8.1 et § 8.2 :

  1. coeff_annuaire multiplicatif → bonus partenaire additif plafonné (+15) — priorité la plus haute, contredit le principe de neutralité commerciale. en_avant payant est fusionné dans ce même bonus.
  2. is_claimed +10 bonus → is_claimed = false malus (−20) — change l'état par défaut : la fiche enrichie devient la norme, la fiche orpheline est l'exception pénalisée.
  3. Unifier /recherche (tri alphabétique) et l'annuaire commune (scoring complet) sur un seul moteur — deux implémentations du même besoin.
  4. Brancher haversineKm et followedIds au scoring — les briques sont déjà disponibles mais non connectées au Ranking Engine des acteurs (doc 34 § 7.2.1 : proximité géographique +20 à 0 km dégressif, affinité relationnelle +30).