diff --git a/CLAUDE.md b/CLAUDE.md index 0a74fa4..f290328 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,5 +76,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R6.3 — Ressources (Stock, Tiers, Fichiers)** : `api/ressources.ts` (usePartners/useParts/usePart/useCreatePurchaseOrder/useDocuments) ; écran Stock (sous-seuil en tête) + fiche pièce + « Commander » pré-rempli en une ligne (fournisseur figé, quantité = manquant jusqu'au seuil, prix = dernier connu — le BC multi-lignes détaillé reste au web, D4) ; Tiers en lecture seule (création/édition réservées au web) ; Fichiers en métadonnées seules — l'ouverture/téléchargement demande `expo-sharing` (dépendance native absente, donc un nouveau build) et est explicitement différée plutôt qu'ajoutée à la légère. Menu branché. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché (toutes les opérations existaient déjà depuis R3). - ✅ **R6.4 — Pilotage (Statistiques, Personnes)** : `EXPO_PUBLIC_WEB_URL`/`WEB_URL` (lien d'activation vers la page web, pas d'équivalent mobile) ; `api/pilotage.ts` ; écran Statistiques (période 3/6/12, coût du mois, taux préventif, pannes par organe, top équipements — cartes plutôt que graphes, D4) ; écran Personnes & équipes (liste + statut, Inviter, lien d'activation en texte sélectionnable — `expo-clipboard` différée, même raisonnement que `expo-sharing` en R6.3 ; taux horaire/rôles/équipes restent au web). **Assistant reste à venir** dans le Menu — un chat sourcé est un nouveau patron d'écran jamais maquetté sur mobile, contrairement aux autres familles qui réutilisaient Carte/LigneInfo/EnteteFiche déjà validés ; mérite son propre tour de design-first. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché. - ✅ **Nav adaptative + correctif déconnexion confirmés par le référent sur iPhone physique** — dernier point ouvert depuis le début de R6, clos. -- 🔄 **Reprise ici — R6 quasi close** (Exploitation/Parc/Ressources/Pilotage livrés pour tous les rôles, hors Assistant) : reste une recette plus complète (Sites/Ascenseurs/Stock/Tiers/Statistiques/Personnes parcourus sur au moins 2 rôles non-technicien — pour l'instant chaque sous-étape n'a été vérifiée que par typecheck/tests/lint, jamais en usage réel au-delà de R6.1) avant de considérer R6 close au même sens que R0→R5 (chacune avait sa recette chiffrée avant tag) ; maquette dédiée à trancher pour l'Assistant mobile ; `expo-sharing` (ouverture de documents) et `expo-clipboard` (copie du lien d'activation) en réserve pour une prochaine étape native. Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »), recette Android sur appareil physique, calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire). +- ✅ **R6.5 — Assistant mobile (chat sourcé + dictée)** : maquette dédiée validée (3 écrans, D1-D5 — D5 ajoutée en revue : question tapée OU dictée, même pipeline que la dictée déjà livrée en clôture, transcription remplit le champ, jamais d'envoi automatique). `api/assistant.ts` (503 géré comme le web). Écran Assistant : chat un échange à la fois, citations numérotées, sources avec extrait exact (« Voir le document » → métadonnées seules, R6.3 ; « Ouvrir l'OT » → fiche R4), refus honnête chiffré + Reformuler, avertissement permanent. Fiche document (`bibliotheque/[id].tsx`, nouveau) — la liste R6.3 y mène aussi désormais. **Le Menu R6 n'a plus d'entrée « à venir »** dans les 4 groupes (Catégories exceptée, admin, hors périmètre mobile). Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché. +- 🔄 **Reprise ici — R6 fonctionnellement complet** (Exploitation/Parc/Ressources/Pilotage, Assistant inclus, pour tous les rôles) : reste une recette complète (parcourir tous les écrans sur au moins 2 rôles non-technicien — pour l'instant chaque sous-étape n'a été vérifiée que par typecheck/tests/lint, jamais en usage réel au-delà de la confirmation R6.1) avant de considérer R6 close au même sens que R0→R5 (chacune avait sa recette chiffrée avant tag) ; `expo-sharing` (ouverture de documents) et `expo-clipboard` (copie du lien d'activation) en réserve pour une prochaine étape native. Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »), recette Android sur appareil physique, calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire). - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5 (v1) + R6 en cours. diff --git a/apps/mobile/app/(tabs)/menu.tsx b/apps/mobile/app/(tabs)/menu.tsx index 11aeda4..f2ccc0e 100644 --- a/apps/mobile/app/(tabs)/menu.tsx +++ b/apps/mobile/app/(tabs)/menu.tsx @@ -54,7 +54,7 @@ const GROUPES: { titre: string; liens: LienMenu[] }[] = [ titre: 'Pilotage', liens: [ { libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], route: '/statistiques' }, - { libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], aVenir: 'L’assistant IA reste sur le grand écran et la clôture mobile pour l’instant — un chat sourcé est un nouveau patron d’écran, pas encore maquetté sur mobile.' }, + { libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], route: '/assistant' }, { libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], route: '/personnes' }, ], }, diff --git a/apps/mobile/app/assistant/index.tsx b/apps/mobile/app/assistant/index.tsx new file mode 100644 index 0000000..00d572b --- /dev/null +++ b/apps/mobile/app/assistant/index.tsx @@ -0,0 +1,372 @@ +import * as FileSystem from 'expo-file-system'; +import { router } from 'expo-router'; +import { useRef, useState } from 'react'; +import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import { + RecordingPresets, + requestRecordingPermissionsAsync, + setAudioModeAsync, + useAudioRecorder, +} from 'expo-audio'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import type { AssistantAnswer, AssistantExcerpt } from '@siop/shared'; +import { useAskAssistant } from '@/api/assistant'; +import { useTranscription } from '@/api/exploitation'; +import { BoutonTel, EnteteFiche } from '@/composants/ui'; +import { useTokens, type Tokens } from '@/theme/tokens'; + +/** Assistant — maquette « Assistant mobile » (validée 02/08) : même contenu + * et mêmes règles que le web (D2 « sourcé ou silencieux », ADR-004 §4 — + * le service IA n'est jamais appelé directement, toujours via l'API). + * D5 : la question peut être tapée OU dictée — même pipeline que la + * dictée déjà livrée en clôture (ADR-004 §5), la transcription REMPLIT le + * champ (éditable), aucun envoi automatique. */ + +interface Echange { + question: string; + reponse?: AssistantAnswer; + erreur?: string; +} + +export default function PageAssistant() { + const t = useTokens(); + const ask = useAskAssistant(); + const transcrire = useTranscription(); + const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); + const [question, setQuestion] = useState(''); + const [echanges, setEchanges] = useState([]); + const [enregistrement, setEnregistrement] = useState(false); + const saisieRef = useRef(null); + + const demarrerDictee = async () => { + const { granted } = await requestRecordingPermissionsAsync(); + if (!granted) return; + // iOS refuse recorder.record() sans autorisation explicite de la session + // audio (RecordingDisabledException) — même correctif qu'en clôture. + await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true }); + await recorder.prepareToRecordAsync(); + recorder.record(); + setEnregistrement(true); + }; + + const terminerDictee = async () => { + await recorder.stop(); + await setAudioModeAsync({ allowsRecording: false }); + setEnregistrement(false); + const uri = recorder.uri; + if (!uri) return; + transcrire.mutate( + { uri, nom: 'question.m4a', mime: 'audio/m4a' }, + { + onSuccess: (resultat) => setQuestion(resultat.text), + // Loi 09-08 (D5) : l'audio ne survit jamais à l'appel, succès ou pas. + onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }), + }, + ); + }; + + 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 ( + + + + + {echanges.length === 0 ? ( + + Posez une question sur vos notices, vos historiques d'intervention ou vos + procédures — chaque réponse cite ses sources. Quand le corpus ne porte pas la + réponse, l'assistant le dit au lieu d'inventer. + + ) : null} + {echanges.map((e, i) => ( + + + {e.question} + + {e.reponse ? ( + saisieRef.current?.focus()} /> + ) : e.erreur ? ( + + {e.erreur} + + ) : ( + + Recherche dans le corpus… + + )} + + ))} + + + {enregistrement ? ( + + + 🎙 + + + Enregistrement en cours… + + void terminerDictee()} + /> + + ) : ( + + void demarrerDictee()} + style={{ + width: 26, + height: 26, + borderRadius: 13, + backgroundColor: t.primaireDoux, + alignItems: 'center', + justifyContent: 'center', + }} + > + 🎙 + + + + + Envoyer + + + + )} + {transcrire.isError ? ( + + {transcrire.error.message} + + ) : null} + + + ); +} + +function Reponse({ + reponse, + t, + surReformuler, +}: { + reponse: AssistantAnswer; + t: Tokens; + surReformuler: () => void; +}) { + if (reponse.mode === 'REFUSAL') { + return ( + + + Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer. + + + 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. + + + + ); + } + return ( + + {reponse.mode === 'GENERATED' && reponse.answer ? ( + + {reponse.answer} + + ) : ( + + Voici ce que portent vos sources — les extraits sont cités tels quels. + + )} + + {reponse.excerpts.map((ex, i) => ( + + ))} + + + + ⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil. + + + + ); +} + +function Source({ ex, no, t }: { ex: AssistantExcerpt; no: number; t: Tokens }) { + const ouvrir = () => { + if (ex.sourceType === 'DOCUMENT' && ex.documentId) router.push(`/bibliotheque/${ex.documentId}`); + else if (ex.workOrderId) router.push(`/ot/${ex.workOrderId}`); + }; + return ( + + + + {no} + + + {ex.title} + + + + {ex.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {ex.locator} + + + « {ex.content} » + + + {ex.sourceType === 'DOCUMENT' ? 'Voir le document' : "Ouvrir l'OT"} + + + ); +} diff --git a/apps/mobile/app/bibliotheque/[id].tsx b/apps/mobile/app/bibliotheque/[id].tsx new file mode 100644 index 0000000..5642cb4 --- /dev/null +++ b/apps/mobile/app/bibliotheque/[id].tsx @@ -0,0 +1,52 @@ +import { useLocalSearchParams } from 'expo-router'; +import { ScrollView, Text } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useDocuments } from '@/api/ressources'; +import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui'; +import { useTokens } from '@/theme/tokens'; + +const KIND_LABEL: Record = { + NOTICE: 'Notice', + CERTIFICATE: 'Certificat', + PHOTO: 'Photo', + OTHER: 'Autre', +}; + +function taille(octets: number): string { + if (octets < 1024) return `${octets} o`; + if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`; + return `${(octets / (1024 * 1024)).toFixed(1)} Mo`; +} + +/** Fiche document — R6.5. Métadonnées seules (D2, comme la bibliothèque + * R6.3) : l'ouverture du fichier lui-même demande `expo-sharing`, pas + * encore une dépendance du projet — ouvrir depuis le web pour l'instant. + * Destination de « Voir le document » depuis les sources de l'Assistant. */ +export default function PageFicheDocument() { + const t = useTokens(); + const { id } = useLocalSearchParams<{ id: string }>(); + const { data: documents } = useDocuments(); + const doc = (documents ?? []).find((d) => d.id === id); + if (!doc) return null; + + return ( + + + + + + + + + + + + Ouvrir le fichier se fait depuis le web pour l'instant. + + + + ); +} diff --git a/apps/mobile/app/bibliotheque/index.tsx b/apps/mobile/app/bibliotheque/index.tsx index 9335fd3..4590546 100644 --- a/apps/mobile/app/bibliotheque/index.tsx +++ b/apps/mobile/app/bibliotheque/index.tsx @@ -1,4 +1,5 @@ -import { FlatList, Text, View } from 'react-native'; +import { router } from 'expo-router'; +import { FlatList, Pressable, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDocuments } from '@/api/ressources'; import { EnteteFiche } from '@/composants/ui'; @@ -48,7 +49,9 @@ export default function PageBibliotheque() { } renderItem={({ item: d }) => ( - router.push(`/bibliotheque/${d.id}`)} style={{ flexDirection: 'row', alignItems: 'center', @@ -71,7 +74,7 @@ export default function PageBibliotheque() { .join(' · ')} - + )} /> diff --git a/apps/mobile/src/api/assistant.ts b/apps/mobile/src/api/assistant.ts new file mode 100644 index 0000000..029c1f6 --- /dev/null +++ b/apps/mobile/src/api/assistant.ts @@ -0,0 +1,26 @@ +import { useMutation } from '@tanstack/react-query'; +import type { AssistantAsk } from '@siop/shared'; +import { api } from './client'; + +/** Hook R6.5 — l'assistant passe par l'API NestJS (le service IA n'est + * jamais appelé depuis l'app, ADR-004 §4). Le 503 est un état ATTENDU du + * contrat (service éteint ou pas encore déployé) — même message que le web. */ + +async function unwrap(res: { data?: T; error?: unknown; response: Response }): Promise { + 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 })), + }); +} diff --git a/docs/02-design/maquettes/maquette-mobile-assistant.html b/docs/02-design/maquettes/maquette-mobile-assistant.html new file mode 100644 index 0000000..72bada6 --- /dev/null +++ b/docs/02-design/maquettes/maquette-mobile-assistant.html @@ -0,0 +1,355 @@ + + + + + +SIOP V2 — Maquette HD · Assistant mobile + + + + + +
+ SIOP · Maquette — Assistant mobile + +
+
+ + +

Intention : même contenu, mêmes règles que +l'Assistant web (maquette-r5, D2) — chaque affirmation porte ses citations numérotées, +chaque source montre le document ou l'historique, la localisation précise et l'extrait +exact. « Ouvrir l'OT » mène à la fiche R4 (déjà générique) ; « Voir le document » mène à ses +métadonnées (bibliothèque R6.3) — l'ouverture du fichier lui-même reste au web pour l'instant +(D2, `expo-sharing` non ajouté à la légère). L'avertissement est permanent, pas un disclaimer +caché.

+
+
+
+
+
Gestionnaire — question posée
+
+
Assistant
+
+
Quel couple de serrage pour les coulisseaux de guides du Gen2 ?
+
Recherche dans le corpus…
+
+
🎙Poser une question…Envoyer
+
+
+
+
+
Réponse sourcée + citations
+
+
Assistant
+
+
Quel couple de serrage pour les coulisseaux de guides du Gen2 ?
+
+

Pour l'Otis Gen2 Premier, la notice prescrit 25 N·m pour les vis de + fixation des coulisseaux1, jeu latéral max. 0,5 mm + 1. Appliqué sur l'ascenseur A1 le 17 juillet + 2.

+
+
+
1Notice Otis Gen2 Premier.pdf
+
Bibliothèque · Asc. A1 · page 42
+
« Serrer au couple de 25 N·m ; jeu latéral maximal : 0,5 mm. »
+
Voir le document
+
+
+
2OT-2026-0341 — Bruit anormal en gaine
+
Historique · clôturé le 17/07/2026
+
« Coulisseau de guide 16 mm × 2 — remplacement effectué. »
+
Ouvrir l'OT
+
+
+
⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.
+
+
+
🎙Poser une question…Envoyer
+
+
+
+
+ + + +
+
+
+
+
Refus honnête et chiffré
+
+
Assistant
+
+
Procédure de remplacement du variateur KONE ?
+
+ Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer. +
J'ai cherché dans 6 documents indexés et 214 bilans d'intervention : + aucune notice KONE, aucun historique de variateur sur ce parc.
+
Reformuler ma question
+
+
+
🎙Poser une question…Envoyer
+
+
+
+
+ + + +
+
+
+
+
Pendant l'enregistrement
+
+
Assistant
+
+
Posez votre question, notice ou historique…
+
+
+
🎙
+
+ + + +
+
« …quel couple de serrage pour les coulisseaux… »
+
■ Terminer la dictée
+
+
+
+
+
+
Transcrite — relecture avant envoi
+
+
Assistant
+
+
Posez votre question, notice ou historique…
+
+
+ 🎙 + Quel couple de serrage pour les coulisseaux de guides du Gen2 ? + Envoyer +
+
✓ Audio purgé (2,1 s après transcription) — relisez avant d'envoyer
+
+
+
+
+ + + + diff --git a/docs/journal/journal.md b/docs/journal/journal.md index b4d637b..fe14594 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,49 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-08-02 — Pr. Daaif (+ Claude) — R6.5 : Assistant mobile (chat sourcé + dictée) + +**Actions** + +- Maquette dédiée (`docs/02-design/maquettes/maquette-mobile-assistant.html`, 3 écrans) rédigée et + validée par le référent : réponse sourcée, refus honnête chiffré, et — demande ajoutée en cours + de revue — **dictée de la question**. Décisions actées : D1 un échange à la fois (pas + d'historique multi-tours conservé, même limite que le web) ; D2 « Ouvrir l'OT » → fiche R4 + telle quelle, « Voir le document » → métadonnées seules (bibliothèque R6.3, pas d'ouverture de + fichier tant qu'`expo-sharing` n'est pas ajouté) ; D3 refus chiffré + avertissement permanents, + identiques au web ; D4 Assistant rejoint le groupe Pilotage pour tout rôle avec + `WORK_ORDERS.view` ; **D5** la question peut être tapée OU dictée — même pipeline que la + dictée déjà livrée en clôture (ADR-004 §5, faster-whisper local, audio jamais persisté) — la + transcription REMPLIT le champ de saisie, éditable, jamais d'envoi automatique. +- `api/assistant.ts` (`useAskAssistant`, 503 géré comme le web — état attendu du contrat, pas une + erreur). +- Écran **Assistant** (`app/assistant/index.tsx`) : chat à un échange à la fois, citations + numérotées, cartes sources (extrait exact + « Voir le document »/« Ouvrir l'OT »), refus honnête + chiffré avec « Reformuler », avertissement permanent. Micro dans la barre de saisie : réutilise + telle quelle la mécanique de `cloture.tsx` (`useAudioRecorder`, `setAudioModeAsync`, + `useTranscription`, purge `FileSystem.deleteAsync` quoi qu'il arrive) — aucun nouveau bug à + découvrir, le pipeline est déjà recetté sur iPhone physique (01/08). +- Fiche document (`app/bibliotheque/[id].tsx`, nouveau) : métadonnées seules, destination de + « Voir le document » — la liste `bibliotheque/index.tsx` (R6.3) y mène aussi désormais (les + cartes étaient jusque-là décoratives, sans action). +- Menu : Assistant route réellement — **le Menu R6 n'a plus aucune entrée « à venir » dans les 4 + groupes** (Catégories excepté, admin, toujours hors périmètre mobile). +- Vérifié : typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché. + +**Décisions** + +- Voir D1-D5 ci-dessus (maquette dédiée, seule sous-étape de R6 à avoir eu son propre tour de + design-first plutôt qu'une exécution directe de la maquette R6 initiale). + +**Prochaine étape** : **R6 fonctionnellement complet** (Exploitation/Parc/Ressources/Pilotage, +Assistant inclus, pour tous les rôles). Reste la recette complète avant de considérer R6 close au +sens R0→R5 (chaque sous-étape n'a été vérifiée que par typecheck/tests/lint jusqu'ici, jamais en +usage réel au-delà de la confirmation R6.1) ; `expo-sharing`/`expo-clipboard` en réserve pour une +prochaine étape native ; restes non bloquants inchangés (Dokploy, Android physique, +`AI_SEUIL_*`/darija, `DOKPLOY_WEBHOOK_URL`). + +--- + ## 2026-08-02 — Pr. Daaif (+ Claude) — Confirmation référent : nav adaptative + déconnexion **Actions**