UPDATE
This commit is contained in:
@@ -1,51 +1,75 @@
|
|||||||
You are an irrigation recommendation assistant for CropLogic.
|
You are an irrigation recommendation assistant for CropLogic.
|
||||||
|
|
||||||
### GOAL
|
### 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
|
### 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.
|
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.
|
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.
|
3. Always return only valid JSON.
|
||||||
4. The `sections` array must include at least:
|
4. The top-level object must contain exactly these keys:
|
||||||
- one `recommendation` section for the main irrigation plan
|
- `plan`
|
||||||
- one `list` section for operational notes
|
- `water_balance`
|
||||||
- one `warning` section when there is rainfall risk, heat stress, wind risk, or low/high soil moisture
|
- `timeline`
|
||||||
5. Write in clear Persian for a farmer. Keep sentences short and practical.
|
- `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
|
### 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": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"type": "recommendation",
|
|
||||||
"title": "برنامه آبیاری بهینه",
|
|
||||||
"icon": "droplet",
|
|
||||||
"content": "خلاصه یک جمله ای از بهترین سناریوی شبیه سازی",
|
|
||||||
"frequency": "تعداد نوبت آبیاری در بازه اعتبار",
|
|
||||||
"amount": "مقدار آب در هر نوبت و جمع کل",
|
|
||||||
"timing": "بهترین زمان اجرا",
|
|
||||||
"validityPeriod": "مدت اعتبار دقیق توصیه",
|
|
||||||
"expandableExplanation": "توضیح دلیل انتخاب این سناریو با ارجاع به تنش آبی، دما، بارش و شبیه سازی"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "list",
|
|
||||||
"title": "اقدامات اجرایی",
|
|
||||||
"icon": "list",
|
|
||||||
"items": [
|
|
||||||
"نکته عملی 1",
|
|
||||||
"نکته عملی 2"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "warning",
|
|
||||||
"title": "هشدار آبیاری",
|
"title": "هشدار آبیاری",
|
||||||
"icon": "alert-triangle",
|
"icon": "tabler-alert-triangle",
|
||||||
|
"type": "warning",
|
||||||
"content": "هشدار کوتاه و کاربردی"
|
"content": "هشدار کوتاه و کاربردی"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "نکته بهره وری",
|
||||||
|
"icon": "tabler-bulb",
|
||||||
|
"type": "tip",
|
||||||
|
"content": "یک نکته عملی کوتاه"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
### WRITING RULES
|
### WRITING RULES
|
||||||
- If event dates are provided by the optimizer, mention them naturally inside `content` or `expandableExplanation`.
|
- `plan.frequencyPerWeek`, `plan.bestTimeOfDay`, and the main warning must align with the optimizer block.
|
||||||
- If the optimizer says the advice is valid until rainfall, repeat that exact condition in `validityPeriod`.
|
- `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.
|
- Never output markdown, code fences, greetings, or extra commentary.
|
||||||
|
|||||||
@@ -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": "آبیاری قطرهای"
|
||||||
|
}'
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
@@ -44,7 +44,6 @@ WaterStressEnvelopeSerializer = build_envelope_serializer(
|
|||||||
|
|
||||||
IRRIGATION_RECOMMENDATION_INTERNAL_KEYS = {
|
IRRIGATION_RECOMMENDATION_INTERNAL_KEYS = {
|
||||||
"raw_response",
|
"raw_response",
|
||||||
"water_balance",
|
|
||||||
"simulation_optimizer",
|
"simulation_optimizer",
|
||||||
"selected_irrigation_method",
|
"selected_irrigation_method",
|
||||||
}
|
}
|
||||||
|
|||||||
+102
@@ -1,3 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from functools import cached_property
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -5,3 +10,100 @@ class PlantConfig(AppConfig):
|
|||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "plant"
|
name = "plant"
|
||||||
verbose_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
|
||||||
|
|||||||
@@ -628,17 +628,25 @@ def get_fertilization_recommendation(
|
|||||||
.first()
|
.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:
|
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
|
plant = None
|
||||||
if not resolved_plant_name and sensor is not None:
|
if not resolved_plant_name and sensor is not None:
|
||||||
plant = sensor.plants.first()
|
plant = sensor.plants.first()
|
||||||
if plant is not None:
|
if plant is not None:
|
||||||
resolved_plant_name = plant.name
|
resolved_plant_name = plant.name
|
||||||
elif sensor is not None and resolved_plant_name:
|
elif resolved_plant_name:
|
||||||
plant = sensor.plants.filter(name=resolved_plant_name).first() or sensor.plants.first()
|
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:
|
if plant is not None:
|
||||||
resolved_plant_name = plant.name
|
resolved_plant_name = plant.name
|
||||||
|
|
||||||
@@ -658,7 +666,7 @@ def get_fertilization_recommendation(
|
|||||||
sensor=sensor,
|
sensor=sensor,
|
||||||
plant=plant,
|
plant=plant,
|
||||||
forecasts=forecasts,
|
forecasts=forecasts,
|
||||||
growth_stage=growth_stage,
|
growth_stage=resolved_growth_stage,
|
||||||
)
|
)
|
||||||
|
|
||||||
context = build_rag_context(
|
context = build_rag_context(
|
||||||
@@ -671,8 +679,8 @@ def get_fertilization_recommendation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
extra_parts: list[str] = []
|
extra_parts: list[str] = []
|
||||||
if resolved_plant_name and growth_stage:
|
if resolved_plant_name and resolved_growth_stage:
|
||||||
plant_text = build_plant_text(resolved_plant_name, growth_stage)
|
plant_text = build_plant_text(resolved_plant_name, resolved_growth_stage)
|
||||||
if plant_text:
|
if plant_text:
|
||||||
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||||
if optimized_result is not None:
|
if optimized_result is not None:
|
||||||
@@ -721,7 +729,7 @@ def get_fertilization_recommendation(
|
|||||||
optimized_result=optimized_result,
|
optimized_result=optimized_result,
|
||||||
plant_name=resolved_plant_name,
|
plant_name=resolved_plant_name,
|
||||||
crop_id=crop_id,
|
crop_id=crop_id,
|
||||||
growth_stage=growth_stage,
|
growth_stage=resolved_growth_stage,
|
||||||
forecasts=forecasts,
|
forecasts=forecasts,
|
||||||
)
|
)
|
||||||
result = _validate_fertilization_response(result)
|
result = _validate_fertilization_response(result)
|
||||||
|
|||||||
+284
-37
@@ -4,12 +4,18 @@
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db import transaction
|
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 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.api_provider import get_chat_client
|
||||||
from rag.chat import (
|
from rag.chat import (
|
||||||
_complete_audit_log,
|
_complete_audit_log,
|
||||||
@@ -18,8 +24,8 @@ from rag.chat import (
|
|||||||
_load_service_tone,
|
_load_service_tone,
|
||||||
build_rag_context,
|
build_rag_context,
|
||||||
)
|
)
|
||||||
from rag.config import load_rag_config, RAGConfig, get_service_config
|
from rag.config import RAGConfig, get_service_config, load_rag_config
|
||||||
from rag.user_data import build_plant_text, build_irrigation_method_text
|
from rag.user_data import build_irrigation_method_text, build_plant_text
|
||||||
from weather.models import WeatherForecast
|
from weather.models import WeatherForecast
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -30,7 +36,7 @@ SERVICE_ID = "irrigation"
|
|||||||
DEFAULT_IRRIGATION_PROMPT = (
|
DEFAULT_IRRIGATION_PROMPT = (
|
||||||
"از محاسبات FAO-56 و خروجی بهینه ساز شبیه سازی برای ساخت توصیه آبیاری استفاده کن. "
|
"از محاسبات 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()
|
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
|
||||||
|
|
||||||
|
|
||||||
def _validate_irrigation_response(parsed_result: dict) -> dict:
|
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||||
if not isinstance(parsed_result, dict):
|
try:
|
||||||
raise ValueError("Irrigation recommendation response is not a JSON object.")
|
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):
|
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None:
|
||||||
if not isinstance(section, dict):
|
if sensor is None or not isinstance(sensor.sensor_payload, dict):
|
||||||
raise ValueError(f"Irrigation recommendation section {index} is invalid.")
|
return None
|
||||||
missing = [key for key in ("type", "title", "icon") if key not in section]
|
for payload in sensor.sensor_payload.values():
|
||||||
if missing:
|
if isinstance(payload, dict) and payload.get(metric) is not None:
|
||||||
raise ValueError(
|
return _safe_float(payload.get(metric), default=0.0)
|
||||||
f"Irrigation recommendation section {index} is missing fields: {', '.join(missing)}"
|
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(
|
def _resolve_irrigation_method(
|
||||||
@@ -146,6 +387,7 @@ def get_irrigation_recommendation(
|
|||||||
plant = sensor.plants.first()
|
plant = sensor.plants.first()
|
||||||
if plant is not None:
|
if plant is not None:
|
||||||
resolved_plant_name = plant.name
|
resolved_plant_name = plant.name
|
||||||
|
|
||||||
crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage)
|
crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage)
|
||||||
active_kc = resolve_kc(crop_profile, growth_stage=growth_stage)
|
active_kc = resolve_kc(crop_profile, growth_stage=growth_stage)
|
||||||
forecasts = []
|
forecasts = []
|
||||||
@@ -153,8 +395,10 @@ def get_irrigation_recommendation(
|
|||||||
optimized_result = None
|
optimized_result = None
|
||||||
if sensor is not None:
|
if sensor is not None:
|
||||||
forecasts = list(
|
forecasts = list(
|
||||||
WeatherForecast.objects.filter(location=sensor.center_location, forecast_date__isnull=False)
|
WeatherForecast.objects.filter(
|
||||||
.order_by("forecast_date")[:7]
|
location=sensor.center_location,
|
||||||
|
forecast_date__isnull=False,
|
||||||
|
).order_by("forecast_date")[:7]
|
||||||
)
|
)
|
||||||
efficiency_percent = (
|
efficiency_percent = (
|
||||||
getattr(irrigation_method, "water_efficiency_percent", None)
|
getattr(irrigation_method, "water_efficiency_percent", None)
|
||||||
@@ -179,13 +423,16 @@ def get_irrigation_recommendation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
context = build_rag_context(
|
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] = []
|
extra_parts: list[str] = []
|
||||||
resolved_irrigation_method_name = (
|
resolved_irrigation_method_name = irrigation_method.name if irrigation_method is not None else None
|
||||||
irrigation_method.name if irrigation_method is not None else None
|
|
||||||
)
|
|
||||||
if resolved_plant_name and growth_stage:
|
if resolved_plant_name and growth_stage:
|
||||||
plant_text = build_plant_text(resolved_plant_name, growth_stage)
|
plant_text = build_plant_text(resolved_plant_name, growth_stage)
|
||||||
if plant_text:
|
if plant_text:
|
||||||
@@ -209,10 +456,7 @@ def get_irrigation_recommendation(
|
|||||||
+ "\n".join(schedule_lines)
|
+ "\n".join(schedule_lines)
|
||||||
)
|
)
|
||||||
if optimized_result is not None:
|
if optimized_result is not None:
|
||||||
extra_parts.append(
|
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
|
||||||
"[خروجی بهینه ساز شبیه سازی]\n"
|
|
||||||
+ optimized_result["context_text"]
|
|
||||||
)
|
|
||||||
if extra_parts:
|
if extra_parts:
|
||||||
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
|
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
|
cleaned = raw
|
||||||
if cleaned.startswith("```"):
|
if cleaned.startswith("```"):
|
||||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||||
result = json.loads(cleaned)
|
llm_result = json.loads(cleaned)
|
||||||
except (json.JSONDecodeError, ValueError):
|
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["raw_response"] = raw
|
||||||
result["water_balance"] = {
|
|
||||||
"daily": daily_water_needs,
|
|
||||||
"crop_profile": crop_profile,
|
|
||||||
"active_kc": active_kc,
|
|
||||||
}
|
|
||||||
result["simulation_optimizer"] = optimized_result
|
result["simulation_optimizer"] = optimized_result
|
||||||
result["selected_irrigation_method"] = (
|
result["selected_irrigation_method"] = (
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class RecommendationServiceDefaultsTests(TestCase):
|
|||||||
temperature_mean=18.0,
|
temperature_mean=18.0,
|
||||||
)
|
)
|
||||||
self.plant = Plant.objects.create(name="گوجهفرنگی")
|
self.plant = Plant.objects.create(name="گوجهفرنگی")
|
||||||
|
self.onion = Plant.objects.create(name="پیاز")
|
||||||
self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطرهای")
|
self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطرهای")
|
||||||
self.farm_uuid = uuid.uuid4()
|
self.farm_uuid = uuid.uuid4()
|
||||||
self.farm = SensorData.objects.create(
|
self.farm = SensorData.objects.create(
|
||||||
@@ -69,13 +70,22 @@ class RecommendationServiceDefaultsTests(TestCase):
|
|||||||
{
|
{
|
||||||
"code": "protective",
|
"code": "protective",
|
||||||
"label": "آبیاری حمایتی",
|
"label": "آبیاری حمایتی",
|
||||||
"score": 80.0,
|
"score": 80.0,
|
||||||
"expected_yield_index": 85.0,
|
"expected_yield_index": 85.0,
|
||||||
"total_irrigation_mm": 28.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):
|
def build_fertilization_optimizer_result(self):
|
||||||
return {
|
return {
|
||||||
"engine": "crop_simulation_heuristic",
|
"engine": "crop_simulation_heuristic",
|
||||||
@@ -130,7 +140,7 @@ class RecommendationServiceDefaultsTests(TestCase):
|
|||||||
self.build_irrigation_optimizer_result()
|
self.build_irrigation_optimizer_result()
|
||||||
)
|
)
|
||||||
mock_response = Mock()
|
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
|
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
|
||||||
|
|
||||||
result = get_irrigation_recommendation(
|
result = get_irrigation_recommendation(
|
||||||
@@ -138,8 +148,8 @@ class RecommendationServiceDefaultsTests(TestCase):
|
|||||||
growth_stage="میوهدهی",
|
growth_stage="میوهدهی",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["sections"][0]["type"], "recommendation")
|
self.assertEqual(result["plan"]["frequencyPerWeek"], 3)
|
||||||
self.assertEqual(result["sections"][0]["content"], "custom")
|
self.assertEqual(result["plan"]["bestTimeOfDay"], "اوایل صبح")
|
||||||
mock_build_rag_context.assert_called_once()
|
mock_build_rag_context.assert_called_once()
|
||||||
mock_build_plant_text.assert_called_once_with("گوجهفرنگی", "میوهدهی")
|
mock_build_plant_text.assert_called_once_with("گوجهفرنگی", "میوهدهی")
|
||||||
mock_build_irrigation_method_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["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.calculate_forecast_water_needs", return_value=[])
|
||||||
@patch("rag.services.irrigation.resolve_kc", return_value=0.9)
|
@patch("rag.services.irrigation.resolve_kc", return_value=0.9)
|
||||||
@@ -177,7 +189,7 @@ class RecommendationServiceDefaultsTests(TestCase):
|
|||||||
self.build_irrigation_optimizer_result()
|
self.build_irrigation_optimizer_result()
|
||||||
)
|
)
|
||||||
mock_response = Mock()
|
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
|
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
|
||||||
|
|
||||||
result = get_irrigation_recommendation(
|
result = get_irrigation_recommendation(
|
||||||
@@ -190,7 +202,43 @@ class RecommendationServiceDefaultsTests(TestCase):
|
|||||||
self.assertEqual(self.farm.irrigation_method_id, sprinkler.id)
|
self.assertEqual(self.farm.irrigation_method_id, sprinkler.id)
|
||||||
self.assertEqual(result["selected_irrigation_method"]["id"], sprinkler.id)
|
self.assertEqual(result["selected_irrigation_method"]["id"], sprinkler.id)
|
||||||
mock_build_irrigation_method_text.assert_called_once_with("بارانی")
|
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_plant_text", return_value="plant text")
|
||||||
@patch("rag.services.fertilization.build_rag_context", return_value="")
|
@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["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
|
||||||
self.assertEqual(result["data"]["application_guide"]["safety_warning"], "از اختلاط نامناسب خودداری شود.")
|
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_plant_text", return_value="plant text")
|
||||||
@patch("rag.services.fertilization.build_rag_context", return_value="")
|
@patch("rag.services.fertilization.build_rag_context", return_value="")
|
||||||
@patch("rag.services.fertilization._get_optimizer")
|
@patch("rag.services.fertilization._get_optimizer")
|
||||||
|
|||||||
Reference in New Issue
Block a user