340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
import { performanceService } from './performanceService.ts';
|
||
import { ManualWeighingRecord } from '../types/manualWeighing.ts';
|
||
import { marked } from 'marked';
|
||
import { getTargetWeightForDay } from '../utils/weightUtils.ts';
|
||
import { apiClient } from './apiService.ts';
|
||
|
||
// Dashboard Insight Types
|
||
export interface DashboardInsightData {
|
||
currentDay: number;
|
||
totalDays: number;
|
||
phase: string;
|
||
kandangName: string;
|
||
initialPopulation: number;
|
||
contextPack: unknown;
|
||
}
|
||
|
||
export interface InsightDetailSection {
|
||
status: 'ok' | 'warning' | 'critical' | 'unknown';
|
||
bullets: string[];
|
||
evidence: string[];
|
||
actions: string[];
|
||
}
|
||
|
||
export interface DashboardInsightJson {
|
||
summary: {
|
||
headline: string;
|
||
bullets: string[];
|
||
risks: string[];
|
||
actions: string[];
|
||
};
|
||
detailed: {
|
||
hitung_ayam: InsightDetailSection;
|
||
berat_ayam: InsightDetailSection;
|
||
fcr: InsightDetailSection;
|
||
eef: InsightDetailSection;
|
||
panel_iot: InsightDetailSection;
|
||
data_quality: InsightDetailSection;
|
||
};
|
||
}
|
||
|
||
const dashboardInsightRequests = new Map<string, Promise<string>>();
|
||
|
||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
|
||
const cleanPromptValue = (value: unknown): unknown => {
|
||
if (value === null || value === undefined) return undefined;
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim();
|
||
return trimmed.length > 0 ? trimmed : undefined;
|
||
}
|
||
if (typeof value === 'number') {
|
||
return Number.isFinite(value) ? value : undefined;
|
||
}
|
||
if (typeof value === 'boolean') {
|
||
return value;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
const cleaned = value
|
||
.map((item) => cleanPromptValue(item))
|
||
.filter((item): item is unknown => item !== undefined);
|
||
return cleaned.length > 0 ? cleaned : undefined;
|
||
}
|
||
if (isPlainObject(value)) {
|
||
const entries: Array<[string, unknown]> = [];
|
||
for (const [key, item] of Object.entries(value)) {
|
||
const cleaned = cleanPromptValue(item);
|
||
if (cleaned !== undefined) {
|
||
entries.push([key, cleaned]);
|
||
}
|
||
}
|
||
if (entries.length === 0) return undefined;
|
||
return Object.fromEntries(entries);
|
||
}
|
||
return value;
|
||
};
|
||
|
||
const serializePromptContext = (value: unknown): string => {
|
||
const cleaned = cleanPromptValue(value);
|
||
return JSON.stringify(cleaned ?? {}, null, 2);
|
||
};
|
||
|
||
const INSIGHT_SECTION_STATUS = ['ok', 'warning', 'critical', 'unknown'] as const;
|
||
|
||
const isInsightStatus = (value: unknown): value is InsightDetailSection['status'] =>
|
||
typeof value === 'string' && INSIGHT_SECTION_STATUS.includes(value as any);
|
||
|
||
const isStringArray = (value: unknown): value is string[] =>
|
||
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||
|
||
const isInsightSection = (value: unknown): value is InsightDetailSection => {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||
const section = value as Record<string, unknown>;
|
||
return (
|
||
isInsightStatus(section.status) &&
|
||
isStringArray(section.bullets) &&
|
||
isStringArray(section.evidence) &&
|
||
isStringArray(section.actions)
|
||
);
|
||
};
|
||
|
||
const isDashboardInsightJson = (value: unknown): value is DashboardInsightJson => {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||
const root = value as Record<string, unknown>;
|
||
const summary = root.summary as Record<string, unknown> | undefined;
|
||
const detailed = root.detailed as Record<string, unknown> | undefined;
|
||
|
||
return (
|
||
!!summary &&
|
||
typeof summary === 'object' &&
|
||
!Array.isArray(summary) &&
|
||
typeof summary.headline === 'string' &&
|
||
isStringArray(summary.bullets) &&
|
||
isStringArray(summary.risks) &&
|
||
isStringArray(summary.actions) &&
|
||
!!detailed &&
|
||
typeof detailed === 'object' &&
|
||
!Array.isArray(detailed) &&
|
||
isInsightSection(detailed.hitung_ayam) &&
|
||
isInsightSection(detailed.berat_ayam) &&
|
||
isInsightSection(detailed.panel_iot) &&
|
||
isInsightSection(detailed.data_quality)
|
||
);
|
||
};
|
||
|
||
const parseDashboardInsightJson = (text: string): DashboardInsightJson | null => {
|
||
const trimmed = text.trim();
|
||
const first = trimmed.indexOf('{');
|
||
const last = trimmed.lastIndexOf('}');
|
||
if (first === -1 || last === -1 || last <= first) {
|
||
return null;
|
||
}
|
||
const candidate = trimmed
|
||
.slice(first, last + 1)
|
||
.replace(/\/\/.*/g, '')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||
.replace(/\.\.\.[\s\S]*?(?=[,}\]\n])/g, '')
|
||
.replace(/,\s*([}\]])/g, '$1');
|
||
|
||
try {
|
||
const parsed = JSON.parse(candidate) as unknown;
|
||
return isDashboardInsightJson(parsed) ? parsed : null;
|
||
} catch {
|
||
try {
|
||
const fallbackClean = candidate
|
||
.replace(/["'][^"']*?\.\.\.[^"']*?["']/g, '""')
|
||
.replace(/\.\.\.[^,}\]\n]*/g, '');
|
||
const parsed = JSON.parse(fallbackClean) as unknown;
|
||
return isDashboardInsightJson(parsed) ? parsed : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
};
|
||
|
||
const DASHBOARD_FOCUSES = [
|
||
'Korelasi tingkat mortalitas dengan tren berat harian',
|
||
'Efisiensi pakan (FCR) vs proyeksi panen dan nilai EEF',
|
||
'Deteksi anomali lingkungan (IoT) terhadap tingkat stres ayam',
|
||
'Keseimbangan jumlah pakan yang masuk dengan performa ADG',
|
||
'Proyeksi pencapaian target panen berdasarkan data pertumbuhan saat ini',
|
||
'Evaluasi performa ayam dibandingkan dengan standar',
|
||
'Identifikasi risiko terbesar hari ini secara holistik (penyakit/pakan/lingkungan)',
|
||
'Evaluasi manajemen pemeliharaan secara keseluruhan (summary harian)',
|
||
];
|
||
|
||
const buildDashboardInsightPrompt = (contextText: string) => {
|
||
const selectedFocus = DASHBOARD_FOCUSES[Math.floor(Math.random() * DASHBOARD_FOCUSES.length)];
|
||
return `
|
||
Anda adalah analis operasional peternakan ayam broiler yang berpengalaman.
|
||
|
||
Tugas:
|
||
- Buat insight ringkas, akurat, actionable, dan mudah dipahami oleh peternak.
|
||
- Tulis dalam Bahasa Indonesia yang profesional namun mudah dimengerti.
|
||
- Output harus berupa satu objek JSON valid saja.
|
||
- Jangan gunakan markdown, code fence, atau teks di luar JSON.
|
||
- Jangan invent angka atau fakta yang tidak ada di data.
|
||
- Jika data kosong, hilang, atau saling bertentangan, jelaskan di bagian data_quality.
|
||
- Summary harus paling penting dan paling singkat.
|
||
- Urutan prioritas analisis: hitung_ayam → berat_ayam → fcr → eef → panel_iot → data_quality.
|
||
- Batasi jawaban serapi mungkin agar cocok untuk UI expandable.
|
||
|
||
Konteks & Aturan Variasi (Penting):
|
||
Untuk hari ini, fokus analisis utama Anda adalah: "${selectedFocus}".
|
||
Analisis seluruh seksi berdasarkan sudut pandang fokus utama ini. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. Dilarang keras berhalusinasi atau menambahkan angka fiktif.
|
||
|
||
PEDOMAN PER SEKSI:
|
||
- hitung_ayam: Analisis populasi dan mortalitas HANYA berdasarkan perhitungan manual (Total populasi awal - mortality - panen). JANGAN mengandalkan data populasi dari IoT. DILARANG KERAS menyertakan data atau analisis terkait FCR, EEF, atau konsumsi pakan (sack) di seksi ini. Fokus hanya pada jumlah ayam, mortalitas, panen, dan afkir.
|
||
- berat_ayam: Analisis bobot rata-rata vs standar Cobb, ADG (pertambahan berat harian), keseragaman.
|
||
- fcr: Analisis Feed Conversion Ratio aktual vs standar Cobb. FCR rendah = efisiensi pakan bagus. Berikan rekomendasi konkret jika FCR buruk.
|
||
- eef: Analisis European Efficiency Factor (EEF/IP). EEF dihitung dari: (% ayam hidup × bobot panen rata-rata) / (FCR × umur panen × 100). Nilai EEF >300 sangat baik, 250-300 baik, <250 perlu perhatian. Berikan interpretasi dan saran peningkatan.
|
||
- panel_iot: Analisis kondisi lingkungan kandang (suhu, kelembaban, CO2) dan dampaknya ke performa ayam. JANGAN membahas amonia — sensornya tidak dipakai dan angkanya tidak dikirim.
|
||
- data_quality: Evaluasi kualitas dan kelengkapan data yang tersedia.
|
||
|
||
Buat aksi (actions) yang SPESIFIK dan DAPAT LANGSUNG DIPRAKTIKKAN oleh peternak, bukan saran umum.
|
||
|
||
DATA OPERASIONAL:
|
||
${contextText}
|
||
`.trim();
|
||
};
|
||
|
||
export const generateReportSummary = async (dashboardData: string): Promise<string> => {
|
||
const startTime = performance.now();
|
||
try {
|
||
const prompt = `
|
||
Anda adalah seorang analis peternakan ayam ahli untuk Charoen Pokphand.
|
||
Berdasarkan data dashboard berikut, buatlah ringkasan laporan harian yang ringkas namun informatif untuk manajer peternakan.
|
||
Sorot masalah kritis, identifikasi tren potensial, dan berikan 2-3 rekomendasi tindakan yang dapat diambil.
|
||
Sajikan dalam format poin-poin yang jelas dan mudah dibaca. Gunakan Bahasa Indonesia.
|
||
|
||
Data Dashboard:
|
||
${dashboardData}
|
||
`;
|
||
|
||
const response = await apiClient.proxyLmStudio(
|
||
prompt,
|
||
'Anda adalah seorang analis peternakan ayam ahli untuk Charoen Pokphand.'
|
||
);
|
||
|
||
return response.response ?? response.message ?? '';
|
||
} catch (error) {
|
||
console.error('Error calling Local LLM API via backend proxy:', error);
|
||
if (error instanceof Error) {
|
||
return `Gagal menghasilkan ringkasan AI: ${error.message}`;
|
||
}
|
||
return 'Gagal menghasilkan ringkasan AI karena kesalahan yang tidak diketahui.';
|
||
} finally {
|
||
const endTime = performance.now();
|
||
performanceService.recordApiTiming('generateReportSummary', endTime - startTime);
|
||
}
|
||
};
|
||
|
||
export const generateWeighingAnalysis = async (
|
||
record: ManualWeighingRecord,
|
||
dayOfCycle: number
|
||
): Promise<string> => {
|
||
const startTime = performance.now();
|
||
try {
|
||
const targetWeight = getTargetWeightForDay(dayOfCycle);
|
||
const dataSummary = record.sessions
|
||
.map(
|
||
(s) =>
|
||
` - Sesi ${s.id} (${s.time}): Rata-rata ${s.average.toFixed(2)}g (Total: ${s.weights.filter((w) => w.bw !== null).length} ayam)`
|
||
)
|
||
.join('\n');
|
||
const totalChickensWeighed = record.sessions.reduce(
|
||
(sum, session) => sum + session.weights.filter((w) => w.bw !== null).length,
|
||
0
|
||
);
|
||
const weightedSum = record.sessions.reduce(
|
||
(sum, session) => sum + session.average * session.weights.filter((w) => w.bw !== null).length,
|
||
0
|
||
);
|
||
const overallAverage = totalChickensWeighed > 0 ? weightedSum / totalChickensWeighed : 0;
|
||
|
||
const prompt = `
|
||
Anda adalah seorang ahli nutrisi dan kesehatan peternakan ayam broiler untuk Charoen Pokphand.
|
||
Anda menerima data penimbangan manual untuk hari ke-${dayOfCycle} dari siklus pemeliharaan.
|
||
|
||
**Tugas Anda:**
|
||
Berikan analisis mendalam terhadap data ini. Buatlah laporan singkat dalam format Markdown yang mencakup poin-poin berikut:
|
||
|
||
1. **Ringkasan Performa Pertumbuhan:** Bandingkan bobot rata-rata keseluruhan (${overallAverage.toFixed(2)}g) dengan bobot standar/target untuk hari ke-${dayOfCycle} (${targetWeight ? targetWeight + 'g' : 'Tidak tersedia'}). Berikan kesimpulan apakah performa pertumbuhan sesuai, di atas, atau di bawah ekspektasi.
|
||
2. **Analisis Keseragaman (Uniformity):** Lihat data dari setiap sesi penimbangan. Apakah ada perbedaan signifikan antara rata-rata bobot antar sesi? Analisis sebaran data dalam setiap sesi (Anda tidak memiliki data mentah, jadi buat asumsi berdasarkan rata-rata). Apakah ada indikasi masalah keseragaman?
|
||
3. **Identifikasi Potensi Masalah:** Berdasarkan data, identifikasi kemungkinan masalah. Contoh: Jika satu sesi memiliki rata-rata jauh lebih rendah, mungkin ada masalah di zona kandang tersebut (misalnya, kepadatan, akses pakan/minum, penyakit).
|
||
4. **Rekomendasi Tindakan:** Berikan 2-3 rekomendasi konkret yang dapat ditindaklanjuti oleh manajer peternakan. Contoh: "Lakukan pemeriksaan fisik di area yang diwakili oleh Timbangan X", "Tinjau kembali kepadatan ayam di zona Y", atau "Pastikan distribusi pakan merata di seluruh kandang".
|
||
|
||
Gunakan format yang jelas dengan heading (misal: ### Analisis Keseragaman) dan bullet points. Gunakan Bahasa Indonesia.
|
||
|
||
**Data Penimbangan Manual:**
|
||
- Tanggal: ${record.date}
|
||
- Hari ke-: ${dayOfCycle}
|
||
- Target Bobot: ${targetWeight ? targetWeight + 'g' : 'Tidak Tersedia'}
|
||
- Rangkuman Sesi:
|
||
${dataSummary}
|
||
`;
|
||
|
||
const response = await apiClient.proxyLmStudio(
|
||
prompt,
|
||
'Anda adalah seorang ahli nutrisi dan kesehatan peternakan ayam broiler untuk Charoen Pokphand.'
|
||
);
|
||
|
||
const rawText = response.response ?? response.message ?? '';
|
||
const html = await marked.parse(rawText);
|
||
return html;
|
||
} catch (error) {
|
||
console.error('Error calling Local LLM API for weighing analysis:', error);
|
||
if (error instanceof Error) {
|
||
return `<p class="text-red-500">Gagal menghasilkan analisis AI: ${error.message}</p>`;
|
||
}
|
||
return '<p class="text-red-500">Gagal menghasilkan analisis AI karena kesalahan yang tidak diketahui.</p>';
|
||
} finally {
|
||
const endTime = performance.now();
|
||
performanceService.recordApiTiming('generateWeighingAnalysis', endTime - startTime);
|
||
}
|
||
};
|
||
|
||
export const generateDashboardInsight = async (data: DashboardInsightData): Promise<string> => {
|
||
const startTime = performance.now();
|
||
const contextText = serializePromptContext(data.contextPack);
|
||
const requestKey = contextText;
|
||
const existingRequest = dashboardInsightRequests.get(requestKey);
|
||
if (existingRequest) {
|
||
return existingRequest;
|
||
}
|
||
|
||
const requestPromise = (async () => {
|
||
try {
|
||
const prompt = buildDashboardInsightPrompt(contextText);
|
||
const res = await apiClient.proxyLmStudio(
|
||
prompt,
|
||
'Anda adalah analis operasional peternakan ayam broiler CP 707 (PT Charoen Pokphand Indonesia). Analisis HANYA berdasarkan standar buku CP 707 edisi Juli 2023. Kembalikan HANYA format JSON valid. Dilarang memberikan penjelasan di luar JSON.',
|
||
0.1
|
||
);
|
||
|
||
const responseText = res.response ?? '';
|
||
const parsed = parseDashboardInsightJson(responseText);
|
||
|
||
if (parsed) {
|
||
return JSON.stringify(parsed, null, 2);
|
||
}
|
||
|
||
throw new Error('Gagal menghasilkan insight AI: respons JSON tidak valid.');
|
||
} catch (error) {
|
||
console.error('❌ Error calling Local LLM API for dashboard insight:', error);
|
||
if (error instanceof Error) {
|
||
throw new Error(`Gagal menghasilkan insight AI: ${error.message}`);
|
||
}
|
||
throw new Error('Gagal menghasilkan insight AI karena kesalahan yang tidak diketahui.');
|
||
} finally {
|
||
dashboardInsightRequests.delete(requestKey);
|
||
const endTime = performance.now();
|
||
performanceService.recordApiTiming('generateDashboardInsight', endTime - startTime);
|
||
}
|
||
})();
|
||
|
||
dashboardInsightRequests.set(requestKey, requestPromise);
|
||
return requestPromise;
|
||
};
|