diff --git a/.gitignore b/.gitignore index a7cdd8c..743d22d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ __pycache__ docker-compose.override.yml docs/ .superpowers/ -AI Insight/chroma_db/ \ No newline at end of file +ai-insight/chroma_db/ \ No newline at end of file diff --git a/AI Insight/Dockerfile b/ai-insight/Dockerfile similarity index 100% rename from AI Insight/Dockerfile rename to ai-insight/Dockerfile diff --git a/AI Insight/chunk_classify.py b/ai-insight/chunk_classify.py similarity index 100% rename from AI Insight/chunk_classify.py rename to ai-insight/chunk_classify.py diff --git a/AI Insight/documents/Manajemen Broiler CP 707 (1).docx b/ai-insight/documents/Manajemen Broiler CP 707 (1).docx similarity index 100% rename from AI Insight/documents/Manajemen Broiler CP 707 (1).docx rename to ai-insight/documents/Manajemen Broiler CP 707 (1).docx diff --git a/ai-insight/documents/~$najemen Broiler CP 707 (1).docx b/ai-insight/documents/~$najemen Broiler CP 707 (1).docx new file mode 100644 index 0000000..91c7182 Binary files /dev/null and b/ai-insight/documents/~$najemen Broiler CP 707 (1).docx differ diff --git a/AI Insight/extract_text.py b/ai-insight/extract_text.py similarity index 100% rename from AI Insight/extract_text.py rename to ai-insight/extract_text.py diff --git a/AI Insight/extracted_txt/Manajemen Broiler CP 707 (1).txt b/ai-insight/extracted_txt/Manajemen Broiler CP 707 (1).txt similarity index 100% rename from AI Insight/extracted_txt/Manajemen Broiler CP 707 (1).txt rename to ai-insight/extracted_txt/Manajemen Broiler CP 707 (1).txt diff --git a/AI Insight/populate_db.py b/ai-insight/populate_db.py similarity index 100% rename from AI Insight/populate_db.py rename to ai-insight/populate_db.py diff --git a/AI Insight/query_rag.py b/ai-insight/query_rag.py similarity index 100% rename from AI Insight/query_rag.py rename to ai-insight/query_rag.py diff --git a/AI Insight/rag_service.py b/ai-insight/rag_service.py similarity index 100% rename from AI Insight/rag_service.py rename to ai-insight/rag_service.py diff --git a/AI Insight/requirements.txt b/ai-insight/requirements.txt similarity index 100% rename from AI Insight/requirements.txt rename to ai-insight/requirements.txt diff --git a/backend/apps/operations/migrations/0018_remove_aiinsight_source.py b/backend/apps/operations/migrations/0018_remove_aiinsight_source.py new file mode 100644 index 0000000..39cba31 --- /dev/null +++ b/backend/apps/operations/migrations/0018_remove_aiinsight_source.py @@ -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", + ), + ] diff --git a/backend/apps/operations/models.py b/backend/apps/operations/models.py index 612dab6..3fb88a4 100644 --- a/backend/apps/operations/models.py +++ b/backend/apps/operations/models.py @@ -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) diff --git a/backend/apps/operations/services/cp707_knowledge.py b/backend/apps/operations/services/cp707_knowledge.py index dfa81c9..caf6c8d 100644 --- a/backend/apps/operations/services/cp707_knowledge.py +++ b/backend/apps/operations/services/cp707_knowledge.py @@ -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( diff --git a/backend/apps/operations/services/insight_service.py b/backend/apps/operations/services/insight_service.py index c158b9c..106968d 100644 --- a/backend/apps/operations/services/insight_service.py +++ b/backend/apps/operations/services/insight_service.py @@ -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) diff --git a/backend/apps/operations/tests_insight_grading.py b/backend/apps/operations/tests_insight_grading.py new file mode 100644 index 0000000..a67d7f9 --- /dev/null +++ b/backend/apps/operations/tests_insight_grading.py @@ -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"]) diff --git a/backend/database/ERD_dashboard.png b/backend/database/ERD_dashboard.png index 3471a8a..2ba6374 100644 Binary files a/backend/database/ERD_dashboard.png and b/backend/database/ERD_dashboard.png differ diff --git a/backend/database/erd_schema.sql b/backend/database/erd_schema.sql index c6dc167..63a08a7 100644 --- a/backend/database/erd_schema.sql +++ b/backend/database/erd_schema.sql @@ -196,7 +196,6 @@ CREATE TABLE IF NOT EXISTS ai_insight ( topic VARCHAR(64) NOT NULL DEFAULT '', report_type VARCHAR(32) NOT NULL DEFAULT 'page', report_period VARCHAR(64) NOT NULL DEFAULT 'current', - source VARCHAR(32) NOT NULL DEFAULT 'generated', -- generated | cache | local_fallback summary TEXT NOT NULL DEFAULT '', citations TEXT NOT NULL DEFAULT '[]', -- JSON array string of RAG citations created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/components/AiInsightCard.tsx b/components/AiInsightCard.tsx index 63037e7..4e303fe 100644 --- a/components/AiInsightCard.tsx +++ b/components/AiInsightCard.tsx @@ -11,7 +11,6 @@ import type { InsightCitation, InsightContext, InsightReportType, - InsightSource, InsightStructuredReport, InsightStructuredSection, InsightStatusLabel, @@ -27,15 +26,6 @@ import InsightTrendChart from './shared/InsightTrendChart.tsx'; const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes -const SOURCE_LABEL: Record = { - generated: { text: 'Baru dibuat', cls: 'bg-green-50 text-green-700 border-green-200' }, - cache: { text: 'Dari cache', cls: 'bg-blue-50 text-blue-700 border-blue-200' }, - local_fallback: { - text: 'Fallback lokal', - cls: 'bg-amber-50 text-amber-800 border-amber-200', - }, -}; - const sectionBadgeClasses: Record = { ok: 'border-emerald-200 bg-emerald-50 text-emerald-700', warning: 'border-amber-200 bg-amber-50 text-amber-700', @@ -179,7 +169,6 @@ export const AiInsightCard: React.FC = ({ const [insightText, setInsightText] = useState(''); const [structuredInsight, setStructuredInsight] = useState(null); - const [source, setSource] = useState(null); const [citations, setCitations] = useState([]); const [loading, setLoading] = useState(false); const [lastUpdated, setLastUpdated] = useState(null); @@ -214,10 +203,9 @@ export const AiInsightCard: React.FC = ({ const topic = page === 'dashboard' ? 'dashboard' : page === 'panel_iot' ? 'iot_panel' : page; const applyInsight = useCallback( - (text: string, nextSource: InsightSource, nextCitations?: InsightCitation[]) => { + (text: string, nextCitations?: InsightCitation[]) => { setInsightText(text); setStructuredInsight(normalizeStructuredInsight(text)); - setSource(nextSource); setCitations(nextCitations ?? []); }, [] @@ -261,6 +249,8 @@ export const AiInsightCard: React.FC = ({ contextPayload.eef_terakhir = scopedMetrics.eef; contextPayload.bobot_avg_gram = scopedMetrics.bobot_avg_gram; contextPayload.ayam_hidup_ekor = scopedMetrics.ayam_hidup_ekor; + contextPayload.mortalitas_kumulatif_persen = + scopedMetrics.mortalitas_kumulatif_persen ?? null; contextPayload.persen_hidup = scopedMetrics.mortalitas_kumulatif_persen == null ? null @@ -323,11 +313,7 @@ export const AiInsightCard: React.FC = ({ throw new Error(apiResult?.message || 'Gagal mengambil insight AI dari server'); } - applyInsight( - apiResult.insight_text, - apiResult.source ?? (forceRefresh ? 'generated' : 'cache'), - apiResult.citations - ); + applyInsight(apiResult.insight_text, apiResult.citations); setLastUpdated(new Date()); setDetailsOpen(false); } catch (err) { @@ -424,7 +410,6 @@ export const AiInsightCard: React.FC = ({ ); - const sourceMeta = source ? SOURCE_LABEL[source] : null; const summary = structuredInsight?.summary; const detailed = structuredInsight?.detailed; @@ -675,13 +660,6 @@ export const AiInsightCard: React.FC = ({ )} )} - {sourceMeta && ( - - {sourceMeta.text} - - )}