mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
- 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>
242 lines
8.8 KiB
TypeScript
242 lines
8.8 KiB
TypeScript
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
|
import {
|
|
DOCUMENT_KIND_LABELS,
|
|
DOCUMENT_KINDS,
|
|
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';
|
|
import { tailleLisible, VignetteDoc } from '@/components/carte-documents';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Modale } from '@/components/ui/modale';
|
|
|
|
/** É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();
|
|
const { data: documents } = useDocuments({
|
|
...(kind ? { kind } : {}),
|
|
...(assetId ? { assetId } : {}),
|
|
});
|
|
const suppression = useDeleteDocument();
|
|
const [modale, setModale] = useState(false);
|
|
const [fichierDepose, setFichierDepose] = useState<File | null>(null);
|
|
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();
|
|
setSurvol(false);
|
|
const fichier = e.dataTransfer.files?.[0];
|
|
if (!fichier) return;
|
|
setFichierDepose(fichier);
|
|
setModale(true);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="entete-page">
|
|
<h1>Bibliothèque — corpus de l'assistant</h1>
|
|
<span className="filajout">
|
|
{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"
|
|
value={kind}
|
|
onChange={(e) => setKind(e.target.value as DocumentKind | '')}
|
|
>
|
|
<option value="">Type : tous</option>
|
|
{DOCUMENT_KINDS.map((k) => (
|
|
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
aria-label="Filtrer par appareil"
|
|
value={assetId}
|
|
onChange={(e) => setAssetId(e.target.value)}
|
|
>
|
|
<option value="">Rattaché à : tout le parc</option>
|
|
{(options ?? []).map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
{a.reference} — {a.siteName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{peutEditer ? (
|
|
<button
|
|
type="button"
|
|
className="zone-depot"
|
|
data-survol={survol || undefined}
|
|
onClick={() => setModale(true)}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
setSurvol(true);
|
|
}}
|
|
onDragLeave={() => setSurvol(false)}
|
|
onDrop={surDepot}
|
|
>
|
|
📂 Glissez un fichier ici — PDF, JPG, PNG · 20 Mo max · rattachez-le à un appareil ou un OT
|
|
</button>
|
|
) : null}
|
|
{documents?.length ? (
|
|
<div className="docs">
|
|
{documents.map((d) => (
|
|
<VignetteDoc
|
|
key={d.id}
|
|
doc={d}
|
|
corpus={administreCorpus}
|
|
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
|
)}
|
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
|
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
|
|
ouverte={modale}
|
|
fichierInitial={fichierDepose}
|
|
surFermeture={(o) => {
|
|
setModale(o);
|
|
if (!o) setFichierDepose(null);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ModaleTeleversement({
|
|
ouverte,
|
|
fichierInitial,
|
|
surFermeture,
|
|
}: {
|
|
ouverte: boolean;
|
|
fichierInitial?: File | null;
|
|
surFermeture: (o: boolean) => void;
|
|
}) {
|
|
const { data: options } = useAssetOptions();
|
|
const upload = useUploadDocument();
|
|
const fichierRef = useRef<HTMLInputElement>(null);
|
|
const [kind, setKind] = useState<DocumentKind>('NOTICE');
|
|
const [assetId, setAssetId] = useState('');
|
|
const [fichier, setFichier] = useState<File | null>(null);
|
|
|
|
// Fichier arrivé par glisser-déposer : la modale s'ouvre déjà remplie.
|
|
useEffect(() => {
|
|
if (fichierInitial) setFichier(fichierInitial);
|
|
}, [fichierInitial]);
|
|
|
|
const envoyer = () => {
|
|
if (!fichier || !assetId) return;
|
|
if (fichier.size > DOCUMENT_MAX_BYTES) {
|
|
window.alert('Fichier trop lourd (20 Mo max).');
|
|
return;
|
|
}
|
|
upload.mutate(
|
|
{ file: fichier, kind, assetId },
|
|
{
|
|
onSuccess: () => {
|
|
setFichier(null);
|
|
surFermeture(false);
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Modale titre="Téléverser un document" ouverte={ouverte} surFermeture={surFermeture}>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="champ">
|
|
<label htmlFor="tv-appareil">Rattacher à l'appareil *</label>
|
|
<select id="tv-appareil" value={assetId} onChange={(e) => setAssetId(e.target.value)}>
|
|
<option value="">Sélectionner…</option>
|
|
{(options ?? []).map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
{a.reference} — {a.siteName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="champ">
|
|
<label htmlFor="tv-type">Type *</label>
|
|
<select id="tv-type" value={kind} onChange={(e) => setKind(e.target.value as DocumentKind)}>
|
|
{DOCUMENT_KINDS.map((k) => (
|
|
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<input
|
|
ref={fichierRef}
|
|
type="file"
|
|
accept="application/pdf,image/jpeg,image/png"
|
|
aria-label="Choisir un fichier"
|
|
className="hidden"
|
|
onChange={(e) => setFichier(e.target.files?.[0] ?? null)}
|
|
/>
|
|
<button type="button" className="zone-photo" onClick={() => fichierRef.current?.click()}>
|
|
{fichier
|
|
? `📄 ${fichier.name} (${tailleLisible(fichier.size)})`
|
|
: '📁 Choisir un fichier — PDF, JPG, PNG · 20 Mo max'}
|
|
</button>
|
|
<p className="note-douce">
|
|
Les photos prises sur une intervention se téléversent plutôt depuis la fiche OT —
|
|
elles restent rattachées à l'intervention.
|
|
</p>
|
|
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
|
|
<div className="pied">
|
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
|
<Button variant="prim" disabled={!fichier || !assetId || upload.isPending} onClick={envoyer}>
|
|
Téléverser
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modale>
|
|
);
|
|
}
|