feat(r6): R6.5 — Assistant mobile (chat sourcé + dictée)

Maquette dédiée validée (docs/02-design/maquettes/maquette-mobile-assistant.html,
3 écrans) — la 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,
un chat sourcé étant un patron d'écran neuf. D1 un échange à la fois ; D2
"Voir le document" → métadonnées seules (R6.3), "Ouvrir l'OT" → fiche R4 ;
D3 refus chiffré + avertissement permanents, identiques au web ; D4
Assistant rejoint Pilotage pour tout rôle avec WORK_ORDERS.view ; D5
(ajoutée en revue) — 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, jamais d'envoi
automatique.

api/assistant.ts (useAskAssistant, 503 géré comme le web). Écran Assistant :
chat un échange à la fois, citations numérotées, sources avec extrait
exact, refus honnête chiffré + Reformuler. Micro dans la barre de saisie :
réutilise telle quelle la mécanique de cloture.tsx (useAudioRecorder,
setAudioModeAsync, useTranscription, purge quoi qu'il arrive) — pipeline
déjà recetté sur iPhone physique (01/08), aucun nouveau bug à découvrir.

Fiche document (bibliotheque/[id].tsx, nouveau) — la liste R6.3 y mène
aussi désormais. Menu : Assistant route réellement — le Menu R6 n'a plus
d'entrée "à venir" dans les 4 groupes (Catégories exceptée, admin, hors
périmètre mobile).

R6 fonctionnellement complet (Exploitation/Parc/Ressources/Pilotage,
Assistant inclus, pour tous les rôles) — reste une recette complète avant
de considérer R6 close au sens R0→R5.

Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-08-02 12:18:25 +01:00
parent 700bd4e7ff
commit ac8b92e9c5
8 changed files with 857 additions and 5 deletions

View File

@@ -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: 'Lassistant IA reste sur le grand écran et la clôture mobile pour linstant — 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' },
],
},

View File

