Elasticsearch interne (jamais exposé, sécurité x-pack désactivée, cohérent avec le reste des services internes de ce serveur), tas JVM fixé à 512 Mo plutôt que le calcul automatique (jusqu'à 50 % de la RAM système, désastreux sur ce serveur à 31 Go réels). Appels HTTP directs depuis Laravel, pas de bibliothèque cliente pour un besoin aussi simple. Index reconstruit après chaque synchronisation du catalogue, page de recherche commune aux profs et aux élèves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
101 lines
3.3 KiB
PHP
101 lines
3.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Recherche;
|
|
|
|
use App\Models\Cours;
|
|
use App\Models\Lab;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Bloc C (CLAUDE.md) : recherche plein texte dans les cours et labs.
|
|
* Instance Elasticsearch interne (voir ansible/roles/elasticsearch), appels
|
|
* HTTP directs plutôt qu'une bibliothèque cliente : le besoin se limite à
|
|
* indexer et chercher, pas de dépendance ajoutée pour ça.
|
|
*/
|
|
class IndexRecherche
|
|
{
|
|
public static function disponible(): bool
|
|
{
|
|
return filled(config('elasticsearch.hote'));
|
|
}
|
|
|
|
public static function reconstruire(): void
|
|
{
|
|
if (! self::disponible()) {
|
|
return;
|
|
}
|
|
|
|
Http::delete(self::url());
|
|
Http::put(self::url(), [
|
|
'mappings' => [
|
|
'properties' => [
|
|
'identifiant' => ['type' => 'keyword'],
|
|
'type' => ['type' => 'keyword'],
|
|
'titre' => ['type' => 'text', 'analyzer' => 'french'],
|
|
'contenu' => ['type' => 'text', 'analyzer' => 'french'],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$documents = Lab::all()
|
|
->map(fn (Lab $lab) => [
|
|
'identifiant' => $lab->identifiant,
|
|
'type' => 'lab',
|
|
'titre' => $lab->titre,
|
|
'contenu' => trim($lab->titre.' '.implode(' ', $lab->competences ?? [])),
|
|
])
|
|
->concat(Cours::all()->map(fn (Cours $cours) => [
|
|
'identifiant' => $cours->identifiant,
|
|
'type' => 'cours',
|
|
'titre' => $cours->titre,
|
|
'contenu' => trim($cours->titre.' '.self::texteBrut($cours->contenu_markdown)),
|
|
]));
|
|
|
|
foreach ($documents as $document) {
|
|
Http::put(self::url("/_doc/{$document['type']}-{$document['identifiant']}"), $document);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{type: string, identifiant: string, titre: string, extrait: string}>
|
|
*/
|
|
public static function chercher(string $texte): array
|
|
{
|
|
if (! self::disponible() || trim($texte) === '') {
|
|
return [];
|
|
}
|
|
|
|
$reponse = Http::post(self::url('/_search'), [
|
|
'query' => ['multi_match' => ['query' => $texte, 'fields' => ['titre^3', 'contenu']]],
|
|
'highlight' => ['fields' => ['contenu' => new \stdClass]],
|
|
'size' => 20,
|
|
]);
|
|
|
|
if ($reponse->failed()) {
|
|
return [];
|
|
}
|
|
|
|
/** @var array<int, array<string, mixed>> $resultats */
|
|
$resultats = $reponse->json('hits.hits') ?? [];
|
|
|
|
return collect($resultats)
|
|
->map(fn (array $hit) => [
|
|
'type' => $hit['_source']['type'],
|
|
'identifiant' => $hit['_source']['identifiant'],
|
|
'titre' => $hit['_source']['titre'],
|
|
'extrait' => $hit['highlight']['contenu'][0] ?? Str::limit($hit['_source']['contenu'], 200),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private static function url(string $chemin = ''): string
|
|
{
|
|
return 'http://'.config('elasticsearch.hote').':'.config('elasticsearch.port').'/'.config('elasticsearch.index').$chemin;
|
|
}
|
|
|
|
private static function texteBrut(string $markdown): string
|
|
{
|
|
return trim(preg_replace('/[#*`_\[\]()>-]/', ' ', $markdown) ?? '');
|
|
}
|
|
}
|