update ai insight pake qwenn
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-07-31 08:52:30 +07:00
1 parent 208e11e4f8
commit a8b49374f1
44 files changed
+119956 -552

No files matched your search

+71 -11
View File
@@ -565,6 +565,8 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [reportType, setReportType] = useState<'daily' | 'weekly' | 'end_cycle'>('daily');
const [reportPeriod, setReportPeriod] = useState<string>('Minggu 1');
const insightInFlightRef = useRef(false);
const applyInsightText = useCallback((text: string) => {
@@ -590,6 +592,17 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
const margin = 20;
const contentWidth = pageWidth - margin * 2;
// Helper to strip non-ASCII to prevent corrupted PDF in jsPDF standard fonts
const safeText = (text: string) => {
if (!text) return '';
return text
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/–|—/g, '-')
.replace(/•/g, '-')
.replace(/[^\x20-\x7E\n]/g, '');
};
// Helper to wrap and add text
let y = 20;
@@ -629,8 +642,8 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
(k: any) => k.id?.toString() === kandangId?.toString()
);
const kandangName = activeKandang?.name || 'Semua Kandang';
const rawCtxDay = input?.currentDay ?? context.cycleData?.currentDay;
const maxDays = input?.totalDays ?? context.cycleData?.totalDays;
const rawCtxDay = context.cycleData?.currentDay;
const maxDays = context.cycleData?.totalDays;
const currentDayVal = maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
const dayAge =
currentDayVal !== undefined && currentDayVal !== null ? `Hari ${currentDayVal}` : '-';
@@ -658,9 +671,10 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
doc.setFontSize(9);
doc.setTextColor(secondaryColor[0], secondaryColor[1], secondaryColor[2]);
const headline =
const headline = safeText(
structuredInsight.summary?.headline ||
'Ringkasan operasional dan analisis kecerdasan buatan untuk farm broiler.';
'Ringkasan operasional dan analisis kecerdasan buatan untuk farm broiler.'
);
const headlineLines = doc.splitTextToSize(headline, contentWidth - 38);
doc.text(headlineLines, margin + 34, y + 7.8);
@@ -702,7 +716,8 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
return;
}
items.forEach((item, index) => {
items.forEach((rawItem, index) => {
const item = safeText(rawItem);
const textLines = doc.splitTextToSize(item, contentWidth - 12);
const textHeight = textLines.length * 4.5;
@@ -1031,7 +1046,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
kandangId,
insightData.contextPack,
CACHE_VERSION,
forceRefresh
forceRefresh,
reportType,
reportPeriod
);
if (!result || !result.success || !result.data?.insightText) {
@@ -1062,7 +1079,15 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
insightInFlightRef.current = false;
}
},
[applyInsightText, context.cycleData, kandangId, prepareInsightData, saveToCache]
[
applyInsightText,
context.cycleData,
kandangId,
prepareInsightData,
saveToCache,
reportType,
reportPeriod,
]
);
// Monitor when KPIs become available
@@ -1081,7 +1106,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
// The scope key already captures cycle/kandang changes. Avoid retriggering on unrelated context updates.
return () => clearTimeout(timer);
}, [kpisReady, kandangId, context.cycleData?.id, generateInsight]); // Regenerate when KPIs become ready or the insight scope changes
}, [kpisReady, kandangId, context.cycleData?.id, generateInsight, reportType, reportPeriod]); // Regenerate when KPIs become ready or the insight scope changes
// Auto-refresh every 6 hours
useEffect(() => {
@@ -1343,12 +1368,47 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
<div className="bg-gradient-to-br from-blue-50 to-purple-50 border border-blue-100 rounded-2xl shadow-lg overflow-hidden">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-4 gap-4">
<div className="flex items-center gap-2">
<Sparkles className="w-6 h-6 text-purple-600" />
<h2 className="text-xl font-bold text-gray-900">AI Insight</h2>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{/* Filter controls only visible on dashboard overview */}
{(page === 'dashboard' || !page) && (
<>
<select
value={reportType}
onChange={(e) => setReportType(e.target.value as any)}
className="text-sm border-gray-300 rounded-lg shadow-sm focus:border-purple-500 focus:ring-purple-500"
>
<option value="daily">Harian</option>
<option value="weekly">Mingguan</option>
{((context.kpis?.umur?.value || 0) >= 35 ||
context.cycleData?.status === 'finished') && (
<option value="end_cycle">Akhir Siklus</option>
)}
</select>
{reportType === 'weekly' && (
<select
value={reportPeriod}
onChange={(e) => setReportPeriod(e.target.value)}
className="text-sm border-gray-300 rounded-lg shadow-sm focus:border-purple-500 focus:ring-purple-500"
>
{Array.from(
{ length: Math.max(1, Math.ceil((context.kpis?.umur?.value || 0) / 7)) },
(_, i) => i + 1
).map((w) => (
<option key={w} value={`Minggu ${w}`}>
Minggu {w}
</option>
))}
</select>
)}
</>
)}
{structuredInsight && (
<button
onClick={downloadPdf}
@@ -1357,7 +1417,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
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-4 h-4" />
<span>Unduh PDF</span>
<span className="hidden sm:inline">Unduh PDF</span>
</button>
)}
<button
+8 -3
View File
@@ -104,6 +104,11 @@ const Dashboard: React.FC<DashboardProps> = ({
const cycleStart = new Date(cycleData.startDate);
cycleStart.setHours(0, 0, 0, 0);
const getLocalDateString = (d: Date) => {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
const dailyTotals = new Map<string, number>();
for (const entry of feedSackHistory) {
if (!usageCameraNames.includes(entry.cameraName)) continue;
@@ -112,7 +117,7 @@ const Dashboard: React.FC<DashboardProps> = ({
entryDate.setHours(0, 0, 0, 0);
if (entryDate < cycleStart) continue;
const dateKey = entryDate.toISOString().split('T')[0] || '';
const dateKey = getLocalDateString(entryDate);
if (!dateKey) continue;
dailyTotals.set(dateKey, (dailyTotals.get(dateKey) || 0) + (entry.count || 0));
}
@@ -121,7 +126,7 @@ const Dashboard: React.FC<DashboardProps> = ({
for (let dayIndex = 0; dayIndex <= reportedDay; dayIndex += 1) {
const date = new Date(cycleStart);
date.setDate(date.getDate() + dayIndex);
const dateKey = date.toISOString().split('T')[0] || '';
const dateKey = getLocalDateString(date);
if (!dateKey) {
history.push(0);
continue;
@@ -131,7 +136,7 @@ const Dashboard: React.FC<DashboardProps> = ({
const targetDate = new Date(cycleStart);
targetDate.setDate(targetDate.getDate() + reportedDay);
const targetDateKey = targetDate.toISOString().split('T')[0] || '';
const targetDateKey = getLocalDateString(targetDate);
const totalKarung = dailyTotals.get(targetDateKey) || 0;
return {
+54 -15
View File
@@ -214,7 +214,11 @@ const PAGE_FOCUSES: Record<InsightTopic, string[]> = {
};
// ─── Prompt builders ─────────────────────────────────────────────────────────
const buildPrompt = (topic: InsightTopic, data: Record<string, unknown>): string => {
const buildPrompt = (
topic: InsightTopic,
data: Record<string, unknown>,
timeframe: 'harian' | 'mingguan' = 'harian'
): 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;
@@ -230,6 +234,7 @@ Pedoman mortalitas strain CP 707: di bawah 5 persen = normal (ok), 5-7 persen =
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Lakukan analisis secara ${timeframe.toUpperCase()}.
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data kandang:
@@ -241,6 +246,7 @@ Pedoman: bobot di bawah target standar CP 707 lebih dari 5 persen = warning. ADG
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Lakukan analisis secara ${timeframe.toUpperCase()}.
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data bobot:
@@ -255,6 +261,7 @@ Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Lakukan analisis secara ${timeframe.toUpperCase()}.
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data FCR:
@@ -268,6 +275,7 @@ Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Lakukan analisis secara ${timeframe.toUpperCase()}.
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data EEF:
@@ -280,6 +288,7 @@ Pedoman: Suhu ideal 20-30 derajat Celsius. Kelembapan ideal 60-70 persen. Amonia
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Lakukan analisis secara ${timeframe.toUpperCase()}.
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data sensor IoT:
@@ -292,6 +301,7 @@ Pedoman: konsumsi pakan harian wajar strain CP 707 sekitar 170 gram per ekor per
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
Lakukan analisis secara ${timeframe.toUpperCase()}.
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
Data karung pakan:
@@ -725,6 +735,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
const [result, setResult] = useState<AiResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [timeframe, setTimeframe] = useState<'harian' | 'mingguan'>('harian');
const inFlightRef = useRef(false);
const lastCacheKeyRef = useRef<string | null>(null);
@@ -787,7 +798,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const generate = useCallback(
async (force = false) => {
const storageKey = `page-insight-v5-${cacheKey}`;
const storageKey = `page-insight-v5-${cacheKey}-${timeframe}`;
// ── On force refresh, evict all caches first ──────────────────────────────
if (force) {
@@ -815,7 +826,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
const memCached = _insightMemoryCache.get(storageKey);
if (memCached) {
setResult(memCached);
lastCacheKeyRef.current = cacheKey;
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
}
return;
}
@@ -829,7 +840,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
if (parsed) {
_insightMemoryCache.set(storageKey, parsed); // promote to memory
setResult(parsed);
lastCacheKeyRef.current = cacheKey;
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
return;
}
sessionStorage.removeItem(storageKey);
@@ -853,18 +864,18 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
if (backendResult) {
_saveToCache(storageKey, backendResult);
setResult(backendResult);
lastCacheKeyRef.current = cacheKey;
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
return;
}
}
// ── 5. Generate via LM Studio proxy with 4000ms timeout ───────────────
const prompt = buildPrompt(topic, contextData);
const prompt = buildPrompt(topic, contextData, timeframe);
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 context_query = `Analisis ${timeframe} ${topic.replace(/_/g, ' ')} hari ke-${contextData.currentDay || contextData.hari_ke || contextData.hari_terakhir || 0} broiler CP707`;
const fetchPromiseCall = apiClient.proxyLmStudio(
prompt,
@@ -895,7 +906,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
_saveToCache(storageKey, parsed);
setResult(parsed);
lastCacheKeyRef.current = cacheKey;
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
} catch (err) {
console.error(`[PageAiInsight:${topic}] LLM request failed/timed out:`, err);
const errMsg = err instanceof Error ? err.message : 'Gagal menghasilkan AI Insight.';
@@ -913,14 +924,14 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
// eslint-disable-next-line react-hooks/preserve-manual-memoization
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[topic, cacheKey, contextData, cycleData?.id, fetchFromBackend, result]
[topic, cacheKey, contextData, cycleData?.id, fetchFromBackend, result, timeframe]
);
useEffect(() => {
if (lastCacheKeyRef.current === cacheKey) return;
if (lastCacheKeyRef.current === `${cacheKey}-${timeframe}`) return;
const timer = setTimeout(() => generate(false), 800);
return () => clearTimeout(timer);
}, [cacheKey, generate]);
}, [cacheKey, timeframe, generate]);
const downloadPdf = () => {
if (!result) return;
@@ -940,6 +951,17 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
const margin = 20;
const contentWidth = pageWidth - margin * 2;
// Helper to strip non-ASCII to prevent corrupted PDF in jsPDF standard fonts
const safeText = (text: string) => {
if (!text) return '';
return text
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/–|—/g, '-')
.replace(/•/g, '-')
.replace(/[^\x20-\x7E\n]/g, '');
};
// Helper to wrap and add text
let y = 20;
@@ -1024,7 +1046,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
doc.setFontSize(9);
doc.setTextColor(secondaryColor[0], secondaryColor[1], secondaryColor[2]);
const headlineLines = doc.splitTextToSize(result.headline, contentWidth - 38);
const headline = safeText(result.headline);
const headlineLines = doc.splitTextToSize(headline, contentWidth - 38);
doc.text(headlineLines, margin + 34, y + 7.8);
y += 26;
@@ -1066,7 +1089,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
return;
}
items.forEach((item, index) => {
items.forEach((rawItem, index) => {
const item = safeText(rawItem);
const textLines = doc.splitTextToSize(item, contentWidth - 12);
const textHeight = textLines.length * 4.5;
@@ -1149,10 +1173,25 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
<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>
<p className="text-xs text-gray-400">Analisis berbasis kecerdasan buatan Qwen 2.5</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* TIMEFRAME TOGGLE */}
<div className="flex bg-slate-100 rounded-lg p-0.5 border border-slate-200 mr-1">
<button
onClick={() => setTimeframe('harian')}
className={`px-2 py-1 text-[11px] font-semibold rounded-md transition-all ${timeframe === 'harian' ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Harian
</button>
<button
onClick={() => setTimeframe('mingguan')}
className={`px-2 py-1 text-[11px] font-semibold rounded-md transition-all ${timeframe === 'mingguan' ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Mingguan
</button>
</div>
{result && (
<button
onClick={downloadPdf}
@@ -1185,7 +1224,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
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...
Menganalisis data dengan Qwen 2.5...
</p>
</div>
)}