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

@@ -0,0 +1,166 @@
import { useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { AssistantAnswer, AssistantExcerpt } from '@siop/shared';
import { useAskAssistant } from '@/api/assistant';
import { ouvrirDocument } from '@/api/gestion';
import { usePermissions } from '@/auth/use-permissions';
import { Button } from '@/components/ui/button';
/** Écrans 1-2 des maquettes R5 : chat « sourcé ou silencieux » (D2).
* Chaque réponse cite ses extraits EXACTS ; sans source au-dessus du seuil,
* le refus est honnête et chiffré (ce qui a été cherché). L'avertissement
* « l'IA propose, vous validez » est permanent, pas un disclaimer caché. */
interface Echange {
question: string;
reponse?: AssistantAnswer;
erreur?: string;
}
function Source({ extrait, no }: { extrait: AssistantExcerpt; no: number }) {
const navigate = useNavigate();
const ouvrir = () => {
if (extrait.sourceType === 'DOCUMENT' && extrait.documentId) {
void ouvrirDocument(extrait.documentId);
} else if (extrait.workOrderId) {
navigate(`/ot/${extrait.workOrderId}`);
}
};
return (
<div className="source">
<span className="no">{no}</span>
<div>
<b>{extrait.title}</b>
<div className="ou">
{extrait.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {extrait.locator}
</div>
<div className="extrait">« {extrait.content} »</div>
</div>
<button type="button" className="ouvrir" onClick={ouvrir}>
{extrait.sourceType === 'DOCUMENT' ? 'Ouvrir' : "Ouvrir l'OT"}
</button>
</div>
);
}
function Reponse({ reponse, surReformuler }: { reponse: AssistantAnswer; surReformuler: () => void }) {
const { can } = usePermissions();
if (reponse.mode === 'REFUSAL') {
return (
<div className="refus">
<b>Je ne trouve pas de source fiable dans votre bibliothèque je préfère ne pas inventer.</b>
<div className="pourquoi">
J'ai cherché dans {reponse.corpus.documents} document
{reponse.corpus.documents > 1 ? 's' : ''} indexé
{reponse.corpus.documents > 1 ? 's' : ''} et {reponse.corpus.reports} bilan
{reponse.corpus.reports > 1 ? 's' : ''} d'intervention : rien d'assez proche de votre
question.
</div>
<div className="actions-sug">
{can('ASSETS', 'edit') ? (
<Link to="/bibliotheque" className="btn prim">
Téléverser la notice dans la bibliothèque
</Link>
) : null}
<Button onClick={surReformuler}>Reformuler ma question</Button>
</div>
</div>
);
}
return (
<div className="msg-r">
{reponse.mode === 'GENERATED' && reponse.answer ? (
<p style={{ whiteSpace: 'pre-wrap' }}>{reponse.answer}</p>
) : (
<p>
Voici ce que portent vos sources — les extraits sont cités tels quels
{reponse.excerpts.map((_, i) => (
<span key={i} className="cite">{i + 1}</span>
))}
.
</p>
)}
<div className="sources">
{reponse.excerpts.map((extrait, i) => (
<Source key={`${extrait.locator}-${i}`} extrait={extrait} no={i + 1} />
))}
</div>
<div className="avert">⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.</div>
</div>
);
}
export default function PageAssistant() {
const ask = useAskAssistant();
const [question, setQuestion] = useState('');
const [echanges, setEchanges] = useState<Echange[]>([]);
const saisieRef = useRef<HTMLInputElement>(null);
const envoyer = () => {
const q = question.trim();
if (q.length < 3 || ask.isPending) return;
setQuestion('');
setEchanges((liste) => [...liste, { question: q }]);
ask.mutate(
{ question: q },
{
onSuccess: (reponse) =>
setEchanges((liste) =>
liste.map((e, i) => (i === liste.length - 1 ? { ...e, reponse } : e)),
),
onError: (erreur) =>
setEchanges((liste) =>
liste.map((e, i) => (i === liste.length - 1 ? { ...e, erreur: erreur.message } : e)),
),
},
);
};
return (
<>
<div className="entete-page">
<h1>Assistant</h1>
<span className="filajout">répond UNIQUEMENT depuis votre bibliothèque et vos historiques</span>
</div>
<div className="chat" aria-live="polite">
{echanges.length === 0 ? (
<div className="carte" style={{ color: 'var(--encre-2)', maxWidth: 760 }}>
Posez une question sur vos notices, vos historiques d'intervention ou vos procédures —
chaque réponse cite ses sources (document et page, ou bilan d'OT). Quand le corpus ne
porte pas la réponse, l'assistant le dit au lieu d'inventer.
</div>
) : null}
{echanges.map((echange, i) => (
<div key={i} style={{ display: 'contents' }}>
<div className="msg-q">{echange.question}</div>
{echange.reponse ? (
<Reponse reponse={echange.reponse} surReformuler={() => saisieRef.current?.focus()} />
) : echange.erreur ? (
<div className="refus" role="alert">
<b>{echange.erreur}</b>
</div>
) : (
<div className="msg-r" style={{ color: 'var(--encre-3)' }}>Recherche dans le corpus</div>
)}
</div>
))}
</div>
<div className="saisie-chat">
<input
ref={saisieRef}
value={question}
maxLength={500}
placeholder="Poser une question (notices, historiques, procédures)…"
aria-label="Poser une question"
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') envoyer();
}}
/>
<Button variant="prim" disabled={question.trim().length < 3 || ask.isPending} onClick={envoyer}>
Envoyer
</Button>
</div>
</>
);
}

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

View File

@@ -5,9 +5,11 @@ import {
REQUIRED_BILAN_FIELDS,
WORK_ORDER_TYPE_LABELS,
type BilanField,
type BilanSuggestion,
type WorkOrderDetail,
type WorkOrderStatus,
} from '@siop/shared';
import { useSuggestBilan } from '@/api/assistant';
import {
useCommentWorkOrder,
usePatchChecklist,
@@ -478,10 +480,108 @@ const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail[
{ champ: 'COMPONENT_CONCERNED', cle: 'componentConcerned', dto: 'componentConcernedId' },
];
/** Écran 3 des maquettes R5 : décrire la panne en français libre → l'IA
* propose des codes EXISTANTS avec justification et confiance (D1). Rien ne
* s'écrit sans le geste humain : « Appliquer » est ce geste — chaque champ
* appliqué garde son liseré « suggéré » jusqu'à modification manuelle. */
function ZoneSuggestion({
surAppliquer,
appliqueTout,
}: {
surAppliquer: (s: BilanSuggestion) => void;
appliqueTout: (liste: BilanSuggestion[]) => void;
}) {
const suggerer = useSuggestBilan();
const [description, setDescription] = useState('');
const [masquees, setMasquees] = useState(false);
const suggestions = masquees ? [] : (suggerer.data?.suggestions ?? []);
return (
<>
<div className="champ-b" style={{ gridColumn: '1 / -1' }}>
<label htmlFor="sug-description">Décrire pour suggérer (optionnel)</label>
<textarea
id="sug-description"
className="zone-libre"
placeholder="Décrivez la panne et ce que vous avez fait — l'IA proposera les codes du bilan…"
value={description}
maxLength={2000}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="actions-sug">
<Button
variant="prim"
disabled={description.trim().length < 10 || suggerer.isPending}
onClick={() => {
setMasquees(false);
suggerer.mutate({ description: description.trim() });
}}
>
{suggerer.isPending ? 'Analyse…' : '✨ Suggérer les codes'}
</Button>
<span style={{ fontSize: 11.5, color: 'var(--encre-3)', alignSelf: 'center' }}>
La description n'écrit rien toute seule — vous appliquez, ou pas.
</span>
</div>
{suggerer.isError ? (
<p className="erreur-form" role="alert">{suggerer.error.message}</p>
) : null}
{suggerer.isSuccess && suggestions.length === 0 && !masquees ? (
<p style={{ fontSize: 12.5, color: 'var(--encre-2)' }}>
Aucun code assez proche de cette description — l'IA ne devine pas : choisissez dans les
sélecteurs.
</p>
) : null}
{suggestions.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
{suggestions.map((s) => (
<div className="suggestion" key={s.field}>
<span className="ia"></span>
<div>
<b>
{BILAN_FIELD_LABELS[s.field]} « {s.label} »
</b>
<div className="just">
{s.similarReports > 0
? `${s.similarReports} bilan${s.similarReports > 1 ? 's' : ''} similaire${s.similarReports > 1 ? 's' : ''} sur ce parc`
: 'proche de votre description'}
</div>
</div>
<span className="confiance">
confiance {s.confidence === 'HIGH' ? 'forte' : 'moyenne'}
</span>
<Button onClick={() => surAppliquer(s)}>Appliquer</Button>
</div>
))}
<div className="actions-sug">
<Button variant="prim" onClick={() => appliqueTout(suggestions)}>
Appliquer les {suggestions.length} (pré-remplir)
</Button>
<Button onClick={() => setMasquees(true)}>Ignorer</Button>
</div>
</div>
) : null}
</div>
</>
);
}
function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const { data: valeurs } = useReferenceValues();
const maj = useUpsertReport(ot.id);
const bloqueurBilan = ot.closureBlockers.find((b) => b.includes('bilan'));
const [suggeres, setSuggeres] = useState<Set<BilanField>>(new Set());
const modifiable = peutEditer && ot.status !== 'DONE' && ot.status !== 'CANCELLED';
const appliquer = (liste: BilanSuggestion[]) => {
const corps = Object.fromEntries(
liste.map((s) => [CHAMPS_BILAN.find((c) => c.champ === s.field)!.dto, s.valueId]),
);
maj.mutate(corps, {
onSuccess: () =>
setSuggeres((avant) => new Set([...avant, ...liste.map((s) => s.field)])),
});
};
return (
<div className="carte">
@@ -491,13 +591,19 @@ function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boole
— requis pour clôturer
</span>
</h2>
{modifiable ? (
<ZoneSuggestion
surAppliquer={(s) => appliquer([s])}
appliqueTout={appliquer}
/>
) : null}
<div className="bilan">
{CHAMPS_BILAN.map(({ champ, cle, dto }) => {
const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive);
const valeur = ot.report?.[cle as 'doorState'] ?? null;
const requis = REQUIRED_BILAN_FIELDS.includes(champ);
return (
<div className="champ-b" key={champ}>
<div className="champ-b" key={champ} data-suggere={suggeres.has(champ) || undefined}>
<label htmlFor={`bilan-${champ}`}>
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
</label>
@@ -505,7 +611,15 @@ function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boole
id={`bilan-${champ}`}
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
value={valeur?.id ?? ''}
onChange={(e) => maj.mutate({ [dto]: e.target.value || null })}
onChange={(e) => {
// choix manuel : le liseré « suggéré » n'a plus lieu d'être
setSuggeres((avant) => {
const suite = new Set(avant);
suite.delete(champ);
return suite;
});
maj.mutate({ [dto]: e.target.value || null });
}}
>
<option value="">Sélectionner…</option>
{options.map((o) => (