@@ -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<Echange[]>([]);
const [enregistrement, setEnregistrement] = useState(false);
const saisieRef = useRef<TextInput>(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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Assistant" />
<ScrollView contentContainerStyle={{ gap: 10, paddingBottom: 8, flexGrow: 1 }}>
{echanges.length === 0 ? (
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
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.
</Text>
) : null}
{echanges.map((e, i) => (
<View key={i} style={{ gap: 8 }}>
<Text
style={{
alignSelf: 'flex-end',
backgroundColor: t.primaire,
color: '#fff',
borderRadius: 13,
borderBottomRightRadius: 3,
paddingHorizontal: 12,
paddingVertical: 9,
maxWidth: '85%',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
}}
>
{e.question}
</Text>
{e.reponse ? (
<Reponse reponse={e.reponse} t={t} surReformuler={() => saisieRef.current?.focus()} />
) : e.erreur ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{e.erreur}
</Text>
) : (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Recherche dans le corpus…
</Text>
)}
</View>
))}
</ScrollView>
{enregistrement ? (
<View
style={{
alignItems: 'center',
gap: 8,
borderWidth: 1.5,
borderColor: t.bordure,
borderRadius: 13,
backgroundColor: t.surface,
padding: 16,
}}
>
<View
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: t.prioBloque,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 18 }}>🎙</Text>
</View>
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
Enregistrement en cours…
</Text>
<BoutonTel
libelle="■ Terminer la dictée"
surAppui={() => void terminerDictee()}
/>
</View>
) : (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 10,
backgroundColor: t.surface,
paddingHorizontal: 10,
paddingVertical: 6,
}}
>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dicter la question"
disabled={transcrire.isPending}
onPress={() => void demarrerDictee()}
style={{
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: t.primaireDoux,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 13 }}>🎙</Text>
</Pressable>
<TextInput
ref={saisieRef}
accessibilityLabel="Poser une question"
value={transcrire.isPending ? 'Transcription' : question}
editable={!transcrire.isPending}
onChangeText={setQuestion}
onSubmitEditing={envoyer}
maxLength={500}
placeholder="Poser une question…"
placeholderTextColor={t.encre3}
style={{ flex: 1, color: t.encre, fontFamily: 'Manrope_600SemiBold', fontSize: 13 }}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel="Envoyer"
disabled={question.trim().length < 3 || ask.isPending}
onPress={envoyer}
>
<Text
style={{
color: question.trim().length < 3 || ask.isPending ? t.encre3 : t.primaire,
fontFamily: 'Manrope_800ExtraBold',
fontSize: 12.5,
}}
>
Envoyer
</Text>
</Pressable>
</View>
)}
{transcrire.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 11.5 }}>
{transcrire.error.message}
</Text>
) : null}
</View>
</SafeAreaView>
);
}
function Reponse({
reponse,
t,
surReformuler,
}: {
reponse: AssistantAnswer;
t: Tokens;
surReformuler: () => void;
}) {
if (reponse.mode === 'REFUSAL') {
return (
<View
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderStyle: 'dashed',
borderRadius: 13,
padding: 12,
gap: 8,
backgroundColor: t.surface,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 12.5, color: t.encre }}>
Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre2 }}>
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.
</Text>
<BoutonTel libelle="Reformuler ma question" variante="contour" surAppui={surReformuler} />
</View>
);
}
return (
<View
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 13,
borderBottomLeftRadius: 3,
padding: 11,
gap: 9,
}}
>
{reponse.mode === 'GENERATED' && reponse.answer ? (
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
{reponse.answer}
</Text>
) : (
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
Voici ce que portent vos sources les extraits sont cités tels quels.
</Text>
)}
<View
style={{ gap: 7, borderTopWidth: 1, borderTopColor: t.bordure, paddingTop: 9 }}
>
{reponse.excerpts.map((ex, i) => (
<Source key={`${ex.locator}-${i}`} ex={ex} no={i + 1} t={t} />
))}
</View>
<View
style={{
flexDirection: 'row',
gap: 6,
backgroundColor: t.safranDoux,
borderRadius: 9,
padding: 8,
}}
>
<Text style={{ color: t.alerte, fontSize: 10.5, fontFamily: 'Manrope_600SemiBold', flex: 1 }}>
L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.
</Text>
</View>
</View>
);
}
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 (
<Pressable
accessibilityRole="button"
onPress={ouvrir}
style={{ backgroundColor: t.surface2, borderRadius: 9, padding: 9, gap: 3 }}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 7 }}>
<Text
style={{
width: 16,
height: 16,
borderRadius: 5,
backgroundColor: t.primaireDoux,
color: t.primaire,
fontSize: 10,
fontFamily: 'Manrope_800ExtraBold',
textAlign: 'center',
lineHeight: 16,
}}
>
{no}
</Text>
<Text
numberOfLines={1}
style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 11.5, color: t.encre }}
>
{ex.title}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre2, marginLeft: 23 }}>
{ex.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {ex.locator}
</Text>
<Text
style={{
fontFamily: 'Manrope_500Medium',
fontStyle: 'italic',
fontSize: 11,
color: t.encre2,
marginLeft: 23,
borderLeftWidth: 2,
borderLeftColor: t.safran,
paddingLeft: 7,
}}
>
« {ex.content} »
</Text>
<Text style={{ marginLeft: 23, color: t.primaire, fontFamily: 'Manrope_700Bold', fontSize: 11 }}>
{ex.sourceType === 'DOCUMENT' ? 'Voir le document' : "Ouvrir l'OT"}
</Text>
</Pressable>
);
}

View File

@@ -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<string, string> = {
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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={doc.fileName} />
<Carte titre="Document">
<LigneInfo nom="Type" valeur={KIND_LABEL[doc.kind] ?? doc.kind} />
<LigneInfo nom="Taille" valeur={taille(doc.size)} />
<LigneInfo nom="Rattaché à" valeur={doc.assetReference ?? doc.workOrderReference ?? '—'} />
<LigneInfo nom="Ajouté par" valeur={doc.uploadedByName ?? '—'} />
<LigneInfo
nom="Ajouté le"
valeur={new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(doc.createdAt))}
/>
</Carte>
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5, textAlign: 'center' }}>
Ouvrir le fichier se fait depuis le web pour l'instant.
</Text>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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() {
</Text>
}
renderItem={({ item: d }) => (
<View
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/bibliotheque/${d.id}`)}
style={{
flexDirection: 'row',
alignItems: 'center',
@@ -71,7 +74,7 @@ export default function PageBibliotheque() {
.join(' · ')}
</Text>
</View>
</View>
</Pressable>
)}
/>
</View>

View File

@@ -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<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 })),
});
}