mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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.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).
|
- ✅ **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).
|
- ✅ **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 R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -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();
|
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).toBeVisible();
|
||||||
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance
|
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function ApercuVignette({ doc }: { doc: DocumentDto }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Statut corpus (R5, D3) : lisible d'un coup d'œil, jamais ambigu. */
|
/** 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') {
|
if (doc.contentType !== 'application/pdf') {
|
||||||
return <span className="st exclu">image — non indexable</span>;
|
return <span className="st exclu">image — non indexable</span>;
|
||||||
}
|
}
|
||||||
@@ -84,42 +84,42 @@ function StatutCorpus({ doc }: { doc: DocumentDto }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={doc.contentType === 'application/pdf' && doc.inCorpus}
|
||||||
|
aria-label={doc.inCorpus ? 'Exclure du corpus' : 'Inclure au corpus'}
|
||||||
|
title={
|
||||||
|
doc.contentType !== 'application/pdf'
|
||||||
|
? 'Seuls les PDF sont indexables'
|
||||||
|
: doc.inCorpus
|
||||||
|
? 'Exclure du corpus (effectif à la prochaine réindexation)'
|
||||||
|
: 'Inclure au corpus (effectif à la prochaine réindexation)'
|
||||||
|
}
|
||||||
|
className="interrupteur corpus"
|
||||||
|
disabled={bascule.isPending || doc.contentType !== 'application/pdf'}
|
||||||
|
onClick={() => bascule.mutate({ id: doc.id, inCorpus: !doc.inCorpus })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function VignetteDoc({
|
export function VignetteDoc({
|
||||||
doc,
|
doc,
|
||||||
corpus,
|
|
||||||
surSuppression,
|
surSuppression,
|
||||||
}: {
|
}: {
|
||||||
doc: DocumentDto;
|
doc: DocumentDto;
|
||||||
/** true = l'utilisateur administre le corpus (statut + interrupteur visibles). */
|
|
||||||
corpus?: boolean;
|
|
||||||
surSuppression?: (id: string) => void;
|
surSuppression?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const bascule = useSetDocumentCorpus();
|
|
||||||
return (
|
return (
|
||||||
<div className="doc">
|
<div className="doc">
|
||||||
<ApercuVignette doc={doc} />
|
<ApercuVignette doc={doc} />
|
||||||
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
|
<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-label={doc.inCorpus ? 'Exclure du corpus' : 'Inclure au corpus'}
|
|
||||||
title={
|
|
||||||
doc.contentType !== 'application/pdf'
|
|
||||||
? 'Seuls les PDF sont indexables'
|
|
||||||
: doc.inCorpus
|
|
||||||
? 'Exclure du corpus (effectif à la prochaine réindexation)'
|
|
||||||
: 'Inclure au corpus (effectif à la prochaine réindexation)'
|
|
||||||
}
|
|
||||||
className="interrupteur corpus"
|
|
||||||
disabled={bascule.isPending || doc.contentType !== 'application/pdf'}
|
|
||||||
onClick={() => bascule.mutate({ id: doc.id, inCorpus: !doc.inCorpus })}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<b>{doc.fileName}</b>
|
<b>{doc.fileName}</b>
|
||||||
<span className="meta">
|
<span className="meta">
|
||||||
{doc.assetReference ? `Asc. ${doc.assetReference} · ` : ''}
|
{doc.assetReference ? `Asc. ${doc.assetReference} · ` : ''}
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import {
|
|||||||
type DocumentKind,
|
type DocumentKind,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { useReindexAssistant } from '@/api/assistant';
|
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 { useAssetOptions } from '@/api/referentiel';
|
||||||
import { usePermissions } from '@/auth/use-permissions';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Modale } from '@/components/ui/modale';
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
|
||||||
@@ -121,15 +127,81 @@ export default function PageBibliotheque() {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{documents?.length ? (
|
{documents?.length ? (
|
||||||
<div className="docs">
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
{documents.map((d) => (
|
<div className="table">
|
||||||
<VignetteDoc
|
<table>
|
||||||
key={d.id}
|
<thead>
|
||||||
doc={d}
|
<tr>
|
||||||
corpus={administreCorpus}
|
<th>Document</th>
|
||||||
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
<th>Rattaché à</th>
|
||||||
/>
|
<th>Indexation</th>
|
||||||
))}
|
{administreCorpus ? <th>Corpus</th> : null}
|
||||||
|
<th aria-label="Actions" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{documents.map((d) => (
|
||||||
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
||||||
|
|||||||
@@ -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`
|
## 2026-07-17 — Pr. Daaif (+ Claude) — Recette R5 (sans clé API) + durcissement production `siop2-ai`
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
Reference in New Issue
Block a user