diff --git a/backend/apps/operations/services/cp707_knowledge.py b/backend/apps/operations/services/cp707_knowledge.py index 817dde1..ea8284e 100644 --- a/backend/apps/operations/services/cp707_knowledge.py +++ b/backend/apps/operations/services/cp707_knowledge.py @@ -394,7 +394,7 @@ def analyze_fcr(actual_fcr: Any, day_age: Any) -> dict[str, Any]: } -def analyze_bw(actual_bw_g: Any, day_age: Any) -> dict[str, Any]: +def analyze_bw(actual_bw_g: Any, day_age: Any, target_bw_g: Any = None) -> dict[str, Any]: try: day_int = int(day_age) except (TypeError, ValueError): @@ -420,7 +420,11 @@ def analyze_bw(actual_bw_g: Any, day_age: Any) -> dict[str, Any]: # 0 g is a missing/placeholder reading, not a real bird weight. if actual is not None and actual <= 0: actual = None - std_bw = get_bw_standard_by_day(day_age) + + std_bw = _as_float(target_bw_g) + if std_bw is None or std_bw <= 0: + std_bw = get_bw_standard_by_day(day_age) + if actual is None or std_bw is None: return { "actual": actual, @@ -437,24 +441,24 @@ def analyze_bw(actual_bw_g: Any, day_age: Any) -> dict[str, Any]: if delta_pct < -15: status = "critical" message = ( - f"Bobot badan aktual {actual:g}g jauh di bawah standar CP 707 ({std_bw:g}g) " + f"Bobot badan aktual {actual:g}g jauh di bawah standar ({std_bw:g}g) " f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Lakukan grading segera." ) elif delta_pct < -5: status = "warning" message = ( - f"Bobot badan aktual {actual:g}g sedikit di bawah standar CP 707 ({std_bw:g}g) " + f"Bobot badan aktual {actual:g}g sedikit di bawah standar ({std_bw:g}g) " f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Tingkatkan stimulasi pakan." ) elif delta_pct > 10: status = "ok" message = ( - f"Bobot badan aktual {actual:g}g melampaui standar CP 707 ({std_bw:g}g) " + f"Bobot badan aktual {actual:g}g melampaui standar ({std_bw:g}g) " f"pada hari ke-{day_age}. Pertumbuhan sangat baik." ) else: status = "ok" - message = f"Bobot badan aktual {actual:g}g sesuai standar CP 707 ({std_bw:g}g) pada hari ke-{day_age}." + message = f"Bobot badan aktual {actual:g}g sesuai standar ({std_bw:g}g) pada hari ke-{day_age}." return { "actual": actual, @@ -1089,7 +1093,8 @@ def build_cp707_standard_block(context_data: dict[str, Any] | None) -> str: ip = get_ip_standard_by_day(day) ip_range = get_ip_standard_range() - bw = get_bw_standard_by_day(day) + target_bw_ctx = _resolve_target_bw(context_data, day) + bw = target_bw_ctx if target_bw_ctx is not None else get_bw_standard_by_day(day) fcr = get_fcr_standard_by_day(day) mort = get_mortality_cum_std_by_day(day) @@ -1279,6 +1284,30 @@ def _resolve_iot(context_pack: dict[str, Any]) -> dict[str, Any]: return context_pack +def _resolve_target_bw(context_pack: dict[str, Any] | None, day_age: int | None) -> float | None: + if not isinstance(context_pack, dict): + return None + direct = _first_positive_number( + context_pack.get("target_bw_gram"), + context_pack.get("targetWeight"), + context_pack.get("bobot_target_gram"), + context_pack.get("target_gram"), + context_pack.get("target_weight"), + ) + if direct is not None: + return direct + + tren = context_pack.get("tren_harian") + if isinstance(tren, list) and day_age is not None: + for row in tren: + if isinstance(row, dict) and row.get("hari") == day_age: + t = _first_positive_number(row.get("target_gram"), row.get("target_bw_gram"), row.get("target")) + if t is not None: + return t + + return None + + def analyze_with_cp707_standards(context_pack: dict[str, Any] | None, topic: str | None = None) -> dict[str, Any]: """Analisis komprehensif; toleran terhadap field InsightContext rebuild dan pack lama. Topic-scoped: only grades metrics in TOPIC_METRICS[topic]. Unknown emitted only for @@ -1294,8 +1323,9 @@ def analyze_with_cp707_standards(context_pack: dict[str, Any] | None, topic: str # Body weight grading if "bw" in metrics_to_grade: actual_bw = _resolve_bw_grams(pack) + target_bw = _resolve_target_bw(pack, day_age) if day_age is not None: - result["analyses"]["bw"] = analyze_bw(actual_bw, day_age) + result["analyses"]["bw"] = analyze_bw(actual_bw, day_age, target_bw) else: result["analyses"]["bw"] = {"status": "unknown", "message": "Umur tidak tersedia untuk grading bobot badan."} diff --git a/backend/apps/operations/services/insight_service.py b/backend/apps/operations/services/insight_service.py index e199bee..7dd2e8a 100644 --- a/backend/apps/operations/services/insight_service.py +++ b/backend/apps/operations/services/insight_service.py @@ -776,6 +776,36 @@ def enforce_mortality_wording( return out +def enforce_bw_wording( + parsed: dict[str, Any], + graded: dict[str, Any] | None, +) -> dict[str, Any]: + """Ensure body weight status and narrative match graded facts.""" + bw = ((graded or {}).get("analyses") or {}).get("bw") or {} + status = str(bw.get("status") or "").lower() + if not status: + return parsed + + message = str(bw.get("message") or "").strip() + actual = bw.get("actual") + std = bw.get("standard") + + out = dict(parsed) + kesimpulan = str(out.get("kesimpulan") or "") + + # If graded status is OK, but summary claims it is below standard / critical: + if status == "ok" and any( + w in kesimpulan.lower() + for w in ("di bawah standar", "sangat rendah", "critical", "kritis", "jauh di bawah") + ): + if message: + out["kesimpulan"] = message + elif actual is not None and std is not None: + out["kesimpulan"] = f"Bobot badan ayam ({actual:g}g) sesuai dengan target standar ({std:g}g)." + + return out + + _DUP_KANDANG = re.compile(r"\b[Kk]andang(?:\s+[Kk]andang)+\b") @@ -1111,6 +1141,7 @@ def generate_insight( "insight": str(parsed_raw.get("insight") or ""), } soft = enforce_mortality_wording(soft, graded) + soft = enforce_bw_wording(soft, graded) soft = sanitize_insight_narrative(soft) parsed_raw["kesimpulan"] = soft.get("kesimpulan") parsed_raw["insight"] = soft.get("insight") @@ -1143,6 +1174,7 @@ def generate_insight( "Output LLM tidak sesuai format JSON yang diminta: " + repr(str(raw)[:200]) ) parsed = enforce_mortality_wording(parsed, graded) + parsed = enforce_bw_wording(parsed, graded) parsed = sanitize_insight_narrative(parsed) insight_text = parsed["insight"] summary = parsed["kesimpulan"] diff --git a/components/AiInsightCard.tsx b/components/AiInsightCard.tsx index e27942b..913ffc8 100644 --- a/components/AiInsightCard.tsx +++ b/components/AiInsightCard.tsx @@ -365,6 +365,14 @@ export const AiInsightCard: React.FC = ({ contextPayload.uniformity_persen = scopedMetrics.uniformity_persen; contextPayload.pakan_kumulatif_karung = scopedMetrics.pakan_kumulatif_karung; + const currentTarget = + scopedMetrics.weightSeries.find((row) => row.hari === contextPayload.hari_ke)?.target ?? + [...scopedMetrics.weightSeries].reverse().find((row) => row.target != null)?.target ?? + null; + if (currentTarget != null && currentTarget > 0) { + contextPayload.target_bw_gram = currentTarget; + } + // EEF series for trend computation (eef, dashboard, end_cycle topics) if (topic === 'eef' || topic === 'dashboard' || reportType === 'end_cycle') { contextPayload.eef_seri = scopedMetrics.kpiSeries diff --git a/components/WeightMonitoringPage.tsx b/components/WeightMonitoringPage.tsx index fababfb..df0973e 100644 --- a/components/WeightMonitoringPage.tsx +++ b/components/WeightMonitoringPage.tsx @@ -132,12 +132,13 @@ const WeightMonitoringPage: React.FC<{ onSettings: () => void }> = ({ onSettings kandangName: selectedKandang.kandang_name, cycleId: selectedCycle?.id, totalDays: selectedCycle?.total_days ?? null, + docWeight: docWeight, weightRows: chartRows, averageWeight: kpiLatest?.average_weight ?? null, uniformity: kpiLatest?.uniformity ?? null, adg: kpiLatest?.average_daily_gain ?? null, }); - }, [selectedKandang, selectedCycle, chartRows, kpiLatest]); + }, [selectedKandang, selectedCycle, docWeight, chartRows, kpiLatest]); if (!selectedCycle) return ; diff --git a/utils/insightContextBuilders.ts b/utils/insightContextBuilders.ts index ec414cd..8e1b07b 100644 --- a/utils/insightContextBuilders.ts +++ b/utils/insightContextBuilders.ts @@ -209,6 +209,7 @@ export type BuildWeightInsightContextArgs = { cycleId?: number; hari_ke?: number | null; totalDays?: number | null; + docWeight?: number | null; weightRows?: WeightInsightRow[]; averageWeight?: number | null; targetWeight?: number | null; @@ -226,8 +227,8 @@ export function buildWeightInsightContext(args: BuildWeightInsightContextArgs): const hari = dayOf(row); if (hari === null) return null; const bobot = num(row.average_weight) ?? num(row.bobot_gram); - const target = - num(row.target_gram) ?? (totalDays != null ? getTargetWeightForAge(hari, totalDays) : null); + const doc = num(row.doc_weight) ?? num(args.docWeight) ?? 40; + const target = num(row.target_gram) ?? (hari > 0 ? getTargetWeightForAge(hari, doc) : null); return { hari, bobot_gram: bobot != null && bobot > 0 ? bobot : null,