adjust ai insight and seed data

This commit is contained in:
Alberto-Audrix committed 2026-09-18 11:34:07 +07:00
1 parent 68e0d1a0bb
commit a2b5b10977
18 files changed
+1835 -140

No files matched your search

@@ -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"])