Skip to content

Commit 20de8a3

Browse files
committed
Add the plant expert: reference base, diagnosis and questions
- Reference base of 170 common plants (houseplants, balcony, herbs): names checked against Wikidata, toxicity from the ASPCA lists. Known species get their figures from the base and Gemma only writes the advice. - One shared expert for scan, sheet, diagnosis and chat, given the plant's context (species, sheet, recent care, room, season). - Health diagnosis from a close-up photo, kept with its photo in a Health section, with a one-tap watering interval change. - "Demande à Plantule": per-plant conversation, streamed as it is written. - Migration 3: diagnoses and chat_messages tables. Claude-Session: https://claude.ai/code/session_01Jg25nsPfSVmrDW93LK1Wby
1 parent 1deb3be commit 20de8a3

40 files changed

Lines changed: 6735 additions & 330 deletions

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@ Tout reste sur le téléphone : pas de compte, pas de serveur. Les données sont
1414
- **Journal** : tout ce qui a été fait ou reporté, par plante.
1515
- **Résumé quotidien** : une notification locale par jour, à l'heure choisie.
1616
- **Scan** : on photographie une plante, Gemma 4 E2B propose l'espèce, puis rédige sa fiche d'entretien et les soins à programmer. Le modèle tourne sur le téléphone : il se télécharge une fois (2,6 Go, en Wi-Fi) et aucune photo ne sort du téléphone. Il faut un téléphone Android avec 6 Go de RAM. Voir [docs/ai-engine.md](docs/ai-engine.md).
17+
- **Base de référence** : 170 plantes courantes (intérieur, balcon, aromatiques) embarquées dans l’app : noms vérifiés sur Wikidata, toxicité d’après l’ASPCA, et des chiffres d’entretien fixés à l’avance. Pour une espèce connue, la fiche prend ses chiffres dans la base et Gemma n’écrit que les conseils : elle est plus rapide et plus juste. La fiche l’indique par « Données vérifiées ».
18+
- **Diagnostic** : une photo de près de ce qui inquiète, et Gemma donne l’état de la plante (saine, à surveiller, à soigner), jusqu’à 3 pistes avec quoi faire, en tenant compte des derniers arrosages, de la pièce et de la saison. Si l’arrosage est en cause, l’app propose de changer l’intervalle en un clic. Les diagnostics sont gardés avec leur photo dans la section Santé de la plante.
19+
- **Demande à Plantule** : poser ses questions sur une plante, avec la réponse qui s’écrit au fil de l’eau. La conversation est gardée pour chaque plante.
1720

1821
## Développer
1922

2023
```bash
2124
npm install
2225
npx expo start # puis « a » pour Android (Expo Go ou émulateur)
23-
npm test # tests unitaires (règles de récurrence, dates, résumé)
26+
npm test # tests unitaires (règles, dates, fiche, diagnostic, base de référence)
2427
npx tsc --noEmit # typecheck
2528
npx expo lint # lint
2629
```
@@ -31,10 +34,11 @@ Structure :
3134

3235
```
3336
src/app/ écrans (Expo Router)
34-
src/ai/ modèle sur le téléphone (téléchargement, exécution) et questions du scan
37+
src/ai/ modèle sur le téléphone (téléchargement, exécution) et questions posées à l’expert (scan, fiche, diagnostic, conversation)
3538
src/components/ composants, dont le design system dans components/ui
39+
src/data/ base de référence des plantes (chiffres d’entretien, toxicité)
3640
src/db/ base SQLite : schéma et migrations, requêtes, hooks réactifs
37-
src/lib/ règles de récurrence, dates, libellés, fiche espèce et sa validation
41+
src/lib/ règles de récurrence, dates, libellés, fiche espèce, diagnostic et leur validation
3842
src/notifications/ résumé quotidien
3943
src/theme/ couleurs, typographie, espacements (clair et sombre)
4044
```

‎docs/ROADMAP.md‎

Lines changed: 16 additions & 148 deletions
Large diffs are not rendered by default.

