214 lines
8.5 KiB
Python
214 lines
8.5 KiB
Python
"""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_treats_zero_as_missing(self):
|
|
self.assertIsNone(cp707._resolve_bw_grams({"bobot_avg_gram": 0}))
|
|
self.assertIsNone(cp707._resolve_bw_grams({"averageWeight": 0}))
|
|
|
|
def test_zero_bw_grades_as_unavailable_not_critical(self):
|
|
graded = grade_context({"hari_ke": 48, "bobot_avg_gram": 0, "fcr_terakhir": 1.7}, "berat_ayam")
|
|
bw = graded["analyses"]["bw"]
|
|
self.assertEqual(bw["status"], "unknown")
|
|
self.assertIn("tidak tersedia", bw["message"].lower())
|
|
|
|
def test_zero_fcr_grades_as_unavailable(self):
|
|
graded = grade_context({"hari_ke": 48, "fcr_terakhir": 0, "bobot_avg_gram": 2100}, "fcr")
|
|
fcr = graded["analyses"]["fcr"]
|
|
self.assertEqual(fcr["status"], "unknown")
|
|
self.assertIn("tidak tersedia", fcr["message"].lower())
|
|
|
|
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, "dashboard")
|
|
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, "dashboard")
|
|
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 unit bernama `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 DuplicateKandangNameTests(SimpleTestCase):
|
|
def test_collapse_kandang_kandang(self):
|
|
from apps.operations.services.insight_service import collapse_duplicate_kandang
|
|
|
|
self.assertEqual(
|
|
collapse_duplicate_kandang(
|
|
"Kandang Kandang 2 mengalami mortalitas kumulatif sebesar 10.44%."
|
|
),
|
|
"Kandang 2 mengalami mortalitas kumulatif sebesar 10.44%.",
|
|
)
|
|
|
|
def test_sanitize_insight_narrative_fields(self):
|
|
from apps.operations.services.insight_service import sanitize_insight_narrative
|
|
|
|
fixed = sanitize_insight_narrative(
|
|
{
|
|
"kesimpulan": "Kandang Kandang 2 mortalitas tinggi.",
|
|
"insight": "Pantau kandang kandang 2 setiap hari.",
|
|
}
|
|
)
|
|
self.assertEqual(fixed["kesimpulan"], "Kandang 2 mortalitas tinggi.")
|
|
self.assertEqual(fixed["insight"], "Pantau kandang 2 setiap hari.")
|
|
|
|
|
|
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"])
|
|
|
|
|
|
class FeedBalanceGradingTests(SimpleTestCase):
|
|
def test_grade_feed_balance_with_rebuild_context_fields(self):
|
|
ctx = {
|
|
"hari_ke": 34,
|
|
"saldo_awal_karung": 500,
|
|
"total_karung_masuk_iot": 1280,
|
|
"total_karung_dituang_iot": 1200,
|
|
"total_karung_keluar_iot": 50,
|
|
}
|
|
graded = grade_context(ctx, "hitung_karung")
|
|
feed = graded["analyses"]["feed_balance"]
|
|
self.assertEqual(feed["status"], "ok")
|
|
self.assertEqual(feed["balance_karung"], 530.0)
|
|
self.assertIn("saldo awal 500.0", feed["message"])
|
|
self.assertIn("masuk 1280.0", feed["message"])
|
|
|
|
def test_grade_feed_balance_with_scoped_derived_keys(self):
|
|
ctx = {
|
|
"hari_ke": 34,
|
|
"saldo_awal_karung": 200,
|
|
"karungMasuk_karung": 800,
|
|
"karungDituang_karung": 750,
|
|
"karungKeluar_karung": 0,
|
|
}
|
|
graded = grade_context(ctx, "hitung_karung")
|
|
feed = graded["analyses"]["feed_balance"]
|
|
self.assertEqual(feed["status"], "ok")
|
|
self.assertEqual(feed["balance_karung"], 250.0)
|
|
|
|
def test_grade_feed_balance_missing_emits_unknown(self):
|
|
ctx = {"hari_ke": 34}
|
|
graded = grade_context(ctx, "hitung_karung")
|
|
feed = graded["analyses"]["feed_balance"]
|
|
self.assertEqual(feed["status"], "unknown")
|
|
self.assertIn("tidak tersedia", feed["message"].lower())
|
|
|