mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r5.3): écrans IA — assistant web, corpus administrable, suggestions OT/mobile
- Contrat (76 opérations) : Document expose inCorpus/indexedAt/chunkCount,
PATCH /documents/{id}/corpus (ASSETS.edit), POST /assistant/reindex
(bilan chiffré) ; ci-contract vérifie désormais aussi le client mobile.
- Web : page /assistant (chat sourcé — extraits exacts cités, Ouvrir vers
PDF authentifié ou fiche OT, avertissement permanent ; refus honnête
chiffré avec action utile) ; Bibliothèque = corpus (bandeau 09-08,
statut d'indexation par document, interrupteur d'exclusion PDF,
Réindexer tout) ; fiche OT : « Décrire pour suggérer » (Appliquer =
geste humain, liseré « suggéré » retiré au choix manuel).
- Mobile : chips de suggestion dans la clôture (un appui = un champ
pré-rempli, « réseau requis » hors-ligne — la file R4 n'en dépend pas).
- apps/ai : seuils AI_SEUIL_* configurables par env (CI + calibrage).
- CI e2e : service siop2-ai (embeddeur déterministe, seuils calibrés sur
mesures : match 0,66 vs bruit 0,11) + parcours R5 Playwright (PDF généré
xref valide → réindexation → réponse sourcée → refus → suggestion).
- Vérifié : 16/16 Playwright, 78 tests API, 23 pytest, 17 jest-expo ;
chaîne réelle au vrai modèle ONNX (web 7/7, mobile Expo web 6/6).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
149
apps/web/e2e/parcours-r5.spec.ts
Normal file
149
apps/web/e2e/parcours-r5.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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');
|
||||
});
|
||||
Reference in New Issue
Block a user