diff --git a/config/tones/fertilization_tone.txt b/config/tones/fertilization_tone.txt index 967a03d..c17b11f 100644 --- a/config/tones/fertilization_tone.txt +++ b/config/tones/fertilization_tone.txt @@ -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": [ - { - "type": "recommendation", - "title": "برنامه کودهی بهینه", - "icon": "leaf", - "content": "خلاصه یک جمله ای از سناریوی منتخب", - "fertilizerType": "نوع کود پیشنهادی", - "amount": "مقدار مصرف دقیق", - "applicationMethod": "روش مصرف", - "timing": "بهترین زمان اجرا", - "validityPeriod": "مدت اعتبار این توصیه", - "expandableExplanation": "دلیل انتخاب این سناریو بر اساس کمبود عناصر، pH، مرحله رشد و شبیه سازی" + "status": "success", + "data": { + "primary_recommendation": { + "display_title": "عنوان نمايشي کوتاه", + "reasoning": "توضيح علمي و کاربردي بر اساس مرحله رشد، کمبود عناصر، ريسک آب و هوا و شبيه سازي", + "summary": "جمع بندي يک جمله اي مناسب براي Hero Card" }, - { - "type": "list", - "title": "نکات اجرایی و اختلاط", - "icon": "list", - "items": [ - "نکته عملی 1", - "نکته عملی 2" + "nutrient_analysis": { + "macro": [ + { + "key": "n", + "name": "نيتروژن (N)", + "value": 0, + "unit": "percent", + "description": "توضيح کوتاه کاربردي" + }, + { + "key": "p", + "name": "فسفر (P)", + "value": 0, + "unit": "percent", + "description": "توضيح کوتاه کاربردي" + }, + { + "key": "k", + "name": "پتاسيم (K)", + "value": 0, + "unit": "percent", + "description": "توضيح کوتاه کاربردي" + } + ], + "micro": [ + { + "key": "zn", + "name": "روي", + "value": 0, + "unit": "percent", + "description": "فقط اگر دانش زمينه اي معتبر داري پر کن" + } ] }, - { - "type": "warning", - "title": "هشدار کودهی", - "icon": "alert-triangle", - "content": "هشدار کوتاه و کاربردی" - } - ] + "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. diff --git a/crop_simulation/recommendation_optimizer.py b/crop_simulation/recommendation_optimizer.py index 5ac64fa..3f011d5 100644 --- a/crop_simulation/recommendation_optimizer.py +++ b/crop_simulation/recommendation_optimizer.py @@ -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 diff --git a/fertilization/FERTILIZATION_RECOMMENDATION_API_FIELDS.md b/fertilization/FERTILIZATION_RECOMMENDATION_API_FIELDS.md new file mode 100644 index 0000000..bdbfb62 --- /dev/null +++ b/fertilization/FERTILIZATION_RECOMMENDATION_API_FIELDS.md @@ -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 diff --git a/fertilization/apps.py b/fertilization/apps.py index 2fc19c7..c29d8ce 100644 --- a/fertilization/apps.py +++ b/fertilization/apps.py @@ -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": [ diff --git a/fertilization/serializers.py b/fertilization/serializers.py index 0950613..c789a8a 100644 --- a/fertilization/serializers.py +++ b/fertilization/serializers.py @@ -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) diff --git a/fertilization/views.py b/fertilization/views.py index 2ab3f31..294c4ad 100644 --- a/fertilization/views.py +++ b/fertilization/views.py @@ -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, diff --git a/integration_tests/test_management_api_flow.py b/integration_tests/test_management_api_flow.py index b9fae15..fda673f 100644 --- a/integration_tests/test_management_api_flow.py +++ b/integration_tests/test_management_api_flow.py @@ -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"}, diff --git a/plant/PLANT_NAMES_API.md b/plant/PLANT_NAMES_API.md new file mode 100644 index 0000000..cdd6487 --- /dev/null +++ b/plant/PLANT_NAMES_API.md @@ -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}/` استفاده کنید diff --git a/plant/migrations/0006_plant_icon.py b/plant/migrations/0006_plant_icon.py new file mode 100644 index 0000000..dc526cb --- /dev/null +++ b/plant/migrations/0006_plant_icon.py @@ -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, + ), + ), + ] diff --git a/plant/models.py b/plant/models.py index f1fed9a..1233c3a 100644 --- a/plant/models.py +++ b/plant/models.py @@ -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, diff --git a/plant/serializers.py b/plant/serializers.py index bedbfb5..37a1822 100644 --- a/plant/serializers.py +++ b/plant/serializers.py @@ -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()) diff --git a/plant/urls.py b/plant/urls.py index 91556a3..67a4280 100644 --- a/plant/urls.py +++ b/plant/urls.py @@ -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("/", PlantDetailView.as_view(), name="plant-detail"), path("fetch-info/", PlantFetchInfoView.as_view(), name="plant-fetch-info"), ] diff --git a/plant/views.py b/plant/views.py index cab6a73..ec86d2f 100644 --- a/plant/views.py +++ b/plant/views.py @@ -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): """دریافت، ویرایش و حذف یک گیاه.""" diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py index 005459f..705664a 100644 --- a/rag/services/fertilization.py +++ b/rag/services/fertilization.py @@ -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: +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 + + +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): - raise ValueError("Fertilization recommendation response is not a JSON object.") + 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 not isinstance(sections, list) or not sections: - raise ValueError("Fertilization recommendation response is missing 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": [], + }, + } - 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)}" - ) + 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), diff --git a/rag/tests/test_recommendation_services.py b/rag/tests/test_recommendation_services.py index 77d7c6f..6cb0328 100644 --- a/rag/tests/test_recommendation_services.py +++ b/rag/tests/test_recommendation_services.py @@ -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( - farm_uuid=str(self.farm_uuid), - growth_stage="رویشی", - ) + 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)