From 8159190a84109a204a2f68bdde803b8208a62c0f Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Thu, 30 Apr 2026 04:00:07 +0330 Subject: [PATCH] UPDATE --- celerybeat-schedule | Bin 16384 -> 16384 bytes ...rigation_fertilization_plan_parser_apis.md | 619 ++++++++++++++++++ fertilization_recommendation/apps.py | 2 +- fertilization_recommendation/serializers.py | 39 ++ fertilization_recommendation/tests.py | 48 +- fertilization_recommendation/urls.py | 3 +- fertilization_recommendation/views.py | 38 ++ irrigation_recommendation/apps.py | 2 +- irrigation_recommendation/serializers.py | 39 ++ irrigation_recommendation/tests.py | 60 ++ irrigation_recommendation/urls.py | 2 + irrigation_recommendation/views.py | 38 ++ 12 files changed, 886 insertions(+), 4 deletions(-) create mode 100644 docs/irrigation_fertilization_plan_parser_apis.md diff --git a/celerybeat-schedule b/celerybeat-schedule index 8c544c185cb3d82425e38d72403b5c7c586f61ba..84ef724fb31d97830c344b8f9162950b57b01d86 100644 GIT binary patch delta 28 jcmZo@U~Fh$+|XvsF2}&Yz?f(>`J+J~qyFam#)>=uc~J;V delta 28 jcmZo@U~Fh$+|XvsE-TK!z?g3``J+J~qu%EG#)>=udyWWk diff --git a/docs/irrigation_fertilization_plan_parser_apis.md b/docs/irrigation_fertilization_plan_parser_apis.md new file mode 100644 index 0000000..fe462c7 --- /dev/null +++ b/docs/irrigation_fertilization_plan_parser_apis.md @@ -0,0 +1,619 @@ +# Free-Text Plan Parser APIs + +این فایل برای تیم فرانت‌اند آماده شده و دو API جدید زیر را توضیح می‌دهد: + +- `POST /api/irrigation/plan-from-text/` +- `POST /api/fertilization/plan-from-text/` + +هدف هر دو API: + +- کاربر یک متن آزاد می‌نویسد +- backend تلاش می‌کند برنامه آبیاری یا کودهی را به JSON ساختاریافته تبدیل کند +- اگر اطلاعات کامل باشد، JSON نهایی برمی‌گردد +- اگر اطلاعات ناقص باشد، API سوال‌های تکمیلی برمی‌گرداند +- فرانت‌اند سوال‌ها را از کاربر می‌پرسد و پاسخ‌ها را دوباره برای API می‌فرستد + +--- + +## رفتار کلی هر دو API + +هر دو endpoint یک flow یکسان دارند: + +1. کاربر متن آزاد اولیه را می‌فرستد +2. اگر متن کامل باشد: + - `status = "completed"` + - `final_plan` برمی‌گردد +3. اگر متن ناقص باشد: + - `status = "needs_clarification"` + - `missing_fields` برمی‌گردد + - `questions` برمی‌گردد +4. فرانت‌اند پاسخ کاربر به سوال‌ها را جمع می‌کند +5. دوباره همان endpoint را با `answers` و `partial_plan` صدا می‌زند +6. این روند تا ساخته شدن `final_plan` ادامه پیدا می‌کند + +--- + +## الگوی کلی response + +هر دو API از envelope استاندارد استفاده می‌کنند: + +```json +{ + "code": 200, + "msg": "موفق", + "data": {} +} +``` + +### معنی فیلدهای envelope + +| فیلد | نوع | توضیح | +|---|---|---| +| `code` | number | کد منطقی پاسخ | +| `msg` | string | پیام کوتاه پاسخ | +| `data` | object | داده اصلی API | + +--- + +## 1) API استخراج برنامه آبیاری + +### Endpoint + +```http +POST /api/irrigation/plan-from-text/ +``` + +### کاربرد + +این API متن آزاد کاربر درباره برنامه آبیاری را به JSON ساختاریافته تبدیل می‌کند. + +### Request Body + +هر سه فیلد زیر اختیاری هستند، اما حداقل یکی از این‌ها باید ارسال شود: + +- `message` +- `answers` +- `partial_plan` + +#### ساختار request + +```json +{ + "message": "برای گوجه فرنگی با آبیاری قطره ای هر سه روز یک بار صبح زود 25 دقیقه آبیاری می کنم و حدود 18 لیتر برای هر بوته می دهم.", + "answers": { + "growth_stage": "گلدهی" + }, + "partial_plan": { + "crop_name": "گوجه فرنگی", + "irrigation_method": "قطره ای" + }, + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### فیلدهای request + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `message` | string | خیر | متن آزاد کاربر | +| `answers` | object | خیر | پاسخ‌های تکمیلی کاربر به سوال‌هایی که قبلا API داده | +| `partial_plan` | object | خیر | خروجی مرحله قبل برای ادامه تکمیل | +| `farm_uuid` | string | خیر | برای غنی‌سازی context مزرعه در AI | + +### قانون validation + +اگر هیچ‌کدام از `message`، `answers` یا `partial_plan` ارسال نشوند: + +```json +{ + "code": 400, + "msg": "Bad Request", + "data": { + "non_field_errors": [ + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ] + } +} +``` + +--- + +## پاسخ موفق - حالت تکمیل شده + +وقتی همه اطلاعات لازم موجود باشد: + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "status_fa": "تکمیل شد", + "summary": "برنامه آبیاری برای گوجه‌فرنگی به روش قطره‌ای هر سه روز یک‌بار صبح زود به مدت 25 دقیقه اجرا می‌شود.", + "missing_fields": [], + "questions": [], + "collected_data": { + "crop_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_method": "قطره‌ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "frequency_text": "هر سه روز یک‌بار", + "interval_days": 3, + "preferred_time_of_day": "صبح زود", + "start_date": "از امروز", + "target_area": "کل مزرعه", + "trigger_conditions": [], + "notes": [] + }, + "final_plan": { + "crop_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_method": "قطره‌ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "frequency_text": "هر سه روز یک‌بار", + "interval_days": 3, + "preferred_time_of_day": "صبح زود", + "start_date": "از امروز", + "target_area": "کل مزرعه", + "trigger_conditions": [], + "notes": [] + } + } +} +``` + +### فیلدهای `data` + +| فیلد | نوع | توضیح | +|---|---|---| +| `status` | string | یکی از `completed` یا `needs_clarification` | +| `status_fa` | string | نسخه فارسی وضعیت | +| `summary` | string | خلاصه قابل نمایش برای کاربر | +| `missing_fields` | array[string] | فیلدهای ناقص | +| `questions` | array[object] | سوال‌های تکمیلی | +| `collected_data` | object | داده‌ای که تا الان از متن و جواب‌ها استخراج شده | +| `final_plan` | object/null | برنامه نهایی؛ فقط در حالت `completed` | + +### فیلدهای `collected_data` و `final_plan` + +| فیلد | نوع | توضیح | +|---|---|---| +| `crop_name` | string | نام محصول | +| `growth_stage` | string | مرحله رشد محصول | +| `irrigation_method` | string | روش آبیاری | +| `water_amount_per_event` | string | مقدار آب هر نوبت | +| `duration_minutes` | number | مدت هر نوبت آبیاری به دقیقه | +| `frequency_text` | string | توصیف متنی فاصله آبیاری | +| `interval_days` | number | فاصله آبیاری بر حسب روز | +| `preferred_time_of_day` | string | زمان مناسب اجرای آبیاری | +| `start_date` | string | زمان یا تاریخ شروع برنامه | +| `target_area` | string | محدوده هدف برنامه | +| `trigger_conditions` | array[string] | شرایط تریگر اختیاری | +| `notes` | array[string] | نکات تکمیلی | + +--- + +## پاسخ موفق - حالت نیاز به سوال تکمیلی + +اگر اطلاعات کامل نباشد: + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه آبیاری برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": [ + "growth_stage", + "start_date", + "target_area" + ], + "questions": [ + { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای کامل شدن برنامه لازم است." + }, + { + "id": "start_date", + "field": "start_date", + "question": "این برنامه از چه تاریخی یا از چه زمانی باید شروع شود؟", + "rationale": "زمان شروع برنامه هنوز مشخص نشده است." + }, + { + "id": "target_area", + "field": "target_area", + "question": "این برنامه برای کل مزرعه است یا بخش/ناحیه خاصی از مزرعه؟", + "rationale": "محدوده اجرای برنامه باید مشخص باشد." + } + ], + "collected_data": { + "crop_name": "گوجه‌فرنگی", + "growth_stage": null, + "irrigation_method": "قطره‌ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "frequency_text": "هر سه روز یک‌بار", + "interval_days": 3, + "preferred_time_of_day": "صبح زود", + "start_date": null, + "target_area": null, + "trigger_conditions": [], + "notes": [] + }, + "final_plan": null + } +} +``` + +### ساختار `questions` + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | string | شناسه سوال | +| `field` | string | فیلدی که این سوال برای آن پرسیده شده | +| `question` | string | متن سوال برای نمایش به کاربر | +| `rationale` | string | توضیح کوتاه برای اینکه چرا این سوال لازم است | + +--- + +## flow پیشنهادی فرانت‌اند برای آبیاری + +### مرحله 1 + +کاربر متن آزاد می‌فرستد: + +```json +{ + "message": "برای گوجه فرنگی هر سه روز یک بار آبیاری می کنم." +} +``` + +### مرحله 2 + +اگر `status = needs_clarification` بود: + +- سوال‌ها را از `data.questions` به کاربر نمایش بده +- پاسخ‌ها را جمع کن + +### مرحله 3 + +درخواست تکمیلی بزن: + +```json +{ + "partial_plan": { + "crop_name": "گوجه فرنگی", + "growth_stage": null, + "irrigation_method": null, + "water_amount_per_event": null, + "duration_minutes": null, + "frequency_text": "هر سه روز یک بار", + "interval_days": 3, + "preferred_time_of_day": null, + "start_date": null, + "target_area": null, + "trigger_conditions": [], + "notes": [] + }, + "answers": { + "growth_stage": "گلدهی", + "irrigation_method": "قطره ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "preferred_time_of_day": "صبح زود", + "start_date": "از امروز", + "target_area": "کل مزرعه" + } +} +``` + +### مرحله 4 + +اگر `status = completed` شد: + +- از `data.final_plan` به عنوان JSON نهایی استفاده کن + +--- + +## 2) API استخراج برنامه کودهی + +### Endpoint + +```http +POST /api/fertilization/plan-from-text/ +``` + +### کاربرد + +این API متن آزاد کاربر درباره برنامه کودهی را به JSON ساختاریافته تبدیل می‌کند. + +### Request Body + +```json +{ + "message": "برای گندم در مرحله پنجه زنی هر 12 روز یک بار 20-20-20 به مقدار 35 کیلوگرم در هکتار از طریق کودآبیاری می دهم.", + "answers": { + "timing": "هر 12 روز یک بار" + }, + "partial_plan": { + "crop_name": "گندم" + }, + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### فیلدهای request + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `message` | string | خیر | متن آزاد کاربر | +| `answers` | object | خیر | پاسخ‌های تکمیلی کاربر | +| `partial_plan` | object | خیر | داده استخراج شده مرحله قبل | +| `farm_uuid` | string | خیر | برای context مزرعه | + +### validation error + +```json +{ + "code": 400, + "msg": "Bad Request", + "data": { + "non_field_errors": [ + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ] + } +} +``` + +--- + +## پاسخ موفق - حالت تکمیل شده + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "status_fa": "تکمیل شد", + "summary": "برنامه کودهی برای گندم در مرحله پنجه زنی با کود 20-20-20 به صورت کودآبیاری هر 12 روز یک بار اجرا می شود.", + "missing_fields": [], + "questions": [], + "collected_data": { + "crop_name": "گندم", + "growth_stage": "پنجه زنی", + "objective": "تقویت رشد رویشی", + "applications": [ + { + "fertilizer_name": "کود کامل 20-20-20", + "formula": "20-20-20", + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر 12 روز یک بار", + "interval_days": 12, + "purpose": "تقویت رشد رویشی" + } + ], + "notes": [] + }, + "final_plan": { + "crop_name": "گندم", + "growth_stage": "پنجه زنی", + "objective": "تقویت رشد رویشی", + "applications": [ + { + "fertilizer_name": "کود کامل 20-20-20", + "formula": "20-20-20", + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر 12 روز یک بار", + "interval_days": 12, + "purpose": "تقویت رشد رویشی" + } + ], + "notes": [] + } + } +} +``` + +### فیلدهای `collected_data` و `final_plan` + +| فیلد | نوع | توضیح | +|---|---|---| +| `crop_name` | string | نام محصول | +| `growth_stage` | string | مرحله رشد | +| `objective` | string/null | هدف برنامه | +| `applications` | array[object] | لیست نوبت‌ها یا اقلام کودی | +| `notes` | array[string] | نکات تکمیلی | + +### ساختار هر application + +| فیلد | نوع | توضیح | +|---|---|---| +| `fertilizer_name` | string | نام کود | +| `formula` | string | فرمول یا آنالیز کود | +| `amount` | string | مقدار مصرف | +| `application_method` | string | روش مصرف | +| `timing` | string | زمان‌بندی مصرف | +| `interval_days` | number | فاصله بین نوبت‌ها | +| `purpose` | string/null | هدف آن نوبت | + +--- + +## پاسخ موفق - حالت نیاز به سوال تکمیلی + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه کودهی برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": [ + "growth_stage", + "formula", + "interval_days" + ], + "questions": [ + { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای تکمیل برنامه لازم است." + }, + { + "id": "formula", + "field": "formula", + "question": "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20.", + "rationale": "ترکیب دقیق کود هنوز مشخص نشده است." + }, + { + "id": "interval_days", + "field": "interval_days", + "question": "فاصله بین نوبت های مصرف کود چند روز است؟", + "rationale": "عدد فاصله بین نوبت ها برای JSON نهایی لازم است." + } + ], + "collected_data": { + "crop_name": "گندم", + "growth_stage": null, + "objective": null, + "applications": [ + { + "fertilizer_name": "کود کامل", + "formula": null, + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر چند وقت یک بار", + "interval_days": null, + "purpose": null + } + ], + "notes": [] + }, + "final_plan": null + } +} +``` + +--- + +## flow پیشنهادی فرانت‌اند برای کودهی + +### درخواست اولیه + +```json +{ + "message": "برای گندم از کود کامل استفاده می کنم." +} +``` + +### اگر incomplete بود + +- از `questions` سوال‌ها را بگیر +- در UI نمایش بده +- پاسخ‌ها را جمع کن + +### درخواست تکمیلی + +```json +{ + "partial_plan": { + "crop_name": "گندم", + "growth_stage": null, + "objective": null, + "applications": [ + { + "fertilizer_name": "کود کامل", + "formula": null, + "amount": null, + "application_method": null, + "timing": null, + "interval_days": null, + "purpose": null + } + ], + "notes": [] + }, + "answers": { + "growth_stage": "پنجه زنی", + "formula": "20-20-20", + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر 12 روز یک بار", + "interval_days": 12 + } +} +``` + +### اگر complete شد + +- از `final_plan` استفاده کن + +--- + +## نکات مهم برای فرانت‌اند + +### 1. به `status` تکیه کنید + +مهم‌ترین فیلد برای کنترل flow: + +- `completed` +- `needs_clarification` + +### 2. اگر `needs_clarification` بود + +باید: + +- `questions` را به کاربر نمایش دهید +- `partial_plan` را نگه دارید +- پاسخ‌های کاربر را در `answers` ارسال کنید + +### 3. اگر `completed` بود + +باید: + +- `final_plan` را به عنوان نسخه نهایی برنامه ذخیره یا نمایش دهید + +### 4. `collected_data` همیشه مهم است + +حتی اگر برنامه ناقص باشد، `collected_data` نشان می‌دهد سیستم تا این لحظه چه چیزهایی را فهمیده است. + +### 5. null در حالت ناقص طبیعی است + +در حالت `needs_clarification` ممکن است بعضی فیلدهای `collected_data` `null` باشند. +اما در حالت `completed` نباید فیلدهای اصلی ناقص باشند. + +### 6. بهتر است سوال‌ها را step-by-step بپرسید + +پیشنهاد: + +- سوال اول را نشان بده +- جواب را بگیر +- همه جواب‌ها را در `answers` جمع کن +- دوباره API را صدا بزن + +--- + +## جمع‌بندی تفاوت دو API + +| API | موضوع | خروجی نهایی | +|---|---|---| +| `/api/irrigation/plan-from-text/` | استخراج برنامه آبیاری | `final_plan` با ساختار آبیاری | +| `/api/fertilization/plan-from-text/` | استخراج برنامه کودهی | `final_plan` با ساختار کودهی | + +--- + +## مسیر فایل + +این داکیومنت در این مسیر ذخیره شده: + +`docs/irrigation_fertilization_plan_parser_apis.md` diff --git a/fertilization_recommendation/apps.py b/fertilization_recommendation/apps.py index d8e6746..d2bed93 100644 --- a/fertilization_recommendation/apps.py +++ b/fertilization_recommendation/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class FertilizationRecommendationConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "fertilization_recommendation" - verbose_name = "Fertilization Recommendation" + verbose_name = "Fertilization Recommendation & Plan Parser" diff --git a/fertilization_recommendation/serializers.py b/fertilization_recommendation/serializers.py index 667427d..dd101f8 100644 --- a/fertilization_recommendation/serializers.py +++ b/fertilization_recommendation/serializers.py @@ -116,6 +116,45 @@ class FertilizationRecommendationListItemSerializer(serializers.Serializer): requested_at = serializers.DateTimeField(source="created_at", read_only=True) +class FreeTextPlanParserRequestSerializer(serializers.Serializer): + message = serializers.CharField(required=False, allow_blank=True, help_text="متن آزاد کاربر.") + answers = serializers.DictField(required=False, help_text="پاسخ های تکمیلی کاربر.") + partial_plan = serializers.DictField(required=False, help_text="داده استخراج شده از مرحله قبل.") + farm_uuid = serializers.UUIDField( + required=False, + allow_null=True, + initial="11111111-1111-1111-1111-111111111111", + help_text="UUID مزرعه برای context اختیاری.", + ) + + def validate(self, attrs): + has_message = bool((attrs.get("message") or "").strip()) + has_answers = isinstance(attrs.get("answers"), dict) and bool(attrs.get("answers")) + has_partial_plan = isinstance(attrs.get("partial_plan"), dict) and bool(attrs.get("partial_plan")) + if not (has_message or has_answers or has_partial_plan): + raise serializers.ValidationError( + {"non_field_errors": ["حداقل یکی از message، answers یا partial_plan باید ارسال شود."]} + ) + return attrs + + +class PlanParserQuestionSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + field = serializers.CharField(required=False, allow_blank=True) + question = serializers.CharField(required=False, allow_blank=True) + rationale = serializers.CharField(required=False, allow_blank=True) + + +class FreeTextPlanParserResponseDataSerializer(serializers.Serializer): + status = serializers.CharField(required=False, allow_blank=True) + status_fa = serializers.CharField(required=False, allow_blank=True) + summary = serializers.CharField(required=False, allow_blank=True) + missing_fields = serializers.ListField(child=serializers.CharField(), required=False) + questions = PlanParserQuestionSerializer(many=True, required=False) + collected_data = serializers.DictField(required=False) + final_plan = serializers.DictField(required=False, allow_null=True) + + class FertilizationRecommendResponseDataSerializer(serializers.Serializer): recommendation_uuid = serializers.UUIDField(read_only=True, required=False) crop_id = serializers.CharField(read_only=True, required=False, allow_blank=True) diff --git a/fertilization_recommendation/tests.py b/fertilization_recommendation/tests.py index 38a186f..634be6c 100644 --- a/fertilization_recommendation/tests.py +++ b/fertilization_recommendation/tests.py @@ -6,7 +6,7 @@ from unittest.mock import patch from external_api_adapter.adapter import AdapterResponse from farm_hub.models import FarmHub, FarmType from .models import FertilizationRecommendationRequest -from .views import RecommendationDetailView, RecommendationListView, RecommendView +from .views import PlanFromTextView, RecommendationDetailView, RecommendationListView, RecommendView class FertilizationRecommendViewTests(TestCase): @@ -21,6 +21,52 @@ class FertilizationRecommendViewTests(TestCase): self.farm_type = FarmType.objects.create(name="زراعی") self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="fert-farm") + @patch("fertilization_recommendation.views.external_api_request") + def test_plan_from_text_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "code": 200, + "msg": "موفق", + "data": { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "need more", + "missing_fields": ["growth_stage"], + "questions": [{"id": "growth_stage", "field": "growth_stage", "question": "?", "rationale": "!"}], + "collected_data": {"crop_name": "گندم"}, + "final_plan": None, + }, + }, + ) + + request = self.factory.post( + "/api/fertilization/plan-from-text/", + {"message": "متن کودهی", "farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["status"], "needs_clarification") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/fertilization/plan-from-text/", + method="POST", + payload={"message": "متن کودهی", "farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_plan_from_text_requires_message_or_answers_or_partial_plan(self): + request = self.factory.post("/api/fertilization/plan-from-text/", {}, format="json") + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("non_field_errors", response.data) + @patch("fertilization_recommendation.views.external_api_request") def test_recommend_returns_updated_response_shape(self, mock_external_api_request): mock_external_api_request.return_value = AdapterResponse( diff --git a/fertilization_recommendation/urls.py b/fertilization_recommendation/urls.py index cef3db7..97c0930 100644 --- a/fertilization_recommendation/urls.py +++ b/fertilization_recommendation/urls.py @@ -1,10 +1,11 @@ from django.urls import path -from .views import ConfigView, RecommendationDetailView, RecommendationListView, RecommendView +from .views import ConfigView, PlanFromTextView, RecommendationDetailView, RecommendationListView, RecommendView urlpatterns = [ path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"), path("recommendations//", RecommendationDetailView.as_view(), name="fertilization-recommendation-detail"), path("recommendations/", RecommendationListView.as_view(), name="fertilization-recommendation-list"), path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"), + path("plan-from-text/", PlanFromTextView.as_view(), name="fertilization-plan-from-text"), ] diff --git a/fertilization_recommendation/views.py b/fertilization_recommendation/views.py index d549be8..76fa876 100644 --- a/fertilization_recommendation/views.py +++ b/fertilization_recommendation/views.py @@ -18,6 +18,8 @@ from farm_hub.models import FarmHub from .mock_data import CONFIG_RESPONSE_DATA from .models import FertilizationRecommendationRequest from .serializers import ( + FreeTextPlanParserRequestSerializer, + FreeTextPlanParserResponseDataSerializer, FertilizationRecommendationListItemSerializer, FertilizationRecommendationListQuerySerializer, FertilizationRecommendRequestSerializer, @@ -485,3 +487,39 @@ class RecommendationDetailView(FarmAccessMixin, APIView): data["status"] = recommendation.status data["status_label"] = recommendation.get_status_display() return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class PlanFromTextView(FarmAccessMixin, APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + request=FreeTextPlanParserRequestSerializer, + responses={200: code_response("FertilizationPlanFromTextResponse", data=FreeTextPlanParserResponseDataSerializer())}, + ) + def post(self, request): + serializer = FreeTextPlanParserRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm_uuid = payload.get("farm_uuid") + if farm_uuid: + farm = self._get_farm(request, farm_uuid) + payload["farm_uuid"] = str(farm.farm_uuid) + + adapter_response = external_api_request( + "ai", + "/api/fertilization/plan-from-text/", + method="POST", + payload=payload, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + if adapter_response.status_code >= 400: + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + return Response( + {"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)}, + status=status.HTTP_200_OK, + ) diff --git a/irrigation_recommendation/apps.py b/irrigation_recommendation/apps.py index 34dad2b..6be1dcd 100644 --- a/irrigation_recommendation/apps.py +++ b/irrigation_recommendation/apps.py @@ -4,4 +4,4 @@ from django.apps import AppConfig class IrrigationRecommendationConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "irrigation_recommendation" - verbose_name = "Irrigation Recommendation" + verbose_name = "Irrigation Recommendation & Plan Parser" diff --git a/irrigation_recommendation/serializers.py b/irrigation_recommendation/serializers.py index a0c5eae..18d158e 100644 --- a/irrigation_recommendation/serializers.py +++ b/irrigation_recommendation/serializers.py @@ -66,6 +66,45 @@ class IrrigationRecommendationListItemSerializer(serializers.Serializer): requested_at = serializers.DateTimeField(source="created_at", read_only=True) +class FreeTextPlanParserRequestSerializer(serializers.Serializer): + message = serializers.CharField(required=False, allow_blank=True, help_text="متن آزاد کاربر.") + answers = serializers.DictField(required=False, help_text="پاسخ های تکمیلی کاربر.") + partial_plan = serializers.DictField(required=False, help_text="داده استخراج شده از مرحله قبل.") + farm_uuid = serializers.UUIDField( + required=False, + allow_null=True, + initial="11111111-1111-1111-1111-111111111111", + help_text="UUID مزرعه برای context اختیاری.", + ) + + def validate(self, attrs): + has_message = bool((attrs.get("message") or "").strip()) + has_answers = isinstance(attrs.get("answers"), dict) and bool(attrs.get("answers")) + has_partial_plan = isinstance(attrs.get("partial_plan"), dict) and bool(attrs.get("partial_plan")) + if not (has_message or has_answers or has_partial_plan): + raise serializers.ValidationError( + {"non_field_errors": ["حداقل یکی از message، answers یا partial_plan باید ارسال شود."]} + ) + return attrs + + +class PlanParserQuestionSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + field = serializers.CharField(required=False, allow_blank=True) + question = serializers.CharField(required=False, allow_blank=True) + rationale = serializers.CharField(required=False, allow_blank=True) + + +class FreeTextPlanParserResponseDataSerializer(serializers.Serializer): + status = serializers.CharField(required=False, allow_blank=True) + status_fa = serializers.CharField(required=False, allow_blank=True) + summary = serializers.CharField(required=False, allow_blank=True) + missing_fields = serializers.ListField(child=serializers.CharField(), required=False) + questions = PlanParserQuestionSerializer(many=True, required=False) + collected_data = serializers.DictField(required=False) + final_plan = serializers.DictField(required=False, allow_null=True) + + class IrrigationRecommendResponseDataSerializer(serializers.Serializer): recommendation_uuid = serializers.UUIDField(read_only=True, required=False) crop_id = serializers.CharField(read_only=True, required=False, allow_blank=True) diff --git a/irrigation_recommendation/tests.py b/irrigation_recommendation/tests.py index 557cc2b..f90d5be 100644 --- a/irrigation_recommendation/tests.py +++ b/irrigation_recommendation/tests.py @@ -10,6 +10,7 @@ from farm_hub.models import FarmHub, FarmType from .models import IrrigationRecommendationRequest from .views import ( IrrigationMethodListView, + PlanFromTextView, RecommendView, RecommendationDetailView, RecommendationListView, @@ -89,6 +90,65 @@ class WaterStressViewTests(TestCase): self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") +class IrrigationPlanFromTextViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="plan-parser-user", + password="secret123", + email="plan-parser@example.com", + phone_number="09120000005", + ) + self.farm_type = FarmType.objects.create(name="گلخانه ای") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Plan Parser Farm") + + @patch("irrigation_recommendation.views.external_api_request") + def test_plan_from_text_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "status_fa": "تکمیل شد", + "summary": "done", + "missing_fields": [], + "questions": [], + "collected_data": {"crop_name": "گوجه فرنگی"}, + "final_plan": {"crop_name": "گوجه فرنگی"}, + }, + }, + ) + + request = self.factory.post( + "/api/irrigation/plan-from-text/", + {"message": "متن برنامه", "farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["status"], "completed") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/plan-from-text/", + method="POST", + payload={"message": "متن برنامه", "farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_plan_from_text_requires_message_or_answers_or_partial_plan(self): + request = self.factory.post("/api/irrigation/plan-from-text/", {}, format="json") + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("non_field_errors", response.data) + + class IrrigationMethodListViewTests(TestCase): def setUp(self): self.factory = APIRequestFactory() diff --git a/irrigation_recommendation/urls.py b/irrigation_recommendation/urls.py index 36223eb..5ffaa4a 100644 --- a/irrigation_recommendation/urls.py +++ b/irrigation_recommendation/urls.py @@ -3,6 +3,7 @@ from django.urls import path from .views import ( ConfigView, IrrigationMethodListView, + PlanFromTextView, RecommendationDetailView, RecommendationListView, RecommendView, @@ -15,5 +16,6 @@ urlpatterns = [ path("recommendations//", RecommendationDetailView.as_view(), name="irrigation-recommendation-detail"), path("recommendations/", RecommendationListView.as_view(), name="irrigation-recommendation-list"), path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"), + path("plan-from-text/", PlanFromTextView.as_view(), name="irrigation-plan-from-text"), path("water-stress/", WaterStressView.as_view(), name="irrigation-water-stress"), ] diff --git a/irrigation_recommendation/views.py b/irrigation_recommendation/views.py index 2d2dbe2..ed5bc3c 100644 --- a/irrigation_recommendation/views.py +++ b/irrigation_recommendation/views.py @@ -20,6 +20,8 @@ from water.views import WaterStressIndexView from .mock_data import CONFIG_RESPONSE_DATA from .models import IrrigationRecommendationRequest from .serializers import ( + FreeTextPlanParserRequestSerializer, + FreeTextPlanParserResponseDataSerializer, IrrigationMethodSerializer, IrrigationRecommendationListItemSerializer, IrrigationRecommendationListQuerySerializer, @@ -353,3 +355,39 @@ class WaterStressView(APIView): {"code": 200, "msg": "success", "data": stress_payload}, status=status.HTTP_200_OK, ) + + +class PlanFromTextView(FarmAccessMixin, APIView): + @extend_schema( + tags=["Irrigation Recommendation"], + request=FreeTextPlanParserRequestSerializer, + responses={200: code_response("IrrigationPlanFromTextResponse", data=FreeTextPlanParserResponseDataSerializer())}, + ) + def post(self, request): + serializer = FreeTextPlanParserRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm_uuid = payload.get("farm_uuid") + if farm_uuid: + farm = self._get_farm(request, farm_uuid) + payload["farm_uuid"] = str(farm.farm_uuid) + + adapter_response = external_api_request( + "ai", + "/api/irrigation/plan-from-text/", + method="POST", + payload=payload, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + if adapter_response.status_code >= 400: + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + return Response( + {"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)}, + status=status.HTTP_200_OK, + )