‎docs/ai-engine.md‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Moteur IA sur le téléphone
22

3-
Décision du 23/09/2026 pour la phase 2 (scan). Code : `src/ai/engine.ts` et `src/ai/model-*.ts`, derrière le contrat `src/ai/types.ts`.
3+
Décision du 23/09/2026 pour la phase 2 (scan). Code : `src/ai/engine.ts` et `src/ai/model-*.ts`, derrière le contrat `src/ai/types.ts`. Ce qu’on demande au modèle (scan, fiche, diagnostic, questions) est dans `src/ai/plant-ai.ts` ; la base de référence dans `src/data/plants.ts` (voir plus bas).
44

55
## Choix
66

@@ -49,8 +49,48 @@ L'app exige **6 Go de RAM** (seuil technique : `Device.totalMemory` ≥ 5 Go, un
4949
- Température 0,3 par défaut, topK 64, topP 0,95, « thinking » désactivé.
5050
- Photo redimensionnée à 1024 px de côté au plus, en JPEG.
5151
- `jsonSchema` passé à LLGuidance. Si le moteur refuse le schéma, la demande est relancée sans contrainte (la réponse est de toute façon validée par l'appelant).
52+
- Réponse au fil de l’eau : avec `onText`, le moteur écrit la réponse morceau par morceau (`execute(parts, onToken, options)`) et `onText` reçoit tout le texte déjà écrit, au plus toutes les 100 ms, puis le texte final (`src/ai/text-stream.ts`). Plus rien n’est envoyé après une annulation.
5253
- Téléchargement : `DownloadTask` d'expo-file-system (SDK 57) avec progression, annulation et reprise (en-tête `Range`), même après un redémarrage de l'app. Écrit en `.part`, vérifié (taille et en-tête `LITERTLM`) puis renommé. Espace disque vérifié avant (+300 Mo de marge). Refus hors Wi-Fi sauf accord explicite (expo-network).
5354

55+
## L’expert Plantule
56+
57+
Toutes les demandes partagent les mêmes instructions, `EXPERT_SYSTEM` (`src/ai/plant-context.ts`) : un jardinier prudent qui conseille des particuliers en France, en phrases courtes et en tutoyant ; il dit quand il ne sait pas, s’appuie sur les données vérifiées sans inventer d’autres chiffres, et conseille le vétérinaire si un animal a mangé une plante toxique. Les tâches en JSON (identification, fiche, diagnostic) y ajoutent « réponds uniquement avec un objet JSON » ; les questions répondent en texte libre.
58+
59+
Pour le diagnostic et les questions, le modèle reçoit ce que l’app sait de la plante (`buildPlantContext`, `contextText`) : espèce, chiffres de la base (ou de la fiche), problèmes fréquents de la fiche, pièce (nom, intérieur ou dehors, lumière), pot et substrat, mois et saison, rappel d’arrosage (intervalle, retard, « terreau encore humide » de suite), les 8 derniers soins (l’arrosage d’abord) et les notes. Le tout tient en 2 000 caractères au plus, environ 600 jetons : les textes libres sont raccourcis et les soins les plus anciens sautent d’abord.
60+
61+
| Demande | Instructions + contexte | Image | Réponse (max) |
62+
|---|---|---|---|
63+
| Identification | ~650 jetons | ~280 | 600 |
64+
| Fiche d’une espèce de la base | ~650 | — | 1 000 |
65+
| Fiche complète (espèce inconnue) | ~850 | — | 1 400 |
66+
| Diagnostic | ~1 350 | ~280 | 900 |
67+
| Question | ~1 700 (dont 6 messages) | — | 400 |
68+
69+
Une nouvelle tentative ajoute la réponse précédente (1 500 caractères au plus) et les erreurs : le diagnostic reste vers 3 000 jetons sur 4 096.
70+
71+
## Base de référence
72+
73+
`src/data/plants.ts` : **170 plantes** (109 d’intérieur, 42 de balcon et de terrasse, 19 aromatiques), avec noms latins et français, lumière, arrosage (intervalle et facteur d’hiver), humidité, températures supportées, toxicité pour les chats et les chiens, engrais, brumisation et rempotage. Recherche (`findReference`) sans tenir compte de la casse, des accents, du signe d’hybride, d’un cultivar entre guillemets, de « spp. », d’un article ou d’un pluriel ; jamais sur un genre seul (« Ficus alii » n’est pas le Ficus benjamina).
74+
75+
Utilisation :
76+
77+
- **Identification** : chaque candidat est relié à la base (`reference_id`), par son nom latin, ou par son nom commun si le genre concorde. Il prend alors le nom latin de la base, et son premier nom commun si celui du modèle n’y figure pas.
78+
- **Fiche** : pour une espèce de la base, les chiffres viennent de la base et Gemma n’écrit que les textes (conseils d’arrosage et de rempotage, substrat, pot, bouturage, problèmes, astuces), avec les faits de la base dans la demande et la consigne de ne pas écrire d’autres chiffres. La fiche a `reference_id` et est enregistrée avec la source `reference`. Sinon, Gemma écrit toute la fiche, comme avant.
79+
- **Diagnostic et questions** : les chiffres de la base font partie du contexte de la plante.
80+
81+
D’où viennent les données, et sous quelle licence :
82+
83+
- **Noms** : chaque nom latin vérifié sur **Wikidata** (licence CC0, domaine public), dont l’identifiant est dans `sources`. Wikidata a aussi fourni une partie des noms français. Pour quelques plantes vendues sous un ancien nom (Echinocactus grusonii, Osteospermum, Schefflera, Dracaena marginata), c’est ce nom qui est gardé et le nom accepté va dans les synonymes.
84+
- **Toxicité** : les listes de l’**ASPCA** (Animal Poison Control Center) pour les chats et les chiens, vérifiées par script pour chaque plante (espèce, ou genre quand l’espèce n’y est pas), avec le lien dans `sources`. On n’en garde que le fait (toxique ou non), pas le texte. Une plante absente des listes est « inconnue », sauf quatre plantes irritantes gardées « toxiques » par prudence (Rhaphidophora, Zamioculcas, croton, agave), commentées dans le fichier.
85+
- **Chiffres d’entretien** (lumière, arrosage, humidité, températures, engrais, brumisation, rempotage) : écrits par nous pour la culture en pot en France, à partir des conseils horticoles courants, et marqués `Plantule` dans `sources`. Aucune source ouverte trouvée pour ces chiffres : FloraDB (échantillon en CC BY-NC), Open Plantbook (compte obligatoire, plages de capteurs plutôt que des rythmes), OpenPlantDB (CC0 mais cultures potagères des États-Unis). Ce sont des repères de départ : chaque plante les ajuste ensuite (« terreau encore humide », diagnostic).
86+
87+
Les tests (`src/lib/plant-reference.test.ts`) vérifient au moins 150 plantes, des identifiants et des noms uniques, qu’aucun nom ne désigne deux plantes, des intervalles entre 1 et 730 jours, un facteur d’hiver parmi 1, 1,5, 2 et 3, un minimum de température inférieur au maximum, et une source pour chaque toxicité connue.
88+
89+
## Diagnostic et questions
90+
91+
- **Diagnostic** (`diagnosePlant`) : la photo et le contexte de la plante dans le même appel. Le modèle liste d’abord jusqu’à 3 problèmes (nom, type, confiance, signes, actions), puis l’état, le résumé et les changements d’arrosage et de lumière. La demande insiste sur trop d’eau ou pas assez, d’après les derniers arrosages et les « terreau encore humide », et sur ce que la photo ne montre pas (racines, petits parasites). Validation stricte (`validateDiagnosis`) avec 2 nouvelles tentatives ; à la relecture d’un diagnostic enregistré, la validation est indulgente. `suggestedWateringInterval` propose ×1,3 pour « moins d’eau » et ×0,75 pour « plus », d’au moins un jour, entre 1 et 730.
92+
- **Questions** (`askPlant`) : contexte de la plante, les 6 derniers messages raccourcis à 400 caractères, puis la question. Réponse courte en texte libre, envoyée au fil de l’eau par `onText`, sans nouvelle tentative.
93+
5494
## Limites connues
5595

5696
- **Pas d'arrêt en cours de génération** : la 0.7.0 n'expose pas `cancelProcess()` (corrigé sur la branche principale de la lib, pas encore publié). `signal` rejette tout de suite, mais le calcul continue en arrière-plan jusqu'à la fin de la réponse et la demande suivante attend.
@@ -64,4 +104,4 @@ L'app exige **6 Go de RAM** (seuil technique : `Device.totalMemory` ≥ 5 Go, un
64104

65105
## À mesurer sur un vrai téléphone
66106

67-
Temps de chargement, temps d'une identification avec photo, RAM, repli CPU/GPU, justesse sur nos plantes (et comparaison avec Pl@ntNet).
107+
Temps de chargement, temps d'une identification avec photo, RAM, repli CPU/GPU, justesse sur nos plantes (et comparaison avec Pl@ntNet). Et depuis la phase 3 : le temps d’un diagnostic, le rythme de la réponse au fil de l’eau, la justesse du diagnostic sur des plantes vraiment malades, et le temps d’une fiche d’espèce connue (texte seul) comparé à une fiche complète.

‎src/ai/fake-engine.ts‎

Lines changed: 146 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
/**
22
* A pretend model for development (browser, Expo Go), turned on with
33
* EXPO_PUBLIC_FAKE_AI=1. It answers like the real one would, after a short
4-
* delay: a Monstera with two look-alikes for any photo (in a terracotta pot
5-
* that needs repotting), and a care sheet for the species named in the
6-
* prompt. Deleting it from the model card and downloading it again shows the
7-
* "not ready" states.
4+
* delay:
5+
* - a scan photo: a Monstera with two look-alikes (in a terracotta pot that
6+
* needs repotting);
7+
* - a diagnosis photo: in turn a plant to treat (too much water, with a
8+
* longer watering interval), one to watch (not enough light) and a healthy
9+
* one;
10+
* - a sheet: the whole sheet for the species named in the prompt, or its
11+
* texts only for a species of the reference base (the app adds the base's
12+
* figures);
13+
* - a question: a short answer written word by word through `onText`.
14+
* Deleting it from the model card and downloading it again shows the "not
15+
* ready" states.
816
*/
917

1018
import type { CareSheet } from '@/lib/care-sheet';
19+
import type { Diagnosis } from '@/lib/diagnosis';
1120
import type { Identification } from '@/lib/identification';
1221

1322
import type { AiEngine, GenerateRequest, ModelStatus } from './types';
1423

1524
const SIZE_BYTES = 2_600_000_000;
1625
const ANSWER_DELAY_MS = 1500;
26+
/** A chat answer is written a word at a time, about ten words a second. */
27+
const WORD_DELAY_MS = 90;
1728

1829
let status: ModelStatus = { state: 'ready', sizeBytes: SIZE_BYTES };
1930
const listeners = new Set<() => void>();
@@ -99,6 +110,7 @@ const SHEETS: CareSheet[] = [
99110
'Dépoussière ses grandes feuilles avec un chiffon humide.',
100111
'Évite le soleil direct de l’après-midi, qui brûle les feuilles.',
101112
],
113+
reference_id: null,
102114
},
103115
{
104116
common_name: 'Philodendron selloum',
@@ -137,6 +149,7 @@ const SHEETS: CareSheet[] = [
137149
'Il prend vite de la place : prévois-lui un coin spacieux.',
138150
'Tourne le pot d’un quart de tour chaque mois pour qu’il pousse droit.',
139151
],
152+
reference_id: null,
140153
},
141154
{
142155
common_name: 'Mini monstera',
@@ -180,6 +193,7 @@ const SHEETS: CareSheet[] = [
180193
'Grimpante : donne-lui un tuteur ou laisse-la retomber d’une étagère.',
181194
'Une salle de bain lumineuse lui convient très bien.',
182195
],
196+
reference_id: null,
183197
},
184198
];
185199

