"""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 = "" _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, )