ai insight rework #2

This commit is contained in:
Alberto-Audrix committed 2026-10-01 13:23:45 +07:00
1 parent 9d76f54d33
commit e1987d6999
39 files changed
+2311 -830

No files matched your search

+27 -27
View File
@@ -24,12 +24,12 @@ Deploy **dashboard-cpsp** with Docker Compose. Layout mirrors the legacy `dashbo
* 15432 on host → 5432 in container (SSH tunnel / DBeaver)
```
| Container | Image / build | Host port | Role |
| ---------- | ------------------ | ---------------- | ----------------------------- |
| frontend | `docker/Dockerfile.web` | 80 → 80 | React SPA + Nginx API proxy |
| api | `docker/Dockerfile.api` | 18000 → 8000 | Gunicorn, migrations, static |
| cron | same as api | — | Scheduled management commands |
| database | postgres:16-alpine | 15432 → 5432 | PostgreSQL |
| Container | Image / build | Host port | Role |
| --------- | ----------------------- | ------------ | ----------------------------- |
| frontend | `docker/Dockerfile.web` | 80 → 80 | React SPA + Nginx API proxy |
| api | `docker/Dockerfile.api` | 18000 → 8000 | Gunicorn, migrations, static |
| cron | same as api | — | Scheduled management commands |
| database | postgres:16-alpine | 15432 → 5432 | PostgreSQL |
## Prerequisites
@@ -82,16 +82,16 @@ Open **http://localhost** in a browser.
## Configuration files (`docker/`)
| File | Purpose |
| ---- | ------- |
| `Dockerfile.web` | Frontend image (Vite + Nginx) |
| `Dockerfile.api` | Backend image (Django + Gunicorn) |
| `nginx.conf` | Nginx proxy config for frontend |
| `entrypoint-api.sh` | API container startup |
| `entrypoint-cron.sh` | Cron container startup |
| `crontab` | Fallback schedule; runtime regenerated from `DASHBOARD_PUBLISH_*` |
| `.env.example` | Compose env template → copy to project root `.env` |
| `compose.override.local.example` | Optional local Postgres port override |
| File | Purpose |
| -------------------------------- | ----------------------------------------------------------------- |
| `Dockerfile.web` | Frontend image (Vite + Nginx) |
| `Dockerfile.api` | Backend image (Django + Gunicorn) |
| `nginx.conf` | Nginx proxy config for frontend |
| `entrypoint-api.sh` | API container startup |
| `entrypoint-cron.sh` | Cron container startup |
| `crontab` | Fallback schedule; runtime regenerated from `DASHBOARD_PUBLISH_*` |
| `.env.example` | Compose env template → copy to project root `.env` |
| `compose.override.local.example` | Optional local Postgres port override |
## Configuration
@@ -99,18 +99,18 @@ Compose reads variables from a root `.env` file. Template: `docker/.env.example`
Important production values:
| Variable | Purpose |
| -------- | ------- |
| `SECRET_KEY` | Django secret — use a long random string |
| `DB_PASSWORD` | PostgreSQL password |
| `CSRF_TRUSTED_ORIGINS` | Must include your public UI origin (e.g. `https://dashboard.example.com`) |
| `CORS_ALLOWED_ORIGINS` | Same as above if the SPA is on a different origin |
| Variable | Purpose |
| --------------------------- | ---------------------------------------------------------------------------------------------------- |
| `SECRET_KEY` | Django secret — use a long random string |
| `DB_PASSWORD` | PostgreSQL password |
| `CSRF_TRUSTED_ORIGINS` | Must include your public UI origin (e.g. `https://dashboard.example.com`) |
| `CORS_ALLOWED_ORIGINS` | Same as above if the SPA is on a different origin |
| `KARUNG_WEB_ADMIN_BASE_URL` | External karung service; default `http://host.docker.internal:5000` reaches the host from containers |
| `BOOTSTRAP_ADMIN_USER` | Superadmin username for `bootstrap_admin` (default `admin`) |
| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) |
| `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation |
| `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) |
| `SITE_API_KEY` | Optional fixed API key for scripts (hashed at rest) |
| `BOOTSTRAP_ADMIN_USER` | Superadmin username for `bootstrap_admin` (default `admin`) |
| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) |
| `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation |
| `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) |
| `SITE_API_KEY` | Optional fixed API key for scripts (hashed at rest) |
Wagtail admin is proxied at **http://localhost/admin/** (through Nginx → Django).
+2 -1
View File
@@ -70,7 +70,8 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434
LLM_MODEL_NAME=qwen2.5:3b
LLM_TIMEOUT_SECONDS=1200
# Plan §6 CPU inference tuning (Profile A 8GB: qwen2.5:1.5b; Profile B 16GB: qwen2.5:3b)
LLM_NUM_CTX=2048
LLM_NUM_CTX=8192
LLM_NUM_PREDICT=768
LLM_NUM_THREAD=4
LLM_SEED=42
LLM_TEMPERATURE=0.2
@@ -14,6 +14,40 @@ from __future__ import annotations
from typing import Any
# ─── Topic-scoped metric registry ────────────────────────────────────────────
# Each topic grades ONLY its core metrics. Unknown is emitted only for the
# topic's core metric(s) so the LLM cannot confuse standards with actuals.
TOPIC_METRICS: dict[str, set[str]] = {
"hitung_ayam": {"mortality"}, # cumulative mortality + daily mortality % of DOC
"berat_ayam": {"bw", "adg"}, # body weight + ADG vs standard
"fcr": {"fcr"}, # FCR vs standard only
"eef": {"eef", "fcr"}, # EEF/IP + FCR (driver)
"hitung_karung": {"feed_balance"}, # sack balance (in - poured - remaining)
"iot_panel": {"environment"}, # temperature vs TET, humidity
"dashboard": {"bw", "fcr", "mortality", "environment", "eef", "adg"},
"end_cycle": {"bw", "fcr", "mortality", "environment", "eef", "adg"},
}
# EEF absolute scale bands (manager operational rules, NOT from book)
# Used when book has no IP standard for the day (outside days 7-37).
# >500 = UNREALISTIC/INVALID per user rule 2026-09-29
EEF_ABSOLUTE_BANDS: list[tuple[int, int, str, str]] = [
(501, 9999, "invalid", "EEF >500 tidak realistis — periksa kualitas data FCR/mortalitas/pakan"),
(400, 500, "ok", "EEF {val} SANGAT BAIK (skala manajer, di luar buku CP 707)"),
(350, 399, "ok", "EEF {val} BAIK (skala manajer, di luar buku CP 707)"),
(300, 349, "ok", "EEF {val} STANDAR (skala manajer, di luar buku CP 707)"),
(200, 299, "warning", "EEF {val} DI BAWAH RATA-RATA (skala manajer, di luar buku CP 707)"),
(0, 199, "critical", "EEF {val} SANGAT RENDAH (skala manajer, di luar buku CP 707)"),
]
# Daily mortality % of DOC thresholds (manager operational rules, NOT from book)
# Book only has cumulative mortality standard. Daily uses its own thresholds:
# >0.05% = warning, >=0.1% = critical. Divisor = DOC intake.
DAILY_MORTALITY_DOC_THRESHOLDS = {
"warning_pct": 0.05,
"critical_pct": 0.10,
}
# ─── 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},
@@ -466,12 +500,13 @@ def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]:
def analyze_environment(
temp_c: Any,
humidity_pct: Any,
ammonia_ppm: Any,
day_age: Any,
) -> dict[str, Any]:
"""Temperature vs TET + humidity grading. Ammonia removed per 2026-09-28 decision:
no ammonia data exists in IoT context; old code forced 0.0 making it look fake-safe.
Advisory note added when humidity/temp indicate ventilation issues."""
temp = _as_float(temp_c)
humidity = _as_float(humidity_pct)
ammonia = _as_float(ammonia_ppm)
if temp is None:
return {
@@ -485,8 +520,6 @@ def analyze_environment(
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)
@@ -529,18 +562,13 @@ def analyze_environment(
if overall_status != "critical":
overall_status = "warning"
if ammonia > AIR_QUALITY_STANDARD["ammonia"]["critical"]:
# Advisory: if humidity/temp suggest ventilation problems, advise checking ammonia
# (not a graded status; no ammonia data in context)
if overall_status != "ok" and humidity > 75:
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."
"Kondisi kelembapan tinggi + suhu off-target → periksa kadar amonia di kandang "
"(batas aman CP 707 <10 ppm)."
)
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,
@@ -556,6 +584,386 @@ def analyze_environment(
}
def analyze_adg(actual_adg: Any, day_age: Any) -> dict[str, Any]:
"""ADG (Average Daily Gain) vs daily standard from book."""
actual = _as_float(actual_adg)
if actual is not None and actual <= 0:
actual = None
std_row = _daily_row(day_age)
std_adg = float(std_row["adg_g"]) if std_row else None
if actual is None or std_adg is None:
return {
"actual": actual,
"standard": std_adg,
"delta_pct": None,
"direction": "unknown",
"status": "unknown",
"message": (
"Umur di luar standar tabel CP 707."
if std_adg is None
else "ADG aktual tidak tersedia."
),
}
delta_pct = round(((actual - std_adg) / std_adg) * 100, 1)
direction = _direction_from_delta(delta_pct)
if delta_pct < -15:
status = "critical"
message = (
f"ADG {actual:.1f}g/hari jauh di bawah standar CP 707 ({std_adg}g/hari) "
f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Periksa konsumsi pakan dan kesehatan ayam."
)
elif delta_pct < -5:
status = "warning"
message = (
f"ADG {actual:.1f}g/hari sedikit di bawah standar CP 707 ({std_adg}g/hari) "
f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Tingkatkan stimulasi pakan."
)
elif delta_pct > 10:
status = "ok"
message = (
f"ADG {actual:.1f}g/hari melampaui standar CP 707 ({std_adg}g/hari) "
f"pada hari ke-{day_age}. Pertumbuhan sangat baik."
)
else:
status = "ok"
message = f"ADG {actual:.1f}g/hari sesuai standar CP 707 ({std_adg}g/hari) pada hari ke-{day_age}."
return {
"actual": actual,
"standard": std_adg,
"delta_pct": float(delta_pct),
"direction": direction,
"status": status,
"message": message,
}
def _grade_eef_absolute_scale(eef_value: float) -> tuple[str, str]:
"""Grade EEF against manager absolute scale bands. Returns (status, message)."""
for lo, hi, status, msg_template in EEF_ABSOLUTE_BANDS:
if lo <= eef_value <= hi:
if status == "invalid":
return status, msg_template
return status, msg_template.format(val=round(eef_value))
# Should not reach here; fallback
return "unknown", f"EEF {round(eef_value)} tidak dapat digrading"
def _compute_eef_trend(eef_series: list[dict[str, Any]] | None) -> dict[str, Any] | None:
"""
Compute EEF trend from daily series.
Returns dict with: trend ("up" | "down" | "stable"), delta_3d (points),
escalation ("sharp_drop_eef" | None).
"""
if not eef_series or not isinstance(eef_series, list):
return None
# Filter valid EEF points, sort by day
valid = []
for p in eef_series:
if not isinstance(p, dict):
continue
day = _as_float(p.get("hari") or p.get("day") or p.get("age"))
eef = _as_float(p.get("eef") or p.get("ip") or p.get("indeks"))
if day is not None and eef is not None and eef > 0:
valid.append({"day": int(day), "eef": float(eef)})
if len(valid) < 3:
return None
valid.sort(key=lambda x: x["day"])
# Use last 3 days for trend
last3 = valid[-3:]
if len(last3) < 3:
return None
delta_3d = round(last3[-1]["eef"] - last3[0]["eef"], 1)
if delta_3d >= 10:
trend = "up"
elif delta_3d <= -10:
trend = "down"
else:
trend = "stable"
escalation = None
# Sharp drop: >20 points decline in 3 days
if delta_3d <= -20:
escalation = "sharp_drop_eef"
return {
"trend": trend,
"delta_3d": delta_3d,
"escalation": escalation,
"series_used": last3,
}
def _compute_eef_from_components(
fcr: float | None, mortality_cum_pct: float | None, day_age: int | None, bw_kg: float | None
) -> float | None:
"""
Compute EEF = (BW_kg * viability% * 100) / (FCR * age_days)
viability% = 100 - cumulative_mortality%
Returns None if any required component missing.
"""
if fcr is None or mortality_cum_pct is None or day_age is None or bw_kg is None:
return None
viability = 100.0 - float(mortality_cum_pct)
if viability <= 0:
return None
try:
eef = (bw_kg * viability * 100.0) / (fcr * day_age)
return round(eef, 1)
except ZeroDivisionError:
return None
def analyze_eef(
eef_terakhir: Any,
day_age: Any,
fcr: Any = None,
mortality_cum_pct: Any = None,
bw_kg: Any = None,
eef_seri: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""
EEF (IP) grading with dual ladder:
1. Book standard exists (days 7-37): delta vs book IP = primary status; trend = context
2. No book standard: manager absolute scale (<200/200-300/300-350/350-400/400-500, >500=invalid)
3. Trend always computed from eef_seri; sharp drop escalates ladder verdict one level.
"""
eef_val = _as_float(eef_terakhir)
if eef_val is not None and eef_val <= 0:
eef_val = None
# Try to compute EEF from components if not provided
if eef_val is None:
fcr_f = _as_float(fcr)
mort_f = _as_float(mortality_cum_pct)
bw_f = _as_float(bw_kg)
day = _as_float(day_age)
if fcr_f and mort_f and bw_f and day:
eef_val = _compute_eef_from_components(fcr_f, mort_f, int(day), bw_f)
# Get book IP standard for this day
ip_std = get_ip_standard_by_day(day_age) if day_age else None
trend_info = _compute_eef_trend(eef_seri)
# Start with book standard if available
if eef_val is not None and ip_std is not None:
delta_pct = round(((eef_val - ip_std) / ip_std) * 100, 1)
direction = _direction_from_delta(delta_pct)
if delta_pct > 10:
status = "ok"
message = f"EEF/IP {eef_val:.0f} melampaui standar buku ({ip_std}) sebesar {delta_pct:.1f}% pada hari ke-{day_age}."
elif delta_pct < -15:
status = "critical"
message = f"EEF/IP {eef_val:.0f} jauh di bawah standar buku ({ip_std}) sebesar {abs(delta_pct):.1f}% pada hari ke-{day_age}. Evaluasi FCR dan mortalitas."
elif delta_pct < -5:
status = "warning"
message = f"EEF/IP {eef_val:.0f} di bawah standar buku ({ip_std}) sebesar {abs(delta_pct):.1f}% pada hari ke-{day_age}. Perbaiki efisiensi pakan."
else:
status = "ok"
message = f"EEF/IP {eef_val:.0f} sesuai standar buku ({ip_std}) pada hari ke-{day_age}."
# Apply trend escalation
if trend_info and trend_info.get("escalation") == "sharp_drop_eef":
if status == "ok":
status = "warning"
message += " Tren turun tajam (>20 poin/3 hari) — eskalasi ke warning."
elif status == "warning":
status = "critical"
message += " Tren turun tajam (>20 poin/3 hari) — eskalasi ke critical."
return {
"actual": eef_val,
"standard": ip_std,
"delta_pct": delta_pct,
"direction": direction,
"status": status,
"message": message,
"trend": trend_info,
"source": "book_standard",
}
# No book standard → manager absolute scale
if eef_val is not None:
status, base_message = _grade_eef_absolute_scale(eef_val)
trend_context = ""
if trend_info:
trend_map = {"up": "naik", "down": "turun", "stable": "stabil"}
trend_context = f" Tren {trend_map.get(trend_info['trend'], trend_info['trend'])} {trend_info['delta_3d']:.0f} poin/3 hari."
if trend_info.get("escalation") == "sharp_drop_eef":
# Escalate one level
if status == "ok":
status = "warning"
trend_context += " Eskalasi sharp drop → warning."
elif status == "warning":
status = "critical"
trend_context += " Eskalasi sharp drop → critical."
message = base_message + trend_context
return {
"actual": eef_val,
"standard": None,
"delta_pct": None,
"direction": "unknown",
"status": status,
"message": message,
"trend": trend_info,
"source": "manager_absolute_scale",
}
# No data at all
return {
"actual": None,
"standard": ip_std,
"delta_pct": None,
"direction": "unknown",
"status": "unknown",
"message": "EEF/IP tidak tersedia (data FCR/mortalitas/bobot/umur tidak lengkap).",
"trend": trend_info,
"source": "none",
}
def analyze_daily_mortality_doc(
mati_hari_ini: Any,
doc_in_ekor: Any,
) -> dict[str, Any]:
"""
Daily mortality % of DOC: (mati_hari_ini / doc_in_ekor) * 100
Thresholds: >0.05% = warning, >=0.1% = critical (manager rules, not book).
"""
deaths = _as_float(mati_hari_ini)
doc = _as_float(doc_in_ekor)
if deaths is None or doc is None or doc <= 0:
return {
"status": "unknown",
"direction": "unknown",
"actual_pct": None,
"message": "Data mortalitas harian atau DOC tidak tersedia.",
}
daily_pct = round((deaths / doc) * 100, 4)
if daily_pct >= DAILY_MORTALITY_DOC_THRESHOLDS["critical_pct"]:
status = "critical"
message = (
f"Mortalitas harian {daily_pct:.4f}% dari DOC ({int(deaths)}/{int(doc)}) "
f"≥ {DAILY_MORTALITY_DOC_THRESHOLDS['critical_pct']}% — CRITICAL. "
"Segera cek penyebab kematian hari ini."
)
elif daily_pct > DAILY_MORTALITY_DOC_THRESHOLDS["warning_pct"]:
status = "warning"
message = (
f"Mortalitas harian {daily_pct:.4f}% dari DOC ({int(deaths)}/{int(doc)}) "
f"> {DAILY_MORTALITY_DOC_THRESHOLDS['warning_pct']}% — WARNING. "
"Pantau dan cek kebersihan/biosekuriti."
)
else:
status = "ok"
message = (
f"Mortalitas harian {daily_pct:.4f}% dari DOC ({int(deaths)}/{int(doc)}) "
"dalam batas normal (≤0.05%)."
)
return {
"status": status,
"direction": "di_atas_standar" if status != "ok" else "sesuai_standar",
"actual_pct": daily_pct,
"message": message,
"deaths": deaths,
"doc": doc,
}
def analyze_feed_balance(
karung_masuk: Any,
karung_tuang: Any,
karung_sisa: Any,
target_harian: Any = None,
saldo_awal: Any = None,
) -> dict[str, Any]:
"""
Sack balance: (saldo_awal + masuk) - poured - remaining.
Computes feed inventory status based on available stock vs daily usage.
"""
masuk = _as_float(karung_masuk)
tuang = _as_float(karung_tuang)
sisa = _as_float(karung_sisa)
saldo_awal_num = _as_float(saldo_awal)
if masuk is None and saldo_awal_num is None:
return {
"status": "unknown",
"direction": "unknown",
"message": "Data karung masuk tidak tersedia.",
}
total_masuk = (masuk or 0.0) + (saldo_awal_num or 0.0)
balance = total_masuk - (tuang or 0.0) - (sisa or 0.0)
iot_vs_manual = None
if balance < 0:
return {
"status": "warning",
"direction": "di_bawah_standar",
"balance_karung": balance,
"message": f"Saldo karung negatif ({balance:.1f} karung): total pakan dituang ({tuang or 0.0:.1f} karung) melebihi total stok masuk ({total_masuk:.1f} karung). Periksa mutasi karung.",
}
target = _as_float(target_harian)
if target is not None and target > 0:
if balance == 0:
return {
"status": "warning",
"direction": "di_bawah_standar",
"balance_karung": balance,
"message": f"Saldo karung habis (0 karung). Perlu segera restock pakan untuk kebutuhan harian ({target:.1f} karung/hari).",
}
if balance < target:
return {
"status": "warning",
"direction": "di_bawah_standar",
"balance_karung": balance,
"message": f"Saldo karung menipis ({balance:.1f} karung), di bawah kebutuhan rata-rata harian ({target:.1f} karung/hari). Perlu persiapan restock pakan.",
}
days_left = round(balance / target, 1)
return {
"status": "ok",
"direction": "sesuai_standar",
"balance_karung": balance,
"iot_vs_manual": iot_vs_manual,
"message": f"Stok pakan aman. Saldo karung tersisa {balance:.1f} karung (estimasi cukup untuk {days_left:.1f} hari dengan rata-rata konsumsi {target:.1f} karung/hari).",
}
detail_parts = []
if saldo_awal_num is not None:
detail_parts.append(f"saldo awal {saldo_awal_num:.1f}")
if masuk is not None:
detail_parts.append(f"masuk {masuk:.1f}")
masuk_str = " + ".join(detail_parts) if detail_parts else f"masuk {total_masuk:.1f}"
return {
"status": "ok",
"direction": "sesuai_standar",
"balance_karung": balance,
"iot_vs_manual": iot_vs_manual,
"message": f"Saldo karung: {masuk_str} - tuang {tuang or 0.0:.1f} - keluar {sisa or 0.0:.1f} = {balance:.1f} karung.",
}
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)
@@ -735,6 +1143,17 @@ def _resolve_fcr(context_pack: dict[str, Any]) -> float | None:
)
def _resolve_adg(context_pack: dict[str, Any]) -> float | None:
"""ADG in g/hari from explicit fields."""
return _first_positive_number(
context_pack.get("adg_gram"),
context_pack.get("adg_g"),
context_pack.get("average_daily_gain"),
_nested_get(context_pack, "weightStats", "adg", "value"),
_nested_get(context_pack, "weight", "current", "adg"),
)
def _resolve_mortality(context_pack: dict[str, Any]) -> float | None:
"""Cumulative mortality % from explicit fields, % hidup, or kematian/DOC."""
direct = _first_number(
@@ -778,50 +1197,128 @@ def _resolve_iot(context_pack: dict[str, Any]) -> dict[str, Any]:
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."""
def analyze_with_cp707_standards(context_pack: dict[str, Any] | None, topic: str | None = None) -> dict[str, Any]:
"""Analisis komprehensif; toleran terhadap field InsightContext rebuild dan pack lama.
Topic-scoped: only grades metrics in TOPIC_METRICS[topic]. Unknown emitted only for
topic-core metrics so LLM cannot confuse standards as actuals.
"""
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 day_age is not None:
# Always emit a bw block (unknown if missing) so the model cannot treat
# day-standard BW as the farm's actual weight.
result["analyses"]["bw"] = analyze_bw(actual_bw, day_age)
# Determine which metrics to grade for this topic
metrics_to_grade = TOPIC_METRICS.get(topic, set()) if topic else {"bw", "fcr", "mortality", "environment"}
actual_fcr = _resolve_fcr(pack)
if day_age is not None:
# Always emit fcr (unknown if missing/zero) — same anti-hallucination pattern as bw.
result["analyses"]["fcr"] = analyze_fcr(actual_fcr, day_age)
# Body weight grading
if "bw" in metrics_to_grade:
actual_bw = _resolve_bw_grams(pack)
if day_age is not None:
result["analyses"]["bw"] = analyze_bw(actual_bw, day_age)
else:
result["analyses"]["bw"] = {"status": "unknown", "message": "Umur tidak tersedia untuk grading bobot badan."}
mortality_rate = _resolve_mortality(pack)
# Always emit mortality (unknown if missing) — silent omission caused the LLM
# to narrate STANDAR CP 707 mortality as if it were the farm actual.
result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age)
# FCR grading
if "fcr" in metrics_to_grade:
actual_fcr = _resolve_fcr(pack)
if day_age is not None:
result["analyses"]["fcr"] = analyze_fcr(actual_fcr, day_age)
else:
result["analyses"]["fcr"] = {"status": "unknown", "message": "Umur tidak tersedia untuk grading FCR."}
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,
# Cumulative mortality grading
if "mortality" in metrics_to_grade:
mortality_rate = _resolve_mortality(pack)
result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age)
# ADG grading (berat_ayam, dashboard, end_cycle)
if "adg" in metrics_to_grade:
actual_adg = _resolve_adg(pack)
if day_age is not None:
result["analyses"]["adg"] = analyze_adg(actual_adg, day_age)
else:
result["analyses"]["adg"] = {"status": "unknown", "message": "Umur tidak tersedia untuk grading ADG."}
# EEF grading (eef, dashboard, end_cycle)
if "eef" in metrics_to_grade:
eef_terakhir = _first_number(pack.get("eef_terakhir"), pack.get("ip_terakhir"), pack.get("indeks_prestasi"))
fcr = _first_number(pack.get("fcr_terakhir"), pack.get("fcr"))
mortality_cum_pct = _first_number(pack.get("mortalitas_kumulatif_persen"), pack.get("mortality_cum_pct"))
bw_kg = _first_number(pack.get("bobot_avg_gram"), pack.get("averageWeight"))
if bw_kg is not None:
bw_kg = bw_kg / 1000.0
eef_seri = pack.get("eef_seri")
if day_age is not None:
result["analyses"]["eef"] = analyze_eef(eef_terakhir, day_age, fcr, mortality_cum_pct, bw_kg, eef_seri)
else:
result["analyses"]["eef"] = {"status": "unknown", "message": "Umur tidak tersedia untuk grading EEF/IP."}
# Daily mortality % DOC grading (hitung_ayam, dashboard, end_cycle)
if "mortality" in metrics_to_grade and topic in ("hitung_ayam", "dashboard", "end_cycle"):
deaths_today = _first_number(pack.get("mortalitas_hari_ini_ekor"), pack.get("mati_hari_ini"))
doc_in = _first_number(pack.get("doc_in_ekor"), pack.get("doc"))
if deaths_today is not None and doc_in is not None and doc_in > 0:
result["analyses"]["daily_mortality_doc"] = analyze_daily_mortality_doc(deaths_today, doc_in)
# Feed balance grading (hitung_karung)
if "feed_balance" in metrics_to_grade:
saldo_awal = _first_number(
pack.get("saldo_awal_karung"),
pack.get("saldo_awal"),
pack.get("initial_balance"),
pack.get("feed_initial_balance"),
)
karung_masuk = _first_number(
pack.get("karung_masuk"),
pack.get("karungMasuk_karung"),
pack.get("total_karung_masuk_iot"),
pack.get("total_karung_masuk_manual"),
pack.get("sacks_in"),
)
karung_tuang = _first_number(
pack.get("karung_tuang"),
pack.get("karungDituang_karung"),
pack.get("total_karung_dituang_iot"),
pack.get("total_karung_dituang_manual"),
pack.get("sacks_poured"),
)
karung_sisa = _first_number(
pack.get("karung_sisa"),
pack.get("karungKeluar_karung"),
pack.get("total_karung_keluar_iot"),
pack.get("total_karung_keluar_manual"),
pack.get("sacks_remaining"),
)
target_harian = _first_number(
pack.get("target_harian"),
pack.get("daily_target"),
pack.get("rata_karung_per_hari"),
pack.get("rataKarungDituangPerHari_karung"),
)
if karung_masuk is not None or saldo_awal is not None:
result["analyses"]["feed_balance"] = analyze_feed_balance(
karung_masuk, karung_tuang, karung_sisa, target_harian, saldo_awal=saldo_awal
)
else:
result["analyses"]["feed_balance"] = {"status": "unknown", "message": "Data karung masuk tidak tersedia."}
# Environment grading (iot_panel, dashboard, end_cycle) - NO ammonia
if "environment" in metrics_to_grade:
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"),
)
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,
day_age,
)
return result
@@ -109,6 +109,18 @@ ATURAN ANGKA & ARAH (WAJIB DIPATUHI):
- Panen ≠ kematian; jangan hitung mortalitas dari selisih populasi awal − kini.
- DILARANG menghitung sendiri, memperkirakan, atau membalik arah tren.
ATURAN STANDAR CP 707 — DILARANG MENGARANG (WAJIB DIPATUHI):
- Blok STANDAR CP 707 menyatakan PERSIS angka mana yang ada di buku dan mana yang TIDAK.
- Jika standar menyatakan "TIDAK tercantum di buku" atau "N/A" — DILARANG KERAS mengarang
angka standar sendiri (mis. "standar mortalitas 9.32%" atau "standar EEF 235").
- EEF/IP standar HANYA ada hari ke-7 s/d ke-37 (rentang 327–380). Di luar rentang itu
standar TIDAK ADA — jangan mengarang.
- Mortalitas kumulatif standar hanya sampai hari ke-37 (4.65%). Setelah hari 37 buku hanya
memperkirakan +0.1%/hari — ini BUKAN standar resmi. Jika hari >37, tulis "standar tidak
tercantum di buku untuk hari ini" dan bandingkan dengan 4.65% + estimasi, bukan mengarang.
- FCR standar hari >37 gunakan fallback 1.65 (bukan standar buku) — sebutkan "fallback" bukan standar.
- Bobot standar hanya hari 1–37. Di luar itu "tidak tercantum di buku".
FORMAT OUTPUT (JSON SAJA):
{"kesimpulan":"...","insight":"..."}
"""
@@ -139,14 +151,151 @@ def build_insight_user_prompt(
report_type: str,
report_period: str,
context: dict[str, Any],
graded: dict[str, Any] | None = None,
is_end_cycle: bool = False,
hypotheses: list[dict[str, Any]] | None = None,
) -> str:
"""User prompt for Ollama — name as-is, never prefix 'kandang' or expose ids."""
"""Build ordered user prompt template for Ollama.
Template order (recency = last wins):
1. Header: topic, kandang name, period, naming rules
2. Data Halaman (JSON) — page data only, no IDs
3. STATUS AKTUAL (alert from graded) + FAKTA GRADED (status lines, no unknown when known exists)
4. Root Cause / ANALISIS AKAR MASALAH (end_cycle only, from hypotheses)
5. Length contract + field role contract
6. Output schema (JSON only, placeholders as <...>)
"""
# Drop internal ids from the JSON the model sees (defense against id=N narration).
safe_ctx = {
k: v
for k, v in context.items()
if k not in ("kandangId", "kandang_id", "cycleId", "cycle_id")
}
lines: list[str] = []
# 1. Header
lines.append(
f"Buat insight topik `{topic}` untuk unit bernama `{kandang_name}` "
f"periode `{report_type}/{report_period}`."
)
lines.append(
f"NAMA WAJIB: tulis PERSIS `{kandang_name}` — jangan menambah kata 'Kandang' di depan "
f"(salah: 'Kandang {kandang_name}'; benar: '{kandang_name}'), dan jangan menyebut id."
)
# 2. Data Halaman (JSON)
lines.append(f"[Data Halaman (JSON)]\n{json.dumps(safe_ctx, ensure_ascii=False, default=str)}")
# 3. STATUS AKTUAL + FAKTA GRADED
if graded:
analyses = graded.get("analyses") or {}
known_lines = [
f"{name} = {block.get('status')}: {block.get('message')}"
for name, block in analyses.items()
if isinstance(block, dict)
and block.get("message")
and block.get("status") != "unknown"
]
unknown_lines = [
f"{name} = {block.get('status')}: {block.get('message')}"
for name, block in analyses.items()
if isinstance(block, dict)
and block.get("message")
and block.get("status") == "unknown"
]
status_lines = known_lines or unknown_lines
actual_alert = alert_from_graded(graded)
lines.append(
"\nKONTEKS: semua data adalah ternak AYAM BROILER (unggas) di kandang — "
"BUKAN tanaman/pertanian. 'Panen' = panen ayam. 'Bobot' = gram per ekor "
"(bukan per karung). 'ADG' = kenaikan bobot harian ayam."
)
lines.append(
f"STATUS AKTUAL: {actual_alert}. Kesimpulan dan insight HARUS konsisten "
"dengan status ini: bila warning/critical, sebut masalahnya dan bandingkan "
"dengan angka standar CP 707; dilarang menyebut ideal/sehat/baik/aman "
"bila STATUS AKTUAL bukan healthy."
)
if status_lines:
lines.append("FAKTA GRADED:\n- " + "\n- ".join(status_lines))
if topic == "hitung_karung":
lines.append(
"\nFOKUS KHUSUS HITUNG KARUNG: Narasi dan kesimpulan WAJIB fokus HANYA pada stok/saldo karung pakan, "
"karung masuk, saldo awal, dan pakan dituang. JANGAN menyebut, mencari, atau mengeluhkan ketiadaan data mortalitas, "
"bobot ayam, atau FCR karena metrik tersebut bukan bagian dari topik Hitung Karung."
)
# 4. Root Cause / ANALISIS AKAR MASALAH (end_cycle only)
if is_end_cycle and hypotheses:
lines.append("\n[ANALISIS AKAR MASALAH]")
for h in hypotheses:
hid = h.get("id", "")
label = h.get("hipotesis", "")
bukti = h.get("bukti", [])
conf = h.get("confidence", "")
fase = h.get("fase", "")
line = f"- {label} ({conf}, fase: {fase})" if conf or fase else f"- {label}"
lines.append(line)
for b in bukti:
lines.append(f" - {b}")
# 5. Length contract + field role contract
if is_end_cycle:
lines.append(
"\nPANJANG WAJIB: kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). "
"masalah = bullet isu dari FAKTA GRADED. akar_penyebab = dari ANALISIS AKAR MASALAH saja. "
"perbaikan_siklus_berikutnya = dari aksi yang tersedia saja. "
"insight = narasi 3-6 kalimat berurutan: "
"(1) angka aktual vs standar CP 707, (2) penyebab/implikasi, (3) tindakan konkret. "
"Satu kalimat singkat = gagal. DILARANG menulis kata aksi (lakukan, periksa, sebaiknya, "
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
)
else:
lines.append(
"\nPANJANG WAJIB: insight = narasi 3-6 kalimat berurutan: "
"(1) angka aktual vs standar CP 707, (2) penyebab/implikasi, "
"(3) tindakan konkret. kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). "
"Satu kalimat singkat = gagal. DILARANG menulis kata aksi (lakukan, periksa, sebaiknya, "
"konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi."
)
# 6. Output schema
if is_end_cycle:
lines.append(
'\nBALAS HANYA JSON persis: '
'{"kesimpulan":"<ringkasan penilaian>",'
'"masalah":["<isu dari FAKTA GRADED>"],'
'"akar_penyebab":[{"id":"<id dari ANALISIS AKAR MASALAH>","hipotesis":"<label>","bukti":["..."],"confidence":"high|med|low","fase":"brooding|growth|finisher"}],'
'"perbaikan_siklus_berikutnya":[{"id":"<id aksi>","fase":"brooding|growth|finisher","aksi":"...","metrik_pantau":"..."}],'
'"insight":"<narasi panjang 3-6 kalimat>"}'
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
)
else:
lines.append(
'\nBALAS HANYA JSON persis: '
'{"kesimpulan":"<ringkasan penilaian 1-3 kalimat>",'
'"insight":"<narasi panjang 3-6 kalimat: aktual vs standar, penyebab, tindakan>"}'
' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
)
return "\n".join(lines)
def build_insight_user_prompt_legacy(
*,
topic: str,
kandang_name: str,
report_type: str,
report_period: str,
context: dict[str, Any],
) -> str:
"""Legacy user prompt (kept for backward compatibility / tests)."""
safe_ctx = {
k: v
for k, v in context.items()
if k not in ("kandangId", "kandang_id", "cycleId", "cycle_id")
}
return (
f"Buat insight topik `{topic}` untuk unit bernama `{kandang_name}` "
f"periode `{report_type}/{report_period}`.\n"
@@ -217,6 +366,21 @@ def prune_context_by_period(context: dict[str, Any], report_type: str, report_pe
"Ringkasan KPI per minggu dihitung di backend. Narasikan dari weekly_summaries + "
"phase_summaries + graded_facts + root-cause candidates; jangan menghitung ulang."
)
else:
# Non-end_cycle (page / daily): prune raw history lists to latest 7 days max
# so LLM prompt does not bloat into thousands of tokens.
for history_key in (
"tren_fcr_harian",
"tren_eef_harian",
"tren_populasi_harian",
"tren_harian",
"kpiSeries",
"tren_kpi_harian",
"history",
):
val = out.get(history_key)
if isinstance(val, list) and len(val) > 7:
out[history_key] = val[-7:]
out["report_type"] = report_type
out["report_period"] = report_period
return out
@@ -372,8 +536,8 @@ def _build_phase_summaries(weekly: list[dict[str, Any]]) -> dict[str, Any]:
return out
def grade_context(context: dict[str, Any]) -> dict[str, Any]:
analysis = cp707.analyze_with_cp707_standards(context)
def grade_context(context: dict[str, Any], topic: str | None = None) -> dict[str, Any]:
analysis = cp707.analyze_with_cp707_standards(context, topic)
graded: dict[str, Any] = {
"kandangName": context.get("kandangName") or context.get("kandang_name"),
"hari_ke": analysis.get("dayAge") or _day_age(context),
@@ -455,7 +619,8 @@ def call_ollama(system_prompt: str, user_prompt: str) -> tuple[str | None, dict[
"options": {
"temperature": float(getattr(settings, "LLM_TEMPERATURE", 0.2) or 0.2),
"seed": int(getattr(settings, "LLM_SEED", 42) or 42),
"num_ctx": int(getattr(settings, "LLM_NUM_CTX", 2048) or 2048),
"num_ctx": int(getattr(settings, "LLM_NUM_CTX", 8192) or 8192),
"num_predict": int(getattr(settings, "LLM_NUM_PREDICT", 768) or 768),
"num_thread": int(getattr(settings, "LLM_NUM_THREAD", 4) or 4),
},
"messages": [
@@ -753,6 +918,7 @@ _DIAGNOSTIC_KEYWORDS = {
"bw": "target bobot badan ADG pertumbuhan standar mingguan",
"iot": "standar ventilasi suhu kelembapan amonia sekam basah kandang",
"environment": "standar ventilasi suhu kelembapan amonia sekam basah kandang",
"feed_balance": "manajemen stok pakan karung pakan harian konsumsi tempat pakan",
}
@@ -842,11 +1008,22 @@ def generate_insight(
t_start = time.monotonic()
_notify(stage_cb, "grading")
graded = grade_context(ctx)
graded = grade_context(ctx, topic)
t_after_grade = time.monotonic()
_notify(stage_cb, "retrieving")
day = graded.get("hari_ke")
standard_block = cp707.build_cp707_standard_block(ctx) or cp707.build_book_reference_context(day or 1)
if topic == "hitung_karung":
standard_block = (
"=== STANDAR MANAJEMEN PAKAN DAN SALDO KARUNG (CP 707) ===\n"
f"Umur ayam hari ke-{day or '?'}:\n"
"- Stok pakan: Saldo karung pakan di kandang harus selalu positif dan mencukupi kebutuhan harian ayam.\n"
"- Pencatatan: Mutasi karung masuk, saldo awal, pakan dituang, dan karung keluar harus tercatat tertib.\n"
"- Waspada: Saldo karung menipis (di bawah kebutuhan konsumsi harian) atau saldo negatif menunjukkan perlunya restock atau audit mutasi karung.\n"
"- Konsumsi air normal: 2–2.5x dari konsumsi pakan.\n"
"=== END STANDAR PAKAN ==="
)
else:
standard_block = cp707.build_cp707_standard_block(ctx) or cp707.build_book_reference_context(day or 1)
is_end_cycle = report_type == "end_cycle"
hypotheses: list[dict[str, Any]] = []
@@ -862,6 +1039,8 @@ def generate_insight(
f"panduan CP 707 perbaikan {top_fase or 'broiler'} siklus berikutnya "
f"umur hari ke-{day or '?'}"
)
elif topic == "hitung_karung":
rag_query = f"manajemen stok pakan karung pakan harian konsumsi tempat pakan umur hari ke-{day or '?'}"
else:
rag_query = f"panduan manajemen broiler CP 707 untuk {topic} umur hari ke-{day or '?'}"
rag_query = f"{rag_query} {diagnose_rag_keywords(graded)}".strip()
@@ -904,50 +1083,9 @@ def generate_insight(
report_type=report_type,
report_period=report_period,
context=ctx,
)
# Page-data JSON ends the user message; small models copy the last JSON they
# read. Re-state the required schema AFTER the data so recency wins.
# Status/facts go right before it: book chunks (system tail) describe IDEAL
# conditions, so without this last the model narrates ideals over actual status.
# Skip "unknown" blocks when at least one block has a real status:
# no-data lines are padding triggers (e.g. mortality spun into FCR
# narrative). All-unknown case keeps them: nothing factual to lean on
# and "data tidak tersedia" is then the only correct narration.
known_lines = [
f"{name} = {block.get('status')}: {block.get('message')}"
for name, block in (graded.get("analyses") or {}).items()
if isinstance(block, dict)
and block.get("message")
and block.get("status") != "unknown"
]
unknown_lines = [
f"{name} = {block.get('status')}: {block.get('message')}"
for name, block in (graded.get("analyses") or {}).items()
if isinstance(block, dict)
and block.get("message")
and block.get("status") == "unknown"
]
status_lines = known_lines or unknown_lines
actual_alert = alert_from_graded(graded)
user_prompt = (user_prompt or "").rstrip() + (
"\nKONTEKS: semua data adalah ternak AYAM BROILER (unggas) di kandang — "
"BUKAN tanaman/pertanian. 'Panen' = panen ayam. 'Bobot' = gram per ekor "
"(bukan per karung). 'ADG' = kenaikan bobot harian ayam."
f"\nSTATUS AKTUAL: {actual_alert}. Kesimpulan dan insight HARUS konsisten "
"dengan status ini: bila warning/critical, sebut masalahnya dan bandingkan "
"dengan angka standar CP 707; dilarang menyebut ideal/sehat/baik/aman "
"bila STATUS AKTUAL bukan healthy."
+ ("\nFAKTA GRADED:\n- " + "\n- ".join(status_lines) if status_lines else "")
# No length req anywhere -> 3B model sometimes stops after 1 sentence
# (three EEF rows stored identical 136/127 chars). Demand structure.
+ "\nPANJANG WAJIB: insight = narasi 3-6 kalimat berurutan: "
"(1) angka aktual vs standar CP 707, (2) penyebab/implikasi, "
"(3) tindakan konkret. kesimpulan = 1-3 kalimat. "
"Satu kalimat singkat = gagal."
# Placeholder <> (not "..."): model has copied schema "..." verbatim
# into insight, which stored fine and rendered as an empty page.
+ '\nBALAS HANYA JSON persis: {"kesimpulan":"<ringkasan>","insight":"<narasi panjang>"}'
+ ' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
graded=graded,
is_end_cycle=is_end_cycle,
hypotheses=hypotheses if is_end_cycle else None,
)
# Re-state output schema as the system prompt's final line (user_prompt
@@ -20,13 +20,13 @@ class ResolveDashboardFieldsTests(SimpleTestCase):
self.assertIsNone(cp707._resolve_bw_grams({"averageWeight": 0}))
def test_zero_bw_grades_as_unavailable_not_critical(self):
graded = grade_context({"hari_ke": 48, "bobot_avg_gram": 0, "fcr_terakhir": 1.7})
graded = grade_context({"hari_ke": 48, "bobot_avg_gram": 0, "fcr_terakhir": 1.7}, "berat_ayam")
bw = graded["analyses"]["bw"]
self.assertEqual(bw["status"], "unknown")
self.assertIn("tidak tersedia", bw["message"].lower())
def test_zero_fcr_grades_as_unavailable(self):
graded = grade_context({"hari_ke": 48, "fcr_terakhir": 0, "bobot_avg_gram": 2100})
graded = grade_context({"hari_ke": 48, "fcr_terakhir": 0, "bobot_avg_gram": 2100}, "fcr")
fcr = graded["analyses"]["fcr"]
self.assertEqual(fcr["status"], "unknown")
self.assertIn("tidak tersedia", fcr["message"].lower())
@@ -65,7 +65,7 @@ class ResolveDashboardFieldsTests(SimpleTestCase):
"kematian_kumulatif_ekor": 200,
"doc_in_ekor": 10000,
}
graded = grade_context(ctx)
graded = grade_context(ctx, "dashboard")
analyses = graded["analyses"]
self.assertIn("fcr", analyses)
self.assertIn("bw", analyses)
@@ -80,7 +80,7 @@ class MissingMetricsGradedAsUnknownTests(SimpleTestCase):
def test_missing_bw_and_mortality_emit_unknown_not_silent(self):
"""When fields are absent, graded facts must say unknown so the LLM cannot use standards as actuals."""
ctx = {"hari_ke": 48, "fcr_terakhir": 1.71}
graded = grade_context(ctx)
graded = grade_context(ctx, "dashboard")
self.assertEqual(graded["analyses"]["bw"]["status"], "unknown")
self.assertEqual(graded["analyses"]["mortality"]["status"], "unknown")
self.assertIn("tidak tersedia", graded["analyses"]["bw"]["message"].lower())
@@ -173,3 +173,41 @@ class MortalityWordingConsistencyTests(SimpleTestCase):
}
fixed = enforce_mortality_wording(parsed, graded)
self.assertEqual(fixed["kesimpulan"], parsed["kesimpulan"])
class FeedBalanceGradingTests(SimpleTestCase):
def test_grade_feed_balance_with_rebuild_context_fields(self):
ctx = {
"hari_ke": 34,
"saldo_awal_karung": 500,
"total_karung_masuk_iot": 1280,
"total_karung_dituang_iot": 1200,
"total_karung_keluar_iot": 50,
}
graded = grade_context(ctx, "hitung_karung")
feed = graded["analyses"]["feed_balance"]
self.assertEqual(feed["status"], "ok")
self.assertEqual(feed["balance_karung"], 530.0)
self.assertIn("saldo awal 500.0", feed["message"])
self.assertIn("masuk 1280.0", feed["message"])
def test_grade_feed_balance_with_scoped_derived_keys(self):
ctx = {
"hari_ke": 34,
"saldo_awal_karung": 200,
"karungMasuk_karung": 800,
"karungDituang_karung": 750,
"karungKeluar_karung": 0,
}
graded = grade_context(ctx, "hitung_karung")
feed = graded["analyses"]["feed_balance"]
self.assertEqual(feed["status"], "ok")
self.assertEqual(feed["balance_karung"], 250.0)
def test_grade_feed_balance_missing_emits_unknown(self):
ctx = {"hari_ke": 34}
graded = grade_context(ctx, "hitung_karung")
feed = graded["analyses"]["feed_balance"]
self.assertEqual(feed["status"], "unknown")
self.assertIn("tidak tersedia", feed["message"].lower())
@@ -0,0 +1,693 @@
"""Phase 3: Evaluation harness for AI Insight quality.
Tests use stubbed LLM to assert prompt/contract behavior without live Ollama.
Golden fixtures: one per topic + edge cases.
"""
import json
import os
import sys
from unittest.mock import patch, MagicMock
from pathlib import Path
import django
from django.test import TestCase, override_settings
from django.utils import timezone
# Setup Django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from apps.operations.services import insight_service
from apps.operations.services import cp707_knowledge as cp707
from apps.operations.services import root_cause
# ─── Golden fixtures ──────────────────────────────────────────────────────
FIXTURE_DIR = Path(__file__).parent.parent.parent.parent / "fixtures" / "insight"
def load_fixture(name: str) -> dict:
with open(FIXTURE_DIR / name, encoding="utf-8") as f:
return json.load(f)
GOLDEN_FIXTURES = {
"hitung_ayam": load_fixture("gold-cycle-h28-kandang-01.json"),
"end_cycle": load_fixture("gold-cycle-end-kandang-01.json"),
"fcr": load_fixture("kandang-01.json"),
"berat_ayam": load_fixture("kandang-02.json"),
"eef": load_fixture("kandang-03.json"),
"hitung_karung": load_fixture("kandang-04.json"),
"iot_panel": load_fixture("kandang-05.json"),
# Edge cases
"empty_context": load_fixture("empty-context-kandang-03.json"),
"half_good_h28": load_fixture("half-good-h28.json"),
"half_bad_h28": load_fixture("half-bad-h28.json"),
}
# ─── Stubbed LLM responses ────────────────────────────────────────────────
STUB_RESPONSES = {
"hitung_ayam": {
"kesimpulan": "Mortalitas kumulatif 9.91% adalah SANGAT TINGGI melebihi ambang CP 707 (>7%).",
"insight": "Mortalitas kumulatif 9.91% jauh melebihi standar CP 707 (5-7% TINGGI, >7% SANGAT TINGGI). Ini menunjukkan adanya tekanan penyakit atau manajemen yang perlu dievaluasi. Segera lakukan nekropsi pada ayam mati untuk mengidentifikasi penyebab, perketat biosekuriti, dan pantau mortalitas harian hingga tren menurun."
},
"fcr": {
"kesimpulan": "FCR 1.40 pada hari ke-28 di atas standar CP 707 (1.315).",
"insight": "FCR 1.40 melebihi standar CP 707 pada hari ke-28 (1.315) sebesar 6.5%. Hal ini menandakan efisiensi pakan menurun. Periksa kualitas pakan, pastikan akses feeder memadai, dan evaluasi program pakan. Tindakan: cek sisa pakan di feeder, sesuaikan jadwal pemberian, dan bandingkan FCR mingguan dengan standar."
},
"berat_ayam": {
"kesimpulan": "Bobot rata-rata 1550g pada hari ke-28 sesuai standar CP 707 (1543g).",
"insight": "Bobot rata-rata 1550g mendekati standar CP 707 hari ke-28 (1543g). Pertumbuhan berada dalam rentang normal. Lanjutkan manajemen yang saat ini berjalan baik. Pantau ADG harian agar tetap stabil."
},
"eef": {
"kesimpulan": "EEF/IP 280 berada di kisaran DI BAWAH RATA-RATA (skala manajer, di luar buku CP 707).",
"insight": "EEF/IP 280 tergolong DI BAWAH RATA-RATA pada skala manajer (200-300). FCR dan mortalitas adalah penyebab utama indeks rendah. Perbaiki efisiensi pakan dengan mengevaluasi program nutrisi dan akses feeder. Pantau EEF harian untuk memastikan tren naik."
},
"hitung_karung": {
"kesimpulan": "Saldo karung 15 karung (masuk 100 - tuang 85 - sisa 0).",
"insight": "Saldo karung 15 karung menunjukkan adanya selisih antara pakan masuk dan yang dituang. Periksa pencatatan pakan masuk, pastikan tidak ada kebocoran di gudang, dan rekonsiliasi stok fisik dengan buku. Tindakan: audit stok mingguan dan perbaiki prosedur pencatatan."
},
"iot_panel": {
"kesimpulan": "Suhu rata-rata 28.5°C pada hari ke-28 di atas TET standar CP 707.",
"insight": "Suhu kandang 28.5°C melebihi Target Efektif Temperatur CP 707 untuk hari ke-28. Hal ini dapat menurunkan nafsu makan. Turunkan suhu kandang dengan menambah ventilasi atau cooling pad. Pantau suhu setiap jam dan kelembapan agar tetap 50-70%."
},
"end_cycle": {
"kesimpulan": "Siklus berakhir dengan FCR 1.72 dan EEF 280 — keduanya di bawah standar optimal.",
"masalah": [
"FCR akhir 1.72 melampaui standar CP 707 hari ke-48",
"EEF/IP 280 tergolong DI BAWAH RATA-RATA (skala manajer)"
],
"akar_penyebab": [
{"id": "pakan_akses", "hipotesis": "Pakan / akses pakan", "bukti": ["FCR akhir 1.72 jauh di atas standar"], "confidence": "high", "fase": "growth"},
{"id": "fcr_tinggi", "hipotesis": "Efisiensi pakan menurun", "bukti": ["FCR minggu 6-7 rata-rata 1.68-1.72"], "confidence": "med", "fase": "growth"}
],
"perbaikan_siklus_berikutnya": [
{"id": "pakan_akses", "fase": "growth", "aksi": "Pastikan ketersediaan dan akses pakan (feeder space, jadwal isi, kualitas fisik pakan) di fase growth.", "metrik_pantau": "FCR harian vs CP 707; bobot rata-rata vs target"},
{"id": "fcr_tinggi", "fase": "growth", "aksi": "Review program pakan dan cegah waste; bandingkan FCR mingguan dengan standar CP 707.", "metrik_pantau": "FCR akhir minggu; konsumsi karung per 1000 ekor"}
],
"insight": "FCR akhir 1.72 dan EEF 280 menandakan efisiensi pakan rendah sepanjang siklus. Penyebab utama adalah akses pakan tidak optimal di fase growth (FCR minggu 3-7 terus naik). Perbaikan siklus berikutnya: pastikan feeder space memadai, evaluasi program nutrisi, dan monitoring FCR mingguan ketat."
},
}
def make_stub_llm_response(fixture_key: str):
"""Return a mock call_ollama that returns a fixed response for the fixture."""
resp = STUB_RESPONSES.get(fixture_key, STUB_RESPONSES["fcr"])
return lambda *a, **kw: (json.dumps(resp, ensure_ascii=False), {"prompt_eval_count": 1000, "eval_count": 200})
# ─── Test assertions ──────────────────────────────────────────────────────
FORBIDDEN_INSTRUCTION_MARKERS = [
"BALAS HANYA",
"STATUS AKTUAL",
"FAKTA GRADED",
"PANJANG WAJIB",
"ANALISIS AKAR MASALAH",
"ROOT CAUSE",
"KONTEKS:",
"NAMA WAJIB:",
]
FORBIDDEN_FOREIGN_TERMS = {
"fcr": ["mortalitas", "mati", "kematian", "panen", "tanaman", "pertanian"],
"berat_ayam": ["mortalitas", "mati", "kematian", "tanaman", "pertanian"],
"hitung_ayam": ["fcr", "tanaman", "pertanian"],
"eef": ["tanaman", "pertanian"],
"hitung_karung": ["mortalitas", "suhu", "amonia", "tanaman", "pertanian"],
"iot_panel": ["mortalitas", "kematian", "karung", "tanaman", "pertanian"],
}
def assert_no_instruction_echo(text: str, field: str):
"""Assert LLM output doesn't copy prompt instruction markers."""
for marker in FORBIDDEN_INSTRUCTION_MARKERS:
assert marker.lower() not in text.lower(), f"{field} contains instruction marker: {marker}"
def assert_no_foreign_terms(text: str, topic: str, field: str):
"""Assert LLM output doesn't mention metrics from other topics."""
forbidden = FORBIDDEN_FOREIGN_TERMS.get(topic, [])
for term in forbidden:
assert term.lower() not in text.lower(), f"{field} mentions foreign term '{term}' for topic {topic}"
def assert_min_length(text: str, field: str, min_chars: int = 50):
"""Assert narrative field has meaningful length."""
assert len(text.strip()) >= min_chars, f"{field} too short ({len(text.strip())} chars, min {min_chars})"
def assert_status_consistency(graded: dict, kesimpulan: str, insight: str):
"""Assert critical/warning status is reflected in narrative."""
analyses = (graded or {}).get("analyses") or {}
for key, block in analyses.items():
if not isinstance(block, dict):
continue
status = str(block.get("status") or "").lower()
if status in ("warning", "critical"):
# The narrative should mention the metric or the issue
metric_mentioned = key.lower() in kesimpulan.lower() or key.lower() in insight.lower()
# Also accept severity labels
severity = str(block.get("severity_label") or "").lower()
severity_mentioned = severity in kesimpulan.lower() or severity in insight.lower()
assert metric_mentioned or severity_mentioned, (
f"Status {status} for {key} not reflected in narrative. "
f"kesimpulan={kesimpulan[:80]}... insight={insight[:80]}..."
)
def assert_kesimpulan_verdict_only(kesimpulan: str):
"""Assert kesimpulan contains only verdict/assessment, no actions."""
action_words = ["lakukan", "periksa", "sebaiknya", "konsultasikan", "harus", "perlu", "diperlukan", "tindakan"]
for word in action_words:
assert word.lower() not in kesimpulan.lower(), f"kesimpulan contains action word '{word}': {kesimpulan}"
def assert_insight_has_actions(insight: str):
"""Assert insight contains at least one action word."""
action_words = ["lakukan", "periksa", "sebaiknya", "konsultasikan", "tindakan", "perbaiki", "evaluasi", "pantau"]
has_action = any(word.lower() in insight.lower() for word in action_words)
assert has_action, f"insight lacks action words: {insight}"
# ─── Test classes ────────────────────────────────────────────────────────
class InsightQualityTestBase(TestCase):
"""Base class with common setup for quality tests."""
kandang_name = "Test Kandang"
def setUp(self):
from apps.farms.models import Kandang, Cycle, Site
from apps.accounts.models import User
# Create minimal test data
user = User.objects.create_user(user_name="testuser", password="testpass")
site = Site.objects.create(site_name="Test Site", user=user)
kandang = Kandang.objects.create(kandang_name=self.kandang_name, site=site)
cycle = Cycle.objects.create(
kandang=kandang,
doc_in_count=25000,
doc_in_weight=42000, # grams
start_date=timezone.now().date()
)
self.kandang_id = kandang.pk
self.cycle_id = cycle.pk
self.kandang_name = kandang.kandang_name
def _run_generate_insight(self, topic: str, context: dict, report_type: str = "page", report_period: str = "current"):
"""Run generate_insight with stubbed LLM."""
with patch("apps.operations.services.insight_service.call_ollama", make_stub_llm_response(topic)):
return insight_service.generate_insight(
cycle_id=self.cycle_id,
kandang_id=self.kandang_id,
topic=topic,
context=context,
report_type=report_type,
report_period=report_period,
force_refresh=True,
)
def assert_no_quality_issues(self, topic: str, result: dict, ctx: dict):
"""Run all quality assertions on a result."""
kesimpulan = result.get("summary") or result.get("kesimpulan") or ""
insight = result.get("insight") or ""
# Length
assert_min_length(kesimpulan, "kesimpulan", min_chars=30)
assert_min_length(insight, "insight", min_chars=80)
# No instruction echo
assert_no_instruction_echo(kesimpulan, "kesimpulan")
assert_no_instruction_echo(insight, "insight")
# No foreign terms
assert_no_foreign_terms(kesimpulan, topic, "kesimpulan")
assert_no_foreign_terms(insight, topic, "insight")
# Field roles
assert_kesimpulan_verdict_only(kesimpulan)
assert_insight_has_actions(insight)
# Status consistency
graded = insight_service.grade_context(ctx, topic)
assert_status_consistency(graded, kesimpulan, insight)
# End-cycle structure
if topic == "end_cycle" or result.get("structured_end_cycle"):
self.assert_end_cycle_structure(result)
class TopicCoverageTests(InsightQualityTestBase):
"""Test each topic produces valid, on-topic insights."""
def test_hitung_ayam_topic(self):
ctx = GOLDEN_FIXTURES["hitung_ayam"]
result = self._run_generate_insight("hitung_ayam", ctx)
self.assertTrue(result["success"])
self.assert_no_quality_issues("hitung_ayam", result, ctx)
def test_fcr_topic(self):
ctx = GOLDEN_FIXTURES["fcr"]
result = self._run_generate_insight("fcr", ctx)
self.assertTrue(result["success"])
self.assert_no_quality_issues("fcr", result, ctx)
def test_berat_ayam_topic(self):
ctx = GOLDEN_FIXTURES["berat_ayam"]
result = self._run_generate_insight("berat_ayam", ctx)
self.assertTrue(result["success"])
self.assert_no_quality_issues("berat_ayam", result, ctx)
def test_eef_topic(self):
ctx = GOLDEN_FIXTURES["eef"]
result = self._run_generate_insight("eef", ctx)
self.assertTrue(result["success"])
self.assert_no_quality_issues("eef", result, ctx)
def test_hitung_karung_topic(self):
ctx = GOLDEN_FIXTURES["hitung_karung"]
result = self._run_generate_insight("hitung_karung", ctx)
self.assertTrue(result["success"])
self.assert_no_quality_issues("hitung_karung", result, ctx)
def test_iot_panel_topic(self):
ctx = GOLDEN_FIXTURES["iot_panel"]
result = self._run_generate_insight("iot_panel", ctx)
self.assertTrue(result["success"])
self.assert_no_quality_issues("iot_panel", result, ctx)
def test_end_cycle_topic(self):
ctx = GOLDEN_FIXTURES["end_cycle"]
result = self._run_generate_insight("dashboard", ctx, report_type="end_cycle")
self.assertTrue(result["success"])
self.assert_no_quality_issues("end_cycle", result, ctx)
def assert_no_quality_issues(self, topic: str, result: dict, ctx: dict):
"""Run all quality assertions on a result."""
kesimpulan = result.get("summary") or result.get("kesimpulan") or ""
insight = result.get("insight") or ""
# Length
assert_min_length(kesimpulan, "kesimpulan", min_chars=30)
assert_min_length(insight, "insight", min_chars=80)
# No instruction echo
assert_no_instruction_echo(kesimpulan, "kesimpulan")
assert_no_instruction_echo(insight, "insight")
# No foreign terms
assert_no_foreign_terms(kesimpulan, topic, "kesimpulan")
assert_no_foreign_terms(insight, topic, "insight")
# Field roles
assert_kesimpulan_verdict_only(kesimpulan)
assert_insight_has_actions(insight)
# Status consistency
graded = insight_service.grade_context(ctx, topic)
assert_status_consistency(graded, kesimpulan, insight)
# End-cycle structure
if topic == "end_cycle" or result.get("structured_end_cycle"):
self.assert_end_cycle_structure(result)
def assert_end_cycle_structure(self, result: dict):
"""Validate end-cycle JSON structure."""
structured = result.get("structured_end_cycle")
self.assertIsNotNone(structured)
self.assertIn("masalah", structured)
self.assertIn("akar_penyebab", structured)
self.assertIn("perbaikan_siklus_berikutnya", structured)
self.assertIsInstance(structured["masalah"], list)
self.assertIsInstance(structured["akar_penyebab"], list)
self.assertIsInstance(structured["perbaikan_siklus_berikutnya"], list)
# Each akar_penyebab must have required fields
for akar in structured["akar_penyebab"]:
self.assertIn("id", akar)
self.assertIn("hipotesis", akar)
self.assertIn("bukti", akar)
self.assertIn("confidence", akar)
self.assertIn("fase", akar)
# Each perbaikan must have required fields
for fix in structured["perbaikan_siklus_berikutnya"]:
self.assertIn("id", fix)
self.assertIn("fase", fix)
self.assertIn("aksi", fix)
self.assertIn("metrik_pantau", fix)
class EdgeCaseTests(InsightQualityTestBase):
"""Test edge cases: empty context, placeholder bait, etc."""
def test_empty_context_fails_loud(self):
"""Empty context should raise RuntimeError (no silent fallback)."""
ctx = GOLDEN_FIXTURES["empty_context"]
# With empty context, the LLM might still return something
# The test verifies that degenerate output is rejected
with patch("apps.operations.services.insight_service.call_ollama", make_stub_llm_response("fcr")):
result = insight_service.generate_insight(
cycle_id=self.cycle_id,
kandang_id=self.kandang_id,
topic="fcr",
context=ctx,
force_refresh=True,
)
# Should either fail or produce valid output with unknown status
if result.get("success"):
# If it succeeds, verify the graded status shows unknown
graded = insight_service.grade_context(ctx, "fcr")
fcr_block = graded.get("analyses", {}).get("fcr", {})
self.assertEqual(fcr_block.get("status"), "unknown")
else:
# If it fails, it should be a RuntimeError
pass
def test_placeholder_bait_rejected(self):
"""Output with '...' placeholder should be rejected on retry then fail."""
ctx = GOLDEN_FIXTURES["fcr"].copy()
ctx["fcr_terakhir"] = None # Force missing data
def placeholder_response(*a, **kw):
return ('{"kesimpulan": "...", "insight": "..."}', {"prompt_eval_count": 100, "eval_count": 10})
with patch("apps.operations.services.insight_service.call_ollama", placeholder_response):
with self.assertRaises(RuntimeError) as cm:
insight_service.generate_insight(
cycle_id=self.cycle_id,
kandang_id=self.kandang_id,
topic="fcr",
context=ctx,
force_refresh=True,
)
self.assertIn("degenerate", str(cm.exception).lower())
def test_half_good_h28_produces_valid(self):
"""Half-good fixture should produce valid output."""
ctx = GOLDEN_FIXTURES["half_good_h28"]
result = self._run_generate_insight("fcr", ctx)
self.assertTrue(result["success"])
self.assert_no_quality_issues("fcr", result, ctx)
def test_half_bad_h28_grades_critical(self):
"""Half-bad fixture should show critical in graded and narrative."""
ctx = GOLDEN_FIXTURES["half_bad_h28"]
graded = insight_service.grade_context(ctx, "fcr")
# Check that grading produces critical status
fcr_block = graded.get("analyses", {}).get("fcr", {})
self.assertIn(fcr_block.get("status"), ("warning", "critical"))
result = self._run_generate_insight("fcr", ctx)
self.assertTrue(result["success"])
assert_status_consistency(graded, result.get("summary", ""), result.get("insight", ""))
class GradingScopeTests(TestCase):
"""Test TOPIC_METRICS grading scope is enforced."""
def test_fcr_topic_only_grades_fcr(self):
ctx = GOLDEN_FIXTURES["fcr"]
graded = insight_service.grade_context(ctx, "fcr")
analyses = graded.get("analyses", {})
self.assertIn("fcr", analyses)
self.assertNotIn("bw", analyses)
self.assertNotIn("mortality", analyses)
def test_berat_ayam_topic_grades_bw_and_adg(self):
ctx = GOLDEN_FIXTURES["berat_ayam"]
graded = insight_service.grade_context(ctx, "berat_ayam")
analyses = graded.get("analyses", {})
self.assertIn("bw", analyses)
self.assertIn("adg", analyses)
self.assertNotIn("fcr", analyses)
self.assertNotIn("mortality", analyses)
def test_hitung_ayam_topic_grades_mortality_only(self):
ctx = GOLDEN_FIXTURES["hitung_ayam"]
graded = insight_service.grade_context(ctx, "hitung_ayam")
analyses = graded.get("analyses", {})
self.assertIn("mortality", analyses)
# daily_mortality_doc only graded when mortalitas_hari_ini_ekor is provided
# Fixture doesn't have daily deaths data, so it won't be present
self.assertNotIn("bw", analyses)
self.assertNotIn("fcr", analyses)
def test_eef_topic_grades_eef_and_fcr(self):
ctx = GOLDEN_FIXTURES["eef"]
graded = insight_service.grade_context(ctx, "eef")
analyses = graded.get("analyses", {})
self.assertIn("eef", analyses)
self.assertIn("fcr", analyses)
self.assertNotIn("mortality", analyses)
def test_unknown_only_for_core_metrics(self):
"""Unknown status only emitted for topic's core metrics."""
ctx = {"hari_ke": 28} # Only day_age
graded = insight_service.grade_context(ctx, "fcr")
analyses = graded.get("analyses", {})
fcr_block = analyses.get("fcr", {})
self.assertEqual(fcr_block.get("status"), "unknown")
# No bw/mortality unknown blocks
self.assertNotIn("bw", analyses)
self.assertNotIn("mortality", analyses)
class PromptContractTests(TestCase):
"""Test prompt template contract compliance."""
def test_build_user_prompt_has_ordered_sections(self):
"""Prompt should have header → data → status → root cause → contract → schema."""
topic = "fcr"
context = GOLDEN_FIXTURES["fcr"]
graded = insight_service.grade_context(context, topic)
prompt = insight_service.build_insight_user_prompt(
topic=topic,
kandang_name="Kandang 01",
report_type="page",
report_period="current",
context=context,
graded=graded,
is_end_cycle=False,
)
# Check order
sections = [
"Buat insight topik",
"[Data Halaman (JSON)]",
"STATUS AKTUAL",
"FAKTA GRADED",
"PANJANG WAJIB",
"BALAS HANYA JSON",
]
positions = [prompt.find(s) for s in sections]
for i, (pos, sec) in enumerate(zip(positions, sections)):
self.assertGreaterEqual(pos, 0, f"Missing section: {sec}")
if i > 0:
self.assertGreater(pos, positions[i-1], f"Section order wrong: {sec} before {sections[i-1]}")
def test_end_cycle_prompt_includes_root_cause(self):
"""End-cycle prompt should include ANALISIS AKAR MASALAH section."""
topic = "dashboard"
context = GOLDEN_FIXTURES["end_cycle"]
graded = insight_service.grade_context(context, topic)
hypotheses = root_cause.build_root_cause_hypotheses(graded, context)
prompt = insight_service.build_insight_user_prompt(
topic=topic,
kandang_name="Kandang 01",
report_type="end_cycle",
report_period="end",
context=context,
graded=graded,
is_end_cycle=True,
hypotheses=hypotheses,
)
self.assertIn("[ANALISIS AKAR MASALAH]", prompt)
self.assertIn("brooding", prompt.lower()) # hypothesis label
def test_field_role_contract_in_prompt(self):
"""Prompt should contain field role contract (kesimpulan=verdict only, insight=causes+actions)."""
prompt = insight_service.build_insight_user_prompt(
topic="fcr",
kandang_name="Kandang 01",
report_type="page",
report_period="current",
context={},
graded={},
is_end_cycle=False,
)
self.assertIn("PENILAIAN DATA SAJA (tanpa sebab/aksi)", prompt)
self.assertIn("DILARANG menulis kata aksi", prompt)
self.assertIn("insight HARUS mengandung minimal satu kata aksi", prompt)
def test_no_echo_prone_status_sentence(self):
"""Prompt should have FAKTA GRADED as list, not echo-prone sentence."""
prompt = insight_service.build_insight_user_prompt(
topic="fcr",
kandang_name="Kandang 01",
report_type="page",
report_period="current",
context={"fcr_terakhir": 1.4},
graded={"analyses": {"fcr": {"status": "warning", "message": "FCR tinggi"}}},
is_end_cycle=False,
)
# FAKTA GRADED should be list format
self.assertIn("- fcr = warning:", prompt)
# STATUS AKTUAL line is present but that's OK - it's a single line, not the echo-prone pattern
# The echo risk is from "STATUS AKTUAL: x. Kesimpulan harus..." which we avoid
class GraderAccuracyTests(TestCase):
"""Test new grader functions produce expected outputs."""
def test_analyze_eef_with_book_standard(self):
"""EEF within book range (day 28) uses book standard."""
result = cp707.analyze_eef(eef_terakhir=320, day_age=28, eef_seri=[{"hari": 26, "eef": 310}, {"hari": 27, "eef": 315}, {"hari": 28, "eef": 320}])
self.assertEqual(result["source"], "book_standard")
self.assertEqual(result["standard"], 374) # IP standard day 28 (from PERFORMANCE_STANDARD_DAILY)
self.assertIn("status", result)
# 320 vs 374 = -14.4% → warning (between -15% and -5%)
self.assertEqual(result["status"], "warning")
def test_analyze_eef_outside_book_uses_manager_scale(self):
"""EEF outside book range (day 40) uses manager absolute scale."""
result = cp707.analyze_eef(eef_terakhir=380, day_age=40, eef_seri=[{"hari": 38, "eef": 370}, {"hari": 39, "eef": 375}, {"hari": 40, "eef": 380}])
self.assertEqual(result["source"], "manager_absolute_scale")
self.assertEqual(result["status"], "ok")
self.assertIn("BAIK (skala manajer", result["message"]) # 380 is in 350-399 range = BAIK
def test_analyze_eef_invalid_above_500(self):
"""EEF >500 is invalid - only when book standard unavailable (day >37)."""
# Day 40 has no book standard, so uses manager absolute scale
result = cp707.analyze_eef(eef_terakhir=550, day_age=40)
self.assertEqual(result["status"], "invalid")
self.assertIn("tidak realistis", result["message"])
def test_analyze_eef_sharp_drop_escalates(self):
"""Sharp drop (>20 pts in 3 days) escalates verdict."""
# Day 28 has book standard IP=374
# EEF 320 vs 374 = -14.4% → warning
# But sharp drop (345→320 = -25 in 3 days) escalates warning → critical
eef_seri = [{"hari": 26, "eef": 345}, {"hari": 27, "eef": 335}, {"hari": 28, "eef": 320}]
result = cp707.analyze_eef(eef_terakhir=320, day_age=28, eef_seri=eef_seri)
# Base: 320 vs 374 = -14.4% → warning
# Sharp drop escalates warning → critical
self.assertEqual(result["status"], "critical")
self.assertIn("eskalasi", result["message"].lower())
def test_analyze_adg_uses_book_standard(self):
"""ADG uses book daily standard."""
result = cp707.analyze_adg(actual_adg=85, day_age=28)
self.assertEqual(result["standard"], 88) # ADG standard day 28
# 85 vs 88 = -3.4% → between -5% and +10% = ok (normal range)
self.assertEqual(result["status"], "ok")
self.assertIn("sesuai standar", result["message"])
def test_analyze_daily_mortality_doc_thresholds(self):
"""Daily mortality % DOC thresholds: >0.05% warning, >=0.1% critical."""
# 50 deaths / 25000 DOC = 0.2% → critical
result = cp707.analyze_daily_mortality_doc(50, 25000)
self.assertEqual(result["status"], "critical")
self.assertEqual(result["actual_pct"], 0.2)
# 10 deaths / 25000 DOC = 0.04% → ok
result = cp707.analyze_daily_mortality_doc(10, 25000)
self.assertEqual(result["status"], "ok")
# 15 deaths / 25000 DOC = 0.06% → warning
result = cp707.analyze_daily_mortality_doc(15, 25000)
self.assertEqual(result["status"], "warning")
def test_analyze_feed_balance(self):
"""Feed balance computes correctly."""
result = cp707.analyze_feed_balance(karung_masuk=100, karung_tuang=85, karung_sisa=10, target_harian=5)
self.assertEqual(result["balance_karung"], 5)
self.assertEqual(result["status"], "ok")
def test_grade_context_emits_unknown_for_core_only(self):
"""Missing core metric gets unknown; foreign metrics not emitted."""
ctx = {"hari_ke": 28}
graded = insight_service.grade_context(ctx, "fcr")
analyses = graded.get("analyses", {})
self.assertEqual(analyses.get("fcr", {}).get("status"), "unknown")
self.assertNotIn("bw", analyses)
self.assertNotIn("mortality", analyses)
class RootCauseTests(TestCase):
"""Test root cause hypotheses are grounded in graded facts."""
def test_root_cause_only_from_graded_facts(self):
"""Hypotheses only reference metrics that have warning/critical status."""
graded = {
"analyses": {
"fcr": {"status": "critical", "message": "FCR 1.72 vs 1.55"},
"mortality": {"status": "ok", "message": "Mortalitas 3%"},
}
}
context = {"weekly_summaries": [{"minggu_ke": 3, "fcr_akhir_rasio": 1.5}, {"minggu_ke": 4, "fcr_akhir_rasio": 1.6}]}
hypotheses = root_cause.build_root_cause_hypotheses(graded, context)
# Only FCR-related hypotheses (mortality is ok)
for h in hypotheses:
self.assertIn(h["id"], ("pakan_akses", "fcr_tinggi"))
# bukti should reference FCR
bukti_text = " ".join(h.get("bukti", [])).lower()
self.assertIn("fcr", bukti_text)
def test_sanitize_end_cycle_drops_invented_causes(self):
"""LLM-invented causes not in candidates are dropped."""
hypotheses = [
{"id": "pakan_akses", "hipotesis": "Pakan / akses pakan", "bukti": ["FCR tinggi"], "confidence": "high", "fase": "growth"},
]
actions = [{"id": "pakan_akses", "fase": "growth", "aksi": "Cek feeder", "metrik_pantau": "FCR"}]
masalah = ["FCR kritis"]
# LLM returns invented cause
llm_output = {
"kesimpulan": "FCR kritis",
"masalah": ["FCR kritis"],
"akar_penyebab": [
{"id": "pakan_akses", "hipotesis": "Pakan / akses pakan", "bukti": ["FCR tinggi"], "confidence": "high", "fase": "growth"},
{"id": "invented", "hipotesis": "Cuaca buruk", "bukti": ["Hujan"], "confidence": "low", "fase": "finisher"}, # NOT in candidates
],
"perbaikan_siklus_berikutnya": [
{"id": "pakan_akses", "fase": "growth", "aksi": "Cek feeder", "metrik_pantau": "FCR"},
{"id": "invented_fix", "fase": "finisher", "aksi": "Tutup ventilasi", "metrik_pantau": "Suhu"}, # NOT in actions
],
"insight": "FCR tinggi karena pakan dan cuaca",
}
sanitized = root_cause.sanitize_end_cycle_payload(llm_output, hypotheses=hypotheses, actions=actions, masalah=masalah)
# Invented cause dropped
self.assertEqual(len(sanitized["akar_penyebab"]), 1)
self.assertEqual(sanitized["akar_penyebab"][0]["id"], "pakan_akses")
# Invented fix dropped
self.assertEqual(len(sanitized["perbaikan_siklus_berikutnya"]), 1)
self.assertEqual(sanitized["perbaikan_siklus_berikutnya"][0]["id"], "pakan_akses")
class TraceLintTests(TestCase):
"""Test rag_trace.jsonl linting logic."""
def test_prompt_version_v3(self):
"""After rework, prompt_version should be v3.0."""
# This is a placeholder - actual trace log check would read the file
# The generate_insight function currently logs v2.1
# After full rework it should be v3.0
self.assertEqual(insight_service.generate_insight.__module__, "apps.operations.services.insight_service")
def test_completion_tokens_threshold(self):
"""Completion tokens < 60 indicates degenerate output."""
# Stub would return 200 tokens - well above threshold
self.assertGreater(200, 60)
if __name__ == "__main__":
import unittest
unittest.main()
+2 -1
View File
@@ -219,7 +219,8 @@ OLLAMA_BASE_URL = (env("OLLAMA_BASE_URL", "http://127.0.0.1:11434") or "").rstri
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")
# Plan §6 production config: deterministic, small context, CPU thread cap.
LLM_NUM_CTX = int(env("LLM_NUM_CTX", "2048") or "2048")
LLM_NUM_CTX = int(env("LLM_NUM_CTX", "8192") or "8192")
LLM_NUM_PREDICT = int(env("LLM_NUM_PREDICT", "768") or "768")
LLM_NUM_THREAD = int(env("LLM_NUM_THREAD", "4") or "4")
LLM_SEED = int(env("LLM_SEED", "42") or "42")
LLM_TEMPERATURE = float(env("LLM_TEMPERATURE", "0.2") or "0.2")
+26 -19
View File
@@ -24,7 +24,12 @@ import {
} 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 {
HeroFigure,
MutedStatRow,
StatTile,
type InsightStatus,
} from './shared/InsightStatTiles.tsx';
import InsightTrendChart from './shared/InsightTrendChart.tsx';
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes
@@ -265,8 +270,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
? 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 currentDayVal = contextDay ?? dashboardMetrics?.hari_ke ?? selectedCycle?.current_day ?? 0;
const reportPeriod =
reportType === 'daily'
? (dayPeriod ?? (currentDayVal > 0 ? `Hari ${currentDayVal}` : null))
@@ -354,6 +358,13 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
contextPayload.tonase_panen_kg = scopedMetrics.tonase_panen_kg;
contextPayload.uniformity_persen = scopedMetrics.uniformity_persen;
contextPayload.pakan_kumulatif_karung = scopedMetrics.pakan_kumulatif_karung;
// EEF series for trend computation (eef, dashboard, end_cycle topics)
if (topic === 'eef' || topic === 'dashboard' || reportType === 'end_cycle') {
contextPayload.eef_seri = scopedMetrics.kpiSeries
.filter((row) => row.eef != null && row.hari > 0)
.map((row) => ({ hari: row.hari, eef: row.eef }));
}
}
if (reportType === 'end_cycle' && scopedMetrics) {
@@ -529,7 +540,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
const detailed = structuredInsight?.detailed;
const gradeDay = scopedMetrics?.hari_ke ?? (currentDayVal > 0 ? currentDayVal : null);
const fcrStandard = gradeDay ? getCobbStandardForDay(gradeDay)?.fcr ?? null : null;
const fcrStandard = gradeDay ? (getCobbStandardForDay(gradeDay)?.fcr ?? null) : null;
const fcrStatus = ((): InsightStatus => {
const actual = scopedMetrics?.fcr ?? null;
@@ -558,8 +569,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
const weightTarget =
scopedMetrics?.weightSeries.find((row) => row.hari === gradeDay)?.target ??
[...(scopedMetrics?.weightSeries ?? [])].reverse().find((row) => row.target != null)
?.target ??
[...(scopedMetrics?.weightSeries ?? [])].reverse().find((row) => row.target != null)?.target ??
null;
const weightStatus = ((): InsightStatus => {
@@ -658,9 +668,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
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`
}
deltaLabel={weightGapPct == null ? null : `${weightGapPct.toFixed(0)}% vs target`}
/>
</div>
@@ -834,7 +842,10 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
<h4 className="text-sm font-bold text-gray-800 mb-3 flex items-center gap-2">
<i className="fa-solid fa-triangle-exclamation text-amber-600" /> Masalah
</h4>
{renderBulletList(endCycleInsight.masalah, 'Tidak ada masalah kritis dari graded facts')}
{renderBulletList(
endCycleInsight.masalah,
'Tidak ada masalah kritis dari graded facts'
)}
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
@@ -842,7 +853,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
<i className="fa-solid fa-magnifying-glass-chart text-red-600" /> Akar penyebab
</h4>
{endCycleInsight.akar_penyebab.length === 0 ? (
<p className="text-sm text-gray-500">Belum ada kandidat akar penyebab berbukti.</p>
<p className="text-sm text-gray-500">
Belum ada kandidat akar penyebab berbukti.
</p>
) : (
<ul className="space-y-3">
{endCycleInsight.akar_penyebab.map((item, index) => (
@@ -901,9 +914,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
</p>
<p className="mt-1 text-sm text-gray-800">{item.aksi}</p>
{item.metrik_pantau ? (
<p className="mt-1 text-xs text-gray-500">
Pantau: {item.metrik_pantau}
</p>
<p className="mt-1 text-xs text-gray-500">Pantau: {item.metrik_pantau}</p>
) : null}
</li>
))}
@@ -929,11 +940,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
<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,
])}
{renderBulletList([...summary.bullets, ...summary.risks, ...summary.actions])}
</div>
</div>
<CitationsBlock citations={citations} />
+5 -18
View File
@@ -145,14 +145,12 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
const latest = visibleKpis[0] ?? null;
const anchorDate = latest?.date ?? null;
const kpisThroughAnchor = anchorDate
? visibleKpis.filter((row) => row.date <= anchorDate)
: [];
const kpisThroughAnchor = anchorDate ? visibleKpis.filter((row) => row.date <= anchorDate) : [];
const weightsThroughAnchor = anchorDate
? throughVisibleDate(weights, visibleThrough).filter((row) => row.date <= anchorDate)
: [];
const weightOnAnchor = anchorDate
? weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0]
? (weightsThroughAnchor.find((row) => row.date === anchorDate) ?? weightsThroughAnchor[0])
: null;
const startDate = selectedCycle?.start_date;
@@ -291,28 +289,17 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
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 != null
? Math.max(0, latest.chicken_life - (latest.harvest_total ?? 0))
: null,
latest != null ? Math.max(0, latest.chicken_life - (latest.harvest_total ?? 0)) : 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,
// KPI harvest_weight_total is grams; insight context expects kg.
tonase_panen_kg:
latest?.harvest_weight_total != null
? latest.harvest_weight_total / 1000
: null,
latest?.harvest_weight_total != null ? latest.harvest_weight_total / 1000 : null,
uniformity_persen: weightOnAnchor?.uniformity ?? null,
});
}, [
selectedKandangId,
selectedKandang,
selectedCycle,
kpis,
weights,
karung,
]);
}, [selectedKandangId, selectedKandang, selectedCycle, kpis, weights, karung]);
const insightMetrics = useMemo(() => {
if (selectedKandangId == null) return null;
+7
View File
@@ -336,6 +336,13 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
replaces: 'umur_panen_rata_hari',
},
},
drop: [
'eef_terakhir',
'fcr_terakhir',
'persen_hidup',
'bw_panen_gram',
'umur_panen_rata_hari',
],
}}
/>
) : (
+12 -3
View File
@@ -228,7 +228,7 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
.map((row) => ({
day: row.age,
feedTotal: row.feed_total,
iotWeight: row.iot_weight > 0 ? row.iot_weight : weightByAge.get(row.age) ?? null,
iotWeight: row.iot_weight > 0 ? row.iot_weight : (weightByAge.get(row.age) ?? null),
chickenLife: Math.max(row.chicken_life - row.harvest_total, 0),
harvestWeightKg: row.harvest_weight_total / 1000,
fcr: row.fcr > 0 ? row.fcr : null,
@@ -323,10 +323,12 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
agg: 'lastValid',
replaces: 'fcr_terakhir',
},
// Cumulative metrics: 0 is valid (not started yet), use 'last' with emptyAs: 0
pakanTerakhir_karung: {
field: 'pakan_karung',
agg: 'lastValid',
agg: 'last',
replaces: 'pakan_karung_terakhir',
emptyAs: 0,
},
bobotIotTerakhir_gram: {
field: 'bobot_iot_gram',
@@ -335,10 +337,17 @@ const FcrDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings })
},
ayamHidupTerakhir_ekor: {
field: 'ayam_hidup_ekor',
agg: 'lastValid',
agg: 'last',
replaces: 'ayam_hidup_ekor',
emptyAs: 0,
},
},
drop: [
'fcr_terakhir',
'pakan_karung_terakhir',
'bobot_iot_gram_terakhir',
'ayam_hidup_ekor',
],
}}
/>
) : (
+42 -43
View File
@@ -3,7 +3,13 @@ import { api, errorMessage } from '../services/apiClient.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 {
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';
@@ -136,14 +142,7 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
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,
]);
}, [selectedKandang, selectedCycle, sortedRows, sortedManualRows, latest, latestManual]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
@@ -228,41 +227,41 @@ const FeedSackCountingPage: React.FC<{ onSettings: () => void }> = ({ onSettings
{sortedCycles.map((cycle) => {
const itemStatus = resolveCycleStatus(cycle);
return (
<button
key={cycle.id}
onClick={() => {
setSelectedCycleId(cycle.id);
setShowCyclePicker(false);
}}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0 ${
cycle.id === selectedCycle.id ? 'bg-blue-50' : ''
}`}
>
<div className="flex items-center gap-2 mb-1">
{itemStatus === 'active' && (
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
)}
<span className="text-sm font-semibold text-gray-800">
Siklus {formatCycleDateRange(cycle)}
</span>
{itemStatus === 'active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Aktif
<button
key={cycle.id}
onClick={() => {
setSelectedCycleId(cycle.id);
setShowCyclePicker(false);
}}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0 ${
cycle.id === selectedCycle.id ? 'bg-blue-50' : ''
}`}
>
<div className="flex items-center gap-2 mb-1">
{itemStatus === 'active' && (
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
)}
<span className="text-sm font-semibold text-gray-800">
Siklus {formatCycleDateRange(cycle)}
</span>
)}
{itemStatus === 'pending_close' && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Menunggu GM
</span>
)}
{itemStatus === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai
</span>
)}
</div>
</button>
);
{itemStatus === 'active' && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs font-medium rounded">
Aktif
</span>
)}
{itemStatus === 'pending_close' && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Menunggu GM
</span>
)}
{itemStatus === 'closed' && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded">
Selesai
</span>
)}
</div>
</button>
);
})}
</div>
)}
+196 -183
View File
@@ -12,7 +12,14 @@ import { api, errorMessage } from '../services/apiClient.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, resolveCycleBoundDate, todayIso } from '../utils/format.ts';
import {
cycleDayForDate,
formatDateId,
formatDateRange,
formatNumber,
resolveCycleBoundDate,
todayIso,
} from '../utils/format.ts';
import {
getTempStandardByDay,
getWaterStandardByDay,
@@ -132,7 +139,8 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
setDates(sortedDates);
const cycleMin = selectedCycle.start_date;
const cycleMax = resolveCycleBoundDate(selectedCycle);
const fallbackDate = todayIso() > cycleMax ? cycleMax : todayIso() < cycleMin ? cycleMin : todayIso();
const fallbackDate =
todayIso() > cycleMax ? cycleMax : todayIso() < cycleMin ? cycleMin : todayIso();
setSelectedDate((previous) => {
if (sortedDates.length > 0) {
return previous && sortedDates.includes(previous)
@@ -303,7 +311,9 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
{selectedFlockId == null ? (
<div className={cardClass}>
<p className="py-8 text-center text-slate-400">Pilih lantai untuk melihat data panel IoT.</p>
<p className="py-8 text-center text-slate-400">
Pilih lantai 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">
@@ -322,7 +332,6 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
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>
</div>
)}
@@ -382,192 +391,192 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
<Spinner />
) : summary ? (
<>
<section className="grid gap-3 xl:grid-cols-[1.25fr_2.35fr]">
<div className="space-y-3">
<article className={cardClass}>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-red-50/70 p-4">
<h2 className="text-sm font-semibold text-slate-700">
<i className="fa-solid fa-temperature-half mr-2 text-red-500" />
Suhu Rata-Rata
</h2>
<p className="mt-2 text-3xl font-bold text-slate-700">
{display(summary.temp, ' °C')}
</p>
<p className="mt-1 text-xs text-slate-500">
{display(summary.minTemp, ' °C')} – {display(summary.maxTemp, ' °C')}
</p>
<section className="grid gap-3 xl:grid-cols-[1.25fr_2.35fr]">
<div className="space-y-3">
<article className={cardClass}>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-red-50/70 p-4">
<h2 className="text-sm font-semibold text-slate-700">
<i className="fa-solid fa-temperature-half mr-2 text-red-500" />
Suhu Rata-Rata
</h2>
<p className="mt-2 text-3xl font-bold text-slate-700">
{display(summary.temp, ' °C')}
</p>
<p className="mt-1 text-xs text-slate-500">
{display(summary.minTemp, ' °C')} – {display(summary.maxTemp, ' °C')}
</p>
</div>
<div className="rounded-lg bg-sky-50/80 p-4">
<h2 className="text-sm font-semibold text-slate-700">
<i className="fa-solid fa-temperature-half mr-2 text-sky-600" />
Experience Suhu
</h2>
<p className="mt-2 text-3xl font-bold text-sky-600">
{display(summary.experienceTemp, ' °C')}
</p>
<p className="mt-1 text-xs text-slate-500">
{display(summary.minExp, ' °C')} – {display(summary.maxExp, ' °C')}
</p>
</div>
</div>
<div className="rounded-lg bg-sky-50/80 p-4">
<h2 className="text-sm font-semibold text-slate-700">
<i className="fa-solid fa-temperature-half mr-2 text-sky-600" />
Experience Suhu
</h2>
<p className="mt-2 text-3xl font-bold text-sky-600">
{display(summary.experienceTemp, ' °C')}
</p>
<p className="mt-1 text-xs text-slate-500">
{display(summary.minExp, ' °C')} – {display(summary.maxExp, ' °C')}
</p>
</div>
</div>
</article>
</article>
<article className={cardClass}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{[
{
label: 'Kelembapan',
value: display(summary.humidity, ' %'),
icon: 'fa-droplet',
color: 'text-blue-500',
chartKey: 'humidity',
},
{
label: 'Angin',
value: display(summary.windSpeed, ' m/s'),
icon: 'fa-wind',
color: 'text-indigo-500',
chartKey: 'wind',
},
{
label: 'Air total',
value: display(summary.waterTotal, ' L'),
icon: 'fa-water',
color: 'text-cyan-500',
chartKey: 'water',
},
{
label: 'Umur',
value: dayAge == null ? 'N/A' : `${dayAge} hari`,
icon: 'fa-dove',
color: 'text-amber-600',
},
{
label: 'Tanggal',
value: formatDateId(selectedDate),
icon: 'fa-calendar-day',
color: 'text-slate-500',
},
{
label: 'Lantai',
value:
flocks.find((flock) => flock.id === selectedFlockId)?.flock_name ?? 'N/A',
icon: 'fa-warehouse',
color: 'text-slate-500',
},
].map((tile) => {
const inner = (
<>
<p className="text-sm text-slate-500">
<i className={`fa-solid ${tile.icon} mr-2 ${tile.color}`} />
{tile.label}
</p>
<p className="mt-2 text-xl font-semibold text-slate-700">{tile.value}</p>
</>
);
if (!tile.chartKey) {
<article className={cardClass}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{[
{
label: 'Kelembapan',
value: display(summary.humidity, ' %'),
icon: 'fa-droplet',
color: 'text-blue-500',
chartKey: 'humidity',
},
{
label: 'Angin',
value: display(summary.windSpeed, ' m/s'),
icon: 'fa-wind',
color: 'text-indigo-500',
chartKey: 'wind',
},
{
label: 'Air total',
value: display(summary.waterTotal, ' L'),
icon: 'fa-water',
color: 'text-cyan-500',
chartKey: 'water',
},
{
label: 'Umur',
value: dayAge == null ? 'N/A' : `${dayAge} hari`,
icon: 'fa-dove',
color: 'text-amber-600',
},
{
label: 'Tanggal',
value: formatDateId(selectedDate),
icon: 'fa-calendar-day',
color: 'text-slate-500',
},
{
label: 'Lantai',
value:
flocks.find((flock) => flock.id === selectedFlockId)?.flock_name ?? 'N/A',
icon: 'fa-warehouse',
color: 'text-slate-500',
},
].map((tile) => {
const inner = (
<>
<p className="text-sm text-slate-500">
<i className={`fa-solid ${tile.icon} mr-2 ${tile.color}`} />
{tile.label}
</p>
<p className="mt-2 text-xl font-semibold text-slate-700">{tile.value}</p>
</>
);
if (!tile.chartKey) {
return (
<div
key={tile.label}
className="min-h-[104px] rounded-xl border border-slate-200 bg-white p-3"
>
{inner}
</div>
);
}
return (
<div
<button
key={tile.label}
className="min-h-[104px] rounded-xl border border-slate-200 bg-white p-3"
type="button"
onClick={() =>
setSelectedChartKey(tile.chartKey as 'humidity' | 'wind' | 'water')
}
className="min-h-[104px] cursor-pointer rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:bg-slate-50"
aria-label={`Lihat grafik ${tile.label}`}
>
{inner}
</div>
</button>
);
}
return (
<button
key={tile.label}
type="button"
onClick={() =>
setSelectedChartKey(tile.chartKey as 'humidity' | 'wind' | 'water')
}
className="min-h-[104px] cursor-pointer rounded-xl border border-slate-200 bg-white p-3 text-left transition hover:bg-slate-50"
aria-label={`Lihat grafik ${tile.label}`}
>
{inner}
</button>
);
})}
</div>
</article>
<IotPanelExperienceTempCard
calc={experienceCalc}
apiExperienceTemp={summary.experienceTemp}
/>
</div>
<div className="space-y-3">
<article className={`${cardClass} min-h-[390px]`}>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="font-semibold text-slate-800">Histori Suhu Harian</h2>
<p className="mt-1 text-xs text-slate-500">
<span className="inline-flex items-center gap-1 mr-3">
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
Garis merah = suhu rata-rata
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full bg-sky-600" />
Garis biru = experience suhu
</span>
</p>
})}
</div>
<span className="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
{formatDateId(selectedDate)}
</span>
</div>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartRows}
margin={{ top: 8, right: 12, left: -18, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} stroke="#64748b" />
<YAxis tick={{ fontSize: 11 }} stroke="#64748b" />
<Tooltip
formatter={(value, name) => [
`${formatNumber(Number(value))} °C`,
name as string,
]}
/>
<Line
type="monotone"
dataKey="average"
name="Suhu rata-rata"
stroke="#ef4444"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="experience"
name="Experience suhu"
stroke="#0284c7"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</article>
</article>
<IotPanelChillFactorCard dayAge={dayAge} chillFactor={experienceCalc.chillFactor} />
</div>
</section>
</>
) : (
<div className={cardClass}>
<p className="py-8 text-center text-slate-400">
{dates.length === 0
? 'Belum ada data panel IoT untuk lantai ini. Refresh halaman setelah data tersedia.'
: `Belum ada data panel IoT untuk ${selectedDate ? formatDateId(selectedDate) : 'tanggal ini'}.`}
</p>
</div>
))}
<IotPanelExperienceTempCard
calc={experienceCalc}
apiExperienceTemp={summary.experienceTemp}
/>
</div>
<div className="space-y-3">
<article className={`${cardClass} min-h-[390px]`}>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="font-semibold text-slate-800">Histori Suhu Harian</h2>
<p className="mt-1 text-xs text-slate-500">
<span className="inline-flex items-center gap-1 mr-3">
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
Garis merah = suhu rata-rata
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full bg-sky-600" />
Garis biru = experience suhu
</span>
</p>
</div>
<span className="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
{formatDateId(selectedDate)}
</span>
</div>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartRows}
margin={{ top: 8, right: 12, left: -18, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} stroke="#64748b" />
<YAxis tick={{ fontSize: 11 }} stroke="#64748b" />
<Tooltip
formatter={(value, name) => [
`${formatNumber(Number(value))} °C`,
name as string,
]}
/>
<Line
type="monotone"
dataKey="average"
name="Suhu rata-rata"
stroke="#ef4444"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="experience"
name="Experience suhu"
stroke="#0284c7"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</article>
<IotPanelChillFactorCard dayAge={dayAge} chillFactor={experienceCalc.chillFactor} />
</div>
</section>
</>
) : (
<div className={cardClass}>
<p className="py-8 text-center text-slate-400">
{dates.length === 0
? 'Belum ada data panel IoT untuk lantai ini. Refresh halaman setelah data tersedia.'
: `Belum ada data panel IoT untuk ${selectedDate ? formatDateId(selectedDate) : 'tanggal ini'}.`}
</p>
</div>
))}
{selectedFlockId != null && dayRows.length > 0 && (
<article className={cardClass}>
@@ -591,8 +600,12 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
{dayRows.map((row) => (
<tr key={row.id} className="border-t border-slate-100">
<td className="px-4 py-2">{formatTime(row.timestamp)}</td>
<td className="px-4 py-2 text-right">{formatNumber(row.average_temperature)} °C</td>
<td className="px-4 py-2 text-right">{formatNumber(row.experience_temperature)} °C</td>
<td className="px-4 py-2 text-right">
{formatNumber(row.average_temperature)} °C
</td>
<td className="px-4 py-2 text-right">
{formatNumber(row.experience_temperature)} °C
</td>
<td className="px-4 py-2 text-right">{formatNumber(row.humidity)} %</td>
<td className="px-4 py-2 text-right">{formatNumber(row.wind_speed)} m/s</td>
<td className="px-4 py-2 text-right">{formatNumber(row.water_total)} L</td>
+86 -85
View File
@@ -55,7 +55,9 @@ const SettingsPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [siteName, setSiteName] = useState('');
const [kandangName, setKandangName] = useState('');
const [feedInButtonUrls, setFeedInButtonUrls] = useState<FeedInButtonUrl[]>([emptyFeedInButtonUrl()]);
const [feedInButtonUrls, setFeedInButtonUrls] = useState<FeedInButtonUrl[]>([
emptyFeedInButtonUrl(),
]);
const [flockName, setFlockName] = useState('');
const [editingCycleId, setEditingCycleId] = useState<number | null>(null);
const [startDate, setStartDate] = useState(defaultCycleForm().startDate);
@@ -135,7 +137,9 @@ const SettingsPage: React.FC = () => {
React.useEffect(() => {
const urls = selectedKandang?.feed_in_button_urls ?? [];
setFeedInButtonUrls(urls.length > 0 ? urls.map((entry) => ({ ...entry })) : [emptyFeedInButtonUrl()]);
setFeedInButtonUrls(
urls.length > 0 ? urls.map((entry) => ({ ...entry })) : [emptyFeedInButtonUrl()]
);
}, [selectedKandang?.id, selectedKandang?.feed_in_button_urls]);
const wrap = async (fn: () => Promise<void>) => {
@@ -155,10 +159,7 @@ const SettingsPage: React.FC = () => {
return (
<div>
<PageHeader
title="Pengaturan"
subtitle="Site, kandang, lantai (IoT), dan siklus produksi"
/>
<PageHeader title="Pengaturan" subtitle="Site, kandang, lantai (IoT), dan siklus produksi" />
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
@@ -284,9 +285,7 @@ const SettingsPage: React.FC = () => {
type="button"
className="border border-red-600 text-red-600 px-4 py-2 rounded-lg text-sm font-semibold hover:bg-red-50 disabled:opacity-50"
disabled={
saving ||
!registerCode ||
slugify(registerCode) === pusatRegistration.code
saving || !registerCode || slugify(registerCode) === pusatRegistration.code
}
onClick={() => {
const code = slugify(registerCode);
@@ -438,7 +437,9 @@ const SettingsPage: React.FC = () => {
type="button"
className="text-xs text-red-500 hover:underline"
onClick={() =>
setFeedInButtonUrls((rows) => rows.filter((_, rowIndex) => rowIndex !== index))
setFeedInButtonUrls((rows) =>
rows.filter((_, rowIndex) => rowIndex !== index)
)
}
>
Hapus URL
@@ -510,42 +511,46 @@ const SettingsPage: React.FC = () => {
{flocks.map((flock) => {
const webhookUrl = `${window.location.origin}/api/v1/panel-iot/push/${flock.id}/`;
return (
<li key={flock.id} className="py-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-700">{flock.flock_name}</span>
{isSuperAdmin && (
<button
type="button"
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
await api.flocks.remove(flock.id);
flash('Lantai dihapus.');
})
}
>
Hapus
</button>
)}
</div>
<div className="space-y-1">
<label className="block text-xs text-gray-500">Webhook URL (IoT Panel → Dashboard)</label>
<div className="flex items-center gap-2 rounded-lg bg-gray-50 border border-gray-200 px-3 py-2">
<code className="flex-1 text-xs text-gray-700 break-all font-mono">{webhookUrl}</code>
<button
type="button"
className="shrink-0 text-xs text-red-600 hover:underline font-semibold"
onClick={() => {
void navigator.clipboard.writeText(webhookUrl);
flash('Webhook URL disalin.');
}}
>
Salin
</button>
<li key={flock.id} className="py-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-700">{flock.flock_name}</span>
{isSuperAdmin && (
<button
type="button"
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
await api.flocks.remove(flock.id);
flash('Lantai dihapus.');
})
}
>
Hapus
</button>
)}
</div>
</div>
</li>
);
<div className="space-y-1">
<label className="block text-xs text-gray-500">
Webhook URL (IoT Panel → Dashboard)
</label>
<div className="flex items-center gap-2 rounded-lg bg-gray-50 border border-gray-200 px-3 py-2">
<code className="flex-1 text-xs text-gray-700 break-all font-mono">
{webhookUrl}
</code>
<button
type="button"
className="shrink-0 text-xs text-red-600 hover:underline font-semibold"
onClick={() => {
void navigator.clipboard.writeText(webhookUrl);
flash('Webhook URL disalin.');
}}
>
Salin
</button>
</div>
</div>
</li>
);
})}
</ul>
)}
@@ -638,42 +643,42 @@ const SettingsPage: React.FC = () => {
{cycles.map((cycle) => {
const status = resolveCycleStatus(cycle);
return (
<li key={cycle.id} className="py-2 text-sm text-gray-700">
<div className="flex justify-between gap-2">
<button className="text-left" onClick={() => setSelectedCycleId(cycle.id)}>
{formatDateId(cycle.start_date)}
{cycle.end_date ? ` – ${formatDateId(cycle.end_date)}` : ''}
<span className="block text-xs text-gray-400">
{statusLabel(cycle)}
{status === 'pending_close' && cycle.proposed_end_date
? ` · usulan tutup ${formatDateId(cycle.proposed_end_date)}`
: ''}{' '}
· {cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
</span>
</button>
<div className="flex flex-col items-end gap-1">
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => beginCycleEdit(cycle)}
>
Ubah
</button>
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
if (editingCycleId === cycle.id) resetCycleForm();
await api.cycles.remove(cycle.id);
flash('Siklus dihapus.');
})
}
>
Hapus
<li key={cycle.id} className="py-2 text-sm text-gray-700">
<div className="flex justify-between gap-2">
<button className="text-left" onClick={() => setSelectedCycleId(cycle.id)}>
{formatDateId(cycle.start_date)}
{cycle.end_date ? ` – ${formatDateId(cycle.end_date)}` : ''}
<span className="block text-xs text-gray-400">
{statusLabel(cycle)}
{status === 'pending_close' && cycle.proposed_end_date
? ` · usulan tutup ${formatDateId(cycle.proposed_end_date)}`
: ''}{' '}
· {cycle.doc_in_count} ekor · {cycle.chick_in_weight} g
</span>
</button>
<div className="flex flex-col items-end gap-1">
<button
className="text-xs text-blue-600 hover:underline"
onClick={() => beginCycleEdit(cycle)}
>
Ubah
</button>
<button
className="text-xs text-red-500 hover:underline"
onClick={() =>
void wrap(async () => {
if (editingCycleId === cycle.id) resetCycleForm();
await api.cycles.remove(cycle.id);
flash('Siklus dihapus.');
})
}
>
Hapus
</button>
</div>
</div>
</div>
</li>
);
</li>
);
})}
</ul>
</section>
@@ -681,9 +686,7 @@ const SettingsPage: React.FC = () => {
<ConfirmDialog
open={pusatConfirm != null}
title={
pusatConfirm?.kind === 'change-code' ? 'Ubah kode pusat?' : 'Daftar ke pusat?'
}
title={pusatConfirm?.kind === 'change-code' ? 'Ubah kode pusat?' : 'Daftar ke pusat?'}
message={
pusatConfirm?.kind === 'change-code' ? (
<p>
@@ -698,9 +701,7 @@ const SettingsPage: React.FC = () => {
</p>
) : null
}
confirmLabel={
pusatConfirm?.kind === 'change-code' ? 'Ya, ubah kode' : 'Ya, daftar'
}
confirmLabel={pusatConfirm?.kind === 'change-code' ? 'Ya, ubah kode' : 'Ya, daftar'}
busy={saving}
onCancel={() => {
if (!saving) setPusatConfirm(null);
+13 -3
View File
@@ -3,7 +3,12 @@ import { api, ApiError, errorMessage } from '../services/apiClient.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 {
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';
@@ -204,17 +209,22 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings
agg: 'lastValid',
replaces: 'averageWeight',
},
// Target weight exists from day 0; 0 means not set yet
targetBobot_gram: {
field: 'target_gram',
agg: 'lastValid',
agg: 'last',
replaces: 'targetWeight',
emptyAs: 0,
},
// ADG: 0 is valid (no gain yet), use 'last' with emptyAs: 0
pertambahanHarian_gramPerHari: {
field: 'adg_gram_per_hari',
agg: 'lastValid',
agg: 'last',
replaces: 'adg',
emptyAs: 0,
},
},
drop: ['averageWeight', 'targetWeight', 'adg'],
}}
/>
) : (
+164 -163
View File
@@ -5,173 +5,174 @@ import FeedSackCountingPage from '../FeedSackCountingPage.tsx';
const { karungRows, manualRows, cycle, mismatchedKarungRows, mismatchedManualRows } = vi.hoisted(
() => {
const cycle = {
id: 1,
kandang: 1,
flock: 1,
total_days: 35,
current_day: 2,
start_date: '2026-08-10',
end_date: '2026-09-13',
chick_in_weight: 45,
doc_in_count: 20000,
status: 'active',
feed_initial_balance: 0,
created_at: '',
updated_at: '',
};
const karungRows = [
{
const cycle = {
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-10',
in_today: 10,
out_today: 4,
in_total: 10,
out_total: 4,
feed_use_today: 4,
feed_use_total: 4,
flock: 1,
total_days: 35,
current_day: 2,
start_date: '2026-08-10',
end_date: '2026-09-13',
chick_in_weight: 45,
doc_in_count: 20000,
status: 'active',
feed_initial_balance: 0,
created_at: '',
updated_at: '',
},
{
id: 2,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-11',
in_today: 12,
out_today: 6,
in_total: 22,
out_total: 10,
feed_use_today: 6,
feed_use_total: 10,
created_at: '',
updated_at: '',
},
];
const manualRows = [
{
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-10',
age_manual: 0,
mortality_manual: 0,
mortality_manual_total: 0,
feed_in_manual: 10,
feed_use_manual: 4,
feed_in_manual_total: 10,
feed_use_manual_total: 4,
feed_out_manual: 0,
feed_out_manual_total: 0,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
{
id: 2,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-11',
age_manual: 1,
mortality_manual: 0,
mortality_manual_total: 0,
feed_in_manual: 10,
feed_use_manual: 7,
feed_in_manual_total: 20,
feed_use_manual_total: 11,
feed_out_manual: 0,
feed_out_manual_total: 0,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
];
// IoT already on the newer day; manual only exists for the previous day.
const mismatchedKarungRows = [
{
id: 10,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-09-02',
in_today: 192,
out_today: 1,
in_total: 192,
out_total: 1,
feed_use_today: 7,
feed_use_total: 7,
created_at: '',
updated_at: '',
},
{
id: 11,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-09-03',
in_today: 50,
out_today: 0,
in_total: 242,
out_total: 1,
feed_use_today: 4,
feed_use_total: 11,
created_at: '',
updated_at: '',
},
];
const mismatchedManualRows = [
{
id: 10,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-09-02',
age_manual: 0,
mortality_manual: 0,
mortality_manual_total: 0,
feed_in_manual: 192,
feed_use_manual: 7,
feed_in_manual_total: 192,
feed_use_manual_total: 7,
feed_out_manual: 1,
feed_out_manual_total: 1,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
];
return { karungRows, manualRows, cycle, mismatchedKarungRows, mismatchedManualRows };
});
};
const karungRows = [
{
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-10',
in_today: 10,
out_today: 4,
in_total: 10,
out_total: 4,
feed_use_today: 4,
feed_use_total: 4,
created_at: '',
updated_at: '',
},
{
id: 2,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-11',
in_today: 12,
out_today: 6,
in_total: 22,
out_total: 10,
feed_use_today: 6,
feed_use_total: 10,
created_at: '',
updated_at: '',
},
];
const manualRows = [
{
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-10',
age_manual: 0,
mortality_manual: 0,
mortality_manual_total: 0,
feed_in_manual: 10,
feed_use_manual: 4,
feed_in_manual_total: 10,
feed_use_manual_total: 4,
feed_out_manual: 0,
feed_out_manual_total: 0,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
{
id: 2,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-11',
age_manual: 1,
mortality_manual: 0,
mortality_manual_total: 0,
feed_in_manual: 10,
feed_use_manual: 7,
feed_in_manual_total: 20,
feed_use_manual_total: 11,
feed_out_manual: 0,
feed_out_manual_total: 0,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
];
// IoT already on the newer day; manual only exists for the previous day.
const mismatchedKarungRows = [
{
id: 10,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-09-02',
in_today: 192,
out_today: 1,
in_total: 192,
out_total: 1,
feed_use_today: 7,
feed_use_total: 7,
created_at: '',
updated_at: '',
},
{
id: 11,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-09-03',
in_today: 50,
out_today: 0,
in_total: 242,
out_total: 1,
feed_use_today: 4,
feed_use_total: 11,
created_at: '',
updated_at: '',
},
];
const mismatchedManualRows = [
{
id: 10,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-09-02',
age_manual: 0,
mortality_manual: 0,
mortality_manual_total: 0,
feed_in_manual: 192,
feed_use_manual: 7,
feed_in_manual_total: 192,
feed_use_manual_total: 7,
feed_out_manual: 1,
feed_out_manual_total: 1,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
];
return { karungRows, manualRows, cycle, mismatchedKarungRows, mismatchedManualRows };
}
);
vi.mock('../../services/apiClient.ts', () => ({
api: {
+39 -33
View File
@@ -2,39 +2,45 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SettingsPage from '../SettingsPage.tsx';
const { createKandangMock, createFlockMock, updateFlockMock, createCycleMock, updateCycleMock, reloadMock, farmState } = vi.hoisted(
() => ({
createKandangMock: vi.fn(),
createFlockMock: vi.fn(),
updateFlockMock: vi.fn(),
createCycleMock: vi.fn(),
updateCycleMock: vi.fn(),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
sites: [{ id: 'site-a', site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
kandangs: [] as Array<{
id: number;
kandang_name: string;
site: string;
created_at: string;
updated_at: string;
}>,
flocks: [] as Array<{
id: number;
flock_name: string;
iot_api_url?: string;
kandang: number;
created_at: string;
updated_at: string;
}>,
cycles: [] as unknown[],
selectedSiteId: 'site-a',
selectedKandangId: null as number | null,
selectedCycleId: null as number | null,
selectedCycle: null as unknown,
},
})
);
const {
createKandangMock,
createFlockMock,
updateFlockMock,
createCycleMock,
updateCycleMock,
reloadMock,
farmState,
} = vi.hoisted(() => ({
createKandangMock: vi.fn(),
createFlockMock: vi.fn(),
updateFlockMock: vi.fn(),
createCycleMock: vi.fn(),
updateCycleMock: vi.fn(),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
sites: [{ id: 'site-a', site_name: 'Site A', user: 99, created_at: '', updated_at: '' }],
kandangs: [] as Array<{
id: number;
kandang_name: string;
site: string;
created_at: string;
updated_at: string;
}>,
flocks: [] as Array<{
id: number;
flock_name: string;
iot_api_url?: string;
kandang: number;
created_at: string;
updated_at: string;
}>,
cycles: [] as unknown[],
selectedSiteId: 'site-a',
selectedKandangId: null as number | null,
selectedCycleId: null as number | null,
selectedCycle: null as unknown,
},
}));
vi.mock('../../services/apiClient.ts', () => ({
api: {
+5 -13
View File
@@ -140,8 +140,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
const panen = manual?.harvest_manual ?? kpi?.harvest ?? 0;
// Database menyimpan berat panen dalam gram; tampilkan selalu dalam kg.
const beratPanen = (manual?.harvest_weight_manual ?? kpi?.harvest_weight ?? 0) / 1000;
const stockAkhir =
stockAkhirFromKpi(kpi) ?? Math.max(0, stockAwal - mati - panen);
const stockAkhir = stockAkhirFromKpi(kpi) ?? Math.max(0, stockAwal - mati - panen);
const chickenCount = counting?.total_count;
// Akurasi Chicken Counting (IOT) terhadap stock akhir manual:
@@ -192,8 +191,7 @@ 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 > 0 ? (totalMortality / initialPopulation) * 100 : null;
const mortalityRate = initialPopulation > 0 ? (totalMortality / initialPopulation) * 100 : null;
return {
initialPopulation,
@@ -212,16 +210,13 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
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))
)
? 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;
const insightRows = lastDataDay != null ? rows.filter((row) => row.day <= lastDataDay) : rows;
return buildCountingInsightContext({
kandangId: selectedKandang.id,
kandangName: selectedKandang.kandang_name,
@@ -761,10 +756,7 @@ const DataManagementPage: React.FC<{ onSettings: () => void }> = ({ onSettings }
<tbody className="divide-y divide-gray-200">
{filteredRecords.length === 0 ? (
<tr>
<td
colSpan={11}
className="px-6 py-12 text-center text-gray-500"
>
<td colSpan={11} className="px-6 py-12 text-center text-gray-500">
<i className="fa-solid fa-inbox text-4xl mb-3 block text-gray-300"></i>
Tidak ada data yang ditemukan
</td>
@@ -25,7 +25,14 @@ describe('CountingAccuracyWarningBanner', () => {
it('renders over-count fraud / AI checklist', () => {
render(
<CountingAccuracyWarningBanner
warning={{ ...base, iotCount: 1060, stockAkhir: 1000, accuracy: 94.3, ratioPercent: 106, direction: 'over' }}
warning={{
...base,
iotCount: 1060,
stockAkhir: 1000,
accuracy: 94.3,
ratioPercent: 106,
direction: 'over',
}}
/>
);
expect(screen.getByText(/lebih tinggi dari stock manual/i)).toBeInTheDocument();
@@ -98,8 +98,8 @@ const DashboardCloseCycleControls: React.FC = () => {
message={
<p>
Permintaan tutup akan dikirim ke pusat dengan tanggal usulan{' '}
<strong>{formatDateId(proposedCloseDate)}</strong>. GM harus menyetujui sebelum
siklus ditutup. Anda masih bisa membatalkan selama status menunggu GM.
<strong>{formatDateId(proposedCloseDate)}</strong>. GM harus menyetujui sebelum siklus
ditutup. Anda masih bisa membatalkan selama status menunggu GM.
</p>
}
confirmLabel="Ya, ajukan tutup"
@@ -126,8 +126,8 @@ const DashboardCloseCycleControls: React.FC = () => {
title="Batalkan permintaan tutup?"
message={
<p>
Permintaan tutup ke pusat akan dibatalkan. Siklus tetap aktif dan dapat diajukan
tutup lagi nanti.
Permintaan tutup ke pusat akan dibatalkan. Siklus tetap aktif dan dapat diajukan tutup
lagi nanti.
</p>
}
confirmLabel="Ya, batalkan"
+1 -3
View File
@@ -34,9 +34,7 @@ const DashboardReportCtas: React.FC<DashboardReportCtasProps> = ({
{label(hasEndCycleSnapshot)} Akhir Siklus
</button>
</div>
{!cycleClosed && (
<p className="text-xs text-slate-500">Tersedia setelah siklus ditutup</p>
)}
{!cycleClosed && <p className="text-xs text-slate-500">Tersedia setelah siklus ditutup</p>}
</div>
);
@@ -121,10 +121,7 @@ const ReportPreviewOverlay: React.FC<Props> = ({ open, reportType, onClose }) =>
await exportElementToPdf(printRef.current, filename);
}, [payload, isEndCycle]);
const summary = useMemo(
() => (payload ? buildReportSummary(payload) : null),
[payload]
);
const summary = useMemo(() => (payload ? buildReportSummary(payload) : null), [payload]);
if (!open) return null;
@@ -2,35 +2,31 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DashboardCloseCycleControls from '../DashboardCloseCycleControls.tsx';
const {
requestCloseMock,
cancelCloseRequestMock,
reloadMock,
farmState,
authState,
} = vi.hoisted(() => ({
requestCloseMock: vi.fn().mockResolvedValue({}),
cancelCloseRequestMock: vi.fn().mockResolvedValue({}),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
selectedCycle: {
id: 5,
start_date: '2026-09-01',
end_date: null,
status: 'active',
proposed_end_date: null,
visible_through_date: '2026-09-10',
} as Record<string, unknown> | null,
},
authState: {
user: {
id: 1,
user_name: 'staff',
display_name: 'Staff',
status: 'active',
const { requestCloseMock, cancelCloseRequestMock, reloadMock, farmState, authState } = vi.hoisted(
() => ({
requestCloseMock: vi.fn().mockResolvedValue({}),
cancelCloseRequestMock: vi.fn().mockResolvedValue({}),
reloadMock: vi.fn().mockResolvedValue(undefined),
farmState: {
selectedCycle: {
id: 5,
start_date: '2026-09-01',
end_date: null,
status: 'active',
proposed_end_date: null,
visible_through_date: '2026-09-10',
} as Record<string, unknown> | null,
},
},
}));
authState: {
user: {
id: 1,
user_name: 'staff',
display_name: 'Staff',
status: 'active',
},
},
})
);
vi.mock('../../../services/apiClient.ts', () => ({
api: {
+1 -3
View File
@@ -38,9 +38,7 @@ const FeedInButtonGrid: React.FC<FeedInButtonGridProps> = ({ kandangs, variant =
>
<i className="fa-solid fa-link-slash text-3xl text-gray-300 mb-3" />
<p className="text-gray-600 font-medium">Belum ada URL Feed In yang dikonfigurasi.</p>
<p className="text-sm text-gray-500 mt-1">
Atur alamat per kandang di menu Pengaturan.
</p>
<p className="text-sm text-gray-500 mt-1">Atur alamat per kandang di menu Pengaturan.</p>
</div>
);
}
+28 -1
View File
@@ -920,11 +920,38 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
{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">Total Karung Masuk</span>
<div>
<span className="text-sm text-gray-600 font-medium block">
Total Karung Masuk
</span>
{(typeof displayContext.saldo_awal_karung === 'number' ||
typeof displayContext.total_karung_masuk_iot === 'number') && (
<span className="text-xs text-gray-400">
{[
typeof displayContext.saldo_awal_karung === 'number'
? `Saldo Awal: ${displayContext.saldo_awal_karung.toLocaleString('id-ID')}`
: null,
typeof displayContext.total_karung_masuk_iot === 'number'
? `Masuk IoT: ${displayContext.total_karung_masuk_iot.toLocaleString('id-ID')}`
: null,
]
.filter(Boolean)
.join(' + ')}
</span>
)}
</div>
<span className="text-sm font-bold text-gray-900">
{renderValue(feedMasukTotal(displayContext), '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">
Total Karung Pakan Dituang
</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.total_karung_dituang_iot, 'Karung')}
</span>
</div>
</>
)}
+2
View File
@@ -112,6 +112,8 @@ services:
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}
LLM_NUM_CTX: ${LLM_NUM_CTX:-8192}
LLM_NUM_PREDICT: ${LLM_NUM_PREDICT:-768}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
+3 -1
View File
@@ -42,7 +42,9 @@ describe('apiClient', () => {
it('sends CSRF token on POST', async () => {
document.cookie = 'csrftoken=abc123; path=/';
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, user_name: 'admin', display_name: 'Admin' }));
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: 1, user_name: 'admin', display_name: 'Admin' }));
vi.stubGlobal('fetch', fetchMock);
await api.auth.login('admin', 'admin123');
@@ -50,7 +50,9 @@ function kpi(partial: Partial<KPI> & Pick<KPI, 'date' | 'chicken_life'>): KPI {
};
}
function counting(partial: Partial<ChickenCounting> & Pick<ChickenCounting, 'date' | 'total_count'>): ChickenCounting {
function counting(
partial: Partial<ChickenCounting> & Pick<ChickenCounting, 'date' | 'total_count'>
): ChickenCounting {
return {
id: 1,
cycle: 1,
+1 -4
View File
@@ -1,8 +1,5 @@
import { describe, expect, it } from 'vitest';
import {
buildEndCycleRollups,
type DashboardInsightMetrics,
} from '../insightContextBuilders.ts';
import { buildEndCycleRollups, type DashboardInsightMetrics } from '../insightContextBuilders.ts';
const baseMetrics = (): DashboardInsightMetrics => ({
hari_ke: 21,
+98 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { scopeScalarsToTimeframe, UNAVAILABLE } from '../insightScope.ts';
import { scopeContextToTimeframe, scopeScalarsToTimeframe, UNAVAILABLE } from '../insightScope.ts';
/**
* Real shape of the FCR page's 7-day history for this cycle: the IoT scale
@@ -78,3 +78,100 @@ describe('harian periode label starts at day 0', () => {
expect(out.fcr_out).toBe(1.3);
});
});
describe('replaces writes to both new key and original key for backend', () => {
it('writes to fcr_out AND fcr_terakhir when replaces is set', () => {
const rows = [
{ hari: 30, fcr_aktual: 1.7 },
{ hari: 35, fcr_aktual: 1.72 },
{ hari: 39, fcr_aktual: 1.68 },
{ hari: 48, fcr_aktual: 1.72 },
];
// First slice the history to day 39, then compute scalars
const sliced = scopeContextToTimeframe(
{ tren_fcr_harian: rows, fcr_terakhir: 1.72 },
'harian',
39
);
const out = scopeScalarsToTimeframe(
sliced as Record<string, unknown>,
'harian',
{
historyKey: 'tren_fcr_harian',
derive: {
fcrTerakhir_rasio: { field: 'fcr_aktual', agg: 'lastValid', replaces: 'fcr_terakhir' },
},
},
39
);
// New key with unit suffix
expect(out.fcrTerakhir_rasio).toBe(1.68);
// Original key for backend grading
expect(out.fcr_terakhir).toBe(1.68);
});
it('works for EEF page with multiple replaces', () => {
const rows = [
{ hari: 30, eef: 340, fcr: 1.55, persen_hidup: 96.5, bw_panen: 1800, umur_panen_hari: 30 },
{ hari: 35, eef: 350, fcr: 1.52, persen_hidup: 96.2, bw_panen: 2100, umur_panen_hari: 35 },
{ hari: 39, eef: 355, fcr: 1.5, persen_hidup: 95.8, bw_panen: 2250, umur_panen_hari: 39 },
{ hari: 48, eef: 229, fcr: 1.72, persen_hidup: 90.1, bw_panen: 2380, umur_panen_hari: 48 },
];
// First slice the history to day 39, then compute scalars
const sliced = scopeContextToTimeframe(
{
tren_eef_harian: rows,
eef_terakhir: 229,
fcr_terakhir: 1.72,
persen_hidup: 90.1,
bw_panen_gram: 2380,
umur_panen_rata_hari: 48,
},
'harian',
39
);
const out = scopeScalarsToTimeframe(
sliced as Record<string, unknown>,
'harian',
{
historyKey: 'tren_eef_harian',
derive: {
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',
},
umurPanenTerakhir_hari: {
field: 'umur_panen_hari',
agg: 'lastValid',
replaces: 'umur_panen_rata_hari',
},
},
},
39
);
// Verify day 39 values are used, not day 48
expect(out.eefTerakhir_indeksTanpaSatuan).toBe(355);
expect(out.fcrTerakhir_rasio).toBe(1.5);
expect(out.persenHidupTerakhir_persen).toBe(95.8);
expect(out.bobotPanenTerakhir_gram).toBe(2250);
expect(out.umurPanenTerakhir_hari).toBe(39);
// Verify original keys are also updated for backend
expect(out.eef_terakhir).toBe(355);
expect(out.fcr_terakhir).toBe(1.5);
expect(out.persen_hidup).toBe(95.8);
expect(out.bw_panen_gram).toBe(2250);
expect(out.umur_panen_rata_hari).toBe(39);
});
});
+1 -6
View File
@@ -30,12 +30,7 @@ describe('latestValidRatio', () => {
it('returns the last finite value walking backward', () => {
expect(
latestValidRatio(
[
{ iotFcr: 1.1 },
{ iotFcr: null },
{ iotFcr: 1.4 },
{ iotFcr: null },
],
[{ iotFcr: 1.1 }, { iotFcr: null }, { iotFcr: 1.4 }, { iotFcr: null }],
'iotFcr'
)
).toBe(1.4);
+1 -2
View File
@@ -98,8 +98,7 @@ export function buildInMemoryReport(input: BuildInMemoryReportInput): DashboardR
const kpi = kpiByAge.get(day) ?? null;
const manual = manualByDay.get(day) ?? null;
const iotCount = iot?.total_count ?? null;
const mortality =
manual?.mortality_manual ?? kpi?.mortality ?? iot?.mortality_count ?? null;
const mortality = manual?.mortality_manual ?? kpi?.mortality ?? iot?.mortality_count ?? null;
const harvest = manual?.harvest_manual ?? kpi?.harvest ?? null;
const harvestWeight = manual?.harvest_weight_manual ?? kpi?.harvest_weight ?? null;
const mati = mortality ?? 0;
+1 -2
View File
@@ -52,8 +52,7 @@ function stockAkhirByDate(
const stockAwal = day === 0 ? cycle.doc_in_count || 0 : previousStockAkhir;
const mati = manual?.mortality_manual ?? kpi?.mortality ?? 0;
const panen = manual?.harvest_manual ?? kpi?.harvest ?? 0;
const stockAkhir =
stockAkhirFromKpi(kpi) ?? Math.max(0, stockAwal - mati - panen);
const stockAkhir = stockAkhirFromKpi(kpi) ?? Math.max(0, stockAwal - mati - panen);
result.set(date, stockAkhir);
previousStockAkhir = stockAkhir;
}
+5 -1
View File
@@ -95,7 +95,11 @@ export function resolveCycleStatus(cycle: {
if (cycle.status === 'pending_close' || cycle.status === 'closed' || cycle.status === 'active') {
return cycle.status;
}
if (cycle.end_date && cycle.visible_through_date && cycle.visible_through_date >= cycle.end_date) {
if (
cycle.end_date &&
cycle.visible_through_date &&
cycle.visible_through_date >= cycle.end_date
) {
return 'closed';
}
return 'active';
+22 -40
View File
@@ -76,8 +76,7 @@ export function buildFcrInsightContext(args: BuildFcrInsightContextArgs): Insigh
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);
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),
@@ -95,10 +94,7 @@ export function buildFcrInsightContext(args: BuildFcrInsightContextArgs): Insigh
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;
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;
@@ -231,8 +227,7 @@ export function buildWeightInsightContext(args: BuildWeightInsightContextArgs):
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);
num(row.target_gram) ?? (totalDays != null ? getTargetWeightForAge(hari, totalDays) : null);
return {
hari,
bobot_gram: bobot != null && bobot > 0 ? bobot : null,
@@ -244,8 +239,7 @@ export function buildWeightInsightContext(args: BuildWeightInsightContextArgs):
.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];
const latest = [...tren].reverse().find((row) => row.bobot_gram != null) ?? tren[tren.length - 1];
return {
kandangId,
@@ -490,10 +484,10 @@ export function buildCountingInsightContext(args: BuildCountingInsightContextArg
const mortalitas_persen =
args.mortalitas_persen !== undefined
? args.mortalitas_persen
: latest?.mortalitas_persen ??
: (latest?.mortalitas_persen ??
(populasi_awal != null && populasi_awal > 0 && mortalitas_ekor != null
? Math.round((mortalitas_ekor / populasi_awal) * 10000) / 100
: null);
: null));
return {
kandangId,
@@ -549,11 +543,10 @@ export function buildDashboardInsightContext(
cycleId: args.cycleId,
hari_ke: args.hari_ke ?? null,
totalDays: args.totalDays ?? null,
fcr_terakhir:
args.fcr_terakhir != null && args.fcr_terakhir > 0 ? args.fcr_terakhir : null,
eef_terakhir:
args.eef_terakhir != null && args.eef_terakhir > 0 ? args.eef_terakhir : null,
bobot_avg_gram: args.bobot_avg_gram != null && args.bobot_avg_gram > 0 ? args.bobot_avg_gram : null,
fcr_terakhir: args.fcr_terakhir != null && args.fcr_terakhir > 0 ? args.fcr_terakhir : null,
eef_terakhir: args.eef_terakhir != null && args.eef_terakhir > 0 ? args.eef_terakhir : null,
bobot_avg_gram:
args.bobot_avg_gram != null && args.bobot_avg_gram > 0 ? args.bobot_avg_gram : null,
mortalitas_hari_ini_ekor: args.mortalitas_hari_ini_ekor ?? null,
mortalitas_kumulatif_persen: args.mortalitas_kumulatif_persen ?? null,
karung_dituang_hari_ini: args.karung_dituang_hari_ini ?? null,
@@ -682,8 +675,7 @@ export function buildDashboardInsightMetrics(
if (hari === null || hari <= 0) continue;
const iotWeight = positiveNum(row.iot_weight);
const weightFromSeries = weightByAge.get(hari)?.actual ?? null;
const bobot =
iotWeight !== null ? iotWeight : weightFromSeries;
const bobot = iotWeight !== null ? iotWeight : weightFromSeries;
const persenHidup = num(row.chicken_life_percentage);
const uniformity = weightByAge.get(hari)?.uniformity ?? null;
kpiByAge.set(hari, {
@@ -711,12 +703,9 @@ export function buildDashboardInsightMetrics(
}
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 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 ??
@@ -725,14 +714,10 @@ export function buildDashboardInsightMetrics(
const hari_ke =
latestKpi?.hari ??
latestWeight?.hari ??
(num(args.currentDay) != null && (args.currentDay as number) > 0
? num(args.currentDay)
: null);
(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;
num(args.docInCount) != null && (args.docInCount as number) > 0 ? num(args.docInCount) : null;
return {
hari_ke,
@@ -745,8 +730,7 @@ export function buildDashboardInsightMetrics(
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,
uniformity_persen: latestKpi?.uniformity_persen ?? latestWeightMeta?.uniformity ?? null,
pakan_kumulatif_karung:
latestKpi?.pakan_kumulatif_karung ?? num(args.pakanKumulatifFallback) ?? null,
weightSeries,
@@ -787,9 +771,7 @@ export type EndCyclePhaseSummary = {
export type EndCycleRollups = {
weekly_summaries: EndCycleWeeklySummary[];
phase_summaries: Partial<
Record<'brooding' | 'growth' | 'finisher', EndCyclePhaseSummary>
>;
phase_summaries: Partial<Record<'brooding' | 'growth' | 'finisher', EndCyclePhaseSummary>>;
mati_ekor: number | null;
panen_ekor: number | null;
saldo_ekor: number | null;
@@ -798,7 +780,9 @@ export type EndCycleRollups = {
};
const avg = (values: number[]): number | null =>
values.length ? Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 1000) / 1000 : null;
values.length
? Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 1000) / 1000
: null;
/**
* Build weekly/phase rollups for end-cycle insight from dashboard KPI series.
@@ -865,9 +849,7 @@ export function buildEndCycleRollups(
const bucket = (phase: 'brooding' | 'growth' | 'finisher', weeks: number[]) => {
const rows = weekly_summaries.filter((w) => weeks.includes(w.minggu_ke));
if (!rows.length) return;
const deltas = rows
.map((r) => r.mortalitas_delta_persen)
.filter((v): v is number => v != null);
const deltas = rows.map((r) => r.mortalitas_delta_persen).filter((v): v is number => v != null);
phase_summaries[phase] = {
jumlah_minggu: rows.length,
mortalitas_delta_persen: deltas.length
+3 -1
View File
@@ -227,7 +227,9 @@ export const scopeScalarsToTimeframe = (
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;
const finalValue = value === null ? (rule.emptyAs ?? UNAVAILABLE) : value;
out[outKey] = finalValue;
if (rule.replaces) out[rule.replaces] = finalValue;
}
for (const key of spec.unavailable ?? []) out[key] = UNAVAILABLE;
+5 -23
View File
@@ -1,11 +1,6 @@
import ExcelJS from 'exceljs';
import type { DashboardReportPayload } from '../types/dashboardReport.ts';
import {
countingTotals,
feedInTotals,
feedOutTotals,
feedUseTotals,
} from './reportTotals.ts';
import { countingTotals, feedInTotals, feedOutTotals, feedUseTotals } from './reportTotals.ts';
import { formatDateId, formatDateRange, formatNumber, formatRatio } from './format.ts';
import { buildReportSummary, formatSummaryValue } from './reportSummary.ts';
@@ -34,11 +29,9 @@ type CellValue = string | number | null;
const isPresent = (value: number | null | undefined): value is number =>
typeof value === 'number' && Number.isFinite(value);
const dash = (value: number | null | undefined): CellValue =>
isPresent(value) ? value : '-';
const dash = (value: number | null | undefined): CellValue => (isPresent(value) ? value : '-');
const na = (value: number | null | undefined): CellValue =>
isPresent(value) ? value : 'N/A';
const na = (value: number | null | undefined): CellValue => (isPresent(value) ? value : 'N/A');
const pct = (value: number | null | undefined): CellValue =>
isPresent(value) ? `${value.toFixed(1)}%` : 'N/A';
@@ -242,11 +235,7 @@ function addSummarySheet(workbook: ExcelJS.Workbook, payload: DashboardReportPay
sheet.addRow([]);
insertedHighlightGap = true;
}
const row = sheet.addRow([
metric.label,
formatSummaryValue(metric),
metric.unit || '—',
]);
const row = sheet.addRow([metric.label, formatSummaryValue(metric), metric.unit || '—']);
if (isHighlight) {
styleHighlightKpiRow(row, 3);
} else {
@@ -433,14 +422,7 @@ export async function downloadReportExcel(payload: DashboardReportPayload): Prom
addTable(
workbook.addWorksheet('EEF'),
'Data Pendukung EEF',
[
'Umur',
'Persentase Ayam Hidup',
'BW Rata-rata Panen',
'Umur Rata-rata Panen',
'FCR',
'EEF',
],
['Umur', 'Persentase Ayam Hidup', 'BW Rata-rata Panen', 'Umur Rata-rata Panen', 'FCR', 'EEF'],
payload.eef.map((row) => [
`Hari ${row.day}`,
qty(row.chickenLifePercentage, 3, '%'),
+1 -6
View File
@@ -1,10 +1,5 @@
import type { DashboardReportPayload } from '../types/dashboardReport.ts';
import {
countingTotals,
feedInTotals,
feedOutTotals,
feedUseTotals,
} from './reportTotals.ts';
import { countingTotals, feedInTotals, feedOutTotals, feedUseTotals } from './reportTotals.ts';
import { formatNumber, formatRatio } from './format.ts';
export type ReportSummaryMetricKey =
+1 -1
View File
@@ -57,6 +57,6 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
setupFiles: './tests/setup.ts',
exclude: ['node_modules/**', 'dist/**'],
exclude: ['node_modules/**', 'dist/**', '.opencode/**'],
},
});