From 777dd1cbc8b60981eddce6f0a5d13e2edd6ebfd7 Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Wed, 14 Oct 2026 13:24:13 +0200 Subject: [PATCH] ajoute la recherche plein texte dans les cours et labs (bloc C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ansible/elasticsearch.yml | 11 ++ ansible/roles/elasticsearch/defaults/main.yml | 7 ++ ansible/roles/elasticsearch/handlers/main.yml | 6 ++ ansible/roles/elasticsearch/tasks/main.yml | 63 +++++++++++ .../templates/elasticsearch.yml.j2 | 15 +++ .../elasticsearch/templates/heap.options.j2 | 3 + ...009-elasticsearch-interne-sans-securite.md | 70 ++++++++++++ docs/dette.md | 1 + docs/journal.md | 23 ++++ docs/runbooks/elasticsearch.md | 64 +++++++++++ .../app/Console/Commands/IndexerRecherche.php | 30 ++++++ .../Console/Commands/SynchroniserCours.php | 2 + .../app/Console/Commands/SynchroniserLabs.php | 2 + .../Http/Controllers/RechercheController.php | 25 +++++ .../app/Services/Recherche/IndexRecherche.php | 101 ++++++++++++++++++ portail/config/elasticsearch.php | 10 ++ portail/resources/views/layouts/app.blade.php | 4 + portail/resources/views/recherche.blade.php | 37 +++++++ portail/routes/web.php | 2 + portail/tests/Feature/RechercheTest.php | 69 ++++++++++++ 20 files changed, 545 insertions(+) create mode 100644 ansible/elasticsearch.yml create mode 100644 ansible/roles/elasticsearch/defaults/main.yml create mode 100644 ansible/roles/elasticsearch/handlers/main.yml create mode 100644 ansible/roles/elasticsearch/tasks/main.yml create mode 100644 ansible/roles/elasticsearch/templates/elasticsearch.yml.j2 create mode 100644 ansible/roles/elasticsearch/templates/heap.options.j2 create mode 100644 docs/adr/0009-elasticsearch-interne-sans-securite.md create mode 100644 docs/runbooks/elasticsearch.md create mode 100644 portail/app/Console/Commands/IndexerRecherche.php create mode 100644 portail/app/Http/Controllers/RechercheController.php create mode 100644 portail/app/Services/Recherche/IndexRecherche.php create mode 100644 portail/config/elasticsearch.php create mode 100644 portail/resources/views/recherche.blade.php create mode 100644 portail/tests/Feature/RechercheTest.php diff --git a/ansible/elasticsearch.yml b/ansible/elasticsearch.yml new file mode 100644 index 0000000..c7a4e96 --- /dev/null +++ b/ansible/elasticsearch.yml @@ -0,0 +1,11 @@ +--- +# Bloc C : recherche plein texte dans les cours et labs. +# Rejoue aussi `portail` : c'est lui qui connaît {{ portail_dossier_app }} +# pour écrire ELASTICSEARCH_HOTE/PORT dans son .env. +- name: Portail et Elasticsearch + hosts: syllabus + become: true + gather_facts: true + roles: + - portail + - elasticsearch diff --git a/ansible/roles/elasticsearch/defaults/main.yml b/ansible/roles/elasticsearch/defaults/main.yml new file mode 100644 index 0000000..e5b2448 --- /dev/null +++ b/ansible/roles/elasticsearch/defaults/main.yml @@ -0,0 +1,7 @@ +--- +elasticsearch_port: 9200 +# Catalogue de cours/labs seulement, volume très faible : un tas aussi +# grand que le calcul automatique d'Elasticsearch (jusqu'à 50 % de la RAM +# système) serait désastreux sur ce serveur à 31 Go partagés avec +# OpenStack et le portail. Voir docs/runbooks/elasticsearch.md. +elasticsearch_tas_mo: 512 diff --git a/ansible/roles/elasticsearch/handlers/main.yml b/ansible/roles/elasticsearch/handlers/main.yml new file mode 100644 index 0000000..5067b27 --- /dev/null +++ b/ansible/roles/elasticsearch/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: redémarrer elasticsearch + ansible.builtin.systemd: + name: elasticsearch + state: restarted + enabled: true diff --git a/ansible/roles/elasticsearch/tasks/main.yml b/ansible/roles/elasticsearch/tasks/main.yml new file mode 100644 index 0000000..8a2f2f5 --- /dev/null +++ b/ansible/roles/elasticsearch/tasks/main.yml @@ -0,0 +1,63 @@ +--- +# Bloc C (CLAUDE.md) : recherche plein texte dans les cours et labs. +# Instance interne seule (voir elasticsearch.yml.j2) : jamais exposée, +# jamais de réseau à configurer dans Nginx — seul le portail lui parle, en +# PHP, sur 127.0.0.1. +- name: ajouter la clé de signature du dépôt Elastic + ansible.builtin.get_url: + url: https://artifacts.elastic.co/GPG-KEY-elasticsearch + dest: /tmp/elastic-gpg-key.asc + mode: "0644" + register: elasticsearch_cle_brute + +- name: déposer la clé au format attendu par apt + ansible.builtin.shell: gpg --dearmor < /tmp/elastic-gpg-key.asc > /usr/share/keyrings/elastic.gpg + args: + creates: /usr/share/keyrings/elastic.gpg + +- name: ajouter le dépôt Elastic 8.x + ansible.builtin.apt_repository: + repo: "deb [signed-by=/usr/share/keyrings/elastic.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main" + filename: elastic-8.x + state: present + update_cache: true + +- name: installer Elasticsearch + ansible.builtin.apt: + name: elasticsearch + state: present + register: elasticsearch_installation + +- name: déployer la configuration (sécurité désactivée, écoute locale seulement) + ansible.builtin.template: + src: elasticsearch.yml.j2 + dest: /etc/elasticsearch/elasticsearch.yml + owner: root + group: elasticsearch + mode: "0660" + notify: redémarrer elasticsearch + +- name: réduire le tas JVM (catalogue de cours/labs, volume très faible) + ansible.builtin.template: + src: heap.options.j2 + dest: /etc/elasticsearch/jvm.options.d/heap.options + owner: root + group: elasticsearch + mode: "0660" + notify: redémarrer elasticsearch + +- name: activer et démarrer Elasticsearch + ansible.builtin.systemd: + name: elasticsearch + state: started + enabled: true + +- name: ajouter/mettre à jour les variables Elasticsearch dans .env du portail + ansible.builtin.lineinfile: + path: "{{ portail_dossier_app }}/.env" + regexp: "^{{ item.cle }}=" + line: "{{ item.cle }}={{ item.valeur }}" + loop: + - { cle: "ELASTICSEARCH_HOTE", valeur: "127.0.0.1" } + - { cle: "ELASTICSEARCH_PORT", valeur: "{{ elasticsearch_port }}" } + notify: redémarrer horizon diff --git a/ansible/roles/elasticsearch/templates/elasticsearch.yml.j2 b/ansible/roles/elasticsearch/templates/elasticsearch.yml.j2 new file mode 100644 index 0000000..91af46e --- /dev/null +++ b/ansible/roles/elasticsearch/templates/elasticsearch.yml.j2 @@ -0,0 +1,15 @@ +# Géré par Ansible (rôle elasticsearch) — ne pas modifier à la main. +# Instance interne (consommée seulement par le portail, jamais exposée) : +# sécurité x-pack désactivée, comme les autres services internes de ce +# serveur (MariaDB/Redis de l'app sur 127.0.0.1, API OpenStack sur +# 10.10.0.1 en HTTP) — l'isolation vient du pare-feu et de l'écoute en +# local seulement, pas de TLS/authentification par service. Voir ADR 0009. +path.data: /var/lib/elasticsearch +path.logs: /var/log/elasticsearch +discovery.type: single-node +network.host: 127.0.0.1 +http.port: {{ elasticsearch_port }} +xpack.security.enabled: false +xpack.security.enrollment.enabled: false +xpack.security.http.ssl.enabled: false +xpack.security.transport.ssl.enabled: false diff --git a/ansible/roles/elasticsearch/templates/heap.options.j2 b/ansible/roles/elasticsearch/templates/heap.options.j2 new file mode 100644 index 0000000..cb13feb --- /dev/null +++ b/ansible/roles/elasticsearch/templates/heap.options.j2 @@ -0,0 +1,3 @@ +# Géré par Ansible (rôle elasticsearch) — ne pas modifier à la main. +-Xms{{ elasticsearch_tas_mo }}m +-Xmx{{ elasticsearch_tas_mo }}m diff --git a/docs/adr/0009-elasticsearch-interne-sans-securite.md b/docs/adr/0009-elasticsearch-interne-sans-securite.md new file mode 100644 index 0000000..273cedb --- /dev/null +++ b/docs/adr/0009-elasticsearch-interne-sans-securite.md @@ -0,0 +1,70 @@ +# ADR 0009 — Elasticsearch interne, sécurité x-pack désactivée + +- **Date** : 2026-10-14 +- **Statut** : accepté + +## Contexte + +Bloc C du CLAUDE.md : « ElasticSearch : recherche plein texte dans les cours +et les labs (seulement si le besoin est réel). » Adam a confirmé le besoin +explicitement (« fonce »). Catalogue minuscule (quelques labs, un seul +`cours.md` aujourd'hui) : aucun besoin d'un cluster, d'un cache de +répertoires exotique ou d'une bibliothèque cliente PHP dédiée. + +## Décisions + +**Une instance Elasticsearch 8.19, locale, jamais exposée.** Installée +depuis le dépôt APT officiel Elastic (clé GPG vérifiée, même principe que +Ceph/ADR 0007). Consommée uniquement par le portail, en PHP, sur +`127.0.0.1:9200` — jamais par un navigateur. + +**Sécurité x-pack désactivée (`xpack.security.enabled: false`).** +L'installation standard active TLS + authentification par défaut depuis la +8.x (mot de passe généré, certificats auto-signés) — pertinent pour un +cluster multi-nœuds exposé, disproportionné pour une instance mono-nœud qui +ne parle qu'à elle-même sur la boucle locale. Cohérent avec le reste de ce +serveur : l'API OpenStack interne (`10.10.0.1`) et le MariaDB/Redis dédiés +au portail (`127.0.0.1:3307`/`6380`) n'ont pas non plus de TLS entre +services — l'isolation vient du pare-feu (`policy drop` par défaut) et de +l'écoute en local, pas d'une authentification par service. Désactiver x-pack +évite aussi de devoir stocker et faire tourner un mot de passe Elasticsearch +supplémentaire pour rien. + +**Tas JVM réduit à 512 Mo (`-Xms512m -Xmx512m`), pas le calcul automatique +d'Elasticsearch.** Par défaut, Elasticsearch vise jusqu'à 50 % de la RAM +système — sur ce serveur à 31 Go réels (voir ADR 0007), cela aurait pu +réserver ~15 Go à un service qui indexe quelques dizaines de documents. +Mesuré après coup : ~900 Mo de RSS au total pour le processus, RAM +disponible du serveur restée stable (~6 Go) pendant tout le chantier. + +**Appels HTTP directs (`Illuminate\Support\Facades\Http`), pas le client +PHP officiel `elastic/elasticsearch`.** Le besoin se limite à indexer des +documents et faire une recherche plein texte avec surlignage — deux appels +REST simples. Ajouter une bibliothèque cliente complète (et ses +dépendances) pour ça aurait été une techno sans besoin réel (CLAUDE.md, +section 8). + +**Analyseur `french` d'Elasticsearch sur les champs `titre`/`contenu`.** +Livré avec Elasticsearch, pas un plugin à installer : améliore réellement la +pertinence sur du contenu pédagogique en français (élision, racines des +mots) sans coût d'exploitation supplémentaire. + +**Reconstruction complète de l'index à chaque synchronisation** +(`labs:synchroniser`, `cours:synchroniser`), pas une mise à jour +incrémentale. Volume du catalogue trop faible pour que ça coûte quoi que ce +soit ; plus simple et plus sûr (jamais de document orphelin si un lab est +retiré). + +## Conséquences + +- Recherche vérifiée en réel avec un compte élève jetable : les deux vrais + documents existants (labs catalogués + `08-deux-lan-routeur/cours.md`, + contenu réel) retrouvés par recherche plein texte, extrait surligné + affiché, compte supprimé ensuite. +- `App\Services\Recherche\IndexRecherche::disponible()` renvoie `false` si + `ELASTICSEARCH_HOTE` n'est pas renseigné : la page de recherche affiche + alors un message d'indisponibilité au lieu d'une erreur — même principe + que `StockageTravauxEleves::disponible()` pour le S3. +- En dette : pas de pagination des résultats (taille de catalogue bien en + dessous du besoin), pas de recherche à facettes (niveau, compétence) — + à ajouter si le catalogue grossit réellement. diff --git a/docs/dette.md b/docs/dette.md index a64ce8e..bad488a 100644 --- a/docs/dette.md +++ b/docs/dette.md @@ -40,3 +40,4 @@ retirer la ligne (et le noter dans le journal) quand c'est fait. | Ceph : démo mono-serveur seulement (1 OSD sur fichier épars, pas de réplication, pas branché à Cinder/Glance) | `ceph_pool_default_size=1` et un seul OSD acceptés uniquement parce que c'est une démo de compréhension, sans donnée réelle. Le vrai cluster (plusieurs OSD, réplication, intégré à Cinder) attend le 2ᵉ serveur du CLAUDE.md. Voir [ADR 0007](adr/0007-ceph-demo-mono-serveur.md). | 2ᵉ serveur (bloc C) | 2026-10-14 | | Ceph : bootstrap fait à la main, pas encore rejouable par Ansible | Comme Kolla à l'étape 2 : acceptable pour une démo, à automatiser si ce cluster doit durer. Le fichier systemd du loop device est suivi dans `infra/ceph/` mais déployé à la main. | Si la démo devient durable | 2026-10-14 | | WebDAV de l'élève : stockage local, pas S3 ; pas de quota par élève | Dossier local (`storage/app/webdav/`), partage le disque serré avec OpenStack et le portail — pas de limite de taille par élève. rclone sait servir un backend S3 sans changement d'architecture (voir [ADR 0008](adr/0008-webdav-eleve-auth-request.md)) : à basculer en même temps que le bucket S3 des devoirs (`docs/runbooks/stockage-s3.md`), et à ajouter un quota si l'usage réel le demande. | Quand le bucket S3 existe / si l'usage le demande | 2026-10-14 | +| Recherche (Elasticsearch) : pas de pagination ni de facettes | Catalogue minuscule aujourd'hui (quelques labs, un seul vrai `cours.md`) : inutile tant que ça reste le cas. Voir [ADR 0009](adr/0009-elasticsearch-interne-sans-securite.md). | Si le catalogue grossit réellement | 2026-10-14 | diff --git a/docs/journal.md b/docs/journal.md index 5f07c20..5d9d93e 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -1251,3 +1251,26 @@ de haut en bas — une bonne question à savoir expliquer en entretien SRE. **Suite** Prochain chantier du bloc C : ElasticSearch. + +## 2026-10-14 — Recherche plein texte, Elasticsearch (bloc C) + +Troisième chantier du bloc C : recherche plein texte dans les cours et les +labs. Détail des choix : [ADR 0009](adr/0009-elasticsearch-interne-sans-securite.md). + +**Fait** + +- Elasticsearch 8.19 installé depuis le dépôt officiel (clé GPG vérifiée), instance strictement interne : écoute `127.0.0.1` seulement, sécurité x-pack désactivée (cohérent avec le reste du serveur — l'API OpenStack interne et le MariaDB/Redis du portail n'ont pas de TLS entre services non plus, l'isolation vient du pare-feu), tas JVM limité à 512 Mo (le calcul automatique d'Elasticsearch, jusqu'à 50 % de la RAM système, aurait pu réserver ~15 Go sur ce serveur à 31 Go réels). +- `App\Services\Recherche\IndexRecherche` : appels HTTP directs (pas de bibliothèque cliente, le besoin se limite à indexer et chercher), analyseur `french` d'Elasticsearch sur les champs texte, `disponible()` même principe que le stockage S3 (la page affiche juste « pas disponible » tant que ce n'est pas configuré, jamais une erreur). +- Index reconstruit automatiquement après `labs:synchroniser` et `cours:synchroniser` — pas de mise à jour incrémentale, le catalogue est trop petit pour que ça coûte quoi que ce soit. +- Page « Recherche » (menu du compte, profs et élèves), route commune aux deux guards (`auth:eleve,web`, déjà utilisée par « Mes cours »/« Paramètres »). +- 5 tests Pest (`Http::fake()`, jamais contre un vrai Elasticsearch) : indisponibilité propre, accès par les deux guards, surlignage affiché avec le bon lien vers la page du cours, reconstruction de l'index envoie bien un document par lab/cours, et n'envoie *aucun* appel réseau quand Elasticsearch n'est pas configuré. + +**Vérifié en réel** : index reconstruit sur le serveur depuis les vraies données (2 documents : le lab 08 et son vrai `cours.md`), recherche du mot « routage » retrouve les deux avec le bon score, page `/recherche` testée avec un compte élève jetable (connexion réelle par cookies de session, pas juste un appel direct au contrôleur) — le mot recherché apparaît bien surligné (`routage`) et le lien vers le cours fonctionne. RAM disponible du serveur restée stable (~6 Go) pendant tout le chantier. Compte de test supprimé ensuite. + +**Comprendre** + +Le réflexe acquis avec Ceph et WebDAV (vérifier l'état réel avant d'agir, mesurer plutôt que deviner) a évité ici une vraie bêtise : Elasticsearch calcule par défaut un tas JVM jusqu'à 50 % de la RAM système, ce qui aurait été catastrophique sur ce serveur sans même un message d'erreur — juste un service qui prend toute la RAM disponible. Fixer explicitement le tas avant le premier démarrage, plutôt qu'après avoir constaté le problème, est le genre de précaution qui ne coûte rien et qui évite un incident. + +**Suite** + +Bloc C restant : WebDAV et Ceph faits, Elasticsearch fait. ElasticSearch avait été écarté du MCP développeurs par Adam (pas pour l'instant). Prochains chantiers confirmés par Adam : LTI Moodle 1.3, puis SSO ENT (Keycloak) — normalement des étapes V2/V3 du produit, avancées à la demande explicite d'Adam. diff --git a/docs/runbooks/elasticsearch.md b/docs/runbooks/elasticsearch.md new file mode 100644 index 0000000..dc4a957 --- /dev/null +++ b/docs/runbooks/elasticsearch.md @@ -0,0 +1,64 @@ +# Runbook — Elasticsearch (recherche dans les cours et labs) + +Instance interne, jamais exposée, consommée seulement par le portail en PHP +(`App\Services\Recherche\IndexRecherche`). Détail des choix : +[ADR 0009](../adr/0009-elasticsearch-interne-sans-securite.md). + +## Où sont les choses + +| Élément | Emplacement | +|---|---| +| Config | `/etc/elasticsearch/elasticsearch.yml` (sécurité x-pack désactivée, écoute `127.0.0.1` seulement) | +| Tas JVM | `/etc/elasticsearch/jvm.options.d/heap.options` (512 Mo, voir ADR 0009) | +| Index | `paillasse_catalogue` | +| Déployé par | `ansible/elasticsearch.yml` (rôle `elasticsearch`, rejoue aussi `portail` pour écrire `.env`) | + +``` +cd ansible && script -q /dev/null ansible-playbook elasticsearch.yml -i inventory.ini +``` + +## Vérifier que ça fonctionne + +``` +systemctl is-active elasticsearch +curl -s http://127.0.0.1:9200/paillasse_catalogue/_count +``` + +Reconstruire l'index manuellement (après un changement de mapping, ou si le +catalogue semble désynchronisé) : + +``` +cd /var/www/portail +sudo -u www-data HOME=/tmp php8.3 artisan recherche:indexer +``` + +Autrement, l'index se reconstruit automatiquement après `labs:synchroniser` +et `cours:synchroniser` (voir ces deux commandes). + +## Pourquoi la RAM est surveillée + +Comme pour Ceph et WebDAV (voir les runbooks correspondants) : ce serveur a +réellement 31 Go de RAM, partagés avec OpenStack et le portail. Le tas JVM +est volontairement limité à 512 Mo (~900 Mo de RSS au total mesuré) — ne +jamais retirer `heap.options` ni laisser Elasticsearch calculer sa taille de +tas automatiquement sur ce serveur. + +## Si le besoin grossit réellement + +Pas de pagination ni de recherche à facettes pour l'instant (catalogue +minuscule). À ajouter dans `App\Services\Recherche\IndexRecherche` et +`RechercheController` si le catalogue de cours/labs grossit vraiment — +jamais avant, pour ne pas construire une fonction sans usage réel. + +## Nettoyer entièrement (si besoin) + +``` +ssh paillasse +sudo systemctl disable --now elasticsearch +sudo rm -rf /var/lib/elasticsearch /etc/elasticsearch +sudo apt-get purge -y elasticsearch +sudo rm /etc/apt/sources.list.d/elastic-8.x.list /usr/share/keyrings/elastic.gpg +sudo apt-get update +``` +Puis retirer `ELASTICSEARCH_HOTE`/`ELASTICSEARCH_PORT` du `.env` du portail +et redémarrer Horizon. diff --git a/portail/app/Console/Commands/IndexerRecherche.php b/portail/app/Console/Commands/IndexerRecherche.php new file mode 100644 index 0000000..916fd56 --- /dev/null +++ b/portail/app/Console/Commands/IndexerRecherche.php @@ -0,0 +1,30 @@ +warn('Elasticsearch non configuré : rien à indexer.'); + + return; + } + + IndexRecherche::reconstruire(); + $this->info('Index reconstruit.'); + } +} diff --git a/portail/app/Console/Commands/SynchroniserCours.php b/portail/app/Console/Commands/SynchroniserCours.php index 9f62a24..aab6091 100644 --- a/portail/app/Console/Commands/SynchroniserCours.php +++ b/portail/app/Console/Commands/SynchroniserCours.php @@ -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(); } } diff --git a/portail/app/Console/Commands/SynchroniserLabs.php b/portail/app/Console/Commands/SynchroniserLabs.php index 8bc6f38..23223fa 100644 --- a/portail/app/Console/Commands/SynchroniserLabs.php +++ b/portail/app/Console/Commands/SynchroniserLabs.php @@ -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(); } } diff --git a/portail/app/Http/Controllers/RechercheController.php b/portail/app/Http/Controllers/RechercheController.php new file mode 100644 index 0000000..1ec6e64 --- /dev/null +++ b/portail/app/Http/Controllers/RechercheController.php @@ -0,0 +1,25 @@ +query('q', '')); + + return view('recherche', [ + 'texte' => $texte, + 'resultats' => IndexRecherche::chercher($texte), + 'disponible' => IndexRecherche::disponible(), + ]); + } +} diff --git a/portail/app/Services/Recherche/IndexRecherche.php b/portail/app/Services/Recherche/IndexRecherche.php new file mode 100644 index 0000000..8e76167 --- /dev/null +++ b/portail/app/Services/Recherche/IndexRecherche.php @@ -0,0 +1,101 @@ + [ + '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 + */ + 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> $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) ?? ''); + } +} diff --git a/portail/config/elasticsearch.php b/portail/config/elasticsearch.php new file mode 100644 index 0000000..40b4b7f --- /dev/null +++ b/portail/config/elasticsearch.php @@ -0,0 +1,10 @@ + env('ELASTICSEARCH_HOTE'), + 'port' => env('ELASTICSEARCH_PORT', 9200), + 'index' => 'paillasse_catalogue', +]; diff --git a/portail/resources/views/layouts/app.blade.php b/portail/resources/views/layouts/app.blade.php index ca9de22..37e9c18 100644 --- a/portail/resources/views/layouts/app.blade.php +++ b/portail/resources/views/layouts/app.blade.php @@ -37,6 +37,10 @@ Mes fichiers @endif + + + Recherche + Aide et assistance diff --git a/portail/resources/views/recherche.blade.php b/portail/resources/views/recherche.blade.php new file mode 100644 index 0000000..e0e0e96 --- /dev/null +++ b/portail/resources/views/recherche.blade.php @@ -0,0 +1,37 @@ +@extends('layouts.app') + +@section('titre', 'Recherche') + +@section('contenu') + +@endsection diff --git a/portail/routes/web.php b/portail/routes/web.php index b7010b0..0814570 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -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) --- diff --git a/portail/tests/Feature/RechercheTest.php b/portail/tests/Feature/RechercheTest.php new file mode 100644 index 0000000..04b1209 --- /dev/null +++ b/portail/tests/Feature/RechercheTest.php @@ -0,0 +1,69 @@ +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 routage 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('routage', 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(); +});