Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
44 KiB
JSON
1 line
44 KiB
JSON
{"repo": "adbayu/modul-analisis-gizi-mbgflow", "files": {"backend/services/aiNutritionService.js": {"56c44efc02c9559dabcf853470a584faea97c599": {"t": 1786068271.4598615, "n": 42664, "h": "506e0fd3ceb4fe6c", "c": "function extractJsonFromText(text) {\n let cleaned = text\n .replace(/^```json\\s*/i, \"\")\n .replace(/^```\\s*/, \"\")\n .replace(/```$/, \"\")\n .replace(/^[^{]*?\\{/s, \"{\")\n .replace(/\\}[^}]*?$/, \"}\")\n .trim();\n\n try {\n return JSON.parse(cleaned);\n } catch {}\n\n const match = cleaned.match(/\\{[\\s\\S]*\\}/);\n if (match) {\n try {\n return JSON.parse(match[0]);\n } catch {}\n }\n\n throw new Error(\"Respons AI tidak bisa di-parse sebagai JSON\");\n}\n\nconst { GoogleGenerativeAI } = require(\"@google/generative-ai\");\nconst { retryWithBackoff } = require(\"../utils/retryHelper\");\n\nconst STATUS = {\n kurang: \"Kurang\",\n cukup: \"Cukup\",\n optimal: \"Optimal\",\n mulaiBerlebih: \"Mulai Berlebih\",\n terlaluBerlebih: \"Terlalu Berlebih\",\n tercatat: \"Tercatat\",\n};\n\nconst TARGET_AKG_2019 = {\n siswa: {\n label: \"Siswa\",\n focus:\n \"energi untuk aktivitas belajar, protein untuk pertumbuhan, zat besi, vitamin A/C, dan keseimbangan makro\",\n nutrients: {\n kalori: { label: \"Kalori\", target: 600, unit: \"kkal\", category: \"macro\" },\n protein: { label: \"Protein\", target: 25, unit: \"g\", category: \"macro\" },\n karbohidrat: {\n label: \"Karbohidrat\",\n target: 80,\n unit: \"g\",\n category: \"macro\",\n },\n lemak: { label: \"Lemak\", target: 18, unit: \"g\", category: \"macro\" },\n serat: { label: \"Serat\", target: 8, unit: \"g\", category: \"micro\" },\n gula: { label: \"Gula\", target: 12, unit: \"g\", category: \"micro\", limitOnly: true },\n \"vitamin a\": { label: \"Vitamin A\", target: 180, unit: \"mcg\", category: \"micro\" },\n \"vitamin c\": { label: \"Vitamin C\", target: 18, unit: \"mg\", category: \"micro\" },\n \"vitamin d\": { label: \"Vitamin D\", target: 4.5, unit: \"mcg\", category: \"micro\" },\n kalsium: { label: \"Kalsium\", target: 300, unit: \"mg\", category: \"micro\" },\n \"zat besi\": { label: \"Zat Besi\", target: 3, unit: \"mg\", category: \"micro\" },\n zinc: { label: \"Zinc\", target: 2.5, unit: \"mg\", category: \"micro\" },\n folat: { label: \"Folat\", target: 120, unit: \"mcg\", category: \"micro\" },\n natrium: { label: \"Natrium\", target: 450, unit: \"mg\", category: \"micro\", limitOnly: true },\n kalium: { label: \"Kalium\", target: 900, unit: \"mg\", category: \"micro\" },\n \"omega 3\": { label: \"Omega-3\", target: 0.3, unit: \"g\", category: \"micro\" },\n },\n },\n balita: {\n label: \"Balita\",\n focus:\n \"kepadatan energi, protein mudah cerna, lemak cukup, zat besi, zinc, vitamin A, dan tekstur menu\",\n nutrients: {\n kalori: { label: \"Kalori\", target: 450, unit: \"kkal\", category: \"macro\" },\n protein: { label: \"Protein\", target: 20, unit: \"g\", category: \"macro\" },\n karbohidrat: {\n label: \"Karbohidrat\",\n target: 65,\n unit: \"g\",\n category: \"macro\",\n },\n lemak: { label: \"Lemak\", target: 15, unit: \"g\", category: \"macro\" },\n serat: { label: \"Serat\", target: 5, unit: \"g\", category: \"micro\" },\n gula: { label: \"Gula\", target: 8, unit: \"g\", category: \"micro\", limitOnly: true },\n \"vitamin a\": { label: \"Vitamin A\", target: 120, unit: \"mcg\", category: \"micro\" },\n \"vitamin c\": { label: \"Vitamin C\", target: 12, unit: \"mg\", category: \"micro\" },\n \"vitamin d\": { label: \"Vitamin D\", target: 4.5, unit: \"mcg\", category: \"micro\" },\n kalsium: { label: \"Kalsium\", target: 195, unit: \"mg\", category: \"micro\" },\n \"zat besi\": { label: \"Zat Besi\", target: 2.1, unit: \"mg\", category: \"micro\" },\n zinc: { label: \"Zinc\", target: 1.5, unit: \"mg\", category: \"micro\" },\n folat: { label: \"Folat\", target: 48, unit: \"mcg\", category: \"micro\" },\n natrium: { label: \"Natrium\", target: 240, unit: \"mg\", category: \"micro\", limitOnly: true },\n kalium: { label: \"Kalium\", target: 780, unit: \"mg\", category: \"micro\" },\n \"omega 3\": { label: \"Omega-3\", target: 0.21, unit: \"g\", category: \"micro\" },\n },\n },\n ibu_hamil: {\n label: \"Ibu Hamil\",\n focus:\n \"tambahan energi, protein, zat besi, folat, kalsium, vitamin D, dan kualitas karbohidrat\",\n nutrients: {\n kalori: { label: \"Kalori\", target: 750, unit: \"kkal\", category: \"macro\" },\n protein: { label: \"Protein\", target: 30, unit: \"g\", category: \"macro\" },\n karbohidrat: {\n label: \"Karbohidrat\",\n target: 90,\n unit: \"g\",\n category: \"macro\",\n },\n lemak: { label: \"Lemak\", target: 22, unit: \"g\", category: \"macro\" },\n serat: { label: \"Serat\", target: 10, unit: \"g\", category: \"micro\" },\n gula: { label: \"Gula\", target: 15, unit: \"g\", category: \"micro\", limitOnly: true },\n \"vitamin a\": { label: \"Vitamin A\", target: 255, unit: \"mcg\", category: \"micro\" },\n \"vitamin c\": { label: \"Vitamin C\", target: 25.5, unit: \"mg\", category: \"micro\" },\n \"vitamin d\": { label: \"Vitamin D\", target: 4.5, unit: \"mcg\", category: \"micro\" },\n kalsium: { label: \"Kalsium\", target: 360, unit: \"mg\", category: \"micro\" },\n \"zat besi\": { label: \"Zat Besi\", target: 8.1, unit: \"mg\", category: \"micro\" },\n zinc: { label: \"Zinc\", target: 3.6, unit: \"mg\", category: \"micro\" },\n folat: { label: \"Folat\", target: 180, unit: \"mcg\", category: \"micro\" },\n natrium: { label: \"Natrium\", target: 450, unit: \"mg\", category: \"micro\", limitOnly: true },\n kalium: { label: \"Kalium\", target: 1410, unit: \"mg\", category: \"micro\" },\n \"omega 3\": { label: \"Omega-3\", target: 0.42, unit: \"g\", category: \"micro\" },\n },\n },\n ibu_menyusui: {\n label: \"Ibu Menyusui\",\n focus:\n \"energi laktasi, protein, cairan, kalsium, vitamin A/C, zinc, dan lemak sehat\",\n nutrients: {\n kalori: { label: \"Kalori\", target: 800, unit: \"kkal\", category: \"macro\" },\n protein: { label: \"Protein\", target: 32, unit: \"g\", category: \"macro\" },\n karbohidrat: {\n label: \"Karbohidrat\",\n target: 95,\n unit: \"g\",\n category: \"macro\",\n },\n lemak: { label: \"Lemak\", target: 24, unit: \"g\", category: \"macro\" },\n serat: { label: \"Serat\", target: 11, unit: \"g\", category: \"micro\" },\n gula: { label: \"Gula\", target: 15, unit: \"g\", category: \"micro\", limitOnly: true },\n \"vitamin a\": { label: \"Vitamin A\", target: 255, unit: \"mcg\", category: \"micro\" },\n \"vitamin c\": { label: \"Vitamin C\", target: 30, unit: \"mg\", category: \"micro\" },\n \"vitamin d\": { label: \"Vitamin D\", target: 4.5, unit: \"mcg\", category: \"micro\" },\n kalsium: { label: \"Kalsium\", target: 360, unit: \"mg\", category: \"micro\" },\n \"zat besi\": { label: \"Zat Besi\", target: 5.4, unit: \"mg\", category: \"micro\" },\n zinc: { label: \"Zinc\", target: 3.9, unit: \"mg\", category: \"micro\" },\n folat: { label: \"Folat\", target: 150, unit: \"mcg\", category: \"micro\" },\n natrium: { label: \"Natrium\", target: 450, unit: \"mg\", category: \"micro\", limitOnly: true },\n kalium: { label: \"Kalium\", target: 1530, unit: \"mg\", category: \"micro\" },\n \"omega 3\": { label: \"Omega-3\", target: 0.42, unit: \"g\", category: \"micro\" },\n },\n },\n lansia: {\n label: \"Lansia\",\n focus:\n \"protein per porsi, serat, natrium, kalsium, vitamin D, dan lemak tidak berlebih\",\n nutrients: {\n kalori: { label: \"Kalori\", target: 520, unit: \"kkal\", category: \"macro\" },\n protein: { label: \"Protein\", target: 24, unit: \"g\", category: \"macro\" },\n karbohidrat: {\n label: \"Karbohidrat\",\n target: 70,\n unit: \"g\",\n category: \"macro\",\n },\n lemak: { label: \"Lemak\", target: 16, unit: \"g\", category: \"macro\" },\n serat: { label: \"Serat\", target: 9, unit: \"g\", category: \"micro\" },\n gula: { label: \"Gula\", target: 10, unit: \"g\", category: \"micro\", limitOnly: true },\n \"vitamin a\": { label: \"Vitamin A\", target: 180, unit: \"mcg\", category: \"micro\" },\n \"vitamin c\": { label: \"Vitamin C\", target: 22.5, unit: \"mg\", category: \"micro\" },\n \"vitamin d\": { label: \"Vitamin D\", target: 6, unit: \"mcg\", category: \"micro\" },\n kalsium: { label: \"Kalsium\", target: 360, unit: \"mg\", category: \"micro\" },\n \"zat besi\": { label: \"Zat Besi\", target: 2.4, unit: \"mg\", category: \"micro\" },\n zinc: { label: \"Zinc\", target: 3.3, unit: \"mg\", category: \"micro\" },\n folat: { label: \"Folat\", target: 120, unit: \"mcg\", category: \"micro\" },\n natrium: { label: \"Natrium\", target: 360, unit: \"mg\", category: \"micro\", limitOnly: true },\n kalium: { label: \"Kalium\", target: 1410, unit: \"mg\", category: \"micro\" },\n \"omega 3\": { label: \"Omega-3\", target: 0.33, unit: \"g\", category: \"micro\" },\n },\n },\n};\n\nconst STANDAR_GIZI = TARGET_AKG_2019.siswa.nutrients;\n\nconst NUTRIENT_ALIASES = {\n calorie: \"kalori\",\n calories: \"kalori\",\n energi: \"kalori\",\n energy: \"kalori\",\n karbo: \"karbohidrat\",\n carbohydrate: \"karbohidrat\",\n carbohydrates: \"karbohidrat\",\n carb: \"karbohidrat\",\n carbs: \"karbohidrat\",\n fat: \"lemak\",\n lipid: \"lemak\",\n fiber: \"serat\",\n sugar: \"gula\",\n \"vit a\": \"vitamin a\",\n vitamina: \"vitamin a\",\n \"vitamin_a\": \"vitamin a\",\n \"vit c\": \"vitamin c\",\n vitaminc: \"vitamin c\",\n \"vitamin_c\": \"vitamin c\",\n \"vit d\": \"vitamin d\",\n vitamind: \"vitamin d\",\n \"vitamin_d\": \"vitamin d\",\n ca: \"kalsium\",\n calcium: \"kalsium\",\n fe: \"zat besi\",\n iron: \"zat besi\",\n besi: \"zat besi\",\n seng: \"zinc\",\n zn: \"zinc\",\n folate: \"folat\",\n sodium: \"natrium\",\n potassium: \"kalium\",\n omega3: \"omega 3\",\n \"omega-3\": \"omega 3\",\n};\n\nfunction normalizeName(name) {\n const key = String(name || \"\")\n .trim()\n .toLowerCase()\n .replace(/[_-]+/g, \" \")\n .replace(/\\s+/g, \" \");\n return NUTRIENT_ALIASES[key] || key;\n}\n\nfunction canonicalTargetKey(value) {\n const key = String(value || \"\")\n .trim()\n .toLowerCase()\n .replace(/[_\\s-]+/g, \"_\");\n\n if (key.includes(\"balita\")) return \"balita\";\n if (key.includes(\"hamil\")) return \"ibu_hamil\";\n if (key.includes(\"menyusui\") || key.includes(\"laktasi\")) return \"ibu_menyusui\";\n if (key.includes(\"lansia\")) return \"lansia\";\n return \"siswa\";\n}\n\nfunction convertUnit(value, fromUnit, toUnit) {\n const from = String(fromUnit || toUnit || \"\").toLowerCase().replace(\"ug\", \"mcg\");\n const to = String(toUnit || from || \"\").toLowerCase().replace(\"ug\", \"mcg\");\n const numeric = Number(value || 0);\n\n if (!Number.isFinite(numeric)) return 0;\n if (!from || !to || from === to) return numeric;\n if (from === \"g\" && to === \"mg\") return numeric * 1000;\n if (from === \"mg\" && to === \"g\") return numeric / 1000;\n if (from === \"mg\" && to === \"mcg\") return numeric * 1000;\n if (from === \"mcg\" && to === \"mg\") return numeric / 1000;\n if (from === \"g\" && to === \"mcg\") return numeric * 1000000;\n if (from === \"mcg\" && to === \"g\") return numeric / 1000000;\n return numeric;\n}\n\nfunction inferStatus(percent, limitOnly = false) {\n if (limitOnly) {\n if (percent <= 100) return { label: STATUS.optimal, severity: \"success\" };\n if (percent <= 120) return { label: STATUS.mulaiBerlebih, severity: \"warning\" };\n return { label: STATUS.terlaluBerlebih, severity: \"danger\" };\n }\n\n if (percent < 70) return { label: STATUS.kurang, severity: \"warning\" };\n if (percent < 90) return { label: STATUS.cukup, severity: \"warning\" };\n if (percent <= 110) return { label: STATUS.optimal, severity: \"success\" };\n if (percent <= 120) return { label: STATUS.mulaiBerlebih, severity: \"warning\" };\n return { label: STATUS.terlaluBerlebih, severity: \"danger\" };\n}\n\nfunction buildNutrition(nutrition = {}, manualNutrients = []) {\n const result = {};\n\n [\"kalori\", \"protein\", \"lemak\", \"karbohidrat\", \"serat\"].forEach((key) => {\n const value = Number(nutrition[key] || 0);\n if (Number.isFinite(value) && value > 0) {\n result[key] = {\n value,\n unit: key === \"kalori\" ? \"kkal\" : \"g\",\n source: \"menu_nutrition\",\n };\n }\n });\n\n if (nutrition.micronutrients && typeof nutrition.micronutrients === \"object\") {\n Object.entries(nutrition.micronutrients).forEach(([name, value]) => {\n const key = normalizeName(name);\n const numeric = Number(value || 0);\n if (key && numeric > 0) {\n result[key] = { value: numeric, unit: \"\", source: \"micronutrients\" };\n }\n });\n }\n\n manualNutrients.forEach((item) => {\n const key = normalizeName(item.nama || item.name || item.label);\n const numeric = Number(item.nilai ?? item.value ?? 0);\n if (!key || !Number.isFinite(numeric) || numeric <= 0) return;\n\n if (!result[key]) {\n result[key] = {\n value: 0,\n unit: item.satuan || item.unit || \"g\",\n source: \"manual\",\n original_label: item.nama || item.name || item.label,\n };\n }\n\n result[key].value += numeric;\n });\n\n return result;\n}\n\nfunction computeNutrientAnalysis(nutrition, targetKey) {\n const target = TARGET_AKG_2019[targetKey] || TARGET_AKG_2019.siswa;\n const analysis = {};\n const nutrientIssues = [];\n const warnings = [];\n let totalScore = 0;\n let maxScore = 0;\n\n Object.entries(nutrition).forEach(([key, data]) => {\n const standard = target.nutrients[key];\n const fallbackLabel =\n data.original_label ||\n key.replace(/\\b\\w/g, (char) => char.toUpperCase()).replace(\"Omega 3\", \"Omega-3\");\n const unit = standard?.unit || data.unit || \"\";\n const value = standard\n ? convertUnit(data.value, data.unit, standard.unit)\n : Number(data.value || 0);\n\n if (!Number.isFinite(value) || value <= 0) return;\n\n if (!standard) {\n analysis[key] = {\n label: fallbackLabel,\n value,\n unit,\n min: 0,\n max: 0,\n target: null,\n percent: null,\n status: STATUS.tercatat,\n score: 100,\n category: key in STANDAR_GIZI ? \"macro\" : \"micro\",\n };\n return;\n }\n\n const percent =\n standard.target > 0 ? Math.round((value / standard.target) * 100) : 0;\n const status = inferStatus(percent, Boolean(standard.limitOnly));\n const score = standard.limitOnly\n ? percent <= 100\n ? 100\n : Math.max(0, Math.round(100 - (percent - 100) * 1.5))\n : Math.max(0, Math.min(100, percent <= 100 ? percent : 100 - (percent - 100)));\n\n analysis[key] = {\n label: standard.label,\n value: Math.round(value * 10) / 10,\n unit: standard.unit,\n min: Math.round(standard.target * 0.9 * 10) / 10,\n max: Math.round(standard.target * 1.1 * 10) / 10,\n target: standard.target,\n percent,\n status: status.label,\n score: Math.max(0, Math.min(100, score)),\n category: standard.category,\n limit_only: Boolean(standard.limitOnly),\n };\n\n totalScore += Math.max(0, Math.min(100, score));\n maxScore += 100;\n\n if (status.severity !== \"success\") {\n nutrientIssues.push({\n key,\n label: standard.label,\n status: status.label,\n severity: status.severity,\n value: Math.round(value * 10) / 10,\n unit: standard.unit,\n target: standard.target,\n percent,\n category: standard.category,\n limitOnly: Boolean(standard.limitOnly),\n });\n }\n });\n\n const core = [\"kalori\", \"protein\", \"karbohidrat\", \"lemak\"];\n const availableCore = core.filter((key) => analysis[key]);\n const availableMicro = Object.values(analysis).filter(\n (item) => item.category === \"micro\",\n );\n\n if (availableCore.length < 4) {\n warnings.push(\n \"Data makronutrien belum lengkap. Hasil AI bersifat sementara dan belum bisa dianggap final.\",\n );\n }\n\n if (availableMicro.length === 0) {\n warnings.push(\n \"Data mikronutrien belum tersedia. Tambahkan vitamin/mineral manual agar analisis ahli gizi lebih utuh.\",\n );\n }\n\n const overallScore =\n maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0;\n let overallStatus = STATUS.optimal;\n if (warnings.length > 0) overallStatus = \"Data Belum Lengkap\";\n else if (overallScore < 70) overallStatus = STATUS.kurang;\n else if (overallScore < 90) overallStatus = STATUS.cukup;\n else if (overallScore > 120) overallStatus = STATUS.terlaluBerlebih;\n else if (overallScore > 110) overallStatus = STATUS.mulaiBerlebih;\n\n return {\n analysis,\n overallScore,\n overallStatus,\n nutrientIssues,\n warnings,\n target,\n };\n}\n\nfunction getMacroDistribution(analysis) {\n const protein = Number(analysis.protein?.value || 0) * 4;\n const karbo = Number(analysis.karbohidrat?.value || 0) * 4;\n const lemak = Number(analysis.lemak?.value || 0) * 9;\n const total = protein + karbo + lemak;\n\n if (total <= 0) return null;\n\n return {\n protein_pct: Math.round((protein / total) * 100),\n karbohidrat_pct: Math.round((karbo / total) * 100),\n lemak_pct: Math.round((lemak / total) * 100),\n };\n}\n\nfunction buildPrompt(\n namaMenu,\n target,\n analysis,\n nutrientIssues,\n overallScore,\n overallStatus,\n macroDistribution,\n warnings,\n menuRole,\n) {\n const nutritionLines = Object.values(analysis)\n .map((item) => {\n const targetText = item.target\n ? `target ${item.target} ${item.unit}, ${item.percent}%`\n : \"target AKG tidak tersedia\";\n return `- ${item.label}: ${item.value} ${item.unit} (${targetText}, status ${item.status})`;\n })\n .join(\"\\n\");\n\n const issuesText =\n nutrientIssues.length > 0\n ? nutrientIssues\n .map(\n (i) =>\n `- ${i.label}: ${i.value} ${i.unit}, target ${i.target} ${i.unit}, ${i.percent}%, status ${i.status}`,\n )\n .join(\"\\n\")\n : \"- Tidak ada isu besar pada nutrien yang tersedia.\";\n\n const distributionText = macroDistribution\n ? `Protein ${macroDistribution.protein_pct}%, karbohidrat ${macroDistribution.karbohidrat_pct}%, lemak ${macroDistribution.lemak_pct}% dari energi makro.`\n : \"Distribusi makro belum bisa dihitung.\";\n\n const warningText =\n warnings.length > 0 ? warnings.map((w) => `- ${w}`).join(\"\\n\") : \"- Data cukup untuk analisis.\";\n const isSupportDrink = menuRole === \"support_drink\";\n const menuRoleText = isSupportDrink\n ? \"Minuman/menu pendukung (contoh susu), bukan menu utama lengkap. Nilai sebagai pelengkap gizi.\"\n : \"Menu utama/lauk-pangan yang dinilai sebagai porsi MBG mandiri.\";\n const drinkRules = isSupportDrink\n ? `\nAturan khusus minuman pendukung:\n- Jangan memaksa minuman seperti susu memenuhi target energi/makro menu utama sendirian.\n- Fokus pada kontribusi pendukung: protein, kalsium, vitamin/mineral, gula, lemak, keamanan porsi.\n- Kekurangan kalori/karbo/serat boleh disebut wajar untuk minuman pendukung.\n- Rekomendasi harus berupa cara memasangkan dengan menu utama, bukan menaikkan porsi minuman berlebihan.`\n : \"\";\n\n return `Kamu adalah ahli gizi profesional program MBG Indonesia. Analisis menu sebagai nutrition reasoning engine, bukan komentar generik.\n\nDATA\nNama menu: ${namaMenu}\nPeran menu: ${menuRoleText}\nTarget: ${target.label}\nFokus target: ${target.focus}\nSkor: ${overallScore}/100\nStatus: ${overallStatus}\nDistribusi makro: ${distributionText}\n\nZat gizi tersedia:\n${nutritionLines}\n\nIsu berbasis data:\n${issuesText}\n\nPeringatan kelengkapan data:\n${warningText}\n\nBalas HANYA JSON valid:\n{\n \"message\": \"1-2 kalimat evaluasi berbasis data\",\n \"catatan\": \"insight utama masalah menu, spesifik menyebut nutrien dan alasan\",\n \"tips\": \"saran praktis koreksi komposisi menu\",\n \"kesimpulan\": \"ringkasan singkat, tegas, profesional untuk ahli gizi\",\n \"recommendations\": [\n {\n \"jenis\": \"Gizi\",\n \"nutrient\": \"nama zat gizi atau null\",\n \"severity\": \"success|warning|danger\",\n \"pesan\": \"rekomendasi spesifik berbasis angka\",\n \"detail\": \"nilai vs target, atau null\"\n }\n ]\n}\n\nAturan:\n- Jangan tulis generik seperti menu baik/menu sehat tanpa alasan.\n- Kaitkan analisis dengan target ${target.label}.\n- Bahas energi, distribusi makro, dominasi zat gizi, dan mikro yang tersedia.\n- Jika data belum lengkap, nyatakan analisis sementara.\n- Maksimal 5 rekomendasi paling penting.${drinkRules}`;\n}\n\nasync function callGeminiAPI(payload) {\n const apiKey = process.env.GEMINI_API_KEY;\n\n if (!apiKey || apiKey === \"your_gemini_api_key_here\") {\n throw new Error(\"GEMINI_API_KEY belum dikonfigurasi di file .env\");\n }\n\n if (apiKey.length < 20) {\n throw new Error(\"GEMINI_API_KEY tampak tidak valid\");\n }\n\n const prompt = buildPrompt(...payload);\n\n const result = await retryWithBackoff(\n async () => {\n const genAI = new GoogleGenerativeAI(apiKey);\n const model = genAI.getGenerativeModel({\n model: \"gemini-2.5-flash-lite\",\n generationConfig: {\n temperature: 0.45,\n topP: 0.9,\n maxOutputTokens: 1400,\n },\n });\n\n const response = await model.generateContent(prompt);\n return response.response.text().trim();\n },\n {\n maxRetries: 0,\n initialDelay: 1500,\n backoffMultiplier: 2,\n name: \"Gemini nutrition reasoning\",\n },\n );\n\n const parsed = extractJsonFromText(result);\n if (!parsed.message || !Array.isArray(parsed.recommendations)) {\n throw new Error(\"Respons Gemini tidak memiliki struktur yang diharapkan\");\n }\n\n return normalizeAiOutput(parsed);\n}\n\nfunction getOpenAIOutputText(data) {\n if (typeof data.output_text === \"string\") return data.output_text;\n\n const chunks = [];\n for (const item of data.output || []) {\n for (const content of item.content || []) {\n if (typeof content.text === \"string\") chunks.push(content.text);\n }\n }\n\n return chunks.join(\"\\n\").trim();\n}\n\nasync function callOpenAIAPI(payload) {\n const apiKey = process.env.OPENAI_API_KEY;\n\n if (!apiKey || apiKey === \"your_openai_api_key_here\") {\n throw new Error(\"OPENAI_API_KEY belum dikonfigurasi di file .env\");\n }\n\n if (apiKey.length < 20) {\n throw new Error(\"OPENAI_API_KEY tampak tidak valid\");\n }\n\n const prompt = buildPrompt(...payload);\n const model = process.env.OPENAI_MODEL || \"gpt-5.2\";\n const schema = {\n type: \"object\",\n additionalProperties: false,\n properties: {\n message: { type: \"string\" },\n catatan: { type: \"string\" },\n tips: { type: \"string\" },\n kesimpulan: { type: \"string\" },\n recommendations: {\n type: \"array\",\n maxItems: 5,\n items: {\n type: \"object\",\n additionalProperties: false,\n properties: {\n jenis: { type: \"string\" },\n nutrient: { type: [\"string\", \"null\"] },\n severity: { type: \"string\", enum: [\"success\", \"warning\", \"danger\"] },\n pesan: { type: \"string\" },\n detail: { type: [\"string\", \"null\"] },\n },\n required: [\"jenis\", \"nutrient\", \"severity\", \"pesan\", \"detail\"],\n },\n },\n },\n required: [\"message\", \"catatan\", \"tips\", \"kesimpulan\", \"recommendations\"],\n };\n\n const response = await fetch(\"https://api.openai.com/v1/responses\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n model,\n input: [\n {\n role: \"system\",\n content:\n \"Kamu ahli gizi MBG Indonesia. Balas hanya JSON valid sesuai schema.\",\n },\n { role: \"user\", content: prompt },\n ],\n text: {\n format: {\n type: \"json_schema\",\n name: \"nutrition_reasoning_output\",\n strict: true,\n schema,\n },\n },\n }),\n });\n\n const data = await response.json().catch(() => null);\n if (!response.ok) {\n const message =\n data?.error?.message ||\n data?.message ||\n `OpenAI API gagal (${response.status})`;\n throw new Error(message);\n }\n\n const text = getOpenAIOutputText(data);\n const parsed = extractJsonFromText(text);\n if (!parsed.message || !Array.isArray(parsed.recommendations)) {\n throw new Error(\"Respons OpenAI tidak memiliki struktur yang diharapkan\");\n }\n\n return {\n ...normalizeAiOutput(parsed),\n model,\n };\n}\n\nasync function callDeepSeekAPI(payload) {\n const apiKey = process.env.DEEPSEEK_API_KEY;\n\n if (!apiKey || apiKey === \"your_deepseek_api_key_here\") {\n throw new Error(\"DEEPSEEK_API_KEY belum dikonfigurasi di file .env\");\n }\n\n if (apiKey.length < 20) {\n throw new Error(\"DEEPSEEK_API_KEY tampak tidak valid\");\n }\n\n const prompt = buildPrompt(...payload);\n const model = process.env.DEEPSEEK_MODEL || \"deepseek-chat\";\n const response = await fetch(\"https://api.deepseek.com/chat/completions\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n model,\n messages: [\n {\n role: \"system\",\n content:\n \"Kamu ahli gizi MBG Indonesia. Balas hanya JSON valid sesuai instruksi.\",\n },\n { role: \"user\", content: prompt },\n ],\n temperature: 0.45,\n max_tokens: 1400,\n response_format: { type: \"json_object\" },\n }),\n });\n\n const data = await response.json().catch(() => null);\n if (!response.ok) {\n const message =\n data?.error?.message ||\n data?.message ||\n `DeepSeek API gagal (${response.status})`;\n throw new Error(message);\n }\n\n const text = data?.choices?.[0]?.message?.content || \"\";\n const parsed = extractJsonFromText(text);\n if (!parsed.message || !Array.isArray(parsed.recommendations)) {\n throw new Error(\"Respons DeepSeek tidak memiliki struktur yang diharapkan\");\n }\n\n return {\n ...normalizeAiOutput(parsed),\n model,\n };\n}\n\nfunction hasConfiguredEnv(name, placeholder) {\n const value = process.env[name];\n return Boolean(value && value !== placeholder);\n}\n\nfunction normalizeAiOutput(parsed) {\n const validSeverities = new Set([\"success\", \"warning\", \"danger\"]);\n return {\n message: parsed.message,\n catatan: parsed.catatan || parsed.message,\n tips: parsed.tips || parsed.recommendations?.[0]?.pesan || parsed.message,\n kesimpulan: parsed.kesimpulan || parsed.message,\n recommendations: (parsed.recommendations || []).map((rec) => ({\n jenis: rec.jenis || \"Gizi\",\n nutrient: rec.nutrient || null,\n severity: validSeverities.has(rec.severity) ? rec.severity : \"warning\",\n pesan: rec.pesan || \"\",\n detail: rec.detail || null,\n })),\n };\n}\n\nfunction formatIssue(issue) {\n return `${issue.label} ${issue.value} ${issue.unit} dari target ${issue.target} ${issue.unit} (${issue.percent}%)`;\n}\n\nfunction inferMenuRole(namaMenu, menuContext = {}) {\n const text = `${namaMenu || \"\"} ${menuContext.deskripsi || \"\"}`.toLowerCase();\n const explicitType = String(menuContext.menuType || menuContext.menu_type || \"\").toLowerCase();\n const isDrink =\n explicitType === \"minuman\" ||\n [\"minum\", \"minuman\", \"jus\", \"teh\", \"susu\", \"smoothie\", \"sirup\", \"wedang\"].some((keyword) =>\n text.includes(keyword),\n );\n const isSupportDrink = isDrink && [\"susu\", \"milk\", \"yoghurt\", \"yogurt\", \"jus\", \"smoothie\"].some((keyword) => text.includes(keyword));\n\n return isSupportDrink ? \"support_drink\" : isDrink ? \"drink\" : \"main_menu\";\n}\n\nfunction buildFallbackResult(namaMenu, target, nutrientIssues, analysis, warnings, menuRole) {\n const macroDistribution = getMacroDistribution(analysis);\n const sortedIssues = [...nutrientIssues].sort((a, b) => {\n const weight = { danger: 3, warning: 2, success: 1 };\n return (weight[b.severity] || 0) - (weight[a.severity] || 0);\n });\n const primaryIssue = sortedIssues[0];\n const macroIssues = sortedIssues.filter((i) => i.category === \"macro\");\n const microIssues = sortedIssues.filter((i) => i.category === \"micro\");\n const filteredIssues =\n menuRole === \"support_drink\"\n ? sortedIssues.filter(\n (issue) => ![\"kalori\", \"karbohidrat\", \"serat\"].includes(issue.label.toLowerCase()),\n )\n : sortedIssues;\n const recommendations = filteredIssues.slice(0, 5).map((issue) => {\n const isHigh =\n issue.status === STATUS.mulaiBerlebih ||\n issue.status === STATUS.terlaluBerlebih;\n const action = isHigh\n ? `Kurangi sumber ${issue.label.toLowerCase()} atau seimbangkan dengan sayur/komponen rendah energi.`\n : `Tingkatkan ${issue.label.toLowerCase()} melalui bahan lokal yang sesuai target ${target.label}.`;\n\n return {\n jenis: \"Gizi\",\n nutrient: issue.label,\n severity: issue.severity,\n pesan: `[${issue.label}] ${action}`,\n detail: formatIssue(issue),\n };\n });\n\n if (recommendations.length === 0) {\n recommendations.push({\n jenis: \"Gizi\",\n nutrient: null,\n severity: \"success\",\n pesan:\n menuRole === \"support_drink\"\n ? `\"${namaMenu}\" layak sebagai menu pendukung untuk target ${target.label} pada data yang tersedia.`\n : `Komposisi \"${namaMenu}\" berada dalam rentang optimal untuk target ${target.label} pada data yang tersedia.`,\n detail: null,\n });\n }\n\n const warningPrefix =\n warnings.length > 0 ? \"Data belum lengkap, sehingga analisis masih sementara. \" : \"\";\n const macroText = macroDistribution\n ? `Distribusi energi makro: protein ${macroDistribution.protein_pct}%, karbohidrat ${macroDistribution.karbohidrat_pct}%, lemak ${macroDistribution.lemak_pct}%.`\n : \"Distribusi makro belum dapat dihitung.\";\n const issueText = primaryIssue\n ? `Masalah utama: ${formatIssue(primaryIssue)} dengan status ${primaryIssue.status}.`\n : \"Tidak ada ketimpangan besar pada zat gizi yang tersedia.\";\n const microText =\n microIssues.length > 0\n ? `Mikronutrien perlu perhatian: ${microIssues\n .slice(0, 3)\n .map((i) => i.label)\n .join(\", \")}.`\n : \"Mikronutrien yang tersedia tidak menunjukkan masalah besar.\";\n const macroDominance =\n menuRole === \"support_drink\"\n ? \"Sebagai minuman pendukung, nilai utama ada pada kontribusi protein, kalsium, vitamin/mineral, dan kontrol gula.\"\n : macroDistribution && macroDistribution.karbohidrat_pct >= 65\n ? \"Karbohidrat tampak dominan, jadi porsi lauk protein dan sayur perlu dicek.\"\n : macroDistribution && macroDistribution.lemak_pct >= 35\n ? \"Lemak relatif dominan, metode masak dan sumber minyak perlu dikendalikan.\"\n : macroDistribution && macroDistribution.protein_pct < 12\n ? \"Kontribusi protein relatif rendah dibanding energi makro.\"\n : \"Distribusi makro relatif terkendali pada data yang tersedia.\";\n\n return {\n message:\n menuRole === \"support_drink\"\n ? `${warningPrefix}Menu ini terbaca sebagai minuman pendukung. ${issueText} ${macroText}`\n : `${warningPrefix}${issueText} ${macroText}`,\n catatan:\n menuRole === \"support_drink\"\n ? `${issueText} ${macroDominance} Kekurangan energi total masih bisa wajar bila dipasangkan dengan menu utama. Fokus ${target.label}: ${target.focus}.`\n : `${issueText} ${macroDominance} Fokus ${target.label}: ${target.focus}.`,\n tips:\n menuRole === \"support_drink\"\n ? \"Pasangkan minuman ini dengan menu utama sumber energi, lauk protein, dan sayur; evaluasi terutama protein, kalsium, dan gula minuman.\"\n : macroIssues.length > 0\n ? recommendations[0].pesan.replace(/^\\[[^\\]]+\\]\\s*/, \"\")\n : `${microText} Lengkapi data vitamin/mineral agar koreksi menu lebih presisi.`,\n kesimpulan:\n menuRole === \"support_drink\"\n ? `${warningPrefix}${namaMenu} lebih tepat dinilai sebagai menu pendukung, bukan menu utama mandiri, untuk target ${target.label}.`\n : `${warningPrefix}${namaMenu} ${\n primaryIssue ? \"perlu koreksi komposisi sebelum dinyatakan optimal\" : \"sudah layak pada data yang tersedia\"\n } untuk target ${target.label}.`,\n recommendations,\n };\n}\n\nasync function analyzeNutrition(nutrition, namaMenu, menuContext = {}) {\n const menuRole = inferMenuRole(namaMenu, menuContext);\n const targetKey = canonicalTargetKey(\n menuContext.targetKey || menuContext.target || menuContext.kategori,\n );\n const mergedNutrition = buildNutrition(\n nutrition,\n menuContext.manualNutrients || menuContext.manual_macronutrients || [],\n );\n const {\n analysis,\n overallScore,\n overallStatus,\n nutrientIssues,\n warnings,\n target,\n } = computeNutrientAnalysis(mergedNutrition, targetKey);\n const macroDistribution = getMacroDistribution(analysis);\n\n let aiOutput;\n let poweredBy = \"OpenAI\";\n let aiEngine = process.env.OPENAI_MODEL || \"gpt-5.2\";\n\n try {\n const payload = [\n namaMenu,\n target,\n analysis,\n nutrientIssues,\n overallScore,\n overallStatus,\n macroDistribution,\n warnings,\n menuRole,\n ];\n\n const providers = [\n {\n name: \"OpenAI Responses API\",\n engine: process.env.OPENAI_MODEL || \"gpt-5.2\",\n configured: hasConfiguredEnv(\"OPENAI_API_KEY\", \"your_openai_api_key_here\"),\n run: callOpenAIAPI,\n },\n {\n name: \"Google Gemini AI\",\n engine: \"gemini-2.5-flash-lite\",\n configured: hasConfiguredEnv(\"GEMINI_API_KEY\", \"your_gemini_api_key_here\"),\n run: callGeminiAPI,\n },\n {\n name: \"DeepSeek AI\",\n engine: process.env.DEEPSEEK_MODEL || \"deepseek-chat\",\n configured: hasConfiguredEnv(\n \"DEEPSEEK_API_KEY\",\n \"your_deepseek_api_key_here\",\n ),\n run: callDeepSeekAPI,\n },\n ];\n const providerErrors = [];\n\n for (const provider of providers) {\n if (!provider.configured) {\n providerErrors.push(`${provider.name}: API key belum dikonfigurasi`);\n continue;\n }\n\n try {\n aiOutput = await provider.run(payload);\n aiEngine = aiOutput.model || provider.engine;\n poweredBy = provider.name;\n break;\n } catch (providerError) {\n const message = providerError?.message || \"gagal tanpa detail\";\n providerErrors.push(`${provider.name}: ${message}`);\n console.warn(`[AI Nutrition] ${provider.name} failed: ${message}`);\n }\n }\n\n if (!aiOutput) {\n throw new Error(providerErrors.join(\" | \"));\n }\n } catch (err) {\n const msg = err.message || \"\";\n if (menuContext.requireAi || menuContext.requireGemini) {\n throw new Error(\n `AI API tidak tersedia untuk analisis dashboard: ${msg}`,\n );\n }\n console.warn(`[AI Nutrition] provider fallback: ${msg}`);\n aiOutput = buildFallbackResult(\n namaMenu,\n target,\n nutrientIssues,\n analysis,\n warnings,\n menuRole,\n );\n poweredBy = \"Rule-Based Nutrition Reasoning\";\n aiEngine = \"rule-based-nutrition-reasoning\";\n }\n\n return {\n menu_nama: namaMenu,\n skor_gizi: overallScore,\n status: overallStatus,\n pesan: aiOutput.message,\n catatan_ai: aiOutput.catatan,\n tips_ai: aiOutput.tips,\n kesimpulan_ai: aiOutput.kesimpulan,\n detail_analisis: analysis,\n rekomendasi: aiOutput.recommendations,\n standar_referensi: `AKG 2019 target ${target.label} per porsi MBG - Powered by ${poweredBy}`,\n target_key: targetKey,\n target_label: target.label,\n target_focus: target.focus,\n menu_role: menuRole,\n macro_distribution: macroDistribution,\n data_quality: {\n is_complete: warnings.length === 0,\n warnings,\n },\n ai_engine: aiEngine,\n analyzed_at: new Date().toISOString(),\n };\n}\n\nfunction getNutritionSummary(nutrition) {\n return {\n kalori: Number(nutrition.kalori || 0),\n protein: Number(nutrition.protein || 0),\n lemak: Number(nutrition.lemak || 0),\n karbohidrat: Number(nutrition.karbohidrat || 0),\n serat: Number(nutrition.serat || 0),\n is_balanced: isBalanced(nutrition),\n };\n}\n\nfunction normalizeMaterialName(value) {\n return String(value || \"\").toLowerCase().replace(/[^a-z0-9]+/g, \"\").trim();\n}\n\nfunction alignMenuNameWithMethod(name, method) {\n const cleanName = String(name || \"Menu MBG\").trim() || \"Menu MBG\";\n const cleanMethod = String(method || \"kukus\").toLowerCase().trim() || \"kukus\";\n const methodWords = [\"goreng\", \"tumis\", \"rebus\", \"kukus\", \"bakar\"];\n const wrongWords = methodWords.filter((word) => word !== cleanMethod);\n let alignedName = cleanName;\n wrongWords.forEach((word) => {\n const re = new RegExp(`\\b${word}\\b`, \"gi\");\n alignedName = alignedName.replace(re, cleanMethod);\n });\n if (!new RegExp(`\\b${cleanMethod}\\b`, \"i\").test(alignedName)) {\n alignedName = `${cleanMethod.charAt(0).toUpperCase()}${cleanMethod.slice(1)} ${alignedName}`;\n }\n return alignedName.replace(/\\s+/g, \" \").trim();\n}\n\nfunction enrichGeneratedIngredients(generatedIngredients, sourceIngredients) {\n const sourceById = new Map();\n const sourceByName = new Map();\n (sourceIngredients || []).forEach((item) => {\n if (item.raw_material_id) sourceById.set(String(item.raw_material_id), item);\n const key = normalizeMaterialName(item.nama || item.nama_bahan || item.name);\n if (key) sourceByName.set(key, item);\n });\n\n return (generatedIngredients || []).map((item) => {\n const source =\n sourceById.get(String(item.raw_material_id || \"\")) ||\n sourceByName.get(normalizeMaterialName(item.nama || item.nama_bahan || item.name));\n return {\n ...item,\n nama: item.nama || source?.nama || source?.nama_bahan || source?.name || \"Bahan\",\n raw_material_id: item.raw_material_id || source?.raw_material_id || source?.id || null,\n harga_satuan: Number(item.harga_satuan ?? source?.harga_satuan ?? source?.standard_price ?? 0),\n qty_available: item.qty_available ?? source?.qty_available ?? null,\n satuan: item.satuan || source?.satuan || source?.unit || \"g\",\n };\n });\n}\n\nfunction isBalanced(nutrition, targetKey = \"siswa\") {\n const merged = buildNutrition(nutrition, []);\n const { nutrientIssues, warnings } = computeNutrientAnalysis(\n merged,\n targetKey,\n );\n return nutrientIssues.length === 0 && warnings.length === 0;\n}\n\nasync function generateMenuFromIngredients(\n ingredients,\n kelompok = \"Siswa\",\n kategori = \"Siswa\",\n caraMasak = \"kukus\",\n) {\n const apiKey = process.env.GEMINI_API_KEY;\n const canUseGemini = apiKey && apiKey !== \"your_gemini_api_key_here\";\n const cleanCaraMasak = String(caraMasak || \"kukus\").trim().toLowerCase();\n\n const TARGET_GIZI = {\n balita: { kalori: \"400-500\", protein: \"15-20\", lemak: \"12-18\", karbo: \"55-70\" },\n siswa: { kalori: \"550-650\", protein: \"20-30\", lemak: \"15-22\", karbo: \"70-90\" },\n ibu_hamil: { kalori: \"700-800\", protein: \"25-35\", lemak: \"20-28\", karbo: \"80-100\" },\n ibu_menyusui: { kalori: \"750-850\", protein: \"30-38\", lemak: \"22-30\", karbo: \"90-110\" },\n };\n const targetKey = String(kategori || kelompok || \"Siswa\")\n .toLowerCase()\n .replace(/\\s+/g, \"_\");\n const target = TARGET_GIZI[targetKey] || TARGET_GIZI.siswa;\n\n const ingList = ingredients\n .map((i) => `- ${i.nama} (${i.jumlah} ${i.satuan})`)\n .join(\"\\n\");\n\n const prompt = `Kamu adalah ahli gizi program MBG Indonesia. Buat satu rekomendasi menu dari bahan tersedia.\n\nBAHAN:\n${ingList}\n\nTARGET PENERIMA ${kategori}:\n- Kalori: ${target.kalori} kkal\n- Protein: ${target.protein} g\n- Lemak: ${target.lemak} g\n- Karbohidrat: ${target.karbo} g\n\nKategori penerima: ${kategori}\nCara masak wajib: ${cleanCaraMasak}\nNama menu WAJIB memuat kata metode \"${cleanCaraMasak}\" dan tidak boleh memuat metode lain. Contoh: jika metode tumis, buat \"Tumis ...\", bukan \"Nasi Goreng\".\nBuat hanya menu MAKANAN, bukan minuman. Jangan membuat susu, jus, smoothie, teh, atau menu minuman lain. Jika ada bahan susu, perlakukan hanya sebagai catatan bahan tersedia dan jangan jadikan menu minuman.\nPilih kombinasi bahan baku API yang cocok untuk makanan utama/lauk/sayur. Perhitungkan metode masak: goreng biasanya menaikkan kalori/lemak; kukus/rebus lebih rendah lemak; bakar/tumis sedang.\n\nBalas JSON valid:\n{\n \"nama_menu\": \"nama menu\",\n \"deskripsi\": \"deskripsi singkat\",\n \"metode_masak\": \"goreng/kukus/rebus/bakar/tumis\",\n \"catatan_metode_masak\": \"jelaskan singkat dampak metode masak terhadap kalori\",\n \"estimasi_gizi\": {\n \"kalori\": number,\n \"protein\": number,\n \"lemak\": number,\n \"karbohidrat\": number,\n \"serat\": number\n },\n \"bahan_digunakan\": [\n { \"raw_material_id\": \"id dari daftar bahan\", \"nama\": \"nama bahan\", \"jumlah\": number, \"satuan\": \"satuan\", \"harga_satuan\": number, \"catatan\": \"opsional\" }\n ],\n \"bahan_kurang\": [\n { \"nama\": \"nama bahan\", \"jumlah_butuh\": number, \"satuan\": \"satuan\", \"alasan\": \"kenapa dibutuhkan\" }\n ],\n \"tips_gizi\": \"satu kalimat tip gizi\",\n \"sesuai_target\": true\n}`;\n\n if (canUseGemini) {\n const genAI = new GoogleGenerativeAI(apiKey);\n const model = genAI.getGenerativeModel({\n model: \"gemini-2.5-flash-lite\",\n generationConfig: { temperature: 0.8, maxOutputTokens: 1024 },\n });\n\n const result = await model.generateContent(prompt);\n const parsed = extractJsonFromText(result.response.text().trim());\n const bahanDigunakan = enrichGeneratedIngredients(parsed.bahan_digunakan, ingredients);\n return {\n ...parsed,\n nama_menu: alignMenuNameWithMethod(parsed.nama_menu, cleanCaraMasak),\n metode_masak: cleanCaraMasak,\n bahan_digunakan: bahanDigunakan,\n kelompok,\n kategori,\n jenis_menu: \"makanan\",\n generated_at: new Date().toISOString(),\n };\n }\n\n const bahanUtama = ingredients.slice(0, 5).map((i) => ({\n nama: i.nama,\n jumlah: Number(i.jumlah || 0),\n satuan: i.satuan || \"g\",\n }));\n const fallbackByKelompok = {\n balita: { kalori: 470, protein: 19, lemak: 15, karbo: 62 },\n siswa: { kalori: 610, protein: 26, lemak: 18, karbo: 82 },\n ibu_hamil: { kalori: 760, protein: 32, lemak: 24, karbo: 95 },\n ibu_menyusui: { kalori: 840, protein: 36, lemak: 26, karbo: 102 },\n porsi_kecil: { kalori: 430, protein: 18, lemak: 14, karbo: 60 },\n porsi_besar: { kalori: 810, protein: 34, lemak: 24, karbo: 98 },\n };\n\n const fallbackTarget =\n fallbackByKelompok[targetKey] || fallbackByKelompok[kelompok] || fallbackByKelompok.siswa;\n\n return {\n nama_menu: alignMenuNameWithMethod(`Menu MBG ${kategori} Berbasis Stok`, cleanCaraMasak),\n deskripsi:\n \"Menu makanan rekomendasi sementara dari bahan baku API. Aktifkan GEMINI_API_KEY untuk hasil generatif penuh.\",\n metode_masak: cleanCaraMasak,\n estimasi_gizi: {\n kalori: fallbackTarget.kalori,\n protein: fallbackTarget.protein,\n lemak: fallbackTarget.lemak,\n karbohidrat: fallbackTarget.karbo,\n serat: 6,\n },\n bahan_digunakan: enrichGeneratedIngredients(bahanUtama, ingredients),\n bahan_kurang: [],\n tips_gizi:\n \"Tambahkan sayur hijau dan buah untuk melengkapi komposisi isi piringku.\",\n sesuai_target: true,\n kelompok,\n kategori,\n jenis_menu: \"makanan\",\n generated_at: new Date().toISOString(),\n source: \"api-material-fallback\",\n };\n}\n\nmodule.exports = {\n analyzeNutrition,\n getNutritionSummary,\n isBalanced,\n generateMenuFromIngredients,\n STANDAR_GIZI,\n TARGET_AKG_2019,\n};\n"}}}} |