@@ -223,11 +237,122 @@ function genericSheet(name: string): CareSheet {
223237
'Place-la près d’une fenêtre, sans soleil brûlant.',
224238
'Réduis les arrosages en hiver, quand elle pousse moins.',
225239
],
240+
reference_id: null,
226241
};
227242
}
228243

229-
function answer(request: GenerateRequest): string {
230-
if (request.imageUri) return JSON.stringify(IDENTIFICATION);
244+
const DIAGNOSES: Diagnosis[] = [
245+
{
246+
status: 'treat',
247+
summary:
248+
'Les feuilles jaunes et molles, avec un terreau souvent encore humide, font penser à trop d’eau. Ce sont des pistes à vérifier.',
249+
problems: [
250+
{
251+
name: 'Excès d’arrosage',
252+
kind: 'care',
253+
confidence: 0.7,
254+
signs: 'Feuilles du bas jaunes et molles, terreau encore humide plusieurs fois de suite.',
255+
actions: [
256+
'Laisse sécher le terreau sur 3 cm avant d’arroser.',
257+
'Vide la soucoupe après chaque arrosage.',
258+
'Vérifie que le pot est bien percé.',
259+
],
260+
},
261+
{
262+
name: 'Pourriture des racines',
263+
kind: 'disease',
264+
confidence: 0.3,
265+
signs: 'La base des tiges paraît sombre.',
266+
actions: ['Dépote-la et coupe les racines brunes et molles.', 'Rempote dans un terreau sec et drainant.'],
267+
},
268+
],
269+
watering_change: 'less',
270+
light_change: 'none',
271+
},
272+
{
273+
status: 'watch',
274+
summary: 'Elle s’étire vers la lumière : rien de grave, mais elle serait mieux plus près d’une fenêtre.',
275+
problems: [
276+
{
277+
name: 'Manque de lumière',
278+
kind: 'environment',
279+
confidence: 0.6,
280+
signs: 'Tiges longues et nouvelles feuilles plus petites.',
281+
actions: ['Rapproche-la d’une fenêtre lumineuse, sans soleil brûlant.'],
282+
},
283+
{
284+
name: 'Araignées rouges',
285+
kind: 'pest',
286+
confidence: 0.2,
287+
signs: 'Feuilles un peu ternes.',
288+
actions: ['Regarde le dessous des feuilles à la loupe.', 'Douche le feuillage à l’eau tiède.'],
289+
},
290+
],
291+
watering_change: 'none',
292+
light_change: 'more',
293+
},
294+
{
295+
status: 'healthy',
296+
summary: 'Elle a l’air en forme : feuilles fermes et bien vertes. Continue comme ça.',
297+
problems: [],
298+
watering_change: 'none',
299+
light_change: 'none',
300+
},
301+
];
302+
let diagnoses = 0;
303+
304+
const CHAT_ANSWERS: { words: RegExp; answer: string }[] = [
305+
{
306+
words: /jaun/i,
307+
answer:
308+
'Des feuilles qui jaunissent viennent le plus souvent d’un excès d’eau. Touche le terreau : s’il est encore humide à deux doigts de profondeur, attends avant d’arroser et vide la soucoupe. Si seules les vieilles feuilles du bas jaunissent, c’est souvent normal.',
309+
},
310+
{
311+
words: /rempot/i,
312+
answer:
313+
'Le meilleur moment pour la rempoter, c’est au printemps, de mars à mai. Fais-le quand les racines sortent par le trou du pot. Prends un pot 2 à 3 cm plus large, percé, avec un terreau frais, puis arrose bien.',
314+
},
315+
{
316+
words: /plac|où|lumi|soleil|fenêtre/i,
317+
answer:
318+
'Mets-la près d’une fenêtre lumineuse, mais sans soleil direct l’après-midi, qui brûle les feuilles. Évite les radiateurs et les courants d’air. Si ses tiges s’étirent, c’est qu’elle manque de lumière.',
319+
},
320+
];
321+
const CHAT_FALLBACK =
322+
'Je ne peux pas te répondre avec certitude sans en savoir plus. Décris-moi ce que tu vois sur la plante, ou fais un diagnostic avec une photo prise de près.';
323+
324+
/** The texts of a sheet, as asked for a species of the reference base. */
325+
function sheetTexts(sheet: CareSheet) {
326+
return {
327+
watering_advice: sheet.watering.advice,
328+
repotting_advice: sheet.repotting.advice,
329+
substrate: sheet.substrate,
330+
pot: sheet.pot,
331+
propagation: sheet.propagation,
332+
problems: sheet.problems,
333+
tips: sheet.tips,
334+
};
335+
}
336+
337+
/** Field names the request's JSON Schema asks for. */
338+
function schemaFields(request: GenerateRequest): readonly string[] {
339+
return (request.jsonSchema as { required?: readonly string[] } | undefined)?.required ?? [];
340+
}
341+
342+
/** The answer, and whether it is free text written word by word. */
343+
function answer(request: GenerateRequest): { text: string; words: boolean } {
344+
const fields = schemaFields(request);
345+
if (request.imageUri && fields.includes('status')) {
346+
const diagnosis = DIAGNOSES[diagnoses++ % DIAGNOSES.length];
347+
return { text: JSON.stringify(diagnosis), words: false };
348+
}
349+
if (request.imageUri) return { text: JSON.stringify(IDENTIFICATION), words: false };
350+
// Questions end the prompt with "Question : …", after the plant's context.
351+
const question = request.prompt.match(/^Question : (.+)$/m)?.[1];
352+
if (question) {
353+
const text = CHAT_ANSWERS.find(({ words }) => words.test(question))?.answer ?? CHAT_FALLBACK;
354+
return { text, words: true };
355+
}
231356
// The sheet prompt names the species between « ».
232357
const name = request.prompt.match(/«\s*([^»]+?)\s*»/)?.[1] ?? 'Plante inconnue';
233358
const sheet =
@@ -236,10 +361,22 @@ function answer(request: GenerateRequest): string {
236361
s.scientific_name.toLowerCase() === name.toLowerCase() ||
237362
s.common_name.toLowerCase() === name.toLowerCase(),
238363
) ?? genericSheet(name);
364+
if (fields.includes('watering_advice')) return { text: JSON.stringify(sheetTexts(sheet)), words: false };
239365
// Written as the schema asks (misting 0 for none), wrapped in a code fence
240366
// as small models like to do.
241367
const json = JSON.stringify({ ...sheet, misting: sheet.misting ?? { interval_days: 0 } }, null, 2);
242-
return `Voici la fiche :\n\`\`\`json\n${json}\n\`\`\``;
368+
return { text: `Voici la fiche :\n\`\`\`json\n${json}\n\`\`\``, words: false };
369+
}
370+
371+
/** Writes `text` a word at a time through `onText`, like the real model streams it. */
372+
async function writeWords(text: string, { onText, signal }: GenerateRequest): Promise<string> {
373+
let written = '';
374+
for (const word of text.split(' ')) {
375+
await wait(WORD_DELAY_MS, signal);
376+
written = written ? `${written} ${word}` : word;
377+
onText?.(written);
378+
}
379+
return written;
243380
}
244381

245382
export const fakeEngine: AiEngine = {
@@ -283,6 +420,7 @@ export const fakeEngine: AiEngine = {
283420
async generate(request) {
284421
if (status.state !== 'ready') throw new Error('Le modèle n’est pas encore téléchargé.');
285422
await wait(ANSWER_DELAY_MS, request.signal);
286-
return answer(request);
423+
const reply = answer(request);
424+
return reply.words ? writeWords(reply.text, request) : reply.text;
287425
},
288426
};

0 commit comments

Comments
 (0)