"""Unified AI Insight generate pipeline: grade → RAG → narrate → cache. Called from `AIInsightViewSet` (`POST generate/`, `GET cached/`). Numbers/status come from `cp707_knowledge`; Ollama only narrates. Architecture map: docs/ai-insight/README.md """ from __future__ import annotations import json import logging import re import threading import time import uuid from pathlib import Path from typing import Any, Callable import httpx from django.conf import settings 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__) # Plan §5: one LLM inference at a time (CPU server; prevents thread thrash). _INFERENCE_LOCK = threading.Lock() _NUMERIC_PIPE_ROW = re.compile(r"^[\d.,]+(\s*\|\s*[\d.,]*)+$") # Stored in AIInsight.alert — condition of graded data, not narrative text. ALERT_HEALTHY = "healthy" ALERT_WARNING = "warning" ALERT_CRITICAL = "critical" ALERT_UNKNOWN = "unknown" _ALERT_RANK = { ALERT_UNKNOWN: 0, ALERT_HEALTHY: 1, ALERT_WARNING: 2, ALERT_CRITICAL: 3, } _GRADER_STATUS_TO_ALERT = { "ok": ALERT_HEALTHY, "healthy": ALERT_HEALTHY, "warning": ALERT_WARNING, "critical": ALERT_CRITICAL, "unknown": ALERT_UNKNOWN, } def alert_from_graded(graded: dict[str, Any] | None) -> str: """Worst condition among graded analysis blocks (critical > warning > healthy).""" analyses = (graded or {}).get("analyses") or {} if not isinstance(analyses, dict) or not analyses: return ALERT_UNKNOWN worst = ALERT_UNKNOWN saw_status = False for block in analyses.values(): if not isinstance(block, dict): continue raw = block.get("status") if raw is None: continue mapped = _GRADER_STATUS_TO_ALERT.get(str(raw).strip().lower()) if mapped is None: continue saw_status = True if _ALERT_RANK[mapped] > _ALERT_RANK[worst]: worst = mapped return worst if saw_status else ALERT_UNKNOWN ANTI_HALLUCINATION_RULES = """ ATURAN NAMA KANDANG & ID: - Pakai nama kandang PERSIS seperti di Data Halaman / prompt (mis. "Kandang 2"). Jangan menambah kata "Kandang" lagi (hindari "Kandang Kandang 2"). - JANGAN menyebut id kandang, cycle id, atau "(id=…)". ATURAN MORTALITAS (WAJIB DIPATUHI — TIDAK BOLEH DILANGGAR): - Bedakan DUA metrik berbeda: 1) mortalitas_hari_ini_ekor = jumlah EKOR mati hari ini (bukan persen). Jika nilainya 0, tulis mortalitas hari ini 0 ekor — JANGAN sebut "5%" atau "TINGGI". 2) mortalitas kumulatif % = hanya dari field *_persen / persen_hidup / [GRADED FACTS]. - Ambang CP 707 untuk mortalitas KUMULATIF %: <5% normal; 5–7% TINGGI; >7% SANGAT TINGGI. Ambang ini BUKAN untuk mortalitas hari ini. - WAJIB: untuk mortalitas kumulatif, sebutkan severity_label dari [GRADED FACTS] PERSIS (contoh: "SANGAT TINGGI"). Jangan mengganti dengan sinonim lain. - DILARANG memakai kata di dilarang_kata pada blok mortality (mis. "rendah", "normal", "baik") jika severity_label = TINGGI atau SANGAT TINGGI. - DILARANG KERAS menulis kalimat kontradiktif seperti "mortalitas rendah … 9%" atau "mortalitas normal melebihi batas kritis". Satu arah saja: ikuti status + severity_label. ATURAN ANGKA & ARAH (WAJIB DIPATUHI): - Angka di blok STANDAR CP 707 adalah STANDAR saja — BUKAN nilai aktual kandang. Jangan menulis standar (mis. bobot 2500 g atau mortalitas 5.75%) seolah data aktual. - Nilai aktual HANYA dari [Data Halaman (JSON)] atau [GRADED FACTS] yang berstatus bukan unknown. - Jika [GRADED FACTS] status=unknown / "tidak tersedia", tulis "data tidak tersedia" — JANGAN mengisi dengan angka standar. - Jika [GRADED FACTS] menyatakan direction/status, ULANGI arah itu — jangan dibalik. - SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD: _gram, _persen, _rasio, _karung, _ekor, _kg, _hari, _indeksTanpaSatuan. Pakai satuan itu persis. - Field yang berisi "tidak tersedia" atau null memang tidak ada datanya. Tulis "data tidak tersedia" dan JANGAN mengarang angkanya. - Status topik tanpa data = unknown, bukan ok. - Panen ≠ kematian; jangan hitung mortalitas dari selisih populasi awal − kini. - DILARANG menghitung sendiri, memperkirakan, atau membalik arah tren. ATURAN STANDAR CP 707 — DILARANG MENGARANG (WAJIB DIPATUHI): - Blok STANDAR CP 707 menyatakan PERSIS angka mana yang ada di buku dan mana yang TIDAK. - Jika standar menyatakan "TIDAK tercantum di buku" atau "N/A" — DILARANG KERAS mengarang angka standar sendiri (mis. "standar mortalitas 9.32%" atau "standar EEF 235"). - EEF/IP standar HANYA ada hari ke-7 s/d ke-37 (rentang 327–380). Di luar rentang itu standar TIDAK ADA — jangan mengarang. - Mortalitas kumulatif standar hanya sampai hari ke-37 (4.65%). Setelah hari 37 buku hanya memperkirakan +0.1%/hari — ini BUKAN standar resmi. Jika hari >37, tulis "standar tidak tercantum di buku untuk hari ini" dan bandingkan dengan 4.65% + estimasi, bukan mengarang. - FCR standar hari >37 gunakan fallback 1.65 (bukan standar buku) — sebutkan "fallback" bukan standar. - Bobot standar hanya hari 1–37. Di luar itu "tidak tercantum di buku". FORMAT OUTPUT (JSON SAJA): {"kesimpulan":"...","insight":"..."} """ 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( *, topic: str, kandang_name: str, report_type: str, report_period: str, context: dict[str, Any], graded: dict[str, Any] | None = None, is_end_cycle: bool = False, hypotheses: list[dict[str, Any]] | None = None, ) -> str: """Build ordered user prompt template for Ollama. Template order (recency = last wins): 1. Header: topic, kandang name, period, naming rules 2. Data Halaman (JSON) — page data only, no IDs 3. STATUS AKTUAL (alert from graded) + FAKTA GRADED (status lines, no unknown when known exists) 4. Root Cause / ANALISIS AKAR MASALAH (end_cycle only, from hypotheses) 5. Length contract + field role contract 6. Output schema (JSON only, placeholders as <...>) """ # Drop internal ids from the JSON the model sees (defense against id=N narration). safe_ctx = { k: v for k, v in context.items() if k not in ("kandangId", "kandang_id", "cycleId", "cycle_id") } lines: list[str] = [] # 1. Header lines.append( f"Buat insight topik `{topic}` untuk unit bernama `{kandang_name}` " f"periode `{report_type}/{report_period}`." ) lines.append( f"NAMA WAJIB: tulis PERSIS `{kandang_name}` — jangan menambah kata 'Kandang' di depan " f"(salah: 'Kandang {kandang_name}'; benar: '{kandang_name}'), dan jangan menyebut id." ) # 2. Data Halaman (JSON) lines.append(f"[Data Halaman (JSON)]\n{json.dumps(safe_ctx, ensure_ascii=False, default=str)}") # 3. STATUS AKTUAL + FAKTA GRADED if graded: analyses = graded.get("analyses") or {} known_lines = [ f"{name} = {block.get('status')}: {block.get('message')}" for name, block in analyses.items() if isinstance(block, dict) and block.get("message") and block.get("status") != "unknown" ] unknown_lines = [ f"{name} = {block.get('status')}: {block.get('message')}" for name, block in analyses.items() if isinstance(block, dict) and block.get("message") and block.get("status") == "unknown" ] status_lines = known_lines or unknown_lines actual_alert = alert_from_graded(graded) lines.append( "\nKONTEKS: semua data adalah ternak AYAM BROILER (unggas) di kandang — " "BUKAN tanaman/pertanian. 'Panen' = panen ayam. 'Bobot' = gram per ekor " "(bukan per karung). 'ADG' = kenaikan bobot harian ayam." ) lines.append( f"STATUS AKTUAL: {actual_alert}. Kesimpulan dan insight HARUS konsisten " "dengan status ini: bila warning/critical, sebut masalahnya dan bandingkan " "dengan angka standar CP 707; dilarang menyebut ideal/sehat/baik/aman " "bila STATUS AKTUAL bukan healthy." ) if status_lines: lines.append("FAKTA GRADED:\n- " + "\n- ".join(status_lines)) if topic == "hitung_karung": lines.append( "\nFOKUS KHUSUS HITUNG KARUNG: Narasi dan kesimpulan WAJIB fokus HANYA pada stok/saldo karung pakan, " "karung masuk, saldo awal, dan pakan dituang. JANGAN menyebut, mencari, atau mengeluhkan ketiadaan data mortalitas, " "bobot ayam, atau FCR karena metrik tersebut bukan bagian dari topik Hitung Karung." ) # 4. Root Cause / ANALISIS AKAR MASALAH (end_cycle only) if is_end_cycle and hypotheses: lines.append("\n[ANALISIS AKAR MASALAH]") for h in hypotheses: hid = h.get("id", "") label = h.get("hipotesis", "") bukti = h.get("bukti", []) conf = h.get("confidence", "") fase = h.get("fase", "") line = f"- {label} ({conf}, fase: {fase})" if conf or fase else f"- {label}" lines.append(line) for b in bukti: lines.append(f" - {b}") # 5. Length contract + field role contract if is_end_cycle: lines.append( "\nPANJANG WAJIB: kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). " "masalah = bullet isu dari FAKTA GRADED. akar_penyebab = dari ANALISIS AKAR MASALAH saja. " "perbaikan_siklus_berikutnya = dari aksi yang tersedia saja. " "insight = narasi 3-6 kalimat berurutan: " "(1) angka aktual vs standar CP 707, (2) penyebab/implikasi, (3) tindakan konkret. " "Satu kalimat singkat = gagal. DILARANG menulis kata aksi (lakukan, periksa, sebaiknya, " "konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi." ) else: lines.append( "\nPANJANG WAJIB: insight = narasi 3-6 kalimat berurutan: " "(1) angka aktual vs standar CP 707, (2) penyebab/implikasi, " "(3) tindakan konkret. kesimpulan = 1-3 kalimat PENILAIAN DATA SAJA (tanpa sebab/aksi). " "Satu kalimat singkat = gagal. DILARANG menulis kata aksi (lakukan, periksa, sebaiknya, " "konsultasikan) di kesimpulan — insight HARUS mengandung minimal satu kata aksi." ) # 6. Output schema if is_end_cycle: lines.append( '\nBALAS HANYA JSON persis: ' '{"kesimpulan":"",' '"masalah":[""],' '"akar_penyebab":[{"id":"","hipotesis":"