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
+1
-1
@@ -10,4 +10,4 @@ __pycache__
|
|||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
docs/
|
docs/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
AI Insight/chroma_db/
|
ai-insight/chroma_db/
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -27,15 +27,6 @@ class IotPanel(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class AIInsight(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()
|
date = models.DateField()
|
||||||
insight_text = models.TextField()
|
insight_text = models.TextField()
|
||||||
alert = models.CharField(max_length=100, blank=True, default="")
|
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)
|
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_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)
|
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="")
|
summary = models.TextField(blank=True, default="")
|
||||||
citations = models.TextField(blank=True, default="[]")
|
citations = models.TextField(blank=True, default="[]")
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
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 {
|
return {
|
||||||
"status": "unknown",
|
"status": "unknown",
|
||||||
"direction": "unknown",
|
"direction": "unknown",
|
||||||
|
"severity_label": None,
|
||||||
|
"actual_pct": None,
|
||||||
|
"dilarang_kata": [],
|
||||||
"stdMortality": std_mortality,
|
"stdMortality": std_mortality,
|
||||||
"message": "Data mortalitas tidak tersedia.",
|
"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"]:
|
if rate > MORTALITY_THRESHOLDS["critical_pct"]:
|
||||||
status = "critical"
|
status = "critical"
|
||||||
direction = "di_atas_standar"
|
direction = "di_atas_standar"
|
||||||
|
severity_label = "SANGAT TINGGI"
|
||||||
|
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
|
||||||
message = (
|
message = (
|
||||||
f"Mortalitas kumulatif {rate:.2f}% melebihi batas kritis CP 707 (>7%). "
|
f"Mortalitas kumulatif {rate:.2f}% adalah SANGAT TINGGI — melebihi batas kritis "
|
||||||
"Lakukan nekropsi darurat dan perketat biosekuriti."
|
f"CP 707 (>7%). Lakukan nekropsi darurat dan perketat biosekuriti."
|
||||||
)
|
)
|
||||||
elif rate >= MORTALITY_THRESHOLDS["warning_pct"]:
|
elif rate >= MORTALITY_THRESHOLDS["warning_pct"]:
|
||||||
status = "warning"
|
status = "warning"
|
||||||
direction = "di_atas_standar"
|
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:
|
elif std_mortality is not None and rate > std_mortality * 1.5:
|
||||||
status = "warning"
|
status = "warning"
|
||||||
direction = "di_atas_standar"
|
direction = "di_atas_standar"
|
||||||
|
severity_label = "TINGGI"
|
||||||
|
dilarang = ["rendah", "normal", "baik", "aman", "terkendali"]
|
||||||
message = (
|
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}."
|
f"({std_mortality:.2f}%) pada hari ke-{day_age}."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
status = "ok"
|
status = "ok"
|
||||||
direction = "sesuai_standar" if (std_mortality is None or rate <= std_mortality) else "di_atas_standar"
|
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 {
|
return {
|
||||||
"status": status,
|
"status": status,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
|
"severity_label": severity_label,
|
||||||
|
"actual_pct": rate,
|
||||||
|
"dilarang_kata": dilarang,
|
||||||
"stdMortality": std_mortality,
|
"stdMortality": std_mortality,
|
||||||
"message": message,
|
"message": message,
|
||||||
}
|
}
|
||||||
@@ -615,6 +644,9 @@ ATURAN WAJIB saat membandingkan dengan standar:
|
|||||||
tersedia di buku untuk hari ini" — jangan mengganti dengan angka lain.
|
tersedia di buku untuk hari ini" — jangan mengganti dengan angka lain.
|
||||||
- Semua angka aktual harus berasal dari [Data Halaman (JSON)]. Dilarang
|
- Semua angka aktual harus berasal dari [Data Halaman (JSON)]. Dilarang
|
||||||
menghitung sendiri atau mengarang angka yang tidak ada di sana.
|
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:
|
def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None:
|
||||||
"""Bobot dalam gram. Field `_kg` hanya untuk legacy payload (dikonversi ×1000)."""
|
"""Bobot dalam gram. Field `_kg` hanya untuk legacy payload (dikonversi ×1000)."""
|
||||||
grams = _first_number(
|
grams = _first_number(
|
||||||
|
context_pack.get("bobot_avg_gram"),
|
||||||
|
context_pack.get("averageWeight"),
|
||||||
context_pack.get("bobot_rata_rata_gram"),
|
context_pack.get("bobot_rata_rata_gram"),
|
||||||
context_pack.get("bobot_iot_gram_terakhir"),
|
context_pack.get("bobot_iot_gram_terakhir"),
|
||||||
context_pack.get("bobot_iot_gram"),
|
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:
|
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_pct"),
|
||||||
|
context_pack.get("mortalitas_kumulatif_persen"),
|
||||||
context_pack.get("mortalitas_persen"),
|
context_pack.get("mortalitas_persen"),
|
||||||
context_pack.get("mortalityRate"),
|
context_pack.get("mortalityRate"),
|
||||||
context_pack.get("mortality_rate_pct"),
|
context_pack.get("mortality_rate_pct"),
|
||||||
_nested_get(context_pack, "chickenCounting", "dashboardSummary", "mortalityRate"),
|
_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]:
|
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": {}}
|
result: dict[str, Any] = {"dayAge": day_age, "analyses": {}}
|
||||||
|
|
||||||
actual_bw = _resolve_bw_grams(pack)
|
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)
|
result["analyses"]["bw"] = analyze_bw(actual_bw, day_age)
|
||||||
|
|
||||||
actual_fcr = _first_number(
|
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)
|
result["analyses"]["fcr"] = analyze_fcr(actual_fcr, day_age)
|
||||||
|
|
||||||
mortality_rate = _resolve_mortality(pack)
|
mortality_rate = _resolve_mortality(pack)
|
||||||
if mortality_rate is not None:
|
# Always emit mortality (unknown if missing) — silent omission caused the LLM
|
||||||
result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age)
|
# 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)
|
iot = _resolve_iot(pack)
|
||||||
temp = _first_number(
|
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
|
return worst if saw_status else ALERT_UNKNOWN
|
||||||
|
|
||||||
ANTI_HALLUCINATION_RULES = """
|
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):
|
ATURAN MORTALITAS (WAJIB DIPATUHI — TIDAK BOLEH DILANGGAR):
|
||||||
- Standar CP 707: mortalitas kumulatif NORMAL adalah < 5%.
|
- Bedakan DUA metrik berbeda:
|
||||||
- Mortalitas >= 5% dan <= 7% = TINGGI (warning) — WAJIB disebut "TINGGI", bukan "rendah" atau "normal".
|
1) mortalitas_hari_ini_ekor = jumlah EKOR mati hari ini (bukan persen).
|
||||||
- Mortalitas > 7% = SANGAT TINGGI (critical) — WAJIB disebut "SANGAT TINGGI".
|
Jika nilainya 0, tulis mortalitas hari ini 0 ekor — JANGAN sebut "5%" atau "TINGGI".
|
||||||
- Mortalitas < 5% = rendah/normal.
|
2) mortalitas kumulatif % = hanya dari field *_persen / persen_hidup / [GRADED FACTS].
|
||||||
- DILARANG KERAS menyebut mortalitas sebagai "rendah" atau "normal" jika nilainya >= 5%.
|
- 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):
|
ATURAN ANGKA & ARAH (WAJIB DIPATUHI):
|
||||||
- Setiap angka yang Anda tulis HARUS ada di [Data Halaman (JSON)] atau di blok STANDAR CP 707
|
- Angka di blok STANDAR CP 707 adalah STANDAR saja — BUKAN nilai aktual kandang.
|
||||||
atau di [GRADED FACTS]. DILARANG menghitung sendiri, memperkirakan, atau membalik arah tren.
|
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.
|
- 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,
|
- SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD: _gram, _persen, _rasio, _karung, _ekor, _kg,
|
||||||
_hari, _indeksTanpaSatuan. Pakai satuan itu persis.
|
_hari, _indeksTanpaSatuan. Pakai satuan itu persis.
|
||||||
@@ -84,12 +99,37 @@ ATURAN ANGKA & ARAH (WAJIB DIPATUHI):
|
|||||||
dan JANGAN mengarang angkanya.
|
dan JANGAN mengarang angkanya.
|
||||||
- Status topik tanpa data = unknown, bukan ok.
|
- Status topik tanpa data = unknown, bukan ok.
|
||||||
- Panen ≠ kematian; jangan hitung mortalitas dari selisih populasi awal − kini.
|
- Panen ≠ kematian; jangan hitung mortalitas dari selisih populasi awal − kini.
|
||||||
|
- DILARANG menghitung sendiri, memperkirakan, atau membalik arah tren.
|
||||||
|
|
||||||
FORMAT OUTPUT (JSON SAJA):
|
FORMAT OUTPUT (JSON SAJA):
|
||||||
{"kesimpulan":"...","insight":"..."}
|
{"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]:
|
def strip_headerless_tables(chunk: str) -> tuple[str, int]:
|
||||||
lines = str(chunk or "").split("\n")
|
lines = str(chunk or "").split("\n")
|
||||||
removed = 0
|
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]:
|
def grade_context(context: dict[str, Any]) -> dict[str, Any]:
|
||||||
analysis = cp707.analyze_with_cp707_standards(context)
|
analysis = cp707.analyze_with_cp707_standards(context)
|
||||||
graded: dict[str, Any] = {
|
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),
|
"hari_ke": analysis.get("dayAge") or _day_age(context),
|
||||||
"analyses": analysis.get("analyses") or {},
|
"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.
|
# Normalize analyzer outputs into explicit direction blocks for the prompt.
|
||||||
for key, block in list(graded["analyses"].items()):
|
for key, block in list(graded["analyses"].items()):
|
||||||
if not isinstance(block, dict):
|
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(
|
def lookup_cached(
|
||||||
*,
|
*,
|
||||||
cycle_id: int,
|
cycle_id: int,
|
||||||
@@ -361,7 +464,7 @@ def get_cached_insight(
|
|||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
return serialize_insight(row, source_override=AIInsight.SOURCE_CACHE)
|
return serialize_insight(row)
|
||||||
|
|
||||||
|
|
||||||
def encode_citations(citations: list[Any] | None) -> str:
|
def encode_citations(citations: list[Any] | None) -> str:
|
||||||
@@ -398,7 +501,7 @@ def decode_citations(raw: Any) -> list[dict[str, Any]]:
|
|||||||
return norm_citations
|
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)
|
norm_citations = decode_citations(row.citations)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -408,7 +511,6 @@ def serialize_insight(row: AIInsight, source_override: str | None = None) -> dic
|
|||||||
"topic": row.topic,
|
"topic": row.topic,
|
||||||
"report_type": row.report_type,
|
"report_type": row.report_type,
|
||||||
"report_period": row.report_period,
|
"report_period": row.report_period,
|
||||||
"source": source_override or row.source or AIInsight.SOURCE_GENERATED,
|
|
||||||
"summary": row.summary or "",
|
"summary": row.summary or "",
|
||||||
"insight": row.insight_text or "",
|
"insight": row.insight_text or "",
|
||||||
"insight_text": "\n\n".join(
|
"insight_text": "\n\n".join(
|
||||||
@@ -490,18 +592,20 @@ def generate_insight(
|
|||||||
if chunks:
|
if chunks:
|
||||||
system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(chunks[:4])
|
system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(chunks[:4])
|
||||||
|
|
||||||
user_prompt = (
|
user_prompt = build_insight_user_prompt(
|
||||||
f"Buat insight topik `{topic}` untuk kandang `{kandang.kandang_name}` "
|
topic=topic,
|
||||||
f"(id={kandang_id}), periode `{report_type}/{report_period}`.\n"
|
kandang_name=kandang.kandang_name,
|
||||||
f"[Data Halaman (JSON)]\n{json.dumps(ctx, ensure_ascii=False, default=str)}"
|
report_type=report_type,
|
||||||
|
report_period=report_period,
|
||||||
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
raw = call_ollama(system_prompt, user_prompt)
|
raw = call_ollama(system_prompt, user_prompt)
|
||||||
parsed = parse_llm_json(raw or "")
|
parsed = parse_llm_json(raw or "")
|
||||||
source = AIInsight.SOURCE_GENERATED
|
|
||||||
if not parsed:
|
if not parsed:
|
||||||
parsed = local_fallback_insight(graded, topic)
|
parsed = local_fallback_insight(graded, topic)
|
||||||
source = AIInsight.SOURCE_LOCAL_FALLBACK
|
else:
|
||||||
|
parsed = enforce_mortality_wording(parsed, graded)
|
||||||
|
|
||||||
insight_text = parsed["insight"]
|
insight_text = parsed["insight"]
|
||||||
summary = parsed["kesimpulan"]
|
summary = parsed["kesimpulan"]
|
||||||
@@ -518,8 +622,7 @@ def generate_insight(
|
|||||||
"insight_text": insight_text,
|
"insight_text": insight_text,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"alert": alert,
|
"alert": alert,
|
||||||
"source": source,
|
|
||||||
"citations": encode_citations(citations),
|
"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"])
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 336 KiB |
@@ -196,7 +196,6 @@ CREATE TABLE IF NOT EXISTS ai_insight (
|
|||||||
topic VARCHAR(64) NOT NULL DEFAULT '',
|
topic VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
report_type VARCHAR(32) NOT NULL DEFAULT 'page',
|
report_type VARCHAR(32) NOT NULL DEFAULT 'page',
|
||||||
report_period VARCHAR(64) NOT NULL DEFAULT 'current',
|
report_period VARCHAR(64) NOT NULL DEFAULT 'current',
|
||||||
source VARCHAR(32) NOT NULL DEFAULT 'generated', -- generated | cache | local_fallback
|
|
||||||
summary TEXT NOT NULL DEFAULT '',
|
summary TEXT NOT NULL DEFAULT '',
|
||||||
citations TEXT NOT NULL DEFAULT '[]', -- JSON array string of RAG citations
|
citations TEXT NOT NULL DEFAULT '[]', -- JSON array string of RAG citations
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
InsightCitation,
|
InsightCitation,
|
||||||
InsightContext,
|
InsightContext,
|
||||||
InsightReportType,
|
InsightReportType,
|
||||||
InsightSource,
|
|
||||||
InsightStructuredReport,
|
InsightStructuredReport,
|
||||||
InsightStructuredSection,
|
InsightStructuredSection,
|
||||||
InsightStatusLabel,
|
InsightStatusLabel,
|
||||||
@@ -27,15 +26,6 @@ import InsightTrendChart from './shared/InsightTrendChart.tsx';
|
|||||||
|
|
||||||
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes
|
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes
|
||||||
|
|
||||||
const SOURCE_LABEL: Record<InsightSource, { text: string; cls: string }> = {
|
|
||||||
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<InsightStatusLabel, string> = {
|
const sectionBadgeClasses: Record<InsightStatusLabel, string> = {
|
||||||
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
warning: 'border-amber-200 bg-amber-50 text-amber-700',
|
warning: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
@@ -179,7 +169,6 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
|
|
||||||
const [insightText, setInsightText] = useState('');
|
const [insightText, setInsightText] = useState('');
|
||||||
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
|
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
|
||||||
const [source, setSource] = useState<InsightSource | null>(null);
|
|
||||||
const [citations, setCitations] = useState<InsightCitation[]>([]);
|
const [citations, setCitations] = useState<InsightCitation[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
@@ -214,10 +203,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
const topic = page === 'dashboard' ? 'dashboard' : page === 'panel_iot' ? 'iot_panel' : page;
|
const topic = page === 'dashboard' ? 'dashboard' : page === 'panel_iot' ? 'iot_panel' : page;
|
||||||
|
|
||||||
const applyInsight = useCallback(
|
const applyInsight = useCallback(
|
||||||
(text: string, nextSource: InsightSource, nextCitations?: InsightCitation[]) => {
|
(text: string, nextCitations?: InsightCitation[]) => {
|
||||||
setInsightText(text);
|
setInsightText(text);
|
||||||
setStructuredInsight(normalizeStructuredInsight(text));
|
setStructuredInsight(normalizeStructuredInsight(text));
|
||||||
setSource(nextSource);
|
|
||||||
setCitations(nextCitations ?? []);
|
setCitations(nextCitations ?? []);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
@@ -261,6 +249,8 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
contextPayload.eef_terakhir = scopedMetrics.eef;
|
contextPayload.eef_terakhir = scopedMetrics.eef;
|
||||||
contextPayload.bobot_avg_gram = scopedMetrics.bobot_avg_gram;
|
contextPayload.bobot_avg_gram = scopedMetrics.bobot_avg_gram;
|
||||||
contextPayload.ayam_hidup_ekor = scopedMetrics.ayam_hidup_ekor;
|
contextPayload.ayam_hidup_ekor = scopedMetrics.ayam_hidup_ekor;
|
||||||
|
contextPayload.mortalitas_kumulatif_persen =
|
||||||
|
scopedMetrics.mortalitas_kumulatif_persen ?? null;
|
||||||
contextPayload.persen_hidup =
|
contextPayload.persen_hidup =
|
||||||
scopedMetrics.mortalitas_kumulatif_persen == null
|
scopedMetrics.mortalitas_kumulatif_persen == null
|
||||||
? null
|
? null
|
||||||
@@ -323,11 +313,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
throw new Error(apiResult?.message || 'Gagal mengambil insight AI dari server');
|
throw new Error(apiResult?.message || 'Gagal mengambil insight AI dari server');
|
||||||
}
|
}
|
||||||
|
|
||||||
applyInsight(
|
applyInsight(apiResult.insight_text, apiResult.citations);
|
||||||
apiResult.insight_text,
|
|
||||||
apiResult.source ?? (forceRefresh ? 'generated' : 'cache'),
|
|
||||||
apiResult.citations
|
|
||||||
);
|
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
setDetailsOpen(false);
|
setDetailsOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -424,7 +410,6 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
const sourceMeta = source ? SOURCE_LABEL[source] : null;
|
|
||||||
const summary = structuredInsight?.summary;
|
const summary = structuredInsight?.summary;
|
||||||
const detailed = structuredInsight?.detailed;
|
const detailed = structuredInsight?.detailed;
|
||||||
|
|
||||||
@@ -675,13 +660,6 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{sourceMeta && (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold ${sourceMeta.cls}`}
|
|
||||||
>
|
|
||||||
{sourceMeta.text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => generateInsight(true)}
|
onClick={() => generateInsight(true)}
|
||||||
disabled={loading || effectiveKandangId == null}
|
disabled={loading || effectiveKandangId == null}
|
||||||
|
|||||||
@@ -239,6 +239,10 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
|
|||||||
eef_terakhir: latest?.eef ?? null,
|
eef_terakhir: latest?.eef ?? null,
|
||||||
bobot_avg_gram: bobotAvg,
|
bobot_avg_gram: bobotAvg,
|
||||||
mortalitas_hari_ini_ekor: latest?.mortality ?? null,
|
mortalitas_hari_ini_ekor: latest?.mortality ?? null,
|
||||||
|
mortalitas_kumulatif_persen:
|
||||||
|
latest?.chicken_life_percentage == null
|
||||||
|
? null
|
||||||
|
: Math.round((100 - latest.chicken_life_percentage) * 100) / 100,
|
||||||
karung_dituang_hari_ini:
|
karung_dituang_hari_ini:
|
||||||
latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null),
|
latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null),
|
||||||
pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null,
|
pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { api, errorMessage } from '../../services/apiClient.ts';
|
|||||||
import type {
|
import type {
|
||||||
InsightCitation,
|
InsightCitation,
|
||||||
InsightContext,
|
InsightContext,
|
||||||
InsightSource,
|
|
||||||
InsightTopic,
|
InsightTopic,
|
||||||
} from '../../types/api.ts';
|
} from '../../types/api.ts';
|
||||||
import { formatRatio } from '../../utils/format.ts';
|
import { formatRatio } from '../../utils/format.ts';
|
||||||
@@ -35,7 +34,6 @@ const _insightMemoryCache = new Map<string, CachedPageInsight>();
|
|||||||
const _insightInFlight = new Map<string, Promise<void>>();
|
const _insightInFlight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
type CachedPageInsight = AiResult & {
|
type CachedPageInsight = AiResult & {
|
||||||
source: InsightSource;
|
|
||||||
citations?: InsightCitation[];
|
citations?: InsightCitation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,15 +67,6 @@ const TOPIC_META: Record<InsightTopic, { title: string; icon: string }> = {
|
|||||||
dashboard: { title: 'AI Insight — Dashboard', icon: 'fa-brain' },
|
dashboard: { title: 'AI Insight — Dashboard', icon: 'fa-brain' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOURCE_LABEL: Record<InsightSource, { text: string; cls: string }> = {
|
|
||||||
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 renderValue = (val: unknown, suffix: string = ''): string => {
|
const renderValue = (val: unknown, suffix: string = ''): string => {
|
||||||
if (val === undefined || val === null || val === '') return 'N/A';
|
if (val === undefined || val === null || val === '') return 'N/A';
|
||||||
if (val === UNAVAILABLE) return UNAVAILABLE;
|
if (val === UNAVAILABLE) return UNAVAILABLE;
|
||||||
@@ -433,7 +422,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
}
|
}
|
||||||
const parsed = parseAiResult(raw);
|
const parsed = parseAiResult(raw);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const wrapped: CachedPageInsight = { ...parsed, source: 'cache' };
|
const wrapped: CachedPageInsight = { ...parsed };
|
||||||
_insightMemoryCache.set(storageKey, wrapped);
|
_insightMemoryCache.set(storageKey, wrapped);
|
||||||
if (!isCurrent()) return;
|
if (!isCurrent()) return;
|
||||||
setResult(wrapped);
|
setResult(wrapped);
|
||||||
@@ -596,7 +585,6 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
const wrapped: CachedPageInsight = {
|
const wrapped: CachedPageInsight = {
|
||||||
summary: parsed.summary,
|
summary: parsed.summary,
|
||||||
insight: parsed.insight,
|
insight: parsed.insight,
|
||||||
source: apiResult.source ?? (force ? 'generated' : 'cache'),
|
|
||||||
citations: apiResult.citations,
|
citations: apiResult.citations,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -658,8 +646,6 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [scopeKey]);
|
}, [scopeKey]);
|
||||||
|
|
||||||
const sourceMeta = result ? SOURCE_LABEL[result.source] : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden mb-6">
|
<div className="rounded-xl border border-gray-100 bg-white shadow-sm overflow-hidden mb-6">
|
||||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100 bg-gray-50/80">
|
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100 bg-gray-50/80">
|
||||||
@@ -691,13 +677,6 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{sourceMeta && (
|
|
||||||
<span
|
|
||||||
className={`hidden sm:inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold ${sourceMeta.cls}`}
|
|
||||||
>
|
|
||||||
{sourceMeta.text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => generate(true)}
|
onClick={() => generate(true)}
|
||||||
disabled={loading || selectedKandangId == null}
|
disabled={loading || selectedKandangId == null}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ services:
|
|||||||
rag:
|
rag:
|
||||||
image: dashboard-cpsp-rag
|
image: dashboard-cpsp-rag
|
||||||
build:
|
build:
|
||||||
context: "./AI Insight"
|
context: "./ai-insight"
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: dashboard-cpsp-rag
|
container_name: dashboard-cpsp-rag
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export const weightStandardData = {
|
|||||||
{
|
{
|
||||||
day: 51,
|
day: 51,
|
||||||
values: [
|
values: [
|
||||||
57, 81, 106, 134, 167, 200, 206, 265, 307, 351, 405, 458, 514, 582, 628, 695, 767, 845, 922,
|
57, 81, 106, 134, 167, 200, 236, 265, 307, 351, 405, 458, 514, 582, 628, 695, 767, 845, 922,
|
||||||
1005, 1095, 1164, 1250, 1340, 1431, 1526, 1627, 1732, 1806, 1905, 2007, 2113, 2217, 2326,
|
1005, 1095, 1164, 1250, 1340, 1431, 1526, 1627, 1732, 1806, 1905, 2007, 2113, 2217, 2326,
|
||||||
2441,
|
2441,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Copy host "AI Insight/chroma_db" into the running rag container volume.
|
# Copy host "ai-insight/chroma_db" into the running rag container volume.
|
||||||
# Named volume rag-chroma mounts over the image-baked index — seed after first up.
|
# Named volume rag-chroma mounts over the image-baked index — seed after first up.
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$Root = Split-Path -Parent $PSScriptRoot
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
$Src = Join-Path $Root "AI Insight\chroma_db"
|
$Src = Join-Path $Root "ai-insight\chroma_db"
|
||||||
$Cid = if ($env:RAG_CONTAINER) { $env:RAG_CONTAINER } else { "dashboard-cpsp-rag" }
|
$Cid = if ($env:RAG_CONTAINER) { $env:RAG_CONTAINER } else { "dashboard-cpsp-rag" }
|
||||||
|
|
||||||
if (-not (Test-Path $Src)) {
|
if (-not (Test-Path $Src)) {
|
||||||
Write-Error "Missing $Src — run: cd 'AI Insight'; python populate_db.py"
|
Write-Error "Missing $Src — run: cd ai-insight; python populate_db.py"
|
||||||
}
|
}
|
||||||
docker inspect $Cid | Out-Null
|
docker inspect $Cid | Out-Null
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Copy host "AI Insight/chroma_db" into the running rag container volume.
|
# Copy host "ai-insight/chroma_db" into the running rag container volume.
|
||||||
# Named volume rag-chroma mounts over the image-baked index — seed after first up.
|
# Named volume rag-chroma mounts over the image-baked index — seed after first up.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
SRC="$ROOT/AI Insight/chroma_db"
|
SRC="$ROOT/ai-insight/chroma_db"
|
||||||
CID="${RAG_CONTAINER:-dashboard-cpsp-rag}"
|
CID="${RAG_CONTAINER:-dashboard-cpsp-rag}"
|
||||||
|
|
||||||
if [[ ! -d "$SRC" ]]; then
|
if [[ ! -d "$SRC" ]]; then
|
||||||
echo "Missing $SRC — run: cd \"AI Insight\" && python populate_db.py" >&2
|
echo "Missing $SRC — run: cd ai-insight && python populate_db.py" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -218,16 +218,12 @@ export type AIInsight = {
|
|||||||
topic: string;
|
topic: string;
|
||||||
report_type: string;
|
report_type: string;
|
||||||
report_period: string;
|
report_period: string;
|
||||||
source?: InsightSource;
|
|
||||||
summary?: string;
|
summary?: string;
|
||||||
citations?: InsightCitation[];
|
citations?: InsightCitation[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Source label shown in AI Insight UI — never hide local fallback as LLM. */
|
|
||||||
export type InsightSource = 'generated' | 'cache' | 'local_fallback';
|
|
||||||
|
|
||||||
export type InsightReportType = 'page' | 'daily' | 'weekly' | 'end_cycle';
|
export type InsightReportType = 'page' | 'daily' | 'weekly' | 'end_cycle';
|
||||||
|
|
||||||
export type InsightTopic =
|
export type InsightTopic =
|
||||||
@@ -306,7 +302,6 @@ export type InsightStructuredReport = {
|
|||||||
/** Response from generate / cached insight endpoints. */
|
/** Response from generate / cached insight endpoints. */
|
||||||
export type InsightApiResponse = {
|
export type InsightApiResponse = {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
source: InsightSource;
|
|
||||||
insight_text: string;
|
insight_text: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
insight?: string;
|
insight?: string;
|
||||||
|
|||||||
@@ -468,6 +468,8 @@ export type BuildDashboardInsightContextArgs = {
|
|||||||
eef_terakhir?: number | null;
|
eef_terakhir?: number | null;
|
||||||
bobot_avg_gram?: number | null;
|
bobot_avg_gram?: number | null;
|
||||||
mortalitas_hari_ini_ekor?: number | null;
|
mortalitas_hari_ini_ekor?: number | null;
|
||||||
|
/** Cumulative mortality % — preferred by the backend grader over deriving from % hidup. */
|
||||||
|
mortalitas_kumulatif_persen?: number | null;
|
||||||
karung_dituang_hari_ini?: number | null;
|
karung_dituang_hari_ini?: number | null;
|
||||||
pakan_kumulatif_karung?: number | null;
|
pakan_kumulatif_karung?: number | null;
|
||||||
ayam_hidup_ekor?: number | null;
|
ayam_hidup_ekor?: number | null;
|
||||||
@@ -495,6 +497,7 @@ export function buildDashboardInsightContext(
|
|||||||
eef_terakhir: args.eef_terakhir ?? null,
|
eef_terakhir: args.eef_terakhir ?? null,
|
||||||
bobot_avg_gram: args.bobot_avg_gram ?? null,
|
bobot_avg_gram: args.bobot_avg_gram ?? null,
|
||||||
mortalitas_hari_ini_ekor: args.mortalitas_hari_ini_ekor ?? null,
|
mortalitas_hari_ini_ekor: args.mortalitas_hari_ini_ekor ?? null,
|
||||||
|
mortalitas_kumulatif_persen: args.mortalitas_kumulatif_persen ?? null,
|
||||||
karung_dituang_hari_ini: args.karung_dituang_hari_ini ?? null,
|
karung_dituang_hari_ini: args.karung_dituang_hari_ini ?? null,
|
||||||
pakan_kumulatif_karung: args.pakan_kumulatif_karung ?? null,
|
pakan_kumulatif_karung: args.pakan_kumulatif_karung ?? null,
|
||||||
ayam_hidup_ekor: args.ayam_hidup_ekor ?? null,
|
ayam_hidup_ekor: args.ayam_hidup_ekor ?? null,
|
||||||
|
|||||||
Reference in new issue
Block a user