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:
pr-daaif
2026-07-17 17:26:22 +01:00
parent 45ae491827
commit 28eecc1fb9
31 changed files with 1431 additions and 24 deletions

View File

@@ -5,6 +5,7 @@ import {
DOCUMENT_MAX_BYTES,
type DocumentKind,
} from '@siop/shared';
import { useReindexAssistant } from '@/api/assistant';
import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
import { useAssetOptions } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
@@ -12,10 +13,12 @@ import { tailleLisible, VignetteDoc } from '@/components/carte-documents';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
/** Écran 7 des maquettes R3 : notices, certificats, photos — toujours
* RATTACHÉS (appareil ou OT). Ces documents nourriront le RAG en R5. */
/** Écran 7 des maquettes R3, devenu écran 5 de R5 : la bibliothèque EST le
* corpus de l'assistant — statut d'indexation visible, interrupteur
* d'exclusion réversible, réindexation par geste explicite (D3). */
export default function PageBibliotheque() {
const { can } = usePermissions();
const reindex = useReindexAssistant();
const [kind, setKind] = useState<DocumentKind | ''>('');
const [assetId, setAssetId] = useState('');
const { data: options } = useAssetOptions();
@@ -29,7 +32,9 @@ export default function PageBibliotheque() {
const [survol, setSurvol] = useState(false);
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
const indexes = (documents ?? []).filter((d) => d.indexedAt && d.inCorpus).length;
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
const administreCorpus = can('ASSETS', 'edit');
const surDepot = (e: DragEvent) => {
e.preventDefault();
@@ -43,16 +48,37 @@ export default function PageBibliotheque() {
return (
<>
<div className="entete-page">
<h1>Bibliothèque</h1>
<h1>Bibliothèque corpus de l'assistant</h1>
<span className="filajout">
{documents?.length ?? 0} documents · {tailleLisible(totalOctets)}
{documents?.length ?? 0} documents · {indexes} indexés · {tailleLisible(totalOctets)}
</span>
<div className="actions">
{administreCorpus ? (
<Button disabled={reindex.isPending} onClick={() => reindex.mutate()}>
{reindex.isPending ? 'Réindexation' : 'Réindexer tout'}
</Button>
) : null}
{peutEditer ? (
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
) : null}
</div>
</div>
<div className="avert">
🛡 Loi 09-08 — anonymisation à l'ingestion : noms, téléphones et e-mails des personnes ne
sont JAMAIS envoyés dans les index ni aux modèles.
</div>
{reindex.isSuccess ? (
<div className="carte" style={{ color: 'var(--encre-2)', fontSize: 13 }}>
Réindexation terminée : {reindex.data.documentsIndexed} document
{reindex.data.documentsIndexed > 1 ? 's' : ''} indexé
{reindex.data.documentsIndexed > 1 ? 's' : ''}, {reindex.data.documentsSkipped} ignoré
{reindex.data.documentsSkipped > 1 ? 's' : ''} (exclus ou non indexables),{' '}
{reindex.data.reportsIndexed} bilans, {reindex.data.chunks} extraits.
</div>
) : null}
{reindex.isError ? (
<p className="erreur-form" role="alert">{reindex.error.message}</p>
) : null}
<div className="filtres">
<select
aria-label="Filtrer par type"
@@ -99,6 +125,7 @@ export default function PageBibliotheque() {
<VignetteDoc
key={d.id}
doc={d}
corpus={administreCorpus}
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
/>
))}
@@ -107,8 +134,8 @@ export default function PageBibliotheque() {
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
)}
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) —
le rattachement propre commence ici.
Le corpus de l'assistant, c'est cette bibliothèque (PDF indexés) plus les bilans
d'intervention codés — rien d'externe. Les réponses citent leurs sources.
</div>
{peutEditer ? (
<ModaleTeleversement