Files
dashboard-cpsp/backend/apps/operations/services/root_cause.py
T

605 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)