462 lines
18 KiB
JavaScript
462 lines
18 KiB
JavaScript
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim();
|
||
const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5-mini').trim();
|
||
const OPENAI_SCAN_MODEL_PRO = (process.env.OPENAI_SCAN_MODEL_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL_PRO || OPENAI_SCAN_MODEL).trim();
|
||
const OPENAI_HEALTH_MODEL = (process.env.OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim();
|
||
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.OPENAI_SCAN_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4.1-mini').trim();
|
||
const OPENAI_SCAN_FALLBACK_MODELS_PRO = (process.env.OPENAI_SCAN_FALLBACK_MODELS_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS_PRO || OPENAI_SCAN_FALLBACK_MODELS).trim();
|
||
const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
|
||
const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim();
|
||
const OPENAI_TIMEOUT_MS = (() => {
|
||
const raw = (process.env.OPENAI_TIMEOUT_MS || process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim();
|
||
const parsed = Number.parseInt(raw, 10);
|
||
if (Number.isFinite(parsed) && parsed >= 10000) return parsed;
|
||
return 45000;
|
||
})();
|
||
|
||
const parseModelChain = (primaryModel, fallbackModels) => {
|
||
const models = [primaryModel];
|
||
for (const model of String(fallbackModels || '').split(',')) {
|
||
const normalized = model.trim();
|
||
if (!normalized) continue;
|
||
models.push(normalized);
|
||
}
|
||
return [...new Set(models.filter(Boolean))];
|
||
};
|
||
|
||
const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_MODELS);
|
||
const OPENAI_SCAN_MODEL_CHAIN_PRO = parseModelChain(OPENAI_SCAN_MODEL_PRO, OPENAI_SCAN_FALLBACK_MODELS_PRO);
|
||
const OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS);
|
||
|
||
const getScanModelChain = (plan) => {
|
||
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
|
||
};
|
||
|
||
const clamp = (value, min, max) => {
|
||
return Math.min(max, Math.max(min, value));
|
||
};
|
||
|
||
const toErrorMessage = (error) => {
|
||
if (error instanceof Error) return error.message;
|
||
return String(error);
|
||
};
|
||
|
||
const summarizeImageUri = (imageUri) => {
|
||
const trimmed = typeof imageUri === 'string' ? imageUri.trim() : '';
|
||
if (!trimmed) return 'empty';
|
||
if (trimmed.startsWith('data:image')) return `data-uri(${Math.round(trimmed.length / 1024)}kb)`;
|
||
return trimmed.length > 120 ? `${trimmed.slice(0, 120)}...` : trimmed;
|
||
};
|
||
|
||
const toJsonString = (content) => {
|
||
const trimmed = typeof content === 'string' ? content.trim() : '';
|
||
if (!trimmed) return '';
|
||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||
if (fenced && fenced[1]) return fenced[1].trim();
|
||
return trimmed;
|
||
};
|
||
|
||
const parseContentToJson = (content) => {
|
||
try {
|
||
const parsed = JSON.parse(toJsonString(content));
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
return parsed;
|
||
}
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const getString = (value) => {
|
||
return typeof value === 'string' ? value.trim() : '';
|
||
};
|
||
|
||
const getNumber = (value) => {
|
||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||
if (typeof value === 'string' && value.trim()) {
|
||
const parsed = Number(value);
|
||
if (Number.isFinite(parsed)) return parsed;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const getStringArray = (value) => {
|
||
if (!Array.isArray(value)) return [];
|
||
return value
|
||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||
.filter(Boolean);
|
||
};
|
||
|
||
const getLanguageLabel = (language) => {
|
||
if (language === 'de') return 'German';
|
||
if (language === 'es') return 'Spanish';
|
||
return 'English';
|
||
};
|
||
|
||
const normalizeIdentifyResult = (raw, language) => {
|
||
const name = getString(raw.name);
|
||
const botanicalName = getString(raw.botanicalName);
|
||
const description = getString(raw.description);
|
||
const confidenceRaw = getNumber(raw.confidence);
|
||
const careInfoRaw = raw.careInfo;
|
||
|
||
if (!name || !botanicalName || !careInfoRaw || typeof careInfoRaw !== 'object' || Array.isArray(careInfoRaw)) {
|
||
return null;
|
||
}
|
||
|
||
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
|
||
const light = getString(careInfoRaw.light);
|
||
const temp = getString(careInfoRaw.temp);
|
||
if (waterIntervalRaw == null || !light || !temp) {
|
||
return null;
|
||
}
|
||
|
||
const fallbackDescription = language === 'de'
|
||
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
|
||
: language === 'es'
|
||
? `${name} se detecto con IA. Debajo veras recomendaciones de cuidado.`
|
||
: `${name} was identified with AI. Care guidance is shown below.`;
|
||
|
||
return {
|
||
name,
|
||
botanicalName,
|
||
confidence: clamp(confidenceRaw == null ? 0.72 : confidenceRaw, 0.05, 0.99),
|
||
description: description || fallbackDescription,
|
||
careInfo: {
|
||
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
|
||
light,
|
||
temp,
|
||
},
|
||
};
|
||
};
|
||
|
||
const normalizeHealthAnalysis = (raw, language) => {
|
||
const scoreRaw = getNumber(raw.overallHealthScore);
|
||
const statusRaw = getString(raw.status);
|
||
const issuesRaw = raw.likelyIssues;
|
||
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
|
||
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
|
||
|
||
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
|
||
return null;
|
||
}
|
||
|
||
const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
|
||
? statusRaw
|
||
: 'watch';
|
||
|
||
const likelyIssues = issuesRaw
|
||
.map((entry) => {
|
||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
||
const title = getString(entry.title);
|
||
const details = getString(entry.details);
|
||
const confidenceRaw = getNumber(entry.confidence);
|
||
if (!title || !details || confidenceRaw == null) return null;
|
||
return {
|
||
title,
|
||
details,
|
||
confidence: clamp(confidenceRaw, 0.05, 0.99),
|
||
};
|
||
})
|
||
.filter(Boolean)
|
||
.slice(0, 4);
|
||
|
||
if (likelyIssues.length === 0 || actionsNowRaw.length < 2 || plan7DaysRaw.length < 2) {
|
||
const fallbackIssue = language === 'de'
|
||
? 'Die KI konnte keine stabilen Gesundheitsmerkmale extrahieren.'
|
||
: language === 'es'
|
||
? 'La IA no pudo extraer senales de salud estables.'
|
||
: 'AI could not extract stable health signals.';
|
||
return {
|
||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||
status,
|
||
likelyIssues: [
|
||
{
|
||
title: language === 'de'
|
||
? 'Analyse unsicher'
|
||
: language === 'es'
|
||
? 'Analisis incierto'
|
||
: 'Uncertain analysis',
|
||
confidence: 0.35,
|
||
details: fallbackIssue,
|
||
},
|
||
],
|
||
actionsNow: actionsNowRaw.length > 0
|
||
? actionsNowRaw
|
||
: [language === 'de' ? 'Neues, schaerferes Foto aufnehmen.' : language === 'es' ? 'Tomar una foto nueva y mas nitida.' : 'Capture a new, sharper photo.'],
|
||
plan7Days: plan7DaysRaw.length > 0
|
||
? plan7DaysRaw
|
||
: [language === 'de' ? 'In 2 Tagen erneut pruefen.' : language === 'es' ? 'Volver a revisar en 2 dias.' : 'Re-check in 2 days.'],
|
||
};
|
||
}
|
||
|
||
return {
|
||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||
status,
|
||
likelyIssues,
|
||
actionsNow: actionsNowRaw,
|
||
plan7Days: plan7DaysRaw,
|
||
};
|
||
};
|
||
|
||
const buildIdentifyPrompt = (language, mode) => {
|
||
const reviewInstruction = mode === 'review'
|
||
? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.'
|
||
: 'Identify the most likely houseplant species from this image with conservative confidence.';
|
||
|
||
const nameLanguageInstruction = language === 'en'
|
||
? '- "name" must be an English common name only. Never return a German or other non-English common name. If no reliable English common name is known, use "botanicalName" as "name" instead of inventing or translating.'
|
||
: `- "name" must be strictly written in ${getLanguageLabel(language)}. If a reliable common name in that language is not known, use "botanicalName" as "name" instead of inventing a localized name.`;
|
||
|
||
return [
|
||
`${reviewInstruction}`,
|
||
'Return strict JSON only in this shape:',
|
||
'{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}',
|
||
'Rules:',
|
||
nameLanguageInstruction,
|
||
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
|
||
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
|
||
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
|
||
'- "confidence" must be between 0 and 1.',
|
||
'- Keep confidence <= 0.55 when the image is ambiguous, blurred, or partially visible.',
|
||
'- "waterIntervalDays" must be an integer between 1 and 45.',
|
||
'- Do not include markdown, explanations, or extra keys.',
|
||
].join('\n');
|
||
};
|
||
|
||
const buildHealthPrompt = (language, plantContext) => {
|
||
const contextLines = plantContext
|
||
? [
|
||
'Plant context:',
|
||
`- name: ${plantContext.name || 'n/a'}`,
|
||
`- botanicalName: ${plantContext.botanicalName || 'n/a'}`,
|
||
`- care.light: ${plantContext.careInfo?.light || 'n/a'}`,
|
||
`- care.temp: ${plantContext.careInfo?.temp || 'n/a'}`,
|
||
`- care.waterIntervalDays: ${plantContext.careInfo?.waterIntervalDays || 'n/a'}`,
|
||
`- description: ${plantContext.description || 'n/a'}`,
|
||
]
|
||
: ['Plant context: not provided'];
|
||
|
||
return [
|
||
`You are an expert botanist and plant health diagnostician. Carefully examine every visible detail of this plant photo and produce a thorough, professional health assessment written in ${getLanguageLabel(language)}.`,
|
||
'',
|
||
'Inspect the following in detail: leaf color (yellowing, browning, bleaching, dark spots, necrosis), leaf texture (wilting, crispy edges, curling, drooping), stem condition (rot, soft spots, discoloration), soil surface (dry cracks, mold, pests, waterlogging signs), visible pests (spider mites, fungus gnats, scale insects, aphids, mealybugs), root health (if visible), pot size and drainage.',
|
||
'',
|
||
'Return strict JSON only in this exact shape:',
|
||
'{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}',
|
||
'',
|
||
'Rules:',
|
||
'- "overallHealthScore": integer 0–100. 100=perfect health, 80–99=minor cosmetic only, 60–79=noticeable issues needing attention, 40–59=significant stress, below 40=severe/critical.',
|
||
'- "status": exactly one of "healthy" (score>=80, no active threats), "watch" (score 50–79, needs monitoring), "critical" (score<50, urgent action needed).',
|
||
'- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:',
|
||
' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")',
|
||
' - "confidence": float 0.05–0.99 reflecting visual certainty',
|
||
' - "details": 2–4 sentence detailed explanation of what you observe visually, what causes it, and what happens if untreated. Be specific — mention leaf color, location, pattern.',
|
||
`- "actionsNow": 5 to 8 specific, actionable steps for the next 24–48 hours. Each step must be a complete sentence with concrete instructions (e.g. amounts, durations, techniques). Written in ${getLanguageLabel(language)}.`,
|
||
`- "plan7Days": 7 to 10 day-by-day or milestone care steps for the coming week. Each step should specify timing and expected outcome. Written in ${getLanguageLabel(language)}.`,
|
||
'- All text fields must be written in the specified language. No markdown, no extra keys.',
|
||
...contextLines,
|
||
].join('\n');
|
||
};
|
||
|
||
const extractMessageContent = (payload) => {
|
||
const content = payload?.choices?.[0]?.message?.content;
|
||
if (typeof content === 'string') return content;
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.map((chunk) => (chunk && chunk.type === 'text' ? chunk.text || '' : ''))
|
||
.join('')
|
||
.trim();
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const postChatCompletion = async ({ modelChain, messages, imageUri, temperature }) => {
|
||
if (!OPENAI_API_KEY) return null;
|
||
if (typeof fetch !== 'function') {
|
||
throw new Error('Global fetch is not available in this Node runtime.');
|
||
}
|
||
|
||
const attemptedModels = [];
|
||
|
||
for (const model of modelChain) {
|
||
attemptedModels.push(model);
|
||
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||
|
||
try {
|
||
const body = {
|
||
model,
|
||
response_format: { type: 'json_object' },
|
||
messages,
|
||
};
|
||
if (typeof temperature === 'number') body.temperature = temperature;
|
||
|
||
const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const body = await response.text();
|
||
console.warn('OpenAI request HTTP error.', {
|
||
status: response.status,
|
||
model,
|
||
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
|
||
image: summarizeImageUri(imageUri),
|
||
bodyPreview: body.slice(0, 300),
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const payload = await response.json();
|
||
return { payload, modelUsed: model, attemptedModels };
|
||
} catch (error) {
|
||
const isTimeoutAbort = error instanceof Error && error.name === 'AbortError';
|
||
console.warn('OpenAI request failed.', {
|
||
model,
|
||
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
|
||
timeoutMs: OPENAI_TIMEOUT_MS,
|
||
aborted: isTimeoutAbort,
|
||
error: toErrorMessage(error),
|
||
image: summarizeImageUri(imageUri),
|
||
});
|
||
continue;
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
}
|
||
}
|
||
|
||
return { payload: null, modelUsed: null, attemptedModels };
|
||
};
|
||
|
||
const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'free' }) => {
|
||
if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] };
|
||
const modelChain = getScanModelChain(plan);
|
||
const completion = await postChatCompletion({
|
||
modelChain,
|
||
imageUri,
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: 'You are a plant identification assistant. Return strict JSON only.',
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
|
||
{ type: 'image_url', image_url: { url: imageUri } },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
if (!completion?.payload) {
|
||
return {
|
||
result: null,
|
||
modelUsed: completion?.modelUsed || null,
|
||
attemptedModels: completion?.attemptedModels || [],
|
||
};
|
||
}
|
||
|
||
const content = extractMessageContent(completion.payload);
|
||
if (!content) {
|
||
console.warn('OpenAI identify returned empty content.', {
|
||
model: completion.modelUsed || modelChain[0],
|
||
mode,
|
||
image: summarizeImageUri(imageUri),
|
||
});
|
||
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||
}
|
||
|
||
const parsed = parseContentToJson(content);
|
||
if (!parsed) {
|
||
console.warn('OpenAI identify returned non-JSON content.', {
|
||
model: completion.modelUsed || modelChain[0],
|
||
mode,
|
||
preview: content.slice(0, 220),
|
||
});
|
||
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||
}
|
||
|
||
const normalized = normalizeIdentifyResult(parsed, language);
|
||
if (!normalized) {
|
||
console.warn('OpenAI identify JSON did not match schema.', {
|
||
model: completion.modelUsed || modelChain[0],
|
||
mode,
|
||
keys: Object.keys(parsed),
|
||
});
|
||
}
|
||
return { result: normalized, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||
};
|
||
|
||
const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
|
||
if (!OPENAI_API_KEY) return { analysis: null, modelUsed: null, attemptedModels: [] };
|
||
const completion = await postChatCompletion({
|
||
modelChain: OPENAI_HEALTH_MODEL_CHAIN,
|
||
imageUri,
|
||
temperature: 0,
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: 'You are a plant health diagnosis assistant. Return strict JSON only.',
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
|
||
{ type: 'image_url', image_url: { url: imageUri } },
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
if (!completion?.payload) {
|
||
return {
|
||
analysis: null,
|
||
modelUsed: completion?.modelUsed || null,
|
||
attemptedModels: completion?.attemptedModels || [],
|
||
};
|
||
}
|
||
|
||
const content = extractMessageContent(completion.payload);
|
||
if (!content) {
|
||
console.warn('OpenAI health returned empty content.', {
|
||
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
|
||
image: summarizeImageUri(imageUri),
|
||
});
|
||
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||
}
|
||
|
||
const parsed = parseContentToJson(content);
|
||
if (!parsed) {
|
||
console.warn('OpenAI health returned non-JSON content.', {
|
||
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
|
||
preview: content.slice(0, 220),
|
||
});
|
||
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||
}
|
||
|
||
return {
|
||
analysis: normalizeHealthAnalysis(parsed, language),
|
||
modelUsed: completion.modelUsed,
|
||
attemptedModels: completion.attemptedModels,
|
||
};
|
||
};
|
||
|
||
module.exports = {
|
||
analyzePlantHealth,
|
||
buildIdentifyPrompt,
|
||
getHealthModel: () => OPENAI_HEALTH_MODEL_CHAIN[0],
|
||
getScanModel: (plan = 'free') => getScanModelChain(plan)[0],
|
||
identifyPlant,
|
||
isConfigured: () => Boolean(OPENAI_API_KEY),
|
||
normalizeIdentifyResult,
|
||
};
|