This commit is contained in:
2026-04-28 19:00:38 +03:30
parent 8471d648a3
commit cb60254c81
8 changed files with 971 additions and 86 deletions
+16 -8
View File
@@ -628,17 +628,25 @@ def get_fertilization_recommendation(
.first()
)
resolved_plant_name = plant_name
plant_config = apps.get_app_config("plant")
resolved_plant_name = plant_config.resolve_plant_name(plant_name)
if not resolved_plant_name and crop_id:
resolved_plant_name = crop_id
resolved_plant_name = plant_config.resolve_plant_name(crop_id)
resolved_growth_stage = plant_config.resolve_growth_stage(growth_stage)
plant = None
if not resolved_plant_name and sensor is not None:
plant = sensor.plants.first()
if plant is not None:
resolved_plant_name = plant.name
elif sensor is not None and resolved_plant_name:
plant = sensor.plants.filter(name=resolved_plant_name).first() or sensor.plants.first()
elif resolved_plant_name:
if sensor is not None:
plant = sensor.plants.filter(name=resolved_plant_name).first()
if plant is None:
Plant = apps.get_model("plant", "Plant")
plant = Plant.objects.filter(name=resolved_plant_name).first()
if plant is None and sensor is not None:
plant = sensor.plants.first()
if plant is not None:
resolved_plant_name = plant.name
@@ -658,7 +666,7 @@ def get_fertilization_recommendation(
sensor=sensor,
plant=plant,
forecasts=forecasts,
growth_stage=growth_stage,
growth_stage=resolved_growth_stage,
)
context = build_rag_context(
@@ -671,8 +679,8 @@ def get_fertilization_recommendation(
)
extra_parts: list[str] = []
if resolved_plant_name and growth_stage:
plant_text = build_plant_text(resolved_plant_name, growth_stage)
if resolved_plant_name and resolved_growth_stage:
plant_text = build_plant_text(resolved_plant_name, resolved_growth_stage)
if plant_text:
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
if optimized_result is not None:
@@ -721,7 +729,7 @@ def get_fertilization_recommendation(
optimized_result=optimized_result,
plant_name=resolved_plant_name,
crop_id=crop_id,
growth_stage=growth_stage,
growth_stage=resolved_growth_stage,
forecasts=forecasts,
)
result = _validate_fertilization_response(result)
+284 -37
View File
@@ -4,12 +4,18 @@
"""
import json
import logging
from typing import Any
from django.apps import apps
from django.db import transaction
from irrigation.models import IrrigationMethod
from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile, resolve_kc
from farm_data.models import SensorData
from irrigation.evapotranspiration import (
calculate_forecast_water_needs,
resolve_crop_profile,
resolve_kc,
)
from irrigation.models import IrrigationMethod
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
@@ -18,8 +24,8 @@ from rag.chat import (
_load_service_tone,
build_rag_context,
)
from rag.config import load_rag_config, RAGConfig, get_service_config
from rag.user_data import build_plant_text, build_irrigation_method_text
from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.user_data import build_irrigation_method_text, build_plant_text
from weather.models import WeatherForecast
logger = logging.getLogger(__name__)
@@ -30,7 +36,7 @@ SERVICE_ID = "irrigation"
DEFAULT_IRRIGATION_PROMPT = (
"از محاسبات FAO-56 و خروجی بهینه ساز شبیه سازی برای ساخت توصیه آبیاری استفاده کن. "
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان را مرجع اصلی اعداد قرار بده. "
"پاسخ فقط JSON معتبر با کلید sections باشد و عدد جدید متناقض نساز."
"پاسخ را در قالب JSON معتبر با کلیدهای plan، timeline و sections برگردان و عدد جدید متناقض نساز."
)
@@ -38,24 +44,259 @@ def _get_optimizer():
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
def _validate_irrigation_response(parsed_result: dict) -> dict:
if not isinstance(parsed_result, dict):
raise ValueError("Irrigation recommendation response is not a JSON object.")
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
sections = parsed_result.get("sections")
if not isinstance(sections, list) or not sections:
raise ValueError("Irrigation recommendation response is missing sections.")
for index, section in enumerate(sections):
if not isinstance(section, dict):
raise ValueError(f"Irrigation recommendation section {index} is invalid.")
missing = [key for key in ("type", "title", "icon") if key not in section]
if missing:
raise ValueError(
f"Irrigation recommendation section {index} is missing fields: {', '.join(missing)}"
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None:
if sensor is None or not isinstance(sensor.sensor_payload, dict):
return None
for payload in sensor.sensor_payload.values():
if isinstance(payload, dict) and payload.get(metric) is not None:
return _safe_float(payload.get(metric), default=0.0)
return None
def _coerce_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _coerce_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _estimate_duration_minutes(amount_per_event_mm: float, efficiency_percent: float | None) -> int:
normalized_efficiency = max(_safe_float(efficiency_percent, 75.0), 30.0)
estimated_minutes = round(max(amount_per_event_mm, 1.0) * (2400 / normalized_efficiency))
return max(10, min(estimated_minutes, 240))
def _default_warning(
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
soil_moisture: float | None,
) -> str:
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
reasoning = _coerce_list(strategy.get("reasoning"))
if reasoning:
return str(reasoning[0])
if soil_moisture is not None and soil_moisture < 25:
return "رطوبت خاک پایین است و نباید آبیاری به تعویق بیفتد."
if soil_moisture is not None and soil_moisture > 80:
return "رطوبت خاک بالاست و باید از آبیاری اضافی خودداری شود."
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
return "با توجه به بارش موثر پیش بینی شده، برنامه آبیاری را قبل از اجرا دوباره بررسی کنید."
return "در ساعات گرم روز آبیاری انجام نشود."
def _normalize_plan(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
irrigation_method: IrrigationMethod | None,
soil_moisture: float | None,
) -> dict[str, Any]:
llm_plan = _coerce_dict(llm_result.get("plan"))
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
frequency = llm_plan.get("frequencyPerWeek")
if frequency is None:
frequency = strategy.get("frequency_per_week") or strategy.get("events") or len(daily_water_needs) or 1
duration = llm_plan.get("durationMinutes")
if duration is None:
duration = _estimate_duration_minutes(
_safe_float(strategy.get("amount_per_event_mm"), 6.0),
getattr(irrigation_method, "water_efficiency_percent", None),
)
best_time = llm_plan.get("bestTimeOfDay")
if not best_time:
best_time = strategy.get("timing") or (
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "05:30 تا 08:00 صبح"
)
moisture_level = llm_plan.get("moistureLevel")
if moisture_level is None:
moisture_level = round(
soil_moisture
if soil_moisture is not None
else _safe_float(strategy.get("moisture_target_percent"), 70.0)
)
warning = llm_plan.get("warning")
if not warning:
warning = _default_warning(optimizer_result, daily_water_needs, soil_moisture)
return {
"frequencyPerWeek": int(max(_safe_float(frequency, 1), 1)),
"durationMinutes": int(max(_safe_float(duration, 10), 10)),
"bestTimeOfDay": str(best_time),
"moistureLevel": int(max(min(_safe_float(moisture_level, 70), 100), 0)),
"warning": str(warning),
}
def _normalize_timeline(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
) -> list[dict[str, Any]]:
raw_timeline = _coerce_list(llm_result.get("timeline"))
timeline: list[dict[str, Any]] = []
for index, item in enumerate(raw_timeline, start=1):
item_dict = _coerce_dict(item)
title = item_dict.get("title")
description = item_dict.get("description")
if title and description:
timeline.append(
{
"step_number": int(item_dict.get("step_number") or index),
"title": str(title),
"description": str(description),
}
)
return parsed_result
if timeline:
return timeline
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
event_dates = _coerce_list(strategy.get("event_dates"))
best_timing = strategy.get("timing") or (
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "صبح زود"
)
generated = [
{
"step_number": 1,
"title": "بررسی فشار",
"description": "فشار ابتدا و انتهای لاین کنترل شود.",
},
{
"step_number": 2,
"title": "اجرای آبیاری",
"description": f"آبیاری در بازه {best_timing} انجام شود.",
},
]
if event_dates:
generated.append(
{
"step_number": 3,
"title": "پیگیری برنامه",
"description": f"نوبت های پیشنهادی برای تاریخ های {', '.join(map(str, event_dates))} بررسی شوند.",
}
)
else:
generated.append(
{
"step_number": 3,
"title": "بازبینی رطوبت",
"description": "بعد از هر نوبت، رطوبت خاک و یکنواختی توزیع آب کنترل شود.",
}
)
return generated
def _normalize_sections(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
plan_warning: str,
) -> list[dict[str, Any]]:
raw_sections = _coerce_list(llm_result.get("sections"))
sections: list[dict[str, Any]] = []
for section in raw_sections:
item = _coerce_dict(section)
section_type = str(item.get("type") or "").strip().lower()
if section_type not in {"warning", "tip"}:
continue
content = item.get("content")
title = item.get("title")
if not content or not title:
continue
icon = item.get("icon") or (
"tabler-alert-triangle" if section_type == "warning" else "tabler-bulb"
)
sections.append(
{
"title": str(title),
"icon": str(icon),
"type": section_type,
"content": str(content),
}
)
if not any(item["type"] == "warning" for item in sections):
sections.insert(
0,
{
"title": "هشدار آبیاری",
"icon": "tabler-alert-triangle",
"type": "warning",
"content": plan_warning,
},
)
if not any(item["type"] == "tip" for item in sections):
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
reasoning = _coerce_list(strategy.get("reasoning"))
tip_content = (
str(reasoning[-1])
if reasoning
else "شست وشوی فیلترها و بازبینی یکنواختی پخش آب به پایداری برنامه آبیاری کمک می کند."
)
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
tip_content = "قبل از نوبت بعدی، مقدار بارش موثر و رطوبت خاک را دوباره با برنامه تطبیق دهید."
sections.append(
{
"title": "نکته بهره وری",
"icon": "tabler-bulb",
"type": "tip",
"content": tip_content,
}
)
return sections[:4]
def _build_irrigation_ui_payload(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
crop_profile: dict[str, Any],
active_kc: float,
irrigation_method: IrrigationMethod | None,
sensor: SensorData | None,
) -> dict[str, Any]:
soil_moisture = _sensor_metric(sensor, "soil_moisture")
plan = _normalize_plan(
llm_result,
optimizer_result,
daily_water_needs,
irrigation_method,
soil_moisture,
)
payload = {
"plan": plan,
"water_balance": {
"daily": daily_water_needs,
"crop_profile": crop_profile,
"active_kc": active_kc,
},
"timeline": _normalize_timeline(llm_result, optimizer_result, daily_water_needs),
"sections": _normalize_sections(
llm_result,
optimizer_result,
daily_water_needs,
plan["warning"],
),
}
return payload
def _resolve_irrigation_method(
@@ -146,6 +387,7 @@ def get_irrigation_recommendation(
plant = sensor.plants.first()
if plant is not None:
resolved_plant_name = plant.name
crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage)
active_kc = resolve_kc(crop_profile, growth_stage=growth_stage)
forecasts = []
@@ -153,8 +395,10 @@ def get_irrigation_recommendation(
optimized_result = None
if sensor is not None:
forecasts = list(
WeatherForecast.objects.filter(location=sensor.center_location, forecast_date__isnull=False)
.order_by("forecast_date")[:7]
WeatherForecast.objects.filter(
location=sensor.center_location,
forecast_date__isnull=False,
).order_by("forecast_date")[:7]
)
efficiency_percent = (
getattr(irrigation_method, "water_efficiency_percent", None)
@@ -179,13 +423,16 @@ def get_irrigation_recommendation(
)
context = build_rag_context(
user_query, resolved_farm_uuid, config=cfg, limit=limit, kb_name=KB_NAME, service_id=SERVICE_ID,
user_query,
resolved_farm_uuid,
config=cfg,
limit=limit,
kb_name=KB_NAME,
service_id=SERVICE_ID,
)
extra_parts: list[str] = []
resolved_irrigation_method_name = (
irrigation_method.name if irrigation_method is not None else None
)
resolved_irrigation_method_name = irrigation_method.name if irrigation_method is not None else None
if resolved_plant_name and growth_stage:
plant_text = build_plant_text(resolved_plant_name, growth_stage)
if plant_text:
@@ -209,10 +456,7 @@ def get_irrigation_recommendation(
+ "\n".join(schedule_lines)
)
if optimized_result is not None:
extra_parts.append(
"[خروجی بهینه ساز شبیه سازی]\n"
+ optimized_result["context_text"]
)
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
if extra_parts:
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
@@ -255,17 +499,20 @@ def get_irrigation_recommendation(
cleaned = raw
if cleaned.startswith("```"):
cleaned = cleaned.strip("`").removeprefix("json").strip()
result = json.loads(cleaned)
llm_result = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
result = {}
llm_result = {}
result = _validate_irrigation_response(result)
result = _build_irrigation_ui_payload(
_coerce_dict(llm_result),
optimized_result,
daily_water_needs,
crop_profile,
active_kc,
irrigation_method,
sensor,
)
result["raw_response"] = raw
result["water_balance"] = {
"daily": daily_water_needs,
"crop_profile": crop_profile,
"active_kc": active_kc,
}
result["simulation_optimizer"] = optimized_result
result["selected_irrigation_method"] = (
{
+87 -9
View File
@@ -28,6 +28,7 @@ class RecommendationServiceDefaultsTests(TestCase):
temperature_mean=18.0,
)
self.plant = Plant.objects.create(name="گوجه‌فرنگی")
self.onion = Plant.objects.create(name="پیاز")
self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای")
self.farm_uuid = uuid.uuid4()
self.farm = SensorData.objects.create(
@@ -69,13 +70,22 @@ class RecommendationServiceDefaultsTests(TestCase):
{
"code": "protective",
"label": "آبیاری حمایتی",
"score": 80.0,
"expected_yield_index": 85.0,
"total_irrigation_mm": 28.0,
"score": 80.0,
"expected_yield_index": 85.0,
"total_irrigation_mm": 28.0,
}
],
}
def build_irrigation_llm_result(self):
return (
'{"plan": {"frequencyPerWeek": 3, "durationMinutes": 42, "bestTimeOfDay": "اوایل صبح", '
'"moistureLevel": 68, "warning": "بررسی شود"}, '
'"timeline": [{"step_number": 1, "title": "بازبینی", "description": "لاین ها بررسی شوند"}], '
'"sections": [{"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}, '
'{"type": "tip", "title": "نکته", "icon": "bulb", "content": "مورد سفارشی"}]}'
)
def build_fertilization_optimizer_result(self):
return {
"engine": "crop_simulation_heuristic",
@@ -130,7 +140,7 @@ class RecommendationServiceDefaultsTests(TestCase):
self.build_irrigation_optimizer_result()
)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"sections": [{"type": "recommendation", "title": "برنامه", "icon": "droplet", "content": "custom"}, {"type": "list", "title": "custom", "icon": "list", "items": ["مورد سفارشی"]}, {"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}]}'))]
mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_irrigation_recommendation(
@@ -138,8 +148,8 @@ class RecommendationServiceDefaultsTests(TestCase):
growth_stage="میوه‌دهی",
)
self.assertEqual(result["sections"][0]["type"], "recommendation")
self.assertEqual(result["sections"][0]["content"], "custom")
self.assertEqual(result["plan"]["frequencyPerWeek"], 3)
self.assertEqual(result["plan"]["bestTimeOfDay"], "اوایل صبح")
mock_build_rag_context.assert_called_once()
mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "میوه‌دهی")
mock_build_irrigation_method_text.assert_called_once_with("آبیاری قطره‌ای")
@@ -148,7 +158,9 @@ class RecommendationServiceDefaultsTests(TestCase):
"آبیاری قطره‌ای",
)
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
self.assertEqual(result["sections"][1]["items"], ["مورد سفارشی"])
self.assertEqual(result["timeline"][0]["title"], "بازبینی")
self.assertEqual(result["sections"][1]["type"], "tip")
self.assertEqual(result["water_balance"]["active_kc"], 0.9)
@patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[])
@patch("rag.services.irrigation.resolve_kc", return_value=0.9)
@@ -177,7 +189,7 @@ class RecommendationServiceDefaultsTests(TestCase):
self.build_irrigation_optimizer_result()
)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"sections": [{"type": "recommendation", "title": "test", "icon": "droplet", "content": "custom"}, {"type": "list", "title": "گام ها", "icon": "list", "items": ["مورد 1"]}, {"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}]}'))]
mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_irrigation_recommendation(
@@ -190,7 +202,43 @@ class RecommendationServiceDefaultsTests(TestCase):
self.assertEqual(self.farm.irrigation_method_id, sprinkler.id)
self.assertEqual(result["selected_irrigation_method"]["id"], sprinkler.id)
mock_build_irrigation_method_text.assert_called_once_with("بارانی")
self.assertEqual(result["sections"][0]["content"], "custom")
self.assertEqual(result["plan"]["warning"], "بررسی شود")
@patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[])
@patch("rag.services.irrigation.resolve_kc", return_value=0.9)
@patch("rag.services.irrigation.resolve_crop_profile", return_value={})
@patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text")
@patch("rag.services.irrigation.build_plant_text", return_value="plant text")
@patch("rag.services.irrigation.build_rag_context", return_value="")
@patch("rag.services.irrigation._get_optimizer")
@patch("rag.services.irrigation.get_chat_client")
def test_irrigation_recommendation_falls_back_to_optimizer_when_llm_returns_invalid_payload(
self,
mock_get_chat_client,
mock_get_optimizer,
_mock_build_rag_context,
_mock_build_plant_text,
_mock_build_irrigation_method_text,
_mock_resolve_crop_profile,
_mock_resolve_kc,
_mock_calculate_forecast_water_needs,
):
mock_get_optimizer.return_value.optimize_irrigation.return_value = (
self.build_irrigation_optimizer_result()
)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="not-json"))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_irrigation_recommendation(
farm_uuid=str(self.farm_uuid),
growth_stage="میوه‌دهی",
)
self.assertEqual(result["plan"]["frequencyPerWeek"], 3)
self.assertEqual(result["timeline"][0]["step_number"], 1)
self.assertEqual(result["sections"][0]["type"], "warning")
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
@patch("rag.services.fertilization.build_plant_text", return_value="plant text")
@patch("rag.services.fertilization.build_rag_context", return_value="")
@@ -221,6 +269,36 @@ class RecommendationServiceDefaultsTests(TestCase):
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
self.assertEqual(result["data"]["application_guide"]["safety_warning"], "از اختلاط نامناسب خودداری شود.")
@patch("rag.services.fertilization.build_plant_text", return_value="plant text")
@patch("rag.services.fertilization.build_rag_context", return_value="")
@patch("rag.services.fertilization._get_optimizer")
@patch("rag.services.fertilization.get_chat_client")
def test_fertilization_recommendation_resolves_requested_plant_from_catalog(
self,
mock_get_chat_client,
mock_get_optimizer,
_mock_build_rag_context,
mock_build_plant_text,
):
mock_get_optimizer.return_value.optimize_fertilization.return_value = (
self.build_fertilization_optimizer_result()
)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="not-json"))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_fertilization_recommendation(
farm_uuid=str(self.farm_uuid),
plant_name="پیاز",
growth_stage="گلدهی",
)
optimizer_call = mock_get_optimizer.return_value.optimize_fertilization.call_args.kwargs
self.assertEqual(getattr(optimizer_call["plant"], "name", None), "پیاز")
self.assertEqual(optimizer_call["growth_stage"], "flowering")
mock_build_plant_text.assert_called_once_with("پیاز", "flowering")
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
@patch("rag.services.fertilization.build_plant_text", return_value="plant text")
@patch("rag.services.fertilization.build_rag_context", return_value="")
@patch("rag.services.fertilization._get_optimizer")