From f704e1188cc675864ded089373011ec9ad4b63d0 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Thu, 30 Apr 2026 03:25:31 +0330 Subject: [PATCH] UPDATE --- .../fertilization_plan_parser_knowledge.txt | 28 + .../irrigation_plan_parser_knowledge.txt | 28 + config/rag_config.yaml | 38 ++ .../tones/fertilization_plan_parser_tone.txt | 93 +++ config/tones/irrigation_plan_parser_tone.txt | 87 +++ ...rigation_fertilization_plan_parser_apis.md | 619 ++++++++++++++++++ fertilization/apps.py | 9 + fertilization/serializers.py | 34 + fertilization/urls.py | 3 +- fertilization/views.py | 85 +++ irrigation/apps.py | 9 + irrigation/serializers.py | 34 + irrigation/urls.py | 2 + irrigation/views.py | 83 +++ rag/services/__init__.py | 4 + rag/services/fertilization_plan_parser.py | 398 +++++++++++ rag/services/irrigation_plan_parser.py | 397 +++++++++++ 17 files changed, 1950 insertions(+), 1 deletion(-) create mode 100644 config/knowledge_base/fertilization_plan_parser/fertilization_plan_parser_knowledge.txt create mode 100644 config/knowledge_base/irrigation_plan_parser/irrigation_plan_parser_knowledge.txt create mode 100644 config/tones/fertilization_plan_parser_tone.txt create mode 100644 config/tones/irrigation_plan_parser_tone.txt create mode 100644 docs/irrigation_fertilization_plan_parser_apis.md create mode 100644 rag/services/fertilization_plan_parser.py create mode 100644 rag/services/irrigation_plan_parser.py diff --git a/config/knowledge_base/fertilization_plan_parser/fertilization_plan_parser_knowledge.txt b/config/knowledge_base/fertilization_plan_parser/fertilization_plan_parser_knowledge.txt new file mode 100644 index 0000000..70d9512 --- /dev/null +++ b/config/knowledge_base/fertilization_plan_parser/fertilization_plan_parser_knowledge.txt @@ -0,0 +1,28 @@ +راهنمای استخراج برنامه کودهی از متن آزاد + +هدف: +تبدیل توضیح متنی کشاورز درباره برنامه کودهی به JSON ساختاریافته. + +اطلاعات کلیدی که معمولا باید استخراج شوند: +- نام محصول +- مرحله رشد +- هدف مصرف +- نام یا فرمول کود +- مقدار مصرف +- روش مصرف +- زمان مصرف +- فاصله بین نوبت ها +- توضیح تکمیلی یا هشدار + +نمونه عبارت های رایج: +- هر 10 روز یک بار +- بعد از آبیاری +- به صورت کودآبیاری +- سرک +- محلول پاشی +- 35 کیلوگرم در هکتار +- 20-20-20 +- برای تقویت رشد رویشی +- برای شروع گلدهی + +اگر متن ناقص بود، باید فقط سوال های لازم برای تکمیل برنامه نهایی پرسیده شود و از حدس زدن خودداری شود. diff --git a/config/knowledge_base/irrigation_plan_parser/irrigation_plan_parser_knowledge.txt b/config/knowledge_base/irrigation_plan_parser/irrigation_plan_parser_knowledge.txt new file mode 100644 index 0000000..0ab5e40 --- /dev/null +++ b/config/knowledge_base/irrigation_plan_parser/irrigation_plan_parser_knowledge.txt @@ -0,0 +1,28 @@ +راهنمای استخراج برنامه آبیاری از متن آزاد + +هدف: +تبدیل توضیح متنی کشاورز درباره برنامه آبیاری به JSON ساختاریافته. + +اطلاعات کلیدی که معمولا باید استخراج شوند: +- نام محصول +- مرحله رشد +- روش آبیاری +- مقدار آب در هر نوبت +- مدت زمان هر نوبت +- فاصله یا تعداد دفعات آبیاری +- زمان مناسب اجرا در روز +- تاریخ شروع یا شرایط شروع +- ناحیه یا سطح هدف +- نکات تکمیلی + +نمونه عبارت های رایج: +- هر سه روز یک بار +- هفته ای دو نوبت +- صبح زود +- بعد از غروب +- 20 لیتر برای هر بوته +- 25 دقیقه +- فقط در ردیف های جنوبی +- اگر هوا خیلی گرم شد یک نوبت اضافه شود + +اگر متن ناقص بود، باید فقط درباره اطلاعاتی سوال شود که برای ساخت برنامه قابل استفاده لازم هستند. diff --git a/config/rag_config.yaml b/config/rag_config.yaml index 523ae29..ef9dc8a 100644 --- a/config/rag_config.yaml +++ b/config/rag_config.yaml @@ -51,6 +51,16 @@ knowledge_bases: tone_file: "config/tones/fertilization_tone.txt" description: "پایگاه دانش توصیه کودهی" + irrigation_plan_parser: + path: "config/knowledge_base/irrigation_plan_parser" + tone_file: "config/tones/irrigation_plan_parser_tone.txt" + description: "پایگاه دانش استخراج برنامه آبیاری از متن آزاد کاربر" + + fertilization_plan_parser: + path: "config/knowledge_base/fertilization_plan_parser" + tone_file: "config/tones/fertilization_plan_parser_tone.txt" + description: "پایگاه دانش استخراج برنامه کودهی از متن آزاد کاربر" + farm_alerts: path: "config/knowledge_base/farm_alerts" tone_file: "config/tones/farm_alerts_tone.txt" @@ -130,6 +140,34 @@ services: avalai_base_url: "https://api.avalai.ir/v1" avalai_api_key_env: "AVALAI_API_KEY" + irrigation_plan_parser: + knowledge_base: "irrigation_plan_parser" + tone_file: "config/tones/irrigation_plan_parser_tone.txt" + use_user_embeddings: false + description: "سرویس استخراج برنامه آبیاری از متن کاربر" + system_prompt: "Only return valid JSON for irrigation plan extraction and clarification." + llm: + provider: "gapgpt" + model: "gpt-4o" + base_url: "https://api.gapgpt.app/v1" + api_key_env: "GAPGPT_API_KEY" + avalai_base_url: "https://api.avalai.ir/v1" + avalai_api_key_env: "AVALAI_API_KEY" + + fertilization_plan_parser: + knowledge_base: "fertilization_plan_parser" + tone_file: "config/tones/fertilization_plan_parser_tone.txt" + use_user_embeddings: false + description: "سرویس استخراج برنامه کودهی از متن کاربر" + system_prompt: "Only return valid JSON for fertilization plan extraction and clarification." + llm: + provider: "gapgpt" + model: "gpt-4o" + base_url: "https://api.gapgpt.app/v1" + api_key_env: "GAPGPT_API_KEY" + avalai_base_url: "https://api.avalai.ir/v1" + avalai_api_key_env: "AVALAI_API_KEY" + farm_alerts: knowledge_base: "farm_alerts" tone_file: "config/tones/farm_alerts_tone.txt" diff --git a/config/tones/fertilization_plan_parser_tone.txt b/config/tones/fertilization_plan_parser_tone.txt new file mode 100644 index 0000000..a2905f3 --- /dev/null +++ b/config/tones/fertilization_plan_parser_tone.txt @@ -0,0 +1,93 @@ +شما یک دستیار دقیق برای استخراج برنامه کودهی از متن آزاد کشاورز هستید. + +هدف: +- متن آزاد کاربر را به JSON ساختاریافته برنامه کودهی تبدیل کن. +- اگر هر بخش مهمی ناقص بود، به جای حدس زدن سوال بپرس. + +قواعد قطعی: +- فقط و فقط JSON معتبر برگردان. +- هیچ متن اضافه، markdown، توضیح بیرون از JSON، کدبلاک یا کلید اضافه تولید نکن. +- اگر حتی یکی از فیلدهای اصلی خالی، null، نامشخص یا مبهم بود باید `status` برابر `needs_clarification` باشد. +- در حالت `completed` هیچ فیلد null یا رشته خالی در `collected_data` و `final_plan` نباید وجود داشته باشد. +- اگر متن ناقص بود، سوال ها باید کوتاه، روشن، غیرتکراری و کاملا کاربردی باشند. +- از حدس زدن نام کود، فرمول، مقدار، روش مصرف، زمان مصرف، فاصله بین نوبت ها یا مرحله رشد خودداری کن. +- اگر کاربر `answers` و `partial_plan` داده باشد، اول آن ها را با متن جدید ادغام کن و فقط سوال های باقی مانده را بپرس. +- اگر چند کود در متن آمده بود، همه را در `applications` لیست کن. +- زبان همه `summary`، `question` و `rationale` ها فارسی باشد. + +فیلدهای اصلی که برای تکمیل برنامه لازم هستند: +- `crop_name` +- `growth_stage` +- `fertilizer_name` +- `formula` +- `amount` +- `application_method` +- `timing` +- `interval_days` + +ساختار دقیق JSON خروجی: +{ + "status": "completed" | "needs_clarification", + "summary": "string", + "missing_fields": ["string"], + "questions": [ + { + "id": "string", + "field": "string", + "question": "string", + "rationale": "string" + } + ], + "collected_data": { + "crop_name": "string|null", + "growth_stage": "string|null", + "objective": "string|null", + "applications": [ + { + "fertilizer_name": "string|null", + "formula": "string|null", + "amount": "string|null", + "application_method": "string|null", + "timing": "string|null", + "interval_days": "integer|null", + "purpose": "string|null" + } + ], + "notes": ["string"] + }, + "final_plan": { + "crop_name": "string", + "growth_stage": "string", + "objective": "string|null", + "applications": [ + { + "fertilizer_name": "string", + "formula": "string", + "amount": "string", + "application_method": "string", + "timing": "string", + "interval_days": "integer", + "purpose": "string|null" + } + ], + "notes": ["string"] + } | null +} + +منطق وضعیت: +- اگر همه فیلدهای اصلی کامل بودند: + - `status = "completed"` + - `missing_fields = []` + - `questions = []` + - `final_plan` باید کامل و بدون null در فیلدهای اصلی باشد +- اگر حتی یکی از فیلدهای اصلی ناقص بود: + - `status = "needs_clarification"` + - `missing_fields` فقط فیلدهای ناقص را شامل شود + - `questions` برای همان فیلدهای ناقص ساخته شود + - `final_plan = null` + +نمونه سوال خوب: +- "محصول الان در چه مرحله رشدی قرار دارد؟" +- "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20." +- "مقدار مصرف هر نوبت کود چقدر است؟" +- "فاصله بین نوبت های مصرف کود چند روز است؟" diff --git a/config/tones/irrigation_plan_parser_tone.txt b/config/tones/irrigation_plan_parser_tone.txt new file mode 100644 index 0000000..6be19e1 --- /dev/null +++ b/config/tones/irrigation_plan_parser_tone.txt @@ -0,0 +1,87 @@ +شما یک دستیار دقیق برای استخراج برنامه آبیاری از متن آزاد کشاورز هستید. + +هدف: +- متن آزاد کاربر را به JSON ساختاریافته برنامه آبیاری تبدیل کن. +- اگر هر بخش مهمی ناقص بود، به جای حدس زدن سوال بپرس. + +قواعد قطعی: +- فقط و فقط JSON معتبر برگردان. +- هیچ متن اضافه، markdown، توضیح بیرون از JSON، کدبلاک یا کلید اضافه تولید نکن. +- اگر حتی یکی از فیلدهای اصلی خالی، null، نامشخص یا مبهم بود باید `status` برابر `needs_clarification` باشد. +- در حالت `completed` هیچ فیلد null یا رشته خالی در `collected_data` و `final_plan` نباید وجود داشته باشد. +- اگر متن ناقص بود، سوال ها باید کوتاه، روشن، غیرتکراری و کاملا کاربردی باشند. +- از حدس زدن مقدار آب، مدت زمان، فاصله آبیاری، زمان اجرا، مرحله رشد، تاریخ شروع یا محدوده هدف خودداری کن. +- اگر کاربر `answers` و `partial_plan` داده باشد، اول آن ها را با متن جدید ادغام کن و فقط سوال های باقی مانده را بپرس. +- زبان همه `summary`، `question` و `rationale` ها فارسی باشد. + +فیلدهای اصلی که برای تکمیل برنامه لازم هستند: +- `crop_name` +- `growth_stage` +- `irrigation_method` +- `water_amount_per_event` +- `duration_minutes` +- `frequency_text` +- `interval_days` +- `preferred_time_of_day` +- `start_date` +- `target_area` + +ساختار دقیق JSON خروجی: +{ + "status": "completed" | "needs_clarification", + "summary": "string", + "missing_fields": ["string"], + "questions": [ + { + "id": "string", + "field": "string", + "question": "string", + "rationale": "string" + } + ], + "collected_data": { + "crop_name": "string|null", + "growth_stage": "string|null", + "irrigation_method": "string|null", + "water_amount_per_event": "string|null", + "duration_minutes": "integer|null", + "frequency_text": "string|null", + "interval_days": "integer|null", + "preferred_time_of_day": "string|null", + "start_date": "string|null", + "target_area": "string|null", + "trigger_conditions": ["string"], + "notes": ["string"] + }, + "final_plan": { + "crop_name": "string", + "growth_stage": "string", + "irrigation_method": "string", + "water_amount_per_event": "string", + "duration_minutes": "integer", + "frequency_text": "string", + "interval_days": "integer", + "preferred_time_of_day": "string", + "start_date": "string", + "target_area": "string", + "trigger_conditions": ["string"], + "notes": ["string"] + } | null +} + +منطق وضعیت: +- اگر همه فیلدهای اصلی کامل بودند: + - `status = "completed"` + - `missing_fields = []` + - `questions = []` + - `final_plan` باید کامل و بدون null باشد +- اگر حتی یکی از فیلدهای اصلی ناقص بود: + - `status = "needs_clarification"` + - `missing_fields` فقط فیلدهای ناقص را شامل شود + - `questions` برای همان فیلدهای ناقص ساخته شود + - `final_plan = null` + +نمونه سوال خوب: +- "محصول الان در چه مرحله رشدی قرار دارد؟" +- "این برنامه از چه تاریخی باید شروع شود؟" +- "این برنامه برای کل مزرعه است یا فقط یک بخش خاص؟" diff --git a/docs/irrigation_fertilization_plan_parser_apis.md b/docs/irrigation_fertilization_plan_parser_apis.md new file mode 100644 index 0000000..ab9ed49 --- /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: + +- کاربر یک متن آزاد می‌نویسد +- بک‌اند تلاش می‌کند برنامه آبیاری یا کودهی را به 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 مزرعه در RAG | + +### قانون validation + +اگر هیچ‌کدام از `message`، `answers` یا `partial_plan` ارسال نشوند: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "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": "داده نامعتبر.", + "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/apps.py b/fertilization/apps.py index c29d8ce..886cd6d 100644 --- a/fertilization/apps.py +++ b/fertilization/apps.py @@ -84,3 +84,12 @@ class FertilizationConfig(AppConfig): def get_optimizer_defaults(self): return self.optimizer_defaults + + @cached_property + def free_text_plan_parser_service(self): + from rag.services.fertilization_plan_parser import FertilizationPlanParserService + + return FertilizationPlanParserService() + + def get_free_text_plan_parser_service(self): + return self.free_text_plan_parser_service diff --git a/fertilization/serializers.py b/fertilization/serializers.py index c789a8a..4a69763 100644 --- a/fertilization/serializers.py +++ b/fertilization/serializers.py @@ -115,3 +115,37 @@ class FertilizationRecommendationResponseDataSerializer(serializers.Serializer): application_guide = FertilizationApplicationGuideSerializer() alternative_recommendations = AlternativeFertilizationRecommendationSerializer(many=True) sections = FertilizationSectionSerializer(many=True, required=False) + + +class FertilizationPlanParserRequestSerializer(serializers.Serializer): + message = serializers.CharField(required=False, allow_blank=True, help_text="توضیح آزاد کاربر درباره برنامه کودهی") + answers = serializers.JSONField(required=False, help_text="پاسخ های تکمیلی کاربر به سوالات مرحله قبل") + partial_plan = serializers.JSONField(required=False, help_text="داده استخراج شده مرحله قبل برای ادامه تکمیل") + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="شناسه مزرعه برای غنی سازی context") + + def validate(self, attrs): + message = (attrs.get("message") or "").strip() + answers = attrs.get("answers") + partial_plan = attrs.get("partial_plan") + if not message and not isinstance(answers, dict) and not isinstance(partial_plan, dict): + raise serializers.ValidationError( + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ) + return attrs + + +class PlanClarificationQuestionSerializer(serializers.Serializer): + id = serializers.CharField() + field = serializers.CharField() + question = serializers.CharField() + rationale = serializers.CharField(required=False, allow_blank=True) + + +class FertilizationPlanParserResponseSerializer(serializers.Serializer): + status = serializers.CharField() + status_fa = serializers.CharField() + summary = serializers.CharField() + missing_fields = serializers.ListField(child=serializers.CharField()) + questions = PlanClarificationQuestionSerializer(many=True) + collected_data = serializers.JSONField() + final_plan = serializers.JSONField(required=False, allow_null=True) diff --git a/fertilization/urls.py b/fertilization/urls.py index 0b2d0d1..ba01118 100644 --- a/fertilization/urls.py +++ b/fertilization/urls.py @@ -1,7 +1,8 @@ from django.urls import path -from .views import FertilizationRecommendView +from .views import FertilizationPlanParserView, FertilizationRecommendView urlpatterns = [ path("recommend/", FertilizationRecommendView.as_view(), name="fertilization-recommend"), + path("plan-from-text/", FertilizationPlanParserView.as_view(), name="fertilization-plan-from-text"), ] diff --git a/fertilization/views.py b/fertilization/views.py index 294c4ad..99c6d21 100644 --- a/fertilization/views.py +++ b/fertilization/views.py @@ -1,3 +1,5 @@ +from django.apps import apps + from drf_spectacular.utils import OpenApiExample, extend_schema from rest_framework import status from rest_framework.response import Response @@ -6,6 +8,8 @@ from rest_framework.views import APIView from config.openapi import build_envelope_serializer, build_response from .serializers import ( + FertilizationPlanParserRequestSerializer, + FertilizationPlanParserResponseSerializer, FertilizationRecommendationResponseDataSerializer, FertilizationRecommendRequestSerializer, ) @@ -20,6 +24,10 @@ FertilizationResponseSerializer = build_envelope_serializer( "FertilizationResponseSerializer", data_schema=FertilizationRecommendationResponseDataSerializer, ) +FertilizationPlanParserEnvelopeSerializer = build_envelope_serializer( + "FertilizationPlanParserEnvelopeSerializer", + data_schema=FertilizationPlanParserResponseSerializer, +) class FertilizationRecommendView(APIView): @@ -147,3 +155,80 @@ class FertilizationRecommendView(APIView): {"code": 200, "msg": "success", "data": final_result}, status=status.HTTP_200_OK, ) + + +class FertilizationPlanParserView(APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + summary="استخراج برنامه کودهی از متن آزاد", + description=( + "توضیح متنی کاربر درباره برنامه کودهی را می گیرد و آن را به JSON ساختاریافته تبدیل می کند. " + "اگر اطلاعات کافی نباشد، سوالات تکمیلی لازم را برمی گرداند تا در درخواست بعدی پاسخ داده شوند." + ), + request=FertilizationPlanParserRequestSerializer, + responses={ + 200: build_response( + FertilizationPlanParserEnvelopeSerializer, + "نتیجه استخراج یا سوالات تکمیلی برنامه کودهی.", + ), + 400: build_response( + FertilizationValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 500: build_response( + FertilizationValidationErrorSerializer, + "خطا در پردازش برنامه کودهی.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست کامل", + value={ + "message": "برای گندم در مرحله پنجه زنی هر 12 روز یک بار 20-20-20 به مقدار 35 کیلوگرم در هکتار از طریق کودآبیاری می دهم.", + "farm_uuid": "11111111-1111-1111-1111-111111111111", + }, + request_only=True, + ), + OpenApiExample( + "نمونه درخواست تکمیلی", + value={ + "partial_plan": { + "crop_name": "گندم", + "applications": [{"fertilizer_name": "20-20-20"}], + }, + "answers": { + "amount": "35 کیلوگرم در هکتار", + "timing": "هر 12 روز یک بار", + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = FertilizationPlanParserRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + validated = serializer.validated_data + service = apps.get_app_config("fertilization").get_free_text_plan_parser_service() + try: + result = service.parse_plan( + message=validated.get("message", ""), + answers=validated.get("answers"), + partial_plan=validated.get("partial_plan"), + farm_uuid=validated.get("farm_uuid"), + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در پردازش برنامه کودهی: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + {"code": 200, "msg": "موفق", "data": result}, + status=status.HTTP_200_OK, + ) diff --git a/irrigation/apps.py b/irrigation/apps.py index 45e106b..1c22ec0 100644 --- a/irrigation/apps.py +++ b/irrigation/apps.py @@ -58,3 +58,12 @@ class IrrigationConfig(AppConfig): def get_water_stress_service(self): return self.water_stress_service + + @cached_property + def free_text_plan_parser_service(self): + from rag.services.irrigation_plan_parser import IrrigationPlanParserService + + return IrrigationPlanParserService() + + def get_free_text_plan_parser_service(self): + return self.free_text_plan_parser_service diff --git a/irrigation/serializers.py b/irrigation/serializers.py index 38098a6..f0fd5a1 100644 --- a/irrigation/serializers.py +++ b/irrigation/serializers.py @@ -80,3 +80,37 @@ class WaterStressResponseSerializer(serializers.Serializer): waterStressIndex = serializers.IntegerField() level = serializers.CharField() sourceMetric = serializers.JSONField() + + +class IrrigationPlanParserRequestSerializer(serializers.Serializer): + message = serializers.CharField(required=False, allow_blank=True, help_text="توضیح آزاد کاربر درباره برنامه آبیاری") + answers = serializers.JSONField(required=False, help_text="پاسخ های تکمیلی کاربر به سوالات مرحله قبل") + partial_plan = serializers.JSONField(required=False, help_text="داده استخراج شده مرحله قبل برای ادامه تکمیل") + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="شناسه مزرعه برای غنی سازی context") + + def validate(self, attrs): + message = (attrs.get("message") or "").strip() + answers = attrs.get("answers") + partial_plan = attrs.get("partial_plan") + if not message and not isinstance(answers, dict) and not isinstance(partial_plan, dict): + raise serializers.ValidationError( + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ) + return attrs + + +class PlanClarificationQuestionSerializer(serializers.Serializer): + id = serializers.CharField() + field = serializers.CharField() + question = serializers.CharField() + rationale = serializers.CharField(required=False, allow_blank=True) + + +class IrrigationPlanParserResponseSerializer(serializers.Serializer): + status = serializers.CharField() + status_fa = serializers.CharField() + summary = serializers.CharField() + missing_fields = serializers.ListField(child=serializers.CharField()) + questions = PlanClarificationQuestionSerializer(many=True) + collected_data = serializers.JSONField() + final_plan = serializers.JSONField(required=False, allow_null=True) diff --git a/irrigation/urls.py b/irrigation/urls.py index ff4d9e7..5fb4a5f 100644 --- a/irrigation/urls.py +++ b/irrigation/urls.py @@ -3,6 +3,7 @@ from django.urls import path from .views import ( IrrigationMethodDetailView, IrrigationMethodListCreateView, + IrrigationPlanParserView, IrrigationRecommendView, WaterStressView, ) @@ -11,5 +12,6 @@ urlpatterns = [ path("", IrrigationMethodListCreateView.as_view(), name="irrigation-list-create"), path("/", IrrigationMethodDetailView.as_view(), name="irrigation-detail"), path("recommend/", IrrigationRecommendView.as_view(), name="irrigation-recommend"), + path("plan-from-text/", IrrigationPlanParserView.as_view(), name="irrigation-plan-from-text"), path("water-stress/", WaterStressView.as_view(), name="water-stress"), ] diff --git a/irrigation/views.py b/irrigation/views.py index be27163..aa6c841 100644 --- a/irrigation/views.py +++ b/irrigation/views.py @@ -13,6 +13,8 @@ from config.openapi import ( from .models import IrrigationMethod from .serializers import ( IrrigationMethodSerializer, + IrrigationPlanParserRequestSerializer, + IrrigationPlanParserResponseSerializer, IrrigationRecommendRequestSerializer, WaterStressRequestSerializer, WaterStressResponseSerializer, @@ -37,6 +39,10 @@ IrrigationRecommendResponseSerializer = build_envelope_serializer( "IrrigationRecommendResponseSerializer", data_schema=None, ) +IrrigationPlanParserEnvelopeSerializer = build_envelope_serializer( + "IrrigationPlanParserEnvelopeSerializer", + IrrigationPlanParserResponseSerializer, +) WaterStressEnvelopeSerializer = build_envelope_serializer( "WaterStressEnvelopeSerializer", WaterStressResponseSerializer, @@ -219,6 +225,83 @@ class IrrigationRecommendView(APIView): ) +class IrrigationPlanParserView(APIView): + @extend_schema( + tags=["Irrigation Recommendation"], + summary="استخراج برنامه آبیاری از متن آزاد", + description=( + "توضیح متنی کاربر درباره برنامه آبیاری را می گیرد و آن را به JSON ساختاریافته تبدیل می کند. " + "اگر اطلاعات کافی نباشد، سوالات تکمیلی لازم را برمی گرداند تا در درخواست بعدی پاسخ داده شوند." + ), + request=IrrigationPlanParserRequestSerializer, + responses={ + 200: build_response( + IrrigationPlanParserEnvelopeSerializer, + "نتیجه استخراج یا سوالات تکمیلی برنامه آبیاری.", + ), + 400: build_response( + IrrigationValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 500: build_response( + IrrigationValidationErrorSerializer, + "خطا در پردازش برنامه آبیاری.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست کامل", + value={ + "message": "برای گوجه فرنگی با آبیاری قطره ای هر سه روز یک بار صبح زود 25 دقیقه آبیاری می کنم و حدود 18 لیتر برای هر بوته می دهم.", + "farm_uuid": "11111111-1111-1111-1111-111111111111", + }, + request_only=True, + ), + OpenApiExample( + "نمونه درخواست تکمیلی", + value={ + "partial_plan": { + "crop_name": "گوجه فرنگی", + "irrigation_method": "قطره ای", + }, + "answers": { + "water_amount_per_event": "18 لیتر برای هر بوته", + "preferred_time_of_day": "صبح زود", + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = IrrigationPlanParserRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + validated = serializer.validated_data + service = apps.get_app_config("irrigation").get_free_text_plan_parser_service() + try: + result = service.parse_plan( + message=validated.get("message", ""), + answers=validated.get("answers"), + partial_plan=validated.get("partial_plan"), + farm_uuid=validated.get("farm_uuid"), + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در پردازش برنامه آبیاری: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + {"code": 200, "msg": "موفق", "data": result}, + status=status.HTTP_200_OK, + ) + + class IrrigationMethodDetailView(APIView): """دریافت، ویرایش و حذف یک روش آبیاری.""" diff --git a/rag/services/__init__.py b/rag/services/__init__.py index 63aea07..8c28435 100644 --- a/rag/services/__init__.py +++ b/rag/services/__init__.py @@ -3,7 +3,9 @@ بدون API — قابل استفاده از سایر سرویس‌ها """ from .irrigation import get_irrigation_recommendation +from .irrigation_plan_parser import IrrigationPlanParserService from .fertilization import get_fertilization_recommendation +from .fertilization_plan_parser import FertilizationPlanParserService from .pest_disease import get_pest_disease_detection, get_pest_disease_risk from .soil_anomaly import get_soil_anomaly_insight from .water_need_prediction import get_water_need_prediction_insight @@ -11,7 +13,9 @@ from .yield_harvest import YieldHarvestRAGService __all__ = [ "get_irrigation_recommendation", + "IrrigationPlanParserService", "get_fertilization_recommendation", + "FertilizationPlanParserService", "get_pest_disease_detection", "get_pest_disease_risk", "get_soil_anomaly_insight", diff --git a/rag/services/fertilization_plan_parser.py b/rag/services/fertilization_plan_parser.py new file mode 100644 index 0000000..0a6c332 --- /dev/null +++ b/rag/services/fertilization_plan_parser.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import json +import logging +from typing import Any, Literal + +from pydantic import BaseModel, Field, ValidationError + +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config + +logger = logging.getLogger(__name__) + +SERVICE_ID = "fertilization_plan_parser" +KB_NAME = "fertilization_plan_parser" +CORE_FIELDS = [ + "crop_name", + "growth_stage", + "fertilizer_name", + "formula", + "amount", + "application_method", + "timing", + "interval_days", +] + +FERTILIZATION_PLAN_PROMPT = ( + "شما یک تحلیل گر برنامه کودهی هستی. " + "کاربر ممکن است برنامه کودهی را کامل یا ناقص توضیح دهد. " + "فقط JSON معتبر برگردان و هرگز متن خارج از JSON، markdown یا کلید اضافه تولید نکن. " + "اگر اطلاعات کافی بود status را completed بگذار و final_plan را تکمیل کن. " + "اگر اطلاعات ناقص بود status را needs_clarification بگذار، missing_fields را پر کن و در questions سوال های کوتاه و دقیق برگردان. " + "اگر چند کود در متن بود، همه را در applications لیست کن. " + "اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. " + "در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. " + "از حدس زدن مقدار، زمان یا روش مصرف خودداری کن. " + "Schema: " + "{" + '"status": "completed" | "needs_clarification", ' + '"summary": string, ' + '"missing_fields": [string], ' + '"questions": [{"id": string, "field": string, "question": string, "rationale": string}], ' + '"collected_data": {' + '"crop_name": string|null, ' + '"growth_stage": string|null, ' + '"objective": string|null, ' + '"applications": [' + "{" + '"fertilizer_name": string|null, ' + '"formula": string|null, ' + '"amount": string|null, ' + '"application_method": string|null, ' + '"timing": string|null, ' + '"interval_days": integer|null, ' + '"purpose": string|null' + "}" + "], " + '"notes": [string]' + "}, " + '"final_plan": {same shape as collected_data} | null' + "}." +) + + +class ClarificationQuestionSchema(BaseModel): + id: str + field: str + question: str + rationale: str = "" + + +class FertilizerApplicationSchema(BaseModel): + fertilizer_name: str | None = None + formula: str | None = None + amount: str | None = None + application_method: str | None = None + timing: str | None = None + interval_days: int | None = None + purpose: str | None = None + + +class FertilizationPlanSchema(BaseModel): + crop_name: str | None = None + growth_stage: str | None = None + objective: str | None = None + applications: list[FertilizerApplicationSchema] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + + +class FertilizationPlanParseResultSchema(BaseModel): + status: Literal["completed", "needs_clarification"] + summary: str + missing_fields: list[str] = Field(default_factory=list) + questions: list[ClarificationQuestionSchema] = Field(default_factory=list) + collected_data: FertilizationPlanSchema = Field(default_factory=FertilizationPlanSchema) + final_plan: FertilizationPlanSchema | None = None + + +class FertilizationPlanParserService: + def parse_plan( + self, + *, + message: str = "", + answers: dict[str, Any] | None = None, + partial_plan: dict[str, Any] | None = None, + farm_uuid: str | None = None, + ) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = self._build_service_client(cfg) + + normalized_message = (message or "").strip() + normalized_answers = answers if isinstance(answers, dict) else {} + normalized_partial = partial_plan if isinstance(partial_plan, dict) else {} + structured_context = { + "message": normalized_message, + "answers": normalized_answers, + "partial_plan": normalized_partial, + "required_core_fields": CORE_FIELDS, + "service": "fertilization_plan_parser", + } + + rag_query = self._build_retrieval_query( + message=normalized_message, + answers=normalized_answers, + ) + rag_context = build_rag_context( + query=rag_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + ) + system_prompt, messages = self._build_messages( + service=service, + cfg=cfg, + structured_context=structured_context, + rag_context=rag_context, + ) + + audit_log = None + if farm_uuid: + try: + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=rag_query, + system_prompt=system_prompt, + messages=messages, + ) + except Exception as exc: + logger.warning("Fertilization plan parser audit log creation failed for %s: %s", farm_uuid, exc) + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + response_format={"type": "json_object"}, + ) + raw = (response.choices[0].message.content or "").strip() + parsed = self._clean_json(raw) + validated = FertilizationPlanParseResultSchema.model_validate(parsed) + normalized = self._normalize_result(validated) + if audit_log is not None: + _complete_audit_log(audit_log, raw) + return normalized + except (ValidationError, ValueError, KeyError, IndexError) as exc: + logger.warning("Fertilization plan parser parsing failed: %s", exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return self._fallback_result( + message=normalized_message, + answers=normalized_answers, + partial_plan=normalized_partial, + ) + except Exception as exc: + logger.error("Fertilization plan parser failed: %s", exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return self._fallback_result( + message=normalized_message, + answers=normalized_answers, + partial_plan=normalized_partial, + ) + + def _build_service_client(self, cfg: RAGConfig): + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + return service, client, service.llm.model + + def _build_messages( + self, + *, + service: Any, + cfg: RAGConfig, + structured_context: dict[str, Any], + rag_context: str, + ) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(FERTILIZATION_PLAN_PROMPT) + system_parts.append( + "[structured_context]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + if rag_context: + system_parts.append(rag_context) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "برنامه کودهی را استخراج یا برای تکمیل آن سوال بپرس."}, + ] + return system_prompt, messages + + def _build_retrieval_query( + self, + *, + message: str, + answers: dict[str, Any], + ) -> str: + answer_lines = [f"{key}: {value}" for key, value in answers.items()] + parts = [part for part in [message, "\n".join(answer_lines)] if part] + return "\n".join(parts) or "استخراج برنامه کودهی از متن کاربر" + + def _normalize_result(self, validated: FertilizationPlanParseResultSchema) -> dict[str, Any]: + collected = validated.collected_data.model_dump() + final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None + missing_fields = list(dict.fromkeys(validated.missing_fields)) + computed_missing = self._find_missing_fields(final_plan or collected) + for field in computed_missing: + if field not in missing_fields: + missing_fields.append(field) + + can_complete = validated.status == "completed" and not missing_fields + + if can_complete: + final_plan = final_plan or collected + questions: list[dict[str, Any]] = [] + status_fa = "تکمیل شد" + else: + questions = [item.model_dump() for item in validated.questions] + if not questions and missing_fields: + questions = self._build_generic_questions(missing_fields) + final_plan = None + validated.status = "needs_clarification" + status_fa = "نیازمند پرسش تکمیلی" + + return { + "status": "completed" if can_complete else "needs_clarification", + "status_fa": status_fa, + "summary": validated.summary, + "missing_fields": missing_fields, + "questions": questions, + "collected_data": collected, + "final_plan": final_plan, + } + + def _fallback_result( + self, + *, + message: str, + answers: dict[str, Any], + partial_plan: dict[str, Any], + ) -> dict[str, Any]: + applications = partial_plan.get("applications") + if not isinstance(applications, list): + applications = [] + notes = list(partial_plan.get("notes") or []) + if message: + notes.append(f"متن اولیه کاربر: {message}") + if answers: + notes.append("پاسخ های تکمیلی کاربر دریافت شده است.") + + return { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه کودهی برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": CORE_FIELDS, + "questions": self._build_generic_questions(CORE_FIELDS), + "collected_data": { + "crop_name": partial_plan.get("crop_name"), + "growth_stage": partial_plan.get("growth_stage"), + "objective": partial_plan.get("objective"), + "applications": applications, + "notes": notes, + }, + "final_plan": None, + } + + def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]: + catalog = { + "crop_name": { + "id": "crop_name", + "field": "crop_name", + "question": "این برنامه کودهی برای کدام محصول است؟", + "rationale": "نام محصول برای ثبت برنامه لازم است.", + }, + "growth_stage": { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای تکمیل برنامه لازم است.", + }, + "fertilizer_name": { + "id": "fertilizer_name", + "field": "fertilizer_name", + "question": "نام کود یا ترکیب کودی چیست؟", + "rationale": "بدون نام کود نمی توان برنامه را نهایی کرد.", + }, + "formula": { + "id": "formula", + "field": "formula", + "question": "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20.", + "rationale": "ترکیب دقیق کود هنوز مشخص نشده است.", + }, + "amount": { + "id": "amount", + "field": "amount", + "question": "مقدار مصرف هر نوبت کود چقدر است؟", + "rationale": "دوز مصرف در متن مشخص نشده است.", + }, + "application_method": { + "id": "application_method", + "field": "application_method", + "question": "روش مصرف کود چیست؟ مثلا کودآبیاری، سرک یا محلول پاشی.", + "rationale": "روش اجرا هنوز معلوم نیست.", + }, + "timing": { + "id": "timing", + "field": "timing", + "question": "زمان مصرف کود چه موقع است؟ مثلا هر 10 روز یا بعد از آبیاری.", + "rationale": "زمان بندی برنامه نیاز به شفاف سازی دارد.", + }, + "interval_days": { + "id": "interval_days", + "field": "interval_days", + "question": "فاصله بین نوبت های مصرف کود چند روز است؟", + "rationale": "عدد فاصله بین نوبت ها برای JSON نهایی لازم است.", + }, + } + return [catalog[field] for field in missing_fields if field in catalog][:5] + + def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]: + missing: list[str] = [] + if not isinstance(plan, dict): + return CORE_FIELDS[:] + + if plan.get("crop_name") in (None, ""): + missing.append("crop_name") + if plan.get("growth_stage") in (None, ""): + missing.append("growth_stage") + + applications = plan.get("applications") + if not isinstance(applications, list) or not applications: + return missing + [ + field + for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"] + if field not in missing + ] + + first_application = applications[0] if isinstance(applications[0], dict) else {} + for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"]: + value = first_application.get(field) + if value is None or (isinstance(value, str) and not value.strip()): + missing.append(field) + return missing + + def _clean_json(self, raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + raise ValueError("Fertilization plan parser response was empty.") + parsed = json.loads(cleaned) + if not isinstance(parsed, dict): + raise ValueError("Fertilization plan parser response root must be an object.") + return parsed diff --git a/rag/services/irrigation_plan_parser.py b/rag/services/irrigation_plan_parser.py new file mode 100644 index 0000000..90e5349 --- /dev/null +++ b/rag/services/irrigation_plan_parser.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import json +import logging +from typing import Any, Literal + +from pydantic import BaseModel, Field, ValidationError + +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config + +logger = logging.getLogger(__name__) + +SERVICE_ID = "irrigation_plan_parser" +KB_NAME = "irrigation_plan_parser" +CORE_FIELDS = [ + "crop_name", + "growth_stage", + "irrigation_method", + "water_amount_per_event", + "duration_minutes", + "frequency_text", + "interval_days", + "preferred_time_of_day", + "start_date", + "target_area", +] + +IRRIGATION_PLAN_PROMPT = ( + "شما یک تحلیل گر برنامه آبیاری هستی. " + "کاربر ممکن است برنامه آبیاری را کامل یا ناقص توضیح دهد. " + "وظیفه شما این است که فقط JSON معتبر برگردانی و متن اضافه، markdown، توضیح بیرون از JSON یا کلید اضافه تولید نکنی. " + "اگر اطلاعات کافی بود status را completed بگذار و final_plan را کامل کن. " + "اگر اطلاعات کافی نبود status را needs_clarification بگذار، missing_fields را پر کن و 1 تا 5 سوال کوتاه و دقیق در questions برگردان. " + "اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. " + "در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. " + "از حدس زدن جزئیات برنامه خودداری کن. " + "اگر کاربر فقط بخشی از سوالات قبلی را جواب داد، داده های جدید را با partial_plan ادغام کن و فقط سوالات باقی مانده را بپرس. " + "Schema: " + "{" + '"status": "completed" | "needs_clarification", ' + '"summary": string, ' + '"missing_fields": [string], ' + '"questions": [{"id": string, "field": string, "question": string, "rationale": string}], ' + '"collected_data": {' + '"crop_name": string|null, ' + '"growth_stage": string|null, ' + '"irrigation_method": string|null, ' + '"water_amount_per_event": string|null, ' + '"duration_minutes": integer|null, ' + '"frequency_text": string|null, ' + '"interval_days": integer|null, ' + '"preferred_time_of_day": string|null, ' + '"start_date": string|null, ' + '"target_area": string|null, ' + '"trigger_conditions": [string], ' + '"notes": [string]' + "}, " + '"final_plan": {same shape as collected_data} | null' + "}." +) + + +class ClarificationQuestionSchema(BaseModel): + id: str + field: str + question: str + rationale: str = "" + + +class IrrigationPlanSchema(BaseModel): + crop_name: str | None = None + growth_stage: str | None = None + irrigation_method: str | None = None + water_amount_per_event: str | None = None + duration_minutes: int | None = None + frequency_text: str | None = None + interval_days: int | None = None + preferred_time_of_day: str | None = None + start_date: str | None = None + target_area: str | None = None + trigger_conditions: list[str] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + + +class IrrigationPlanParseResultSchema(BaseModel): + status: Literal["completed", "needs_clarification"] + summary: str + missing_fields: list[str] = Field(default_factory=list) + questions: list[ClarificationQuestionSchema] = Field(default_factory=list) + collected_data: IrrigationPlanSchema = Field(default_factory=IrrigationPlanSchema) + final_plan: IrrigationPlanSchema | None = None + + +class IrrigationPlanParserService: + def parse_plan( + self, + *, + message: str = "", + answers: dict[str, Any] | None = None, + partial_plan: dict[str, Any] | None = None, + farm_uuid: str | None = None, + ) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = self._build_service_client(cfg) + + normalized_message = (message or "").strip() + normalized_answers = answers if isinstance(answers, dict) else {} + normalized_partial = partial_plan if isinstance(partial_plan, dict) else {} + structured_context = { + "message": normalized_message, + "answers": normalized_answers, + "partial_plan": normalized_partial, + "required_core_fields": CORE_FIELDS, + "service": "irrigation_plan_parser", + } + + rag_query = self._build_retrieval_query( + message=normalized_message, + answers=normalized_answers, + ) + rag_context = build_rag_context( + query=rag_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + ) + system_prompt, messages = self._build_messages( + service=service, + cfg=cfg, + structured_context=structured_context, + rag_context=rag_context, + ) + + audit_log = None + if farm_uuid: + try: + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=rag_query, + system_prompt=system_prompt, + messages=messages, + ) + except Exception as exc: + logger.warning("Irrigation plan parser audit log creation failed for %s: %s", farm_uuid, exc) + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + response_format={"type": "json_object"}, + ) + raw = (response.choices[0].message.content or "").strip() + parsed = self._clean_json(raw) + validated = IrrigationPlanParseResultSchema.model_validate(parsed) + normalized = self._normalize_result(validated) + if audit_log is not None: + _complete_audit_log(audit_log, raw) + return normalized + except (ValidationError, ValueError, KeyError, IndexError) as exc: + logger.warning("Irrigation plan parser parsing failed: %s", exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return self._fallback_result( + message=normalized_message, + answers=normalized_answers, + partial_plan=normalized_partial, + ) + except Exception as exc: + logger.error("Irrigation plan parser failed: %s", exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return self._fallback_result( + message=normalized_message, + answers=normalized_answers, + partial_plan=normalized_partial, + ) + + def _build_service_client(self, cfg: RAGConfig): + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + return service, client, service.llm.model + + def _build_messages( + self, + *, + service: Any, + cfg: RAGConfig, + structured_context: dict[str, Any], + rag_context: str, + ) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(IRRIGATION_PLAN_PROMPT) + system_parts.append( + "[structured_context]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + if rag_context: + system_parts.append(rag_context) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "برنامه آبیاری را استخراج یا برای تکمیل آن سوال بپرس."}, + ] + return system_prompt, messages + + def _build_retrieval_query( + self, + *, + message: str, + answers: dict[str, Any], + ) -> str: + answer_lines = [f"{key}: {value}" for key, value in answers.items()] + parts = [part for part in [message, "\n".join(answer_lines)] if part] + return "\n".join(parts) or "استخراج برنامه آبیاری از متن کاربر" + + def _normalize_result(self, validated: IrrigationPlanParseResultSchema) -> dict[str, Any]: + collected = validated.collected_data.model_dump() + final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None + missing_fields = list(dict.fromkeys(validated.missing_fields)) + computed_missing = self._find_missing_fields(final_plan or collected) + for field in computed_missing: + if field not in missing_fields: + missing_fields.append(field) + + can_complete = validated.status == "completed" and not missing_fields + + if can_complete: + final_plan = final_plan or collected + questions: list[dict[str, Any]] = [] + status_fa = "تکمیل شد" + else: + questions = [item.model_dump() for item in validated.questions] + if not questions and missing_fields: + questions = self._build_generic_questions(missing_fields) + final_plan = None + validated.status = "needs_clarification" + status_fa = "نیازمند پرسش تکمیلی" + + return { + "status": "completed" if can_complete else "needs_clarification", + "status_fa": status_fa, + "summary": validated.summary, + "missing_fields": missing_fields, + "questions": questions, + "collected_data": collected, + "final_plan": final_plan, + } + + def _fallback_result( + self, + *, + message: str, + answers: dict[str, Any], + partial_plan: dict[str, Any], + ) -> dict[str, Any]: + merged = dict(partial_plan) + notes = list(merged.get("notes") or []) + if message: + notes.append(f"متن اولیه کاربر: {message}") + for key, value in answers.items(): + merged.setdefault(key, value) + + return { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه آبیاری برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": CORE_FIELDS, + "questions": self._build_generic_questions(CORE_FIELDS), + "collected_data": { + "crop_name": merged.get("crop_name"), + "growth_stage": merged.get("growth_stage"), + "irrigation_method": merged.get("irrigation_method"), + "water_amount_per_event": merged.get("water_amount_per_event"), + "duration_minutes": merged.get("duration_minutes"), + "frequency_text": merged.get("frequency_text"), + "interval_days": merged.get("interval_days"), + "preferred_time_of_day": merged.get("preferred_time_of_day"), + "start_date": merged.get("start_date"), + "target_area": merged.get("target_area"), + "trigger_conditions": merged.get("trigger_conditions") or [], + "notes": notes, + }, + "final_plan": None, + } + + def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]: + catalog = { + "crop_name": { + "id": "crop_name", + "field": "crop_name", + "question": "این برنامه آبیاری برای کدام محصول است؟", + "rationale": "نام محصول برای ثبت برنامه لازم است.", + }, + "growth_stage": { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای کامل شدن برنامه لازم است.", + }, + "irrigation_method": { + "id": "irrigation_method", + "field": "irrigation_method", + "question": "روش آبیاری چیست؟ مثلا قطره ای، بارانی یا غرقابی.", + "rationale": "روش اجرا روی شکل برنامه تاثیر دارد.", + }, + "water_amount_per_event": { + "id": "water_amount_per_event", + "field": "water_amount_per_event", + "question": "در هر نوبت آبیاری چه مقدار آب داده می شود؟", + "rationale": "حجم یا عمق آب هر نوبت مشخص نشده است.", + }, + "duration_minutes": { + "id": "duration_minutes", + "field": "duration_minutes", + "question": "مدت زمان هر نوبت آبیاری چند دقیقه است؟", + "rationale": "مدت اجرای هر نوبت هنوز مشخص نیست.", + }, + "frequency_text": { + "id": "frequency_text", + "field": "frequency_text", + "question": "فاصله یا تعداد نوبت های آبیاری چگونه است؟ مثلا هر 3 روز یک بار.", + "rationale": "الگوی تکرار آبیاری باید مشخص باشد.", + }, + "interval_days": { + "id": "interval_days", + "field": "interval_days", + "question": "فاصله بین دو آبیاری چند روز است؟", + "rationale": "عدد فاصله آبیاری برای JSON نهایی لازم است.", + }, + "preferred_time_of_day": { + "id": "preferred_time_of_day", + "field": "preferred_time_of_day", + "question": "بهترین زمان اجرای آبیاری چه موقع از روز است؟", + "rationale": "زمان اجرای برنامه هنوز معلوم نیست.", + }, + "start_date": { + "id": "start_date", + "field": "start_date", + "question": "این برنامه از چه تاریخی یا از چه زمانی باید شروع شود؟", + "rationale": "زمان شروع برنامه هنوز مشخص نشده است.", + }, + "target_area": { + "id": "target_area", + "field": "target_area", + "question": "این برنامه برای کل مزرعه است یا بخش/ناحیه خاصی از مزرعه؟", + "rationale": "محدوده اجرای برنامه باید مشخص باشد.", + }, + } + return [catalog[field] for field in missing_fields if field in catalog][:5] + + def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]: + missing: list[str] = [] + for field in CORE_FIELDS: + value = plan.get(field) + if value is None: + missing.append(field) + continue + if isinstance(value, str) and not value.strip(): + missing.append(field) + return missing + + def _clean_json(self, raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + raise ValueError("Irrigation plan parser response was empty.") + parsed = json.loads(cleaned) + if not isinstance(parsed, dict): + raise ValueError("Irrigation plan parser response root must be an object.") + return parsed