mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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>
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|