Basculer le menu
Changer de menu des préférences
Basculer le menu personnel
Non connecté(e)
Votre adresse IP sera visible au public si vous faites des modifications.
Version datée du 20 octobre 2026 à 17:21 par Thomas234 (discussion | contributions) (Essai de l'assistant Microguide (JS personnel, visible seulement par moi))
(diff) ← Version précédente | Version actuelle (diff) | Version suivante → (diff)

Note : après avoir publié vos modifications, il se peut que vous deviez forcer le rechargement complet du cache de votre navigateur pour voir les changements.

  • Firefox / Safari : maintenez la touche Maj (Shift) en cliquant sur le bouton Actualiser ou appuyez sur Ctrl + F5 ou Ctrl + R (⌘ + R sur un Mac).
  • Google Chrome : appuyez sur Ctrl + Maj + R (⌘ + Shift + R sur un Mac).
  •  Edge : maintenez la touche Ctrl en cliquant sur le bouton Actualiser ou pressez Ctrl + F5.
/* =====================================================================
   Assistant Microguide — recherche-réponse pour microguide.ca
   =====================================================================
   Fonctionne ENTIÈREMENT dans le navigateur du visiteur :
     - il lit le wiki par son API publique (api.php) ;
     - il bâtit son index tout seul, puis le garde en cache 24 h ;
     - il répond en recopiant mot pour mot le texte et les tableaux du wiki,
       avec le lien de la page.
   Aucun serveur à louer, aucune clé d'API, aucun service externe, aucune
   dépendance. Rien ne sort du wiki.

   INSTALLATION : coller ce fichier au complet dans MediaWiki:Common.js
   (Thomas234 a le droit « interface-admin », donc l'accès à cette page).

   POUR L'ENLEVER : vider MediaWiki:Common.js de ce bloc. Rien d'autre
   n'a été modifié sur le wiki.

   Réglage facultatif, avant ce bloc :
     window.MICROGUIDE_BASE = "https://microguide.ca";  // sinon : site courant
   ===================================================================== */

/* ---------- 1/4 vocabulaire ---------- */
// Vocabulaire de l'assistant microguide.
//
// GLOSSAIRE : français québécois, abréviations cliniques et noms de rue ->
// mots réellement écrits dans le wiki. Appliqué sans IA, donc sans erreur
// possible (principe repris du glossaire du bibliothécaire).
//
// INTENTIONS : quelle SORTE de renseignement la question demande, pour
// privilégier la bonne section (« quoi donner » -> section Traitements).

window.MICROGUIDE_GLOSSAIRE = {
  "atb": [
    "antibiotique",
    "antibiotiques"
  ],
  "abx": [
    "antibiotique",
    "antibiotiques"
  ],
  "anti biotique": [
    "antibiotique"
  ],
  "traiter": [
    "traitement",
    "traitements"
  ],
  "traite": [
    "traitement"
  ],
  "soigner": [
    "traitement"
  ],
  "donner": [
    "traitement"
  ],
  "prescrire": [
    "traitement"
  ],
  "couvrir": [
    "couverture",
    "traitement"
  ],
  "couvre": [
    "couverture"
  ],
  "tuer": [
    "traitement",
    "sensibilite"
  ],
  "marche": [
    "efficace",
    "traitement"
  ],
  "fonctionne": [
    "efficace",
    "traitement"
  ],
  "pogner": [
    "infection",
    "acquisition"
  ],
  "attraper": [
    "infection",
    "acquisition"
  ],
  "poumon": [
    "pulmonaire",
    "pneumonie"
  ],
  "poumons": [
    "pulmonaire",
    "pneumonie"
  ],
  "pipi": [
    "urinaire"
  ],
  "urine": [
    "urinaire"
  ],
  "vessie": [
    "cystite",
    "urinaire"
  ],
  "rein": [
    "pyelonephrite",
    "urinaire"
  ],
  "reins": [
    "pyelonephrite",
    "urinaire"
  ],
  "sang": [
    "bacteriemie",
    "sepsis",
    "hemoculture"
  ],
  "coeur": [
    "endocardite"
  ],
  "peau": [
    "cutanee",
    "tissus mous",
    "cellulite"
  ],
  "os": [
    "osteomyelite",
    "osteoarticulaire"
  ],
  "cerveau": [
    "meningite"
  ],
  "ventre": [
    "intra abdominale",
    "digestif"
  ],
  "diarrhee": [
    "digestif",
    "colite"
  ],
  "oreille": [
    "otite"
  ],
  "gorge": [
    "pharyngite",
    "orl"
  ],
  "yeux": [
    "keratite",
    "ophtalmologique"
  ],
  "oeil": [
    "keratite",
    "ophtalmologique"
  ],
  "bibitte": [
    "bacterie"
  ],
  "germe": [
    "bacterie"
  ],
  "microbe": [
    "bacterie"
  ],
  "resistant": [
    "resistance",
    "resistances"
  ],
  "resiste": [
    "resistance"
  ],
  "dose": [
    "posologie",
    "dose"
  ],
  "poso": [
    "posologie"
  ],
  "grossesse": [
    "grossesse",
    "enceinte"
  ],
  "enceinte": [
    "grossesse"
  ],
  "allergie": [
    "allergie",
    "allergique"
  ],
  "penicilline": [
    "penicilline",
    "penicillines"
  ],
  "pseudo": [
    "pseudomonas"
  ],
  "staph": [
    "staphylococcus",
    "staphyloccocus"
  ],
  "strep": [
    "streptococcus",
    "streptocoque"
  ],
  "pneumo": [
    "pneumoniae",
    "pneumocoque"
  ],
  "sarm": [
    "sarm",
    "meticilline",
    "resistant"
  ],
  "e coli": [
    "escherichia",
    "coli"
  ],
  "ecoli": [
    "escherichia",
    "coli"
  ],
  "c diff": [
    "clostridioides",
    "difficile"
  ],
  "cdiff": [
    "clostridioides",
    "difficile"
  ],
  "klebs": [
    "klebsiella"
  ],
  "entero": [
    "enterocoque",
    "enterococcus",
    "enterobacter"
  ],
  "bgn": [
    "gram negatif"
  ],
  "bgp": [
    "gram positif"
  ],
  "cocci": [
    "cocci",
    "coque"
  ],
  "labo": [
    "laboratoire"
  ],
  "gram": [
    "gram"
  ],
  "vanco": [
    "vancomycine"
  ],
  "cipro": [
    "ciprofloxacine"
  ],
  "levo": [
    "levofloxacine"
  ],
  "moxi": [
    "moxifloxacine"
  ],
  "ceftri": [
    "ceftriaxone"
  ],
  "cefta": [
    "ceftazidime"
  ],
  "pip tazo": [
    "piperacilline",
    "tazobactam"
  ],
  "piptazo": [
    "piperacilline",
    "tazobactam"
  ],
  "tazo": [
    "tazobactam",
    "piperacilline"
  ],
  "mero": [
    "meropeneme",
    "meropenem"
  ],
  "erta": [
    "ertapenem"
  ],
  "clinda": [
    "clindamycine"
  ],
  "flagyl": [
    "metronidazole"
  ],
  "metro": [
    "metronidazole"
  ],
  "doxy": [
    "doxycycline"
  ],
  "azithro": [
    "azithromycine"
  ],
  "clav": [
    "clavulanate",
    "amoxicilline"
  ],
  "amox": [
    "amoxicilline"
  ],
  "cloxa": [
    "cloxacilline"
  ],
  "cefaz": [
    "cefazoline",
    "cefazolin"
  ],
  "keflex": [
    "cephalexine"
  ],
  "genta": [
    "gentamicine"
  ],
  "tobra": [
    "tobramycine"
  ],
  "septra": [
    "triclotrimoxazole",
    "tmp",
    "smx"
  ],
  "tmp smx": [
    "triclotrimoxazole"
  ],
  "fkp": [
    "fibrose kystique",
    "fkp"
  ],
  "mpoc": [
    "mpoc"
  ],
  "si": [
    "soins intensifs"
  ],
  "usi": [
    "soins intensifs"
  ]
};

window.MICROGUIDE_INTENTIONS = {
  "traitement": {
    "declencheurs": [
      "traiter",
      "traite",
      "traitement",
      "atb",
      "abx",
      "antibiotique",
      "antibiotiques",
      "soigner",
      "donner",
      "prescrire",
      "couvrir",
      "couverture",
      "efficace",
      "quoi donner",
      "tuer",
      "marche",
      "fonctionne",
      "choix",
      "empirique",
      "per os",
      "po",
      "iv"
    ],
    "titres": [
      "traitement",
      "traitements",
      "efficace",
      "efficaces",
      "spectre",
      "couverture",
      "sensibilite",
      "posologie"
    ]
  },
  "resistance": {
    "declencheurs": [
      "resistance",
      "resistances",
      "resistant",
      "resiste",
      "mecanisme",
      "mecanismes",
      "blse",
      "carbapenemase",
      "sarm",
      "ervr"
    ],
    "titres": [
      "resistance",
      "resistances",
      "mecanisme",
      "mecanismes"
    ]
  },
  "clinique": {
    "declencheurs": [
      "infection",
      "infections",
      "cause",
      "causes",
      "clinique",
      "maladie",
      "symptome",
      "symptomes",
      "manifestation",
      "manifestations",
      "tableau",
      "pneumonie",
      "cellulite",
      "endocardite",
      "meningite",
      "urinaire",
      "osteomyelite",
      "abces",
      "bacteriemie",
      "sepsis",
      "otite",
      "keratite"
    ],
    "titres": [
      "infection",
      "infections",
      "clinique",
      "manifestations",
      "principales"
    ]
  },
  "epidemiologie": {
    "declencheurs": [
      "epidemiologie",
      "reservoir",
      "ou vit",
      "transmission",
      "attraper",
      "pogner",
      "acquisition",
      "colonisation",
      "colonisant",
      "risque",
      "nosocomial",
      "nosocomiale",
      "communautaire",
      "porteur"
    ],
    "titres": [
      "epidemiologie",
      "reservoir",
      "transmission",
      "acquisition",
      "specificite"
    ]
  },
  "laboratoire": {
    "declencheurs": [
      "laboratoire",
      "labo",
      "identification",
      "identifier",
      "culture",
      "gram",
      "microscope",
      "coloration",
      "catalase",
      "coagulase",
      "oxydase",
      "lactose",
      "milieu",
      "gelose",
      "hemoculture",
      "tsi"
    ],
    "titres": [
      "laboratoire",
      "laboratoires",
      "microscope",
      "identification",
      "culture",
      "caracteristiques",
      "tests"
    ]
  }
};

/* ---------- 2/4 construction de l'index ---------- */
// Construit l'index de microguide.ca DANS LE NAVIGATEUR, à partir de l'API
// du wiki. Aucun fichier à maintenir : l'index se refait tout seul, donc il
// est toujours à jour avec le wiki.
//
// Deux appels suffisent : le contenu de toutes les pages, puis l'URL des
// images. Environ 130 Ko, gardés en cache local pour 24 h.

(function (root) {
  "use strict";

  var MOTS_VIDES = ("a au aux avec ce ces dans de des du elle en et eux il ils je la le les leur lui ma mais " +
    "me meme mes moi mon ne nos notre nous on ou par pas pour qu que qui sa se ses son sur ta te tes toi ton " +
    "tu un une vos votre vous y est sont etre ete suis es sommes etes ont ai as avons avez avait etait sera " +
    "seront c d j l m n s t quel quelle quels quelles quoi comment pourquoi quand combien cet cette plus " +
    "moins tres bien aussi alors donc car si comme tout tous toute toutes autre autres faire fait fais peut " +
    "peux peuvent doit dois doivent").split(" ");

  function normalise(s) {
    return (s || "").toLowerCase().normalize("NFD")
      .replace(/[̀-ͯ]/g, "")
      .replace(/[-'’/]/g, " ");
  }

  var VIDES = new Set(MOTS_VIDES);

  function mots(s) {
    return (normalise(s).match(/[a-z0-9]+/g) || []).filter(function (w) {
      return w.length > 1 && !VIDES.has(w);
    });
  }

  // ------------------------------------------------ nettoyage du wikitext

  function cellules(bloc) {
    var out = [];
    bloc.split("\n").forEach(function (l) {
      l = l.trim();
      if (l[0] === "!" || l[0] === "|") {
        l = l.replace(/^[!|]+/, "");
        if (l.indexOf("|") >= 0 && l.indexOf("||") < 0) l = l.replace(/^[^|]*\|(?!\|)/, "");
        l.split("||").forEach(function (c) {
          c = c.replace(/^[a-z\-]+\s*=\s*"[^"]*"\s*\|/, "").trim();
          if (c && !/^[-\s|]*$/.test(c)) out.push(c);
        });
      }
    });
    return out.join("\n");
  }

  function nettoyer(t) {
    t = t.replace(/<!--[\s\S]*?-->/g, "");
    t = t.replace(/<ref[^>]*\/>/g, "").replace(/<ref[\s\S]*?<\/ref>/g, "");
    for (var i = 0; i < 5; i++) {
      var t2 = t.replace(/\{\{[^{}]*\}\}/g, "");
      if (t2 === t) break;
      t = t2;
    }
    t = t.replace(/\{\|[\s\S]*?\|\}/g, function (m) { return cellules(m); });
    t = t.replace(/\[\[(?:Fichier|File|Image)\s*:[^\]]*\]\]/gi, "");
    t = t.replace(/\[\[([^\]|]*)\|([^\]]*)\]\]/g, "$2").replace(/\[\[([^\]]*)\]\]/g, "$1");
    t = t.replace(/\[https?:\/\/\S+\s+([^\]]*)\]/g, "$1").replace(/\[https?:\/\/\S+\]/g, "");
    t = t.replace(/<br\s*\/?>/gi, "\n");
    t = t.replace(/<\/?(div|span|p|big|small|center|u|b|i|sub|sup|font|blockquote)[^>]*>/gi, "");
    t = t.replace(/<[^>]+>/g, " ");
    t = t.replace(/'''/g, "").replace(/''/g, "");
    t = t.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
    t = t.replace(/[ \t]+/g, " ").replace(/\n\s*\n\s*\n+/g, "\n\n");
    return t.trim();
  }

  // ------------------------------------------------ découpage en sections

  function decouper(wikitext) {
    var lignes = wikitext.split("\n");
    var sections = [], cur = { titre: "", lignes: [] };
    lignes.forEach(function (ligne) {
      var m = ligne.match(/^\s*(={2,6})\s*(.+?)\s*\1\s*$/);
      if (m) {
        if (cur.lignes.length) sections.push(cur);
        var brut = m[2];
        // un titre qui n'est qu'une image n'est pas un titre
        if (/^\s*\[\[(?:Fichier|File|Image)\s*:/i.test(brut)) brut = "";
        cur = { titre: nettoyer(brut) || brut.replace(/^['= ]+|['= ]+$/g, ""), lignes: [] };
      } else {
        cur.lignes.push(ligne);
      }
    });
    if (cur.lignes.length) sections.push(cur);

    return sections.map(function (s) {
      var brut = s.lignes.join("\n");
      var imgs = [];
      var re = /\[\[(?:Fichier|File|Image)\s*:\s*([^|\]]+)/gi, m;
      while ((m = re.exec(brut))) imgs.push(m[1].trim().replace(/_/g, " "));
      var texte = nettoyer(brut);
      var puces = [], prose = [];
      texte.split("\n").forEach(function (l) {
        l = l.trim();
        if (!l) return;
        if (/^[\*\#•·–-]\s*/.test(l)) puces.push(l.replace(/^[\*\#•·–-]+\s*/, "").trim());
        else prose.push(l);
      });
      return { titre: s.titre, puces: puces, prose: prose, images: imgs };
    }).filter(function (s) {
      return s.puces.length || s.prose.length || s.images.length;
    });
  }

  // ------------------------------------------------ appels à l'API

  function api(params, base) {
    var u = (base || "") + "/api.php?origin=*&format=json&formatversion=2&" +
      Object.keys(params).map(function (k) {
        return encodeURIComponent(k) + "=" + encodeURIComponent(params[k]);
      }).join("&");
    return fetch(u, { credentials: "omit" }).then(function (r) {
      if (!r.ok) throw new Error("API " + r.status);
      return r.json();
    });
  }

  function toutesLesPages(base) {
    var pages = [];
    function tour(cont) {
      var p = {
        action: "query", generator: "allpages", gapnamespace: 0, gaplimit: "max",
        prop: "revisions", rvprop: "content|timestamp", rvslots: "main"
      };
      if (cont) for (var k in cont) p[k] = cont[k];
      return api(p, base).then(function (d) {
        ((d.query && d.query.pages) || []).forEach(function (pg) {
          var rev = pg.revisions && pg.revisions[0];
          var txt = rev && rev.slots && rev.slots.main && rev.slots.main.content;
          if (txt) pages.push({ titre: pg.title, wikitext: txt });
        });
        if (d.continue) return tour(d.continue);
        return pages;
      });
    }
    return tour(null);
  }

  function toutesLesImages(base) {
    var map = {};
    function tour(cont) {
      var p = { action: "query", list: "allimages", ailimit: "max" };
      if (cont) for (var k in cont) p[k] = cont[k];
      return api(p, base).then(function (d) {
        ((d.query && d.query.allimages) || []).forEach(function (im) {
          map[im.name.replace(/_/g, " ")] = im.url;
        });
        if (d.continue) return tour(d.continue);
        return map;
      });
    }
    return tour(null);
  }

  // ------------------------------------------------ assemblage de l'index

  function assembler(pagesBrutes, imgMap, base) {
    var pages = [], sections = [], vus = {};

    // Le wiki contient des redirections et des pages-coquilles (titres mal
    // orthographiés, pages vides) qui, sans ça, passent devant les vraies.
    pagesBrutes = pagesBrutes.filter(function (p) {
      if (/^\s*#\s*REDIRECT/i.test(p.wikitext)) return false;
      return nettoyer(p.wikitext).replace(/\s/g, "").length >= 80;
    });
    // à titre égal, on garde la page la plus fournie
    pagesBrutes.sort(function (a, b) { return b.wikitext.length - a.wikitext.length; });

    pagesBrutes.forEach(function (p) {
      if (vus[p.titre]) return;            // redirections vers le même titre
      var secs = decouper(p.wikitext);
      if (!secs.length) return;
      vus[p.titre] = 1;

      var pid = pages.length;
      var imgsPage = [];
      secs.forEach(function (sec, k) {
        sec.images.forEach(function (n) {
          if (imgMap[n]) {
            imgsPage.push({
              nom: n, url: imgMap[n],
              section: sec.titre || p.titre,
              entete: k === 0 && !sec.titre
            });
          }
        });
      });

      pages.push({
        id: pid, titre: p.titre,
        url: base + "/index.php/" + encodeURI(p.titre.replace(/ /g, "_")),
        images: imgsPage
      });

      secs.forEach(function (s) {
        var plat = s.prose.concat(s.puces).join(" ");
        var images = s.images.filter(function (n) { return imgMap[n]; })
          .map(function (n) { return { nom: n, url: imgMap[n] }; });
        if (plat.length < 15 && !images.length) return;
        sections.push({
          page: pid, titre: s.titre || p.titre,
          puces: s.puces, prose: s.prose, images: images,
          // le titre compte double : une section « Couverture » doit se
          // trouver même si son contenu n'est qu'un tableau en image
          mots: mots((s.titre || "") + " " + (s.titre || "") + " " + plat)
        });
      });
    });

    var df = {};
    sections.forEach(function (s) {
      new Set(s.mots).forEach(function (w) { df[w] = (df[w] || 0) + 1; });
    });
    sections.forEach(function (s) {
      var tf = {};
      s.mots.forEach(function (w) { tf[w] = (tf[w] || 0) + 1; });
      s.tf = tf; s.len = s.mots.length; delete s.mots;
    });

    return {
      pages: pages, sections: sections, df: df, N: sections.length,
      wiki: base, mots_vides: MOTS_VIDES,
      glossaire: root.MICROGUIDE_GLOSSAIRE || {},
      intentions: root.MICROGUIDE_INTENTIONS || {},
      bati: Date.now()
    };
  }

  // ------------------------------------------------ cache local 24 h

  var CLE = "microguide-index-v1";

  function duCache() {
    try {
      var b = localStorage.getItem(CLE);
      if (!b) return null;
      var d = JSON.parse(b);
      if (!d.bati || Date.now() - d.bati > 864e5) return null;
      d.glossaire = root.MICROGUIDE_GLOSSAIRE || {};
      d.intentions = root.MICROGUIDE_INTENTIONS || {};
      return d;
    } catch (e) { return null; }
  }

  function auCache(d) {
    try { localStorage.setItem(CLE, JSON.stringify(d)); } catch (e) { /* quota : tant pis */ }
  }

  // Construit (ou relit) l'index. Renvoie une promesse.
  function charger(base) {
    base = base || "";
    var c = duCache();
    if (c) return Promise.resolve(c);
    return Promise.all([toutesLesPages(base), toutesLesImages(base)])
      .then(function (r) {
        var ix = assembler(r[0], r[1], base);
        auCache(ix);
        return ix;
      });
  }

  root.MicroguideIndexeur = {
    charger: charger, assembler: assembler, decouper: decouper,
    nettoyer: nettoyer, mots: mots, viderCache: function () {
      try { localStorage.removeItem(CLE); } catch (e) {}
    }
  };
})(typeof module !== "undefined" ? module.exports : window);

/* ---------- 3/4 moteur de réponse ---------- */
// Moteur de réponse de l'assistant microguide — AUCUNE INVENTION POSSIBLE.
//
// La réponse est composée uniquement de phrases et de puces recopiées
// telles quelles depuis les pages du wiki, avec le titre de la section et
// le lien. Il n'y a pas de modèle de langue qui réécrit : le texte affiché
// existe mot pour mot sur microguide.ca.
//
// Principe repris du bibliothécaire de Thomas : les passages sources SONT
// la réponse.

(function (root) {
  "use strict";

  function normalise(s) {
    return (s || "")
      .toLowerCase()
      .normalize("NFD")
      .replace(/[̀-ͯ]/g, "");
  }

  function Moteur(index) {
    this.ix = index;
    this.vides = new Set(index.mots_vides || []);
    this.gloss = index.glossaire || {};
    // longueur moyenne des sections, pour BM25
    var t = 0;
    for (var i = 0; i < index.sections.length; i++) t += index.sections[i].len;
    this.avgdl = t / Math.max(1, index.sections.length);
  }

  Moteur.prototype.mots = function (s) {
    var self = this;
    return (normalise(s).match(/[a-z0-9]+/g) || []).filter(function (w) {
      return w.length > 1 && !self.vides.has(w);
    });
  };

  // Ajoute le vocabulaire québécois / les abréviations cliniques
  Moteur.prototype.etendre = function (q) {
    var n = normalise(q);
    var base = this.mots(q);
    var ajouts = [];
    for (var cle in this.gloss) {
      var re = new RegExp("\\b" + cle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\b");
      if (re.test(n)) {
        var vals = this.gloss[cle];
        for (var i = 0; i < vals.length; i++) {
          ajouts = ajouts.concat(this.mots(vals[i]));
        }
      }
    }
    // les mots de la question pèsent plus que les ajouts du glossaire
    var poids = {};
    base.forEach(function (w) { poids[w] = Math.max(poids[w] || 0, 1); });
    ajouts.forEach(function (w) { poids[w] = Math.max(poids[w] || 0, 0.6); });
    return poids;
  };

  // Quelle sorte de renseignement la question demande-t-elle ?
  // « quoi donner pour un pseudomonas » -> traitement, donc on privilégie
  // les sections « Traitements » plutôt qu'une autre qui parle aussi d'ATB.
  Moteur.prototype.intentions = function (question) {
    var n = normalise(question), out = [];
    var defs = this.ix.intentions || {};
    for (var nom in defs) {
      var d = defs[nom], touche = false;
      for (var i = 0; i < d.declencheurs.length; i++) {
        var t = d.declencheurs[i];
        var re = new RegExp("\\b" + t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
        if (re.test(n)) { touche = true; break; }
      }
      if (touche) out.push({ nom: nom, titres: d.titres });
    }
    return out;
  };

  // BM25 sur les sections
  Moteur.prototype.chercher = function (question, nb) {
    var ix = this.ix, self = this;
    var poids = this.etendre(question);
    var inten = this.intentions(question);
    var k1 = 1.4, b = 0.72, N = ix.N;
    var res = [];

    // Quelle page la question NOMME-t-elle ? « le SARM ça se traite comment »
    // doit répondre avec la page du SARM, pas avec une autre page qui parle
    // aussi de traitement. On mesure la correspondance avec le titre de page,
    // pondérée par la rareté des mots (un mot rare identifie mieux).
    var entite = [];
    for (var pi = 0; pi < ix.pages.length; pi++) {
      var mt = self.mots(ix.pages[pi].titre), e = 0;
      var vusMot = {};
      for (var mi = 0; mi < mt.length; mi++) {
        var mw = mt[mi];
        if (vusMot[mw]) continue;
        vusMot[mw] = 1;
        if (poids[mw] >= 0.6) {
          var dfp = ix.df[mw] || 1;
          e += Math.log(1 + (N - dfp + 0.5) / (dfp + 0.5));
        }
      }
      entite[pi] = e;
    }
    var entiteMax = Math.max.apply(null, entite.concat([0]));

    for (var i = 0; i < ix.sections.length; i++) {
      var s = ix.sections[i], score = 0, touches = 0;
      for (var w in poids) {
        var f = s.tf[w];
        if (!f) continue;
        var df = ix.df[w] || 1;
        var idf = Math.log(1 + (N - df + 0.5) / (df + 0.5));
        score += poids[w] * idf * (f * (k1 + 1)) / (f + k1 * (1 - b + b * s.len / self.avgdl));
        touches++;
      }
      if (!score) continue;
      // bonus quand le titre de la page ou de la section correspond
      var tp = normalise(ix.pages[s.page].titre);
      var ts = normalise(s.titre);
      for (var w2 in poids) {
        if (poids[w2] < 1) continue;
        if (tp.indexOf(w2) >= 0) score += 2.2 * poids[w2];
        if (ts.indexOf(w2) >= 0) score += 1.5 * poids[w2];
      }
      // la section répond-elle à la sorte de question posée ?
      var bonusIntention = 0;
      for (var a = 0; a < inten.length; a++) {
        var titres = inten[a].titres;
        for (var c = 0; c < titres.length; c++) {
          if (ts.indexOf(titres[c]) >= 0) {
            bonusIntention = Math.max(bonusIntention, 1);
            break;
          }
        }
      }
      if (inten.length) {
        // multiplicatif : une section hors sujet garde son score, la bonne
        // section passe devant sans écraser la pertinence des mots.
        score *= bonusIntention ? 1.9 : 0.85;
      }
      // la page nommée par la question passe devant les autres
      if (entiteMax > 0 && entite[s.page] > 0) {
        score *= 1 + 1.6 * (entite[s.page] / entiteMax);
      }
      res.push({ i: i, score: score, touches: touches });
    }
    res.sort(function (a, b) { return b.score - a.score; });
    return res.slice(0, nb || 8);
  };

  // Compose la réponse : toujours un texte, toujours du verbatim + source.
  Moteur.prototype.repondre = function (question) {
    var ix = this.ix;
    var hits = this.chercher(question, 10);
    var inten = this.intentions(question);

    if (!hits.length) {
      return {
        etat: "rien",
        intro: "Je n'ai rien trouvé sur microguide.ca pour cette question. " +
               "Le wiki ne semble pas couvrir ce sujet — il n'y a donc rien que je puisse te citer.",
        blocs: [],
        suggestions: ix.pages.slice(0, 0)
      };
    }

    // on regroupe par page, en gardant l'ordre de pertinence
    var parPage = {}, ordre = [];
    hits.forEach(function (h) {
      var s = ix.sections[h.i];
      if (!parPage[s.page]) { parPage[s.page] = []; ordre.push(s.page); }
      parPage[s.page].push({ s: s, score: h.score });
    });

    var meilleur = hits[0].score;
    var blocs = [];
    // page principale : jusqu'à 3 sections ; puis 2 pages d'appoint, 1 section
    ordre.slice(0, 3).forEach(function (pid, rang) {
      var page = ix.pages[pid];
      var secs = parPage[pid]
        .filter(function (x) { return x.score >= meilleur * (rang === 0 ? 0.28 : 0.45); })
        .slice(0, rang === 0 ? 3 : 1);
      if (!secs.length) return;
      // Images à afficher : celles dont la section correspond à la sorte de
      // question posée (un tableau de couverture pour une question de
      // traitement), plus la fiche d'en-tête de la page quand c'est la page
      // principale. Les images résument souvent mieux que le texte.
      var titresRetenus = {};
      secs.forEach(function (x) { titresRetenus[normalise(x.s.titre)] = 1; });
      var images = (page.images || []).filter(function (im) {
        var ns = normalise(im.section);
        if (titresRetenus[ns]) return true;
        for (var a = 0; a < inten.length; a++) {
          var titres = inten[a].titres;
          for (var c = 0; c < titres.length; c++) {
            if (ns.indexOf(titres[c]) >= 0) return true;
          }
        }
        return rang === 0 && im.entete;
      }).slice(0, 3);

      blocs.push({
        page: page.titre,
        url: page.url,
        images: images,
        sections: secs.map(function (x) {
          return { titre: x.s.titre, prose: x.s.prose, puces: x.s.puces };
        })
      });
    });

    var faible = meilleur < 6;
    var p0 = blocs.length ? blocs[0].page : null;
    var intro;
    if (faible) {
      intro = "Je ne trouve pas de passage qui répond clairement à cette question sur microguide.ca. " +
              "Voici ce qui s'en rapproche le plus, recopié tel quel — à vérifier, ça ne répond " +
              "peut-être pas à ce que tu cherches.";
    } else {
      intro = "D'après " + (blocs.length > 1 ? "les pages du wiki" : "la page « " + p0 + " »") +
              ", voici ce qui est écrit sur microguide.ca, recopié mot pour mot :";
    }

    return { etat: faible ? "faible" : "ok", intro: intro, blocs: blocs };
  };

  // Rendu texte (pour les essais en ligne de commande)
  Moteur.prototype.texte = function (question) {
    var r = this.repondre(question);
    var out = ["Q: " + question, "", r.intro, ""];
    r.blocs.forEach(function (b) {
      out.push("── " + b.page + "  <" + b.url + ">");
      (b.images || []).forEach(function (im) {
        out.push("   [IMAGE] " + im.nom + "  (§ " + im.section + ")");
      });
      b.sections.forEach(function (s) {
        out.push("   § " + s.titre);
        s.prose.forEach(function (p) { out.push("     " + p); });
        s.puces.forEach(function (p) { out.push("     • " + p); });
      });
      out.push("");
    });
    return out.join("\n");
  };

  root.MicroguideMoteur = Moteur;
})(typeof module !== "undefined" ? module.exports : window);

/* ---------- 4/4 bulle ---------- */
// Bulle de l'assistant microguide : lanceur en bas à droite, visible sur
// toutes les pages du wiki. Tout tourne dans le navigateur du visiteur, à
// partir de l'API du wiki : aucun serveur, aucune clé, aucun service externe.

(function () {
  "use strict";

  if (window.__microguideAssistant) return;   // déjà chargé
  window.__microguideAssistant = true;

  var BASE = (window.MICROGUIDE_BASE !== undefined)
    ? window.MICROGUIDE_BASE
    : (location.origin || "");

  var CSS = [
    '.mga-launch{position:fixed;z-index:9000;right:calc(18px + env(safe-area-inset-right,0px));',
    'bottom:calc(18px + env(safe-area-inset-bottom,0px));display:flex;align-items:center;gap:9px;',
    'background:#3f7490;color:#fff;border:none;font:600 14.5px/1 inherit;padding:13px 19px;',
    'border-radius:30px;cursor:pointer;box-shadow:0 6px 22px -6px rgba(20,40,55,.5)}',
    '.mga-launch:hover{filter:brightness(1.08)}',
    '.mga-launch svg{width:18px;height:18px;flex:0 0 auto}',
    '.mga-panel{position:fixed;z-index:9001;right:calc(18px + env(safe-area-inset-right,0px));',
    'bottom:calc(18px + env(safe-area-inset-bottom,0px));',
    'width:min(440px,calc(100vw - 32px));max-height:min(680px,calc(100vh - 36px));',
    'background:#fff;color:#1c2228;border:1px solid #dcd8d0;border-radius:12px;',
    'box-shadow:0 18px 50px -14px rgba(15,30,42,.45);display:flex;flex-direction:column;',
    'overflow:hidden;font:400 15px/1.6 "IBM Plex Sans",system-ui,sans-serif}',
    '.mga-head{display:flex;align-items:center;gap:10px;padding:13px 15px;border-bottom:1px solid #dcd8d0}',
    '.mga-head .t{font-weight:600;font-size:14.5px;flex:1;min-width:0}',
    '.mga-head .t em{display:block;font-style:normal;font-size:11px;color:#8995a0;font-weight:400}',
    '.mga-ico{background:none;border:none;color:#8995a0;cursor:pointer;font-size:19px;line-height:1;padding:4px 6px;border-radius:5px}',
    '.mga-ico:hover{color:#1c2228;background:#f4f1ea}',
    '.mga-log{flex:1;overflow-y:auto;padding:15px;display:flex;flex-direction:column;gap:17px;min-height:0}',
    '.mga-intro{font-size:13.5px;color:#55616b}',
    '.mga-intro p{margin:0 0 9px}',
    '.mga-rule{border-left:3px solid #3f7490;background:#e4eff6;padding:9px 12px;color:#1c2228}',
    '.mga-samples{display:flex;flex-direction:column;gap:6px;margin-top:11px}',
    '.mga-samples button{text-align:left;background:none;border:1px solid #dcd8d0;color:#1c2228;',
    'font:400 13px/1.5 inherit;padding:8px 11px;border-radius:6px;cursor:pointer}',
    '.mga-samples button:hover{border-color:#3f7490;background:#e4eff6}',
    '.mga-q{align-self:flex-end;max-width:88%;background:#3f7490;color:#fff;padding:9px 13px;',
    'border-radius:12px 12px 3px 12px;font-size:14px}',
    '.mga-a{font-size:14px}',
    '.mga-lead{color:#55616b;margin:0 0 12px}',
    '.mga-lead.weak{background:#faf0dd;border-left:3px solid #8a5a1c;padding:9px 12px;color:#1c2228}',
    '.mga-src{border-top:1px solid #dcd8d0;padding-top:11px;margin-top:13px}',
    '.mga-src:first-of-type{border-top:none;padding-top:0;margin-top:0}',
    '.mga-srch{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap;margin-bottom:8px}',
    '.mga-srch .lbl{font-family:"IBM Plex Mono",monospace;font-size:10px;letter-spacing:.1em;',
    'text-transform:uppercase;color:#8995a0}',
    '.mga-srch a{color:#3f7490;font-weight:600;font-size:14px;text-decoration:none;border-bottom:1px solid transparent}',
    '.mga-srch a:hover{border-bottom-color:#3f7490}',
    '.mga-sec{margin:0 0 11px}',
    '.mga-sect{font-family:"IBM Plex Mono",monospace;font-size:10.5px;letter-spacing:.07em;',
    'text-transform:uppercase;color:#3f7490;margin-bottom:5px}',
    '.mga-quote{font-family:"IBM Plex Serif",Georgia,serif;font-size:14px;background:#f4f1ea;',
    'border-left:2px solid #dcd8d0;padding:9px 12px;color:#1c2228}',
    '.mga-quote p{margin:0 0 6px}.mga-quote p:last-child{margin-bottom:0}',
    '.mga-quote ul{margin:5px 0 0;padding-left:19px}.mga-quote li{margin:2px 0}',
    '.mga-fig{margin:0 0 11px}',
    '.mga-fig img{display:block;width:100%;height:auto;border:1px solid #dcd8d0;border-radius:4px;background:#fff}',
    '.mga-fig figcaption{font-family:"IBM Plex Mono",monospace;font-size:10.5px;color:#8995a0;margin-top:4px}',
    '.mga-none{background:#faf0dd;border-left:3px solid #8a5a1c;padding:11px 13px;font-size:13.5px}',
    '.mga-ask{border-top:1px solid #dcd8d0;padding:11px;display:flex;gap:8px}',
    '.mga-ask input{flex:1;min-width:0;background:#fbfaf7;color:#1c2228;border:1px solid #dcd8d0;',
    'border-radius:7px;padding:10px 12px;font:400 14px/1.4 inherit}',
    '.mga-ask input:focus{outline:2px solid #3f7490;outline-offset:-1px;border-color:#3f7490}',
    '.mga-ask button{background:#3f7490;color:#fff;border:none;border-radius:7px;padding:0 15px;',
    'font:600 14px/1 inherit;cursor:pointer}',
    '.mga-foot{padding:0 13px 11px;font-family:"IBM Plex Mono",monospace;font-size:11px;color:#8995a0}',
    '@media (max-width:460px){.mga-panel{right:8px;left:8px;width:auto}}'
  ].join("");

  var SAMPLES = [
    "quels antibiotiques pour traiter un pseudomonas ?",
    "la couverture de la ceftriaxone",
    "comment on traite un c diff",
    "le SARM ça se traite comment",
    "c'est quoi la posologie de la gentamicine"
  ];

  function el(tag, cls, txt) {
    var n = document.createElement(tag);
    if (cls) n.className = cls;
    if (txt != null) n.textContent = txt;
    return n;
  }

  var moteur = null, chargement = null, log, intro, champ, panel, launch;

  function construire() {
    var style = el("style");
    style.textContent = CSS;
    document.head.appendChild(style);

    launch = el("button", "mga-launch");
    launch.setAttribute("aria-label", "Ouvrir l'assistant");
    launch.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
      'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5a8.4 8.4 0 0 1-.9 ' +
      '3.8A8.5 8.5 0 0 1 12.5 20a8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7A8.4 8.4 0 0 1 4 11.5a8.5 8.5 0 0 1 ' +
      '4.7-7.6A8.4 8.4 0 0 1 12.5 3h.5a8.5 8.5 0 0 1 8 8z"/></svg>';
    launch.appendChild(document.createTextNode("Demander au guide"));
    document.body.appendChild(launch);

    panel = el("section", "mga-panel");
    panel.hidden = true;
    panel.setAttribute("aria-label", "Assistant Microguide");

    var head = el("div", "mga-head");
    var t = el("div", "t", "Assistant Microguide");
    t.appendChild(el("em", null, "répond uniquement avec le contenu du wiki"));
    head.appendChild(t);
    var bReset = el("button", "mga-ico", "⟲");
    bReset.title = "Effacer";
    var bClose = el("button", "mga-ico", "✕");
    bClose.title = "Fermer";
    head.appendChild(bReset);
    head.appendChild(bClose);
    panel.appendChild(head);

    log = el("div", "mga-log");
    intro = el("div", "mga-intro");
    intro.appendChild(el("p", null, "Pose ta question en français, comme tu la dirais à l'oral."));
    intro.appendChild(el("div", "mga-rule",
      "Je ne rédige rien de moi-même. Je recopie les passages du wiki mot pour mot, avec le " +
      "lien de la page et le tableau quand il y en a un. Si le wiki ne le dit pas, je ne l'invente pas."));
    var samples = el("div", "mga-samples");
    SAMPLES.forEach(function (q) {
      var b = el("button", null, q);
      b.addEventListener("click", function () { demander(q); });
      samples.appendChild(b);
    });
    intro.appendChild(samples);
    log.appendChild(intro);
    panel.appendChild(log);

    var form = el("form", "mga-ask");
    champ = el("input");
    champ.type = "text";
    champ.placeholder = "Ta question…";
    champ.autocomplete = "off";
    champ.setAttribute("aria-label", "Ta question");
    var env = el("button", null, "Chercher");
    env.type = "submit";
    form.appendChild(champ);
    form.appendChild(env);
    form.addEventListener("submit", function (e) {
      e.preventDefault();
      demander(champ.value);
    });
    panel.appendChild(form);

    var foot = el("div", "mga-foot", "index rebâti depuis le wiki");
    panel.appendChild(foot);
    document.body.appendChild(panel);

    launch.addEventListener("click", ouvrir);
    bClose.addEventListener("click", fermer);
    bReset.addEventListener("click", function () {
      Array.prototype.slice.call(log.children).forEach(function (n) {
        if (n !== intro) n.remove();
      });
      intro.hidden = false;
      champ.value = "";
      champ.focus();
    });
    document.addEventListener("keydown", function (e) {
      if (e.key === "Escape" && !panel.hidden) fermer();
    });
    panel.__foot = foot;
  }

  function ouvrir() {
    panel.hidden = false;
    launch.hidden = true;
    precharger();
    setTimeout(function () { champ.focus(); }, 30);
  }
  function fermer() {
    panel.hidden = true;
    launch.hidden = false;
  }

  // L'index se bâtit au premier usage, pas au chargement de la page : la
  // navigation normale du wiki n'est donc jamais ralentie.
  function precharger() {
    if (moteur || chargement) return chargement;
    chargement = window.MicroguideIndexeur.charger(BASE).then(function (ix) {
      ix.glossaire = window.MICROGUIDE_GLOSSAIRE || {};
      ix.intentions = window.MICROGUIDE_INTENTIONS || {};
      moteur = new window.MicroguideMoteur(ix);
      if (panel.__foot) {
        panel.__foot.textContent = ix.pages.length + " pages · " + ix.sections.length + " sections";
      }
      return moteur;
    });
    return chargement;
  }

  function rendreSection(s) {
    var wrap = el("div", "mga-sec");
    wrap.appendChild(el("div", "mga-sect", s.titre));
    var box = el("div", "mga-quote");
    s.prose.forEach(function (p) { box.appendChild(el("p", null, p)); });
    if (s.puces.length) {
      var ul = el("ul");
      s.puces.forEach(function (p) { ul.appendChild(el("li", null, p)); });
      box.appendChild(ul);
    }
    wrap.appendChild(box);
    return wrap;
  }

  function rendreReponse(r) {
    var box = el("div", "mga-a");
    box.appendChild(el("p", "mga-lead" + (r.etat === "faible" ? " weak" : ""), r.intro));
    if (!r.blocs.length) {
      box.appendChild(el("div", "mga-none",
        "Aucune page du wiki ne traite de ça. Je préfère te le dire plutôt que de te donner " +
        "une réponse que je n'ai pas lue quelque part."));
      return box;
    }
    r.blocs.forEach(function (b) {
      var src = el("div", "mga-src");
      var h = el("div", "mga-srch");
      h.appendChild(el("span", "lbl", "Page"));
      var a = el("a", null, b.page);
      a.href = b.url;
      h.appendChild(a);
      src.appendChild(h);
      (b.images || []).forEach(function (im) {
        var fig = el("figure", "mga-fig");
        var img = document.createElement("img");
        img.src = im.url;
        img.alt = im.section + " — " + b.page;
        img.loading = "lazy";
        fig.appendChild(img);
        fig.appendChild(el("figcaption", null, "Tableau du wiki — " + im.section));
        src.appendChild(fig);
      });
      b.sections.forEach(function (s) { src.appendChild(rendreSection(s)); });
      box.appendChild(src);
    });
    return box;
  }

  function demander(q) {
    q = (q || "").trim();
    if (!q) return;
    intro.hidden = true;
    log.appendChild(el("div", "mga-q", q));
    var attente = el("p", "mga-lead", "Je cherche dans le wiki…");
    log.appendChild(attente);
    log.scrollTop = log.scrollHeight;
    champ.value = "";

    precharger().then(function (m) {
      attente.remove();
      log.appendChild(rendreReponse(m.repondre(q)));
      log.scrollTop = log.scrollHeight;
    })["catch"](function (e) {
      attente.remove();
      log.appendChild(el("div", "mga-none",
        "Je n'ai pas pu lire le wiki (" + (e && e.message ? e.message : "erreur") +
        "). Réessaie dans un moment."));
    });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", construire);
  } else {
    construire();
  }
})();