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

+1 -1
View File
@@ -10,4 +10,4 @@ __pycache__
docker-compose.override.yml
docs/
.superpowers/
AI Insight/chroma_db/
ai-insight/chroma_db/
File renamed without changes.
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",
),
]
-15
View File
@@ -27,15 +27,6 @@ class IotPanel(models.Model):
class AIInsight(models.Model):
SOURCE_GENERATED = "generated"
SOURCE_CACHE = "cache"
SOURCE_LOCAL_FALLBACK = "local_fallback"
SOURCE_CHOICES = [
(SOURCE_GENERATED, "Generated"),
(SOURCE_CACHE, "Cache"),
(SOURCE_LOCAL_FALLBACK, "Local fallback"),
]
date = models.DateField()
insight_text = models.TextField()
alert = models.CharField(max_length=100, blank=True, default="")
@@ -50,12 +41,6 @@ class AIInsight(models.Model):
topic = models.CharField(max_length=64, blank=True, default="", db_index=True)
report_type = models.CharField(max_length=32, blank=True, default="page", db_index=True)
report_period = models.CharField(max_length=64, blank=True, default="current", db_index=True)
source = models.CharField(
max_length=32,
choices=SOURCE_CHOICES,
default=SOURCE_GENERATED,
blank=True,
)
summary = models.TextField(blank=True, default="")
citations = models.TextField(blank=True, default="[]")
created_at = models.DateTimeField(auto_now_add=True)
@@ -393,6 +393,9 @@ def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]:
return {
"status": "unknown",
"direction": "unknown",
"severity_label": None,
"actual_pct": None,
"dilarang_kata": [],
"stdMortality": std_mortality,
"message": "Data mortalitas tidak tersedia.",
}
@@ -400,29 +403,55 @@ def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]:
if rate > MORTALITY_THRESHOLDS["critical_pct"]:
status = "critical"
direction = "di_atas_standar"
severity_label = "SANGAT TINGGI"
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
message = (
f"Mortalitas kumulatif {rate:.2f}% melebihi batas kritis CP 707 (>7%). "
"Lakukan nekropsi darurat dan perketat biosekuriti."
f"Mortalitas kumulatif {rate:.2f}% adalah SANGAT TINGGI — melebihi batas kritis "
f"CP 707 (>7%). Lakukan nekropsi darurat dan perketat biosekuriti."
)
elif rate >= MORTALITY_THRESHOLDS["warning_pct"]:
status = "warning"
direction = "di_atas_standar"
message = f"Mortalitas kumulatif {rate:.2f}% perlu diwaspadai. Standar CP 707 <5%."
severity_label = "TINGGI"
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
message = (
f"Mortalitas kumulatif {rate:.2f}% adalah TINGGI (warning). "
"Standar CP 707 <5%."
)
elif rate >= MORTALITY_THRESHOLDS["normal_pct"]:
# 5% inclusive through just under warning threshold — still not "rendah"
status = "warning"
direction = "di_atas_standar"
severity_label = "TINGGI"
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
message = (
f"Mortalitas kumulatif {rate:.2f}% adalah TINGGI. "
"Standar CP 707 <5%."
)
elif std_mortality is not None and rate > std_mortality * 1.5:
status = "warning"
direction = "di_atas_standar"
severity_label = "TINGGI"
dilarang = ["rendah", "normal", "baik", "aman", "terkendali"]
message = (
f"Mortalitas {rate:.2f}% melebihi standar kumulatif harian CP 707 "
f"Mortalitas kumulatif {rate:.2f}% adalah TINGGI relatif standar harian CP 707 "
f"({std_mortality:.2f}%) pada hari ke-{day_age}."
)
else:
status = "ok"
direction = "sesuai_standar" if (std_mortality is None or rate <= std_mortality) else "di_atas_standar"
message = f"Mortalitas {rate:.2f}% dalam batas normal CP 707 (<5%)."
severity_label = "normal/rendah"
dilarang = ["SANGAT TINGGI", "kritis"]
message = (
f"Mortalitas kumulatif {rate:.2f}% normal/rendah — dalam batas CP 707 (<5%)."
)
return {
"status": status,
"direction": direction,
"severity_label": severity_label,
"actual_pct": rate,
"dilarang_kata": dilarang,
"stdMortality": std_mortality,
"message": message,
}
@@ -615,6 +644,9 @@ ATURAN WAJIB saat membandingkan dengan standar:
tersedia di buku untuk hari ini" — jangan mengganti dengan angka lain.
- Semua angka aktual harus berasal dari [Data Halaman (JSON)]. Dilarang
menghitung sendiri atau mengarang angka yang tidak ada di sana.
- Angka di blok ini adalah STANDAR saja (bukan nilai aktual kandang).
Jika bobot/mortalitas aktual tidak ada di Data Halaman / GRADED FACTS,
tulis "data tidak tersedia" — JANGAN menyalin angka standar sebagai aktual.
═══════════════════════════════════════════════"""
@@ -653,6 +685,8 @@ def _resolve_day_age(context_pack: dict[str, Any]) -> int | None:
def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None:
"""Bobot dalam gram. Field `_kg` hanya untuk legacy payload (dikonversi ×1000)."""
grams = _first_number(
context_pack.get("bobot_avg_gram"),
context_pack.get("averageWeight"),
context_pack.get("bobot_rata_rata_gram"),
context_pack.get("bobot_iot_gram_terakhir"),
context_pack.get("bobot_iot_gram"),
@@ -673,14 +707,34 @@ def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None:
def _resolve_mortality(context_pack: dict[str, Any]) -> float | None:
return _first_number(
"""Cumulative mortality % from explicit fields, % hidup, or kematian/DOC."""
direct = _first_number(
context_pack.get("mortalitas_kumulatif_pct"),
context_pack.get("mortalitas_kumulatif_persen"),
context_pack.get("mortalitas_persen"),
context_pack.get("mortalityRate"),
context_pack.get("mortality_rate_pct"),
_nested_get(context_pack, "chickenCounting", "dashboardSummary", "mortalityRate"),
_nested_get(context_pack, "chickenCounting", "mortality", "mortalityCount"),
)
if direct is not None:
return direct
persen_hidup = _first_number(context_pack.get("persen_hidup"))
if persen_hidup is not None:
return round((100.0 - persen_hidup) * 100) / 100
death = _first_number(
context_pack.get("kematian_kumulatif_ekor"),
context_pack.get("mortalitas_kumulatif_ekor"),
)
doc = _first_number(
context_pack.get("doc_in_ekor"),
context_pack.get("doc_in"),
context_pack.get("initialPopulation"),
)
if death is not None and doc is not None and doc > 0:
return round((death / doc) * 10000) / 100
return None
def _resolve_iot(context_pack: dict[str, Any]) -> dict[str, Any]:
@@ -702,7 +756,9 @@ def analyze_with_cp707_standards(context_pack: dict[str, Any] | None) -> dict[st
result: dict[str, Any] = {"dayAge": day_age, "analyses": {}}
actual_bw = _resolve_bw_grams(pack)
if actual_bw is not None and day_age is not None:
if day_age is not None:
# Always emit a bw block (unknown if missing) so the model cannot treat
# day-standard BW as the farm's actual weight.
result["analyses"]["bw"] = analyze_bw(actual_bw, day_age)
actual_fcr = _first_number(
@@ -716,8 +772,9 @@ def analyze_with_cp707_standards(context_pack: dict[str, Any] | None) -> dict[st
result["analyses"]["fcr"] = analyze_fcr(actual_fcr, day_age)
mortality_rate = _resolve_mortality(pack)
if mortality_rate is not None:
result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age)
# Always emit mortality (unknown if missing) — silent omission caused the LLM
# to narrate STANDAR CP 707 mortality as if it were the farm actual.
result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age)
iot = _resolve_iot(pack)
temp = _first_number(
@@ -67,16 +67,31 @@ def alert_from_graded(graded: dict[str, Any] | None) -> str:
return worst if saw_status else ALERT_UNKNOWN
ANTI_HALLUCINATION_RULES = """
ATURAN NAMA KANDANG & ID:
- Pakai nama kandang PERSIS seperti di Data Halaman / prompt (mis. "Kandang 2").
Jangan menambah kata "Kandang" lagi (hindari "Kandang Kandang 2").
- JANGAN menyebut id kandang, cycle id, atau "(id=…)".
ATURAN MORTALITAS (WAJIB DIPATUHI — TIDAK BOLEH DILANGGAR):
- Standar CP 707: mortalitas kumulatif NORMAL adalah < 5%.
- Mortalitas >= 5% dan <= 7% = TINGGI (warning) — WAJIB disebut "TINGGI", bukan "rendah" atau "normal".
- Mortalitas > 7% = SANGAT TINGGI (critical) — WAJIB disebut "SANGAT TINGGI".
- Mortalitas < 5% = rendah/normal.
- DILARANG KERAS menyebut mortalitas sebagai "rendah" atau "normal" jika nilainya >= 5%.
- Bedakan DUA metrik berbeda:
1) mortalitas_hari_ini_ekor = jumlah EKOR mati hari ini (bukan persen).
Jika nilainya 0, tulis mortalitas hari ini 0 ekor — JANGAN sebut "5%" atau "TINGGI".
2) mortalitas kumulatif % = hanya dari field *_persen / persen_hidup / [GRADED FACTS].
- Ambang CP 707 untuk mortalitas KUMULATIF %: <5% normal; 5–7% TINGGI; >7% SANGAT TINGGI.
Ambang ini BUKAN untuk mortalitas hari ini.
- WAJIB: untuk mortalitas kumulatif, sebutkan severity_label dari [GRADED FACTS]
PERSIS (contoh: "SANGAT TINGGI"). Jangan mengganti dengan sinonim lain.
- DILARANG memakai kata di dilarang_kata pada blok mortality (mis. "rendah", "normal",
"baik") jika severity_label = TINGGI atau SANGAT TINGGI.
- DILARANG KERAS menulis kalimat kontradiktif seperti "mortalitas rendah … 9%" atau
"mortalitas normal melebihi batas kritis". Satu arah saja: ikuti status + severity_label.
ATURAN ANGKA & ARAH (WAJIB DIPATUHI):
- Setiap angka yang Anda tulis HARUS ada di [Data Halaman (JSON)] atau di blok STANDAR CP 707
atau di [GRADED FACTS]. DILARANG menghitung sendiri, memperkirakan, atau membalik arah tren.
- Angka di blok STANDAR CP 707 adalah STANDAR saja — BUKAN nilai aktual kandang.
Jangan menulis standar (mis. bobot 2500 g atau mortalitas 5.75%) seolah data aktual.
- Nilai aktual HANYA dari [Data Halaman (JSON)] atau [GRADED FACTS] yang berstatus bukan unknown.
- Jika [GRADED FACTS] status=unknown / "tidak tersedia", tulis "data tidak tersedia" —
JANGAN mengisi dengan angka standar.
- Jika [GRADED FACTS] menyatakan direction/status, ULANGI arah itu — jangan dibalik.
- SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD: _gram, _persen, _rasio, _karung, _ekor, _kg,
_hari, _indeksTanpaSatuan. Pakai satuan itu persis.
@@ -84,12 +99,37 @@ ATURAN ANGKA & ARAH (WAJIB DIPATUHI):
dan JANGAN mengarang angkanya.
- Status topik tanpa data = unknown, bukan ok.
- Panen ≠ kematian; jangan hitung mortalitas dari selisih populasi awal − kini.
- DILARANG menghitung sendiri, memperkirakan, atau membalik arah tren.
FORMAT OUTPUT (JSON SAJA):
{"kesimpulan":"...","insight":"..."}
"""
def build_insight_user_prompt(
*,
topic: str,
kandang_name: str,
report_type: str,
report_period: str,
context: dict[str, Any],
) -> str:
"""User prompt for Ollama — name as-is, never prefix 'kandang' or expose ids."""
# Drop internal ids from the JSON the model sees (defense against id=N narration).
safe_ctx = {
k: v
for k, v in context.items()
if k not in ("kandangId", "kandang_id", "cycleId", "cycle_id")
}
return (
f"Buat insight topik `{topic}` untuk `{kandang_name}` "
f"periode `{report_type}/{report_period}`. "
"Gunakan nama kandang persis seperti tertulis; jangan menambah kata 'Kandang' "
"dan jangan menyebut id.\n"
f"[Data Halaman (JSON)]\n{json.dumps(safe_ctx, ensure_ascii=False, default=str)}"
)
def strip_headerless_tables(chunk: str) -> tuple[str, int]:
lines = str(chunk or "").split("\n")
removed = 0
@@ -176,10 +216,14 @@ def _build_weekly_summaries(context: dict[str, Any]) -> list[dict[str, Any]]:
def grade_context(context: dict[str, Any]) -> dict[str, Any]:
analysis = cp707.analyze_with_cp707_standards(context)
graded: dict[str, Any] = {
"kandangId": context.get("kandangId") or context.get("kandang_id"),
"kandangName": context.get("kandangName") or context.get("kandang_name"),
"hari_ke": analysis.get("dayAge") or _day_age(context),
"analyses": analysis.get("analyses") or {},
}
# Surface daily mortality headcount so the model cannot confuse the 5%
# cumulative threshold with "mortalitas hari ini".
if "mortalitas_hari_ini_ekor" in context:
graded["mortalitas_hari_ini_ekor"] = context.get("mortalitas_hari_ini_ekor")
# Normalize analyzer outputs into explicit direction blocks for the prompt.
for key, block in list(graded["analyses"].items()):
if not isinstance(block, dict):
@@ -319,6 +363,65 @@ def local_fallback_insight(graded: dict[str, Any], topic: str) -> dict[str, str]
}
_SOFT_MORTALITY_WORD = re.compile(
r"\b(rendah|normal|baik|aman|terkendali|sedikit)\b",
re.IGNORECASE,
)
_MENTIONS_MORTALITY = re.compile(r"mortalitas", re.IGNORECASE)
def _text_soft_labels_mortality(text: str) -> bool:
"""True when narrative uses soft adjectives in a mortality sentence."""
if not text or not _MENTIONS_MORTALITY.search(text):
return False
# Prefer soft word near "mortalitas" (same sentence-ish window).
for match in _MENTIONS_MORTALITY.finditer(text):
start = max(0, match.start() - 60)
end = min(len(text), match.end() + 80)
if _SOFT_MORTALITY_WORD.search(text[start:end]):
return True
return bool(_SOFT_MORTALITY_WORD.search(text) and _MENTIONS_MORTALITY.search(text))
def enforce_mortality_wording(
parsed: dict[str, str],
graded: dict[str, Any] | None,
) -> dict[str, str]:
"""Rewrite LLM text that calls high mortality 'rendah/normal' (small-model slip)."""
mort = ((graded or {}).get("analyses") or {}).get("mortality") or {}
status = str(mort.get("status") or "").lower()
if status not in ("warning", "critical"):
return parsed
label = mort.get("severity_label") or ("SANGAT TINGGI" if status == "critical" else "TINGGI")
actual = mort.get("actual_pct")
pct = f"{float(actual):.2f}%" if isinstance(actual, (int, float)) else None
message = str(mort.get("message") or "").strip()
out = dict(parsed)
kesimpulan = str(out.get("kesimpulan") or "")
insight = str(out.get("insight") or "")
if not (_text_soft_labels_mortality(kesimpulan) or _text_soft_labels_mortality(insight)):
return out
# Prefer the deterministic graded message; keep non-mortality insight if clean.
if message:
out["kesimpulan"] = message
else:
pct_part = f" ({pct})" if pct else ""
out["kesimpulan"] = (
f"Mortalitas kumulatif{pct_part} adalah {label} menurut ambang CP 707."
)
if _text_soft_labels_mortality(insight):
out["insight"] = (
"Segera tinjau penyebab kematian (nekropsi bila kritis), perketat biosekuriti, "
"dan pantau mortalitas harian hingga tren menurun."
)
return out
def lookup_cached(
*,
cycle_id: int,
@@ -361,7 +464,7 @@ def get_cached_insight(
)
if row is None:
return None
return serialize_insight(row, source_override=AIInsight.SOURCE_CACHE)
return serialize_insight(row)
def encode_citations(citations: list[Any] | None) -> str:
@@ -398,7 +501,7 @@ def decode_citations(raw: Any) -> list[dict[str, Any]]:
return norm_citations
def serialize_insight(row: AIInsight, source_override: str | None = None) -> dict[str, Any]:
def serialize_insight(row: AIInsight) -> dict[str, Any]:
norm_citations = decode_citations(row.citations)
return {
"success": True,
@@ -408,7 +511,6 @@ def serialize_insight(row: AIInsight, source_override: str | None = None) -> dic
"topic": row.topic,
"report_type": row.report_type,
"report_period": row.report_period,
"source": source_override or row.source or AIInsight.SOURCE_GENERATED,
"summary": row.summary or "",
"insight": row.insight_text or "",
"insight_text": "\n\n".join(
@@ -490,18 +592,20 @@ def generate_insight(
if chunks:
system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(chunks[:4])
user_prompt = (
f"Buat insight topik `{topic}` untuk kandang `{kandang.kandang_name}` "
f"(id={kandang_id}), periode `{report_type}/{report_period}`.\n"
f"[Data Halaman (JSON)]\n{json.dumps(ctx, ensure_ascii=False, default=str)}"
user_prompt = build_insight_user_prompt(
topic=topic,
kandang_name=kandang.kandang_name,
report_type=report_type,
report_period=report_period,
context=ctx,
)
raw = call_ollama(system_prompt, user_prompt)
parsed = parse_llm_json(raw or "")
source = AIInsight.SOURCE_GENERATED
if not parsed:
parsed = local_fallback_insight(graded, topic)
source = AIInsight.SOURCE_LOCAL_FALLBACK
else:
parsed = enforce_mortality_wording(parsed, graded)
insight_text = parsed["insight"]
summary = parsed["kesimpulan"]
@@ -518,8 +622,7 @@ def generate_insight(
"insight_text": insight_text,
"summary": summary,
"alert": alert,
"source": source,
"citations": encode_citations(citations),
},
)
return serialize_insight(row, source_override=source)
return serialize_insight(row)
@@ -0,0 +1,135 @@
"""Regression: dashboard field names must grade; prompt must not invent ids/names."""
from django.test import SimpleTestCase
from apps.operations.services import cp707_knowledge as cp707
from apps.operations.services.insight_service import (
ANTI_HALLUCINATION_RULES,
build_insight_user_prompt,
enforce_mortality_wording,
grade_context,
)
class ResolveDashboardFieldsTests(SimpleTestCase):
def test_resolve_bw_from_bobot_avg_gram(self):
self.assertEqual(cp707._resolve_bw_grams({"bobot_avg_gram": 2100}), 2100.0)
def test_resolve_bw_from_average_weight(self):
self.assertEqual(cp707._resolve_bw_grams({"averageWeight": 1950}), 1950.0)
def test_resolve_mortality_from_mortalitas_kumulatif_persen(self):
self.assertEqual(
cp707._resolve_mortality({"mortalitas_kumulatif_persen": 2.4}),
2.4,
)
def test_resolve_mortality_from_persen_hidup(self):
self.assertAlmostEqual(
cp707._resolve_mortality({"persen_hidup": 97.5}),
2.5,
)
def test_resolve_mortality_from_deaths_over_doc(self):
self.assertAlmostEqual(
cp707._resolve_mortality(
{"kematian_kumulatif_ekor": 50, "doc_in_ekor": 10000}
),
0.5,
)
def test_dashboard_shaped_context_grades_bw_and_mortality(self):
"""Reproduce Kandang 2 day-48 payload: FCR present, BW/mort via dashboard keys."""
ctx = {
"hari_ke": 48,
"fcr_terakhir": 1.71,
"bobot_avg_gram": 2100,
"persen_hidup": 98.0,
"mortalitas_hari_ini_ekor": 0,
"kematian_kumulatif_ekor": 200,
"doc_in_ekor": 10000,
}
graded = grade_context(ctx)
analyses = graded["analyses"]
self.assertIn("fcr", analyses)
self.assertIn("bw", analyses)
self.assertIn("mortality", analyses)
self.assertEqual(analyses["bw"]["actual"], 2100.0)
self.assertEqual(analyses["mortality"]["status"], "ok")
self.assertIn("2.00%", analyses["mortality"]["message"])
self.assertEqual(graded.get("mortalitas_hari_ini_ekor"), 0)
class MissingMetricsGradedAsUnknownTests(SimpleTestCase):
def test_missing_bw_and_mortality_emit_unknown_not_silent(self):
"""When fields are absent, graded facts must say unknown so the LLM cannot use standards as actuals."""
ctx = {"hari_ke": 48, "fcr_terakhir": 1.71}
graded = grade_context(ctx)
self.assertEqual(graded["analyses"]["bw"]["status"], "unknown")
self.assertEqual(graded["analyses"]["mortality"]["status"], "unknown")
self.assertIn("tidak tersedia", graded["analyses"]["bw"]["message"].lower())
class InsightPromptNamingTests(SimpleTestCase):
def test_user_prompt_uses_name_without_kandang_prefix_or_id(self):
prompt = build_insight_user_prompt(
topic="dashboard",
kandang_name="Kandang 2",
report_type="page",
report_period="current",
context={"hari_ke": 48, "kandangId": 6, "cycleId": 99},
)
self.assertIn("untuk `Kandang 2`", prompt)
self.assertNotIn("untuk kandang `Kandang 2`", prompt)
self.assertNotIn("id=", prompt)
self.assertNotIn('"kandangId"', prompt)
self.assertNotIn('"cycleId"', prompt)
def test_anti_hallucination_forbids_standard_as_actual_and_confusing_today_mortality(self):
rules = ANTI_HALLUCINATION_RULES.lower()
self.assertIn("standar", rules)
self.assertIn("bukan nilai aktual", rules)
self.assertIn("mortalitas_hari_ini", rules)
self.assertIn("jangan menyebut id", rules)
self.assertIn("severity_label", rules)
class MortalityWordingConsistencyTests(SimpleTestCase):
def test_critical_mortality_has_explicit_severity_label(self):
result = cp707.analyze_mortality(9.49, 48)
self.assertEqual(result["status"], "critical")
self.assertEqual(result["severity_label"], "SANGAT TINGGI")
self.assertEqual(result["actual_pct"], 9.49)
self.assertIn("rendah", result["dilarang_kata"])
def test_enforce_rewrites_rendah_when_mortality_is_critical(self):
graded = {
"analyses": {
"mortality": cp707.analyze_mortality(9.49, 48),
}
}
parsed = {
"kesimpulan": (
"Kandang 1 menunjukkan mortalitas kumulatif yang rendah pada hari ke-48, "
"mencapai 9.49%, melebihi batas kritis CP707 (>7%)."
),
"insight": "Lakukan nekropsi darurat.",
}
fixed = enforce_mortality_wording(parsed, graded)
text = f"{fixed['kesimpulan']} {fixed['insight']}".lower()
self.assertNotIn("rendah", text)
self.assertIn("sangat tinggi", text)
self.assertIn("9.49", fixed["kesimpulan"])
def test_enforce_leaves_ok_mortality_narrative_alone(self):
graded = {
"analyses": {
"mortality": cp707.analyze_mortality(2.1, 20),
}
}
parsed = {
"kesimpulan": "Mortalitas kumulatif rendah (2.10%), masih dalam batas normal.",
"insight": "Pertahankan biosekuriti.",
}
fixed = enforce_mortality_wording(parsed, graded)
self.assertEqual(fixed["kesimpulan"], parsed["kesimpulan"])
Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 336 KiB

-1
View File
@@ -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,
+4 -26
View File
@@ -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<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> = {
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<AiInsightCardProps> = ({
const [insightText, setInsightText] = useState('');
const [structuredInsight, setStructuredInsight] = useState<InsightStructuredReport | null>(null);
const [source, setSource] = useState<InsightSource | null>(null);
const [citations, setCitations] = useState<InsightCitation[]>([]);
const [loading, setLoading] = useState(false);
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 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<AiInsightCardProps> = ({
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<AiInsightCardProps> = ({
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<AiInsightCardProps> = ({
</section>
);
const sourceMeta = source ? SOURCE_LABEL[source] : null;
const summary = structuredInsight?.summary;
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
onClick={() => generateInsight(true)}
disabled={loading || effectiveKandangId == null}
+4
View File
@@ -239,6 +239,10 @@ const Dashboard: React.FC<Props> = ({ setView }) => {
eef_terakhir: latest?.eef ?? null,
bobot_avg_gram: bobotAvg,
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:
latest != null ? latest.feed : (karung?.feed_use_today ?? karung?.out_today ?? null),
pakan_kumulatif_karung: karung?.feed_use_total ?? latest?.feed_total ?? null,
+1 -22
View File
@@ -12,7 +12,6 @@ import { api, errorMessage } from '../../services/apiClient.ts';
import type {
InsightCitation,
InsightContext,
InsightSource,
InsightTopic,
} from '../../types/api.ts';
import { formatRatio } from '../../utils/format.ts';
@@ -35,7 +34,6 @@ const _insightMemoryCache = new Map<string, CachedPageInsight>();
const _insightInFlight = new Map<string, Promise<void>>();
type CachedPageInsight = AiResult & {
source: InsightSource;
citations?: InsightCitation[];
};
@@ -69,15 +67,6 @@ const TOPIC_META: Record<InsightTopic, { title: string; icon: string }> = {
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 => {
if (val === undefined || val === null || val === '') return 'N/A';
if (val === UNAVAILABLE) return UNAVAILABLE;
@@ -433,7 +422,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
}
const parsed = parseAiResult(raw);
if (parsed) {
const wrapped: CachedPageInsight = { ...parsed, source: 'cache' };
const wrapped: CachedPageInsight = { ...parsed };
_insightMemoryCache.set(storageKey, wrapped);
if (!isCurrent()) return;
setResult(wrapped);
@@ -596,7 +585,6 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const wrapped: CachedPageInsight = {
summary: parsed.summary,
insight: parsed.insight,
source: apiResult.source ?? (force ? 'generated' : 'cache'),
citations: apiResult.citations,
};
@@ -658,8 +646,6 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
return () => clearTimeout(timer);
}, [scopeKey]);
const sourceMeta = result ? SOURCE_LABEL[result.source] : null;
return (
<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">
@@ -691,13 +677,6 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
))}
</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
onClick={() => generate(true)}
disabled={loading || selectedKandangId == null}
+1 -1
View File
@@ -32,7 +32,7 @@ services:
rag:
image: dashboard-cpsp-rag
build:
context: "./AI Insight"
context: "./ai-insight"
dockerfile: Dockerfile
container_name: dashboard-cpsp-rag
restart: unless-stopped
+1 -1
View File
@@ -135,7 +135,7 @@ export const weightStandardData = {
{
day: 51,
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,
2441,
],
+3 -3
View File
@@ -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.
$ErrorActionPreference = "Stop"
$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" }
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
if ($LASTEXITCODE -ne 0) {
+3 -3
View File
@@ -1,13 +1,13 @@
#!/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.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/AI Insight/chroma_db"
SRC="$ROOT/ai-insight/chroma_db"
CID="${RAG_CONTAINER:-dashboard-cpsp-rag}"
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
fi
-5
View File
@@ -218,16 +218,12 @@ export type AIInsight = {
topic: string;
report_type: string;
report_period: string;
source?: InsightSource;
summary?: string;
citations?: InsightCitation[];
created_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 InsightTopic =
@@ -306,7 +302,6 @@ export type InsightStructuredReport = {
/** Response from generate / cached insight endpoints. */
export type InsightApiResponse = {
success?: boolean;
source: InsightSource;
insight_text: string;
summary?: string;
insight?: string;
+3
View File
@@ -468,6 +468,8 @@ export type BuildDashboardInsightContextArgs = {
eef_terakhir?: number | null;
bobot_avg_gram?: 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;
pakan_kumulatif_karung?: number | null;
ayam_hidup_ekor?: number | null;
@@ -495,6 +497,7 @@ export function buildDashboardInsightContext(
eef_terakhir: args.eef_terakhir ?? null,
bobot_avg_gram: args.bobot_avg_gram ?? 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,
pakan_kumulatif_karung: args.pakan_kumulatif_karung ?? null,
ayam_hidup_ekor: args.ayam_hidup_ekor ?? null,