adjust ai insight and seed data
This commit is contained in:
1 parent
68e0d1a0bb
commit
a2b5b10977
18 files changed
+1835
-140
No files matched your search
@@ -19,6 +19,7 @@ from django.utils import timezone
|
||||
from apps.farms.models import Cycle, Kandang
|
||||
from apps.operations.models import AIInsight
|
||||
from apps.operations.services import cp707_knowledge as cp707
|
||||
from apps.operations.services import root_cause
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -105,6 +106,24 @@ FORMAT OUTPUT (JSON SAJA):
|
||||
{"kesimpulan":"...","insight":"..."}
|
||||
"""
|
||||
|
||||
END_CYCLE_OUTPUT_RULES = """
|
||||
FORMAT OUTPUT AKHIR SIKLUS (JSON SAJA — WAJIB):
|
||||
{
|
||||
"kesimpulan":"...",
|
||||
"masalah":["..."],
|
||||
"akar_penyebab":[{"id":"...","hipotesis":"...","bukti":["..."],"confidence":"high|med|low"}],
|
||||
"perbaikan_siklus_berikutnya":[{"id":"...","fase":"brooding|growth|finisher","aksi":"...","metrik_pantau":"..."}],
|
||||
"insight":"..."
|
||||
}
|
||||
|
||||
ATURAN AKHIR SIKLUS:
|
||||
- Masalah = ringkas isu dari [GRADED FACTS] (critical/warning saja).
|
||||
- Akar penyebab HANYA dari [ROOT CAUSE CANDIDATES] — boleh rapikan bahasa/bukti, JANGAN menambah hipotesis baru.
|
||||
- Perbaikan HANYA dari [NEXT CYCLE ACTIONS] — boleh rapikan bahasa, JANGAN menambah aksi baru.
|
||||
- Sertakan field "id" dari kandidat/aksi yang dipakai.
|
||||
- Jika kandidat kosong dan KPI sehat, tulis kesimpulan positif singkat; masalah/akar/perbaikan boleh [].
|
||||
"""
|
||||
|
||||
|
||||
def build_insight_user_prompt(
|
||||
*,
|
||||
@@ -122,10 +141,10 @@ def build_insight_user_prompt(
|
||||
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"Buat insight topik `{topic}` untuk unit bernama `{kandang_name}` "
|
||||
f"periode `{report_type}/{report_period}`.\n"
|
||||
f"NAMA WAJIB: tulis PERSIS `{kandang_name}` — jangan menambah kata 'Kandang' di depan "
|
||||
f"(salah: 'Kandang {kandang_name}'; benar: '{kandang_name}'), dan jangan menyebut id.\n"
|
||||
f"[Data Halaman (JSON)]\n{json.dumps(safe_ctx, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
|
||||
@@ -167,22 +186,62 @@ def _day_age(context: dict[str, Any]) -> int | None:
|
||||
def prune_context_by_period(context: dict[str, Any], report_type: str, report_period: str) -> dict[str, Any]:
|
||||
"""Light prune for history arrays; keep scalars. Full FE scope happens client-side."""
|
||||
out = dict(context)
|
||||
# Placeholder zeros (esp. weight/FCR sensors) → null so LLM sees "unavailable".
|
||||
for key in (
|
||||
"bobot_avg_gram",
|
||||
"averageWeight",
|
||||
"bobot_rata_rata_gram",
|
||||
"bobot_iot_gram",
|
||||
"bobot_iot_gram_terakhir",
|
||||
"fcr_terakhir",
|
||||
"fcr",
|
||||
"eef_terakhir",
|
||||
):
|
||||
val = out.get(key)
|
||||
if isinstance(val, (int, float)) and val <= 0:
|
||||
out[key] = None
|
||||
if report_type == "end_cycle":
|
||||
# Deterministic weekly KPI rollup when weekly_summaries absent.
|
||||
if "weekly_summaries" not in out:
|
||||
if "weekly_summaries" not in out or not out.get("weekly_summaries"):
|
||||
out["weekly_summaries"] = _build_weekly_summaries(out)
|
||||
if "phase_summaries" not in out or not out.get("phase_summaries"):
|
||||
out["phase_summaries"] = _build_phase_summaries(out.get("weekly_summaries") or [])
|
||||
out["catatan_end_cycle"] = (
|
||||
"Ringkasan KPI per minggu dihitung di backend. Narasikan dari weekly_summaries + graded_facts; "
|
||||
"jangan menghitung ulang."
|
||||
"Ringkasan KPI per minggu dihitung di backend. Narasikan dari weekly_summaries + "
|
||||
"phase_summaries + graded_facts + root-cause candidates; jangan menghitung ulang."
|
||||
)
|
||||
out["report_type"] = report_type
|
||||
out["report_period"] = report_period
|
||||
return out
|
||||
|
||||
|
||||
def _row_day(row: dict[str, Any]) -> int | None:
|
||||
day = row.get("hari") or row.get("day") or row.get("age")
|
||||
try:
|
||||
day_i = int(day)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return day_i if day_i > 0 else None
|
||||
|
||||
|
||||
def _row_num(row: dict[str, Any], *keys: str) -> float | None:
|
||||
for key in keys:
|
||||
val = row.get(key)
|
||||
if isinstance(val, (int, float)):
|
||||
return float(val)
|
||||
return None
|
||||
|
||||
|
||||
def _build_weekly_summaries(context: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
histories = []
|
||||
for key in ("tren_fcr_harian", "tren_harian", "tren_7_hari_terakhir", "history"):
|
||||
histories: list[Any] = []
|
||||
for key in (
|
||||
"kpiSeries",
|
||||
"tren_kpi_harian",
|
||||
"tren_fcr_harian",
|
||||
"tren_harian",
|
||||
"tren_7_hari_terakhir",
|
||||
"history",
|
||||
):
|
||||
val = context.get(key)
|
||||
if isinstance(val, list) and val:
|
||||
histories = val
|
||||
@@ -191,28 +250,121 @@ def _build_weekly_summaries(context: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
for row in histories:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
day = row.get("hari") or row.get("day")
|
||||
try:
|
||||
day_i = int(day)
|
||||
except (TypeError, ValueError):
|
||||
day_i = _row_day(row)
|
||||
if day_i is None:
|
||||
continue
|
||||
week = max(1, (day_i + 6) // 7)
|
||||
by_week.setdefault(week, []).append(row)
|
||||
|
||||
summaries = []
|
||||
prev_mort: float | None = None
|
||||
for week, rows in sorted(by_week.items()):
|
||||
fcrs = [r.get("fcr_aktual") or r.get("fcr") for r in rows if isinstance(r.get("fcr_aktual") or r.get("fcr"), (int, float))]
|
||||
rows_sorted = sorted(rows, key=lambda r: _row_day(r) or 0)
|
||||
fcrs = [
|
||||
v
|
||||
for r in rows_sorted
|
||||
if (v := _row_num(r, "fcr_aktual", "fcr", "fcr_terakhir")) is not None
|
||||
]
|
||||
bws = [
|
||||
v
|
||||
for r in rows_sorted
|
||||
if (v := _row_num(r, "bobot_avg_gram", "actual", "average_weight", "bw")) is not None
|
||||
]
|
||||
feeds = [
|
||||
v
|
||||
for r in rows_sorted
|
||||
if (v := _row_num(r, "pakan_kumulatif_karung", "feed_total", "karung")) is not None
|
||||
]
|
||||
morts = [
|
||||
v
|
||||
for r in rows_sorted
|
||||
if (v := _row_num(r, "mortalitas_kumulatif_persen", "mortality_pct")) is not None
|
||||
]
|
||||
mort_end = morts[-1] if morts else None
|
||||
mort_delta = None
|
||||
if mort_end is not None:
|
||||
mort_delta = round(mort_end - (prev_mort or 0.0), 3)
|
||||
prev_mort = mort_end
|
||||
|
||||
cold = sum(
|
||||
_row_num(r, "jam_suhu_di_bawah_standar", "temp_below_hours", "hours_temp_below") or 0
|
||||
for r in rows_sorted
|
||||
)
|
||||
humid = sum(
|
||||
_row_num(
|
||||
r, "jam_kelembapan_di_atas_standar", "humidity_above_hours", "hours_humidity_above"
|
||||
)
|
||||
or 0
|
||||
for r in rows_sorted
|
||||
)
|
||||
ammonia = sum(
|
||||
_row_num(r, "jam_amonia_tinggi", "ammonia_high_hours", "hours_ammonia_high") or 0
|
||||
for r in rows_sorted
|
||||
)
|
||||
|
||||
summaries.append(
|
||||
{
|
||||
"minggu_ke": week,
|
||||
"jumlah_hari_data": len(rows),
|
||||
"jumlah_hari_data": len(rows_sorted),
|
||||
"fcr_rata_rasio": round(sum(fcrs) / len(fcrs), 3) if fcrs else None,
|
||||
"fcr_akhir_rasio": fcrs[-1] if fcrs else None,
|
||||
"bobot_akhir_gram": bws[-1] if bws else None,
|
||||
"pakan_akhir_karung": feeds[-1] if feeds else None,
|
||||
"mortalitas_kumulatif_persen": mort_end,
|
||||
"mortalitas_delta_persen": mort_delta,
|
||||
"jam_suhu_di_bawah_standar": round(cold, 1) if cold else 0,
|
||||
"jam_kelembapan_di_atas_standar": round(humid, 1) if humid else 0,
|
||||
"jam_amonia_tinggi": round(ammonia, 1) if ammonia else 0,
|
||||
}
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def _build_phase_summaries(weekly: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Roll weekly rows into brooding / growth / finisher buckets."""
|
||||
buckets: dict[str, list[dict[str, Any]]] = {
|
||||
root_cause.PHASE_BROODING: [],
|
||||
root_cause.PHASE_GROWTH: [],
|
||||
root_cause.PHASE_FINISHER: [],
|
||||
}
|
||||
for row in weekly:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
week = row.get("minggu_ke")
|
||||
try:
|
||||
week_i = int(week)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
# week 1-2 ≈ days 1-14, week 3-4 ≈ 15-28, week 5+ ≈ 29+
|
||||
if week_i <= 2:
|
||||
buckets[root_cause.PHASE_BROODING].append(row)
|
||||
elif week_i <= 4:
|
||||
buckets[root_cause.PHASE_GROWTH].append(row)
|
||||
else:
|
||||
buckets[root_cause.PHASE_FINISHER].append(row)
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
for phase, rows in buckets.items():
|
||||
if not rows:
|
||||
continue
|
||||
mort_deltas = [
|
||||
float(r["mortalitas_delta_persen"])
|
||||
for r in rows
|
||||
if isinstance(r.get("mortalitas_delta_persen"), (int, float))
|
||||
]
|
||||
fcrs = [
|
||||
float(r["fcr_akhir_rasio"])
|
||||
for r in rows
|
||||
if isinstance(r.get("fcr_akhir_rasio"), (int, float))
|
||||
]
|
||||
out[phase] = {
|
||||
"jumlah_minggu": len(rows),
|
||||
"mortalitas_delta_persen": round(sum(mort_deltas), 3) if mort_deltas else None,
|
||||
"fcr_akhir_rasio": fcrs[-1] if fcrs else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def grade_context(context: dict[str, Any]) -> dict[str, Any]:
|
||||
analysis = cp707.analyze_with_cp707_standards(context)
|
||||
graded: dict[str, Any] = {
|
||||
@@ -346,6 +498,38 @@ def parse_llm_json(text: str) -> dict[str, str] | None:
|
||||
}
|
||||
|
||||
|
||||
def parse_end_cycle_llm_json(text: str) -> dict[str, Any] | None:
|
||||
"""Parse end-cycle structured JSON; tolerate partial fields."""
|
||||
if not text:
|
||||
return None
|
||||
cleaned = re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.I)
|
||||
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned.strip(), flags=re.I)
|
||||
cleaned = re.sub(r"\s*```\s*$", "", cleaned)
|
||||
first = cleaned.find("{")
|
||||
last = cleaned.rfind("}")
|
||||
if first < 0 or last <= first:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(cleaned[first : last + 1])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
# Accept if any of the end-cycle keys or daily keys exist.
|
||||
keys = (
|
||||
"kesimpulan",
|
||||
"ringkasan",
|
||||
"summary",
|
||||
"masalah",
|
||||
"akar_penyebab",
|
||||
"perbaikan_siklus_berikutnya",
|
||||
"insight",
|
||||
)
|
||||
if not any(k in parsed for k in keys):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def local_fallback_insight(graded: dict[str, Any], topic: str) -> dict[str, str]:
|
||||
analyses = graded.get("analyses") or {}
|
||||
lines = []
|
||||
@@ -384,9 +568,9 @@ def _text_soft_labels_mortality(text: str) -> bool:
|
||||
|
||||
|
||||
def enforce_mortality_wording(
|
||||
parsed: dict[str, str],
|
||||
parsed: dict[str, Any],
|
||||
graded: dict[str, Any] | None,
|
||||
) -> dict[str, str]:
|
||||
) -> dict[str, Any]:
|
||||
"""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()
|
||||
@@ -422,6 +606,25 @@ def enforce_mortality_wording(
|
||||
return out
|
||||
|
||||
|
||||
_DUP_KANDANG = re.compile(r"\b[Kk]andang(?:\s+[Kk]andang)+\b")
|
||||
|
||||
|
||||
def collapse_duplicate_kandang(text: str) -> str:
|
||||
"""Fix LLM slip 'Kandang Kandang 2' → 'Kandang 2' (name already includes Kandang)."""
|
||||
if not text:
|
||||
return text
|
||||
return _DUP_KANDANG.sub(lambda m: "Kandang" if m.group(0)[0].isupper() else "kandang", text)
|
||||
|
||||
|
||||
def sanitize_insight_narrative(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Deterministic cleanup of narrative fields after the LLM."""
|
||||
out = dict(parsed)
|
||||
for key in ("kesimpulan", "insight", "summary", "insight_text"):
|
||||
if key in out and out[key] is not None:
|
||||
out[key] = collapse_duplicate_kandang(str(out[key]))
|
||||
return out
|
||||
|
||||
|
||||
def lookup_cached(
|
||||
*,
|
||||
cycle_id: int,
|
||||
@@ -503,6 +706,16 @@ def decode_citations(raw: Any) -> list[dict[str, Any]]:
|
||||
|
||||
def serialize_insight(row: AIInsight) -> dict[str, Any]:
|
||||
norm_citations = decode_citations(row.citations)
|
||||
summary = collapse_duplicate_kandang(row.summary or "")
|
||||
insight_body = collapse_duplicate_kandang(row.insight_text or "")
|
||||
insight_text = "\n\n".join(
|
||||
part for part in [summary, insight_body] if part
|
||||
) or insight_body or ""
|
||||
structured = root_cause.parse_embedded_end_cycle(insight_body)
|
||||
if structured and summary and not structured.get("kesimpulan"):
|
||||
structured = {**structured, "kesimpulan": summary}
|
||||
elif structured:
|
||||
structured = sanitize_insight_narrative(structured)
|
||||
return {
|
||||
"success": True,
|
||||
"id": row.pk,
|
||||
@@ -511,13 +724,10 @@ def serialize_insight(row: AIInsight) -> dict[str, Any]:
|
||||
"topic": row.topic,
|
||||
"report_type": row.report_type,
|
||||
"report_period": row.report_period,
|
||||
"summary": row.summary or "",
|
||||
"insight": row.insight_text or "",
|
||||
"insight_text": "\n\n".join(
|
||||
part for part in [row.summary or "", row.insight_text or ""] if part
|
||||
)
|
||||
or row.insight_text
|
||||
or "",
|
||||
"summary": summary,
|
||||
"insight": insight_body,
|
||||
"insight_text": insight_text,
|
||||
"structured_end_cycle": structured,
|
||||
"alert": row.alert or ALERT_UNKNOWN,
|
||||
"citations": norm_citations,
|
||||
"date": row.date.isoformat() if row.date else None,
|
||||
@@ -579,16 +789,46 @@ def generate_insight(
|
||||
day = graded.get("hari_ke")
|
||||
standard_block = cp707.build_cp707_standard_block(ctx) or cp707.build_book_reference_context(day or 1)
|
||||
|
||||
rag_query = f"panduan manajemen broiler CP 707 untuk {topic} umur hari ke-{day or '?'}"
|
||||
is_end_cycle = report_type == "end_cycle"
|
||||
hypotheses: list[dict[str, Any]] = []
|
||||
next_actions: list[dict[str, Any]] = []
|
||||
masalah: list[str] = []
|
||||
if is_end_cycle:
|
||||
hypotheses = root_cause.build_root_cause_hypotheses(graded, ctx)
|
||||
next_actions = root_cause.build_next_cycle_actions(hypotheses)
|
||||
masalah = root_cause.build_masalah_from_graded(graded)
|
||||
# Prefer phase-targeted RAG for end-cycle remediation.
|
||||
top_fase = next((h.get("fase") for h in hypotheses if h.get("fase")), None)
|
||||
rag_query = (
|
||||
f"panduan CP 707 perbaikan {top_fase or 'broiler'} siklus berikutnya "
|
||||
f"umur hari ke-{day or '?'}"
|
||||
)
|
||||
else:
|
||||
rag_query = f"panduan manajemen broiler CP 707 untuk {topic} umur hari ke-{day or '?'}"
|
||||
chunks, citations = fetch_rag_chunks(rag_query, topic)
|
||||
|
||||
system_prompt = (
|
||||
"Anda adalah asisten farm broiler on-premise. Tugas Anda HANYA menulis narasi "
|
||||
"dari GRADED FACTS + standar CP 707 + cuplikan SOP. Jangan menghitung ulang.\n"
|
||||
f"{ANTI_HALLUCINATION_RULES}\n"
|
||||
)
|
||||
if is_end_cycle:
|
||||
# End-cycle replaces the 2-field daily format instructions.
|
||||
system_prompt = system_prompt.replace(
|
||||
'FORMAT OUTPUT (JSON SAJA):\n{"kesimpulan":"...","insight":"..."}',
|
||||
END_CYCLE_OUTPUT_RULES.strip(),
|
||||
)
|
||||
system_prompt += (
|
||||
f"{standard_block}\n"
|
||||
f"[GRADED FACTS]\n{json.dumps(graded, ensure_ascii=False, default=str)}\n"
|
||||
)
|
||||
if is_end_cycle:
|
||||
system_prompt += (
|
||||
f"\n[ROOT CAUSE CANDIDATES]\n"
|
||||
f"{json.dumps(hypotheses, ensure_ascii=False, default=str)}\n"
|
||||
f"\n[NEXT CYCLE ACTIONS]\n"
|
||||
f"{json.dumps(next_actions, ensure_ascii=False, default=str)}\n"
|
||||
)
|
||||
if chunks:
|
||||
system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(chunks[:4])
|
||||
|
||||
@@ -601,14 +841,41 @@ def generate_insight(
|
||||
)
|
||||
|
||||
raw = call_ollama(system_prompt, user_prompt)
|
||||
parsed = parse_llm_json(raw or "")
|
||||
if not parsed:
|
||||
parsed = local_fallback_insight(graded, topic)
|
||||
if is_end_cycle:
|
||||
parsed_raw = parse_end_cycle_llm_json(raw or "")
|
||||
if parsed_raw:
|
||||
# Apply mortality wording on narrative fields before sanitize.
|
||||
soft = {
|
||||
"kesimpulan": str(parsed_raw.get("kesimpulan") or ""),
|
||||
"insight": str(parsed_raw.get("insight") or ""),
|
||||
}
|
||||
soft = enforce_mortality_wording(soft, graded)
|
||||
soft = sanitize_insight_narrative(soft)
|
||||
parsed_raw["kesimpulan"] = soft.get("kesimpulan")
|
||||
parsed_raw["insight"] = soft.get("insight")
|
||||
payload = root_cause.sanitize_end_cycle_payload(
|
||||
parsed_raw,
|
||||
hypotheses=hypotheses,
|
||||
actions=next_actions,
|
||||
masalah=masalah,
|
||||
)
|
||||
payload = sanitize_insight_narrative(payload)
|
||||
else:
|
||||
payload = root_cause.local_fallback_end_cycle(graded, ctx)
|
||||
summary = collapse_duplicate_kandang(str(payload.get("kesimpulan") or ""))
|
||||
insight_text = collapse_duplicate_kandang(
|
||||
root_cause.format_end_cycle_insight_text(payload)
|
||||
)
|
||||
else:
|
||||
parsed = enforce_mortality_wording(parsed, graded)
|
||||
parsed = parse_llm_json(raw or "")
|
||||
if not parsed:
|
||||
parsed = local_fallback_insight(graded, topic)
|
||||
else:
|
||||
parsed = enforce_mortality_wording(parsed, graded)
|
||||
parsed = sanitize_insight_narrative(parsed)
|
||||
insight_text = parsed["insight"]
|
||||
summary = parsed["kesimpulan"]
|
||||
|
||||
insight_text = parsed["insight"]
|
||||
summary = parsed["kesimpulan"]
|
||||
alert = alert_from_graded(graded)
|
||||
|
||||
row, _created = AIInsight.objects.update_or_create(
|
||||
|
||||
Reference in new issue
Block a user