update ai insight migration for all page

This commit is contained in:
Alberto-Audrix committed 2026-09-17 10:11:14 +07:00
1 parent 07d36081ce
commit 18790ab8f6
69 files changed
+9457 -399

No files matched your search

+2 -1
View File
@@ -9,4 +9,5 @@ backup
__pycache__
docker-compose.override.yml
docs/
.superpowers/
.superpowers/
AI Insight/chroma_db/
+24
View File
@@ -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"]
+34
View File
@@ -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 ""
+120
View File
@@ -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
+180
View File
@@ -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()
+165
View File
@@ -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()
+198
View File
@@ -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")
+8
View File
@@ -0,0 +1,8 @@
chromadb
sentence-transformers
python-docx
pypdf
python-dotenv
openai
fastapi
uvicorn
+6
View File
@@ -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
+5 -3
View File
@@ -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="[]"),
),
]
+31 -6
View File
@@ -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",
)
]
+7
View File
@@ -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")
+82 -2
View File
@@ -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]
+6
View File
@@ -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

+31 -17
View File
@@ -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'
]
+833
View File
@@ -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
View File
@@ -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>
+61 -54
View File
@@ -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 />
+61 -51
View File
@@ -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 />
+105 -40
View File
@@ -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
View File
@@ -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 &&
(() => {
+2 -2
View File
@@ -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>
+65 -47
View File
@@ -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);
+76 -40
View File
@@ -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,
+105
View File
@@ -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;
+169
View File
@@ -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>
);
+230
View File
@@ -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;
+942
View File
@@ -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;
+44
View File
@@ -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
+6
View File
@@ -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."
}
+24
View File
@@ -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 }
]
}
+24
View File
@@ -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 }
]
}
+20
View File
@@ -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 }
]
}
+20
View File
@@ -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 }
]
}
+11
View File
@@ -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
}
+11
View File
@@ -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
}
+12
View File
@@ -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
}
+1
View File
@@ -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": {
+179
View File
@@ -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())
+74
View File
@@ -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
+20
View File
@@ -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"
+23
View File
@@ -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"
+27
View File
@@ -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
View File
@@ -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);
});
});
+94
View File
@@ -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');
});
});
+87
View File
@@ -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;
};
+721
View File
@@ -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,
};
}
+201
View File
@@ -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';
}
}
+156
View File
@@ -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.',
};
};
+275
View File
@@ -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';
};