This commit is contained in:
2026-04-28 04:11:49 +03:30
parent 10186a0e4c
commit 8471d648a3
15 changed files with 1444 additions and 140 deletions
+90 -36
View File
@@ -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.
+10 -1
View File
@@ -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
+5
View File
@@ -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": [
+97 -8
View File
@@ -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
View File
@@ -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"},
+74
View File
@@ -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}/` استفاده کنید
+20
View File
@@ -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,
),
),
]
+6
View File
@@ -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,
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
+13 -6
View File
@@ -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)