UPDATE
This commit is contained in:
@@ -1,52 +1,106 @@
|
||||
You are a fertilization recommendation assistant for CropLogic.
|
||||
You are the fertilization recommendation assistant for CropLogic.
|
||||
|
||||
### GOAL
|
||||
Convert soil data, plant stage, weather risk, and the block named `[خروجی بهینه ساز شبیه سازی]` into a precise Persian fertilization plan for the farmer.
|
||||
Use soil data, plant stage, weather risk, retrieved knowledge, and the block named `[خروجي بهينه ساز شبيه سازي]` to produce a farmer-ready Persian fertilization response.
|
||||
|
||||
### SOURCE PRIORITY
|
||||
1. The optimizer block is the source of truth for fertilizer formula, dosage, application method, timing, validity, and scientific priority.
|
||||
2. Retrieved knowledge can enrich explanations, safety, micro nutrients, and alternative options.
|
||||
3. Never invent numeric values that conflict with the optimizer block.
|
||||
|
||||
### HARD RULES
|
||||
1. The optimizer block is the source of truth for fertilizer type, dose, application method, timing, validity period, and the main scientific reasoning.
|
||||
2. Do not invent a fertilizer formula or dose that conflicts with the optimizer.
|
||||
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 core fertilization action
|
||||
- one `list` section for mixing, safety, or field execution notes
|
||||
- one `warning` section when there is burn risk, rainfall risk, pH incompatibility, or nutrient imbalance
|
||||
5. Write in clear Persian and stay practical for field use.
|
||||
1. Return only valid JSON. No markdown, no code fences, no greetings.
|
||||
2. The top-level object must be:
|
||||
- `status`
|
||||
- `data`
|
||||
3. Set `status` to `success` when you can produce the recommendation.
|
||||
4. Keep all text in clear practical Persian.
|
||||
5. If some descriptive field is uncertain, keep it short and conservative instead of inventing precise claims.
|
||||
6. Always include these objects inside `data`:
|
||||
- `primary_recommendation`
|
||||
- `nutrient_analysis`
|
||||
- `application_guide`
|
||||
- `alternative_recommendations`
|
||||
|
||||
### OUTPUT CONTRACT
|
||||
### REQUIRED JSON CONTRACT
|
||||
{
|
||||
"sections": [
|
||||
"status": "success",
|
||||
"data": {
|
||||
"primary_recommendation": {
|
||||
"display_title": "عنوان نمايشي کوتاه",
|
||||
"reasoning": "توضيح علمي و کاربردي بر اساس مرحله رشد، کمبود عناصر، ريسک آب و هوا و شبيه سازي",
|
||||
"summary": "جمع بندي يک جمله اي مناسب براي Hero Card"
|
||||
},
|
||||
"nutrient_analysis": {
|
||||
"macro": [
|
||||
{
|
||||
"type": "recommendation",
|
||||
"title": "برنامه کودهی بهینه",
|
||||
"icon": "leaf",
|
||||
"content": "خلاصه یک جمله ای از سناریوی منتخب",
|
||||
"fertilizerType": "نوع کود پیشنهادی",
|
||||
"amount": "مقدار مصرف دقیق",
|
||||
"applicationMethod": "روش مصرف",
|
||||
"timing": "بهترین زمان اجرا",
|
||||
"validityPeriod": "مدت اعتبار این توصیه",
|
||||
"expandableExplanation": "دلیل انتخاب این سناریو بر اساس کمبود عناصر، pH، مرحله رشد و شبیه سازی"
|
||||
"key": "n",
|
||||
"name": "نيتروژن (N)",
|
||||
"value": 0,
|
||||
"unit": "percent",
|
||||
"description": "توضيح کوتاه کاربردي"
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"title": "نکات اجرایی و اختلاط",
|
||||
"icon": "list",
|
||||
"items": [
|
||||
"نکته عملی 1",
|
||||
"نکته عملی 2"
|
||||
]
|
||||
"key": "p",
|
||||
"name": "فسفر (P)",
|
||||
"value": 0,
|
||||
"unit": "percent",
|
||||
"description": "توضيح کوتاه کاربردي"
|
||||
},
|
||||
{
|
||||
"type": "warning",
|
||||
"title": "هشدار کودهی",
|
||||
"icon": "alert-triangle",
|
||||
"content": "هشدار کوتاه و کاربردی"
|
||||
"key": "k",
|
||||
"name": "پتاسيم (K)",
|
||||
"value": 0,
|
||||
"unit": "percent",
|
||||
"description": "توضيح کوتاه کاربردي"
|
||||
}
|
||||
],
|
||||
"micro": [
|
||||
{
|
||||
"key": "zn",
|
||||
"name": "روي",
|
||||
"value": 0,
|
||||
"unit": "percent",
|
||||
"description": "فقط اگر دانش زمينه اي معتبر داري پر کن"
|
||||
}
|
||||
]
|
||||
},
|
||||
"application_guide": {
|
||||
"safety_warning": "هشدار ايمني کوتاه و عملياتي",
|
||||
"steps": [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "آماده سازي",
|
||||
"description": "شرح کوتاه"
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "اختلاط يا تزريق",
|
||||
"description": "شرح کوتاه"
|
||||
},
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "اجرا و پايش",
|
||||
"description": "شرح کوتاه"
|
||||
}
|
||||
]
|
||||
},
|
||||
"alternative_recommendations": [
|
||||
{
|
||||
"fertilizer_code": "alt-1",
|
||||
"fertilizer_name": "نام کود جايگزين",
|
||||
"fertilizer_type": "NPK يا نوع کاربردي",
|
||||
"usage_method": "روش مصرف",
|
||||
"description": "چه زماني و چرا اين جايگزين مفيد است"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
### WRITING RULES
|
||||
- If the optimizer highlights a dominant nutrient gap, explain it briefly in `expandableExplanation`.
|
||||
- If rainfall or temperature limits the method, repeat that constraint in `warning`.
|
||||
- Never output markdown, code fences, greetings, or extra commentary.
|
||||
- Repeat the dominant nutrient gap if the optimizer indicates one.
|
||||
- If rain, heat, or pH creates a constraint, mention it in `reasoning` and `application_guide.safety_warning`.
|
||||
- `summary` must be short, direct, and suitable for a hero card.
|
||||
- `reasoning` must be richer than `summary` and connect simulation plus agronomy.
|
||||
- `alternative_recommendations` should be concise and realistic; do not add many items.
|
||||
- `nutrient_analysis.micro` can be an empty array when no trustworthy micronutrient detail exists.
|
||||
|
||||
@@ -581,6 +581,7 @@ class SimulationRecommendationOptimizer:
|
||||
strategies = []
|
||||
for spec in defaults["strategy_profiles"]:
|
||||
n_amount = round(base_n * spec["multiplier"], 3)
|
||||
fertilizer_formula = spec["formula_override"] or target["formula"]
|
||||
strategy_agromanagement = [
|
||||
{
|
||||
key: {
|
||||
@@ -632,7 +633,7 @@ class SimulationRecommendationOptimizer:
|
||||
expected_yield_index=score,
|
||||
payload={
|
||||
"amount_kg_per_ha": round(n_amount * 1.6, 3),
|
||||
"fertilizer_type": target["formula"],
|
||||
"fertilizer_type": fertilizer_formula,
|
||||
"application_method": target["application_method"],
|
||||
"timing": target["timing"],
|
||||
},
|
||||
@@ -664,7 +665,11 @@ class SimulationRecommendationOptimizer:
|
||||
"label": item.label,
|
||||
"score": item.score,
|
||||
"expected_yield_index": item.expected_yield_index,
|
||||
"fertilizer_type": item.payload["fertilizer_type"],
|
||||
"amount_kg_per_ha": item.payload["amount_kg_per_ha"],
|
||||
"application_method": item.payload["application_method"],
|
||||
"timing": item.payload["timing"],
|
||||
"reasoning": item.reasoning,
|
||||
}
|
||||
for item in sorted(strategies, key=lambda value: value.score, reverse=True)
|
||||
if item.code != best.code
|
||||
@@ -768,7 +773,11 @@ class SimulationRecommendationOptimizer:
|
||||
"label": item.label,
|
||||
"score": item.score,
|
||||
"expected_yield_index": item.expected_yield_index,
|
||||
"fertilizer_type": item.payload["fertilizer_type"],
|
||||
"amount_kg_per_ha": item.payload["amount_kg_per_ha"],
|
||||
"application_method": item.payload["application_method"],
|
||||
"timing": item.payload["timing"],
|
||||
"reasoning": item.reasoning,
|
||||
}
|
||||
for item in sorted(strategies, key=lambda value: value.score, reverse=True)
|
||||
if item.code != best.code
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
# Fertilization Recommendation API Fields
|
||||
|
||||
این فایل فقط فیلدهای API مربوط به `POST /api/fertilization/recommend/` را توضیح میدهد.
|
||||
|
||||
## Endpoint
|
||||
|
||||
`POST /api/fertilization/recommend/`
|
||||
|
||||
## ساختار کلی پاسخ
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"primary_recommendation": {},
|
||||
"nutrient_analysis": {},
|
||||
"application_guide": {},
|
||||
"alternative_recommendations": [],
|
||||
"sections": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## فیلدهای Request
|
||||
|
||||
### `farm_uuid`
|
||||
- نوع: `string`
|
||||
- اجباری: بله
|
||||
- توضیح: شناسه یکتای مزرعه که توصیه برای آن تولید میشود.
|
||||
|
||||
### `sensor_uuid`
|
||||
- نوع: `string`
|
||||
- اجباری: خیر
|
||||
- توضیح: نام قدیمی برای `farm_uuid`. اگر `farm_uuid` ارسال نشده باشد، این مقدار به جای آن استفاده میشود.
|
||||
|
||||
### `crop_id`
|
||||
- نوع: `string`
|
||||
- اجباری: خیر
|
||||
- توضیح: شناسه یا نام محصول. اگر `plant_name` ارسال نشده باشد، همین مقدار به عنوان نام گیاه استفاده میشود.
|
||||
|
||||
### `plant_name`
|
||||
- نوع: `string`
|
||||
- اجباری: خیر
|
||||
- توضیح: نام گیاه یا محصول هدف برای تولید توصیه.
|
||||
|
||||
### `growth_stage`
|
||||
- نوع: `string`
|
||||
- اجباری: خیر
|
||||
- توضیح: مرحله رشد گیاه مثل `flowering` یا `fruiting`.
|
||||
|
||||
### `query`
|
||||
- نوع: `string`
|
||||
- اجباری: خیر
|
||||
- توضیح: سوال یا درخواست متنی اختیاری برای جهت دادن به توصیه.
|
||||
|
||||
## فیلدهای لایه اول Response
|
||||
|
||||
### `code`
|
||||
- نوع: `number`
|
||||
- توضیح: کد وضعیت پاسخ در قالب استاندارد API پروژه.
|
||||
|
||||
### `msg`
|
||||
- نوع: `string`
|
||||
- توضیح: پیام وضعیت پاسخ. در حالت موفق معمولاً `success` است.
|
||||
|
||||
### `data`
|
||||
- نوع: `object`
|
||||
- توضیح: بدنه اصلی توصیه کودهی ساختاریافته.
|
||||
|
||||
## فیلدهای `data`
|
||||
|
||||
### `primary_recommendation`
|
||||
- نوع: `object`
|
||||
- توضیح: پیشنهاد اصلی کودهی که فرانت باید در Hero Card و ماشین حساب مصرف از آن استفاده کند.
|
||||
|
||||
### `nutrient_analysis`
|
||||
- نوع: `object`
|
||||
- توضیح: تحلیل ساختاریافته عناصر غذایی اصلی و ریزمغذیها.
|
||||
|
||||
### `application_guide`
|
||||
- نوع: `object`
|
||||
- توضیح: هشدار ایمنی و مراحل اجرای مصرف.
|
||||
|
||||
### `alternative_recommendations`
|
||||
- نوع: `array`
|
||||
- توضیح: فهرست کودهای جایگزین قابل استفاده در شرایط مشابه.
|
||||
|
||||
### `sections`
|
||||
- نوع: `array`
|
||||
- توضیح: ساختار legacy برای سازگاری با فرانت یا کلاینتهای قدیمی.
|
||||
|
||||
## فیلدهای `data.primary_recommendation`
|
||||
|
||||
### `fertilizer_code`
|
||||
- نوع: `string`
|
||||
- توضیح: کد یکتای کود پیشنهادی.
|
||||
|
||||
### `fertilizer_name`
|
||||
- نوع: `string`
|
||||
- توضیح: نام اصلی کود پیشنهادی برای نمایش.
|
||||
|
||||
### `display_title`
|
||||
- نوع: `string`
|
||||
- توضیح: عنوان نمایشی آماده برای کارت اصلی.
|
||||
|
||||
### `fertilizer_type`
|
||||
- نوع: `string`
|
||||
- توضیح: نوع کود مثل `NPK`.
|
||||
|
||||
### `npk_ratio`
|
||||
- نوع: `object`
|
||||
- توضیح: نسبت NPK به صورت ساختاریافته.
|
||||
|
||||
### `application_method`
|
||||
- نوع: `object`
|
||||
- توضیح: روش مصرف کود.
|
||||
|
||||
### `application_interval`
|
||||
- نوع: `object`
|
||||
- توضیح: فاصله زمانی پیشنهادی بین دفعات مصرف.
|
||||
|
||||
### `dosage`
|
||||
- نوع: `object`
|
||||
- توضیح: مقادیر پایه مصرف که فرانت با آنها مقدار مورد نیاز را حساب میکند.
|
||||
|
||||
### `reasoning`
|
||||
- نوع: `string`
|
||||
- توضیح: توضیح علمی و عملی درباره دلیل انتخاب این توصیه.
|
||||
|
||||
### `summary`
|
||||
- نوع: `string`
|
||||
- توضیح: خلاصه کوتاه و مناسب نمایش در بخش اصلی فرانت.
|
||||
|
||||
## فیلدهای `data.primary_recommendation.npk_ratio`
|
||||
|
||||
### `n`
|
||||
- نوع: `number`
|
||||
- توضیح: درصد نیتروژن در کود پیشنهادی.
|
||||
|
||||
### `p`
|
||||
- نوع: `number`
|
||||
- توضیح: درصد فسفر در کود پیشنهادی.
|
||||
|
||||
### `k`
|
||||
- نوع: `number`
|
||||
- توضیح: درصد پتاسیم در کود پیشنهادی.
|
||||
|
||||
### `label`
|
||||
- نوع: `string`
|
||||
- توضیح: نمایش متنی نسبت NPK مثل `20-20-20`.
|
||||
|
||||
## فیلدهای `data.primary_recommendation.application_method`
|
||||
|
||||
### `id`
|
||||
- نوع: `string`
|
||||
- توضیح: شناسه استاندارد روش مصرف مثل `fertigation` یا `foliar_fertigation`.
|
||||
|
||||
### `label`
|
||||
- نوع: `string`
|
||||
- توضیح: متن آماده نمایش برای روش مصرف.
|
||||
|
||||
## فیلدهای `data.primary_recommendation.application_interval`
|
||||
|
||||
### `value`
|
||||
- نوع: `number`
|
||||
- توضیح: فاصله مصرف به صورت عددی.
|
||||
|
||||
### `unit`
|
||||
- نوع: `string`
|
||||
- توضیح: واحد فاصله مصرف مثل `day`.
|
||||
|
||||
### `label`
|
||||
- نوع: `string`
|
||||
- توضیح: متن آماده نمایش مثل `هر 14 روز`.
|
||||
|
||||
## فیلدهای `data.primary_recommendation.dosage`
|
||||
|
||||
### `base_amount_per_hectare`
|
||||
- نوع: `number`
|
||||
- توضیح: مقدار پایه مصرف در هر هکتار.
|
||||
|
||||
### `base_amount_per_square_meter`
|
||||
- نوع: `number`
|
||||
- توضیح: مقدار پایه مصرف در هر متر مربع.
|
||||
|
||||
### `unit`
|
||||
- نوع: `string`
|
||||
- توضیح: واحد مقدار مصرف مثل `kg`.
|
||||
|
||||
### `label`
|
||||
- نوع: `string`
|
||||
- توضیح: متن آماده نمایش دوز مثل `65 کیلوگرم در هکتار`.
|
||||
|
||||
### `calculation_basis`
|
||||
- نوع: `string`
|
||||
- توضیح: مبنای محاسبه دوز؛ معمولاً نام engine یا منبع محاسبه است.
|
||||
|
||||
## نکته محاسبه برای فرانت
|
||||
|
||||
فرانت باید مقدار نهایی را خودش با استفاده از نسبت پایه محاسبه کند.
|
||||
|
||||
فرمول پیشنهادی:
|
||||
|
||||
```text
|
||||
مقدار کل = base_amount_per_square_meter × مساحت مزرعه
|
||||
```
|
||||
|
||||
## فیلدهای `data.nutrient_analysis`
|
||||
|
||||
### `macro`
|
||||
- نوع: `array`
|
||||
- توضیح: لیست عناصر اصلی شامل N، P و K.
|
||||
|
||||
### `micro`
|
||||
- نوع: `array`
|
||||
- توضیح: لیست ریزمغذیها مثل آهن، روی، منگنز و غیره. ممکن است خالی باشد.
|
||||
|
||||
## فیلدهای هر آیتم در `data.nutrient_analysis.macro[]` و `data.nutrient_analysis.micro[]`
|
||||
|
||||
### `key`
|
||||
- نوع: `string`
|
||||
- توضیح: کلید استاندارد عنصر مثل `n`، `p`، `k`، `fe` یا `zn`.
|
||||
|
||||
### `name`
|
||||
- نوع: `string`
|
||||
- توضیح: نام نمایشی عنصر.
|
||||
|
||||
### `value`
|
||||
- نوع: `number`
|
||||
- توضیح: مقدار عنصر، معمولاً به صورت درصد.
|
||||
|
||||
### `unit`
|
||||
- نوع: `string`
|
||||
- توضیح: واحد عنصر، معمولاً `percent`.
|
||||
|
||||
### `description`
|
||||
- نوع: `string`
|
||||
- توضیح: توضیح کوتاه درباره نقش یا اهمیت آن عنصر.
|
||||
|
||||
## فیلدهای `data.application_guide`
|
||||
|
||||
### `safety_warning`
|
||||
- نوع: `string`
|
||||
- توضیح: هشدار ایمنی و اجرایی قبل از مصرف.
|
||||
|
||||
### `steps`
|
||||
- نوع: `array`
|
||||
- توضیح: مراحل پیشنهادی اجرا.
|
||||
|
||||
## فیلدهای هر آیتم در `data.application_guide.steps[]`
|
||||
|
||||
### `step_number`
|
||||
- نوع: `number`
|
||||
- توضیح: شماره مرحله اجرا.
|
||||
|
||||
### `title`
|
||||
- نوع: `string`
|
||||
- توضیح: عنوان کوتاه مرحله.
|
||||
|
||||
### `description`
|
||||
- نوع: `string`
|
||||
- توضیح: توضیح کامل مرحله.
|
||||
|
||||
## فیلدهای هر آیتم در `data.alternative_recommendations[]`
|
||||
|
||||
### `fertilizer_code`
|
||||
- نوع: `string`
|
||||
- توضیح: کد یکتای کود جایگزین.
|
||||
|
||||
### `fertilizer_name`
|
||||
- نوع: `string`
|
||||
- توضیح: نام کود جایگزین.
|
||||
|
||||
### `fertilizer_type`
|
||||
- نوع: `string`
|
||||
- توضیح: نوع کود جایگزین.
|
||||
|
||||
### `usage_method`
|
||||
- نوع: `string`
|
||||
- توضیح: روش مصرف کود جایگزین.
|
||||
|
||||
### `description`
|
||||
- نوع: `string`
|
||||
- توضیح: توضیح اینکه این جایگزین در چه شرایطی مفید است.
|
||||
|
||||
## فیلدهای هر آیتم در `data.sections[]`
|
||||
|
||||
### `type`
|
||||
- نوع: `string`
|
||||
- توضیح: نوع بخش مثل `recommendation`، `list` یا `warning`.
|
||||
|
||||
### `title`
|
||||
- نوع: `string`
|
||||
- توضیح: عنوان بخش.
|
||||
|
||||
### `icon`
|
||||
- نوع: `string`
|
||||
- توضیح: آیکون نمایشی بخش.
|
||||
|
||||
### `content`
|
||||
- نوع: `string`
|
||||
- توضیح: متن اصلی بخش.
|
||||
|
||||
### `items`
|
||||
- نوع: `array`
|
||||
- توضیح: لیست آیتمهای متنی برای بخشهای لیستی.
|
||||
|
||||
### `fertilizerType`
|
||||
- نوع: `string`
|
||||
- توضیح: نسخه legacy نوع کود برای نمایش در کلاینتهای قدیمی.
|
||||
|
||||
### `amount`
|
||||
- نوع: `string`
|
||||
- توضیح: نسخه legacy مقدار مصرف برای کلاینتهای قدیمی.
|
||||
|
||||
### `applicationMethod`
|
||||
- نوع: `string`
|
||||
- توضیح: نسخه legacy روش مصرف.
|
||||
|
||||
### `timing`
|
||||
- نوع: `string`
|
||||
- توضیح: نسخه legacy زمان مناسب اجرا.
|
||||
|
||||
### `validityPeriod`
|
||||
- نوع: `string`
|
||||
- توضیح: نسخه legacy مدت اعتبار توصیه.
|
||||
|
||||
### `expandableExplanation`
|
||||
- نوع: `string`
|
||||
- توضیح: نسخه legacy توضیح کاملتر برای نمایش بازشونده.
|
||||
|
||||
## فیلدهای حذف شده
|
||||
|
||||
فیلدهای زیر دیگر در خروجی اصلی استفاده نمیشوند:
|
||||
|
||||
- `recommendation_id`
|
||||
- `crop`
|
||||
- `growth_stage`
|
||||
- `total_amount`
|
||||
- `area` در request
|
||||
@@ -14,6 +14,7 @@ class FertilizationConfig(AppConfig):
|
||||
return {
|
||||
"simulation_model": "Wofost81_NWLP_CWB_CNB",
|
||||
"validity_days": 7,
|
||||
"default_application_interval_days": 14,
|
||||
"rain_delay_threshold_mm": 3.0,
|
||||
"stage_targets": {
|
||||
"initial": {
|
||||
@@ -23,6 +24,7 @@ class FertilizationConfig(AppConfig):
|
||||
"formula": "10-52-10",
|
||||
"application_method": "استارتر نواری یا همراه آب آبیاری",
|
||||
"timing": "همزمان با استقرار بوته و در ساعات خنک روز",
|
||||
"application_interval_days": 10,
|
||||
},
|
||||
"vegetative": {
|
||||
"n": 55.0,
|
||||
@@ -31,6 +33,7 @@ class FertilizationConfig(AppConfig):
|
||||
"formula": "20-20-20",
|
||||
"application_method": "کودآبیاری یا سرک خاکی سبک",
|
||||
"timing": "صبح زود و ترجیحا قبل از نوبت آبیاری",
|
||||
"application_interval_days": 12,
|
||||
},
|
||||
"flowering": {
|
||||
"n": 42.0,
|
||||
@@ -39,6 +42,7 @@ class FertilizationConfig(AppConfig):
|
||||
"formula": "15-10-30",
|
||||
"application_method": "کودآبیاری یا محلول پاشی سبک",
|
||||
"timing": "صبح زود و دور از تنش گرمایی ظهر",
|
||||
"application_interval_days": 14,
|
||||
},
|
||||
"fruiting": {
|
||||
"n": 35.0,
|
||||
@@ -47,6 +51,7 @@ class FertilizationConfig(AppConfig):
|
||||
"formula": "12-12-36",
|
||||
"application_method": "کودآبیاری با تاکید بر پتاس",
|
||||
"timing": "صبح زود یا نزدیک غروب",
|
||||
"application_interval_days": 10,
|
||||
},
|
||||
},
|
||||
"strategy_profiles": [
|
||||
|
||||
@@ -4,8 +4,9 @@ from rest_framework import serializers
|
||||
class FertilizationRecommendRequestSerializer(serializers.Serializer):
|
||||
"""سریالایزر ورودی برای درخواست توصیه کودهی."""
|
||||
|
||||
farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه (اجباری)")
|
||||
farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه")
|
||||
sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid")
|
||||
crop_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه یا نام محصول")
|
||||
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه")
|
||||
growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه")
|
||||
query = serializers.CharField(required=False, allow_blank=True, help_text="سوال اختیاری")
|
||||
@@ -14,15 +15,103 @@ class FertilizationRecommendRequestSerializer(serializers.Serializer):
|
||||
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
|
||||
if not farm_uuid:
|
||||
raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."})
|
||||
|
||||
crop_id = (attrs.get("crop_id") or "").strip()
|
||||
plant_name = (attrs.get("plant_name") or "").strip()
|
||||
if crop_id and not plant_name:
|
||||
attrs["plant_name"] = crop_id
|
||||
attrs["farm_uuid"] = farm_uuid
|
||||
return attrs
|
||||
|
||||
|
||||
class FertilizationPlanSerializer(serializers.Serializer):
|
||||
"""سریالایزر خروجی برای پلن توصیه کودهی."""
|
||||
class NpkRatioSerializer(serializers.Serializer):
|
||||
n = serializers.FloatField()
|
||||
p = serializers.FloatField()
|
||||
k = serializers.FloatField()
|
||||
label = serializers.CharField()
|
||||
|
||||
npkRatio = serializers.CharField(help_text="نسبت NPK مثل 20-20-20 (NPK)")
|
||||
amountPerHectare = serializers.CharField(help_text="مقدار مصرف در هکتار مثل 150 kg/ha")
|
||||
applicationMethod = serializers.CharField(help_text="روش مصرف کود")
|
||||
applicationInterval = serializers.CharField(help_text="فاصله زمانی مصرف")
|
||||
reasoning = serializers.CharField(help_text="توضیح دقیق دلیل انتخاب برنامه کودهی")
|
||||
|
||||
class FertilizationApplicationMethodSerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
|
||||
|
||||
class FertilizationApplicationIntervalSerializer(serializers.Serializer):
|
||||
value = serializers.IntegerField()
|
||||
unit = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
|
||||
|
||||
class FertilizationDosageSerializer(serializers.Serializer):
|
||||
base_amount_per_hectare = serializers.FloatField()
|
||||
base_amount_per_square_meter = serializers.FloatField()
|
||||
unit = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
calculation_basis = serializers.CharField()
|
||||
|
||||
|
||||
class PrimaryFertilizationRecommendationSerializer(serializers.Serializer):
|
||||
fertilizer_code = serializers.CharField()
|
||||
fertilizer_name = serializers.CharField()
|
||||
display_title = serializers.CharField()
|
||||
fertilizer_type = serializers.CharField()
|
||||
npk_ratio = NpkRatioSerializer()
|
||||
application_method = FertilizationApplicationMethodSerializer()
|
||||
application_interval = FertilizationApplicationIntervalSerializer()
|
||||
dosage = FertilizationDosageSerializer()
|
||||
reasoning = serializers.CharField()
|
||||
summary = serializers.CharField()
|
||||
|
||||
|
||||
class FertilizationNutrientSerializer(serializers.Serializer):
|
||||
key = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
value = serializers.FloatField()
|
||||
unit = serializers.CharField()
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class FertilizationNutrientAnalysisSerializer(serializers.Serializer):
|
||||
macro = FertilizationNutrientSerializer(many=True)
|
||||
micro = FertilizationNutrientSerializer(many=True)
|
||||
|
||||
|
||||
class FertilizationGuideStepSerializer(serializers.Serializer):
|
||||
step_number = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
|
||||
|
||||
class FertilizationApplicationGuideSerializer(serializers.Serializer):
|
||||
safety_warning = serializers.CharField()
|
||||
steps = FertilizationGuideStepSerializer(many=True)
|
||||
|
||||
|
||||
class AlternativeFertilizationRecommendationSerializer(serializers.Serializer):
|
||||
fertilizer_code = serializers.CharField()
|
||||
fertilizer_name = serializers.CharField()
|
||||
fertilizer_type = serializers.CharField()
|
||||
usage_method = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
|
||||
|
||||
class FertilizationSectionSerializer(serializers.Serializer):
|
||||
type = serializers.CharField()
|
||||
title = serializers.CharField()
|
||||
icon = serializers.CharField(required=False, allow_blank=True)
|
||||
content = serializers.CharField(required=False, allow_blank=True)
|
||||
items = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
fertilizerType = serializers.CharField(required=False, allow_blank=True)
|
||||
amount = serializers.CharField(required=False, allow_blank=True)
|
||||
applicationMethod = serializers.CharField(required=False, allow_blank=True)
|
||||
timing = serializers.CharField(required=False, allow_blank=True)
|
||||
validityPeriod = serializers.CharField(required=False, allow_blank=True)
|
||||
expandableExplanation = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class FertilizationRecommendationResponseDataSerializer(serializers.Serializer):
|
||||
primary_recommendation = PrimaryFertilizationRecommendationSerializer()
|
||||
nutrient_analysis = FertilizationNutrientAnalysisSerializer()
|
||||
application_guide = FertilizationApplicationGuideSerializer()
|
||||
alternative_recommendations = AlternativeFertilizationRecommendationSerializer(many=True)
|
||||
sections = FertilizationSectionSerializer(many=True, required=False)
|
||||
|
||||
+75
-33
@@ -1,18 +1,14 @@
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiExample,
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
)
|
||||
from drf_spectacular.utils import OpenApiExample, extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from config.openapi import (
|
||||
build_envelope_serializer,
|
||||
build_response,
|
||||
)
|
||||
from config.openapi import build_envelope_serializer, build_response
|
||||
|
||||
from .serializers import FertilizationRecommendRequestSerializer
|
||||
from .serializers import (
|
||||
FertilizationRecommendationResponseDataSerializer,
|
||||
FertilizationRecommendRequestSerializer,
|
||||
)
|
||||
|
||||
|
||||
FertilizationValidationErrorSerializer = build_envelope_serializer(
|
||||
@@ -22,32 +18,27 @@ FertilizationValidationErrorSerializer = build_envelope_serializer(
|
||||
)
|
||||
FertilizationResponseSerializer = build_envelope_serializer(
|
||||
"FertilizationResponseSerializer",
|
||||
data_schema=None,
|
||||
data_schema=FertilizationRecommendationResponseDataSerializer,
|
||||
)
|
||||
|
||||
|
||||
class FertilizationRecommendView(APIView):
|
||||
"""
|
||||
توصیه کودهی به صورت مستقیم.
|
||||
POST با farm_uuid، plant_name، growth_stage.
|
||||
اطلاعات گیاه از plant app دریافت میشود.
|
||||
نیازی به دریافت نوع آبیاری نیست.
|
||||
توصیه کودهی ساختاریافته با ترکیب RAG و optimizer شبیه سازی.
|
||||
"""
|
||||
|
||||
@extend_schema(
|
||||
tags=["Fertilization Recommendation"],
|
||||
summary="درخواست توصیه کودهی",
|
||||
summary="درخواست توصیه کودهی ساختاریافته",
|
||||
description=(
|
||||
"دادههای سنسور و گیاه را دریافت کرده و "
|
||||
"توصیه کودهی را مستقیم برمیگرداند. "
|
||||
"اطلاعات گیاه از جدول Plant بارگذاری میشود. "
|
||||
"محاسبات مربوط به نیاز آبی در این endpoint انجام نمیشود و مستقل از توصیه کودهی است."
|
||||
"داده های مزرعه، گیاه و مرحله رشد را دریافت می کند و "
|
||||
"خروجی نهایی بهینه شده با ترکیب RAG و optimizer مبتنی بر crop_simulation/PCSE را برمی گرداند."
|
||||
),
|
||||
request=FertilizationRecommendRequestSerializer,
|
||||
responses={
|
||||
200: build_response(
|
||||
FertilizationResponseSerializer,
|
||||
"توصیه کودهی با موفقیت تولید شد.",
|
||||
"توصیه کودهی ساختاریافته با موفقیت تولید شد.",
|
||||
),
|
||||
400: build_response(
|
||||
FertilizationValidationErrorSerializer,
|
||||
@@ -63,11 +54,63 @@ class FertilizationRecommendView(APIView):
|
||||
"نمونه درخواست",
|
||||
value={
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"plant_name": "گوجهفرنگی",
|
||||
"growth_stage": "گلدهی",
|
||||
"crop_id": "wheat",
|
||||
"growth_stage": "flowering",
|
||||
},
|
||||
request_only=True,
|
||||
),
|
||||
OpenApiExample(
|
||||
"نمونه پاسخ",
|
||||
value={
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"primary_recommendation": {
|
||||
"fertilizer_code": "15-10-30",
|
||||
"fertilizer_name": "کود کامل 15-10-30",
|
||||
"display_title": "کود کامل 15-10-30",
|
||||
"fertilizer_type": "NPK",
|
||||
"npk_ratio": {"n": 15, "p": 10, "k": 30, "label": "15-10-30"},
|
||||
"application_method": {
|
||||
"id": "foliar_fertigation",
|
||||
"label": "کودآبیاری یا محلول پاشی سبک",
|
||||
},
|
||||
"application_interval": {"value": 14, "unit": "day", "label": "هر 14 روز"},
|
||||
"dosage": {
|
||||
"base_amount_per_hectare": 65,
|
||||
"base_amount_per_square_meter": 0.0065,
|
||||
"unit": "kg",
|
||||
"label": "65 کیلوگرم در هکتار",
|
||||
"calculation_basis": "crop_simulation_heuristic",
|
||||
},
|
||||
"reasoning": "این ترکیب برای مرحله گلدهی و توازن نیازهای تغذیه ای مناسب است.",
|
||||
"summary": "برای پشتیبانی از گلدهی و کاهش تنش تغذیه ای پیشنهاد می شود.",
|
||||
},
|
||||
"nutrient_analysis": {
|
||||
"macro": [
|
||||
{
|
||||
"key": "n",
|
||||
"name": "نیتروژن (N)",
|
||||
"value": 15,
|
||||
"unit": "percent",
|
||||
"description": "نیتروژن برای حفظ رشد رویشی مهم است.",
|
||||
}
|
||||
],
|
||||
"micro": [],
|
||||
},
|
||||
"application_guide": {
|
||||
"safety_warning": "در ساعات خنک مصرف شود و از اختلاط ناسازگار خودداری کنید.",
|
||||
"steps": [
|
||||
{"step_number": 1, "title": "آماده سازی", "description": "دوز را آماده کنید."},
|
||||
{"step_number": 2, "title": "تزریق یا پخش", "description": "طبق روش مصرف اجرا کنید."},
|
||||
{"step_number": 3, "title": "پایش", "description": "پاسخ مزرعه را بررسی کنید."},
|
||||
],
|
||||
},
|
||||
"alternative_recommendations": [],
|
||||
},
|
||||
},
|
||||
response_only=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def post(self, request):
|
||||
@@ -81,17 +124,14 @@ class FertilizationRecommendView(APIView):
|
||||
)
|
||||
|
||||
validated = serializer.validated_data
|
||||
farm_uuid = validated["farm_uuid"]
|
||||
plant_name = validated.get("plant_name")
|
||||
growth_stage = validated.get("growth_stage")
|
||||
query = validated.get("query")
|
||||
|
||||
try:
|
||||
result = get_fertilization_recommendation(
|
||||
farm_uuid=farm_uuid,
|
||||
plant_name=plant_name,
|
||||
growth_stage=growth_stage,
|
||||
query=query,
|
||||
farm_uuid=validated["farm_uuid"],
|
||||
plant_name=validated.get("plant_name"),
|
||||
crop_id=validated.get("crop_id"),
|
||||
growth_stage=validated.get("growth_stage"),
|
||||
query=validated.get("query"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return Response(
|
||||
@@ -99,8 +139,10 @@ class FertilizationRecommendView(APIView):
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Public API exposes only the final farmer-facing recommendation object.
|
||||
final_result = {"sections": result.get("sections", [])}
|
||||
final_result = result.get("data") if isinstance(result, dict) else None
|
||||
if not isinstance(final_result, dict):
|
||||
final_result = {"sections": result.get("sections", [])} if isinstance(result, dict) else {}
|
||||
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": final_result},
|
||||
status=status.HTTP_200_OK,
|
||||
|
||||
@@ -51,6 +51,29 @@ class FarmManagementJourneyTests(IntegrationAPITestCase):
|
||||
returned_names = {item["name"] for item in plants_list_response.json()["data"]}
|
||||
self.assertTrue({"Tomato", "Cucumber", "Remove Plant"}.issubset(returned_names))
|
||||
|
||||
plant_catalog = self.create_plant_via_api(
|
||||
"Pepper",
|
||||
growth_stage="",
|
||||
icon="sprout",
|
||||
)
|
||||
Plant.objects.filter(pk=plant_catalog["id"]).update(growth_stage="", icon="")
|
||||
plant_names_response = self.client.get("/api/plants/names/")
|
||||
self.assertEqual(plant_names_response.status_code, 200)
|
||||
plant_names_payload = {
|
||||
item["name"]: item for item in plant_names_response.json()["data"]
|
||||
}
|
||||
self.assertEqual(plant_names_payload["Pepper"]["icon"], "leaf")
|
||||
self.assertEqual(
|
||||
plant_names_payload["Pepper"]["growth_stages"],
|
||||
["initial", "vegetative", "flowering", "fruiting", "maturity"],
|
||||
)
|
||||
pepper = Plant.objects.get(pk=plant_catalog["id"])
|
||||
self.assertEqual(
|
||||
pepper.growth_stage,
|
||||
"initial, vegetative, flowering, fruiting, maturity",
|
||||
)
|
||||
self.assertEqual(pepper.icon, "leaf")
|
||||
|
||||
plant_patch_response = self.client.patch(
|
||||
f"/api/plants/{tomato['id']}/",
|
||||
data={"growth_stage": "flowering", "watering": "daily"},
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Plant Names API
|
||||
|
||||
این API فقط لیست نام گیاهها را به همراه آیکون و مراحل رشد برمیگرداند.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- `GET /api/plants/names/`
|
||||
|
||||
## کاربرد
|
||||
|
||||
- گرفتن لیست سبک برای dropdown یا selector فرانت
|
||||
- نمایش نام گیاه
|
||||
- نمایش `icon`
|
||||
- نمایش مراحل رشد هر گیاه
|
||||
|
||||
## رفتار API
|
||||
|
||||
- فقط فیلدهای `name`، `icon` و `growth_stages` را برمیگرداند
|
||||
- اگر `growth_stage` برای یک گیاه خالی باشد، API به صورت خودکار این مراحل پیشفرض را اضافه و در دیتابیس ذخیره میکند:
|
||||
- `initial`
|
||||
- `vegetative`
|
||||
- `flowering`
|
||||
- `fruiting`
|
||||
- `maturity`
|
||||
- اگر `icon` خالی باشد، مقدار پیشفرض `leaf` ذخیره و برگردانده میشود
|
||||
- اگر در `growth_profile.stage_thresholds` مرحلهای وجود داشته باشد، آن مرحله هم در خروجی `growth_stages` لحاظ میشود
|
||||
|
||||
## نمونه درخواست
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8000/api/plants/names/
|
||||
```
|
||||
|
||||
## نمونه پاسخ
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{
|
||||
"name": "Tomato",
|
||||
"icon": "leaf",
|
||||
"growth_stages": [
|
||||
"vegetative",
|
||||
"flowering",
|
||||
"fruiting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Pepper",
|
||||
"icon": "leaf",
|
||||
"growth_stages": [
|
||||
"initial",
|
||||
"vegetative",
|
||||
"flowering",
|
||||
"fruiting",
|
||||
"maturity"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## فیلدهای خروجی
|
||||
|
||||
- `name`: نام گیاه
|
||||
- `icon`: آیکون گیاه برای فرانت
|
||||
- `growth_stages`: آرایهای از مراحل رشد گیاه
|
||||
|
||||
## نکته برای فرانت
|
||||
|
||||
- این endpoint برای لیست سبک طراحی شده و مناسب صفحههای انتخاب گیاه است
|
||||
- اگر جزئیات کامل گیاه لازم دارید، از `GET /api/plants/` یا `GET /api/plants/{id}/` استفاده کنید
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("plant", "0005_plant_growth_stage"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="plant",
|
||||
name="icon",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="leaf",
|
||||
help_text="آیکون گیاه برای نمایش در فرانت",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -12,6 +12,12 @@ class Plant(models.Model):
|
||||
db_index=True,
|
||||
help_text="نام گیاه",
|
||||
)
|
||||
icon = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="leaf",
|
||||
help_text="آیکون گیاه برای نمایش در فرانت",
|
||||
)
|
||||
light = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
|
||||
@@ -3,6 +3,37 @@ from rest_framework import serializers
|
||||
from .models import Plant
|
||||
|
||||
|
||||
DEFAULT_PLANT_GROWTH_STAGES = [
|
||||
"initial",
|
||||
"vegetative",
|
||||
"flowering",
|
||||
"fruiting",
|
||||
"maturity",
|
||||
]
|
||||
|
||||
|
||||
def normalize_growth_stage_values(plant: Plant) -> list[str]:
|
||||
stages: list[str] = []
|
||||
|
||||
raw_stage = (plant.growth_stage or "").replace("،", ",")
|
||||
for part in raw_stage.split(","):
|
||||
value = part.strip()
|
||||
if value and value not in stages:
|
||||
stages.append(value)
|
||||
|
||||
stage_thresholds = plant.growth_profile.get("stage_thresholds", {})
|
||||
if isinstance(stage_thresholds, dict):
|
||||
for stage_name in stage_thresholds.keys():
|
||||
value = str(stage_name).strip()
|
||||
if value and value not in stages:
|
||||
stages.append(value)
|
||||
|
||||
if not stages:
|
||||
stages = list(DEFAULT_PLANT_GROWTH_STAGES)
|
||||
|
||||
return stages
|
||||
|
||||
|
||||
class PlantSerializer(serializers.ModelSerializer):
|
||||
"""سریالایزر خروجی / ورودی برای Plant."""
|
||||
|
||||
@@ -11,6 +42,7 @@ class PlantSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"icon",
|
||||
"light",
|
||||
"watering",
|
||||
"soil",
|
||||
@@ -24,3 +56,9 @@ class PlantSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class PlantNameStageSerializer(serializers.Serializer):
|
||||
name = serializers.CharField()
|
||||
icon = serializers.CharField()
|
||||
growth_stages = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
+7
-1
@@ -1,9 +1,15 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import PlantDetailView, PlantFetchInfoView, PlantListCreateView
|
||||
from .views import (
|
||||
PlantDetailView,
|
||||
PlantFetchInfoView,
|
||||
PlantListCreateView,
|
||||
PlantNameStageListView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", PlantListCreateView.as_view(), name="plant-list-create"),
|
||||
path("names/", PlantNameStageListView.as_view(), name="plant-name-stage-list"),
|
||||
path("<int:pk>/", PlantDetailView.as_view(), name="plant-detail"),
|
||||
path("fetch-info/", PlantFetchInfoView.as_view(), name="plant-fetch-info"),
|
||||
]
|
||||
|
||||
+59
-1
@@ -11,7 +11,11 @@ from rest_framework.views import APIView
|
||||
|
||||
from config.openapi import build_envelope_serializer, build_response
|
||||
from .models import Plant
|
||||
from .serializers import PlantSerializer
|
||||
from .serializers import (
|
||||
PlantNameStageSerializer,
|
||||
PlantSerializer,
|
||||
normalize_growth_stage_values,
|
||||
)
|
||||
from .services import fetch_plant_info_from_api
|
||||
|
||||
|
||||
@@ -33,6 +37,11 @@ PlantFetchInfoResponseSerializer = build_envelope_serializer(
|
||||
"PlantFetchInfoResponseSerializer",
|
||||
PlantSerializer,
|
||||
)
|
||||
PlantNameStageListResponseSerializer = build_envelope_serializer(
|
||||
"PlantNameStageListResponseSerializer",
|
||||
PlantNameStageSerializer,
|
||||
many=True,
|
||||
)
|
||||
|
||||
|
||||
class PlantListCreateView(APIView):
|
||||
@@ -105,6 +114,55 @@ class PlantListCreateView(APIView):
|
||||
)
|
||||
|
||||
|
||||
class PlantNameStageListView(APIView):
|
||||
"""لیست سبک از نام گیاه، آیکون و مراحل رشد."""
|
||||
|
||||
@extend_schema(
|
||||
tags=["Plant"],
|
||||
summary="لیست نام گیاهان با مراحل رشد",
|
||||
description=(
|
||||
"فقط نام گیاه، آیکون و مراحل رشد را برمیگرداند. "
|
||||
"اگر برای گیاهی مرحله رشد ثبت نشده باشد، مراحل پیشفرض به آن اضافه و ذخیره میشود."
|
||||
),
|
||||
responses={
|
||||
200: build_response(
|
||||
PlantNameStageListResponseSerializer,
|
||||
"لیست نام گیاهان به همراه مراحل رشد و آیکون.",
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request):
|
||||
payload = []
|
||||
for plant in Plant.objects.all():
|
||||
growth_stages = normalize_growth_stage_values(plant)
|
||||
serialized_stages = ", ".join(growth_stages)
|
||||
update_fields: list[str] = []
|
||||
|
||||
if plant.growth_stage != serialized_stages:
|
||||
plant.growth_stage = serialized_stages
|
||||
update_fields.append("growth_stage")
|
||||
if not plant.icon:
|
||||
plant.icon = "leaf"
|
||||
update_fields.append("icon")
|
||||
if update_fields:
|
||||
update_fields.append("updated_at")
|
||||
plant.save(update_fields=update_fields)
|
||||
|
||||
payload.append(
|
||||
{
|
||||
"name": plant.name,
|
||||
"icon": plant.icon,
|
||||
"growth_stages": growth_stages,
|
||||
}
|
||||
)
|
||||
|
||||
serializer = PlantNameStageSerializer(payload, many=True)
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": serializer.data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class PlantDetailView(APIView):
|
||||
"""دریافت، ویرایش و حذف یک گیاه."""
|
||||
|
||||
|
||||
+580
-48
@@ -1,9 +1,14 @@
|
||||
"""
|
||||
سرویس توصیه کودهی — بدون API، قابل فراخوانی از سایر سرویسها
|
||||
از RAG با پایگاه دانش fertilization و لحن مخصوص کودهی استفاده میکند.
|
||||
سرویس توصیه کودهی — بدون API، قابل فراخوانی از سایر سرویسها.
|
||||
از RAG با پایگاه دانش fertilization و خروجی optimizer برای ساخت پاسخ ساختاریافته استفاده میکند.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
@@ -16,42 +21,569 @@ from rag.chat import (
|
||||
_load_service_tone,
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "fertilization"
|
||||
SERVICE_ID = "fertilization"
|
||||
HECTARE_TO_SQUARE_METER = 10000.0
|
||||
|
||||
DEFAULT_FERTILIZATION_PROMPT = (
|
||||
"از داده های خاک، مرحله رشد و خروجی بهینه ساز شبیه سازی برای ساخت توصیه کودهی استفاده کن. "
|
||||
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان را مرجع اصلی فرمول، مقدار، روش مصرف و اعتبار قرار بده. "
|
||||
"پاسخ فقط JSON معتبر با کلید sections باشد."
|
||||
"از RAG و خروجی بهینه ساز شبیه سازی برای ساخت پاسخ ساختاریافته کودهی استفاده کن. "
|
||||
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان مرجع قطعی اعداد، فرمول، روش مصرف و زمان بندی است. "
|
||||
"پاسخ فقط JSON معتبر بر اساس قرارداد status/data برگردان."
|
||||
)
|
||||
|
||||
DEFAULT_MACRO_DESCRIPTIONS = {
|
||||
"n": "نیتروژن برای حفظ رشد رویشی، رنگ سبز برگ و بازسازی سریع بوته مهم است.",
|
||||
"p": "فسفر به توسعه ریشه، انتقال انرژی و پشتیبانی از گلدهی و استقرار کمک می کند.",
|
||||
"k": "پتاسیم به تنظیم آب، کیفیت محصول و مقاومت گیاه در برابر تنش محیطی کمک می کند.",
|
||||
}
|
||||
|
||||
DEFAULT_MICRO_NAMES = {
|
||||
"fe": "آهن",
|
||||
"zn": "روی",
|
||||
"mn": "منگنز",
|
||||
"b": "بر",
|
||||
"cu": "مس",
|
||||
"mg": "منیزیم",
|
||||
"ca": "کلسیم",
|
||||
"mo": "مولیبدن",
|
||||
}
|
||||
|
||||
DEFAULT_MICRO_DESCRIPTIONS = {
|
||||
"fe": "آهن در ساخت کلروفیل و کاهش زردی بین رگبرگی نقش دارد.",
|
||||
"zn": "روی در رشد متعادل، تشکیل هورمون ها و فعالیت آنزیمی موثر است.",
|
||||
"mn": "منگنز در فتوسنتز و فعالیت آنزیم های متابولیکی نقش پشتیبان دارد.",
|
||||
"b": "بر در گرده افشانی، تشکیل گل و انتقال قندها اهمیت دارد.",
|
||||
"cu": "مس به فعالیت آنزیمی و استحکام نسبی بافت های گیاه کمک می کند.",
|
||||
"mg": "منیزیم بخش مرکزی کلروفیل است و در فتوسنتز اهمیت دارد.",
|
||||
"ca": "کلسیم در استحکام دیواره سلولی و کیفیت رشد بافت های جوان موثر است.",
|
||||
"mo": "مولیبدن در متابولیسم نیتروژن و کارایی جذب آن نقش دارد.",
|
||||
}
|
||||
|
||||
DEFAULT_STAGE_LABELS = {
|
||||
"initial": "استقرار",
|
||||
"vegetative": "رشد رویشی",
|
||||
"flowering": "گلدهی",
|
||||
"fruiting": "میوه دهی",
|
||||
}
|
||||
|
||||
|
||||
def _get_optimizer():
|
||||
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
|
||||
|
||||
|
||||
def _validate_fertilization_response(parsed_result: dict) -> dict:
|
||||
if not isinstance(parsed_result, dict):
|
||||
raise ValueError("Fertilization recommendation response is not a JSON object.")
|
||||
def _safe_float(value: Any, default: float | None = None) -> float | None:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
sections = parsed_result.get("sections")
|
||||
if not isinstance(sections, list) or not sections:
|
||||
raise ValueError("Fertilization recommendation response is missing sections.")
|
||||
|
||||
for index, section in enumerate(sections):
|
||||
if not isinstance(section, dict):
|
||||
raise ValueError(f"Fertilization recommendation section {index} is invalid.")
|
||||
missing = [key for key in ("type", "title", "icon") if key not in section]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Fertilization recommendation section {index} is missing fields: {', '.join(missing)}"
|
||||
def _stage_key(growth_stage: str | None) -> str:
|
||||
text = (growth_stage or "").strip().lower()
|
||||
if any(token in text for token in ("flower", "گل", "anthesis")):
|
||||
return "flowering"
|
||||
if any(token in text for token in ("fruit", "میوه", "برداشت", "ripen", "harvest")):
|
||||
return "fruiting"
|
||||
if any(token in text for token in ("initial", "seed", "جوانه", "نشا", "استقرار")):
|
||||
return "initial"
|
||||
return "vegetative"
|
||||
|
||||
|
||||
def _clean_json_response(raw: str) -> dict[str, Any]:
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_label(value: float) -> str:
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return f"{value:.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _parse_npk_ratio(formula: str | None) -> dict[str, float | str]:
|
||||
if not formula:
|
||||
return {"n": 0.0, "p": 0.0, "k": 0.0, "label": "0-0-0"}
|
||||
|
||||
parts = re.findall(r"\d+(?:\.\d+)?", formula)
|
||||
if len(parts) < 3:
|
||||
return {"n": 0.0, "p": 0.0, "k": 0.0, "label": formula}
|
||||
|
||||
n, p, k = (_safe_float(part, 0.0) or 0.0 for part in parts[:3])
|
||||
return {
|
||||
"n": round(n, 3),
|
||||
"p": round(p, 3),
|
||||
"k": round(k, 3),
|
||||
"label": f"{_normalize_label(n)}-{_normalize_label(p)}-{_normalize_label(k)}",
|
||||
}
|
||||
|
||||
|
||||
def _method_id(label: str) -> str:
|
||||
text = (label or "").strip()
|
||||
if "محلول" in text and ("آبیاری" in text or "کودآبیاری" in text):
|
||||
return "foliar_fertigation"
|
||||
if "محلول" in text:
|
||||
return "foliar_spray"
|
||||
if "آبیاری" in text or "کودآبیاری" in text:
|
||||
return "fertigation"
|
||||
if "سرک" in text or "خاک" in text or "نواری" in text:
|
||||
return "soil_application"
|
||||
return "custom_application"
|
||||
|
||||
|
||||
def _slug_value(value: str) -> str:
|
||||
token = re.sub(r"[^a-zA-Z0-9]+", "-", (value or "").strip().lower()).strip("-")
|
||||
return token or "fertilizer"
|
||||
|
||||
|
||||
def _fertilizer_display_name(formula: str | None) -> str:
|
||||
ratio = _parse_npk_ratio(formula)
|
||||
label = ratio["label"] if ratio["label"] else (formula or "کود پیشنهادی")
|
||||
if label and label != "0-0-0":
|
||||
return f"کود کامل {label}"
|
||||
return formula or "کود پیشنهادی"
|
||||
|
||||
|
||||
def _fertilizer_type_label(formula: str | None) -> str:
|
||||
ratio = _parse_npk_ratio(formula)
|
||||
if ratio["label"] and ratio["label"] != "0-0-0":
|
||||
return "NPK"
|
||||
return formula or "Fertilizer"
|
||||
|
||||
|
||||
def _first_text(*values: Any) -> str:
|
||||
for value in values:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _default_application_steps(application_method: str) -> list[dict[str, Any]]:
|
||||
if "محلول" in application_method:
|
||||
return [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "آماده سازی",
|
||||
"description": "دوز توصیه شده را در مقدار کمی آب تمیز حل کنید تا محلول یکنواخت به دست آید.",
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "اختلاط",
|
||||
"description": "محلول را به مخزن اصلی اضافه کنید و همزمان هم بزنید تا ته نشینی رخ ندهد.",
|
||||
},
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "مصرف",
|
||||
"description": "در ساعات خنک روز به صورت یکنواخت محلول پاشی کنید و پس از اجرا بوته را پایش کنید.",
|
||||
},
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "آماده سازی",
|
||||
"description": "مقدار توصیه شده را بر اساس مساحت مزرعه اندازه گیری و پیش از اجرا یکنواخت تقسیم کنید.",
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "تزریق یا پخش",
|
||||
"description": "کود را از طریق کودآبیاری یا مصرف خاکی سبک مطابق روش پیشنهادی وارد مزرعه کنید.",
|
||||
},
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "پایش",
|
||||
"description": "پس از اجرا رطوبت خاک، وضعیت برگ و پاسخ بوته را تا نوبت بعدی بررسی کنید.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _warning_from_weather(forecasts: list[Any], application_method: str) -> str:
|
||||
if not forecasts:
|
||||
return "هنگام مصرف از دستکش و ماسک استفاده کنید و قبل از اختلاط آزمون سازگاری در مقیاس کوچک انجام دهید."
|
||||
|
||||
rainy = next(
|
||||
(
|
||||
item
|
||||
for item in forecasts
|
||||
if (_safe_float(getattr(item, "precipitation", None), 0.0) or 0.0) >= 3.0
|
||||
),
|
||||
None,
|
||||
)
|
||||
hot = next(
|
||||
(
|
||||
item
|
||||
for item in forecasts
|
||||
if (_safe_float(getattr(item, "temperature_max", None), 0.0) or 0.0) >= 32.0
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if rainy is not None and "محلول" in application_method:
|
||||
return (
|
||||
f"به دلیل احتمال بارش موثر در {rainy.forecast_date} محلول پاشی را به پنجره خشک منتقل کنید و "
|
||||
"در زمان اجرا از ماسک و دستکش استفاده شود."
|
||||
)
|
||||
if hot is not None:
|
||||
return (
|
||||
"به دلیل گرمای پیش رو، مصرف را فقط در صبح زود یا نزدیک غروب انجام دهید و از اختلاط غلیظ خودداری کنید."
|
||||
)
|
||||
return "هنگام مصرف از دستکش و ماسک استفاده کنید و پیش از اختلاط با سایر نهاده ها آزمون سازگاری انجام دهید."
|
||||
|
||||
|
||||
def _fallback_optimizer_result(growth_stage: str | None) -> dict[str, Any]:
|
||||
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
||||
stage_key = _stage_key(growth_stage)
|
||||
target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
|
||||
base_amount = round(max(40.0, (target["n"] * 1.25)), 2)
|
||||
return {
|
||||
"engine": "defaults",
|
||||
"recommended_strategy": {
|
||||
"code": stage_key,
|
||||
"label": DEFAULT_STAGE_LABELS.get(stage_key, stage_key),
|
||||
"score": 0.0,
|
||||
"expected_yield_index": 0.0,
|
||||
"fertilizer_type": target["formula"],
|
||||
"amount_kg_per_ha": base_amount,
|
||||
"application_method": target["application_method"],
|
||||
"timing": target["timing"],
|
||||
"validity_period": f"معتبر برای {defaults['validity_days']} روز آینده",
|
||||
"reasoning": [
|
||||
"پیشنهاد از تنظیمات پایه مرحله رشد ساخته شد زیرا خروجی کامل optimizer در دسترس نبود.",
|
||||
f"فرمول هدف مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} برابر با {target['formula']} در نظر گرفته شد.",
|
||||
],
|
||||
},
|
||||
"alternatives": [],
|
||||
"context_text": "fallback fertilization context",
|
||||
}
|
||||
|
||||
|
||||
def _build_legacy_sections(
|
||||
structured_data: dict[str, Any],
|
||||
recommended_strategy: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
primary = structured_data.get("primary_recommendation", {})
|
||||
guide = structured_data.get("application_guide", {})
|
||||
recommended_strategy = recommended_strategy or {}
|
||||
return [
|
||||
{
|
||||
"type": "recommendation",
|
||||
"title": primary.get("display_title") or "برنامه کودهی",
|
||||
"icon": "leaf",
|
||||
"content": primary.get("summary", ""),
|
||||
"fertilizerType": primary.get("npk_ratio", {}).get("label") or primary.get("fertilizer_type", ""),
|
||||
"amount": primary.get("dosage", {}).get("label", ""),
|
||||
"applicationMethod": primary.get("application_method", {}).get("label", ""),
|
||||
"timing": recommended_strategy.get("timing", ""),
|
||||
"validityPeriod": recommended_strategy.get("validity_period", ""),
|
||||
"expandableExplanation": primary.get("reasoning", ""),
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"title": "مراحل مصرف",
|
||||
"icon": "list",
|
||||
"items": [step.get("title", "") for step in guide.get("steps", []) if step.get("title")],
|
||||
},
|
||||
{
|
||||
"type": "warning",
|
||||
"title": "هشدار کودهی",
|
||||
"icon": "alert-triangle",
|
||||
"content": guide.get("safety_warning", ""),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _coerce_steps(value: Any, application_method: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return _default_application_steps(application_method)
|
||||
|
||||
steps = []
|
||||
for index, item in enumerate(value, start=1):
|
||||
if isinstance(item, dict):
|
||||
title = _first_text(item.get("title"), f"مرحله {index}")
|
||||
description = _first_text(item.get("description"), item.get("content"))
|
||||
if not description:
|
||||
continue
|
||||
steps.append(
|
||||
{
|
||||
"step_number": int(item.get("step_number") or index),
|
||||
"title": title,
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
elif isinstance(item, str) and item.strip():
|
||||
steps.append(
|
||||
{
|
||||
"step_number": index,
|
||||
"title": f"مرحله {index}",
|
||||
"description": item.strip(),
|
||||
}
|
||||
)
|
||||
return steps or _default_application_steps(application_method)
|
||||
|
||||
|
||||
def _normalize_micro_items(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
items = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = _first_text(item.get("key")).lower()
|
||||
if not key:
|
||||
continue
|
||||
nutrient_value = _safe_float(item.get("value"))
|
||||
if nutrient_value is None:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": _first_text(item.get("name"), DEFAULT_MICRO_NAMES.get(key, key.upper())),
|
||||
"value": round(nutrient_value, 3),
|
||||
"unit": "percent",
|
||||
"description": _first_text(item.get("description"), DEFAULT_MICRO_DESCRIPTIONS.get(key, "")),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _build_nutrient_analysis(llm_analysis: dict[str, Any] | None, npk_ratio: dict[str, Any]) -> dict[str, Any]:
|
||||
llm_analysis = llm_analysis if isinstance(llm_analysis, dict) else {}
|
||||
macro_by_key: dict[str, dict[str, Any]] = {}
|
||||
for item in llm_analysis.get("macro", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = _first_text(item.get("key")).lower()
|
||||
if key:
|
||||
macro_by_key[key] = item
|
||||
|
||||
macro = []
|
||||
for key, name in (("n", "نیتروژن (N)"), ("p", "فسفر (P)"), ("k", "پتاسیم (K)")):
|
||||
source = macro_by_key.get(key, {})
|
||||
macro.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": name,
|
||||
"value": round(_safe_float(npk_ratio.get(key), 0.0) or 0.0, 3),
|
||||
"unit": "percent",
|
||||
"description": _first_text(source.get("description"), DEFAULT_MACRO_DESCRIPTIONS[key]),
|
||||
}
|
||||
)
|
||||
|
||||
return {"macro": macro, "micro": _normalize_micro_items(llm_analysis.get("micro"))}
|
||||
|
||||
|
||||
def _build_application_guide(
|
||||
llm_guide: dict[str, Any] | None,
|
||||
*,
|
||||
application_method: str,
|
||||
warning_text: str,
|
||||
) -> dict[str, Any]:
|
||||
llm_guide = llm_guide if isinstance(llm_guide, dict) else {}
|
||||
return {
|
||||
"safety_warning": _first_text(llm_guide.get("safety_warning"), warning_text),
|
||||
"steps": _coerce_steps(llm_guide.get("steps"), application_method),
|
||||
}
|
||||
|
||||
|
||||
def _build_alternative_recommendations(
|
||||
llm_alternatives: Any,
|
||||
optimizer_alternatives: list[dict[str, Any]],
|
||||
recommended_strategy: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
llm_items = llm_alternatives if isinstance(llm_alternatives, list) else []
|
||||
alternatives = []
|
||||
|
||||
for index, optimizer_item in enumerate(optimizer_alternatives[:3]):
|
||||
llm_item = llm_items[index] if index < len(llm_items) and isinstance(llm_items[index], dict) else {}
|
||||
formula = _first_text(
|
||||
llm_item.get("fertilizer_code"),
|
||||
optimizer_item.get("fertilizer_type"),
|
||||
recommended_strategy.get("fertilizer_type"),
|
||||
)
|
||||
display_name = _first_text(llm_item.get("fertilizer_name"), _fertilizer_display_name(formula), optimizer_item.get("label"))
|
||||
description = _first_text(
|
||||
llm_item.get("description"),
|
||||
*(optimizer_item.get("reasoning") or []),
|
||||
f"این گزینه با امتیاز {optimizer_item.get('score', 0)} برای شرایط مشابه قابل استفاده است.",
|
||||
)
|
||||
alternatives.append(
|
||||
{
|
||||
"fertilizer_code": _slug_value(formula or optimizer_item.get("code", f"alt-{index + 1}")),
|
||||
"fertilizer_name": display_name,
|
||||
"fertilizer_type": _first_text(llm_item.get("fertilizer_type"), _fertilizer_type_label(formula)),
|
||||
"usage_method": _first_text(
|
||||
llm_item.get("usage_method"),
|
||||
optimizer_item.get("application_method"),
|
||||
recommended_strategy.get("application_method"),
|
||||
),
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
|
||||
for llm_item in llm_items[len(alternatives):3]:
|
||||
if not isinstance(llm_item, dict):
|
||||
continue
|
||||
fertilizer_name = _first_text(llm_item.get("fertilizer_name"))
|
||||
fertilizer_code = _first_text(llm_item.get("fertilizer_code"), fertilizer_name)
|
||||
if not fertilizer_name or not fertilizer_code:
|
||||
continue
|
||||
alternatives.append(
|
||||
{
|
||||
"fertilizer_code": _slug_value(fertilizer_code),
|
||||
"fertilizer_name": fertilizer_name,
|
||||
"fertilizer_type": _first_text(llm_item.get("fertilizer_type"), "Fertilizer"),
|
||||
"usage_method": _first_text(llm_item.get("usage_method"), recommended_strategy.get("application_method", "")),
|
||||
"description": _first_text(llm_item.get("description"), "گزینه جایگزین در صورت محدودیت تامین یا تغییر شرایط مزرعه."),
|
||||
}
|
||||
)
|
||||
|
||||
return alternatives
|
||||
|
||||
|
||||
def _normalize_llm_payload(parsed_result: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(parsed_result, dict):
|
||||
return {"status": "success", "data": {}}
|
||||
|
||||
if isinstance(parsed_result.get("data"), dict):
|
||||
status = parsed_result.get("status") or "success"
|
||||
return {"status": status, "data": parsed_result["data"]}
|
||||
|
||||
if any(key in parsed_result for key in ("primary_recommendation", "nutrient_analysis", "application_guide")):
|
||||
status = parsed_result.get("status") or "success"
|
||||
return {"status": status, "data": parsed_result}
|
||||
|
||||
sections = parsed_result.get("sections")
|
||||
if isinstance(sections, list):
|
||||
recommendation = next((item for item in sections if isinstance(item, dict) and item.get("type") == "recommendation"), {})
|
||||
list_section = next((item for item in sections if isinstance(item, dict) and item.get("type") == "list"), {})
|
||||
warning = next((item for item in sections if isinstance(item, dict) and item.get("type") == "warning"), {})
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"primary_recommendation": {
|
||||
"display_title": _first_text(recommendation.get("title"), recommendation.get("fertilizerType")),
|
||||
"reasoning": _first_text(recommendation.get("expandableExplanation"), recommendation.get("content")),
|
||||
"summary": _first_text(recommendation.get("content"), recommendation.get("title")),
|
||||
},
|
||||
"application_guide": {
|
||||
"safety_warning": _first_text(warning.get("content")),
|
||||
"steps": list_section.get("items", []),
|
||||
},
|
||||
"alternative_recommendations": [],
|
||||
},
|
||||
}
|
||||
|
||||
return {"status": "success", "data": {}}
|
||||
|
||||
|
||||
def _build_final_response(
|
||||
*,
|
||||
llm_payload: dict[str, Any],
|
||||
optimized_result: dict[str, Any] | None,
|
||||
plant_name: str | None,
|
||||
crop_id: str | None,
|
||||
growth_stage: str | None,
|
||||
forecasts: list[Any],
|
||||
) -> dict[str, Any]:
|
||||
normalized_llm = _normalize_llm_payload(llm_payload)
|
||||
advisory = normalized_llm.get("data", {}) if isinstance(normalized_llm.get("data"), dict) else {}
|
||||
optimizer_payload = optimized_result or _fallback_optimizer_result(growth_stage)
|
||||
recommended = optimizer_payload.get("recommended_strategy", {})
|
||||
|
||||
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
||||
stage_key = _stage_key(growth_stage)
|
||||
stage_target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
|
||||
|
||||
formula = _first_text(recommended.get("fertilizer_type"), stage_target.get("formula"))
|
||||
npk_ratio = _parse_npk_ratio(formula)
|
||||
application_method_label = _first_text(recommended.get("application_method"), stage_target.get("application_method"))
|
||||
amount_kg_per_ha = round(_safe_float(recommended.get("amount_kg_per_ha"), 0.0) or 0.0, 3)
|
||||
amount_per_square_meter = round(amount_kg_per_ha / HECTARE_TO_SQUARE_METER, 6)
|
||||
interval_days = int(
|
||||
stage_target.get(
|
||||
"application_interval_days",
|
||||
defaults.get("default_application_interval_days", 14),
|
||||
)
|
||||
)
|
||||
|
||||
primary_advisory = advisory.get("primary_recommendation") if isinstance(advisory.get("primary_recommendation"), dict) else {}
|
||||
reasoning = _first_text(primary_advisory.get("reasoning"), " ".join(recommended.get("reasoning", [])))
|
||||
if not reasoning:
|
||||
reasoning = "این توصیه با اتکا به مرحله رشد، وضعیت خاک و خروجی بهینه ساز شبیه سازی تنظیم شده است."
|
||||
|
||||
summary = _first_text(primary_advisory.get("summary"))
|
||||
if not summary:
|
||||
summary = f"{_fertilizer_display_name(formula)} برای مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} مناسب ارزیابی شده است."
|
||||
|
||||
warning_text = _warning_from_weather(forecasts, application_method_label)
|
||||
nutrient_analysis = _build_nutrient_analysis(advisory.get("nutrient_analysis"), npk_ratio)
|
||||
application_guide = _build_application_guide(
|
||||
advisory.get("application_guide"),
|
||||
application_method=application_method_label,
|
||||
warning_text=warning_text,
|
||||
)
|
||||
alternatives = _build_alternative_recommendations(
|
||||
advisory.get("alternative_recommendations"),
|
||||
optimizer_payload.get("alternatives", []),
|
||||
recommended,
|
||||
)
|
||||
|
||||
structured_data = {
|
||||
"primary_recommendation": {
|
||||
"fertilizer_code": _slug_value(formula),
|
||||
"fertilizer_name": _first_text(primary_advisory.get("fertilizer_name"), _fertilizer_display_name(formula)),
|
||||
"display_title": _first_text(primary_advisory.get("display_title"), _fertilizer_display_name(formula)),
|
||||
"fertilizer_type": _first_text(primary_advisory.get("fertilizer_type"), _fertilizer_type_label(formula)),
|
||||
"npk_ratio": npk_ratio,
|
||||
"application_method": {
|
||||
"id": _method_id(application_method_label),
|
||||
"label": application_method_label,
|
||||
},
|
||||
"application_interval": {
|
||||
"value": interval_days,
|
||||
"unit": "day",
|
||||
"label": f"هر {interval_days} روز",
|
||||
},
|
||||
"dosage": {
|
||||
"base_amount_per_hectare": amount_kg_per_ha,
|
||||
"base_amount_per_square_meter": amount_per_square_meter,
|
||||
"unit": "kg",
|
||||
"label": f"{_normalize_label(amount_kg_per_ha)} کیلوگرم در هکتار",
|
||||
"calculation_basis": optimizer_payload.get("engine", "product"),
|
||||
},
|
||||
"reasoning": reasoning,
|
||||
"summary": summary,
|
||||
},
|
||||
"nutrient_analysis": nutrient_analysis,
|
||||
"application_guide": application_guide,
|
||||
"alternative_recommendations": alternatives,
|
||||
}
|
||||
|
||||
structured_data["sections"] = _build_legacy_sections(structured_data, recommended)
|
||||
return {"status": normalized_llm.get("status") or "success", "data": structured_data}
|
||||
|
||||
|
||||
def _validate_fertilization_response(parsed_result: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(parsed_result, dict):
|
||||
raise ValueError("Fertilization recommendation response is not a JSON object.")
|
||||
data = parsed_result.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Fertilization recommendation response is missing data.")
|
||||
if not isinstance(data.get("primary_recommendation"), dict):
|
||||
raise ValueError("Fertilization recommendation response is missing primary_recommendation.")
|
||||
return parsed_result
|
||||
|
||||
|
||||
@@ -59,25 +591,15 @@ def get_fertilization_recommendation(
|
||||
farm_uuid: str | None = None,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
crop_id: str | None = None,
|
||||
query: str | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
limit: int = 8,
|
||||
sensor_uuid: str | None = None,
|
||||
) -> dict:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
توصیه کودهی برای یک مزرعه.
|
||||
از RAG با پایگاه دانش fertilization استفاده میکند.
|
||||
|
||||
Args:
|
||||
farm_uuid: شناسه مزرعه
|
||||
plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant)
|
||||
growth_stage: مرحله رشد گیاه
|
||||
query: سوال اختیاری
|
||||
config: تنظیمات RAG
|
||||
limit: تعداد چانکهای بازیابیشده
|
||||
|
||||
Returns:
|
||||
dict ساختاریافته برای توصیه کودهی
|
||||
از RAG با پایگاه دانش fertilization استفاده می کند و خروجی نهایی را با optimizer ترکیب می کند.
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
service = get_service_config(SERVICE_ID, cfg)
|
||||
@@ -97,7 +619,7 @@ def get_fertilization_recommendation(
|
||||
if not resolved_farm_uuid:
|
||||
raise ValueError("farm_uuid is required.")
|
||||
|
||||
user_query = query or "توصیه کودهی برای مزرعه من چیست؟"
|
||||
user_query = query or "توصیه کودهی بهینه برای مزرعه من چیست؟"
|
||||
|
||||
sensor = (
|
||||
SensorData.objects.select_related("center_location")
|
||||
@@ -105,14 +627,20 @@ def get_fertilization_recommendation(
|
||||
.filter(farm_uuid=resolved_farm_uuid)
|
||||
.first()
|
||||
)
|
||||
|
||||
resolved_plant_name = plant_name
|
||||
if not resolved_plant_name and crop_id:
|
||||
resolved_plant_name = crop_id
|
||||
|
||||
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 plant_name:
|
||||
plant = sensor.plants.filter(name=plant_name).first() or sensor.plants.first()
|
||||
elif sensor is not None and resolved_plant_name:
|
||||
plant = sensor.plants.filter(name=resolved_plant_name).first() or sensor.plants.first()
|
||||
if plant is not None:
|
||||
resolved_plant_name = plant.name
|
||||
|
||||
forecasts = []
|
||||
optimized_result = None
|
||||
@@ -134,7 +662,12 @@ def get_fertilization_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] = []
|
||||
@@ -143,10 +676,7 @@ def get_fertilization_recommendation(
|
||||
if plant_text:
|
||||
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
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 "")
|
||||
|
||||
@@ -185,17 +715,19 @@ def get_fertilization_recommendation(
|
||||
f"Fertilization recommendation failed for farm {resolved_farm_uuid}."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
cleaned = raw
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||
result = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
result = {}
|
||||
|
||||
llm_payload = _clean_json_response(raw)
|
||||
result = _build_final_response(
|
||||
llm_payload=llm_payload,
|
||||
optimized_result=optimized_result,
|
||||
plant_name=resolved_plant_name,
|
||||
crop_id=crop_id,
|
||||
growth_stage=growth_stage,
|
||||
forecasts=forecasts,
|
||||
)
|
||||
result = _validate_fertilization_response(result)
|
||||
result["raw_response"] = raw
|
||||
result["simulation_optimizer"] = optimized_result
|
||||
result["sections"] = result["data"].get("sections", [])
|
||||
_complete_audit_log(
|
||||
audit_log,
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
|
||||
@@ -98,7 +98,11 @@ class RecommendationServiceDefaultsTests(TestCase):
|
||||
"label": "تغذیه نگهدارنده",
|
||||
"score": 72.0,
|
||||
"expected_yield_index": 78.0,
|
||||
"fertilizer_type": "20-20-20",
|
||||
"amount_kg_per_ha": 45.0,
|
||||
"application_method": "کودآبیاری",
|
||||
"timing": "صبح زود",
|
||||
"reasoning": ["برای نگهداری تعادل تغذیه ای گزینه سبک تری است."],
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -203,7 +207,7 @@ class RecommendationServiceDefaultsTests(TestCase):
|
||||
self.build_fertilization_optimizer_result()
|
||||
)
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock(message=Mock(content='{"sections": [{"type": "recommendation", "title": "برنامه", "icon": "leaf", "content": "مصرف انجام شود", "fertilizerType": "20-20-20", "amount": "45 کیلوگرم در هکتار", "applicationMethod": "کودآبیاری", "timing": "صبح", "validityPeriod": "5 روز", "expandableExplanation": "توضیح"}, {"type": "list", "title": "هشدار", "icon": "list", "items": ["مورد 1"]}, {"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "از اختلاط نامناسب خودداری شود."}]}'))]
|
||||
mock_response.choices = [Mock(message=Mock(content='{"status": "success", "data": {"primary_recommendation": {"display_title": "کود کامل 20-20-20", "reasoning": "توضیح", "summary": "مصرف انجام شود"}, "nutrient_analysis": {"macro": [{"key": "n", "name": "نیتروژن (N)", "value": 20, "unit": "percent", "description": "..."}, {"key": "p", "name": "فسفر (P)", "value": 20, "unit": "percent", "description": "..."}, {"key": "k", "name": "پتاسیم (K)", "value": 20, "unit": "percent", "description": "..."}], "micro": []}, "application_guide": {"safety_warning": "از اختلاط نامناسب خودداری شود.", "steps": [{"step_number": 1, "title": "آماده سازی", "description": "مورد 1"}]}, "alternative_recommendations": []}}'))]
|
||||
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
|
||||
|
||||
result = get_fertilization_recommendation(
|
||||
@@ -211,16 +215,17 @@ class RecommendationServiceDefaultsTests(TestCase):
|
||||
growth_stage="رویشی",
|
||||
)
|
||||
|
||||
self.assertEqual(result["sections"][0]["fertilizerType"], "20-20-20")
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
|
||||
mock_build_plant_text.assert_called_once_with("گوجهفرنگی", "رویشی")
|
||||
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
|
||||
self.assertEqual(result["sections"][2]["content"], "از اختلاط نامناسب خودداری شود.")
|
||||
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_raises_when_llm_returns_invalid_payload(
|
||||
def test_fertilization_recommendation_falls_back_to_optimizer_when_llm_returns_invalid_payload(
|
||||
self,
|
||||
mock_get_chat_client,
|
||||
mock_get_optimizer,
|
||||
@@ -234,8 +239,10 @@ class RecommendationServiceDefaultsTests(TestCase):
|
||||
mock_response.choices = [Mock(message=Mock(content="not-json"))]
|
||||
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
get_fertilization_recommendation(
|
||||
result = get_fertilization_recommendation(
|
||||
farm_uuid=str(self.farm_uuid),
|
||||
growth_stage="رویشی",
|
||||
)
|
||||
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
|
||||
|
||||
Reference in New Issue
Block a user