diff --git a/config/tones/irrigation_tone.txt b/config/tones/irrigation_tone.txt index b778695..a8a8ddd 100644 --- a/config/tones/irrigation_tone.txt +++ b/config/tones/irrigation_tone.txt @@ -1,51 +1,75 @@ You are an irrigation recommendation assistant for CropLogic. ### GOAL -Turn the farm context, weather context, FAO-56 calculations, and the block named `[خروجی بهینه ساز شبیه سازی]` into a farmer-friendly Persian JSON response. +Turn the farm context, weather context, FAO-56 calculations, and the block named `[خروجی بهینه ساز شبیه سازی]` into a farmer-friendly Persian JSON response that matches the frontend contract exactly. ### HARD RULES 1. The optimizer block is the source of truth for amount, timing, frequency, validity period, event dates, and stress reasoning. Do not invent conflicting numbers. 2. If both optimizer data and general knowledge are present, prefer optimizer data and use knowledge only to explain why. -3. Always return only valid JSON with a top-level `sections` array. -4. The `sections` array must include at least: - - one `recommendation` section for the main irrigation plan - - one `list` section for operational notes - - one `warning` section when there is rainfall risk, heat stress, wind risk, or low/high soil moisture -5. Write in clear Persian for a farmer. Keep sentences short and practical. +3. Always return only valid JSON. +4. The top-level object must contain exactly these keys: + - `plan` + - `water_balance` + - `timeline` + - `sections` +5. Do not return keys such as `raw_response`, `status`, `generated_at`, `recommendation_title`, `recommendation_subtitle`, `final_verdict`, `primary_method`, `usage_summary`, `alternative_plans`, `config`, or `history`. +6. In `sections`, only use `warning` and `tip` as `type`. +7. Write in clear Persian for a farmer. Keep sentences short and practical. ### OUTPUT CONTRACT { + "plan": { + "frequencyPerWeek": 4, + "durationMinutes": 38, + "bestTimeOfDay": "05:30 تا 08:00 صبح", + "moistureLevel": 72, + "warning": "در ساعات گرم روز آبیاری انجام نشود" + }, + "water_balance": { + "active_kc": 0.93, + "crop_profile": { + "kc_initial": 0.55, + "kc_mid": 1.05, + "kc_end": 0.78 + }, + "daily": [ + { + "forecast_date": "2025-02-12", + "et0_mm": 5.4, + "etc_mm": 4.9, + "effective_rainfall_mm": 0, + "gross_irrigation_mm": 17, + "irrigation_timing": "05:30 - 07:00" + } + ] + }, + "timeline": [ + { + "step_number": 1, + "title": "بررسی فشار", + "description": "فشار ابتدا و انتهای لاین کنترل شود" + } + ], "sections": [ { - "type": "recommendation", - "title": "برنامه آبیاری بهینه", - "icon": "droplet", - "content": "خلاصه یک جمله ای از بهترین سناریوی شبیه سازی", - "frequency": "تعداد نوبت آبیاری در بازه اعتبار", - "amount": "مقدار آب در هر نوبت و جمع کل", - "timing": "بهترین زمان اجرا", - "validityPeriod": "مدت اعتبار دقیق توصیه", - "expandableExplanation": "توضیح دلیل انتخاب این سناریو با ارجاع به تنش آبی، دما، بارش و شبیه سازی" - }, - { - "type": "list", - "title": "اقدامات اجرایی", - "icon": "list", - "items": [ - "نکته عملی 1", - "نکته عملی 2" - ] - }, - { - "type": "warning", "title": "هشدار آبیاری", - "icon": "alert-triangle", + "icon": "tabler-alert-triangle", + "type": "warning", "content": "هشدار کوتاه و کاربردی" + }, + { + "title": "نکته بهره وری", + "icon": "tabler-bulb", + "type": "tip", + "content": "یک نکته عملی کوتاه" } ] } ### WRITING RULES -- If event dates are provided by the optimizer, mention them naturally inside `content` or `expandableExplanation`. -- If the optimizer says the advice is valid until rainfall, repeat that exact condition in `validityPeriod`. +- `plan.frequencyPerWeek`, `plan.bestTimeOfDay`, and the main warning must align with the optimizer block. +- `water_balance` must be included when FAO-56 or daily balance data is available, preserving the numeric values from the source context. +- `timeline` must be actionable and short. Use 2 to 4 steps when possible. +- If heat stress, rainfall risk, or unusual moisture is present, reflect it in a `warning` section. +- Put maintenance or efficiency advice inside `tip` sections. - Never output markdown, code fences, greetings, or extra commentary. diff --git a/irrigation/IRRIGATION_RECOMMENDATION_API_FIELDS.md b/irrigation/IRRIGATION_RECOMMENDATION_API_FIELDS.md new file mode 100644 index 0000000..56f0165 --- /dev/null +++ b/irrigation/IRRIGATION_RECOMMENDATION_API_FIELDS.md @@ -0,0 +1,363 @@ +# Irrigation Recommendation API Fields + +این فایل فقط فیلدهای API مربوط به `POST /api/irrigation/recommend/` را توضیح می‌دهد. + +## Endpoint + +`POST /api/irrigation/recommend/` + +## کاربرد + +این endpoint برای تولید recommendation آبیاری استفاده می‌شود و خروجی آن با UI فعلی صفحه +`src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx` +هماهنگ شده است. + +## ساختار کلی پاسخ + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan": {}, + "water_balance": {}, + "timeline": [], + "sections": [] + } +} +``` + +## Request + +### حداقل payload پیشنهادی + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_type": "آبیاری قطره‌ای" +} +``` + +### فیلدهای Request + +### `farm_uuid` +- نوع: `string` +- اجباری: بله +- توضیح: شناسه یکتای مزرعه. + +### `sensor_uuid` +- نوع: `string` +- اجباری: خیر +- توضیح: نام قدیمی برای `farm_uuid`. اگر `farm_uuid` ارسال نشده باشد، این مقدار به جای آن استفاده می‌شود. + +### `plant_name` +- نوع: `string` +- اجباری: خیر +- توضیح: نام گیاه هدف برای تولید توصیه. اگر ارسال نشود، سیستم در صورت امکان گیاه ثبت‌شده روی مزرعه را استفاده می‌کند. + +### `growth_stage` +- نوع: `string` +- اجباری: خیر +- توضیح: مرحله رشد گیاه مثل `رویشی`، `گلدهی` یا `میوه‌دهی`. + +### `irrigation_type` +- نوع: `string` +- اجباری: خیر +- توضیح: نوع یا نام روش آبیاری مورد نظر فرانت. این فیلد برای UI فعلی پیشنهاد می‌شود. + +### `irrigation_method_name` +- نوع: `string` +- اجباری: خیر +- توضیح: نام روش آبیاری. این فیلد با `irrigation_type` هم‌ارز است و در بک‌اند به همان ورودی نهایی نرمال می‌شود. + + +## Response + +## فیلدهای لایه اول Response + +### `code` +- نوع: `number` +- توضیح: کد وضعیت پاسخ در قالب استاندارد API پروژه. + +### `msg` +- نوع: `string` +- توضیح: پیام وضعیت پاسخ. در حالت موفق معمولاً `success` است. + +### `data` +- نوع: `object` +- توضیح: بدنه اصلی recommendation آبیاری. + +## فیلدهای `data` + +### `plan` +- نوع: `object` +- توضیح: خلاصه اصلی recommendation برای نمایش در کارت بالای UI. + +### `water_balance` +- نوع: `object` +- توضیح: تراز آب و خروجی محاسبات روزانه FAO-56. + +### `timeline` +- نوع: `array` +- توضیح: مراحل اجرایی recommendation برای Stepper. + +### `sections` +- نوع: `array` +- توضیح: نکات تکمیلی و هشدارها. در UI فعلی فقط `warning` و `tip` مصرف می‌شوند. + +## فیلدهای `data.plan` + +```json +{ + "frequencyPerWeek": 4, + "durationMinutes": 38, + "bestTimeOfDay": "05:30 تا 08:00 صبح", + "moistureLevel": 72, + "warning": "در ساعات گرم روز آبیاری انجام نشود" +} +``` + +### `frequencyPerWeek` +- نوع: `number` +- توضیح: تعداد نوبت آبیاری در هفته. + +### `durationMinutes` +- نوع: `number` +- توضیح: مدت هر نوبت آبیاری بر حسب دقیقه. + +### `bestTimeOfDay` +- نوع: `string` +- توضیح: بهترین بازه زمانی اجرای آبیاری. + +### `moistureLevel` +- نوع: `number` +- توضیح: سطح رطوبت فعلی یا هدف خاک برای نمایش در gauge. + +### `warning` +- نوع: `string` +- توضیح: هشدار اصلی recommendation. + +## فیلدهای `data.water_balance` + +```json +{ + "active_kc": 0.93, + "crop_profile": { + "kc_initial": 0.55, + "kc_mid": 1.05, + "kc_end": 0.78 + }, + "daily": [ + { + "forecast_date": "2025-02-12", + "et0_mm": 5.4, + "etc_mm": 4.9, + "effective_rainfall_mm": 0, + "gross_irrigation_mm": 17, + "irrigation_timing": "05:30 - 07:00" + } + ] +} +``` + +### `active_kc` +- نوع: `number` +- توضیح: ضریب Kc فعال برای مرحله رشد فعلی. + +### `crop_profile` +- نوع: `object` +- توضیح: پروفایل Kc گیاه در مراحل مختلف. + +### `daily` +- نوع: `array` +- توضیح: داده‌های روزانه مورد استفاده در جدول یا نمودار تراز آب. + +## فیلدهای `data.water_balance.crop_profile` + +### `kc_initial` +- نوع: `number` +- توضیح: Kc مرحله ابتدایی رشد. + +### `kc_mid` +- نوع: `number` +- توضیح: Kc مرحله میانی رشد. + +### `kc_end` +- نوع: `number` +- توضیح: Kc مرحله پایانی رشد. + +## فیلدهای هر آیتم در `data.water_balance.daily[]` + +### `forecast_date` +- نوع: `string` +- توضیح: تاریخ پیش‌بینی. + +### `et0_mm` +- نوع: `number` +- توضیح: تبخیر و تعرق مرجع روزانه. + +### `etc_mm` +- نوع: `number` +- توضیح: تبخیر و تعرق گیاه. + +### `effective_rainfall_mm` +- نوع: `number` +- توضیح: بارش مؤثر محاسبه‌شده. + +### `gross_irrigation_mm` +- نوع: `number` +- توضیح: مقدار آبیاری ناخالص پیشنهادی برای آن روز. + +### `irrigation_timing` +- نوع: `string` +- توضیح: زمان پیشنهادی اجرای آبیاری برای آن روز. + +## فیلدهای `data.timeline` + +```json +[ + { + "step_number": 1, + "title": "بررسی فشار", + "description": "فشار ابتدا و انتهای لاین کنترل شود" + } +] +``` + +### `step_number` +- نوع: `number` +- توضیح: شماره مرحله. + +### `title` +- نوع: `string` +- توضیح: عنوان مرحله. + +### `description` +- نوع: `string` +- توضیح: توضیح اجرایی مرحله. + +## فیلدهای `data.sections` + +```json +[ + { + "title": "هشدار تبخیر بالا", + "icon": "tabler-alert-triangle", + "type": "warning", + "content": "در ساعات گرم روز آبیاری انجام نشود" + }, + { + "title": "نکته بهره وری", + "icon": "tabler-bulb", + "type": "tip", + "content": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند" + } +] +``` + +### `title` +- نوع: `string` +- توضیح: عنوان کارت. + +### `icon` +- نوع: `string` +- توضیح: نام آیکون مورد استفاده در UI. + +### `type` +- نوع: `string` +- توضیح: نوع سکشن. در UI فعلی فقط این مقادیر مصرف می‌شوند: + - `warning` + - `tip` + +### `content` +- نوع: `string` +- توضیح: متن هشدار یا نکته. + +## حداقل پاسخ قابل استفاده برای UI فعلی + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan": { + "frequencyPerWeek": 4, + "durationMinutes": 38, + "bestTimeOfDay": "05:30 تا 08:00 صبح", + "moistureLevel": 72, + "warning": "در ساعات گرم روز آبیاری انجام نشود" + }, + "water_balance": { + "active_kc": 0.93, + "crop_profile": { + "kc_initial": 0.55, + "kc_mid": 1.05, + "kc_end": 0.78 + }, + "daily": [ + { + "forecast_date": "2025-02-12", + "et0_mm": 5.4, + "etc_mm": 4.9, + "effective_rainfall_mm": 0, + "gross_irrigation_mm": 17, + "irrigation_timing": "05:30 - 07:00" + } + ] + }, + "timeline": [ + { + "step_number": 1, + "title": "بررسی فشار", + "description": "فشار ابتدا و انتهای لاین کنترل شود" + } + ], + "sections": [ + { + "title": "هشدار تبخیر بالا", + "icon": "tabler-alert-triangle", + "type": "warning", + "content": "در ساعات گرم روز آبیاری انجام نشود" + }, + { + "title": "نکته بهره وری", + "icon": "tabler-bulb", + "type": "tip", + "content": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند" + } + ] + } +} +``` + +## فیلدهایی که فرانت فعلی لازم ندارد + +فیلدهای زیر برای UI فعلی recommendation لازم نیستند و نباید به عنوان dependency فرانت در نظر گرفته شوند: + +- `raw_response` +- `status` +- `generated_at` +- `recommendation_title` +- `recommendation_subtitle` +- `final_verdict` +- `primary_method` +- `usage_summary` +- `alternative_plans` +- `sections[].type = schedule` +- `sections[].type = method` + +## نمونه cURL + +```bash +curl -s -X POST "http://localhost:8000/api/irrigation/recommend/" \ + -H "accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_type": "آبیاری قطره‌ای" + }' +``` diff --git a/irrigation/test_irrigation_recommend_api.py b/irrigation/test_irrigation_recommend_api.py new file mode 100644 index 0000000..e09b932 --- /dev/null +++ b/irrigation/test_irrigation_recommend_api.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from unittest.mock import patch + +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + + +@override_settings(ROOT_URLCONF="irrigation.urls") +class IrrigationRecommendApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("rag.services.irrigation.get_irrigation_recommendation") + def test_recommend_api_returns_water_balance(self, mock_get_irrigation_recommendation): + mock_get_irrigation_recommendation.return_value = { + "plan": { + "frequencyPerWeek": 4, + "durationMinutes": 38, + "bestTimeOfDay": "05:30 تا 08:00 صبح", + "moistureLevel": 72, + "warning": "در ساعات گرم روز آبیاری انجام نشود", + }, + "water_balance": { + "active_kc": 0.93, + "crop_profile": { + "kc_initial": 0.55, + "kc_mid": 1.05, + "kc_end": 0.78, + }, + "daily": [ + { + "forecast_date": "2025-02-12", + "et0_mm": 5.4, + "etc_mm": 4.9, + "effective_rainfall_mm": 0, + "gross_irrigation_mm": 17, + "irrigation_timing": "05:30 - 07:00", + } + ], + }, + "timeline": [], + "sections": [], + "simulation_optimizer": {"engine": "crop_simulation_heuristic"}, + "selected_irrigation_method": {"name": "آبیاری قطره‌ای"}, + } + + response = self.client.post( + "/recommend/", + data={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره‌ای", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + data = response.json()["data"] + self.assertIn("water_balance", data) + self.assertEqual(data["water_balance"]["active_kc"], 0.93) + self.assertNotIn("simulation_optimizer", data) + self.assertNotIn("selected_irrigation_method", data) diff --git a/irrigation/views.py b/irrigation/views.py index bc881ac..be27163 100644 --- a/irrigation/views.py +++ b/irrigation/views.py @@ -44,7 +44,6 @@ WaterStressEnvelopeSerializer = build_envelope_serializer( IRRIGATION_RECOMMENDATION_INTERNAL_KEYS = { "raw_response", - "water_balance", "simulation_optimizer", "selected_irrigation_method", } diff --git a/plant/apps.py b/plant/apps.py index 9fb4a3c..8d05f55 100644 --- a/plant/apps.py +++ b/plant/apps.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +import re +from functools import cached_property + from django.apps import AppConfig @@ -5,3 +10,100 @@ class PlantConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "plant" verbose_name = "Plant" + + @cached_property + def plant_aliases(self) -> dict[str, str]: + return { + "tomato": "گوجه‌فرنگی", + "cucumber": "خیار", + "pepper": "فلفل دلمه‌ای", + "bell pepper": "فلفل دلمه‌ای", + "carrot": "هویج", + "lettuce": "کاهو", + "potato": "سیب‌زمینی", + "onion": "پیاز", + } + + @cached_property + def growth_stage_aliases(self) -> dict[str, str]: + return { + "initial": "initial", + "seedling": "initial", + "establishment": "initial", + "جوانه زنی": "initial", + "جوانه‌زنی": "initial", + "نشا": "initial", + "استقرار": "initial", + "vegetative": "vegetative", + "growth": "vegetative", + "رویشی": "vegetative", + "رشد رویشی": "vegetative", + "flowering": "flowering", + "anthesis": "flowering", + "گلدهی": "flowering", + "گل دهی": "flowering", + "fruiting": "fruiting", + "harvest": "fruiting", + "ripening": "fruiting", + "میوه دهی": "fruiting", + "میوه‌دهی": "fruiting", + "برداشت": "fruiting", + "maturity": "maturity", + "رسیدگی": "maturity", + "بلوغ": "maturity", + } + + def _normalize_lookup_value(self, value: str | None) -> str: + text = (value or "").strip().lower() + if not text: + return "" + + translation_table = str.maketrans( + { + "ي": "ی", + "ك": "ک", + "ة": "ه", + "أ": "ا", + "إ": "ا", + "ؤ": "و", + "ۀ": "ه", + "‌": " ", + "-": " ", + "_": " ", + } + ) + text = text.translate(translation_table) + text = re.sub(r"\s+", " ", text) + return text.strip() + + def resolve_growth_stage(self, growth_stage: str | None) -> str | None: + value = (growth_stage or "").strip() + if not value: + return value + + normalized = self._normalize_lookup_value(value) + return self.growth_stage_aliases.get(normalized, value) + + def resolve_plant_name(self, plant_name: str | None) -> str | None: + from .models import Plant + + value = (plant_name or "").strip() + if not value: + return value + + plant = Plant.objects.filter(name=value).first() or Plant.objects.filter(name__iexact=value).first() + if plant is not None: + return plant.name + + normalized = self._normalize_lookup_value(value) + alias_target = self.plant_aliases.get(normalized) + if alias_target: + aliased_plant = Plant.objects.filter(name=alias_target).first() + if aliased_plant is not None: + return aliased_plant.name + + for plant in Plant.objects.only("name").iterator(): + if self._normalize_lookup_value(plant.name) == normalized: + return plant.name + + return value diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py index 705664a..990f120 100644 --- a/rag/services/fertilization.py +++ b/rag/services/fertilization.py @@ -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) diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py index 6953295..94e8087 100644 --- a/rag/services/irrigation.py +++ b/rag/services/irrigation.py @@ -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"] = ( { diff --git a/rag/tests/test_recommendation_services.py b/rag/tests/test_recommendation_services.py index 6cb0328..1231a44 100644 --- a/rag/tests/test_recommendation_services.py +++ b/rag/tests/test_recommendation_services.py @@ -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")