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
@@ -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)
|
||||
Reference in new issue
Block a user