diff --git a/DOCKER.md b/DOCKER.md index e2a9ba3..db5d275 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -24,12 +24,12 @@ Deploy **dashboard-cpsp** with Docker Compose. Layout mirrors the legacy `dashbo * 15432 on host → 5432 in container (SSH tunnel / DBeaver) ``` -| Container | Image / build | Host port | Role | -| ---------- | ------------------ | ---------------- | ----------------------------- | -| frontend | `docker/Dockerfile.web` | 80 → 80 | React SPA + Nginx API proxy | -| api | `docker/Dockerfile.api` | 18000 → 8000 | Gunicorn, migrations, static | -| cron | same as api | — | Scheduled management commands | -| database | postgres:16-alpine | 15432 → 5432 | PostgreSQL | +| Container | Image / build | Host port | Role | +| --------- | ----------------------- | ------------ | ----------------------------- | +| frontend | `docker/Dockerfile.web` | 80 → 80 | React SPA + Nginx API proxy | +| api | `docker/Dockerfile.api` | 18000 → 8000 | Gunicorn, migrations, static | +| cron | same as api | — | Scheduled management commands | +| database | postgres:16-alpine | 15432 → 5432 | PostgreSQL | ## Prerequisites @@ -82,16 +82,16 @@ Open **http://localhost** in a browser. ## Configuration files (`docker/`) -| File | Purpose | -| ---- | ------- | -| `Dockerfile.web` | Frontend image (Vite + Nginx) | -| `Dockerfile.api` | Backend image (Django + Gunicorn) | -| `nginx.conf` | Nginx proxy config for frontend | -| `entrypoint-api.sh` | API container startup | -| `entrypoint-cron.sh` | Cron container startup | -| `crontab` | Fallback schedule; runtime regenerated from `DASHBOARD_PUBLISH_*` | -| `.env.example` | Compose env template → copy to project root `.env` | -| `compose.override.local.example` | Optional local Postgres port override | +| File | Purpose | +| -------------------------------- | ----------------------------------------------------------------- | +| `Dockerfile.web` | Frontend image (Vite + Nginx) | +| `Dockerfile.api` | Backend image (Django + Gunicorn) | +| `nginx.conf` | Nginx proxy config for frontend | +| `entrypoint-api.sh` | API container startup | +| `entrypoint-cron.sh` | Cron container startup | +| `crontab` | Fallback schedule; runtime regenerated from `DASHBOARD_PUBLISH_*` | +| `.env.example` | Compose env template → copy to project root `.env` | +| `compose.override.local.example` | Optional local Postgres port override | ## Configuration @@ -99,18 +99,18 @@ Compose reads variables from a root `.env` file. Template: `docker/.env.example` Important production values: -| Variable | Purpose | -| -------- | ------- | -| `SECRET_KEY` | Django secret — use a long random string | -| `DB_PASSWORD` | PostgreSQL password | -| `CSRF_TRUSTED_ORIGINS` | Must include your public UI origin (e.g. `https://dashboard.example.com`) | -| `CORS_ALLOWED_ORIGINS` | Same as above if the SPA is on a different origin | +| Variable | Purpose | +| --------------------------- | ---------------------------------------------------------------------------------------------------- | +| `SECRET_KEY` | Django secret — use a long random string | +| `DB_PASSWORD` | PostgreSQL password | +| `CSRF_TRUSTED_ORIGINS` | Must include your public UI origin (e.g. `https://dashboard.example.com`) | +| `CORS_ALLOWED_ORIGINS` | Same as above if the SPA is on a different origin | | `KARUNG_WEB_ADMIN_BASE_URL` | External karung service; default `http://host.docker.internal:5000` reaches the host from containers | -| `BOOTSTRAP_ADMIN_USER` | Superadmin username for `bootstrap_admin` (default `admin`) | -| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) | -| `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation | -| `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) | -| `SITE_API_KEY` | Optional fixed API key for scripts (hashed at rest) | +| `BOOTSTRAP_ADMIN_USER` | Superadmin username for `bootstrap_admin` (default `admin`) | +| `BOOTSTRAP_ADMIN_PASSWORD` | Superadmin password for `bootstrap_admin` (required for production bootstrap) | +| `BOOTSTRAP_STAFF_USER` | Optional staff username; leave empty to skip staff creation | +| `BOOTSTRAP_STAFF_PASSWORD` | Staff password (required when `BOOTSTRAP_STAFF_USER` is set) | +| `SITE_API_KEY` | Optional fixed API key for scripts (hashed at rest) | Wagtail admin is proxied at **http://localhost/admin/** (through Nginx → Django). diff --git a/backend/.env.example b/backend/.env.example index 4c17df2..47d03af 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -70,7 +70,8 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434 LLM_MODEL_NAME=qwen2.5:3b LLM_TIMEOUT_SECONDS=1200 # Plan §6 CPU inference tuning (Profile A 8GB: qwen2.5:1.5b; Profile B 16GB: qwen2.5:3b) -LLM_NUM_CTX=2048 +LLM_NUM_CTX=8192 +LLM_NUM_PREDICT=768 LLM_NUM_THREAD=4 LLM_SEED=42 LLM_TEMPERATURE=0.2 diff --git a/backend/apps/operations/services/cp707_knowledge.py b/backend/apps/operations/services/cp707_knowledge.py index c24b341..d9c40ae 100644 --- a/backend/apps/operations/services/cp707_knowledge.py +++ b/backend/apps/operations/services/cp707_knowledge.py @@ -14,6 +14,40 @@ from __future__ import annotations from typing import Any +# ─── Topic-scoped metric registry ──────────────────────────────────────────── +# Each topic grades ONLY its core metrics. Unknown is emitted only for the +# topic's core metric(s) so the LLM cannot confuse standards with actuals. +TOPIC_METRICS: dict[str, set[str]] = { + "hitung_ayam": {"mortality"}, # cumulative mortality + daily mortality % of DOC + "berat_ayam": {"bw", "adg"}, # body weight + ADG vs standard + "fcr": {"fcr"}, # FCR vs standard only + "eef": {"eef", "fcr"}, # EEF/IP + FCR (driver) + "hitung_karung": {"feed_balance"}, # sack balance (in - poured - remaining) + "iot_panel": {"environment"}, # temperature vs TET, humidity + "dashboard": {"bw", "fcr", "mortality", "environment", "eef", "adg"}, + "end_cycle": {"bw", "fcr", "mortality", "environment", "eef", "adg"}, +} + +# EEF absolute scale bands (manager operational rules, NOT from book) +# Used when book has no IP standard for the day (outside days 7-37). +# >500 = UNREALISTIC/INVALID per user rule 2026-09-29 +EEF_ABSOLUTE_BANDS: list[tuple[int, int, str, str]] = [ + (501, 9999, "invalid", "EEF >500 tidak realistis — periksa kualitas data FCR/mortalitas/pakan"), + (400, 500, "ok", "EEF {val} SANGAT BAIK (skala manajer, di luar buku CP 707)"), + (350, 399, "ok", "EEF {val} BAIK (skala manajer, di luar buku CP 707)"), + (300, 349, "ok", "EEF {val} STANDAR (skala manajer, di luar buku CP 707)"), + (200, 299, "warning", "EEF {val} DI BAWAH RATA-RATA (skala manajer, di luar buku CP 707)"), + (0, 199, "critical", "EEF {val} SANGAT RENDAH (skala manajer, di luar buku CP 707)"), +] + +# Daily mortality % of DOC thresholds (manager operational rules, NOT from book) +# Book only has cumulative mortality standard. Daily uses its own thresholds: +# >0.05% = warning, >=0.1% = critical. Divisor = DOC intake. +DAILY_MORTALITY_DOC_THRESHOLDS = { + "warning_pct": 0.05, + "critical_pct": 0.10, +} + # ─── Standar Performa Mingguan CP 707 ─────────────────────────────────────── PERFORMANCE_STANDARD_WEEKLY: list[dict[str, Any]] = [ {"week": 1, "targetBW_g": 195, "adg_g": 34, "cumFeedConsumption_g": 164.5, "fcr": 0.844}, @@ -466,12 +500,13 @@ def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]: def analyze_environment( temp_c: Any, humidity_pct: Any, - ammonia_ppm: Any, day_age: Any, ) -> dict[str, Any]: + """Temperature vs TET + humidity grading. Ammonia removed per 2026-09-28 decision: + no ammonia data exists in IoT context; old code forced 0.0 making it look fake-safe. + Advisory note added when humidity/temp indicate ventilation issues.""" temp = _as_float(temp_c) humidity = _as_float(humidity_pct) - ammonia = _as_float(ammonia_ppm) if temp is None: return { @@ -485,8 +520,6 @@ def analyze_environment( if humidity is None: humidity = 65.0 - if ammonia is None: - ammonia = 0.0 temp_std = get_temp_standard_by_day(day_age) tet = get_tet_by_day(day_age) @@ -529,18 +562,13 @@ def analyze_environment( if overall_status != "critical": overall_status = "warning" - if ammonia > AIR_QUALITY_STANDARD["ammonia"]["critical"]: + # Advisory: if humidity/temp suggest ventilation problems, advise checking ammonia + # (not a graded status; no ammonia data in context) + if overall_status != "ok" and humidity > 75: issues.append( - f"Amonia {ammonia:.1f} ppm di atas batas kritis CP 707 (>25 ppm). " - "Pertumbuhan ayam menurun. Tingkatkan ventilasi segera dan gemburkan sekam basah." + "Kondisi kelembapan tinggi + suhu off-target → periksa kadar amonia di kandang " + "(batas aman CP 707 <10 ppm)." ) - overall_status = "critical" - elif ammonia > AIR_QUALITY_STANDARD["ammonia"]["warning"]: - issues.append( - f"Amonia {ammonia:.1f} ppm melebihi batas aman CP 707 (<10 ppm). Tingkatkan sirkulasi udara." - ) - if overall_status != "critical": - overall_status = "warning" return { "status": overall_status, @@ -556,6 +584,386 @@ def analyze_environment( } +def analyze_adg(actual_adg: Any, day_age: Any) -> dict[str, Any]: + """ADG (Average Daily Gain) vs daily standard from book.""" + actual = _as_float(actual_adg) + if actual is not None and actual <= 0: + actual = None + + std_row = _daily_row(day_age) + std_adg = float(std_row["adg_g"]) if std_row else None + + if actual is None or std_adg is None: + return { + "actual": actual, + "standard": std_adg, + "delta_pct": None, + "direction": "unknown", + "status": "unknown", + "message": ( + "Umur di luar standar tabel CP 707." + if std_adg is None + else "ADG aktual tidak tersedia." + ), + } + + delta_pct = round(((actual - std_adg) / std_adg) * 100, 1) + direction = _direction_from_delta(delta_pct) + + if delta_pct < -15: + status = "critical" + message = ( + f"ADG {actual:.1f}g/hari jauh di bawah standar CP 707 ({std_adg}g/hari) " + f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Periksa konsumsi pakan dan kesehatan ayam." + ) + elif delta_pct < -5: + status = "warning" + message = ( + f"ADG {actual:.1f}g/hari sedikit di bawah standar CP 707 ({std_adg}g/hari) " + f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Tingkatkan stimulasi pakan." + ) + elif delta_pct > 10: + status = "ok" + message = ( + f"ADG {actual:.1f}g/hari melampaui standar CP 707 ({std_adg}g/hari) " + f"pada hari ke-{day_age}. Pertumbuhan sangat baik." + ) + else: + status = "ok" + message = f"ADG {actual:.1f}g/hari sesuai standar CP 707 ({std_adg}g/hari) pada hari ke-{day_age}." + + return { + "actual": actual, + "standard": std_adg, + "delta_pct": float(delta_pct), + "direction": direction, + "status": status, + "message": message, + } + + +def _grade_eef_absolute_scale(eef_value: float) -> tuple[str, str]: + """Grade EEF against manager absolute scale bands. Returns (status, message).""" + for lo, hi, status, msg_template in EEF_ABSOLUTE_BANDS: + if lo <= eef_value <= hi: + if status == "invalid": + return status, msg_template + return status, msg_template.format(val=round(eef_value)) + # Should not reach here; fallback + return "unknown", f"EEF {round(eef_value)} tidak dapat digrading" + + +def _compute_eef_trend(eef_series: list[dict[str, Any]] | None) -> dict[str, Any] | None: + """ + Compute EEF trend from daily series. + Returns dict with: trend ("up" | "down" | "stable"), delta_3d (points), + escalation ("sharp_drop_eef" | None). + """ + if not eef_series or not isinstance(eef_series, list): + return None + + # Filter valid EEF points, sort by day + valid = [] + for p in eef_series: + if not isinstance(p, dict): + continue + day = _as_float(p.get("hari") or p.get("day") or p.get("age")) + eef = _as_float(p.get("eef") or p.get("ip") or p.get("indeks")) + if day is not None and eef is not None and eef > 0: + valid.append({"day": int(day), "eef": float(eef)}) + + if len(valid) < 3: + return None + + valid.sort(key=lambda x: x["day"]) + # Use last 3 days for trend + last3 = valid[-3:] + if len(last3) < 3: + return None + + delta_3d = round(last3[-1]["eef"] - last3[0]["eef"], 1) + + if delta_3d >= 10: + trend = "up" + elif delta_3d <= -10: + trend = "down" + else: + trend = "stable" + + escalation = None + # Sharp drop: >20 points decline in 3 days + if delta_3d <= -20: + escalation = "sharp_drop_eef" + + return { + "trend": trend, + "delta_3d": delta_3d, + "escalation": escalation, + "series_used": last3, + } + + +def _compute_eef_from_components( + fcr: float | None, mortality_cum_pct: float | None, day_age: int | None, bw_kg: float | None +) -> float | None: + """ + Compute EEF = (BW_kg * viability% * 100) / (FCR * age_days) + viability% = 100 - cumulative_mortality% + Returns None if any required component missing. + """ + if fcr is None or mortality_cum_pct is None or day_age is None or bw_kg is None: + return None + viability = 100.0 - float(mortality_cum_pct) + if viability <= 0: + return None + try: + eef = (bw_kg * viability * 100.0) / (fcr * day_age) + return round(eef, 1) + except ZeroDivisionError: + return None + + +def analyze_eef( + eef_terakhir: Any, + day_age: Any, + fcr: Any = None, + mortality_cum_pct: Any = None, + bw_kg: Any = None, + eef_seri: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """ + EEF (IP) grading with dual ladder: + 1. Book standard exists (days 7-37): delta vs book IP = primary status; trend = context + 2. No book standard: manager absolute scale (<200/200-300/300-350/350-400/400-500, >500=invalid) + 3. Trend always computed from eef_seri; sharp drop escalates ladder verdict one level. + """ + eef_val = _as_float(eef_terakhir) + if eef_val is not None and eef_val <= 0: + eef_val = None + + # Try to compute EEF from components if not provided + if eef_val is None: + fcr_f = _as_float(fcr) + mort_f = _as_float(mortality_cum_pct) + bw_f = _as_float(bw_kg) + day = _as_float(day_age) + if fcr_f and mort_f and bw_f and day: + eef_val = _compute_eef_from_components(fcr_f, mort_f, int(day), bw_f) + + # Get book IP standard for this day + ip_std = get_ip_standard_by_day(day_age) if day_age else None + + trend_info = _compute_eef_trend(eef_seri) + + # Start with book standard if available + if eef_val is not None and ip_std is not None: + delta_pct = round(((eef_val - ip_std) / ip_std) * 100, 1) + direction = _direction_from_delta(delta_pct) + + if delta_pct > 10: + status = "ok" + message = f"EEF/IP {eef_val:.0f} melampaui standar buku ({ip_std}) sebesar {delta_pct:.1f}% pada hari ke-{day_age}." + elif delta_pct < -15: + status = "critical" + message = f"EEF/IP {eef_val:.0f} jauh di bawah standar buku ({ip_std}) sebesar {abs(delta_pct):.1f}% pada hari ke-{day_age}. Evaluasi FCR dan mortalitas." + elif delta_pct < -5: + status = "warning" + message = f"EEF/IP {eef_val:.0f} di bawah standar buku ({ip_std}) sebesar {abs(delta_pct):.1f}% pada hari ke-{day_age}. Perbaiki efisiensi pakan." + else: + status = "ok" + message = f"EEF/IP {eef_val:.0f} sesuai standar buku ({ip_std}) pada hari ke-{day_age}." + + # Apply trend escalation + if trend_info and trend_info.get("escalation") == "sharp_drop_eef": + if status == "ok": + status = "warning" + message += " Tren turun tajam (>20 poin/3 hari) — eskalasi ke warning." + elif status == "warning": + status = "critical" + message += " Tren turun tajam (>20 poin/3 hari) — eskalasi ke critical." + + return { + "actual": eef_val, + "standard": ip_std, + "delta_pct": delta_pct, + "direction": direction, + "status": status, + "message": message, + "trend": trend_info, + "source": "book_standard", + } + + # No book standard → manager absolute scale + if eef_val is not None: + status, base_message = _grade_eef_absolute_scale(eef_val) + + trend_context = "" + if trend_info: + trend_map = {"up": "naik", "down": "turun", "stable": "stabil"} + trend_context = f" Tren {trend_map.get(trend_info['trend'], trend_info['trend'])} {trend_info['delta_3d']:.0f} poin/3 hari." + if trend_info.get("escalation") == "sharp_drop_eef": + # Escalate one level + if status == "ok": + status = "warning" + trend_context += " Eskalasi sharp drop → warning." + elif status == "warning": + status = "critical" + trend_context += " Eskalasi sharp drop → critical." + + message = base_message + trend_context + + return { + "actual": eef_val, + "standard": None, + "delta_pct": None, + "direction": "unknown", + "status": status, + "message": message, + "trend": trend_info, + "source": "manager_absolute_scale", + } + + # No data at all + return { + "actual": None, + "standard": ip_std, + "delta_pct": None, + "direction": "unknown", + "status": "unknown", + "message": "EEF/IP tidak tersedia (data FCR/mortalitas/bobot/umur tidak lengkap).", + "trend": trend_info, + "source": "none", + } + + +def analyze_daily_mortality_doc( + mati_hari_ini: Any, + doc_in_ekor: Any, +) -> dict[str, Any]: + """ + Daily mortality % of DOC: (mati_hari_ini / doc_in_ekor) * 100 + Thresholds: >0.05% = warning, >=0.1% = critical (manager rules, not book). + """ + deaths = _as_float(mati_hari_ini) + doc = _as_float(doc_in_ekor) + + if deaths is None or doc is None or doc <= 0: + return { + "status": "unknown", + "direction": "unknown", + "actual_pct": None, + "message": "Data mortalitas harian atau DOC tidak tersedia.", + } + + daily_pct = round((deaths / doc) * 100, 4) + + if daily_pct >= DAILY_MORTALITY_DOC_THRESHOLDS["critical_pct"]: + status = "critical" + message = ( + f"Mortalitas harian {daily_pct:.4f}% dari DOC ({int(deaths)}/{int(doc)}) " + f"≥ {DAILY_MORTALITY_DOC_THRESHOLDS['critical_pct']}% — CRITICAL. " + "Segera cek penyebab kematian hari ini." + ) + elif daily_pct > DAILY_MORTALITY_DOC_THRESHOLDS["warning_pct"]: + status = "warning" + message = ( + f"Mortalitas harian {daily_pct:.4f}% dari DOC ({int(deaths)}/{int(doc)}) " + f"> {DAILY_MORTALITY_DOC_THRESHOLDS['warning_pct']}% — WARNING. " + "Pantau dan cek kebersihan/biosekuriti." + ) + else: + status = "ok" + message = ( + f"Mortalitas harian {daily_pct:.4f}% dari DOC ({int(deaths)}/{int(doc)}) " + "dalam batas normal (≤0.05%)." + ) + + return { + "status": status, + "direction": "di_atas_standar" if status != "ok" else "sesuai_standar", + "actual_pct": daily_pct, + "message": message, + "deaths": deaths, + "doc": doc, + } + + +def analyze_feed_balance( + karung_masuk: Any, + karung_tuang: Any, + karung_sisa: Any, + target_harian: Any = None, + saldo_awal: Any = None, +) -> dict[str, Any]: + """ + Sack balance: (saldo_awal + masuk) - poured - remaining. + Computes feed inventory status based on available stock vs daily usage. + """ + masuk = _as_float(karung_masuk) + tuang = _as_float(karung_tuang) + sisa = _as_float(karung_sisa) + saldo_awal_num = _as_float(saldo_awal) + + if masuk is None and saldo_awal_num is None: + return { + "status": "unknown", + "direction": "unknown", + "message": "Data karung masuk tidak tersedia.", + } + + total_masuk = (masuk or 0.0) + (saldo_awal_num or 0.0) + balance = total_masuk - (tuang or 0.0) - (sisa or 0.0) + iot_vs_manual = None + + if balance < 0: + return { + "status": "warning", + "direction": "di_bawah_standar", + "balance_karung": balance, + "message": f"Saldo karung negatif ({balance:.1f} karung): total pakan dituang ({tuang or 0.0:.1f} karung) melebihi total stok masuk ({total_masuk:.1f} karung). Periksa mutasi karung.", + } + + target = _as_float(target_harian) + if target is not None and target > 0: + if balance == 0: + return { + "status": "warning", + "direction": "di_bawah_standar", + "balance_karung": balance, + "message": f"Saldo karung habis (0 karung). Perlu segera restock pakan untuk kebutuhan harian ({target:.1f} karung/hari).", + } + if balance < target: + return { + "status": "warning", + "direction": "di_bawah_standar", + "balance_karung": balance, + "message": f"Saldo karung menipis ({balance:.1f} karung), di bawah kebutuhan rata-rata harian ({target:.1f} karung/hari). Perlu persiapan restock pakan.", + } + days_left = round(balance / target, 1) + return { + "status": "ok", + "direction": "sesuai_standar", + "balance_karung": balance, + "iot_vs_manual": iot_vs_manual, + "message": f"Stok pakan aman. Saldo karung tersisa {balance:.1f} karung (estimasi cukup untuk {days_left:.1f} hari dengan rata-rata konsumsi {target:.1f} karung/hari).", + } + + detail_parts = [] + if saldo_awal_num is not None: + detail_parts.append(f"saldo awal {saldo_awal_num:.1f}") + if masuk is not None: + detail_parts.append(f"masuk {masuk:.1f}") + masuk_str = " + ".join(detail_parts) if detail_parts else f"masuk {total_masuk:.1f}" + + return { + "status": "ok", + "direction": "sesuai_standar", + "balance_karung": balance, + "iot_vs_manual": iot_vs_manual, + "message": f"Saldo karung: {masuk_str} - tuang {tuang or 0.0:.1f} - keluar {sisa or 0.0:.1f} = {balance:.1f} karung.", + } + + def build_book_reference_context(day_age: Any) -> str: std_bw = get_bw_standard_by_day(day_age) std_fcr = get_fcr_standard_by_day(day_age) @@ -735,6 +1143,17 @@ def _resolve_fcr(context_pack: dict[str, Any]) -> float | None: ) +def _resolve_adg(context_pack: dict[str, Any]) -> float | None: + """ADG in g/hari from explicit fields.""" + return _first_positive_number( + context_pack.get("adg_gram"), + context_pack.get("adg_g"), + context_pack.get("average_daily_gain"), + _nested_get(context_pack, "weightStats", "adg", "value"), + _nested_get(context_pack, "weight", "current", "adg"), + ) + + def _resolve_mortality(context_pack: dict[str, Any]) -> float | None: """Cumulative mortality % from explicit fields, % hidup, or kematian/DOC.""" direct = _first_number( @@ -778,50 +1197,128 @@ def _resolve_iot(context_pack: dict[str, Any]) -> dict[str, Any]: return context_pack -def analyze_with_cp707_standards(context_pack: dict[str, Any] | None) -> dict[str, Any]: - """Analisis komprehensif; toleran terhadap field InsightContext rebuild dan pack lama.""" +def analyze_with_cp707_standards(context_pack: dict[str, Any] | None, topic: str | None = None) -> dict[str, Any]: + """Analisis komprehensif; toleran terhadap field InsightContext rebuild dan pack lama. + Topic-scoped: only grades metrics in TOPIC_METRICS[topic]. Unknown emitted only for + topic-core metrics so LLM cannot confuse standards as actuals. + """ pack = context_pack if isinstance(context_pack, dict) else {} day_age = _resolve_day_age(pack) result: dict[str, Any] = {"dayAge": day_age, "analyses": {}} - actual_bw = _resolve_bw_grams(pack) - if day_age is not None: - # Always emit a bw block (unknown if missing) so the model cannot treat - # day-standard BW as the farm's actual weight. - result["analyses"]["bw"] = analyze_bw(actual_bw, day_age) + # Determine which metrics to grade for this topic + metrics_to_grade = TOPIC_METRICS.get(topic, set()) if topic else {"bw", "fcr", "mortality", "environment"} - 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) + # Body weight grading + if "bw" in metrics_to_grade: + actual_bw = _resolve_bw_grams(pack) + if day_age is not None: + result["analyses"]["bw"] = analyze_bw(actual_bw, day_age) + else: + result["analyses"]["bw"] = {"status": "unknown", "message": "Umur tidak tersedia untuk grading bobot badan."} - mortality_rate = _resolve_mortality(pack) - # Always emit mortality (unknown if missing) — silent omission caused the LLM - # to narrate STANDAR CP 707 mortality as if it were the farm actual. - result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age) + # FCR grading + if "fcr" in metrics_to_grade: + actual_fcr = _resolve_fcr(pack) + if day_age is not None: + result["analyses"]["fcr"] = analyze_fcr(actual_fcr, day_age) + else: + result["analyses"]["fcr"] = {"status": "unknown", "message": "Umur tidak tersedia untuk grading FCR."} - iot = _resolve_iot(pack) - temp = _first_number( - iot.get("suhu_rata_rata_C"), - iot.get("avgTemp"), - pack.get("suhu_rata_rata_C"), - ) - hum = _first_number( - iot.get("kelembapan_persen"), - iot.get("humidity"), - pack.get("kelembapan_persen"), - ) - ammonia = _first_number( - iot.get("amonia_ppm"), - iot.get("ammonia"), - pack.get("amonia_ppm"), - ) - if temp is not None and day_age is not None: - result["analyses"]["environment"] = analyze_environment( - temp, - hum if hum is not None else 65.0, - ammonia if ammonia is not None else 0.0, - day_age, + # Cumulative mortality grading + if "mortality" in metrics_to_grade: + mortality_rate = _resolve_mortality(pack) + result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age) + + # ADG grading (berat_ayam, dashboard, end_cycle) + if "adg" in metrics_to_grade: + actual_adg = _resolve_adg(pack) + if day_age is not None: + result["analyses"]["adg"] = analyze_adg(actual_adg, day_age) + else: + result["analyses"]["adg"] = {"status": "unknown", "message": "Umur tidak tersedia untuk grading ADG."} + + # EEF grading (eef, dashboard, end_cycle) + if "eef" in metrics_to_grade: + eef_terakhir = _first_number(pack.get("eef_terakhir"), pack.get("ip_terakhir"), pack.get("indeks_prestasi")) + fcr = _first_number(pack.get("fcr_terakhir"), pack.get("fcr")) + mortality_cum_pct = _first_number(pack.get("mortalitas_kumulatif_persen"), pack.get("mortality_cum_pct")) + bw_kg = _first_number(pack.get("bobot_avg_gram"), pack.get("averageWeight")) + if bw_kg is not None: + bw_kg = bw_kg / 1000.0 + eef_seri = pack.get("eef_seri") + if day_age is not None: + result["analyses"]["eef"] = analyze_eef(eef_terakhir, day_age, fcr, mortality_cum_pct, bw_kg, eef_seri) + else: + result["analyses"]["eef"] = {"status": "unknown", "message": "Umur tidak tersedia untuk grading EEF/IP."} + + # Daily mortality % DOC grading (hitung_ayam, dashboard, end_cycle) + if "mortality" in metrics_to_grade and topic in ("hitung_ayam", "dashboard", "end_cycle"): + deaths_today = _first_number(pack.get("mortalitas_hari_ini_ekor"), pack.get("mati_hari_ini")) + doc_in = _first_number(pack.get("doc_in_ekor"), pack.get("doc")) + if deaths_today is not None and doc_in is not None and doc_in > 0: + result["analyses"]["daily_mortality_doc"] = analyze_daily_mortality_doc(deaths_today, doc_in) + + # Feed balance grading (hitung_karung) + if "feed_balance" in metrics_to_grade: + saldo_awal = _first_number( + pack.get("saldo_awal_karung"), + pack.get("saldo_awal"), + pack.get("initial_balance"), + pack.get("feed_initial_balance"), ) + karung_masuk = _first_number( + pack.get("karung_masuk"), + pack.get("karungMasuk_karung"), + pack.get("total_karung_masuk_iot"), + pack.get("total_karung_masuk_manual"), + pack.get("sacks_in"), + ) + karung_tuang = _first_number( + pack.get("karung_tuang"), + pack.get("karungDituang_karung"), + pack.get("total_karung_dituang_iot"), + pack.get("total_karung_dituang_manual"), + pack.get("sacks_poured"), + ) + karung_sisa = _first_number( + pack.get("karung_sisa"), + pack.get("karungKeluar_karung"), + pack.get("total_karung_keluar_iot"), + pack.get("total_karung_keluar_manual"), + pack.get("sacks_remaining"), + ) + target_harian = _first_number( + pack.get("target_harian"), + pack.get("daily_target"), + pack.get("rata_karung_per_hari"), + pack.get("rataKarungDituangPerHari_karung"), + ) + if karung_masuk is not None or saldo_awal is not None: + result["analyses"]["feed_balance"] = analyze_feed_balance( + karung_masuk, karung_tuang, karung_sisa, target_harian, saldo_awal=saldo_awal + ) + else: + result["analyses"]["feed_balance"] = {"status": "unknown", "message": "Data karung masuk tidak tersedia."} + + # Environment grading (iot_panel, dashboard, end_cycle) - NO ammonia + if "environment" in metrics_to_grade: + iot = _resolve_iot(pack) + temp = _first_number( + iot.get("suhu_rata_rata_C"), + iot.get("avgTemp"), + pack.get("suhu_rata_rata_C"), + ) + hum = _first_number( + iot.get("kelembapan_persen"), + iot.get("humidity"), + pack.get("kelembapan_persen"), + ) + if temp is not None and day_age is not None: + result["analyses"]["environment"] = analyze_environment( + temp, + hum if hum is not None else 65.0, + day_age, + ) return result diff --git a/backend/apps/operations/services/insight_service.py b/backend/apps/operations/services/insight_service.py index 637ed0a..e8eaecd 100644 --- a/backend/apps/operations/services/insight_service.py +++ b/backend/apps/operations/services/insight_service.py @@ -109,6 +109,18 @@ ATURAN ANGKA & ARAH (WAJIB DIPATUHI): - 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":"..."} """ @@ -139,14 +151,151 @@ def build_insight_user_prompt( 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: - """User prompt for Ollama — name as-is, never prefix 'kandang' or expose ids.""" + """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":"