UPDATE
This commit is contained in:
@@ -521,13 +521,95 @@ def _build_alert_stats(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return stats
|
||||
|
||||
|
||||
def _snapshot_metric(ai_snapshot: dict[str, Any] | None, metric_name: str) -> float | None:
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return None
|
||||
farm_metrics = ai_snapshot.get("farm_metrics") or {}
|
||||
resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {}
|
||||
if not isinstance(resolved_metrics, dict):
|
||||
return None
|
||||
return safe_number(resolved_metrics.get(metric_name), None)
|
||||
|
||||
|
||||
|
||||
def _snapshot_weather(ai_snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return {}
|
||||
weather = ai_snapshot.get("weather") or {}
|
||||
forecast = weather.get("forecast") if isinstance(weather, dict) else None
|
||||
return forecast if isinstance(forecast, dict) else {}
|
||||
|
||||
|
||||
|
||||
def _block_metric_alerts(ai_snapshot: dict[str, Any] | None, sensor_id: str) -> list[dict[str, Any]]:
|
||||
alerts: list[dict[str, Any]] = []
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return alerts
|
||||
for block in ai_snapshot.get("block_metrics") or []:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
block_code = str(block.get("block_code") or "default-block")
|
||||
metrics = block.get("resolved_metrics") or {}
|
||||
moisture = safe_number(metrics.get("soil_moisture"), None)
|
||||
if moisture is not None and moisture < 25:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="moisture",
|
||||
current_value=moisture,
|
||||
threshold_value=25.0,
|
||||
severity="warning" if moisture >= 18 else "danger",
|
||||
duration_hours=1.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
zone_id=block_code,
|
||||
direction="below",
|
||||
metadata={
|
||||
"evaluation_level": "block",
|
||||
"affected_blocks": [block_code],
|
||||
"source": "block_metrics",
|
||||
},
|
||||
)
|
||||
)
|
||||
return alerts
|
||||
|
||||
|
||||
|
||||
def _sub_block_support(ai_snapshot: dict[str, Any] | None, metric_type: str) -> list[dict[str, Any]]:
|
||||
evidence: list[dict[str, Any]] = []
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return evidence
|
||||
metric_key = {
|
||||
"moisture": "soil_moisture",
|
||||
"ph": "soil_ph",
|
||||
"ec": "electrical_conductivity",
|
||||
}.get(metric_type)
|
||||
if not metric_key:
|
||||
return evidence
|
||||
for sub_block in ai_snapshot.get("sub_block_metrics") or []:
|
||||
if not isinstance(sub_block, dict):
|
||||
continue
|
||||
metrics = sub_block.get("resolved_metrics") or {}
|
||||
value = metrics.get(metric_key)
|
||||
if value is None:
|
||||
continue
|
||||
evidence.append(
|
||||
{
|
||||
"block_code": sub_block.get("block_code") or "default-block",
|
||||
"sub_block_code": sub_block.get("sub_block_code") or "default-sub-block",
|
||||
"metric": metric_key,
|
||||
"value": round(float(value), 2),
|
||||
}
|
||||
)
|
||||
return evidence
|
||||
|
||||
|
||||
|
||||
def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
|
||||
context = context or {}
|
||||
sensor = context.get("sensor")
|
||||
ai_snapshot = (ai_bundle or {}).get("ai_snapshot") if isinstance(ai_bundle, dict) else None
|
||||
forecasts = context.get("forecasts", [])
|
||||
history = context.get("history", [])
|
||||
|
||||
if sensor is None:
|
||||
if not isinstance(ai_snapshot, dict):
|
||||
return {
|
||||
"totalAlerts": 0,
|
||||
"alerts": [],
|
||||
@@ -537,14 +619,116 @@ def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bu
|
||||
"prioritizedAlertSummaries": [],
|
||||
"recommendedOperationalActions": [],
|
||||
"humanReadableExplanations": [],
|
||||
"source_metadata": {"status": "missing", "fallback": "no_ai_snapshot"},
|
||||
}
|
||||
|
||||
alerts = []
|
||||
alerts.extend(_detect_moisture_alert(sensor, history, sensor_id))
|
||||
alerts.extend(_detect_ph_alert(sensor, history, sensor_id))
|
||||
alerts.extend(_detect_ec_alert(sensor, history, sensor_id))
|
||||
alerts.extend(_detect_frost_alert(forecasts, sensor_id))
|
||||
alerts.extend(_detect_fungal_risk(sensor, forecasts, history, sensor_id))
|
||||
|
||||
moisture = _snapshot_metric(ai_snapshot, "soil_moisture")
|
||||
if moisture is not None and moisture < 25:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="moisture",
|
||||
current_value=moisture,
|
||||
threshold_value=25.0,
|
||||
severity="warning" if moisture >= 18 else "danger",
|
||||
duration_hours=1.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="below",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"affected_blocks": [item.get("block_code") for item in (ai_snapshot.get("block_metrics") or [])],
|
||||
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "moisture"),
|
||||
"source": "farm_metrics",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
soil_ph = _snapshot_metric(ai_snapshot, "soil_ph")
|
||||
if soil_ph is not None and not (6.0 <= soil_ph <= 7.8):
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="ph",
|
||||
current_value=soil_ph,
|
||||
threshold_value="6.0-7.8",
|
||||
severity="warning" if 5.5 <= soil_ph <= 8.2 else "danger",
|
||||
duration_hours=1.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="below" if soil_ph < 6.0 else "above",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "ph"),
|
||||
"source": "farm_metrics",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
ec = _snapshot_metric(ai_snapshot, "electrical_conductivity")
|
||||
if ec is not None and ec > 2.5:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="ec",
|
||||
current_value=ec,
|
||||
threshold_value=2.5,
|
||||
severity="warning" if ec <= 3.2 else "danger",
|
||||
duration_hours=1.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="above",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "ec"),
|
||||
"source": "farm_metrics",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
weather = _snapshot_weather(ai_snapshot)
|
||||
if weather:
|
||||
temp_min = safe_number(weather.get("temperature_min"), None)
|
||||
if temp_min is not None and temp_min < 0:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="temperature",
|
||||
current_value=temp_min,
|
||||
threshold_value=0.0,
|
||||
severity="warning" if temp_min >= -2 else "danger",
|
||||
duration_hours=24.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="below",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"source": "weather_forecast",
|
||||
"weather_policy": "center_location_latest_forecast",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
humidity = safe_number(weather.get("humidity_mean"), None)
|
||||
if humidity is not None and moisture is not None and humidity > 75 and moisture > 60:
|
||||
alerts.append(
|
||||
_make_alert(
|
||||
metric_type="fungal_risk",
|
||||
current_value=humidity,
|
||||
threshold_value=75.0,
|
||||
severity="warning" if humidity <= 85 else "danger",
|
||||
duration_hours=24.0,
|
||||
timestamp=_now(),
|
||||
sensor_id=sensor_id,
|
||||
direction="above",
|
||||
metadata={
|
||||
"evaluation_level": "farm",
|
||||
"supporting_sub_blocks": _sub_block_support(ai_snapshot, "moisture"),
|
||||
"source": "farm_metrics+weather_forecast",
|
||||
"soil_moisture": round(moisture, 2),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
alerts.extend(_block_metric_alerts(ai_snapshot, sensor_id))
|
||||
|
||||
ordered_alerts = _sort_alerts(alerts)
|
||||
clusters = _build_clusters(ordered_alerts)
|
||||
@@ -559,4 +743,9 @@ def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bu
|
||||
"prioritizedAlertSummaries": [alert["summary"] for alert in ordered_alerts],
|
||||
"recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts],
|
||||
"humanReadableExplanations": [alert["explanation"] for alert in ordered_alerts],
|
||||
"source_metadata": {
|
||||
"farm_metrics": (ai_snapshot.get("source_metadata") or {}).get("farm_metrics", {}),
|
||||
"weather": ((ai_snapshot.get("weather") or {}).get("source_metadata") or {}),
|
||||
"default_block_policy": (ai_snapshot.get("aggregation_policy") or {}).get("default_block_policy"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ from typing import Any
|
||||
from django.apps import apps
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from farm_data.context import load_farm_context
|
||||
from farm_data.services import build_ai_farm_snapshot
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
@@ -156,12 +156,18 @@ def _build_structured_context(
|
||||
if context is None:
|
||||
raise ValueError("farm_uuid نامعتبر است یا اطلاعات هشدار مزرعه پیدا نشد.")
|
||||
|
||||
tracker = build_farm_alerts_tracker(sensor_id=farm_uuid, context=context, ai_bundle=None)
|
||||
ai_snapshot = build_ai_farm_snapshot(farm_uuid)
|
||||
tracker = build_farm_alerts_tracker(
|
||||
sensor_id=farm_uuid,
|
||||
context=context,
|
||||
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||
)
|
||||
structured = {
|
||||
"farm_profile": _farm_profile(context, farm_uuid),
|
||||
"tracker": tracker,
|
||||
"forecasts": _forecast_summary(context),
|
||||
"incoming_alerts": _normalize_incoming_alerts(incoming_alerts),
|
||||
"ai_snapshot_source_metadata": (ai_snapshot or {}).get("source_metadata", {}),
|
||||
}
|
||||
return context, structured
|
||||
|
||||
@@ -321,7 +327,7 @@ def _llm_response(
|
||||
) -> tuple[dict[str, Any], str, str]:
|
||||
cfg = load_rag_config()
|
||||
service, service_cfg, model, client = _build_service_config(cfg, service_id)
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
farm_details = build_ai_farm_snapshot(farm_uuid)
|
||||
rag_context = build_rag_context(
|
||||
query=query,
|
||||
sensor_uuid=farm_uuid,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from farm_alerts.alerts_tracker import build_farm_alerts_tracker
|
||||
|
||||
|
||||
class FarmAlertsTrackerCanonicalTests(SimpleTestCase):
|
||||
def test_whole_farm_alert_uses_aggregated_metrics(self):
|
||||
ai_snapshot = {
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 16.0}},
|
||||
"block_metrics": [{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 14.0}}],
|
||||
"sub_block_metrics": [
|
||||
{"block_code": "block-1", "sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 12.0}}
|
||||
],
|
||||
"weather": {"forecast": {"temperature_min": 8.0, "humidity_mean": 60.0}, "source_metadata": {}},
|
||||
"source_metadata": {"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"}},
|
||||
"aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"},
|
||||
}
|
||||
|
||||
tracker = build_farm_alerts_tracker(
|
||||
sensor_id="farm-1",
|
||||
context={"forecasts": []},
|
||||
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||
)
|
||||
|
||||
self.assertEqual(tracker["totalAlerts"], 2)
|
||||
self.assertEqual(tracker["alerts"][0]["metadata"]["evaluation_level"], "farm")
|
||||
self.assertEqual(tracker["alerts"][0]["metadata"]["source"], "farm_metrics")
|
||||
self.assertEqual(tracker["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot")
|
||||
|
||||
def test_block_specific_alert_includes_affected_block(self):
|
||||
ai_snapshot = {
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 30.0}},
|
||||
"block_metrics": [
|
||||
{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 31.0}},
|
||||
{"block_code": "block-2", "resolved_metrics": {"soil_moisture": 17.0}},
|
||||
],
|
||||
"sub_block_metrics": [
|
||||
{"block_code": "block-2", "sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 15.0}}
|
||||
],
|
||||
"weather": {"forecast": {}, "source_metadata": {}},
|
||||
"source_metadata": {"farm_metrics": {}},
|
||||
"aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"},
|
||||
}
|
||||
|
||||
tracker = build_farm_alerts_tracker(
|
||||
sensor_id="farm-1",
|
||||
context={"forecasts": []},
|
||||
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||
)
|
||||
|
||||
block_alerts = [alert for alert in tracker["alerts"] if alert.get("zone_id") == "block-2"]
|
||||
self.assertEqual(len(block_alerts), 1)
|
||||
self.assertEqual(block_alerts[0]["metadata"]["evaluation_level"], "block")
|
||||
self.assertEqual(block_alerts[0]["metadata"]["affected_blocks"], ["block-2"])
|
||||
|
||||
def test_default_block_sub_block_policy_is_reported(self):
|
||||
ai_snapshot = {
|
||||
"farm_metrics": {"resolved_metrics": {"soil_moisture": 22.0}},
|
||||
"block_metrics": [{"block_code": "default-block", "resolved_metrics": {"soil_moisture": 22.0}}],
|
||||
"sub_block_metrics": [
|
||||
{"block_code": "default-block", "sub_block_code": "default-sub-block", "resolved_metrics": {"soil_moisture": 22.0}}
|
||||
],
|
||||
"weather": {"forecast": {}, "source_metadata": {}},
|
||||
"source_metadata": {"farm_metrics": {}},
|
||||
"aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"},
|
||||
}
|
||||
|
||||
tracker = build_farm_alerts_tracker(
|
||||
sensor_id="farm-1",
|
||||
context={"forecasts": []},
|
||||
ai_bundle={"ai_snapshot": ai_snapshot},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
tracker["source_metadata"]["default_block_policy"],
|
||||
"1_main_block + 1_default_sub_block_when_missing",
|
||||
)
|
||||
|
||||
def test_missing_snapshot_uses_explicit_fallback_metadata(self):
|
||||
tracker = build_farm_alerts_tracker(sensor_id="farm-1", context={"forecasts": []}, ai_bundle={})
|
||||
|
||||
self.assertEqual(tracker["totalAlerts"], 0)
|
||||
self.assertEqual(tracker["source_metadata"]["fallback"], "no_ai_snapshot")
|
||||
Reference in New Issue
Block a user