feat(r5): bibliothèque-corpus en tableau (arbitrage de recette du référent)

- La liste des documents passe en tableau, conforme à l'écran 5 de
  maquette-r5 (la bibliothèque sera surtout des PDF) : Document,
  Rattaché à, Indexation, Corpus (ASSETS.edit), actions.
- Les vignettes restent aux cartes « Documents » des fiches OT/ascenseur
  (photos) ; StatutCorpus/InterrupteurCorpus extraits en composants.
- Une image non indexable affiche son interrupteur éteint quel que soit
  l'état stocké — l'interrupteur montre la réalité du corpus.
- e2e adapté aux lignes de tableau, 16/16 Playwright rejoués ; artefact
  de revue pixel mis à jour (arbitrages rendus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-17 21:40:28 +01:00
parent b69c54ed0f
commit c204eb994f
5 changed files with 135 additions and 39 deletions

View File

@@ -64,5 +64,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R5.1+ génération opt-in** : `Generateur` ADR-004 §3 extractif par défaut, `AI_GENERATION=api` + `AI_API_KEY` (exigée au boot, jamais loguée ; Dokploy secrets) + `AI_MODEL` (défaut claude-opus-4-8), SDK anthropic en extra optionnel, citations [n] obligatoires, repli extractif sur tout échec (dont `stop_reason=refusal`). **R5.2 assistant au contrat** : `POST /assistant/ask` (WORK_ORDERS.view) + `POST /assistant/suggest-bilan` (WORK_ORDERS.edit) proxifiés par NestJS (traduction dialecte interne contrat, 503 propre), suggestion = codes EXISTANTS seulement (confiance + « N bilans similaires »), normalisation fastembed corrigée (débusquée en chaîne réelle), 23 pytest, tests API sur stub HTTP.
- **R5.3 — écrans IA** : page web Assistant (chat sourcé, refus honnête chiffré, « Ouvrir » vers PDF/OT), Bibliothèque = corpus administrable (statut d'indexation par document, interrupteur d'exclusion, « Réindexer tout », bandeau 09-08), suggestions fiche OT Appliquer » = geste humain, liseré « suggéré » retiré au choix manuel) et clôture mobile (chips, « réseau requis » hors-ligne) ; contrat 76 opérations (corpus sur Document, `PATCH /documents/{id}/corpus`, `POST /assistant/reindex` ci-contract vérifie aussi le client mobile) ; seuils `AI_SEUIL_*` par env ; job e2e CI avec `siop2-ai` (embeddeur déterministe, seuils calibrés sur mesures réelles), 16/16 Playwright, 78 tests API ; chaîne vérifiée au vrai modèle ONNX (web 7/7, mobile Expo web 6/6, 0 erreur console).
- **Recette R5 sans clé API + durcissement** (17/07) : la recette a invalidé MiniLM-384 (page-réponse classée derrière des passages sans rapport, 0,24 vs 0,41) **bascule mesurée vers `paraphrase-multilingual-mpnet-base-v2` 768 d** (ADR-004 amendé, migration `r5_embeddings_mpnet`, découpage ~350 car., seuils 0,45/0,40/0,55) ; recette type (réponse sourcée p. 2, refus honnête, D1-D5, tout en extractif) ; revue pixel publiée (artefact, 3 arbitrages) ; Dockerfile `siop2-ai` (modèle au build, non-root), compose Dokploy (service interne, `AI_SERVICE_TOKEN` requis, génération opt-in), runbook §5-6 (service IA, calibrage seuils client, réindexation post-déploiement).
- 🔄 **Reprise ici** : validation du référent (revue pixel R5 + 3 arbitrages) tag `release/r5`. Restes : recette R4 sur téléphone (Expo Go), redéploiement Dokploy (`release/r3` puis r5), secret `DOKPLOY_WEBHOOK_URL`, calibrage `AI_SEUIL_*` sur corpus SPELEV réel.
- **Revue pixel R5 validée par le référent (17/07)** 1 correction appliquée sur arbitrage : la Bibliothèque-corpus passe en **tableau** (Document/Rattaché à/Indexation/Corpus + actions ; interrupteur éteint pour les non-indexables), vignettes conservées sur les cartes Documents des fiches ; « Appliquer » direct et calibrage au runbook validés tels quels. 16/16 Playwright rejoués.
- 🔄 **Reprise ici** : tag `release/r5` (sur le mot du référent). Restes : recette R4 sur téléphone (Expo Go), redéploiement Dokploy (`release/r3` puis r5 avec `AI_SERVICE_TOKEN`), secret `DOKPLOY_WEBHOOK_URL`, calibrage `AI_SEUIL_*` sur corpus SPELEV réel.
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

@@ -65,7 +65,7 @@ test('recette R5 : corpus → réindexation → assistant sourcé → refus honn
),
});
await page.getByRole('button', { name: 'Téléverser', exact: true }).last().click();
const vignette = page.locator('.doc', { hasText: fichier });
const vignette = page.locator('tr', { hasText: fichier }); // tableau du corpus (arbitrage référent)
await expect(vignette).toBeVisible();
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance

View File

