adjust ai insight and seed data
This commit is contained in:
1 parent
68e0d1a0bb
commit
a2b5b10977
18 files changed
+1835
-140
No files matched your search
Binary file not shown.
@@ -17,28 +17,28 @@ Every daily series is hand-tuned so the numbers stay internally consistent:
|
||||
57.47 t live weight, avg ~1,412 g/bird at ~33.1 days; tail harvests leave
|
||||
fewer than 50 birds in the house at close-out.
|
||||
* ``kpi`` / ``manual`` – FCR/EEF/lot-weight follow the same formulas as the
|
||||
Kandang 2 preview:
|
||||
Kandang 2 preview / ``kpi_rollups``:
|
||||
FCR = feed_total_kg / (harvest_weight_total_g + lot_weight_g)
|
||||
EEF = life_pct * (avg_harvest_weight/1000) / (FCR * avg_harvest_day) * 100
|
||||
lot = chicken_life * average_weight
|
||||
* ``iot_panel`` – derived from the real Kandang 2 sensor extract with
|
||||
deterministic per-house jitter (same site, same season).
|
||||
chicken_life (KPI) = DOC − mortality (excludes harvest; matches kpi_rollups)
|
||||
stock_akhir = chicken_life − harvest_total (in-house remaining)
|
||||
lot = stock_akhir * average_weight
|
||||
iot_weight (KPI) = average_weight g/bird (not lot total; rollups multiply by stock)
|
||||
* ``iot_panel`` – 10-minute readings via ``seed_iot_10min`` (real Kandang 2
|
||||
Lantai 1 extract + deterministic per-house jitter).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.farms.models import Cycle, Flock, Kandang, Site
|
||||
from apps.jobs.sukawarna_preview import IOT_EXTRACT_JSON
|
||||
from apps.operations.models import (
|
||||
ChickenCounting,
|
||||
ChickenWeight,
|
||||
@@ -127,7 +127,7 @@ OUT_TODAY = [0] * 42 + [3, 4, 5, 3, 2, 2, 1]
|
||||
|
||||
# --- Harvest plan: age -> (birds, avg live weight g/bird) --------------------
|
||||
# 20 partial harvests day 29-48; the tail harvests clear out the house so the
|
||||
# leftover flock (chicken_life) at close-out stays under 50 birds.
|
||||
# leftover flock (stock_akhir) at close-out stays under 50 birds.
|
||||
HARVEST = {
|
||||
29: (4_500, 1_310),
|
||||
30: (5_000, 1_360),
|
||||
@@ -163,18 +163,17 @@ class Command(BaseCommand):
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--password", default=DEFAULT_PASSWORD)
|
||||
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
|
||||
parser.add_argument("--no-reset", action="store_true")
|
||||
parser.add_argument("--jitter-seed", type=int, default=7)
|
||||
parser.add_argument(
|
||||
"--skip-iot-10min",
|
||||
action="store_true",
|
||||
help="Skip 10-minute IoT panel seeding (default: run seed_iot_10min)",
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
iot_path = Path(options["iot_json"])
|
||||
if not iot_path.is_file():
|
||||
raise CommandError(f"IoT extract not found: {iot_path}")
|
||||
iot = json.loads(iot_path.read_text(encoding="utf-8"))
|
||||
password = options["password"]
|
||||
rng = random.Random(options["jitter_seed"])
|
||||
|
||||
user, created = User.objects.get_or_create(
|
||||
user_name="staff",
|
||||
@@ -329,10 +328,12 @@ class Command(BaseCommand):
|
||||
|
||||
mort_today = MORTALITY[i]
|
||||
mort_total_after = mort_total + mort_today
|
||||
life = DOC_IN_COUNT - mort_total_after - harvest_total
|
||||
# Liveability for EEF: birds still accounted for (DOC minus mortality),
|
||||
# not remaining in-house stock (which drops during harvest).
|
||||
life_pct = (DOC_IN_COUNT - mort_total_after) / DOC_IN_COUNT * 100.0 if DOC_IN_COUNT else 0.0
|
||||
# Matches kpi_rollups: chicken_life excludes harvest; stock_akhir is in-house.
|
||||
chicken_life = DOC_IN_COUNT - mort_total_after
|
||||
stock_akhir = max(chicken_life - harvest_total, 0)
|
||||
life_pct = (
|
||||
chicken_life / DOC_IN_COUNT * 100.0 if DOC_IN_COUNT else 0.0
|
||||
)
|
||||
# Running averages: flock weight before the first harvest, then the
|
||||
# harvest-weighted running mean carried forward on idle days.
|
||||
if harvest_today:
|
||||
@@ -345,7 +346,7 @@ class Command(BaseCommand):
|
||||
avg_hday = harvest_day_accum / harvest_total
|
||||
avg_hwt = harvest_wt_accum / harvest_total
|
||||
|
||||
lot_weight_g = life * weight if weight else 0.0
|
||||
lot_weight_g = stock_akhir * weight if weight else 0.0
|
||||
denom = harvest_weight_total + lot_weight_g
|
||||
fcr = (feed_total * 50_000) / denom if denom > 0 else 0.0
|
||||
if avg_hday > 0 and fcr > 0:
|
||||
@@ -393,9 +394,9 @@ class Command(BaseCommand):
|
||||
"harvest_total": harvest_total,
|
||||
"harvest_weight": float(harvest_today * harvest_wt_today),
|
||||
"harvest_weight_total": harvest_weight_total,
|
||||
"chicken_life": life,
|
||||
"chicken_life": chicken_life,
|
||||
"chicken_life_percentage": _round(life_pct, 3),
|
||||
"iot_weight": _round(lot_weight_g, 2),
|
||||
"iot_weight": _round(weight, 2),
|
||||
"average_harvest_day": _round(avg_hday, 4),
|
||||
"average_harvest_weight": _round(avg_hwt, 4),
|
||||
"fcr": _round(fcr, 4),
|
||||
@@ -409,30 +410,18 @@ class Command(BaseCommand):
|
||||
|
||||
mort_total = mort_total_after
|
||||
|
||||
# --- IoT panel: Kandang 2 sensors with per-house jitter -------------
|
||||
panel_count = 0
|
||||
for flock_block in iot["flocks"]:
|
||||
flock = flocks_by_name[flock_block["name"]]
|
||||
for row in flock_block["rows"]:
|
||||
day = date.fromisoformat(row["date"])
|
||||
avg_temp = float(row["average_temperature"]) + rng.uniform(-0.4, 0.4)
|
||||
humidity = min(max(float(row["humidity"]) + rng.uniform(-3.0, 3.0), 0.0), 100.0)
|
||||
wind = max(float(row["wind_speed"]) + rng.uniform(-0.1, 0.1), 0.0)
|
||||
water = max(float(row["water_total"]) * 0.9 + rng.uniform(-120.0, 120.0), 0.0)
|
||||
chill = float(row["chill_factor"])
|
||||
exp_temp = avg_temp - (70.0 - humidity) / 5.0 - wind * chill
|
||||
IotPanel.objects.update_or_create(
|
||||
flock=flock,
|
||||
date=day,
|
||||
defaults={
|
||||
"wind_speed": _round(wind, 2),
|
||||
"humidity": _round(humidity, 1),
|
||||
"water_total": _round(water, 1),
|
||||
"average_temperature": _round(avg_temp, 2),
|
||||
"experience_temperature": _round(exp_temp, 4),
|
||||
},
|
||||
)
|
||||
panel_count += 1
|
||||
if not options["skip_iot_10min"]:
|
||||
call_command(
|
||||
"seed_iot_10min",
|
||||
cycle_start=START_DATE.isoformat(),
|
||||
jitter_seed=options["jitter_seed"],
|
||||
no_reset=options["no_reset"],
|
||||
)
|
||||
panel_count = IotPanel.objects.filter(
|
||||
flock__kandang=kandang,
|
||||
date__gte=cycle.start_date,
|
||||
date__lte=cycle.end_date,
|
||||
).count()
|
||||
|
||||
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
|
||||
self.stdout.write(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
@@ -11,7 +11,6 @@ from apps.accounts.models import User
|
||||
from apps.farms.models import Cycle, Flock, Kandang, Site
|
||||
from apps.jobs.sukawarna_preview import (
|
||||
FEED_REFERENCE_XLSX,
|
||||
IOT_EXTRACT_JSON,
|
||||
PREVIEW_XLSX,
|
||||
as_number,
|
||||
load_feed_reference,
|
||||
@@ -40,22 +39,22 @@ class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--password", default=DEFAULT_PASSWORD)
|
||||
parser.add_argument("--xlsx", default=str(PREVIEW_XLSX))
|
||||
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
|
||||
parser.add_argument("--feed-reference", default=str(FEED_REFERENCE_XLSX))
|
||||
parser.add_argument("--no-reset", action="store_true")
|
||||
parser.add_argument(
|
||||
"--skip-iot-10min",
|
||||
action="store_true",
|
||||
help="Skip 10-minute IoT panel seeding (default: run seed_iot_10min)",
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
xlsx = Path(options["xlsx"])
|
||||
iot_path = Path(options["iot_json"])
|
||||
feed_reference_path = Path(options["feed_reference"])
|
||||
if not xlsx.is_file():
|
||||
raise CommandError(f"Preview not found: {xlsx}")
|
||||
if not iot_path.is_file():
|
||||
raise CommandError(f"IoT extract not found: {iot_path}")
|
||||
|
||||
sheets = load_preview(xlsx)
|
||||
iot = json.loads(iot_path.read_text(encoding="utf-8"))
|
||||
feed_reference = {}
|
||||
if feed_reference_path.is_file():
|
||||
feed_reference = load_feed_reference(feed_reference_path)
|
||||
@@ -257,9 +256,13 @@ class Command(BaseCommand):
|
||||
"harvest_total": int(as_number(row.get("panen_total"))),
|
||||
"harvest_weight": float(as_number(row.get("harvest_weight_g"))),
|
||||
"harvest_weight_total": float(as_number(row.get("harvest_weight_total_g"))),
|
||||
"chicken_life": int(as_number(row.get("chicken_life"))),
|
||||
# Preview `chicken_life` is in-house remaining; KPI stores
|
||||
# DOC − mortality (matches kpi_rollups / FCR stock_akhir math).
|
||||
"chicken_life": int(as_number(row.get("chicken_life")))
|
||||
+ int(as_number(row.get("panen_total"))),
|
||||
"chicken_life_percentage": float(as_number(row.get("chicken_life_pct"))),
|
||||
"iot_weight": float(as_number(row.get("lot_weight_g"))),
|
||||
# Per-bird avg g (matches kpi_rollups); not lot_weight_g total.
|
||||
"iot_weight": float(cw_by_date[day].average_weight),
|
||||
"average_harvest_day": float(as_number(row.get("avg_harvest_day"))),
|
||||
"average_harvest_weight": float(as_number(row.get("avg_harvest_weight_g"))),
|
||||
"fcr": float(as_number(row.get("fcr"))),
|
||||
@@ -275,23 +278,17 @@ class Command(BaseCommand):
|
||||
if missing:
|
||||
raise CommandError(f"KPI dates missing source rows: {missing}")
|
||||
|
||||
panel_count = 0
|
||||
for flock_block in iot["flocks"]:
|
||||
flock = flocks_by_name[flock_block["name"]]
|
||||
for row in flock_block["rows"]:
|
||||
day = date.fromisoformat(row["date"])
|
||||
IotPanel.objects.update_or_create(
|
||||
flock=flock,
|
||||
date=day,
|
||||
defaults={
|
||||
"wind_speed": float(row["wind_speed"]),
|
||||
"humidity": float(row["humidity"]),
|
||||
"water_total": float(row["water_total"]),
|
||||
"average_temperature": float(row["average_temperature"]),
|
||||
"experience_temperature": float(row["experience_temperature"]),
|
||||
},
|
||||
)
|
||||
panel_count += 1
|
||||
if not options["skip_iot_10min"]:
|
||||
call_command(
|
||||
"seed_iot_10min",
|
||||
cycle_start=start_date.isoformat(),
|
||||
no_reset=options["no_reset"],
|
||||
)
|
||||
panel_count = IotPanel.objects.filter(
|
||||
flock__kandang=kandang,
|
||||
date__gte=cycle.start_date,
|
||||
date__lte=cycle.end_date,
|
||||
).count()
|
||||
|
||||
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
|
||||
self.stdout.write(
|
||||
|
||||
@@ -291,6 +291,9 @@ def get_mortality_cum_std_by_day(day_age: Any) -> float | None:
|
||||
|
||||
def analyze_fcr(actual_fcr: Any, day_age: Any) -> dict[str, Any]:
|
||||
actual = _as_float(actual_fcr)
|
||||
# FCR 0 / negative is not a valid reading — treat as missing.
|
||||
if actual is not None and actual <= 0:
|
||||
actual = None
|
||||
std_fcr = get_fcr_standard_by_day(day_age)
|
||||
if actual is None or std_fcr is None:
|
||||
return {
|
||||
@@ -339,6 +342,9 @@ def analyze_fcr(actual_fcr: Any, day_age: Any) -> dict[str, Any]:
|
||||
|
||||
def analyze_bw(actual_bw_g: Any, day_age: Any) -> dict[str, Any]:
|
||||
actual = _as_float(actual_bw_g)
|
||||
# 0 g is a missing/placeholder reading, not a real bird weight.
|
||||
if actual is not None and actual <= 0:
|
||||
actual = None
|
||||
std_bw = get_bw_standard_by_day(day_age)
|
||||
if actual is None or std_bw is None:
|
||||
return {
|
||||
@@ -667,6 +673,15 @@ def _first_number(*candidates: Any) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _first_positive_number(*candidates: Any) -> float | None:
|
||||
"""Like `_first_number`, but treat 0 / negative as missing (sensor placeholder)."""
|
||||
for value in candidates:
|
||||
n = _as_float(value)
|
||||
if n is not None and n > 0:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_day_age(context_pack: dict[str, Any]) -> int | None:
|
||||
day = _first_number(
|
||||
context_pack.get("hari_ke"),
|
||||
@@ -683,8 +698,11 @@ def _resolve_day_age(context_pack: dict[str, Any]) -> int | None:
|
||||
|
||||
|
||||
def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None:
|
||||
"""Bobot dalam gram. Field `_kg` hanya untuk legacy payload (dikonversi ×1000)."""
|
||||
grams = _first_number(
|
||||
"""Bobot dalam gram. Field `_kg` hanya untuk legacy payload (dikonversi ×1000).
|
||||
|
||||
Values <= 0 are treated as unavailable (common IoT/placeholder zero).
|
||||
"""
|
||||
grams = _first_positive_number(
|
||||
context_pack.get("bobot_avg_gram"),
|
||||
context_pack.get("averageWeight"),
|
||||
context_pack.get("bobot_rata_rata_gram"),
|
||||
@@ -697,7 +715,7 @@ def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None:
|
||||
if grams is not None:
|
||||
return grams
|
||||
|
||||
kg = _first_number(
|
||||
kg = _first_positive_number(
|
||||
context_pack.get("bobot_iot_kg_terakhir"),
|
||||
context_pack.get("bobot_iot_kg"),
|
||||
)
|
||||
@@ -706,6 +724,17 @@ def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_fcr(context_pack: dict[str, Any]) -> float | None:
|
||||
"""FCR ratio; 0 / negative counts as missing."""
|
||||
return _first_positive_number(
|
||||
context_pack.get("fcr_terakhir"),
|
||||
context_pack.get("fcr"),
|
||||
_nested_get(context_pack, "fcr_eef", "fcr", "actual"),
|
||||
_nested_get(context_pack, "weight", "current", "fcr"),
|
||||
_nested_get(context_pack, "fcrSummary", "current"),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_mortality(context_pack: dict[str, Any]) -> float | None:
|
||||
"""Cumulative mortality % from explicit fields, % hidup, or kematian/DOC."""
|
||||
direct = _first_number(
|
||||
@@ -761,14 +790,9 @@ def analyze_with_cp707_standards(context_pack: dict[str, Any] | None) -> dict[st
|
||||
# day-standard BW as the farm's actual weight.
|
||||
result["analyses"]["bw"] = analyze_bw(actual_bw, day_age)
|
||||
|
||||
actual_fcr = _first_number(
|
||||
pack.get("fcr_terakhir"),
|
||||
pack.get("fcr"),
|
||||
_nested_get(pack, "fcr_eef", "fcr", "actual"),
|
||||
_nested_get(pack, "weight", "current", "fcr"),
|
||||
_nested_get(pack, "fcrSummary", "current"),
|
||||
)
|
||||
if actual_fcr is not None and day_age is not None:
|
||||
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)
|
||||
|
||||
mortality_rate = _resolve_mortality(pack)
|
||||
|
||||
@@ -19,6 +19,7 @@ from django.utils import timezone
|
||||
from apps.farms.models import Cycle, Kandang
|
||||
from apps.operations.models import AIInsight
|
||||
from apps.operations.services import cp707_knowledge as cp707
|
||||
from apps.operations.services import root_cause
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -105,6 +106,24 @@ FORMAT OUTPUT (JSON SAJA):
|
||||
{"kesimpulan":"...","insight":"..."}
|
||||
"""
|
||||
|
||||
END_CYCLE_OUTPUT_RULES = """
|
||||
FORMAT OUTPUT AKHIR SIKLUS (JSON SAJA — WAJIB):
|
||||
{
|
||||
"kesimpulan":"...",
|
||||
"masalah":["..."],
|
||||
"akar_penyebab":[{"id":"...","hipotesis":"...","bukti":["..."],"confidence":"high|med|low"}],
|
||||
"perbaikan_siklus_berikutnya":[{"id":"...","fase":"brooding|growth|finisher","aksi":"...","metrik_pantau":"..."}],
|
||||
"insight":"..."
|
||||
}
|
||||
|
||||
ATURAN AKHIR SIKLUS:
|
||||
- Masalah = ringkas isu dari [GRADED FACTS] (critical/warning saja).
|
||||
- Akar penyebab HANYA dari [ROOT CAUSE CANDIDATES] — boleh rapikan bahasa/bukti, JANGAN menambah hipotesis baru.
|
||||
- Perbaikan HANYA dari [NEXT CYCLE ACTIONS] — boleh rapikan bahasa, JANGAN menambah aksi baru.
|
||||
- Sertakan field "id" dari kandidat/aksi yang dipakai.
|
||||
- Jika kandidat kosong dan KPI sehat, tulis kesimpulan positif singkat; masalah/akar/perbaikan boleh [].
|
||||
"""
|
||||
|
||||
|
||||
def build_insight_user_prompt(
|
||||
*,
|
||||
@@ -122,10 +141,10 @@ def build_insight_user_prompt(
|
||||
if k not in ("kandangId", "kandang_id", "cycleId", "cycle_id")
|
||||
}
|
||||
return (
|
||||
f"Buat insight topik `{topic}` untuk `{kandang_name}` "
|
||||
f"periode `{report_type}/{report_period}`. "
|
||||
"Gunakan nama kandang persis seperti tertulis; jangan menambah kata 'Kandang' "
|
||||
"dan jangan menyebut id.\n"
|
||||
f"Buat insight topik `{topic}` untuk unit bernama `{kandang_name}` "
|
||||
f"periode `{report_type}/{report_period}`.\n"
|
||||
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.\n"
|
||||
f"[Data Halaman (JSON)]\n{json.dumps(safe_ctx, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
|
||||
@@ -167,22 +186,62 @@ def _day_age(context: dict[str, Any]) -> int | None:
|
||||
def prune_context_by_period(context: dict[str, Any], report_type: str, report_period: str) -> dict[str, Any]:
|
||||
"""Light prune for history arrays; keep scalars. Full FE scope happens client-side."""
|
||||
out = dict(context)
|
||||
# Placeholder zeros (esp. weight/FCR sensors) → null so LLM sees "unavailable".
|
||||
for key in (
|
||||
"bobot_avg_gram",
|
||||
"averageWeight",
|
||||
"bobot_rata_rata_gram",
|
||||
"bobot_iot_gram",
|
||||
"bobot_iot_gram_terakhir",
|
||||
"fcr_terakhir",
|
||||
"fcr",
|
||||
"eef_terakhir",
|
||||
):
|
||||
val = out.get(key)
|
||||
if isinstance(val, (int, float)) and val <= 0:
|
||||
out[key] = None
|
||||
if report_type == "end_cycle":
|
||||
# Deterministic weekly KPI rollup when weekly_summaries absent.
|
||||
if "weekly_summaries" not in out:
|
||||
if "weekly_summaries" not in out or not out.get("weekly_summaries"):
|
||||
out["weekly_summaries"] = _build_weekly_summaries(out)
|
||||
if "phase_summaries" not in out or not out.get("phase_summaries"):
|
||||
out["phase_summaries"] = _build_phase_summaries(out.get("weekly_summaries") or [])
|
||||
out["catatan_end_cycle"] = (
|
||||
"Ringkasan KPI per minggu dihitung di backend. Narasikan dari weekly_summaries + graded_facts; "
|
||||
"jangan menghitung ulang."
|
||||
"Ringkasan KPI per minggu dihitung di backend. Narasikan dari weekly_summaries + "
|
||||
"phase_summaries + graded_facts + root-cause candidates; jangan menghitung ulang."
|
||||
)
|
||||
out["report_type"] = report_type
|
||||
out["report_period"] = report_period
|
||||
return out
|
||||
|
||||
|
||||
def _row_day(row: dict[str, Any]) -> int | None:
|
||||
day = row.get("hari") or row.get("day") or row.get("age")
|
||||
try:
|
||||
day_i = int(day)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return day_i if day_i > 0 else None
|
||||
|
||||
|
||||
def _row_num(row: dict[str, Any], *keys: str) -> float | None:
|
||||
for key in keys:
|
||||
val = row.get(key)
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
return None
|
||||
|
||||
|
||||
def _build_weekly_summaries(context: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
histories = []
|
||||
for key in ("tren_fcr_harian", "tren_harian", "tren_7_hari_terakhir", "history"):
|
||||
histories: list[Any] = []
|
||||
for key in (
|
||||
"kpiSeries",
|
||||
"tren_kpi_harian",
|
||||
"tren_fcr_harian",
|
||||
"tren_harian",
|
||||
"tren_7_hari_terakhir",
|
||||
"history",
|
||||
):
|
||||
val = context.get(key)
|
||||
if isinstance(val, list) and val:
|
||||
histories = val
|
||||
@@ -191,28 +250,121 @@ def _build_weekly_summaries(context: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
for row in histories:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
day = row.get("hari") or row.get("day")
|
||||
try:
|
||||
day_i = int(day)
|
||||
except (TypeError, ValueError):
|
||||
day_i = _row_day(row)
|
||||
if day_i is None:
|
||||
continue
|
||||
week = max(1, (day_i + 6) // 7)
|
||||
by_week.setdefault(week, []).append(row)
|
||||
|
||||
summaries = []
|
||||
prev_mort: float | None = None
|
||||
for week, rows in sorted(by_week.items()):
|
||||
fcrs = [r.get("fcr_aktual") or r.get("fcr") for r in rows if isinstance(r.get("fcr_aktual") or r.get("fcr"), (int, float))]
|
||||
rows_sorted = sorted(rows, key=lambda r: _row_day(r) or 0)
|
||||
fcrs = [
|
||||
v
|
||||
for r in rows_sorted
|
||||
if (v := _row_num(r, "fcr_aktual", "fcr", "fcr_terakhir")) is not None
|
||||
]
|
||||
bws = [
|
||||
v
|
||||
for r in rows_sorted
|
||||
if (v := _row_num(r, "bobot_avg_gram", "actual", "average_weight", "bw")) is not None
|
||||
]
|
||||
feeds = [
|
||||
v
|
||||
for r in rows_sorted
|
||||
if (v := _row_num(r, "pakan_kumulatif_karung", "feed_total", "karung")) is not None
|
||||
]
|
||||
morts = [
|
||||
v
|
||||
for r in rows_sorted
|
||||
if (v := _row_num(r, "mortalitas_kumulatif_persen", "mortality_pct")) is not None
|
||||
]
|
||||
mort_end = morts[-1] if morts else None
|
||||
mort_delta = None
|
||||
if mort_end is not None:
|
||||
mort_delta = round(mort_end - (prev_mort or 0.0), 3)
|
||||
prev_mort = mort_end
|
||||
|
||||
cold = sum(
|
||||
_row_num(r, "jam_suhu_di_bawah_standar", "temp_below_hours", "hours_temp_below") or 0
|
||||
for r in rows_sorted
|
||||
)
|
||||
humid = sum(
|
||||
_row_num(
|
||||
r, "jam_kelembapan_di_atas_standar", "humidity_above_hours", "hours_humidity_above"
|
||||
)
|
||||
or 0
|
||||
for r in rows_sorted
|
||||
)
|
||||
ammonia = sum(
|
||||
_row_num(r, "jam_amonia_tinggi", "ammonia_high_hours", "hours_ammonia_high") or 0
|
||||
for r in rows_sorted
|
||||
)
|
||||
|
||||
summaries.append(
|
||||
{
|
||||
"minggu_ke": week,
|
||||
"jumlah_hari_data": len(rows),
|
||||
"jumlah_hari_data": len(rows_sorted),
|
||||
"fcr_rata_rasio": round(sum(fcrs) / len(fcrs), 3) if fcrs else None,
|
||||
"fcr_akhir_rasio": fcrs[-1] if fcrs else None,
|
||||
"bobot_akhir_gram": bws[-1] if bws else None,
|
||||
"pakan_akhir_karung": feeds[-1] if feeds else None,
|
||||
"mortalitas_kumulatif_persen": mort_end,
|
||||
"mortalitas_delta_persen": mort_delta,
|
||||
"jam_suhu_di_bawah_standar": round(cold, 1) if cold else 0,
|
||||
"jam_kelembapan_di_atas_standar": round(humid, 1) if humid else 0,
|
||||
"jam_amonia_tinggi": round(ammonia, 1) if ammonia else 0,
|
||||
}
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def _build_phase_summaries(weekly: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Roll weekly rows into brooding / growth / finisher buckets."""
|
||||
buckets: dict[str, list[dict[str, Any]]] = {
|
||||
root_cause.PHASE_BROODING: [],
|
||||
root_cause.PHASE_GROWTH: [],
|
||||
root_cause.PHASE_FINISHER: [],
|
||||
}
|
||||
for row in weekly:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
week = row.get("minggu_ke")
|
||||
try:
|
||||
week_i = int(week)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
# week 1-2 ≈ days 1-14, week 3-4 ≈ 15-28, week 5+ ≈ 29+
|
||||
if week_i <= 2:
|
||||
buckets[root_cause.PHASE_BROODING].append(row)
|
||||
elif week_i <= 4:
|
||||
buckets[root_cause.PHASE_GROWTH].append(row)
|
||||
else:
|
||||
buckets[root_cause.PHASE_FINISHER].append(row)
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
for phase, rows in buckets.items():
|
||||
if not rows:
|
||||
continue
|
||||
mort_deltas = [
|
||||
float(r["mortalitas_delta_persen"])
|
||||
for r in rows
|
||||
if isinstance(r.get("mortalitas_delta_persen"), (int, float))
|
||||
]
|
||||
fcrs = [
|
||||
float(r["fcr_akhir_rasio"])
|
||||
for r in rows
|
||||
if isinstance(r.get("fcr_akhir_rasio"), (int, float))
|
||||
]
|
||||
out[phase] = {
|
||||
"jumlah_minggu": len(rows),
|
||||
"mortalitas_delta_persen": round(sum(mort_deltas), 3) if mort_deltas else None,
|
||||
"fcr_akhir_rasio": fcrs[-1] if fcrs else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def grade_context(context: dict[str, Any]) -> dict[str, Any]:
|
||||
analysis = cp707.analyze_with_cp707_standards(context)
|
||||
graded: dict[str, Any] = {
|
||||
@@ -346,6 +498,38 @@ def parse_llm_json(text: str) -> dict[str, str] | None:
|
||||
}
|
||||
|
||||
|
||||
def parse_end_cycle_llm_json(text: str) -> dict[str, Any] | None:
|
||||
"""Parse end-cycle structured JSON; tolerate partial fields."""
|
||||
if not text:
|
||||
return None
|
||||
cleaned = re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.I)
|
||||
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned.strip(), flags=re.I)
|
||||
cleaned = re.sub(r"\s*```\s*$", "", cleaned)
|
||||
first = cleaned.find("{")
|
||||
last = cleaned.rfind("}")
|
||||
if first < 0 or last <= first:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(cleaned[first : last + 1])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
# Accept if any of the end-cycle keys or daily keys exist.
|
||||
keys = (
|
||||
"kesimpulan",
|
||||
"ringkasan",
|
||||
"summary",
|
||||
"masalah",
|
||||
"akar_penyebab",
|
||||
"perbaikan_siklus_berikutnya",
|
||||
"insight",
|
||||
)
|
||||
if not any(k in parsed for k in keys):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def local_fallback_insight(graded: dict[str, Any], topic: str) -> dict[str, str]:
|
||||
analyses = graded.get("analyses") or {}
|
||||
lines = []
|
||||
@@ -384,9 +568,9 @@ def _text_soft_labels_mortality(text: str) -> bool:
|
||||
|
||||
|
||||
def enforce_mortality_wording(
|
||||
parsed: dict[str, str],
|
||||
parsed: dict[str, Any],
|
||||
graded: dict[str, Any] | None,
|
||||
) -> dict[str, str]:
|
||||
) -> dict[str, Any]:
|
||||
"""Rewrite LLM text that calls high mortality 'rendah/normal' (small-model slip)."""
|
||||
mort = ((graded or {}).get("analyses") or {}).get("mortality") or {}
|
||||
status = str(mort.get("status") or "").lower()
|
||||
@@ -422,6 +606,25 @@ def enforce_mortality_wording(
|
||||
return out
|
||||
|
||||
|
||||
_DUP_KANDANG = re.compile(r"\b[Kk]andang(?:\s+[Kk]andang)+\b")
|
||||
|
||||
|
||||
def collapse_duplicate_kandang(text: str) -> str:
|
||||
"""Fix LLM slip 'Kandang Kandang 2' → 'Kandang 2' (name already includes Kandang)."""
|
||||
if not text:
|
||||
return text
|
||||
return _DUP_KANDANG.sub(lambda m: "Kandang" if m.group(0)[0].isupper() else "kandang", text)
|
||||
|
||||
|
||||
def sanitize_insight_narrative(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Deterministic cleanup of narrative fields after the LLM."""
|
||||
out = dict(parsed)
|
||||
for key in ("kesimpulan", "insight", "summary", "insight_text"):
|
||||
if key in out and out[key] is not None:
|
||||
out[key] = collapse_duplicate_kandang(str(out[key]))
|
||||
return out
|
||||
|
||||
|
||||
def lookup_cached(
|
||||
*,
|
||||
cycle_id: int,
|
||||
@@ -503,6 +706,16 @@ def decode_citations(raw: Any) -> list[dict[str, Any]]:
|
||||
|
||||
def serialize_insight(row: AIInsight) -> dict[str, Any]:
|
||||
norm_citations = decode_citations(row.citations)
|
||||
summary = collapse_duplicate_kandang(row.summary or "")
|
||||
insight_body = collapse_duplicate_kandang(row.insight_text or "")
|
||||
insight_text = "\n\n".join(
|
||||
part for part in [summary, insight_body] if part
|
||||
) or insight_body or ""
|
||||
structured = root_cause.parse_embedded_end_cycle(insight_body)
|
||||
if structured and summary and not structured.get("kesimpulan"):
|
||||
structured = {**structured, "kesimpulan": summary}
|
||||
elif structured:
|
||||
structured = sanitize_insight_narrative(structured)
|
||||
return {
|
||||
"success": True,
|
||||
"id": row.pk,
|
||||
@@ -511,13 +724,10 @@ def serialize_insight(row: AIInsight) -> dict[str, Any]:
|
||||
"topic": row.topic,
|
||||
"report_type": row.report_type,
|
||||
"report_period": row.report_period,
|
||||
"summary": row.summary or "",
|
||||
"insight": row.insight_text or "",
|
||||
"insight_text": "\n\n".join(
|
||||
part for part in [row.summary or "", row.insight_text or ""] if part
|
||||
)
|
||||
or row.insight_text
|
||||
or "",
|
||||
"summary": summary,
|
||||
"insight": insight_body,
|
||||
"insight_text": insight_text,
|
||||
"structured_end_cycle": structured,
|
||||
"alert": row.alert or ALERT_UNKNOWN,
|
||||
"citations": norm_citations,
|
||||
"date": row.date.isoformat() if row.date else None,
|
||||
@@ -579,16 +789,46 @@ def generate_insight(
|
||||
day = graded.get("hari_ke")
|
||||
standard_block = cp707.build_cp707_standard_block(ctx) or cp707.build_book_reference_context(day or 1)
|
||||
|
||||
rag_query = f"panduan manajemen broiler CP 707 untuk {topic} umur hari ke-{day or '?'}"
|
||||
is_end_cycle = report_type == "end_cycle"
|
||||
hypotheses: list[dict[str, Any]] = []
|
||||
next_actions: list[dict[str, Any]] = []
|
||||
masalah: list[str] = []
|
||||
if is_end_cycle:
|
||||
hypotheses = root_cause.build_root_cause_hypotheses(graded, ctx)
|
||||
next_actions = root_cause.build_next_cycle_actions(hypotheses)
|
||||
masalah = root_cause.build_masalah_from_graded(graded)
|
||||
# Prefer phase-targeted RAG for end-cycle remediation.
|
||||
top_fase = next((h.get("fase") for h in hypotheses if h.get("fase")), None)
|
||||
rag_query = (
|
||||
f"panduan CP 707 perbaikan {top_fase or 'broiler'} siklus berikutnya "
|
||||
f"umur hari ke-{day or '?'}"
|
||||
)
|
||||
else:
|
||||
rag_query = f"panduan manajemen broiler CP 707 untuk {topic} umur hari ke-{day or '?'}"
|
||||
chunks, citations = fetch_rag_chunks(rag_query, topic)
|
||||
|
||||
system_prompt = (
|
||||
"Anda adalah asisten farm broiler on-premise. Tugas Anda HANYA menulis narasi "
|
||||
"dari GRADED FACTS + standar CP 707 + cuplikan SOP. Jangan menghitung ulang.\n"
|
||||
f"{ANTI_HALLUCINATION_RULES}\n"
|
||||
)
|
||||
if is_end_cycle:
|
||||
# End-cycle replaces the 2-field daily format instructions.
|
||||
system_prompt = system_prompt.replace(
|
||||
'FORMAT OUTPUT (JSON SAJA):\n{"kesimpulan":"...","insight":"..."}',
|
||||
END_CYCLE_OUTPUT_RULES.strip(),
|
||||
)
|
||||
system_prompt += (
|
||||
f"{standard_block}\n"
|
||||
f"[GRADED FACTS]\n{json.dumps(graded, ensure_ascii=False, default=str)}\n"
|
||||
)
|
||||
if is_end_cycle:
|
||||
system_prompt += (
|
||||
f"\n[ROOT CAUSE CANDIDATES]\n"
|
||||
f"{json.dumps(hypotheses, ensure_ascii=False, default=str)}\n"
|
||||
f"\n[NEXT CYCLE ACTIONS]\n"
|
||||
f"{json.dumps(next_actions, ensure_ascii=False, default=str)}\n"
|
||||
)
|
||||
if chunks:
|
||||
system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(chunks[:4])
|
||||
|
||||
@@ -601,14 +841,41 @@ def generate_insight(
|
||||
)
|
||||
|
||||
raw = call_ollama(system_prompt, user_prompt)
|
||||
parsed = parse_llm_json(raw or "")
|
||||
if not parsed:
|
||||
parsed = local_fallback_insight(graded, topic)
|
||||
if is_end_cycle:
|
||||
parsed_raw = parse_end_cycle_llm_json(raw or "")
|
||||
if parsed_raw:
|
||||
# Apply mortality wording on narrative fields before sanitize.
|
||||
soft = {
|
||||
"kesimpulan": str(parsed_raw.get("kesimpulan") or ""),
|
||||
"insight": str(parsed_raw.get("insight") or ""),
|
||||
}
|
||||
soft = enforce_mortality_wording(soft, graded)
|
||||
soft = sanitize_insight_narrative(soft)
|
||||
parsed_raw["kesimpulan"] = soft.get("kesimpulan")
|
||||
parsed_raw["insight"] = soft.get("insight")
|
||||
payload = root_cause.sanitize_end_cycle_payload(
|
||||
parsed_raw,
|
||||
hypotheses=hypotheses,
|
||||
actions=next_actions,
|
||||
masalah=masalah,
|
||||
)
|
||||
payload = sanitize_insight_narrative(payload)
|
||||
else:
|
||||
payload = root_cause.local_fallback_end_cycle(graded, ctx)
|
||||
summary = collapse_duplicate_kandang(str(payload.get("kesimpulan") or ""))
|
||||
insight_text = collapse_duplicate_kandang(
|
||||
root_cause.format_end_cycle_insight_text(payload)
|
||||
)
|
||||
else:
|
||||
parsed = enforce_mortality_wording(parsed, graded)
|
||||
parsed = parse_llm_json(raw or "")
|
||||
if not parsed:
|
||||
parsed = local_fallback_insight(graded, topic)
|
||||
else:
|
||||
parsed = enforce_mortality_wording(parsed, graded)
|
||||
parsed = sanitize_insight_narrative(parsed)
|
||||
insight_text = parsed["insight"]
|
||||
summary = parsed["kesimpulan"]
|
||||
|
||||
insight_text = parsed["insight"]
|
||||
summary = parsed["kesimpulan"]
|
||||
alert = alert_from_graded(graded)
|
||||
|
||||
row, _created = AIInsight.objects.update_or_create(
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
"""Deterministic root-cause candidates and next-cycle actions for end-cycle insights.
|
||||
|
||||
LLM may only narrate/refine wording from these lists — never invent new causes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
CONFIDENCE_HIGH = "high"
|
||||
CONFIDENCE_MED = "med"
|
||||
CONFIDENCE_LOW = "low"
|
||||
|
||||
PHASE_BROODING = "brooding"
|
||||
PHASE_GROWTH = "growth"
|
||||
PHASE_FINISHER = "finisher"
|
||||
|
||||
END_CYCLE_JSON_MARKER = "<!--end_cycle_json-->"
|
||||
|
||||
_CONF_RANK = {CONFIDENCE_LOW: 1, CONFIDENCE_MED: 2, CONFIDENCE_HIGH: 3}
|
||||
|
||||
|
||||
def _as_float(value: Any) -> float | None:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _as_int(value: Any) -> int | None:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _analysis(graded: dict[str, Any] | None, key: str) -> dict[str, Any]:
|
||||
analyses = (graded or {}).get("analyses") or {}
|
||||
block = analyses.get(key) if isinstance(analyses, dict) else None
|
||||
return block if isinstance(block, dict) else {}
|
||||
|
||||
|
||||
def _weekly_rows(context: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
rows = (context or {}).get("weekly_summaries")
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
def _phase_rows(context: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raw = (context or {}).get("phase_summaries")
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _mortality_delta_by_week(weeks: list[dict[str, Any]]) -> dict[int, float]:
|
||||
out: dict[int, float] = {}
|
||||
for row in weeks:
|
||||
week = _as_int(row.get("minggu_ke") or row.get("week"))
|
||||
delta = _as_float(
|
||||
row.get("mortalitas_delta_persen")
|
||||
or row.get("mortality_delta_pct")
|
||||
or row.get("mortalitas_delta")
|
||||
)
|
||||
if week is not None and delta is not None:
|
||||
out[week] = delta
|
||||
return out
|
||||
|
||||
|
||||
def _iot_cold_hours_early(weeks: list[dict[str, Any]]) -> float:
|
||||
total = 0.0
|
||||
for row in weeks:
|
||||
week = _as_int(row.get("minggu_ke") or row.get("week"))
|
||||
if week is None or week > 2:
|
||||
continue
|
||||
hours = _as_float(
|
||||
row.get("jam_suhu_di_bawah_standar")
|
||||
or row.get("temp_below_hours")
|
||||
or row.get("hours_temp_below")
|
||||
)
|
||||
if hours is not None and hours > 0:
|
||||
total += hours
|
||||
return total
|
||||
|
||||
|
||||
def _iot_humid_or_ammonia_late(weeks: list[dict[str, Any]]) -> tuple[float, float]:
|
||||
humid = 0.0
|
||||
ammonia = 0.0
|
||||
for row in weeks:
|
||||
week = _as_int(row.get("minggu_ke") or row.get("week"))
|
||||
if week is None or week < 4:
|
||||
continue
|
||||
h = _as_float(
|
||||
row.get("jam_kelembapan_di_atas_standar")
|
||||
or row.get("humidity_above_hours")
|
||||
or row.get("hours_humidity_above")
|
||||
)
|
||||
a = _as_float(
|
||||
row.get("jam_amonia_tinggi")
|
||||
or row.get("ammonia_high_hours")
|
||||
or row.get("hours_ammonia_high")
|
||||
)
|
||||
if h is not None and h > 0:
|
||||
humid += h
|
||||
if a is not None and a > 0:
|
||||
ammonia += a
|
||||
return humid, ammonia
|
||||
|
||||
|
||||
def build_masalah_from_graded(graded: dict[str, Any] | None) -> list[str]:
|
||||
"""Critical/warning graded messages become the Masalah bullet list."""
|
||||
analyses = (graded or {}).get("analyses") or {}
|
||||
if not isinstance(analyses, dict):
|
||||
return []
|
||||
masalah: list[str] = []
|
||||
for key in ("mortality", "fcr", "bw", "environment"):
|
||||
block = analyses.get(key)
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
status = str(block.get("status") or "").lower()
|
||||
if status not in ("warning", "critical"):
|
||||
continue
|
||||
msg = str(block.get("message") or "").strip()
|
||||
if msg:
|
||||
masalah.append(msg)
|
||||
return masalah
|
||||
|
||||
|
||||
def build_root_cause_hypotheses(
|
||||
graded: dict[str, Any] | None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rank evidence-backed root-cause candidates for end-cycle narration."""
|
||||
ctx = context or {}
|
||||
weeks = _weekly_rows(ctx)
|
||||
phases = _phase_rows(ctx)
|
||||
mort = _analysis(graded, "mortality")
|
||||
fcr = _analysis(graded, "fcr")
|
||||
bw = _analysis(graded, "bw")
|
||||
env = _analysis(graded, "environment")
|
||||
|
||||
mort_status = str(mort.get("status") or "").lower()
|
||||
fcr_status = str(fcr.get("status") or "").lower()
|
||||
bw_status = str(bw.get("status") or "").lower()
|
||||
env_status = str(env.get("status") or "").lower()
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
|
||||
mort_by_week = _mortality_delta_by_week(weeks)
|
||||
early_mort = (mort_by_week.get(1) or 0) + (mort_by_week.get(2) or 0)
|
||||
brooding_phase = (
|
||||
phases.get(PHASE_BROODING) if isinstance(phases.get(PHASE_BROODING), dict) else {}
|
||||
)
|
||||
brooding_mort = _as_float(
|
||||
brooding_phase.get("mortalitas_delta_persen")
|
||||
or brooding_phase.get("mortalitas_kumulatif_persen")
|
||||
)
|
||||
cold_hours = _iot_cold_hours_early(weeks)
|
||||
env_cold = env_status in ("warning", "critical") and "bawah" in str(
|
||||
env.get("message") or ""
|
||||
).lower()
|
||||
|
||||
if (early_mort >= 2.0 or (brooding_mort is not None and brooding_mort >= 2.0)) and (
|
||||
cold_hours >= 4 or env_cold
|
||||
):
|
||||
bukti: list[str] = []
|
||||
if early_mort >= 2.0:
|
||||
bukti.append(f"Lonjakan mortalitas minggu 1–2: +{early_mort:.2f} poin persen")
|
||||
elif brooding_mort is not None:
|
||||
bukti.append(f"Mortalitas fase brooding: {brooding_mort:.2f}%")
|
||||
if cold_hours >= 4:
|
||||
bukti.append(
|
||||
f"Jumlah pembacaan data suhu di bawah standar (minggu 1–2): {cold_hours:.0f}"
|
||||
)
|
||||
if env_cold and env.get("message"):
|
||||
bukti.append(str(env["message"]))
|
||||
candidates.append(
|
||||
{
|
||||
"id": "brooding_suhu",
|
||||
"hipotesis": "Brooding / kontrol suhu",
|
||||
"bukti": bukti,
|
||||
"confidence": CONFIDENCE_HIGH,
|
||||
"fase": PHASE_BROODING,
|
||||
}
|
||||
)
|
||||
elif early_mort >= 3.0 or (brooding_mort is not None and brooding_mort >= 3.0):
|
||||
bukti = []
|
||||
if early_mort >= 3.0:
|
||||
bukti.append(f"Lonjakan mortalitas minggu 1–2: +{early_mort:.2f} poin persen")
|
||||
if brooding_mort is not None:
|
||||
bukti.append(f"Mortalitas fase brooding: {brooding_mort:.2f}%")
|
||||
candidates.append(
|
||||
{
|
||||
"id": "brooding_manajemen",
|
||||
"hipotesis": "Manajemen brooding (tanpa sinyal suhu lengkap)",
|
||||
"bukti": bukti,
|
||||
"confidence": CONFIDENCE_MED,
|
||||
"fase": PHASE_BROODING,
|
||||
}
|
||||
)
|
||||
|
||||
fcr_lag = fcr_status in ("warning", "critical")
|
||||
bw_lag = bw_status in ("warning", "critical") or str(
|
||||
bw.get("direction") or ""
|
||||
) == "di_bawah_standar"
|
||||
if fcr_lag and bw_lag:
|
||||
bukti = []
|
||||
if fcr.get("message"):
|
||||
bukti.append(str(fcr["message"]))
|
||||
if bw.get("message"):
|
||||
bukti.append(str(bw["message"]))
|
||||
mid_fcr = None
|
||||
for row in weeks:
|
||||
week = _as_int(row.get("minggu_ke") or row.get("week"))
|
||||
if week in (3, 4):
|
||||
mid_fcr = _as_float(row.get("fcr_akhir_rasio") or row.get("fcr_rata_rasio"))
|
||||
if mid_fcr is not None:
|
||||
bukti.append(f"FCR pertengahan siklus (minggu 3–4): {mid_fcr:.3f}")
|
||||
candidates.append(
|
||||
{
|
||||
"id": "pakan_akses",
|
||||
"hipotesis": "Pakan / akses pakan",
|
||||
"bukti": bukti or ["FCR dan bobot menyimpang dari standar CP 707"],
|
||||
"confidence": (
|
||||
CONFIDENCE_HIGH
|
||||
if fcr_status == "critical" or bw_status == "critical"
|
||||
else CONFIDENCE_MED
|
||||
),
|
||||
"fase": PHASE_GROWTH,
|
||||
}
|
||||
)
|
||||
elif fcr_lag:
|
||||
bukti = [str(fcr["message"])] if fcr.get("message") else ["FCR di atas standar CP 707"]
|
||||
candidates.append(
|
||||
{
|
||||
"id": "fcr_tinggi",
|
||||
"hipotesis": "Efisiensi pakan menurun",
|
||||
"bukti": bukti,
|
||||
"confidence": CONFIDENCE_MED,
|
||||
"fase": PHASE_GROWTH,
|
||||
}
|
||||
)
|
||||
|
||||
humid_h, ammonia_h = _iot_humid_or_ammonia_late(weeks)
|
||||
late_mort = sum(v for w, v in mort_by_week.items() if w >= 4)
|
||||
env_humid_or_ammonia = env_status in ("warning", "critical") and any(
|
||||
k in str(env.get("message") or "").lower() for k in ("kelembapan", "amonia", "ammonia")
|
||||
)
|
||||
if (humid_h >= 4 or ammonia_h >= 2 or env_humid_or_ammonia) and (
|
||||
late_mort >= 1.5 or mort_status in ("warning", "critical")
|
||||
):
|
||||
bukti = []
|
||||
if late_mort >= 1.5:
|
||||
bukti.append(f"Kenaikan mortalitas minggu 4+: +{late_mort:.2f} poin persen")
|
||||
if humid_h >= 4:
|
||||
bukti.append(
|
||||
f"Jumlah pembacaan data kelembapan di atas standar (minggu 4+): {humid_h:.0f}"
|
||||
)
|
||||
if ammonia_h >= 2:
|
||||
bukti.append(
|
||||
f"Jumlah pembacaan data amonia tinggi (minggu 4+): {ammonia_h:.0f}"
|
||||
)
|
||||
if env.get("message"):
|
||||
bukti.append(str(env["message"]))
|
||||
candidates.append(
|
||||
{
|
||||
"id": "litter_ventilasi",
|
||||
"hipotesis": "Litter / ventilasi",
|
||||
"bukti": bukti,
|
||||
"confidence": CONFIDENCE_HIGH if ammonia_h >= 2 or humid_h >= 8 else CONFIDENCE_MED,
|
||||
"fase": PHASE_FINISHER,
|
||||
}
|
||||
)
|
||||
|
||||
uniformity = _as_float(ctx.get("uniformity_persen") or ctx.get("uniformity"))
|
||||
if uniformity is not None and uniformity < 80:
|
||||
candidates.append(
|
||||
{
|
||||
"id": "kerapatan_feeder",
|
||||
"hipotesis": "Kerapatan / ruang tempat pakan",
|
||||
"bukti": [f"Uniformity rendah: {uniformity:.1f}% (<80%)"],
|
||||
"confidence": CONFIDENCE_MED,
|
||||
"fase": PHASE_GROWTH,
|
||||
}
|
||||
)
|
||||
|
||||
has_specific = any(
|
||||
c["id"] in ("brooding_suhu", "brooding_manajemen", "litter_ventilasi", "pakan_akses")
|
||||
for c in candidates
|
||||
)
|
||||
if mort_status in ("warning", "critical") and not has_specific:
|
||||
bukti = []
|
||||
if mort.get("message"):
|
||||
bukti.append(str(mort["message"]))
|
||||
actual = mort.get("actual_pct")
|
||||
if isinstance(actual, (int, float)):
|
||||
bukti.append(f"Mortalitas kumulatif {float(actual):.2f}%")
|
||||
candidates.append(
|
||||
{
|
||||
"id": "penyakit_biosekuriti",
|
||||
"hipotesis": "Penyakit / biosekuriti",
|
||||
"bukti": bukti
|
||||
or ["Mortalitas di atas ambang CP 707 tanpa sinyal fase/IoT spesifik"],
|
||||
"confidence": CONFIDENCE_LOW,
|
||||
"fase": PHASE_BROODING,
|
||||
}
|
||||
)
|
||||
|
||||
candidates.sort(
|
||||
key=lambda c: (-_CONF_RANK.get(str(c.get("confidence")), 0), str(c.get("id")))
|
||||
)
|
||||
return candidates[:5]
|
||||
|
||||
|
||||
_ACTION_TEMPLATES: dict[str, dict[str, str]] = {
|
||||
"brooding_suhu": {
|
||||
"fase": PHASE_BROODING,
|
||||
"aksi": (
|
||||
"Perketat kontrol suhu brooding hari 1–14 sesuai target CP 707; "
|
||||
"cek heater dan distribusi panas."
|
||||
),
|
||||
"metrik_pantau": "jumlah pembacaan suhu out-of-range; mortalitas harian minggu 1–2",
|
||||
},
|
||||
"brooding_manajemen": {
|
||||
"fase": PHASE_BROODING,
|
||||
"aksi": (
|
||||
"Audit SOP brooding (kepadatan DOC, air minum, pencahayaan) "
|
||||
"sebelum chick-in siklus berikutnya."
|
||||
),
|
||||
"metrik_pantau": "mortalitas kumulatif hari 1–14; keseragaman DOC",
|
||||
},
|
||||
"pakan_akses": {
|
||||
"fase": PHASE_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",
|
||||
},
|
||||
"fcr_tinggi": {
|
||||
"fase": PHASE_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",
|
||||
},
|
||||
"litter_ventilasi": {
|
||||
"fase": PHASE_FINISHER,
|
||||
"aksi": (
|
||||
"Perbaiki manajemen litter dan ventilasi finisher; "
|
||||
"jaga kelembapan 50–70% dan amonia rendah."
|
||||
),
|
||||
"metrik_pantau": "jumlah pembacaan RH/amonia out-of-range; mortalitas minggu 4+",
|
||||
},
|
||||
"kerapatan_feeder": {
|
||||
"fase": PHASE_GROWTH,
|
||||
"aksi": "Sesuaikan kerapatan dan jumlah tempat pakan/minum agar uniformity naik.",
|
||||
"metrik_pantau": "uniformity %; CV bobot",
|
||||
},
|
||||
"penyakit_biosekuriti": {
|
||||
"fase": PHASE_BROODING,
|
||||
"aksi": (
|
||||
"Perketat biosekuriti, siapkan protokol nekropsi jika mortalitas kritis, "
|
||||
"dan tinjau vaksinasi/traffic kandang."
|
||||
),
|
||||
"metrik_pantau": "mortalitas harian; pola kematian per zona kandang",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_next_cycle_actions(
|
||||
hypotheses: list[dict[str, Any]] | None,
|
||||
*,
|
||||
limit: int = 3,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Map top hypotheses to phase-grouped next-cycle checklist items."""
|
||||
actions: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for hyp in hypotheses or []:
|
||||
hid = str(hyp.get("id") or "")
|
||||
template = _ACTION_TEMPLATES.get(hid)
|
||||
if not template or hid in seen:
|
||||
continue
|
||||
seen.add(hid)
|
||||
actions.append(
|
||||
{
|
||||
"id": hid,
|
||||
"fase": template["fase"],
|
||||
"aksi": template["aksi"],
|
||||
"metrik_pantau": template["metrik_pantau"],
|
||||
}
|
||||
)
|
||||
if len(actions) >= limit:
|
||||
break
|
||||
return actions
|
||||
|
||||
|
||||
def sanitize_end_cycle_payload(
|
||||
parsed: dict[str, Any] | None,
|
||||
*,
|
||||
hypotheses: list[dict[str, Any]],
|
||||
actions: list[dict[str, Any]],
|
||||
masalah: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Keep LLM wording only when causes stay inside the candidate list."""
|
||||
allowed_ids = {str(h.get("id")) for h in hypotheses if h.get("id")}
|
||||
allowed_labels = {
|
||||
str(h.get("hipotesis")).strip().lower() for h in hypotheses if h.get("hipotesis")
|
||||
}
|
||||
|
||||
raw = parsed if isinstance(parsed, dict) else {}
|
||||
kesimpulan = str(
|
||||
raw.get("kesimpulan") or raw.get("summary") or raw.get("ringkasan") or ""
|
||||
).strip()
|
||||
if not kesimpulan and masalah:
|
||||
kesimpulan = masalah[0]
|
||||
if not kesimpulan:
|
||||
kesimpulan = "Ringkasan akhir siklus tersedia dari graded facts."
|
||||
|
||||
raw_masalah = raw.get("masalah")
|
||||
if isinstance(raw_masalah, list) and raw_masalah:
|
||||
cleaned_masalah = [str(x).strip() for x in raw_masalah if str(x).strip()]
|
||||
else:
|
||||
cleaned_masalah = list(masalah)
|
||||
|
||||
akar: list[dict[str, Any]] = []
|
||||
raw_akar = raw.get("akar_penyebab")
|
||||
if isinstance(raw_akar, list):
|
||||
for item in raw_akar:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
hid = str(item.get("id") or "").strip()
|
||||
label = str(item.get("hipotesis") or "").strip()
|
||||
if hid and hid not in allowed_ids:
|
||||
continue
|
||||
if not hid and label.lower() not in allowed_labels:
|
||||
continue
|
||||
match = next(
|
||||
(
|
||||
h
|
||||
for h in hypotheses
|
||||
if str(h.get("id")) == hid
|
||||
or str(h.get("hipotesis")).lower() == label.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
continue
|
||||
bukti_raw = item.get("bukti")
|
||||
if isinstance(bukti_raw, list) and bukti_raw:
|
||||
bukti = [str(b).strip() for b in bukti_raw if str(b).strip()]
|
||||
else:
|
||||
bukti = list(match.get("bukti") or [])
|
||||
akar.append(
|
||||
{
|
||||
"id": match.get("id"),
|
||||
"hipotesis": label or match.get("hipotesis"),
|
||||
"bukti": bukti,
|
||||
"confidence": match.get("confidence") or CONFIDENCE_LOW,
|
||||
"fase": match.get("fase"),
|
||||
}
|
||||
)
|
||||
if not akar:
|
||||
akar = [
|
||||
{
|
||||
"id": h.get("id"),
|
||||
"hipotesis": h.get("hipotesis"),
|
||||
"bukti": list(h.get("bukti") or []),
|
||||
"confidence": h.get("confidence"),
|
||||
"fase": h.get("fase"),
|
||||
}
|
||||
for h in hypotheses
|
||||
]
|
||||
|
||||
perbaikan: list[dict[str, Any]] = []
|
||||
raw_fix = raw.get("perbaikan_siklus_berikutnya")
|
||||
allowed_action_ids = {str(a.get("id")) for a in actions if a.get("id")}
|
||||
if isinstance(raw_fix, list):
|
||||
for item in raw_fix:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
aid = str(item.get("id") or "").strip()
|
||||
aksi = str(item.get("aksi") or "").strip()
|
||||
if aid and aid not in allowed_action_ids:
|
||||
continue
|
||||
match = next((a for a in actions if str(a.get("id")) == aid), None)
|
||||
if match is None and aksi:
|
||||
match = next(
|
||||
(a for a in actions if aksi[:40].lower() in str(a.get("aksi") or "").lower()),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
continue
|
||||
perbaikan.append(
|
||||
{
|
||||
"id": match.get("id"),
|
||||
"fase": str(item.get("fase") or match.get("fase")),
|
||||
"aksi": aksi or match.get("aksi"),
|
||||
"metrik_pantau": str(
|
||||
item.get("metrik_pantau") or match.get("metrik_pantau") or ""
|
||||
),
|
||||
}
|
||||
)
|
||||
if not perbaikan:
|
||||
perbaikan = [
|
||||
{
|
||||
"id": a.get("id"),
|
||||
"fase": a.get("fase"),
|
||||
"aksi": a.get("aksi"),
|
||||
"metrik_pantau": a.get("metrik_pantau"),
|
||||
}
|
||||
for a in actions
|
||||
]
|
||||
|
||||
insight = str(raw.get("insight") or "").strip()
|
||||
if not insight and perbaikan:
|
||||
insight = "Prioritaskan perbaikan fase sesuai daftar tindakan siklus berikutnya."
|
||||
|
||||
return {
|
||||
"kesimpulan": kesimpulan,
|
||||
"masalah": cleaned_masalah,
|
||||
"akar_penyebab": akar,
|
||||
"perbaikan_siklus_berikutnya": perbaikan,
|
||||
"insight": insight,
|
||||
}
|
||||
|
||||
|
||||
def format_end_cycle_insight_text(payload: dict[str, Any]) -> str:
|
||||
"""Human-readable body + embedded JSON for the FE structured renderer."""
|
||||
lines: list[str] = []
|
||||
masalah = payload.get("masalah") or []
|
||||
if masalah:
|
||||
lines.append("Masalah:")
|
||||
for item in masalah:
|
||||
lines.append(f"• {item}")
|
||||
lines.append("")
|
||||
|
||||
akar = payload.get("akar_penyebab") or []
|
||||
if akar:
|
||||
lines.append("Akar penyebab:")
|
||||
for item in akar:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
conf = item.get("confidence") or ""
|
||||
label = item.get("hipotesis") or ""
|
||||
lines.append(f"• {label}" + (f" ({conf})" if conf else ""))
|
||||
for bukti in item.get("bukti") or []:
|
||||
lines.append(f" - {bukti}")
|
||||
lines.append("")
|
||||
|
||||
perbaikan = payload.get("perbaikan_siklus_berikutnya") or []
|
||||
if perbaikan:
|
||||
lines.append("Perbaikan siklus berikutnya:")
|
||||
for item in perbaikan:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
fase = item.get("fase") or ""
|
||||
aksi = item.get("aksi") or ""
|
||||
metrik = item.get("metrik_pantau") or ""
|
||||
lines.append(f"• [{fase}] {aksi}")
|
||||
if metrik:
|
||||
lines.append(f" Pantau: {metrik}")
|
||||
lines.append("")
|
||||
|
||||
closing = str(payload.get("insight") or "").strip()
|
||||
if closing:
|
||||
lines.append(closing)
|
||||
|
||||
readable = "\n".join(lines).strip()
|
||||
embedded = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
if readable:
|
||||
return f"{readable}\n\n{END_CYCLE_JSON_MARKER}\n{embedded}"
|
||||
return f"{END_CYCLE_JSON_MARKER}\n{embedded}"
|
||||
|
||||
|
||||
def parse_embedded_end_cycle(text: str) -> dict[str, Any] | None:
|
||||
"""Extract structured end-cycle payload from stored insight_text."""
|
||||
if not text or END_CYCLE_JSON_MARKER not in text:
|
||||
return None
|
||||
raw = text.split(END_CYCLE_JSON_MARKER, 1)[1].strip()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def local_fallback_end_cycle(
|
||||
graded: dict[str, Any] | None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
hypotheses = build_root_cause_hypotheses(graded, context)
|
||||
actions = build_next_cycle_actions(hypotheses)
|
||||
masalah = build_masalah_from_graded(graded)
|
||||
return sanitize_end_cycle_payload(
|
||||
None,
|
||||
hypotheses=hypotheses,
|
||||
actions=actions,
|
||||
masalah=masalah,
|
||||
)
|
||||
@@ -15,6 +15,22 @@ class ResolveDashboardFieldsTests(SimpleTestCase):
|
||||
def test_resolve_bw_from_bobot_avg_gram(self):
|
||||
self.assertEqual(cp707._resolve_bw_grams({"bobot_avg_gram": 2100}), 2100.0)
|
||||
|
||||
def test_resolve_bw_treats_zero_as_missing(self):
|
||||
self.assertIsNone(cp707._resolve_bw_grams({"bobot_avg_gram": 0}))
|
||||
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})
|
||||
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})
|
||||
fcr = graded["analyses"]["fcr"]
|
||||
self.assertEqual(fcr["status"], "unknown")
|
||||
self.assertIn("tidak tersedia", fcr["message"].lower())
|
||||
|
||||
def test_resolve_bw_from_average_weight(self):
|
||||
self.assertEqual(cp707._resolve_bw_grams({"averageWeight": 1950}), 1950.0)
|
||||
|
||||
@@ -79,7 +95,7 @@ class InsightPromptNamingTests(SimpleTestCase):
|
||||
report_period="current",
|
||||
context={"hari_ke": 48, "kandangId": 6, "cycleId": 99},
|
||||
)
|
||||
self.assertIn("untuk `Kandang 2`", prompt)
|
||||
self.assertIn("untuk unit bernama `Kandang 2`", prompt)
|
||||
self.assertNotIn("untuk kandang `Kandang 2`", prompt)
|
||||
self.assertNotIn("id=", prompt)
|
||||
self.assertNotIn('"kandangId"', prompt)
|
||||
@@ -94,6 +110,30 @@ class InsightPromptNamingTests(SimpleTestCase):
|
||||
self.assertIn("severity_label", rules)
|
||||
|
||||
|
||||
class DuplicateKandangNameTests(SimpleTestCase):
|
||||
def test_collapse_kandang_kandang(self):
|
||||
from apps.operations.services.insight_service import collapse_duplicate_kandang
|
||||
|
||||
self.assertEqual(
|
||||
collapse_duplicate_kandang(
|
||||
"Kandang Kandang 2 mengalami mortalitas kumulatif sebesar 10.44%."
|
||||
),
|
||||
"Kandang 2 mengalami mortalitas kumulatif sebesar 10.44%.",
|
||||
)
|
||||
|
||||
def test_sanitize_insight_narrative_fields(self):
|
||||
from apps.operations.services.insight_service import sanitize_insight_narrative
|
||||
|
||||
fixed = sanitize_insight_narrative(
|
||||
{
|
||||
"kesimpulan": "Kandang Kandang 2 mortalitas tinggi.",
|
||||
"insight": "Pantau kandang kandang 2 setiap hari.",
|
||||
}
|
||||
)
|
||||
self.assertEqual(fixed["kesimpulan"], "Kandang 2 mortalitas tinggi.")
|
||||
self.assertEqual(fixed["insight"], "Pantau kandang 2 setiap hari.")
|
||||
|
||||
|
||||
class MortalityWordingConsistencyTests(SimpleTestCase):
|
||||
def test_critical_mortality_has_explicit_severity_label(self):
|
||||
result = cp707.analyze_mortality(9.49, 48)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Unit tests for deterministic end-cycle root-cause candidates."""
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.operations.services import root_cause as rc
|
||||
|
||||
|
||||
class RootCauseHypothesisTests(SimpleTestCase):
|
||||
def test_early_mortality_plus_cold_hours_yields_brooding_suhu(self):
|
||||
graded = {
|
||||
"analyses": {
|
||||
"mortality": {
|
||||
"status": "critical",
|
||||
"severity_label": "SANGAT TINGGI",
|
||||
"actual_pct": 9.5,
|
||||
"message": "Mortalitas kumulatif 9.50% adalah SANGAT TINGGI",
|
||||
}
|
||||
}
|
||||
}
|
||||
context = {
|
||||
"weekly_summaries": [
|
||||
{
|
||||
"minggu_ke": 1,
|
||||
"mortalitas_delta_persen": 2.5,
|
||||
"jam_suhu_di_bawah_standar": 12,
|
||||
},
|
||||
{"minggu_ke": 2, "mortalitas_delta_persen": 1.2, "jam_suhu_di_bawah_standar": 6},
|
||||
]
|
||||
}
|
||||
hyps = rc.build_root_cause_hypotheses(graded, context)
|
||||
self.assertTrue(hyps)
|
||||
self.assertEqual(hyps[0]["id"], "brooding_suhu")
|
||||
self.assertEqual(hyps[0]["confidence"], "high")
|
||||
self.assertEqual(hyps[0]["fase"], "brooding")
|
||||
|
||||
def test_fcr_and_bw_lag_yields_pakan(self):
|
||||
graded = {
|
||||
"analyses": {
|
||||
"fcr": {"status": "critical", "message": "FCR di atas standar"},
|
||||
"bw": {
|
||||
"status": "warning",
|
||||
"direction": "di_bawah_standar",
|
||||
"message": "Bobot di bawah standar",
|
||||
},
|
||||
"mortality": {"status": "ok", "message": "Mortalitas normal"},
|
||||
}
|
||||
}
|
||||
hyps = rc.build_root_cause_hypotheses(graded, {})
|
||||
ids = [h["id"] for h in hyps]
|
||||
self.assertIn("pakan_akses", ids)
|
||||
|
||||
def test_high_mortality_alone_is_low_confidence_biosecurity(self):
|
||||
graded = {
|
||||
"analyses": {
|
||||
"mortality": {
|
||||
"status": "critical",
|
||||
"actual_pct": 9.49,
|
||||
"message": "Mortalitas kumulatif 9.49% adalah SANGAT TINGGI",
|
||||
}
|
||||
}
|
||||
}
|
||||
hyps = rc.build_root_cause_hypotheses(graded, {})
|
||||
self.assertEqual(len(hyps), 1)
|
||||
self.assertEqual(hyps[0]["id"], "penyakit_biosekuriti")
|
||||
self.assertEqual(hyps[0]["confidence"], "low")
|
||||
|
||||
|
||||
class NextCycleActionsTests(SimpleTestCase):
|
||||
def test_actions_map_from_top_hypotheses(self):
|
||||
hyps = [
|
||||
{
|
||||
"id": "brooding_suhu",
|
||||
"hipotesis": "Brooding / kontrol suhu",
|
||||
"confidence": "high",
|
||||
"fase": "brooding",
|
||||
"bukti": [],
|
||||
},
|
||||
{
|
||||
"id": "pakan_akses",
|
||||
"hipotesis": "Pakan / akses pakan",
|
||||
"confidence": "med",
|
||||
"fase": "growth",
|
||||
"bukti": [],
|
||||
},
|
||||
]
|
||||
actions = rc.build_next_cycle_actions(hyps)
|
||||
self.assertEqual(len(actions), 2)
|
||||
self.assertEqual(actions[0]["fase"], "brooding")
|
||||
self.assertIn("suhu", actions[0]["aksi"].lower())
|
||||
self.assertTrue(actions[0]["metrik_pantau"])
|
||||
|
||||
|
||||
class SanitizeEndCycleTests(SimpleTestCase):
|
||||
def test_drops_invented_causes(self):
|
||||
hyps = [
|
||||
{
|
||||
"id": "penyakit_biosekuriti",
|
||||
"hipotesis": "Penyakit / biosekuriti",
|
||||
"confidence": "low",
|
||||
"fase": "brooding",
|
||||
"bukti": ["mort high"],
|
||||
}
|
||||
]
|
||||
actions = rc.build_next_cycle_actions(hyps)
|
||||
parsed = {
|
||||
"kesimpulan": "Ada masalah mortalitas",
|
||||
"masalah": ["Mortalitas tinggi"],
|
||||
"akar_penyebab": [
|
||||
{"id": "alien_cause", "hipotesis": "Serangan alien", "bukti": ["X"]},
|
||||
{
|
||||
"id": "penyakit_biosekuriti",
|
||||
"hipotesis": "Penyakit / biosekuriti",
|
||||
"bukti": ["Nekropsi diperlukan"],
|
||||
},
|
||||
],
|
||||
"perbaikan_siklus_berikutnya": [
|
||||
{"id": "alien_cause", "fase": "brooding", "aksi": "Do nothing"},
|
||||
{
|
||||
"id": "penyakit_biosekuriti",
|
||||
"fase": "brooding",
|
||||
"aksi": "Perketat biosekuriti custom",
|
||||
"metrik_pantau": "mortalitas harian",
|
||||
},
|
||||
],
|
||||
"insight": "Fokus biosekuriti",
|
||||
}
|
||||
out = rc.sanitize_end_cycle_payload(
|
||||
parsed, hypotheses=hyps, actions=actions, masalah=["Mortalitas tinggi"]
|
||||
)
|
||||
ids = [a["id"] for a in out["akar_penyebab"]]
|
||||
self.assertEqual(ids, ["penyakit_biosekuriti"])
|
||||
self.assertNotIn("alien_cause", [p["id"] for p in out["perbaikan_siklus_berikutnya"]])
|
||||
self.assertIn("biosekuriti", out["perbaikan_siklus_berikutnya"][0]["aksi"].lower())
|
||||
|
||||
def test_format_embeds_json_marker(self):
|
||||
payload = rc.local_fallback_end_cycle(
|
||||
{
|
||||
"analyses": {
|
||||
"mortality": {
|
||||
"status": "critical",
|
||||
"actual_pct": 9.0,
|
||||
"message": "Mortalitas kritis",
|
||||
}
|
||||
}
|
||||
},
|
||||
{},
|
||||
)
|
||||
text = rc.format_end_cycle_insight_text(payload)
|
||||
self.assertIn(rc.END_CYCLE_JSON_MARKER, text)
|
||||
parsed = rc.parse_embedded_end_cycle(text)
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual(parsed["kesimpulan"], payload["kesimpulan"])
|
||||
@@ -8,6 +8,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
import { api, errorMessage } from '../services/apiClient.ts';
|
||||
import type {
|
||||
EndCycleStructuredInsight,
|
||||
InsightCitation,
|
||||
InsightContext,
|
||||
InsightReportType,
|
||||
@@ -16,7 +17,9 @@ import type {
|
||||
InsightStatusLabel,
|
||||
} from '../types/api.ts';
|
||||
import {
|
||||
buildEndCycleRollups,
|
||||
type DashboardInsightMetrics,
|
||||
type EndCycleIotDayAggregate,
|
||||
scopeDashboardInsightMetrics,
|
||||
} from '../utils/insightContextBuilders.ts';
|
||||
import { getCobbStandardForDay } from '../utils/insightHelpers.ts';
|
||||
@@ -25,6 +28,7 @@ import { HeroFigure, MutedStatRow, StatTile, type InsightStatus } from './shared
|
||||
import InsightTrendChart from './shared/InsightTrendChart.tsx';
|
||||
|
||||
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes
|
||||
const END_CYCLE_JSON_MARKER = '<!--end_cycle_json-->';
|
||||
|
||||
const sectionBadgeClasses: Record<InsightStatusLabel, string> = {
|
||||
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
@@ -33,6 +37,12 @@ const sectionBadgeClasses: Record<InsightStatusLabel, string> = {
|
||||
unknown: 'border-slate-200 bg-slate-50 text-slate-600',
|
||||
};
|
||||
|
||||
const confidenceBadge: Record<string, string> = {
|
||||
high: 'border-red-200 bg-red-50 text-red-700',
|
||||
med: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
low: 'border-slate-200 bg-slate-50 text-slate-600',
|
||||
};
|
||||
|
||||
export interface AiInsightCardProps {
|
||||
/** Required — insight is always per kandang. */
|
||||
kandangId: number;
|
||||
@@ -40,6 +50,8 @@ export interface AiInsightCardProps {
|
||||
context?: InsightContext;
|
||||
/** Display-only KPIs + weight series (not sent to the LLM). */
|
||||
dashboardMetrics?: DashboardInsightMetrics | null;
|
||||
/** Optional IoT day aggregates for end-cycle root-cause enrichment. */
|
||||
iotDayAggregates?: EndCycleIotDayAggregate[];
|
||||
page?: 'hitung_ayam' | 'berat_ayam' | 'fcr' | 'eef' | 'panel_iot' | 'data_quality' | 'dashboard';
|
||||
}
|
||||
|
||||
@@ -133,6 +145,73 @@ const normalizeStructuredInsight = (rawText: string): InsightStructuredReport |
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeEndCycleInsight = (
|
||||
raw: unknown,
|
||||
fallbackSummary?: string | null
|
||||
): EndCycleStructuredInsight | null => {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const parsed = raw as Record<string, unknown>;
|
||||
const hasShape =
|
||||
Array.isArray(parsed.masalah) ||
|
||||
Array.isArray(parsed.akar_penyebab) ||
|
||||
Array.isArray(parsed.perbaikan_siklus_berikutnya);
|
||||
if (!hasShape) return null;
|
||||
|
||||
const akarRaw = Array.isArray(parsed.akar_penyebab) ? parsed.akar_penyebab : [];
|
||||
const fixRaw = Array.isArray(parsed.perbaikan_siklus_berikutnya)
|
||||
? parsed.perbaikan_siklus_berikutnya
|
||||
: [];
|
||||
|
||||
return {
|
||||
kesimpulan:
|
||||
asNonEmptyString(parsed.kesimpulan) ||
|
||||
asNonEmptyString(fallbackSummary) ||
|
||||
'Ringkasan akhir siklus',
|
||||
masalah: asStringArray(parsed.masalah, 8),
|
||||
akar_penyebab: akarRaw
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
id: asNonEmptyString(item.id) ?? undefined,
|
||||
hipotesis: asNonEmptyString(item.hipotesis) || 'Hipotesis',
|
||||
bukti: asStringArray(item.bukti, 6),
|
||||
confidence: asNonEmptyString(item.confidence) ?? undefined,
|
||||
fase: asNonEmptyString(item.fase) ?? undefined,
|
||||
})),
|
||||
perbaikan_siklus_berikutnya: fixRaw
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
id: asNonEmptyString(item.id) ?? undefined,
|
||||
fase: asNonEmptyString(item.fase) || 'growth',
|
||||
aksi: asNonEmptyString(item.aksi) || '',
|
||||
metrik_pantau: asNonEmptyString(item.metrik_pantau) ?? undefined,
|
||||
}))
|
||||
.filter((item) => item.aksi.length > 0),
|
||||
insight: asNonEmptyString(parsed.insight) ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const parseEndCycleFromText = (
|
||||
text: string,
|
||||
fallbackSummary?: string | null
|
||||
): EndCycleStructuredInsight | null => {
|
||||
if (!text) return null;
|
||||
if (text.includes(END_CYCLE_JSON_MARKER)) {
|
||||
const raw = text.split(END_CYCLE_JSON_MARKER, 2)[1]?.trim() ?? '';
|
||||
try {
|
||||
return normalizeEndCycleInsight(JSON.parse(raw), fallbackSummary);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const jsonText = extractJsonObject(text);
|
||||
if (!jsonText) return null;
|
||||
try {
|
||||
return normalizeEndCycleInsight(JSON.parse(jsonText), fallbackSummary);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => {
|
||||
if (!citations || citations.length === 0) return null;
|
||||
return (
|
||||
@@ -163,12 +242,14 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
||||
kandangId,
|
||||
context: contextProp,
|
||||
dashboardMetrics = null,
|
||||
iotDayAggregates = [],
|
||||
page = 'dashboard',
|
||||
}) => {
|
||||
const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm();
|
||||
|
||||
const [insightText, setInsightText] = useState('');
|
||||
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
|
||||
const [endCycleInsight, setEndCycleInsight] = useState<EndCycleStructuredInsight | null>(null);
|
||||
const [citations, setCitations] = useState<InsightCitation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
@@ -203,10 +284,22 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
||||
const topic = page === 'dashboard' ? 'dashboard' : page === 'panel_iot' ? 'iot_panel' : page;
|
||||
|
||||
const applyInsight = useCallback(
|
||||
(text: string, nextCitations?: InsightCitation[]) => {
|
||||
(
|
||||
text: string,
|
||||
nextCitations?: InsightCitation[] | 'cache' | 'local_fallback',
|
||||
structuredEndCycle?: EndCycleStructuredInsight | null,
|
||||
summary?: string | null
|
||||
) => {
|
||||
const citationsArg = Array.isArray(nextCitations) ? nextCitations : [];
|
||||
setInsightText(text);
|
||||
setStructuredInsight(normalizeStructuredInsight(text));
|
||||
setCitations(nextCitations ?? []);
|
||||
const endCycle =
|
||||
structuredEndCycle ??
|
||||
parseEndCycleFromText(text, summary) ??
|
||||
(typeof nextCitations === 'string' ? null : null);
|
||||
setEndCycleInsight(endCycle);
|
||||
// Prefer end-cycle structured UI over legacy multi-topic structured dump.
|
||||
setStructuredInsight(endCycle ? null : normalizeStructuredInsight(text));
|
||||
setCitations(citationsArg);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -263,6 +356,22 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
||||
contextPayload.pakan_kumulatif_karung = scopedMetrics.pakan_kumulatif_karung;
|
||||
}
|
||||
|
||||
if (reportType === 'end_cycle' && scopedMetrics) {
|
||||
const rollups = buildEndCycleRollups(scopedMetrics, iotDayAggregates);
|
||||
contextPayload.weekly_summaries = rollups.weekly_summaries;
|
||||
contextPayload.phase_summaries = rollups.phase_summaries;
|
||||
contextPayload.tren_kpi_harian = rollups.tren_kpi_harian;
|
||||
contextPayload.mati_ekor = rollups.mati_ekor;
|
||||
contextPayload.panen_ekor = rollups.panen_ekor;
|
||||
contextPayload.saldo_ekor = rollups.saldo_ekor;
|
||||
if (iotDayAggregates.length > 0) {
|
||||
const latestIot = iotDayAggregates[iotDayAggregates.length - 1]!;
|
||||
contextPayload.suhu_rata_rata_C = latestIot.avg_temp_C;
|
||||
contextPayload.kelembapan_persen = latestIot.avg_humidity_pct;
|
||||
contextPayload.iot_day_aggregates = iotDayAggregates;
|
||||
}
|
||||
}
|
||||
|
||||
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
|
||||
|
||||
if (!forceRefresh) {
|
||||
@@ -313,7 +422,12 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
||||
throw new Error(apiResult?.message || 'Gagal mengambil insight AI dari server');
|
||||
}
|
||||
|
||||
applyInsight(apiResult.insight_text, apiResult.citations);
|
||||
applyInsight(
|
||||
apiResult.insight_text,
|
||||
apiResult.citations,
|
||||
apiResult.structured_end_cycle ?? null,
|
||||
apiResult.summary
|
||||
);
|
||||
setLastUpdated(new Date());
|
||||
setDetailsOpen(false);
|
||||
} catch (err) {
|
||||
@@ -343,6 +457,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
||||
reportType,
|
||||
reportPeriod,
|
||||
scopedMetrics,
|
||||
iotDayAggregates,
|
||||
applyInsight,
|
||||
]
|
||||
);
|
||||
@@ -701,6 +816,102 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
||||
Coba lagi
|
||||
</button>
|
||||
</div>
|
||||
) : endCycleInsight ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i className="fa-solid fa-circle-check text-green-600" /> Kesimpulan
|
||||
</h4>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-3 rounded-lg border border-gray-100">
|
||||
{endCycleInsight.kesimpulan}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<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')}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<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>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{endCycleInsight.akar_penyebab.map((item, index) => (
|
||||
<li
|
||||
key={`${item.id ?? item.hipotesis}-${index}`}
|
||||
className="rounded-lg border border-gray-100 bg-gray-50 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-gray-800">{item.hipotesis}</p>
|
||||
{item.confidence ? (
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
|
||||
confidenceBadge[item.confidence] ?? confidenceBadge.low
|
||||
}`}
|
||||
>
|
||||
{item.confidence}
|
||||
</span>
|
||||
) : null}
|
||||
{item.fase ? (
|
||||
<span className="text-[10px] uppercase tracking-wide text-gray-500">
|
||||
{item.fase}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{item.bukti.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{item.bukti.map((bukti, buktiIdx) => (
|
||||
<li key={`${bukti}-${buktiIdx}`} className="text-xs text-gray-600">
|
||||
— {bukti}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<h4 className="text-sm font-bold text-gray-800 mb-3 flex items-center gap-2">
|
||||
<i className="fa-solid fa-clipboard-check text-blue-600" /> Perbaikan siklus
|
||||
berikutnya
|
||||
</h4>
|
||||
{endCycleInsight.perbaikan_siklus_berikutnya.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Belum ada checklist perbaikan.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{endCycleInsight.perbaikan_siklus_berikutnya.map((item, index) => (
|
||||
<li
|
||||
key={`${item.id ?? item.aksi}-${index}`}
|
||||
className="rounded-lg border border-blue-100 bg-blue-50/40 p-3"
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-blue-700">
|
||||
{item.fase}
|
||||
</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>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{endCycleInsight.insight ? (
|
||||
<p className="mt-3 text-sm text-gray-600">{endCycleInsight.insight}</p>
|
||||
) : null}
|
||||
<CitationsBlock citations={citations} />
|
||||
</div>
|
||||
</div>
|
||||
) : structuredInsight && summary ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
@@ -778,7 +989,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
||||
</div>
|
||||
) : insightText ? (
|
||||
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
|
||||
{insightText}
|
||||
{insightText.includes(END_CYCLE_JSON_MARKER)
|
||||
? insightText.split(END_CYCLE_JSON_MARKER)[0]?.trim()
|
||||
: insightText}
|
||||
<CitationsBlock citations={citations} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
Karung,
|
||||
KPI,
|
||||
ManualInput,
|
||||
PanelIoT,
|
||||
} from '../types/api.ts';
|
||||
import type { View } from '../types/common.ts';
|
||||
import { useFarm } from '../context/FarmContext.tsx';
|
||||
@@ -23,7 +24,10 @@ import { getCountingAccuracyWarning } from '../utils/countingAccuracyWarning.ts'
|
||||
import {
|
||||
buildDashboardInsightContext,
|
||||
buildDashboardInsightMetrics,
|
||||
buildIotDayAggregates,
|
||||
type EndCycleIotDayAggregate,
|
||||
} from '../utils/insightContextBuilders.ts';
|
||||
import { getTempStandardByDay } from '../utils/cp707EnvStandards.ts';
|
||||
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
|
||||
import DashboardCloseCycleControls from './dashboard/DashboardCloseCycleControls.tsx';
|
||||
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
|
||||
@@ -47,6 +51,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
const [manualInputs, setManualInputs] = useState<ManualInput[]>([]);
|
||||
const [weights, setWeights] = useState<ChickenWeight[]>([]);
|
||||
const [karung, setKarung] = useState<Karung | null>(null);
|
||||
const [iotDayAggregates, setIotDayAggregates] = useState<EndCycleIotDayAggregate[]>([]);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportType, setReportType] = useState<DashboardReportType>('daily');
|
||||
|
||||
@@ -57,6 +62,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
setManualInputs([]);
|
||||
setWeights([]);
|
||||
setKarung(null);
|
||||
setIotDayAggregates([]);
|
||||
return;
|
||||
}
|
||||
const cycleId = selectedCycle.id;
|
||||
@@ -83,6 +89,44 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
setManualInputs(manualRows);
|
||||
setWeights(weightRows);
|
||||
setKarung(latestKarung);
|
||||
|
||||
// Best-effort IoT aggregates for end-cycle root cause (non-blocking on failure).
|
||||
try {
|
||||
const flockId =
|
||||
kpiRows.find((row) => row.flock_id != null)?.flock_id ??
|
||||
(selectedKandang
|
||||
? (await api.flocks.list({ kandang_id: selectedKandang.id }))[0]?.id
|
||||
: null);
|
||||
if (flockId == null) {
|
||||
setIotDayAggregates([]);
|
||||
} else {
|
||||
let iotRows: PanelIoT[] = [];
|
||||
try {
|
||||
iotRows = await api.panelIot.list({ cycle_id: cycleId, flock_id: flockId });
|
||||
} catch {
|
||||
// Some deployments require a date — fall back to sampling recent dates.
|
||||
const dates = await api.panelIot.dates({ cycle_id: cycleId, flock_id: flockId });
|
||||
const sample = [...dates].sort().slice(-14);
|
||||
const batches = await Promise.all(
|
||||
sample.map((date) =>
|
||||
api.panelIot.list({ cycle_id: cycleId, flock_id: flockId, date }).catch(() => [])
|
||||
)
|
||||
);
|
||||
iotRows = batches.flat();
|
||||
}
|
||||
if (!cancelled) {
|
||||
setIotDayAggregates(
|
||||
buildIotDayAggregates({
|
||||
rows: iotRows,
|
||||
cycleStartDate: selectedCycle.start_date,
|
||||
getTempTarget: getTempStandardByDay,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setIotDayAggregates([]);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(errorMessage(err));
|
||||
} finally {
|
||||
@@ -92,7 +136,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedCycle]);
|
||||
}, [selectedCycle, selectedKandang]);
|
||||
|
||||
const cards: KpiCardModel[] = useMemo(() => {
|
||||
// All cards share the latest complete KPI day (not calendar current_day / live IoT).
|
||||
@@ -246,12 +290,19 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
karung_dituang_hari_ini:
|
||||
latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null),
|
||||
pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null,
|
||||
ayam_hidup_ekor: latest?.chicken_life ?? null,
|
||||
ayam_hidup_ekor:
|
||||
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,
|
||||
tonase_panen_kg: latest?.harvest_weight_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,
|
||||
uniformity_persen: weightOnAnchor?.uniformity ?? null,
|
||||
});
|
||||
}, [
|
||||
@@ -420,6 +471,7 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
||||
kandangId={selectedKandangId}
|
||||
context={insightContext ?? undefined}
|
||||
dashboardMetrics={insightMetrics}
|
||||
iotDayAggregates={iotDayAggregates}
|
||||
page="dashboard"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -17,6 +17,7 @@ import { buildCountingInsightContext } from '../../utils/insightContextBuilders.
|
||||
import PopulationTrendChart from './PopulationTrendChart.tsx';
|
||||
import CountingAccuracyWarningBanner from './CountingAccuracyWarningBanner.tsx';
|
||||
import { getCountingAccuracyWarning } from '../../utils/countingAccuracyWarning.ts';
|
||||
import { stockAkhirFromKpi } from '../../utils/kpiStock.ts';
|
||||
import { PageAiInsight } from '../shared/PageAiInsight.tsx';
|
||||
|
||||
type Row = {
|
||||
@@ -139,7 +140,8 @@ 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 = kpi?.chicken_life ?? 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:
|
||||
|
||||
@@ -299,6 +299,30 @@ export type InsightStructuredReport = {
|
||||
>;
|
||||
};
|
||||
|
||||
/** Structured end-cycle insight (Masalah / Akar penyebab / Perbaikan). */
|
||||
export type EndCycleRootCause = {
|
||||
id?: string;
|
||||
hipotesis: string;
|
||||
bukti: string[];
|
||||
confidence?: 'high' | 'med' | 'low' | string;
|
||||
fase?: string;
|
||||
};
|
||||
|
||||
export type EndCycleAction = {
|
||||
id?: string;
|
||||
fase: string;
|
||||
aksi: string;
|
||||
metrik_pantau?: string;
|
||||
};
|
||||
|
||||
export type EndCycleStructuredInsight = {
|
||||
kesimpulan: string;
|
||||
masalah: string[];
|
||||
akar_penyebab: EndCycleRootCause[];
|
||||
perbaikan_siklus_berikutnya: EndCycleAction[];
|
||||
insight?: string;
|
||||
};
|
||||
|
||||
/** Response from generate / cached insight endpoints. */
|
||||
export type InsightApiResponse = {
|
||||
success?: boolean;
|
||||
@@ -306,6 +330,7 @@ export type InsightApiResponse = {
|
||||
summary?: string;
|
||||
insight?: string;
|
||||
structured?: InsightStructuredReport | null;
|
||||
structured_end_cycle?: EndCycleStructuredInsight | null;
|
||||
citations?: InsightCitation[];
|
||||
message?: string;
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('buildDashboardInsightMetrics', () => {
|
||||
iot_weight: 1400,
|
||||
mortality_total: 500,
|
||||
harvest_total: 100,
|
||||
harvest_weight_total: 150.5,
|
||||
harvest_weight_total: 150_500, // grams
|
||||
feed_total: 400,
|
||||
},
|
||||
],
|
||||
@@ -45,7 +45,7 @@ describe('buildDashboardInsightMetrics', () => {
|
||||
expect(metrics.hari_ke).toBe(28);
|
||||
expect(metrics.fcr).toBe(1.55);
|
||||
expect(metrics.eef).toBe(260);
|
||||
expect(metrics.ayam_hidup_ekor).toBe(9500);
|
||||
expect(metrics.ayam_hidup_ekor).toBe(9400); // 9500 life − 100 harvest
|
||||
expect(metrics.mortalitas_kumulatif_persen).toBe(5);
|
||||
expect(metrics.bobot_avg_gram).toBe(1400);
|
||||
expect(metrics.doc_in_ekor).toBe(10000);
|
||||
@@ -108,7 +108,7 @@ describe('scopeDashboardInsightMetrics', () => {
|
||||
iot_weight: 450,
|
||||
mortality_total: 200,
|
||||
harvest_total: 50,
|
||||
harvest_weight_total: 80,
|
||||
harvest_weight_total: 80_000, // grams
|
||||
feed_total: 90,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildEndCycleRollups,
|
||||
type DashboardInsightMetrics,
|
||||
} from '../insightContextBuilders.ts';
|
||||
|
||||
const baseMetrics = (): DashboardInsightMetrics => ({
|
||||
hari_ke: 21,
|
||||
fcr: 1.45,
|
||||
eef: 280,
|
||||
ayam_hidup_ekor: 9000,
|
||||
mortalitas_kumulatif_persen: 4.2,
|
||||
bobot_avg_gram: 900,
|
||||
doc_in_ekor: 10000,
|
||||
kematian_kumulatif_ekor: 420,
|
||||
panen_kumulatif_ekor: 0,
|
||||
tonase_panen_kg: 0,
|
||||
uniformity_persen: 78,
|
||||
pakan_kumulatif_karung: 400,
|
||||
weightSeries: [],
|
||||
kpiSeries: [
|
||||
{
|
||||
hari: 7,
|
||||
fcr: 0.9,
|
||||
eef: null,
|
||||
ayam_hidup_ekor: 9800,
|
||||
mortalitas_kumulatif_persen: 1.5,
|
||||
bobot_avg_gram: 180,
|
||||
kematian_kumulatif_ekor: 150,
|
||||
panen_kumulatif_ekor: 0,
|
||||
tonase_panen_kg: 0,
|
||||
uniformity_persen: 85,
|
||||
pakan_kumulatif_karung: 80,
|
||||
},
|
||||
{
|
||||
hari: 14,
|
||||
fcr: 1.1,
|
||||
eef: null,
|
||||
ayam_hidup_ekor: 9600,
|
||||
mortalitas_kumulatif_persen: 3.0,
|
||||
bobot_avg_gram: 450,
|
||||
kematian_kumulatif_ekor: 300,
|
||||
panen_kumulatif_ekor: 0,
|
||||
tonase_panen_kg: 0,
|
||||
uniformity_persen: 82,
|
||||
pakan_kumulatif_karung: 180,
|
||||
},
|
||||
{
|
||||
hari: 21,
|
||||
fcr: 1.45,
|
||||
eef: null,
|
||||
ayam_hidup_ekor: 9000,
|
||||
mortalitas_kumulatif_persen: 4.2,
|
||||
bobot_avg_gram: 900,
|
||||
kematian_kumulatif_ekor: 420,
|
||||
panen_kumulatif_ekor: 0,
|
||||
tonase_panen_kg: 0,
|
||||
uniformity_persen: 78,
|
||||
pakan_kumulatif_karung: 400,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('buildEndCycleRollups', () => {
|
||||
it('builds weekly mortality deltas and phase buckets', () => {
|
||||
const rollups = buildEndCycleRollups(baseMetrics(), [
|
||||
{
|
||||
date: '2026-01-05',
|
||||
hari: 5,
|
||||
avg_temp_C: 28,
|
||||
avg_humidity_pct: 60,
|
||||
sample_count: 10,
|
||||
hours_temp_below: 8,
|
||||
hours_humidity_above: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(rollups.weekly_summaries).toHaveLength(3);
|
||||
expect(rollups.weekly_summaries[0]?.mortalitas_delta_persen).toBe(1.5);
|
||||
expect(rollups.weekly_summaries[1]?.mortalitas_delta_persen).toBe(1.5);
|
||||
expect(rollups.weekly_summaries[0]?.jam_suhu_di_bawah_standar).toBe(8);
|
||||
expect(rollups.phase_summaries.brooding?.mortalitas_delta_persen).toBe(3);
|
||||
expect(rollups.mati_ekor).toBe(420);
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,10 @@ export function buildInMemoryReport(input: BuildInMemoryReportInput): DashboardR
|
||||
const mati = mortality ?? 0;
|
||||
const panen = harvest ?? 0;
|
||||
const countBefore = day === 0 ? cycle.doc_in_count || 0 : (previousStockAkhir ?? 0);
|
||||
const countAfter = kpi?.chicken_life ?? Math.max(0, countBefore - mati - panen);
|
||||
const countAfter =
|
||||
kpi != null
|
||||
? Math.max(0, kpi.chicken_life - (kpi.harvest_total ?? 0))
|
||||
: Math.max(0, countBefore - mati - panen);
|
||||
previousStockAkhir = countAfter;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChickenCounting, Cycle, KPI, ManualInput } from '../types/api.ts';
|
||||
import { addDaysIso, cycleDayForDate, cycleLastDayIndex } from './format.ts';
|
||||
import { stockAkhirFromKpi } from './kpiStock.ts';
|
||||
|
||||
export const COUNTING_ACCURACY_WARNING_THRESHOLD = 95;
|
||||
|
||||
@@ -51,7 +52,8 @@ 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 = kpi?.chicken_life ?? Math.max(0, stockAwal - mati - panen);
|
||||
const stockAkhir =
|
||||
stockAkhirFromKpi(kpi) ?? Math.max(0, stockAwal - mati - panen);
|
||||
result.set(date, stockAkhir);
|
||||
previousStockAkhir = stockAkhir;
|
||||
}
|
||||
|
||||
+240
-10
@@ -4,6 +4,12 @@ import { getTargetWeightForAge } from './weight.ts';
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
/** Positive finite number only — 0 / negative treated as missing sensor data. */
|
||||
const positiveNum = (value: unknown): number | null => {
|
||||
const n = num(value);
|
||||
return n != null && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
const requireKandangId = (kandangId: number | null | undefined, topic: string): number => {
|
||||
if (kandangId == null || !Number.isFinite(kandangId) || kandangId <= 0) {
|
||||
throw new Error(`kandangId wajib — insight ${topic} tidak boleh untuk semua kandang`);
|
||||
@@ -493,9 +499,11 @@ export function buildDashboardInsightContext(
|
||||
cycleId: args.cycleId,
|
||||
hari_ke: args.hari_ke ?? null,
|
||||
totalDays: args.totalDays ?? null,
|
||||
fcr_terakhir: args.fcr_terakhir ?? null,
|
||||
eef_terakhir: args.eef_terakhir ?? null,
|
||||
bobot_avg_gram: args.bobot_avg_gram ?? null,
|
||||
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,
|
||||
@@ -604,7 +612,7 @@ export function buildDashboardInsightMetrics(
|
||||
for (const row of args.weights) {
|
||||
const hari = num(row.age);
|
||||
if (hari === null || hari <= 0) continue;
|
||||
const actual = num(row.average_weight);
|
||||
const actual = positiveNum(row.average_weight);
|
||||
const doc = num(row.doc_weight) ?? 40;
|
||||
const target = getTargetWeightForAge(hari, doc);
|
||||
weightByAge.set(hari, {
|
||||
@@ -622,22 +630,31 @@ export function buildDashboardInsightMetrics(
|
||||
for (const row of args.kpis) {
|
||||
const hari = num(row.age);
|
||||
if (hari === null || hari <= 0) continue;
|
||||
const iotWeight = num(row.iot_weight);
|
||||
const iotWeight = positiveNum(row.iot_weight);
|
||||
const weightFromSeries = weightByAge.get(hari)?.actual ?? null;
|
||||
const bobot =
|
||||
iotWeight !== null && iotWeight > 0 ? iotWeight : weightFromSeries;
|
||||
iotWeight !== null ? iotWeight : weightFromSeries;
|
||||
const persenHidup = num(row.chicken_life_percentage);
|
||||
const uniformity = weightByAge.get(hari)?.uniformity ?? null;
|
||||
kpiByAge.set(hari, {
|
||||
hari,
|
||||
fcr: num(row.fcr),
|
||||
eef: num(row.eef),
|
||||
ayam_hidup_ekor: num(row.chicken_life),
|
||||
fcr: positiveNum(row.fcr),
|
||||
eef: positiveNum(row.eef),
|
||||
// chicken_life is DOC − mortality; in-house remaining subtracts harvest.
|
||||
ayam_hidup_ekor: (() => {
|
||||
const life = num(row.chicken_life);
|
||||
if (life == null) return null;
|
||||
return Math.max(0, life - (num(row.harvest_total) ?? 0));
|
||||
})(),
|
||||
mortalitas_kumulatif_persen: mortalitasFromPersenHidup(persenHidup),
|
||||
bobot_avg_gram: bobot,
|
||||
kematian_kumulatif_ekor: num(row.mortality_total),
|
||||
panen_kumulatif_ekor: num(row.harvest_total),
|
||||
tonase_panen_kg: num(row.harvest_weight_total),
|
||||
// KPI harvest_weight_total is grams; insight tile labels kg.
|
||||
tonase_panen_kg: (() => {
|
||||
const g = num(row.harvest_weight_total);
|
||||
return g == null ? null : g / 1000;
|
||||
})(),
|
||||
uniformity_persen: uniformity,
|
||||
pakan_kumulatif_karung: num(row.feed_total),
|
||||
});
|
||||
@@ -687,6 +704,219 @@ export function buildDashboardInsightMetrics(
|
||||
};
|
||||
}
|
||||
|
||||
/** One IoT day aggregate for end-cycle environment rollups. */
|
||||
export type EndCycleIotDayAggregate = {
|
||||
date: string;
|
||||
hari: number | null;
|
||||
avg_temp_C: number | null;
|
||||
avg_humidity_pct: number | null;
|
||||
sample_count: number;
|
||||
hours_temp_below: number;
|
||||
hours_humidity_above: number;
|
||||
};
|
||||
|
||||
export type EndCycleWeeklySummary = {
|
||||
minggu_ke: number;
|
||||
jumlah_hari_data: number;
|
||||
fcr_rata_rasio: number | null;
|
||||
fcr_akhir_rasio: number | null;
|
||||
bobot_akhir_gram: number | null;
|
||||
pakan_akhir_karung: number | null;
|
||||
mortalitas_kumulatif_persen: number | null;
|
||||
mortalitas_delta_persen: number | null;
|
||||
jam_suhu_di_bawah_standar: number;
|
||||
jam_kelembapan_di_atas_standar: number;
|
||||
jam_amonia_tinggi: number;
|
||||
};
|
||||
|
||||
export type EndCyclePhaseSummary = {
|
||||
jumlah_minggu: number;
|
||||
mortalitas_delta_persen: number | null;
|
||||
fcr_akhir_rasio: number | null;
|
||||
};
|
||||
|
||||
export type EndCycleRollups = {
|
||||
weekly_summaries: EndCycleWeeklySummary[];
|
||||
phase_summaries: Partial<
|
||||
Record<'brooding' | 'growth' | 'finisher', EndCyclePhaseSummary>
|
||||
>;
|
||||
mati_ekor: number | null;
|
||||
panen_ekor: number | null;
|
||||
saldo_ekor: number | null;
|
||||
doc_in_ekor: number | null;
|
||||
tren_kpi_harian: DashboardInsightKpiPoint[];
|
||||
};
|
||||
|
||||
const avg = (values: number[]): number | 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.
|
||||
* Optional IoT day aggregates enrich temperature/humidity out-of-range hours.
|
||||
*/
|
||||
export function buildEndCycleRollups(
|
||||
metrics: DashboardInsightMetrics,
|
||||
iotDays: EndCycleIotDayAggregate[] = []
|
||||
): EndCycleRollups {
|
||||
const byWeek = new Map<number, DashboardInsightKpiPoint[]>();
|
||||
for (const row of metrics.kpiSeries) {
|
||||
if (!row.hari || row.hari <= 0) continue;
|
||||
const week = Math.max(1, Math.ceil(row.hari / 7));
|
||||
const list = byWeek.get(week) ?? [];
|
||||
list.push(row);
|
||||
byWeek.set(week, list);
|
||||
}
|
||||
|
||||
const iotByWeek = new Map<number, EndCycleIotDayAggregate[]>();
|
||||
for (const day of iotDays) {
|
||||
const hari = day.hari;
|
||||
if (hari == null || hari <= 0) continue;
|
||||
const week = Math.max(1, Math.ceil(hari / 7));
|
||||
const list = iotByWeek.get(week) ?? [];
|
||||
list.push(day);
|
||||
iotByWeek.set(week, list);
|
||||
}
|
||||
|
||||
const weekly_summaries: EndCycleWeeklySummary[] = [];
|
||||
let prevMort: number | null = null;
|
||||
for (const week of [...byWeek.keys()].sort((a, b) => a - b)) {
|
||||
const rows = [...(byWeek.get(week) ?? [])].sort((a, b) => a.hari - b.hari);
|
||||
const fcrs = rows.map((r) => r.fcr).filter((v): v is number => v != null);
|
||||
const bws = rows.map((r) => r.bobot_avg_gram).filter((v): v is number => v != null);
|
||||
const feeds = rows.map((r) => r.pakan_kumulatif_karung).filter((v): v is number => v != null);
|
||||
const morts = rows
|
||||
.map((r) => r.mortalitas_kumulatif_persen)
|
||||
.filter((v): v is number => v != null);
|
||||
const mortEnd = morts.length ? morts[morts.length - 1]! : null;
|
||||
const mortDelta =
|
||||
mortEnd == null ? null : Math.round((mortEnd - (prevMort ?? 0)) * 1000) / 1000;
|
||||
if (mortEnd != null) prevMort = mortEnd;
|
||||
|
||||
const iotRows = iotByWeek.get(week) ?? [];
|
||||
weekly_summaries.push({
|
||||
minggu_ke: week,
|
||||
jumlah_hari_data: rows.length,
|
||||
fcr_rata_rasio: avg(fcrs),
|
||||
fcr_akhir_rasio: fcrs.length ? fcrs[fcrs.length - 1]! : null,
|
||||
bobot_akhir_gram: bws.length ? bws[bws.length - 1]! : null,
|
||||
pakan_akhir_karung: feeds.length ? feeds[feeds.length - 1]! : null,
|
||||
mortalitas_kumulatif_persen: mortEnd,
|
||||
mortalitas_delta_persen: mortDelta,
|
||||
jam_suhu_di_bawah_standar: iotRows.reduce((s, d) => s + (d.hours_temp_below || 0), 0),
|
||||
jam_kelembapan_di_atas_standar: iotRows.reduce(
|
||||
(s, d) => s + (d.hours_humidity_above || 0),
|
||||
0
|
||||
),
|
||||
jam_amonia_tinggi: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const phase_summaries: EndCycleRollups['phase_summaries'] = {};
|
||||
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);
|
||||
phase_summaries[phase] = {
|
||||
jumlah_minggu: rows.length,
|
||||
mortalitas_delta_persen: deltas.length
|
||||
? Math.round(deltas.reduce((a, b) => a + b, 0) * 1000) / 1000
|
||||
: null,
|
||||
fcr_akhir_rasio: rows[rows.length - 1]?.fcr_akhir_rasio ?? null,
|
||||
};
|
||||
};
|
||||
bucket('brooding', [1, 2]);
|
||||
bucket('growth', [3, 4]);
|
||||
bucket(
|
||||
'finisher',
|
||||
weekly_summaries.map((w) => w.minggu_ke).filter((w) => w >= 5)
|
||||
);
|
||||
|
||||
return {
|
||||
weekly_summaries,
|
||||
phase_summaries,
|
||||
mati_ekor: metrics.kematian_kumulatif_ekor,
|
||||
panen_ekor: metrics.panen_kumulatif_ekor,
|
||||
saldo_ekor: metrics.ayam_hidup_ekor,
|
||||
doc_in_ekor: metrics.doc_in_ekor,
|
||||
tren_kpi_harian: metrics.kpiSeries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate raw panel-iot readings into per-day env summaries with OOR hours.
|
||||
* `hari` is derived from cycle start when possible.
|
||||
*/
|
||||
export function buildIotDayAggregates(args: {
|
||||
rows: Array<{
|
||||
date: string;
|
||||
timestamp?: string | null;
|
||||
average_temperature?: number | null;
|
||||
humidity?: number | null;
|
||||
}>;
|
||||
cycleStartDate?: string | null;
|
||||
getTempTarget: (hari: number) => number | null;
|
||||
humidityMax?: number;
|
||||
}): EndCycleIotDayAggregate[] {
|
||||
const humidityMax = args.humidityMax ?? 70;
|
||||
const byDate = new Map<
|
||||
string,
|
||||
{ temps: number[]; hums: number[]; below: number; above: number }
|
||||
>();
|
||||
|
||||
for (const row of args.rows) {
|
||||
const date = row.date;
|
||||
if (!date) continue;
|
||||
const bucket = byDate.get(date) ?? { temps: [], hums: [], below: 0, above: 0 };
|
||||
const temp = num(row.average_temperature);
|
||||
const hum = num(row.humidity);
|
||||
let hari: number | null = null;
|
||||
if (args.cycleStartDate) {
|
||||
const start = new Date(`${args.cycleStartDate}T00:00:00`);
|
||||
const cur = new Date(`${date}T00:00:00`);
|
||||
if (!Number.isNaN(start.getTime()) && !Number.isNaN(cur.getTime())) {
|
||||
hari = Math.floor((cur.getTime() - start.getTime()) / 86400000) + 1;
|
||||
}
|
||||
}
|
||||
if (temp != null) {
|
||||
bucket.temps.push(temp);
|
||||
const target = hari != null ? args.getTempTarget(hari) : null;
|
||||
if (target != null && temp < target - 3) {
|
||||
// Treat each sample as ~1 hour contribution when dense; else count as 1.
|
||||
bucket.below += 1;
|
||||
}
|
||||
}
|
||||
if (hum != null) {
|
||||
bucket.hums.push(hum);
|
||||
if (hum > humidityMax) bucket.above += 1;
|
||||
}
|
||||
byDate.set(date, bucket);
|
||||
}
|
||||
|
||||
const out: EndCycleIotDayAggregate[] = [];
|
||||
for (const [date, bucket] of [...byDate.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
let hari: number | null = null;
|
||||
if (args.cycleStartDate) {
|
||||
const start = new Date(`${args.cycleStartDate}T00:00:00`);
|
||||
const cur = new Date(`${date}T00:00:00`);
|
||||
if (!Number.isNaN(start.getTime()) && !Number.isNaN(cur.getTime())) {
|
||||
hari = Math.floor((cur.getTime() - start.getTime()) / 86400000) + 1;
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
date,
|
||||
hari,
|
||||
avg_temp_C: avg(bucket.temps),
|
||||
avg_humidity_pct: avg(bucket.hums),
|
||||
sample_count: bucket.temps.length || bucket.hums.length,
|
||||
hours_temp_below: bucket.below,
|
||||
hours_humidity_above: bucket.above,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope display metrics to a selected cycle day.
|
||||
* Weight chart keeps rows with `hari <= upToDay`.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* KPI.chicken_life = DOC − cumulative mortality (excludes harvest).
|
||||
* Stock akhir (in-house remaining) = chicken_life − harvest_total.
|
||||
*/
|
||||
export function stockAkhirFromKpi(
|
||||
kpi: { chicken_life?: number | null; harvest_total?: number | null } | null | undefined
|
||||
): number | null {
|
||||
if (kpi?.chicken_life == null) return null;
|
||||
return Math.max(0, kpi.chicken_life - (kpi.harvest_total ?? 0));
|
||||
}
|
||||
Reference in new issue
Block a user