fix and adjust main dashboard ai insight

This commit is contained in:
Alberto-Audrix committed 2026-09-17 14:27:08 +07:00
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",
),
]
-15
View File
@@ -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"])