moteur de recherche : trigram+substring, navbar, page resultats
This commit is contained in:
@@ -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")
|
||||
* 0–0.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 0–1 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user