import { expect, test } from '@playwright/test'; /** * Recette R5 (plan de releases) : la bibliothèque EST le corpus (bandeau * 09-08, statut d'indexation, interrupteur, réindexation explicite) ; puis * l'assistant « sourcé ou silencieux » (réponse citée depuis le PDF téléversé, * refus honnête et chiffré sinon) ; enfin la suggestion de bilan sur un OT * (codes existants, appliqués par le geste humain, liseré « suggéré »). * Le service IA tourne avec l'embeddeur déterministe et des seuils abaissés * (ci.yml) — c'est le CIRCUIT qui se recette, le vrai modèle se recette en * local (journal R5.1). */ const suffix = Date.now().toString(36); /** PDF 1 page minimal mais VALIDE (xref calculée) — pypdf doit pouvoir en * extraire le texte : c'est ce qui alimente l'index côté service IA. */ function pdfMinimal(texte: string): Buffer { const contenu = `BT /F1 12 Tf 72 720 Td (${texte.replace(/[()\\]/g, '\\$&')}) Tj ET`; const objets = [ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R ' + '/Resources << /Font << /F1 5 0 R >> >> >>', `<< /Length ${contenu.length} >>\nstream\n${contenu}\nendstream`, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', ]; let corps = '%PDF-1.4\n'; const offsets: number[] = []; objets.forEach((objet, i) => { offsets.push(corps.length); corps += `${i + 1} 0 obj\n${objet}\nendobj\n`; }); const debutXref = corps.length; corps += `xref\n0 ${objets.length + 1}\n0000000000 65535 f \n`; for (const offset of offsets) corps += `${String(offset).padStart(10, '0')} 00000 n \n`; corps += `trailer\n<< /Size ${objets.length + 1} /Root 1 0 R >>\nstartxref\n${debutXref}\n%%EOF`; return Buffer.from(corps, 'latin1'); } async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) { await page.goto('/connexion'); await page.getByRole('button', { name: nom }).click(); await expect(page.locator('.topbar')).toBeVisible(); } test('recette R5 : corpus → réindexation → assistant sourcé → refus honnête', async ({ page }) => { test.setTimeout(180_000); // deux réindexations complètes du corpus réel const fichier = `e2e-notice-${suffix}.pdf`; // 1 · Nadia (gestionnaire, ASSETS.edit) — la bibliothèque est le corpus await connexionDemo(page, /Nadia Berrada/); await page.getByRole('link', { name: 'Fichiers' }).click(); await expect(page.getByRole('heading', { name: /corpus de l'assistant/ })).toBeVisible(); await expect(page.locator('.avert')).toContainText('Loi 09-08'); // l'anonymisation est DITE // 2 · Téléverser une notice PDF de test rattachée à A1 await page.getByRole('button', { name: 'Téléverser' }).click(); await page.getByLabel("Rattacher à l'appareil *").selectOption({ index: 1 }); await page.getByLabel('Choisir un fichier').setInputFiles({ name: fichier, mimeType: 'application/pdf', buffer: pdfMinimal( 'Couple de serrage des coulisseaux de guides : 25 Nm. Verifier le jeu lateral.', ), }); await page.getByRole('button', { name: 'Téléverser', exact: true }).last().click(); const vignette = page.locator('.doc', { hasText: fichier }); await expect(vignette).toBeVisible(); await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance // 3 · Réindexer tout — geste explicite, bilan chiffré, statut à jour await page.getByRole('button', { name: 'Réindexer tout' }).click(); // l'ingestion réelle (MinIO + pypdf + embeddings) peut dépasser les 5 s await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 }); await expect(vignette.locator('.st')).toContainText('indexé ·'); await expect(vignette.locator('.st')).toContainText('extrait'); // 4 · L'assistant répond SOURCÉ depuis ce PDF (embeddeur déterministe : // la question reprend les mots de la notice) await page.getByRole('link', { name: 'Assistant' }).click(); await expect(page.getByText('répond UNIQUEMENT depuis votre bibliothèque')).toBeVisible(); await page .getByLabel('Poser une question') .fill('couple de serrage des coulisseaux de guides ?'); await page.getByRole('button', { name: 'Envoyer' }).click(); const source = page.locator('.source', { hasText: fichier }); await expect(source).toBeVisible(); await expect(source.locator('.extrait')).toContainText('25 Nm'); // l'extrait EXACT await expect(source.locator('.ou')).toContainText('p. 1'); // la citation pointe la page await expect(page.locator('.msg-r .avert')).toContainText('vous validez'); // pas un disclaimer caché // 5 · Sans source au-dessus du seuil : refus honnête, chiffré, avec l'action utile await page.getByLabel('Poser une question').fill('xylophone quantique zorglub ?'); await page.getByRole('button', { name: 'Envoyer' }).click(); const refus = page.locator('.refus'); await expect(refus).toBeVisible(); await expect(refus).toContainText('je préfère ne pas inventer'); await expect(refus).toContainText(/J'ai cherché dans \d+ documents? indexés? et \d+ bilans?/); await expect(refus.getByRole('link', { name: /Téléverser la notice/ })).toBeVisible(); // 6 · L'interrupteur exclut la notice — réversible, effectif à la réindexation await page.getByRole('link', { name: 'Fichiers' }).click(); await vignette.getByRole('switch').click(); await expect(vignette.locator('.st')).toHaveText('exclu du corpus'); await page.getByRole('button', { name: 'Réindexer tout' }).click(); await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 }); // Ménage : la notice de test sort de la bibliothèque page.on('dialog', (d) => void d.accept()); await vignette.getByRole('button', { name: 'Supprimer' }).click(); await expect(vignette).toHaveCount(0); }); test('recette R5 : suggestion de bilan — codes existants, appliqués par l\'humain', async ({ page, }) => { const titre = `E2E Suggestion ${suffix}`; // 1 · Nadia crée un OT de dépannage sur A1 await connexionDemo(page, /Nadia Berrada/); await page.getByRole('link', { name: 'Ordres de travail' }).click(); await page.getByRole('button', { name: '+ Nouvel OT' }).click(); await page.getByLabel('Objet *').fill(titre); await page.getByLabel('Équipement *').selectOption({ index: 1 }); await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click(); await expect(page.getByRole('heading', { name: titre })).toBeVisible(); // 2 · Décrire pour suggérer — l'IA propose des codes EXISTANTS, justifiés await page .getByLabel('Décrire pour suggérer (optionnel)') .fill('Frottement mécanique sur les portes, nettoyage et graissage effectués, essais OK.'); await page.getByRole('button', { name: /Suggérer les codes/ }).click(); const suggestions = page.locator('.suggestion'); await expect(suggestions.first()).toBeVisible(); await expect(page.locator('.confiance').first()).toContainText('confiance'); // 3 · « Appliquer les N » : les sélecteurs se pré-remplissent, liseré « suggéré » await page.getByRole('button', { name: /Appliquer les \d/ }).click(); await expect(page.locator('.champ-b[data-suggere]').first()).toBeVisible(); const anomalie = page.locator('#bilan-ANOMALY'); await expect(anomalie).not.toHaveValue(''); // 4 · Un choix MANUEL retire le liseré du champ concerné (l'humain a repris la main) const champAnomalie = page.locator('.champ-b', { has: anomalie }); await expect(champAnomalie).toHaveAttribute('data-suggere', 'true'); await anomalie.selectOption({ index: 1 }); await expect(champAnomalie).not.toHaveAttribute('data-suggere', 'true'); });