Files
folio/src/SearchEngine.php
T

277 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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' est pré-calculé dans search_index.json, sinon on stripe à la volée
$plain = $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),
'tier' => $this->determineTier($tokens, $tWords, $cWords, $pWords),
];
}
}
usort($results, static function (array $a, array $b): int {
if ($a['tier'] !== $b['tier']) {
return $a['tier'] <=> $b['tier'];
}
return $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;
}
/**
* Classe un résultat en tier :
* 1 → tous les tokens trouvés exactement dans le titre
* 2 → tous les tokens trouvés exactement dans titre, catégorie ou contenu
* 3 → au moins un token uniquement en correspondance floue
*/
private function determineTier(array $tokens, array $tWords, array $cWords, array $pWords): int
{
$inTitle = true;
foreach ($tokens as $token) {
if ($this->tokenScore($token, $tWords, false) < 0.75) {
$inTitle = false;
break;
}
}
if ($inTitle) {
return 1;
}
$allWords = array_merge($tWords, $cWords, $pWords);
foreach ($tokens as $token) {
if ($this->tokenScore($token, $allWords, false) < 0.75) {
return 3;
}
}
return 2;
}
/**
* Retourne un score 01 mesurant à quel point $token correspond
* au meilleur mot de la liste $words.
*/
private function tokenScore(string $token, array $words, bool $fuzzy = true): 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 ($fuzzy && $tLen >= 4) {
$sim = $this->trigramSimilarity($token, $w);
if ($sim >= self::FUZZY_FLOOR) {
$best = max($best, $sim * 0.55); // fuzzy (fautes de frappe)
}
}
}
return $best;
}
/**
* Calcule un score cumulé (OR) pour plusieurs tokens sur un ensemble d'articles.
* Tokenise chaque article une seule fois — évite N tokenisations avec N appels à search().
* Le fuzzy (trigramme) est désactivé sur le contenu (poids 1.0) pour des raisons de perf.
*
* @param string[] $tokens Mots normalisés (lowercase, sans accents)
* @param array[] $articles Articles (doivent avoir uuid, title, category, plain|content)
* @return array{0: array<string, float>, 1: array<string, array>}
*/
public function scorePool(array $tokens, array $articles): array
{
if (empty($tokens) || empty($articles)) {
return [[], []];
}
$scoreMap = [];
$articleMap = [];
foreach ($articles as $article) {
$plain = $article['plain'] ?? $this->stripMarkdown($article['content'] ?? '');
$tWords = $this->tokenize($article['title'] ?? '');
$cWords = $this->tokenize($article['category'] ?? '');
$pWords = $this->tokenize($plain);
$total = 0.0;
foreach ($tokens as $token) {
$ts = $this->tokenScore($token, $tWords, true) * self::TITLE_WEIGHT
+ $this->tokenScore($token, $cWords, true) * self::CAT_WEIGHT
+ $this->tokenScore($token, $pWords, false) * self::CONTENT_WEIGHT;
$total += $ts;
}
if ($total > 0.0) {
$uuid = $article['uuid'];
$scoreMap[$uuid] = $total;
$articleMap[$uuid] = $article;
}
}
return [$scoreMap, $articleMap];
}
// ─── 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);
}
}