diff --git a/.gitignore b/.gitignore index f9cc606..a7cdd8c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ backup __pycache__ docker-compose.override.yml docs/ -.superpowers/ \ No newline at end of file +.superpowers/ +AI Insight/chroma_db/ \ No newline at end of file diff --git a/AI Insight/Dockerfile b/AI Insight/Dockerfile new file mode 100644 index 0000000..250dceb --- /dev/null +++ b/AI Insight/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.9-slim + +WORKDIR /app + + +# Copy dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Pre-download the Hugging Face embedding model during Docker build phase +# so it is baked into the image and does not need internet to start +RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')" + +# Copy source code and vector DB +COPY . . + +# Set offline environment variables +ENV HF_HUB_OFFLINE=1 +ENV TRANSFORMERS_OFFLINE=1 +ENV RAG_PORT=5002 + +EXPOSE 5002 + +CMD ["python", "rag_service.py"] diff --git a/AI Insight/chunk_classify.py b/AI Insight/chunk_classify.py new file mode 100644 index 0000000..00efb85 --- /dev/null +++ b/AI Insight/chunk_classify.py @@ -0,0 +1,34 @@ +"""Classify CP 707 extracted chunks as prosa vs tabel for RAG metadata.""" + +from __future__ import annotations + +import re + +_NUMERIC_PIPE_ROW = re.compile(r"^[\d.,]+(\s*\|\s*[\d.,]*)+$") +_CHAPTER_RE = re.compile( + r"(?:bab|chapter|lampiran)\s*([0-9IVXLC]+)", + re.IGNORECASE, +) + + +def classify_chunk_tipe(text: str) -> str: + """Return 'tabel' if the chunk is mostly headerless numeric rows, else 'prosa'.""" + lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()] + if not lines: + return "prosa" + numeric = sum(1 for ln in lines if _NUMERIC_PIPE_ROW.match(ln)) + if numeric >= max(2, len(lines) // 2): + return "tabel" + return "prosa" + + +def detect_bab(text: str, source_filename: str = "") -> str: + """Best-effort chapter / lampiran label from chunk text or filename.""" + haystack = f"{source_filename}\n{text[:800]}" + match = _CHAPTER_RE.search(haystack) + if match: + return match.group(0).strip() + lower = source_filename.lower() + if "lampiran" in lower: + return "lampiran" + return "" diff --git a/AI Insight/documents/Manajemen Broiler CP 707 (1).docx b/AI Insight/documents/Manajemen Broiler CP 707 (1).docx new file mode 100644 index 0000000..fed088f Binary files /dev/null and b/AI Insight/documents/Manajemen Broiler CP 707 (1).docx differ diff --git a/AI Insight/extract_text.py b/AI Insight/extract_text.py new file mode 100644 index 0000000..a1bdab3 --- /dev/null +++ b/AI Insight/extract_text.py @@ -0,0 +1,120 @@ +import os +import glob +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +INPUT_DIR = os.getenv("INPUT_DIR", "documents") +OUTPUT_TXT_DIR = os.getenv("OUTPUT_TXT_DIR", "extracted_txt") + +def extract_docx(file_path): + """Mengekstrak teks dari file .docx dengan menjaga urutan asli paragraf dan tabel.""" + import docx + from docx.oxml import OxmlElement + from docx.text.paragraph import Paragraph + from docx.table import Table + + doc = docx.Document(file_path) + full_text = [] + + # Iterasi semua elemen anak di dalam body document untuk menjaga urutan + for element in doc.element.body: + tag = element.tag + if tag.endswith('p'): + para = Paragraph(element, doc) + if para.text.strip(): + full_text.append(para.text) + elif tag.endswith('tbl'): + table = Table(element, doc) + table_text = [] + for row in table.rows: + row_text = [] + for cell in row.cells: + text = cell.text.strip() + # Hindari duplikasi text sel gabungan (merged cells) secara berturut-turut + if not row_text or row_text[-1] != text: + row_text.append(text) + if row_text: + table_text.append(" | ".join(row_text)) + if table_text: + full_text.append("\n".join(table_text)) + + return "\n\n".join(full_text) + +def extract_pdf(file_path): + """Mengekstrak teks dari file .pdf halaman demi halaman.""" + from pypdf import PdfReader + reader = PdfReader(file_path) + full_text = [] + for i, page in enumerate(reader.pages): + text = page.extract_text() + if text and text.strip(): + full_text.append(text) + return "\n".join(full_text) + +def extract_txt(file_path): + """Membaca file teks dengan encoding UTF-8.""" + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + return f.read() + +def main(): + # Pastikan folder input ada + if not os.path.exists(INPUT_DIR): + print(f"Folder input '{INPUT_DIR}' tidak ditemukan. Membuat folder...") + os.makedirs(INPUT_DIR) + print(f"Silakan letakkan file dokumen Anda di folder '{INPUT_DIR}' lalu jalankan kembali script ini.") + return + + # Buat folder output jika belum ada + os.makedirs(OUTPUT_TXT_DIR, exist_ok=True) + + # Cari semua dokumen pendukung + supported_extensions = ["*.docx", "*.pdf", "*.txt"] + files_to_process = [] + for ext in supported_extensions: + # Cari case-insensitive atau kombinasikan lowercase/uppercase + files_to_process.extend(glob.glob(os.path.join(INPUT_DIR, ext))) + files_to_process.extend(glob.glob(os.path.join(INPUT_DIR, ext.upper()))) + + # Hapus duplikasi jika ada (karena pencarian case-sensitive pada OS tertentu) + files_to_process = list(set(files_to_process)) + + if not files_to_process: + print(f"Tidak ada file .docx, .pdf, atau .txt yang ditemukan di folder '{INPUT_DIR}'.") + return + + print(f"Menemukan {len(files_to_process)} file dokumen untuk diekstrak.") + + for file_path in files_to_process: + filename = os.path.basename(file_path) + base_name, ext = os.path.splitext(filename) + output_file_path = os.path.join(OUTPUT_TXT_DIR, f"{base_name}.txt") + + print(f"Mengekstrak: {filename} ... ", end="", flush=True) + + try: + ext_lower = ext.lower() + if ext_lower == ".docx": + text = extract_docx(file_path) + elif ext_lower == ".pdf": + text = extract_pdf(file_path) + elif ext_lower == ".txt": + text = extract_txt(file_path) + else: + print("Format tidak didukung (dilewati)") + continue + + # Simpan hasil teks ke file .txt di folder output + with open(output_file_path, "w", encoding="utf-8") as f: + f.write(text) + + print(f"Selesai! Disimpan ke: {output_file_path}") + + except Exception as e: + print(f"GAGAL! Error: {str(e)}") + + print("\nProses ekstraksi selesai seluruhnya!") + +if __name__ == "__main__": + main() diff --git a/AI Insight/extracted_txt/Manajemen Broiler CP 707 (1).txt b/AI Insight/extracted_txt/Manajemen Broiler CP 707 (1).txt new file mode 100644 index 0000000..4b1ee60 --- /dev/null +++ b/AI Insight/extracted_txt/Manajemen Broiler CP 707 (1).txt @@ -0,0 +1,1729 @@ +Manajemen Broiler CP 707 + +Technical Service Development & Customer Care Department. + +PT Charoen Pokphand Indonesia, Tbk. + +Jl. Ancol VIII No. 1, Ancol Barat - Jakarta Telp. (021) 6919999 + +The Most Comprehensive + +Poultry Equipment Provider and Distributor in Indonesia + +www.bredsonequipment.com + +Bredson Aplication + +Ventilation System + +Drinking System + +Egg Tray Transportation + +Feeding System + +CPI-PE Factory / Office : + +Jl. Modern CP Industri No 11, Babakan Bandung, Serang - Banten 42186 Indonesia + +Phone : 0254-2252777 + +Email : poultry.equipment@cpi-pe.co.id Website : www.cpi-pe.co.id + +Exclusive Brand Partner + +Daftar Isi + +Daftar Isi | 01 +Pendahuluan | 03 +Karakteristik CP Broiler | 04 +Pakan CP Broler | 05 +Manajemen Pemeliharaan Broiler pada Kandang Open House | 06 +Persiapan Kandang | 07 +Periode Brooding | 09 +Kepadatan Ayam | 13 +Manajemen Litter | 14 +Manajemen Air Minum dan Pakan | 16 +Culling / Afkir Ayam | 21 +Biosekuriti | 22 +Pelaksanaan Panen | 24 +Manajemen Pemeliharaan Broiler pada Kandang Closed House | 25 +Perbandingan Closed House Vs Open House | 26 +Persiapan Kandang | 27 +Perawatan Alat | 28 +Tempat Pakan | 29 +Pemanas (Heater) | 30 +Sictem Ventilasi | 31 +Sistem Pendingin Evaporasi (Cooling Pad) | 32 +Tirai dan Plafon | 33 +Flektrikal | 34 +Genset | 35 +Sekat Pembatas | 36 + +Pendahuluan + +Manual CP Broiler ini disusun untuk para pelanggan sehingga mengetahui manajemen dan praktek pemeliharaan ayam broiler agar diperoleh performance yang maksimal. + +Dalam pedoman praktis ini kami akan memfokuskan perhatian pada aspek manajemen dan menjelaskan apa yang dapat dicapai oleh CP Broiler pada kondisi temperatur dan kelembaban yang tinggi di daerah tropis seperti Indonesia. + +Kami yakin ini berguna bagi semua pihak yang secara langsung berhubungan dengan produksi broiler. + +CP Broiler telah dikembangkan agar daya tahan hidupnya lebih tinggi dan konversi pakan yang efisien, serta berat akhir yang maksimal. Para pelanggan CP akan puas terhadap peningkatan performance broiler yang sangat tahan terhadap tantangan di masa pemeliharaan, mulai dari minggu pertama hingga ayam dipanen dan dikirim ke pasar atau ke rumah potong. + +Manual ini menjadi panduan untuk dapat tercapainya produktivitas sesuai dengan angka-angka yang tertera dalam standard. Namun demikian angka standar tersebut tidak dapat dijadikan jaminan/garansi akan selalu tercapai, karena banyak faktor yang sangat berpengaruh dalam pencapaian produktivitas. + +Edisi Juli 2023 + +Karakteristik CP Broiler + +CP broiler merupakan hasil persilangan galur murni yang unggul dan rekayasa genetika, dengan cara FCR rendah, pola pertumbuhan cepat dan lebih selektif (daging dada lebih banyak). CP broiler dipelihara untuk bisa dipanen lebih awal dengan berat panen 1 - 1,5 kg maupun berat panen diatas 2 kg. Laju pertumbuhan broiler dapat diatur dengan program pencahayaan dan program pemberian pakan yang baik. Bila pertumbuhan 7 hari pertama mencapai di atas 200 gr/ekor, secara umum akan menghasilkan berat badan yang sangat baik pada akhir masa panen. + +CP broiler disediakan untuk para pelanggan sebagai "Satu Produk Untuk Semua pasar". Pertumbuhan yang cepat pada umur - umur awal dan pada umur yang lebih tua akan menghasilkan daging dada yang baik, konversi pakan yang rendah dan daya tahap hidup yang baik. + +Standar Performans Mingguan untuk C.P. Broiler + +Umur +(Minggu) | Berat Badan +(Gram/Ekor) | Pertambahan +Berat Badan +(Gram/Hari/Ekor) | Konsumsi Pakan | + | Rata-rata | Cum | FCR +1 | 195 | 34 | 164,5 | 0,844 +2 | 499 | 50 | 530,5 | 1,063 +3 | 954 | 80 | 1181,5 | 1,238 +4 | 1543 | 88 | 2198,5 | 1,425 +5 | 2191 | 94 | 3461 | 1,580 + +Tabel diatas merupakan suatu referensi atau pedoman yang menggambarkan performance strain ayam pada kondisi optimum, tanpa pembatasan pada tiap tingkatan umur. Standar diatas dapat dicapai tergantung dari manajemen pemeliharaan dan lingkungan. + +Pakan CP Broiler + +Charoen Pokphand Indonesia memproduksi pakan untuk ayam broiler dengan memberikan keseimbangan yang tepat antara protein, energi, mineral, vitamin dan asam amino esensial sehingga didapatkan pertumbuhan dan performance ayam broiler yang optimum. Kita mengetahui bahwa pakan merupakan pengeluaran paling besar dari total biaya produksi broiler. Oleh karena itu harus dapat dipastikan bahwa pakan yang diberikan harus berkualitas baik sesuai dengan spesifikasinya. + +Pakan Broiler Charoen Pokphand Indonesia + + | JAKARTA | SEMARANG | SURABAYA | MEDAN | LAMPUNG | KAWASAN TIMUR +INDONESIA +PRE STARTER | 510 +BR-10 | B10 | 510 +610 | 511F | 500 | 510 +STARTER | 511 +611 +CP-11 +OC-100 +511 Bravo +BR -11 | B11 +B11S +B11MTK +HG11B+ +HP11B +NP11B | 511 +611 +511B +HG11BCH +511D | BP11 +BP11M +V511 +B311 +V311 | 611 +511 +611B +511B +N511 | 511 +511 Bravo +BP11 Bravo +NP11 +BP11 +611 Bravo +611 Borneo +BR11 +SB11 +FINISHER | 512 +612 +CP-12 +PC-200 +512 Bravo +BR-12 | B12 +HP12BWD +HP12BGWD +B12MTKGWD +HP12B +HP12BG | 512 +512B +512BG +B12MTK +BP12WD | B512G +V512 +B512 +BP12 | 612 +512 +612B +512B +N512 | 512 +BP12 +512 Bravo +BP12 Bravo +612 Bravo +BR12 +SB12 + +Persiapan Kandang + +Aktivitas 7 hari pertama setelah ayam panen (pembersihan kandang): + +Pemberian racun tikus. Pengeluaran kotoran, pengeluaran peralatan kandang dan pencucian diluar kandang + +Penyemprotan insektisida dan soda api (terutama pada kandang bekas ayam yang terinfeksi kasus cocci dan gumboro), + +Cuci kandang dengan deterjen, disemua bagian kandang (Bagian dalam, luar, atas & bawah kandang /slat jika kandang panggung) + +Pembersihan lingkungan sekitar kandang (rumput dan kotoran sisa) + +Pasang tirai kandang , setelah itu dilanjutkan dengan penyemprotan disinfektan ke - 1 secara merata. + +Persiapan DOC masuk. + +Aktivitas 8 hari menjelang masuk DOC: + +Tabur sekam (2.5 4 kg/M2) di dalam kandang dan semprot dengan desinfektan- 2 + +Pemasangan peralatan induk buatan. + +Pemasukan tempat makan dan minum. + +Penyemprotan desinfektan ke 3 + +Penggunaan larutan desinfektan minimal 125 cc/ M2 (Jenis dan dosis desinfektan terlampir). + +Catatan: + +Khusus ayam yang terkena penyakit perlu dilakukan penyemprotan sebelum litter keluar kandang dengan menggunakan desinfektan dan dilanjutkan dengan penyemprotan insektisida. + +Contoh jadwal waktu persiapan kandang + +No. | Kegiatan | Jumlah | Waktu (Hari Ke-) + | Hari | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 + | Pembersihan dan Persiapan Kandang +1 | Panen | 3 hari | Х | +2 | Pembuangan Sekam | 3 hari | | X | +3 | Pencucian Peralatan | 3 hari | | X | Х | +4 | Cuci Kandang (Detergen) | 2 hari | | X | +5 | Flashing Jalur Minum | 2 hari | | X | +6 | Semprot Desinfektan | 1 hari | | X | +7 | Pemasukan Sekam | 2 hari | | X | +8 | Semprot Desinfektan | 1 hari | | X | +9 | Persiapan Brooder & Peralatan | 1 hari | | X +10 | Istirahat Kandang | 15 hari | + + +TODO: Rapihkan tablenya seperti di atas + +No. | Kegiatan | Jumlah Hari | Waktu (hari ke-) + | -5 | -4 | -3 | -2 | -1 | 0 | 1 | 2 +11 | Semprot Desinfektan | 1 hari | | X | +12 | Cek Peralatan Minum | 1 hari | | X | +13 | Cek Peralatan Brooding | 1 hari | | X | +14 | Pakan dan LPG Datang | 3 hari | | X | +15 | DOC in | | X + +Periode Brooding (1-2 Minggu) + +a. Tujuan Brooding + +Tujuan brooding adalah untuk menyediakan lingkungan yang nyaman dan sehat secara efisien dan ekonomis bagi anak ayam, untuk pertumbuhan optimal. + +Saat ayam berumur 2 minggu pertama, terjadi perbanyakan sel (hiperplasia). Perbanyakan sel tersebut meliputi perkembangan saluran pencernaan/gastrointestinal, perkembangan saluran pernapasan dan perkembangan sistem kekebalan. + +b. Manajemen Penerimaan DOC + +Harus ada komunikasi yang baik antara bagian delivery hatchery, marketing DOC dan customer untuk menentukan jadwal waktu pengiriman. Sebelum DOC datang, pakan dan air minum harus tersedia. Pemanas dinyalakan 6 jam sebelum DOC tiba agar suhu ruangan ideal untuk anak ayam. Pada kasus pengiriman jarak jauh, saat ayam tiba di farm, berikan larutan gula 1% pada air yang didistribusikan pertama kali untuk membantu memulihkan energi. + +Beberapa hal yang harus diperhatikan saat DOC datang: + +Cek kondisi mobil pengangkut DOC meliputi: segel, kondisi kipas, surat jalan dan cek sampel DOC 10 % dari jumlah DOC yang dikirim, disaksikan oleh supir pengirim, meliputi berat DOC, kelincahan serta kondisi umum lainnya (Mati di Box, Cacat dan lemah). + +Setelah DOC dicek, secepatnya disebar masing-masing brooder yang telah disiapkan. + +Pastikan kesiapan perlengkapan area brooder meliputi sumber pemanas, lampu penerang, serta tempat pakan, tempat minum yang telah terisi. Tebar DOC sesegera mungkin pada brooder. + +Kesehatan dan Kualitas Anak Ayam + +Perhatikan anak ayam secara intensif selama 6-8 jam pertama untuk memastikan bahwa anak ayam tersebut cukup nyaman berada di area brooder, termasuk check kondisi tembolok anak ayam (minimal 85 % DOC sudah makan dan minum). Area brooding sebaiknya diberi alas koran, dan diganti secara rutin. + +Penyebab utama kematian di minggu pertama adalah: + +Dehidrasi, pada saat pengiriman (transportasi tidak standart) dan terlalu lama DOC di sebar ke area brooding. + +Temperatur brooding terlalu tinggi atau rendah. + +Air minum terlambat dan tempat minum kurang, sehingga ayam mengalami kesulitan dalam mendapatkan air minum pada 24 jam pertama. + +c. Manajemen Brooding + +Terdapat dua sistem dasar yang diterapkan untuk brooder pada broiler, yakni Spot Brooding (induk buatan setempat/pemanasan setempat) dan Whole House Brooding (induk buatan menyeluruh/pemanasan menyeluruh). + +1. Spot Brooding (Induk buatan setempat/pemanasan setempat) + +Layout Brooder untuk 500 ekor + +Densitas 40 Ek/M2 Luas 12 – 14 M2 1 unit heater 20 unit BCF/FT 10 Unit PSMark 1 unit Lampu + +Diperlukan lingkaran/sekat pelindung anak ayam (chick guard) dengan tinggi 45-50 cm untuk melindungi anak ayam dari aliran udara dingin, serta agar anak ayam tetap dekat dengan pemanas, pakan, dan minum. Tempatkan 40 ekor anak ayam per m2 pada hari pertama dan lebarkan area brooder secara bertahap. Untuk 500 ekor anak ayam, Gunakan brooder dengan luas 12-15 m2 untuk 500 ekor anak ayam. + +2. Whole House Brooding (Induk buatan menyeluruh/pemanasan seluruh kandang) + +Kandang dapat dipanaskan dengan sistem pemanas langsung dan tidak langsung. Sistem pemanas tidak langsung biasanya menggunakan gas, dan menyemburkan udara panas ke dalam kandang pada satu titik. Penambahan kipas dalam kandang pada sistem ini dapat digunakan untuk meningkatkan kualitas udara dan perataan temperatur. + +d. Lama Brooding + +Lama brooding tergantung dari lokasi farm dan kondisi lingkungan. + +lklim | Lama Brooding (hari) +Musim panas | 8 - 12 +Musim Hujan | 10 - 14 + +Catatan: lihat kondisi ayam & lingkungan + +Suhu Pemeliharaan + +Umur (hari) | Suhu (°C) | Kelembaban +1 - 2 | 32 | 50% - 70% +3 - 4 | 31 | 50% - 70% +5 - 7 | 30 | 50% - 70% +8 -14 | 29 | 50% - 70% +15 - 21 | 28 | 50% - 70% +22 - 28 | 26 | 50% - 70% +29 - 35 | 23 | 50% - 70% +>36 | 22 | 50% - 70% + +Tingkah Laku Anak Ayam Sesuai dengan Temperatur + +Kepadatan Ayam + +Jumlah ayam per m2 dapat bervariasi tergantung dari umur panen, tipe kandang dan iklim yang terjadi pada saat itu. + +Pada kandang terbuka dengan ventilasi alami kepadatannya adalah 12 - 13 kg/m2. Pada kandang dengan aliran udara yang bisa diatur (kandang tertutup), kepadatan dapat mencapai 24 - 30 kg/m2 berat hidup tergantung pada fasilitas dan efisiensi kandang. + +Direkomendasikan juga untuk menurunkan kepadatan kandang pada saat temperatur tinggi. + +Kepadatan Ayam Berdasarkan Berat Panen (Kandang Terbuka) + +Berat | Kepadatan (ekor/m²) +0.80 - 0.99 | 11.0 - 11.1 +1.00 - 1.19 | 10.0 10.5 +1.20 - 1.39 | 9.0 - 9.5 +1.40 - 1.59 | 8.0 - 8.5 +1.60 - 1.89 | 7.5 - 8.0 +> 1.90 | 7.0 - 7.5 + +Manajemen Litter + +Perhatikan kondisi litter: + +Siapkan litter beberapa hari sebelum DOC datang, desinfeksi sesuai prosedur dan pastikan suhu litter 32°C saat DOC datang. + +Lakukan pembalikan litter sejak umur 7 hari secara rutin untuk menghindari penggumpalan. + +Sekam yang basah diambil dan diganti dengan sekam baru untuk menghindari amoniak yang tinggi dalam kandang yang dapat menyebabkan penyakit (CRD, Coli, Coccidiosis dan sebagainya). + +Untuk kandang postal dengan lantai tanah maka pembalikan sekam dilakukan lebih sering. + +Untuk kandang panggung turunkan litter secara berkala mulai sekitar umur 16 hari (atau berat badan 600 gr). + +Sistem Pemberian Pakan + +Kebutuhan tempat pakan: + +Jumlah kebutuhan tempat pakan untuk 500 ekor + +Umur | Kepadatan +(ekor/m²) | Chicken Feeder +Tray (CFT) | Alas Feeder +Tube +1 | 40 | 20 | - +4 | 30 | 20 | - +7 | 15 | 20 +10 | 7 | - | 20 + +Tujuannya untuk memaksimalkan intake dan BW selama masa brooding awal. + +Pemberian pakan lakukan sesegera mungkin setelah DOC datang, pola pemberian pakan yang baik akan membantu meningkatkan konsumsi pakan minggu pertama. Pemberian pakan sedikit demi sedikit tetapi sering mungkin sangat dianjurkan. + +Frekuensi Pemberian Pakan + +Umur (hari) | Frekuensi Pemberian +1 - 4 | 8 +5 - 10 | 6 +11 - 14 | 3 +> 15 | 2 + +Ketinggian Feeder untuk Broiler + +Bibir feeder pan (feeder tube) harus sedikit lebih rendah daripada tembolok ayam jika ayam berdiri tegak. + +Manajemen Air Minum dan Pakan + +Air minum harus selalu tersedia setiap saat untuk broiler dengan kualitas air minum yang baik dan bebas dari Salmonella, E.coli dan bakteri patogen lainnya. Kekurangan persediaan air minum, baik dalam jumlah, penyebaran serta jumlah tempat minum dan konsumsinya dapat mempengaruhi proses pertumbuhan. + +Pada saat ayam datang, berikan larutan gula 1% paling lama 2 - 3 jam pertama serta berikan antibiotik pada hari ke-1 hingga ke-3 di saat pagi hari (paling lama 5 - 6 jam) dan berikan vitamin pada saat sore hari. Air harus selalu bersih dan segar dan dilakukan tes secara teratur terhadap kandungan zat kimia dan komposisi bakteriologis (6 bulan sekali). Untuk menjaga air dalam kondisi normal, gunakan 3-5 ppm chlorine untuk mengurangi masalah Salmonella, E.Coli dan bakteri patogen lainnya. + +1. Ketinggian tempat air minum untuk broiler + +Tempat air minum harus selalu dicek ketinggiannya setiap hari. Pada umur 18 hari diatur ketinggianya bibir tempat air minum sejajar dengan punggung ayam. + +Kandang yang menggunakan nipple harus disesuaikan ketinggiannya secara sentral menggunakan kerekan (handwind) sehingga ayam dapat minum dengan mengangkat kepala 35° - 45° terhadap nipple. + +2. Level air minum + +Ketinggian air minum sebaiknya 0,6 cm di bawah tutup tempat minum sampai dengan 7-10 hari dan harus ada air di dasar tempat minum dengan ketinggian 0,6 cm sejak hari ke-10 dan selanjutnya. Pengeluaran air dari nipple minimal 80 ml per menit dengan tekanan 30 - 40 cm water column. + +3. Kualitas air minum + +Kualitas air sangat penting karena ayam minum 2 - 2.5 kali dari jumlah pakan yang dikonsumsinya. Lakukan analisa kualitas air minum dua kali setahun untuk memastikan bahwa air minum tersebut masih layak dikonsumsi ditinjau dari kandungan mineral, bahan organik dan bakteri. + +Pada temperatur normal, konsumsi air minum ayam adalah 2 - 2.5 kali dari konsumsi pakan. Faktor ini sebaiknya digunakan sebagai pedoman sehingga penyimpangan konsumsi air yang berkaitan dengan konsumsi pakan, temperatur atau kesehatan ayam dapat segera diketahui dan diperbaiki. + +Konsumsi Air/1000 ekor/hari (pada suhu 21°C) + +Umur (minggu) | Liter +1 | 58 - 65 +2 | 102 - 115 +3 | 149 - 167 +4 | 192 - 216 +5 | 232 - 261 +6 | 274 - 308 +7 | 309 - 347 +> 8 | 342 - 385 + +Di atas suhu 21°C, kebutuhan air minum meningkat rata-rata 6,5% setiap kenaikan 1°C. + +Kualitas Air Minum + +LEVEL MAKSIMAL MINERAL DAN BAKTERI PADA AIR MINUM YANG DAPAT DITOLELIR +Total bahan padat terlarut | 300-500 ppm +Khlorida | 200 mg/l +PH | 6 - 8 +Nitrat | 45 ppm +Sulfat | 200 pp +Besi | 1 mg/l +Kalsium | 75 mg/l +Tembaga | 0,05 mg/l +Magnesium | 30 mg/l +Mangan | 0,05 mg/l +Seng | 5 mg/l +Fluorida | 0,06 mg/l +Merkuri | 0,002 mg/l +Timah | 0,05 mg/l +Faecal Coliform | 0 + +Feeding space per bird for different feeder types + +Feeder Type +(Tipe Tempat Pakan) | Feeding Space (Jarak Antar Tempat Pakan) +Pan feeders | 30-50 birds per pan (the lower ratio for bigger birds [> 3.5 kg/7.7 lb]) +Flat chain/auger* | 2.5 cm/bird (1 in/bird) +Tube feeders | 30-50 birds/tube (for a 38 cm/15 inch diameter feeder) + +Tujuannya untuk mengurangi kompetisi antar ayam. + +Semua jenis pakan harus disesuaikan untuk meminimalisir tumpahan pakan serta akses yang optimal untuk ayam. Alas tempat pakan harus sejajar dengan bagian atas dada (Gambar 3.5). Ketinggian tempat pakan mungkin harus disesuaikan satu per satu. Ketinggian rantai penyalur pakan dapat disesuaikan dengan katrol atau setelan bagian bawah tempat pakan. + +Ketinggian tempat pakan yang salah (terlalu tinggi/terlalu rendah) akan meningkatkan tumpahan pakan. Selain kerugian ekonomi dan penurunan kinerja, ketika hal ini terjadi, perkiraan konversi pakan akan menjadi tidak akurat dan pakan yang tumpah, saat dimakan, kemungkinan akan membawa risiko kontaminasi bakteri yang lebih tinggi. + +Pakan harus didistribusikan secara merata dan seragam di seluruh sistem pemberian pakan untuk memberikan kesempatan yang sama bagi semua ayam untuk makan pada waktu yang sama. Distribusi pakan yang tidak merata dapat mengakibatkan penurunan kinerja, peningkatan gesekan antar ayam yang terkait dengan persaingan di tempat pakan, dan peningkatan tumpahan pakan. Untuk memastikan distribusi yang merata, semua pengaturan penyesuaian harus diatur sama pada setiap pan atau tabung pakan. Sistem penampungan pakan mungkin memerlukan penyesuaian pada masing-masing individu tempat pakan. Penyesuaian kedalaman tempat pakan lebih mudah dilakukan dengan chain feeder systems atau tempat pakan menggunakan penyalur seperti rantai karena hanya diperlukan penyesuaian tunggal pada hopper. Pemeliharaan menggunakan chain feeder systems yang hati-hati akan meminimalkan kejadian kerusakan kaki pada ayam. + +Ketika dikelola dengan benar (jika diisi secara otomatis) memiliki keuntungan bahwa semuanya terisi secara bersamaan, membuat pakan segera tersedia untuk ayam. Sistem otomatis harus diperiksa secara teratur untuk memastikan bahwa tempat penyimpanan pakan terisi dengan benar. + +Ketika tempat pakan dengan aliran rantai digunakan, distribusi pakan membutuhkan waktu lebih lama untuk diselesaikan dan pakan tidak segera tersedia untuk semua ayam. Pada tahap awal pembesaran, aliran pakan harus dipantau secara ketat dan dijalankan setiap kali tingkat pakan menjadi terlalu rendah (tempat pakan hanya boleh kosong jika sedang dibersihkan - lihat paragraf di bawah). Tempat pakan yang berantai perlu dijalankan lebih sering sepanjang hari seiring bertambahnya usia ayam dan makan lebih cepat untuk memastikan bahwa pakan tetap diisi (Gambar 3.6). Kunci manajemen chain feeder system yang baik adalah pemantauan rutin kedalaman pakan dan perilaku ayam. + +Culling/Afkir Ayam + +Pelaksanaan culling ayam sebaiknya dilakukan sejak penerimaan DOC dan dapat dilakukan saat dibawah umur 7 hari. Ayam yang culling meliputi: + +Ayam yang dehidrasi + +Ayam yang pantat atau pusarnya basah + +Ayam yang kerdil, lemah atau cacat. + +Ayam yang kecil dan kurang sehat dapat menjadi pemicu masuknya penyakit, sehingga harus diculling/afkir. + +Biosekuriti + +a. Pencegahan penyebaran penyakit melalui manusia + +Orang dan kendaraan yang masuk ke lokasi kandang harus dibatasi. Masuk ke lokasi kandang harus disemprot dengan disinfektan. + +Siapkan celup kaki dan celup tangan di tiap kandang (air dan disinfektan). Sangat disarankan untuk menyediakan 2 macam sepatu boot (untuk penggunaan di luar dan penggunaan di dalam kandang). + +Lakukan penyemprotan juga terhadap kendaraan yang keluar masuk kandang. Jangan lakukan kunjungan ke kandang ayam sehat setelah mengunjungi ayam yang sakit. + +b. Pencegahan penyebaran penyakit melalui ayam + +Segera keluarkan ayam yang mati dalam kandang setiap hari. Jika ada ayam yang menunjukkan gejala sakit segera culling / afkir. + +Untuk ayam yang mati karena sakit, celupkan ke dalam desinfektan, dihitung dan dimasukan ke dalam karung kemudian dimusnahkan (Dikubur/dibakar) + +c. Pencegahan penyebaran penyakit melalui peralatan + +Sistem all-in all-out akan membantu pencegahan penyebaran penyakit dari ayam tua ke ayam muda. Peternak harus menjaga kebersihan dan desinfeksi kandang serta peralatan untuk mengurangi mikroorganisme patogen hingga level minimum. Peralatan kandang sebaiknya jangan dipindahkan dari kandang satu ke kandang lainnya serta hindari transfer pakan antar kandang. + +d. Pencegahan penyebaran penyakit melalui vektor + +Vektor penyebab penyakit dapat berasal dari karyawan, tikus, burung liar, serangga, dan binatang liar lainnya. Pasang pagar keliling farm untuk mencegah binatang liar masuk ke lokasi farm. Menjalankan program pemberian racun tikus dan obat serangga secara berkala jika diperlukan. + +Pelaksanaan Panen + +Sebelum merencanakan panen, kebutuhan pakan harus disesuaikan, karena itu diperlukan perhitungan yang benar terhadap kebutuhan pakan yang diperlukan sampai ayam dipanen. Manager farm bertanggung jawab untuk mendapatkan performance yang optimal saat panen, melaksanakan periode pemuasaan yang tepat saat sebelum penangkapan ayam dan mengkoordinir serta mengawasi petugas penangkap ayam. + +Rata-rata dibutuhkan 8 jam antara saat dimulainya pemuasaan pada ayam sampai saat pemotongan ayam. Pemuasaan yang dilaksanakan di atas 8 jam, akan menyebabkan kehilangan berat ayam sekitar 0,2% per jam pada kondisi temperatur normal. + +Diperlukan waktu minimal 4 jam setelah pengosongan tempat pakan, sebelum dilakukan penangkapan ayam. Air minum sebaiknya tersedia dalam jumlah yang cukup sampai saat penangkapan ayam dimulai, panen sebaiknya dilakukan pada malam hari. + +Perbandingan Closed House Vs Open House + +Item | Closed house | Opened house +Kepadatan | √ 14 - 20 birds/m 2 + +√ 20-30 kg/m 2 | 6-8 birds/m 2 + +12-13 kg/m 2 +Cuaca | √ Tidak berpengaruh | Sangat berpengaruh +Stres dari Lingkungan | √ Tidak berpengaruh | Sangat berpengaruh +Effective temperature | √ Dapat diatur sesuai kebutuhan ayam | Tidak dapat diatur +Biosecurity | √ Mudah di kontrol | Susah dikontrol +Keseragaman | √ Baik | Kurang Baik +Pencahayaan | √ Merata | Tidak Merata +Biaya Investasi | √ Tinggi | Rendah +Cost/kg Live Birds | √ Rendah | Tinggi +FCR | √ Rendah | Tinggi +Mortalitas | √ Rendah | Tinggi +Performance | √ Stabil | Tidak Stabil + +NO | Jumlah hari | Hari Ke- | Keterangan + | Pembersihan kandang dan peralatan | Jml +hari | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | +1 | Pengeluaran pupuk kandang | 3 | x | +2 | Pencucian kandang dengan menggunakan deterjen dan dibilas dengan air sampai tidak ada sisa-sisa bahan organik. | 2 | | x | | Deterjen 1 kg : 100 L air +3 | Bersihkan rumput dan semak sekitar kandang agar tidak menjadi sarang penyakit. | 2 | | x | +4 | Pencucian peralatan kandang dengan desinfektan. | 2 | | x | | Benzalkonium Chloride (BKC) 1 : 500 L air. +5 | Penyiraman lantai dan dinding dengan soda api. | 1 | | x | | Soda Api 5 kg : 200 L air +6 | Penyiraman kapur pada lantai dan dinding (optional). | 1 | | x | | 1 kg untuk 15m². +7 | Penaburan sekam. | 2 | | x | | 40-50 bag/1000 ekor. +8 | Pemasangan semua peralatan yang sudah bersih ke dalam kandang dan setting area Brooding. | 2 | | x | +9 | Cek semua peralatan, apakah bekerja secara normal atau tidak. | 1 | | x | +10 | Kuras bak Cooling Pad, flushing Torn dan Jalur Nipple dengan Acidifier atau Sitrun, diamkan selama 12 jam setelah itu bilas dengan air bersih. | 2 | | x | | Acidifier 20 ml : 1 L atau Sitrun 3 g : 1 liter air. +11 | Nyalakan Cooling Pad dengan air campuran desinfektan selama ±30 menit (sampai semua bagian Cell Deck basah). | 1 | | x | +12 | Penutupan tirai dengan rapat. | 4 | | x | +13 | Penyemprotan sekam dengan Formalin 40%. | 1 | | x | | 5 liter : 100 liter air +14 | Persiapan sprayer dan celup kaki di pintu masuk kandang. | 1 | | x | 2 ml : 1 liter air +15 | Penyemprotan desinfektan sehari sebelum Chick In. | 1 | | x | +16 | Final cek peralatan untuk memastikan semua dapat bekerja dengan baik. | 1 | | x | + +Tempat Pakan + +Tempat Pakan Manual + +Bersihkan feeder tray dari kotoran 2 kali sehari. + +Bersihkan baby chick dari sekam guna mencegah ayam makan sekam dan lakukan pemutaran baby chick agar pakan kembali terisi. + +Langsung bersihkan tempat pakan yang sudah tidak dipakai dan simpan di gudang agar lebih awet dan tidak menjadi sumber pembusukan pakan. + +Tempat Pakan Otomatis + +Pastikan tidak ada sisa pakan pada saat ayam selesai dipanen. Kuras dengan menyalakannya secara manual dan tampung sisa pakan di pengeluaran belakang dengan karung. + +Menguras tempat pakan otomatis bisa menggunakan sekam, jagung ataupun pakan. + +Tutup lubang pada penampungan cone pada saat istirahat kandang untuk menghindari tikus atau binatang lain masuk. + +Cek pan feeder dengan membuka/mencabut satu per satu sebelum masuk kandang bila perlu untuk menghindari ada penggumpalan sisa pakan yang sudah berjamur pada lubang pengeluaran. + +Pipa atau line pakan harus rata agar auger tidak menggerus pipa line. + +Saat menuangkan pakan jangan sampai benang atau kertas label pakan terbawa ke dalam auger. + +Pemanas (Heater) + +Jangan memasang gas dengan keadaan terguling karena akan memicu kebakaran. + +Bersihkan dan rapikan heater setelah masa brooding berakhir. + +Tutup lubang yang ada pada heater untuk menghindari serangga atau tikus masuk ke dalam heater. + +Simpan di ruangan yang aman dari kebocoran dan sinar matahari. + +Sistem Ventilasi + +Bersihkan dan bungkus temptron dan sensor dengan plastik agar tidak terkena air saat pencucian kandang. + +Bungkus motor fan dengan plastik dan bersihkan bagian fan saat pencucian kandang. + +Cek kekencangan fan belt sebelum ayam masuk. + +Lakukan pemberian gemuk atau stemfet pada bearing setiap periode. + +Bersihkan nako dari debu setiap 3 hari sekali. + +Cek putaran dan kondisi baling-baling fan. + +Cek temptron dan lakukan kalibrasi dengan menggunakan alat ukur seperti kestrel untuk menghindari adanya penyimpangan ataupun kerusakan. + +Sistem Pendingin Evaporasi (Cooling Pad) + +Lindungi Cell Deck dengan kawat ram untuk menghidari masuknya binatang. + +Bersihkan Cell Deck dengan air bertekanan. + +Cek kondisi pompa. + +Kuras bak penampungan air setiap periode. + +Cek kebocoran pipa. + +Lakukan pengecekkan dengan menyalakan cooling pad pada 1-2 hari sebelum ayam masuk untuk memastikan cooling pad berjalan normal dan sekaligus untuk desinfeksi ke dalam kandang. + +Perhatikan kerataan air pada Cell Deck dan periksa pipa pengeluaran air. + +Tirai dan Plafon + +Tirai Luar + +Cek kondisi tirai luar apakah berfungsi dengan baik pada saat persiapan kandang. + +Cek dan bersihkan otomatis tirai (curt-o-matic) dan penarik tirai (hand winch). + +Beri pelumas pada hand winch. + +Cek dan perbaiki tirai yang bolong setiap periode sebelum ayam masuk. Kebocoran kandang dapat menurunkan daya hisap kipas sehingga kipas tidak akan berfungsi optimal. + +Blocking/Tirai Dalam + +Lakukan pencucian dengan menggunakan desinfektan saat blocking sudah tidak digunakan. + +Lipat dan beri tanda untuk membedakan blocking depan atau blocking belakang. + +Plafon + +Cek dan perbaiki kebocoran pada plafon. + +Elektrikal + +Bersihkan komponen dalam panel dengan kuas atau angin bertekanan (kompresor) setiap periode. + +Cek saluran kabel dari longgar dan terlepasnya isolasi. + +Kencangkan baut-baut terminal pada MCB. + +Cek tegangan secara berkala pada indikator panel. + +Genset + +Jaga kebersihan genset, bersihkan debu, kotoran, minyak, dan air yang menempel pada genset terutama di bagian radiator secara teratur. + +Nyalakan genset setiap 3 hari sekali selama 5-10 menit. + +Cek isi solar dalam tangki tambahkan apabila kurang. + +Cek volume dan kondisi oli, tambahkan bila kurang. Ganti oli mesin setiap 6 bulan sekali atau bila kondisi sudah tidak normal. + +Cek kondisi aki (accu) dan air accu. + +Cek fungsi alarm. + +Cek kondisi fan belt. + +Bersihkan filter udara setiap periode pemeliharaan. + +Lakukan penggantian komponen berkala sesuai umur pakai spare part tersebut. + +Sekat Pembatas + +· Cuci dan bersihkan dengan air bertekanan tinggi saat pencucian kandang. + +Rapikan dan beri tanda sesuai urutannya (jika ukuran berbeda). + +Controller System + +Fungsi controller system yaitu mengatur jalannya fan, heater, dan cooling pad sesuai dengan suhu yang kita inginkan. + +Jenis | Kekurangan | Kelebihan | Cara Kerja +Temptron 304 | Tidak dapat +mengatur +Intermitten, +perlu Timer + +tambahan. | Investasi lebih murah. | √ Terdapat 4 pilihan kontrol. 3 untuk fan dan 1 untuk heater/ cooling pad. Jika suhu aktual sensor di atas suhu setting maka fan atau cooling pad akan beroperasi (T-C). Jika suhu aktual di bawah suhu setting maka heater yang akan beroperasi (T-H). +Temptron 304D | Intermitten tidak bisa satuan detik. Lebih mahal dari 304. | Dapat mengatur +Intermitten dari Temptron. | √ Terdapat 4 pilihan kontrol. 3 untuk fan dan 1 untuk heater/ cooling pad. Jika suhu aktual sensor di atas suhu setting maka fan atau cooling pad akan beroperasi (T-C). Jika suhu aktual di bawah suhu setting maka heater yang akan beroperasi (T-H). +Temptron 607 A-C | Investasi +mahal dari 304 dan 304D. | Dapat Mengatur Intermitten fan dan cooling pad dari Temptron. Baik satuan detik maupun menit. | √ Terdapat 35 pilihan kontrol, namun yang biasanya terpakai hanya 16 Kontrol. Set 1 (Waktu), Set 2 (Suhu Target), Set 3 (Heater), Set 4 (Intermitten Fan 1), Set 5 (Fan 2), Set 6 (Fan 3), Set 7 (Fan 4), Set 8 (Fan 5), Set 9 (Waktu ON Intermitten Fan), Set 10 (Waktu OFF Intermitten Fan), Set 11 (Kelembapan), Set 12 (Coolpad), Set 13 (Cool On Time), Set 14 (Cool Off Time), Set 15 (Alarm Low), Set 16(Alarm High). + | Jika Set 2 dibuat 30°C dan Set 4 dibuat 1°C artinya 1°C di atas 30°C (± 0.3°C) maka fan akan menyala terus. Sedangkan di bawah itu kipas berjalan intermitten (mati hidup). +Jika Set 2 dibuat 30°C dan set 3 dibuat 1°C artinya 1°C dibawah 30°C (± 0.3°C) maka heater akan menyala. + +Persiapan Brooding + +Kebutuhan Sapronak 20.000 Ekor + +Item | Kapasitas | Kebutuhan +Sekam | 5-10 cm atau 40-50 bag/1000 ekor | 800-1000 (bag 50 kg) +Sekatan | | 4 buah/lantai +Pemanas +* Heater +* Gasolek +Gas +Lampu | @ 10.000 ekor +@ 750 ekor +18-25 gr/ekor +18 watt/25 lux (essential white) | 2 buah +26 buah +6-9 tabung +3 line per 3 meter +Density | 40 ekor/m² (0-3 hari) | 500 m 2 +Panjang | | 42 m +Lebar | | 12 m +Supply Air Minum +Regulator +*Nipple +*TMO | 117 m/line, 4 line, 12 ekor/titik +80 ekor/buah | 10 cm (cek tekanan di nipple) +1872 buah +250 buah +Supply Pakan *Plastik/kertas koran/karung +*Pan feeder + +*Baby chick +*Feeder tray +*Feeder tube | Isi pakan 10-50 gr/ekor + +117 m/line, 3 line,40-50 ekor/buah +25-30 ekor/buah +25-30 ekor/buah +25-30 ekor/buah | 25-50% area + +468 buah + +650 buah +650 buah +650 buah + +Luas Area Brooding + +Misal : | Populasi 20.000 ekor +Kepadatan 0-3 hari yaitu 40 ekor/m² += 20.000/40 += 500 m² +Lebar kandang 12 m + +Maka panjang yang harus disiapkan yaitu 500/12= 41.6 m = 42 m + +Persiapan sebelum DOC Datang + +Nyalakan heater/gasolek minimal 2-3 jam sebelum kedatangan (matikan fan intermitten sampai DOC datang agar panas terjaga). + +Lakukan kalibrasi sensor dengan termometer (kestrel) lihat pada temptron pastikan sesuai (maksimum toleran 0,3 -0,5°C) + +Campur larutan air gula 2% atau sorbitol dalam torn atau dosatron hitung kebutuhan airnya dan alirkan pada nipple dengan membuka keran regulator sampai nipple terisi larutan air gula. + +Pastikan suhu litter sudah hangat (32°C) saat DOC diturunkan. Ukur dengan thermogun. + +Pastikan semua tempat pakan terisi penuh dan posisi tempat pakan mudah dijangkau. + +Pan feeder mulai digunakan dari umur sehari. Isi pan feeder penuh dengan level rendah agar ayam dapat menjangkau dengan mudah. + +Berikan tempat pakan tambahan berupa karung/plastik/koran 25-50% area dan tabur 10-50 gr/ekor pakan. + +Pastikan mangkuk nipple dalam keadaan bersih dan kucurkan air dari nipple agar DOC dapat langsung minum. + +Berikan suhu yang ideal untuk DOC (31-33°C). + +Cek ketinggian dan tekanan pada nipple. + +Timbang dan hitung DOC pada saat datang minimal 5% dari total populasi. + +Cek kualitas DOC. + +Setelah DOC Masuk + +Jangan langsung meninggalkan DOC begitu saja. + +Cek penyebaran ayam. + +Ajari DOC dengan menekan pentil nipple agar DOC tertarik untuk minum. + +Pastikan DOC dapat makan dan minum dengan baik. + +Culling DOC lemah dan abnormal. + +Cek minimal 2 jam sekali selama 24 jam pertama secara bergantian, untuk menggeretak ayam beraktivitas. + +Bersihkan tempat pakan dari sekam. + +Lakukan pengecekan tembolok. + +Selalu perhatikan mangkuk nipple. Bila kering artinya tidak ada air yang keluar dari pentil nipple atau tekanan terlalu rendah bahkan terlalu kencang sehingga ayam tidak mau minum. + +Pastikan pengaturan suhu sesuai kenyamanan ayam sebelum keluar kandang. + +Pengecekan Tembolok + +Sampling 100 ekor saat 24 jam setelah DOC IN Dikatakan baik jika lebih dari 95% tembolok penuh terisi pakan dan air. + +Time of Crop Fill | Target Crop Fill + +(% of Chicks with Full Crops) +2 hours | 75 +4 hours | 80 +8 hours | >80 +12 hours | >85 +24 hours | >95 +48 hours | 100 + +PENUH PAKAN & MINUM | PENUH & KERAS | PENUH & LUNAK | KOSONG +95% | PROBLEM WATER INTAKE | PROBLEM FEED +INTAKE | PROBLEM WATER INTAKE / FEED INTAKE + +Catatan: Untuk informasi lebih lanjut silahkan menghubungi Technical Service CPI setempat + +Pelebaran Area Brooding + +ND | Umur | Density | Panjang | Lebar +i | 0-3 | 40 | 42 | 12 +ii | 4-7 | 30 | 56 | 12 +iii | 8-10 | 25 | 67 | 12 +iv | 11-12 | 20 | 83 | 12 +v | 13-15 | 17 | 98 | 12 +vi | >16 | 14 | 120 | 12 + +CATATAN: + +Pemasangan blocking 4-6 meter dari area brooding baik depan maupun belakang. + +Pada bagian bawah blocking tidak boleh ada kebocoran, untuk menghindari ayam terkena angin langsung dapat diganjal dengan karung berisi sekam atau lainnya. + +Pelebaran area brooding disesuaikan dengan pertumbuhan ayam. + +Pelebaran dilakukan dengan mempertimbangkan kondisi oksigen, kualitas sekam, temperatur lingkungan dan pertumbuhan ayam. + +Manajemen Brooding (Umur 1-14 Hari) + +Prioritas Kebutuhan Anak Ayam Masa Brooding + +Oksigen + +Suhu + +Cahaya + +Air + +Pakan + +Manajemen Ventilasi + +Minimum Ventilasi + +Adalah sistem ventilasi dimana anak ayam (DOC) tidak boleh mendapatkan angin langsung (still air). Minimum Ventilasi digunakan saat suhu luar lebih rendah dari suhu target effective temperature. Pada malam hari pada saat masa brooding fan on secara intermitten untuk menghindari over ventilasi. + +Hal yang perlu diperhatikan saat minimum ventilasi. + +Pergantian udara + +Kecepatan angin + +Kebutuhan oksigen. + +Kelembapan di dalam kandang. + +Pengaturan inlet dan tirai dalam (blocking). + +Untuk memenuhi kebutuhan minimum dapat dihitung dengan ketetapan 4 CFM setiap 1 Kg ayam. + +Pertukaran udara pada saat brooding yang baik 5-10 menit (tergantung daerah kandang) + +Contoh: Populasi 20.000 ekor + +Bw 0,040 Kg, Umur ayam 1 hari. + +Kapasitas Kipas 50" 22500 CFM + +Ventilasi Udara Minimum = 20.000 x 0.04 x 4 cfm / 22.500 = 0.14 = 14% + +Artinya hanya butuh kurang dari satu kipas atau 14% kipas. + +Jika pertukaran udara 7 Menit, artinya kipas on 59 detik off 361 detik atau intermitten kipas dibulatkan menjadi 1 menit on: 6 menit off. + + | Air Quality | +Fan off time: | 0 Minutes | 5 Minutes | 10 Minutes | 15 Minutes +Ammonia | 15 ppm | 35 ppm | 50 ppm | 80 ppm +Carbon Dioxide | 100 ppm | 1500 ppm | 2600 ppm | 3500 ppm +Humidity | 68 % | 78 % | 86 % | 97 % +Temperatur | 68 °F | 75 °F | 82 °F | 88 °F + +Efek kualitas udara saat kipas minimum ventilasi tidak berjalan. (Sumber Cobb, 2015). + +Blocking (Tirai Dalam) + + +Blocking dalam 3/4 bagian | Blocking dalam dengan double cover + +Perlu Diperhatikan: + +Pada saat heater menyala naikkan blocking dalam mulai dari depan ke belakang. + +0-7 hari blocking depan max buka 50% dan 8-14 hari max buka 30% dari bawah (Still Air). + +Blocking dengan double cover ini dapat mengurangi fluktuasi suhu yang ekstrim terutama di area depan brooding serta menjadikan panas di dalam area brooding akan terjaga. Double cover akan terbuka saat kipas hidup dan menutup saat kipas mati. + +Still Air + +Manajemen Pencahayaan + +Intensitas cahaya minimum 25 lux. + +Intensitas cahaya yang lebih tinggi (40 60 lux) akan membuat rangsangan nafsu makan dan minum yang lebih baik. + +Cahaya harus merata di area brooding. + +Mulai umur 15 hari intensitas cahaya dapat dikurangi menjadi 5 lux. + +Program pencahayaan dimulai pada umur 2 hari. + +Sebelum lampu mati pastikan ayam sudah makan dan minum dengan cukup, pastikan tempat pakan terisi agar ayam dapat langsung makan pada saat lampu menyala. + +Program Pencahayaan + +Manfaat: + +Memproduksi hormon melatonin untuk kekebalan tubuh ayam + +Memperbaiki FCR + +Memberikan waktu istirahat untuk ayam + +Mencegah penyakit SMS (Spiking Mortality Syndrome) + +Umur | Lama Nyala | Waktu Gelap +2-7 hari | 23 jam | 20.00-21.00 WIB +8-14 hari | 22 jam | 20.00-22.00 WIB +15-20 | 20 jam | 20.00-23.00 WIB +21 - 28 | 20 jam | 20.00-24.00 WIB +29 - panen | 23 jam | 20.00-22.00 WIB + +Catatan: Jika Bobot Badan Minggu Kedua dan Konsumsi Pakan Rendah Cukup 22 Jam Saja. + +Manajemen Air Minum + +Air minum diberikan secara ad libitum/tersedia setiap saat. + +Suhu air minum ideal 10-14°C. + +Tekanan yang cukup dari nipple akan meningkatkan konsumsi minum dan konsumsi makan ayam. + +Selalu lakukan pengecekkan tekanan dan ketinggian air minum. + +Segera perbaiki kebocoran air minum yang akan membuat litter basah. + +Pastikan konsumsi minum ayam minimal 1 ml/ekor/jam pada 24 jam pertama. + + +Week 1 | 40 ml (1.35 oz)/min +Week 2 | 50 ml (1.69 oz)/min +Week 3 | 60 ml (2.03 oz)/min +Week 4 | 70 ml (2.37 oz)/min +Week 5 | 90 ml (3.04 oz)/min + +Tekanan Air (Sumber Cobb, 2015). + +Level air minum (Sumber Cobb, 2015). + +Dosatron + +Menghitung Kebutuhan Air Minum + +Misal: + +1 hari konsumsi ayam 14 gr/ekor/hari. + +Populasi 20.000 ekor. + +Konsumsi 14 gr/ekor/hari. + +Untuk 20.000 ekor = 280 Kg/hari (24 jam). + +Kebutuhan minum ayam 2 - 2,5x pakan, + +artinya 560-700 L/hari (24 jam) + +Untuk pemberian obat selama 6 jam, air yang dibutuhkan (700L/24 jam) x 6 jam = 175 liter air + +Kebutuhan Pengencer 6 jam obat = + +1% : | 1/100 x 175L = 1,75 Liter +2% : | 2/100 x 175L = 3,5 Liter + +HASIL PERHITUNGAN MERUPAKAN PENGENCER SETELAH DITAMBAH OBAT + +Dosis obat tetap dihitung berdasarkan kebutuhan ayam. + +Disarankan untuk ayam umur 0-14 hari menggunakan 2% dan >14 hari menggunakan 1%. + +Manajemen Pakan + +Pakan diberikan secara ad libitum/full feed + +Kebutuhan Tempat Pakan untuk Populasi 20.000 Ekor + +Umur Ayam +Peralatan | DOC | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | … | 35 +Baby Chick Feeder | 500 unit | 400 unit | 250 unit | 150 unit | BCF tidak digunakan +Feeder Tray | 400 unit | 200 unit | 100 unit | Feeder Tray tidak digunakan +Feeder Tube | Feeder Tube belum digunakan | 400 unit | 500 unit | 600 unit | 800 unit + + +Feeder Tray | Baby Chick | Feeder Tube + +Manajemen Grower Umur 15 Hari - Panen + +Hal yang perlu diperhatikan pada periode grower: + +Kelembapan di kandang semakin tinggi. + +Konsentrasi amoniak semakin tinggi. + +Panas tubuh semakin meningkat. + +Kualitas sekam mulai menurun. + +Bulu mulai menutup umur 22 hari dan sempurna di umur 29 hari. + +Faktor Heat Stress. + +Penggantian kondisi kandang dari hangat ke dingin dengan menjaga ayam tetap merasa nyaman. + +Imunitas menurun. + +Artinya perlu penanganan dan kondisi lingkungan yang sempurna "perfect climate" untuk ayam bisa tumbuh seoptimal mungkin. + +"Ventilasi yang Tepat Akan Membuat Ayam Nyaman dan Tumbuh dengan Optimal" + +Manajemen Ventilasi 15 Hari - Panen + +Pada periode ini pengaturan suhu harus memperhatikan Target Efektif Temperatur (TET) atau suhu yang dirasakan ayam. + +Target Efektif Temperatur Dipengaruhi Oleh 3 Hal: + +Kelembapan + +Suhu + +Kecepatan Angin + +Perlu Dipahami: + +Semakin tua ayam semakin rendah target efektif temperaturnya. + +Semakin tinggi kelembapan semakin tinggi suhu yang dirasakan ayam. + +Hal yang tidak dapat dihindari adalah semakin tua ayam kelembapan dalam kandang semakin tinggi. + +Ayam memerlukan efek wind chill untuk melepas dan menurunkan suhu tubuhnya. + +Target Efectif - Tempertur (TET) +Umur | TET +1 - 2 hr. | 32°C +3 - 4 hr. | 31°C +5 - 7 hr. | 30°C +8 - 14 hr. | 29°C +15 - 21 hr. | 27°C +22 - 28 hr. | 25°C +29 - 35 hr. | 22°C +36 - Lay. | 21°C + +Tunnel Ventilation + +"Sistem ventilasi kandang seperti halnya terowongan dimana udara masuk berasal dari kandang bagian depan (inlet) dan keluar melalui kandang bagian belakang (outlet) dengan bantuan exhaust fan (negative pressure)". + +Ayam merasa lebih dingin jika lebih banyak panas tubuh yang dikeluarkan oleh adanya kecepatan angin yang lebih tinggi, hal ini disebut "wind chill effect". + +Ayam akan merasa lebih dingin dari temperatur Actual (dry bulb temperature) dan disebut "Target Effective Temperature". + +Tunnel Ventilation akan meningkatkan wind chill effect + +Aplikasi Target Efektif Temperatur + +Actual Suhu | Air Velocity Feet Per Minute at Rh 70% | Target Efectif - Tempertur (TET) +Actual Suhu | 0 | 50 | 100 | 150 | 200 | 250 | 300 | 350 | 400 | 450 | 500 | Target Efectif - Tempertur (TET) +(oC) | | Umur | TET +35.0 | 38 | 37 | 36 | 33 | 31 | 30 | 29 | 27 | 26 | 25 | 24 | 1 - 2 hr. | 32°C +34.5 | 38 | 36 | 35 | 33 | 30 | 29 | 27 | 26 | 25 | 24 | 3 - 4 hr. | 31°C +34.1 | 37 | 36 | 35 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 5 - 7 hr. | 30°C +33.6 | 37 | 36 | 34 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 8 - 14 hr. | 29°C +33.1 | 36 | 35 | 34 | 32 | 29 | 28 | 27 | 26 | 25 | 24 | 15 - 21 hr. | 28°C +32.7 | 36 | 35 | 33 | 31 | 29 | 28 | 27 | 26 | 25 | 23 | 22 - 28 hr. | 26°C +32.2 | 36 | 34 | 33 | 31 | 29 | 28 | 27 | 26 | 24 | 23 | 29 - 35 hr. | 23°C +31.7 | 35 | 34 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 36 - Lay. | 22°C +31.3 | 34 | 33 | 32 | 30 | 28 | 27 | 26 | 25 | 24 | 23 | +30.8 | 34 | 32 | 31 | 30 | 28 | 27 | 26 | 25 | 24 | 23 | +30.3 | 33 | 32 | 31 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | +29.9 | 32 | 31 | 30 | 29 | 27 | 26 | 25 | 24 | 23 | +29.4 | 32 | 31 | 30 | 29 | 27 | 26 | 25 | 24 | 23 | +28.9 | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | +28.5 | 31 | 30 | 29 | 27 | 26 | 25 | 24 | 23 | 22 | +28.0 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | +27.5 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | +27.1 | 29 | 28 | 27 | 26 | 25 | 24 | 22 | 21 | 20 | +26.6 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +26.2 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 20 | 19 | +25.7 | 27 | 26 | 25 | 24 | 23 | 22 | 20 | 19 | +25.3 | 27 | 26 | 25 | 24 | 23 | 22 | 20 | 19 | +24.8 | 26 | 25 | 24 | 23 | 21 | 20 | 19 | +24.4 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +23.9 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +23.4 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +23.0 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +22.5 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +22.0 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +21.6 | 24 | 22 | 21 | 20 | 19 | 18 | 17 | +21.1 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | + +Cara Mengaplikasikan TET: + +Pada kelembapan 70% untuk mencapai target efektif temperatur 26°C, dengan suhu aktual 28,9°C maka kecepatan angin yang harus diciptakan yaitu 250 FPM. + +Jika kecepatan angin 3 fan sebesar 250 FPM Artinya fan ke-3 harus sudah kita nyalakan di suhu 28,5-29,4°C. + +Exchange Rate (Pertukaran Udara) + +Perhitungan Exchange Rate sangat terkait dengan perpindahan gas buang dalam kandang dan kecepatan masuknya suplai udara segar ke dalam kandang. + +Closed house dengan ukuran: + +Lebar 12 meter + +Tinggi 2 meter + +Panjang 120 meter + +Kecepatan angin rata-rata hasil observasi 8 fan = 3 m/s + +Exchange rate-nya adalah: + +Suplai udara = 2 meter x 12 meter x 3 m/s = 72 m3/s + +Jika diubah menjadi per-jam, maka perhitungannya adalah : + +72 m3/s x 3600 s = 259.200 m3/jam + +Volume kandang = 2 meter x 12 meter x 120 meter = 2880 m3 + +Exchange rate = 2880 m3 / 259200 m3/jam = 0,011 jam= 39,6 detik, + +Exchange rate kurang dari 1 Menit, dikategorikan BAIK. + +Jadi jika berhadapan dengan semua gas buang dalam kandang closed dan atau suplai oksigen, maka kita harus memperhatikan exchange rate. + +Manajemen Inlet + +Jika volume udara yang melalui in-let tidak sama dengan out-let, maka akan menimbulkan masalah : + +1). In-let < Out-let: Kecepatan angin melalui in-let dan pad akan lebih cepat dan static pressure akan tinggi, akibatnya kerja kipas semakin berat dan efisiensi saturasi pad akan menurun. Jika efisiensi saturasi menurun maka wind chill effect yang dicapai semakin kecil. + +2). In-let > Out-let: Kecepatan angin melalui in-let dan pad akan rendah dan static pressure akan rendah, akibatnya kerja kipas semakin ringan dan efisiensi saturasi pad akan meningkat. Jika efisiensi saturasi meningkat, maka penurunan suhu dengan menggunakan cooling pad akan mudah di capai. + +Tetapi akan terjadi efek botol, dimana angin akan mengalami turbulen di daerah belakang (daerah sebelum kipas). + +Hal ini menimbulkan banyak kerugian : + +1). Daerah belakang akan lebih lembap, + +2). Gas beracun daerah belakang lebih besar, + +3). Suhu daerah belakang semakin tinggi. + +Bukaan Inlet harus disesuaikan dengan jumlah kipas yang menyala. + + +Selalu ukur kecepatan angin di depan inlet! | Jika tinggi inlet 160 cm dan jumlah kipas 8 buah, Maka setiap 1 kipas nyala inlet dibuka 20 cm + +Bukaan Inlet Lebar dengan Kecepatan Angin Rendah + +Bukaan Inlet Lebar dengan Kecepatan Angin Rendah + +Kecepatan angin yang rendah (<500 fpm) Akan menciptakan Dead Spot pada area Depan dan Udara di Tengah Akan Lebih Tinggi. + +Bukaan Inlet Kecil dengan Kecepatan Angin Tinggi + +Bukaan Inlet Kecil dengan Kecepatan Angin Tinggi + +Problem dan Penanganan + +Problem | Penyebab | Penanganan +Perbedaan temperatur antara area depan dan belakang kandang | Kipas tidak cukup. +Kesalahan setting. +Kipas bermasalah. +Pengaturan inlet. +Cooling pad bermasalah. +Pengaturan blocking. | Cek kipas. +Cek setting-an temptron. +Sesuaikan ventilasi +dengan kebutuhan ayam. +Cek fan belt dan motor +kipas. +Cell Deck tidak rata. +Inlet terlalu kecil, kecepatan +angin tinggi. +Temperatur terlalu tinggi | Kebocoran tirai. +Adanya penghalang aliran udara. +Air cooling pad tidak keluar atau tidak rata pada Cell Deck. +Supply air kurang. +Motor pompa rusak. +Cell Deck kotor. | Cek dan tambal tirai yang bocor. +Cek cooling pad dan motor pompa. +Bersihkan Cell Deck. +Cek pipa cooling pad. +Kelembapan terlalu tinggi | Air minum tumpah. +Nipple bocor. +Wet droping. +Sekam basah. +Kesalahan pengaturan cooling pad. +Waktu nyala cooling pad terlalu lama. | Cek instalasi air minum. +Cek tekanan air. +Pemeriksaan kesehatan ayam. +Keluarkan sekam yang basah. +Setting cooling pad sesuai kebutuhan. +Ayam minum tidak merata, pada ayam umur kecil tidak mampu untuk mematuk pentil. | • Pentil ketika ditekan keras/kesat. + +• Terlalu lama terendam air kaporit. | • Buka dan cuci pentil dengan menggunakan sabun dan bilas dengan air kemudian pasang kembali. + +• Ganti pentil dengan yang baru. +Ayam tidak mau minum, dehidrasi, kematian dan afkir tinggi. | • Klorinasi tinggi/kaporit terlalu pekat. | • Cek klorin dengan test kit klorin kadar klorin ideal 1-3 ppm di ujung nipple. + +• Jika terlalu pekat buang air sebagian dan tambahkan air putih. + +• Selalu cek kandungan klorin minimal 3 hari sekali. +Feed intake rendah. | • Intensitas cahaya rendah. | • Paling utama yaitu cek kondisi lingkungan ayam. + +• Bila lingkungan baik, bisa karena intensitas cahaya yang kurang. + +• Minimal intensitas cahaya pada masa brooding yaitu 25 Lux. + +• Ganti dengan lampu yang berintensitas lebih tinggi. + +LAMPIRAN 1 + +CONTOH CATATAN HARIAN + +LAPORAN PEMELIHARAAN AYAM BROILER + +Nama Peternak : ……… +Tgl Masuk DOC : ……… +Kondisi DOC : ……… | Jenis DOC : ……… +Kode Box : ……… | Jam datang DOC : ……… +Kondisi Farm : ……… +Berat rata-rata : ……… + +Tanggal | Umur | Pakan | Mati/Afkir | Obat & Vaksin +Tanggal | Umur | Std | Act | Mati | Afkir | Total | Obat & Vaksin + | 1 | + | 2 | + | 3 | + | 4 | + | 5 | + | 6 | + | 7 | +Jumlah Mg I | 0 | Ket : +Pakan gr/ek | | Kematian | ... % | Ket : +Berat Rata-rata | | Sisa Ayam | | Ket : +FCR | | Sisa Ayam | ... Ek | Ket : + +Tanggal | Umur | Pakan | Mati/Afkir | Obat & Vaksin +Tanggal | Umur | Std | Act | Mati | Afkir | Total | Obat & Vaksin + | 1 | + | 2 | + | 3 | + | 4 | + | 5 | + | 6 | + | 7 | +Jumlah Mg I | 0 | Ket : +Pakan gr/ek | | Kematian | ... % | Ket : +Berat Rata-rata | | Sisa Ayam | | Ket : +FCR | | Sisa Ayam | ... Ek | Ket : + +Tanggal | Umur | Pakan | Mati/Afkir | Obat & Vaksin +Tanggal | Umur | Std | Act | Mati | Afkir | Total | Obat & Vaksin + | 1 | + | 2 | + | 3 | + | 4 | + | 5 | + | 6 | + | 7 | +Jumlah Mg I | 0 | Ket : +Pakan gr/ek | | Kematian | ... % | Ket : +Berat Rata-rata | | Ket : +FCR | | Sisa Ayam | ... Ek | Ket : + +Tanggal | Umur | Pakan | Mati/Afkir | Obat & Vaksin +Tanggal | Umur | Std | Act | Mati | Afkir | Total | Obat & Vaksin + | 1 | + | 2 | + | 3 | + | 4 | + | 5 | + | 6 | + | 7 | +Jumlah Mg I | 0 | Ket : +Pakan gr/ek | | Kematian | ... % | Ket : +Berat Rata-rata | | Sisa Ayam | | Ket : +FCR | | Sisa Ayam | ... Ek | Ket : + +Tanggal | Umur | Pakan | Mati/Afkir | Obat & Vaksin +Tanggal | Umur | Std | Act | Mati | Afkir | Total | Obat & Vaksin + | 1 | + | 2 | + | 3 | + | 4 | + | 5 | + | 6 | + | 7 | +Jumlah Mg I | 0 | Ket : +Pakan gr/ek | | Kematian | ... % | Ket : +Berat Rata-rata | | Ket : +FCR | | Sisa Ayam | ... Ek | Ket : + +Tanggal | Umur | Pakan | Mati/Afkir | Obat & Vaksin +Tanggal | Umur | Std | Act | Mati | Afkir | Total | Obat & Vaksin + | 1 | + | 2 | + | 3 | + | 4 | + | 5 | + | 6 | + | 7 | +Jumlah Mg I | 0 | Ket : +Pakan gr/ek | | Kematian | ... % | Ket : +Berat Rata-rata | | Ket : +FCR | | Sisa Ayam | ... Ek | Ket : + +Saran : + + + + + + +Hasil Panen +Umur = +Berat Badan = +FCR = +Mortalitas = +IP = + +LAMPIRAN 2 + +PERFORMANCE STANDARD DAILY CP 707 +Age (days) | Average of BW (g) | ADG (g) | Mortality (%) | Feed Used (g) | FCR | IP +0 | 42 | | Daily | CUM | +1 | 57 | 15 | 0,40 | 13 | 0,228 | +2 | 73 | 16 | 0,50 | 17 | 30 | 0,411 | +3 | 90 | 17 | 0,60 | 20,5 | 50,5 | 0,561 | +4 | 110 | 20 | 0,70 | 23 | 73,5 | 0,668 | +5 | 134 | 24 | 0,80 | 26 | 99,5 | 0,743 | +6 | 161 | 27 | 0,90 | 31 | 130,5 | 0,811 | +7 | 195 | 34 | 1,00 | 34 | 164,5 | 0,844 | 327 +8 | 231 | 36 | 1,10 | 35 | 199,5 | 0,864 | 331 +9 | 269 | 38 | 1,20 | 41 | 240,5 | 0,894 | 330 +10 | 311 | 42 | 1,30 | 46 | 286,5 | 0,921 | 333 +11 | 355 | 44 | 1,40 | 52 | 338,5 | 0,954 | 334 +12 | 401 | 46 | 1,50 | 58 | 396,5 | 0,989 | 333 +13 | 449 | 48 | 1,60 | 64 | 460,5 | 1,026 | 331 +14 | 499 | 50 | 1,70 | 70 | 530,5 | 1,063 | 330 +15 | 552 | 53 | 1,80 | 73 | 603,5 | 1,093 | 331 +16 | 609 | 57 | 1,90 | 80 | 683,5 | 1,122 | 333 +17 | 669 | 60 | 2,00 | 86 | 769,5 | 1,150 | 335 +18 | 734 | 65 | 2,10 | 92 | 861,5 | 1,174 | 340 +19 | 802 | 68 | 2,20 | 100 | 961,5 | 1,199 | 344 +20 | 874 | 72 | 2,30 | 107 | 1068,5 | 1,223 | 349 +21 | 954 | 80 | 2,40 | 113 | 1181,5 | 1,238 | 358 +22 | 1035 | 81 | 2,52 | 128 | 1309,5 | 1,265 | 362 +23 | 1117 | 82 | 2,64 | 133 | 1442,5 | 1,291 | 366 +24 | 1200 | 83 | 2,76 | 139 | 1581,5 | 1,318 | 369 +25 | 1284 | 84 | 2,88 | 145 | 1726,5 | 1,345 | 371 +26 | 1369 | 85 | 3,00 | 151 | 1877,5 | 1,371 | 372 +27 | 1455 | 86 | 3,12 | 157 | 2034,5 | 1,398 | 373 +28 | 1543 | 88 | 3,25 | 164 | 2198,5 | 1,425 | 374 +29 | 1633 | 90 | 3,38 | 173 | 2371,5 | 1,452 | 375 +30 | 1725 | 92 | 3,52 | 176,5 | 2548 | 1,477 | 376 +31 | 1817 | 92 | 3,66 | 178 | 2726 | 1,500 | 376 +32 | 1910 | 93 | 3,80 | 180 | 2906 | 1,521 | 377 +33 | 2003 | 93 | 3,95 | 183 | 3089 | 1,542 | 378 +34 | 2097 | 94 | 4,10 | 185 | 3274 | 1,561 | 379 +35 | 2191 | 94 | 4,25 | 187 | 3461 | 1,580 | 379 +36 | 2285 | 94 | 4,45 | 190 | 3651 | 1,598 | 380 +37 | 2380 | 95 | 4,65 | 193 | 3844 | 1,615 | 380 + +Lampiran 3 + +Kualitas udara dan dampaknya untuk ayam + +Amoniak | Ideal level < 10 ppm. +Dapat tercium baunya pada 20 ppm atau lebih. +> 10 ppm akan merusak permukaan paru-paru; air sac inflamation. +> 20 ppm akan lebih rentan terhadap penyakit pernapasan. +> 25 ppm angka pertumbuhan menurun tergantung pada tinggi temperatur dan umur. +> 50 ppm angka pertumbuhan menurun. +Karbondioksida | Ideal level < 3,000 ppm. +> 3,500 ppm menyebabkan ascites dan kematian tinggi. +Karbondioksida sangat fatal pada level tinggi. +Karbon Monoksida | Ideal level 10 ppm. +> 50 ppm mempengaruhi kesehatan ayam. +100 ppm meningkatkan angka kematian dari tidak adanya supply oksigen +Karbon monoksida sangat fatal pada level tinggi. +Debu | Merusak saluran pernapasan dan lebih rentan terhadap penyakit seperti Air Saculitis. +Debu di dalam kandang harus diusahakan seminimal mungkin. +Kelembapan | Ideal level 50-60% setelah brooding. +Efeknya bervariasi bersamaan dengan temperature. +Pada >29°C (84.2°F) dan >70% RH, pertumbuhan akan berpengaruh. +RH <50% sebagian selama brooding akan mempengaruhi pertumbuhan. + +LAMPIRAN 4 + +Setting fan farm closed house 8 kipas + +Umur | Target Efektif | Heater | Kecepatan Angin Maks (FPM) Tengah | Jumlah Kipas | Intermiten | Fan 1 (°C) | Fan 2 (°C) | Fan 3 (°C) | Fan 4 (°C) | Fan 5 (°C) | Fan 6 (°C) | Fan 7 (°C) | Fan 8 (°C) | Coolingpad +Umur | Target Efektif | Heater | Kecepatan Angin Maks (FPM) Tengah | Min | Max | On (Mnt) | Off (Mnt) | Fan 1 (°C) | Fan 2 (°C) | Fan 3 (°C) | Fan 4 (°C) | Fan 5 (°C) | Fan 6 (°C) | Fan 7 (°C) | Fan 8 (°C) | (°C) | On (Mnt) +1 | 32,5 | 32,0 | 100 | 0 | 1 | 6 | 35,0 | +2 | 32,2 | 31,7 | 100 | 0 | 2 | 1 | 6 | 34,6 | 37,1 | +3 | 31,8 | 31,3 | 150 | 0 | 2 | 1 | 5 | 34,2 | 36,6 | +4 | 31,5 | 31,0 | 150 | 0 | 2 | 1 | 5 | 33,8 | 36,1 | +5 | 31,0 | 30,5 | 150 | 0 | 2 | 1 | 4 | 33,2 | 35,3 | +6 | 30,5 | 30,0 | 200 | 0 | 2 | 1 | 3 | 32,5 | 34,5 | +7 | 30,0 | 29,5 | 200 | 0 | 3 | 1 | 3 | 31,9 | 33,8 | 35,7 | +8 | 29,8 | 29,3 | 200 | 0 | 3 | 1 | 2 | 31,6 | 33,4 | 35,2 | +9 | 29,5 | 29,0 | 250 | 0 | 3 | 1 | 2 | 31,3 | 33,1 | 34,8 | +10 | 29,3 | 28,8 | 250 | 0 | 3 | 2 | 31,0 | 32,7 | 34,4 | +11 | 29,0 | 28,5 | 300 | 0 | 4 | 2 | 1 | 30,7 | 32,4 | 34,0 | 35,7 | +12 | 28,7 | 28,2 | 300 | 0 | 4 | 2 | 1 | 30,3 | 32,0 | 33,6 | 35,2 | +13 | 28,4 | 27,9 | 300 | 0 | 4 | 2 | 1 | 30,0 | 31,6 | 33,2 | 34,8 | +14 | 28,1 | 27,6 | 350 | 1 | 4 | | D | 31,2 | 32,7 | 34,3 | +15 | 27,8 | | 350 | 1 | 4 | | D | 30,8 | 32,3 | 33,8 | +16 | 27,5 | | 350 | 1 | 5 | | D | 30,4 | 31,9 | 33,4 | 34,8 | +17 | 27,2 | | 400 | 1 | 5 | | D | 30,0 | 31,5 | 32,9 | 34,3 | +18 | 26,9 | | 400 | 1 | 5 | | D | 29,7 | 31,0 | 32,4 | 33,8 | +19 | 26,6 | | 400 | 2 | 5 | | D | 30,6 | 32,0 | 33,3 | +20 | 26,3 | | 450 | 2 | 5 | | D | 30,2 | 31,5 | 32,8 | +21 | 26,0 | | 450 | 2 | 6 | | D | 29,8 | 31,0 | 32,3 | 33,5 | +22 | 25,7 | | >500 | 2 | 6 | | D | 29,3 | 30,6 | 31,8 | 33,0 | | 35 | 2 | 4 +23 | 25,3 | | 3 | 6 | | D | 30,1 | 31,3 | 32,5 | | 35 | 2 | 4 +24 | 25,0 | | 3 | 6 | | D | 29,6 | 30,8 | 31,9 | | 34 | 2 | 4 +25 | 24,7 | | 3 | 7 | | D | 29,1 | 30,3 | 31,4 | 32,5 | | 34 | 2 | 3 +26 | 24,3 | | 4 | 7 | | D | 29,7 | 30,8 | 31,9 | | 34 | 2 | 3 +27 | 24,0 | | 4 | 7 | | D | 29,2 | 30,3 | 31,3 | | 33 | 2 | 3 +28 | 23,7 | | 4 | 8 | | D | 28,7 | 29,7 | 30,8 | 31,8 | 33 | 2 +29 | 23,3 | | 5 | 8 | | D | 29,2 | 30,2 | 31,2 | 32 | 2 +30 | 23,0 | | 5 | 8 | | D | 28,7 | 29,6 | 30,5 | 31 | 2 +31 | 22,7 | | 6 | 8 | | D | 29,0 | 29,9 | 30 | 2 +32 | 22,3 | | 6 | 8 | | D | 28,4 | 29,3 | 30 | 2 +33 | 22,0 | | 7 | 8 | | D | 28,7 | 29 | 2 | 1 +34 | 22,0 | | 7 | 8 | | D | 28,3 | 29 | 2 | 1 +35 | 21,0 | | 7 | 8 | | D | 28,0 | 29 | 2 | 1 + +Catatan: + +Setting berikut untuk kandang 12x120 jumlah kipas 8 buah. + +Kebutuhan kipas berdasarkan bobot badan standar CP707. + +Bw DOC dibawah 40 gr target di 33-34 °C + +Coolingpad mulai dinyalakan umur 21 hari (tergantung daerah kandang) + +Pada siang hari sesuaikan dengan target efektif temperatur. + +Pada saat heater menyala naikan blocking dalam mulai dari depan ke belakang. + +0-7 hari blocking depan max buka 50% dan 8-14 hari max buka 30% dari bawah (still air). + +Selalu cek penyebaran dan kenyamanan ayam. + +LAMPIRAN 5 + +Target Efektif Temperatur Pada Tiap Kecapatan Angin Dan Kelembapan + +Actual Suhu | Air Velocity Feet Per Minute at Rh 70% | Target Efectif - Tempertur (TET) +Suhu | 0 | 50 | 100 | 150 | 200 | 250 | 300 | 350 | 400 | 450 | 500 | Umur | TET +(oC) | +35 | 38 | 37 | 36 | 33 | 31 | 30 | 29 | 27 | 26 | 25 | 24 | 1 - 2 hr. | 32°C +34.5 | 38 | 36 | 35 | 33 | 30 | 29 | 27 | 26 | 25 | 24 | 3 - 4 hr. | 31°C +34.1 | 37 | 36 | 35 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 5 - 7 hr. | 30°C +33.6 | 37 | 36 | 34 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 8 - 14 hr. | 29°C +33.1 | 36 | 35 | 34 | 32 | 29 | 28 | 27 | 26 | 25 | 24 | 15 - 21 hr. | 28°C +32.7 | 36 | 35 | 33 | 31 | 29 | 28 | 27 | 26 | 25 | 23 | 22 - 28 hr. | 26°C +32.2 | 36 | 34 | 33 | 31 | 29 | 28 | 27 | 26 | 24 | 23 | 29 - 35 hr. | 23°C +31.7 | 35 | 34 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 36 - Lay. | 22°C +31.3 | 34 | 33 | 32 | 30 | 28 | 27 | 26 | 25 | 24 | 23 | +30.8 | 34 | 32 | 31 | 30 | 28 | 27 | 26 | 25 | 24 | 23 | +30.3 | 33 | 32 | 31 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | +29.9 | 32 | 31 | 30 | 29 | 27 | 26 | 25 | 24 | 23 | +29.4 | 32 | 31 | 30 | 29 | 27 | 26 | 25 | 24 | 23 | +28.9 | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | +28.5 | 31 | 30 | 29 | 27 | 26 | 25 | 24 | 23 | 22 | +28 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | | 70% +27.5 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | +27.1 | 29 | 28 | 27 | 26 | 25 | 24 | 22 | 21 | 20 | +26.6 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +26.2 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 20 | 19 | +25.7 | 27 | 26 | 25 | 24 | 23 | 22 | 20 | 19 | +25.3 | 27 | 26 | 25 | 24 | 23 | 22 | 20 | 19 | +24.8 | 26 | 25 | 24 | 23 | 21 | 20 | 19 | +24.4 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +23.9 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +23.4 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +23 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +22.5 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +22 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +21.6 | 24 | 22 | 21 | 20 | 19 | 18 | 17 | +21.1 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | + +Lampiran 6 + +Target Efektif Temperatur Pada Tiap Kecepatan Angin Dan Kelembapan + +Actual Suhu (oC) | Air Velocity Feet Per Minute at Rh 80% | Target Efectif - Tempertur (TET) +Actual Suhu (oC) | 0 | 50 | 100 | 150 | 200 | 250 | 300 | 350 | 400 | 450 | 500 | Target Efectif - Tempertur (TET) +Actual Suhu (oC) | | Umur | TET +35 | 40 | 39 | 37 | 35 | 32 | 31 | 29 | 28 | 27 | 26 | 1 - 2 hr. | 32°C +34.5 | 39 | 38 | 37 | 35 | 32 | 31 | 29 | 28 | 26 | 25 | 3 - 4 hr. | 31°C +34.1 | 39 | 38 | 37 | 34 | 32 | 31 | 30 | 29 | 28 | 26 | 25 | 5 - 7 hr. | 30°C +33.6 | 39 | 38 | 36 | 34 | 32 | 31 | 30 | 29 | 28 | 26 | 25 | 8 - 14 hr. | 29°C +33.1 | 38 | 37 | 36 | 34 | 31 | 30 | 29 | 28 | 26 | 25 | 15 - 21 hr. | 28°C +32.7 | 38 | 37 | 36 | 34 | 31 | 30 | 29 | 28 | 26 | 25 | 22 - 28 hr. | 26°C +32.2 | 37 | 36 | 33 | 31 | 30 | 29 | 28 | 26 | 25 | 29 - 35 hr. | 23°C +31.7 | 36 | 35 | 33 | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 36 - Lay. | 22°C +31.3 | 36 | 35 | 34 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | +30.8 | 35 | 34 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | +30.3 | 34 | 33 | 31 | 29 | 28 | 27 | 26 | 25 | +29.9 | 33 | 32 | 31 | 29 | 28 | 27 | 26 | 25 | +29.4 | 33 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | +28.9 | 32 | 31 | 29 | 28 | 27 | 26 | 25 | 24 | +28.5 | 32 | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | | 80% +28 | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | +27.5 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | +27.1 | 30 | 29 | 28 | 27 | 26 | 25 | 23 | 22 | 21 | +26.6 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 21 | 20 | +26.2 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 21 | 20 | +25.7 | 28 | 27 | 26 | 25 | 24 | 23 | 21 | 20 | +25.3 | 28 | 27 | 26 | 25 | 24 | 23 | 21 | 20 | +24.8 | 27 | 26 | 25 | 24 | 22 | 21 | 20 | +24.4 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | +23.9 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | +23.4 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | +23 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +22.5 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +22 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +21.6 | 25 | 23 | 22 | 21 | 20 | 19 | 18 | +21.1 | 24 | 23 | 21 | 20 | 19 | 18 | + +Lampiran 7 + +Target Efektif Temperatur Pada Tiap Kecepatan Angin Dan Kelembapan + +Actual Suhu (oC) | Air Velocity Feet Per Minute at Rh 50% | Target Efectif - Temperatur (TET) +Actual Suhu (oC) | 0 | 50 | 100 | 150 | 200 | 250 | 300 | 350 | 400 | 450 | 500 | Umur | TET +Actual Suhu (oC) | +35 | 34 | 32 | 29 | 27 | 26 | 24 | 23 | 22 | 1 - 2 hr. | 32°C +34.5 | 35 | 33 | 31 | 29 | 26 | 25 | 24 | 23 | 22 | 3 - 4 hr. | 31°C +34.1 | 34 | 32 | 30 | 28 | 26 | 25 | 24 | 23 | 22 | 21 | 5 - 7 hr. | 30°C +33.6 | 34 | 32 | 29 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 8 - 14 hr. | 29°C +33.1 | 33 | 31 | 28 | 27 | 25 | 24 | 23 | 22 | 21 | 15 - 21 hr. | 28°C +32.7 | 33 | 30 | 28 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 22 - 28 hr. | 26°C +32.2 | 32 | 29 | 27 | 26 | 24 | 23 | 22 | 21 | 20 | 29 - 35 hr. | 23°C +31.7 | 32 | 29 | 27 | 26 | 24 | 23 | 22 | 21 | 20 | 36 - Lay. | 22°C +31.3 | 31 | 29 | 27 | 26 | 24 | 23 | 22 | 21 | 20 | +30.8 | 31 | 29 | 27 | 26 | 24 | 23 | 22 | 21 | 20 | +30.3 | 30 | 28 | 27 | 26 | 24 | 23 | 22 | 21 | 20 | +29.9 | 30 | 28 | 27 | 26 | 24 | 23 | 22 | 21 | 20 | +29.4 | 29 | 28 | 27 | 26 | 24 | 23 | 22 | 21 | 20 | +28.9 | 29 | 28 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | +28.5 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +28 | 27 | 26 | 24 | 23 | 22 | 21 | 20 | 19 | +27.5 | 28 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +27.1 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | +26.6 | 27 | 26 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +26.2 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +25.7 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | +25.3 | 25 | 24 | 23 | 22 | 21 | 19 | 18 | 17 | +24.8 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | +24.4 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | +23.9 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | +23.4 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | +23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | +22.5 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | + +Lampiran 8 + +Target Efektif Temperatur Pada Tiap Kecepatan Angin Dan Kelembapan + +Actual Suhu (oC) | Air Velocity Feet Per Minute at Rh 90% | Target Efectif - Tempertur (TET) | +Actual Suhu (oC) | 0 | 50 | 100 | 150 | 200 | 250 | 300 | 350 | 400 | 450 | 500 | Umur | TET +Actual Suhu (oC) | +35 | 42 | 40 | 39 | 37 | 34 | 33 | 31 | 29 | 28 | 27 | 1 - 2 hr. | 32°C +34.5 | 41 | 40 | 39 | 37 | 34 | 33 | 31 | 29 | 28 | 27 | 3 - 4 hr. | 31°C +34.1 | 41 | 40 | 39 | 36 | 34 | 33 | 31 | 29 | 28 | 27 | 5 - 7 hr. | 30°C +33.6 | 40 | 39 | 36 | 34 | 33 | 32 | 31 | 29 | 28 | 27 | 8 - 14 hr. | 29°C +33.1 | 40 | 39 | 36 | 34 | 33 | 32 | 31 | 30 | 28 | 27 | 15 - 21 hr. | 28°C +32.7 | 39 | 36 | 33 | 32 | 31 | 30 | 28 | 27 | 22 - 28 hr. | 26°C +32.2 | 39 | 36 | 33 | 32 | 31 | 30 | 28 | 27 | 29 - 35 hr. | 23°C +31.7 | 38 | 35 | 33 | 32 | 31 | 30 | 28 | 27 | 36 - Lay. | 22°C +31.3 | 37 | 35 | 32 | 31 | 30 | 29 | 28 | 27 | 90% +30.8 | 36 | 34 | 32 | 31 | 30 | 29 | 28 | 27 | 90% +30.3 | 35 | 33 | 31 | 30 | 29 | 28 | 27 | 90% +29.9 | 35 | 34 | 32 | 31 | 30 | 29 | 28 | 27 | 90% +29.4 | 34 | 33 | 32 | 30 | 29 | 28 | 27 | 90% +28.9 | 33 | 32 | 31 | 29 | 28 | 27 | 26 | 90% +28.5 | 33 | 32 | 30 | 29 | 28 | 27 | 26 | 25 | 90% +28 | 32 | 31 | 29 | 28 | 27 | 26 | 25 | 24 | 90% +27.5 | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 90% +27.1 | 31 | 30 | 29 | 28 | 27 | 26 | 24 | 23 | 22 | 90% +26.6 | 30 | 29 | 28 | 27 | 26 | 24 | 22 | 21 | 90% +26.2 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 22 | 21 | 90% +25.7 | 29 | 28 | 27 | 26 | 25 | 24 | 22 | 21 | 90% +25.3 | 29 | 28 | 27 | 26 | 25 | 24 | 22 | 21 | 90% +24.8 | 28 | 27 | 26 | 25 | 24 | 22 | 21 | 90% +24.4 | 28 | 27 | 26 | 25 | 23 | 22 | 21 | 90% +23.9 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 90% +23.4 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 90% +23 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 90% +22.5 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 90% +22 | 26 | 25 | 23 | 22 | 21 | 20 | 19 | 90% +21.6 | 26 | 24 | 23 | 22 | 21 | 20 | 19 | 90% +21.1 | 26 | 24 | 22 | 21 | 20 | 19 | 18 | 90% + +Lampiran 9 + +Kebutuhan Pengencer Dosatron Untuk 20.000 Ekor + +Umur | Feed Intake Daily (gr) | Wate Intake Daily (l) | Kebutuhan Air (Jam) | Air Dosatron +Umur | Feed Intake Daily (gr) | Wate Intake Daily (l) | Kebutuhan Air (Jam) | 2 % | 1 % +Hari | /ekor | 20000 | 4 | 6 | 8 | 4 Jam | 6 Jam | 8 Jam | 4 Jam | 6 Jam | 8 Jam +1 | 14 | 0,035 | 700 | 117 | 175 | 233 | 2,3 | 3,5 | 4,7 | 1,2 | 1,8 | 2,3 +2 | 15 | 0,0375 | 750 | 125 | 188 | 250 | 3 | 4 | 5 | 1,3 | 1,9 | 2,5 +3 | 18 | 0,045 | 900 | 150 | 225 | 300 | 3 | 5 | 6 | 1,5 | 2,3 | 3,0 +4 | 21 | 0,0525 | 1050 | 175 | 263 | 350 | 4 | 5,3 | 7 | 1,8 | 2,6 | 3,5 +5 | 24 | 0,06 | 1200 | 200 | 300 | 400 | 4 | 6,0 | 8 | 2,0 | 3,0 | 4,0 +6 | 27 | 0,0675 | 1350 | 225 | 300 | 450 | 5 | 6,8 | 9 | 2,3 | 3,4 | 4,5 +7 | 31 | 0,0775 | 1550 | 258 | 388 | 517 | 5 | 7,8 | 10 | 2,6 | 3,9 | 5,2 +8 | 35 | 0,0875 | 1750 | 292 | 438 | 583 | 6 | 8,8 | 12 | 2,9 | 4,4 | 5,8 +9 | 41 | 0,1025 | 2050 | 342 | 513 | 683 | 7 | 10 | 14 | 3,4 | 5,1 | 6,8 +10 | 46 | 0,115 | 2300 | 383 | 575 | 767 | 8 | 12 | 15 | 3,8 | 5,8 | 7,7 +11 | 52 | 0,13 | 2600 | 433 | 650 | 867 | 9 | 13,0 | 17 | 4,3 | 5,8 | 8,7 +12 | 58 | 0,145 | 2900 | 483 | 725 | 967 | 10 | 14,5 | 19 | 4,8 | 7,3 | 9,7 +13 | 64 | 0,16 | 3200 | 533 | 800 | 1067 | 11 | 16,0 | 21 | 5,3 | 8,0 | 10,7 +14 | 70 | 0,175 | 3500 | 583 | 875 | 1167 | 12 | 17,5 | 23 | 5,8 | 8,8 | 11,7 +15 | 76 | 0,19 | 3800 | 633 | 950 | 1267 | 13 | 19,0 | 25 | 6,3 | 9,5 | 12,7 +16 | 82 | 0,205 | 4100 | 683 | 1025 | 1367 | 14 | 21 | 27 | 6,8 | 10,3 | 13,7 +17 | 88 | 0,22 | 4400 | 733 | 1100 | 1467 | 15 | 22 | 29 | 7,3 | 11,0 | 14,7 +18 | 94 | 0,235 | 4700 | 783 | 1175 | 1567 | 16 | 24 | 31 | 7,8 | 11,8 | 15,7 +19 | 100 | 0,25 | 5000 | 833 | 1250 | 1667 | 17 | 25 | 33 | 8,3 | 12,5 | 16,7 +20 | 107 | 0,2675 | 5350 | 892 | 1338 | 1783 | 18 | 27 | 36 | 8,9 | 13,4 | 17,8 +21 | 113 | 0,2825 | 5650 | 942 | 1413 | 1883 | 19 | 28 | 38 | 9,4 | 14,1 | 18,8 +22 | 118 | 0,295 | 5900 | 983 | 1475 | 1967 | 20 | 30 | 39 | 9,8 | 14,8 | 19,7 +23 | 124 | 0,31 | 6200 | 1033 | 1550 | 2067 | 21 | 31 | 41 | 10,3 | 15,5 | 20,7 +24 | 130 | 0,325 | 6500 | 1083 | 1625 | 2167 | 22 | 33 | 43 | 10,8 | 16,3 | 21,7 +25 | 135 | 0,3375 | 6750 | 1125 | 1688 | 2250 | 23 | 34 | 45 | 11,3 | 16,9 | 22,5 +26 | 141 | 0,3525 | 7050 | 1175 | 1763 | 2350 | 24 | 35 | 47 | 11,8 | 17,6 | 23,5 +27 | 146 | 0,365 | 7300 | 1217 | 1825 | 2433 | 24 | 37 | 49 | 12,2 | 18,3 | 24,3 +28 | 151 | 0,3775 | 7550 | 1258 | 1888 | 2517 | 25 | 38 | 50 | 12,6 | 18,9 | 24,3 + +Quality Life through Healthier Livestock + +PT SHS INTERNATIONAL + +Maspion Plaza Lt. 11 Jl. Gunung Sahari Raya Kav. 18 Jakarta Utara 14420 + +Telp.: 62-21-64701200, Fax: 62-21-64700968 + +Email: contact@shs.co.id + +Medan: Jl. SM. Raja KM 8.5 No.53 (CPP Amplas) Medan Amplas, Medan Telp. (061) 88807328, 88807329 + +Palembang: Jl. Soekarno Hatta Komp. Ruko Soetamas No. 8 Kel. Karya Baru Kec. Alang Alang Lebar Rt. 027 Rw. 008 Palembang 30154 Telp. (0711) 5613556 + +Lampung: Jl. Yos Sudarso No.257 Garuntang Teluk Betung Selatan, Bandar Lampung Telp. (0721) 700785 + +Bandung: Jl. Soekarno Hatta Komp. Ruko Batununggal Indah Raya No. 223 Bandung Telp. (022) 751 4276-(022) 751 4403 + +Semarang: Puri Anjasmoro Blok EE-2 / 24 Semarang Telp. (024) 760 2811, 7663 2252, 7607333 + +Surabaya: Jl. Surabaya Mojokerto Km. 26 Keboharan, Krian Telp. (031) 99893000 + +Denpasar: Jl. Gatot Subroto Timur No. 330 X Denpasar Telp. (0361) 240971 / 245186 + +Pontianak: Jl. Gusti Hamzah Kav. 1 A Pontianak Telp. (0561) 730 586 Accounting: (0561) 762 928 + +Makassar: Jl. Perintis Kemerdekaan 1 Km.8 Perintis Bussines Center Blok C3 no. 3 (belakang toko harapan baru) Telp. (0411) 8993. + +Pilihan yang Paling Tepat Untuk Peternak Profesional + +PT. INDOVETRACO MAKMUR ABADI + +Maspion Plaza Lt. 9 Jl. Gunung Sahari Raya Kav. 18 Jakarta - 14420 Telp. 6221 6470 1200 Fax. 6221 6470 0967 + +Medan: Jl. SM. Raja KM 8.5 No.53 (CPP Amplas) Medan Amplas, Medan Telp. (061) 88807328, 88807329 + +Palembang: Jl. Soekarno Hatta Komp. Ruko Soetamas No. 8 Kel. Karya Baru Kec. Alang Alang Lebar Rt. 027 Rw. 008 Palembang 30154 Telp. (0711) 5613556 + +Lampung: Jl. Yos Sudarso No.257 Garuntang Teluk Betung Selatan, Bandar Lampung Telp. (0721) 700785 + +Bandung: Jl. Soekarno Hatta Komp. Ruko Batununggal Indah Raya No. 223 Bandung Telp. (022) 751 4276-(022) 751 4403 + +Semarang: Puri Anjasmoro Blok EE-2 / 24 Semarang Telp. (024) 760 2811, 7663 2252, 7607333 + +Surabaya: Jl. Surabaya Mojokerto Km. 26 Keboharan, Krian Telp. (031) 99893000 + +Denpasar: Jl. Gatot Subroto Timur No. 330 X Denpasar Telp. (0361) 240971 / 245186 + +Pontianak: Jl. Gusti Hamzah Kav. 1 A Pontianak Telp. (0561) 730 586 Accounting: (0561) 762 928 + +Makassar: Jl. Perintis Kemerdekaan 1 Km.8 Perintis Bussines Center Blok C3 no. 3 (belakang toko harapan baru ) Telp. (0411) 8993000 + +Perpaduan Teknologi dan Pengalaman Kami Mewujudkan Sukses Bersama + +PT CHAROEN POKPHAND INDONESIA, Tbk + +JAKARTA: Jl. Ancol VIII No. 1 Ancol Barat Telp. (021) 691 9999 + +BALARAJA: Jl. Raya Serang Km.30 Desa Cangkudu, Kec. Balaraja, Tangerang -Banten 15610 + +Telp. (021) 595 1005 -595 1008 Fax (021) 595 1015 + +SURABAYA: Jl. Raya Surabaya - Mojokerto Km. 26 Desa Keboharan Kec. Krian, Sidoarjo, Telp. (031) 897 2992 + +SURABAYA: Jl. Surabaya - Mojokerto Km. 19 + +Sepanjang Sidoarjo Jawa Timur 61257 + +MEDAN: Jl. Pulau Sumbawa No. 5, Kawasan Industri Medan + +Km. 10 - 15 Mabar. Telp. (061) 685 2288, 685 2282 + +MAKASSAR: Kawasan Industri Makassar, Jl. Kima 17 Kav. DD 11 Makassar, Sulawesi Selatan. Telp. (0411) 515 999 SEMARANG: JI. Raya Semarang - Demak Km. 8 + +Genuk - Semarang 50118. Telp. (024) 6580235, Fax. (024) 6581362 + +SAYUNG: Jl. Raya Semarang - Demak Km. 9 + +Desa Purwosari, Kec. Sayung, Kab. Demak 59562 + +Telp. (024) 86002300, Fax (024)8592039 + +CIREBON: JI. Cirebon Brebes Km 11, Ds. Astanajapura + +Kec. Astanajapura Cirebon - Jawa Barat 45181. + +Telp.(0231) 510 900, Fax.(0231) 510 900/510 920 + +LAMPUNG: Jl. Ir. Sutami Km. 15 Desa Rejo Mulyo, Tanjung Bintang, Lampung Selatan. Telp (0721) 350440 Fax. (0721) 350434 \ No newline at end of file diff --git a/AI Insight/populate_db.py b/AI Insight/populate_db.py new file mode 100644 index 0000000..125382d --- /dev/null +++ b/AI Insight/populate_db.py @@ -0,0 +1,180 @@ +import os +import glob +from pathlib import Path +from dotenv import load_dotenv +import chromadb +from sentence_transformers import SentenceTransformer + +from chunk_classify import classify_chunk_tipe, detect_bab + +# Set offline mode agar sentence-transformers tidak mencoba menghubungi Hugging Face di jaringan on-premise +os.environ["HF_HUB_OFFLINE"] = "1" + +# Load environment variables +load_dotenv() + +SCRIPT_DIR = Path(__file__).parent +OUTPUT_TXT_DIR = os.getenv("OUTPUT_TXT_DIR", str(SCRIPT_DIR / "extracted_txt")) +CHROMA_DB_DIR = os.getenv("CHROMA_DB_DIR", str(SCRIPT_DIR / "chroma_db")) +EMBEDDING_MODEL_NAME = os.getenv( + "EMBEDDING_MODEL_NAME", + "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", +) + +def chunk_text(text, chunk_size=800, chunk_overlap=150): + """Memecah teks menjadi chunk berdasarkan paragraf, baris, atau kata.""" + paragraphs = text.split("\n\n") + chunks = [] + current_chunk = "" + + for para in paragraphs: + para = para.strip() + if not para: + continue + + # Jika paragraf itu sendiri lebih besar dari chunk_size, bagi berdasarkan baris + if len(para) > chunk_size: + if current_chunk: + chunks.append(current_chunk) + current_chunk = "" + + lines = para.split("\n") + for line in lines: + line = line.strip() + if not line: + continue + + if len(line) > chunk_size: + # Bagi baris panjang berdasarkan kata + words = line.split(" ") + temp_chunk = "" + for word in words: + if len(temp_chunk) + len(word) + 1 > chunk_size: + if temp_chunk: + chunks.append(temp_chunk) + overlap_start = max(0, len(temp_chunk) - chunk_overlap) + temp_chunk = temp_chunk[overlap_start:].strip() + if temp_chunk: + temp_chunk += " " + word + else: + temp_chunk = word + else: + if temp_chunk: + temp_chunk += " " + word + else: + temp_chunk = word + if temp_chunk: + current_chunk = temp_chunk + else: + if len(current_chunk) + len(line) + 1 > chunk_size: + chunks.append(current_chunk) + overlap_start = max(0, len(current_chunk) - chunk_overlap) + current_chunk = current_chunk[overlap_start:].strip() + if current_chunk: + current_chunk += "\n" + line + else: + current_chunk = line + else: + if current_chunk: + current_chunk += "\n" + line + else: + current_chunk = line + else: + # Pengelompokan paragraf standar + if len(current_chunk) + len(para) + 2 > chunk_size: + chunks.append(current_chunk) + overlap_start = max(0, len(current_chunk) - chunk_overlap) + current_chunk = current_chunk[overlap_start:].strip() + if current_chunk: + current_chunk += "\n\n" + para + else: + current_chunk = para + else: + if current_chunk: + current_chunk += "\n\n" + para + else: + current_chunk = para + + if current_chunk: + chunks.append(current_chunk) + + return chunks + +def main(): + if not os.path.exists(OUTPUT_TXT_DIR): + print(f"Folder teks terekstrak '{OUTPUT_TXT_DIR}' tidak ditemukan. Jalankan extract_text.py terlebih dahulu.") + return + + txt_files = glob.glob(os.path.join(OUTPUT_TXT_DIR, "*.txt")) + if not txt_files: + print(f"Tidak ada file .txt ditemukan di '{OUTPUT_TXT_DIR}'. Jalankan extract_text.py terlebih dahulu.") + return + + # Inisialisasi Model Embedding lokal + print(f"Memuat model embedding lokal '{EMBEDDING_MODEL_NAME}'...") + model = SentenceTransformer(EMBEDDING_MODEL_NAME) + print("Model embedding berhasil dimuat.") + + # Inisialisasi Chroma DB client + print(f"Menginisialisasi Chroma DB di folder '{CHROMA_DB_DIR}'...") + client = chromadb.PersistentClient(path=CHROMA_DB_DIR) + + # Hapus koleksi lama jika ada untuk menghindari duplikasi data lama saat indeks ulang + try: + client.delete_collection(name="company_sop") + print("Koleksi lama 'company_sop' berhasil dihapus untuk indeks ulang.") + except Exception: + pass + + collection = client.create_collection(name="company_sop") + + total_chunks = 0 + + for file_path in txt_files: + filename = os.path.basename(file_path) + print(f"\nMemproses chunking & embedding untuk file: {filename}...") + + with open(file_path, "r", encoding="utf-8") as f: + text = f.read() + + chunks = chunk_text(text, chunk_size=800, chunk_overlap=150) + if not chunks: + print(f"File {filename} kosong atau tidak menghasilkan chunk.") + continue + + print(f"Menghasilkan {len(chunks)} chunks dari {filename}. Membuat embedding...") + + # Hitung embeddings + embeddings = model.encode(chunks) + embeddings_list = [emb.tolist() for emb in embeddings] + + # Metadata: tipe=prosa|tabel, bab — query path prefers prosa + metadatas = [] + for i, chunk in enumerate(chunks): + tipe = classify_chunk_tipe(chunk) + bab = detect_bab(chunk, filename) + metadatas.append( + { + "source": filename, + "chunk_index": i, + "tipe": tipe, + "bab": bab or "", + } + ) + ids = [f"{filename}_chunk_{i}" for i in range(len(chunks))] + + # Tambahkan ke Chroma DB + collection.add( + documents=chunks, + embeddings=embeddings_list, + metadatas=metadatas, + ids=ids + ) + + total_chunks += len(chunks) + print(f"Berhasil menyimpan {len(chunks)} chunks ke Chroma DB.") + + print(f"\nProses pembuatan database selesai! Total {total_chunks} chunks berhasil disimpan di Chroma DB.") + +if __name__ == "__main__": + main() diff --git a/AI Insight/query_rag.py b/AI Insight/query_rag.py new file mode 100644 index 0000000..a1bbbf8 --- /dev/null +++ b/AI Insight/query_rag.py @@ -0,0 +1,165 @@ +""" +CLI / local-dev helper to query Chroma + an OpenAI-compatible LLM (e.g. LM Studio). + +NOT used by the dashboard runtime. Production/lab path is: + Django insight_service → HTTP → rag_service.py (/query) → Ollama from Django. +Prefer `rag_service.py` + `populate_db.py` when testing what the app actually calls. +""" + +import os +import sys +import requests +from dotenv import load_dotenv +import chromadb +from sentence_transformers import SentenceTransformer + +# Set offline mode agar sentence-transformers tidak mencoba menghubungi Hugging Face di jaringan on-premise +os.environ["HF_HUB_OFFLINE"] = "1" + +# Load environment variables +load_dotenv() + +CHROMA_DB_DIR = os.getenv("CHROMA_DB_DIR", "chroma_db") +EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "all-MiniLM-L6-v2") +OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "http://localhost:1234/v1") +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "lm-studio") +LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "qwen2.5:7b") + +def main(): + # 1. Inisialisasi Database Vektor + if not os.path.exists(CHROMA_DB_DIR): + print(f"Database Chroma DB di '{CHROMA_DB_DIR}' tidak ditemukan. Silakan jalankan extract_text.py dan populate_db.py terlebih dahulu.") + return + + print("Menghubungkan ke Chroma DB...") + chroma_client = chromadb.PersistentClient(path=CHROMA_DB_DIR) + try: + collection = chroma_client.get_collection(name="company_sop") + except Exception as e: + print(f"Koleksi 'company_sop' tidak ditemukan di database. Pastikan populate_db.py sudah dijalankan dengan sukses. Error: {e}") + return + + # 2. Inisialisasi Model Embedding lokal + print(f"Memuat model embedding lokal '{EMBEDDING_MODEL_NAME}' untuk kueri...") + embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME) + print("Model embedding berhasil dimuat.") + + # 3. Konfigurasi koneksi LM Studio native v1 API + LM_STUDIO_API_URL = os.getenv("LM_STUDIO_API_URL") + if not LM_STUDIO_API_URL: + openai_base = os.getenv("OPENAI_BASE_URL", "http://localhost:1234/v1") + if openai_base.endswith("/v1"): + LM_STUDIO_API_URL = openai_base.replace("/v1", "/api/v1/chat") + else: + LM_STUDIO_API_URL = f"{openai_base.rstrip('/')}/api/v1/chat" + + print(f"Mengonfigurasi koneksi LLM ke native API: {LM_STUDIO_API_URL} (Model: {LLM_MODEL_NAME})...") + + print("\n" + "="*60) + print(" PIPELINE RAG LOKAL - ASISTEN SOP PERUSAHAAN (QWEN 2.5)") + print(" Ketik 'keluar' atau 'exit' untuk menyudahi percakapan.") + print("="*60 + "\n") + + while True: + try: + query = input("\nPertanyaan Anda: ").strip() + if not query: + continue + if query.lower() in ["keluar", "exit", "q", "quit"]: + print("Sampai jumpa!") + break + + print("\n[1/3] Mencari dokumen referensi relevan di database lokal...", end="", flush=True) + # Buat embedding kueri + query_embedding = embedding_model.encode([query])[0].tolist() + + # Cari kueri di Chroma DB (ambil 6 chunk teratas) + results = collection.query( + query_embeddings=[query_embedding], + n_results=6 + ) + print(" Selesai!") + + retrieved_chunks = results['documents'][0] + retrieved_metadatas = results['metadatas'][0] + + if not retrieved_chunks or len(retrieved_chunks) == 0: + print("⚠️ Tidak ditemukan referensi dokumen yang cocok dengan pertanyaan Anda.") + continue + + # Tampilkan referensi yang ditemukan + print("\n[Referensi yang Ditemukan]:") + for idx, meta in enumerate(retrieved_metadatas): + print(f" - [{idx+1}] File: {meta['source']} (Chunk: {meta['chunk_index']})") + + # 4. Susun Prompt dengan Konteks SOP + context = "\n\n---\n\n".join(retrieved_chunks) + + system_prompt = ( + "Anda adalah asisten AI perusahaan yang profesional. Tugas Anda adalah memberikan jawaban " + "yang valid, akurat, dan sesuai dengan Standar Operasional Prosedur (SOP) atau dokumen acuan perusahaan " + "yang disediakan di bawah ini.\n" + "Patuhi aturan berikut:\n" + "1. Jawablah HANYA berdasarkan informasi yang ada dalam dokumen acuan di bawah.\n" + "2. Jika jawaban tidak dapat ditemukan di dalam dokumen tersebut secara eksplisit atau logis, katakan dengan sopan " + "bahwa 'Maaf, informasi tersebut tidak ditemukan dalam dokumen SOP/acuan perusahaan kami.' Jangan mengarang informasi.\n" + "3. Sajikan data dengan valid dan rapi." + ) + + user_prompt = f"""Dokumen SOP / Acuan Perusahaan: +========================================= +{context} +========================================= + +Pertanyaan Pengguna: {query} + +Jawaban berdasarkan Dokumen Acuan:""" + + print(f"\n[2/3] Menghubungi LLM Qwen 2.5 lokal di {LM_STUDIO_API_URL}...", end="", flush=True) + + # Panggil LM Studio native v1 API + headers = { + "Content-Type": "application/json" + } + if OPENAI_API_KEY and OPENAI_API_KEY != "lm-studio": + headers["Authorization"] = f"Bearer {OPENAI_API_KEY}" + + payload = { + "model": LLM_MODEL_NAME, + "input": user_prompt, + "system_prompt": system_prompt, + "temperature": 0.1, + "max_output_tokens": 32000 + } + + response = requests.post(LM_STUDIO_API_URL, json=payload, headers=headers) + response.raise_for_status() + print(" Selesai!") + + answer = response.json()["response"] + + # Pisahkan proses berpikir () jika ada (khusus model reasoning seperti Qwen 2.5) + import re + think_match = re.search(r'(.*?)', answer, re.DOTALL) + clean_answer = re.sub(r'.*?', '', answer, flags=re.DOTALL).strip() + + if think_match and think_match.group(1).strip(): + print(" Selesai!") + print("\n[Proses Berpikir Qwen 2.5]:") + print("." * 50) + print(think_match.group(1).strip()) + print("." * 50) + else: + print(" Selesai!") + + print("\n[3/3] Respon Asisten SOP:") + print("-"*50) + print(clean_answer) + print("-"*50) + + except Exception as e: + print(f"\n❌ Terjadi kesalahan: {e}") + print("Harap pastikan server LLM lokal Anda (LM Studio/vLLM/llama.cpp) sedang berjalan dan dapat diakses.") + +if __name__ == "__main__": + main() diff --git a/AI Insight/rag_service.py b/AI Insight/rag_service.py new file mode 100644 index 0000000..13adf2a --- /dev/null +++ b/AI Insight/rag_service.py @@ -0,0 +1,198 @@ +""" +RAG microservice for the CP 707 knowledge base (ChromaDB + SentenceTransformers). + +Runtime entrypoint used by Django `insight_service.fetch_rag_chunks` +via HTTP `RAG_SERVICE_URL` (default compose :5002; NUC AI lab often :5102). +Offline HuggingFace mode — do not confuse with `query_rag.py` (CLI/dev only). +""" + +import os +import sys +from pathlib import Path +from dotenv import load_dotenv + +# Set offline agar tidak download dari HuggingFace +os.environ["HF_HUB_OFFLINE"] = "1" +os.environ["TRANSFORMERS_OFFLINE"] = "1" + +# Load .env dari direktori yang sama dengan script ini +script_dir = Path(__file__).parent +load_dotenv(script_dir / ".env") + +CHROMA_DB_DIR = str(script_dir / os.getenv("CHROMA_DB_DIR", "chroma_db")) +EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") +RAG_PORT = int(os.getenv("RAG_PORT", "5002")) +COLLECTION_NAME = "company_sop" + +print(f"[RAG Service] ChromaDB path: {CHROMA_DB_DIR}") +print(f"[RAG Service] Embedding model: {EMBEDDING_MODEL_NAME}") + +# Import setelah env set +import chromadb +from sentence_transformers import SentenceTransformer +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +import uvicorn + +# ─── Init ChromaDB & Embedding Model ───────────────────────────────────────── +print("[RAG Service] Memuat ChromaDB...") +try: + chroma_client = chromadb.PersistentClient(path=CHROMA_DB_DIR) + collection = chroma_client.get_collection(name=COLLECTION_NAME) + total_chunks = collection.count() + print(f"[RAG Service] ChromaDB loaded. Total chunks: {total_chunks}") +except Exception as e: + print(f"[RAG Service] ERROR: Gagal load ChromaDB: {e}") + sys.exit(1) + +print(f"[RAG Service] Memuat embedding model '{EMBEDDING_MODEL_NAME}'...") +try: + embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME) + print("[RAG Service] Embedding model berhasil dimuat.") +except Exception as e: + print(f"[RAG Service] ERROR: Gagal load embedding model: {e}") + print("[RAG Service] Pastikan model sudah didownload. Jalankan populate_db.py terlebih dahulu.") + sys.exit(1) + +# ─── FastAPI App ────────────────────────────────────────────────────────────── +app = FastAPI( + title="CP707 RAG Service", + description="Retrieval-Augmented Generation service untuk buku Manajemen Broiler CP 707", + version="1.0.0" +) + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:5001", + "http://127.0.0.1:5001", + "http://localhost:8000", + "http://127.0.0.1:8000", + ], + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + +# ─── Request/Response Models ───────────────────────────────────────────────── +class QueryRequest(BaseModel): + query: str = Field(..., min_length=1, description="Query text untuk mencari chunk CP707 relevan") + n_results: int = Field(default=4, ge=1, le=10, description="Jumlah chunk yang dikembalikan") + topic: str = Field(default="", description="Topic insight: berat_ayam, fcr, iot_panel, dll") + # Prefer prose SOP; table rows without headers are dangerous for the LLM. + tipe: str = Field(default="prosa", description="Filter metadata tipe: prosa | tabel | any") + +class QueryResponse(BaseModel): + success: bool + chunks: list + sources: list + metadatas: list + total_found: int + query_used: str + +class HealthResponse(BaseModel): + status: str + total_chunks: int + embedding_model: str + +# ─── Topic → Query enhancement mapping ────────────────────────────────────── +# Tambahkan keyword relevan per topic agar embedding search lebih tepat sasaran +TOPIC_QUERY_HINTS = { + "berat_ayam": "berat badan target bobot ADG pertumbuhan standar mingguan ayam broiler", + "fcr": "FCR feed conversion ratio konsumsi pakan efisiensi standar broiler", + "iot_panel": "suhu kandang kelembapan amonia CO2 ventilasi lingkungan pemeliharaan broiler", + "eef": "EEF indeks performa IP efisiensi produksi siklus broiler", + "hitung_ayam": "mortalitas deplesi kematian afkir populasi standar toleransi broiler", + "hitung_karung": "pakan karung konsumsi harian feed intake standar broiler", +} + +# ─── Endpoints ─────────────────────────────────────────────────────────────── +@app.get("/health", response_model=HealthResponse) +def health_check(): + return HealthResponse( + status="ok", + total_chunks=collection.count(), + embedding_model=EMBEDDING_MODEL_NAME, + ) + +@app.post("/query", response_model=QueryResponse) +def query_cp707(req: QueryRequest): + """ + Cari chunk CP707 yang relevan berdasarkan query. + Jika topic disediakan, tambahkan hint keyword agar hasil lebih relevan. + """ + enhanced_query = req.query + if req.topic and req.topic in TOPIC_QUERY_HINTS: + enhanced_query = f"{req.query} {TOPIC_QUERY_HINTS[req.topic]}" + + try: + query_embedding = embedding_model.encode([enhanced_query])[0].tolist() + + # Over-fetch then filter by tipe so prosa chunks win when metadata exists. + fetch_n = min(max(req.n_results * 3, req.n_results), max(collection.count(), 1)) + where = None + if req.tipe and req.tipe != "any": + where = {"tipe": req.tipe} + + try: + results = collection.query( + query_embeddings=[query_embedding], + n_results=fetch_n, + where=where, + ) + except Exception: + # Older indexes may lack tipe metadata — fall back unfiltered. + results = collection.query( + query_embeddings=[query_embedding], + n_results=fetch_n, + ) + + chunks = results["documents"][0] if results["documents"] else [] + metadatas = results["metadatas"][0] if results["metadatas"] else [] + + # If unfiltered fallback returned tables, drop them when prosa was requested. + if req.tipe == "prosa" and metadatas: + paired = [ + (c, m) + for c, m in zip(chunks, metadatas) + if (m or {}).get("tipe", "prosa") != "tabel" + ] + if paired: + chunks, metadatas = [list(x) for x in zip(*paired)] + else: + # Keep original if everything was tabel (better something than nothing; + # Django stripHeaderlessTables still cleans numeric rows). + pass + + chunks = chunks[: req.n_results] + metadatas = metadatas[: req.n_results] + + sources = [] + for m in metadatas: + bab = (m or {}).get("bab") or "" + src = (m or {}).get("source", "unknown") + idx = (m or {}).get("chunk_index", "?") + label = f"{src}" + if bab: + label += f" / {bab}" + label += f" (chunk {idx})" + sources.append(label) + + return QueryResponse( + success=True, + chunks=chunks, + sources=sources, + metadatas=metadatas, + total_found=len(chunks), + query_used=enhanced_query, + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"RAG query error: {str(e)}") + + +if __name__ == "__main__": + print(f"[RAG Service] Starting on http://0.0.0.0:{RAG_PORT}") + uvicorn.run(app, host="0.0.0.0", port=RAG_PORT, log_level="warning") diff --git a/AI Insight/requirements.txt b/AI Insight/requirements.txt new file mode 100644 index 0000000..4788e98 --- /dev/null +++ b/AI Insight/requirements.txt @@ -0,0 +1,8 @@ +chromadb +sentence-transformers +python-docx +pypdf +python-dotenv +openai +fastapi +uvicorn diff --git a/backend/.env.example b/backend/.env.example index 71ff71e..f49b77d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -64,3 +64,9 @@ CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS=30 CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=true CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=true CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=true + +# AI Insight (Ollama on host + RAG service) +RAG_SERVICE_URL=http://127.0.0.1:5002 +OLLAMA_BASE_URL=http://127.0.0.1:11434 +LLM_MODEL_NAME=qwen2.5:3b +LLM_TIMEOUT_SECONDS=1200 diff --git a/backend/apps/farms/tests_pusat_export.py b/backend/apps/farms/tests_pusat_export.py index bf4ca1a..d5cec59 100644 --- a/backend/apps/farms/tests_pusat_export.py +++ b/backend/apps/farms/tests_pusat_export.py @@ -58,11 +58,13 @@ class PusatExportTests(TestCase): ) AIInsight.objects.create( cycle=self.cycle, + kandang=self.kandang, date=today, insight_text="ok", - alert="none", - section="fcr", - session="morning", + alert="healthy", + topic="fcr", + report_type="page", + report_period="Hari 1", ) IotPanel.objects.create( flock=self.flock, diff --git a/backend/apps/operations/migrations/0012_ai_insight_cache_scope.py b/backend/apps/operations/migrations/0012_ai_insight_cache_scope.py new file mode 100644 index 0000000..57de0ed --- /dev/null +++ b/backend/apps/operations/migrations/0012_ai_insight_cache_scope.py @@ -0,0 +1,110 @@ +# Generated for AI Insight cache scope (multi-kandang generate/cache) + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("farms", "0014_kandang_feed_in_button_urls"), + ("operations", "0011_aiinsight_unique_cycle_date_section_session"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="aiinsight", + name="uniq_ai_insight_cycle_date_section_session", + ), + migrations.AddField( + model_name="aiinsight", + name="kandang", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="ai_insights", + to="farms.kandang", + ), + ), + migrations.AddField( + model_name="aiinsight", + name="topic", + field=models.CharField(blank=True, db_index=True, default="", max_length=64), + ), + migrations.AddField( + model_name="aiinsight", + name="report_type", + field=models.CharField(blank=True, db_index=True, default="page", max_length=32), + ), + migrations.AddField( + model_name="aiinsight", + name="report_period", + field=models.CharField(blank=True, db_index=True, default="current", max_length=64), + ), + migrations.AddField( + model_name="aiinsight", + name="version", + field=models.CharField(blank=True, default="v1", max_length=32), + ), + migrations.AddField( + model_name="aiinsight", + name="source", + field=models.CharField( + blank=True, + choices=[ + ("generated", "Generated"), + ("cache", "Cache"), + ("local_fallback", "Local fallback"), + ], + default="generated", + max_length=32, + ), + ), + migrations.AddField( + model_name="aiinsight", + name="kesimpulan", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="aiinsight", + name="graded_facts", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="aiinsight", + name="citations", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="aiinsight", + name="expires_at", + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name="aiinsight", + name="alert", + field=models.CharField(blank=True, default="", max_length=100), + ), + migrations.AlterField( + model_name="aiinsight", + name="section", + field=models.CharField(blank=True, default="", max_length=100), + ), + migrations.AlterField( + model_name="aiinsight", + name="session", + field=models.CharField(blank=True, default="", max_length=100), + ), + migrations.AlterModelOptions( + name="aiinsight", + options={"ordering": ["-date", "-created_at"]}, + ), + migrations.AddConstraint( + model_name="aiinsight", + constraint=models.UniqueConstraint( + fields=("cycle", "kandang", "topic", "report_type", "report_period", "version"), + name="uniq_ai_insight_cache_scope", + ), + ), + ] diff --git a/backend/apps/operations/migrations/0013_remove_aiinsight_expires_at.py b/backend/apps/operations/migrations/0013_remove_aiinsight_expires_at.py new file mode 100644 index 0000000..528123a --- /dev/null +++ b/backend/apps/operations/migrations/0013_remove_aiinsight_expires_at.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("operations", "0012_ai_insight_cache_scope"), + ] + + operations = [ + migrations.RemoveField( + model_name="aiinsight", + name="expires_at", + ), + ] diff --git a/backend/apps/operations/migrations/0014_remove_aiinsight_section_session.py b/backend/apps/operations/migrations/0014_remove_aiinsight_section_session.py new file mode 100644 index 0000000..bff1f4d --- /dev/null +++ b/backend/apps/operations/migrations/0014_remove_aiinsight_section_session.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("operations", "0013_remove_aiinsight_expires_at"), + ] + + operations = [ + migrations.RemoveField( + model_name="aiinsight", + name="section", + ), + migrations.RemoveField( + model_name="aiinsight", + name="session", + ), + ] diff --git a/backend/apps/operations/migrations/0015_remove_aiinsight_version.py b/backend/apps/operations/migrations/0015_remove_aiinsight_version.py new file mode 100644 index 0000000..7575748 --- /dev/null +++ b/backend/apps/operations/migrations/0015_remove_aiinsight_version.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("operations", "0014_remove_aiinsight_section_session"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="aiinsight", + name="uniq_ai_insight_cache_scope", + ), + migrations.RemoveField( + model_name="aiinsight", + name="version", + ), + migrations.AddConstraint( + model_name="aiinsight", + constraint=models.UniqueConstraint( + fields=("cycle", "kandang", "topic", "report_type", "report_period"), + name="uniq_ai_insight_cache_scope", + ), + ), + ] diff --git a/backend/apps/operations/migrations/0016_aiinsight_summary_drop_graded_facts.py b/backend/apps/operations/migrations/0016_aiinsight_summary_drop_graded_facts.py new file mode 100644 index 0000000..dded8d1 --- /dev/null +++ b/backend/apps/operations/migrations/0016_aiinsight_summary_drop_graded_facts.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("operations", "0015_remove_aiinsight_version"), + ] + + operations = [ + migrations.RenameField( + model_name="aiinsight", + old_name="kesimpulan", + new_name="summary", + ), + migrations.RemoveField( + model_name="aiinsight", + name="graded_facts", + ), + ] diff --git a/backend/apps/operations/migrations/0017_aiinsight_citations_text.py b/backend/apps/operations/migrations/0017_aiinsight_citations_text.py new file mode 100644 index 0000000..a196a4c --- /dev/null +++ b/backend/apps/operations/migrations/0017_aiinsight_citations_text.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("operations", "0016_aiinsight_summary_drop_graded_facts"), + ] + + operations = [ + migrations.AlterField( + model_name="aiinsight", + name="citations", + field=models.TextField(blank=True, default="[]"), + ), + ] diff --git a/backend/apps/operations/models.py b/backend/apps/operations/models.py index 05df92f..612dab6 100644 --- a/backend/apps/operations/models.py +++ b/backend/apps/operations/models.py @@ -27,22 +27,47 @@ class IotPanel(models.Model): class AIInsight(models.Model): + SOURCE_GENERATED = "generated" + SOURCE_CACHE = "cache" + SOURCE_LOCAL_FALLBACK = "local_fallback" + SOURCE_CHOICES = [ + (SOURCE_GENERATED, "Generated"), + (SOURCE_CACHE, "Cache"), + (SOURCE_LOCAL_FALLBACK, "Local fallback"), + ] + date = models.DateField() insight_text = models.TextField() - alert = models.CharField(max_length=100) - section = models.CharField(max_length=100) - session = models.CharField(max_length=100) + alert = models.CharField(max_length=100, blank=True, default="") cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="ai_insights") + kandang = models.ForeignKey( + "farms.Kandang", + on_delete=models.CASCADE, + related_name="ai_insights", + null=True, + blank=True, + ) + topic = models.CharField(max_length=64, blank=True, default="", db_index=True) + report_type = models.CharField(max_length=32, blank=True, default="page", db_index=True) + report_period = models.CharField(max_length=64, blank=True, default="current", db_index=True) + source = models.CharField( + max_length=32, + choices=SOURCE_CHOICES, + default=SOURCE_GENERATED, + blank=True, + ) + summary = models.TextField(blank=True, default="") + citations = models.TextField(blank=True, default="[]") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = "ai_insight" - ordering = ["-date"] + ordering = ["-date", "-created_at"] constraints = [ models.UniqueConstraint( - fields=["cycle", "date", "section", "session"], - name="uniq_ai_insight_cycle_date_section_session", + fields=["cycle", "kandang", "topic", "report_type", "report_period"], + name="uniq_ai_insight_cache_scope", ) ] diff --git a/backend/apps/operations/serializers.py b/backend/apps/operations/serializers.py index 0affb12..4e2d716 100644 --- a/backend/apps/operations/serializers.py +++ b/backend/apps/operations/serializers.py @@ -138,3 +138,10 @@ class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSeria model = AIInsight fields = "__all__" read_only_fields = ["id", "created_at", "updated_at"] + + def to_representation(self, instance): + from apps.operations.services.insight_service import decode_citations + + data = super().to_representation(instance) + data["citations"] = decode_citations(instance.citations) + return data diff --git a/backend/apps/operations/services/cp707_knowledge.py b/backend/apps/operations/services/cp707_knowledge.py new file mode 100644 index 0000000..dfa81c9 --- /dev/null +++ b/backend/apps/operations/services/cp707_knowledge.py @@ -0,0 +1,746 @@ +""" +CP 707 Knowledge Base + +Sumber: Buku "Manajemen Broiler CP 707" oleh PT Charoen Pokphand Indonesia, Tbk. +Edisi Juli 2023 + +Seluruh standar teknis dari buku panduan CP 707 untuk referensi on-premise AI Insight. +TIDAK ada data dari sumber luar — hanya dari buku ini. + +Port murni Python 3.11+ dari cp707Knowledge.js (tanpa dependensi Django). +""" + +from __future__ import annotations + +from typing import Any + +# ─── Standar Performa Mingguan CP 707 ─────────────────────────────────────── +PERFORMANCE_STANDARD_WEEKLY: list[dict[str, Any]] = [ + {"week": 1, "targetBW_g": 195, "adg_g": 34, "cumFeedConsumption_g": 164.5, "fcr": 0.844}, + {"week": 2, "targetBW_g": 499, "adg_g": 50, "cumFeedConsumption_g": 530.5, "fcr": 1.063}, + {"week": 3, "targetBW_g": 954, "adg_g": 80, "cumFeedConsumption_g": 1181.5, "fcr": 1.238}, + {"week": 4, "targetBW_g": 1543, "adg_g": 88, "cumFeedConsumption_g": 2198.5, "fcr": 1.425}, + {"week": 5, "targetBW_g": 2191, "adg_g": 94, "cumFeedConsumption_g": 3461, "fcr": 1.580}, +] + +# ─── Standar Performa Harian CP 707 (Lampiran 2) ──────────────────────────── +PERFORMANCE_STANDARD_DAILY: list[dict[str, Any]] = [ + {"day": 1, "bw_g": 57, "adg_g": 15, "mortalityCum_pct": 0.40, "feedDaily_g": 13, "feedCum_g": 13, "fcr": 0.228, "ip": None}, + {"day": 2, "bw_g": 73, "adg_g": 16, "mortalityCum_pct": 0.50, "feedDaily_g": 17, "feedCum_g": 30, "fcr": 0.411, "ip": None}, + {"day": 3, "bw_g": 90, "adg_g": 17, "mortalityCum_pct": 0.60, "feedDaily_g": 20.5, "feedCum_g": 50.5, "fcr": 0.561, "ip": None}, + {"day": 4, "bw_g": 110, "adg_g": 20, "mortalityCum_pct": 0.70, "feedDaily_g": 23, "feedCum_g": 73.5, "fcr": 0.668, "ip": None}, + {"day": 5, "bw_g": 134, "adg_g": 24, "mortalityCum_pct": 0.80, "feedDaily_g": 26, "feedCum_g": 99.5, "fcr": 0.743, "ip": None}, + {"day": 6, "bw_g": 161, "adg_g": 27, "mortalityCum_pct": 0.90, "feedDaily_g": 31, "feedCum_g": 130.5, "fcr": 0.811, "ip": None}, + {"day": 7, "bw_g": 195, "adg_g": 34, "mortalityCum_pct": 1.00, "feedDaily_g": 34, "feedCum_g": 164.5, "fcr": 0.844, "ip": 327}, + {"day": 8, "bw_g": 231, "adg_g": 36, "mortalityCum_pct": 1.10, "feedDaily_g": 35, "feedCum_g": 199.5, "fcr": 0.864, "ip": 331}, + {"day": 9, "bw_g": 269, "adg_g": 38, "mortalityCum_pct": 1.20, "feedDaily_g": 41, "feedCum_g": 240.5, "fcr": 0.894, "ip": 330}, + {"day": 10, "bw_g": 311, "adg_g": 42, "mortalityCum_pct": 1.30, "feedDaily_g": 46, "feedCum_g": 286.5, "fcr": 0.921, "ip": 333}, + {"day": 11, "bw_g": 355, "adg_g": 44, "mortalityCum_pct": 1.40, "feedDaily_g": 52, "feedCum_g": 338.5, "fcr": 0.954, "ip": 334}, + {"day": 12, "bw_g": 401, "adg_g": 46, "mortalityCum_pct": 1.50, "feedDaily_g": 58, "feedCum_g": 396.5, "fcr": 0.989, "ip": 333}, + {"day": 13, "bw_g": 449, "adg_g": 48, "mortalityCum_pct": 1.60, "feedDaily_g": 64, "feedCum_g": 460.5, "fcr": 1.026, "ip": 331}, + {"day": 14, "bw_g": 499, "adg_g": 50, "mortalityCum_pct": 1.70, "feedDaily_g": 70, "feedCum_g": 530.5, "fcr": 1.063, "ip": 330}, + {"day": 15, "bw_g": 552, "adg_g": 53, "mortalityCum_pct": 1.80, "feedDaily_g": 73, "feedCum_g": 603.5, "fcr": 1.093, "ip": 331}, + {"day": 16, "bw_g": 609, "adg_g": 57, "mortalityCum_pct": 1.90, "feedDaily_g": 80, "feedCum_g": 683.5, "fcr": 1.122, "ip": 333}, + {"day": 17, "bw_g": 669, "adg_g": 60, "mortalityCum_pct": 2.00, "feedDaily_g": 86, "feedCum_g": 769.5, "fcr": 1.150, "ip": 335}, + {"day": 18, "bw_g": 734, "adg_g": 65, "mortalityCum_pct": 2.10, "feedDaily_g": 92, "feedCum_g": 861.5, "fcr": 1.174, "ip": 340}, + {"day": 19, "bw_g": 802, "adg_g": 68, "mortalityCum_pct": 2.20, "feedDaily_g": 100, "feedCum_g": 961.5, "fcr": 1.199, "ip": 344}, + {"day": 20, "bw_g": 874, "adg_g": 72, "mortalityCum_pct": 2.30, "feedDaily_g": 107, "feedCum_g": 1068.5, "fcr": 1.223, "ip": 349}, + {"day": 21, "bw_g": 954, "adg_g": 80, "mortalityCum_pct": 2.40, "feedDaily_g": 113, "feedCum_g": 1181.5, "fcr": 1.238, "ip": 358}, + {"day": 22, "bw_g": 1035, "adg_g": 81, "mortalityCum_pct": 2.52, "feedDaily_g": 128, "feedCum_g": 1309.5, "fcr": 1.265, "ip": 362}, + {"day": 23, "bw_g": 1117, "adg_g": 82, "mortalityCum_pct": 2.64, "feedDaily_g": 133, "feedCum_g": 1442.5, "fcr": 1.291, "ip": 366}, + {"day": 24, "bw_g": 1200, "adg_g": 83, "mortalityCum_pct": 2.76, "feedDaily_g": 139, "feedCum_g": 1581.5, "fcr": 1.318, "ip": 369}, + {"day": 25, "bw_g": 1284, "adg_g": 84, "mortalityCum_pct": 2.88, "feedDaily_g": 145, "feedCum_g": 1726.5, "fcr": 1.345, "ip": 371}, + {"day": 26, "bw_g": 1369, "adg_g": 85, "mortalityCum_pct": 3.00, "feedDaily_g": 151, "feedCum_g": 1877.5, "fcr": 1.371, "ip": 372}, + {"day": 27, "bw_g": 1455, "adg_g": 86, "mortalityCum_pct": 3.12, "feedDaily_g": 157, "feedCum_g": 2034.5, "fcr": 1.398, "ip": 373}, + {"day": 28, "bw_g": 1543, "adg_g": 88, "mortalityCum_pct": 3.25, "feedDaily_g": 164, "feedCum_g": 2198.5, "fcr": 1.425, "ip": 374}, + {"day": 29, "bw_g": 1633, "adg_g": 90, "mortalityCum_pct": 3.38, "feedDaily_g": 173, "feedCum_g": 2371.5, "fcr": 1.452, "ip": 375}, + {"day": 30, "bw_g": 1725, "adg_g": 92, "mortalityCum_pct": 3.52, "feedDaily_g": 176.5, "feedCum_g": 2548, "fcr": 1.477, "ip": 376}, + {"day": 31, "bw_g": 1817, "adg_g": 92, "mortalityCum_pct": 3.66, "feedDaily_g": 178, "feedCum_g": 2726, "fcr": 1.500, "ip": 376}, + {"day": 32, "bw_g": 1910, "adg_g": 93, "mortalityCum_pct": 3.80, "feedDaily_g": 180, "feedCum_g": 2906, "fcr": 1.521, "ip": 377}, + {"day": 33, "bw_g": 2003, "adg_g": 93, "mortalityCum_pct": 3.95, "feedDaily_g": 183, "feedCum_g": 3089, "fcr": 1.542, "ip": 378}, + {"day": 34, "bw_g": 2097, "adg_g": 94, "mortalityCum_pct": 4.10, "feedDaily_g": 185, "feedCum_g": 3274, "fcr": 1.561, "ip": 379}, + {"day": 35, "bw_g": 2191, "adg_g": 94, "mortalityCum_pct": 4.25, "feedDaily_g": 187, "feedCum_g": 3461, "fcr": 1.580, "ip": 379}, + {"day": 36, "bw_g": 2285, "adg_g": 94, "mortalityCum_pct": 4.45, "feedDaily_g": 190, "feedCum_g": 3651, "fcr": 1.598, "ip": 380}, + {"day": 37, "bw_g": 2380, "adg_g": 95, "mortalityCum_pct": 4.65, "feedDaily_g": 193, "feedCum_g": 3844, "fcr": 1.615, "ip": 380}, +] + +# ─── Target Suhu Pemeliharaan per Umur (Buku CP 707) ──────────────────────── +TEMPERATURE_STANDARD: list[dict[str, Any]] = [ + {"ageDay_from": 1, "ageDay_to": 2, "temp_C": 32, "humidity_pct_min": 50, "humidity_pct_max": 70}, + {"ageDay_from": 3, "ageDay_to": 4, "temp_C": 31, "humidity_pct_min": 50, "humidity_pct_max": 70}, + {"ageDay_from": 5, "ageDay_to": 7, "temp_C": 30, "humidity_pct_min": 50, "humidity_pct_max": 70}, + {"ageDay_from": 8, "ageDay_to": 14, "temp_C": 29, "humidity_pct_min": 50, "humidity_pct_max": 70}, + {"ageDay_from": 15, "ageDay_to": 21, "temp_C": 28, "humidity_pct_min": 50, "humidity_pct_max": 70}, + {"ageDay_from": 22, "ageDay_to": 28, "temp_C": 26, "humidity_pct_min": 50, "humidity_pct_max": 70}, + {"ageDay_from": 29, "ageDay_to": 35, "temp_C": 23, "humidity_pct_min": 50, "humidity_pct_max": 70}, + {"ageDay_from": 36, "ageDay_to": 99, "temp_C": 22, "humidity_pct_min": 50, "humidity_pct_max": 70}, +] + +# ─── Target Efektif Temperatur (TET) per Umur ─────────────────────────────── +TARGET_EFFECTIVE_TEMPERATURE: list[dict[str, Any]] = [ + {"ageDay_from": 1, "ageDay_to": 2, "tet_C": 32}, + {"ageDay_from": 3, "ageDay_to": 4, "tet_C": 31}, + {"ageDay_from": 5, "ageDay_to": 7, "tet_C": 30}, + {"ageDay_from": 8, "ageDay_to": 14, "tet_C": 29}, + {"ageDay_from": 15, "ageDay_to": 21, "tet_C": 27}, + {"ageDay_from": 22, "ageDay_to": 28, "tet_C": 25}, + {"ageDay_from": 29, "ageDay_to": 35, "tet_C": 22}, + {"ageDay_from": 36, "ageDay_to": 99, "tet_C": 21}, +] + +# ─── Standar Kualitas Udara (Lampiran 3) ──────────────────────────────────── +AIR_QUALITY_STANDARD: dict[str, Any] = { + "ammonia": { + "ideal_pct": "<10 ppm", + "warning": 10, # >10 ppm: merusak permukaan paru-paru + "critical": 25, # >25 ppm: pertumbuhan menurun + "severe": 50, # >50 ppm: pertumbuhan menurun signifikan + "note": ">20 ppm lebih rentan terhadap penyakit pernapasan", + }, + "co2": { + "ideal": "<3000 ppm", + "critical": 3500, # >3500 ppm: ascites dan kematian tinggi + }, + "co": { + "ideal": "10 ppm", + "warning": 50, # >50 ppm mempengaruhi kesehatan + "critical": 100, # 100 ppm: meningkatkan angka kematian + }, + "humidity": { + "ideal_after_brooding": "50-60%", + "warning_high": 70, # >70% pada suhu >29°C berpengaruh pada pertumbuhan + "warning_low": 50, # RH <50% selama brooding berpengaruh pada pertumbuhan + }, +} + +# ─── Standar Mortalitas CP 707 ─────────────────────────────────────────────── +MORTALITY_THRESHOLDS: dict[str, Any] = { + "normal_pct": 5, # <5% mortalitas dianggap normal + "warning_pct": 7, # 5-7% perlu perhatian + "critical_pct": 7, # >7% kritis + # Standar kumulatif per umur (dari tabel harian lampiran 2) + "byDayStandard": [ + {"day": d["day"], "mortalityCumStd_pct": d["mortalityCum_pct"]} + for d in PERFORMANCE_STANDARD_DAILY + ], +} + +# ─── Kepadatan Kandang (Buku CP 707) ───────────────────────────────────────── +DENSITY_STANDARD: dict[str, Any] = { + "openHouse": { + "minKgPerM2": 12, + "maxKgPerM2": 13, + "description": "Kandang terbuka dengan ventilasi alami", + }, + "closedHouse": { + "minKgPerM2": 24, + "maxKgPerM2": 30, + "description": "Kandang tertutup dapat mencapai 24-30 kg/m2", + }, + "byHarvestWeight": [ + {"minBW_kg": 0.80, "maxBW_kg": 0.99, "density_ekorPerM2_min": 11.0, "density_ekorPerM2_max": 11.1}, + {"minBW_kg": 1.00, "maxBW_kg": 1.19, "density_ekorPerM2_min": 10.0, "density_ekorPerM2_max": 10.5}, + {"minBW_kg": 1.20, "maxBW_kg": 1.39, "density_ekorPerM2_min": 9.0, "density_ekorPerM2_max": 9.5}, + {"minBW_kg": 1.40, "maxBW_kg": 1.59, "density_ekorPerM2_min": 8.0, "density_ekorPerM2_max": 8.5}, + {"minBW_kg": 1.60, "maxBW_kg": 1.89, "density_ekorPerM2_min": 7.5, "density_ekorPerM2_max": 8.0}, + {"minBW_kg": 1.90, "maxBW_kg": 99, "density_ekorPerM2_min": 7.0, "density_ekorPerM2_max": 7.5}, + ], +} + +# ─── Konsumsi Air per 1000 ekor per hari (suhu 21°C) ───────────────────────── +WATER_CONSUMPTION_STANDARD: list[dict[str, Any]] = [ + {"week": 1, "minLiter": 58, "maxLiter": 65}, + {"week": 2, "minLiter": 102, "maxLiter": 115}, + {"week": 3, "minLiter": 149, "maxLiter": 167}, + {"week": 4, "minLiter": 192, "maxLiter": 216}, + {"week": 5, "minLiter": 232, "maxLiter": 261}, + {"week": 6, "minLiter": 274, "maxLiter": 308}, + {"week": 7, "minLiter": 309, "maxLiter": 347}, + {"week": 8, "minLiter": 342, "maxLiter": 385}, +] +# Catatan: Di atas 21°C, kebutuhan air meningkat rata-rata 6.5% per kenaikan 1°C +WATER_INCREASE_PER_DEGREE_ABOVE_21C_PCT = 6.5 + +# ─── Program Pencahayaan CP 707 ─────────────────────────────────────────────── +LIGHTING_PROGRAM: list[dict[str, Any]] = [ + {"ageDay_from": 2, "ageDay_to": 7, "onHours": 23, "darkFrom": "20:00", "darkTo": "21:00"}, + {"ageDay_from": 8, "ageDay_to": 14, "onHours": 22, "darkFrom": "20:00", "darkTo": "22:00"}, + {"ageDay_from": 15, "ageDay_to": 20, "onHours": 20, "darkFrom": "20:00", "darkTo": "23:00"}, + {"ageDay_from": 21, "ageDay_to": 28, "onHours": 20, "darkFrom": "20:00", "darkTo": "24:00"}, + {"ageDay_from": 29, "ageDay_to": 99, "onHours": 23, "darkFrom": "20:00", "darkTo": "22:00"}, +] +LIGHTING_MIN_INTENSITY_LUX = 25 +LIGHTING_BROODING_OPTIMAL_LUX = "40-60" +LIGHTING_AFTER_15DAYS_LUX = 5 + + +def _daily_row(day_age: Any) -> dict[str, Any] | None: + try: + day = int(day_age) + except (TypeError, ValueError): + return None + for row in PERFORMANCE_STANDARD_DAILY: + if row["day"] == day: + return row + return None + + +def _as_float(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + n = float(value) + except (TypeError, ValueError): + return None + if n != n: # NaN + return None + return n + + +def _direction_from_delta(delta_pct: float, *, band: float = 5.0) -> str: + if delta_pct > band: + return "di_atas_standar" + if delta_pct < -band: + return "di_bawah_standar" + return "sesuai_standar" + + +def get_fcr_standard_by_day(day_age: Any) -> float | None: + std = _daily_row(day_age) + if std: + return float(std["fcr"]) + try: + day = int(day_age) + except (TypeError, ValueError): + return None + if day > 37: + return 1.65 + return None + + +def get_bw_standard_by_day(day_age: Any) -> float | None: + std = _daily_row(day_age) + if std: + return float(std["bw_g"]) + try: + day = int(day_age) + except (TypeError, ValueError): + return None + if day > 37: + return 2500.0 + return None + + +def get_temp_standard_by_day(day_age: Any) -> dict[str, Any]: + try: + day = int(day_age) + except (TypeError, ValueError): + return dict(TEMPERATURE_STANDARD[-1]) + for row in TEMPERATURE_STANDARD: + if row["ageDay_from"] <= day <= row["ageDay_to"]: + return dict(row) + return dict(TEMPERATURE_STANDARD[-1]) + + +def get_tet_by_day(day_age: Any) -> float: + try: + day = int(day_age) + except (TypeError, ValueError): + return 21.0 + for row in TARGET_EFFECTIVE_TEMPERATURE: + if row["ageDay_from"] <= day <= row["ageDay_to"]: + return float(row["tet_C"]) + return 21.0 + + +def get_ip_standard_by_day(day_age: Any) -> int | None: + """ + Standar IP (Indeks Performans / EEF) menurut Lampiran 2 buku CP 707. + + Kolom IP di tabel buku hanya terisi mulai hari ke-7 dan berhenti di hari + ke-37 (nilai 380). Di luar rentang itu buku TIDAK menyatakan standar, jadi + fungsi ini mengembalikan None — jangan menggantinya dengan tebakan. + """ + std = _daily_row(day_age) + if std is None: + return None + ip = std.get("ip") + return int(ip) if isinstance(ip, (int, float)) else None + + +def get_ip_standard_range() -> dict[str, int] | None: + """Rentang IP yang tercantum di buku, untuk konteks saat hari di luar tabel.""" + values = [int(d["ip"]) for d in PERFORMANCE_STANDARD_DAILY if isinstance(d.get("ip"), (int, float))] + if not values: + return None + return {"min": min(values), "max": max(values), "lastDay": 37} + + +def get_mortality_cum_std_by_day(day_age: Any) -> float | None: + std = _daily_row(day_age) + if std: + return float(std["mortalityCum_pct"]) + try: + day = int(day_age) + except (TypeError, ValueError): + return None + if day > 37: + return 4.65 + ((day - 37) * 0.1) + return None + + +def analyze_fcr(actual_fcr: Any, day_age: Any) -> dict[str, Any]: + actual = _as_float(actual_fcr) + std_fcr = get_fcr_standard_by_day(day_age) + if actual is None or std_fcr is None: + return { + "actual": actual, + "standard": std_fcr, + "delta_pct": None, + "direction": "unknown", + "status": "unknown", + "message": "Umur di luar standar tabel CP 707." if std_fcr is None else "FCR aktual tidak tersedia.", + } + + delta_pct = round(((actual - std_fcr) / std_fcr) * 100, 1) + direction = _direction_from_delta(delta_pct) + + if delta_pct > 10: + status = "critical" + message = ( + f"FCR {actual:.2f} melebihi standar CP 707 ({std_fcr:.3f}) sebesar {delta_pct:.1f}% " + f"pada umur hari ke-{day_age}. Periksa potensi pakan tercecer dan kualitas pakan." + ) + elif delta_pct > 5: + status = "warning" + message = ( + f"FCR {actual:.2f} sedikit di atas standar CP 707 ({std_fcr:.3f}) " + f"pada umur hari ke-{day_age}. Atur ketinggian piringan pakan dan evaluasi fines dalam pakan." + ) + elif delta_pct < -5: + status = "ok" + message = ( + f"FCR {actual:.2f} lebih baik dari standar CP 707 ({std_fcr:.3f}) " + f"pada umur hari ke-{day_age}. Pertahankan manajemen pakan." + ) + else: + status = "ok" + message = f"FCR {actual:.2f} sesuai standar CP 707 ({std_fcr:.3f}) pada umur hari ke-{day_age}." + + return { + "actual": actual, + "standard": std_fcr, + "delta_pct": float(delta_pct), + "direction": direction, + "status": status, + "message": message, + } + + +def analyze_bw(actual_bw_g: Any, day_age: Any) -> dict[str, Any]: + actual = _as_float(actual_bw_g) + std_bw = get_bw_standard_by_day(day_age) + if actual is None or std_bw is None: + return { + "actual": actual, + "standard": std_bw, + "delta_pct": None, + "direction": "unknown", + "status": "unknown", + "message": "Umur di luar standar tabel CP 707." if std_bw is None else "Bobot aktual tidak tersedia.", + } + + delta_pct = round(((actual - std_bw) / std_bw) * 100, 1) + direction = _direction_from_delta(delta_pct) + + if delta_pct < -15: + status = "critical" + message = ( + f"Bobot badan aktual {actual:g}g jauh di bawah standar CP 707 ({std_bw:g}g) " + f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Lakukan grading segera." + ) + elif delta_pct < -5: + status = "warning" + message = ( + f"Bobot badan aktual {actual:g}g sedikit di bawah standar CP 707 ({std_bw:g}g) " + f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Tingkatkan stimulasi pakan." + ) + elif delta_pct > 10: + status = "ok" + message = ( + f"Bobot badan aktual {actual:g}g melampaui standar CP 707 ({std_bw:g}g) " + f"pada hari ke-{day_age}. Pertumbuhan sangat baik." + ) + else: + status = "ok" + message = f"Bobot badan aktual {actual:g}g sesuai standar CP 707 ({std_bw:g}g) pada hari ke-{day_age}." + + return { + "actual": actual, + "standard": std_bw, + "delta_pct": float(delta_pct), + "direction": direction, + "status": status, + "message": message, + } + + +def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]: + rate = _as_float(mortality_rate_pct) + std_mortality = get_mortality_cum_std_by_day(day_age) + + if rate is None: + return { + "status": "unknown", + "direction": "unknown", + "stdMortality": std_mortality, + "message": "Data mortalitas tidak tersedia.", + } + + if rate > MORTALITY_THRESHOLDS["critical_pct"]: + status = "critical" + direction = "di_atas_standar" + message = ( + f"Mortalitas kumulatif {rate:.2f}% melebihi batas kritis CP 707 (>7%). " + "Lakukan nekropsi darurat dan perketat biosekuriti." + ) + elif rate >= MORTALITY_THRESHOLDS["warning_pct"]: + status = "warning" + direction = "di_atas_standar" + message = f"Mortalitas kumulatif {rate:.2f}% perlu diwaspadai. Standar CP 707 <5%." + elif std_mortality is not None and rate > std_mortality * 1.5: + status = "warning" + direction = "di_atas_standar" + message = ( + f"Mortalitas {rate:.2f}% melebihi standar kumulatif harian CP 707 " + f"({std_mortality:.2f}%) pada hari ke-{day_age}." + ) + else: + status = "ok" + direction = "sesuai_standar" if (std_mortality is None or rate <= std_mortality) else "di_atas_standar" + message = f"Mortalitas {rate:.2f}% dalam batas normal CP 707 (<5%)." + + return { + "status": status, + "direction": direction, + "stdMortality": std_mortality, + "message": message, + } + + +def analyze_environment( + temp_c: Any, + humidity_pct: Any, + ammonia_ppm: Any, + day_age: Any, +) -> dict[str, Any]: + temp = _as_float(temp_c) + humidity = _as_float(humidity_pct) + ammonia = _as_float(ammonia_ppm) + + if temp is None: + return { + "status": "unknown", + "direction": "unknown", + "issues": [], + "tempStd": get_temp_standard_by_day(day_age), + "tet": get_tet_by_day(day_age), + "message": "Data suhu tidak tersedia.", + } + + if humidity is None: + humidity = 65.0 + if ammonia is None: + ammonia = 0.0 + + temp_std = get_temp_standard_by_day(day_age) + tet = get_tet_by_day(day_age) + issues: list[str] = [] + overall_status = "ok" + + if temp > temp_std["temp_C"] + 4: + issues.append( + f"Suhu {temp:.1f}°C jauh di atas target CP 707 ({temp_std['temp_C']}°C) " + f"untuk umur hari ke-{day_age}. Nyalakan cooling pad/exhaust fan segera." + ) + overall_status = "critical" + elif temp > temp_std["temp_C"] + 2: + issues.append( + f"Suhu {temp:.1f}°C di atas target CP 707 ({temp_std['temp_C']}°C) " + f"untuk umur hari ke-{day_age}. Tingkatkan ventilasi." + ) + if overall_status != "critical": + overall_status = "warning" + elif temp < temp_std["temp_C"] - 3: + issues.append( + f"Suhu {temp:.1f}°C di bawah target CP 707 ({temp_std['temp_C']}°C). Nyalakan heater." + ) + if overall_status != "critical": + overall_status = "warning" + + if humidity < temp_std["humidity_pct_min"]: + issues.append( + f"Kelembapan {humidity:.1f}% di bawah standar CP 707 " + f"({temp_std['humidity_pct_min']}-{temp_std['humidity_pct_max']}%)." + ) + if overall_status != "critical": + overall_status = "warning" + elif humidity > temp_std["humidity_pct_max"]: + issues.append( + f"Kelembapan {humidity:.1f}% di atas standar CP 707 " + f"({temp_std['humidity_pct_min']}-{temp_std['humidity_pct_max']}%). " + "Periksa kebocoran nipple dan sekam basah." + ) + if overall_status != "critical": + overall_status = "warning" + + if ammonia > AIR_QUALITY_STANDARD["ammonia"]["critical"]: + issues.append( + f"Amonia {ammonia:.1f} ppm di atas batas kritis CP 707 (>25 ppm). " + "Pertumbuhan ayam menurun. Tingkatkan ventilasi segera dan gemburkan sekam basah." + ) + overall_status = "critical" + elif ammonia > AIR_QUALITY_STANDARD["ammonia"]["warning"]: + issues.append( + f"Amonia {ammonia:.1f} ppm melebihi batas aman CP 707 (<10 ppm). Tingkatkan sirkulasi udara." + ) + if overall_status != "critical": + overall_status = "warning" + + return { + "status": overall_status, + "direction": "sesuai_standar" if overall_status == "ok" else "di_atas_standar", + "issues": issues, + "tempStd": temp_std, + "tet": tet, + "message": ( + " ".join(issues) + if issues + else f"Lingkungan kandang sesuai standar CP 707 untuk umur hari ke-{day_age}." + ), + } + + +def build_book_reference_context(day_age: Any) -> str: + std_bw = get_bw_standard_by_day(day_age) + std_fcr = get_fcr_standard_by_day(day_age) + std_mortality = get_mortality_cum_std_by_day(day_age) + temp_std = get_temp_standard_by_day(day_age) + tet = get_tet_by_day(day_age) + + return f""" +=== REFERENSI STANDAR BUKU CP 707 (SUMBER TUNGGAL - PT CHAROEN POKPHAND INDONESIA) === +Umur ayam hari ke-{day_age}: +- Target bobot badan: {std_bw if std_bw is not None else 'N/A'} gram +- Target FCR: {std_fcr if std_fcr is not None else 'N/A'} +- Target mortalitas kumulatif normal: <5% (standar CP 707) +- Target mortalitas kumulatif standar hari ke-{day_age}: {str(std_mortality) + '%' if std_mortality is not None else 'N/A'} +- Target suhu kandang: {temp_std['temp_C']}°C +- Target kelembapan: {temp_std['humidity_pct_min']}%-{temp_std['humidity_pct_max']}% +- Target Efektif Temperatur (TET): {tet}°C +- Batas amonia aman: <10 ppm (ideal), >25 ppm = pertumbuhan menurun (dari Lampiran 3 CP 707) +- Batas CO2 aman: <3000 ppm (>3500 ppm = ascites & kematian tinggi) +- Konsumsi air normal: 2-2.5x konsumsi pakan + +CATATAN PENTING: +- Analisis HANYA berdasarkan standar buku CP 707 +- Jika ada data yang tidak ada di buku CP 707, nyatakan "data tidak tersedia di buku CP 707" +- Risiko yang disebutkan HARUS berdasarkan data aktual vs standar CP 707 +=== END REFERENSI === +""" + + +def build_cp707_standard_block(context_data: dict[str, Any] | None) -> str: + """ + Blok standar CP 707 berlabel satuan untuk hari yang sedang dilihat + (setara buildCp707StandardBlock di aiInsights.js lama). + """ + if not isinstance(context_data, dict): + return "" + + raw = None + for key in ("hari_ke", "hari_terakhir", "currentDay", "dayAge"): + if context_data.get(key) is not None: + raw = context_data.get(key) + break + day_f = _as_float(raw) + if day_f is None or day_f <= 0: + return "" + day = int(day_f) + + ip = get_ip_standard_by_day(day) + ip_range = get_ip_standard_range() + bw = get_bw_standard_by_day(day) + fcr = get_fcr_standard_by_day(day) + mort = get_mortality_cum_std_by_day(day) + + lines = [ + f"- Bobot badan standar hari ke-{day}: {str(bw) + ' gram' if bw is not None else 'tidak tercantum di buku'}", + f"- FCR standar hari ke-{day}: {str(fcr) + ' (rasio, tanpa satuan)' if fcr is not None else 'tidak tercantum di buku'}", + f"- Mortalitas kumulatif standar hari ke-{day}: {str(mort) + ' %' if mort is not None else 'tidak tercantum di buku'}", + ] + if mort is not None: + hidup = round((100 - mort) * 100) / 100 + lines.append( + f"- Persen hidup standar hari ke-{day}: {hidup} % " + "(turunan langsung dari mortalitas standar di atas — pakai angka ini, jangan menghitung sendiri)" + ) + else: + lines.append(f"- Persen hidup standar hari ke-{day}: tidak tersedia") + + if ip is not None: + lines.append(f"- IP/EEF standar hari ke-{day}: {ip} (indeks, TANPA satuan)") + else: + last_day = ip_range["lastDay"] if ip_range else 37 + rng = f"{ip_range['min']}-{ip_range['max']}" if ip_range else "327-380" + lines.append( + f"- IP/EEF standar hari ke-{day}: TIDAK tercantum di buku. " + f"Kolom IP pada Lampiran 2 hanya terisi hari ke-7 s/d ke-{last_day} dengan rentang {rng}. " + "Jangan mengarang standar untuk hari ini." + ) + + joined = "\n".join(lines) + return f""" +═══════════════════════════════════════════════ +STANDAR CP 707 UNTUK HARI INI (angka resmi dari Lampiran 2 buku) +{joined} + +ATURAN WAJIB saat membandingkan dengan standar: +- Pakai HANYA angka di blok ini sebagai standar. DILARANG mengambil angka + standar dari tabel mentah di kutipan buku di bawah — kolomnya tidak berjudul, + dan angka pakan (gram) sering tertukar menjadi standar IP/EEF. +- Sebutkan satuan dengan benar: gram untuk bobot dan pakan, persen untuk + mortalitas, dan IP/EEF adalah indeks TANPA satuan. +- Jika standar untuk suatu metrik tidak tercantum, tulis "standar tidak + tersedia di buku untuk hari ini" — jangan mengganti dengan angka lain. +- Semua angka aktual harus berasal dari [Data Halaman (JSON)]. Dilarang + menghitung sendiri atau mengarang angka yang tidak ada di sana. +═══════════════════════════════════════════════""" + + +def _nested_get(obj: Any, *path: str) -> Any: + cur = obj + for key in path: + if not isinstance(cur, dict): + return None + cur = cur.get(key) + return cur + + +def _first_number(*candidates: Any) -> float | None: + for value in candidates: + n = _as_float(value) + if n is not None: + return n + return None + + +def _resolve_day_age(context_pack: dict[str, Any]) -> int | None: + day = _first_number( + context_pack.get("hari_ke"), + _nested_get(context_pack, "metadata", "currentDay"), + _nested_get(context_pack, "cycle", "currentAgeDay"), + context_pack.get("ageDay"), + context_pack.get("umurHari"), + context_pack.get("currentDay"), + context_pack.get("dayAge"), + ) + if day is None or day <= 0: + return None + return int(day) + + +def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None: + """Bobot dalam gram. Field `_kg` hanya untuk legacy payload (dikonversi ×1000).""" + grams = _first_number( + context_pack.get("bobot_rata_rata_gram"), + context_pack.get("bobot_iot_gram_terakhir"), + context_pack.get("bobot_iot_gram"), + _nested_get(context_pack, "weightStats", "averageWeight", "value"), + _nested_get(context_pack, "weight", "current", "averageWeight"), + context_pack.get("berat_rata_rata"), + ) + if grams is not None: + return grams + + kg = _first_number( + context_pack.get("bobot_iot_kg_terakhir"), + context_pack.get("bobot_iot_kg"), + ) + if kg is not None: + return kg * 1000.0 + return None + + +def _resolve_mortality(context_pack: dict[str, Any]) -> float | None: + return _first_number( + context_pack.get("mortalitas_kumulatif_pct"), + context_pack.get("mortalitas_persen"), + context_pack.get("mortalityRate"), + context_pack.get("mortality_rate_pct"), + _nested_get(context_pack, "chickenCounting", "dashboardSummary", "mortalityRate"), + _nested_get(context_pack, "chickenCounting", "mortality", "mortalityCount"), + ) + + +def _resolve_iot(context_pack: dict[str, Any]) -> dict[str, Any]: + for key in ("iotPanel", "panel_iot", "telemetry", "iotData", "iot"): + raw = context_pack.get(key) + if isinstance(raw, dict): + display = raw.get("display") + if isinstance(display, dict): + return display + return raw + # Flat InsightContext fields (rebuild) + return context_pack + + +def analyze_with_cp707_standards(context_pack: dict[str, Any] | None) -> dict[str, Any]: + """Analisis komprehensif; toleran terhadap field InsightContext rebuild dan pack lama.""" + pack = context_pack if isinstance(context_pack, dict) else {} + day_age = _resolve_day_age(pack) + result: dict[str, Any] = {"dayAge": day_age, "analyses": {}} + + actual_bw = _resolve_bw_grams(pack) + if actual_bw is not None and day_age is not None: + result["analyses"]["bw"] = analyze_bw(actual_bw, day_age) + + actual_fcr = _first_number( + pack.get("fcr_terakhir"), + pack.get("fcr"), + _nested_get(pack, "fcr_eef", "fcr", "actual"), + _nested_get(pack, "weight", "current", "fcr"), + _nested_get(pack, "fcrSummary", "current"), + ) + if actual_fcr is not None and day_age is not None: + result["analyses"]["fcr"] = analyze_fcr(actual_fcr, day_age) + + mortality_rate = _resolve_mortality(pack) + if mortality_rate is not None: + result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age) + + iot = _resolve_iot(pack) + temp = _first_number( + iot.get("suhu_rata_rata_C"), + iot.get("avgTemp"), + pack.get("suhu_rata_rata_C"), + ) + hum = _first_number( + iot.get("kelembapan_persen"), + iot.get("humidity"), + pack.get("kelembapan_persen"), + ) + ammonia = _first_number( + iot.get("amonia_ppm"), + iot.get("ammonia"), + pack.get("amonia_ppm"), + ) + if temp is not None and day_age is not None: + result["analyses"]["environment"] = analyze_environment( + temp, + hum if hum is not None else 65.0, + ammonia if ammonia is not None else 0.0, + day_age, + ) + + return result diff --git a/backend/apps/operations/services/insight_service.py b/backend/apps/operations/services/insight_service.py new file mode 100644 index 0000000..c158b9c --- /dev/null +++ b/backend/apps/operations/services/insight_service.py @@ -0,0 +1,525 @@ +"""Unified AI Insight generate pipeline: grade → RAG → narrate → cache. + +Called from `AIInsightViewSet` (`POST generate/`, `GET cached/`). +Numbers/status come from `cp707_knowledge`; Ollama only narrates. +Architecture map: docs/ai-insight/README.md +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +import httpx +from django.conf import settings +from django.utils import timezone + +from apps.farms.models import Cycle, Kandang +from apps.operations.models import AIInsight +from apps.operations.services import cp707_knowledge as cp707 + +logger = logging.getLogger(__name__) + +_NUMERIC_PIPE_ROW = re.compile(r"^[\d.,]+(\s*\|\s*[\d.,]*)+$") + +# Stored in AIInsight.alert — condition of graded data, not narrative text. +ALERT_HEALTHY = "healthy" +ALERT_WARNING = "warning" +ALERT_CRITICAL = "critical" +ALERT_UNKNOWN = "unknown" +_ALERT_RANK = { + ALERT_UNKNOWN: 0, + ALERT_HEALTHY: 1, + ALERT_WARNING: 2, + ALERT_CRITICAL: 3, +} +_GRADER_STATUS_TO_ALERT = { + "ok": ALERT_HEALTHY, + "healthy": ALERT_HEALTHY, + "warning": ALERT_WARNING, + "critical": ALERT_CRITICAL, + "unknown": ALERT_UNKNOWN, +} + + +def alert_from_graded(graded: dict[str, Any] | None) -> str: + """Worst condition among graded analysis blocks (critical > warning > healthy).""" + analyses = (graded or {}).get("analyses") or {} + if not isinstance(analyses, dict) or not analyses: + return ALERT_UNKNOWN + + worst = ALERT_UNKNOWN + saw_status = False + for block in analyses.values(): + if not isinstance(block, dict): + continue + raw = block.get("status") + if raw is None: + continue + mapped = _GRADER_STATUS_TO_ALERT.get(str(raw).strip().lower()) + if mapped is None: + continue + saw_status = True + if _ALERT_RANK[mapped] > _ALERT_RANK[worst]: + worst = mapped + return worst if saw_status else ALERT_UNKNOWN + +ANTI_HALLUCINATION_RULES = """ +ATURAN MORTALITAS (WAJIB DIPATUHI — TIDAK BOLEH DILANGGAR): +- Standar CP 707: mortalitas kumulatif NORMAL adalah < 5%. +- Mortalitas >= 5% dan <= 7% = TINGGI (warning) — WAJIB disebut "TINGGI", bukan "rendah" atau "normal". +- Mortalitas > 7% = SANGAT TINGGI (critical) — WAJIB disebut "SANGAT TINGGI". +- Mortalitas < 5% = rendah/normal. +- DILARANG KERAS menyebut mortalitas sebagai "rendah" atau "normal" jika nilainya >= 5%. + +ATURAN ANGKA & ARAH (WAJIB DIPATUHI): +- Setiap angka yang Anda tulis HARUS ada di [Data Halaman (JSON)] atau di blok STANDAR CP 707 + atau di [GRADED FACTS]. DILARANG menghitung sendiri, memperkirakan, atau membalik arah tren. +- Jika [GRADED FACTS] menyatakan direction/status, ULANGI arah itu — jangan dibalik. +- SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD: _gram, _persen, _rasio, _karung, _ekor, _kg, + _hari, _indeksTanpaSatuan. Pakai satuan itu persis. +- Field yang berisi "tidak tersedia" atau null memang tidak ada datanya. Tulis "data tidak tersedia" + dan JANGAN mengarang angkanya. +- Status topik tanpa data = unknown, bukan ok. +- Panen ≠ kematian; jangan hitung mortalitas dari selisih populasi awal − kini. + +FORMAT OUTPUT (JSON SAJA): +{"kesimpulan":"...","insight":"..."} +""" + + +def strip_headerless_tables(chunk: str) -> tuple[str, int]: + lines = str(chunk or "").split("\n") + removed = 0 + kept: list[str] = [] + for line in lines: + trimmed = line.strip() + if trimmed and _NUMERIC_PIPE_ROW.match(trimmed): + removed += 1 + continue + kept.append(line) + if removed == 0: + return chunk, 0 + text = ( + "\n".join(kept).strip() + + "\n[Tabel angka tanpa judul kolom dihapus dari kutipan ini karena tidak dapat " + "dibaca dengan benar. Gunakan blok STANDAR CP 707 / GRADED FACTS untuk angka standar.]" + ) + return text, removed + + +def _day_age(context: dict[str, Any]) -> int | None: + raw = ( + context.get("hari_ke") + or context.get("hari_terakhir") + or context.get("currentDay") + or context.get("dayAge") + ) + try: + day = int(raw) + except (TypeError, ValueError): + return None + return day if day > 0 else None + + +def prune_context_by_period(context: dict[str, Any], report_type: str, report_period: str) -> dict[str, Any]: + """Light prune for history arrays; keep scalars. Full FE scope happens client-side.""" + out = dict(context) + if report_type == "end_cycle": + # Deterministic weekly KPI rollup when weekly_summaries absent. + if "weekly_summaries" not in out: + out["weekly_summaries"] = _build_weekly_summaries(out) + out["catatan_end_cycle"] = ( + "Ringkasan KPI per minggu dihitung di backend. Narasikan dari weekly_summaries + graded_facts; " + "jangan menghitung ulang." + ) + out["report_type"] = report_type + out["report_period"] = report_period + return out + + +def _build_weekly_summaries(context: dict[str, Any]) -> list[dict[str, Any]]: + histories = [] + for key in ("tren_fcr_harian", "tren_harian", "tren_7_hari_terakhir", "history"): + val = context.get(key) + if isinstance(val, list) and val: + histories = val + break + by_week: dict[int, list[dict[str, Any]]] = {} + for row in histories: + if not isinstance(row, dict): + continue + day = row.get("hari") or row.get("day") + try: + day_i = int(day) + except (TypeError, ValueError): + continue + week = max(1, (day_i + 6) // 7) + by_week.setdefault(week, []).append(row) + + summaries = [] + for week, rows in sorted(by_week.items()): + fcrs = [r.get("fcr_aktual") or r.get("fcr") for r in rows if isinstance(r.get("fcr_aktual") or r.get("fcr"), (int, float))] + summaries.append( + { + "minggu_ke": week, + "jumlah_hari_data": len(rows), + "fcr_rata_rasio": round(sum(fcrs) / len(fcrs), 3) if fcrs else None, + "fcr_akhir_rasio": fcrs[-1] if fcrs else None, + } + ) + return summaries + + +def grade_context(context: dict[str, Any]) -> dict[str, Any]: + analysis = cp707.analyze_with_cp707_standards(context) + graded: dict[str, Any] = { + "kandangId": context.get("kandangId") or context.get("kandang_id"), + "hari_ke": analysis.get("dayAge") or _day_age(context), + "analyses": analysis.get("analyses") or {}, + } + # Normalize analyzer outputs into explicit direction blocks for the prompt. + for key, block in list(graded["analyses"].items()): + if not isinstance(block, dict): + continue + if "direction" not in block and block.get("deviation") is not None: + try: + dev = float(block["deviation"]) + if abs(dev) <= 5: + block["direction"] = "sesuai_standar" + elif key == "fcr": + block["direction"] = "di_atas_standar" if dev > 0 else "di_bawah_standar" + else: + block["direction"] = "di_atas_standar" if dev > 0 else "di_bawah_standar" + except (TypeError, ValueError): + pass + return graded + + +def fetch_rag_chunks(query: str, topic: str, n_results: int = 4) -> tuple[list[str], list[dict[str, Any]]]: + base = getattr(settings, "RAG_SERVICE_URL", "") or "" + if not base: + return [], [] + url = f"{base.rstrip('/')}/query" + try: + with httpx.Client(timeout=8.0) as client: + resp = client.post( + url, + json={"query": query, "topic": topic or "", "n_results": n_results, "tipe": "prosa"}, + ) + if resp.status_code >= 400: + logger.warning("RAG query HTTP %s", resp.status_code) + return [], [] + data = resp.json() + chunks = data.get("chunks") or [] + metas = data.get("metadatas") or [] + sources = data.get("sources") or [] + citations = [] + cleaned = [] + for i, chunk in enumerate(chunks): + text, _ = strip_headerless_tables(chunk) + if text.strip(): + cleaned.append(text) + meta = metas[i] if i < len(metas) else {} + citations.append( + { + "source": (meta or {}).get("source") or (sources[i] if i < len(sources) else "cp707"), + "bab": (meta or {}).get("bab") or "", + "chunk_id": (meta or {}).get("chunk_index"), + "tipe": (meta or {}).get("tipe") or "prosa", + "excerpt": text[:240], + } + ) + return cleaned, citations + except Exception as exc: # noqa: BLE001 + logger.warning("RAG unavailable: %s", exc) + return [], [] + + +def call_ollama(system_prompt: str, user_prompt: str) -> str | None: + model = getattr(settings, "LLM_MODEL_NAME", "qwen2.5:3b") or "qwen2.5:3b" + base = getattr(settings, "OLLAMA_BASE_URL", "http://127.0.0.1:11434") or "http://127.0.0.1:11434" + url = f"{base.rstrip('/')}/api/chat" + timeout = float(getattr(settings, "LLM_TIMEOUT_SECONDS", 1200) or 1200) + payload = { + "model": model, + "stream": False, + "options": {"temperature": 0.2}, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + } + try: + with httpx.Client(timeout=timeout) as client: + resp = client.post(url, json=payload) + if resp.status_code >= 400: + logger.error("Ollama HTTP %s: %s", resp.status_code, resp.text[:500]) + return None + data = resp.json() + message = data.get("message") or {} + return message.get("content") or data.get("response") + except Exception as exc: # noqa: BLE001 + logger.error("Ollama call failed: %s", exc) + return None + + +def parse_llm_json(text: str) -> dict[str, str] | None: + if not text: + return None + cleaned = re.sub(r"[\s\S]*?", "", text, flags=re.I) + cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned.strip(), flags=re.I) + cleaned = re.sub(r"\s*```\s*$", "", cleaned) + first = cleaned.find("{") + last = cleaned.rfind("}") + if first < 0 or last <= first: + return None + try: + parsed = json.loads(cleaned[first : last + 1]) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + kesimpulan = ( + parsed.get("kesimpulan") + or parsed.get("ringkasan") + or parsed.get("summary") + or "" + ) + insight = ( + parsed.get("insight") + or parsed.get("rekomendasi") + or parsed.get("insights") + or "" + ) + if not kesimpulan and not insight: + return None + return { + "kesimpulan": str(kesimpulan) or "Model tidak mengembalikan kesimpulan eksplisit.", + "insight": str(insight) or "Model tidak mengembalikan rekomendasi eksplisit.", + } + + +def local_fallback_insight(graded: dict[str, Any], topic: str) -> dict[str, str]: + analyses = graded.get("analyses") or {} + lines = [] + for name, block in analyses.items(): + if isinstance(block, dict) and block.get("message"): + lines.append(f"{name}: {block['message']}") + if not lines: + return { + "kesimpulan": f"Data untuk topik {topic} tidak cukup untuk dianalisis (status unknown).", + "insight": "Lengkapi data operasional kandang, lalu generate ulang. Jangan mengarang angka.", + } + return { + "kesimpulan": lines[0], + "insight": "\n".join(lines[1:]) if len(lines) > 1 else lines[0], + } + + +def lookup_cached( + *, + cycle_id: int, + kandang_id: int, + topic: str, + report_type: str, + report_period: str, +) -> AIInsight | None: + return ( + AIInsight.objects.filter( + cycle_id=cycle_id, + kandang_id=kandang_id, + topic=topic, + report_type=report_type, + report_period=report_period or "current", + ) + .order_by("-updated_at") + .first() + ) + + +def get_cached_insight( + *, + cycle_id: int, + kandang_id: int, + topic: str, + report_type: str = "page", + report_period: str = "current", +) -> dict[str, Any] | None: + row = ( + AIInsight.objects.filter( + cycle_id=cycle_id, + kandang_id=kandang_id, + topic=topic, + report_type=report_type, + report_period=report_period or "current", + ) + .order_by("-updated_at") + .first() + ) + if row is None: + return None + return serialize_insight(row, source_override=AIInsight.SOURCE_CACHE) + + +def encode_citations(citations: list[Any] | None) -> str: + """Persist citations as a JSON array string in TEXT column.""" + if not citations: + return "[]" + return json.dumps(citations, ensure_ascii=False, default=str) + + +def decode_citations(raw: Any) -> list[dict[str, Any]]: + """Load citations from TEXT (JSON string) or legacy list.""" + if raw is None or raw == "": + return [] + if isinstance(raw, list): + data = raw + elif isinstance(raw, str): + try: + data = json.loads(raw) + except json.JSONDecodeError: + return [] + else: + return [] + if not isinstance(data, list): + return [] + # Normalize citation keys for FE (chapter alias). + norm_citations: list[dict[str, Any]] = [] + for c in data: + if not isinstance(c, dict): + continue + item = dict(c) + if "chapter" not in item and item.get("bab"): + item["chapter"] = item["bab"] + norm_citations.append(item) + return norm_citations + + +def serialize_insight(row: AIInsight, source_override: str | None = None) -> dict[str, Any]: + norm_citations = decode_citations(row.citations) + return { + "success": True, + "id": row.pk, + "cycle": row.cycle_id, + "kandang": row.kandang_id, + "topic": row.topic, + "report_type": row.report_type, + "report_period": row.report_period, + "source": source_override or row.source or AIInsight.SOURCE_GENERATED, + "summary": row.summary or "", + "insight": row.insight_text or "", + "insight_text": "\n\n".join( + part for part in [row.summary or "", row.insight_text or ""] if part + ) + or row.insight_text + or "", + "alert": row.alert or ALERT_UNKNOWN, + "citations": norm_citations, + "date": row.date.isoformat() if row.date else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + + +def generate_insight( + *, + cycle_id: int, + kandang_id: int, + topic: str, + context: dict[str, Any], + report_type: str = "page", + report_period: str = "current", + force_refresh: bool = False, +) -> dict[str, Any]: + if not kandang_id: + raise ValueError("kandang_id wajib — insight tidak boleh untuk semua kandang") + if not cycle_id: + raise ValueError("cycle_id wajib") + if not topic: + raise ValueError("topic wajib") + + try: + cycle = Cycle.objects.select_related("kandang").get(pk=cycle_id) + except Cycle.DoesNotExist as exc: + raise ValueError("Cycle not found") from exc + try: + kandang = Kandang.objects.get(pk=kandang_id) + except Kandang.DoesNotExist as exc: + raise ValueError("Kandang not found") from exc + if cycle.kandang_id != kandang_id: + raise ValueError("kandang_id tidak cocok dengan cycle") + + report_period = report_period or "current" + report_type = report_type or "page" + + if not force_refresh: + cached = get_cached_insight( + cycle_id=cycle_id, + kandang_id=kandang_id, + topic=topic, + report_type=report_type, + report_period=report_period, + ) + if cached: + return cached + + ctx = dict(context or {}) + ctx.setdefault("kandangId", kandang_id) + ctx.setdefault("kandang_id", kandang_id) + ctx.setdefault("kandangName", kandang.kandang_name) + ctx.setdefault("cycleId", cycle_id) + ctx = prune_context_by_period(ctx, report_type, report_period) + + graded = grade_context(ctx) + day = graded.get("hari_ke") + standard_block = cp707.build_cp707_standard_block(ctx) or cp707.build_book_reference_context(day or 1) + + rag_query = f"panduan manajemen broiler CP 707 untuk {topic} umur hari ke-{day or '?'}" + chunks, citations = fetch_rag_chunks(rag_query, topic) + + system_prompt = ( + "Anda adalah asisten farm broiler on-premise. Tugas Anda HANYA menulis narasi " + "dari GRADED FACTS + standar CP 707 + cuplikan SOP. Jangan menghitung ulang.\n" + f"{ANTI_HALLUCINATION_RULES}\n" + f"{standard_block}\n" + f"[GRADED FACTS]\n{json.dumps(graded, ensure_ascii=False, default=str)}\n" + ) + if chunks: + system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(chunks[:4]) + + user_prompt = ( + f"Buat insight topik `{topic}` untuk kandang `{kandang.kandang_name}` " + f"(id={kandang_id}), periode `{report_type}/{report_period}`.\n" + f"[Data Halaman (JSON)]\n{json.dumps(ctx, ensure_ascii=False, default=str)}" + ) + + raw = call_ollama(system_prompt, user_prompt) + parsed = parse_llm_json(raw or "") + source = AIInsight.SOURCE_GENERATED + if not parsed: + parsed = local_fallback_insight(graded, topic) + source = AIInsight.SOURCE_LOCAL_FALLBACK + + insight_text = parsed["insight"] + summary = parsed["kesimpulan"] + alert = alert_from_graded(graded) + + row, _created = AIInsight.objects.update_or_create( + cycle=cycle, + kandang=kandang, + topic=topic, + report_type=report_type, + report_period=report_period, + defaults={ + "date": timezone.localdate(), + "insight_text": insight_text, + "summary": summary, + "alert": alert, + "source": source, + "citations": encode_citations(citations), + }, + ) + return serialize_insight(row, source_override=source) diff --git a/backend/apps/operations/tests_insight_alert.py b/backend/apps/operations/tests_insight_alert.py new file mode 100644 index 0000000..e2922b9 --- /dev/null +++ b/backend/apps/operations/tests_insight_alert.py @@ -0,0 +1,51 @@ +from django.test import SimpleTestCase + +from apps.operations.services.insight_service import alert_from_graded + + +class AlertFromGradedTests(SimpleTestCase): + def test_empty_analyses_is_unknown(self): + self.assertEqual(alert_from_graded({}), "unknown") + self.assertEqual(alert_from_graded({"analyses": {}}), "unknown") + + def test_ok_maps_to_healthy(self): + graded = {"analyses": {"fcr": {"status": "ok"}}} + self.assertEqual(alert_from_graded(graded), "healthy") + + def test_warning_and_critical_unchanged(self): + self.assertEqual( + alert_from_graded({"analyses": {"fcr": {"status": "warning"}}}), + "warning", + ) + self.assertEqual( + alert_from_graded({"analyses": {"mortality": {"status": "critical"}}}), + "critical", + ) + + def test_unknown_stays_unknown(self): + graded = {"analyses": {"bw": {"status": "unknown"}}} + self.assertEqual(alert_from_graded(graded), "unknown") + + def test_worst_wins_critical_over_warning_and_healthy(self): + graded = { + "analyses": { + "fcr": {"status": "ok"}, + "mortality": {"status": "warning"}, + "environment": {"status": "critical"}, + } + } + self.assertEqual(alert_from_graded(graded), "critical") + + def test_worst_wins_warning_over_healthy_and_unknown(self): + graded = { + "analyses": { + "fcr": {"status": "ok"}, + "bw": {"status": "unknown"}, + "mortality": {"status": "warning"}, + } + } + self.assertEqual(alert_from_graded(graded), "warning") + + def test_ignores_non_dict_blocks(self): + graded = {"analyses": {"fcr": "ok", "bw": {"status": "ok"}}} + self.assertEqual(alert_from_graded(graded), "healthy") diff --git a/backend/apps/operations/views.py b/backend/apps/operations/views.py index ed523fe..5ca7eb0 100644 --- a/backend/apps/operations/views.py +++ b/backend/apps/operations/views.py @@ -35,7 +35,7 @@ from apps.operations.services.chicken_counting_edge import ( ) from apps.operations.services.visibility import dashboard_publish_time, visible_through_date -READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates"}) +READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates", "cached"}) class VisibilityFilteredMixin: @@ -252,9 +252,89 @@ class IotPanelViewSet(viewsets.ModelViewSet): class AIInsightViewSet(CycleScopedViewSet): - queryset = AIInsight.objects.select_related("cycle").all() + queryset = AIInsight.objects.select_related("cycle", "kandang").all() serializer_class = AIInsightSerializer + def get_queryset(self): + qs = super().get_queryset() + params = self.request.query_params + if params.get("kandang_id"): + qs = qs.filter(kandang_id=params["kandang_id"]) + if params.get("topic"): + qs = qs.filter(topic=params["topic"]) + if params.get("report_type"): + qs = qs.filter(report_type=params["report_type"]) + if params.get("report_period"): + qs = qs.filter(report_period=params["report_period"]) + return qs + + @action(detail=False, methods=["post"], url_path="generate") + def generate(self, request): + from apps.operations.services.insight_service import generate_insight + + data = request.data or {} + cycle_id = data.get("cycle_id") + kandang_id = data.get("kandang_id") + topic = data.get("topic") + context = data.get("context") or {} + report_type = data.get("report_type") or "page" + report_period = data.get("report_period") or "current" + force_refresh = bool(data.get("force_refresh")) + if not cycle_id or not kandang_id or not topic: + return Response( + {"detail": "cycle_id, kandang_id, and topic are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Reject mixed-farm prompts: context must match selected kandang. + ctx_kid = context.get("kandangId") or context.get("kandang_id") + if ctx_kid is not None and int(ctx_kid) != int(kandang_id): + return Response( + {"detail": "context.kandangId must match kandang_id."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + result = generate_insight( + cycle_id=int(cycle_id), + kandang_id=int(kandang_id), + topic=str(topic), + context=context if isinstance(context, dict) else {}, + report_type=str(report_type), + report_period=str(report_period), + force_refresh=force_refresh, + ) + except ValueError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: # noqa: BLE001 + return Response( + {"detail": f"Insight generate failed: {exc}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + return Response(result) + + @action(detail=False, methods=["get"], url_path="cached") + def cached(self, request): + from apps.operations.services.insight_service import get_cached_insight + + params = request.query_params + cycle_id = params.get("cycle_id") + kandang_id = params.get("kandang_id") + topic = params.get("topic") + if not cycle_id or not kandang_id or not topic: + return Response( + {"detail": "cycle_id, kandang_id, and topic are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + result = get_cached_insight( + cycle_id=int(cycle_id), + kandang_id=int(kandang_id), + topic=str(topic), + report_type=params.get("report_type") or "page", + report_period=params.get("report_period") or "current", + ) + if result is None: + return Response({"detail": "No cached insight."}, status=status.HTTP_404_NOT_FOUND) + return Response(result) + class HealthView(APIView): permission_classes = [AllowAny] diff --git a/backend/config/settings.py b/backend/config/settings.py index acbefea..44f3d32 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -219,3 +219,9 @@ CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED = env_bool( CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED = env_bool( "CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED", True ) + +# AI Insight: Ollama + RAG (generate intended for NUC / host Ollama). +RAG_SERVICE_URL = (env("RAG_SERVICE_URL", "http://127.0.0.1:5002") or "").rstrip("/") +OLLAMA_BASE_URL = (env("OLLAMA_BASE_URL", "http://127.0.0.1:11434") or "").rstrip("/") +LLM_MODEL_NAME = env("LLM_MODEL_NAME", "qwen2.5:3b") or "qwen2.5:3b" +LLM_TIMEOUT_SECONDS = float(env("LLM_TIMEOUT_SECONDS", "1200") or "1200") diff --git a/backend/database/ERD_dashboard.png b/backend/database/ERD_dashboard.png index 002ae2f..3471a8a 100644 Binary files a/backend/database/ERD_dashboard.png and b/backend/database/ERD_dashboard.png differ diff --git a/backend/database/erd_schema.sql b/backend/database/erd_schema.sql index 55cefb4..c6dc167 100644 --- a/backend/database/erd_schema.sql +++ b/backend/database/erd_schema.sql @@ -16,8 +16,9 @@ -- cycles.status: active | pending_close | closed (GM approves end_date) -- cycles.close_requested_by_id → user_access (optional) -- kandang → flock → iot_panel --- cycles → ai_insight, feed_sacks, chicken_counting, chicken_weight, --- manual_input, kpi +-- cycles → ai_insight (scoped by kandang/topic/report), feed_sacks, +-- chicken_counting, chicken_weight, manual_input, kpi +-- kandang.feed_in_button_urls: JSON map of feed-in button URLs per slot -- kpi → chicken_counting, chicken_weight, feed_sacks, manual_input -- ============================================================================= @@ -99,11 +100,12 @@ CREATE INDEX IF NOT EXISTS idx_sites_pusat_active_site_id ON sites (pusat_active -- 3. Kandang (Coop / Pen) -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS kandang ( - kandang_id SERIAL PRIMARY KEY, - kandang_name VARCHAR(30) NOT NULL, - site_id VARCHAR(64) NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + kandang_id SERIAL PRIMARY KEY, + kandang_name VARCHAR(30) NOT NULL, + site_id VARCHAR(64) NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE, + feed_in_button_urls JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id); @@ -180,23 +182,35 @@ CREATE UNIQUE INDEX IF NOT EXISTS uniq_iot_panel_flock_timestamp ON iot_panel (f -- ----------------------------------------------------------------------------- -- 7. AI Insight +-- Persisted per cache scope: cycle + kandang + topic + report_type + +-- report_period. Regenerate overwrites the same row. +-- alert: condition from graded CP707 facts (healthy|warning|critical|unknown). -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS ai_insight ( - id SERIAL PRIMARY KEY, + id BIGSERIAL PRIMARY KEY, date DATE NOT NULL, insight_text TEXT NOT NULL, - alert VARCHAR(100) NOT NULL, - section VARCHAR(100) NOT NULL, - session VARCHAR(100) NOT NULL, + alert VARCHAR(100) NOT NULL DEFAULT '', + cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE, + kandang_id BIGINT REFERENCES kandang (kandang_id) ON DELETE CASCADE, + topic VARCHAR(64) NOT NULL DEFAULT '', + report_type VARCHAR(32) NOT NULL DEFAULT 'page', + report_period VARCHAR(64) NOT NULL DEFAULT 'current', + source VARCHAR(32) NOT NULL DEFAULT 'generated', -- generated | cache | local_fallback + summary TEXT NOT NULL DEFAULT '', + citations TEXT NOT NULL DEFAULT '[]', -- JSON array string of RAG citations created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id); +CREATE INDEX IF NOT EXISTS idx_ai_insight_kandang_id ON ai_insight (kandang_id); +CREATE INDEX IF NOT EXISTS idx_ai_insight_topic ON ai_insight (topic); +CREATE INDEX IF NOT EXISTS idx_ai_insight_report_type ON ai_insight (report_type); +CREATE INDEX IF NOT EXISTS idx_ai_insight_report_period ON ai_insight (report_period); CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date); -CREATE UNIQUE INDEX IF NOT EXISTS uniq_ai_insight_cycle_date_section_session - ON ai_insight (cycle_id, date, section, session); +CREATE UNIQUE INDEX IF NOT EXISTS uniq_ai_insight_cache_scope + ON ai_insight (cycle_id, kandang_id, topic, report_type, report_period); -- ----------------------------------------------------------------------------- -- 8. Feed Sacks @@ -255,7 +269,7 @@ CREATE TABLE IF NOT EXISTS chicken_weight ( CREATE INDEX IF NOT EXISTS idx_chicken_weight_cycle_id ON chicken_weight (cycle_id); CREATE INDEX IF NOT EXISTS idx_chicken_weight_date ON chicken_weight (date); -CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_weight_cycle_date ON chicken_weight (cycle_id, date); +CREATE UNIQUE INDEX IF NOT EXISTS uniq_cw_cycle_date ON chicken_weight (cycle_id, date); -- ----------------------------------------------------------------------------- -- 11. Manual Input @@ -341,7 +355,7 @@ DECLARE tbl TEXT; BEGIN FOREACH tbl IN ARRAY ARRAY[ - 'user_access', 'sites', 'kandang', 'cycles', 'flock', + 'user_access', 'api_keys', 'sites', 'kandang', 'cycles', 'flock', 'iot_panel', 'ai_insight', 'feed_sacks', 'chicken_counting', 'chicken_weight', 'manual_input', 'kpi' ] diff --git a/components/AiInsightCard.tsx b/components/AiInsightCard.tsx new file mode 100644 index 0000000..63037e7 --- /dev/null +++ b/components/AiInsightCard.tsx @@ -0,0 +1,833 @@ +/** + * Dashboard AI Insight card (page / daily / weekly / end_cycle report modes). + * + * Parallel client to `PageAiInsight` (topic pages) — same API, different UX. + * Architecture: docs/ai-insight/README.md + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFarm } from '../context/FarmContext.tsx'; +import { api, errorMessage } from '../services/apiClient.ts'; +import type { + InsightCitation, + InsightContext, + InsightReportType, + InsightSource, + InsightStructuredReport, + InsightStructuredSection, + InsightStatusLabel, +} from '../types/api.ts'; +import { + type DashboardInsightMetrics, + scopeDashboardInsightMetrics, +} from '../utils/insightContextBuilders.ts'; +import { getCobbStandardForDay } from '../utils/insightHelpers.ts'; +import { formatRatio } from '../utils/format.ts'; +import { HeroFigure, MutedStatRow, StatTile, type InsightStatus } from './shared/InsightStatTiles.tsx'; +import InsightTrendChart from './shared/InsightTrendChart.tsx'; + +const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes + +const SOURCE_LABEL: Record = { + generated: { text: 'Baru dibuat', cls: 'bg-green-50 text-green-700 border-green-200' }, + cache: { text: 'Dari cache', cls: 'bg-blue-50 text-blue-700 border-blue-200' }, + local_fallback: { + text: 'Fallback lokal', + cls: 'bg-amber-50 text-amber-800 border-amber-200', + }, +}; + +const sectionBadgeClasses: Record = { + ok: 'border-emerald-200 bg-emerald-50 text-emerald-700', + warning: 'border-amber-200 bg-amber-50 text-amber-700', + critical: 'border-red-200 bg-red-50 text-red-700', + unknown: 'border-slate-200 bg-slate-50 text-slate-600', +}; + +export interface AiInsightCardProps { + /** Required — insight is always per kandang. */ + kandangId: number; + /** Optional curated context from a page builder (e.g. buildFcrInsightContext). */ + context?: InsightContext; + /** Display-only KPIs + weight series (not sent to the LLM). */ + dashboardMetrics?: DashboardInsightMetrics | null; + page?: 'hitung_ayam' | 'berat_ayam' | 'fcr' | 'eef' | 'panel_iot' | 'data_quality' | 'dashboard'; +} + +const parseDayFromPeriod = (period: string | null): number | null => { + if (!period) return null; + const match = String(period).match(/\d+/); + if (!match) return null; + const day = parseInt(match[0], 10); + return Number.isFinite(day) && day > 0 ? day : null; +}; + +const stripJsonFences = (text: string) => + text + .trim() + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```$/i, '') + .trim(); + +const extractJsonObject = (text: string): string | null => { + const stripped = stripJsonFences(text); + if (!stripped) return null; + if (stripped.startsWith('{') && stripped.endsWith('}')) return stripped; + const first = stripped.indexOf('{'); + const last = stripped.lastIndexOf('}'); + if (first === -1 || last === -1 || last <= first) return null; + return stripped.slice(first, last + 1); +}; + +const asNonEmptyString = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const asStringArray = (value: unknown, limit = 3): string[] => { + if (!Array.isArray(value)) return []; + return value + .map((item) => asNonEmptyString(item)) + .filter((item): item is string => item !== null) + .slice(0, limit); +}; + +const asInsightStatus = (value: unknown): InsightStatusLabel => { + if (value === 'ok' || value === 'warning' || value === 'critical' || value === 'unknown') { + return value; + } + return 'unknown'; +}; + +const normalizeInsightSection = (value: unknown): InsightStructuredSection => { + const section = (value && typeof value === 'object' ? value : {}) as Record; + return { + status: asInsightStatus(section.status), + bullets: asStringArray(section.bullets, 3), + evidence: asStringArray(section.evidence, 4), + actions: asStringArray(section.actions, 3), + }; +}; + +const normalizeStructuredInsight = (rawText: string): InsightStructuredReport | null => { + try { + const jsonText = extractJsonObject(rawText); + if (!jsonText) return null; + const parsed = JSON.parse(jsonText) as Record; + const summary = ( + parsed.summary && typeof parsed.summary === 'object' ? parsed.summary : {} + ) as Record; + const detailed = ( + parsed.detailed && typeof parsed.detailed === 'object' ? parsed.detailed : {} + ) as Record; + + return { + summary: { + headline: asNonEmptyString(summary.headline) || 'Ringkasan belum tersedia', + bullets: asStringArray(summary.bullets, 3), + risks: asStringArray(summary.risks, 3), + actions: asStringArray(summary.actions, 3), + }, + detailed: { + hitung_ayam: normalizeInsightSection(detailed.hitung_ayam), + berat_ayam: normalizeInsightSection(detailed.berat_ayam), + fcr: normalizeInsightSection(detailed.fcr), + eef: normalizeInsightSection(detailed.eef), + panel_iot: normalizeInsightSection(detailed.panel_iot), + data_quality: normalizeInsightSection(detailed.data_quality), + }, + }; + } catch { + return null; + } +}; + +const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => { + if (!citations || citations.length === 0) return null; + return ( +
+

+ Sumber referensi +

+
    + {citations.map((c, i) => ( +
  • + + {c.chapter || c.title || c.source || `Referensi ${i + 1}`} + + {c.excerpt ? — {c.excerpt} : null} +
  • + ))} +
+
+ ); +}; + +/** + * Dashboard AI Insight report card (shell). + * Requires a kandangId — never generates for "all kandang". + * Wire page-specific KPIs/charts via `context` from insightContextBuilders. + */ +export const AiInsightCard: React.FC = ({ + kandangId, + context: contextProp, + dashboardMetrics = null, + page = 'dashboard', +}) => { + const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); + + const [insightText, setInsightText] = useState(''); + const [structuredInsight, setStructuredInsight] = useState(null); + const [source, setSource] = useState(null); + const [citations, setCitations] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdated, setLastUpdated] = useState(null); + const [error, setError] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const [reportType, setReportType] = useState('daily'); + const [dayPeriod, setDayPeriod] = useState(null); + const insightInFlightRef = useRef(false); + + const effectiveKandangId = kandangId ?? selectedKandangId; + const contextDay = + typeof contextProp?.hari_ke === 'number' && Number.isFinite(contextProp.hari_ke) + ? contextProp.hari_ke + : null; + // Prefer context/data day over cycle calendar day (can be ahead of KPI/weight data). + const currentDayVal = + contextDay ?? dashboardMetrics?.hari_ke ?? selectedCycle?.current_day ?? 0; + const reportPeriod = + reportType === 'daily' + ? (dayPeriod ?? (currentDayVal > 0 ? `Hari ${currentDayVal}` : null)) + : reportType === 'weekly' + ? dayPeriod + : null; + + const scopedMetrics = useMemo(() => { + if (!dashboardMetrics) return null; + if (reportType === 'end_cycle') return dashboardMetrics; + const selectedDay = parseDayFromPeriod(reportPeriod); + return scopeDashboardInsightMetrics(dashboardMetrics, selectedDay); + }, [dashboardMetrics, reportType, reportPeriod]); + + const topic = page === 'dashboard' ? 'dashboard' : page === 'panel_iot' ? 'iot_panel' : page; + + const applyInsight = useCallback( + (text: string, nextSource: InsightSource, nextCitations?: InsightCitation[]) => { + setInsightText(text); + setStructuredInsight(normalizeStructuredInsight(text)); + setSource(nextSource); + setCitations(nextCitations ?? []); + }, + [] + ); + + const generateInsight = useCallback( + async (forceRefresh = false) => { + if (insightInFlightRef.current) return; + if (effectiveKandangId == null) { + setError('Pilih kandang terlebih dahulu. Insight digenerate per kandang.'); + return; + } + if (!selectedCycle?.id) { + setError('Data siklus tidak tersedia. Pastikan ada siklus aktif.'); + return; + } + + insightInFlightRef.current = true; + if (forceRefresh) setLoading(true); + setError(null); + + try { + const contextPayload: InsightContext = { + kandangId: effectiveKandangId, + kandangName: selectedKandang?.kandang_name, + cycleId: selectedCycle.id, + hari_ke: currentDayVal > 0 ? currentDayVal : selectedCycle.current_day, + totalDays: selectedCycle.total_days, + ...(contextProp ?? {}), + }; + + // Always stamp required identity after spread so caller cannot clear it. + contextPayload.kandangId = effectiveKandangId; + if (currentDayVal > 0) contextPayload.hari_ke = currentDayVal; + + // Prefer period-scoped display metrics so the model analyzes the same + // numbers the muted rows / tiles show for the selected day. + if (scopedMetrics) { + contextPayload.hari_ke = scopedMetrics.hari_ke ?? contextPayload.hari_ke; + contextPayload.fcr_terakhir = scopedMetrics.fcr; + contextPayload.eef_terakhir = scopedMetrics.eef; + contextPayload.bobot_avg_gram = scopedMetrics.bobot_avg_gram; + contextPayload.ayam_hidup_ekor = scopedMetrics.ayam_hidup_ekor; + contextPayload.persen_hidup = + scopedMetrics.mortalitas_kumulatif_persen == null + ? null + : Math.round((100 - scopedMetrics.mortalitas_kumulatif_persen) * 100) / 100; + contextPayload.doc_in_ekor = scopedMetrics.doc_in_ekor; + contextPayload.kematian_kumulatif_ekor = scopedMetrics.kematian_kumulatif_ekor; + contextPayload.panen_kumulatif_ekor = scopedMetrics.panen_kumulatif_ekor; + contextPayload.tonase_panen_kg = scopedMetrics.tonase_panen_kg; + contextPayload.uniformity_persen = scopedMetrics.uniformity_persen; + contextPayload.pakan_kumulatif_karung = scopedMetrics.pakan_kumulatif_karung; + } + + let apiResult = null as Awaited> | null; + + if (!forceRefresh) { + try { + apiResult = await api.insights.cached({ + cycle_id: selectedCycle.id, + kandang_id: effectiveKandangId, + topic, + report_type: reportType, + report_period: reportPeriod, + }); + if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null; + } catch { + apiResult = null; + } + // Manual-generate only: never POST generate on mount / soft refresh. + if (!apiResult) { + applyInsight('', 'cache'); + setLastUpdated(null); + setError(null); + return; + } + } else { + const generateCall = api.insights.generate({ + cycle_id: selectedCycle.id, + kandang_id: effectiveKandangId, + topic, + report_type: reportType, + report_period: reportPeriod, + context: contextPayload, + force_refresh: true, + }); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + 'TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.' + ) + ), + CLIENT_TIMEOUT_MS + ) + ); + apiResult = await Promise.race([generateCall, timeoutPromise]); + } + + if (!apiResult?.insight_text && !apiResult?.summary) { + throw new Error(apiResult?.message || 'Gagal mengambil insight AI dari server'); + } + + applyInsight( + apiResult.insight_text, + apiResult.source ?? (forceRefresh ? 'generated' : 'cache'), + apiResult.citations + ); + setLastUpdated(new Date()); + setDetailsOpen(false); + } catch (err) { + console.error('Error generating insight:', err); + if (forceRefresh) { + applyInsight('', 'local_fallback'); + setLastUpdated(null); + setError(errorMessage(err)); + setDetailsOpen(false); + } else { + applyInsight('', 'cache'); + setLastUpdated(null); + setError(null); + } + } finally { + setLoading(false); + insightInFlightRef.current = false; + } + }, + [ + effectiveKandangId, + selectedCycle, + selectedKandang, + contextProp, + currentDayVal, + topic, + reportType, + reportPeriod, + scopedMetrics, + applyInsight, + ] + ); + + useEffect(() => { + if (effectiveKandangId == null || !selectedCycle?.id) return; + // Soft cache load only — never auto-generate. + const timer = setTimeout(() => generateInsight(false), 50); + return () => clearTimeout(timer); + }, [effectiveKandangId, selectedCycle?.id, reportType, reportPeriod, generateInsight]); + + // NOTE: no interval auto-refresh / auto-generate (manual Generate only). + const formatLastUpdated = () => { + if (!lastUpdated) return ''; + const diff = Date.now() - lastUpdated.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + if (hours > 0) return `${hours} jam yang lalu`; + if (minutes > 0) return `${minutes} menit yang lalu`; + return 'Baru saja'; + }; + + const renderBulletList = (items: string[], emptyLabel = 'data belum tersedia') => { + if (!items.length) return

{emptyLabel}

; + return ( +
    + {items.map((item, index) => ( +
  • + + {item} +
  • + ))} +
+ ); + }; + + const renderDetailSection = ( + title: string, + section: InsightStructuredSection, + accentClass: string + ) => ( +
+
+

{title}

+ + {section.status} + +
+
+
+

Poin

+
{renderBulletList(section.bullets)}
+
+
+

Bukti

+
{renderBulletList(section.evidence)}
+
+
+

Aksi

+
{renderBulletList(section.actions)}
+
+
+
+ ); + + const sourceMeta = source ? SOURCE_LABEL[source] : null; + const summary = structuredInsight?.summary; + const detailed = structuredInsight?.detailed; + + const gradeDay = scopedMetrics?.hari_ke ?? (currentDayVal > 0 ? currentDayVal : null); + const fcrStandard = gradeDay ? getCobbStandardForDay(gradeDay)?.fcr ?? null : null; + + const fcrStatus = ((): InsightStatus => { + const actual = scopedMetrics?.fcr ?? null; + if (actual === null || fcrStandard === null) return 'unknown'; + if (actual <= fcrStandard) return 'ok'; + if (actual <= fcrStandard * 1.1) return 'warning'; + return 'critical'; + })(); + + const eefStatus = ((): InsightStatus => { + const actual = scopedMetrics?.eef ?? null; + if (actual === null) return 'unknown'; + if ((gradeDay ?? 0) < 35) return 'unknown'; + if (actual >= 300) return 'ok'; + if (actual >= 250) return 'warning'; + return 'critical'; + })(); + + const mortalityStatus = ((): InsightStatus => { + const pct = scopedMetrics?.mortalitas_kumulatif_persen ?? null; + if (pct === null) return 'unknown'; + if (pct < 5) return 'ok'; + if (pct <= 7) return 'warning'; + return 'critical'; + })(); + + const weightTarget = + scopedMetrics?.weightSeries.find((row) => row.hari === gradeDay)?.target ?? + [...(scopedMetrics?.weightSeries ?? [])].reverse().find((row) => row.target != null) + ?.target ?? + null; + + const weightStatus = ((): InsightStatus => { + const actual = scopedMetrics?.bobot_avg_gram ?? null; + if (actual === null || weightTarget === null || weightTarget <= 0) return 'unknown'; + const dev = ((actual - weightTarget) / weightTarget) * 100; + if (dev >= -5) return 'ok'; + if (dev >= -15) return 'warning'; + return 'critical'; + })(); + + const weightGapPct = + scopedMetrics?.bobot_avg_gram != null && weightTarget != null && weightTarget > 0 + ? ((scopedMetrics.bobot_avg_gram - weightTarget) / weightTarget) * 100 + : null; + + const chartHighlight = + reportType === 'daily' && gradeDay != null + ? { firstDay: gradeDay, lastDay: gradeDay } + : gradeDay != null + ? { firstDay: 1, lastDay: gradeDay } + : null; + + const renderVisualBlock = () => { + if (!scopedMetrics || page !== 'dashboard') return null; + + const fcrVal = scopedMetrics.fcr; + const eefVal = scopedMetrics.eef; + const popVal = scopedMetrics.ayam_hidup_ekor; + const mortVal = scopedMetrics.mortalitas_kumulatif_persen; + const bobotVal = scopedMetrics.bobot_avg_gram; + + return ( +
+
+ fcrStandard ? '+' : ''}${formatRatio(fcrVal - fcrStandard, 3)} vs standar` + : null + } + /> + = 35 + ? 'Standar umur panen: >300' + : 'Belum umur panen — wajar masih rendah' + } + status={eefStatus} + deltaLabel={ + eefVal == null || (gradeDay ?? 0) < 35 + ? null + : eefVal >= 300 + ? 'di atas standar' + : 'di bawah standar' + } + /> +
+ +
+ + + +
+ +
+ + + + + + +
+ +
+ >} + series={[ + { field: 'actual', label: 'Bobot aktual', color: '#dc2626' }, + { field: 'target', label: 'Target CP 707', color: '#9ca3af', dashed: true }, + ]} + unit="g" + title="Bobot Aktual vs Target CP 707" + highlight={chartHighlight} + gap={{ actualField: 'actual', targetField: 'target' }} + emptyLabel="Belum ada data bobot untuk digambarkan." + /> +
+
+ ); + }; + + return ( +
+
+
+
+ +
+

AI Insight

+

+ {selectedKandang?.kandang_name ?? `Kandang ${effectiveKandangId}`} + {selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''} +

+
+
+
+ {(page === 'dashboard' || !page) && ( + <> + + {reportType === 'daily' && currentDayVal > 0 && ( + + )} + + )} + {sourceMeta && ( + + {sourceMeta.text} + + )} + +
+
+ + {effectiveKandangId != null ? renderVisualBlock() : null} + +
+ {effectiveKandangId == null ? ( +
+ Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak + diizinkan. +
+ ) : loading && !insightText ? ( +
+
+
+

+ Menganalisis data peternakan… proses ini bisa memakan waktu beberapa menit. +

+
+
+ ) : error && !insightText ? ( +
+

Error

+

{error}

+ +
+ ) : structuredInsight && summary ? ( +
+
+

+ Kesimpulan +

+

+ {summary.headline} +

+
+

+ AI Insight +

+
+ {renderBulletList([ + ...summary.bullets, + ...summary.risks, + ...summary.actions, + ])} +
+
+ +
+ + {page === 'dashboard' && detailed && ( + <> + + {detailsOpen && ( +
+ {detailed.hitung_ayam && + renderDetailSection( + 'Hitung Ayam', + detailed.hitung_ayam, + sectionBadgeClasses[detailed.hitung_ayam.status] + )} + {detailed.berat_ayam && + renderDetailSection( + 'Berat Ayam', + detailed.berat_ayam, + sectionBadgeClasses[detailed.berat_ayam.status] + )} + {detailed.fcr && + renderDetailSection( + 'FCR', + detailed.fcr, + sectionBadgeClasses[detailed.fcr.status] + )} + {detailed.eef && + renderDetailSection( + 'EEF', + detailed.eef, + sectionBadgeClasses[detailed.eef.status] + )} + {detailed.panel_iot && + renderDetailSection( + 'Panel IoT', + detailed.panel_iot, + sectionBadgeClasses[detailed.panel_iot.status] + )} + {detailed.data_quality && + renderDetailSection( + 'Kualitas Data', + detailed.data_quality, + sectionBadgeClasses[detailed.data_quality.status] + )} +
+ )} + + )} +
+ ) : insightText ? ( +
+ {insightText} + +
+ ) : ( +
+ +

Belum ada AI Insight untuk periode ini.

+

Klik tombol refresh untuk generate insight.

+ +
+ )} +
+ + {lastUpdated && ( +
+

Terakhir diperbarui: {formatLastUpdated()}

+
+ )} +
+
+ ); +}; + +export default AiInsightCard; diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index be6ff56..8998f28 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api, ApiError, errorMessage } from '../services/apiClient.ts'; import type { - AIInsight, ChickenCounting, ChickenWeight, Karung, @@ -21,12 +20,17 @@ import { } from '../utils/format.ts'; import { throughVisibleDate } from '../utils/visibility.ts'; import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts'; +import { + buildDashboardInsightContext, + buildDashboardInsightMetrics, +} from '../utils/insightContextBuilders.ts'; import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx'; import DashboardCloseCycleControls from './dashboard/DashboardCloseCycleControls.tsx'; import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx'; import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx'; import CountingAccuracyWarningBanner from './counting/CountingAccuracyWarningBanner.tsx'; import type { DashboardReportType } from '../types/dashboardReport.ts'; +import { AiInsightCard } from './AiInsightCard.tsx'; type Props = { setView: (view: View) => void }; @@ -35,7 +39,7 @@ function chronological(rows: T[]): T[] { } const Dashboard: React.FC = ({ setView }) => { - const { selectedCycle, selectedKandang, selectedSite } = useFarm(); + const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [kpis, setKpis] = useState([]); @@ -43,7 +47,6 @@ const Dashboard: React.FC = ({ setView }) => { const [manualInputs, setManualInputs] = useState([]); const [weights, setWeights] = useState([]); const [karung, setKarung] = useState(null); - const [insights, setInsights] = useState([]); const [reportOpen, setReportOpen] = useState(false); const [reportType, setReportType] = useState('daily'); @@ -54,7 +57,6 @@ const Dashboard: React.FC = ({ setView }) => { setManualInputs([]); setWeights([]); setKarung(null); - setInsights([]); return; } const cycleId = selectedCycle.id; @@ -63,12 +65,11 @@ const Dashboard: React.FC = ({ setView }) => { setLoading(true); setError(null); try { - const [kpiRows, countingRows, manualRows, weightRows, insightRows] = await Promise.all([ + const [kpiRows, countingRows, manualRows, weightRows] = await Promise.all([ api.kpis.list({ cycle_id: cycleId }), api.countings.list({ cycle_id: cycleId }), api.manualInputs.list({ cycle_id: cycleId }), api.weights.list({ cycle_id: cycleId }), - api.insights.list({ cycle_id: cycleId }), ]); let latestKarung: Karung | null = null; try { @@ -82,7 +83,6 @@ const Dashboard: React.FC = ({ setView }) => { setManualInputs(manualRows); setWeights(weightRows); setKarung(latestKarung); - setInsights(insightRows); } catch (err) { if (!cancelled) setError(errorMessage(err)); } finally { @@ -211,6 +211,73 @@ const Dashboard: React.FC = ({ setView }) => { }); }, [selectedCycle, countings, kpis, manualInputs]); + const insightContext = useMemo(() => { + if (selectedKandangId == null) return null; + const visibleThrough = selectedCycle?.visible_through_date; + const visibleKpis = throughVisibleDate(kpis, visibleThrough); + const latest = visibleKpis[0] ?? null; + const anchorDate = latest?.date ?? null; + const weightsThroughAnchor = anchorDate + ? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate) + : []; + const weightOnAnchor = anchorDate + ? (weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0]) + : null; + const bobotAvg = + latest && latest.iot_weight > 0 + ? latest.iot_weight + : (weightOnAnchor?.average_weight ?? null); + + return buildDashboardInsightContext({ + kandangId: selectedKandangId, + kandangName: selectedKandang?.kandang_name, + cycleId: selectedCycle?.id, + // Prefer latest KPI age — cycle.current_day can be ahead of available data. + hari_ke: latest?.age ?? selectedCycle?.current_day ?? null, + totalDays: selectedCycle?.total_days ?? null, + fcr_terakhir: latest?.fcr ?? null, + eef_terakhir: latest?.eef ?? null, + bobot_avg_gram: bobotAvg, + mortalitas_hari_ini_ekor: latest?.mortality ?? null, + karung_dituang_hari_ini: + latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null), + pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null, + ayam_hidup_ekor: latest?.chicken_life ?? null, + persen_hidup: latest?.chicken_life_percentage ?? null, + doc_in_ekor: selectedCycle?.doc_in_count ?? null, + kematian_kumulatif_ekor: latest?.mortality_total ?? null, + panen_kumulatif_ekor: latest?.harvest_total ?? null, + tonase_panen_kg: latest?.harvest_weight_total ?? null, + uniformity_persen: weightOnAnchor?.uniformity ?? null, + }); + }, [ + selectedKandangId, + selectedKandang, + selectedCycle, + kpis, + weights, + karung, + ]); + + const insightMetrics = useMemo(() => { + if (selectedKandangId == null) return null; + const visibleThrough = selectedCycle?.visible_through_date; + const visibleKpis = throughVisibleDate(kpis, visibleThrough); + const latest = visibleKpis[0] ?? null; + const anchorDate = latest?.date ?? null; + const weightsThroughAnchor = anchorDate + ? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate) + : throughVisibleDate(weights, visibleThrough); + + return buildDashboardInsightMetrics({ + kpis: visibleKpis, + weights: weightsThroughAnchor, + currentDay: latest?.age ?? selectedCycle?.current_day ?? null, + docInCount: selectedCycle?.doc_in_count ?? null, + pakanKumulatifFallback: karung?.feed_use_total ?? latest?.feed_total ?? null, + }); + }, [selectedKandangId, selectedCycle, kpis, weights, karung]); + if (!selectedCycle) return setView('kandangSettings')} />; const feedTotal = karung?.feed_use_total ?? kpis[0]?.feed_total ?? 0; @@ -344,32 +411,17 @@ const Dashboard: React.FC = ({ setView }) => {
-

AI Insight

- {insights.length === 0 ? ( - Belum ada insight untuk siklus ini. + {selectedKandangId != null ? ( + ) : ( -
- {insights.slice(0, 4).map((item) => ( -
-
-
-

- {item.section} -

-

- {item.alert || 'Insight'} -

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} +
+ Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak + didukung.
)}
diff --git a/components/EefDashboardPage.tsx b/components/EefDashboardPage.tsx index af59bc0..5834093 100644 --- a/components/EefDashboardPage.tsx +++ b/components/EefDashboardPage.tsx @@ -9,10 +9,12 @@ import { YAxis, } from 'recharts'; import { api, errorMessage } from '../services/apiClient.ts'; -import type { AIInsight, KPI } from '../types/api.ts'; +import type { KPI } from '../types/api.ts'; import { useFarm } from '../context/FarmContext.tsx'; import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx'; import { formatNumber, formatRatio } from '../utils/format.ts'; +import { buildEefInsightContext } from '../utils/insightContextBuilders.ts'; +import { PageAiInsight } from './shared/PageAiInsight.tsx'; type EefRow = { day: number; @@ -164,9 +166,8 @@ const SupportingMetricChart: React.FC<{ ); const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { - const { selectedCycle } = useFarm(); + const { selectedCycle, selectedKandang } = useFarm(); const [rows, setRows] = useState([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); @@ -176,12 +177,8 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) if (!selectedCycle) return; setError(null); try { - const [kpiRows, insightRows] = await Promise.all([ - api.kpis.list({ cycle_id: selectedCycle.id }), - api.insights.list({ cycle_id: selectedCycle.id }), - ]); + const kpiRows = await api.kpis.list({ cycle_id: selectedCycle.id }); setRows(kpiRows); - setInsights(insightRows); setLastUpdated(new Date()); } catch (err) { setError(errorMessage(err)); @@ -191,7 +188,6 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) useEffect(() => { if (!selectedCycle) { setRows([]); - setInsights([]); setLastUpdated(null); return; } @@ -262,21 +258,16 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) const latestEef = getLatestValid('eef'); - const eefInsights = useMemo(() => { - const matched = insights.filter((item) => { - const text = - `${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase(); - return ( - text.includes('eef') || - text.includes('fcr') || - text.includes('hidup') || - text.includes('live') || - text.includes('panen') || - text.includes('bw') - ); + const insightContext = useMemo(() => { + if (!selectedKandang) return null; + return buildEefInsightContext({ + kandangId: selectedKandang.id, + kandangName: selectedKandang.kandang_name, + cycleId: selectedCycle?.id, + totalDays: selectedCycle?.total_days ?? null, + eefRows, }); - return (matched.length > 0 ? matched : insights).slice(0, 4); - }, [insights]); + }, [selectedKandang, selectedCycle, eefRows]); if (!selectedCycle) return ; @@ -304,37 +295,53 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) )} -
-

- - AI Insight EEF -

- {loading ? ( - - ) : eefInsights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {eefInsights.map((item) => ( -
-
-
-

{item.section}

-

{item.alert || 'Insight'}

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight EEF +

+ Pilih kandang untuk melihat dan generate AI Insight. +
+ )} {loading ? ( diff --git a/components/FcrDashboardPage.tsx b/components/FcrDashboardPage.tsx index 06ca354..214b145 100644 --- a/components/FcrDashboardPage.tsx +++ b/components/FcrDashboardPage.tsx @@ -9,10 +9,12 @@ import { YAxis, } from 'recharts'; import { api, errorMessage } from '../services/apiClient.ts'; -import type { AIInsight, ChickenWeight, KPI } from '../types/api.ts'; +import type { ChickenWeight, KPI } from '../types/api.ts'; import { useFarm } from '../context/FarmContext.tsx'; import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx'; import { formatNumber, formatRatio } from '../utils/format.ts'; +import { buildFcrInsightContext } from '../utils/insightContextBuilders.ts'; +import { PageAiInsight } from './shared/PageAiInsight.tsx'; type FcrRow = { day: number; @@ -155,10 +157,9 @@ const SupportingMetricChart: React.FC<{ ); const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { - const { selectedCycle } = useFarm(); + const { selectedCycle, selectedKandang } = useFarm(); const [rows, setRows] = useState([]); const [weightRows, setWeightRows] = useState([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); @@ -168,13 +169,11 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) if (!selectedCycle) return; setError(null); try { - const [kpiRows, insightRows, weightList] = await Promise.all([ + const [kpiRows, weightList] = await Promise.all([ api.kpis.list({ cycle_id: selectedCycle.id }), - api.insights.list({ cycle_id: selectedCycle.id }), api.weights.list({ cycle_id: selectedCycle.id }), ]); setRows(kpiRows); - setInsights(insightRows); setWeightRows(weightList); setLastUpdated(new Date()); } catch (err) { @@ -186,7 +185,6 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) if (!selectedCycle) { setRows([]); setWeightRows([]); - setInsights([]); setLastUpdated(null); return; } @@ -268,20 +266,16 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) const latestFcr = getLatestValid('fcr'); const latestIotWeight = getLatestValid('iotWeight'); - const fcrInsights = useMemo(() => { - const matched = insights.filter((item) => { - const text = - `${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase(); - return ( - text.includes('fcr') || - text.includes('pakan') || - text.includes('feed') || - text.includes('konversi') || - text.includes('panen') - ); + const insightContext = useMemo(() => { + if (!selectedKandang) return null; + return buildFcrInsightContext({ + kandangId: selectedKandang.id, + kandangName: selectedKandang.kandang_name, + cycleId: selectedCycle?.id, + totalDays: selectedCycle?.total_days ?? null, + fcrRows, }); - return (matched.length > 0 ? matched : insights).slice(0, 4); - }, [insights]); + }, [selectedKandang, selectedCycle, fcrRows]); if (!selectedCycle) return ; @@ -309,37 +303,53 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) )} -
-

- - AI Insight FCR -

- {loading ? ( - - ) : fcrInsights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {fcrInsights.map((item) => ( -
-
-
-

{item.section}

-

{item.alert || 'Insight'}

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight FCR +

+ Pilih kandang untuk melihat dan generate AI Insight. +
+ )} {loading ? ( diff --git a/components/FeedSackCountingPage.tsx b/components/FeedSackCountingPage.tsx index af65716..2fc0c87 100644 --- a/components/FeedSackCountingPage.tsx +++ b/components/FeedSackCountingPage.tsx @@ -1,21 +1,22 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { api, errorMessage } from '../services/apiClient.ts'; -import type { AIInsight, Cycle, Karung, ManualInput } from '../types/api.ts'; +import type { Cycle, Karung, ManualInput } from '../types/api.ts'; import { useFarm } from '../context/FarmContext.tsx'; import { Banner, EmptyFarm, Spinner } from './ui/Feedback.tsx'; import { addDaysIso, cycleDayForDate, formatDateId, resolveCycleStatus, todayIso } from '../utils/format.ts'; +import { buildFeedSackInsightContext } from '../utils/insightContextBuilders.ts'; import FeedStats from './feedSack/FeedStats.tsx'; import FeedSaldoCard from './feedSack/FeedSaldoCard.tsx'; import FeedTrendChart from './feedSack/FeedTrendChart.tsx'; import FeedDailySummaryTable from './feedSack/FeedDailySummaryTable.tsx'; import InitialBalanceCard from './feedSack/InitialBalanceCard.tsx'; +import { PageAiInsight } from './shared/PageAiInsight.tsx'; const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const { selectedCycle, selectedKandang, selectedSite, cycles, setSelectedCycleId, updateCycle } = useFarm(); const [rows, setRows] = useState([]); const [manualRows, setManualRows] = useState([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); @@ -26,13 +27,11 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings setLoading(true); setError(null); try { - const [karungRows, insightRows, manualList] = await Promise.all([ + const [karungRows, manualList] = await Promise.all([ api.karungs.list({ cycle_id: cycleId }), - api.insights.list({ cycle_id: cycleId }), api.manualInputs.list({ cycle_id: cycleId }), ]); setRows(karungRows); - setInsights(insightRows); setManualRows(manualList); } catch (err) { setError(errorMessage(err)); @@ -44,7 +43,6 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings useEffect(() => { if (!selectedCycle) { setRows([]); - setInsights([]); setManualRows([]); return; } @@ -110,6 +108,43 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings })} ${date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}`; }, [latest]); + const insightContext = useMemo(() => { + if (!selectedKandang || !selectedCycle) return null; + const manualByDate = new Map(sortedManualRows.map((row) => [row.date, row])); + const dailyRows = sortedRows.map((row) => { + const hari = cycleDayForDate(selectedCycle.start_date, row.date); + const manual = manualByDate.get(row.date); + return { + day: hari, + masuk: row.in_today, + dituang: row.feed_use_today, + keluar: row.out_today, + masukManual: manual?.feed_in_manual ?? null, + dituangManual: manual?.feed_use_manual ?? null, + }; + }); + return buildFeedSackInsightContext({ + kandangId: selectedKandang.id, + kandangName: selectedKandang.kandang_name, + cycleId: selectedCycle.id, + totalDays: selectedCycle.total_days, + dailyRows, + saldo_awal_karung: selectedCycle.feed_initial_balance ?? null, + total_karung_masuk_iot: latest?.in_total ?? null, + total_karung_dituang_iot: latest?.feed_use_total ?? null, + total_karung_keluar_iot: latest?.out_total ?? null, + total_karung_masuk_manual: latestManual?.feed_in_manual_total ?? null, + total_karung_dituang_manual: latestManual?.feed_use_manual_total ?? null, + }); + }, [ + selectedKandang, + selectedCycle, + sortedRows, + sortedManualRows, + latest, + latestManual, + ]); + if (!selectedCycle) return ; const cycleStatus = resolveCycleStatus(selectedCycle); @@ -249,40 +284,70 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings ) : (
- {/* AI Insight */} -
-

- - AI Insight Hitung Karung -

- {insights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {insights.slice(0, 4).map((item) => ( -
-
-
-

- {item.section} -

-

- {item.alert || 'Insight'} -

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight Hitung Karung +

+ Pilih kandang untuk melihat dan generate AI Insight. +
+ )} {/* Saldo Awal */} void }> = ({ onSettings }) => { const [dates, setDates] = useState([]); const [selectedDate, setSelectedDate] = useState(''); const [dayRows, setDayRows] = useState([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); @@ -82,7 +88,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { setDates([]); setSelectedDate(''); setDayRows([]); - setInsights([]); return; } let cancelled = false; @@ -109,7 +114,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { setDates([]); setSelectedDate(''); setDayRows([]); - setInsights([]); return; } let cancelled = false; @@ -117,19 +121,26 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { setLoading(true); setError(null); try { - const [dateList, insightRows] = await Promise.all([ - api.panelIot.dates({ cycle_id: selectedCycle.id, flock_id: selectedFlockId }), - api.insights.list({ cycle_id: selectedCycle.id }), - ]); + const dateList = await api.panelIot.dates({ + cycle_id: selectedCycle.id, + flock_id: selectedFlockId, + }); if (cancelled) return; const sortedDates = [...dateList].sort(); setDates(sortedDates); - setInsights(insightRows); - setSelectedDate((previous) => - previous && sortedDates.includes(previous) - ? previous - : (sortedDates[sortedDates.length - 1] ?? '') - ); + const cycleMin = selectedCycle.start_date; + const cycleMax = resolveCycleBoundDate(selectedCycle); + const fallbackDate = todayIso() > cycleMax ? cycleMax : todayIso() < cycleMin ? cycleMin : todayIso(); + setSelectedDate((previous) => { + if (sortedDates.length > 0) { + return previous && sortedDates.includes(previous) + ? previous + : (sortedDates[sortedDates.length - 1] ?? fallbackDate); + } + // No IoT dates yet — keep a navigable date inside the cycle window. + if (previous && previous >= cycleMin && previous <= cycleMax) return previous; + return fallbackDate; + }); } catch (err) { if (!cancelled) setError(errorMessage(err)); } finally { @@ -224,21 +235,29 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { [summary, dayAge] ); - const iotInsights = useMemo(() => { - const matched = insights.filter((item) => { - const text = - `${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase(); - return ( - text.includes('iot') || - text.includes('suhu') || - text.includes('kelembapan') || - text.includes('humidity') || - text.includes('temperature') || - text.includes('lingkungan') - ); + const insightContext = useMemo(() => { + if (!selectedKandang) return null; + return buildIotInsightContext({ + kandangId: selectedKandang.id, + kandangName: selectedKandang.kandang_name, + cycleId: selectedCycle?.id, + hari_ke: dayAge, + totalDays: selectedCycle?.total_days ?? null, + suhu_rata_rata_C: summary?.temp ?? null, + experience_suhu_C: summary?.experienceTemp ?? null, + kelembapan_persen: summary?.humidity ?? null, + kecepatan_angin_mPerDetik: summary?.windSpeed ?? null, + konsumsi_air_L: summary?.waterTotal ?? null, + setpoint_C: getTempStandardByDay(dayAge), + suhu_min_C: summary?.minTemp ?? null, + suhu_max_C: summary?.maxTemp ?? null, + waktu_data: selectedDate || null, }); - return (matched.length > 0 ? matched : insights).slice(0, 4); - }, [insights]); + }, [selectedKandang, selectedCycle, summary, dayAge, selectedDate]); + + // Always allow navigating the cycle window — never hide empty days behind missing IoT dates. + const datePickerMin = selectedCycle?.start_date ?? undefined; + const datePickerMax = selectedCycle ? resolveCycleBoundDate(selectedCycle) : undefined; const refresh = () => { setRefreshing(true); @@ -285,63 +304,94 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { /> {error && {error}} - {loading && dayRows.length === 0 ? ( - - ) : summary ? ( - <> -
-

- - AI Insight Panel IoT -

- {iotInsights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {iotInsights.map((item) => ( -
-
-
-

- {item.section} -

-

- {item.alert || 'Insight'} -

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
-
- - - - setSelectedDate(event.target.value)} - className="rounded-lg border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-700 focus:ring-2 focus:ring-red-500 focus:border-red-500" - /> - - {summary.count} pembacaan -
+ {selectedFlockId == null ? ( +
+

Pilih flock untuk melihat data panel IoT.

+
+ ) : ( +
+ + + + setSelectedDate(event.target.value)} + className="rounded-lg border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-700 focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> + + + {summary + ? `${summary.count} pembacaan` + : dates.length === 0 + ? 'Belum ada tanggal dengan data IoT' + : 'Tidak ada pembacaan pada tanggal ini'} + +
+ )} + {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight Panel IoT +

+ Pilih kandang untuk melihat dan generate AI Insight. +
+ )} + + {selectedFlockId != null && + (loading && dayRows.length === 0 ? ( + + ) : summary ? ( + <>
@@ -518,14 +568,16 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
- - ) : ( -
-

- Belum ada data panel IoT untuk flock ini. -

-
- )} + + ) : ( +
+

+ {dates.length === 0 + ? 'Belum ada data panel IoT untuk flock ini. Tanggal tetap bisa dipilih dalam rentang siklus; generate ulang setelah data tersedia.' + : `Belum ada data panel IoT untuk ${selectedDate ? formatDateId(selectedDate) : 'tanggal ini'}.`} +

+
+ ))} {selectedChartKey && (() => { diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 94a65f5..868c2c4 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -468,7 +468,7 @@ const SettingsPage: React.FC = () => {

Flock

- Flock tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus. + Lantai tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus.

{isSuperAdmin ? (
{
) : ( -

Hanya admin yang dapat membuat flock.

+

Hanya admin yang dapat membuat lantai.

)} {!selectedKandangId ? (

Pilih kandang terlebih dahulu.

diff --git a/components/WeightMonitoringPage.tsx b/components/WeightMonitoringPage.tsx index a6ec186..9d863ac 100644 --- a/components/WeightMonitoringPage.tsx +++ b/components/WeightMonitoringPage.tsx @@ -1,20 +1,21 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api, ApiError, errorMessage } from '../services/apiClient.ts'; -import type { AIInsight, ChickenWeight, LatestAvg } from '../types/api.ts'; +import type { ChickenWeight, LatestAvg } from '../types/api.ts'; import { useFarm } from '../context/FarmContext.tsx'; import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx'; import { formatDateId, formatDateRange, formatNumber, resolveCycleStatus } from '../utils/format.ts'; import { formatWeight } from '../utils/weight.ts'; +import { buildWeightInsightContext } from '../utils/insightContextBuilders.ts'; import WeightStats from './weight/WeightStats.tsx'; import DailyWeightProgressionChart from './weight/DailyWeightProgressionChart.tsx'; import DailyWeightGainChart from './weight/DailyWeightGainChart.tsx'; import DailyDetailPanel from './weight/DailyDetailPanel.tsx'; +import { PageAiInsight } from './shared/PageAiInsight.tsx'; const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { const { selectedCycle, selectedSite, selectedKandang } = useFarm(); const [rows, setRows] = useState([]); const [latest, setLatest] = useState(null); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); @@ -26,12 +27,8 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings const reload = async (cycleId: number) => { setError(null); try { - const [list, insightRows] = await Promise.all([ - api.weights.list({ cycle_id: cycleId }), - api.insights.list({ cycle_id: cycleId }), - ]); + const list = await api.weights.list({ cycle_id: cycleId }); setRows(list); - setInsights(insightRows); try { setLatest(await api.weights.latestAvg({ cycle_id: cycleId })); } catch (err) { @@ -47,7 +44,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings if (!selectedCycle) { setRows([]); setLatest(null); - setInsights([]); setSelectedDate(''); return; } @@ -106,14 +102,6 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings const minDate = rows.length > 0 ? (rows[rows.length - 1]?.date ?? '') : ''; const maxDate = rows.length > 0 ? (rows[0]?.date ?? '') : ''; - const weightInsights = useMemo(() => { - const matched = insights.filter((item) => { - const section = `${item.section ?? ''} ${item.alert ?? ''}`.toLowerCase(); - return section.includes('berat') || section.includes('weight') || section.includes('timbang'); - }); - return (matched.length > 0 ? matched : insights).slice(0, 4); - }, [insights]); - // Prefer the latest non-zero row for the KPI card so a closed cycle with // trailing empty days does not read as "0 g". const kpiLatest = useMemo(() => { @@ -132,6 +120,20 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings }; }, [latest, rows]); + const insightContext = useMemo(() => { + if (!selectedKandang) return null; + return buildWeightInsightContext({ + kandangId: selectedKandang.id, + kandangName: selectedKandang.kandang_name, + cycleId: selectedCycle?.id, + totalDays: selectedCycle?.total_days ?? null, + weightRows: chartRows, + averageWeight: kpiLatest?.average_weight ?? null, + uniformity: kpiLatest?.uniformity ?? null, + adg: kpiLatest?.average_daily_gain ?? null, + }); + }, [selectedKandang, selectedCycle, chartRows, kpiLatest]); + if (!selectedCycle) return ; return ( @@ -177,37 +179,53 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings ) : (
- {/* AI Insight */} -
-

AI Insight

- {weightInsights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {weightInsights.map((item) => ( -
-
-
-

- {item.section} -

-

- {item.alert || 'Insight'} -

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( + + ) : ( +
+

+ + AI Insight Berat Ayam +

+ Pilih kandang untuk melihat dan generate AI Insight. +
+ )} {/* TOP SECTION: DATA SELURUH SIKLUS */}
diff --git a/components/__tests__/FeedSackCountingPage.test.tsx b/components/__tests__/FeedSackCountingPage.test.tsx index d9507fc..07eebf5 100644 --- a/components/__tests__/FeedSackCountingPage.test.tsx +++ b/components/__tests__/FeedSackCountingPage.test.tsx @@ -179,9 +179,6 @@ vi.mock('../../services/apiClient.ts', () => ({ list: vi.fn().mockResolvedValue(karungRows), request: vi.fn().mockResolvedValue({ karung: {}, upstream: {} }), }, - insights: { - list: vi.fn().mockResolvedValue([]), - }, manualInputs: { list: vi.fn().mockResolvedValue(manualRows), }, @@ -189,6 +186,10 @@ vi.mock('../../services/apiClient.ts', () => ({ errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'), })); +vi.mock('../shared/PageAiInsight.tsx', () => ({ + PageAiInsight: () =>
, +})); + vi.mock('../../context/FarmContext.tsx', () => ({ useFarm: () => ({ selectedSite: { id: 1, site_name: 'Sukawarna', user: 1, created_at: '', updated_at: '' }, @@ -199,6 +200,7 @@ vi.mock('../../context/FarmContext.tsx', () => ({ created_at: '', updated_at: '', }, + selectedKandangId: 1, selectedCycle: cycle, cycles: [cycle], setSelectedCycleId: vi.fn(), @@ -216,7 +218,6 @@ describe('FeedSackCountingPage', () => { beforeEach(() => { vi.mocked(api.karungs.list).mockResolvedValue(karungRows); vi.mocked(api.manualInputs.list).mockResolvedValue(manualRows); - vi.mocked(api.insights.list).mockResolvedValue([]); }); it('renders the recreated hitung karung view with stats, saldo, and daily table', async () => { @@ -229,7 +230,7 @@ describe('FeedSackCountingPage', () => { screen.getByText('Monitoring karung masuk dan karung dituang per hari') ).toBeInTheDocument(); expect(screen.getByText(/10 Agu 2026 - 13 Sep 2026/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Sync hari ini/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sync/i })).toBeInTheDocument(); // Ringkasan stats expect(screen.getAllByText('Karung Masuk').length).toBeGreaterThan(0); diff --git a/components/counting/DataManagementPage.tsx b/components/counting/DataManagementPage.tsx index e8be52b..15fd8dd 100644 --- a/components/counting/DataManagementPage.tsx +++ b/components/counting/DataManagementPage.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { api, errorMessage } from '../../services/apiClient.ts'; -import type { AIInsight, ChickenCounting, KPI, ManualInput } from '../../types/api.ts'; +import type { ChickenCounting, KPI, ManualInput } from '../../types/api.ts'; import { useFarm } from '../../context/FarmContext.tsx'; import { Banner, EmptyFarm, PageHeader, Spinner } from '../ui/Feedback.tsx'; import { @@ -13,9 +13,11 @@ import { resolveCycleStatus, todayIso, } from '../../utils/format.ts'; +import { buildCountingInsightContext } from '../../utils/insightContextBuilders.ts'; import PopulationTrendChart from './PopulationTrendChart.tsx'; import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx'; import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts'; +import { PageAiInsight } from '../shared/PageAiInsight.tsx'; type Row = { day: number; @@ -50,7 +52,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } const [kpis, setKpis] = useState([]); const [countings, setCountings] = useState([]); const [manualRows, setManualRows] = useState([]); - const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(null); @@ -64,16 +65,14 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } setLoading(true); setError(null); try { - const [k, c, m, i] = await Promise.all([ + const [k, c, m] = await Promise.all([ api.kpis.list({ cycle_id: cycleId }), api.countings.list({ cycle_id: cycleId }), api.manualInputs.list({ cycle_id: cycleId }), - api.insights.list({ cycle_id: cycleId }), ]); setKpis(k); setCountings(c); setManualRows(m); - setInsights(i); } catch (err) { setError(errorMessage(err)); } finally { @@ -86,7 +85,6 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } setKpis([]); setCountings([]); setManualRows([]); - setInsights([]); return; } void loadData(selectedCycle.id); @@ -192,9 +190,8 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } const totalPanen = latest?.harvestTotal ?? 0; const totalBeratPanen = latest?.harvestWeightTotal ?? 0; const currentPopulation = latest?.stockAkhir ?? initialPopulation; - const mortalityRate = initialPopulation - ? ((totalMortality / initialPopulation) * 100).toFixed(2) - : '0.00'; + const mortalityRate = + initialPopulation > 0 ? (totalMortality / initialPopulation) * 100 : null; return { initialPopulation, @@ -206,6 +203,38 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } }; }, [rows, selectedCycle]); + const insightContext = useMemo(() => { + if (!selectedKandang || !selectedCycle) return null; + // Cap insight history to last day with KPI (or counting) data — calendar + // padding can extend past days that actually have operational figures. + const lastKpiAge = kpis.length > 0 ? Math.max(...kpis.map((k) => k.age)) : null; + const lastCountingDay = + countings.length > 0 + ? Math.max( + ...countings.map((c) => cycleDayForDate(selectedCycle.start_date, c.date)) + ) + : null; + const lastDataDay = + lastKpiAge != null || lastCountingDay != null + ? Math.max(lastKpiAge ?? 0, lastCountingDay ?? 0) + : null; + const insightRows = + lastDataDay != null ? rows.filter((row) => row.day <= lastDataDay) : rows; + return buildCountingInsightContext({ + kandangId: selectedKandang.id, + kandangName: selectedKandang.kandang_name, + cycleId: selectedCycle.id, + totalDays: selectedCycle.total_days ?? null, + dailyRows: insightRows, + populasi_awal_ekor: stats.initialPopulation > 0 ? stats.initialPopulation : null, + populasi_kini_ekor: stats.currentPopulation, + mortalitas_kumulatif_ekor: stats.totalMortality, + mortalitas_persen: stats.mortalityRate, + panen_kumulatif_ekor: stats.totalPanen, + berat_panen_kumulatif_kg: stats.totalBeratPanen, + }); + }, [selectedKandang, selectedCycle, rows, stats, kpis, countings]); + const chartData = useMemo( () => rows.map((r) => ({ @@ -306,36 +335,41 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings } )} - {/* AI Insight */} -
-

- - AI Insight -

- {insights.length === 0 ? ( - Belum ada insight untuk siklus ini. - ) : ( -
- {insights.slice(0, 4).map((item) => ( -
-
-
-

{item.section}

-

{item.alert || 'Insight'}

-
- {item.session} -
-

- {item.insight_text} -

-
- ))} -
- )} -
+ {selectedKandang && insightContext ? ( +
+ +
+ ) : ( +
+

+ + AI Insight +

+ Pilih kandang untuk melihat dan generate AI Insight. +
+ )} {/* Population Chart */}
@@ -413,7 +447,9 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }

Tingkat Mortalitas

-

{stats.mortalityRate}%

+

+ {stats.mortalityRate == null ? 'N/A' : `${stats.mortalityRate.toFixed(2)}%`} +

diff --git a/components/counting/__tests__/DataManagementPage.test.tsx b/components/counting/__tests__/DataManagementPage.test.tsx index 3be2131..e387877 100644 --- a/components/counting/__tests__/DataManagementPage.test.tsx +++ b/components/counting/__tests__/DataManagementPage.test.tsx @@ -28,13 +28,14 @@ vi.mock('../../../services/apiClient.ts', () => ({ ]), create: vi.fn(), }, - insights: { - list: vi.fn().mockResolvedValue([]), - }, }, errorMessage: (err: unknown) => (err instanceof Error ? err.message : 'Terjadi kesalahan'), })); +vi.mock('../../shared/PageAiInsight.tsx', () => ({ + PageAiInsight: () =>
, +})); + vi.mock('../../../context/FarmContext.tsx', () => ({ useFarm: () => ({ selectedSite: { id: 1, site_name: 'Sukawarna', user: 1, created_at: '', updated_at: '' }, @@ -45,6 +46,7 @@ vi.mock('../../../context/FarmContext.tsx', () => ({ created_at: '', updated_at: '', }, + selectedKandangId: 1, selectedCycle: { id: 1, kandang: 1, diff --git a/components/shared/InsightConditionChart.tsx b/components/shared/InsightConditionChart.tsx new file mode 100644 index 0000000..10139de --- /dev/null +++ b/components/shared/InsightConditionChart.tsx @@ -0,0 +1,105 @@ +import React from 'react'; + +export interface ConditionBar { + label: string; + actual: number | null; + standard: number | null; + unit: string; + /** Optional note shown under the bar, e.g. why a standard is absent. */ + note?: string; +} + +export interface InsightConditionChartProps { + bars: ConditionBar[]; + title?: string; + emptyLabel?: string; +} + +const AKTUAL = '#dc2626'; +const STANDAR = '#3b82f6'; + +const fmt = (value: number, unit: string) => + `${Number(value).toLocaleString('id-ID', { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ''}`; + +/** + * Current readings against their CP 707 standard, one row per metric. + * Used when there is no per-day history (e.g. live IoT snapshot). + */ +const InsightConditionChart: React.FC = ({ + bars, + title, + emptyLabel = 'Belum ada pembacaan sensor untuk digambarkan.', +}) => { + const usable = bars.filter((bar) => bar.actual !== null || bar.standard !== null); + + if (usable.length === 0) { + return ( +
+ {emptyLabel} +
+ ); + } + + return ( +
+ {title &&

{title}

} +
+ + + Aktual + + + + Standar CP 707 + +
+ +
+ {usable.map((bar) => { + const max = Math.max(bar.actual ?? 0, bar.standard ?? 0, 1); + const pct = (value: number | null) => + value === null ? 0 : Math.max(2, Math.round((value / max) * 100)); + + return ( +
+
+ {bar.label} + + {bar.actual === null ? 'data tidak tersedia' : fmt(bar.actual, bar.unit)} + {bar.standard !== null && ( + · standar {fmt(bar.standard, bar.unit)} + )} + +
+ +
+
+ {bar.actual !== null && ( +
+ )} +
+
+ {bar.standard !== null && ( +
+ )} +
+
+ + {bar.note &&

{bar.note}

} +
+ ); + })} +
+
+ ); +}; + +export default InsightConditionChart; diff --git a/components/shared/InsightStatTiles.tsx b/components/shared/InsightStatTiles.tsx new file mode 100644 index 0000000..a27afb3 --- /dev/null +++ b/components/shared/InsightStatTiles.tsx @@ -0,0 +1,169 @@ +import React from 'react'; + +/** + * Headline figures for AI Insight cards. + * Matches NEW dashboard gray card patterns (white + border-gray-100). + */ + +export type InsightStatus = 'ok' | 'warning' | 'critical' | 'unknown'; + +const STATUS_CHIP: Record = { + ok: { cls: 'bg-green-50 text-green-800 border-green-200', glyph: '✓' }, + warning: { cls: 'bg-amber-50 text-amber-800 border-amber-200', glyph: '!' }, + critical: { cls: 'bg-red-50 text-red-800 border-red-200', glyph: '⚠' }, + unknown: { cls: 'bg-gray-50 text-gray-500 border-gray-200', glyph: '–' }, +}; + +interface DeltaChipProps { + status: InsightStatus; + label: string; +} + +export const DeltaChip: React.FC = ({ status, label }) => { + const chip = STATUS_CHIP[status]; + return ( + + + {label} + + ); +}; + +/** Axis-less sparkline — shape only; the tile number carries the value. */ +export const Sparkline: React.FC<{ values: number[]; className?: string }> = ({ + values, + className, +}) => { + const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v)); + if (clean.length < 2) return null; + + const width = 88; + const height = 28; + const min = Math.min(...clean); + const max = Math.max(...clean); + const span = max - min || 1; + const step = width / (clean.length - 1); + const points = clean + .map((value, index) => { + const x = index * step; + const y = height - 2 - ((value - min) / span) * (height - 4); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); + + return ( + + ); +}; + +interface HeroFigureProps { + label: string; + value: string; + unit?: string; + context?: string | null; + status?: InsightStatus; + deltaLabel?: string | null; + sparkline?: number[]; +} + +export const HeroFigure: React.FC = ({ + label, + value, + unit, + context, + status = 'unknown', + deltaLabel, + sparkline, +}) => ( +
+
+
{label}
+
+ + {value} + + {unit ? {unit} : null} +
+
+ {deltaLabel ? : null} + {context ? {context} : null} +
+
+ {sparkline && sparkline.length > 1 ? ( + + ) : null} +
+); + +interface StatTileProps { + label: string; + value: string; + unit?: string; + hint?: string | null; + status?: InsightStatus; + deltaLabel?: string | null; + sparkline?: number[]; +} + +export const StatTile: React.FC = ({ + label, + value, + unit, + hint, + status = 'unknown', + deltaLabel, + sparkline, +}) => ( +
+
+ {label} + {hint ? {hint} : null} +
+
+ {value} + {unit ? {unit} : null} +
+ {deltaLabel ? ( +
+ +
+ ) : null} + {sparkline && sparkline.length > 1 ? ( + + ) : null} +
+); + +/** Secondary figures: subordinate to hero / tiles. */ +export const MutedStatRow: React.FC<{ label: string; hint?: string | null; value: string }> = ({ + label, + hint, + value, +}) => ( +
+ + {label} + {hint ? {hint} : null} + + {value} +
+); diff --git a/components/shared/InsightTrendChart.tsx b/components/shared/InsightTrendChart.tsx new file mode 100644 index 0000000..f46e6ce --- /dev/null +++ b/components/shared/InsightTrendChart.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { + Area, + CartesianGrid, + ComposedChart, + Legend, + Line, + ReferenceArea, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +/** One plotted line: which field in the row, how to name it, what colour. */ +export interface InsightSeries { + field: string; + label: string; + color: string; + /** Draw as a dashed reference line — for a target or standard, not a measurement. */ + dashed?: boolean; +} + +/** + * Shade the distance between a measurement and the standard it is judged against. + * + * Recharts has no between-two-series area, so the band is two stacked areas: a + * transparent base up to the lower of the pair, then the gap itself. + */ +export interface InsightGapSpec { + actualField: string; + targetField: string; +} + +export interface InsightTrendChartProps { + /** FULL history — the analysed window is highlighted, not isolated. */ + rows: Array>; + series: InsightSeries[]; + /** Inclusive day range the insight text actually covers. */ + highlight?: { firstDay: number; lastDay: number } | null; + unit?: string; + title?: string; + emptyLabel?: string; + gap?: InsightGapSpec | null; +} + +const dayOf = (row: Record): number | null => { + for (const key of ['hari', 'day']) { + const value = row[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + } + return null; +}; + +const numOrNull = (value: unknown) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +/** + * Trend behind an insight, with the analysed period marked. + */ +const InsightTrendChart: React.FC = ({ + rows, + series, + highlight, + unit, + title, + emptyLabel = 'Belum ada data untuk digambarkan.', + gap = null, +}) => { + const data = (Array.isArray(rows) ? rows : []) + .map((row) => { + const day = dayOf(row); + if (day === null) return null; + const point: Record = { hari: day }; + for (const s of series) point[s.field] = numOrNull(row[s.field]); + if (gap) { + const actual = numOrNull(row[gap.actualField]); + const target = numOrNull(row[gap.targetField]); + point.bandBase = actual !== null && target !== null ? Math.min(actual, target) : null; + point.bandSize = actual !== null && target !== null ? Math.abs(actual - target) : null; + } + return point; + }) + .filter((point): point is Record & { hari: number } => point !== null) + .sort((a, b) => a.hari - b.hari); + + const drawn = series.filter((s) => data.some((point) => point[s.field] !== null)); + + if (data.length === 0 || drawn.length === 0) { + return ( +
+ {emptyLabel} +
+ ); + } + + const days = data.map((point) => point.hari); + const step = Math.max(1, Math.ceil(days.length / 10)); + const ticks = days.filter((_, i) => i % step === 0 || i === days.length - 1); + + const band = + highlight && Number.isFinite(highlight.firstDay) && Number.isFinite(highlight.lastDay) + ? highlight + : null; + const bandFrom = band + ? band.firstDay === band.lastDay + ? band.firstDay - 0.4 + : band.firstDay + : 0; + const bandTo = band ? (band.firstDay === band.lastDay ? band.lastDay + 0.4 : band.lastDay) : 0; + + const gapFill = (() => { + if (!gap) return null; + const last = [...data] + .reverse() + .find((point) => point[gap.actualField] !== null && point[gap.targetField] !== null); + const actual = last?.[gap.actualField]; + const target = last?.[gap.targetField]; + if (typeof actual !== 'number' || typeof target !== 'number' || target === 0) { + return 'rgba(107, 114, 128, 0.12)'; + } + const deviation = ((actual - target) / target) * 100; + if (deviation >= -5) return 'rgba(12, 163, 12, 0.12)'; + if (deviation >= -15) return 'rgba(250, 178, 25, 0.18)'; + return 'rgba(208, 59, 59, 0.14)'; + })(); + + return ( +
+ {title &&

{title}

} + {band && ( +

+ Area berbayang = periode yang dibahas insight + {band.firstDay === band.lastDay + ? ` (hari ke-${band.lastDay})` + : ` (hari ke-${band.firstDay} s/d ke-${band.lastDay})`} +

+ )} + + + + {band && ( + + )} + `H${value}`} + /> + Number(value).toLocaleString('id-ID')} + /> + `Hari ke-${value}`} + formatter={(value, name) => { + const label = String(name ?? ''); + if (label === 'bandBase' || label === 'bandSize') return null; + if (value == null || Array.isArray(value)) return null; + return [`${Number(value).toLocaleString('id-ID')}${unit ? ` ${unit}` : ''}`, label]; + }} + /> + {drawn.length > 1 && } + {gap && gapFill && ( + <> + + + + )} + {drawn.map((s) => ( + + ))} + + +
+ ); +}; + +export default InsightTrendChart; diff --git a/components/shared/PageAiInsight.tsx b/components/shared/PageAiInsight.tsx new file mode 100644 index 0000000..b1d8526 --- /dev/null +++ b/components/shared/PageAiInsight.tsx @@ -0,0 +1,942 @@ +/** + * Per-page / per-topic AI Insight shell (FCR, weight, counting, IoT, …). + * + * Builds/scopes context, then loads cached insight on mount. + * LLM generate runs only when the user clicks Generate (force=true). + * Distinct from + * `AiInsightCard` (Dashboard report modes). See docs/ai-insight/README.md. + */ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useFarm } from '../../context/FarmContext.tsx'; +import { api, errorMessage } from '../../services/apiClient.ts'; +import type { + InsightCitation, + InsightContext, + InsightSource, + InsightTopic, +} from '../../types/api.ts'; +import { formatRatio } from '../../utils/format.ts'; +import { getCobbStandardForDay } from '../../utils/insightHelpers.ts'; +import { parseAiResult, type AiResult } from '../../utils/insightParse.ts'; +import { + scopeContextToTimeframe, + scopeScalarsToTimeframe, + describeTimeframe, + UNAVAILABLE, + type ScalarScopeSpec, +} from '../../utils/insightScope.ts'; +import InsightTrendChart, { type InsightSeries } from './InsightTrendChart.tsx'; +import InsightConditionChart, { type ConditionBar } from './InsightConditionChart.tsx'; +import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx'; + +const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes + +const _insightMemoryCache = new Map(); +const _insightInFlight = new Map>(); + +type CachedPageInsight = AiResult & { + source: InsightSource; + citations?: InsightCitation[]; +}; + +export type { InsightTopic }; + +export interface PageAiInsightProps { + topic: InsightTopic; + /** Curated context — must include kandangId (never null "all kandang"). */ + contextData: InsightContext | Record; + cacheKey: string; + scalarScope?: ScalarScopeSpec; + chartSpec?: + | { + type: 'trend'; + historyKey: string; + series: InsightSeries[]; + unit?: string; + title?: string; + gap?: { actualField: string; targetField: string }; + } + | { type: 'condition'; bars: ConditionBar[]; title?: string }; +} + +const TOPIC_META: Record = { + hitung_ayam: { title: 'AI Insight — Hitung Ayam', icon: 'fa-kiwi-bird' }, + berat_ayam: { title: 'AI Insight — Berat Ayam', icon: 'fa-weight-scale' }, + fcr: { title: 'AI Insight — FCR', icon: 'fa-wheat-awn' }, + eef: { title: 'AI Insight — EEF', icon: 'fa-chart-line' }, + iot_panel: { title: 'AI Insight — Panel IoT', icon: 'fa-temperature-half' }, + hitung_karung: { title: 'AI Insight — Hitung Karung', icon: 'fa-boxes-stacked' }, + dashboard: { title: 'AI Insight — Dashboard', icon: 'fa-brain' }, +}; + +const SOURCE_LABEL: Record = { + generated: { text: 'Baru dibuat', cls: 'bg-green-50 text-green-700 border-green-200' }, + cache: { text: 'Dari cache', cls: 'bg-blue-50 text-blue-700 border-blue-200' }, + local_fallback: { + text: 'Fallback lokal', + cls: 'bg-amber-50 text-amber-800 border-amber-200', + }, +}; + +const renderValue = (val: unknown, suffix: string = ''): string => { + if (val === undefined || val === null || val === '') return 'N/A'; + if (val === UNAVAILABLE) return UNAVAILABLE; + return `${val} ${suffix}`.trim(); +}; + +const requireKandangId = (ctx: Record): number => { + const raw = ctx.kandangId ?? ctx.kandang_id; + const id = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(id) || id <= 0) { + throw new Error('kandangId wajib — AI Insight tidak boleh digenerate untuk semua kandang'); + } + return id; +}; + +const _saveToCache = (storageKey: string, result: CachedPageInsight): void => { + _insightMemoryCache.set(storageKey, result); + try { + sessionStorage.setItem(storageKey, JSON.stringify(result)); + } catch { + /* ignore */ + } +}; + +const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => { + if (!citations || citations.length === 0) return null; + return ( +
+

+ Sumber referensi +

+
    + {citations.map((c, i) => ( +
  • + + {c.chapter || c.title || c.source || `Referensi ${i + 1}`} + + {c.excerpt ? — {c.excerpt} : null} +
  • + ))} +
+
+ ); +}; + +export const PageAiInsight: React.FC = ({ + topic, + contextData, + cacheKey, + scalarScope, + chartSpec, +}) => { + const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); + + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const supportsTimeframe = Boolean(scalarScope); + const timeframe: 'harian' | 'mingguan' = 'harian'; + + const selectedDayKey = `insight-day-${topic}`; + const [selectedDay, setSelectedDayState] = useState(() => { + try { + const raw = sessionStorage.getItem(selectedDayKey); + const parsed = raw === null ? NaN : parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } catch { + return null; + } + }); + const setSelectedDay = useCallback( + (next: number | null) => { + setSelectedDayState(next); + try { + if (next === null) sessionStorage.removeItem(selectedDayKey); + else sessionStorage.setItem(selectedDayKey, String(next)); + } catch { + /* ignore */ + } + }, + [selectedDayKey] + ); + + const availableDays = React.useMemo(() => { + const key = + scalarScope?.historyKey ?? (chartSpec?.type === 'trend' ? chartSpec.historyKey : null); + if (!key) return []; + const rows = (contextData as Record)[key]; + if (!Array.isArray(rows)) return []; + const days = new Set(); + for (const row of rows) { + if (typeof row !== 'object' || row === null) continue; + const r = row as Record; + for (const field of ['hari', 'day']) { + const value = r[field]; + if (typeof value === 'number' && Number.isFinite(value)) days.add(value); + } + } + return [...days].sort((a, b) => a - b); + }, [contextData, scalarScope, chartSpec]); + + const lastAvailableDay: number | null = + availableDays.length > 0 ? availableDays[availableDays.length - 1]! : null; + + // Dropdown shows last available day when nothing is selected — generate/cache + // must use that same day, not cycle.current_day (calendar can be ahead of data). + const activeDay: number | null = + timeframe === 'harian' && selectedDay !== null && availableDays.includes(selectedDay) + ? selectedDay + : null; + const effectiveDay: number | null = + timeframe === 'harian' ? (activeDay ?? lastAvailableDay) : null; + + const scopeKey = `${cacheKey}-${timeframe}${effectiveDay === null ? '' : `-h${effectiveDay}`}`; + + const inFlightRef = useRef(false); + const lastCacheKeyRef = useRef(null); + const desiredKeyRef = useRef(''); + const requestIdRef = useRef(0); + const generateRef = useRef<(force?: boolean) => Promise>(async () => {}); + + const chartHighlight = React.useMemo(() => { + if (!chartSpec || chartSpec.type !== 'trend') return null; + const rows = (contextData as Record)[chartSpec.historyKey]; + if (!Array.isArray(rows)) return null; + const days = rows + .map((row) => { + const r = row as Record; + for (const key of ['hari', 'day']) { + const value = r[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + } + return null; + }) + .filter((day): day is number => day !== null); + if (days.length === 0) return null; + if (timeframe === 'harian' && effectiveDay !== null) { + return { firstDay: 0, lastDay: effectiveDay }; + } + const lastDay = Math.max(...days); + const firstDay = timeframe === 'harian' ? lastDay : Math.max(Math.min(...days), lastDay - 6); + return { firstDay, lastDay }; + }, [chartSpec, contextData, timeframe, effectiveDay]); + + const displayContext = React.useMemo(() => { + const raw = contextData as Record; + if (!scalarScope) return raw; + const sliced = scopeContextToTimeframe(raw, timeframe, effectiveDay); + const scoped = scopeScalarsToTimeframe( + sliced as Record, + timeframe, + scalarScope, + effectiveDay + ); + const out: Record = { ...raw }; + for (const [outKey, rule] of Object.entries(scalarScope.derive)) { + if (rule.replaces) out[rule.replaces] = scoped[outKey]; + } + for (const key of scalarScope.unavailable ?? []) out[key] = UNAVAILABLE; + out.periode = scoped.periode; + return out; + }, [contextData, timeframe, effectiveDay, scalarScope]); + + const periodeLabel = typeof displayContext.periode === 'string' ? displayContext.periode : null; + + const trendChartRows = React.useMemo(() => { + if (!chartSpec || chartSpec.type !== 'trend') return []; + const rows = (contextData as Record)[chartSpec.historyKey]; + if (!Array.isArray(rows)) return []; + if (timeframe !== 'harian' || effectiveDay === null) { + return rows as Array>; + } + return (rows as Array>).filter((row) => { + for (const field of ['hari', 'day']) { + const value = row[field]; + if (typeof value === 'number' && Number.isFinite(value)) return value <= effectiveDay; + } + return true; + }); + }, [chartSpec, contextData, timeframe, effectiveDay]); + + const currentDay = selectedCycle?.current_day ?? null; + + const heroSpec = React.useMemo(() => { + const n = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + const standardDay = effectiveDay ?? currentDay ?? null; + const standard = standardDay ? getCobbStandardForDay(standardDay) : null; + const dayLabel = standardDay ? `hari ke-${standardDay}` : null; + + if (topic === 'berat_ayam') { + const actual = n(displayContext.averageWeight); + const target = n(displayContext.targetWeight) ?? standard?.weight ?? null; + if (actual === null) return null; + const dev = target ? ((actual - target) / target) * 100 : null; + return { + label: 'Rata-rata Bobot Aktual', + value: Math.round(actual).toLocaleString('id-ID'), + unit: 'g', + context: target + ? `Target CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${Math.round(target).toLocaleString('id-ID')} g` + : null, + status: (dev === null + ? 'unknown' + : dev >= -5 + ? 'ok' + : dev >= -15 + ? 'warning' + : 'critical') as InsightStatus, + deltaLabel: dev === null ? null : `${dev.toFixed(0)}% vs target`, + }; + } + + if (topic === 'fcr') { + const actual = n(displayContext.fcr_terakhir); + if (actual === null) return null; + const std = standard?.fcr ?? null; + return { + label: 'FCR Aktual', + value: formatRatio(actual, 3), + unit: undefined, + context: std ? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}` : null, + status: (std === null + ? 'unknown' + : actual <= std + ? 'ok' + : actual <= std * 1.1 + ? 'warning' + : 'critical') as InsightStatus, + deltaLabel: + std === null + ? null + : `${actual > std ? '+' : ''}${formatRatio(actual - std, 3)} vs standar`, + }; + } + + if (topic === 'eef') { + const actual = n(displayContext.eef_terakhir); + if (actual === null) return null; + const atHarvest = (currentDay ?? 0) >= 35; + return { + label: 'EEF', + value: formatRatio(actual, 3), + unit: undefined, + context: atHarvest ? 'Standar umur panen: >300' : 'Belum umur panen — wajar masih rendah', + status: (!atHarvest + ? 'unknown' + : actual >= 300 + ? 'ok' + : actual >= 250 + ? 'warning' + : 'critical') as InsightStatus, + deltaLabel: atHarvest ? (actual >= 300 ? 'di atas standar' : 'di bawah standar') : null, + }; + } + + if (topic === 'iot_panel') { + const actual = n(displayContext.suhu_rata_rata_C); + const setpoint = n(displayContext.setpoint_C); + if (actual === null) return null; + const diff = setpoint === null ? null : actual - setpoint; + return { + label: 'Suhu Rata-rata Kandang', + value: actual.toLocaleString('id-ID'), + unit: '°C', + context: setpoint === null ? null : `Setpoint: ${setpoint.toLocaleString('id-ID')} °C`, + status: (diff === null + ? 'unknown' + : Math.abs(diff) <= 1 + ? 'ok' + : Math.abs(diff) <= 2.5 + ? 'warning' + : 'critical') as InsightStatus, + deltaLabel: + diff === null ? null : `${diff > 0 ? '+' : ''}${diff.toFixed(1)} °C vs setpoint`, + }; + } + + if (topic === 'hitung_karung') { + const actual = n(displayContext.total_karung_dituang_iot); + if (actual === null) return null; + const manual = n(displayContext.total_karung_dituang_manual); + const perDay = n(displayContext.rata_karung_per_hari); + const parts = [ + manual === null ? null : `Manual: ${manual.toLocaleString('id-ID')} karung`, + perDay === null ? null : `${perDay.toLocaleString('id-ID')} karung/hari`, + ].filter(Boolean); + return { + label: 'Pakan Dituang (IOT)', + value: actual.toLocaleString('id-ID'), + unit: 'karung', + context: parts.length > 0 ? parts.join(' · ') : null, + status: 'unknown' as InsightStatus, + deltaLabel: null, + }; + } + + return null; + }, [topic, displayContext, currentDay, effectiveDay]); + + const meta = TOPIC_META[topic] ?? TOPIC_META.dashboard; + + const generate = useCallback( + async (force = false) => { + const storageKey = `page-insight-v1-${scopeKey}`; + const requestId = ++requestIdRef.current; + const isCurrent = () => requestIdRef.current === requestId; + + if (force) { + _insightMemoryCache.delete(storageKey); + try { + sessionStorage.removeItem(storageKey); + } catch { + /* ignore */ + } + } + + if (!force) { + const memCached = _insightMemoryCache.get(storageKey); + if (memCached) { + if (!isCurrent()) return; + setResult(memCached); + setLoading(false); + lastCacheKeyRef.current = scopeKey; + return; + } + } + + if (!force && _insightInFlight.has(storageKey)) { + await _insightInFlight.get(storageKey); + if (!isCurrent()) return; + const memCached = _insightMemoryCache.get(storageKey); + if (memCached) { + setResult(memCached); + setLoading(false); + lastCacheKeyRef.current = scopeKey; + } + return; + } + + if (!force) { + try { + const raw = sessionStorage.getItem(storageKey); + if (raw) { + const parsedJson = JSON.parse(raw) as CachedPageInsight; + if (parsedJson?.summary || parsedJson?.insight) { + _insightMemoryCache.set(storageKey, parsedJson); + if (!isCurrent()) return; + setResult(parsedJson); + setLoading(false); + lastCacheKeyRef.current = scopeKey; + return; + } + const parsed = parseAiResult(raw); + if (parsed) { + const wrapped: CachedPageInsight = { ...parsed, source: 'cache' }; + _insightMemoryCache.set(storageKey, wrapped); + if (!isCurrent()) return; + setResult(wrapped); + setLoading(false); + lastCacheKeyRef.current = scopeKey; + return; + } + sessionStorage.removeItem(storageKey); + } + } catch { + /* ignore */ + } + } + + if (inFlightRef.current && !force) return; + inFlightRef.current = true; + // Only show the LLM loading UI when the user clicks Generate. + if (force) { + setLoading(true); + setResult(null); + } + setError(null); + + const fetchPromise = (async () => { + try { + if (selectedKandangId == null && !(contextData as Record).kandangId) { + throw new Error( + 'Pilih kandang terlebih dahulu. AI Insight digenerate per kandang, bukan semua kandang.' + ); + } + if (!selectedCycle?.id) { + throw new Error('Data siklus tidak tersedia. Pastikan ada siklus aktif.'); + } + + const rawCtx = contextData as Record; + const kandangId = requireKandangId({ + ...rawCtx, + kandangId: rawCtx.kandangId ?? selectedKandangId, + }); + + const rawCtxDay = + effectiveDay ?? + rawCtx.hari_ke ?? + rawCtx.hari_terakhir ?? + rawCtx.currentDay ?? + currentDay; + const maxDays = (rawCtx.totalDays as number | undefined) ?? selectedCycle.total_days; + const currentDayVal = + typeof rawCtxDay === 'number' && maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay; + + const slicedContextData = scopeContextToTimeframe(contextData, timeframe, effectiveDay); + const scopedContextData = scalarScope + ? scopeScalarsToTimeframe( + slicedContextData as Record, + timeframe, + scalarScope, + effectiveDay + ) + : slicedContextData; + + const dataPeriode = (scopedContextData as Record)?.periode; + // Prefer explicit day-0..N wording when a day is selected (matches dashboard reports). + const timeframeLabel = + effectiveDay !== null + ? describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay) + : !supportsTimeframe + ? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)' + : typeof dataPeriode === 'string' && dataPeriode.trim().length > 0 + ? dataPeriode + : describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay); + + const reportPeriod = + timeframe === 'harian' + ? effectiveDay !== null + ? `Hari ${effectiveDay}` + : currentDayVal + ? `Hari ${currentDayVal}` + : null + : null; + + const contextPayload: InsightContext = { + ...(scopedContextData as InsightContext), + kandangId, + kandangName: + (rawCtx.kandangName as string | undefined) ?? + selectedKandang?.kandang_name ?? + undefined, + cycleId: selectedCycle.id, + hari_ke: typeof effectiveDay === 'number' ? effectiveDay : typeof currentDayVal === 'number' ? currentDayVal : null, + totalDays: maxDays ?? null, + periode: timeframeLabel, + }; + + let apiResult = null as Awaited> | null; + + if (!force) { + try { + apiResult = await api.insights.cached({ + cycle_id: selectedCycle.id, + kandang_id: kandangId, + topic, + report_type: 'page', + report_period: reportPeriod, + }); + if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null; + } catch { + apiResult = null; + } + // Manual-generate only: never POST generate on mount / soft refresh. + if (!apiResult) { + if (!isCurrent()) return; + setResult(null); + setLoading(false); + lastCacheKeyRef.current = scopeKey; + return; + } + } else { + const generateCall = api.insights.generate({ + cycle_id: selectedCycle.id, + kandang_id: kandangId, + topic, + report_type: 'page', + report_period: reportPeriod, + context: contextPayload, + force_refresh: true, + }); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + 'TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.' + ) + ), + CLIENT_TIMEOUT_MS + ) + ); + apiResult = await Promise.race([generateCall, timeoutPromise]); + } + + if (!isCurrent()) return; + + if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) { + throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.'); + } + + const text = apiResult.insight_text ?? ''; + const parsed = + apiResult.summary || apiResult.insight + ? { + summary: apiResult.summary || '', + insight: apiResult.insight || '', + } + : parseAiResult(text); + + if (!parsed || (!parsed.summary && !parsed.insight)) { + throw new Error('Respons AI tidak dapat diproses.'); + } + + const wrapped: CachedPageInsight = { + summary: parsed.summary, + insight: parsed.insight, + source: apiResult.source ?? (force ? 'generated' : 'cache'), + citations: apiResult.citations, + }; + + _saveToCache(storageKey, wrapped); + if (!isCurrent()) return; + setResult(wrapped); + lastCacheKeyRef.current = scopeKey; + } catch (err) { + if (!isCurrent()) return; + console.error(`[PageAiInsight:${topic}] request failed:`, err); + // Soft cache misses/errors should not look like a failed generate. + if (force) { + setError(errorMessage(err)); + setResult(null); + } else { + setResult(null); + } + } finally { + if (isCurrent()) { + setLoading(false); + inFlightRef.current = false; + } + _insightInFlight.delete(storageKey); + } + })(); + + _insightInFlight.set(storageKey, fetchPromise); + await fetchPromise; + }, + [ + topic, + contextData, + selectedCycle, + selectedKandang, + selectedKandangId, + currentDay, + timeframe, + effectiveDay, + scalarScope, + supportsTimeframe, + scopeKey, + ] + ); + + useEffect(() => { + desiredKeyRef.current = scopeKey; + generateRef.current = generate; + }); + + // Day / scope change: drop previous insight immediately and only soft-load cache (no generate UI). + useEffect(() => { + if (lastCacheKeyRef.current === scopeKey) return; + requestIdRef.current += 1; + setResult(null); + setError(null); + setLoading(false); + inFlightRef.current = false; + const timer = setTimeout(() => generateRef.current(false), 50); + return () => clearTimeout(timer); + }, [scopeKey]); + + const sourceMeta = result ? SOURCE_LABEL[result.source] : null; + + return ( +
+
+
+
+ +
+
+ {meta.title} +

+ {selectedKandang?.kandang_name ?? 'Kandang'} + {selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''} + {effectiveDay != null ? ` · Hari ke-${effectiveDay}` : currentDay != null ? ` · Hari ke-${currentDay}` : ''} +

+
+
+
+ {supportsTimeframe && timeframe === 'harian' && availableDays.length > 1 && ( + + )} + {sourceMeta && ( + + {sourceMeta.text} + + )} + +
+
+ +
+ {selectedKandangId == null && ( +
+ +

+ Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak + diizinkan. +

+
+ )} + + {loading && !result && ( +
+
+

+ Menganalisis data dengan model AI… proses ini bisa memakan waktu beberapa menit. +

+
+ )} + + {error && !result && ( +
+ +
+

Gagal memuat insight

+

{error}

+ +
+
+ )} + + {!loading && !error && !result && selectedKandangId != null && ( +
+ +

Belum ada AI Insight untuk periode ini.

+

Klik tombol refresh untuk generate insight.

+ +
+ )} + + {result && ( +
+ {heroSpec && ( + + )} +
+ {periodeLabel && ( +

+ Angka di bawah dihitung dari periode{' '} + {periodeLabel} +

+ )} +
+ {topic === 'fcr' && ( + <> +
+ + Konsumsi Pakan Kumulatif + + + {renderValue(displayContext.pakan_karung_terakhir, 'Karung')} + +
+
+ Rata-rata Bobot + + {renderValue(displayContext.bobot_iot_gram_terakhir, 'g')} + +
+
+ Populasi Hidup + + {renderValue(displayContext.ayam_hidup_ekor, 'Ekor')} + +
+
+ Total Bobot Panen + + {renderValue(displayContext.beratPanen_kg, 'kg')} + +
+ + )} + + {topic === 'berat_ayam' && ( + <> +
+ Target Bobot + + {renderValue(displayContext.targetWeight, 'g')} + +
+
+ Uniformity + + {renderValue(displayContext.uniformity, '%')} + +
+
+ + Average Daily Gain (ADG) + + + {renderValue(displayContext.adg, 'g/hari')} + +
+ + )} + + {topic === 'eef' && ( + <> +
+ EEF Aktual + + {typeof displayContext.eef_terakhir === 'number' + ? formatRatio(displayContext.eef_terakhir, 3) + : renderValue(displayContext.eef_terakhir)} + +
+
+ FCR Aktual + + {typeof displayContext.fcr_terakhir === 'number' + ? formatRatio(displayContext.fcr_terakhir, 3) + : renderValue(displayContext.fcr_terakhir)} + +
+
+ Persentase Hidup + + {renderValue(displayContext.persen_hidup, '%')} + +
+ + )} + + {topic === 'iot_panel' && ( + <> +
+ Kelembapan + + {renderValue(displayContext.kelembapan_persen, '%')} + +
+
+ Kecepatan Angin + + {renderValue(displayContext.kecepatan_angin_mPerDetik, 'm/s')} + +
+ + )} + + {topic === 'hitung_karung' && ( + <> +
+ Saldo Awal Pakan + + {renderValue(displayContext.saldo_awal_karung, 'Karung')} + +
+ + )} +
+ + {chartSpec && ( +
+ {chartSpec.type === 'trend' ? ( + + ) : ( + + )} +
+ )} + +
+

+ Kesimpulan +

+

+ {result.summary} +

+
+ +
+

+ AI Insight +

+

+ {result.insight} +

+
+ + +
+
+ )} +
+
+ ); +}; + +export default PageAiInsight; diff --git a/docker-compose.yml b/docker-compose.yml index 2ea0e7b..4f9dbd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,38 @@ services: ports: - "127.0.0.1:15432:5432" + rag: + image: dashboard-cpsp-rag + build: + context: "./AI Insight" + dockerfile: Dockerfile + container_name: dashboard-cpsp-rag + restart: unless-stopped + ports: + - "127.0.0.1:5002:5002" + environment: + RAG_PORT: "5002" + HF_HUB_OFFLINE: "1" + TRANSFORMERS_OFFLINE: "1" + CHROMA_DB_DIR: chroma_db + EMBEDDING_MODEL_NAME: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 + volumes: + # Empty named volume hides image-baked index — run scripts/seed_rag_chroma.* after first up. + - rag-chroma:/app/chroma_db + networks: + - dashboard-cpsp-network + + llm: + image: ollama/ollama:latest + container_name: dashboard-cpsp-llm + restart: unless-stopped + ports: + - "127.0.0.1:11434:11434" + volumes: + - llm-ollama:/root/.ollama + networks: + - dashboard-cpsp-network + api: image: dashboard-cpsp-api build: @@ -73,6 +105,10 @@ services: PUSAT_BASE_URL: ${PUSAT_BASE_URL:-} PUSAT_API_KEY: ${PUSAT_API_KEY:-} SITE_API_BASE_URL: ${SITE_API_BASE_URL:-} + RAG_SERVICE_URL: ${RAG_SERVICE_URL:-http://rag:5002} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://llm:11434} + LLM_MODEL_NAME: ${LLM_MODEL_NAME:-qwen2.5:3b} + LLM_TIMEOUT_SECONDS: ${LLM_TIMEOUT_SECONDS:-1200} extra_hosts: - "host.docker.internal:host-gateway" volumes: @@ -80,6 +116,10 @@ services: depends_on: database: condition: service_healthy + rag: + condition: service_started + llm: + condition: service_started networks: - dashboard-cpsp-network healthcheck: @@ -171,3 +211,7 @@ volumes: driver: local api-media: driver: local + rag-chroma: + driver: local + llm-ollama: + driver: local diff --git a/docker/.env.example b/docker/.env.example index f4b0f02..8f6012b 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -51,3 +51,9 @@ CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS=30 CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=true CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=true CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=true + +# AI Insight — RAG + Ollama (llm service) in compose +RAG_SERVICE_URL=http://rag:5002 +OLLAMA_BASE_URL=http://llm:11434 +LLM_MODEL_NAME=qwen2.5:3b +LLM_TIMEOUT_SECONDS=1200 diff --git a/fixtures/insight/empty-context-kandang-03.json b/fixtures/insight/empty-context-kandang-03.json new file mode 100644 index 0000000..0bf5933 --- /dev/null +++ b/fixtures/insight/empty-context-kandang-03.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": "v1", + "kandangId": 3, + "kandangName": "Kandang 03 Kosong", + "cycleId": 203, + "topic": "fcr", + "hari_ke": null, + "totalDays": 35, + "fcr_terakhir": null, + "bobot_iot_gram_terakhir": null, + "pakan_karung_terakhir": null, + "ayam_hidup_ekor": null, + "mortalitas_kumulatif_persen": null, + "tren_fcr_harian": [] +} diff --git a/fixtures/insight/gold-cycle-end-kandang-01.json b/fixtures/insight/gold-cycle-end-kandang-01.json new file mode 100644 index 0000000..7cd52c8 --- /dev/null +++ b/fixtures/insight/gold-cycle-end-kandang-01.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "v1", + "kandangId": 1, + "kandangName": "Kandang Atas", + "cycleId": 101, + "topic": "dashboard", + "hari_ke": 48, + "totalDays": 48, + "report_type": "end_cycle", + "doc_in_ekor": 25000, + "mati_ekor": 1986, + "afkir_ekor": 491, + "panen_ekor": 22523, + "saldo_ekor": 0, + "mortalitas_kumulatif_persen": 9.91, + "fcr_terakhir": 1.72, + "eef_terakhir": 280, + "weekly_summaries": [ + { "minggu_ke": 1, "fcr_rata_rasio": 0.9, "jumlah_hari_data": 7 }, + { "minggu_ke": 2, "fcr_rata_rasio": 1.1, "jumlah_hari_data": 7 }, + { "minggu_ke": 3, "fcr_rata_rasio": 1.3, "jumlah_hari_data": 7 }, + { "minggu_ke": 4, "fcr_rata_rasio": 1.45, "jumlah_hari_data": 7 }, + { "minggu_ke": 5, "fcr_rata_rasio": 1.58, "jumlah_hari_data": 7 }, + { "minggu_ke": 6, "fcr_rata_rasio": 1.68, "jumlah_hari_data": 7 }, + { "minggu_ke": 7, "fcr_rata_rasio": 1.72, "jumlah_hari_data": 6 } + ] +} diff --git a/fixtures/insight/gold-cycle-h28-kandang-01.json b/fixtures/insight/gold-cycle-h28-kandang-01.json new file mode 100644 index 0000000..17f22ae --- /dev/null +++ b/fixtures/insight/gold-cycle-h28-kandang-01.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": "v1", + "kandangId": 1, + "kandangName": "Kandang Atas", + "cycleId": 101, + "topic": "hitung_ayam", + "hari_ke": 28, + "totalDays": 48, + "doc_in_ekor": 25000, + "mati_ekor": 1986, + "afkir_ekor": 491, + "panen_ekor": 22523, + "saldo_ekor": 0, + "mortalitas_kumulatif_persen": 9.91, + "persenHidup_persen": 90.09, + "catatan": "Gold dari CATATAN_PERBAIKAN_AI_INSIGHT.md — CYCLE-JBW-2026-05-22. Panen ≠ kematian." +} diff --git a/fixtures/insight/half-bad-h28.json b/fixtures/insight/half-bad-h28.json new file mode 100644 index 0000000..4f5aa6a --- /dev/null +++ b/fixtures/insight/half-bad-h28.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": "v1", + "kandangId": 4, + "kandangName": "Kandang 4 Half Buruk", + "cycleId": 302, + "topic": "fcr", + "hari_ke": 28, + "totalDays": 29, + "fcr_terakhir": 1.7, + "bobot_rata_rata_gram": 1200, + "bobot_iot_gram": 1200, + "mortalitas_persen": 8.5, + "mortalitas_kumulatif_persen": 8.5, + "ayam_hidup_ekor": 41175, + "suhu_rata_rata_C": 32.0, + "kelembapan_persen": 78.0, + "pakan_karung_terakhir": 90, + "tren_fcr_harian": [ + { "hari": 22, "fcr_aktual": 1.5 }, + { "hari": 24, "fcr_aktual": 1.58 }, + { "hari": 26, "fcr_aktual": 1.65 }, + { "hari": 28, "fcr_aktual": 1.7 } + ] +} diff --git a/fixtures/insight/half-good-h28.json b/fixtures/insight/half-good-h28.json new file mode 100644 index 0000000..eeac7f5 --- /dev/null +++ b/fixtures/insight/half-good-h28.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": "v1", + "kandangId": 3, + "kandangName": "Kandang 3 Half Baik", + "cycleId": 301, + "topic": "fcr", + "hari_ke": 28, + "totalDays": 29, + "fcr_terakhir": 1.4, + "bobot_rata_rata_gram": 1543, + "bobot_iot_gram": 1543, + "mortalitas_persen": 2.8, + "mortalitas_kumulatif_persen": 2.8, + "ayam_hidup_ekor": 43740, + "suhu_rata_rata_C": 26.0, + "kelembapan_persen": 60.0, + "pakan_karung_terakhir": 80, + "tren_fcr_harian": [ + { "hari": 22, "fcr_aktual": 1.28 }, + { "hari": 24, "fcr_aktual": 1.32 }, + { "hari": 26, "fcr_aktual": 1.36 }, + { "hari": 28, "fcr_aktual": 1.4 } + ] +} diff --git a/fixtures/insight/kandang-01.json b/fixtures/insight/kandang-01.json new file mode 100644 index 0000000..3ca1e93 --- /dev/null +++ b/fixtures/insight/kandang-01.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": "v1", + "kandangId": 1, + "kandangName": "Kandang 01 Sehat", + "cycleId": 201, + "topic": "fcr", + "hari_ke": 28, + "totalDays": 35, + "fcr_terakhir": 1.4, + "bobot_iot_gram_terakhir": 1550, + "pakan_karung_terakhir": 120, + "ayam_hidup_ekor": 9800, + "mortalitas_kumulatif_persen": 2.1, + "tren_fcr_harian": [ + { "hari": 22, "fcr_aktual": 1.28 }, + { "hari": 24, "fcr_aktual": 1.32 }, + { "hari": 26, "fcr_aktual": 1.36 }, + { "hari": 28, "fcr_aktual": 1.4 } + ] +} diff --git a/fixtures/insight/kandang-02.json b/fixtures/insight/kandang-02.json new file mode 100644 index 0000000..07bd979 --- /dev/null +++ b/fixtures/insight/kandang-02.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": "v1", + "kandangId": 2, + "kandangName": "Kandang 02 Bermasalah", + "cycleId": 202, + "topic": "fcr", + "hari_ke": 28, + "totalDays": 35, + "fcr_terakhir": 1.7, + "bobot_iot_gram_terakhir": 1200, + "pakan_karung_terakhir": 150, + "ayam_hidup_ekor": 8800, + "mortalitas_kumulatif_persen": 8.5, + "tren_fcr_harian": [ + { "hari": 22, "fcr_aktual": 1.5 }, + { "hari": 24, "fcr_aktual": 1.58 }, + { "hari": 26, "fcr_aktual": 1.65 }, + { "hari": 28, "fcr_aktual": 1.7 } + ] +} diff --git a/fixtures/insight/kandang-03.json b/fixtures/insight/kandang-03.json new file mode 100644 index 0000000..ee6ddd2 --- /dev/null +++ b/fixtures/insight/kandang-03.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": "v1", + "kandangId": 3, + "kandangName": "Kandang 03", + "cycleId": 203, + "topic": "iot_panel", + "hari_ke": 14, + "suhu_rata_rata_C": 29.5, + "kelembapan_persen": 62, + "amonia_ppm": 8 +} diff --git a/fixtures/insight/kandang-04.json b/fixtures/insight/kandang-04.json new file mode 100644 index 0000000..6cdb8cb --- /dev/null +++ b/fixtures/insight/kandang-04.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": "v1", + "kandangId": 4, + "kandangName": "Kandang 04", + "cycleId": 204, + "topic": "berat_ayam", + "hari_ke": 21, + "bobot_rata_rata_gram": 920, + "adg_gram": 75, + "uniformitas_persen": 78 +} diff --git a/fixtures/insight/kandang-05.json b/fixtures/insight/kandang-05.json new file mode 100644 index 0000000..a47c4ae --- /dev/null +++ b/fixtures/insight/kandang-05.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": "v1", + "kandangId": 5, + "kandangName": "Kandang 05", + "cycleId": 205, + "topic": "hitung_karung", + "hari_ke": 30, + "pakan_in_karung": 40, + "pakan_out_karung": 38, + "pakan_use_karung": 35, + "saldo_karung": 120 +} diff --git a/package.json b/package.json index 8630861..9f39054 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "type-check": "tsc --noEmit", "test": "vitest", "test:run": "vitest run", + "test:insight-fixtures": "python scripts/eval_insight_fixtures.py", "prepare": "husky" }, "lint-staged": { diff --git a/scripts/eval_insight_fixtures.py b/scripts/eval_insight_fixtures.py new file mode 100644 index 0000000..eeb5ab3 --- /dev/null +++ b/scripts/eval_insight_fixtures.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Eval harness for insight fixtures (laptop/CI OK — no Ollama / Django required). + +Runs CP707 grading + guardrail checks against fixtures/insight/*.json. +LLM narrate smoke belongs on the NUC (scripts/nuc_insight_smoke.sh). + +Usage (from repo root): + python scripts/eval_insight_fixtures.py +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "backend" / "apps" / "operations" / "services")) + +import cp707_knowledge as cp707 # noqa: E402 + +FIXTURES = ROOT / "fixtures" / "insight" +_NUMERIC_PIPE_ROW = re.compile(r"^[\d.,]+(\s*\|\s*[\d.,]*)+$") + + +def strip_headerless_tables(chunk: str) -> tuple[str, int]: + lines = str(chunk or "").split("\n") + removed = 0 + kept: list[str] = [] + for line in lines: + trimmed = line.strip() + if trimmed and _NUMERIC_PIPE_ROW.match(trimmed): + removed += 1 + continue + kept.append(line) + if removed == 0: + return chunk, 0 + text = "\n".join(kept).strip() + "\n[Tabel angka tanpa judul kolom dihapus]" + return text, removed + + +def load_fixtures() -> list[tuple[str, dict]]: + return [(p.name, json.loads(p.read_text(encoding="utf-8"))) for p in sorted(FIXTURES.glob("*.json"))] + + +def assert_true(cond: bool, msg: str, errors: list[str]) -> None: + if not cond: + errors.append(msg) + + +def eval_fixture(name: str, ctx: dict) -> list[str]: + errors: list[str] = [] + assert_true(ctx.get("kandangId") is not None, f"{name}: kandangId wajib", errors) + + day = ctx.get("hari_ke") + day_i = int(day) if isinstance(day, (int, float)) and day else None + + if name.startswith("empty-context"): + analysis = cp707.analyze_with_cp707_standards(ctx) + assert_true(isinstance(analysis.get("analyses"), dict), f"{name}: analyses dict", errors) + # No numbers to invent — analyzers should simply omit or unknown + return errors + + mort = ctx.get("mortalitas_kumulatif_persen") + if isinstance(mort, (int, float)) and mort >= 5: + block = cp707.analyze_mortality(float(mort), day_i or 28) + assert_true( + block["status"] in ("warning", "critical"), + f"{name}: mortalitas {mort}% harus warning/critical, got {block['status']}", + errors, + ) + msg = block.get("message", "").lower() + assert_true( + "kritis" in msg or "waspada" in msg or "melebihi" in msg or ">" in msg, + f"{name}: pesan mortalitas harus tegas: {block.get('message')}", + errors, + ) + + if ctx.get("topic") == "fcr" and isinstance(ctx.get("fcr_terakhir"), (int, float)) and day_i: + block = cp707.analyze_fcr(float(ctx["fcr_terakhir"]), day_i) + assert_true("direction" in block, f"{name}: analyze_fcr harus punya direction", errors) + assert_true( + block.get("status") in ("ok", "warning", "critical", "unknown"), + f"{name}: status FCR invalid", + errors, + ) + + if ctx.get("report_type") == "end_cycle": + weeks = ctx.get("weekly_summaries") + assert_true(isinstance(weeks, list) and len(weeks) > 0, f"{name}: weekly_summaries wajib", errors) + + sample = "prosa aman\n1 | 195 | 34 | 164,5 | 0,844\n2 | 499 | 50 | 530,5 | 1,063\n" + cleaned, removed = strip_headerless_tables(sample) + assert_true(removed >= 2, f"{name}: strip headerless tables", errors) + assert_true("164,5" not in cleaned.split("[")[0], f"{name}: angka tabel harus hilang dari prosa", errors) + + # Gold mortality math: mati+afkir+panen == DOC + if name.startswith("gold-cycle"): + doc = ctx.get("doc_in_ekor") + mati = ctx.get("mati_ekor") + afkir = ctx.get("afkir_ekor") + panen = ctx.get("panen_ekor") + if all(isinstance(x, (int, float)) for x in (doc, mati, afkir, panen)): + assert_true( + int(mati) + int(afkir) + int(panen) == int(doc), + f"{name}: DOC harus = mati+afkir+panen (panen ≠ kematian)", + errors, + ) + + return errors + + +def main() -> int: + fixtures = load_fixtures() + if not fixtures: + print("FAIL: no fixtures found") + return 1 + all_errors: list[str] = [] + for name, ctx in fixtures: + errs = eval_fixture(name, ctx) + if errs: + print(f"FAIL {name}") + for e in errs: + print(f" - {e}") + all_errors.extend(errs) + else: + print(f"PASS {name}") + + by_name = {n: c for n, c in fixtures} + assert_true( + by_name["kandang-01.json"]["kandangId"] != by_name["kandang-02.json"]["kandangId"], + "kandang-01/02 harus id berbeda", + all_errors, + ) + assert_true( + by_name["kandang-01.json"]["fcr_terakhir"] != by_name["kandang-02.json"]["fcr_terakhir"], + "FCR antar kandang harus beda (anti bleed fixture)", + all_errors, + ) + + # Half-cycle baik vs buruk (mirrors DB seed Kandang 3 / 4) + if "half-good-h28.json" in by_name and "half-bad-h28.json" in by_name: + good = by_name["half-good-h28.json"] + bad = by_name["half-bad-h28.json"] + assert_true(good["kandangId"] != bad["kandangId"], "half good/bad id berbeda", all_errors) + assert_true(good["fcr_terakhir"] < bad["fcr_terakhir"], "half baik FCR < half buruk", all_errors) + assert_true( + good["mortalitas_persen"] < 5 <= bad["mortalitas_persen"], + "half baik mort <5%, buruk >=5%", + all_errors, + ) + g_fcr = cp707.analyze_fcr(float(good["fcr_terakhir"]), 28) + b_fcr = cp707.analyze_fcr(float(bad["fcr_terakhir"]), 28) + assert_true(g_fcr["status"] == "ok", f"half-good FCR harus ok, got {g_fcr['status']}", all_errors) + assert_true( + b_fcr["status"] in ("warning", "critical"), + f"half-bad FCR harus warning/critical, got {b_fcr['status']}", + all_errors, + ) + g_bw = cp707.analyze_bw(float(good["bobot_rata_rata_gram"]), 28) + b_bw = cp707.analyze_bw(float(bad["bobot_rata_rata_gram"]), 28) + assert_true(g_bw["status"] == "ok", f"half-good BW harus ok, got {g_bw['status']}", all_errors) + assert_true( + b_bw["status"] in ("warning", "critical"), + f"half-bad BW harus warning/critical, got {b_bw['status']}", + all_errors, + ) + + if all_errors: + print(f"\n{len(all_errors)} error(s)") + return 1 + print(f"\nAll {len(fixtures)} fixtures OK (grading/guardrails). LLM generate: run on NUC.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/insight_smoke.sh b/scripts/insight_smoke.sh new file mode 100644 index 0000000..dcaeb39 --- /dev/null +++ b/scripts/insight_smoke.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Smoke-test AI Insight generate (qwen2.5:3b). Prefer NUC/server, not laptop as proof. +# Prerequisites: API + RAG + llm containers up, model pulled, DB migrated. +# +# export API_BASE=http://127.0.0.1:18000/api/v1 +# export API_KEY=... +# ./scripts/insight_smoke.sh +set -euo pipefail + +API_BASE="${API_BASE:-http://127.0.0.1:18000/api/v1}" +CYCLE_A="${CYCLE_A:-1}" +KANDANG_A="${KANDANG_A:-1}" +CYCLE_B="${CYCLE_B:-2}" +KANDANG_B="${KANDANG_B:-2}" +TOPIC="${TOPIC:-eef}" +AUTH_HEADER=() +if [[ -n "${API_KEY:-}" ]]; then + AUTH_HEADER=(-H "X-API-Key: ${API_KEY}") +fi + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FIXTURE_A="${ROOT}/fixtures/insight/kandang-01.json" +FIXTURE_B="${ROOT}/fixtures/insight/kandang-02.json" + +echo "== Health ==" +curl -fsS "${API_BASE}/health/" | head -c 400; echo + +echo "== RAG health (optional) ==" +curl -fsS "${RAG_URL:-http://127.0.0.1:5002}/health" || echo "RAG not up — generate may fall back without SOP chunks" + +echo "== Ollama tags (optional) ==" +curl -fsS "${OLLAMA_URL:-http://127.0.0.1:11434}/api/tags" | head -c 400 || echo "Ollama not up — expect local_fallback" +echo + +gen() { + local cycle="$1" kandang="$2" fixture="$3" force="$4" + local ctx + ctx="$(python - </dev/null || true" +docker cp "${Src}/." "${Cid}:/app/chroma_db/" +docker restart $Cid +Write-Host "Done. Check: curl -fsS http://127.0.0.1:5002/health" diff --git a/scripts/seed_rag_chroma.sh b/scripts/seed_rag_chroma.sh new file mode 100644 index 0000000..1fbcee1 --- /dev/null +++ b/scripts/seed_rag_chroma.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Copy host "AI Insight/chroma_db" into the running rag container volume. +# Named volume rag-chroma mounts over the image-baked index — seed after first up. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$ROOT/AI Insight/chroma_db" +CID="${RAG_CONTAINER:-dashboard-cpsp-rag}" + +if [[ ! -d "$SRC" ]]; then + echo "Missing $SRC — run: cd \"AI Insight\" && python populate_db.py" >&2 + exit 1 +fi + +if ! docker inspect "$CID" >/dev/null 2>&1; then + echo "Container $CID not found. Start with: docker compose up -d rag" >&2 + exit 1 +fi + +echo "Seeding Chroma into $CID:/app/chroma_db from $SRC" +docker exec "$CID" sh -c 'rm -rf /app/chroma_db/* /app/chroma_db/.[!.]* 2>/dev/null || true' +docker cp "$SRC/." "$CID:/app/chroma_db/" +docker restart "$CID" +echo "Done. Check: curl -fsS http://127.0.0.1:5002/health" diff --git a/services/apiClient.ts b/services/apiClient.ts index 499f161..d1a9778 100644 --- a/services/apiClient.ts +++ b/services/apiClient.ts @@ -9,6 +9,9 @@ import type { Flock, FlockWrite, InitialBalanceResult, + InsightApiResponse, + InsightCachedParams, + InsightGenerateParams, Karung, KarungRequestResult, Kandang, @@ -253,5 +256,29 @@ export const api = { insights: { list: async (query?: CycleFilter) => unwrapList(await get | AIInsight[]>('/ai-insights/', query)), + /** + * Generate (or refresh) an insight for one kandang. + * Backend may take several minutes (qwen2.5:3b) — callers should + * show long-timeout UX. `kandang_id` is required. + */ + generate: (body: InsightGenerateParams) => { + if (body.kandang_id == null) { + return Promise.reject(new Error('kandang_id wajib — insight tidak boleh untuk semua kandang')); + } + return post('/ai-insights/generate/', body); + }, + /** Fetch cached insight for the same scope key (no LLM call). */ + cached: (params: InsightCachedParams) => { + if (params.kandang_id == null) { + return Promise.reject(new Error('kandang_id wajib — insight tidak boleh untuk semua kandang')); + } + return get('/ai-insights/cached/', { + cycle_id: params.cycle_id, + kandang_id: params.kandang_id, + topic: params.topic, + report_type: params.report_type, + report_period: params.report_period ?? undefined, + }); + }, }, }; diff --git a/types/api.ts b/types/api.ts index ca2bb32..5fa832c 100644 --- a/types/api.ts +++ b/types/api.ts @@ -209,17 +209,112 @@ export type PanelIoT = { export type AIInsight = { id: number; cycle: number; - kandang: number; - flock_id: number; + kandang: number | null; + flock_id?: number; date: string; insight_text: string; + /** Condition from graded facts: healthy | warning | critical | unknown */ alert: string; - section: string; - session: string; + topic: string; + report_type: string; + report_period: string; + source?: InsightSource; + summary?: string; + citations?: InsightCitation[]; created_at: string; updated_at: string; }; +/** Source label shown in AI Insight UI — never hide local fallback as LLM. */ +export type InsightSource = 'generated' | 'cache' | 'local_fallback'; + +export type InsightReportType = 'page' | 'daily' | 'weekly' | 'end_cycle'; + +export type InsightTopic = + | 'hitung_ayam' + | 'berat_ayam' + | 'fcr' + | 'eef' + | 'iot_panel' + | 'hitung_karung' + | 'dashboard'; + +export type InsightCitation = { + id?: string; + title?: string; + chapter?: string; + excerpt?: string; + source?: string; +}; + +/** + * Curated page context sent to the insight service. + * `kandangId` is required — never generate for "all kandang". + */ +export type InsightContext = { + kandangId: number; + kandangName?: string; + cycleId?: number; + hari_ke?: number | null; + totalDays?: number | null; + periode?: string | null; + [key: string]: unknown; +}; + +export type InsightGenerateParams = { + cycle_id: number; + kandang_id: number; + topic: InsightTopic | string; + report_type?: InsightReportType; + report_period?: string | null; + context: InsightContext; + force_refresh?: boolean; +}; + +export type InsightCachedParams = { + cycle_id: number; + kandang_id: number; + topic: InsightTopic | string; + report_type?: InsightReportType; + report_period?: string | null; +}; + +export type InsightStatusLabel = 'ok' | 'warning' | 'critical' | 'unknown'; + +export type InsightStructuredSection = { + status: InsightStatusLabel; + bullets: string[]; + evidence: string[]; + actions: string[]; +}; + +export type InsightStructuredReport = { + summary: { + headline: string; + bullets: string[]; + risks: string[]; + actions: string[]; + }; + detailed?: Partial< + Record< + 'hitung_ayam' | 'berat_ayam' | 'fcr' | 'eef' | 'panel_iot' | 'data_quality', + InsightStructuredSection + > + >; +}; + +/** Response from generate / cached insight endpoints. */ +export type InsightApiResponse = { + success?: boolean; + source: InsightSource; + insight_text: string; + summary?: string; + insight?: string; + structured?: InsightStructuredReport | null; + citations?: InsightCitation[]; + message?: string; +}; + export type KarungRequestResult = { karung: Karung; upstream: Record; diff --git a/utils/__tests__/dashboardInsightMetrics.test.ts b/utils/__tests__/dashboardInsightMetrics.test.ts new file mode 100644 index 0000000..38b2bfe --- /dev/null +++ b/utils/__tests__/dashboardInsightMetrics.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDashboardInsightMetrics, + scopeDashboardInsightMetrics, +} from '../insightContextBuilders.ts'; +import { getTargetWeightForAge } from '../weight.ts'; + +describe('buildDashboardInsightMetrics', () => { + it('builds latest scalars and day-indexed weight series with targets', () => { + const metrics = buildDashboardInsightMetrics({ + kpis: [ + { + age: 10, + fcr: 1.2, + eef: 180, + chicken_life: 9800, + chicken_life_percentage: 98, + iot_weight: 0, + mortality_total: 200, + harvest_total: 0, + harvest_weight_total: 0, + feed_total: 120, + }, + { + age: 28, + fcr: 1.55, + eef: 260, + chicken_life: 9500, + chicken_life_percentage: 95, + iot_weight: 1400, + mortality_total: 500, + harvest_total: 100, + harvest_weight_total: 150.5, + feed_total: 400, + }, + ], + weights: [ + { age: 10, average_weight: 280, doc_weight: 40, uniformity: 82.5 }, + { age: 28, average_weight: 1350, doc_weight: 40, uniformity: 78.1 }, + ], + currentDay: 30, + docInCount: 10000, + }); + + expect(metrics.hari_ke).toBe(28); + expect(metrics.fcr).toBe(1.55); + expect(metrics.eef).toBe(260); + expect(metrics.ayam_hidup_ekor).toBe(9500); + expect(metrics.mortalitas_kumulatif_persen).toBe(5); + expect(metrics.bobot_avg_gram).toBe(1400); + expect(metrics.doc_in_ekor).toBe(10000); + expect(metrics.kematian_kumulatif_ekor).toBe(500); + expect(metrics.panen_kumulatif_ekor).toBe(100); + expect(metrics.tonase_panen_kg).toBe(150.5); + expect(metrics.uniformity_persen).toBe(78.1); + expect(metrics.pakan_kumulatif_karung).toBe(400); + expect(metrics.weightSeries).toHaveLength(2); + expect(metrics.weightSeries[0]).toEqual({ + hari: 10, + actual: 280, + target: getTargetWeightForAge(10, 40), + }); + expect(metrics.kpiSeries.map((row) => row.hari)).toEqual([10, 28]); + }); + + it('falls back to weight average when iot_weight is missing', () => { + const metrics = buildDashboardInsightMetrics({ + kpis: [ + { + age: 7, + fcr: 1.1, + eef: 100, + chicken_life: 9900, + chicken_life_percentage: 99, + iot_weight: 0, + }, + ], + weights: [{ age: 7, average_weight: 190, doc_weight: 40 }], + pakanKumulatifFallback: 55, + }); + + expect(metrics.bobot_avg_gram).toBe(190); + expect(metrics.pakan_kumulatif_karung).toBe(55); + }); +}); + +describe('scopeDashboardInsightMetrics', () => { + const base = buildDashboardInsightMetrics({ + kpis: [ + { + age: 7, + fcr: 1.1, + eef: 90, + chicken_life: 9900, + chicken_life_percentage: 99, + iot_weight: 200, + mortality_total: 100, + harvest_total: 0, + harvest_weight_total: 0, + feed_total: 40, + }, + { + age: 14, + fcr: 1.3, + eef: 150, + chicken_life: 9800, + chicken_life_percentage: 98, + iot_weight: 450, + mortality_total: 200, + harvest_total: 50, + harvest_weight_total: 80, + feed_total: 90, + }, + ], + weights: [ + { age: 7, average_weight: 200, doc_weight: 40, uniformity: 85 }, + { age: 14, average_weight: 450, doc_weight: 40, uniformity: 80 }, + { age: 21, average_weight: 800, doc_weight: 40, uniformity: 75 }, + ], + docInCount: 10000, + }); + + it('filters weight series and uses exact-day KPI scalars', () => { + const scoped = scopeDashboardInsightMetrics(base, 7); + expect(scoped.hari_ke).toBe(7); + expect(scoped.fcr).toBe(1.1); + expect(scoped.bobot_avg_gram).toBe(200); + expect(scoped.doc_in_ekor).toBe(10000); + expect(scoped.kematian_kumulatif_ekor).toBe(100); + expect(scoped.pakan_kumulatif_karung).toBe(40); + expect(scoped.uniformity_persen).toBe(85); + expect(scoped.weightSeries.map((row) => row.hari)).toEqual([7]); + }); + + it('does not invent scalars for a day without KPI snapshot', () => { + const scoped = scopeDashboardInsightMetrics(base, 21); + expect(scoped.hari_ke).toBe(21); + expect(scoped.fcr).toBeNull(); + expect(scoped.eef).toBeNull(); + expect(scoped.bobot_avg_gram).toBe(800); // from weight series at day 21 + expect(scoped.kematian_kumulatif_ekor).toBeNull(); + expect(scoped.doc_in_ekor).toBe(10000); // cycle constant still shown + expect(scoped.weightSeries.map((row) => row.hari)).toEqual([7, 14, 21]); + }); +}); diff --git a/utils/__tests__/insightScopeAggregate.test.ts b/utils/__tests__/insightScopeAggregate.test.ts new file mode 100644 index 0000000..8786734 --- /dev/null +++ b/utils/__tests__/insightScopeAggregate.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { scopeScalarsToTimeframe, UNAVAILABLE } from '../insightScope.ts'; + +/** + * Real shape of the FCR page's 7-day history for this cycle: the IoT scale + * stopped reporting at day 38 and has written 0 ever since, and the flock was + * fully harvested on day 48 so `ayam` ends at 0 too. + */ +const ROWS = [ + { day: 42, fcr: 1.8, pakan: 1064, bobot_kg: 0, ayam: 1424 }, + { day: 44, fcr: 1.81, pakan: 1067, bobot_kg: 0, ayam: 1314 }, + { day: 46, fcr: 1.82, pakan: 1072, bobot_kg: 0, ayam: 1253 }, + { day: 47, fcr: 1.78, pakan: 1073, bobot_kg: 1.38, ayam: 822 }, + { day: 48, fcr: 1.7, pakan: 1073, bobot_kg: 0, ayam: 0 }, +]; + +const spec = (agg: 'last' | 'lastValid') => ({ + historyKey: 'tren', + derive: { + bobot_kg_out: { field: 'bobot_kg', agg }, + ayam_out: { field: 'ayam', agg }, + }, +}); + +describe('lastValid aggregation', () => { + it('last takes the trailing zero — the reported bug', () => { + const out = scopeScalarsToTimeframe({ tren: ROWS }, 'mingguan', spec('last')); + expect(out.bobot_kg_out).toBe(0); + expect(out.ayam_out).toBe(0); + }); + + it('lastValid walks back past the zeros', () => { + const out = scopeScalarsToTimeframe({ tren: ROWS }, 'mingguan', spec('lastValid')); + expect(out.bobot_kg_out).toBe(1.38); + expect(out.ayam_out).toBe(822); + }); + + it('reports unavailable when every reading in the window is zero', () => { + const allZero = [ + { day: 47, bobot_kg: 0, ayam: 0 }, + { day: 48, bobot_kg: 0, ayam: 0 }, + ]; + const out = scopeScalarsToTimeframe({ tren: allZero }, 'mingguan', spec('lastValid')); + expect(out.bobot_kg_out).toBe(UNAVAILABLE); + expect(out.ayam_out).toBe(UNAVAILABLE); + }); + + it('leaves a genuine non-zero final reading alone', () => { + const rows = [ + { day: 47, bobot_kg: 1.3, ayam: 900 }, + { day: 48, bobot_kg: 1.45, ayam: 850 }, + ]; + const out = scopeScalarsToTimeframe({ tren: rows }, 'mingguan', spec('lastValid')); + expect(out.bobot_kg_out).toBe(1.45); + expect(out.ayam_out).toBe(850); + }); +}); + +describe('harian periode label starts at day 0', () => { + it('labels cumulative window as hari ke-0 s/d ke-N even if first row is day 2', () => { + const rows = [ + { day: 2, fcr: 1.1 }, + { day: 5, fcr: 1.2 }, + { day: 8, fcr: 1.3 }, + ]; + const out = scopeScalarsToTimeframe( + { tren: rows, fcr_terakhir: 1.3 }, + 'harian', + { + historyKey: 'tren', + derive: { + fcr_out: { field: 'fcr', agg: 'lastValid', replaces: 'fcr_terakhir' }, + }, + }, + 8 + ); + expect(out.periode).toBe('hari ke-0 s/d ke-8'); + expect(out.fcr_out).toBe(1.3); + }); +}); diff --git a/utils/__tests__/insightScopeDay.test.ts b/utils/__tests__/insightScopeDay.test.ts new file mode 100644 index 0000000..fc06a65 --- /dev/null +++ b/utils/__tests__/insightScopeDay.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { scopeContextToTimeframe, describeTimeframe } from '../insightScope.ts'; + +/** + * Picking a day in the AI Insight day picker means "the cycle so far, up to + * that day" — day 0 through day N — not that day in isolation. A single row + * shows no trend and cannot support a cumulative figure such as + * mortality-to-date, which is what these cards lead with. + */ +const ctx = { + riwayat: [ + { hari: 0, nilai: 5 }, + { hari: 1, nilai: 10 }, + { hari: 5, nilai: 50 }, + { hari: 12, nilai: 120 }, + { hari: 30, nilai: 300 }, + ], + // Snapshot list: no time dimension, must survive slicing intact. + perangkat: [{ nama: 'B1' }, { nama: 'B2' }], + bobot: 1234, +}; + +const daysIn = (scoped: typeof ctx) => scoped.riwayat.map((row) => row.hari); + +describe('scopeContextToTimeframe — jendela kumulatif hari 0..N', () => { + it('menyertakan seluruh hari sampai hari yang dipilih', () => { + expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 5))).toEqual([0, 1, 5]); + expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 12))).toEqual([0, 1, 5, 12]); + expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 30))).toEqual([0, 1, 5, 12, 30]); + }); + + it('tidak menyertakan hari setelah hari yang dipilih', () => { + expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 5))).not.toContain(12); + expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 5))).not.toContain(30); + }); + + it('menyertakan hari 0 sebagai awal siklus', () => { + expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 1))).toEqual([0, 1]); + }); + + it('hari tanpa data sendiri tetap memuat hari-hari sebelumnya', () => { + // Hari 7 tidak ada barisnya, tapi jendela 0..7 tetap berisi hari 0, 1, 5. + expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 7))).toEqual([0, 1, 5]); + }); + + it('tetap memakai hari terakhir saja bila tidak ada hari yang diminta', () => { + // Perilaku lama, dipakai saat pengguna belum menyentuh dropdown. + expect(daysIn(scopeContextToTimeframe(ctx, 'harian'))).toEqual([30]); + }); + + it('tidak memotong daftar snapshot dan tidak menyentuh skalar', () => { + const scoped = scopeContextToTimeframe(ctx, 'harian', 5); + expect(scoped.perangkat).toHaveLength(2); + expect(scoped.bobot).toBe(1234); + }); + + it('tidak mengubah perilaku mingguan', () => { + // Jendela 7 hari tetap dihitung dari hari terakhir, apa pun hari yang dipilih. + expect(daysIn(scopeContextToTimeframe(ctx, 'mingguan', 5))).toEqual([30]); + }); + + it('mengenali penamaan field "day" maupun "hari"', () => { + const inggris = { + riwayat: [ + { day: 3, nilai: 1 }, + { day: 9, nilai: 2 }, + ], + }; + expect(scopeContextToTimeframe(inggris, 'harian', 3).riwayat.map((r) => r.day)).toEqual([3]); + }); +}); + +describe('describeTimeframe — label periode', () => { + it('menyatakan rentang kumulatif, bukan satu hari', () => { + // Menyebut "hari ke-30" saja membuat model melaporkan angka kumulatif + // 31 hari seolah-olah itu capaian satu hari. + expect(describeTimeframe('harian', 48, 30)).toContain('hari ke-0 s/d ke-30'); + expect(describeTimeframe('harian', 48, 30)).toContain('kumulatif'); + }); + + it('menandai bahwa periode berakhir bukan di hari ini', () => { + expect(describeTimeframe('harian', 48, 30)).toContain('BUKAN hari ini'); + }); + + it('menandai bahwa periode berakhir di hari ini bila memang begitu', () => { + expect(describeTimeframe('harian', 30, 30)).toContain('berakhir hari ini'); + expect(describeTimeframe('harian', 30, 30)).not.toContain('BUKAN hari ini'); + }); + + it('mempertahankan perilaku lama saat tidak ada hari dipilih', () => { + expect(describeTimeframe('harian', 30)).toBe('Hari ini saja (hari ke-30)'); + expect(describeTimeframe('mingguan', 30)).toContain('7 hari terakhir'); + }); +}); diff --git a/utils/cp707EnvStandards.ts b/utils/cp707EnvStandards.ts new file mode 100644 index 0000000..ce2c737 --- /dev/null +++ b/utils/cp707EnvStandards.ts @@ -0,0 +1,87 @@ +/** + * Environment standards from the CP 707 book, for client-side charting only. + * + * Mirrors temperature / air-quality tables in + * `backend/apps/operations/services/cp707_knowledge.py` — that Python module + * is authoritative for LLM grading. This TS copy exists so IoT charts can + * draw targets without a round trip. Keep both in sync when the book edition + * changes. + */ + +/** Target suhu pemeliharaan per umur (buku CP 707). */ +const TEMPERATURE_STANDARD = [ + { from: 1, to: 2, temp_C: 32 }, + { from: 3, to: 4, temp_C: 31 }, + { from: 5, to: 7, temp_C: 30 }, + { from: 8, to: 14, temp_C: 29 }, + { from: 15, to: 21, temp_C: 28 }, + { from: 22, to: 28, temp_C: 26 }, + { from: 29, to: 35, temp_C: 23 }, + { from: 36, to: 99, temp_C: 22 }, +]; + +/** Kelembapan ideal sama untuk semua umur menurut tabel buku: 50–70 %. */ +export const HUMIDITY_RANGE_PCT = { min: 50, max: 70 }; + +/** Standar kualitas udara (Lampiran 3). */ +export const AIR_QUALITY = { + /** Amonia: ideal <10 ppm, batas kritis >25 ppm. */ + ammoniaSafe_ppm: 10, + ammoniaCritical_ppm: 25, + /** CO2: ideal <3000 ppm, kritis >3500 ppm. */ + co2Ideal_ppm: 3000, +}; + +/** + * Standar konsumsi air per 1000 ekor per hari pada suhu 21°C (buku CP 707). + * Di atas 21°C kebutuhan naik rata-rata 6.5% per derajat. + */ +const WATER_CONSUMPTION_STANDARD: { week: number; minLiter: number; maxLiter: number }[] = [ + { week: 1, minLiter: 58, maxLiter: 65 }, + { week: 2, minLiter: 102, maxLiter: 115 }, + { week: 3, minLiter: 149, maxLiter: 167 }, + { week: 4, minLiter: 192, maxLiter: 216 }, + { week: 5, minLiter: 232, maxLiter: 261 }, + { week: 6, minLiter: 274, maxLiter: 308 }, + { week: 7, minLiter: 309, maxLiter: 347 }, + { week: 8, minLiter: 342, maxLiter: 385 }, +]; + +/** Standar konsumsi air (rata-rata min–max) untuk umur hari tertentu, atau null. */ +export const getWaterStandardByDay = ( + dayAge: number | null | undefined +): { min: number; max: number } | null => { + if (typeof dayAge !== 'number' || !Number.isFinite(dayAge) || dayAge <= 0) return null; + const week = Math.min(Math.ceil(dayAge / 7), WATER_CONSUMPTION_STANDARD.length); + const row = WATER_CONSUMPTION_STANDARD.find((r) => r.week === week); + return row ? { min: row.minLiter, max: row.maxLiter } : null; +}; + +/** + * Suhu target untuk umur tertentu, atau null bila umurnya tidak masuk akal. + * Umur di luar tabel jatuh ke baris terakhir (36+ hari), sesuai isi buku. + */ +export const getTempStandardByDay = (dayAge: number | null | undefined): number | null => { + if (typeof dayAge !== 'number' || !Number.isFinite(dayAge) || dayAge <= 0) return null; + const row = TEMPERATURE_STANDARD.find((r) => dayAge >= r.from && dayAge <= r.to); + return row ? row.temp_C : null; +}; + +/** + * Number out of a display-formatted reading. + * + * `computeDashboard` hands back strings like "32,1 °C" and "62,8 %" — already + * scaled from the raw sensor encoding, which is why they are the safe source — + * so the chart has to strip the unit and accept the Indonesian decimal comma. + * A plain number passes through unchanged. + */ +export const parseReading = (value: unknown): number | null => { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value !== 'string' || value.trim() === '') return null; + const cleaned = value + .replace(/[^\d,.-]/g, '') + .replace(/\.(?=\d{3}\b)/g, '') + .replace(',', '.'); + const n = Number(cleaned); + return Number.isFinite(n) ? n : null; +}; diff --git a/utils/insightContextBuilders.ts b/utils/insightContextBuilders.ts new file mode 100644 index 0000000..bdb1a7b --- /dev/null +++ b/utils/insightContextBuilders.ts @@ -0,0 +1,721 @@ +import type { InsightContext } from '../types/api.ts'; +import { getTargetWeightForAge } from './weight.ts'; + +const num = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +const requireKandangId = (kandangId: number | null | undefined, topic: string): number => { + if (kandangId == null || !Number.isFinite(kandangId) || kandangId <= 0) { + throw new Error(`kandangId wajib — insight ${topic} tidak boleh untuk semua kandang`); + } + return kandangId; +}; + +const dayOf = (row: { day?: unknown; hari?: unknown; age?: unknown }): number | null => + num(row.day) ?? num(row.hari) ?? num(row.age); + +// ─── FCR ───────────────────────────────────────────────────────────────────── + +export type FcrInsightRow = { + day?: number; + hari?: number; + feedTotal?: number | null; + pakan_karung?: number | null; + iotWeight?: number | null; + /** IoT average bird weight in grams (same as KPI `iot_weight`). */ + bobot_iot_gram?: number | null; + /** @deprecated use bobot_iot_gram — legacy name wrongly implied kg */ + bobot_iot_kg?: number | null; + chickenLife?: number | null; + ayam_hidup_ekor?: number | null; + harvestWeightKg?: number | null; + beratPanen_kg?: number | null; + fcr?: number | null; + fcr_aktual?: number | null; + [key: string]: unknown; +}; + +export type BuildFcrInsightContextArgs = { + kandangId: number; + kandangName?: string; + cycleId?: number; + hari_ke?: number | null; + totalDays?: number | null; + fcrRows?: FcrInsightRow[]; + /** Optional precomputed latest scalars (units in field names). */ + fcr_terakhir?: number | null; + pakan_karung_terakhir?: number | null; + bobot_iot_gram_terakhir?: number | null; + /** @deprecated use bobot_iot_gram_terakhir */ + bobot_iot_kg_terakhir?: number | null; + ayam_hidup_ekor?: number | null; + beratPanen_kg?: number | null; +}; + +/** + * Build curated FCR InsightContext for one kandang. + * Numeric fields carry units in their names so the model cannot invent units. + * IoT weight is grams (matches FCR page StatCards / KPI `iot_weight`). + */ +export function buildFcrInsightContext(args: BuildFcrInsightContextArgs): InsightContext { + const kandangId = requireKandangId(args.kandangId, 'FCR'); + + const rows = Array.isArray(args.fcrRows) ? args.fcrRows : []; + const tren = rows + .map((row) => { + const hari = dayOf(row); + if (hari === null) return null; + // Prefer explicit grams; legacy `bobot_iot_kg` on rows was often mislabeled grams. + const bobotGram = + num(row.iotWeight) ?? num(row.bobot_iot_gram) ?? num(row.bobot_iot_kg); + return { + hari, + fcr_aktual: num(row.fcr) ?? num(row.fcr_aktual), + pakan_karung: num(row.feedTotal) ?? num(row.pakan_karung), + bobot_iot_gram: bobotGram, + ayam_hidup_ekor: num(row.chickenLife) ?? num(row.ayam_hidup_ekor), + beratPanen_kg: num(row.harvestWeightKg) ?? num(row.beratPanen_kg), + }; + }) + .filter((row): row is NonNullable => row !== null) + .sort((a, b) => a.hari - b.hari); + + const latest = [...tren].reverse().find((row) => row.fcr_aktual != null) ?? tren[tren.length - 1]; + + const fcr_terakhir = args.fcr_terakhir ?? latest?.fcr_aktual ?? null; + const pakan_karung_terakhir = args.pakan_karung_terakhir ?? latest?.pakan_karung ?? null; + const bobot_iot_gram_terakhir = + args.bobot_iot_gram_terakhir ?? + args.bobot_iot_kg_terakhir ?? + latest?.bobot_iot_gram ?? + null; + const ayam_hidup_ekor = args.ayam_hidup_ekor ?? latest?.ayam_hidup_ekor ?? null; + const beratPanen_kg = args.beratPanen_kg ?? latest?.beratPanen_kg ?? null; + const hari_ke = args.hari_ke ?? latest?.hari ?? null; + + return { + kandangId, + kandangName: args.kandangName, + cycleId: args.cycleId, + hari_ke, + totalDays: args.totalDays ?? null, + fcr_terakhir, + pakan_karung_terakhir, + bobot_iot_gram_terakhir, + ayam_hidup_ekor, + beratPanen_kg, + tren_fcr_harian: tren, + tren_7_hari_terakhir: tren.slice(-7).map((r) => ({ + hari: r.hari, + fcr: r.fcr_aktual, + pakan: r.pakan_karung, + bobot_gram: r.bobot_iot_gram, + ayam: r.ayam_hidup_ekor, + })), + }; +} + +// ─── EEF ───────────────────────────────────────────────────────────────────── + +export type EefInsightRow = { + day?: number; + hari?: number; + eef?: number | null; + fcr?: number | null; + chickenLifePercentage?: number | null; + persen_hidup?: number | null; + averageHarvestBw?: number | null; + bw_panen_gram?: number | null; + averageHarvestDay?: number | null; + [key: string]: unknown; +}; + +export type BuildEefInsightContextArgs = { + kandangId: number; + kandangName?: string; + cycleId?: number; + hari_ke?: number | null; + totalDays?: number | null; + eefRows?: EefInsightRow[]; + eef_terakhir?: number | null; + fcr_terakhir?: number | null; + persen_hidup?: number | null; + bw_panen_gram?: number | null; + umur_panen_rata_hari?: number | null; +}; + +export function buildEefInsightContext(args: BuildEefInsightContextArgs): InsightContext { + const kandangId = requireKandangId(args.kandangId, 'EEF'); + + const rows = Array.isArray(args.eefRows) ? args.eefRows : []; + const tren = rows + .map((row) => { + const hari = dayOf(row); + if (hari === null) return null; + return { + hari, + eef: num(row.eef), + fcr: num(row.fcr), + persen_hidup: num(row.chickenLifePercentage) ?? num(row.persen_hidup), + bw_panen: num(row.averageHarvestBw) ?? num(row.bw_panen_gram), + umur_panen_hari: num(row.averageHarvestDay), + }; + }) + .filter((row): row is NonNullable => row !== null) + .sort((a, b) => a.hari - b.hari); + + const latest = [...tren].reverse().find((row) => row.eef != null) ?? tren[tren.length - 1]; + + return { + kandangId, + kandangName: args.kandangName, + cycleId: args.cycleId, + hari_ke: args.hari_ke ?? latest?.hari ?? null, + totalDays: args.totalDays ?? null, + eef_terakhir: args.eef_terakhir ?? latest?.eef ?? null, + fcr_terakhir: args.fcr_terakhir ?? latest?.fcr ?? null, + persen_hidup: args.persen_hidup ?? latest?.persen_hidup ?? null, + bw_panen_gram: args.bw_panen_gram ?? latest?.bw_panen ?? null, + umur_panen_rata_hari: args.umur_panen_rata_hari ?? latest?.umur_panen_hari ?? null, + tren_eef_harian: tren, + tren_7_hari_terakhir: tren.slice(-7), + }; +} + +// ─── Weight ────────────────────────────────────────────────────────────────── + +export type WeightInsightRow = { + day?: number; + hari?: number; + age?: number; + average_weight?: number | null; + bobot_gram?: number | null; + target_gram?: number | null; + uniformity?: number | null; + average_daily_gain?: number | null; + adg_gram_per_hari?: number | null; + [key: string]: unknown; +}; + +export type BuildWeightInsightContextArgs = { + kandangId: number; + kandangName?: string; + cycleId?: number; + hari_ke?: number | null; + totalDays?: number | null; + weightRows?: WeightInsightRow[]; + averageWeight?: number | null; + targetWeight?: number | null; + uniformity?: number | null; + adg?: number | null; +}; + +export function buildWeightInsightContext(args: BuildWeightInsightContextArgs): InsightContext { + const kandangId = requireKandangId(args.kandangId, 'berat ayam'); + const totalDays = args.totalDays ?? null; + + const rows = Array.isArray(args.weightRows) ? args.weightRows : []; + const tren = rows + .map((row) => { + const hari = dayOf(row); + if (hari === null) return null; + const bobot = num(row.average_weight) ?? num(row.bobot_gram); + const target = + num(row.target_gram) ?? + (totalDays != null ? getTargetWeightForAge(hari, totalDays) : null); + return { + hari, + bobot_gram: bobot != null && bobot > 0 ? bobot : null, + target_gram: target, + adg_gram_per_hari: num(row.average_daily_gain) ?? num(row.adg_gram_per_hari), + uniformity_persen: num(row.uniformity), + }; + }) + .filter((row): row is NonNullable => row !== null) + .sort((a, b) => a.hari - b.hari); + + const latest = + [...tren].reverse().find((row) => row.bobot_gram != null) ?? tren[tren.length - 1]; + + return { + kandangId, + kandangName: args.kandangName, + cycleId: args.cycleId, + hari_ke: args.hari_ke ?? latest?.hari ?? null, + totalDays, + averageWeight: args.averageWeight ?? latest?.bobot_gram ?? null, + targetWeight: args.targetWeight ?? latest?.target_gram ?? null, + uniformity: args.uniformity ?? latest?.uniformity_persen ?? null, + adg: args.adg ?? latest?.adg_gram_per_hari ?? null, + tren_harian: tren, + }; +} + +// ─── IoT panel ─────────────────────────────────────────────────────────────── + +export type BuildIotInsightContextArgs = { + kandangId: number; + kandangName?: string; + cycleId?: number; + hari_ke?: number | null; + totalDays?: number | null; + suhu_rata_rata_C?: number | null; + experience_suhu_C?: number | null; + kelembapan_persen?: number | null; + kecepatan_angin_mPerDetik?: number | null; + konsumsi_air_L?: number | null; + setpoint_C?: number | null; + suhu_min_C?: number | null; + suhu_max_C?: number | null; + waktu_data?: string | null; +}; + +export function buildIotInsightContext(args: BuildIotInsightContextArgs): InsightContext { + const kandangId = requireKandangId(args.kandangId, 'panel IoT'); + + return { + kandangId, + kandangName: args.kandangName, + cycleId: args.cycleId, + hari_ke: args.hari_ke ?? null, + totalDays: args.totalDays ?? null, + suhu_rata_rata_C: args.suhu_rata_rata_C ?? null, + experience_suhu_C: args.experience_suhu_C ?? null, + kelembapan_persen: args.kelembapan_persen ?? null, + kecepatan_angin_mPerDetik: args.kecepatan_angin_mPerDetik ?? null, + konsumsi_air_L: args.konsumsi_air_L ?? null, + setpoint_C: args.setpoint_C ?? null, + suhu_min_C: args.suhu_min_C ?? null, + suhu_max_C: args.suhu_max_C ?? null, + waktu_data: args.waktu_data ?? null, + }; +} + +// ─── Feed sack (hitung karung) ─────────────────────────────────────────────── + +export type FeedSackInsightRow = { + day?: number; + hari?: number; + masuk?: number | null; + dituang?: number | null; + keluar?: number | null; + masukManual?: number | null; + dituangManual?: number | null; + in_today?: number | null; + feed_use_today?: number | null; + out_today?: number | null; + [key: string]: unknown; +}; + +export type BuildFeedSackInsightContextArgs = { + kandangId: number; + kandangName?: string; + cycleId?: number; + hari_ke?: number | null; + totalDays?: number | null; + dailyRows?: FeedSackInsightRow[]; + saldo_awal_karung?: number | null; + total_karung_masuk_iot?: number | null; + total_karung_dituang_iot?: number | null; + total_karung_keluar_iot?: number | null; + total_karung_masuk_manual?: number | null; + total_karung_dituang_manual?: number | null; + rata_karung_per_hari?: number | null; +}; + +export function buildFeedSackInsightContext(args: BuildFeedSackInsightContextArgs): InsightContext { + const kandangId = requireKandangId(args.kandangId, 'hitung karung'); + + const rows = Array.isArray(args.dailyRows) ? args.dailyRows : []; + const tren = rows + .map((row) => { + const hari = dayOf(row); + if (hari === null) return null; + return { + hari, + masuk: num(row.masuk) ?? num(row.in_today), + dituang: num(row.dituang) ?? num(row.feed_use_today), + keluar: num(row.keluar) ?? num(row.out_today), + masukManual: num(row.masukManual), + dituangManual: num(row.dituangManual), + }; + }) + .filter((row): row is NonNullable => row !== null) + .sort((a, b) => a.hari - b.hari); + + const sumField = (field: 'masuk' | 'dituang' | 'keluar' | 'masukManual' | 'dituangManual') => { + if (tren.length === 0) return null; + let total = 0; + let any = false; + for (const row of tren) { + const v = row[field]; + if (v != null) { + total += v; + any = true; + } + } + return any ? total : null; + }; + + const total_dituang_iot = args.total_karung_dituang_iot ?? sumField('dituang'); + const hari_ke = args.hari_ke ?? (tren.length > 0 ? tren[tren.length - 1]!.hari : null); + const rata = + args.rata_karung_per_hari ?? + (total_dituang_iot != null && hari_ke != null && hari_ke > 0 + ? Math.round((total_dituang_iot / hari_ke) * 10) / 10 + : null); + + return { + kandangId, + kandangName: args.kandangName, + cycleId: args.cycleId, + hari_ke, + totalDays: args.totalDays ?? null, + saldo_awal_karung: args.saldo_awal_karung ?? null, + total_karung_masuk_iot: args.total_karung_masuk_iot ?? sumField('masuk'), + total_karung_dituang_iot: total_dituang_iot, + total_karung_keluar_iot: args.total_karung_keluar_iot ?? sumField('keluar'), + total_karung_masuk_manual: args.total_karung_masuk_manual ?? sumField('masukManual'), + total_karung_dituang_manual: args.total_karung_dituang_manual ?? sumField('dituangManual'), + rata_karung_per_hari: rata, + tren_harian: tren, + }; +} + +// ─── Counting (hitung ayam) ────────────────────────────────────────────────── + +export type CountingInsightRow = { + day?: number; + hari?: number; + stockAkhir?: number | null; + populasi?: number | null; + mati?: number | null; + panen?: number | null; + chickenCount?: number | null; + [key: string]: unknown; +}; + +export type BuildCountingInsightContextArgs = { + kandangId: number; + kandangName?: string; + cycleId?: number; + hari_ke?: number | null; + totalDays?: number | null; + dailyRows?: CountingInsightRow[]; + populasi_awal_ekor?: number | null; + populasi_kini_ekor?: number | null; + mortalitas_kumulatif_ekor?: number | null; + /** Null when DOC-in unknown — never invent 0%. */ + mortalitas_persen?: number | null; + panen_kumulatif_ekor?: number | null; + berat_panen_kumulatif_kg?: number | null; +}; + +export function buildCountingInsightContext(args: BuildCountingInsightContextArgs): InsightContext { + const kandangId = requireKandangId(args.kandangId, 'hitung ayam'); + + const rows = Array.isArray(args.dailyRows) ? args.dailyRows : []; + const tren = rows + .map((row) => { + const hari = dayOf(row); + if (hari === null) return null; + return { + hari, + populasi: num(row.stockAkhir) ?? num(row.populasi), + mati_ekor: num(row.mati), + panen_ekor: num(row.panen), + chicken_count_iot_ekor: num(row.chickenCount), + }; + }) + .filter((row): row is NonNullable => row !== null) + .sort((a, b) => a.hari - b.hari); + + const latest = tren[tren.length - 1]; + const populasi_awal = args.populasi_awal_ekor ?? null; + const mortalitas_ekor = args.mortalitas_kumulatif_ekor ?? null; + const mortalitas_persen = + args.mortalitas_persen !== undefined + ? args.mortalitas_persen + : populasi_awal != null && populasi_awal > 0 && mortalitas_ekor != null + ? (mortalitas_ekor / populasi_awal) * 100 + : null; + + return { + kandangId, + kandangName: args.kandangName, + cycleId: args.cycleId, + hari_ke: args.hari_ke ?? latest?.hari ?? null, + totalDays: args.totalDays ?? null, + populasi_awal_ekor: populasi_awal, + populasi_kini_ekor: args.populasi_kini_ekor ?? latest?.populasi ?? null, + mortalitas_kumulatif_ekor: mortalitas_ekor, + mortalitas_persen, + panen_kumulatif_ekor: args.panen_kumulatif_ekor ?? null, + berat_panen_kumulatif_kg: args.berat_panen_kumulatif_kg ?? null, + tren_populasi_harian: tren, + }; +} + +// ─── Dashboard ─────────────────────────────────────────────────────────────── + +export type BuildDashboardInsightContextArgs = { + kandangId: number; + kandangName?: string; + cycleId?: number; + hari_ke?: number | null; + totalDays?: number | null; + fcr_terakhir?: number | null; + eef_terakhir?: number | null; + bobot_avg_gram?: number | null; + mortalitas_hari_ini_ekor?: number | null; + karung_dituang_hari_ini?: number | null; + pakan_kumulatif_karung?: number | null; + ayam_hidup_ekor?: number | null; + persen_hidup?: number | null; + /** Muted-row / detail scalars for LLM analysis. */ + doc_in_ekor?: number | null; + kematian_kumulatif_ekor?: number | null; + panen_kumulatif_ekor?: number | null; + tonase_panen_kg?: number | null; + uniformity_persen?: number | null; +}; + +export function buildDashboardInsightContext( + args: BuildDashboardInsightContextArgs +): InsightContext { + const kandangId = requireKandangId(args.kandangId, 'dashboard'); + + return { + kandangId, + kandangName: args.kandangName, + cycleId: args.cycleId, + hari_ke: args.hari_ke ?? null, + totalDays: args.totalDays ?? null, + fcr_terakhir: args.fcr_terakhir ?? null, + eef_terakhir: args.eef_terakhir ?? null, + bobot_avg_gram: args.bobot_avg_gram ?? null, + mortalitas_hari_ini_ekor: args.mortalitas_hari_ini_ekor ?? null, + karung_dituang_hari_ini: args.karung_dituang_hari_ini ?? null, + pakan_kumulatif_karung: args.pakan_kumulatif_karung ?? null, + ayam_hidup_ekor: args.ayam_hidup_ekor ?? null, + persen_hidup: args.persen_hidup ?? null, + doc_in_ekor: args.doc_in_ekor ?? null, + kematian_kumulatif_ekor: args.kematian_kumulatif_ekor ?? null, + panen_kumulatif_ekor: args.panen_kumulatif_ekor ?? null, + tonase_panen_kg: args.tonase_panen_kg ?? null, + uniformity_persen: args.uniformity_persen ?? null, + }; +} + +/** Day-indexed weight point for dashboard insight chart (display only). */ +export type DashboardInsightWeightPoint = { + hari: number; + actual: number | null; + target: number | null; +}; + +/** Day-indexed KPI snapshot so the card can scope tiles to a selected day. */ +export type DashboardInsightKpiPoint = { + hari: number; + fcr: number | null; + eef: number | null; + ayam_hidup_ekor: number | null; + mortalitas_kumulatif_persen: number | null; + bobot_avg_gram: number | null; + kematian_kumulatif_ekor: number | null; + panen_kumulatif_ekor: number | null; + tonase_panen_kg: number | null; + uniformity_persen: number | null; + pakan_kumulatif_karung: number | null; +}; + +/** + * Display-only metrics for main-dashboard AI Insight visuals. + * Kept separate from LLM `InsightContext` so chart series are not sent to the model. + */ +export type DashboardInsightMetrics = { + hari_ke: number | null; + fcr: number | null; + eef: number | null; + ayam_hidup_ekor: number | null; + mortalitas_kumulatif_persen: number | null; + bobot_avg_gram: number | null; + /** Cycle DOC intake — constant across days. */ + doc_in_ekor: number | null; + kematian_kumulatif_ekor: number | null; + panen_kumulatif_ekor: number | null; + tonase_panen_kg: number | null; + uniformity_persen: number | null; + pakan_kumulatif_karung: number | null; + weightSeries: DashboardInsightWeightPoint[]; + kpiSeries: DashboardInsightKpiPoint[]; +}; + +export type BuildDashboardInsightMetricsArgs = { + /** KPI rows already filtered by visibility / anchor (newest-first or any order). */ + kpis: Array<{ + age?: number | null; + date?: string | null; + fcr?: number | null; + eef?: number | null; + chicken_life?: number | null; + chicken_life_percentage?: number | null; + iot_weight?: number | null; + mortality_total?: number | null; + harvest_total?: number | null; + harvest_weight_total?: number | null; + feed_total?: number | null; + }>; + /** Weight rows already filtered by visibility / anchor. */ + weights: Array<{ + age?: number | null; + date?: string | null; + average_weight?: number | null; + doc_weight?: number | null; + uniformity?: number | null; + }>; + /** Fallback day label when no KPI age is present. */ + currentDay?: number | null; + /** DOC chick-in count for the cycle (muted row). */ + docInCount?: number | null; + /** Optional override when KPI feed_total is missing (e.g. karung.feed_use_total). */ + pakanKumulatifFallback?: number | null; +}; + +const mortalitasFromPersenHidup = (persenHidup: number | null): number | null => { + if (persenHidup === null || !Number.isFinite(persenHidup)) return null; + return Math.round((100 - persenHidup) * 100) / 100; +}; + +/** + * Build display metrics for dashboard AI Insight heroes / tiles / weight chart. + * Callers must pre-filter rows with `throughVisibleDate` (and anchor) like Dashboard does. + */ +export function buildDashboardInsightMetrics( + args: BuildDashboardInsightMetricsArgs +): DashboardInsightMetrics { + const weightByAge = new Map< + number, + { actual: number | null; target: number | null; uniformity: number | null } + >(); + for (const row of args.weights) { + const hari = num(row.age); + if (hari === null || hari <= 0) continue; + const actual = num(row.average_weight); + const doc = num(row.doc_weight) ?? 40; + const target = getTargetWeightForAge(hari, doc); + weightByAge.set(hari, { + actual, + target, + uniformity: num(row.uniformity), + }); + } + + const weightSeries: DashboardInsightWeightPoint[] = [...weightByAge.entries()] + .map(([hari, point]) => ({ hari, actual: point.actual, target: point.target })) + .sort((a, b) => a.hari - b.hari); + + const kpiByAge = new Map(); + for (const row of args.kpis) { + const hari = num(row.age); + if (hari === null || hari <= 0) continue; + const iotWeight = num(row.iot_weight); + const weightFromSeries = weightByAge.get(hari)?.actual ?? null; + const bobot = + iotWeight !== null && iotWeight > 0 ? iotWeight : weightFromSeries; + const persenHidup = num(row.chicken_life_percentage); + const uniformity = weightByAge.get(hari)?.uniformity ?? null; + kpiByAge.set(hari, { + hari, + fcr: num(row.fcr), + eef: num(row.eef), + ayam_hidup_ekor: num(row.chicken_life), + mortalitas_kumulatif_persen: mortalitasFromPersenHidup(persenHidup), + bobot_avg_gram: bobot, + kematian_kumulatif_ekor: num(row.mortality_total), + panen_kumulatif_ekor: num(row.harvest_total), + tonase_panen_kg: num(row.harvest_weight_total), + uniformity_persen: uniformity, + pakan_kumulatif_karung: num(row.feed_total), + }); + } + + const kpiSeries = [...kpiByAge.values()].sort((a, b) => a.hari - b.hari); + const latestKpi = + kpiSeries.length > 0 ? kpiSeries[kpiSeries.length - 1]! : null; + const latestWeight = + weightSeries.length > 0 ? weightSeries[weightSeries.length - 1]! : null; + const latestWeightMeta = + latestWeight != null ? weightByAge.get(latestWeight.hari) : undefined; + + const bobotLatest = + latestKpi?.bobot_avg_gram ?? + (latestWeight?.actual != null && latestWeight.actual > 0 ? latestWeight.actual : null); + + const hari_ke = + latestKpi?.hari ?? + latestWeight?.hari ?? + (num(args.currentDay) != null && (args.currentDay as number) > 0 + ? num(args.currentDay) + : null); + + const docIn = + num(args.docInCount) != null && (args.docInCount as number) > 0 + ? num(args.docInCount) + : null; + + return { + hari_ke, + fcr: latestKpi?.fcr ?? null, + eef: latestKpi?.eef ?? null, + ayam_hidup_ekor: latestKpi?.ayam_hidup_ekor ?? null, + mortalitas_kumulatif_persen: latestKpi?.mortalitas_kumulatif_persen ?? null, + bobot_avg_gram: bobotLatest, + doc_in_ekor: docIn, + kematian_kumulatif_ekor: latestKpi?.kematian_kumulatif_ekor ?? null, + panen_kumulatif_ekor: latestKpi?.panen_kumulatif_ekor ?? null, + tonase_panen_kg: latestKpi?.tonase_panen_kg ?? null, + uniformity_persen: + latestKpi?.uniformity_persen ?? latestWeightMeta?.uniformity ?? null, + pakan_kumulatif_karung: + latestKpi?.pakan_kumulatif_karung ?? num(args.pakanKumulatifFallback) ?? null, + weightSeries, + kpiSeries, + }; +} + +/** + * Scope display metrics to a selected cycle day. + * Weight chart keeps rows with `hari <= upToDay`. + * Tile scalars use the KPI snapshot at exactly that day — never a newer day's values. + */ +export function scopeDashboardInsightMetrics( + metrics: DashboardInsightMetrics, + upToDay: number | null +): DashboardInsightMetrics { + if (upToDay === null || !Number.isFinite(upToDay) || upToDay <= 0) { + return metrics; + } + + const weightSeries = metrics.weightSeries.filter((row) => row.hari <= upToDay); + const kpiSeries = metrics.kpiSeries.filter((row) => row.hari <= upToDay); + const exact = metrics.kpiSeries.find((row) => row.hari === upToDay) ?? null; + const weightAtDay = weightSeries.find((row) => row.hari === upToDay) ?? null; + + return { + hari_ke: upToDay, + fcr: exact?.fcr ?? null, + eef: exact?.eef ?? null, + ayam_hidup_ekor: exact?.ayam_hidup_ekor ?? null, + mortalitas_kumulatif_persen: exact?.mortalitas_kumulatif_persen ?? null, + bobot_avg_gram: exact?.bobot_avg_gram ?? weightAtDay?.actual ?? null, + doc_in_ekor: metrics.doc_in_ekor, + kematian_kumulatif_ekor: exact?.kematian_kumulatif_ekor ?? null, + panen_kumulatif_ekor: exact?.panen_kumulatif_ekor ?? null, + tonase_panen_kg: exact?.tonase_panen_kg ?? null, + uniformity_persen: exact?.uniformity_persen ?? null, + pakan_kumulatif_karung: exact?.pakan_kumulatif_karung ?? null, + weightSeries, + kpiSeries, + }; +} diff --git a/utils/insightHelpers.ts b/utils/insightHelpers.ts new file mode 100644 index 0000000..7893796 --- /dev/null +++ b/utils/insightHelpers.ts @@ -0,0 +1,201 @@ +import { cobbStandardPerformance } from '../mockData/cobbStandard.ts'; + +/** + * Analyze trend of numerical data over a window + * @param data Array of numerical values + * @param windowSize Number of recent data points to analyze + * @returns Trend description in Indonesian + */ +export function analyzeTrend(data: number[], windowSize = 7): string { + if (!data || data.length < 2) { + return 'tidak cukup data'; + } + + // Filter out invalid values (null, undefined, NaN, 0) + const validData = data.filter((val) => val != null && !isNaN(val) && val !== 0); + + if (validData.length < 2) { + return 'tidak cukup data'; + } + + // Take last N values + const recentData = validData.slice(-Math.min(windowSize, validData.length)); + + if (recentData.length < 2) { + return 'tidak cukup data'; + } + + // Calculate simple linear regression slope + const n = recentData.length; + const xMean = (n - 1) / 2; // Mean of indices 0, 1, 2, ..., n-1 + const yMean = recentData.reduce((sum, val) => sum + val, 0) / n; + + let numerator = 0; + let denominator = 0; + + for (let i = 0; i < n; i++) { + numerator += (i - xMean) * ((recentData[i] ?? 0) - yMean); + denominator += Math.pow(i - xMean, 2); + } + + const slope = denominator !== 0 ? numerator / denominator : 0; + + // Calculate relative slope (as percentage of mean) + const relativeSlope = yMean !== 0 ? (slope / yMean) * 100 : 0; + + // Determine trend based on relative slope + // Threshold: >2% = increasing, <-2% = decreasing, else stable + if (relativeSlope > 2) { + return 'meningkat'; + } else if (relativeSlope < -2) { + return 'menurun'; + } else { + return 'stabil'; + } +} + +/** + * Get cycle phase based on day of cycle + * @param day Current day of cycle (1-based) + * @returns Phase name + */ +export function getCyclePhase(day: number): string { + if (day <= 14) { + return 'Brooding'; + } else if (day <= 28) { + return 'Growth'; + } else { + return 'Finisher'; + } +} + +/** + * Get Cobb standard performance metrics for a given day + * @param day Day of cycle (1-based) + * @returns Object with FCR and weight, or null if not available + */ +export function getCobbStandardForDay(day: number): { fcr: number; weight: number } | null { + const standard = cobbStandardPerformance.find((entry) => entry.day === day); + if (standard) { + return { + fcr: standard.fcr, + // Weight targets live in page builders / CP707 knowledge; FCR row has no weight column here. + weight: 0, + }; + } + return null; +} + +/** + * Calculate deviation between actual and target values + * @param actual Actual value + * @param target Target value + * @returns Object with absolute and percentage deviation + */ +export function calculateDeviation( + actual: number, + target: number +): { absolute: number; percent: number } { + const absolute = actual - target; + const percent = target !== 0 ? (absolute / target) * 100 : 0; + return { absolute, percent }; +} + +/** + * Determine environmental status based on readings and cycle phase + * @param temp Temperature in Celsius + * @param humidity Humidity percentage + * @param ammonia Ammonia in ppm + * @param day Current day of cycle + * @returns Status description in Indonesian + */ +export function getEnvironmentalStatus( + temp: number, + humidity: number, + ammonia: number, + day: number +): string { + const issues: string[] = []; + + // Age-appropriate temperature ranges + // Younger chicks need warmer temperatures + let optimalTempMin: number; + let optimalTempMax: number; + + if (day <= 7) { + optimalTempMin = 32; + optimalTempMax = 35; + } else if (day <= 14) { + optimalTempMin = 30; + optimalTempMax = 32; + } else if (day <= 21) { + optimalTempMin = 28; + optimalTempMax = 30; + } else { + optimalTempMin = 26; + optimalTempMax = 28; + } + + // Check temperature + if (temp < optimalTempMin) { + issues.push('suhu terlalu rendah'); + } else if (temp > optimalTempMax) { + issues.push('suhu terlalu tinggi'); + } + + // Check humidity (optimal: 50-70%) + if (humidity < 50) { + issues.push('kelembapan rendah'); + } else if (humidity > 70) { + issues.push('kelembapan tinggi'); + } + + // Check ammonia (safe: <25ppm, warning: 25-50ppm, critical: >50ppm) + if (ammonia > 50) { + issues.push('amonia kritis'); + } else if (ammonia > 25) { + issues.push('amonia tinggi'); + } + + if (issues.length === 0) { + return 'optimal'; + } else if (issues.length === 1) { + return issues[0] ?? 'optimal'; + } else { + return issues.join(', '); + } +} + +/** + * Analyze feed consumption trend + * @param feedHistory Array of daily feed consumption (in sacks) + * @param _currentDay Current day of cycle (reserved for future use) + * @returns Trend description in Indonesian + */ +export function analyzeFeedTrend(feedHistory: number[], _currentDay: number): string { + if (!feedHistory || feedHistory.length < 3) { + return 'tidak cukup data'; + } + + // Get last 7 days of feed data + const recentFeed = feedHistory.slice(-7); + + // Rough expectation: feed consumption should increase gradually as chickens grow + // Early days: 0.5-1 sack/1000 birds/day + // Mid cycle: 1-2 sacks/1000 birds/day + // Late cycle: 2-3 sacks/1000 birds/day + + // This is a simplified heuristic - in real deployment, this would be calibrated + // based on actual farm data and population size + + // For now, just analyze if consumption is increasing (expected as birds grow) + const trend = analyzeTrend(recentFeed, 7); + + if (trend === 'meningkat') { + return 'sesuai pertumbuhan'; + } else if (trend === 'menurun') { + return 'menurun (perlu perhatian)'; + } else { + return 'stabil'; + } +} diff --git a/utils/insightParse.ts b/utils/insightParse.ts new file mode 100644 index 0000000..346c9bb --- /dev/null +++ b/utils/insightParse.ts @@ -0,0 +1,156 @@ +/** + * Turns whatever JSON the local model returns into {summary, insight}. + * + * The models routinely invent their own field names — `tren_mortalitas_harian`, + * `dampak_populasi`, `rekomendasi_tindakan` instead of `kesimpulan`/`insight`. + * The old per-page parser answered that by substituting a cheerful default + * ("Analisis operasional kandang terpantau dengan baik."), which could mask a + * report of total flock loss, and the counting page answered it by discarding + * the response outright. + * + * Rule here: never invent reassuring text, and never silently drop content. Any + * field the model produced is folded into the output; only a response with no + * usable text at all returns null, so the UI can show a real error instead. + */ + +export interface AiResult { + summary: string; + insight: string; +} + +/** Field names seen standing in for the conclusion. */ +const KESIMPULAN_KEYS = [ + 'kesimpulan', + 'kesimpulan_analisis', + 'ringkasan', + 'headline', + 'summary', + 'conclusion', +]; + +/** Field names seen standing in for the recommendation. */ +const INSIGHT_KEYS = [ + 'insight', + 'insights', + 'rekomendasi', + 'rekomendasi_tindakan', + 'tindakan', + 'saran', + 'actions', + 'recommendations', +]; + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Turns a leftover field name into a readable label: `tren_mortalitas_harian` -> `Tren mortalitas harian`. */ +const humanizeKey = (key: string) => { + const spaced = key.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2'); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +}; + +/** Flattens any value into readable plain text. Arrays become bullet lines. */ +const renderValue = (value: unknown, depth = 0): string => { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + + if (Array.isArray(value)) { + return value + .map((item) => renderValue(item, depth + 1)) + .filter((item) => item.length > 0) + .map((item) => (depth === 0 ? `• ${item}` : item)) + .join('\n'); + } + + if (isPlainObject(value)) { + return Object.entries(value) + .map(([key, nested]) => { + const rendered = renderValue(nested, depth + 1); + return rendered ? `${humanizeKey(key)}: ${rendered}` : ''; + }) + .filter((line) => line.length > 0) + .join('\n'); + } + + return ''; +}; + +const takeFirst = (source: Record, keys: string[]) => { + for (const key of keys) { + if (key in source) { + const rendered = renderValue(source[key]); + if (rendered) return { key, text: rendered }; + } + } + return null; +}; + +/** Strips think-tags, code fences, comments and trailing commas, then isolates the JSON object. */ +const extractJsonObject = (text: string): string | null => { + const cleaned = text + .replace(/[\s\S]*?<\/think>/gi, '') + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```\s*$/, '') + .replace(/\/\/.*/g, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/,\s*([}\]])/g, '$1') + .trim(); + + const first = cleaned.indexOf('{'); + const last = cleaned.lastIndexOf('}'); + if (first === -1 || last === -1 || last <= first) return null; + return cleaned.slice(first, last + 1); +}; + +export const parseAiResult = (text: string): AiResult | null => { + if (!text || typeof text !== 'string') return null; + + const candidate = extractJsonObject(text); + if (!candidate) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + return null; + } + if (!isPlainObject(parsed)) return null; + + const used = new Set(); + + const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS); + if (kesimpulanHit) used.add(kesimpulanHit.key); + + const insightHit = takeFirst(parsed, INSIGHT_KEYS); + if (insightHit) used.add(insightHit.key); + + // Anything the model invented a name for still carries its analysis, so it is + // appended under a readable label rather than thrown away. + const leftovers = Object.entries(parsed) + .filter(([key]) => !used.has(key)) + .map(([key, value]) => { + const rendered = renderValue(value); + return rendered ? `${humanizeKey(key)}: ${rendered}` : ''; + }) + .filter((line) => line.length > 0); + + let kesimpulan = kesimpulanHit?.text ?? ''; + let insight = insightHit?.text ?? ''; + + if (!kesimpulan && leftovers.length > 0) { + // Promote the first salvaged field to the conclusion so the card is never + // headed by a generic sentence the model never wrote. + kesimpulan = leftovers.shift() as string; + } + if (leftovers.length > 0) { + insight = [insight, ...leftovers].filter(Boolean).join('\n\n'); + } + + if (!kesimpulan && !insight) return null; + + return { + summary: kesimpulan || 'Model tidak mengembalikan ringkasan eksplisit.', + insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.', + }; +}; diff --git a/utils/insightScope.ts b/utils/insightScope.ts new file mode 100644 index 0000000..5c06671 --- /dev/null +++ b/utils/insightScope.ts @@ -0,0 +1,275 @@ +/** + * Narrows a per-page insight's context data down to the period the user picked. + * + * The per-page AI Insight used to send the whole cycle to the model no matter + * which timeframe was selected, and only changed a label in the prompt — so + * "Harian" and "Mingguan" produced near-identical answers. This trims the data + * itself, which is the only thing the model actually reads. + */ + +export type InsightTimeframe = 'harian' | 'mingguan'; + +/** + * Rows of a day-indexed history carry a day number and/or a date. + * + * Pages are inconsistent about the field name: the dashboard builds `day`/`date` + * while the FCR and EEF pages build `hari`/`tanggal`. Recognising only the + * English spelling meant those two pages' histories were never sliced at all — + * their timeframe selector changed nothing. + */ +type DayRow = Record & { + day?: unknown; + hari?: unknown; + date?: unknown; + tanggal?: unknown; +}; + +const DAY_KEYS = ['day', 'hari'] as const; +const DATE_KEYS = ['date', 'tanggal'] as const; + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const dayOf = (row: DayRow): number | null => { + for (const key of DAY_KEYS) { + const value = row[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + } + return null; +}; + +const hasDateField = (row: DayRow): boolean => + DATE_KEYS.some((key) => typeof row[key] === 'string'); + +/** + * A history indexed by time, as opposed to a snapshot list (cameras, devices, + * coops) that has no time dimension. Only the former may be sliced by period — + * trimming a device list to "today" would silently hide devices. + */ +const isDayIndexedArray = (value: unknown[]): value is DayRow[] => + value.length > 0 && + value.every( + (item) => + isPlainObject(item) && (dayOf(item as DayRow) !== null || hasDateField(item as DayRow)) + ); + +const sliceHistory = ( + rows: DayRow[], + timeframe: InsightTimeframe, + selectedDay: number | null = null +): DayRow[] => { + const days = rows.map(dayOf).filter((day): day is number => day !== null); + + if (timeframe === 'harian') { + if (days.length > 0) { + // Picking a day means "the cycle so far, up to that day" — day 0 through + // day N — not that day in isolation. A single row shows no trend and + // cannot support a cumulative figure like mortality-to-date. + if (selectedDay !== null) + return rows.filter((row) => (dayOf(row) ?? Infinity) <= selectedDay); + return rows.filter((row) => dayOf(row) === Math.max(...days)); + } + // Date-only history: the array position is the only ordering we have. + if (selectedDay !== null) return rows.slice(0, selectedDay + 1); + return rows.slice(-1); + } + + if (days.length > 0) { + const latestDay = Math.max(...days); + // The 7-day window ending today, matched on each row's own `day` so gaps in + // the data do not shift the window. + return rows.filter((row) => { + const day = dayOf(row); + return day !== null && day > latestDay - 7 && day <= latestDay; + }); + } + return rows.slice(-7); +}; + +/** + * Returns a copy of `contextData` where every day-indexed history is trimmed to + * `timeframe`. Snapshot lists, scalars, and nested objects are preserved. + */ +export const scopeContextToTimeframe = ( + contextData: T, + timeframe: InsightTimeframe, + selectedDay: number | null = null +): T => { + if (Array.isArray(contextData)) { + const scoped = isDayIndexedArray(contextData) + ? sliceHistory(contextData as DayRow[], timeframe, selectedDay) + : contextData; + return scoped.map((item) => + scopeContextToTimeframe(item, timeframe, selectedDay) + ) as unknown as T; + } + + if (isPlainObject(contextData)) { + const scoped: Record = {}; + for (const [key, value] of Object.entries(contextData)) { + scoped[key] = scopeContextToTimeframe(value, timeframe, selectedDay); + } + return scoped as unknown as T; + } + + return contextData; +}; + +export const UNAVAILABLE = 'tidak tersedia'; + +/** + * How one page's summary scalars relate to its day-indexed history. + * + * `derive` maps an OUTPUT key — which must carry its unit, e.g. + * `bobotRataRata_gram` — to the history field it is computed from and how to + * aggregate it across the window. `drop` lists the original unitless keys the + * derived ones replace; leaving them in would hand the model both the stale + * figure and the period one and let it pick. `unavailable` names scalars with + * no day-indexed source at all: they are marked rather than left showing a + * value from a different period. + */ +export type ScalarScopeSpec = { + historyKey: string; + /** + * `replaces` names the original key this derived value stands in for. The + * prompt drops the original, but the card's summary grid still renders it — + * without the link the grid kept showing cycle-to-date figures beside a + * narrative scoped to one period, which reads as the toggle doing nothing. + */ + derive: Record< + string, + { + field: string; + /** + * `last` takes the final row verbatim. `lastValid` walks backwards past + * zeros — use it for readings where 0 means "the sensor sent nothing", + * not "the measurement was zero". The IoT scale stopped reporting at day + * 38 and has written 0 every day since, so `last` handed the card + * "Rata-rata Bobot 0 kg" while the page beside it showed the real figure. + */ + agg: 'mean' | 'sum' | 'last' | 'lastValid'; + replaces?: string; + /** + * What to write when the window holds no usable reading. The default is + * `UNAVAILABLE`, which is right for figures a missing day says nothing + * about. Set `0` for the ones the supervisor wants reported as a plain + * zero instead — a day with no weighing (day 48) has to render "0 g", + * not a blank, because a blank read as a broken card. + */ + emptyAs?: 0; + } + >; + drop?: string[]; + unavailable?: string[]; +}; + +const round2 = (value: number) => Math.round(value * 100) / 100; + +const aggregate = (rows: DayRow[], field: string, agg: 'mean' | 'sum' | 'last' | 'lastValid') => { + const values = rows + .map((row) => row[field]) + .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); + if (values.length === 0) return null; + if (agg === 'lastValid') { + // Mirrors `getLatestValid` on the FCR/EEF pages, so the insight card and + // the page it sits on cannot report different numbers. All-zero means the + // reading is missing, which has to surface as "tidak tersedia" rather than + // a confident 0. + for (let i = values.length - 1; i >= 0; i--) { + const value = values[i]; + if (value !== undefined && value !== 0) return value; + } + return null; + } + if (agg === 'last') return values[values.length - 1] ?? null; + const total = values.reduce((sum, value) => sum + value, 0); + return round2(agg === 'sum' ? total : total / values.length); +}; + +/** + * Bring a per-page context's scalars into the same period as its history. + * + * Slicing the history alone is not enough: a "Mingguan" payload whose chart + * covers days 42-48 while `averageWeight` still holds the day-48 figure lets + * the model quote either, which is the exact contradiction already fixed on the + * dashboard. Run this AFTER `scopeContextToTimeframe`, on the sliced context. + */ +export const scopeScalarsToTimeframe = ( + scopedContext: Record, + timeframe: InsightTimeframe, + spec: ScalarScopeSpec, + selectedDay: number | null = null +): Record => { + const history = scopedContext[spec.historyKey]; + if (!Array.isArray(history)) return scopedContext; + const rows = history as DayRow[]; + + const out: Record = { ...scopedContext }; + for (const key of spec.drop ?? []) delete out[key]; + + const days = rows.map(dayOf).filter((day): day is number => day !== null); + // Harian = cumulative from cycle start (hari ke-0), matching dashboard reports. + // Do not label from the first day that happens to have a row (e.g. "2 s/d 8"). + let label: string; + if (days.length === 0) { + label = UNAVAILABLE; + } else if (timeframe === 'harian') { + const endDay = selectedDay ?? Math.max(...days); + label = + selectedDay === null && Math.min(...days) === Math.max(...days) + ? `hari ke-${endDay}` + : `hari ke-0 s/d ke-${endDay}`; + } else if (Math.min(...days) === Math.max(...days)) { + label = `hari ke-${Math.max(...days)}`; + } else { + label = `hari ke-${Math.min(...days)} s/d ke-${Math.max(...days)}`; + } + + for (const [outKey, rule] of Object.entries(spec.derive)) { + const value = aggregate(rows, rule.field, rule.agg); + out[outKey] = value === null ? (rule.emptyAs ?? UNAVAILABLE) : value; + } + for (const key of spec.unavailable ?? []) out[key] = UNAVAILABLE; + + out.periode = label; + // Only the multi-day window needs the "not today" warning; saying it in daily + // mode contradicts the label directly above it. + // Only a window that is not today needs the "not today" warning; saying it in + // a single-day-today report contradicts the label directly above it. + const satuHariTerakhir = + timeframe === 'harian' && days.length > 0 && Math.min(...days) === Math.max(...days); + const bukanHariIni = satuHariTerakhir ? '' : 'BUKAN kondisi hari ini. '; + const dataSpan = + days.length > 0 && Math.min(...days) > 0 + ? ` Baris data tersedia mulai hari ke-${Math.min(...days)}.` + : ''; + out.catatan_periode = + `Angka ringkas di objek ini dihitung dari periode ${label} (${rows.length} hari data).${dataSpan} ` + + `${bukanHariIni}Satuan setiap angka tertulis di akhir nama field ` + + `(_gram, _persen, _rasio, _karung, _ekor, _kg) — pakai satuan itu persis. ` + + `Field berisi "${UNAVAILABLE}" memang tidak ada datanya: tulis "data tidak tersedia" ` + + `dan JANGAN mengarang angkanya.`; + + return out; +}; + +/** Human-readable description of the window, for the prompt and the UI. */ +export const describeTimeframe = ( + timeframe: InsightTimeframe, + currentDay?: number | null, + selectedDay: number | null = null +) => { + if (timeframe === 'harian') { + // Daily now means "the cycle so far", so the label must state the range. A + // past end-day must not be described as "hari ini" either — the model + // repeats that phrasing back and claims the figures are today's. + if (selectedDay !== null) { + const ekor = `hari ke-0 s/d ke-${selectedDay} (kumulatif sejak awal siklus)`; + return selectedDay === currentDay ? `${ekor}, berakhir hari ini` : `${ekor}, BUKAN hari ini`; + } + return currentDay ? `Hari ini saja (hari ke-${currentDay})` : 'Hari ini saja (hari terakhir)'; + } + return currentDay + ? `7 hari terakhir (hari ke-${Math.max(1, currentDay - 6)} sampai hari ke-${currentDay})` + : '7 hari terakhir'; +};