@@ -65,7 +65,7 @@ function ApercuVignette({ doc }: { doc: DocumentDto }) {
}
/** Statut corpus (R5, D3) : lisible d'un coup d'œil, jamais ambigu. */
function StatutCorpus({ doc }: { doc: DocumentDto }) {
export function StatutCorpus({ doc }: { doc: DocumentDto }) {
if (doc.contentType !== 'application/pdf') {
return <span className="st exclu">image non indexable</span>;
}
@@ -84,28 +84,16 @@ function StatutCorpus({ doc }: { doc: DocumentDto }) {
);
}
export function VignetteDoc({
doc,
corpus,
surSuppression,
}: {
doc: DocumentDto;
/** true = l'utilisateur administre le corpus (statut + interrupteur visibles). */
corpus?: boolean;
surSuppression?: (id: string) => void;
}) {
/** Interrupteur d'inclusion au corpus (R5, D3) — PDF seulement, réversible.
* Une image non indexable s'affiche ÉTEINTE quel que soit l'état stocké :
* l'interrupteur montre la réalité du corpus, pas une colonne de base. */
export function InterrupteurCorpus({ doc }: { doc: DocumentDto }) {
const bascule = useSetDocumentCorpus();
return (
<div className="doc">
<ApercuVignette doc={doc} />
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
{corpus ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatutCorpus doc={doc} />
<button
type="button"
role="switch"
aria-checked={doc.inCorpus}
aria-checked={doc.contentType === 'application/pdf' && doc.inCorpus}
aria-label={doc.inCorpus ? 'Exclure du corpus' : 'Inclure au corpus'}
title={
doc.contentType !== 'application/pdf'
@@ -118,8 +106,20 @@ export function VignetteDoc({
disabled={bascule.isPending || doc.contentType !== 'application/pdf'}
onClick={() => bascule.mutate({ id: doc.id, inCorpus: !doc.inCorpus })}
/>
</span>
) : null}
);
}
export function VignetteDoc({
doc,
surSuppression,
}: {
doc: DocumentDto;
surSuppression?: (id: string) => void;
}) {
return (
<div className="doc">
<ApercuVignette doc={doc} />
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
<b>{doc.fileName}</b>
<span className="meta">
{doc.assetReference ? `Asc. ${doc.assetReference} · ` : ''}

View File

@@ -6,10 +6,16 @@ import {
type DocumentKind,
} from '@siop/shared';
import { useReindexAssistant } from '@/api/assistant';
import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
import {
ouvrirDocument,
telechargerDocument,
useDeleteDocument,
useDocuments,
useUploadDocument,
} from '@/api/gestion';
import { useAssetOptions } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { tailleLisible, VignetteDoc } from '@/components/carte-documents';
import { InterrupteurCorpus, StatutCorpus, tailleLisible } from '@/components/carte-documents';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
@@ -121,15 +127,81 @@ export default function PageBibliotheque() {
</button>
) : null}
{documents?.length ? (
<div className="docs">
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>Document</th>
<th>Rattaché à</th>
<th>Indexation</th>
{administreCorpus ? <th>Corpus</th> : null}
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{documents.map((d) => (
<VignetteDoc
key={d.id}
doc={d}
corpus={administreCorpus}
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
/>
<tr key={d.id}>
<td>
<b>{d.fileName}</b>
<span className="sous">
{DOCUMENT_KIND_LABELS[d.kind]} · {tailleLisible(d.size)} ·{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(
new Date(d.createdAt),
)}
{d.uploadedByName ? ` · ${d.uploadedByName}` : ''}
</span>
</td>
<td>
{d.assetReference ? `Asc. ${d.assetReference}` : null}
{d.assetReference && d.workOrderReference ? ' · ' : null}
{d.workOrderReference ?? null}
{!d.assetReference && !d.workOrderReference ? '—' : null}
</td>
<td>
<StatutCorpus doc={d} />
</td>
{administreCorpus ? (
<td>
<InterrupteurCorpus doc={d} />
</td>
) : null}
<td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
<span style={{ display: 'inline-flex', gap: 10 }}>
<button
type="button"
onClick={() => void ouvrirDocument(d.id)}
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
>
Ouvrir
</button>
<button
type="button"
onClick={() => void telechargerDocument(d.id, d.fileName)}
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
>
Télécharger
</button>
{peutEditer ? (
<button
type="button"
onClick={() => {
if (window.confirm(`Supprimer « ${d.fileName} » ?`)) {
suppression.mutate(d.id);
}
}}
style={{ color: 'var(--danger)', fontWeight: 600, fontSize: 12.5 }}
>
Supprimer
</button>
) : null}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>

View File

@@ -4,6 +4,29 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
## 2026-07-17 — Pr. Daaif (+ Claude) — Arbitrages de recette R5 rendus : le corpus passe en tableau
**Actions**
- **Arbitrage du référent** sur la revue pixel : « Ça serait mieux d'utiliser un tableau car la plupart des
fichiers vont être des PDF » — le reste validé (« Appliquer » à écriture directe, calibrage au runbook).
- **Bibliothèque en tableau**, conforme à l'écran 5 de maquette-r5 : colonnes Document (nom + type · taille ·
date · auteur), Rattaché à, Indexation, Corpus (interrupteur, visible pour ASSETS.edit), actions
Ouvrir/Télécharger/Supprimer. Les vignettes restent aux cartes « Documents » des fiches (photos d'OT).
- Détail aligné sur la maquette : une image non indexable affiche son interrupteur **éteint** quel que soit
l'état stocké — l'interrupteur montre la réalité du corpus, pas une colonne de base.
- e2e adapté (lignes de tableau), **16/16 Playwright rejoués** ; artefact de revue pixel mis à jour (même URL),
arbitrages marqués rendus.
**Décisions**
- Revue pixel R5 **validée par le référent** (1 correction appliquée). Le tag `release/r5` peut être posé.
**Prochaine étape** : tag `release/r5` sur le mot du référent. Restes : recette R4 sur téléphone (Expo Go),
redéploiement Dokploy (`release/r3` puis r5 avec `AI_SERVICE_TOKEN`), calibrage `AI_SEUIL_*` sur corpus SPELEV.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — Recette R5 (sans clé API) + durcissement production `siop2-ai`
**Actions**