From 302124aa87678a14292c789f9feb939129d11ecc Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Fri, 24 Apr 2026 02:12:06 +0330 Subject: [PATCH] UPDATE --- .../fertilization/fertilization_knowledge.txt | 98 +++++++-- .../irrigation/irrigation_knowledge.txt | 27 ++- config/settings.py | 2 - config/tones/fertilization_tone.txt | 64 ++++-- config/tones/irrigation_tone.txt | 63 ++++-- config/urls.py | 1 - rag/chat.py | 34 +++- rag/services/fertilization.py | 28 ++- rag/services/irrigation.py | 28 ++- rag/tests/test_recommendation_api.py | 76 +++++++ rag/urls.py | 4 - rag/views.py | 192 ++++++------------ scripts/generate_mock_data.py | 23 --- tasks/__init__.py | 0 tasks/apps.py | 7 - tasks/celery_tasks.py | 15 -- tasks/urls.py | 8 - tasks/views.py | 128 ------------ 18 files changed, 392 insertions(+), 406 deletions(-) create mode 100644 rag/tests/test_recommendation_api.py delete mode 100644 tasks/__init__.py delete mode 100644 tasks/apps.py delete mode 100644 tasks/celery_tasks.py delete mode 100644 tasks/urls.py delete mode 100644 tasks/views.py diff --git a/config/knowledge_base/fertilization/fertilization_knowledge.txt b/config/knowledge_base/fertilization/fertilization_knowledge.txt index f731123..33b0221 100644 --- a/config/knowledge_base/fertilization/fertilization_knowledge.txt +++ b/config/knowledge_base/fertilization/fertilization_knowledge.txt @@ -1,24 +1,80 @@ -# دانش پایه کودهی +بخش دوم: راهنمای کوددهی گوجه‌فرنگی +گوجه‌فرنگی گیاهی پرمصرف است و به عناصر درشت‌مغذی (نیتروژن، فسفر، پتاسیم - NPK) و ریزمغذی‌ها (به ویژه کلسیم و منیزیم) نیاز دارد. -## نیتروژن (N) -نیتروژن برای رشد سبزینه و برگ‌ها ضروری است. کمبود آن باعث زردی برگ‌ها و کاهش رشد می‌شود. -منابع نیتروژن: اوره (46% N)، نیترات آمونیوم (34% N)، سولفات آمونیوم (21% N). -مصرف بیش از حد نیتروژن باعث رشد رویشی بیش از حد و کاهش مقاومت به بیماری می‌شود. +۱. مراحل مختلف کوددهی: -## فسفر (P) -فسفر برای ریشه‌زایی، گلدهی و میوه‌دهی مهم است. کمبود آن رشد ریشه را محدود می‌کند. -منابع فسفر: سوپرفسفات تریپل (46% P2O5)، DAP (18-46-0). -فسفر در خاک‌های قلیایی (pH > 7.5) به‌سختی جذب می‌شود. +قبل از کاشت (آماده‌سازی خاک): +افزودن کود دامی پوسیده یا ورمی‌کمپوست جهت بهبود بافت خاک. +استفاده از کودهای پایه فسفر بالا (برای ریشه‌زایی) و پتاسیم. +مرحله رشد رویشی (قبل از گل‌دهی): +نیاز به نیتروژن ( +𝑁 +N +) برای رشد برگ‌ها و ساقه‌ها بیشتر است. +احتیاط: نیتروژن بیش از حد باعث رشد علفی گیاه شده و گل‌دهی را به تاخیر می‌اندازد. استفاده از کود متعادل مانند +20 +− +20 +− +20 +20−20−20 + با غلظت مناسب توصیه می‌شود. +مرحله گل‌دهی و تشکیل میوه: +در این مرحله نیاز به نیتروژن کاهش و نیاز به فسفر ( +𝑃 +P +) و پتاسیم ( +𝐾 +K +) به شدت افزایش می‌یابد. پتاسیم برای کیفیت، اندازه و رنگ میوه ضروری است. +کودهای پتاس‌بالا (مانند +12 +− +12 +− +36 +12−12−36 +) مناسب هستند. +مرحله رشد و رسیدن میوه: +ادامه تغذیه با پتاسیم بالا. +محلول‌پاشی کلسیم در این مرحله بسیار حیاتی است. +۲. عناصر کلیدی و ریزمغذی‌های ضروری: -## پتاسیم (K) -پتاسیم مقاومت به خشکی، سرما و بیماری را افزایش می‌دهد. در کیفیت میوه نقش دارد. -منابع پتاسیم: سولفات پتاسیم (50% K2O)، کلرید پتاسیم (60% K2O). - -## pH و جذب عناصر -pH خاک بر جذب عناصر غذایی تأثیر مستقیم دارد. pH مناسب برای اغلب محصولات ۶ تا ۷ است. -در pH پایین (اسیدی): آهن و منگنز زیاد جذب می‌شوند ولی فسفر و کلسیم کم. -در pH بالا (قلیایی): آهن، روی و منگنز کم جذب می‌شوند. - -## EC و کودهی -EC بالا نشان‌دهنده شوری خاک است. قبل از کودهی باید EC بررسی شود. -اگر EC بالای ۴ dS/m باشد، کودهی باید با احتیاط انجام شود. +کلسیم ( +𝐶 +𝑎 +Ca +): کمبود کلسیم (یا عدم جذب آن به دلیل نوسانات آبیاری) باعث عارضه پوسیدگی گلگاه (سیاه شدن ته گوجه‌فرنگی) می‌شود. استفاده از کود نیترات کلسیم به صورت کودآبیاری یا محلول‌پاشی ضروری است. +منیزیم ( +𝑀 +𝑔 +Mg +): کمبود آن باعث زرد شدن برگ‌های پیر (در حالی که رگبرگ‌ها سبز می‌مانند) می‌شود. سولفات منیزیم برای رفع این مشکل مفید است. +آهن ( +𝐹 +𝑒 +Fe +) و روی ( +𝑍 +𝑛 +Zn +): برای شادابی و فتوسنتز گیاه لازم هستند و معمولاً به صورت محلول‌پاشی یا کودهای کلاته استفاده می‌شوند. +خلاصه نکات طلایی پایگاه دانش: +پوسیدگی گلگاه: ترکیبی از کمبود کلسیم و آبیاری نامنظم است. همیشه رطوبت خاک را یکنواخت نگه دارید و از کلسیم استفاده کنید. +ترک‌خوردگی میوه: ناشی از تغییر ناگهانی رطوبت خاک (مثلاً آبیاری سنگین بعد از یک دوره خشکی) است. +تنظیم +𝑝 +𝐻 +pH + خاک: گوجه‌فرنگی در خاکی با +𝑝 +𝐻 +pH + بین +6.0 +6.0 + تا +6.8 +6.8 + بهترین جذب مواد مغذی را دارد. +فاصله کوددهی کلسیم و فسفر: کودهای حاوی کلسیم را هرگز با کودهای حاوی فسفر یا سولفات همزمان مخلوط نکنید (رسوب می‌کنند). \ No newline at end of file diff --git a/config/knowledge_base/irrigation/irrigation_knowledge.txt b/config/knowledge_base/irrigation/irrigation_knowledge.txt index 96465ce..8b25390 100644 --- a/config/knowledge_base/irrigation/irrigation_knowledge.txt +++ b/config/knowledge_base/irrigation/irrigation_knowledge.txt @@ -1,18 +1,15 @@ -# دانش پایه آبیاری +بخش اول: راهنمای آبیاری گوجه‌فرنگی (آب‌دهی) +نیاز آبی گوجه‌فرنگی به مرحله رشد، نوع خاک و شرایط آب و هوایی بستگی دارد. مهم‌ترین اصل در آبیاری گوجه‌فرنگی نظم و یکنواختی است. -## تبخیر-تعرق مرجع (ET0) -ET0 نشان‌دهنده میزان آب مورد نیاز گیاه مرجع (چمن) در یک روز است. واحد آن mm/day است. -ET0 بالا یعنی هوا گرم و خشک است و گیاه آب بیشتری نیاز دارد. +۱. مراحل مختلف رشد و نیاز آبی: -## رابطه بارش و آبیاری -اگر بارش پیش‌بینی شده از ET0 بیشتر باشد، معمولاً آبیاری لازم نیست. -بارش مؤثر حدود ۷۰-۸۰ درصد بارش واقعی است (بخشی تبخیر و رواناب می‌شود). +مرحله نشاء و رشد اولیه: خاک باید مرطوب (نه غرقاب) نگه داشته شود تا ریشه‌ها به خوبی مستقر شوند. آبیاری سطحی و مکرر توصیه می‌شود. +مرحله گل‌دهی: تنش آبی در این مرحله باعث ریزش گل‌ها می‌شود. آبیاری باید منظم باشد. +مرحله تشکیل و بزرگ شدن میوه: بیشترین نیاز آبی در این مرحله است. آبیاری باید عمیق و منظم باشد تا از مشکلاتی مانند ترک‌خوردگی میوه و پوسیدگی گلگاه جلوگیری شود. +مرحله رسیدن میوه: با شروع رنگ گرفتن گوجه‌ها، آبیاری را کمی کاهش دهید. این کار باعث افزایش قند، بهبود طعم و جلوگیری از ترک خوردن میوه می‌شود. +۲. نکات کلیدی در آبیاری: -## رطوبت خاک -رطوبت مناسب خاک بسته به نوع خاک و محصول متفاوت است. -خاک رسی رطوبت بیشتری نگه می‌دارد. خاک شنی سریع‌تر خشک می‌شود. -آبیاری باید وقتی انجام شود که رطوبت خاک به حد بحرانی (MAD) رسیده باشد. - -## دمای هوا و آبیاری -در دماهای بالای ۳۵ درجه، تبخیر سطحی زیاد است و آبیاری صبح زود یا عصر توصیه می‌شود. -در دماهای زیر ۵ درجه، آبیاری ممکن است به ریشه آسیب بزند. +روش آبیاری: بهترین روش، آبیاری قطره‌ای است. آبیاری بارانی باعث خیس شدن برگ‌ها و افزایش خطر بیماری‌های قارچی می‌شود. +زمان آبیاری: بهترین زمان، صبح زود است تا گیاه در طول روز رطوبت کافی داشته باشد و برگ‌ها تا شب خشک شوند. +عمق آبیاری: آبیاری باید عمیق باشد تا ریشه‌ها به عمق خاک نفوذ کنند (حداقل ۱۵ تا ۲۰ سانتی‌متر). +مالچ‌پاشی: استفاده از مالچ (پلاستیک کشاورزی یا کاه و کلش) روی خاک، رطوبت را حفظ کرده و از تبخیر سریع آب جلوگیری می‌کند. \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index 606c787..ee57fa2 100644 --- a/config/settings.py +++ b/config/settings.py @@ -26,7 +26,6 @@ INSTALLED_APPS = [ "drf_spectacular_sidecar", "dashboard_data", "rag", - "tasks", "location_data", "farm_data.apps.FarmDataConfig", "weather", @@ -123,7 +122,6 @@ SPECTACULAR_SETTINGS = { {"name": "Dashboard Data", "description": "تجمیع داده‌های داشبورد مزرعه"}, {"name": "RAG Chat", "description": "چت هوشمند RAG"}, {"name": "RAG Recommendations", "description": "توصیه‌های آبیاری و کودهی مبتنی بر RAG"}, - {"name": "Tasks", "description": "مدیریت تسک‌های Celery"}, {"name": "Soil Data", "description": "داده‌های خاک (SoilGrids)"}, {"name": "Farm Data", "description": "داده‌های مزرعه و سنسورها"}, {"name": "Farm Parameters", "description": "پارامترهای سنسورهای مزرعه"}, diff --git a/config/tones/fertilization_tone.txt b/config/tones/fertilization_tone.txt index 3eabee0..77ae53c 100644 --- a/config/tones/fertilization_tone.txt +++ b/config/tones/fertilization_tone.txt @@ -1,24 +1,48 @@ -# لحن توصیه کودهی +You are an expert agricultural consultant AI specializing in plant nutrition and soil fertility. Your task is to analyze the provided Knowledge Base (Context) — which includes soil test results, crop growth stage, and current farm conditions — to provide actionable fertilization advice to farmers. -سبک پاسخ: -- تخصصی و دقیق: نسبت NPK، مقدار در هکتار، روش مصرف و فاصله زمانی را مشخص کن -- بر اساس داده‌های NPK خاک، pH، و نوع محصول -- فرمت خروجی حتماً JSON باشد و دقیقاً به شکل زیر: +### TONE & STYLE +- Be friendly, respectful, and easy to understand for a farmer. Avoid overly complex academic jargon unless explained. +- Speak directly to the farmer in Persian (Farsi). +- If mathematical expressions or chemical ratios are used (like $N-P-K$ formulas or percentages like $20\%$), ensure they are clear. + +### CORE RULES +1. MANDATORY FERTILIZER RECOMMENDATION: Your response MUST always include a clear fertilization recommendation. You must tell the farmer exactly what nutrient or fertilizer is needed based on the context. +2. METHOD AND TIMING: Every fertilization recommendation MUST specify the application method (e.g., foliar spray, fertigation, soil application) and the precise timing (e.g., early morning, avoiding high wind/temperature). +3. VALIDITY PERIOD: Specify the time window during which this fertilizer should be applied for maximum efficacy based on the crop's growth stage. +4. NO EXTRA TEXT: Your entire response MUST be ONLY a valid JSON object. Do not include any text or markdown formatting outside of the JSON structure itself. +5. JSON STRUCTURE: You must strictly adhere to the JSON structure provided below. + +### JSON OUTPUT STRUCTURE { - "plan": { - "npkRatio": "", - "amountPerHectare": "", - "applicationMethod": "", - "applicationInterval": "", - "reasoning": "" + "sections": [ + { + "type": "recommendation", + "title": "عنوان توصیه (مانند: برنامه محلول‌پاشی تقویتی)", + "icon": "leaf", + "content": "توضیح کوتاه توصیه", + "fertilizerType": "نوع کود پیشنهادی (مثلاً: کود $N-P-K$ با فرمول $20-20-20$ یا اوره)", + "amount": "میزان مصرف دقیق (مثلاً: ۳ در هزار یا ۵۰ کیلوگرم در هکتار)", + "applicationMethod": "روش مصرف (مثلاً: محلول‌پاشی روی برگ، همراه با آبیاری، چالکود)", + "timing": "بهترین زمان انجام کوددهی (مثلاً: ساعات خنک صبح، قبل از آبیاری)", + "validityPeriod": "محدوده زمانی مجاز برای انجام این کوددهی (مثلاً: تا پایان مرحله پنجه‌زنی)", + "expandableExplanation": "توضیحات تکمیلی و دلایل علمی برای رفع کمبود عناصر (اختیاری)" + }, + { + "type": "list", + "title": "نکات مهم ایمنی و اختلاط", + "icon": "list", + "items": [ + "نکته اول (مثلاً: از اختلاط با ترکیبات مسی خودداری شود)", + "نکته دوم" + ] + }, + { + "type": "warning", + "title": "هشدار کمبود عناصر یا سوختگی", + "icon": "alert-triangle", + "content": "متن هشدار (در صورت وجود علائم کمبود شدید یا خطر سوختگی گیاه)" } + ] } -- فقط JSON خروجی بده، بدون توضیح اضافی -- اگر سطح NPK خاک مناسب باشد، در reasoning ذکر کن و مقدار کمتر پیشنهاد بده -- هشدارهای مهم درباره مصرف بیش از حد کود را ذکر کن -- npkRatio بر اساس مرحله رشد گیاه و وضعیت خاک تعیین شود -- amountPerHectare بر اساس نوع خاک و نیاز گیاه -- applicationMethod بر اساس نوع کود و شرایط مزرعه -- applicationInterval بر اساس مرحله رشد و سرعت جذب -- reasoning باید شامل تحلیل EC خاک، pH، و مواد آلی باشد -- مقادیر را به انگلیسی و با واحد بنویس (مثل kg/ha) + +Note: The "sections" array MUST contain at least one object with "type": "recommendation" dedicated to fertilization. Valid icons for this topic include "leaf", "flask", "list", and "alert-triangle". Ensure the JSON is properly escaped and strictly valid. diff --git a/config/tones/irrigation_tone.txt b/config/tones/irrigation_tone.txt index cdac272..998e9e3 100644 --- a/config/tones/irrigation_tone.txt +++ b/config/tones/irrigation_tone.txt @@ -1,23 +1,46 @@ -# لحن توصیه آبیاری +You are an expert agricultural consultant AI. Your task is to analyze the provided Knowledge Base (Context), which includes scientific agricultural data and specific farm conditions, to provide actionable irrigation advice to farmers. -سبک پاسخ: -- مستقیم و عملیاتی: زمان، مدت، تعداد دفعات و روش آبیاری را مشخص کن -- بر اساس داده‌های هواشناسی (بارش، ET0، دما) و رطوبت خاک -- فرمت خروجی حتماً JSON باشد و دقیقاً به شکل زیر: +### TONE & STYLE +- Be friendly, respectful, and easy to understand for a farmer. Avoid overly complex academic jargon unless explained. +- Speak directly to the farmer in Persian (Farsi). + +### CORE RULES +1. MANDATORY IRRIGATION RECOMMENDATION: Your response MUST always include a clear irrigation recommendation. You cannot simply provide general information; you must tell the farmer what to do regarding irrigation. +2. VALIDITY PERIOD: Every irrigation recommendation MUST include its validity period (e.g., "This recommendation is valid for the next 3 days" or "Valid until the next rainfall"). You must specify this clearly so the farmer knows when to seek new advice. +3. NO EXTRA TEXT: Your entire response MUST be ONLY a valid JSON object. Do not include any greeting text or markdown formatting (like +```json) outside of the JSON structure itself. +4. JSON STRUCTURE: You must strictly adhere to the JSON structure provided below. + +### JSON OUTPUT STRUCTURE { - "plan": { - "frequencyPerWeek": , - "durationMinutes": , - "bestTimeOfDay": "", - "moistureLevel": , - "warning": "" - } + "sections": [ +{ +"type": "recommendation", +"title": "عنوان توصیه (مانند: برنامه آبیاری فوری)", +"icon": "droplet", +"content": "توضیح کوتاه توصیه", +"frequency": "دوره تناوب آبیاری (اختیاری)", +"amount": "میزان آب مورد نیاز (مثلاً بر اساس میلیمتر یا ساعت آبیاری)", +"timing": "بهترین زمان انجام آبیاری", +"validityPeriod": "مدت زمان اعتبار این توصیه (مثلاً: معتبر برای ۳ روز آینده با توجه به پیش‌بینی هوا)", +"expandableExplanation": "توضیحات تکمیلی و دلایل علمی برای کشاورز (اختیاری)" +}, +{ +"type": "list", +"title": "نکات مهم", +"icon": "list", +"items": [ +"نکته اول", +"نکته دوم" +] +}, +{ +"type": "warning", +"title": "هشدار تنش آبی یا شرایط خاص", +"icon": "alert-triangle", +"content": "متن هشدار" } -- فقط JSON خروجی بده، بدون توضیح اضافی -- اگر بارش پیش‌بینی شده باشد، آبیاری را به تعویق بینداز -- اگر رطوبت خاک کافی است، آبیاری لازم نیست -- هشدارها را در فیلد warning قرار بده -- مقادیر عددی را بر اساس نوع گیاه، روش آبیاری و مرحله رشد محاسبه کن -- bestTimeOfDay باید بر اساس شرایط آب و هوایی و فصل تعیین شود -- frequencyPerWeek بر اساس نیاز آبی گیاه و شرایط خاک -- durationMinutes بر اساس روش آبیاری و ظرفیت خاک + ] +} + +Note: The "sections" array MUST contain at least one object with "type": "recommendation" dedicated to irrigation. You can use "list" or "warning" types as needed based on the context. Ensure the JSON is properly escaped and strictly valid. diff --git a/config/urls.py b/config/urls.py index bf106d7..4e7b963 100644 --- a/config/urls.py +++ b/config/urls.py @@ -15,7 +15,6 @@ urlpatterns = [ # --- App APIs --- path("api/rag/", include("rag.urls")), path("api/dashboard-data/", include("dashboard_data.urls")), - path("api/tasks/", include("tasks.urls")), path("api/soil-data/", include("location_data.urls")), path("api/farm-data/", include("farm_data.urls")), path("api/plants/", include("plant.urls")), diff --git a/rag/chat.py b/rag/chat.py index 78288f6..b56644f 100644 --- a/rag/chat.py +++ b/rag/chat.py @@ -111,6 +111,29 @@ def _create_audit_log( return log +def _complete_audit_log(audit_log: "ChatAuditLog", response_text: str) -> None: + from .models import ChatAuditLog + + audit_log.response_text = response_text + audit_log.status = ChatAuditLog.STATUS_COMPLETED + audit_log.save(update_fields=["response_text", "status", "updated_at"]) + + +def _fail_audit_log( + audit_log: "ChatAuditLog", + error_message: str, + response_text: str = "", +) -> None: + from .models import ChatAuditLog + + audit_log.response_text = response_text + audit_log.error_message = error_message + audit_log.status = ChatAuditLog.STATUS_FAILED + audit_log.save( + update_fields=["response_text", "error_message", "status", "updated_at"] + ) + + def build_rag_context( query: str, sensor_uuid: str | None = None, @@ -267,9 +290,7 @@ def chat_rag_stream( yield content full_response = "".join(response_chunks) - audit_log.response_text = full_response - audit_log.status = ChatAuditLog.STATUS_COMPLETED - audit_log.save(update_fields=["response_text", "status", "updated_at"]) + _complete_audit_log(audit_log, full_response) logger.info( "Completed chat response id=%s farm_uuid=%s response_len=%s response=\n%s", audit_log.id, @@ -279,12 +300,7 @@ def chat_rag_stream( ) except Exception as exc: partial_response = "".join(response_chunks) - audit_log.response_text = partial_response - audit_log.error_message = str(exc) - audit_log.status = ChatAuditLog.STATUS_FAILED - audit_log.save( - update_fields=["response_text", "error_message", "status", "updated_at"] - ) + _fail_audit_log(audit_log, str(exc), partial_response) logger.exception( "Chat request failed id=%s service_id=%s farm_uuid=%s partial_response_len=%s", audit_log.id, diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py index 7f32406..8509a43 100644 --- a/rag/services/fertilization.py +++ b/rag/services/fertilization.py @@ -6,7 +6,13 @@ import json import logging from rag.api_provider import get_chat_client -from rag.chat import build_rag_context, _load_service_tone +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) from rag.config import load_rag_config, RAGConfig, get_service_config from rag.user_data import build_plant_text @@ -97,6 +103,14 @@ def get_fertilization_recommendation( {"role": "system", "content": system_content}, {"role": "user", "content": user_query}, ] + audit_log = _create_audit_log( + farm_uuid=sensor_uuid, + service_id=SERVICE_ID, + model=model, + query=user_query, + system_prompt=system_content, + messages=messages, + ) try: response = client.chat.completions.create( @@ -106,7 +120,7 @@ def get_fertilization_recommendation( raw = response.choices[0].message.content.strip() except Exception as exc: logger.error("Fertilization recommendation error for %s: %s", sensor_uuid, exc) - return { + result = { "fertilizer_needed": None, "fertilizer_type": None, "amount_kg_per_hectare": None, @@ -114,6 +128,12 @@ def get_fertilization_recommendation( "npk_status": None, "raw_response": None, } + _fail_audit_log( + audit_log, + str(exc), + response_text=json.dumps(result, ensure_ascii=False, default=str), + ) + return result try: cleaned = raw @@ -128,4 +148,8 @@ def get_fertilization_recommendation( } result["raw_response"] = raw + _complete_audit_log( + audit_log, + json.dumps(result, ensure_ascii=False, default=str), + ) return result diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py index aa7553d..7300667 100644 --- a/rag/services/irrigation.py +++ b/rag/services/irrigation.py @@ -8,7 +8,13 @@ import logging from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile, resolve_kc from farm_data.models import SensorData from rag.api_provider import get_chat_client -from rag.chat import build_rag_context, _load_service_tone +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) from rag.config import load_rag_config, RAGConfig, get_service_config from rag.user_data import build_plant_text, build_irrigation_method_text from weather.models import WeatherForecast @@ -149,6 +155,14 @@ def get_irrigation_recommendation( {"role": "system", "content": system_content}, {"role": "user", "content": user_query}, ] + audit_log = _create_audit_log( + farm_uuid=sensor_uuid, + service_id=SERVICE_ID, + model=model, + query=user_query, + system_prompt=system_content, + messages=messages, + ) try: response = client.chat.completions.create( @@ -158,13 +172,19 @@ def get_irrigation_recommendation( raw = response.choices[0].message.content.strip() except Exception as exc: logger.error("Irrigation recommendation error for %s: %s", sensor_uuid, exc) - return { + result = { "irrigation_needed": None, "amount_mm": None, "reason": f"خطا در دریافت توصیه: {exc}", "next_check_date": None, "raw_response": None, } + _fail_audit_log( + audit_log, + str(exc), + response_text=json.dumps(result, ensure_ascii=False, default=str), + ) + return result try: cleaned = raw @@ -184,4 +204,8 @@ def get_irrigation_recommendation( "crop_profile": crop_profile, "active_kc": active_kc, } + _complete_audit_log( + audit_log, + json.dumps(result, ensure_ascii=False, default=str), + ) return result diff --git a/rag/tests/test_recommendation_api.py b/rag/tests/test_recommendation_api.py new file mode 100644 index 0000000..60ad9a6 --- /dev/null +++ b/rag/tests/test_recommendation_api.py @@ -0,0 +1,76 @@ +from unittest.mock import patch + +from django.test import TestCase +from rest_framework.test import APIClient + + +class RagRecommendationApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("rag.services.irrigation.get_irrigation_recommendation") + def test_irrigation_recommendation_returns_direct_result(self, mock_get_irrigation_recommendation): + mock_get_irrigation_recommendation.return_value = { + "plan": { + "frequencyPerWeek": 3, + "durationMinutes": 25, + }, + "raw_response": "{\"plan\": {\"frequencyPerWeek\": 3, \"durationMinutes\": 25}}", + } + + response = self.client.post( + "/api/rag/recommend/irrigation/", + data={ + "sensor_uuid": "sensor-123", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "میوه‌دهی", + "irrigation_method_name": "قطره‌ای", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["plan"]["frequencyPerWeek"], 3) + mock_get_irrigation_recommendation.assert_called_once_with( + sensor_uuid="sensor-123", + plant_name="گوجه‌فرنگی", + growth_stage="میوه‌دهی", + irrigation_method_name="قطره‌ای", + query=None, + ) + + @patch("rag.services.fertilization.get_fertilization_recommendation") + def test_fertilization_recommendation_returns_direct_result(self, mock_get_fertilization_recommendation): + mock_get_fertilization_recommendation.return_value = { + "plan": { + "npkRatio": "20-20-20", + "amountPerHectare": "80 kg", + }, + "raw_response": "{\"plan\": {\"npkRatio\": \"20-20-20\", \"amountPerHectare\": \"80 kg\"}}", + } + + response = self.client.post( + "/api/rag/recommend/fertilization/", + data={ + "sensor_uuid": "sensor-456", + "plant_name": "گندم", + "growth_stage": "رویشی", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["plan"]["npkRatio"], "20-20-20") + mock_get_fertilization_recommendation.assert_called_once_with( + sensor_uuid="sensor-456", + plant_name="گندم", + growth_stage="رویشی", + query=None, + ) + + def test_removed_status_endpoints_return_404(self): + irrigation_response = self.client.get("/api/rag/recommend/irrigation/sample-task/status/") + fertilization_response = self.client.get("/api/rag/recommend/fertilization/sample-task/status/") + + self.assertEqual(irrigation_response.status_code, 404) + self.assertEqual(fertilization_response.status_code, 404) diff --git a/rag/urls.py b/rag/urls.py index 7bce258..3b64bcb 100644 --- a/rag/urls.py +++ b/rag/urls.py @@ -3,15 +3,11 @@ from django.urls import path from .views import ( ChatView, IrrigationRecommendationView, - IrrigationRecommendationStatusView, FertilizationRecommendationView, - FertilizationRecommendationStatusView, ) urlpatterns = [ path("chat/", ChatView.as_view()), path("recommend/irrigation/", IrrigationRecommendationView.as_view(), name="recommend-irrigation"), - path("recommend/irrigation//status/", IrrigationRecommendationStatusView.as_view(), name="recommend-irrigation-status"), path("recommend/fertilization/", FertilizationRecommendationView.as_view(), name="recommend-fertilization"), - path("recommend/fertilization//status/", FertilizationRecommendationStatusView.as_view(), name="recommend-fertilization-status"), ] diff --git a/rag/views.py b/rag/views.py index 6ef010d..4780adb 100644 --- a/rag/views.py +++ b/rag/views.py @@ -21,8 +21,6 @@ from config.openapi import ( build_envelope_serializer, build_message_response_serializer, build_response, - build_task_queue_data_serializer, - build_task_status_data_serializer, ) from .chat import chat_rag_stream @@ -33,27 +31,19 @@ logger = logging.getLogger(__name__) RagChatErrorResponseSerializer = build_message_response_serializer( "RagChatErrorResponseSerializer" ) -RagIrrigationQueueResponseSerializer = build_envelope_serializer( - "RagIrrigationQueueResponseSerializer", - build_task_queue_data_serializer("RagIrrigationQueueDataSerializer"), -) -RagIrrigationStatusResponseSerializer = build_envelope_serializer( - "RagIrrigationStatusResponseSerializer", - build_task_status_data_serializer("RagIrrigationStatusDataSerializer"), -) -RagFertilizationQueueResponseSerializer = build_envelope_serializer( - "RagFertilizationQueueResponseSerializer", - build_task_queue_data_serializer("RagFertilizationQueueDataSerializer"), -) -RagFertilizationStatusResponseSerializer = build_envelope_serializer( - "RagFertilizationStatusResponseSerializer", - build_task_status_data_serializer("RagFertilizationStatusDataSerializer"), -) RagValidationErrorResponseSerializer = build_envelope_serializer( "RagValidationErrorResponseSerializer", data_required=False, allow_null=True, ) +RagIrrigationResponseSerializer = build_envelope_serializer( + "RagIrrigationResponseSerializer", + drf_serializers.JSONField(), +) +RagFertilizationResponseSerializer = build_envelope_serializer( + "RagFertilizationResponseSerializer", + drf_serializers.JSONField(), +) class ChatView(APIView): @@ -157,17 +147,17 @@ class ChatView(APIView): class IrrigationRecommendationView(APIView): """ - توصیه آبیاری با Celery. + توصیه آبیاری به صورت مستقیم. POST با sensor_uuid، plant_name، growth_stage، irrigation_method_name. - تسک در صف قرار می‌گیرد و task_id برگشت داده می‌شود. + نتیجه همان لحظه برگشت داده می‌شود. """ @extend_schema( tags=["RAG Recommendations"], summary="درخواست توصیه آبیاری", description=( - "داده‌های سنسور، گیاه و روش آبیاری را دریافت کرده و یک تسک Celery " - "برای تولید توصیه آبیاری در صف قرار می‌دهد." + "داده‌های سنسور، گیاه و روش آبیاری را دریافت کرده و " + "توصیه آبیاری را به صورت مستقیم برمی‌گرداند." ), request=inline_serializer( name="IrrigationRecommendationRequest", @@ -180,14 +170,18 @@ class IrrigationRecommendationView(APIView): }, ), responses={ - 202: build_response( - RagIrrigationQueueResponseSerializer, - "تسک توصیه آبیاری در صف قرار گرفت.", + 200: build_response( + RagIrrigationResponseSerializer, + "توصیه آبیاری با موفقیت تولید شد.", ), 400: build_response( RagValidationErrorResponseSerializer, "پارامتر ورودی نامعتبر است.", ), + 500: build_response( + RagValidationErrorResponseSerializer, + "خطا در تولید توصیه آبیاری.", + ), }, examples=[ OpenApiExample( @@ -203,7 +197,7 @@ class IrrigationRecommendationView(APIView): ], ) def post(self, request: Request): - from rag.tasks import irrigation_recommendation_task + from rag.services.irrigation import get_irrigation_recommendation sensor_uuid = request.data.get("sensor_uuid") if not sensor_uuid: @@ -212,72 +206,40 @@ class IrrigationRecommendationView(APIView): status=status.HTTP_400_BAD_REQUEST, ) - task = irrigation_recommendation_task.delay( - sensor_uuid=str(sensor_uuid), - plant_name=request.data.get("plant_name"), - growth_stage=request.data.get("growth_stage"), - irrigation_method_name=request.data.get("irrigation_method_name"), - query=request.data.get("query"), - ) + try: + result = get_irrigation_recommendation( + sensor_uuid=str(sensor_uuid), + plant_name=request.data.get("plant_name"), + growth_stage=request.data.get("growth_stage"), + irrigation_method_name=request.data.get("irrigation_method_name"), + query=request.data.get("query"), + ) + except Exception: + logger.exception("Direct irrigation recommendation failed for sensor %s", sensor_uuid) + return Response( + {"code": 500, "msg": "خطا در تولید توصیه آبیاری.", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + return Response( - { - "code": 202, - "msg": "تسک توصیه آبیاری در صف قرار گرفت.", - "data": { - "task_id": task.id, - "status_url": f"/api/rag/recommend/irrigation/{task.id}/status/", - }, - }, - status=status.HTTP_202_ACCEPTED, - ) - - -class IrrigationRecommendationStatusView(APIView): - """وضعیت تسک توصیه آبیاری.""" - - @extend_schema( - tags=["RAG Recommendations"], - summary="وضعیت تسک توصیه آبیاری", - description="وضعیت تسک Celery توصیه آبیاری را برمی‌گرداند.", - responses={ - 200: build_response( - RagIrrigationStatusResponseSerializer, - "وضعیت فعلی تسک توصیه آبیاری.", - ), - }, - ) - def get(self, request, task_id): - from celery.result import AsyncResult - - result = AsyncResult(task_id) - data = {"task_id": task_id, "status": result.state} - if result.state == "PENDING": - data["message"] = "تسک در صف یا یافت نشد." - elif result.state == "PROGRESS": - data["progress"] = result.info - elif result.state == "SUCCESS": - data["result"] = result.result - elif result.state == "FAILURE": - data["error"] = str(result.result) - return Response( - {"code": 200, "msg": "success", "data": data}, + {"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK, ) class FertilizationRecommendationView(APIView): """ - توصیه کودهی با Celery. + توصیه کودهی به صورت مستقیم. POST با sensor_uuid، plant_name، growth_stage. - تسک در صف قرار می‌گیرد و task_id برگشت داده می‌شود. + نتیجه همان لحظه برگشت داده می‌شود. """ @extend_schema( tags=["RAG Recommendations"], summary="درخواست توصیه کودهی", description=( - "داده‌های سنسور و گیاه را دریافت کرده و یک تسک Celery " - "برای تولید توصیه کودهی در صف قرار می‌دهد." + "داده‌های سنسور و گیاه را دریافت کرده و " + "توصیه کودهی را به صورت مستقیم برمی‌گرداند." ), request=inline_serializer( name="FertilizationRecommendationRequest", @@ -289,14 +251,18 @@ class FertilizationRecommendationView(APIView): }, ), responses={ - 202: build_response( - RagFertilizationQueueResponseSerializer, - "تسک توصیه کودهی در صف قرار گرفت.", + 200: build_response( + RagFertilizationResponseSerializer, + "توصیه کودهی با موفقیت تولید شد.", ), 400: build_response( RagValidationErrorResponseSerializer, "پارامتر ورودی نامعتبر است.", ), + 500: build_response( + RagValidationErrorResponseSerializer, + "خطا در تولید توصیه کودهی.", + ), }, examples=[ OpenApiExample( @@ -311,7 +277,7 @@ class FertilizationRecommendationView(APIView): ], ) def post(self, request: Request): - from rag.tasks import fertilization_recommendation_task + from rag.services.fertilization import get_fertilization_recommendation sensor_uuid = request.data.get("sensor_uuid") if not sensor_uuid: @@ -320,53 +286,21 @@ class FertilizationRecommendationView(APIView): status=status.HTTP_400_BAD_REQUEST, ) - task = fertilization_recommendation_task.delay( - sensor_uuid=str(sensor_uuid), - plant_name=request.data.get("plant_name"), - growth_stage=request.data.get("growth_stage"), - query=request.data.get("query"), - ) + try: + result = get_fertilization_recommendation( + sensor_uuid=str(sensor_uuid), + plant_name=request.data.get("plant_name"), + growth_stage=request.data.get("growth_stage"), + query=request.data.get("query"), + ) + except Exception: + logger.exception("Direct fertilization recommendation failed for sensor %s", sensor_uuid) + return Response( + {"code": 500, "msg": "خطا در تولید توصیه کودهی.", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + return Response( - { - "code": 202, - "msg": "تسک توصیه کودهی در صف قرار گرفت.", - "data": { - "task_id": task.id, - "status_url": f"/api/rag/recommend/fertilization/{task.id}/status/", - }, - }, - status=status.HTTP_202_ACCEPTED, - ) - - -class FertilizationRecommendationStatusView(APIView): - """وضعیت تسک توصیه کودهی.""" - - @extend_schema( - tags=["RAG Recommendations"], - summary="وضعیت تسک توصیه کودهی", - description="وضعیت تسک Celery توصیه کودهی را برمی‌گرداند.", - responses={ - 200: build_response( - RagFertilizationStatusResponseSerializer, - "وضعیت فعلی تسک توصیه کودهی.", - ), - }, - ) - def get(self, request, task_id): - from celery.result import AsyncResult - - result = AsyncResult(task_id) - data = {"task_id": task_id, "status": result.state} - if result.state == "PENDING": - data["message"] = "تسک در صف یا یافت نشد." - elif result.state == "PROGRESS": - data["progress"] = result.info - elif result.state == "SUCCESS": - data["result"] = result.result - elif result.state == "FAILURE": - data["error"] = str(result.result) - return Response( - {"code": 200, "msg": "success", "data": data}, + {"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK, ) diff --git a/scripts/generate_mock_data.py b/scripts/generate_mock_data.py index b4a7ad6..cf39830 100644 --- a/scripts/generate_mock_data.py +++ b/scripts/generate_mock_data.py @@ -803,29 +803,6 @@ def main(): error_response(400, "داده نامعتبر.", {"code": ["This field is required."]}), ) - register( - "tasks/post_200.json", - "POST", - "/api/tasks/", - 200, - "Task trigger success", - ok_response({"task_id": "sample-task-123"}), - ) - for name, payload in { - "pending": task_pending("sample-task-123"), - "progress": task_progress("sample-task-123", TASK_PROGRESS), - "success": task_success("sample-task-123", "done"), - "failure": task_failure("sample-task-123", "Sample task failed."), - }.items(): - register( - f"tasks/status/get_200_{name}.json", - "GET", - "/api/tasks/{task_id}/status/", - 200, - f"Task status {name}", - payload, - ) - (MOCK_DIR / "index.json").write_text( json.dumps(index, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", diff --git a/tasks/__init__.py b/tasks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tasks/apps.py b/tasks/apps.py deleted file mode 100644 index b5dc464..0000000 --- a/tasks/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class TasksConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "tasks" - verbose_name = "Celery Tasks" diff --git a/tasks/celery_tasks.py b/tasks/celery_tasks.py deleted file mode 100644 index 1e0f755..0000000 --- a/tasks/celery_tasks.py +++ /dev/null @@ -1,15 +0,0 @@ -import time - -from config.celery import app - - -@app.task(bind=True) -def sample_task(self, duration: int = 1): - """تسک نمونه برای تست. duration تعداد ثانیه‌ای که تسک طول می‌کشه.""" - for i in range(duration): - self.update_state( - state="PROGRESS", - meta={"current": i + 1, "total": duration, "message": "در حال پردازش..."}, - ) - time.sleep(1) - return {"status": "completed", "duration": duration} diff --git a/tasks/urls.py b/tasks/urls.py deleted file mode 100644 index 67c59b6..0000000 --- a/tasks/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import path - -from .views import TaskStatusView, TaskTriggerView - -urlpatterns = [ - path("", TaskTriggerView.as_view(), name="task-trigger"), - path("/status/", TaskStatusView.as_view(), name="task-status"), -] diff --git a/tasks/views.py b/tasks/views.py deleted file mode 100644 index 0a7d889..0000000 --- a/tasks/views.py +++ /dev/null @@ -1,128 +0,0 @@ -from celery.result import AsyncResult - -from drf_spectacular.utils import ( - OpenApiExample, - OpenApiResponse, - extend_schema, - inline_serializer, -) -from rest_framework import status -from rest_framework import serializers as drf_serializers -from rest_framework.response import Response -from rest_framework.views import APIView - -from config.openapi import ( - build_envelope_serializer, - build_response, - build_task_status_data_serializer, -) - -from .celery_tasks import sample_task - - -TaskTriggerResponseSerializer = build_envelope_serializer( - "TaskTriggerResponseSerializer", - inline_serializer( - name="TaskTriggerPayloadSerializer", - fields={ - "task_id": drf_serializers.CharField(), - }, - ), -) -TaskStatusResponseSerializer = build_envelope_serializer( - "TaskStatusEnvelopeSerializer", - build_task_status_data_serializer("TaskStatusPayloadSerializer"), -) - - -class TaskTriggerView(APIView): - """ - ثبت و اجرای تسک. - POST با بدنه اختیاری: {"duration": 3} - مدت زمان تسک به ثانیه. - """ - - @extend_schema( - tags=["Tasks"], - summary="ثبت و اجرای تسک", - description="یک تسک نمونه را در صف Celery قرار می‌دهد.", - request=inline_serializer( - name="TaskTriggerRequest", - fields={ - "duration": drf_serializers.IntegerField( - required=False, default=1, help_text="مدت زمان تسک به ثانیه (۱–۶۰)" - ), - }, - ), - responses={ - 200: build_response( - TaskTriggerResponseSerializer, - "تسک نمونه با موفقیت در صف قرار گرفت.", - ), - }, - examples=[ - OpenApiExample( - "نمونه درخواست", - value={"duration": 3}, - request_only=True, - ), - OpenApiExample( - "نمونه پاسخ", - value={"code": 200, "msg": "success", "data": {"task_id": "abc123-def456"}}, - response_only=True, - ), - ], - ) - def post(self, request): - duration = request.data.get("duration", 1) - try: - duration = int(duration) - duration = max(1, min(duration, 60)) - except (TypeError, ValueError): - duration = 1 - result = sample_task.delay(duration) - return Response( - {"code": 200, "msg": "success", "data": {"task_id": result.id}}, - status=status.HTTP_200_OK, - ) - - -class TaskStatusView(APIView): - """ - وضعیت تسک بر اساس task_id. - GET /api/tasks//status/ - """ - - @extend_schema( - tags=["Tasks"], - summary="وضعیت تسک", - description="وضعیت یک تسک Celery را بر اساس task_id برمی‌گرداند.", - responses={ - 200: build_response( - TaskStatusResponseSerializer, - "وضعیت فعلی تسک Celery.", - ), - }, - examples=[ - OpenApiExample( - "تسک موفق", - value={"code": 200, "msg": "success", "data": {"task_id": "abc123", "status": "SUCCESS", "result": "done"}}, - response_only=True, - ), - ], - ) - def get(self, request, task_id): - result = AsyncResult(task_id) - state = result.state - data = {"task_id": task_id, "status": state} - if state == "PENDING": - data["message"] = "تسک در صف یا یافت نشد." - elif state == "PROGRESS": - data["progress"] = result.info - elif state == "SUCCESS": - data["result"] = result.result - elif state == "FAILURE": - data["error"] = str(result.result) - return Response( - {"code": 200, "msg": "success", "data": data}, - status=status.HTTP_200_OK, - )