fix and adjust main dashboard ai insight
This commit is contained in:
1 parent
18790ab8f6
commit
68e0d1a0bb
27 files changed
+360
-107
No files matched your search
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("operations", "0017_aiinsight_citations_text"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="aiinsight",
|
||||
name="source",
|
||||
),
|
||||
]
|
||||
@@ -27,15 +27,6 @@ class IotPanel(models.Model):
|
||||
|
||||
|
||||
class AIInsight(models.Model):
|
||||
SOURCE_GENERATED = "generated"
|
||||
SOURCE_CACHE = "cache"
|
||||
SOURCE_LOCAL_FALLBACK = "local_fallback"
|
||||
SOURCE_CHOICES = [
|
||||
(SOURCE_GENERATED, "Generated"),
|
||||
(SOURCE_CACHE, "Cache"),
|
||||
(SOURCE_LOCAL_FALLBACK, "Local fallback"),
|
||||
]
|
||||
|
||||
date = models.DateField()
|
||||
insight_text = models.TextField()
|
||||
alert = models.CharField(max_length=100, blank=True, default="")
|
||||
@@ -50,12 +41,6 @@ class AIInsight(models.Model):
|
||||
topic = models.CharField(max_length=64, blank=True, default="", db_index=True)
|
||||
report_type = models.CharField(max_length=32, blank=True, default="page", db_index=True)
|
||||
report_period = models.CharField(max_length=64, blank=True, default="current", db_index=True)
|
||||
source = models.CharField(
|
||||
max_length=32,
|
||||
choices=SOURCE_CHOICES,
|
||||
default=SOURCE_GENERATED,
|
||||
blank=True,
|
||||
)
|
||||
summary = models.TextField(blank=True, default="")
|
||||
citations = models.TextField(blank=True, default="[]")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -393,6 +393,9 @@ def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "unknown",
|
||||
"direction": "unknown",
|
||||
"severity_label": None,
|
||||
"actual_pct": None,
|
||||
"dilarang_kata": [],
|
||||
"stdMortality": std_mortality,
|
||||
"message": "Data mortalitas tidak tersedia.",
|
||||
}
|
||||
@@ -400,29 +403,55 @@ def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]:
|
||||
if rate > MORTALITY_THRESHOLDS["critical_pct"]:
|
||||
status = "critical"
|
||||
direction = "di_atas_standar"
|
||||
severity_label = "SANGAT TINGGI"
|
||||
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
|
||||
message = (
|
||||
f"Mortalitas kumulatif {rate:.2f}% melebihi batas kritis CP 707 (>7%). "
|
||||
"Lakukan nekropsi darurat dan perketat biosekuriti."
|
||||
f"Mortalitas kumulatif {rate:.2f}% adalah SANGAT TINGGI — melebihi batas kritis "
|
||||
f"CP 707 (>7%). Lakukan nekropsi darurat dan perketat biosekuriti."
|
||||
)
|
||||
elif rate >= MORTALITY_THRESHOLDS["warning_pct"]:
|
||||
status = "warning"
|
||||
direction = "di_atas_standar"
|
||||
message = f"Mortalitas kumulatif {rate:.2f}% perlu diwaspadai. Standar CP 707 <5%."
|
||||
severity_label = "TINGGI"
|
||||
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
|
||||
message = (
|
||||
f"Mortalitas kumulatif {rate:.2f}% adalah TINGGI (warning). "
|
||||
"Standar CP 707 <5%."
|
||||
)
|
||||
elif rate >= MORTALITY_THRESHOLDS["normal_pct"]:
|
||||
# 5% inclusive through just under warning threshold — still not "rendah"
|
||||
status = "warning"
|
||||
direction = "di_atas_standar"
|
||||
severity_label = "TINGGI"
|
||||
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
|
||||
message = (
|
||||
f"Mortalitas kumulatif {rate:.2f}% adalah TINGGI. "
|
||||
"Standar CP 707 <5%."
|
||||
)
|
||||
elif std_mortality is not None and rate > std_mortality * 1.5:
|
||||
status = "warning"
|
||||
direction = "di_atas_standar"
|
||||
severity_label = "TINGGI"
|
||||
dilarang = ["rendah", "normal", "baik", "aman", "terkendali"]
|
||||
message = (
|
||||
f"Mortalitas {rate:.2f}% melebihi standar kumulatif harian CP 707 "
|
||||
f"Mortalitas kumulatif {rate:.2f}% adalah TINGGI relatif standar harian CP 707 "
|
||||
f"({std_mortality:.2f}%) pada hari ke-{day_age}."
|
||||
)
|
||||
else:
|
||||
status = "ok"
|
||||
direction = "sesuai_standar" if (std_mortality is None or rate <= std_mortality) else "di_atas_standar"
|
||||
message = f"Mortalitas {rate:.2f}% dalam batas normal CP 707 (<5%)."
|
||||
severity_label = "normal/rendah"
|
||||
dilarang = ["SANGAT TINGGI", "kritis"]
|
||||
message = (
|
||||
f"Mortalitas kumulatif {rate:.2f}% normal/rendah — dalam batas CP 707 (<5%)."
|
||||
)
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"direction": direction,
|
||||
"severity_label": severity_label,
|
||||
"actual_pct": rate,
|
||||
"dilarang_kata": dilarang,
|
||||
"stdMortality": std_mortality,
|
||||
"message": message,
|
||||
}
|
||||
@@ -615,6 +644,9 @@ ATURAN WAJIB saat membandingkan dengan standar:
|
||||
tersedia di buku untuk hari ini" — jangan mengganti dengan angka lain.
|
||||
- Semua angka aktual harus berasal dari [Data Halaman (JSON)]. Dilarang
|
||||
menghitung sendiri atau mengarang angka yang tidak ada di sana.
|
||||
- Angka di blok ini adalah STANDAR saja (bukan nilai aktual kandang).
|
||||
Jika bobot/mortalitas aktual tidak ada di Data Halaman / GRADED FACTS,
|
||||
tulis "data tidak tersedia" — JANGAN menyalin angka standar sebagai aktual.
|
||||
═══════════════════════════════════════════════"""
|
||||
|
||||
|
||||
@@ -653,6 +685,8 @@ 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(
|
||||
context_pack.get("bobot_avg_gram"),
|
||||
context_pack.get("averageWeight"),
|
||||
context_pack.get("bobot_rata_rata_gram"),
|
||||
context_pack.get("bobot_iot_gram_terakhir"),
|
||||
context_pack.get("bobot_iot_gram"),
|
||||
@@ -673,14 +707,34 @@ def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None:
|
||||
|
||||
|
||||
def _resolve_mortality(context_pack: dict[str, Any]) -> float | None:
|
||||
return _first_number(
|
||||
"""Cumulative mortality % from explicit fields, % hidup, or kematian/DOC."""
|
||||
direct = _first_number(
|
||||
context_pack.get("mortalitas_kumulatif_pct"),
|
||||
context_pack.get("mortalitas_kumulatif_persen"),
|
||||
context_pack.get("mortalitas_persen"),
|
||||
context_pack.get("mortalityRate"),
|
||||
context_pack.get("mortality_rate_pct"),
|
||||
_nested_get(context_pack, "chickenCounting", "dashboardSummary", "mortalityRate"),
|
||||
_nested_get(context_pack, "chickenCounting", "mortality", "mortalityCount"),
|
||||
)
|
||||
if direct is not None:
|
||||
return direct
|
||||
|
||||
persen_hidup = _first_number(context_pack.get("persen_hidup"))
|
||||
if persen_hidup is not None:
|
||||
return round((100.0 - persen_hidup) * 100) / 100
|
||||
|
||||
death = _first_number(
|
||||
context_pack.get("kematian_kumulatif_ekor"),
|
||||
context_pack.get("mortalitas_kumulatif_ekor"),
|
||||
)
|
||||
doc = _first_number(
|
||||
context_pack.get("doc_in_ekor"),
|
||||
context_pack.get("doc_in"),
|
||||
context_pack.get("initialPopulation"),
|
||||
)
|
||||
if death is not None and doc is not None and doc > 0:
|
||||
return round((death / doc) * 10000) / 100
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_iot(context_pack: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -702,7 +756,9 @@ def analyze_with_cp707_standards(context_pack: dict[str, Any] | None) -> dict[st
|
||||
result: dict[str, Any] = {"dayAge": day_age, "analyses": {}}
|
||||
|
||||
actual_bw = _resolve_bw_grams(pack)
|
||||
if actual_bw is not None and day_age is not None:
|
||||
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)
|
||||
|
||||
actual_fcr = _first_number(
|
||||
@@ -716,8 +772,9 @@ def analyze_with_cp707_standards(context_pack: dict[str, Any] | None) -> dict[st
|
||||
result["analyses"]["fcr"] = analyze_fcr(actual_fcr, day_age)
|
||||
|
||||
mortality_rate = _resolve_mortality(pack)
|
||||
if mortality_rate is not None:
|
||||
result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age)
|
||||
# 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)
|
||||
|
||||
iot = _resolve_iot(pack)
|
||||
temp = _first_number(
|
||||
|
||||
@@ -67,16 +67,31 @@ def alert_from_graded(graded: dict[str, Any] | None) -> str:
|
||||
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):
|
||||
- Standar CP 707: mortalitas kumulatif NORMAL adalah < 5%.
|
||||
- Mortalitas >= 5% dan <= 7% = TINGGI (warning) — WAJIB disebut "TINGGI", bukan "rendah" atau "normal".
|
||||
- Mortalitas > 7% = SANGAT TINGGI (critical) — WAJIB disebut "SANGAT TINGGI".
|
||||
- Mortalitas < 5% = rendah/normal.
|
||||
- DILARANG KERAS menyebut mortalitas sebagai "rendah" atau "normal" jika nilainya >= 5%.
|
||||
- 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):
|
||||
- Setiap angka yang Anda tulis HARUS ada di [Data Halaman (JSON)] atau di blok STANDAR CP 707
|
||||
atau di [GRADED FACTS]. DILARANG menghitung sendiri, memperkirakan, atau membalik arah tren.
|
||||
- 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.
|
||||
@@ -84,12 +99,37 @@ ATURAN ANGKA & ARAH (WAJIB DIPATUHI):
|
||||
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.
|
||||
|
||||
FORMAT OUTPUT (JSON SAJA):
|
||||
{"kesimpulan":"...","insight":"..."}
|
||||
"""
|
||||
|
||||
|
||||
def build_insight_user_prompt(
|
||||
*,
|
||||
topic: str,
|
||||
kandang_name: str,
|
||||
report_type: str,
|
||||
report_period: str,
|
||||
context: dict[str, Any],
|
||||
) -> str:
|
||||
"""User prompt for Ollama — name as-is, never prefix 'kandang' or expose ids."""
|
||||
# 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")
|
||||
}
|
||||
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"[Data Halaman (JSON)]\n{json.dumps(safe_ctx, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
|
||||
|
||||
def strip_headerless_tables(chunk: str) -> tuple[str, int]:
|
||||
lines = str(chunk or "").split("\n")
|
||||
removed = 0
|
||||
@@ -176,10 +216,14 @@ def _build_weekly_summaries(context: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def grade_context(context: dict[str, Any]) -> dict[str, Any]:
|
||||
analysis = cp707.analyze_with_cp707_standards(context)
|
||||
graded: dict[str, Any] = {
|
||||
"kandangId": context.get("kandangId") or context.get("kandang_id"),
|
||||
"kandangName": context.get("kandangName") or context.get("kandang_name"),
|
||||
"hari_ke": analysis.get("dayAge") or _day_age(context),
|
||||
"analyses": analysis.get("analyses") or {},
|
||||
}
|
||||
# Surface daily mortality headcount so the model cannot confuse the 5%
|
||||
# cumulative threshold with "mortalitas hari ini".
|
||||
if "mortalitas_hari_ini_ekor" in context:
|
||||
graded["mortalitas_hari_ini_ekor"] = context.get("mortalitas_hari_ini_ekor")
|
||||
# Normalize analyzer outputs into explicit direction blocks for the prompt.
|
||||
for key, block in list(graded["analyses"].items()):
|
||||
if not isinstance(block, dict):
|
||||
@@ -319,6 +363,65 @@ def local_fallback_insight(graded: dict[str, Any], topic: str) -> dict[str, str]
|
||||
}
|
||||
|
||||
|
||||
_SOFT_MORTALITY_WORD = re.compile(
|
||||
r"\b(rendah|normal|baik|aman|terkendali|sedikit)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MENTIONS_MORTALITY = re.compile(r"mortalitas", re.IGNORECASE)
|
||||
|
||||
|
||||
def _text_soft_labels_mortality(text: str) -> bool:
|
||||
"""True when narrative uses soft adjectives in a mortality sentence."""
|
||||
if not text or not _MENTIONS_MORTALITY.search(text):
|
||||
return False
|
||||
# Prefer soft word near "mortalitas" (same sentence-ish window).
|
||||
for match in _MENTIONS_MORTALITY.finditer(text):
|
||||
start = max(0, match.start() - 60)
|
||||
end = min(len(text), match.end() + 80)
|
||||
if _SOFT_MORTALITY_WORD.search(text[start:end]):
|
||||
return True
|
||||
return bool(_SOFT_MORTALITY_WORD.search(text) and _MENTIONS_MORTALITY.search(text))
|
||||
|
||||
|
||||
def enforce_mortality_wording(
|
||||
parsed: dict[str, str],
|
||||
graded: dict[str, Any] | None,
|
||||
) -> dict[str, str]:
|
||||
"""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()
|
||||
if status not in ("warning", "critical"):
|
||||
return parsed
|
||||
|
||||
label = mort.get("severity_label") or ("SANGAT TINGGI" if status == "critical" else "TINGGI")
|
||||
actual = mort.get("actual_pct")
|
||||
pct = f"{float(actual):.2f}%" if isinstance(actual, (int, float)) else None
|
||||
message = str(mort.get("message") or "").strip()
|
||||
|
||||
out = dict(parsed)
|
||||
kesimpulan = str(out.get("kesimpulan") or "")
|
||||
insight = str(out.get("insight") or "")
|
||||
|
||||
if not (_text_soft_labels_mortality(kesimpulan) or _text_soft_labels_mortality(insight)):
|
||||
return out
|
||||
|
||||
# Prefer the deterministic graded message; keep non-mortality insight if clean.
|
||||
if message:
|
||||
out["kesimpulan"] = message
|
||||
else:
|
||||
pct_part = f" ({pct})" if pct else ""
|
||||
out["kesimpulan"] = (
|
||||
f"Mortalitas kumulatif{pct_part} adalah {label} menurut ambang CP 707."
|
||||
)
|
||||
|
||||
if _text_soft_labels_mortality(insight):
|
||||
out["insight"] = (
|
||||
"Segera tinjau penyebab kematian (nekropsi bila kritis), perketat biosekuriti, "
|
||||
"dan pantau mortalitas harian hingga tren menurun."
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def lookup_cached(
|
||||
*,
|
||||
cycle_id: int,
|
||||
@@ -361,7 +464,7 @@ def get_cached_insight(
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return serialize_insight(row, source_override=AIInsight.SOURCE_CACHE)
|
||||
return serialize_insight(row)
|
||||
|
||||
|
||||
def encode_citations(citations: list[Any] | None) -> str:
|
||||
@@ -398,7 +501,7 @@ def decode_citations(raw: Any) -> list[dict[str, Any]]:
|
||||
return norm_citations
|
||||
|
||||
|
||||
def serialize_insight(row: AIInsight, source_override: str | None = None) -> dict[str, Any]:
|
||||
def serialize_insight(row: AIInsight) -> dict[str, Any]:
|
||||
norm_citations = decode_citations(row.citations)
|
||||
return {
|
||||
"success": True,
|
||||
@@ -408,7 +511,6 @@ def serialize_insight(row: AIInsight, source_override: str | None = None) -> dic
|
||||
"topic": row.topic,
|
||||
"report_type": row.report_type,
|
||||
"report_period": row.report_period,
|
||||
"source": source_override or row.source or AIInsight.SOURCE_GENERATED,
|
||||
"summary": row.summary or "",
|
||||
"insight": row.insight_text or "",
|
||||
"insight_text": "\n\n".join(
|
||||
@@ -490,18 +592,20 @@ def generate_insight(
|
||||
if chunks:
|
||||
system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(chunks[:4])
|
||||
|
||||
user_prompt = (
|
||||
f"Buat insight topik `{topic}` untuk kandang `{kandang.kandang_name}` "
|
||||
f"(id={kandang_id}), periode `{report_type}/{report_period}`.\n"
|
||||
f"[Data Halaman (JSON)]\n{json.dumps(ctx, ensure_ascii=False, default=str)}"
|
||||
user_prompt = build_insight_user_prompt(
|
||||
topic=topic,
|
||||
kandang_name=kandang.kandang_name,
|
||||
report_type=report_type,
|
||||
report_period=report_period,
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
raw = call_ollama(system_prompt, user_prompt)
|
||||
parsed = parse_llm_json(raw or "")
|
||||
source = AIInsight.SOURCE_GENERATED
|
||||
if not parsed:
|
||||
parsed = local_fallback_insight(graded, topic)
|
||||
source = AIInsight.SOURCE_LOCAL_FALLBACK
|
||||
else:
|
||||
parsed = enforce_mortality_wording(parsed, graded)
|
||||
|
||||
insight_text = parsed["insight"]
|
||||
summary = parsed["kesimpulan"]
|
||||
@@ -518,8 +622,7 @@ def generate_insight(
|
||||
"insight_text": insight_text,
|
||||
"summary": summary,
|
||||
"alert": alert,
|
||||
"source": source,
|
||||
"citations": encode_citations(citations),
|
||||
},
|
||||
)
|
||||
return serialize_insight(row, source_override=source)
|
||||
return serialize_insight(row)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Regression: dashboard field names must grade; prompt must not invent ids/names."""
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.operations.services import cp707_knowledge as cp707
|
||||
from apps.operations.services.insight_service import (
|
||||
ANTI_HALLUCINATION_RULES,
|
||||
build_insight_user_prompt,
|
||||
enforce_mortality_wording,
|
||||
grade_context,
|
||||
)
|
||||
|
||||
|
||||
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_from_average_weight(self):
|
||||
self.assertEqual(cp707._resolve_bw_grams({"averageWeight": 1950}), 1950.0)
|
||||
|
||||
def test_resolve_mortality_from_mortalitas_kumulatif_persen(self):
|
||||
self.assertEqual(
|
||||
cp707._resolve_mortality({"mortalitas_kumulatif_persen": 2.4}),
|
||||
2.4,
|
||||
)
|
||||
|
||||
def test_resolve_mortality_from_persen_hidup(self):
|
||||
self.assertAlmostEqual(
|
||||
cp707._resolve_mortality({"persen_hidup": 97.5}),
|
||||
2.5,
|
||||
)
|
||||
|
||||
def test_resolve_mortality_from_deaths_over_doc(self):
|
||||
self.assertAlmostEqual(
|
||||
cp707._resolve_mortality(
|
||||
{"kematian_kumulatif_ekor": 50, "doc_in_ekor": 10000}
|
||||
),
|
||||
0.5,
|
||||
)
|
||||
|
||||
def test_dashboard_shaped_context_grades_bw_and_mortality(self):
|
||||
"""Reproduce Kandang 2 day-48 payload: FCR present, BW/mort via dashboard keys."""
|
||||
ctx = {
|
||||
"hari_ke": 48,
|
||||
"fcr_terakhir": 1.71,
|
||||
"bobot_avg_gram": 2100,
|
||||
"persen_hidup": 98.0,
|
||||
"mortalitas_hari_ini_ekor": 0,
|
||||
"kematian_kumulatif_ekor": 200,
|
||||
"doc_in_ekor": 10000,
|
||||
}
|
||||
graded = grade_context(ctx)
|
||||
analyses = graded["analyses"]
|
||||
self.assertIn("fcr", analyses)
|
||||
self.assertIn("bw", analyses)
|
||||
self.assertIn("mortality", analyses)
|
||||
self.assertEqual(analyses["bw"]["actual"], 2100.0)
|
||||
self.assertEqual(analyses["mortality"]["status"], "ok")
|
||||
self.assertIn("2.00%", analyses["mortality"]["message"])
|
||||
self.assertEqual(graded.get("mortalitas_hari_ini_ekor"), 0)
|
||||
|
||||
|
||||
class MissingMetricsGradedAsUnknownTests(SimpleTestCase):
|
||||
def test_missing_bw_and_mortality_emit_unknown_not_silent(self):
|
||||
"""When fields are absent, graded facts must say unknown so the LLM cannot use standards as actuals."""
|
||||
ctx = {"hari_ke": 48, "fcr_terakhir": 1.71}
|
||||
graded = grade_context(ctx)
|
||||
self.assertEqual(graded["analyses"]["bw"]["status"], "unknown")
|
||||
self.assertEqual(graded["analyses"]["mortality"]["status"], "unknown")
|
||||
self.assertIn("tidak tersedia", graded["analyses"]["bw"]["message"].lower())
|
||||
|
||||
|
||||
class InsightPromptNamingTests(SimpleTestCase):
|
||||
def test_user_prompt_uses_name_without_kandang_prefix_or_id(self):
|
||||
prompt = build_insight_user_prompt(
|
||||
topic="dashboard",
|
||||
kandang_name="Kandang 2",
|
||||
report_type="page",
|
||||
report_period="current",
|
||||
context={"hari_ke": 48, "kandangId": 6, "cycleId": 99},
|
||||
)
|
||||
self.assertIn("untuk `Kandang 2`", prompt)
|
||||
self.assertNotIn("untuk kandang `Kandang 2`", prompt)
|
||||
self.assertNotIn("id=", prompt)
|
||||
self.assertNotIn('"kandangId"', prompt)
|
||||
self.assertNotIn('"cycleId"', prompt)
|
||||
|
||||
def test_anti_hallucination_forbids_standard_as_actual_and_confusing_today_mortality(self):
|
||||
rules = ANTI_HALLUCINATION_RULES.lower()
|
||||
self.assertIn("standar", rules)
|
||||
self.assertIn("bukan nilai aktual", rules)
|
||||
self.assertIn("mortalitas_hari_ini", rules)
|
||||
self.assertIn("jangan menyebut id", rules)
|
||||
self.assertIn("severity_label", rules)
|
||||
|
||||
|
||||
class MortalityWordingConsistencyTests(SimpleTestCase):
|
||||
def test_critical_mortality_has_explicit_severity_label(self):
|
||||
result = cp707.analyze_mortality(9.49, 48)
|
||||
self.assertEqual(result["status"], "critical")
|
||||
self.assertEqual(result["severity_label"], "SANGAT TINGGI")
|
||||
self.assertEqual(result["actual_pct"], 9.49)
|
||||
self.assertIn("rendah", result["dilarang_kata"])
|
||||
|
||||
def test_enforce_rewrites_rendah_when_mortality_is_critical(self):
|
||||
graded = {
|
||||
"analyses": {
|
||||
"mortality": cp707.analyze_mortality(9.49, 48),
|
||||
}
|
||||
}
|
||||
parsed = {
|
||||
"kesimpulan": (
|
||||
"Kandang 1 menunjukkan mortalitas kumulatif yang rendah pada hari ke-48, "
|
||||
"mencapai 9.49%, melebihi batas kritis CP707 (>7%)."
|
||||
),
|
||||
"insight": "Lakukan nekropsi darurat.",
|
||||
}
|
||||
fixed = enforce_mortality_wording(parsed, graded)
|
||||
text = f"{fixed['kesimpulan']} {fixed['insight']}".lower()
|
||||
self.assertNotIn("rendah", text)
|
||||
self.assertIn("sangat tinggi", text)
|
||||
self.assertIn("9.49", fixed["kesimpulan"])
|
||||
|
||||
def test_enforce_leaves_ok_mortality_narrative_alone(self):
|
||||
graded = {
|
||||
"analyses": {
|
||||
"mortality": cp707.analyze_mortality(2.1, 20),
|
||||
}
|
||||
}
|
||||
parsed = {
|
||||
"kesimpulan": "Mortalitas kumulatif rendah (2.10%), masih dalam batas normal.",
|
||||
"insight": "Pertahankan biosekuriti.",
|
||||
}
|
||||
fixed = enforce_mortality_wording(parsed, graded)
|
||||
self.assertEqual(fixed["kesimpulan"], parsed["kesimpulan"])
|
||||
Reference in new issue
Block a user