This commit is contained in:
2026-05-13 16:45:54 +03:30
parent 948c062b93
commit 46fe62fa04
96 changed files with 3834 additions and 155 deletions
+197 -8
View File
@@ -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"),
},
}
+9 -3
View File
@@ -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,
+83
View File
@@ -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")