mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
372
apps/mobile/app/assistant/index.tsx
Normal file
372
apps/mobile/app/assistant/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
apps/mobile/app/bibliotheque/[id].tsx
Normal file
52
apps/mobile/app/bibliotheque/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
26
apps/mobile/src/api/assistant.ts
Normal file
26
apps/mobile/src/api/assistant.ts
Normal 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 })),
|
||||
});
|
||||
}
|
||||
355
docs/02-design/maquettes/maquette-mobile-assistant.html
Normal file
355
docs/02-design/maquettes/maquette-mobile-assistant.html
Normal file
@@ -0,0 +1,355 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SIOP V2 — Maquette HD · Assistant mobile</title>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
SIOP V2 — Maquette HD « Assistant mobile » (design-first).
|
||||
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE EXPO (principe n°1).
|
||||
Suite de R6 (mobile ouvert à tous les rôles) : le Menu avait laissé
|
||||
« Assistant » en « à venir » — un chat sourcé est un patron d'écran neuf,
|
||||
jamais maquetté sur mobile (contrairement à Sites/Stock/Personnes qui
|
||||
réutilisaient Carte/LigneInfo/EnteteFiche déjà validés). Reprend le
|
||||
contenu et les règles de l'Assistant web (maquette-r5.html, écrans 1-2,
|
||||
D2 « sourcé ou silencieux ») porté au format téléphone. Tokens et gabarit
|
||||
téléphone repris tels quels de maquette-mobile-tous-roles.html.
|
||||
|
||||
Décisions proposées à validation :
|
||||
D1 — Un échange à la fois (question → réponse), comme le web ; pas
|
||||
d'historique de conversation multi-tours conservé entre les sessions
|
||||
(même limite que le web aujourd'hui).
|
||||
D2 — « Ouvrir » une source d'historique (OT) mène à la fiche OT R4 telle
|
||||
quelle (déjà générique, aucune dépendance nouvelle). « Ouvrir » une
|
||||
source document mène à ses MÉTADONNÉES seulement (même écran que la
|
||||
bibliothèque R6.3) — l'ouverture du fichier lui-même reste au web
|
||||
tant qu'`expo-sharing` n'est pas ajouté (même arbitrage que R6.3/6.4,
|
||||
pas de nouvelle dépendance native au milieu d'une passe).
|
||||
D3 — Le refus chiffré (« cherché dans N documents, M bilans ») et
|
||||
l'avertissement « l'IA propose, vous validez » sont permanents,
|
||||
identiques au web — aucune règle nouvelle, juste portée au pouce.
|
||||
D4 — Assistant rejoint le groupe Pilotage du Menu pour tous les rôles
|
||||
ayant WORK_ORDERS.view (même garde que le web).
|
||||
D5 — La question peut être TAPÉE OU DICTÉE (référent, 02/08) : réutilise
|
||||
EXACTEMENT le pipeline déjà validé (ADR-004 §5, faster-whisper local,
|
||||
opt-in, audio jamais persisté — même écran Voix que la clôture R4/R5).
|
||||
La transcription REMPLIT le champ de saisie, éditable — jamais
|
||||
d'envoi automatique après dictée : l'humain relit sa question comme
|
||||
il relirait n'importe quel texte tapé, puis appuie sur Envoyer.
|
||||
-->
|
||||
<style>
|
||||
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
|
||||
:root{
|
||||
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
|
||||
--safran:#dd8a0b;--safran-doux:#fdf3e3;
|
||||
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
|
||||
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
|
||||
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
|
||||
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
|
||||
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
|
||||
--st-annule:#68788f;--st-annule-fond:#edf0f4;
|
||||
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
|
||||
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
|
||||
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
|
||||
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
|
||||
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
|
||||
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
|
||||
--transition:140ms ease-out;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root{
|
||||
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
|
||||
--safran:#e89a1f;--safran-doux:#33270f;
|
||||
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
|
||||
--bordure:#2b3850;--bordure-forte:#3d4d6b;
|
||||
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
|
||||
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
|
||||
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
|
||||
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
|
||||
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
|
||||
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
|
||||
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
|
||||
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
|
||||
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
|
||||
}}
|
||||
:root[data-theme="light"]{
|
||||
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
|
||||
--safran:#dd8a0b;--safran-doux:#fdf3e3;
|
||||
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
|
||||
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
|
||||
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
|
||||
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
|
||||
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
|
||||
--st-annule:#68788f;--st-annule-fond:#edf0f4;
|
||||
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
|
||||
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
|
||||
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
|
||||
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
|
||||
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
|
||||
--safran:#e89a1f;--safran-doux:#33270f;
|
||||
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
|
||||
--bordure:#2b3850;--bordure-forte:#3d4d6b;
|
||||
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
|
||||
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
|
||||
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
|
||||
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
|
||||
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
|
||||
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
|
||||
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
|
||||
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
|
||||
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
|
||||
}
|
||||
|
||||
/* ═══ Base ═══ */
|
||||
*{box-sizing:border-box;margin:0}
|
||||
html,body{background:var(--fond)}
|
||||
body{font-family:var(--police-ui);color:var(--encre);font-size:14px;line-height:1.5}
|
||||
button{font:inherit;cursor:pointer;border:0;background:none;color:inherit}
|
||||
button:focus-visible,a:focus-visible{outline:2px solid var(--primaire);outline-offset:2px;border-radius:4px}
|
||||
|
||||
/* ═══ Barre de revue (outil maquette, pas l'app) ═══ */
|
||||
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
|
||||
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
|
||||
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
|
||||
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
|
||||
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
|
||||
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
|
||||
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
|
||||
box-shadow:inset 3px 0 0 var(--safran)}
|
||||
.revue .droite{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
|
||||
.note-ecran{max-width:1120px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
|
||||
.note-ecran b{color:var(--encre)}
|
||||
|
||||
/* ═══ Écrans / téléphone (repris de maquette-mobile-tous-roles) ═══ */
|
||||
.ecran{display:none;max-width:1120px;margin:18px auto 44px;padding:0 20px}
|
||||
.ecran.actif{display:block}
|
||||
.tels{display:flex;gap:28px;justify-content:center;flex-wrap:wrap}
|
||||
.tel{width:310px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
|
||||
box-shadow:var(--ombre-menu);display:flex;flex-direction:column}
|
||||
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center;flex:none}
|
||||
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
|
||||
.tel .legende{flex:none;text-align:center;font-size:11.5px;font-weight:700;letter-spacing:.05em;
|
||||
text-transform:uppercase;color:var(--encre-3);padding:8px;background:var(--surface-2)}
|
||||
.ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:560px}
|
||||
.entete-tel{display:flex;align-items:center;gap:8px}
|
||||
.entete-tel .retour{font-size:20px;color:var(--primaire);font-weight:700}
|
||||
.entete-tel b{font-size:16px;letter-spacing:.02em}
|
||||
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:11px;border-radius:10px;
|
||||
font-weight:700;font-size:13.5px;background:var(--primaire);color:#fff;width:100%}
|
||||
.btn-tel.contour{background:var(--surface);color:var(--primaire);border:1.5px solid var(--primaire)}
|
||||
.btn-tel.gris{background:var(--bordure-forte);color:var(--surface)}
|
||||
|
||||
/* ═══ Nouveautés : chat au format téléphone ═══ */
|
||||
.chat-tel{display:flex;flex-direction:column;gap:10px;flex:1}
|
||||
.msg-q-tel{align-self:flex-end;background:var(--primaire);color:#fff;border-radius:13px 13px 3px 13px;
|
||||
padding:9px 12px;max-width:85%;font-size:12.5px;font-family:'Manrope',sans-serif}
|
||||
.msg-r-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:13px 13px 13px 3px;
|
||||
padding:11px 12px;display:flex;flex-direction:column;gap:9px;font-size:12.5px}
|
||||
.cite-tel{display:inline-flex;align-items:center;justify-content:center;min-width:15px;height:15px;
|
||||
border-radius:5px;background:var(--primaire-doux);color:var(--primaire);font-size:10px;font-weight:800;
|
||||
vertical-align:1px;margin:0 1px}
|
||||
.sources-tel{display:flex;flex-direction:column;gap:7px;border-top:1px dashed var(--bordure);padding-top:9px}
|
||||
.source-tel{display:flex;flex-direction:column;gap:4px;background:var(--surface-2);border-radius:9px;padding:8px 9px}
|
||||
.source-tel .entete-source{display:flex;align-items:center;gap:7px}
|
||||
.source-tel .no{flex:none;width:16px;height:16px;border-radius:5px;background:var(--primaire-doux);
|
||||
color:var(--primaire);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:800}
|
||||
.source-tel b{font-size:11.5px;flex:1}
|
||||
.source-tel .ou{color:var(--encre-2);font-size:10.5px;margin-left:23px}
|
||||
.source-tel .extrait{color:var(--encre-2);font-size:11px;font-style:italic;border-left:2px solid var(--safran);
|
||||
padding-left:7px;margin-left:23px}
|
||||
.source-tel .ouvrir{margin-left:23px;align-self:flex-start;color:var(--primaire);font-weight:700;font-size:11px}
|
||||
.avert-tel{display:flex;gap:6px;align-items:flex-start;background:var(--safran-doux);color:var(--alerte);
|
||||
border-radius:9px;padding:8px 9px;font-size:10.5px;font-weight:600;line-height:1.4}
|
||||
.refus-tel{background:var(--surface);border:1.5px dashed var(--bordure-forte);border-radius:13px;
|
||||
padding:11px 12px;display:flex;flex-direction:column;gap:8px;font-size:12.5px}
|
||||
.refus-tel b{color:var(--encre)}
|
||||
.refus-tel .pourquoi{color:var(--encre-2);font-size:11.5px}
|
||||
.saisie-chat-tel{margin-top:auto;display:flex;gap:8px;align-items:center;border:1.5px solid var(--bordure-forte);
|
||||
border-radius:10px;background:var(--surface);padding:9px 10px}
|
||||
.saisie-chat-tel span.txt{flex:1;color:var(--encre-3);font-size:12px}
|
||||
.saisie-chat-tel span.txt.rempli{color:var(--encre);font-weight:600}
|
||||
.saisie-chat-tel .envoyer{flex:none;color:var(--primaire);font-weight:800;font-size:12.5px}
|
||||
.micro-mini{flex:none;width:26px;height:26px;border-radius:50%;background:var(--primaire-doux);color:var(--primaire);
|
||||
display:flex;align-items:center;justify-content:center;font-size:13px}
|
||||
.rappel-tel{color:var(--encre-3);font-size:11px;text-align:center;padding:8px}
|
||||
|
||||
/* Dictée de la question (D5) — même patron que l'écran Voix R5 */
|
||||
.dictee-tel{margin-top:auto;display:flex;flex-direction:column;align-items:center;gap:10px;
|
||||
border:1.5px solid var(--bordure);border-radius:13px;background:var(--surface);padding:18px 12px}
|
||||
.micro-gd{width:48px;height:48px;border-radius:50%;background:var(--prio-bloque);color:#fff;display:flex;
|
||||
align-items:center;justify-content:center;font-size:19px}
|
||||
.onde-tel{display:flex;gap:3px;align-items:center;justify-content:center;height:22px}
|
||||
.onde-tel i{width:3px;border-radius:2px;background:var(--primaire)}
|
||||
.dictee-tel .provisoire{font-size:12px;color:var(--encre-2);text-align:center;font-style:italic}
|
||||
.purge-tel{display:flex;align-items:center;gap:6px;font-size:10.5px;font-weight:700;color:var(--st-termine)}
|
||||
</style>
|
||||
|
||||
<header class="revue">
|
||||
<b>SIOP · Maquette — Assistant mobile</b>
|
||||
<nav class="onglets" role="tablist" aria-label="Écrans">
|
||||
<button role="tab" aria-selected="true" data-cible="sourcee">1 · Réponse sourcée</button>
|
||||
<button role="tab" aria-selected="false" data-cible="refus">2 · Sans source = refus</button>
|
||||
<button role="tab" aria-selected="false" data-cible="dictee">3 · Dictée de la question</button>
|
||||
</nav>
|
||||
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ 1 · RÉPONSE SOURCÉE ═══ -->
|
||||
<p class="note-ecran" data-note="sourcee"><b>Intention :</b> même contenu, mêmes règles que
|
||||
l'Assistant web (maquette-r5, D2) — chaque affirmation porte ses <b>citations numérotées</b>,
|
||||
chaque source montre le document ou l'historique, la localisation précise et l'<b>extrait
|
||||
exact</b>. « 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é.</p>
|
||||
<section class="ecran actif" id="sourcee">
|
||||
<div class="tels">
|
||||
<div class="tel">
|
||||
<div class="encoche"><i></i></div>
|
||||
<div class="legende">Gestionnaire — question posée</div>
|
||||
<div class="ecran-tel">
|
||||
<div class="entete-tel"><span class="retour">‹</span><b>Assistant</b></div>
|
||||
<div class="chat-tel">
|
||||
<div class="msg-q-tel">Quel couple de serrage pour les coulisseaux de guides du Gen2 ?</div>
|
||||
<div class="rappel-tel">Recherche dans le corpus…</div>
|
||||
</div>
|
||||
<div class="saisie-chat-tel"><span class="micro-mini">🎙</span><span class="txt">Poser une question…</span><span class="envoyer">Envoyer</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tel">
|
||||
<div class="encoche"><i></i></div>
|
||||
<div class="legende">Réponse sourcée + citations</div>
|
||||
<div class="ecran-tel">
|
||||
<div class="entete-tel"><span class="retour">‹</span><b>Assistant</b></div>
|
||||
<div class="chat-tel">
|
||||
<div class="msg-q-tel">Quel couple de serrage pour les coulisseaux de guides du Gen2 ?</div>
|
||||
<div class="msg-r-tel">
|
||||
<p>Pour l'Otis <b>Gen2 Premier</b>, la notice prescrit <b>25 N·m</b> pour les vis de
|
||||
fixation des coulisseaux<span class="cite-tel">1</span>, jeu latéral max. 0,5 mm
|
||||
<span class="cite-tel">1</span>. Appliqué sur l'ascenseur A1 le 17 juillet
|
||||
<span class="cite-tel">2</span>.</p>
|
||||
<div class="sources-tel">
|
||||
<div class="source-tel">
|
||||
<div class="entete-source"><span class="no">1</span><b>Notice Otis Gen2 Premier.pdf</b></div>
|
||||
<div class="ou">Bibliothèque · Asc. A1 · page 42</div>
|
||||
<div class="extrait">« Serrer au couple de 25 N·m ; jeu latéral maximal : 0,5 mm. »</div>
|
||||
<div class="ouvrir">Voir le document</div>
|
||||
</div>
|
||||
<div class="source-tel">
|
||||
<div class="entete-source"><span class="no">2</span><b>OT-2026-0341 — Bruit anormal en gaine</b></div>
|
||||
<div class="ou">Historique · clôturé le 17/07/2026</div>
|
||||
<div class="extrait">« Coulisseau de guide 16 mm × 2 — remplacement effectué. »</div>
|
||||
<div class="ouvrir">Ouvrir l'OT</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avert-tel">⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="saisie-chat-tel"><span class="micro-mini">🎙</span><span class="txt">Poser une question…</span><span class="envoyer">Envoyer</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ 2 · SANS SOURCE = REFUS ═══ -->
|
||||
<p class="note-ecran" data-note="refus" hidden><b>Intention :</b> l'anti-hallucination rendue
|
||||
visible, comme au web — quand le corpus ne porte pas la réponse, l'assistant le dit, CHIFFRE ce
|
||||
qu'il a cherché, et propose l'action utile. Jamais de réponse inventée « pour faire plaisir ».</p>
|
||||
<section class="ecran" id="refus">
|
||||
<div class="tels">
|
||||
<div class="tel">
|
||||
<div class="encoche"><i></i></div>
|
||||
<div class="legende">Refus honnête et chiffré</div>
|
||||
<div class="ecran-tel">
|
||||
<div class="entete-tel"><span class="retour">‹</span><b>Assistant</b></div>
|
||||
<div class="chat-tel">
|
||||
<div class="msg-q-tel">Procédure de remplacement du variateur KONE ?</div>
|
||||
<div class="refus-tel">
|
||||
<b>Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.</b>
|
||||
<div class="pourquoi">J'ai cherché dans 6 documents indexés et 214 bilans d'intervention :
|
||||
aucune notice KONE, aucun historique de variateur sur ce parc.</div>
|
||||
<div class="btn-tel contour">Reformuler ma question</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="saisie-chat-tel"><span class="micro-mini">🎙</span><span class="txt">Poser une question…</span><span class="envoyer">Envoyer</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ 3 · DICTÉE DE LA QUESTION ═══ -->
|
||||
<p class="note-ecran" data-note="dictee" hidden><b>Intention :</b> même pipeline que la dictée déjà
|
||||
livrée en clôture (ADR-004 §5) — appui sur le micro du champ de saisie, transcription locale
|
||||
(faster-whisper), audio purgé immédiatement. La transcription REMPLIT le champ, éditable :
|
||||
l'humain relit sa question avant d'appuyer sur Envoyer, exactement comme s'il l'avait tapée.</p>
|
||||
<section class="ecran" id="dictee">
|
||||
<div class="tels">
|
||||
<div class="tel">
|
||||
<div class="encoche"><i></i></div>
|
||||
<div class="legende">Pendant l'enregistrement</div>
|
||||
<div class="ecran-tel">
|
||||
<div class="entete-tel"><span class="retour">‹</span><b>Assistant</b></div>
|
||||
<div class="chat-tel">
|
||||
<div class="rappel-tel">Posez votre question, notice ou historique…</div>
|
||||
</div>
|
||||
<div class="dictee-tel">
|
||||
<div class="micro-gd">🎙</div>
|
||||
<div class="onde-tel">
|
||||
<i style="height:7px"></i><i style="height:16px"></i><i style="height:11px"></i>
|
||||
<i style="height:20px"></i><i style="height:9px"></i><i style="height:14px"></i>
|
||||
<i style="height:6px"></i>
|
||||
</div>
|
||||
<div class="provisoire">« …quel couple de serrage pour les coulisseaux… »</div>
|
||||
<div class="btn-tel" style="background:var(--danger)">■ Terminer la dictée</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tel">
|
||||
<div class="encoche"><i></i></div>
|
||||
<div class="legende">Transcrite — relecture avant envoi</div>
|
||||
<div class="ecran-tel">
|
||||
<div class="entete-tel"><span class="retour">‹</span><b>Assistant</b></div>
|
||||
<div class="chat-tel">
|
||||
<div class="rappel-tel">Posez votre question, notice ou historique…</div>
|
||||
</div>
|
||||
<div class="saisie-chat-tel" style="align-items:flex-start">
|
||||
<span class="micro-mini">🎙</span>
|
||||
<span class="txt rempli">Quel couple de serrage pour les coulisseaux de guides du Gen2 ?</span>
|
||||
<span class="envoyer">Envoyer</span>
|
||||
</div>
|
||||
<div class="purge-tel">✓ Audio purgé (2,1 s après transcription) — relisez avant d'envoyer</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var onglets=document.querySelectorAll('.revue .onglets button');
|
||||
var ecrans=document.querySelectorAll('.ecran');
|
||||
var notes=document.querySelectorAll('.note-ecran');
|
||||
onglets.forEach(function(b){b.addEventListener('click',function(){
|
||||
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
|
||||
b.setAttribute('aria-selected','true');
|
||||
var cible=b.dataset.cible;
|
||||
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
|
||||
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
|
||||
window.scrollTo({top:0});
|
||||
})});
|
||||
var ordre=['auto','light','dark'];var i=0;
|
||||
var bt=document.getElementById('btn-theme');
|
||||
bt.addEventListener('click',function(){
|
||||
i=(i+1)%3;var m=ordre[i];
|
||||
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
|
||||
else{document.documentElement.setAttribute('data-theme',m)}
|
||||
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user