Module 04 — RAG, Outils MCP & Fonctionnalités avancées

Étape 4 de la mission « QA Engineer d’une flotte GenAI multi-tenant ». « Enrichis l’IA avec tes documents, tes outils, tes canaux. »

Ce notebook est la couche pédagogique du module 04. Il teste les fonctionnalités avancées d’Open WebUI : le RAG (Knowledge Bases attachées au chat), les outils MCP (recherche web, exécution de code), et les Channels (canaux collaboratifs). Il raconte le module, lit le banc de tests réel (04-rag-tools.spec.ts), en orchestre l’inventaire et propose des exercices exécutables. Le fichier .spec.ts reste le moteur de test (backend) : on ne le remplace pas, on l’enrobe.

Portabilité. Toutes les cellules s’exécutent sans navigateur ni instance Open WebUI : elles lisent le code des tests et raisonnent sur les concepts (tests conditionnels, évolution UI, sélecteurs positionnels). L’exécution live (KBs, outils MCP, canaux réels) est documentée au §4 et différée au capstone.

1. Objectifs & place dans la mission

La mission compte cinq étapes. Tu es à la quatrième : le chat de base est certifié (module 03), on attaque les fonctionnalités avancées qui transforment un simple chat en plateforme RAG/outils/canaux. La difficulté nouvelle : ces fonctionnalités dépendent de la configuration de l’instance — un test doit pouvoir s’adapter à leur absence.

