mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
@@ -44,14 +44,15 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm --filter @siop/shared build
|
- run: pnpm --filter @siop/shared build
|
||||||
- name: Régénérer la spec et le client typé
|
- name: Régénérer la spec et les clients typés
|
||||||
run: |
|
run: |
|
||||||
pnpm --filter @siop/shared contract
|
pnpm --filter @siop/shared contract
|
||||||
pnpm --filter @siop/web generate:client
|
pnpm --filter @siop/web generate:client
|
||||||
|
pnpm --filter @siop/mobile generate:client
|
||||||
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
- name: Vérifier qu'aucun artefact ne dérive du contrat
|
||||||
run: |
|
run: |
|
||||||
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts; then
|
if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts apps/mobile/src/api/schema.d.ts; then
|
||||||
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client)."
|
echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client && pnpm --filter @siop/mobile generate:client)."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -190,8 +191,30 @@ jobs:
|
|||||||
- run: pnpm --filter @siop/api prisma:generate
|
- run: pnpm --filter @siop/api prisma:generate
|
||||||
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
- run: pnpm --filter @siop/api exec prisma migrate deploy
|
||||||
- run: pnpm --filter @siop/api build
|
- run: pnpm --filter @siop/api build
|
||||||
|
- uses: astral-sh/setup-uv@v5
|
||||||
|
- name: Démarrer le service IA (R5 — embeddeur déterministe, seuils CI)
|
||||||
|
working-directory: apps/ai
|
||||||
|
env:
|
||||||
|
AI_EMBEDDINGS: deterministe
|
||||||
|
AI_SERVICE_TOKEN: dev-only-ai-token
|
||||||
|
# L'embeddeur déterministe (tri-grammes hachés) produit des scores
|
||||||
|
# bien plus bas que le vrai modèle : seuils calibrés sur mesures
|
||||||
|
# réelles (vrai match ≈ 0,66 ; bruit de collisions ≈ 0,11 ; codes
|
||||||
|
# de bilan pertinents ≈ 0,14-0,16 ; parasites ≈ 0,08).
|
||||||
|
AI_SEUIL_PERTINENCE: '0.20'
|
||||||
|
AI_SEUIL_SUGGESTION: '0.10'
|
||||||
|
AI_SEUIL_CONFIANCE_FORTE: '0.30'
|
||||||
|
run: |
|
||||||
|
uv sync
|
||||||
|
nohup uv run uvicorn siop_ai.app:app --host 127.0.0.1 --port 8000 > /tmp/siop-ai.log 2>&1 &
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -fsS http://127.0.0.1:8000/healthz && break; sleep 1;
|
||||||
|
done
|
||||||
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
|
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
|
||||||
- run: pnpm --filter @siop/web e2e
|
- run: pnpm --filter @siop/web e2e
|
||||||
|
- name: Journal du service IA en cas d'échec
|
||||||
|
if: failure()
|
||||||
|
run: cat /tmp/siop-ai.log || true
|
||||||
- name: Traces Playwright en cas d'échec
|
- name: Traces Playwright en cas d'échec
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -61,5 +61,7 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
|
- 🏁 **R4 CLOSE (17/07/2026, tag `release/r4`)** : posé sur décision du référent avec la recette « mode avion » validée 13/13 en Expo web piloté ; **la recette sur téléphone (Expo Go — vrai mode avion, scan caméra) est reportée et reste due avant toute production client mobile**.
|
||||||
- ✅ **R5 — maquettes rédigées** (17/07) : `maquette-r5.html`, 6 écrans (assistant RAG à citations sources document/page/extrait, refus explicite hors corpus, suggestion de bilan web+mobile à validation humaine, corpus & ingestion administrable, voix opt-in avec purge) + 5 décisions à acter (aucune écriture automatique, sourcé ou silencieux, corpus fermé, anonymisation 09-08, voix purgée).
|
- ✅ **R5 — maquettes rédigées** (17/07) : `maquette-r5.html`, 6 écrans (assistant RAG à citations sources document/page/extrait, refus explicite hors corpus, suggestion de bilan web+mobile à validation humaine, corpus & ingestion administrable, voix opt-in avec purge) + 5 décisions à acter (aucune écriture automatique, sourcé ou silencieux, corpus fermé, anonymisation 09-08, voix purgée).
|
||||||
- ✅ **R5 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R5.1 socle `apps/ai`** : ADR-004 (embeddings locaux fastembed 384d, pgvector via migration Prisma `r5_ia` (`RagChunk` + corpus sur Document), génération opt-in — mode extractif par défaut, service jamais exposé joint par l'API seule), pipeline d'ingestion anonymisé D4 (fonction pure testée, PDF paginés + bilans codés), `/internal/reindex` + `/internal/search` sous jeton de service, 14 pytest + ruff + job CI `ai` (embeddeur déterministe en CI). Vérifié en réel : corpus seedé indexé en 7 s, recherche sémantique concluante, 0 identité dans les chunks.
|
- ✅ **R5 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R5.1 socle `apps/ai`** : ADR-004 (embeddings locaux fastembed 384d, pgvector via migration Prisma `r5_ia` (`RagChunk` + corpus sur Document), génération opt-in — mode extractif par défaut, service jamais exposé joint par l'API seule), pipeline d'ingestion anonymisé D4 (fonction pure testée, PDF paginés + bilans codés), `/internal/reindex` + `/internal/search` sous jeton de service, 14 pytest + ruff + job CI `ai` (embeddeur déterministe en CI). Vérifié en réel : corpus seedé indexé en 7 s, recherche sémantique concluante, 0 identité dans les chunks.
|
||||||
- 🔄 **Reprise ici** : R5.2 — assistant au contrat (proxy NestJS authentifié → `siop2-ai`, « sourcé ou silencieux » en mode extractif) + suggestion de codes de bilan. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
|
- ✅ **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).
|
||||||
|
- 🔄 **Reprise ici** : recette R5 (doit passer SANS clé API) → durcissement production (Dockerfile `siop2-ai`, compose Dokploy, calibrage des seuils sur corpus client) → tag `release/r5`. Restes : recette R4 sur téléphone (Expo Go), redéploiement Dokploy de `release/r3`, secret `DOKPLOY_WEBHOOK_URL`.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -78,7 +78,12 @@ async def demander(corps: RequeteRecherche) -> dict:
|
|||||||
(ce qui a été cherché) — la rédaction n'existe qu'en mode génératif."""
|
(ce qui a été cherché) — la rédaction n'existe qu'en mode génératif."""
|
||||||
async with app.state.pool.acquire() as cnx:
|
async with app.state.pool.acquire() as cnx:
|
||||||
reponse = await repondre(
|
reponse = await repondre(
|
||||||
cnx, app.state.embeddeur, app.state.generateur, corps.question, corps.limite
|
cnx,
|
||||||
|
app.state.embeddeur,
|
||||||
|
app.state.generateur,
|
||||||
|
corps.question,
|
||||||
|
corps.limite,
|
||||||
|
seuil_pertinence=app.state.reglages.ai_seuil_pertinence,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"mode": reponse.mode,
|
"mode": reponse.mode,
|
||||||
@@ -100,5 +105,11 @@ async def suggerer(corps: RequeteSuggestion) -> dict:
|
|||||||
"""Suggestion de codes de bilan (D1) : uniquement des codes EXISTANTS,
|
"""Suggestion de codes de bilan (D1) : uniquement des codes EXISTANTS,
|
||||||
avec confiance et « N bilans similaires » — l'humain applique, ou pas."""
|
avec confiance et « N bilans similaires » — l'humain applique, ou pas."""
|
||||||
async with app.state.pool.acquire() as cnx:
|
async with app.state.pool.acquire() as cnx:
|
||||||
suggestions = await suggerer_bilan(cnx, app.state.embeddeur, corps.description)
|
suggestions = await suggerer_bilan(
|
||||||
|
cnx,
|
||||||
|
app.state.embeddeur,
|
||||||
|
corps.description,
|
||||||
|
seuil_suggestion=app.state.reglages.ai_seuil_suggestion,
|
||||||
|
seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte,
|
||||||
|
)
|
||||||
return {"suggestions": [asdict(s) for s in suggestions]}
|
return {"suggestions": [asdict(s) for s in suggestions]}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ CHAMPS_BILAN = {
|
|||||||
"COMPONENT_CONCERNED": "élément concerné",
|
"COMPONENT_CONCERNED": "élément concerné",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Défauts — surchargés par la config (AI_SEUIL_*) : calibrage en recette.
|
||||||
SEUIL_PERTINENCE = 0.30 # en dessous : le corpus ne porte pas la réponse
|
SEUIL_PERTINENCE = 0.30 # en dessous : le corpus ne porte pas la réponse
|
||||||
SEUIL_SUGGESTION = 0.35
|
SEUIL_SUGGESTION = 0.35
|
||||||
SEUIL_CONFIANCE_FORTE = 0.55
|
SEUIL_CONFIANCE_FORTE = 0.55
|
||||||
@@ -61,10 +62,11 @@ async def repondre(
|
|||||||
generateur: Generateur,
|
generateur: Generateur,
|
||||||
question: str,
|
question: str,
|
||||||
limite: int = 5,
|
limite: int = 5,
|
||||||
|
seuil_pertinence: float = SEUIL_PERTINENCE,
|
||||||
) -> ReponseAssistant:
|
) -> ReponseAssistant:
|
||||||
documents, bilans = await _taille_corpus(cnx)
|
documents, bilans = await _taille_corpus(cnx)
|
||||||
extraits = await chercher(cnx, embeddeur, question, limite)
|
extraits = await chercher(cnx, embeddeur, question, limite)
|
||||||
pertinents = [e for e in extraits if e.score >= SEUIL_PERTINENCE]
|
pertinents = [e for e in extraits if e.score >= seuil_pertinence]
|
||||||
|
|
||||||
if not pertinents:
|
if not pertinents:
|
||||||
# D2 : refus explicite — on dit ce qu'on a cherché, on n'invente rien.
|
# D2 : refus explicite — on dit ce qu'on a cherché, on n'invente rien.
|
||||||
@@ -104,6 +106,8 @@ async def suggerer_bilan(
|
|||||||
cnx: asyncpg.Connection,
|
cnx: asyncpg.Connection,
|
||||||
embeddeur: Embeddeur,
|
embeddeur: Embeddeur,
|
||||||
description: str,
|
description: str,
|
||||||
|
seuil_suggestion: float = SEUIL_SUGGESTION,
|
||||||
|
seuil_confiance_forte: float = SEUIL_CONFIANCE_FORTE,
|
||||||
) -> list[SuggestionBilan]:
|
) -> list[SuggestionBilan]:
|
||||||
valeurs = await cnx.fetch(
|
valeurs = await cnx.fetch(
|
||||||
'SELECT id, field, label FROM "ReferenceValue" WHERE "isActive" ORDER BY field, label'
|
'SELECT id, field, label FROM "ReferenceValue" WHERE "isActive" ORDER BY field, label'
|
||||||
@@ -122,7 +126,7 @@ async def suggerer_bilan(
|
|||||||
meilleurs: dict[str, tuple[asyncpg.Record, float]] = {}
|
meilleurs: dict[str, tuple[asyncpg.Record, float]] = {}
|
||||||
for valeur, vecteur in zip(valeurs, v_valeurs):
|
for valeur, vecteur in zip(valeurs, v_valeurs):
|
||||||
score = _cosinus(v_description, vecteur)
|
score = _cosinus(v_description, vecteur)
|
||||||
if score < SEUIL_SUGGESTION:
|
if score < seuil_suggestion:
|
||||||
continue
|
continue
|
||||||
champ = valeur["field"]
|
champ = valeur["field"]
|
||||||
if champ not in meilleurs or score > meilleurs[champ][1]:
|
if champ not in meilleurs or score > meilleurs[champ][1]:
|
||||||
@@ -140,7 +144,7 @@ async def suggerer_bilan(
|
|||||||
field=valeur["field"],
|
field=valeur["field"],
|
||||||
value_id=str(valeur["id"]),
|
value_id=str(valeur["id"]),
|
||||||
label=valeur["label"],
|
label=valeur["label"],
|
||||||
confidence="FORTE" if score >= SEUIL_CONFIANCE_FORTE else "MOYENNE",
|
confidence="FORTE" if score >= seuil_confiance_forte else "MOYENNE",
|
||||||
similar_reports=similaires,
|
similar_reports=similaires,
|
||||||
score=round(score, 4),
|
score=round(score, 4),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class Reglages(BaseSettings):
|
|||||||
ai_generation: str = "off"
|
ai_generation: str = "off"
|
||||||
ai_api_key: str = "" # requise seulement si ai_generation=api — jamais loguée
|
ai_api_key: str = "" # requise seulement si ai_generation=api — jamais loguée
|
||||||
ai_model: str = "claude-opus-4-8"
|
ai_model: str = "claude-opus-4-8"
|
||||||
|
# Seuils de similarité — constantes de départ, calibrables par env
|
||||||
|
# (recette sur corpus réel ; abaissés en CI e2e — embeddeur déterministe).
|
||||||
|
ai_seuil_pertinence: float = 0.30
|
||||||
|
ai_seuil_suggestion: float = 0.35
|
||||||
|
ai_seuil_confiance_forte: float = 0.55
|
||||||
|
|
||||||
model_config = {"env_prefix": "", "case_sensitive": False}
|
model_config = {"env_prefix": "", "case_sensitive": False}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export class AssistantController {
|
|||||||
return this.assistant.ask(body);
|
return this.assistant.ask(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Réindexer le corpus — même droit que la gestion du référentiel (D3). */
|
||||||
|
@Post('reindex')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
reindex() {
|
||||||
|
return this.assistant.reindex();
|
||||||
|
}
|
||||||
|
|
||||||
/** Suggérer des codes — réservé à qui remplit des bilans (D1). */
|
/** Suggérer des codes — réservé à qui remplit des bilans (D1). */
|
||||||
@Post('suggest-bilan')
|
@Post('suggest-bilan')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AssistantAsk,
|
AssistantAsk,
|
||||||
BilanField,
|
BilanField,
|
||||||
BilanSuggestionsResponse,
|
BilanSuggestionsResponse,
|
||||||
|
ReindexResult,
|
||||||
SuggestBilan,
|
SuggestBilan,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { loadEnv } from '../config/env';
|
import { loadEnv } from '../config/env';
|
||||||
@@ -81,6 +82,21 @@ export class AssistantService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reindex(): Promise<ReindexResult> {
|
||||||
|
const brut = await this.appeler<{
|
||||||
|
documents_indexes: number;
|
||||||
|
documents_ignores: number;
|
||||||
|
bilans_indexes: number;
|
||||||
|
extraits: number;
|
||||||
|
}>('/internal/reindex', {});
|
||||||
|
return {
|
||||||
|
documentsIndexed: brut.documents_indexes,
|
||||||
|
documentsSkipped: brut.documents_ignores,
|
||||||
|
reportsIndexed: brut.bilans_indexes,
|
||||||
|
chunks: brut.extraits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async suggestBilan(dto: SuggestBilan): Promise<BilanSuggestionsResponse> {
|
async suggestBilan(dto: SuggestBilan): Promise<BilanSuggestionsResponse> {
|
||||||
const brut = await this.appeler<{
|
const brut = await this.appeler<{
|
||||||
suggestions: {
|
suggestions: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
@@ -14,11 +15,17 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
|
import {
|
||||||
|
DOCUMENT_MAX_BYTES,
|
||||||
|
DocumentCorpusUpdateSchema,
|
||||||
|
type DocumentCorpusUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
import {
|
import {
|
||||||
AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
} from '../auth/current-user.decorator';
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { DocumentsService } from './documents.service';
|
import { DocumentsService } from './documents.service';
|
||||||
|
|
||||||
@Controller('documents')
|
@Controller('documents')
|
||||||
@@ -69,6 +76,16 @@ export class DocumentsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Corpus IA (D3) : réservé aux gestionnaires du référentiel. */
|
||||||
|
@Patch(':id/corpus')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
setCorpus(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(DocumentCorpusUpdateSchema)) body: DocumentCorpusUpdate,
|
||||||
|
) {
|
||||||
|
return this.documents.setCorpus(id, body.inCorpus);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ export class DocumentsService {
|
|||||||
workOrderReference: row.workOrder?.reference ?? null,
|
workOrderReference: row.workOrder?.reference ?? null,
|
||||||
uploadedByName: row.uploadedBy?.displayName ?? null,
|
uploadedByName: row.uploadedBy?.displayName ?? null,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
inCorpus: row.inCorpus,
|
||||||
|
indexedAt: row.indexedAt?.toISOString() ?? null,
|
||||||
|
chunkCount: row.chunkCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Interrupteur corpus (D3) — effectif à la prochaine réindexation. */
|
||||||
|
async setCorpus(id: string, inCorpus: boolean): Promise<DocumentDto> {
|
||||||
|
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException('Document inconnu');
|
||||||
|
const updated = await this.prisma.document.update({
|
||||||
|
where: { id },
|
||||||
|
data: { inCorpus },
|
||||||
|
include: documentInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,12 +42,20 @@ const REPONSE_SUGGEST = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REPONSE_REINDEX = {
|
||||||
|
documents_indexes: 6,
|
||||||
|
documents_ignores: 2,
|
||||||
|
bilans_indexes: 214,
|
||||||
|
extraits: 180,
|
||||||
|
};
|
||||||
|
|
||||||
describe('Assistant (e2e — stub du service IA)', () => {
|
describe('Assistant (e2e — stub du service IA)', () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let stub: Server;
|
let stub: Server;
|
||||||
let ahmed: string; // Technicien : view + edit sur WORK_ORDERS
|
let ahmed: string; // Technicien : view + edit sur WORK_ORDERS, ASSETS en lecture
|
||||||
let karim: string; // Demandeur : aucun droit WORK_ORDERS
|
let karim: string; // Demandeur : aucun droit WORK_ORDERS
|
||||||
let rachid: string; // Vue seule : view sans edit
|
let rachid: string; // Vue seule : view sans edit
|
||||||
|
let nadia: string; // Gestionnaire : ASSETS.edit — administre le corpus
|
||||||
const requetesRecues: { url: string; jeton: string | undefined }[] = [];
|
const requetesRecues: { url: string; jeton: string | undefined }[] = [];
|
||||||
const http = () => request(app.getHttpServer());
|
const http = () => request(app.getHttpServer());
|
||||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
@@ -62,6 +70,7 @@ describe('Assistant (e2e — stub du service IA)', () => {
|
|||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK));
|
if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK));
|
||||||
else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST));
|
else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST));
|
||||||
|
else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX));
|
||||||
else {
|
else {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end('{}');
|
res.end('{}');
|
||||||
@@ -87,6 +96,7 @@ describe('Assistant (e2e — stub du service IA)', () => {
|
|||||||
ahmed = await login('Technicien');
|
ahmed = await login('Technicien');
|
||||||
karim = await login('Demandeur');
|
karim = await login('Demandeur');
|
||||||
rachid = await login('Vue seule');
|
rachid = await login('Vue seule');
|
||||||
|
nadia = await login('Gestionnaire');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -146,6 +156,19 @@ describe('Assistant (e2e — stub du service IA)', () => {
|
|||||||
.expect(403);
|
.expect(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reindex : traduit le bilan d’indexation, réservé à ASSETS.edit (D3)', async () => {
|
||||||
|
const res = await http().post('/assistant/reindex').set(auth(nadia)).expect(200);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
documentsIndexed: 6,
|
||||||
|
documentsSkipped: 2,
|
||||||
|
reportsIndexed: 214,
|
||||||
|
chunks: 180,
|
||||||
|
});
|
||||||
|
expect(requetesRecues.at(-1)!.url).toBe('/internal/reindex');
|
||||||
|
// Ahmed (Technicien) lit le parc mais n'administre pas le corpus
|
||||||
|
await http().post('/assistant/reindex').set(auth(ahmed)).expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
it('question trop courte : 400 avant tout appel au service IA', async () => {
|
it('question trop courte : 400 avant tout appel au service IA', async () => {
|
||||||
const avant = requetesRecues.length;
|
const avant = requetesRecues.length;
|
||||||
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
|
await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400);
|
||||||
|
|||||||
@@ -87,6 +87,36 @@ describe('Bibliothèque & analytics (e2e)', () => {
|
|||||||
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
|
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('corpus (R5, D3) : nouveau document inclus par défaut, bascule réversible et gardée', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const envoye = await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'NOTICE')
|
||||||
|
.field('assetId', a1.id)
|
||||||
|
.attach('file', PNG_1PX, { filename: `corpus-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(201);
|
||||||
|
// le contrat expose l'état d'indexation — jamais indexé à la naissance
|
||||||
|
expect(envoye.body).toMatchObject({ inCorpus: true, indexedAt: null, chunkCount: 0 });
|
||||||
|
|
||||||
|
const exclu = await http()
|
||||||
|
.patch(`/documents/${envoye.body.id}/corpus`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ inCorpus: false })
|
||||||
|
.expect(200);
|
||||||
|
expect(exclu.body.inCorpus).toBe(false);
|
||||||
|
|
||||||
|
// Karim (Demandeur) n'administre pas le corpus
|
||||||
|
await http()
|
||||||
|
.patch(`/documents/${envoye.body.id}/corpus`)
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({ inCorpus: true })
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
|
||||||
|
});
|
||||||
|
|
||||||
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
||||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ScrollView, Text, View } from 'react-native';
|
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import {
|
import {
|
||||||
BILAN_FIELD_LABELS,
|
BILAN_FIELD_LABELS,
|
||||||
BILAN_FIELDS,
|
BILAN_FIELDS,
|
||||||
REQUIRED_BILAN_FIELDS,
|
REQUIRED_BILAN_FIELDS,
|
||||||
type BilanField,
|
type BilanField,
|
||||||
|
type BilanSuggestion,
|
||||||
type ReportUpsert,
|
type ReportUpsert,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useReferenceValues, useWorkOrder } from '@/api/exploitation';
|
import { useReferenceValues, useSuggestionBilan, useWorkOrder } from '@/api/exploitation';
|
||||||
import { useHorsLigne } from '@/auth/session';
|
import { useHorsLigne } from '@/auth/session';
|
||||||
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
|
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
|
||||||
import { enfilerBilan, enfilerTransition } from '@/file/actions';
|
import { enfilerBilan, enfilerTransition } from '@/file/actions';
|
||||||
@@ -95,6 +96,10 @@ export default function PageCloture() {
|
|||||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||||
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
|
||||||
|
<CarteSuggestion
|
||||||
|
horsLigne={horsLigne}
|
||||||
|
surApplication={(s) => setChoix((c) => ({ ...c, [s.field]: s.valueId }))}
|
||||||
|
/>
|
||||||
<Carte titre="Bilan d'intervention — requis pour clôturer">
|
<Carte titre="Bilan d'intervention — requis pour clôturer">
|
||||||
<View style={{ gap: 10 }}>
|
<View style={{ gap: 10 }}>
|
||||||
{[0, 2, 4].map((rang) => (
|
{[0, 2, 4].map((rang) => (
|
||||||
@@ -151,3 +156,118 @@ export default function PageCloture() {
|
|||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Écran 4 des maquettes R5 : décrire au pouce → chips suggérées (D1 — un
|
||||||
|
* appui = un choix humain, les chips ne font que pré-remplir les sélecteurs).
|
||||||
|
* L'IA est un service serveur : hors-ligne, la suggestion attend le réseau
|
||||||
|
* — la clôture en file R4, elle, n'en a pas besoin. */
|
||||||
|
function CarteSuggestion({
|
||||||
|
horsLigne,
|
||||||
|
surApplication,
|
||||||
|
}: {
|
||||||
|
horsLigne: boolean;
|
||||||
|
surApplication: (s: BilanSuggestion) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTokens();
|
||||||
|
const suggerer = useSuggestionBilan();
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [appliquees, setAppliquees] = useState<Set<BilanField>>(new Set());
|
||||||
|
const suggestions = suggerer.data?.suggestions ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Carte titre="Décrire pour suggérer (optionnel)">
|
||||||
|
<TextInput
|
||||||
|
multiline
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="Décrivez la panne et ce que vous avez fait…"
|
||||||
|
placeholderTextColor={t.encre3}
|
||||||
|
accessibilityLabel="Décrire pour suggérer"
|
||||||
|
style={{
|
||||||
|
minHeight: 64,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.bordureForte,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: t.surface,
|
||||||
|
padding: 9,
|
||||||
|
color: t.encre,
|
||||||
|
fontFamily: 'Manrope_500Medium',
|
||||||
|
fontSize: 13,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BoutonTel
|
||||||
|
libelle={
|
||||||
|
horsLigne
|
||||||
|
? 'Suggérer — réseau requis'
|
||||||
|
: suggerer.isPending
|
||||||
|
? 'Analyse…'
|
||||||
|
: '✨ Suggérer les codes'
|
||||||
|
}
|
||||||
|
variante="contour"
|
||||||
|
desactive={horsLigne || suggerer.isPending || description.trim().length < 10}
|
||||||
|
surAppui={() => {
|
||||||
|
setAppliquees(new Set());
|
||||||
|
suggerer.mutate(description.trim());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{suggerer.isError ? (
|
||||||
|
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
|
||||||
|
{suggerer.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{suggerer.isSuccess && suggestions.length === 0 ? (
|
||||||
|
<Text style={{ color: t.encre2, fontFamily: 'Manrope_500Medium', fontSize: 12 }}>
|
||||||
|
Aucun code assez proche — l’IA ne devine pas : choisissez dans les sélecteurs.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{suggestions.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const faite = appliquees.has(s.field);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={s.field}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Appliquer ${BILAN_FIELD_LABELS[s.field]} : ${s.label}`}
|
||||||
|
disabled={faite}
|
||||||
|
onPress={() => {
|
||||||
|
surApplication(s);
|
||||||
|
setAppliquees((avant) => new Set([...avant, s.field]));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: t.primaire,
|
||||||
|
backgroundColor: faite ? t.primaire : t.primaireDoux,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: faite ? '#fff' : t.primaire,
|
||||||
|
fontFamily: 'Manrope_700Bold',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{faite ? '✓' : '✨'} {BILAN_FIELD_LABELS[s.field]} : {s.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<Text style={{ color: t.encre3, fontFamily: 'Manrope_500Medium', fontSize: 11 }}>
|
||||||
|
Rien ne s’enregistre sans votre geste — les chips pré-remplissent les sélecteurs
|
||||||
|
ci-dessous.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Carte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ export function useCocheChecklist(otId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Suggestion R5 (D1) : l'IA est un service SERVEUR — la suggestion demande le
|
||||||
|
* réseau, la clôture en file R4 fonctionne sans elle. */
|
||||||
|
export function useSuggestionBilan() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (description: string) =>
|
||||||
|
unwrap(await api.POST('/assistant/suggest-bilan', { body: { description } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useBilan(otId: string) {
|
export function useBilan(otId: string) {
|
||||||
const invalide = useInvalideOT(otId);
|
const invalide = useInvalideOT(otId);
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
109
apps/mobile/src/api/schema.d.ts
vendored
109
apps/mobile/src/api/schema.d.ts
vendored
@@ -407,6 +407,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/documents/{id}/corpus": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/** Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation) */
|
||||||
|
patch: operations["updateDocumentCorpus"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/documents/{id}": {
|
"/documents/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -475,6 +492,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/assistant/reindex": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à l’ingestion — D4) */
|
||||||
|
post: operations["reindexAssistant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/search": {
|
"/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1437,6 +1471,9 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
Document: {
|
Document: {
|
||||||
@@ -1452,6 +1489,12 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
|
};
|
||||||
|
DocumentCorpusUpdate: {
|
||||||
|
inCorpus: boolean;
|
||||||
};
|
};
|
||||||
AnalyticsSummary: {
|
AnalyticsSummary: {
|
||||||
months: number;
|
months: number;
|
||||||
@@ -1518,6 +1561,12 @@ export interface components {
|
|||||||
SuggestBilan: {
|
SuggestBilan: {
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
ReindexResult: {
|
||||||
|
documentsIndexed: number;
|
||||||
|
documentsSkipped: number;
|
||||||
|
reportsIndexed: number;
|
||||||
|
chunks: number;
|
||||||
|
};
|
||||||
SearchResponse: {
|
SearchResponse: {
|
||||||
workOrders: {
|
workOrders: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -2990,6 +3039,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateDocumentCorpus: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DocumentCorpusUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Document mis à jour */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Document"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Inconnu */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
deleteDocument: {
|
deleteDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3101,6 +3183,33 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
reindexAssistant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Bilan d’indexation */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ReindexResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
globalSearch: {
|
globalSearch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
149
apps/web/e2e/parcours-r5.spec.ts
Normal file
149
apps/web/e2e/parcours-r5.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recette R5 (plan de releases) : la bibliothèque EST le corpus (bandeau
|
||||||
|
* 09-08, statut d'indexation, interrupteur, réindexation explicite) ; puis
|
||||||
|
* l'assistant « sourcé ou silencieux » (réponse citée depuis le PDF téléversé,
|
||||||
|
* refus honnête et chiffré sinon) ; enfin la suggestion de bilan sur un OT
|
||||||
|
* (codes existants, appliqués par le geste humain, liseré « suggéré »).
|
||||||
|
* Le service IA tourne avec l'embeddeur déterministe et des seuils abaissés
|
||||||
|
* (ci.yml) — c'est le CIRCUIT qui se recette, le vrai modèle se recette en
|
||||||
|
* local (journal R5.1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
/** PDF 1 page minimal mais VALIDE (xref calculée) — pypdf doit pouvoir en
|
||||||
|
* extraire le texte : c'est ce qui alimente l'index côté service IA. */
|
||||||
|
function pdfMinimal(texte: string): Buffer {
|
||||||
|
const contenu = `BT /F1 12 Tf 72 720 Td (${texte.replace(/[()\\]/g, '\\$&')}) Tj ET`;
|
||||||
|
const objets = [
|
||||||
|
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||||
|
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||||
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R ' +
|
||||||
|
'/Resources << /Font << /F1 5 0 R >> >> >>',
|
||||||
|
`<< /Length ${contenu.length} >>\nstream\n${contenu}\nendstream`,
|
||||||
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||||
|
];
|
||||||
|
let corps = '%PDF-1.4\n';
|
||||||
|
const offsets: number[] = [];
|
||||||
|
objets.forEach((objet, i) => {
|
||||||
|
offsets.push(corps.length);
|
||||||
|
corps += `${i + 1} 0 obj\n${objet}\nendobj\n`;
|
||||||
|
});
|
||||||
|
const debutXref = corps.length;
|
||||||
|
corps += `xref\n0 ${objets.length + 1}\n0000000000 65535 f \n`;
|
||||||
|
for (const offset of offsets) corps += `${String(offset).padStart(10, '0')} 00000 n \n`;
|
||||||
|
corps += `trailer\n<< /Size ${objets.length + 1} /Root 1 0 R >>\nstartxref\n${debutXref}\n%%EOF`;
|
||||||
|
return Buffer.from(corps, 'latin1');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: nom }).click();
|
||||||
|
await expect(page.locator('.topbar')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('recette R5 : corpus → réindexation → assistant sourcé → refus honnête', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000); // deux réindexations complètes du corpus réel
|
||||||
|
const fichier = `e2e-notice-${suffix}.pdf`;
|
||||||
|
|
||||||
|
// 1 · Nadia (gestionnaire, ASSETS.edit) — la bibliothèque est le corpus
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Fichiers' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /corpus de l'assistant/ })).toBeVisible();
|
||||||
|
await expect(page.locator('.avert')).toContainText('Loi 09-08'); // l'anonymisation est DITE
|
||||||
|
|
||||||
|
// 2 · Téléverser une notice PDF de test rattachée à A1
|
||||||
|
await page.getByRole('button', { name: 'Téléverser' }).click();
|
||||||
|
await page.getByLabel("Rattacher à l'appareil *").selectOption({ index: 1 });
|
||||||
|
await page.getByLabel('Choisir un fichier').setInputFiles({
|
||||||
|
name: fichier,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: pdfMinimal(
|
||||||
|
'Couple de serrage des coulisseaux de guides : 25 Nm. Verifier le jeu lateral.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: 'Téléverser', exact: true }).last().click();
|
||||||
|
const vignette = page.locator('.doc', { hasText: fichier });
|
||||||
|
await expect(vignette).toBeVisible();
|
||||||
|
await expect(vignette.locator('.st')).toHaveText('à indexer'); // jamais indexé à la naissance
|
||||||
|
|
||||||
|
// 3 · Réindexer tout — geste explicite, bilan chiffré, statut à jour
|
||||||
|
await page.getByRole('button', { name: 'Réindexer tout' }).click();
|
||||||
|
// l'ingestion réelle (MinIO + pypdf + embeddings) peut dépasser les 5 s
|
||||||
|
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
|
||||||
|
await expect(vignette.locator('.st')).toContainText('indexé ·');
|
||||||
|
await expect(vignette.locator('.st')).toContainText('extrait');
|
||||||
|
|
||||||
|
// 4 · L'assistant répond SOURCÉ depuis ce PDF (embeddeur déterministe :
|
||||||
|
// la question reprend les mots de la notice)
|
||||||
|
await page.getByRole('link', { name: 'Assistant' }).click();
|
||||||
|
await expect(page.getByText('répond UNIQUEMENT depuis votre bibliothèque')).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByLabel('Poser une question')
|
||||||
|
.fill('couple de serrage des coulisseaux de guides ?');
|
||||||
|
await page.getByRole('button', { name: 'Envoyer' }).click();
|
||||||
|
const source = page.locator('.source', { hasText: fichier });
|
||||||
|
await expect(source).toBeVisible();
|
||||||
|
await expect(source.locator('.extrait')).toContainText('25 Nm'); // l'extrait EXACT
|
||||||
|
await expect(source.locator('.ou')).toContainText('p. 1'); // la citation pointe la page
|
||||||
|
await expect(page.locator('.msg-r .avert')).toContainText('vous validez'); // pas un disclaimer caché
|
||||||
|
|
||||||
|
// 5 · Sans source au-dessus du seuil : refus honnête, chiffré, avec l'action utile
|
||||||
|
await page.getByLabel('Poser une question').fill('xylophone quantique zorglub ?');
|
||||||
|
await page.getByRole('button', { name: 'Envoyer' }).click();
|
||||||
|
const refus = page.locator('.refus');
|
||||||
|
await expect(refus).toBeVisible();
|
||||||
|
await expect(refus).toContainText('je préfère ne pas inventer');
|
||||||
|
await expect(refus).toContainText(/J'ai cherché dans \d+ documents? indexés? et \d+ bilans?/);
|
||||||
|
await expect(refus.getByRole('link', { name: /Téléverser la notice/ })).toBeVisible();
|
||||||
|
|
||||||
|
// 6 · L'interrupteur exclut la notice — réversible, effectif à la réindexation
|
||||||
|
await page.getByRole('link', { name: 'Fichiers' }).click();
|
||||||
|
await vignette.getByRole('switch').click();
|
||||||
|
await expect(vignette.locator('.st')).toHaveText('exclu du corpus');
|
||||||
|
await page.getByRole('button', { name: 'Réindexer tout' }).click();
|
||||||
|
await expect(page.getByText('Réindexation terminée')).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// Ménage : la notice de test sort de la bibliothèque
|
||||||
|
page.on('dialog', (d) => void d.accept());
|
||||||
|
await vignette.getByRole('button', { name: 'Supprimer' }).click();
|
||||||
|
await expect(vignette).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recette R5 : suggestion de bilan — codes existants, appliqués par l\'humain', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const titre = `E2E Suggestion ${suffix}`;
|
||||||
|
|
||||||
|
// 1 · Nadia crée un OT de dépannage sur A1
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Ordres de travail' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
|
||||||
|
await page.getByLabel('Objet *').fill(titre);
|
||||||
|
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||||
|
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: titre })).toBeVisible();
|
||||||
|
|
||||||
|
// 2 · Décrire pour suggérer — l'IA propose des codes EXISTANTS, justifiés
|
||||||
|
await page
|
||||||
|
.getByLabel('Décrire pour suggérer (optionnel)')
|
||||||
|
.fill('Frottement mécanique sur les portes, nettoyage et graissage effectués, essais OK.');
|
||||||
|
await page.getByRole('button', { name: /Suggérer les codes/ }).click();
|
||||||
|
const suggestions = page.locator('.suggestion');
|
||||||
|
await expect(suggestions.first()).toBeVisible();
|
||||||
|
await expect(page.locator('.confiance').first()).toContainText('confiance');
|
||||||
|
|
||||||
|
// 3 · « Appliquer les N » : les sélecteurs se pré-remplissent, liseré « suggéré »
|
||||||
|
await page.getByRole('button', { name: /Appliquer les \d/ }).click();
|
||||||
|
await expect(page.locator('.champ-b[data-suggere]').first()).toBeVisible();
|
||||||
|
const anomalie = page.locator('#bilan-ANOMALY');
|
||||||
|
await expect(anomalie).not.toHaveValue('');
|
||||||
|
|
||||||
|
// 4 · Un choix MANUEL retire le liseré du champ concerné (l'humain a repris la main)
|
||||||
|
const champAnomalie = page.locator('.champ-b', { has: anomalie });
|
||||||
|
await expect(champAnomalie).toHaveAttribute('data-suggere', 'true');
|
||||||
|
await anomalie.selectOption({ index: 1 });
|
||||||
|
await expect(champAnomalie).not.toHaveAttribute('data-suggere', 'true');
|
||||||
|
});
|
||||||
@@ -14,6 +14,10 @@ const API_ENV = {
|
|||||||
DATABASE_URL:
|
DATABASE_URL:
|
||||||
process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop',
|
process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop',
|
||||||
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||||
|
// R5 : le service IA écoute sur 8000 (CI : embeddeur déterministe,
|
||||||
|
// seuils abaissés — voir ci.yml ; localement : lancez apps/ai avant).
|
||||||
|
AI_SERVICE_URL: process.env.AI_SERVICE_URL ?? 'http://localhost:8000',
|
||||||
|
AI_SERVICE_TOKEN: process.env.AI_SERVICE_TOKEN ?? 'dev-only-ai-token',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Coquille } from '@/layout/coquille';
|
|||||||
import PageAchats from '@/pages/achats';
|
import PageAchats from '@/pages/achats';
|
||||||
import PageActivation from '@/pages/activation';
|
import PageActivation from '@/pages/activation';
|
||||||
import PageAscenseurs from '@/pages/ascenseurs';
|
import PageAscenseurs from '@/pages/ascenseurs';
|
||||||
|
import PageAssistant from '@/pages/assistant';
|
||||||
import PageBibliotheque from '@/pages/bibliotheque';
|
import PageBibliotheque from '@/pages/bibliotheque';
|
||||||
import PageCategories from '@/pages/categories';
|
import PageCategories from '@/pages/categories';
|
||||||
import PageCompteurs from '@/pages/compteurs';
|
import PageCompteurs from '@/pages/compteurs';
|
||||||
@@ -67,6 +68,7 @@ export default function App() {
|
|||||||
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
|
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
|
||||||
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
|
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
|
||||||
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
|
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
|
||||||
|
<Route path="/assistant" element={dansCoquille(<PageAssistant />)} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
57
apps/web/src/api/assistant.ts
Normal file
57
apps/web/src/api/assistant.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { AssistantAsk, SuggestBilan } from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
/** Hooks R5 — l'assistant passe par l'API NestJS (le service IA n'est jamais
|
||||||
|
* appelé du navigateur, ADR-004 §4). Le 503 est un état ATTENDU du contrat
|
||||||
|
* (service éteint ou pas encore déployé) : les écrans l'affichent posément. */
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.response.status === 503) {
|
||||||
|
throw new Error('Assistant indisponible pour le moment — réessayez dans un instant.');
|
||||||
|
}
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAskAssistant() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssistantAsk) =>
|
||||||
|
unwrap(await api.POST('/assistant/ask', { body })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSuggestBilan() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: SuggestBilan) =>
|
||||||
|
unwrap(await api.POST('/assistant/suggest-bilan', { body })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReindexAssistant() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => unwrap(await api.POST('/assistant/reindex')),
|
||||||
|
// la réindexation met à jour indexedAt/chunkCount de chaque document
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetDocumentCorpus() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { id: string; inCorpus: boolean }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH('/documents/{id}/corpus', {
|
||||||
|
params: { path: { id: input.id } },
|
||||||
|
body: { inCorpus: input.inCorpus },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['documents'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
109
apps/web/src/api/schema.d.ts
vendored
109
apps/web/src/api/schema.d.ts
vendored
@@ -407,6 +407,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/documents/{id}/corpus": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/** Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation) */
|
||||||
|
patch: operations["updateDocumentCorpus"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/documents/{id}": {
|
"/documents/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -475,6 +492,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/assistant/reindex": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à l’ingestion — D4) */
|
||||||
|
post: operations["reindexAssistant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/search": {
|
"/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1437,6 +1471,9 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
Document: {
|
Document: {
|
||||||
@@ -1452,6 +1489,12 @@ export interface components {
|
|||||||
uploadedByName: string | null;
|
uploadedByName: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
inCorpus: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
chunkCount: number;
|
||||||
|
};
|
||||||
|
DocumentCorpusUpdate: {
|
||||||
|
inCorpus: boolean;
|
||||||
};
|
};
|
||||||
AnalyticsSummary: {
|
AnalyticsSummary: {
|
||||||
months: number;
|
months: number;
|
||||||
@@ -1518,6 +1561,12 @@ export interface components {
|
|||||||
SuggestBilan: {
|
SuggestBilan: {
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
ReindexResult: {
|
||||||
|
documentsIndexed: number;
|
||||||
|
documentsSkipped: number;
|
||||||
|
reportsIndexed: number;
|
||||||
|
chunks: number;
|
||||||
|
};
|
||||||
SearchResponse: {
|
SearchResponse: {
|
||||||
workOrders: {
|
workOrders: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -2990,6 +3039,39 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateDocumentCorpus: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DocumentCorpusUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Document mis à jour */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Document"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Inconnu */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
deleteDocument: {
|
deleteDocument: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3101,6 +3183,33 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
reindexAssistant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Bilan d’indexation */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ReindexResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service IA indisponible */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
globalSearch: {
|
globalSearch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type DocumentDto,
|
type DocumentDto,
|
||||||
type DocumentKind,
|
type DocumentKind,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { useSetDocumentCorpus } from '@/api/assistant';
|
||||||
import {
|
import {
|
||||||
blobDocument,
|
blobDocument,
|
||||||
ouvrirDocument,
|
ouvrirDocument,
|
||||||
@@ -63,11 +64,62 @@ function ApercuVignette({ doc }: { doc: DocumentDto }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) {
|
/** Statut corpus (R5, D3) : lisible d'un coup d'œil, jamais ambigu. */
|
||||||
|
function StatutCorpus({ doc }: { doc: DocumentDto }) {
|
||||||
|
if (doc.contentType !== 'application/pdf') {
|
||||||
|
return <span className="st exclu">image — non indexable</span>;
|
||||||
|
}
|
||||||
|
if (!doc.inCorpus) return <span className="st exclu">exclu du corpus</span>;
|
||||||
|
if (!doc.indexedAt) return <span className="st encours">à indexer</span>;
|
||||||
|
const quand = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(doc.indexedAt));
|
||||||
|
return (
|
||||||
|
<span className="st ok">
|
||||||
|
indexé · {quand} · {doc.chunkCount} extrait{doc.chunkCount > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VignetteDoc({
|
||||||
|
doc,
|
||||||
|
corpus,
|
||||||
|
surSuppression,
|
||||||
|
}: {
|
||||||
|
doc: DocumentDto;
|
||||||
|
/** true = l'utilisateur administre le corpus (statut + interrupteur visibles). */
|
||||||
|
corpus?: boolean;
|
||||||
|
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} · ` : ''}
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ export const IcoStatistiques = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IcoAssistant = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M12 3l1.8 4.7L18.5 9l-4.7 1.8L12 15.5l-1.8-4.7L5.5 9l4.7-1.3z" />
|
||||||
|
<path d="M18.5 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const IcoPersonnes = () => (
|
export const IcoPersonnes = () => (
|
||||||
<svg {...base}>
|
<svg {...base}>
|
||||||
<circle cx="9" cy="8" r="3.5" />
|
<circle cx="9" cy="8" r="3.5" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { api } from '@/api/client';
|
|||||||
import { usePermissions } from '@/auth/use-permissions';
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
import {
|
import {
|
||||||
IcoAscenseurs,
|
IcoAscenseurs,
|
||||||
|
IcoAssistant,
|
||||||
IcoCategories,
|
IcoCategories,
|
||||||
IcoDemandes,
|
IcoDemandes,
|
||||||
IcoFichiers,
|
IcoFichiers,
|
||||||
@@ -75,6 +76,7 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
|||||||
groupe: 'Pilotage',
|
groupe: 'Pilotage',
|
||||||
liens: [
|
liens: [
|
||||||
{ libelle: 'Statistiques', icone: IcoStatistiques, route: '/statistiques', permission: ['ANALYTICS', 'view'] },
|
{ libelle: 'Statistiques', icone: IcoStatistiques, route: '/statistiques', permission: ['ANALYTICS', 'view'] },
|
||||||
|
{ libelle: 'Assistant', icone: IcoAssistant, route: '/assistant', permission: ['WORK_ORDERS', 'view'] },
|
||||||
{ libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
|
{ libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
166
apps/web/src/pages/assistant.tsx
Normal file
166
apps/web/src/pages/assistant.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
DOCUMENT_MAX_BYTES,
|
DOCUMENT_MAX_BYTES,
|
||||||
type DocumentKind,
|
type DocumentKind,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { useReindexAssistant } from '@/api/assistant';
|
||||||
import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
|
import { 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';
|
||||||
@@ -12,10 +13,12 @@ import { tailleLisible, VignetteDoc } 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';
|
||||||
|
|
||||||
/** Écran 7 des maquettes R3 : notices, certificats, photos — toujours
|
/** Écran 7 des maquettes R3, devenu écran 5 de R5 : la bibliothèque EST le
|
||||||
* RATTACHÉS (appareil ou OT). Ces documents nourriront le RAG en R5. */
|
* corpus de l'assistant — statut d'indexation visible, interrupteur
|
||||||
|
* d'exclusion réversible, réindexation par geste explicite (D3). */
|
||||||
export default function PageBibliotheque() {
|
export default function PageBibliotheque() {
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
const reindex = useReindexAssistant();
|
||||||
const [kind, setKind] = useState<DocumentKind | ''>('');
|
const [kind, setKind] = useState<DocumentKind | ''>('');
|
||||||
const [assetId, setAssetId] = useState('');
|
const [assetId, setAssetId] = useState('');
|
||||||
const { data: options } = useAssetOptions();
|
const { data: options } = useAssetOptions();
|
||||||
@@ -29,7 +32,9 @@ export default function PageBibliotheque() {
|
|||||||
const [survol, setSurvol] = useState(false);
|
const [survol, setSurvol] = useState(false);
|
||||||
|
|
||||||
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
|
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 peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
|
||||||
|
const administreCorpus = can('ASSETS', 'edit');
|
||||||
|
|
||||||
const surDepot = (e: DragEvent) => {
|
const surDepot = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -43,16 +48,37 @@ export default function PageBibliotheque() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="entete-page">
|
<div className="entete-page">
|
||||||
<h1>Bibliothèque</h1>
|
<h1>Bibliothèque — corpus de l'assistant</h1>
|
||||||
<span className="filajout">
|
<span className="filajout">
|
||||||
{documents?.length ?? 0} documents · {tailleLisible(totalOctets)}
|
{documents?.length ?? 0} documents · {indexes} indexés · {tailleLisible(totalOctets)}
|
||||||
</span>
|
</span>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
{administreCorpus ? (
|
||||||
|
<Button disabled={reindex.isPending} onClick={() => reindex.mutate()}>
|
||||||
|
{reindex.isPending ? 'Réindexation…' : 'Réindexer tout'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{peutEditer ? (
|
{peutEditer ? (
|
||||||
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
|
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="filtres">
|
||||||
<select
|
<select
|
||||||
aria-label="Filtrer par type"
|
aria-label="Filtrer par type"
|
||||||
@@ -99,6 +125,7 @@ export default function PageBibliotheque() {
|
|||||||
<VignetteDoc
|
<VignetteDoc
|
||||||
key={d.id}
|
key={d.id}
|
||||||
doc={d}
|
doc={d}
|
||||||
|
corpus={administreCorpus}
|
||||||
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
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={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
||||||
)}
|
)}
|
||||||
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
<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 corpus de l'assistant, c'est cette bibliothèque (PDF indexés) plus les bilans
|
||||||
le rattachement propre commence ici.
|
d'intervention codés — rien d'externe. Les réponses citent leurs sources.
|
||||||
</div>
|
</div>
|
||||||
{peutEditer ? (
|
{peutEditer ? (
|
||||||
<ModaleTeleversement
|
<ModaleTeleversement
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
REQUIRED_BILAN_FIELDS,
|
REQUIRED_BILAN_FIELDS,
|
||||||
WORK_ORDER_TYPE_LABELS,
|
WORK_ORDER_TYPE_LABELS,
|
||||||
type BilanField,
|
type BilanField,
|
||||||
|
type BilanSuggestion,
|
||||||
type WorkOrderDetail,
|
type WorkOrderDetail,
|
||||||
type WorkOrderStatus,
|
type WorkOrderStatus,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
|
import { useSuggestBilan } from '@/api/assistant';
|
||||||
import {
|
import {
|
||||||
useCommentWorkOrder,
|
useCommentWorkOrder,
|
||||||
usePatchChecklist,
|
usePatchChecklist,
|
||||||
@@ -478,10 +480,108 @@ const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail[
|
|||||||
{ champ: 'COMPONENT_CONCERNED', cle: 'componentConcerned', dto: 'componentConcernedId' },
|
{ 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 }) {
|
function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
|
||||||
const { data: valeurs } = useReferenceValues();
|
const { data: valeurs } = useReferenceValues();
|
||||||
const maj = useUpsertReport(ot.id);
|
const maj = useUpsertReport(ot.id);
|
||||||
const bloqueurBilan = ot.closureBlockers.find((b) => b.includes('bilan'));
|
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 (
|
return (
|
||||||
<div className="carte">
|
<div className="carte">
|
||||||
@@ -491,13 +591,19 @@ function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boole
|
|||||||
— requis pour clôturer
|
— requis pour clôturer
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
{modifiable ? (
|
||||||
|
<ZoneSuggestion
|
||||||
|
surAppliquer={(s) => appliquer([s])}
|
||||||
|
appliqueTout={appliquer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="bilan">
|
<div className="bilan">
|
||||||
{CHAMPS_BILAN.map(({ champ, cle, dto }) => {
|
{CHAMPS_BILAN.map(({ champ, cle, dto }) => {
|
||||||
const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive);
|
const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive);
|
||||||
const valeur = ot.report?.[cle as 'doorState'] ?? null;
|
const valeur = ot.report?.[cle as 'doorState'] ?? null;
|
||||||
const requis = REQUIRED_BILAN_FIELDS.includes(champ);
|
const requis = REQUIRED_BILAN_FIELDS.includes(champ);
|
||||||
return (
|
return (
|
||||||
<div className="champ-b" key={champ}>
|
<div className="champ-b" key={champ} data-suggere={suggeres.has(champ) || undefined}>
|
||||||
<label htmlFor={`bilan-${champ}`}>
|
<label htmlFor={`bilan-${champ}`}>
|
||||||
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
|
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
|
||||||
</label>
|
</label>
|
||||||
@@ -505,7 +611,15 @@ function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boole
|
|||||||
id={`bilan-${champ}`}
|
id={`bilan-${champ}`}
|
||||||
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
|
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
|
||||||
value={valeur?.id ?? ''}
|
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>
|
<option value="">Sélectionner…</option>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
|
|||||||
@@ -1016,3 +1016,91 @@ table {
|
|||||||
border: 1px solid var(--bordure);
|
border: 1px solid var(--bordure);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══ R5 · Assistant (chat sourcé — maquette-r5, écrans 1-2) ═══ */
|
||||||
|
.chat { display: flex; flex-direction: column; gap: 12px; max-width: 760px; }
|
||||||
|
.msg-q {
|
||||||
|
align-self: flex-end; background: var(--primaire); color: #fff;
|
||||||
|
border-radius: 14px 14px 4px 14px; padding: 10px 14px; max-width: 75%; font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.msg-r {
|
||||||
|
background: var(--surface); border: 1px solid var(--bordure);
|
||||||
|
border-radius: 14px 14px 14px 4px; padding: 12px 14px; max-width: 88%;
|
||||||
|
font-size: 13.5px; display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.msg-r p b { color: var(--encre); }
|
||||||
|
.cite {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px;
|
||||||
|
border-radius: 5px; background: var(--primaire-doux); color: var(--primaire);
|
||||||
|
font-size: 10.5px; font-weight: 800; vertical-align: 2px; margin: 0 1px;
|
||||||
|
}
|
||||||
|
.sources {
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
border-top: 1px dashed var(--bordure); padding-top: 10px;
|
||||||
|
}
|
||||||
|
.source {
|
||||||
|
display: flex; gap: 10px; align-items: flex-start; background: var(--surface-2);
|
||||||
|
border-radius: 9px; padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.source .no {
|
||||||
|
flex: none; width: 18px; height: 18px; border-radius: 5px; background: var(--primaire-doux);
|
||||||
|
color: var(--primaire); display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 11px; font-weight: 800;
|
||||||
|
}
|
||||||
|
.source b { font-size: 12.5px; }
|
||||||
|
.source .ou { color: var(--encre-2); font-size: 11.5px; }
|
||||||
|
.source .extrait {
|
||||||
|
color: var(--encre-2); font-size: 12px; font-style: italic;
|
||||||
|
border-left: 2px solid var(--safran); padding-left: 8px; margin-top: 3px;
|
||||||
|
}
|
||||||
|
.source .ouvrir {
|
||||||
|
margin-left: auto; color: var(--primaire); font-weight: 700; font-size: 12px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.avert {
|
||||||
|
display: flex; gap: 8px; align-items: center; background: var(--safran-doux); color: var(--alerte);
|
||||||
|
border-radius: 9px; padding: 8px 10px; font-size: 12px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.saisie-chat { display: flex; gap: 8px; max-width: 760px; }
|
||||||
|
.saisie-chat input {
|
||||||
|
flex: 1; border: 1.5px solid var(--bordure-forte); border-radius: 10px;
|
||||||
|
background: var(--surface); padding: 11px 13px; font: inherit; color: var(--encre);
|
||||||
|
}
|
||||||
|
.refus {
|
||||||
|
background: var(--surface); border: 1.5px dashed var(--bordure-forte); border-radius: 14px;
|
||||||
|
padding: 12px 14px; max-width: 88%; font-size: 13.5px;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.refus b { color: var(--encre); }
|
||||||
|
.refus .pourquoi { color: var(--encre-2); font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* ═══ R5 · Suggestion de bilan (écran 3) ═══ */
|
||||||
|
.zone-libre {
|
||||||
|
width: 100%; min-height: 74px; border: 1.5px solid var(--bordure-forte); border-radius: 10px;
|
||||||
|
background: var(--surface); padding: 10px 12px; font: inherit; font-size: 13px; color: var(--encre);
|
||||||
|
}
|
||||||
|
.suggestion {
|
||||||
|
display: flex; gap: 10px; align-items: flex-start; border: 1.5px solid var(--primaire);
|
||||||
|
background: var(--primaire-doux); border-radius: 10px; padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.suggestion .ia { flex: none; font-size: 15px; }
|
||||||
|
.suggestion b { font-size: 13px; }
|
||||||
|
.suggestion .just { color: var(--encre-2); font-size: 12px; }
|
||||||
|
.confiance {
|
||||||
|
margin-left: auto; font-size: 10.5px; font-weight: 800; color: var(--primaire); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.actions-sug { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
|
||||||
|
/* Liseré « suggéré » : la valeur vient d'une suggestion APPLIQUÉE par l'humain */
|
||||||
|
.champ-b[data-suggere] { position: relative; }
|
||||||
|
.champ-b[data-suggere] select { border-color: var(--primaire); background: var(--primaire-doux); }
|
||||||
|
.champ-b[data-suggere]::after {
|
||||||
|
content: 'suggéré'; position: absolute; top: 14px; right: 8px; font-size: 9px; font-weight: 800;
|
||||||
|
color: var(--primaire); background: var(--surface); padding: 0 5px; border-radius: 99px;
|
||||||
|
border: 1px solid var(--primaire); pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ R5 · Corpus (écran 5) ═══ */
|
||||||
|
.st.ok { color: var(--st-termine); }
|
||||||
|
.st.ok::before { background: var(--st-termine); }
|
||||||
|
.st.exclu { color: var(--encre-3); }
|
||||||
|
.st.exclu::before { background: var(--encre-3); }
|
||||||
|
.interrupteur.corpus[aria-checked='true'] { background: var(--succes); }
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R5.3 : les écrans de l'IA (web + mobile) et le corpus administrable
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **Contrat** (76 opérations) : `Document` expose son état de corpus (`inCorpus`, `indexedAt`, `chunkCount`), `PATCH /documents/{id}/corpus` (bascule réversible, ASSETS.edit), `POST /assistant/reindex` (bilan chiffré traduit du dialecte interne). Clients web **et mobile** régénérés — le job ci-contract vérifie désormais les deux.
|
||||||
|
- **Web — page Assistant** (sidebar Pilotage, WORK_ORDERS.view) : chat fidèle aux écrans 1-2 de maquette-r5 — réponse avec **extraits exacts cités** (« Ouvrir » → PDF en onglet authentifié, OT → fiche), avertissement « l'IA propose, vous validez » permanent ; **refus honnête chiffré** (« j'ai cherché dans N documents et M bilans ») avec l'action utile (téléverser la notice).
|
||||||
|
- **Web — Bibliothèque = corpus** (écran 5) : bandeau 09-08 (l'anonymisation est DITE), statut par document (indexé · date · extraits / à indexer / exclu / image non indexable), interrupteur d'exclusion (PDF seulement), « Réindexer tout » avec bilan. **Fiche OT** (écran 3) : « Décrire pour suggérer » dans la carte Bilan — suggestions justifiées (« N bilans similaires », confiance), « Appliquer » = le geste humain qui écrit (cohérent avec la carte à enregistrement direct), **liseré « suggéré »** retiré dès qu'un choix manuel reprend la main.
|
||||||
|
- **Mobile — clôture** (écran 4) : décrire au pouce → **chips suggérées** (un appui = un champ pré-rempli, ✓), note D1 visible ; hors-ligne le bouton dit « réseau requis » (l'IA est un service serveur — la clôture en file R4 n'en dépend pas).
|
||||||
|
- **`apps/ai`** : seuils par env (`AI_SEUIL_*`) — nécessaires à la CI et au calibrage de recette.
|
||||||
|
- **CI** : le job e2e démarre `siop2-ai` (embeddeur déterministe) et le parcours R5 se recette en vrai : upload d'un **PDF généré avec xref valide** → réindexation → statut → réponse sourcée (extrait « 25 Nm », p. 1) → refus sur charabia → exclusion → suggestion appliquée sur un OT créé. **16/16 Playwright** (recettes R0→R5).
|
||||||
|
- **Vérifications réelles** : seuils CI **mesurés** (vrai match 0,66 vs bruit de collisions 0,11 → pertinence 0,20 ; codes pertinents 0,14-0,16 vs parasite 0,08 → suggestion 0,10) ; chaîne complète au **vrai modèle ONNX** (réindexation, ask sourcé p. 8/p. 10, suggestions étagées avec vrais comptes de bilans) ; parcours **mobile Expo web 6/6** et **web 7/7** au vrai modèle, 0 erreur console. 78 tests API (2 nouveaux : bascule corpus gardée, reindex traduit + 403).
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- Sur le web, « Appliquer » écrit immédiatement (comme tout le reste de la carte Bilan R2) : le clic EST la validation humaine — l'esprit de la maquette (« rien sans vous ») est porté par le geste, pas par un bouton « Enregistrer » séparé. À montrer en revue pixel.
|
||||||
|
- Les seuils par défaut du vrai modèle (0,30/0,35/0,55) restent **à calibrer en recette sur le corpus client réel** : mesuré ce jour sur un corpus non-métier, le multilingual-MiniLM donne des scores plats (hors-sujet 0,35-0,43 vs pertinent 0,24-0,28) — c'est exactement pourquoi ils sont configurables.
|
||||||
|
|
||||||
|
**Prochaine étape** : recette R5 complète (doit passer SANS clé API), durcissement production (Dockerfile `siop2-ai` + compose Dokploy), puis tag `release/r5`. Restes : recette R4 sur appareil, redéploiement Dokploy de `release/r3`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-17 — Pr. Daaif (+ Claude) — R5.2 : l'assistant au contrat + suggestion de codes de bilan
|
## 2026-07-17 — Pr. Daaif (+ Claude) — R5.2 : l'assistant au contrat + suggestion de codes de bilan
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
@@ -1096,6 +1096,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/documents/{id}/corpus": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "updateDocumentCorpus",
|
||||||
|
"summary": "Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation)",
|
||||||
|
"tags": [
|
||||||
|
"documents"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DocumentCorpusUpdate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Document mis à jour",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Document"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Inconnu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/documents/{id}": {
|
"/documents/{id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"operationId": "deleteDocument",
|
"operationId": "deleteDocument",
|
||||||
@@ -1243,6 +1293,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/assistant/reindex": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "reindexAssistant",
|
||||||
|
"summary": "Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à l’ingestion — D4)",
|
||||||
|
"tags": [
|
||||||
|
"assistant"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Bilan d’indexation",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReindexResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service IA indisponible"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/search": {
|
"/search": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "globalSearch",
|
"operationId": "globalSearch",
|
||||||
@@ -4807,6 +4886,26 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
|
},
|
||||||
|
"inCorpus": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"indexedAt": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"chunkCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4818,7 +4917,10 @@
|
|||||||
"assetReference",
|
"assetReference",
|
||||||
"workOrderReference",
|
"workOrderReference",
|
||||||
"uploadedByName",
|
"uploadedByName",
|
||||||
"createdAt"
|
"createdAt",
|
||||||
|
"inCorpus",
|
||||||
|
"indexedAt",
|
||||||
|
"chunkCount"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
@@ -4892,6 +4994,26 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
|
},
|
||||||
|
"inCorpus": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"indexedAt": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"chunkCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4903,7 +5025,23 @@
|
|||||||
"assetReference",
|
"assetReference",
|
||||||
"workOrderReference",
|
"workOrderReference",
|
||||||
"uploadedByName",
|
"uploadedByName",
|
||||||
"createdAt"
|
"createdAt",
|
||||||
|
"inCorpus",
|
||||||
|
"indexedAt",
|
||||||
|
"chunkCount"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"DocumentCorpusUpdate": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"inCorpus": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"inCorpus"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
@@ -5256,6 +5394,39 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"ReindexResult": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"documentsIndexed": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"documentsSkipped": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"reportsIndexed": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
},
|
||||||
|
"chunks": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -9007199254740991,
|
||||||
|
"maximum": 9007199254740991
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"documentsIndexed",
|
||||||
|
"documentsSkipped",
|
||||||
|
"reportsIndexed",
|
||||||
|
"chunks"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"SearchResponse": {
|
"SearchResponse": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
} from './schemas/portail';
|
} from './schemas/portail';
|
||||||
import {
|
import {
|
||||||
AnalyticsSummarySchema,
|
AnalyticsSummarySchema,
|
||||||
|
DocumentCorpusUpdateSchema,
|
||||||
DocumentSchema,
|
DocumentSchema,
|
||||||
DocumentsResponseSchema,
|
DocumentsResponseSchema,
|
||||||
} from './schemas/documents';
|
} from './schemas/documents';
|
||||||
@@ -54,6 +55,7 @@ import {
|
|||||||
AssistantAnswerSchema,
|
AssistantAnswerSchema,
|
||||||
AssistantAskSchema,
|
AssistantAskSchema,
|
||||||
BilanSuggestionsResponseSchema,
|
BilanSuggestionsResponseSchema,
|
||||||
|
ReindexResultSchema,
|
||||||
SuggestBilanSchema,
|
SuggestBilanSchema,
|
||||||
} from './schemas/assistant';
|
} from './schemas/assistant';
|
||||||
import {
|
import {
|
||||||
@@ -491,6 +493,19 @@ export const API_CONTRACT: ApiOperation[] = [
|
|||||||
404: { description: 'Inconnu' },
|
404: { description: 'Inconnu' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateDocumentCorpus',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/documents/{id}/corpus',
|
||||||
|
summary: 'Inclure/exclure du corpus IA (D3 — réversible, effectif à la prochaine réindexation)',
|
||||||
|
tags: ['documents'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'DocumentCorpusUpdate', schema: DocumentCorpusUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Document mis à jour', name: 'Document', schema: DocumentSchema },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
operationId: 'deleteDocument',
|
operationId: 'deleteDocument',
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
@@ -538,6 +553,17 @@ export const API_CONTRACT: ApiOperation[] = [
|
|||||||
503: { description: 'Service IA indisponible' },
|
503: { description: 'Service IA indisponible' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
operationId: 'reindexAssistant',
|
||||||
|
method: 'post',
|
||||||
|
path: '/assistant/reindex',
|
||||||
|
summary: 'Réindexer le corpus (bibliothèque PDF + bilans clôturés, anonymisés à l’ingestion — D4)',
|
||||||
|
tags: ['assistant'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Bilan d’indexation', name: 'ReindexResult', schema: ReindexResultSchema },
|
||||||
|
503: { description: 'Service IA indisponible' },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
operationId: 'globalSearch',
|
operationId: 'globalSearch',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|||||||
@@ -60,3 +60,14 @@ export const BilanSuggestionsResponseSchema = z.object({
|
|||||||
suggestions: z.array(BilanSuggestionSchema),
|
suggestions: z.array(BilanSuggestionSchema),
|
||||||
});
|
});
|
||||||
export type BilanSuggestionsResponse = z.infer<typeof BilanSuggestionsResponseSchema>;
|
export type BilanSuggestionsResponse = z.infer<typeof BilanSuggestionsResponseSchema>;
|
||||||
|
|
||||||
|
// ————— Réindexation du corpus (bibliothèque = corpus, D3) —————
|
||||||
|
|
||||||
|
export const ReindexResultSchema = z.object({
|
||||||
|
documentsIndexed: z.number().int(),
|
||||||
|
/** Exclus du corpus (interrupteur) ou format non indexable (non-PDF). */
|
||||||
|
documentsSkipped: z.number().int(),
|
||||||
|
reportsIndexed: z.number().int(),
|
||||||
|
chunks: z.number().int(),
|
||||||
|
});
|
||||||
|
export type ReindexResult = z.infer<typeof ReindexResultSchema>;
|
||||||
|
|||||||
@@ -28,9 +28,18 @@ export const DocumentSchema = z.object({
|
|||||||
workOrderReference: z.string().nullable(),
|
workOrderReference: z.string().nullable(),
|
||||||
uploadedByName: z.string().nullable(),
|
uploadedByName: z.string().nullable(),
|
||||||
createdAt: z.iso.datetime(),
|
createdAt: z.iso.datetime(),
|
||||||
|
/** R5 (D3) : corpus visible et réversible, document par document. */
|
||||||
|
inCorpus: z.boolean(),
|
||||||
|
indexedAt: z.iso.datetime().nullable(), // null = jamais indexé
|
||||||
|
chunkCount: z.number().int(),
|
||||||
});
|
});
|
||||||
export type DocumentDto = z.infer<typeof DocumentSchema>;
|
export type DocumentDto = z.infer<typeof DocumentSchema>;
|
||||||
|
|
||||||
|
export const DocumentCorpusUpdateSchema = z.object({
|
||||||
|
inCorpus: z.boolean(),
|
||||||
|
});
|
||||||
|
export type DocumentCorpusUpdate = z.infer<typeof DocumentCorpusUpdateSchema>;
|
||||||
|
|
||||||
export const DocumentsResponseSchema = z.object({
|
export const DocumentsResponseSchema = z.object({
|
||||||
documents: z.array(DocumentSchema),
|
documents: z.array(DocumentSchema),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user