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

@@ -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)