ajoute la recherche plein texte dans les cours et labs (bloc C)
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>
This commit is contained in:
parent
e549f03e1b
commit
777dd1cbc8
20 changed files with 545 additions and 0 deletions
30
portail/app/Console/Commands/IndexerRecherche.php
Normal file
30
portail/app/Console/Commands/IndexerRecherche.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Recherche\IndexRecherche;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Rejoue l'index de recherche (bloc C) depuis les tables labs/cours —
|
||||
* appelé après labs:synchroniser et cours:synchroniser, et disponible ici
|
||||
* pour une reconstruction manuelle (changement de mapping, par exemple).
|
||||
*/
|
||||
class IndexerRecherche extends Command
|
||||
{
|
||||
protected $signature = 'recherche:indexer';
|
||||
|
||||
protected $description = 'Reconstruit l’index de recherche (cours et labs) dans Elasticsearch';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! IndexRecherche::disponible()) {
|
||||
$this->warn('Elasticsearch non configuré : rien à indexer.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
IndexRecherche::reconstruire();
|
||||
$this->info('Index reconstruit.');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Models\Cours;
|
||||
use App\Services\Catalogue\CacheCatalogue;
|
||||
use App\Services\Recherche\IndexRecherche;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
|
@ -44,5 +45,6 @@ class SynchroniserCours extends Command
|
|||
}
|
||||
|
||||
CacheCatalogue::viderCours();
|
||||
IndexRecherche::reconstruire();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Console\Commands;
|
|||
use App\Models\Lab;
|
||||
use App\Services\Catalogue\CacheCatalogue;
|
||||
use App\Services\Labs\DefinitionLab;
|
||||
use App\Services\Recherche\IndexRecherche;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
|
|
@ -32,5 +33,6 @@ class SynchroniserLabs extends Command
|
|||
}
|
||||
|
||||
CacheCatalogue::viderLabs();
|
||||
IndexRecherche::reconstruire();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
portail/app/Http/Controllers/RechercheController.php
Normal file
25
portail/app/Http/Controllers/RechercheController.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Recherche\IndexRecherche;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Recherche plein texte dans les cours et labs (bloc C, CLAUDE.md), commune
|
||||
* aux profs et aux élèves — pas de donnée sensible dans le catalogue.
|
||||
*/
|
||||
class RechercheController extends Controller
|
||||
{
|
||||
public function chercher(Request $request): View
|
||||
{
|
||||
$texte = trim((string) $request->query('q', ''));
|
||||
|
||||
return view('recherche', [
|
||||
'texte' => $texte,
|
||||
'resultats' => IndexRecherche::chercher($texte),
|
||||
'disponible' => IndexRecherche::disponible(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
101
portail/app/Services/Recherche/IndexRecherche.php
Normal file
101
portail/app/Services/Recherche/IndexRecherche.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?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) ?? '');
|
||||
}
|
||||
}
|
||||
10
portail/config/elasticsearch.php
Normal file
10
portail/config/elasticsearch.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Bloc C (CLAUDE.md) : recherche plein texte dans les cours et labs,
|
||||
// instance interne (voir ansible/roles/elasticsearch), jamais de
|
||||
// dépendance ajoutée : appels HTTP directs (App\Services\Recherche\IndexRecherche).
|
||||
'hote' => env('ELASTICSEARCH_HOTE'),
|
||||
'port' => env('ELASTICSEARCH_PORT', 9200),
|
||||
'index' => 'paillasse_catalogue',
|
||||
];
|
||||
|
|
@ -37,6 +37,10 @@
|
|||
Mes fichiers
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ route('recherche') }}" role="menuitem">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5 21 21"/></svg>
|
||||
Recherche
|
||||
</a>
|
||||
<a href="{{ route('aide.app') }}" role="menuitem">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .8-1 1.7v.5"/><path d="M12 17h.01"/></svg>
|
||||
Aide et assistance
|
||||
|
|
|
|||
37
portail/resources/views/recherche.blade.php
Normal file
37
portail/resources/views/recherche.blade.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
@extends('layouts.app')
|
||||
|
||||
@section('titre', 'Recherche')
|
||||
|
||||
@section('contenu')
|
||||
<div class="mx-auto max-w-3xl px-5 py-10">
|
||||
<h1 class="text-3xl font-extrabold" style="color:var(--sy-ink)">Recherche</h1>
|
||||
<p class="mt-2 font-semibold" style="color:var(--sy-muted)">Dans les cours et les labs du catalogue.</p>
|
||||
|
||||
<form action="{{ route('recherche') }}" method="get" class="mt-6 flex gap-2">
|
||||
<input type="search" name="q" value="{{ $texte }}" placeholder="Adressage IPv4, routage, chiffrement…" class="flex-1 rounded-xl border border-[#c4c7c5] bg-white px-4 py-2 font-semibold" autofocus>
|
||||
<button type="submit" class="inline-flex h-11 items-center rounded-full bg-black px-5 font-bold text-white">Chercher</button>
|
||||
</form>
|
||||
|
||||
@if (! $disponible)
|
||||
<p class="mt-6 rounded-2xl border p-4 font-semibold" style="border-color:var(--sy-line);background:var(--sy-surface);color:var(--sy-muted)">
|
||||
La recherche n'est pas disponible pour le moment.
|
||||
</p>
|
||||
@elseif ($texte !== '' && empty($resultats))
|
||||
<p class="mt-6 font-semibold" style="color:var(--sy-muted)">Rien trouvé pour « {{ $texte }} ».</p>
|
||||
@elseif (! empty($resultats))
|
||||
<ul class="mt-6 grid gap-3">
|
||||
@foreach ($resultats as $resultat)
|
||||
<li class="rounded-2xl border p-5" style="border-color:var(--sy-line);background:var(--sy-surface)">
|
||||
<p class="font-mono text-xs font-semibold uppercase" style="color:var(--sy-muted)">{{ $resultat['type'] === 'cours' ? 'Cours' : 'Lab' }}</p>
|
||||
@if ($resultat['type'] === 'cours')
|
||||
<a href="{{ route('mes-cours.show', $resultat['identifiant']) }}" class="mt-1 block text-lg font-extrabold underline underline-offset-4" style="color:var(--sy-ink)">{{ $resultat['titre'] }}</a>
|
||||
@else
|
||||
<p class="mt-1 text-lg font-extrabold" style="color:var(--sy-ink)">{{ $resultat['titre'] }}</p>
|
||||
@endif
|
||||
<p class="mt-2 font-semibold" style="color:var(--sy-muted)">{!! $resultat['extrait'] !!}</p>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
|
@ -29,6 +29,7 @@ use App\Http\Controllers\QuizController;
|
|||
use App\Http\Controllers\QuizEleveController;
|
||||
use App\Http\Controllers\RaccourciController;
|
||||
use App\Http\Controllers\RapportCspController;
|
||||
use App\Http\Controllers\RechercheController;
|
||||
use App\Http\Controllers\RejoindreClasseController;
|
||||
use App\Http\Controllers\ResultatsController;
|
||||
use App\Http\Controllers\SeanceController;
|
||||
|
|
@ -122,6 +123,7 @@ Route::middleware(['auth:eleve,web', 'session.unique:eleve', 'session.unique:web
|
|||
Route::post('/raccourcis/{raccourci}/supprimer', [RaccourciController::class, 'destroy'])->name('raccourcis.destroy');
|
||||
Route::get('/emploi-du-temps', [EmploiDuTempsController::class, 'show'])->name('emploi-du-temps');
|
||||
Route::get('/mes-cours/{cours:identifiant}', [MesCoursController::class, 'montrer'])->name('mes-cours.show');
|
||||
Route::get('/recherche', [RechercheController::class, 'chercher'])->middleware('throttle:30,1')->name('recherche');
|
||||
});
|
||||
|
||||
// --- Espace élève (protégé, session unique) ---
|
||||
|
|
|
|||
69
portail/tests/Feature/RechercheTest.php
Normal file
69
portail/tests/Feature/RechercheTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\Cours;
|
||||
use App\Models\Eleve;
|
||||
use App\Models\Lab;
|
||||
use App\Services\Recherche\IndexRecherche;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('la recherche affiche un message d’indisponibilité tant qu’Elasticsearch n’est pas configuré', function () {
|
||||
$eleve = Eleve::factory()->create();
|
||||
|
||||
eleve($this, $eleve)->get(route('recherche', ['q' => 'routage']))
|
||||
->assertOk()
|
||||
->assertSee('pas disponible');
|
||||
});
|
||||
|
||||
test('un prof et un élève peuvent tous les deux utiliser la recherche', function () {
|
||||
$eleve = Eleve::factory()->create();
|
||||
eleve($this, $eleve)->get(route('recherche'))->assertOk();
|
||||
|
||||
$classe = Classe::factory()->create();
|
||||
commeProf($this, $classe->prof)->get(route('recherche'))->assertOk();
|
||||
});
|
||||
|
||||
test('un résultat de type cours renvoie vers la page du cours, avec l’extrait surligné', function () {
|
||||
config(['elasticsearch.hote' => '127.0.0.1']);
|
||||
Cours::create(['identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'contenu_markdown' => 'Le routage statique...']);
|
||||
|
||||
Http::fake([
|
||||
'*/_search' => Http::response([
|
||||
'hits' => ['hits' => [
|
||||
[
|
||||
'_source' => ['type' => 'cours', 'identifiant' => '08-deux-lan-routeur', 'titre' => 'Deux LAN et un routeur', 'contenu' => 'Le routage statique...'],
|
||||
'highlight' => ['contenu' => ['Le <em>routage</em> statique...']],
|
||||
],
|
||||
]],
|
||||
]),
|
||||
]);
|
||||
|
||||
$eleve = Eleve::factory()->create();
|
||||
|
||||
eleve($this, $eleve)->get(route('recherche', ['q' => 'routage']))
|
||||
->assertOk()
|
||||
->assertSee('Deux LAN et un routeur')
|
||||
->assertSee(route('mes-cours.show', '08-deux-lan-routeur'), false)
|
||||
->assertSee('<em>routage</em>', false);
|
||||
});
|
||||
|
||||
test('reconstruire l’index envoie un document par lab et par cours', function () {
|
||||
config(['elasticsearch.hote' => '127.0.0.1']);
|
||||
Http::fake();
|
||||
Lab::factory()->create(['identifiant' => 'lab-test', 'titre' => 'Lab de test', 'competences' => ['routage']]);
|
||||
Cours::create(['identifiant' => 'cours-test', 'titre' => 'Cours de test', 'contenu_markdown' => '# Titre']);
|
||||
|
||||
IndexRecherche::reconstruire();
|
||||
|
||||
Http::assertSent(fn ($r) => $r->method() === 'DELETE' && str_ends_with($r->url(), '/paillasse_catalogue'));
|
||||
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_ends_with($r->url(), '/_doc/lab-lab-test'));
|
||||
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_ends_with($r->url(), '/_doc/cours-cours-test'));
|
||||
});
|
||||
|
||||
test('sans Elasticsearch configuré, reconstruire l’index ne fait aucun appel réseau', function () {
|
||||
Http::fake();
|
||||
|
||||
IndexRecherche::reconstruire();
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue