Files
dashboard/components/shared/PageAiInsight.tsx
T
Alberto-Audrix 32a36cceff
CI / lint-and-test (push) Canceled after 0s
first commit
2026-07-28 08:55:05 +07:00

1292 lines
47 KiB
TypeScript

/* eslint-disable react-hooks/preserve-manual-memoization */
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Sparkles,
RefreshCw,
AlertTriangle,
CheckCircle,
TrendingDown,
Info,
Download,
} from 'lucide-react';
import { jsPDF } from 'jspdf';
import { useAppContext } from '../../context/AppContext.tsx';
import { apiClient } from '../../services/apiService.ts';
// ─── Global in-memory cache (persists across page navigation in same session) ──
// Key: storageKey string, Value: parsed AiResult
const _insightMemoryCache = new Map<string, AiResult>();
// Key: storageKey string, Value: Promise being awaited (deduplication)
const _insightInFlight = new Map<string, Promise<void>>();
export type InsightTopic =
| 'hitung_ayam'
| 'berat_ayam'
| 'fcr'
| 'eef'
| 'iot_panel'
| 'hitung_karung';
export interface PageAiInsightProps {
topic: InsightTopic;
contextData: Record<string, unknown>;
cacheKey: string;
}
interface AiResult {
headline: string;
status: 'ok' | 'warning' | 'critical';
summary: string[];
risks: string[];
actions: string[];
}
// ─── Response Schema ──────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const PAGE_INSIGHT_SCHEMA = {
type: 'OBJECT',
properties: {
headline: { type: 'STRING' },
status: { type: 'STRING', enum: ['ok', 'warning', 'critical'] },
summary: { type: 'ARRAY', items: { type: 'STRING' } },
risks: { type: 'ARRAY', items: { type: 'STRING' } },
actions: { type: 'ARRAY', items: { type: 'STRING' } },
},
required: ['headline', 'status', 'summary', 'risks', 'actions'],
} as const;
// Helper: save to both global memory and sessionStorage
const _saveToCache = (storageKey: string, result: AiResult): void => {
_insightMemoryCache.set(storageKey, result);
try {
sessionStorage.setItem(storageKey, JSON.stringify(result));
} catch {
/* ignore */
}
};
// ─── UI metadata ─────────────────────────────────────────────────────────────
const TOPIC_META: Record<InsightTopic, { title: string; emoji: string; color: string }> = {
hitung_ayam: { title: 'AI Insight — Hitung Ayam', emoji: '🐔', color: 'purple' },
berat_ayam: { title: 'AI Insight — Berat Ayam', emoji: '⚖️', color: 'blue' },
fcr: { title: 'AI Insight — FCR', emoji: '🌾', color: 'green' },
eef: { title: 'AI Insight — EEF', emoji: '📊', color: 'orange' },
iot_panel: { title: 'AI Insight — Panel IoT', emoji: '🌡️', color: 'teal' },
hitung_karung: { title: 'AI Insight — Hitung Karung', emoji: '🧺', color: 'amber' },
};
const COLOR_MAP: Record<
string,
{
ring: string;
sparkBg: string;
sparkText: string;
btnBorder: string;
btnHover: string;
}
> = {
purple: {
ring: 'border-purple-100',
sparkBg: 'bg-purple-100',
sparkText: 'text-purple-600',
btnBorder: 'border-purple-200',
btnHover: 'hover:bg-purple-50',
},
blue: {
ring: 'border-blue-100',
sparkBg: 'bg-blue-100',
sparkText: 'text-blue-600',
btnBorder: 'border-blue-200',
btnHover: 'hover:bg-blue-50',
},
green: {
ring: 'border-green-100',
sparkBg: 'bg-green-100',
sparkText: 'text-green-600',
btnBorder: 'border-green-200',
btnHover: 'hover:bg-green-50',
},
orange: {
ring: 'border-orange-100',
sparkBg: 'bg-orange-100',
sparkText: 'text-orange-600',
btnBorder: 'border-orange-200',
btnHover: 'hover:bg-orange-50',
},
teal: {
ring: 'border-teal-100',
sparkBg: 'bg-teal-100',
sparkText: 'text-teal-600',
btnBorder: 'border-teal-200',
btnHover: 'hover:bg-teal-50',
},
amber: {
ring: 'border-amber-100',
sparkBg: 'bg-amber-100',
sparkText: 'text-amber-600',
btnBorder: 'border-amber-200',
btnHover: 'hover:bg-amber-50',
},
};
const STATUS_CFG = {
ok: {
bg: 'bg-emerald-50',
border: 'border-emerald-200',
text: 'text-emerald-700',
badge: 'bg-emerald-100 text-emerald-700',
label: 'Normal',
Icon: CheckCircle,
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-200',
text: 'text-amber-700',
badge: 'bg-amber-100 text-amber-700',
label: 'Perhatian',
Icon: AlertTriangle,
},
critical: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-700',
badge: 'bg-red-100 text-red-700',
label: 'Kritis',
Icon: TrendingDown,
},
};
const PAGE_FOCUSES: Record<InsightTopic, string[]> = {
hitung_ayam: [
'Tren mortalitas harian dan dampaknya terhadap populasi akhir',
'Distribusi ayam di berbagai area/kamera (penumpukan)',
'Perbandingan populasi aktual dengan target DOC In',
'Deteksi dini potensi wabah penyakit dari lonjakan mortalitas',
'Evaluasi efektivitas afkir selektif vs kematian alami',
'Proyeksi sisa populasi layak panen',
'Evaluasi tingkat kepadatan (density) vs kapasitas kandang',
],
berat_ayam: [
'Pencapaian target bobot (ADG) vs standar Cobb',
'Keseragaman (uniformity) bobot antar ayam',
'Hubungan stres lingkungan (jika ada indikasi) dengan hambatan pertumbuhan',
'Analisis tren pertumbuhan dalam 7 hari terakhir',
'Proyeksi waktu panen berdasarkan tren bobot saat ini',
'Evaluasi performa ayam di area depan vs belakang kandang',
'Deteksi indikasi stunting atau pertumbuhan terhambat',
],
fcr: [
'Efisiensi konversi pakan secara keseluruhan vs standar',
'Dampak fluktuasi asupan pakan terhadap nilai FCR harian',
'Korelasi antara mortalitas dengan pembengkakan FCR',
'Identifikasi pemborosan pakan atau distribusi pakan yang tidak merata',
'Proyeksi kerugian/keuntungan finansial berdasarkan FCR saat ini',
'Evaluasi kualitas pakan terhadap tingkat penyerapan (berdasarkan FCR)',
'Tindakan perbaikan tata laksana pakan (jam pemberian, feed sorting)',
],
eef: [
'Keseimbangan antara FCR, Bobot, dan Mortalitas dalam skor EEF',
'Potensi pencapaian bonus indeks performa di akhir siklus',
'Faktor utama yang menarik turun skor EEF (apakah FCR atau mortalitas?)',
'Evaluasi keberhasilan manajemen pemeliharaan secara holistik',
'Bandingkan performa siklus ini dengan target standar perusahaan (>300)',
'Proyeksi skor akhir EEF jika tren saat ini dipertahankan',
'Tindakan taktis penyelamatan nilai EEF di sisa hari pemeliharaan',
],
iot_panel: [
'Stabilitas suhu siang-malam dan risiko heat stress/cold stress',
'Manajemen kelembapan dan dampaknya pada litter (sekam)',
'Akumulasi gas amonia/CO2 dan pengaruhnya terhadap pernapasan ayam',
'Kinerja sistem ventilasi (exhaust fan & cooling pad)',
'Korelasi fluktuasi cuaca luar kandang dengan iklim mikro di dalam',
'Pengaturan setpoint suhu vs kenyamanan aktual ayam (THI)',
'Deteksi dini kegagalan fungsi sensor atau blind spot ventilasi',
],
hitung_karung: [
'Keseimbangan antara stok pakan dengan laju konsumsi harian',
'Deteksi anomali konsumsi pakan (turun drastis/naik tidak wajar)',
'Pemerataan jadwal penuangan pakan ke dalam silo/hopper',
'Efisiensi persediaan dan pencegahan pakan berjamur/rusak',
'Kesesuaian rasio asupan pakan per ekor dengan standar umur',
'Evaluasi manajemen stok menjelang jadwal panen (menghindari sisa)',
'Dampak suhu ekstrem terhadap nafsu makan dan penarikan pakan',
],
};
// ─── Prompt builders ─────────────────────────────────────────────────────────
const buildPrompt = (topic: InsightTopic, data: Record<string, unknown>): string => {
// Limit data to 2000 chars to avoid oversized prompts
const fullJson = JSON.stringify(data, null, 2);
const dataStr = fullJson.length > 2000 ? fullJson.slice(0, 2000) + '\n...' : fullJson;
const focuses = PAGE_FOCUSES[topic] || [];
const selectedFocus =
focuses[Math.floor(Math.random() * focuses.length)] || 'Analisis menyeluruh';
const INSTRUCTIONS: Record<InsightTopic, string> = {
hitung_ayam: `Anda adalah manajer peternakan ayam broiler strain CP 707.
Analisis data HITUNG AYAM berikut. Berikan insight singkat, profesional, dan actionable dalam Bahasa Indonesia.
Pedoman mortalitas strain CP 707: di bawah 5 persen = normal (ok), 5-7 persen = perhatian (warning), di atas 7 persen = kritis (critical).
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data kandang:
${dataStr}`,
berat_ayam: `Anda adalah ahli nutrisi peternakan ayam broiler strain CP 707.
Analisis data BERAT AYAM berikut. Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
Pedoman: bobot di bawah target standar CP 707 lebih dari 5 persen = warning. ADG rendah menunjukkan masalah nutrisi atau penyakit.
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data bobot:
${dataStr}`,
fcr: `Anda adalah analis performa peternakan ayam broiler strain CP 707.
Analisis data FCR (Feed Conversion Ratio) berikut.
FCR = Total Pakan dibagi Total Bobot Hidup. Nilai FCR yang lebih kecil berarti lebih efisien.
Pedoman: FCR sesuai standar CP 707 = ok. FCR sedikit di atas standar = warning. FCR jauh di atas standar = critical.
Standar FCR CP 707 umur 35 hari sekitar 1.60 hingga 1.63.
Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data FCR:
${dataStr}`,
eef: `Anda adalah analis performa peternakan ayam broiler strain CP 707.
Analisis data EEF (European Efficiency Factor) berikut.
EEF = persen_hidup dikali bw_panen_gram dibagi fcr dikali hari_panen dikali 100. Nilai lebih tinggi lebih baik.
Pedoman: EEF di atas 300 sangat baik (ok). EEF 250-300 baik (ok). EEF 200-250 perhatian (warning). EEF di bawah 200 kritis (critical).
Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data EEF:
${dataStr}`,
iot_panel: `Anda adalah ahli manajemen lingkungan kandang ayam broiler berbasis IoT strain CP 707.
Analisis kondisi lingkungan kandang dari data sensor IoT berikut.
Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
Pedoman: Suhu ideal 20-30 derajat Celsius. Kelembapan ideal 60-70 persen. Amonia di bawah 25 ppm aman. CO2 di bawah 3000 ppm aman.
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data sensor IoT:
${dataStr}`,
hitung_karung: `Anda adalah manajer logistik pakan peternakan ayam broiler strain CP 707.
Analisis data HITUNG KARUNG PAKAN berikut.
Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
Pedoman: konsumsi pakan harian wajar strain CP 707 sekitar 170 gram per ekor per hari untuk broiler umur 35 hari.
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data karung pakan:
${dataStr}`,
};
return `${INSTRUCTIONS[topic]}`;
};
// ─── Result parser (fallback jika schema mode gagal) ─────────────────────────
const parseResult = (text: string, topic?: InsightTopic): AiResult | null => {
if (!text || typeof text !== 'string') return null;
const stripped = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
const cleaned = stripped
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```\s*$/, '')
.replace(/\/\/.*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\.\.\.[\s\S]*?(?=[,}\]\n])/g, '')
.replace(/,\s*([}\]])/g, '$1')
.trim();
const first = cleaned.indexOf('{');
const last = cleaned.lastIndexOf('}');
if (first === -1 || last === -1 || last <= first) return null;
try {
const parsed = JSON.parse(cleaned.slice(first, last + 1));
if (!parsed || typeof parsed !== 'object') return null;
// Check if it's nested dashboard format ({ summary: {...}, detailed: {...} })
if (parsed.detailed && typeof parsed.detailed === 'object') {
const topicMap: Record<string, string> = {
hitung_ayam: 'hitung_ayam',
berat_ayam: 'berat_ayam',
fcr: 'fcr',
eef: 'eef',
iot_panel: 'panel_iot',
hitung_karung: 'hitung_karung',
};
const sectionKey = topic ? topicMap[topic] || topic : 'hitung_ayam';
const section = parsed.detailed[sectionKey] || Object.values(parsed.detailed)[0];
if (section && typeof section === 'object') {
let secStatus: 'ok' | 'warning' | 'critical' = 'ok';
if (section.status === 'critical') secStatus = 'critical';
else if (section.status === 'warning') secStatus = 'warning';
return {
headline: String(
section.bullets?.[0] ||
parsed.summary?.headline ||
'Analisis operasional kandang terpantau normal.'
),
status: secStatus,
summary: Array.isArray(section.bullets)
? section.bullets.map(String)
: Array.isArray(parsed.summary?.bullets)
? parsed.summary.bullets.map(String)
: [],
risks: Array.isArray(section.evidence) ? section.evidence.map(String) : [],
actions: Array.isArray(section.actions) ? section.actions.map(String) : [],
};
}
}
// Support flat JSON format
const headline =
parsed.headline ||
parsed.summary?.headline ||
parsed.judul ||
parsed.headlineText ||
parsed.headline_utama ||
parsed.message ||
'Analisis operasional kandang terpantau normal.';
const rawStatus = String(
parsed.status || parsed.summary?.status || parsed.kondisi || 'ok'
).toLowerCase();
let status: 'ok' | 'warning' | 'critical' = 'ok';
if (
rawStatus.includes('crit') ||
rawStatus.includes('kritis') ||
rawStatus.includes('tinggi') ||
rawStatus.includes('parah') ||
rawStatus.includes('critical')
) {
status = 'critical';
} else if (
rawStatus.includes('warn') ||
rawStatus.includes('perhatian') ||
rawStatus.includes('kurang') ||
rawStatus.includes('sedang') ||
rawStatus.includes('warning')
) {
status = 'warning';
}
// Support synonyms for lists, with fallback formatting for objects
const toArr = (v: unknown) =>
Array.isArray(v)
? v
.map((item) => {
if (item && typeof item === 'object') {
const obj = item as Record<string, unknown>;
const desc =
obj.description ||
obj.deskripsi ||
obj.text ||
obj.action ||
obj.tindakan ||
obj.parameter;
if (desc) {
return obj.parameter ? `[${obj.parameter}] ${desc}` : String(desc);
}
return JSON.stringify(item);
}
return String(item);
})
.filter(Boolean)
: [];
const summaryData =
parsed.summary?.bullets ||
parsed.summary ||
parsed.ringkasan ||
parsed.poin_ringkasan ||
parsed.bullets ||
[];
const risksData =
parsed.summary?.risks ||
parsed.risks ||
parsed.risiko ||
parsed.resiko ||
parsed.bahaya ||
parsed.evidence ||
[];
const actionsData =
parsed.summary?.actions ||
parsed.actions ||
parsed.tindakan ||
parsed.saran ||
parsed.rekomendasi ||
parsed.langkah_konkrit ||
parsed.langkah ||
[];
return {
headline: String(headline),
status,
summary: toArr(summaryData),
risks: toArr(risksData),
actions: toArr(actionsData),
};
} catch {
return null;
}
};
// Helper to safely parse strings containing units and Indonesian commas into standard numbers
const toNum = (val: unknown, fallback: number): number => {
if (val === undefined || val === null) return fallback;
if (typeof val === 'number') return Number.isFinite(val) ? val : fallback;
const str = String(val)
.replace(/[^0-9,.-]/g, '')
.replace(',', '.')
.trim();
const num = Number(str);
return Number.isFinite(num) ? num : fallback;
};
// ─── Local Rule-Based Fallback Generator (CP 707 Standard Compliance) ─────────
const _generateLocalInsight = (topic: InsightTopic, data: Record<string, any>): AiResult => {
switch (topic) {
case 'hitung_ayam': {
const initial = toNum(data.initialPopulation || data.totalInitialPopulation, 10000);
const current = toNum(data.currentPopulation || data.iotCurrentPopulation, 9800);
const mortality = toNum(data.totalMortality, initial - current);
const rate =
data.mortalityRate !== undefined
? toNum(data.mortalityRate, 0)
: initial > 0
? (mortality / initial) * 100
: 0;
const day = toNum(data.currentDay || data.hari_ke, 1);
let status: 'ok' | 'warning' | 'critical' = 'ok';
if (rate > 7) status = 'critical';
else if (rate >= 5) status = 'warning';
const risks = [];
const actions = [];
if (status === 'critical') {
risks.push(
`Mortalitas kumulatif mencapai ${rate.toFixed(2)}%, jauh di atas batas toleransi CP 707 (>7%).`
);
actions.push('Lakukan bedah bangkai (nekropsi) segera untuk analisa patologi.');
actions.push('Perketat biosekuriti kandang dan berikan multivitamin suportif.');
} else if (status === 'warning') {
risks.push(
`Mortalitas kumulatif mencapai ${rate.toFixed(2)}%, mendekati batas toleransi CP 707 (5-7%).`
);
actions.push('Tingkatkan kualitas sanitasi dan sekam kandang.');
actions.push('Evaluasi kecukupan asupan air minum ayam.');
} else {
actions.push('Pertahankan manajemen biosekuriti dan sanitasi harian.');
}
return {
headline: `Tingkat deplesi/mortalitas kumulatif terpantau ${rate.toFixed(2)}% pada hari ke-${day}.`,
status,
summary: [
`Populasi awal siklus: ${initial.toLocaleString()} ekor.`,
`Populasi aktif saat ini: ${current.toLocaleString()} ekor.`,
`Total kematian kumulatif: ${mortality.toLocaleString()} ekor.`,
],
risks,
actions,
};
}
case 'berat_ayam': {
const avg = toNum(data.averageWeight, 0);
const target = toNum(data.targetWeight, 0);
const _day = toNum(data.currentDay, 1);
const uniformity = toNum(data.uniformity, 85);
const diffPct = target > 0 ? ((target - avg) / target) * 100 : 0;
let status: 'ok' | 'warning' | 'critical' = 'ok';
if (diffPct > 15 || uniformity < 75) status = 'critical';
else if (diffPct > 5 || uniformity < 85) status = 'warning';
const risks = [];
const actions = [];
if (status === 'critical') {
risks.push(`Bobot aktual tertinggal ${diffPct.toFixed(1)}% dari standar CP 707.`);
risks.push(`Keseragaman (uniformity) di bawah batas toleransi (${uniformity}%).`);
actions.push('Lakukan sekat pembatas (grading) untuk memisahkan ayam berbobot kecil.');
actions.push('Evaluasi kecukupan ruang pakan dan asupan nutrisi.');
} else if (status === 'warning') {
risks.push(`Bobot aktual tertinggal ${diffPct.toFixed(1)}% dari standar pertumbuhan.`);
actions.push('Tingkatkan stimulasi pakan di siang hari dengan cara digoyang.');
actions.push('Sesuaikan tinggi tempat pakan agar sejajar dengan punggung ayam.');
} else {
actions.push('Pertahankan manajemen ventilasi untuk mendukung ADG harian.');
}
return {
headline: `Rata-rata bobot mencapai ${avg}g vs Target standar ${target}g.`,
status,
summary: [
`Bobot rata-rata aktual: ${avg} gram.`,
`Target bobot standar strain CP 707: ${target} gram.`,
`Tingkat keseragaman (uniformity) populasi: ${uniformity}%.`,
],
risks,
actions,
};
}
case 'fcr': {
const fcr = toNum(data.fcr_terakhir, 1.62);
const day = toNum(data.hari_terakhir, 35);
let status: 'ok' | 'warning' | 'critical' = 'ok';
if (fcr > 1.7) status = 'critical';
else if (fcr > 1.63) status = 'warning';
const risks = [];
const actions = [];
if (status === 'critical') {
risks.push(`FCR mencapai ${fcr.toFixed(2)}, jauh di atas target standar CP 707 (>1.70).`);
actions.push('Periksa kebocoran silo/hopper dan potensi pakan tercecer.');
actions.push('Evaluasi kesehatan pencernaan ayam terhadap infeksi.');
} else if (status === 'warning') {
risks.push(
`FCR mencapai ${fcr.toFixed(2)}, sedikit di atas standar efisiensi (1.60-1.63).`
);
actions.push('Atur ketinggian piringan pakan untuk meminimalkan pakan tumpah.');
actions.push('Pantau kualitas fisik pakan (kadar debu/fines).');
} else {
actions.push('Pertahankan ketepatan penimbangan dan pemberian pakan harian.');
}
return {
headline: `Feed Conversion Ratio (FCR) tercatat sebesar ${fcr.toFixed(2)} pada hari ke-${day}.`,
status,
summary: [
`FCR aktual saat ini: ${fcr.toFixed(2)}.`,
`Nilai FCR memenuhi standar efisiensi konversi pakan strain CP 707.`,
],
risks,
actions,
};
}
case 'eef': {
const eef = toNum(data.eef_terakhir, 300);
const day = toNum(data.hari_terakhir, 35);
let status: 'ok' | 'warning' | 'critical' = 'ok';
if (eef < 200) status = 'critical';
else if (eef < 250) status = 'warning';
const risks = [];
const actions = [];
if (status === 'critical') {
risks.push(`EEF di zona kritis (<200) akibat penurunan viabilitas dan FCR buruk.`);
actions.push('Lakukan audit manajemen menyeluruh terkait ventilasi dan biosekuriti.');
} else if (status === 'warning') {
risks.push(
`Skor EEF (${eef.toFixed(0)}) berada di batas minimal standar optimal (200-250).`
);
actions.push('Tingkatkan pengelolaan sirkulasi udara kandang.');
} else {
actions.push('Pertahankan performa pemeliharaan hingga masa panen selesai.');
}
return {
headline: `Indeks performa EEF mencapai skor ${eef.toFixed(0)} pada hari ke-${day}.`,
status,
summary: [
`EEF aktual saat ini: ${eef.toFixed(0)}.`,
`Skor EEF melambangkan efisiensi produksi siklus berjalan secara keseluruhan.`,
],
risks,
actions,
};
}
case 'iot_panel': {
const temp = toNum(data.suhu_rata_rata_C, 25);
const hum = toNum(data.kelembapan_persen, 65);
const ammonia = toNum(data.amonia_ppm, 10);
const co2 = toNum(data.co2_ppm, 1500);
const isTempBad = temp < 20 || temp > 30;
const isHumBad = hum < 60 || hum > 70;
const isAmoniaBad = ammonia > 25;
let status: 'ok' | 'warning' | 'critical' = 'ok';
if (ammonia > 35 || temp > 34) status = 'critical';
else if (isTempBad || isHumBad || isAmoniaBad) status = 'warning';
const risks = [];
const actions = [];
if (isTempBad) {
risks.push(
`Suhu kandang (${temp.toFixed(1)}°C) berada di luar batas ideal CP 707 (20-30°C).`
);
actions.push(
temp > 30
? 'Aktifkan kipas pendingin tambahan atau exhaust fan.'
: 'Nyalakan pemanas tambahan.'
);
}
if (isAmoniaBad) {
risks.push(
`Kadar amonia tinggi (${ammonia.toFixed(1)} ppm), dapat mengiritasi mata dan paru ayam.`
);
actions.push('Tingkatkan minimal ventilasi kandang untuk mengeluarkan gas amonia.');
}
if (status === 'ok') {
actions.push('Pertahankan setting otomatis iklim mikro kandang.');
}
return {
headline: `Kondisi Lingkungan Kandang: Suhu Rata-rata ${temp.toFixed(1)}°C, Kelembapan ${hum.toFixed(1)}%.`,
status,
summary: [
`Suhu rata-rata: ${temp.toFixed(1)}°C.`,
`Kelembapan udara: ${hum.toFixed(1)}%.`,
`Kadar amonia: ${ammonia.toFixed(1)} ppm.`,
`Kadar CO2: ${co2.toFixed(1)} ppm.`,
],
risks,
actions,
};
}
case 'hitung_karung': {
const dituang = toNum(data.total_karung_dituang_iot, 0);
const masuk = toNum(data.total_karung_masuk_iot, 0);
const saldo = toNum(data.saldo_awal_karung, 100);
const sisa = saldo + masuk - dituang;
let status: 'ok' | 'warning' | 'critical' = 'ok';
if (sisa < 10) status = 'critical';
else if (sisa < 30) status = 'warning';
const risks = [];
const actions = [];
if (status === 'critical') {
risks.push(`Stok pakan menipis secara kritis (${sisa} karung tersisa).`);
actions.push('Segera ajukan pengiriman pakan darurat.');
} else if (status === 'warning') {
risks.push(`Stok pakan tersisa ${sisa} karung, cukup untuk 1-2 hari.`);
actions.push('Buat permintaan pakan baru hari ini.');
} else {
actions.push('Lakukan pemantauan konsumsi pakan harian.');
}
return {
headline: `Sisa stok pakan terhitung sebanyak ${sisa} karung.`,
status,
summary: [
`Pakan masuk: ${masuk} karung.`,
`Pakan dituang: ${dituang} karung.`,
`Sisa cadangan pakan: ${sisa} karung.`,
],
risks,
actions,
};
}
default:
return {
headline: 'Analisis operasional kandang terpantau normal.',
status: 'ok',
summary: ['Data operasional normal.'],
risks: [],
actions: ['Lanjutkan pengawasan berkala.'],
};
}
};
// ─── Component ───────────────────────────────────────────────────────────────
export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData, cacheKey }) => {
const { cycleData, kandangs, selectedKandangId } = useAppContext();
const [result, setResult] = useState<AiResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlightRef = useRef(false);
const lastCacheKeyRef = useRef<string | null>(null);
const meta = TOPIC_META[topic] ?? TOPIC_META['hitung_ayam'];
const colors = COLOR_MAP[meta.color] ?? COLOR_MAP['purple'];
const fetchFromBackend = useCallback(
async (cycleId: string): Promise<AiResult | null> => {
try {
const res = await apiClient.getAiInsight(cycleId);
if (!res.success || !res.data?.insightText) {
return null;
}
const parsedDashboard = JSON.parse(res.data.insightText);
const detailed = parsedDashboard.detailed;
if (!detailed) return null;
const topicMap: Record<InsightTopic, string> = {
hitung_ayam: 'hitung_ayam',
berat_ayam: 'berat_ayam',
fcr: 'fcr',
eef: 'eef',
iot_panel: 'panel_iot',
hitung_karung: 'hitung_karung',
};
const key = topicMap[topic];
const section = detailed[key];
if (!section) return null;
let status: 'ok' | 'warning' | 'critical' = 'ok';
if (section.status === 'critical') status = 'critical';
else if (section.status === 'warning') status = 'warning';
const headlineMap: Record<InsightTopic, string> = {
hitung_ayam: 'Analisis estimasi populasi harian dan tingkat mortalitas kandang.',
berat_ayam: 'Evaluasi bobot harian (ADG) dan tingkat keseragaman ayam.',
fcr: 'Analisis efisiensi konversi pakan harian terhadap standar.',
eef: 'Indeks performa keseluruhan efisiensi produksi siklus berjalan.',
iot_panel: 'Pemantauan parameter suhu, kelembaban, dan gas amonia kandang.',
hitung_karung: 'Analisis stok pakan dan efisiensi konsumsi harian.',
};
return {
headline: section.bullets?.[0] || headlineMap[topic],
status,
summary: section.bullets || [],
risks: section.evidence || [],
actions: section.actions || [],
};
} catch (err) {
console.warn('Failed to load insight from backend cache:', err);
return null;
}
},
[topic]
);
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const generate = useCallback(
async (force = false) => {
const storageKey = `page-insight-v5-${cacheKey}`;
// ── On force refresh, evict all caches first ──────────────────────────────
if (force) {
_insightMemoryCache.delete(storageKey);
try {
sessionStorage.removeItem(storageKey);
} catch {
/* ignore */
}
}
// ── 1. Global memory cache (instant, survives page navigation) ────────────
if (!force) {
const memCached = _insightMemoryCache.get(storageKey);
if (memCached) {
setResult(memCached);
lastCacheKeyRef.current = cacheKey;
return;
}
}
// ── 2. De-duplicate: if same key is already in-flight, wait for it ────────
if (!force && _insightInFlight.has(storageKey)) {
await _insightInFlight.get(storageKey);
const memCached = _insightMemoryCache.get(storageKey);
if (memCached) {
setResult(memCached);
lastCacheKeyRef.current = cacheKey;
}
return;
}
// ── 3. sessionStorage fallback (survives component unmount) ───────────────
if (!force) {
try {
const raw = sessionStorage.getItem(storageKey);
if (raw) {
const parsed = parseResult(raw);
if (parsed) {
_insightMemoryCache.set(storageKey, parsed); // promote to memory
setResult(parsed);
lastCacheKeyRef.current = cacheKey;
return;
}
sessionStorage.removeItem(storageKey);
}
} catch {
/* ignore */
}
}
if (inFlightRef.current) return;
inFlightRef.current = true;
setLoading(true);
setError(null);
// Register this promise for deduplication
const fetchPromise = (async () => {
try {
// ── 4. Backend database cache (bypass if force-refreshing) ────────────
if (!force && cycleData?.id) {
const backendResult = await fetchFromBackend(cycleData.id);
if (backendResult) {
_saveToCache(storageKey, backendResult);
setResult(backendResult);
lastCacheKeyRef.current = cacheKey;
return;
}
}
// ── 5. Generate via LM Studio proxy with 4000ms timeout ───────────────
const prompt = buildPrompt(topic, contextData);
const system_prompt =
'Anda adalah analis peternakan ayam broiler berpengalaman. Kembalikan HANYA objek JSON valid dengan format: {"headline": "kesimpulan singkat", "status": "ok/warning/critical", "summary": ["poin 1", "poin 2"], "risks": ["risiko 1"], "actions": ["tindakan 1"]}. Dilarang memberikan teks penjelasan di luar objek JSON.';
// Build a short semantic query for RAG (describe what we're analyzing)
const context_query = `Analisis ${topic.replace(/_/g, ' ')} hari ke-${contextData.currentDay || contextData.hari_ke || contextData.hari_terakhir || 0} broiler CP707`;
const fetchPromiseCall = apiClient.proxyLmStudio(
prompt,
system_prompt,
0.2,
topic,
context_query,
contextData
);
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('TIMEOUT_LIMIT')), 600000)
);
const json = await Promise.race([fetchPromiseCall, timeoutPromise]);
const text = json.response ?? '';
const parsed = parseResult(text, topic);
if (!parsed) {
console.error(`[PageAiInsight:${topic}] Raw response:`, text.slice(0, 500));
throw new Error('Respons AI tidak dapat diproses.');
}
if (result && JSON.stringify(parsed) === JSON.stringify(result)) {
throw new Error(
'Data belum diperbarui. Hasil analisis AI masih sama dengan versi sebelumnya.'
);
}
_saveToCache(storageKey, parsed);
setResult(parsed);
lastCacheKeyRef.current = cacheKey;
} catch (err) {
console.error(`[PageAiInsight:${topic}] LLM request failed/timed out:`, err);
const errMsg = err instanceof Error ? err.message : 'Gagal menghasilkan AI Insight.';
setError(errMsg);
setResult(null);
} finally {
setLoading(false);
inFlightRef.current = false;
_insightInFlight.delete(storageKey);
}
})();
_insightInFlight.set(storageKey, fetchPromise);
await fetchPromise;
// eslint-disable-next-line react-hooks/preserve-manual-memoization
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[topic, cacheKey, contextData, cycleData?.id, fetchFromBackend, result]
);
useEffect(() => {
if (lastCacheKeyRef.current === cacheKey) return;
const timer = setTimeout(() => generate(false), 800);
return () => clearTimeout(timer);
}, [cacheKey, generate]);
const downloadPdf = () => {
if (!result) return;
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const primaryColor = [79, 70, 229]; // Indigo [R, G, B]
const secondaryColor = [31, 41, 55]; // Gray-800
const lightGray = [243, 244, 246]; // Gray-100
// Page dimensions
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
const contentWidth = pageWidth - margin * 2;
// Helper to wrap and add text
let y = 20;
// 1. Header Title
doc.setFont('helvetica', 'bold');
doc.setFontSize(20);
doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.text('Daya Cognitive AI Insights', margin, y);
// Subtitle
y += 7;
doc.setFont('helvetica', 'normal');
doc.setFontSize(8.5);
doc.setTextColor(100, 116, 139); // Slate-500
doc.text(
'Strategic operations diagnostics & predictive actions recommended by sovereign LLMs',
margin,
y
);
// Divider line
y += 5;
doc.setDrawColor(226, 232, 240); // Slate-200
doc.setLineWidth(0.5);
doc.line(margin, y, pageWidth - margin, y);
// 2. Metadata Section (Horizontal tags)
y += 8;
doc.setFillColor(lightGray[0], lightGray[1], lightGray[2]);
doc.rect(margin, y, contentWidth, 10, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(71, 85, 105); // Slate-600
const activeKandang = kandangs?.find((k: any) => k.id === selectedKandangId);
const kandangName = activeKandang?.name || 'Semua Kandang';
const rawCtxDay =
(contextData as any)?.currentDay ??
(contextData as any)?.hari_ke ??
(contextData as any)?.hari_terakhir ??
cycleData?.currentDay;
const maxDays = (contextData as any)?.totalDays ?? cycleData?.totalDays;
const currentDayVal = maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
const dayAge =
currentDayVal !== undefined && currentDayVal !== null ? `Hari ${currentDayVal}` : '-';
const metaText = `SCOPE: ${meta.title.toUpperCase()} | KANDANG: ${kandangName.toUpperCase()} | UMUR SIKLUS: ${dayAge} | TANGGAL: ${new Date().toLocaleDateString('id-ID')}`;
doc.text(metaText, margin + 4, y + 6.5);
// 3. Headline Box (Status-based background)
y += 18;
// Determine status badge properties
let statusLabel = 'OK';
let badgeColor = [16, 185, 129]; // Emerald (OK)
let badgeBg = [209, 250, 229];
if (result.status === 'warning') {
statusLabel = 'PERINGATAN';
badgeColor = [245, 158, 11]; // Amber
badgeBg = [254, 243, 199];
} else if (result.status === 'critical') {
statusLabel = 'KRITIS';
badgeColor = [239, 68, 68]; // Red
badgeBg = [254, 226, 226];
}
// Draw Headline status background
doc.setFillColor(badgeBg[0], badgeBg[1], badgeBg[2]);
doc.roundedRect(margin, y, contentWidth, 18, 2, 2, 'F');
// Status Badge text
doc.setFillColor(badgeColor[0], badgeColor[1], badgeColor[2]);
doc.roundedRect(margin + 4, y + 4, 26, 5.5, 1, 1, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(7.5);
doc.setTextColor(255, 255, 255);
doc.text(statusLabel, margin + 6, y + 7.8);
// Headline Text
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(secondaryColor[0], secondaryColor[1], secondaryColor[2]);
const headlineLines = doc.splitTextToSize(result.headline, contentWidth - 38);
doc.text(headlineLines, margin + 34, y + 7.8);
y += 26;
// 4. Three core sections (Summary, Risks, Actions)
const drawSection = (
titleText: string,
items: string[],
titleColor: number[],
bulletBg: number[],
isOrdered = false
) => {
// Check if we need a new page
if (y > 240) {
doc.addPage();
y = 20;
}
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(titleColor[0], titleColor[1], titleColor[2]);
doc.text(titleText, margin, y);
y += 5;
doc.setDrawColor(titleColor[0], titleColor[1], titleColor[2]);
doc.setLineWidth(0.3);
doc.line(margin, y, margin + 18, y);
y += 8;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(55, 65, 81); // Slate-700
if (items.length === 0) {
doc.setFont('helvetica', 'italic');
doc.text('Tidak ada data analisis', margin + 4, y);
y += 8;
return;
}
items.forEach((item, index) => {
const textLines = doc.splitTextToSize(item, contentWidth - 12);
const textHeight = textLines.length * 4.5;
// Check page boundary for item
if (y + textHeight > 265) {
doc.addPage();
y = 20;
}
// Draw Bullet point / number badge
if (isOrdered) {
doc.setFillColor(bulletBg[0], bulletBg[1], bulletBg[2]);
doc.roundedRect(margin, y - 3, 4.5, 4.5, 1, 1, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(7);
doc.setTextColor(titleColor[0], titleColor[1], titleColor[2]);
doc.text((index + 1).toString(), margin + 1.5, y + 0.2);
} else {
doc.setFillColor(bulletBg[0], bulletBg[1], bulletBg[2]);
doc.circle(margin + 2.2, y - 1, 1.0, 'F');
}
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(55, 65, 81);
doc.text(textLines, margin + 8, y);
y += textHeight + 3.5;
});
y += 3;
};
// Draw Summary Section (Indigo style)
drawSection('Ringkasan Analisis', result.summary, [79, 70, 229], [224, 231, 255]);
// Draw Risks Section (Amber style)
drawSection('Risiko Operasional', result.risks, [217, 119, 6], [253, 243, 219]);
// Draw Actions Section (Emerald style)
drawSection('Tindakan & Rekomendasi', result.actions, [5, 150, 105], [209, 250, 229], true);
// Footer on all pages
const totalPages = doc.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
doc.setFont('helvetica', 'normal');
doc.setFontSize(7.5);
doc.setTextColor(148, 163, 184); // Slate-400
// Footer text
const footerText = `Laporan AI Insights CP 707 | Halaman ${i} dari ${totalPages}`;
doc.text(footerText, margin, 285);
// Timestamp
doc.text(new Date().toLocaleString('id-ID'), pageWidth - margin - 35, 285);
}
// Save document
const filename = `AI_Insight_${topic}_Kandang_${kandangName.replace(/\s+/g, '_')}_Hari_${currentDayVal || '0'}.pdf`;
doc.save(filename);
};
const scfg = result ? STATUS_CFG[result.status] : null;
const StatusIcon = scfg?.Icon ?? Info;
return (
<div
className={`rounded-2xl border ${colors.ring} bg-gradient-to-br from-white via-slate-50 to-white shadow-md overflow-hidden mb-6`}
>
{/* Header */}
<div
className={`flex items-center justify-between px-5 py-3.5 border-b ${colors.ring} bg-white/70`}
>
<div className="flex items-center gap-2.5">
<div className={`p-1.5 ${colors.sparkBg} rounded-lg`}>
<Sparkles className={`w-4 h-4 ${colors.sparkText}`} />
</div>
<div>
<span className="text-sm font-bold text-gray-900">
{meta.emoji} {meta.title}
</span>
<p className="text-xs text-gray-400">Analisis berbasis kecerdasan buatan DeepSeek R1</p>
</div>
</div>
<div className="flex items-center gap-2">
{result && (
<button
onClick={downloadPdf}
disabled={loading}
title="Unduh PDF"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-800 bg-slate-900 text-white hover:bg-slate-800 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-xs font-semibold shadow-sm"
>
<Download className="w-3.5 h-3.5" />
<span>Unduh PDF</span>
</button>
)}
<button
onClick={() => generate(true)}
disabled={loading}
title="Refresh insight"
className={`p-1.5 rounded-lg border ${colors.btnBorder} bg-white ${colors.btnHover} transition-all disabled:opacity-40 disabled:cursor-not-allowed`}
>
<RefreshCw
className={`w-3.5 h-3.5 ${colors.sparkText} ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
</div>
{/* Body */}
<div className="p-4">
{loading && !result && (
<div className="flex items-center gap-3 py-6 justify-center">
<div
className={`w-8 h-8 rounded-full border-4 border-slate-100 border-t-current ${colors.sparkText} animate-spin`}
/>
<p className="text-sm text-gray-400 animate-pulse">
Menganalisis data dengan DeepSeek R1...
</p>
</div>
)}
{error && !result && (
<div className="flex items-start gap-2 bg-red-50 border border-red-100 rounded-xl p-3">
<AlertTriangle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-semibold text-red-600">Gagal memuat insight</p>
<p className="text-xs text-red-500 mt-0.5">{error}</p>
<button
onClick={() => generate(true)}
className="text-xs text-red-600 underline mt-1 hover:text-red-800"
>
Coba lagi
</button>
</div>
</div>
)}
{!loading && !error && !result && (
<div className="flex items-center justify-center gap-2 py-6 text-gray-300 text-xs">
<div className="w-4 h-4 rounded-full border-2 border-gray-200 border-t-gray-400 animate-spin" />
<span>Memuat AI insight...</span>
</div>
)}
{result && scfg && (
<div className="space-y-3">
<div
className={`rounded-xl p-3 border ${scfg.bg} ${scfg.border} flex items-start gap-2.5`}
>
<StatusIcon className={`w-4 h-4 shrink-0 mt-0.5 ${scfg.text}`} />
<div className="flex-1">
<span
className={`text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded-full ${scfg.badge}`}
>
{scfg.label}
</span>
<p className={`mt-1 text-sm font-semibold ${scfg.text}`}>{result.headline}</p>
{loading && (
<p className="text-xs text-gray-400 mt-1 animate-pulse">Memperbarui...</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2.5">
<div className="bg-white rounded-xl border border-gray-100 p-3 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-gray-400 mb-2">
📋 Ringkasan
</p>
<ul className="space-y-1.5">
{result.summary.map((s, i) => (
<li key={i} className="flex gap-1.5 text-xs text-gray-600">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-slate-400 shrink-0" />
{s}
</li>
))}
</ul>
</div>
<div className="bg-amber-50 rounded-xl border border-amber-100 p-3 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-amber-600 mb-2">
⚠️ Risiko
</p>
{result.risks.length > 0 ? (
<ul className="space-y-1.5">
{result.risks.map((r, i) => (
<li key={i} className="flex gap-1.5 text-xs text-amber-800">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0" />
{r}
</li>
))}
</ul>
) : (
<p className="text-xs text-amber-500 italic">Tidak ada risiko signifikan</p>
)}
</div>
<div className="bg-emerald-50 rounded-xl border border-emerald-100 p-3 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-emerald-600 mb-2">
✅ Tindakan
</p>
<ol className="space-y-1.5">
{result.actions.map((a, i) => (
<li key={i} className="flex gap-1.5 text-xs text-emerald-800">
<span className="shrink-0 w-4 h-4 rounded-full bg-emerald-200 text-emerald-700 flex items-center justify-center font-bold text-[10px]">
{i + 1}
</span>
{a}
</li>
))}
</ol>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default PageAiInsight;