À la fin de ce module, tu sais :

  • écrire un test conditionnel (test.skip() runtime) quand une feature (KB, MCP, canal) n’est pas configurée ;
  • adapter tes tests à l’évolution de l’UI (le workflow change entre les versions : v0.8.8 a remplacé le raccourci # par un menu +) ;
  • choisir un sélecteur robuste (getByRole) plutôt qu’un sélecteur positionnel (nth(1), dernier recours) ;
  • combiner API et navigateur — l’API donne les IDs (canaux), le navigateur teste l’expérience.
Étape Module Ce que tu certifies
1 01 — Découverte Mon outillage fonctionne, je sais lire un test.
2 02 — Navigation & Auth L’accès et l’admin.
3 03 — Chat & Streaming Le cœur métier (chat IA).
4 04 — RAG, outils & canaux (ici) Les fonctions avancées.
5 05 — Multi-tenant & CI/CD L’isolation + l’industrialisation.
# --- Setup : localiser la serie et le module, SANS exposer de chemin absolu ---
from pathlib import Path
import re, shutil, subprocess

def _redact(texte: str) -> str:
    # Anti-fuite : ne jamais afficher de chemin absolu (machine de l'auteur, home...).
    out = texte
    for absolu in {str(Path.cwd().resolve()), str(Path.home())}:
        out = out.replace(absolu, ".")
        out = out.replace(absolu.replace("/", "\\"), ".")
    return out

def trouver_racine_serie(depart=None) -> Path:
    # La racine de la serie = le dossier qui contient playwright.config.ts.
    p = Path(depart or Path.cwd()).resolve()
    for cand in [p, *p.parents]:
        if (cand / "playwright.config.ts").exists():
            return cand
    for sub in Path.cwd().resolve().rglob("playwright.config.ts"):
        return sub.parent
    raise FileNotFoundError("playwright.config.ts introuvable depuis le dossier courant")

SERIE = trouver_racine_serie()
MODULE = SERIE / "04-rag-tools-avances"
SPEC = MODULE / "04-rag-tools.spec.ts"

print("Serie       :", SERIE.name)
print("Module      :", MODULE.name)
print("Banc de test:", SPEC.name, "(present)" if SPEC.exists() else "(ABSENT)")
Serie       : Playwright-OWUI
Module      : 04-rag-tools-avances
Banc de test: 04-rag-tools.spec.ts (present)

2. Tests conditionnels & évolution de l’UI — s’adapter à l’instance et aux versions

Deux défis distinguent le module 04 :

(a) Les fonctionnalités avancées sont optionnelles. Une instance minimale peut n’avoir aucune Knowledge Base, aucun outil MCP, aucun canal. Un test qui échouerait bruyamment dans ce cas serait inutile. La solution : le test conditionnel — on vérifie la visibilité de l’option, puis on délègue à test.skip() :

const attachKB = page.getByRole('button', { name: /connaissance|knowledge/i });
const isVisible = await attachKB.isVisible({ timeout: 5_000 }).catch(() => false);
test.skip(!isVisible, 'Option KB non disponible');  // skip runtime, pas un échec

Le test démarre, évalue la condition pendant l’exécution, et se marque skipped (avec une raison) si la feature est absente. Un skip n’est pas une régression — c’est le contrat du test sur une instance minimale.

(b) L’UI évolue entre les versions. Avant v0.8.8, le raccourci # dans le chat input ouvrait un popup de KB ; depuis v0.8.8, les KBs s’attachent via le bouton + → « Joindre une connaissance ». Un test écrit pour l’ancien workflow casse sur la nouvelle version. Réflexe : vérifier visuellement et mettre à jour les sélecteurs/workflows quand l’UI drift — c’est la maintenance permanente d’une suite E2E.

# --- Lire le banc de tests et en extraire la structure (sans le lancer) ---
texte = SPEC.read_text(encoding="utf-8")

# Titres des tests : test('....', ...) — guillemets simples
titres = re.findall(r"test\('([^']+)'", texte)
# Le module 04 contient PLUSIEURS blocs describe (un par partie) — on les liste tous
describes = re.findall(r"describe\('([^']+)'", texte)

print(f"{len(describes)} partie(s) (describe) dans {SPEC.name} :")
for i, d in enumerate(describes, 1):
    print(f"  {i}. {d}")
print()
print(f"{len(titres)} test(s) defini(s) au total :")
for i, t in enumerate(titres, 1):
    print(f"  {i}. {t}")

# Compter les exercices embarques (commentaires // EXERCICE)
nb_ex = len(re.findall(r"//\s*EXERCICE", texte))
print()
print(f"{nb_ex} exercice(s) embarque(s) dans les commentaires du spec.")
3 partie(s) (describe) dans 04-rag-tools.spec.ts :
  1. 04a — Knowledge Bases (RAG)
  2. 04b — Outils MCP (Model Context Protocol)
  3. 04c — Channels (canaux de discussion)

7 test(s) defini(s) au total :
  1. attacher une knowledge base via le menu +
  2. chat avec KB attachee — reponse enrichie par les documents
  3. menu outils MCP accessible (si configure)
  4. ouvrir le selecteur d outils MCP
  5. recherche web via outils (si disponible)
  6. acceder aux canaux de discussion
  7. poster un message dans un canal

1 exercice(s) embarque(s) dans les commentaires du spec.

3. Sélecteurs robustes vs positionnels — et l’astuce API + navigateur

Quand une fonctionnalité n’a pas d’aria-label stable, le testeur est tenté par le sélecteur positionnel (nth(1) = 2ᵉ bouton). C’est une solution de dernier recours : elle casse au moindre réordonnancement du DOM. Le spec l’utilise pour le menu outils (le 2ᵉ bouton icone, sans aria-label fiable) :

// DERNIER RECOURS — le 2eme bouton du chat input (pas d'aria-label stable)
const toolsButton = page.locator('#message-input-container').locator('button').nth(1);

La règle : privilégier l’accès sémantique (getByRole, getByText, aria-label) qui survit au drift d’interface ; ne tomber sur nth(N) que s’il n’existe vraiment aucun sélecteur stable.

L’astuce API + navigateur (canals). En mode headless, la sidebar est souvent repliée (icônes seules) — les liens de canaux deviennent invisibles. Pour contourner, on combine : l’API récupère les IDs des canaux, puis le navigateur y accède par URL directe /channels/{id}. L’API donne les données, le navigateur teste l’expérience — robuste même en headless.

const token = await apiLogin(request, baseUrl, email, password);     // API : donne les IDs
channels = await getChannels(request, baseUrl, token);
await page.goto(`/channels/${channels[0].id}`);                      // Navigateur : teste l'UI
# --- Inventaire REEL via Playwright : `npx playwright test --grep '04' --list` ---
# Liste les tests SANS les executer (--list) et SANS navigateur.
# Ne tourne que si npx + node_modules sont presents ; sinon repli propre.
def lister_tests_module(serie: Path, grep: str = "04"):
    if shutil.which("npx") is None:
        return None, "npx introuvable — `npm install` requis (repli : inventaire statique ci-dessus)."
    if not (serie / "node_modules").exists():
        return None, "node_modules absent — `npm install` requis (repli : inventaire statique ci-dessus)."
    try:
        r = subprocess.run(
            f"npx playwright test --grep {grep} --list",
            cwd=str(serie), shell=True, capture_output=True, text=True, timeout=180,
        )
        sortie = (r.stdout or "") + (r.stderr or "")
        return r.returncode, _redact(sortie.strip())
    except Exception as e:
        return None, f"{type(e).__name__}: {e}"

code_retour, sortie = lister_tests_module(SERIE, "04")
print("returncode:", code_retour)
print(sortie if sortie else "(aucune sortie)")
returncode: None
node_modules absent — `npm install` requis (repli : inventaire statique ci-dessus).

4. Lancer le module (pour de vrai)

L’inventaire ci-dessus se fait hors-ligne. Pour exécuter les tests, il faut un .env configuré — jamais commité. Les fonctionnalités avancées nécessitent en plus une instance pré-configurée (KBs créées, outils MCP installés, canaux existants) ; sinon les tests se skipperont proprement.

OWUI_URL=...           # instance Open WebUI pre-configurée (KBs, outils MCP, canaux)
OWUI_EMAIL=...
OWUI_PASSWORD=...
OWUI_CLOUD_MODEL=gpt-5.1

Puis :

npm install
npx playwright install chromium
npm run test:module4                         # ce module uniquement
npx playwright test --grep "04" --headed     # navigateur VISIBLE (debug)
PWDEBUG=1 npx playwright test --grep "04" --headed   # Playwright Inspector
npm run report                               # rapport HTML

Sur une instance minimale, la plupart des tests seront skippés (features absentes) — c’est sain. L’exécution complète suppose une instance riche : c’est le scénario du capstone. Ce notebook reste reproductible partout, sans instance ni secret.

# --- Demonstration executable : classer un selecteur (robuste vs positionnel) ---
# Pas de navigateur : on raisonne sur la *forme* du selecteur (concept du para. 3).
# Un selecteur sémantique (getByRole/aria-label) survit au drift d'interface ;
# un selecteur positionnel (nth(N)) casse au moindre reorder — dernier recours.
def robustesse_selecteur(selecteur: str) -> str:
    s = selecteur.lower()
    if "getbyrole" in s or "aria-label" in s or "getbytext" in s:
        return "robuste"   # accès sémantique (survit au drift)
    if "nth(" in s or ".first()" in s or ".last()" in s:
        return "fragile"   # positionnel (dernier recours, casse au reorder)
    return "neutre"        # id / class CSS (stable si l'id est sémantique)

exemples = [
    "page.getByRole('button', { name: /connaissance|knowledge/i })",  # robuste
    "page.locator('#message-input-container').locator('button').nth(1)",  # fragile (positionnel)
    "page.locator('#chat-input')",                                    # neutre (id CSS)
    "page.getByText(/Outils|Tools/i)",                                # robuste
]
for sel in exemples:
    print(f"  {robustesse_selecteur(sel):8} : {sel}")
  robuste  : page.getByRole('button', { name: /connaissance|knowledge/i })
  fragile  : page.locator('#message-input-container').locator('button').nth(1)
  neutre   : page.locator('#chat-input')
  robuste  : page.getByText(/Outils|Tools/i)

5. Interpréter les résultats — beaucoup de skips = instance minimale (saine)

Le module 04 est celui où le rapport peut afficher majoritairement des skipped sans qu’il y ait le moindre problème. Sur une instance sans KB, sans outil MCP, sans canal, 6 tests sur 7 se skipperont — avec chacun une raison explicite (« Aucune KB configurée », « Outils MCP non configurés », « Aucun canal disponible »).

  • skipped (majoritaire ici) = features absentes de l’instance, pas des régressions. Le test a correctement évalué la condition et décidé de ne pas s’exécuter. Lire les raisons de skip pour distinguer « feature absente » (sain) de « sélecteur cassé » (drift d’UI).
  • failed = presque toujours un sélecteur cassé (l’UI a drifté depuis l’écriture du test) ou une feature présente mais défaillante. À investiguer : d’abord vérifier le sélecteur (cf §3), puis la feature elle-même.
  • passed = la feature était configurée ET fonctionne.

Piège spécifique au module 04 — distinguer « skip sain » de « skip masquant un drift ». Un test.skip(!isVisible, '...') se déclenche dès que l’élément ciblé n’est pas trouvé. Si l’élément existe mais que le sélecteur est devenu obsolète (UI changée), le test se skippe silencieusement au lieu d’échouer. D’où l’importance de periodically exécuter les tests sur une instance riche (capstone) pour confirmer que les skips viennent bien de l’absence de feature, pas d’un sélecteur périmé.

# --- Qualifier un rapport (echantillon module 04 — instance minimale) ---
rapport_exemple = [
    {"test": "attacher une knowledge base via le menu +", "status": "skipped"},   # option KB absente
    {"test": "chat avec KB attachee", "status": "skipped"},          # aucune KB configuree
    {"test": "menu outils MCP accessible", "status": "skipped"},     # outils MCP non configures
    {"test": "ouvrir le selecteur d outils MCP", "status": "skipped"},
    {"test": "recherche web via outils", "status": "skipped"},       # recherche web indisponible
    {"test": "acceder aux canaux de discussion", "status": "passed"},   # canaux configures
    {"test": "poster un message dans un canal", "status": "passed"},
]

def qualifier(rapport):
    n = {"passed": 0, "failed": 0, "skipped": 0, "flaky": 0}
    for t in rapport:
        n[t["status"]] = n.get(t["status"], 0) + 1
    return n

bilan = qualifier(rapport_exemple)
print("Bilan module 04 (instance minimale) :", bilan)
# 5 skips legitimes (features absentes) + 2 passed + 0 failed = sain
verdict = "GO" if bilan["failed"] == 0 else "NO-GO (investiguer les failed)"
print("Verdict provisoire :", verdict, "(5 skips = instance minimale saine, pas une regression)")
Bilan module 04 (instance minimale) : {'passed': 2, 'failed': 0, 'skipped': 5, 'flaky': 0}
Verdict provisoire : GO (5 skips = instance minimale saine, pas une regression)

Exercices

Trois exercices, tous exécutables tels quels (ils tournent sans erreur et renvoient un placeholder). Remplace le corps de chaque fonction, ré-exécute, et compare au comportement attendu décrit plus haut.

Exercice 1 — Robustesse d’un sélecteur

Complète robustesse_du_selecteur pour qu’elle renvoie "robuste", "fragile" ou "neutre" selon la forme du sélecteur, sans réutiliser robustesse_selecteur du §3. Appuie-toi sur la classification du §3 (getByRole/aria-label/getByText = robuste ; nth()/.first()/.last() = fragile ; #id/class CSS = neutre).

def robustesse_du_selecteur(selecteur: str) -> str:
    # TODO : renvoyer "robuste" / "fragile" / "neutre" selon la forme du selecteur.
    # Indice : inspecte la présence de getByRole/aria-label vs nth(.
    return "neutre"  # placeholder — a remplacer

# Test rapide (doit afficher "fragile" une fois complete) :
print("robustesse de '.nth(1)' :", robustesse_du_selecteur("page.locator('button').nth(1)"))
robustesse de '.nth(1)' : neutre

Exercice 2 — Relier un test à son concept

Complète concept_du_test : à partir du titre d’un test du module 04, renvoie le concept principal ("rag", "mcp", ou "channels"). Inspire-toi des trois parties (describe 04a/04b/04c) de l’inventaire du §2.

def concept_du_test(titre: str) -> str:
    # TODO : mapper un titre de test -> son concept principal
    # Ex : "attacher une knowledge base..." -> "rag"
    return "a determiner"  # placeholder

for t in ["attacher une knowledge base via le menu +",
          "chat avec KB attachee — reponse enrichie par les documents",
          "menu outils MCP accessible (si configure)",
          "ouvrir le selecteur d outils MCP",
          "recherche web via outils (si disponible)",
          "acceder aux canaux de discussion",
          "poster un message dans un canal"]:
    print(f"  {t!r} -> {concept_du_test(t)}")
  'attacher une knowledge base via le menu +' -> a determiner
  'chat avec KB attachee — reponse enrichie par les documents' -> a determiner
  'menu outils MCP accessible (si configure)' -> a determiner
  'ouvrir le selecteur d outils MCP' -> a determiner
  'recherche web via outils (si disponible)' -> a determiner
  'acceder aux canaux de discussion' -> a determiner
  'poster un message dans un canal' -> a determiner

Exercice 3 — L’assertion de comptage (compter les outils)

Le spec laisse en commentaire : « Comptez le nombre d’outils disponibles ». Une assertion Playwright typique pour compter des éléments utilise toHaveCount(N). En TypeScript ce serait await expect(page.locator('[rôle="option"]')).toHaveCount(3);. Ici, renvoie cette ligne sous forme de chaîne depuis ligne_comptage_outils() (on valide la forme, pas l’exécution navigateur).

def ligne_comptage_outils(selecteur: str = '[role="option"]') -> str:
    # TODO : renvoyer la ligne d'assertion Playwright comptant les outils via toHaveCount.
    # Forme attendue : await expect(page.locator('<selecteur>')).toHaveCount(N);
    return "a completer"  # placeholder

print(ligne_comptage_outils())
a completer

Conclusion & étape suivante

Tu sais désormais tester les fonctions avancées d’une plateforme GenAI : écrire des tests conditionnels (skip runtime quand une feature est absente), t’adapter à l’évolution de l’UI entre versions, choisir des sélecteurs robustes plutôt que positionnels, et combiner API + navigateur pour des tests headless fiables. Tu as surtout intégré le réflexe propre au module 04 : beaucoup de skips sur une instance minimale = comportement sain, pas une régression — à condition de périodiquement revalider sur une instance riche.

➡️ Étape 5 — Module 05 : Multi-tenant & CI/CD. Tu clores la mission par l’isolation multi-tenant et l’industrialisation : certifier qu’un tenant ne fuite pas chez l’autre, et automatiser toute la suite en CI.


Note de reproductibilité (C.3). Les cellules de ce notebook ont été exécutées hors-ligne : lecture du .spec.ts, inventaire --list (sans navigateur) et démonstrations pure-Python. Aucune ne requiert d’instance Open WebUI, de secret, de KB, d’outil MCP ni de canal configurés. L’exécution live des tests E2E (instance riche pré-configurée) est volontairement différée au capstone et sera revalidée en Phase 3 (Open WebUI v0.9.6). Aucun claim de compatibilité v0.9.6 n’est porté par ce module.

Retour au sommet