update ai insight migration for all page
This commit is contained in:
1 parent
07d36081ce
commit
18790ab8f6
69 files changed
+9457
-399
No files matched your search
+2
-1
@@ -9,4 +9,5 @@ backup
|
||||
__pycache__
|
||||
docker-compose.override.yml
|
||||
docs/
|
||||
.superpowers/
|
||||
.superpowers/
|
||||
AI Insight/chroma_db/
|
||||
@@ -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"]
|
||||
@@ -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 ""
|
||||
Binary file not shown.
@@ -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()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
@@ -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 (<think>) jika ada (khusus model reasoning seperti Qwen 2.5)
|
||||
import re
|
||||
think_match = re.search(r'<think>(.*?)</think>', answer, re.DOTALL)
|
||||
clean_answer = re.sub(r'<think>.*?</think>', '', 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()
|
||||
@@ -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")
|
||||
@@ -0,0 +1,8 @@
|
||||
chromadb
|
||||
sentence-transformers
|
||||
python-docx
|
||||
pypdf
|
||||
python-dotenv
|
||||
openai
|
||||
fastapi
|
||||
uvicorn
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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="[]"),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"<think>[\s\S]*?</think>", "", 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)
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 331 KiB |
@@ -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'
|
||||
]
|
||||
|
||||
@@ -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<InsightSource, { text: string; cls: string }> = {
|
||||
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<InsightStatusLabel, string> = {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const summary = (
|
||||
parsed.summary && typeof parsed.summary === 'object' ? parsed.summary : {}
|
||||
) as Record<string, unknown>;
|
||||
const detailed = (
|
||||
parsed.detailed && typeof parsed.detailed === 'object' ? parsed.detailed : {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
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 (
|
||||
<div className="mt-4 rounded-lg border border-gray-100 bg-gray-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">
|
||||
Sumber referensi
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{citations.map((c, i) => (
|
||||
<li key={c.id ?? `${c.title ?? 'cite'}-${i}`} className="text-xs text-gray-600">
|
||||
<span className="font-medium text-gray-800">
|
||||
{c.chapter || c.title || c.source || `Referensi ${i + 1}`}
|
||||
</span>
|
||||
{c.excerpt ? <span className="text-gray-500"> — {c.excerpt}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<AiInsightCardProps> = ({
|
||||
kandangId,
|
||||
context: contextProp,
|
||||
dashboardMetrics = null,
|
||||
page = 'dashboard',
|
||||
}) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||
|
||||
const [insightText, setInsightText] = useState('');
|
||||
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
|
||||
const [source, setSource] = useState<InsightSource | null>(null);
|
||||
const [citations, setCitations] = useState<InsightCitation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [reportType, setReportType] = useState<InsightReportType>('daily');
|
||||
const [dayPeriod, setDayPeriod] = useState<string | null>(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<ReturnType<typeof api.insights.generate>> | 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<never>((_, 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 <p className="text-sm text-gray-500">{emptyLabel}</p>;
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${item}-${index}`} className="flex gap-2 text-sm text-gray-700">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDetailSection = (
|
||||
title: string,
|
||||
section: InsightStructuredSection,
|
||||
accentClass: string
|
||||
) => (
|
||||
<section className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500">{title}</p>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${accentClass}`}
|
||||
>
|
||||
{section.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Poin</p>
|
||||
<div className="mt-2">{renderBulletList(section.bullets)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Bukti</p>
|
||||
<div className="mt-2">{renderBulletList(section.evidence)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Aksi</p>
|
||||
<div className="mt-2">{renderBulletList(section.actions)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="mb-5 space-y-3 rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<HeroFigure
|
||||
label="FCR Aktual"
|
||||
value={fcrVal == null ? 'N/A' : formatRatio(fcrVal, 3)}
|
||||
context={
|
||||
fcrStandard != null && gradeDay != null
|
||||
? `Standar CP 707 hari ke-${gradeDay}: ${formatRatio(fcrStandard, 3)}`
|
||||
: null
|
||||
}
|
||||
status={fcrStatus}
|
||||
deltaLabel={
|
||||
fcrVal != null && fcrStandard != null
|
||||
? `${fcrVal > fcrStandard ? '+' : ''}${formatRatio(fcrVal - fcrStandard, 3)} vs standar`
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<HeroFigure
|
||||
label="EEF"
|
||||
value={eefVal == null ? 'N/A' : formatRatio(eefVal, 3)}
|
||||
context={
|
||||
(gradeDay ?? 0) >= 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'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
||||
<StatTile
|
||||
label="Populasi Hidup"
|
||||
value={popVal == null ? 'N/A' : popVal.toLocaleString('id-ID')}
|
||||
unit={popVal == null ? undefined : 'ekor'}
|
||||
hint="s/d kini"
|
||||
/>
|
||||
<StatTile
|
||||
label="Mortalitas Kumulatif"
|
||||
value={mortVal == null ? 'N/A' : `${mortVal.toFixed(2)}%`}
|
||||
status={mortalityStatus}
|
||||
deltaLabel={
|
||||
mortVal == null
|
||||
? null
|
||||
: mortalityStatus === 'ok'
|
||||
? '✓ normal <5%'
|
||||
: mortalityStatus === 'warning'
|
||||
? '↑ melebihi standar <5%'
|
||||
: '↑ tinggi, standar <5%'
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
label="Bobot Rata-rata"
|
||||
value={bobotVal == null ? 'N/A' : Math.round(bobotVal).toLocaleString('id-ID')}
|
||||
unit={bobotVal == null ? undefined : 'g'}
|
||||
status={weightStatus}
|
||||
deltaLabel={
|
||||
weightGapPct == null ? null : `${weightGapPct.toFixed(0)}% vs target`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
||||
<MutedStatRow
|
||||
label="Jumlah Populasi (Jumlah DOC)"
|
||||
value={
|
||||
scopedMetrics.doc_in_ekor == null
|
||||
? 'N/A'
|
||||
: scopedMetrics.doc_in_ekor.toLocaleString('id-ID')
|
||||
}
|
||||
/>
|
||||
<MutedStatRow
|
||||
label="Jumlah Kematian (Kematian + Afkir)"
|
||||
hint={gradeDay != null ? `— s/d hari ke-${gradeDay}` : null}
|
||||
value={
|
||||
scopedMetrics.kematian_kumulatif_ekor == null
|
||||
? 'N/A'
|
||||
: scopedMetrics.kematian_kumulatif_ekor.toLocaleString('id-ID')
|
||||
}
|
||||
/>
|
||||
<MutedStatRow
|
||||
label="Jumlah terpanen"
|
||||
hint={gradeDay != null ? `— s/d hari ke-${gradeDay}` : null}
|
||||
value={
|
||||
scopedMetrics.panen_kumulatif_ekor == null
|
||||
? 'N/A'
|
||||
: scopedMetrics.panen_kumulatif_ekor.toLocaleString('id-ID')
|
||||
}
|
||||
/>
|
||||
<MutedStatRow
|
||||
label="Jumlah tonase panen"
|
||||
hint={gradeDay != null ? `— s/d hari ke-${gradeDay}` : null}
|
||||
value={
|
||||
scopedMetrics.tonase_panen_kg == null
|
||||
? 'N/A'
|
||||
: `${scopedMetrics.tonase_panen_kg.toLocaleString('id-ID')} kg`
|
||||
}
|
||||
/>
|
||||
<MutedStatRow
|
||||
label="Uniformity (AI Scale)"
|
||||
value={
|
||||
scopedMetrics.uniformity_persen == null
|
||||
? 'N/A'
|
||||
: `${scopedMetrics.uniformity_persen.toFixed(1)}%`
|
||||
}
|
||||
/>
|
||||
<MutedStatRow
|
||||
label="Konsumsi pakan (Sack Counting)"
|
||||
value={
|
||||
scopedMetrics.pakan_kumulatif_karung == null
|
||||
? 'N/A'
|
||||
: `${scopedMetrics.pakan_kumulatif_karung.toLocaleString('id-ID')} karung`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<InsightTrendChart
|
||||
rows={scopedMetrics.weightSeries as Array<Record<string, unknown>>}
|
||||
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."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-100 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-4 gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<i className="fa-solid fa-brain text-red-600 text-xl" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-bold text-gray-900">AI Insight</h2>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{selectedKandang?.kandang_name ?? `Kandang ${effectiveKandangId}`}
|
||||
{selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(page === 'dashboard' || !page) && (
|
||||
<>
|
||||
<select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value as InsightReportType)}
|
||||
className="text-sm border border-gray-200 rounded-lg shadow-sm bg-white px-2 py-1.5"
|
||||
>
|
||||
<option value="daily">Harian</option>
|
||||
{(currentDayVal >= 35 || selectedCycle?.status === 'closed') && (
|
||||
<option value="end_cycle">Akhir Siklus</option>
|
||||
)}
|
||||
</select>
|
||||
{reportType === 'daily' && currentDayVal > 0 && (
|
||||
<select
|
||||
value={reportPeriod ?? `Hari ${currentDayVal}`}
|
||||
onChange={(e) => setDayPeriod(e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded-lg shadow-sm bg-white px-2 py-1.5"
|
||||
>
|
||||
{Array.from({ length: currentDayVal }, (_, i) => i + 1).map((d) => (
|
||||
<option key={d} value={`Hari ${d}`}>
|
||||
Hari {d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{sourceMeta && (
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold ${sourceMeta.cls}`}
|
||||
>
|
||||
{sourceMeta.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => generateInsight(true)}
|
||||
disabled={loading || effectiveKandangId == null}
|
||||
className={`p-2 rounded-lg transition-all shadow-sm border border-gray-200 ${
|
||||
loading ? 'bg-gray-100 cursor-not-allowed' : 'bg-white hover:bg-gray-50'
|
||||
}`}
|
||||
title="Refresh insight"
|
||||
>
|
||||
<i className={`fa-solid fa-rotate-right text-gray-700 ${loading ? 'fa-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{effectiveKandangId != null ? renderVisualBlock() : null}
|
||||
|
||||
<div className="min-h-[120px]">
|
||||
{effectiveKandangId == null ? (
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-lg p-4 text-sm text-amber-800">
|
||||
Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak
|
||||
diizinkan.
|
||||
</div>
|
||||
) : loading && !insightText ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-red-600" />
|
||||
<p className="text-sm text-gray-600 text-center max-w-sm">
|
||||
Menganalisis data peternakan… proses ini bisa memakan waktu beberapa menit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error && !insightText ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700 text-sm font-semibold mb-2">Error</p>
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
<button
|
||||
onClick={() => generateInsight(true)}
|
||||
className="text-xs text-red-600 underline mt-2"
|
||||
>
|
||||
Coba lagi
|
||||
</button>
|
||||
</div>
|
||||
) : structuredInsight && summary ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-circle-check text-green-600" /> Kesimpulan
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
{summary.headline}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-brain text-red-600" /> AI Insight
|
||||
</h4>
|
||||
<div className="text-sm text-gray-700 space-y-2 leading-relaxed">
|
||||
{renderBulletList([
|
||||
...summary.bullets,
|
||||
...summary.risks,
|
||||
...summary.actions,
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
<CitationsBlock citations={citations} />
|
||||
</div>
|
||||
|
||||
{page === 'dashboard' && detailed && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setDetailsOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
|
||||
>
|
||||
{detailsOpen ? 'Sembunyikan detail' : 'Lihat detail per metrik'}
|
||||
</button>
|
||||
{detailsOpen && (
|
||||
<div className="space-y-3">
|
||||
{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]
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : insightText ? (
|
||||
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
|
||||
{insightText}
|
||||
<CitationsBlock citations={citations} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<i className="fa-solid fa-brain text-gray-300 text-2xl" />
|
||||
<p className="text-sm text-gray-500">Belum ada AI Insight untuk periode ini.</p>
|
||||
<p className="text-xs text-gray-400">Klik tombol refresh untuk generate insight.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generateInsight(true)}
|
||||
className="mt-2 inline-flex items-center gap-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||
Generate Insight
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastUpdated && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500">Terakhir diperbarui: {formatLastUpdated()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiInsightCard;
|
||||
+84
-32
@@ -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<T>(rows: T[]): T[] {
|
||||
}
|
||||
|
||||
const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
|
||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [kpis, setKpis] = useState<KPI[]>([]);
|
||||
@@ -43,7 +47,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
|
||||
const [weights, setWeights] = useState<ChickenWeight[]>([]);
|
||||
const [karung, setKarung] = useState<Karung | null>(null);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportType, setReportType] = useState<DashboardReportType>('daily');
|
||||
|
||||
@@ -54,7 +57,6 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
setManualInputs([]);
|
||||
setWeights([]);
|
||||
setKarung(null);
|
||||
setInsights([]);
|
||||
return;
|
||||
}
|
||||
const cycleId = selectedCycle.id;
|
||||
@@ -63,12 +65,11 @@ const Dashboard: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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 <EmptyFarm onSettings={() => setView('kandangSettings')} />;
|
||||
|
||||
const feedTotal = karung?.feed_use_total ?? kpis[0]?.feed_total ?? 0;
|
||||
@@ -344,32 +411,17 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
</div>
|
||||
</div>
|
||||
<section className="mt-8">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
|
||||
{insights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
{selectedKandangId != null ? (
|
||||
<AiInsightCard
|
||||
kandangId={selectedKandangId}
|
||||
context={insightContext ?? undefined}
|
||||
dashboardMetrics={insightMetrics}
|
||||
page="dashboard"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{insights.slice(0, 4).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{item.section}
|
||||
</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">
|
||||
{item.alert || 'Insight'}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50 p-4 text-sm text-amber-800">
|
||||
Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak
|
||||
didukung.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -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<KPI[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
@@ -304,37 +295,53 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight EEF
|
||||
</h2>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : eefInsights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{eefInsights.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="eef"
|
||||
cacheKey={`eef-k${selectedKandang.id}-c${selectedCycle.id}-rows${eefRows.length}-eef${String(latestEef ?? 'null').replace('.', '_')}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_eef_harian',
|
||||
title: 'Tren EEF',
|
||||
series: [{ field: 'eef', label: 'EEF', color: '#dc2626' }],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_eef_harian',
|
||||
derive: {
|
||||
// Match EEF page StatCards: latest valid value, never a window mean.
|
||||
eefTerakhir_indeksTanpaSatuan: {
|
||||
field: 'eef',
|
||||
agg: 'lastValid',
|
||||
replaces: 'eef_terakhir',
|
||||
},
|
||||
fcrTerakhir_rasio: {
|
||||
field: 'fcr',
|
||||
agg: 'lastValid',
|
||||
replaces: 'fcr_terakhir',
|
||||
},
|
||||
persenHidupTerakhir_persen: {
|
||||
field: 'persen_hidup',
|
||||
agg: 'lastValid',
|
||||
replaces: 'persen_hidup',
|
||||
},
|
||||
bobotPanenTerakhir_gram: {
|
||||
field: 'bw_panen',
|
||||
agg: 'lastValid',
|
||||
replaces: 'bw_panen_gram',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight EEF
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
|
||||
@@ -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<KPI[]>([]);
|
||||
const [weightRows, setWeightRows] = useState<ChickenWeight[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
@@ -309,37 +303,53 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight FCR
|
||||
</h2>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : fcrInsights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{fcrInsights.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="fcr"
|
||||
cacheKey={`fcr-k${selectedKandang.id}-c${selectedCycle.id}-rows${fcrRows.length}-fcr${String(latestFcr ?? 'null').replace('.', '_')}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_fcr_harian',
|
||||
title: 'Tren FCR',
|
||||
series: [{ field: 'fcr_aktual', label: 'FCR aktual', color: '#16a34a' }],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_fcr_harian',
|
||||
derive: {
|
||||
// Match FCR page StatCards: latest valid value, never a window mean.
|
||||
fcrTerakhir_rasio: {
|
||||
field: 'fcr_aktual',
|
||||
agg: 'lastValid',
|
||||
replaces: 'fcr_terakhir',
|
||||
},
|
||||
pakanTerakhir_karung: {
|
||||
field: 'pakan_karung',
|
||||
agg: 'lastValid',
|
||||
replaces: 'pakan_karung_terakhir',
|
||||
},
|
||||
bobotIotTerakhir_gram: {
|
||||
field: 'bobot_iot_gram',
|
||||
agg: 'lastValid',
|
||||
replaces: 'bobot_iot_gram_terakhir',
|
||||
},
|
||||
ayamHidupTerakhir_ekor: {
|
||||
field: 'ayam_hidup_ekor',
|
||||
agg: 'lastValid',
|
||||
replaces: 'ayam_hidup_ekor',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight FCR
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
|
||||
@@ -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<Karung[]>([]);
|
||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
const cycleStatus = resolveCycleStatus(selectedCycle);
|
||||
@@ -249,40 +284,70 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* AI Insight */}
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2"></i>
|
||||
AI Insight Hitung Karung
|
||||
</h2>
|
||||
{insights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{insights.slice(0, 4).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{item.section}
|
||||
</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">
|
||||
{item.alert || 'Insight'}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="hitung_karung"
|
||||
cacheKey={`pakan-k${selectedKandang.id}-c${selectedCycle.id}-day${currentDay}-rows${sortedRows.length}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_harian',
|
||||
title: 'Karung dituang vs masuk per hari',
|
||||
unit: 'karung',
|
||||
series: [
|
||||
{ field: 'dituang', label: 'Dituang (IOT)', color: '#dc2626' },
|
||||
{ field: 'masuk', label: 'Masuk (IOT)', color: '#3b82f6' },
|
||||
{ field: 'masukManual', label: 'Masuk (Manual)', color: '#0ea5e9' },
|
||||
],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_harian',
|
||||
derive: {
|
||||
karungMasuk_karung: {
|
||||
field: 'masuk',
|
||||
agg: 'sum',
|
||||
replaces: 'total_karung_masuk_iot',
|
||||
},
|
||||
karungDituang_karung: {
|
||||
field: 'dituang',
|
||||
agg: 'sum',
|
||||
replaces: 'total_karung_dituang_iot',
|
||||
},
|
||||
karungKeluar_karung: { field: 'keluar', agg: 'sum' },
|
||||
karungMasukManual_karung: {
|
||||
field: 'masukManual',
|
||||
agg: 'sum',
|
||||
replaces: 'total_karung_masuk_manual',
|
||||
},
|
||||
karungDituangManual_karung: {
|
||||
field: 'dituangManual',
|
||||
agg: 'sum',
|
||||
replaces: 'total_karung_dituang_manual',
|
||||
},
|
||||
rataKarungDituangPerHari_karung: {
|
||||
field: 'dituang',
|
||||
agg: 'mean',
|
||||
replaces: 'rata_karung_per_hari',
|
||||
},
|
||||
},
|
||||
drop: [
|
||||
'total_karung_masuk_iot',
|
||||
'total_karung_dituang_iot',
|
||||
'total_karung_masuk_manual',
|
||||
'total_karung_dituang_manual',
|
||||
'rata_karung_per_hari',
|
||||
],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2"></i>
|
||||
AI Insight Hitung Karung
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Saldo Awal */}
|
||||
<InitialBalanceCard
|
||||
|
||||
+144
-92
@@ -9,13 +9,20 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import type { AIInsight, Flock, PanelIoT } from '../types/api.ts';
|
||||
import type { Flock, PanelIoT } from '../types/api.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
|
||||
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber } from '../utils/format.ts';
|
||||
import { cycleDayForDate, formatDateId, formatDateRange, formatNumber, resolveCycleBoundDate, todayIso } from '../utils/format.ts';
|
||||
import {
|
||||
getTempStandardByDay,
|
||||
getWaterStandardByDay,
|
||||
HUMIDITY_RANGE_PCT,
|
||||
} from '../utils/cp707EnvStandards.ts';
|
||||
import { buildIotInsightContext } from '../utils/insightContextBuilders.ts';
|
||||
import { computeExperienceCalc } from './iotPanel/chillFactor.ts';
|
||||
import IotPanelChillFactorCard from './iotPanel/IotPanelChillFactorCard.tsx';
|
||||
import IotPanelExperienceTempCard from './iotPanel/IotPanelExperienceTempCard.tsx';
|
||||
import { PageAiInsight } from './shared/PageAiInsight.tsx';
|
||||
|
||||
const cardClass = 'rounded-xl border border-slate-200 bg-white p-4 shadow-sm';
|
||||
|
||||
@@ -57,7 +64,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
const [dates, setDates] = useState<string[]>([]);
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
const [dayRows, setDayRows] = useState<PanelIoT[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 && <Banner kind="error">{error}</Banner>}
|
||||
{loading && dayRows.length === 0 ? (
|
||||
<Spinner />
|
||||
) : summary ? (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-bold text-gray-800">
|
||||
<i className="fa-solid fa-brain mr-2 text-red-600" />
|
||||
AI Insight Panel IoT
|
||||
</h2>
|
||||
{iotInsights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{iotInsights.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{item.section}
|
||||
</p>
|
||||
<h3 className="mt-1 font-semibold text-gray-800">
|
||||
{item.alert || 'Insight'}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-600">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<i className="fa-regular fa-calendar" />
|
||||
<label htmlFor="iot-date-picker" className="text-slate-500">
|
||||
Tanggal:
|
||||
</label>
|
||||
<input
|
||||
id="iot-date-picker"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
min={dates[0] ?? undefined}
|
||||
max={dates[dates.length - 1] ?? undefined}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-xs">{summary.count} pembacaan</span>
|
||||
</div>
|
||||
{selectedFlockId == null ? (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">Pilih flock untuk melihat data panel IoT.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<i className="fa-regular fa-calendar" />
|
||||
<label htmlFor="iot-date-picker" className="text-slate-500">
|
||||
Tanggal:
|
||||
</label>
|
||||
<input
|
||||
id="iot-date-picker"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
min={datePickerMin}
|
||||
max={datePickerMax}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{summary
|
||||
? `${summary.count} pembacaan`
|
||||
: dates.length === 0
|
||||
? 'Belum ada tanggal dengan data IoT'
|
||||
: 'Tidak ada pembacaan pada tanggal ini'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="iot_panel"
|
||||
cacheKey={`iot-k${selectedKandang.id}-c${selectedCycle.id}-d${selectedDate}-t${summary?.temp ?? 'na'}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'condition',
|
||||
title: 'Kondisi kandang vs standar CP 707',
|
||||
bars: [
|
||||
{
|
||||
label: 'Suhu rata-rata',
|
||||
actual: summary?.temp ?? null,
|
||||
standard: getTempStandardByDay(dayAge),
|
||||
unit: '°C',
|
||||
note:
|
||||
getTempStandardByDay(dayAge) === null
|
||||
? 'Umur tidak diketahui, standar suhu tidak dapat ditentukan.'
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: 'Kelembapan',
|
||||
actual: summary?.humidity ?? null,
|
||||
standard: HUMIDITY_RANGE_PCT.max,
|
||||
unit: '%',
|
||||
note: `Rentang ideal buku ${HUMIDITY_RANGE_PCT.min}–${HUMIDITY_RANGE_PCT.max}%; batang biru menandai batas atas.`,
|
||||
},
|
||||
{
|
||||
label: 'Experience Suhu',
|
||||
actual: summary?.experienceTemp ?? null,
|
||||
standard: getTempStandardByDay(dayAge),
|
||||
unit: '°C',
|
||||
},
|
||||
{
|
||||
label: 'Konsumsi Air',
|
||||
actual: summary?.waterTotal ?? null,
|
||||
standard: getWaterStandardByDay(dayAge)?.max ?? null,
|
||||
unit: 'L',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-bold text-gray-800">
|
||||
<i className="fa-solid fa-brain mr-2 text-red-600" />
|
||||
AI Insight Panel IoT
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedFlockId != null &&
|
||||
(loading && dayRows.length === 0 ? (
|
||||
<Spinner />
|
||||
) : summary ? (
|
||||
<>
|
||||
<section className="grid gap-3 xl:grid-cols-[1.25fr_2.35fr]">
|
||||
<div className="space-y-3">
|
||||
<article className={cardClass}>
|
||||
@@ -518,14 +568,16 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
|
||||
<IotPanelChillFactorCard dayAge={dayAge} chillFactor={experienceCalc.chillFactor} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">
|
||||
Belum ada data panel IoT untuk flock ini.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={cardClass}>
|
||||
<p className="py-8 text-center text-slate-400">
|
||||
{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'}.`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedChartKey &&
|
||||
(() => {
|
||||
|
||||
@@ -468,7 +468,7 @@ const SettingsPage: React.FC = () => {
|
||||
<section className="bg-white rounded-xl shadow-sm p-5">
|
||||
<h2 className="font-semibold text-gray-800 mb-3">Flock</h2>
|
||||
<p className="mb-3 text-xs text-gray-500">
|
||||
Flock tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus.
|
||||
Lantai tetap untuk kandang ini (dipakai Panel IoT). Tidak terikat ke siklus.
|
||||
</p>
|
||||
{isSuperAdmin ? (
|
||||
<form
|
||||
@@ -501,7 +501,7 @@ const SettingsPage: React.FC = () => {
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat flock.</p>
|
||||
<p className="mb-4 text-xs text-gray-500">Hanya admin yang dapat membuat lantai.</p>
|
||||
)}
|
||||
{!selectedKandangId ? (
|
||||
<p className="text-xs text-gray-500">Pilih kandang terlebih dahulu.</p>
|
||||
|
||||
@@ -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<ChickenWeight[]>([]);
|
||||
const [latest, setLatest] = useState<LatestAvg | null>(null);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<LatestAvg | null>(() => {
|
||||
@@ -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 <EmptyFarm onSettings={onSettings} />;
|
||||
|
||||
return (
|
||||
@@ -177,37 +179,53 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* AI Insight */}
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
|
||||
{weightInsights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{weightInsights.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
{item.section}
|
||||
</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">
|
||||
{item.alert || 'Insight'}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<PageAiInsight
|
||||
topic="berat_ayam"
|
||||
cacheKey={`wm-k${selectedKandang.id}-c${selectedCycle.id}-day${selectedCycle.current_day}-rows${chartRows.length}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_harian',
|
||||
title: 'Bobot aktual vs target',
|
||||
unit: 'gram',
|
||||
gap: { actualField: 'bobot_gram', targetField: 'target_gram' },
|
||||
series: [
|
||||
{ field: 'bobot_gram', label: 'Bobot aktual', color: '#7c3aed' },
|
||||
{ field: 'target_gram', label: 'Target', color: '#9ca3af', dashed: true },
|
||||
],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_harian',
|
||||
derive: {
|
||||
// Match Weight page StatCards: latest valid value, never a window mean.
|
||||
bobotTerakhir_gram: {
|
||||
field: 'bobot_gram',
|
||||
agg: 'lastValid',
|
||||
replaces: 'averageWeight',
|
||||
},
|
||||
targetBobot_gram: {
|
||||
field: 'target_gram',
|
||||
agg: 'lastValid',
|
||||
replaces: 'targetWeight',
|
||||
},
|
||||
pertambahanHarian_gramPerHari: {
|
||||
field: 'adg_gram_per_hari',
|
||||
agg: 'lastValid',
|
||||
replaces: 'adg',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<section>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-brain text-red-600 mr-2" />
|
||||
AI Insight Berat Ayam
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* TOP SECTION: DATA SELURUH SIKLUS */}
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -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: () => <div data-testid="page-ai-insight" />,
|
||||
}));
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<KPI[]>([]);
|
||||
const [countings, setCountings] = useState<ChickenCounting[]>([]);
|
||||
const [manualRows, setManualRows] = useState<ManualInput[]>([]);
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 }
|
||||
<CountingAccuracyWarningBanner warning={countingAccuracyWarning} />
|
||||
)}
|
||||
|
||||
{/* AI Insight */}
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-purple-500 mr-2"></i>
|
||||
AI Insight
|
||||
</h2>
|
||||
{insights.length === 0 ? (
|
||||
<Banner>Belum ada insight untuk siklus ini.</Banner>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{insights.slice(0, 4).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
|
||||
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{item.session}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
|
||||
{item.insight_text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{selectedKandang && insightContext ? (
|
||||
<div className="mb-6">
|
||||
<PageAiInsight
|
||||
topic="hitung_ayam"
|
||||
cacheKey={`hitung-k${selectedKandang.id}-c${selectedCycle.id}-day${selectedCycle.current_day}-rows${rows.length}`}
|
||||
contextData={insightContext}
|
||||
chartSpec={{
|
||||
type: 'trend',
|
||||
historyKey: 'tren_populasi_harian',
|
||||
title: 'Tren populasi',
|
||||
unit: 'ekor',
|
||||
series: [{ field: 'populasi', label: 'Populasi', color: '#7c3aed' }],
|
||||
}}
|
||||
scalarScope={{
|
||||
historyKey: 'tren_populasi_harian',
|
||||
derive: {
|
||||
populasiKini_ekor: {
|
||||
field: 'populasi',
|
||||
agg: 'lastValid',
|
||||
replaces: 'populasi_kini_ekor',
|
||||
},
|
||||
},
|
||||
drop: ['populasi_kini_ekor'],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-purple-500 mr-2"></i>
|
||||
AI Insight
|
||||
</h2>
|
||||
<Banner>Pilih kandang untuk melihat dan generate AI Insight.</Banner>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Population Chart */}
|
||||
<div className="mb-6">
|
||||
@@ -413,7 +447,9 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-1">Tingkat Mortalitas</p>
|
||||
<p className="text-xl font-bold text-orange-600">{stats.mortalityRate}%</p>
|
||||
<p className="text-xl font-bold text-orange-600">
|
||||
{stats.mortalityRate == null ? 'N/A' : `${stats.mortalityRate.toFixed(2)}%`}
|
||||
</p>
|
||||
</div>
|
||||
<i className="fa-solid fa-chart-line text-orange-500 text-2xl"></i>
|
||||
</div>
|
||||
|
||||
@@ -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: () => <div data-testid="page-ai-insight" />,
|
||||
}));
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<InsightConditionChartProps> = ({
|
||||
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 (
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
{title && <h4 className="mb-1 text-sm font-bold text-gray-800">{title}</h4>}
|
||||
<div className="mb-3 flex items-center gap-4 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: AKTUAL }} />
|
||||
Aktual
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: STANDAR }} />
|
||||
Standar CP 707
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div key={bar.label}>
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-xs font-medium text-gray-600">{bar.label}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{bar.actual === null ? 'data tidak tersedia' : fmt(bar.actual, bar.unit)}
|
||||
{bar.standard !== null && (
|
||||
<span className="text-gray-400"> · standar {fmt(bar.standard, bar.unit)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="h-2.5 w-full rounded-sm bg-gray-100">
|
||||
{bar.actual !== null && (
|
||||
<div
|
||||
className="h-full rounded-sm"
|
||||
style={{ width: `${pct(bar.actual)}%`, background: AKTUAL }}
|
||||
title={`Aktual ${fmt(bar.actual, bar.unit)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2.5 w-full rounded-sm bg-gray-100">
|
||||
{bar.standard !== null && (
|
||||
<div
|
||||
className="h-full rounded-sm"
|
||||
style={{ width: `${pct(bar.standard)}%`, background: STANDAR }}
|
||||
title={`Standar ${fmt(bar.standard, bar.unit)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bar.note && <p className="mt-1 text-[11px] text-gray-400">{bar.note}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightConditionChart;
|
||||
@@ -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<InsightStatus, { cls: string; glyph: string }> = {
|
||||
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<DeltaChipProps> = ({ status, label }) => {
|
||||
const chip = STATUS_CHIP[status];
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${chip.cls}`}
|
||||
>
|
||||
<span aria-hidden="true">{chip.glyph}</span>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
width={width}
|
||||
height={height}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
interface HeroFigureProps {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
context?: string | null;
|
||||
status?: InsightStatus;
|
||||
deltaLabel?: string | null;
|
||||
sparkline?: number[];
|
||||
}
|
||||
|
||||
export const HeroFigure: React.FC<HeroFigureProps> = ({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
context,
|
||||
status = 'unknown',
|
||||
deltaLabel,
|
||||
sparkline,
|
||||
}) => (
|
||||
<div className="flex items-end justify-between gap-4 rounded-xl border border-gray-100 bg-white px-5 py-4 shadow-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{label}</div>
|
||||
<div className="mt-1 flex items-baseline gap-1.5 pb-0.5">
|
||||
<span className="text-5xl font-bold leading-[1.15] tracking-tight text-gray-900">
|
||||
{value}
|
||||
</span>
|
||||
{unit ? <span className="text-sm font-medium text-gray-500">{unit}</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{deltaLabel ? <DeltaChip status={status} label={deltaLabel} /> : null}
|
||||
{context ? <span className="text-xs text-gray-500">{context}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{sparkline && sparkline.length > 1 ? (
|
||||
<Sparkline values={sparkline} className="shrink-0 text-red-400" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface StatTileProps {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
hint?: string | null;
|
||||
status?: InsightStatus;
|
||||
deltaLabel?: string | null;
|
||||
sparkline?: number[];
|
||||
}
|
||||
|
||||
export const StatTile: React.FC<StatTileProps> = ({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
hint,
|
||||
status = 'unknown',
|
||||
deltaLabel,
|
||||
sparkline,
|
||||
}) => (
|
||||
<div className="rounded-xl border border-gray-100 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="text-xs font-medium text-gray-500">
|
||||
{label}
|
||||
{hint ? <span className="ml-1 text-[11px] text-gray-400">{hint}</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold leading-[1.3] text-gray-900">{value}</span>
|
||||
{unit ? <span className="text-xs font-medium text-gray-500">{unit}</span> : null}
|
||||
</div>
|
||||
{deltaLabel ? (
|
||||
<div className="mt-1.5">
|
||||
<DeltaChip status={status} label={deltaLabel} />
|
||||
</div>
|
||||
) : null}
|
||||
{sparkline && sparkline.length > 1 ? (
|
||||
<Sparkline values={sparkline} className="mt-2 text-gray-300" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Secondary figures: subordinate to hero / tiles. */
|
||||
export const MutedStatRow: React.FC<{ label: string; hint?: string | null; value: string }> = ({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between border-b border-gray-50 py-1.5">
|
||||
<span className="text-sm text-gray-600">
|
||||
{label}
|
||||
{hint ? <span className="ml-1 text-xs text-gray-400">{hint}</span> : null}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{value}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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<string, unknown>): 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<InsightTrendChartProps> = ({
|
||||
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<string, number | null> = { 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<string, number | null> & { 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 (
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
{title && <h4 className="mb-1 text-sm font-bold text-gray-800">{title}</h4>}
|
||||
{band && (
|
||||
<p className="mb-3 text-xs text-gray-500">
|
||||
Area berbayang = periode yang dibahas insight
|
||||
{band.firstDay === band.lastDay
|
||||
? ` (hari ke-${band.lastDay})`
|
||||
: ` (hari ke-${band.firstDay} s/d ke-${band.lastDay})`}
|
||||
</p>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={230}>
|
||||
<ComposedChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke="#e5e7eb" vertical={false} />
|
||||
{band && (
|
||||
<ReferenceArea
|
||||
x1={bandFrom}
|
||||
x2={bandTo}
|
||||
fill="#6b7280"
|
||||
fillOpacity={0.12}
|
||||
stroke="#6b7280"
|
||||
strokeOpacity={0.25}
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="hari"
|
||||
type="number"
|
||||
domain={[(days[0] ?? 0) - 0.5, (days[days.length - 1] ?? 0) + 0.5]}
|
||||
ticks={ticks}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: '#6b7280' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
tickFormatter={(value) => `H${value}`}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: '#6b7280' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={52}
|
||||
tickFormatter={(value) => Number(value).toLocaleString('id-ID')}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
fontSize: 12,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d1d5db',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
}}
|
||||
labelFormatter={(value) => `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 && <Legend wrapperStyle={{ fontSize: 12 }} iconType="plainline" />}
|
||||
{gap && gapFill && (
|
||||
<>
|
||||
<Area
|
||||
dataKey="bandBase"
|
||||
stackId="gap"
|
||||
stroke="none"
|
||||
fill="none"
|
||||
legendType="none"
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
<Area
|
||||
dataKey="bandSize"
|
||||
stackId="gap"
|
||||
stroke="none"
|
||||
fill={gapFill}
|
||||
fillOpacity={1}
|
||||
legendType="none"
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{drawn.map((s) => (
|
||||
<Line
|
||||
key={s.field}
|
||||
type="monotone"
|
||||
dataKey={s.field}
|
||||
name={s.label}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={s.dashed ? '5 4' : undefined}
|
||||
dot={!s.dashed && data.length <= 20 ? { r: 3, strokeWidth: 0, fill: s.color } : false}
|
||||
activeDot={{ r: 5, strokeWidth: 2, stroke: '#ffffff' }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightTrendChart;
|
||||
@@ -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<string, CachedPageInsight>();
|
||||
const _insightInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
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<string, unknown>;
|
||||
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<InsightTopic, { title: string; icon: string }> = {
|
||||
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<InsightSource, { text: string; cls: string }> = {
|
||||
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<string, unknown>): 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 (
|
||||
<div className="mt-4 rounded-lg border border-gray-100 bg-gray-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">
|
||||
Sumber referensi
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{citations.map((c, i) => (
|
||||
<li key={c.id ?? `${c.title ?? 'cite'}-${i}`} className="text-xs text-gray-600">
|
||||
<span className="font-medium text-gray-800">
|
||||
{c.chapter || c.title || c.source || `Referensi ${i + 1}`}
|
||||
</span>
|
||||
{c.excerpt ? <span className="text-gray-500"> — {c.excerpt}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
topic,
|
||||
contextData,
|
||||
cacheKey,
|
||||
scalarScope,
|
||||
chartSpec,
|
||||
}) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||
|
||||
const [result, setResult] = useState<CachedPageInsight | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const supportsTimeframe = Boolean(scalarScope);
|
||||
const timeframe: 'harian' | 'mingguan' = 'harian';
|
||||
|
||||
const selectedDayKey = `insight-day-${topic}`;
|
||||
const [selectedDay, setSelectedDayState] = useState<number | null>(() => {
|
||||
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<string, unknown>)[key];
|
||||
if (!Array.isArray(rows)) return [];
|
||||
const days = new Set<number>();
|
||||
for (const row of rows) {
|
||||
if (typeof row !== 'object' || row === null) continue;
|
||||
const r = row as Record<string, unknown>;
|
||||
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<string | null>(null);
|
||||
const desiredKeyRef = useRef<string>('');
|
||||
const requestIdRef = useRef(0);
|
||||
const generateRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
|
||||
|
||||
const chartHighlight = React.useMemo(() => {
|
||||
if (!chartSpec || chartSpec.type !== 'trend') return null;
|
||||
const rows = (contextData as Record<string, unknown>)[chartSpec.historyKey];
|
||||
if (!Array.isArray(rows)) return null;
|
||||
const days = rows
|
||||
.map((row) => {
|
||||
const r = row as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
if (!scalarScope) return raw;
|
||||
const sliced = scopeContextToTimeframe(raw, timeframe, effectiveDay);
|
||||
const scoped = scopeScalarsToTimeframe(
|
||||
sliced as Record<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
);
|
||||
const out: Record<string, unknown> = { ...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<string, unknown>)[chartSpec.historyKey];
|
||||
if (!Array.isArray(rows)) return [];
|
||||
if (timeframe !== 'harian' || effectiveDay === null) {
|
||||
return rows as Array<Record<string, unknown>>;
|
||||
}
|
||||
return (rows as Array<Record<string, unknown>>).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<string, unknown>).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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
)
|
||||
: slicedContextData;
|
||||
|
||||
const dataPeriode = (scopedContextData as Record<string, unknown>)?.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<ReturnType<typeof api.insights.generate>> | 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<never>((_, 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 (
|
||||
<div className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden mb-6">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100 bg-gray-50/80">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="p-1.5 bg-red-50 rounded-lg">
|
||||
<i className={`fa-solid ${meta.icon} text-red-600 text-sm`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-bold text-gray-900">{meta.title}</span>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{selectedKandang?.kandang_name ?? 'Kandang'}
|
||||
{selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}
|
||||
{effectiveDay != null ? ` · Hari ke-${effectiveDay}` : currentDay != null ? ` · Hari ke-${currentDay}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{supportsTimeframe && timeframe === 'harian' && availableDays.length > 1 && (
|
||||
<select
|
||||
value={effectiveDay ?? availableDays[availableDays.length - 1]}
|
||||
onChange={(e) => setSelectedDay(parseInt(e.target.value, 10))}
|
||||
className="text-[11px] font-semibold border border-gray-200 bg-white text-gray-700 rounded-lg px-2 py-1 shadow-sm"
|
||||
title="Pilih hari siklus"
|
||||
>
|
||||
{availableDays.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
Hari {d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{sourceMeta && (
|
||||
<span
|
||||
className={`hidden sm:inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold ${sourceMeta.cls}`}
|
||||
>
|
||||
{sourceMeta.text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => generate(true)}
|
||||
disabled={loading || selectedKandangId == null}
|
||||
title="Refresh insight"
|
||||
className="p-1.5 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i
|
||||
className={`fa-solid fa-rotate-right text-gray-600 text-sm ${loading ? 'fa-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{selectedKandangId == null && (
|
||||
<div className="flex items-start gap-2 bg-amber-50 border border-amber-100 rounded-xl p-3">
|
||||
<i className="fa-solid fa-triangle-exclamation text-amber-500 mt-0.5" />
|
||||
<p className="text-xs text-amber-800">
|
||||
Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak
|
||||
diizinkan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !result && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-4 border-gray-100 border-t-red-600 animate-spin" />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm">
|
||||
Menganalisis data dengan model AI… proses ini bisa memakan waktu beberapa menit.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !result && (
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-100 rounded-xl p-3">
|
||||
<i className="fa-solid fa-triangle-exclamation text-red-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-red-600">Gagal memuat insight</p>
|
||||
<p className="text-xs text-red-500 mt-0.5">{error}</p>
|
||||
<button
|
||||
onClick={() => generate(true)}
|
||||
className="text-xs text-red-600 underline mt-1 hover:text-red-800"
|
||||
>
|
||||
Coba lagi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !result && selectedKandangId != null && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
|
||||
<i className="fa-solid fa-brain text-gray-300 text-2xl" />
|
||||
<p className="text-sm text-gray-500">Belum ada AI Insight untuk periode ini.</p>
|
||||
<p className="text-xs text-gray-400">Klik tombol refresh untuk generate insight.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generate(true)}
|
||||
className="mt-2 inline-flex items-center gap-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-700"
|
||||
>
|
||||
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||
Generate Insight
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{heroSpec && (
|
||||
<HeroFigure
|
||||
label={heroSpec.label}
|
||||
value={heroSpec.value}
|
||||
unit={heroSpec.unit}
|
||||
context={heroSpec.context}
|
||||
status={heroSpec.status}
|
||||
deltaLabel={heroSpec.deltaLabel}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
||||
{periodeLabel && (
|
||||
<p className="mb-3 text-xs font-medium text-gray-500">
|
||||
Angka di bawah dihitung dari periode{' '}
|
||||
<span className="text-gray-700">{periodeLabel}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-3 gap-x-6">
|
||||
{topic === 'fcr' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
Konsumsi Pakan Kumulatif
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.pakan_karung_terakhir, 'Karung')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Rata-rata Bobot</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.bobot_iot_gram_terakhir, 'g')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Populasi Hidup</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.ayam_hidup_ekor, 'Ekor')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Total Bobot Panen</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.beratPanen_kg, 'kg')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'berat_ayam' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Target Bobot</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.targetWeight, 'g')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Uniformity</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.uniformity, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
Average Daily Gain (ADG)
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.adg, 'g/hari')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'eef' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">EEF Aktual</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{typeof displayContext.eef_terakhir === 'number'
|
||||
? formatRatio(displayContext.eef_terakhir, 3)
|
||||
: renderValue(displayContext.eef_terakhir)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">FCR Aktual</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{typeof displayContext.fcr_terakhir === 'number'
|
||||
? formatRatio(displayContext.fcr_terakhir, 3)
|
||||
: renderValue(displayContext.fcr_terakhir)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Persentase Hidup</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.persen_hidup, '%')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'iot_panel' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Kelembapan</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.kelembapan_persen, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Kecepatan Angin</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.kecepatan_angin_mPerDetik, 'm/s')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topic === 'hitung_karung' && (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Saldo Awal Pakan</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderValue(displayContext.saldo_awal_karung, 'Karung')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chartSpec && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||
{chartSpec.type === 'trend' ? (
|
||||
<InsightTrendChart
|
||||
rows={trendChartRows}
|
||||
series={chartSpec.series}
|
||||
unit={chartSpec.unit}
|
||||
title={chartSpec.title}
|
||||
highlight={chartHighlight}
|
||||
gap={chartSpec.gap ?? null}
|
||||
/>
|
||||
) : (
|
||||
<InsightConditionChart bars={chartSpec.bars} title={chartSpec.title} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-circle-check text-green-600" /> Kesimpulan
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
{result.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-brain text-red-600" /> AI Insight
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100 whitespace-pre-wrap">
|
||||
{result.insight}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CitationsBlock citations={result.citations} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageAiInsight;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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())
|
||||
@@ -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 - <<PY
|
||||
import json
|
||||
ctx=json.load(open(r'''${fixture}''', encoding='utf-8'))
|
||||
ctx['kandangId']=int('${kandang}')
|
||||
ctx['cycleId']=int('${cycle}')
|
||||
print(json.dumps(ctx, ensure_ascii=False))
|
||||
PY
|
||||
)"
|
||||
curl -fsS -X POST "${API_BASE}/ai-insights/generate/" \
|
||||
"${AUTH_HEADER[@]}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"cycle_id\":${cycle},\"kandang_id\":${kandang},\"topic\":\"${TOPIC}\",\"report_type\":\"page\",\"report_period\":\"smoke\",\"force_refresh\":${force},\"context\":${ctx}}"
|
||||
}
|
||||
|
||||
echo "== Generate Kandang A =="
|
||||
OUT_A="$(gen "$CYCLE_A" "$KANDANG_A" "$FIXTURE_A" true)"
|
||||
echo "$OUT_A" | head -c 500; echo
|
||||
|
||||
echo "== Generate Kandang B =="
|
||||
OUT_B="$(gen "$CYCLE_B" "$KANDANG_B" "$FIXTURE_B" true)"
|
||||
echo "$OUT_B" | head -c 500; echo
|
||||
|
||||
echo "== Cache hit Kandang A (force_refresh=false) =="
|
||||
OUT_A2="$(gen "$CYCLE_A" "$KANDANG_A" "$FIXTURE_A" false)"
|
||||
echo "$OUT_A2" | head -c 300; echo
|
||||
|
||||
python - <<PY
|
||||
import json
|
||||
a=json.loads('''${OUT_A}''')
|
||||
b=json.loads('''${OUT_B}''')
|
||||
a2=json.loads('''${OUT_A2}''')
|
||||
assert a.get('source') in ('generated','local_fallback','cache')
|
||||
assert b.get('source') in ('generated','local_fallback','cache')
|
||||
assert a2.get('source') == 'cache' or a2.get('insight_text') == a.get('insight_text')
|
||||
assert a.get('insight_text') != b.get('insight_text') or a.get('summary') != b.get('summary'), 'A/B text must differ (anti bleed)'
|
||||
print('SMOKE OK: A→B→A cache / no cross-kandang bleed')
|
||||
PY
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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.
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
$Src = Join-Path $Root "AI Insight\chroma_db"
|
||||
$Cid = if ($env:RAG_CONTAINER) { $env:RAG_CONTAINER } else { "dashboard-cpsp-rag" }
|
||||
|
||||
if (-not (Test-Path $Src)) {
|
||||
Write-Error "Missing $Src — run: cd 'AI Insight'; python populate_db.py"
|
||||
}
|
||||
docker inspect $Cid | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Container $Cid not found. Start with: docker compose up -d rag"
|
||||
}
|
||||
|
||||
Write-Host "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
|
||||
Write-Host "Done. Check: curl -fsS http://127.0.0.1:5002/health"
|
||||
@@ -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"
|
||||
@@ -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<Paginated<AIInsight> | 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<InsightApiResponse>('/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<InsightApiResponse>('/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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
+99
-4
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<typeof row> => 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<typeof row> => 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<typeof row> => 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<typeof row> => 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<typeof row> => 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<number, DashboardInsightKpiPoint>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> =>
|
||||
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<string, unknown>, 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(/<think>[\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<string>();
|
||||
|
||||
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.',
|
||||
};
|
||||
};
|
||||
@@ -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<string, unknown> & {
|
||||
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<string, unknown> =>
|
||||
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 = <T>(
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>,
|
||||
timeframe: InsightTimeframe,
|
||||
spec: ScalarScopeSpec,
|
||||
selectedDay: number | null = null
|
||||
): Record<string, unknown> => {
|
||||
const history = scopedContext[spec.historyKey];
|
||||
if (!Array.isArray(history)) return scopedContext;
|
||||
const rows = history as DayRow[];
|
||||
|
||||
const out: Record<string, unknown> = { ...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';
|
||||
};
|
||||
Reference in new issue
Block a user