import { FoodLogRepository } from '@/add-log/repository'; import { STORAGE_KEYS } from '@/common/constants/storageKeys'; import { resizeAndEncodeImage } from '@/common/utils/image'; import { captureException, flushIfDev, startSpan } from '@/remote/monitor'; import { getDatabase } from '@/remote/database'; import { strings } from '@/common/strings'; import type { BoundingBox, FoodLog, FoodLogStatus } from '@/types/foodlog'; import type { Nutrition } from '@/types/nutrition'; import AsyncStorage from '@react-native-async-storage/async-storage'; import NetInfo from '@react-native-community/netinfo'; import * as Crypto from 'expo-crypto'; import * as FileSystem from 'expo-file-system'; import { estimateNutrition, identifyFood, identifyMultipleFoods } from './foodDetectionService'; type IdentifiedItem = { foodName: string; ingredients: string[]; boundingBox?: BoundingBox; }; const MAX_ATTEMPTS = 3; let isProcessing = false; async function isCompareAiEnabled(): Promise { const raw = await AsyncStorage.getItem(STORAGE_KEYS.CASTLE_FEATURES); if (!raw) return false; try { return JSON.parse(raw).includes('compareAiModels'); } catch { return false; } } function normalizeIngredients(ingredients: string[]): string[] { return ingredients.map(ing => ing.trim()).filter(Boolean); } function buildIngredientList(candidate: { hpPredIngredients?: string[]; hrPredIngredients?: string[]; }): string[] { return normalizeIngredients([ ...(candidate.hpPredIngredients ?? []), ...(candidate.hrPredIngredients ?? []), ]); } async function markFailure(id: string, status: FoodLogStatus, attempts: number) { const db = getDatabase(); const repo = new FoodLogRepository(db); await repo.markFailure(id, status); } async function updateIdentification( id: string, foodName: string, ingredients: string[], boundingBox?: BoundingBox ): Promise { const db = getDatabase(); const repo = new FoodLogRepository(db); await repo.updateIdentification(id, foodName, ingredients, boundingBox); } async function updateNutrition( id: string, novaClass: number | undefined, nutrition: Nutrition | undefined ): Promise { const db = getDatabase(); const repo = new FoodLogRepository(db); await repo.updateNutrition(id, novaClass, nutrition); } async function ensurePhotoExists(photoUri: string): Promise { const info = await FileSystem.getInfoAsync(photoUri); if (!info.exists) { throw new Error('missing_photo'); } } async function insertAdditionalFoodLog(params: { profileId: string; photoUri: string; eatenAt: string; dayId: string; foodName: string; ingredients: string[]; boundingBox?: BoundingBox; }): Promise { const db = getDatabase(); const repo = new FoodLogRepository(db); const id = Crypto.randomUUID(); await repo.create({ id, profileId: params.profileId, dayId: params.dayId, foodName: params.foodName, ingredients: params.ingredients, photoUri: params.photoUri, eatenAt: new Date(params.eatenAt), status: 'pending_ai_nutrition_reco', boundingBox: params.boundingBox, }); return id; } async function runIdentification(log: FoodLog): Promise { const baseName = log.userInputFoodName ?? log.foodName; if (log.photoUri) { await ensurePhotoExists(log.photoUri); const base64 = await startSpan( { name: 'background_image_encode', op: 'serialize', attributes: { log_id: log.id } }, () => resizeAndEncodeImage(log.photoUri!) ); try { const items = await identifyMultipleFoods({ base64 }); if (items.length > 0) { return items.map(item => ({ foodName: item.foodName, ingredients: normalizeIngredients([...item.hpPredIngredients, ...item.hrPredIngredients]), boundingBox: item.boundingBox, })); } } catch { void 0; } const { result } = await identifyFood({ base64 }); const candidate = result?.candidates[0]; if (!candidate) { throw new Error('identification_failed'); } return [ { foodName: candidate.foodName ?? baseName ?? strings.addLog.detection.unknownFood, ingredients: buildIngredientList(candidate), }, ]; } if (!baseName) { throw new Error('missing_name'); } const { result } = await identifyFood({ text: baseName }); const candidate = result?.candidates[0]; if (!candidate) { throw new Error('identification_failed'); } return [ { foodName: candidate.foodName ?? baseName, ingredients: buildIngredientList(candidate), }, ]; } export async function processPendingFoodLogs(): Promise { if (isProcessing) return; isProcessing = true; let hasMore = false; try { const netState = await NetInfo.fetch(); const isOnline = !!netState.isConnected && netState.isInternetReachable !== false; if (!isOnline) return; const db = getDatabase(); const repo = new FoodLogRepository(db); const BATCH_SIZE = 5; const logs = await repo.getPendingProcessing(BATCH_SIZE); if (logs.length === 0) return; hasMore = logs.length === BATCH_SIZE; for (const log of logs) { const attempts = log.processingAttempts ?? 0; const nextAttempt = attempts + 1; if (attempts >= MAX_ATTEMPTS) { await markFailure(log.id, log.processingStatus, attempts); continue; } if (log.processingStatus === 'pending_ai_food_reco') { const hasRemotePhoto = !!log.remotePhotoPath; const hasLocalPhoto = !!log.photoUri; if (hasRemotePhoto && !hasLocalPhoto) { continue; } } try { await startSpan( { name: 'background_processing', op: 'task', forceTransaction: true, attributes: { log_id: log.id, status: log.processingStatus }, }, async span => { if (log.processingStatus === 'pending_ai_food_reco') { await startSpan({ name: 'background_food_reco', op: 'ai.inference' }, async () => { const items = await runIdentification(log); const [first, ...rest] = items; await updateIdentification( log.id, first.foodName, first.ingredients, first.boundingBox ); log.foodName = first.foodName; log.ingredients = first.ingredients; log.processingStatus = 'pending_ai_nutrition_reco'; if (log.profileId && log.dayId) { for (const item of rest) { await insertAdditionalFoodLog({ profileId: log.profileId, photoUri: log.photoUri!, eatenAt: log.eatenAt, dayId: log.dayId, foodName: item.foodName, ingredients: item.ingredients, boundingBox: item.boundingBox, }); } } }); } if (log.processingStatus === 'pending_ai_nutrition_reco') { await startSpan( { name: 'background_nutrition_reco', op: 'ai.inference' }, async () => { const ingredients = normalizeIngredients(log.ingredients ?? []); if (log.profileId && (await isCompareAiEnabled())) { const { analyzeNutritionWithComparison } = await import( './compareAiForNutrition' ); const result = await analyzeNutritionWithComparison({ foodLogId: log.id, profileId: log.profileId, foodName: log.foodName, ingredients, }); await updateNutrition(log.id, result.novaClass, result.nutrition); } else { const result = await estimateNutrition({ foodName: log.foodName, ingredients, }); await updateNutrition(log.id, result.novaClass, result.nutrition); } } ); } span.setStatus({ code: 1 }); } ); await flushIfDev(); } catch (e) { captureException(e, { logId: log.id, status: log.processingStatus }); await markFailure(log.id, log.processingStatus, nextAttempt); } } } finally { isProcessing = false; if (hasMore) { setTimeout(() => processPendingFoodLogs(), 100); } } }