import { CareInfo, IdentificationResult, Language } from '../../types'; type OpenAiScanMode = 'primary' | 'review'; export interface OpenAiHealthIssue { title: string; confidence: number; details: string; } export interface OpenAiHealthAnalysis { overallHealthScore: number; status: 'healthy' | 'watch' | 'critical'; likelyIssues: OpenAiHealthIssue[]; actionsNow: string[]; plan7Days: string[]; } const OPENAI_API_KEY = (process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim(); const OPENAI_SCAN_MODEL = (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5-mini').trim(); const OPENAI_SCAN_MODEL_PRO = (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL_PRO || OPENAI_SCAN_MODEL).trim(); const OPENAI_HEALTH_MODEL = (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim(); const 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.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS_PRO || OPENAI_SCAN_FALLBACK_MODELS).trim(); const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim(); const OPENAI_CHAT_COMPLETIONS_URL = 'https://api.openai.com/v1/chat/completions'; const OPENAI_TIMEOUT_MS = (() => { const raw = (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: string, fallbackModels: string): string[] => { const models = [primaryModel]; fallbackModels.split(',').forEach((model) => { const normalized = model.trim(); if (normalized) models.push(normalized); }); return [...new Set(models)]; }; 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: 'free' | 'pro'): string[] => { return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN; }; const clamp = (value: number, min: number, max: number): number => { return Math.min(max, Math.max(min, value)); }; const toErrorMessage = (error: unknown): string => { if (error instanceof Error) return error.message; return String(error); }; const summarizeImageUri = (imageUri: string): string => { const trimmed = 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: string): string => { const trimmed = content.trim(); if (!trimmed) return trimmed; const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); if (fenced?.[1]) return fenced[1].trim(); return trimmed; }; const parseContentToJson = (content: string): Record | null => { try { const parsed = JSON.parse(toJsonString(content)); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record; } return null; } catch { return null; } }; const getString = (value: unknown): string => { return typeof value === 'string' ? value.trim() : ''; }; const getNumber = (value: unknown): number | null => { 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: unknown): string[] => { if (!Array.isArray(value)) return []; return value .map((item) => (typeof item === 'string' ? item.trim() : '')) .filter(Boolean); }; const normalizeResult = ( raw: Record, language: Language, ): IdentificationResult | null => { 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 careInfoObj = careInfoRaw as Record; const waterIntervalRaw = getNumber(careInfoObj.waterIntervalDays); const light = getString(careInfoObj.light); const temp = getString(careInfoObj.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 ?? 0.72, 0.05, 0.99), description: description || fallbackDescription, careInfo: { waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)), light, temp, }, }; }; const getLanguageLabel = (language: Language): string => { if (language === 'de') return 'German'; if (language === 'es') return 'Spanish'; return 'English'; }; const buildPrompt = (language: Language, mode: OpenAiScanMode): string => { 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: Language, plantContext?: { name: string; botanicalName: string; careInfo: CareInfo; description?: string; }, ): string => { const contextLines = plantContext ? [ `Plant context:`, `- name: ${plantContext.name}`, `- botanicalName: ${plantContext.botanicalName}`, `- care.light: ${plantContext.careInfo.light}`, `- care.temp: ${plantContext.careInfo.temp}`, `- care.waterIntervalDays: ${plantContext.careInfo.waterIntervalDays}`, `- description: ${plantContext.description || 'n/a'}`, ] : ['Plant context: not provided']; return [ `Analyze this plant photo for real health condition signs with focus on yellowing leaves, watering stress, pests, and light stress.`, `Return strict JSON only in this shape:`, `{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}`, `Rules:`, `- "overallHealthScore" must be an integer between 0 and 100.`, `- "status" must be one of: "healthy", "watch", "critical".`, `- "likelyIssues" must contain 1 to 4 items sorted by confidence descending.`, `- "confidence" must be between 0 and 1.`, `- "title", "details", "actionsNow", and "plan7Days" must be written in ${getLanguageLabel(language)}.`, `- "actionsNow" should be immediate steps for the next 24 hours.`, `- "plan7Days" should be short actionable steps for the next week.`, `- Do not include markdown, explanations, or extra keys.`, ...contextLines, ].join('\n'); }; const buildFallbackHealthAnalysis = ( language: Language, plantContext?: { name: string; botanicalName: string; careInfo: CareInfo; description?: string; }, ): OpenAiHealthAnalysis => { if (language === 'de') { return { overallHealthScore: 58, status: 'watch', likelyIssues: [ { title: 'Eingeschraenkte KI-Analyse', confidence: 0.42, details: `${plantContext?.name || 'Die Pflanze'} konnte wegen instabiler Antwort nicht vollstaendig bewertet werden.`, }, ], actionsNow: [ 'Neues Foto bei hellem, indirektem Licht aufnehmen.', 'Blaetter auf Flecken, Schaedlinge und trockene Raender pruefen.', 'Erst giessen, wenn die oberen 2-3 cm Erde trocken sind.', ], plan7Days: [ 'In 2 Tagen mit neuem Foto erneut pruefen.', 'Farbe und Blattspannung taeglich beobachten.', 'Bei Verschlechterung Standort und Giessrhythmus anpassen.', ], }; } if (language === 'es') { return { overallHealthScore: 58, status: 'watch', likelyIssues: [ { title: 'Analisis de IA limitado', confidence: 0.42, details: `${plantContext?.name || 'La planta'} no pudo evaluarse por completo por una respuesta inestable.`, }, ], actionsNow: [ 'Tomar una foto nueva con luz brillante e indirecta.', 'Revisar hojas por manchas, plagas y bordes secos.', 'Regar solo si los 2-3 cm superiores del sustrato estan secos.', ], plan7Days: [ 'Revisar otra vez en 2 dias con una foto nueva.', 'Observar color y firmeza de hojas cada dia.', 'Si empeora, ajustar ubicacion y frecuencia de riego.', ], }; } return { overallHealthScore: 58, status: 'watch', likelyIssues: [ { title: 'Limited AI analysis', confidence: 0.42, details: `${plantContext?.name || 'This plant'} could not be fully assessed due to an unstable provider response.`, }, ], actionsNow: [ 'Capture a new photo in bright indirect light.', 'Inspect leaves for spots, pests, and dry edges.', 'Water only if the top 2-3 cm of soil is dry.', ], plan7Days: [ 'Re-check in 2 days with a new photo.', 'Track leaf color and firmness daily.', 'If symptoms worsen, adjust placement and watering cadence.', ], }; }; const normalizeHealthAnalysis = ( raw: Record, language: Language, ): OpenAiHealthAnalysis | null => { const scoreRaw = getNumber(raw.overallHealthScore); const statusRaw = getString(raw.status); const issuesRaw = raw.likelyIssues; const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 6); const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 7); if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) { return null; } const status: OpenAiHealthAnalysis['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 issueObj = entry as Record; const title = getString(issueObj.title); const details = getString(issueObj.details); const confidenceRaw = getNumber(issueObj.confidence); if (!title || !details || confidenceRaw == null) return null; return { title, details, confidence: clamp(confidenceRaw, 0.05, 0.99), } as OpenAiHealthIssue; }) .filter((entry): entry is OpenAiHealthIssue => Boolean(entry)) .slice(0, 4); if (likelyIssues.length === 0 || actionsNowRaw.length === 0 || plan7DaysRaw.length === 0) { 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 extractMessageContent = (payload: unknown): string => { const response = payload as { choices?: Array<{ message?: { content?: string | Array<{ type?: string; text?: string }>; }; }>; }; const content = response.choices?.[0]?.message?.content; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .map((chunk) => (chunk?.type === 'text' ? chunk.text || '' : '')) .join('') .trim(); } return ''; }; const postChatCompletion = async ( modelChain: string[], imageUri: string, messages: Array>, ): Promise<{ payload: Record | null; modelUsed: string | null; attemptedModels: string[] }> => { const attemptedModels: string[] = []; for (const model of modelChain) { attemptedModels.push(model); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS); try { const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${OPENAI_API_KEY}`, }, body: JSON.stringify({ model, response_format: { type: 'json_object' }, messages, }), signal: controller.signal, }); if (!response.ok) { const body = await response.text(); console.warn('OpenAI request HTTP error.', { status: response.status, model, image: summarizeImageUri(imageUri), bodyPreview: body.slice(0, 300), }); continue; } const payload = (await response.json()) as Record; return { payload, modelUsed: model, attemptedModels }; } catch (error) { const isTimeoutAbort = error instanceof Error && error.name === 'AbortError'; console.warn('OpenAI request failed.', { model, timeoutMs: OPENAI_TIMEOUT_MS, aborted: isTimeoutAbort, error: toErrorMessage(error), image: summarizeImageUri(imageUri), }); continue; } finally { clearTimeout(timeout); } } return { payload: null, modelUsed: null, attemptedModels }; }; export const openAiScanService = { isConfigured: (): boolean => Boolean(OPENAI_API_KEY), identifyPlant: async ( imageUri: string, language: Language, mode: OpenAiScanMode = 'primary', plan: 'free' | 'pro' = 'free', ): Promise => { if (!OPENAI_API_KEY) return null; const modelChain = getScanModelChain(plan); const completion = await postChatCompletion( modelChain, imageUri, [ { role: 'system', content: 'You are a plant identification assistant. Return strict JSON only.', }, { role: 'user', content: [ { type: 'text', text: buildPrompt(language, mode) }, { type: 'image_url', image_url: { url: imageUri } }, ], }, ], ); if (!completion.payload) return null; const content = extractMessageContent(completion.payload); if (!content) { console.warn('OpenAI plant scan returned empty message content.', { model: completion.modelUsed || modelChain[0], mode, image: summarizeImageUri(imageUri), }); return null; } const parsed = parseContentToJson(content); if (!parsed) { console.warn('OpenAI plant scan returned non-JSON content.', { model: completion.modelUsed || modelChain[0], mode, preview: content.slice(0, 220), }); return null; } const normalized = normalizeResult(parsed, language); if (!normalized) { console.warn('OpenAI plant scan JSON did not match required schema.', { model: completion.modelUsed || modelChain[0], mode, keys: Object.keys(parsed), }); } return normalized; }, analyzePlantHealth: async ( imageUri: string, language: Language, plantContext?: { name: string; botanicalName: string; careInfo: CareInfo; description?: string; }, ): Promise => { if (!OPENAI_API_KEY) return null; const completion = await postChatCompletion( OPENAI_HEALTH_MODEL_CHAIN, imageUri, [ { 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 buildFallbackHealthAnalysis(language, plantContext); const content = extractMessageContent(completion.payload); if (!content) { console.warn('OpenAI health check returned empty content.', { model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0], image: summarizeImageUri(imageUri), }); return buildFallbackHealthAnalysis(language, plantContext); } const parsed = parseContentToJson(content); if (!parsed) { console.warn('OpenAI health check returned non-JSON content.', { model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0], preview: content.slice(0, 220), }); return buildFallbackHealthAnalysis(language, plantContext); } return normalizeHealthAnalysis(parsed, language) || buildFallbackHealthAnalysis(language, plantContext); }, };