moteur de recherche : trigram+substring, navbar, page resultats

This commit is contained in:
Cedric Abonnel
2026-05-12 01:29:01 +02:00
parent 3a5ae2631d
commit f236ea24de
9 changed files with 465 additions and 74 deletions
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
/**
* Moteur de recherche plein-texte en mémoire.
*
* Algorithme : scoring multi-champ avec correspondance exacte, sous-chaîne et
* similarité trigramme. Logique AND : tous les tokens de la requête doivent
* matcher quelque part pour qu'un article soit retourné.
*
* Score par token :
* 1.0 → mot identique (ex. "Linky" = "Linky")
* 0.75 → sous-chaîne (ex. "voiture" ⊂ "voitures")
* 00.5 → similarité trigramme (ex. "linki" ≈ "linky")
*
* Poids par champ : titre × 6, catégorie × 3, contenu × 1.
*/
class SearchEngine
{
private const TITLE_WEIGHT = 6.0;
private const CAT_WEIGHT = 3.0;
private const CONTENT_WEIGHT = 1.0;
private const FUZZY_FLOOR = 0.55; // seuil min. de similarité trigramme
private const SNIPPET_LEN = 220;
/**
* @param array<array> $articles Liste brute d'articles (depuis ArticleManager)
* @return array<array{article: array, score: float, snippet: string}>
*/
public function search(string $query, array $articles): array
{
$tokens = $this->tokenize($query);
if (empty($tokens)) {
return [];
}
$results = [];
foreach ($articles as $article) {
$plain = $this->stripMarkdown($article['content'] ?? '');
$tWords = $this->tokenize($article['title'] ?? '');
$cWords = $this->tokenize($article['category'] ?? '');
$pWords = $this->tokenize($plain);
$score = $this->scoreArticle($tokens, $tWords, $cWords, $pWords);
if ($score > 0.0) {
$results[] = [
'article' => $article,
'score' => $score,
'snippet' => $this->buildSnippet($plain, $tokens),
];
}
}
usort($results, fn ($a, $b) => $b['score'] <=> $a['score']);
return $results;
}
// ─── Scoring ─────────────────────────────────────────────────────────────
private function scoreArticle(array $tokens, array $tWords, array $cWords, array $pWords): float
{
$total = 0.0;
foreach ($tokens as $token) {
$ts = $this->tokenScore($token, $tWords) * self::TITLE_WEIGHT
+ $this->tokenScore($token, $cWords) * self::CAT_WEIGHT
+ $this->tokenScore($token, $pWords) * self::CONTENT_WEIGHT;
if ($ts <= 0.0) {
return 0.0; // AND strict : token introuvable → article exclu
}
$total += $ts;
}
return $total;
}
/**
* Retourne un score 01 mesurant à quel point $token correspond
* au meilleur mot de la liste $words.
*/
private function tokenScore(string $token, array $words): float
{
$best = 0.0;
$tLen = mb_strlen($token);
foreach ($words as $w) {
if ($w === $token) {
return 1.0; // exact
}
if ($tLen >= 3 && (str_contains($w, $token) || str_contains($token, $w))) {
$best = max($best, 0.75); // sous-chaîne (pluriels, conjugaisons)
}
if ($tLen >= 4) {
$sim = $this->trigramSimilarity($token, $w);
if ($sim >= self::FUZZY_FLOOR) {
$best = max($best, $sim * 0.55); // fuzzy (fautes de frappe)
}
}
}
return $best;
}
// ─── Trigramme ───────────────────────────────────────────────────────────
private function trigramSimilarity(string $a, string $b): float
{
$tA = $this->trigrams($a);
$tB = $this->trigrams($b);
if (empty($tA) || empty($tB)) {
return 0.0;
}
$common = count(array_intersect($tA, $tB));
return $common / max(count($tA), count($tB));
}
/** @return string[] */
private function trigrams(string $s): array
{
$out = [];
$len = mb_strlen($s);
for ($i = 0; $i + 2 < $len; $i++) {
$out[] = mb_substr($s, $i, 3);
}
return array_unique($out);
}
// ─── Snippet avec surbrillance ────────────────────────────────────────────
private function buildSnippet(string $text, array $tokens): string
{
$norm = $this->normalize($text);
$pos = 0;
foreach ($tokens as $token) {
$p = mb_strpos($norm, $token);
if ($p !== false) {
$pos = max(0, $p - 60);
break;
}
}
$raw = mb_substr($text, $pos, self::SNIPPET_LEN);
if ($pos > 0) {
$raw = '…' . ltrim($raw);
}
if ($pos + self::SNIPPET_LEN < mb_strlen($text)) {
$raw .= '…';
}
$escaped = htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Surbrillance : on cherche les tokens dans le texte HTML-échappé
foreach ($tokens as $token) {
$escaped = (string) preg_replace(
'/(' . preg_quote(htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), '/') . ')/iu',
'<mark>$1</mark>',
$escaped
);
}
return $escaped;
}
// ─── Helpers texte ────────────────────────────────────────────────────────
/** Découpe en mots normalisés (min. 2 caractères). */
private function tokenize(string $text): array
{
$norm = $this->normalize($text);
$words = preg_split('/\W+/u', $norm, -1, PREG_SPLIT_NO_EMPTY) ?: [];
return array_values(array_filter($words, fn ($w) => mb_strlen($w) >= 2));
}
/** Minuscule + translittération des accents français. */
private function normalize(string $text): string
{
$text = mb_strtolower($text, 'UTF-8');
return strtr($text, [
'à' => 'a', 'â' => 'a', 'ä' => 'a',
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
'î' => 'i', 'ï' => 'i',
'ô' => 'o', 'ö' => 'o',
'ù' => 'u', 'û' => 'u', 'ü' => 'u',
'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe', 'ñ' => 'n',
]);
}
/** Retire la syntaxe Markdown pour extraire le texte brut. */
private function stripMarkdown(string $md): string
{
$t = preg_replace('/!\[[^\]]*\]\([^)]+\)/', '', $md) ?? $md; // images
$t = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $t) ?? $t; // liens
$t = preg_replace('/```[\s\S]*?```/', '', $t) ?? $t; // blocs code
$t = preg_replace('/`[^`]+`/', '', $t) ?? $t; // code inline
$t = preg_replace('/^#{1,6}\s*/m', '', $t) ?? $t; // titres
$t = preg_replace('/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/', '$1', $t) ?? $t; // gras/italique
$t = preg_replace('/^\s*[-*+|>]\s*/m', '', $t) ?? $t; // listes, citations, tableaux
$t = preg_replace('/\n{2,}/', ' ', $t) ?? $t;
return trim($t);
}
}